【Vue.js 3.0源码】响应式之内部实现原理(上)

自我介绍:大家好,我是吉帅振的网络日志;微信公众号:吉帅振的网络日志;前端开发工程师,工作4年,去过上海、北京,经历创业公司,进过大厂,现在郑州敲代码。

一、前言

除了组件化,Vue.js 另一个核心设计思想就是响应式。它的本质是当数据变化后会自动执行某个函数,映射到组件的实现就是,当数据变化后,会自动触发组件的重新渲染。响应式是 Vue.js 组件化更新渲染的一个核心机制

在 Vue.js 2.x 中,Watcher 就是依赖,有专门针对组件渲染的 render watcher。注意这里有两个流程,首先是依赖收集流程,组件在 render 的时候会访问模板中的数据,触发 getter 把 render watcher 作为依赖收集,并和数据建立联系;然后是派发通知流程,当我对这些数据修改的时候,会触发 setter,通知 render watcher 更新,进而触发了组件的重新渲染。Object.defineProperty API 的一些缺点:不能监听对象属性新增和删除;初始化阶段递归执行 Object.defineProperty 带来的性能负担。

Vue.js 3.0 为了解决 Object.defineProperty 的这些缺陷,使用 Proxy API 重写了响应式部分,并独立维护和发布整个 reactivity 库,下面我们就一起来深入学习 Vue.js 3.0 响应式部分的实现原理。

二、响应式对象的实现差异

在 Vue.js 2.x 中构建组件时,只要我们在 data、props、computed 中定义数据,那么它就是响应式的,举个例子:

<template>

  <div>

    <p>{{ msg }}</p>

    <button @click="random">Random msg</button>

  </div>

</template>

<script>

  export default {

    data() {

      return {

        msg: 'msg reactive'

      }

    },

    methods: {

      random() {

        this.msg = Math.random()

      }

    }

  }

</script>

上述组件初次渲染会显示“msg reactive”,当我们点击按钮的时候,会执行 random 函数,random 函数会修改 this.msg,就会发现组件重新渲染了。

我们对这个例子做一些改动,模板部分不变,我们把 msg 数据的定义放到created 钩子中:

export default {

  created() {

    this.msg = 'msg not reactive'

  }, 

  methods: {

    random() {

      this.msg = Math.random()

    }

  }

}

此时,组件初次渲染显示“msg not reactive”,但是我们再次点击按钮就会发现组件并没有重新渲染。这个问题相信你可能遇到过,其中的根本原因是我们在 created 中定义的 this.msg 并不是响应式对象,所以 Vue.js 内部不会对它做额外的处理。而 data 中定义的数据,Vue.js 内部在组件初始化的过程中会把它变成响应式,这是一个相对黑盒的过程,用户通常不会感知到。

你可能会好奇,为什么我在 created 钩子函数中定义数据而不在 data 中去定义?其实在 data 中定义数据最终也是挂载到组件实例 this 上,这和我直接在 created 钩子函数通过 this.xxx 定义的数据唯一区别就是,在 data 中定义的数据是响应式的。在一些场景下,如果我们仅仅想在组件上下文中共享某个变量,而不必去监测它的这个数据变化,这时就特别适合在 created 钩子函数中去定义这个变量,因为创建响应式的过程是有性能代价的,这相当于一种 Vue.js 应用的性能优化小技巧,你掌握了这一点就可以在合适的场景中应用了。

到了 Vue.js 3.0 构建组件时,你可以不依赖于 Options API,而使用 Composition API 去编写。对于刚才的例子,我们可以用 Composition API 这样改写:

<template>

  <div>

    <p>{{ state.msg }}</p>

    <button @click="random">Random msg</button>

  </div>

</template>

<script>

  import { reactive } from 'vue'

  export default {

    setup() {

      const state = reactive({

        msg: 'msg reactive'

      })



      const random = function() {

        state.msg = Math.random()

      }



      return {

        random,

        state

      }

    }

  }

</script>

可以看到,我们通过 setup 函数实现和前面示例同样的功能。请注意,这里我们引入了 reactive API,它可以把一个对象数据变成响应式。  可以看出来 Composition API 更推荐用户主动定义响应式对象,而非内部的黑盒处理。这样用户可以更加明确哪些数据是响应式的,如果你不想让数据变成响应式,就定义成它的原始数据类型即可。也就是在 Vue.js 3.0 中,我们用 reactive 这个有魔力的函数,把数据变成了响应式,那么它内部到底是怎么实现的呢?我们接下来一探究竟。

三、Reactive API

我们先来看一下 reactive 函数的具体实现过程:

function reactive (target) {

   // 如果尝试把一个 readonly proxy 变成响应式,直接返回这个 readonly proxy

  if (target && target.__v_isReadonly) {

     return target

  } 

  return createReactiveObject(target, false, mutableHandlers, mutableCollectionHandlers)

}

function createReactiveObject(target, isReadonly, baseHandlers, collectionHandlers) {

  if (!isObject(target)) {

    // 目标必须是对象或数组类型

    if ((process.env.NODE_ENV !== 'production')) {

      console.warn(`value cannot be made reactive: ${String(target)}`)

    }

    return target

  }

  if (target.__v_raw && !(isReadonly && target.__v_isReactive)) {

    // target 已经是 Proxy 对象,直接返回

    // 有个例外,如果是 readonly 作用于一个响应式对象,则继续

    return target

  }

  if (hasOwn(target, isReadonly ? "__v_readonly" /* readonly */ : "__v_reactive" /* reactive */)) {

    // target 已经有对应的 Proxy 了

    return isReadonly ? target.__v_readonly : target.__v_reactive

  }

  // 只有在白名单里的数据类型才能变成响应式

  if (!canObserve(target)) {

    return target

  }

  // 利用 Proxy 创建响应式

  const observed = new Proxy(target, collectionTypes.has(target.constructor) ? collectionHandlers : baseHandlers)

  // 给原始数据打个标识,说明它已经变成响应式,并且有对应的 Proxy 了

  def(target, isReadonly ? "__v_readonly" /* readonly */ : "__v_reactive" /* reactive */, observed)

  return observed

}

可以看到,reactive 内部通过 createReactiveObject 函数把 target 变成了一个响应式对象。在这个过程中,createReactiveObject 函数主要做了以下几件事情。

1.函数首先判断 target 是不是数组或者对象类型,如果不是则直接返回。所以原始数据 target 必须是对象或者数组

2.如果对一个已经是响应式的对象再次执行 reactive,还应该返回这个响应式对象,举个例子:

import { reactive } from 'vue'

const original = { foo: 1 }

const observed = reactive(original)

const observed2 = reactive(observed)

observed === observed2

可以看到 observed 已经是响应式结果了,如果对它再去执行 reactive,返回的值 observed2 和 observed 还是同一个对象引用。这里 reactive 函数会通过 target.__v_raw 属性来判断 target 是否已经是一个响应式对象(因为响应式对象的 __v_raw 属性会指向它自身,后面会提到),如果是的话则直接返回响应式对象。

3.如果对同一个原始数据多次执行 reactive ,那么会返回相同的响应式对象,举个例子:

import { reactive } from 'vue'

const original = { foo: 1 }

const observed = reactive(original)

const observed2 = reactive(original)

observed === observed2

可以看到,原始数据 original 被反复执行 reactive,但是响应式结果 observed 和 observed2 是同一个对象。所以这里 reactive 函数会通过 target.__v_reactive 判断 target 是否已经有对应的响应式对象(因为创建完响应式对象后,会给原始对象打上 __v_reactive 标识,后面会提到),如果有则返回这个响应式对象。

4.使用 canObserve 函数对 target 对象做一进步限制:

const canObserve = (value) => {

  return (!value.__v_skip &&

   isObservableType(toRawType(value)) &&

   !Object.isFrozen(value))

}

const isObservableType = /*#__PURE__*/ makeMap('Object,Array,Map,Set,WeakMap,WeakSet')

比如,带有 __v_skip 属性的对象、被冻结的对象,以及不在白名单内的对象如 Date 类型的对象实例是不能变成响应式的。

5.通过 Proxy API 劫持 target 对象,把它变成响应式。我们把 Proxy 函数返回的结果称作响应式对象,这里 Proxy 对应的处理器对象会根据数据类型的不同而不同,我们稍后会重点分析基本数据类型的 Proxy 处理器对象,reactive 函数传入的 baseHandlers 值是 mutableHandlers。

6.给原始数据打个标识,如下:

target.__v_reactive = observed

这就是前面“对同一个原始数据多次执行 reactive ,那么会返回相同的响应式对象”逻辑的判断依据。仔细想想看,响应式的实现方式无非就是劫持数据,Vue.js 3.0 的 reactive API 就是通过 Proxy 劫持数据,而且由于 Proxy 劫持的是整个对象,所以我们可以检测到任何对对象的修改,弥补了 Object.defineProperty API 的不足。接下来,我们继续看 Proxy 处理器对象 mutableHandlers 的实现:

const mutableHandlers = {

  get,

  set,

  deleteProperty,

  has,

  ownKeys

}

它其实就是劫持了我们对 observed 对象的一些操作,比如:

  • 访问对象属性会触发 get 函数;
  • 设置对象属性会触发 set 函数;
  • 删除对象属性会触发 deleteProperty 函数;
  • in 操作符会触发 has 函数;
  • 通过 Object.getOwnPropertyNames 访问对象属性名会触发 ownKeys 函数。

因为无论命中哪个处理器函数,它都会做依赖收集和派发通知这两件事其中的一个,所以这里我只要分析常用的 get 和 set 函数就可以了。

四、依赖收集:get 函数

依赖收集发生在数据访问的阶段,由于我们用 Proxy API 劫持了数据对象,所以当这个响应式对象属性被访问的时候就会执行 get 函数,我们来看一下 get 函数的实现,其实它是执行 createGetter 函数的返回值,为了分析主要流程,这里省略了 get 函数中的一些分支逻辑,isReadonly 也默认为 false:

function createGetter(isReadonly = false) {

  return function get(target, key, receiver) {

    if (key === "__v_isReactive" /* isReactive */) {

      // 代理 observed.__v_isReactive

      return !isReadonly

    }

    else if (key === "__v_isReadonly" /* isReadonly */) {

      // 代理 observed.__v_isReadonly

      return isReadonly;

    }

    else if (key === "__v_raw" /* raw */) {

      // 代理 observed.__v_raw

      return target

    }

    const targetIsArray = isArray(target)

    // arrayInstrumentations 包含对数组一些方法修改的函数

    if (targetIsArray && hasOwn(arrayInstrumentations, key)) {

      return Reflect.get(arrayInstrumentations, key, receiver)

    }

    // 求值

    const res = Reflect.get(target, key, receiver)

    // 内置 Symbol key 不需要依赖收集

    if (isSymbol(key) && builtInSymbols.has(key) || key === '__proto__') {

      return res

    }

    // 依赖收集

    !isReadonly && track(target, "get" /* GET */, key)

    return isObject(res)

      ? isReadonly

        ?

        readonly(res)

        // 如果 res 是个对象或者数组类型,则递归执行 reactive 函数把 res 变成响应式

        : reactive(res)

      : res

  }

}

结合上述代码来看,get 函数主要做了四件事情,首先对特殊的 key 做了代理,这就是为什么我们在 createReactiveObject 函数中判断响应式对象是否存在 __v_raw 属性,如果存在就返回这个响应式对象本身。
接着通过 Reflect.get 方法求值,如果 target 是数组且 key 命中了 arrayInstrumentations,则执行对应的函数,我们可以大概看一下 arrayInstrumentations 的实现:

const arrayInstrumentations = {}

['includes', 'indexOf', 'lastIndexOf'].forEach(key => {

  arrayInstrumentations[key] = function (...args) {

    // toRaw 可以把响应式对象转成原始数据

    const arr = toRaw(this)

    for (let i = 0, l = this.length; i < l; i++) {

      // 依赖收集

      track(arr, "get" /* GET */, i + '')

    }

    // 先尝试用参数本身,可能是响应式数据

    const res = arr[key](...args)

    if (res === -1 || res === false) {

      // 如果失败,再尝试把参数转成原始数据

      return arr[key](...args.map(toRaw))

    }

    else {

      return res

    }

  }

})

也就是说,当 target 是一个数组的时候,我们去访问 target.includes、target.indexOf 或者 target.lastIndexOf 就会执行 arrayInstrumentations 代理的函数,除了调用数组本身的方法求值外,还对数组每个元素做了依赖收集。因为一旦数组的元素被修改,数组的这几个 API 的返回结果都可能发生变化,所以我们需要跟踪数组每个元素的变化。

回到 get 函数,第三步就是通过 Reflect.get 求值,然后会执行 track 函数收集依赖,我们稍后重点分析这个过程。函数最后会对计算的值 res 进行判断,如果它也是数组或对象,则递归执行 reactive 把 res 变成响应式对象。这么做是因为 Proxy 劫持的是对象本身,并不能劫持子对象的变化,这点和 Object.defineProperty API 一致。但是 Object.defineProperty 是在初始化阶段,即定义劫持对象的时候就已经递归执行了,而 Proxy 是在对象属性被访问的时候才递归执行下一步 reactive,这其实是一种延时定义子对象响应式的实现,在性能上会有较大的提升。

整个 get 函数最核心的部分其实是执行 track 函数收集依赖,下面我们重点分析这个过程。

// 是否应该收集依赖

let shouldTrack = true

// 当前激活的 effect

let activeEffect

// 原始数据对象 map

const targetMap = new WeakMap()

function track(target, type, key) {

  if (!shouldTrack || activeEffect === undefined) {

    return

  }

  let depsMap = targetMap.get(target)

  if (!depsMap) {

    // 每个 target 对应一个 depsMap

    targetMap.set(target, (depsMap = new Map()))

  }

  let dep = depsMap.get(key)

  if (!dep) {

    // 每个 key 对应一个 dep 集合

    depsMap.set(key, (dep = new Set()))

  }

  if (!dep.has(activeEffect)) {

    // 收集当前激活的 effect 作为依赖

    dep.add(activeEffect)

   // 当前激活的 effect 收集 dep 集合作为依赖

    activeEffect.deps.push(dep)

  }

}

分析这个函数的实现前,我们先想一下要收集的依赖是什么,我们的目的是实现响应式,就是当数据变化的时候可以自动做一些事情,比如执行某些函数,所以我们收集的依赖就是数据变化后执行的副作用函数。再来看实现,我们把 target 作为原始的数据,key 作为访问的属性。我们创建了全局的 targetMap 作为原始数据对象的 Map,它的键是 target,值是 depsMap,作为依赖的 Map;这个 depsMap 的键是 target 的 key,值是 dep 集合,dep 集合中存储的是依赖的副作用函数。所以每次 track ,就是把当前激活的副作用函数 activeEffect 作为依赖,然后收集到 target 相关的 depsMap 对应 key 下的依赖集合 dep 中。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值