如何監聽陣列變化?

狒狒神48k發表於2018-04-23

起源:在 Vue 的資料繫結中會對一個物件屬性的變化進行監聽,並且通過依賴收集做出相應的檢視更新等等。

問題:一個物件所有型別的屬性變化都能被監聽到嗎?

之前用 Object.defineProperty通過物件的 getter/setter簡單的實現了物件屬性變化的監聽,並且去通過依賴關係去做相應的依賴處理。

但是,這是存在問題的,尤其是當物件中某個屬性的值是陣列的時候。正如 Vue 文件所說:

由於 JavaScript 的限制,Vue 無法檢測到以下陣列變動:

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

Vue 原始碼中也可以看到確實是對陣列做了特殊處理的。原因就是 ES5 及以下的版本無法做到對陣列的完美繼承

實驗一下?

用之前寫好的 observe做了一個簡單的實驗,如下:

import { observe } from './mvvm'
const data = {
  name: 'Jiang',
  userInfo: {
    gender: 0
  },
  list: []
}
// 此處直接使用了前面寫好的 getter/setter
observe(data)
data.name = 'Solo'
data.userInfo.gender = 1
data.list.push(1)
console.log(data)
複製程式碼

結果是這樣的:

如何監聽陣列變化?

從結果可以看出問題所在,data中 name、userInfo、list 屬性的值均發生了變化,但是陣列 list 的變化並沒有被 observe監聽到。原因是什麼呢?簡單來說,運算元組的方法,也就是 Array.prototype上掛載的方法並不能觸發該屬性的 setter,因為這個屬性並沒有做賦值操作。

如何解決這個問題?

Vue 中解決這個問題的方法,是將陣列的常用方法進行重寫,通過包裝之後的陣列方法就能夠去在呼叫的時候被監聽到。

在這裡,我想的一種方法與它類似,大概就是通過原型鏈去攔截對陣列的操作,從而實現對運算元組這個行為的監聽。

實現如下:

// 讓 arrExtend 先繼承 Array 本身的所有屬性
const arrExtend = Object.create(Array.prototype)
const arrMethods = [
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
]
/**
 * arrExtend 作為一個攔截物件, 對其中的方法進行重寫
 */
arrMethods.forEach(method => {
  const oldMethod = Array.prototype[method]
  const newMethod = function(...args) {
    oldMethod.apply(this, args)
    console.log(`${method}方法被執行了`)
  }
  arrExtend[method] = newMethod
})

export default {
  arrExtend
}
複製程式碼

需要在 defineReactive 函式中新增的程式碼為:

if (Array.isArray(value)) {
    value.__proto__ = arrExtend
 }
複製程式碼

測試一下:data.list.push(1)

我們看看結果:

如何監聽陣列變化?

上面程式碼的邏輯一目瞭然,也是 Vue 中實現思路的簡化。將 arrExtend 這個物件作為攔截器。首先讓這個物件繼承 Array 本身的所有屬性,這樣就不會影響到陣列本身其他屬性的使用,後面對相應的函式進行改寫,也就是在原方法呼叫後去通知其它相關依賴這個屬性發生了變化,這點和 Object.definePropertysetter所做的事情幾乎完全一樣,唯一的區別是可以細化到使用者到底做的是哪一種操作,以及陣列的長度是否變化等等。

還有什麼別的辦法嗎?

ES6 中我們看到了一個讓人耳目一新的屬性——Proxy。我們先看一下概念:

通過呼叫 new Proxy() ,你可以建立一個代理用來替代另一個物件(被稱為目標),這個代理對目標物件進行了虛擬,因此該代理與該目標物件表面上可以被當作同一個物件來對待。

代理允許你攔截在目標物件上的底層操作,而這原本是 JS 引擎的內部能力。攔截行為使用了一個能夠響應特定操作的函式(被稱為陷阱)。

Proxy顧名思義,就是代理的意思,這是一個能讓我們隨意玩弄物件的特性。當我們,通過Proxy去對一個物件進行代理之後,我們將得到一個和被代理物件幾乎完全一樣的物件,並且可以對這個物件進行完全的監控。

什麼叫完全監控?Proxy所帶來的,是對底層操作的攔截。前面我們在實現對物件監聽時使用了Object.defineProperty,這個其實是 JS 提供給我們的高階操作,也就是通過底層封裝之後暴露出來的方法。Proxy的強大之處在於,我們可以直接攔截對代理物件的底層操作。這樣我們相當於從一個物件的底層操作開始實現對它的監聽。

改進一下我們的程式碼?

const createProxy = data => {
  if (typeof data === 'object' && data.toString() === '[object Object]') {
    for (let k in data) {
      if (typeof data[k] === 'object') {
        defineObjectReactive(data, k, data[k])
      } else {
        defineBasicReactive(data, k, data[k])
      }
    }
  }
}

function defineObjectReactive(obj, key, value) {
  // 遞迴
  createProxy(value)
  obj[key] = new Proxy(value, {
    set(target, property, val, receiver) {
      if (property !== 'length') {
        console.log('Set %s to %o', property, val)
      }
      return Reflect.set(target, property, val, receiver)
    }
  })
}

function defineBasicReactive(obj, key, value) {
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: false,
    get() {
      return value
    },
    set(newValue) {
      if (value === newValue) return
      console.log(`發現 ${key} 屬性 ${value} -> ${newValue}`)
      value = newValue
    }
  })
}

export default {
  createProxy
}
複製程式碼

對於一個物件中的基礎型別的屬性,我們還是通過Object.defineProperty來實現響應式的屬性,因為這裡並不存在痛點,但是在實現對Object型別的屬性進行監聽的時候,我採用的是建立代理,因為我們之前的痛點在於無法去有效監聽陣列的變化。當我們使用這種改進方法之後,我們不用像之前通過重寫陣列的方法來實現對陣列操作的監聽了,因為之前這種方法存在很多的侷限性,我們不能覆蓋所有的陣列操作,同時,我們也不能響應到類似於data.array.length = 0這種操作。通過代理實現之後,一切都不一樣了。我們可以從底層就實現對陣列的變化進行監聽。甚至能watch到陣列長度的變化等等各種更加細節的東西。這無疑解決了很大的問題。

我們呼叫一下剛才的方法,試試看?

let data = {
  name: 'Jiang',
  userInfo: {
    gender: 0,
    movies: []
  },
  list: []
}
createProxy(data)

data.name = 'Solo'
data.userInfo.gender = 0
data.userInfo.movies.push('星際穿越')
data.list.push(1)
複製程式碼

輸出為:

如何監聽陣列變化?

結果非常完美~我們實現了對物件所有屬性變化的監聽Proxy的騷操作還有很多很多,比如說將代理當作原型放到原型鏈上,這樣一來就可以只對子類不含有的屬性進行監聽,非常的強大。Proxy可以得到更加廣泛的應用,而且場景很多。這也是我第一次去使用,還需要多加鞏固( ;´Д`)

相關文章