当前位置: 首页 > 工具软件 > Watcher > 使用案例 >

Vue源码实现之watcher拾遗

刁俊人
2023-12-01

目录

1. Watcher构造函数参数options和渲染watcher标志位

2. watcher收集的新老依赖deps和newDeps的作用

3. watcher中getter的目的就是去touch目标数据以触发依赖收集

4. Watcher设置依赖收集标志时为什么要pushTarget和popTarget


这篇文章是紧跟着《Vue源码分析基础之响应式原理》的一篇文章。如果刚开始接触vue源码的童鞋,推荐先看完上面一篇文章,这样理解起来可能会轻松点。

1. Watcher构造函数参数options和渲染watcher标志位

vue源码的watcher实现和我们《Vue源码分析基础之响应式原理》分析的思路大致差不多,但是具体实现细节上会有不少的差异,所以下面会做一些补充性的分析。

这里我们先看下watcher类的构造函数

export default class Watcher {
  constructor(
    vm: Component,
    expOrFn: string | Function,
    cb: Function,
    options?: ?Object,
    isRenderWatcher?: boolean
  ) {
    this.vm = vm;
    if (isRenderWatcher) {
      vm._watcher = this;
    }
    vm._watchers.push(this);
     // options
    if (options) {
      this.deep = !!options.deep;
      this.lazy = !!options.lazy;
      this.sync = !!options.sync;
      ...
    }
    // options
    ...
    this.cb = cb;
    this.deps = [];
    this.newDeps = [];
    this.depIds = new Set();
    this.newDepIds = new Set();
    
    // parse expression for getter
    if (typeof expOrFn === "function") {
      this.getter = expOrFn;
    } else {
      this.getter = parsePath(expOrFn);
      if (!this.getter) {
        this.getter = noop;
      }
    }
    this.value = this.lazy ? undefined : this.get();
  }
}

首先,我们来分析下构造函数的参数参数比我们《Vue源码分析基础之响应式原理》构建的watcher多了两个:

  • options:一些控制选项,比如我们写vue组件代码的watch时,可以额外提供deep选项来对监控对象的深层属性的修改进行监控
  • isRenderWatcher: 渲染watcher标志位。什么是渲染watcher?它是我们vue系统中用到的三种watcher(计算属性watcher,渲染watcher,组件watch钩子开启的watcher,我会在后面的文章对这些watchers进行分析)中的其中一种,它会在组件挂载时进行创建并将getter设置为组件更新函数updateComponent,且每个组件都会有且只有一个渲染watcher,该watcher创建开始收集依赖时,data中所有属性的dep.subs都会存入这个watcher订阅,一旦我们写的data里面的数据状态发生变化,即会触发watcher的update->run->get->getter->updateComponent->render|patch来更新真实DOM。同时,我们会将每个组件的渲染watcher放到vm._watcher上,以便forceUpdate组件时可以直接触发watcher的更新。

2. watcher收集的新老依赖deps和newDeps的作用

这里跟着需要提一提的是deps和newDeps,因为每次数据更新时vue都会触发渲染watcher进而调用到render来重新构建虚拟dom,构建虚拟dom时需要解析模板中绑定的数据的变化,从而为模板中用到的这些data中的属性建立起watcher的依赖订阅。但是新的页面因为已经发生了变化,很有可能在之前我们模板中用到了某个data中的属性,但更新后的模板不再用这个属性了,那么我们就需要将渲染watcher从该属性的dep.subs中删除,否则就会造成修改该属性时依然触发页面重新渲染的bug。

比如下面的模板代码

<template>
  <div>
    <div v-if="show">Hello world</div>
    <div @click="show = !show">toggle</div>
  </div>
</template>

假如当前show为true,那么在点击toggle的时候,show会被改成false,从而导致了依赖show变量的渲染watcher的update方法的运行来重新构建虚拟DOM和更新真实DOM,期间show自身的dep.subs原来是有watcher的订阅在里面的,这时我们就需要将其移除掉了。

而watcher上面的deps和newDeps要做的就是这些事情。

  • deps:上一次通过这个watcher进行依赖收集时所收集到的所有依赖集合。以渲染watcher为例,比如你这个页面现在用到了两个data中的属性,那么这两个属性对应的dep都会被这个watcher订阅,同时,这个两个dep都会放到watcher.deps下面
  • newDeps:本次这个watcher记性依赖收集时收集到的所有依赖集合。沿用上面的举例,比如这一次你的页面通过v-if之类的指令取消掉了对其中一个data的属性的引用,这时你的watcher.newDeps就只会有其中个属性的dep
  • 结果:那么通过对上面的deps和newDeps一比较,我们就知道应该将取消页面引用的那个data属性的dep.subs的watcher给移除掉,以便产生前面说的bug了

那么newDeps是什么时候产生的呢?当然是在依赖收集的时候了。

在watcher收集依赖的时候,会触发自身的get方法,关键的代码如下

// observer/watcher.js文件
get() {
  pushTarget(this)
  let value = this.getter.call(vm, vm)
  popTarget()
  this.cleanupDeps()
  return value
}

// observer/dep.js文件
export function pushTarget(target: ?Watcher) {
  targetStack.push(target)
  Dep.target = target
}

get方法首先做的就是调用pushTarget来将watcher自身推入到targetStack里面,然后和以前分析的那样设置全局的Dep.target为watcher自身。这里为什么要用pushTarget,我们下面会另外起个小节来分析,这里我们就认为它仅仅是设置了Dep.target就行了。

跟着调用getter去触发依赖的收集,这样当某个被obeserved的data的属性被读取(touch)的时候,就会触发该属性自身对应的依赖对象dep的depend方法来进行依赖收集。

// observer/index.js文件
function defineReactive(data, key, val = data[key]) {
  const dep = new Dep()
  let childOb = observe(val)
  Object.defineProperty(data, key, {
    // getter
    get() {
      if (Dep.target) {
        dep.depend()
      }
      return val
    },
  });
}

// observer/dep.js文件
  depend() {
    if (Dep.target) {
      Dep.target.addDep(this)
    }
  }

最终进入到watcher的addDep中

  addDep(dep: Dep) {
    const id = dep.id
    if (!this.newDepIds.has(id)) {
      this.newDepIds.add(id)
      this.newDeps.push(dep)
      if (!this.depIds.has(id)) {
        dep.addSub(this)
      }
    }
  }

从中我们将上面defineReactive中对应属性的dep加入到了这个watcher的newDeps,同时将自己加入了该dep的subs中,实现了依赖的收集。

但这并没有完。这里还只是走完了get方法的getter调用,get方法后面还调用了一个cleanupDeps的方法,而该方法的作用,就是我们上面说的将以前依赖某个data属性的watcher,从该属性的dep.subs中移除,然后形成该watcher所依赖的所有属性的dep组成的最新的deps,并保存到watcher.deps中

 cleanupDeps() {
    let i = this.deps.length
    while (i--) {
      const dep = this.deps[i]
      if (!this.newDepIds.has(dep.id)) {
        dep.removeSub(this)
      }
    }
    let tmp = this.depIds
    this.depIds = this.newDepIds
    this.newDepIds = tmp
    this.newDepIds.clear()
    tmp = this.deps
    this.deps = this.newDeps
    this.newDeps = tmp
    this.newDeps.length = 0
  }

3. watcher中getter的目的就是去touch目标数据以触发依赖收集

跟着要看下的就是构造函数中对getter的操作。

 // parse expression for getter
    if (typeof expOrFn === "function") {
      this.getter = expOrFn;
    } else {
      this.getter = parsePath(expOrFn);
      if (!this.getter) {
        this.getter = noop;
      }
    }

我们知道watcher中的getter的作用是去取得被监控数据具体的属性,通常是在构造函数调用get方法时去调用下getter,从而读取下这个属性,因为此前对这个对象进行了observe(data),所以一旦我们读取了这个属性,那么get方法设置了Dep.target的标识位的情况下就会触发依赖收集。

这就是构造函数传入的expOrFn是"count.total"这种形式的数据的情况。

但是expOrFn也可能是个函数,比如我们刚才说的渲染watcher初始化时传入的就是updateComponent这个方法。

这个时候构造函数发现你传入的是个函数,它就会直接将这个方法赋值给getter。这样在跟着调用的get,然后触发getter时,就是直接调用到这个函数。比如updateComponent方法,开始去调用渲染函数从新生成虚拟DOM,期间touch到所有页面引用的data数据,触发依赖收集。

所以本质上来说,该getter的作用和上面的情况是一致的,也就是说,getter存在的目的就是为了去touch下目标监控数据,从而触发对这些数据的依赖的收集!

4. Watcher设置依赖收集标志时为什么要pushTarget和popTarget

正如我们前面看到的,在vue的源代码中,开启依赖收集标志和关闭依赖收集标志并不是直接操作Dep.target的赋值,而是通过pushTarget和popTarget来做的

// observer/dep.js文件

export function pushTarget(target: ?Watcher) {
  targetStack.push(target)
  Dep.target = target
}

export function popTarget() {
  targetStack.pop()
  Dep.target = targetStack[targetStack.length - 1]
}

首先,代码字面意义没有什么不好理解的,就是pushTarget的时候将当前watcher推入到栈中,并将该watcher设置成Dep.target。popTarget的时候从栈顶pop一个watcher出来并将其设置到Dep.target。

问题是why?

其实这里主要是要解决依赖收集的嵌套的问题。

这里我能想到的一个应用场景是在计算属性watcher中,计算属性初始话时会调用initComputed方法。关键代码如下:

const computedWatcherOptions = { lazy: true }
function initComputed(vm: Component, computed: Object) {
  ...
  const watchers = vm._computedWatchers = Object.create(null)
  for (const key in computed) {
    const userDef = computed[key];

    watchers[key] = new Watcher(
      vm,
      getter || noop,
      noop,
      computedWatcherOptions
    );

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

首先以用户自定义的computed函数作为getter创建watcher并放到vm的_computedWatchers数组下。但因为设置了lazy为tru,所以watcher构造时默认不会自动调用getter来立刻更新计算属性,而是等到如页面{{计算属性}}使用时再触发下面的computeGetter来触发更新,可以参考上面watcher的构造函数对lazy的判断部分:

this.value = this.lazy ? undefined : this.get();

跟着为我们定义的每个计算属性调用defineComputed方法

export function defineComputed(
  target: any,
  key: string,
  userDef: Object | Function
) {
  const 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 (process.env.NODE_ENV !== 'production' &&
    sharedPropertyDefinition.set === noop) {
    sharedPropertyDefinition.set = function () {
      warn(
        `Computed property "${key}" was assigned to but it has no setter.`,
        this
      )
    }
  }
  Object.defineProperty(target, key, sharedPropertyDefinition)
}

这个方法主要的目的是将用户自定义计算属性挂到vm上面,以便页面通过{{计算属性}}直接访问。然后将该计算属性的getter定义为computedGetter,这样一来,在页面等访问{{计算属性}}时该函数将调用_computedWatchers对应的watcher的getter来重新计算属性并触发所依赖属性的依赖收集。

该computedGetter会在什么时候被触发呢?

在组件渲染watcher执行updateComponent时。因为render渲染函数需要读取页面引用到的data属性和computed计算属性来生成虚拟dom。

此时Dep.target是该组件的渲染watcher,栈顶也是该渲染watcher。这个需要记一下,我们下面需要用。

跟着,我们看下计算属性被访问时触发的computedGetter具体是怎么实现的:

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

首先从vm中拿到我们上面创建的这个计算属性对应的watcher,然后执行evaludate

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

很明显,evaluate就是执行下watcher的get方法,进而触发我们自定义的计算属性方法,如果该方法使用了data属性,则会针对这个watcher开始对这些属性进行依赖收集。比如下面的自定义计算属性就依赖了data中的属性counter,所以counter的dep.subs会收集这个计算属性watcher到其囊中。

computed: {
  twiceCounter: function() {
    return this.counter * 2
  }
}

但是,这里要注意了,在执行get方法时,里面首先会做一个pushTarget。

记得此前Dep.target是谁吧?本组件的渲染watcher。这时你要对计算属性watcher做依赖收集(即看下我们自定义计算属性的方法体里面用了哪些data属性,将watcher自身加入到这些属性的dep.subs中),所以就会将计算属性watcher压栈,将Dep.target设置成自身,这样所依赖的属性的dep.subs收集到的才是计算属性watcher,好让该属性改变时自动触发计算属性的更新。

完了后,popTarget,计算属性watcher出栈,此时栈顶又变为组件的渲染watcher,同时设置为Dep.target。

为什么呢?因为createComputedGetter紧跟着执行的watcher.depend()需要用到本组件的渲染watcher。

// observer/watcher.js文件
depend() {
  let i = this.deps.length
  while (i--) {
    this.deps[i].depend()
  }
}

注意这里的this是计算属性watcher,而this.deps指的是该计算属性watcher所有收集到的依赖,比如由data多个属性的dep所组成,同时,这时Dep.target是组件的渲染watcher而非计算属性watcher。

那么这个方法所实现的逻辑就是,让组件渲染watcher订阅所有该计算属性watcher收集到的依赖。

也就是说,一旦这个计算属性所依赖的某个data属性修改了,将首先会通知计算属性watcher来将计算属性进行更新,然后通知渲染watcher来重新渲染整个组件。

而这,也就是为什么需要pushTarget/popTarget以及targetStack的原因,如前面所说,主要为了解决依赖收集过程中的嵌套问题。即在收集渲染watcher得依赖过程中,发现需要先收集计算属性watcher的依赖,这时就要将渲染watcher先压栈,把栈顶和Dep.target留给计算属性watcher,等计算属性watcher收集完依赖后,再恢复现场,把组件渲染watcher置顶,并设置Dep.target,以完成渲染watcher后续的依赖收集动作。

我是@天地会珠海分舵,「青葱日历」和「三日清单」 作者。能力一般,水平有限,觉得我说的还有那么点道理的不妨点个赞关注下!

 类似资料: