手写实现 vue2 和 vue3 的响应式原理和依赖收集、触发的对比

4,406 阅读11分钟

vue3的响应式有了突破性的实现,那么vue3的响应式和vue2有多大区别呢?

Object.defineProperty初探

相信大家都已经知道了 vue2 的响应式原理是基于Object.defineProperty Object.defineProperty() 方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象

不知道大家有没有使用过 vue2 中的工具函数:vue.util.defineReactive(obj, key, val),this.$set(obj, key, val) 他们都是设置响应式数据的函数,我们就来实现一个

// vue.util.defineReactive(obj, key, val)
function defineReactive(obj, key, val) {
  Object.defineProperty(obj, key, {
    get() {
      console.log("get key:", key, val);
      return val;
    },
    set(v) {
      console.log("set key:", key, v);
      val = v;
    },
  });
}

// this.$set(obj, key, val), 这里需要注意obj,必须是已经进行响应式处理的对象
function set(obj, key, val) {
  defineReactive(obj, key, val);
}

let obj = {};
defineReactive(obj, "name", "coboy");
// 读取一下
obj.name;
// 更改一下
obj.name = "coman";

reactive.log.png

可以看到我们已经实现了对对象 key 拦截 但目前需要手动进行监听拦截,我们需要自动拦截

function observe(obj) {
  if (typeof obj !== "object" || obj === null) {
    return;
  }
  Object.keys(obj).forEach((key) => defineReactive(obj, key, obj[key]));
}
function defineReactive(obj, key, val) {
  observe(val);
  Object.defineProperty(obj, key, {
    get() {
      console.log("get key:", key, val);
      return val;
    },
    set(v) {
      console.log("set key:", key, v);
      observe(v); // 有可能设置的也是一个对象
      val = v;
    },
  });
}

然后我们就可以

let obj = { name: "coboy", age: 18, info: { desc: "技术爱好者" } };
observe(obj);
// 读取一下
obj.name;
// 更改一下
obj.name = "coman";
// 深度读取
obj.info.desc;
// 更改一下
obj.info.desc = "世界人民大团结万岁";

vue2.log2.png

vue2响应式原理

vue2-defineProperty.png

原理分析

初始化的时候对data数据进行Observer响应式处理,还要Compile解析和编译模板,产生更新函数updater,并在这个过程中产生Watcher,将来对应数据变化时Watcher会调⽤更新函数,通过Dep和响应数据的key之间建立关系,将来data中数据⼀旦发⽣变化,会⾸先找到对应的Dep,通知所有Watcher执⾏更新函数

  • Vue:框架构造函数
  • Observer:执⾏数据响应化(分辨数据是对象还是数组)
  • Compile:编译模板,初始化视图,收集依赖(更新函数、watcher创建)
  • Watcher:执⾏更新函数(更新dom)
  • Dep:管理多个Watcher,批量更新

我们今天只对响应式原理方面进行深入探讨,vue的框架的其他内容省略

实现一个vue2最简单的依赖收集更新的例子

模拟更新函数
<div id="app"></div>
let obj = { name: "coboy", age: 18, info: { desc: "技术爱好者" } };
observe(obj);

// 模拟更新函数
function update(key) {
    const fn = function (val) {
        app.innerHTML = val
    }
    fn(obj[key])
}

update('age')

引入动态更新

setInterval(() => {
    obj.age++
}, 1000)

发现defineReactive函数中的get,set方法已经进行了拦截,但模拟更新函数还不能对HTML进行更新

vue2.log3.png

依赖收集和触发原理

这个时候我们就要引入Watcher, Dep,vue2中两个比较重要的概念了

// 负责具体节点的更新
class Watcher {
    constructor(key, update) {
        this.key = key
        this.updater = update
    }

    update() {
        const val = obj[this.key]
        this.updater(val)
    }
}
// Dep和响应式属性key之间有一一对应关系
class Dep {
    constructor() {
        // Dep中保存了所有的关注者
        this.deps = []
    }
	// 订阅
    subscribe(dep) {
        this.deps.push(dep)
    }
	// 通知
    notify() {
        this.deps.forEach(dep => dep.update())
    }
}

Watcher负责具体节点的更新

Watcher一定要知道哪个key跟哪个节点跟Watcher有关,我要更新谁,这就是Watcher的职责,只管做事,不管为谁做。当Dep通知的时候,才去更新

Dep和响应式属性key之间有一一对应关系

Dep像管家,只管通知别人做事,负责通知watchers去更新

在更新函数中产生Watcher

// 模拟更新函数
function update(key) {
    const fn = function (val) {
        app.innerHTML = val
    }
    fn(obj[key])
    new Watcher(key, function(val) {
        fn(val)
    })
}

依赖收集

依赖收集就是把包含Watcher的更新函数和Dep之间建立联系,这是一个发布订阅的过程

那么dep在什么地方创建呢,我们知道每一个key有与之对应的dep,所以每次执行一次defineReactive的时候就应该产生一个dep

dep.create.png

这个闭包会形成一个key,也会形成唯一的dep,则这个dep和这个key形成一一对应的关系,每调一次就形成一个,然后在用户get值的时候,也就是在属性拦截的get里把watcher和dep之间要建立关系,这就是所谓的依赖收集。

触发依赖收集

vue-watcher1.png

设置一个全局的静态属性,储存Watcher实例

读一下触发Object.defineProperty里的get方法进行依赖收集

一旦建立了关系,则设置为空,防止频繁被添加

dep.add.png

判断是否存在target,存在则订阅一下

触发更新

将来key的值更新了就dep就去通知更新

dep.notify.png

视图中会⽤到data中某key,这称为依赖。同⼀个key可能出现多次,每次都需要收集出来⽤⼀个 Watcher来维护它们,此过程称为依赖收集。 多个Watcher需要⼀个Dep来管理,需要更新时由Dep统⼀通知。

vue3的响应式原理

proxy

Proxy 对象用于创建一个对象的代理,从而实现基本操作的拦截和自定义(如属性查找、赋值、枚举、函数调用等)。

实现一个最简单的reactive
function reactive(obj) {
    return new Proxy(obj, {
        get(target, key) {
            console.log('get key:', key, target[key])
            return target[key]
        },
        set(target, key, val) {
            target[key] = val
            console.log('set key:', key, target[key])
        }
    })
}
const obj = reactive({ name: "coboy", age: 18, info: { desc: "技术爱好者" } })
obj.name // 读一下
obj.name = 'coman' // 更改一下

reactive.log.png

我们看到也可以像vue2的Object.defineProperty那样实现对数据的拦截

但发现对象嵌套的之后,它就读取不了

obj.info.desc // 读取不了
Proxy是一个懒处理的代理

我们发现Proxy是一个懒处理的代理,你不访问具体的key,它都不进行处理,所以需要再get里做递归拦截操作

proxy.get.png

在初始化的过程中有一件事情要做,就是依赖收集,当将来这些数值发生变化之后去通知组件或页面进行更新,这个跟vue2是一样,但具体的实现则完全不同,vue3有一个突破性的实现。vue2是创建一个watcher,一个dep建立关系

vue3的新api—effect

effect会接收一个函数fn,这个函数会和它内部的那些依赖响应式数据之间建立关系,那怎么建立关系呢?

// 临时存储副作用函数
const effectStack = [];

// 依赖收集函数
function effect(fn) {
    effectStack.push(fn)
    fn() // 立即执行一下,触发track,组件也需要初始化
}

// 依赖收集:建立target,key和fn之间的映射关系
function track(target, key) {

}

// 触发更新 当某个响应式数据发生变化,根据target,key获取对应的fn并执行他们
function trigger(target, key) {

}

根据源码先搭建一个框架,再进行详细编写

effect接收的fn在执行的时候可能会报错,所以要创建一个高阶函数createReactiveEffect 进行处理,所以用户传进来的fn函数是进行了包装的,因为有很多额外的事情要做。

createReactEffect.png

因为执行了fn,fn里面可能会存在响应式数据,所以会触发Proxy里的get,set函数

get-set.png

某个fn函数读取了某个值,那么就要建立当前target,key和fn的之间的关系,将来某个key值发生了变化,那么就要去通知相关的fn函数,重新执行

track,trigger函数的实现
const targetMap = new WeakMap() // WeakMap 弱引用,不影响垃圾回收机制

// 依赖收集:建立target/key和fn之间映射关系
function track(target, key) {
    // 1.获取当前副作用函数
    const effect = effectStack[effectStack.length - 1]
    if(effect){
        // 2.取出target/key对应的map
        let depMap = targetMap.get(target)
        if(!depMap){
           depMap = new Map()
           targetMap.set(target, depMap) 
        }

        // 3.获取key对应的set
        let deps = depMap.get(key)
        if(!deps){
            deps = new Set()
            depMap.set(key, deps)
        }

        // 4.存入set
        deps.add(effect)
    }
}

// 触发更新:当某个响应式数据发生变化,根据target、key获取对应的fn并执行他们
function trigger(target, key) {console.log('targetMap', targetMap)
    // 1.获取target/key对应的set,并遍历执行他们
    const depMap = targetMap.get(target)

    if(depMap){
        const deps = depMap.get(key)
        if(deps){
            deps.forEach(dep => dep());
        }
    }
}

我们来测试一下

const obj = reactive({ name: "coboy", age: 18, info: { desc: "技术爱好者" } })

effect(() => {
    console.log('effect1', obj.name)
})
effect(() => {
    console.log('effect2', obj.name, obj.info.desc)
})
obj.name = 'coman' // 更改一下
obj.info.desc = 'coder' // 更改一下

这个函数就和name这个key建立了联系,将来这个name的值发生了改变,这个函数将会被重新执行,

effect.log.png

第一次两个函数都立即执行了一下,分别输出了effect1, effect2 obj.name的值变成coman之后,跟name,这个key有关联的effect1,effect2又分别重新执行 obj.info.desc的值变成coder之后,因为只有effect2跟obj.info.desc有关联,所以只有effect2被执行了

vue2和vue3的响应式原理对比

vue3-proxy.png

effect里添加一个依赖函数fn,fn中有响应式数据发生变化,那么这个fn的依赖函数会再次执行,如果effect添加了一个组件更新函数,那么那个组件里的依赖变量也就可以这个组件更新函数产生了映射关系,所以effect的作用就是把两者之间的映射关系保存起来。

如果effect添加了一个组件更新函数fn,这个更新函数会被包装成一个新的高阶函数fn1,这个fn1会立即执行一次,也就是所谓组件初始化的过程。在这个过程中同时也在进行依赖映射关系的保存。组件初始化在读取响应式数据的值的时候,就触发了Proxy中的getter函数,然后使用track方法进行依赖收集,把当前的key和刚才读取的函数fn函数建立依赖关系。

fn在执行之前先临时存储在了一个全局变量effectStack里,当track被触发执行的时候,就可以取出effectStack里的fn函数,再和当前的响应式对象target和key保存在一个WeakMap的数据结构里面。对应关系是响应式对象target是WeakMap,key是Map,fn是Set,因为一个Key可能被多个fn调用,而且fn不用重复添加,需要去重,所以需要使用Set数据结构。

将来响应式数据发生变化,那么就会触发Proxy的setter函数,这个时候就会去调佣触发更新函数trigger,trigger函数会根据当前的target和key去刚刚在getter里保存起来的数据仓库中取出与当前target和key对应的更新函数fn,重新执行一次。

vue2,watcher,dep.png

vue2是使用Object.defineProperty对每个响应式对象的key进行拦截,从而可以侦测数据变化,很明显这种方式不是很好。

  1. 首先它只能对对象支持比较好,对数组支持不了,对数组需要独特的处理,而vue3使用Proxy,则是代理整个对象,数组也可以侦测数据的变化
  2. vue2初始化的时候需要递归变量对象的所有key,速度慢,如果初始化的对象很大,那么时间和内存的成本开销则非常大,因为在初始化的时就需要创建很多的Dep和Watcher来保存这个依赖关系
  3. vue2新增删除属性无法监听,需要使用特殊的api
  4. vue2不支持Map,Set,Class等数据结构

vue3是使用Proxy进行代理,而Proxy是一种懒处理机制,就是你不访问具体的值,则不会产出依赖关系变量和函数 用生动的例子来说明一下:Proxy相当于把数据放在一个小区了,Proxy在门口放一个保安进行拦截,但你只要不出小区,保安都不会对你进行拦截检查处理,而vue2则相当于派出很多保安挨家挨户进行检查,也就是 这个小区有多少住户,就需要上门检查多少次。

全部代码

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>手写实现vue2和vue3的响应式和依赖收集、触发的对比</title>
</head>
<body>
    <div id="app"></div>
    <script>
        // vue.util.defineReactive(obj, key, val)
        function defineReactive(obj, key, val) {
            observe(val);
            const dep = new Dep()
            Object.defineProperty(obj, key, {
                get() {
                    console.log("get key:", key, val);
                    Dep.target && dep.subscribe(Dep.target) 
                    return val;
                },
                set(v) {
                    console.log("set key:", key, v);
                    observe(v); // 有可能设置的也是一个对象
                    val = v;
                    // 通知更新
                    dep.notify()
                },
            });
        }

        function observe(obj) {
            if (typeof obj !== "object" || obj === null) {
                return;
            }
            Object.keys(obj).forEach((key) => defineReactive(obj, key, obj[key]));
        }
        // this.$set(obj, key, val), 这里需要注意obj,必须是已经进行响应式处理的对象
        function set(obj, key, val) {
            defineReactive(obj, key, val);
        }

        class Watcher {
            constructor(key, update) {
                this.key = key
                this.updater = update
                // 触发依赖收集
                Dep.target = this // 设置一个全局的静态属性,储存Watcher实例
                obj[key] // 读一下触发Object.defineProperty里的get方法进行依赖收集
                Dep.target = null // 一旦建立了关系,则设置为空,防止频繁被添加
            }

            update() {
                const val = obj[this.key]
                this.updater(val)
            }
        }
        // 每一个key关联一个Dep
        class Dep {
            constructor() {
                this.deps = []
            }

            subscribe(dep) {
                this.deps.push(dep)
            }

            notify() {
                this.deps.forEach(dep => dep.update())
            }
        }

        // 模拟更新函数
        function update(key) {
            const fn = function (val) {
                app.innerHTML = val
            }
            fn(obj[key])
            new Watcher(key, function(val) {
                fn(val)
            })
        }

        let obj = { name: "coboy", age: 18, info: { desc: "技术爱好者" } };
        observe(obj);

        update('age')
        
        setInterval(() => {
            obj.age++
        }, 1000)
</script>
</body>
</html>
function reactive(obj) {
    return new Proxy(obj, {
        get(target, key) {
            console.log('get', key)
            // 依赖收集
            track(target, key)
            return typeof target[key] === 'object' ? reactive(target[key]) : target[key]
        },
        set(target, key, val) {
            console.log('set', key)
            target[key] = val
            // 通知更新
            trigger(target, key)
        },
        deleteProperty(target, key) {
            console.log('delete', key)
            delete target[key]
            trigger(target, key)
        }
    })
}

// 临时储存副作用函数
const effectStack = [] // 为什么是数组呢? 因为effect存在嵌套,就会有多个副作用函数

// 依赖收集函数:包装fn,立即执行fn,返回包装结果
function effect(fn) {
    const e = createReactiveEffect(fn)
    e()
    return e
}

function createReactiveEffect(fn) {
    const effect = function() {
        try{
            effectStack.push(fn)
             return fn() // 执行可能会返回结果,所以要return
        } finally { 
            effectStack.pop()
        }
    }
    return effect
}

// 保存依赖关系的数据结构
const targetMap = new WeakMap() // WeakMap 弱引用,不影响垃圾回收机制

// 依赖收集:建立target/key和fn之间映射关系
function track(target, key) {
    // 1.获取当前副作用函数
    const effect = effectStack[effectStack.length - 1]
    if(effect){
        // 2.取出target/key对应的map
        let depMap = targetMap.get(target)
        if(!depMap){
           depMap = new Map()
           targetMap.set(target, depMap) 
        }

        // 3.获取key对应的set
        let deps = depMap.get(key)
        if(!deps){
            deps = new Set()
            depMap.set(key, deps)
        }

        // 4.存入set
        deps.add(effect)
    }
}

// 触发更新:当某个响应式数据发生变化,根据target、key获取对应的fn并执行他们
function trigger(target, key) {
    // 1.获取target/key对应的set,并遍历执行他们
    const depMap = targetMap.get(target)

    if(depMap){
        const deps = depMap.get(key)
        if(deps){
            deps.forEach(dep => dep());
        }
    }
}


const obj = reactive({ name: "coboy", age: 18, info: { desc: "技术爱好者" } })

effect(() => {
    console.log('effect1', obj.name)
})
effect(() => {
    console.log('effect2', obj.name, obj.info.desc)
})
obj.name = 'coman' // 更改一下
obj.info.desc = 'coder' // 更改一下