computed 和 watch 有什么异同
相同点:
-
计算属性
和监听属性
, 本质上都是一个watcher实例, 它们都通过响应式系统与数据,页面建立通信. 不同点 -
计算属性
带有"懒计算"的功能, 为什么我不说是缓存呢? 后面你就知道了. -
监听的逻辑有差异. 这一点从使用时就特别明显,
监听属性
是目标值变了,它去执行函数.而计算属性
是函数的值变了, 它重新求值. -
页面刷新以后, 计算属性会默认立即执行, 而watch属性则需要我们自己配置
其实从表层来看, 计算属性和监听属性似乎就这么点差别了.但是你也明白, 这么回答面试官肯定是远远不够的. 所以我们要从源码层面, 去深究上述两点, 搞清楚他们的下一个不同点: "实现差异"
computed
简单的来说, 它的作用就是, 自动计算我们定义在函数内的"公式"
data() {
return {
a: 1,
b: 1
}
}
computed: {
total() {
return this.a + this.b
}
}
这样的场景, 想必你也非常熟, 只要 this.a
或者 this.b
的值发生变化, 这个total值就会变化. 我们这也是我们需要搞明白的.
实现
从这个函数名, 就可以看明白initComputed
, 这是初始化计算属性的函数. 它的就是遍历下我们定义的computed对象, 然后从中给每一个值定义一个watcher实例
.
watcher实例是响应式系统的中负责监听数据变化的角色. 如果你对Vue2的响应式系统不了解的话,建议你读一下这篇用大白话来聊聊Vue2响应式的原理.
计算属性执行的时候会访问到, this.a
和 this.b
. 这时候这两个值因为Data
初始化的时候就被定义成响应式数据了. 它们内部会有一个Dep实例
, Dep实例
就会把这个计算watcher
放到自己的sub数组
里. 待日后自己更新了, 就去通知数组内的watcher实例
更新.
const computedWatcherOptions = { lazy: true }
// vm: 组件实例 computed 组件内的 计算属性对象
function initComputed (vm: Component, computed: Object) {
// 遍历所有的计算属性
for (const key in computed) {
// 用户定义的 computed
const userDef = computed[key]
const getter = typeof userDef === 'function' ? userDef : userDef.get
watchers[key] = new Watcher( // 👈 这里
vm,
getter || noop,
noop,
computedWatcherOptions
)
defineComputed(vm, key, userDef)
}
computedWatcherOptions
, 传了{ lazy: true }
, 它意味着 watcher实例
在刚被创建的时候, 不会立即执行我们定义的计算属性函数, 这也是和监听属性不一样的地方, 我们后面再来看
到这里我们就明白了计算watcher实例
在计算属性执行流程的作用了, 也就是初始化的过程. 接下来我们来看计算属性是怎么执行的.
仔细看上方代码, 最下面有个defineComputed
, 他用来定义一个计算属性, 内部做了两件事情, 拿到我们定义的函数. (为了不让流程复杂, 我们只看DEMO的这种定义方式), 然后塞到Object.defineProperty
. 好让我们访问的时候, 去执行我们定义的函数.
export function defineComputed (
target: any,
key: string,
userDef: Object | Function
) {
sharedPropertyDefinition.get = computedGetter
// 当访问一次计算属性的key 就会触发一次 sharedPropertyDefinition
// 对computed 做了一次劫持
Object.defineProperty(target, key, sharedPropertyDefinition)
}
target
我们只需理解成this
即可. 也就是说我们每次使用计算属性, 就会执行一次computedGetter
就像我们DEMO中的, 我们 this.total
它就会执行我们定义的函数. 怎么实现的呢?
function computedGetter () {
// 拿到 上述 创建的 watcher 实例
const watcher = this._computedWatchers && this._computedWatchers[key]
if (watcher) {
// 首次执行的时候 dirty 基于 lazy 所以是true
if (watcher.dirty) {
// 这个方法会执行一次计算
// dirty 设置为 false
// 这个函数执行完毕后, 当前 计算watcher就会推出
watcher.evaluate()
}
// 如果当前激活的渲染watcher存在
if (Dep.target) {
/**
* evaluate后求值的同时, 如果当前 渲染watcher 存在,
* 则通知当前的收集了 计算watcher 的 dep 收集当前的 渲染watcher
*
* 为什么要这么做?
* 假设这个计算属性是在模板中被使用的, 并且渲染watcher没有被对应的dep收集
* 那派发更新的时候, 计算属性依赖的值发生改变, 而当前渲染watcher不被更新
* 就会出现, 页面中的计算属性值没有发生改变的情况.
*
* 本质上计算属性所依赖的dep, 也可以看做这个属性值本身的dep实例.
*/
watcher.depend()
}
return watcher.value
}
}
这个函数, 是计算属性实现的核心逻辑. 我加了很多备注, 希望对你有用. 我们先来回顾文章开头提到的第一个不同点.
计算属性带有"懒计算"的功能, 为什么我要这么说?. 关键就在于上述代码中的watcher.dirty
. 在计算watcher
实例化的时候. 一开始watcher.dirty
会被设置为true
. 显然上面的逻辑判断就能够走通了.
这时会执行watcher
的evaluate()
, 也就是求值. 这里的get, 你只需要简单的理解为我们定义的计算属性函数就可以了.
evaluate () {
this.value = this.get()
this.dirty = false
}
this.dirty
这时候就被变成false
🤔也就是说, 因为它是false, 以后这个函数执行, 就不会再执行这个函数了. Vue为什么要这么做? 当然是觉得, 它依赖的值没有变化, 就没有计算的必要啦
那我们就需要思考下一个问题了, dirty
什么时候又恢复成 true
, 显然就是需要重新计算的时候.
这里我们需要来看一下响应式系统的代码. 这里我们只需要看下set部分
的逻辑
set: function reactiveSetter (newVal) {
const value = getter ? getter.call(obj) : val
if (newVal === value || (newVal !== newVal && value !== value)) {
return
}
// 通知它的订阅者更新
dep.notify()
}
这段代码会做两件事情:
- 如果新值和旧值一样, 我们就无需做任何事情.
- 通知这个数据下的订阅者, 也就是watcher实例更新.
notify
方法就是遍历一下, 它的数组, 然后执行数组里每个watcher
的update
方法
update () {
/* istanbul ignore else */
if (this.lazy) {
// 假设当前 发布者 通知 值被重新 set
// 则把 dirty 设置为 true 当computed 被使用的时候 就可以重新调用计算
// 渲染wacher 执行完毕 堆出后, 会轮到当前的渲染watcher执行update
// 此时就会去执行queueWatcher(this), 再重新执行 组件渲染时候
// 会用到计算属性, 在这时因为 dirty 为 true 所以能重新求值
// dirty就像一个阀门, 用于判断是否应该重新计算
this.dirty = true
}
}
就是在这里, dirty
被重新设置为了true
. 我们来总结一下关于dirty
的具体流程
首先, 一开始dirty
为true
, 一旦执行了一次计算,就会设置为false
. 然后当它定义的函数内部依赖的值比如: this.a
和 this.b
发声了变化. 这个值就会重新变为true
.
现在我们就明白 dirty
的作用了. 他就是用来记录我们依赖的值有没有变, 如果变了就重新计算一下值, 如果没变, 那就返回以前的值. 就像一个懒加载的理念. 这也是计算属性缓存的一种方式, 是不是实现逻辑完全跟缓存搭不着边?
到这里你可能会觉得奇怪. 直到这里我们好像只是一直在让dirty
变成true | false
, 完全没有涉及到计算属性函数的执行呀?
那接下来我们就来看看在什么时候, 计算属性会被执行, 我们回过头重新看看computedGetter
函数
function computedGetter () {
// 拿到 上述 创建的 watcher 实例
const watcher = this._computedWatchers && this._computedWatchers[key]
if (watcher) {
// 首次执行的时候 dirty 基于 lazy 所以是true
if (watcher.dirty) {
// 这个方法会执行一次计算
// dirty 设置为 false
// 这个函数执行完毕后, 当前 计算watcher就会推出
watcher.evaluate()
}
// 如果当前激活的渲染watcher存在
if (Dep.target) {
/**
* evaluate后求值的同时, 如果当前 渲染watcher 存在,
* 则通知当前的收集了 计算watcher 的 dep 收集当前的 渲染watcher
*
* 为什么要这么做?
* 假设这个计算属性是在模板中被使用的, 并且渲染watcher没有被对应的dep收集
* 那派发更新的时候, 计算属性依赖的值发生改变, 而当前渲染watcher不被更新
* 就会出现, 页面中的计算属性值没有发生改变的情况.
*
* 本质上计算属性所依赖的dep, 也可以看做这个属性值本身的dep实例.
*/
watcher.depend()
}
return watcher.value
}
}
这里有一段 Dep.target
的判断逻辑. 这是什么意思呢. Dep.target
是当前正在渲染组件
. 它代指的是你定义的组件, 它也是一个watcher
, 我们一般称之为渲染watcher
.
计算属性watcher
, 被通知更新的时候, 会改变dirty
的值. 而渲染watcher
被通知更新的时候, 它就会更新一次页面.
显然我们现在的问题是, 计算属性的dirty
重新变为ture
了, 怎么让页面知道现在要重新刷新了呢?
通过watcher.depend()
这个方法会通知当前数据的Dep实例
去收集我们的渲染watcher
. 将其收集起来.当数据发生变化的时候, 首先通知计算watcher
更改drity
值, 然后通知渲染watcher
更新页面. 渲染watcher更新
页面的时候, 如果在页面的HTML结果中我们用到了total
这个属性. 就会触发它对应的computedGetter
方法. 也就是执行上面这部分代码. 这时候drity
为ture
, 就能如期执行watcher.evaluate()
方法了
那它重新收集这个渲染watcher
吗? 不会的放心吧. 具体怎么处理就留给你自己去看了.
直到这里我们就看完了computed的逻辑了, 好像也不难对吧? 我们来总结一下:
-
computed属性
的缓存功能, 实际上是通过一个dirty
字段作为节流阀实现的, 如果需要重新求值, 阀门就打开, 否则就一直返回原先的值, 而无需重新计算. -
computed属性
和组件一样, 本质上都是一个watcher
实例.
watch
watch相对要简单很多了, 在这里我们略过watch属性所有的配置, 仅去考虑他的基本功能
先来看看Demo, 这是一个最简单的例子, 只要count
在任何时候下发生变化,handler函数
就会被执行.这也是我们目前需要思考的问题.
data() {
return {
count: 0
}
},
watch: {
count: {
hanlder(){
console.log('count changed')
}
}
}
我们直接看源码, 在初始化状态的时候, 有一个initWatch函数, 它负责初始化我们的监听属性
实现
// src/core/instance/state.js
function initWatch (vm: Component, watch: Object) {
// 遍历我们定义的wathcer
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对象
中定义的count对象
, 然后拿到handler
值, 从上面的代码中我们也可以看出来, 实际上handler
是一个数组也是ok的.
我们继续看createWatcher函数
, 它会解析出我们的配置, 然后调用$watch
实现监听. 实际上我们也可以通过这个方法, 函数式实现监听
function createWatcher (
vm: Component,
expOrFn: string | Function,
handler: any,
options?: Object
) {
if (isPlainObject(handler)) {
options = handler
handler = handler.handler
}
if (typeof handler === 'string') {
handler = vm[handler]
}
return vm.$watch(expOrFn, handler, options)
}
$watch函数
是Vue实例原型上的一个方法, 这也是为什么我们可以通过this的形式去调用它的原因
Vue.prototype.$watch = function (
expOrFn: string | Function, // 这个可以是 key
cb: any, // 待执行的函数
options?: Object // 一些配置
): Function {
const vm: Component = this
// 创建一个 watcher 此时的 expOrFn 是监听对象
const watcher = new Watcher(vm, expOrFn, cb, options)
return function unwatchFn () {
watcher.teardown()
}
}
从代码可以看出来, 实际上$watch
属性, 就实例化了一个watcher对象
, 然后通过这个watcher
实现了监听,这也是和计算属性一样的地方.
既然它也是watcherh实例, 那本质上都是通过Vue的响应式系统实现的监听. 那我们要考虑的就是count
的Dep实例
, 是什么时候收集了这个watcher实例
的
我们先来看一下实例化的时候, 传给watcher构造函数的几个参数.
vm
是组件实例, 也就是我们常用的this
expOrFn
是在我们的Demo
中就是count
, 也就是被监听的属性cb
就是我们的handler函数
if (typeof expOrFn === 'function') {
this.getter = expOrFn
} else {
// 如果是一个字符则转为一个 一个 getter 函数
// 这里这么做是为了通过 this.[watcherKey] 的形式
// 能够触发 被监听属性的 依赖收集
this.getter = parsePath(expOrFn)
if (!this.getter) {
this.getter = noop
process.env.NODE_ENV !== 'production' && 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()
这是watcher实例化
的时候, 会默认执行的一串代码, 记得我们在computed实例化的时候传入的函数吗, 也是expOrFn
. 如果是一个函数会被直接赋予. 如果是一个字符串. 则parsePath
通过创建为一个函数. 大家不需要关注这个函数的行为, 它内部就是执行一次this.[expOrFn]
. 也就是this.total
最后, 因为lazy
是false
. 这个值只有计算属性的时候才会被传true
.所以首次会执行this.get()
. get
里面则是执行一次getter()
触发响应式
到这里监听属性的初始化逻辑就算是完成了, 但是在数据更新的时候, 监听属性的触发还有与计算属性不一样的地方.
监听属性是异步触发的, 为什么这么说呢?
- 实际上监听属性的执行逻辑和组件的渲染是一样的. 它们都会被放到一个
nextTick
函数中, 没错就是我们熟悉的API.它可以让我们的同步逻辑, 放到下一个Tick在执行.
如果你不懂nextTick的逻辑, 没关系. 欢迎关注我, 我将会在下篇文章中着重介绍这个API
总结
🤭我相信你看到这里, 也知道面试应该怎么回答这个问题了.这里我们再来总结一下. (这里我就不提应用层面的差异了, 你比我还懂...)
相同点:
-
计算属性和监听属性以及组件实例, 本质上都是一个Watcher实例.只是行为不同.
-
计算属性和监听属性对于新值与旧值一样的赋值操作, 都不会做任何变化. 但这点的实现是由响应式系统完成的
不同点:
-
计算属性具有"懒计算"功能, 只有依赖的值变化了, 才允许重新计算. 称为"缓存", 个人觉得不准确
-
在数据更新时, 计算属性的dirty状态会立即改变, 而监听属性与组件重新渲染, 至少都会在下一个"tick"执行.
感谢😘
如果觉得文章内容对你有帮助:
-
❤️欢迎关注点赞哦! 我会尽最大努力产出高质量的文章
个人公众号: 前端Link
联系作者: linkcyd 😁
往期: