Virtual DOM
这个概念相信大部分人都不会陌生,它产生的前提是浏览器中的 DOM 是很“昂贵"的,为了更直观的感受,可以简单的把一个简单的 div 元素的属性都打印出来看看,注意真正的 DOM 元素是非常庞大的,因为浏览器的标准就把 DOM 设计的非常复杂。当频繁的去做 DOM 更新,会产生一定的性能问题。
而 Virtual DOM 就是用一个原生的 JS 对象去描述一个 DOM 节点,所以它比创建一个 DOM 的代价要小很多。在 Vue.js 中,Virtual DOM 是用 VNode 这么一个 Class 去描述,它是定义在 src/core/vdom/vnode.js
中的。
export default class VNode { tag: string | void; data: VNodeData | void; children: ?Array<VNode>; text: string | void; elm: Node | void; ns: string | void; context: Component | void; // rendered in this component's scope key: string | number | void; componentOptions: VNodeComponentOptions | void; componentInstance: Component | void; // component instance parent: VNode | void; // component placeholder node // strictly internal raw: boolean; // contains raw HTML? (server only) isStatic: boolean; // hoisted static node isRootInsert: boolean; // necessary for enter transition check isComment: boolean; // empty comment placeholder? isCloned: boolean; // is a cloned node? isOnce: boolean; // is a v-once node? asyncFactory: Function | void; // async component factory function asyncMeta: Object | void; isAsyncPlaceholder: boolean; ssrContext: Object | void; fnContext: Component | void; // real context vm for functional nodes fnOptions: ?ComponentOptions; // for SSR caching fnScopeId: ?string; // functional scope id support constructor ( tag?: string, data?: VNodeData, children?: ?Array<VNode>, text?: string, elm?: Node, context?: Component, componentOptions?: VNodeComponentOptions, asyncFactory?: Function ) { this.tag = tag this.data = data this.children = children this.text = text this.elm = elm this.ns = undefined this.context = context this.fnContext = undefined this.fnOptions = undefined this.fnScopeId = undefined this.key = data && data.key this.componentOptions = componentOptions this.componentInstance = undefined this.parent = undefined this.raw = false this.isStatic = false this.isRootInsert = true this.isComment = false this.isCloned = false this.isOnce = false this.asyncFactory = asyncFactory this.asyncMeta = undefined this.isAsyncPlaceholder = false } // DEPRECATED: alias for componentInstance for backwards compat. /* istanbul ignore next */ get child (): Component | void { return this.componentInstance } }
可以看到 Vue.js 中的 Virtual DOM 的定义还是略微复杂一些的,因为它这里包含了很多 Vue.js 的特性。这里千万不要被这些茫茫多的属性吓到,实际上 Vue.js 中 Virtual DOM 是借鉴了一个开源库 snabbdom 的实现,然后加入了一些 Vue.js 特色的东西。如果想深入了解 Vue.js 的 Virtual DOM 前不妨先阅读这个库的源码,因为它更加简单和纯粹。
总结
其实 VNode 是对真实 DOM 的一种抽象描述,它的核心定义无非就几个关键属性,标签名、数据、子节点、键值等,其它属性都是都是用来扩展 VNode 的灵活性以及实现一些特殊 feature 的。由于 VNode 只是用来映射到真实 DOM 的渲染,不需要包含操作 DOM 的方法,因此它是非常轻量和简单的。
Virtual DOM 除了它的数据结构的定义,映射到真实的 DOM 实际上要经历 VNode 的 create、diff、patch 等过程。那么在 Vue.js 中,VNode 的 create 是通过之前提到的 createElement 方法创建的。
Vue.js 利用 createElement 方法创建 VNode
,它定义在 src/core/vdom/create-elemenet.js
中:
// wrapper function for providing a more flexible interface // without getting yelled at by flow 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 方法实际上是对 _createElement 方法的封装,它允许传入的参数更加灵活,在处理这些参数后,调用真正创建 VNode 的函数 _createElement:
export function _createElement ( context: Component, tag?: string | Class<Component> | Function | Object, data?: VNodeData, children?: any, normalizationType?: number ): VNode | Array<VNode> { if (isDef(data) && isDef((data: any).__ob__)) { process.env.NODE_ENV !== 'production' && warn( `Avoid using observed data object as vnode data: ${JSON.stringify(data)}\n` + 'Always create fresh vnode data objects in each render!', context ) return createEmptyVNode() } // object syntax in v-bind if (isDef(data) && isDef(data.is)) { tag = data.is } if (!tag) { // in case of component :is set to falsy value return createEmptyVNode() } // warn against non-primitive key if (process.env.NODE_ENV !== 'production' && isDef(data) && isDef(data.key) && !isPrimitive(data.key) ) { if (!__WEEX__ || !('@binding' in data.key)) { warn( 'Avoid using non-primitive value as key, ' + 'use string/number value instead.', context ) } } // support single function children as default scoped slot if (Array.isArray(children) && typeof children[0] === 'function' ) { data = data || {} data.scopedSlots = { default: children[0] } children.length = 0 } 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 vnode = new VNode( config.parsePlatformTagName(tag), data, children, undefined, undefined, context ) } else if (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) } if (Array.isArray(vnode)) { return vnode } else if (isDef(vnode)) { if (isDef(ns)) applyNS(vnode, ns) if (isDef(data)) registerDeepBindings(data) return vnode } else { return createEmptyVNode() } }
_createElement 方法
有 5 个参数:
context
表示 VNode 的上下文环境,它是 Component 类型;tag
表示标签,它可以是一个字符串,也可以是一个 Component;children
表示当前 VNode 的子节点,它是任意类型的,它接下来需要被规范为标准的 VNode 数组;normalizationType
表示子节点规范的类型,类型不同规范的方法也就不一样,它主要是参考 render 函数是编译生成的还是用户手写的。createElement
函数的流程略微有点多,我们接下来主要分析 2 个重点的流程 —— children 的规范化以及 VNode 的创建。children 的规范化
由于 Virtual DOM 实际上是一个树状结构,每一个 VNode 可能会有若干个子节点,这些子节点应该也是 VNode 的类型。_createElement 接收的第 4 个参数 children 是任意类型的,因此需要把它们规范成 VNode 类型。
这里根据 normalizationType
的不同,调用了 normalizeChildren(children) 和 simpleNormalizeChildren(children) 方法
,它们的定义都在 src/core/vdom/helpers/normalzie-children.js
中:
// The template compiler attempts to minimize the need for normalization by // statically analyzing the template at compile time. // // For plain HTML markup, normalization can be completely skipped because the // generated render function is guaranteed to return Array<VNode>. There are // two cases where extra normalization is needed: // 1. When the children contains components - because a functional component // may return an Array instead of a single root. In this case, just a simple // normalization is needed - if any child is an Array, we flatten the whole // thing with Array.prototype.concat. It is guaranteed to be only 1-level deep // because functional components already normalize their own children. 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 } // 2. When the children contains constructs that always generated nested Arrays, // e.g. <template>, <slot>, v-for, or when the children is provided by user // with hand-written render functions / JSX. In such cases a full normalization // is needed to cater to all possible types of children values. export function normalizeChildren (children: any): ?Array<VNode> { return isPrimitive(children) ? [createTextVNode(children)] : Array.isArray(children) ? normalizeArrayChildren(children) : undefined }
simpleNormalizeChildren
方法调用场景是 render 函数当函数是编译生成的。理论上编译生成的 children 都已经是 VNode 类型的,但这里有一个例外,就是 functional component 函数式组件返回的是一个数组而不是一个根节点,所以会通过 Array.prototype.concat 方法把整个 children 数组打平,让它的深度只有一层。
normalizeChildren
方法的调用场景有 2 种:
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
接收 2 个参数,children 表示要规范的子节点,nestedIndex 表示嵌套的索引,因为单个 child 可能是一个数组类型。 normalizeArrayChildren 主要的逻辑就是遍历 children,获得单个节点 c,然后对 c 的类型判断,如果是一个数组类型,则递归调用 normalizeArrayChildren; 如果是基础类型,则通过 createTextVNode 方法转换成 VNode 类型;否则就已经是 VNode 类型了,如果 children 是一个列表并且列表还存在嵌套的情况,则根据 nestedIndex 去更新它的 key。这里需要注意一点,在遍历的过程中,对这 3 种情况都做了如下处理:如果存在两个连续的 text节点,会把它们合并成一个 text 节点。
经过对 children 的规范化,children 变成了一个类型为 VNode 的 Array。
VNode 的创建
回到 createElement 函数,规范化 children 后,接下来会去创建一个 VNode 的实例:
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 vnode = new VNode( config.parsePlatformTagName(tag), data, children, undefined, undefined, context ) } else if (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) }
这里先对 tag 做判断,如果是 string 类型,则接着判断如果是内置的一些节点,则直接创建一个普通 VNode,如果是为已注册的组件名,则通过 createComponent 创建一个组件类型的 VNode,否则创建一个未知的标签的 VNode。 如果是 tag 一个 Component 类型,则直接调用 createComponent 创建一个组件类型的 VNode 节点。对于 createComponent 创建组件类型的 VNode 的过程,我们之后会去介绍,本质上它还是返回了一个 VNode。
总结
那么至此,大致了解了 createElement 创建 VNode 的过程,每个 VNode 有 children,children 每个元素也是一个 VNode,这样就形成了一个 VNode Tree,它很好的描述了我们的 DOM Tree。
回到 mountComponent 函数的过程,就已经知道 vm._render 是如何创建了一个 VNode,接下来就是要把这个 VNode 渲染成一个真实的 DOM 并渲染出来,这个过程是通过 vm._update 完成的,接下来分析一下这个过程。
Vue 的 _update 是实例的一个私有方法,它被调用的时机有 2 个,一个是首次渲染,一个是数据更新的时候;由于只分析首次渲染部分,数据更新部分会在之后分析响应式原理的时候涉及。_update 方法的作用是把 VNode 渲染成真实的 DOM,它的定义在 src/core/instance/lifecycle.js
中:
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) { const vm: Component = this const prevEl = vm.$el const prevVnode = vm._vnode const prevActiveInstance = activeInstance activeInstance = vm vm._vnode = vnode // Vue.prototype.__patch__ is injected in entry points // based on the rendering backend used. if (!prevVnode) { // initial render vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */) } else { // updates vm.$el = vm.__patch__(prevVnode, vnode) } activeInstance = prevActiveInstance // update __vue__ reference if (prevEl) { prevEl.__vue__ = null } if (vm.$el) { vm.$el.__vue__ = vm } // if parent is an HOC, update its $el as well if (vm.$vnode && vm.$parent && vm.$vnode === vm.$parent._vnode) { vm.$parent.$el = vm.$el } // updated hook is called by the scheduler to ensure that children are // updated in a parent's updated hook. }
_update 的核心就是调用 vm.__patch__
方法,这个方法实际上在不同的平台,比如 web 和 weex 上的定义是不一样的,因此在 web 平台中它的定义在 src/platforms/web/runtime/index.js
中:
Vue.prototype.__patch__ = inBrowser ? patch : noop
可以看到,甚至在 web 平台上,是否是服务端渲染也会对这个方法产生影响。因为在服务端渲染中,没有真实的浏览器 DOM 环境,所以不需要把 VNode 最终转换成 DOM,因此是一个空函数,而在浏览器端渲染中,它指向了 patch 方法,它的定义在 src/platforms/web/runtime/patch.js
中:
import * as nodeOps from 'web/runtime/node-ops' import { createPatchFunction } from 'core/vdom/patch' import baseModules from 'core/vdom/modules/index' import platformModules from 'web/runtime/modules/index' // the directive module should be applied last, after all // built-in modules have been applied. const modules = platformModules.concat(baseModules) export const patch: Function = createPatchFunction({ nodeOps, modules })
该方法的定义是调用 createPatchFunction 方法的返回值,这里传入了一个对象,包含 nodeOps 参数和 modules 参数。其中,nodeOps 封装了一系列 DOM 操作的方法,modules 定义了一些模块的钩子函数的实现,我们这里先不详细介绍,来看一下 createPatchFunction 的实现,它定义在 src/core/vdom/patch.js
中:
const hooks = ['create', 'activate', 'update', 'remove', 'destroy'] export function createPatchFunction (backend) { let i, j const cbs = {} const { modules, nodeOps } = backend for (i = 0; i < hooks.length; ++i) { cbs[hooks[i]] = [] for (j = 0; j < modules.length; ++j) { if (isDef(modules[j][hooks[i]])) { cbs[hooks[i]].push(modules[j][hooks[i]]) } } } // ... return function patch (oldVnode, vnode, hydrating, removeOnly) { if (isUndef(vnode)) { if (isDef(oldVnode)) invokeDestroyHook(oldVnode) return } let isInitialPatch = false const insertedVnodeQueue = [] if (isUndef(oldVnode)) { // empty mount (likely as component), create new root element isInitialPatch = true createElm(vnode, insertedVnodeQueue) } else { const isRealElement = isDef(oldVnode.nodeType) if (!isRealElement && sameVnode(oldVnode, vnode)) { // patch existing root node patchVnode(oldVnode, vnode, insertedVnodeQueue, removeOnly) } else { if (isRealElement) { // mounting to a real element // check if this is server-rendered content and if we can perform // a successful hydration. if (oldVnode.nodeType === 1 && oldVnode.hasAttribute(SSR_ATTR)) { oldVnode.removeAttribute(SSR_ATTR) hydrating = true } if (isTrue(hydrating)) { if (hydrate(oldVnode, vnode, insertedVnodeQueue)) { invokeInsertHook(vnode, insertedVnodeQueue, true) return oldVnode } else if (process.env.NODE_ENV !== 'production') { warn( 'The client-side rendered virtual DOM tree is not matching ' + 'server-rendered content. This is likely caused by incorrect ' + 'HTML markup, for example nesting block-level elements inside ' + '<p>, or missing <tbody>. Bailing hydration and performing ' + 'full client-side render.' ) } } // either not server-rendered, or hydration failed. // create an empty node and replace it oldVnode = emptyNodeAt(oldVnode) } // replacing existing element const oldElm = oldVnode.elm const parentElm = nodeOps.parentNode(oldElm) // create new node createElm( vnode, insertedVnodeQueue, // extremely rare edge case: do not insert if old element is in a // leaving transition. Only happens when combining transition + // keep-alive + HOCs. (#4590) oldElm._leaveCb ? null : parentElm, nodeOps.nextSibling(oldElm) ) // update parent placeholder node element, recursively if (isDef(vnode.parent)) { let ancestor = vnode.parent const patchable = isPatchable(vnode) while (ancestor) { for (let i = 0; i < cbs.destroy.length; ++i) { cbs.destroy[i](ancestor) } ancestor.elm = vnode.elm if (patchable) { for (let i = 0; i < cbs.create.length; ++i) { cbs.create[i](emptyNode, ancestor) } // #6513 // invoke insert hooks that may have been merged by create hooks. // e.g. for directives that uses the "inserted" hook. const insert = ancestor.data.hook.insert if (insert.merged) { // start at index 1 to avoid re-invoking component mounted hook for (let i = 1; i < insert.fns.length; i++) { insert.fns[i]() } } } else { registerRef(ancestor) } ancestor = ancestor.parent } } // destroy old node if (isDef(parentElm)) { removeVnodes(parentElm, [oldVnode], 0, 0) } else if (isDef(oldVnode.tag)) { invokeDestroyHook(oldVnode) } } } invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch) return vnode.elm } }
createPatchFunction 内部定义了一系列的辅助方法,最终返回了一个 patch 方法,这个方法就赋值给了 vm._update 函数里调用的 vm.__patch__
。
在介绍 patch 的方法实现之前,可以思考一下为何 Vue.js 源码绕了这么一大圈,把相关代码分散到各个目录。因为前面介绍过,patch 是平台相关的,在 Web 和 Weex 环境,它们把虚拟 DOM 映射到 “平台 DOM” 的方法是不同的,并且对 “DOM” 包括的属性模块创建和更新也不尽相同。因此每个平台都有各自的 nodeOps 和 modules,它们的代码需要托管在 src/platforms 这个大目录下。
而不同平台的 patch 的主要逻辑部分是相同的,所以这部分公共的部分托管在 core 这个大目录下。差异化部分只需要通过参数来区别,这里用到了一个函数柯里化的技巧,通过 createPatchFunction 把差异化参数提前固化,这样不用每次调用 patch 的时候都传递 nodeOps 和 modules 了,这种编程技巧也非常值得学习。
在这里,nodeOps 表示对 “平台 DOM” 的一些操作方法,modules 表示平台的一些模块,它们会在整个 patch 过程的不同阶段执行相应的钩子函数。这些代码的具体实现会在之后的章节介绍。
回到 patch 方法本身,它接收 4个参数,oldVnode 表示旧的 VNode 节点,它也可以不存在或者是一个 DOM 对象;vnode 表示执行 _render 后返回的 VNode 的节点;hydrating 表示是否是服务端渲染;removeOnly 是给 transition-group 用的,之后会介绍。
patch 的逻辑看上去相对复杂,因为它有着非常多的分支逻辑,为了方便理解,我们并不会在这里介绍所有的逻辑,仅会针对我们之前的例子分析它的执行逻辑。之后我们对其它场景做源码分析的时候会再次回顾 patch 方法。
先来回顾例子:
var app = new Vue({ el: '#app', render: function (createElement) { return createElement('div', { attrs: { id: 'app' }, }, this.message) }, data: { message: 'Hello Vue!' } })
然后在 vm._update 的方法里是这么调用 patch 方法的:
// initial render vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
结合例子,当是首次渲染,所以在执行 patch 函数的时候,传入的 vm.$el
对应的是例子中 id 为 app 的 DOM 对象,这个也就是在 index.html 模板中写的 <div id="app">
, vm.$el
的赋值是在之前 mountComponent 函数做的,vnode 对应的是调用 render 函数的返回值,hydrating 在非服务端渲染情况下为 false,removeOnly 为 false。
确定了这些入参后,回到 patch 函数的执行过程,看几个关键步骤。
const isRealElement = isDef(oldVnode.nodeType) if (!isRealElement && sameVnode(oldVnode, vnode)) { // patch existing root node patchVnode(oldVnode, vnode, insertedVnodeQueue, removeOnly) } else { if (isRealElement) { // mounting to a real element // check if this is server-rendered content and if we can perform // a successful hydration. if (oldVnode.nodeType === 1 && oldVnode.hasAttribute(SSR_ATTR)) { oldVnode.removeAttribute(SSR_ATTR) hydrating = true } if (isTrue(hydrating)) { if (hydrate(oldVnode, vnode, insertedVnodeQueue)) { invokeInsertHook(vnode, insertedVnodeQueue, true) return oldVnode } else if (process.env.NODE_ENV !== 'production') { warn( 'The client-side rendered virtual DOM tree is not matching ' + 'server-rendered content. This is likely caused by incorrect ' + 'HTML markup, for example nesting block-level elements inside ' + '<p>, or missing <tbody>. Bailing hydration and performing ' + 'full client-side render.' ) } } // either not server-rendered, or hydration failed. // create an empty node and replace it oldVnode = emptyNodeAt(oldVnode) } // replacing existing element const oldElm = oldVnode.elm const parentElm = nodeOps.parentNode(oldElm) // create new node createElm( vnode, insertedVnodeQueue, // extremely rare edge case: do not insert if old element is in a // leaving transition. Only happens when combining transition + // keep-alive + HOCs. (#4590) oldElm._leaveCb ? null : parentElm, nodeOps.nextSibling(oldElm) ) }
由于传入的 oldVnode 实际上是一个 DOM container,所以 isRealElement 为 true,接下来又通过 emptyNodeAt 方法把 oldVnode 转换成 VNode 对象,然后再调用 createElm 方法,这个方法在这里非常重要,来看一下它的实现:
function createElm ( vnode, insertedVnodeQueue, parentElm, refElm, nested, ownerArray, index ) { if (isDef(vnode.elm) && isDef(ownerArray)) { // This vnode was used in a previous render! // now it's used as a new node, overwriting its elm would cause // potential patch errors down the road when it's used as an insertion // reference node. Instead, we clone the node on-demand before creating // associated DOM element for it. vnode = ownerArray[index] = cloneVNode(vnode) } vnode.isRootInsert = !nested // for transition enter check if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) { return } const data = vnode.data const children = vnode.children const tag = vnode.tag if (isDef(tag)) { if (process.env.NODE_ENV !== 'production') { if (data && data.pre) { creatingElmInVPre++ } if (isUnknownElement(vnode, creatingElmInVPre)) { warn( 'Unknown custom element: <' + tag + '> - did you ' + 'register the component correctly? For recursive components, ' + 'make sure to provide the "name" option.', vnode.context ) } } vnode.elm = vnode.ns ? nodeOps.createElementNS(vnode.ns, tag) : nodeOps.createElement(tag, vnode) setScope(vnode) /* istanbul ignore if */ if (__WEEX__) { // ... } else { createChildren(vnode, children, insertedVnodeQueue) if (isDef(data)) { invokeCreateHooks(vnode, insertedVnodeQueue) } insert(parentElm, vnode.elm, refElm) } if (process.env.NODE_ENV !== 'production' && data && data.pre) { creatingElmInVPre-- } } else if (isTrue(vnode.isComment)) { vnode.elm = nodeOps.createComment(vnode.text) insert(parentElm, vnode.elm, refElm) } else { vnode.elm = nodeOps.createTextNode(vnode.text) insert(parentElm, vnode.elm, refElm) } }
createElm 的作用是通过虚拟节点创建真实的 DOM 并插入到它的父节点中。 来看一下它的一些关键逻辑,createComponent 方法目的是尝试创建子组件,这个逻辑在之后组件的章节会详细介绍,在当前这个 case 下它的返回值为 false;接下来判断 vnode 是否包含 tag,如果包含,先简单对 tag 的合法性在非生产环境下做校验,看是否是一个合法标签;然后再去调用平台 DOM 的操作去创建一个占位符元素。
vnode.elm = vnode.ns ? nodeOps.createElementNS(vnode.ns, tag) : nodeOps.createElement(tag, vnode) // 接下来调用 createChildren 方法去创建子元素: createChildren(vnode, children, insertedVnodeQueue) function createChildren (vnode, children, insertedVnodeQueue) { if (Array.isArray(children)) { if (process.env.NODE_ENV !== 'production') { checkDuplicateKeys(children) } for (let i = 0; i < children.length; ++i) { createElm(children[i], insertedVnodeQueue, vnode.elm, null, true, children, i) } } else if (isPrimitive(vnode.text)) { nodeOps.appendChild(vnode.elm, nodeOps.createTextNode(String(vnode.text))) } }
createChildren 的逻辑很简单,实际上是遍历子虚拟节点,递归调用 createElm,这是一种常用的深度优先的遍历算法,这里要注意的一点是在遍历过程中会把 vnode.elm 作为父容器的 DOM 节点占位符传入。
接着再调用 invokeCreateHooks 方法执行所有的 create 的钩子并把 vnode push 到 insertedVnodeQueue 中。
if (isDef(data)) { invokeCreateHooks(vnode, insertedVnodeQueue) } function invokeCreateHooks (vnode, insertedVnodeQueue) { for (let i = 0; i < cbs.create.length; ++i) { cbs.create[i](emptyNode, vnode) } i = vnode.data.hook // Reuse variable if (isDef(i)) { if (isDef(i.create)) i.create(emptyNode, vnode) if (isDef(i.insert)) insertedVnodeQueue.push(vnode) } }
最后调用 insert 方法把 DOM 插入到父节点中,因为是递归调用,子元素会优先调用 insert,所以整个 vnode 树节点的插入顺序是先子后父。来看一下 insert方法,它的定义在 src/core/vdom/patch.js
上。
insert(parentElm, vnode.elm, refElm) function insert (parent, elm, ref) { if (isDef(parent)) { if (isDef(ref)) { if (ref.parentNode === parent) { nodeOps.insertBefore(parent, elm, ref) } } else { nodeOps.appendChild(parent, elm) } } } insert 逻辑很简单,调用一些 nodeOps 把子节点插入到父节点中,这些辅助方法定义在 src/platforms/web/runtime/node-ops.js 中: export function insertBefore (parentNode: Node, newNode: Node, referenceNode: Node) { parentNode.insertBefore(newNode, referenceNode) } export function appendChild (node: Node, child: Node) { node.appendChild(child) }
其实就是调用原生 DOM 的 API 进行 DOM 操作,看到这里,很多同学恍然大悟,原来 Vue 是这样动态创建的 DOM。
在 createElm 过程中,如果 vnode 节点如果不包含 tag,则它有可能是一个注释或者纯文本节点,可以直接插入到父元素中。在我们这个例子中,最内层就是一个文本 vnode,它的 text 值取的就是之前的 this.message 的值 Hello Vue!。
再回到 patch 方法,首次渲染我们调用了 createElm 方法,这里传入的 parentElm 是 oldVnode.elm 的父元素, 在我们的例子是 id 为 #app div 的父元素,也就是 Body;实际上整个过程就是递归创建了一个完整的 DOM 树并插入到 Body 上。
最后,我们根据之前递归 createElm 生成的 vnode 插入顺序队列,执行相关的 insert 钩子函数,这部分内容我们之后会详细介绍。
总结
那么从主线上把模板和数据如何渲染成最终的 DOM 的过程分析完毕。
这里只是分析了最简单和最基础的场景,在实际项目中是把页面拆成很多组件的,Vue 另一个核心思想就是组件化。
Vue.js 另一个核心思想是组件化
。 所谓组件化,就是把页面拆分成多个组件 (component),每个组件依赖的 CSS、JavaScript、模板、图片等资源放在一起开发和维护。组件是资源独立的,组件在系统内部可复用,组件和组件之间可以嵌套。
在用 Vue.js 开发实际项目的时候,就是像搭积木一样,编写一堆组件拼装生成页面。在 Vue.js 的官网中,也是花了大篇幅来介绍什么是组件,如何编写组件以及组件拥有的属性和特性。
那么将从源码的角度来分析 Vue 的组件内部是如何工作的,只有了解了内部的工作原理,才能让我们使用它的时候更加得心应手。
接下来会用 Vue-cli 初始化的代码为例,来分析一下 Vue 组件初始化的一个过程。
import Vue from 'vue' import App from './App.vue' var app = new Vue({ el: '#app', // 这里的 h 是 createElement 方法 render: h => h(App) })
这段代码相信很多人都很熟悉,它和之前相同的点也是通过 render 函数去渲染的,不同的这次通过 createElement 传的参数是一个组件而不是一个原生的标签。
在分析 createElement 的实现的时候,它最终会调用 _createElement 方法,其中有一段逻辑是对参数 tag 的判断,如果是一个普通的 html 标签,像上一章的例子那样是一个普通的 div,则会实例化一个普通 VNode 节点,否则通过 createComponent 方法创建一个组件 VNode。
if (typeof tag === 'string') { let Ctor ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag) if (config.isReservedTag(tag)) { // platform built-in elements vnode = new VNode( config.parsePlatformTagName(tag), data, children, undefined, undefined, context ) } else if (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) }
在传入的是一个 App 对象,它本质上是一个 Component 类型,那么它会走到上述代码的 else 逻辑,直接通过 createComponent 方法来创建 vnode。所以接下来我们来看一下 createComponent 方法的实现,它定义在 src/core/vdom/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 { if (isUndef(Ctor)) { return } const baseCtor = context.$options._base // plain options object: turn it into a constructor if (isObject(Ctor)) { Ctor = baseCtor.extend(Ctor) } // if at this stage it's not a constructor or an async component factory, // reject. if (typeof Ctor !== 'function') { if (process.env.NODE_ENV !== 'production') { warn(`Invalid Component definition: ${String(Ctor)}`, context) } return } // async component let asyncFactory if (isUndef(Ctor.cid)) { asyncFactory = Ctor Ctor = resolveAsyncComponent(asyncFactory, baseCtor, context) if (Ctor === undefined) { // return a placeholder node for async component, which is rendered // as a comment node but preserves all the raw information for the node. // the information will be used for async server-rendering and hydration. return createAsyncPlaceholder( asyncFactory, data, context, children, tag ) } } data = data || {} // resolve constructor options in case global mixins are applied after // component constructor creation resolveConstructorOptions(Ctor) // transform component v-model data into props & events if (isDef(data.model)) { transformModel(Ctor.options, data) } // extract props const propsData = extractPropsFromVNodeData(data, Ctor, tag) // functional component if (isTrue(Ctor.options.functional)) { return createFunctionalComponent(Ctor, propsData, data, context, children) } // extract listeners, since these needs to be treated as // child component listeners instead of DOM listeners const listeners = data.on // replace with listeners with .native modifier // so it gets processed during parent component patch. data.on = data.nativeOn if (isTrue(Ctor.options.abstract)) { // abstract components do not keep anything // other than props & listeners & slot // work around flow const slot = data.slot data = {} if (slot) { data.slot = slot } } // 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 ) // Weex specific: invoke recycle-list optimized @render function for // extracting cell-slot template. // https://github.com/Hanks10100/weex-native-directive/tree/master/component /* istanbul ignore if */ if (__WEEX__ && isRecyclableComponent(vnode)) { return renderRecyclableComponentTemplate(vnode) } return vnode }
可以看到,createComponent 的逻辑也会有一些复杂,但是分析源码比较推荐的是只分析核心流程,分支流程可以之后针对性的看,所以这里针对组件渲染这个 case 主要就 3 个关键步骤:
构造子类构造函数,安装组件钩子函数和实例化 vnode。
构造子类构造函数
const baseCtor = context.$options._base // plain options object: turn it into a constructor if (isObject(Ctor)) { Ctor = baseCtor.extend(Ctor) }
在编写一个组件的时候,通常都是创建一个普通对象,还是以 App.vue 为例,代码如下:
import HelloWorld from './components/HelloWorld' export default { name: 'app', components: { HelloWorld } }
这里 export 的是一个对象,所以 createComponent 里的代码逻辑会执行到 baseCtor.extend(Ctor),在这里 baseCtor 实际上就是 Vue,这个的定义是在最开始初始化 Vue 的阶段,在 src/core/global-api/index.js
中的 initGlobalAPI 函数有这么一段逻辑:
// this is used to identify the "base" constructor to extend all plain-object // components with in Weex's multi-instance scenarios. Vue.options._base = Vue
细心的会发现,这里定义的是 Vue.option,而createComponent 取的是 context.$options,实际上在src/core/instance/init.js
里 Vue 原型上的 _init 函数中有这么一段逻辑:
vm.$options = mergeOptions( resolveConstructorOptions(vm.constructor), options || {}, vm )
这样就把 Vue 上的一些 option 扩展到了 vm.
o
p
t
i
o
n
上
,
所
以
我
们
也
就
能
通
过
v
m
.
option 上,所以我们也就能通过 vm.
option上,所以我们也就能通过vm.options._base 拿到 Vue 这个构造函数了。mergeOptions 的实现我们会在后续章节中具体分析,现在只需要理解它的功能是把 Vue 构造函数的 options 和用户传入的 options 做一层合并,到 vm.$options 上。
在了解了 baseCtor 指向了 Vue 之后,我们来看一下 Vue.extend 函数的定义,在 src/core/global-api/extend.js
中。
/** * Class inheritance */ 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 的作用就是构造一个 Vue 的子类,它使用一种非常经典的原型继承的方式把一个纯对象转换一个继承于 Vue 的构造器 Sub 并返回,然后对 Sub 这个对象本身扩展了一些属性,如扩展 options、添加全局 API 等;并且对配置中的 props 和 computed 做了初始化工作;最后对于这个 Sub 构造函数做了缓存,避免多次执行 Vue.extend 的时候对同一个子组件重复构造。
这样当我们去实例化 Sub 的时候,就会执行 this._init 逻辑再次走到了 Vue 实例的初始化逻辑,实例化子组件的逻辑在之后的章节会介绍。
const Sub = function VueComponent (options) { this._init(options) }
安装组件钩子函数
// install component management hooks onto the placeholder node installComponentHooks(data)
之前提到 Vue.js 使用的 Virtual DOM 参考的是开源库 snabbdom,它的一个特点是在 VNode 的 patch 流程中对外暴露了各种时机的钩子函数,方便我们做一些额外的事情,Vue.js 也是充分利用这一点,在初始化一个 Component 类型的 VNode 的过程中实现了几个钩子函数:
const componentVNodeHooks = { init (vnode: VNodeWithData, hydrating: boolean): ?boolean { if ( vnode.componentInstance && !vnode.componentInstance._isDestroyed && vnode.data.keepAlive ) { // kept-alive components, treat as a patch const mountedNode: any = vnode // work around flow componentVNodeHooks.prepatch(mountedNode, mountedNode) } else { const child = vnode.componentInstance = createComponentInstanceForVnode( vnode, activeInstance ) child.$mount(hydrating ? vnode.elm : undefined, hydrating) } }, prepatch (oldVnode: MountedComponentVNode, vnode: MountedComponentVNode) { const options = vnode.componentOptions const child = vnode.componentInstance = oldVnode.componentInstance updateChildComponent( child, options.propsData, // updated props options.listeners, // updated listeners vnode, // new parent vnode options.children // new children ) }, insert (vnode: MountedComponentVNode) { const { context, componentInstance } = vnode if (!componentInstance._isMounted) { componentInstance._isMounted = true callHook(componentInstance, 'mounted') } if (vnode.data.keepAlive) { if (context._isMounted) { // vue-router#1212 // During updates, a kept-alive component's child components may // change, so directly walking the tree here may call activated hooks // on incorrect children. Instead we push them into a queue which will // be processed after the whole patch process ended. queueActivatedComponent(componentInstance) } else { activateChildComponent(componentInstance, true /* direct */) } } }, destroy (vnode: MountedComponentVNode) { const { componentInstance } = vnode if (!componentInstance._isDestroyed) { if (!vnode.data.keepAlive) { componentInstance.$destroy() } else { deactivateChildComponent(componentInstance, true /* direct */) } } } } 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 } } } 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 }
整个 installComponentHooks 的过程就是把 componentVNodeHooks 的钩子函数合并到 data.hook 中,在 VNode 执行 patch 的过程中执行相关的钩子函数,具体的执行我们稍后在介绍 patch 过程中会详细介绍。这里要注意的是合并策略,在合并过程中,如果某个时机的钩子已经存在 data.hook 中,那么通过执行 mergeHook 函数做合并,这个逻辑很简单,就是在最终执行的时候,依次执行这两个钩子函数即可。
实例化 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
最后一步非常简单,通过 new VNode 实例化一个 vnode 并返回。需要注意的是和普通元素节点的 vnode 不同,组件的 vnode 是没有 children 的,这点很关键,在之后的 patch 过程中我们会再提。
总结
主要分析了 createComponent 的实现,了解到它在渲染一个组件的时候的 3 个关键逻辑:构造子类构造函数,安装组件钩子函数和实例化 vnode。createComponent 后返回的是组件 vnode,它也一样走到 vm._update 方法,进而执行了 patch 函数。
通过上面的分析知道,当通过 createComponent 创建了组件 VNode,接下来会走到 vm._update,执行 vm.__patch__
去把 VNode 转换成真正的 DOM 节点。这个过程在前一章已经分析过了,但是针对一个普通的 VNode 节点,接下来看看组件的 VNode 会有哪些不一样的地方。
patch 的过程会调用 createElm 创建元素节点,回顾一下 createElm 的实现,它的定义在 src/core/vdom/patch.js
中:
function createElm ( vnode, insertedVnodeQueue, parentElm, refElm, nested, ownerArray, index ) { // ... if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) { return } // ... }
createComponent
在删掉多余的代码,只保留关键的逻辑,这里会判断 createComponent(vnode, insertedVnodeQueue, parentElm, refElm) 的返回值,如果为 true 则直接结束,那么接下来看一下 createComponent 方法的实现:
function createComponent (vnode, insertedVnodeQueue, parentElm, refElm) { let i = vnode.data if (isDef(i)) { const isReactivated = isDef(vnode.componentInstance) && i.keepAlive if (isDef(i = i.hook) && isDef(i = i.init)) { i(vnode, false /* hydrating */) } // after calling the init hook, if the vnode is a child component // it should've created a child instance and mounted it. the child // component also has set the placeholder vnode's elm. // in that case we can just return the element and be done. if (isDef(vnode.componentInstance)) { initComponent(vnode, insertedVnodeQueue) insert(parentElm, vnode.elm, refElm) if (isTrue(isReactivated)) { reactivateComponent(vnode, insertedVnodeQueue, parentElm, refElm) } return true } } } createComponent 函数中,首先对 vnode.data 做了一些判断: let i = vnode.data if (isDef(i)) { // ... if (isDef(i = i.hook) && isDef(i = i.init)) { i(vnode, false /* hydrating */) // ... } // .. }
如果 vnode 是一个组件 VNode,那么条件会满足,并且得到 i 就是 init 钩子函数,回顾在创建组件 VNode 的时候合并钩子函数中就包含 init 钩子函数,定义在 src/core/vdom/create-component.js
中:
init (vnode: VNodeWithData, hydrating: boolean): ?boolean { if ( vnode.componentInstance && !vnode.componentInstance._isDestroyed && vnode.data.keepAlive ) { // kept-alive components, treat as a patch const mountedNode: any = vnode // work around flow componentVNodeHooks.prepatch(mountedNode, mountedNode) } else { const child = vnode.componentInstance = createComponentInstanceForVnode( vnode, activeInstance ) child.$mount(hydrating ? vnode.elm : undefined, hydrating) } },
init 钩子函数执行也很简单,先不考虑 keepAlive 的情况,它是通过 createComponentInstanceForVnode 创建一个 Vue 的实例,然后调用 $mount 方法
挂载子组件,
先来看一下 createComponentInstanceForVnode 的实现:
export function createComponentInstanceForVnode ( vnode: any, // we know it's MountedComponentVNode but flow doesn't parent: any, // activeInstance in lifecycle state ): Component { const options: InternalComponentOptions = { _isComponent: true, _parentVnode: vnode, parent } // check inline-template render functions const inlineTemplate = vnode.data.inlineTemplate if (isDef(inlineTemplate)) { options.render = inlineTemplate.render options.staticRenderFns = inlineTemplate.staticRenderFns } return new vnode.componentOptions.Ctor(options) }
createComponentInstanceForVnode 函数构造的一个内部组件的参数,然后执行 new vnode.componentOptions.Ctor(options)。这里的 vnode.componentOptions.Ctor 对应的就是子组件的构造函数,上一节分析了它实际上是继承于 Vue 的一个构造器 Sub,相当于 new Sub(options) 这里有几个关键参数要注意几个点,_isComponent 为 true 表示它是一个组件,parent 表示当前激活的组件实例(注意,这里比较有意思的是如何拿到组件实例,后面会介绍。
所以子组件的实例化实际上就是在这个时机执行的,并且它会执行实例的 _init 方法,这个过程有一些和之前不同的地方需要挑出来说,代码在src/core/instance/init.js
中:
Vue.prototype._init = function (options?: Object) { const vm: Component = this // merge options 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 ) } // ... if (vm.$options.el) { vm.$mount(vm.$options.el) } }
这里首先是合并 options 的过程有变化,_isComponent 为 true,所以走到了 initInternalComponent 过程,这个函数的实现也简单看一下:
export function initInternalComponent (vm: Component, options: InternalComponentOptions) { const opts = vm.$options = Object.create(vm.constructor.options) // doing this because it's faster than dynamic enumeration. const parentVnode = options._parentVnode opts.parent = options.parent opts._parentVnode = parentVnode const vnodeComponentOptions = parentVnode.componentOptions opts.propsData = vnodeComponentOptions.propsData opts._parentListeners = vnodeComponentOptions.listeners opts._renderChildren = vnodeComponentOptions.children opts._componentTag = vnodeComponentOptions.tag if (options.render) { opts.render = options.render opts.staticRenderFns = options.staticRenderFns } }
这个过程重点记住以下几个点即可:opts.parent = options.parent、opts._parentVnode = parentVnode
,它们是把之前通过 createComponentInstanceForVnode 函数传入的几个参数合并到内部的选项 $options 里了。
再来看一下 _init 函数最后执行的代码:
if (vm.$options.el) { vm.$mount(vm.$options.el) }
由于组件初始化的时候是不传 el 的,因此组件是自己接管了 $mount
的过程,这个过程的主要流程在上一章介绍过了,回到组件 init 的过程,componentVNodeHooks 的 init 钩子函数,在完成实例化的 _init 后,接着会执行 child.$mount(hydrating ? vnode.elm : undefined, hydrating)
。这里 hydrating 为 true 一般是服务端渲染的情况,这里只考虑客户端渲染,所以这里 $mount
相当于执行 child.$mount(undefined, false)
,它最终会调用 mountComponent 方法,进而执行 vm._render() 方法:
Vue.prototype._render = function (): VNode { const vm: Component = this const { render, _parentVnode } = vm.$options // set parent vnode. this allows render functions to have access // to the data on the placeholder node. vm.$vnode = _parentVnode // render self let vnode try { vnode = render.call(vm._renderProxy, vm.$createElement) } catch (e) { // ... } // set parent vnode.parent = _parentVnode return vnode }
只保留关键部分的代码,这里的 _parentVnode 就是当前组件的父 VNode,而 render 函数生成的 vnode 当前组件的渲染 vnode,vnode 的 parent 指向了 _parentVnode,也就是 vm.$vnode,它们是一种父子的关系。
在执行完 vm._render 生成 VNode 后,接下来就要执行 vm._update 去渲染 VNode 了。来看一下组件渲染的过程中有哪些需要注意的,vm._update 的定义在 src/core/instance/lifecycle.js
中:
export let activeInstance: any = null Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) { const vm: Component = this const prevEl = vm.$el const prevVnode = vm._vnode const prevActiveInstance = activeInstance activeInstance = vm vm._vnode = vnode // Vue.prototype.__patch__ is injected in entry points // based on the rendering backend used. if (!prevVnode) { // initial render vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */) } else { // updates vm.$el = vm.__patch__(prevVnode, vnode) } activeInstance = prevActiveInstance // update __vue__ reference if (prevEl) { prevEl.__vue__ = null } if (vm.$el) { vm.$el.__vue__ = vm } // if parent is an HOC, update its $el as well if (vm.$vnode && vm.$parent && vm.$vnode === vm.$parent._vnode) { vm.$parent.$el = vm.$el } // updated hook is called by the scheduler to ensure that children are // updated in a parent's updated hook. }
_update 过程中有几个关键的代码,首先 vm._vnode = vnode 的逻辑,这个 vnode 是通过 vm._render() 返回的组件渲染 VNode,vm._vnode 和 vm.$vnode 的关系就是一种父子关系,用代码表达就是
vm._vnode.parent === vm.$vnode。还有一段比较有意思的代码: export let activeInstance: any = null Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) { // ... const prevActiveInstance = activeInstance activeInstance = vm if (!prevVnode) { // initial render vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */) } else { // updates vm.$el = vm.__patch__(prevVnode, vnode) } activeInstance = prevActiveInstance }
这个 activeInstance 作用就是保持当前上下文的 Vue 实例,它是在 lifecycle 模块的全局变量,定义是 export let activeInstance: any = null,并且在之前我们调用 createComponentInstanceForVnode 方法的时候从 lifecycle 模块获取,并且作为参数传入的。因为实际上 JavaScript 是一个单线程,Vue 整个初始化是一个深度遍历的过程,在实例化子组件的过程中,它需要知道当前上下文的 Vue 实例是什么,并把它作为子组件的父 Vue 实例。之前我们提到过对子组件的实例化过程先会调用initInternalComponent(vm, options) 合并 options,把 parent 存储在 vm.$options 中,在 $mount 之前会调用 initLifecycle(vm)方法:
export function initLifecycle (vm: Component) { const options = vm.$options // locate first non-abstract parent 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. p a r e n t 就 是 用 来 保 留 当 前 v m 的 父 实 例 , 并 且 通 过 p a r e n t . parent 就是用来保留当前 vm 的父实例,并且通过 parent. parent就是用来保留当前vm的父实例,并且通过parent.children.push(vm) 来把当前的 vm 存储到父实例的 $children 中。
在 vm._update 的过程中,把当前的 vm 赋值给 activeInstance,同时通过 const prevActiveInstance = activeInstance 用 prevActiveInstance 保留上一次的 activeInstance。实际上,prevActiveInstance 和当前的 vm 是一个父子关系,当一个 vm 实例完成它的所有子树的 patch 或者 update 过程后,activeInstance 会回到它的父实例,这样就完美地保证了 createComponentInstanceForVnode 整个深度遍历过程中,我们在实例化子组件的时候能传入当前子组件的父 Vue 实例,并在 _init 的过程中,通过 vm.$parent 把这个父子关系保留。
那么回到 _update,最后就是调用 __patch__
渲染 VNode 了。
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */) function patch (oldVnode, vnode, hydrating, removeOnly) { // ... let isInitialPatch = false const insertedVnodeQueue = [] if (isUndef(oldVnode)) { // empty mount (likely as component), create new root element isInitialPatch = true createElm(vnode, insertedVnodeQueue) } else { // ... } // ... }
这里又回到了本节开始的过程,之前分析过负责渲染成 DOM 的函数是 createElm,注意这里只传了 2 个参数,所以对应的 parentElm 是 undefined。再来看看它的定义:
function createElm ( vnode, insertedVnodeQueue, parentElm, refElm, nested, ownerArray, index ) { // ... if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) { return } const data = vnode.data const children = vnode.children const tag = vnode.tag if (isDef(tag)) { // ... vnode.elm = vnode.ns ? nodeOps.createElementNS(vnode.ns, tag) : nodeOps.createElement(tag, vnode) setScope(vnode) /* istanbul ignore if */ if (__WEEX__) { // ... } else { createChildren(vnode, children, insertedVnodeQueue) if (isDef(data)) { invokeCreateHooks(vnode, insertedVnodeQueue) } insert(parentElm, vnode.elm, refElm) } // ... } else if (isTrue(vnode.isComment)) { vnode.elm = nodeOps.createComment(vnode.text) insert(parentElm, vnode.elm, refElm) } else { vnode.elm = nodeOps.createTextNode(vnode.text) insert(parentElm, vnode.elm, refElm) } }
注意,这里传入的 vnode 是组件渲染的 vnode,也就是之前说的 vm._vnode,如果组件的根节点是个普通元素,那么 vm._vnode 也是普通的 vnode,这里 createComponent(vnode, insertedVnodeQueue, parentElm, refElm) 的返回值是 false。接下来的过程就和之前一样了,先创建一个父节点占位符,然后再遍历所有子 VNode 递归调用 createElm,在遍历的过程中,如果遇到子 VNode 是一个组件的 VNode,则重复本节开始的过程,这样通过一个递归的方式就可以完整地构建了整个组件树。
由于这个时候传入的 parentElm 是空,所以对组件的插入,在 createComponent 有这么一段逻辑:
function createComponent (vnode, insertedVnodeQueue, parentElm, refElm) { let i = vnode.data if (isDef(i)) { // .... if (isDef(i = i.hook) && isDef(i = i.init)) { i(vnode, false /* hydrating */) } // ... if (isDef(vnode.componentInstance)) { initComponent(vnode, insertedVnodeQueue) insert(parentElm, vnode.elm, refElm) if (isTrue(isReactivated)) { reactivateComponent(vnode, insertedVnodeQueue, parentElm, refElm) } return true } } }
在完成组件的整个 patch 过程后,最后执行 insert(parentElm, vnode.elm, refElm) 完成组件的 DOM 插入,如果组件 patch 过程中又创建了子组件,那么DOM 的插入顺序是先子后父。
总结
那么到此,一个组件的 VNode 是如何创建、初始化、渲染的过程也就介绍完毕了。在对组件化的实现有一个大概了解后,接下来介绍一下这其中的一些细节。在编写一个组件实际上是编写一个 JavaScript 对象,对象的描述就是各种配置,之前提到在 _init 的最初阶段执行的就是 merge options 的逻辑。
通过之前的源码分析知道,new Vue 的过程通常有 2 种场景,一种是外部我们的代码主动调用 new Vue(options) 的方式实例化一个 Vue 对象;另一种是我们上一节分析的组件过程中内部通过 new Vue(options) 实例化子组件。
无论哪种场景,都会执行实例的 _init(options) 方法,它首先会执行一个 merge options 的逻辑,相关的代码在 src/core/instance/init.js
中:
Vue.prototype._init = function (options?: Object) { // merge options 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 ) } // ... }
可以看到不同场景对于 options 的合并逻辑是不一样的,并且传入的 options 值也有非常大的不同,接下来我会分开介绍 2 种场景的 options 合并过程。
为了更直观,可以举个简单的示例:
import Vue from 'vue' let childComp = { template: '<div>{{msg}}</div>', created() { console.log('child created') }, mounted() { console.log('child mounted') }, data() { return { msg: 'Hello Vue' } } } Vue.mixin({ created() { console.log('parent created') } }) let app = new Vue({ el: '#app', render: h => h(childComp) })
外部调用场景
当执行 new Vue 的时候,在执行 this._init(options)
的时候,就会执行如下逻辑去合并 options:
vm.$options = mergeOptions( resolveConstructorOptions(vm.constructor), options || {}, vm )
这里通过调用 mergeOptions
方法来合并,它实际上就是把 resolveConstructorOptions(vm.constructor)
的返回值和 options 做合并,resolveConstructorOptions
的实现先不考虑,在我们这个场景下,它还是简单返回 vm.constructor.options
,相当于 Vue.options
,那么这个值又是什么呢,其实在 initGlobalAPI(Vue) 的时候定义了这个值,代码在src/core/global-api/index.js
中:
export function initGlobalAPI (Vue: GlobalAPI) { // ... Vue.options = Object.create(null) ASSET_TYPES.forEach(type => { Vue.options[type + 's'] = Object.create(null) }) // this is used to identify the "base" constructor to extend all plain-object // components with in Weex's multi-instance scenarios. Vue.options._base = Vue extend(Vue.options.components, builtInComponents) // ... }
首先通过 Vue.options = Object.create(null) 创建一个空对象,然后遍历 ASSET_TYPES,
ASSET_TYPES 的定义在 src/shared/constants.js 中: export const ASSET_TYPES = [ 'component', 'directive', 'filter' ]
所以上面遍历 ASSET_TYPES 后的代码相当于:
Vue.options.components = {} Vue.options.directives = {} Vue.options.filters = {}
接着执行了 Vue.options._base = Vue
,它的作用在上节实例化子组件的时候介绍了。
最后通过 extend(Vue.options.components, builtInComponents)
把一些内置组件扩展到 Vue.options.components
上,Vue 的内置组件目前有 <keep-alive>、<transition> 和 <transition-group>
组件,这也就是为什么我们在其它组件中使用 <keep-alive> 组件
不需要注册的原因,这块儿后续我们介绍 <keep-alive> 组件
的时候会详细讲。
那么回到 mergeOptions 这个函数,它的定义在 src/core/util/options.js
中:
/** * Merge two option objects into a new one. * Core utility used in both instantiation and inheritance. */ 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) const extendsFrom = child.extends if (extendsFrom) { parent = mergeOptions(parent, extendsFrom, 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 主要功能就是把 parent 和 child 这两个对象根据一些合并策略,合并成一个新对象并返回。
比较核心的几步,先递归把 extends 和 mixixns 合并到 parent 上,然后遍历 parent,调用 mergeField,然后再遍历 child,如果 key 不在 perent 的自身属性上,则调用 mergeField。
这里有意思的是 mergeField 函数,它对不同的 key 有着不同的合并策略。举例来说,对于生命周期函数,它的合并策略是这样的:
function mergeHook ( parentVal: ?Array<Function>, childVal: ?Function | ?Array<Function> ): ?Array<Function> { return childVal ? parentVal ? parentVal.concat(childVal) : Array.isArray(childVal) ? childVal : [childVal] : parentVal } LIFECYCLE_HOOKS.forEach(hook => { strats[hook] = mergeHook })
这其中的 LIFECYCLE_HOOKS 的定义在 src/shared/constants.js
中:
export const LIFECYCLE_HOOKS = [ 'beforeCreate', 'created', 'beforeMount', 'mounted', 'beforeUpdate', 'updated', 'beforeDestroy', 'destroyed', 'activated', 'deactivated', 'errorCaptured' ]
这里定义了 Vue.js 所有的钩子函数名称,所以对于钩子函数,他们的合并策略都是 mergeHook 函数。这个函数的实现也非常有意思,用了一个多层 3 元运算符,逻辑就是如果不存在 childVal ,就返回 parentVal;否则再判断是否存在 parentVal,如果存在就把 childVal 添加到 parentVal 后返回新数组;否则返回 childVal 的数组。所以回到 mergeOptions 函数,一旦 parent 和 child 都定义了相同的钩子函数,那么它们会把 2 个钩子函数合并成一个数组。
关于其它属性的合并策略的定义都可以在 src/core/util/options.js
文件中看到。
通过执行 mergeField 函数,把合并后的结果保存到 options 对象中,最终返回它。
因此,在当前这个 case 下,执行完如下合并后:
vm.$options = mergeOptions( resolveConstructorOptions(vm.constructor), options || {}, vm ) vm.$options 的值差不多是如下这样: vm.$options = { components: { }, created: [ function created() { console.log('parent created') } ], directives: { }, filters: { }, _base: function Vue(options) { // ... }, el: "#app", render: function (h) { //... } }
组件场景
由于组件的构造函数是通过 Vue.extend 继承自 Vue 的
,先回顾一下这个过程,代码定义在 src/core/global-api/extend.js
中。
/** * Class inheritance */ Vue.extend = function (extendOptions: Object): Function { // ... Sub.options = mergeOptions( Super.options, extendOptions ) // ... // 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) // ... return Sub }
只保留关键逻辑,这里的 extendOptions
对应的就是前面定义的组件对象,它会和 Vue.options
合并到 Sub.opitons
中。
接下来再回忆一下子组件的初始化过程,代码定义在src/core/vdom/create-component.js
中:
export function createComponentInstanceForVnode ( vnode: any, // we know it's MountedComponentVNode but flow doesn't parent: any, // activeInstance in lifecycle state ): Component { const options: InternalComponentOptions = { _isComponent: true, _parentVnode: vnode, parent } // ... return new vnode.componentOptions.Ctor(options) }
这里的 vnode.componentOptions.Ctor
就是指向 Vue.extend 的返回值 Sub, 所以 执行 new vnode.componentOptions.Ctor(options)
接着执行 this._init(options)
,因为 options._isComponent
为 true,那么合并 options 的过程走到了 initInternalComponent(vm, options)
逻辑。先来看一下它的代码实现,在 src/core/instance/init.js
中:
export function initInternalComponent (vm: Component, options: InternalComponentOptions) { const opts = vm.$options = Object.create(vm.constructor.options) // doing this because it's faster than dynamic enumeration. const parentVnode = options._parentVnode opts.parent = options.parent opts._parentVnode = parentVnode const vnodeComponentOptions = parentVnode.componentOptions opts.propsData = vnodeComponentOptions.propsData opts._parentListeners = vnodeComponentOptions.listeners opts._renderChildren = vnodeComponentOptions.children opts._componentTag = vnodeComponentOptions.tag if (options.render) { opts.render = options.render opts.staticRenderFns = options.staticRenderFns } }
initInternalComponent 方法
首先执行 const opts = vm.$options = Object.create(vm.constructor.options)
,这里的 vm.construction
就是子组件的构造函数 Sub,相当于 vm.$options = Sub.options
。
接着又把实例化子组件传入的子组件父 VNode 实例 parentVnode、子组件的父 Vue 实例 parent 保存到 vm.$options 中,另外还保留了 parentVnode 配置中的如 propsData 等其它的属性。
这么看来,initInternalComponent
只是做了简单一层对象赋值,并不涉及到递归、合并策略等复杂逻辑。
因此,当前这个 case 下,执行完如下合并后:
initInternalComponent(vm, options) vm.$options 的值差不多是如下这样: vm.$options = { parent: Vue /*父Vue实例*/, propsData: undefined, _componentTag: undefined, _parentVnode: VNode /*父VNode实例*/, _renderChildren:undefined, __proto__: { components: { }, directives: { }, filters: { }, _base: function Vue(options) { //... }, _Ctor: {}, created: [ function created() { console.log('parent created') }, function created() { console.log('child created') } ], mounted: [ function mounted() { console.log('child mounted') } ], data() { return { msg: 'Hello Vue' } }, template: '<div>{{msg}}</div>' } }
总结
那么至此,Vue 初始化阶段对于 options 的合并过程就介绍完了,需要知道对于 options 的合并有 2 种方式,子组件初始化过程通过 initInternalComponent
方式要比外部初始化 Vue 通过 mergeOptions
的过程要快,合并完的结果保留在 vm.$options
中。
纵观一些库、框架的设计几乎都是类似的,自身定义了一些默认配置,同时又可以在初始化阶段传入一些定义配置,然后去 merge 默认配置,来达到定制化不同需求的目的。只不过在 Vue 的场景下,会对 merge 的过程做一些精细化控制,虽然在开发自己的 JSSDK 的时候并没有 Vue 这么复杂,但这个设计思想是值得借鉴的。以上内容用于对vue的学习和理解。