Skip to content
目录

Vue源码分析之计算属性与侦听函数

上一节我们从头到尾分析了vue的响应式原理(包括vue3),接下来趁热打铁分析下computed、watch的实现原理。如果你对vue的响应式原理还很模糊,建议先阅读上一篇搞明白响应式原理再来看本篇文章,当然computed、watch是对响应式的扩展,响应式懂了这里才不会迷茫

还是一样如果你对于某些原理不是很明白时,学会自己打断点调试,用最简单的例子多分析几遍有助于理解

计算属性

计算属性顾名思义就是由计算得来的值,在vue中通常用来使用需要通过简单计算后的属性,引用vue官方描述:模板内的表达式非常便利,但是设计它们的初衷是用于简单运算的,在模板中放入太多的逻辑会让模板过重且难以维护

简单使用

vue
<template>
  <div>{{ name }}</div>
  <div>{{ age }}</div>
  <div>{{ `我的名字:${this.name},今年 ${this.age} 岁` }}</div> 
  <div>{{ intro }}</div> 
</template>
<script>
export default {
  data: () => ({
    name: "Jack",
    age: 10
  }),
  computed: {
    intro() {
      return `我的名字:${this.name},今年 ${this.age}`
    }
  }
}
</script>

上面便是一个最简单的计算属性使用例子,通过计算得到的一个值,使用计算属性模板更易维护也方便后续的扩展

计算属性缓存

应该都听说过计算属性有缓存吧,也就是当计算属性依赖的data值没有发生变化时,是不会重新执行计算属性的函数的,那在页面渲染时也就只会执行一次,我们使用上面的例子简单来验证下:

vue
<template>
  <!-- 模板中使用3个计算属性 -->
  <div>{{ intro }}</div> 
  <div>{{ intro }}</div> 
  <div>{{ intro }}</div> 
</template>
<script>
export default {
  // 省略...
  computed: {
    intro() {
      console.log("执行了intro函数"); // 看这里会打印几次
      return `我的名字:${this.name},今年 ${this.age}`;
    }
  }
}
</script>

从以上结果可以看到intro函数只打印了一次,也就是说在模板渲染中对intro函数的执行只触发了一次,这也就是所谓的计算属性缓存,如果同样的函数定义在方法体中就会执行多遍,严重影响性能

看上去还挺神奇的,接下来就一探究竟👇

计算属性本质

我们知道vue中存在3种watcher分别是:render watchercomputed watcheruser watcher,watcher主要用来执行对应的表达式,而watcher又是被dep控制的,dep大家应该都知道在对data进行getter/setter拦截时所附加的一个对象,用来收集相关联的watcher对象,当触发setter时便通知watcher进行更新,watcher也就会执行对应的表达式进行更新,这就是响应式的过程

那么计算属性其实本质就是一个watcher,由于定义在computed中又被称为计算wathcer。其实通过响应式过程你大概已经知道了计算属性缓存的原因了,大概就是watcher的更新只被dep控制,只要dep所属的属性值没变就不会通知watcher进行更新,所以在模板渲染时并不会触发计算属性的多次执行,因为computed对应的data值没变,这就是原理

vue内部会统一把computed变成对象形式包含get/set两个函数,如上面的写法其实内部会变成:

ts
intro = {
  get() {
    return `我的名字:${this.name},今年 ${this.age}`;
  },
  set: noop // 空函数
}

通常计算属性的set使用也不多也不推荐使用set,一般一个计算属性一个函数就行了

下面通过一张图来加强理解计算属性的工作过程:

上图清晰的展示了只有data改变时便会通过dep通知计算watcher进行更新也就会执行计算属性的get函数,模板渲染时并不会触发计算属性的多次执行。上图也反映了一个问题:当data改变时只会通知计算watcher进行更新,而计算watcher并不会通知渲染watcher更新(只有dep会收集watcher,watcher不会收集watcher),那么渲染watcher又是如何知道需要重新渲染的呢?你是否也有一样的疑问呢❓

通过这个疑问并根据响应式原理应该能推出dep肯定和渲染watcher也会有关系的,就是这个关系是怎么建立起来的❓从响应式原理中我们知道dep会通过Dep.watcher这个属性值和watcher产生关系也就是依赖收集,而当计算watcher表达式执行时,当前的Dep.watcher也只会是计算watcher的get函数,并不会是渲染watcher,dep也就不会收集到渲染watcher,那么dep到底是如何收集到渲染watcher的,接下来带着这个疑问进一步了解下

原理实现

  1. 组件初始化时对computed属性进行初始化:
ts
// src/core/instance/state 58行
export function initState(vm: Component) {
  vm._watchers = [];
  const opts = vm.$options;
  // 初始化computed
  if (opts.computed) initComputed(vm, opts.computed);
}
  1. 遍历computed为每一个属性生成对应的watcher(计算watcher):
ts
// src/core/instance/state 137行
function initComputed(vm: Component, computed: Object) {
	// 组件实例上定义 _computedWatchers 属性用来存放所有的computedwatcher
  const watchers = (vm._computedWatchers = Object.create(null));

  // 遍历 computed,生成对应的watcher
  for (const key in computed) {
    const userDef = computed[key];
    // 如果是function就直接取值,反之对象取 get 属性
    const getter = typeof userDef === "function" ? userDef : userDef.get;

    // 定义当前 key 的watcher, 也就是计算属性watcher
    watchers[key] = new Watcher(
      vm,
      getter || noop,  // 这里看到 表达式就是 我们定义的函数或者get函数
      noop,
      { lazy: true }   // 注意这个选项很重要,用来标识这个是计算属性watcher(记住这个,后面会用到)
    );

    // 代理每个computed属性到实例上,这就是为什么 this可以获取到计算属性的原因
    if (!(key in vm)) {
      defineComputed(vm, key, userDef);
    }
  }
}
  1. 来看看计算watcher实例化时会怎么样,初始化时由于opts传入lazy,所以不会立即执行函数:
ts
// src/core/observer/watcher
export default class Watcher {
  vm: Component;
  lazy: boolean;
  value: any;

  // 这里的expOrFn就是计算属性的getter
  // cb为空,opts 为 {lazy: true}
  constructor(vm: Component, expOrFn: string | Function, cb: Function, opts) {
    this.vm = vm;
    if (options) {
    	// lazy: true
      this.lazy = !!options.lazy;
    }
    // 注意这个 dirty 很重要,来缓存计算属性的值,首先进来为 true
    this.dirty = this.lazy;
    if (typeof expOrFn === "function") {
      // getter 变成了 计算属性的get函数
      this.getter = expOrFn;
    }
    // 由于lazy为true,外面初始化时也就不会执行函数
    this.value = this.lazy ? undefined : this.get();
  }
}
  1. 接着第2步骤最后代码,代理computed中的每个key到this实例上,这个步骤很关键也是重点。上一步骤我们知道了computed在初始化watcher时并不会立马执行,那么当组件执行render函数的过程中就会访问computed属性(假设模板中用到了computed属性),这时就会触发下方的getter函数
ts
const sharedPropertyDefinition = {
  enumerable: true,
  configurable: true,
  get: noop,
  set: noop,
};
export function defineComputed(
  target: any,
  key: string,
  userDef: Object | Function
) {
  // 值为函数时
  if (typeof userDef === "function") {
    sharedPropertyDefinition.get = createComputedGetter(key);
    sharedPropertyDefinition.set = noop;
  } else { // 值为对象时
    sharedPropertyDefinition.get = userDef.get
      ? userDef.cache !== false
        ? createComputedGetter(key)
        : createGetterInvoker(userDef.get)
      : noop;
    // 如果有set函数,没有设置noop 空函数
    sharedPropertyDefinition.set = userDef.set || noop;
  }
  // 设置描述对象,对key进行getter/setter拦截,也就是访问 target[key] 时,就会访问下方的getter函数
  Object.defineProperty(target, key, sharedPropertyDefinition);
}

// this实例上获取 computed时,就会触发这里的get
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;
    }
  };
}

首先通过this拿到当前computed属性的watcher,刚开始dirty的值时true,那么就会执行watcher.evaluate(),内部会执行watcher的get方法,get中又会执行getter方法,从第3步应该知道this.getter就是计算属性的函数。在执行计算属性函数过程中就会对data进行访问触发响应式对象对应的dep的depend对当前watcher进行收集,也就是会收集当前计算属性的watcher,最后计算的值赋值给当前watcher的value,这时将dirty设置为false。然后下一步判断Dep.target为true时执行watcher.depend,当前计算属性函数执行完后就会将Dep.target变成上一个watcher,这里就假设为渲染watcher,那么来看看watcher的depend干了啥

ts
export default class Watcher {
  vm: Component;
  lazy: boolean;
  value: any;

  // 省略部分代码...

  get() {
  	// 设置Dep.target为当前计算属性的watcher
    pushTarget(this);
    let value;
    const vm = this.vm;
    // 执行计算属性 函数,此过程会访问响应式对应,就会触发get进行dep进行depend当前watcher(依赖收集)
    value = this.getter.call(vm, vm);
    popTarget(); // 出栈
    return value;
  }

  // dirty为true时执行
  evaluate() {
    this.value = this.get();
    this.dirty = false;
  }
}

depend中会遍历所有的deps(也就是收集了当前计算watcher的所有dep,也就是某些对象等key的dep),执行每个dep的depend方法,主动让这些响应式对象对当前的Dep.target进行收集,上面我们说了当前的target为渲染watcher,也就是这个操作会让响应式对象和渲染watcher产生关系。

ts
export default class Watcher {
  vm: Component;
  lazy: boolean;
  value: any;

  // 遍历所有收集了当前计算watcher的dep,执行dep的depend方法主动进行依赖收集
  depend() {
    let i = this.deps.length;
    while (i--) {
      this.deps[i].depend();
    }
  }
}

再来想想如果render函数中有很多地方用到了同一个计算属性,那么就会再次访问当前实例的计算属性getter,首先还是获取到当前计算属性的watcher,这时dirty值为false,所以就不会再执行watcher.evaluate也就不会再执行计算属性的函数了,直接返回上一次计算后的结果watcher.value,这也就是为什么计算属性会缓存的真实面目

ts
// 省略部分代码...
// dirtry为false时,不会执行
if (watcher.dirty) {
  watcher.evaluate();
}
return watcher.value;
  1. 当对应的响应式对象更新时就会触发计算属性的watcher更新,执行update函数,这里lazy为true顾将dirty设置为true,然后啥都不用做。接着dep会通知渲染watcher进行更新(上面讲了dep会收集渲染watcher),这样就会重新执行render函数,再次会访问计算属性的getter,那么就和第一次页面渲染访问计算属性一样的逻辑,执行计算属性的函数得到最新的值,这里就不再重复了
ts
export default class Watcher {
  dirty: boolean;
  // 省略部分代码...
  update() {
    if (this.lazy) {
      this.dirty = true;
    }
  }
}

到这里计算属性的原理就讲的差不多了,其实很简单。计算属性的执行的重新执行是由对应的data属性的dep控制的,并且dep也会收集渲染watcher。只有data的值改变时计算属性才会执行,否则都会使用上次的值,这样的逻辑也合情合理

小提示

computed可以通过cache:false来取消缓存,通过它可以设置成不同的getter,这样在每次访问计算属性时都会执行它的函数,当然一般也用不到这个选项,官方文档也没有给出这个选项,这里可以从源码中定义初始化计算属性的getter时会进行判断,感兴趣的可以看看

ts
computed: {
  intro: {
    get() {
      console.log("执行了intro函数");
      return `我的名字:${this.name},今年 ${this.age}`;
    },
    cache: false
  }
}

这样在每次访问intro都会执行,那么就会打印多次

代码调试

这里我们简单调试下上面的代码,来证明下以上的想法,首先是data的dep会收集渲染watcher,另一个就是多次使用同一个计算属性不会再执行

render函数第一次访问计算属性

第一次会执行evaluate函数

执行计算属性函数触发依赖收集

计算属性中的响应式对象收集渲染函数

render函数后面访问计算属性不再执行

响应式对象确实收集了计算watcher和渲染watcher

Vue3的computed

Vue3中的computed其实可vue2差不多,唯一一处不一样的就是响应式对象不会直接收集render函数,他只会收集对应的computed函数,而computed函数会收集render函数,因此当响应式对象的值发生变化时更新遵循以下规则:data => computed => render,其实这样设计能更好的反映出每个对象的真正effect

以下是一个最简单的vue的computed使用例子:

vue
<template>
  <button @click="number++">改变number</button>
  {{ total }}
</template>
<script>
import { ref, computed } from "vue"
export default {
  setup() {
    const number = ref(1);
    const total = computed(() => number % 5);

    return { total }
  }
}
</script>

这个例子定义了一个number响应式对象,然后定义了一个计算属性total,它是通过number的值并进行计算得到的。因此在页面render时会访问total,total会收集当前的activeEffect也就是render函数,然后computed初次执行会访问number属性,触发number的get这样number就会收集当前的activeEffect也就是total中的函数。这样当在页面中点击按钮改变number的值时,number首先会通知computed重新计算,computed在通知render函数重新渲染

当然computed也是有缓存的功能,也是通过dirty来控制的,只要对应的响应式对象的值不变,dirty就为false不会执行,直接返回上次的值(首次dirty为true);接下来简单分析下整个流程👇:

  1. computed的内部实现过程,首先但我们用computed(() => number.value % 5)这样的代码时先执行computed函数,内部会对传入的参数做统一处理,你可以传函数也可以传对象({get(),set()}),最后实例化ComputedRefImpl并返回。ComputedRefImpl实例化首先会创建ReactiveEffect,并标识当前computed属性,来表示是计算属性
ts
// packages/reactivity/src/computed 112行
export function computed<T>(
  getterOrOptions: ComputedGetter<T> | WritableComputedOptions<T>,
  debugOptions?: DebuggerOptions,
  isSSR = false
) {
  let getter: ComputedGetter<T>
  let setter: ComputedSetter<T>

  const onlyGetter = isFunction(getterOrOptions)
  // 函数形式
  if (onlyGetter) {
    getter = getterOrOptions
    setter = NOOP
  } else {
    // 对象形式和vue2一样
    getter = getterOrOptions.get
    setter = getterOrOptions.set
  }

  const cRef = new ComputedRefImpl(getter, setter, onlyGetter || !setter, isSSR)
  // 返回当前 ComputedRefImpl 实例
  return cRef as any
}

// computed真正的对象,computed.value 就是这个类中的属性
export class ComputedRefImpl<T> {
  public dep?: Dep = undefined

  private _value!: T
  public readonly effect: ReactiveEffect<T>

  public _dirty = true
  public _cacheable: boolean

  constructor(getter, _setter) {
    // 创建自己的ReactiveEffect对象
    this.effect = new ReactiveEffect(getter, () => {
      if (!this._dirty) {
        this._dirty = true
        triggerRefValue(this)
      }
    })
    this.effect.computed = this
  }
  // 省略...
}
  1. 然后当组件render函数执行时会获取computed的value属性,首先会执行trackRefValue函数内部会让当前计算属性的dep对render函数进行收集,然后由于初次_dirty为true,就会执行effect.run也就是ReactiveEffect的run,此过程会设置activeEffect为当前computed,然后执行computed中我们定义函数。内部会访问number触发它的get然后对computed的effect进行收集,最后执行完后_dirty变成false,这样就完成了初次的依赖回收过程
ts
export class ComputedRefImpl<T> {
  public dep?: Dep = undefined

  private _value!: T
  public readonly effect: ReactiveEffect<T>

  public _dirty = true
  public _cacheable: boolean
  // 省略...

  // render获取计算属性值时触发
  get value() {
    const self = toRaw(this)
    trackRefValue(self) // 此过程会让computed对render进行收集
    if (self._dirty || !self._cacheable) {
      self._dirty = false
      // 第一次 dirty 为true,会执行run也就是执行computed回调函数,内部会访问响应式对象使computed被对应的data收集
      self._value = self.effect.run()!
    }
    return self._value
  }

  set value(newValue: T) {
    this._setter(newValue)
  }
}

// packages/reactivity/src/effect 53行
export class ReactiveEffect<T = any> {
  run() {
    let parent: ReactiveEffect | undefined = activeEffect
    let lastShouldTrack = shouldTrack
    while (parent) {
      if (parent === this) {
        return
      }
      parent = parent.parent
    }
    try {
      this.parent = activeEffect
      // 改变当前activeEffect为 computed的Effect
      activeEffect = this
      shouldTrack = true

      trackOpBit = 1 << ++effectTrackDepth

      if (effectTrackDepth <= maxMarkerBits) {
        initDepMarkers(this)
      } else {
        cleanupEffect(this)
      }
      // 执行computed 回调函数,内部访问number值,触发number对computed的收集
      return this.fn()
    }
  }
}
  1. 当改变number的值时,会派发更新通知number的dep中的所有effect进行更新,这里就会通知computed effect进行更新,但这里不会执行ReactiveEffect的run函数,而执行scheduler函数,scheduler仅仅会将_dirty的值改为false,然后通知computed自己的dep中的所有effect进行更新,这样render函数重新执行时就和第一次执行一致了:
ts
// number触发自己的依赖进行更新
export function triggerEffects(dep, debuggerEventExtraInfo) {
  const effects = isArray(dep) ? dep : [...dep]
  for (const effect of effects) {
    // 执行计算属性
    if (effect.computed) {
      triggerEffect(effect, debuggerEventExtraInfo)
    }
  }
  // 省略...
}

function triggerEffect(effect: ReactiveEffect, debuggerEventExtraInfo?: DebuggerEventExtraInfo) {
  if (effect !== activeEffect || effect.allowRecurse) {
    // 注意这个 scheduler 属性很重要,这个在 ComputedRefImpl 实例化时会传入
    // 所以它会执行 scheduler,而不是run函数,也就是不会立马执行computed回调,等到render时才会执行
    if (effect.scheduler) {
      effect.scheduler()
    } else {
      effect.run()
    }
  }
}

export class ComputedRefImpl<T> {
  // 部分省略...
  constructor(
    getter: ComputedGetter<T>,
    private readonly _setter: ComputedSetter<T>,
    isReadonly: boolean,
    isSSR: boolean
  ) {
    this.effect = new ReactiveEffect(getter, () => { // 这个就是 scheduler
      if (!this._dirty) {
        this._dirty = true
        // 上面执行这里时,computed通知自己dep中收集的所有依赖进行更新,这时就会重新执行render
        triggerRefValue(this)
      }
    })
  }

以上就是vue3的computed整体过程,整体来说vue3的实现更加清晰。vue3中通过track、triggereffect机制完成依赖收集和派发更新,因此所有对象都可能是effect,而没有像vue2中那样死板。再者就是vue3的computed依赖收集能真正反映出依赖的收集关系,而不像vue2中data还需要额外对render收集一次!

侦听函数

侦听函数在vue中就是我们常用的watch又称user watcher,之所以这么称呼是因为它也是一个watcher对象并且user的值为true。watch通常用来监听响应式对象,当响应式对象setter执行时会通过dep通知user watcher执行,就会执行我们watch中定义的回调函数,然后再做点什么👀

简单使用

vue
<template>
  <button @click="count++">改变</button>
</template>
<script>
export default {
  data: () => ({
    count: 0,
  }),
  watch: {
    count: "watchCount"
  },
  methods: {
    watchCount(n, o) {
      console.log(n, o);
    }
  }
}
</script>

你可以能没见过以上watch形式,count的值仅仅是一个字符串,并不是我们常用的函数或者对象,但是他确实能监听到count的变化,执行的是methods中对应的方法,这种写法后面你就会明白

初始化过程

其实user watch很简单,这里就简单的啃下源码:

ts
// src/core/instance/state 58行
export function initState(vm: Component) {
  const opts = vm.$options;
  // 如果定义了watch,就进行初始化
  if (opts.watch && opts.watch !== nativeWatch) {
    initWatch(vm, opts.watch);
  }
}

// 初始化
function initWatch(vm: Component, watch: Object) {
  for (const key in watch) {
    const handler = watch[key];
    // watch属性值为数组的情况
    if (Array.isArray(handler)) {
      for (let i = 0; i < handler.length; i++) {
        createWatcher(vm, key, handler[i]);
      }
    } else {
    	// watch 属性的值为普通对象
      createWatcher(vm, key, handler);
    }
  }
}

// 每个watch 创建对应的watch
function createWatcher(
  vm: Component,
  expOrFn: string | Function,
  handler: any,
  options?: Object
) {
	// 这里会对handler做统一处理,handler也就是回调函数,可能是函数、对象、字符串
	// 如果当前的属性的值(handler)为对象,那么回调函数就为 对象的 handler 属性(这种方式应该用过)
  if (isPlainObject(handler)) {
    options = handler;
    handler = handler.handler;
  }
  // 当前属性的值(handler)为字符串,回调就从当前实例上取
  if (typeof handler === "string") {
    handler = vm[handler];
  }
  // 最后执行执行 $watch 方法
  return vm.$watch(expOrFn, handler, options);
}

这里列举下以上watch的不同的定义形式:

ts
watch: {
  // 函数
  count() {},
  // 数组和字符串,循环时就是字符串;字符串时回调函数会从this上拿,也就是vm['cb1']
  // 这就是为什么我们的例子可以写成字符串的形式,其实通过vm['watchCount']会取到methods上的方法
  count: ["cb1", "cb2"],
  count: "cb1",
  // 对象
  count: {
    handler() {}
  }
}

$watch

我们定义的watch最终内部会调用原型$watch方法,所以我们还可以使用$watch进行函数侦听:

ts
const unwatch = this.$watch(key, cb, ops);
  1. $watch是实现侦听函数的核心,那么就来看看它内部具体实现:
ts
Vue.prototype.$watch = function (expOrFn: string | Function, cb: any, options?: Object): Function {
  const vm: Component = this;
  options = options || {};
  // 设置user为true,这个很重要用来表示当前Watcher为user watcher
  options.user = true;
  // 创建user Watcher
  const watcher = new Watcher(vm, expOrFn, cb, options);
  // 如果设置 immediate,会立即执行一次 cb
  if (options.immediate) {
    cb.call(vm, watcher.value);
  }
  // $watch 函数会返回一个函数,执行它 会执行 watcher.teardown
  return function unwatchFn() {
    watcher.teardown();
  };
};
  1. 创建userWatcher时会标识当前watcher为user watcher(user = true),并且有cb也就是watch的回调函数。这里最重要的就是parsePathexpOrFn的解析,通常watch用来检测响应式对象,一般都会写对应的key也可以嵌套(如:foo、foo.bar)。parsePath返回一个函数并赋值给watcher.getter,然后立即执行getter,也就会执行parsePath返回的函数,执行时会将当前vm传入,然后根据watch监听的key循环获取到对应的响应式属性的值,然后返回。此过程就会触发检测的key对当前userWatcher的收集
ts
export default class Watcher {
  vm: Component;
  cb: Function;
  user: boolean;
  value: any;

  constructor(vm: Component, expOrFn: string | Function, cb: Function, options?: Object) {
    this.vm = vm;
    // this.user 为true
    if (options) { this.user = !!options.user;}
    // cb 就是 watch 的回调函数
    this.cb = cb;
    // 这里需要注意 expOrFn 通常为 字符串 如:this.$watch("a.b.c", cb, {});
    if (typeof expOrFn === "function") { this.getter = expOrFn; }
    // 那么就会走这里,这里会通过parsePath将 用户监听的 key 传递进去,最后返回
    else { this.getter = parsePath(expOrFn); }
    // 最后执行得到值
    this.value = this.lazy ? undefined : this.get();
  }
}

// parsePath 会返回一个函数
const bailRE = new RegExp(`[^${unicodeRegExp.source}.$_\\d]`)
export function parsePath (path: string): any {
  if (bailRE.test(path)) { return }
  const segments = path.split('.')
  return function (obj) {
    for (let i = 0; i < segments.length; i++) {
      if (!obj) return
      obj = obj[segments[i]]
    }
    return obj
  }
}
  1. 当watch监听的key值被修改后就会让dep通知user watcher进行更新,user watcher就会执行update方法,然后通过异步更新队列策略,最后执行watcher的run方法:
ts
export default class Watcher {
  // dep通知watch更新
  update() {
    if (this.lazy) { this.dirty = true; }
    else if (this.sync) { this.run(); }
    // 这里user watcheer 会进入更新队列
    else { queueWatcher(this); }
  }

  // 更新队列执行时会执行watcher.run
  run() {
    if (this.active) {
      // 拿到最新的值
      const value = this.get();
      if (value !== this.value || isObject(value) || this.deep) {
        const oldValue = this.value;
        this.value = value;
        // 如果是user watcher,执行cb
        if (this.user) {
          this.cb.call(this.vm, value, oldValue);
        } else {
          this.cb.call(this.vm, value, oldValue);
        }
      }
    }
  }
}

immediate

immediate会让watch立即执行一次,来看它的实现:

ts
Vue.prototype.$watch = function (expOrFn: string | Function, cb: any, options?: Object): Function {
  // 省略...
  const watcher = new Watcher(vm, expOrFn, cb, options);
  // 如果设置 immediate,会立即执行一次 cb
  if (options.immediate) {
    cb.call(vm, watcher.value);
  }
  //...
};

事件卸载

watch可以主动进行监听的卸载,watch会返回一个函数,执行它就会卸载监听。原理就是通过watcher的teardown方法遍历当前watcher的所有dep执行移除当前watcher,这样data更新时就不会再被通知更新了:

ts
Vue.prototype.$watch = function (expOrFn: string | Function, cb: any, options?: Object): Function {
  // 省略...
  const watcher = new Watcher(vm, expOrFn, cb, options);
  // 省略...
  // $watch 函数会返回一个函数,执行它 会执行 watcher.teardown
  return function unwatchFn() {
    watcher.teardown();
  };
};

// 来看下teardown的实现
export default class Watcher {
  // 遍历当前user watcher所有被收集的dep,执行removeSub这样就会让dep移除对user watcher的收集
  teardown() {
    if (this.active) {
      let i = this.deps.length;
      while (i--) {
        this.deps[i].removeSub(this);
      }
      this.active = false;
    }
  }
}

deep

deep选项主要用来深层监听某个属性,比如下面深层监听foo,那么当foo或者bar改变时都会触发user watcher的回调函数执行:

ts
const data = {
  foo: {
    bar: 1
  }
}
this.$watch("foo", cb, { deep: true });

那么内部是如何实现deep的呢?其实道理很简单只要让深层嵌套的属性的dep也都收集当前的user watcher,那么深层嵌套的属性值改变时也会通知user watcher进行更新。主要就是通过traverse方法深度遍历嵌套对象访问对应的key就会触发dep收集当前watcher,这样深层嵌套的属性值改变时也会通知watcher进行更新:

ts
export default class Watcher {
	// 省略
  get() {
    pushTarget(this);
    let value;
    const vm = this.vm;
    try {
      value = this.getter.call(vm, vm);
    } catch (e) {
      if (this.user) {
        handleError(e, vm, `getter for watcher "${this.expression}"`);
      }
    } finally {
    	// 当执行当前get方法获取到响应式对应的value时,如果用户设置了deep
    	// 那么就对当前值执行 traverse 方法,内部会循环遍历key,触发它们的get
      if (this.deep) {
        traverse(value);
      }
    }
    return value;
  }
}

// src/core/observer/traverse 
export function traverse (val: any) {
  _traverse(val, seenObjects)
  seenObjects.clear()
}

function _traverse (val: any, seen: SimpleSet) {
  let i, keys
  const isA = Array.isArray(val)
  // 不合法的值直接return
  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)
  }
  // 值是数组或者对象时,深层遍历就会触发每个key的get
  // 然后触发每个key的dep执行depend,这样深层嵌套的key就会收集当前的user watcher
  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)
  }
}

sync

这个属性顾名思义同步执行,通常情况下user watcher会被push到异步更新队列中去,然后按顺序进行更新。当设置了这个属性后,每次监听对象值的更新,都会立即执行user watcher的cb,而不会被推到异步更新队列中去,执行也就会被提前

ts
export default class Watcher {
	// 省略...
  update() {
    if (this.lazy) {
      this.dirty = true;
    }
    // dep通知更新时,由于sync位true,会立即执行run得到最新的值然后执行cb
    else if (this.sync) {
      this.run();
    } else {
      queueWatcher(this);
    }
  }
}

注意事项

watch的侦听函数中不能再对所侦听的对象进行值的修改,这样就会造成死循环不断执行回调函数:

vue
<template>
  <button @click="count++">改变</button>
</template>
<script>
export default {
  data: () => ({
    count: 0,
  }),
  watch: {
    count: "watchCount"
  },
  methods: {
    watchCount(n, o) {
      this.count ++;
      console.log(n, o);
    }
  }
}
</script>

Vue3的watch

vue3中的watch其实和vue2的没啥区别,vue3的选项多了一个flush: 'pre' | 'post' | 'sync'参数,其用来控制回调函数的执行时机,默认值为pre会推送到queueJob队列中,在页面渲染前执行;如果值为post那么会将回调推送到postFlushCbs中,这会在queue执行完后再执行postFlushCbs中的回调,在页面渲染后执行;最后一个值sync和vue2一样,当watch的对象值改变时会立马执行不需要进入队列异步更新

ts
// packages/runtime-core/src/apiWatch 158行
export function watch<T = any, Immediate extends Readonly<boolean> = false>(
  source: T | WatchSource<T>,
  cb: any,
  options?: WatchOptions<Immediate>
): WatchStopHandle {
  return doWatch(source as any, cb, options)
}

function doWatch(
  source: WatchSource | WatchSource[] | WatchEffect | object,
  cb: WatchCallback | null,
  { immediate, deep, flush, onTrack, onTrigger }: WatchOptions = EMPTY_OBJ
): WatchStopHandle {
  const instance =
    getCurrentScope() === currentInstance?.scope ? currentInstance : null
  // const instance = currentInstance
  let getter: () => any
  let forceTrigger = false
  let isMultiSource = false
  // 省略代码...
  // 1. 先对watch对象的值进行规范化,值可能是 对象、数组、函数等等

  // deep时深度遍历
  if (cb && deep) {
    const baseGetter = getter
    getter = () => traverse(baseGetter())
  }

  let cleanup: () => void
  let onCleanup: OnCleanup = (fn: () => void) => {
    cleanup = effect.onStop = () => {
      callWithErrorHandling(fn, instance, ErrorCodes.WATCH_CLEANUP)
    }
  }

  let oldValue: any = isMultiSource
    ? new Array((source as []).length).fill(INITIAL_WATCHER_VALUE)
    : INITIAL_WATCHER_VALUE
  // 对回调做一次包装
  const job: SchedulerJob = () => {
    if (!effect.active) {
      return
    }
    if (cb) {
      // 省略...
    } else {
      // watchEffect
      effect.run()
    }
  }

  // 处理flush 参数,用来区别不同的执行时机
  let scheduler: EffectScheduler
  if (flush === 'sync') {
    scheduler = job as any // the scheduler function gets called directly
  } else if (flush === 'post') {
    scheduler = () => queuePostRenderEffect(job, instance && instance.suspense)
  } else {
    job.pre = true
    if (instance) job.id = instance.uid
    scheduler = () => queueJob(job)
  }

  // 创建 ReactiveEffect
  const effect = new ReactiveEffect(getter, scheduler)

  if (cb) {
    // 处理 immediate 情况,立即执行一次
    if (immediate) {
      job()
    } else {
      // 否则计算一次旧的值
      oldValue = effect.run()
    }
  } else if (flush === 'post') {
    queuePostRenderEffect(
      effect.run.bind(effect),
      instance && instance.suspense
    )
  } else {
    effect.run()
  }

  // 卸载事件
  const unwatch = () => {
    effect.stop()
    if (instance && instance.scope) {
      remove(instance.scope.effects!, effect)
    }
  }
  return unwatch
}

watchEffect

watchEffect本质还是watch,只不过参数调换了位置而已,这里更像React的useEffect,内部的回调会主动执行,并且当内部的依赖发生改变时,再次触发回调执行:

ts
watchEffect(effect: (onCleanup: OnCleanup) => void, options?: { flush?: 'pre' | 'post' | 'sync' })

watchEffect原理很简单内部主动会执行一次回调函数,这个过程中就会访问到对应的响应式对象,然后触发对当前回调函数的收集,同理data更新时也会通知回调再次执行。回调函数在内部会被封装一次,将onCleanup作为参数传入:

ts
export function watchEffect(
  effect: WatchEffect,
  options?: WatchOptionsBase
): WatchStopHandle {
  return doWatch(effect, null, options)
}

function doWatch(
  source: WatchSource | WatchSource[] | WatchEffect | object,
  cb: WatchCallback | null,
  { immediate, deep, flush, onTrack, onTrigger }: WatchOptions = EMPTY_OBJ
): WatchStopHandle {
  // source 为function
  if (isFunction(source)) {
    // 没有cb,不会走这里
    if (cb) {
      // getter with cb
      getter = () =>
        callWithErrorHandling(source, instance, ErrorCodes.WATCH_GETTER)
    } else {
      // watchEffect 回调会被封装一次
      getter = () => {
        if (instance && instance.isUnmounted) {
          return
        }
        if (cleanup) {
          cleanup()
        }
        // 这里就会把 onCleanup 作为参数传入
        return callWithAsyncErrorHandling(
          source,
          instance,
          ErrorCodes.WATCH_CALLBACK,
          [onCleanup]
        )
      }
    }
  }

  // 清除副作用
  let cleanup: () => void
  let onCleanup: OnCleanup = (fn: () => void) => {
    cleanup = effect.onStop = () => {
      callWithErrorHandling(fn, instance, ErrorCodes.WATCH_CLEANUP)
    }
  }
  const job: SchedulerJob = () => {
    if (!effect.active) return
    if (cb) { /* 省略... */ } else {
      // watchEffect
      effect.run()
    }
  }

  const effect = new ReactiveEffect(getter, scheduler)
  // initial run
  effect.run()

  const unwatch = () => {
    effect.stop()
    if (instance && instance.scope) {
      remove(instance.scope.effects!, effect)
    }
  }
  return unwatch
}

watchEffect还是很好理解的,剩下的还有watchPostEffectwatchSyncEffect都和这个一样,自己看看就行了

总结

到这里就把vue的响应式原理全部讲解完了,其中包括vue3的。响应式遵循发布/订阅模式,通过拦截对象的getter/setter实现对当前的effect进行收集和通知,这样就可以精确的进行更新,这也是为什么vue的更新粒度可以做到组件的原因。如果你对它的响应式还是比较模糊,建议你结合这两篇响应式原理文章尝试写几个例子并进行断点调试,可以帮助你解决自己的疑惑,你也可以在下方留言我会及时回复。接下来一起来看看vue的模板编译过程吧

若您在阅读过程中发现一些错误:如语句不通、文字、逻辑等错误,可以在评论区指出,我会及时调整修改,感谢您的阅读。同时您觉得这篇文章对您有帮助的话,可以打赏作者一笔作为鼓励,金额不限,感谢支持🤝。
支付宝捐赠微信支付捐赠

Released under the MIT License.