Vue3源码阅读——响应式是如何实现的(reavtive篇)
前言
本文属于笔者Vue3源码阅读系列第三篇文章,往期精彩:
响应式源码预计产出两篇文章,本文主要对应reactive部分。主要内容:创建响应式代理对象以及代理对象handler的实现。
本文字数12000+,阅读完预计10分钟。
什么是响应式
在探索源码之前,咱们先来聊一下响应式的概念。在Vue官方文档中写道:
响应性是一种可以使我们声明式地处理变化的编程范式。
定义不好理解?那官网还举了一个很经典的例子:

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

上图来自官方文档,从图中我们能够看到:初始化时对状态数据做了劫持,在执行组件的render函数时,会访问一些状态数据,就会触发这些状态数据的getter,然后render函数对应的render watcher就会被这个状态收集为依赖,当状态变更触发setter,setter中通知render watcher更新,然后render函数重新执行以更新组件。 就这样完成了响应式的过程。
Vue2中通过Object.defineProperty给每个属性设置getter、setter,他的特点如下:
Object.defineProperty是通过给对象新增属性/修改现有属性 来实现数据的劫持。需要遍历对象的每一个key去实现,当遇到很大的对象或者嵌套层级很深的对象,性能问题会很明显。Object.defineProperty这种方式无法拦截到给对象新增属性这种操作,因为组件初始化不能预知会新增哪些属性,也就没法设置getter/setter,所以我们不得不使用Vue2提供的$setapi,再去Object.defineProperty给新增的属性加上getter/setter。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的源码:

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

createReactiveObject中主要是一些校验,最关键的就是最后的new Proxy(...),来看下具体做了哪些校验:
isObject校验,val !== null && typeof val === 'object'。- 已经是
proxy了,不能再响应式处理,但是有一种情况例外。const obj = reactive({}) const obj2 = reactive(obj) // 会直接返回obj obj === obj2 // true toReadonly(obj) // 这样是OK的 - 对一个原始对象多次响应式处理。
const obj = {} const objProxy = reactive(obj) const objProxy2 = reactive(obj) // 直接返回objProxy objProxy === objProxy2 // true - 不能被响应式处理的情况。
接下来我们看mutableHandlers的实现。
BaseHandlers
mutableHandlers的实现在 packages/reactivity/src/baseHandlers.ts 中,在 basehandlers 中包含了四种 handler :
mutableHandlers可变处理。readonlyHandlers只读处理。shallowReactiveHandlers浅观察处理(只观察目标对象的第一层属性)。shallowReadonlyHandlers浅观察 && 只读处理。
其中 readonlyHandlers shallowReactiveHandlers shallowReadonlyHandlers 都是 mutableHandlers 的变形版本,这里笔者将以 mutableHandlers 这个可变的来展开描述。
mutableHandlers
mutableHandlers的定义:

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

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:

ownKeys
用于拦截遍历的操作(for in、for of...),先调用track收集依赖,然后调用Reflect.ownKeys返回结果。
get
const get = /*#__PURE__*/ createGetter()
createGetter接受两个参数,可创建readonly的get方法,还能创建shallow的get方法:

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

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

从上图中可以看到,分别对'includes', 'indexOf', 'lastIndexOf' 和 'push', 'pop', 'shift', 'unshift', 'splice'两类的API进行了重写。那么问题就来了:为什么要重写?
为什么要重写includes, indexOf, lastIndexOf
咱们先来看,重写干了些什么:
- 调用
toRaw得到原始数组。 - 然后遍历原始数组,
track每一项,key就是索引。 - 调用数组的
includes/indexOf/lastIndexOf得到结果res1。 - 如果结果为
-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的逻辑下,页面并不会更新。我们来找下原因,当执行render中this.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>
笔者在本地将重写的逻辑注释以后,上面的内容渲染结果如下:

为啥会这样?当调用this.proxy.indexOf(this.obj),会get(arr, 0),因为arr[0]是一个Object,那么调用reactive(arr[0]),最终return了arr[0]的代理对象,代理对象肯定!==arr[0] 啊,因此返回-1;当调用this.proxy.indexOf(this.proxy[0]),会先this.proxy[0],即get(arr, 0),跟之前的一样return了arr[0]的代理对象,当执行indexOf时,又会get(arr, 0),还是走到reactive(arr[0]),只不过这个对象的代理已经创建过一次了,存在一个weakMap中,就直接return了weakMap中对应的代理对象,所以二者相等,返回0。
这也就是为什么要把参数调用toRaw得到原始对象后再找一次,就是为了防止原始对象和其代理对象比较这种情况。
为什么要重写push, pop, shift, unshift, splice
同样的,先来看重写干了啥:
- 暂停
track。 - 调用数组API得到结果。
- 恢复
track。 - 返回结果。
为了理解到重写的背景,我们先看个例子:

我们能够看到,调用了一次push,会触发一次length的get,一次length的set,而且刚好被重写的这些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的逻辑看完。

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

- 先获取到当前值
oldValue - 如果
oldValue是只读的Ref,但是value(即将设置的值)不是Ref,直接return false。 - 如果
shallow为false
- 如果
value(即将设置的值)不是只读、浅响应的,把oldValue和value都toRaw。 - 如果
target不是Array,并且oldValue是Ref,value不是Ref,那就直接设置oldValue.value并返回。
- 判断要设置的
key存不存在,数组的话判断key是不是小于数组length的整数,对象就调用hasOwn。 - 调用
Reflect.set(target, key, value, receiver)设置value。 - 如果
target是原型链上的东西,不触发更新。 - 如果
hadKey为false,代表是ADD操作,需要触发更新。 - 如果
oldValue和value相等,不触发更新。
- 什么时候会有新旧值相等的情况?例如监听了一个数组,执行了
push操作,会触发多次setter;第一次setter是新加的值,会触发更新;第二次是由于新加的值导致length改变;此时value === oldValue,不再重复触发。
总结
到此,响应式reactive篇的内容已全部完成,最后来概括一下大致内容:
- 响应式的概念
Vue2如何实现响应式reactive函数源码createReactiveObject的实现baseHandlers中四种Handler介绍mutableHandlers的实现(get、set、has、deletedeleteProperty、ownKeys)- 为什么要重写数组的
includes, indexOf, lastIndexOfAPI - 为什么要重写
push, pop, shift, unshift, spliceAPI
本文涉及到的track、trigger,对应收集依赖过程以及触发更新的过程,将在下一篇详细展开。
这是笔者第三篇源码分析类的文章,如果掘友们有什么建议,或者文中有错误,还请评论指出,谢谢!
如果本文对你有一点点帮助,点个赞支持一下吧,你的每一个【赞】都是我创作的最大动力 ^_^。
转载自:https://juejin.cn/post/7245145631181307962