组件化
如果觉得写得不错,请到GitHub给我一个Star
上一篇:Vue2.0源码分析:响应式原理(下)
下一篇:Vue2.0源码分析:组件化(下)
由于掘金文章字数限制,不得不拆分上、下两篇文章。
介绍
在之前几个章节中,我们提到过很多次组件的概念,组件在我们日常的开发过程中出现频率是非常高的,它也是Vue的两大核心之一:数据驱动和组件化。
在前面章节我们已经介绍完了数据驱动,在这个章节我们会着重介绍与组件化相关的知识,我们将从入口文件main.js开始探索组件化的奥秘。
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
Vue.config.productionTip = false
new Vue({
router,
store,
render: h => h(App)
}).$mount('#app')
$mount方法
代码分析
在前面我们已经知道,Vue会根据不同的情况去挂载不同的$mount方法,其中带compiler版本的$mount方法是在src/platforms/web/entry-runtime-with-compiler.js文件中被重新定义,其代码如下:
const mount = Vue.prototype.$mount
Vue.prototype.$mount = function (
el?: string | Element,
hydrating?: boolean
): Component {
el = el && query(el)
/* istanbul ignore if */
if (el === document.body || el === document.documentElement) {
process.env.NODE_ENV !== 'production' && warn(
`Do not mount Vue to <html> or <body> - mount to normal elements instead.`
)
return this
}
const options = this.$options
// resolve template/el and convert to render function
if (!options.render) {
let template = options.template
if (template) {
if (typeof template === 'string') {
if (template.charAt(0) === '#') {
template = idToTemplate(template)
/* istanbul ignore if */
if (process.env.NODE_ENV !== 'production' && !template) {
warn(
`Template element not found or is empty: ${options.template}`,
this
)
}
}
} else if (template.nodeType) {
template = template.innerHTML
} else {
if (process.env.NODE_ENV !== 'production') {
warn('invalid template option:' + template, this)
}
return this
}
} else if (el) {
template = getOuterHTML(el)
}
if (template) {
/* istanbul ignore if */
if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
mark('compile')
}
const { render, staticRenderFns } = compileToFunctions(template, {
outputSourceRange: process.env.NODE_ENV !== 'production',
shouldDecodeNewlines,
shouldDecodeNewlinesForHref,
delimiters: options.delimiters,
comments: options.comments
}, this)
options.render = render
options.staticRenderFns = staticRenderFns
/* istanbul ignore if */
if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
mark('compile end')
measure(`vue ${this._name} compile`, 'compile', 'compile end')
}
}
}
return mount.call(this, el, hydrating)
}
我们可以看到,在代码最顶部它首先获取并缓存了Vue.prototype上原始的$mount方法,然后重新在Vue.prototype上定义$mount方法,其中在最新的$mount方法的最底部,还调用了缓存下来的原始$mount方法。
那么,这个原始的$mount方法又在哪里被定义呢,其实它是在src/core/platforms/web/runtime/index.js中被定义,其代码如下:
Vue.prototype.$mount = function (
el?: string | Element,
hydrating?: boolean
): Component {
el = el && inBrowser ? query(el) : undefined
return mountComponent(this, el, hydrating)
}
搞清楚了这两个$mount方法的区别后,我们接下来首先分析带compiler版本的$mount方法实现,它主要做三件事情:获取el元素、处理template和调用原始$mount方法,我们将根据这几个步骤来分别进行代码分析。
代码分析:
- 获取el元素:还记得在
main.js入口文件中,我们调用$mount方法时传递了#app参数吗。
import Vue from 'vue'
import App from './App.vue'
new Vue({
render: h => h(App)
}).$mount('#app')
当执行$mount方法的时候,首先要做的就是根据传递的el元素获取到要挂载的DOM元素节点,它使用query这个方法来获取DOM元素节点,其中这个方法的代码如下:
export function query (el: string | Element): Element {
if (typeof el === 'string') {
const selected = document.querySelector(el)
if (!selected) {
process.env.NODE_ENV !== 'production' && warn(
'Cannot find element: ' + el
)
return document.createElement('div')
}
return selected
} else {
return el
}
}
我们可以看到在query方法中,首先对el参数做了判断,如果不是string类型,则直接返回;如果是则通过document.querySelector去获取DOM元素,如果没有获取到,则创建一个div元素返回并提示错误信息。
在看完以上代码后,我们可能有一个疑问:什么时候el参数不为string类型呢?其实$mount方法可以直接接受一个DOM元素节点,既意味着我们可以在入口文件中这样写:
import Vue from 'vue'
import App from './App.vue'
new Vue({
render: h => h(App)
}).$mount(document.querySelector('#app'))
我们在Vue官方文档中,肯定看到过这样一段提示内容:el提供的元素只能作为挂载点。不同于 Vue 1.x,所有的挂载元素会被 Vue 生成的 DOM 替换。因此不推荐挂载 root 实例到 html 或者 body 上。
在$mount方法中,我们也可以看到这样一段代码,它提示我们不能直接挂载到html或body上:
if (el === document.body || el === document.documentElement) {
process.env.NODE_ENV !== 'production' && warn(
`Do not mount Vue to <html> or <body> - mount to normal elements instead.`
)
return this
}
那么,为什么不能挂载到html或者body上呢,其实这是因为:$mount方法执行后,会直接替换挂载节点上面的内容,如果直接挂载html或者body上,很有可能会丢失掉一些东西,比如:meta,link或者script等。
- 处理template:处理
template是$mount方法的核心,这个过程也相对比较复杂,代码比较多一点,但流程还是比较清晰的。首先会对render进行判断,如果有render那么就不会再走处理template这部分的逻辑了,一个使用render的例子就是我们的main.js入口文件:
import Vue from 'vue'
import App from './App.vue'
new Vue({
render: h => h(App)
}).$mount('#app')
因为在创建根实例的时候提供了render选项,因此在$mount方法中进行$options.render条件为真,直接走最后一步:调用原始$mount方法。
注意:其实我们使用Vue-Cli脚手架创建的项目,组件在$mount方法执行的时候,已经存在render函数了,这是因为vue-loader已经帮我们把template转换为render函数了,因此对于大多数情况来说不会走处理template的过程,只有少部分特殊情况才会走template处理。
在分析完提供render选择的分支后,我们来看一下不提供render选项的时候,处理template的逻辑。我们先看一下,什么情况下会走处理template,以下面代码为例:
export default {
name: 'HelloWorld',
data () {
return {
msg: 'Welcome to Your Vue.js App'
}
},
template: `<div class="hello">{{ msg }}</div>`
}
这个时候对于条件判断template和typeof template === 'string'都为真,因此会走最后一步compileToFunctions(template, ...),这一步主要是把template编译成render函数,这个过程我们会在后续详细进行说明。转换完毕以后,在把render赋值到options.render上面,这个步骤就跟我们手动提供一个render函数是类似的。
处理template的过程我们已经整体介绍完毕了,然后我们来分析一下没有提到的细节问题,首先当我们判断完毕typeof template === 'string'后,为什么还进行了如下代码判断:
if (template.charAt(0) === '#') {
template = idToTemplate(template)
/* istanbul ignore if */
if (process.env.NODE_ENV !== 'production' && !template) {
warn(
`Template element not found or is empty: ${options.template}`,
this
)
}
}
这是因为,template我们可以直接传递一个DOM节点的id,例如:
export default {
template: '#main'
}
这个时候,检查到template第一个字符为#号,然后调用idToTemplate,它的代码如下:
const idToTemplate = cached(id => {
const el = query(id)
return el && el.innerHTML
})
这段代码主要作用就是根据id查询DOM元素,然后返回它的innerHTML内容。
接下来第二个问题,为什么会有如下else if分支逻辑?
else if (template.nodeType) {
template = template.innerHTML
}
这是因为,template除了可以接受字符串以外,还可以直接接受一个DOM元素节点,例如:
<div id="main">
<div>dom</div>
</div>
export default {
name: 'HelloWorld',
template: document.querySelector('#main')
}
最后一个问题,如果我既没有传递render,也没有提供template,会发生什么呢?其实它会最后降级到去获取el选项,代码如下:
else if (el) {
template = getOuterHTML(el)
}
如果render和template都没有提供,那么会在最后一步使用el选项,然后通过el获取DOM元素的outerHTML,innerHTML和outerHTML的区别如下:
// 模拟一个DOM元素
const dom = `<div id="main">
<div>dom</div>
</div>`
const innerHTML = '<div>dom</div>'
const outerHTML = `<div id="main">
<div>dom</div>
</div>``
- **调用原始mount
方法的最后一个步骤,也就是着重分析原始(公共)$mount`方法。我们先来回顾一下这个方法的实现代码:
Vue.prototype.$mount = function (
el?: string | Element,
hydrating?: boolean
): Component {
el = el && inBrowser ? query(el) : undefined
return mountComponent(this, el, hydrating)
}
在这个方法中,处理el的过程跟之前的没有什么区别,那么我们分析的重点就落到了mountComponent方法,这个方法是定义在src/core/instance/lifecycle.js文件中,其代码如下:
export function mountComponent (
vm: Component,
el: ?Element,
hydrating?: boolean
): Component {
vm.$el = el
if (!vm.$options.render) {
vm.$options.render = createEmptyVNode
if (process.env.NODE_ENV !== 'production') {
/* istanbul ignore if */
if ((vm.$options.template && vm.$options.template.charAt(0) !== '#') ||
vm.$options.el || el) {
warn(
'You are using the runtime-only build of Vue where the template ' +
'compiler is not available. Either pre-compile the templates into ' +
'render functions, or use the compiler-included build.',
vm
)
} else {
warn(
'Failed to mount component: template or render function not defined.',
vm
)
}
}
}
callHook(vm, 'beforeMount')
let updateComponent
/* istanbul ignore if */
if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
updateComponent = () => {
const name = vm._name
const id = vm._uid
const startTag = `vue-perf-start:${id}`
const endTag = `vue-perf-end:${id}`
mark(startTag)
const vnode = vm._render()
mark(endTag)
measure(`vue ${name} render`, startTag, endTag)
mark(startTag)
vm._update(vnode, hydrating)
mark(endTag)
measure(`vue ${name} patch`, startTag, endTag)
}
} else {
updateComponent = () => {
vm._update(vm._render(), hydrating)
}
}
// we set this to vm._watcher inside the watcher's constructor
// since the watcher's initial patch may call $forceUpdate (e.g. inside child
// component's mounted hook), which relies on vm._watcher being already defined
new Watcher(vm, updateComponent, noop, {
before () {
if (vm._isMounted && !vm._isDestroyed) {
callHook(vm, 'beforeUpdate')
}
}
}, true /* isRenderWatcher */)
hydrating = false
// manually mounted instance, call mounted on self
// mounted is called for render-created child components in its inserted hook
if (vm.$vnode == null) {
vm._isMounted = true
callHook(vm, 'mounted')
}
return vm
}
mountComponent方法代码看起来很多,其实做的事情并不复杂,我们可以把它分为三个步骤:callHook触发生命周期函数、定义updateComponent和定义渲染Watcher。
- callHook触发生命周期函数:这一部分最简单,只需要调用
callHook方法触发对应的生命周期即可,在mountComponent方法中,一共有三处触发生命周期的地方,分别是:beforeMount,mounted和beforeUpdate。 - 定义updateComponent:定义
updateComponent方法我们只需要看else分支即可,if分支主要做性能埋点相关的事情,这里会在开启浏览器performance时用到。updateComponent方法里面的代码调用了vm._update()这个方法的主要作用是触发组件重新渲染,而vm._render()我们在之前已经介绍过了。 - 定义渲染Watcher:在
mountComponent方法中定义了一个渲染Watcher,其中渲染Watcher的第二个参数传递了我们的updateComponent,这个参数会在渲染Watcher实例化的时候赋值给this.getter属性,当进行派发更新的时候,会遍历subs数组执行update,然后调用this.getter,也就是再次调用updateComponent,然后让组件重新渲染。
流程图
在分析完$mount方法后,我们可以得到如下流程图:
render和renderProxy
介绍完$mount后,我们来看一下render以及renderProxy相关的逻辑,这一节的主要目标是:弄清楚renderProxy的作用以及render的实现原理。
renderProxy
我们在之前介绍的initMixin方法中,有下面这样一段代码:
import { initProxy } from './proxy'
export default initMixin (Vue) {
Vue.prototype._init = function () {
// ...
if (process.env.NODE_ENV !== 'production') {
initProxy(vm)
} else {
vm._renderProxy = vm
}
// ...
}
}
initProxy是定义在src/core/instance/proxy.js文件中的一个方法,其代码如下:
let initProxy
initProxy = function initProxy (vm) {
if (hasProxy) {
// determine which proxy handler to use
const options = vm.$options
const handlers = options.render && options.render._withStripped
? getHandler
: hasHandler
vm._renderProxy = new Proxy(vm, handlers)
} else {
vm._renderProxy = vm
}
}
代码分析:
- 这个方法首先判断了当前环境是否支持原生
Proxy,如果支持则创建一个Proxy代理,其中hasProxy是一个boolean值,它的实现逻辑如下:
const hasProxy = typeof Proxy !== 'undefined' && isNative(Proxy)
- 然后根据
options.render和options.render._withStripped的值来选择使用getHandler还是hasHandler,当使用vue-loader解析.vue文件时,这个时候options.render._withStripped为真值,因此选用getHandler。当选择使用compiler版本的Vue.js时,我们的入口文件中根实例是这样定义的:
import Vue from 'vue'
import App from './App'
new Vue({
el: '#app',
components: { App },
template: '<App/>'
})
这个时候,对于根实例而言其options.render._withStripped为undefined,因此使用hasHandler。在搞清楚什么时候使用getHandler和hasHandler后,我们可能会有另外的问题:
getHandler和hasHandler是干什么的?怎么触发?
在回答第一个问题之前,我们先来看一下getHandler和hasHandler的定义:
const allowedGlobals = makeMap(
'Infinity,undefined,NaN,isFinite,isNaN,' +
'parseFloat,parseInt,decodeURI,decodeURIComponent,encodeURI,encodeURIComponent,' +
'Math,Number,Date,Array,Object,Boolean,String,RegExp,Map,Set,JSON,Intl,' +
'require' // for Webpack/Browserify
)
const warnNonPresent = (target, key) => {
warn(
`Property or method "${key}" is not defined on the instance but ` +
'referenced during render. Make sure that this property is reactive, ' +
'either in the data option, or for class-based components, by ' +
'initializing the property. ' +
'See: https://vuejs.org/v2/guide/reactivity.html#Declaring-Reactive-Properties.',
target
)
}
const warnReservedPrefix = (target, key) => {
warn(
`Property "${key}" must be accessed with "$data.${key}" because ` +
'properties starting with "$" or "_" are not proxied in the Vue instance to ' +
'prevent conflicts with Vue internals. ' +
'See: https://vuejs.org/v2/api/#data',
target
)
}
const hasHandler = {
has (target, key) {
const has = key in target
const isAllowed = allowedGlobals(key) ||
(typeof key === 'string' && key.charAt(0) === '_' && !(key in target.$data))
if (!has && !isAllowed) {
if (key in target.$data) warnReservedPrefix(target, key)
else warnNonPresent(target, key)
}
return has || !isAllowed
}
}
const getHandler = {
get (target, key) {
if (typeof key === 'string' && !(key in target)) {
if (key in target.$data) warnReservedPrefix(target, key)
else warnNonPresent(target, key)
}
return target[key]
}
}
我们可以看到,getHandler和hasHandler所做的事情几乎差不多,都是在渲染阶段对不合法的数据做判断和处理。对于warnNonPresent而言,它提示我们在模板中使用了未定义的变量;对于warnReservedPrefix而言,它提示我们不能定义带$或者_开头的变量,因为这样容易和一些内部的属性相互混淆。
<template>
{{msg1}}
{{$age}}
</template>
<script>
// msg1报错
// $age报错
export default {
data () {
return {
msg: 'message',
$age: 23
}
}
}
</script>
紧接着,我们第二个问题:getHandler和hasHandler如何触发?这其实涉及到一点ES6 Proxy方面的知识,我们以下面这段代码为例来进行说明:
const obj = {
a: 1,
b: 2,
c: 3
}
const proxy = new Proxy(obj, {
has (target, key) {
console.log(key)
return key in target
},
get (target, key) {
console.log(key)
return target[key]
}
})
// 触发getHandler,输出a
proxy.a
// 触发hasHandler,输出 b c
with(proxy){
const d = b + c
}
在以上代码中,我们定义了一个proxy代理,当我们访问proxy.a的时候,根据Proxy相关的知识会触发getHandler,因此会输出a。当我们使用with访问proxy的时候,在其中任何属性的访问都会触发hasHandler,因此会输出b和c。
在以上代码分析完毕后,我们就可以对initProxy的作用进行一个总结:在渲染阶段对不合法的数据做判断和处理。
render
在之前的代码中,我们在mountComponent中遇到过下面这样一段代码:
updateComponent = () => {
vm._update(vm._render(), hydrating)
}
在这一节,我们来分析一下_render函数的实现,它其实是在src/core/instance/render.js文件中被定义:
export function renderMixin (Vue) {
Vue.prototype._render = function (): VNode {
const vm: Component = this
const { render, _parentVnode } = vm.$options
// ...省略代码
let vnode
try {
// There's no need to maintain a stack because all render fns are called
// separately from one another. Nested component's render fns are called
// when parent component is patched.
currentRenderingInstance = vm
vnode = render.call(vm._renderProxy, vm.$createElement)
} catch (e) {
handleError(e, vm, `render`)
}
// ...省略代码
vnode.parent = _parentVnode
return vnode
}
}
其中通过$options结构出来的render,就是我们实例化的时候提供的render选择或者通过template编译好的render函数。在_render代码中,最重要的一步是render.call函数的调用,render函数执行后会返回VNode,VNode会在之后的处理过程中使用到。
我们在render.call方法调用的时候,除了传递我们的renderProxy代理,还传递了一个$createElement函数,其中这个函数是在initRender方法中被定义:
export function initRender (vm) {
// ...省略代码
vm._c = (a, b, c, d) => createElement(vm, a, b, c, d, false)
// normalization is always applied for the public version, used in
// user-written render functions.
vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true)
// ...省略代码
}
我们发现,vm.$createElement和vm._c的函数定义是差不多的,唯一的区别是在调用createElement方法的时候,传递的最后一个参数不相同。$createElement和_c方法虽然方法定义差不多,但使用场景是不一样的,$createElement通常是用户手动提供的render来使用,而_c方法通常是模板编译生成的render来使用的。
根据render函数的定义,我们可以把template例子改写成使用render的形式:
<template>
<div id="app">
{{msg}}
</div>
</template>
<script>
export default () {
data () {
return {
msg: 'message'
}
}
}
</script>
render改写后:
export default {
data () {
return {
msg: 'message'
}
},
render: ($createElement) {
return $createElement('div', {
attrs: {
id: 'app'
}
}, this.message)
}
}
在这一小节,我们分析了render的实现,在下一小节我们将深入学习createElement方法的实现原理。
createElement
在上一节,我们知道了render函数执行的时候,会调用$createElement或者_c方法,也知道了它们最后其实调用的是同一个createElement方法,只不过最后一个参数有点区别。在这一节,我们来详细分析一下createElement方法的实现逻辑。
createElement是定义在src/core/vdom/create-element.js文件中,其代码如下:
const SIMPLE_NORMALIZE = 1
const ALWAYS_NORMALIZE = 2
export function createElement (
context: Component,
tag: any,
data: any,
children: any,
normalizationType: any,
alwaysNormalize: boolean
): VNode | Array<VNode> {
if (Array.isArray(data) || isPrimitive(data)) {
normalizationType = children
children = data
data = undefined
}
if (isTrue(alwaysNormalize)) {
normalizationType = ALWAYS_NORMALIZE
}
return _createElement(context, tag, data, children, normalizationType)
}
在分析代码之前,我们来看一下$createElement和_c方法最后一个不相同的参数,在createElement中体现在什么地方。我们可以从最后一个参数命名猜测其作用,对于模板编译调用_c时,其alwaysNormalize传递的是false,因为_c只会在内部使用,因此其方法调用的时候参数格式是比较规范的,我们不需要过多的进行normalize。而$createElement是提供给用户使用的,为了让$createElement更加简洁和实用,允许用户传递不同形式的参数来调用$createElement,这也就造成了用户手写的render,我们必须始终进行normalize。
在上述分析完毕后,我们就知道了$createElement和_c最后一个不相同的参数,体现在什么地方了:调用_c时对children进行简单规范化,调用$createElement时必须始终对children进行规范化。
回到正题,我们发现createElement其实是对_createElement方法的一层包裹,之所以这样做是为了让createElement达到一种类似于函数重载的功能(JavaScript实际并没有这个概念)。其中第三个参数data是可以不传的。
// 不传递data
createElement(this, 'div', 'Hello, Vue', 1, false)
// 传递data
createElement(this, 'div', undefined, 'Hello, Vue', 1, false)
当不传递data的时候,我们需要把第三、第四个参数往后移动一个位置,然后把data赋值为undefined,最后在把处理好的参数传递给_createElement。接下来,我们先看一下_createElement方法几个参数的具体作用:
context:VNode当前上下环境。tag:标签,可以是正常的HTML元素标签,也可以是Component组件。data:VNode的数据,其类型为VNodeData,可以在根目录flow/vnode.js文件中看到其具体定义。children:VNode的子节点。normalizationType:children子节点规范化类型。
其具体实现代码如下:
export function _createElement (
context: Component,
tag?: string | Class<Component> | Function | Object,
data?: VNodeData,
children?: any,
normalizationType?: number
): VNode | Array<VNode> {
// ...省略代码
if (!tag) {
// in case of component :is set to falsy value
return createEmptyVNode()
}
if (normalizationType === ALWAYS_NORMALIZE) {
children = normalizeChildren(children)
} else if (normalizationType === SIMPLE_NORMALIZE) {
children = simpleNormalizeChildren(children)
}
let vnode, ns
if (typeof tag === 'string') {
let Ctor
ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag)
if (config.isReservedTag(tag)) {
// platform built-in elements
if (process.env.NODE_ENV !== 'production' && isDef(data) && isDef(data.nativeOn)) {
warn(
`The .native modifier for v-on is only valid on components but it was used on <${tag}>.`,
context
)
}
vnode = new VNode(
config.parsePlatformTagName(tag), data, children,
undefined, undefined, context
)
} else if ((!data || !data.pre) && isDef(Ctor = resolveAsset(context.$options, 'components', tag))) {
// component
vnode = createComponent(Ctor, data, context, children, tag)
} else {
// unknown or unlisted namespaced elements
// check at runtime because it may get assigned a namespace when its
// parent normalizes children
vnode = new VNode(
tag, data, children,
undefined, undefined, context
)
}
} else {
// direct component options / constructor
vnode = createComponent(tag, data, context, children)
}
// ...省略代码
}
_createElement的代码看起来有点多,但它主要做两件事情:规范化子节点和创建VNode节点,接下来我们围绕这两个方面来详细介绍。
- 规范化子节点:因为虚拟
DOM是一个树形结构,每一个节点都应该是VNode类型,但是children参数又是任意类型的,所以如果有子节点,我们需要把它进行规范化成VNode类型,如果没有子节点,那么children就是undefined。至于如何规范化,则是通过normalizationType参数来实现的,其中normalizationType可能的值我们只说三种:undefined表示不进行规范化,1表示简单规范化,2表示始终规范化。我们先来看当值为1的情况,它调用了simpleNormalizeChildren,这个方法和normalizeChildren是定义在同一个地方src/core/vdom/helpers/normalize-children.js文件中,其代码如下:
export function simpleNormalizeChildren (children: any) {
for (let i = 0; i < children.length; i++) {
if (Array.isArray(children[i])) {
return Array.prototype.concat.apply([], children)
}
}
return children
}
simpleNormalizeChildren的作用是把多维数组降低一个维度,例如二维数组降低到一维数组,三维数组降低到二维数组,这样做的目的是为了方便后续遍历children。
// 展示使用,实例为VNode
let children = ['VNode', ['VNode', 'VNode'], 'VNode']
// 简单规范化子节点
children = simpleNormalizeChildren(children)
// 规范化后
console.log(children) // ['VNode', 'VNode', 'VNode', 'VNode']
接下来我们来看值为2的情况,它调用了normalizeChildren,其代码如下:
export function normalizeChildren (children: any): ?Array<VNode> {
return isPrimitive(children)
? [createTextVNode(children)]
: Array.isArray(children)
? normalizeArrayChildren(children)
: undefined
}
normalizeChildren的代码不是很多,也不是很复杂。当children是基础类型值的时候,直接返回一个文本节点的VNode数组,createTextVNode我们在之前已经介绍过了。如果不是,则再判断是否为数组,不是则其children就是undefined,是的话就调用normalizeArrayChildren来规范化。接下来,我们重点分析以下normalizeArrayChildren的实现,它和normalizeChildren是定义在同一个位置,其实现代码如下:
function normalizeArrayChildren (children: any, nestedIndex?: string): Array<VNode> {
const res = []
let i, c, lastIndex, last
for (i = 0; i < children.length; i++) {
c = children[i]
if (isUndef(c) || typeof c === 'boolean') continue
lastIndex = res.length - 1
last = res[lastIndex]
// nested
if (Array.isArray(c)) {
if (c.length > 0) {
c = normalizeArrayChildren(c, `${nestedIndex || ''}_${i}`)
// merge adjacent text nodes
if (isTextNode(c[0]) && isTextNode(last)) {
res[lastIndex] = createTextVNode(last.text + (c[0]: any).text)
c.shift()
}
res.push.apply(res, c)
}
} else if (isPrimitive(c)) {
if (isTextNode(last)) {
// merge adjacent text nodes
// this is necessary for SSR hydration because text nodes are
// essentially merged when rendered to HTML strings
res[lastIndex] = createTextVNode(last.text + c)
} else if (c !== '') {
// convert primitive to vnode
res.push(createTextVNode(c))
}
} else {
if (isTextNode(c) && isTextNode(last)) {
// merge adjacent text nodes
res[lastIndex] = createTextVNode(last.text + c.text)
} else {
// default key for nested array children (likely generated by v-for)
if (isTrue(children._isVList) &&
isDef(c.tag) &&
isUndef(c.key) &&
isDef(nestedIndex)) {
c.key = `__vlist${nestedIndex}_${i}__`
}
res.push(c)
}
}
}
return res
}
虽然normalizeArrayChildren的代码很多,但做的事情并不复杂,我们只要关注遍历过程中几个重要的逻辑分支即可。
- 遍历项为数组:这种情况稍微复杂一点,多见于
v-for或者slot的时候,会出现嵌套VNode数组的情况,如果存在嵌套VNode的情况会递归调用normalizeArrayChildren,我们以下面这个例子为例:
<template>
<div id="app">
<p>{{msg}}</p>
<span v-for="(item, index) in list" :key="index">{{item}}</span>
</div>
</template>
<script>
export default {
name: 'App',
data () {
return {
msg: 'message',
list: [1, 2, 3]
}
}
}
</script>
当App组件render函数执行的时候,其children子节点会出现VNode嵌套数组的情况,可以用以下代码示例说明:
const children = [
[ { tag: 'p' }, ... ],
[
[ { tag: 'span', ... } ],
[ { tag: 'span', ... } ],
[ { tag: 'span', ... } ]
]
]
递归调用normalizeArrayChildren方法后,嵌套数组被处理成了一维数组,如下:
const children = [
[ { tag: 'p' }, ... ],
[ { tag: 'span', ... } ],
[ { tag: 'span', ... } ],
[ { tag: 'span', ... } ]
]
- 遍历项为基础类型:当为基础类型的时候,调用封装的
createTextVNode方法来创建一个文本节点,然后push到结果数组中。 - 遍历项已经是
VNode类型:这种情况最简单,如果不属于以上两种情况,那么代表本身已经是VNode类型了,这时候我们什么都不需要做,直接push到结果数组中即可。
在这三个逻辑分支中,都判断了isTextNode,这部分的代码主要是用来优化文本节点:如果存在两个连续的文本节点,则将其合并成一个文本节点。
// 合并前
const children = [
{ text: 'Hello ', ... },
{ text: 'Vue.js', ... },
]
// 合并后
const children = [
{ text: 'Hello Vue.js', ... }
]
- 创建VNode节点:创建
VNode节点的逻辑有两大分支,tag为string类型和component类型,其中string类型又存在几个小的逻辑判断分支。在createElement章节,我们重点介绍类型为string的分支。在这个分支中,首先判断tag提供的标签名是不是平台保留标签(html或svg标签),如果是则直接创建对应标签的VNode节点,如果不是则尝试在已经全局或者局部注册的组件中去匹配,匹配成功则使用createComponent去创建组件节点,如果没有匹配上则创建一个未知标签的VNode节点,例如:
<template>
<div id="app">
<div>{{msg}}</div>
<hello-world :msg="msg" />
<cms>12321321</cms>
</div>
</template>
<script>
import HelloWorld from '@/components/HelloWorld.vue'
export default {
name: 'App',
data () {
return {
msg: 'message',
}
},
components: {
HelloWorld
}
}
</script>
tag为cms,但它既不像div一样是平台保留标签,又不像hello-world一样是已经局部注册过的组件,它属于未知的标签。这里之所以直接创建未知标签的VNode而不是报错,这是因为子节点在createElement的过程中,有可能父节点会为其提供一个namespace,真正做未知标签校验的过程发生在path阶段,path的过程我们将在后续进行介绍。
createComponent
在前面介绍createElement方法的过程中,我们提到过有两处都调用了createComponent方法,在这一节我们来详细分析一下createComponent方法的实现逻辑。
createComponent是定义在src/core/vmode/create-component.js文件中的,其代码如下:
export function createComponent (
Ctor: Class<Component> | Function | Object | void,
data: ?VNodeData,
context: Component,
children: ?Array<VNode>,
tag?: string
): VNode | Array<VNode> | void {
// ...省略其它
const baseCtor = context.$options._base
// plain options object: turn it into a constructor
if (isObject(Ctor)) {
Ctor = baseCtor.extend(Ctor)
}
data = data || {}
// resolve constructor options in case global mixins are applied after
// component constructor creation
resolveConstructorOptions(Ctor)
// install component management hooks onto the placeholder node
installComponentHooks(data)
// return a placeholder vnode
const name = Ctor.options.name || tag
const vnode = new VNode(
`vue-component-${Ctor.cid}${name ? `-${name}` : ''}`,
data, undefined, undefined, undefined, context,
{ Ctor, propsData, listeners, tag, children },
asyncFactory
)
// ...省略其它
return vnode
}
因为createComponent方法要实现的功能点有很多,以上是我们的精简代码,其中被精简掉的代码有:组件检验相关、异步组件相关、获取prosData相关、抽象组件相关以及weex相关。
在分析createComponent方法的时候,我们主要关注两个方面的内容:构造子类构造函数和安装组件钩子函数。至于最后的创建组件VNode并返回VNode,则是最简单的,在这一步我们只需要知道创建组件VNode的时候,向VNode构造函数传递的第三个参数children为undefined,也就是说组件VNode没有children子节点,因为其值为undefined。
代码分析:
- 构造子类构造函数:在代码最开始,首先通过
$options._base拿到基础构造函数,这个基础构造函数也就是大Vue的构造函数,$options._base赋值过程是在initGlobalAPI函数执行的过程中赋值的。
export function initGlobalAPI (Vue) {
Vue.options._base = Vue
}
根据我们之前介绍的规则,我们在options上的属性,可以在后续通过$options拿到,这是因为在this._init方法的执行过程中,进行了mergeOptions配置合并。
vm.$options = mergeOptions(
resolveConstructorOptions(vm.constructor),
options || {},
vm
)
我们再来看一下createComponent的第一个参数,以App.vue组件为例:
import HelloWorld from '@/components/HelloWorld.vue'
export default {
name: 'App',
data () {
return {
msg: 'message',
age: 23,
list: [1, 2, 3]
}
},
components: {
HelloWorld
}
}
我们在App.vue组件中export导出的是一个对象,其中对象定义了name、data以及components三个属性,那么Ctor参数就应该是这个对象,但当我们真实调试的时候却发现Ctor属性比我们想象的还要多,这是因为vue-loader在处理.vue文件的时候默认帮我们做了一些处理,以下是App.vue真实调试时的Ctor参数:
const Ctor = {
beforeCreate: [function () {}],
beforeDestroy: [function () {}],
components: {
HelloWorld
},
data () {
return {
msg: 'message',
age: 23,
list: [1, 2, 3]
}
},
name: 'App',
render: function () {},
staticRenderFns: [],
__file: './App.vue',
_compiled: true
}
接下来,我们来看一下baseCtor.extend,全局的extend方法的定义位置我们已经在之前介绍过了,它是在initGlobalAPI方法中调用initExtend时被定义的,其中initExtend定义在src/core/global-api/extend.js文件中,代码如下:
export function initExtend (Vue: GlobalAPI) {
Vue.cid = 0
let cid = 1
Vue.extend = function (extendOptions: Object): Function {
extendOptions = extendOptions || {}
const Super = this
const SuperId = Super.cid
const cachedCtors = extendOptions._Ctor || (extendOptions._Ctor = {})
if (cachedCtors[SuperId]) {
return cachedCtors[SuperId]
}
const name = extendOptions.name || Super.options.name
if (process.env.NODE_ENV !== 'production' && name) {
validateComponentName(name)
}
const Sub = function VueComponent (options) {
this._init(options)
}
Sub.prototype = Object.create(Super.prototype)
Sub.prototype.constructor = Sub
Sub.cid = cid++
Sub.options = mergeOptions(
Super.options,
extendOptions
)
Sub['super'] = Super
// 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)
}
// allow further extension/mixin/plugin usage
Sub.extend = Super.extend
Sub.mixin = Super.mixin
Sub.use = Super.use
// create asset registers, so extended classes
// can have their private assets too.
ASSET_TYPES.forEach(function (type) {
Sub[type] = Super[type]
})
// enable recursive self-lookup
if (name) {
Sub.options.components[name] = Sub
}
// keep a reference to the super options at extension time.
// later at instantiation we can check if Super's options have
// been updated.
Sub.superOptions = Super.options
Sub.extendOptions = extendOptions
Sub.sealedOptions = extend({}, Sub.options)
// cache constructor
cachedCtors[SuperId] = Sub
return Sub
}
}
我们看一下Vue.extend方法最核心的几段代码:
const Super = this
const Sub = function VueComponent (options) {
this._init(options)
}
Sub.prototype = Object.create(Super.prototype)
Sub.prototype.constructor = Sub
在extend方法中,使用了非常经典的寄生组合继承的方式,来让Sub子类去继承父类的属性和方法。在原型继承之前,首先调用了this._init方法,这个方法的逻辑我们在之前已经提到过了,这里就不再累述。原型继承后,Sub子类就拥有了Super父类全部的属性和方法,例如:
const Super = function () {
this.id = 1
this.name = 'Super'
}
Super.prototype.say = function () {
console.log('hello Super')
}
const Sub = function () {
Super.call(this)
}
Sub.prototype = Object.create(Super.prototype)
Sub.prototype.constructor = Sub
const sub = new Sub()
console.log(sub.id) // 1
console.log(sub.name) // Super
sub.say() // hellp Super
我们来看另外几段代码:
const cachedCtors = extendOptions._Ctor || (extendOptions._Ctor = {})
if (cachedCtors[SuperId]) {
return cachedCtors[SuperId]
}
cachedCtors[SuperId] = Sub
这几段代码是用来缓存的,作用是:如果我们先在A.vue文件中引入了header.vue组件,它会执行一遍extend,随后我们又在B.vue文件中引入了header.vue,由于已经有了header.vue组件的缓存,因此不再执行后续的代码,直接返回。
// A.vue
import MHeader from '@/components/header.vue'
export default {
name: 'AComponent',
components: {
MHeader
}
}
// B.vue
import MHeader from '@/components/header.vue'
export default {
name: 'BComponent',
components: {
MHeader
}
}
// header.vue只会extend一次。
最后在继承完毕后,还处理了props、computed以及各种全局API方法,这部分的逻辑跟之前我们提到过的是一样的,不再此累述。
- 安装组件钩子函数:我们在前面提到过,
Vue中的虚拟DOM借鉴了开源库snabbdom的实现,在这个库里面当VNode节点处于不同的场景下,提供了对应的钩子函数来方便我们处理相关的逻辑,这些钩子函数如下:
在Vue中也用到了这些钩子函数,它的定义如下:
const componentVNodeHooks = {
init: function () {}, // 初始化时触发
prepatch: function () {}, // patch之前触发
insert: function () {}, // 插入到DOM时触发
destroy: function () {} // 节点移除之前触发
}
我们来看一下,installComponentHooks方法的定义:
const hooksToMerge = Object.keys(componentVNodeHooks)
function installComponentHooks (data: VNodeData) {
const hooks = data.hook || (data.hook = {})
for (let i = 0; i < hooksToMerge.length; i++) {
const key = hooksToMerge[i]
const existing = hooks[key]
const toMerge = componentVNodeHooks[key]
if (existing !== toMerge && !(existing && existing._merged)) {
hooks[key] = existing ? mergeHook(toMerge, existing) : toMerge
}
}
}
在installComponentHooks方法执行的时候,遍历了我们定义的hooks对象的属性,然后在遍历的过程中把这些hook赋值到我们传递的参数上面,其中有一个地方值得我们注意:如果已经有了相同的hook,则会执行mergeHook来合并,mergeHook方法的定义如下:
function mergeHook (f1: any, f2: any): Function {
const merged = (a, b) => {
// flow complains about extra args which is why we use any
f1(a, b)
f2(a, b)
}
merged._merged = true
return merged
}
我们以下面代码为例,来举例说明:
// 合并前
const hooks = {
init: function () {
console.log('init hook 1')
}
}
const vnode = {
data: {
hook: {
init: function () {
console.log('init hook 2')
}
}
}
}
// 合并
mergeHook()
// 合并后
const vnode = {
data: {
hook: {
init: () => {
init1(),
init2()
}
}
}
}
在createComponent这一小节,我们介绍了组件会进行mergeOptions配置合并,为了更好的理解path的过程,我们会在接下来的小节优先介绍mergeOptions配置合并策略。
合并策略
在这一节合并策略中,我们主要分三个步骤来说明:配置合并的背景、配置合并的场景以及合并策略。
背景
我们可以会很好奇,为什么要进行配置合并?这是因为Vue内部存在一些默认的配置,在初始化的时候又允许我们提供一些自定义配置,这是为了在不同的场景下达到定制化个性需求的目的。纵观一些优秀的开源库、框架它们的设计理念几乎都是类似的。
我们举例来说明一下配置合并的背景:
Vue.mixin({
created () {
console.log('global created mixin')
},
mounted () {
console.log('global mounted mixin')
}
})
假设我们使用Vue.mixin方法全局混入了两个生命周期配置created和mounted,那么在我们的应用中,这两个生命周期配置都会反应到各个实例上去,无论是根实例还是各种组件实例。但对于根实例或者组件实例而言,它们也可能会拥有自己的created或mounted配置,如果不进行合理的配置合并,那么会出现一些意料之外的问题。
场景
要进行配置合并的场景不止一两处,我们主要介绍以下四种场景:
- vue-loader:在之前我们提到过当我们使用
.vue文件的形式进行开发的时候,由于.vue属于特殊的文件扩展,webpack无法原生识别,因此需要对应的loader去解析,它就是vue-loader。假如我们撰写以下HelloWorld.vue组件,然后在别的地方去引入它。
// HelloWorld.vue
export default {
name: 'HelloWorld',
data () {
return {
msg: 'hello, world'
}
}
}
// App.vue
import HelloWorld from '@/components/HelloWorld.vue'
export default {
name: 'App',
components: {
HelloWorld
}
}
因为我们在HelloWorld.vue文件中只提供了name和data两个配置选项,但真正调试的时候我们发现HelloWorld组件的实例上多了很多额外的属性,这是因为vue-loader帮我们默认添加的。
const HelloWorld = {
beforeCreate: [function () {}],
beforeDestroy: [function () {}],
name: 'HelloWorld',
data () {
return {
msg: 'hello, world'
}
},
...
}
我们可以发现vue-loader默认添加的有beforeCreate和beforeDestroy两个配置,如果我们组件自身也提供了这两个配置的话,这种情况必须进行配置合并。
- extend:在上一节我们介绍
createComponent的时候,我们知道子组件会继承大Vue上的一些属性或方法,假设我们全局注册了一个组件。
import HelloWorld from '@/components/HelloWorld.vue'
Vue.component('HelloWorld', HelloWorld)
当我们在其它组件中也注册了一些组件,这样大Vue上的components就要和组件中的components进行合理的配置合并。
- mixin:在前面的配置合并背景小节中,我们使用
Vue.mixin全局混入了两个生命周期配置,这属于mixin配置合并的范围,我们来举例另外一种组件内的mixin混入场景:
// mixin定义
const sayMixin = {
created () {
console.log('hello mixin created')
},
mounted () {
console.log('hello mixin mounted')
}
}
// 组件引入mixin
export default {
name: 'App',
mixins: [sayMixin],
created () {
console.log('app component created')
},
mounted () {
console.log('app component mounted')
}
}
当在App.vue组件中提供mixins选择的时候,因为在我们定义的sayMixin也提供了created和mounted两个生命周期配置,因此这种情况下也要进行配置合并。又因为mixins接受一个数组选项,假如我们传递了多个已经定义的mixin,而这些mixin又可能会存在提供了相同配置的情况,因此同样需要进行配置合并。
注意:Vue.mixin全局API方法在内部调用了mergeOptions来进行混入,它的定义位置我们在之前的initGlobalAPI小节中提到过,其实现代码如下:
import { mergeOptions } from '../util/index'
export function initMixin (Vue: GlobalAPI) {
Vue.mixin = function (mixin: Object) {
this.options = mergeOptions(this.options, mixin)
return this
}
}
- this._init:严格意义上来说,这里其实并不算是一个配置合并的场景,而应该是一种配置合并的手段。对于第一种
vue-loader和第二种extend的场景,它们在必要的场景下也会在this._init进行配置合并,例如在子组件实例化的时候,它在构造函数中就调用了this._init:
const Sub = function VueComponent (options) {
this._init(options)
}
Vue.prototype._init = function () {
// ...省略其它
if (options && options._isComponent) {
// optimize internal component instantiation
// since dynamic options merging is pretty slow, and none of the
// internal component options needs special treatment.
initInternalComponent(vm, options)
} else {
vm.$options = mergeOptions(
resolveConstructorOptions(vm.constructor),
options || {},
vm
)
}
// ...省略其它
}
合并策略
我们先来看看合并策略的代码,它是定义在src/core/util/options.js文件中,其代码如下:
export function mergeOptions (
parent: Object,
child: Object,
vm?: Component
): Object {
if (process.env.NODE_ENV !== 'production') {
checkComponents(child)
}
if (typeof child === 'function') {
child = child.options
}
normalizeProps(child, vm)
normalizeInject(child, vm)
normalizeDirectives(child)
// Apply extends and mixins on the child options,
// but only if it is a raw options object that isn't
// the result of another mergeOptions call.
// Only merged options has the _base property.
if (!child._base) {
if (child.extends) {
parent = mergeOptions(parent, child.extends, vm)
}
if (child.mixins) {
for (let i = 0, l = child.mixins.length; i < l; i++) {
parent = mergeOptions(parent, child.mixins[i], vm)
}
}
}
const options = {}
let key
for (key in parent) {
mergeField(key)
}
for (key in child) {
if (!hasOwn(parent, key)) {
mergeField(key)
}
}
function mergeField (key) {
const strat = strats[key] || defaultStrat
options[key] = strat(parent[key], child[key], vm, key)
}
return options
}
我们先忽略mergeOptions方法中其它的代码,来看最核心的mergeField,在这个方法里面,它会根据不同的key,调用策略对象strats中的策略方法,然后把合并完的配置再赋值到options上,strats策略对象每个key的具体定义我们会在之后对应的章节中介绍。
默认合并策略
在mergeField方法中,我们看到当传入的key没有对应的策略方法时,会使用defaultStrat默认合并策略,它的定义代码如下:
const defaultStrat = function (parentVal: any, childVal: any): any {
return childVal === undefined
? parentVal
: childVal
}
defaultStrat默认合并策略的代码非常简单,即:简单的覆盖已有值,例如:
const defaultStrat = function (parentVal, childVal) {
return childVal === undefined
? parentVal
: childVal
}
const parent = {
age: 23,
name: 'parent',
sex: 1
}
const child = {
age: undefined,
name: 'child',
address: '广州'
}
function mergeOptions (parent, child) {
let options = {}
for (const key in parent) {
mergeField(key)
}
for (const key in child) {
if (!parent.hasOwnProperty(key)) {
mergeField(key)
}
}
function mergeField (key) {
options[key] = defaultStrat(parent[key], child[key])
}
return options
}
const $options = mergeOptions(parent, child)
console.log($options) // { age: 23, name: 'child', sex: 1, address: '广州' }
代码分析:在以上案例中,age和name都存在于parent和child对象中,因为child.age值为undefined,所以最后取parent.age值,这种情况也适用于sex属性的合并。因为child.name值不为undefined,所以最后取child.name的值,这种情况也适用于address属性的合并。
注意:如果你想针对某一个选择修改它的默认合并策略,可以使用Vue.config.optionMergeStrategies去配置,例如:
// 自定义el选择的合并策略,只取第二个参数的。
import Vue from 'vue'
Vue.config.optionMergeStrategies.el = (toVal, fromVal) {
return fromVal
}
el和propsData合并
对于el和propsData属性的合并,在Vue中使用了默认合并策略,其定义代码如下:
const strats = config.optionMergeStrategies
if (process.env.NODE_ENV !== 'production') {
strats.el = strats.propsData = function (parent, child, vm, key) {
// ...省略其它
return defaultStrat(parent, child)
}
}
对于el和propsData这两个选项来说,使用默认合并策略的原因很简单,因为el和propsData只允许有一份。
生命周期hooks合并
对于生命周期钩子函数而言,它们都是通过mergeHook方法来合并的,strats策略对象上关于hooks属性定义代码如下:
export const LIFECYCLE_HOOKS = [
'beforeCreate',
'created',
'beforeMount',
'mounted',
'beforeUpdate',
'updated',
'beforeDestroy',
'destroyed',
'activated',
'deactivated',
'errorCaptured',
'serverPrefetch'
]
LIFECYCLE_HOOKS.forEach(hook => {
strats[hook] = mergeHook
})
我们接下来看一下mergeHook是如何实现的,其代码如下:
function mergeHook (
parentVal: ?Array<Function>,
childVal: ?Function | ?Array<Function>
): ?Array<Function> {
const res = childVal
? parentVal
? parentVal.concat(childVal)
: Array.isArray(childVal)
? childVal
: [childVal]
: parentVal
return res
? dedupeHooks(res)
: res
}
function dedupeHooks (hooks) {
const res = []
for (let i = 0; i < hooks.length; i++) {
if (res.indexOf(hooks[i]) === -1) {
res.push(hooks[i])
}
}
return res
}
我们可以看到在mergeHook方法中,它用到了三层三目运算来判断,首先判断了是否有childVal,如果没有则直接返回parentVal;如果有,则parentVal有没有,如果有则一定是数组形式,这个时候直接把childVal添加到parentVal数组的末尾;如果没有,则需要判断一下childVal是不是数组,如果不是数组则转成数组,如果已经是数组了,则直接返回。
在最后还判断了res,然后满足条件则调用dedupeHooks,这个方法的作用很简单,就是剔除掉数组中的重复项。最后,我们根据以上逻辑撰写几个案例来说明。
// 情况一
const parentVal = [function created1 () {}]
const childVal = undefined
const res = [function created1 () {}]
// 情况二
const parentVal = [function created1 () {}]
const childVal = [function created2 () {}]
const res = [function created1 () {}, function created2 () {}]
// 情况三
const parentVal = undefined
const childVal = [function created2 () {}]
const res = [function created2 () {}]
我们再来看一个比较特殊的场景:
// mixin.js
export const sayMixin = {
created () {
console.log('say mixin created')
}
}
export const helloMixin = {
created () {
console.log('hello mixin created')
}
}
// App.vue
export default {
name: 'App',
created () {
console.log('component created')
}
}
// 执行顺序
// say mixin created
// hello mixin created
// component created
代码分析:我们可以看到mixins里面的created生命周期函数会优先于组件自身提供的created生命周期函数,这是因为在遍历parent和child的属性之前,会优先处理extends和mixins选项。以mixins为例,它会首先遍历我们提供的mixins数组,然后依次把这些配置按照规则合并到parent上,最后在遍历child的属性时,才会把其自身的配置合并对应的位置,在我们提供的例子当中,自身提供的created会使用数组concat方法添加到数组的末尾。当组件触发created生命周期的时候,会按照数组顺序依次调用。
if (!child._base) {
if (child.extends) {
parent = mergeOptions(parent, child.extends, vm)
}
if (child.mixins) {
for (let i = 0, l = child.mixins.length; i < l; i++) {
parent = mergeOptions(parent, child.mixins[i], vm)
}
}
}
data和provide合并
对于data和provide而言,它们最后都使用mergeDataOrFn来合并,只不过对于data选项比较特殊,它需要单独包裹一层,它们在strats策略对象上的属性定义如下:
strats.data = function (
parentVal: any,
childVal: any,
vm?: Component
): ?Function {
if (!vm) {
if (childVal && typeof childVal !== 'function') {
process.env.NODE_ENV !== 'production' && warn(
'The "data" option should be a function ' +
'that returns a per-instance value in component ' +
'definitions.',
vm
)
return parentVal
}
return mergeDataOrFn(parentVal, childVal)
}
return mergeDataOrFn(parentVal, childVal, vm)
}
strats.provide = mergeDataOrFn
在合并data的包裹函数中,对childVal进行了检验,如果不是函数类型,提示错误信息并直接返回。如果时,再调用mergeDataOrFn方法来合并。接下来,我们来看一下mergeDataOrFn方法的具体实现逻辑:
export function mergeDataOrFn (
parentVal: any,
childVal: any,
vm?: Component
): ?Function {
if (!vm) {
// in a Vue.extend merge, both should be functions
if (!childVal) {
return parentVal
}
if (!parentVal) {
return childVal
}
// when parentVal & childVal are both present,
// we need to return a function that returns the
// merged result of both functions... no need to
// check if parentVal is a function here because
// it has to be a function to pass previous merges.
return function mergedDataFn () {
return mergeData(
typeof childVal === 'function' ? childVal.call(this, this) : childVal,
typeof parentVal === 'function' ? parentVal.call(this, this) : parentVal
)
}
} else {
return function mergedInstanceDataFn () {
// instance merge
const instanceData = typeof childVal === 'function'
? childVal.call(vm, vm)
: childVal
const defaultData = typeof parentVal === 'function'
? parentVal.call(vm, vm)
: parentVal
if (instanceData) {
return mergeData(instanceData, defaultData)
} else {
return defaultData
}
}
}
}
在mergeDataOrFn方法中,我们可以发现它根据vm进行了区分,但这两块的合并思路是一致的:如果parentVal和childVal是函数类型,则分别调用这个函数,然后合并它们返回的对象,这种情况主要针对data合并。对于provide而言,它不需要是function类型,因此直接使用mergeData来合并即可。我们再回过头来看,为什么要区分vm,这是因为要处理兼容provide的情况,当传递provide的时候,因为这个属性是在父级定义的,因此this属于父级而不是当前组件vm。
最后来看一下mergeData方法的实现代码:
function mergeData (to: Object, from: ?Object): Object {
if (!from) return to
let key, toVal, fromVal
const keys = hasSymbol
? Reflect.ownKeys(from)
: Object.keys(from)
for (let i = 0; i < keys.length; i++) {
key = keys[i]
// in case the object is already observed...
if (key === '__ob__') continue
toVal = to[key]
fromVal = from[key]
if (!hasOwn(to, key)) {
set(to, key, fromVal)
} else if (
toVal !== fromVal &&
isPlainObject(toVal) &&
isPlainObject(fromVal)
) {
mergeData(toVal, fromVal)
}
}
return to
}
mergeData和前面提到extend方法所做的事情几乎是一样的,只不过由于data中所有的属性(包括嵌套对象的属性),我们需要使用set处理成响应式的。set方法就是Vue.set或this.$set方法的本体,它定义在src/core/observer/index.js文件中,我们之前在响应式章节提到过。
components、directives和filters合并
对于components、directives以及filters的合并是同一个mergeAssets方法,strats策略对象上关于这几种属性定义代码如下:
const ASSET_TYPES = [
'component',
'directive',
'filter'
]
ASSET_TYPES.forEach(function (type) {
strats[type + 's'] = mergeAssets
})
接下来,我们看一下mergeAssets具体定义:
function mergeAssets (
parentVal: ?Object,1
childVal: ?Object,
vm?: Component,
key: string
): Object {
const res = Object.create(parentVal || null)
if (childVal) {
process.env.NODE_ENV !== 'production' && assertObjectType(key, childVal, vm)
return extend(res, childVal)
} else {
return res
}
}
mergeAssets方法的代码不是很多,逻辑也很清晰,首先以parentVal创建一个res原型,如果childVal没有,则直接返回这个res原型;如果有,则使用extend把childVal上的所有属性扩展到res原型上。有一点需要注意,extend不是我们之前提到的Vue.extend或者this.$extend,它是定义在src/shared/utils.js文件中的一个方法,其代码如下:
export function extend (to: Object, _from: ?Object): Object {
for (const key in _from) {
to[key] = _from[key]
}
return to
}
我们撰写一个简单的例子来说明一下extend方法的用法:
const obj1 = {
name: 'AAA',
age: 23
}
const obj2 = {
sex: '男',
address: '广州'
}
const extendObj = extend(obj1, obj2)
console.log(extendObj) // { name: 'AAA', age: 23, sex: '男', address: '广州' }
在介绍完extend方法后,我们回到mergeAssets方法,我们同样举例说明:
// main.js
import Vue from 'vue'
import HelloWorld from '@/components/HelloWorld.vue'
Vue.component('HelloWorld', HelloWorld)
// App.vue
import Test from '@/components/test.vue'
export default {
name: 'App',
components: {
Test
}
}
在main.js入口文件中,我们全局定义了一个HelloWorld 全局组件,然后在App.vue中又定义了一个Test局部组件,当代码运行到mergeAssets的时候,部分参数如下:
const parentVal = {
HelloWorld: function VueComponent () {...},
KeepAlive: {...},
Transition: {...},
TransitionGroup: {...}
}
const childVal = {
Test: function VueComponent () {...}
}
因为parentVal和childVal都有值,因此会调用extend方法,调用前和调用后的res如下所示:
// 调用前
const res = {
__proto__: {
HelloWorld: function VueComponent () {...},
KeepAlive: {...},
Transition: {...},
TransitionGroup: {...}
}
}
// extend调用后
const res = {
Test: function VueComponent () {...},
__proto__: {
HelloWorld: function VueComponent () {...},
KeepAlive: {...},
Transition: {...},
TransitionGroup: {...}
}
}
假如我们在App.vue组件中都使用了这两个组件,如下:
<template>
<div>
<test />
<hello-world />
</div>
</template>
在App.vue组件渲染的过程中,当编译到<test />时,会在其components选项中查找组件,马上在自身属性上找到了test.vue。然后当编译到<hello-world />的时候,在自身对象上找不到这个属性,根据原型链的规则会在原型上去找,然后在__proto__上找到了HelloWorld.vue组件,两个组件得以顺利的被解析和渲染。
对于另外两个选项directives和filters,它们跟components是一样的处理逻辑。
watch合并
对于watch选项而言,它使用的合并方法是单独定义的,其在strats策略对象上的属性定义如下:
strats.watch = function (
parentVal: ?Object,
childVal: ?Object,
vm?: Component,
key: string
): ?Object {
// work around Firefox's Object.prototype.watch...
if (parentVal === nativeWatch) parentVal = undefined
if (childVal === nativeWatch) childVal = undefined
/* istanbul ignore if */
if (!childVal) return Object.create(parentVal || null)
if (process.env.NODE_ENV !== 'production') {
assertObjectType(key, childVal, vm)
}
if (!parentVal) return childVal
const ret = {}
extend(ret, parentVal)
for (const key in childVal) {
let parent = ret[key]
const child = childVal[key]
if (parent && !Array.isArray(parent)) {
parent = [parent]
}
ret[key] = parent
? parent.concat(child)
: Array.isArray(child) ? child : [child]
}
return ret
}
我们可以看到watch配置的合并与hooks合并的思路几乎差不多,只是多了一些微小的差异,当childVal没有时,直接返回按照parentVal创建的原型,类似的当parentVal没有是,直接返回childVal,注意这里因为是自身的配置,因此不需要像parentVal那样创建并一个原型。当parentVal和childVal都存在时,首先把parentVal上的属性全部扩展到ret对象上,然后遍历childVal的属性键。在遍历的过程中如果parent值不为数组形式,则手动处理成数组形式,然后把child使用数组concat方法添加到数组的末尾。以上代码分析,可以使用下面的示例来说明:
// 情况一
const parentVal = {
msg: function () {
console.log('parent watch msg')
}
}
const childVal = undefined
const ret = {
__proto__: {
msg: function () {
console.log('parent watch msg')
}
}
}
// 情况二
const parentVal = undefined
const childVal = {
msg: function () {
console.log('child watch msg')
}
}
const ret = {
msg: function () {
console.log('child watch msg')
}
}
// 情况三
const parentVal = {
msg: function () {
console.log('parent watch msg')
}
}
const childVal = {
msg: function () {
console.log('child watch msg')
}
}
const ret = {
msg: [
function () {
console.log('parent watch msg')
},
function () {
console.log('child watch msg')
}
]
}
与hooks一样,如果在mixins里面也提供了与自身组件一样的watch,那么会优先执行mixins里面的watch,然后在执行自身组件中的watch。
props、methods、inject和computed合并
props、methods、inject和computed和之前我们提到的几种配置有点不一样,这几种配置有一个共同点:不允许存在相同的属性,例如我们在methods上提供的属性,不管来自于哪里,我们只需要把所有属性合并在一起即可。
接下来我们来看一下这几个属性在strats策略对象上的具体定义:
strats.props =
strats.methods =
strats.inject =
strats.computed = function (
parentVal: ?Object,
childVal: ?Object,
vm?: Component,
key: string
): ?Object {
if (childVal && process.env.NODE_ENV !== 'production') {
assertObjectType(key, childVal, vm)
}
if (!parentVal) return childVal
const ret = Object.create(null)
extend(ret, parentVal)
if (childVal) extend(ret, childVal)
return ret
}
我们可以看到,在其实现方法中代码并不是很复杂,仅仅使用到extend方法合并对象属性即可。当parentVal没有时,直接返回childVal,这里也不需要创建并返回一个原型,原因在上面提到过。如果parentVal有,则先创建一个原型,再使用extend把parentVal上的所有属性全部扩展到ret对象上。最后再判断childVal,如果有则再使用extend把childVal上的对象扩展到ret上,如果没有,则直接返回。以上代码分析,我们举例说明:
const parentVal = {
age: 23,
name: 'AAA'
}
const parentVal = {
address: '广州'
}
const ret = {
age: 23,
name: 'AAA',
address: '广州'
}
上一篇:Vue2.0源码分析:响应式原理(下)
下一篇:Vue2.0源码分析:组件化(下)
由于掘金文章字数限制,不得不拆分上、下两篇文章。