前言
markdown
1. vue版本:3.2.47
2. 该系列文章为个人学习总结,如有错误,欢迎指正;尚有不足,请多指教!
3. 阅读源码暂未涉及ssr服务端渲染,直接跳过
4. 部分调试代码(例如:console.warn等),不涉及主要内容,直接跳过
5. 涉及兼容vue2的代码直接跳过(例如:__FEATURE_OPTIONS_API__等)
6. 注意源码里的`__DEV__`不是指`dev`调试,详情请看`rollup.config.js`
effect用法
effect
的主要作用就是要监听传入函数内使用的响应式变量更新后,重新执行该函数。与watch
的区别是:watch
仅能监听一个响应式变量,effect
可以监听函数内使用过的一个或多个。例如:
typescript
// 检测分页器数据发生变化,请求远端获取表格数据
const pageIndex = ref<number>(1);
const pageSize = ref<number>(10);
// 使用watch要监听多次
watch(pageIndex, () => queryRemoteTableData(pageIndex.value, pageSize.value));
watch(pageSize, () => queryRemoteTableData(pageIndex.value, pageSize.value));
// 使用effect更优雅
effect(() => {
// pageIndex或pageSize变化,自动执行
queryRemoteTableData(pageIndex.value, pageSize.value);
});
effect实现
typescript
// @file core/packages/reactivity/src/effect.ts
export function effect<T = any>(
fn: () => T,
options?: ReactiveEffectOptions
): ReactiveEffectRunner {
if ((fn as ReactiveEffectRunner).effect) {
fn = (fn as ReactiveEffectRunner).effect.fn
}
// 本质是生成ReactiveEffect实例
const _effect = new ReactiveEffect(fn)
if (options) {
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
构建ReactiveEffect
实例,非lazy
配置则立即运行run方法。
typescript
// @file core/packages/reactivity/src/effect.ts
// The number of effects currently being tracked recursively.
let effectTrackDepth = 0
export let trackOpBit = 1
// 最大递归标记层数(把1左移31次将到达最高符号位为负数)
const maxMarkerBits = 30
export let activeEffect: ReactiveEffect | undefined
export class ReactiveEffect<T = any> {
active = true
deps: Dep[] = []
parent: ReactiveEffect | undefined = undefined
/**
* Can be attached after creation
* @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
constructor(
public fn: () => T,
public scheduler: EffectScheduler | null = null,
scope?: EffectScope
) {
recordEffectScope(this, scope)
}
run() {
// 当前effect失效,直接执行
if (!this.active) {
return this.fn()
}
// parent:effect内层effect时或递归effect
let parent: ReactiveEffect | undefined = activeEffect
// 暂存当前是否可追踪状态
let lastShouldTrack = shouldTrack
while (parent) {
// 存在递归effect,只执行最外层
if (parent === this) {
return
}
// 追溯至最顶层effect,避免成环
parent = parent.parent
}
try {
// 暂存当前activeEffect
this.parent = activeEffect
// 全局变量设置为当前effect
activeEffect = this
// 提前设置fn函数执行时内部数据可追踪响应变化
shouldTrack = true
// 每嵌套一层effect,标记位左移一位
trackOpBit = 1 << ++effectTrackDepth
if (effectTrackDepth <= maxMarkerBits) {
initDepMarkers(this)
} else {
// 超出最大标记位,清除全部deps和effect之间关联关系
cleanupEffect(this)
}
// fn执行时可能内部有effect或触发其他effect执行
return this.fn()
} finally {
if (effectTrackDepth <= maxMarkerBits) {
finalizeDepMarkers(this)
}
// 还原标记位
trackOpBit = 1 << --effectTrackDepth
// 还原activeEffect
activeEffect = this.parent
// 还原上层可追踪状态
shouldTrack = lastShouldTrack
// 清除当前parent
this.parent = undefined
if (this.deferStop) {
this.stop()
}
}
}
stop() {
// stopped while running itself - defer the cleanup
if (activeEffect === this) {
this.deferStop = true
} else if (this.active) {
cleanupEffect(this)
if (this.onStop) {
this.onStop()
}
this.active = false
}
}
}
effect
构建ReactiveEffect
类的功能如下,如果有需要可以自行封装类似effect
:
run
: 在执行传入方法fn
前后重置一些上下文,在fn
被执行时订阅其使用到的响应式变量。deps
: 关联的dep
scheduler
: 依赖更新时回调任务stop
: 方法可以停止依赖收集onTrack
调试订阅响应式变量onTrigger
调试关联变量触发更新
简单绘制了如下调用关系图:
effect
内递归触发更新
typescript
const obj = reactive({
bar: 2
});
effect(() => {
console.log(obj.bar);
});
obj.bar++;
// 2
// 3
这是一个常见demo
: effect
内fn
函数默认会执行一次(除非设置lazy
属性)进行依赖收集,打印:2;然后修改bar
变量会在调用一次,打印:3。
下面我们看一个内嵌effect
:
在
vue
的单文本组件内,effect
传入的其实就是template
模板编译后合并setup
执行结果(定义响应式变量,当然也可以定义非响应式变量)组成的函数。所以内嵌effect
就相当于父组件调用子组件,也是比较常见的一种情况。
typescript
const obj = reactive({
foo: 1
});
const parentEffect = effect(() => { // 父
console.log('effect-parent:', obj.foo);
const childEffect = effect(() => { // 子
console.log('effect-child', obj.foo++);
});
});
// effect-parent: 1
// effect-child: 1
新增与parentEffect
订阅相同变量的childEffect
,并触发自身更新,理论上childEffect
执行完,parentEffect
和自身会重新执行一次,形成死循环,实际只执行了一次。或者我们简化一下:
typescript
const obj = reactive({
foo: 1
});
const parentEffect = effect(() => { // 父
console.log('effect-parent:', obj.foo++);
});
// effect-parent: 1
parentEffect
订阅foo
并主动触发更新,然后再次执行parentEffect
再次触发更新。实际上并没有造成死循环,来看看effect
内部run
方法是如何处理:
- 第一次执行
parentEffect
的run
方法,activeEffect
指向当前生成effect
- 执行
obj.foo++
订阅并触发更新 finally
还未被执行,activeEffect
和parent
等上下文变量还未被释放- 再次执行
run
方法,(parent === this)
检测到死循环,退出不再继续执行 - 执行
finally
,释放上下文
但是上述代码中防止死循环的逻辑真的完美吗,来看如下demo
:
typescript
const obj = reactive({
foo: 1,
});
const effect1 = effect(() => { // 父
console.log('effect1:', obj.foo);
const effect2 = effect(() => { // 子
obj.foo++;
});
});
// 外层触发更新
obj.foo += 20;
// Maximum call stack size exceeded
还是简单看一下执行流程:
- 第一次执行
effect1
,activeEffect
指向effect1
,关联obj.foo
effect1
的finally
等待执行- 第一次执行
effect2
,parent
指向effect1
,activeEffect
指向effect2
,关联obj.foo
effect2
内部执行obj.foo++
,effect2
的finally
等待执行effect1
再次执行,(parent.parent === this)
,检测到死循环,结束执行- 先执行
effect2
的finally
,后执行effect1
的finally
- 执行外层
obj.foo += 20;
,先执行effect1
(先收集),后执行effect2
- 执行
effect1
,activeEffect
指向effect1
,不会重复收集(下文讨论) effect1
的finally
等待执行- 创建新的
effect2-new1
,parent
指向effect1
,activeEffect
指向effect2-new1
effect2-new1
内部执行obj.foo++
,触发effect1
重新执行effect2-new1
的finally
等待执行effect1
重新执行(parent.parent === this)
,检测到死循环- 先执行
effect2
的finally
,后执行effect2-new1
的finally
- 再执行第8步的后执行
effect2
,此时parent
和activeEffect
等检测死循环的上下文已被释放,再次执行必然重复步骤8
造成这种死循环的原因是嵌套父子effect
订阅同一响应式变量的同一属性,触发外部effect
时会重新创建内部effect
,而外部effect
对相同dep
(同一对象同一属性)不会再次依赖收集,造成一个父effect
对应两个或多个子effect
的情况,所以此时检测死循环的机制就会失效。并且这种嵌套effect
,在每次祖父级更新时,都会创建对应的子级effect
,多次执行后会有大量子级effect
保存在内存中造成应用卡顿甚至崩溃,所以新手建议慎用内嵌effect
。至于单文本组件内父子组件是如何处理这种问题,我们后续讨论。
effect
有效的依赖收集
effect
内每访问响应式变量一次,就会执行此处的track
方法。具体订阅过程后续讨论。
依赖收集相关的代码如下:
typescript
// @file core/packages/reactivity/src/effect.ts
const targetMap = new WeakMap<any, KeyToDepMap>()
/**
* fn内只要使用到响应式数据,都会调用此方法,后续讨论
* @param target 响应式对象
* @param 访问类型:get、has、iterate
* @param 访问key值
*/
export function track(target: object, type: TrackOpTypes, key: unknown) {
if (shouldTrack && activeEffect) {
let depsMap = targetMap.get(target)
if (!depsMap) {
targetMap.set(target, (depsMap = new Map()))
}
let dep = depsMap.get(key)
// 每个target的key值对应一个dep
if (!dep) {
depsMap.set(key, (dep = createDep()))
}
const eventInfo = __DEV__
? { effect: activeEffect, target, type, key }
: undefined
// 以上代码仅构造关联target的dep
trackEffects(dep, eventInfo)
}
}
// 判断dep是否有效,是则和effect进行关联
export function trackEffects(
dep: Dep,
debuggerEventExtraInfo?: DebuggerEventExtraInfo
) {
let shouldTrack = false
// 当前层级在30层以内,走dep标记模式
if (effectTrackDepth <= maxMarkerBits) {
// 新收集的dep
if (!newTracked(dep)) {
// 给新创建的dep增加标记位
dep.n |= trackOpBit // set newly tracked
// 如果当前dep是缓存的dep则无需重新追踪
shouldTrack = !wasTracked(dep)
}
} else {
// Full cleanup mode.
// 当前deep如果没有被effect追踪,则应当重新追踪
shouldTrack = !dep.has(activeEffect!)
}
if (shouldTrack) {
// dep和effect互相关联,实现依赖收集
dep.add(activeEffect!)
activeEffect!.deps.push(dep)
// 省略...
}
}
// @file core/packages/reactivity/src/dep.ts
const createDep = (effects?: ReactiveEffect[]): Dep => {
const dep = new Set<ReactiveEffect>(effects) as Dep
dep.w = 0
dep.n = 0
return dep
}
// 判断dep在当前嵌套层级是否被订阅
const wasTracked = (dep: Dep): boolean => (dep.w & trackOpBit) > 0
// 判断dep在当前嵌套层级是否被新订阅
const newTracked = (dep: Dep): boolean => (dep.n & trackOpBit) > 0
// 断开失效的dep并还原dep标记位
const finalizeDepMarkers = (effect: ReactiveEffect) => {
const { deps } = effect
if (deps.length) {
let ptr = 0
for (let i = 0; i < deps.length; i++) {
const dep = deps[i]
// 当前dep是缓存的,但是fn执行完后没有被再次被收集
if (wasTracked(dep) && !newTracked(dep)) {
// 无效的缓存dep,被断开关联
dep.delete(effect)
// 这里可以判断一下dep有没有与之关联的effect,如果没有则可以从缓存中删除之
} else {
// 失效的dep被覆盖
deps[ptr++] = dep
}
// 清除当前嵌套层级的标记值
dep.w &= ~trackOpBit
dep.n &= ~trackOpBit
}
// 删除失效dep
deps.length = ptr
}
}
以如下demo
来分析为什么第二次执行obj.baz++
无效:
typescript
const obj = reactive({
bar: 1,
foo: 200,
baz: 3000
});
const effect1 = effect(() => {
if (obj.bar > 1) {
console.log('effect-foo:', obj.foo);
} else {
console.log('effect-baz:', obj.baz);
}
});
// effect-baz: 3000
obj.baz++;
// effect-baz: 3001
obj.bar++;
// effect-foo: 200
obj.baz++; // 无效
effect1
的fn
执行前,初始化trackOpBit = 1 << ++effectTrackDepth
,此时trackOpBit
为10(二进制)
,执行initDepMarkers
,此时无关联dep
空转一下。effect1
执行fn
访问obj.bar
和obj.baz
,分别调用track
方法- 从
targetMap
和depsMap
两个map
缓存中获取关联的dep
,没有则调用createDep
方法分别创建dep-bar
和dep-baz
- 新建
dep
状态均为dep.w = 0;dep.n = 0
被newTracked(dep)
判定为新创建的dep
,执行dep.n |= trackOpBit
标记当前dep
为新收集的 - 当前
dep
状态均为:dep.w = 0;dep.n = 10(二进制)
,shouldTrack = !wasTracked(dep)
被判定为非缓存的 - 建立
dep-bar
和dep-baz
与当前effect的关联关系 - 执行
finally
块中finalizeDepMarkers(this)
,重置对应dep
的标记位,此时dep-bar
和dep-baz
状态均为:dep.n = 0;dep.w = 0
- 第一次执行
obj.baz++
,流程与1~6一样,正常打印 - 执行
obj.bar++
,此时trackOpBit
为10(二进制)
- 调用
initDepMarkers
后,缓存的dep-bar
和dep-baz
状态均为:dep.n = 0;dep.w = 10(二进制)
fn
访问obj.bar
和obj.foo
,不再访问obj.baz
,执行track(obj, 'bar')
和track(obj, 'foo')
,创建新的dep-foo
,状态为dep.w = 0;dep.n = 0
dep-bar
和dep-foo
被!newTracked(dep)
判断为新收集的,执行dep.n |= trackOpBit
后,dep-foo
状态为dep.w = 0;dep.n = 10(二进制)
,dep-bar
的状态为:dep.n = 10(二进制);dep.w = 10(二进制)
,dep-baz
的状态为:dep.n = 0;dep.w = 10(二进制)
dep-bar
被!wasTracked(dep)
判断为已经关联的dep
,无需重新关联;建立dep-foo
当前effect的关联关系- 执行
finally
块中finalizeDepMarkers(this)
,dep-baz
被wasTracked(dep) && !newTracked(dep)
判断为已经失效的dep
,将被断开与当前effect
关联关系 - 再次执行
obj.baz++
不会触发effect1
执行
这里使用targetMap
和depsMap
两个map
缓存dep
是为了防止effect
内重复访问相同变量的相同属性或者同一响应式变量同一属性被不同effect
访问,而创建重复的dep
。dep
的n
和w
标记位主要是用于区分当前dep
与之关联effect
是否失效,避免失效的dep
也能触发更新。这样在一定程度上减少创建dep
的开销和触发effect
执行的次数。但也容易让开发者在存在条件判断的effect
内访问响应式变量时,而忽略条件判断发生变化后对应的依赖关系会与之断开关联关系的情况。就如上例中的obj.bar > 1
变化后,更新obj.baz++
其实是不会触发effect
重新执行的。
typescript
const obj = reactive({
bar: 1,
foo: 200,
baz: 3000
});
const effect1 = effect(() => {
// 如有必要,请在条件判断外访问属性
const {
bar,
foo,
baz
} = obj;
if (bar > 1) {
console.log('effect-foo:', foo);
} else {
console.log('effect-baz:', baz);
}
});
为了避免上述问题,可以将属性访问提取到条件判断外层。在单文本组件template
内无法定义变量的情况下可以使用computed
替换。
避免嵌套effect
层级过深
注意:这里的嵌套也可以是effect
相互触发依赖更新的情况,例如:
typescript
const obj = reactive({
foo: 1,
bar: 200,
baz: 3000
});
const effect1 = effect(() => {
console.log('effect1-foo:', obj.foo);
obj.bar++;
});
const effect2 = effect(() => {
console.log('effect2-bar:', obj.bar);
obj.baz++;
});
const effect3 = effect(() => {
console.log('effect3-baz', obj.baz);
});
// effect1-foo: 1
// effect2-bar: 201
// effect3-baz 3001
obj.foo++;
// effect1-foo: 2
// effect2-bar: 202
// effect3-baz 3002
在执行obj.foo++
前都是常规流程,就不做赘述:
- 此时存在三个
dep
:dep-foo
、dep-bar
和dep-baz
。分别与三个effect
相关联。 obj.foo++
触发effect1
执行,trackOpBit = 1 << ++effectTrackDepth
后trackOpBit
变为10(二进制)
- 执行
initDepMarkers
后与effect1
关联的dep-foo
和dep-bar
的状态均为:dep.n = 0;dep.w = 10(二进制)
effect1
内执行obj.bar++
触发effect2
执行,trackOpBit = 1 << ++effectTrackDepth
后trackOpBit
变为100(二进制)
- 执行
initDepMarkers
后与effect2
关联的dep-bar
的状态变为:dep.n = 0;dep.w = 110(二进制)
;dep-baz
的状态变为:dep.n = 0;dep.w = 100(二进制)
effect2
内执行obj.baz++
又触发effect3
执行,trackOpBit = 1 << ++effectTrackDepth
后trackOpBit
变为1000(二进制)
- 执行
initDepMarkers
后与effect3
关联的dep-baz
的状态变为:dep.n = 0;dep.w = 1100(二进制)
- 此时
dep-foo
的状态为:dep.n = 0;dep.w = 10(二进制)
,dep-bar
的状态为:dep.n = 0;dep.w = 110(二进制)
,dep-baz
的状态变为:dep.n = 0;dep.w = 1100(二进制)
,增加位标记代替effect
执行重新创建dep
,从而节省创建dep
的开销。 effect
内track
响应式变量时,shouldTrack = !wasTracked(dep)
均被判定为false
------已经收集过的依赖,无需重新关联。- 最后在
finalizeDepMarkers
内根据当前trackOpBit
来还原标记位
从如上执行流程可以看出,给增加dep
增加n
和w
标记位用来区分不同嵌套层级effect
访问相同响应式变量的相同属性时,已经与之关联的缓存dep
无需重新关联,从而更细粒度的缓存关联关系。如果没有这个标记位,也就无法判断当前层级effect
与dep
是否存在有效的关联关系,那么effect
每次执行前需要断开当前effect
关联的所有dep
并重新收集关联,这样就会增加一定的性能开销。
而且effectTrackDepth <= maxMarkerBits
被判定为false
时,也即嵌套层级大于30
层(因为javascript
虽然使用的是64位双精度浮点数
存储浮点数,但是位运算时强制转换为是32
位有符号整型,且最高位为符号位)时,dep
的标记位n
和w
等于无效,也会重新收集关联关系。所以在开发过程中尽量避免effect
嵌套层级超过30层的情况。
总结
effect
用法将传入的函数执行过程中访问过的响应式变量与之生成订阅关系,在变量更新时重新执行该函数effect
封装ReactiveEffect
实例,构建传入函数执行上下文并自动执行之effect
内部嵌套effect
容易造成死循环,谨慎使用effect
只与fn
执行过程中使用到的响应式变量生成订阅关系,未访问无效effect
嵌套层级过深可能造成性能问题