美文网首页我的大学
聊一聊 Vue3 中响应式原理

聊一聊 Vue3 中响应式原理

作者: Cryptic | 来源:发表于2020-10-20 16:59 被阅读0次

    引言

    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,欢迎各位大佬批评斧正~

    相关文章

      网友评论

        本文标题:聊一聊 Vue3 中响应式原理

        本文链接:https://www.haomeiwen.com/subject/sgbdmktx.html