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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,294评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,493评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,790评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,595评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,718评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,906评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,053评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,797评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,250评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,570评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,711评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,388评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,018评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,796评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,023评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,461评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,595评论 2 350