Vue原始碼解析之陣列變異

格子熊發表於2018-12-03

力有不逮的物件

眾所周知,在 Vue 中,直接修改物件屬性的值無法觸發響應式。當你直接修改了物件屬性的值,你會發現,只有資料改了,但是頁面內容並沒有改變。

這是什麼原因?

原因在於: Vue 的響應式系統是基於Object.defineProperty這個方法的,該方法可以監聽物件中某個元素的獲取或修改,經過了該方法處理的資料,我們稱其為響應式資料。但是,該方法有一個很大的缺點,新增屬性或者刪除屬性不會觸發監聽,舉個栗子:

var vm = new Vue({
    data () {
        return {
            obj: {
                a: 1
            }
        }
    }
})
// `vm.obj.a` 現在是響應式的

vm.obj.b = 2
// `vm.obj.b` 不是響應式的
複製程式碼

原因在於,在 Vue 初始化的時候, Vue 內部會對 data 方法的返回值進行深度響應式處理,使其變為響應式資料,所以, vm.obj.a 是響應式的。但是,之後設定的 vm.obj.b 並沒有經過 Vue 初始化時響應式的洗禮,所以,理所應當的不是響應式。

那麼,vm.obj.b可以變成響應式嗎?當然可以,通過 vm.$set 方法就可以完美地實現要求,在此不再贅述相關原理了,之後應該會寫一篇文章講述 vm.$set 背後的原理。

更悽慘的陣列

上面說了這麼多,還沒有提到本篇文章的主角——陣列,現在該主角出場了。

比起物件,陣列的境遇更加悽慘一些,看看官方文件:

由於 JavaScript 的限制, Vue 不能檢測以下變動的陣列:

  1. 當你利用索引直接設定一個項時,例如:vm.items[indexOfItem] = newValue
  2. 當你修改陣列的長度時,例如:vm.items.length = newLength

有可能官方文件不是很清晰,那我們繼續舉個栗子:

var vm = new Vue({
    data () {
        return {
            items: ['a', 'b', 'c']
        }
    }
})
vm.items[1] = 'x' // 不是響應性的
vm.items.length = 2 // 不是響應性的
複製程式碼

也就是說,陣列連自身元素的修改也無法監聽,原因在於, Vuedata 方法返回的物件中的元素進行響應式處理時,如果元素是陣列時,僅僅對陣列本身進行響應式化,而不對陣列內部元素進行響應式化。

這也就導致如官方文件所寫的後果,無法直接修改陣列內部元素來觸發響應式。

那麼,有沒有破解方法呢?

當然有,官方規定了 7 個陣列方法,通過這 7 個陣列方法,可以很開心地觸發陣列的響應式,這 7 個陣列方法分別是:

  • push()
  • pop()
  • shift()
  • unshift()
  • splice()
  • sort()
  • reverse()

可以發現,這 7 個陣列方法貌似就是原生的那些陣列方法,為什麼這 7 個陣列方法可以觸發應式,觸發檢視更新呢?

你是不是心裡想著:陣列方法了不起呀,陣列方法就可以為所欲為啊?

騷瑞啊,這 7 個陣列方法是真的可以為所欲為的。

因為,它們是變異後的陣列方法。

陣列變異思路

什麼是變異陣列方法?

變異陣列方法即保持陣列方法原有功能不變的前提下對其進行功能擴充,在 Vue 中這個所謂的功能擴充就是新增響應式功能。

將普通的陣列變為變異陣列的方法分為兩步:

  1. 功能擴充
  2. 陣列劫持

功能擴充

先來個思考題:

有這樣一個需求,要求在不改變原有函式功能以及呼叫方式的情況下,使得每次呼叫該函式都能在控制檯中列印出'HelloWorld'

其實思路很簡單,分為三步:

  1. 使用新的變數快取原函式
  2. 重新定義原函式
  3. 在新定義的函式中呼叫原函式

看看具體的程式碼實現:

function A () {
    console.log('呼叫了函式A')
}

const nativeA = A
A = function () {
    console.log('HelloWorld')
    nativeA()
}
複製程式碼

可以看到,通過這種方式,我們就保證了在不改變 A 函式行為的前提下對其進行了功能擴充。

接下來,我們使用這種方法對陣列原本方法進行功能擴充:

// 變異方法名稱
const methodsToPatch = [
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
]

const arrayProto = Array.prototype
// 繼承原有陣列的方法
const arrayMethods = Object.create(arrayProto)

mutationMethods.forEach(method => {
    // 快取原生陣列方法
    const original = arrayProto[method]
    arrayMethods[method] = function (...args) {
        const result = original.apply(this, args)
        
        console.log('執行響應式功能')
        
        return result
    }
})
複製程式碼

從程式碼中可以看出來,我們呼叫 arrayMethods 這個物件中的方法有兩種情況:

  1. 呼叫功能擴充方法:直接呼叫 arrayMethods 中的方法
  2. 呼叫原生方法:這種情況下,通過原型鏈查詢定義在陣列原型中的原生方法

通過上述方法,我們實現了對陣列原生方法進行功能的擴充,但是,有一個巨大的問題擺在面前:我們該如何讓陣列例項呼叫功能擴充後陣列方法呢?

解決這一問題的方法就是:陣列劫持。

陣列劫持

陣列劫持,顧名思義就是將原本陣列例項要繼承的方法替換成我們功能擴充後的方法。

想一想,我們在前面實現了一個功能擴充後的陣列 arrayMethods ,這個自定義的陣列繼承自陣列物件,我們只需要將其和普通陣列例項連線起來,讓普通陣列繼承於它即可。

而想實現上述操作,就是通過原型鏈。

實現方法如下程式碼所示:

let arr = []
// 通過隱式原型繼承arrayMethods
arr.__proto__ = arrayMethods

// 執行變異後方法
arr.push(1)
複製程式碼

通過功能擴充和陣列劫持,我們終於實現了變異陣列,接下來讓我們看看 Vue 原始碼是如何實現變異陣列的。

原始碼解析

我們來到 src/core/observer/index.js 中在 Observer 類中的 constructor 函式:

constructor (value: any) {
    this.value = value
    this.dep = new Dep()
    this.vmCount = 0
    def(value, '__ob__', this)
    // 檢測是否是陣列
    if (Array.isArray(value)) {
        // 能力檢測
        const augment = hasProto
        ? protoAugment
        : copyAugment
        // 通過能力檢測的結果選擇不同方式進行陣列劫持
        augment(value, arrayMethods, arrayKeys)
        // 對陣列的響應式處理
        this.observeArray(value)
    } else {
        this.walk(value)
    }
}
複製程式碼

Observer 這個類是 Vue 響應式系統的核心組成部分,在初始化階段最主要的功能是將目標物件進行響應式化。在這裡,我們主要關注其對陣列的處理。

其對陣列的處理主要是以下程式碼

// 能力檢測
const augment = hasProto
? protoAugment
: copyAugment
// 通過能力檢測的結果選擇不同方式進行陣列劫持
augment(value, arrayMethods, arrayKeys)
// 對陣列的響應式處理,很本文關係不大,略過
this.observeArray(value)
複製程式碼

首先定義了 augment 常量,這個常量的值由 hasProto 決定。

我們來看看 hasProto

export const hasProto = '__proto__' in {}
複製程式碼

可以發現, hasProto 其實就是一個布林值常量,用來表示瀏覽器是否支援直接使用 __proto__ (隱式原型) 。

所以,第一段程式碼很好理解:根據根據能力檢測結果選擇不同的陣列劫持方法,如果瀏覽器支援隱式原型,則呼叫 protoAugment 函式作為陣列劫持的方法,反之則使用 copyAugment

不同的陣列劫持方法

現在我們來看看 protoAugment 以及 copyAugment

function protoAugment (target, src: Object, keys: any) {
  /* eslint-disable no-proto */
  target.__proto__ = src
  /* eslint-enable no-proto */
}
複製程式碼

可以看到, protoAugment 函式極其簡潔,和在陣列變異思路中所說的方法一致:將陣列例項直接通過隱式原型與變異陣列連線起來,通過這種方式繼承變異陣列中的方法。

接下來我們再看看 copyAugment

function copyAugment (target: Object, src: Object, keys: Array<string>) {
  for (let i = 0, l = keys.length; i < l; i++) {
    const key = keys[i]
    // Object.defineProperty的封裝
    def(target, key, src[key])
  }
}
複製程式碼

由於在這種情況下,瀏覽器不支援直接使用隱式原型,所以陣列劫持方法要麻煩很多。我們知道該函式接收的第一個引數是陣列例項,第二個引數是變異陣列,那麼第三個引數是什麼?

// 獲取變異陣列中所有自身屬性的屬性名
const arrayKeys = Object.getOwnPropertyNames(arrayMethods)
複製程式碼

arrayKeys 在該檔案的開頭就定義了,即變異陣列中的所有自身屬性的屬性名,是一個陣列。

回頭再看 copyAugment 函式就很清晰了,將所有變異陣列中的方法,直接定義在陣列例項本身,相當於變相的實現了陣列的劫持。

實現了陣列劫持後,我們再來看看 Vue 中是怎樣實現陣列的功能擴充的。

功能擴充

陣列功能擴充的程式碼位於 src/core/observer/array.js ,程式碼如下:

import { def } from '../util/index'

// 快取陣列原型
const arrayProto = Array.prototype
// 實現 arrayMethods.__proto__ === Array.prototype
export const arrayMethods = Object.create(arrayProto)

// 需要進行功能擴充的方法
const methodsToPatch = [
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
]

/**
 * Intercept mutating methods and emit events
 */
methodsToPatch.forEach(function (method) {
  // cache original method
  // 快取原生陣列方法
  const original = arrayProto[method]
  // 在變異陣列中定義功能擴充方法
  def(arrayMethods, method, function mutator (...args) {
    // 執行並快取原生陣列方法的執行結果
    const result = original.apply(this, args)
    // 響應式處理
    const ob = this.__ob__
    let inserted
    switch (method) {
      case 'push':
      case 'unshift':
        inserted = args
        break
      case 'splice':
        inserted = args.slice(2)
        break
    }
    if (inserted) ob.observeArray(inserted)
    // notify change
    ob.dep.notify()
    // 返回原生陣列方法的執行結果
    return result
  })
})
複製程式碼

可以發現,原始碼在實現的方式上,和我在陣列變異思路中採用的方法一致,只不過在其中新增了響應式的處理。

總結

Vue 的變異陣列從本質上是來說是一種裝飾器模式,通過學習它的原理,我們在實際工作中可以輕鬆處理這類保持原有功能不變的前提下對其進行功能擴充的需求。

相關文章