在 Vue 中讓 localStorage 支援回應式設計

在許多當紅的網頁前端框架中,都採用了回應式設計(Reactive Design),讓狀態變化時自動通知框架來對視圖(View)進行重繪,但通常這麼方便的功能只會支援框架自己提出的 state 模型上,而這次我們來談談如何在 Vue.js[1] 中使用 localStorage[2] 並支援回應式設計吧。

Reactivity[3]

在 Vue.js 中,各個元件(Component)中都有一個 $data 欄位,用以存放回應式的狀態,Vue.js 會在狀態內部改變時,通知並重繪視圖。但在一般的情況下,如果不是存放在 $data 下的狀態是不會被追蹤的,除非我們手動跟 Vue.js 註冊這個物件,如下範例所示:

new Vue({
  el: '#app',
  data: {
    foo: 1,
  },
  created() {
    this.bar = 2
  },
  methods: {
    addFoo() {
      this.foo += 1
    },
    addBar() {
      this.bar += 1
    },
  },
})

See the Pen jOWGYYE by David Kuo (@david50407) on CodePen.

除非因為我們改變了 this.foo 而觸發更新,否則改變 this.bar 值的時候並不會觸發更新。

那是因為在初始化元件時,Vue 會對定義好的 $data 進行 Hook,讓裡面每個欄位的變化都可以被 Vue 所偵測,而 Vue 其實也有提供 API 讓我們可以自己來實作被 Vue 聽的物件,下面有兩個範例:

// Hook whole object
const foobar = Vue.observable({ fb: 3 })

new Vue({
  el: '#app',
  data: {
    foo: 1,
  },
  created() {
    this.foobar = foobar
    // Hook only on bar field
    Vue.util.defineReactive(this, 'bar', 2)
  },
  methods: {
    addFoo() {
      this.foo += 1
    },
    addBar() {
      this.bar += 1
    },
    addFooBar() {
      this.foobar.fb += 1
    }
  },
})

See the Pen JjGrMzL by David Kuo (@david50407) on CodePen.

Vue 的行為其實是利用了 Object.defineProperty[4] 來建立與欄位同名的 Getter/Setter[5],並在我們呼叫使用時記錄下這之間的相依關係,在塞入新值的時候通知這些被相依的部分來進行更新[6]

// vue@2.6.11:src/core/observer/index.js
export function defineReactive (obj, key, ...) {
  const dep = new Dep()
  // ...
  Object.defineProperty(obj, key, {
    // ...
    get() {
      // ...
      dep.depend()
      // ...
    },
    set(newVal) {
      // ...
      dep.notify()
    }
  })
}

而在該物件本來就有 Getter/Setter 的情況下,Vue 也會在 Hook 中進行呼叫以達到與大部分物件相容的可能性。

設計簡易 localStorage Vue.js Plugin

瞭解了 Vue 中 Reactivity 的機制之後,我們就可以來設計一下我們理想中的功能以及該如何進行實作,localStorage 有永續性儲存及跨瀏覽頁狀態的特性,適合做一些較為進階的功能。總之,我們先來設計一個簡單的 PoC 讓 Vue 會依據 localStorage 的內容作出更新:

class LocalStorage {
  static install (Vue, options) {
    const instance = new LocalStorage(Vue, options)
    
    Object.defineProperty(Vue.prototype, '$localStorage', {
      get: () => instance.storage,
    })

    Vue.localStorage = instance.storage
    
    return instance
  }
  
  static pack (value) {
    if (value === undefined)
      return undefined
    
    return JSON.stringify(value)
  }
  static unpack (value) {
    if (value === undefined)
      return undefined

    try {
      return JSON.parse(value)
    } catch (e) {
      return value
    }
  }

  constructor (Vue, { fields = [] }) {
    const storage = {}
    fields.forEach((property) => {
      Object.defineProperty(storage, property, {
        get: () => LocalStorage.unpack(window.localStorage.getItem(property)),
        set: (val) => window.localStorage.setItem(property, LocalStorage.pack(val)),
        configurable: true,
      })
      
      Vue.util.defineReactive(storage, property, storage[property])
    })
    
    this.storage = storage
  }
}

Vue.use(LocalStorage, {
  fields: ['num'],
})

new Vue({
  el: '#app',
  created() {
    if (this.$localStorage.num === undefined)
      this.$localStorage.num = 0
  },
  methods: {
    add() {
      this.$localStorage.num += 1
    },
  },
})

See the Pen ZEQXrar by David Kuo (@david50407) on CodePen.

從上面的 PoC 中已經可以發現,我們在跨元件甚至是跨 Vue 實例的情況下已經可以成功的同步 Vue.localStorage 中的東西了。我們就先以這個 PoC 來解釋整體的思路吧:

Vue.js Plugin Interface[7]

Vue 中提供了一個註冊 Plugin 的方法:

Vue.use(Plugin)

而 Vue 會去呼叫 Plugin.install 方法,並將 Vue 以及傳入 Vue.use 的參數都傳入這個方法中,如此以來我們就可以取得我們要擴充的這個 Vue(如果你同時有很多 Vue 實作的話……),我們在這裡只要做好 Vue 的擴充就好了,有關存取 localStorage 的實作我們放到 LocalStorage 這個 class 中進行:

class LocalStorage {
  // 定義 LocalStorage.install
  static install (Vue, options) {
    const instance = new LocalStorage(Vue, options)
    
    // 定義 vm.$localStorage
    Object.defineProperty(Vue.prototype, '$localStorage', {
      get: () => instance.storage,
    })

    // 定義 Vue.localStorage
    Vue.localStorage = instance.storage
    
    return instance
  }
}

實際操作 localStorage

在實際實作 localStorage 的時候,我們打算讓物件的型別儘可能的保留下來,這邊使用最簡單的方式實作 —— 透過 JSON.stringify[8]/JSON.parse[9],儘管這裡會把一些自定物件給統統轉換為 JSON 儲存而丟失型別,但先求有再求好,之後可以再來決定序列化/反序列化的方法:

class LocalStorage {
  // 序列化
  static pack (value) {
    if (value === undefined)
      return undefined
    
    return JSON.stringify(value)
  }
  // 反序列化
  static unpack (value) {
    if (value === undefined)
      return undefined

    try {
      return JSON.parse(value)
    } catch (e) {
      return value
    }
  }

  constructor (Vue, { fields = [] }) {
    const storage = {}
    // 對每個指定的 fields 進行定義
    fields.forEach((property) => {
      // 定義對應的 Getter/Setter 以直接存取 localStorage
      Object.defineProperty(storage, property, {
        get: () => LocalStorage.unpack(window.localStorage.getItem(property)),
        set: (val) => window.localStorage.setItem(property, LocalStorage.pack(val)),
        // 設為 configurable 以讓 Vue 進行 Hook
        configurable: true,
      })
      // 讓 Vue hook 對應欄位
      Vue.util.defineReactive(storage, property, storage[property])
    })
    
    this.storage = storage
  }
}

我們這邊使用一個空的 storage 物件來進行 Object.defineProperty 讓我們指定的欄位們在這個物件上都有受 Vue 監聽的 Getter/Setter,如此以來就可以簡單的在同一個頁面上分享 localStorage 了。

改良 LocalStorage Plugin

跨頁面同步更新

咦?我剛剛是說了「可以簡單的在同一個頁面上分享 localStorage」嗎?

如果各位把上面的範例開到不同的分頁中簡單的嘗試一下就可以發現,雖然在同一個頁面上的 localStorage 都可以跟著更新沒錯,但一遇到了跨頁面就無法自動更新了…… 這怎麼行?localStorage 的其中一個好處就是可以跨頁面儲存啊,如果不能做到跨頁面更新的話不就沒有什麼意義了。

所以我們接下來要進行改良,先從如何監聽更新開始,Javascript 提供了一個方便我們監聽 localStorage 從其他頁面更新了的事件 —— storage 事件[10]

window.addEventListener('storage', ({key, newValue}) => {
  if (key == 'num') {
    document.body.innerText = `num updates: ${newValue}`
  }
})

See the Pen QWyqmGr by David Kuo (@david50407) on CodePen.

所以我們只要在監聽到事件之後塞進去 Vue.localStorage 中就好了對吧?像這樣:

window.addEventListener('storage', ({key, newValue}) => {
  Vue.localStorage[key] = LocalStorage.unpack(newValue)
})

See the Pen XWXeEKQ by David Kuo (@david50407) on CodePen.

奇怪,為什麼還是沒有進行更新啊?

原來是 Vue 在 Hook 的 Setter 中有判斷,如果要塞進去的值跟目前的值(從 Getter 取出)相同的話,那麼為了效能考量而不會進行重繪[11],而從 Getter 直接取出的話實際上是會取出目前 localStorage 中最新的值(與 storage 事件中的 newValue 相同)導致 Vue 不會進行更新:

// vue@2.6.11:src/core/observer/index.js
export function defineReactive (obj, key, ...) {
  // ...
  Object.defineProperty(obj, key, {
    // ...,
    set(newVal) {
      const value = getter ? getter.call(obj) : val
      if (newVal === value || (newVal !== newVal && value !== value)) {
        return
      }
      // ...
    }
  })
}

所以我們必須得在前面再增加一層 Proxy 來讓 Getter 不會取得 localStorage 中最新的值,而是最後更新過的值才行,於是我在這裡設計了一個 Cache 機制,讓所有最後在這個頁面上更新過的內容都寫入 Cache 中,讀取時也會直接從 Cache 中讀取:

class LocalStorage {
  static createCache() {
    return new Proxy({}, {
      get(target, property, receiver) {
        if (!(property in target)) {
          target[property] = LocalStorage.unpack(window.localStorage.getItem(property))
        }
        
        return target[property]
      },
      set(target, property, value, receiver) {
        target[property] = value
        window.localStorage.setItem(property, LocalStorage.pack(value))
        
        return true
      },
    })
  }
}

See the Pen ZEQXoRp by David Kuo (@david50407) on CodePen.

這裡使用了 Proxy API[12] 來捕捉所有的 Get/Set Properties 行為,這樣就可以不管在 Cache 中寫入或讀取任何值都完美的被我們 Hook 起來了,其實在 Vue 3 中也是放棄了 Object.defineProperty 而改用 Proxy 來 Hook。

動態欄位

既然我們都使用了 Proxy 來幫我們捕捉在 Cache 中任意欄位的變化了,是不是也表示我們可以用一樣的方法來讓我們不需要再傳遞一個欄位清單到我們的 LocalStorage Plugin 了呢?

沒錯,馬上就來試驗一下:

class LocalStorage {
  constructor(Vue) {
    // ...
    this.storage = new Proxy({}, {
      get(target, property, receiver) {
        if (!target.hasOwnProperty(property)) {
          observeItem(target, property)
        }
        return target[property]
      },
      set(target, property, value) {
        if (!target.hasOwnProperty(property)) {
          observeItem(target, property)
        }
        
        target[property] = value
        return true
      },
    })
  }
}

See the Pen WNrZJmv by David Kuo (@david50407) on CodePen.

更好的動態欄位

很好,看起來只有被用到的 num 欄位被記錄在我們的 LocalStorage Plugin 裡面了。

咦?奇怪,如果 localStorage 中有其他欄位而我們卻還沒有 Get 過的話,在 Vue.localStorage 裡面就沒有辦法透過 Object.keys/in/for-in 等方法進行遍歷了…… 這樣不就跟沒有動態欄位一樣嗎?

好在 Proxy API 提供了更多的功能,可以讓我們對上面這些操作進行 Hook,於是我們就可以著手修改成下面的形式:

class LocalStorage {
  constructor(Vue) {
    // ...
    this.storage = new Proxy({}, {
      get(target, property, receiver) {
        // 我們只 Hook string property key
        if (!target.hasOwnProperty(property) && typeof property === 'string') {
          observeItem(target, property)
        }
        return target[property]
      },
      set(target, property, value) {
        // 我們只 Hook string property key
        if (!target.hasOwnProperty(property) && typeof property === 'string') {
          observeItem(target, property)
        }
        
        target[property] = value
        return true
      },
      deleteProperty(target, property) {
        delete _cache[property]
        // Keep Vue tracking this property
        target[property] = undefined
        
        return true
      },
      ownKeys(target) {
        // 從 window.localStorage 讀取 keys
        return Reflect.ownKeys(window.localStorage)
      },
      getOwnPropertyDescriptor(target, property) {
        // 從 windows.localStorage 讀取 property descriptor
        return Reflect.getOwnPropertyDescriptor(window.localStorage, property)
      },
      has(target, property) {
        // 判斷 windows.localStorage 中是否有該 property
        return property in window.localStorage
      },
    })
  }
}

See the Pen XWXeYWe by David Kuo (@david50407) on CodePen.

在 v-for 遍歷的時候,Vue 會嘗試取得 vm.$localStorage[[Symbol.iterator]] 導致我們原先寫的 get 會嘗試去監聽這個東西,而導致有錯誤被拋出,於是順便修改一下 Hook 的規則,改成只對 string key property 去監聽。

同時,這個範例其實也是我們這次的完整版,還偷偷加上了支援 delete 操作的方法,有興趣的同學就自己去看看吧 XD

後記

在研究如何實作一個 Reactive 的狀態並與 localStorage 同步的過程中也是第一次嘗試了 Proxy API 的寫法,其實寫起來還滿舒服的,有點期待 Vue 3 的更新了。

到時候應該會把本文寫的 Plugin 包成一個 package 丟到 npm 上面,大家也可以直接拿上面的程式碼去玩玩看,或是修改成符合自己用途的 Plugin 順便練習看看!


  1. 常見的 MVC 前端框架,見 Vue.js 官方網站以瞭解更多。 ↩︎

  2. 瀏覽器中獨有的 localStorage 功能,提供網頁前端儲存少許資料的空間,詳見 MDN 上面的說明↩︎

  3. 有關 Vue 中 Reactivity 的運作機制,可以參見 Vue 網站上的介紹↩︎

  4. 有關 Object.defineProperty 的說明,請參見 MDN 上的文件↩︎

  5. 有關 Getter/Setter 的說明,請參見 MDN 上的文件↩︎

  6. 原始碼請見 https://github.com/vuejs/vue/blob/v2.6.11/src/core/observer/index.js#L135↩︎

  7. 有關如何撰寫 Vue Plugin,在 Vue 官網上有一些基礎的範例↩︎

  8. 有關 JSON.stringify 的用法,請見 MDN 上的文件說明↩︎

  9. 有關 JSON.parse 的用法,請見 MDN 上的文件說明↩︎

  10. 有關 storage 事件的詳細資訊,可以參考 MDN 上的說明↩︎

  11. 原始碼請見 https://github.com/vuejs/vue/blob/v2.6.11/src/core/observer/index.js#L176↩︎

  12. 有關 Proxy 的用法,可以參考 MDN 上的說明↩︎