一、引言
CSR:(client side render)客户端渲染,SSR:(server side render)服务端渲染。面向的场景不同,最佳实践的数据流选择也不同。SSR通常适合 首屏性能、安全性、SEO或存在静态页面体量需求较高的场景。推荐选择支持GraphQL的开发框架。比如apollographql、relay,nextjs,gatsby 等, 上了框架,往往数据流的选择空间有限,且GraphQL在浏览器缓存及数据一致性问题上一直是业界头疼的问题。故在数据流的选型上本文仅面向CSR谈谈自己的理解。
适用对象:适合未使用开发框架、喜欢尝鲜的同学,框架通常会在数据流上做强约束。比如阿里的ice,数据流通常约束为icestore。umijs数据流通常约束为dva, 或者通过webpack插件扩展成其他,比如hox。成体系化的框架,适合团战,开发效率高,但是较难拥抱变化,最佳实践往往需兼顾既往业务包袱,常常负重前行...
二、数据流流派划分
Facebook React官方正品数据流只有一个,就是组件的 props。
然而开发者在组织业务代码时,通常会选取些更适合代码和业务逻辑组织的数据流框架。 ,比如react 的hook之前redux、redux-saga、dva、mobx、rxjs、react-context 等等,hook 之后 unstated-next、recoil、hox 、easy-peasy、little-saga 等等。总结下react数据流的发展经历的几波流派,从最早的 redux、mobx、context 三大流派争鸣。到了现今多数皆已接轨了hook的时代,为方便归纳,从消费代码的角度,根据代码书写的方式,大致分为3派(以较著名的代表库、或想法命名):
1、redux-dva 流派
该流派思想源自redux,写法上取经于dva。依然保留将state、reducer、dispatch、effect集中管理的方式,通过hook,提供给组件使用。通常使用的库有:icestore 、easy-peasy 、unistore 、freactal 、kea 、zustand 等..
代码组织 以icestore为例:
// 1️⃣ Create a custom hook as usual
const counter = {
state: 0,
reducers: {
increment:(prevState) => prevState + 1,
decrement:(prevState) => prevState - 1,
},
effects: () => ({
async asyncDecrement() {
await delay(1000);
this.decrement();
},
})
};
const models = {
counter,
};
// 2️⃣ Create the store
const store = createStore(models);
// 3️⃣ Consume model
const { useModel } = store;
function Counter() {
const [ count, dispatchers ] = useModel('counter');
const { increment, asyncDecrement } = dispatchers;
return (
<div>
<span>{count}</span>
<button type="button" onClick={increment}>+</button>
<button type="button" onClick={asyncDecrement}>-</button>
</div>
);
}
在该流派中,hook在其中扮演的角色仅仅替代了写法不够清爽的context,承载确保组件更新的作用。状态集中管理,逻辑集中定义。代码的美观性较好。但业务逻辑reducer、action、effect针对具体的state,数据、业务集中管理,针对性较强。
2、react-hook流派
代码示例: jotai_hox_unstate-next 挑选了unstate-next(使用hook+context)、hox(使用hook未用context)、jotai(recoil的简化版,使用atom+hook+context)三个具有代表性的库,做了简单事务的演示,每个都有普通版、和saga版。方便对比学习。
该流派将hook直接用于数据流管理。使用useState作为数据源,利用hook的其他API、或函数操作state,并返回相关操作的接口对象。通常使用的库有:unstate-next 、constate 、hox 、icestore-next 等 (hox不完全等同其他几个库利用context实现的数据跨组件共享,只是写法类似)..
以hox为例:
import { createModel } from "hox";
import { useState } from "react";
const counter1 = 10;
const counter2 = 20;
const useCount = (defValue) => {
const [countNum, setCountNum] = useState(defValue);
const addone = () => {
setCountNum(countNum + 1);
};
const subone = () => {
setCountNum(countNum - 1);
};
return { countNum, addone, subone }; //暴露操作的接口或数据
};
// 该处的作用让Hook包裹的数据变化,使其具有相对的全局性。
const useCountModel1 = createModel(useCount, counter1);
const useCountModel2 = createModel(useCount, counter2);
export default function CountBtn() {
const { countNum, addone, subone } = useCountModel2();
return (
<div className="App">
<div>
<button type="button" onClick={addone}>
加1
</button>
<button type="button" onClick={subone}>
减1
</button>
<br />
Echo: {countNum}
</div>
</div>
);
}
你可以在 codesandbox 尝试一下 (方便对比,index.js中使用 ./hox/App即可)。
hook通常用于复用逻辑,可以将大粒度的数据拆分成小粒度的状态,并精细化暴露对其操作的接口,而逻辑当具有状态时,有多个状态,就需要多个hook。如上面例子的两个model。
3、recoil流派
recoil 通过引入的数据流向图的概念,使得数据(atom)及派生数据(selector)的变化、异步查询的结果能被纯函数和高效订阅映射到相应的组件中,recoil 的主要设计原理可参见: recoil状态管理库 ,在此不做拷贝复述。该流派的库有:recoil、jotai 等..
实现以上同样方式的代码:
import { atom, useRecoilState } from "recoil";
const counter1 = atom({
key: "counter1",
default: 0
});
const counter2 = atom({
key: "counter2",
default: 10
});
const useCount = (defAtom) => {
const [countNum, setCountNum] = useRecoilState(defAtom);
const addone = () => {
setCountNum(countNum + 1);
};
const subone = () => {
setCountNum(countNum - 1);
};
return { countNum, addone, subone };
};
export default function CountBtn() {
const { countNum, addone, subone } = useCount(counter2); // or useCount(counter1)
return (
<div className="App">
<div>
<button type="button" onClick={addone}>
加1
</button>
<button type="button" onClick={subone}>
减1
</button>
<br />
Echo: {countNum}
</div>
</div>
);
}
你可以在 CodeSandbox 中尝试一下(index.js中使用 ./recoil/App即可) 。
为什么recoil单独作为一个流派?看起来写法挺类似,在react-hook流派中,hook同时包裹着数据和逻辑,createModel (hox)化后,hook函数是全局的(js函数本身就是对象),hook整体充当数据共享的桥梁,逻辑的状态也是同时可以共享。而在recoil中,通过hook中使用atom,atom才是组件共享数据的桥梁。同时atom可以派生,且多个atom之间可以通过selector 进行计算组合,使用上比较灵活。但是如需复用逻辑使用hook,那么在hook中也需要使用atom,当组件使用hook时,每个组件都有一份atom的值副本,使得数据存在较多冗余。recoil目前API设计比较精细,品类众多。学习成本比较昂贵。不如考虑基于URL的大型应用,推荐使用轻量级的 jotai。
三、数据流选择依据
数据流的选择需非常谨慎,底层技术的升级,往往造成数据流框架的更新迭代, 而数据流的升级迭代,往往导致项目业务逻辑的重写。因此在数据流选型考虑中,不能只考虑当下,还需要考虑可预见的底层变化。在影响选型的主要因素上,我们围绕以下三个方面:底层变化的适应性、逻辑复用性、解决问题的能力 去平衡选择
底层变化的适应性
首先是js 语言层面升级,语言新概念的引入对框架的影响非常大,EMCAscript 多次概念的引入都导致了不少流行的底层框架进行了版本迭代甚至重构,比如 es6 的await/async ,Generator,Proxy,Reflection 概念的引入都直接导致 vue2 -> vue3,koa1 -> koa2 等等..EMCAscript 下一个王炸概念大概率是Decorator 装饰器。 Decorator提案起始于2015年,至今有5年之久,目前处于stage-3阶段。Decroator是对class、filed、method、funtion 的二次封装,使得一些代码的通用模式装饰器化。必然造成一波的数据流写法的迭代,而该次升级预期涉及的是代码写法的简化升级,考虑编译器语言运行时的优化,stage-3推翻了不少stage-2的设计原则,到stage-4后到落地目测还有1-2年的稳定沉淀期,所以本次暂不考虑使用Decorator写法的数据流。
其次React底层concurrent并发模式的升级,该模式使用渲染版本的概念在框架层面实现无阻塞、可中断的渲染,将过去的组件渲染三阶段Receded(后退) → Skeleton(骨架) → Complete(完成),改变为:Pending(挂起) → Skeleton → Complete 。挂起的过程通常为异步或重计算过程,在相应事件的渲染中,通过一些复杂算法丢弃不必要的渲染版本。改变以往需要大量使用防抖(debounce) 和 节流(throttling)、Loading暂停响应 等技巧来改善UI的用户体验方式,解决了异步组件的渲染与触发事件常常因频繁操作而导致不同步的问题。上手后,体感可用“妙不可言" 4字来形容。
目前宣称可以向后兼容concurrent模式的有recoil、jotai(recoil的简化版)。mobx hox 由于未采用context,在兼容 concurrent模式时会遇到不少障碍。而使用context 的react-hook流派的数据流,未来通过内部的升级,应该也能够较好的兼容concurrent模式。
逻辑的复用性
在组件开发实践中,react为了解决组件业务逻辑复用问题,2018年底提出了hook。而数据流的使用往往和业务紧密相关,自然需要考虑逻辑的复用问题。既然底层提供快捷通道。那么选择建立在hook之上的数据流显得十分顺应发展潮流。并且社区有很优秀的hook库 react-use,ahooks 等,提炼了很多优秀的通用逻辑。
由于react对hook的使用做了部分约束。使得一些更精细化、条件化的hook的组合使用上会遇到少许不便。但总体这个代价远小于其所带来的收益。相比redux-dva流派,数据及管理的集中,自然数据及逻辑通用性较差,通过派发action 和负载来机制更新数据,模块化程度较高,细粒度数据的复用率较低。所以在复用性上,系统面对细粒度数据响应的使用场景较多时,推荐使用react-hook 及 recoil流派。 当 系统使用整体数据全局化使用较多的场景时,推荐 redux-dva流派。
解决问题的能力
数据流在业务实践中,通常重点关注以下几点:异步事务、数据回溯、全局与局部状态区分、数据生命周期、调试工具。
异步事务、数据回溯、全局与局部状态区分
react数据流发展到hook,redux就一定得消亡了么?未必。redux 核心的思想是数据、管理(store、reducer)的集中,操作(action、dispatch)上的分散。非常适合分布式系统和异步环境。在协同办公、票务订单处理(使用saga)等场景,优势很大。为保证数据的一致性、分布式的app或组件不可能去随时随地的去同步中心数据块,那么在组件端,通过指令+数据负载的方式,非常友好的做到了组件与业务逻辑分离。同时数据的中心化管理,让数据的整体、或局部回溯能较轻松的实现。在事务需滚回的业务场景,优势明显。由于数据的集中管理,数据只有全局状态。通过action来区分不同的操作,在管理庞大的组件堆里,组件派发action后的数据流向往往不清晰。如果代码体量大,业务逻辑复杂,则维护保养比较困难。
react-hook、recoil流派,数据全局和局部状态区分较容易,单纯使用hook是局部的,而createModel (hox)化,或是使用atom(recoil)就是全局的。由于数据往往被精细化拆分。要实现整体状态的回溯难度较大。在局部状态的回溯中,通过缓存数据的方式,较容易实现。在应对异步事务的场景中,我们在 jotai_hox_unstate-next 的codesandbox示例中能看到使用hook的数据流同样具有异步事务的能力(借助little-saga实现)。
数据整体回溯、及整体数据的全局化需求较大。推荐采用redux-dva 流派。虽然recoil也提供了对所有atom快照的功能。但目前API尚不稳定,待观望。
对异步事件的处理上,兼容最好的当属recoil流派了。在异步事务上,jotai_hox_unstate-next 示例中的jotai-saga与hox-saga和unstate-next-saga对比后,我们能较清晰的看到,react-hook流派和saga的结合使用更自然。recoil和jotai与hox和unstat-next对比,jotai和recoil,当第一组件启动自增时,第二组件无法暂停自增。当需要逻辑也需要全局化时,推荐采用react-hook流派。
而react-hook流派中的 hox和unstate-next主要区别在于是否基于context。不基于原生context,虽然用起来很爽,但在兼容react-native,ssr,复杂异步、和concurrent模式上,通常会比较麻烦。如果想少踩点坑 ,推荐使用基于context的 react-hook流派库。
数据生命周期、调试工具
react-dva 流派成熟度较高。调试工具也都比较成熟。相比年轻的react-hook和recoil 流派,往往因技术而异。
比如unstate-next 等库, 能较细化provider,使得共享数据作用域的划分比较自由。而hox、recoil、jotai 这几个是根全局性的。就容易导致即使组件已经被销毁,其数据仍然存在的情况,数据的生命周期管控较难,往往存在内存泄漏的风险。
在调试工具方面, react-dva 和基于context的react-hook都有现成的调试工具,而jotai 通过一些不甚有好的配置,recoil通过一些不甚友好的API 来辅助调试。
四、总结
总结归纳下会发现,现有数据流工具中,不同的场景,不同的流派均有擅长的地方。似乎没有一个银弹能解决所有的问题。日积月累的学习和实践,越来越感觉到“编译即框架”的精髓和威力所在。或许很多问题,如果我们出发点在另一个维度,也许对于需要解决问题的会是个降维打击。就像webpack5的联邦模块,它似乎提供一个更好的微前端实践方式。然而对于工程化的配置,则似乎需要在编译或IDE的层面才能智能的解决。理想数据流似乎更可能发在babel/macros 的层上,通过编译屏蔽掉底层变化的差异。只关注数据的定义、表达和使用。
能力有限,不当之处,欢迎留言指正....
以下是广告: webpack5 + react17 微前端 antd design 的多标签参考模版,同时支持Pwa,由于均使用最新依赖,fast-refresh尚不稳定,postcss因兼容问题暂时关闭。数据流、国际化及布局正在迁移中,适合练手使用,数据流确定后会陆续添加功能组件。