技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTVUE

Vue.js的computed和watch是如何工作的?

所属馆藏
Vue
文件格式
Markdown
原始路径
Vue/11-Vue js的computed和watch是如何工作的?
本文目录8 个章节
  1. computed和watch定义
  2. computed和watch用法异同
  3. watch的高级用法
  4. computed 的本质——Vue 2 源码中的 computed watcher
  5. Vue 2.6.14 的稳定 computed 实现
  6. watch底层是如何工作的?
  7. Vue 3 当前说法:computed、watch 与 watchEffect
  8. 官方参考

Vue.js的computed和watch是如何工作的?

Category(分类): Vue Status: 已核对

版本说明:原文的用法与源码主线基于 Vue 2。本文保留这份长篇源码走读,并紧邻标注稳定版本与历史提交;后文另补 Vue 3 当前实现。不要把 Vue 2 的 _computedWatchersWatcher 结构直接套到 Vue 3。

Vue的组件对象支持计算属性computed和侦听属性watch两个选项,但这两个属性用法有什么异同以及它们底层实现的原理是什么?本文将用例子结合源码来进行总结。

本文将从以下六个模块进行总结:

  • computed和watch定义
  • computed和watch用法异同
  • watch的高级用法
  • computed的本质 —— computed watch
  • watch底层是如何工作的?
  • 总结

computed和watch定义

1.computed是计算属性,类似于过滤器,对绑定到视图的数据进行处理,并监听变化进而执行对应的方法,对这部分不太明白的话可以看一下我的另一篇文章Vue.js的响应式系统原理。官网的例子:

<div id="example">
  <p>Original message: "{{ message }}"</p>
  <p>Computed reversed message: "{{ reversedMessage }}"</p>
</div>
var vm = new Vue({
  el: '#example',
  data: {
    message: 'Hello'
  },
  computed: {
    // 计算属性的 getter
    reversedMessage: function () {
      // `this` 指向 vm 实例
      return this.message.split('').reverse().join('')
    }
  }
})

结果:

Original message: "Hello"
Computed reversed message: "olleH"

计算属性是基于它们的依赖进行缓存的。只在相关依赖发生改变时它们才会重新求值。值得注意的是“reversedMessage”不能在组件的props和data中定义,否则会报错。

2.watch是一个侦听的动作,用来观察和响应 Vue 实例上的数据变动。官网上的例子:

<div id="watch-example">
  <p>
    Ask a yes/no question:
    <input v-model="question">
  </p>
  <p>{{ answer }}</p>
</div>
<!-- 因为 AJAX 库和通用工具的生态已经相当丰富,Vue 核心代码没有重复提供这些功能。 -->
<!-- 这也可以让你自由选择自己更熟悉的工具。 -->
<script src="https://cdn.jsdelivr.net/npm/axios@0.12.0/dist/axios.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/lodash@4.13.1/lodash.min.js"></script>
<script>
var watchExampleVM = new Vue({
  el: '#watch-example',
  data: {
    question: '',
    answer: 'I cannot give you an answer until you ask a question!'
  },
  watch: {
    // 如果 `question` 发生改变,这个函数就会运行
    question: function (newQuestion, oldQuestion) {
      this.answer = 'Waiting for you to stop typing...'
      this.debouncedGetAnswer()
    }
  },
  created: function () {
    // `_.debounce` 是一个通过 Lodash 限制操作频率的函数。
    // 在这个例子中,我们希望限制访问 yesno.wtf/api 的频率,
    // 直到用户输入完毕才发出 AJAX 请求。
    // 参考:https://lodash.com/docs#debounce
    this.debouncedGetAnswer = _.debounce(this.getAnswer, 500)
  },
  methods: {
    getAnswer: function () {
      if (this.question.indexOf('?') === -1) {
        this.answer = 'Questions usually contain a question mark. ;-)'
        return
      }

      this.answer = 'Thinking...'
      var vm = this

      axios.get('https://yesno.wtf/api')
        .then(function (response) {
          vm.answer = _.capitalize(response.data.answer)
        })
        .catch(function (error) {
          vm.answer = 'Error! Could not reach the API. ' + error
        })
    }
  }
})
</script>

在这个示例中,使用 watch 选项允许我们执行异步操作 (访问一个 API),限制我们执行该操作的频率,并在我们得到最终结果前,设置中间状态。这些都是计算属性无法做到的。

computed和watch用法异同

下面来总结下这两者用法的异同:

相同:两者都会依赖响应式数据,并在依赖变化时参与更新。

不同:更准确的用途定义是:computed 是有缓存的纯派生值;watch 是针对显式 source 的副作用回调。计算属性 getter 应保持纯粹,不要修改其他状态、请求接口或操作 DOM;这些副作用应交给 watch / watchEffect。派生状态优先使用 computed,不是因为 watch 必然“多调用”,而是声明式关系更容易理解和维护。

watch的高级用法

1.handler方法和immediate属性

<div id="demo">{{ fullName }}</div>
var vm = new Vue({
  el: '#demo',
  data: {
    firstName: 'Foo',
    lastName: 'Bar',
    fullName: 'Foo Bar'
  },
  watch: {
    firstName: function (val) {
      console.log('第一次没有执行~')
      this.fullName = val + ' ' + this.lastName
    }
  }
})

可以看到,初始化的时候watch是不会执行的。看上边的例子,只要当firstName的值改变的时候才会执行监听计算。但如果想在第一次它在被绑定的时候就执行怎么办?这时候就要修改一下我们的例子:

watch: {
  firstName: {
    handler(val) {
      console.log('第一次执行了~')
      this.fullName = val + ' ' + this.lastName
    },
    // 声明 firstName 的 watcher 后立即执行 handler
    immediate: true
  }
}

打开控制台可以看到打印出了‘第一次执行了~’。注意到handler了吗,我们给 firstName 绑定了一个handler方法,之前我们写的 watch 方法其实默认写的就是这个handler,Vue.js会去处理这个逻辑,最终编译出来其实就是这个handler。

而immediate:true代表如果在 watch 里声明了 firstName 之后,就会立即先去执行里面的handler方法,如果为 false就跟我们以前的效果一样,不会在绑定的时候就执行。为什么加上handler方法和immediate:true就能在绑定的时候第一次就执行呢?待会儿在分析源码的时候就能理解了。

2.deep属性

watch里还有一个deep属性,代表是否开启深度监听,默认为false,下面来看一个例子:

<div id="app">
  <div>obj.a: {{ obj.a }}</div>
  <input type="text" v-model="obj.a">
</div>
var vm = new Vue({
  el: '#app',
  data: {
    obj: {
      a: 1
    }
  },
  watch: {
    obj: {
      handler(val) {
        console.log('obj.a changed')
      },
      immediate: true
    }
  }
})

当我们在 input 中修改已经存在的 obj.a 时,默认监听 obj 引用的 watcher 不会执行,原因是没有启用深度监听,而不是 obj.a 无法响应。另一个需要分开的历史限制是:Vue 2 基于 Object.defineProperty,不能自动检测对象属性的新增/删除,需要 Vue.set / vm.$set;Vue 3 的 Proxy 响应式系统没有这一限制。

默认情况下 在handler方法中 只监听obj这个属性它的引用的变化,我们只有给obj赋值的时候它才会监听到,比如我们在 mounted事件钩子函数中对obj进行重新赋值:

mounted() {
  this.obj = {
    a: '123'
  }
}

这样handler就会执行了,且打印出了'obj.a changed'。

但是我们如果需要监听obj里的属性值呢?这时候,deep属性就派上用场了。我们只需要加上deep:true,就能深度监听obj里属性值。

watch: {
  obj: {
    handler(val) {
      console.log('obj.a changed')
    },
    immediate: true,
    deep: true
  }
}

deep 的意思是递归访问嵌套响应式值,触发已有 getter 并收集深层依赖;它不是在每一层新建一个 watcher。在源码中的体现,定义在src/core/observer/traverse.js中:

/* @flow */
import { _Set as Set, isObject } from '../util/index'
import type { SimpleSet } from '../util/index'
import VNode from '../vdom/vnode'

const seenObjects = new Set()

/**
 * Recursively traverse an object to evoke all converted
 * getters, so that every nested property inside the object
 * is collected as a "deep" dependency.
 */
export function traverse (val: any) {
  _traverse(val, seenObjects)
  seenObjects.clear()
}

function _traverse (val: any, seen: SimpleSet) {
  let i, keys
  const isA = Array.isArray(val)
  if ((!isA && !isObject(val)) || Object.isFrozen(val) || val instanceof VNode) {
    return
  }
  if (val.__ob__) {
    const depId = val.__ob__.dep.id
    if (seen.has(depId)) {
      return
    }
    seen.add(depId)
  }
  if (isA) {
    i = val.length
    while (i--) _traverse(val[i], seen)
  } else {
    keys = Object.keys(val)
    i = keys.length
    while (i--) _traverse(val[keys[i]], seen)
  }
}

如果this.deep == true,即存在deep,则触发每个深层对象的依赖,追踪其变化。traverse方法递归每一个对象或者数组,触发它们的getter,使得对象或数组的每一个成员都被依赖收集,形成一个“深(deep)”依赖关系。这个函数实现还有一个小的优化,遍历过程中会把子响应式对象通过它们的 dep.id 记录到 seenObjects,避免以后重复访问。

但是 deep 会遍历整个对象图,大对象的访问和依赖收集成本可能很高。深层属性原地变化时,回调中的新旧值通常还是同一个对象引用;Vue 不会自动保存旧快照。这样我们可以用字符串的形式来优化:

watch: {
  'obj.a': {
    handler(val) {
      console.log('obj.a changed')
    },
    immediate: true
    // deep: true
  }
}

直到遇到'obj.a'属性,才会给该属性设置监听函数,提高性能。

computed 的本质——Vue 2 源码中的 computed watcher

重要历史标注:下面 computedWatcherOptions = { computed: true }Watcher.computed、自有 depgetAndInvoke 片段来自 Vue 仓库 2018-03-23 的短暂开发提交 653aac2,随后被 6b1d431 revert。保留它是为了呈现原作者的源码推理,但它不是 Vue 2.6.14 的发布实现,也不是 Vue 3 实现。

我们知道new Vue()的时候会调用_init方法,该方法会初始化生命周期,初始化事件,初始化render,初始化data,computed,methods,watcher等等。对这部分不太明白的话可以参考我写的另外一篇文章:Vue.js源码角度:剖析模版和数据渲染成最终的DOM的过程。今天主要来看以下初始化watch(initWatch)的实现,我加上了注释方便理解,定义在src/core/instance/state.js中:

// state.js:历史提交 653aac2,已被 revert
const computedWatcherOptions = { computed: true }

function initComputed(vm, computed) {
  const watchers = vm._computedWatchers = Object.create(null)
  const isSSR = isServerRendering()

  for (const key in computed) {
    const userDef = computed[key]
    const getter = typeof userDef === 'function' ? userDef : userDef.get

    if (!isSSR) {
      watchers[key] = new Watcher(vm, getter || noop, noop, computedWatcherOptions)
    }

    if (!(key in vm)) defineComputed(vm, key, userDef)
  }
}

通过源码我们可以发现它先声明了一个名为watchers的空对象,同时在vm上也挂载了这个空对象。之后遍历计算属性,并把每个属性的方法赋给userDef,如果userDef是function的话就赋给getter,接着判断是否是服务端渲染,如果不是的话就创建一个Watcher实例。不过需要注意的是,这里新建的实例中我们传入了第四个参数,也就是computedWatcherOptions。const computedWatcherOptions = { computed: true },这个对象是实现computed watcher的关键。这时,Watcher中的逻辑就有变化了:

// watcher.js:同一历史提交中的选项
if (options) {
  this.deep = !!options.deep
  this.user = !!options.user
  this.computed = !!options.computed
  this.sync = !!options.sync
  this.before = options.before
} else {
  this.deep = this.user = this.computed = this.sync = false
}
this.dirty = this.computed

这里传入的options就是上边定义的computedWatcherOptions,当走initData方法的时候,options并不存在,但当走到initComputed的时候,computedWatcherOptions中的computed为true,注意上边的一行代码this.dirty = this.computed,将this.computed赋值给this.dirty。接着看下边的代码:

evaluate() {
  if (this.dirty) {
    this.value = this.get()
    this.dirty = false
  }
  return this.value
}

只有this.dirty为true的时候才能通过 this.get() 求值,然后把 this.dirty 设置为 false。在求值过程中,会执行 value = this.getter.call(vm, vm),这实际上就是执行了计算属性定义的 getter 函数,否则直接返回value。

当对计算属性依赖的数据做修改的时候,会触发 setter 过程,通知所有订阅它变化的 watcher 更新,执行 watcher.update() 方法:

update() {
  if (this.computed) {
    if (this.dep.subs.length === 0) {
      // lazy:等下次读取时再计算
      this.dirty = true
    } else {
      // activated:值确实变化时再通知订阅者
      this.getAndInvoke(() => this.dep.notify())
    }
  } else if (this.sync) {
    this.run()
  } else {
    queueWatcher(this)
  }
}

对于这个历史提交中的 computed watcher,它有 lazy 和 activated 两种模式。如果 this.dep.subs.length === 0,说明没有订阅者,此时只把 this.dirty 设为 true,等下次访问计算属性时再求值;否则会执行 getAndInvoke

getAndInvoke(cb) {
  const value = this.get()
  if (value !== this.value || isObject(value) || this.deep) {
    const oldValue = this.value
    this.value = value
    this.dirty = false
    cb.call(this.vm, value, oldValue)
  }
}

getAndInvoke 会重新计算并比较新旧值;当新旧值不相等、值是对象/数组或启用了深度监听时执行回调。这里的回调是 this.dep.notify(),会通知相关渲染 watcher。这段历史实现体现了“计算属性基于响应式依赖缓存”的思路,但并非 Vue 2.6.14 的最终实现。

Vue 2.6.14 的稳定 computed 实现

Vue 2.6.14 在 state.js 使用的是:

const computedWatcherOptions = { lazy: true }
watchers[key] = new Watcher(vm, getter || noop, noop, computedWatcherOptions)

Watcherlazydirty 表示计算属性尚需求值。读取计算属性时,evaluate() 执行 getter 并把 dirty 设为 false;依赖更新时 update() 对 lazy watcher 只把 dirty 重新设为 true。渲染 watcher 读取计算属性时,depend() 让外层 watcher 订阅其底层依赖。这样既能按需计算,也能基于响应式依赖缓存。可核对 Vue 2.6.14 state.jswatcher.js

watch底层是如何工作的?

上边提到了在new Vue()的时候调用了_init方法完成了初始化。在这当中有调用了initWatch方法,定义在src/core/instance/state.js中:

function initWatch(vm, watch) {
  for (const key in watch) {
    const handler = watch[key]
    if (Array.isArray(handler)) {
      for (let i = 0; i < handler.length; i++) {
        createWatcher(vm, key, handler[i])
      }
    } else {
      createWatcher(vm, key, handler)
    }
  }
}

遍历watch对象,并将每个watchkey赋值给handler,如果是数组则遍历调用 createWatcher 方法,否则直接调用createWatcher方法。接下来看一下createWatcher方法的定义:

function createWatcher(vm, expOrFn, handler, options) {
  if (isPlainObject(handler)) {
    options = handler
    handler = handler.handler
  }
  if (typeof handler === 'string') handler = vm[handler]
  return vm.$watch(expOrFn, handler, options)
}

通过代码可以发现,createWatcher方法vm.$watch(keyOrFn, handler, options) 函数,调用了Vue.prototype.$watch方法,定义在src/core/instance/state.js中:

Vue.prototype.$watch = function (expOrFn, cb, options) {
  const vm = this
  if (isPlainObject(cb)) return createWatcher(vm, expOrFn, cb, options)

  options = options || {}
  options.user = true
  const watcher = new Watcher(vm, expOrFn, cb, options)
  if (options.immediate) cb.call(vm, watcher.value)

  return function unwatchFn() {
    watcher.teardown()
  }
}

通过代码可以看到,watch 最终调用 Vue.prototype.$watch。如果回调是配置对象,会先交给 createWatcher 规范化;随后创建一个 Watcher,并通过 options.user = true 标记为用户 watcher。依赖变化后,watcher 的 run 方法会执行回调 cb;设置 immediate: true 时,创建后立即以当前值调用一次回调。最后返回的 unwatchFn 会调用 teardown 移除该 watcher。

所以watcher是如何工作的?本质上也是基于 Watcher 实现的,它是一个 user watcher。前面提到了计算属性computed本质上是一个computed watcher。

Vue 3 当前说法:computed、watch 与 watchEffect

Vue 3 不再使用 Vue 2 的 _computedWatchers。当前 computed() 返回由 ComputedRefImpl 表示的 ref,通过自身依赖、脏检查与 refreshComputed 缓存派生值;watch / watchEffect 则基于 ReactiveEffect 和调度器运行副作用。内部实现会变化,公共语义比类名更重要。

<script setup>
import { computed, ref, watch, watchEffect } from 'vue'

const firstName = ref('Ada')
const lastName = ref('Lovelace')

// 有缓存的纯派生值,不做请求或状态写入
const fullName = computed(() => `${firstName.value} ${lastName.value}`)

// 显式 source,默认惰性执行,能取得新旧值
const stopWatch = watch(firstName, (value, oldValue) => {
  console.log(value, oldValue)
})

// 立即执行,并自动跟踪同步执行期间读取的依赖
const stopEffect = watchEffect(() => {
  console.log(fullName.value)
})

// 不再需要时可手动停止
stopWatch()
stopEffect()
</script>

watchEffect 没有 lazy 选项,也不是 computed 与 watch 的合并:它不返回缓存派生值,没有显式 source 和新旧值;异步回调只追踪第一个 await 之前读取的依赖。需要默认懒执行用 watch,需要派生值用 computed

deep、新旧值与 Vue 2 限制

  • Vue 3 watch 默认是浅的;直接监听 reactive 对象时会隐式深度监听,getter source 可用 { deep: true }。Vue 3.5+ 还允许 deep 使用数字限制最大遍历深度。
  • 深层属性原地变化但对象未替换时,newValue === oldValue;需要前后快照应自行复制,或只监听具体 getter。
  • “新增/删除属性无法检测、属性必须预先出现在 data”是 Vue 2 Object.defineProperty 的历史限制;Vue 2 可用 Vue.set / vm.$set。Vue 3 使用 Proxy,不应沿用该结论。
  • immediate: true 的首次调用中,Vue 3 的 oldValueundefined

调度、单次侦听与清理

  • 默认 flush: 'pre':批量调度,在所属组件 DOM 更新前调用。读取更新后的 DOM 用 flush: 'post'flush: 'sync' 不批处理,应谨慎用于高频数据。
  • Vue 3.4+ 支持 once: true,在 source 首次变化后自动停止。
  • watcher 回调第三参数 onCleanup 可取消过期请求。Vue 3.5+ 的 onWatcherCleanup() 必须在同步执行阶段、await 之前注册。
import { onWatcherCleanup, watch } from 'vue'

watch(id, async (newId) => {
  const controller = new AbortController()
  onWatcherCleanup(() => controller.abort()) // Vue 3.5+,必须在 await 前
  const response = await fetch(`/api/items/${newId}`, { signal: controller.signal })
  data.value = await response.json()
}, { flush: 'post', once: false })

官方参考

总结

通过以上的分析,深入理解了计算属性computed和侦听属性watch是如何工作的。在 Vue 2 的实现语境里,计算属性使用 lazy computed watcher,侦听属性使用 user watcher。computed 适合声明无副作用的缓存派生值,watch / watchEffect 适合响应变化并执行异步请求、日志或外部系统同步等副作用。能用 computed 表达派生状态时应优先使用 computed

作者:慕晨同学链接:https://juejin.cn/post/6844903667884097543来源:稀土掘金著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS