繼上一節內容,我們將
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
的相關方法。
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
函式的實現思路。其實這兩個過程都牽扯到元件,所以這一節對很多環節都無法深入分析,下一節開始會進入元件的專題。我相信分析完元件後,讀者會對整個渲染過程會有更深刻的理解和思考。
- 深入剖析Vue原始碼 - 選項合併(上)
- 深入剖析Vue原始碼 - 選項合併(下)
- 深入剖析Vue原始碼 - 資料代理,關聯子父元件
- 深入剖析Vue原始碼 - 例項掛載,編譯流程
- 深入剖析Vue原始碼 - 完整渲染過程
- 深入剖析Vue原始碼 - 元件基礎
- 深入剖析Vue原始碼 - 元件進階
- 深入剖析Vue原始碼 - 響應式系統構建(上)
- 深入剖析Vue原始碼 - 響應式系統構建(中)
- 深入剖析Vue原始碼 - 響應式系統構建(下)
- 深入剖析Vue原始碼 - 來,跟我一起實現diff演算法!
- 深入剖析Vue原始碼 - 揭祕Vue的事件機制
- 深入剖析Vue原始碼 - Vue插槽,你想了解的都在這裡!
- 深入剖析Vue原始碼 - 你瞭解v-model的語法糖嗎?
- 深入剖析Vue原始碼 - Vue動態元件的概念,你會亂嗎?
- 徹底搞懂Vue中keep-alive的魔法(上)
- 徹底搞懂Vue中keep-alive的魔法(下)