深入Vue核心:从0开始手写响应式系统(一)

238 阅读14分钟

在现代前端开发中,Vue.js以其轻量级和易用性的特点在同类框架中脱颖而出。响应式系统是Vue的核心特性之一。它实现了一套能够自动追踪数据的变化并更新视图的机制。这不仅简化了开发流程,还提高了应用的性能和用户体验。

本文将深入探讨Vue响应式系统的工作原理,并指导你如何从零开始构建一个简易的响应式系统。

本文部分代码实现参考了《Vue.js设计与实现》一书中的第4章内容。该书由霍春阳编写,深入探讨了 Vue.js 的核心设计理念和实现细节。对于希望深入理解 Vue 响应式系统的读者,强烈推荐阅读原文,以获取更全面的知识和背景信息

为什么Vue要有响应式系统

Vue的设计哲学是“数据驱动视图”,响应式系统是实现这一理念的基础。开发者通过改变数据就能驱动视图的变化,使得状态管理更加清晰和可控,这种方法还带来了以下几个显著的好处:

  1. 简化开发:通过自动追踪数据变化并更新视图,开发者无需手动操作DOM,可以更专注于业务逻辑和数据处理
  2. 性能优化:通过精细的依赖追踪,只对变化的部分更新,避免了不必要的DOM操作,从而提高了性能
  3. 可维护性高: 随着应用的复杂度增加,手动管理DOM和状态会变得更加困难。响应式系统提供了一种更便捷的方式来构建复杂的用户界面

理解Proxy代理

在开始手写代码之前,我们首先来简单了解下Proxy

Proxy可以创建一个对象的代理,从而拦截和自定义该对象的基本操作。Proxy是Vue3响应式系统的核心,它也是ES6引入的新特性之一

使用方法如下,构造函数接收两个参数:

const p = new Proxy(target, handler)
  • target: 被代理的目标对象(可以是任何类型的对象,包括原生数组,函数,甚至另一个代理)

  • handler: 一个通常以函数作为属性的对象,各属性中的函数,如getset,分别定义了在执行不同操作时代理的行为。

相比起Vue2使用的Object.defineProperty,Vue3的Proxy提供了更全面的拦截功能:

  1. 不仅可以拦截对象属性的读取和写入,还可以拦截删除、枚举等操作, Object.defineProperty只能拦截属性的读取和写入

  2. Proxy能够监听数组的变化以及动态添加和删除的属性,而 Object.defineProperty无法对数组的变化进行有效监听,且对于动态属性需要重新定义,这会导致额外的性能开销

具体实现

1. 数据代理

对于一个对象,如果它发生变化时,这部分的更新自动同步到视图上,那这个对象就可以被称作响应式数据

为了执行同步数据的工作,我们需要有一个函数来进行DOM的更新操作,当响应式数据变化时,这个函数也会自动执行,这样的函数就被称为副作用函数

响应式系统的主要功能都是基于副作用函数来实现的,包括:

  • 模版渲染
  • 计算属性更新
  • 用户定义的侦听器

比如,对于下面这部分代码, 修改obj.text的属性值后,需要手动运行effect函数才能把改动同步到视图上

const obj = { text: 'hello world'}
// 如果obj.text的值发生变化,effect函数能够自动执行,那obj就是响应式数据
function effect(){
    document.body.innetText = obj.text
}

如何才能实现自动同步呢?

我们可以通过Proxy对这个对象进行代理,拦截它的读写操作,在检测到obj.text的值发生变化时,调用effect函数

这么一来,当obj.text属性再次被修改时,set函数就会触发,effect就会自动执行并更新DOM

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8" />
    <title>手写Vue响应式-二魔</title>
</head>
<body>
    <h1>手写Vue响应式</h1>
</body>
<script>
    const data = { text: "Hello World!" };
    // 通过Proxy代理对数据的读取操作进行拦截
    const obj = new Proxy(data, {
        get(target, key) {
            return target[key];
        },
        set(target, key, newVal) {
            target[key] = newVal;
            // 检测到写入操作,这个时候需要执行对应的副作用函数
            effect();
            // 返回 true 代表设置操作成功
            return true;
        },
    });

    function effect() {
        // 触发obj.text的getter函数,执行读取操作
        console.log("effect triggered");
        document.body.innerText = obj.text;
    }
    effect();
    setTimeout(() => {
        // 触发obj.text的setter函数,写入操作
        obj.text = "Hello Vue!";
    }, 1000);
</script>
</html>

2. 副作用管理

现在我们硬编码了副作用函数的执行,实际调用中的副作用函数的命名不一定是effect,可以是effect2, effect3等等

为了让响应式系统具有通用性,我们来定义一个全局变量activeEffect来代表当前正在执行的副作用函数,而原本effect函数的功能调整为对副作用函数进行注册

这样即便是匿名函数也可以被记录下来被调用,系统就不会再依赖副作用函数的名字了

具体改动如下:

  • 在进行读取操作时,即副作用函数执行时,记录相应的副作用函数
  • 在进行写入操作时,执行之前记录的副作用函数
let activeEffect // 当前正在执行的副作用函数
const data = { text: "Hello World!" };
// 通过Proxy代理对数据的读取操作进行拦截
const obj = new Proxy(data, {
    get(target, key) {
        return target[key];
    },
    set(target, key, newVal) {
        target[key] = newVal;
        activeEffect();
        return true;
    },
});

function effect(fn) {
    activeEffect = fn;
    fn();
}

effect(() => {
    console.log("effect triggered");
    document.body.innerText = obj.text;
});

setTimeout(() => {
    // 触发obj.text的setter函数,写入操作
    obj.text = "Hello Vue!";
}, 1000);

不过这个实现有一个问题:一个变量会有多个副作用函数

只用一个变量储存的话,后续响应式变量再次发生变化时,那只有最近一次执行的副作用函数会被调用

比如,如果我们又增加了一个新的副作用函数,

然后修改 obj.text = "Hello Vue!";

就会发现页面中的内容并没有被更改,命令行也只打印了第二个副作用函数的日志

effect(() => {
    console.log("effect triggered");
    document.body.innerText = obj.text;
});
effect(() => {
    console.log("effect triggered 2");
    console.log('obj.text: ', obj.text);
});

为了解决多副作用函数的问题,我们需要创建一个桶bucket专门用来存储副作用函数

  • 执行读取操作时,把副作用函数通过activeEffect变量收集到桶中
  • 执行写入操作时,把副作用函数都从桶中取出来执行

这样就不担心副作用函数被覆盖了

let activeEffect
const bucket = new Set(); // Set定义储存副作用函数的桶
const data = { text: "Hello World!" };
// 通过Proxy代理对数据的读取操作进行拦截
const obj = new Proxy(data, {
    get(target, key) {
        // 将副作用函数添加到桶中
        bucket.add(activeEffect);
        return target[key];
    },
    set(target, key, newVal) {
        target[key] = newVal;
        // 检测到写入操作,这个时候从桶里取出副作用并依次执行
        bucket.forEach((fn) => fn());
        return true;
    },
});

Screenshot 2024-11-08 at 11.51.35.png

3. 依赖关系建立

按照当前响应式系统的逻辑

  • 当我们对obj.text属性执行读取操作时,对应的副作用函数会被收集
  • 相应地,只有对obj.text这个属性进行写入操作时,这些副作用函数才会执行

可实际情况并不是这样,比如,如果把setTimeout中的内容改成对其他对象属性操作时,上面两个副作用函数也会被再次触发执行

setTimeout(() => {
    // 原本的obj.text改成obj.nonExist
    obj.nonExist = "Hello Vue!";
}, 1000);

原因很简单,Proxy中的setter函数并不是只监听obj.text,而是整个obj对象,

所以obj的任意一个属性的写入操作都会触发副作用函数的执行,同理读取操作也会对副作用函数进行存储

现在的痛点就是:如何在副作用函数与被操作的目标字段之间建立明确的联系,保证每个目标字段有自己对应的副作用函数集合

观察之前定义的副作用函数,我们可以看到有三个关键元素

  1. 被操作的target对象:obj
  2. 被操作的对象属性keyobj.text
  3. 执行的副作用函数:effectFn
effect(function effectFn(){
    document.body.innerText = obj.text;
});

这三个元素构成的依赖关系如下

graph LR
    A[obj] --> B[obj.text]
    B --> C[effectFn]

我们可以从副作用函数桶bucket着手,重新定义它的数据结构,让它可以记录下这层依赖关系

之前我们是用Set集合来定义bucket,现在我们改成使用WeakMap

WeakMapMap用法类似,都是键值对存储,这里我们之所以选择使用前者,是因为它对键是弱引用,对垃圾清理器更友好

  • 如果一个对象只被 WeakMap 引用,而没有其他引用指向该对象,那么这个对象可以被垃圾回收器回收
  • 反过来,如果是 Map,即使不再需要某个对象,只要它作为键存在于 Map 中,它就不会被垃圾回收。这就会导致内存泄漏,尤其是在生命周期较长的应用中,未能及时清理不再使用的对象可能会占用大量内存

修改后的桶的数据结构图如下

Vue Reactive System Whiteboard.png

  • bucket储存着targetkey的依赖关系
    • obj => obj.text
  • depsMap储存着keyset的关系
    • obj.text => effectFn
let activeEffect // 当前正在执行的副作用函数
const data = { text: "Hello World!" };
const bucket = new WeakMap()

const obj = new Proxy(data, {
    get(target, key) {
        // 没有需要绑定的副作用函数,直接return
        if (!activeEffect) return target[key]

        let depsMap = bucket.get(target)
        // 如果不存在,那么就新建一个与 target 关联的depsMap
        if (!depsMap) {
            bucket.set(target, (depsMap = new Map()))
        }

        // 如果不存在,同样新建一个与 key 关联的deps
        let deps = depsMap.get(key)
        if (!deps) {
            depsMap.set(key, (deps = new Set()))
        }
        // 最后将当前激活的副作用函数添加到“桶”里
        deps.add(activeEffect)

        return target[key]
    },
    set(target, key, newVal) {
        target[key] = newVal
        // 根据target从桶中取得 depsMap
        // 再根据key取得所有副作用函数 effects,然后依次执行
        const depsMap = bucket.get(target)
        if (!depsMap) return
        const deps = depsMap.get(key)
        deps && deps.forEach(effectFn => effectFn())
    }
})

再次运行代码,现在修改obj.nonExist已经不会再触发副作用函数了

最后,为了进一步调高代码整洁度,我们可以把将副作用函数加入桶中从桶中取出副作用函数执行的这两个模块逻辑抽成单独的函数:

  • track: 在执行读取操作时执行,建立依赖关系
  • trigger: 在执行写入操作时执行,执行副作用函数
function track(target, key) {
    // 没有 activeEffect,直接 return
    if (!activeEffect) return
    let depsMap = bucket.get(target)
    // 如果不存在,那么就新建一个与 target 关联的depsMap
    if (!depsMap) {
        bucket.set(target, (depsMap = new Map()))
    }
    // 如果不存在,同样新建一个与 key 关联的deps
    let deps = depsMap.get(key)
    if (!deps) {
        depsMap.set(key, (deps = new Set()))
    }
    // 最后将当前激活的副作用函数添加到桶里
    deps.add(activeEffect)
}

function trigger(target, key) {
    // 根据target从桶中取得 depsMap
    // 再根据key取得所有副作用函数 effects,然后依次执行
    const depsMap = bucket.get(target)
    if (!depsMap) return
    const effects = depsMap.get(key)
    effects && effects.forEach(fn => fn())
}

const obj = new Proxy(data, {
    get(target, key) {
        track(target, key)
        return target[key]
    },
    set(target, key, newVal) {
        target[key] = newVal
        trigger(target, key)
    }
})

4. 双向依赖

现在的系统还是不够灵活, 虽然我们可以在响应式变量与副作用函数之间建立起明确的依赖关系,但是无法反过来在需要时解除这层依赖关系

下面的例子可以更好地解释这个需求的必要性:

data中引入一个新的属性showText,用于决定是否展示obj.text,同时在副作用函数effectFn中加上这层逻辑

随后我们将showText的值改为fasle,然后再次修改obj.text的值

const data = { text: "Hello World!", showText: true };
effect(function effectFn() {
    console.log("effectFn triggered");
    document.body.innerText = obj.showText ? obj.text : "No text";
});

obj.showText = false;
obj.text = "Hello Vue!";

按理说此时effectFn不应该执行,因为这个三元表达式始终都返回"No text",跟obj.text的值没有关系

结果显示obj.text的修改依旧触发了副作用函数的执行

image.png

这就是因为我们没有解除之前的依赖,导致产生了遗留的副作用函数

解决这个问题的思路很简单:

  • 每次副作用函数执行前,先把它从所有与之关联的依赖集合中删除

  • 副作用函数执行完毕后,会重新建立联系,但在新的联系中不会再包含遗留的副作用函数

为了实现上述逻辑,我们需要应用以下的更改:

  1. 在注册副作用函数时,为副作用函数effctFn创建一个新的属性deps,专门用于储存依赖集合
  2. track中将依赖的副作用函数添加到deps中的时候,也反过来将deps加入副作用函数的依赖集合deps
  3. 在执行副作用函数之前,遍历deps清空重置依赖集合
function cleanup(effectFn) {
    // 遍历 effectFn.deps 数组
    for (const dep of effectFn.deps) {
        // 将 effectFn 从依赖集合中移除,解除依赖关系
        dep.delete(effectFn)
    }
    // 最后将 effectFn.deps 数组清空, 防止内存泄漏
    effectFn.deps.length = 0
}

function effect(fn) {
    const effectFn = () => {
        // 3. 清空依赖集合
        cleanup(effectFn)
        activeEffect = effectFn
        fn()
    }
    // 1. 为副作用函数effectFn注册一个deps属性
    effectFn.deps = []
    effectFn()
}


function track(target, key) {
    // ...
    // 2. effectFn.deps进行双向收集
    activeEffect.deps.push(deps)
}

现在运行会发现页面陷入了无限循环

Screenshot 2024-11-06 at 16.11.04.png

我们回到代码中一步步看下怎么回事:

  1. 首先执行obj.showText = false
  2. 写入操作,执行trigger() => 执行副作用函数: effectFn()
  3. cleanup(effectFn) => dep.delete(effectFn)
  4. effectFn()继续执行:
  5. fn() => 读取obj.showText => 读取操作,执行track() => deps.add(activeEffect)

也就是说,下面trigger中的这段代码执行时,每删掉一个fn,就又加回来一个fn,所以forEach才会不停地执行effectFn, 导致无限循环

const effects = depsMap.get(key)
effects && effects.forEach(fn => fn())

知道起因之后就好办了,我们直接在这个节点进行处理,创建另外一个Set集合专门用于执行副作用函数,这样它的运行就不会再受外界影响

    function trigger(target, key) {
        //...
        const effectsToRun = new Set(effects)
        effectsToRun.forEach(fn => fn())
    }

现在obj.text的改动已经不会触发副作用函数了

image.png

5. 封装响应式函数

最后我们对当前的实现进行一个简单的封装:定义一个reactive函数,返回一个Proxy对象,这样就不需要在定义时重写Proxy的回调函数,直接调用reactive即可

function reactive(data) {
    return new Proxy(data, {
        get(target, key) {
            track(target, key)
            return Reflect.get(target, key)
        },
        set(target, key, newVal) {
            Reflect.set(target, key, newVal)
            trigger(target, key)
        }
    })
}

同时我们将Proxy中的数据读写操作改成用Reflect对象处理

get(target, key) {
    track(target, key)
    return Reflect.get(target, key)
}
set(target, key, newVal) {
    Reflect.set(target, key, newVal)
    trigger(target, key)
}

相比直接通过target操作,Reflect有两个好处:

  1. 在执行失败时不会抛出异常,而是返回一个Boolean布尔值。简化错误处理
  2. Reflect可以调用原生的对象方法。这意味着可以在拦截器中实现自定义逻辑的同时,也能保留原始操作的行为

最终的实现结果如下:

总结

通过上述步骤,我们成功构建了一个基础的响应式系统,具体过程如下:

  1. 数据代理:首先利用 Proxy 对象实现数据代理,有效监听并拦截对对象的读写操作。
  2. 副作用管理:创建了一个变量 activeEffect,用于指向当前正在执行的副作用函数,从而确保我们能够正确追踪和管理这些函数。
  3. 依赖关系建立:使用 WeakMap 集合创建了一个“桶”,在这里我们存储现有的副作用函数,同时明确对象与副作用函数之间的依赖关系。
  4. 双向依赖:为副作用函数添加了 deps 属性,以实现双向依赖的管理,这使得我们在处理分支切换时更加灵活和高效。
  5. 封装响应式函数:最后,我们封装了一个 reactive 函数,方便重复定义响应式变量,并利用 Reflect 对象的方法来优化错误处理和原始操作的调用。

通过搭建这样一个完整的响应式系统框架,我们不仅深入理解了 Vue 的底层原理,也为将来更高效地使用这一框架打下了坚实的基础。希望读者通过本文,能够对Vue的响应式系统有更深的认识,并在未来的项目中灵活运用这些知识