前端框架都在用的 Signal 信号量

251 阅读4分钟

在现代前端开发中,信号(Signal) 是一种用于管理和响应状态变化的机制,当信号的值发生变化时所有依赖于该信号的部分将自动重新计算或重新渲染,从而实现高效的 UI 更新。随着响应式编程理念的普及,越来越多的库和工具开始引入或支持信号机制,以提升状态管理和 UI 更新的效率

  • 主要框架:Solid.js 是最显著采用 Signal 概念的框架,利用细粒度的响应式系统实现高性能更新
  • 传统库的响应式机制:尽管没有 Signal 命名,但 Vue 的 ref、MobX 的 observable 等,也实现了类似信号的响应式更新逻辑
  • 与框架集成:如 @preact/signals 为 Preact 提供信号支持,Recoil 在 React 环境中引入类似的响应式机制
  • 独立库:S.js、signals-js 等独立库提供了纯粹的 Signal 和 Effect 功能,适用于不同的项目需求

Vue 文档中也对 Signal 有一定的阐述 很多其它框架已经引入了与 Vue 组合式 API 中的 ref 类似的响应性基础类型,并称之为“信号”:

从根本上说,信号是与 Vue 中的 ref 相同的响应性基础类型。它是一个在访问时跟踪依赖、在变更时触发副作用的值容器。这种基于响应性基础类型的范式在前端领域并不是一个特别新的概念:它可以追溯到十多年前的 Knockout observablesMeteor Tracker 等实现。Vue 的选项式 API 和 React 的状态管理库 MobX 也是基于同样的原则,只不过将基础类型这部分隐藏在了对象属性背后。

虽然这并不是信号的必要特征,但如今这个概念经常与细粒度订阅和更新的渲染模型一起讨论。由于使用了虚拟 DOM,Vue 目前依靠编译器来实现类似的优化。然而,我们也在探索一种新的、受 Solid 启发的、名为 Vapor Mode 的编译策略,它不依赖于虚拟 DOM,而是更多地利用 Vue 的内置响应性系统。

初识 Signal

与 React 的 useState 或 Vue 的 ref 类似,Signal 提供了一种声明式的方式来管理状态,但在性能和可预测性方面具有一些独特的优势。Solid.js 是一个基于信号的声明式前端框架,利用编译时优化和细粒度的响应式系统,实现了高性能的 UI 更新,看一个简单的 demo

import { createSignal, createEffect } from "solid-js";

function Counter() {
  const [count, setCount] = createSignal(1);
  const increment = () => setCount(count => count + 1);

    createEffect(() => {
      console.log(`Count is now: ${count()}`);
    });

  return (
    <button type="button" onClick={increment}>
      {count()}
    </button>
  );
}

惊呆了,如果把 createSignal 改成 useState、createEffect 改成 useEffect,不就是 React 吗?

虽然代码结构类似,但仔细观察我们还是能够发现一些区别:

  • createSignal 返回值 count本质上是一个 getter 函数,而 useState 返回的是 Value
  • createEffect 不需要声明依赖,而 useEffect 需要

React 19 的 Compiler 可以做到自动收集依赖,避免组件重复渲染

Solid.js 的细粒度响应式

  • 信号(Signal) :每个 createSignal 创建的信号都是独立的响应单元,拥有自己的依赖追踪。只有直接依赖该信号的部分会在信号变化时更新
  • 副作用(Effect)createEffect 会立即执行,并在依赖的信号变化时同步重新执行
  • 编译时优化:Solid.js 在编译阶段进行了大量优化,生成的代码直接操作真实的 DOM,避免了运行时的虚拟 DOM 比对

React 的组件渲染

  • 状态(State)useState 更新状态时,会重新渲染整个组件函数,导致组件内所有依赖状态的部分重新计算,即使其中一些部分的内容可能没有变化
  • 副作用(Effect)useEffect 的副作用在组件渲染后异步执行,与 Solid.js 的同步执行不同
  • 虚拟 DOM:React 使用虚拟 DOM 来描述 UI,每次状态更新都会生成新的虚拟 DOM,并与旧的进行差异比对(diffing),然后应用必要的 DOM 变更

Angular、Qwik 作者 Miško Hevery 在博客 useSignal() is the Future of Web Frameworks 中表示

简单实现 createSignal、createEffect

Signal 用于存储和管理状态。每个信号包含一个值和一个用于更新该值的函数。当信号的值发生变化时,所有依赖该信号的副作用(effects)会被重新执行

  1. 依赖追踪:当 createEffect 被调用时,当前的副作用函数被设置为“活动副作用”。在副作用函数执行过程中,任何被访问的信号都会将当前副作用函数添加到它们的依赖列表中
  2. 信号更新:当信号的值通过 setter 函数被更新时,它会遍历并执行所有依赖该信号的副作用函数,从而实现响应式更新
// 全局变量,用于存储当前活动的副作用函数
let activeEffect = null;

// createEffect 实现
function createEffect(effect) {
  // 设置当前活动的副作用函数
  activeEffect = effect;
  // 执行副作用函数
  effect();
  // 清空当前活动的副作用函数
  activeEffect = null;
}

// createSignal 实现
function createSignal(initialValue) {
  // 存储信号的值
  let value = initialValue;
  // 存储依赖该信号的副作用函数集合
  const effects = new Set();

  // getter 函数
  const get = () => {
    // 如果有活动的副作用函数,将其添加到依赖列表
    if (activeEffect) {
      effects.add(activeEffect);
    }
    return value;
  };

  // setter 函数
  const set = (newValue) => {
    if (newValue !== value) {
      value = newValue;
      // 触发所有依赖该信号的副作用函数
      effects.forEach(effect => effect());
    }
  };

  return [get, set];
}

使用示例

// 创建两个信号
const [count, setCount] = createSignal(0);
const [name, setName] = createSignal('张三');

// 创建副作用:当 count 或 name 变化时,输出新的值
createEffect(() => {
  console.log(`当前计数: ${count()}`);
});

createEffect(() => {
  console.log(`当前姓名: ${name()}`);
});

// 更新信号的值
console.log('--- 更新 count ---');
setCount(1);
// 输出:
// 当前计数: 0
// 当前姓名: 张三
// --- 更新 count ---
// 当前计数: 1

console.log('--- 更新 name ---');
setName('李四');
// 输出:
// 当前姓名: 李四

Vue 的 ref

Solid 的 createSignal() API 设计强调了读/写隔离,信号通过一个只读的 getter 和另一个单独的 setter 暴露:

const [count, setCount] = createSignal(0)

count() // 访问值
setCount(1) // 更新值

count 信号在没有 setter 的情况也能传递。这就保证了除非 setter 也被明确暴露,否则状态永远不会被改变。可以轻易地在 Vue 中模仿这种风格

import { shallowRef, triggerRef } from 'vue'

export function createSignal(value, options) {
  const r = shallowRef(value)
  const get = () => r.value
  const set = (v) => {
    r.value = typeof v === 'function' ? v(r.value) : v
    if (options?.equals === false) triggerRef(r)
  }
  return [get, set]
}

Vue 正经的用法是通过ref 创建一个响应式的数据引用。基本数据类型需通过 .value 访问和修改

<template>
  <button @click="increment">{{ count }}</button>
</template>

<script>
  import { ref, watchEffect } from 'vue';

  export default {
    setup() {
      const count = ref(0);

      watchEffect(() => {
        console.log(`Count is now: ${count.value}`);
      });

      const increment = () => {
        count.value++;
      };

      return { count, increment };
    }
  }
</script>

Vue3 基于 Proxy 的响应式系统,通过 Proxy 可以拦截对对象属性的读取和修改,从而实现依赖追踪和自动更新。使用 Proxy 结合一个全局的 Effect 函数栈来实现依赖追踪,在读取响应式属性时 Proxy 拦截器会将当前活跃的副作用函数与该属性关联起来。当属性发生变化时,相关的副作用函数会被重新执行

读取属性

  • 不论是模板渲染还是副作用函数执行,都会触发属性的 getter
  • Proxy 拦截器在 getter 中检查是否有当前活跃的副作用函数(即正在执行的 watcher)
  • 如果有,将该副作用函数添加到属性的依赖列表中

修改属性

  • Proxy 拦截器在 setter 中,检查新值是否与旧值不同
  • 如果不同,更新属性的值,并通知所有依赖该属性的副作用函数

Vue 的响应式系统是通过 Virtual DOM 来实现的,这意味着它在更新时需要对 DOM 树进行 diff 操作,同时 Vue 的更新和 React 类似都是异步的,通过批处理机制来最小化更新次数并优化性能。数据改变后会在下一个 “tick” 时更新 DOM。虽然 Vue 进行了许多优化,但相较于 Solid.js 的直接更新,性能要略逊一筹。