一、nextTick 的理解
-
nextTick
是Vue
的一个核心实现,在介绍Vue
的nextTick
之前,为了方便大家理解,先简单介绍一下JS
的运行机制。 -
JS
运行机制,JS
执行是单线程的,它是基于事件循环的。事件循环大致分为以下几个步骤:
- 所有同步任务都在主线程上执行,形成一个执行栈(
execution context stack
)。 - 主线程之外,还存在一个"任务队列"(
task queue
)。只要异步任务有了运行结果,就在"任务队列"之中放置一个事件。 - 一旦"执行栈"中的所有同步任务执行完毕,系统就会读取"任务队列",看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。
- 主线程不断重复上面的第三步。
-
主线程的执行过程就是一个
tick
,而所有的异步结果都是通过 “任务队列” 来调度。 消息队列中存放的是一个个的任务(task
)。 规范中规定task
分为两大类,分别是macro task
和micro task
,并且每个macro task
结束后,都要清空所有的micro task
。 -
关于
macro task
和micro task
的概念,这里不会细讲,简单通过一段代码演示他们的执行顺序:
for (macroTask of macroTaskQueue) {
// 1. Handle current MACRO-TASK
handleMacroTask();
// 2. Handle all MICRO-TASK
for (microTask of microTaskQueue) {
handleMicroTask(microTask);
}
}
在浏览器环境中,常见的 macro task 有 setTimeout、MessageChannel、postMessage、setImmediate;常见的 micro task 有 MutationObsever 和 Promise.then。
Vue
的实现,在Vue
源码2.5+
后,nextTick
的实现单独有一个JS
文件来维护它,它的源码并不多,总共也就100
多行。接下来我们来看一下它的实现,在src/core/util/next-tick.js
中:
import { noop } from 'shared/util'
import { handleError } from './error'
import { isIOS, isNative } from './env'
const callbacks = []
let pending = false
function flushCallbacks () {
pending = false
const copies = callbacks.slice(0)
callbacks.length = 0
for (let i = 0; i < copies.length; i++) {
copies[i]()
}
}
// Here we have async deferring wrappers using both microtasks and (macro) tasks.
// In < 2.4 we used microtasks everywhere, but there are some scenarios where
// microtasks have too high a priority and fire in between supposedly
// sequential events (e.g. #4521, #6690) or even between bubbling of the same
// event (#6566). However, using (macro) tasks everywhere also has subtle problems
// when state is changed right before repaint (e.g. #6813, out-in transitions).
// Here we use microtask by default, but expose a way to force (macro) task when
// needed (e.g. in event handlers attached by v-on).
let microTimerFunc
let macroTimerFunc
let useMacroTask = false
// Determine (macro) task defer implementation.
// Technically setImmediate should be the ideal choice, but it's only available
// in IE. The only polyfill that consistently queues the callback after all DOM
// events triggered in the same loop is by using MessageChannel.
/* istanbul ignore if */
if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
macroTimerFunc = () => {
setImmediate(flushCallbacks)
}
} else if (typeof MessageChannel !== 'undefined' && (
isNative(MessageChannel) ||
// PhantomJS
MessageChannel.toString() === '[object MessageChannelConstructor]'
)) {
const channel = new MessageChannel()
const port = channel.port2
channel.port1.onmessage = flushCallbacks
macroTimerFunc = () => {
port.postMessage(1)
}
} else {
/* istanbul ignore next */
macroTimerFunc = () => {
setTimeout(flushCallbacks, 0)
}
}
// Determine microtask defer implementation.
/* istanbul ignore next, $flow-disable-line */
if (typeof Promise !== 'undefined' && isNative(Promise)) {
const p = Promise.resolve()
microTimerFunc = () => {
p.then(flushCallbacks)
// in problematic UIWebViews, Promise.then doesn't completely break, but
// it can get stuck in a weird state where callbacks are pushed into the
// microtask queue but the queue isn't being flushed, until the browser
// needs to do some other work, e.g. handle a timer. Therefore we can
// "force" the microtask queue to be flushed by adding an empty timer.
if (isIOS) setTimeout(noop)
}
} else {
// fallback to macro
microTimerFunc = macroTimerFunc
}
/**
* Wrap a function so that if any code inside triggers state change,
* the changes are queued using a (macro) task instead of a microtask.
*/
export function withMacroTask (fn: Function): Function {
return fn._withTask || (fn._withTask = function () {
useMacroTask = true
const res = fn.apply(null, arguments)
useMacroTask = false
return res
})
}
export function nextTick (cb?: Function, ctx?: Object) {
let _resolve
callbacks.push(() => {
if (cb) {
try {
cb.call(ctx)
} catch (e) {
handleError(e, ctx, 'nextTick')
}
} else if (_resolve) {
_resolve(ctx)
}
})
if (!pending) {
pending = true
if (useMacroTask) {
macroTimerFunc()
} else {
microTimerFunc()
}
}
// $flow-disable-line
if (!cb && typeof Promise !== 'undefined') {
return new Promise(resolve => {
_resolve = resolve
})
}
}
-
next-tick.js
申明了microTimerFunc
和macroTimerFunc
两个变量,它们分别对应的是micro task
的函数和macro task
的函数。对于macro task
的实现,优先检测是否支持原生setImmediate
,这是一个高版本IE
和Edge
才支持的特性,不支持的话再去检测是否支持原生的MessageChannel
,如果也不支持的话就会降级为setTimeout 0
;而对于micro task
的实现,则检测浏览器是否原生支持Promise
,不支持的话直接指向macro task
的实现。 -
next-tick.js
对外暴露了两个函数,先来看nextTick
,这就是我们在上一节执行nextTick(flushSchedulerQueue)
所用到的函数。它的逻辑也很简单,把传入的回调函数cb
压入callbacks
数组,最后一次性地根据useMacroTask
条件执行macroTimerFunc
或者是microTimerFunc
,而它们都会在下一个tick
执行flushCallbacks
,flushCallbacks
的逻辑非常简单,对callbacks
遍历,然后执行相应的回调函数。 -
这里使用
callbacks
而不是直接在nextTick
中执行回调函数的原因是保证在同一个tick
内多次执行nextTick
,不会开启多个异步任务,而把这些异步任务都压成一个同步任务,在下一个tick
执行完毕。 -
nextTick
函数最后还有一段逻辑,如下所示:
if (!cb && typeof Promise !== 'undefined') {
return new Promise(resolve => {
_resolve = resolve
})
}
这是当
nextTick
不传cb
参数的时候,提供一个 Promise 化的调用,比如:
nextTick().then(() => {})
当
_resolve
函数执行,就会跳到then
的逻辑中。
-
next-tick.js
还对外暴露了withMacroTask
函数,它是对函数做一层包装,确保函数执行过程中对数据任意的修改,触发变化执行nextTick
的时候强制走macroTimerFunc
。比如对于一些DOM
交互事件,如v-on
绑定的事件回调函数的处理,会强制走macro task
。 -
总结:通过对
nextTick
的分析,并结合setter
分析,我们了解到数据的变化到DOM
的重新渲染是一个异步过程,发生在下一个tick
。这就是我们平时在开发的过程中,比如从服务端接口去获取数据的时候,数据做了修改,如果我们的某些方法去依赖了数据修改后的DOM
变化,我们就必须在nextTick
后执行。比如下面的伪代码:
getData(res).then(()=>{
this.xxx = res.data
this.$nextTick(() => {
// 这里我们可以获取变化后的 DOM
})
})
Vue.js 提供了 2 种调用
nextTick
的方式,一种是全局 APIVue.nextTick
,一种是实例上的方法vm.$nextTick
,无论我们使用哪一种,最后都是调用next-tick.js
中实现的nextTick
方法。
二、检测变化的注意事项
-
对响应式数据对象以及它的
getter
和setter
部分做了了解,但是对于一些特殊情况是需要注意的,接下来我们就从源码的角度来看Vue
是如何处理这些特殊情况的。 -
对象添加属性,对于使用
Object.defineProperty
实现响应式的对象,当我们去给这个对象添加一个新的属性的时候,是不能够触发它的setter
的,比如:
var vm = new Vue({
data:{
a:1
}
})
// vm.b 是非响应的
vm.b = 2
但是添加新属性的场景我们在平时开发中会经常遇到,那么 Vue 为了解决这个问题,定义了一个全局 API
Vue.set
方法,它在src/core/global-api/index.js
中初始化:
Vue.set = set
这个
set
方法的定义在src/core/observer/index.js
中:
/**
* Set a property on an object. Adds the new property and
* triggers change notification if the property doesn't
* already exist.
*/
export function set (target: Array<any> | Object, key: any, val: any): any {
if (process.env.NODE_ENV !== 'production' &&
(isUndef(target) || isPrimitive(target))
) {
warn(`Cannot set reactive property on undefined, null, or primitive value: ${(target: any)}`)
}
if (Array.isArray(target) && isValidArrayIndex(key)) {
target.length = Math.max(target.length, key)
target.splice(key, 1, val)
return val
}
if (key in target && !(key in Object.prototype)) {
target[key] = val
return val
}
const ob = (target: any).__ob__
if (target._isVue || (ob && ob.vmCount)) {
process.env.NODE_ENV !== 'production' && warn(
'Avoid adding reactive properties to a Vue instance or its root $data ' +
'at runtime - declare it upfront in the data option.'
)
return val
}
if (!ob) {
target[key] = val
return val
}
defineReactive(ob.value, key, val)
ob.dep.notify()
return val
}
set
方法接收三个参数,target
可能是数组或者是普通对象,key
代表的是数组的下标或者是对象的键值,val
代表添加的值。首先判断如果target
是数组且key
是一个合法的下标,则之前通过splice
去添加进数组然后返回,这里的splice
其实已经不仅仅是原生数组的splice
了,后面详细介绍数组的逻辑。接着又判断key
已经存在于target
中,则直接赋值返回,因为这样的变化是可以观测到了。接着再获取到target.__ob__
并赋值给ob
,之前分析过它是在Observer
的构造函数执行的时候初始化的,表示Observer
的一个实例,如果它不存在,则说明target
不是一个响应式的对象,则直接赋值并返回。最后通过defineReactive(ob.value, key, val)
把新添加的属性变成响应式对象,然后再通过ob.dep.notify()
手动的触发依赖通知,还记得我们在给对象添加getter
的时候有这么一段逻辑:
export function defineReactive (
obj: Object,
key: string,
val: any,
customSetter?: ?Function,
shallow?: boolean
) {
// ...
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
},
// ...
})
}
-
在
getter
过程中判断了childOb
,并调用了childOb.dep.depend()
收集了依赖,这就是为什么执行Vue.set
的时候通过ob.dep.notify()
能够通知到watcher
,从而让添加新的属性到对象也可以检测到变化。这里如果value
是个数组,那么就通过dependArray
把数组每个元素也去做依赖收集。 -
数组,
Vue
也是不能检测到以下变动的数组,如下所示:
- 当你利用索引直接设置一个项时,例如:
vm.items[indexOfItem] = newValue
- 当你修改数组的长度时,例如:
vm.items.length = newLength
- 对于第一种情况,可以使用:
Vue.set(example1.items, indexOfItem, newValue)
;而对于第二种情况,可以使用vm.items.splice(newLength)
。
- 对于
Vue.set
的实现,当target
是数组的时候,也是通过target.splice(key, 1, val)
来添加的,那么这里的splice
到底有什么办法能让添加的对象变成响应式的呢。其实之前我们也分析过,在通过observe
方法去观察对象的时候会实例化Observer
,在它的构造函数中是专门对数组做了处理,它的定义在src/core/observer/index.js
中,如下所示:
export class Observer {
constructor (value: any) {
this.value = value
this.dep = new Dep()
this.vmCount = 0
def(value, '__ob__', this)
if (Array.isArray(value)) {
const augment = hasProto
? protoAugment
: copyAugment
augment(value, arrayMethods, arrayKeys)
this.observeArray(value)
} else {
// ...
}
}
}
- 这里我们只需要关注
value
是Array
的情况,首先获取augment
,这里的hasProto
实际上就是判断对象中是否存在__proto__
,如果存在则augment
指向protoAugment
, 否则指向copyAugment
,来看一下这两个函数的定义:
/**
* Augment an target Object or Array by intercepting
* the prototype chain using __proto__
*/
function protoAugment (target, src: Object, keys: any) {
/* eslint-disable no-proto */
target.__proto__ = src
/* eslint-enable no-proto */
}
/**
* Augment an target Object or Array by defining
* hidden properties.
*/
/* istanbul ignore next */
function copyAugment (target: Object, src: Object, keys: Array<string>) {
for (let i = 0, l = keys.length; i < l; i++) {
const key = keys[i]
def(target, key, src[key])
}
}
protoAugment
方法是直接把target.__proto__
原型直接修改为src
,而copyAugment
方法是遍历 keys,通过def
,也就是Object.defineProperty
去定义它自身的属性值。对于大部分现代浏览器都会走到protoAugment
,那么它实际上就把value
的原型指向了arrayMethods
,arrayMethods
的定义在src/core/observer/array.js
中:
import { def } from '../util/index'
const arrayProto = Array.prototype
export const arrayMethods = Object.create(arrayProto)
const methodsToPatch = [
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse'
]
/**
* Intercept mutating methods and emit events
*/
methodsToPatch.forEach(function (method) {
// cache original method
const original = arrayProto[method]
def(arrayMethods, method, function mutator (...args) {
const result = original.apply(this, args)
const ob = this.__ob__
let inserted
switch (method) {
case 'push':
case 'unshift':
inserted = args
break
case 'splice':
inserted = args.slice(2)
break
}
if (inserted) ob.observeArray(inserted)
// notify change
ob.dep.notify()
return result
})
})
-
可以看到,
arrayMethods
首先继承了Array
,然后对数组中所有能改变数组自身的方法,如push、pop
等这些方法进行重写。重写后的方法会先执行它们本身原有的逻辑,并对能增加数组长度的三个方法push、unshift、splice
方法做了判断,获取到插入的值,然后把新添加的值变成一个响应式对象,并且再调用ob.dep.notify()
手动触发依赖通知,这就很好地解释了之前的示例中调用vm.items.splice(newLength)
方法可以检测到变化。 -
总结:对响应式对象又有了更全面的认识,如果在实际工作中遇到了这些特殊情况,我们就可以知道如何把它们也变成响应式的对象。其实对于对象属性的删除也会用同样的问题,
Vue
同样提供了Vue.del
的全局API
,它的实现和Vue.set
大同小异,甚至还要更简单一些。
三、计算属性和侦听属性
-
Vue
的组件对象支持了计算属性computed
和侦听属性watch
两个选项,但是不了解什么时候该用computed
什么时候该用watch
。我们接下来从源码实现的角度来分析它们两者有什么区别。 -
computed
,计算属性的初始化是发生在Vue
实例初始化阶段的initState
函数中,执行了if (opts.computed) initComputed(vm, opts.computed)
,initComputed
的定义在src/core/instance/state.js
中:
const computedWatcherOptions = { computed: true }
function initComputed (vm: Component, computed: Object) {
// $flow-disable-line
const watchers = vm._computedWatchers = Object.create(null)
// computed properties are just getters during SSR
const isSSR = isServerRendering()
for (const key in computed) {
const userDef = computed[key]
const getter = typeof userDef === 'function' ? userDef : userDef.get
if (process.env.NODE_ENV !== 'production' && getter == null) {
warn(
`Getter is missing for computed property "${key}".`,
vm
)
}
if (!isSSR) {
// create internal watcher for the computed property.
watchers[key] = new Watcher(
vm,
getter || noop,
noop,
computedWatcherOptions
)
}
// component-defined computed properties are already defined on the
// component prototype. We only need to define computed properties defined
// at instantiation here.
if (!(key in vm)) {
defineComputed(vm, key, userDef)
} else if (process.env.NODE_ENV !== 'production') {
if (key in vm.$data) {
warn(`The computed property "${key}" is already defined in data.`, vm)
} else if (vm.$options.props && key in vm.$options.props) {
warn(`The computed property "${key}" is already defined as a prop.`, vm)
}
}
}
}
-
函数首先创建
vm._computedWatchers
为一个空对象,接着对computed
对象做遍历,拿到计算属性的每一个userDef
,然后尝试获取这个userDef
对应的getter
函数,拿不到则在开发环境下报警告。接下来为每一个getter
创建一个watcher
,这个watcher
和渲染watcher
有一点很大的不同,它是一个computed watcher
,因为const computedWatcherOptions = { computed: true }
。computed watcher
和普通watcher
的差别我稍后会介绍。最后对判断如果key
不是vm
的属性,则调用defineComputed(vm, key, userDef)
,否则判断计算属性对于的key
是否已经被data
或者prop
所占用,如果是的话则在开发环境报相应的警告。 -
那么接下来需要重点关注
defineComputed
的实现,如下所示:
export function defineComputed (
target: any,
key: string,
userDef: Object | Function
) {
const shouldCache = !isServerRendering()
if (typeof userDef === 'function') {
sharedPropertyDefinition.get = shouldCache
? createComputedGetter(key)
: userDef
sharedPropertyDefinition.set = noop
} else {
sharedPropertyDefinition.get = userDef.get
? shouldCache && userDef.cache !== false
? createComputedGetter(key)
: userDef.get
: noop
sharedPropertyDefinition.set = userDef.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)
}
- 这段逻辑很简单,其实就是利用
Object.defineProperty
给计算属性对应的key
值添加getter
和setter
,setter
通常是计算属性是一个对象,并且拥有set
方法的时候才有,否则是一个空函数。在平时的开发场景中,计算属性有setter
的情况比较少,我们重点关注一下getter
部分,缓存的配置也先忽略,最终getter
对应的是createComputedGetter(key)
的返回值,来看一下它的定义:
function createComputedGetter (key) {
return function computedGetter () {
const watcher = this._computedWatchers && this._computedWatchers[key]
if (watcher) {
watcher.depend()
return watcher.evaluate()
}
}
}
createComputedGetter
返回一个函数computedGetter
,它就是计算属性对应的 getter。
- 整个计算属性的初始化过程到此结束,我们知道计算属性是一个
computed watcher
,它和普通的watcher
有什么区别呢,为了更加直观,接下来来我们来通过一个例子来分析computed watcher
的实现,如下所示:
var vm = new Vue({
data: {
firstName: 'Foo',
lastName: 'Bar'
},
computed: {
fullName: function () {
return this.firstName + ' ' + this.lastName
}
}
})
当初始化这个
computed watcher
实例的时候,构造函数部分逻辑稍有不同:
constructor (
vm: Component,
expOrFn: string | Function,
cb: Function,
options?: ?Object,
isRenderWatcher?: boolean
) {
// ...
if (this.computed) {
this.value = undefined
this.dep = new Dep()
} else {
this.value = this.get()
}
}
可以发现
computed watcher
会并不会立刻求值,同时持有一个dep
实例。然后当我们的render
函数执行访问到this.fullName
的时候,就触发了计算属性的getter
,它会拿到计算属性对应的watcher
,然后执行watcher.depend()
,来看一下它的定义:
/**
* Depend on this watcher. Only for computed property watchers.
*/
depend () {
if (this.dep && Dep.target) {
this.dep.depend()
}
}
注意,这时候的
Dep.target
是渲染watcher
,所以this.dep.depend()
相当于渲染watcher
订阅了这个computed watcher
的变化。然后再执行watcher.evaluate()
去求值,来看一下它的定义:
/**
* Evaluate and return the value of the watcher.
* This only gets called for computed property watchers.
*/
evaluate () {
if (this.dirty) {
this.value = this.get()
this.dirty = false
}
return this.value
}
-
evaluate
的逻辑非常简单,判断this.dirty
,如果为true
则通过this.get()
求值,然后把this.dirty
设置为false
。在求值过程中,会执行value = this.getter.call(vm, vm)
,这实际上就是执行了计算属性定义的getter
函数,在我们这个例子就是执行了return this.firstName + ' ' + this.lastName
。 -
这里需要特别注意的是,由于
this.firstName
和this.lastName
都是响应式对象,这里会触发它们的getter
,根据我们之前的分析,它们会把自身持有的dep
添加到当前正在计算的watcher
中,这个时候Dep.target
就是这个computed watcher
。最后通过return this.value
拿到计算属性对应的值。我们知道了计算属性的求值过程,那么接下来看一下它依赖的数据变化后的逻辑。 -
一旦我们对计算属性依赖的数据做修改,则会触发
setter
过程,通知所有订阅它变化的watcher
更新,执行watcher.update()
方法,如下所示:
/* istanbul ignore else */
if (this.computed) {
// A computed property watcher has two modes: lazy and activated.
// It initializes as lazy by default, and only becomes activated when
// it is depended on by at least one subscriber, which is typically
// another computed property or a component's render function.
if (this.dep.subs.length === 0) {
// In lazy mode, we don't want to perform computations until necessary,
// so we simply mark the watcher as dirty. The actual computation is
// performed just-in-time in this.evaluate() when the computed property
// is accessed.
this.dirty = true
} else {
// In activated mode, we want to proactively perform the computation
// but only notify our subscribers when the value has indeed changed.
this.getAndInvoke(() => {
this.dep.notify()
})
}
} else if (this.sync) {
this.run()
} else {
queueWatcher(this)
}
- 那么对于计算属性这样的
computed watcher
,它实际上是有两种模式,lazy
和active
。如果this.dep.subs.length === 0
成立,则说明没有人去订阅这个computed watcher
的变化,仅仅把this.dirty = true
,只有当下次再访问这个计算属性的时候才会重新求值。在我们的场景下,渲染watcher
订阅了这个computed watcher
的变化,那么它会执行:
this.getAndInvoke(() => {
this.dep.notify()
})
getAndInvoke (cb: Function) {
const value = this.get()
if (
value !== this.value ||
// Deep watchers and watchers on Object/Arrays should fire even
// when the value is the same, because the value may
// have mutated.
isObject(value) ||
this.deep
) {
// set new value
const oldValue = this.value
this.value = value
this.dirty = false
if (this.user) {
try {
cb.call(this.vm, value, oldValue)
} catch (e) {
handleError(e, this.vm, `callback for watcher "${this.expression}"`)
}
} else {
cb.call(this.vm, value, oldValue)
}
}
}
-
getAndInvoke
函数会重新计算,然后对比新旧值,如果变化了则执行回调函数,那么这里这个回调函数是this.dep.notify()
,在我们这个场景下就是触发了渲染watcher
重新渲染。 -
通过以上的分析,我们知道计算属性本质上就是一个
computed watcher
,也了解了它的创建过程和被访问触发getter
以及依赖更新的过程,其实这是最新的计算属性的实现,之所以这么设计是因为Vue
想确保不仅仅是计算属性依赖的值发生变化,而是当计算属性最终计算的值发生变化才会触发渲染watcher
重新渲染,本质上是一种优化。 -
watch
,侦听属性的初始化也是发生在Vue
的实例初始化阶段的initState
函数中,在computed
初始化之后,执行了:
if (opts.watch && opts.watch !== nativeWatch) {
initWatch(vm, opts.watch)
}
来看一下
initWatch
的实现,它的定义在src/core/instance/state.js
中:
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)
}
}
}
这里就是对
watch
对象做遍历,拿到每一个handler
,因为 Vue 是支持watch
的同一个key
对应多个handler
,所以如果handler
是一个数组,则遍历这个数组,调用createWatcher
方法,否则直接调用createWatcher
:
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)
}
这里的逻辑也很简单,首先对
hanlder
的类型做判断,拿到它最终的回调函数,最后调用vm.$watch(keyOrFn, handler, options)
函数,$watch
是 Vue 原型上的方法,它是在执行stateMixin
的时候定义的:
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 || {}
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
最终会调用$watch
方法,这个方法首先判断cb
如果是一个对象,则调用createWatcher
方法,这是因为$watch
方法是用户可以直接调用的,它可以传递一个对象,也可以传递函数。接着执行const watcher = new Watcher(vm, expOrFn, cb, options)
实例化了一个watcher
,这里需要注意一点这是一个user watcher
,因为options.user = true
。通过实例化watcher
的方式,一旦我们watch
的数据发送变化,它最终会执行watcher
的run
方法,执行回调函数cb
,并且如果我们设置了immediate
为true
,则直接会执行回调函数cb
。最后返回了一个unwatchFn
方法,它会调用teardown
方法去移除这个watcher
。所以本质上侦听属性也是基于Watcher
实现的,它是一个user watcher
。其实Watcher
支持了不同的类型,下面我们看下它有哪些类型以及它们的作用。 -
Watcher options
,Watcher
的构造函数对options
做的了处理,代码如下:
if (options) {
this.deep = !!options.deep
this.user = !!options.user
this.computed = !!options.computed
this.sync = !!options.sync
// ...
} else {
this.deep = this.user = this.computed = this.sync = false
}
所以
watcher
总共有 4 种类型,我们来一一分析它们,看看不同的类型执行的逻辑有哪些差别。
deep watcher
,通常,如果我们想对一下对象做深度观测的时候,需要设置这个属性为true
,考虑到这种情况:
var vm = new Vue({
data() {
a: {
b: 1
}
},
watch: {
a: {
handler(newVal) {
console.log(newVal)
}
}
}
})
vm.a.b = 2
这个时候是不会 log 任何数据的,因为我们是 watch 了
a
对象,只触发了a
的 getter,并没有触发a.b
的 getter,所以并没有订阅它的变化,导致我们对vm.a.b = 2
赋值的时候,虽然触发了 setter,但没有可通知的对象,所以也并不会触发 watch 的回调函数了。而我们只需要对代码做稍稍修改,就可以观测到这个变化了,如下所示:
watch: {
a: {
deep: true,
handler(newVal) {
console.log(newVal)
}
}
}
- 这样就创建了一个
deep watcher
了,在watcher
执行get
求值的过程中有一段逻辑:
get() {
let value = this.getter.call(vm, vm)
// ...
if (this.deep) {
traverse(value)
}
}
在对 watch 的表达式或者函数求值后,会调用
traverse
函数,它的定义在src/core/observer/traverse.js
中:
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)
}
}
-
traverse
的逻辑也很简单,它实际上就是对一个对象做深层递归遍历,因为遍历过程中就是对一个子对象的访问,会触发它们的getter
过程,这样就可以收集到依赖,也就是订阅它们变化的watcher
,这个函数实现还有一个小的优化,遍历过程中会把子响应式对象通过它们的dep id
记录到seenObjects
,避免以后重复访问。 -
那么在执行了
traverse
后,我们再对watch
的对象内部任何一个值做修改,也会调用watcher
的回调函数了。对deep watcher
的理解非常重要,今后工作中如果大家观测了一个复杂对象,并且会改变对象内部深层某个值的时候也希望触发回调,一定要设置deep
为true
,但是因为设置了deep
后会执行traverse
函数,会有一定的性能开销,所以一定要根据应用场景权衡是否要开启这个配置。 -
user watcher
,通过vm.$watch
创建的watcher
是一个user watcher
,其实它的功能很简单,在对watcher
求值以及在执行回调函数的时候,会处理一下错误,如下:
get() {
if (this.user) {
handleError(e, vm, `getter for watcher "${this.expression}"`)
} else {
throw e
}
},
getAndInvoke() {
// ...
if (this.user) {
try {
this.cb.call(this.vm, value, oldValue)
} catch (e) {
handleError(e, this.vm, `callback for watcher "${this.expression}"`)
}
} else {
this.cb.call(this.vm, value, oldValue)
}
}
handleError
在 Vue 中是一个错误捕获并且暴露给用户的一个利器。
-
computed watcher
,computed watcher
几乎就是为计算属性量身定制的,我们刚才已经对它做了详细的分析,这里不再赘述了。 -
sync watcher
,在我们之前对setter
的分析过程知道,当响应式数据发送变化后,触发了watcher.update()
,只是把这个watcher
推送到一个队列中,在nextTick
后才会真正执行watcher
的回调函数。而一旦我们设置了sync
,就可以在当前Tick
中同步执行watcher
的回调函数,如下所示:
update () {
if (this.computed) {
// ...
} else if (this.sync) {
this.run()
} else {
queueWatcher(this)
}
}
只有当我们需要 watch 的值的变化到执行
watcher
的回调函数是一个同步过程的时候才会去设置该属性为 true。
- 总结:对计算属性和侦听属性的实现有了深入的了解,计算属性本质上是
computed watcher
,而侦听属性本质上是user watcher
。就应用场景而言,计算属性适合用在模板渲染中,某个值是依赖了其它的响应式对象甚至是计算属性计算而来;而侦听属性适用于观测某个值的变化去完成一段复杂的业务逻辑。同时我们又了解了watcher
的四个options
,通常我们会在创建user watcher
的时候配置deep
和sync
,可以根据不同的场景做相应的配置。
四、组件更新
- 之前已经讲了
Vue
的组件化实现过程,不过只有Vue
组件的创建过程,并没有涉及到组件数据发生变化,更新组件的过程。而现在对数据响应式原理的分析,了解到当数据发生变化的时候,会触发渲染watcher
的回调函数,进而执行组件的更新过程,接下来我们来详细分析这一过程,如下所示:
updateComponent = () => {
vm._update(vm._render(), hydrating)
}
new Watcher(vm, updateComponent, noop, {
before () {
if (vm._isMounted) {
callHook(vm, 'beforeUpdate')
}
}
}, true /* isRenderWatcher */)
- 组件的更新还是调用了
vm._update
方法,我们再回顾一下这个方法,它的定义在src/core/instance/lifecycle.js
中:
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
const vm: Component = this
// ...
const prevVnode = vm._vnode
if (!prevVnode) {
// initial render
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
} else {
// updates
vm.$el = vm.__patch__(prevVnode, vnode)
}
// ...
}
- 组件更新的过程,会执行
vm.$el = vm.__patch__(prevVnode, vnode)
,它仍然会调用patch
函数,在src/core/vdom/patch.js
中定义:
return function patch (oldVnode, vnode, hydrating, removeOnly) {
if (isUndef(vnode)) {
if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
return
}
let isInitialPatch = false
const insertedVnodeQueue = []
if (isUndef(oldVnode)) {
// empty mount (likely as component), create new root element
isInitialPatch = true
createElm(vnode, insertedVnodeQueue)
} else {
const isRealElement = isDef(oldVnode.nodeType)
if (!isRealElement && sameVnode(oldVnode, vnode)) {
// patch existing root node
patchVnode(oldVnode, vnode, insertedVnodeQueue, removeOnly)
} else {
if (isRealElement) {
// ...
}
// replacing existing element
const oldElm = oldVnode.elm
const parentElm = nodeOps.parentNode(oldElm)
// create new node
createElm(
vnode,
insertedVnodeQueue,
// extremely rare edge case: do not insert if old element is in a
// leaving transition. Only happens when combining transition +
// keep-alive + HOCs. (#4590)
oldElm._leaveCb ? null : parentElm,
nodeOps.nextSibling(oldElm)
)
// update parent placeholder node element, recursively
if (isDef(vnode.parent)) {
let ancestor = vnode.parent
const patchable = isPatchable(vnode)
while (ancestor) {
for (let i = 0; i < cbs.destroy.length; ++i) {
cbs.destroy[i](ancestor)
}
ancestor.elm = vnode.elm
if (patchable) {
for (let i = 0; i < cbs.create.length; ++i) {
cbs.create[i](emptyNode, ancestor)
}
// #6513
// invoke insert hooks that may have been merged by create hooks.
// e.g. for directives that uses the "inserted" hook.
const insert = ancestor.data.hook.insert
if (insert.merged) {
// start at index 1 to avoid re-invoking component mounted hook
for (let i = 1; i < insert.fns.length; i++) {
insert.fns[i]()
}
}
} else {
registerRef(ancestor)
}
ancestor = ancestor.parent
}
}
// destroy old node
if (isDef(parentElm)) {
removeVnodes(parentElm, [oldVnode], 0, 0)
} else if (isDef(oldVnode.tag)) {
invokeDestroyHook(oldVnode)
}
}
}
invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
return vnode.elm
}
- 这里执行
patch
的逻辑和首次渲染是不一样的,因为oldVnode
不为空,并且它和vnode
都是VNode
类型,接下来会通过sameVNode(oldVnode, vnode)
判断它们是否是相同的VNode
来决定走不同的更新逻辑:
function sameVnode (a, b) {
return (
a.key === b.key && (
(
a.tag === b.tag &&
a.isComment === b.isComment &&
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b)
) || (
isTrue(a.isAsyncPlaceholder) &&
a.asyncFactory === b.asyncFactory &&
isUndef(b.asyncFactory.error)
)
)
)
}
-
sameVnode
的逻辑非常简单,如果两个vnode
的key
不相等,则是不同的;否则继续判断对于同步组件,则判断isComment
、data
、input
类型等是否相同,对于异步组件,则判断asyncFactory
是否相同。所以根据新旧vnode
是否为sameVnode
,会走到不同的更新逻辑,我们先来说一下不同的情况。 -
新旧节点不同,如果新旧
vnode
不同,那么更新的逻辑非常简单,它本质上是要替换已存在的节点,大致分为三步,如下所示:
- 创建新节点,如下所示:
const oldElm = oldVnode.elm
const parentElm = nodeOps.parentNode(oldElm)
// create new node
createElm(
vnode,
insertedVnodeQueue,
// extremely rare edge case: do not insert if old element is in a
// leaving transition. Only happens when combining transition +
// keep-alive + HOCs. (#4590)
oldElm._leaveCb ? null : parentElm,
nodeOps.nextSibling(oldElm)
)
以当前旧节点为参考节点,创建新的节点,并插入到 DOM 中,
createElm
的逻辑我们之前分析过。
- 更新父的占位符节点,如下所示:
// update parent placeholder node element, recursively
if (isDef(vnode.parent)) {
let ancestor = vnode.parent
const patchable = isPatchable(vnode)
while (ancestor) {
for (let i = 0; i < cbs.destroy.length; ++i) {
cbs.destroy[i](ancestor)
}
ancestor.elm = vnode.elm
if (patchable) {
for (let i = 0; i < cbs.create.length; ++i) {
cbs.create[i](emptyNode, ancestor)
}
// #6513
// invoke insert hooks that may have been merged by create hooks.
// e.g. for directives that uses the "inserted" hook.
const insert = ancestor.data.hook.insert
if (insert.merged) {
// start at index 1 to avoid re-invoking component mounted hook
for (let i = 1; i < insert.fns.length; i++) {
insert.fns[i]()
}
}
} else {
registerRef(ancestor)
}
ancestor = ancestor.parent
}
}
我们只关注主要逻辑即可,找到当前
vnode
的父的占位符节点,先执行各个module
的destroy
的钩子函数,如果当前占位符是一个可挂载的节点,则执行module
的create
钩子函数。
- 删除旧节点,如下所示:
// destroy old node
if (isDef(parentElm)) {
removeVnodes(parentElm, [oldVnode], 0, 0)
} else if (isDef(oldVnode.tag)) {
invokeDestroyHook(oldVnode)
}
把
oldVnode
从当前 DOM 树中删除,如果父节点存在,则执行removeVnodes
方法:
function removeVnodes (parentElm, vnodes, startIdx, endIdx) {
for (; startIdx <= endIdx; ++startIdx) {
const ch = vnodes[startIdx]
if (isDef(ch)) {
if (isDef(ch.tag)) {
removeAndInvokeRemoveHook(ch)
invokeDestroyHook(ch)
} else { // Text node
removeNode(ch.elm)
}
}
}
}
function removeAndInvokeRemoveHook (vnode, rm) {
if (isDef(rm) || isDef(vnode.data)) {
let i
const listeners = cbs.remove.length + 1
if (isDef(rm)) {
// we have a recursively passed down rm callback
// increase the listeners count
rm.listeners += listeners
} else {
// directly removing
rm = createRmCb(vnode.elm, listeners)
}
// recursively invoke hooks on child component root node
if (isDef(i = vnode.componentInstance) && isDef(i = i._vnode) && isDef(i.data)) {
removeAndInvokeRemoveHook(i, rm)
}
for (i = 0; i < cbs.remove.length; ++i) {
cbs.remove[i](vnode, rm)
}
if (isDef(i = vnode.data.hook) && isDef(i = i.remove)) {
i(vnode, rm)
} else {
rm()
}
} else {
removeNode(vnode.elm)
}
}
function invokeDestroyHook (vnode) {
let i, j
const data = vnode.data
if (isDef(data)) {
if (isDef(i = data.hook) && isDef(i = i.destroy)) i(vnode)
for (i = 0; i < cbs.destroy.length; ++i) cbs.destroy[i](vnode)
}
if (isDef(i = vnode.children)) {
for (j = 0; j < vnode.children.length; ++j) {
invokeDestroyHook(vnode.children[j])
}
}
}
-
删除节点逻辑很简单,就是遍历待删除的
vnodes
做删除,其中removeAndInvokeRemoveHook
的作用是从DOM
中移除节点并执行module
的remove
钩子函数,并对它的子节点递归调用removeAndInvokeRemoveHook
函数;invokeDestroyHook
是执行module
的destory
钩子函数以及vnode
的destory
钩子函数,并对它的子vnode
递归调用invokeDestroyHook
函数;removeNode
就是调用平台的DOM API
去把真正的DOM
节点移除。 -
在之前组件生命周期的时候提到
beforeDestroy & destroyed
这两个生命周期钩子函数,它们就是在执行invokeDestroyHook
过程中,执行了vnode
的destory
钩子函数,它的定义在src/core/vdom/create-component.js
中:
const componentVNodeHooks = {
destroy (vnode: MountedComponentVNode) {
const { componentInstance } = vnode
if (!componentInstance._isDestroyed) {
if (!vnode.data.keepAlive) {
componentInstance.$destroy()
} else {
deactivateChildComponent(componentInstance, true /* direct */)
}
}
}
}
当组件并不是
keepAlive
的时候,会执行componentInstance.$destroy()
方法,然后就会执行beforeDestroy & destroyed
两个钩子函数。
- 新旧节点相同,对于新旧节点不同的情况,这种创建新节点 -> 更新占位符节点 -> 删除旧节点的逻辑是很容易理解的。还有一种组件
vnode
的更新情况是新旧节点相同,它会调用patchVNode
方法,它的定义在src/core/vdom/patch.js
中:
function patchVnode (oldVnode, vnode, insertedVnodeQueue, removeOnly) {
if (oldVnode === vnode) {
return
}
const elm = vnode.elm = oldVnode.elm
if (isTrue(oldVnode.isAsyncPlaceholder)) {
if (isDef(vnode.asyncFactory.resolved)) {
hydrate(oldVnode.elm, vnode, insertedVnodeQueue)
} else {
vnode.isAsyncPlaceholder = true
}
return
}
// reuse element for static trees.
// note we only do this if the vnode is cloned -
// if the new node is not cloned it means the render functions have been
// reset by the hot-reload-api and we need to do a proper re-render.
if (isTrue(vnode.isStatic) &&
isTrue(oldVnode.isStatic) &&
vnode.key === oldVnode.key &&
(isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
) {
vnode.componentInstance = oldVnode.componentInstance
return
}
let i
const data = vnode.data
if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {
i(oldVnode, vnode)
}
const oldCh = oldVnode.children
const ch = vnode.children
if (isDef(data) && isPatchable(vnode)) {
for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
if (isDef(i = data.hook) && isDef(i = i.update)) i(oldVnode, vnode)
}
if (isUndef(vnode.text)) {
if (isDef(oldCh) && isDef(ch)) {
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
} else if (isDef(ch)) {
if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
} else if (isDef(oldCh)) {
removeVnodes(elm, oldCh, 0, oldCh.length - 1)
} else if (isDef(oldVnode.text)) {
nodeOps.setTextContent(elm, '')
}
} else if (oldVnode.text !== vnode.text) {
nodeOps.setTextContent(elm, vnode.text)
}
if (isDef(data)) {
if (isDef(i = data.hook) && isDef(i = i.postpatch)) i(oldVnode, vnode)
}
}
patchVnode
的作用就是把新的vnode
patch
到旧的vnode
上,这里我们只关注关键的核心逻辑,我把它拆成四步骤:
- 执行
prepatch
钩子函数,如下所示:
let i
const data = vnode.data
if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {
i(oldVnode, vnode)
}
当更新的
vnode
是一个组件vnode
的时候,会执行prepatch
的方法,它的定义在src/core/vdom/create-component.js
中:
const componentVNodeHooks = {
prepatch (oldVnode: MountedComponentVNode, vnode: MountedComponentVNode) {
const options = vnode.componentOptions
const child = vnode.componentInstance = oldVnode.componentInstance
updateChildComponent(
child,
options.propsData, // updated props
options.listeners, // updated listeners
vnode, // new parent vnode
options.children // new children
)
}
}
prepatch
方法就是拿到新的vnode
的组件配置以及组件实例,去执行updateChildComponent
方法,它的定义在src/core/instance/lifecycle.js
中:
export function updateChildComponent (
vm: Component,
propsData: ?Object,
listeners: ?Object,
parentVnode: MountedComponentVNode,
renderChildren: ?Array<VNode>
) {
if (process.env.NODE_ENV !== 'production') {
isUpdatingChildComponent = true
}
// determine whether component has slot children
// we need to do this before overwriting $options._renderChildren
const hasChildren = !!(
renderChildren || // has new static slots
vm.$options._renderChildren || // has old static slots
parentVnode.data.scopedSlots || // has new scoped slots
vm.$scopedSlots !== emptyObject // has old scoped slots
)
vm.$options._parentVnode = parentVnode
vm.$vnode = parentVnode // update vm's placeholder node without re-render
if (vm._vnode) { // update child tree's parent
vm._vnode.parent = parentVnode
}
vm.$options._renderChildren = renderChildren
// update $attrs and $listeners hash
// these are also reactive so they may trigger child update if the child
// used them during render
vm.$attrs = parentVnode.data.attrs || emptyObject
vm.$listeners = listeners || emptyObject
// update props
if (propsData && vm.$options.props) {
toggleObserving(false)
const props = vm._props
const propKeys = vm.$options._propKeys || []
for (let i = 0; i < propKeys.length; i++) {
const key = propKeys[i]
const propOptions: any = vm.$options.props // wtf flow?
props[key] = validateProp(key, propOptions, propsData, vm)
}
toggleObserving(true)
// keep a copy of raw propsData
vm.$options.propsData = propsData
}
// update listeners
listeners = listeners || emptyObject
const oldListeners = vm.$options._parentListeners
vm.$options._parentListeners = listeners
updateComponentListeners(vm, listeners, oldListeners)
// resolve slots + force update if has children
if (hasChildren) {
vm.$slots = resolveSlots(renderChildren, parentVnode.context)
vm.$forceUpdate()
}
if (process.env.NODE_ENV !== 'production') {
isUpdatingChildComponent = false
}
}
-
updateChildComponent
的逻辑也非常简单,由于更新了vnode
,那么vnode
对应的实例vm
的一系列属性也会发生变化,包括占位符vm.$vnode
的更新、slot
的更新,listeners
的更新,props
的更新等等。 -
执行
update
钩子函数,如下所示:
if (isDef(data) && isPatchable(vnode)) {
for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
if (isDef(i = data.hook) && isDef(i = i.update)) i(oldVnode, vnode)
}
回到
patchVNode
函数,在执行完新的vnode
的prepatch
钩子函数,会执行所有module
的update
钩子函数以及用户自定义update
钩子函数,对于module
的钩子函数,之后我们会有具体的章节针对一些具体的 case 分析。
- 完成
patch
过程,如下所示:
const oldCh = oldVnode.children
const ch = vnode.children
if (isDef(data) && isPatchable(vnode)) {
for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
if (isDef(i = data.hook) && isDef(i = i.update)) i(oldVnode, vnode)
}
if (isUndef(vnode.text)) {
if (isDef(oldCh) && isDef(ch)) {
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
} else if (isDef(ch)) {
if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
} else if (isDef(oldCh)) {
removeVnodes(elm, oldCh, 0, oldCh.length - 1)
} else if (isDef(oldVnode.text)) {
nodeOps.setTextContent(elm, '')
}
} else if (oldVnode.text !== vnode.text) {
nodeOps.setTextContent(elm, vnode.text)
}
- 如果
vnode
是个文本节点且新旧文本不相同,则直接替换文本内容。如果不是文本节点,则判断它们的子节点,并分了几种情况处理:
oldCh
与ch
都存在且不相同时,使用updateChildren
函数来更新子节点。- 如果只有
ch
存在,表示旧节点不需要了。如果旧的节点是文本节点则先将节点的文本清除,然后通过addVnodes
将ch
批量插入到新节点elm
下。 - 如果只有
oldCh
存在,表示更新的是空节点,则需要将旧的节点通过removeVnodes
全部清除。 - 当只有旧节点是文本节点的时候,则清除其节点文本内容。
- 执行
postpatch
钩子函数,如下所示:
if (isDef(data)) {
if (isDef(i = data.hook) && isDef(i = i.postpatch)) i(oldVnode, vnode)
}
再执行完
patch
过程后,会执行postpatch
钩子函数,它是组件自定义的钩子函数,有则执行。那么在整个pathVnode
过程中,最复杂的就是updateChildren
方法了。
updateChildren
,如下所示:
function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
let oldStartIdx = 0
let newStartIdx = 0
let oldEndIdx = oldCh.length - 1
let oldStartVnode = oldCh[0]
let oldEndVnode = oldCh[oldEndIdx]
let newEndIdx = newCh.length - 1
let newStartVnode = newCh[0]
let newEndVnode = newCh[newEndIdx]
let oldKeyToIdx, idxInOld, vnodeToMove, refElm
// removeOnly is a special flag used only by <transition-group>
// to ensure removed elements stay in correct relative positions
// during leaving transitions
const canMove = !removeOnly
if (process.env.NODE_ENV !== 'production') {
checkDuplicateKeys(newCh)
}
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (isUndef(oldStartVnode)) {
oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
} else if (isUndef(oldEndVnode)) {
oldEndVnode = oldCh[--oldEndIdx]
} else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
} else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue)
canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue)
canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
} else {
if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
if (isUndef(idxInOld)) { // New element
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
} else {
vnodeToMove = oldCh[idxInOld]
if (sameVnode(vnodeToMove, newStartVnode)) {
patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue)
oldCh[idxInOld] = undefined
canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
} else {
// same key but different element. treat as new element
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
}
}
newStartVnode = newCh[++newStartIdx]
}
}
if (oldStartIdx > oldEndIdx) {
refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
} else if (newStartIdx > newEndIdx) {
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
}
}
updateChildren
的逻辑比较复杂,直接读源码比较晦涩,我们可以通过一个具体的示例来分析它。
<template>
<div id="app">
<div>
<ul>
<li v-for="item in items" :key="item.id">{{ item.val }}</li>
</ul>
</div>
<button @click="change">change</button>
</div>
</template>
<script>
export default {
name: 'App',
data() {
return {
items: [
{id: 0, val: 'A'},
{id: 1, val: 'B'},
{id: 2, val: 'C'},
{id: 3, val: 'D'}
]
}
},
methods: {
change() {
this.items.reverse().push({id: 4, val: 'E'})
}
}
}
</script>
- 总结:组件更新的过程核心就是新旧
vnode diff
,对新旧节点相同以及不同的情况分别做不同的处理。新旧节点不同的更新流程是创建新节点->更新父占位符节点->删除旧节点;而新旧节点相同的更新流程是去获取它们的children
,根据不同情况做不同的更新逻辑。最复杂的情况是新旧节点相同且它们都存在子节点,那么会执行updateChildren
逻辑。
五、Props 的理解
-
Props
作为组件的核心特性之一,也是我们平时开发Vue
项目中接触最多的特性之一,它可以让组件的功能变得丰富,也是父子组件通讯的一个渠道。那么它的实现原理是怎样的,我们来看一下。 -
规范化,在初始化
props
之前,首先会对props
做一次normalize
,它发生在mergeOptions
的时候,在src/core/util/options.js
中:
export function mergeOptions (
parent: Object,
child: Object,
vm?: Component
): Object {
// ...
normalizeProps(child, vm)
// ...
}
function normalizeProps (options: Object, vm: ?Component) {
const props = options.props
if (!props) return
const res = {}
let i, val, name
if (Array.isArray(props)) {
i = props.length
while (i--) {
val = props[i]
if (typeof val === 'string') {
name = camelize(val)
res[name] = { type: null }
} else if (process.env.NODE_ENV !== 'production') {
warn('props must be strings when using array syntax.')
}
}
} else if (isPlainObject(props)) {
for (const key in props) {
val = props[key]
name = camelize(key)
res[name] = isPlainObject(val)
? val
: { type: val }
}
} else if (process.env.NODE_ENV !== 'production') {
warn(
`Invalid value for option "props": expected an Array or an Object, ` +
`but got ${toRawType(props)}.`,
vm
)
}
options.props = res
}
合并配置,它主要就是处理我们定义组件的对象
option
,然后挂载到组件的实例this.$options
中。
- 我们接下来重点看
normalizeProps
的实现,其实这个函数的主要目的就是把我们编写的props
转成对象格式,因为实际上props
除了对象格式,还允许写成数组格式,如下所示:
-
当
props
是一个数组,每一个数组元素prop
只能是一个string
,表示prop
的key
,转成驼峰格式,prop
的类型为空。 -
当
props
是一个对象,对于props
中每个prop
的key
,我们会转驼峰格式,而它的value
,如果不是一个对象,我们就把它规范成一个对象。 -
如果
props
既不是数组也不是对象,就抛出一个警告。
- 如下所示,举个例子:
export default {
props: ['name', 'nick-name']
}
经过
normalizeProps
后,会被规范成:
options.props = {
name: { type: null },
nickName: { type: null }
}
export default {
props: {
name: String,
nickName: {
type: Boolean
}
}
}
经过
normalizeProps
后,会被规范成:
options.props = {
name: { type: String },
nickName: { type: Boolean }
}
由于对象形式的
props
可以指定每个prop
的类型和定义其它的一些属性,推荐用对象形式定义props
。
- 初始化,
Props
的初始化主要发生在new Vue
中的initState
阶段,在src/core/instance/state.js
中:
export function initState (vm: Component) {
// ....
const opts = vm.$options
if (opts.props) initProps(vm, opts.props)
// ...
}
function initProps (vm: Component, propsOptions: Object) {
const propsData = vm.$options.propsData || {}
const props = vm._props = {}
// cache prop keys so that future props updates can iterate using Array
// instead of dynamic object key enumeration.
const keys = vm.$options._propKeys = []
const isRoot = !vm.$parent
// root instance props should be converted
if (!isRoot) {
toggleObserving(false)
}
for (const key in propsOptions) {
keys.push(key)
const value = validateProp(key, propsOptions, propsData, vm)
/* istanbul ignore else */
if (process.env.NODE_ENV !== 'production') {
const hyphenatedKey = hyphenate(key)
if (isReservedAttribute(hyphenatedKey) ||
config.isReservedAttr(hyphenatedKey)) {
warn(
`"${hyphenatedKey}" is a reserved attribute and cannot be used as component prop.`,
vm
)
}
defineReactive(props, key, value, () => {
if (!isRoot && !isUpdatingChildComponent) {
warn(
`Avoid mutating a prop directly since the value will be ` +
`overwritten whenever the parent component re-renders. ` +
`Instead, use a data or computed property based on the prop's ` +
`value. Prop being mutated: "${key}"`,
vm
)
}
})
} else {
defineReactive(props, key, value)
}
// static props are already proxied on the component's prototype
// during Vue.extend(). We only need to proxy props defined at
// instantiation here.
if (!(key in vm)) {
proxy(vm, `_props`, key)
}
}
toggleObserving(true)
}
-
initProps
主要做三件事情:校验、响应式和代理。 -
校验,校验的逻辑很简单,遍历
propsOptions
,执行validateProp(key, propsOptions, propsData, vm)
方法。这里的propsOptions
就是我们定义的props
在规范后生成的options.props
对象,propsData
是从父组件传递的prop
数据。所谓校验的目的就是检查一下我们传递的数据是否满足prop
的定义规范。再来看一下validateProp
方法,它定义在src/core/util/options.js
中:
export function validateProp (
key: string,
propOptions: Object,
propsData: Object,
vm?: Component
): any {
const prop = propOptions[key]
const absent = !hasOwn(propsData, key)
let value = propsData[key]
// boolean casting
const booleanIndex = getTypeIndex(Boolean, prop.type)
if (booleanIndex > -1) {
if (absent && !hasOwn(prop, 'default')) {
value = false
} else if (value === '' || value === hyphenate(key)) {
// only cast empty string / same name to boolean if
// boolean has higher priority
const stringIndex = getTypeIndex(String, prop.type)
if (stringIndex < 0 || booleanIndex < stringIndex) {
value = true
}
}
}
// check default value
if (value === undefined) {
value = getPropDefaultValue(vm, prop, key)
// since the default value is a fresh copy,
// make sure to observe it.
const prevShouldObserve = shouldObserve
toggleObserving(true)
observe(value)
toggleObserving(prevShouldObserve)
}
if (
process.env.NODE_ENV !== 'production' &&
// skip validation for weex recycle-list child component props
!(__WEEX__ && isObject(value) && ('@binding' in value))
) {
assertProp(prop, key, value, vm, absent)
}
return value
}
validateProp
主要就做三件事情:处理Boolean
类型的数据,处理默认数据,prop
断言,并最终返回prop
的值。
先来看
Boolean
类型数据的处理逻辑,如下所示:
const prop = propOptions[key]
const absent = !hasOwn(propsData, key)
let value = propsData[key]
// boolean casting
const booleanIndex = getTypeIndex(Boolean, prop.type)
if (booleanIndex > -1) {
if (absent && !hasOwn(prop, 'default')) {
value = false
} else if (value === '' || value === hyphenate(key)) {
// only cast empty string / same name to boolean if
// boolean has higher priority
const stringIndex = getTypeIndex(String, prop.type)
if (stringIndex < 0 || booleanIndex < stringIndex) {
value = true
}
}
}
先通过
const booleanIndex = getTypeIndex(Boolean, prop.type)
来判断prop
的定义是否是Boolean
类型的,如下所示:
function getType (fn) {
const match = fn && fn.toString().match(/^\s*function (\w+)/)
return match ? match[1] : ''
}
function isSameType (a, b) {
return getType(a) === getType(b)
}
function getTypeIndex (type, expectedTypes): number {
if (!Array.isArray(expectedTypes)) {
return isSameType(expectedTypes, type) ? 0 : -1
}
for (let i = 0, len = expectedTypes.length; i < len; i++) {
if (isSameType(expectedTypes[i], type)) {
return i
}
}
return -1
}
getTypeIndex
函数就是找到type
和expectedTypes
匹配的索引并返回。
prop
类型定义的时候可以是某个原生构造函数,也可以是原生构造函数的数组,比如:
export default {
props: {
name: String,
value: [String, Boolean]
}
}
-
如果
expectedTypes
是单个构造函数,就执行isSameType
去判断是否是同一个类型;如果是数组,那么就遍历这个数组,找到第一个同类型的,返回它的索引。 -
回到
validateProp
函数,通过const booleanIndex = getTypeIndex(Boolean, prop.type)
得到booleanIndex
,如果prop.type
是一个Boolean
类型,则通过absent && !hasOwn(prop, 'default')
来判断如果父组件没有传递这个prop
数据并且没有设置default
的情况,则value
为 false。 -
接着判断
value === '' || value === hyphenate(key)
的情况,如果满足则先通过const stringIndex = getTypeIndex(String, prop.type)
获取匹配String
类型的索引,然后判断stringIndex < 0 || booleanIndex < stringIndex
的值来决定value
的值是否为true
。这块逻辑稍微有点绕,我们举两个例子来说明: -
例如你定义一个组件
Student
:
export default {
name: String,
nickName: [Boolean, String]
}
然后在父组件中引入这个组件:
<template>
<div>
<student name="Kate" nick-name></student>
</div>
</template>
或者是:
<template>
<div>
<student name="Kate" nick-name="nick-name"></student>
</div>
</template>
第一种情况没有写属性的值,满足
value === ''
,第二种满足value === hyphenate(key)
的情况,另外nickName
这个prop
的类型是Boolean
或者是String
,并且满足booleanIndex < stringIndex
,所以对nickName
这个prop
的value
为true
。接下来看一下默认数据处理逻辑:
// check default value
if (value === undefined) {
value = getPropDefaultValue(vm, prop, key)
// since the default value is a fresh copy,
// make sure to observe it.
const prevShouldObserve = shouldObserve
toggleObserving(true)
observe(value)
toggleObserving(prevShouldObserve)
}
- 当
value
的值为undefined
的时候,说明父组件根本就没有传这个prop
,那么我们就需要通过getPropDefaultValue(vm, prop, key)
获取这个prop
的默认值。我们这里只关注getPropDefaultValue
的实现,toggleObserving
和observe
的作用我们之后会说,如下所示:
function getPropDefaultValue (vm: ?Component, prop: PropOptions, key: string): any {
// no default, return undefined
if (!hasOwn(prop, 'default')) {
return undefined
}
const def = prop.default
// warn against non-factory defaults for Object & Array
if (process.env.NODE_ENV !== 'production' && isObject(def)) {
warn(
'Invalid default value for prop "' + key + '": ' +
'Props with type Object/Array must use a factory function ' +
'to return the default value.',
vm
)
}
// the raw prop value was also undefined from previous render,
// return previous default value to avoid unnecessary watcher trigger
if (vm && vm.$options.propsData &&
vm.$options.propsData[key] === undefined &&
vm._props[key] !== undefined
) {
return vm._props[key]
}
// call factory function for non-Function types
// a value is Function if its prototype is function even across different execution context
return typeof def === 'function' && getType(prop.type) !== 'Function'
? def.call(vm)
: def
}
-
检测如果
prop
没有定义default
属性,那么返回undefined
,通过这块逻辑我们知道除了Boolean
类型的数据,其余没有设置default
属性的prop
默认值都是undefined
。 -
接着是开发环境下对
prop
的默认值是否为对象或者数组类型的判断,如果是的话会报警告,因为对象和数组类型的prop
,他们的默认值必须要返回一个工厂函数。 -
接下来的判断是如果上一次组件渲染父组件传递的
prop
的值是undefined
,则直接返回 上一次的默认值vm._props[key]
,这样可以避免触发不必要的watcher
的更新。 -
最后就是判断
def
如果是工厂函数且prop
的类型不是Function
的时候,返回工厂函数的返回值,否则直接返回def
。 -
至此,我们讲完了
validateProp
函数的Boolean
类型数据的处理逻辑和默认数据处理逻辑,最后来看一下prop
断言逻辑,如下所示:
if (
process.env.NODE_ENV !== 'production' &&
// skip validation for weex recycle-list child component props
!(__WEEX__ && isObject(value) && ('@binding' in value))
) {
assertProp(prop, key, value, vm, absent)
}
在开发环境且非
weex
的某种环境下,执行assertProp
做属性断言,如下所示:
function assertProp (
prop: PropOptions,
name: string,
value: any,
vm: ?Component,
absent: boolean
) {
if (prop.required && absent) {
warn(
'Missing required prop: "' + name + '"',
vm
)
return
}
if (value == null && !prop.required) {
return
}
let type = prop.type
let valid = !type || type === true
const expectedTypes = []
if (type) {
if (!Array.isArray(type)) {
type = [type]
}
for (let i = 0; i < type.length && !valid; i++) {
const assertedType = assertType(value, type[i])
expectedTypes.push(assertedType.expectedType || '')
valid = assertedType.valid
}
}
if (!valid) {
warn(
getInvalidTypeMessage(name, value, expectedTypes),
vm
)
return
}
const validator = prop.validator
if (validator) {
if (!validator(value)) {
warn(
'Invalid prop: custom validator check failed for prop "' + name + '".',
vm
)
}
}
}
assertProp
函数的目的是断言这个prop
是否合法,如下所示:
-
首先判断如果
prop
定义了required
属性但父组件没有传递这个prop
数据的话会报一个警告。 -
接着判断如果
value
为空且prop
没有定义required
属性则直接返回。 -
然后再去对
prop
的类型做校验,先是拿到prop
中定义的类型type
,并尝试把它转成一个类型数组,然后依次遍历这个数组,执行assertType(value, type[i])
去获取断言的结果,直到遍历完成或者是valid
为true
的时候跳出循环:
const simpleCheckRE = /^(String|Number|Boolean|Function|Symbol)$/
function assertType (value: any, type: Function): {
valid: boolean;
expectedType: string;
} {
let valid
const expectedType = getType(type)
if (simpleCheckRE.test(expectedType)) {
const t = typeof value
valid = t === expectedType.toLowerCase()
// for primitive wrapper objects
if (!valid && t === 'object') {
valid = value instanceof type
}
} else if (expectedType === 'Object') {
valid = isPlainObject(value)
} else if (expectedType === 'Array') {
valid = Array.isArray(value)
} else {
valid = value instanceof type
}
return {
valid,
expectedType
}
}
-
assertType
的逻辑很简单,先通过getType(type)
获取prop
期望的类型expectedType
,然后再去根据几种不同的情况对比prop
的值value
是否和expectedType
匹配,最后返回匹配的结果。 -
如果循环结束后
valid
仍然为false
,那么说明prop
的值value
与prop
定义的类型都不匹配,那么就会输出一段通过getInvalidTypeMessage(name, value, expectedTypes)
生成的警告信息,就不细说了。 -
最后判断当
prop
自己定义了validator
自定义校验器,则执行validator
校验器方法,如果校验不通过则输出警告信息。
-
响应式,回到
initProps
方法,当我们通过const value = validateProp(key, propsOptions, propsData, vm)
对prop
做验证并且获取到prop
的值后,接下来需要通过defineReactive
把prop
变成响应式。 -
defineReactive
我们之前已经介绍过,这里要注意的是,在开发环境中我们会校验prop
的key
是否是HTML
的保留属性,并且在defineReactive
的时候会添加一个自定义setter
,当我们直接对prop
赋值的时候会输出警告:
if (process.env.NODE_ENV !== 'production') {
const hyphenatedKey = hyphenate(key)
if (isReservedAttribute(hyphenatedKey) ||
config.isReservedAttr(hyphenatedKey)) {
warn(
`"${hyphenatedKey}" is a reserved attribute and cannot be used as component prop.`,
vm
)
}
defineReactive(props, key, value, () => {
if (!isRoot && !isUpdatingChildComponent) {
warn(
`Avoid mutating a prop directly since the value will be ` +
`overwritten whenever the parent component re-renders. ` +
`Instead, use a data or computed property based on the prop's ` +
`value. Prop being mutated: "${key}"`,
vm
)
}
})
}
关于
prop
的响应式有一点不同的是当vm
是非根实例的时候,会先执行toggleObserving(false)
,它的目的是为了响应式的优化。
- 代理,在经过响应式处理后,我们会把
prop
的值添加到vm._props
中,比如key
为name
的prop
,它的值保存在vm._props.name
中,但是我们在组件中可以通过this.name
访问到这个prop
,这就是代理做的事情,如下所示:
// static props are already proxied on the component's prototype
// during Vue.extend(). We only need to proxy props defined at
// instantiation here.
if (!(key in vm)) {
proxy(vm, `_props`, key)
}
通过
proxy
函数实现了上述需求。
const sharedPropertyDefinition = {
enumerable: true,
configurable: true,
get: noop,
set: noop
}
export function proxy (target: Object, sourceKey: string, key: string) {
sharedPropertyDefinition.get = function proxyGetter () {
return this[sourceKey][key]
}
sharedPropertyDefinition.set = function proxySetter (val) {
this[sourceKey][key] = val
}
Object.defineProperty(target, key, sharedPropertyDefinition)
}
- 当访问
this.name
的时候就相当于访问this._props.name
。其实对于非根实例的子组件而言,prop
的代理发生在Vue.extend
阶段,在src/core/global-api/extend.js
中:
Vue.extend = function (extendOptions: Object): Function {
// ...
const Sub = function VueComponent (options) {
this._init(options)
}
// ...
// For props and computed properties, we define the proxy getters on
// the Vue instances at extension time, on the extended prototype. This
// avoids Object.defineProperty calls for each instance created.
if (Sub.options.props) {
initProps(Sub)
}
if (Sub.options.computed) {
initComputed(Sub)
}
// ...
return Sub
}
function initProps (Comp) {
const props = Comp.options.props
for (const key in props) {
proxy(Comp.prototype, `_props`, key)
}
}
这么做的好处是不用为每个组件实例都做一层
proxy
,是一种优化手段。
-
Props 更新,当父组件传递给子组件的
props
值变化,子组件对应的值也会改变,同时会触发子组件的重新渲染。那么接下来我们就从源码角度来分析这两个过程。 -
子组件
props
更新,如下所示:
-
首先,
prop
数据的值变化在父组件,我们知道在父组件的render
过程中会访问到这个prop
数据,所以当prop
数据变化一定会触发父组件的重新渲染,那么重新渲染是如何更新子组件对应的prop
的值呢? -
在父组件重新渲染的最后,会执行
patch
过程,进而执行patchVnode
函数,patchVnode
通常是一个递归过程,当它遇到组件vnode
的时候,会执行组件更新过程的prepatch
钩子函数,在src/core/vdom/patch.js
中:
function patchVnode (
oldVnode,
vnode,
insertedVnodeQueue,
ownerArray,
index,
removeOnly
) {
// ...
let i
const data = vnode.data
if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {
i(oldVnode, vnode)
}
// ...
}
prepatch
函数定义在src/core/vdom/create-component.js
中:
prepatch (oldVnode: MountedComponentVNode, vnode: MountedComponentVNode) {
const options = vnode.componentOptions
const child = vnode.componentInstance = oldVnode.componentInstance
updateChildComponent(
child,
options.propsData, // updated props
options.listeners, // updated listeners
vnode, // new parent vnode
options.children // new children
)
}
- 内部会调用
updateChildComponent
方法来更新props
,注意第二个参数就是父组件的propData
,那么为什么vnode.componentOptions.propsData
就是父组件传递给子组件的prop
数据呢(这个也同样解释了第一次渲染的propsData
来源)?原来在组件的render
过程中,对于组件节点会通过createComponent
方法来创建组件vnode
:
export function createComponent (
Ctor: Class<Component> | Function | Object | void,
data: ?VNodeData,
context: Component,
children: ?Array<VNode>,
tag?: string
): VNode | Array<VNode> | void {
// ...
// extract props
const propsData = extractPropsFromVNodeData(data, Ctor, tag)
// ...
const vnode = new VNode(
`vue-component-${Ctor.cid}${name ? `-${name}` : ''}`,
data, undefined, undefined, undefined, context,
{ Ctor, propsData, listeners, tag, children },
asyncFactory
)
// ...
return vnode
}
- 在创建组件
vnode
的过程中,首先从data
中提取出propData
,然后在new VNode
的时候,作为第七个参数VNodeComponentOptions
中的一个属性传入,所以我们可以通过vnode.componentOptions.propsData
拿到prop
数据。接着看updateChildComponent
函数,它的定义在src/core/instance/lifecycle.js
中:
export function updateChildComponent (
vm: Component,
propsData: ?Object,
listeners: ?Object,
parentVnode: MountedComponentVNode,
renderChildren: ?Array<VNode>
) {
// ...
// update props
if (propsData && vm.$options.props) {
toggleObserving(false)
const props = vm._props
const propKeys = vm.$options._propKeys || []
for (let i = 0; i < propKeys.length; i++) {
const key = propKeys[i]
const propOptions: any = vm.$options.props // wtf flow?
props[key] = validateProp(key, propOptions, propsData, vm)
}
toggleObserving(true)
// keep a copy of raw propsData
vm.$options.propsData = propsData
}
// ...
}
-
我们重点来看更新
props
的相关逻辑,这里的propsData
是父组件传递的props
数据,vm
是子组件的实例。vm._props
指向的就是子组件的props
值,propKeys
就是在之前initProps
过程中,缓存的子组件中定义的所有prop
的key
。主要逻辑就是遍历propKeys
,然后执行props[key] = validateProp(key, propOptions, propsData, vm)
重新验证和计算新的prop
数据,更新vm._props
,也就是子组件的props
,这个就是子组件props
的更新过程。 -
子组件重新渲染,其实子组件的重新渲染有两种情况,一个是
prop
值被修改,另一个是对象类型的prop
内部属性的变化。
-
先来看一下
prop
值被修改的情况,当执行props[key] = validateProp(key, propOptions, propsData, vm)
更新子组件prop
的时候,会触发prop
的setter
过程,只要在渲染子组件的时候访问过这个prop
值,那么根据响应式原理,就会触发子组件的重新渲染。 -
再来看一下当对象类型的
prop
的内部属性发生变化的时候,这个时候其实并没有触发子组件prop
的更新。但是在子组件的渲染过程中,访问过这个对象prop
,所以这个对象prop
在触发getter
的时候会把子组件的render watcher
收集到依赖中,然后当我们在父组件更新这个对象prop
的某个属性的时候,会触发setter
过程,也就会通知子组件render watcher
的update
,进而触发子组件的重新渲染。 -
以上就是当父组件
props
更新,触发子组件重新渲染的两种情况。
toggleObserving
,最后toggleObserving
,它的定义在src/core/observer/index.js
中:
export let shouldObserve: boolean = true
export function toggleObserving (value: boolean) {
shouldObserve = value
}
它在当前模块中定义了
shouldObserve
变量,用来控制在observe
的过程中是否需要把当前值变成一个Observer
对象。那么为什么在props
的初始化和更新过程中,多次执行toggleObserving(false)
呢,接下来我们就来分析这几种情况。
- 在
initProps
的过程中,如下所示:
const isRoot = !vm.$parent
// root instance props should be converted
if (!isRoot) {
toggleObserving(false)
}
for (const key in propsOptions) {
// ...
const value = validateProp(key, propsOptions, propsData, vm)
defineReactive(props, key, value)
// ...
}
toggleObserving(true)
对于非根实例的情况,我们会执行
toggleObserving(false)
,然后对于每一个prop
值,去执行defineReactive(props, key, value)
去把它变成响应式。回顾一下defineReactive
的定义:
export function defineReactive (
obj: Object,
key: string,
val: any,
customSetter?: ?Function,
shallow?: boolean
) {
// ...
let childOb = !shallow && observe(val)
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter () {
// ...
},
set: function reactiveSetter (newVal) {
// ...
}
})
}
-
通常对于值
val
会执行observe
函数,然后遇到val
是对象或者数组的情况会递归执行defineReactive
把它们的子属性都变成响应式的,但是由于shouldObserve
的值变成了false
,这个递归过程被省略了。为什么会这样呢? -
因为正如我们前面分析的,对于对象的
prop
值,子组件的prop
值始终指向父组件的prop
值,只要父组件的prop
值变化,就会触发子组件的重新渲染,所以这个observe
过程是可以省略的。最后再执行toggleObserving(true)
恢复shouldObserve
为true
。在validateProp
的过程中:
// check default value
if (value === undefined) {
value = getPropDefaultValue(vm, prop, key)
// since the default value is a fresh copy,
// make sure to observe it.
const prevShouldObserve = shouldObserve
toggleObserving(true)
observe(value)
toggleObserving(prevShouldObserve)
}
- 这种是父组件没有传递
prop
值对默认值的处理逻辑,因为这个值是一个拷贝,所以我们需要toggleObserving(true)
,然后执行observe(value)
把值变成响应式。在updateChildComponent
过程中:
// update props
if (propsData && vm.$options.props) {
toggleObserving(false)
const props = vm._props
const propKeys = vm.$options._propKeys || []
for (let i = 0; i < propKeys.length; i++) {
const key = propKeys[i]
const propOptions: any = vm.$options.props // wtf flow?
props[key] = validateProp(key, propOptions, propsData, vm)
}
toggleObserving(true)
// keep a copy of raw propsData
vm.$options.propsData = propsData
}
其实和
initProps
的逻辑一样,不需要对引用类型props
递归做响应式处理,所以也需要toggleObserving(false)
。
- 总结:了解了
props
的规范化、初始化、更新等过程的实现原理;也了解了Vue
内部对props
如何做响应式的优化;同时还了解到props
的变化是如何触发子组件的更新。了解这些对我们平时对props
的应用,遇到问题时的定位追踪会有很大的帮助。