繼上一節內容,我們將
Vue
複雜的掛載流程通過圖解流程,程式碼分析的方式簡單梳理了一遍,其中也講到了模板編譯的大致流程,然而在掛載的核心處,我們並沒有分析模板編譯後函式如何渲染為視覺化的DOM
節點。這一節,我們將重新回到Vue
例項掛載的最後一個環節:渲染DOM
節點。在渲染真實DOM的過程中,Vue
引進了虛擬DOM
的概念,虛擬DOM
作為JS
物件和真實DOM
中間的一個緩衝存,極大的優化了JS
頻繁操作DOM
的效能問題,接下來我們將慢慢展開分析。
4.1 Virtual DOM
4.1.1 瀏覽器的渲染流程
當瀏覽器接收到一個Html
檔案時,JS引擎和瀏覽器的渲染引擎便開始工作了。從渲染引擎的角度,它首先會將html
檔案解析成一個DOM
樹,與此同時,瀏覽器將識別並載入CSS
樣式,並和DOM
樹一起合併為一個渲染樹。有了渲染樹後,渲染引擎將計算所有元素的位置資訊,最後通過繪製,在螢幕上列印最終的內容。而JS
引擎的作用是通過DOM
相關的API
去操作DOM
物件,而當我們操作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幾個,這裡列舉大部分屬性,只重點關注幾個關鍵屬性:標籤名,資料,子節點。其他的屬性都是用來擴充套件Vue的靈活性。
除此之外,原始碼中還定義了Vnode的其他方法
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
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
);
···
return cloned
}
複製程式碼
4.3 Vnode的建立
先簡單回顧一下掛載的流程,掛載的過程呼叫的是Vue
例項上$mount
方法,而$mount
的核心是mountComponent
方法。在這之前,如果我們傳遞的是template
模板,會經過一系列的模板編譯過程,並根據不同平臺生成對應程式碼,瀏覽器對應的是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方法,該方法定義了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.$createElement
方法會作為render函式的引數傳入。這個引數也是在手寫render
函式時使用的createElement
引數的由來
new Vue({
el: '#app',
render: function(createElement) {
return createElement('div', {}, this.message)
},
data() {
return {
message: 'dom'
}
}
})
複製程式碼
vm.$createElement
是Vue
中initRender
所定義的方法,其中 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
建立Vnode
之前,會對傳入的引數進行處理。例如當沒有data
資料時,引數會往前填充。
function createElement (
context, // vm 例項
tag, // 標籤
data, // 節點相關資料,屬性
children, // 子節點
normalizationType,
alwaysNormalize // 區分內部編譯生成的render還是手寫render
) {
// 對傳入引數做處理,可以沒有data,如果沒有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
函式去寫渲染模板,就需要考慮使用者操作帶來的不確定性,因此在生成Vnode
的過程中,_createElement
會先進行資料規範的檢測,將不合法的資料型別錯誤提前暴露給使用者。接下來將列舉幾個容易犯錯誤的實際場景,方便理解原始碼中如何處理這類錯誤的。
-
- 用響應式物件做節點屬性
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) {
// 資料物件不能是定義在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() // 返回註釋節點
}
// 針對動態元件 :is 的特殊處理,元件相關知識放到特定章節分析。
if (isDef(data) && isDef(data.is)) {
tag = data.is;
}
if (!tag) {
// 防止動態元件 :is 屬性設定為false時,需要做特殊處理
return createEmptyVNode()
}
// 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
);
}
}
···
// 省略後續操作
}
複製程式碼
4.3.2 子節點children規範化
Virtual DOM
需要保證每一個子節點都是Vnode
型別,這裡分兩種場景。
- 1.
render
函式編譯,理論上通過render
函式編譯生成的都是Vnode
型別,但是有一個例外,函式式元件返回的是一個陣列(關於元件,以及函式式元件內容,我們放到專門講元件的時候專題分析),這個時候Vue
的處理是將整個children
拍平。 - 2.使用者定
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'
)
}
複製程式碼
=== 進行資料檢測和元件規範化後,接下來通過new VNode
便可以生成一棵```VNode``樹。===具體細節由於篇幅原因,不展開分析。
4.4 虛擬Vnode對映成真實DOM - update
回到 updateComponent
的最後一個過程,虛擬的DOM
樹virtual dom
生成後,呼叫Vue
原型上_update
方法,將虛擬DOM
對映成為真實的DOM
。
updateComponent = function () {
// render生成虛擬DOM,update渲染真實DOM
vm._update(vm._render(), hydrating);
};
複製程式碼
從原始碼上可以知道,update
主要有兩個呼叫時機,一個是初次資料渲染時,另一個是資料更新時觸發真實DOM
更新。這一節只分析初次渲染的操作,資料更新放到響應式系統中展開。
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__
方法,而__patch__
來源於:
// 瀏覽器端才有DOM,服務端沒有dom,所以patch為一個空函式
Vue.prototype.__patch__ = inBrowser ? patch : noop;
複製程式碼
patch
方法又是createPatchFunction
方法的返回值,createPatchFunction
內部定義了一系列輔助的方法,但其核心是通過呼叫createEle
方法,createEle
會呼叫一系列封裝好的原生DOM
的API
進行dom
操作,建立節點,插入子節點,遞迴建立一個完整的DOM
樹並插入到Body
中。這部分邏輯分支較為複雜,在原始碼上打debugger
並根據實際場景跑不同的分支有助於理解這部分的邏輯。內容較多就不一一展開。
總結
這一節分析了mountComponent
的兩個核心方法,render
和update
,他們分別完成對render
函式轉化為Virtual DOM
和將Virtual DOM
對映為真實DOM
的過程。整個渲染過程邏輯相對也是比較清晰的。