ブログ

Vue.jsとIntersectionObserverで作る無限スクロール

最近あまり見かけなくなった無限スクロールアニメーションをVue.jsIntersectionObserveを使用して実装してみました。メモ程度に残しておきます。

※Vue.jsで無限スクロールを実装する場合にはv-infinite-scrollというコンポーネントも用意されていますが、今回はオリジナルで作成しています。

実装例

下記が実装例です。

VueとIntersectionObserverで実装した無限スクロール

スクロールに応じてブログの記事の取得と表示を繰り返しているシンプルな実装です。ブログの記事の取得にはWordPressのREST APIを使用しています。実装したかった機能は以下の通りです。

  • 初回ロード時は10件の記事を表示
  • スクロールすると追加で10件の記事を表示
  • フェードアニメーションで追加の記事を表示
  • データ取得時にローディングアニメーションを表示

GitHub
vue-infinite-scroll

コードの全体像

以下がコードの全体像です。無限スクロールと関係のないスタイルは省略しています。

App.vue
<script setup>
import axios from 'axios';
import { onMounted, ref, useTemplateRef, nextTick } from 'vue';
const BATCH_SIZE = 10;
const posts = ref([]);
const isLoading = ref(false);
const hasMore = ref(true);
const list = useTemplateRef("ref-list"); // useTemplateRefでテンプレート内のDOM要素を取得
let observer = null;
const fetchPosts = async () => {
if (isLoading.value || !hasMore.value) return; // isLoadingを含めることで重複リクエストを防止
isLoading.value = true;
// 初回以外の場合に遅延を設定
if (posts.value.length > 0) {
await new Promise(resolve => setTimeout(resolve, 500));
}
try {
const response = await axios.get('https://vool.jp/wp-json/wp/v2/posts', {
params: {
offset: posts.value.length, // 既に取得済みのデータ数から開始
per_page: BATCH_SIZE, // 1回のリクエストで返すレコードの数を指定
},
});
if (response.data.length === 0) {
hasMore.value = false;
// 既存のオブザーバーを切断
observer?.disconnect();
} else {
// 新しく取得したデータを追加
posts.value = [...posts.value, ...response.data];
// データ取得/追加後、DOM更新を待ってからobserveLastItem()を実行
await nextTick();
observeLastItem();
}
} catch (error) {
console.error('データの取得中にエラーが発生しました:', error.message);
} finally {
isLoading.value = false;
}
};
const observeLastItem = () => {
// 既存のオブザーバーを切断
observer?.disconnect();
// 新しいオブザーバーを作成
observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
fetchPosts();
}
});
},
{
root: null,
rootMargin: "0px 0px 0px 0px", // ビューポートの下端を基準
threshold: 1 // 対象の要素の最下部を基準
}
);
// リスト内の最後の子要素を監視
const lastItem = list.value.lastElementChild;
if (lastItem) {
observer.observe(lastItem);
}
};
onMounted(() => {
fetchPosts();
});
</script>
<template>
<div class="l-inner">
<ul ref="ref-list" class="list">
<TransitionGroup name="fade">
<li v-for="post in posts" :key="post.id" class="item">
<a :href="post.link" class="link" target="_blank" rel="noopener noreferrer">
<p>ID:{{ post.id }}</p>
<p>スラッグ:{{ post.slug }}</p>
<p>タイトル:{{ post.title.rendered }}</p>
<p v-html="post.excerpt.rendered"></p>
</a>
</li>
</TransitionGroup>
</ul>
<div v-if="isLoading">
<span class="loader"></span>
</div>
</div>
</template>
<style scoped>
.fade-enter-from {
opacity: 0;
}
.fade-enter-active {
transition: opacity 0.75s ease;
}
.fade-enter-to {
opacity: 1;
}
</style>

処理の言語化

Vue.jsの基本的な機能の他にデータの取得にはaxios、スクロールの監視にIntersectionObserverを使用しています。要点となる処理ごとに忘れない程度にアウトプットします。

データの取得

axiosでのデータ取得時にパラメーターを指定しています。

App.vue
<script setup>
const response = await axios.get('https://vool.jp/wp-json/wp/v2/posts', {
params: {
offset: posts.value.length, // 既に取得済みのデータ数から開始
per_page: BATCH_SIZE, // 1回のリクエストで返すレコードの数を指定
},
});
</script>

WordPressのREST APIのレスポンスではoffsetで「投稿を取得したい任意のオフセット(何件目から取得するか)」を、per_pageで「1回のリクエストで返すレコードの数」を指定することができます。

今回は既に取得済みのデータ数からデータを取得したいのでoffsetにはposts.value.lengthを、10件取得したいのでper_pageには10を指定しました。

※パラメーター名はAPIの種類によって変わるので注意が必要。例えばJSONPlaceholderではそれぞれ_start_limitが該当する。

DOMの更新を待機

リアクティブな値に新しく取得したデータを追加した後、再び要素を監視するためにnextTick()を使用しています。

App.vue
<script setup>
// 新しく取得したデータを追加
posts.value = [...posts.value, ...response.data];
// データ取得/追加後、DOM更新を待ってからobserveLastItem()を実行
await nextTick();
observeLastItem();
</script>

nextTick()を使用することで、データ更新後にDOMへの反映を待ってから後続の処理を実行することができます。これはVueがリアクティブな状態を変更したとき、その結果実行されるDOMの更新は同期的には処理されないためです。

後続のobserveLastItem()ではテンプレートのDOMを直接参照しているので、新しく取得したデータがDOMに追加された後に実行する必要があります。

テンプレートの要素を参照

useTemplateRef()を使用して、テンプレートの要素を参照しています。

App.vue
<script setup>
const list = useTemplateRef("ref-list");
</script>
<template>
<div class="l-inner">
<ul ref="ref-list" class="list">
</ul>
</div>
</template>

取得したいtemplate内の要素にref属性を設定し、設定した値をuseTemplateRefに引数として渡すことで要素を取得することができます。

Vueの3.5以降のバージョンで使用できる比較的新しい機能のようです。

トランジションを設定

スクロールして追加の投稿が表示される際にフェードアニメーションを設定しています。

App.vue
<template>
<TransitionGroup name="fade">
<li v-for="post in posts" :key="post.id" class="item">
<a :href="post.link" class="link" target="_blank" rel="noopener noreferrer">
<p>ID:{{ post.id }}</p>
<p>スラッグ:{{ post.slug }}</p>
<p>タイトル:{{ post.title.rendered }}</p>
<p v-html="post.excerpt.rendered"></p>
</a>
</li>
</TransitionGroup>
</template>
<style scoped>
.fade-enter-from {
opacity: 0;
}
.fade-enter-active {
transition: opacity 0.75s ease;
}
.fade-enter-to {
opacity: 1;
}
</style>

v-forでの追加の要素のレンダリング時にアニメーションを実行するために、TransitionGroupを使用しています。nameはfadeで設定し、opacityがゆっくり変化するようにしています。

このTransitionGroupTransitionコンポーネントがVueではとても便利だなと感じます。同じことをバニラJSでしようとすると、setTimeoutなどを使用する必要がありどうしてもコードが長くなってしまいがちです。

データの取得状況を監視

スクロール時に追加のデータが表示されるまでローディングを表示しています。

App.vue
<script setup>
const isLoading = ref(false);
const fetchPosts = async () => {
isLoading.value = true;
// 以下省略
}
</script>
<template>
<div v-if="isLoading">
<span class="loader"></span>
</div>
</template>

リアクティブなデータであるisLoadingをデータ取得時にはtrueにすることにより、ローダーを条件付きレンダリングしています。

まとめ

今回はVue.jsで無限スクロールを実装する方法についてアウトプットしました。

Vueで無限スクロールを実装する方法は調べてみると他にも色々あるようで、Vuetifyのv-intersectという機能を使用する方法もあるようです。

今回の実装を通じて非同期処理や、パラメーターを使用したデータの取得方法、トランジションなどの理解を深めることができました。Vueの理解度も0.5くらい上がった気がします。

参考サイト