vlambda博客
学习文章列表

『一起读书』Vue.js设计与实现04-设计一个完善的响应系统

1写在前面

响应系统是Vue.js的重要组成部分,我们要实现一个简易的响应式系统,必须先要了解什么是响应式数据和副作用函数。在实现过程中,我们需要考虑如何避免无限递归,为什么需要嵌套副作用函数,以及多个副作用函数之间会产生什么影响?

2副作用函数

所谓副作用函数,指的是会产生副作用的函数,而副作用指的是函数effect的执行会直接或间接影响到其它函数的执行,那么就说effect函数产生了副作用,effect就是副作用函数。

<div id="app"></div>

<script>
//全局变量
let state = {
name:"onechuan",
age:18,
address:"北京"
}
function effect(){
app.innerHTML = "hello pingping," + state.name + "," + state.age + "," + state.address;
}
effect();

setTimeout(()=>{
//修改全局变量,产生副作用
state.address = "广州";
},1000)
</script>

在上面的代码片段中,副作用函数effect会设置id为app的标签innerHTML属性 app.innerHTML = "hello pingping," + state.name + "," + state.age + "," + state.address;,其中state.address的值为"北京"。而当state.address发生变化时,希望副作用函数effect能够重新执行,state.address的值变为"广州"。

当前在setTimeout函数中代码修改了state.address的值,除了对象的值本身发生变化外,没有其他任何变化,达不到要求的效果。如果希望值变化后副作用函数立即更新,那么state对象数据就必须是响应式的,那么什么是响应式的,应该如何让state实现响应式呢?

3响应式数据

对上面的要求进行分析,要想让state变成响应式数据,需要满足两个条件:

  • 在副作用函数effect执行时,从对象state中读取address的值,触发读取操作
  • 当修改state.address的值时,把对象state中的address的值进行修改,触发设置操作

再次思考,响应式数据的实现就变成了拦截对象进行取值和设值操作。当从state对象中读取address时,就将副作用函数effect存储到容器中,当设置state对象中的address值的时候,从容器中取出effect函数并执行。

取值操作
『一起读书』Vue.js设计与实现04-设计一个完善的响应系统
设置操作

那么,到底应该如何实现对一个对象属性的读取和设置操作呢?在Vue.js2中采用的是Object.defineProperty函数实现的,而在Vue.js3中则是采用Proxy代理对象的方法实现的。我们根据上面的思路和流程图,先简易实现个最low的拦截取值设置操作。

<div id="app"></div>
<script>
//全局变量
let state = {
    name:"onechuan",
    age:18,
    address:"北京"
}

// 存储副作用函数的桶
const bucket = new Set();

// 对原始数据的代理
const obj = new Proxy(state,{
    // 拦截读取操作
    get(target, key){
        // 将副作用函数effect添加到存储副作用函数的桶中
        bucket.add(effect)
        // 返回属性值
        return target[key]
    },
    // 拦截设置操作
    set(target, key, newVal){
        // 设置属性值
        target[key] = newVal
        // 把副作用函数从桶里取出并执行
        bucket.forEach(fn=>fn())
        // 返回true代表设置操作成功
        return true
    }
})

function effect(){
    const app = document.querySelector("#app");
    app.innerHTML = obj.name + "," + obj.age + "," + obj.address;
}
    
effect();

setTimeout(()=>{
    //修改全局变量,产生副作用
    obj.address = "广州";
},1000)
</script>

在浏览器中渲染得到:

『一起读书』Vue.js设计与实现04-设计一个完善的响应系统

1s后页面更新渲染为:

看到上面的代码片段,不禁想问为什么要将存储副作用函数的容器类型设置为Set类型,这是因为对于同一个对象属性进行多次代理就会出现死循环的情况,对此使用Set可以用于去重。

state是被代理的原始数据,而obj是采用Proxy进行代理后的对象数据,在其中实现了拦截和取值设值操作,在取值和设置过程中实现了副作用函数effect的存储和取出执行的操作。

4尚且完善的响应式系统

为什么说是尚且完善的响应式系统,这是因为在本段中将循序渐进介绍,如何实现一个功能尚且完善的响应式系统。可以实现通用式的副作用函数,匿名函数也能够被收集到副作用函数容器中,而非命名的effect函数。

注册副作用函数

要实现这一点,只需要编写一个通用函数,提供注册副作用函数机制即可。

// 全局变量用于存储当前被注册的副作用函数
let activeEffect;
// effect用于注册副作用函数
function effect(fn){
    // 当调用effect注册副作用函数时,将副作用函数fn赋值给activeEffect
    activeEffect = fn;
    // 执行副作用函数
    fn();
}

effect(()=>{
  app.innerHTML = state.name + "," + state.age + "," + state.address;
})

在上面代码片段中,传递一个闭包即可实现注册副作用函数的功能,当effect函数执行时,先将effect传递的闭包函数暂存到变量activeEffect,作为当前注册的副作用函数。

//原始数据
let state = {
    name:"onechuan",
    age:18,
    address:"北京"
}
// 存储副作用函数的桶
const bucket = new Set();
// 对原始数据的代理
const obj = new Proxy(state,{
    // 拦截读取操作
    get(target, key){
        // 将activeEffect存储的副作用函数收集到桶里
        if(activeEffect){
            bucket.add(activeEffect)
        }
        // 返回属性值
        return target[key]
    },
    // 拦截设置操作
    set(target, key, newVal){
        // 设置属性值
        target[key] = newVal
        // 把副作用函数从桶里取出并执行
        bucket.forEach(fn=>fn())
        // 返回true代表设置操作成功
        return true
    }
})

effect(()=>{
  app.innerHTML = state.name + "," + state.age + "," + state.address;
})

setTimeout(()=>{
    //修改全局变量,产生副作用
    obj.address = "广州";
},1000)

当我们在响应式数据obj上设置一个不存在的属性时,副作用函数并不会去对象上读取这个属性的值,也就是这个不存在的属性并没有与副作用函数建立响应联系。原本不应该触发副作用函数中的匿名函数,但是实际上却触发了effect函数的执行,这也印证了我们当前设计的系统还存在缺陷。

之所以出现上面的问题,这是因为在没有副作用函数与被操作的目标字段之间建立明确的关系,这就是为什么在Vue.js3实际设计中没有简单使用Set类型的原因。为了解决这种问题,我们只需要在副作用函数与被操作字段间建立联系即可,重新设计收集副作用函数的容器数据结构。

依赖收集的数据结构

要重新设计副作用函数的容器数据结构,需要我们分析effect函数的执行机制,这段代码中存在三个重要部分:

  • 被操作(读取)的代理对象obj (target对象)
  • 被操作(读取)的属性名称address (target对象的键名)
  • 使用effect函数注册的副作用函数effectFn

三者建立的关系是:

|-target
  |- key
    |- effectFn

对于上面的分析,我们得先重新设计存储副作用函数的依赖收集容器的数据结构,创建WeakMap用于存储对象,Set用于存储副作用函数。

// 创建存储副作用函数的桶
const bucket = new WeakMap();
// 全局变量用于存储被注册的副作用函数
let activeEffect;

// 响应式函数
const obj = new Proxy(state,{
    // 拦截读取操作
    get(target, key){
        // 没有activeEffect
        if(!activeEffect) return
        // 根据目标对象从桶中获得副作用函数
        let depsMap = bucket.get(target);
        // 判断是否存在,不存在则创建一个Map
        if(!depsMap) bucket.set(target, depsMap = new Map())
        // 根据key从depsMap取的deps,存储着与key相关的副作用函数
        let deps = depsMap.get(key);
        // 判断key对应的副作用函数是否存在
        if(!deps) depsMap.set(key, deps = new Set())
        // 最后将激活的副作用函数添加到桶里
        deps.add(activeEffect)
        // 返回属性值
        return target[key]
    },
    // 拦截设值操作
    set(target, key, newVal){
        // 设置属性值
        target[key] = newVal;
        // 根据target从桶中取的depMaps
        const depMaps = bucket.get(target);
        // 判断是否存在
        if(!depMaps) return
        // 根据key值取得对应的副作用函数
        const effects = depMaps.get(key);
        // 执行副作用函数
        effects && effects.forEach(fn=>fn())
    }
})

在上面的代码片段中,所写WeakMap、Map和Set的数据结构关系如下图所示。三者的具体作用:

  • WeakMap用于存储代理对象target,用于存储和判断当前对象是否已经被Proxy进行代理过。如果被代理过则直接返回WeakMap中的代理对象,如果没有被代理过则使用Proxy进行代理后存储,从而避免同一个对象被代理多次。
  • Map用于存储经过Proxy代理的对象的属性名
  • Set用于存储Map中对应的每个属性的副作用函数,可以用于去重,避免多次调用

为什么使用WeakMap作为存储对象的容器呢?

这是因为WeakMap是弱引用的Map,不会影响到垃圾回收机制的正常工作,WeakMap多引用的对象执行完毕后,会将对象从内存中移除,从而避免内存泄漏。所以WeakMap经常用于存储那些只有当key所引用对象存在时(没有被回收)才有价值的信息。

在前面代码片段中,如果target对象没有任何引用了,说明用户没有使用它,此时垃圾回收机制就可以将其进行清除,从而避免内存溢出。

整理抽取代码

将前面的代码片段进行抽取函数,封装得到track和trigger函数,使得我们的代码逻辑更加清晰明了,也能带给我们更大的灵活性。

// 全局变量用于存储被注册的副作用函数
let activeEffect;
// 创建存储副作用函数的桶
const bucket = new WeakMap();
// 原始数据
const state = {
    name:"pingping",
    age:18,
    address:"北京"
}

// 响应式函数
const obj = new Proxy(state,{
    // 拦截读取操作
    get(target, key){
        // 将副作用函数activeEffect添加到存储副作用函数的WeakMap中
        track(target, key)
        // 返回属性值
        return target[key]
    },
    // 拦截设值操作
    set(target, key, newVal){
        // 设置属性值
        target[key] = newVal;
        // 将副作用函数从WeakMap中取出并执行
        trigger(target, key)
    }
})

// 在get拦截函数中调用追踪取值函数的变化
function track(target, key){
    // 没有activeEffect
    if(!activeEffect) return
    // 根据目标对象从桶中获得副作用函数
    let depsMap = bucket.get(target);
    // 判断是否存在,不存在则创建一个Map
    if(!depsMap) bucket.set(target, depsMap = new Map())
    // 根据key从depsMap取的deps,存储着与key相关的副作用函数
    let deps = depsMap.get(key);
    // 判断key对应的副作用函数是否存在
    if(!deps) depsMap.set(key, deps = new Set())
    // 最后将激活的副作用函数添加到桶里
    deps.add(activeEffect)
}

// 在set拦截函数中调用trigger函数触发变化
function trigger(target, key){
    // 根据target从桶中取的depMaps
    const depMaps = bucket.get(target);
    // 判断是否存在
    if(!depMaps) return
    // 根据key值取得对应的副作用函数
    const effects = depMaps.get(key);
    // 执行副作用函数
    effects && effects.forEach(fn=>fn())
}
// effect用于注册副作用函数
function effect(fn){
    // 当调用effect注册副作用函数时,将副作用函数fn赋值给activeEffect
    activeEffect = fn;
    // 执行副作用函数
    fn();
}


effect(()=>{
    console.log("打印");
    document.body.innerText = obj.name + "," + obj.age + "," + obj.address; 
})

// 设置一个不存在的属性时
setTimeout(()=>{
    obj.address = "广州"
},1000)

5写在后面

在本文中简单实现了可以进行依赖收集的响应式系统,使用WeakMap配合Map构建了新的存储结构,能够在响应式数据和副作用函数之间建立更加精确的联系。之所以采用WeakMap存储引用对象,是因为其是弱引用的,当某个对象不再被使用时会被垃圾回收机制清除。此外,还对响应式系统的代码进行了功能抽取,对应封装成调用函数track和trigger。

本文主要内容总结来自:霍春阳《Vue.js设计与实现》第四章节,如有侵权,联系删除