🙂

VueUseのuseLocalStorageに一手間加えてちょっと便利に使う

2022/05/25に公開

やりたかったこと

画面からLocalStorageの値を変更した時に、変更内容を即座に画面に反映したかった。
例えば、コンポーネントAでフラグを切り替えた時に、コンポーネントBの表示内容をリアクティブに更新したい、といったようなイメージです。

LocalStorageの操作について

Vue.jsにはVueUseというイカしたパッケージがあり、useLocalStorageという関数を用意してくれています。
LocalStorageをリアクティブに扱えるパッケージは他にもあると思いますが、一番信用できそうだという独断と偏見で、今回はVueUseをベースにしました。

動作確認用

https://github.com/negibouze/vue-observable-localstorage

ざっくり説明

きっかけ

まず、useLocalStorageを使えば、LocalStorageの変更自体はとても簡単にできます。

// bind object
const state = useLocalStorage('my-store', { hello: 'hi', greeting: 'Hello' })

で初期値が登録されるし、stateの値を変更すれば、LocalStorageの値も変わります。
これでも十分便利なのですが、このままだと他の場所で同じオブジェクトを参照している時に、変更した値が即時反映されないという問題がありました。
例えば、下記のような場合、コンポーネントAでstateを変更しても、コンポーネントBには反映されません。

// コンポーネントA
const state = useLocalStorage('my-store', { hello: 'hi', greeting: 'Hello' })
// コンポーネントB
const state = useLocalStorage('my-store', {}, { writeDefaults: false })

これを何とかしたいというのが今回のきっかけです。

調査

用意されているオプションでは実現できなさそうだったので実装を見てみると、EventListenerを登録しているところが見つかりました。
何となくこれを使えばできそうな気がします。

  if (window && listenToStorageChanges)
    useEventListener(window, 'storage', update)

実装

調査結果をもとに、下記のような関数を作りました。
※GitHubに上がっているファイルはもう少し工夫をしてあるので、興味を持っていただけた方はそちらをご参照ください。

export const useObservableLocalStorage: Type = (...params) => {
  const key = params[0]
  const item = useLocalStorage(...params)
  〜 中略 〜
  return computed({
    get: () => item.value,
    set: newVal => {
      const serializer = StorageSerializers[getType(newVal)].write ?? JSON.stringify
      const newValue = serializer(newVal)
      const event = new StorageEvent('storage', { key, newValue })
      window.dispatchEvent(event)
    }
  })
}

ポイントはsetの処理です。
setと言いつつ、この中では値を変更しておらず、調査で発見したstorageイベントをdispatchしているだけです。
以降の処理をざっくり説明すると、useLocalStorage(正確にはuseStorage)側で以下のような処理が実行されます。

  1. EventListenerに登録されているupdate関数が呼ばれる
  function update(event?: StorageEvent) {
    if (event && event.key !== key)
      return

    data.value = read(event)
  }
  1. read関数の戻り値でdataが更新される
  2. dataをwatchしているため、write関数が呼ばれる
  const { pause: pauseWatch, resume: resumeWatch } = pausableWatch(
    data,
    () => write(data.value),
    { flush, deep, eventFilter },
  )
  1. write関数によって、LocalStorageの値が更新される

なぜ、これでやりたかったことが実現できるのか

まず、useLocalStorageの戻り値は前述のdataです。
なので、下記の場合、stateの値を変更するとwatchが発火します。

const state = useLocalStorage('my-store', { hello: 'hi', greeting: 'Hello' })

これでLocalStorageの値は更新されますが、dataの値はstateを変更した所以外では変わらないため、他のコンポーネントには反映されません。
一方、Eventをdispatchした場合、update関数の中でdataの値が変更されます(その結果watchが発火してLocalStorageの値も変更されます)。
EventをListenしているところ全てでupdate関数が実行されるため、他のコンポーネントも更新されます。

注意事項

今回の方法で値を変更した場合、分かっている範囲で下記の注意点があります。

  1. update関数が複数回実行されます(listenToStorageChangesをtrueにして実行したuseLocalStorageの数だけ呼ばれます)。

  2. 複数の場所でLocalStorageの値を変更できるようにした場合、どこかで値を変更した時にLocalStorageへの書き込み処理が複数回実行されます(変更可能にした箇所の数だけ呼ばれます)。
    例えば、下記の場合object1の値を変更した時に書き込み処理が3回実行されます。

const object1 = useObservableLocalStorage<Value>({ key: 'my-object', initialValue: {} })
const object2 = useObservableLocalStorage<Value>({ key: 'my-object', initialValue: {} })
const object3 = useObservableLocalStorage<Value>({ key: 'my-object', initialValue: {} })

すべて同じ値で実行されるため最終的な結果に問題はないと思いますが、多用するとパフォーマンスに影響が出るかもしれません。

終わりに

ここが違うよ、こうすればもっと簡単にできるよ、などありましたら、コメントをいただけると、とてもありがたいです。

Discussion