RellikaZZ

RellikaZZ

前端 signal 介绍及原理

WHY#

在解释 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>
  );
}

What#

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

加载中...
此文章数据所有权由区块链加密技术和智能合约保障仅归创作者所有。