2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

vue-infinite-loadingをTypeScriptで使うQuickHack、あるいはinstanceofが使えないクラスの型の保証について

Posted at

よく見かける無限スクロール。使う用事があったので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

はて?

型定義を確認すると

index.d.ts
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サポートが甘いと起こり得る問題のような気がします。似たようなハマり方をした際は参考としてどうぞ。

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?