本文重點講述Vue2渲染的整體流程,包括資料響應的實現(雙向繫結)、模板編譯、virtual dom原理等,希望讀者看完有所收穫。
部落格同步原文連結:imhjm.com/article/59b…
前言
此部分內容初步介紹前端主流框架部分特點,來提高大家對框架的認識,從而最後匯出對vue2原理的整體介紹
參考尤雨溪的live 不吹不黑聊聊前端框架
有興趣的同學可以聽聽
現代主流框架均使用一種資料=>檢視
的方式,隱藏了繁瑣的dom操作,採用了宣告式程式設計(Declarative Programming
)替代了過去的類jquery的指令式程式設計(Imperative Programming
)
$("#xxx").text("xxx");
// 變為下者
view = render(state);複製程式碼
前者我們詳細地寫了如何去操作dom節點的過程,我們命令什麼,它就操作什麼;
後者則是我們輸入了資料狀態,輸出檢視(我們不關心中間的過程,它們均由框架幫助我們實現);
前者固然直接,但是當應用變得複雜則程式碼將難以維護,而後者框架幫我們實現了一系列的操作,無需管理過程,優勢顯然可見。
為了實現這一點,就是實現如何輸入資料,輸出檢視,我們就會注意到上面的render函式,render函式的實現,主要在對dom效能的優化上,當然實現方式也多種多樣,直接的innerHTML、使用documentFragment、還有virtual dom,在不同場景下效能上有所不同,但是框架追求的是在大部分場景中框架已經滿足你的優化需求,這裡我們也不加以贅述,後文會提到。
當然還有資料變化偵測,從而re-render檢視,資料變化偵測中,值得一提的是資料生產者(Producer
)和資料消費者(Consumer
)之間的聯絡,這裡,我們可以暫且將系統(檢視)作為一個資料的消費者,我們的程式碼設定資料的變化,作為資料的生產者
我們這裡可以分為系統不可感知資料變化
和系統可感知資料變化
Rx.js中是將兩者通訊分成拉取(
Pull
)和推送(Push
),比較不好理解,這裡我自己就分了個類
- 系統不可感知資料變化
像React/Angular這類框架並不知道資料什麼時候變了,但是它檢視什麼時候更新呢,比如React就是通過setState發訊號告訴系統有可能資料變了,然後通過virtual dom diff去渲染檢視,angular則是有一個髒值檢查流程,遍歷比對
- 系統可感知資料變化
Rx.js / vue這一類響應式的,通過觀察者模式,使用Observable (可觀察物件),Observer (觀察者)(或者是watcher)去訂閱(比如檢視渲染這一類,其實也可以當成一個觀察者去訂閱資料了,後面會提到),系統是可以很準確知道哪裡資料變了的,從而也就能實現檢視更新渲染。
上者系統不可感知資料變化
,粒度粗,有時候還得手動優化(比如pureComponet和shouldComponentUpdate)去跳過一些資料不會更新的檢視從而提升效能
下者系統可感知資料變化
,粒度細,但是繫結大量觀察者,有大量的依賴追蹤的記憶體開銷
所以
這裡也就終於提到本文的主角Vue2,它採用了折中粒度的方式,粒度到元件級別上,由watcher訂閱資料,當資料變化我們可以得知哪個元件資料變了,然後採用virtual dom diff的方式去更新相應元件。
後文我們也將展開它是如何實現這些過程的,我們可以先從一個簡單的應用開始。
從一個簡單的應用看起
<div id="app">
{{ message }}
</div>
var app = new Vue({
el: '#app',
data: {
message: 'Hello Vue!'
}
})
app.message = `xxx`; // 發現檢視發生了變化複製程式碼
從這裡我們也可以提出幾個問題,讓後面原理的解析更有針對性。
- 資料響應?如何得知資料變化?
還有一個小細節,app.message如何拿到vue data中的message?
- 資料變動如何和檢視聯絡在一起?
- virtual dom是什麼?virtual dom diff又是什麼?
當然同時我們也會講解一些收集依賴等相關的概念。
資料響應原理
Object.defineProperty
Vue資料響應核心是使用了Object.defineProperty
方法(IE9+)在物件中定義屬性或者修改屬性,其中存取描述符很關鍵的就是get和set,提供給屬性getter和setter方法
可以看下面例子,我們攔截到了資料獲取以及設定
var obj = {};
Object.defineProperty(obj, 'msg', {
get () {
console.log('get')
},
set (newValue) {
console.log('set', newValue)
}
});
obj.msg // get
obj.msg = 'hello world' // set hello world複製程式碼
順便提到那個小細節的問題
app.message如何拿到vue data中的message?
其實也是跟Object.defineProperty
有關
Vue在初始化資料的時候會遍歷data代理這些資料
function initData (vm) {
let data = vm.$options.data
vm._data = data
const keys = Object.keys(data)
let i = keys.length
while (i--) {
const key = keys[i]
proxy(vm, `_data`, key)
}
observe(data)
}複製程式碼
proxy做了哪些操作呢?
function proxy (target, sourceKey, key) {
Object.defineProperty(target, key, {
enumerable: true,
configurable: true,
get () {
return this[sourceKey][key]
}
set () {
this[sourceKey][key] = val
}
})
}複製程式碼
其實就是用Object.defineProperty
多加了一層的訪問
因此我們就可以用app.message
訪問到app.data.message
也算個Object.defineProperty
小應用吧
講完這語法的核心層面得知了如何知道資料發生變化,但是響應,是還有迴應的,接下來來談下Vue是如何實現資料響應的?
其實就是解決下面的問題,如何實現$watch?
const vm = new Vue({
data:{
msg: 1,
}
})
vm.$watch("msg", () => console.log("msg變了"));
vm.msg = 2; //輸出「msg變了」複製程式碼
觀察者模式(Observer, Watcher, Dep)
Vue實現響應式有三個很重要的類,Observer類,Watcher類,Dep類
我這裡先籠統介紹一下(詳細可見原始碼英文註解)
- Observer類主要用於給Vue的資料
defineProperty
增加getter/setter方法,並且在getter/setter中收集依賴或者通知更新 - Watcher類來用於觀察資料(或者表示式)變化然後執行回撥函式(其中也有收集依賴的過程),主要用於$watch API和指令上
- Dep類就是一個可觀察物件,可以有不同指令訂閱它(它是多播的)
觀察者模式,跟釋出/訂閱模式有點像
但是其實略有不同,釋出/訂閱模式是由統一的事件分發排程中心,on則往中心中陣列加事件(訂閱),emit則從中心中陣列取出事件(釋出),釋出和訂閱以及釋出後排程訂閱者的操作都是由中心統一完成
但是觀察者模式則沒有這樣的中心,觀察者訂閱了可觀察物件,當可觀察物件釋出事件,則就直接排程觀察者的行為,所以這裡觀察者和可觀察物件其實就產生了一個依賴的關係,這個是釋出/訂閱模式上沒有體現的。
其實Dep就是dependence依賴的縮寫
如何實現觀察者模式呢?
我們先看下面程式碼,下面程式碼實現了Watcher去訂閱Dep的過程,Dep由於是可以被多個Watcher所訂閱的,所以它擁有著訂閱者陣列,訂閱了它,就把Watcher放入陣列即可。
class Dep {
constructor () {
this.subs = []
}
notify () {
const subs = this.subs.slice()
for (let i = 0; i < subs.length; i++) {
subs[i].update()
}
}
addSub (sub) {
this.subs.push(sub)
}
}
class Watcher {
constructor () {
}
update () {
}
}
let dep = new Dep()
dep.addSub(new Watcher()) // Watcher訂閱了依賴複製程式碼
我們實現了訂閱,那通知釋出呢,也就是上面的notify在哪裡實現呢?
我們到這裡就可以聯絡到資料響應,我們需要的是資料變化去通知更新,那顯然是會在defineProperty中的setter中去實現了,聰明的你應該想到了,我們可以把每一個資料當成一個Dep例項,然後setter的時候去notify就行了,所以我們可以在defineProperty中new Dep(),通過閉包setter就可以取到Dep例項了
就像下面這樣
function defineReactive (obj, key, val) {
const dep = new Dep()
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter () {
//...
},
set: function reactiveSetter (newVal) {
//...
dep.notify()
}
})
}複製程式碼
然後這裡就又產生了一個問題
你都把Dep例項放裡面了,我怎麼讓我的Watcher例項訂閱到這個Dep例項呢,Vue在這裡實現了精妙的一筆,從get裡面做手腳,在get中是可以取到這個Dep例項的,所以可以在執行watch操作的時候,執行獲取數值,觸發getter去收集依賴
function defineReactive (obj, key, val) {
const dep = new Dep()
const property = Object.getOwnPropertyDescriptor(obj, key)
const getter = property && property.get
const setter = property && property.set
let childOb = observe(val)
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter () {
const value = getter ? getter.call(obj) : val
if (Dep.target) {
dep.depend() // 等價執行dep.addSub(Dep.target),在這裡收集
}
return value
},
set: function reactiveSetter (newVal) {
const value = getter ? getter.call(obj) : val
if (newVal === value) {
return
}
if (setter) {
setter.call(obj, newVal)
} else {
val = newVal
}
dep.notify()
}
})複製程式碼
這裡我們也要結合Watcher的實現來看
class Watcher () {
constructor (vm, expOrFn, cb, options) {
this.cb = cb
this.value = this.get()
}
get () {
pushTarget(this) // 標記全域性變數Dep.target
let value = this.getter.call(vm, vm) // 觸發getter
if (this.deep) {
traverse(value)
}
popTarget() // 標記全域性變數Dep.target
return value
}
update () {
this.run()
}
run () {
const value = this.get() // new Value
// re-collect dep
if (value !== this.value ||
isObject(value)) {
const oldValue = this.value
this.value = value
this.cb.call(this.vm, value, oldValue)
}
}
}複製程式碼
所以我們在new Watcher的時候會執行一個求值的操作,然後因為標記了這個Watcher觸發的,所以收集了依賴,也就是觀察者訂閱了依賴(這個求值有可能不止觸發了一個getter,有可能觸發了很多個getter,那就收集了多個依賴),我們可以再注意一下上面的run操作,也就是dep.notify()後watcher會執行的操作,還會出現一個get操作,我們可以注意到這裡重新收集了一波依賴!(當然裡面有相關的去重操作)
我們再回來回顧上面我們要解決的小例子
const vm = new Vue({
data: {
msg: 1,
}
})
vm.$watch("msg", () => console.log("msg變了"));
vm.msg = 2; //輸出「變了」複製程式碼
$watcher其實就是一個new Watcher的封裝
即new Watcher(vm, 'msg', () => console.log("msg變了"))
- 首先是new Vue遍歷了資料,給資料defineProperty加上了getter/setter方法
- 我們
new Watcher(vm, 'msg', () => console.log("msg變了"))
,首先標記了全域性變數Dep.target = 該Watcher例項,然後執行msg的get操作,觸發到了它的getter,然後dep成功獲取到它的訂閱者,放入它的訂閱者陣列,最後我們將Dep.target = null - 最後設定vm.msg = 2,觸發到了setter,閉包中的dep.notify,遍歷訂閱者陣列,執行相應的回撥操作。
其實講到這裡,核心的響應式原理就講得差不多了。
但是其實Object.defineProperty並不是萬能的,
- 陣列的push/pop等操作
- 不能監測陣列length長度的變化
- 陣列的arr[xxx] = yyy無法感知
- 同樣的,物件屬性的新增和刪除無法感知
為了解決這些本身js限制的問題
- Vue首先是對陣列方法進行變異,用
__proto__
繼承那些方法(如果不行則直接一個個defineProperty到陣列上),具體的變異方法就是在後面加上dep.notify的操作 - 至於屬性的新增和刪除,我們可以想象到,增加屬性,那我們根本沒有defineProperty,刪除屬性則連我們之前的defineProperty都給刪了,所以這裡Vue增加了一個$set/$delete的API去實現這些操作,同樣也是在最後加上了dep.notify的操作
- 當然以上就不是單純靠defineProperty中每一個資料所對應的dep來實現了,在Observer類也有一個dep例項,同時會給資料掛載一個
__ob__
屬性去獲取它的Observer例項,像陣列和物件的上面特殊操作,在watch收集依賴的時候都會把這個依賴收集到,然後最後使用的是這個dep去notify更新這部分就不詳細介紹了,有興趣的讀者可以閱讀原始碼
這裡我們可以稍微提一下一個ES6的新特性Proxy,很有可能是下一代響應機制的主角,因為它可以解決我們上面的缺陷,但是由於相容問題還不能很好地使用,可以讓我們期待一下~
現在我們再來看看Vue官網的這張圖
至少目前我們對右半部分很清晰了,Data如何和Watcher聯絡已經很清楚,但是Render Function,Watcher怎麼Trigger Render Function這個還需要去解答,當然還有左下角的Virtual DOM Tree
資料與檢視如何聯絡
我這裡摘出一段關鍵的Vue程式碼
class Watcher () {
constructor (vm, expOrFn, cb, options) {
}
}
updateComponent = () => {
// hydrating有關ssr本文不涉及
vm._update(vm._render(), hydrating)
}
vm._watcher = new Watcher(vm, updateComponent, noop)
// noop是回撥函式,它是空函式複製程式碼
這個其實就是Watcher和Render的核心關係
還記得我們上面所說的,在執行new Watcher會有一個求值的操作,這裡的求值是一個函式表示式,也就是執行updateComponent,執行updateComponent後,會再執行vm._render()
,傳引數給vm._update(vm._render(), hydrating)
,收集完依賴以後才結束,這裡有兩個關鍵的點,vm._render
在做什麼?vm._update
在做什麼?
vm._render
我們看下Vue.prototype._render
是何方神聖(以下為刪減程式碼)
Vue.prototype._render = function (): VNode {
const vm: Component = this
const {
render,
staticRenderFns,
_parentVnode
} = vm.$options
// ...
let vnode
try {
// vm._renderProxy我們直接當成vm,其實就是為了開發環境報warning用的
vnode = render.call(vm._renderProxy, vm.$createElement)
} catch (e) {
}
// set parent
vnode.parent = _parentVnode
return vnode
}複製程式碼
所以它這裡我們可以看到裡面是執行了render函式,render函式來自options,然後返回了vnode
所以到這裡我們可以把我們的目光移到這個render函式從哪裡來的
如果熟悉Vue2的朋友可能知道,Vue提供了一個選項是render就是作為這個函式的,假如沒有提供這個選項呢
我們不妨看看生命週期
我們可以看到
Compile template into render function
(沒有template會將el的outerHTML當成template),所以這裡就有一個模板編譯的過程
模板編譯
再摘一段核心程式碼
const ast = parse(template.trim(), options) // 構建抽象語法樹
optimize(ast, options) // 優化
const code = generate(ast, options) // 生成程式碼
return {
ast,
render: code.render,
staticRenderFns: code.staticRenderFns
}複製程式碼
我們可以看到上面分成三部分
- 將模板轉化為抽象語法樹
- 優化抽象語法樹
- 根據抽象語法樹生成程式碼
那裡面具體做了什麼呢?這裡我簡略講一下
- 第一部分其實就是各種正則了,對左右開閉標籤的匹配以及屬性的收集,通過棧的形式,不斷出棧入棧去匹配以及更換父節點,最後生成一個物件,包含children,children又包含children的物件
- 第二部分則是以第一部分為基礎,根據節點型別找出一些靜態的節點並標記
- 第三部分就是生成render函式程式碼了
所以最後會產生這樣的效果
模板
<div id="container">
<p>Message is: {{ message }}</p>
</div>複製程式碼
生成render函式
(function() {
with (this) {
return _c('div', {
attrs: {
"id": "container"
}
}, [_c('p', [_v("Message is: " + _s(message))])])
}
}
)複製程式碼
這裡我們又可以結合上面的程式碼了
vnode = render.call(vm._renderProxy, vm.$createElement)複製程式碼
其中_c
就是vm.$createElement
我們將virtual dom具體實現移到下一節,以防影響我們Vue2主線
vm.$createElement其實就是一個建立vnode的一個API
知道了vm._render()
建立了vnode返回,接下來就是vm._update
了
vm._update
vm._update
部分也是跟virtual dom有關,下一節具體介紹,我們可以先透露下函式的功能,顧名思義,就是更新檢視,根據傳入的vnode更新到檢視中。
資料到檢視的整體流程
所以到這裡我們就可以得出一個資料到檢視的整體流程的結論了
- 在元件級別,vue會執行一個new Watcher
- new Watcher首先會有一個求值的操作,它的求值就是執行一個函式,這個函式會執行render,其中可能會有編譯模板成render函式的操作,然後生成vnode(virtual dom),再將virtual dom應用到檢視中
- 其中將virtual dom應用到檢視中(這裡涉及到diff後文會講),一定會對其中的表示式求值(比如{{message}},我們肯定會取到它的值再去渲染的),這裡會觸發到相應的getter操作完成依賴的收集
- 當資料變化的時候,就會notify到這個元件級別的Watcher,然後它還會去求值,從而重新收集依賴,並且重新渲染檢視
我們再一次來看看Vue官網的這張圖
一切順理成章!
Virtual DOM
我們上一節隱藏了很多Virtual DOM的細節,是因為Virtual DOM大篇幅有可能讓我們忘記我們所要探究的問題,這裡我們來揭開Virtual DOM的謎團,它其實並沒有那麼神祕。
為什麼會有Virtual DOM?
做過前端效能優化的朋友應該都知道,DOM操作都是很慢的,我們要減少對它的操作
為啥慢呢?
我們可以嘗試打出一層DOM的key
我們可以看出它的屬性是龐大,更何況這只是一層
同時直接對DOM的操作,就必須很注意一些有可能觸發重排的操作。
那Virtual DOM是什麼角色呢?它其實就是我們程式碼到操作DOM的一層緩衝,既然操作DOM慢,那我操作js物件快吧,我就操作js物件,然後最後把這個物件再一起轉換成真正的DOM就行了
所以就變成 程式碼 => Virtual DOM( 一個特殊的js物件) => DOM
什麼是Virtual DOM
上文其實我們就解答了什麼是虛擬DOM,它就是一個特殊的js物件
我們可以看看Vue中的Vnode是怎麼定義的?
export class VNode {
constructor (
tag?: string,
data?: VNodeData,
children?: ?Array<VNode>,
text?: string,
elm?: Node,
context?: Component,
componentOptions?: VNodeComponentOptions,
asyncFactory?: Function
) {
this.tag = tag
this.data = data
this.children = children
this.text = text
this.elm = elm
this.ns = undefined
this.context = context
this.functionalContext = undefined
this.key = data && data.key
this.componentOptions = componentOptions
this.componentInstance = undefined
this.parent = undefined
this.raw = false
this.isStatic = false
this.isRootInsert = true
this.isComment = false
this.isCloned = false
this.isOnce = false
this.asyncFactory = asyncFactory
this.asyncMeta = undefined
this.isAsyncPlaceholder = false
}
}複製程式碼
用以上這些屬性就能來表示一個DOM節點
Virtual DOM演算法
這裡我們講的就是涉及上面vm.update
的操作
- 首先是js物件(Virtual DOM)描述樹(
vm._render
),轉換dom插入(第一次渲染) - 狀態變化,生成新的js物件(Virtual DOM),比對新舊物件
- 將變更應用到DOM上,並儲存新的js物件(Virtual DOM),重複第二步操作
用js物件描述樹(生成Virtual DOM),Vue中就是先轉成AST生成code,然後通過$creatElement通過Vnode的那種形式生成Virtual DOM (vm._render的操作
)
這裡我們可以具體看下vm._update
(其實就是Virtual DOM演算法的後兩步)
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
const vm: Component = this
if (vm._isMounted) {
callHook(vm, 'beforeUpdate')
}
const prevEl = vm.$el
const prevVnode = vm._vnode
// ...
if (!prevVnode) {
// initial render
// 第一次渲染
vm.$el = vm.__patch__(
vm.$el, vnode, hydrating, false /* removeOnly */,
vm.$options._parentElm,
vm.$options._refElm
)
} else {
// updates
// 更新檢視
vm.$el = vm.__patch__(prevVnode, vnode)
}
// ...
}複製程式碼
可以看到一個關鍵點vm.__patch__
,其實它就是Virtual DOM Diff的核心,也是它最後把真實DOM插入的
Virtual DOM Diff
完整Virtual DOM Diff演算法,根據有一篇論文(我忘記在哪裡了),是需要O(n^3)的,因為它涉及跨層級的複用,這種時間複雜度是不可接受的,同時考慮到DOM較少涉及跨層級的複用,所以就減少至當前層級的複用,這個演算法的複雜度就降到O(n)了,Perfect~
引用一張React經典的圖來幫助大家理解吧,左右同一顏色圈起來的就是比較/複用的範圍
步入正題,我們看看Vue的patch函式
function patch (oldVnode, vnode, hydrating, removeOnly, parentElm, refElm) {
if (isUndef(vnode)) {
if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
return
}
let isInitialPatch = false
const insertedVnodeQueue = []
if (isUndef(oldVnode)) {
// empty mount (likely as component), create new root element
// 老節點不存在,直接建立元素
isInitialPatch = true
createElm(vnode, insertedVnodeQueue, parentElm, refElm)
} else {
const isRealElement = isDef(oldVnode.nodeType)
if (!isRealElement && sameVnode(oldVnode, vnode)) {
// patch existing root node
// 新節點和老節點相同,則給老節點打補丁
patchVnode(oldVnode, vnode, insertedVnodeQueue, removeOnly)
} else {
// ... 省略ssr程式碼
// replacing existing element
// 新節點和老節點相同,直接替換老節點
const oldElm = oldVnode.elm
const parentElm = nodeOps.parentNode(oldElm)
createElm(
vnode,
insertedVnodeQueue,
// extremely rare edge case: do not insert if old element is in a
// leaving transition. Only happens when combining transition +
// keep-alive + HOCs. (#4590)
oldElm._leaveCb ? null : parentElm,
nodeOps.nextSibling(oldElm)
)
}
}
// ...省略程式碼
return vnode.elm
}複製程式碼
所以patch大概做下面幾件事
- 判斷老節點存不存在
- 不存在則為首次渲染,直接建立元素
- 存在的話則sameVnode使用判斷根節點是否相同
- 相同則使用patchVnode給老節點打補丁
- 不相同則使用新節點直接替換老節點
對於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)
) || (
isTrue(a.isAsyncPlaceholder) &&
a.asyncFactory === b.asyncFactory &&
isUndef(b.asyncFactory.error)
)
)
)
}複製程式碼
對於patchVnode
其實就是比較節點的子節點,分別對新老節點的擁有的子節點做判斷,假如兩者都沒有或者一者有一者沒有,就比較容易,直接刪除或者增加即可,但是假如兩者都有子節點,這裡就涉及到列表對比以及一些複用操作了,實現的方法是updateChildren
function patchVnode (oldVnode, vnode, insertedVnodeQueue, removeOnly) {
if (oldVnode === vnode) {
// 新老節點相同
return
}
// ... 省略程式碼
if (isUndef(vnode.text)) {
// 假如新節點沒有text
if (isDef(oldCh) && isDef(ch)) {
// 假如老節點和新節點都有子節點
// 不相等則更新子節點
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
} else if (isDef(ch)) {
// 新節點有子節點,老節點沒有
// 老節點加上
if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
} else if (isDef(oldCh)) {
// 老節點有子節點,新節點沒有
// 老節點移除
removeVnodes(elm, oldCh, 0, oldCh.length - 1)
} else if (isDef(oldVnode.text)) {
// 老節點有文字,新節點沒有文字
nodeOps.setTextContent(elm, '')
}
} else if (oldVnode.text !== vnode.text) {
// 假如新節點和老節點text不相等
nodeOps.setTextContent(elm, vnode.text)
}
if (isDef(data)) {
if (isDef(i = data.hook) && isDef(i = i.postpatch)) i(oldVnode, vnode)
}
}複製程式碼
我們最後再來看看這個updateChildren
這部分其實就是leetcode.com/problems/ed… 最小編輯距離問題,這裡也並沒有用複雜的動態規劃演算法(複雜度為O(m * n))去實現最小的移動操作,而是選擇可犧牲一定的dom操作去優化部分場景,複雜度可以降低到O(max(m, n),比較分別首尾節點,如果沒有匹配到,則使用第一個節點key(這裡就是我們常在v-for用的)去找相同的key去patch比較,假如沒有key的話,則是直接遍歷找相似的節點,有則patch移動,沒有則建立新節點
這裡告訴我們
列表假如有可能有複用的節點,可以使用唯一的key去標識,提升patch效率,但是也不能亂設定key,假如根本不一樣,但是你設定一樣的話,會導致框架沒找到真正相似的節點去複用,反而降低效率,會增加一個建立dom的消耗這裡程式碼較多,有興趣的讀者可以深入閱讀,這裡我就不畫圖了,讀者也可以找網上的相應updateChildren的圖,有助於理解patch的過程
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
// removeOnly is a special flag used only by <transition-group>
// to ensure removed elements stay in correct relative positions
// during leaving transitions
const canMove = !removeOnly
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (isUndef(oldStartVnode)) {
// 假如老節點的第一個子節點不存在
// 老節點頭指標就往下一個移動
oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
} else if (isUndef(oldEndVnode)) {
// 假如老節點的最後一個子節點不存在
// 老節點尾指標就往上一個移動
oldEndVnode = oldCh[--oldEndIdx]
} else if (sameVnode(oldStartVnode, newStartVnode)) {
// 假如新節點的第一個和老節點的第一個相同
// patch該節點並且新老節點頭指標分別往下一個移動
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
} else if (sameVnode(oldEndVnode, newEndVnode)) {
// 假如新節點的最後一個和老節點的最後一個相同
// patch該節點並且新老節點尾指標分別往上一個移動
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
// 假如新節點的最後一個和老節點的第一個相同
// patch該節點並且新節點尾指標往上一個移動,老節點頭指標往下一個移動
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
// 假如新節點的第一個和老節點的最後一個相同
// patch該節點並且老節點尾指標往上一個移動,新節點頭指標往下一個移動
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue)
canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
} else {
// 建立老節點key to index的對映
if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key] // 假如新節點第一個有key,找該key下老節點的index
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx) // 假如新節點沒有key,直接遍歷找相同的index
if (isUndef(idxInOld)) { // New element
// 假如沒有找到index,則建立節點
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm)
} else {
// 假如有index,則找出這個需要move的老節點
vnodeToMove = oldCh[idxInOld]
/* istanbul ignore if */
if (process.env.NODE_ENV !== 'production' && !vnodeToMove) {
warn(
'It seems there are duplicate keys that is causing an update error. ' +
'Make sure each v-for item has a unique key.'
)
}
if (sameVnode(vnodeToMove, newStartVnode)) {
// move老節點和新節點的第一個基本相同則開始patch
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)
}
}
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)
}
}複製程式碼
總結
到這裡整體Vue2原理也就講解結束了,還有很多細節沒有深入,讀者可以閱讀原始碼去深入研究。
我們可以再回顧下開頭的問題(其實文中也是不斷的在提出問題解決問題),作為看到這裡的你,希望你能有所收穫~
- 資料響應?如何得知資料變化?(提示:defineProperty)
還有一個小細節,app.message如何拿到vue data中的message?
- 資料變動如何和檢視聯絡在一起?(提示:Watcher、Dep、Observer)
- virtual dom是什麼?virtual dom diff又是什麼?(提示:特殊的js物件)
參考連結/推薦閱讀
最後
謝謝閱讀~
歡迎follow我哈哈github.com/BUPT-HJM
歡迎繼續觀光我的新部落格~
歡迎關注