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

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

序言

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

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

數據流方案之3大流派

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

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

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

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

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

咱們同時也看看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>
  );
}
複製代碼

查看在線示例

精確更新

開篇我說對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請查看此文

結語

Recoil推崇狀態和派生數據更細粒度控制,寫法上demo看起來簡單,實際上代碼規模大以後依然很繁瑣。

Concent遵循redux單一狀態樹的本質,推崇模塊化管理數據以及派生數據,同時依靠Proxy能力完成了運行時依賴收集追求不可變的完美整合。

因此你將得到:

  • 運行時的依賴收集 ,同時也遵循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

https://codesandbox.io/s/concent-guide-xvcej

Edit on StackBlitz

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

若是有關於concent的疑問,能夠掃碼加羣諮詢,會盡力答疑解惑,幫助你瞭解更多,裏面的很多小夥伴都變成老司機了,用過以後都表示很是happy,客官上船試試便知😀。

相關文章
相關標籤/搜索