[Concent小課堂]認識組合api,換個姿勢擼更清爽的react

鍾正楷發表於2020-08-19

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

這裡有一份收集中的狀態管理清單,歡迎有興趣的朋友瞭解^_^
awesome-state

序言

composition api(組合api) 和 optional api(可選api) 兩種組織程式碼的方式,相信大家在vue3各種相關的介紹文裡已經瞭解到不少了,它們可以同時存在,並非強制你只能使用哪一種,但組合api兩大優勢的確讓開發者們更傾向於使用它來替代可選api。

  • 以函式為基礎單位來打包可複用邏輯,並注入到任意元件,讓檢視和業務解耦更優雅
  • 讓相同功能的業務更加緊密的放置到一起,不被割裂開,提高開發與維護體驗

以上兩點在react裡均被hook優雅的解決了,那麼相比hook,組合api還具有什麼優勢呢?這裡就不賣關子了,相信已有小夥伴在尤大大介紹組合api時已經知道,組合api是靜態定義的,解決了hook必需每次渲染都重新生成臨時閉包函式的效能問題,也沒有了hook裡閉包舊值陷阱,人工檢測依賴等編碼體驗問題。

但是,react是all in js的編碼方式,所以只要我們敢想、敢做,一切優秀的程式設計模型都可以吸納進來,接下來我們用原生hook和concent的setup並通過例項和講解,來徹底解決尤大提到的這個關於hook的痛點吧^_^

react hook

我們在此先設計一個傳統的計數器,要求如下

  • 有一個小數,一個大數
  • 有兩組加、減按鈕,分別對小數大數做操作,小數按鈕加減1,大數按鈕加減100
  • 計數器初次掛載時拉取歡迎問候語
  • 當小數達到100時,按鈕變為紅色,否則變為綠色
  • 當大數達到1000時,按鈕變為紫色,否則變為綠色
  • 當大數達到10000時,上報大數的數字
  • 計算器解除安裝時,上報當前的數字

為了完成此需求,我們需要用到以下5把鉤子

useState

過完需求,我們需要用到第一把鉤子useState來做元件首次渲染的狀態初始化

function Counter() {
  const [num, setNum] = useState(6);
  const [bigNum, setBigNum] = useState(120);
}

useCallback

如需使用快取函式,則要用到第二把鉤子useCallback,此處我們使用這把鉤子來定義加減函式

  const addNum = useCallback(() => setNum(num + 1), [num]);
  const addNumBig = useCallback(() => setBigNum(bigNum + 100), [bigNum]);

useMemo

如需用到快取的計算結果,則要用到第三把鉤子useMemo,此處我們使用這把鉤子來計算按鈕顏色

 const numBtnColor = useMemo(() => {
    return num > 100 ? 'red' : 'green';
  }, [num]);
  const bigNumBtnColor = useMemo(() => {
    return bigNum > 1000 ? 'purple' : 'green';
  }, [bigNum]);

useEffect

處理函式的副作用則需用到第四把鉤子useEffect,此處我們用來處理一下兩個需求

  • 當大數達到10000時,上報大數的數字
  • 計算器解除安裝時,上報當前的數字
  useEffect(() => {
    if (bigNum > 10000) api.report('reach 10000')
  }, [bigNum])
  useEffect(() => {
    return ()=>{
      api.reportStat(num, bigNum)
    }
  }, [])

useRef

上面使用清理函式的useEffect寫法在IDE是會被警告的,因為內部使用了num, bigNum變數(不寫依賴會陷入閉包舊值陷阱),所以要求我們宣告依賴

可是如果為了避免IDE警告,我們改為如下方式顯然不是我們表達的本意,我們只是想元件解除安裝時報告一下數字,而不是每一輪渲染都觸發清理函式

  useEffect(() => {
    return ()=>{
      api.reportStat(num, bigNum)
    }
  }, [num, bigNum])

這個時候我們需要第5把鉤子useRef,來幫忙我們固定依賴了,所以正確的寫法是

  const ref = useRef();// ref是一個固定的變數,每一輪渲染都指向同一個值
  ref.current = {num, bigNum};// 幫我們記住最新的值
  useEffect(() => {
    return () => {
      const {num, bigNum} = ref.current;
      reportStat(num, bigNum);
    };
  }, [ref]);

完整的計數器

使完5把鉤子,我們完整的元件如下

function Counter() {
  const [num, setNum] = useState(88);
  const [bigNum, setBigNum] = useState(120);
  const addNum = useCallback(() => setNum(num + 1), [num]);
  const addNumBig = useCallback(() => setBigNum(bigNum + 100), [bigNum]);
  const numBtnColor = useMemo(() => {
    return num > 100 ? "red" : "green";
  }, [num]);
  const bigNumBtnColor = useMemo(() => {
    return bigNum > 1000 ? "purple" : "green";
  }, [bigNum]);
  useEffect(() => {
    if (bigNum > 10000) report("reach 10000");
  }, [bigNum]);

  const ref = useRef();
  ref.current = {num, bigNum};
  useEffect(() => {
    return () => {
      const {num, bigNum} = ref.current;
      reportStat(num, bigNum);
    };
  }, [ref]);

  // render ui ...
}

當然我們可以基於hook可定製的特性,將這段程式碼單獨抽象為一個鉤子,這樣的話只需將資料和方法匯出,以便讓多種ui表達的Counter元件可以複用,同時也做到ui與業務隔離,利於維護。

function useMyCounter(){
  // .... 略
  return { num, bigNum. addNum, addNumBig, numBtnColor, bigNumBtnColor}
}

concent setup

hook函式在每一輪渲染期間一定是需要全部重新執行一遍的,所以不可避免的在每一輪渲染期間都會產生大量的臨時閉包函式,如果我們能省掉他們,的確能幫gc減輕一些回收壓力的,現在我們來看看使用setup改造完畢後的Counter會是什麼樣子吧。

使用concent非常簡單,只需要在根元件之前,先使用runapi啟動即可,因此處我們沒有模組定義,直接呼叫就可以了。

import { run } from 'concent';

run();// 先啟動,在render
ReactDOM.render(<App />, rootEl)

接著我們將以上邏輯稍加改造,全部包裹到setup內部,setup函式內部的邏輯只會被執行一次,需要用到的由渲染上下文ctx提供的api有initStatecomputedeffectsetState,同時配合setState呼叫時還需要讀取的狀態state,也由ctx獲得。

function setup(ctx) {// 渲染上下文
  const { initState, computed, effect, state, setState } = ctx;
  // setup僅在元件首次渲染之前執行一次,我們可在內部書寫相關業務邏輯
}

initState

initState用於初始化狀態,替代了useState,當我們的元件狀態較大時依然可以不用考慮如何切分狀態粒度。

initState({ num: 6, bigNum: 120 });

此處也支援函式是寫法初始化狀態

initState(()=>({ num: 6, bigNum: 120 }));

computed

computed用於定義計算函式,從引數列表裡解構時就確定了計算的輸入依賴,相比useMemo,更直接與優雅。

// 僅當num發生變化時,才觸發此計算函式
computed('numBtnColor', ({ num }) => (num > 100 ? 'red' : 'green'));

此處我們需要定義兩個計算函式,可以用你計算物件描述體來配置計算函式,這樣只需呼叫一次computed即可

computed({
  numBtnColor: ({ num }) => num > 100 ? 'red' : 'green',
  bigNumBtnColor: ({ bigNum }) => bigNum > 1000 ? 'purple' : 'green',
});

effect

effect的用法和useEffect是一模一樣的,區別僅僅是依賴陣列僅傳入key名稱即可,同時effect內部將函式元件和類元件的生命週期進行了統一封裝,使用者可以將業務不做任何修改便遷移到類元件身上

effect(() => {
  if (state.bigNum > 10000) api.report('reach 10000')
}, ['bigNum'])
effect(() => {
  // 這裡可以書寫首次渲染完畢時需要做的事情
  return () => {
      // 解除安裝時觸發的清理函式
    api.reportStat(state.num, state.bigNum)
  }
}, []);

setState

用於修改狀態,我們在setup內部基於setState定義完方法後,然後返回即可,接著我們可以在任意使用此setup的元件裡,通過ctx.settings拿到這些方法控制程式碼便可呼叫

function setup(ctx) {// 渲染上下文
  const { state, setState } = ctx;
  return {// 匯出方法
    addNum: () => setState({ num: state.num + 1 }),
    addNumBig: () => setState({ bigNum: state.bigNum + 100 }),
  }
}

完整的Setup Counter

基於上述幾個api,我們最終的Counter的邏輯程式碼如下

function setup(ctx) {// 渲染上下文
  const { initState, computed, effect, state, setState } = ctx;
  // 初始化資料
  initState({ num: 6, bigNum: 120 });
  // 定義計算函式
  computed({
    // 引數列表解構時就確定了計算的輸入依賴
    numBtnColor: ({ num }) => num > 100 ? 'red' : 'green',
    bigNumBtnColor: ({ bigNum }) => bigNum > 1000 ? 'purple' : 'green',
  });
  // 定義副作用
  effect(() => {
    if (state.bigNum > 10000) api.report('reach 10000')
  }, ['bigNum'])
  effect(() => {
    return () => {
      api.reportStat(state.num, state.bigNum)
    }
  }, []);

  return {// 匯出方法
    addNum: () => setState({ num: state.num + 1 }),
    addNumBig: () => setState({ bigNum: state.bigNum + 100 }),
  }
}

定義完核心的業務邏輯,緊接著,我們可在任意函式元件內部使用useConcent裝配我們定義好的setup來使用它了,useConcent會返回一個渲染上下文(和setup函式引數列表裡指的是同一個物件引用,有時我們也稱例項上下文),我們可按需獲從ctx上取出目標資料和方法,針對此示例,我們可以匯出
state(資料),settings(setup打包返回的法法),refComputed(例項的計算函式結果容器)這3個key來使用即可。

import { useConcent } from 'concent';

function NewCounter() {
  const { state, settings, refComputed } = useConcent(setup);
  // const { num, bigNum } = state;
  // const { addNum, addNumBig } = settings;
  // const { numBtnColor, bigNumBtnColor } = refComputed;
}

我們上面提到setup同樣可以裝配給類元件,使用register即可,需要注意的是裝配後的類元件,可以從this.ctx上直接獲取concent為其生成的渲染上下文,同時呢this.statethis.ctx.state是等效的,this.setStatethis.ctx.setState也是等效的,方便使用者程式碼0改動即可接入concent使用。

import { register } from 'concent';

@register(setup)
class NewClsCounter extends Component{
  render(){
   const { state, settings, refComputed } = this.ctx;
  }
}

結語

對比原生hook,setup將業務邏輯固定在只會被執行一次的函式內部,提供了更友好的api,且同時完美相容類元件與函式元件,讓使用者可以逃離hook的使用規則煩惱(想想看 useEffect 配合 useRef,是不是都有不小的認知成本?),而不是將這些約束學習障礙轉嫁給使用者, 同時對gc也更加友好了,相信大家都已預設了hookreact的一個重要發明,但是其實它不是針對使用者的,而是針對框架的,使用者其實是不需要了解那些燒腦的細節與規則的,而對於concent使用者來說,其實只需一個鉤子開啟一個傳送門,即可在另一個空間內部實現所有業務邏輯,而且這些邏輯同樣可以複用到類元件上。

親愛的客官看了這麼多,還不趕緊上手試試,以下提供了兩種寫法的連結,供你把玩?

  • one more thing

    上訴兩個hook Counter如果想做狀態共享,我們需要改造程式碼接入redux或者自建Context,但是在concent的開發模式下,setup無需任何改造,僅僅只需要提前宣告一個模組,然後註冊元件內屬於該模組即可,這種絲滑般的遷移過程可以讓使用者靈活應對各種複雜場景。

    import { run } from 'concent';
    
    run({
      counter:{
        state: { num:88, bigNum: 120 },
      },
      //reducer: {...}, // 如運算元據流程複雜,可再將業務提升到此處
    })
    
    // 對於函式元件
    useConcent({setup});
    //  ---> 改為
    useConcent({setup, module:'counter'})
    
    // 對於函式元件
    @register({setup});
    //  ---> 改為
    @register({setup, module:'counter'});

    往期文章

    ❤ 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

    相關文章