likes
comments
collection
share

Vue3源码阅读——响应式是如何实现的(reavtive篇)

作者站长头像
站长
· 阅读数 12

前言

本文属于笔者Vue3源码阅读系列第三篇文章,往期精彩:

  1. 生成vnode到渲染vnode的过程是怎样的
  2. 组件创建及其初始化过程

响应式源码预计产出两篇文章,本文主要对应reactive部分。主要内容:创建响应式代理对象以及代理对象handler的实现

本文字数12000+,阅读完预计10分钟。

什么是响应式

在探索源码之前,咱们先来聊一下响应式的概念。在Vue官方文档中写道:

响应性是一种可以使我们声明式地处理变化的编程范式。

定义不好理解?那官网还举了一个很经典的例子:

Vue3源码阅读——响应式是如何实现的(reavtive篇)

A2的值通过公式来设置的,不论A0、A1任意一个变化,A2都会自动的重新计算。这就是响应式——自动更新,状态自动保持同步

先回顾一下Vue2的响应式实现

Vue3源码阅读——响应式是如何实现的(reavtive篇)

上图来自官方文档,从图中我们能够看到:初始化时对状态数据做了劫持,在执行组件的render函数时,会访问一些状态数据,就会触发这些状态数据的getter,然后render函数对应的render watcher就会被这个状态收集为依赖,当状态变更触发settersetter中通知render watcher更新,然后render函数重新执行以更新组件。 就这样完成了响应式的过程。

Vue2中通过Object.defineProperty给每个属性设置gettersetter,他的特点如下:

  1. Object.defineProperty是通过给对象新增属性/修改现有属性 来实现数据的劫持。需要遍历对象的每一个key去实现,当遇到很大的对象或者嵌套层级很深的对象,性能问题会很明显。
  2. Object.defineProperty这种方式无法拦截到给对象新增属性这种操作,因为组件初始化不能预知会新增哪些属性,也就没法设置getter/setter,所以我们不得不使用Vue2提供的$setapi,再去Object.defineProperty给新增的属性加上getter/setter
  3. Object.defineProperty支持IE,兼容性较好。

正是因为第三点,因此Vue2才使用Object.defineProperty去实现数据的劫持,即便它有很多缺点。

深入Vue3 Reactivity

Vue3使用Proxy来实现数据的劫持,接下来我们进入源码(packages/reactivity/src/reactive.ts):

在看响应式的实现之前,先来了解一下源码中的枚举变量

export const enum ReactiveFlags {
  SKIP = '__v_skip', // 跳过响应式处理
  IS_REACTIVE = '__v_isReactive', // 是响应式的
  IS_READONLY = '__v_isReadonly', // 是只读的
  IS_SHALLOW = '__v_isShallow', // 是浅响应式的
  RAW = '__v_raw' // 用来存代理的原始对象
}
// ...
const enum TargetType {
  INVALID = 0, // 不能代理的类型
  COMMON = 1, // Object、Array
  COLLECTION = 2 // 集合类型 Map、Set、WeakMap、WeakSet
}

// target 和 type 的映射
function targetTypeMap(rawType: string) {
  switch (rawType) {
    case 'Object':
    case 'Array':
      return TargetType.COMMON
    case 'Map':
    case 'Set':
    case 'WeakMap':
    case 'WeakSet':
      return TargetType.COLLECTION
    default:
      return TargetType.INVALID
  }
}

// 根据target 返回 targetType
function getTargetType(value: Target) {
  return value[ReactiveFlags.SKIP] || !Object.isExtensible(value)
    ? TargetType.INVALID
    : targetTypeMap(toRawType(value))
}

ReactiveFlags是响应式的标识,在对一个target调用对应API后,就会在这个target对应的代理对象上打对应的标识,有意思的是RAW——它是用来存原始对象的引用的,然后可通过toRaw(proxy)来获取它。上面的代码很好理解,可以先看下,然后去看后面的代码更轻松。

reactive

接着看reactive的源码:

Vue3源码阅读——响应式是如何实现的(reavtive篇)

reactive的逻辑:先判断传入的对象是不是只读的,是就直接返回,否则调用createReactiveObject

createReactiveObject

Vue3源码阅读——响应式是如何实现的(reavtive篇)

createReactiveObject中主要是一些校验,最关键的就是最后的new Proxy(...),来看下具体做了哪些校验:

  1. isObject校验,val !== null && typeof val === 'object'
  2. 已经是proxy了,不能再响应式处理,但是有一种情况例外。
    const obj = reactive({})
    const obj2 = reactive(obj) // 会直接返回obj
    obj === obj2 // true
    toReadonly(obj) // 这样是OK的
    
  3. 对一个原始对象多次响应式处理。
    const obj = {}
    const objProxy = reactive(obj)
    const objProxy2 = reactive(obj) // 直接返回objProxy
    objProxy === objProxy2 // true
    
  4. 不能被响应式处理的情况。

接下来我们看mutableHandlers的实现。

BaseHandlers

mutableHandlers的实现在 packages/reactivity/src/baseHandlers.ts 中,在 basehandlers 中包含了四种 handler

  1. mutableHandlers 可变处理。
  2. readonlyHandlers 只读处理。
  3. shallowReactiveHandlers 浅观察处理(只观察目标对象的第一层属性)。
  4. shallowReadonlyHandlers 浅观察 && 只读处理。

其中 readonlyHandlers shallowReactiveHandlers shallowReadonlyHandlers 都是 mutableHandlers 的变形版本,这里笔者将以 mutableHandlers 这个可变的来展开描述。

mutableHandlers

mutableHandlers的定义:

Vue3源码阅读——响应式是如何实现的(reavtive篇)

咱们先看比较简单的几个:

Vue3源码阅读——响应式是如何实现的(reavtive篇)

deleteProperty

用于拦截从target删除某个属性的操作(delete target.xxx),会先检查target中是否包含这个key,然后获取这个属性的值,接着调用Reflect.deleteProperty删除key,如果删除成功的话,调用trigger触发更新。暂时先记下trigger的作用是为了触发更新,后续咱们再来分析它。

has

用于拦截判断某个key是否存在于target的操作(xxx in target),先调用Reflect.has拿到结果,然后判断如果这个key不是Symbol类型,则调用track收集依赖;如果是Symbol类型,那就判断这个key在不在builtInSymbols中,不在也调用track收集依赖。暂时先记下track的作用是为了收集依赖,后续咱们再来分析它。

builtInSymbols

Vue3源码阅读——响应式是如何实现的(reavtive篇)

ownKeys

用于拦截遍历的操作(for in、for of...),先调用track收集依赖,然后调用Reflect.ownKeys返回结果。

get

const get = /*#__PURE__*/ createGetter()

createGetter接受两个参数,可创建readonlyget方法,还能创建shallowget方法:

Vue3源码阅读——响应式是如何实现的(reavtive篇)

createGetter的逻辑一个屏截不完,咱们先看上图的逻辑:在get中处理了这四个标识应该返回什么值,当调用proxy[ReactiveFlags.IS_xxx]就会执行到这个地方对应的逻辑,咱们接着看后面的逻辑:

Vue3源码阅读——响应式是如何实现的(reavtive篇)

上图中逻辑:判断target是不是Array,如果是的话,判断key是不是['push', 'pop', 'shift', 'unshift', 'splice','includes', 'indexOf', 'lastIndexOf']中的一个,如果是的话就返回arrayInstrumentations中对应重写过后的方法。

Vue3源码阅读——响应式是如何实现的(reavtive篇)

从上图中可以看到,分别对'includes', 'indexOf', 'lastIndexOf''push', 'pop', 'shift', 'unshift', 'splice'两类的API进行了重写。那么问题就来了:为什么要重写?

为什么要重写includes, indexOf, lastIndexOf

咱们先来看,重写干了些什么:

  1. 调用toRaw得到原始数组。
  2. 然后遍历原始数组,track每一项,key就是索引。
  3. 调用数组的includes/indexOf/lastIndexOf得到结果res1
  4. 如果结果为-1或者false,则将参数也调用toRaw得到原始对象后再次调用数组的includes/indexOf/lastIndexOf并返回结果res2;否则直接返回res1

这里可以看到主要做了两件事:遍历原始数组调用track & 如果直接用参数找不到,就调用toRaw得到原始对象后再找一次,那我们还是分开来看这么做的原因。

为什么(遍历原始数组调用track

看个例子:

<script type="module">
  import {
    h,
    ref,
    createApp,
    reactive,
    effect
  } from '../../dist/vue.runtime.esm-bundler.js'

  const App = {
    name: 'App',
    setup() {
      const arr = ['a', 'b', 'c', 'd']
      const proxy = reactive(arr)
      return {
        proxy
      }
    },
    render() {
      return h('div', { tId: 1 }, [
        h('p', {}, this.proxy.indexOf('d', 2)),
        h(
          'button',
          {
            onClick: () => {
              this.proxy[0] = 'd'
            }
          },
          'click'
        )
      ])
    }
  }

  createApp(App).mount(document.querySelector('#root'))
</script>

在上面的例子中,页面一开始会显示文本3和一个click按钮,当点击click按钮,我们期望的是文本更新为0,因为我把数组的第0项改为d了,但是在没有重写indexOf的逻辑下,页面并不会更新。我们来找下原因,当执行renderthis.proxy.indexOf('d', 2)的时候,会执行到get(arr, length) -> get(arr, 2) -> get(arr, 3),就找到d的索引为3了,在这个过程中,就只对arr[2]arr[3]进行了track,所以也只有修改arr[2]arr[3]才会触发组件更新,因此我们修改arr[0]并不会触发更新。说起原因还是由于这三个APIincludes/indexOf/lastIndexOf的第二个参数,可以指定起始索引,所以就会漏掉一些数组的项没有track,所以重写的时候才需要遍历track

为什么(如果直接用参数找不到,就调用toRaw得到原始对象后再找一次)

为了帮助理解再看一个例子:

<div id="root"></div>

<script type="module">
  import {
    h,
    ref,
    createApp,
    reactive,
    effect
  } from '../../dist/vue.runtime.esm-bundler.js'

  const App = {
    name: 'App',
    setup() {
      const obj = {
        text: '主页'
      }
      const proxy = reactive([obj])
      return {
        proxy,
        obj
      }
    },
    render() {
      return h('div', { tId: 1 }, [
        h('p', {}, this.proxy.indexOf(this.obj)),
        h('p', {}, this.proxy.indexOf(this.proxy[0]))
      ])
    }
  }

  createApp(App).mount(document.querySelector('#root'))
</script>

笔者在本地将重写的逻辑注释以后,上面的内容渲染结果如下:

Vue3源码阅读——响应式是如何实现的(reavtive篇)

为啥会这样?当调用this.proxy.indexOf(this.obj),会get(arr, 0),因为arr[0]是一个Object,那么调用reactive(arr[0]),最终returnarr[0]代理对象代理对象肯定!==arr[0] 啊,因此返回-1;当调用this.proxy.indexOf(this.proxy[0]),会先this.proxy[0],即get(arr, 0),跟之前的一样returnarr[0]代理对象,当执行indexOf时,又会get(arr, 0),还是走到reactive(arr[0]),只不过这个对象的代理已经创建过一次了,存在一个weakMap中,就直接returnweakMap中对应的代理对象,所以二者相等,返回0

这也就是为什么要把参数调用toRaw得到原始对象后再找一次,就是为了防止原始对象和其代理对象比较这种情况。

为什么要重写push, pop, shift, unshift, splice 同样的,先来看重写干了啥:

  1. 暂停track
  2. 调用数组API得到结果。
  3. 恢复track
  4. 返回结果。

为了理解到重写的背景,我们先看个例子:

Vue3源码阅读——响应式是如何实现的(reavtive篇)

我们能够看到,调用了一次push,会触发一次lengthget,一次lengthset,而且刚好被重写的这些API都是会改变数组length的API。这样的话,假如我们没有对这些API重写,在effect中使用这些API会怎么样:

const arr = ['a', 'b', 'c', 'd']
const obj2 = reactive(arr)
effect(() => {
  obj2.push('r')
})
effect(() => {
  obj2.push('i')
})
/**
  第1个effect:
  收集key='length',触发track(target, ..., 'length')操作
  相当于proxy[4]=r,触发key='4' 以及 key='length' 的 trigger 操作

  第2个effect:
  收集key='length',触发track(target, 'length')操作
  相当于proxy[5]=i,触发key='5'以及key='length'的trigger操作

  由于第1个effect收集了key='length',因此会触发第1个effect重新执行,再次收集key='length'和触发key='6'以及key='length'的trigger操作;
  由于第2个effect收集了key='length',因此会触发第2个effect重新执行,再次收集key='length'和触发key='7'以及key='length'的trigger操作;
  由于第1个effect收集了key='length',因此会触发第1个effect重新执行,再次收集key='length'和触发key='8'以及key='length'的trigger操作;
  引起了死循环......
*/

根据上面的例子,不应该在调用push, pop, shift, unshift, splice这些API的时候进行track,因此重写就是为了暂停调用这些API过程中触发的track(target, ..., length),防止在某些情况下死循环。

到此对数组的重写原因就讲清楚了。接下来接着把get的逻辑看完。

Vue3源码阅读——响应式是如何实现的(reavtive篇)

  1. 调用Reflect.get(target, key, receiver)获取本次get的结果res
  2. 如果keySymbol类型并且这个key包含在builtInSymbols中,直接返回res。(builtInSymbolshas部分已经说过。)
  3. 如果key不是Symbol型,但是这个keyisNonTrackableKeys中,不需要对这个key进行track,直接返回res
    const isNonTrackableKeys = /*#__PURE__*/ makeMap('__proto__,__v_isRef,__isVue')
    
  4. 接着,如果不是只读的,进行track,收集依赖。
  5. track完了以后,如果是浅响应式,不管target[key]是不是对象,就直接返回了。
  6. 如果resRef并且targetArray并且key是整数,返回res,否则返回res.value
  7. 如果res是对象,那么就接着进行响应式处理,并返回代理对象,根据isReadonly的值调用readonly/reactive。可以看到嵌套对象的响应式是在get才会响应式处理,懒响应式,相比Vue2的递归getter/setter好多了。
  8. 最后返回res,在没有命中以上的if判断会执行到这里。

到此get的逻辑就完了,接下来看set

set

Vue3源码阅读——响应式是如何实现的(reavtive篇)

  1. 先获取到当前值oldValue
  2. 如果oldValue是只读的Ref,但是value(即将设置的值)不是Ref,直接return false
  3. 如果shallowfalse
  • 如果value(即将设置的值)不是只读、浅响应的,把oldValuevaluetoRaw
  • 如果target不是Array,并且oldValueRefvalue不是Ref,那就直接设置oldValue.value并返回。
  1. 判断要设置的key存不存在,数组的话判断key是不是小于数组length的整数,对象就调用hasOwn
  2. 调用Reflect.set(target, key, value, receiver)设置value
  3. 如果target是原型链上的东西,不触发更新。
  4. 如果hadKeyfalse,代表是ADD操作,需要触发更新。
  5. 如果oldValuevalue 相等,不触发更新。
  • 什么时候会有新旧值相等的情况?例如监听了一个数组,执行了 push 操作,会触发多次 setter;第一次 setter 是新加的值,会触发更新;第二次是由于新加的值导致length改变;此时value === oldValue,不再重复触发。

总结

到此,响应式reactive篇的内容已全部完成,最后来概括一下大致内容:

  1. 响应式的概念
  2. Vue2如何实现响应式
  3. reactive函数源码
  4. createReactiveObject的实现
  5. baseHandlers中四种Handler介绍
  6. mutableHandlers的实现(get、set、has、deletedeleteProperty、ownKeys
  7. 为什么要重写数组的includes, indexOf, lastIndexOfAPI
  8. 为什么要重写push, pop, shift, unshift, spliceAPI

本文涉及到的tracktrigger,对应收集依赖过程以及触发更新的过程,将在下一篇详细展开。

这是笔者第三篇源码分析类的文章,如果掘友们有什么建议,或者文中有错误,还请评论指出,谢谢!

如果本文对你有一点点帮助,点个赞支持一下吧,你的每一个【】都是我创作的最大动力 ^_^