手寫 Vue 系列 之 Vue1.x

前言

前面我們用 12 篇文章詳細講解了 Vue2 的框架源碼。接下來我們就開始手寫 Vue 系列,寫一個自己的 Vue 框架,用最簡單的程式碼實現 Vue 的核心功能,進一步理解 Vue 核心原理。

為什麼要手寫框架

有人會有疑問:我已經詳細閱讀過框架源碼了,甚至不止兩三遍,這難道還不夠嗎?我自認為對框架的源碼已經很熟悉了,我覺得沒必要再手寫。

有沒有必要手寫框架 這個事情,和 有沒有必要閱讀框架源碼 的答案一樣。看你的出發點是什麼。

讀源碼

如果你是抱以學習的態度,那不用說,閱讀框架源碼肯定是有必要的。

大家都明白,平時的業務開發中,你身邊人的水平可能都跟你差不多,所以你在業務中基本是看不到太多的優秀編碼和思想。

而一個框架所包含的優秀設計和最佳實踐就很多了,在閱讀的時候有太多讓你恍然大悟和驚艷的地方。即使你覺得自己現在段位不夠,可能看不到那麼多,但是源碼對你的影響是潛移默化的。看多了優秀的程式碼,在你自己平時的編碼中會不自覺的應用你學到的這些優秀編碼方式。更何況 Vue 的大部分程式碼都是尤大自己寫的,程式碼品質那是毋庸置疑的。

手寫框架

至於 手寫框架是否有必要 ?只要你讀了框架源碼,就必須自己手寫一個。理由很簡單,你閱讀框架源碼的目的是學習,你說你對源碼已經非常熟了,你說你都學會了,那怎麼檢驗?檢驗的方式也很簡單,把你學到的東西向外輸出,分三個階段:

  1. 寫技術部落格、畫思維導圖(掌握 30%)

  2. 給他人分享,比如組內分享、錄影片都行(掌握 60%)

  3. 手寫框架,造輪子是檢驗你學習成果最好的方式(掌握 90%)

有沒有發現前兩階段都是在講他人的東西,你說你學到了,確實,你能向外輸出,學你肯定是學到了,但是學到了多少呢?我覺得差不多是 60%,舉個例子:

別人問你 Vue 的響應式原理是什麼?經過前兩個階段的輸出,你可能說的頭頭是道,比如 Object.defineProperty、getter、setter、依賴收集、依賴通知 watcher 更新等等。但是這整個過程你能否寫出來呢?如果你第一次寫,大概率是不行的,實現的時候會發現,根本不像你說的那麼簡單,要考慮東西遠不止你說的那些。如果不信大家可以試試,檢驗一下。

要知道,造輪子的過程其實就是你應用的過程,只有你真的寫出來了,你才算是真的學到了。如果只看不寫,基本上可以算是進階版的 只看不練

所以,檢驗你是否真的學會並深入理解某個框架的實現原理,模仿 造輪子 是最好的檢驗方式。

手寫 Vue1.x

在開始之前,我們先做好準備工作,在自己的工作目錄下,新建我們的源碼目錄,比如:

mkdir lyn-vue && cd lyn-vue

這裡我不想額外安裝和配置打包工具,太麻煩了,採用現代瀏覽器原生支援的 ESM 的方式,所以大家需要在本地裝一個 serve,起一個伺服器。vite 就是這個原理,只不過它的服務端是自己實現的,因為它需要針對 import 的不同資源做相應的處理,比如解析 import 請求的是 node_modules 還是 用戶自己的模組,亦或者是 TS 模組的轉譯等等。

npm i serve -g

安裝好之後,在 lyn-vue 目錄下執行 serve 命令,會在本地起一個伺服器,接下來就進入編碼階段。

目標

下面的示例程式碼就是今天的目標,用我們自己手寫的 Vue 框架把這個示例跑起來。

我們需要實現以下能力:

  • 數據響應式攔截

    • 原始值

    • 普通對象

    • 數組

  • 數據響應式更新

    • 依賴收集,Dep

    • 依賴通知 Watcher 更新

    • 編譯器,compiler

  • methods + 事件 + 數據響應式更新

  • v-bind 指令

  • v-model 雙向綁定

    • input 輸入框

    • checkbox

    • select

/vue1.0.html

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <title>Lyn Vue1.0</title>
</head>

<body>
  <div id="app">
    <h3>數據響應式更新 原理</h3>
    <div>{{ t }}</div>
    <div>{{ t1 }}</div>
    <div>{{ arr }}</div>
    <h3>methods + 事件 + 數據響應式更新 原理</h3>
    <div>
      <p>{{ counter }}</p>
      <button v-on:click="handleAdd"> Add </button>
      <button v-on:click="handleMinus"> Minus </button>
    </div>
    <h3>v-bind</h3>
    <span v-bind:title="title">右鍵審查元素查看我的 title 屬性</span>
    <h3>v-model 原理</h3>
    <div>
      <input type="text" v-model="inputVal" />
      <div>{{ inputVal }}</div>
    </div>
    <div>
      <input type="checkbox" v-model="isChecked">
      <div>{{ isChecked }}</div>
    </div>
    <div>
      <select v-model="selectValue">
        <option value="1">1</option>
        <option value="2">2</option>
        <option value="3">3</option>
      </select>
      <div>{{ selectValue }}</div>
    </div>
  </div>
  <script type="module">
    import Vue from './src/index.js'
    const ins = new Vue({
      el: '#app',
      data() {
        return {
          // 原始值和對象的響應式原理
          t: 't value',
          t1: {
            tt1: 'tt1 value'
          },
          // 數組的響應式原理
          arr: [1, 2, 3],
          // 響應式更新
          counter: 0,
          // v-bind
          title: '看我',
          // v-model
          inputVal: 'test',
          isChecked: true,
          selectValue: 2
        }
      },
      // methods + 事件 + 數據響應式更新 原理
      methods: {
        handleAdd() {
          this.counter++
        },
        handleMinus() {
          this.counter--
        }
      },
    })
    // 數據響應式攔截
    setTimeout(() => {
      console.log('********** 屬性值為原始值時的 getter、setter ************')
      console.log(ins.t)
      ins.t = 'change t value'
      console.log(ins.t)
    }, 1000)

    setTimeout(() => {
      console.log('********** 屬性的新值為對象的情況 ************')
      ins.t = {
        tt: 'tt value'
      }
      console.log(ins.t.tt)
    }, 2000)

    setTimeout(() => {
      console.log('********** 驗證對深層屬性的 getter、setter 攔截 ************')
      ins.t1.tt1 = 'change tt1 value'
      console.log(ins.t1.tt1)
    }, 3000)

    setTimeout(() => {
      console.log('********** 將值為對象的屬性更新為原始值 ************')
      console.log(ins.t1)
      ins.t1 = 't1 value'
      console.log(ins.t1)
    }, 4000)

    setTimeout(() => {
      console.log('********** 數組操作方法的攔截 ************')
      console.log(ins.arr)
      ins.arr.push(4)
      console.log(ins.arr)
    }, 5000)
  </script>
</body>

</html>

數據響應式攔截

Vue 構造函數

/src/index.js

/**
 * Vue 構造函數
 * @param {*} options new Vue(options) 時傳遞的配置對象
 */
export default function Vue(options) {
  this._init(options)
}

this._init

/src/index.js

/**
 * 初始化配置對象
 * @param {*} options 
 */
Vue.prototype._init = function (options) {
  // 將 options 配置掛載到 Vue 實例上
  this.$options = options
  // 初始化 options.data
  // 代理 data 對象上的各個屬性到 Vue 實例
  // 給 data 對象上的各個屬性設置響應式能力
  initData(this)
}

initData

/src/initData.js

/**
 * 1、初始化 options.data
 * 2、代理 data 對象上的各個屬性到 Vue 實例
 * 3、給 data 對象上的各個屬性設置響應式能力
 * @param {*} vm 
 */
export default function initData(vm) {
  // 獲取 data 選項
  let { data } = vm.$options
  // 設置 vm._data 選項,保證它的值肯定是一個對象
  if (!data) {
    vm._data = {}
  } else {
    vm._data = typeof data === 'function' ? data() : data
  }
  // 代理,將 data 對象上的的各個屬性代理到 Vue 實例上,支援 通過 this.xx 的方式訪問
  for (let key in vm._data) {
    proxy(vm, '_data', key)
  }
  // 設置響應式
  observe(vm._data)
}

proxy

/src/utils.js

/**
 * 將 key 代理到 target 上,
 * 比如 代理 this._data.xx 為 this.xx
 * @param {*} target 目標對象,比如 vm
 * @param {*} sourceKey 原始 key,比如 _data
 * @param {*} key 代理的原始對象上的指定屬性,比如 _data.xx
 */
export function proxy(target, sourceKey, key) {
  Object.defineProperty(target, key, {
    // target.key 的讀取操作實際上返回的是 target.sourceKey.key
    get() {
      return target[sourceKey][key]
    },
    // target.key 的賦值操作實際上是 target.sourceKey.key = newV
    set(newV) {
      target[sourceKey][key] = newV
    }
  })
}

observe

/src/observe.js

/**
 * 通過 Observer 類為對象設置響應式能力
 * @returns Observer 實例
 */
export default function observe(value) {
  // 避免無限遞歸
  // 當 value 不是對象直接結束遞歸
  if (typeof value !== 'object') return

  // value.__ob__ 是 Observer 實例
  // 如果 value.__ob__ 屬性已經存在,說明 value 對象已經具備響應式能力,直接返回已有的響應式對象
  if (value.__ob__) return value.__ob__

  // 返回 Observer 實例
  return new Observer(value)
}

Observer

/src/observer.js

/**
 * 為普通對象或者數組設置響應式的入口 
 */
export default function Observer(value) {
  // 為對象設置 __ob__ 屬性,值為 this,標識當前對象已經是一個響應式對象了
  Object.defineProperty(value, '__ob__', {
    value: this,
    // 設置為 false,禁止被枚舉,
    // 1、可以在遞歸設置數據響應式的時候跳過 __ob__ 
    // 2、將響應式對象字元串化時也不限顯示 __ob__ 對象
    enumerable: false,
    writable: true,
    configurable: true
  })

  if (Array.isArray(value)) {
    // 數組響應式
    protoArgument(value)
    this.observeArray(value)
  } else {
    // 對象響應式
    this.walk(value)
  }
}

/**
 * 遍歷對象的每個屬性,為這些屬性設置 getter、setter 攔截
 */
Observer.prototype.walk = function (obj) {
  for (let key in obj) {
    defineReactive(obj, key, obj[key])
  }
}

// 遍曆數組的每個元素,為每個元素設置響應式
// 其實這裡是為了處理元素為對象的情況,以達到 this.arr[idx].xx 是響應式的目的
Observer.prototype.observeArray = function (arr) {
  for (let item of arr) {
    observe(item)
  }
}

defineReactive

/src/defineReactive.js

/**
 * 通過 Object.defineProperty 為 obj.key 設置 getter、setter 攔截
 */
export default function defineReactive(obj, key, val) {
  // 遞歸調用 observe,處理 val 仍然為對象的情況
  observe(val)

  Object.defineProperty(obj, key, {
    // 當發現 obj.key 的讀取行為時,會被 get 攔截
    get() {
      console.log(`getter: key = ${key}`)
      return val
    },
    // 當發生 obj.key = xx 的賦值行為時,會被 set 攔截
    set(newV) {
      console.log(`setter: ${key} = ${newV}`)
      if (newV === val) return
      val = newV
      // 對新值進行響應式處理,這裡針對的是新值為非原始值的情況,比如 val 為對象、數組
      observe(val)
    }
  })
}

protoArgument

/src/protoArgument.js

/**
 * 通過攔截數組的七個方法來實現
 */

// 數組默認原型對象
const arrayProto = Array.prototype
// 以數組默認原型對象為原型創建一個新的對象
const arrayMethods = Object.create(arrayProto)
// 被 patch 的七個方法,通過攔截這七個方法來實現數組響應式
// 為什麼是這七個方法?因為只有這七個方法是能更改數組本身的,像 cancat 這些方法都是會返回一個新的數組,不會改動數組本身
const methodsToPatch = ['push', 'pop', 'unshift', 'shift', 'splice', 'sort', 'reverse']

// 遍歷 methodsToPatch
methodsToPatch.forEach(method => {
  // 攔截數組的七個方法,先完成本職工作,再額外完成響應式的工作
  Object.defineProperty(arrayMethods, method, {
    value: function(...args) {
      // 完成方法的本職工作,比如 this.arr.push(xx)
      const ret = arrayProto[method].apply(this, args)
      // 將來接著實現響應式相關的能力
      console.log('array reactive')
      return ret
    },
    configurable: true,
    writable: true,
    enumerable: true
  })
})

/**
 * 覆蓋數組(arr)的原型對象
 * @param {*} arr 
 */
export default function protoArgument(arr) {
  arr.__proto__ = arrayMethods
}

效果

能達到如下效果,則表示數據響應式攔截功能完成。即能跑通目標中示例程式碼的 「數據響應式攔截」 部分的程式碼(最後的那堆 setTimeout)。

動圖地址: //gitee.com/liyongning/typora-image-bed/raw/master/202203092000920.image

響應式原理.gif

數據響應式更新

現在已經能攔截到對數據的獲取和更新,接下來就可以在攔截數據的地方增加一些 「能力」,以完成 數據響應式更新 的功能。

增加的這些能力其實就是大家耳熟能詳的東西:在 getter 中進行依賴收集,setter 中依賴通知 watcher 更新。

Vue1.x 中響應式數據對象的所有屬性(key)和 dep 是一一對應對應關係,一個 key 對應一個 dep;響應式數據在頁面中每引用一次就會產生一個 watcher,所以在 Vue1.0 中 dep 和 watcher 是一對多的關係。

依賴收集

Dep

/src/dep.js

/**
 * Dep
 * Vue1.0 中 key 和 Dep 是一一對應關係,舉例來說:
 * new Vue({
 *   data() {
 *     return {
 *       t1: xx,
 *       t2: {
 *         tt2: xx
 *       },
 *       arr: [1, 2, 3, { t3: xx }]
 *     }
 *   }
 * })
 * data 函數 return 回來的對象是一個 dep
 * 對象中的 key => t1、t2、tt2、arr、t3 都分別對應一個 dep
 */
export default function Dep() {
  // 存儲當前 dep 實例收集的所有 watcher
  this.watchers = []
}

// Dep.target 是一個靜態屬性,值為 null 或者 watcher 實例
// 在實例化 Watcher 時進行賦值,待依賴收集完成後在 Watcher 中又重新賦值為 null
Dep.target = null

/**
 * 收集 watcher
 * 在發生讀取操作時(vm.xx) && 並且 Dep.target 不為 null 時進行依賴收集
 */
Dep.prototype.depend = function () {
  // 防止 Watcher 實例被重複收集
  if (this.watchers.includes(Dep.target)) return
  // 收集 Watcher 實例
  this.watchers.push(Dep.target)
}

/**
 * dep 通知自己收集的所有 watcher 執行更新函數
 */
Dep.prototype.notify = function () {
  for (let watcher of this.watchers) {
    watcher.update()
  }
}

Watcher

/src/watcher.js

import Dep from "./dep.js"

/**
 * @param {*} cb 回調函數,負責更新 DOM 的回調函數
 */
export default function Watcher(cb) {
  // 備份 cb 函數
  this._cb = cb
  // 賦值 Dep.target
  Dep.target = this
  // 執行 cb 函數,cb 函數中會發生 vm.xx 的屬性讀取,進行依賴收集
  cb()
  // 依賴收集完成,Dep.target 重新賦值為 null,防止重複收集
  Dep.target = null
}

/**
 * 響應式數據更新時,dep 通知 watcher 執行 update 方法,
 * 讓 update 方法執行 this._cb 函數更新 DOM
 */
Watcher.prototype.update = function () {
  this._cb()
}

Observer

改造 Observer 構造函數,在 value.ob 對象上設置一個 dep 實例。這個 dep 是對象本身的 dep,方便在更新對象本身時使用,比如:數組依賴通知更新時就會用到。

/src/observer.js

/**
 * 為普通對象或者數組設置響應式的入口
 */
export default function Observer(value) {
  // 為對象本身設置一個 dep,方便在更新對象本身時使用,比如 數組通知依賴更新時就會用到
  this.dep = new Dep()  
  // ... 省略已有內容
}

defineReactive

改造 defineReactive 方法,增加依賴收集和依賴通知更新的程式碼

/src/defineReactive.js

/**
 * 通過 Object.defineProperty 為 obj.key 設置 getter、setter 攔截
 * getter 時收集依賴
 * setter 時依賴通過 watcher 更新
 */
export default function defineReactive(obj, key, val) {
  // 遞歸調用 observe,處理 val 仍然為對象的情況
  const childOb = observe(val)

  const dep = new Dep()

  Object.defineProperty(obj, key, {
    // 當發現 obj.key 的讀取行為時,會被 get 攔截
    get() {
      // 讀取數據時 && Dep.target 不為 null,則進行依賴收集
      if (Dep.target) {
        dep.depend()
        // 如果存在子 ob,則順道一塊兒完成依賴收集
        if (childOb) {
          childOb.dep.depend()
        }
      }
      console.log(`getter: key = ${key}`)
      return val
    },
    // 當發生 obj.key = xx 的賦值行為時,會被 set 攔截
    set(newV) {
      console.log(`setter: ${key} = ${newV}`)
      if (newV === val) return
      val = newV
      // 對新值進行響應式處理,這裡針對的是新值為非原始值的情況,比如 val 為對象、數組
      observe(val)
      // 數據更新,讓 dep 通知自己收集的所有 watcher 執行 update 方法
      dep.notify()
    }
  })
}

protoArgument

改造七個數組方法的 patch 修補程式,當數組新增元素時,對新元素進行響應式處理和依賴通知更新。

/src/protoArgument.js

/**
 * 通過攔截數組的七個方法來實現
 */

// 數組默認原型對象
const arrayProto = Array.prototype
// 以數組默認原型對象為原型創建一個新的對象
const arrayMethods = Object.create(arrayProto)
// 被 patch 的七個方法,通過攔截這七個方法來實現數組響應式
// 為什麼是這七個方法?因為只有這七個方法是能更改數組本身的,像 cancat 這些方法都是會返回一個新的數組,不會改動數組本身
const methodsToPatch = ['push', 'pop', 'unshift', 'shift', 'splice', 'sort', 'reverse']

// 遍歷 methodsToPatch
methodsToPatch.forEach(method => {
  // 攔截數組的七個方法,先完成本職工作,再額外完成響應式的工作
  Object.defineProperty(arrayMethods, method, {
    value: function(...args) {
      // 完成方法的本職工作,比如 this.arr.push(xx)
      const ret = arrayProto[method].apply(this, args)
      // 將來接著實現響應式相關的能力
      console.log('array reactive')
      // 新增的元素列表
      let inserted = []
      switch(method) {
        case 'push':
        case 'unshift':
          inserted = args
          break;
        case 'splice':
          // this.arr.splice(idx, num, x, x, x)
          inserted = args.slice(2)
          break;
      }
      // 如果數組有新增的元素,則對新增的元素進行響應式處理
      inserted.length && this.__ob__.observeArray(inserted)
      // 依賴通知更新
      this.__ob__.dep.notify()
      return ret
    },
    configurable: true,
    writable: true,
    enumerable: true
  })
})

/**
 * 覆蓋數組(arr)的原型對象
 * @param {*} arr 
 */
export default function protoArgument(arr) {
  arr.__proto__ = arrayMethods
}

到這裡依賴收集就全部完成了。但是你會發現頁面還是沒有發生任何變化,響應式數據在頁面沒有得到渲染,數據更新時頁面更是沒有任何變化。這是為什麼?還需要做什麼事情嗎?

其實回顧依賴收集的程式碼會發現有一個被我們遺漏的地方,大家有沒有發現 Watcher 構造函數似乎從來沒有被實例化過,那也就是說依賴收集其實從來沒有被觸發過,因為只有實例化 Watcher 時 Dep.target 才會被賦值。

那麼問題就來了,Watcher 應該在什麼什麼時候被實例化呢?大家可能沒有看過 Vue1 的源碼,但是 Vue2 的源碼前面帶大家看過了,仔細回想一下,什麼時候會去實例化 Watcher。

答案是 mountComponent,也就是掛載階段,初始化完成後執行 $mount,$mount 調用 mountComponent,mountComponent 方法中有一步就是在實例化 Watcher。如果這塊兒有遺忘,大家可以再去翻看一下這部分的源碼。

所以接下來我們要實現的就是編譯器了,也就是 $mount 方法。

編譯器

這部分利用 DOM 操作實現了一個簡版的編譯器。從中你可以看到節點樹的編譯過程,明白文本節點、v-on:click、v-bind、v-model 指令的實現原理。

$mount

/src/index.js

Vue.prototype._init = function (options) {
  ... 省略
  
  // 如果存在 el 配置項,則調用 $mount 方法編譯模版
  if (this.$options.el) {
    this.$mount()
  }
}

Vue.prototype.$mount = function () {
  mount(this)
}

mount

/src/compiler/index.js

/**
 * 編譯器
 */
export default function mount(vm) {
  // 獲取 el 選擇器所表示的元素
  let el = document.querySelector(vm.$options.el)

  // 編譯節點
  compileNode(Array.from(el.childNodes), vm)
}

compileNode

/src/compiler/compileNode.js

/**
 * 遞歸編譯整棵節點樹
 * @param {*} nodes 節點
 * @param {*} vm Vue 實例
 */
export default function compileNode(nodes, vm) {
  // 循環遍歷當前節點的所有子節點
  for (let i = 0, len = nodes.length; i < len; i++) {
    const node = nodes[i]
    if (node.nodeType === 1) { // 元素節點
      // 編譯元素上的屬性節點
      compileAttribute(node, vm)
      // 遞歸編譯子節點
      compileNode(Array.from(node.childNodes), vm)
    } else if (node.nodeType === 3 && node.textContent.match(/{{(.*)}}/)) {
      // 編譯文本節點
      compileTextNode(node, vm)
    }
  }
}

compileTextNode

文本節點響應式更新的原理

/src/compiler/compileTextNode.js

/**
 * 編譯文本節點
 * @param {*} node 節點
 * @param {*} vm Vue 實例
 */
export default function compileTextNode(node, vm) {
  // <span>{{ key }}</span>
  const key = RegExp.$1.trim()
  // 當響應式數據 key 更新時,dep 通知 watcher 執行 update 函數,cb 會被調用
  function cb() {
    node.textContent = JSON.stringify(vm[key])
  }
  // 實例化 Watcher,執行 cb,觸發 getter,進行依賴收集
  new Watcher(cb)
}

compileAttribute

v-on:click、v-bind 和 v-model 指令的原理

/src/compiler/compileAttribute.js

/**
 * 編譯屬性節點
 * @param {*} node 節點
 * @param {*} vm Vue 實例
 */
export default function compileAttribute(node, vm) {
  // 將類數組格式的屬性節點轉換為數組
  const attrs = Array.from(node.attributes)
  // 遍歷屬性數組
  for (let attr of attrs) {
    // 屬性名稱、屬性值
    const { name, value } = attr
    if (name.match(/v-on:click/)) {
      // 編譯 v-on:click 指令
      compileVOnClick(node, value, vm)
    } else if (name.match(/v-bind:(.*)/)) {
      // v-bind
      compileVBind(node, value, vm)
    } else if (name.match(/v-model/)) {
      // v-model
      compileVModel(node, value, vm)
    }
  }
}

compileVOnClick

/src/compiler/compileAttribute.js

/**
 * 編譯 v-on:click 指令
 * @param {*} node 節點
 * @param {*} method 方法名
 * @param {*} vm Vue 實例
 */
function compileVOnClick(node, method, vm) {
  // 給節點添加一個 click 事件,回調函數是對應的 method
  node.addEventListener('click', function (...args) {
    // 給 method 綁定 this 上下文
    vm.$options.methods[method].apply(vm, args)
  })
}

compileVBind

/src/compiler/compileAttribute.js

/**
 * 編譯 v-bind 指令
 * @param {*} node 節點
 * @param {*} attrValue 屬性值
 * @param {*} vm Vue 實例
 */
function compileVBind(node, attrValue, vm) {
  // 屬性名稱
  const attrName = RegExp.$1
  // 移除模版中的 v-bind 屬性
  node.removeAttribute(`v-bind:${attrName}`)
  // 當屬性值發生變化時,重新執行回調函數
  function cb() {
    node.setAttribute(attrName, vm[attrValue])
  }
  // 實例化 Watcher,當屬性值發生變化時,dep 通知 watcher 執行 update 方法,cb 被執行,重新更新屬性
  new Watcher(cb)
}

compileVModel

/src/compiler/compileAttribute.js

/**
 * 編譯 v-model 指令
 * @param {*} node 節點 
 * @param {*} key v-model 的屬性值
 * @param {*} vm Vue 實例
 */
function compileVModel(node, key, vm) {
  // 節點標籤名、類型
  let { tagName, type } = node
  // 標籤名轉換為小寫
  tagName = tagName.toLowerCase()
  if (tagName === 'input' && type === 'text') {
    // <input type="text" v-model="inputVal" />

    // 設置 input 輸入框的初始值
    node.value = vm[key]
    // 給節點添加 input 事件,當事件發生時更改響應式數據
    node.addEventListener('input', function () {
      vm[key] = node.value
    })
  } else if (tagName === 'input' && type === 'checkbox') {
    // <input type="checkbox" v-model="isChecked" />

    // 設置選擇框的初始狀態
    node.checked = vm[key]
    // 給節點添加 change 事件,當事件發生時更改響應式數據
    node.addEventListener('change', function () {
      vm[key] = node.checked
    })
  } else if (tagName === 'select') {
    // <select v-model="selectedValue"></select>

    // 設置下拉框初始選中的選項
    node.value = vm[key]
    // 添加 change 事件,當事件發生時更改響應式數據
    node.addEventListener('change', function () {
      vm[key] = node.value
    })
  }
}

總結

到這裡,一個簡版的 Vue1.x 就實現完了。回顧一下,我們實現了如下功能:

  • 數據響應式攔截

    • 普通對象

    • 數組

  • 數據響應式更新

    • 依賴收集

      • Dep

      • Watcher

    • 編譯器

      • 文本節點

      • v-on:click

      • v-bind

      • v-model

目標 中示例程式碼的執行結果如下:

動圖地址://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/fcceda69f08a4d0a8b4f9c1e96032ad6~tplv-k3u1fbpfcp-watermark.image

May-26-2021 09-12-48.gif

面試官 問:Vue1.x 數據響應式是如何實現的?

Vue 數據響應式的核心原理是 Object.defineProperty

通過遞歸遍歷整個 data 對象,為對象中的每個 key 設置一個 getter、setter。如果 key 為數組,則走數組響應式的流程。

數組響應式是通過 Object.defineProperty 去攔截數組的七個方法實現的。首先增強了那個七個方法,在完成方法本職工作的基礎上增加了依賴通知更新的能力,而且如果有新增數據,則新數據也會被進行響應式處理。

數據響應式更新的能力是通過數據響應式攔截結合 Dep、Watcher、編譯器來實現的。

當做完數據初始化工作以後(即響應式攔截),就進入掛載階段,開始編譯整棵 DOM 樹,編譯過程中 碰到響應式數據,實例化 Watcher,這時會發生數據讀取操作,觸發 getter,進行依賴收集,將 Watcher 實例放到當前響應式屬性對應的 dep 中。

待將來響應式數據更新時,觸發 setter,然後出發 dep 通知自己收集的所有 Watcher 實例去執行 update 方法,觸發回調函數的執行,從而更新 DOM。

以上 Vue1.x 的整個響應式原理的實現。

面試官 問:你如何評價 Vue1.x 響應式原理的設計?

Vue1.x 其實是尤大為了解決自己工作上的痛點而實現的,當時他覺得各種 DOM 操作太繁瑣了,初始化時需要通過 DOM 操作將數據設置到節點上,還要監聽 DOM 操作,當 DOM 更新時,更新相應的數據。於是他就想著能不能把這個過程自動化,這就產生了 Vue1.x。

這麼一想,Vue1.x 的實現其實就很合理了,確實達到了預期的目標。通過 Object.defineProperty 攔截數據的讀取和設置,頁面初次渲染時,通過編譯器編譯整棵 DOM 樹,給 DOM 節點設置初始值,當 DOM 節點更新時又自動更新了響應式數據,或者響應式數據更新時,通過 Watcher 自動更新對應的 DOM 節點。

這個時候的 Vue 在完成中小型 Web 系統是沒有任何問題的。而且相比於 Vue 2.x 性能會更好,因為響應式數據更新時,Watcher 可以直接更新對應的 DOM 節點,沒有 2.x 的 VNode 開銷和 Diff 過程。

但是大型 Web 系統就搞不定了,理由也很簡單,也是因為它的設計。因為 Vue1.x 中 Watcher 和 模版中響應式數據是 一一對應 關係,也就是說頁面中每引用一次響應式數據,就會產生一個 Watcher。在大型系統中,一個頁面的數據量可能是非常大的,那就會產生大量的 Watcher,佔用大量資源,導致性能下降。

所以一句話總結就是,Vue1.x 在中小型系統中性能會很好,定向更新 DOM 節點,但是大型系統由於 Watcher 太多,導致資源佔用過多,性能下降。

於是 Vue2.x 中通過引入 VNode 和 Diff 的來解決這個問題,具體的實現原理將在下一篇文章 手寫 Vue 系列之 Vue2.x 中去介紹。

預告

接下來的文章,會將本篇文章中實現的 Vue1.x 升級為 Vue2.x,引入 Vnode、diff 演算法來解決 Vue1.x 的性能瓶頸。

另外會額外實現一些其它的核心原理,比如 computed、非同步更新隊列、child component、插槽 等。

鏈接

感謝各位的:關注點贊收藏評論,我們下期見。


當學習成為了習慣,知識也就變成了常識。 感謝各位的 關注點贊收藏評論

新影片和文章會第一時間在微信公眾號發送,歡迎關註:李永寧lyn

文章已收錄到 github 倉庫 liyongning/blog,歡迎 Watch 和 Star。