40行原生JS实现Signal模式——告别重渲染,把响应式精确到DOM文本节点

很长一段时间里,前端响应式几乎等于“数据变了就重新跑一遍render函数,然后虚拟DOM打补丁”。这套模式足够通用,但有一个怎么都绕不过去的毛病:一个状态变了,哪怕只影响页面角落里的一小段文字,整个组件都得重新执行一遍。React用memo、useMemo各种手段去缓解,Vue用组件级依赖追踪尽量避免组件重新渲染,但本质上还是在“重新计算”这条路上打转。直到Signal这个概念借助SolidJS、Preact Signals、Qwik这些新一批框架火起来,大家才猛然意识到——原来完全可以不重新执行组件,只更新实际用到的那几个DOM节点。

Signal这个思路其实不算新,十几年前Knockout.js就搞过类似的东西,但那时候没火起来。如今重新翻红,很大程度上是因为现代框架把虚拟DOM这条路走到了极致,人们开始重新审视“精确更新”的价值。而且有意思的是,Signal这种东西并不一定需要框架,用四五十行原生JavaScript就能搭出一个完全可用的信号系统,然后直接在普通页面里用起来。这篇文章就来做这件事,一边拆解原理,一边拼出一个麻雀虽小五脏俱全的Signal方案。

一、Signal到底是个什么概念

用一句大白话说:Signal是一套“自动追踪依赖关系的状态管理方式”。它由三样东西组成:

  • 信号(Signal):一个能读能写的值容器。读的时候,如果当前有正在运行的“副作用”,这个副作用就会被记录成依赖;写的时候,自动通知所有依赖它的东西去更新。
  • 计算属性(Computed):一个基于其他信号计算出来的值,它自己也是只读信号。当它依赖的信号变化时,它会自动重新计算,并且它自己的依赖者也会被通知。
  • 副作用(Effect):一段会在依赖变化时自动重新执行的代码。通常就是更新DOM的函数。

传统响应式(比如Vue 2的Object.defineProperty、Vue 3的Proxy)也是这套逻辑,但有个关键区别:它们跟踪依赖的时候,绑定的更新目标是“组件”,而不是具体的DOM节点。Signal跟踪依赖的粒度则更细,绑定到的是每一个读取了信号的表达式。这意味着如果某个DOM节点只显示了一个信号的值,那么当这个信号变化时,只需要更新那个节点的文本内容,连它的父元素都不需要重新创建。

这个特性让Signal特别适合需要局部高频更新的场景,比如实时股票行情、在线协作白板、聊天输入提示等等。在这些地方,虚拟DOM的diff开销会变得不可忽略,而Signal的精确更新几乎没有浪费。

二、从零手写Signal——三个函数搞定核心

动手实现之前,先明确目标。我们要达成这样三个效果:

  1. 创建一个信号,可以读写值。
  2. 创建一个副作用,它能自动追踪自己读过的信号,并在那些信号变化时重新执行。
  3. 创建一个计算属性,它自动追踪依赖的信号,值变化时通知它的依赖者。

核心代码不到五十行,我们先从最底层的依赖追踪开始。

1. 全局的“当前正在运行的副作用”

为了实现自动追踪,我们需要一个全局变量来存储当前正在执行的副作用函数。任何信号在它被读取的时候,都会检查这个全局变量,如果存在,就把自己注册到这个副作用函数的依赖列表里。

let currentEffect = null;

2. 信号工厂函数

信号就是一个对象,有getset方法。get的时候进行依赖收集,set的时候触发更新。

function createSignal(initialValue) {
    let value = initialValue;
    // 依赖集合,存储所有依赖这个信号的effect回调
    const subscribers = new Set();

    return {
        get() {
            // 如果有正在执行的effect,把它的回调注册进来
            if (currentEffect) {
                subscribers.add(currentEffect);
            }
            return value;
        },
        set(newValue) {
            if (value !== newValue) {
                value = newValue;
                // 值变了,通知所有依赖者重新执行
                subscribers.forEach(effect => effect());
            }
        }
    };
}

上面这段代码里,subscribers是一个Set,存的是副作用函数本身。之所以用Set,是为了避免同一个effect被重复收集。当set被调用时,遍历执行这些函数,信号就完成了它的通知使命。

3. 副作用函数

副作用函数需要包裹一层,在执行内部逻辑之前把自己设置为全局的currentEffect,执行完毕再清空。这个过程叫“依赖收集”。同时,副作用函数本身也需要支持初始化执行一次。

function createEffect(fn) {
    const effect = () => {
        currentEffect = effect;
        fn();
        currentEffect = null;
    };
    // 立刻执行一次,完成初次依赖收集和渲染
    effect();
    return effect;
}

这里有个细微但重要的事情:effect函数在每次执行的时候,依赖关系会重新收集。这意味着如果某次执行中没有读取某个信号,那么那个信号的变化就不会触发这个effect。依赖是动态的,不是静态绑定。

4. 计算属性

计算属性本身是一个只读信号,它内部通过一个副作用来监听依赖的变化,并在依赖变化时更新自己的值,然后通知自己的订阅者。

function createComputed(fn) {
    const signal = createSignal();
    createEffect(() => {
        signal.set(fn());
    });
    return {
        get: signal.get
    };
}

这个实现非常简洁。计算属性内部创建了一个信号,然后通过副作用监听fn里读取的所有信号。只要依赖变化,副作用重新执行,给内部信号写入新值,进而通知依赖这个计算属性的其他effect。虽然这里每次都会执行一次set,但内部信号在值没变时不会触发订阅者(因为我们在set里做了value !== newValue的判断)。

至此,核心三个函数完成,总共确实只有四十行左右。

三、实战:用Signal驱动一个纯原生DOM应用

光有核心函数还不够,我们需要把它们和实际的DOM操作连接起来。假设我们要做一个简单的计数器应用,界面包含一个数字显示和两个按钮(加一、减一)。完全不使用任何框架,只用原生DOM API。

HTML结构很简单:

<div id="app">
    <div id="count-display"></div>
    <button id="increment-btn">加一</button>
    <button id="decrement-btn">减一</button>
</div>

接下来用Signal来管理状态和更新DOM。在脚本里,我们会创建一个计数器信号,然后用副作用把信号值绑定到显示元素上,同时给按钮绑定事件来修改信号值。

// 假设上面的 createSignal、createEffect 已经定义好了

const count = createSignal(0);

// 获取DOM元素
const displayEl = document.getElementById('count-display');
const incBtn = document.getElementById('increment-btn');
const decBtn = document.getElementById('decrement-btn');

// 用副作用建立数据到DOM的绑定
createEffect(() => {
    displayEl.textContent = count.get();
});

// 按钮事件直接操作信号
incBtn.addEventListener('click', () => {
    count.set(count.get() + 1);
});

decBtn.addEventListener('click', () => {
    count.set(count.get() - 1);
});

运行这段代码,点击按钮时数字会实时更新。关键是,更新过程完全没有重新生成DOM结构,也没有虚拟DOM diff,甚至连innerHTML都没用。变化的只有#count-display这个元素的文本节点。这种更新方式在大型应用里可以显著减少不必要的DOM操作。

把计数器换成更复杂的Todo列表,效果也是类似的。假设有一个信号存储任务列表(数组),一个计算属性用来展示“剩余未完成任务数量”。每一处用到这些数据的地方各自独立更新。

const todos = createSignal([
    { text: '学习Signal', completed: false },
    { text: '写技术文章', completed: true }
]);

const remainingCount = createComputed(() => {
    return todos.get().filter(t => !t.completed).length;
});

// 副作用:渲染列表
createEffect(() => {
    const listEl = document.getElementById('todo-list');
    listEl.innerHTML = ''; // 简单起见重新构建,真实场景可做更细粒度
    todos.get().forEach((todo, index) => {
        const li = document.createElement('li');
        li.textContent = todo.text + (todo.completed ? ' ✅' : '');
        li.addEventListener('click', () => {
            const newTodos = todos.get().slice();
            newTodos[index].completed = !newTodos[index].completed;
            todos.set(newTodos);
        });
        listEl.appendChild(li);
    });
});

// 副作用:更新剩余数量
createEffect(() => {
    document.getElementById('remaining').textContent = `剩余:${remainingCount.get()}`;
});

这里为了演示方便,列表的渲染用了innerHTML清空重建,这实际上丢失了Signal细粒度更新的优势。如果追求极致性能,我们可以让每条Todo项都自己拥有一个独立的effect,只更新那条项的状态文字。不过那就需要更细粒度的绑定机制,比如SolidJS里那种把每个数据项映射为独立信号的做法。原理是相通的,实现起来也不复杂,只是代码会多一些。

四、Signal模式中几个容易忽略的细节

上面这套简易实现,已经能让你体会到Signal的基本运作方式,但实际用起来还有几个需要填的坑。

1. 防止effect嵌套导致的依赖混乱

如果一个effect里面又创建了另一个effect,或者effect执行过程中触发了其它effect,currentEffect的全局单值设计就会出错。正确的做法是用一个栈来管理当前正在执行的effect,而不是简单的一个变量。这样嵌套执行时,内层effect执行完毕后能正确恢复外层的currentEffect

const effectStack = [];

function createEffect(fn) {
    const effect = () => {
        effectStack.push(effect);
        currentEffect = effect;
        fn();
        effectStack.pop();
        currentEffect = effectStack.length > 0 ? effectStack[effectStack.length - 1] : null;
    };
    effect();
    return effect;
}

2. 批量更新

如果在一个同步操作里连续修改多个信号,每个信号变化都会立即触发对应的effect,可能导致多次DOM更新。虽然浏览器通常会把多次DOM操作合并成一次渲染,但effect里可能还有其它副作用(比如网络请求)。很多Signal实现都采用了“批量更新”机制,在一个微任务或者requestAnimationFrame里统一执行所有被触发的effect。我们的简易版没做这个,但通过一个简单的batch函数就能实现。

let batchDepth = 0;
const pendingEffects = new Set();

function batch(fn) {
    batchDepth++;
    fn();
    batchDepth--;
    if (batchDepth === 0) {
        pendingEffects.forEach(effect => effect());
        pendingEffects.clear();
    }
}

然后在createSignalset里,根据batchDepth决定是立即执行还是先加入pendingEffects等待批量处理。

3. 副作用清理

如果一个effect里注册了事件监听或者定时器,当它重新执行时,需要先把旧的事件监听清掉,否则会越积越多。高级一点的Signal实现会提供onCleanup之类的机制,允许effect内部注册清理函数。我们也可以在createEffect里加入这一层支持,每次执行effect前先调用上一次注册的清理函数。

五、Signal的适用边界与思考

Signal这种模式最大的优点是精确更新和几乎零开销的依赖追踪,特别适合交互复杂、高频更新的界面。但它也不是银弹。在使用Signal时,你需要显式地管理每个数据的读取点,这会让代码里到处散落着count.get()的调用,相比Vue的模板自动解包或者React的JSX直接读取变量,写法上会更啰嗦一点。SolidJS通过编译器在编译时帮你加上.get(),解决了这个问题,但原生JS场景下这个手写成本确实存在。

另一个需要注意的地方是,Signal的依赖追踪是同步的,在read时收集。如果你在异步回调里读取信号的值,那这个读取不会被当前正在执行的effect追踪到,除非你手动建立了关联。这也是为什么很多框架会要求effect里所有数据读取都放在同步代码里执行。

不过,整体来看,Signal给了开发者一种完全不同于虚拟DOM状态管理的思路。它不需要你规划组件的粒度,也不需要费心去memo和shouldComponentUpdate,只要搞清楚数据从哪来、到哪去,剩下的更新交给依赖图去自动处理。在一些非框架场景,比如写一个浏览器插件、一个油猴脚本、或者一个Web Component内部状态管理,用Signal会比引进一整个框架轻量得多。

回到开头那句话,用四十行JS写出自己的Signal,并不是要替代React或Vue,而是帮助你穿透框架表面的API,看到响应式系统最核心的那几个运转齿轮。当你理解了信号、依赖收集、副作用调度这三件事,再看SolidJS、Vue Reactivity或MobX的时候,就不会觉得它们是什么黑魔法,只是一些工程化包装得更好的版本而已。

下次遇到一个并不需要重型框架的页面,不妨把上面那四十行代码塞进去,试试看用Signal直接操作DOM是不是会让开发体验清爽不少。

40行原生JS实现Signal模式——告别重渲染,把响应式精确到DOM文本节点
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请联系本站我们会及时删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 javascript 40行原生JS实现Signal模式——告别重渲染,把响应式精确到DOM文本节点 https://www.taomawang.com/web/javascript/2417.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务