vue源碼閱讀之數據渲染過程

1、概述

vue已經是目前國內前端web端三分天下之一,也是工做中主要技術棧之一。在平常使用中知其然也好奇着因此然,所以嘗試閱讀vue源碼並進行總結。本文旨在梳理初始化頁面時data中的數據是如何渲染到頁面上的。本文將帶着這個疑問一點點「追究」vue的'思路'。整體來講vue模版渲染大體流程如圖1所示:

圖1:vue模版渲染流程html

從圖中能夠看到模版渲染過程經歷了數據處理(initState)、模版編譯(compileToFunctions)生成渲染函數(render)、render函數生成虛擬dom、虛擬dom映射爲真實DOM (patch)掛載到頁面這幾個過程。上述幾個函數在數據渲染過程當中起到了關鍵做用。所以本文就從這幾個函數出發,深刻研究vue數據渲染到頁面上的原理。前端

2、什麼是Virtual DOM ?

vue利用虛擬DOM技術來提升頁面渲染和更新的速度。所以在正式分析數據渲染過程以前,有必要先了解一下什麼是Virtual DOM,以及Virtual DOM的優點。

2.1 virtual dom 產生的緣由

Virtual DOM 產生的前提是瀏覽器中的 DOM操做 是很「昂貴"的,爲了更直觀的感覺,我把一個簡單的 div 元素的屬性都打印出來,如圖2所示:

圖2:dom元素屬性vue

能夠看到,瀏覽器把 DOM 設計的很是複雜、很是龐大。在瀏覽器當中,dom的實現和ECMAScript的實現是分離的。所以當咱們頻繁的去作 DOM 更新,就是頻繁經過js代碼調用dom的接口,就至關於兩個相互獨立的模塊發生了交互。這樣,相比於在同一個模塊當中互相調用,這種跨模塊的調用它的性能損耗是很是高的。而且dom操做致使瀏覽器的重繪(repaint)和重排(reflow)會帶來更大的性能損耗。只要在渲染過程當中進行一次 DOM 更新,整個渲染流程都會重作一遍。如圖3所示:

圖3:瀏覽器渲染流程node

而 Virtual DOM 就是用一個原生的 JS 對象去描述一個 DOM 節點,因此它比建立一個 DOM 的代價要小不少。圖4所示爲vitrual dom結構:

圖4:Virtual DOM實例web

上述的virtual dom最後會生成真實的dom結構。如圖5所示:

圖5:Virtual DOM映射成真實dom正則表達式

在 Vue.js 中,Virtual DOM 是用 VNode 這麼一個 Class 去描述, 是對真實 DOM 的一種抽象描述,它的核心定義無非就幾個關鍵屬性,標籤名、數據、子節點、鍵值等。因爲 VNode 只是用來映射到真實 DOM 的渲染,不須要包含操做 DOM 的方法,所以它是很是輕量和簡單的。當數據發生改變時是一次性渲染到頁面,同時vue內部經過diff算法減小頁面的重繪和重排,從而提升了頁面渲染的速度。

2.2 Virtual DOM 主要思想

VirtualDOM的主要思想就是模擬DOM的樹狀結構,在內存中建立保存映射DOM信息的節點數據,在因爲交互等因素須要視圖更新時,先經過對節點數據進行diff獲得差別結果後,再一次性對DOM進行批量更新操做,這就比如在內存中建立了一個平行世界,瀏覽器中DOM樹的每個節點與屬性數據都在這個平行世界中存在着另外一個版本的虛擬DOM樹,全部複雜曲折的更新邏輯都在平行世界中的VirtualDOM處理完成,只將最終的更新結果發送給瀏覽器中的DOM樹執行,這樣就避免了冗餘瑣碎的DOM樹操做負擔,進而有效提升了性能。基於 Virtual DOM 的數據更新與UI同步機制:初始渲染時,首先將數據渲染爲 Virtual DOM,而後由 Virtual DOM 生成 DOM。

圖6:vitual dom生成dom示意圖算法

比較兩個 DOM 樹的差別是 Virtual DOM 算法最核心的部分,這也是所謂的 Virtual DOM 的diff 算法。diff算法的核心是比較只會在同層級進行, 不會跨層級比較。而不像逐層逐層搜索遍歷的方式,時間複雜度將會達到 O(n^3)的級別,代價很是高,而只比較同層級的方式時間複雜度能夠下降到O(n)。能夠用一張圖示意:

圖7:virtual dom更新示意圖瀏覽器

數據更新時,渲染獲得新的 Virtual DOM,與上一次的 Virtual DOM 進行 diff,獲得全部須要在 DOM 上進行的變動,而後在 patch 過程當中應用到 DOM 上實現UI的同步更新。所以 Virtual DOM算法主要包括這幾步: 初始化視圖的時候,用原生JS對象表示DOM樹,生成一個對象樹,而後根據這個對象樹來生成一個真正的DOM樹,插入到文檔中。 當狀態更新的時候,從新生成一個對象樹,將新舊兩個對象樹作對比,記錄差別。 把記錄的差別應用到第一步生成的真正的DOM樹上,視圖更新完成。 在vue中也是採用了virtual dom的diff算法以下圖,具體diff算法過程在patch函數執行。

圖8:vitrual dom在vue中應用app

3、數據渲染過程

3.1 數據綁定實現邏輯 ---- initState

本節正式分析從new vue()到數據渲染到頁面的過程,在src/core/instance/index.js 中定義了一個Vue的構造函數。當執行new Vue(options)時就會執行this._init(options)這個函數。

圖9: vue構造函數dom

以一個簡單的實例開始。定義以下模版和js代碼:

圖10: 實例

在調用Vue構造函數時候傳入el和data。此時傳入this._init(options)中的options = { el: '#app', data: { message: '我是一條信息'}}。在_init函數中會執行一系列初始化操做:初始化生命週期、初始化事件、初始化數據等。其中初始化數據是本節關心的內容,跟數據綁定關聯最大的是 initState。所以咱們如今重點研究一下initState(vm)。入口是src/core/instance/state.js。

圖11: initState函數

傳入data後會調用initData(vm)函數,對data進行處理。initData函數將當前傳入的data賦值給vm._data。vm是當前vue實例。而後會執行代理函數proxy。

圖12: proxy函數

proxy函數的原理是經過 Object.defineProperty()函數在實例對象vm上定義與data數據字段同名的訪問器屬性,而且這些屬性代理的值是vm._data上對應屬性的值。當咱們訪問vm[key] 就會經過get方法去訪問vm[sourceKey][key] 即vm._data[key]。也就是說vm.message 就會去訪問vm._data.message也就是vm.data.message。因此 this.message就是this._data.message,只不過_data是vue內部使用的。這也就是咱們經過this.message就能訪問到data裏面的message對應的值。即'我是一條信息‘。

圖13: 數據綁定過程

同理,當咱們設置一個屬性值時會經過set方法去設置vm._data[key]的值。到這一步咱們已經能夠獲取傳入的data裏面的數據了。那麼message是如何渲染到頁面視圖層的呢?下一節就深刻研究vue的掛載過程。

3.2 渲染函數 ---- render

3.2.1 對el的處理

_init函數執行完上述的初始化過程後會判斷是否傳入el,若傳入就執行掛載函數$mount。

圖14: $mount函數

$mount首先會經過query(el)函數對傳入的el以下處理:

圖15: query函數

若是el是字符串則經過document.querySelector(el)方法查找該字符串對應的dom元素。若沒找到,則經過方document.createElement('div')方法動態建立一個div,若傳入的el是dom元素。那麼就返回該元素。最終都是用一個dom元素來掛載實例。值得注意的是Vue 不能掛載在 body、html 這樣的根節點上。緣由是vue在掛載是會將對應的dom對象替換成新的div,但body和html是不適合替換的。若是el是body或者html就會拋出警告,這也就是爲何平時咱們一般會用「#app「或者「div「掛載實例的緣由。

3.2.2 模版內容提取

判斷是否傳入render函數,若是渲染函數存在會直接調用運行時版 $mount 函數,咱們知道運行時版 $mount 僅有兩句代碼,且真正的掛載是經過調用 mountComponent 函數完成的,因此可想而知 mountComponent 完成掛載所需的必要條件就是:提供渲染函數給 mountComponent。 render分爲用戶手寫和模版編譯兩種形式,手寫render函數格式以下。render函數的好處是,不會有在html中直接使用插值時,在實際掛載前出現{{message}}這樣的內容。只有在render函數執行完成後纔會把message替換到頁面上去。這樣會有更好的體驗。下面的render函數最終會渲染成一個id爲app1,內容爲‘我是一條信息'的div元素,替換掉以前的掛載節點el。因此這也是爲何不使用body或者html進行掛載的緣由,由於咱們不能覆蓋到整個body或者html。

圖16: 手寫render函數

在 Vue 2.0 版本中,全部 Vue 的組件的渲染最終都須要 render 方法,不管咱們是用單文件 .vue 方式開發組件,仍是寫了 el 或者 template 屬性,最終都會轉換成 render 方法。咱們的例子中沒有傳入render函數,所以須要來研究一下在沒有傳入render函數的狀況下如何經過模版編譯成render函數。

圖17: template處理

(1)若是存在template而且傳入的template是字符串,且以#開頭,以下:

圖18: template第一種形式

那麼就找到id爲#app的元素innerHtml();獲取該innerHtml()是在idToTemplate函數中進行的。也是調用query函數獲取對應的dom元素的innerHTML。並保存在template變量中。

圖19: idToTemplate函數

(2)若是傳入的template是dom節點。以下:那麼直接將該節點的innerHtml賦值給template 變量。

圖20: template第二種形式

(3)若是options中沒有template,可是有el,那麼就獲取el對應元素的全部內容。

圖21: el元素內容提取

getOuterHTML 函數的源碼以下:

圖22: getOuterHTML函數

它接收一個 DOM 元素做爲參數,並返回該元素的 outerHTML。該函數首先判斷了 el.outerHTML 是否存在,也就是說一個元素的 outerHTML 屬性未必存在,實際上在 IE9-11 中 SVG 標籤元素是沒有 innerHTML 和 outerHTML 這兩個屬性的,解決這個問題的方案很簡單,能夠把 SVG 元素放到一個新建立的 div 元素中,這樣新 div 元素的 innerHTML 屬性的值就等價於 SVG標籤 outerHTML 的值。

咱們最初提供的實例子符合第(3)類狀況。通過以上邏輯的處理以後,理想狀態下此時 template 變量應該以下所示的一個模板字符串,將用於渲染函數的生成。

圖22: 提取的模版字符串

模版提取過程如圖23所示:

圖23: 模版提取

3.2.3 模版編譯成render函數

拿到模版內容後就會調用compileToFunctions函數將模版編譯成render函數。下面看下compileToFunctions生成render方法的具體實現。編譯主要有三個過程:

圖23: 編譯過程

1.解析模版字符串生成AST ---- parse(template.trim(), options)。 parse 會用正則等方式解析 template模板中的指令、class、style等數據,造成AST樹。AST是一種用Javascript對象的形式來描述整個模版。parse會調用parseHTML函數,因爲 parseHTML 的邏輯也很是複雜,所以我也用了僞代碼的方式表達。

圖24: parseHTML僞代碼

總體來講它的邏輯就是循環解析 template ,用正則作各類匹配,對於不一樣狀況分別進行不一樣的處理,直到整個 template 被解析完畢。 在匹配的過程當中會利用 advance 函數不斷前進整個模板字符串,直到字符串末尾。

圖25: advance函數

爲了更加直觀地說明 advance 的做用,能夠經過一副圖表示:

調用 advance 函數:advance(4) 獲得結果:

圖26: advance函數執行示意圖

因此在整個html循環中會不斷調用advance函數,達到把這個html解析完畢的目的。詳細過程有興趣的小夥伴自行去了解。那麼至此,parse 的過程就分析完了,看似複雜,但咱們能夠拋開細節理清它的總體流程。

圖27: parse流程圖

parse 的目標是把 template 模板字符串轉換成 AST 樹,它是一種用 JavaScript 對象的形式來描述整個模板。那麼整個 parse 的過程是利用正則表達式順序解析模板,當解析到開始標籤、閉合標籤、文本的時候都會分別執行對應的回調函數,來達到構造 AST 樹的目的。我的理解就是把template(模板)解析成一個對象,該對象是包含這個模板因此信息的一種數據,而這種數據瀏覽器是不支持的,爲Vue後面的處理template提供基礎數據。本實例中會生成以下AST樹。

圖28: ast樹形結構

2.優化AST語法樹 ---- optimize(ast, options)。 爲何此處會有優化過程?咱們知道Vue是數據驅動,是響應式的,可是template模版中並非全部的數據都是響應式的,也有許多數據是初始化渲染以後就不會有變化的,那麼這部分數據對應的DOM也不會發生變化。後面有一個 update 更新界面的過程,在這當中會有一個 patch 的過程, diff 算法會直接跳過靜態節點,從而減小了比較的過程,優化了 patch 的性能。

圖29: optimize流程

3.codegen:將優化後的AST樹轉換成可執行的代碼。

圖30: codegen流程

template模版經歷過parse->optimize->codegen三個過程以後,就能夠獲得render function函數了。

圖31: 編譯後生成的render函數

從模版提取到render函數的生成的過程總結以下:

圖32: 編譯render函數的過程

3.3 render到VNode的生成

調用 render.call(vm._renderProxy, vm.$createElement)函數並返回生成的虛擬節點(vnode)。能夠看到,render 函數中 createElement 方法就是 vm.$createElement 方法。

圖33: initRender函數

vm.$createElement 方法定義是在執行 initRender 方法的時候,能夠看到除了 vm.$createElement 方法,還有一個 vm._c 方法,它是被模板編譯成的 render 函數使用,而 vm.$createElement 是用戶手寫 render 方法使用的,這倆個方法支持的參數相同,而且內部都調用了 createElement 方法。

圖34: createElement函數

createElement 方法其實是對 _createElement 方法的封裝,它容許傳入的參數更加靈活,在處理這些參數後,調用真正建立 VNode 的函數 _crateElement 。_createElement最終實例化VNode,返回vnode或者一個空的vnode。

圖35: vnode實例化

本文例子生成以下的虛擬dom:

圖36:生成的vnode

簡單的梳理了createElement函數流程圖,能夠參考下圖:

圖37:createElement函數流程圖

3.3虛擬DOM映射爲真實DOM ----patch

vm._render 函數的做用是生成的虛擬節點(vnode)。vm._update 函數的做用是把 vm._render 函數生成的虛擬節點渲染成真正的 DOM。

圖38:_update函數

update方法會在兩種狀況下被調用,一是new Vue初始化的時候,還有一種狀況就是當咱們改變data數據,頁面從新渲染時調用。update最終會調用patch方法。patch實際調用的是createPatchFunction({ nodeOps, modules })。這個方法接收兩個參數,nodeOps,modules。摘取一部分nodeOps內容

圖39:nodeOps

能夠看到,裏面都是一些原生dom操做的封裝,摘取modules一部份內容。

圖40:modules

能夠看到,是一些對原生dom特性控制的封裝,以及一些輔助函數, 下面咱們回到createPatchFunction,createPatchFunction 方法中首先定義了好多的輔助函數,最後返回了一個函數,即patch,來看下這個patch。在該函數中,第一個參數是dom元素,第二個參數是vnode。

圖41:patch函數

一系列判斷事後會執行emptyNodeAt()輔助函數,能夠看到,emptyNodeAt()函數的功能是建立一個新的vnode。所以oldVnode = emptyNodeAt(oldNode)創新了新的vnode替換,而原來的oldNode(dom節點)能夠在該vnode節點的elm元素中訪問到。

圖42:patch函數

取到當前的el對應的dom節點和其父節點後,開始利用createElm函數建立新的dom節點。在最初的實例中,oldElm就是id爲app的div。而parentElm就是其父元素,即body。接下來調用createElm方法,這個方法定義於傳入的modules輔助函數中, 這個方法纔是真實dom操做的核心所在,它的做用就是將vnode掛載到真實的dom上。咱們進入createElm。

圖43:createElm函數

在createElm()函數中,主要完成的功能是將構建dom子節點插入到父節點中,而且一直循環到該節點沒有子節點爲止。這個過程createElm()函數和createChildren函數一塊兒完成。

建立子節點

圖44:建立子節點

能夠看到,在createChildren()函數中,若是該vnode的子節點是矩陣的話,就會調用createElm()函數。所以兩個函數是相互調用生成dom節點,而後插入到父節點的過程。若是該子節點是最後一個節點,則直接在dom節點後面插入該文本節點。最後調用insert將整個dom樹一次性插入到body中。上面從主線上完成模版和數據的渲染。

圖45:插入節點

此外,由於新建了一個div來渲染視圖,所以應該把原來的就定義的用來掛載的dom節點(通常是個div)刪掉。因此,能夠看到,在vue的渲染過程當中,會建立新的dom節點替換掉之前的節點,所以咱們在初始化的時候不能將節點選擇掛載在html和body上。

圖46:刪除原節點

4、總結

回過頭來看,數據的渲染邏輯並非特別複雜,核心關鍵的幾步流程仍是很是清晰的:
  1. new Vue,執行初始化,將傳入的data數據綁定到當前實例,就能夠經過this.message的形式訪問傳入的數據。這個過程是執行initData()函數完成的。
  2. 掛載$mount方法,經過自定義Render方法、template、el等生成Render函數。若是傳入了模版(template)就將模版裏面的內容編譯成render函數,不然將傳入的el對應的元素的內容編譯成render函數。編譯是調用compileToFunctions函數完成的。也能夠本身手寫render函數,能夠減小編譯這一環節。其中render渲染函數的優先級最高,template次之且需編譯成渲染函數,而掛載點el屬性對應的元素若存在,則在前二者均不存在時,其outerHTML纔會用於編譯與渲染。
  3. 生成render函數後,調用_createElement函數生成vnode。
  4. 將虛擬DOM映射爲真實DOM頁面上。
相關文章
相關標籤/搜索