react-bits 单向数据流模式:用订阅式 Store 让 React 组件回归纯粹展示
【免费下载链接】react-bits✨ React patterns, techniques, tips and tricks ✨项目地址: https://gitcode.com/gh_mirrors/re/react-bits
单向数据流是 React 应用数据管理的基石思想:让数据只沿一个方向流动,消除多个状态源带来的不可预测性。本文基于 react-bits 仓库中 patterns/7.one-way-data-flow.md 的核心思路,从零实现一个支持订阅的最小 Store,并将它接入 App 与 Switcher 两个组件,最终让组件变成"Store 数据的哑展示层"。读完后你将掌握:订阅-发布式 Store 的完整写法、forceUpdate的适用边界与高阶组件替代方案,以及单向数据流与 Flux、Redux、容器组件模式之间的演进关系。
一、为什么需要单向数据流
在 React 应用中,状态可以散落在各个组件内部:每个组件自己维护this.state,父子组件之间通过 props 回调传递数据,兄弟组件之间则依赖"提升状态"或事件总线。当组件树变大,这种多状态源并存的方式会出现两个典型问题:
- 状态来源不唯一:同一个业务数据可能在多个组件中各存一份,改了一处忘了另一处,界面与数据出现分歧;
- 数据流向不透明:数据在组件间"绕来绕去",难以追踪一个值从哪里来、被谁修改。
单向数据流(One-way data flow)的思路是:消除多个状态,只保留一个权威状态,这个状态通常存放在 Store 中。组件不再各自为政,而是统一从 Store 读取数据、通过 Store 提供的方法修改数据。数据流向固定为:
用户交互 → 调用 Store.set() → 触发订阅回调 → 组件重新渲染这样整条链路只有一条"数据高速公路",调试和推理都变得简单。
二、实现一个可订阅的最小 Store
要让组件感知到数据变化,Store 对象必须提供"订阅变更"的能力,即典型的发布-订阅(Pub/Sub)模式。原文档给出的实现非常精简,只有四个成员:
var Store = { _handlers: [], _flag: '', onChange: function (handler) { this._handlers.push(handler); }, set: function (value) { this._flag = value; this._handlers.forEach(handler => handler()) }, get: function () { return this._flag; } };逐个拆解这四个成员,可以看出一个最小 Store 的全部要素:
| 成员 | 类型 | 职责 |
|---|---|---|
_handlers | 数组 | 存放所有订阅者(回调函数),是 Store 内部的私有属性 |
_flag | 任意值 | 当前唯一的数据源,本例用字符串示意,实际业务中可以是对象 |
onChange(handler) | 方法 | 订阅接口:把回调推入_handlers,之后每次数据变更都会调用它 |
set(value) | 方法 | 写接口:更新_flag,然后遍历_handlers逐个通知订阅者 |
get() | 方法 | 读接口:返回当前_flag的值 |
值得注意的细节:
set是唯一的写入口:外部组件不能直接改_flag,只能通过set写入,这保证了"改数据必发通知"的强约束;- 通知是同步广播:
set内部forEach逐个调用订阅者,不涉及异步调度,逻辑简单直白; - 没有退订机制:这是刻意简化的结果。真实场景中通常会给
onChange返回一个退订函数(或在组件卸载时清理),避免内存泄漏。
可以看到,这个 Store 与仓库中 Flux 模式文档 里 Dispatcher 的register/dispatch思路一脉相承:Flux 用 Dispatcher 统一分发 action 到各 store 的update方法,这里的set承担了类似的"变更广播"职责,只是收敛为单 Store、单数据源的极简形态。
三、将 App 组件挂接到 Store
有了 Store 之后,接下来要让应用根组件App订阅 Store 的变更,并在每次变更时重新渲染:
class App extends React.Component { constructor(props) { super(props); Store.onChange(this.forceUpdate.bind(this)); } render() { return ( <div> <Switcher value={ Store.get() } onChange={ Store.set.bind(Store) }/> </div> ); } }这段代码包含两个关键动作:
- 订阅:在
constructor里调用Store.onChange(...),把this.forceUpdate.bind(this)注册为变更回调。此后只要Store.set被调用,App就会强制重新渲染; - 读写分离地传递 props:
value={ Store.get() }从 Store 读取当前值,onChange={ Store.set.bind(Store) }把 Store 的写方法(绑定好this)作为回调下发给子组件。
注意Store.set.bind(Store)这一步:Store是普通对象字面量,其方法里的this依赖调用方式。把set作为 props 传下去后,调用方上下文不再是Store,所以必须bind(Store),否则this._flag会指向错误的对象。这是一个非常容易踩坑的细节。
关于 forceUpdate:能用,但不够优雅
原文档特别提醒:forceUpdate并不是 React 推荐的做法。
forceUpdate会跳过shouldComponentUpdate的优化机会,强制组件及其子树重新渲染,破坏了 React 基于状态差异做渲染决策的正常机制。它在这里出现只是因为"足够简单"——不需要引入额外抽象,三行代码就能演示订阅驱动的重渲染。
原文档原话:Normally a high-order component is used to enable the re-rendering. We used forceUpdate just to keep the example simple.(正常情况下应该用高阶组件来触发重渲染,这里用 forceUpdate 只是为了保持示例简单。)
那么"正规"做法是什么?参见仓库中 Presentational vs Container 模式文档:把数据逻辑封装进容器组件,由其负责订阅 Store、持有 state,并用 render 只输出纯展示组件。更典型的形态是高阶组件(HOC)——仓库中 Feature Flags 文档 展示了用connect把 Redux store 注入容器的完整范式:
return connect((store) => { isEnabled: isFeatureEnabled(store, featureName) })(FeatureFlaggedContainer);这套思路与本文的 Store 订阅如出一辙:connect本质上就是"订阅 store 变化 → 触发容器重渲染 → 把新数据注入 props"的通用化封装,只是把forceUpdate换成了可控的订阅管理,并附带了shouldComponentUpdate层面的优化。
四、Switcher 组件:彻底移除内部状态
数据流理顺之后,受益最明显的是子组件。原来的Switcher可能自带一个开关状态(比如this.state.on),而现在它完全不需要内部 state 了:
class Switcher extends React.Component { constructor(props) { super(props); this._onButtonClick = e => { this.props.onChange(!this.props.value); } } render() { return ( <button onClick={ this._onButtonClick }> { this.props.value ? 'lights on' : 'lights off' } </button> ); } }分析一下这个组件的依赖:
- 读:只通过
this.props.value拿到当前开关状态,不自己存副本; - 写:只通过
this.props.onChange(!this.props.value)把"希望切换"的意图上报给父级,由父级转交Store.set; - 渲染:纯由 props 决定,同样的 props 永远渲染出同样的 UI。
点击按钮的完整链路因此变得非常清晰:
点击 button → _onButtonClick → this.props.onChange(!value) → Store.set(新值) → 广播所有订阅者 → App.forceUpdate() → Store.get() 返回新值 → Switcher 拿到新 props → 重新渲染按钮文案这里有一个与 React 语义相关的注意点:onChange回调经由 props 层层上传,setState的调用最终发生在 Store 内部(严格说是发生在Store.set里,而不是某个组件里)。而关于setState的异步批处理问题,仓库中有两份独立文档专门讨论:setState 的异步本质 说明了 React 事件处理器内setState会被批处理、而setTimeout/AJAX 等场景下会同步更新的行为差异;向 setState 传入函数 则给出了依赖旧状态更新时的推荐写法。在 Store 场景下,如果多个组件在短时间内连续set,同样值得参考函数式更新的思路,避免读到过期值。
五、这个模式带来的核心收益
原文档在结尾总结了单向数据流最本质的两个收益:
- 组件变成 Store 数据的"哑展示":组件不再关心数据从哪来、如何变化,只负责"把 props 渲染成 UI"。这大大降低了单个组件的认知负担,也让组件可以被随意复用——只要给它 props,它就能工作;
- 用声明式方式写应用,把复杂度集中到一处:应用被写成"数据是什么,界面就是什么"的声明式形态,所有关于数据变更的逻辑(何时变、变什么、变了通知谁)都收敛在 Store 这一个地方,而不是散落在每个组件的生命周期方法里。
这一点与 Presentational vs Container 文档 的分层哲学完全一致:展示组件只关心外观(how things look),容器组件负责数据与业务逻辑(how things work)。单向数据流正是让"展示层"得以彻底简化的前提——因为权威数据永远在 Store,组件内部不再需要额外的状态副本。
同时要看到这条模式的边界:它是一个教学级的最小实现,用于揭示单向数据流的核心机制。真实项目中通常不会手写这种 Store,而是直接使用 Flux(仓库中 Flux 模式文档 给出了带 Dispatcher、action、store 注册校验的完整实现)或 Redux 等成熟方案,它们把本文中的"订阅广播"、"唯一数据源"思想工业化了。但无论框架如何演进,你看到的底层骨架始终是:数据统一存储 → 变更通知订阅者 → 组件从 Store 拉取新值重渲染。
六、小结:从最小 Store 到工业级状态管理
| 层级 | 本文示例 | 工业级方案 |
|---|---|---|
| 数据存储 | Store._flag | Redux 单一 store / reducer |
| 变更通知 | _handlers+forEach | subscribe/connect |
| 触发渲染 | forceUpdate | 容器组件 + HOC(如connect) |
| 写数据入口 | Store.set | dispatch(action) |
| 组件形态 | 无内部 state 的展示组件 | 展示/容器分层 |
本文基于 patterns/7.one-way-data-flow.md 展开:我们实现了一个仅 10 余行的订阅式 Store,用它驱动了App与Switcher两个组件的单向数据流动,理解了forceUpdate的局限与高阶组件的演进方向。当你下次遇到"状态散落各处、数据流向混乱"的应用时,不妨先回到这条最简单的链路:一个 Store、一次广播、一层纯粹展示——复杂应用的状态管理,往往就是从这条单向链路生长出来的。
【免费下载链接】react-bits✨ React patterns, techniques, tips and tricks ✨项目地址: https://gitcode.com/gh_mirrors/re/react-bits
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考