前言
本文分享 12 道 vue 高頻原理面試題,覆蓋了 vue 核心實現原理,其實一個框架的實現原理一篇文章是不可能說完的,希望通過這 12 道問題,讓讀者對自己的 Vue 掌握程度有一定的認識(B 數),從而彌補自己的不足,更好的掌握 Vue ❤️
1. Vue 響應式原理
“我自己是一名從事了6年web前端開發的老程序員(我的微信:web-xxq),今年年初我花了一個月整理了一份最適合2019年自學的web前端全套培訓教程(視頻+源碼+筆記+項目實戰),從最基礎的HTML+CSS+JS到移動端HTML5以及各種框架和新技術都有整理,打包給每一位前端小夥伴,這裏是前端學習者聚集地,歡迎初學和進階中的小夥伴(所有前端教程關注我的微信公衆號:web前端學習圈,關注後回覆“2020”即可領取)。
核心實現類:
Observer : 它的作用是給對象的屬性添加 getter 和 setter,用於依賴收集和派發更新
Dep : 用於收集當前響應式對象的依賴關係,每個響應式對象包括子對象都擁有一個 Dep 實例(裏面 subs 是 Watcher 實例數組),當數據有變更時,會通過 dep.notify()通知各個 watcher。
Watcher : 觀察者對象 , 實例分爲渲染 watcher (render watcher),計算屬性 watcher (computed watcher),偵聽器 watcher(user watcher)三種
Watcher 和 Dep 的關係
watcher 中實例化了 dep 並向 dep.subs 中添加了訂閱者,dep 通過 notify 遍歷了 dep.subs 通知每個 watcher 更新。
依賴收集
- initState 時,對 computed 屬性初始化時,觸發 computed watcher 依賴收集
- initState 時,對偵聽屬性初始化時,觸發 user watcher 依賴收集
- render()的過程,觸發 render watcher 依賴收集
- re-render 時,vm.render()再次執行,會移除所有 subs 中的 watcer 的訂閱,重新賦值。
派發更新
- 組件中對響應的數據進行了修改,觸發 setter 的邏輯
- 調用 dep.notify()
- 遍歷所有的 subs(Watcher 實例),調用每一個 watcher 的 update 方法。
原理
當創建 Vue 實例時,vue 會遍歷 data 選項的屬性,利用 Object.defineProperty 爲屬性添加 getter 和 setter 對數據的讀取進行劫持(getter 用來依賴收集,setter 用來派發更新),並且在內部追蹤依賴,在屬性被訪問和修改時通知變化。
每個組件實例會有相應的 watcher 實例,會在組件渲染的過程中記錄依賴的所有數據屬性(進行依賴收集,還有 computed watcher,user watcher 實例),之後依賴項被改動時,setter 方法會通知依賴與此 data 的 watcher 實例重新計算(派發更新),從而使它關聯的組件重新渲染。
一句話總結:
vue.js 採用數據劫持結合發佈-訂閱模式,通過 Object.defineproperty 來劫持各個屬性的 setter,getter,在數據變動時發佈消息給訂閱者,觸發響應的監聽回調
2. computed 的實現原理
computed 本質是一個惰性求值的觀察者。
computed 內部實現了一個惰性的 watcher,也就是 computed watcher,computed watcher 不會立刻求值,同時持有一個 dep 實例。
其內部通過 this.dirty 屬性標記計算屬性是否需要重新求值。
當 computed 的依賴狀態發生改變時,就會通知這個惰性的 watcher,
computed watcher 通過 this.dep.subs.length 判斷有沒有訂閱者,
有的話,會重新計算,然後對比新舊值,如果變化了,會重新渲染。 (Vue 想確保不僅僅是計算屬性依賴的值發生變化,而是當計算屬性最終計算的值發生變化時纔會觸發渲染 watcher 重新渲染,本質上是一種優化。)
沒有的話,僅僅把 this.dirty = true。 (當計算屬性依賴於其他數據時,屬性並不會立即重新計算,只有之後其他地方需要讀取屬性的時候,它纔會真正計算,即具備 lazy(懶計算)特性。)
3. computed 和 watch 有什麼區別及運用場景?
區別
computed 計算屬性 : 依賴其它屬性值,並且 computed 的值有緩存,只有它依賴的屬性值發生改變,下一次獲取 computed 的值時纔會重新計算 computed 的值。
watch 偵聽器 : 更多的是「觀察」的作用,無緩存性,類似於某些數據的監聽回調,每當監聽的數據變化時都會執行回調進行後續操作。
運用場景
運用場景:
當我們需要進行數值計算,並且依賴於其它數據時,應該使用 computed,因爲可以利用 computed 的緩存特性,避免每次獲取值時,都要重新計算。
當我們需要在數據變化時執行異步或開銷較大的操作時,應該使用 watch,使用 watch 選項允許我們執行異步操作 ( 訪問一個 API ),限制我們執行該操作的頻率,並在我們得到最終結果前,設置中間狀態。這些都是計算屬性無法做到的。
4. 爲什麼在 Vue3.0 採用了 Proxy,拋棄了 Object.defineProperty?
Object.defineProperty 本身有一定的監控到數組下標變化的能力,但是在 Vue 中,從性能/體驗的性價比考慮,尤大大就棄用了這個特性(Vue 爲什麼不能檢測數組變動 )。爲了解決這個問題,經過 vue 內部處理後可以使用以下幾種方法來監聽數組
push();
pop();
shift();
unshift();
splice();
sort();
reverse();
由於只針對了以上 7 種方法進行了 hack 處理,所以其他數組的屬性也是檢測不到的,還是具有一定的侷限性。
Object.defineProperty 只能劫持對象的屬性,因此我們需要對每個對象的每個屬性進行遍歷。Vue 2.x 裏,是通過 遞歸 + 遍歷 data 對象來實現對數據的監控的,如果屬性值也是對象那麼需要深度遍歷,顯然如果能劫持一個完整的對象是纔是更好的選擇。Proxy 可以劫持整個對象,並返回一個新的對象。Proxy 不僅可以代理對象,還可以代理數組。還可以代理動態增加的屬性。
5. Vue 中的 key 到底有什麼用?
key 是給每一個 vnode 的唯一 id,依靠 key,我們的 diff 操作可以更準確、更快速 (對於簡單列表頁渲染來說 diff 節點也更快,但會產生一些隱藏的副作用,比如可能不會產生過渡效果,或者在某些節點有綁定數據(表單)狀態,會出現狀態錯位。)
diff 算法的過程中,先會進行新舊節點的首尾交叉對比,當無法匹配的時候會用新節點的 key 與舊節點進行比對,從而找到相應舊節點.
更準確 : 因爲帶 key 就不是就地複用了,在 sameNode 函數 a.key === b.key 對比中可以避免就地複用的情況。所以會更加準確,如果不加 key,會導致之前節點的狀態被保留下來,會產生一系列的 bug。
更快速 : key 的唯一性可以被 Map 數據結構充分利用,相比於遍歷查找的時間複雜度 O(n),Map 的時間複雜度僅僅爲 O(1),源碼如下:
function createKeyToOldIdx(children, beginIdx, endIdx) {
let i, key;
const map = {};
for (i = beginIdx; i <= endIdx; ++i) {
key = children[i].key;
if (isDef(key)) map[key] = i;
}
return map;
}
6. 談一談 nextTick 的原理
JS 運行機制
JS 執行是單線程的,它是基於事件循環的。事件循環大致分爲以下幾個步驟:
- 所有同步任務都在主線程上執行,形成一個執行棧(execution context stack)。
- 主線程之外,還存在一個"任務隊列"(task queue)。只要異步任務有了運行結果,就在"任務隊列"之中放置一個事件。
- 一旦"執行棧"中的所有同步任務執行完畢,系統就會讀取"任務隊列",看看裏面有哪些事件。那些對應的異步任務,於是結束等待狀態,進入執行棧,開始執行。
- 主線程不斷重複上面的第三步。
主線程的執行過程就是一個 tick,而所有的異步結果都是通過 “任務隊列” 來調度。 消息隊列中存放的是一個個的任務(task)。 規範中規定 task 分爲兩大類,分別是 macro task 和 micro task,並且每個 macro task 結束後,都要清空所有的 micro task。
for (macroTask of macroTaskQueue) {
// 1. Handle current MACRO-TASK
handleMacroTask();
// 2. Handle all MICRO-TASK
for (microTask of microTaskQueue) {
handleMicroTask(microTask);
}
}
在瀏覽器環境中 :
常見的 macro task 有 setTimeout、MessageChannel、postMessage、setImmediate
常見的 micro task 有 MutationObsever 和 Promise.then
異步更新隊列
可能你還沒有注意到,Vue 在更新 DOM 時是異步執行的。只要偵聽到數據變化,Vue 將開啓一個隊列,並緩衝在同一事件循環中發生的所有數據變更。
如果同一個 watcher 被多次觸發,只會被推入到隊列中一次。這種在緩衝時去除重複數據對於避免不必要的計算和 DOM 操作是非常重要的。
然後,在下一個的事件循環“tick”中,Vue 刷新隊列並執行實際 (已去重的) 工作。
Vue 在內部對異步隊列嘗試使用原生的 Promise.then、MutationObserver 和 setImmediate,如果執行環境不支持,則會採用 setTimeout(fn, 0) 代替。
在 vue2.5 的源碼中,macrotask 降級的方案依次是:setImmediate、MessageChannel、setTimeout
vue 的 nextTick 方法的實現原理:
- vue 用異步隊列的方式來控制 DOM 更新和 nextTick 回調先後執行
- microtask 因爲其高優先級特性,能確保隊列中的微任務在一次事件循環前被執行完畢
- 考慮兼容問題,vue 做了 microtask 向 macrotask 的降級方案
7. vue 是如何對數組方法進行變異的 ?
我們先來看看源碼
const arrayProto = Array.prototype;
export const arrayMethods = Object.create(arrayProto);
const methodsToPatch = [
"push",
"pop",
"shift",
"unshift",
"splice",
"sort",
"reverse"
];
/**
* Intercept mutating methods and emit events
*/
methodsToPatch.forEach(function(method) {
// cache original method
const original = arrayProto[method];
def(arrayMethods, method, function mutator(...args) {
const result = original.apply(this, args);
const ob = this.__ob__;
let inserted;
switch (method) {
case "push":
case "unshift":
inserted = args;
break;
case "splice":
inserted = args.slice(2);
break;
}
if (inserted) ob.observeArray(inserted);
// notify change
ob.dep.notify();
return result;
});
});
/**
* Observe a list of Array items.
*/
Observer.prototype.observeArray = function observeArray(items) {
for (var i = 0, l = items.length; i < l; i++) {
observe(items[i]);
}
};
簡單來說,Vue 通過原型攔截的方式重寫了數組的 7 個方法,首先獲取到這個數組的ob,也就是它的 Observer 對象,如果有新的值,就調用 observeArray 對新的值進行監聽,然後手動調用 notify,通知 render watcher,執行 update
8. Vue 組件 data 爲什麼必須是函數 ?
new Vue()實例中,data 可以直接是一個對象,爲什麼在 vue 組件中,data 必須是一個函數呢?
因爲組件是可以複用的,JS 裏對象是引用關係,如果組件 data 是一個對象,那麼子組件中的 data 屬性值會互相污染,產生副作用。
所以一個組件的 data 選項必須是一個函數,因此每個實例可以維護一份被返回對象的獨立的拷貝。new Vue 的實例是不會被複用的,因此不存在以上問題。
9. 談談 Vue 事件機制,手寫$on,$off,$emit,$once
Vue 事件機制 本質上就是 一個 發佈-訂閱 模式的實現。
class Vue {
constructor() {
// 事件通道調度中心
this._events = Object.create(null);
}
$on(event, fn) {
if (Array.isArray(event)) {
event.map(item => {
this.$on(item, fn);
});
} else {
(this._events[event] || (this._events[event] = [])).push(fn);
}
return this;
}
$once(event, fn) {
function on() {
this.$off(event, on);
fn.apply(this, arguments);
}
on.fn = fn;
this.$on(event, on);
return this;
}
$off(event, fn) {
if (!arguments.length) {
this._events = Object.create(null);
return this;
}
if (Array.isArray(event)) {
event.map(item => {
this.$off(item, fn);
});
return this;
}
const cbs = this._events[event];
if (!cbs) {
return this;
}
if (!fn) {
this._events[event] = null;
return this;
}
let cb;
let i = cbs.length;
while (i--) {
cb = cbs[i];
if (cb === fn || cb.fn === fn) {
cbs.splice(i, 1);
break;
}
}
return this;
}
$emit(event) {
let cbs = this._events[event];
if (cbs) {
const args = [].slice.call(arguments, 1);
cbs.map(item => {
args ? item.apply(this, args) : item.call(this);
});
}
return this;
}
}
10. 說說 Vue 的渲染過程
- 調用 compile 函數,生成 render 函數字符串 ,編譯過程如下:
- parse 函數解析 template,生成 ast(抽象語法樹)
- optimize 函數優化靜態節點 (標記不需要每次都更新的內容,diff 算法會直接跳過靜態節點,從而減少比較的過程,優化了 patch 的性能)
- generate 函數生成 render 函數字符串
- 調用 new Watcher 函數,監聽數據的變化,當數據發生變化時,Render 函數執行生成 vnode 對象
- 調用 patch 方法,對比新舊 vnode 對象,通過 DOM diff 算法,添加、修改、刪除真正的 DOM 元素
11. 聊聊 keep-alive 的實現原理和緩存策略
export default {
name: "keep-alive",
abstract: true, // 抽象組件屬性 ,它在組件實例建立父子關係的時候會被忽略,發生在 initLifecycle 的過程中
props: {
include: patternTypes, // 被緩存組件
exclude: patternTypes, // 不被緩存組件
max: [String, Number] // 指定緩存大小
},
created() {
this.cache = Object.create(null); // 緩存
this.keys = []; // 緩存的VNode的鍵
},
destroyed() {
for (const key in this.cache) {
// 刪除所有緩存
pruneCacheEntry(this.cache, key, this.keys);
}
},
mounted() {
// 監聽緩存/不緩存組件
this.$watch("include", val => {
pruneCache(this, name => matches(val, name));
});
this.$watch("exclude", val => {
pruneCache(this, name => !matches(val, name));
});
},
render() {
// 獲取第一個子元素的 vnode
const slot = this.$slots.default;
const vnode: VNode = getFirstComponentChild(slot);
const componentOptions: ?VNodeComponentOptions =
vnode && vnode.componentOptions;
if (componentOptions) {
// name不在inlcude中或者在exlude中 直接返回vnode
// check pattern
const name: ?string = getComponentName(componentOptions);
const { include, exclude } = this;
if (
// not included
(include && (!name || !matches(include, name))) ||
// excluded
(exclude && name && matches(exclude, name))
) {
return vnode;
}
const { cache, keys } = this;
// 獲取鍵,優先獲取組件的name字段,否則是組件的tag
const key: ?string =
vnode.key == null
? // same constructor may get registered as different local components
// so cid alone is not enough (#3269)
componentOptions.Ctor.cid +
(componentOptions.tag ? `::${componentOptions.tag}` : "")
: vnode.key;
// 命中緩存,直接從緩存拿vnode 的組件實例,並且重新調整了 key 的順序放在了最後一個
if (cache[key]) {
vnode.componentInstance = cache[key].componentInstance;
// make current key freshest
remove(keys, key);
keys.push(key);
}
// 不命中緩存,把 vnode 設置進緩存
else {
cache[key] = vnode;
keys.push(key);
// prune oldest entry
// 如果配置了 max 並且緩存的長度超過了 this.max,還要從緩存中刪除第一個
if (this.max && keys.length > parseInt(this.max)) {
pruneCacheEntry(cache, keys[0], keys, this._vnode);
}
}
// keepAlive標記位
vnode.data.keepAlive = true;
}
return vnode || (slot && slot[0]);
}
};
原理
- 獲取 keep-alive 包裹着的第一個子組件對象及其組件名
- 根據設定的 include/exclude(如果有)進行條件匹配,決定是否緩存。不匹配,直接返回組件實例
- 根據組件 ID 和 tag 生成緩存 Key,並在緩存對象中查找是否已緩存過該組件實例。如果存在,直接取出緩存值並更新該 key 在 this.keys 中的位置(更新 key 的位置是實現 LRU 置換策略的關鍵)
- 在 this.cache 對象中存儲該組件實例並保存 key 值,之後檢查緩存的實例數量是否超過 max 的設置值,超過則根據 LRU 置換策略刪除最近最久未使用的實例(即是下標爲 0 的那個 key)
- 最後組件實例的 keepAlive 屬性設置爲 true,這個在渲染和執行被包裹組件的鉤子函數會用到,這裏不細說
LRU 緩存淘汰算法
LRU(Least recently used)算法根據數據的歷史訪問記錄來進行淘汰數據,其核心思想是“如果數據最近被訪問過,那麼將來被訪問的機率也更高”。
keep-alive 的實現正是用到了 LRU 策略,將最近訪問的組件 push 到 this.keys 最後面,this.keys[0]也就是最久沒被訪問的組件,當緩存實例超過 max 設置值,刪除 this.keys[0]
12. vm.$set()實現原理是什麼?
受現代 JavaScript 的限制 (而且 Object.observe 也已經被廢棄),Vue 無法檢測到對象屬性的添加或刪除。
由於 Vue 會在初始化實例時對屬性執行 getter/setter 轉化,所以屬性必須在 data 對象上存在才能讓 Vue 將它轉換爲響應式的。
對於已經創建的實例,Vue 不允許動態添加根級別的響應式屬性。但是,可以使用 Vue.set(object, propertyName, value) 方法向嵌套對象添加響應式屬性。
那麼 Vue 內部是如何解決對象新增屬性不能響應的問題的呢?
export function set(target: Array<any> | Object, key: any, val: any): any {
// target 爲數組
if (Array.isArray(target) && isValidArrayIndex(key)) {
// 修改數組的長度, 避免索引>數組長度導致splice()執行有誤
target.length = Math.max(target.length, key);
// 利用數組的splice變異方法觸發響應式
target.splice(key, 1, val);
return val;
}
// target爲對象, key在target或者target.prototype上 且必須不能在 Object.prototype 上,直接賦值
if (key in target && !(key in Object.prototype)) {
target[key] = val;
return val;
}
// 以上都不成立, 即開始給target創建一個全新的屬性
// 獲取Observer實例
const ob = (target: any).__ob__;
// target 本身就不是響應式數據, 直接賦值
if (!ob) {
target[key] = val;
return val;
}
// 進行響應式處理
defineReactive(ob.value, key, val);
ob.dep.notify();
return val;
}
- 如果目標是數組,使用 vue 實現的變異方法 splice 實現響應式
- 如果目標是對象,判斷屬性存在,即爲響應式,直接賦值
- 如果 target 本身就不是響應式,直接賦值
- 如果屬性不是響應式,則調用 defineReactive 方法進行響應式處理
後記
如果你和我一樣喜歡前端,也愛動手摺騰,歡迎關注我一起玩耍啊~ ❤️