react 哲学_细聊Concent amp; Recoil , 探索react数据流的新开发模式
開源不易,感謝你的支持,? star me if you like concent ^_^
序言
之前發表了一篇文章 redux、mobx、concent特性大比拼, 看后生如何對局前輩,吸引了不少感興趣的小伙伴入群開始了解和使用 concent,并獲得了很多正向的反饋,實實在在的幫助他們提高了開發體驗,群里人數雖然還很少,但大家熱情高漲,技術討論氛圍濃厚,對很多新鮮技術都有保持一定的敏感度,如上個月開始逐漸被提及得越來越多的出自facebook的最新狀態管理方案 recoil,雖然還處于實驗狀態,但是相必大家已經私底下開始欲欲躍試了,畢竟出生名門,有fb背書,一定會大放異彩。
不過當我體驗完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 1await dispatch("counter/inc");// 同步修改console.log(getState("counter").num);// log 2await 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組件
注意到兩種寫法區別很小,除了組件的定義方式不一樣,其實渲染邏輯和數據來源都一模一樣。
渲染它們查看結果
在線示例
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){// errorconst [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只是對我們的代碼提出了更苛刻的要求。
我們首先要理解current mode原理是因為fiber架構模擬出了和整個渲染堆棧(即fiber node上存儲的信息),得以有機會讓react自己以**組件**為單位調度組件的渲染過程,可以懸停并再次進入渲染,安排優先級高的先渲染,重度渲染的組件會**切片**為多個時間段反復渲染,而concent的上下文本身是獨立于react存在的(接入concent不需要再頂層包裹任何Provider), 只負責處理業務生成新的數據,然后按需派發給對應的實例(實例的狀態本身是一個個孤島,concent只負責同步建立起了依賴的store的數據),之后就是react自己的調度流程,修改狀態的函數并不會因為組件反復重入而多次執行(這點需要我們遵循不該在渲染過程中書寫包含有副作用的代碼原則),react僅僅是調度組件的渲染時機,而組件的**中斷**和**重入**針對也是這個渲染過程.
所以同樣的,對于concent
const setup = (ctx)=>{ctx.effect(()=>{// effect是對useEffect的封裝,// 同樣在current mode下該副作用也只觸發一次(由react保證)track.upload('renderTrigger');}); }// good function Test2(){useConcent({setup})return <h1>bad case</h1> }同樣的,依賴收集在`current mode`模式下,重復渲染僅僅是導致觸發了多次收集,只要狀態輸入一樣,渲染結果冪等,收集到的依賴結果也是冪等的。
// 假設這是一個渲染很耗時的組件,在current mode模式下可能會被中斷渲染 function HeavyComp(){const { state } = useConcent({module:'counter'});// 屬于counter模塊// 這里讀取了num 和 numBig兩個值,收集到了依賴// 即當僅當counter模塊的num、numBig的發生變化時,才觸發其重渲染(最終還是調用setState)// 而counter模塊的其他值發生變化時,不會觸發該實例的setStatereturn (<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/strict-mode.html#detecting-unexpected-side-effects, 以引導用戶書寫更符合規范的react代碼,以便適配將來提供的current mode。
react所有新特性其實都是被`fiber`激活了,有了`fiber`架構,衍生出了`hook`、`time slicing`、`suspense`以及將來的`Concurrent Mode`,class組件和function組件都可以在`Concurrent Mode`下安全工作,只要遵循規范即可。
摘取自: 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`其實為了引導用戶寫能夠在`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,}}, });// ---> ui處消費狀態或衍生數據,在ui處結構了才產生依賴 const { state, moduleComputed, setState } = useConcent('counter') const { numx2, numBigx2, numSumBig} = moduleComputed; const { num, numBig } = state;所以你將獲得:
- 運行時的依賴收集 ,同時也遵循react不可變的原則
- 一切皆函數(state, reducer, computed, watch, event...),能獲得更友好的ts支持
- 支持中間件和插件機制,很容易兼容redux生態
- 同時支持集中與分形模塊配置,同步與異步模塊加載,對大型工程的彈性重構過程更加友好
最后解答一下關于concent是否支持current mode的疑惑,先上結論,100%支持。
我們首先要理解current mode原理是因為fiber架構模擬出了和整個渲染堆棧(即fiber node上存儲的信息),得以有機會讓react自己以組件為單位調度組件的渲染過程,可以懸停并再次進入渲染,安排優先級高的先渲染,重度渲染的組件會切片為多個時間段反復渲染,而concent的上下文本身是獨立于react存在的(接入concent不需要再頂層包裹任何Provider), 只負責處理業務生成新的數據,然后按需派發給對應的實例,之后就是react自己的調度流程,修改狀態的函數并不會因為組件反復重入而多次執行(這點需要我們遵循不該在渲染過程中書寫包含有副作用的代碼原則),react僅僅是調度組件的渲染時機。
? star me if you like concent ^_^
Edit on CodeSandbox
Edit on StackBlitz
如果有關于concent的疑問,可以掃碼加群咨詢,會盡力答疑解惑,幫助你了解更多,里面的不少小伙伴都變成老司機了,用過之后都表示非常happy,客官上船試試便知 。
總結
以上是生活随笔為你收集整理的react 哲学_细聊Concent amp; Recoil , 探索react数据流的新开发模式的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 对标苹果的它苹果12对标手机
- 下一篇: c罗的车(c罗的老婆)