浏览器内置响应式编程?用 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 [email protected]
volta install [email protected]

# 装依赖
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 仅供尝鲜,但未来可能重塑前端生态,成为浏览器与框架的通用响应式引擎。

相关推荐
几何心凉11 分钟前
两款好用的工具,大模型训练事半功倍.....
前端
Dontla35 分钟前
黑马node.js教程(nodejs教程)——AJAX-Day01-04.案例_地区查询——查询某个省某个城市所有地区(代码示例)
前端·ajax·node.js
威哥爱编程36 分钟前
vue2和vue3的响应式原理有何不同?
前端·vue.js
呆呆的猫40 分钟前
【前端】Vue3 + AntdVue + Ts + Vite4 + pnpm + Pinia 实战
前端
qq_4560016542 分钟前
30、Vuex 为啥可以进行缓存处理
前端
浪裡遊1 小时前
Nginx快速上手
运维·前端·后端·nginx
天生我材必有用_吴用1 小时前
Vue3后台管理项目封装一个功能完善的图标icon选择器Vue组件动态加载icon文件下的svg图标文件
前端
小p1 小时前
初探typescript装饰器在一些场景中的应用
前端·typescript·nestjs
晓得迷路了1 小时前
栗子前端技术周刊第 72 期 - 快 10 倍的 TypeScript、React Router 7.3、Astro 5.5...
前端·javascript·typescript
xiaoyan20152 小时前
vue3仿Deepseek/ChatGPT流式聊天AI界面,对接deepseek/OpenAI API
前端·vue.js·deepseek