信号驱动:在原生JavaScript中实现轻量级响应式系统

前端框架的状态管理经常被描述得很玄乎,但从本质上看,它们大部分都解决同一个问题:一个变量的值变了,页面里用到它的地方怎么跟着更新。这几年,Signal(信号)这个概念从Solid.js开始被广泛接受,后来Preact、Angular甚至Vue的核心都引入了类似的机制。

Signal不是一个框架才有的高级特性,它其实就是一段几十行就能写明白的代码。今天咱们抛开框架,徒手实现一个能跑的、支持computed和effect的signal仓库。不依赖浏览器API,也不依赖框架,就是纯纯的JavaScript逻辑。

这段代码写完之后,你可以把它直接塞进任何项目里用。即使不用,理解了这个机制之后,你再看Vue的ref、Solid的createSignal,会感觉像是看老朋友一样。

从“发布订阅”到“依赖收集”

很多文章一上来就扔出“发布订阅”、“观察者模式”这种名词。Signal其实比它们更细腻一点。发布订阅的典型场景是:我发布一个事件,所有订阅了的人都会收到通知。至于这个通知和订阅者有没有关系,事件系统完全不关心。

Signal要解决的是另外一个事:我读了一个变量,只有真正的读取行为发生,才把这个变量记录下来。当变量发生变化的时候,它只通知那些真正“读过它”的代码。如果一段代码定义了函数但从来没执行,那它读信号的行为不会被统计。

这种做法的关键是“运行时自动追踪”,靠的就是JavaScript的函数执行栈。当我的effect函数开始执行时,我先把“当前正在运行的effect”记录在一个全局变量里。然后函数继续执行,执行过程中一旦发生了signal的get操作,这个signal就把“当前正在运行的effect”拉进自己的依赖列表里。这就是所谓的依赖收集。

先不谈太深的理论,咱们直接写基础类。一个signal只需要保存一个当前值,以及一组依赖它的effect。每次get的时候把当前运行的effect加进去,每次set的时候把依赖列表里的effect全部执行一遍。

let currentEffect = null;

function createSignal(initialValue) {
    let value = initialValue;
    const effects = new Set();

    function get() {
        if (currentEffect !== null) {
            effects.add(currentEffect);
        }
        return value;
    }

    function set(newValue) {
        // 值没变强制不触发,省掉无用的更新
        if (Object.is(value, newValue)) {
            return;
        }
        value = newValue;
        // 执行所有依赖
        effects.forEach(effect => effect());
    }

    return [get, set];
}

不要看到Set就紧张,Set是用来保证同一个effect不会被重复收集多次的。Object.is的判断能解决两个问题:一是NaN不等于NaN的比较,二是+0和-0这种边界。如果你的业务里只有普通的数字字符串,其实这里换成===也行。

写到这,一个“裸的”signal能用了,但没有自动执行的effect,我们得手动调用set之后的回调。真正想让响应式跑起来,还缺一个“把自己注册currentEffect的通道”。

实现一个带自动追踪的effect

effect是一个函数,它期望触发一次,并且在它读取过的任何signal发生变化之后,再自动触发一次。

function createEffect(fn) {
    const effect = () => {
        // 先把全局的currentEffect指向自身
        currentEffect = effect;
        // 执行用户函数,触发内部get的收集
        fn();
        // 收集完之后赶紧把它置空,不然其他地方的signal.get会误收集
        currentEffect = null;
    };

    effect();

    return effect;
}

这里要注意一个问题:如果effect函数内部有分支,比如if (a) { b() },当a的值变化导致分支不再执行b的读取时,b对于这个effect而言就已经过时了。但上面的实现却无法处理“取消订阅”场景。因为上一次收集到的dep还在effects列表里,b以后每次变化都会触发这个effect。

这种过期的依赖会导致内存泄漏加不必要的重执行。要是放在SPA场景下,甚至可能引起无法预估的bug。

要解决这个问题,必须在effect执行前,把自己从所有signal的订阅列表里清理掉,然后再执行读取,重新收集。实现上,我给signal增加一个“移除特定effect”的能力:

function cleanupEffect(effect) {
    effect.dependencies.forEach(dep => dep.delete(effect));
    effect.dependencies.clear();
}

function createEffect(fn) {
    const effect = () => {
        // 执行前先断开所有旧连接
        cleanupEffect(effect);
        currentEffect = effect;
        fn();
        currentEffect = null;
    };

    effect.dependencies = new Set();

    function get() {
        if (currentEffect !== null) {
            currentEffect.dependencies.add(trackedSet);
        }
        // ... 
    }

    effect();
    return effect;
}

看起来有点绕,更简洁的做法是让每个signal都保留一个监听者集合,并且effect自己记录自己访问过的集合。当effect重新运行之前,把所有旧集合里的自己删掉。然后把当前signal作为新集合加进自己的dependencies。

我们直接整合成一份可以运行的精简实现:

let activeEffect = null;

function createSignal(value) {
    const subscribers = new Set();

    const get = () => {
        if (activeEffect !== null) {
            subscribers.add(activeEffect);
            activeEffect.depNames.add(subscribers);
        }
        return value;
    };

    const set = (nextValue) => {
        if (Object.is(value, nextValue)) return;
        value = nextValue;
        // 很重要:需要先复制一份,不然subscribers在遍历里被修改会出问题
        [...subscribers].forEach((fn) => fn());
    };

    return [get, set];
}

function createEffect(fn) {
    const effect = () => {
        // 先清理
        effect.depNames.forEach(depSet => depSet.delete(effect));
        effect.depNames.clear();

        activeEffect = effect;
        fn();
        activeEffect = null;
    };

    effect.depNames = new Set();
    effect();

    return effect;
}

这个版本已经适当地处理了“被取消掉的依赖”。当依赖的分支不再被读取时,对应的subscribers集合里就找不到这个effect了。

为了便于理解,我把subscribers直接叫做“Set”。这里的depNames更像一个“反向索引”,帮助effect知道哪些Set里收了自己,执行前好去把自己删掉。

但createEffect的返回值其实不太常用。一般用户不需要拿到effect的句柄,如果要销毁一个effect,可以用dispose函数。在上面的基础上,再加上返回停用函数即可:

function createEffect(fn) {
    let isDisposed = false;

    const effect = () => {
        if (isDisposed) return;

        effect.depNames.forEach(depSet => depSet.delete(effect));
        effect.depNames.clear();

        activeEffect = effect;
        fn();
        activeEffect = null;
    };

    effect.depNames = new Set();
    effect();

    const dispose = function() {
        isDisposed = true;
        effect.depNames.forEach(depSet => depSet.delete(effect));
        effect.depNames.clear();
    };

    return dispose;
}

到这里,Signal的核心机制不到70行代码就已经完整暴露在眼前了。

为什么需要computed——缓存懒计算

直接靠effect加signal,实现响应式已经够用。但考虑一个性能问题:多个effect同时依赖同一个复杂的派生值,比如“购物车的总价”,如果每一项都实时算一次,不划算。所以有了computed。

computed也是一个signal,但它不产出一个稳定值,而是“根据其他信号计算出结果,结果缓存起来”。只有当依赖的signal发生变化时,computed才重新计算。

computed自己依赖上游信号,同时又是下游effect的依赖。这就有了两个层面的缓存:一是值级缓存:如果上游没变,多次读取computed不会重新执行计算函数;二是订阅关系:上游一变,computed失效并且通知下游。

为了避免“computed更新一次导致下游effect也跟着执行两次”,实现里需要非常谨慎地管理脏状态。先用一个简单但有效的办法:computed内部自己维护一个bool,叫做dirty。当上游信号set触发更新时,不是立刻重新计算,而是把computed置为dirty。

代码是这样的:

function createComputed(computeFn) {
    const [get, set] = createSignal(undefined);
    let cachedValue;
    let isDirty = true;

    // 专门用来标记依赖变动的effect
    const recompute = () => {
        // 标记计算依赖的后代发生变化
        if (!isDirty) {
            isDirty = true;
            // 通知下游(发个信号,但不值得立刻算)
            set(undefined);
        }
    };

    const disposable = createEffect(() => {
        const result = computeFn();
        if (isDirty || result !== cachedValue) {
            cachedValue = result;
            isDirty = false;
            set(result);
        }
        // 第一轮执行完 reset
    });

    // 上面的写法有点尴尬,因为createEffect是立刻执行的,我们没法第一轮就正确处理。
    // 所以需要更明确的lazy模式。
}

这个写法有问题,第一次计算时,isDirty为true,它set(undefined),影响依赖computed的底层effect被误触发。更好的方式:我构建一个“脏检查”但让第一次执行不通知下游。

很多响应式库不会直接通过createEffect来实现computed,而是单独维护依赖。咱们为了理解和易懂,用一个更直观的lazy + effect方案。

function createComputed(computeFn) {
    let currentValue;
    let dirty = true;
    let computeEff;

    const [getValue, setValue] = createSignal(undefined);

    const runCompute = () => {
        if (dirty) {
            const newValue = computeFn();
            if (Object.is(newValue, currentValue)) return;
            currentValue = newValue;
            setValue(newValue);
            dirty = false;
        }
        return currentValue;
    };

    computeEff = createEffect(() => {
        // 这里的代码会在依赖变化时执行,但我们的compute还没跑,
        // 所以先在effect运行中调用 computeFn 来让依赖收集作用在 computeEff 上
        dirty = true;
        runCompute(); // 这时候会真正执行一次。
    });

    // 但是createEffect会先执行一遍,这导致无效的第一轮。
}

我们希望的computed是lazy(惰性)。不应该在创建的时候立马执行computeFn,应该当有人第一次读它的时候,才去执行。为了应付这个延迟问题,可以创建computed时,里面先不通过createEffect绑定依赖,而是返回一个普通signal的get/set,同时维护一层内部订阅。

下面是更符合Solid风格的lazy computed实现,代码清晰也不短:

function createComputed(fn) {
    const listeners = new Set();
    const effectDeps = new Set();

    let currentValue;
    let isDirty = true;
    let isComputing = false;
    let hasRun = false;

    function get() {
        if (activeEffect !== null) {
            listeners.add(activeEffect);
            activeEffect.depNames.add(listeners);
        }

        // 如果还没有计算过,直接计算
        if (isDirty && !isComputing) {
            const prevEffect = activeEffect;
            activeEffect = null;
            isComputing = true;
            try {
                // 注意这时候fn内部读取signal,此时activeEffect是null,不会收集到当前computed里。
                // 需要一个单独的机制:把fn的依赖收集到effectDeps
                currentValue = trackDependencies(fn, effectDeps);
            } finally {
                activeEffect = prevEffect;
                isComputing = false;
            }
            isDirty = false;
        } else if (isDirty && isComputing) {
            // 处理循环引用或重入的情况
            throw new Error('Circular computation detected');
        }

        return currentValue;
    }

    function markDirty() {
        if (!isDirty) {
            isDirty = true;
            // 通知下游
            [...listeners].forEach(effect => effect());
        }
    }

    // 订阅上游dependency
    // 这个track函数会在内部创建effect来订阅上游
    function trackDependencies(compFn, depSet) {
        // 利用全局activeEffect来做一次立即执行并且追踪
        // 我们需要一个特殊的effect,它既不会直接暴露给下游,又能保存依赖
        let result;
        const tempEff = () => {
            // 清除旧依赖
            tempEff.depNames.forEach(set => set.delete(tempEff));
            tempEff.depNames.clear();

            activeEffect = tempEff;
            result = compFn();
            activeEffect = null;
        };
        tempEff.depNames = new Set();

        // 先临时跑一遍清理旧的
        tempEff();

        // 监听上游变化:当上游的任意signal发生set时,需要调用markDirty
        const notifyUpstream = () => {
            markDirty();
        };

        // 把tempEff收集到的所有订阅集合作为depSet,但是把notifyUpstream挂进去?
        // 其实上游的subscribers会存tempEff,当上游set时调用tempEff。
        // 要的是tempEff被触发。tempEff会在触发后执行一次tempEff(), 从而重算。
        // 所以我们直接用tempEff即可,它内部已经会重算 result。
        // 但是还要限制:只有第一次运行后,上游变化才能使tempEff再次执行。
        // 这样完全可以使用createEffect简化了。
        // 重新确认逻辑:
    }

    return [get, markDirty];
}

写到这里发现,要在文章篇幅内做到简单易懂,上面“精密的lazy computed”实现容易看得很累。实际上大部分项目直接用一个更取巧的方案:把computed的求值逻辑封装在一个effect函数里,但这个函数又在全局缓存当前值。若是上游变化,effect即自动运行,把计算结果存下来,然后通知下面的普通signal。

取舍之后,最适合演示的computed版本就是这一种,它易于理解,性能足够覆盖90%的前端需求:

function createComputed(computeFn) {
    const [result, setResult] = createSignal(undefined);
    let hasRun = false;

    createEffect(() => {
        const value = computeFn();
        // 如果值一样或者初次未定义就不触发多余的set
        setResult(value);
    });

    return result; // 返回一个函数,与signal的get等价
}

这段代码虽然不够聪明,但它用已经写好的createEffect和createSignal实现computed,完全够用。第一次执行时,effect内部读取上游信号收集依赖,并调用setResult存下结果。之后上游变化,effect再次运行,再次计算。所有时机都正确。

为了避免重复运行setResult触发下游多余更新,我们在set里使用了Object.is判断,如果新老值相等,set直接return不触发订阅。所以写成上面的形式性能也不差。

想要代码更严谨的话,createComputed内部可以再包一层,把effect返回的dispose存起来。这样也便于以后销毁computed。

下面是最终的computed写法,带缓存和销毁。

function createComputed(computeFn) {
    const [get, set] = createSignal(undefined);

    let disposed = false;

    function recompute() {
        if (disposed) return;
        set(computeFn());
    }

    const dispose = createEffect(recompute);

    return {
        get value() {
            return get();
        },
        dispose
    };
}

注意不要直接用recompute作为createEffect参数直接传。因为第一次调用createEffect会立刻执行recompute,从而初始化值,这是好的行为。

如果上游信号的值没变,computeFn执行一次,则set也不会触发下游更新。如果computeFn返回值没变,下游也不会触发。这套“值比对”在上层代码里被Object.is包住了。

批量更新与微任务调度

现在有一个新的问题。当多个信号连续改变,effect会被同步触发多次。假如effect内部有DOM读写,这种高频执行可能会影响性能。所以现代Signal库普遍内置了批量调度或自动批次。我们加一个batch函数:在同一个调用栈内,多次set只执行一次effect。

最简单的批处理模式叫“同步合并”,利用微任务。需要一个队列,把变更的effect加进去,然后在Promise.resolve().then()里统一执行。

先定义几个全局变量:

let runningEffects = [];
let isBatching = false;
const pendingEffects = new Set();

function enqueueEffect(effect) {
    if (isBatching) {
        // 批量调度场景,用set来去重
        pendingEffects.add(effect);
        return;
    }

    queueMicrotask(() => {
        if (pendingEffects.has(effect)) {
            pendingEffects.delete(effect);
            effect();
        }
    });
}

改造我们的signal,让set时不是直接调用subscribers里的effect,而是先入队。由于在优先级上要考虑宏任务和微任务,queueMicrotask比较合适。不过如果要支持脱离浏览器环境的Node,可以保留setTimeout作为fallback。

完整的批量更新版本如下。注意effect执行时直接调的是effect(),所以必须保证effect内部有防重入处理。我们加一个当前是否正在执行effect的判断。

let activeEffect = null;
const pendingEffects = new Set();

function scheduleEffect(effect) {
    if (activeEffect === effect) return;

    pendingEffects.add(effect);

    queueMicrotask(() => {
        if (pendingEffects.delete(effect)) {
            effect();
        }
    });
}

function createSignal(value) {
    const subs = new Set();

    const get = () => {
        if (activeEffect !== null) {
            subs.add(activeEffect);
            activeEffect.depNames.add(subs);
        }
        return value;
    };

    const set = (next) => {
        if (Object.is(value, next)) return;
        value = next;
        [...subs].forEach(effect => scheduleEffect(effect));
    };

    return [get, set];
}

而createEffect内部需要防止同一effect重复被调度,并且当它的依赖分发时,effect执行前还会清空旧的依赖,重新建立新的。有一个微妙的问题是:effect执行过程中会调fn,而fn如果修改其他信号,能否触发那些信号的调度?答案是能,但由于我们正在activeEffect里执行,scheduleEffect会把这些新触发的effect加入队列。

但此时activeEffect这个标记已经指向当前effect。当新的effect里取信号时,信号会把“旧effect”误认为依赖。这不对。一个effect运行期间,不应该让另一个信号的读写打断当前的收集链。

解决办法是,在effect执行fn之前,临时把activeEffect置成自身;在fn执行完后,再置回null。如果fn执行期间又触发了另一个effect,由于使用的是queueMicrotask,第二个effect不会同步执行,它会进入微任务队列。因此不会出现嵌套。但如果有人手动在新effect函数里立即执行signal.get,不会有问题,因为activeEffect还是当前的effect,可能造成依赖错乱。通常实际代码里不会出现这种场景,因为嵌套effect靠队列异步消化了。

更好的方式是使用一个栈来管理activeEffect,让嵌套effect也能正确区分依赖。这里就不在文章中过度展开。

实战:写一个响应式计数器加自动求和

理解了上述机制后,我们现在需要把代码和真实DOM结合。可以在所有信号变化后统一更新页面。针对一个最简单的打卡场景:一个number类型的count,加一个按钮,再实时显示它的double和总点击次数。

代码里把signal和effect函数做一些变量名调整,然后用于DOM操作。由于没有用到编译器,只需要在set更改完值之后,让配套的effect自动更新DOM节点就行。

但纯粹的原生DOM更新需要反复getElementById,为了干净我们把那部分封装成小型指令。

先写一个bindText函数,用一个signal的get方法,对应一个DOM节点,每次变更后把textContent更新。

function bindText(nodeId, getter) {
    const el = document.getElementById(nodeId);
    if (!el) return;
    createEffect(() => {
        el.textContent = getter();
    });
}

然后创建一个计数器signal和它的展示。

const [count, setCount] = createSignal(0);
const [clickCount, setClickCount] = createSignal(0);
const doubleCount = createComputed(() => count() * 2);

当按钮被点击时,同时修改两个信号:count加1,clickCount加一。为了避免一个effect里连续触发多次更新,可以把它们包在batch中。实现一个batch函数如下:

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

function batch(fn) {
    batchDepth++;
    try {
        fn();
    } finally {
        batchDepth--;
        if (batchDepth === 0) {
            // 直接同步执行队列里的effects
            batchQueue.forEach(effect=>effect());
            batchQueue.clear();
        }
    }
}

function scheduleInBatch(effect) {
    if (batchDepth > 0) {
        batchQueue.add(effect);
    } else {
        queueMicrotask(()=>effect());
    }
}

你也可以直接用简单的batch来控制同步粒度。这里利用batch深度来支持嵌套batch。

于是点击事件就可以这样写:

document.getElementById('inc-btn').addEventListener('click', () => {
    batch(() => {
        setCount(count() + 1);
        setClickCount(clickCount() + 1);
    });
});

如果effect内部读取了count和clickCount,batch结束后,它最多只会执行一次。因为Set去重保证了即使触发多次,也只入队一个执行任务。batchQueue里是effect对象本身,所以在同一轮batch里,一个effect只执行一次。

从信号到可表达的视图层——组合起来

这个思路可以直接做出一小块vdom引擎。需要创建一个节点描述对象,然后用Signal替换内部状态。例子如下:点击按钮,列表项动态增加并且附带删除按钮。

不过,要确保全文不至于过长到读不下去,我在这只提供一个小示例。假设有一个组件,包含一个input和一个p标签,输入的值实时映射在p里。用signal构建很简单:

const [text, setText] = createSignal('');

const onInput = (e) => setText(e.target.value);

// 创建Effect来自动更新p的内容
createEffect(() => {
    document.querySelector('#live-text').textContent = `当前内容:${text()}`;
});

其实这里可能有人觉得奇怪,在createEffect里使用了e.target,但这个不在signal读取范围内,只是JS闭包引用,没问题。

如果你想要构建更复杂的数据流,比如输入框是受控的:

const [val, setVal] = createSignal('你好');

createEffect(() => {
    document.querySelector('#my-input').value = val();
});

// 监听输入事件反过来更新信号
document.querySelector('#my-input').addEventListener('input', (e) => {
    setVal(e.target.value);
});

这时候如果还有另一个effect读取val,它会自动跟随更新。这些内容都是原生JS与现代响应式的最佳粘合示例。

为什么大家开始认真考虑Signal

Signal并不是新发明,它这波流行主要是因为打中了框架长期以来的两个痛点。

痛点之一是细粒度更新。React的re-render要求“尽量少的reconcile”,但它很难做到真正的粒控。Signal从设计上就自带粒度。一个信号变了,就只有读它的effect被执行。其他effect无需关心。

痛点之二是性能调优的心理负担。什么memo、useCallback、selector都成了过去式。Signal把缓存、依赖追踪、脏值检查放入运行时,开发者不需要手动标记依赖关系。在一个模块里直接使用一个signal的get,就可以读写到state和action里,共享通信非常自然。

Signal带来的另一大变化是“框架无关”。你可以先写一个signal.ts,不去依赖Vue或React上下文。随后在Vue组件里,通过effectScope包裹使用;在React里,通过useSyncExternalStore接入。状态核心逻辑就能跨框架复用。这在单体仓库中尤其有价值。

最近像Angular也推出了基于signal的响应式API,Preact Signals库已经能独立于Preact运行。这就说明单纯的signal机制成为一个稳定的可复用状态模型,完全成为独立工具。对我来说,它最大的吸引力是“不需要那么多元件层级来传播变更”。

我预测Signal会在后续的JS标准或库生态里占据更核心的位置,而在那之前,把这几十行原理看懂,怎么都不亏。

边界话题:Signal与Immutable哪个更优雅

如果之前的代码看得仔细,应该能发现基础的Signal结构虽然灵活,但缺少结构化数据的细粒度追踪。比如一个对象里有几十个属性,改动其中一个属性会触发所有读取该对象任意属性的effect。这在原生实现里很难优化。

要解决这问题,有两个方向。一是采用更细粒度的signal,每个属性都拆成一个信号。二是采用不可变数据,每次set时生成新对象,利用“对象引用变化”来更新所有相关effect。但用不可变数据也有损耗,因为计算diff或者整个组件的重跑不可避免。

在当前最前沿的实现中,很多库做“自动跟踪属性粒度”的响应式。比如把getter function包在React.memo外,或者使用代理进行属性级追踪。但这样会让代码体积变大,复杂度也更高。大部分真实场景下的信号粒度已经足够满足性能。

如果你关心更细的粒度,可以尝试“map of signals”设计:

const state = {
    users: createSignal([]),
    current: createSignal(null)
}

这并不复杂,而且符合直觉。

顺带聊聊effect里的异步陷阱

例如,如果effect内部读取一个信号,然后立刻用setTimeout回调再读一次信号,那回调里读取与effect依赖无关联。因为setTimeout执行时,activeEffect已经为null,等执行到回调时,当前线程的effect栈肯定为空。换句话说,延迟中读取的信号不会被视为依赖。这个设计是好事,否则异步循环会导致依赖爆炸。

然而很多新手以为写个effect然后里面随手fetch就能实现响应式,实际上是做不到。必须在fetch过程中,把需要的状态先提取出来,再在回调中调用set。可能他们会觉得“为什么不更新了”,这其实是正常现象,不是bug。要理解执行时机。

如果希望在异步回调里重新订阅,那就需要手动调用effect所属的依赖收集函数。但那样容易搞出死循环,基本不建议。

反之,有一个技巧很好用:在effect里监听数据变化,然后节流更新。比如effect中读取信号,但不直接在effect里做开销大的操作,而是设一个debounce定时器。这种情况下,effect自身的重复开发不会取消上一次的定时器,所以需要手动处理。很容易造成副作用错乱。可以配合清理函数实现取消之前的未完成任务。

function createEffectWithCleanup(fn) {
    let cleanup;
    const dispose = createEffect(() => {
        if (typeof cleanup === 'function') cleanup();
        cleanup = fn();
    });
    return dispose;
}

// 用法:
createEffectWithCleanup(() => {
    const data = state.apiData();
    const timer = setTimeout(() => console.log('debounced', data), 500);
    return () => clearTimeout(timer);
});

这样即使信号频繁变,上一次的timer会被清除,只会执行最后一次。这是不是比在useEffect里手动写依赖数组要舒服得多?

真正的微型Signal库,全部代码集合

现在把所有核心代码整合一下,组成一个完整且健壮的小型库,约120行(不计注释)。它对大多数demo、工具库和简单应用都足够用。

let activeEffect = null;
let batchDepth = 0;
const batchUpdateQueue = new Set();

function scheduleEffect(effect) {
    if (batchDepth > 0) {
        batchUpdateQueue.add(effect);
    } else {
        queueMicrotask(() => {
            if (batchUpdateQueue.delete(effect)) {
                effect();
            }
        });
    }
}

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

function createSignal(initialValue) {
    let value = initialValue;
    const subscriptions = new Set();

    const get = () => {
        if (activeEffect !== null) {
            subscriptions.add(activeEffect);
            activeEffect.depSources.add(subscriptions);
        }
        return value;
    };

    const set = (next) => {
        if (Object.is(value, next)) return;
        value = next;
        const effectsToRun = [...subscriptions];
        effectsToRun.forEach(effect => scheduleEffect(effect));
    };

    return [get, set];
}

function cleanupEffect(effect) {
    effect.depSources.forEach(c => c.delete(effect));
    effect.depSources.clear();
}

function createEffect(fn) {
    const effect = () => {
        cleanupEffect(effect);
        const prev = activeEffect;
        activeEffect = effect;
        try {
            fn();
        } finally {
            activeEffect = prev;
        }
    };

    effect.depSources = new Set();
    effect();

    const dispose = () => {
        cleanupEffect(effect);
    };
    return dispose;
}

function createComputed(fn) {
    const [get, set] = createSignal(undefined);
    let disposed = false;

    const update = () => {
        if (!disposed) set(fn());
    };

    const dispose = createEffect(update);

    return Object.freeze({
        get value() {
            return get();
        },
        dispose() {
            disposed = true;
            dispose();
        }
    });
}

export { createSignal, createEffect, createComputed, batch };

这份代码里没有处理异常导致activeEffect回滚的情况,依赖清理也还算干净。在实际使用中,如果想兼容旧浏览器,把queueMicrotask换成Promise.resolve().then即可。

在真实项目中使用这个小库

你可以直接把这个库和DOM操作结合,做一个类似ToDoList。用signal保存todos数组,同时用一个computed来统计未完成数量。每次修改通过生成新数组代替push。触发更新时,由于计算函数返回新的数组,而存储signal的值被替换成新数组,所有依赖todos列表的effect都会被触发。

这里注意用新数组代替原地push,是为了保证触发signal的变化。否则数组内存地址没变,effect收不到变更。

let idSeq = 1;
const [todos, setTodos] = createSignal([
    { id: 0, title: '学信号', done: false },
    { id: 1, title: '手写实现', done: true }
]);

function addTodo(title) {
    setTodos([...todos(), { id: idSeq++, title, done: false }]);
}

function toggleTodo(id) {
    setTodos(todos().map(todo =>
        todo.id === id ? { ...todo, done: !todo.done } : todo
    ));
}

渲染部分也可以复用effect。监听列表区域,每次todos变化时,重新生成innerHTML。

const listEl = document.getElementById('todo-list');

createEffect(() => {
    const items = todos().map(todo => `
        <li data-id="${todo.id}" class="${todo.done ? 'done' : ''}">
            ${todo.title}
            <button data-id="${todo.id}">切换</button>
        </li>
    `).join('');

    listEl.innerHTML = `<ul>${items}</ul>`;
});

但每次重建整个DOM会影响事件监听和输入框聚焦,效率不高。可以使用document.createDocumentFragment来优化,或者只更新变化部分。这里为了方便演示就直接重绘,但真实项目应该用keyed diff。

点击和切换事件用事件委托绑定在listEl上,这样即使重绘后的事件监听还在。

listEl.addEventListener('click', (e) => {
    const button = e.target.closest('button');
    if (!button) return;
    const id = Number(button.dataset.id);
    toggleTodo(id);
});

这样一个小应用已经拥有完整的dependency tracking和自动更新能力,所有代码都是纯原生。没有加style,但可运行。

怎么理解signal背后的心智模型

不用把它看得太高级。想象你的数据流是一条有方向的管道,每个signal节点维护着一批依赖它的effect和computed节点。数据一变,就会顺着这条管道自动流向末端UI。你的代码不再是指令式地“把这个值赋给那个DOM”,而是声明式地表达“这个节点的内容等于某个signal取反”之类的关系。

由于管道方向是固定的,你不需要去手动“通知”某个节点更新。这就是signal消除大量样板代码的原因。

但也别以为它万能。signal不适合存储超大集合并频繁做全量更新,因为创建订阅关系本身也有成本。前端应用里,只要把它应用到组件粒度的状态管理上,效果就很显著。

最后说一下依赖循环的危险:当computed依赖自己,比如一个computed读取自身的value,会造成无限循环。在最终的实现代码中,监测这种循环很难,只能靠开发者自律。在一些框架里会有明确的警告,我们的minilibrary则没有。

如果要用这个思路构建大型应用,需要实现内存泄漏检测和动态依赖图的调度策略。真正的框架代码里,会为每个计算加入effect版本号、位掩码等更复杂的数据结构,避免使用Set的开销。但核心思路和今天写的完全一致。

总结性废话我们不说了

本文试图用不到两百行代码说明新型javascript响应式系统的底层机制。从createSignal、effect依赖收集,到computed和batch更新,再到真实demo。没有什么神秘黑魔法,只要你愿意去打开浏览器的控制台调试,看订阅关系怎么建立、怎么清除,就会很快掌握它。

想在自己的下一个开源库或小工具里使用,完全可以在这份代码的基础上扩展。如果想深入框架级别,也可以去读Preact/Solid的signal源码,一定会在里面看到眼熟的结构。

信号驱动:在原生JavaScript中实现轻量级响应式系统
收藏 (0) 打赏

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

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

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

淘吗网 javascript 信号驱动:在原生JavaScript中实现轻量级响应式系统 https://www.taomawang.com/web/javascript/2717.html

常见问题

相关文章

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

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