react 哲学_细聊Concent amp; Recoil , 探索react数据流的新开发模式
開源不易,感謝你的支持,? star me if you like concent ^_^
序言
之前發(fā)表了一篇文章 redux、mobx、concent特性大比拼, 看后生如何對局前輩,吸引了不少感興趣的小伙伴入群開始了解和使用 concent,并獲得了很多正向的反饋,實實在在的幫助他們提高了開發(fā)體驗,群里人數(shù)雖然還很少,但大家熱情高漲,技術(shù)討論氛圍濃厚,對很多新鮮技術(shù)都有保持一定的敏感度,如上個月開始逐漸被提及得越來越多的出自facebook的最新狀態(tài)管理方案 recoil,雖然還處于實驗狀態(tài),但是相必大家已經(jīng)私底下開始欲欲躍試了,畢竟出生名門,有fb背書,一定會大放異彩。
不過當(dāng)我體驗完recoil后,我對其中標(biāo)榜的精確更新保持了懷疑態(tài)度,有一些誤導(dǎo)的嫌疑,這一點下文會單獨分析,是否屬于誤導(dǎo)讀者在讀完本文后自然可以得出結(jié)論,總之本文主要是分析Concent與Recoil的代碼風(fēng)格差異性,并探討它們對我們將來的開發(fā)模式有何新的影響,以及思維上需要做什么樣的轉(zhuǎn)變。
數(shù)據(jù)流方案之3大流派
目前主流的數(shù)據(jù)流方案按形態(tài)都可以劃分以下這三類
- redux流派
redux、和基于redux衍生的其他作品,以及類似redux思路的作品,代表作有dva、rematch等等。 - mobx流派
借助definePerperty和Proxy完成數(shù)據(jù)劫持,從而達(dá)到響應(yīng)式編程目的的代表,類mobx的作品也有不少,如dob等。 - Context流派
這里的Context指的是react自帶的Context api,基于Context api打造的數(shù)據(jù)流方案通常主打輕量、易用、概覽少,代表作品有unstated、constate等,大多數(shù)作品的核心代碼可能不超過500行。
到此我們看看Recoil應(yīng)該屬于哪一類?很顯然按其特征屬于Context流派,那么我們上面說的主打輕量對 Recoil并不適用了,打開其源碼庫發(fā)現(xiàn)代碼并不是幾百行完事的,所以基于Context api做得好用且強大就未必輕量,由此看出facebook對Recoil是有野心并給予厚望的。
我們同時也看看Concent屬于哪一類呢?Concent在v2版本之后,重構(gòu)數(shù)據(jù)追蹤機制,啟用了defineProperty和Proxy特性,得以讓react應(yīng)用既保留了不可變的追求,又享受到了運行時依賴收集和ui精確更新的性能提升福利,既然啟用了defineProperty和Proxy,那么看起來Concent應(yīng)該屬于mobx流派?
事實上Concent屬于一種全新的流派,不依賴react的Context api,不破壞react組件本身的形態(tài),保持追求不可變的哲學(xué),僅在react自身的渲染調(diào)度機制之上建立一層邏輯層狀態(tài)分發(fā)調(diào)度機制,defineProperty和Proxy只是用于輔助收集實例和衍生數(shù)據(jù)對模塊數(shù)據(jù)的依賴,而修改數(shù)據(jù)入口還是setState(或基于setState封裝的dispatch, invoke, sync),讓Concent可以0入侵的接入react應(yīng)用,真正的即插即用和無感知接入。
即插即用的核心原理是,Concent自建了一個平行于react運行時的全局上下文,精心維護(hù)這模塊與實例之間的歸屬關(guān)系,同時接管了組件實例的更新入口setState,保留原始的setState為reactSetState,所有當(dāng)用戶調(diào)用setState時,concent除了調(diào)用reactSetState更新當(dāng)前實例ui,同時智能判斷提交的狀態(tài)是否也還有別的實例關(guān)心其變化,然后一并拿出來依次執(zhí)行這些實例的reactSetState,進(jìn)而達(dá)到了狀態(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>); };渲染它們查看結(jié)果
暴露定義好的這兩個組件, 查看在線示例
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ù)建設(shè)中...),可能部分人會認(rèn)為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就引導(dǎo)用戶將數(shù)據(jù)模塊化管理起來.
引入reducer
上述示例中我們直接掉一個呢setState修改數(shù)據(jù),但是真實的情況是數(shù)據(jù)落地前有很多同步的或者異步的業(yè)務(wù)邏輯操作,所以我們對模塊填在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 1await dispatch("counter/inc");// 同步修改console.log(getState("counter").num);// log 2await dispatch("counter/asyncInc");// 異步修改console.log(getState("counter").num);// log 3 })()注意dispatch調(diào)用時基于字符串匹配方式,之所以保留這樣的調(diào)用方式是為了照顧需要動態(tài)調(diào)用的場景,其實更推薦的寫法是
import { dispatch } from "concent";await dispatch("counter/inc"); // 修改為 await dispatch(reducer.inc);其實run接口定義的reducer集合已被concent集中管理起來,并允許用戶以reducer.${moduleName}.${methodName}的方式調(diào)用,所以這里我們甚至可以基于reducer發(fā)起調(diào)用
import { reducer as ccReducer } from 'concent';await dispatch(reducer.inc); // 修改為 await ccReducer.counter.inc();接入react
上述示例主要演示了如何定義狀態(tài)和修改狀態(tài),那么接下來我們需要用到以下兩個api來幫助react組件生成實例上下文(等同于與vue 3 setup里提到的渲染上下文),以及獲得消費concent模塊數(shù)據(jù)的能力
- register, 注冊類組件為concent組件
- useConcent, 注冊函數(shù)組件為concent組件
注意到兩種寫法區(qū)別很小,除了組件的定義方式不一樣,其實渲染邏輯和數(shù)據(jù)來源都一模一樣。
渲染它們查看結(jié)果
在線示例
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一致的寫法。
組件調(diào)用reducer
concent為每一個組件實例都生成了實例上下文,方便用戶直接通過ctx.mr調(diào)用reducer方法
mr 為 moduleReducer的簡寫,直接書寫為ctx.moduleReducer也是合法的// --------- 對于類組件 ----------- changeNum = () => this.setState({ num: 10 }) // ===> 修改為 changeNum = () => this.ctx.mr.inc(10);// or this.ctx.mr.asynInc(10)//當(dāng)然這里也可以寫為ctx.dispatch調(diào)用,不過更推薦用上面的moduleReducer直接調(diào)用 //this.ctx.dispatch('inc', 10); // or this.ctx.dispatch('asynInc', 10)// --------- 對于函數(shù)組件 ----------- const { state, mr } = useConcent("counter");// useConcent 返回的就是ctx const changeNum = () => mr.inc(20); // or ctx.mr.asynInc(10)//對于函數(shù)組將同樣支持dispatch調(diào)用方式 //ctx.dispatch('inc', 10); // or ctx.dispatch('asynInc', 10)異步計算函數(shù)
run接口里支持?jǐn)U展computed屬性,即讓用戶定義一堆衍生數(shù)據(jù)的計算函數(shù)集合,它們可以是同步的也可以是異步的,同時支持一個函數(shù)用另一個函數(shù)的輸出作為輸入來做二次計算,計算的輸入依賴是自動收集到的。
const computed = {// 定義計算函數(shù)集合numx10({ num }) {return num * 10;},// n:newState, o:oldState, f:fnCtx// 結(jié)構(gòu)出num,表示當(dāng)前計算依賴是num,僅當(dāng)num發(fā)生變化時觸發(fā)此函數(shù)重計算async numx10_2({ num }, o, f) {// 必需調(diào)用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);} })當(dāng)然更好的做法,利用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模塊,方便組件連接到它,消費相關(guān)計算函數(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;// 當(dāng)然,可以更細(xì)粒度的獲得指定結(jié)算函數(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'}{/** 此處拿到錯誤可以用于渲染,當(dāng)然也拋出去 */}{/** 讓ErrorBoundary之類的組件捕捉并渲染降級頁面 */}{counterCuStatus.err ? counterCuStatus.err.message : ''}<br />{moduleComputed.numx10_2}<br />{moduleComputed.numx10_3}<br /><button onClick={changeNum}>changeNum</button></div>); }查看在線示例
精確更新
開篇我說對Recoli提到的精確更新保持了懷疑態(tài)度,有一些誤導(dǎo)的嫌疑,此處我們將揭開疑團(tuán)
大家知道hook使用規(guī)則是不能寫在條件控制語句里的,這意味著下面語句是不允許的
const NumView = () => {const [show, setShow] = useState(true);if(show){// errorconst [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時,當(dāng)前實例的渲染對state.num的渲染有依賴return {show ? <h1>{state.num}</h1> : 'nothing'} }點我查看代碼示例
當(dāng)然如果用戶對num值有ui渲染完畢后,有發(fā)生改變時需要做其他事的需求,類似useEffect的效果,concent也支持用戶將其抽到setup里,定義effect來完成此場景訴求,相比useEffect,setup里的ctx.effect只需定義一次,同時只需傳遞key名稱,concent會自動對比前一刻和當(dāng)前刻的值來決定是否要觸發(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/> }更多關(guān)于effect與useEffect請查看此文
current mode
關(guān)于concent是否支持current mode這個疑問呢,這里先說答案,concent是100%完全支持的,或者進(jìn)一步說,所有狀態(tài)管理工具,最終觸發(fā)的都是setState或forceUpdate,我們只要在渲染過程中不要寫具有任何副作用的代碼,讓相同的狀態(tài)輸入得到的渲染結(jié)果冪,即是在current mode下運行安全的代碼。
current mode只是對我們的代碼提出了更苛刻的要求。
我們首先要理解current mode原理是因為fiber架構(gòu)模擬出了和整個渲染堆棧(即fiber node上存儲的信息),得以有機會讓react自己以**組件**為單位調(diào)度組件的渲染過程,可以懸停并再次進(jìn)入渲染,安排優(yōu)先級高的先渲染,重度渲染的組件會**切片**為多個時間段反復(fù)渲染,而concent的上下文本身是獨立于react存在的(接入concent不需要再頂層包裹任何Provider), 只負(fù)責(zé)處理業(yè)務(wù)生成新的數(shù)據(jù),然后按需派發(fā)給對應(yīng)的實例(實例的狀態(tài)本身是一個個孤島,concent只負(fù)責(zé)同步建立起了依賴的store的數(shù)據(jù)),之后就是react自己的調(diào)度流程,修改狀態(tài)的函數(shù)并不會因為組件反復(fù)重入而多次執(zhí)行(這點需要我們遵循不該在渲染過程中書寫包含有副作用的代碼原則),react僅僅是調(diào)度組件的渲染時機,而組件的**中斷**和**重入**針對也是這個渲染過程.
所以同樣的,對于concent
const setup = (ctx)=>{ctx.effect(()=>{// effect是對useEffect的封裝,// 同樣在current mode下該副作用也只觸發(fā)一次(由react保證)track.upload('renderTrigger');}); }// good function Test2(){useConcent({setup})return <h1>bad case</h1> }同樣的,依賴收集在`current mode`模式下,重復(fù)渲染僅僅是導(dǎo)致觸發(fā)了多次收集,只要狀態(tài)輸入一樣,渲染結(jié)果冪等,收集到的依賴結(jié)果也是冪等的。
// 假設(shè)這是一個渲染很耗時的組件,在current mode模式下可能會被中斷渲染 function HeavyComp(){const { state } = useConcent({module:'counter'});// 屬于counter模塊// 這里讀取了num 和 numBig兩個值,收集到了依賴// 即當(dāng)僅當(dāng)counter模塊的num、numBig的發(fā)生變化時,才觸發(fā)其重渲染(最終還是調(diào)用setState)// 而counter模塊的其他值發(fā)生變化時,不會觸發(fā)該實例的setStatereturn (<div>num: {state.num} numBig: {state.numBig}</div>); }最后我們可以梳理一下,`hook`本身是支持把邏輯剝離到用的自定義hook(無ui返回的函數(shù)),而其他狀態(tài)管理也只是多做了一層工作,引導(dǎo)用戶把邏輯剝離到它們的規(guī)則之下,最終還是把業(yè)務(wù)處理數(shù)據(jù)交回給`react`組件調(diào)用其`setState`或`forceUpdate`觸發(fā)重渲染,`current mode`的引入并不會對現(xiàn)有的狀態(tài)管理或者新生的狀態(tài)管理方案有任何影響,僅僅是對用戶的ui代碼提出了更高的要求,以免因為`current mode`引發(fā)難以排除的bug
為此react還特別提供了`React.Strict`組件來故意觸發(fā)雙調(diào)用機制, https://reactjs.org/docs/strict-mode.html#detecting-unexpected-side-effects, 以引導(dǎo)用戶書寫更符合規(guī)范的react代碼,以便適配將來提供的current mode。
react所有新特性其實都是被`fiber`激活了,有了`fiber`架構(gòu),衍生出了`hook`、`time slicing`、`suspense`以及將來的`Concurrent Mode`,class組件和function組件都可以在`Concurrent Mode`下安全工作,只要遵循規(guī)范即可。
摘取自: https://reactjs.org/docs/strict-mode.html#detecting-unexpected-side-effects
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`其實為了引導(dǎo)用戶寫能夠在`Concurrent Mode`里運行的代碼而提供的輔助api,先讓用戶慢慢習(xí)慣這些限制,循序漸進(jìn)一步一步來,最后再推出`Concurrent Mode`。
結(jié)語
Recoil推崇狀態(tài)和派生數(shù)據(jù)更細(xì)粒度控制,寫法上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)樹的本質(zhì),推崇模塊化管理數(shù)據(jù)以及派生數(shù)據(jù),同時依靠Proxy能力完成了運行時依賴收集和追求不可變的完美整合。
run({counter: {// 聲明一個counter模塊state: { num: 1, numBig: 100 }, // 定義狀態(tài)computed:{// 定義計算,參數(shù)列表里解構(gòu)具體的狀態(tài)時確定了依賴numx2: ({num})=> num * 2,numBigx2: ({numBig})=> numBig * 2,numSumBig: ({num, numBig})=> num + numBig,}}, });// ---> ui處消費狀態(tài)或衍生數(shù)據(jù),在ui處結(jié)構(gòu)了才產(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)
- 同時支持集中與分形模塊配置,同步與異步模塊加載,對大型工程的彈性重構(gòu)過程更加友好
最后解答一下關(guān)于concent是否支持current mode的疑惑,先上結(jié)論,100%支持。
我們首先要理解current mode原理是因為fiber架構(gòu)模擬出了和整個渲染堆棧(即fiber node上存儲的信息),得以有機會讓react自己以組件為單位調(diào)度組件的渲染過程,可以懸停并再次進(jìn)入渲染,安排優(yōu)先級高的先渲染,重度渲染的組件會切片為多個時間段反復(fù)渲染,而concent的上下文本身是獨立于react存在的(接入concent不需要再頂層包裹任何Provider), 只負(fù)責(zé)處理業(yè)務(wù)生成新的數(shù)據(jù),然后按需派發(fā)給對應(yīng)的實例,之后就是react自己的調(diào)度流程,修改狀態(tài)的函數(shù)并不會因為組件反復(fù)重入而多次執(zhí)行(這點需要我們遵循不該在渲染過程中書寫包含有副作用的代碼原則),react僅僅是調(diào)度組件的渲染時機。
? star me if you like concent ^_^
Edit on CodeSandbox
Edit on StackBlitz
如果有關(guān)于concent的疑問,可以掃碼加群咨詢,會盡力答疑解惑,幫助你了解更多,里面的不少小伙伴都變成老司機了,用過之后都表示非常happy,客官上船試試便知 。
總結(jié)
以上是生活随笔為你收集整理的react 哲学_细聊Concent amp; Recoil , 探索react数据流的新开发模式的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 对标苹果的它苹果12对标手机
- 下一篇: c罗的车(c罗的老婆)