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

rvc3.png

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

序言

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

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

數據流方案之3大流派

目前主流的數據流方案按形態均可以劃分如下這三類react

  • redux流派

redux、和基於redux衍生的其餘做品,以及相似redux思路的做品,表明做有dva、rematch等等。git

  • mobx流派

藉助definePerperty和Proxy完成數據劫持,從而達到響應式編程目的的表明,類mobx的做品也有很多,如dob等。github

  • Context流派

這裏的Context指的是react自帶的Context api,基於Context api打造的數據流方案一般主打輕量、易用、概覽少,表明做品有unstated、constate等,大多數做品的核心代碼可能不超過500行。編程

到此咱們看看Recoil應該屬於哪一類?很顯然按其特徵屬於Context流派,那麼咱們上面說的主打輕量對
Recoil並不適用了,打開其源碼庫發現代碼並非幾百行完事的,因此基於Context api作得好用且強大就未必輕量,由此看出facebookRecoil是有野心並給予厚望的。redux

咱們同時也看看Concent屬於哪一類呢?Concentv2版本以後,重構數據追蹤機制,啓用了defineProperty和Proxy特性,得以讓react應用既保留了不可變的追求,又享受到了運行時依賴收集和ui精確更新的性能提高福利,既然啓用了defineProperty和Proxy,那麼看起來Concent應該屬於mobx流派?api

事實上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

相關文章
相關標籤/搜索