經過Vue源碼研究其運行流程

研究源碼的好處

今天我來研究一下vue源碼,從中也有很多收穫。css

  1. 有利於平常工做中更恰當的使用vue,優化業務代碼。
  2. 加深理解數據驅動,響應式原理、組件化、diff等底層原理。

從Vue實例開始

來看一個官方文檔的示例vue

<div id="app">
  {{ message }}
</div>

var app = new Vue({
  el: '#app',
  data: {
    message: 'Hello Vue!'
  }
})
複製代碼

Vue的起步會new一個Vue實例,由實例來生成和更新HTML,呈如今瀏覽器上。根實例上,又會有不少子組件,子組件也是繼承自Vue類的實例。因此Vue類是核心。node

源碼目錄

看以前先熟悉一下源碼目錄。es6

src
├── compiler        # 編譯相關 
├── core            # 核心代碼 
├── platforms       # 不一樣平臺的支持
├── server          # 服務端渲染
├── sfc             # .vue 文件解析
├── shared          # 共享代碼
複製代碼

Vue類的代碼,就在core文件夾的instanc文件夾下。web

Vue類的基本結構

查看src/core/instance/index.js文件。算法

import { initMixin } from './init'
import { stateMixin } from './state'
import { renderMixin } from './render'
import { eventsMixin } from './events'
import { lifecycleMixin } from './lifecycle'
import { warn } from '../util/index'

function Vue (options) {
  if (process.env.NODE_ENV !== 'production' &&
    !(this instanceof Vue)
  ) {
    warn('Vue is a constructor and should be called with the `new` keyword')
  }
  this._init(options)
}

initMixin(Vue)
stateMixin(Vue)
eventsMixin(Vue)
lifecycleMixin(Vue)
renderMixin(Vue)

export default Vue

複製代碼

這裏導出的是Vue類。既然是導出一個類,卻沒有使用es6語法class,仍是使用es5的構造函數方式,這樣作的緣由是Vue類內的代碼太多了,須要做拆分,將不一樣邏輯的代碼放在不一樣文件中。如這裏把初始化邏輯拆分到init.js文件,而後initMixin(Vue)將初始化的方法掛在到Vue類上。api

當new Vue時,執行構造函數,this._init(options)被調用。這個_init方法就在initMixin(Vue)被掛在Vue類上的。也就是在實例化以前,對於Vue類的建造,已經完成。瀏覽器

我來寫個僞代碼,initMixin、stateMixin、eventsMixin等拆分出去等代碼,合併到一塊兒,用class呈現一個Vue類的樣子。bash

export default class Vue {
    constructor(options){
        this._init(options);
    }
    
    // init相關
    // 源碼中是用Vue.prototype._init語法,將方法掛到類上
    _init(){ ...初始化邏輯 }
    
    
    // state相關
    
    get $data(){ ... }
    set $data(){ ... }
    get $props(){ ... }
    set $props(){ ... }
    
    $set() { ... }
    $delete(){ ... }
    
    $watch(){ ... }
    
    // event相關
    
    $on(){ ... }
    $once(){ ... }
    $off(){ ... }
    $emit(){ ... }
    
    // lifecycleMixin相關
    _update(){ ... }
    
    $forceUpdate(){ ... }
    $destroy(){ ... }
    
    // render相關
    
    $nextTick(){ ... }
    _render(){ ... }
    
    // 掛載相關
    $mount() { ... }
}
複製代碼

首先看到是一種規範,下劃線開頭的方法,是私有方法。開頭的方法,是公開的方法。咱們用vue寫項目時,應該都使用過this.emit/$nextTick等,就是調用vue實例的方法。app

下面的代碼也都是僞代碼,是爲說明源碼的邏輯。重點是梳理源碼的思路和運行流程。

研究初始化邏輯

平常寫項目時,new Vue(options:最主要的是傳入模版和數據),而後vue實例就幫咱們渲染出了界面。

上面分析到new Vue中調用了_init方法。來看它的邏輯。

_init(options) {
    // 1,加強vm(vue實例,也就是this)。就是往vm對象上添加屬性。
    // 如vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true) 給vm添加一個$createElement方法,用於建立VNode的。
    // 如將咱們聲明的data/props/computed裏的字段代理到vm上。這是咱們在項目裏寫代碼時,data中定義a,在其餘地方就能夠用this.a能夠訪問到的緣由。
    // 而且將字段都變成get/set形式屬性。以便隨後進行依賴收集和派發更新的操做。
    ...
    
    // 2,vue實例加強完畢後,它得幹活了,生成虛擬VNode,而後更新DOM。
    // 乾的活都在下面這句代碼中。$options是_init中加強時獲得的,$mount是在上面定義Vue類時定義好的。
    if (vm.$options.el) {
      vm.$mount(vm.$options.el)
    }
}
複製代碼

這裏沒貼出_init全部代碼,細節能夠你們本身去看,思路上_init作了兩件事,增長vue實例和調用$mount生成DOM。

掛載中發生的事

接下來看看$mount方法執行中發生的事。

$mount() {
    // 主要邏輯以下
 
    // 1 定義一個回調
    updateComponent = () => {
      vm._update(vm._render(), hydrating)
    }
    
    // 2 利用Watcher執行回調
    new Watcher(vm, updateComponent,...)
}
複製代碼

掛載中會實例化一個Watcher,Watcher的做用,一是初始化時調用updateComponent,二是數據發生變化時,執行updateComponent,也就是爲了實現響應式。

updateComponent中,_render生成VNode,虛擬節點樹。而後_update使用VNode,生成和更新DOM。

生成虛擬節點樹

看_render中的邏輯。

_render(){
    // 主要邏輯以下
    var vnode = render(vm.$createElement);
    
    return vnode;
}

// render方法就是
// <div id="app">
//  {{ message }}
// </div>
// 這樣的模版通過編譯後的js語法。
render: function (createElement) {
  return createElement('div', {
     attrs: {
        id: 'app'
      },
  }, this.message)
}

複製代碼

render最終會生成一個根vnode,根vnode節點裏又有不少內置/自定義組件節點,是一個虛擬節點樹。

生成真實DOM

_update 方法的做用是把 VNode 渲染成真實的 DOM。

_update(vnode){
    // 主要邏輯以下
    if(初始化) {
        // vm.$el存放的是DOM節點的id,這樣patch就能夠將生成的dom內容添加到根dom上了。
        patch(vm.$el,vnode);
    } else {
        // 更新
        patch(prevVnode, vnode)
    }
}
複製代碼

_update方法是經過調用patch方法實現生成DOM的,分爲初始化的狀況和數據變化時更新的狀況。咱們先來看初始化時的狀況。

// 平臺相關的patch,在web上或是移動端上,patch方法確定是不一樣的,由於涉及到操做DOM了,不一樣平臺的DOM api確定不同
patch(){
    // 初始化主要邏輯
    
    createElm(vnode) 
}

createElm(vnode) {
    // 1,生成此節點的DOM對象
    vnode.elm = nodeOps.createElement(tag, vnode);
    // 2. 去遍歷子節點
    createChildren(vnode, vnode.children)
    // 3. 將生成的DOM節點插入到界面中
    insert(vnode)
}

// 這裏看到遞歸調用createElm,最終生成全部DOM
createChildren(vnode, children, insertedVnodeQueue) {
    for (let i = 0; i < children.length; ++i) {
      createElm(children[i])
    }
}

insert(vnode){
    // 將生成的DOM節點插入到界面中,使用DOM api
}

複製代碼

小結

拋開數據更新流程和組件化相關邏輯,初始化的流程研究完成。

  1. 實例化一個Vue:new Vue(),調用_init()。
  2. _init:加強實例和調用$mount。
  3. $mount: 實例化Watcher監聽數據變化,調用_render和_update。
  4. _render: 生成Vnode樹。
  5. _update:利用Vnode樹生成DOM並掛載到界面上。

組件化研究

上面分析的是最簡單狀況下,只有一個根Vue的初始化流程,如今增長一個因素,根Vue下,還有不少子組件,也是一個個Vue。

值得注意的是,平常咱們寫的組件,是一個由template + js對象 + css的.vue文件,由vue-loader轉化成一個js對象,這個對象只是子組件的配置對象,並非繼承自Vue類的實例。下面咱們就來看一下,源碼是怎樣使用咱們寫的配置對象(.vue文件生成)的。

一次輪迴

根組件new Vue -> _init -> $mount -> _render —> _update。

子組件的邏輯從根組件的_render開始:

_render(){
    // 主要邏輯以下
    var vnode = render(vm.$createElement);
    
    return vnode;
}

render: function (createElement) {
    return createElement('div', {
     attrs: {
        id: 'app',
        children:[
            createElement('BlogList', children, this.blogList)
        ]
      },
    }, this.message);
}

createElement(tag, children, data) {
    let vnode;
    // div,span這種
    if(平臺內置標籤) {
        // 建立普通的vnode
        vnode = new VNode(tag, children, data);
    } else if (是自定義組件) {
        // 建立自定義組件的vnode
        vnode = createComponent(Ctor, data, children, tag)
    }
}

複製代碼

_render的職責沒有變,就是爲了得到vnode。但在createElement這一層,若是遇到組件節點,會生成一個組件的vnode,添加到最終的虛擬node樹上。

再看createComponent方法,生成的是怎樣的組件VNode:

createComponent(Ctor){
    // 1,利用咱們的配置對象,建立子組件Vue類
    Ctor = baseCtor.extend(Ctor) // 入參的Ctor就是咱們編寫的子組件.vue文件的內容。返回值是繼承了Vue類的子類,它不但有基類的功能,還合併了.vue文件中定義的成員。
    
    // 2,將自組件類做爲參數傳遞給vnode
    const vnode = new VNode(Ctor);
    
    return vnode;
}
複製代碼

這裏值得注意的是,咱們的Vue子組件只是在此處組裝成了類,可是並無實例化它。實例化還要等到_update時。

從_render()拿到vnode後,接下里就看一下_update:

_update(vnode){
    // 初始化時的邏輯
    patch(vm.$el,vnode);
}

patch(){
    // 初始化主要邏輯
    
    createElm(vnode) 
}

createElm(vnode) {
    
    // 添加處理自定義組件邏輯
    if(createComponent(vnode)){
        return;
    }

    // 1,生成此節點的DOM對象
    vnode.elm = nodeOps.createElement(tag, vnode);
    // 2. 去遍歷子節點
    createChildren(vnode, vnode.children)
    // 3. 將生成的DOM節點插入到界面中
    insert(vnode)
}

createComponent(vnode){
    if(自定義組件的vnode){
        // 1, 從vnode中拿到組件的類。
        const Ctor = vnode.componentOptions.Ctor; // 這個在_render過程當中拼裝的
        
        new Ctor(options); // Ctor就是Vue的子類,因此這句能夠當作是new Vue(options)
        
        return true;
    }
    return false;
}

複製代碼

new Ctor(options)能夠當作是new Vue(options)。輪迴開始了,又開始_init -> _init -> $mount -> _render —> _update。子組件也會走一遍這樣的流程。

子組件走完這樣的流程,那麼它已經操做完DOM了。控制權又回到父組件。父組件繼續_update。

總結

咱們將組件的因素添加到源碼流程分析以後,只是添加了一個輪迴,子組件也是走這麼一套流程。

響應式原理研究

研究過初始化流程以後,接下來該研究數據變動時,更新界面的邏輯了。咱們也管這叫響應式。

首先看圖,數據變化是在哪裏影響到源碼運行的:

是在 mount方法,mount方法以下:

$mount() {
    // 主要邏輯以下
 
    // 1 定義一個回調
    updateComponent = () => {
      vm._update(vm._render(), hydrating)
    }
    
    // 2 利用Watcher執行回調
    new Watcher(vm, updateComponent,...)
}
複製代碼

這時Watcher起做用了,數據變化,Watcher從新調用updateComponent,而後->_render-> _update。

值得一提的時,若是是子組件的數據變化時,只會觸發子組件的Watcher,更新範圍就小不少。因此平常寫業務代碼時,若是某項數據,只跟這個子組件有關,就不要寫在父組件或根組件上,這樣就不會影響性能了。

數據更新還有兩個問題:

  1. Vue怎樣作的知道數據被更新的。
  2. 數據變化後,流程中發生了什麼變化。這裏面就涉及到常常被說起的diff了。

響應式對象

平常寫代碼,會有以下寫法

export default {
    data() {
        return {
            blogList:[],
        }
    }
    
    methods:{
        onSearch(){
            this.blogList = await api.fetchBlogList();
        }
    }
}
複製代碼

給blogList賦值,DOM就是響應更新。緣由是Vue幫助咱們將blogList轉換成了響應式對象。這發生在_init中,加強Vue實例階段,調用$mount以前。

_init(options) {
    // 1,加強vm
    ...
    initState(vm)
    ...
    
    // 2,調用$mount
    if (vm.$options.el) {
      vm.$mount(vm.$options.el)
    }
}

initState(vm) {
    // 將data內的字段都變成響應式對象
    
    const keys = Object.keys(vm.data)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(vm, keys[i])
    }
}

defineReactive(obj,key){
  Object.defineProperty(obj, key, { 
      get:function(){ ... },
      set:function(newVal){ ... }
  })
}

複製代碼

將data變成響應式對象以後。在_render()時,要讀取data生成vnode樹,因此 會觸發data的get函數:

const dep = new Dep()

get:function(){
    dep.depend();
    return value;
}
set:function(newVal){
    val = newVal
    dep.notify();
}

複製代碼

這裏有一個dep對象。在get的時候,作了一個依賴收集(dep.depend())的操做,而後在set的時候,也就是咱們調用this.blogList = 數據; 時,作了一次派發更新。

我來畫一下 get/set和dep,watcher對象的關係:

在數據初次render被get的時候,dep記錄一下這個數據,當數據被set的時候,就觸發dep關聯的watcher,watcher觸發_render -> _update,開始了界面的更新。

vnode的diff

對於_render()方法,初始化和更新沒有區別,都是要生成新的vnode。不一樣的地方在於_update()。

_update(vnode){
    // 主要邏輯以下
    
    // 在vm上拿到上次的vnode樹
    const prevVnode = vm._vnode;
    vm._vnode = vnode;
    if(初始化) {
        patch(vm.$el,vnode);
    } else {
        // 更新,對比先後兩次的vnode樹
        patch(prevVnode, vnode)
    }
}
複製代碼

對比先後兩次的vnode樹,就是常說的diff流程。咱們來看看

patch(prevVnode, vnode) {
    // 1. 若是新舊vnode不一樣 key、tag、data都不一樣
    // 建立新節點,在節點的父節點下添加新節點,刪除舊節點
    createElm(); // 建立新節點,不是指建立vnode,而是用新vnode建立DOM
    vnode.parent.insert() // 將建立的DOM,添加到父節點下
    destroyElm(); // 刪除舊的DOM
    
    
    // 2, 若是新舊vnode相同,相同是指 key、tag、data相同,但不表明它們的子節點,也都相同
    
    prepatch(oldVnode,vnode); // 更新vnode對應的vue實例的一些屬性
    const oldCh = oldVnode.children // 舊vnode的子節點
    const ch = vnode.children // 新vnode的子節點
    
    if (isDef(oldCh) && isDef(ch)) {
        if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly) // oldCh 與 ch 都存在且不相同時,使用 updateChildren 函數來更新子節點
    } else if (isDef(ch)) {
        if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
        addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue) // 若是隻有 ch 存在,表示舊節點不須要了。若是舊的節點是文本節點則先將節點的文本清除,而後經過 addVnodes 將 ch 批量插入到新節點 elm 下。
    } else if (isDef(oldCh)) {
        removeVnodes(elm, oldCh, 0, oldCh.length - 1) // 若是隻有 oldCh 存在,表示更新的是空節點,則須要將舊的節點經過 removeVnodes 所有清除。
    } else if (isDef(oldVnode.text)) {
        nodeOps.setTextContent(elm, '') // 當只有舊節點是文本節點的時候,則清除其節點文本內容。
    }
    
}
複製代碼

這段邏輯開始複雜了,總結來講就是新舊節點相同的更新流程是去獲取它們的 children,根據不一樣狀況作不一樣的更新邏輯。

其中最複雜的是updateChildren,就是是相同vnode節點,還都有一堆子節點。這地方網上有不少分析,還多爲圖解。這裏列兩個我搜到的掘金上的文章,我就不重複敘述了:

詳解vue的diff算法

圖文詳解 vue diff 核心方法 updateChildren

結語

到此,經過源碼大體梳理了Vue的運行流程。最後貼一張分析的總體流程圖 :

相關文章
相關標籤/搜索