阅读 926

聊一聊 Vue3 中响应式原理

引言

Vue.js 3.0 "One Piece" 正式发布已经有一段时间了,真可谓是千呼万唤始出来啊!

相比于 Vue2.xVue3.0 在新的版本中提供了更好的性能、更小的捆绑包体积、更好的 TypeScript 集成、用于处理大规模用例的新 API

在发布之前,尤大大就已经声明了响应式方面将采用 Proxy 对于之前的 Object.defineProperty 进行改写。其主要目的就是弥补 Object.defineProperty 自身的一些缺陷,例如无法检测到对象属性的新增或者删除,不能监听数组的变化等。

Vue3 采用了新的 Proxy 实现数据读取和设置拦截,不仅弥补了之前 Vue2Object.defineProperty 的缺陷,同时也带来了性能上的提升。

今天,我们就来盘一盘它,看看 Vue3 中响应式是如何实现的。

Proxy ?

The Proxy object enables you to create a proxy for another object, which can intercept and redefine fundamental operations for that object.MDN

Proxy - 代理,顾名思义,就是在要访问的对象之前增加一个中间层,这样就不直接访问对象,而是通过中间层做一个中转,通过操作代理对象,来实现修改目标对象。

关于 Proxy 的更多的知识,可以参考我之前的一篇文章 —— 初探 Vue3.0 中的一大亮点——Proxy !,这里我就不在赘述。

reactive 和 effect 方法

Vue3 中响应式核心方法就是 reactiveeffect , 其中 reactive 方法是负责将数据变成响应式,effect 方法的作用是根据数据变化去更新视图或调用函数,与 react 中的 useEffect 有点类似~

其大概用法如下:

let { reactive, effect } = Vue;
let data = reactive({ name: 'Hello' });

effect(() => {
    console.log(data.name)
})

data.name = 'World';
复制代码

默认会执行一次,打印 Hello , 之后更改了 data.name 的值后,会在触发执行一次,打印World

我们先看看 reactive 方法的实现~

reactive.js

首先应该明确,我们应该导出一个 reactive 方法,该方法有一个参数 target,目的就是将 target 变成响应式对象,因此返回值就是一个响应式对象。

import {isObject} from "../shared/utils";
// Vue3 响应式原理
// 响应式方法,将 target 对象变成响应式对象
export function reactive (target) {
    // 创建响应式对象
    return createReactiveObject(target);
}

// 创建响应式对象
function createReactiveObject (target) {
    // 不是对象,直接返回
    if ( !isObject(target) ) return target;
    // 创建 Proxy 代理
    const observed = new Proxy(target,{})
    return observed;
}

复制代码

reactive 方法基本结构就是如此,给定一个对象,返回一个响应式对象。

其中 isObject 方法用于判断是否是对象,不是对象不需要代理,直接返回即可。

reactive 方法的重点是 Proxy 的第二个参数handler,它承载监控对象变化,依赖收集,视图更新等各项重大责任,我们重点来研究这个对象。

handler.js

Vue3Proxyhandler 主要设置了 getsetdeletePropertyhasownKeys 这些属性,即拦截了对象的读取,设置,删除,in 以及 Object.getOwnPropertyNames 方法和 Object.getOwnPropertySymbols 方法。

这里我们偷个懒,暂时就考虑 setget 操作。

handler.get()

get 获取属性比较简单,我们先来看看这个,这里我们用一个方法创建 getHanlder

// 创建 get
function createGetter () {
  return function get (target, key, receiver) {
      // proxy + reflect
      const res = Reflect.get(target, key, receiver);  // target[key];

      // 如果是对象,递归代理
      if ( isObject(res) ) return reactive(res);

      console.log('获取属性的值:', 'target:', target, 'key:', key)

      return res;
  }
}
复制代码

这里推荐使用了 Reflect.get 而并非 target[key]

可以发现,Vue3 是在取值的时候才去递归遍历属性的,而非 Vue2 中一开始就递归 data 给每个属性添加 Watcher,这也是 Vue3 性能提升之一。

handler.set()

同理 set 操作,我们也是用一个方法创建 setHandler

// 创建 set
function createSetter () {
    return function set (target, key, value, receiver) {
        // 设置属性值
        const res = Reflect.set(target, key, value, receiver);	
        return res;
    }
}
复制代码

Reflect.set 会返回一个 Boolean 值,用于判断属性是否设置成功。

完事后将 handler 导出,然后在 reactive 中引入即可。

const get = createGetter();
const set = createSetter();

// 拦截普通对象和数组
export const mutableHandler = {
    get,
    set
}
复制代码

测试几组对象貌似没啥问题,其实是有一个坑,这个坑也跟数组有关。

  let { reactive } = Vue;
  // 代理数组
  let arr = [1,2,3]
  let proxy = reactive(arr)
  // 添加元素
  proxy.push(4)
复制代码

如上例子,如果我们选择代理数组,在 setHandler 中打印其 keyvalue 的话会得到 3 4length 4 这两组值:

  • 第一组表示给数组索引为 3 的位置新增一个 4 的值
  • 第二组表示将数组的 length 改为 4

如果不作处理,那么会导致如果更新视图的话,则会触发两次,这肯定是不允许的,因此,我们需要将区分新增和修改这两种操作。

Vue3 中是通过判断 target 是否存在该属性来区分是新增还是修改操作,需要借助一个工具方法 —— hasOwnProperty

// 判断自身是否包含某个属性
function hasOwnProperty (target,key) {
    return Object.prototype.hasOwnProperty.call(target,key);
}
复制代码

这里我们将上述的 createSetter 方法修改如下:

function createSetter () {
  return function set (target, key, value, receiver) {
      // 需要判断修改属性还是新增属性,如果原始值于新设置的值一样,则不作处理
      const hasKey = hasOwnProperty(target, key);
      // 获取原始值
      const oldVal = target[key];
      const res = Reflect.set(target, key, value, receiver);	// target[key]=value;
		
      if ( !hasKey ) { 
          // 新增属性
          console.log('新增了属性:', 'key:', key, 'value:', value);
      } else if ( hasChanged(value, oldVal) ) { 
          // 原始值于新设置的值不一样,修改属性值
          console.log('修改了属性:', 'key:', key, 'value:', value)
      }

      // 值未发生变化,不作处理
      return res;
  }
}
复制代码

如此一来,我们调 push 方法的时候,就只会触发一次更新了,非常巧妙的避免了无意义的更新操作。

effect.js

光上述构造响应式对象并不能完成响应式的操作,我们还需要一个非常重要的方法 effect,它会在初始化执行的时候存储跟其有关的数据依赖,当依赖数据发生变化的时候,则会再次触发 effect 传递的函数。

其基本雏形如下,入参是一个函数,还有个可选参数 options 方便后面计算属性等使用,暂时不考虑:

// 响应式副作用方法
export function effect (fn,options = {}) {
	// 创建响应式 effect
    const reactiveEffect = createReactiveEffect(fn, options);
    
    // 默认执行一次
    reactiveEffect()
}
复制代码

createReactiveEffect 就是为了将 fn 变成响应式函数,监控数据变化,执行 fn 函数,因此该函数是一个高阶函数。

let activeEffect;	// 当前 effect
const effectStack = [];	// effect 栈

// 创建响应式 effect
function createReactiveEffect (fn, options) {
    // 创建的响应式函数
    const reactiveEffect = function () {
        // 防止不停更改属性导致死循环
        if ( !effectStack.includes(reactiveEffect) ) {
            try {
                effectStack.push(reactiveEffect);
                // 将当前 effect 存储到 activeEffect
                activeEffect = reactiveEffect;		
                // 运行 fn 函数
                return fn();
            } finally {
                // 执行完清空
                effectStack.pop();
                activeEffect = effectStack[effectStack.length - 1];
            }
        }
    }
    return reactiveEffect;
}
复制代码

createReactiveEffect 将原来的 fn 转变成一个 reactvieEffect , 并将当前的 effect 挂到全局的 activeEffect 上,目的是为了一会与当前所依赖的属性做好对应关系。

我们必须要将依赖属性构造成 { prop : [effect,effect] } 这种结构,才能保证依赖属性变化的时候,依次去触发与之相关的 effect,因此,需要在 get 属性的时候,做属性的依赖收集,将属性与 effect 关联起来。

依赖收集 —— track

在获取对象的属性时,会触发 getHandler ,再次做属性的依赖收集,即 Vue2 中的发布订阅。

setHandler 中获取属性的时候,做一次 track(target, key) 操作。

整个 track 的数据结构大概是这样

/** 
* 最外层是 WeakMap,其 key 是 target 对象,值是一个 map
* map 中包含 target 的属性,key 为每一个属性 , 值为属性对应的 `effect` 
*/
     key  		       val(map)
{name : 'chris}     {  name : Set(effect,effect) , age : Set() }

复制代码

目的就是将 targetkeyeffect 之间做好对应的关系映射。

const targetMap = new WeakMap();
// 依赖收集
export function tract(target,key){
    // activeEffect 为空
    if ( activeEffect === undefined ) {
        return; // 说明取值的属性,不依赖于 effect
    }

    // 判断 target 对象是否收集过依赖
    let depsMap = targetMap.get(target);
    // 不存在构建
    if ( !depsMap ) {
        targetMap.set(target, (depsMap = new Map()));
    }

    // 判断要收集的 key 中是否收集过 effect
    let dep = depsMap.get(key);
    // 不存在则创建
    if ( !dep ) {
        depsMap.set(key, (dep = new Set()));
    }

    // 如果未收集过当前依赖则添加
    if ( !dep.has(activeEffect) ) {
        dep.add(activeEffect);
    }
}
复制代码

打印 targetMap 的结构如下:

targetMap

**触发更新 —— trigger **

上述已经完成了依赖收集,剩下就是监控数据变化,触发更新操作,即在 setHandler 中添加 trigger 触发操作。

// 触发更新
export function trigger (target, type, key) {
    // 获取 target 的依赖
    const depsMap = targetMap.get(target);
    // 没有依赖收集,直接返回
    if ( !depsMap ) return;

    // 获取 effects
    const effects = new Set();

    // 添加 key 对应的 effect
    const add = (effectsToAdd) => {
        if ( effectsToAdd ) {
            effectsToAdd.forEach(effect => {
                effects.add(effect)
            })
        }
    }

    // 执行单个 effect
    const run = (effect) => {
        effect && effect()
    }

    // 获取 key 对应的 effect
    if ( key !== null ) {
        add(depsMap.get(key));
    }

    if ( type === 'add' ) { // 对数组新增会触发 length 对应的依赖
        let effects = depsMap.get(Array.isArray(target) ? 'length' : '');
        add(effects);
    }

    // 触发更新
    effects.forEach(run);
}
复制代码

这样一来,获取数据的时候通过 track 进行依赖收集,更新数据的时候再通过 trigger 进行更新,就完成了整个数据的响应式操作。

再回头看看我们先前提到的例子:

let { effect, reactive } = Vue;

let data = reactive({ name: 'Hello' })
effect(() => {
    console.log(data.name, '  ***** effect *****  ');
})

data.name = 'World'
复制代码

控制台会依次打印 Hello ***** effect ***** 以及 World ***** effect ***** , 分别是首次渲染触发跟更新数据重渲染触发,至此功能实现!

总结

整体来说,Vue3 相比于 Vue2 在很多方面都做了调整,数据的响应式只是冰山一角,但是可以看出尤大团队非常巧妙的利用了 Proxy 的特点以及 es6 的数据结构和方法。另外,Composition API 的模式跟 React 在某些程度上有异曲同工之妙,这种设计模式让我们在实际开发使用中更加的方法快捷,值得我们去学习,加油!

最后附上仓库地址 github,欢迎各位大佬批评斧正~