浏览器内置响应式编程?用 TC39 Signals 提案实现 TodoList

当框架开始「内卷」,EcmaScript官方出手了!

你还在为 Vue 的 ref()、React 的 useState()、Solid 的 createSignal() 疯狂切脑吗?

十年前,jQuery 一统江湖时,没人能想象今天的前端框架会为状态管理卷成这样------每个框架都在造自己的轮子,每个轮子都叫 Signal(信号),但每个轮子长得都不一样。Vue 说"用我的响应式代理",React 说"Hooks 才是未来",Solid 微微一笑:"我比你们都像原子"。

但这场内卷,可能要被 JavaScript 的「亲爹」TC39 终结了。

随着proposal-signals的提案横空出世,它直接放话:"别吵了,我来给 JavaScript 造个原生的响应式引擎!" 它要做的事情很简单------把 Signal 写进 ECMAScript 标准,让浏览器自带响应式超能力

这意味着什么?

  • 🔥 未来你可能不再需要 useEffectwatch 的魔法
  • 🚀 跨框架组件共享状态?一个原生 Signal 直接搞定
  • 💡 开发者工具里直接看「信号依赖图谱」,Debug 响应式就像开上帝视角

今天,我们就用 100% 原生 JavaScript + Signals提案的官方Polyfill,手搓一个「原子级响应式」的 TodoList,看看这玩意儿到底是不是在画大饼。


Signals 提案 API 速览

核心三件套:State / Computed / Watcher

TC39 的 Signals 设计极其「原子化」,只提供最底层的响应式引擎。三巨头用法如下:

1. Signal.State ------ 响应式宇宙的原子

这就是可变的响应式数据源 ,对标 Vue 的 ref() 或 Solid 的 createSignal()

csharp 复制代码
javascript
// 创建一个计数器信号,初始值为 0
const counter = new Signal.State(0);

// 读值:必须用 .get()
console.log(counter.get()); // 输出 0

// 改值:用 .set() 触发响应链
counter.set(42); // 所有依赖 counter 的地方自动更新!

黑科技点

  • 🧲 自带「脏检查」:只有值真正变化时才会触发更新(默认用 Object.is 对比)
  • ⚡ 同步更新:没有 React 的批量更新魔法,改值即生效

2. Signal.Computed ------ 自动「抱大腿」的派生状态

自动追踪依赖 的派生信号,对标 Vue 的 computed() 或 Solid 的衍生函数:

dart 复制代码
javascript
// 自动依赖 counter,counter 变我就变!
const isEven = new Signal.Computed(() => {
  return (counter.get() % 2) === 0;
});

console.log(isEven.get()); // counter=42 → 输出 true

逆天特性

  • 🔄 惰性求值:没人用我时,我绝不浪费 CPU!(不主动计算)
  • 🧠 依赖动态追踪:每次计算自动识别新依赖,和旧依赖说拜拜
  • 💾 缓存之王:依赖不变时,直接返回上次结果

3. Signal.subtle.Watcher ------ 监听「宇宙大爆炸」

副作用调度器 ,对标 React 的 useEffect 或 Vue 的 watchEffect,但更底层:

javascript 复制代码
javascript
// 创建一个监听器
const watcher = new Signal.subtle.Watcher(() => {
  console.log('检测到信号变化,快安排渲染!');
});

// 监听 isEven 信号
watcher.watch(isEven);

// 手动触发首次监听
watcher.watch();

高阶玩法

  • 🕵️ 脏检查 :用 watcher.getPending() 获取待更新的信号
  • 🎛️ 调度控制 :可整合 requestAnimationFramequeueMicrotask 批量更新

危险武器库(普通开发者慎用)

提案还藏了一堆「灭霸手套」级 API,专供框架作者:

  • Signal.subtle.untrack(() => ...) ------ 让代码逃离依赖追踪(慎用!可能引发「量子纠缠」Bug)
  • Signal.subtle.introspectSources() ------ 透视信号的依赖图谱(Debug 神器)
  • Signal.subtle.hasSinks() ------ 判断信号是否被监听(内存泄漏克星)

重要警告 ⚠️

  • 🚧 Stage 1 的 API 如同薛定谔的猫:今天能用,明天可能原地消失
  • 🔥 性能未知:Polyfill 版本仅为演示逻辑,未做极致优化
  • 💥 不要在生产环境使用:除非你想给老板表演「如何用三天重构项目」

Polyfill食用指南:如何把「未来黑科技」装进今天的浏览器?

先看仓库的 package.json,我们得破译几个关键字段:

json 复制代码
{
  "name": "signal-polyfill",
  "type": "module",          // 🚨 构建产物是 ESM 模块,浏览器和现代 JS 环境直接开箱即用
  "main": "dist/index.js",   // 🔑 入口文件在此!构建后提刀来见
  "scripts": {
    "build": "vite build"    // ⚡ 构建命令:pnpm build(懂的都懂)
  },
  "volta": {                 // 🌍 版本锁死器!防「我电脑能跑你电脑炸」的玄学 Bug
    "node": "22.0.0",        // Node 版本别乱动,小心「量子纠缠」
    "pnpm": "9.0.6"          // 包管理器也得听话,叛逆少年容易翻车
  }
}

Step 1:克隆 & 构建 ------ 程序员の仪式感

bash 复制代码
bash
# 克隆仓库
git clone https://github.com/proposal-signals/signal-polyfill.git
cd signal-polyfill

# Volta 锁版本(没安装?先 npm i -g volta)
volta install node@22.0.0
volta install pnpm@9.0.6

# 装依赖
pnpm install

# 执行构建(Vite 在背后默默打工)
pnpm build

Step 2:提货 dist/index.js

构建完成后,dist/index.js 就是你的「信号灯启动器」。用现代浏览器直接 ESM 导入:

javascript 复制代码
javascript
// 注意路径!别手抖写成 './dist/index.js'
import { Signal } from './dist/index.js'; 

// 开始表演!
const counter = new Signal.State(0);

手搓TodoList

Step 1:创建文件结构

需要注意,Signals提案不包含effect API,因为此类 API 通常与高度依赖框架/库的渲染和批处理策略深度集成。但是官方的polyfill还是给我们提供了简易的effect实现的示例代码,我们直接拿来主义。

我们来新建个文件夹,把dist文件夹放进来并重命名为signal-polyfill,另外分别新建三个文件index.html,signal.js,index.js

erlang 复制代码
.
├── signal-polyfill/
│   └── index.js         
├── signal.js
├── index.js
└── index.html
html 复制代码
<!DOCTYPE html>
<html lang="zh">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>TodoList with Proposal-Signals</title>
  </head>
  <body>
    <script type="module" src="signal.js"></script>
    <script type="module" src="index.js"></script>
  </body>
</html>

注意,由于polyfill构建产物是ESM模块,所以我们需要使用<script type="module">来导入js,现代浏览器是支持直接导入ESM模块的。

effect函数的实现搬到signal.js里,Signal本身也在这个模块里再次导出。

js 复制代码
import { Signal } from './dist/index.js'


let needsEnqueue = true;

const w = new Signal.subtle.Watcher(() => {
  if (needsEnqueue) {
    needsEnqueue = false;
    queueMicrotask(processPending);
  }
});

function processPending() {
  needsEnqueue = true;

  for (const s of w.getPending()) {
    s.get();
  }

  w.watch();
}

export function effect(callback) {
  let cleanup;

  const computed = new Signal.Computed(() => {
    typeof cleanup === "function" && cleanup();
    cleanup = callback();
  });

  w.watch(computed);
  computed.get();

  return () => {
    w.unwatch(computed);
    typeof cleanup === "function" && cleanup();
    cleanup = undefined;
  };
}

export { Signal }

接下来我们的主要逻辑就在index.js中完成了。

js 复制代码
import { Signal, effect } from './signal.js'
// ...

Step 2:HTML 骨架

html 复制代码
<style>
  html,
  body {
    margin: 0;
    padding: 0;
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
      'Helvetica Neue', Arial;
  }

  body {
    font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif;
    line-height: 1.4em;
    background: #f5f5f5;
    color: #4d4d4d;
    min-width: 230px;
    max-width: 550px;
    margin: 0 auto;
    font-weight: 300;
  }

  .todoapp {
    background: #fff;
    margin: 130px 0 40px 0;
    position: relative;
    box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2),
      0 25px 50px 0 rgba(0, 0, 0, 0.1);
  }

  .todoapp h1 {
    position: absolute;
    top: -125px;
    width: 100%;
    font-size: 100px;
    font-weight: 100;
    text-align: center;
    color: rgba(175, 47, 47, 0.15);
    text-rendering: optimizeLegibility;
  }

  .new-todo {
    position: relative;
    margin: 0;
    width: 100%;
    font-size: 24px;
    font-family: inherit;
    font-weight: inherit;
    line-height: 1.4em;
    color: inherit;
    padding: 16px 16px 16px 60px;
    border: none;
    background: rgba(0, 0, 0, 0.003);
    box-shadow: inset 0 -2px 1px rgba(0, 0, 0, 0.03);
    box-sizing: border-box;
  }

  .todo-list {
    margin: 0;
    padding: 0;
    list-style: none;
  }

  .todo-list li {
    position: relative;
    font-size: 24px;
    border-bottom: 1px solid #ededed;
    display: flex;
    align-items: center;
    padding: 15px;
  }

  .todo-list li:last-child {
    border-bottom: none;
  }

  .todo-list li.completed span {
    color: #d9d9d9;
    text-decoration: line-through;
  }

  .todo-list li input[type='checkbox'] {
    width: 30px;
    height: 30px;
    margin: 0 15px 0 0;
    cursor: pointer;
  }

  .todo-list li button {
    display: none;
    margin-left: auto;
    font-size: 24px;
    color: #cc9a9a;
    background: none;
    border: none;
    cursor: pointer;
    transition: color 0.2s ease-out;
    padding: 0 10px;
  }

  .todo-list li:hover button {
    display: block;
  }

  .todo-list li button:hover {
    color: #af5b5e;
  }

  .todo-list li span {
    flex: 1;
    word-break: break-all;
    transition: color 0.4s;
  }

  .filters {
    margin: 0;
    padding: 10px 15px;
    border-top: 1px solid #e6e6e6;
    display: flex;
    justify-content: center;
    gap: 10px;
  }

  .filters button {
    color: inherit;
    margin: 3px;
    padding: 3px 7px;
    text-decoration: none;
    border: 1px solid transparent;
    border-radius: 3px;
    cursor: pointer;
    background: none;
  }

  .filters button.active {
    border-color: rgba(175, 47, 47, 1);
  }

  .filters button:hover {
    border-color: rgba(175, 47, 47, 0.8);
  }

  .todo-count {
    float: left;
    text-align: left;
    padding: 10px 15px;
    color: #777;
  }
</style>

<div class="todoapp">
  <h1>Todos</h1>
  <input
    class="new-todo"
    placeholder="What needs to be done?"
    id="todoInput"
    autofocus
  />
  <ul class="todo-list" id="todoList"></ul>
  <div class="todo-count" id="todoCount"></div>
  <div class="filters">
    <button data-filter="all" class="active">All</button>
    <button data-filter="active">Active</button>
    <button data-filter="completed">Completed</button>
  </div>
</div>

Step 3: 状态管理核心

js 复制代码
import { Signal, effect } from './signal.js'

// 状态管理
const todos = new Signal.State([])
const todoItem = new Signal.State('')
const filter = new Signal.State('all')

// DOM 元素
const todoInput = document.getElementById('todoInput')
const todoList = document.getElementById('todoList')
const todoCount = document.getElementById('todoCount')
const filterBtns = document.querySelectorAll('.filters button')

// 添加待办事项
function addTodo() {
  const text = todoItem.get().trim()
  if (text) {
    todos.set([
      ...todos.get(),
      {
        id: Date.now(),
        text,
        completed: false,
      },
    ])
    todoItem.set('')
    todoInput.value = ''
  }
}

// 删除待办事项
function removeTodo(id) {
  todos.set(todos.get().filter((todo) => todo.id !== id))
}

// 切换待办事项状态
function toggleTodo(id) {
  todos.set(
    todos
      .get()
      .map((todo) =>
        todo.id === id ? { ...todo, completed: !todo.completed } : todo
      )
  )
}

// 过滤待办事项
function getFilteredTodos() {
  const currentFilter = filter.get()
  const currentTodos = todos.get()

  switch (currentFilter) {
    case 'active':
      return currentTodos.filter((todo) => !todo.completed)
    case 'completed':
      return currentTodos.filter((todo) => todo.completed)
    default:
      return currentTodos
  }
}

// 更新待办事项计数
function updateTodoCount() {
  const activeTodos = todos.get().filter((todo) => !todo.completed)
  const itemText = activeTodos.length === 1 ? 'item' : 'items'
  todoCount.textContent = `${activeTodos.length} ${itemText} left`
}

// 事件监听
todoInput.addEventListener('input', (e) => todoItem.set(e.target.value))
todoInput.addEventListener('keyup', (e) => {
  if (e.key === 'Enter') addTodo()
})

filterBtns.forEach((btn) => {
  btn.addEventListener('click', () => {
    filterBtns.forEach((b) => b.classList.remove('active'))
    btn.classList.add('active')
    filter.set(btn.dataset.filter)
  })
})

// 渲染待办事项列表
effect(() => {
  const filteredTodos = getFilteredTodos()

  todoList.innerHTML = filteredTodos
    .map(
      (todo) => `
        <li class="${todo.completed ? 'completed' : ''}">
            <input type="checkbox" ${todo.completed ? 'checked' : ''}
                onchange="window.toggleTodo(${todo.id})">
            <span>${todo.text}</span>
            <button onclick="window.removeTodo(${todo.id})">×</button>
        </li>
    `
    )
    .join('')
  updateTodoCount()
})

// 暴露给全局以供 HTML 中的事件处理使用
window.toggleTodo = toggleTodo
window.removeTodo = removeTodo

最终效果🚀:在线demo

总结🎉

Signals 提案精要 :JavaScript 响应式编程的标准化基石,通过原子级状态管理 ​(Signal.State)、自动依赖追踪 ​(Signal.Computed)和精准副作用调度 ​(Watcher),实现跨框架状态互通与高效更新。开发者可告别手动订阅/缓存,用 50 行代码构建零依赖的「量子纠缠级」应用(如 TodoList),性能直逼原生且 DevTools 深度可调。当前处于 Stage 1,Polyfill 仅供尝鲜,但未来可能重塑前端生态,成为浏览器与框架的通用响应式引擎。

相关推荐
万少1 小时前
第五款 HarmonyOS 上架作品 奇趣故事匣 来了
前端·harmonyos·客户端
OpenGL1 小时前
Android targetSdkVersion升级至35(Android15)相关问题
前端
rzl021 小时前
java web5(黑马)
java·开发语言·前端
Amy.Wang1 小时前
前端如何实现电子签名
前端·javascript·html5
今天又在摸鱼1 小时前
Vue3-组件化-Vue核心思想之一
前端·javascript·vue.js
蓝婷儿1 小时前
每天一个前端小知识 Day 21 - 浏览器兼容性与 Polyfill 策略
前端
百锦再1 小时前
Vue中对象赋值问题:对象引用被保留,仅部分属性被覆盖
前端·javascript·vue.js·vue·web·reactive·ref
jingling5552 小时前
面试版-前端开发核心知识
开发语言·前端·javascript·vue.js·面试·前端框架
拾光拾趣录2 小时前
CSS 深入解析:提升网页样式技巧与常见问题解决方案
前端·css
莫空00002 小时前
深入理解JavaScript属性描述符:从数据属性到存取器属性
前端·面试