STORES Product Blog

こだわりを持ったお商売を支える「STORES」のテクノロジー部門のメンバーによるブログです。

Vue に stale-while-revalidate がやってくる

STORES でフロントエンド開発をしているushironokoです。今回は Vue でも SWR のようなしくみが使え、遠くない未来で標準的に使われることになりそうだ、という話を書きます。stale-while-revalidate とはどのようなものなのかについても簡単に解説していきます。

SWR(stale-while-revalidate) とは何か

Vue や Nuxt 界隈の技術者はあまり縁がないため、そもそも SWR と称されるものが何者なのかご存知でない方も多いはずです。SWR は stale-while-revalidate と呼ばれるキャッシュ戦略に基づいたデータフェッチライブラリで、React のカスタムフックとして提供されています。つまり、元々 React 向けのライブラリとして作られたものです。

github.com

stale-while-revalidate は RFC 5861 で策定された効率的な HTTPCache-Control を実現するための戦略で、SWR はその略称となります。RFC 5861 では具体的には2つのキャッシュ戦略が示されています。

  • stale-while-revalidate

    • 指定された期間に行われるキャッシュの再検証中は古いキャッシュを返す
  • stale-if-error

    • 指定された期間にネットワークエラーがあった場合古いキャッシュを返す

指定された期間は、キャッシュの生存期間とは別に指定します。例えば max-age=600stale-while-revalidate=600 とすると20分間保持したキャッシュを返却します。ただし、stale-while-revalidate が有効なのは再検証が終わっていない間で、キャッシュが破棄された場合は次のフェッチでサーバーへ問い合わせを行います。

RFC 2616 では、「慎重に検討された状況」では期限の切れたキャッシュを返却しても良い、ということになっています。古いキャッシュというのはキャッシュの生存期間を過ぎてもstale-while-revalidate、または stale-if-error の値に基づいて返しても良いキャッシュ、つまり「慎重に検討された状況」で返しても良いキャッシュのことを指します。 重要なのは再検証の際にブロックされないということです。

キャッシュを更新している間に古いキャッシュを返すことで、ユーザーに遅延を感じさせないようにキャッシュコントロールできます。これは結果整合性を重視するという考えに基づきます。

SWR はこの2つをうまく落とし込んだライブラリです。stale-while-revalidate を自動で行い、さらにユーザコードから非同期処理を隠蔽することで stale-if-error を楽に実装できます。以上のことから React 界隈では頻繁に利用されており、スター数や使用数も多い著名なライブラリとなっています。

Vue にやってきたSWR、SWRV

では Vue 界隈ではどうなっているかというと、SWRV というライブラリで同じような実装が実現できます。SWRV の開発にはNuxtのコアメンバーも関わっています。

github.com

SWRV は SWR (紛らわしい)と同じく useSWRV という Composition API ベースの関数が提供されており、これを用いて賢いキャッシュ戦略を簡単に実装できます。例えば以下のようなコードがあります (雰囲気コードです)。

<script lang="ts">
import { defineComponent, reactive, ref, Ref } from 'vue'

async function useFetch(id: number, isLoading: Ref<boolean>) {
  isLoading.value = true
  const res = await fetch('/api/posts/1')
  isLoading.value = false
  return res.json()
}

export default defineComponent({
  async setup() {
    const isLoading = ref(false)
    const res = reactive(await useFetch(1, isLoading))
    return {
      res,
      isLoading,
    }
  },
})
</script>

<template>
  <div v-if="!res?.title || isLoading">Loading...</div>
  <div v-else>{{ res.title }}</div>
</template>

自分で isLoading のようなフラグを持ち、ローディングが終わっているか判別しています。レスポンスデータが存在すれば表示するというコードです。

問題点として通信が失敗した時の処理が欠けているのと、抽象化できていないので通信処理を行うコンポーネント全てにローディングのフラグを持つ必要があることが挙げられます。このあたりはよく Vuex などに詰め込んでいた記憶がありますが、SWRV に任せることで非常に見通しの良いコードになります。

<script lang="ts">
import { defineComponent, ref } from 'vue'
import useSWRV from 'swrv'
import { Posts } from './api/types'

export default defineComponent({
  setup() {
    const endpoint = ref('/api/posts/1')
    const { data, error } = useSWRV<Posts>(endpoint.value, fetch)
    return {
      data,
      error,
    }
  },
})
</script>

<template>
  <div v-if="error">error</div>
  <div v-if="data === undefined && !error">Loading...</div>
  <div v-if="data">{{ data.title }}</div>
</template>

useSWRV は第一引数に渡されたエンドポイントの値をキーにしてレスポンスをキャッシュします。第二引数には任意のフェッチャーを指定できます。今回の場合はネイティブの fetch ですが、axiosky を渡すこともできます。

dataerrortoRefs されて返されるため、変化があった場合は再レンダリングされます。また非同期処理を完全に隠蔽できていることがみて取れます。

useSWRV は以下を自動で行います。

  • 定期的な再検証
  • キャッシュの更新
  • 通信エラー時のキャッシュ返却
  • 定期的なリトライ

そのため画面には基本最新のキャッシュが表示され、表示できない時は古いキャッシュを表示します。通信エラーの場合でもキャッシュを返却するので、常にユーザーは何かしらのコンテンツを見ることができる点が良いですね。

キャッシュの設定を手動で行う場合

SWRV のキャッシュをどこで行うかの設定はキャッシュの実装を継承することで変更できます。デフォルトではプライベートなキーとMapを持つクラス、つまりインメモリにキャッシュされます。以下に実装を見ることができますが、非常にシンプルです。

https://github.com/Kong/swrv/blob/8be65954a6f38f8b3063c6c4728c59f016dec022/src/cache/index.ts

公式の例ではキャッシュ先を localStorage へ差し替えるものがあります。SWRVCache を継承して getter/setter をオーバーライドするだけです。あとは useSWRV する時に第三引数としてインスタンスを渡します。

class LocalStorageCache extends SWRVCache {
  STORAGE_KEY = 'swrv'

  private encode (storage) { return btoa(JSON.stringify(storage)) }
  private decode (storage) { return JSON.parse(atob(storage)) }

  get (k, ttl) {
    const item = localStorage.getItem(this.STORAGE_KEY)
    if (item) {
      return JSON.parse(atob(item))[k]
    }
  }

  set (k, v) {
    let payload = {}
    const storage = localStorage.getItem(this.STORAGE_KEY)
    if (storage) {
      payload = this.decode(storage)
      payload[k] = { data: v, ttl: Date.now() }
    } else {
      payload = { [k]: { data: v, ttl: Date.now() } }
    }

    localStorage.setItem(this.STORAGE_KEY, this.encode(payload))
  }
}

const myCache = new LocalStorageCache()

export default {
  setup () {
    return useSWRV(key, fetch, { cache: myCache })
  }
}

利用する場合の注意点

useSWRV はトップレベルでのみ動作します。つまりネストされた関数の中では実行できません。これは SWRV の制約というよりも Composition API の制約になります。例えば愚直にイベントハンドラに指定すると、$isServer of null のようなエラーを吐きます。この場合の解決策として、キーをイベントハンドラに紐づけてクリックイベントの発火時のみ useSWRV が動作するように記述する方法が示されています。

https://github.com/Kong/swrv/issues/40

const hasClicked = ref(false)

const onClick = () => {
  hasClicked.value = true
};

const { data } = useSWRV(() => hasClicked.value && 'test', fetcher);

return {
  onClick,
};

それから、SWR のようにリトライのアルゴリズムexponential backoff algorithm で最適化されているかどうかはドキュメントからは読み取れませんでした。まだコードを追っていないので実際どうなのかわかりませんが、いずれ対応されるやもしれません。

Nuxt に入りそうな話

実は SWRV はかなり前から存在していて、時期的には大体 Composition API のブリッジライブラリが出た頃だったと記憶しています。ではなぜ今さら来たと表現するのかというと、Nuxt 3 にて SWR ライクなデータフェッチの仕組みを提供すると発表されたためです。

nuxtjs.slides.com

SWRV は Nuxt のコアメンバーがメンテナンスしていることもありこのあたりに関連性を見出せそうです。直接利用されるかどうかはわかりませんが、今のうちにその概念自体は学んでおくべきと言えます。

最後に

SWR を例に stale-while-revalidate の考え方と、Vue 版である SWRV について紹介しました。プロダクションで使っている、こんなハマりどころがあった等あればぜひ言及していただきたいです。それでは。

参考まとめ

https://tools.ietf.org/html/rfc5861

https://tools.ietf.org/html/rfc2616#section-13.1.1

https://blog.jxck.io/entries/2016-04-16/stale-while-revalidate.html

https://guuu.io/2020/data-fetching-vue-composition-api/

https://web.dev/stale-while-revalidate/

https://panda-program.com/posts/useswr

https://nuxtjs.slides.com/atinux/stale-of-nuxt-2020