Bobi.ink
2019-05-20
今天是 520,这是本系列最后一篇文章,主要涵盖 React 状态管理的相关方案。
前几篇文章在掘金首发基本石沉大海, 没什么阅读量. 可能是文章篇幅太长了?掘金值太低了? 还是错别字太多了? 后面静下心来想想,写作对我来说是一种学习和积累的过程, 让我学习更全面更系统性去描述一个事物. 但是写作确实是一件非常耗时的事情, 文章的每句话都要细细推敲, 还要避免主观性太强避免误导了别人.
所以模仿<<内核恐慌>>的口号: “想看的人看,不想看的人就别看”
系列目录
文章目录
现在的前端框架,包括 React 的一个核心思想就是数据驱动视图, 即UI = f(state)
. 这种开发方式的变化其实得益于 Virtual-DOM, 它使得我们不需要关心浏览器底层 DOM 的操作细节,只需关心‘状态(state)’和‘状态到 UI 的映射关系(f)’. 所以如果你是初学者,不能理解什么是‘数据驱动’, 还是不推荐继续阅读文章下面的内容。
但是随着 state
的复杂化, 框架现有的组件化方式很难驾驭 f
(视图的映射关系变得复杂, 难以被表达和维护); 或者相关类型的应用数据流本来就比较复杂, 组件之间的交互关系多样,本来难以使用UI = f(state)
这种关系来表达; 或者应用的组件状态过于离散,需要统一的治理等等. 我们就有了状态管理的需求.
状态管理最基础的解决方式就是分层,也就是说和传统的 MV*
模式没有本质区别, 主流状态管理的主要结构基本都是这样的:
他们基本都包含这些特点:
f
映射关系, 让UI = f(state)
这个表达式更彻底dispatch+reducer
, mobx 要求数据变更函数使用action
装饰或放在flow
函数中,目的就是让状态的变更根据可预测性但是, React 的状态管理方案太多了,选择这些方案可能会让人抓狂,你需要权衡很多东西:
对于大部分简单的应用和中后台项目来说是不需要状态管理的。说实话这些应用和传统 web 页面没什么区别, 每个页面都各自独立,每次打开一个新页面时拉取最新数据,增删改查仅此而已. 对于这些场景 React 的组件状态就可以满足, 没有必要为了状态管理而状态管理. 这种各自独立的‘静态’页面,引入状态管理就是过度设计了。
在考虑引入状态管理之前考虑一下这些手段是否可以解决你的问题:
当你的应用有以下场景时,就要开始考虑状态管理:
首先确定是否需要 Redux、Mobx 这些复杂的状态管理工具? 在 2019 他们很多功能都可以被 React 本身提供的特性取代. 随着 React 16.3 发布了新的 Context API,我们可以方便地在它之上做简单的状态管理, 我们应该优先选择这些原生态的状态管理方式。
例如: 简单的使用 Context API 来做状态管理:
最近 hooks 用得比较爽(参考上一篇文章: 组件的思维),我就想配合 Context API 做一个状态管理器, 后来发现早就有人这么干了: unstated-next, 代码只有 38 行(Hooks+Context),接口非常简单:
依赖于 hooks 本身灵活的特性,我们可以用它来做很多东西, 仅限于想象力. 例如异步数据获取:
抑或者实现 Redux 的核心功能:
总结一下使用 hooks 作为状态管理器的优点:
Hooks Container
. 上一篇文章提到 hooks 写着写着很像组件,组件写着写着很像 hooks,在用法上组件可以认为是一种’特殊’的 hooks。相比组件, hooks 有更灵活的组合特性需要注意的地方
所以 Context+ Hooks 可以用于满足简单的状态管理需求, 对于复杂的状态管理需求还是需要用上 Redux、Mobx 这类专业的状态管理器.
其他类似的方案
扩展
unstated
是一个极简的状态管理方案,其作者也说了不要认为unstated 是一个 Redux killer, 不要在它之上构建复杂的工具,也就是不要重复造轮子。所以一般到了这个地步, 其实你就应该考虑 Redux、Mobx、Rxjs 这些复杂的状态管理框架了。
Redux 是学习 React 绕不过的一个框架. 尽管 Redux 的代码只有一百多行,概念却很多,学习曲线非常陡峭,看官方文档就知道了。即使它的实现很简洁,但是开发代码并不简洁(和 mobx 相反, 脏活留给开发者),尤其遵循它的’最佳实践’,是从头开始构建一个项目是非常繁琐的. 还在现在有类似 dva 或 rematch 这样的二次封装库来简化它.
本文不打算深入介绍 Redux 的相关实践, 社区上面有非常多的教程,官方文档也非常详尽. 这里会介绍 Redux 的主要架构和核心思想,以及它的适用场景.
Redux 的主要结构如上,在此之前你先要搞清楚 Redux 的初衷是什么,才能明白它为什么要这么设计. 在我看来 Redux 主要为了解决以下两个问题:
其实这也是 flux
的初衷, 只是有些它有些东西没做好. 明白 Redux 的初衷,现在来看看它的设计就会清晰很多
- 可以简化应用数据流. 解决传统多 model 模型数据流混乱问题(比如一个 model 可以修改其他 model,一个 view 受到多个 model 驱动),让数据变动变得可预测可调试
- 同构化应用开发
- 方便调试
- 方便做数据镜像. 可以实现撤销/重做、时间旅行、热重载、状态持久化和恢复
什么时候应该使用 Redux?
首先还是警告一下: You Might Not Need Redux, Redux 不是你的第一选择。
当我们需要处理复杂的应用状态,且 React 本身无法满足你时. 比如:
最佳实践
个人觉得react-boilerplate是最符合官方‘最佳实践’的项目模板. 它的应用工作流如下:
特性:
immer
(不可变数据变更),redux-saga
(异步数据流处理),reselect
(选取和映射 state,支持 memo,可复合),connected-react-router
(绑定 react-router v4)再看看 react-boilerplate 目录结构. 这是我个人比较喜欢的项目组件方式,组织非常清晰,很有参考意义
/src
/components # 展示组件
/containers # ?容器/页面组件
/App # 根组件, 例如放置Provider和Router
/HomePage # 页面组件
index.js # 页面入口
constants.js # ? 在这里定义各种常量。包括Action Type
actions.js # ? 定义各种Action函数
saga.js # ? redux-saga 定义各种saga方法, 用于处理异步流程
reducer.js # ? reducer。 页面组件的reducer和saga都会按需注入到根store
selectors.js # ? redux state映射和计算
message.js
Form.js # 各种局部组件
Input.js
...
/FeaturePage # 其他页面组件结构同上
...
/translations # i18n 翻译文件
/utils
reducerInjectors.js # ?reducer 注入器, 实现和页面组件一起按需注入
sagaInjectors.js # ?saga 注入器, 同上
lodable.js
app.js # 应用入口
i18n.js # i18n配置
configureStore.js # ? 创建和配置Redux Store
reducers.js # ? 根reducers, 合并所有'页面状态'和'全局状态'(如router, language, global(例如用户鉴权信息))
? 开始吐槽!
immer+saga+reselect
笔者个人更喜欢类似 Vuex 这种Ducks
风格的组织方式,将模块下的 action,saga,reducer 和 mapper 都组织在一个文件下面:
Redux 的二次封装框架基本采用类似的风格, 如rematch
这些二次封装框架一般做了以下优化(其实可以当做是 Vuex 的优点),来提升 Redux 的开发体验:
- **使用 Ducks 风格组织代码**.聚合分散的 reducer,saga,actions…
- **更简化的 API**
- **提供了简单易用的模块化(或者称为‘分形’)或者命名空间机制**。模块本身支持‘状态隔离’,让模块的 reducer、saga 只专注于模块自己的状态. 另外模块还考虑**动态加载**
- **内置副作用处理机制**。如使用 saga 或 redux-promise
- **简化了不可变数据的操作方式**。 如使用 immer
- **简化 reducer**。Redux 内置了 combineReducers 来复合多个 reducer,在 reducer 内部我们一般使用 switch 语句来接收 action 和处理数据变动, 其实写起来非常啰嗦. **Vuex 和这些封装框架不约而同使用了 key/value 形式**, 更为简洁明了
- **简化 view 层的 connect 接口**。如简化 mapProps,mapDispatch 这些代码写起来也比较繁琐
- **领域数据还是应用数据?** 领域数据一般推荐放在 ReduxStore 中,我们通常会将 Redux 的 Store 看作一个数据库,存放范式化的数据。
- **状态是否会被多个组件或者跨页面共享?** Redux Store 是一个全局状态存储器,既然使用 Redux 了,有理由让 Redux 来管理跨越多组件的状态
- **状态是否需要被镜像化?** 如果你的应用要做‘时间旅行(撤销/重做)’或者应用持久化,这个状态需要被恢复,那么应该放到 Redux Store,集中化管理数据是 Redux 的强项
- **状态是否需要跨越组件的生命周期?** 将状态放在组件局部,就会跟着组件一起被销毁。如果希望状态跨越组件的生命周期,应该放到父组件或者 Redux Store 中. 比如一个模态框编辑的数据在关闭后是否需要保留
原则是能放在局部的就放在局部. 在局部状态和全局状态中取舍需要一点开发经验.
另外作为一个集中化的状态管理器,为了状态的可读性(更容易理解)和可操作性(更容易增删查改),在状态结构上面的设计也需要花费一些精力的. 这个数据库结构的设计方法是一样的, 在设计状态之前你需要理清各种领域对象之间的关系, 在数据获取和数据变更操作复杂度/性能之间取得平衡.
Redux 官方推荐范式化 State,扁平化结构树, 减少嵌套,减少数据冗余. 也就是倾向于更方便被更新和存储,至于视图需要什么则交由 reselect 这些库进行计算映射和组合.
所以说 Redux 没那么简单, 当然 80%的 Web 应用也不需要这么复杂.
集中化的 Store,再通过 Connect 机制可以让状态在整个应用范围内被复用;Dumb 组件抽离的状态和行为,也容易被复用
现在假设你需要将单个 container 抽离成独立的应用,单个 container 是无法独立工作的。在分形的架构下,一个‘应用’有更小的‘应用’组成,‘应用’内部有自己的状态机制,单个应用可以独立工作,也可以作为子应用. 例如 Redux 的鼻祖 Elm 的架构:
Store的结构和应用的结构保持一致,
每个 Elm 组件也是一个 Elm 应用,包含完整的Action、Update、Model和View. 使得单独的应用可以被复用
Redux 不是分形和 Redux 本身的定位有关,它是一个纯粹的状态管理器,不涉及组件的视图实现,所以无法像 elm 和 cyclejs 一样形成一个完整的应用闭环。 其实可以发现 react 组件本身就是分形的,组件原本就是状态和视图的集合.
分形的好处就是可以实现更灵活的复用和组合,减少胶水代码。显然现在支持纯分形架构的框架并不流行,原因可能是门槛比较高。个人认为不支持分形在工程上还不至于成为 Redux 的痛点,我们可以通过‘模块化’将 Redux 拆分为多个模块,在多个 Container 中进行独立维护,从某种程度上是否就是分形?另外这种横向隔离的 UI 和状态,也是有好处的,比如 UI 相比业务的状态变化的频度会更大.
个人感觉到页面这个级别的分化刚刚好,比如方便分工。比如最近笔者就有这样一个项目, 我们需要将一个原生 Windows 客户端转换成 electron 实现,限于资源问题,这个项目涉及到两个团队之间协作. 对于这个项目应用 Store 就是一个接口层,Windows 团队负责在这里维护状态和实现业务逻辑,而我们前端团队则负责展示层. 这样一来 Windows 不需要学习 React 和视图展示,我们也不需要关系他们复杂的业务逻辑(底层还是使用 C++, 暴露部分接口给 node)
七,可能还有性能问题
总结
本节主要介绍的 Redux 设计的动机,以及围绕着这个动机一系列设计, 再介绍了 Redux 的一些缺点和最佳实践。Redux 的生态非常繁荣,如果是初学者或不想折腾还是建议使用 Dva 或 rematch 这类二次封装框架,这些框架通常就是 Redux 一些最佳实践的沉淀, 减少折腾的时间。当然这只是个开始,组织一个大型项目你还有很多要学的。
扩展阅读
Mobx 提供了一个类似 Vue 的响应式系统,相对 Redux 来说 Mobx 的架构更容易理解。 拿官方的图来看:
@observable
将数据转换为‘响应式数据’,类似于 Vue 的 data。这些数据在一些上下文(例如 computed,observer 的包装的 React 组件,reaction)中被访问时可以被收集依赖,当这些数据变动时相关的依赖就会被通知.
响应式数据带来的两个优点是 ① 简化数据操作方式(相比 redux 和 setState); ② 精确的数据绑定,只有数据真正变动时,视图才需要渲染,组件依赖的粒度越小,视图就可以更精细地更新- 衍生数据。Mobx 也推荐不要在状态中放置冗余或可推导的数据,而是使用 `@computed` 计算衍生的状态. computed 的概念类似于 Redux 中的 reselect,对范式化的数据进行反范式化或者聚合计算
- 副作用衍生. 当数据变动时触发依赖该数据的副作用,其中包含‘视图’。视图是响应式数据的映射
上面就是 Mobx 的核心概念。举一个简单的例子:
但是Mobx 不是一个框架,它不会像 Redux 一样告诉你如何去组织代码,在哪存储状态或者如何处理事件, 也没有最佳实践。好处是你可以按照自己的喜好组件项目,比如按照 Redux(Vuex)方式,也可以使用面向对象方式组织; 坏处是如果你没有相关经验, 会不知所措,不知道如何组织代码
Mobx 一般使用面向对象的方式对 Store 进行组织, 官方文档构建大型可扩展可维护项目的最佳实践也介绍了这种方式, 这个其实就是经典的 MV* 模式:
src/
components/ # 展示组件
models/ # ? 放置一些领域对象
Order.ts
User.ts
Product.ts
...
stores/ # store
AppStore.ts # 应用Store,存放应用全局信息,如auth,language,theme
OrderStore.ts
RootStore.ts # 根Store,组合所有下级Store
...
containers/
App/ # 根组件
Orders/ # 页面组件
...
utils/
store.ts # store初始化
index.tsx
领域对象
面向对象领域有太多的名词和概念,而且比较抽象,如果理解有误请纠正. 暂且不去理论领域对象是什么,尚且视作是现实世界中一个业务实体在 OOP 的抽象. 具体来说可以当做MVC
模式中的 M, 或者是 ORM 中数据库中映射出来的对象.
对于复杂的领域对象,会抽取为单独的类,比如前面例子中的Todo
类, 抽取为类的好处是它具有封装性,可以包含关联的行为、定义和其他对象的关联关系,相比纯对象表达能力更强. 缺点就是不好序列化
因为它们和页面的关联关系较弱,且可能在多个页面中被复用, 所以放在根目录的models/
下. 在代码层面领域对象有以下特点:
示例
import { observable } from 'mobx';
export default class Order {
public id: string;
@observable
public name: string;
@observable
public createdDate: Date;
@observable
public product: Product;
@observable
public user: User;
}
Store
Store 只是一个 Model 容器, 负责管理 model 对象的生命周期、定义衍生状态、封装副作用、和后端接口集成等等. Store 一般是单例. 在 Mobx 应用中一般会划分为多个 Store 绑定不同的页面。
示例
import { observable, computed, reaction } from 'mobx';
export default class OrderStore {
// 定义模型state
@observable orders: Order[] = [];
_unSubscribeOrderChange: Function
rootStore: RootStore
// 定义衍生数据
@computed get finishedOrderCount() {}
@computed get finishedOrders() {}
// 定义副作用衍生
subscribeOrderChange() { this._unSubscribeOrderChange = this.orders.observe((changeData) => {} }
// 定义action
@action addOrder (order) {}
@action removeOrder (order) {}
// 或者一些异步的action
async fetchOrders () {
const orders = await fetchOrders()
orders.forEach(item => this.addOrder(new OrderModel(this, item)))
}
// 初始化,初始化数据结构,初始化订阅等等
initialize () {
this.subscribeOrderChange()
}
// 一些清理工作
release () {
this._unSubscribeOrderChange()
}
constructor(store: RootStore) {
// 和rootStore进行通信
this.rootStore = store
}
}
根 Store
class RootStore {
constructor() {
this.appStore = new AppStore(this);
this.orderStore = new OrderStore(this);
...
}
}
<Provider rootStore={new RootStore()}>
<App />
</Provider>
看一个 真实世界的例子
这种传统 MVC 的组织方式主要有以下优点:
问题
还有一些 mobx 本身的问题, 这些问题在上一篇文章也提过, 另外可以看这篇文章(Mvvm 前端数据流框架精讲):
- Observable 数组并非真正的数组. 比如 antd 的 Table 组件就不认 mobx 的数组, 需要传入到组件之间使用 slice 进行转换
- 向一个已存在的 observable 对象中添加属性不会被自动捕获
MV*
只是 Mobx 的其中一种主流组织方式, 很多文章在讨论 Redux 和 mobx 时往往会沦为函数式和面向对象之争,然后就下结论说 Redux 更适合大型项目,下这种结论最主要的原因是 Redux 有更多约束(only one way to do it), 适合项目的演进和团队协作, 而不在于函数式和面向对象。当然函数式和面向对象范式都有自己擅长的领域,例如函数式适合数据处理和复杂数据流抽象,而面向对象适合业务模型的抽象, 所以不要一竿子打死.
换句话说适不适合大型项目是项目组织问题, Mobx 前期并没有提出任何解决方案和最佳实践。这不后来其作者也开发了mobx-state-tree这个神器,作为 MobX 官方提供的状态模型构建库,MST 吸收了 Redux 等工具的优点,旨在结合不可变数据/函数式(transactionality, traceability and composition)和可变数据/面向对象(discoverability, co-location and encapsulation)两者的优点, 提供了很多诸如数据镜像(time travel)、hot reload、action middleware、集成 redux-devtools 以及强类型(Typescript + 运行时检查(争议点))等很有用的特性, 其实它更像是后端 ActiveRecord 这类 ORM 工具, 构建一个对象图。
典型的代码:
限于笔者对 MST 实践不多,而且文章篇幅已经很长,所以就不展开了,后续有机会再分享分享。
还是得下一个结论, 选择 Mobx 还是 Redux? 这里还是引用来自MobX vs Redux: Comparing the Opposing Paradigms - React Conf 2017 纪要的结论:
上述结论的主要依据是 Redux 对 action / event 作出反应,而 MobX 对 state 变化作出反应。比如当一个数据变更涉及到 Mobx 的多个 Store,可以体现出 Redux 的方式更加优雅,数据流更加清晰. 前面都详尽阐述了 Mobx 和 Redux 的优缺点,mobx 还有 MST 加持, 相信读者心里早已有自己的喜好
扩展
如果上文提到的状态管理工具都无法满足你的需要,你的项目复杂程度可能超过全国 99%的项目了. RxJS 可能可以助你一臂之力, RxJS 非常适合复杂异步事件流的应用,笔者在这方面实践也比较少,推荐看看徐飞的相关文章, 另外 Redux(Redux-Observable)和 Mobx 实际上也可以配合 RxJS 使用
推荐这篇文章State of React State Management for 2019
Please enable JavaScript to view the comments powered by Disqus.