Vue computed

Vue


一说起 computed,提到最多的就是:计算属性是惰性求值的,而且计算属性的值是会被缓存的,只有当依赖的响应式数据更新后才会被重新计算求值。

那你是否真的了解这其中的缘由?又是不是想过:

  1. 计算属性改变后,是如何通知页面重新渲染的
  2. 与计算属性相关联的响应式数据,又是通过什么方式将计算属性的 watcher 实例添加进自己的观察者队列的呢?

# computed

在组件初始化过程中,跟 props、data、watch 类似,initState 内部也会对组件内的 computed 属性进行初始化。

抛开 SSR 场景,以下面的用法为例:

{
  data() {
    return {
      a: 1,
      b: 2
    }
  },
  computed: {
    c() {
      return this.a + this.b
    }
  },
}
1
2
3
4
5
6
7
8
9
10
11
12
13

# 初始化

// 初始化 computed
function initComputed(vm: Component, computed: Object) {
  // 创建的watcher实例会被存储到组件实例的 `_computedWatchers` 对象中
  const watchers = (vm._computedWatchers = Object.create(null))

  // 遍历所有计算属性的定义
  for (const key in computed) {
    const userDef = computed[key]

    const getter = typeof userDef === 'function' ? userDef : userDef.get

    // ========= 会为每个computed key 创建一个watcher实例 ========= //
    // ====== computed 的本质就是一个 `lazy: true` 的 Watcher 实例 ======== //
    watchers[key] = new Watcher(vm, getter || noop, noop, { lazy: true })
    // ...
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

# 惰性求值

在初始化过程中,由于 { lazy: true },所以计算属性的 watcher 并不会立即调用 getter 进行求值,这就是 惰性求值 的第一个表现。

这里的getter就是示例中 function c() { return this.a + this.b } 这个方法

// 代码有删减
class Watcher {
  constructor(vm, expOrFn, cb, options, isRenderWatcher) {
    this.vm = vm
    vm._watchers.push(this)
    // ...
    this.dirty = this.lazy = !!options.lazy
    // ...
    this.getter = expOrFn
    // ...
    // 计算属性的 watcher ,它的 lazy 属性为 true
    this.value = this.lazy ? undefined : this.get()
  }

  get() {
    pushTarget(this)

    const vm = this.vm
    let value = this.getter.call(vm, vm)

    popTarget()
    return value
  }

  // 依赖的数据变更后,调用该方法
  update() {
    if (this.lazy) {
      // 这里会将 dirty 置为 true
      this.dirty = true
    } else if (this.sync) {
      this.run()
    } else {
      queueWatcher(this)
    }
  }

  evaluate() {
    this.value = this.get()
    // 这里会将 dirty 置为 false
    this.dirty = false
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42

# dirty

在 Watcher 类中可以看到,lazy 一旦赋值就再也不会改变,而 dirty 虽然初始化时取了 lazy 属性的值,但这个值是会发生改变的,改变的时机有两个:

  • evaluate 方法内,执行完 getter 进行求值后,会将该值置为 false
  • update 方法内,如果 lazy 属性为 true 的时候,则会将该值再置为 true

这里可以先说下 dirty 的作用:

computed 属性除了是惰性求值的外,它的值还是可以被缓存的,而 dirty 作用就是用来标识当前的缓存值是否依然有效,

  • 如果为 false,说明缓存可用,无需调用 getter 重新计算
  • 如果为 true,说明缓存数据 '脏了',需要重新计算

说到这里,应该可以明白,为什么是在依赖更新后将 dirty 置为 true,而在调用 getter 重新求值后又置为 false 了

接着深入看下evaluate以及update这两个方法究竟是在什么时候被调用的

# 求值

computed 计算属性的定义其实除了文章开头的 function 的方式,还可以以对象的方式,单独设置 getter 和 setter:

{
  computed: {
    c: {
      // getter
      get: function () {
        return this.a + this.b
      },
      // setter
      set: function (newValue) {
        var values = newValue.split(' ')
        this.a = values[0]
        this.b = values[values.length - 1]
      }
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

初始化过程中的最后,会根据当前计算属性的 key 是否在当前组件实例上存在,进行 computed 属性的 getter 和 setter 的配置工作,

function initComputed() {
  // ...

  // 如果 key 在当前实例上不存在
  if (!(key in vm)) {
    defineComputed(vm, key, userDef)
  }
}
1
2
3
4
5
6
7
8

# defineComputed

// 代码有删减
function defineComputed(target, key, userDef) {
  // computed 属性值是一个函数
  if (typeof userDef === 'function') {
    sharedPropertyDefinition.get = createComputedGetter(key)
    sharedPropertyDefinition.set = noop
  } else {
    // 是一个对象
    sharedPropertyDefinition.get = userDef.get
      ? createComputedGetter(key)
      : noop
    sharedPropertyDefinition.set = userDef.set || noop
  }
  // ...
  Object.defineProperty(target, key, sharedPropertyDefinition)
}

function createComputedGetter(key) {
  // 这个就是计算属性的 getter,在访问计算属性的值的时候会被触发
  return function computedGetter() {
    // 取出对应的观察者
    const watcher = this._computedWatchers && this._computedWatchers[key]
    if (watcher) {
      if (watcher.dirty) {
        // 执行getter
        watcher.evaluate()
      }
      // ...
      return watcher.value
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

重点在 createComputedGetter 方法中返回的计算属性的真正 getter。还是以开头的例子来看,假设在模板插值中用到了计算属性 c 的值,此时就会调用这里的 computedGetter 方法

# 初次求值

  • computedGetter 方法首先从组件实例的 _computedWatchers 属性中获取到属性 cwatcher 实例(这里是一对一的关系)。如果不存在则无需求值
  • 存在的话,会根据 watcher.dirty 的值决定是否进行求值操作。初次渲染该值为 true,所以这里会执行 evaluate 方法进行求值操作
    • 求得的值被缓存在 watcher 实例的 value 属性上,同时将 this.dirty = false
  • 最后返回了这个求得的值

这样的话,由于 dirty 是 false,所以即便 c 后续又被多次访问到,也不会被多次求值,而是直接返回 watcher.value 缓存的结果

这里还有很重要的一步,就是对于计算属性 c 的 watcher 实例,对 c 所依赖的 a 和 b 的依赖收集过程。简单地说,就是属性 a 和属性 b 如何将计算属性 c 的 watcher 实例,添加到自己的观察者队列中来,以便在自己更新后通知到 c 去重新计算求值

这个过程稍微有点复杂,一步步看:

# 依赖收集

  1. 上面提到初次渲染时,会执行 evaluate 方法对 c 进行求值操作,也就意味着计算属性 c 的 watcher 实例的 get 方法会被执行。所以这里 pushTarget(this) 中的 this 就是计算属性 c 的 watcher 实例。此时 Dep.target = this
get() {
  pushTarget(this)

  const vm = this.vm
  let value = this.getter.call(vm, vm)

  popTarget()
  return value
}
1
2
3
4
5
6
7
8
9
  1. this.getter 执行,也就是 function c() { return this.a + this.b } 这个方法被执行。所以这里又会触发属性 a 和 属性 b 的 getter。以 a 为例:
// a 的 getter
{
  get: function reactiveGetter() {
    const value = getter ? getter.call(obj) : val
    // 依赖收集
    if (Dep.target) {
      dep.depend()
      // ...
    }
    return value
  }
}
1
2
3
4
5
6
7
8
9
10
11
12

由于 Dep.target 存在,所以会进入到属性 a 的依赖收集过程 dep.depend(),这样一来就将计算属性 c 的 watcher 实例添加进属性 a 的 watcher 队列中去了,属性 b 同理。

那接下来就看下当依赖的属性 a 或者 b 发生更新后,计算属性 c 又是如何被重新计算求值的。

# 重新求值

假如有下列场景:a 的值被重新赋值为 20

this.a = 20
1

由于 a 的值更新了,所以所有观察了该值的 watcher 实例的 update 方法将会被调用,所以此时计算属性 c 的 watcher 实例的 update 方法会被调用:

  • 因为计算属性的 lazy === true,所以 dirty 属性又被置为了 true,此时同样不会对 c 进行求值
// 依赖的数据变更后,调用该方法
update() {
  if (this.lazy) {
    // 这里会将 dirty 置为 true
    this.dirty = true
  } else if (this.sync) {
    this.run()
  } else {
    queueWatcher(this)
  }
}
1
2
3
4
5
6
7
8
9
10
11
  • 而当应用中,再次访问到 c 的时候,c 的 getter 会被执行,由于 dirty 属性为 true,所以此时就会对 c 进行重新求值
function computedGetter() {
  // 取出对应的观察者
  const watcher = this._computedWatchers && this._computedWatchers[key]
  if (watcher) {
    if (watcher.dirty) {
      // 执行getter,进行求值
      watcher.evaluate()
    }
    // ...
    return watcher.value
  }
}
1
2
3
4
5
6
7
8
9
10
11
12

到这里就解答了文章一开始说到的惰性求值值会被缓存以及计算属性的 watcher 实例是如何被添加进其依赖数据的观察者队列中去的几个问题。还剩下最后一个问题,就是当计算属性的值更新之后,如何通知渲染 watcher 去重新渲染页面呢?

# 通知渲染 Watcher

这一部分更加的绕,建议不太熟悉响应式原理部分源码的同学可以结合源码一点点看。

上面提到,在计算属性 c 的求值过程会触发属性 a 和 b 的 getter,

// a 的 getter
{
  get: function reactiveGetter() {
    const value = getter ? getter.call(obj) : val
    // 依赖收集
    if (Dep.target) {
      dep.depend()
      // ...
    }
    return value
  }
}
1
2
3
4
5
6
7
8
9
10
11
12

还是以 a 为例,来看下 dep.depend() 的过程中都做了什么

# dep.depend

class Dep {
  depend() {
    if (Dep.target) {
      Dep.target.addDep(this)
    }
  }
}
1
2
3
4
5
6
7

此时 Dep.target 指向的是 计算属性 c 的 watcher 实例,所以

class Watcher {
  // ...
  addDep(dep: Dep) {
    // ...
    this.newDeps.push(dep)
    // ...
  }
}
1
2
3
4
5
6
7
8

经过 addDep 之后,计算属性 c 的 watcher 实例的 newDeps 属性中就有了 a 和 b 这两个依赖。

然后回到计算属性 c 的 getter 中来,其实在计算属性 c 的 watcher 实例存在的时候,里面的逻辑还有一步,就是当 Dep.target 有值时,会调用计算属性 c 的 watcher 实例的 depend 方法,

function computedGetter() {
  // 取出对应的观察者
  const watcher = this._computedWatchers && this._computedWatchers[key]
  if (watcher) {
    if (watcher.dirty) {
      watcher.evaluate()
    }
    if (Dep.target) {
      watcher.depend()
    }
    return watcher.value
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13

那此时的 Dep.target 又指向谁呢?还是计算属性 c 的 watcher 实例吗?

很显然不是,因为当watcher.evaluate()执行结束后,此时计算属性 c 的 watcher 实例的 get 方法 已经执行完毕,也就意味着下面的 popTarget 已经将计算属性 c 的 watcher 实例弹出了栈外。

get() {
  pushTarget(this)
  let value
  try {
    const vm = this.vm
    value = this.getter.call(vm, vm)
  } catch(e) {
    // ...
  } finally {
    popTarget()
    this.cleanupDeps()
  }
  return value
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# watcher.cleanupDeps

另外,还要再提一下在 finally 块中,cleanupDeps 的操作:这里会将计算属性 c 的 watcher 实例 deps 属性赋值为 newDeps,也就是此时 deps 中也保存了 a 和 b 的两个 dep 实例。

class Watcher {
  cleanupDeps() {
    // ...
    this.deps = this.newDeps
    // ...
  }
}
1
2
3
4
5
6
7

当计算属性 c 的 watcher 实例被弹出之后,此时栈内还有谁呢?那就是组件的渲染 watcher。至于为什么,大家可以去了解下组件的挂载过程,这里不再赘述。

接下来就看下 if (Dep.target) { watcher.depend() } 中,计算属性 c 的 watcher 实例的 depend 方法又做了什么

# watcher.depend

depend() {
  let i = this.deps.length
  while (i--) {
    this.deps[i].depend()
  }
}
1
2
3
4
5
6

由于 this.cleanupDeps() 方法的执行,将属性 a 和 b 的 dep 实例都放到了计算属性 c 的 watcher 实例的 dep 队列中,所以这里会遍历这个队列,并依次调用了它们的 depend 方法

depend () {
  if (Dep.target) {
    Dep.target.addDep(this)
  }
}
1
2
3
4
5

记住这里的 Dep.target 已经指向了 当前组件的渲染 watcher,所以最终Dep.target.addDep(this)的执行结果就是:

  • 在 a 的 watcher 队列中放入渲染 watcher
  • 在 b 的 watcher 队列中放入渲染 watcher

这样一来,无论是 a 还是 b 发生了变化,最终都会通知到页面渲染 watcher 来重新渲染页面。这里就解答了文章一开始提到的最后一个问题,计算属性改变后,是如何通知页面重新渲染的。而且 页面渲染 watcher 与 计算属性 c 的入栈顺序保证了,当页面重新渲染后,始终得到的都是计算属性 c 的最新值。

# 总结

  • 计算属性与普通的 data 中的属性不同,计算属性并没有对应的 Dep 实例,也没有对应的依赖收集过程。
  • 计算属性的本质是一个惰性求值的 watcher 实例,它所依赖的数据,会将这个 watcher 实例添加进自身的观察者队列中去,以便在其依赖的数据变更后,通知它的 watcher 实例进行重新求值
  • 计算属性重新求值后,也是通过其依赖的数据的观察者队列中的渲染 watcher 来重新渲染页面的,它自身跟渲染 watcher 无关
Last Updated: 10/21/2024, 4:15:17 PM