十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

React开发者如何借Elm架构重构状态管理

React开发者如何借Elm架构重构状态管理 React 开发者在经历了多个业务项目之后通常会对组件状态管理产生同一种感觉明明只是一个弹窗、一个表单或一张列表数据流却越来越难跟踪。useState可以改状态useReducer可以收敛逻辑但代码能不能运行、状态会不会失控大多依赖团队自律。如果把同一个问题交给 Elm结果会非常不同。Elm 是一门运行在 JavaScript 生态里的函数式语言体系不大却把单向数据流、不可变数据、受控副作用做成了编译器强制规则。这篇内容专门写给 React 开发者目标是带你用一个最小案例读懂 Elm 的三板斧Model、Update、View也就是著名的 Elm Architecture然后把它和 React 一一对应起来最后再把值得借鉴的工程经验带回 React 项目。1. 先建立一个思想坐标Elm 不是 React 的替代品而是 React 问题的“放大镜”Elm 经常被描述成“让人上瘾的函数式前端语言”但这句话对 React 开发者没有太多指导意义。更准确的理解是Elm 把 React 社区一直在提倡、但始终没有在语言层面强制执行的约束全部做成了编译规则。学习 Elm 不是在学一个新框架而是在理解一组更严格的状态管理约定。1.1 Elm 是什么它解决什么核心问题Elm 是一门编译为 JavaScript 的纯函数式语言。它拥有类似 Haskell 的类型系统但语法比 Haskell 柔和得多也不需要读者先掌握范畴论。Elm 的核心主张可以用一句话概括把前端应用的状态变化建模成“输入一个旧状态和一条消息返回一个新状态”的纯函数并让所有副作用都变成显式的数据描述。它解决的核心问题是状态失控。React 项目中常见的问题包括父组件更新了 props子组件没有同步更新useEffect依赖数组写错导致请求重复或缺失某处给对象赋值后被另一处覆盖接口返回了空值渲染层直接抛异常。这些问题在 Elm 里绝大多数会被编译器挡住因为 Elm 不允许隐式的状态修改、不允许未处理的空值、不允许隐藏的副作用。1.2 React 开发者的熟悉感与陌生感React 开发者第一次看 Elm 代码不会完全陌生。React 的函数组件本身就是“从 props 返回 UI”的纯函数也就是props JSXElm 中一个普通的view : Model - Html Msg函数做的是同一件事。React 里的状态提升对应 Elm 中把所有状态收归到一个顶层 ModelReact 的useReducer从设计上看几乎就是 Elm update 函数的简化版React 的不可变更新思想在 Elm 里则是唯一合法操作。陌生感来自另外几个地方。Elm 没有 Hook、没有生命周期、没有 class 组件也没有组件内部状态。一个 Elm 应用通常只有一个 Model、一个 Msg 类型、一个 update 函数和若干 view 函数。组件在 Elm 中不是“拥有状态的单元”而是“负责渲染一段视图的纯函数”。这种设计会让 React 开发者一开始不太适应因为它把 React 中很灵活的组件封装边界彻底简化了。1.3 为什么 React 开发者值得花时间学 Elm在 React 生态内状态管理方案的更替已经持续了多年从原始的setState到 Redux、Mobx、Zustand、Jotai。每一种方案都在解决“状态如何分发、更新和读取”的问题。Elm 的独特价值在于它把这些问题收敛成了一套被编译器守护的模型。看懂了 Elm再回头看 React 的useReducer、状态提升和副作用拆分会更容易理解这些 API 为什么要那样设计。另外Elm 适合用来练习“不可变数据思维”。在 React 项目中写const newState { ...state, count: state.count 1 }是纪律在 Elm 中写错了根本编译不过编译器会告诉你不可以这样赋值。这种强制性训练会反哺你的日常 React 开发。2. 搭建 Elm 开发环境用 elm reactor 免掉 webpack 配置学习期不需要引入 webpack、Vite 或任何构建工具。Elm 官方提供了一套学习友好的开发方式elm reactor。它会在本机启动一个文件服务浏览器直接打开对应.elm文件就能运行。2.1 安装 Elm 编译器如果本机已经有 Node.js可以通过 npm 安装 Elmnpm install -g elm安装完成后检查版本elm --version如果输出类似0.19.1的版本号说明安装成功。当前 Elm 主版本稳定在 0.19 系列网上大部分教程也基于这个版本。如果你的团队已有特定版本要求落地前先确认依赖版本避免教程代码与本地版本不一致。安装失败时优先检查 npm 镜像源、网络环境或系统权限。也可以从 Elm 官网直接下载对应系统的安装包不需要依赖 npm。2.2 初始化项目并理解 elm.json创建一个项目目录并进入mkdir elm-counter cd elm-counter elm initelm init会生成两个关键内容src目录用于存放你的.elm源码文件。elm.json这是 Elm 的工程配置文件类似package.json。elm.json内容看起来类似{ type: application, source-directories: [ src ], elm-version: 0.19.1, dependencies: { direct: { elm/browser: 1.0.2, elm/core: 1.0.5, elm/html: 1.0.0 }, indirect: { elm/json: 1.1.3, elm/time: 1.0.0, elm/url: 1.0.0, elm/virtual-dom: 1.0.3 } }, test-dependencies: { direct: {}, indirect: {} } }source-directories决定了 Elm 编译器从哪里查找源码文件。dependencies分成direct和indirect前者是项目直接依赖后者是间接依赖。这里的 elm/browser 负责描述应用入口elm/html 提供 HTML 渲染能力elm/json 提供 JSON 编解码elm/url 和 elm/time 是常用基础库。2.3 启动 reactor 并建立验证方式执行elm reactor默认端口是 8000。启动后浏览器访问http://localhost:8000页面会显示src目录下的文件列表点击Main.elm就能在浏览器中看到 Elm 应用效果。修改源码后刷新浏览器reactor 会即时编译如果代码有编译错误页面会直接显示错误信息而不是白屏。检查点有两个页面能正常渲染出你写的内容。修改源码后刷新页面内容能同步变化。需要注意elm reactor只适合学习和本地调试并不适合作为生产模式。生产环境建议使用elm make将 Elm 编译成独立的压缩 JS 文件elm make src/Main.elm --optimize --outputmain.js这个命令会在当前目录生成main.js之后可以像引入普通 JavaScript 文件一样把它嵌入到现有页面或 React 应用中。3. 用 Elm 写一个计数器把 Model-View-Update 核心链路跑通学习任何前端框架计数器都是最小闭环案例。Elm 计数器虽然简单但已经覆盖了 Elm Architecture 的完整流程Model 定义状态、Msg 定义消息、update 处理消息、view 渲染界面。3.1 创建 Main.elm 并写入完整案例在src目录下创建Main.elmmodule 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 alias Model Int init : Model init 0 type Msg Increment | Decrement update : Msg - Model - Model update msg model case msg of Increment - model 1 Decrement - model - 1 view : Model - Html Msg view model div [] [ button [ onClick Decrement ] [ text - ] , div [] [ text (String.fromInt model) ] , button [ onClick Increment ] [ text ] ]这段代码是完整的 Elm 应用不需要额外配置。保存文件后通过elm reactor打开对应页面就能看到一个带加减按钮的计数器。3.2 从 React 组件翻译成 Elm 视图把同一个计数器用 React 写大概是这样import { useState } from react; function Counter() { const [count, setCount] useState(0); return ( div button onClick{() setCount(count - 1)}-/button span{count}/span button onClick{() setCount(count 1)}/button /div ); }两段代码的 UI 渲染逻辑几乎一一对应React 中的div、button对应 Elm 中的div、button函数。React 中的onClick对应 Elm 中的onClick。React 中{count}插值对应 Elm 中text (String.fromInt model)。React 中useState对应 Elm 中Model加update。但结构上有本质差异。React 组件内部用useState创建了一个局部状态setCount可以直接在事件处理器里调用Elm 中视图函数只负责描述“当前状态长什么样”它不能修改状态。点击事件发生时Elm 会生成一条 Msg由 Elm 运行时调用update函数再重新执行view。这一点是最重要的心智切换React 中状态变更发生在组件函数里Elm 中状态变更只发生在update里。3.3 update 函数如何替代 setState 与 useReducerupdate函数的签名是update : Msg - Model - Model它接收一条 Msg 和当前 Model返回一个新 Model。这个签名明确表达了两个约束更新必须通过消息不能直接改状态。更新是纯函数同样的消息和旧状态一定产生同样的新状态。React 中最接近这个模型的是useReducerfunction reducer(state, action) { switch (action.type) { case increment: return state 1; case decrement: return state - 1; default: return state; } }Elm 的case msg of和 React reducer 中的switch (action.type)思路相同。区别在于Elm 中所有 Msg 分支都是编译器枚举出来的漏掉任何一个分支都会编译失败React 中的 switch 漏掉某个 case 通常在运行时才有表现。4. 把 Elm 的每个概念映射回 React建立可翻译的心智模型React 开发者学习 Elm 最有效的方式不是背 Elm 语法而是建立一张翻译表。把 Elm 的 Model、Msg、update、view、Cmd、Sub 分别对应到 React 能力中的某个位置再看它们之间的不同点。4.1 Model 对应 React 的应用状态但层级完全不同Elm 中的 Model 是整个应用的唯一状态。它可以是一个整数也可以是一个复杂的 recordtype alias Model { count : Int , userName : String , users : List User , loading : Bool }React 中很容易出现“一个组件一组状态”比如弹窗组件有自己的open列表组件有自己的list它们的生命周期彼此独立。Elm 把这些全部收拢到一个 Model 中任何 UI 变化都反映在同一个 Model 的值变化上。这样做的好处是状态可预测性极强只要知道 Model 的值和最近一条 Msg就能推断整个应用发生了什么。代价是组件复用不再通过“组件内部自带状态”来实现而是把局部状态作为参数传给视图函数。viewHeader : Model - Html Msg viewHeader model div [] [ text model.userName ]这种写法和 React 中的纯函数组件function Header(props) { return div{props.userName}/div; }很接近。差别在于 Elm 中不存在useState之类的隐藏状态。4.2 Msg 对应 React 的 action但类型精确到每个参数React 中使用useReducer时action 通常是一个对象dispatch({ type: changeName, value: Tom });Elm 更直接每种消息本身就是一种类型type Msg ChangeName String | Increment | DecrementChangeName是一个带参数的构造器使用方式是onInput ChangeName这里ChangeName的类型是String - Msg正好匹配输入框事件处理器的要求。编译器会检查消息参数的类型传错类型时直接报编译错误不需要等到运行期才看到数据异常。4.3 view、命令和订阅分别对应渲染、副作用和监听Elm 的view函数对应 React 组件渲染Cmd对应“应用主动发起的副作用”比如 HTTP 请求、本地存储写入、生成随机数Sub对应“应用监听外部信号”比如窗口尺寸变化、服务器推送、定时器回调。React 中useEffect把这两类副作用混在一起useEffect(() { fetchData(); const handler () {}; window.addEventListener(resize, handler); return () window.removeEventListener(resize, handler); }, []);Elm 将两者分开。请求通过 update 返回Cmd监听通过subscriptions函数返回Sub。这种拆分的价值在于代码审查时只要看 update 返回的命令和 subscriptions 返回的订阅就知道应用的副作用入口在哪里不会出现“某个 effect 悄悄改了外部状态”的情况。4.4 Elm 与 React 核心概念速查表Elm 概念React 对应物一致点关键差异ModeluseState / useReducer 的状态描述 UI 数据来源Elm 全应用只有一个 ModelMsguseReducer 的 action状态更新入口Elm 的 Msg 是强类型联合类型update 函数reducer 函数旧状态加消息产生新状态Elm 分支必须穷尽漏分支编译失败view 函数React 函数组件从数据渲染 UIElm 视图不拥有局部状态CmduseEffect 中发起的副作用代表应用主动行为Elm 副作用被显式建模SubaddEventListener / 订阅监听外部事件Elm 订阅集中在 subscriptions 函数组件复用函数组件组合函数组合 UIElm 无组件实例只是纯函数调用这张表可以作为后续写 Elm 代码时的快速参考。遇到不清楚怎么写时先想清楚当前功能要落到 Elm 的哪个概念里再回来查表。5. Elm 用编译器“提前消灭”了 React 运行时最常见的几类错误React 开发中很多问题发生在运行时某个属性拼写错误、某个接口字段为空、某个 effect 依赖漏写。Elm 不能消除所有 bug但它把高频错误从“运行期报错”提前到了“编译期报错”。5.1 穷尽匹配让“漏分支”不再成为隐藏问题Elm 的case表达式要求所有分支都被覆盖。以消息处理为例type Msg Increment | Decrement | Reset update msg model case msg of Increment - model 1 Decrement - model - 1如果Msg新增了Reset但update没有加对应分支编译器会明确提示这个 case 没有覆盖所有可能的分支。React 中如果useReducer的 switch 少写一个 case只要不触发该 action页面就不会暴露问题等触发时可能已经产生了错误数据。Elm 处理 Maybe 的方式也体现了编译器保护。访问一个可能不存在的字典键Dict.get name userDict这个表达式的类型是Maybe String在使用前必须处理两种情况case Dict.get name userDict of Just name - text name Nothing - text unknownReact 开发者通常用obj?.name ?? unknown处理类似问题这种写法宽容但也容易在业务变化时静默吞掉异常。Elm 的强制分支会迫使你在每个使用点明确表达空值策略。5.2 不可变更新被语言强制而不是靠代码规范React 官方文档一再强调不要直接修改 state不要给 props 赋值。这个要求在 React 中依靠 eslint 规则和团队 review 维持Elm 则直接在语言层禁止。Elm 更新 record 的语法是{ model | count model.count 1 }这行代码不修改原 record而是返回一个新 record。Elm 中不存在“把一个对象赋值为另一个值”或“push 进一个数组”这类可变操作。列表操作通过List.map、List.filter、List.concat完成永远返回新列表。所以 React 里常见的“setState 之后页面没更新最后发现直接改对象了”这类问题在 Elm 中根本不可能出现。5.3 副作用显式建模不依赖 useEffect 的依赖数组useEffect的依赖数组是 React 新手最容易犯错的地方。依赖漏写会导致使用过期数据依赖多写会导致请求重复发送。这类问题在代码审查时很难一眼发现因为它需要理解闭包捕获了哪些值。Elm 将副作用模型简化成两类命令Cmd应用主动发任务由update返回。订阅Sub应用被动收信号由subscriptions返回。例如一个带 HTTP 请求的 update 结构update : Msg - Model - ( Model, Cmd Msg ) update msg model case msg of FetchData - ( { model | loading True } , Http.get { url https://api.example.com/data , expect Http.expectJson GotData dataDecoder } ) GotData result - case result of Ok data - ( { model | loading False, data data }, Cmd.none ) Err error - ( { model | loading False, error 请求失败 }, Cmd.none )这段代码中副作用由Http.get显式创建返回值必须交给 update 返回。收到响应后通过GotData消息进入 update再在分支里处理成功和失败。整个过程没有“某个 effect 悄悄捕获了旧 state”的问题因为 update 就是一个纯函数收到的 model 就是当前值。6. Elm 与 JavaScript/React 的边界ports、flags 与嵌入模式Elm 不是一座封闭孤岛。虽然在 Elm 内部要求纯函数但它保留了与 JavaScript 互操作的通道flags 和 ports。React 开发者可以在现有项目里逐步引入 Elm而不是一次性重写。6.1 用 ports 让 Elm 与 JavaScript 通信ports是 Elm 与外部世界沟通的端口它分两种输出端口Elm 向 JavaScript 发送消息。输入端口JavaScript 向 Elm 发送消息。定义方式如下port module Main exposing (main) import Browser port sendToJs : String - Cmd msg port receiveFromJs : (String - msg) - Sub msg在 JavaScript 侧通过初始化 Elm 应用时生成的app对象来订阅和发送const app Elm.Main.init({ node: document.getElementById(elm-app) }); app.ports.sendToJs.subscribe(function (message) { console.log(Elm 发来的消息:, message); }); setTimeout(() { app.ports.receiveFromJs.send(来自 JavaScript 的消息); }, 1000);ports传参通常使用 JSON 可序列化的数据。Elm 里接收 JS 消息时需要对应解码器发送给 JS 时也会做序列化处理。6.2 在 React 项目里集成一个 Elm 应用生产环境最常见的引入方式是把 Elm 编译成 JS再挂载到指定 DOM 节点。先用elm make编译elm make src/Main.elm --optimize --outputelm-app.js然后在 HTML 中引入script srcelm-app.js/script div idelm-app/div script const app Elm.Main.init({ node: document.getElementById(elm-app), flags: { userId: 123 } }); /scriptflags是 Elm 应用启动时接收的初始数据常用于传用户 ID、Token 或配置项。React 项目中也可以把 Elm 应用包进一个 React 组件在useEffect中初始化在卸载时清理。这种集成方式的好处是团队可以在一个大型 React 项目里把某个状态特别复杂的模块独立用 Elm 实现其他部分继续使用 React互不干扰。6.3 学习环境与生产环境的差异维度学习环境elm reactor生产环境启动方式elm reactor浏览器直接开页elm make --optimize生成压缩 JS构建工具不需要可接入 npm scripts、打包器或现有 CI外部通信一般不需要需要 flags 传参、ports 与 JS 互操作错误处理浏览器直接展示编译错误需要关注运行时日志和错误恢复代码组织单文件即可需要拆模块、定义好数据编解码器测试与监控可省略需要接入测试、错误上报和性能监控Elm 生产项目依然存在运行时错误只是出现概率远低于 React 项目。异步请求失败、数据格式变化、浏览器 API 异常等都可能在运行时发生。生产环境仍然需要日志、监控和异常上报。7. React 开发者学 Elm 的常见坑与排查路径从 React 切到 Elm最大的风险不是语法而是沿用 React 的习惯去写 Elm。下面这些坑在实际学习中经常出现。7.1 坑一把 JavaScript 的 if 写法直接搬到 ElmElm 中也有if但它不是语句而是表达式。写法必须包含else并且两个分支返回相同类型。-- 错误缺少 else编译失败 if model 0 then text 正数 -- 正确 if model 0 then text 正数 else text 非正数更常见的情况是需要多个分支这时推荐用casecase compare model 0 of GT - text 正数 LT - text 负数 EQ - text 零React 开发者容易在写多条件判断时图省事把if写错。遇到编译报错先检查分支是否完整、分支返回类型是否一致。7.2 坑二在 view 中直接调用 update 或执行副作用React 开发者在事件处理器里调用setState是正常操作但 Elm 的 view 是纯函数不能直接调 update。-- 错误view 中不能执行 update view model button [ onClick (update Increment model) ] [ text ]正确写法是只派发 Msg让 Elm 运行时调用 updateview model button [ onClick Increment ] [ text ]判断标准很简单view 里只允许创建消息不允许处理状态变化。所有状态变化逻辑都应该在update中描述。7.3 坑三忘记导入必要的 Html 或事件模块Elm 的模块导入非常严格。Html、Html.Events、Html.Attributes是三个高频模块漏导入一个就会编译失败。例如使用class属性时只导入Html是不够的import Html exposing (Html, div, text) import Html.Attributes exposing (class) view model div [ class container ] [ text content ]如果不导入Html.Attributes编译器会提示找不到class变量。解决方式是按依赖的 API 补全 import。经验是用到 HTML 标签导入Html用到事件导入Html.Events用到 class、href、id 等属性导入Html.Attributes。7.4 坑四混淆 Cmd 和 Sub初学 Elm 时容易把发送命令和订阅信号混在一起。要记住一条规则update返回Cmd表示“应用要做一件事”。subscriptions返回Sub表示“应用等一个外部信号”。如果计划做一个定时刷新功能subscriptions : Model - Sub Msg subscriptions model Time.every 5000 Tick如果计划发起一个 HTTP 请求update msg model case msg of Fetch - ( model, Http.get { ... } )两者不能互换。如果定义了订阅却没有在subscriptions里返回定时器永远不触发如果定义了命令却没有在update分支里返回请求也不会发出。7.5 编译错误排查清单现象可能原因检查方式处理建议找不到变量或函数import 缺失或模块名写错查看报错位置和代码首部按报错提示补全 importif 缺少 else把 if 当语句写看 if 表达式是否闭合补全 else 分支或改用 case分支返回类型不一致不同分支返回不同类型根据编译器提示对比类型统一分支返回类型view 里出现状态变化调用了 update 或产生副作用看事件处理函数是否只是 Msg 构造器改为只派发 Msg订阅不触发subscriptions 未返回对应 Sub检查 subscriptions 函数在 subscriptions 中返回事件订阅命令不执行update 未返回 Cmd检查 update 分支返回值返回( model, someCmd )页面白屏且控制台无日志Elm 编译错误打开 reactor 页面或执行elm make按编译错误修改源码排查 Elm 问题起点永远是先解决编译错误。Elm 的编译提示通常很具体会指出文件名、行号、表达式类型以及不符的原因。React 开发者不要习惯性去“试运行看效果”而是先读懂编译器输出的完整信息。8. 学完 Elm 之后把最重要的三条经验带回 React 项目学习 Elm 的最终目标不一定是把项目改成 Elm。对大多数 React 团队来说更有价值的做法是吸收 Elm 的约束用回 React 自己的语法体系里。8.1 用 useReducer 收拢复杂业务状态当一个组件的状态之间存在联动关系时尽量避免使用多个useState分散管理。Elm 的 update 模式可以平移成 React 的useReducer。function reducer(state, action) { switch (action.type) { case fieldChange: { return { ...state, values: { ...state.values, [action.field]: action.value } }; } case validateStart: { return { ...state, validating: true, error: }; } case validateSuccess: { return { ...state, validating: false }; } case validateFail: { return { ...state, validating: false, error: action.message }; } default: return state; } }这样写之后所有状态流转都集中在一个 reducer 文件中测试时只需要传入旧 state 和 action验证新 state 是否符合预期。它不具备 Elm 编译器级别的穷尽检查但可以把“状态更新散落在组件各处”的问题缓解很多。8.2 状态尽量规范化UI 临时状态和领域状态分层管理Elm 强制使用单一 Model这让开发者天然思考“哪些数据属于应用领域状态哪些只是 UI 瞬时状态”。回到 React 项目可以把这个思路落地为领域状态表单数据、列表数据、用户信息、权限标记统一抽到 reducer。UI 临时状态弹窗是否打开、loading 动画、当前输入焦点可以继续用useState。不要把所有状态都塞进全局 store也不要让每个小组件各自维护一份领域状态拷贝。这样分层以后代码可读性会明显提升。状态变更的入口越少追踪越容易。8.3 给 React 开发者的 Elm 学习路径清单如果你打算继续深入学习 Elm可以参考下面的顺序先读官方文档中的 The Elm Architecture 部分搞清楚 Model、Msg、update、view 四个概念之间的关系。动手写计数器、TodoList、列表筛选这三个最小案例感受 Elm 编译器对不可变更新和穷尽匹配的强制。练习Http请求和Time.every订阅理解 Cmd 和 Sub 的区别。用 ports 写一个和外部 JavaScript 通信的页面掌握 Elm 与 JS 生态的边界。回到 React 项目用useReducer重构一个最容易出状态的模块记录重构前后的差异。Elm 的价值不在于“把前端写得像 Haskell”而在于它把状态管理的复杂性压到最低让开发者重新思考前端代码中可以有哪些更严格的约束。尤其对已经积累了一定 React 业务经验的开发者来说Elm 更像一面镜子你不一定要住进去但照着镜子整理一遍自己的代码结构会有不少收获。
返回列表