Signals 来了:前端响应式编程正在发生的范式转移

Signals 信号塔:手绘插画风格,marker pen 风格

凌晨两点,你的屏幕上是一堆 useStateuseEffectuseMemouseCallback,还有那些永远记不清的依赖数组。你刚修好一个 bug,又引出了两个新 bug——一个下拉框的值变了,但某个远处的图表没更新,因为 useEffect 的依赖数组里漏写了那个状态。

或者你是 Vue 开发者,你习惯了 .value 的仪式感,每次取数都要写 count.value,每次改值都要写 count.value++。当你试图在模板里直接用 count 时,Vue 会礼貌地报错:'count' is not a reactive value。你开始怀疑人生:为什么我不能用直觉编程?

更让人沮丧的是性能问题。你有一个包含 1000 个项的长列表,每次"选中一个"这个简单操作,整个列表都要重新渲染。原因很简单:React 的 useState 只知道"状态变了",不知道"哪个地方用了这个状态"。它只能把整个组件树都跑一遍,做虚拟 DOM diff。

这些问题,你我都遇到过。它们有一个共同的名字:命令式状态管理与声明式 UI 之间的鸿沟

而今天我要讲的 Signals,正是这个问题的一种根本性解法。它不是新东西——早在 2012 年 Knockout.js 就玩过这一套——但它正在以全新的姿态杀回来,而且这次,是所有主流框架都在拥抱它。

让我们开始。

# Signals 究竟是什么:一个比喻

想象你是公司前台。你不需要知道谁打了电话、你只需要在电话响的时候接起来。当有人来找你,说"如果有人打电话来,通知我一声",你就在心里记一笔,然后等电话响的时候去通知那个人。

这就是 Signals 的核心理念:数据拥有"通知"的能力,而不需要数据的消费者主动去"轮询"或"订阅"

# 用代码说话

// Signals 的最小形态
import { signal, computed, effect } from '@solidjs/signals';

// 创建一个信号 - 就像一个会喊"我变了"的值容器
const count = signal(0);

// 派生值 - 自动追踪依赖,依赖不变就不重新计算
const doubled = computed(() => count.value * 2);

// 副作用 - 自动追踪它用到的所有信号
effect(() => {
  console.log(`计数是 ${count.value},双倍是 ${doubled.value}`);
});

// 改变值
count.value = 1;  // 输出: "计数是 1,双倍是 2"
count.value = 5;  // 输出: "计数是 5,双倍是 10"

你注意到了吗?count.value 的赋值没有调用任何订阅函数,没有 useEffect,没有 useMemo,只有简单的赋值。但 doubled 自动更新了,effect 自动重新执行了。

这就是 Signals 的魔力:细粒度的自动依赖追踪

# 对比 React 的 useState

// React useState - 你需要"显式声明"你的依赖
function Counter() {
  const [count, setCount] = useState(0);
  const [doubled, setDoubled] = useState(0);

  // 你必须手动同步 doubled 和 count 的关系
  useEffect(() => {
    setDoubled(count * 2);
  }, [count]); // 漏写这个依赖?恭喜你,bug 来了

  // 而且这个 effect 会在每次渲染后运行
  // 如果里面有异步操作?混乱开始

  return <button onClick={() => setCount(count + 1)}>{doubled}</button>;
}

# 对比 Vue 的 ref

// Vue 3 ref - 你需要 .value 的仪式感
import { ref, computed, watch } from 'vue';

const count = ref(0);
const doubled = computed(() => count.value * 2);

// 每次取值都要写 .value
console.log(count.value);
count.value++;

// 如果想在 count 变化时做点什么
watch(count, (newVal) => {
  console.log(`计数变了: ${newVal}`);
});

Vue 的 ref 其实是 Signals 的前身,但 .value 这个语法糖既是一种保护(让你区分响应式和非响应式),也是一种负担(每次写都很烦)。

React useState vs Signals 对比图:左边的混乱依赖数组 vs 右边的清晰自动订阅,手绘插画风格

# Signals 的本质

你可以把 Signals 想象成一种智能的、会自动广播的值容器

  1. 信号 (Signal):一个可变值,当它被修改时,会通知所有"订阅"了它的地方
  2. 计算值 (Computed):一个只读的派生值,它自动订阅自己的依赖
  3. 副作用 (Effect):一段会自动订阅它读取的所有信号,并在信号变化时重新运行的代码

这三个概念构成了 Signals 的全部。与 React 的"状态驱动渲染"不同,Signals 是"状态驱动更新"——更新精确到使用这个值的地方,而不需要重新渲染整个组件

# 三大主流框架的拥抱现状

# SolidJS:原生实现,性能怪兽

SolidJS 是第一个把 Signals 作为核心竞争力的框架。从第一天起,它就没有虚拟 DOM,没有 useState,只有 Signals。

// SolidJS - Signals 原生使用
import { createSignal, createMemo, createEffect } from 'solid-js';

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

  // computed - 响应式派生
  const doubled = createMemo(() => count() * 2);

  // effect - 响应式副作用
  createEffect(() => {
    console.log(`双倍值: ${doubled()}`);
  });

  return (
    <div>
      <p>计数: {count()}</p>
      <p>双倍: {doubled()}</p>
      <button onClick={() => setCount(c => c + 1)}>加一</button>
    </div>
  );
}

注意这里 count() 是函数调用形式,而不是 count.value。SolidJS 的信号是函数,这种设计让它可以在编译时静态分析访问模式,从而生成精确的更新代码。

SolidJS 的性能数据

  • 在 js-framework-benchmark 中,SolidJS 的性能与 vanilla JS 几乎一样快,比 React 快 10-50 倍
  • 没有虚拟 DOM diff,直接精确更新 DOM 节点
  • 编译时优化,信号访问可以被静态分析

# Vue 3.4+:实质就是 Signals

Vue 3 在 2019 年引入了 Composition API,其中的 refreactive 本质上就是 Signals 的变体。Vue 3.4 更是做了重要改进:

// Vue 3.4 - ref 的 .value 可以不写了(仅限 setup)
import { ref, computed, watchEffect } from 'vue';

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

    // computed - 响应式派生
    const doubled = computed(() => count.value * 2);

    // effect - watchEffect 自动追踪
    watchEffect(() => {
      console.log(`双倍值: ${doubled.value}`);
    });

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

    return { count, doubled, increment };
  }
}

Vue 的 ref 在模板中会自动解包 .value,所以在模板里写 是可以的。这是一种"魔法",也是 Vue 的特色。

2024 年,Vue 团队正式宣布 Vue 的响应式系统与 Signals 的概念高度一致,并且暗示未来版本会进一步融合 Signals 特性。

# Angular 17+:Signals 作为官方 API

Angular 在 2023 年 11 月的 Angular 17 中引入了 Signals 作为官方状态管理方案:

// Angular 17 - Signals API
import { signal, computed, effect } from '@angular/core';

@Component({
  selector: 'app-counter',
  template: `
    <p>计数: {{ count() }}</p>
    <p>双倍: {{ doubled() }}</p>
    <button (click)="increment()">加一</button>
  `
})
export class CounterComponent {
  // signal - 响应式值容器
  count = signal(0);

  // computed - 派生值
  doubled = computed(() => this.count() * 2);

  // effect - 副作用
  constructor() {
    effect(() => {
      console.log(`双倍值: ${this.doubled()}`);
    });
  }

  increment() {
    this.count.update(c => c + 1);
  }
}

Angular 的 Signals 集成了 Zone.js 的变化检测机制,但 Signals 本身是独立工作的——你可以选择只用 Signals 而不用 Zone.js,性能会更好。

# React:TC39 标准化提案进行时

2023 年 9 月,一份关于将 Signals 引入 JavaScript 的提案提交到了 TC39(JavaScript 的标准委员会)。提案的作者来自 Chrome、Meta、GitHub 等公司。

// TC39 Signals 提案(草案阶段)
// 注意:这是未来可能的 JavaScript 标准 API,不是现在的 React

const count = Signal.global({ value: 0 });
const doubled = Signal.computed(() => count.get() * 2);

Effect.root(() => {
  Effect.watch(() => {
    console.log(`双倍值: ${doubled.get()}`);
  });

  count.set(5);  // 输出: "双倍值: 10"
});

如果这个提案通过,Signals 将成为 JavaScript 语言的一部分,所有框架都可以使用。这将是前端历史上最大的范式转移之一。

三大框架拥抱 Signals 现状图:SolidJS、Vue、Angular、React 围绕中央 SIGNALS,手绘插画风格

# 实战代码对比:同一个例子,三种写法

让我们用一个具体的例子来对比:实现一个计数器 + 派生值 + 异步副作用的组件。

# 需求描述

  1. 一个计数器,点击按钮 +1
  2. 显示计数器的双倍值(派生)
  3. 显示计数器的平方(派生)
  4. 当计数超过 100 时,发送 API 请求(异步副作用)
  5. 显示"请求中"和"请求完成"的状态

# React 版本

// React 实现
import { useState, useEffect, useCallback, useRef } from 'react';

function Counter() {
  const [count, setCount] = useState(0);
  const [doubled, setDoubled] = useState(0);
  const [squared, setSquared] = useState(0);
  const [status, setStatus] = useState('idle');
  const [apiResult, setApiResult] = useState(null);
  const requestIdRef = useRef(0);

  // 同步派生值 - 每次 count 变化都要手动更新
  useEffect(() => {
    setDoubled(count * 2);
    setSquared(count * count);
  }, [count]);

  // 异步副作用 - 依赖追踪的噩梦
  useEffect(() => {
    if (count > 100) {
      setStatus('loading');
      const requestId = ++requestIdRef.current;

      fetch(`/api/heavy?count=${count}`)
        .then(res => res.json())
        .then(data => {
          // 防止竞态
          if (requestId === requestIdRef.current) {
            setApiResult(data);
            setStatus('done');
          }
        })
        .catch(() => {
          if (requestId === requestIdRef.current) {
            setStatus('error');
          }
        });
    }
  }, [count]); // 依赖数组,count 变化就重新执行

  return (
    <div>
      <p>计数: {count}</p>
      <p>双倍: {doubled}</p>
      <p>平方: {squared}</p>
      <p>API 状态: {status}</p>
      {apiResult && <p>API 结果: {JSON.stringify(apiResult)}</p>}
      <button onClick={() => setCount(c => c + 1)}>加一</button>
    </div>
  );
}

# Vue 3 版本

// Vue 3 实现
import { ref, computed, watch } from 'vue';

export default {
  setup() {
    const count = ref(0);
    const status = ref('idle');
    const apiResult = ref(null);

    // computed - 自动追踪依赖
    const doubled = computed(() => count.value * 2);
    const squared = computed(() => count.value * count.value);

    // watch - 异步副作用
    watch(count, async (newCount) => {
      if (newCount > 100) {
        status.value = 'loading';
        try {
          const res = await fetch(`/api/heavy?count=${newCount}`);
          const data = await res.json();
          apiResult.value = data;
          status.value = 'done';
        } catch {
          status.value = 'error';
        }
      }
    }, { immediate: false });

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

    return { count, doubled, squared, status, apiResult, increment };
  }
}

# SolidJS / Signals 版本

// SolidJS Signals 实现
import { createSignal, createMemo, createEffect } from 'solid-js';
import { createResource } from 'solid-js';

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

  // computed - 简洁的派生值
  const doubled = createMemo(() => count() * 2);
  const squared = createMemo(() => count() * count());

  // createResource - 内置的异步数据管理
  const [apiData] = createResource(
    count,  // 自动追踪这个信号
    async (currentCount) => {
      if (currentCount > 100) {
        const res = await fetch(`/api/heavy?count=${currentCount}`);
        return res.json();
      }
      return null;
    }
  );

  return (
    <div>
      <p>计数: {count()}</p>
      <p>双倍: {doubled()}</p>
      <p>平方: {squared()}</p>
      <p>API 状态: {apiData.loading ? 'loading' : apiData() ? 'done' : 'idle'}</p>
      <Show when={apiData()}>
        <p>API 结果: {JSON.stringify(apiData())}</p>
      </Show>
      <button onClick={() => setCount(c => c + 1)}>加一</button>
    </div>
  );
}

# 对比总结

维度 React Vue 3 SolidJS/Signals
派生值 需要 useEffect + useState computed createMemo
异步副作用 useEffect + 竞态处理 watch + async createResource 内置处理
依赖追踪 手动声明依赖数组 自动追踪 自动追踪
样板代码 中等
运行时开销 虚拟 DOM + diff Proxy + 模板编译 编译时优化,零运行时

三种代码实现的对比草图:React / Vue 3 / SolidJS,手绘插画风格

# 性能与心智:Signals 的得与失

# 性能优势

1. 精确更新,告别过度渲染

传统的虚拟 DOM diff 需要比较整个组件树,但 Signals 可以精确到每一个使用信号的地方。

场景:1000 个列表项,其中一项的计数增加

React:
  - 触发重新渲染
  - 虚拟 DOM diff 整个组件树
  - 假设 diff 结果是"只变化了一个元素"
  - 但 React 需要重新渲染整个列表的父组件

Signals:
  - 触发 count signal 更新
  - 只重新计算/渲染使用了 count 的那个 DOM 节点
  - 其他 999 个节点完全不动

在 js-framework-benchmark 的"replace all rows"测试中,SolidJS 的性能是 React 的 50 倍。不是 50%,是 50 倍。

2. 动画与实时数据

Signals 的细粒度更新特别适合:

  • 实时数据可视化(股票行情、游戏状态)
  • 动画(每一帧只更新需要变化的元素)
  • 大量表单(只验证真正变化的字段)

3. 内存与初始化

Signals 不需要创建组件实例来管理状态。状态是独立的值容器,不与特定的组件生命周期绑定。

# 客观看待的不足

1. 学习曲线

Signals 引入了新的心智模型:

  • "订阅是自动的":这与 React 的"显式依赖"完全不同
  • 时序问题:当 effect A 读取 signal B,而 effect B 读取 signal A 时,会发生什么?
  • 计算循环:computed 值是否可以依赖自己?

2. 调试工具不成熟

React 有强大的 React DevTools,可以查看组件树、状态历史、性能分析。Signals 的调试主要靠:

  • console.log 打印值
  • 各框架自己的开发工具(Solid DevTools、Vue DevTools 的响应式面板)
  • 没有统一的标准

3. 生态系统尚浅

  • React 生态有 8 年的积累,所有第三方库都是基于 hooks/class 的
  • Signals 是新范式,很多库还没有适配
  • TypeScript 类型推断有时不理想

4. SSR 挑战

服务端渲染时,Signals 的水合(hydration)过程比 React 更复杂,因为需要重建客户端的订阅关系。

# 我的判断与怀疑:从业者视角

# 这次不是炒作

我见过太多"银弹"了。Angular 1 的"下一次Web开发方式",React 的"革命性UI库",Vue 的"渐进式框架",Svelte 的"删掉 runtime",每个都声称要改变一切。

但 Signals 不一样。

因为这次,所有框架都在跟进。React 在推动 TC39 标准化,Vue 已经在用,Angular 官方采纳,SolidJS 已经证明可行。这不是某一家公司的营销,而是整个行业对同一个解法的共识。

这让我想起 2015 年 ES6 的标准化——Promise、async/await、class 语法,这些"新特性"最后都成为了标准,改变了所有代码。Signals 可能也是这样的机会。

# 但也请保持怀疑

问题 1:真的会标准化吗?

TC39 的提案需要经过多轮讨论和实现,周期可能是 3-5 年。而且即使标准化了,框架的实现细节也会不同。Vue 的 signals 和 Solid 的 signals 可能不是一回事。

问题 2:迁移成本

全球有几千万 React 开发者,他们的代码都是基于 hooks 的。你告诉他们"忘掉 useState 吧"?这不是一个 SignalState 能解决的事。

问题 3:虚拟 DOM 真的那么糟吗?

虚拟 DOM 是 React 的核心,也是它最大的优势之一:简单、抽象、与平台无关。Signals 精确但也耦合——它需要知道你具体在哪个 DOM 节点渲染这个值。

问题 4:调试问题怎么解决?

这是我认为最大的风险。如果调试工具不成熟,开发者就会觉得 Signals 是个"黑盒"。而"黑盒"在前端开发中是不可接受的——我们需要能够追踪 bug、理解行为。

# 前端框架之争的终点

我有一个预测:前端框架的终点不是某个具体的框架,而是"声明式 + 响应式"成为默认范式

未来的前端,可能是这样的:

  • JSX/模板 负责声明 UI 结构
  • Signals/状态 负责声明数据和它的派生关系
  • 编译器负责优化,生成高性能的更新代码
  • 框架的差异主要在于语法糖和生态,而核心范式统一

就像今天没有人在讨论"应该用面向对象还是面向过程"一样,10 年后可能也没有人在讨论"应该用 React 还是 Vue",因为它们都是"声明式 UI + 响应式状态"的变体。

前端范式转移时间轴:从 2020 年三大框架割据到 2028 年统一响应式范式,手绘插画风格

# 行动建议:普通开发者现在应该做什么?

# 如果你是 React 开发者

现在不需要立刻切换框架,但需要:

  1. 学习 Signals 的思维方式,理解"自动订阅"的含义
  2. 关注 React 社区对 Signals 的探索(比如 preact/signals@lit-labs/task
  3. 等待 React 官方的 Signals 支持(如果他们真的做的话)
  4. 保持对 useState/useEffect 的熟练度——它还会存在很久

# 如果你是 Vue 开发者

你已经在一个 Signals-like 的世界里了

  1. 深入理解 ref/reactive/computed 的原理
  2. 尝试在不用 Vue 的场景下使用 Signals(比如用 solid-js 写独立组件)
  3. Vue 3.4+ 的 Composition API 已经足够强大,先用好它

# 如果你想学 Signals

从 SolidJS 开始

npm create solid

SolidJS 是最纯粹的 Signals 实现,没有历史包袱,文档优秀,性能极致。你可以用它写一个 Todo App,感受 Signals 的工作方式。

学习资源

  • SolidJS 官方文档:solidjs.com
  • 《Deep Dive into Signals》(Ryan Carniato 的博客,SolidJS 作者)
  • TC39 Signals 提案:github.com/tc39/proposal-signals

# 如果你是框架作者或架构师

现在做技术选型决策时

  1. 如果你在做性能敏感的应用(游戏、数据可视化、实时系统),考虑 SolidJS
  2. 如果你在做企业级应用,Vue 3 或 React 仍然可靠
  3. 如果你在评估未来技术趋势,Signals 是值得押注的方向

# 尾声

凌晨两点,你终于修好了那个 bug。屏幕上不再有混乱的依赖数组,不再有漏写的 useEffect,不再有"为什么这里没更新"的困惑。

你写下了:

const count = signal(0);
const doubled = computed(() => count() * 2);

effect(() => {
  console.log(`双倍值: ${doubled()}`);
});

count.value = 100;

它工作了。就像你直觉中应该工作的那样。

前端开发的范式正在转移。你可以选择观望,也可以选择站在潮头。但无论如何,Signals 教会我们的最重要的事情是:

好的抽象应该让复杂变简单,而不是把简单变复杂。