細聊Concent & Recoil , 探索react資料流的新開發模式

鍾正楷發表於2020-08-04

rvc3.png

開源不易,感謝你的支援,❤ star me if you like concent ^_^

序言

之前發表了一篇文章 redux、mobx、concent特性大比拼, 看後生如何對局前輩,吸引了不少感興趣的小夥伴入群開始瞭解和使用 concent,並獲得了很多正向的反饋,實實在在的幫助他們提高了開發體驗,群里人數雖然還很少,但大家熱情高漲,技術討論氛圍濃厚,對很多新鮮技術都有保持一定的敏感度,如上個月開始逐漸被提及得越來越多的出自facebook的最新狀態管理方案 recoil,雖然還處於實驗狀態,但是相必大家已經私底下開始欲欲躍試了,畢竟出生名門,有fb背書,一定會大放異彩。

不過當我體驗完recoil後,我對其中標榜的精確更新保持了懷疑態度,有一些誤導的嫌疑,這一點下文會單獨分析,是否屬於誤導讀者在讀完本文後自然可以得出結論,總之本文主要是分析ConcentRecoil的程式碼風格差異性,並探討它們對我們將來的開發模式有何新的影響,以及思維上需要做什麼樣的轉變。

資料流方案之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做得好用且強大就未必輕量,由此看出facebookRecoil是有野心並給予厚望的。

我們同時也看看Concent屬於哪一類呢?Concentv2版本之後,重構資料追蹤機制,啟用了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;
  }
});

定義非同步的派生資料

selectorget支援定義非同步函式

需要注意的點是,如果有依賴,必需先書寫好依賴在開始執行非同步邏輯
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.SuspenseRecoilRoot,前者用於配合非同步計算函式需要,後者用於注入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";

(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

上述示例主要演示瞭如何定義狀態和修改狀態,那麼接下來我們需要用到以下兩個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.asynCtx()

//  --------- 對於函式元件 -----------
const { state, mr } = useConcent("counter");// useConcent 返回的就是ctx
const changeNum = () => mr.inc(20);// or ctx.mr.asynCtx()

非同步計算函式

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>
  );
}

![]https://raw.githubusercontent...

檢視線上示例

精確更新

開篇我說對Recoli提到的精確更新保持了懷疑態度,有一些誤導的嫌疑,此處我們將揭開疑團

大家知道hook使用規則是不能寫在條件控制語句裡的,這意味著下面語句是不允許的

const NumView = () => {
  const [show, setShow] = useState(true);
  if(show){// error
    const [num, setNum] = useRecoilState(numState);
  }
}

所以使用者如果ui渲染裡如果某個狀態用不到此資料時,某處改變了num值依然會觸發NumView重渲染,但是concent的例項上下文裡取出來的statemoduleComputed是一個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%完全支援的,或者進一步說,所有狀態管理工具,最終觸發的都是setStateforceUpdate,我們只要在渲染過程中不要寫具有任何副作用的程式碼,讓相同的狀態輸入得到的渲染結果冪,即是在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僅僅是排程元件的渲染時機,而元件的中斷重入針對也是這個渲染過程。

所以同樣的,對於concent

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元件呼叫其setStateforceUpdate觸發重渲染,current mode的引入並不會對現有的狀態管理或者新生的狀態管理方案有任何影響,僅僅是對使用者的ui程式碼提出了更高的要求,以免因為current mode引發難以排除的bug

為此react還特別提供了React.Strict元件來故意觸發雙呼叫機制, https://reactjs.org/docs/stri... 以引導使用者書寫更符合規範的react程式碼,以便適配將來提供的current mode。

react所有新特性其實都是被fiber啟用了,有了fiber架構,衍生出了hooktime slicingsuspense以及將來的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,
     }
  },
});

// ---> 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生態
  • 同時支援集中與分形模組配置,同步與非同步模組載入,對大型工程的彈性重構過程更加友好

❤ star me if you like concent ^_^

Edit on CodeSandbox
https://codesandbox.io/s/concent-guide-xvcej

Edit on StackBlitz
https://stackblitz.com/edit/cc-multi-ways-to-wirte-code

相關文章