Vue實現原理+前端性能優化
一、Vue實現原理
1、Vue簡介
現在的大前端時代,是一個動蕩紛爭的時代,江湖中已經分成了很多門派,主要以Vue,React還有Angular為首,形成前端框架三足鼎立的局勢。Vue在前端框架中的地位就像曾經的jQuery,由于其簡單易懂、開發效率高,已經成為了前端工程師必不可少的技能之一。
Vue是一種漸進式JavaScript框架,完美融合了第三方插件和UI組件庫,它和jQuery最大的區別在于,Vue無需開發人員直接操作DOM節點,就可以改變頁面渲染內容,在應用開發者具有一定的HTML、CSS、JavaScript的基礎上,能夠快速上手,開發出優雅、簡潔的應用程序模塊。
但是我們提及Vue的時候,更多的是關注它的用法,而不是學習它是如何解決前端問題的,這多少有點亞健康。有前端開發經驗的人,一定在開發過程中遇到過奇奇怪怪的問題,然后稀里糊涂地解決,倘若再次遇到相似的問題,便再次手足無措,作為一名前端工程師,在遇到問題的時候我們是否能準確定位產生問題的原因并及時解決,主要取決于我們對前端框架的理解是否足夠深入。
2、Vue實現原理
2.1 虛擬DOM(Virtual DOM)
隨著時代的發展,Web應用的頁面交互效果越來越復雜,頁面功能越來越豐富,需要維護的狀態越來越多,DOM操作也越來越頻繁。DOM操作雖然簡單易用,但是會產生不好維護的問題。
在程序執行的過程中,Watcher初始化時會將每一個節點和狀態進行一一關聯和映射,setter監聽到Data的狀態發生改變后,就會通知Watcher,Watcher會將這些變化通知曾經記錄過的DOM以及跟這些狀態相關的節點,從而觸發頁面的渲染過程。組件接收到狀態變化后,會通過編譯將模板轉換成渲染函數Render,執行渲染函數就會得到一個虛擬DOM樹,通過對比舊的虛擬DOM和新生成的虛擬DOM樹,來更新對應的實際DOM節點,執行頁面渲染。
主流前端框架幾乎都在使用虛擬DOM,但是在使用虛擬DOM的時候,Angular和React都無法確定具體是哪個狀態發生了變化,因此需要在舊的虛擬DOM和新的虛擬DOM之間進行暴力對比,但Vue從1.0版本開始,就通過細粒度的綁定來更新視圖,也就是說,當狀態發生變化的時候Vue可以知道具體是哪個狀態哪些節點需要發生改變,從而對這個節點執行更新,然而這種細粒度的變化偵測會有一些內存開銷影響性能,一個項目越復雜,開銷就越大。
Vue從2.0版本后,為了優化性能,引入了虛擬DOM,選擇了一個折中的方案,既不需要暴力對比整個新舊虛擬DOM,也不需要通過細粒度的綁定來實現視圖的更新,即以組件為單位進行Watcher監聽,也就是說即便一個組件內有多個節點使用了某個狀態,也只需一個Watcher來監聽這個狀態的變化,當這個狀態發生變化時,Watcher通知組件,組件內部通過虛擬DOM的方式去進行節點的對比和重新渲染。
2.2 常用指令實現原理
指令是指Vue提供的以“v-”前綴的特性,當指令中表達式的內容發生變化時,會連帶影響DOM內容發生變化。Vue.directive全局API可以創建自定義指令,并獲取全局指令,除了自定義指令,Vue還內置了一些開發過程中常用的指令,如v-if、v-for等。在Vue模板解析時,會將指令解析到AST,使用AST生成字符串的過程中實現指令的功能。
在解析模板時,會將節點上的指令解析出來并添加到AST的directives屬性中,directives將數據發送到VNode中,在虛擬DOM進行頁面渲染時,會觸發某些鉤子函數,當鉤子函數被觸發后,就說明指令已生效。
2.2.1 v-if指令原理
在應用程序中使用v-if指令:
- <div v-if="create">create if</div>
- <div v-else>create else</div>
在編譯階段生成:
- (create)
- ? _c('div',[_v("create if")])
- : _c('div',[_v("create else")])
在代碼執行時,會根據create的值來選擇創建哪個節點。
2.2.2 v-for指令原理
在應用程序中使用v-for指令:
- <li v-for="(item,index) in list">{{item}}</li>
在編譯階段生成:
- _l((list), function(item, index){
- return _c('li',[
- _v(_s(item))
- ])
- })
_l是renderList的別名,執行代碼時,_l函數會循環list變量,調用第二個參數中傳遞的函數,傳遞兩個參數:item和index,當_c函數被調用時,會執行_v函數,創建一個節點。
2.2.3 自定義指令原理
在應用程序中,指令的處理邏輯分別監聽了create函數、update函數以及destory函數,具體實現如下:
- export default {
- create: updateDirectives,
- update: updateDirectives,
- destory: function unbindDirectives (vnode){
- updateDirectives(vnode, emptyNode)
- }
- }
鉤子函數被觸發后,會執行updateDirectives函數,代碼如下:
- function updateDirectives(oldVnode, vnode){
- if (oldVnode.data.directives || vnode.data.directives) {
- _update(oldVnode, vnode)
- }
- }
在該函數中,不論是否存在舊虛擬節點,只要其中存在directives,就會執行_update函數,_update函數代碼如下:
- function _update(oldVnode, vnode) {
- const isCreate = oldVnode === emptyNode
- const isDestory = vnode === emptyNode
- const oldDirs = normalizeDirectives(oldVnode.data.directives, oldVnode.context)
- const newDirs = normalizeDirectives(vnode.data.directives, vnode.context)
- const dirsWithInsert = []
- const dirsWithPostpatch = []
- let key, oldDir, dir
- for (key in newDirs) {
- oldDir = oldDirs[key]
- dir = newDirs[key]
- if (!oldDir) { //新指令觸發bind
- callHook(dir, 'bind', vnode, oldVnode)
- if (dir.def && dir.def.inserted) {
- dirsWithInsert.push(dir)
- }
- } else { //指令已存在觸發update
- dir.oldValue = oldDir.value
- callHook(dir, 'update', vnode, oldVnode)
- if (dir.def && dir.def.componentUpdated) {
- dirsWithPostpatch.push(dir)
- }
- }
- }
- if (dirsWithInsert.length) {
- const callInsert = () => {
- for (let i = 0; i < dirsWithInsert.length; i++) {
- callHook(dirsWithInsert[i], 'inserted', vnode, oldVnode)
- }
- }
- if (isCreate) {
- mergeVNodeHook(vnode, 'insert', callInsert)
- } else {
- callInsert()
- }
- }
- if (dirsWithPostpatch.length) {
- mergeVNodeHook(vnode, 'postpatch', () => {
- for(let i = 0; i < dirsWithPostpatch.length; i++) {
- callHook(dirsWithPostpatch[i], 'componentUpdated', vnode, oldVnode)
- }
- })
- }
- if (!isCreate) {
- for(key in oldDirs) {
- if (!newDirs[key]) {
- callHook(oldDirs[key], 'unbind', oldVnode, oldVnode, isDestory)
- }
- }
- }
- }
isCreate:判斷該虛擬節點是否是一個新建的節點。
isDistory:判斷是否刪除一個舊虛擬節點。
oldDirs:舊的指令集合,oldVnode中保存的指令。
newDirs:新的指令集合,vnode中保存的指令。
dirsWithInsert:觸發inserted指令鉤子函數的指令列表。
dirsWithPostpatch:觸發componentUpdated鉤子函數的指令列表。
通過normalizeDirectives函數將模板中使用的指令從用戶注冊的自定義指令集合中取出來的結果如下:
- {
- v-customize: {
- def: {inserted: f},
- modifiers: {},
- name: "customize",
- rawName: "v-customize"
- }
- }
自定義指令的代碼為:
- Vue.directives('customize', {
- inserted: function (el) {
- el.customize()
- }
- })
虛擬DOM在對比和渲染時,會根據不同情景觸發不同的鉤子函數,當使用虛擬節點創建一個新的實際節點時,會觸發create鉤子函數,當一個DOM節點插入到父節點時,會觸發insert鉤子函數。
callHook函數執行鉤子函數的方式如下:
- function callHook(dir, hook, vnode, oldVnode, isDestory) {
- const fn = dir.def && dir.def[hook]
- if (fn) {
- try {
- fn(vnode.elm, dir, vnode, oldVnode, isDestory)
- } catch (e) {
- handleError(e, vnode.context, `directive ${dir.name} ${hook} hook`)
- }
- }
- }
callHook函數的參數意義分別為:
dir:指令對象。
hook:將要觸發的鉤子函數名。
vnode:新的虛擬節點。
oldVnode:舊的虛擬節點。
isDestory:判斷是否刪除一個舊虛擬節點。
虛擬DOM在渲染時會觸發的所有鉤子函數及其觸發機制如下:
需要注意的是,remove函數是只有一個元素從其父元素中移除時才會觸發,如果該元素是被移除元素的子元素,則不會觸發remove函數。
二、前端性能優化
前端在一個應用中,主要承擔在用戶打開一個頁面時,發送請求到服務器,接收服務器返回的頁面進行渲染并將渲染結果呈現給用戶的功能。
要提高前端性能需要從與用戶操作無關的客戶端和服務端交互和瀏覽器解析頁面著手,也就是從傳輸和渲染兩方面著手。
1、請求傳輸
1.1請求維度
基于目前前后端傳輸廣泛使用的HTTP 1.1協議,可以從壓縮請求的大小和減少請求的數量兩方面著手進行優化,主要的優化手段如下:
1.2協議維度
從1.0的短連接到1.1的長連接到HTTP2.0、3.0,做出了很多改變,每次協議的升級對前端性能優化來講都是一次飛躍。HTTP2.0的新特性如下:
二進制分幀:HTTP2.0在應用層與傳輸層之間增加一個二進制分幀層,將所有傳輸的信息分割為更小的消息和幀,并對它們采用二進制格式的編碼,使通信都在一個可以承載任意數量的雙向數據流的TCP連接上完成。
壓縮頭部:使用HPACK算法,規定了在客戶端和服務器端會使用并且維護“首部表”來跟蹤和存儲之前發送的鍵值對,對于相同的頭部,不必再通過請求發送,減少了頭部開銷。
多路復用:客戶端和服務器可以把HTTP消息分解為互不依賴的幀,然后亂序發送,最后再在另一端把它們重新組合起來。
請求優先級:每個流都可以帶有一個31bit的優先值:0表示最高優先級;2的31次方-1表示最低優先級。
服務器推送:通過提供push-promise幀來實現真正意義上的瀏覽器推送,擺脫利用ajax輪詢進行偽實時的場景。
2、瀏覽器渲染
2.1 瀏覽器單線程解析渲染阻塞
瀏覽器的主要構成如下:
它的幾個常駐線程如下:
由于GUI線程和JS引擎線程互斥,故衍生了一系列避免渲染過程中發生阻塞的優化方法,如樣式文件放頭部,腳本文件放在DOM節點最末尾;針對不需要操作DOM的腳本,可以采用動態創建script標簽的方式載入;腳本文件加上async或者defer等。
2.2 巨大的DOM開銷
在瀏覽器渲染的過程中,巨大的DOM開銷無疑成為了渲染效率是最大瓶頸。通過如下代碼可以輸出一個空DOM節點,查看它所包含的300余個屬性和事件。
- let ele = document.createElement("div")
- let obj = {}
- for (const prop in ele) {
- obj[prop]=ele[prop]
- }
- console.log(obj)
2.2.1重繪與回流
重繪是指當頁面展示元素中的一些元素需要更新屬性,這些屬性只是影響元素的外觀、風格,而不會影響布局的,比如background-color。回流是指當頁面展示元素中的一部分(或全部)因為元素的規模尺寸、布局、隱藏等改變而需要重新構建。顯然,重繪不一定導致回流,回流必然導致重繪。
它們都會帶來一定的DOM開銷,需要盡力去避免,常見的避免手段有避免觸發同步布局事件;對于復雜動畫效果,使用絕對定位讓其脫離文檔流;css3硬件加速(transform、opacity、filters、Will-change)等。
2.2.2虛擬DOM
針對巨大的DOM開銷,除了盡力避免重繪和回流,近幾年還有一種比較流行的,各大框架比如VUE、react都使用的虛擬DOM的方式。
虛擬DOM是一顆以js對象為基礎的樹,用對象屬性來描述節點,是對DOM的抽象,通過一系列操作將其映射到真實環境。
用一段代碼來模擬展示一下這個過程,首先用戶編寫模板如下:
- <ul id="myId">
- <li v-for="item in list">{{item}}</li>
- </ul>
編譯后的內容如下所示,采用了creatElement語法糖的形式創建節點。
- createElement {
- "ul",
- {
- attr:{
- id: "myId"
- }
- },
- [
- createElement("li", 1),
- createElement("li", 2),
- createElement("li", 3)
- ]
- }
經過渲染函數的執行生成虛擬DOM樹,其大致結構如下:
最終將虛擬DOM樹轉化為真實DOM。
虛擬DOM對性能的DOM開銷的優化主要體現在當節點有變化時,它可以通過differ算法比較變化前后的虛擬DOM結構的變化,通過對節點屬性的修改做必要的調整,而不是無腦的銷毀舊節點創建新節點。
這個過程的主要步驟是:用js對象結構表示DOM樹的結構,然后用這個樹構造一個真正的DOM樹,插入文檔中;當狀態變更時,重新構造一棵新樹,與舊樹進行對比,記錄差異;將記錄的差異應用到所構建的真正的DOM樹上。需要特別注意的是,differ算法遵循同級比較的原則,在使用的過程中要盡量減少跨層級的DOM調整。