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: 1setState('counter', {num:10});// 批改counter模块的num值为10console.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 返回的就是ctxconst 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/>}
更多对于effect与useEffect请查看此文
current mode
对于concent是否反对current mode
这个疑难呢,这里先说答案,concent
是100%齐全反对的,或者进一步说,所有状态管理工具,最终触发的都是setState
或forceUpdate
,咱们只有在渲染过程中不要写具备任何副作用的代码,让雷同的状态输出失去的渲染后果幂,即是在current mode
下运行平安的代码。
current mode
只是对咱们的代码提出了更刻薄的要求。
// badfunction Test(){ track.upload('renderTrigger');// 上报渲染触发事件 return <h1>bad case</h1>}// goodfunction 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'); });}// goodfunction 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