当框架开始「内卷」,EcmaScript官方出手了!
你还在为 Vue 的 ref()、React 的 useState()、Solid 的 createSignal() 疯狂切脑吗?
十年前,jQuery 一统江湖时,没人能想象今天的前端框架会为状态管理卷成这样------每个框架都在造自己的轮子,每个轮子都叫 Signal(信号),但每个轮子长得都不一样。Vue 说"用我的响应式代理",React 说"Hooks 才是未来",Solid 微微一笑:"我比你们都像原子"。
但这场内卷,可能要被 JavaScript 的「亲爹」TC39 终结了。
随着proposal-signals的提案横空出世,它直接放话:"别吵了,我来给 JavaScript 造个原生的响应式引擎!" 它要做的事情很简单------把 Signal 写进 ECMAScript 标准,让浏览器自带响应式超能力。
这意味着什么?
- 🔥 未来你可能不再需要
useEffect
和watch
的魔法 - 🚀 跨框架组件共享状态?一个原生 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()
获取待更新的信号 - 🎛️ 调度控制 :可整合
requestAnimationFrame
或queueMicrotask
批量更新
危险武器库(普通开发者慎用)
提案还藏了一堆「灭霸手套」级 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 仅供尝鲜,但未来可能重塑前端生态,成为浏览器与框架的通用响应式引擎。