Vue2 原理淺談

小深刻的秋鼠發表於2017-10-27

本文重點講述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
歡迎繼續觀光我的新部落格~

歡迎關注

相關文章