突破 Hooks 所有限制,只要 50 行代碼

大家好,我是卡頌。

你是否很討厭Hooks調用順序的限制(Hooks不能寫在條件語句裏)?

你是否遇到過在useEffect中使用了某個state,又忘記將其加入依賴項,導致useEffect回調執行時機出問題?

怪自己粗心?怪自己不好好看文檔?

答應我,不要怪自己。

根本原因在於React沒有將Hooks實現爲響應式更新。

是實現難度很高麼?本文會用 50 行代碼實現無限制版Hooks,其中涉及的知識也是VueMobx等基於響應式更新的庫的底層原理。

本文的正確食用方式是收藏後用電腦看,跟着我一起敲代碼(完整在線Demo鏈接見閱讀原文)。

手機黨要是看了懵逼的話不要自責,是你食用方式不對。

注:本文代碼來自 Ryan Carniato 的文章 Building a Reactive Library from Scratch[1],老哥是SolidJS作者

萬丈高樓平地起

首先來實現useState

function useState(value) {
  const getter = () => value;
  const setter = (newValue) =value = newValue;
  
  return [getter, setter];
}

返回值數組第一項負責取值,第二項負責賦值。相比React,我們有個小改動:返回值的第一個參數是個函數而不是state本身。

使用方式如下:

const [count, setCount] = useState(0);

console.log(count()); // 0
setCount(1);
console.log(count()); // 1

沒有黑魔法

接下來實現useEffect,包括幾個要點:

舉個例子:

const [count, setCount] = useState(0);

useEffect(() ={
  window.title = count();
})
useEffect(() ={
  console.log('沒我啥事兒')
})

count變化後第一個useEffect會執行回調(因爲他內部依賴count),但是第二個useEffect不會執行。

前端沒有黑魔法,這裏是如何實現的呢?

答案是:訂閱發佈。

繼續用上面的例子來解釋訂閱發佈關係建立的時機:

const [count, setCount] = useState(0);

useEffect(() ={
  window.title = count();
})

useEffect定義後他的回調會立刻執行一次,在其內部會執行:

window.title = count();

count執行時會建立effectstate之間訂閱發佈的關係。

當下次執行setCount(setter)時會通知訂閱了count變化的useEffect,執行其回調函數。

數據結構之間的關係如圖:

每個useState內部有個集合subs,用來保存**「訂閱該 state 變化」**的effect

effect是每個useEffect對應的數據結構:

const effect = {
  execute,
  deps: new Set()
}

其中:

我知道你有點暈。看看上面的結構圖,緩緩,咱再繼續。

實現 useEffect

首先需要一個棧來保存當前正在執行的effect。這樣當調用getterstate才知道應該與哪個effect建立聯繫。

舉個例子:

// effect1
useEffect(() ={
  window.title = count();
})
// effect2
useEffect(() ={
  console.log('沒我啥事兒')
})

count執行時需要知道自己處在effect1的上下文中(而不是effect2),這樣才能與effect1建立聯繫。

// 當前正在執行effect的棧
const effectStack = [];

接下來實現useEffect,包括如下功能點:

代碼如下:

  function useEffect(callback) {
    const execute = () ={
      // 重置依賴
      cleanup(effect);
      // 推入棧頂
      effectStack.push(effect);

      try {
        callback();
      } finally {
        // 出棧
        effectStack.pop();
      }
    }
    const effect = {
      execute,
      deps: new Set()
    }
    // 立刻執行一次,建立依賴關係
    execute();
  }

cleanup用來移除該effect與所有他依賴的state之間的聯繫,包括:

function cleanup(effect) {
  // 將該effect訂閱的所有state變化移除
  for (const dep of effect.deps) {
    dep.delete(effect);
  }
  // 將該effect依賴的所有state移除
  effect.deps.clear();
}

移除後,執行useEffect回調會再逐一重建關係。

改造 useState

接下來改造useState,完成建立訂閱發佈關係的邏輯,要點如下:

function useState(value) {
  // 訂閱列表
  const subs = new Set();

  const getter = () ={
    // 獲取當前上下文的effect
    const effect = effectStack[effectStack.length - 1];
    if (effect) {
      // 建立聯繫
      subscribe(effect, subs);
    }
    return value;
  }
  const setter = (nextValue) ={
    value = nextValue;
    // 通知所有訂閱該state變化的effect回調執行
    for (const sub of [...subs]) {
      sub.execute();
    }
  }
  return [getter, setter];
}

subscribe的實現,同樣包括 2 個關係的建立:

function subscribe(effect, subs) {
  // 訂閱關係建立
  subs.add(effect);
  // 依賴關係建立
  effect.deps.add(subs);
}

讓我們來試驗下:

const [name1, setName1] = useState('KaSong');
useEffect(() => console.log('誰在那兒!', name1())) 
// 打印: 誰在那兒!KaSong
setName1('KaKaSong');
// 打印: 誰在那兒!KaKaSong

實現 useMemo

接下來基於已有的 2 個hook實現useMemo

function useMemo(callback) {
  const [s, set] = useState();
  useEffect(() => set(callback()));
  return s;
}

自動依賴跟蹤

這套 50 行的Hooks還有個強大的隱藏特性:自動依賴跟蹤。

我們拓展下上面的例子:

const [name1, setName1] = useState('KaSong');
const [name2, setName2] = useState('XiaoMing');
const [showAll, triggerShowAll] = useState(true);

const whoIsHere = useMemo(() ={
  if (!showAll()) {
    return name1();
  }
  return `${name1()} 和 ${name2()}`;
})

useEffect(() => console.log('誰在那兒!', whoIsHere()))

現在我們有 3 個statename1name2showAll

whoIsHere作爲memo,依賴以上三個state

最後,當whoIsHere變化時,會觸發useEffect回調。

當以上代碼運行後,基於初始的 3 個state,會計算出whoIsHere,進而觸發useEffect回調,打印:

// 打印:誰在那兒!KaSong 和 XiaoMing

接下來調用:

setName1('KaKaSong');
// 打印:誰在那兒!KaKaSong 和 XiaoMing
triggerShowAll(false);
// 打印:誰在那兒!KaKaSong

下面的事情就有趣了,當調用:

setName2('XiaoHong');

並沒有log打印。

這是因爲當triggerShowAll(false)導致showAll statefalse後,whoIsHere進入如下邏輯:

if (!showAll()) {
  return name1();
}

由於沒有執行name2,所以name2whoIsHere已經沒有訂閱發佈關係了!

只有當triggerShowAll(true)後,whoIsHere進入如下邏輯:

return `${name1()} 和 ${name2()}`;

此時whoIsHere纔會重新依賴name1name2

自動的依賴跟蹤,是不是很酷~

總結

至此,基於**「訂閱發佈」**,我們實現了可以**「自動依賴跟蹤」**的無限制Hooks

這套理念是最近幾年纔有人使用麼?

早在 2010 年初KnockoutJS就用這種細粒度的方式實現響應式更新了。

不知道那時候,「Steve Sanderson」KnockoutJS作者)有沒有預見到 10 年後的今天,細粒度更新會在各種庫和框架中被廣泛使用。

參考資料

[1]

Building a Reactive Library from Scratch: https://dev.to/ryansolid/building-a-reactive-library-from-scratch-1i0p

我是卡頌,《React 技術揭祕》作者,全球開發者資訊觀察者

加我個人微信,我會:

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/ghpJf2-qxD4nXjek5dqLHA