Vue原始碼學習之響應式是如何實現的
目錄
- 前言
- 一、一個響應式系統的關鍵要素
- 1、如何監聽資料變化
- 2、如何進行依賴收集——實現 Dep 類
- 3、資料變化時如何更新——實現 Watcher 類
- 二、虛擬 DOM 和 diff
- 1、虛擬 DOM 是什麼?
- 2、diff 演算法——新舊節點對比
- 三、nextTick
- 四、總結
前言
作為前端開發,我們的日常工作就是將資料渲染到頁面➕處理使用者互動。在 中,資料變化時頁面會重新渲染,比如我們在頁面上顯示一個數字,旁邊有一個點選按鈕,每次點選一下按鈕,頁面上所顯示的數字會加一,這要怎麼去實現呢?
按照原生 的邏輯想一想,我們應該做三件事:監聽點選事件,在事件處理函式中修改資料,然後手動去修改 DOM 重新渲染,這和我們使用 Vue 的最大區別在於多了一步【手動去修改DOM重新渲染】,這一步看起來簡單,但我們得考慮幾個問題:
- 需要修改哪個 DOM ?
- 資料每變化一次就需要去修改一次 DOM 嗎?
- 怎麼去保證修改 DOM 的效能?
所以要實現一個響應式系統並不簡單🍳,來結合 Vue 原始碼學習一下 Vue 中優秀的思想叭~
一、一個響應式系統的關鍵要素
1、如何監聽資料變化
顯然通過監聽所有使用者互動事件來獲取資料變化是非常繁瑣的,且有些資料的變動也不一定是使用者觸發的,那Vue是怎麼監聽資料變化的呢?—— Object.defineProperty
Object.defineProperty 方法為什麼能監聽資料變化?該方法可以直接在一個物件上定義一個新屬性,或者修改一個物件的現有屬性, 並返回這個物件,先來看一下它的語法:
Object.defineProperty(obj,prop,descriptor) // obj是傳入的物件,prop是要定義或修改的屬性,descriptor是屬性描述符
這裡比較核心的是descriptor,它有很多可選鍵值。這裡我們最關心的是get和set,其中 get是一個給屬性提供的 getter 方法,當我們訪問了該屬性的時候會觸發 getter 方法;set是一個給屬性提供的 setter 方法,當我們對該屬性做修改的時候會觸發 setter 方法。
簡言之,一旦一個數據物件擁有了 getter 和 setter,我們就可以輕鬆監聽它的變化了,並可將其稱之為響應式物件。具體怎麼做呢?
function observe(data) { if (isObject(data)) { Object.keys(data).forEach(key => { defineReactive(data,key) }) } } function defineReactive(obj,prop) { let val = obj[prop] let dep = new Dep() // 用來收集依賴 Object.defineProperty(obj,{ get() { // 訪問物件屬性了,說明依賴當前物件屬性,把依賴收集起來 dep.depend() return val } set(newVal) { if (newVal === val) return // 資料被修改了,該通知相關人員更新相應的檢視了 val = newVal dep.notify() } }) // 深層監聽 if (isObject(val)) { observe(val) } return obj }
這裡我們需要一個 Dep 類(dependency)來做依賴收集🎭
PS:Object.defineProperty 只能監聽已存在的屬性,對於新增的屬性就無能為力了,同時無法監聽陣列的變化(Vue2中通過重寫陣列原型上的方法解決這一問題),所以在 Vue3 中將其換成了功能更強大的Proxy。
2、如何進行依賴收集——實現 Dep 類
基於建構函式實現:
function Dep() { // 用deps陣列來儲存各項依賴 this.deps = [] } // Dep.target用來記錄正在執行的watcher例項,這是一個全域性唯一的 Watcher // 這是一個非常巧妙的設計,因為JS是單執行緒的,在同一時間只能有一個全域性的 Watcher 被計算 Dep.target = null // 在原型上定義depend方法,每個例項都能訪問 Dep.prototype.depend = function() { if (Dep.target) { this.deps.push(Dep.target) } } // 在原型上定義notify方法,用於通知watcher更新 Dep.prototype.notify = function() { this.deps.forEach(watcher => { watcher.update() }) } // Vue中會有巢狀的邏輯,比如元件巢狀,所以利用棧來記錄巢狀的watcher // 棧,先入後出 const targetStack = [] function pushTarget(_target) { if (Dep.target) targetStack.push(Dep.target) Dep.target = _target } function popTarget() { Dep.target = targetStack.pop() }
這裡主要理解原型上的兩個方法:depend 和 notify,一個用於新增依賴,一個用於通知更新。我們說收集“依賴”,那 this.deps 數組裡到底存的是啥東西啊?Vue 設定了 Watcher 的概念用作依賴表示,即 this.deps 裡收集的是一個個 Watcher。
3、資料變化時如何更新——實現 Watcher 類
Watcher,在Vue中有三種類型,分別用於頁面渲染以及computed和watch這兩個API,為了區分,將不同用處的 Watcher 分別稱為 renderWatcher、computedWatcher 和 watchWatcher。
用 class 實現一下:
class Watcher { constructor(expOrFn) { // 這裡傳入引數不是函式時IydHG需要解析,parsePath略 this.getter = typeof expOrFn === 'function' ? expOrFn : parsePath(expOrFn) this.get() } // class中定義函式不需要寫function get() { // 執行到這時,this是當前的watcher例項,也是Dep.target pushTarget(this) this.value = this.getter() popTarget() } update() { this.get() } }
到這裡,一個簡單的響應式系統就成形了,總結來說:Object.defineProperty 讓我們能夠知道誰訪問了資料以及什麼時候資料發生變化,Dep 可以記錄都有哪些 DOM 和某個資料有關,Watcher 可以在資料變化的時候通知 DOM 去更新。
Watcher和Dep是一個非常經典的觀察者設計模式的實現。
二、虛擬 DOM 和 diff
1、虛擬 DOM 是什麼?
虛擬 DOM 是用 JS 中的物件來表示真實的DOM,如果有資料變動,先在虛擬 DOM 上改動,最後再去改動真實的DOM,good idea!💡
關於虛擬 DOM 的優勢,還是聽尤大的:
在我看來 Virtual DOM 真正的價值從來都不是效能,而是它 1) 為函式式的 UI 方式打開了大門;2) 可以渲染到 DOM 以外的 backend。
舉個例子:
<template> <div id="app" class="container"> <h1>HELLO WORLD!</h1> </div> </template>
// 對應的vnode { tag: 'div',props: { id: 'app',class: 'cowww.cppcns.comntainer' },children: { tag: 'h1',children: 'HELLO WORLD!' } }
我們可以這樣去定義:
function VNode(tag,data,childern,text,elm) { this.tag = tag this.data = data this.childern = childern this.text = text this.elm = elm // 對真實節點的引用 }
2、diff 演算法——新舊節點對比
資料變化時,會觸發渲染 watcher 的回撥,更新檢視。Vue 原始碼中在更新檢視時用 patch 方法比較新舊節點的異同。
(1)判斷新舊節點是不是相同節點
function sameVNode() function sameVnode(a,b) { return a.key === b.key && ( a.tag === b.tag && a.isComment === b.isComment && isDef(a.data) === isDef(b.data) && sameInwww.cppcns.computType(a,b) ) }
(2)若新舊節點不同
替換舊節點:建立新節點 --> 刪除舊節點
(3)若新舊節點相同
- 都沒有子節點,好說
- 一個有子節點一個沒有,好說,要麼刪除個子節點要麼新增個子節點
- 都有子節點,這可就有點複雜了,執行updateChildren:
function updateChildren (parentElm,oldCh,newCh,insertedVnodeQueue,removeOnly) { let oldStartIdx = 0 let newStartIdx = 0 let oldEndIdx = oldCh.length - 1 let oldStartVnode = oldCh[0] let oldEndVnode = oldCh[oldEndIdx] let newEndIdx = newCh.length - 1 let newStartVnode = newCh[0] let newEndVnode = newCh[newEndIdx] let oldKeyToIdx,idxInOld,vnodeToMove,refElm // 以上是新舊Vnode的首尾指標、新舊Vnode的首尾節點 while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) { // 如果不滿足這個while條件,表示新舊Vnode至少有一個已經遍歷了一遍了,就退出迴圈 if (isUndef(oldStartVnode)) { oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left } else if (isUndef(oldEndVnode)) { oldEndVnode = oldCh[--oldEndIdx] } else if (sameVnode(oldStartVnode,newStartVnode)) { // 比較舊的開頭和新的開頭是否是相同節點 patchVnode(oldStartVnode,newStartVnode,insertedVnodeQueue) oldStartVnode = oldCh[++oldStartIdx] newStartVnode = newCh[++newStartIdx] } else if (sameVnode(oldEndVnode,newEndVnode)) { // 比較舊的結尾和新的結尾是否是相同節點 patchVnode(oldEndVnode,newEndVnode,insertedVnodeQueue) oldEndVnode = oldCh[--oldEndIdx] newEndVnode = newCh[--newEndIdx] } else if (sameVnode(oldStartVnode,newEndVnode)) { // Vnode moved right // 比較舊的開頭和新的結尾是否是相同節點 patchVnode(oldStartVnode,insertedVnodeQueue) canMove && nodeOps.insertBefore(parentElm,oldStartVnode.elm,nodeOps.nextSibling(oldEndVnode.elm)) oldStartVnode = oldCh[++oldStartIdx] newEndVnode = newCh[--newEndIdx] } else if (sameVnode(oldEndVnode,newStartVnode)) { // Vnode moved left // 比較舊的結尾和新的開頭是否是相同節點 patchVnode(oldEndVnode,oldEndVnode.elm,oldStartVnode.elm) oldEndVnode = oldCh[--oldEndIdx] newStartVnode = newCh[++newStartIdx] } else { // 設定key和不設定key的區別: // 不設key,newCh和oldCh只會進行頭尾兩端的相互比較,設key後,除了頭尾兩端的比較外,還會從用key生成的物件oldKeyToIdx中查詢匹配的節點,所以為節點設定key可以更高效的利用dom。 if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh,oldStartIdx,oldEndIdx) idxInOld = isDef(newStartVnode.key) ? oldKeyToIdx[newStartVnode.key] : findIdxInOld(newStartVnode,oldEndIdx) // 抽取出oldVnode序列的帶有key的節點放在map中,然後再遍歷新的vnode序列 // 判斷該vnode的key是否在map中,若在則找到該key對應的oldVnode,如果此oldVnode與遍歷到的vnode是sameVnode的話,則複用dom並移動dom節點位置 if (isUndef(idxInOld)) { // New element createElm(newStartVnode,parentElm,false,newStartIdx) } else { vnodeToMove = oldCh[idxInOld] if (sameVnode(vnodeToMove,newStartVnode)) { patchVnode(vnodeToMove,insertedVnodeQueue) oldCh[idxInOld] = undefined canMove && nodeOps.insertBefore(parentElm,vnodeToMove.elm,oldStartVnode.elm) } else { // same key but different element. treat as new element createElm(newStartVnode,newStartIdx) } } newStartVnode = newCh[++newStartIdx] } } if (oldStartIdx > oldEndIdx) { refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm addVnodes(parentElm,refElm,newStartIdx,newEndIdx,insertedVnodeQueue) } else if (newStartIdx > newEndIdx) { removeVnodes(parentElm,oldEndIdx) } }
這裡主要的邏輯是:新節點的頭和尾與舊節點的頭和尾分別比較,看是不是相同節點,如果是就直接patchVnode;否則的話,用一個 Map 儲存舊節點的 key,然後遍歷新節點的 key 看它們是不是在舊節點中存在,相同 key 那就複用;這裡時間複雜度是O(n),空間複雜度也是O(n),用空間換時間~
diff 演算法主要是為了減少更新量,找到最小差異部分 DOM ,只更新差異部分。
三、nextTick
所謂 nextTick,即下一個 tick,那 tick 是什麼呢?
我們知道 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、setInterval;常見的 micro task 有 MutationObsever 和 Promise.then。
我們知道資料的變化到 DOM 的重新渲染是一個非同步過程,發生在下一個 tick。比如我們平時在開發的過程中,從服務端介面去獲取資料的時候,資料做了修改,如果我們的某些方法去依賴了資料修改後的 DOM 變化,我們就必須在nextTick後執行。比如下面的虛擬碼:
getData(res).then(() => { this.xxx = res.data this.$nextTick(() => { // 這裡我們可以獲取變化後的 DOM }) })
四、總結
到此這篇關於Vue原始碼學習之響應式是如何實現的文章就介紹到這了,更多相關Vue響應式實現內容請搜尋我們以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援我們!