分析vue3源码16(computed实现)

207 阅读9分钟

Vue Computed 的设计与实现

一、前言

在上一节中,我们分析了 Vue 的调度系统实现。本节我们将分析 computed 计算属性的实现原理。computed 是 Vue 中最常用的特性之一,它能够基于响应式状态派生出新的状态,并且具有缓存的特性。

二、示例引入

让我们从一个简单的计算属性示例开始:

<template>
  <div>
    <p>原始价格: {{ price }}</p>
    <p>折扣价格: {{ discountPrice }}</p>
    <button @click="increasePrice">加价</button>
  </div>
</template>

<script>
import { ref, computed } from "vue";

export default {
  setup() {
    const price = ref(100);
    const discount = ref(0.9);

    // 计算属性:根据原价和折扣计算折扣价
    const discountPrice = computed(() => {
      console.log("computing discount price...");
      return price.value * discount.value;
    });

    const increasePrice = () => {
      price.value += 10;
    };

    return { price, discountPrice, increasePrice };
  },
};
</script>

这个例子展示了计算属性的几个重要特性:

  • 响应式计算:当 price 或 discount 变化时,discountPrice 会自动更新
  • 缓存特性:如果依赖值没有变化,多次访问 discountPrice 只会计算一次
  • 惰性求值:只有在访问 discountPrice 时才会进行计算

三、核心实现分析

3.1 computed 函数的入口

当我们调用 computed 函数时,它会创建一个 ComputedRefImpl 实例:

export function computed<T>(
  getterOrOptions: ComputedGetter<T> | WritableComputedOptions<T>,
  debugOptions?: DebuggerOptions,
  isSSR = false
) {
  let getter: ComputedGetter<T>;
  let setter: ComputedSetter<T> | undefined;

  // 处理参数,支持传入 getter 函数或包含 get/set 的对象
  if (isFunction(getterOrOptions)) {
    getter = getterOrOptions;
  } else {
    getter = getterOrOptions.get;
    setter = getterOrOptions.set;
  }

  // 创建 ComputedRefImpl 实例
  const cRef = new ComputedRefImpl(getter, setter, isSSR);

  if (__DEV__ && debugOptions && !isSSR) {
    cRef.onTrack = debugOptions.onTrack;
    cRef.onTrigger = debugOptions.onTrigger;
  }

  return cRef as any;
}

3.2 ComputedRefImpl 的实现

ComputedRefImpl 类本质上是一个特殊的 effect,它与 ReactiveEffect 一样实现了 Subscriber 接口,成为响应式系统中的一个订阅者:

export class ComputedRefImpl<T = any> implements Subscriber {
  // 作为订阅者,与 ReactiveEffect 一样维护双向依赖关系
  private deps?: Link = undefined; // 指向自己的依赖项
  private depsTail?: Link = undefined; // 指向最后一个依赖项

  // 作为响应式数据,需要收集依赖自己的订阅者
  private readonly dep: Dep = new Dep(this);

  // 计算结果缓存
  private _value: any = undefined;

  // 响应式标记
  private readonly __v_isRef = true;
  private readonly __v_isReadonly: boolean;

  // effect 的运行状态标记
  private flags: EffectFlags = EffectFlags.DIRTY;
  private globalVersion: number = globalVersion - 1;

  constructor(
    // 计算函数,相当于 effect 的 fn
    public fn: ComputedGetter<T>,
    private readonly setter: ComputedSetter<T> | undefined,
    isSSR: boolean
  ) {
    this[ReactiveFlags.IS_READONLY] = !setter;
    this.isSSR = isSSR;
  }
}

与 ReactiveEffect 的共同点

  1. 订阅者身份

    // ReactiveEffect
    deps?: Link = undefined
    depsTail?: Link = undefined
    
    // ComputedRefImpl
    deps?: Link = undefined
    depsTail?: Link = undefined
    
    • 都实现了 Subscriber 接口
    • 都使用 deps 和 depsTail 参与双向十字链表
    • 都会被加入到依赖的 dep.subs 列表中
  2. 执行机制

    // ReactiveEffect
    run() {
      // 执行副作用函数
      return this.fn()
    }
    
    // ComputedRefImpl
    get value() {
      // 执行计算函数
      refreshComputed(this)
      return this._value
    }
    
    • 都有一个核心函数需要执行(fn/计算函数)
    • 都会在执行过程中收集依赖
    • 都支持停止和清理
  3. 依赖追踪

    // 都会在执行过程中被收集为依赖
    dep.track()
    
    // 都会在依赖变化时收到通知
    notify(): true | void
    
    • 都参与响应式系统的依赖追踪
    • 都实现了 notify 方法接收更新通知
    • 都支持依赖的动态收集和清理

计算属性的特殊之处

虽然本质相同,但计算属性有其特殊性:

  1. 双重身份

    // 既是订阅者
    implements Subscriber
    
    // 也是响应式数据
    private readonly dep: Dep = new Dep(this)
    private readonly __v_isRef = true
    
    • 不仅订阅其他响应式数据
    • 自身也是一个响应式数据,可以被其他 effect 订阅
  2. 缓存机制

    private flags: EffectFlags = EffectFlags.DIRTY
    private _value: any = undefined
    
    • 通过脏值标记控制重新计算
    • 缓存计算结果避免重复计算
    • 只在被访问且脏值时才重新执行
  3. 调度时机

    notify() {
      this.flags |= EffectFlags.DIRTY
      batch(this, true)
    }
    
    • 依赖变化时不会立即计算
    • 标记为脏值并等待下次访问
    • 通过调度系统实现批量更新

这种设计让计算属性既保持了 effect 的响应式特性,又增加了缓存和延迟计算的优化,使其能够:

  • 准确地追踪和响应依赖变化
  • 避免不必要的重复计算
  • 在合适的时机进行更新
  • 支持复杂的计算属性链式依赖

3.3 计算属性的刷新过程

现在我们已经了解了计算属性的基本结构,接下来让我们看看它是如何处理值的更新的。

让我们以开头的折扣价格示例来分析计算属性是如何获取最新值的。当模板中的 {{ discountPrice }} 被渲染时,会触发计算属性的 get value 过程:

get value(): T {
  // 1. 依赖收集:收集当前正在执行的 effect(模板渲染 effect)
  const link = __DEV__
    ? this.dep.track({
        target: this,
        type: TrackOpTypes.GET,
        key: "value",
      })
    : this.dep.track();

  // 2. 计算新值
  refreshComputed(this);

  // 3. 同步版本号,用于后续的依赖追踪
  if (link) {
    link.version = this.dep.version;
  }

  // 4. 返回计算结果
  return this._value;
}

这个过程涉及到几个关键步骤:

1. 依赖收集(作为 ref 数据)
const link = this.dep.track();
  • 当模板渲染时,会创建一个渲染 effect
  • 这个渲染 effect 会被收集为计算属性的依赖
  • 这样当计算属性的值变化时,可以通知模板重新渲染
2. 计算值的更新

refreshComputed 函数是获取计算属性新值的核心逻辑:

export function refreshComputed(computed: ComputedRefImpl): undefined {
  // 1. 快速路径:如果正在收集依赖且不是脏值,直接返回
  if (
    computed.flags & EffectFlags.TRACKING &&
    !(computed.flags & EffectFlags.DIRTY)
  ) {
    return;
  }
  // 清除脏值标记
  computed.flags &= ~EffectFlags.DIRTY;

  // 2. 全局版本号检查:如果没有响应式变化发生,直接返回
  if (computed.globalVersion === globalVersion) {
    return;
  }
  // 更新版本号
  computed.globalVersion = globalVersion;

  const dep = computed.dep;
  // 3. 设置运行标记
  computed.flags |= EffectFlags.RUNNING;

  // 4. 依赖检查:如果依赖没有变化且不是 SSR,直接返回
  // 在 SSR 中没有渲染 effect,计算属性没有订阅者,
  // 因此不会追踪依赖,不能依赖脏值检查
  if (
    dep.version > 0 &&
    !computed.isSSR &&
    computed.deps &&
    !isDirty(computed)
  ) {
    computed.flags &= ~EffectFlags.RUNNING;
    return;
  }

  // 5. 保存当前上下文
  const prevSub = activeSub;
  const prevShouldTrack = shouldTrack;
  // 设置新的上下文
  activeSub = computed;
  shouldTrack = true;

  try {
    // 6. 准备依赖
    prepareDeps(computed);
    // 7. 执行计算函数
    const value = computed.fn(computed._value);
    // 8. 值变化检查:如果是首次计算或值发生变化
    if (dep.version === 0 || hasChanged(value, computed._value)) {
      // 更新值和版本号
      computed._value = value;
      dep.version++;
    }
  } catch (err) {
    // 9. 错误处理:确保即使出错也更新版本号
    dep.version++;
    throw err;
  } finally {
    // 10. 恢复上下文
    activeSub = prevSub;
    shouldTrack = prevShouldTrack;
    // 11. 清理旧依赖
    cleanupDeps(computed);
    // 12. 清除运行标记
    computed.flags &= ~EffectFlags.RUNNING;
  }
}

这个函数实现了完整的计算属性刷新逻辑:

  1. 优化检查
  • 通过 TRACKING 和 DIRTY 标记快速判断是否需要重新计算
  • 使用全局版本号避免不必要的计算
  1. 依赖追踪准备
  • 设置运行标记,表示正在执行计算
  • 保存并更新依赖追踪上下文
  1. 计算过程
  • 准备新一轮的依赖收集
  • 执行计算函数,同时收集新的依赖
  • 比较新旧值,决定是否需要更新
  1. 依赖管理
  • 在计算开始前准备依赖(prepareDeps)
  • 计算完成后清理不再需要的依赖(cleanupDeps)
  • 维护依赖的版本号,用于变化检测
  1. 错误处理
  • 确保即使计算出错也能正确更新版本号
  • 保证错误不会影响依赖追踪系统的状态
  1. 上下文管理
  • 正确保存和恢复全局状态
  • 确保计算属性的执行不影响其他响应式操作

这个实现确保了计算属性的几个重要特性:

  • 准确的依赖追踪
  • 高效的缓存机制
  • 正确的错误处理
  • 与响应式系统的完美集成
3. 依赖变化时的更新流程

当依赖发生变化时(例如点击按钮执行 price.value += 10),会触发一个完整的更新流程:

// ComputedRefImpl 的 notify 方法实现
notify(): true | void {
  // 1. 标记为脏值
  this.flags |= EffectFlags.DIRTY
  
  // 2. 检查是否需要进入调度队列
  if (
    !(this.flags & EffectFlags.NOTIFIED) &&  // 还未被通知
    activeSub !== this  // 避免自身递归
  ) {
    // 3. 将当前计算属性加入调度队列
    batch(this, true)
    return true
  } else if (__DEV__) {
    // 开发环境下的警告处理
  }
}

完整的更新流程如下:

  1. 触发依赖更新

    • price.value += 10 触发 price 的 set 操作
    • price 通知其所有依赖者(包括 discountPrice 计算属性)
  2. 计算属性接收通知

    • discountPrice 的 notify 方法被调用
    • 将计算属性标记为"脏值"(DIRTY)
    • 通过调度系统(batch)将更新加入队列
  3. 等待访问

    • 计算属性不会立即重新计算
    • 而是等到下次访问 .value 时才会计算
    • 这体现了计算属性的"惰性计算"特性
  4. 重新计算过程 当模板中的 {{ discountPrice }} 被访问时:

    get value(): T {
      // 1. 收集当前访问者作为依赖
      const link = this.dep.track()
      
      // 2. 由于之前被标记为 DIRTY,这里会触发重新计算
      refreshComputed(this)
      
      // 3. 更新依赖的版本号
      if (link) {
        link.version = this.dep.version
      }
      
      // 4. 返回新计算的值
      return this._value
    }
    
  5. 更新传播

    • 如果计算结果发生变化,会通知依赖这个计算属性的其他响应式效果
    • 例如触发模板的重新渲染

这种设计实现了几个重要目标:

  • 避免不必要的计算:只有真正需要值的时候才计算

  • 批量更新优化:通过调度系统统一处理更新

  • 防止递归死循环:通过标记机制避免自身递归

  • 保持响应式链路:确保依赖变化能够正确传播

3.4 缓存机制

<template>
  <div>{{ discountPrice }} - {{ discountPrice }}</div>
</template>

计算属性的缓存机制体现在:

  • 同一次渲染中多次访问 discountPrice
  • 第一次访问会计算新值
  • 第二次访问直接返回缓存的 _value,因为 DIRTY 标记已被清除
  • 惰性计算:只在真正需要值的时候才计算
  • 缓存复用:多次访问只计算一次
  • 响应式更新:依赖变化时自动标记为脏值,等待下次访问时更新

计算属性的设计实现了以下重要目标:

  • 避免不必要的计算:只有真正需要值的时候才计算
  • 批量更新优化:通过调度系统统一处理更新
  • 防止递归死循环:通过标记机制避免自身递归
  • 保持响应式链路:确保依赖变化能够正确传播

四、总结

通过以上分析,我们详细了解了 Vue 计算属性的实现原理:

  1. 通过 ComputedRefImpl 实现响应式依赖收集和更新
  2. 使用脏值检查和缓存机制提升性能
  3. 借助调度系统实现异步更新

在下一节中,我们将分析与计算属性密切相关的另一个重要特性 —— watch 的实现原理。watch 虽然与计算属性有些相似,但它们的使用场景和实现细节都有很大的不同。