今天我们来说下vue实例的$mount中都发生了什么。$mount是Vue原型上的方法,是Vue实例化的最后一步。$mount分为带编译器版本和不带编译器版本。我们以下面的代码为例,来讲下在$mount时都发生了什么。
实例代码如下(来源于codesandbox的默认vue项目代码):
// main.js
import Vue from "vue";
import App from "./App.vue";
Vue.config.productionTip = false;
new Vue({
render: h => h(App)
}).$mount("#app");
// app.vue
<template>
<div id="app">
<img width="25%" src="./assets/logo.png">
<HelloWorld msg="Hello Vue in CodeSandbox!" />
</div>
</template>
<script>
import HelloWorld from "./components/HelloWorld";
export default {
name: "App",
components: {
HelloWorld
}
};
</script>
<style>
#app {
font-family: "Avenir", Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
margin-top: 60px;
}
</style>
// HelloWorld.vue
<template>
<div class="hello">
<h1>{{ msg }}</h1>
<h3>Installed CLI Plugins</h3>
</div>
</template>
<script>
export default {
name: "HelloWorld",
props: {
msg: String
}
};
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
h3 {
margin: 40px 0 0;
}
ul {
list-style-type: none;
padding: 0;
}
li {
display: inline-block;
margin: 0 10px;
}
a {
color: #42b983;
}
</style>
$mount是vue实例化中不可缺少的一部分,它将template转化成虚拟dom,然后根据虚拟dom渲染真实dom节点并进行挂载。在这个过程中,主要讲以下几点:
- mountComponent中都发生了什么
- 渲染watcher(renderWatcher)的执行过程
- entry-runtime.js和entry-runtime-with-compiler.js的区别及适用场景
1. mountComponent中发生了什么?
在Vue实例化完成的最后一步(即_init原型方法中),如果初始化的参数里有el,则自动调用$mount方法。否则,需要手动调用$mount方法并传入挂载节点。
实例代码中,是在new Vue()之后手动调用了$mount方法,并传入了App组件。下面找到$mount方法的定义:
// src/platforms/web/runtime/index.js
Vue.prototype.$mount = function (
el?: string | Element,
hydrating?: boolean
): Component {
el = el && inBrowser ? query(el) : undefined
/**
* el: {name: "App", components: {…}, render: ƒ, staticRenderFns: Array(0), _compiled: true, ...rest}
**/
return mountComponent(this, el, hydrating)
}
由上可知,$mount获取到传入的App, 并且去query。在query中,由于App经过vue-loader编译后是一个Object,所以在query中被直接返回了。所以然后紧接着调用mountComponent函数。
// src/core/instance
export function mountComponent (
vm: Component,
el: ?Element,
hydrating?: boolean
): Component {
vm.$el = el
// 1. 检查vm.$options.render是否存在,如果不存在,给出警告
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
)
}
}
}
// 2. 调用beforeMount生命周期钩子
callHook(vm, 'beforeMount')
let updateComponent
updateComponent = () => {
// mountComponent的核心
vm._update(vm._render(), hydrating)
}
// 3. 生成渲染watcher
new Watcher(vm, updateComponent, noop, {
before () {
// 如果当前vm实例已经被挂载,并且没有被销毁,调用beforeMount生命周期钩子
if (vm._isMounted && !vm._isDestroyed) {
callHook(vm, 'beforeUpdate')
}
}
}, true /* isRenderWatcher */)
hydrating = false
if (vm.$vnode == null) {
vm._isMounted = true
callHook(vm, 'mounted')
}
return vm
}
在mountComponent中,renderWatcher是最关键的地方。通过renderWatcher,将render函数转换成vnode,然后通过vm._update,将vnode渲染成真实的dom。
2. 渲染watcher(renderWatcher)的执行过程
渲染watcher的执行过程,就是Watcher实例化的过程。
// src/core/observer/watcher.js
export default class Watcher {
constructor (
vm: Component,
expOrFn: string | Function,
cb: Function,
options?: ?Object,
isRenderWatcher?: boolean
) {
this.vm = vm
// 当前isrenderwatcher为true
if (isRenderWatcher) {
vm._watcher = this
}
vm._watchers.push(this)
// options
if (options) {
this.before = options.before // 这个函数会选择性调用beforeUpdate钩子函数
} else {
this.deep = this.user = this.lazy = this.sync = false
}
// expOrFn 就是updateComponent函数。
if (typeof expOrFn === 'function') {
this.getter = expOrFn // 会走到这里啦
}
this.value = this.lazy // lazy是false,所以会调用this.get函数。
? undefined
: this.get()
}
get () {
pushTarget(this) // 将当前的Dep.target设置为该renderWatcher。
let value
const vm = this.vm
try {
value = this.getter.call(vm, vm) // 在这里会调用updateComponent,即vm._update(vm._render(), hydrating);
} catch (e) {
if (this.user) {
handleError(e, vm, `getter for watcher "${this.expression}"`)
} else {
throw e
}
} finally {
if (this.deep) {
traverse(value)
}
popTarget() // getter执行完毕后把Dep.target设置为之前的watcher实例。
this.cleanupDeps()
}
return value
}
}
由以上代码可知,在renderWatcher的执行过程中,this.get()的执行是最为重要的。而this.get()的执行则主要切换了Dep.target,执行了updateComponent函数。
updateComponent中, 主要执行了vm._update(vm._render(), hydrating)函数。下面着重来分析vm._update的过程。
vm._render的执行过程
_render是Vue原型上的方法,定义在src/core/instance/render.js中,下面分析下_render方法主要做了什么。 其实_render主要做了一件事,调用render函数生成vnode(虚拟dom)并返回。
1. 从vm.$options取出render和_parentVnode。在上边的例子中,new Vue({ render: h => h(App) }).$mount('#app')。
在这里, _parentVnode为undefined。
2. 设置_parentVnode为vm.$vnode,即当前vue实例的占位符vnode。
3. 设置currentRenderingInstance = vm, 同时执行渲染函数render,取得render返回的vnode。
4. 然后将currentRenderingInstance置为null
5. 返回vnode,并设置vnode.parent为_parentVnode.
在这里render函数主要调用了 render.call(vm, vm.createElement。就是根据传入的tag或者component,生成对应的vnode的一个函数。下面来分析下vm.
createElement也定义在render.js文件中,在vm._init中执行挂载。vm.$createElement定义如下:
// 最后一个true用以标示是用户主动调用,即定义了render函数。而非由编译器调用。
vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true)
// 在例子中,传入的参数如下:
vm.$createElement(App);
createElement最终会调用_createElement,下面来分析下_createElement。
// src/core/vdom/create-element.js
export function _createElement (
context: Component,
tag?: string | Class<Component> | Function | Object,
data?: VNodeData,
children?: any,
normalizationType?: number
): VNode | Array<VNode> {
// 如果传入的data是一个响应式数据,直接创建并返回空vnode
if (isDef(data) && isDef((data: any).__ob__)) {
return createEmptyVNode()
}
// 处理<component is=""></component>动态组件情况
if (isDef(data) && isDef(data.is)) {
tag = data.is
}
if (!tag) {
return createEmptyVNode()
}
// ... 有删减
// 在例子中,这两个值是相等的。处理过程在createElement中。
if (normalizationType === ALWAYS_NORMALIZE) {
// 处理children, 最终生成一个[vnode, vnode, vnode]数组。
children = normalizeChildren(children)
} else if (normalizationType === SIMPLE_NORMALIZE) {
children = simpleNormalizeChildren(children)
}
let vnode, ns
// 此时tag为对象App
if (typeof tag === 'string') {
let Ctor
ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag)
if (config.isReservedTag(tag)) {
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 {
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()
}
}
接下来会调用createComponent函数来创建组件。在createComponent中,主要做了下面几件事:
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
// 1. 调用Vue.extend创建子组件构造器VueComponent
if (isObject(Ctor)) {
Ctor = baseCtor.extend(Ctor)
}
data = data || {}
// 2. 同步全局的属性到子组件构造器,主要是处理全局的mixins
resolveConstructorOptions(Ctor)
// 3. 安装组件管理的钩子init, prepatch, insert, destroy. 这些钩子将在不同的时期调用。
installComponentHooks(data)
// 4. 创建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
}
至于Vue.extend及其它函数的细节,就不详细讲了。大概明白它主要做了什么就好。
至此,vm._render的过程就进行完了,主要就是根据render函数创建并返回了vnode。
vm._update过程
vm._update主要是将vnode转换成真实dom。在这个过程中,包含了真实dom节点的增删改查操作,dom节点属性的操作及虚拟节点vnode的patchDiff过程。
vm._update也是Vue原型上的方法,定义在src/core/instance/lifecycle.js中。在_update中,主要就是取出之前的渲染vnode, 然后新旧vnode进行patch。在patch中将vnode的改动映射到真实dom上。在这个过程中,尤其是新旧vnode都存在时,通过patchDiff算法,找出最小的更改点,尽可能重用dom或者尽可能少地操作dom,达到一个相对较好的效果。
在patch的过程中,activeInstance为当前vm实例。当patch过程完成后,还原activeInstance为之前的vm实例。
第一次进行渲染时,preVnode为undefined。所以会进入if分支。
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
const vm: Component = this
const prevEl = vm.$el
const prevVnode = vm._vnode
// 1. 设置当前vm为activeInstance实例,在patch过程中会用到
const restoreActiveInstance = setActiveInstance(vm)
// 2. 设置vm的渲染vnode为传入的vnode
vm._vnode = vnode
// Vue.prototype.__patch__ is injected in entry points
// based on the rendering backend used.
if (!prevVnode) {
// 3. 在上边的例子中,初次渲染,会进入这个分支
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
} else {
// updates
vm.$el = vm.__patch__(prevVnode, vnode)
}
// 4. 还原之前的vm实例为activeInstance
restoreActiveInstance()
// update __vue__ reference
if (prevEl) {
prevEl.__vue__ = null
}
if (vm.$el) {
vm.$el.__vue__ = vm
}
// 高阶组件相关
if (vm.$vnode && vm.$parent && vm.$vnode === vm.$parent._vnode) {
vm.$parent.$el = vm.$el
}
}
vm.__patch也是Vue原型上的方法。__patch__最终调用了src/core/vdom/patch中的createPatchFunction方法,它的返回值就是__patch__函数。
在调用createPatchFunction时,传入了nodeOps和modules。nodeOpts和modules都是平台相关的。这也就是为什么Vue能在多个平台上工作的原因。以weex为例,Vue负责生成vnode,在createPatchFunction时传入平台相关的节点操作和modules,提前固化相关的节点操作和模块配置,以便在patch的过程中生成平台相关的节点和进行相关的节点操作。
在createPatchFunction中,提取了modules中的'create', 'activate', 'update', 'remove',
'destroy'的钩子函数,并存储在cbs中,以便后期的patch函数调用。最后,返回了patch函数。
最终工作的还是createPatchFunction返回的patch函数。这是整个patch过程的核心。下面来看下这个patch函数。
return function patch (oldVnode, vnode, hydrating, removeOnly) {
// 如果新vnode不存在,旧的存在,调用钩子函数销毁旧的vnode
if (isUndef(vnode)) {
if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
return
}
let isInitialPatch = false
const insertedVnodeQueue = []
if (isUndef(oldVnode)) {
// 如果旧的vnode不存在,创建vnode
// 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)) {
// 真正的update,新旧vnode对比更新
// patch existing root node
patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
} else {
if (isRealElement) {
// 初次patch。 例子中会进入这里, 创建一个空的vnode。
oldVnode = emptyNodeAt(oldVnode)
}
// replacing existing element
const oldElm = oldVnode.elm // 即$mount('#app')中的#app的dom节点
const parentElm = nodeOps.parentNode(oldElm) // body
// create new node
createElm(
vnode,
insertedVnodeQueue,
oldElm._leaveCb ? null : parentElm,
nodeOps.nextSibling(oldElm) // textnode
)
// update parent placeholder node element, recursively
// 由于示例代码中,vnode的parent为undefined,所以不会进入该分支。
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([oldVnode], 0, 0)
} else if (isDef(oldVnode.tag)) {
invokeDestroyHook(oldVnode)
}
}
}
invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
return vnode.elm
}
对于示例代码而言,最终会走到createElm中。createElm(vnode, [], body, textnode)。 在createElm中,对vnode进行处理,最终通过调用nodeOpts中的各种操作,完成对dom节点的增删改查操作。同时,通过调用对应的钩子函数,完成对dom属性的变更操作。
在createElement之后,如果当前的vnode有parent,即当前vnode是一个渲染vnode,而vnode.parent是一个组件占位符vnode。更新vnode.parent所绑定的elm为创建新节点后的vnode的elm。同时调用相关的钩子函数完成占位符节点的更新操作。
之后,移除oldVnode,增加vnode到parentElm。将子组件由子到父调用mounted钩子函数。
至此patch的初始创建过程完成。最后返回新创建或者更改后的最新的vnode.elm即真实的dom节点。
下面来看下createElm的代码。createElm主要做了以下几件事: 要说的都在注释里了,就不再额外阐述了。总之createElm就做了创建并插入dom节点这件事。
// src/core/vdom/patch.js
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
// 1. 尝试去创建组件,如果当前vnode是占位符vnode,则会返回true,
// 也就不走之后的流程了。示例代码中第一次到达此处的是一个组件的vnode,
// 它是占位符vnode,所以会进入createComponent执行创建VueComponent实例。
if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {
return
}
const data = vnode.data
const children = vnode.children
const tag = vnode.tag
// 如果tag存在,此处的tag应为一个dom节点的tagname。组件的tag会在前边createComponent被处理掉。
if (isDef(tag)) {
if (process.env.NODE_ENV !== 'production') {
if (data && data.pre) {
creatingElmInVPre++
}
}
2. // 获取到vnode.tag,创建真实的dom节点
vnode.elm = vnode.ns
? nodeOps.createElementNS(vnode.ns, tag)
: nodeOps.createElement(tag, vnode)
setScope(vnode)
// 此处移除了weex里的逻辑,只留下web平台的代码
// 3. 调用createChildren,对vnode的children进行深度遍历,直到
createChildren(vnode, children, insertedVnodeQueue)
if (isDef(data)) {
// 调用对应的钩子函数
invokeCreateHooks(vnode, insertedVnodeQueue)
}
// 4. 将当前vnode.elm插入到paremtElm中。完成dom创建操作
insert(parentElm, vnode.elm, refElm)
if (process.env.NODE_ENV !== 'production' && data && data.pre) {
creatingElmInVPre--
}
} else if (isTrue(vnode.isComment)) {
// 如果是注释vnode,直接创建注释节点并插入
vnode.elm = nodeOps.createComment(vnode.text)
insert(parentElm, vnode.elm, refElm)
} else {
// 否则,把当前vnode视为文本节点,创建文本节点并插入
vnode.elm = nodeOps.createTextNode(vnode.text)
insert(parentElm, vnode.elm, refElm)
}
}
既然createElm的主要逻辑在createComponent中,那就看下createComponent吧。在createComponent中,根据当前的组件vnode,会生成VueComponent实例并执行相应的初始化过程,最后调用子组件实例的$mount方法。进行子组件相关dom节点的创建工作。
// src/core/vdom/patch.js
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)) {
// 1. 调用组件vnode.data.hook上的init方法,执行了子组件构造函数的_init函数,并且执行了子组件的$mount方法。
i(vnode, false /* hydrating */)
}
// 在调用init钩子后,如果当前vnode是一个子组件,它应该已经创建
// 了一个组件实例并且挂载了它(即调用了$mount方法。
// 子组件也已经设置了elm属性,elm是子组件生成的对应的dom节点。
if (isDef(vnode.componentInstance)) {
// componentInstance就是子组件对应的vm实例。
// 清空子组件vnode.data.pendingInsert,并把它存在insertedVnodeQueue上。
// 设置当前vnode.elm属性为componentInstance的$el。
initComponent(vnode, insertedVnodeQueue)
// 将vnode对应的dom节点插入到父节点指定的位置。
insert(parentElm, vnode.elm, refElm)
if (isTrue(isReactivated)) {
reactivateComponent(vnode, insertedVnodeQueue, parentElm, refElm)
}
return true
}
}
}
在生成子组件vm实例时,会调用vm实例上的$mount方法。而$mount方法又要走到patch function中。最终也会进入到createElm方法中。不过这时调用createComponent会返回false。因为此时的vnode.tag为App.vue中的父级div()。
之后进入createElm的第二步、第三步、第四步。这个过程就不再赘述了。反正就是一个createElm和createChildren互相调用的过程。在这个过程中,创建了对应的dom节点,并通过调用invokeCreateHook函数完成对dom属性的操作。
最后经过createElm,已经生成了一个属性完备的dom节点,并且已经插入到了body中。初次的vnode渲染dom就完成了。
entry-runtime.js和entry-runtime-with-compiler.js的区别及适用场景
从字面意思看,两个文件就差了compiler即编译器。翻阅代码可得,entry-runtime-with-compiler版本是对entry-runtime.js版本的扩展,即重写了entry-runtime.js中Vue的原型方法$mount。
在vue-cli初始化项目时,会让我们选择使用哪个版本的vue,官方推荐的是选择with-compiler版本的。
其实,with-compiler版本比runtime版本多的就是一个编译器,就是在初始化vue时的render函数。如果没有选择这个版本,我们需要在初始化vue实例时手动传入render函数配置项。而不能直接以sfc(单文件组件)的方式来写vue。
而在手动传入render函数时,我们要以创建虚拟dom的形式传入参数。例如:
new Vue({
render(h) {
return h('div', { staticClass: 'app', data: {} }, 'hello,world');
}
})
而使用带compiler版本的,我们可以这么写:
<div id='app'>hello,world</div>
new Vue({
el: '#app'
})
这期就分析到这里了。拜拜~