共计 12744 个字符,预计需要花费 32 分钟才能阅读完成。
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/>
}
更多对于 effect 与 useEffect 请查看此文
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