繼上一節內容,咱們將
Vue
複雜的掛載流程經過圖解流程,代碼分析的方式簡單梳理了一遍,最後也講到了模板編譯的大體流程。然而在掛載的核心處,咱們並無分析模板編譯後渲染函數是如何轉換爲可視化DOM
節點的。所以這一章節,咱們將從新回到Vue
實例掛載的最後一個環節:渲染DOM
節點。在渲染真實DOM
的過程當中,Vue
引進了虛擬DOM
的概念,這是Vue
架構設計中另外一個重要的理念。虛擬DOM
做爲JS
對象和真實DOM
中間的一個緩衝層,對JS
頻繁操做DOM
的引發的性能問題有很好的緩解做用。html
當瀏覽器接收到一個Html
文件時,JS
引擎和瀏覽器的渲染引擎便開始工做了。從渲染引擎的角度,它首先會將html
文件解析成一個DOM
樹,與此同時,瀏覽器將識別並加載CSS
樣式,並和DOM
樹一塊兒合併爲一個渲染樹。有了渲染樹後,渲染引擎將計算全部元素的位置信息,最後經過繪製,在屏幕上打印最終的內容。JS
引擎和渲染引擎雖然是兩個獨立的線程,可是JS引擎卻能夠觸發渲染引擎工做,當咱們經過腳本去修改元素位置或外觀時,JS
引擎會利用DOM
相關的API
方法去操做DOM
對象,此時渲染引擎變開始工做,渲染引擎會觸發迴流或者重繪。下面是迴流重繪的兩個概念:node
DOM
的修改引起了元素尺寸的變化時,瀏覽器須要從新計算元素的大小和位置,最後將從新計算的結果繪製出來,這個過程稱爲迴流。DOM
的修改只單純改變元素的顏色時,瀏覽器此時並不須要從新計算元素的大小和位置,而只要從新繪製新樣式。這個過程稱爲重繪。很顯然迴流比重繪更加耗費性能。算法
經過了解瀏覽器基本的渲染機制,咱們很容易聯想到當不斷的經過JS
修改DOM
時,不經意間會觸發到渲染引擎的迴流或者重繪,這個性能開銷是很是巨大的。所以爲了下降開銷,咱們須要作的是儘量減小DOM
操做。有什麼方法能夠作到呢?api
虛擬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'
}]
}
複製代碼
Vue
在渲染機制的優化上,一樣引進了virtual dom
的概念,它是用Vnode
這個構造函數去描述一個DOM
節點。bash
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
的相關方法。app
// 建立註釋vnode節點
var createEmptyVNode = function (text) {
if ( text === void 0 ) text = '';
var node = new VNode();
node.text = text;
node.isComment = true; // 標記註釋節點
return node
};
複製代碼
// 建立文本vnode節點
function createTextVNode (val) {
return new VNode(undefined, undefined, undefined, String(val))
}
複製代碼
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
的克隆只是一層淺拷貝,它不會對子節點進行深度克隆。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的方法
}
複製代碼
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'
}
}
}
}
})
複製代碼
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
的完整生成。
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'
)
}
複製代碼
在數據檢測和組件規範化後,接下來經過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'
}]
}]
}
複製代碼
回到 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
時,會遞歸調用子組件的掛載過程,這個過程咱們也會放到後面章節去分析。
這一節分析了mountComponent
的兩個核心方法,render
和update
,在分析前重點介紹了存在於JS
操做和DOM
渲染的橋樑:Virtual DOM
。JS
對DOM
節點的批量操做會先直接反應到Virtual DOM
這個描述對象上,最終的結果纔會直接做用到真實節點上。能夠說,Virtual DOM
很大程度提升了渲染的性能。文章重點介紹了render
函數轉換成Virtual DOM
的過程,並大體描述了_update
函數的實現思路。其實這兩個過程都牽扯到組件,因此這一節對不少環節都沒法深刻分析,下一節開始會進入組件的專題。我相信分析完組件後,讀者會對整個渲染過程會有更深入的理解和思考。