computed:懒计算的响应式值

0 阅读3分钟

在前面的几篇文章中,我们完整地探索了 Vue3 响应式系统的核心:从 reactiveref 创建响应式数据,到 track 收集依赖,再到 trigger 触发更新。今天,我们将站在这些基础之上,剖析一个更高阶的响应式 API——computed,它如何在实现懒计算的同时,又能精确地响应变化。

前言:为什么需要 computed?

在模板中直接写复杂逻辑,往往会让代码变得臃肿且难以维护:

<template>
  <div>{{ todos.filter(todo => todo.done).length }} / {{ todos.length }} 已完成</div>
</template>

我们可以用 methods 来封装:

methods: {
  completedCount() {
    console.log('methods 重新计算了');
    return this.todos.filter(todo => todo.done).length;
  }
}

methods 有个问题:每次渲染都会重新执行,即使 todos 没有变化。这就是 computed 登场的地方:

computed: {
  completedCount() {
    console.log('computed 重新计算了');
    return this.todos.filter(todo => todo.done).length;
  }
}

computed 的核心概念

惰性求值(Lazy Evaluation)

惰性求值意味着:计算属性不会立即执行,而是在真正需要其值时才进行计算。

const todos = reactive([
  { text: '学习 Vue3', done: false },
  { text: '理解 computed', done: true }
]);

// 创建 computed,但此时不会执行 getter
const completedCount = computed(() => {
  console.log('getter 执行了');
  return todos.filter(todo => todo.done).length;
});

console.log('computed 已创建,但 getter 未执行');
// 输出: computed 已创建,但 getter 未执行

// 访问 .value 时,getter 才执行
console.log(completedCount.value); 
// 输出: getter 执行了
// 输出: 1

脏检查机制(Dirty Flag)

Vue3 通过一个内部的 _dirty 标志来实现缓存控制:

  • 初始状态:_dirty = true,表示需要重新计算。
  • 首次访问:执行 getter,缓存结果,_dirty = false
  • 依赖变化:_dirty = true,但不立即重新计算。
  • 再次访问:发现 _dirty = true,重新计算并更新缓存。
// 伪代码:脏检查机制
class ComputedRefImpl {
  _dirty = true;  // 默认脏,需要计算
  _value;         // 缓存的值
  
  get value() {
    if (this._dirty) {
      this._value = this.getter(); // 重新计算
      this._dirty = false;          // 标记为干净
    }
    return this._value;
  }
}

缓存策略的优势

这种缓存策略带来了显著的性能提升:

const todos = reactive([/* 大量数据 */]);

const completedCount = computed(() => {
  // 即使 todos 有 10000 项,只要依赖不变就不会重新计算
  return todos.filter(todo => todo.done).length;
});

// 多次访问,只计算一次
console.log(completedCount.value); // 计算
console.log(completedCount.value); // 缓存
console.log(completedCount.value); // 缓存

// 只有依赖变化后才重新计算
todos.push({ text: '新任务', done: true }); // 触发 _dirty = true
console.log(completedCount.value); // 重新计算

computed 与 effect 的关系

基于 effect 的实现

computed 本质上是一个特殊的 effect,但它有两个关键区别:

  • 懒执行:不立即运行,只在读取 .value 时运行。
  • 缓存结果:依赖不变时直接返回缓存值。
class ComputedRefImpl {
  constructor(getter) {
    this.getter = getter;
    this._dirty = true;
    this._value = undefined;
    
    // 创建一个 effect 来追踪依赖
    this.effect = new ReactiveEffect(getter, () => {
      // 调度器:依赖变化时执行
      if (!this._dirty) {
        this._dirty = true;
        // 触发依赖这个 computed 的 effect
        trigger(this, 'value');
      }
    });
  }
  
  get value() {
    // 收集依赖(谁在读取这个 computed)
    track(this, 'value');
    
    if (this._dirty) {
      this._dirty = false;
      // 运行 effect 获取新值
      this._value = this.effect.run();
    }
    
    return this._value;
  }
}

双向依赖关系

computed 同时扮演着两个角色:

  • 作为依赖的消费者:依赖其他响应式数据。
  • 作为依赖的提供者:被其他 effect(如渲染函数)依赖。
const price = ref(100);
const quantity = ref(2);

// computed 作为消费者:依赖 price 和 quantity
const total = computed(() => price.value * quantity.value);

// effect 作为消费者:依赖 computed
effect(() => {
  console.log(`总价是:${total.value}`);
});

这种双向关系通过 tracktrigger 完美串联: computed双向绑定关系

computed 的 setter 处理

可写计算属性

除了只读的计算属性,Vue3 还支持提供 setter 的可写计算属性:

const firstName = ref('张');
const lastName = ref('三');

const fullName = computed({
  get: () => `${firstName.value}-${lastName.value}`,
  set: (newValue) => {
    [firstName.value, lastName.value] = newValue.split('-');
  }
});

console.log(fullName.value); // 输出: 张-三

fullName.value = '李-四';
console.log(firstName.value); // 输出: 李
console.log(lastName.value);  // 输出: 四

setter 的实现原理

支持 setter 的 computed 需要额外处理:

class ComputedRefImpl {
  constructor(getter, setter) {
    this.getter = getter;
    this.setter = setter;
    this._dirty = true;
    this._value = undefined;
    
    this.effect = new ReactiveEffect(getter, () => {
      if (!this._dirty) {
        this._dirty = true;
        trigger(this, 'value');
      }
    });
  }
  
  get value() {
    track(this, 'value');
    
    if (this._dirty) {
      this._dirty = false;
      this._value = this.effect.run();
    }
    
    return this._value;
  }
  
  set value(newValue) {
    if (this.setter) {
      this.setter(newValue);
    } else {
      console.warn('计算属性是只读的');
    }
  }
}

手写实现:完整的 computed 函数

现在让我们整合所有概念,实现一个完整的 computed:

class ComputedRefImpl {
  constructor(getter, setter) {
    this.getter = getter;
    this.setter = setter;
    this._dirty = true;
    this._value = undefined;
    this.__v_isRef = true; // 标记为 ref,支持自动解包
    
    // 创建 effect,并传入调度器
    this.effect = new ReactiveEffect(getter, () => {
      // 调度器:依赖变化时执行
      if (!this._dirty) {
        this._dirty = true;
        // 触发依赖这个 computed 的 effect
        trigger(this, 'value');
      }
    });
  }
  
  get value() {
    // 收集读取当前 computed 的依赖
    track(this, 'value');
    
    // 如果脏,重新计算
    if (this._dirty) {
      this._dirty = false;
      this._value = this.effect.run();
    }
    
    return this._value;
  }
  
  set value(newValue) {
    if (this.setter) {
      this.setter(newValue);
    } else {
      console.warn('计算属性是只读的');
    }
  }
}

/**
 * 完整版 computed 实现
 * @param {Function|Object} getterOrOptions 可以是 getter 函数,或者包含 get/set 的对象
 * @returns {ComputedRefImpl} 计算属性 ref
 */
function computed(getterOrOptions) {
  let getter;
  let setter;
  
  // 处理参数:可以是函数,也可以是对象
  if (isFunction(getterOrOptions)) {
    getter = getterOrOptions;
    setter = null;
  } else {
    getter = getterOrOptions.get;
    setter = getterOrOptions.set;
    
    // 确保 getter 存在
    if (!isFunction(getter)) {
      console.warn('计算属性必须提供 getter 函数');
      getter = () => undefined;
    }
  }
  
  return new ComputedRefImpl(getter, setter);
}

对比:methods 与 computed 的本质区别

使用场景对比

特性methodscomputed
缓存无缓存有缓存
执行时机每次访问/渲染依赖变化时
适用场景事件处理、非响应式逻辑派生数据、复杂计算
返回值任意类型只能返回计算值
依赖追踪自动追踪
性能开销每次执行首次 + 依赖变化

进阶:计算属性的最佳实践

1. 保持计算属性的纯粹性

计算属性应该是纯函数:相同的输入总是返回相同的输出,不产生副作用。

// ❌ 不纯粹:修改外部状态
const wrong = computed(() => {
  externalVariable.value++; // 副作用!
  return count.value * 2;
});

// ❌ 不纯粹:异步操作
const wrongAsync = computed(async () => {
  return await fetchData(); // computed 不应返回 Promise
});

// ✅ 纯粹:只基于依赖计算
const right = computed(() => {
  return count.value * 2;
});

2. 避免在 computed 中修改依赖

// ❌ 错误:在 computed 中修改依赖
const counter = ref(0);
const double = computed(() => {
  counter.value++; // 会导致无限循环!
  return counter.value * 2;
});

// ✅ 正确:使用 watch 来处理修改
watch(counter, (newVal) => {
  console.log('counter 变化了', newVal);
});

3. 合理拆分计算属性

// ❌ 一个 computed 做太多事
const userInfo = computed(() => {
  const fullName = `${user.firstName} ${user.lastName}`;
  const age = new Date().getFullYear() - user.birthYear;
  const status = user.isActive ? '在线' : '离线';
  return { fullName, age, status };
});

// ✅ 拆分成多个 focused 的计算属性
const fullName = computed(() => `${user.firstName} ${user.lastName}`);
const age = computed(() => new Date().getFullYear() - user.birthYear);
const status = computed(() => user.isActive ? '在线' : '离线');

4. 使用 computed 的 setter 实现双向绑定

// 在组件中实现 v-model 双向绑定
const props = defineProps(['modelValue']);
const emit = defineEmits(['update:modelValue']);

const value = computed({
  get: () => props.modelValue,
  set: (newValue) => emit('update:modelValue', newValue)
});

// 模板中使用
<input v-model="value" />

5. 性能优化:避免不必要的计算

// ❌ 每次访问都创建新对象
const userProfile = computed(() => ({
  name: user.value.name,
  age: user.value.age,
  email: user.value.email
}));

// ✅ 缓存结果,只有依赖变化才重新创建
import { computed } from 'vue';

const userProfile = computed(() => {
  // 返回稳定的引用
  return {
    name: user.value.name,
    age: user.value.age,
    email: user.value.email
  };
});

// 或者在需要时才创建
const userName = computed(() => user.value.name);
const userAge = computed(() => user.value.age);
const userEmail = computed(() => user.value.email);

结语

computed 是 Vue3 响应式系统中一颗璀璨的明珠,理解 computed 的实现原理,不仅能帮助我们更好地使用它,还能在遇到性能问题时做出正确的优化决策。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!