Vue 原理解析(四): 虚拟Dom是怎么生成的
在经过初始化阶段之后,即将开始组件的挂载,在挂载之前有必要了解下虚拟Dom 的概念。我们知道vue@2.0 开始引入了虚拟dom, 主要解决的问题是, 大部分情况下可以降低使用Javascript 去操作跨线程的庞大dom所需要的昂贵性能,让dom 操作的性能更高效; 以及虚拟Dom可以用于SSR以及跨端使用。虚拟Dom, 顾明思议并不是真实的Dom, 而是使用javascript 的对象来对真.
在经过初始化阶段之后,即将开始组件的挂载,在挂载之前有必要了解下虚拟Dom 的概念。我们知道vue@2.0 开始引入了虚拟dom, 主要解决的问题是, 大部分情况下可以降低使用Javascript 去操作跨线程的庞大dom所需要的昂贵性能,让dom 操作的性能更高效; 以及虚拟Dom可以用于SSR以及跨端使用。
虚拟Dom, 顾明思议并不是真实的Dom, 而是使用javascript 的对象来对真实dom的一个描述。 一个真实的dom 也无非是有标签名, 属性, 子节点等这些来描述它,如下对比讲解:
*真实Dom
<div id='app' class='wrap'>
<h2>
hello
</h2>
</div>
我们可以在Render 函数里这样描述它:
new Vue({
render(h) {
return h('div', {
attrs: {
id: 'app',
class: 'wrap'
}
}, [
h('h2', 'hello')
])
}
})
这个时候它并不是用对象来描述的, 使用的是render函数内的数据结构去描述的真实Dom, 而我们需要将这段描述转为用对象的形式, render函数使用的是参数h方法并用VNdoe这个类来实例化它们, 所以我们再了解h的实现原理前,首先看下vNode类是什么, 给出它定义的地方:
export default class VNode {
constructor (
tag
data
children
text
elm
context
componentOptions
asyncFactory
) {
this.tag = tag // 标签名
this.data = data // 属性 如id/class
this.children = children // 子节点
this.text = text // 文本内容
this.elm = elm // 该VNode对应的真实节点
this.ns = undefined // 节点的namespace
this.context = context // 该VNode对应实例
this.fnContext = undefined // 函数组件的上下文
this.fnOptions = undefined // 函数组件的配置
this.fnScopeId = undefined // 函数组件的ScopeId
this.key = data && data.key // 节点绑定的key 如v-for
this.componentOptions = componentOptions // 组件VNode的options
this.componentInstance = undefined // 组件的实例
this.parent = undefined // vnode组件的占位符节点
this.raw = false // 是否为平台标签或文本
this.isStatic = false // 静态节点
this.isRootInsert = true // 是否作为根节点插入
this.isComment = false // 是否是注释节点
this.isCloned = false // 是否是克隆节点
this.isOnce = false // 是否是v-noce节点
this.asyncFactory = asyncFactory // 异步工厂方法
this.asyncMeta = undefined // 异步meta
this.isAsyncPlaceholder = false // 是否为异步占位符
}
get child () { // 别名
return this.componentInstance
}
}
这是VNode 类定义的地方, 它支持一共八个参数,其实常用的并不多。 如tag是元素节点的名称, children为它的子节点 text是文本节点内的文本。 实例化后的对象就有共二十三个属性作为vue的内部一个节点的描述,它描述的是将它创建为一个怎样的真实Dom。 大部分属性默认是false 或 undefined, 而通过这些属性有效的值就可以组装出不同的描述, 如真实的Dom中会有元素节点, 文本节点, 注释节点等。 而通过这样一个VNode类,也可以描述出相应的节点, 部分节点vue内部还做了相应的封装:
注释节点
export const createEmptyVNode = (text = '') => {
const node = new VNode()
node.text = text
node.isComment = true
return node
}
- 创建一个空的VNode, 有效属性只有text 和 isComment 来表示一个注释节点。
真实的注释节点:
<!-- 注释节点 -->
VNode描述:
createEmptyVNode ('注释节点')
{
text: '注释节点',
isComment: true
}
文本节点
VNode描述:
createTextVNode('文本节点')
{
text: '文本节点'
}
克隆节点
export function cloneVNode (vnode) {
const cloned = new VNode(
vnode.tag,
vnode.data,
vnode.children,
vnode.text,
vnode.elm,
vnode.context,
vnode.componentOptions,
vnode.asyncFactory
)
cloned.ns = vnode.ns
cloned.isStatic = vnode.isStatic
cloned.key = vnode.key
cloned.isComment = vnode.isComment
cloned.fnContext = vnode.fnContext
cloned.fnOptions = vnode.fnOptions
cloned.fnScopeId = vnode.fnScopeId
cloned.asyncMeta = vnode.asyncMeta
cloned.isCloned = true
return cloned
}
- 将一个现有的VNode 节点拷贝一份, 只是被拷贝节点的isCloned 属性为false, 而拷贝得到的节点的isCloned 属性为 true, 除此之外它们完全相同。
元素节点
真实的元素节点:
<div>
hello
<span>Vue!</span>
</div>
VNode描述:
{
tag: 'div',
children: [
{
text: 'hello'
},
{
tag: 'span',
children: [
{
text: Vue!
}
]
}
],
}
组件节点
渲染App组件:
new Vue({
render(h) {
return h(App)
}
})
VNode描述:
{
tag: 'vue-component-2',
componentInstance: {...},
componentOptions: {...},
context: {...},
data: {...}
}
- 组件的VNode 会和元素节点相比会有两个特有的属性componentInstall 和 componentOptions。 VNode 的类型有很多, 它们都是从这个VNode 类中实例化出来的,只是属性不同。
开始挂载阶段
this._init() 方法的最后:
... 初始化
if (vm.$options.el) {
vm.$mount(vm.$options.el)
}
如果用户有传入el, 就执行 vm. m o u n t 方 法 并 传 入 e l 开 始 挂 载 。 这 里 的 mount 方法并传入el开始挂载。 这里的 mount方法并传入el开始挂载。这里的mount 方法在完整版和运行时版本又会有点不同,它们区别如下:
运行时版本:
Vue.prototype.$mount = function(el) { // 最初的定义
return mountComponent(this, query(el));
}
完整版:
const mount = Vue.prototype.$mount
Vue.prototype.$mount = function(el) { // 拓展编译后的
if(!this.$options.render) { ---|
if(this.$options.template) { ---|
...经过编译器转换后得到render函数 ---| 编译阶段
} ---|
} ---|
return mount.call(this, query(el))
}
-----------------------------------------------
export function query(el) { // 获取挂载的节点
if(typeof el === 'string') { // 比如#app
const selected = document.querySelector(el)
if(!selected) {
return document.createElement('div')
}
return selected
} else {
return el
}
}
完整版有一个骚操作, 首先将==$mount方法缓存到mount== 变量上, 然后使用函数劫持的手段重新定义 $mount 函数, 并在其内部增加编译相关的代码, 最后还是使用原来定义的 $mount 方法挂载。 所以核心是要了解最初定义 $mount 方法时内的 mountComponnet 方法:
export function mountComponent(vm, el) {
vm.$el = el
...
callHook(vm, 'beforeMount')
...
const updateComponent = function () {
vm._update(vm._render())
}
...
}
首先将传入的el 赋值给 vm.$el, 这个时候 el 是一个真实dom, 接着会执行用户自己定义的beforeMount 钩子。 接下来会定义一个重要的函数变量updateComponent, 它的内部首先会执行vm._render()方法, 将返回的结果传入 vm._update 内再执行。 我们这章主要就来分析这个vm._render() 方法做了什么事情, 看下它的定义:
Vue.prototype._render = function() {
const vm = this
const { render } = vm.$options
const vnode = render.call(vm, vm.$createElement)
return vnode
}
首先会得到自定义的render 函数, 传入 vm. c r e a t e E l e m e n t 这 个 方 法 ( 也 就 是 上 面 例 子 内 的 = = h = = 方 法 ) , 将 执 行 的 返 回 结 果 赋 值 给 = = v n o d e = = , 这 里 也 就 完 成 了 = = r e n d e r = = 函 数 内 数 据 结 构 转 为 = = v n o d e = = 的 操 作 。 而 这 个 v m . createElement 这个方法(也就是上面例子内的==h==方法),将执行的返回结果赋值给==vnode== , 这里也就完成了 ==render== 函数内数据结构转为==vnode== 的操作。 而这个 vm. createElement这个方法(也就是上面例子内的==h==方法),将执行的返回结果赋值给==vnode==,这里也就完成了==render==函数内数据结构转为==vnode==的操作。而这个vm.createElement 是在之前初始化 initRender 方法内挂载到 vm 实例下的:
vm._c = (a, b, c, d) => createElement(vm, a, b, c, d, false) // 编译
vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true) // 手写
无论是编译而来还是手动写的render 函数, 它们都是返回了 createElement 这个函数, 继续查找它的定义:
const SIMPLE_NORMALIZE = 1
const ALWAYS_NORMALIZE = 2
export default createElement(
context,
tag,
data,
children,
normalizationType,
alwaysNormalize) {
if(Array.isArray(data) || isPrimitive(data)) { // data是数组或基础类型
normalizationType = children --|
children = data --| 参数移位
data = undefined --|
}
if (isTrue(alwaysNormalize)) { // 如果是手写render
normalizationType = ALWAYS_NORMALIZE
}
return _createElement(contenxt, tag, data, children, normalizationType)
}
这里是对传入的参数处理, 如果第三个参数传入的是数组(子元素)或者基础类型的值, 就将参数位置改变。 然后对传入的最后一个参数是true 还是 false 做处理, 这会决定之后对children 属性的处理方式。这里又是对==_createElement== 做封装 所以我们还是继续看它的定义:
export function _createElement(
context, tag, data, children, normalizationType
) {
if (normalizationType === ALWAYS_NORMALIZE) { // 手写render函数
children = normalizeChildren(children)
} else if (normalizationType === SIMPLE_NORMALIZE) { //编译render函数
children = simpleNormalizeChildren(children)
}
if(typeof tag === 'string') { // 标签
let vnode, Ctor
if(config.isReservedTag(tag)) { // 如果是html标签
vnode = new VNode(tag, data, children, undefined, undefined, context)
}
...
} else { // 就是组件了
vnode = createComponent(tag, data, context, children)
}
...
return vnode
}
首先我们会看到针对最后一个参数的布尔值对children 做不同的处理, 如果是编译render 函数, 就将children 格式化为一维数组:
function simpleNormalizeChildren(children) { // 编译render的处理函数
for (let i = 0; i < children.length; i++) {
if (Array.isArray(children[i])) {
return Array.prototype.concat.apply([], children)
}
}
return children
}
我们现在主要看下手写的render 函数是怎么处理的, 从接下来的==_createElement== 方法我们知道, 转化VNode 是分两种情况的:
普通的元素节点转化为 VNode
以一段children 是二维数组代码为例, 我们来说明普通元素是如何转VNode的:
render(h) {
return h(
"div",
[
[
[h("h1", "title h1")],
[h('h2', "title h2")]
],
[
h('h3', 'title h3')
]
]
);
}
因为==_createElement== 方法是对h 方法的封装, 所以h 方法的第一个参数对应的就是==_createElement== 方法内的tag, 第二个参数对应的是data。 又因为h 方法是递归的, 所以首先从h(‘h1’, ‘title h1’) 开始解析, 经过参数上移后children 就是title h1 这段文本了, 所以会在normalizeChildren 方法将它转为 [createTextVNode(children)] 一个文本的VNode节点:
function normalizeChildren(children) { // 手写`render`的处理函数
return isPrimitive(children) //原始类型 typeof为string/number/symbol/boolean之一
? [createTextVNode(children)] // 转为数组的文本节点
: Array.isArray(children) // 如果是数组
? normalizeArrayChildren(children)
: undefined
}
接着会满足==_createElement== 方法内的这个条件:
if(typeof tag === 'string'){ tag为h1标签
if(config.isReservedTag(tag)) { // 是html标签
vnode = new VNode(
tag, // h1
data, // undefined
children, 转为了 [{text: 'title h1'}]
undefined,
undefined,
context
)
}
}
...
return vnode
返回的vnode结构为:
{
tag: h1,
children: [
{ text: title h1 }
]
}
然后依次处理h(‘h2’, ‘title h2’), h(‘h3’, ‘title h3’) 会得到三个VNode 实例的节点。 接着会执行最外层的h(div, [[VNode, Vnode], [VNode]])方法, 注意它的结构是二维数组, 这个时候它就满足normalizeChildren 方法内的Array.isArray(children) 这个条件了, 会执行normalizeArrayChildren 这个方法:
function normalizeArrayChildren(children) {
const res = [] // 存放结果
for(let i = 0; i < children.length; i++) { // 遍历每一项
let c = children[i]
if(isUndef(c) || typeof c === 'boolean') { // 如果是undefined 或 布尔值
continue // 跳过
}
if(Array.isArray(c)) { // 如果某一项是数组
if(c.length > 0) {
c = normalizeArrayChildren(c) // 递归结果赋值给c,结果就是[VNode]
... 合并相邻的文本节点
res.push.apply(res, c) //小操作
}
} else {
...
res.push(c)
}
}
return res
}
如果children 内的某一项是数组就递归调用自己, 将自己传入并将返回的结果覆盖自身, 递归内的结果就是res.push©得到的, 这里c 也是 [VNode] 数组结构。 覆盖自己之后执行res.push.apply(res, c) ,添加到res 内。 这里vue秀了一个小操作, 在一个数组内push一个数组, 本来应该是二维数组的, 使用这个写法后res.push.apply(res, c), 结果最后就是一维数组了。 res 最后返回的结果==[VNode, VNode, VNode], 这也是children== 最终的样子。 接着执行h(‘div’, [VNode,VNode, VNode]) 方法, 又满足了之前同样的条件:
if (config.isReservedTag(tag)) { // 标签为div
vnode = new VNode(
tag, data, children, undefined, undefined, context
)
}
return vnode
所以最终得到的vnode结构就是这样的:
{
tag: 'div',
children: [VNode, VNode, VNode]
}
以上就是普通元素节点转VNode的具体过程。
组件转化为 VNode
接下来我们了解VNode 的创建过程, 常见示例如下:
main.js
new Vue({
render(h) {
return h(App)
}
})
app.vue
import Child from '@/pages/child'
export default {
name: 'app',
components: {
Child
}
}
我们在main.js 内打印下App 组件:
{
beforeCreate: [ƒ]
beforeDestroy: [ƒ]
components: {Child: {…}}
name: "app"
render: ƒ ()
staticRenderFns: []
__file: "src/App.vue"
_compiled: true
}
我们只是定义了name 和 componnets 属性, 打印出来为什么会多了这么多属性? 这是vue-loader解析后添加的, 例如: ==render: f() == 就是将 App 组件的 template 模板转换而来的, 我们记住这个组件对象即可。
我们再来简单看一下==_createElement== 函数:
export function _createElement(
context, tag, data, children, normalizationType
) {
...
if(typeof tag === 'string') { // 标签
...
} else { // 就是组件了
vnode = createComponent(
tag, // 组件对象
data, // undefined
context, // 当前vm实例
children // undefined
)
}
...
return vnode
}
很显然这里tag并不是一个string , 转而会调用createComponent() 方法:
export function createComponent ( // 上
Ctor, data = {}, context, children, tag
) {
const baseCtor = context.$options._base
if (isObject(Ctor)) { // 组件对象
Ctor = baseCtor.extend(Ctor) // 转为Vue的子类
}
...
}
这里要补充一点, 在new Vue() 之前定义全局API时:
export function initGlobalAPI(Vue) {
...
Vue.options._base = Vue
Vue.extend = function(extendOptions){...}
}
经过初始化合并options 之后,当前实例就有了context.$options._base 这个属性, 然后执行它的extend 这个方法, 传入我们的组件对象, 看下extend 方法的定义:
Vue.cid = 0
let cid = 1
Vue.extend = function (extendOptions = {}) {
const Super = this // Vue基类构造函数
const name = extendOptions.name || Super.options.name
const Sub = function (options) { // 定义构造函数
this._init(options) // _init继承而来
}
Sub.prototype = Object.create(Super.prototype) // 继承基类Vue初始化定义的原型方法
Sub.prototype.constructor = Sub // 构造函数指向子类
Sub.cid = cid++
Sub.options = mergeOptions( // 子类合并options
Super.options, // components, directives, filters, _base
extendOptions // 传入的组件对象
)
Sub['super'] = Super // Vue基类
// 将基类的静态方法赋值给子类
Sub.extend = Super.extend
Sub.mixin = Super.mixin
Sub.use = Super.use
ASSET_TYPES.forEach(function (type) { // ['component', 'directive', 'filter']
Sub[type] = Super[type]
})
if (name) { 让组件可以递归调用自己,所以一定要定义name属性
Sub.options.components[name] = Sub // 将子类挂载到自己的components属性下
}
Sub.superOptions = Super.options
Sub.extendOptions = extendOptions
return Sub
}
仔细观察extend 这个方法不难发现, 我们传入的组件对象相当于就是之前new Vue(options) 里面的options, 也就是用户定义的配置, 然后和Vue 之前就定义的原型方法以及全局API合并, 然后返回一个新的构造函数,它拥有Vue 完整的功能。 让我们继续createComponent 的其他逻辑:
export function createComponent ( // 中
Ctor, data = {}, context, children, tag
) {
...
const listeners = data.on // 父组件v-on传递的事件对象格式
data.on = data.nativeOn // 组件的原生事件
installComponentHooks(data) // 为组件添加钩子方法
...
}
之前说明初始化事件initEvents时,这里的data.on 就是父组件传递给子组件的事件对象, 复制给变量listeners;data.nativeOn 是绑定在组件上有native 修饰符的事件。 接着会执行一个组件比较重要的方法installComponentHooks, 它的作用是往组件的data属性下挂载hook这个对象, 里面有init, prepatch, insert, destroy 四个方法, 这四个方法会在之后的将VNode转为真实Dom 的 patch 阶段会用到。 我们继续createComponent的其他逻辑:
export function createComponent ( // 下
Ctor, data = {}, context, children, tag
) {
...
const name = Ctor.options.name || tag // 拼接组件tag用
const vnode = new VNode( // 创建组件VNode
`vue-component-${Ctor.cid}${name ? `-${name}` : ''}`, // 对应tag属性
data, // 有父组件传递自定义事件和挂载的hook对象
undefined, // 对应children属性
undefined, // 对应text属性
undefined, // 对应elm属性
context, // 当前实例
{ // 对应componentOptions属性
Ctor, // 子类构造函数
propsData, // props具体值的对象集合
listeners, // 父组件传递自定义事件对象集合
tag, // 使用组件时的名称
children // 插槽内的内容,也是VNode格式
},
asyncFactory
)
return vnode
}
组件生成的VNode 如下:
{
tag: 'vue-component-1-app',
context: {...},
componentOptions: {
Ctor: function(){...},
propsData: undefined,
children: undefined,
tag: undefined,
children: undefined
},
data: {
on: undefined, // 为原生事件
data: {
init: function(){...},
insert: function(){...},
prepatch: function(){...},
destroy: function(){...}
}
}
}
如果看到tag 属性是vue-component 开头就是组件了, 以上就是组件VNode 的初始化。 简单理解就是如果h函数的参数是组件对象,就将它转为一个Vue 的子类, 虽然组件VNode 的 children, text, ele == 为 undefined, 但它的独有属性componentOptions== 保存了组件需要的相关信息。 它们的VNode 生成了。
接下来的章节将使用它们,将它们变为真实的Dom。
最后我们以一道Vue 问题结束本章的内容
- 请问Vue@2 为什么引入虚拟Dom, 谈谈对虚拟Dom 的理解?
解答:
-
随着现代应用对页面功能要求越复杂, 管理的状态越多, 如果还是使用之前的JavaScript 线程去频繁操作GUI 线程的索大Dom, 对性能会有很大的损耗, 而且会造成状态难以管理,逻辑混乱等情况。 引入虚拟Dom 后, 在框架的内部将虚拟Dom树型结构与真实Dom做了映射, 让我们不用再命令式的去操作Dom ,可以将重心转去维护这可树形结构内的状态即可, 状态的变化就会驱动Dom发生改变, 具体的Dom 操作Vue帮我们完成, 而且这些大部分可以在JavaScript 线程完成, 性能更高。
-
虚拟Dom只是一种数据结构, 可以让它不仅仅使用在浏览器环境, 还可以用于SSR以及Weex等场景。
更多推荐
所有评论(0)