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

vue源码分析之watcher为何收集dep

夏弘文
2023-12-01

vue在渲染的时候有一个依赖收集的过程,data属性对应的dep,会收集watcher,同时watcher也会收集dep。dep收集watcher的原因是为了属性值更改的时候,通过dep通知watcher更新,这一点很多文章已有详细分析,但是几乎没有文章详细分析watcher为何反向收集dep,所以此篇文章我将结合vue源码与实际业务代码案例,详细分析原因。如有错误,欢迎指正。

vue如何进行依赖收集

首先,我们先简单回顾一下vue初始化过程,看看vue是如何进行依赖收集

1. new Vue发生了什么

先忽略其他与此次主题无关的逻辑,new Vue干了两件事:

  • initState,初始化data、computed等,我们常说的数据劫持发生在这里
  • 调用$mount方法,把我们写的template最终渲染成真实dom挂载到页面上
// new vue的时候仅仅调用init init方法是在initMixin挂在到Vue原型上
function Vue (options) {
	// ....
  this._init(options)
}

function initMixin (Vue: Class<Component>) {
  Vue.prototype._init = function (options?: Object) {
    const vm: Component = this
    // 合并options
    if (options && options._isComponent) {
      initInternalComponent(vm, options)
    } else {
      vm.$options = mergeOptions(
        resolveConstructorOptions(vm.constructor),
        options || {},
        vm
      )
    }
		// initState中对我们传入对data进行了数据劫持
    initState(vm)
		// 调用$mount开始挂载 
    if (vm.$options.el) {
      vm.$mount(vm.$options.el)
    }
  }
}
复制代码

2. initState发生了什么

initState主要是对我们传入的options进行了初始化,这里我们先关注initData,即初始化我们传入的data

function initState (vm: Component) {
  vm._watchers = []
  const opts = vm.$options
  if (opts.props) initProps(vm, opts.props)
  if (opts.methods) initMethods(vm, opts.methods)
  if (opts.data) {
    initData(vm)
  } else {
    observe(vm._data = {}, true /* asRootData */)
  }
  if (opts.computed) initComputed(vm, opts.computed)
  if (opts.watch && opts.watch !== nativeWatch) {
    initWatch(vm, opts.watch)
  }
}
复制代码

initData主要干了两件事:

  • proxy函数对this._data进行代理,所以我们可以通过this.xxx访问到data的数据
  • observe函数把data变成响应式
function initData (vm: Component) {
  let data = vm.$options.data
  data = vm._data = typeof data === 'function'
    ? getData(data, vm)
    : data || {}
	 // ...
  const keys = Object.keys(data)
  const props = vm.$options.props
  const methods = vm.$options.methods
  let i = keys.length
  while (i--) {
		// key重复校验 如methods中的key不能和data中的key重复,因为两者最终都会被代理this上
    const key = keys[i]
    if (process.env.NODE_ENV !== 'production') {
      if (methods && hasOwn(methods, key)) {
        warn(
          `Method "${key}" has already been defined as a data property.`,
          vm
        )
      }
    }
    if (props && hasOwn(props, key)) {
      process.env.NODE_ENV !== 'production' && warn(
        `The data property "${key}" is already declared as a prop. ` +
        `Use prop default value instead.`,
        vm
      )
    } else if (!isReserved(key)) {
			// this._data.xxx变this.xxx
      proxy(vm, `_data`, key)
    }
  }
  // 数据变响应式
  observe(data, true /* asRootData */)
}
复制代码

数据劫持核心方法defineReactive,在$mount调用后,生成vnode的时候触发get的时候调用dep.depend()进行依赖收集。

function defineReactive (
  obj: Object,
  key: string,
  val: any,
  customSetter?: ?Function,
  shallow?: boolean
) {
  const dep = new Dep()

  const property = Object.getOwnPropertyDescriptor(obj, key)
  if (property && property.configurable === false) {
    return
  }
  // cater for pre-defined getter/setters
  const getter = property && property.get
  const setter = property && property.set
  if ((!getter || setter) && arguments.length === 2) {
    val = obj[key]
  }

  let childOb = !shallow && observe(val)
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter () {
      const value = getter ? getter.call(obj) : val
      if (Dep.target) {
				// 依赖收集
        dep.depend()
        if (childOb) {
          childOb.dep.depend()
          if (Array.isArray(value)) {
            dependArray(value)
          }
        }
      }
      return value
    },
    set: function reactiveSetter (newVal) {
      const value = getter ? getter.call(obj) : val
      /* eslint-disable no-self-compare */
      if (newVal === value || (newVal !== newVal && value !== value)) {
        return
      }
      /* eslint-enable no-self-compare */
      if (process.env.NODE_ENV !== 'production' && customSetter) {
        customSetter()
      }
      // #7981: for accessor properties without setter
      if (getter && !setter) return
      if (setter) {
        setter.call(obj, newVal)
      } else {
        val = newVal
      }
      childOb = !shallow && observe(newVal)
      dep.notify()
    }
  })
}
复制代码

重点:dep.depend()中watcher收集了dep

//dep.js 
// 这里Dep.target指向的是wather 原因稍后分析 
depend () {
    if (Dep.target) {
      Dep.target.addDep(this)
    }
  }
//watcher.js
addDep (dep: Dep) {
    const id = dep.id
    if (!this.newDepIds.has(id)) {
			// watcher的dep对象收集了dep
      this.newDepIds.add(id)
      this.newDeps.push(dep)
      if (!this.depIds.has(id)) {
				// dep中收集了watcher
        dep.addSub(this)
      }
    }
  }
复制代码

根据以上分析,我们知道当访问data属性并且Dep.target为true的时候,会进行依赖收集,也就是说watcher在这个时候收集了dep。

Watcher收集dep的时机

vue中的watcher大体可以分成三类:

  • 渲染watcher
  • 计算watcher
  • watchWatcher

在以下三种方法被调用时,Dep.target会指向其对应的watcher:

  • $mount
//$mount调用的实际是mountComponent方法
Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  el = el && inBrowser ? query(el) : undefined
  return mountComponent(this, el, hydrating)
}

//mountComponent方法
function mountComponent(
  vm: Component,
  el: ?Element,
  hydrating?: boolean
): Component {
  vm.$el = el;
	// ...
  callHook(vm, "beforeMount");
  let updateComponent;
  // ...
	updateComponent = () => {
      vm._update(vm._render(), hydrating);
    };
	// 
  new Watcher(
    vm,
    updateComponent,
    noop,
    {
      before() {
        if (vm._isMounted && !vm._isDestroyed) {
          callHook(vm, "beforeUpdate");
        }
      },
    },
    true /* isRenderWatcher */
  );
  hydrating = false;

  // manually mounted instance, call mounted on self
  // mounted is called for render-created child components in its inserted hook
  if (vm.$vnode == null) {
    vm._isMounted = true;
    callHook(vm, "mounted");
  }
  return vm;
}
复制代码

在mountComponent方法中,创建了watcher实例,最后一个参数为true,表明这是一个渲染watcher。

渲染watcher lazy为false,所以调用get方法,get方法调用的getter 就是我们传入的mountComponent。

export default class Watcher {
  constructor (
    vm: Component,
    expOrFn: string | Function,
    cb: Function,
    options?: ?Object,
    isRenderWatcher?: boolean
  ) {
    this.vm = vm
    // ...
    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()
  }

  
  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}"`)
      } else {
        throw e
      }
    } finally {
      // "touch" every property so they are all tracked as
      // dependencies for deep watching
      if (this.deep) {
        traverse(value)
      }
      popTarget()
      this.cleanupDeps()
    }
    return value
  }
}
复制代码

我们看看pushTarget,pushTarget的逻辑很简单,把当前watcher入栈,并把Dep.target指向watcher。

Dep.target = null
const targetStack = []

function pushTarget (target: ?Watcher) {
  targetStack.push(target)
  Dep.target = target
}
复制代码

所以这时候Dep.target为true,并且updateComponent中调用_render生成vnode的时候,会访问到data的数据。此时,被访问到的data收集了该渲染Watcher,渲染Watch也收集了data的dep。

	updateComponent = () => {
      vm._update(vm._render(), hydrating);
    };
复制代码
  • initComputed
const computedWatcherOptions = { lazy: true }

function initComputed (vm: Component, computed: Object) {
  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)
    } 
  }
}
复制代码

在initComputed中,为每个计算属性创建了Watcher,并传入了computedWatcherOptions表明这是一个计算watcher,并调用了defineComputed

defineComputed主要是为了数据劫持,当我们访问this.xxx计算属性的时候,实际上访问的是

sharedPropertyDefinition.get指向的createComputedGetter方法

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)
}
复制代码

createComputedGetter方法也很简单,取出该计算属性对应的watcher,dirty默认为true,我们先看watcher.evaluate()

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
    }
  }
}
复制代码

evaluate就是调用get方法,根据上面$mount方法调用的分析,get方法执行的时候,会把Dep.target指向当前的计算Watcher,并调用计算属性对应的方法(也就是cValue),在这个过程会访问到data的数据(在这里就是value)。此时,computed依赖的data收集了该计算Watcher,计算Watch也收集了所依赖data的dep。

// 示例
computed:{
	cValue(){
		return this.value
	}
}
复制代码
evaluate () {
    this.value = this.get()
    this.dirty = false
  }
复制代码
  • initWatch

initWatch最终调用的是$watch方法

function initWatch(vm: Component, watch: Object) {
  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);
    }
  }
}

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中我们看new watcher的逻辑

Vue.prototype.$watch = function (
    expOrFn: string | Function,
    cb: any,
    options?: Object
  ): Function {
    const vm: Component = this;
    if (isPlainObject(cb)) {
      return createWatcher(vm, expOrFn, cb, options);
    }
    options = options || {};
		// 该参数表明这是watchWatcher
    options.user = true;
    const watcher = new Watcher(vm, expOrFn, cb, options);
    if (options.immediate) {
      const info = `callback for immediate watcher "${watcher.expression}"`;
      pushTarget();
      invokeWithErrorHandling(cb, vm, [watcher.value], vm, info);
      popTarget();
    }
    return function unwatchFn() {
      watcher.teardown();
    };
  };
复制代码
// 从这里可以看到 watcher这次接收的expOrFn不是func
createWatcher(vm, key, handler);

// Wacther类中的构造函数 所以这是会走parsePath逻辑
if (typeof expOrFn === 'function') {
      this.getter = expOrFn
    } else {
      this.getter = parsePath(expOrFn)
    }
复制代码

parsePath返回了一个函数,这个函数在Watcher的get方法中调用,会传入vue实例,所以也就访问到了data属性 ,这一过程watchWatcher收集了data的dep。

const bailRE = new RegExp(`[^${unicodeRegExp.source}.$_\\d]`)

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
  }
}
复制代码

以上,我们分析了vue初始化的时候,watcher收集dep的的时机。下面我们分别讨论三种watcher为何都要收集dep。

渲染watcher为什么要收集dep

这是Watcher get函数的部分逻辑,我们重点看cleanupDeps

get () {
    pushTarget(this)
		value = this.getter.call(vm, vm)
		// ...
    this.cleanupDeps()
    return value
  }
复制代码

cleanupDeps主要逻辑是对比新旧deps,把不在新deps中的旧dep对应watcher删除。这里watcher中存放了dep,能够通知dep 把自己remove掉。

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
  }
复制代码

从上面分析可以知道,渲染watcher对应的getter是updateComponent方法,updateComponent最终会导致页面重新渲染。为什么vue每次更新页面,都会清理依次依赖呢?

我们考虑一下这种情况:

初始化的时候渲染watcher订阅了a的dep,然后我们执行了toggle方法把isTrue置为false。如果不清理依赖,调用change A的时候,a的dep会通知渲染watcher更新,这显然不合理。所以第一次旧的deps包含a的dep,再对比新deps,并没有a的dep,说明这次渲染a没有被收集到,所以需要清理掉dep中的watcher避免无用的更新。

<template>
  <div>
    <div v-if="isTrue">{{a}}</div>
    <div v-else>{{b}}</div>
  </div>
</template>

<script>
  export default {
    data(){
      return {
        isTrue: true,
        a:1,
        b:2

      }
    },
    methods: {
      toggle(){
        this.isTrue = false
      },
      changeA(){
        this.a = Math.random()

      }
    },
  }
</script>
复制代码

计算watcher为什么要收集dep

首先还是看场景,下面这种场景计算属性在template上,但是依赖的data不在template上。那么在生成vnode的时候,并不会访问到所依赖data,那么这些所依赖的data的dep也就无法通知watcher。而计算watcher是不具备更新页面功能的,我们先看一下如下场景

<template>
	<div class="about">
		<h1>{{ person }}</h1>
	</div>
</template>
<script>
export default {
	data() {
		return { firstName: 'Hello', lastName: 'World' }
	},

	computed: {
		person() {
			return this.firstName + this.lastName
		}
	}
}
</script>
复制代码

计算watcher仅仅是更改dirty属性,并没有更新页面功能

update () {
    // 计算属性走this.lazy逻辑
    if (this.lazy) {
      this.dirty = true
    } else if (this.sync) {
      this.run()
    } else {
      queueWatcher(this)
    }
  }
复制代码

为了使这种情况能更新页面上的person,我们看一下vue的实现方式。

上面有分析过,当渲染时方法this.person,实际上是调用computedGetter方法。


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;
    }
  };
}
复制代码

此时的Dep.target还是渲染watcher,调用 watcher.depend(), this.deps存的是firstName和lastName对应的dep

depend () {
    let i = this.deps.length
    while (i--) {
      this.deps[i].depend()
    }
  }
复制代码

循环执行this.deps[i].depend(),也就是让person所依赖的firstName和lastName的dep去收集当前的watcher

//dep.js
depend () {
    if (Dep.target) {
			// 当前watcher收集dep
      Dep.target.addDep(this)
    }
  }
// watch.js
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收集wathcer
        dep.addSub(this)
      }
    }
  }

// dep.js
addSub (sub: Watcher) {
    this.subs.push(sub)
  }
复制代码

从源码可以看出,生成vnode的时候虽然渲染watcher没有订阅计算属性所依赖data的dep,但是计算watcher初始化的时候收集到了所依赖data的dep,这时就可以让所依赖data的dep的去收集渲染watcher。

但是这种方式会有问题,就是当计算属性最后返回的值没有变化,也会触发vnode的重新生成。虽然在新旧vnode进行diff算法的时候最终不会生成真实dom,但是也浪费了性能。我们看一下场景

export default {
	data() {
		return { a: 1 }
	},

	computed: {
		b() {
			return this.a * 0
		}
	}
}
复制代码

无论a怎么变化,b的值始终是0。但是由于a变化了,会触发渲染watcher更新。 在vue-2.5.17-beta.0中修改了computed的实现方式解决上面这个问题,但是带来了其他问题而没有被采用,感兴趣的朋友可以看看。 Component is re-rendered when computed value stay same and its dep value changes. · Issue #11399 · vuejs/vue (github.com)

[v2.5.17-beta.0] Array changes do not always trigger computed property updates · Issue #7960 · vuejs/vue (github.com)

watchWatch为什么要收集dep

watchWatch这种场景比较容易理解,原因是$watch方法返回了unwatchFn方法,也就是解除订阅,这一点实现方式和cleanupDep的方式一样,移除watcher的订阅。

Vue.prototype.$watch = function (
    expOrFn: string | Function,
    cb: any,
    options?: Object
  ): Function {
	   // ...
    return function unwatchFn() {
      watcher.teardown();
    };
  };
复制代码

watcher.teardown()方法,将watcher从dep中移除

teardown () {
    if (this.active) {
      // ...
      let i = this.deps.length
      while (i--) {
        this.deps[i].removeSub(this)
      }
      this.active = false
    }
  }
复制代码

总结

经过以上分析,可以知道除了计算watcher,另外两种watcher收集dep都是为了后面便于解除watcher的订阅,而计算watcher收集dep目的是为了让这些dep能够有机会收集渲染watcher。

 类似资料: