初探 Vue 3 响应式源码(三):Computed

224 阅读7分钟

一、计算属性的必要性

假设我们有如下模板:

{{ user.firstName + ' ' + user.lastName }}

当多处复用这个表达式时:

  • 维护成本:表达式修改需全局搜索替换
  • 性能浪费:每次渲染重复计算相同结果
  • 逻辑隔离:业务逻辑侵入视图层

所以我们需要computed这个能自动追踪依赖,且最好能够缓存计算结果的东西

const name = computed(() => user.firstName + ' ' + user.lastName)

我们来看一下computed的三个核心特性:

  1. 惰性执行:不访问时不计算,访问时执行
  2. 值缓存:依赖未变化时复用结果
  3. 依赖追踪:自动建立响应式关联

接下来,我们来分析一下computed是如何写出来的

二: 从零实现计算属性

1. 幼崽形态

// 第一阶段:直接返回函数结果
function computed(fn) {
  return fn()
}
let user = reactive({
  firstName: '张',
  lastName: '三'
})
let fullName = computed(() => user.firstName + user.lastName )
console.log(fullName); // 张三

现存问题:

正常的computed是用.value来访问值的,我们的用.value访问不到

function computed(fn) {
  return fn()
}

let user = reactive({
  firstName: '张',
  lastName: '三'
})
let fullName = computed(() => user.firstName + user.lastName )
console.log(fullName.value); // 报错,因为fullName没有value属性

2. 支持 .value 访问

我们要在获取.value属性的时候进行拦截

// 第二阶段:value属性返回函数结果
function computed(fn) {
  return { 
    get value() { return fn() } 
  }
}
let user = reactive({
  firstName: '张',
  lastName: '三'
})
let fullName = computed(() => user.firstName + user.lastName )
console.log(fullName.value); // 控制台:张三

现存问题:

在依赖的数据变化后,计算属性没有随之变化

// 第二阶段:value属性返回函数结果
function computed(fn) {
  return { 
    get value() { return fn() } 
  }
}
let user = reactive({
  firstName: '张',
  lastName: '三'
})
let fullName = computed(() => user.firstName + user.lastName )
console.log(fullName.value); // 控制台:张三
user.lastName = '四'
console.log(fullName.value); // 控制台:张三(预期是变成了张四)

可以看到,在lastName属性更改后,我们的fullName依然是第一次计算的结果,并没有变成预想中的“张四”

3. ### 实现响应式更新

这里我们需要加入effect, 对effect不熟悉的小伙伴可以看下上篇文章 # Vue 3 响应式系统(二):Effect

// 接入effect
function computed(fn) {
  const effectFn = effect(fn)
  
  return {
    get value() {
      return effectFn() // 通过effect建立依赖关系
    }
  }
}
let user = reactive({
  firstName: '张',
  lastName: '三'
})
let fullName = computed(() => user.firstName + user.lastName )
console.log(fullName.value); // 控制台:张三
user.lastName = '四'
console.log(fullName.value); // 控制台:张四(因为当依赖的数据变化时,会重新执行传给computed的函数,也就重新计算了)

简单说下为什么加了effect就可以,effect自动追踪函数内部访问的响应式数据(如 user.firstNameuser.lastName),并建立依赖关系。当这些响应式数据变化时,effect 会自动重新执行传入的函数,从而触发更新。

我们传给comptuedfncomputed会传给effect,这样当effect中检测到我们computed传给他的fn中依赖数据变化的时候,就会重新执行我们计算属性传给他的fn,也就重新计算了。

现存问题:

computed在没有访问的时候应该是不执行的,现在还没有访问,就已经执行过一次fn

function computed(fn) {
  // 省略 如上
}
let user = reactive({
  // 省略 如上
})
let fullName = computed(() => (){
  console.log('computed执行了')
  return user.firstName + user.lastName
})
// 未访问计算属性fullName,控制台就已经打印'computed执行了'

4. 惰性执行优化

这里我们通过effect的调度器,让effect变为首次不执行。

// 加入lazy: true
function computed(fn) {
  const effectFn = effect(fn, { 
    lazy: true, // 🚨 延迟执行
  })
  
  return {
    get value() {
      return effectFn() // 通过effect建立依赖关系
    }
  }
}

let user = reactive({
  firstName: '张',
  lastName: '三'
})
let fullName = computed(() => (){
  console.log('computed执行了')
  return user.firstName + user.lastName
})
// 未访问fullName,控制台不会打印

现存问题:

computed应该是缓存的,但我们的computed即使依赖的数据没变化,每次访问也都会重新计算。

// 加入lazy: true
function computed(fn) {
  const effectFn = effect(fn, { 
    lazy: true, // 延迟执行
  })
  
  return {
    get value() {
      return effectFn() // 通过effect建立依赖关系
    }
  }
}

let user = reactive({
  firstName: '张',
  lastName: '三'
})
let fullName = computed(() => (){
  console.log('computed执行了')
  return user.firstName + user.lastName
})
// 测试访问 fullName
console.log(fullName.value); // 输出: "张三";打印: "computed执行了"
console.log(fullName.value); // 输出: "张三";打印: "computed执行了"
console.log(fullName.value); // 输出: "张三";打印: "computed执行了"

在例子中我们可以看到,每次访问fullName.value都会执行这个log语句,代表我们传给computed的函数被执行,也就是说每次访问,都在重新计算,这当然是与我们对comptued带缓存这个认知不符的,所以要怎么加入呢?

5. 值缓存机制

缓存如何实现,需要加入一个叫做dirty的标志位,代表当前是否为脏数据,如果脏数据了,我们则重新计算。

scheduler(调度器):用于控制副作用函数的执行时机

function computed(fn) {
  let value
  let dirty = true // 🆕 脏检查标志

  const effectFn = effect(fn, {
    lazy: true,
    scheduler() { 
      dirty = true
    }
  })
  return {
    get value() {
      if (dirty) { // 如果为脏数据,才重新计算。
        value = effectFn()
        dirty = false 
      }
      return value
    }
  }
}

只要重新运行effect,我们就将dirty变为true,只要dirtytrue,我们在获取的时候就重新获取数据。

反之,如果依赖的数据没有变化,也就不会重新运行effectdirty也就不会变为truedirty不为true代表数据不需要重新请求,我们就直接返回老value就好了。

看看效果:

function computed(fn) {
  let value
  let dirty = true // 🆕 脏检查标志

  const effectFn = effect(fn, {
    lazy: true,
    scheduler() { 
      dirty = true
    }
  })

  return {
    get value() {
      if (dirty) {
        value = effectFn()
        dirty = false
      }
      return value
    }
  }
}
let fullName = computed(() => (){
  console.log('computed执行了')
  return user.firstName + user.lastName
})
// 测试访问 fullName
console.log(fullName.value); // 输出: "张三";打印: "computed执行了"
console.log(fullName.value); // 输出: "张三";
console.log(fullName.value); // 输出: "张三";
user.lastName = '四'
console.log(fullName.value); // 输出: "张四";打印: "computed执行了"
console.log(fullName.value); // 输出: "张四"

现存问题:

当计算属性之间存在依赖链时,也就是computed A引用computed B时,我们的实现会出现computed B变化后,computed A不能及时更新的问题

function computed(fn) {
  // ...如上
}
// 响应式数据
const data = reactive({ 
  a: 1 
})

// 创建计算属性
const double = computed(() => {
  return data.a * 2
})

const quadruple = computed(() => {
  return double.value * 2 // 依赖 double
})

// 初始访问
console.log(quadruple.value) // 期望输出: 4 ; 实际输出4

// 修改依赖的数据
data.a = 2

// 再次访问
console.log(quadruple.value) // 期望输出: 8 ; 实际输出4

在这个例子中:

  1. double 依赖于 data.a
  2. quadruple 依赖于 double.value

data.a 发生变化时:

  • double 会被标记为 dirty(因为 effectscheduler 会触发)。
  • 但是,由于 computed 中没有手动调用 triggerquadruple 并不知道 double 已经发生了变化。
  • 因此,quadruple 不会重新计算,即使 double 的值已经更新。

所以,我们应该完善computed自身的响应式,如何做呢,就是加入tracktrigger

6. 支持嵌套计算属性

可能有同学要问了,我computed中已经将传来的回调给effect了,effect中就会调用track了,为什么还要在computed中再次加入tracktrigger呢?

那是因为,computed既是观察者,也是被观察者。

我们现在仅仅做到了computed当观察者的一面,他依赖的数据变化时他会重新计算,但并没有做好他当“被观察者”的一面,也就是说他自身变化的时候他不会去通知别人,所以我们要加入tracktrigger来完善computed自身的依赖触发

track 用于依赖收集,trigger 用于触发更新

// 完整版实现
function computed(fn) {
  let value
  let dirty = true
  const deps = new Set() // 🆕 新增依赖收集器
  const effectFn = effect(fn, {
    lazy: true,
    scheduler() {
      if (!dirty) {
        dirty = true
        // 触发所有依赖该计算属性的副作用
        trigger(thisObj, 'value') 
      }
    }
  })
  const thisObj = {
    get value() {
      // 收集当前正在执行的副作用
      track(thisObj, 'value') 
      if (dirty) {
        value = effectFn()
        dirty = false
      }
      return value
    }
  }
  return thisObj
}

加入后我们重新测试:

function computed(fn) {
  // ...如上
}
// 响应式数据
const data = reactive({ 
  a: 1 
})

// 创建计算属性
const double = computed(() => {
  return data.a * 2
})

const quadruple = computed(() => {
  return double.value * 2 // 依赖 double
})

// 初始访问
console.log(quadruple.value) // 期望输出: 4 ; 实际输出4

// 修改依赖的数据
data.a = 2

// 再次访问
console.log(quadruple.value) // 期望输出: 8 ; 实际输出8

加入了tracktriggercomputed 就可以既当好“观察者”又可以当好“被观察者”啦

总结

computed实现的内部细节就已经完成啦~

附录:完整实现代码

// 生产级computed实现(简化版)
function computed(getter) {
  let value, dirty = true
  let effectFn = effect(getter, {
    lazy: true,
    scheduler: () => {
      if (!dirty) {
        dirty = true
        trigger(obj, 'value')
      }
    }
  })
  const obj = {
    _isRef: true,
    get value() {
      track(obj, 'value')
      if (dirty) {
        value = effectFn()
        dirty = false
      }
      return value
    }
  }

  return obj
}