Vue3响应式原理与reactive、effect、computed实现

微信扫一扫,分享到朋友圈

Vue3响应式原理与reactive、effect、computed实现

一、Vue3响应式系统简介

Vue响应式系统的核心依然是 对数据进行劫持
,只不过Vue3采样点是 Proxy类
,而Vue2采用的是 Object.defineProperty()
。Vue3之所以采用Proxy类主要有两个原因:

  • 可以提升性能,Vue2是通过层层递归的方式对数据进行劫持,并且 数据劫持一开始就要进行层层递归(一次性递归)
    ,如果对象的路径非常深将会非常影响性能。而 Proxy可以在用到数据的时候再进行对下一层属性的劫持
  • Proxy可以实现 对整个对象的劫持
    ,而Object.defineProperty()只能实现对 对象的属性
    进行劫持。所以对于 对象上的方法
    或者 新增
    删除
    的属性则无能为力。
// 展示使用Object.defineProperty()存在的缺点
const obj = {name: "vue", arr: [1, 2, 3]};
Object.keys(obj).forEach((key) => {
let value = obj[key];
Object.defineProperty(obj, key, {
get() {
console.log(`get key is ${key}`);
return value;
},
set(newVal) {
console.log(`set key is ${key}, newVal is ${newVal}`);
value = newVal;
}
});
});
// 此时给对象新增一个age属性
obj.age = 18; // 因为对象劫持的时候,没有对age进行劫持,所以新增属性无法劫持
delete obj.name; // 删除对象上已经进行劫持的name属性,发现删除属性操作也无法劫持
obj.arr.push(4); // 无法劫持数组的push等方法
obj.arr[3] = 4; // 无法劫持数组的索引操作,因为没有对数组的每个索引进行劫持,并且由于性能原因,Vue2并没有对数组的每个索引进行劫持
// 使用Proxy实现完美劫持
const obj = {name: "vue", arr: [1, 2, 3]};
function proxyData(value) {
const proxy = new Proxy(value, {
get(target, key) {
console.log(`get key is ${key}`);
const val = target[key];
if (typeof val === "object") {
return proxyData(val);
}
return val;
},
set(target, key, value) {
console.log(`set key is ${key}, value is ${value}`);
return target[key] = value;
},
deleteProperty(target, key) {
console.log(`delete key is ${key}`);
}
});
return proxy;
}
const proxy = proxyData(obj);
proxy.age = 18; // 可对新增属性进行劫持
delete proxy.name; // 可对删除属性进行劫持
proxy.arr.push(4); // 可对数组的push等方法进行劫持
proxy.arr[3] = 4; // 可对象数组的索引操作进行劫持

二、Vue3响应式系统初体验

Vue3的响应式系统被放到了一个单独的 @vue/reactivity
模块中,其提供了 reactive
effect
computed
等方法,其中reactive用于 定义响应式的数据
,effect相当于是Vue2中的 watcher
,computed用于定义 计算属性
。我们先来看一下这几个函数的简单示例,如:

import {reactive, effect, computed} from "@vue/reactivity";
const state = reactive({
name: "lihb",
age: 18,
arr: [1, 2, 3]
});
console.log(state); // 这里返回的是Proxy代理后的对象
effect(() => {
console.log("effect run");
console.log(state.name); // 每当name数据变化将会导致effect重新执行
});
state.name = "vue"; // 数据发生变化后会触发使用了该数据的effect重新执行
const info = computed(() => { // 创建一个计算属性,依赖name和age
return `name: ${state.name}, age: ${state.age}`;
});
effect(() => { // name和age变化会导致计算属性的value发生变化,从而导致当前effect重新执行
console.log(`info is ${info.value}`);
});

三、实现reactive方法

reactive() 方法本质是传入一个 要定义成响应式的target目标对象
,然后通过 Proxy类去代理这个target对象
,最后 返回代理之后的对象
,如:

export function reactive(target) {
return new Proxy(target, {
get() {
},
set() {
}
});
}

如果我们代理的仅仅是普通对象或者数组,那么我们可以直接采用上面的形式,但是我们还需要代理 Set
Map
WeakMap
WeakSet
等集合类。所以为了程序的扩展性,我们需要 根据target的类型动态的返回Proxy类的handler
。我们可以改写成如下形式:

// shared/index.js
export const isObject = (val) => val !== null && typeof val === 'object';
import {isObject} from "./shared";
import {mutableHandlers, mutableCollectionHandlers} from "./handlers";
const collectionTypes = new Set([Set, Map, WeakMap, WeakSet]);
export function reactive(target) {
// 给函数传入不同的handlers然后通过target类型进判断
return createReactiveObject(target, false, mutableHandlers, mutableCollectionHandlers);
}
function createReactiveObject(target, isReadonly, baseHandlers, collectionHandlers) {
if (!isObject(target)) { // 如果传入的target不是对象,那么直接返回该对象即可
return target;
}
// 根据传入的target的类型判断该使用哪种handler,如果是Set或Map则采用collectionHandlers,如果是普通对象或数组则采用baseHandlers
const observed = new Proxy(target, collectionTypes.has(target.constructor) ? collectionHandlers : baseHandlers);
return observed;
}

接下来我们就需要实现Proxy的handlers,对于普通对象和数组,我们需要使用baseHandlers即mutableHandlers,Proxy的handler可以代理很多方法,比如 get
set
deleteProperty
has
ownKeys
,如果将这些方法直接都写在handlers上,那么handlers就会变得非常多代码,所以可以将这些方法分开,如下:

// handlers.js
const get = createGetter();
const set = createSetter();
function createGetter(isReadonly = false, shallow = false) {
return function get(target, key, receiver) {
const res = Reflect.get(target, key, receiver); // 等价于target[key]
console.log(`拦截到了get取值操作`, target, key);
return res;
}
}
function createSetter(shallow = false) {
return function set(target, key, value, receiver) {
const result = Reflect.set(target, key, value, receiver); // 等价于target[key] = value
console.log(`拦截到了set设置值操作`, target, key, value);
return result; // set方法必须返回一个值
}
}
export const mutableHandlers = {
get,
set,
// deleteProperty,
// has,
// ownKeys
}
export const mutableCollectionHandlers = {
}

Proxy的handlers对象中的get和set方法都可以拿到 被代理的对象
target、获取或修改了对象的哪个key,设置了新的值value,以及 被代理后的对象
receiver,目前我们拦截到用户的get操作后 仅仅是从target中取出对应的值并返回回去
,拦截到用户的set操作后 仅仅是修改了target中对应key的值并返回回去

此时会存在一个问题,如果我们执行 state.arr.push(4)
这样的一个操作,会发现 仅仅触发了arr的取值操作
,并 没有收到arr新增了一个值的通知
。因为Proxy代理只是浅层的代理,只代理了一层,所以 我们拿到的arr是一个普通数组
,此时对普通数组进行操作是不会收到通知的。正是由于Proxy是浅层代理,所以避免了一上来就递归,我们需要修改get,在取到的值是对象的时候再去代理这个对象,如:

+ import { isObject } from "./shared";
+ import { reactive } from "./reactive";
function createGetter(isReadonly = false, shallow = false) {
return function get(target, key, receiver) {
const res = Reflect.get(target, key, receiver); // 等价于target[key]
console.log(`拦截到了get取值操作`, target, key);
+ if (isObject(res)) { // 如果取到的值是一个对象,则代理这个值
+    return reactive(res);
+ }
return res;
}
}

此时我们再次执行 state.arr.push(4)
,可以看到输出结果如下:

拦截到了get取值操作 {name: "lihb", age: 18, arr: Array(3)} arr
拦截到了get取值操作 (3) [1, 2, 3] push
拦截到了get取值操作 (3) [1, 2, 3] length
拦截到了set设置值操作 (4) [1, 2, 3, 4] 3 4
拦截到了set设置值操作 (4) [1, 2, 3, 4] length 4

同时也触发了length的修改,其实我们将4 push进入数组后,数组的length会自动修改,也就是说不需要再去设置一遍length的值了,同样的我们执行 state.arr[0] = 1
也会触发set操作, 设置的是同样的值也会触发set操作
,所以我们 需要判断一下设置的新值和旧值是否相同,不同才需要触发set操作

// shared/index.js
+ export const hasOwn = (target, key) => Object.prototype.hasOwnProperty.call(target, key);
+ export const hasChanged = (newValue, oldValue) => newValue !== oldValue;
import { isObject, hasOwn, hasChanged } from "./shared";
function createSetter(shallow = false) {
return function set(target, key, value, receiver) {
const hadKey = hasOwn(target, key);
const oldValue = target[key]; // 修改前获取到旧的值
const result = Reflect.set(target, key, value, receiver); // 等价于target[key] = value
if (!hadKey) {  // 如果当前target对象中没有该key,则表示是新增属性
console.log(`用户新增了一个属性,key is ${key}, value is ${value}`);
} else if (hasChanged(value, oldValue)) { // 判断一下新设置的值和之前的值是否相同,不同则属于更新操作
console.log(`用户修改了一个属性,key is ${key}, value is ${value}`);
}
return result;
}
}

此时再次执行 state.arr.push(4)
就不会触发length的更新了,执行 state.arr[0] = 1
也不会触发索引为0的值更新了。

四、实现effect()方法

经过前面reactive()方法的实现,我们已经能够拿到一个响应式的数据对象了,我们进行get和set操作都能够被拦截。接下来就是实现effect()方法, 当我们修改数据的时候
能够触发传入effect的回调函数执行

effect()方法的回调函数要想在数据发生变化后能够执行, 必须返回一个响应式的effect()函数
,所以effect()内部会返回一个响应式的effect。

所谓响应式的effect,就是 该effect在执行的时候会在取值之前将自己放入到effectStack收到栈顶
同时将自己标记为activeEffect,以便进行依赖收集与reactive进行关联

export function effect(fn, options = {}) {
const effect = createReactiveEffect(fn, options); // 返回一个响应式的effect函数
if (!options.lazy) { // 如果不是计算属性的effect,那么会立即执行该effect
effect();
}
return effect;
}
let uid = 0;
let activeEffect; // 存放当前执行的effect
const effectStack = []; // 如果存在多个effect,则依次放入栈中
function createReactiveEffect(fn, options) {
/**
* 所谓响应式的effect,就是该effect在执行的时候会将自己放入到effectStack收到栈顶,
* 同时将自己标记为activeEffect,以便进行依赖收集与reactive进行关联
*
*/
const effect = function reactiveEffect() {
if (!effectStack.includes(effect)) { // 防止不停的更改属性导致死循环
try {
// 在取值之前将当前effect放到栈顶并标记为activeEffect
effectStack.push(effect); // 将自己放到effectStack的栈顶
activeEffect = effect; // 同时将自己标记为activeEffect
return fn(); // 执行effect的回调就是一个取值的过程
} finally {
effectStack.pop(); // 从effectStack栈顶将自己移除
activeEffect = effectStack[effectStack.length - 1]; // 将effectStack的栈顶元素标记为activeEffect
}
}
}
effect.options = options;
effect.id = uid++;
effect.deps = []; // 依赖了哪些属性,哪些属性变化了需要执行当前effect
return effect;
}

这里的取值操作就是 传入effect(fn)函数的fn的执行
fn中会使用到响应式数据

此时数据发生变化还无法通知effect的回调函数执行,因为reactive和effect还未关联起来,也就是说还没有进行依赖收集,所以接下来需要进行依赖收集。

① 什么时候收集依赖?

我们需要在取值的时候开始收集依赖,所以 需要在取值之前将依赖的effect放到栈顶并标识为activeEffect
,而前面响应式effect执行的时候已经实现,而执行effect回调取值的时候会 在Proxy的handlers的get中进行取值
,所以我们需要在这里进行依赖收集。

+ import { track, trigger } from "./effect";
function createGetter(isReadonly = false, shallow = false) {
return function get(target, key, receiver) {
const res = Reflect.get(target, key, receiver); // 等价于target[key]
console.log(`拦截到了get取值操作`, target, key);
+ track(target, "get", key); // 取值的时候开始收集依赖
if (isObject(res)) {
return reactive(res);
}
return res;
}
}

同样的,需要 在Proxy类的handlers的set中触发依赖的执行

function createSetter(shallow = false) {
return function set(target, key, value, receiver) {
const hadKey = hasOwn(target, key);
const oldValue = target[key]; // 修改前获取到旧的值
const result = Reflect.set(target, key, value, receiver); // 等价于target[key] = value
if (!hadKey) {  // 如果当前target对象中没有该key,则表示是新增属性
console.log(`用户新增了一个属性,key is ${key}, value is ${value}`);
+ trigger(target, "add", key, value); // 新增了一个属性,触发依赖的effect执行
} else if (hasChanged(value, oldValue)) { // 判断一下新设置的值和之前的值是否相同,不同则属于更新操作
console.log(`用户修改了一个属性,key is ${key}, value is ${value}`);
+ trigger(target, "set", key, value); // 修改了属性值,触发依赖的effect执行
}
return result;
}
}

② 如何收集依赖,如何保存依赖?

首先依赖是一个一个的effect函数,我们可以通过Set集合进行存储,而这个Set集合肯定是要和对象的某个key进行对应,即哪些effect依赖了对象中某个key对应的值,这个对应关系可以通过一个Map对象进行保存,即:

// depMap
{
someKey: [effect1, effect2,..., effectn] // 用集合存储依赖的effect,并放入Map对象中与对象的key相对应
}

如果只有一个响应式对象,那么我们直接用一个全局的Map对象根据不同的key进行保存即可,即用上面的Map结构就可以了。

但是我们的 响应式对象是可以创建多个的
,并且 每个响应式对象的key也可能相同
,所以仅仅通过一个Map结构以key的方式保存是无法实现的。

既然响应式对象有多个,那么就可以 以整个响应式对象作为key进行区分
,而 能够用一个对象作为key的数据结构
就是 WeakMap
,所以我们可以用一个全局的WeakMap结构进行存储,如下:

// 全局的WeakMap
{
targetObj1: {
someKey: [effect1, effect2,..., effectn]
},
targetObj2: {
someKey: [effect1, effect2,..., effectn]
}
...
}

当我们取值的时候,首先通过该target对象从全局的WeakMap对象中取出对应的depsMap对象,然后根据修改的key获取到对应的dep依赖集合对象,然后将当前effect放入到dep依赖集合中,完成依赖的收集。

// 用一个全局的WeakMap结构以target作为key保存该target对象下的key对应的依赖
const targetMap = new WeakMap();
/**
* 取值的时候开始收集依赖,即收集effect
*/
export function track(target, type, key) {
if (activeEffect == undefined) { // 收集依赖的时候必须要存在activeEffect
return;
}
let depsMap = targetMap.get(target); // 根据target对象取出当前target对应的depsMap结构
if (!depsMap) { // 第一次收集依赖可能不存在
targetMap.set(target, (depsMap = new Map()));
}
let dep = depsMap.get(key); // 根据key取出对应的用于存储依赖的Set集合
if (!dep) { // 第一次可能不存在
depsMap.set(key, (dep = new Set()));
}
if (!dep.has(activeEffect)) { // 如果依赖集合中不存在activeEffect
dep.add(activeEffect); // 将当前effect放到依赖集合中
// 一个effect可能使用到了多个key,所以会有多个dep依赖集合
activeEffect.deps.push(dep); // 让当前effect也保存一份dep依赖集合
}
}

触发依赖更新,当修改值的时候,也是通过target对象从全局的WeakMap对象中取出对应的depMap对象,然后根据修改的key取出对应的dep依赖集合,并遍历该集合中的所有effect,并执行effect。

每次effect执行,都会重新将当前effect放到栈顶,然后执行effect回调再次取值的时候,再一次执行track收集依赖,不过第二次track的时候,对应的依赖集合中已经存在当前effect了,所以不会再次将当前effect添加进去了。

/**
* 数据发生变化的时候,触发依赖的effect执行
*/
export function trigger(target, type, key, value) {
const depsMap = targetMap.get(target); // 获取当前target对应的Map
if (!depsMap) { // 如果该对象没有收集依赖
console.log("该对象还未收集依赖"); // 比如修改值的时候,没有调用过effect
return;
}
const run = (effects) => {
if (effects) {
effects.forEach(effect => effect());
}
}
/**
*  对于effect中使用到的数据,那肯定是响应式对象中已经存在的key,当数据变化后肯定能通过该key拿到对应的依赖,
* 对于新增的key,我们也不需要通知effect执行。
* 但是对于数组而言,如果给数组新增了一项,我们是需要通知的,如果我们仍然以key的方式去获取依赖那肯定是无法获取到的,
* 因为也是属于新增的一个索引,之前没有对其收集依赖,但是我们使用数组的时候会使用JSON.stringify(arr),此时会取length属性,
* 索引会收集length的依赖,数组新增元素后,其length会发生变化,我们可以通过length属性去获取依赖
*/
if (key !== null) {
run(depsMap.get(key)); // 对象新增一个属性,由于没有依赖故不会执行
}
if (type === "add") {
run(depsMap.get(Array.isArray(target)? "length": ""));
}
}

此时已经完成了effect和active的关联了,当数据发生变化的时候,就会遍历之前收集的依赖,从而重新执行effect,effect的执行必然会导致effect的回调函数执行。

五、实现computed()方法

计算属性本质也是一个effect,也就是说, 计算属性内部会创建一个effect对象
,只不过这个effect不是立即执行,而是 等到取值的时候再执行
,从之前computed的用法中,可以看到, computed()函数返回一个对象
,并且 这个对象中有一个value属性
可以进行get和set操作

import {isFunction} from './shared/index';
import { effect, track, trigger } from './effect';
export function computed(getterOrOptions) {
let getter;
let setter;
if (isFunction(getterOrOptions)) {
getter = getterOrOptions;
setter = () => {};
} else {
getter = getterOrOptions.get;
setter = getterOrOptions.set;
}
let dirty = true; // 默认是脏的数据
let computed;
// 计算属性本质也是一个effect,其回调函数就是计算属性的getter
let runner = effect(getter, {
lazy: true, // 默认是非立即执行,等到取值的时候再执行
computed: true, // 标识这个effect是计算属性的effect
scheduler: () => { // 数据发生变化的时候不是直接执行当前effect,而是执行这个scheduler弄脏数据
if (!dirty) { // 如果数据是干净的
dirty = true; // 弄脏数据
trigger(computed, "set", "value"); // 数据变化后,触发value依赖
}
}
});
let value;
computed = {
get value() {
if (dirty) {
value = runner(); // 等到取值的时候再执行计算属性内部创建的effect
dirty = false; // 取完值后数据就不是脏的了
track(computed, "get", "value"); // 对计算属性对象收集value属性
}
return value;
},
set value(newVal) {
setter(newVal);
}
}
return computed;
}

由于计算属性的effect比较特殊,不是立即执行,所以不能像之前一样,数据发生变化后,都遍历并立即执行effect, 需要将计算属性的effect和普通的effect分开存放
,如果是计算属性的effect,则 执行其scheduler()方法将数据弄脏即可

export function trigger(target, type, key, value) {
const depsMap = targetMap.get(target); // 获取当前target对应的Map
if (!depsMap) { // 如果该对象没有收集依赖
console.log("该对象还未收集依赖"); // 比如修改值的时候,没有调用过effect
return;
}
// const run = (effects) => {
//     if (effects) {
//         effects.forEach(effect => effect());
//     }
// }
const effects = new Set(); // 存放普通的effect
const computedRunners = new Set(); // 存放计算属性的effect
/**
* 之前是直接将依赖集合中所有的effect遍历并立即执行,
* 但是计算属性并不是立即执行,而是去执行其scheduler()函数弄脏数据即可
* 所以需要将普通的effect和计算属性的effect分开保存起来
*/
const add = (effectsToAdd) => {
if (effectsToAdd) {
effectsToAdd.forEach((effect) => {
if (effect.options.computed) { // 如果是计算属性的effect
computedRunners.add(effect);
} else {
effects.add(effect);
}
});
}
}
/**
*  对于effect中使用到的数据,那肯定是响应式对象中已经存在的key,当数据变化后肯定能通过该key拿到对应的依赖,
* 对于新增的key,我们也不需要通知effect执行。
* 但是对于数组而言,如果给数组新增了一项,我们是需要通知的,如果我们仍然以key的方式去获取依赖那肯定是无法获取到的,
* 因为也是属于新增的一个索引,之前没有对其收集依赖,但是我们使用数组的时候会使用JSON.stringify(arr),此时会取length属性,
* 索引会收集length的依赖,数组新增元素后,其length会发生变化,我们可以通过length属性去获取依赖
*/
if (key !== null) {
// run(depsMap.get(key)); // 对象新增一个属性,由于没有依赖故不会执行
add(depsMap.get(key));
}
if (type === "add") {
// run(depsMap.get(Array.isArray(target)? "length": ""));
add(depsMap.get(Array.isArray(target)? "length": ""));
}
// 将run()方法修改为,计算属性的effect执行scheduler,普通effect则直接执行effect
const run = (effect) => {
if (effect.options.scheduler) { // 如果是计算属性的effect则执行其scheduler()方法
effect.options.scheduler();
} else { // 如果是普通的effect则立即执行effect方法
effect();
}
}
// 遍历计算属性的effect
computedRunners.forEach(run);
effects.forEach(run);
}

微信扫一扫,分享到朋友圈

Vue3响应式原理与reactive、effect、computed实现

前端框架的插件机制全梳理(axios、koa、redux、vuex)

上一篇

2020 ,6 种不死的编程语言!

下一篇

你也可能喜欢

Vue3响应式原理与reactive、effect、computed实现

长按储存图像,分享给朋友