ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

使用 Reagent 构建 TodoMVC:ClojureScript 与 React 的极简函数式结合实战

使用 Reagent 构建 TodoMVC:ClojureScript 与 React 的极简函数式结合实战 示例工程前端【免费下载链接】todomvcHelping you select a JavaScript framework - Todo apps for React.js, Angular, Vue and many more项目地址https://gitcode.com/gh_mirrors/to/todomvc点击查看免费下载Reagent 是 ClojureScript 与 React 之间的一座极简桥梁它允许开发者仅用普通的 ClojureScript 函数与数据结构配合类似 Hiccup 的语法来定义高效的 React 组件。本仓库 examples/reagent 目录下提供了完整的 Reagent TodoMVC 实现本文将以该示例为主体深入讲解如何用 Reagent 组织状态、拆分组件、处理路由与持久化并给出从源码到编译运行的完整实战方案读完即可在本地运行并动手改造一个函数式风格的 Todo 应用。Reagent 与 TodoMVC一个最小可运行的函数式 React 实践TodoMVC 是社区公认的前端框架对比基准同一份功能规格app-spec.md用不同技术栈各实现一遍。Reagent 版本的特殊价值在于它把 React 的组件化与 Clojure 的不可变数据 纯函数哲学直接融合你不再需要编写class、管理this、手动setState而是用函数返回 Hiccup 向量来描述 UI用reagent/atom承载响应式状态。整个应用源码位于 examples/reagent/src/cljs/todomvc编译产物为 examples/reagent/js/app.js入口页面为 examples/reagent/index.html。Reagent 的核心心智模型Reagent 的核心是函数即组件、数据即视图Hiccup 语法用 Clojure 向量描述 DOM。例如[:input.new-todo {:type text}]表示一个带new-todoclass 的 input 元素第一个元素是标签名可带 CSS 类选择器第二个元素是属性 map后续元素是子节点。reagent/atomreagent.core/atom是 Reagent 版的响应式状态容器。任何组件渲染时读取的reagent/atom一旦被reset!或swap!修改相关组件会自动重渲染这与 React 的useState/setState机制殊途同归但调用方式更接近 Clojure 原生的atom操作。纯函数组件组件就是一个普通 Clojure 函数入参是数据返回值是 Hiccup 向量天然可测试、可组合。项目结构与构建配置目录组织Reagent 示例的目录结构遵循源码ClojureScript与产物编译后的 JS分离的原则examples/reagent/ ├── index.html # 页面入口 ├── project.clj # Leiningen 项目配置依赖与 cljsbuild 构建 ├── package.json # npm 依赖仅 TodoMVC 通用样式与脚本 ├── js/ │ └── app.js # 编译生成的 JS 产物 └── src/ └── cljs/todomvc/ ├── core.cljs # 根组件与渲染入口 ├── session.cljs # 全局状态todos、计数器、筛选类型 ├── actions.cljs # 状态变更操作增删改、切换、清空 ├── helpers.cljs # 纯工具函数过滤、trim、ID 生成等 ├── routes.cljs # hash 路由与筛选状态联动 └── components/ # 10 个 UI 组件Leiningen 与 cljsbuild 配置examples/reagent/project.clj 是构建的核心它定义了依赖与两种编译 profile(defproject todomvc 0.1.0-SNAPSHOT :dependencies [[org.clojure/clojure 1.6.0] [org.clojure/clojurescript 0.0-3058 :scope provided] [reagent 0.5.0] [secretary 1.2.2] [alandipert/storage-atom 1.2.4]] :jvm-opts [--add-modules java.xml.bind] :min-lein-version 2.5.0 :plugins [[lein-cljsbuild 1.0.4]] :profiles {:dev {:cljsbuild {:builds [{:source-paths [src/cljs] :compiler {:output-to js/app.js}}]}} :prod {:cljsbuild {:builds [{:source-paths [src/cljs] :compiler {:output-to js/app.js :optimizations :advanced :elide-asserts true :pretty-print false}}]}}})各依赖与配置项的含义配置项作用reagent 0.5.0React 的 ClojureScript 绑定本示例所用版本secretary 1.2.2基于 hash 的轻量路由库用于/active、/completed等筛选 URLalandipert/storage-atom 1.2.4把reagent/atom持久化到localStorage的库:source-paths [src/cljs]告诉编译器从哪里找 ClojureScript 源码:output-to js/app.js编译产物输出路径:optimizations :advancedprod生产模式启用 Google Closure 高级优化变量重命名、死代码消除体积更小:elide-asserts trueprod剔除断言进一步减体积:pretty-print falseprod输出压缩后的单行 JS注意:dev与:prod两个 profile 的输出路径一致均为js/app.js区别在于是否启用高级优化这也对应了 readme 中优化版本的编译说明。npm 依赖examples/reagent/package.json 只声明了两个运行时依赖均为 TodoMVC 官方通用资源不包含任何框架代码{ private: true, dependencies: { todomvc-app-css: ^2.1.0, todomvc-common: ^1.0.1 } }它们在 examples/reagent/index.html 中被引用link relstylesheet hrefnode_modules/todomvc-common/base.css link relstylesheet hrefnode_modules/todomvc-app-css/index.css ... script srcnode_modules/todomvc-common/base.js/script script srcjs/app.js/script script todomvc.core.run(); /script页面结构非常简单div idapp作为 React 挂载点todomvc.core.run()在页面加载后启动应用。注意data-frameworkreagent属性是 TodoMVC 通用脚本用于识别当前框架的约定。应用入口与根组件从 atom 到视图的响应式链路渲染入口examples/reagent/src/cljs/todomvc/core.cljs 是应用的心脏它把全部组件组装成一棵 Hiccup 树并通过reagent/render挂载到 DOM(defn ^:export run [] (reagent/render [todo-app] (js/document.getElementById app)))^:export元数据确保run函数在 Closure 高级优化下也不会被重命名从而能被 examples/reagent/index.html 中的todomvc.core.run()直接调用。根组件 todo-app(defn todo-app [] [:div [:section.todoapp [:header.header [title/component] [todo-input/component]] [:div {:style {:display (helpers/display-elem (helpers/todos-any? session/todos))}} [:section.main [todos-toggle/component] [todos-list/component (helpers/todos-all session/todos)]] [:footer.footer [todos-count/component] [todos-filters/component] [todos-clear/component] ]]] [footer/component]])这里展示了 Reagent 的组件复用语法[title/component]这种向量即是对另一个组件的调用等价于在 React 中写Title /。根组件通过读取session/todos一个reagent/atom来驱动整个 UI任何对它的修改都会自动触发重渲染——整个应用的状态流是单向且可预测的session中的 atom → 组件渲染时读取 → 用户交互调用actions→swap!修改 atom → 自动重渲染。为什么组件可以自动更新因为reagent/atom在渲染期间被读取时Reagent 会建立渲染依赖追踪当这个 atom 之后被修改只有那些在渲染过程中读取过它的组件才会被标记并重渲染。这是 Reagent 与 React 传统setState模型的最大区别——你不需要手动声明这个状态影响哪些组件数据流关系完全由渲染代码的读取行为自动推导这也正是 readme 中所说minimalistic interface极简接口的深层含义。全局状态与 localStorage 持久化examples/reagent/src/cljs/todomvc/session.cljs 集中定义了应用的三个全局状态(reset! alandiper.storage-atom/storage-delay 0) (def todos (local-storage (reagent/atom (sorted-map)) :todos-reagent)) ;; will look like {id {:id _ :title _ :completed _ }} (def todos-counter (local-storage (reagent/atom 0) :todos-counter-reagent)) ;; will inc for each new todo (def todos-display-type (reagent/atom :all)) ;; the options are :all, :active, :completed三个状态的设计要点todos一个sorted-mapkey 为 todo 的 idvalue 为{:id _ :title _ :completed _}的 map。用有序 map 而非向量是为了让新插入的 todo 天然排在末尾渲染顺序稳定。todos-counter自增计数器为每个新 todo 生成唯一 id。它与todos一起通过local-storage包装自动持久化到浏览器 localStorage对应的存储键分别是:todos-reagent和:todos-counter-reagent——刷新页面后数据不丢失。todos-display-type当前筛选类型取值:all、:active、:completed。它不需要持久化用普通reagent/atom即可。其中(reset! alandipert.storage-atom/storage-delay 0)把写入 localStorage 的延迟设为 0让每次状态变更立即落盘。状态变更层actions 与纯函数 helpersactions对 atom 的原子操作examples/reagent/src/cljs/todomvc/actions.cljs 是控制器层所有 UI 交互最终都收敛到这里用swap!对session/todos做不可变更新(defn add-todo [title default] (let [id (helpers/create-todo-id) trimmed-title (helpers/trim-title title)] (swap! session/todos assoc id {:id id :title trimmed-title :completed false}) (reset! title default))) (defn toggle-todo [id] (swap! session/todos update-in [id :completed] not)) (defn toggle-all-todos [bool] (doseq [todo (helpers/todos-all session/todos)] (swap! session/todos assoc-in [(:id todo) :completed] (not bool)))) (defn delete-todo [id] (swap! session/todos dissoc id)) (defn save-todo [id title editing] (let [trimmed-title (helpers/trim-title title)] (if-not (empty? trimmed-title) (swap! session/todos assoc-in [id :title] trimmed-title) (delete-todo id)) (reset! editing false))) (defn clear-completed-todos [todos] (doseq [todo (helpers/todos-completed todos)] (delete-todo (:id todo))))几个值得注意的实现细节add-todo在写入前会trim-title去除首尾空格且把输入框标题重置为default空字符串save-todo的规则是编辑后若标题为空则删除该 todo否则更新标题并退出编辑态所有操作都通过swap! 纯函数完成不产生任何可变对象符合 Clojure 不可变数据的最佳实践。helpers纯函数工具箱examples/reagent/src/cljs/todomvc/helpers.cljs 提供无副作用的工具函数(def enter-key 13) (def escape-key 27) (defn trim-title [title] (clojure.string/trim title)) (defn display-elem [bool] (if bool inline none)) (defn display-item [bool] (if bool list-item none)) (defn todo-display-filter [completed display-type] (case display-type :completed completed :active (not completed) true)) (defn create-todo-id [] (swap! session/todos-counter inc)) (defn todos-all [todos] (vals todos)) (defn todos-active [todos] (filter #(not (:completed %)) (todos-all todos))) (defn todos-completed [todos] (filter :completed (todos-all todos))) (defn todos-any? [todos] (pos? (count (todos-all todos)))) (defn todos-any-completed? [todos] (pos? (count (todos-completed todos)))) (defn todos-all-completed? [todos] ( (count (todos-all todos)) (count (todos-completed todos))))关键点todo-display-filter把筛选类型 完成状态映射为是否显示display-item/display-elem将其转为 CSS 的display值键盘常量enter-key(13) 与escape-key(27) 供输入组件复用。从源码结构看TodoMVC 规格中completed 计数/全部完成等逻辑都由todos-any-completed?、todos-all-completed?这类纯函数承担便于单测。组件拆分10 个组件的分工examples/reagent/src/cljs/todomvc/components 下共 10 个组件与 readme 中的 Components Summary 一一对应。核心组件的实现方式如下。列表与条目todo-list todo-itemexamples/reagent/src/cljs/todomvc/components/todos_list.cljs 用for生成列表并借助^{:key (:id todo)}元数据为每个子项提供 React 所需的稳定 key等价于 React 的keyprop(defn component [todos] [:ul.todo-list (for [todo todos] ^{:key (:id todo)} [todo-item/component todo])])examples/reagent/src/cljs/todomvc/components/todo_item.cljs 是单条 todo 的完整交互单元(defn component [todo] (let [editing (reagent/atom false)] (fn [{:keys [id title completed] :as todo}] [:li {:class (todo-item-class completed editing) :style {:display (helpers/display-item (helpers/todo-display-filter completed session/todos-display-type))}} [:div.view [todo-checkbox id completed] [:label {:on-double-click #(reset! editing true)} title] [:button.destroy {:on-click #(actions/delete-todo id)}]] [todo-edit/component todo editing]])))这里展示了一个重要的 Reagent 模式外层函数只执行一次创建局部 atomediting内层闭包函数才是真正的渲染函数接收最新 props。这样editing状态在组件实例间相互隔离同时每次重渲染都拿到最新todo。复选框、双击编辑、删除按钮的事件分别绑定到actions/toggle-todo、本地editing状态和actions/delete-todo。输入框与编辑框基于 create-class 的生命周期用法examples/reagent/src/cljs/todomvc/components/todo_input.cljs 实现新增输入框。由于需要组件挂载后自动聚焦这一 React 生命周期能力它使用reagent/create-class包装(defn component-did-mount [x] (.focus (reagent/dom-node x))) (defn component [] (reagent/create-class {:reagent-render component-render :component-did-mount component-did-mount}))reagent/dom-node返回组件对应的真实 DOM 节点reagent/create-class允许在保持函数式渲染的同时挂接 React 生命周期钩子。component-render内部用本地reagent/atom管理受控输入框的 value回车键helpers/enter-key13触发actions/add-todo(defn on-key-down [k title default] (let [key-pressed (.-which k)] (condp key-pressed helpers/enter-key (actions/add-todo title default) nil)))examples/reagent/src/cljs/todomvc/components/todo_edit.cljs 是编辑框同样用create-class但钩子是component-did-update每次更新后聚焦便于连续编辑。它的键盘逻辑覆盖了回车保存与 Esc 取消(defn on-key-down [k id title default editing] (let [key-pressed (.-which k)] (condp key-pressed helpers/enter-key (actions/save-todo id title editing) helpers/escape-key (do (reset! title default) (reset! editing false)) nil)))on-blur也会触发actions/save-todo与回车保存共用同一条保存路径这与 TodoMVC 规格一致。其余展示组件todos_toggle.cljs全选/取消全选todos_count.cljs剩余未完成计数todos_filters.cljsAll / Active / Completed 筛选链接todos_clear.cljs清除已完成title.cljs 与 footer.cljs静态标题与页脚信息含双击编辑提示与致谢。组件总览图如下可对照 readme 的 Components Summary 部分查看目录内各组件的对应关系。路由与筛选联动examples/reagent/src/cljs/todomvc/routes.cljs 使用 secretary Google Closure 的goog.History实现 hash 路由(secretary/set-config! :prefix #) (secretary/defroute / [] (reset! session/todos-display-type :all)) (secretary/defroute /active [] (reset! session/todos-display-type :active)) (secretary/defroute /completed [] (reset! session/todos-display-type :completed)) (doto (History.) (events/listen EventType/NAVIGATE (fn [event] (secretary/dispatch! (.-token event)))) (.setEnabled true))其工作机制是goog.History监听浏览器 hash 变化NAVIGATE事件把 token如active交给secretary/dispatch!匹配路由路由处理器把session/todos-display-type重置为对应关键字。由于todo-item渲染时读取了session/todos-display-type见 todo_item.cljs 中的todo-display-filter调用筛选类型的改变会立刻触发列表重渲染。于是#/active、#/completed、#/三个 URL 直接对应三种视图刷新与分享 URL 都能保持筛选状态——这是 TodoMVC 规格要求的行为。运行与构建readme 中给出的运行流程如下前提本机已安装 Leiningen并在 examples/reagent 目录下操作$ lein cljsbuild once该命令读取project.clj中:devprofile 的配置把 examples/reagent/src/cljs 下的源码编译输出为 examples/reagent/js/app.js。注意开发模式下未指定:optimizations默认采用无优化/简单优化适合调试。如需编译优化版本readme 明确指出需要两步$ lein clean $ lein with-profile prod cljsbuild oncelein clean先清空上一次的编译产物避免增量编译残留随后用:prodprofile 执行cljsbuild once此时启用:optimizations :advancedClosure 高级优化、:elide-asserts true、:pretty-print false得到体积更小的生产包。依赖说明页面还引用了node_modules下的 TodoMVC 通用样式与脚本因此首次运行前还需在 examples/reagent 目录执行npm install安装 examples/reagent/package.json 中声明的todomvc-app-css与todomvc-common。编译完成后直接在浏览器打开 examples/reagent/index.html 即可看到应用由于页面引用相对路径资源建议通过本地静态服务器或仓库测试服务器 server.js 访问。小结从本示例可以学到什么通过阅读 examples/reagent/readme.md 并对照源码可以提炼出四条可复用的 Reagent 实践状态分层全局响应式状态集中在 session.cljs变更操作收敛到 actions.cljs纯计算放进 helpers.cljs三个模块职责清晰、便于测试持久化零成本用alandipert/storage-atom的local-storage包装reagent/atom一行代码即获得 localStorage 持久化能力函数式组件 按需生命周期常规渲染用纯函数需要聚焦等生命周期行为时再用reagent/create-class挂接钩子兼顾简洁与灵活路由与状态联动secretary goog.History把 URL hash 直接映射到筛选状态保持地址栏即应用状态。对于想要评估函数式语言能否优雅地驱动 React的开发者这个示例给出了一个完整、可运行、符合 TodoMVC 官方规格的答案全部业务逻辑只用纯函数与不可变数据实现无需任何 class 与 this这正是 Reagent minimalistic interface 设计目标的直接体现。参考仓库根目录的 app-spec.md 可以逐条核对本示例与 TodoMVC 官方规格的符合程度。赞分享示例工程前端【免费下载链接】todomvcHelping you select a JavaScript framework - Todo apps for React.js, Angular, Vue and many more项目地址https://gitcode.com/gh_mirrors/to/todomvc点击查看免费下载相关推荐100行代码如何与键盘完美同步动画UIViewController-KeyboardAnimation源码实现原理深度剖析100行代码如何与键盘完美同步动画UIViewController KeyboardAnimation源码实现原理深度剖析 UIViewController移动开发使用 useTimeoutFn 构建可控制的 setTimeoutAiri 项目 VueUse 定时器组合式函数实战使用 useTimeoutFn 构建可控制的 setTimeoutAiri 项目 VueUse 定时器组合式函数实战 useTimeoutFn 是 VueUsAI 应用人工智能大模型数字人AI Agent语音前端后端桌面应用移动开发即时通讯3D渲染终极指南SwiftTips函数式编程中组合函数与高阶函数的实战应用终极指南SwiftTips函数式编程中组合函数与高阶函数的实战应用 SwiftTips是一个由John Sundell创建的Swift技巧与窍门集合专注于分创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进