Vue源碼學(xué)習(xí)之響應(yīng)式是如何實現(xiàn)的
前言
作為前端開發(fā),我們的日常工作就是將數(shù)據(jù)渲染到頁面➕處理用戶交互。在 Vue 中,數(shù)據(jù)變化時頁面會重新渲染,比如我們在頁面上顯示一個數(shù)字,旁邊有一個點擊按鈕,每次點擊一下按鈕,頁面上所顯示的數(shù)字會加一,這要怎么去實現(xiàn)呢?
按照原生 JS 的邏輯想一想,我們應(yīng)該做三件事:監(jiān)聽點擊事件,在事件處理函數(shù)中修改數(shù)據(jù),然后手動去修改 DOM 重新渲染,這和我們使用 Vue 的最大區(qū)別在于多了一步【手動去修改DOM重新渲染】,這一步看起來簡單,但我們得考慮幾個問題:
- 需要修改哪個 DOM ?
- 數(shù)據(jù)每變化一次就需要去修改一次 DOM 嗎?
- 怎么去保證修改 DOM 的性能?
所以要實現(xiàn)一個響應(yīng)式系統(tǒng)并不簡單🍳,來結(jié)合 Vue 源碼學(xué)習(xí)一下 Vue 中優(yōu)秀的思想叭~
一、一個響應(yīng)式系統(tǒng)的關(guān)鍵要素
1、如何監(jiān)聽數(shù)據(jù)變化
顯然通過監(jiān)聽所有用戶交互事件來獲取數(shù)據(jù)變化是非常繁瑣的,且有些數(shù)據(jù)的變動也不一定是用戶觸發(fā)的,那Vue是怎么監(jiān)聽數(shù)據(jù)變化的呢?—— Object.defineProperty
Object.defineProperty 方法為什么能監(jiān)聽數(shù)據(jù)變化?該方法可以直接在一個對象上定義一個新屬性,或者修改一個對象的現(xiàn)有屬性, 并返回這個對象,先來看一下它的語法:
Object.defineProperty(obj, prop, descriptor) // obj是傳入的對象,prop是要定義或修改的屬性,descriptor是屬性描述符
這里比較核心的是 descriptor,它有很多可選鍵值。這里我們最關(guān)心的是 get 和 set,其中 get 是一個給屬性提供的 getter 方法,當(dāng)我們訪問了該屬性的時候會觸發(fā) getter 方法;set 是一個給屬性提供的 setter 方法,當(dāng)我們對該屬性做修改的時候會觸發(fā) setter 方法。
簡言之,一旦一個數(shù)據(jù)對象擁有了 getter 和 setter,我們就可以輕松監(jiān)聽它的變化了,并可將其稱之為響應(yīng)式對象。具體怎么做呢?
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, prop, { get() { // 訪問對象屬性了,說明依賴當(dāng)前對象屬性,把依賴收集起來 dep.depend() return val } set(newVal) { if (newVal === val) return // 數(shù)據(jù)被修改了,該通知相關(guān)人員更新相應(yīng)的視圖了 val = newVal dep.notify() } }) // 深層監(jiān)聽 if (isObject(val)) { observe(val) } return obj }
這里我們需要一個 Dep 類(dependency)來做依賴收集🎭
PS:Object.defineProperty 只能監(jiān)聽已存在的屬性,對于新增的屬性就無能為力了,同時無法監(jiān)聽數(shù)組的變化(Vue2中通過重寫數(shù)組原型上的方法解決這一問題),所以在 Vue3 中將其換成了功能更強(qiáng)大的Proxy。
2、如何進(jìn)行依賴收集——實現(xiàn) Dep 類
基于構(gòu)造函數(shù)實現(xiàn):
function Dep() { // 用deps數(shù)組來存儲各項依賴 this.deps = [] } // Dep.target用來記錄正在運行的watcher實例,這是一個全局唯一的 Watcher // 這是一個非常巧妙的設(shè)計,因為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 數(shù)組里到底存的是啥東西???Vue 設(shè)置了 Watcher 的概念用作依賴表示,即 this.deps 里收集的是一個個 Watcher。
3、數(shù)據(jù)變化時如何更新——實現(xiàn) Watcher 類
Watcher,在Vue中有三種類型,分別用于頁面渲染以及computed和watch這兩個API,為了區(qū)分,將不同用處的 Watcher 分別稱為 renderWatcher、computedWatcher 和 watchWatcher。
用 class 實現(xiàn)一下:
class Watcher { constructor(expOrFn) { // 這里傳入?yún)?shù)不是函數(shù)時需要解析,parsePath略 this.getter = typeof expOrFn === 'function' ? expOrFn : parsePath(expOrFn) this.get() } // class中定義函數(shù)不需要寫function get() { // 執(zhí)行到這時,this是當(dāng)前的watcher實例,也是Dep.target pushTarget(this) this.value = this.getter() popTarget() } update() { this.get() } }
到這里,一個簡單的響應(yīng)式系統(tǒng)就成形了,總結(jié)來說:Object.defineProperty 讓我們能夠知道誰訪問了數(shù)據(jù)以及什么時候數(shù)據(jù)發(fā)生變化,Dep 可以記錄都有哪些 DOM 和某個數(shù)據(jù)有關(guān),Watcher 可以在數(shù)據(jù)變化的時候通知 DOM 去更新。
Watcher 和 Dep 是一個非常經(jīng)典的觀察者設(shè)計模式的實現(xiàn)。
二、虛擬 DOM 和 diff
1、虛擬 DOM 是什么?
虛擬 DOM 是用 JS 中的對象來表示真實的DOM,如果有數(shù)據(jù)變動,先在虛擬 DOM 上改動,最后再去改動真實的DOM,good idea!💡
關(guān)于虛擬 DOM 的優(yōu)勢,還是聽尤大的:
在我看來 Virtual DOM 真正的價值從來都不是性能,而是它 1) 為函數(shù)式的 UI 編程方式打開了大門;2) 可以渲染到 DOM 以外的 backend。
舉個例子:
<template> <div id="app" class="container"> <h1>HELLO WORLD!</h1> </div> </template>
// 對應(yīng)的vnode { tag: 'div', props: { id: 'app', class: 'container' }, 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 // 對真實節(jié)點的引用 }
2、diff 算法——新舊節(jié)點對比
數(shù)據(jù)變化時,會觸發(fā)渲染 watcher 的回調(diào),更新視圖。Vue 源碼中在更新視圖時用 patch 方法比較新舊節(jié)點的異同。
(1)判斷新舊節(jié)點是不是相同節(jié)點
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) && sameInputType(a, b) ) }
(2)若新舊節(jié)點不同
替換舊節(jié)點:創(chuàng)建新節(jié)點 --> 刪除舊節(jié)點
(3)若新舊節(jié)點相同
- 都沒有子節(jié)點,好說
- 一個有子節(jié)點一個沒有,好說,要么刪除個子節(jié)點要么新增個子節(jié)點
- 都有子節(jié)點,這可就有點復(fù)雜了,執(zhí)行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的首尾節(jié)點 while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) { // 如果不滿足這個while條件,表示新舊Vnode至少有一個已經(jīng)遍歷了一遍了,就退出循環(huán) if (isUndef(oldStartVnode)) { oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left } else if (isUndef(oldEndVnode)) { oldEndVnode = oldCh[--oldEndIdx] } else if (sameVnode(oldStartVnode, newStartVnode)) { // 比較舊的開頭和新的開頭是否是相同節(jié)點 patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue) oldStartVnode = oldCh[++oldStartIdx] newStartVnode = newCh[++newStartIdx] } else if (sameVnode(oldEndVnode, newEndVnode)) { // 比較舊的結(jié)尾和新的結(jié)尾是否是相同節(jié)點 patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue) oldEndVnode = oldCh[--oldEndIdx] newEndVnode = newCh[--newEndIdx] } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right // 比較舊的開頭和新的結(jié)尾是否是相同節(jié)點 patchVnode(oldStartVnode, newEndVnode, 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 // 比較舊的結(jié)尾和新的開頭是否是相同節(jié)點 patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue) canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm) oldEndVnode = oldCh[--oldEndIdx] newStartVnode = newCh[++newStartIdx] } else { // 設(shè)置key和不設(shè)置key的區(qū)別: // 不設(shè)key,newCh和oldCh只會進(jìn)行頭尾兩端的相互比較,設(shè)key后,除了頭尾兩端的比較外,還會從用key生成的對象oldKeyToIdx中查找匹配的節(jié)點,所以為節(jié)點設(shè)置key可以更高效的利用dom。 if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx) idxInOld = isDef(newStartVnode.key) ? oldKeyToIdx[newStartVnode.key] : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx) // 抽取出oldVnode序列的帶有key的節(jié)點放在map中,然后再遍歷新的vnode序列 // 判斷該vnode的key是否在map中,若在則找到該key對應(yīng)的oldVnode,如果此oldVnode與遍歷到的vnode是sameVnode的話,則復(fù)用dom并移動dom節(jié)點位置 if (isUndef(idxInOld)) { // New element createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx) } else { vnodeToMove = oldCh[idxInOld] if (sameVnode(vnodeToMove, newStartVnode)) { patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue) oldCh[idxInOld] = undefined canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm) } else { // same key but different element. treat as new element createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx) } } newStartVnode = newCh[++newStartIdx] } } if (oldStartIdx > oldEndIdx) { refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue) } else if (newStartIdx > newEndIdx) { removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx) } }
這里主要的邏輯是:新節(jié)點的頭和尾與舊節(jié)點的頭和尾分別比較,看是不是相同節(jié)點,如果是就直接patchVnode;否則的話,用一個 Map 存儲舊節(jié)點的 key,然后遍歷新節(jié)點的 key 看它們是不是在舊節(jié)點中存在,相同 key 那就復(fù)用;這里時間復(fù)雜度是O(n),空間復(fù)雜度也是O(n),用空間換時間~
diff 算法主要是為了減少更新量,找到最小差異部分 DOM ,只更新差異部分。
三、nextTick
所謂 nextTick,即下一個 tick,那 tick 是什么呢?
我們知道 JS 執(zhí)行是單線程的,它處理異步邏輯是基于事件循環(huán),主要分為以下幾步:
- 所有同步任務(wù)都在主線程上執(zhí)行,形成一個執(zhí)行棧(execution context stack);
- 主線程之外,還存在一個"任務(wù)隊列"(task queue)。只要異步任務(wù)有了運行結(jié)果,就在"任務(wù)隊列"之中放置一個事件;
- 一旦"執(zhí)行棧"中的所有同步任務(wù)執(zhí)行完畢,系統(tǒng)就會讀取"任務(wù)隊列",看看里面有哪些事件。那些對應(yīng)的異步任務(wù),于是結(jié)束等待狀態(tài),進(jìn)入執(zhí)行棧,開始執(zhí)行;
- 主線程不斷重復(fù)上面的第三步。
主線程的執(zhí)行過程就是一個 tick,而所有的異步結(jié)果都是通過 “任務(wù)隊列” 來調(diào)度。 消息隊列中存放的是一個個的任務(wù)(task)。 規(guī)范中規(guī)定 task 分為兩大類,分別是 macro task 和 micro task,并且每個 macro task 結(jié)束后,都要清空所有的 micro task。
for (macroTask of macroTaskQueue) { // 1. Handle current MACRO-TASK handleMacroTask() // 2. Handle all MICRO-TASK for (microTask of microTaskQueue) { handleMicroTask(microTask) } }
在瀏覽器環(huán)境中,常見的 macro task 有 setTimeout、MessageChannel、postMessage、setImmediate、setInterval;常見的 micro task 有 MutationObsever 和 Promise.then。
我們知道數(shù)據(jù)的變化到 DOM 的重新渲染是一個異步過程,發(fā)生在下一個 tick。比如我們平時在開發(fā)的過程中,從服務(wù)端接口去獲取數(shù)據(jù)的時候,數(shù)據(jù)做了修改,如果我們的某些方法去依賴了數(shù)據(jù)修改后的 DOM 變化,我們就必須在 nextTick 后執(zhí)行。比如下面的偽代碼:
getData(res).then(() => { this.xxx = res.data this.$nextTick(() => { // 這里我們可以獲取變化后的 DOM }) })
四、總結(jié)
到此這篇關(guān)于Vue源碼學(xué)習(xí)之響應(yīng)式是如何實現(xiàn)的文章就介紹到這了,更多相關(guān)Vue響應(yīng)式實現(xiàn)內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
詳解element-ui 組件el-autocomplete使用踩坑記錄
最近使用了el-autocomplete組件,本文主要介紹了element-ui 組件el-autocomplete使用踩坑記錄,文中通過示例代碼介紹的非常詳細(xì),具有一定的參考價值,感興趣的小伙伴們可以參考一下2022-03-03vue動態(tài)生成新表單并且添加驗證校驗規(guī)則方式
這篇文章主要介紹了vue動態(tài)生成新表單并且添加驗證校驗規(guī)則方式,具有很好的參考價值,希望對大家有所幫助。如有錯誤或未考慮完全的地方,望不吝賜教2022-10-10Vue實現(xiàn)導(dǎo)航欄點擊當(dāng)前標(biāo)簽變色功能
這篇文章主要為大家詳細(xì)介紹了Vue實現(xiàn)導(dǎo)航欄點擊當(dāng)前標(biāo)簽變色功能,文中示例代碼介紹的非常詳細(xì),具有一定的參考價值,感興趣的小伙伴們可以參考一下2019-05-05vue2.0$nextTick監(jiān)聽數(shù)據(jù)渲染完成之后的回調(diào)函數(shù)方法
今天小編就為大家分享一篇vue2.0$nextTick監(jiān)聽數(shù)據(jù)渲染完成之后的回調(diào)函數(shù)方法,具有很好的參考價值,希望對大家有所幫助。一起跟隨小編過來看看吧2018-09-09