推荐学习指数:⭐️️⭐️️⭐️️

1. 前言

最近大家提起 Signals,都在说:“Signals 是前端框架的未来”。从使用情况来说,还真是这样。Angular、Ember、Preact、Qwik、Solid、Svelte、Vue,甚至 Mobx、Tailwind 都已使用 Signals,甚至已经有了关于 Signals 的 TC39 提案,未来可能直接在 JavaScript 中使用 Signals。然而,Signals 并不是一个新概念,早在 2010 年的 Knockout.js 就有使用。

所以 Signals 到底是什么?又是因为什么流行起来?背后的实现原理是什么?带来了哪些好处,解决了哪些问题?为什么会受到大家的喜欢和推崇?

因为 Svelte 5 的符文功能正是基于 Signals,所以本篇就带大家探究一下 Signals。

2. Signals

什么是 Signals 呢?在我看来,它是一种实现细粒度响应式的技术称呼。在学术论文中经常被称为“ Signals”,也会被称为:Observables、Atoms、Subjects、Refs(想想 Vue 的 Ref)。因为是一种技术名称,所以它没有具体的 API,每个框架都有自己的实现方式。

2.1. 响应式

关于“细粒度响应式”,让我们来慢慢解释。

首先是什么是响应式?

JavaScript 的响应式是有限制的,举个例子:

let count = 1;
let doubled = count * 2;

count = 2;
console.log(doubled);

此时 doubled的打印结果为 2,但我们想要的是 4,为此我们可以这样做:

let count = 1;
let doubled = () => count * 2;

count = 2;
console.log(doubled());

count = 3;
console.log(doubled());

这当然能解决问题,但实际项目开发中,我们还想要在 count值发生修改的时候做一些事情(这就是我们常说的 effect),我们很容易想到使用观察者模式:

function observable(value) {
  const subscribers = new Set();
  return {
    subscribe(fn) {
      subscribers.add(fn);
    },
    update(value) {
      subscribers.forEach((fn) => fn(value));
    },
  };
}

let count = observable(0);

count.subscribe((count) => {
  console.log(count);
});

count.update(2);

这当然能解决问题,但每次都要手动进行订阅会让代码更加复杂,而且我们该如何处理像 doubled 这种衍生状态呢?我们或许会这样做:

let count = observable(0);
let doubled;

count.subscribe((count) => {
  console.log("count =", count);
});

count.subscribe((count) => {
  doubled = count * 2;
  console.log("count * 2 =", doubled);
});

count.update(2);

可以看出:对于衍生状态以及其修改时的副作用订阅,这些都会增加代码的复杂度。

为了统一解决这些问题,在不断地实践中便有了“Signals”,它本质还是基于观察者模式,但是借助了 getter 和 setter,所以它解决了手动订阅的问题。

它的使用方式如下:

let subscriber = null;

function signal(value) {
  const subscribers = new Set();
  return {
    get value() {
      if (subscriber) {
        subscribers.add(subscriber);
      }
      return value;
    },
    set value(newValue) {
      value = newValue;
      subscribers.forEach((fn) => fn());
    },
  };
}

function effect(fn) {
  subscriber = fn;
  fn();
  subscriber = null;
}

function derived(fn) {
  const derived = signal();
  effect(() => {
    derived.value = fn();
  });
  return derived;
}

let count = signal(0);
let doubled = derived(() => count.value * 2);

effect(() => {
  console.log("count =", count.value);
});

effect(() => {
  console.log("count * 2 =", doubled.value);
});

count.value = 2;

这是我们自己定义的 Signals 函数和使用方式,但已经接近主流框架 Signals 的使用方式了。单看 Signals 其实是没有什么特别的,搭配 Reactions(也被称为 Effects、Autoruns、 Watches 或 Computed)和衍生状态的处理会让 Signals 的功能更加强大。其中 Reactions 会监听 Signals 的改变,在其发生更新时重新运行。

所以再看这段代码,其实还是很神奇的,因为修改 count 的值和 effect 表面上没有什么直接关联的地方,但在底层的实现中,effect 的回调函数会调用 count,因为调用了 count,所以自动订阅了 effect 函数。当 count 的值发生修改时,就会重新运行 effect 函数。

这就是 Signals 所带来的响应式,其实很多框架都已经实现了。

比如这是 2010 年的 Knockout:

const count = ko.observable(0);

const doubleCount = ko.pureComputed(() => count() * 2);

// logs whenever doubleCount updates
ko.computed(() => console.log(doubleCount()));

Vue:

import { ref, watchEffect } from "vue";

const count = ref(0);

watchEffect(() => {
  document.body.innerHTML = `Count is: ${count.value}`;
});

// 更新 DOM
count.value++;

Solid.js:

import { createSignal } from "solid-js";

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

function Counter() {
  return (
    <div
      onClick={() => {
        setCount((c) => c + 1);
      }}
    >
      Count: {count()}
    </div>
  );
}

Signals TC39 草案:

const counter = new Signal.State(0);
console.log(counter.get()); // 0
counter.set(1);
console.log(counter.get()); // 1
const isEven = new Signal.Computed(() => (counter.get() & 1) == 0);
console.log(isEven.get()); // false
counter.set(2);
console.log(isEven.get()); // true

2.2. 细粒度

那细粒度又是指什么呢?这就不得不提 React 了,我们举个例子:

export function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

在 React 中,useState() 返回一个状态值和修改状态值的方法,当你调用 setCount 告诉 React 有状态修改的时候,React 必须重新渲染整个组件。

但使用 Signals 是不会的,我们以 Solid.js 为例:

import { createSignal } from "solid-js";

function Counter() {
  const [count, setCount] = createSignal(0);
  return <button onClick={() => setCount(count() + 1)}>{count()}</button>;
}

在 Solid.js 中,虽然代码看起来极其相似,但当你调用 setCount 更新状态的时候,组件并不会重新渲染,它会直接更新 DOM 内容。

让我们举个更明显的例子:

这是 React:

import { useState, useEffect } from "react";

export default function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const intervalId = setInterval(() => {
      setCount((c) => c + 1);
    }, 1000);
    return () => clearInterval(intervalId);
  }, []);

  console.log(count);
  return <h1>{count}</h1>;
}

你可以想到,React 每秒都会打印一次 count,因为 setCount 会触发组件重新渲染。

但如果是在 Solid.js 中:

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

function Counter() {
  const [count, setCount] = createSignal(0);

  const timer = setInterval(() => setCount((c) => c + 1), 1000);
  onCleanup(() => clearInterval(timer));
  console.log(count());
  return <h1>{count()}</h1>;
}

render(() => <Counter />, document.getElementById("app"));

count 只会打印一次,如果你想每秒都打印,可以这样写:

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

function Counter() {
  const [count, setCount] = createSignal(0);

  const timer = setInterval(() => setCount((c) => c + 1), 1000);
  onCleanup(() => clearInterval(timer));
  return <h1>{console.log(count())}</h1>;
}

render(() => <Counter />, document.getElementById("app"));

那么问题来了,为什么两者的效果截然不同呢?

这是因为 React 返回的是状态值(StateValue),而 Solid.js 返回的是状态引用(StateReference)。

对于 React 而言,useState 是不知道状态值在组件中是如何使用的,当你通过 setCount通知 React 发生状态更改,因为 React 不知道页面的哪一部分发生了变化,因此必须重新渲染整个组件,这个计算代价是高昂的。

而对于 Solid.js 而言,为了能够做出反应,信号会在状态被调用时(count())收集上下文信息,换句话说,使用 count() 时会自动创建一个 subscription,所以当 count 的值发生修改的时候,Solid.js 已经知道哪里要发生修改,所以无须渲染整个组件,只更改对应部分的值即可。

这就是 Signals 带来的细粒度。介绍 Svelte 5 的文档经常会有 “Fine-grained reactivity” 这个名词,其实说的就是细粒度响应式。Svelte 5 正是基于 Signals 对代码进行了重构。

3. Signals 的简单历史

Signals 为什么逐渐火起来呢?这离不开各个框架的支持以及一些关键节点事件的发生。所以简单说一下(权当八卦听了):

最早是被追溯到 2010 年的 Knockout.js:

const count = ko.observable(0);

const doubleCount = ko.pureComputed(() => count() * 2);

ko.computed(() => console.log(doubleCount()));

后来 React 流行,这种响应式成为小众。

2014 年,Vue 一开始就采用了响应式,但只是将其作为内部的实现机制。Vue 现在的官网上有《与信号 (signal) 的联系》章节,与主流框架的 Signals 做了对比。

2019 年,Svelte3 展示了编译器的能力,从编译的角度实现了响应式,这让大家更加注重编译所带来的优化效果在,这也将成为未来框架的趋势。

正式火起来应该是 Solid.js 的诞生以及它将 Signals 这个概念推出来,这也是 Solid.js 的基础。Solid.js 的作者 Ryan Carniato 发布了很多介绍响应式和 Signals 的文章,受到大家的欢迎。

2023 年,Builder.io 的 CTO、Angular、Qwik 的作者 Miško Hevery 发文《useSignal() is the Future of Web Frameworks》。这应该就是“Signals 是前端框架的未来”这种说法的来源了。它的主要观点就是 React 的 useState 返回的响应值在改变时会导致组件重新渲染,基于 Signals 会自动创建订阅,通知改变,带来更高的性能。加上随着这么多年各个框架的实践,Signals 的开发者使用体验已经很好了,不比传统框架差。所以未来应该是基于 Signals 的。

随着 Signals 的概念逐渐深入人心,Angular、Ember、Preact、Qwik、Solid、Svelte、Vue,甚至 Mobx、Tailwind 都已使用 Signals,现在已经有了关于 Signals 的 TC39 提案,未来可能直接在 JavaScript 中使用 Signals。

React 考虑在底层使用:

4. 参考链接

  1. A Hands-on Introduction to Fine-Grained Reactivity
  2. The Evolution of Signals in JavaScript
  3. Implementing Signals from Scratch
  4. Patterns for Reactivity with Modern Vanilla JavaScript – Frontend Masters Boost