Hello, 这里是link😋, 上期讲完了data
, 这期我们来看看一个原生节点是怎么挂载的. 也就是我们封面的这个节点.
流程图
这是本文内容的总图, 可以对照这个图看文章, 如果这里看不清楚的话, 可以到我的源码项目vue-core-analyse自行下载,欢迎star哦~✨
初始化函数
在我们所有的
data
,props
等状态初始完毕后, _init函数最终会执行一个$mount函数
// /src/core/instance/init.js
Vue.prototype._init = function (options?: Object) {
const vm: Component = this
// a uid
vm._uid = uid++
// 用于标识当前的实例是 Vue 实例
vm._isVue = true
// 合并配置项
if (options && options._isComponent) {
initInternalComponent(vm, options)
} else {
vm.$options = mergeOptions(
resolveConstructorOptions(vm.constructor),
options || {},
vm
)
}
// expose real self
vm._self = vm
initLifecycle(vm)
initEvents(vm)
initRender(vm)
callHook(vm, 'beforeCreate') // 生命周期函数
initInjections(vm)
initState(vm) // 这里就是我们上期讲的 data 等属性的初始化处
initProvide(vm)
callHook(vm, 'created')
// ---------- 在所有属性初始化完毕后, 我们开始挂载节点
if (vm.$options.el) {
vm.$mount(vm.$options.el) // 👈 这里是我们本期的关注点
}
编译版本的区别
编译版本的不同, 会让Vue在节点的挂载上做的事情有些不同, 我们先了解一下这两个版本的异同
Vue-cli3
脚手架在我们生成项目的时候, 就会提供这样的选择, 提示我们需要选择哪个版本的Vue, 这里可以这样总结:
-
runtime-with-complier
版本: 是一个完整版的Vue
, 它能够提供从模板(template) => 真实dom 的生成所需的全部功能, 一般源码调试我们选择这个版本. -
runtime-only
版本: 将编译部分的工作抽离了出来, 作为一个vue-loader
去单独工作. 这样能够缩减vue的大小, 在性能上也会更好, 所以业务开发我们通常都是选择这个版本.
runtime-with-complier
执行流程: template => ast(抽象语法树) => 生成render() => 生成VDom => 真实Dom
new Vue({
template: '<div id="app"> Hello Vue <div>'
}).$mount('#app')
在这个版本的Vue中, 当项目运行时, 也就是runtime
阶段, Vue需要自行完成上述流程的编译, 这样显然运行需要的时间就更多了.
runtime-only
生成VDom => 真实Dom
src
├─ App.vue
└─ main.js
new Vue({
render: h => h(App),
}).$mount('#app')
only
将App.vue
转换成render函数
的过程发生在构建阶段, 也就是这些工作由Vue-Loader
完成.
不难看出从业务的角度出发,
only
版本的性能更加优越, 因为在 runtime 的时候需要做的事情变少了. 模板编译工作, 我们可以在构建阶段
(丢给webpack去做)就完成了
$mount 函数
以下是两个版本的
$mount函数
的两个定义处
- 我们先把这个
$mount
称为only
👇
// src/platforms/web/runtime/index.js
Vue.prototype.$mount = function (
el?: string | Element,
hydrating?: boolean
): Component {
// 判断 是不是 dom
el = el && inBrowser ? query(el) : undefined
return mountComponent(this, el, hydrating)
}
- 把这个
$mount
称为compiler
👇
// /src/platforms/web/entry-runtime-with-compiler.js
// 将原先的 mount 拿出来
const mount = Vue.prototype.$mount
// 定义一个新的, 由于在 runtime-compiler 版本我们需要做额外的处理
Vue.prototype.$mount = function (
el?: string | Element,
hydrating?: boolean
): Component {
// 将 el 属性 转化为 dom 对象
el = el && query(el)
if(template) {
// 编译工作在这里执行, 最后会生成一个render函数赋值给 this.$options
// 目前先理解为 走过这一步, 我们就有 render 函数了
}
return mount.call(this, el, hydrating) // 这里执行
}
-
这里Vue做了什么事情呢? 其实很简单, 首先定义
only
, 然后在entry-runtime-with-compiler.js
中把only
拿出来保存在变量mount
中. 然后complier
的最后return
的时候 做一个尾回调执行only
-
梳理完他们两者的区别, 我们就来看看他们究竟做了什么事情. 我们先来看看流程图, 这里蓝色部分是
complier
的$mount函数
做的事情, 而红色部分是only
版本$mount函数
做的事情.
组件挂载
那么接下来, 我们来看一下
mountComponent
函数做了哪些事情.
export function mountComponent (
vm: Component,
el: ?Element,
hydrating?: boolean
): Component {
// 挂载 dom
vm.$el = el
// vue 初始化的时候,无论是template还是 render 最终都会转化为 render函数 进行渲染
if (!vm.options.render) { /* 如果到这里了还没有 render 函数 那就会报错了 */ }
// 周期钩子
callHook(vm, 'beforeMount') // beforeMount 就是在这个节点触发的
let updateComponent
// 创建 updateComponent 函数, 之后更新数据 updateComponent 会被触发执行
updateComponent = () => {
vm._update(vm._render(), hydrating)
}
// 创建一个观察者实例
vm._watcher = new Watcher(vm, updateComponent, noop)
return vm
}
这个函数做了两件事情:
-
定义了一个
updateComponent
-
创建了一个观察者实例
Watcher
, 并传入updateComponent
这里你可以理解为, Vue 挂载了第一个组件, 就是我们定义的 new Vue()
本质上它也是一个组件. 也就是说, 每一次我们创建组件, 就是在实例化一个Vue, 并且创建一个 watcher
这里我们先不去理解观察者实例内部的逻辑, 只需要知道发生变化的时候, 在实例内部会去调用
updateComponent
updateComponent函数内部会执行两个函数:
-
vm.render()
: 负责生成虚拟节点 -
_update()
: 负责更新操作, 将虚拟节点转化为真实节点, 并插入到DOM树中
我们先来看 vm._render()
函数的工作, 他最终会返回一个 vnode
给我们的 _update
// /src/core/instance/render.js
Vue.prototype._render = function (): VNode {
const vm: Component = this
const { render } = vm.$options
let vnode
try {
// 标记当前渲染的实例
currentRenderingInstance = vm
vnode = render.call(vm._renderProxy, vm.$createElement) // 👈 我们需要关注这里
} catch (e) {
// ... 这一部分我们无需关注
} finally {
// 取消当前实例的标记
currentRenderingInstance = null
}
return vnode
}
请注意, 这个
render函数
, 就是由vue-loader
或者Vue
本身生成的, 当然还有一种方式, 就是我们自己定义的, 他默认会传入一个实例, 还有一个函数, 我相信很多小伙伴在文档中也有看到过. 我这里指个路Vue - 渲染函数 & JSX
例如:
new Vue({
el: '#app',
data:{
msg: 'parent-Vue'
},
render: function ($createElement) { // 创建一个 <div id="foo"></div>
// 这里参数, 就是对应上面的 a, b, c, d
return $createElement(
'p',
{ attrs: { id: 'foo' } },
[ h('div', '111') ]
)
}
})
也就是说在 render函数
内部, vm.$createElement
函数会被默认执行, 我们来看看定义
- 这是
$createElement
👇 的定义, 返回了一个默认执行的createElement
vm.$createElement = function (a, b, c, d) { return createElement(vm, a, b, c, d, true); };
createElement
最终会返回_createElement
, 其实Vue这么做, 只不过是在createElement
, 做一些参数的处理工作, 因为我们的createElement
配置是比较灵活的
// /src/core/instance/render.js
var SIMPLE_NORMALIZE = 1;
var ALWAYS_NORMALIZE = 2;
function createElement (
context, // 当前实例
tag, // 标签
data, // 当前节点的属性 id 等
children, // 子节点
normalizationType, // 针对子节点操作类型
alwaysNormalize // 用于约束上一个参数 具体看下面代码
) {
if (Array.isArray(data) || isPrimitive(data)) {
normalizationType = children;
children = data;
data = undefined;
}
// 看图可以知道, alwaysNormalize 在当前流程下总是被置为 true
if (isTrue(alwaysNormalize)) {
// 这里主要是对子节点做操作不同的标识
normalizationType = ALWAYS_NORMALIZE;
}
return _createElement(context, tag, data, children, normalizationType)
}
为了防止参数看起来很混乱, 我们以上面那个demo为例, 画一个图对照着看😝
创建虚拟节点
_createElement会判断当前tag
是否为一个string类型
, 是的话 通过new VNode(tag)
去描述一个原生节点, 否则如果是一个组件就要做额外的处理
export function _createElement (
context: Component,
tag?: string | Class<Component> | Function | Object, // tag 也有可能是组件, 函数 等
data?: VNodeData, // 这里的data 是 key id 这种定义在标签里的属性
children?: any, // 子节点
normalizationType?: number // 操作类型类型
): VNode {
if (isDef(data) && isDef(data.is)) {
// 存在 is 属性 则证明要将某个标签指定为 is 中的这个标签
tag = data.is
}
// 递归子节点, 请注意我们上面 demo 中的子节点数组, 就是在这一部分做了操作
if (normalizationType === ALWAYS_NORMALIZE) {
children = normalizeChildren(children)
} else if (normalizationType === SIMPLE_NORMALIZE) {
children = simpleNormalizeChildren(children)
}
let vnode, ns
// 标签
if (typeof tag === 'string') {
// html 原生保留标签
// platform built-in elements
vnode = new VNode(
// 创建平台保留标签 这里理解成 (tag) => tag 就好了
config.parsePlatformTagName(tag), data, children,
undefined, undefined, context
)
} else {
// direct component options / constructor
// 如果是一个组件, (这个是下一章节的内容)
vnode = createComponent(tag, data, context, children)
}
return vnode
}
这个函数主要做了三件事:
-
通过
normalizeChildren
获取子节点.- 这里实际上是对子节点做了一些优化操作, 比如两个相邻的子节点都是文本, 那就合并它, 由于不是本次重点, 我们就不介绍了
-
如果
tag
是一个字符串 则创建一个Vnode
-
否则创建一个组件
new VNode()
其实就是一个对象, 相较于用一个原生的DOM做为节点, VNode需要的内存要小的的多, 因为DOM上有很多我们不需要的属性
由于 Vnode() 的定义比较长, 十分占用篇幅, 大家可以通过这里看vnode.js
好的, 直到这里一个虚拟节点的产生逻辑, 我们就看完了, 接下来我们来看一下. 一个标签是怎样被挂载到Dom树上的
节点的挂载
在 vm._update()
中, 它负责将这个节点转化为真实的Dom节点, 并且插入到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 // 取出上一个虚拟节点(上次更新), 第一次的时候这里为空
vm._vnode = vnode
if (!prevVnode) { // 不存在前虚拟节点证明是初始化
// 初始化
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
} else {
// 更新节点
vm.$el = vm.__patch__(prevVnode, vnode)
}
}
其中有一个很重要的函数
vm.__patch__
根据名字我们就可以大概猜到它可能是一个"补丁"
我们先来看看 patch函数
做了哪些事情
- 将旧的节点转化为
空虚拟节点
(为了方便卸载旧的节点) createElm()
创建一个新节点- diff新旧节点 ( 我们现在处于初始化阶段, 显然这一步不会执行 )
- 卸载旧节点
// /src/core/vdom/patch.js
return function patch (
oldVnode, // 本流程中是 vm.$el 也就是真实的根节点
vnode, // 我们上面创建的 vnode
hydrating, // 用不到
removeOnly // 用不到
) {
let isInitialPatch = false
const insertedVnodeQueue = []
// 将真实 dom 转化为 VNode
oldVnode = emptyNodeAt(oldVnode)
// oldElm 是一个真实节点
const oldElm = oldVnode.elm
// 拿到当前dom的父节点
const parentElm = nodeOps.parentNode(oldElm)
// 👇 创建节点
createElm(
vnode, // 当前虚拟节点
insertedVnodeQueue, // 这里是空数组
parentElm, // 初始化的时候 这里是 body标签
nodeOps.nextSibling(oldElm) // 兄弟节点(最后插入父元素要用到)
)
// 显然初始化的时候不会走到这里
// 👇 更新节点
if (isDef(vnode.parent)) { /* 这是更新阶段做的事情, diff也在这里 */ }
// 👇 删除旧节点
if (isDef(parentElm)) {
removeVnodes([oldVnode], 0, 0)
} else if (isDef(oldVnode.tag)) {
invokeDestroyHook(oldVnode)
}
return vnode.elm
}
我们先来看看步骤2
createElm()
首先, 会判断当前虚拟节点是否可以作为一个组件被创建, 再将其作为一个普通的节点(正常的DOM, 或者注释以及文本节点)
-
tag
不存在的情况很简单,
tag
如果不存在, 也就是不存在标签的情况, 比如:<p></p>
那肯定是文本, 或者注释, 所以在这个函数最后会创建这两种标签 并且通过insert函数
插入到parentElm
-
再来看看
tag
存在的情况-
通过
nodeOps.createElement(tag, vnode)
创建一个真实节点. (nodeOps
我们就看成window
就可以了, 实际上他就是封装了一个针对节点操作的工具箱.) -
执行
createChildren(vnode, children, insertedVnodeQueue)
递归创建子节点 -
执行
insert(parentElm, vnode.elm, refElm)
插入当前创建的节点
-
function createElm (vnode, insertedVnodeQueue, parentElm, refElm, nested) {
vnode.isRootInsert = !nested
if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {
// 前虚拟节点是否可以作为一个组件被创建
// 但这是下篇的内容, 我们先跳过
return
}
const data = vnode.data
const children = vnode.children
const tag = vnode.tag // tag 可能是一个组件对象
// 是否有定义?
if (isDef(tag)) {
vnode.elm = nodeOps.createElement(tag, vnode)
// 创建子节点, 内部递归调用了 createElm
createChildren(vnode, children, 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)
}
}
createElm()
执行流程
接下来我们研究一下createChildren
createChildren
函数主要的作用就是递归创建子节点, 可以仔细看上图的流程👆
function createChildren (vnode, children, insertedVnodeQueue) {
if (Array.isArray(children)) {
for (let i = 0; i < children.length; ++i) {
// 创建所有子节点
createElm(children[i], insertedVnodeQueue, vnode.elm, null, true)
}
} else if (isPrimitive(vnode.text)) {
nodeOps.appendChild(vnode.elm, nodeOps.createTextNode(vnode.text))
}
}
在createChildren()
执行完毕后就会执行 insert()
负责将节点插入到父元素中.
function insert (parent, elm, ref) {
if (isDef(parent)) {
if (isDef(ref)) {
// ref元素 存在的话, 就将其插入到 ref元素 之前
// 这个元素是真实节点的下一个真实兄弟节点
// 我猜这么做, 插入位置会更准确?
if (nodeOps.parentNode(ref) === parent) {
nodeOps.insertBefore(parent, elm, ref)
}
} else {
// 否则是直接添加到 父元素下
nodeOps.appendChild(parent, elm)
}
}
}
好了, 到这里节点的生成就算是结束了. 但是请注意:
createChildren()
的执行是在 insert()
, 之前则意味着, 子节点的createElm()
完毕后才会进行当前节点的插入. 所以他们的插入顺序应该是这样的:
当前节点创建 => 创建子节点 => 子节点插入到当前节点 => 当前节点插入到它的父节点
当然这是递归调用的特性, 实际上Vue的组件也是这样的特性, 从而规定了生命周期的执行顺序. 请务必理解这一点, 对于后续理解组件的挂载有益.
旧节点的卸载
在 patch()
函数的最后一步, 有一串这样的代码:
// 👇 删除旧节点
if (isDef(parentElm)) {
removeVnodes([oldVnode], 0, 0)
} else if (isDef(oldVnode.tag)) {
invokeDestroyHook(oldVnode)
}
removeVnodes
内部就是执行了节点的移除. 为什么需要这么呢, 如果打断点你就会发现, 在createElm()
执行后, 会有两个节点存在. 随后这段代码执行, 才会消失
我们来看一个demo
<div id="app" ref="app">{{ msg }}</div>
new Vue({
el: '#app',
data:{
msg: 'parent-Vue'
},
})
然后在createElm()
前打一个断点, 当createElm执行完毕后
, 但是旧节点卸载还没执行前, 就会出现👇这种情况
感谢😘
如果觉得文章内容对你有帮助:
-
❤️欢迎关注点赞哦! 我会尽最大努力产出高质量的文章
个人公众号: 前端Link
联系作者: linkcyd 😁
往期: