よく見かける無限スクロール。使う用事があったのでVueで簡単に実装できる方法を調べてみました。vue-infinite-loading
というのがポピュラーなようです。
$ npm install vue-infinite-loading
環境
- Vue 2.6.11
- Vuetify 2.3.18
- TypeScript 4.0.5
コード
前提
サーバーサイドにはページング機能付きで楽曲情報(Song)を返すAPIがある
ページサイズ16で下方向無限スクロールを実装する
セレクトボックスの値が変更されると、それまでに読み込んだデータは破棄して再度1ページ目から表示開始
エラー処理は省略
<template>
<v-container>
<v-row>
<v-vol>
<v-select :items="selectItems" v-model="selectedGroup" @change="onChangeGroup"/>
</v-vol>
</v-row>
<v-row>
<v-col v-for="item in songList" key="item.id>
<v-img :src="item.coverImage"/>
</v-col>
<InfiniteLoading ref="infiniteLoading" @infinite="onEndOfPage"/>
<v-row>
</v-container>
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import axios, { AxiosResponse, AxiosError } from 'axios';
import InfiniteLoading, { StateChanger } from 'vue-infinite-loading';
interface Song {
title: string;
artist: string;
}
@Component({
components: {
InfiniteLoading
}
})
export default class InfiniteLoadingTest extends Vue {
songList: string[] = [];
page = 0;
gotAllData = false;
selectItems: string[] = ['μ\'s', 'Aqours', '虹ヶ咲', 'Liella!'];
selectedGroup = '';
get pageSize() {
return 16;
}
async mounted() {
const params = {
offset: 0,
limit: this.pageSize
};
const songList: AxiosResponse<Song[]> = await axios.get<Song[]>('https://example.com/songs', {params: params});
this.songList = res.data;
let loading: InfiniteLoading | null = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const isInfiniteLoading = (x: any): x is InfiniteLoading => (x !== null && typeof x === 'object' && typeof x.distance === 'number');
if (isInfiniteLoading(this.$refs.infiniteLoading)) {
loading = this.$refs.infiniteLoading;
}
if (!loading) {
throw 'にゃーん';
}
if (res.data.length < this.pageSize) {
this.gotAllData = true;
loading.stateChanger.complete();
} else {
loading.stateChanger.loaded();
}
}
async onEndOfPage($state: StateChanger): void {
if (this.gotAllData) {
return;
}
this.page++;
const params = {
offset: this.page * this.pageSize,
limit: this.pageSize
};
const songList: AxiosResponse<Song[]> = await axios.get<Song[]>('https://example.com/songs', {params: params});
this.songList = this.songList.concat(res.data);
if (res.data.length < this.pageSize) {
this.gotAllData = true;
$state.complete();
} else {
$state.loaded();
}
}
async onChangeGroup() {
let loading: InfiniteLoading | null = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const isInfiniteLoading = (x: any): x is InfiniteLoading => (x !== null && typeof x === 'object' && typeof x.distance === 'number');
if (isInfiniteLoading(this.$refs.infiniteLoading)) {
loading = this.$refs.infiniteLoading;
}
if (!loading) {
throw 'にゃーん';
}
loading.stateChanger.reset();
const params = {
offset: 0,
limit: this.pageSize,
group: this.selectedGroup
};
const songList: AxiosResponse<Song[]> = await axios.get<Song[]>('https://example.com/songs', {params: params});
this.songList = songList.data;
this.gotAlldata = songList.data.length < this.pageSize;
if (this.gotAllData) {
loading.stateChanger.complete();
} else {
loading.stateChanger.loaded();
}
}
}
</script>
全部の説明書き始めると長くなりすぎるので悩んだところだけ説明します。
this.$refs
を通して要素に直接アクセスするのはTypeScriptだと鬼門だと思ってるんですが、今回もInfinoteLoadingの状態管理のために要素アクセスが必要で、それも普段使っている手が通じなかったので厄介でした。
よくやるのはいろいろな型が入っている可能性があるオブジェクトに対して
const isVue = (x: unknown): x is Vue => x instanceof Vue;
isVue(someObjectLikeVue);
のようにしてタイプガードで型を判定・保証するものですが、今回InfiniteLoading
であることを確認しようとして同様に実装すると
x instanceof InfiniteLoading;
=> TypeError: Right-hand side of 'instanceof' is not callable
はて?
型定義を確認すると
export default class InfiniteLoading extends Vue {
// The trigger distance
distance: number;
// The load spinner type
spinner: SpinnerType;
// The scroll direction
direction: DirectionType;
// Whether find the element which has `infinite-wrapper` attribute as the scroll wrapper
forceUseInfiniteWrapper: boolean | string;
// Infinite event handler
onInfinite: ($state: StateChanger) => void;
// The method collection used to change infinite state
stateChanger: StateChanger;
// Slots
$slots: Slots;
static install: PluginFunction<InfiniteOptions>;
}
ごく普通のクラスに見えるけどこれがcallableではないということらしい。
JavaScriptで書くならinstanceof
の右辺はコンストラクタオブジェクトである必要がある。わかる。ただそれがTypeScriptだとどうなるのか、正直よくわかっていません。
で、かなり無理矢理回避しているのがコード内に何箇所かあるこの部分
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const isInfiniteLoading = (x: any): x is InfiniteLoading => (x !== null && typeof x === 'object' && typeof x.distance === 'number');
if (isInfiniteLoading(this.$refs.infiniteLoading)) {
loading = this.$refs.infiniteLoading;
}
TypeScriptではcatch
以外でany
使ったら負けですが、ここは本当にどうしようもないので許してください、が1行目。
あとはnullでなく、何らかのオブジェクトであり、number型のdistance
プロパティを持っていればまあ多分InfiniteLoading
だろう、ヨシ!
この方法が厳密に型安全かと言われるとそうではないんですが、ライブラリ側のTypeScriptサポートが甘いと起こり得る問題のような気がします。似たようなハマり方をした際は参考としてどうぞ。