手写一个Mini响应式系统:彻底看穿Vue3的依赖收集魔法

用Vue3写组件的时候,我一直有种“被宠坏”的感觉:随便改一个ref的值,页面自动更新,管你数据藏在哪个犄角旮旯。直到有一天我忍不住翻源码,才发现所谓的响应式核心其实就两件事:做一次代理,收集依赖,在改数据时触发依赖。

今天我不讲什么大而全的实现,就带着你把Vue3里最核心的响应式系统用不到一百行代码重写出来。等你动手写完,再看那些监听、computed、watch,会发现它们全是在这个地基上长出来的。

一、响应式到底在干嘛

先去店里买杯奶茶,思考一个例子。

let price = 10;
let count = 2;
let total = price * count;

console.log(total); // 20

price = 20;
console.log(total); // 还是20,因为total没有重新计算

正常的JavaScript就是这样,total不会跟着price变。响应式的目标,就是让total在price变化时自动重新计算。要实现这个效果,必须有一个叫“副作用”的东西,就是我们想自动执行的函数。Vue里就是 effect

一个响应式系统最少要有三样东西:

  • reactive:把原始对象变成响应式对象。
  • effect:注册一个副作用函数,它会读取响应式对象。
  • 依赖收集/触发更新:effect读取某个属性时,把这个effect记下来;属性变化时,再去执行之前记下来的effect。

画成图就是:

读取属性 -> track(收集当前effect)
修改属性 -> trigger(执行所有读取过该属性的effect)

上面两句话是整个Vue响应式的灵魂。下面我用代码把它们落地。

二、先搞一个全局存储依赖的地方

依赖存哪里?我用一个WeakMap。WeakMap的键是原始对象,值是一个Map;Map的键是属性名,值是一个Set,里面放的是effect函数。为什么用WeakMap?因为它对对象的引用是弱引用,对象被垃圾回收了,这里对应的项也会自动消失,不会内存泄漏。

const targetMap = new WeakMap();

// 当前正在执行的effect
let activeEffect = null;

activeEffect是个全局变量,意思就是“现在正在跑的那个副作用函数”。有了activeEffect,才知道读取属性时应该把谁收集起来。

三、reactive:用Proxy包一下

reactive接收一个对象,返回Proxy代理。我们在get里收集依赖,在set里触发更新。

function reactive(target) {
    return new Proxy(target, {
        get(obj, key, receiver) {
            // 读属性时收集依赖
            track(obj, key);
            return Reflect.get(obj, key, receiver);
        },
        set(obj, key, value, receiver) {
            const oldValue = obj[key];
            const result = Reflect.set(obj, key, value, receiver);
            if (oldValue !== value) {
                // 值变了才触发更新
                trigger(obj, key);
            }
            return result;
        }
    });
}

这里有个细节:为什么用Reflect.get而不是直接返回obj[key]?因为如果对象上有getter或者有继承,两者行为可能有差异。为了正确性,我们遵循Vue的做法。

有的同学会问:如果对象嵌套很深怎么办?比如 state.user.info.name。我们的reactive只代理了外层,state.user 取出来还是普通对象,不会收集依赖。真实Vue的reactive是惰性深代理:当读取一个属性时,如果这个属性值还是对象,就再对它做一次reactive。这里我为了代码精简,先不处理深代理,只演示基础原理。

四、track:收集依赖

function track(target, key) {
    if (!activeEffect) return;

    let depsMap = targetMap.get(target);
    if (!depsMap) {
        depsMap = new Map();
        targetMap.set(target, depsMap);
    }

    let depSet = depsMap.get(key);
    if (!depSet) {
        depSet = new Set();
        depsMap.set(key, depSet);
    }

    depSet.add(activeEffect);
}

这段代码的意图很清晰:找到 target 对应的 depsMap,再找到 key 对应的 depSet,把当前 activeEffect 放进去。以后属性一变化,trigger 就遍历这个 depSet 去执行所有 effect。

五、trigger:触发依赖

function trigger(target, key) {
    const depsMap = targetMap.get(target);
    if (!depsMap) return;

    const depSet = depsMap.get(key);
    if (!depSet) return;

    // 把当前所有希望执行的effect copy出来,避免死循环
    const effectsToRun = new Set(depSet);
    effectsToRun.forEach(effect => {
        effect();
    });
}

注意我copy了一份,用新Set执行。因为如果effect执行时又修改了同一个属性,会再次触发trigger,而原来的Set还在被迭代,可能会出现问题。Copy一份是常规操作。

六、effect:注册副作用

effect函数接收一个函数fn。执行fn的时候,设置activeEffect为fn,然后立即跑一次,读取属性时track就会把fn收集起来。

function effect(fn) {
    activeEffect = fn;
    fn(); // 立即执行,触发依赖收集
    activeEffect = null;
}

简单粗暴。真实Vue里effect还有调度器、stop、cleanup等逻辑,这里都省略了。但核心思想就是上面这样。

七、第一次测试:total能自动更新了

我们写个最简单的HTML页面,不依赖任何构建工具,拿上面的代码跑一下。

const product = reactive({ price: 10, count: 2 });

let total;
effect(() => {
    total = product.price * product.count;
    console.log('total现在是', total);
});

// 修改价格
product.price = 20;
// 修改数量
product.count = 3;

输出结果:

total现在是 20
total现在是 40
total现在是 60

看见没?total每次都会自动跟着product的值变化。这就是迷你响应式系统。

刚才代码里的effect像不像Vue组件的渲染函数?组件第一次渲染时,读取了模板里用到的响应式数据,于是render effect被收集为依赖;后来某个响应式数据变了,render effect重新执行,界面就更新了。万变不离其宗。

八、改进:支持ref

Vue3里除了reactive这种对象式,还有一个ref用来包装基本类型。比如 const num = ref(0),然后 num.value 来读写。实际上ref也可以基于reactive实现:把基本类型包成一个对象,然后reactive这个对象。

function ref(initialValue) {
    const refObj = { value: initialValue };
    return reactive(refObj);
}

试试看:

const count = ref(0);

effect(() => {
    console.log('count.value =', count.value);
});

count.value = 1; // 输出 count.value = 1

哦,原来ref就是给一个对象加了一个value属性,然后套上reactive。这也是为什么ref修改后需要靠.value来访问。

九、再加一个简单computed

计算属性computed本质上是一个懒执行的effect,并且缓存结果。我们来实现一个简化版。

function computed(getter) {
    let cachedValue;
    let dirty = true;

    const runner = () => {
        cachedValue = getter();
        dirty = false;
    };

    effect(runner);

    return {
        get value() {
            if (dirty) {
                runner();
            }
            return cachedValue;
        }
    };
}

这里有一个坑:effect(runner)执行的时候,runner里会读取响应式数据,于是runner就被收集为依赖。当某个依赖变化时,runner会被重新执行,此时dirty应该设为true,这样下次访问computed.value时才会重新计算。但上面的代码里,trigger会直接执行runner,导致runner执行一次,但dirty没变。我们需要改一下:

function computed(getter) {
    let cachedValue;
    let dirty = true;

    const runner = () => {
        cachedValue = getter();
        dirty = false;
    };

    // 用effect包裹runner,但我们要控制不立即执行dirty操作?
    // 实际上我们可以手动管理一个effect:
    const e = effectFn();

    // 更准确的做法是模仿Vue,在trigger时标记dirty,而不是直接执行runner
    // 为了演示简单,我改成这样:

其实还没写完。因为我们要让依赖变化时设置dirty=true,而不是直接执行。用触发函数的时候,我们可以单独处理:

function trigger(target, key) {
    const depsMap = targetMap.get(target);
    if (!depsMap) return;
    const depSet = depsMap.get(key);
    if (!depSet) return;

    const effectsToRun = new Set(depSet);
    effectsToRun.forEach(effect => {
        if (effect._isRunner) {
            effect._dirty = true; // 只是把标记设为true
        } else {
            effect();
        }
    });
}

但这样改造就有点复杂了。为了不让文章失控,我在这里提供一个能跑的简化版computed,利用effect的返回值版本。我们先把effect升级一下,让effect可以返回一个包装函数。

function effect(fn) {
    const runner = () => {
        activeEffect = runner;
        fn();
        activeEffect = null;
    };
    runner(); // 初始化收集依赖
    return runner;
}

这样effect返回的就是那个runner函数。在computed中,我们可以控制:当依赖变化时,执行runner但只把dirty设为true,不重算。改进后的computed:

function computed(getter) {
    let cachedValue;
    let dirty = true;

    const runner = effect(() => {
        // 依赖变化时,这个函数会被调用
        dirty = true;
    });

    // 但这样不对,因为effect执行时传给我们的函数是修改dirty,而不是重算
    // 正确做法:用一个额外的effect来追踪getter,并设置标记

    // 我们换种思路:
    const e = effect(() => {
        cachedValue = getter();
        dirty = false;
    });

    // 我们需要在trigger时,设置dirty为true,但我们不知道依赖变化会调用e
    // 所以可以在trigger中判断:如果effect有一个._computed属性,就只置脏
}

算了,文章不是源码分析,我决定把computed留作一个思考题,不在这里细写。反正你已经理解了核心:effect在get中收集依赖,trigger时执行依赖。computed只是在这个基础上加了缓存和懒计算。

感兴趣的朋友可以自己查Vue3源码中computed的实现,本质是用了ComputedRefImpl类,内部有一个effect,并设置了scheduler调度器来控制dirty。

十、再优化一步:支持嵌套对象

刚才提到reactive只代理了一层。如果是深层对象,我们可以在get的时候判断当前属性值是不是对象,如果是,就递归reactive。比如:

function reactive(target) {
    return new Proxy(target, {
        get(obj, key, receiver) {
            const value = Reflect.get(obj, key, receiver);
            if (typeof value === 'object' && value !== null) {
                return reactive(value);
            }
            track(obj, key);
            return value;
        },
        set(...) { ... }
    });
}

这样 state.user.info.name 在读取时,name所在的info对象也会被reactive代理,从而能够收集依赖。但每次get都重新reactive一次,会有性能浪费。真实Vue里用WeakMap缓存结果。这里我们点到为止,理解概念为主。

十一、用这个mini系统做一个简单的状态管理

光说不练假把式。我们用它来实现一个非常简单的按钮点击计数页面(虽然我写了不用样式,但逻辑能跑)。下面是完整的HTML示例:

<body>
    <button id="btn">点我</button>
    <span id="text">0</span>

    <script>
        // 上面所有代码放在这里
        function reactive(target) { ... }
        function effect(fn) { ... }
        function track() { ... }
        function trigger() { ... }

        const state = reactive({ count: 0 });

        effect(() => {
            document.getElementById('text').textContent = state.count;
        });

        document.getElementById('btn').addEventListener('click', () => {
            state.count++;
        });
    </script>
</body>

点击按钮,页面上的数字自动增加,并且是state.count变化后,那个effect重新执行来更新DOM。这不就是一个完全自制的MVP版Vue吗?

十二、总结与心得

每次写这种底层原理的文章,我都会有一种恍然大悟的感觉。以前用ref和reactive就像用魔法,现在知道了魔法背后是Proxy和两个普通函数。一旦自己动手写过一遍,再去看Vue的源码文档,就会觉得那些专业术语特别亲切。

响应式不是Vue发明的,但它把这套思想普及到了前端日常开发中。理解依赖收集之后,你再也不会因为“修改了数组怎么不更新”这种问题而抓耳挠腮,因为你清楚知道数组的index操作在Proxy的get/set里是怎么被track/trigger的。

这里我只写了最核心的骨架,真实工程中还要考虑:多次修改同一属性导致的死循环、effect的清理旧依赖、调度器实现批量更新、异步渲染、嵌套effect栈等。但骨架不变——track和trigger,一对老搭档。

我建议你跟着代码敲一遍,不要求背,就放在IDE里跑一跑,然后改一改,看看什么情况下会失效。只有亲手拆过玩具,才知道玩具里面装的什么齿轮。

希望这篇文章能让你对Vue3的响应式拥有“我能控制它”的信心。

手写一个Mini响应式系统:彻底看穿Vue3的依赖收集魔法
收藏 (0) 打赏

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

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

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

淘吗网 javascript 手写一个Mini响应式系统:彻底看穿Vue3的依赖收集魔法 https://www.taomawang.com/web/javascript/2491.html

常见问题

相关文章

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

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