学习 Vue2 中 computed

665 阅读7分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第 33 天,点击查看活动详情

学习 Vue2 中 computed

1. start

  • 纸上得来终觉浅,绝知此事要躬行。
  • 我阅读了 Vue2computed 相关源码,终于理解了它的运行逻辑,写一篇文章记录一下自己的收获。
  • 阅读本文建议对 Vue2 中的 Dep、Watcher 有一定了解。

源码版本 : Vue@2.6.14

2. computed 的使用

首先,先介绍一下 computed 是如何使用的。

2.1 函数形式

<!DOCTYPE html>
<html lang="zh">
  <head>
    <meta charset="UTF-8" />
    <title>lazy_tomato</title>
  </head>

  <body>
    <div id="app">
      <div>{{ tomato }}</div>
    </div>
    <script src="https://cdn.jsdelivr.net/npm/vue@2.7.10/dist/vue.js"></script>
    <script>
      new Vue({
        el: '#app',
        data() {
          return {
            name: '番茄',
            say: '好吃',
          }
        },
        computed: {
          tomato() {
            return this.name + '---' + this.say
          },
        },
      })
    </script>
  </body>
</html>

image.png

2.2 自定义计算属性的 get set

<body>
  <div id="app">
    <div>firstName{{ firstName }}</div>
    <div>lastName{{ lastName }}</div>
    <div>fullName{{ fullName }}</div>
  </div>
  <script src="https://cdn.jsdelivr.net/npm/vue@2.7.10/dist/vue.js"></script>
  <script>
    new Vue({
      el: '#app',
      data() {
        return {
          firstName: '番茄',
          lastName: '好吃',
        }
      },
      computed: {
        fullName: {
          // getter
          get: function () {
            return this.firstName + '-' + this.lastName
          },
          // setter
          set: function (newValue) {
            var names = newValue.split('-')
            this.firstName = names[0]
            this.lastName = names[names.length - 1]
          },
        },
      },
      mounted() {
        setTimeout(() => {
          this.fullName = '西红柿-酸'
        }, 3000)
      },
    })
  </script>
</body>

20221027170016.gif

2.3 总结

Vue2 中的 计算属性 computed 本身是一个对象。

  1. 属性值可以为一个函数,直接当做 getter 的形式使用。
  2. 属性值可以为一个对象,可以自定义 getter , setter。

3. 源码阅读

3.1 计算属性的初始化

3.1.1 initComputed

computed 的初始化是在 _init中的 initState => initComputed

\src\core\instance\state.js

var computedWatcherOptions = { lazy: true }

function initComputed(vm, computed) {
  // 1. 在组件上定义一个 `_computedWatchers` 对象,存储这个组件中计算属性的 `watcher实例`;
  var watchers = (vm._computedWatchers = Object.create(null))
  var isSSR = isServerRendering() // 是服务端渲染

  // 2. 遍历传入的 computed, (选项 computed 是一个对象类型)
  for (var key in computed) {
    var userDef = computed[key]

    // 3. 定义一个变量 getter , 如果 computed 中属性的属性值是函数,则 getter = 属性值,否则存储属性值的 get;
    var getter = typeof userDef === 'function' ? userDef : userDef.get
    if (getter == null) {
      warn('Getter is missing for computed property "' + key + '".', vm)
    }

    if (!isSSR) {
      // 4. 每一个计算属性,都会创 `new Watcher()`,这里注意两点:1.传入了配置` { lazy: true }`; 2.传入了自定义的 getter;
      watchers[key] = new Watcher(
        vm,
        getter || noop,
        noop,
        computedWatcherOptions
      )
    }
    // 5. 排除 key 重复的情况,执行`defineComputed`。
    if (!(key in vm)) {
      defineComputed(vm, key, userDef)
    } else {
      if (key in vm.$data) {
        warn(
          'The computed property "' + key + '" is already defined in data.',
          vm
        )
      } else if (vm.$options.props && key in vm.$options.props) {
        warn(
          'The computed property "' + key + '" is already defined as a prop.',
          vm
        )
      } else if (vm.$options.methods && key in vm.$options.methods) {
        warn(
          'The computed property "' + key + '" is already defined as a method.',
          vm
        )
      }
    }
  }
}

3.1.2 源码逻辑讲解

  1. 在组件实例上定义一个属性 _computedWatchers ,存储这个组件中计算属性的 watcher实例

  2. 遍历传入的 computed 选项;

    • 定义一个变量 getter , 如果 computed 中属性的属性值是函数,则 getter = 属性值,否则存储属性值的 get;

      这行代码,就实现了计算属性的两种写法。

      1. 计算属性直接是一个函数的写法
      2. 计算属性是对象,可以定义 getset属性;
    • 每一个计算属性,都会 new Watcher(),这里注意两点:1.传入了配置 { lazy: true }; 2.传入了自定义的 getter;

    • 在排除 key 重复的情况,执行 defineComputed

3.1.3 小节

梳理一下 computed 初始化的整体逻辑

  1. Vue实例 上绑定一个 _computedWatchers属性,存储这个组件中计算属性的 watcher实例
  2. for in 的方式遍历 computed,给每一个属性值都创建一个 watcher实例,并且把 computed中的每一个属性绑定到Vue实例上。
  • 选项 computed 是一个对象,以后在编写代码的时候,不要再纠结 computed 是对象还是函数,傻傻分不清楚。
  • Vue实例 上的 _computedWatchers 对象,存储这个组件中计算属性的 watcher实例。以后如果打印 this 看到 _computedWatchers 这个属性不会觉得陌生了;
  • computed 中的属性,会生成一个对应的 watcher实例,这个 watcher实例 的属性有一些特殊。(特殊在哪里?后续会细说)
  • computed 中的属性名,会绑定到 Vue实例 上,并且给其赋值我们处理好的函数。 这也就是为什么可以直接使用 this.xxx 访问 computed 中的属性名。

3.2 new Watcher

计算属性的 new Watcher() 有哪些特殊操作?我结合一个真实的使用案例来分析。

3.2.1 使用案例:

<script src="./vue.js"></script>
<script>
  new Vue({
    el: '#app',
    data() {
      return {
        name: 'lazy',
      }
    },
    computed: {
      tomato() {
        return this.name.toUpperCase()
      },
    },
  })
</script>

3.2.2 computed 初始化的时候:

// 在 initComputed 函数中初始化的源码如下:
watchers[key] = new Watcher(vm, getter || noop, noop, computedWatcherOptions)

// 案例代码可以转换为如下逻辑
// watchers['tomato'] = new Watcher(
//   vm,
//   tomato() => {
//     return this.name.toUpperCase()
//   },
//   noop,
//   { lazy: true }
// )

3.2.3 Watcher 类:

var Watcher = function Watcher(vm, expOrFn, cb, options, isRenderWatcher) {
  this.vm = vm
  if (isRenderWatcher) {
    vm._watcher = this
  }
  vm._watchers.push(this)
  // options
  if (options) {
    this.deep = !!options.deep
    this.user = !!options.user
    this.lazy = !!options.lazy
    this.sync = !!options.sync
    this.before = options.before
  } else {
    this.deep = this.user = this.lazy = this.sync = false
  }
  this.cb = cb
  this.id = ++uid$1 // uid for batching
  this.active = true
  this.dirty = this.lazy // for lazy watchers
  this.deps = []
  this.newDeps = []
  this.depIds = new _Set()
  this.newDepIds = new _Set()
  this.expression = expOrFn.toString()
  // parse expression for getter
  if (typeof expOrFn === 'function') {
    this.getter = expOrFn
  } else {
    this.getter = parsePath(expOrFn)
    if (!this.getter) {
      this.getter = noop
      warn(
        'Failed watching path: "' +
          expOrFn +
          '" ' +
          'Watcher only accepts simple dot-delimited paths. ' +
          'For full control, use a function instead.',
        vm
      )
    }
  }
  this.value = this.lazy ? undefined : this.get()
}

3.2.4 最终得到的 watcher 实例:

// 下方实例经过简化
var w1 = {
  active: true,
  dirty: true,
  expression: "tommto () {\n   return '132'\n }",
  getter: function tommto() {
    return this.name.toUpperCase()
  },
  id: 1,
  lazy: true,
  sync: false,
  user: false,
  value: undefined,
}

3.2.5 小节:

解释一下上述的逻辑,总的来说就是实例化了一个 watcher实例。 但是这个 watcher实例 的属性有点特殊:

  1. 属性:getter 是用户定义的函数(computed 选项定义的函数);
  2. 属性:dirtytrue(标示需不需要重新求值);
  3. 属性:lazytrue(标示自己是计算属性生成的 watcher);
  4. 属性:valueundefined

注意 value 默认是空的。

3.3 defineComputed

3.3.1 defineComputed 源码

initComputed 中,除了实例化了一个 watcher实例,还执行了 defineComputed,我们来看看它的源码。

源码:

function noop(a, b, c) {}

var sharedPropertyDefinition = {
  enumerable: true,
  configurable: true,
  get: noop,
  set: noop,
}

function createComputedGetter(key) {
  return function computedGetter() {
    var watcher = this._computedWatchers && this._computedWatchers[key]
    if (watcher) {
      // 需要重新计算
      if (watcher.dirty) {
        watcher.evaluate()
      }

      // 收集依赖
      if (Dep.target) {
        watcher.depend()
      }
      return watcher.value
    }
  }
}

// 参数,新增this
function createGetterInvoker(fn) {
  return function computedGetter() {
    return fn.call(this, this)
  }
}

function defineComputed(target, key, userDef) {
  // 不是服务端渲染,则需要缓存。
  var shouldCache = !isServerRendering()

  // 属性值为函数的情况
  if (typeof userDef === 'function') {
    sharedPropertyDefinition.get = shouldCache
      ? createComputedGetter(key)
      : createGetterInvoker(userDef)
    sharedPropertyDefinition.set = noop
  } else {
    // 属性值不为函数的情况
    sharedPropertyDefinition.get = userDef.get
      ? shouldCache && userDef.cache !== false
        ? createComputedGetter(key)
        : createGetterInvoker(userDef.get)
      : noop
    sharedPropertyDefinition.set = userDef.set || noop
  }

  // 异常情况处理
  if (sharedPropertyDefinition.set === noop) {
    sharedPropertyDefinition.set = function () {
      warn(
        'Computed property "' + key + '" was assigned to but it has no setter.',
        this
      )
    }
  }

  // 5. 在组件实例上存储这个计算属性。
  Object.defineProperty(target, key, sharedPropertyDefinition)
}

3.3.2 整体逻辑

上述代码的整体逻辑:

  1. computed 选项中的每个属性,绑定到我们的 Vue 实例上。

  2. 绑定的时候重写了他们的getter,setter

    • 源码精简

      // 1. 我们演示案例不是服务端渲染,所以源码中`shouldCache` 为 `true`;
      // 2. `sharedPropertyDefinition` 就是一个普通对象;
      // 源码可以精简为如下代码:
      const obj = {}
      obj.get = createComputedGetter(key)
      obj.set = () => {}
      Object.defineProperty(vm, key, obj)
      
    • 重写getter,即 createComputedGetter

3.3.3 createComputedGetter

createComputedGetter,会返回一个名为 createComputedGetter 的函数,当做这个计算属性的 getter。

function createComputedGetter(key) {
  return function computedGetter() {
    const watcher = this._computedWatchers && this._computedWatchers[key]
    if (watcher) {
      if (watcher.dirty) {
        watcher.evaluate()
      }
      if (Dep.target) {
        watcher.depend()
      }
      return watcher.value
    }
  }
}

// 重新调用 get 方法。
Watcher.prototype.evaluate = function evaluate() {
  this.value = this.get()
  this.dirty = false
}

// 收集依赖
Watcher.prototype.depend = function depend() {
  var i = this.deps.length
  while (i--) {
    this.deps[i].depend()
  }
}

3.3.4 解释上述代码:

  1. 当我使用这个计算属性的时候,就会调用 computedGetter 函数。

    computed: {
       tomato() {
           return this.name + '---好吃'
       },
    },
    
    // 这种方式使用计算属性,就会调用 `computedGette`
    this.tomato
    
  2. computedGetter 函数中的逻辑;

    • 拿到 _computedWatchers 中存储的 计算属性的 watcher实例
    • evaluate (评估)表示重新触发 get,重新求值;
    • dirty (脏的)标示需要重新求值的状态变量;
    • watcher.dirty 存在,重新求值。
    • Dep.target 存在,收集依赖。
  3. watcher.evaluate() , 它会执行 computed 配置中用户定义的函数,更新我们计算属性的值,然后设置 dirty 为 false;

  4. Dep.target 即收集依赖。

4. 页面渲染的流程

4.1 案例:

<body>
  <div id="app">
    <div>{{ tomato }}</div>
  </div>
  <script src="https://cdn.jsdelivr.net/npm/vue@2.7.10/dist/vue.js"></script>
  <script>
    new Vue({
      el: '#app',
      data() {
        return {
          name: '番茄',
        }
      },
      computed: {
        tomato() {
          return this.name + '---好吃'
        },
      },
    })
  </script>
</body>

4.2 渲染页面前:

// 案例执行逻辑如下:

1`new Vue()`

2`_init()`

3`initState()`

4`initDate()`

5`initComputed()`

此时内存中存在以下数据:

  1. 配置项 dataname 属性的 dep(d1);
  2. 计算属性 tomato 生成的 watcher实例(w1);

w1可以理解为: { dirty: true, lazy: true,value: undefined,}

4.3 开始渲染页面:

  1. 渲染页面会创建一个渲染 watcher (w2)

  2. 渲染页面的时候,因为模板中使用到了 tomato,会执行对应 computedGetter 的逻辑

  3. 计算属性首次加载,w1.dirty 默认为 true,会触发 watcher.evaluate()

  4. watcher.evaluate(),会触发 computed 选项中用户自定义的函数,然后设置 w1.dirtyfalse;

  5. 用户自定义函数又读取了 this.name,所以会触发 this.namegetter

    例如执行:return this.name + '---好吃',会触发 this.namegetter

  6. Dep.target的相关逻辑

    • 执行用户自定义的函数的时候:

      `d1` 收集 `w1`;
      w1 中的 deps 收集 `d1`
    • computedGetter中的Dep.target

      `w1 中的 deps 中所有的 dep` 都收集 `w2`
      // 上述逻辑其实就是 `d1 收集 w2`
      

4.4 页面改变

假如 name 值发生改变,调用 d1 中存储的所有 watcher实例update 方法。

执行顺序从先到后。先通知 w1,再通知 w2;

Watcher.prototype.update = function update() {
  if (this.lazy) {
    this.dirty = true
  } else if (this.sync) {
    this.run()
  } else {
    queueWatcher(this)
  }
}

计算watcher 的update w1.update

//
if (this.lazy) {
  this.dirty = true
}

由于 w1 是计算属性定义的,则lazy 为 true,所以设置 w1.dirty = true 然后结束。

渲染watcher的 updataw2.update

// 渲染 watcher
queueWatcher(this)

queueWatcher(),会执行 渲染 watchergetter。因为模板中使用了 tomato 这个计算属性, 随后会触发 computedGetter

如果需要更新

  • w1.dirty 为true,会触发 watcher.evaluate(),更新计算属性的值。

如果没有更新

  • w1.dirty 为false,不会触发 watcher.evaluate(),计算属性的旧值。

总结

计算Watcher 和普通 Watcher 的区别:

看到别人总结的非常好,这里借鉴过来。

  1. 用 lazy 为 true 标示为它是一个计算Watcher
  2. 计算 Watcher 的 get 和 set 是在初始化(initComputed)时经过 defineComputed() 方法重写了的;
  3. 当它所依赖的属性发生改变时虽然也会调用 计算Watcher.update(),但是因为它的 lazy 属性为 true,所以只执行把 dirty 设置为 true 这一个操作,并不会像其它的 Watcher 一样执行 queueWatcher() 或者 run();
  4. 当有用到这个 计算Watcher 的时候,例如视图渲染时调用了它时,才会触发 计算Watcher 的 get,但又由于这个 get 在初始化时被重写了,其内部会判断 dirty 的值是否为 true 来决定是否需要执行 evaluate()重新计算;
  5. 因此才有了这么一句话:当计算属性所依赖的属性发生变化时并不会马上重新计算(只是将 dirty 设置为了 true 而已),而是要等到其它地方读取这个计算属性的时候(会触发重写的 get)时才重新计算,因此它具备懒计算特性;

其他需要注意的事项:

  1. computed 中不支持异步,watch 中支持;
  2. computed 中注重结果,watch 中注重过程;
  3. computed 中存在缓存,methods 中不存在缓存。;

个人总结

  1. computed 本质上也是通过 new Wathcer 来实现的;

  2. 用 lazy 为 true 标示为它是一个计算Watcher;

  3. 用 dirty 为 true 标示它是否需要重新计算;

  4. Watcher类 的形参 expression,可以接收字符串或者函数。方便自定义 watcher中的getter

  5. 依赖收集和通知更新的执行顺序很有意思,先 dirty 为 true,后重新求值。

  6. 计算Watcher更注重结果。

end

  • 加油!