Vue演变史 —— 3.0(原理刨析、代码实现)

481 阅读5分钟

src=http___5b0988e595225.cdn.sohucs.com_q_mini,c_zoom,w_640_images_20171013_a22021b35993423e99c590284c519393.jpeg&refer=http___5b0988e595225.cdn.sohucs.jfif

阅读之前附上之前写的Vue源码系列的几篇文章:

  1. Vue演变史 —— 1.0(原理刨析、代码实现)
  2. Vue演变史 —— 2.0(原理刨析、代码实现)
  3. Vue2.0详解diff算法

三个函数,让你搞懂Vue3.0的核心响应式原理

响应式原理对比

在面试中,可能会问你Vue1.0~Vue3.0三个版本的响应式原理是什么?你会说:

  • Vue1.0、Vue2.0都是基于Object.defineProperty()实现的。
  • Vue3.0是基于Proxy实现的。 以上回答都没有任何问题,核心原理就是Object.defineProperty()Proxy这俩个Api。

Vue2.0

我们都知道Vue2.0核心响应式原理是通过Object.defineProperty()实现的,所以我们现在看一下Object.defineProperty()的用法。

Object.defineProperty(obj,key,attributes)

  • obj:想要处理的对象
  • key:属性名称
  • attributes:属性描述符(get、set、configurable、enumerable、value、writable)
function defineReactive(obj, key, val) {
    Object.defineProperty(obj, key, {
        get() {
            console.log(`读取属性:${key}`);
            return val;

        },
        set(newVal) {
            if (newVal !== val) {
                val = newVal
                console.log(`修改属性:${key} —————— 修改后的值为:${val}`);
            }
        }
    })
}

const data = {
    bar: "bar",
    foo: "foo"
}

Object.keys(data).forEach(key => {
    defineReactive(data, key, data[key]);
})

data.bar
data.bar = "newBar";

data.foo
data.foo = "newFoo";

控制台输出:

image.png

上面就是Object.defineProperty()的基本用法。那为什么Vue3.0会改用Proxy呢?接着看。

// 继续以上代码

// 新增属性name
data["name"] = "张三"; 

我们调试发现控制台跟上面代码输出一样,并没有检测到对象的新增了一个name属性。

image.png

由此可见:

  • 弃用原因:
  1. Object.defineProperty()在Vue中,无法检测到对象的新增操作,我们想要给对象新增响应式属性,必须使用Vue.$set()
  2. Object.defineProperty()在Vue中,会完全遍历data中的所有属性。而Proxy是一个懒加载的过程,只有被访问到才会做响应式处理,这也就是Vue3.0快的原因。

Vue3.0

从上面我们知道了Object.defineProperty()的弊端,接下来我们看一下Proxy的用法。

function reactive(obj) {
    return new Proxy(obj, {
        get(target, key) {
            console.log(`读取属性:${key}`);
            return target[key];
        },
        set(target, key, value) {
            if (target[key] !== value) {
                target[key] = value;
                console.log(`设置属性:${key} —————— 设置后的值为:${target[key]}`);
            }
        },
        deleteProperty(target, key, value) {
            delete target[key];
            console.log(`删除属性:${key} —————— 删除后的值为:${target}`);
        },
    })
}

const data = {
    bar: "bar",
    foo: "foo"
}
const instance = reactive(data);

// 正常访问就不测试了,我们直接新增属性
instance["name"] = "张三";

控制台输出: image.png 从浏览器输出可以看出Proxy,可以直接检测到对象新增属性的操作。在Vue3.0中我们新增对象属性的时候,再也不用Vue.$set()了。

Vue3.0响应式原理实现

先来看一个例子,这个例子跟我之前实现Vue1.0的时候一样

<div class="container">
</div>
<script>
        const data = {
                foo: "foo"
        }
        function update() {
                document.querySelector(".container").innerHTML = data.foo;
        }
        update();
</script>

代码很简单,如果想让我们页面的内容跟着foo发生改变,要怎么做?

// 继续以上代码
data.foo = "newFoo";
update();

是不是只要data.foo发生改变的时候,调用一下update函数就行。

这里顺便提一点题外话,Vue1.0、Vue2.0Vue3.0存储update函数的位置不同:

    1. Vue1.0和Vue2.0是:将update存储在当前key的闭包环境中,更新时,准确找到当前key的闭包环境,dep.notify()进行更新。
    1. Vue3.0是:将update函数存储在一个叫WeakMap对象中,更新时,因为Proxy会给handler传入target、key,所以就可以从WeakMap中查找到对应的cb(更新函数),从而完成更新。

思考

从上面的例子可以看出,当我们试图改变对象中某个属性的值,也就是data.foo = xxx时,调用update函数就可以完成更新。

既然如此,对于对象来说那是不是只要我们在:

  • getter时:依赖收集(收集update函数)
  • setter时:调用收集的函数触发更新 就可以完成我们的响应式更新,而这也是Vue3.0的核心思想。

根据上面的想法,我画了一张图:

1632304761(1).jpg 三句话描述一张图(这里的cb,就是update函数):

  • effectcb函数存入effectStack,并且调用cb函数,因为cb中读取了对象的某个key,触发当前keygetter,执行完之后清空effectStack。(注意:清空effectStack之前,先执行track

  • trackgetter触发时调用,并且可以拿到target,key,并且联合effectStack中存储的cb函数组成类似于{target:{key:[cb]}}存入targetMap中。(这一步不理解没关系,具体看下面实现)

  • triggersetter触发时调用,并且可以拿到target,key,会从targetMap找到当前target.key的中cb函数并执行,完成更新。

effect

作为响应式处理的起点,这个函数主要处理以下几件事:

  1. 执行传入的cb函数。
  2. 处理cb函数执行异常的情况
  3. 将函数临时存储到effectStack中(这一步的用途在track(依赖收集)的时候会看到)
<div class="container">
</div>
<script>
const effectStack = [];

function effect(cb) {
    const e = createEffectReactive(cb);
    e();
}

function createEffectReactive(cb) {
    // 对cb函数异常处理,并返回cb函数的执行结果
    const effectFn = function () {
        try {
            effectStack.push(effectFn);
            cb();
        } finally {
            effectStack.pop();
        }
    }
    return effectFn;
}

const data = {
    foo: "foo"
}
function update() {
    document.querySelector(".container").innerHTML = data.foo;
}
effect(update);
</script>

现在页面正常显示:

image.png

但是如果我们想实现data.foo = xxx页面就变化的话,是不是只要我们在set时,执行对应函数就可以,所以接下来我们要做的就是:拦截对象属性的操作

Proxy

主要用来拦截对象的属性操作,作为Vue3.0响应式的核心成员之一,它的实现也很简单:

Proxy (obj,handler)

  • obj:想要操作的对象
  • handler:ProxyHandler类型(get、set、deleteProperty、...)
function defineReactive(obj) {
    return new Proxy(obj, {
        get(target, key) {
            return target[key];
        },
        set(target, key, value) {
            const res = target[key];
            if (res !== value) {
                target[key] = value;
            }   
        },
        defineProperty(target, key) {
            delete target[key];
        }
    })
}

处理data中的属性

const data = defineReactive({
    foo: "foo"
})

setter时调用更新函数

<div class="container">
</div>
<script>    
    // Proxy  
    function defineReactive(obj) {
        return new Proxy(obj, {
            ...,
            set(target, key, value) {
                const res = target[key];
                if (res !== value) {
                    target[key] = value;
                    update();
                }   
            }
        })
    }
</script>

测试一下代码,页面会在2秒之后,变成newFoo

image.png 如此,我们已经实现了我们想要的效果。

但是应用中不可能只有一个对象,也不可能一个对象只有一个key,我们需要将cb函数分别存储到对应的key中,这个过程简称依赖收集

什么是依赖收集?

  • 当你触发某个keygetter时,会调用tracktrack会将存储在effectStack里面的cb函数联合target、key,组成类似于{target:{key:[cb]}}数据,把它存储到targetMap中,到时候trigger的时候,就可以根据target、key,找到对应的cb函数,执行更新。

trigger和track

两句话描述这两个函数:

  • getter时调用track进行依赖收集
  • setter时调用trigger通知更新
// 依赖收集
function track(target, key) {
    const effect = effectStack[effectStack.length - 1]; // 获取更新函数
    const depMap = targetMap.get(target);
    if (!depMap) {
        depMap = new Map();
        targetMap.set(target, depMap);
    }
    const deps = depMap.get(key);
    if (!deps) {
        deps = new Set();
        depMap.set(key, deps);
    }
    deps.add(effect); // 将更新函数存储到当前key对应的数组中
}

// 通知更新
function trigger(target, key) {
    const depMap = targetMap.get(target);
    if (depMap) {
        const deps = depMap.get(key);
        if (deps) {
            deps.forEach(dep => {
                dep();
            })

        }
    }
}

完整代码

<div class="container">
</div>
        
<script>
// effect
const effectStack = [];
function effect(cb) {
    const e = createEffectReactive(cb);
    e();
}
function createEffectReactive(cb) {
    function effect() {
        try {
            effectStack.push(effect);
            cb();
        } finally {
            effectStack.pop();
        }
    }
    return effect;
}

// Proxy
function defineReactive(obj) {
    return new Proxy(obj, {
        get(target, key) {
            track(target, key);
            return target[key];

        }, 
        set(target, key, value) {
            const res = target[key];
            if (res !== value) {
                target[key] = value;
                trigger(target, key);
            }
        },
        defineProperty(target, key) {
            delete target[key];
            trigger(target, key);
        }
    })
}

const targetMap = new WeakMap();

// track(依赖收集)
function track(target, key) {
    const effect = effectStack[effectStack.length - 1];
    let depMap = targetMap.get(target);
    if (!depMap) {
        depMap = new Map();
        targetMap.set(target, depMap);
    }
    let deps = depMap.get(key);
    if (!deps) {
        deps = new Set();
        depMap.set(key, deps);
    }
    deps.add(effect);
}

// trigger(通知更新)
function trigger(target, key) {
    const depMap = targetMap.get(target);
    if (depMap) {
        const deps = depMap.get(key);
        if (deps) {
            deps.forEach(dep => {
                dep();
            })
        }
    }
}


const data = defineReactive({
    foo: "foo"
})
function update() {
    document.querySelector(".container").innerHTML = data.foo;
}
effect(update);

setTimeout(() => {
    console.log("触发");
    data.foo = "newFoo";
}, 2000)


</script>

实现ref

基础类型做响应式一般用ref,读取方式为.value,所以它的实现方式也很简单:

function ref(value) {
    return defineReactive({
        value
    })
}

实现computed

function computed(fn) {
    const res = ref();
    effect(() => res.value = fn());
    return res;
}

结尾

文章有总结不到位的地方,我后期会持续修改。