共计 10059 个字符,预计需要花费 26 分钟才能阅读完成。
继上一节内容,咱们将
Vue
简单的挂载流程通过图解流程,代码剖析的形式简略梳理了一遍,最初也讲到了模板编译的大抵流程。然而在挂载的外围处,咱们并没有剖析模板编译后渲染函数是如何转换为可视化DOM
节点的。因而这一章节,咱们将从新回到Vue
实例挂载的最初一个环节:渲染DOM
节点。在渲染实在DOM
的过程中,Vue
引进了虚构DOM
的概念,这是Vue
架构设计中另一个重要的理念。虚构DOM
作为JS
对象和实在DOM
两头的一个缓冲层,对JS
频繁操作DOM
的引起的性能问题有很好的缓解作用。
4.1 Virtual DOM
4.1.1 浏览器的渲染流程
当浏览器接管到一个 Html
文件时,JS
引擎和浏览器的渲染引擎便开始工作了。从渲染引擎的角度,它首先会将 html
文件解析成一个 DOM
树,与此同时,浏览器将辨认并加载 CSS
款式,并和 DOM
树一起合并为一个渲染树。有了渲染树后,渲染引擎将计算所有元素的地位信息,最初通过绘制,在屏幕上打印最终的内容。JS
引擎和渲染引擎尽管是两个独立的线程,然而 JS 引擎却能够触发渲染引擎工作,当咱们通过脚本去批改元素地位或外观时,JS
引擎会利用 DOM
相干的 API
办法去操作 DOM
对象, 此时渲染引擎变开始工作,渲染引擎会触发回流或者重绘。上面是回流重绘的两个概念:
- 回流:当咱们对
DOM
的批改引发了元素尺寸的变动时,浏览器须要从新计算元素的大小和地位,最初将从新计算的后果绘制进去,这个过程称为回流。 - 重绘:当咱们对
DOM
的批改只单纯扭转元素的色彩时,浏览器此时并不需要从新计算元素的大小和地位,而只有从新绘制新款式。这个过程称为重绘。
很显然回流比重绘更加消耗性能。
通过理解浏览器根本的渲染机制,咱们很容易联想到当一直的通过 JS
批改 DOM
时,不经意间会触发到渲染引擎的回流或者重绘,这个性能开销是十分微小的。因而为了升高开销,咱们须要做的是尽可能减少 DOM
操作。有什么办法能够做到呢?
4.1.2 缓冲层 - 虚构 DOM
虚构 DOM
是为了解决频繁操作 DOM
引发性能问题的产物。虚构 DOM
(上面称为Virtual DOM
) 是将页面的状态形象为 JS
对象的模式,实质上是 JS
和实在 DOM
的中间层,当咱们想用 JS
脚本大批量进行 DOM
操作时,会优先作用于 Virtual DOM
这个 JS
对象,最初通过比照将要改变的局部告诉并更新到实在的 DOM
。只管最终还是操作实在的DOM
,但Virtual DOM
能够将多个改变合并成一个批量的操作,从而缩小 DOM
重排的次数,进而缩短了生成渲染树和绘制所花的工夫。
咱们看一个实在的 DOM
蕴含了什么:
浏览器将一个实在 DOM
设计得很简单,不仅蕴含了本身的属性形容,大小地位等定义,也囊括了 DOM
领有的浏览器事件等。正因为如此简单的构造,咱们频繁去操作 DOM
或多或少会带来浏览器的性能问题。而作为数据和实在 DOM
之间的一层缓冲,Virtual DOM
只是用来映射到实在 DOM
的渲染,因而不须要蕴含操作 DOM
的办法,它只有在对象中重点关注几个属性即可。
// 实在 DOM | |
<div id="real"><span>dom</span></div> | |
// 实在 DOM 对应的 JS 对象 | |
{ | |
tag: 'div', | |
data: {id: 'real'}, | |
children: [{ | |
tag: 'span', | |
children: 'dom' | |
}] | |
} |
4.2 Vnode
Vue
在渲染机制的优化上,同样引进了 virtual dom
的概念,它是用 Vnode
这个构造函数去形容一个 DOM
节点。
4.2.1 Vnode 构造函数
var VNode = function VNode (tag,data,children,text,elm,context,componentOptions,asyncFactory) { | |
this.tag = tag; // 标签 | |
this.data = data; // 数据 | |
this.children = children; // 子节点 | |
this.text = text; | |
··· | |
··· | |
}; |
Vnode
定义的属性差不多有 20 几个,显然用 Vnode
对象要比实在 DOM
对象形容的内容要简略得多,它只用来单纯形容节点的要害属性,例如标签名,数据,子节点等。并没有保留跟浏览器相干的 DOM
办法。除此之外,Vnode
也会有其余的属性用来扩大 Vue
的灵活性。
源码中也定义了创立 Vnode
的相干办法。参考 Vue3 源码视频解说:进入学习
4.2.2 创立 Vnode 正文节点
// 创立正文 vnode 节点 | |
var createEmptyVNode = function (text) {if ( text === void 0) text = ''; | |
var node = new VNode(); | |
node.text = text; | |
node.isComment = true; // 标记正文节点 | |
return node | |
}; |
4.2.3 创立 Vnode 文本节点
// 创立文本 vnode 节点 | |
function createTextVNode (val) {return new VNode(undefined, undefined, undefined, String(val)) | |
} |
4.2.4 克隆 vnode
function cloneVNode (vnode) { | |
var cloned = new VNode( | |
vnode.tag, | |
vnode.data, | |
vnode.children && vnode.children.slice(), | |
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 | |
} |
留神:cloneVnode
对 Vnode
的克隆只是一层浅拷贝,它不会对子节点进行深度克隆。
4.3 Virtual DOM 的创立
先简略回顾一下挂载的流程,挂载的过程是调用 Vue
实例上 $mount
办法,而 $mount
的外围是 mountComponent
函数。如果咱们传递的是 template
模板,模板会先通过编译器的解析,并最终依据不同平台生成对应代码,此时对应的就是将 with
语句封装好的 render
函数; 如果传递的是 render
函数,则跳过模板编译过程,间接进入下一个阶段。下一阶段是拿到 render
函数,调用 vm._render()
办法将 render
函数转化为 Virtual DOM
,并最终通过vm._update()
办法将 Virtual DOM
渲染为实在的 DOM
节点。
Vue.prototype.$mount = function(el, hydrating) { | |
··· | |
return mountComponent(this, el) | |
} | |
function mountComponent() { | |
··· | |
updateComponent = function () {vm._update(vm._render(), hydrating); | |
}; | |
} |
咱们先看看 vm._render()
办法是如何 将 render 函数转化为 Virtual DOM的。
回顾一下第一章节内容,文章介绍了 Vue
在代码引入时会定义很多属性和办法,其中有一个 renderMixin
过程,咱们之前只提到了它会定义跟渲染无关的函数,实际上它只定义了两个重要的办法,_render
函数就是其中一个。
// 引入 Vue 时,执行 renderMixin 办法,该办法定义了 Vue 原型上的几个办法,其中一个便是 _render 函数 | |
renderMixin();// | |
function renderMixin() {Vue.prototype._render = function() { | |
var ref = vm.$options; | |
var render = ref.render; | |
··· | |
try {vnode = render.call(vm._renderProxy, vm.$createElement); | |
} catch (e) {···} | |
··· | |
return vnode | |
} | |
} |
抛开其余代码,_render 函数的外围是 render.call(vm._renderProxy, vm.$createElement)
局部,vm._renderProxy
在数据代理剖析过,实质上是为了做数据过滤检测,它也绑定了 render
函数执行时的 this
指向。vm.$createElement
办法会作为 render
函数的参数传入。回顾一下,在手写 render
函数时,咱们会利用 render
函数的第一个参数 createElement
进行渲染函数的编写,这里的 createElement
参数就是定义好的 $createElement
办法。
new Vue({ | |
el: '#app', | |
render: function(createElement) {return createElement('div', {}, this.message) | |
}, | |
data() { | |
return {message: 'dom'} | |
} | |
}) |
初始化 _init
时,有一个 initRender
函数,它就是用来定义渲染函数办法的,其中就有 vm.$createElement
办法的定义,除了 $createElement
,_c
办法的定义也相似。其中 vm._c
是 template
外部编译成 render
函数时调用的办法,vm.$createElement
是手写 render
函数时调用的办法。两者的惟一区别仅仅是最初一个参数的不同。通过模板生成的 render
办法能够保障子节点都是 Vnode
,而手写的render
须要一些测验和转换。
function initRender(vm) {vm._c = function(a, b, c, d) {return createElement(vm, a, b, c, d, false); } | |
vm.$createElement = function (a, b, c, d) {return createElement(vm, a, b, c, d, true); }; | |
} |
createElement
办法实际上是对 _createElement
办法的封装,在调用 _createElement
前,它会先对传入的参数进行解决,毕竟手写的 render
函数参数规格不对立。举一个简略的例子。
// 没有 data | |
new Vue({ | |
el: '#app', | |
render: function(createElement) {return createElement('div', this.message) | |
}, | |
data() { | |
return {message: 'dom'} | |
} | |
}) | |
// 有 data | |
new Vue({ | |
el: '#app', | |
render: function(createElement) {return createElement('div', {}, this.message) | |
}, | |
data() { | |
return {message: 'dom'} | |
} | |
}) |
这里如果第二个参数是变量或者数组,则默认是没有传递 data
, 因为data
个别是对象模式存在。
function createElement ( | |
context, // vm 实例 | |
tag, // 标签 | |
data, // 节点相干数据,属性 | |
children, // 子节点 | |
normalizationType, | |
alwaysNormalize // 辨别外部编译生成的 render 还是手写 render | |
) { | |
// 对传入参数做解决,如果没有 data,则将第三个参数作为第四个参数应用,往上类推。if (Array.isArray(data) || isPrimitive(data)) { | |
normalizationType = children; | |
children = data; | |
data = undefined; | |
} | |
// 依据是 alwaysNormalize 辨别是外部编译应用的,还是用户手写 render 应用的 | |
if (isTrue(alwaysNormalize)) {normalizationType = ALWAYS_NORMALIZE;} | |
return _createElement(context, tag, data, children, normalizationType) // 真正生成 Vnode 的办法 | |
} |
4.3.1 数据标准检测
Vue
既然裸露给用户用 render
函数去手写渲染模板,就须要思考用户操作带来的不确定性,因而 _createElement
在创立 Vnode
前会先数据的规范性进行检测,将不非法的数据类型谬误提前裸露给用户。接下来将列举几个在理论场景中容易犯的谬误,也不便咱们了解源码中对这类谬误的解决。
- 用响应式对象做
data
属性
new Vue({ | |
el: '#app', | |
render: function (createElement, context) {return createElement('div', this.observeData, this.show) | |
}, | |
data() { | |
return { | |
show: 'dom', | |
observeData: { | |
attr: {id: 'test'} | |
} | |
} | |
} | |
}) |
- 当非凡属性 key 的值为非字符串,非数字类型时
new Vue({ | |
el: '#app', | |
render: function(createElement) {return createElement('div', { key: this.lists}, this.lists.map(l => {return createElement('span', l.name) | |
})) | |
}, | |
data() { | |
return { | |
lists: [{name: '111'}, | |
{name: '222'} | |
], | |
} | |
} | |
}) |
这些标准都会在创立 Vnode
节点之前发现并报错,源代码如下:
function _createElement (context,tag,data,children,normalizationType) { | |
// 1. 数据对象不能是定义在 Vue data 属性中的响应式数据。if (isDef(data) && isDef((data).__ob__)) { | |
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() // 返回正文节点} | |
if (isDef(data) && isDef(data.is)) {tag = data.is;} | |
if (!tag) { | |
// 避免动静组件 :is 属性设置为 false 时,须要做非凡解决 | |
return createEmptyVNode()} | |
// 2. key 值只能为 string,number 这些原始数据类型 | |
if (isDef(data) && isDef(data.key) && !isPrimitive(data.key) | |
) { | |
{ | |
warn( | |
'Avoid using non-primitive value as key,' + | |
'use string/number value instead.', | |
context | |
); | |
} | |
} | |
··· | |
} |
这些规范性检测保障了后续 Virtual DOM tree
的残缺生成。
4.3.2 子节点 children 规范化
Virtual DOM tree
是由每个 Vnode
以树状模式拼成的虚构 DOM
树,咱们在转换实在节点时须要的就是这样一个残缺的 Virtual DOM tree
,因而咱们须要保障每一个子节点都是Vnode
类型, 这里分两种场景剖析。
- 模板编译
render
函数,实践上template
模板通过编译生成的render
函数都是Vnode
类型,然而有一个例外,函数式组件返回的是一个数组 (这个非凡例子,能够看函数式组件的文章剖析), 这个时候Vue
的解决是将整个children
拍平成一维数组。 - 用户定义
render
函数,这个时候又分为两种状况,一个是当chidren
为文本节点时,这时候通过后面介绍的createTextVNode
创立一个文本节点的VNode
; 另一种绝对简单,当children
中有v-for
的时候会呈现嵌套数组,这时候的解决逻辑是,遍历children
,对每个节点进行判断,如果仍旧是数组,则持续递归调用,直到类型为根底类型时,调用createTextVnode
办法转化为Vnode
。这样通过递归,children
也变成了一个类型为Vnode
的数组。
function _createElement() { | |
··· | |
if (normalizationType === ALWAYS_NORMALIZE) { | |
// 用户定义 render 函数 | |
children = normalizeChildren(children); | |
} else if (normalizationType === SIMPLE_NORMALIZE) { | |
// 模板编译生成的的 render 函数 | |
children = simpleNormalizeChildren(children); | |
} | |
} | |
// 解决编译生成的 render 函数 | |
function simpleNormalizeChildren (children) {for (var i = 0; i < children.length; i++) { | |
// 子节点为数组时,进行开平操作,压成一维数组。if (Array.isArray(children[i])) {return Array.prototype.concat.apply([], children) | |
} | |
} | |
return children | |
} | |
// 解决用户定义的 render 函数 | |
function normalizeChildren (children) { | |
// 递归调用,直到子节点是根底类型,则调用创立文本节点 Vnode | |
return isPrimitive(children) | |
? [createTextVNode(children)] | |
: Array.isArray(children) | |
? normalizeArrayChildren(children) | |
: undefined | |
} | |
// 判断是否根底类型 | |
function isPrimitive (value) { | |
return ( | |
typeof value === 'string' || | |
typeof value === 'number' || | |
typeof value === 'symbol' || | |
typeof value === 'boolean' | |
) | |
} |
4.3.4 理论场景
在数据检测和组件规范化后,接下来通过 new VNode()
便能够生成一棵残缺的 VNode
树,留神在 _render
过程中会遇到子组件,这个时候会优先去做子组件的初始化,这部分放到组件环节专门剖析。咱们用一个理论的例子,完结 render
函数到 Virtual DOM
的剖析。
template
模板模式
var vm = new Vue({ | |
el: '#app', | |
template: '<div><span>virtual dom</span></div>' | |
}) |
- 模板编译生成
render
函数
(function() {with(this){return _c('div',[_c('span',[_v("virual dom")])]) | |
} | |
}) |
Virtual DOM tree
的后果(省略版)
{ | |
tag: 'div', | |
children: [{ | |
tag: 'span', | |
children: [{ | |
tag: undefined, | |
text: 'virtual dom' | |
}] | |
}] | |
} |
4.4 虚构 Vnode 映射成实在 DOM
回到 updateComponent
的最初一个过程, 虚构的 DOM
树在生成 virtual dom
后,会调用 Vue
原型上 _update
办法,将虚构 DOM
映射成为实在的 DOM
。从源码上能够晓得,_update
的调用机会有两个,一个是产生在首次渲染阶段,另一个产生数据更新阶段。
updateComponent = function () { | |
// render 生成虚构 DOM,update 渲染实在 DOM | |
vm._update(vm._render(), hydrating); | |
}; |
vm._update
办法的定义在 lifecycleMixin
中。
lifecycleMixin() | |
function lifecycleMixin() {Vue.prototype._update = function (vnode, hydrating) { | |
var vm = this; | |
var prevEl = vm.$el; | |
var prevVnode = vm._vnode; // prevVnode 为旧 vnode 节点 | |
// 通过是否有旧节点判断是首次渲染还是数据更新 | |
if (!prevVnode) { | |
// 首次渲染 | |
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false) | |
} else { | |
// 数据更新 | |
vm.$el = vm.__patch__(prevVnode, vnode); | |
} | |
} |
_update
的外围是 __patch__
办法,如果是服务端渲染,因为没有 DOM
,_patch
办法是一个空函数,在有 DOM
对象的浏览器环境下,__patch__
是 patch
函数的援用。
// 浏览器端才有 DOM,服务端没有 dom,所以 patch 为一个空函数 | |
Vue.prototype.__patch__ = inBrowser ? patch : noop; |
而 patch
办法又是 createPatchFunction
办法的返回值,createPatchFunction
办法传递一个对象作为参数,对象领有两个属性,nodeOps
和 modules
,nodeOps
封装了一系列操作原生 DOM
对象的办法。而 modules
定义了模块的钩子函数。
var patch = createPatchFunction({nodeOps: nodeOps, modules: modules}); | |
// 将操作 dom 对象的办法合集做解冻操作 | |
var nodeOps = /*#__PURE__*/Object.freeze({ | |
createElement: createElement$1, | |
createElementNS: createElementNS, | |
createTextNode: createTextNode, | |
createComment: createComment, | |
insertBefore: insertBefore, | |
removeChild: removeChild, | |
appendChild: appendChild, | |
parentNode: parentNode, | |
nextSibling: nextSibling, | |
tagName: tagName, | |
setTextContent: setTextContent, | |
setStyleScope: setStyleScope | |
}); | |
// 定义了模块的钩子函数 | |
var platformModules = [ | |
attrs, | |
klass, | |
events, | |
domProps, | |
style, | |
transition | |
]; | |
var modules = platformModules.concat(baseModules); |
真正的 createPatchFunction
函数有一千多行代码,这里就不不便列举进去了,它的外部首先定义了一系列辅助的办法,而外围是通过调用 createElm
办法进行 dom
操作,创立节点,插入子节点,递归创立一个残缺的 DOM
树并插入到 Body
中。并且在产生实在阶段阶段,会有 diff
算法来判断前后 Vnode
的差别,以求最小化扭转实在阶段。前面会有一个章节的内容去解说 diff
算法。createPatchFunction
的过程只须要先记住一些论断,函数外部会调用封装好的 DOM api
,依据Virtual DOM
的后果去生成实在的节点。其中如果遇到组件 Vnode
时,会递归调用子组件的挂载过程,这个过程咱们也会放到前面章节去剖析。
4.5 小结
这一节剖析了 mountComponent
的两个外围办法,render
和 update
, 在剖析前重点介绍了存在于JS
操作和 DOM
渲染的桥梁:Virtual DOM
。JS
对 DOM
节点的批量操作会先间接反馈到 Virtual DOM
这个形容对象上, 最终的后果才会间接作用到实在节点上。能够说,Virtual DOM
很大水平进步了渲染的性能。文章重点介绍了 render
函数转换成 Virtual DOM
的过程,并大抵形容了 _update
函数的实现思路。其实这两个过程都牵扯到组件,所以这一节对很多环节都无奈深入分析,下一节开始会进入组件的专题。我置信剖析完组件后,读者会对整个渲染过程会有更粗浅的了解和思考。