一個新的 React 概念:Effect Event

大家好,我卡頌。

每個框架由於實現原理的區別,都會有些獨特的概念。比如:

React中,有一個 「非常容易」 被誤用的API —— useEffect,今天要介紹的Effect Event就屬於由useEffect衍生出的概念。

被誤用的 useEffect

本文一共會涉及三個概念:

首先來聊聊EventEffectuseEffect容易被誤用也是因爲這兩個概念很容易混淆。

Event 的概念

在下面的代碼中,點擊div會觸發點擊事件,onClick是點擊回調。其中onClick就屬於Event

function App() {
  const [num , update] = useState(0);
  
  function onClick() {
    update(num + 1);
  }
  
  return (
    <div onClick={onClick}>{num}</div>
  )
}

Event的特點是:「是由某些行爲觸發,而不是狀態變化觸發的邏輯」

比如,在上述代碼中,onClick是由 「點擊事件」 這一行爲觸發的邏輯,num狀態變化不會觸發onClick

Effect 的概念

Effect則與Event相反,他是 「由某些狀態變化觸發的,而不是某些行爲觸發的邏輯」

比如,在下述代碼中,當title變化後document.title會更新爲title的值:

function Title({title}) {
  useEffect(() => {
    document.title = title;
  }, [title])
  
  // ...
}

上述代碼中useEffect的邏輯就屬於Effect,他是由title變化觸發的。除了useEffect外,下面兩個Hook也屬於Effect

爲什麼容易誤用?

現在問題來了:EventEffect的概念完全不同,爲什麼會被誤用?

舉個例子,在項目的第一個版本中,我們在useEffect中有個初始化數據的邏輯:

function App() {
  const [data, updateData] = useState(null);

  useEffect(() => {
    fetchData().then(data => {
      // ...一些業務邏輯
      // 更新data
      updateData(data);
    })
  }, []);

  // ...
}

隨着項目發展,你又接到一個需求:提交表單後更新數據。

爲了複用之前的邏輯,你新增了options狀態(保存表單數據),並將他作爲useEffect的依賴:

function App() {
  const [data, updateData] = useState(null);
  const [options, updateOptions] = useState(null);

  useEffect(() => {
    fetchData(options).then(data => {
      // ...一些業務邏輯
      // 更新data
      updateData(data);
    })
  }, [options]);
  
  
  function onSubmit(opt) {
    updateOptions(opt);
  }

  // ...
}

現在,提交表單後(觸發onSubmit回調)就能複用之前的數據初始化邏輯。

這麼做實在是方便,以至於很多同學認爲這就是useEffect的用法。但其實這是典型的 「useEffect 誤用」

仔細分析我們會發現:「提交表單」 顯然是個Event(由提交的行爲觸發),Event的邏輯應該寫在事件回調中,而不是useEffect中。正確的寫法應該是這樣:

function App() {
  const [data, updateData] = useState(null);

  useEffect(() => {
    fetchData().then(data => {
      // ...一些業務邏輯
      // 更新data
      updateData(data);
    })
  }, []);
  
  
  function onSubmit(opt) {
    fetchData(opt).then(data => {
      // ...一些業務邏輯
      // 更新data
      updateData(data);
    })
  }

  // ...
}

上述例子邏輯比較簡單,兩種寫法的區別不大。但在實際項目中,隨着項目不斷迭代,可能出現如下代碼:

useEffect(() => {
  fetchData(options).then(data => {
    // ...一些業務邏輯
    // 更新data
    updateData(data);
  })
}, [options, xxx, yyy, zzz]);

屆時,很難清楚fetchData方法會在什麼情況下執行,因爲:

  1. useEffect的依賴項太多了

  2. 很難完全掌握每個依賴項變化的時機

所以,在React中,我們需要清楚的區分EventEffect,也就是清楚的區分 「一段邏輯是由行爲觸發的,還是狀態變化觸發的?」

useEffect 的依賴問題

現在,我們已經能清楚的區分EventEffect,按理說寫項目不會有問題了。但是,由於 「Effect 的機制問題」,我們還面臨一個新問題。

假設我們有段聊天室代碼,當roomId變化後,要重新連接到新聊天室。在這個場景下,聊天室的斷開 / 重新連接依賴於roomId狀態的變化,顯然屬於Effect,代碼如下:

function ChatRoom({roomId}) {
  useEffect(() => {
    const connection = createConnection(roomId);
    connection.connect();
    
    return () => {
      connection.disconnect()
    };
  }, [roomId]);
  
  // ...
}

接下來你接到了新需求 —— 當連接成功後,彈出 「全局提醒」

「全局提醒」 是否是黑暗模式,受到theme props影響。useEffect修改後的代碼如下:

useEffect(() => {
  const connection = createConnection(roomId);
  connection.connect();
  
  connection.on('connected', () => {
    showNotification('連接成功!', theme);
  });
  
  return () => connection.disconnect();
}, [roomId, theme]);

但這段代碼有個嚴重問題 —— 任何導致theme變化的情況都會導致聊天室斷開 / 重新連接。畢竟,theme也是useEffect的依賴項。

在這個例子中,雖然Effect依賴theme,但Effect並不是由theme變化而觸發的(他是由roomId變化觸發的)。

爲了應對這種場景,React提出了一個新概念 —— Effect Event。他指那些 「在 Effect 內執行,但 Effect 並不依賴其中狀態的邏輯」,比如上例中的:

() => {
  showNotification('連接成功!', theme);
}

我們可以使用useEffectEvent(這是個試驗性Hook)定義Effect Event

function ChatRoom({roomId, theme}) {
  const onConnected = useEffectEvent(() => {
    showNotification('連接成功!', theme);
  });

  useEffect(() => {
    const connection = createConnection(roomId);
    connection.connect();
    
    connection.on('connected', () => {
      onConnected();
    });
    
    return () => {
      connection.disconnect()
    };
  }, [roomId]);
  
  // ...
}

在上面代碼中,theme被移到onConnected(他是個Effect Event)中,useEffect雖然使用了theme的最新值,但並不需要將他作爲依賴。

useEffectEvent 源碼解析

useEffectEvent的實現並不複雜,核心代碼如下:

function updateEvent(callback) {
  const hook = updateWorkInProgressHook();
  // 保存callback的引用
  const ref = hook.memoizedState;
  // 在useEffect執行前更新callback的引用
  useEffectEventImpl({ref, nextImpl: callback});
  
  return function eventFn() {
    if (isInvalidExecutionContextForEventFunction()) {
      throw new Error(
        "A function wrapped in useEffectEvent can't be called during rendering.",
      );
    }
    return ref.impl.apply(undefined, arguments);
  };
}

其中ref變量保存 「callback 的引用」。對於上述例子中:

const onConnected = useEffectEvent(() => {
  showNotification('連接成功!', theme);
});

ref保存對如下函數的引用:

() => {
  showNotification('連接成功!', theme);
}

useEffectEventImpl方法接受refcallback的最新值爲參數,在useEffect執行前會將ref中保存的callback引用更新爲callback的最新值

所以,當在useEffect中執行onConnected,獲取的就是ref中保存的下述閉包的最新值:

() => {
  showNotification('連接成功!', theme);
}

閉包中的theme自然也是最新值。

useEffectEvent 與 useEvent

仔細觀察下useEffectEvent的返回值,他包含了兩個限制:

return function eventFn() {
    if (isInvalidExecutionContextForEventFunction()) {
      throw new Error(
        "A function wrapped in useEffectEvent can't be called during rendering.",
      );
    }
    return ref.impl.apply(undefined, arguments);
};

第一個限制比較明顯 —— 下面這行代碼限制useEffectEvent的返回值只能在useEffect回調中執行(否則會報錯):

if (isInvalidExecutionContextForEventFunction()) {
  // ...    
}

另一個限制則比較隱晦 —— 返回值是個全新的引用:

return function eventFn() {
  // ...
};

如果你不太明白 「全新的引用」 爲什麼是個限制,考慮下返回一個useCallback返回值:

return useCallback((...args) ={
    const fn = ref.impl;
    return fn(...args);
}[]);

這將會讓useEffectEvent的返回值成爲不變的引用,如果再去掉 「只能在 useEffect 回調中執行」 的限制,那麼useEffectEvent將是加強版的useCallback

舉個例子,如果破除上述限制,那麼對於下面的代碼:

function App({a, b}) {
  const [c, updateC] = useState(0);
  const fn = useCallback(() => a + b + c, [a, b, c])
  
  // ...
}

useEffectEvent替代useCallback,代碼如下:

const fn = useEffectEvent(() => a + b + c)

相比於useCallback,他有 2 個優點:

  1. 不用顯式聲明依賴

  2. 即使依賴變了,fn的引用也不會變,簡直是性能優化的最佳選擇

那麼React爲什麼要爲useEffectEvent加上限制呢?

實際上,useEffectEvent的前身useEvent就是遵循上述實現,但是由於:

  1. useEvent的定位應該是Effect Event,但實際用途更廣(可以替代useCallback),這不符合他的定位

  2. 當前React Forget(能生成等效於useMemouseCallback代碼的官方編譯器)並未考慮useEvent,如果增加這個hook,會提高React Forget實現的難度

所以,useEvent並沒有正式進入標準。相反,擁有更多限制的useEffectEvent反而進入了 React 文檔 [1]。

總結

今天我們學到三個概念:

其中Effect EventReact中的具體實現是useEffectEvent。相比於他的前身useEvent,他附加了 2 條限制:

  1. 只能在Effect內執行

  2. 始終返回不同的引用

在我看來,Effect Event的出現完全是由於Hooks實現機制上的複雜性(必須顯式指明依賴)導致的心智負擔。

畢竟,同樣遵循Hooks理念的Vue Composition API就沒有這方面問題。

參考資料

[1] React 文檔: https://react.dev/learn/separating-events-from-effects

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