2020-7-3 seo達人
序言
之前發(fā)表了一篇文章 redux、mobx、concent特性大比拼, 看后生如何對局前輩,吸引了不少感興趣的小伙伴入群開始了解和使用 concent,并獲得了很多正向的反饋,實實在在的幫助他們提高了開發(fā)體驗,群里人數(shù)雖然還很少,但大家熱情高漲,技術討論氛圍濃厚,對很多新鮮技術都有保持一定的敏感度,如上個月開始逐漸被提及得越來越多的出自facebook的狀態(tài)管理方案 recoil,雖然還處于實驗狀態(tài),但是相必大家已經(jīng)私底下開始欲欲躍試了,畢竟出生名門,有fb背書,一定會大放異彩。
不過當我體驗完recoil后,我對其中標榜的更新保持了懷疑態(tài)度,有一些誤導的嫌疑,這一點下文會單獨分析,是否屬于誤導讀者在讀完本文后自然可以得出結論,總之本文主要是分析Concent與Recoil的代碼風格差異性,并探討它們對我們將來的開發(fā)模式有何新的影響,以及思維上需要做什么樣的轉變。
數(shù)據(jù)流方案之3大流派
目前主流的數(shù)據(jù)流方案按形態(tài)都可以劃分以下這三類
redux流派
redux、和基于redux衍生的其他作品,以及類似redux思路的作品,代表作有dva、rematch等等。
mobx流派
借助definePerperty和Proxy完成數(shù)據(jù)劫持,從而達到響應式編程目的的代表,類mobx的作品也有不少,如dob等。
Context流派
這里的Context指的是react自帶的Context api,基于Context api打造的數(shù)據(jù)流方案通常主打輕量、易用、概覽少,代表作品有unstated、constate等,大多數(shù)作品的核心代碼可能不超過500行。
到此我們看看Recoil應該屬于哪一類?很顯然按其特征屬于Context流派,那么我們上面說的主打輕量對
Recoil并不適用了,打開其源碼庫發(fā)現(xiàn)代碼并不是幾百行完事的,所以基于Context api做得好用且強大就未必輕量,由此看出facebook對Recoil是有野心并給予厚望的。
我們同時也看看Concent屬于哪一類呢?Concent在v2版本之后,重構數(shù)據(jù)追蹤機制,啟用了defineProperty和Proxy特性,得以讓react應用既保留了不可變的追求,又享受到了運行時依賴收集和ui更新的性能提升福利,既然啟用了defineProperty和Proxy,那么看起來Concent應該屬于mobx流派?
事實上Concent屬于一種全新的流派,不依賴react的Context api,不破壞react組件本身的形態(tài),保持追求不可變的哲學,僅在react自身的渲染調度機制之上建立一層邏輯層狀態(tài)分發(fā)調度機制,defineProperty和Proxy只是用于輔助收集實例和衍生數(shù)據(jù)對模塊數(shù)據(jù)的依賴,而修改數(shù)據(jù)入口還是setState(或基于setState封裝的dispatch, invoke, sync),讓Concent可以0入侵的接入react應用,真正的即插即用和無感知接入。
即插即用的核心原理是,Concent自建了一個平行于react運行時的全局上下文,精心維護這模塊與實例之間的歸屬關系,同時接管了組件實例的更新入口setState,保留原始的setState為reactSetState,所有當用戶調用setState時,concent除了調用reactSetState更新當前實例ui,同時智能判斷提交的狀態(tài)是否也還有別的實例關心其變化,然后一并拿出來依次執(zhí)行這些實例的reactSetState,進而達到了狀態(tài)全部同步的目的。
Recoil初體驗
我們以常用的counter來舉例,熟悉一下Recoil暴露的四個高頻使用的api
atom,定義狀態(tài)
selector, 定義派生數(shù)據(jù)
useRecoilState,消費狀態(tài)
useRecoilValue,消費派生數(shù)據(jù)
定義狀態(tài)
外部使用atom接口,定義一個key為num,初始值為0的狀態(tài)
const numState = atom({
key: "num",
default: 0
});
定義派生數(shù)據(jù)
外部使用selector接口,定義一個key為numx10,初始值是依賴numState再次計算而得到
const numx10Val = selector({
key: "numx10",
get: ({ get }) => {
const num = get(numState);
return num * 10;
}
});
定義異步的派生數(shù)據(jù)
selector的get支持定義異步函數(shù)
需要注意的點是,如果有依賴,必需先書寫好依賴在開始執(zhí)行異步邏輯
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;
}
});
消費狀態(tài)
組件里使用useRecoilState接口,傳入想要獲去的狀態(tài)(由atom創(chuàng)建而得)
const NumView = () => {
const [num, setNum] = useRecoilState(numState);
const add = ()=>setNum(num+1);
return (
<div>
{num}<br/>
<button onClick={add}>add</button>
</div>
);
}
消費派生數(shù)據(jù)
組件里使用useRecoilValue接口,傳入想要獲去的派生數(shù)據(jù)(由selector創(chuàng)建而得),同步派生數(shù)據(jù)和異步派生數(shù)據(jù),皆可通過此接口獲得
const NumValView = () => {
const numx10 = useRecoilValue(numx10Val);
const asyncNumx10 = useRecoilValue(asyncNumx10Val);
return (
<div>
numx10 :{numx10}<br/>
</div>
);
};
渲染它們查看結果
暴露定義好的這兩個組件, 查看在線示例
export default ()=>{
return (
<>
<NumView />
<NumValView />
</>
);
};
頂層節(jié)點包裹React.Suspense和RecoilRoot,前者用于配合異步計算函數(shù)需要,后者用于注入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文檔(還在持續(xù)建設中...),可能部分人會認為api太多,難于記住,其實大部分都是可選的語法糖,我們以counter為例,只需要使用到以下兩個api即可
run,定義模塊狀態(tài)(必需)、模塊計算(可選)、模塊觀察(可選)
運行run接口后,會生成一份concent全局上下文
setState,修改狀態(tài)
定義狀態(tài)&修改狀態(tài)
以下示例我們先脫離ui,直接完成定義狀態(tài)&修改狀態(tài)的目的
import { run, setState, getState } from "concent";
run({
counter: {// 聲明一個counter模塊
state: { num: 1 }, // 定義狀態(tài)
}
});
console.log(getState('counter').num);// log: 1
setState('counter', {num:10});// 修改counter模塊的num值為10
console.log(getState('counter').num);// log: 10
我們可以看到,此處和redux很類似,需要定義一個單一的狀態(tài)樹,同時第一層key就引導用戶將數(shù)據(jù)模塊化管理起來.
引入reducer
上述示例中我們直接掉一個呢setState修改數(shù)據(jù),但是真實的情況是數(shù)據(jù)落地前有很多同步的或者異步的業(yè)務邏輯操作,所以我們對模塊填在reducer定義,用來聲明修改數(shù)據(jù)的方法集合。
import { run, dispatch, getState } from "concent";
const delay = () => new Promise(r => setTimeout(r, 1000));
const state = () => ({ num: 1 });// 狀態(tài)聲明
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來觸發(fā)修改狀態(tài)的方法
因dispatch會返回一個Promise,所以我們需要用一個async 包裹起來執(zhí)行代碼
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調用時基于字符串匹配方式,之所以保留這樣的調用方式是為了照顧需要動態(tài)調用的場景,其實更推薦的寫法是
import { dispatch } from "concent";
(async ()=>{
console.log(getState("counter").num);// log 1
await dispatch(reducer.inc);// 同步修改
console.log(getState("counter").num);// log 2
await dispatch(reducer.asyncInc);// 異步修改
console.log(getState("counter").num);// log 3
})()
接入react
上述示例主要演示了如何定義狀態(tài)和修改狀態(tài),那么接下來我們需要用到以下兩個api來幫助react組件生成實例上下文(等同于與vue 3 setup里提到的渲染上下文),以及獲得消費concent模塊數(shù)據(jù)的能力
register, 注冊類組件為concent組件
useConcent, 注冊函數(shù)組件為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>
);
}
注意到兩種寫法區(qū)別很小,除了組件的定義方式不一樣,其實渲染邏輯和數(shù)據(jù)來源都一模一樣。
渲染它們查看結果
在線示例
const rootElement = document.getElementById("root");
ReactDOM.render(
<React.StrictMode>
<div>
<ClsComp />
<FnComp />
</div>
</React.StrictMode>,
rootElement
);
對比Recoil,我們發(fā)現(xiàn)沒有頂層并沒有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.asynCtx()
// --------- 對于函數(shù)組件 -----------
const { state, mr } = useConcent("counter");// useConcent 返回的就是ctx
const changeNum = () => mr.inc(20);// or ctx.mr.asynCtx()
異步計算函數(shù)
run接口里支持擴展computed屬性,即讓用戶定義一堆衍生數(shù)據(jù)的計算函數(shù)集合,它們可以是同步的也可以是異步的,同時支持一個函數(shù)用另一個函數(shù)的輸出作為輸入來做二次計算,計算的輸入依賴是自動收集到的。
const computed = {// 定義計算函數(shù)集合
numx10({ num }) {
return num * 10;
},
// n:newState, o:oldState, f:fnCtx
// 結構出num,表示當前計算依賴是num,僅當num發(fā)生變化時觸發(fā)此函數(shù)重計算
async numx10_2({ num }, o, f) {
// 必需調用setInitialVal給numx10_2一個初始值,
// 該函數(shù)僅在初次computed觸發(fā)時執(zhí)行一次
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 }
});
上述計算函數(shù)里,我們刻意讓numx10_3在某個時候報錯,對于此錯誤,我們可以在run接口的第二位options配置里定義errorHandler來捕捉。
run({/**storeConfig*/}, {
errorHandler: (err)=>{
alert(err.message);
}
})
當然更好的做法,利用concent-plugin-async-computed-status插件來完成對所有模塊計算函數(shù)執(zhí)行狀態(tài)的統(tǒng)一管理。
import cuStatusPlugin from "concent-plugin-async-computed-status";
run(
{/**storeConfig*/},
{
errorHandler: err => {
console.error('errorHandler ', err);
// alert(err.message);
},
plugins: [cuStatusPlugin], // 配置異步計算函數(shù)執(zhí)行狀態(tài)管理插件
}
);
該插件會自動向concent配置一個cuStatus模塊,方便組件連接到它,消費相關計算函數(shù)的執(zhí)行狀態(tài)數(shù)據(jù)
function Test() {
const { moduleComputed, connectedState, setState, state, ccUniqueKey } = useConcent({
module: "counter",// 屬于counter模塊,狀態(tài)直接從state獲得
connect: ["cuStatus"],// 連接到cuStatus模塊,狀態(tài)從connectedState.{$moduleName}獲得
});
const changeNum = () => setState({ num: state.num + 1 });
// 獲得counter模塊的計算函數(shù)執(zhí)行狀態(tài)
const counterCuStatus = connectedState.cuStatus.counter;
// 當然,可以更細粒度的獲得指定結算函數(shù)的執(zhí)行狀態(tài)
// 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>
);
}
![]https://raw.githubusercontent...
查看在線示例
更新
開篇我說對Recoli提到的更新保持了懷疑態(tài)度,有一些誤導的嫌疑,此處我們將揭開疑團
大家知道hook使用規(guī)則是不能寫在條件控制語句里的,這意味著下面語句是不允許的
const NumView = () => {
const [show, setShow] = useState(true);
if(show){// error
const [num, setNum] = useRecoilState(numState);
}
}
所以用戶如果ui渲染里如果某個狀態(tài)用不到此數(shù)據(jù)時,某處改變了num值依然會觸發(fā)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渲染完畢后,有發(fā)生改變時需要做其他事的需求,類似useEffect的效果,concent也支持用戶將其抽到setup里,定義effect來完成此場景訴求,相比useEffect,setup里的ctx.effect只需定義一次,同時只需傳遞key名稱,concent會自動對比前一刻和當前刻的值來決定是否要觸發(fā)副作用函數(shù)。
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%完全支持的,或者進一步說,所有狀態(tài)管理工具,最終觸發(fā)的都是setState或forceUpdate,我們只要在渲染過程中不要寫具有任何副作用的代碼,讓相同的狀態(tài)輸入得到的渲染結果冪,即是在current mode下運行安全的代碼。
current mode只是對我們的代碼提出了更苛刻的要求。
// bad
function Test(){
track.upload('renderTrigger');// 上報渲染觸發(fā)事件
return <h1>bad case</h1>
}
// good
function Test(){
useEffect(()=>{
// 就算僅執(zhí)行了一次setState, current mode下該組件可能會重復渲染,
// 但react內部會保證該副作用只觸發(fā)一次
track.upload('renderTrigger');
})
return <h1>bad case</h1>
}
我們首先要理解current mode原理是因為fiber架構模擬出了和整個渲染堆棧(即fiber node上存儲的信息),得以有機會讓react自己以組件為單位調度組件的渲染過程,可以懸停并再次進入渲染,安排優(yōu)先級高的先渲染,重度渲染的組件會切片為多個時間段反復渲染,而concent的上下文本身是獨立于react存在的(接入concent不需要再頂層包裹任何Provider), 只負責處理業(yè)務生成新的數(shù)據(jù),然后按需派發(fā)給對應的實例(實例的狀態(tài)本身是一個個孤島,concent只負責同步建立起了依賴的store的數(shù)據(jù)),之后就是react自己的調度流程,修改狀態(tài)的函數(shù)并不會因為組件反復重入而多次執(zhí)行(這點需要我們遵循不該在渲染過程中書寫包含有副作用的代碼原則),react僅僅是調度組件的渲染時機,而組件的中斷和重入針對也是這個渲染過程。
所以同樣的,對于concent
const setup = (ctx)=>{
ctx.effect(()=>{
// effect是對useEffect的封裝,
// 同樣在current mode下該副作用也只觸發(fā)一次(由react保證)
track.upload('renderTrigger');
});
}
// good
function Test2(){
useConcent({setup})
return <h1>good case</h1>
}
同樣的,依賴收集在current mode模式下,重復渲染僅僅是導致觸發(fā)了多次收集,只要狀態(tài)輸入一樣,渲染結果冪等,收集到的依賴結果也是冪等的。
// 假設這是一個渲染很耗時的組件,在current mode模式下可能會被中斷渲染
function HeavyComp(){
const { state } = useConcent({module:'counter'});// 屬于counter模塊
// 這里讀取了num 和 numBig兩個值,收集到了依賴
// 即當僅當counter模塊的num、numBig的發(fā)生變化時,才觸發(fā)其重渲染(最終還是調用setState)
// 而counter模塊的其他值發(fā)生變化時,不會觸發(fā)該實例的setState
return (
<div>num: {state.num} numBig: {state.numBig}</div>
);
}
最后我們可以梳理一下,hook本身是支持把邏輯剝離到用的自定義hook(無ui返回的函數(shù)),而其他狀態(tài)管理也只是多做了一層工作,引導用戶把邏輯剝離到它們的規(guī)則之下,最終還是把業(yè)務處理數(shù)據(jù)交回給react組件調用其setState或forceUpdate觸發(fā)重渲染,current mode的引入并不會對現(xiàn)有的狀態(tài)管理或者新生的狀態(tài)管理方案有任何影響,僅僅是對用戶的ui代碼提出了更高的要求,以免因為current mode引發(fā)難以排除的bug
為此react還特別提供了React.Strict組件來故意觸發(fā)雙調用機制, https://reactjs.org/docs/stri... 以引導用戶書寫更符合規(guī)范的react代碼,以便適配將來提供的current mode。
react所有新特性其實都是被fiber激活了,有了fiber架構,衍生出了hook、time slicing、suspense以及將來的Concurrent Mode,class組件和function組件都可以在Concurrent Mode下安全工作,只要遵循規(guī)范即可。
摘取自: 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推崇狀態(tài)和派生數(shù)據(jù)更細粒度控制,寫法上demo看起來簡單,實際上代碼規(guī)模大之后依然很繁瑣。
// 定義狀態(tài)
const numState = atom({key:'num', default:0});
const numBigState = atom({key:'numBig', default:100});
// 定義衍生數(shù)據(jù)
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處消費狀態(tài)或衍生數(shù)據(jù)
const [num] = useRecoilState(numState);
const [numBig] = useRecoilState(numBigState);
const numx2 = useRecoilValue(numx2Val);
const numBigx2 = useRecoilValue(numBigx2Val);
const numSumBig = useRecoilValue(numSumBigVal);
Concent遵循redux單一狀態(tài)樹的本質,推崇模塊化管理數(shù)據(jù)以及派生數(shù)據(jù),同時依靠Proxy能力完成了運行時依賴收集和追求不可變的完美整合。
run({
counter: {// 聲明一個counter模塊
state: { num: 1, numBig: 100 }, // 定義狀態(tài)
computed:{// 定義計算,參數(shù)列表里解構具體的狀態(tài)時確定了依賴
numx2: ({num})=> num * 2,
numBigx2: ({numBig})=> numBig * 2,
numSumBig: ({num, numBig})=> num + numBig,
}
},
});
// ---> ui處消費狀態(tài)或衍生數(shù)據(jù),在ui處結構了才產(chǎn)生依賴
const { state, moduleComputed, setState } = useConcent('counter')
const { numx2, numBigx2, numSumBig} = moduleComputed;
const { num, numBig } = state;
所以你將獲得:
運行時的依賴收集 ,同時也遵循react不可變的原則
一切皆函數(shù)(state, reducer, computed, watch, event...),能獲得更友好的ts支持
支持中間件和插件機制,很容易兼容redux生態(tài)
同時支持集中與分形模塊配置,同步與異步模塊加載,對大型工程的彈性重構過程更加友好
藍藍設計( m.sillybuy.com )是一家專注而深入的界面設計公司,為期望卓越的國內外企業(yè)提供卓越的UI界面設計、BS界面設計 、 cs界面設計 、 ipad界面設計 、 包裝設計 、 圖標定制 、 用戶體驗 、交互設計、 網(wǎng)站建設 、平面設計服務