ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

React开发者学Elm:用Model-View-Update重构状态管理思维

React开发者学Elm:用Model-View-Update重构状态管理思维 如果你已经写了很久 React一定遇到过这类问题组件越来越多useState散落在各个模块useEffect的依赖数组稍不注意就会引发重复请求或者死循环项目一复杂状态流转变得很难追踪调试起来只能到处打日志。这些问题并不是你写得不够小心而是 React 这种“灵活”的模式天然把大量约束交给了开发者自觉。今天这篇笔记围绕一份面向 React 开发者的 Elm 入门材料展开。Elm 是一门编译到 JavaScript 的纯函数式语言它的核心架构 Model-View-Update 深刻影响了 React 生态里的 Redux、Recoil 等状态管理方案。学习 Elm 不一定是为了用它替换 React而是为了理解前端状态管理的“更优解”到底长什么样。本文会从 El m 的基础概念、环境搭建、核心架构讲起配合计数器、待办清单两个完整示例最后总结 React 开发者最容易踩的坑和最佳实践。1. 为什么 React 开发者应该学习 Elm1.1 Elm 到底是什么Elm 是一门专门用于构建 Web 前端应用的纯函数式语言由 Evan Czaplicki 在 2012 年左右设计最终编译为 JavaScript 运行在浏览器中。Elm 的设计目标很激进没有运行时异常、没有undefined、没有隐式状态修改、没有副作用泄漏。它把这些保证不是靠规范或团队纪律而是直接放进语言和编译器层面。换句话说只要 Elm 代码能通过编译它就一定能正常运行不会出现“页面突然白屏”“某个变量不知道在哪里被改成了undefined”这类问题。这在 JavaScript 生态里几乎是不敢想象的。对于 React 开发者来说Elm 最熟悉的点是它的架构Model状态 ↓ View视图 ↓ Msg消息/动作 ↓ Update更新函数 ↓ 新的 Model这个模式被称为 TEAThe Elm Architecture也就是 Model-View-Update。React 生态中的 Redux 正是借鉴了这一思想单一数据源、纯函数更新、状态不可变。1.2 React 开发者的常见痛点随着 React 项目规模增长你大概率遇到过下面这些情况。第一状态散落。每个组件都有自己的useState跨组件共享状态时要引入 Context、Redux、Zustand 等方案状态来源一多排查数据问题时经常要上下文切换。第二副作用难控制。useEffect是所有 React 开发者绕不开的坎。依赖数组写少了闭包捕获旧值写多了接口被反复调用。React 官方后来给出了useEffectEvent、use等新方案但本质上副作用仍然是“组件渲染之后顺手做的事”缺少统一表达。第三状态可能被隐式修改。JavaScript 对象默认是可变的一个不小心把props里的对象直接改了就会触发各种诡异问题而且很难复现。Elm 则从根源上解决了这些问题整个应用只有一个 Model所有操作都通过 Msg 触发Update 是纯函数外部无法修改状态副作用被明确建模为 Cmd 和 Sub编译器会强制你处理它们。1.3 学习 Elm 对 React 开发者的实际收益这里要澄清一点学习 Elm 并不是为了让你立刻把 React 项目重写一遍。它的实际收益体现在三个层面。第一理解状态管理的本质。你写 Redux 时可能只是照着文档写 reducer但学了 Elm 之后你会理解为什么 state 要不可变、为什么 action 要做成普通对象、为什么 reducer 必须是纯函数。这些不是 Redux 的规则而是函数式架构的基本原则。第二提升代码设计能力。Elm 的类型系统非常严格要求你在编码前想清楚数据长什么样、有哪些状态分支。这种“先设计类型再写逻辑”的思维方式对写 React 时的 TypeScript 建模也有帮助。第三面试中更有深度。当在 React 面试中被问到“Redux 的原理是什么”“如何处理复杂状态”时如果你能结合 Elm 的 TEA 架构来回答会明显比背八股文更有说服力。2. Elm 的核心架构Model-View-Update2.1 一句话理解 Elm 的思维模型Elm 的整个应用可以浓缩为一个公式UI f(state)视图是状态的纯函数。同一个状态一定渲染出同一个界面。你不需要关心“上一次 DOM 是什么”“这次 diff 怎么算”你只需要维护状态和消息。在 React 中这个公式其实也存在UI f(state)但 React 允许你在渲染过程中调用其他函数、读取外部变量、修改 ref甚至直接操作 DOM。Elm 则把这些额外行为全部禁止了组件函数只接收 Model 并返回 Html没有别的选择。2.2 三大部分详解Elm 的 TEA 架构由三个核心部分组成。ModelModel 是应用的全部状态。它可以是整数、字符串、记录类似对象、列表或者这些类型的组合。在 React 中你可能同时维护useState的多个状态外加 Redux 的全局状态再外加路由状态在 Elm 中所有状态都汇总成一个 Model。type alias Model { count : Int , todos : List String , inputText : String }MsgMsg 是一种自定义类型它描述了应用中所有可能发生的事情。点击按钮、输入文字、接口返回、定时器触发每一个事件都对应一个 Msg。type Msg Increment | Decrement | UpdateInput String | AddTodo这里需要特别注意Msg 只是“描述”不是“操作”。它本身没有逻辑只是携带数据。UpdateUpdate 是一个纯函数接收当前 Model 和一条 Msg返回新的 Model。update : Msg - Model - Model update msg model case msg of Increment - { model | count model.count 1 } Decrement - { model | count model.count - 1 }这个函数不允许修改传入的model只能返回一个新的。Elm 的编译器会确保这一点。2.3 与 React Redux 的对应关系下面的表格可以帮你快速建立映射。概念React ReduxElm状态容器Redux StoreModel动作描述Action / Action CreatorMsg状态更新ReducerUpdate视图渲染React 组件View 函数副作用Redux Thunk / Saga / useEffectCmd / Sub类型约束TypeScript 可选编译器强制状态不可变靠规范语言强制可以看到Redux 几乎就是 TEA 架构在 JavaScript 世界的移植版本。如果你已经熟悉 Redux理解 Elm 会非常快反过来如果你先学了 Elm再回头看 Redux也会觉得很多设计不再难以理解。3. 环境准备与第一个 Elm 工程3.1 安装 Elm 编译器Elm 的安装方式很简单如果你本机已经有 Node.js 环境直接使用 npm 全局安装即可。npm install -g elmmacOS 用户也可以使用 Homebrewbrew install elm安装完成后验证版本elm --versionElm 当前主线版本为 0.19.x。需要提醒的是Elm 的版本迭代节奏与 JavaScript 生态不同它非常保守不会频繁发布破坏性更新。本文示例基于 0.19 语法如果你使用其他版本个别 API 可能略有差异但整体架构是一致的。3.2 创建项目并启动开发服务在任意目录初始化一个 Elm 项目mkdir elm-primer cd elm-primer elm initelm init会生成两个文件elm.json和src/Main.elm。elm.json是项目配置文件类似package.jsonsrc/Main.elm是入口源文件。接下来启动开发服务器elm reactor默认情况下elm reactor会在http://localhost:8000启动一个开发服务。在浏览器中打开该地址点击src/Main.elm就可以看到页面渲染结果。如果只想快速实验语法也可以使用 REPLelm repl在 REPL 中可以直接输入表达式并查看结果 1 2 3 String.length hello 53.3 项目结构说明一个简单的 Elm 项目结构大致如下elm-primer/ ├── elm.json └── src/ └── Main.elmElm 对文件规范有严格要求src目录下的文件名必须与模块名一致。也就是说模块Main必须写在src/Main.elm中模块Page.Home必须写在src/Page/Home.elm中。这种约束在初期可能觉得繁琐但项目变大后会非常舒服目录即模块模块即目录不需要额外记忆路径。4. Elm 基础语法速览4.1 函数定义与类型标注Elm 的函数定义非常简洁函数名和参数之间用空格分隔返回值在函数体末尾。add a b a b调用同样不需要括号result add 1 2虽然 Elm 可以自动推断类型但官方强烈建议显式写出类型标注这也是大型项目可维护性的基础。add : Int - Int - Int add a b a b读法是从右往左add接收一个Int返回一个函数该函数再接收一个Int最终返回Int。这背后是柯里化的思想在 React 中你平时写的高阶函数、bind也利用了类似思路。4.2 自定义类型与模式匹配自定义类型是 Elm 中最具表达力的特性之一。它相当于 TypeScript 的联合类型和可辨识联合的结合体但更强。type Msg Increment | Decrement | SetName String | SaveUser { name : String, age : Int }处理这些类型时使用case和模式匹配describe msg case msg of Increment - 增加 Decrement - 减少 SetName name - 设置名字 name SaveUser user - 保存用户 user.name在 React TypeScript 中你可能写过类似的代码type Msg | { type: increment } | { type: decrement } | { type: setName; name: string }; function update(msg: Msg) { switch (msg.type) { case increment: return ...; case decrement: return ...; case setName: return ...; } }但 TypeScript 的switch并不会强制你处理所有分支漏掉一个只会让代码多一个隐式 bug。Elm 的编译器会直接报错分支没有覆盖所有可能值必须补全才能通过编译。4.3 列表与记录Elm 的列表可以看作是不可变数组常用操作如下numbers [1, 2, 3, 4] -- 追加元素原列表不变 newNumbers 0 :: numbers -- [0, 1, 2, 3, 4] -- 拼接两个列表 more numbers [5, 6] -- 映射 doubled List.map (\x - x * 2) numbers -- 过滤 evens List.filter (\x - modBy 2 x 0) numbers -- 折叠 sum List.foldl (\x acc - x acc) 0 numbersElm 中的所有数据结构都是不可变的。numbers经过任何操作后都不会变化你会得到一个新的列表。这与你对 React 中setState传入新对象的理解一致但 Elm 是在语言层面保证的。记录就是 Elm 中的“对象”type alias User { name : String , age : Int } john : User john { name John, age 18 }更新记录时使用管道语法返回一个新记录olderJohn { john | age 19 }注意john本身没有变olderJohn是一个新记录。5. 完整实战计数器与待办清单5.1 计数器最小 MVU 示例先从一个计数器开始帮助你建立对 Elm 程序的整体认知。新建src/Main.elm写入以下完整代码module Main exposing (main) import Browser import Html exposing (Html, button, div, text) import Html.Events exposing (onClick) main Browser.sandbox { init init, update update, view view } type Msg Increment | Decrement init : Int init 0 update : Msg - Int - Int update msg model case msg of Increment - model 1 Decrement - model - 1 view : Int - Html Msg view model div [] [ button [ onClick Decrement ] [ text - ] , div [] [ text (String.fromInt model) ] , button [ onClick Increment ] [ text ] ]代码说明Browser.sandbox是 Elm 提供的一个简化入口适合没有副作用的纯交互应用。你只需要给它三个字段init初始状态、update更新函数、view渲染函数。type Msg定义了两种消息Increment和Decrement。update接收消息和当前状态返回新状态。view接收状态返回Html Msg它表示这个视图产生的消息类型是Msg。在elm reactor中打开src/Main.elm点击加号减号可以看到计数更新。整个流程就是一次 MVU 循环点击按钮触发 MsgUpdate 计算新 ModelView 重新渲染。5.2 待办清单带输入框和列表操作接下来做一个更接近真实业务场景的待办清单应用它包含输入框、添加、切换状态、删除等功能。module Main exposing (main) import Browser import Html exposing (Html, button, div, input, text) import Html.Attributes exposing (placeholder, value) import Html.Events exposing (onClick, onInput) main Browser.sandbox { init init, update update, view view } type alias Todo { id : Int , title : String , completed : Bool } type alias Model { todos : List Todo , draft : String } type Msg Add | UpdateDraft String | Toggle Int | Delete Int init : Model init { todos [], draft } addTodo : String - List Todo - List Todo addTodo title todos todos [ { id List.length todos 1, title title, completed False } ] update : Msg - Model - Model update msg model case msg of Add - if String.trim model.draft then model else { model | todos addTodo (String.trim model.draft) model.todos , draft } UpdateDraft newDraft - { model | draft newDraft } Toggle id - let updateTodo todo if todo.id id then { todo | completed not todo.completed } else todo in { model | todos List.map updateTodo model.todos } Delete id - { model | todos List.filter (\todo - todo.id / id) model.todos } todoItem : Todo - Html Msg todoItem todo div [] [ text todo.title , text ( - (if todo.completed then 已完成 else 进行中)) , button [ onClick (Toggle todo.id) ] [ text 切换 ] , button [ onClick (Delete todo.id) ] [ text 删除 ] ] view : Model - Html Msg view model div [] [ input [ placeholder 输入待办事项 , value model.draft , onInput UpdateDraft ] [] , button [ onClick Add ] [ text 添加 ] , div [] (List.map todoItem model.todos) ]这一段代码比计数器复杂不少但核心逻辑仍然是 TEA 架构。来对照 React 理解一下Model对应组件里的多个useState。我们没有把todos和draft分开而是放在同一个 Model 里。Msg对应事件回调。React 中你会直接写setTodos([...])Elm 中则只是Add。update对应多个useState回调逻辑的集合全部收敛到一个函数。view相当于 React 的 JSX 渲染函数但它完全没有内部状态也不会直接修改外部变量。在浏览器中测试一下输入文字、点击添加、切换完成状态、删除待办每个操作都只走同一条 MVU 数据流。这就是“单向数据流”在语言层面上的体现。6. 副作用处理Cmd、Sub 与 React 的差异6.1 React 中的副作用为什么难管理React 处理副作用的工具是useEffect。它本身非常灵活可以发送请求、操作 DOM、订阅事件、定时器等。但灵活性也带来了问题你无法从代码结构中看出这个组件发起了哪些副作用只能逐段阅读useEffect内容。更麻烦的是依赖数组。经典代码如下useEffect(() { fetchUser(userId).then(setUser); }, [userId]);如果某个依赖被遗漏可能会产生闭包陷阱如果依赖项变多每一次渲染都要判断是否重新执行。调试时你很难区分“是数据变了导致重新请求”还是“依赖数组写错了”。6.2 Elm 的副作用建模Cmd 和 Sub在 Elm 中副作用被划分为两类。第一类是发起副作用比如发起 HTTP 请求、生成随机数、写入本地存储对应Cmd。Cmd是一个不透明的命令值它描述了“我想做某件事”但不会立即执行。Update 函数可以返回( Model, Cmd Msg )的元组Elm 运行时会在合适的时机执行这些命令。第二类是接收外部消息比如 WebSocket 推送、定时器触发、浏览器尺寸变化对应Sub。Sub描述“我想订阅哪些外部事件”订阅关系在subscriptions函数中声明。用随机数发送请求作为例子Update 中返回命令update : Msg - Model - ( Model, Cmd Msg ) update msg model case msg of GenerateRandom - ( model, Random.generate NewNumber (Random.int 1 100) ) NewNumber num - ( { model | number num }, Cmd.none )注意这里的写法GenerateRandom 消息到达时Update 返回新 Model 和一条命令命令成功后会生成NewNumber消息再次进入 Update完成一次副作用闭环。对比 React 的useEffectElm 的优势是所有副作用都被类型系统追踪编译器能告诉你某个命令对应什么消息订阅了什么事件什么时候取消订阅这些都不会被遗漏。6.3 为什么 Elm 更适合复杂状态调试在 React 项目中调试状态通常需要借助 Redux DevTools、React DevTools 的 Time Travel或者在关键位置手动console.log。而 Elm 的架构天生支持可追溯调试。由于所有状态更新都经过update函数所有消息都是可序列化的数据Elm 可以自动记录每一次消息触发前后的 Model 变化。官方调试工具elm/time-travel能让你回到任意一步查看当时的状态和界面。这种体验不是通过调试库模拟的而是架构带来的天然属性。在 React 中即使使用 ReduxAction 也可能是 thunk、Promise、函数不一定可序列化追踪时依然有盲区。Elm 的 Msg 类型则没有这种问题。7. 常见问题与排查思路7.1 编译器报错分支未覆盖这是 Elm 初学者最容易遇到的错误。比如定义了type Msg Add | Delete但在update中只处理了Addupdate msg model case msg of Add - model编译器会提示Thiscaseis not exhaustive。解决方案很简单在case中补全Delete分支即可。这是编译器在帮你避免漏写逻辑而不是故意刁难。7.2 使用()而不是给变量赋值在 Elm 中定义别名或类型时使用没有问题但在模块定义、函数参数、记录更新等场景中容易混淆。一个典型错误是在type alias中间使用:分割字段时写错符号type alias User { name : String -- 这里必须是冒号 , age : Int }记录更新时用的是管道语法容易漏写花括号-- 正确 { user | age 20 } -- 错误 user | age 207.3elm reactor页面无法访问如果执行elm reactor后浏览器打开http://localhost:8000一直转圈或报错先确认命令是否在项目根目录执行其次确认是否已经生成elm.json。如果还没有初始化项目需要先运行elm init。另一个常见情况是端口被占用。可以换一个端口启动elm reactor --port90007.4 常见错误对照表问题现象常见原因解决思路编译报错case不完整自定义类型有多个构造器但只处理了部分补全缺失分支页面不更新在 update 中返回了同一个 model检查是否创建了新数据而不是原地修改输入框无法输入没有将onInput消息连接到 model.draft检查 view 中的value model.draft和 update 的UpdateDraftList.map类型报错函数签名与列表元素类型不匹配检查函数输入输出类型JSON 解码失败使用Json.Decode时字段名或类型不对使用Json.Decode.Pipeline逐字段调试8. 最佳实践与工程建议8.1 先设计类型再写逻辑Elm 项目里推荐的开发顺序是先定义Model、Msg、API 返回的数据类型再编写update和view。这和 TypeScript 项目中的“先定义 interface再写实现”类似但 Elm 的编译器会强制你把这个习惯做到位。例如在写待办清单之前先想清楚type alias Todo { id : Int , title : String , completed : Bool } type Msg Add | Delete Int | Toggle Int类型一旦确定业务逻辑的框架也就清晰了。8.2 保持 Update 函数纯净与依赖最小化在 Elm 中Update 函数必须是纯函数不能用Debug.log打印完就完事更不能在 Update 里发起 HTTP 请求。如果发现某个 Update 分支需要执行副作用应该让分支返回( Model, Cmd Msg )把副作用交给 Cmd 描述。另外update的每个分支尽量保持职责单一。如果一个分支里塞了太多逻辑考虑拆成辅助函数例如待办清单里的addTodo就是独立辅助函数。8.3 善用类型别名表达业务含义不要直接使用Int、String到处传递尽量使用类型别名和构造函数type alias UserId Int type alias UserName String这样函数签名可读性更强编译器也能提供更明确的错误提示。8.4 React 项目中的可迁移实践即使你不打算在真实项目中使用 Elm也可以把它的理念带回 React 开发中用useReducer替代散落的useState。对于复杂度较高的组件或模块把状态更新收敛到一个 reducer 中用 action 描述意图而不是直接调用多个 setState。让副作用更显式。参考 Cmd 的思想把请求、存储等操作封装成带明确输入输出的函数而不是在组件内部随意嵌套useEffect。用可辨识联合类型优化分支处理。TypeScript 的 discriminated union 可以在一定程度上模拟 Elm 的自定义类型配合 exhaustive switch 的检查让分支完备性得到保障。8.5 注意 Elm 生态的边界Elm 虽然优秀但生态与 JavaScript 相比小很多。很多成熟的 React 生态库在 Elm 中可能没有对应实现。在评估是否采用 Elm 时需要综合考虑团队学习成本、组件库丰富度、后端接口联调效率等因素。现实中更常见的路径是在 React 技术栈中引入 Elm 的思路或者在小模块中试点 Elm验证后再决定是否扩大应用范围。9. 总结与学习路线如果你想深入 Elm这里给出一个循序渐进的学习路径。第一步熟练掌握基础语法。包括函数定义、类型标注、自定义类型、case 表达式、List 操作、记录更新。可以用 REPL 练习目标是能流畅地写出包含类型标注的小函数。第二步独立完成 TEA 架构的简单应用。可以先从计数器开始然后是待办清单再尝试加一个 HTTP 请求或随机数功能。重点是理解 Model、Msg、Update、View 四个部分如何协作以及 Cmd 和 Sub 如何表达副作用。第三步学习 Elm 的 JSON 编解码。Elm 不能直接把 JSON 转成类型必须通过Json.Decode编写解码器。这一部分对前后端联调至关重要也是 Elm 类型安全的重要环节。第四步研究 Elm 的模块化设计、外部 JavaScript 互操作接口Port、以及性能优化方法。Elm 的虚拟 DOM 虽然不如 React 那样频繁出现在讨论中但它有自己的性能优势适合了解其做事方式。最后把从 Elm 中学到的架构思想带回 React 项目。你可以重温 Redux 的 reducer 写法以新的视角去看待useReducer和状态管理方案的设计取舍。如果你正在准备 React 面试能将 Elm 与 Redux 的异同讲得清楚本身就是一次很好的加分展示。希望这份面向 React 开发者的 Elm 入门笔记能帮你打开一扇新的大门。动手把上面的计数器例子跑起来你会很快感受到“编译器管着你”这种开发模式的安全感。
返回列表