前言
vue3响应式这块的源码,在vue框架中是比较核心的模块,对我来说,阅读难度还是有一些的,特别是ts,因为之前没怎么在项目中写过,刚开始看起来很不习惯,但是得益于我的好兄弟chatgpt的帮助,再加上适当的练习,自然就不是问题了,慢慢也就习惯了。
除了已经克服的ts的问题,直接看源码还是会感觉不知道该看什么,不知道哪里是重点,好在我买了霍春阳大佬的书《Vue设计与实现》,本身讲的就是源码的东西,而且讲的很细,所以我是一边看书,一边看源码的,当然,书上讲的自然没有源码详细,而且源码还再不停迭代,但是大致内容是不会变得,如果遇到大的变动,我们不妨可以看看源码的提交记录,了解那些地方为什么要那样写,好处是什么,再不行,可以多看看测试用例,说不定就能找到答案,因为看测试用例比较直白一点,所以,我现在看某部分源码之前,还是习惯先看测试用例的,毕竟有些方法可能都没有用过,不建议直接看源码。
为了加深自己对源码的理解,觉得有必要总结一下,加深记忆。
附上源码地址v3.2.45
vue3响应式相比vue2的优点
vue2的响应式是使用Object.defineProperty
实现的,由于api的限制,只能拦截对象属性的get
和set
方法,很多功能是无法实现的,存在不少缺点,比如:
-
已代理的对象,新增和删除属性无法监听,只能通过官方提供的
set
和delete
方法去操作才行 -
通过索引修改数组的元素值,或者是使用某些会改变数组长度的方法,比如
push、pop、splice
等方法,为了解决这个问题,前者使用set
方法,后者重写数组相关方法,覆盖数组原型上的方法,以此实现响应式 -
无法代理
Set
和Map
数据,不能实现响应式 -
将data中数据转换成响应式数据时,需要一次性递归,数据非常多或者数据层级较深时,比较消耗性能
对比之下,vue3的优势很明显,vue3使用Proxy api,代理方式更多,除了get
和set
,还支持has、delete、ownKeys
等,还可以监听数组元素的变化,以及Set
和Map
数据结构也能监听,对于响应式数据的生成,并非一次性递归所有属性进行转化,而是每次get
的时候将非原始数据的属性转为Proxy
对象返回
准备
文件目录介绍
源码版本是3.2.45
src
├── baseHandlers.ts #定义了用于代理普通对象和数组的 Proxy handler
├── collectionHandlers.ts #定义了用于代理 Set、Map、WeakSet 和 WeakMap 的 Proxy handler
├── computed.ts #实现了计算属性
├── deferredComputed.ts #实现了延迟计算的计算属性,只有在真正需要获取计算属性的值时才会计算
├── dep.ts #依赖集合相关方法,用于收集和管理响应式数据的依赖
├── effect.ts #定义副作用函数的注册方法,定义收集副作用函数的track方法和触发副作用函数的trigger方法
├── effectScope.ts #定义了 effectScope 类型和相应的 API,用于管理 effect 的生命周期
├── index.ts
├── operations.ts #枚举了track和trigger的操作方法
├── reactive.ts #实现了将非原始值转换成响应式对象的函数
├── ref.ts #实现了将原始值转换成响应式对象的函数
└── warning.ts #定义了警告的方法,用于在开发环境,打印一些警告信息
关于调试
-
在vue package下调试
在vue3根目录
package.json
中定义有脚本,直接运行即可:pnpm run dev
然后在vue目录下随便创建个文件夹(一般是examples),创建html文件,引入dist中的源码即可
-
在reactivity package下调试
因为vue3源码架构是monorepo架构,所有也能直接将reactivity单独打包,在其内部生成dist文件,所以单纯看reactivity源码的时候,可以直接它里边写demo调试,需要在vue3根目录运行如下命令:
node ./scripts/dev.js reactivity -f esm-browser
打包目标是reactivity,模块的格式是esm-browser,./scripts/dev.js不传参数时,默认打vue这个完整的包,默认模块的格式是global(iife)。
然后在reactivity下随便创建个文件夹(一般是examples),创建创建html文件,引入dist中的源码即可:
<script type="module"> import { computed, ref, effect } from '../dist/reactivity.esm-browser.js' const foo = ref('foo') console.log(foo.value) </script>
effect文件
effect.ts 是vue3响应式的核心模块,实现了以副作用函数自动收集响应式数据的依赖,并在响应式数据发生变更的时候,自动触发依赖重新执行的功能。
其中,副作用函数是需要通过effect
方法来执行的,effect
方法会将传入的副作用函数换成ReactiveEffect
实例保存,并在合适的时候调用ReactiveEffect
实例的方法run
去执行副作用函数,为了方便描述,就把ReactiveEffect
实例抽象的看作是副作用函数,因为响应式数据的依赖集合中存储的就是ReactiveEffect
实例,所以也可以把依赖抽象看作是副作用函数,所以要先知道这几个概念,避免后续混淆
effect
effect
源码:
function effect<T = any>(
fn: () => T,
options?: ReactiveEffectOptions
): ReactiveEffectRunner {
if ((fn as ReactiveEffectRunner).effect) {
fn = (fn as ReactiveEffectRunner).effect.fn
}
const _effect = new ReactiveEffect(fn)
if (options) {
// 合并options到ReactiveEffect实例,有scope配置时,执行recordEffectScope注册副作用函数到effectScope
extend(_effect, options)
if (options.scope) recordEffectScope(_effect, options.scope)
}
if (!options || !options.lazy) {
_effect.run()
}
const runner = _effect.run.bind(_effect) as ReactiveEffectRunner
runner.effect = _effect
return runner
}
effect
方法接收一个副作用函数fn
和配置选项options
,然后根据副作用函数生成一个ReactiveEffect
实例,options
的lazy用来配置不立即执行副作用函数,而是响应式数据变更后再执行,具体所有的配置如下所示:
interface ReactiveEffectOptions extends DebuggerOptions {
lazy?: boolean // 是否立即执行
scheduler?: EffectScheduler // 调度器,用来实现更灵活和复杂的功能,比如计算属性
scope?: EffectScope // effect作用域,用来统一管理一些副作用函数
allowRecurse?: boolean // 是否允许递归调用自身的副作用函数
onStop?: () => void // 副作用函数被注销后的钩子函数
}
该options
继承了dev环境下用于调试的DebuggerOptions
接口。effect
方法会返回一个runner
,其实就是ReactiveEffect
实例的run
方法,且绑定了自身的实例,一般用来手动调用副作用函数,或者调用stop
方法在合适时机注销自身
ReactiveEffect
ReactiveEffect
类是用来封装副作用函数的,上面也说了,响应式数据的依赖就是ReactiveEffect
实例,ReactiveEffect
类有很多的实例属性,以及实例方法run
和stop
,简略源码如下所示:
class ReactiveEffect<T = any> {
active = true
deps: Dep[] = []
parent: ReactiveEffect | undefined = undefined
// @internal
computed?: ComputedRefImpl<T>
// @internal
allowRecurse?: boolean
// @internal
private deferStop?: boolean
onStop?: () => void
// dev only
onTrack?: (event: DebuggerEvent) => void
// dev only
onTrigger?: (event: DebuggerEvent) => void
// fn和scheduler使用public声明,所以无需this.fn = fn; this.scheduler = scheduler
constructor(
public fn: () => T,
public scheduler: EffectScheduler | null = null,
scope?: EffectScope
) {
recordEffectScope(this, scope) // 有scope的话,会被scope统一管理
}
// run() {...}
// stop() {...}
}
从代码可以看出,有几个属性标识了@internal
,用来提示该属性仅供内部使用,fn
和scheduler
即作为constructor的参数,又使用了public
修饰符,这样写可以将其直接转为ReactiveEffect
类的public
实例属性。介绍下非@internal
的public
实例属性:
-
active属性表示实例的激活状态,当调用stop方法,会将实例状态转为非激活状态
-
deps属性用来存储当前实例(也可以说是副作用函数)对应的所有响应式数据的依赖集合(Dep),
[Dep, Dep,...]
-
parent属性用来存储当前实例的父级
ReactiveEffect
,适用于effect嵌套使用的场景 -
fn是一个方法,实际的副作用函数
-
scheduler是一个方法,调度器,用于实现更复杂的功能
run()
run
实例方法比较复杂,源码如下:
run() {
// 判断是否是激活状态,不是的话,以普通方法直接执行并返回
if (!this.active) {
return this.fn()
}
// 记录当前层级effect的shouldTrack,shouldTrack在track的时候有用
let lastShouldTrack = shouldTrack
// 类似if (effectStack.includes(this)) return,就是判断effect调用栈中是否包含当前effect,包含的话就返回,可查看commit 2993a24
let parent: ReactiveEffect | undefined = activeEffect
while (parent) {
if (parent === this) {
return
}
parent = parent.parent
}
try {
// 执行this.fn之前,通过this.parent记录当前执行的ReactiveEffect,并将activeEffect指向当前,this.fn执行完毕后再将activeEffect的值恢复回来
this.parent = activeEffect // 如果effect没有嵌套执行时,则activeEffect === undefined
activeEffect = this
shouldTrack = true
// effectTrackDepth表示effect方法嵌套执行的深度
// trackOpBit以二进制的形式来跟踪当前effect执行的深度,这么写是为了性能考虑
trackOpBit = 1 << ++effectTrackDepth
// maxMarkerBits最大为30,考虑使用SMI技术优化,通过位运算进行高效的计算
if (effectTrackDepth <= maxMarkerBits) {
// 对deps中的每个dep的 w 标记进行初始化,
initDepMarkers(this)
} else {
// 一般不会进入这里,除非effect嵌套超过30层
cleanupEffect(this)
}
return this.fn()
} finally {
if (effectTrackDepth <= maxMarkerBits) {
// 将activeEffect对应deps中每一个dep的w和n中对应当前调用栈的bit位,置为0
finalizeDepMarkers(this)
}
trackOpBit = 1 << --effectTrackDepth
activeEffect = this.parent
shouldTrack = lastShouldTrack
this.parent = undefined
// 当在ReactiveEffect执行过程中调用stop,则deferStop会被置为true,然后ReactiveEffect执行结束后调用stop
if (this.deferStop) {
this.stop()
}
}
}
run
方法其实主要目的是为了执行真正的副作用函数this.fn
,但是因为effect
是可以嵌套执行的,在执行this.fn
时,可能会继续执行一个内部的effect
,所以在执行this.fn
之前或之后,会添加一些代码用来处理入栈前的准备工作,以及出栈后的回归工作,具体为何这样写,是因为内部effect下面如果还有响应式数据的读取操作时,会收集错依赖。
还有一个很重要的问题,就是this.fn
方法在执行前,需要先清理它收集的依赖集合,就是把当前副作用函数从响应式数据的依赖集合中删除,再进行收集,虽然依赖集合是Set数据类型,不会有重复的问题,但是,如果遇到如下存在分支切换逻辑的代码,就会出现问题:
const isShow = ref(false)
const text = ref('red')
let dummy
effect(() => {
dummy = isShow.value ? text.value : 'green'
})
isShow.value = false
text.value = 'yellow' // 不应该触发副作用函数重新执行
当isShow
的值为false
时,text
的值变化后,就不应该再触发副作用函数重新执行了,为此,需要每次执行this.fn
之前,需要清理依赖,当isShow
改变后,触发this.fn
重新执行,并清理依赖重新收集,text.value
没有读取到,不会触发get
方法进行收集依赖,所以text
的依赖集合中已经没有了当前的副作用函数,text
的值变化后,就不会触发副作用函数重新执行了。vue3之前的版本是副作用函数执行前,会把当前副作用函数从响应式数据对应的依赖集合中全部删除,不管副作用函数重新执行时,是否还会被响应式数据收集,当前版本是优化过的,后面会具体讲解。
stop()
stop
方法比较简单,源码如下:
stop() {
if (activeEffect === this) {
this.deferStop = true
} else if (this.active) {
cleanupEffect(this)
if (this.onStop) {
this.onStop()
}
this.active = false
}
}
副作用函数内部如果调用了stop方法,则会命中第一个判断条件,例如:
const useStop = ref(false)
const runner = effect(() => {
if (useStop.value) {
runner.effect.stop()
}
})
useStop.value = true
因为方法正在执行中时,自身调用了stop
方法,会导致finalizeDepMarkers
无法将ReactiveEffect
实例属性deps
中依赖集合dep
的(n和w)置为0,所以stop
方法一定要等ReactiveEffect
实例执行结束后再执行,可以参考effect测试用例edge case: self-stopping effect tracking ref。
stop
方法最终还是要执行第二个判断条件的逻辑,执行cleanupEffect
,调用onStop
钩子,ReactiveEffect
实例属性active
状态置为false
响应式的优化
之前讲过,副作用函数中存在分支切换逻辑的代码,副作用函数如果不从响应式数据的依赖集合中删除它自己,就会导致,一些和副作用函数没有关系的响应式数据发生变化时,使副作用函数重新执行。为了解决这个问题,每次执行副作用函数时,都先从响应式数据的依赖集合中删除当前副作用函数,然后再重新收集依赖,这样就可以解决此问题。但是这样其实不太妥当,因为一个副作用函数中,存在分支切换逻辑的代码不会太多,每次执行都先清空可能不太划算,比较消耗性能,有优化的空间,为此,有大佬提出了新的方式。
为了更好的理解,我们假设副作用函数不能嵌套执行,每次都只能执行一个。然后副作用函数执行的时候,给响应式数据的依赖集合Dep
上添加两个属性,一个是w
,等于1
时,表示当前副作用函数已经被收集过,等于0
时,表示没有被收集过;另一个是n
,等于1
时,表示当前副作用函数是新收集的,等于0
时,表示不是新收集的,Dep
的类型如下代码所示:
type Dep = Set<ReactiveEffect> & TrackedMarkers
interface TrackedMarkers {
w: number // 只能是0和1
n: number // 只能是0和1
}
上面说过副作用函数是以ReactiveEffect
实例保存的,ReactiveEffect
实例有一个属性deps
,用来存储所有涉及的响应式数据的依赖集合的引用,这个要知道。
然后开始执行如下代码,副作用函数中的响应式数据有flag
和text
:
const flag = ref(true)
const text = ref('hello')
effect(() => {
flag.value ? text.value : 'world'
})
flag.value = false
它们的依赖集合是dep
,dep.w
和dep.n
默都是认为0:
// 单纯的描述dep
const dep = new Set()
dep.w = 0
dep.n = 0
-
执行
effect
方法,会触发副作用函数首次执行:-
副作用函数执行前,开始尝试将它的
deps
中所有dep
的w
置为1,但是因为副作用函数还没执行过,依赖还未收集,故deps
是[]
-
开始执行副作用函数,触发
flag
和text
的get
进行依赖收集,同时会将flag
和text
的dep.n
置为1,表示是新收集的,副作用函数的deps
中也会保存flag
和text
的依赖集合,此时deps
为:[ dep, // flag的依赖集合 w:0 n:1 dep // text的依赖集合 w:0 n:1 ]
-
副作用函数执行完毕后,会从
dep.w === 1 && dep.n === 0
的dep
中,删除当前副作用函数对应的依赖,因为此时flag
和text
的依赖都不符合条件,所以无需删除,最后会将deps
的所有dep
的w
和n
置为0,此时deps
:[ dep, // flag的依赖集合 w:0 n:0 dep // text的依赖集合 w:0 n:0 ]
-
-
flag.value = false
会触发副作用函数第二次执行:-
副作用函数执行前,开始尝试将它的
deps
中所有dep
的w
置为1,此时依赖已经被收集过了,故deps
:[ dep, // flag的依赖集合 w:1 n:0 dep // text的依赖集合 w:1 n:0 ]
-
开始执行副作用函数,触发
flag
的get
进行依赖收集,会将flag
的dep.n
置为1,flag
的依赖已经收集过了,不会重复收集,text
的get
没有执行,故状态不变,此时deps
为:[ dep, // flag的依赖集合 w:1 n:1 dep // text的依赖集合 w:1 n:0 ]
-
副作用函数执行完毕后,会从
dep.w === 1 && dep.n === 0
的dep
中,删除当前副作用函数对应的依赖,因为此时text
的依赖符合条件,所以会把当前副作用函数对应的依赖从text
的dep
中删除,最后会将deps
的所有dep
的w
和n
置为0,此时deps
:[ dep, // flag的依赖集合 w:0 n:0 ]
-
-
此时再更改
text.value
的话,已经不会触发副作用函数重新执行了,很完美。
为了便于理解,我们简化了很多,把effect
当作是不能嵌套的,但是实际effect
是可以嵌套的,比如通过链式调用计算属性的场景,就是一个计算属性的getter
内引入另一个计算属性进行计算。为此,Dep
的w
和n
只有0和1,已经不够用了,所以源码中才用更多位的二进制来记录所有嵌套层级的w
和n
,用trackOpBit
来指向当前正在指向effect
层级,我还写了一个小demo来简单模拟多层嵌套effect的执行过程,真实的源码部分就不再讲了。
track
track
方法是给响应式数据进行依赖收集的,响应式数据只有在副作用函数内部进行读取操作的,才会触发依赖收集,源码如下:
function track(target: object, type: TrackOpTypes, key: unknown) {
// shouldTrack用来控制依赖是否要收集,被收集过的shouldTrack为false
// activeEffect不存在,说明响应式数据的读取操作,不在副作用函数内
if (shouldTrack && activeEffect) {
let depsMap = targetMap.get(target)
if (!depsMap) {
targetMap.set(target, (depsMap = new Map()))
}
let dep = depsMap.get(key)
if (!dep) {
depsMap.set(key, (dep = createDep()))
}
const eventInfo = __DEV__
? { effect: activeEffect, target, type, key }
: undefined
trackEffects(dep, eventInfo) // 尝试收集依赖
}
}
function trackEffects(
dep: Dep,
debuggerEventExtraInfo?: DebuggerEventExtraInfo
) {
let shouldTrack = false
if (effectTrackDepth <= maxMarkerBits) {
/**
* 举个例子,在执行到第二个getter的时候,newTracked(dep)为true,则shoulTrack保持false,不会再收集重复的依赖
* effect(() => {
b = a.value
console.log(a.value)
})
*/
if (!newTracked(dep)) {
dep.n |= trackOpBit // set newly tracked
shouldTrack = !wasTracked(dep) // 没有被收集过的,才会收集
}
} else {
// Full cleanup mode.
shouldTrack = !dep.has(activeEffect!)
}
if (shouldTrack) {
dep.add(activeEffect!) // 往依赖集合中添加当前副作用函数,即ReactiveEffect实例
activeEffect!.deps.push(dep) // 同时给当前副作用函数添加响应式数据依赖集合的引用
if (__DEV__ && activeEffect!.onTrack) {
activeEffect!.onTrack({
effect: activeEffect!,
...debuggerEventExtraInfo!
})
}
}
}
trigger
如果响应式数据已经收集了依赖,则响应式数据发生变更时就会触发其依赖集合中的所有依赖重新执行,源码如下:
// 响应式数据变更的方式
const enum TriggerOpTypes {
SET = 'set',
ADD = 'add',
DELETE = 'delete',
CLEAR = 'clear'
}
function trigger(
target: object,
type: TriggerOpTypes,
key?: unknown,
newValue?: unknown,
oldValue?: unknown,
oldTarget?: Map<unknown, unknown> | Set<unknown>
) {
const depsMap = targetMap.get(target)
if (!depsMap) {
// never been tracked
return
}
// deps,用来保存所有要执行的依赖
let deps: (Dep | undefined)[] = []
// CLEAR模式,执行所有依赖
if (type === TriggerOpTypes.CLEAR) {
deps = [...depsMap.values()]
} else if (key === 'length' && isArray(target)) { // 数组length变化
const newLength = Number(newValue)
// target是数组时,lenth变化,或者设置的索引大于lenth(说明间接更改了length),都要触发length对应依赖重新执行
depsMap.forEach((dep, key) => {
if (key === 'length' || key >= newLength) {
deps.push(dep)
}
})
} else {
// 正常key的SET | ADD | DELETE 需要触发key对应的依赖
if (key !== void 0) {
deps.push(depsMap.get(key))
}
// 根据具体情况,判断是否触发ITERATE_KEY和MAP_KEY_ITERATE_KEY的依赖集合
switch (type) {
case TriggerOpTypes.ADD:
if (!isArray(target)) {
deps.push(depsMap.get(ITERATE_KEY))
if (isMap(target)) {
deps.push(depsMap.get(MAP_KEY_ITERATE_KEY))
}
} else if (isIntegerKey(key)) {
// new index added to array -> length changes
deps.push(depsMap.get('length'))
}
break
case TriggerOpTypes.DELETE:
if (!isArray(target)) {
deps.push(depsMap.get(ITERATE_KEY))
if (isMap(target)) {
deps.push(depsMap.get(MAP_KEY_ITERATE_KEY))
}
}
break
case TriggerOpTypes.SET:
if (isMap(target)) { // Map专属
deps.push(depsMap.get(ITERATE_KEY))
}
break
}
}
const eventInfo = __DEV__
? { target, type, key, newValue, oldValue, oldTarget }
: undefined
if (deps.length === 1) {
if (deps[0]) {
if (__DEV__) {
triggerEffects(deps[0], eventInfo)
} else {
triggerEffects(deps[0])
}
}
} else {
const effects: ReactiveEffect[] = []
for (const dep of deps) {
if (dep) {
effects.push(...dep)
}
}
if (__DEV__) {
triggerEffects(createDep(effects), eventInfo)
} else {
triggerEffects(createDep(effects))
}
}
}
function triggerEffects(
dep: Dep | ReactiveEffect[],
debuggerEventExtraInfo?: DebuggerEventExtraInfo
) {
// spread into array for stabilization
const effects = isArray(dep) ? dep : [...dep]
// 先执行计算属性
for (const effect of effects) {
if (effect.computed) {
triggerEffect(effect, debuggerEventExtraInfo)
}
}
for (const effect of effects) {
if (!effect.computed) {
triggerEffect(effect, debuggerEventExtraInfo)
}
}
}
function triggerEffect(
effect: ReactiveEffect,
debuggerEventExtraInfo?: DebuggerEventExtraInfo
) {
// 副作用函数内触发依赖的话,不执行
if (effect !== activeEffect || effect.allowRecurse) {
if (__DEV__ && effect.onTrigger) {
effect.onTrigger(extend({ effect }, debuggerEventExtraInfo))
}
if (effect.scheduler) { // 有调度器则执行调度器
effect.scheduler()
} else {
effect.run()
}
}
}
各种响应式数据如何代理
总结一下Object、Array、Set和Map、基本数据类型是如何进行依赖的收集和触发的
Object对象
Object对象的所有可能的读取操作如下:
- 访问属性:
obj.foo
- 判断对象或原型链上是否有给定的
key
:key in obj
- 使用
for...in
循环遍历对象:for(const key in obj)
如何收集依赖:
- 第一种可以直接通过
Proxy
的get
拦截,使用键名key
来存储依赖 - 第二种直接通过
Proxy
的has
拦截,也是使用键名key
来存储依赖,has
本身也相当于读取操作,所以需要收集依赖 - 第三种
for...in
循环,可以通过Proxy
的ownKeys
拦截,但是因为for...in
操作不针对某一个key
,而是针对对象的所有key
,所以我们使用一个独一无二的值Symbol('iterate')
来存储对应的依赖
Object对象的所有可能的修改操作如下:
- 修改属性:
obj.foo = 2
- 新增属性:
obj.bar = 'is_new'
- 删除属性:
delete obj.foo
如何触发依赖重新执行:
- 修改属性值会触发
key
的依赖重新执行 - 新增属性会触发
Symbol('iterate')
的依赖重新执行,因为for...in
操作遍历的是对象的key
,对象key
的增减,都要重新执行for...in
操作,而value
变化了就不受影响,当然除非我们在for...in
循环内部读取了value
,才会触发for...in
重新执行,这也是很常见的情况,不过那就是get
的职责了 - 删除和新增是一样的,会触发
Symbol('iterate')
的依赖重新执行
Array数组
数组是一种特殊的对象,和对象有一些共性和不同的地方
Array数组的所有可能的读取操作如下:
- 通过索引读取数组的元素值:
arr[0]
- 访问数组的长度:
arr.length
- 把数组作为对象,使用
for...in
操作遍历:for(const key in arr)
- 使用
for...of
迭代遍历数组 - 数组的原型方法,如
concat
、join
、every
、some
、find
、findIndex
、includes
等,以及所有不改变原数组的原型方法
如何收集依赖:
- 前三种与对象的依赖收集是一样的操作
for...of
迭代遍历数组比较复杂一些,要先了解一些概念,首先for...of
是用来遍历迭代对象的,迭代对象需要实现迭代协议,也就是实现@@iterator
方法。@@name标志在ECMAScript规范里用来代指javascript内建的symbols值,@@iterator
对应的就是Symbol.iterator
,所以如果一个对象实现了Symbol.iterator
方法,那它就是可迭代对象。因为数组是一个可迭代对象,所以它的原型上是有Symbol.iterator
方法的,除了Symbol.iterator
方法,还有values
、keys
和entries
方法,Symbol.iterator
方法其实就是values
方法。values
方法会读取数组的length
和所有的value
,keys
方法会读取数组的length
和所有的key
,entries
方法会读取数组的length
和所有的[key, value]
。讲完了这些,我们就知道了for...of
需要收集length
和所有的索引的依赖,还是使用get
方法就行- 数组的原型方法中,
includes
、indexOf
和lastIndexOf
会读取数组的length
和所有索引,因为数组转为Proxy
代理后这些方法存在一些问题,需要拦截这几个方法,在拦截的时候去收集length
和所有索引的依赖就可以了。join
和concat
方法都会读取length
和所有索引,无需特意处理,所以直接走get
方法收集依赖,有点不解的地方是join
和concat
方法名本身为啥也会收集依赖?every
方法的依赖收集行为同上,some
、find
、findIndex
方法有点特别,他们只要符合回调函数的条件,就不会继续遍历了,所以它们只收集length
和符合条件之前的索引的依赖,其他的同理,就不一个个说了
Array数组的所有可能的修改操作如下:
- 通过索引修改数组的元素值:
arr[0] = 1
- 修改数组长度:
arr.length = 0
- 数组的栈方法:
push
、pop
、shift
、unshift
- 修改原数组的原型方法:
splice
、fill
、sort
等
如何触发依赖重新执行:
- 修改索引值会触发索引的依赖重新执行,但是如果修改的是一个不存在索引,会间接的触发
length
值的修改,所以会触发length
的依赖重新执行 length
的值如果改小了,则会间接影响到那些大于更新后length
的索引,所以那些索引对应的依赖和length
对应的依赖会重新执行- 数组的栈方法会隐式修改数组的长度,更大的问题是它们执行的时候,即会读取
length
值,也会隐式更改length
值,为了不使程序栈溢出,需要对这些即会读取length
,也会修改length
的原型方法,做些特别处理,执行这些方法的时候,将shouldTrack = false
,不进行依赖收集,执行完毕后,再将shouldTrack = true
splice
方法也是和上面一样的操作。fill
方法会修改所有的索引值,所以所有索引的依赖会重新触发。sort
方法执行也是根据条件来的,触发条件被排序的索引,会重新触发依赖执行
Set和Map
Set
和Map
数据的原型属性和方法大致相同,不同点在于给集合添加数据的方法,Set
使用add
方法,而Map
使用Set
方法。
Set
类型原型属性和方法如下:
size
:返回集合中元素的数量add(value)
:向集合中添加给定的值clear()
:清空集合delete(value)
:从集合中删除给定的值has(value)
:判断集合中是否存在给定的值keys()
:返回一个迭代器对象values()
:对于Set
集合类型来说,keys()
和values()
等价entries()
:返回一个迭代器对象,每一次迭代值为[value, value]
forEach(callback[, thisArg])
:forEach
函数会遍历集合中的所有元素,并对每一个元素调用callback
函数,forEach
函数接收可选的第二个参数thisArg
,用于指定callback
执行时的this
值
Map
类型原型属性和方法如下:
size
:返回Map
数据中键值对的数量clear()
:清空Map
delete(key)
:删除指定key
的键值对has(key)
:判断Map
中否存在给定的key
的键值对get(key)
:读取指定key
对应的值set(key, value)
:为Map
设置新的键值对或修改已存在key
的键值对keys()
:返回一个迭代器对象,每一次迭代值为键值对的key
值values()
:返回一个迭代器对象,每一次迭代值为键值对的value
值entries()
:返回一个迭代器对象,每一次迭代值为键值对的[key, value]
forEach(callback[, thisArg])
:forEach
函数会遍历Map
中的所有键值对,并对每一个键值对调用callback
函数,forEach
函数接收可选的第二个参数thisArg
,用于指定callback
执行时的this
值
如何代理:
相较于Object
和Array
可以通过get
拦截器拦截后,直接返回属性或索引的值,Set
和Map
的读取操作是调用自身原型的has
或get
方法,因为Proxy
对象没有对应的方法,所以需要我们自定义实现相关方法,这样当我们通过Proxy
实例访问Map
和Set
方法的时候,就可以在get
拦截器内拿到方法名称,然后返回我们自定义实现的方法,注意,针对Map
和Set
类型数据,Proxy
拦截器只用到了get
。自定义实现的方法,简单理解就是使用了策略模式,将每个方法对应的逻辑封装成一个方法,内部还是执行Map
和Set
的原生方法,源码中用mutableInstrumentations
对象存储所有的策略:
const mutableInstrumentations: Record<string, Function> = {
get(this: MapTypes, key: unknown) {
return get(this, key)
},
get size() {
return size(this as unknown as IterableCollections)
},
has,
add,
set,
delete: deleteEntry,
clear,
forEach: createForEach(false, false)
}
// 给 mutableInstrumentations添加相关迭代方法
const iteratorMethods = ['keys', 'values', 'entries', Symbol.iterator]
iteratorMethods.forEach(method => {
mutableInstrumentations[method as string] = createIterableMethod(
method,
false,
false
)
})
Map
和Set
的所有可能的读取操作如下:
size
:访问器属性has(value)
get(key)
keys()
values()
entries()
forEach(callback[, thisArg])
如何收集依赖:
mutableInstrumentations
定义的size
也是个访问器属性,get size() {}
会获取target
自身的size
属性,以Symbol('iterate')
存储依赖。mutableInstrumentations
定义的has
和get
方法逻辑差别不大,Set
以value
存储依赖,Map
以key
存储依赖mutableInstrumentations
定义的迭代方法中,只有当数据类型是Map
,且方法是keys
时,是针对整个Map
的所有key
,所以会以Symbol(Map key iterate)
存储依赖,其他的所有情况,都是针对集合所有元素或Map
所有键值对的,所以会以Symbol(iterate)
存储依赖mutableInstrumentations
定义的forEach
方法, 是针对集合所有元素或Map
所有键值对的,所以会以Symbol(iterate)
存储依赖
Map
和Set
的所有可能的更新操作如下:
clear()
delete(key)
add(value)
set(key, value)
如何触发依赖重新执行:
clear()
操作会使Set
或Map
数据的所有依赖重新执行delete(key)
操作会使Map
的key
、Set
的value
以及Symbol(iterate)
的依赖重新执行,如果是Map
,还会触发Symbol(Map key iterate)
的依赖重新执行add(value)
操作会触发Set
的Symbol(iterate)
的依赖重新执行set(key, value)
操作分两种,一种新增,一种更新。新增操作时,会触发Map
的Symbol(iterate)
和Symbol(Map key iterate)
的依赖重新执行,更新操作时,会触发对应key
的依赖执行
基本数据类型
基本数据类型的代理是最简单的
基本数据类型如number
,string
,boolean
,null
,undefined
,Symbol
和bigint
,无法使用Proxy
直接代理,为此需要将其转为一个对象包裹起来,像这样{ value: '' }
,然后给这个对象的value
添加getter
和setter
,这样每次进行读和写的时候都可以拦截到,在getter
中收集依赖,在setter
中触发依赖执行。
补充一些知识
-
ref
也可以处理非基本数据类型,如果传入一个Object
或Array
等非基本数据类型,在创建RefImpl
实例时,会通过toReactive(value)
将它们转为Proxy
对象 -
如果在
reactive
类型数据内部有属性是ref
类型,在读取和设置时会自动解包,无需再通过.value
去操作-
读取时
getter
内部会执行如下代码:if (isRef(res)) { // ref unwrapping - skip unwrap for Array + integer key. return targetIsArray && isIntegerKey(key) ? res : res.value // 自动解包 }
-
设置时
setter
内部会执行如下代码,如果旧值是ref
,而新值非ref
,说明是给ref
属性值赋值,则只需要通过oldValue.value = value
更新ref
就可以了if (!shallow) { if (!isShallow(value) && !isReadonly(value)) { oldValue = toRaw(oldValue) value = toRaw(value) } // isRef(oldValue) && !isRef(value) 表示给ref属性值赋值 if (!isArray(target) && isRef(oldValue) && !isRef(value)) { oldValue.value = value return true } }
-
-
使用
toRefs(...reactive({ a: 1, b: 2 }))
可以解决reactive
数据展开时,响应式丢失的问题 -
为什么在模板中可以不用使用
.value
读取和修改ref
?因为使用了proxyRefs
方法对ref
数据进行了代理 -
toRef
方法转成的ref
数据与普通ref
数据是不太一样的,普通ref
数据内部是RefImpl
类,而toRef
返回的ref
数据内部是ObjectRefImpl
类,ObjectRefImpl
类自身并没有依赖的收集和触发能力,而是基于被转数据,所以正常的用法是将响应式数的某个属性通过toRef
转为ref
计算属性
computed
实现计算属性的核心是ComputedRefImpl
类,ComputedRefImpl
类源码如下:
class ComputedRefImpl<T> {
public dep?: Dep = undefined
private _value!: T
public readonly effect: ReactiveEffect<T>
public readonly __v_isRef = true
public readonly [ReactiveFlags.IS_READONLY]: boolean = false
public _dirty = true // 是否需要重新计算
public _cacheable: boolean // this._cacheable = !isSSR
constructor(
getter: ComputedGetter<T>,
private readonly _setter: ComputedSetter<T>,
isReadonly: boolean,
isSSR: boolean
) {
// 第二个参数为调度器
this.effect = new ReactiveEffect(getter, () => {
if (!this._dirty) {
this._dirty = true
triggerRefValue(this) // 只有在effect内部读取当前计算属性才会触发dep执行,否则dep为空不执行
}
})
this.effect.computed = this
this.effect.active = this._cacheable = !isSSR
this[ReactiveFlags.IS_READONLY] = isReadonly
}
get value() {
// the computed ref may get wrapped by other proxies e.g. readonly() #3376
const self = toRaw(this)
trackRefValue(self)
// _dirty为true,则需要重新执行getter
if (self._dirty || !self._cacheable) {
self._dirty = false
self._value = self.effect.run()!
}
return self._value
}
set value(newValue: T) {
this._setter(newValue)
}
}
从源码可以看出来,computed
的ComputedRefImpl
类和ref
的RefImpl
类是很相似的,它们都是属于__v_isRef
类型,通过拦截getter
和setter
,处理响应逻辑。computed
之所以能够实现自动计算的能力,是因为它内置了ReactiveEffect
实例,就是在内部使用了副作用函数对getter
内的所有响应式数据进行了依赖收集,并使用调度器scheduler
,这样,当getter
内的响应式数据发生变更后,就会触发scheduler
执行,而不是getter
重新执行。
调度器使用一个实例属性_dirty
,来控制读取计算属性时,使用缓存数据还是重新触发getter
方法计算
// --调度器方法
// 如果需要重新计算,会把_dirty置为true
() => {
if (!this._dirty) {
this._dirty = true
triggerRefValue(this)
}
}
// --get value() 内部
// _dirty为true,则需要重新执行getter
if (self._dirty || !self._cacheable) {
self._dirty = false
self._value = self.effect.run()!
}
deferredComputed
相比computed
,deferredComputed
实现了如下功能:
- 异步计算
- 懒加载(懒加载trigger方法)
DeferredComputedRefImpl
类是deferredComputed
的具体实现,先附上源码:
const tick = /*#__PURE__*/ Promise.resolve()
const queue: any[] = []
let queued = false
const scheduler = (fn: any) => {
queue.push(fn)
if (!queued) {
queued = true
tick.then(flush)
}
}
const flush = () => {
for (let i = 0; i < queue.length; i++) {
queue[i]()
}
queue.length = 0
queued = false
}
class DeferredComputedRefImpl<T> {
public dep?: Dep = undefined
private _value!: T
private _dirty = true // 是否需要重新计算
public readonly effect: ReactiveEffect<T>
public readonly __v_isRef = true
public readonly [ReactiveFlags.IS_READONLY] = true
constructor(getter: ComputedGetter<T>) {
let compareTarget: any
let hasCompareTarget = false
let scheduled = false
// 第二个参数是ReactiveEffect实例的调度器
this.effect = new ReactiveEffect(getter, (computedTrigger?: boolean) => {
if (this.dep) {
if (computedTrigger) {
compareTarget = this._value
hasCompareTarget = true
} else if (!scheduled) {
const valueToCompare = hasCompareTarget ? compareTarget : this._value
scheduled = true
hasCompareTarget = false
scheduler(() => {
if (this.effect.active && this._get() !== valueToCompare) {
triggerRefValue(this)
}
scheduled = false
})
}
for (const e of this.dep) {
if (e.computed instanceof DeferredComputedRefImpl) {
e.scheduler!(true /* computedTrigger */)
}
}
}
this._dirty = true
})
this.effect.computed = this as any
}
private _get() {
if (this._dirty) {
this._dirty = false
return (this._value = this.effect.run()!)
}
return this._value
}
get value() {
trackRefValue(this)
return toRaw(this)._get()
}
}
异步计算
如果熟悉vue
组件的异步更新原理,可能就比较好理解了,当我们在一个微任务内多次更改响应式数据,那么组件就会在nextTick
进行更新,这对性能优化很重要,减少了不必要的dom
更新,这里其实是一样的道理。
该功能主要依赖的变量和方法:
queue
:微任务异步队列queued
:排队等待的标识,确保当前微任务期间加入的任务,会在下一次微任务中调用flush
刷新flush()
:刷新(执行)微任务队列scheduled
:是否执行scheduler
标识,为了确保一个微任务期间,多次更改响应式数据,只会触发更新一次scheduler(fn)
:往queue
存入任务方法
计算属性的getter
所依赖的响应式数据发生变更,会触发ReactiveEffect
实例的调度器执行,判断当前计算属性是否有依赖,有的话,如果满足computedTrigger !== true && scheduled === false
,会将scheduled = true
,并调用scheduler
方法创建一个任务,存入queue
队列,只有这个任务执行完毕,scheduled
的值才会恢复false
,而这个任务只有在微任务结束后才会执行,不管期间依赖数据变更了多少次,任务执行的时候都只会取最终的更新结果,这就达到了使计算属性异步计算的目的。
懒加载
该功能主要依赖的变量和方法:
hasCompareTarget
compareTarget
我们注意到,上面提到的存入queue
队列的任务中有this._get() !== valueToCompare
这样一个判断,说明我们存入到queue
中的任务最终还并不一定会触发计算属性的依赖(并不是计算属性的getter
)重新执行,只有计算属性的计算结果发生了变化才会触发其依赖重新执行。
至于计算属性链式调用计算属性的情况,就不说了,太让人头大了😖,自行查看测试用例了解:sync access of invalidated chained computed should not prevent final effect from running
最后
断断续续的基本把这块写完了,文笔稀烂,有些内容自己懂了,但是写下来让别人也能看懂还是非常不容易的,接下来准备开始看runtime-core
模块,继续总结……
参考资料
书籍:Vue.js设计与实现
原文链接:https://juejin.cn/post/7227062606040760380 作者:心有猛虎嗷呜