原生JavaScript实现Signal响应式系统:从零构建细粒度状态管理引擎

如果你最近关注前端框架的动向,一定注意到一个有趣的现象——Angular团队在大力推广Signal,SolidJS凭借细粒度响应式在一众框架中杀出重围,Preact Signals的npm下载量也在稳步攀升,Vue的ref/runtime实现和Signal有着千丝万缕的联系。TC39甚至已经有一份关于将Signal标准化到JavaScript语言本身的提案在讨论中。Signal这个概念正在从框架的内部机制走向语言层面的基础设施。

但抛开这些框架层面的讨论,Signal的内核到底是什么?它为什么能让状态变更如此高效?我花了一些时间阅读SolidJS和Preact Signals的源码,又参考了TC39提案中的设计思路,决定用最朴素的方式把核心机制复现一遍——不依赖任何框架,就用原生JavaScript。

这篇文章会带你从零开始,一步步实现一个功能完备的Signal响应式系统。我们会先搭出一个最简陋的版本,然后逐步补全computed、effect嵌套、依赖追踪和批量更新这些关键能力。最后用一个购物车的例子把整套机制串起来。整个实现大约200行代码,麻雀虽小但五脏俱全。

从一个简单的想法说起

Signal模式的核心思想用一句话就能概括:当你改变一个值的时候,所有依赖这个值的地方自动更新。这和EventEmitter那种”我变了,你自己来拿”的模式不同,Signal是推拉结合的——值变了主动通知,但具体数据是按需计算的。

实现这个机制需要解决三个问题:第一,怎么知道谁依赖了谁(依赖收集);第二,值变化时怎么通知依赖方(派发更新);第三,如果有中间计算步骤(比如A变了导致B也要变),这个链路怎么自动串起来。

先看一个最简版本的实现,它只完成依赖收集和派发更新这两件事。这个版本虽然简陋,但已经能跑通核心流程了。

// === 最简实现:只有 signal 和 effect ===

// 用一个全局变量标记"当前正在执行的effect"
let currentEffect = null;

function signal(initialValue) {
    let value = initialValue;
    // 用一个Set存所有依赖这个signal的effect
    const subscribers = new Set();

    return {
        get value() {
            // 读取时收集依赖
            if (currentEffect !== null) {
                subscribers.add(currentEffect);
            }
            return value;
        },
        set value(newValue) {
            if (newValue === value) return;
            value = newValue;
            // 写入时通知所有订阅者
            for (const sub of subscribers) {
                sub();
            }
        }
    };
}

function effect(fn) {
    // 把fn设为当前effect,然后立即执行一次
    // 执行过程中但凡读到signal的value,就会自动收集
    const wrapped = () => {
        currentEffect = wrapped;
        try {
            fn();
        } finally {
            currentEffect = null;
        }
    };
    wrapped();
    return wrapped;
}

上面的代码不到30行,但已经包含了Signal模式最关键的机制。我拆开来讲一下执行过程:

当你调用effect(() => console.log(count.value))时,wrapped函数先把currentEffect指向自己,然后执行回调。回调里读到count.value,触发getter,getter发现currentEffect不为null,就把这个effect函数塞进subscribers集合里。这样一来,后续只要count.value被修改,setter就会遍历subscribers,逐个执行这些effect。

这个机制妙就妙在依赖关系完全由运行时自动推导,不需要你手动声明。你读哪个signal,就自动订阅哪个signal。而且try...finally保证无论回调是否抛错,currentEffect都会被重置,避免污染后续的逻辑。

不过这个版本有个明显的缺陷——如果effect里又嵌套了effect,外层的currentEffect会被内层覆盖,导致依赖收集错乱。另外computed(派生状态)还没实现,光有signal和effect在实际场景中不太够用。

处理嵌套effect

嵌套effect在真实场景中并不罕见。比如你写了一个组件,组件本身有一个effect监听某个状态,组件内部又调用了另一个包含effect的工具函数。这时候effect就嵌套了。

修复方案不复杂,用一个栈来保存currentEffect的上下文就行:

let currentEffect = null;
const effectStack = [];

function effect(fn) {
    const wrapped = () => {
        // 入栈
        effectStack.push(wrapped);
        currentEffect = wrapped;
        try {
            fn();
        } finally {
            // 出栈,恢复上一层effect
            effectStack.pop();
            currentEffect = effectStack.length > 0
                ? effectStack[effectStack.length - 1]
                : null;
        }
    };
    wrapped();
    return wrapped;
}

用栈结构来管理effect的执行上下文,这样无论嵌套多少层,依赖收集都能精准地绑定到当前正在执行的那个effect上。这个思路其实和React的Hooks实现中用来追踪组件实例的机制有点像,只不过我们这里处理的是effect而非组件。

实现computed:自动追踪的派生值

computed本质上是一个特殊的signal,它的值不是直接赋的,而是通过一个计算函数推导出来的。关键在于:computed需要追踪它依赖了哪些signal,当这些signal变化时,computed要重新计算。同时,computed本身也可以被effect依赖,形成依赖链。

实现computed有一个容易踩坑的地方——它需要处理”脏标记”。如果每次读取computed的值都重新计算,那性能就太差了。合理的做法是:依赖没变的时候直接返回缓存值,只有依赖变了才标记为”脏”,下次读取时重新计算。

function computed(fn) {
    let cachedValue;
    let isDirty = true; // 初始状态需要计算
    const subscribers = new Set();

    // 用一个内部effect来追踪computed依赖了哪些signal
    const updateEffect = effect(() => {
        if (isDirty) {
            const newValue = fn();
            if (newValue !== cachedValue) {
                cachedValue = newValue;
                // computed的值变了,通知依赖computed的订阅者
                for (const sub of subscribers) {
                    sub();
                }
            }
        }
        isDirty = false;
    });

    return {
        get value() {
            // 谁读了computed,就和这个computed绑定
            if (currentEffect !== null) {
                subscribers.add(currentEffect);
            }
            // 如果脏了,触发重新计算
            if (isDirty) {
                updateEffect();
            }
            return cachedValue;
        }
    };
}

等等,上面这段代码有个循环逻辑的问题——updateEffect本身是一个effect,它执行时如果读到signal,signal会把updateEffect加入自己的订阅者列表。当signal变化时,updateEffect被调用,但它并不会立即重算fn(),而是把isDirty设为true。真正重算发生在下次读取computed的value时(懒计算)。

不过这段代码还差一个关键环节:signal变化后,怎么让computed知道该标脏了?我们需要在signal的setter中主动通知computed的内部effect。这其实已经通过effect机制自动完成了——updateEffect在初次执行时读了signal的value,signal已经把updateEffect加入了订阅者列表。当signal变化,updateEffect被调用,它把isDirty置为true。

整理一下依赖链:signal → computed的内部effect → computed标脏 → 依赖computed的effect重新读取 → 触发重算。这条链完全自动串联,不需要手动干预。

不过上面的computed实现还需要打磨。目前的问题在于,updateEffect被signal触发后只负责标脏,但依赖computed的外部effect不会自动重新执行——除非computed主动通知它们。我们得在标脏的同时触发computed自身的订阅者。来,重写一版更健壮的:

function computed(fn) {
    let cachedValue;
    let isDirty = true;
    const subscribers = new Set();

    // 这个effect在signal变化时被调用
    const runner = effect(() => {
        // 这里故意读一下fn()来建立依赖
        const newValue = fn();
        if (!isDirty || newValue === cachedValue) {
            // 依赖变了但值没变,不通知下游
            isDirty = false;
            return;
        }
        cachedValue = newValue;
        isDirty = false;
        // 通知依赖这个computed的订阅者
        for (const sub of [...subscribers]) {
            sub();
        }
    });

    return {
        get value() {
            if (currentEffect !== null) {
                subscribers.add(currentEffect);
            }
            if (isDirty) {
                // 懒计算:读到脏数据时强制刷新
                runner();
            }
            return cachedValue;
        }
    };
}

这一版的关键改进在于:computed内部的effect(runner)在signal变化时被触发,它不仅重新计算了值,还主动通知了依赖computed的外部订阅者。这样无论是同步读取还是异步更新,依赖链都不会断裂。

有个细节值得注意:[...subscribers]做了一个浅拷贝再遍历。这是为了防止在遍历过程中subscribers被修改(比如某个effect执行时又添加了新的订阅),导致迭代器异常。这种防御性写法在实际项目中经常能救你一命。

batch:把多次更新合并成一次

设想一个场景:你连续修改了三个signal,每个signal都触发了一轮effect执行。如果这三个signal被同一个computed依赖,那computed会被触发三次,而前两次的计算结果根本没机会被用到。这不仅浪费,还可能导致中间状态被错误地暴露给外部。

batch的作用就是圈定一个范围,在这个范围内的所有signal变更先攒着,等batch结束再一次性触发所有受影响的effect。这样中间状态就不会外泄。

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

function batch(fn) {
    batchDepth++;
    try {
        fn();
    } finally {
        batchDepth--;
        if (batchDepth === 0) {
            // 批量执行积攒的effect
            const effectsToRun = [...pendingEffects];
            pendingEffects.clear();
            for (const ef of effectsToRun) {
                ef();
            }
        }
    }
}

光有batch函数还不够,signal的setter也得配合——在batch期间不立即触发订阅者,而是把它们塞进pendingEffects,等batch结束再统一处理。我们需要改造signal的setter:

function signal(initialValue) {
    let value = initialValue;
    const subscribers = new Set();

    return {
        get value() {
            if (currentEffect !== null) {
                subscribers.add(currentEffect);
            }
            return value;
        },
        set value(newValue) {
            if (newValue === value) return;
            value = newValue;
            if (batchDepth > 0) {
                // 在batch中,延迟通知
                for (const sub of subscribers) {
                    pendingEffects.add(sub);
                }
            } else {
                for (const sub of subscribers) {
                    sub();
                }
            }
        }
    };
}

这样一来,下面的代码就安全了:

batch(() => {
    firstName.value = 'Zhang';
    lastName.value = 'San';
    age.value = 28;
});
// batch结束后,依赖这些signal的effect只执行一次

batch在框架层面非常有用。比如组件初始化时一口气设置多个状态,或者处理一批用户输入时,batch可以显著减少不必要的重复计算。

防止循环依赖和无限递归

实际使用中,循环依赖是个绕不开的问题。比如computed A依赖signal B,而signal B的effect又间接读了computed A,这就形成了一个环。不处理的话,setter触发effect,effect触发computed重算,computed又触发setter……直接栈溢出。

一个轻量级的防护手段是给effect执行加一个递归深度限制,或者在effect执行前检查它是否已经在调用栈中。这里用一个简单的标记位来处理:

function effect(fn) {
    let running = false;
    const wrapped = () => {
        if (running) return; // 防止重入
        effectStack.push(wrapped);
        currentEffect = wrapped;
        running = true;
        try {
            fn();
        } finally {
            running = false;
            effectStack.pop();
            currentEffect = effectStack.length > 0
                ? effectStack[effectStack.length - 1]
                : null;
        }
    };
    wrapped();
    return wrapped;
}

这个running标记确保同一个effect不会在尚未执行完毕时被再次触发。虽然不能从根源上解开循环依赖,但至少避免了栈溢出,给开发者留出了排查问题的时间。

如果你想做得更完善,可以在computed内部维护一个依赖图,检测到环时在控制台打印警告。不过生产级别的循环检测会带来不小的性能开销,大部分Signal库选择把这个责任交给开发者——设计合理的状态结构,避免循环依赖。

清理effect:别让过期的订阅一直留着

在实际应用中,effect可能会被创建又销毁。比如一个组件卸载了,它注册的effect就应该停止响应状态变更。如果不清理,不仅浪费内存,还可能引发”在已卸载组件上更新状态”的经典bug。

给effect加上清理能力很简单——返回一个dispose函数就行:

function effect(fn) {
    let disposed = false;
    let running = false;

    const wrapped = () => {
        if (disposed || running) return;
        effectStack.push(wrapped);
        currentEffect = wrapped;
        running = true;
        try {
            fn();
        } finally {
            running = false;
            effectStack.pop();
            currentEffect = effectStack.length > 0
                ? effectStack[effectStack.length - 1]
                : null;
        }
    };
    wrapped();

    return () => {
        disposed = true;
        // 注意:这里没有从signal的subscribers中移除wrapped
        // 完整实现需要在signal侧维护反向引用,篇幅所限这里先略过
        // 实际生产中Preact Signals用了一个巧妙的节点链表来解决这个问题
    };
}

说实话,从signal的subscribers集合中精确移除某个effect是个有点头疼的问题。如果signal存的是effect引用,而effect被dispose后这些引用就成了僵尸数据。Preact Signals的解决方案是让每个effect维护一个”它订阅了哪些signal”的列表,dispose时遍历这个列表把自己从各个signal中摘除。这套机制需要一个双向链表结构,代码量会翻倍。上面的简化版用disposed标记来阻止已销毁的effect再次执行,虽然没清理引用,但在大多数场景下已经够用了。

完整的Signal引擎代码

把前面所有的代码整合起来,得到一个功能基本完备的Signal引擎:

// ===== 完整实现 =====

let currentEffect = null;
const effectStack = [];
let batchDepth = 0;
const pendingEffects = new Set();

function signal(initialValue) {
    let value = initialValue;
    const subscribers = new Set();

    return {
        get value() {
            if (currentEffect !== null) {
                subscribers.add(currentEffect);
            }
            return value;
        },
        set value(newValue) {
            if (Object.is(newValue, value)) return;
            value = newValue;
            if (batchDepth > 0) {
                for (const sub of subscribers) {
                    pendingEffects.add(sub);
                }
            } else {
                for (const sub of [...subscribers]) {
                    sub();
                }
            }
        },
        // 暴露订阅者数量,调试用
        get _subscriberCount() {
            return subscribers.size;
        }
    };
}

function effect(fn) {
    let disposed = false;
    let running = false;

    const wrapped = () => {
        if (disposed || running) return;
        effectStack.push(wrapped);
        currentEffect = wrapped;
        running = true;
        try {
            fn();
        } finally {
            running = false;
            effectStack.pop();
            currentEffect = effectStack.length > 0
                ? effectStack[effectStack.length - 1]
                : null;
        }
    };
    wrapped();

    return () => {
        disposed = true;
    };
}

function computed(fn) {
    let cachedValue;
    let isDirty = true;
    const subscribers = new Set();

    const runner = effect(() => {
        const newValue = fn();
        if (!isDirty && Object.is(newValue, cachedValue)) {
            isDirty = false;
            return;
        }
        cachedValue = newValue;
        isDirty = false;
        for (const sub of [...subscribers]) {
            sub();
        }
    });

    return {
        get value() {
            if (currentEffect !== null) {
                subscribers.add(currentEffect);
            }
            if (isDirty) {
                runner();
            }
            return cachedValue;
        }
    };
}

function batch(fn) {
    batchDepth++;
    try {
        fn();
    } finally {
        batchDepth--;
        if (batchDepth === 0 && pendingEffects.size > 0) {
            const effectsToRun = [...pendingEffects];
            pendingEffects.clear();
            for (const ef of effectsToRun) {
                ef();
            }
        }
    }
}

全部加在一起不到100行。这套代码可以直接复制到浏览器控制台或者Node.js里运行。接下来用购物车的例子来验证它好不好使。

实战案例:一个响应式购物车

我们用刚写好的Signal引擎来构建一个购物车的状态管理层。需求很常见:商品列表、添加/移除商品、修改数量、自动计算总价和总数量,外加一个”是否包邮”的判断(满99包邮)。

// ===== 购物车案例 =====

// 商品数据
const cartItems = signal([
    { id: 1, name: '机械键盘', price: 349, quantity: 1 },
    { id: 2, name: '鼠标垫', price: 29, quantity: 2 },
    { id: 3, name: 'Type-C数据线', price: 19, quantity: 1 },
]);

// 派生状态:总数量
const totalQuantity = computed(() => {
    return cartItems.value.reduce((sum, item) => sum + item.quantity, 0);
});

// 派生状态:总价
const totalPrice = computed(() => {
    return cartItems.value.reduce(
        (sum, item) => sum + item.price * item.quantity,
        0
    );
});

// 派生状态:是否包邮
const freeShipping = computed(() => totalPrice.value >= 99);

// 派生状态:还差多少包邮
const gapToFreeShipping = computed(() => {
    if (freeShipping.value) return 0;
    return Math.round((99 - totalPrice.value) * 100) / 100;
});

// 副作用:当总价变化时打印摘要
const logEffect = effect(() => {
    const summary = [
        `商品数: ${totalQuantity.value} 件`,
        `总价: ¥${totalPrice.value}`,
        freeShipping.value ? '✅ 已包邮' : `📦 还差 ¥${gapToFreeShipping.value} 包邮`
    ].join(' | ');
    console.log('[购物车]', summary);
});

// 模拟操作
console.log('=== 初始状态 ===');
// (effect在创建时已自动执行一次,所以初始状态已经打印了)

console.log('n=== 增加数据线数量 ===');
const currentItems = cartItems.value;
currentItems[2].quantity = 3;
cartItems.value = [...currentItems]; // 触发更新

console.log('n=== 再买一个耳机 ===');
cartItems.value = [
    ...cartItems.value,
    { id: 4, name: '蓝牙耳机', price: 199, quantity: 1 }
];

console.log('n=== 使用batch批量修改 ===');
batch(() => {
    const items = cartItems.value;
    items[0].quantity = 2;
    items[1].quantity = 1;
    cartItems.value = [...items];
});
// batch结束后effect只触发一次

// 清理
logEffect();

在浏览器控制台运行这段代码,你会看到类似这样的输出:

[购物车] 商品数: 4 件 | 总价: ¥426 | ✅ 已包邮
[购物车] 商品数: 6 件 | 总价: ¥446 | ✅ 已包邮
[购物车] 商品数: 8 件 | 总价: ¥645 | ✅ 已包邮
[购物车] 商品数: 8 件 | 总价: ¥996 | ✅ 已包邮

注意batch那一步——我们同时修改了键盘和鼠标垫的数量,但日志只打印了一次。这就是batch在起作用。如果没有batch,两个signal变更会触发两次effect执行,中间那次计算其实是无效的。

细心的你可能发现了一个问题:修改数组内元素的属性时,我需要用cartItems.value = [...cartItems.value]这种”整体替换”的方式来触发更新。这是因为我们的signal用Object.is做新旧值比较,直接cartItems.value[0].quantity = 2不会改变数组引用,signal认为值没变,就不会通知订阅者。这是一个设计取舍——深比较会带来性能开销,浅比较则需要开发者在修改嵌套对象时显式创建新引用。SolidJS和Preact Signals都选择了浅比较,配合不可变数据的实践来使用。

如果你想让signal支持深层响应,可以在setter里做一层浅比较后强制触发,或者像Vue那样用Proxy包裹整个对象。不过那就进入了另一个话题——基于Proxy的响应式系统,它的实现复杂度和本文讲的Signal模式有本质区别。

这套实现和框架里的Signal有什么差距

坦诚地讲,上面这套代码和Preact Signals或SolidJS的production实现之间还有不少距离。主要的差距集中在这些方面:

依赖追踪的精确度。我们的实现中,effect重新执行时会清掉旧的依赖、重建新的依赖。但如果effect内部有分支逻辑(if-else),执行路径变了,旧分支的依赖可能还残留着。Preact Signals用了一个精妙的双向链表结构,每次effect执行前先”清理”上一轮的依赖关系,确保不会有僵尸依赖。

内存管理。前面提到过,dispose一个effect后,signal的subscribers里还留着它的引用。短生命周期的应用无所谓,但长期运行的SPA中这会导致内存缓慢泄漏。生产级的Signal库会维护完整的反向依赖图。

异步和微任务调度。我们的effect是同步执行的,signal一变化立刻触发。但在真实场景中,把effect的执行推迟到微任务队列(用queueMicrotaskPromise.resolve())能带来更好的批量效果,也能避免在DOM操作中途触发重排。SolidJS的调度器在这方面做了大量优化。

错误边界。如果某个effect抛了异常,不应该影响其他effect的执行。我们的实现用try...finally保护了currentEffect的恢复,但没有隔离错误——一个effect炸了,同一批次的后续effect都不会执行。加一个try-catch包裹每个effect调用就能解决。

不过这些”不足”恰恰是学习Signal最有价值的部分。当你理解了基础实现,再去看Preact Signals或SolidJS的源码时,会发现那些复杂的数据结构和优化策略都有了清晰的动机——它们都是在解决上述这些具体问题。

Signal解决了什么问题,没有解决什么问题

Signal模式最适合的场景是:状态之间有明确的派生关系,而且这些关系可以用有向无环图来描述。购物车总价依赖每个商品的价格和数量,包邮状态依赖总价——这种依赖链用Signal表达非常自然。相比之下,用Redux处理同样的逻辑需要写reducer、action、selector,样板代码多出不少。

但Signal不是万能药。它管的是”数据怎么变”,不管”数据从哪来”。异步请求、服务端推送、WebSocket消息这些外部数据源,还是需要你自己封装。另外,Signal的依赖图在运行时动态构建,如果状态结构过于复杂(成百上千个相互关联的signal),调试时会比较痛苦——你很难一眼看清完整的依赖拓扑。

还有一个容易被忽略的点:Signal是同步的、精确的,但DOM更新往往需要批量和异步。把Signal直接接上DOM操作(像我们在购物车例子中做的那样)在小规模场景下完全可行,但在大规模应用中,你需要在Signal层和渲染层之间加一个调度层。这正是SolidJS的渲染器在做的事情。

写在最后

用不到100行原生JavaScript实现一个Signal响应式系统,这件事本身就说明了Signal模式的核心并不复杂。它的精髓在于把”状态-依赖-更新”这个循环自动化了,让开发者只需要声明状态和计算规则,剩下的事情交给运行时处理。

TC39的Signal提案如果最终落地,意味着浏览器会原生提供Signal构造函数和相关的ComputedWatcherAPI。到那时,框架可以站在语言原语的基础上做更高层的抽象,不同框架之间的状态甚至可以互通——因为底层是同一套Signal机制。这对前端生态的影响可能比当年Promise/A+规范统一异步接口还要深远。

如果你对Signal的内部机制还有好奇,推荐去读Preact Signals的源码,它的实现非常紧凑,核心逻辑也就几百行。SolidJS的源码也很值得研究,特别是它的调度器和渲染器如何与Signal协同工作。理解了这些,你会对”响应式”三个字有全新的认识。


附录:在浏览器中运行完整示例

将以下代码复制到浏览器控制台即可运行完整的购物车示例(包含上面实现的Signal引擎和购物车逻辑):

// 复制上面的"完整实现"代码段(signal、effect、computed、batch四个函数)
// 然后粘贴,接着复制"购物车案例"代码段
// 就能在控制台看到完整的响应式效果

如果你想在此基础上扩展,可以尝试加上这些功能:从localStorage持久化购物车数据、添加商品库存校验、或者把日志输出换成实际的DOM更新。你会发现,Signal引擎本身不需要任何修改——它只负责状态管理,展示层的事情交给展示层。

原生JavaScript实现Signal响应式系统:从零构建细粒度状态管理引擎
收藏 (0) 打赏

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

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

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

淘吗网 javascript 原生JavaScript实现Signal响应式系统:从零构建细粒度状态管理引擎 https://www.taomawang.com/web/javascript/2424.html

常见问题

相关文章

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

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