概要
Vue.jsで連想配列・多次元配列を持つ画像セットを扱う際、indexの切り替えで都度imgタグにセットされたsrc属性のURLへfetchしてしまう問題があります。
これを回避するためにkey属性をはじめとするVue.jsの仕様を1から調べ直してみたのでご紹介します。
問題の背景
とある開発でオブジェクト・配列としてネストしたデータ構造から動的に表示内容を切り替える実装がありました。
以下のような画面イメージです。
※draw.ioで作成したシステムデザインをGemini Nano Bananaに投げて生成
問題点: 画像セットを切り替える度にfetchが実行される
img属性のsrcが変更され、再度元の画像URLがセットされたとしても再fetchsされてしまいます。
ブラウザ側のディスクキャッシュが有効であればキャッシュが利用される場合もありますが、基本的にはキャッシュされないため通信上のオーバーヘッドが生じます。
サンプルソースコード
ImageSection.vue
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { ImageSet } from '@/types/ImageSet'
import ImageItem from './ImageItem.vue'
/**
* 画像セット
* 1つのセットに複数画像を持つ
*/
const imageSet = ref<ImageSet>({
id: 'sample-image-set-001',
details: 'サンプル画像セットの説明文です',
imageList: [
{
id: 'image-001',
label: '画像1',
beforeImageSrc: 'https://picsum.photos/200/300',
afterImageSrc: 'https://picsum.photos/400',
},
{
id: 'image-002',
label: '画像2',
beforeImageSrc: 'https://placebear.com/400/300',
afterImageSrc: 'https://placebear.com/405/300',
},
{
id: 'image-003',
label: '画像3',
beforeImageSrc: 'https://picsum.photos/200',
afterImageSrc: 'https://picsum.photos/400',
},
],
})
/**
* 現在選択中の画像セットインデックス
*/
const currentImageIndex = ref(0)
/**
* 現在選択中の画像一覧
*/
const currentImageSet = computed(() => imageSet.value?.imageList[currentImageIndex.value])
</script>
<template>
<ul style="display: flex">
<li v-for="(_, idx) in imageSet?.imageList" :key="idx">
<button @click="currentImageIndex = idx">{{ `画像${idx + 1}` }}</button>
</li>
</ul>
<ImageItem :image-item="currentImageSet" />
</template>
ImageItem.vue
<script setup lang="ts">
import type { ImageItem } from '@/types/ImageSet'
import { computed, ref } from 'vue'
const props = defineProps<{
imageItem?: ImageItem
}>()
/**
* 現在表示中の画像キー
*/
const currentImageKey = ref<'before' | 'after'>('before')
const IMAGE_STATE_KEY = ['before', 'after']
const currentImage = computed(() =>
currentImageKey.value === 'after'
? props.imageItem?.afterImageSrc
: props.imageItem?.beforeImageSrc,
)
</script>
<template>
<div style="display: flex">
<label v-for="(state, idx) in IMAGE_STATE_KEY" :key="idx">
{{ state }}
<input v-model="currentImageKey" type="radio" :value="state" />
</label>
</div>
<div>
<p>{{ imageItem?.label }}</p>
<img width="200" height="300" :src="currentImage" />
</div>
</template>
※データバインドに関するサンプルのためスタイル定義はほぼ省略しています。
試行錯誤
試行1. imgタグをKeepAliveでラップする→❌
Vue.jsには KeepAliveというビルドインコンポーネントが提供されており、これでラップされたコンポーネントはキャッシュされます。
例えば、動的コンポーネントによる切り替えたり、v-ifにfalseがセットされ再度trueに戻ったケース等でキャッシュが適用されるため再レンダリングせずに済みます。
また、コンポーネント操作で変更された状態も保持されます。
e.g.
<!-- 非アクティブなコンポーネントはキャッシュされます! -->
<KeepAlive>
<component :is="activeComponent" />
</KeepAlive>
このKeepAliveを ImageItem.vueのimgタグに適用することで解決を試みました。
<div>
<p>{{ imageItem?.label }}</p>
<!-- ここに追加 -->
<KeepAlive>
<img width="200" height="300" :src="currentImage" />
</KeepAlive>
</div>
しかし効果はなし。
試行2. KeepAlive追加 + imgタグにkey属性を追加
key属性はコンポーネントの再レンダリングを行うためのヒントとして利用されるため、逆にコンポーネントがアクティブになった際、キーが等しければキャッシュが効くのではないか?と思い試してみました。
<div>
<p>{{ imageItem?.label }}</p>
<!-- ここに追加 -->
<KeepAlive>
<img width="200" height="300" :key="imageCacheKey" :src="currentImage" />
</KeepAlive>
</div>
これも効果はなし。
ここで一度key属性の振る舞いについて調べ、考え直してみました。
key属性について
引用:https://ja.vuejs.org/api/built-in-special-attributes#key
特別な属性 key は、主に Vue の仮想 DOM アルゴリズムが新しいノードリストを古いリストに対して差分する際に、vnode を識別するためのヒントとして使用されます。
つまり、key属性は要素の 「識別」 に使われるのであり、keyの変更はvnodeから新しくDOMを生成するかどうかの判定に使われています。
既に同じキーの要素があれば再利用されますし、意図的に変更してレンダリングを再度実行することができるとも言えます。
公式ドキュメントにも以下のような 強制的な要素の置き換えについて説明されています。
また、要素/コンポーネントを再利用するのではなく、強制的に置き換えるためにも使用できます。これは、次のような場合に便利です:
コンポーネントのライフサイクルフックを適切にトリガーする
トランジションをトリガーする
例えば:
<!-- template !-->
<transition>
<span :key="text">{{ text }}</span>
</transition>
text が変更されると、 はパッチされるのではなく、常に置き換えられるので、トランジションがトリガーされます。
つまりkey属性はKeepAlive側から「識別」を参照するために使われるということですね。
KeepAliveの仕様
引用: https://ja.vuejs.org/guide/built-ins/keep-alive#keepalive
<KeepAlive>は、複数のコンポーネント間を動的に切り替えるときに、コンポーネントインスタンスを条件付きでキャッシュ可能にするビルトインコンポーネントです。
この文章を見た時に気がついたのが KeepAliveはコンポーネントインスタンスをキャッシュする仕組みであるという点です。
標準DOMのvnodeを直に書いてもコンポーネントインスタンスが生成されないのではないか?という疑問が浮上したため、試しにラップしてみることにしました。
試行3. 画像表示部をコンポーネント化し、key属性付与&KeelAliveでラップ→⭕️
これで一度fetchした画像は再fetch(レンダリング)されることなく状態を保持できるようになりました!
<div>
<p>{{ imageItem?.label }}</p>
<KeepAlive>
<ImageViewer :key="currentImageCacheKey" :image-url="currentImage" />
</KeepAlive>
</div>
ImageViewer.vue
<script setup lang="ts">
defineProps<{
imageUrl: string
}>()
</script>
<template>
<img width="200" height="300" :src="imageUrl" />
</template>
更に ImageSection.vue もリストによる切り替えを含むためKeepAliveを適用していきます。
ImageSection.vue
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { ImageSet } from '@/types/ImageSet'
import ImageItem from './ImageItem.vue'
/**
* 画像セット
* 1つのセットに複数画像を持つ
*/
const imageSet = ref<ImageSet>({
id: 'sample-image-set-001',
details: 'サンプル画像セットの説明文です',
imageList: [
{
id: 'image-001',
label: '画像1',
beforeImageSrc: 'https://picsum.photos/200/300',
afterImageSrc: 'https://picsum.photos/400',
},
{
id: 'image-002',
label: '画像2',
beforeImageSrc: 'https://placebear.com/400/300',
afterImageSrc: 'https://placebear.com/405/300',
},
{
id: 'image-003',
label: '画像3',
beforeImageSrc: 'https://picsum.photos/200',
afterImageSrc: 'https://picsum.photos/400',
},
],
})
/**
* 現在選択中の画像セットインデックス
*/
const currentImageIndex = ref(0)
/**
* 現在選択中の画像一覧
*/
const currentImageSet = computed(() => imageSet.value?.imageList[currentImageIndex.value])
/**
* イメージセットのキャッシュキー
*/
const currentImageSetCacheKey = computed(() => currentImageSet.value?.id)
</script>
<template>
<ul style="display: flex">
<li v-for="(_, idx) in imageSet?.imageList" :key="idx">
<button @click="currentImageIndex = idx">{{ `画像${idx + 1}` }}</button>
</li>
</ul>
<KeepAlive>
<ImageItem :key="currentImageSetCacheKey" :image-item="currentImageSet" />
</KeepAlive>
</template>
これにより以下2つのレイヤーでのキャッシュ制御が可能になりました。
1. 画像セット別のキャッシュ
2. Before/After画像のキャッシュ
気を付けることとして、imgタグにつける キー属性は必ずユニークに定義する という点です。
単なるv-forから得たindexでは他の画像セットと重複するリスク、before/afterの識別が困難であるという点から避けるべきと考えています。
当記事では 画像セットID + Before/After画像URLという構造でユニーク化していますが、URLをキーに使うのも微妙なので、Viewerコンポーネントに対してUUIDやnanoIDを発行しても良いかもしれません。
もう少しKeepAliveの使い方を掘り下げる
画像セット一覧が大量になるとキャッシュするインスタンスも増加する可能性があります。
デスクトップ・ラップトップであればそこまで深刻になることは少ないですが、スマホ環境ではキャッシュが増えすぎるとメモリ不足を引き起こす可能性があります。
対策としてKeepAliveでキャッシュできるインスタンス数を制限します。
引用:https://ja.vuejs.org/guide/built-ins/keep-alive#max-cached-instances
maxprops を使うと、キャッシュできるコンポーネントインスタンスの最大数を制限することができます。max が指定された場合、 は LRU キャッシュ のように動作します。つまり、キャッシュインスタンスの数が指定された最大数を越えようとした時点で、最も過去にアクセスされたキャッシュインスタンスが破棄され、新しいキャッシュ用のスペースが作られます。
つまり、maxをつけておけばインスタンスのキャッシュ数を制御できます。
e.g.
<KeepAlive :max="10">
<component :is="activeComponent" />
</KeepAlive>
これをユーザーエージェントやビューポート等でスマホ環境の判定を行い、maxの数値を設定することもできそうですね。
hardwareConcurrency, deviceMemory等で端末スペックを判定する方法もありますが、これらの値はプライバシー保護の観点で曖昧になっていることがあるため信頼性はそこまで高くありません。
感想
画像を多次元で扱うシーンは様々なサービスで登場します。
コンテンツ配信の効率化やWeb最適化とは別のレイヤーでうまく活用し、ユーザー体験を向上させていきたいですね。
Vueのkey属性だけでなくKeepAliveの仕様についても誤解していた点があったため、良い勉強になりました。

