生命周期
生命周期各阶段
如图所示,生命周期可以分为四个阶段:初始化阶段、模板编译阶段、挂载阶段、卸载阶段
初始化阶段
如图所示,new Vue()
到created
之间的阶段叫作初始化阶段。
这个阶段主要的目的是在实例上初始化一些属性、事件、以及响应式数据,如props
、methods
、data
、computed
、watch
、provide
、inject
等。
模板编译阶段
如图所示,在created
和beforeMount
钩子函数之间的阶段是模板编译阶段。
主要目的是将模板编译为渲染函数,只存在于完整版中。
当使用vue-loader
或者vueify
时,*.vue
文件内部的模板会在构建时预编译成JavaScript
,所以最终打包好的包里是不需要编译器的,用运行时版本即可。由于这时模板已经预编译成渲染函数,所以在生命周期中并不存在模板编译阶段,初始化阶段的下一个生命周期是挂载阶段。
挂载阶段
如图所示,beforeMount
钩子函数到mounted
钩子函数之间是挂载阶段。
Vue.js
会将其实例挂载到DOM
元素上,通俗的讲,就是将模板渲染到指定的DOM
元素中。在挂载的过程中,Vue.js
会开启watcher
来持续追踪依赖的变化。
在已经挂载的状态下,仍然会持续追踪状态的变化。当数据状态发生变化时,Watcher
会通知虚拟DOM重新渲染视图,并且会在渲染视图前触发beforeUpdate
钩子函数,渲染完毕后触发updated
钩子函数。
通常,在运行时大部分时间下,Vue.js
处于已经挂载状态,每当状态发生变化时,Vue.js
都会通知组件使用虚拟DOM重新渲染,也就是常说的响应式。这个状态会持续到组件被销毁。
卸载阶段
如图所示,应用调用vm.$destroy
方法后,Vue.js
的生命周期会进入卸载阶段。
在这个阶段,Vue.js
会将自身从父组件中删除,取消实例上所有依赖的追踪并且移除所有的事件监听器。
小结
生命周期整体上可以分为两部分:第一部分是初始化阶段、模板编译阶段与挂载阶段,第二部分是卸载阶段。
从源码角度了解生命周期
主要介绍初始化阶段的内部原理。
new Vue()被调用时发生了什么
当new Vue()
被调用时,会首先进行一些初始化操作,然后进入模板编译阶段,最后进入挂载阶段。
具体实现:
function Vue(){
if(process.env.NODE_ENV !== 'production' && !(this instanceof Vue)){
warn('Vue is a constructor and should be called with the `new` keyword')
}
this._init(options)
}
export default Vue
先进行安全检查,在非生产环境下,如果没有使用new
来调用Vue,则会在控制台抛出错误警告:Vue是构造函数,应该使用new关键字调用。
然后调用this._init(options)
来执行生命周期初始化流程。也就是说,生命周期的初始化流程在this,_init
中实现。
_init方法的定义
Vue.js通过调用initMixin
方法将_init
挂载到Vue构造函数的原型上,代码如下:
import { initMixin } from './init';
function Vue(){
if(process.env.NODE_ENV !== 'production' && !(this instanceof Vue)){
warn('Vue is a constructor and should be called with the `new` keyword')
}
this._init(options)
}
initMixin(Vue);
export default Vue
将init.js
文件导出的initMixin
函数引入后,通过调用initMixin
函数想Vue
构造函数的原型上挂载一些方法。initMixin
方法的实现代码如下:
export function initMixin(Vue){
Vue.prototype._init = function(options){
// do things
}
}
只是在Vue构造函数的prptotype
属性上添加了一个_init
方法。
_init方法的内部原理
当new Vue()
执行后,触发的一系列初始化流程都是在_initr
方法中启动的。
Vue.prototype._init = function(options){
vm.$options = mergeOptions(
resolveConstructorOptions(vm.constructor),
options || {},
vm
)
initLifecycle(vm);
initEvents(vm);
initRender(vm);
callHook(vm,'beforeCreate');
initInjections(vm); //在data/props前初始化inject
initState(vm);
initProvide(vm); //在data/props后初始化provide
callHook(vm,'created');
// 如果用户在实例化Vue.js时传递了el选项,则自动开启模板编译阶段与挂载阶段
// 如果没有传递el选项,则不进入下个生命周期流程
// 用户需要执行vm.$mount方法,手动开启模板编译阶段与挂载阶段
if (vm.$options.el) {
vm.$mount(vm.$options.el)
}
}
Vue.js会在初始化流程的不同时期通过callHook
函数触发生命周期钩子。
在执行初始化流程之前,实例上挂载了$options
属性。目的是将用户传递的options
选项与当前构造函数的options属性及其父级实例构造函数的options
属性,合并成一个新的options
并赋值给$options
属性。resolveConstructorOptions
的作用就是获取当当前实例中构造函数的options
选项及其所有父级的构造函数的options
。
在生命周期beforeCreate
被触发之前执行了initLifecycle
、initEvents
和initRender
。在初始化过程中,首先初始化事件与属性,然后触发生命周期钩子beforeCreate
。随后初始化provide/inject
和状态,这里的状态指的是props
、methods
、data
、computed
以及watch
。接着触发生命周期钩子created
。最后,判断用户是否在参数中提供了el选项,如果是,则调用vm.$mount
方法,进入后面的生命周期阶段。
callHook函数的实现原理
callHook
的作用是触发用户设置的生命周期钩子,而用户设置的生命周期钩子,而用户设置的生命周期钩子会在执行new Vue()
时通过参数传递给Vue.js
。也就是说,可以在Vue.js
的构造函数中通过options
参数得到用户设置的生命周期钩子。
用户传入的options
参数最终会与构造函数的options
属性合并成一个新的options
并赋值到vm.$options
属性中,所以我们可以通过vm.$options
得到用户设置的生命周期函数。例如:通过vm.$options.created
得到用户设置的created
钩子函数。
值得注意的是,Vue,js
在合并options
的过程中会找出options
中所有key
是钩子函数的名字,并将它转换成数组。
下面列出了所有生命周期钩子的函数名:
- beforeCreate
- created
- beforeMount
- mounted
- beforeUpdate
- updated
- beforeDestroy
- destroyed
- activated
- deactivated
- errorCaptured
也就是说,通过vm.$options.created
获取的是一个数组,数组中包含了钩子函数,例如:
console.log(vm.$options.created);
为什么要把生命周期钩子转换成数组?
Vue,mixin
方法会将选项写入Vue,options
中,因此它会影响之后创建的所有Vue,js
实例,而Vue.js
在初始化时会将构造函数中的options
和用户传入的options
选项合并成一个新的选项并赋值给vm.$options
,所以这里会发生一个现象:Vue.mixin
和用户在实例化Vue.js
时,如果设置了同一个生命周期钩子,那么在触发生命周期时,需要同时触发这两个函数。而转换成数组后,可以在同一个生命周期钩子列表中保存多个生命周期钩子 。
那么我们就可以知道,callHook
的实现只需要从vm.$options
中获取生命周期钩子列表,遍历列表,执行每一个生命周期钩子,就可以触发钩子函数,
export function callHook(vm,hook){
const handlers = vm.$options[hook];
if (handlers) {
for (let i=0;i<handlers.length;i++) {
try {
handlers[i].call(vm);
} catch (e) {
handleError(e, vm, `${hook} hook`)
}
}
}
}
上面的代码给出了callHook
的实现原理,它接受vm
和hook
两个参数,其中前者是Vue.js
实例的this
,后者是生命周期钩子的名称。
我们使用hook
和vm.$options
中获取钩子函数列表后赋值给handlers
,随后遍历handlers
,执行每一个钩子函数。
这里使用try,,catch
语句捕获钩子函数内发生的错误,并使用handleError
处理错误。handleError
会依次执行父组件的errorCaptured
钩子函数与全局的config.errorHandler
,这也是为什么生命周期钩子errorCaptured
可以捕获子孙组件的错误。关于handleError
与生命周期钩子errorCaptured
,我们会在随后的内容中详细介绍。
errorCaptured与错误处理
errorCaptured
钩子函数的作用是捕获来自子孙组件的错误,此钩子函数会收到三个参数:错误对象、发生错误的组件实例和包含错误来源的字符串。然后此钩子函数可以返回false
,阻止该错误继续向上传播。
传播规则如下:
- 默认情况下,如果全局的
config.errorHandler
被定义,那么所有的错误都会发送给它,这样这些错误可以在单个位置报告给分析服务。 - 如果一个组件继承的链路或其父级从属链路中存在多个
errorCaptured
钩子,则它们将会被相同的错误逐个唤起。 - 如果
errorCaptured
钩子函数自身抛出了一个错误,则这个新错误和原本被捕获的错误都会发送给全局的config.errorHandler
。 - 一个
errorCaptured
钩子函数能够返回false
来阻止错误继续向上传播。本质上是说:“这个错误已经被搞定,应该被忽略”。它会阻止其他被这个错误唤起的errorCaptured
钩子函数和全局的config.errorHandler
。
事实上,errorCaptured
钩子函数与Vue.js
的错误处理有着千丝万缕的关系。Vue.js
会捕获所有用户代码抛出的错误,然后会使用一个名叫handleError
的函数来处理这些错误。
用户编写的所有函数都是Vue.js
调用的,例如用户在代码中注册的事件、生命周期钩子、渲染函数、函数类型的data属性、vm.$watch
的第一个参数(函数类型)、nextTick
和指令等。
Vue.js
在调用这些函数时,会使用try...catch
语句来捕获有可能发生的错误。当错误发生并且被try...catch
语句捕获后,Vue.js
会使用handleError
函数来处理错误,该函数会依次触发父组件链路上的每一个父组件中定义的errorCaptured
钩子函数。如果全局的config.errorHandler
被定义,那么所有的错误也会同时发送给config.errorHandler
。也就是说,错误的传播规则是在handleError
函数中实现的。
handleError
函数的实现原理并不复杂。根据前面的传播规则,我们先实现第一个需求:将所有错误发送给config. errorHandler
,相关代码如下:
export function handleError(err, vm, info) {
// 这里的config.errorHandler就是Vue.config.errorHandler
if (config.errorHandler) {
try {
return config.errorHandler.call(null, err, vm, info)
} catch (e) {
logError(err);
}
}
}
function logError(err){
console.error(err);
}
这里先判断Vue.config. errorHandler
是否存在,如果存在,则调用它,并将错误对象、发生错误的组件实例以及一个包含错误来源信息的字符串通过参数的方式传递给它,并且使用try...catch
语句捕获错误。如果全局错误处理的函数也发生报错,则在控制台打印其中抛出的错误。不论用户是否使用Vue.config.errorHandler
捕获错误, Vue.js
都会将错误信息打印在控制台。
接下来实现第二个功能:如果一个组件继承的链路或其父级从属链路中存在多个errorCaptured
钩子函数,则它们将会被相同的错误逐个唤起。在实现第二个功能之前,我们先调整一下代码的架构:
export function handleError(err, vm, info) {
globalHandleError(err, vm, info);
}
function globalHandleError (err, vm, info) {
// 这里的config.errorHandler就是Vue.config.errorHandler
if (config.errorHandler) {
try {
return config.errorHandler.call(null, err, vm, info)
} catch (e) {
logError(err);
}
}
}
function logError(err){
console.error(err);
}
上面的代码片将全局错误处理相关的代码放到globalHandleError
函数中。下面实现第二个功能:
export function handleError (err, vm, info){
if(vm){
let cur = vm;
while ((cur=cur.$parent)) {
const hooks = cur.$options.errorCaptured;
if (hooks) {
for (let i=0;i<hooks.length;i++) {
hooks[i].call(cur, err, vm, info)
}
}
}
}
globalHandleError(err, vm, info)
}
通过while
语句自底向上不停地循环获取父组件,知道根组件。
在循环中,通过cur.$options.errorCaptured
属性读出errorCaptured
钩子函数列表,遍历钩子函数列表依次执行列表中的每一个errorCaptured
钩子函数。
自底向上的每一层都会读出当前层组件的errorCaptured
钩子函数列表,并依次执行列表中的每一个钩子函数。当组件循环到根组件时,从属链路中的多个errorCaptured
钩子函数就都被触发完了,此时,我们就不难理解为什么errorCaptured
可以捕获来自子孙组件抛出的错误了。
如果errorCaptured
钩子函数自身抛出了一个错误,那么这个新错误和原本被捕获的错误都会发送给全局的config.errorHandler
export function handleError (err, vm, info){
if(vm){
let cur = vm;
while ((cur=cur.$parent)) {
const hooks = cur.$options.errorCaptured;
if (hooks) {
for (let i=0;i<hooks.length;i++) {
try {
hooks[i].call(cur, err, vm, info);
} catch (e) {
globalHandleError(e, cur, 'errorCaptured hook');
}
}
}
}
}
globalHandleError(err, vm, info)
}
可以看到,只需要使用try...catch
语句捕获钩子函数可能发出的错误,并通过执行globalHandleError
将捕获到的错误发送给全局错误处理函数config.errorHandler
即可。因为这个错误是钩子函数自身抛出的新错误,所以不影响自底向上执行钩子函数的流程。而原有的错误则会在自底向上这个循环结束后,将错误传递给全局错误处理钩子函数,就像代码中所写的那样。
接下来实现最后一个功能:一个errorCaptured
钩子函数能够返回false
来阻止错误继续·向上传播。它会阻止其他被这个错误唤起的errorCaptured
钩子函数和全局的config.errorHandler
export function handleError (err, vm, info){
if(vm){
let cur = vm;
while ((cur=cur.$parent)) {
const hooks = cur.$options.errorCaptured;
if (hooks) {
for (let i=0;i<hooks.length;i++) {
try {
const capture = hooks[i].call(cur, err, vm, info) === false;
if(capture) return;
} catch (e) {
globalHandleError(e, cur, 'errorCaptured hook');
}
}
}
}
}
globalHandleError(err, vm, info)
}
从代码中可以看到,改动并不是很大,但是很巧妙。这里使用capture
保存钩子函数执行后的返回值,如果返回值false
,则使用return
语句停止程序继续执行。其巧妙的地方在于代码中的逻辑是先自底向上传递错误,之后再执行globalHandleError
将错误发送给全局错误处理钩子函数。所以只要在自底向上这个循环中的某一层执行了return
语句,程序就会立即停止执行,从而实现功能。因为一旦钩子函数返回了false
, handleError
函数将会执行return
语句终止程序执行,所以错误向上传递和全局的config.errorHandler
都会被停止。
初始化实例属性
在Vue.js
的整个生命周期中,初始化实例属性是第一步,需要实例话的属性既有Vue.js
内部需要用到的属性,也有提供给外部使用的属性。
注意:以$开头的属性是提供给用户使用的外部属性,以_开头的属性是提供给内部使用的内部属性。
Vue.js
通过initLifecycle
函数向实例中挂载属性,该函数接受Vue.js
实例作为参数。所以在函数中,只需要向Vue,js
实例设置属性即可达到向Vue.js
实例挂载属性的目的。代码如下:
export function initLifecycle (vm) {
const options = vm.$options;
// 找出第一个非抽象类
let parent = options.parent;
if (parent && !options.abstract) {
while (parent.$options.abstract && parent.$parent) {
parent = parent.$parent;
}
parent.$children.push(vm);
}
vm.$parent = parent;
vm.$root = parent ? parent.$root : vm;
vm.$children = [];
vm.$refs = {};
vm._watcher = null;
vm._isDestroyed = false;
vm._isBeingDestroyed = false;
}
可以看到,其逻辑并不复杂,只是在Vue.js
实例上设置一些属性并提供一个默认值。
稍微有点复杂的是vm.$parent
属性,它需要找到第一个非抽象类型的父级,所以代码中会,进行判断:如果当前组件不是抽象组件并且存在父级,那么需要通过while
来自底向上循环;如果父级是抽象类,那么继续向上,直到遇到第一个非抽象类的父级时,将它赋值给vm.$parent
属性。
另一个值得注意的是vm.$children
属性,它会包含当前实例的直接子组件。该属性的值是从子组件中主动添加到父组件中的。上面代码中的parent.$children.push(vm)
,就是将当前实例添加到父组件实例的$children
属性中。
最后一个值得注意的属性是vm.$froot
,它表示当前组件树的根Vue.js
实例。这个属性的实现原理很巧妙,也很好理解。如果当前组件没有父组件,那么它自己其实就是根组件,它的$root
属性是它自己,而它的子组件的vm.$root
属性是沿用父级的$root
,所以其直接子组件的$root
属性还是它,其孙组件的$root
属性沿用其直接子组件中的$root
属性,以此类推。因此,我们会发现这其实是自顶向下将根组件的$root
依次传递给每一个子组件的过程。
初始化事件
初始化事件是指将父组件在模板中使用的v-on
注册的事件添加到子组件的事件系统中。
在Vue.js
中,父组件可以在使用子组件的地方用v-on
来监听子组件触发的事件。例如:
<div id="counter-event-example">
<p>{{total}}</p>
<button-counrter v-on:increment = 'incrementTotal'></button-counrter>
<button-counrter v-on:increment = 'incrementTotal'></button-counrter>
</div>
Vue.component('button',{
template:'<button v-on:click = "incrementCounter" >{{counter}}</button>',
data:function(){
return {
counter:0
}
},
methods: {
incrementCounter:function(){
this.counter += 1;
this.$emit('increment');
}
},
})
new Vue({
el:'#counter-event-example',
data:{
total:0,
},
methods: {
incrementTotal:function() {
this.total +=1;
}
},
})
父组件的模板里使用v-on
监听子组件中触发的increment
事件,并在子组件中使用this.$emit
触发该事件。
为什么不使用注册模板中的浏览器事件?
你可能会有疑问,为什么不使用注册模板中的浏览器事件?对于这个问题,我们需要先简单介绍一下模板编译和虚拟DOM。在模板编译阶段,可以得到某个标签上的所有属性,其中就包括使用v-on或@注册的事件。在模板编译阶段,我们会将整个模板编译成渲染函数,而渲染函数其实就是一些嵌套在一起的创建元素节点的函数。创建元素节点的函数是这样的: _c(tagName, data, children)
。当渲染流程启动时,渲染函数会被执行并生成一份VNode
,随后虚拟DOM会使用VNode进行对比与渲染。在这个过程中会创建一些元素,但此时会判断当前这个标签究竟是真的标签还是一个组件:如果是组件标签,那么会将子组件实例化并给它传递一些参数,其中就包括父组件在模板中使用v-on
注册在子组件标签上的事,件;如果是平台标签,则创建元素并插入到DOM中,同时会将标签上使用v-on
注册的事件注册到浏览器事件中。
简单来说,如果v-on
写在组件标签上,那么这个事件会注册到子组件Vue.js
事件系统中;如果是写在平台标签上,例如div,那么事件会被注册到浏览器事件中。
我们会发现,子组件在初始化时,也就是初始化Vue.js
实例时,有可能会接收父组件向子组件注册的事件。而子组件自身在模板中注册的事件,只有在渲染的时候才会根据虚拟DOM的对比结果来确定是注册事件还是解绑事件。
所以在实例初始化阶段,被初始化的事件指的是父组件在模板中使用v-on
监听子组件内触发的事件。
Vue.js
通过initEvents
函数来执行初始化事件相关的逻辑,其代码如下:
export function initEvents (vm) {
vm._event = Object.create(null);
// 初始化父组件附加的事件
const listeners = vm.$options._parentListeners;
if (listeners) {
updateComponentListenerts(vm, listeners)
}
}
首先在vm
上新增_events
属性并将它初始化为空对象,用来存储事件。事实上,所有使用vm.$on
注册的事件监听器都会保存到vm._events
属性中。
在模板编译阶段,当模板解析到组件标签时,会实例化子组件,同时将标签上注册的事件解析成object
并通过参数传递给子组件。所以当子组件被实例化时,可以在参数中获取父组件向自己注册的事件,这些事件最终会被保存在vm.$options._parentListeners
中。
用前面的例子中举例,vm.$options._parentListeners
是下面的样子:
{increment: function(){}}
通过前面的代码可以看到,如果vm.$options._parentListeners
不为空,则调用updateComponentListeners
方法,将父组件向子组件注册的事件注册到子组件实例中。
updateComponentListeners
的逻辑很简单,只需要循环vm.$options._parentListeners
并使用vm.$on
把事件都注册到this._events
中即可。updateComponentListeners
函数的源码如下:
let target;
function add (event, fn, once) {
if (once) {
target.$once(event, fn);
} else {
target.$on(event, fn);
}
}
function remove (event, fn) {
target.$off(event, fn)
}
export function updateComponentListeners (vm, listeners, oldListeners) {
target = vm;
updateListeners(listeners, oldListeners || {}, add, remove, vm)
}
其中封装了add
和remove
这两个函数,用来新增和删除事件。此外,还通过updatelisteners
函数对比listeners
和oldListeners
的不同,并调用参数中提供的add
和remove
进行相应的注册事件和卸载事件的操作。它的实现思路并不复杂:如果listeners
对象中存在某个key
(也就是事件名)在oldListeners
中不存在,那么说明这个事件是需要新增的事件;反过来,如果oldListeners
中存在某些key
(事件名)在listeners
中不存在,那么说明这个事件是需要从事件系统中移除的。
updateListeners
函数的实现如下:
export function updateListeners (on, oldOn, add, remove, vm) {
let name, cur, old, event;
for (name in on) {
cur = on[name];
old = oldOn[name];
event = normalizeEvent (name);
if (isUndef(cur)) {
process.env.NODE_ENV !== 'production' && warn(
`Invalid handler for event "${event.name}":got` + String(cur),
vm
)
} else if (isUndef(old)) {
if (isUndef(cur, fns)) {
cur = on[name] = createFnInvoker(cur);
}
add(event.name, cur, event.once, event.capture, event.passive)
} else if (cur !== old) {
old.fns = cur;
on[name] = old;
}
}
for (name in oldOn) {
if (isUndef(on[name])) {
event = normalizeEvent(name);
remove(event.name, oldOn[name], event.capture);
}
}
}
该函数接收5个参数,分别是on
、oldOn
,add
, remove
和vm
。其主要逻辑是比对on
和oldOn
来分辨哪些事件需要执行add
注册事件,哪些事件需要执行remove
删除事件。
上面代码大致可以分为两部分,第一部分是循环on
,第二部分是循环oldOn
。第一部分的主,要作用是判断哪些事件在oldOn
中不存在,调用add
注册这些事件。第二部分的作用是循环oldOn
,判断哪些事件在on
中不存在,调用remove
移除这些事件。
在循环on
的过程中,有如下三个判断:
- 判断事件名对应的值是否是
ubdefined
或null
,如果是,则在控制台发出警告。 - 判断事件名在
oldOn
中是否存在,如果不存在,则调用add
注册事件。 - 如果事件名在
old
和oldOn
中都存在,但是它们并不相同,则将事件回调替换成on
中的回调,并且把on
中的回调引用指向真实的事件系统中注册的事件,也就是oldOn
中对应的事件。
注意:代码中的isUndef
函数用于判断传入的参数是否为undefined
或null
。
此外,代码中还有normalizeEvent
函数,它的作用是什么?
Vue.js
的模板中支持事件修饰符,例如capture
,once
和passive
等,如果我们在模板中注册事件时使用了事件修饰符,那么在模板编译阶段解析标签上的属性时,会将这些修饰符改成对应的符号加在事件名的前面,例如<child v-on: increment.once="a"x</child>
。此时vm.Soptions._parentListeners
是下面的样子:
{~increment: function () {}}
可以看到,事件名的前面新增了一个~符号,这说明该事件的事件修饰符是once
,我们通过这样的方式来分辨当前事件是否使用了事件修饰符。而normalizeEvent
函数的作用是将事件修饰符解析出来,其代码如下:
const normalizeEvent = name => {
const passive = name.charAt(0) === '&';
name = passive ? name.slice(1) : name;
const once = name.charAt(0) === '~';
name = once ? name.slice(1) : name;
const capture = name.charAt(0) === '!';
name = capture ? name.slice(1) : name;
return {
name,
once,
capture,
passive
}
}
可以看到,如果事件有修饰符,则会将它截取出来。最终输出的对象中保存了事件名以及一些事件修饰符,这些修饰符为true
说明事件使用了此事件修饰符。
初始化inject
inject
和provide
通常是成对出现的,我们使用Vue.js
开发应用时很少用到它们。这里先简单介绍它们的作用。
provide/inject的使用方式
说明:provide
和inject
主要为高阶插件/组件库提供用例,并不推荐直接用于程序代码中。
inject
和provide
选项需要一起使用,它们允许祖先组件向其所有子孙后代注入依赖,并在其上下游关系成立的时间里始终生效(不论组件层次有多深),如果你熟悉React
,会发现这与它的上下文特性很相似provide
选项应该是一个对象或返回一个对象的函数。该对象包含可注入其子孙的属性,可以使用ES2015 Symbol
作为key
,但是这只在原生支持Symbol
和Reflect.ownkeys
的环境下可工作。
inject
选项应该是一个字符串数组或对象,其中对象的key
是本地的绑定名, value
是一个key
(字符串或Symbol
)或对象,用来在可用的注入内容中搜索。
如果是对象,那么它有如下两个属性。
- name: 它是可用在注入内容中的用来搜索的
key
(字符串或Symbol
)。 default
:它是在降级情况下使用的value
。
==说明:==可用的注入内容指的是祖先组件通过provide
注入了内容,子孙组件可以通过inject
获取祖先组件注入的内容。
示例如下:
var Provider = {
provide:{
foo:'bar'
}
}
var Child = {
inject: ['foo'],
created() {
console.log(this.foo); // =>"bar"
},
}
如果使用ES6的Symbol
作为key
,则provide
函数和inject
对象如下所示:
const s = Symbol()
const Provider = {
provide () {
return {
[s]: 'foo'
}
}
}
const Child = {
inject: { s },
}
并且可以在data/props
中访问注入的值。例如,使用一个注入的值作为props
的默认值:
const Child = {
inject: ['foo'],
props: {
bar: {
default () {
return this.foo;
}
}
}
}
或者使用一个注入的值作为数据入口:
const Child = {
inject: ['foo'],
data () {
return {
bar: this.foo
}
}
}
在Vue.js 2.5.0+
版本中,可以通过设置inject
的默认值使其变成可选项:
const Child = {
inject: {
foo: {default: 'foo'}
}
}
如果它需要从一个不同名字的属性注入,则使用from
来表示其源属性。
const Child = {
inject : {
foo: {
from: 'bar',
default: 'foo'
}
}
}
上面代码表示祖先组件注入的名字是bar
,子组件将内容注入到foo
中,在子组件中可以通过this.foo
来访问内容。
inject
的默认值与props
的默认值类似,我们需要对非原始值使用一个工厂方法:
const Child = {
inject: {
foo: {
from: 'bar',
default: ()=>{}
}
}
}
inject的内部原理
虽然inject
和provide
是成对出现的,但是二者在内部的实现是分开处理的,先处理inject
后处理provide
。inject
在data/props
之前初始化,而provide
在data/props
后面初始化。这样做的目的是让用户可以在data/props
中使用inject
所注入的内容。也就是说,可以让data/props
依赖inject
,所以需要将初始化inject
放在初始化data/props
的前面。
通过前面的介绍我们得知,通过provide
注入的内容可以被所有子孙组件通过inject
得到。
很明显,初始化inject
,就是使用inject
配置的key
从当前组件读取内容,读不到则读取它的父组件,以此类推。它是一个自底向上获取内容的过程,最终将找到的内容保存到实例(this)
中,这样就可以直接在this
上读取通过inject
导入的注入内容。
初始化inject
的方法叫做initInjections
,代码如下:
export function initInjections (vm) {
const result = resolveInject(vm.$options.inject, vm)
if (result) {
observerState.shouldConvert = false;
Object.keys(result).forEach(key => {
defineReactive(vm, key, result[key])
})
observerState.shouldConvert = true;
}
}
其中,resolveInject
函数的作用是通过用户配置的inject
, 自底向上搜索可用的注入内容,并将搜索结果返回。上面的代码将注入结果保存到result
变量中。
接下来,循环result
并依次调用defineReactive
函数将它们设置到Vue.js
实例上。
代码中有一个细节需要注意,在循环注入内容前,有一行代码是;
observerState.shouldConvert = false;
在循环结束后,有一行代码是:
observerState.shouldConvert = true;
其作用是通知defineReactive函数不要将内容转换成响应式。其原理也很简单,在将值转换成响应式之前,判断observerstate. shouldconvert属性即可。
接下来,我们主要看resolveInject
的实现原理,它是如何自底向上搜索可用的注入内容"的呢?
事实上,实现这个功能的主要思想是:读出用户在当前组件中设置的inject
的key
,然后循环key
,将每一个key
从当前组件起,不断向父组件查找是否有值,找到了就停止循环,最终将所有key
对应的值一起返回即可。
按照上面的思想,resolveInject
函数最初的代码时下面这样的:
export function resolveInject (inject, vm) {
if (inject) {
const result = Object.create(null);
// do things
return result;
}
}
第一步要做的事情是获取inject
的key
, provide/inject
可以支持symbol
,但它只在原·生支持Symbol
和Reflect.ownkeys
的环境下才可以工作,所以获取key
需要考虑到Symbol
的情况,此时代码如下:
export function resolveInject (inject, vm) {
if (inject) {
const result = Object.create(null);
const keys = hasSymbol
? Reflect.ownKeys(inject).filter(key => {
return Object.getOwnPropertyDescriptor(inject, key).enumerable
})
: Object.keys(inject);
return result;
}
}
如果浏览器原生支持Symbol
,那么使用Reflect.ownkeys
读取出inject
的所有key
;如果浏览器原生不支持Symbol
,那么使用object.keys
获取key
,其区别是Reflect.ownkeys
可以读取Symbol
类型的属性,而object.keys
读不出来。由于通过Reflect.ownkeys
读出的key
包括不可枚举的属性,所以代码中需要使用filter
将不可枚举的属性过滤掉。
Reflect.ownkeys
有一个特点,它可以返回所有自有属性的键名,其中字符串类型和Symbol
类型都包含在内。而object.getOwnPropertyNames
和object.keys
返回的结果不会包含Symbol
类型的属性名, object.getOwnPropertySymbols
方法又只返回symbol
类型的属性
所以,如果浏览器原生支持symbol
,那么Reflect.ownkeys
是比较符合我们目标的一个API
,它的返回值会包含所有类型的属性名,我们唯一需要做的事就是使用filter
将不可枚举的属性过滤掉。
如果浏览器元素不支持Symbol
,那么object.keys
是比较符合目标的API
,因为它仅返回自身可枚举的全部属性名,而object. getownpropertyNames
会把不可枚举的属性名也返回。
得到了用户设置的inject
的所有属性名之后,就可以循环这些属性名, 自底向上搜索值。这可以使用while
循环实现,其代码如下:
export function resolveInject (inject, vm) {
if (inject) {
const result = Object.create(null);
const keys = hasSymbol
? Reflect.ownKeys(inject).filter(key => {
return Object.getOwnPropertyDescriptor(inject, key).enumerable
})
: Object.keys(inject);
for (let i = 0; i < keys.length; i++) {
const key = key[i];
const provideKey = inject[key].from;
let source = vm;
while (source) {
if (source._provided && provideKey in source._provided) {
result[key] = source._provided[provideKey]
break;
}
source = source.$parent;
}
}
return result;
}
}
在上述代码中,最外层使用for
循环key
,在循环体内可以依次得到每一次key
值,并通过from
属性得到provide
源属性。然后通过源属性使用while
循环来搜索内容。最开始source
等于当前组件实例,如果原始属性在source
的_provided
中能找到对应的值,那么将其设置到result
中,并使用break
跳出循环。否则,将source
设置为父组件实例进行下一轮循环,以此类推。
注意:当使用provide
注入内容时,其实是将内容注入到当前组件实例的_provide
中,所以inject
可以从父组件实例的_provide
中获取注入的内容。通过这样的方式,最终会在祖先组件中搜索到inject
中设置的所有属性的内容。
inject
其实还支持数组的形式,如果用户将inject
的值设置为数组,那么inject
中是没有from
属性的,此时这个逻辑是不是有问题?
其实是没问题的,因为当Vue.js
被实例化时,会在上下文(this)
中添加$options
属性,这会把用户提供的数据规格化,其中就包括inject
也就是说, Vue.js
在实例化的第一步是规格化用户传入的数据,如果inject
传递的内容是数组,那么数组会被规格化成对象并存放在from
属性中。
例如,用户设置的inject
是这样的:
{
inject: [foo]
}
被规格化之是下面这样的:
{
inject: {
foo: {
from: 'foo'
}
}
}
不论是数组形式还是对象中使用from
属性的形式,本质上其实是让用户设置原属性名与当,前组件中的属性名。如果用户设置的是数组,那么就认为用户是让两个属性名保持一致。
现在,我们就可以搜索所有祖先组件注入的内容了。但是通过前面的介绍,我们知道inject
是支持默认值的。也就是说,在所有祖先组件实例中都搜索不到注入内容时,如果用户设置了默,认值,那么将使用默认值。
要实现这个功能,我们只需要在while
循环结束时,判断source
是否为false
,相关代码如下:
export function resolveInject (inject, vm) {
if (inject) {
const result = Object.create(null);
const keys = hasSymbol
? Reflect.ownKeys(inject).filter(key => {
return Object.getOwnPropertyDescriptor(inject, key).enumerable
})
: Object.keys(inject);
for (let i = 0; i < keys.length; i++) {
const key = key[i];
const provideKey = inject[key].from;
let source = vm;
while (source) {
if (source._provided && provideKey in source._provided) {
result[key] = source._provided[provideKey]
break;
}
source = source.$parent;
}
if (!source) {
if ('default' in inject[key]) {
const provideDefault = inject[key].default;
result[key] = typeof provideDefault === 'function'
? provideDefault.call(vm)
: provideDefault
} else if (process.env.NODE_ENV !== 'production') {
warn(`Injection "${key}" not found`, vm)
}
}
}
return result;
}
}
上面代码新增了默认值相关的逻辑,如果!source
为true
,那么判断inject [key]
中是否存在default
属性。如果存在,则当前key
的结果是默认值。这里有一个细节需要注意,那就是默认值支持函数,所以需要判断默认值的类型是不是函数,是则执行函数,将函数的返回值设置给result [key]
。
如果inject [key]
中不存在default
属性,那么会在非生产环境下的控制台中打印警告。
初始化状态
当我们使用Vuejs
开发应用时,经常会使用一些状态,例如props
, methods
, data
, computed
和watch
。在Vue.js
内部,这些状态在使用之前需要进行初始化。本节将详细介绍初始化这些状态的内部原理。
通过本节的学习,我们将理解什么是props
,为什么methods
中的方法可以通过this
访问,data
在Vue.js
内部是什么样的, computed
是如何工作的,以及watch
的原理等。
initState
函数的代码如下:
export function initState (vm) {
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 {
observer(vm._data = {}, true /* asRootData */)
}
if (obs.computed) initComputed(vm, opts.computed)
if (opts.watch && opts.watch !== nativeWatch) {
initWatch(vm, opts.watch);
}
}
在上面的代码中,首先在vm
上新增一个属性_watchers
,用来保存当前组件中所有的watcher
实例。无论是使用vm.$watch
注册的watcher
实例还是使用watch
选项添加的watcher
实例,都会添加到vm._watchers
中。
在之前介绍过,可以通过vm.watchers
得到当前Vue.js
实例中所注册的所有watcher
实例,并将它们依次卸载。
接下来要做的事情很简单,先判断vm.$options
中是否存在props
属性,如果存在,则调用initProps
初始化props
.
然后判断vm.$options
中是否存在methods
属性,如果存在,则调用initMethods
初始化methods
。接着判断vm.$options
中是否存在data
属性:如果存在,则调用initData
初始化data
;如果不存在,则直接使用observe
函数观察空对象。
注意:observer
函数的作用是将数据转换成响应式的。
data
初始化之后,会判断vm.$options
中是否存在computed
属性,如果存在,则调用,initComputed
初始化computed
。最后判断vm.$options
中是否存在watch
属性,如果存在,则调用initwatch
初始化watch
.
用户在实例化Vue.js
时使用了哪些状态,哪些状态就需要被初始化,没有用到的状态则不用初始化。例如,用户只使用了data
,那么只需要初始化data
即可。如果你足够细心,就会发现初始化的顺序其实是精心安排的。先初始化props
,后初始化data
,这样就可以在data
中使用props
中的数据了。在watch
中既可以观察props
,也可以观察data
,因为它是最后被初始化的。
下图给出了初始化状态的结构图。初始化状态可以分为5个子项,分别是初始化props
、初始化methods
、初始化data
、初始化computed
和初始化watch
,下面我们将分别针对这5个子项进行详细介绍。
初始化props
props
的实现原理大体上是这样的:父组件提供数据,子组件通过props
字段选择自己需要哪些内容,Vue.js
内部通过子组件的props
选项将需要的数据筛选出来之后添加到子组件的上下文中。
为了更清晰地理解props
的原理,我们简单介绍Vuejs
组件系统的运作原理。
事实上, Vue.js
中的所有组件都是Vue.js实例
,组件在进行模板解析时,会将标签上的属性解析成数据,最终生成渲染函数。而渲染函数被执行时,会生成真实的DOM节点并渲染到视图中。但是这里面有一个细节,如果某个节点是组件节点,也就是说模板中的、某个标签的名字是组件名,那么在虚拟DOM渲染的过程中会将子组件实例化,这会将模板解析时从标签属性上解析出的数据当作参数传递给子组件,其中就包含props
数据。
- 规格化
props
子组件被实例化时,会先对props
进行规格化处理,规格化之后的props
为对象的格式。
说明:props
可以通过数组指定需要哪些属性。但在Vue.js
内部,数组格式的props
将被规格化成对象格式。
规格化props
代码如下:
function normalizeProps (options, vm) {
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 (isPlianObject(props)) {
for (const key in props) {
val = props[key];
name = camelize(val);
res[name] = isPlianObject(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;
}
在上述代码中,首先判断是否有props
属性,如果没有,说明用户没有使用props
接收任何数据,那么不需要规格化,直接使用return
语句退出即可。然后声明了一个变量res
,用来保存规格化后的结果。
随后是规格化props
的主要逻辑。先检查props
是否为一个数组。如果不是,则调用isPlainobject
函数检查它是否为对象类型,如果都不是,那么在非生产环境下在控制台中打印警告。如果props
是数组,那么通过while
语句循环数组中的每一项,判断props
名称的类型是否是String
类型:如果不是,则在非生产环境下在控制台中打印警告;如果是,则调用camelize
函数将props
名称驼峰化,即可以将a-b
这样的名称转换成aB
.
也就是说,如果在父组件的模板中使用这样的语法:
<child user-name="mgd"></child>
那么在子组件的props
选项中需要使用userName
:
{
props: ['usreName']
}
而使用user-name
是不行的。例如:下面这样设置props
选项是无法得到props
数据的:
{
props: ['usre-name']
}
随后将props
名当作属性,设置到res
中,值为{type:null}
:
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 ('porps must be strings when using array syntax.')
}
}
}
总结一下,上面做的事情是将Array
类型的props
规格化成Object
类型。
如果props
的类型不是Array
而是Object
,那么根据props
的语法可以知道,props
对象中的值可以是一个基础的类型函数,例如:
{
propA: Number
}
也有可能是一个数组,提供多个可能的类型,例如:
{
propB: [String, Number]
}
还可能是一个对象类型的高级选项,例如:
{
porpC: {
type: String,
required: true
}
}
所以代码中的逻辑是使用for...in
语句循环props
。
在循环中得到key
与val
之后,判断val
的类型是否是object
:如果是,则在res
上设置key
为名的属性,值为val
;如果不是,那么说明val
的值可能是基础的类型函数或者是一个数组提供多个可能的类型。那么在res
上设置key
为名、值为{type: val}
的属性,代码如下:
if (isPlianObject(props)) {
for (const key in props) {
val = props[key];
name = camelize(val);
res[name] = isPlianObject(val)
? val
: { type: val}
}
}
规格化之后的props
的类型既有可能是基础的类型函数,也有可能是数组。在这后面断言props
是否有效时会用到。
2. 初始化props
正如前面我们介绍的,初始化props
的内部原理是:通过规格化之后的props
从其父组件传入的props
数据中或从使用new
创建实例时传入的propsData
参数中,筛选出需要的数据保存在vm._props
中,然后在vm
上设置一个代理,实现通过vm.x
访问vm.props.x
的目的。
初始化props
的方法叫作initProps
,其代码如下:
function initProps (vm, propsOptions) {
const propsData = vm.$options.propsData || {};
const props = vm._props = {};
// 缓存props的值
const key = vm.$options._propKey = [];
const isRoot = !vm.$parent;
// root实例的props属性应该被转换成响应式数据
if (!isRoot) {
toggleObserving(false);
}
for (const key in propsOptions) {
keys.push(key);
const value = ValidateProp(key, propsOptions, propsData, vm);
defineReactive(props, key, value);
if (!(key in vm)) {
proxy(vm, `_props`, key)
}
}
toggleObserving(true);
}
initProps
函数接收两个参数:vm
和propsOptions
,前者是Vue.js
实例,后者是规格化之后的props
选项。
随后在函数中声明了4个变量propsData
, props
, keys
和isRoot
。变量propsData
中保存的是通过父组件传入或用户通过propsData
传入的真实props
数据。变量props
是指向vm._props
的指针,也就是所有设置到props
变量中的属性最终都会保存到vm.props
中。变量keys
是指向vm.$options._propkeys
的指针,其作用是缓存props
对象中的key
,将来更新props
时只需要遍历vm.$options._propkeys
数组即可得到所有props
的key
,变量isRoot
的作用是判断当前组件是否是根组件。
接下来,会判断当前组件是否是根组件,如果不是,那么不需要将props
数据转换成响应式数据。这里toggleObserving
函数的作用是确定并控制defineReactive
函数调用时所传入的value
参数是否需要转换成响应式的。toggleObserving
是一个闭包函数,所以能通过调用它并传入一个参数来控制observer/index.js
文件的作用域中的变量shouldObserve
。这样当数据将要被转换成响应式时,可以通过变量shouldObserve
来判断是否需要将数据转换成响应式的。
然后循环propsOptions
,在循环体中先将key
添加到keys
中,然后调用validateProp
函数将得到的props
数据通过defineReactive
函数设置到vm._props
中。
最后判断这个key
在vm
中是否存在,如果不存在,则调用proxy
,在vm
上设置一个以key
为属性的代理,当使用vm[key]
访问数据时,其实访问的是vm._props [key]
.
关于proxy
函数,会在之后介绍它的内部原理。
这里的重点是validateProp
函数是如何获取props
内容的。validateProp
的代码如下:
export function ValidateProp (key, propOptions, propsData, vm) {
const prop = propOptions[key];
const absent = !hasOwn(propsData, key);
let value = propsData[key];
// 处理布尔类型的props
if (isType(Boolean, prop.type)) {
if (absent && !hasOwn(prop, 'default')) {
value = false;
}else if (!isType(String, prop.type) && (value === '' || value === hyphenate(key))) {
value = true;
}
}
// 检查默认值
if (value === undefined) {
value = getPropDefaultValue(vm, prop, key);
// 因为默认值是新的数据,所以需要将它转换成响应式的
const prevShouldConvert = observerState.shouldConvert;
observerState.shouldConvert = true;
observe(value);
observerState.shouldConvert = prevShouldConvert;
}
if (process.env.NODE_ENV !== 'production') {
assertProp(prop, key, value, vm, absent);
}
return value;
}
ValidateProp
函数接受如下四个参数:
- key:
propOptions
中的属性名 - propOptions: 子组件用户设置的
props
选项 - propsData: 父组件或用户提供的
props
数据 - vm:
Vue.js
上下文实例,this的别名
函数中先声明3个变量prop
, absent
和value
,变量prop
保存的内容是当前这个key
的prop
选项。变量absent
表示当前的key
在用户提供的props
选项中是否存在。变量value
表示使用当前这个key
在用户提供的props
选项中获取的数据。也就是说,这3个变量分别保存当前这个key
的prop
选项、prop
数据以及一个布尔值(用来判断prop
数据是否存在),事实上,变量value
中可能存在正确的值,也有可能不存在。
函数的剩余代码主要解决特殊情况。首先,解决布尔类型prop
的特殊情况。
先使用isType
方法判断prop
的type
属性是否是布尔值,如果是,那么开始处理布尔值类型的prop
数据。布尔值的特殊情况比其他类型多,其他类型的prop
在value
有数据时,不需要进行特殊处理,只有在没有数据的时候检查默认值即可,而布尔值类型的prop
有两种额外的场景需要处理。
一种情况是key
不存在,也就是说父组件或用户并没有提供这个数据,并且props
选项中也没有设置默认值,那么这时候需要将value
设置成false
。另一种情况是key
存在,但value
是空字符串或者value
和key
相等。
注意:这里的value
和key
相等除了常见的a="a"
这种方式的相等外,还包含userName="user-name"
这种方式。
也就是说,在下面这些使用方式下,子组件的prop
都将设置为true
:
<child name></child>
<child name="name"></child>
<child userName="user-name"></child>
解决布尔类型prop
的特殊情况的代码如下:
if (isType(Boolean, prop.type)) {
if (absent && !hasOwn(prop, 'default')) {
value = false;
}else if (!isType(String, prop.type) && (value === '' || value === hyphenate(key))) {
value = true;
}
}
这里的hyphenate
函数会将key
进行驼峰转换,也就是说userName
转换完之后是user-name
,所以属性为userName
的值如果是user-name
,那么也会将value
设置为true
.
所以当子组件props
选项中的userName
属性为布尔类型时,其实下面这种情况也会将value
设置为true
:
<child user-name="user-name"><child>
除了布尔值需要特殊处理之外,其他类型的prop
只需要处理一种情况,并不需要进行额外的特殊处理。那就是如果子组件通过props
选项设置的key
在props
数据中并不存在,这时props
选项中如果提供了默认值,则需要使用它,并将默认值转换成响应式数据。代码如下:
if (value === undefined) {
value = getPropDefaultValue(vm, prop, key)
// 因为默认值的是新数据,所以需要将它转换 成响应式的
const prevShouldObserve = shouldObserve;
toggleObserving(true);
observe(value);
toggleObserving(prevShouldObserve);
}
这里使用getPropDefaultvalue
函数获取prop
的默认值,随后使用observe
函数将获取的默认值转换成响应式的。而toggleobserving
函数可以决定observer
被调用时,是否会将value
转换成响应式的。因此,代码中先使用toggleobserving(true)
,然后调用observe
,再调用toggleobserving (prevShouldobserve)
将状态恢复成最初的状态。
随后,会在validateProp
函数中判断当前运行环境是否是生产环境,如果不是,会调用assertProp
来断言prop
是否有效:
if (process.env.NODE_ENV !== 'production') {
assertProp(prop, key, value, vm, absent)
}
assertProp
函数的代码如下:
function assertProp (prop, name, value, vm, absent) {
if (prop.required && observe) {
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 = assertedType(value, type[i]);
expectedTypes.push(assertedType.expectedType || '');
valid = assertedType.valid;
}
}
if (!valid) {
warn (
`Invalid prop: type check failed for prop "${name}".` +
`Expected ${expectedTypes.map(capitalize).join(', ')}` +
`, got ${toRawType(value)}.`,
vm
)
return;
}
const validator = prop.validator;
if (validator) {
if (!validator(value)) {
warn (
'Invalid prop: custom validator check failed for prop "' + name +'".',
vm
)
}
}
}
虽然assertProp
函数的代码看起来有点长,但其实逻辑并不复杂。首先它接收5个参数,分别是prop
、name
、value
、vm
、absent
,它们的含义如下:
参数 | 含义 |
---|---|
prop | prop选项 |
name | props中prop选项的key |
value | prop数据(propData) |
vm | 上下文(this) |
absent | prop属性不存在key属性 |
这个函数最先处理必填项,如果prop
中设置了必填项( required为true )
并且prop
数据中没有这个key
属性,那么在控制台输出警告,并使用return
语句终止函数运行。这里prop.required
表示prop
选项中设置了必填项, absent
表示该数据不存在。
随后处理没有设置必填项并且value
不存在的情况,这种情况是合法的,直接返回undefined
即可。这里有一个技巧,即value == null
用的是双等号。在双等号中, null
和undefined
是相等的,也就是说value
是null
或undefined
都会为true
.
接下来校验类型,其中声明了3个变量type
、expectedTypes
和valid
, type
就是prop
中用来校验的类型, valid
表示是否校验成功。
通常情况下, type
是一个原生构造函数或一个数组,或者用户没提供type
。如果用户提供了原生构造函数或者数组,因为!type
的缘故,变量valid
默认等于false
;如果用户没设置type
.那么valid
默认等于true
,即当作校验成功处理。
但有一种特例,那就是当type
等于true
的时候。Vue.js
的props
支持这样的语法props: { somep rop: true }
,这说明prop
一定会校验成功。所以当这种语法出现的时候,由于type===true
,所以valid
变量的默认值就是true
.
变量expectedTypes
是用来保存type
的列表,当校验失败,在控制台打印警告时,可以将变量expectedTypes
中保存的类型打印出来。
接下来将校验类型。如果用户提供了type
,那么判断type
是否是一个数组,如果不是,就将它转换成数组
接下来循环type
数组,并调用assertType
函数校验value
, assertType
函数校验后会返回一个对象,该对象有两个属性valid
和expectedType
,前者表示是否校验成功,后者表示类型,例如: {valid: true, expectedType: "Boolean"}
。
然后将类型添加到expectedTypes
中,并将valid
变量设置为assertedType.valid
.
当循环结束后,如果变量valid
为true
,就说明校验成功。循环中的条件语句有这样一句话: !valid
,即type
列表中只要有一个校验成功,循环就结束,认为是成功了。
现在已经校验完毕,接下来只需要判断valid
为false
时在控制台打印警告即可。
可以看到,此时会将expectedTypes
打印出来,但是在打印之前先使用map
将数组重新调整了一遍,而capitalize
函数的作用是将字符串的一个字母改成大写。
我们知道, prop
支持自定义验证函数,所以最后要出来自定义验证函数。在代码中,首先判断用户是否设置了validator
,如果设置了,就执行它,否则调用warn
函数在控制台打印警告。
当prop
断言结束后,我们回到validateProp
函数,执行了最后一行代码,将value
返回。
初始化methods
初始化methods
时,只需要循环选项中的methods
对象,并将每个属性依次挂载到vm
上即可,相关代码如下:
function initMethods (vm, methods) {
const props = vm.$options.props;
for (const key in methods) {
if (process.env.NODE_ENV !== 'production') {
if (methods[key] == null) {
warn (
`Mthods "${key}" has an undefined value in the component definition. ` +
`Did you reference the funciton correctly?`,
vm
)
}
if (props && hasOwn(props, key)) {
warn (
`Nethod "${key}" has already been defined as a prop.`,
vm
)
}
if ((key in vm) && isReserved(key)) {
warn (
`Method "${key}" conflicts with an existing Vue instance method. ` +
`Avoid defining component methods that start with _ or $.`
)
}
}
vm[key] = methods[key] == null ? noop :bind(methods[key], vm)
}
}
这里先声明一个变量props
,用来判断methods
中的方法是否和props
发生了重复,然后使用for...in
语句循环methods
对象。
在循环中,主要逻辑分为两部分:
- 校验方法是否合法
- 将方法挂载到
vm
中
- 校验方法是否合法
在循环中会判断执行环境,在非生产环境下需要校验methods
并在控制台发出警告。
当methods
的某个方法只有key
没有value
时,会在控制台发出警告。如果methods
中的·某个方法已经在props
中声明过了,会在控制台发出警告。如果methods
中的某个方法已经存在于vm
中,并且方法名是以$
或_
开头的,也会在控制台发出警告。这里isReserved
函数的作用是判断字符串是否是以$
或_
开头。 - 将方法挂载到
vm
中
将方法赋值到vm
中很简单,详见initMethods
方法的最后一行代码。其中会判断方法(methods [key])
是否存在:如果不存在,则将noop
赋值到vm[key]
中;如果存在,则将该方法通过bind
改写它的this
后,再赋值到vm[key]
中。
这样,我们就可以通过vm.x
访问到methods
中的x
方法了。
初始化data
提到data
,相信大家都不陌生,我们在使用Vue.js
开发项目的过程中经常会用它来保存一·此数据。那么, data
内部究竟是怎样的呢?
简单来说, data
中的数据最终会保存到vm._data
中。然后在vm
上设置一个代理,使得通过vm.x
可以访问到vm.data
中的 属性。最后由于这些数据并不是响应式数据,所以需要调用observe
函数将data
转换成响应式数据。于是, data
就完成了初始化。
但在真正的代码中,需要增加判断一些条件,如果发现data
的使用方式不正确,那么会在控制台打印出警告。初始化data
的代码如下:
function initData () {
let data = vm.$options.data;
data = vm._data = typeof data === 'function'
? getData(data, vm)
: data || {}
if (!isPlianObject(data)) {
data = {};
process.env.NODE_ENV !== 'production' && warn (
`data functions should return an object:\n` +
`https://vuejs.org/v2/guide/componment.html#data-Must-Be-a-Function`,
vm
)
}
// 将data代理到Vue.js实例上
const keys = Object.keys(data);
const props = vm.$options.props;
const methods = vm.$options.methods
let i = keys.length;
while (i--) {
const key = keys[i];
if (process.env.NODE_ENV !== 'production') {
if (methods && hasOwn(methods,key)) {
warn (
`Methods "${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)) {
proxy(vm, `_data`, key)
}
}
// 观察数据
observer(data, true /* asRootData */)
}
在上述代码中,我们首先从选项中得到data
,并将其保存在data
变量中。然后需要判断data
的类型,如果是函数,则需要执行函数并将返回值赋值给变量data
和vm.data
。这里我们并没有见到函数data
被执行,而是看到了函数getData
被执行。其实,函数getData
中的逻辑也是调用data
函数并将值返回,只不过getData
中有一些细节处理,比如使用try...catch
语句捕获data
函数中有可能发生的错误等。
最终得到的data
值应该是object
类型,否则就在非生产环境下在控制台打印出警告,并为data
设置默认值,也就是空对象。
接下来要做的事情是将data
代理到实例上。代码中首先声明了3个变量: keys
, props
与methods
。接着循环data
,其中先判断当前执行环境,如果不是生产环境,那么判断当前循环的key
是否存在于methods
中,如果存在,说明数据重复了,在控制台打印警告。然后以同样的方式判断props
中是否存在某个属性与key
相同,如果发现确实有相同的属性,那么在非生产环境下在控制台打印警告。
只有props
中不存在当前与key
相同的属性时,才会将属性代理到实例上,前提是属性名不能以$
或_
开头。如果data
中的某个key
与methods
发生了重复,依然会将data
代理到实例中,但如果与props
发生了重复,则不会将data
代理到实例中。
代码中调用了proxy
函数实现代理功能。该函数的作用是在第一个参数上设置一个属性名为第三个参数的属性。这个属性的修改和获取操作实际上针对的是与第二个参数相同属性名的属性。proxy
的代码如下:
const sharedPropertyDefinition = {
enumerable: true,
configurable: true,
get: noop,
set: noop
}
export function proxy (target, sourceKey, key) {
sharedPropertyDefinition.get = function proxyGetter () {
return this[sourceKey][key];
}
sharedPropertyDefinition.set = function proxySetter (val) {
this[sourceKey][key] = val;
}
Object.defineProperty(target, key, sharedPropertyDefinition)
}
这里先声明了一个变量sharedPropertyDefinition
作为默认属性描述符。接下来声明了proxy
函数,此函数接收3个参数: target
、sourcekey
和key
。随后在代码中设置了get
和set
属性,相当于给属性提供了getter
和 setter
方法。在getter
方法中读取了this[sourcekey][key]
,在setter
方法中设置了this [sourcekey] [key]
属性。最后,使用object.defineProperty
方法为target
定义一个属性,属性名为key
,属性描述符为sharedPropertyDefinition
通过这样的方式将vm._data
中的方法代理到vm
上。所有属性都代理后,执行observe
函,数将数据转换成响应式的。关于如何将数据转换成响应式数据,在之前介绍过。
初始化computed
大家肯定对计算属性computed
不陌生,在实际项目中我们会经常用它。但对于刚入门的新手来说,它不是很好理解,它和watch
到底有哪些不同呢?本节将详细介绍其内部原理。
简单来说, computed
是定义在vm
上的一个特殊的getter
方法。之所以说特殊,是因为在vm
上定义getter
方法时, get
并不是用户提供的函数,而是Vuejs
内部的一个代理函数。在代理函数中可以结合watcher
实现缓存与收集依赖等功能。
我们知道计算属性的结果会被缓存,且只有在计算属性所依赖的响应式属性或者说计算属性的返回值发生变化时才会重新计算。那么,如何知道计算属性的返回值是否发生了变化?这其实是结合watcher
的dirty
属性来分辨的:当dirty
属性为true
时,说明需要重新计算“计算属性”的返回值;当dirty
属性为false
时,说明计算属性的值并没有变,不需要重新计算。
当计算属性中的内容发生变化后,计算属性的Watcher
与组件的Watcher
都会得到通知。计算属性的Watcher
会将自己的dirty
属性设置为true
,当下一次读取计算属性时,就会重新计算一次值。然后组件的watcher
也会得到通知,从而执行render
函数进行重新渲染的操作。由于要重新执行render
函数,所以会重新读取计算属性的值,这时候计算属性的watcher
已经把自己的dirty
属性设置为true
,所以会重新计算一次计算属性的值,用于本次渲染。
简单来说,计算属性会通过Watcher
来观察它所用到的所有属性的变化,当这些属性发生变化时,计算属性会将自身的watcher
的dirty
属性设置为true
,说明自身的返回值变了。
下图给出了计算属性的内部原理。在模板中使用了一个数据渲染视图时,如果这个数据恰好是计算属性,那么读取数据这个操作其实会触发计算属性的getter
方法(初始化计算属性时在vm
上设置的getter
方法)。
这个getter
方法被触发时会做两件事。
- 计算当前计算属性的值,此时会使用
watcher
去观察计算属性中用到的所有其他数据的变化。同时将计算属性的watcher
的dirty
属性设置为false,
这样再次读取计算属性时将不再重新计算,除非计算属性所依赖的数据发生了变化。 - 当计算属性中用到的数据发生变化时,将得到通知从而进行重新渲染操作。
注意:如果是在模板中读取计算属性,那么使用组件的Watcher
观察计算属性中用到的所有数据的变化。如果是用户自定义的watch
,那么其实是使用用户定义的watcher
观察计算属性中用到的所有数据的变化。其区别在于当计算属性函数中用到的数据发生变化时,向谁发送通知。
以上两件事做完后,就可以实现当数据发生变化时计算属性清楚缓存,组件收到通知去重新渲染视图。
说明:计算属性的一个特点是有缓存。计算属性函数所依赖的数据在没有发生变化的情况下,会反复读取计算属性,而计算属性并不会反复执行。
初始化计算属性的具体实现:
const computedWatcherOptions = {lazy: true};
function initComputed (vm, computed) {
const watchers = vm._comoutedWatcher = Object.create(null);
// 计算属性在SSR环境中,只是一个普通的getter方法
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
)
}
// 在非SSR环境中,为计算属性创建内部观察器
if (!isSSR) {
watchers[key] = new Watcher(
vm,
getter || noop,
noop,
computedWatcherOptions
)
}
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
)
}
}
}
}
在上述代码中,我们先声明了一个变量computedWatcherOptions
,其作用和它的名字相同,是一个Watcher
选项。在实例化Watcher
时,通过参数告诉Watcher
类应该生成一个供计算属性使用的watcher
实例。
initComputed
函数的作用是初始化计算属性,它接受两个参数:
- vm:Vue.js实例上下文(this)
- computed: 计算属性对象
随后在vm上新增了_computedWatchers属性并且声明了变量watchers,其值为一个空的,对象,而_computedwatchers属性用来保存所有计算属性的watcher实例。
说明:Object. create(null)
创建出来的对象没有原型,它不存在_proto_
属性。
随后声明的变量isSSR
用于判断当前运行环境是否是SSR
(服务端渲染), isServerRendering
工具函数执行后,会返回一个布尔值用于判断是否是服务端渲染环境。
接下来,使用for..in
循环computed
对象,依次初始化每个计算属性。在循环中先声明变量userDef
来保存用户设置的计算属性定义,然后通过userDef
获取getter
函数。这里只需要判断用户提供的计算属性是否是函数,如果是函数,则将这个函数当作getter
,否则默认将用户提供的计算属性当作对象处理,获取对象的get
方法。这时如果用户传入的计算属性不合法也就是说既不是函数,也不是对象,或者提供了对象但没有提供get
方法,就在非生产环境下在控制台打印警告以提示用户。
随后判断当前环境是否是服务端渲染环境,如果不是,就需要创建watcher
实例。Watcher
在整个计算属性内部原理中非常重要,后面我们会介绍它的作用。创建watcher
实例时有一个细节需要注意,即第二个参数的getter
其实是用户设置的计算属性的get
函数。
最后,判断当前循环到的计算属性的名字是否已经存在于vm
中:如果存在,则在非生产环境下的控制台打印警告,如果不存在,则使用definecomputed
函数在vm
上设置一个计算属性。这里有一个细节需要注意,那就是当计算属性的名字已经存在于vm
中时,说明已经有了一个重名的data
或者props
,也有可能是与methods
重名,这时候不会在vm
上定义计算属性。
但在Vue.js
中,只有与data
和props
重名时,才会打印警告。如果与methods
重名,并不会在控制台打印警告。所以如果与methods
重名,计算属性会悄悄失效,我们在开发过程中应该尽量避免这种情况。此外,还需要说明一下defineComputed
函数,它有3个参数: vm
、 key
和userDef
。其完整代码如下:
const sharePropertyDefinition = {
enumerable: true,
configurable: true,
get: noop,
set: noop
}
export function defineComputed (target, key, userDef) {
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' &&
sharePropertyDefinition.set === noop ) {
sharePropertyDefinition.set = function () {
warn (
`Computed property "${key}" was assigned to but is has no setter.`,
this
)
}
}
Object.defineProperty(target, key, sharePropertyDefinition)
}
在上述代码中,先定义了变量sharedPropertybefinition
,它的作用与之前介绍的proxy
函数所使用的sharedPropertyDefinition
变量相同。事实上,在源码中,这两个函数使用的其实是同一个变量,这个变量是一个默认的属性描述符,它经常与Object.defineProperty
配合使用。
接着,函数defineComputed
接收3个参数target
, key
和userDef
,其意思是在target
上定义一个key
属性,属性的getter
和setter
根据userDef
的值来设置。
然后函数中声明了变量shouldCache
,它的作用是判断computed
是否应该有缓存。这里调用isServerRendering
函数来判断当前环境是否是服务端渲染环境。因此,变量shouldCache
只有在非服务端渲染环境下才为true
。也就是说,只有在非服务端渲染环境下,计算属性才有缓存
接下来,判断userDef
的类型。Vuejs
支持用户设置两种类型的计算属性:函数和对象。例如
var vm = new Vue({
data: {a:1},
computed: {
// 仅读取
aDouble: function () {
return this.a * 2
},
// 读取和设置
aPlus: {
get: function () {
return this.a + 1
},
set: function () {
this.a = v - 1
}
}
}
})
所以在定义计算属性时,需要判断userDef
的类型是函数还是对象。如果是函数,则将函数理解为getter
函数。如果是对象,则将对象的get
方法作为getter
方法, set
方法作为setter
方法。
这里有一个细节需要注意,我们要通过判断shouldcache
来选择将get
设置成userDef
这种普通的getter
函数,还是设置为计算属性的getter
函数。其区别是如果将sharedPropertyDefinition.get
设置为userDef
函数,那么这个计算属性只是一个普通的getter
方法,没有缓存。当计算属性中所使用的数据发生变化时,计算属性的Watcher
也不会得到任何通知,使用计算属性的watcher
也不会得到任何通知。它就是一个普通的getter
,每次读取操作都会执行一遍函数。这种情况通常在服务端渲染环境下生效,因为数据响应式的过程在服务器上是多余的。如果将sharedPropertybefinition. get
设置为计算属性的getter
,那么计算属性将具备缓存和观察计算属性依赖数据的变化等响应式功能。稍后,我们再介绍createComputedGetter
的实现。
由于用户并没有设置setter
函数,所以将sharedPropertyDefinition.set
设置为noop
.而noop
是一个空函数,如果userDef
的类型不是函数,那么假设它是对象类型。在else
语句中先设置sharedPropertyDefinition.get
,后设置sharedPropertyDefinition.set
。设置sharedPropertyDefinition. get
时需要判断userDef.get
是否存在。如果不存在,则将sharedPropertyDefinition.get
设置成noop
。如果存在,那么逻辑和前面介绍的相同,如果shouldCache
为true
并且用户没有明确地将userDef.cache
设置为false
,则调用createComputedGetter
函数将sharedPropertyDefinition. get
设置成计算属性的getter
函数,否则将sharedPropertyDefinition.get
设置成普通的getter
函数userDef.get
设置完getter
后设置setter
。这简单很多,只需要判断userDef. set
是否存在,如果存在,则将sharedPropertyDefinition. set
设置为userDef.set
,否则设置为noop
.如果用户在没有设置setter
的情况下对计算属性进行了修改操作, Vue.js
会在非生产环境,下在控制台打印警告。其实现原理很简单,如果用户没有设置setter
函数,那么为计算属性设置一个默认的setter
函数,并且当函数执行时,打印出警告即可。
在defineComputed
函数的最后,我们调用Object.defineProperty
方法在target
对象上设置key
属性,其中属性描述符为前面我们设置的sharedPropertyDefinition
。计算属性就是这样被设置到vm
上的。
通过前面的介绍,我们发现计算属性的缓存与响应式功能主要在于是否将getter
方法设置为createcomputedGetter
函数执行后的返回结果。下面我们介绍createComputedGetter
函数是如何实现缓存以及响应式功能的,其代码如下:
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;
}
}
}
这个函数是一个高阶函数,它接收一个参数key
并返回另一个函数computedGetter
通过前面的介绍知道,最终被设置到getter
方法中的函数其实是被返回的computedGetter
函数。在非服务端渲染环境下,每当计算属性被读取时, computedGetter
函数都会被执行。
在computedGetter
函数中,先使用key
从this._computedwatchers
中读出watcher
并赋值给变量watcher
。而this._computedwatchers
属性保存了所有计算属性的watcher
实例。
如果watcher
存在,那么判断watcher.dirty
是否为true
。前面我们介绍watcher.dirty
属性用于标识计算属性的返回值是否有变化,如果它为true
,说明计算属性所依赖的状态发生了变化,它的返回值有可能也会有变化,所以需要重新计算得出最新的结果。
计算属性的缓存就是通过这个判断来实现的。每当计算属性所依赖的状态发生变化时,会将watcher.dirty
设置为true
,这样当下一次读取计算属性时,会发现watcher.dirty
为true
此时会重新计算返回值,否则就直接使用之前的计算结果。
随后判断Dep.target
是否存在,如果存在,则调用watcher.depend
方法。这段代码的目的在于将读取计算属性的那个watcher
添加到计算属性所依赖的所有状态的依赖列表中。换句话说,就是让读取计算属性的那个watcher
持续观察计算属性所依赖的状态的变化。
使用计算属性的同学大多会有一个疑问:为什么我在模板里只使用了一个计算属性,但是把,计算属性中用到的另一个状态给改了,模板会重新渲染,它是怎么知道自己需要重新渲染的呢?
这是因为组件的Watcher
观察了计算属性中所依赖的所有状态的变化。当计算属性中所依赖的状态发生变化时,组件的Watcher
会得到通知,然后就会执行重新渲染操作。
之前介绍watcher
时,并没有介绍其depend
与evaluate
方法。事实上,其中定义了depend
与evaluate
方法专门用于实现计算属性相关的功能,代码如下:
export default class Watcher {
constructor (vm, expOrFn, cb, options) {
// 隐藏无关代码
if (options) {
this.lazy = !!options.lazy;
} else {
this.lazy = false;
}
this.dirty = this.lazy;
this.value = this.lazy
? undefined
: this.get()
}
evaluate () {
this.value = this.get();
this.dirty = false;
}
depend () {
let i = this.deps.length;
while (i--) {
this.dep[i].depend();
}
}
}
可以看到, evaluate
方法的逻辑很简单,就是执行this.get
方法重新计算一下值,然后将this.dirty
设置为false
.
虽然depend
方法的代码不多,但它的作用并不简单。从代码中可以看到, watcher.depend
方法会遍历this.deps
属性(该属性中保存了计算属性用到的所有状态的dep
实例,而每个属性的dep
实例中保存了它的所有依赖),并依次执行dep
实例的depend
方法。
执行dep
实例的depend
方法可以将组件的watcher
实例添加到dep
实例的依赖列表中。换句话说, this.deps
是计算属性中用到的所有状态的dep
实例,而依次执行了dep
实例的depend
方法就是将组件的Watcher
依次加入到这些dep
实例的依赖列表中,这就实现了让组件的Watcher
观察计算属性中用到的所有状态的变化。当这些状态发生变化时,组件的watcher
会收到通知,从而进行重新渲染操作。
前面我们介绍的计算属性原理是Vue.js
在2.5.2版本中的实现。Vue.js
在2.5.17版本中,对计算属性的实现方式做了一个改动,这个改动使得计算属性的原理有一些不太一样的地方,这是因为现有的计算属性存在着一个问题.
前面我们介绍组件的Watcher
会观察计算属性中用到的所有数据的变化。这就导致一个问题:如果计算属性中用到的状态发生了变化,但最终计算属性的返回值并没有变,这时计算属性依然会认为自己的返回值变了,组件也会重新走一遍渲染流程。只不过最终由于虚拟DOM的Dif中发现没有变化,所以在视觉上并不会发现UI有变化,其实渲染函数会被执行。
也就是说,计算属性只是观察它所用到的所有数据是否发生了变化,但并没有真正去校验它自身的返回值是否有变化,所以当它所使用的数据发生变化后,它就认为自己的返回值也会有变化,但事实并不总是这样。有人在Vue.js
的GitHub Issues
里提出了这个问题,地址为https://github.com/vuejs/vue/issues/7767
,同时,他还给出了一个案例来演示这个问题,地址: https:/sfiddle.net72gzmayL/
。
为了解决这个问题,作者把计算属性的实现做了一些改动,改动后的逻辑是:组件的watcher
不再观察计算属性用到的数据的变化,而是让计算属性的Watcher
得到通知后,计算一次计算属性的值,如果发现这一次计算出来的值与上一次计算出来的值不一样,再去主动通知组件的Watcher
进行重新渲染操作。这样就可以解决前面提到的问题,只有计算属性的返回值真的变了,才会重新执行渲染函数。
下图给出了新版计算属性的内部原理。与之前最大的区别就是组件的watcher
不再观察数据的变化了,而是只观察计算属性的watcher
(把组件的watcher
实例添加到计算属性的watcher
实例的依赖列表中),然后计算属性主动通知组件是否需要进行渲染操作。
此时计算属性的getter
被触发时做的事情发生了变化,它会做下面两件事:
- 使用组件的
Watcher
观察计算属性的Watcher
,也就是把组件的Watcher
添加到计算属性的Watcher
的依赖列表中,让计算属性的watcher
向组件的watcher
发送通知。 - 使用计算属性的
watcher
观察计算属性函数中用到的所有数据,当这些数据发生变化时,向计算属性的watcher
发送通知。
注意:如果是在模板中读取计算属性,那么使用组件的Watcher
观察计算属性的Watcher
;如果是用户使用vm.$watch
定义的Watcher
,那么其实是使用用户定义的Watcher
观察计算属性的watcher
。其区别是当计算属性通过计算发现自己的返回值发生变化后,计算属性的watcher
向谁发送通知。
修复这个问题的Pull Requests
地址为: https://github.com/vuejs/vue/pull/7824
,下面来看一下,这个Pull Requests
都有哪些修改。首先createComputedGetter
函数中的内容发生了变化,改动后的代码如下:
function createComputedGetter (key) {
return function computedGetter () {
const watcher = this._computedWatchers && this._computedWatchers[key]
if (watcher) {
watcher.depend()
return watcher.evaluate()
}
}
}
改动后的函数依然是一个高阶函数,依然返回computedGetter
函数,但是computedGetter
函数中的内容发生了变化。从代码上看,改动后的代码比改动前的代码少了很多。
computedGetter
函数依然是先使用key
从this._computedwatchers
中读出watcher
并赋值给变量watcher
。随后判断watcher
是否存在,如果存在,则执行watcher.depend()
和watcher.evaluate()
,并将watcher. evaluate()
的返回值当作计算属性函数的计算结果返回出去
depend
方法被执行后,会将读取计算属性的那个watcher
添加到计算属性的watcher
的依赖列表中,这可以让计算属性的watcher
向使用计算属性的watcher
发送通知。
Watcher的代码变成了下面的样子:
export default class Watcher {
constructor (vm, expOrFn, cb, options) {
if (options) {
this.computed = !!options.computed;
} else {
this.computed = false;
}
this.dirty = this.computed;
if (this.computed) {
this.value = undefined;
this.dep = new Dep()
} else {
this.value = this.get();
}
}
update () {
if (this.computed) {
if (this.dep.subs.length === 0) {
this.dirty = true;
} else {
this.getAndInvoke(()=>{
this.dep.notify();
})
}
}
}
getAndInvoke (cb) {
const value = this.get();
if (
value !== this.value ||
isObject(value) ||
this.deep
) {
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);
}
}
}
evaluate () {
if (this.dirty) {
this.value = this.get();
this.dirty = false;
}
return this.value;
}
depend () {
if (this.dep && Dep.target) {
this.dep.depend();
}
}
}
可以看到, evaluate
方法稍微有点改动,但并不是很大。先通过dirty
属性判断返回值是否发生了变化,如果发生了变化,就执行get
方法重新计算一次,然后将dirty
属性设置为false
,表示数据已经是最新的,不需要重新计算,最后返回本次计算出来的结果。
depend
方法的改动有点大,这一次不再是将Dep. target
添加到计算属性所用到的所有数据的依赖列表中,而是改成了将Dep.target
添加到计算属性的依赖列表中。this.dep
用于在实例化Watcher
时进行判断,如果为计算属性用的Watcher
,则实例化一个dep
实例并将其放在this.dep
属性上。
当计算属性中用到的数据发生变化时,计算属性的Watcher
的update
方法会被执行,此时会判断当前watcher
是不是计算属性的Watcher
,如果是,那么有两种模式,一种是主动发送通知,另一种是将dirty
设置为true
。行业术语中,这两种方式分别叫作activated
和lazy
.
从代码中可以看出,分辨这两种模式可以使用依赖的数量, activated
模式要求至少有一个依赖。其实也可以理解,如果没有任何依赖,那么主动去向谁发送通知呢?
大部分情况下都是有依赖的,这个依赖有可能是组件的watcher
,这取决于谁读取了计算属性。
我们假设这个依赖是组件的Watcher
,那么当计算属性所使用的数据发生变化后,会执行计算属性的Watcher
的update
方法。随后可以看到,发送通知的代码是在this. getAndInvoke
函数的回调中执行的。可以很明确地告诉你,这个函数的作用是对比计算属性的返回值。只有计算属性的返回值真的发生了变化,才会执行回调,从而主动发送通知让组件的watcher
去执行重新渲染逻辑。
初始化watch
初始化状态的最后一步是初始化watcher
。在initState
函数的最后,有这样一行代码:
if (opts.watch && opts.watch !==nativeWatcher) {
initWatch(vm, opts.watch);
}
只有当用户设置了watch
选项并且watch
选项不等于浏览器原生的watch
时,才进行初始化watch
的操作。之所以使用这样的语句(opts.watch !== nativewatch)
判断,是因为Firefox
浏览器中的object.prototype
上有一个watch
方法。当用户没有设置watch
时,在Firefox
浏览器下的opts.watch
将是object.prototype.watch
函数,所以通过这样的语句可以避免这种问题.
代码中通过调用initwatch
函数并传递两个参数vm
和opts.watch
来初始化watch
选项。这里我们先简单回顾watch
的使用方式。
- 类型:{ [key: string]: string | Function | Object |Array }
- 介绍: 一个对象,其中键是需要观察的表达式,值是对应的回调函数,也可以是方法名或者包含选项的对象。Vue.js实例将会在实例化时调用
vm.$watch()
遍历watch
对象的每一个属性。 - 示例:
var vm = new Vue({
data: {
a: 1,
b: 2,
c: 3,
d: 4,
e: {
f: {
g: 5
}
}
},
watch: {
a: function (val, oldValue) {
console.log("new: %s, old: %s, val, oldVal");
},
// 方法名
b: 'someMethod',
// 深度watcher
c: {
handler: function (val, oldVal) {/* ... */}
},
// 该回调将会在侦听开始之后被立即调用
d: {
handler: function (val, oldVal) {/* */},
immediate:true
},
e: [
function handle1 (val, oldVal) {/* */},
function handle2 (val, oldVal) {/* */}
],
// watch vm.e.f's value: {g: 5}
'e.f': function (val, oldVal) {/* */}
}
})
vm.a = 2 // => new: 2,old: 1
初始化watch
选项的实现思路并不复杂,前面也略微提到了。watch
选项的功能和vm.$watch
是相同的,所以只需要循环watch
选项,将对象中的每一项依次调用vm.$watch
方法来观察表达式或computed
在Vue.js
实例上的变化即可。
由于watch
选项的值同时支持字符串、函数、对象和数组类型,不同的类型有不同的用法,所以在调用vm.$watch
之前需要对这些类型做一些适配。initwatch
函数的代码如下:
function initWatch (vm, watch) {
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);
}
}
}
它接收两个参数vm
和watch
,后者是用户设置的watch
对象。随后使用for...in
循环遍历watch
对象,通过key
得到watch
对象的值并赋值给变量handler
.
此时变量handler
的类型是不确定的,watch
选项的值其实可以大致分为两类:数组和其他。数组中的每一项可以是其他任意类型,所以代码中先处理数组的情况。如果handler
的类型是数组,那么遍历数组并将数组中的每一项依次调用createwatcher
函数来创建watcher
。如果不是数组,那么直接调用createwatcher
函数创建一个watcher
.
createwatcher
函数主要负责处理其他类型的handler
并调用vm.$watch
创建watcher
观察表达式,其代码如下:
function createWatcher (vm, expOrFn, handler, $options) {
if (isPlianObject(handler)) {
options = handler;
handler = handler.handler;
}
if (typeof handler === 'string') {
handler = vm[handler];
}
return vm.$watch(expOrFn, handler, options)
}
它接受如下4个参数:
- vm:
Vue.js
实例上下文 - expOrFn: 表达式或计算属性函数
- handler: watch对象的值
- options: 用于传递给
vm.$watch
的选项对象。
执行createwatcher
函数时,handler
的类型有三种可能:字符串、函数和对象。如果handler
的类型是函数,那么不用特殊处理,直接把它传递给vm.$watch
即可。如果是对象,那么说明用户设置了一个包含选项的对象,因此将options
的值设置为handler
,并且将变量handler
设置为handler
对象的handler
方法。如果handler
的类型是字符串,那么从vm
中取出方法,将它赋值给handler
变量即可。
针对不同类型的值处理完毕后, handler
变量是回调函数, options
为vm.$watch
的选项,所以接下来只需要调用vm.$watch
即可完成初始化watch
的任务。
初始化provide
状态初始化的下一步是初始化provide
,本节中我们将介绍provide
的内部原理。
provide
选项应该是一个对象或者是返回一个对象的函数。该对象包含可注入其子孙的属·性。在该对象中,你可以使用ES2015 symbol
作为key
,但是它只在原生支持symbol
和Reflect.ownkeys
的环境下工作。初始化provide
时,只需要将provide
选项添加到vm._provided
即可,相关代码如下:
export function initProvide (vm) {
const provide = vm.$options.provide;
if (provide) {
vm._provide = typeof provide === 'function'
? provide.call(vm)
: provide
}
}
这里首先判断provide
的类型是否是函数,如果是,则执行函数,将返回值赋值给vm._provided
,否则直接将变量provide
赋值给vm.provided
.
总结
本章详细介绍了new Vue()被执行时Vue.js的背后发生了什么。
Vuejs
的整体生命周期可以分为4个阶段:初始化阶段、模板编译阶段、挂载阶段和卸载阶段。初始化阶段结束后,会触发created
钩子函数。在created
钩子函数与beforeMount
钩子函数之间的这个阶段是模板编译阶段,这个阶段在不同的构建版本中不一定存在。挂载阶段在beforeMount
钩子函数与mounted
期间。挂载完毕后, Vuejs
处于已挂载阶段。已挂载阶段会持续追踪状态的变化,当数据(状态)发生变化时, watcher
会通知虚拟DOM
重新渲染视图。在渲染视图前触发beforeUpdate
钩子函数,渲染完毕后触发updated
钩子函数。当vm.$destroy
被调用时,组件进入卸载阶段。卸载前会触发beforeDestroy
钩子函数,卸载后会触发destroyed
钩子函数。
new Vue ()
被执行后, Vuejs
进入初始化阶段,然后选择性进入模板编译与挂载阶段。在初始化阶段,会分别初始化实例属性、事件、provide/inject
以及状态等,其中状态又包含props
, methods
、 data
,computed
与watch
.