vue3源码与应用-effect

前言

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

  1. run: 在执行传入方法fn前后重置一些上下文,在fn被执行时订阅其使用到的响应式变量。
  2. deps: 关联的dep
  3. scheduler: 依赖更新时回调任务
  4. stop: 方法可以停止依赖收集
  5. onTrack调试订阅响应式变量
  6. onTrigger调试关联变量触发更新

简单绘制了如下调用关系图:

effect内递归触发更新

typescript 复制代码
const obj = reactive({
  bar: 2
});
effect(() => {
  console.log(obj.bar);
});
obj.bar++;

// 2
// 3

这是一个常见demo: effectfn函数默认会执行一次(除非设置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方法是如何处理:

  1. 第一次执行parentEffectrun方法,activeEffect指向当前生成effect
  2. 执行obj.foo++订阅并触发更新
  3. finally还未被执行,activeEffectparent等上下文变量还未被释放
  4. 再次执行run方法,(parent === this)检测到死循环,退出不再继续执行
  5. 执行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

还是简单看一下执行流程:

  1. 第一次执行effect1activeEffect指向effect1,关联obj.foo
  2. effect1finally等待执行
  3. 第一次执行effect2parent指向effect1activeEffect指向effect2,关联obj.foo
  4. effect2内部执行obj.foo++
  5. effect2finally等待执行
  6. effect1再次执行,(parent.parent === this),检测到死循环,结束执行
  7. 先执行effect2finally,后执行effect1finally
  8. 执行外层obj.foo += 20;,先执行effect1(先收集),后执行effect2
  9. 执行effect1activeEffect指向effect1,不会重复收集(下文讨论)
  10. effect1finally等待执行
  11. 创建新的effect2-new1parent指向effect1activeEffect指向effect2-new1
  12. effect2-new1内部执行obj.foo++,触发effect1重新执行
  13. effect2-new1finally等待执行
  14. effect1重新执行(parent.parent === this),检测到死循环
  15. 先执行effect2finally,后执行effect2-new1finally
  16. 再执行第8步的后执行effect2 ,此时parentactiveEffect等检测死循环的上下文已被释放,再次执行必然重复步骤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++; // 无效
  1. effect1fn执行前,初始化trackOpBit = 1 << ++effectTrackDepth,此时trackOpBit10(二进制),执行initDepMarkers,此时无关联dep空转一下。
  2. effect1执行fn访问obj.barobj.baz,分别调用track方法
  3. targetMapdepsMap两个map缓存中获取关联的dep,没有则调用createDep方法分别创建dep-bardep-baz
  4. 新建dep状态均为dep.w = 0;dep.n = 0newTracked(dep)判定为新创建的dep,执行dep.n |= trackOpBit标记当前dep为新收集的
  5. 当前dep状态均为:dep.w = 0;dep.n = 10(二进制)shouldTrack = !wasTracked(dep)被判定为非缓存的
  6. 建立dep-bardep-baz与当前effect的关联关系
  7. 执行finally块中finalizeDepMarkers(this),重置对应dep的标记位,此时dep-bardep-baz状态均为:dep.n = 0;dep.w = 0
  8. 第一次执行obj.baz++,流程与1~6一样,正常打印
  9. 执行obj.bar++,此时trackOpBit10(二进制)
  10. 调用initDepMarkers后,缓存的dep-bardep-baz状态均为:dep.n = 0;dep.w = 10(二进制)
  11. fn访问obj.barobj.foo,不再访问obj.baz,执行track(obj, 'bar')track(obj, 'foo'),创建新的dep-foo,状态为dep.w = 0;dep.n = 0
  12. dep-bardep-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(二进制)
  13. dep-bar!wasTracked(dep)判断为已经关联的dep,无需重新关联;建立dep-foo当前effect的关联关系
  14. 执行finally块中finalizeDepMarkers(this)dep-bazwasTracked(dep) && !newTracked(dep)判断为已经失效的dep,将被断开与当前effect关联关系
  15. 再次执行obj.baz++不会触发effect1执行

这里使用targetMapdepsMap两个map缓存dep是为了防止effect内重复访问相同变量的相同属性或者同一响应式变量同一属性被不同effect访问,而创建重复的depdepnw标记位主要是用于区分当前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++前都是常规流程,就不做赘述:

  1. 此时存在三个depdep-foodep-bardep-baz。分别与三个effect相关联。
  2. obj.foo++触发effect1执行,trackOpBit = 1 << ++effectTrackDepthtrackOpBit变为10(二进制)
  3. 执行initDepMarkers后与effect1关联的dep-foodep-bar的状态均为:dep.n = 0;dep.w = 10(二进制)
  4. effect1内执行obj.bar++触发effect2执行,trackOpBit = 1 << ++effectTrackDepthtrackOpBit变为100(二进制)
  5. 执行initDepMarkers后与effect2关联的dep-bar的状态变为:dep.n = 0;dep.w = 110(二进制)dep-baz的状态变为:dep.n = 0;dep.w = 100(二进制)
  6. effect2内执行obj.baz++又触发effect3执行,trackOpBit = 1 << ++effectTrackDepthtrackOpBit变为1000(二进制)
  7. 执行initDepMarkers后与effect3关联的dep-baz的状态变为:dep.n = 0;dep.w = 1100(二进制)
  8. 此时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的开销。
  9. effecttrack响应式变量时,shouldTrack = !wasTracked(dep)均被判定为false------已经收集过的依赖,无需重新关联。
  10. 最后在finalizeDepMarkers内根据当前trackOpBit来还原标记位

从如上执行流程可以看出,给增加dep增加nw标记位用来区分不同嵌套层级effect访问相同响应式变量的相同属性时,已经与之关联的缓存dep无需重新关联,从而更细粒度的缓存关联关系。如果没有这个标记位,也就无法判断当前层级effectdep是否存在有效的关联关系,那么effect每次执行前需要断开当前effect关联的所有dep并重新收集关联,这样就会增加一定的性能开销。

而且effectTrackDepth <= maxMarkerBits被判定为false时,也即嵌套层级大于30层(因为javascript虽然使用的是64位双精度浮点数存储浮点数,但是位运算时强制转换为是32位有符号整型,且最高位为符号位)时,dep的标记位nw等于无效,也会重新收集关联关系。所以在开发过程中尽量避免effect嵌套层级超过30层的情况。

总结

  1. effect用法将传入的函数执行过程中访问过的响应式变量与之生成订阅关系,在变量更新时重新执行该函数
  2. effect封装ReactiveEffect实例,构建传入函数执行上下文并自动执行之
  3. effect内部嵌套effect容易造成死循环,谨慎使用
  4. effect只与fn执行过程中使用到的响应式变量生成订阅关系,未访问无效
  5. effect嵌套层级过深可能造成性能问题
相关推荐
web1350858863524 分钟前
前端node.js
前端·node.js·vim
m0_5127446425 分钟前
极客大挑战2024-web-wp(详细)
android·前端
若川34 分钟前
Taro 源码揭秘:10. Taro 到底是怎样转换成小程序文件的?
前端·javascript·react.js
潜意识起点1 小时前
精通 CSS 阴影效果:从基础到高级应用
前端·css
奋斗吧程序媛1 小时前
删除VSCode上 origin/分支名,但GitLab上实际上不存在的分支
前端·vscode
IT女孩儿1 小时前
JavaScript--WebAPI查缺补漏(二)
开发语言·前端·javascript·html·ecmascript
m0_748256563 小时前
如何解决前端发送数据到后端为空的问题
前端
请叫我飞哥@3 小时前
HTML5适配手机
前端·html·html5
@解忧杂货铺5 小时前
前端vue如何实现数字框中通过鼠标滚轮上下滚动增减数字
前端·javascript·vue.js
F-2H7 小时前
C语言:指针4(常量指针和指针常量及动态内存分配)
java·linux·c语言·开发语言·前端·c++