RellikaZZ

RellikaZZ

前端 signal 介紹及原理

為什麼#

在解釋 signal 是什麼之前,我們看一個案例

// 使用useEffect加定時器實現遞增,下面的 Demo 有什麼問題?為什麼會這樣,該怎麼改?
function Demo() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    setInterval(() => {
      setCount(count + 1);
    }, 500);
  }, []);

  useEffect(() => {
    setInterval(() => {
      console.log(count);
    }, 500);
  }, []);

  return <div>{count}</div>;
}

export default Demo;

這段代碼預期是希望能每間隔 500ms 時,打印出一個遞增的 count。
拋開沒有清除定時器這個問題,實際上這段代碼只會一直打印 0。這是因為 useEffect 的依賴項裡沒有 count,因此 count 始終是 0。
如果有安裝 eslint-plugin-react-hooks 並 react-hooks/exhaustive-deps 的話可以更清楚地看到提示。

常見的解法有以下三種:

  1. 透過 ref 保存 state,也是大家使用的比較多的方案
import * as React from 'react';

export default function App() {
  const countRef = React.useRef(0);
  const [count, setCount] = React.useState(0);
  React.useEffect(() => {
    setInterval(() => {
      countRef.current = countRef.current + 1;
      // 為了頁面能響應變化,需要 setCount
      setCount(countRef.current);
    }, 500);
  }, []);

  return (
    <div>
      <h1>{count}</h1>
    </div>
  );
}

這是利用 ref 不變的原理。

  1. 透過 useReducer
import * as React from 'react';

const initialCount = 0;
const reducer = (count, action) => {
  switch (action.type) {
    case 'increment':
      return count + 1;
    default:
      throw new Error();
  }
};

export default function App() {
  const [count, dispatch] = React.useReducer(reducer, initialCount);
  React.useEffect(() => {
    setInterval(() => {
      dispatch({
        type: 'increment',
      });
    }, 500);
  }, [dispatch]);

  return (
    <div>
      <h1>{count}</h1>
    </div>
  );
}

這是利用了 dispatch 不變的原理。
3. 將定時器的回調保存到 ref

import * as React from 'react';

export default function App() {
  const savedCallback = React.useRef<() => void>();
  const [count, setCount] = React.useState(0);
  const callback = () => {
    setCount(count + 1);
  };
  React.useEffect(() => {
    savedCallback.current = callback;
  });
  React.useEffect(() => {
    setInterval(() => {
      savedCallback.current();
    }, 500);
  }, []);

  return (
    <div>
      <h1>{count}</h1>
    </div>
  );
}

稍微簡潔了點,但依然引入了額外的 ref
可以發現,上述的方案都保證了符合 react-hooks/exhaustive-deps,即 useEffect 中不能使用未存在於 deps 的變量。(題外話:是否應該嚴格遵循這條 rule 曾經也引起過爭議)
這是在 react 中常見的做法。而本次想介紹的是另一種可能性 signal

import * as React from 'react';
import { useSignal, useSignalEffect } from '@preact/signals-react';

export default function App() {
  const count = useSignal(0);
  useSignalEffect(() => {
    setInterval(() => {
      count.value++;
    }, 1000);
  });

  return (
    <div>
      <h1>{count}</h1>
    </div>
  );
}

什麼#

7980d6da-cbdc-4521-ad12-106b7af7c72e.png
Qwik 裡用到的就是 signal 管理狀態的,其作者(同時也是 Angular 的作者)表示 Signal 是前端框架的未來。Vue、Solid.js、Preact、Svelte 都已實現 Signal。
signal 的流行主要是為了解決兩個問題:

  • 更好的 DX(開發者體驗)即無需手動維護 effect 的依賴,無需擔心閉包陷阱
  • 更好的細粒度更新性能
    更好的 DX 在上述的例子中可以感受到,那麼更好的細粒度更新性能又是從何說起?

細粒度更新#

還是上述的例子,如果我們在非 signal 實現的版本添加一些代碼,如下:

// RefRenderDemo.tsx
import * as React from 'react';

function Child() {
  console.log('render child');
  return <div>child</div>;
}

export default function App() {
  const countRef = React.useRef(0);
  const [count, setCount] = React.useState(0);
  React.useEffect(() => {
    setInterval(() => {
      countRef.current = countRef.current + 1;
      setCount(countRef.current);
    }, 500);
  }, []);

  console.log('rendering');

  return (
    <div>
      <h1>{count}</h1>
      <Child />
    </div>
  );
}

在控制台上可以看到每次 count 更新時,都將打印一行 rendering,甚至 Child 組件也將打印 render child。
這是因為

React 中的 useState () 只返回狀態值,這意味著它不知道這個狀態值是如何使用的,必須重新呈現整個組件樹以響應狀態更改。

如果換成 signal,rendeing 和 render child 只會被打印一次

// SignalRenderDemo.tsx
import * as React from 'react';
import { useSignal, useSignalEffect } from '@preact/signals-react';

function Child() {
  console.log('render child');
  return <div>child</div>;
}

export default function App() {
  const count = useSignal(0);
  useSignalEffect(() => {
    setInterval(() => {
      count.value++;
    }, 1000);
  });
  console.log('rendering');

  return (
    <div>
      <h1>{count}</h1>
      <Child />
    </div>
  );
}

關鍵的區別在於信號返回 getter 和 setter,而 useState 返回的是值和 setter
上述的 demo 由於語法糖可能不太明顯,我們看看 solidjs 中的 signal 的寫法

import { render } from "solid-js/web";
import { createSignal } from "solid-js";

function Counter() {
  const [count, setCount] = createSignal(0);
  setInterval(() => setCount(count() + 1), 1000);
  return <div>Count: {count()}</div>;
}

count 實際上是個 getter 方法,調用後才能獲取到狀態值。

原理#

使用 signal 時,當 signal 的狀態值改變時,信號本身保持不變,這就避免了組件的 rerendering。那麼如何通知組件更新呢?實際上是透過發布訂閱模式實現

Signals are event emitters that hold a list of subscriptions. They notify their subscribers whenever their value changes

如果了解過 vue 的響應式原理,應該比較容易理解 signal。下面是 createSignal 以及 useSignal 的一個簡易實現

// mySignal.ts
import React from 'react';

let currentListener = undefined;

export const createSignal = (initialValue) => {
  // 訂閱列表
  const subscribers = new Set();
  // 狀態值
  let value = initialValue;
  const getter = () => {
    if (currentListener !== undefined) {
      // 當讀取值的時候,收集訂閱
      subscribers.add(currentListener);
    }
    return value;
  };
  const setter = (newValue) => {
    value = newValue;
    subscribers.forEach((subscriber) => {
      // 當變更值的時候,執行訂閱
      subscriber();
    });
  };
  return [getter, setter];
};

export const createSignalEffect = (callback) => {
  // 將回調保存到 currentListener
  currentListener = callback;
  // 這裡的 callback 如果調用到了 signal 的 getter,就會將 currentListener 存儲到 subscribers 中
  callback();
  // 還原 currentListener
  currentListener = undefined;
};

// 封裝成 hook
export const useSignal = (value) =>
  React.useMemo(() => createSignal(value), []);

使用時

// MySignalDemo.tsx
import * as React from 'react';
import { useSignal, createSignalEffect } from './mySignal';

export default function App() {
  const [count, setCount] = useSignal(0);
  const nodeRef = React.useRef();
  // 模擬 preact/signals 在組件渲染時訂閱的行為
  React.useEffect(() => {
    createSignalEffect(() => {
      nodeRef.current.innerText = count();
    });
  }, []);

  return <div ref={nodeRef} onClick={() => setCount(count() + 1)} />;
}

【模擬 preact/signals 在組件渲染時訂閱的行為】指的是在源碼的註釋中提到的這部分邏輯

image

現狀#

我個人的看法是 signal 有很多優點,但目前最好不要在 react 項目中去使用。
首先 react 官方覺得 signal 並非一個好的模式,可以參考 dan 對 signal 的一些看法
https://dev.to/this-is-learning/react-vs-signals-10-years-later-3k71 以及

image

其次,在實現上,@preact/signals-react 使用了一個屬性__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED
https://github.com/preactjs/signals/blob/main/packages/react/src/index.ts#L22
這個屬性中文翻譯是:內部神秘屬性,不要亂用!否則你會被炒魷魚!關於這個屬性有興趣的可以看看這篇文章
https://zhuanlan.zhihu.com/p/540482955

總結#

signal 提供了更有好的 DX 以及性能,原理是發布訂閱,但目前來說個人覺得並不太適合應用於 react。

Demo 地址#

https://stackblitz.com/edit/react-ts-k36uan?file=App.tsx

參考#

https://overreacted.io/zh-hans/making-setinterval-declarative-with-react-hooks/
https://dev.to/this-is-learning/react-vs-signals-10-years-later-3k71
https://preactjs.com/blog/introducing-signals/
https://www.builder.io/blog/usesignal-is-the-future-of-web-frameworks#code-use-ref-code-does-not-render

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。