TNTWeb - 全称腾讯新闻前端团队,组内小伙伴在Web前端、NodeJS开发、UI设计、移动APP等大前端领域都有所实践和积累。
目前团队主要支持腾讯新闻各业务的前端开发,业务开发之余也积累沉淀了一些前端基础设施,赋能业务提效和产品创新。
团队倡导开源共建,拥有各种技术大牛,团队Github地址:https://github.com/tnfe
本文作者fantasticsoul,concent github:https://github.com/concentjs/concent
序言
facebook的全新新状态管理方案 recoil,最大的亮点是提出了atom
的概念,原子化的管理所有状态节点,然后让用户在逻辑层自由组合。
在体验完recoil后,对其中的精确更新保持了一点怀疑态度,这一点会在下文结尾处和大家共同探讨,在此之前本文主要是分析Concent
与Recoil
的使用体验差异性,并预测它们对将来开发模式有何新影响,以及思维上需要做什么样的转变。
数据流方案之3大流派
目前主流的数据流方案按形态都可以划分以下这三类
- redux流派
redux、和基于redux衍生的其他作品,以及类似redux思路的作品,代表作有dva、rematch等等。 - mobx流派
借助definePerperty和Proxy完成数据劫持,从而达到响应式编程目的的代表,类mobx的作品也有不少,如dob等。 - Context流派
这里的Context指的是react自带的Context api,基于Context api打造的数据流方案通常主打轻量、易用、概念少,代表作品有unstated、constate等,大多数作品的核心代码可能不超过500行。
到此我们看看Recoil
应该属于哪一类?很显然按其特征属于Context流派,那么我们上面说的主打轻量对Recoil
并不适用了,打开其源码库发现代码并不是几百行完事的,所以基于Context api
做得好用且强大就未必轻量,由此看出facebook
对Recoil
是有野心并给予厚望的。
我们同时也看看Concent
属于哪一类呢?Concent
在v2
版本之后,重构数据追踪机制,启用了defineProperty和Proxy特性,得以让react应用既保留了不可变的追求,又享受到了运行时依赖收集和ui精确更新的性能提升福利,既然启用了defineProperty和Proxy,那么看起来Concent
应该属于mobx流派?
事实上Concent
属于一种全新的流派,不依赖react的Context api,不破坏react组件本身的形态,保持追求不可变的哲学,仅在react自身的渲染调度机制之上建立一层逻辑层状态分发调度机制,defineProperty和Proxy只是用于辅助收集实例和衍生数据对模块数据的依赖,而修改数据入口还是setState
(或基于setState封装的dispatch, invoke, sync),让Concent
可以0入侵的接入react应用,真正的即插即用和无感知接入。
即插即用的核心原理是,Concent
自建了一个平行于react运行时的全局上下文,精心维护这模块与实例之间的归属关系,同时接管了组件实例的更新入口setState,保留原始的setState为reactSetState,所有当用户调用setState时,concent除了调用reactSetState更新当前实例ui,同时智能判断提交的状态是否也还有别的实例关心其变化,然后一并拿出来依次执行这些实例的reactSetState,进而达到了状态全部同步的目的。
Recoil初体验
我们以常用的counter来举例,熟悉一下Recoil
暴露的四个高频使用的api
- atom,定义状态
- selector, 定义派生数据
- useRecoilState,消费状态
- useRecoilValue,消费派生数据
定义状态
外部使用atom
接口,定义一个key为num
,初始值为0
的状态
const numState = atom({
key: "num",
default: 0
});
定义派生数据
外部使用selector
接口,定义一个key为numx10
,初始值是依赖numState
再次计算而得到
const numx10Val = selector({
key: "numx10",
get: ({ get }) => {
const num = get(numState);
return num * 10;
}
});
定义异步的派生数据
selector
的get
支持定义异步函数
需要注意的点是,如果有依赖,必需先书写好依赖在开始执行异步逻辑
const delay = () => new Promise(r => setTimeout(r, 1000));
const asyncNumx10Val = selector({
key: "asyncNumx10",
get: async ({ get }) => {
// !!!这句话不能放在delay之下, selector需要同步的确定依赖
const num = get(numState);
await delay();
return num * 10;
}
});
消费状态
组件里使用useRecoilState
接口,传入想要获去的状态(由atom
创建而得)
const NumView = () => {
const [num, setNum] = useRecoilState(numState);
const add = ()=>setNum(num+1);
return (
<div>
{num}<br/>
<button onClick={add}>add</button>
</div>
);
}
消费派生数据
组件里使用useRecoilValue
接口,传入想要获去的派生数据(由selector
创建而得),同步派生数据和异步派生数据,皆可通过此接口获得
const NumValView = () => {
const numx10 = useRecoilValue(numx10Val);
const asyncNumx10 = useRecoilValue(asyncNumx10Val);
return (
<div>
numx10 :{numx10}<br/>
</div>
);
};
渲染它们查看结果
暴露定义好的这两个组件, 查看在线示例
export default ()=>{
return (
<>
<NumView />
<NumValView />
</>
);
};
顶层节点包裹React.Suspense
和RecoilRoot
,前者用于配合异步计算函数需要,后者用于注入Recoil
上下文
const rootElement = document.getElementById("root");
ReactDOM.render(
<React.StrictMode>
<React.Suspense fallback={<div>Loading...</div>}>
<RecoilRoot>
<Demo />
</RecoilRoot>
</React.Suspense>
</React.StrictMode>,
rootElement
);
Concent初体验
如果读过concent文档(还在持续建设中...),可能部分人会认为api太多,难于记住,其实大部分都是可选的语法糖,我们以counter为例,只需要使用到以下两个api即可
run,定义模块状态(必需)、模块计算(可选)、模块观察(可选)
运行run接口后,会生成一份concent全局上下文
- setState,修改状态
定义状态&修改状态
以下示例我们先脱离ui,直接完成定义状态&修改状态的目的
import { run, setState, getState } from "concent";
run({
counter: {// 声明一个counter模块
state: { num: 1 }, // 定义状态
}
});
console.log(getState('counter').num);// log: 1
setState('counter', {num:10});// 修改counter模块的num值为10
console.log(getState('counter').num);// log: 10
我们可以看到,此处和redux
很类似,需要定义一个单一的状态树,同时第一层key就引导用户将数据模块化管理起来.
引入reducer
上述示例中我们直接调用setState
修改数据,但是真实的情况是数据落地前有很多同步的或者异步的业务逻辑操作,所以我们对模块添加reducer
定义,用来声明修改数据的方法集合。
import { run, dispatch, getState } from "concent";
const delay = () => new Promise(r => setTimeout(r, 1000));
const state = () => ({ num: 1 });// 状态声明
const reducer = {// reducer声明
inc(payload, moduleState) {
return { num: moduleState.num + 1 };
},
async asyncInc(payload, moduleState) {
await delay();
return { num: moduleState.num + 1 };
}
};
run({
counter: { state, reducer }
});
然后我们用dispatch
来触发修改状态的方法
因dispatch会返回一个Promise,所以我们需要用一个async 包裹起来执行代码
import { dispatch } from "concent";
(async ()=>{
console.log(getState("counter").num);// log 1
await dispatch("counter/inc");// 同步修改
console.log(getState("counter").num);// log 2
await dispatch("counter/asyncInc");// 异步修改
console.log(getState("counter").num);// log 3
})()
注意dispatch调用时基于字符串匹配方式,之所以保留这样的调用方式是为了照顾需要动态调用的场景,其实更推荐的写法是
import { dispatch } from "concent";
await dispatch("counter/inc");
// 修改为
await dispatch(reducer.inc);
其实run
接口定义的reducer
集合已被concent
集中管理起来,并允许用户以reducer.${moduleName}.${methodName}
的方式调用,所以这里我们甚至可以基于reducer
发起调用
import { reducer as ccReducer } from 'concent';
await dispatch(reducer.inc);
// 修改为
await ccReducer.counter.inc();
接入react
上述示例主要演示了如何定义状态和修改状态,那么接下来我们需要用到以下两个api来帮助react组件生成实例上下文(等同于与vue 3 setup里提到的渲染上下文),以及获得消费concent模块数据的能力
- register, 注册类组件为concent组件
- useConcent, 注册函数组件为concent组件
import { register, useConcent } from "concent";
@register("counter")
class ClsComp extends React.Component {
changeNum = () => this.setState({ num: 10 })
render() {
return (
<div>
<h1>class comp: {this.state.num}</h1>
<button onClick={this.changeNum}>changeNum</button>
</div>
);
}
}
function FnComp() {
const { state, setState } = useConcent("counter");
const changeNum = () => setState({ num: 20 });
return (
<div>
<h1>fn comp: {state.num}</h1>
<button onClick={changeNum}>changeNum</button>
</div>
);
}
注意到两种写法区别很小,除了组件的定义方式不一样,其实渲染逻辑和数据来源都一模一样。
渲染它们查看结果
const rootElement = document.getElementById("root");
ReactDOM.render(
<React.StrictMode>
<div>
<ClsComp />
<FnComp />
</div>
</React.StrictMode>,
rootElement
);
对比Recoil
,我们发现没有顶层并没有Provider
或者Root
类似的组件包裹,react组件就已接入concent,做到真正的即插即用和无感知接入,同时api
保留为与react
一致的写法。
组件调用reducer
concent为每一个组件实例都生成了实例上下文,方便用户直接通过ctx.mr
调用reducer方法
mr 为 moduleReducer的简写,直接书写为ctx.moduleReducer也是合法的
// --------- 对于类组件 -----------
changeNum = () => this.setState({ num: 10 })
// ===> 修改为
changeNum = () => this.ctx.mr.inc(10);// or this.ctx.mr.asynInc(10)
//当然这里也可以写为ctx.dispatch调用,不过更推荐用上面的moduleReducer直接调用
//this.ctx.dispatch('inc', 10); // or this.ctx.dispatch('asynInc', 10)
// --------- 对于函数组件 -----------
const { state, mr } = useConcent("counter");// useConcent 返回的就是ctx
const changeNum = () => mr.inc(20); // or ctx.mr.asynInc(10)
//对于函数组将同样支持dispatch调用方式
//ctx.dispatch('inc', 10); // or ctx.dispatch('asynInc', 10)
异步计算函数
run
接口里支持扩展computed
属性,即让用户定义一堆衍生数据的计算函数集合,它们可以是同步的也可以是异步的,同时支持一个函数用另一个函数的输出作为输入来做二次计算,计算的输入依赖是自动收集到的。
const computed = {// 定义计算函数集合
numx10({ num }) {
return num * 10;
},
// n:newState, o:oldState, f:fnCtx
// 结构出num,表示当前计算依赖是num,仅当num发生变化时触发此函数重计算
async numx10_2({ num }, o, f) {
// 必需调用setInitialVal给numx10_2一个初始值,
// 该函数仅在初次computed触发时执行一次
f.setInitialVal(num * 55);
await delay();
return num * 100;
},
async numx10_3({ num }, o, f) {
f.setInitialVal(num * 1);
await delay();
// 使用numx10_2再次计算
const ret = num * f.cuVal.numx10_2;
if (ret % 40000 === 0) throw new Error("-->mock error");
return ret;
}
}
// 配置到counter模块
run({
counter: { state, reducer, computed }
});
上述计算函数里,我们刻意让numx10_3
在某个时候报错,对于此错误,我们可以在run
接口的第二位options
配置里定义errorHandler
来捕捉。
run({/**storeConfig*/}, {
errorHandler: (err)=>{
alert(err.message);
}
})
当然更好的做法,利用concent-plugin-async-computed-status
插件来完成对所有模块计算函数执行状态的统一管理。
import cuStatusPlugin from "concent-plugin-async-computed-status";
run(
{/**storeConfig*/},
{
errorHandler: err => {
console.error('errorHandler ', err);
// alert(err.message);
},
plugins: [cuStatusPlugin], // 配置异步计算函数执行状态管理插件
}
);
该插件会自动向concent配置一个cuStatus
模块,方便组件连接到它,消费相关计算函数的执行状态数据
function Test() {
const { moduleComputed, connectedState, setState, state, ccUniqueKey } = useConcent({
module: "counter",// 属于counter模块,状态直接从state获得
connect: ["cuStatus"],// 连接到cuStatus模块,状态从connectedState.{$moduleName}获得
});
const changeNum = () => setState({ num: state.num + 1 });
// 获得counter模块的计算函数执行状态
const counterCuStatus = connectedState.cuStatus.counter;
// 当然,可以更细粒度的获得指定结算函数的执行状态
// const {['counter/numx10_2']:num1Status, ['counter/numx10_3']: num2Status} = connectedState.cuStatus;
return (
<div>
{state.num}
<br />
{counterCuStatus.done ? moduleComputed.numx10 : 'computing'}
{/** 此处拿到错误可以用于渲染,当然也抛出去 */}
{/** 让ErrorBoundary之类的组件捕捉并渲染降级页面 */}
{counterCuStatus.err ? counterCuStatus.err.message : ''}
<br />
{moduleComputed.numx10_2}
<br />
{moduleComputed.numx10_3}
<br />
<button onClick={changeNum}>changeNum</button>
</div>
);
}
精确更新
开篇我说对Recoli
提到的精确更新保持了怀疑态度,有一些误导的嫌疑,此处我们将揭开疑团
大家知道hook
使用规则是不能写在条件控制语句里的,这意味着下面语句是不允许的
const NumView = () => {
const [show, setShow] = useState(true);
if(show){// error
const [num, setNum] = useRecoilState(numState);
}
}
所以用户如果ui渲染里如果某个状态用不到此数据时,某处改变了num
值依然会触发NumView
重渲染,但是concent
的实例上下文里取出来的state
和moduleComputed
是一个Proxy
对象,是在实时的收集每一轮渲染所需要的依赖,这才是真正意义上的按需渲染和精确更新。
const NumView = () => {
const [show, setShow] = useState(true);
const {state} = useConcent('counter');
// show为true时,当前实例的渲染对state.num的渲染有依赖
return {show ? <h1>{state.num}</h1> : 'nothing'}
}
当然如果用户对num值有ui渲染完毕后,有发生改变时需要做其他事的需求,类似useEffect
的效果,concent也支持用户将其抽到setup
里,定义effect
来完成此场景诉求,相比useEffect
,setup里的ctx.effect
只需定义一次,同时只需传递key名称,concent会自动对比前一刻和当前刻的值来决定是否要触发副作用函数。
conset setup = (ctx)=>{
ctx.effect(()=>{
console.log('do something when num changed');
return ()=>console.log('clear up');
}, ['num'])
}
function Test1(){
useConcent({module:'cunter', setup});
return <h1>for setup<h1/>
}
current mode
关于concent是否支持current mode
这个疑问呢,这里先说答案,concent
是100%完全支持的,或者进一步说,所有状态管理工具,最终触发的都是setState
或forceUpdate
,我们只要在渲染过程中不要写具有任何副作用的代码,让相同的状态输入得到的渲染结果幂,即是在current mode
下运行安全的代码。
current mode
只是对我们的代码提出了更苛刻的要求。
// bad
function Test(){
track.upload('renderTrigger');// 上报渲染触发事件
return <h1>bad case</h1>
}
// good
function Test(){
useEffect(()=>{
// 就算仅执行了一次setState, current mode下该组件可能会重复渲染,
// 但react内部会保证该副作用只触发一次
track.upload('renderTrigger');
})
return <h1>bad case</h1>
}
我们首先要理解current mode原理是因为fiber架构模拟出了和整个渲染堆栈(即fiber node上存储的信息),得以有机会让react自己以组件为单位调度组件的渲染过程,可以悬停并再次进入渲染,安排优先级高的先渲染,重度渲染的组件会切片为多个时间段反复渲染,而concent的上下文本身是独立于react存在的(接入concent不需要再顶层包裹任何Provider), 只负责处理业务生成新的数据,然后按需派发给对应的实例(实例的状态本身是一个个孤岛,concent只负责同步建立起了依赖的store数据),之后就是react自己的调度流程,修改状态的函数并不会因为组件反复重入而多次执行(这点需要我们遵循不该在渲染过程中书写包含有副作用的代码原则),react仅仅是调度组件的渲染时机,而组件的中断和重入针对也是这个渲染过程。
所以以下示例代码是没问题的
const setup = (ctx)=>{
ctx.effect(()=>{
// effect是对useEffect的封装,
// 同样在current mode下该副作用也只触发一次(由react保证)
track.upload('renderTrigger');
});
}
// good
function Test2(){
useConcent({setup})
return <h1>good case</h1>
}
同样的,依赖收集在current mode
模式下,重复渲染仅仅是导致触发了多次收集,只要状态输入一样,渲染结果幂等,收集到的依赖结果也是幂等的。
// 假设这是一个渲染很耗时的组件,在current mode模式下可能会被中断渲染
function HeavyComp(){
const { state } = useConcent({module:'counter'});// 属于counter模块
// 这里读取了num 和 numBig两个值,收集到了依赖
// 即当仅当counter模块的num、numBig的发生变化时,才触发其重渲染(最终还是调用setState)
// 而counter模块的其他值发生变化时,不会触发该实例的setState
return (
<div>num: {state.num} numBig: {state.numBig}</div>
);
}
最后我们可以梳理一下,hook
本身是支持把逻辑剥离到用的自定义hook(无ui返回的函数),而其他状态管理也只是多做了一层工作,引导用户把逻辑剥离到它们的规则之下,最终还是把业务处理数据交回给react
组件调用其setState
或forceUpdate
触发重渲染,current mode
的引入并不会对现有的状态管理或者新生的状态管理方案有任何影响,仅仅是对用户的ui代码提出了更高的要求,以免因为current mode
引发难以排除的bug
为此react还特别提供了React.Strict
组件来故意触发双调用机制, https://reactjs.org/docs/stri..., 以引导用户书写更符合规范的react代码,以便适配将来提供的current mode。
react所有新特性其实都是被fiber
激活了,有了fiber
架构,衍生出了hook
、time slicing
、suspense
以及将来的Concurrent Mode
,class组件和function组件都可以在Concurrent Mode
下安全工作,只要遵循规范即可。
摘取自: https://reactjs.org/docs/stri...
Strict mode can’t automatically detect side effects for you, but it can help you spot them by making them a little more deterministic. This is done by intentionally double-invoking the following functions:
- Class component constructor, render, and shouldComponentUpdate methods
- Class component static getDerivedStateFromProps method
- Function component bodies
- State updater functions (the first argument to setState)
- Functions passed to useState, useMemo, or useReducer
所以呢,React.Strict
其实为了引导用户写能够在Concurrent Mode
里运行的代码而提供的辅助api,先让用户慢慢习惯这些限制,循序渐进一步一步来,最后再推出Concurrent Mode
。
结语
Recoil
推崇状态和派生数据更细粒度控制,写法上demo看起来简单,实际上代码规模大之后依然很繁琐。
// 定义状态
const numState = atom({key:'num', default:0});
const numBigState = atom({key:'numBig', default:100});
// 定义衍生数据
const numx2Val = selector({
key: "numx2",
get: ({ get }) => get(numState) * 2,
});
const numBigx2Val = selector({
key: "numBigx2",
get: ({ get }) => get(numBigState) * 2,
});
const numSumBigVal = selector({
key: "numSumBig",
get: ({ get }) => get(numState) + get(numBigState),
});
// ---> ui处消费状态或衍生数据
const [num] = useRecoilState(numState);
const [numBig] = useRecoilState(numBigState);
const numx2 = useRecoilValue(numx2Val);
const numBigx2 = useRecoilValue(numBigx2Val);
const numSumBig = useRecoilValue(numSumBigVal);
Concent
遵循redux
单一状态树的本质,推崇模块化管理数据以及派生数据,同时依靠Proxy
能力完成了运行时依赖收集和追求不可变的完美整合。
run({
counter: {// 声明一个counter模块
state: { num: 1, numBig: 100 }, // 定义状态
computed:{// 定义计算,参数列表里解构具体的状态时确定了依赖
numx2: ({num})=> num * 2,
numBigx2: ({numBig})=> numBig * 2,
numSumBig: ({num, numBig})=> num + numBig,
}
},
});
而类组件和函数可以用同样的方式去消费数据和绑定方法
// ###### 函数组件
function Demo(){
const { state, moduleComputed, setState } = useConcent('counter')
// ---> ui处消费状态或衍生数据,在ui处结构了才产生依赖
const { numx2, numBigx2, numSumBig} = moduleComputed;
const { num, numBig } = state;
// ... ui logic
}
// ###### 类组件
const DemoCls = register('counter')(
class DemoCls extends React.Component{
render(){
const { state, moduleComputed, setState } = this.ctx;
// ---> ui处消费状态或衍生数据,在ui处结构了才产生依赖
const { numx2, numBigx2, numSumBig} = moduleComputed;
const { num, numBig } = state;
// ... ui logic
}
}
)
所以你将获得:
- 运行时的依赖收集 ,同时也遵循react不可变的原则
- 一切皆函数(state, reducer, computed, watch, event...),能获得更友好的ts支持
- 类组件与函数组件可以共享一套model模型
- 支持中间件和插件机制,很容易兼容redux生态
- 同时支持集中与分形模块配置,同步与异步模块加载,对大型工程的弹性重构过程更加友好
❤ star me if you like concent ^_^
团队
TNTWeb - 腾讯新闻前端团队,TNTWeb致力于行业前沿技术探索和团队成员个人能力提升。为前端开发人员整理出了小程序以及web前端技术领域的最新优质内容,每周更新✨,欢迎star,github地址:https://github.com/tnfe/TNT-Weekly
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。