為了方便自己對知識點的鞏固和理解,整理了李永寧大佬 12 篇《Vue源碼解讀》的文末知識點總結,在這里可以一覽天下。如果想看詳細文章,可點擊標題下方的“閱讀原文”即可。
(1)前言
(2)Vue 初始化過程
Vue 的初始化過程(new Vue(options))都做了什么?
- 處理組件配置項
- 初始化根組件時進行了選項合并操作,將全局配置合并到根組件的局部配置上
- 初始化每個子組件時做了一些性能優化,將組件配置對象上的一些深層次屬性放到 vm.$options 選項中,以提高代碼的執行效率
- 初始化組件實例的關系屬性,比如
children、
refs 等
- 處理自定義事件
- 調用 beforeCreate 鉤子函數
- 初始化組件的 inject 配置項,得到 ret[key] = val 形式的配置對象,然后對該配置對象進行響應式處理,并代理每個 key 到 vm 實例上
- 數據響應式,處理 props、methods、data、computed、watch 等選項
- 解析組件配置項上的 provide 對象,將其掛載到 vm._provided 屬性上
- 調用 created 鉤子函數
- 如果發現配置項上有 el 選項,則自動調用
$mount
方法,也就是說有了 el 選項,就不需要再手動調用$mount
方法,反之,沒提供 el 選項則必須調用$mount
- 接下來則進入掛載階段
(3)響應式原理
Vue 響應式原理是怎么實現的?
- 響應式的核心是通過 Object.defineProperty 攔截對數據的訪問和設置
- 響應式的數據分為兩類:
- 對象,循環遍歷對象的所有屬性,為每個屬性設置 getter、setter,以達到攔截訪問和設置的目的,如果屬性值依舊為對象,則遞歸為屬性值上的每個 key 設置 getter、setter
- 訪問數據時(obj.key)進行依賴收集,在 dep 中存儲相關的 watcher
- 設置數據時由 dep 通知相關的 watcher 去更新
- 數組,增強數組的那 7 個可以更改自身的原型方法,然后攔截對這些方法的操作
- 添加新數據時進行響應式處理,然后由 dep 通知 watcher 去更新
- 刪除數據時,也要由 dep 通知 watcher 去更新
- 對象,循環遍歷對象的所有屬性,為每個屬性設置 getter、setter,以達到攔截訪問和設置的目的,如果屬性值依舊為對象,則遞歸為屬性值上的每個 key 設置 getter、setter
methods、computed 和 watch 有什么區別?
<!DOCTYPE html>
<html lang="en">
<head>
<title>methods、computed、watch 有什么區別</title>
</head>
<body>
<div id="app">
<!-- methods -->
<div>{{ returnMsg() }}</div>
<div>{{ returnMsg() }}</div>
<!-- computed -->
<div>{{ getMsg }}</div>
<div>{{ getMsg }}</div>
</div>
<script src="../../dist/vue.js"></script>
<script>
new Vue({
el: '#app',
data: {
msg: 'test'
},
mounted() {
setTimeout(() => {
this.msg = 'msg is changed'
}, 1000)
},
methods: {
returnMsg() {
console.log('methods: returnMsg')
return this.msg
}
},
computed: {
getMsg() {
console.log('computed: getMsg')
return this.msg + ' hello computed'
}
},
watch: {
msg: function(val, oldVal) {
console.log('watch: msg')
new Promise(resolve => {
setTimeout(() => {
this.msg = 'msg is changed by watch'
}, 1000)
})
}
}
})
</script>
</body>
</html>
示例其實就是答案了
- 使用場景
- methods 一般用于封裝一些較為復雜的處理邏輯(同步、異步)
- computed 一般用于封裝一些簡單的同步邏輯,將經過處理的數據返回,然后顯示在模版中,以減輕模版的重量
- watch 一般用于當需要在數據變化時執行異步或開銷較大的操作
- 區別
- methods VS computed
- 通過示例會發現,如果在一次渲染中,有多個地方使用了同一個 methods 或 computed 屬性,methods 會被執行多次,而 computed 的回調函數則只會被執行一次。
- 通過閱讀源碼我們知道,在一次渲染中,多次訪問 computedProperty,只會在第一次執行 computed 屬性的回調函數,后續的其它訪問,則直接使用第一次的執行結果(watcher.value),而這一切的實現原理則是通過對 watcher.dirty 屬性的控制實現的。而 methods,每一次的訪問則是簡單的方法調用(this.xxMethods)。
- computed VS watch
- 通過閱讀源碼我們知道,computed 和 watch 的本質是一樣的,內部都是通過 Watcher 來實現的,其實沒什么區別,非要說區別的化就兩點:1、使用場景上的區別,2、computed 默認是懶執行的,切不可更改。
- methods VS watch
- methods 和 watch 之間其實沒什么可比的,完全是兩個東西,不過在使用上可以把 watch 中一些邏輯抽到 methods 中,提高代碼的可讀性。
- methods VS computed
(4)異步更新
Vue 的異步更新機制是如何實現的?
- Vue 的異步更新機制的核心是利用了瀏覽器的異步任務隊列來實現的,首選微任務隊列,宏任務隊列次之。
- 當響應式數據更新后,會調用 dep.notify 方法,通知 dep 中收集的 watcher 去執行 update 方法,watcher.update 將 watcher 自己放入一個 watcher 隊列(全局的 queue 數組)。
- 然后通過 nextTick 方法將一個刷新 watcher 隊列的方法(flushSchedulerQueue)放入一個全局的 callbacks 數組中。
- 如果此時瀏覽器的異步任務隊列中沒有一個叫 flushCallbacks 的函數,則執行 timerFunc 函數,將 flushCallbacks 函數放入異步任務隊列。如果異步任務隊列中已經存在 flushCallbacks 函數,等待其執行完成以后再放入下一個 flushCallbacks 函數。
- flushCallbacks 函數負責執行 callbacks 數組中的所有 flushSchedulerQueue 函數。
- flushSchedulerQueue 函數負責刷新 watcher 隊列,即執行 queue 數組中每一個 watcher 的 run 方法,從而進入更新階段,比如執行組件更新函數或者執行用戶 watch 的回調函數。
- 完整的執行過程其實就是今天源碼閱讀的過程。
Vue 的 nextTick API 是如何實現的?
Vue.nextTick 或者 vm.$nextTick 的原理其實很簡單,就做了兩件事:
- 將傳遞的回調函數用 try catch 包裹然后放入 callbacks 數組
- 執行 timerFunc 函數,在瀏覽器的異步任務隊列放入一個刷新 callbacks 數組的函數
(5)全局 API
Vue.use(plugin) 做了什么?
負責安裝 plugin 插件,其實就是執行插件提供的 install 方法。
- 首先判斷該插件是否已經安裝過
- 如果沒有,則執行插件提供的 install 方法安裝插件,具體做什么有插件自己決定
Vue.mixin(options) 做了什么?
負責在 Vue 的全局配置上合并 options 配置。然后在每個組件生成 vnode 時會將全局配置合并到組件自身的配置上來。
- 標準化 options 對象上的 props、inject、directive 選項的格式
- 處理 options 上的 extends 和 mixins,分別將他們合并到全局配置上
- 然后將 options 配置和全局配置進行合并,選項沖突時 options 配置會覆蓋全局配置
Vue.component(compName, Comp) 做了什么?
負責注冊全局組件。其實就是將組件配置注冊到全局配置的 components 選項上(options.components),然后各個子組件在生成 vnode 時會將全局的 components 選項合并到局部的 components 配置項上。
- 如果第二個參數為空,則表示獲取 compName 的組件構造函數
- 如果 Comp 是組件配置對象,則使用 Vue.extend 方法得到組件構造函數,否則直接進行下一步
- 在全局配置上設置組件信息,this.options.components.compName = CompConstructor
Vue.directive('my-directive', {xx}) 做了什么?
在全局注冊 my-directive 指令,然后每個子組件在生成 vnode 時會將全局的 directives 選項合并到局部的 directives 選項中。原理同 Vue.component 方法:
- 如果第二個參數為空,則獲取指定指令的配置對象
- 如果不為空,如果第二個參數是一個函數的話,則生成配置對象 { bind: 第二個參數, update: 第二個參數 }
- 然后將指令配置對象設置到全局配置上,this.options.directives['my-directive'] = {xx}
Vue.filter('my-filter', function(val) {xx}) 做了什么?
負責在全局注冊過濾器 my-filter,然后每個子組件在生成 vnode 時會將全局的 filters 選項合并到局部的 filters 選項中。原理是:
- 如果沒有提供第二個參數,則獲取 my-filter 過濾器的回調函數
- 如果提供了第二個參數,則是設置 this.options.filters['my-filter'] = function(val) {xx}。
Vue.extend(options) 做了什么?
Vue.extend 基于 Vue 創建一個子類,參數 options 會作為該子類的默認全局配置,就像 Vue 的默認全局配置一樣。所以通過 Vue.extend 擴展一個子類,一大用處就是內置一些公共配置,供子類的子類使用。
- 定義子類構造函數,這里和 Vue 一樣,也是調用 _init(options)
- 合并 Vue 的配置和 options,如果選項沖突,則 options 的選項會覆蓋 Vue 的配置項
- 給子類定義全局 API,值為 Vue 的全局 API,比如 Sub.extend = Super.extend,這樣子類同樣可以擴展出其它子類
- 返回子類 Sub
Vue.set(target, key, val) 做了什么?
由于 Vue 無法探測普通的新增 property (比如 this.myObject.newProperty = 'hi'),所以通過 Vue.set 為向響應式對象中添加一個 property,可以確保這個新 property 同樣是響應式的,且觸發視圖更新。
- 更新數組指定下標的元素:Vue.set(array, idx, val),內部通過 splice 方法實現響應式更新
- 更新對象已有屬性:Vue.set(obj, key ,val),直接更新即可 => obj[key] = val
- 不能向 Vue 實例或者 $data 動態添加根級別的響應式數據
- Vue.set(obj, key, val),如果 obj 不是響應式對象,會執行 obj[key] = val,但是不會做響應式處理
- Vue.set(obj, key, val),為響應式對象 obj 增加一個新的 key,則通過 defineReactive 方法設置響應式,并觸發依賴更新
面試官 問:Vue.delete(target, key) 做了什么?
刪除對象的 property。如果對象是響應式的,確保刪除能觸發更新視圖。這個方法主要用于避開 Vue 不能檢測到 property 被刪除的限制,但是你應該很少會使用它。當然同樣不能刪除根級別的響應式屬性。
- Vue.delete(array, idx),刪除指定下標的元素,內部是通過 splice 方法實現的
- 刪除響應式對象上的某個屬性:Vue.delete(obj, key),內部是執行 delete obj.key,然后執行依賴更新即可
Vue.nextTick(cb) 做了什么?
Vue.nextTick(cb) 方法的作用是延遲回調函數 cb 的執行,一般用于 this.key = newVal 更改數據后,想立即獲取更改過后的 DOM 數據:
this.key = 'new val'
Vue.nextTick(function() {
// DOM 更新了
})
其內部的執行過程是:
- this.key = 'new val,觸發依賴通知更新,將負責更新的 watcher 放入 watcher 隊列
- 將刷新 watcher 隊列的函數放到 callbacks 數組中
- 在瀏覽器的異步任務隊列中放入一個刷新 callbacks 數組的函數
- Vue.nextTick(cb) 來插隊,將 cb 函數放入 callbacks 數組
- 待將來的某個時刻執行刷新 callbacks 數組的函數
- 然后執行 callbacks 數組中的眾多函數,觸發 watcher.run 的執行,更新 DOM
- 由于 cb 函數是在后面放到 callbacks 數組,所以這就保證了先完成的 DOM 更新,再執行 cb 函數
(6)實例方法
面試官 問:vm.$set(obj, key, val) 做了什么?
vm.$set
用于向響應式對象添加一個新的 property,并確保這個新的 property 同樣是響應式的,并觸發視圖更新。由于 Vue 無法探測對象新增屬性或者通過索引為數組新增一個元素,比如:this.obj.newProperty = 'val'
、this.arr[3] = 'val'
。所以這才有了 vm.$set
,它是 Vue.set
的別名。
- 為對象添加一個新的響應式數據:調用 defineReactive 方法為對象增加響應式數據,然后執行 dep.notify 進行依賴通知,更新視圖
- 為數組添加一個新的響應式數據:通過 splice 方法實現
vm.$delete(obj, key) 做了什么?
vm.$delete 用于刪除對象上的屬性。如果對象是響應式的,且能確保能觸發視圖更新。該方法主要用于避開 Vue 不能檢測屬性被刪除的情況。它是 Vue.delete 的別名。
- 刪除數組指定下標的元素,內部通過 splice 方法來完成
- 刪除對象上的指定屬性,則是先通過 delete 運算符刪除該屬性,然后執行 dep.notify 進行依賴通知,更新視圖
vm.$watch(expOrFn, callback, [options]) 做了什么?
vm.$watch 負責觀察 Vue 實例上的一個表達式或者一個函數計算結果的變化。當其發生變化時,回調函數就會被執行,并為回調函數傳遞兩個參數,第一個為更新后的新值,第二個為老值。
這里需要 注意 一點的是:如果觀察的是一個對象,比如:數組,當你用數組方法,比如 push 為數組新增一個元素時,回調函數被觸發時傳遞的新值和老值相同,因為它們指向同一個引用,所以在觀察一個對象并且在回調函數中有新老值是否相等的判斷時需要注意。
vm.$watch
的第一個參數只接收簡單的響應式數據的鍵路徑,對于更復雜的表達式建議使用函數作為第一個參數。
至于 vm.$watch
的內部原理是:
- 設置 options.user = true,標志是一個用戶 watcher
- 實例化一個 Watcher 實例,當檢測到數據更新時,通過 watcher 去觸發回調函數的執行,并傳遞新老值作為回調函數的參數
- 返回一個 unwatch 函數,用于取消觀察
vm.$on(event, callback) 做了什么?
監聽當前實例上的自定義事件,事件可由 vm.$emit
觸發,回調函數會接收所有傳入事件觸發函數vm.$emit
的額外參數。
vm.$on
的原理很簡單,就是處理傳遞的 event 和 callback 兩個參數,將注冊的事件和回調函數以鍵值對的形式存儲到 vm._event 對象中,vm._events = { eventName: [cb1, cb2, ...], ... }
。
vm.$emit(eventName, [...args]) 做了什么?
觸發當前實例上的指定事件,附加參數都會傳遞給事件的回調函數。
其內部原理就是執行 vm._events[eventName] 中所有的回調函數。
備注:從 on和on 和 on和emit 的實現原理也能看出,組件的自定義事件其實是誰觸發誰監聽,所以在這會兒再回頭看 Vue 源碼解讀(2)—— Vue 初始化過程 中關于 initEvent 的解釋就會明白在說什么,因為組件自定義事件的處理內部用的就是 vm.on、vm.on、vm.on、vm.emit。
vm.$off([event, callback]) 做了什么?
移除自定義事件監聽器,即移除 vm._events
對象上相關數據。
- 如果沒有提供參數,則移除實例的所有事件監聽
- 如果只提供了 event 參數,則移除實例上該事件的所有監聽器
- 如果兩個參數都提供了,則移除實例上該事件對應的監聽器
vm.$once(event, callback) 做了什么?
監聽一個自定義事件,但是該事件只會被觸發一次。一旦觸發以后監聽器就會被移除。
其內部的實現原理是:
- 包裝用戶傳遞的回調函數,當包裝函數執行的時候,除了會執行用戶回調函數之外還會執行 vm.$off(event, 包裝函數) 移除該事件
- 用 vm.$on(event, 包裝函數) 注冊事件
vm._update(vnode, hydrating) 做了什么?
官方文檔沒有說明該 API,這是一個用于源碼內部的實例方法,負責更新頁面,是頁面渲染的入口,其內部根據是否存在 prevVnode 來決定是首次渲染,還是頁面更新,從而在調用 patch 函數時傳遞不同的參數。該方法在業務開發中不會用到。
vm.$forceUpdate() 做了什么?
迫使 Vue 實例重新渲染,它僅僅影響組件實例本身和插入插槽內容的子組件,而不是所有子組件。其內部原理到也簡單,就是直接調用 vm._watcher.update(),它就是 watcher.update() 方法,執行該方法觸發組件更新。
vm.$destroy() 做了什么?
負責完全銷毀一個實例。清理它與其它實例的連接,解綁它的全部指令和事件監聽器。在執行過程中會調用 beforeDestroy 和 destroy 兩個鉤子函數。在大多數業務開發場景下用不到該方法,一般都通過 v-if 指令來操作。其內部原理是:
- 調用 beforeDestroy 鉤子函數
- 將自己從老爹肚子里($parent)移除,從而銷毀和老爹的關系
- 通過 watcher.teardown() 來移除依賴監聽
- 通過 vm._ _ patch _ _(vnode, null) 方法來銷毀節點
- 調用 destroyed 鉤子函數
- 通過 vm.$off 方法移除所有的事件監聽
vm.$nextTick(cb) 做了什么?
vm.$nextTick 是 Vue.nextTick 的別名,其作用是延遲回調函數 cb 的執行,一般用于 this.key = newVal 更改數據后,想立即獲取更改過后的 DOM 數據:
this.key = 'new val'
Vue.nextTick(function() {
// DOM 更新了
})
其內部的執行過程是:
- this.key = 'new val',觸發依賴通知更新,將負責更新的 watcher 放入 watcher 隊列
- 將刷新 watcher 隊列的函數放到 callbacks 數組中
- 在瀏覽器的異步任務隊列中放入一個刷新 callbacks 數組的函數
- vm.$nextTick(cb) 來插隊,直接將 cb 函數放入 callbacks 數組
- 待將來的某個時刻執行刷新 callbacks 數組的函數
- 然后執行 callbacks 數組中的眾多函數,觸發 watcher.run 的執行,更新 DOM
- 由于 cb 函數是在后面放到 callbacks 數組,所以這就保證了先完成的 DOM 更新,再執行 cb 函數
vm._render 做了什么?
官方文檔沒有提供該方法,它是一個用于源碼內部的實例方法,負責生成 vnode。其關鍵代碼就一行,執行 render 函數生成 vnode。不過其中加了大量的異常處理代碼。
(7)Hook Event
什么是 Hook Event?
Hook Event 是 Vue 的自定義事件結合生命周期鉤子實現的一種從組件外部為組件注入額外生命周期方法的功能。
Hook Event 是如果實現的?
<comp @hook:lifecycleMethod="method" />
處理組件自定義事件的時候(vm.$on) 如果發現組件有 hook:xx 格式的事件(xx 為 Vue 的生命周期函數),則將 vm._hasHookEvent 置為 true,表示該組件有 Hook Event
在組件生命周期方法被觸發的時候,內部會通過 callHook 方法來執行這些生命周期函數,在生命周期函數執行之后,如果發現 vm._hasHookEvent 為 true,則表示當前組件有 Hook Event,通過 vm.$emit('hook:xx') 觸發 Hook Event 的執行
(8)編譯器 之 解析
面試官 問:簡單說一下 Vue 的編譯器都做了什么?
Vue 的編譯器做了三件事情:
- 將組件的 html 模版解析成 AST 對象
- 優化,遍歷 AST,為每個節點做靜態標記,標記其是否為靜態節點,然后進一步標記出靜態根節點,這樣在后續更新的過程中就可以跳過這些靜態節點了;標記靜態根用于生成渲染函數階段,生成靜態根節點的渲染函數
- 從 AST 生成運行時的渲染函數,即大家說的 render,其實還有一個,就是 staticRenderFns 數組,里面存放了所有的靜態節點的渲染函數
詳細說一說編譯器的解析過程,它是怎么將 html 字符串模版變成 AST 對象的?
- 遍歷 HTML 模版字符串,通過正則表達式匹配 "<"
- 跳過某些不需要處理的標簽,比如:注釋標簽、條件注釋標簽、Doctype。
- 備注:整個解析過程的核心是處理開始標簽和結束標簽
- 解析開始標簽
- 得到一個對象,包括 標簽名(tagName)、所有的屬性(attrs)、標簽在 html 模版字符串中的索引位置
- 進一步處理上一步得到的 attrs 屬性,將其變成 [{ name: attrName, value: attrVal, start: xx, end: xx }, ...] 的形式
- 通過標簽名、屬性對象和當前元素的父元素生成 AST 對象,其實就是一個 普通的 JS 對象,通過 key、value 的形式記錄了該元素的一些信息
- 接下來進一步處理開始標簽上的一些指令,比如 v-pre、v-for、v-if、v-once,并將處理結果放到 AST 對象上
- 處理結束將 ast 對象存放到 stack 數組
- 處理完成后會截斷 html 字符串,將已經處理掉的字符串截掉
- 解析閉合標簽
- 如果匹配到結束標簽,就從 stack 數組中拿出最后一個元素,它和當前匹配到的結束標簽是一對。
- 再次處理開始標簽上的屬性,這些屬性和前面處理的不一樣,比如:key、ref、scopedSlot、樣式等,并將處理結果放到元素的 AST 對象上
- 備注:視頻中說這塊兒有誤,回頭看了下,沒有問題,不需要改,確實是這樣
- 然后將當前元素和父元素產生聯系,給當前元素的 ast 對象設置 parent 屬性,然后將自己放到父元素的 ast 對象的 children 數組中
- 最后遍歷完整個 html 模版字符串以后,返回 ast 對象
(9)編譯器 之 優化
簡單說一下 Vue 的編譯器都做了什么?
Vue 的編譯器做了三件事情:
- 將組件的 html 模版解析成 AST 對象
- 優化,遍歷 AST,為每個節點做靜態標記,標記其是否為靜態節點,然后進一步標記出靜態根節點,這樣在后續更新的過程中就可以跳過這些靜態節點了;標記靜態根用于生成渲染函數階段,生成靜態根節點的渲染函數
- 從 AST 生成運行渲染函數,即大家說的 render,其實還有一個,就是 staticRenderFns 數組,里面存放了所有的靜態節點的渲染函數
詳細說一下靜態標記的過程
- 標記靜態節點
- 通過遞歸的方式標記所有的元素節點
- 如果節點本身是靜態節點,但是存在非靜態的子節點,則將節點修改為非靜態節點
- 標記靜態根節點,基于靜態節點,進一步標記靜態根節點
- 如果節點本身是靜態節點 && 而且有子節點 && 子節點不全是文本節點,則標記為靜態根節點
- 如果節點本身不是靜態根節點,則遞歸的遍歷所有子節點,在子節點中標記靜態根
什么樣的節點才可以被標記為靜態節點?
- 文本節點
- 節點上沒有 v-bind、v-for、v-if 等指令
- 非組件
(10)編譯器 之 生成渲染函數
簡單說一下 Vue 的編譯器都做了什么?
Vue 的編譯器做了三件事情:
- 將組件的 html 模版解析成 AST 對象
- 優化,遍歷 AST,為每個節點做靜態標記,標記其是否為靜態節點,然后進一步標記出靜態根節點,這樣在后續更新的過程中就可以跳過這些靜態節點了;標記靜態根用于生成渲染函數階段,生成靜態根節點的渲染函數
- 從 AST 生成運行渲染函數,即大家說的 render,其實還有一個,就是 staticRenderFns 數組,里面存放了所有的靜態節點的渲染函數
詳細說一下渲染函數的生成過程
大家一說到渲染函數,基本上說的就是 render 函數,其實編譯器生成的渲染有兩類:
- 第一類就是一個 render 函數,負責生成動態節點的 vnode
- 第二類是放在一個叫 staticRenderFns 數組中的靜態渲染函數,這些函數負責生成靜態節點的 vnode
渲染函數生成的過程,其實就是在遍歷 AST 節點,通過遞歸的方式,處理每個節點,最后生成形如:_c(tag, attr, children, normalizationType)
的結果。tag 是標簽名,attr 是屬性對象,children 是子節點組成的數組,其中每個元素的格式都是_c(tag, attr, children, normalizationTYpe)
的形式,normalization 表示節點的規范化類型,是一個數字 0、1、2,不重要。
在處理 AST 節點過程中需要大家重點關注也是面試中常見的問題有:
- 靜態節點是怎么處理的(靜態節點的處理分為兩步)?
- 將生成靜態節點 vnode 函數放到 staticRenderFns 數組中
- 返回一個
_m(idx)
的可執行函數,意思是執行 staticRenderFns 數組中下標為 idx 的函數,生成靜態節點的 vnode
- v-once、v-if、v-for、組件 等都是怎么處理的?
- 單純的 v-once 節點處理方式和靜態節點一致
- v-if 節點的處理結果是一個三元表達式
- v-for 節點的處理結果是可執行的
_l
函數,該函數負責生成 v-for 節點的 vnode - 組件的處理結果和普通元素一樣,得到的是形如
_c(compName)
的可執行代碼,生成組件的 vnode
碎碎念
到這里,Vue 編譯器 的源碼解讀就結束了。相信大家在閱讀的過程中不免會產生云里霧里的感覺。這個沒什么,編譯器這塊兒確實是比較復雜,可以說是整個框架最難理解也是代碼量最大的一部分了。一定要靜下心來多讀幾遍,遇到無法理解的地方,一定要勤動手,通過示例代碼加斷點調試的方式幫助自己理解。
當你讀完幾遍以后,這時候情況可能就會好一些,但是有些地方可能還會有些暈,這沒事,正常。畢竟這是一個框架的編譯器,要處理的東西太多太多了,你只需要理解其核心思想(模版解析、靜態標記、代碼生成)就可以了。后面會有 手寫 Vue 系列,編譯器這部分會有一個簡版的實現,幫助加深對這部分知識的理解。
編譯器讀完以后,會發現有個不明白的地方:編譯器最后生成的代碼都是經過 with 包裹的,比如:
<div id="app">
<div v-for="item in arr" :key="item">{{ item }}</div>
</div>
經過編譯后生成:
with (this) {
return _c(
'div',
{
attrs:
{
"id": "app"
}
},
_l(
(arr),
function (item) {
return _c(
'div',
{
key: item
},
[_v(_s(item))]
)
}
),
0
)
}
都知道,with 語句可以擴展作用域鏈,所以生成的代碼中的 _c
、_l
、_v
、_s
都是 this 上一些方法,也就是說在運行時執行這些方法可以生成各個節點的 vnode。
所以聯系前面的知識,響應式數據更新的整個執行過程就是:
- 響應式攔截到數據的更新
- dep 通知 watcher 進行異步更新
- watcher 更新時執行組件更新函數 updateComponent
- 首先執行 vm._render 生成組件的 vnode,這時就會執行編譯器生成的函數
問題:
- 渲染函數中的 _c、_l、、_v、_s 等方法是什么?
- 它們是如何生成 vnode 的?
下一篇文章 Vue 源碼解讀(11)—— render helper 將會帶來這部分知識的詳細解讀,也是面試經常被問題的:比如:v-for 的原理是什么?
(11)render helper
一個組件是如何變成 VNode?
- 組件實例初始化,最后執行 $mount 進入掛載階段
- 如果是只包含運行時的 vue.js,只直接進入掛載階段,因為這時候的組件已經變成了渲染函數,編譯過程通過模塊打包器 + vue-loader + vue-template-compiler 完成的
- 如果沒有使用預編譯,則必須使用全量的 vue.js
- 掛載時如果發現組件配置項上沒有 render 選項,則進入編譯階段
- 將模版字符串編譯成 AST 語法樹,其實就是一個普通的 JS 對象
- 然后優化 AST,遍歷 AST 對象,標記每一個節點是否為靜態靜態;然后再進一步標記出靜態根節點,在組件后續更新時會跳過這些靜態節點的更新,以提高性能
- 接下來從 AST 生成渲染函數,生成的渲染函數有兩部分組成:
- 負責生成動態節點 VNode 的 render 函數
- 還有一個 staticRenderFns 數組,里面每一個元素都是一個生成靜態節點 VNode 的函數,這些函數會作為 render 函數的組成部分,負責生成靜態節點的 VNode
- 接下來將渲染函數放到組件的配置對象上,進入掛載階段,即執行 mountComponent 方法
- 最終負責渲染組件和更新組件的是一個叫 updateComponent 方法,該方法每次執行前首先需要執行
vm._render
函數,該函數負責執行編譯器生成的 render,得到組件的 VNode - 將一個組件生成 VNode 的具體工作是由 render 函數中的
_c
、_o
、_l
、_m
等方法完成的,這些方法都被掛載到 Vue 實例上面,負責在運行時生成組件 VNode
提示:到這里首先要明白什么是 VNode,一句話描述就是 —— 組件模版的 JS 對象表現形式,它就是一個普通的 JS 對象,詳細描述了組件中各節點的信息
下面說的有點多,其實記住一句就可以了,設置組件配置信息,然后通過 new VNode(組件信息) 生成組件的 VNode
-
_c
,負責生成組件或 HTML 元素的 VNode,_c
是所有 render helper 方法中最復雜,也是最核心的一個方法,其它的_xx
都是它的組成部分- 接收標簽、屬性 JSON 字符串、子節點數組、節點規范化類型作為參數
- 如果標簽是平臺保留標簽或者一個未知的元素,則直接 new VNode(標簽信息) 得到 VNode
- 如果標簽是一個組件,則執行 createComponent 方法生成 VNode
- 函數式組件執行自己的 render 函數生成 VNode
- 普通組件則實例化一個 VNode,并且在在 data.hook 對象上設置 4 個方法,在組件的 patch 階段會被調用,從而進入子組件的實例化、掛載階段,然后進行編譯生成渲染函數,直至完成渲染
- 當然生成 VNode 之前會進行一些配置處理比如:
- 子組件選項合并,合并全局配置項到組件配置項上
- 處理自定義組件的 v-model
- 處理組件的 props,提取組件的 props 數據,以組件的 props 配置中的屬性為 key,父組件中對應的數據為 value 生成一個 propsData 對象;當組件更新時生成新的 VNode,又會進行這一步,這就是 props 響應式的原理
- 處理其它數據,比如監聽器
- 安裝內置的 init、prepatch、insert、destroy 鉤子到 data.hooks 對象上,組件 patch 階段會用到這些鉤子方法
-
_l
,運行時渲染 v-for 列表的幫助函數,循環遍歷 val 值,依次為每一項執行 render 方法生成 VNode,最終返回一個 VNode 數組 -
_m
,負責生成靜態節點的 VNode,即執行 staticRenderFns 數組中指定下標的函數
簡單總結 render helper 的作用就是:在 Vue 實例上掛載一些運行時的工具方法,這些方法用在編譯器生成的渲染函數中,用于生成組件的 VNode。
好了,到這里,一個組件從初始化開始到最終怎么變成 VNode 就講完了,最后剩下的就是 patch 階段了,下一篇文章將講述如何將組件的 VNode 渲染到頁面上。
(12)patch
你能說一說 Vue 的 patch 算法嗎?
Vue 的 patch 算法有三個作用:負責首次渲染和后續更新或者銷毀組件
- 如果老的 VNode 是真實元素,則表示首次渲染,創建整棵 DOM 樹,并插入 body,然后移除老的模版節點
- 如果老的 VNode 不是真實元素,并且新的 VNode 也存在,則表示更新階段,執行 patchVnode
- 首先是全量更新所有的屬性
- 如果新老 VNode 都有孩子,則遞歸執行 updateChildren,進行 diff 過程
- 針對前端操作 DOM 節點的特點進行如下優化:
- 同層比較(降低時間復雜度)深度優先(遞歸)
- 而且前端很少有完全打亂節點順序的情況,所以做了四種假設,假設新老 VNode 的開頭結尾存在相同節點,一旦命中假設,就避免了一次循環,降低了 diff 的時間復雜度,提高執行效率。如果不幸沒有命中假設,則執行遍歷,從老的 VNode 中找到新的 VNode 的開始節點
- 找到相同節點,則執行 patchVnode,然后將老節點移動到正確的位置
- 如果老的 VNode 先于新的 VNode 遍歷結束,則剩余的新的 VNode 執行新增節點操作
- 如果新的 VNode 先于老的 VNode 遍歷結束,則剩余的老的 VNode 執行刪除操縱,移除這些老節點
- 如果新的 VNode 有孩子,老的 VNode 沒孩子,則新增這些新孩子節點
- 如果老的 VNode 有孩子,新的 VNode 沒孩子,則刪除這些老孩子節點
- 剩下一種就是更新文本節點
- 如果新的 VNode 不存在,老的 VNode 存在,則調用 destroy,銷毀老節點
碎碎念
好了,到這里,Vue 源碼解讀系列就結束了,如果你認認真真的讀完整個系列的文章,相信你對 Vue 源碼已經相當熟悉了,不論是從宏觀層面理解,還是某些細節方面的詳解,應該都沒問題。即使有些細節現在不清楚,但是當遇到問題時,你也能一眼看出來該去源碼的什么位置去找答案。
到這里你可以試著在自己的腦海中復述一下 Vue 的整個執行流程。過程很重要,但 總結 才是最后的升華時刻。如果在哪個環節卡住了,可再回去讀相應的部分就可以了。
還記得系列的第一篇文章中提到的目標嗎?相信閱讀幾遍下來,你一定可以在自己的簡歷中寫到:精通 Vue 框架的源碼原理。
接下來會開始 Vue 的手寫系列。
作者:李永寧
鏈接:https://juejin.cn/user/1028798616461326
來源:掘金
著作權歸作者所有。商業轉載請聯系作者獲得授權,非商業轉載請注明出處。