しかたらむーの てっくぶろ

組み込みソフトの古参エンジニアがモダンなフロントエンドエンジニアに転生 その進化の記録です

Vue.js + Typescriptで作る『レスポンシブでスクローラブルなリスト』

~ 目次 ~

はじめに

比較的使う場面の多いリスト表示をVue+Typescriptでレスポンシブ&スクローラブルに作ってみたので記事にまとめておきます。よくありそうな名簿のリスト表示といった想定で、ID番号、名前、メールアドレスをリストで表示し、フィルタやソートができるレスポンシブなリストを作ります。Vueでの作り方の参考になれば幸いです。

出来上がりはこんな感じ

f:id:Shikataramuno:20181107093440p:plain

コードはここ

GitHub - Shikataramuno/responsive-scrollable-grid at ver1.0

仕様

簡単ですが、以下のような仕様を前提に作ってみます。
* リスト表示するのはメンバのid、名前、メールアドレスとします。
* リスト表示はスクロール可能とします。
* 任意の文字列でid、名前、メールアドレスをフィルタ出来るようにします。
* リストはid、名前、メールアドレスの列ごとに昇順/降順でソートできるようにします。
* レスポンシブ対応とします。

開発環境

使用したツールのバージョンです。

> node --version
v8.9.4
> npm --version
5.6.0
> vue --version
3.1.1

プロジェクトの作成

それではプロジェクトの作成から始めましょう。最初にVue CLIを使ってプロジェクトのひな型を作ります。プロジェクト名をresponsive-scrollable-gridにしていますが、お好みの名前で問題ありません。

>vue create responsive-scrollable-grid
Vue CLI v3.1.1
? Please pick a preset: Manually select features
? Check the features needed for your project:
 () Babel
 () TypeScript
 () Progressive Web App (PWA) Support
 ( ) Router
 ( ) Vuex
>() CSS Pre-processors
 (*) Linter / Formatter
 ( ) Unit Testing
 ( ) E2E Testing

上記の設定でenterを入力すると細かな設定を聞いてきます(以下を参照)。最後から二つ目のConfigの設定ファイルを指定するところだけ package.json を指定します。その他はデフォルトでOKです。

Vue CLI v3.1.1
? Please pick a preset: Manually select features
? Check the features needed for your project: Babel, TS, PWA, CSS Pre-processors, Linter
? Use class-style component syntax? Yes
? Use Babel alongside TypeScript for auto-detected polyfills? Yes
? Pick a CSS pre-processor (PostCSS, Autoprefixer and CSS Modules are supported by default): Sass/SCSS
? Pick a linter / formatter config: TSLint
? Pick additional lint features: Lint on save
? Where do you prefer placing config for Babel, PostCSS, ESLint, etc.? In package.json
? Save this as a preset for future projects? No
後はプロジェクトの作成が始まり、
・・・・
📄  Generating README.md...
🎉  Successfully created project responsive-scrollable-grid.
👉  Get started with the following commands:
 $ cd responsive-scrollable-grid
 $ npm run serve
>
となればプロジェクトの作成は完了でひな型はできあがりです。

クラス設計

一応、上述の仕様を実現するため次のように設計しました。

  • 個々のメンバをMemberクラスでモデリングします。

  • リスト表示のUIをResponsiveScrollableGridコンポーネントに実装します。リスト表示するデータはコンポーネントが生成されるタイミングでモックを作成するようにします。実際の開発では、このあたりのデータはリモートにあるサーバーから取得する場合がほとんどと思いますが、UIの実装例を示すのがこのサンプルの目的なので疑似的にデータを生成します。

  • 項目(id、name、address)毎にソート順を管理するので、項目ごとのソート順をSortOrderクラスで保持するようにします。

簡単ですが、クラス図で書くとこんなかんじですかね… f:id:Shikataramuno:20181107194613p:plain

HTML(構造)の設計

同様に、上述の出来上がりUIを実現するHTMLの構造を次のように設計しました。

  • レスポンシブ対応
    リストのヘッダ部はPC用とMobile用にそれぞれ定義し、CSSのメディアクエリでdisplay属性を切り替えるようにします。リストのデータ部は基本、共通としますがMobile版では各行に項目のタイトルをつけるので、この部分だけヘッダ部と同様にCSSでdisplay属性を切り替えるようにします。

  • リストのタグ
    表形式のUIを構築するのでリストタグ(ol,ul,li,...)やテーブルタグ(Table,tr,td,th,...)を使うことも考えましたが、このサンプルでは中身のレイアウトや表現の自由度を考慮しdivタグで構築していくことにします。

  • 繰り返しの構造
    表形式には列方向の繰り返しと行方向の繰り返しがあります(当然ですね)。 リストのヘッダ部はタイトル(id,name,address )ごとの列方向を v-for ディレクティブで繰り返し div タグを定義していきます。
    リストのデータ部は、件数(個々のメンバ)ごとの行方向と、タイトルごとの列方向を、それぞれ v-for ディレクティブで繰り返し div タグを定義していく構造にします。

ファイル構成

では、実装に取り掛かります。まずは、プロジェクト作成時のファイル構成を変更からです。
プロジェクト作成時のフォルダ、ファイル構成は次のとおりです。

RESPONSIVE-SCROLLABLE-GRID\SRC
│  App.vue
│  main.ts
│  registerServiceWorker.ts
│  shims-tsx.d.ts
│  shims-vue.d.ts
│
├─assets
│      logo.png
│
└─components
        HelloWorld.vue    <---- これを削除します。
デフォルトで作成される src/components/HelloWorld.vue を削除し、クラス設計で定義したクラス用のファイルを作成していきます。ResponsiveScrollableGrid.vue と src/models 以下に Member クラスと StorOrders クラスのファイルを作成します。
RESPONSIVE-SCROLLABLE-GRID\SRC
│  App.vue
│  main.ts
│  registerServiceWorker.ts
│  shims-tsx.d.ts
│  shims-vue.d.ts
│
├─assets
│      logo.png
│
├─components
│      ResponsiveScrollableGrid.vue <--- これを追加します。
│
└─models                             <--- これを追加します。
        Member.ts                    <--- Memberクラス
        SortOrders.ts                <--- SortOrerdクラス
次にそれぞれのファイルについて簡単にポイントを説明します。

Member クラス

個々のMemberを表すクラスです。 ID番号、名前、メアドをプロパティとして持ち、任意文字列のフィルタ用に isIncluded(str: string): boolean メソッドを、列指定のソート用に列のタイトルをキーに値を連想配列で取得する getValue(key: string): string メソッド*1を提供します(細かな点はコードで確認してください)。

SrotOrder クラス

列ごとのソートオーダー(昇順、降順)を保持するクラスです。 列ごとのオーダーをプロパティとして持ち、列ごとのオーダー(昇順/降順)切り替えに selectedKey(key: string): void メソッドを、ソートオーダーの取得用に列のタイトルをキーに値を連想配列で取得する getOrder(ket: string): number*2 メソッドを提供します(細かな点はコードで確認してください)。

ResponsiveScrollableGrid コンポーネント

ようやく本体のコンポーネントです。
html、Script、Styleのそれぞれのポイントについて解説します。

■ htmlのポイント

まずはPC版のリストヘッダの構造です。

    <div class="pc table-row header">
      <b-row>
        <label class="title" >メンバ一覧 </label>
      </b-row>
      <b-row class='query-box'>
        <b-col cols="2">
          <form id="search">
            <input name="query" class="filter" v-model="searchQuery" placeholder="フィルタ文字列">
          </form>
        </b-col>
        <b-col cols="10">
        </b-col>
      </b-row>
      <div class="wrapper attributes header">
        <div v-for="(val, idx) in columns" v-bind:key=idx @click="sortBy(val)" :class="[{ active: sortKey === val }, val]">
          {{ val }}
          <span class="arrow" :class="sortOrders[val] > 0 ? 'asc' : 'dsc'"></span>
        </div>
      </div>
    </div>

  • 8行目で任意文字列フィルタの入力フォームを配置し v-model ディレクティブで オブジェクトプロパティ searchQuery にバインドしています。

  • 15~18行目で列のタイトルを配置しています。15行目のv-forディレクティブでオブジェクトプロパティ columns 配列の長さ分、列タイトルを登録していきます。ここで idx は配列の要素番号、val は配列に格納された値(列のタイトル名称)が入ります。PC版では列のタイトルをクリックするとその列をキーに行を並べ替えるため v-on ディレクティブを使い click イベントで sortBy メソッドを呼び出す様に定義しています。

  • 17行目は列タイトルをクリックした際のソートオーダーを▲▼で表示するための属性を三項演算式を使って定義しています。

次はリストヘッダのレスポンシブ対応のMobile表示部分です。

// 25:
    <div class="mobile">
      <b-container class="table-row header">
        <b-row>
          <label class="title" >メンバ一覧 </label>
        </b-row>
        <b-row>
          <b-col cols="4">
            <input name="query" class="filter" v-model="searchQuery" placeholder="フィルタ文字列">
          </b-col>
          <b-col cols="4">
            <b-dropdown id="ddown-buttons" split right variant="success" size="sm" class="sorter">
              <template slot="button-content">
                {{sortKey}}
                <span class="arrow" :class="sortOrders[sortKey] > 0 ? 'asc' : 'dsc'"></span>
              </template>
              <b-dropdown-item v-for="(val, idx) in columns" v-bind:key=idx @click="sortBy(val)" :class="[{ active: sortKey == val }, { focus: sortKey == val }]">
                {{ val }}
              </b-dropdown-item>
            </b-dropdown>
          </b-col>
        </b-row>
      </b-container>
    </div>

  • 32行目で任意文字列フィルタの入力フォームを配置し searchQuery にバインドしています。

  • 35~43行目で列のタイトル名とクリック時のメソッド呼び出し、ソートオーダーの表示切替を行っています。モバイルでは表示エリアが限られるためドロップダウン形式で列タイトルを表示するようにしています。
    htmlのタグはPC版とは異なりますが v-ディレクティブの使い方や呼び出すメソッド、条件などは同じところがポイントです。

そして、両者共通のリストデータ部分です。

// 49:
    <div class="data-field">
      <div v-for="(entry,idx) in members" v-bind:key=idx @click="edit(entry.id)">
        <div class="table-row data">
          <div class="wrapper attributes data">
            <div v-for="(val, idx) in columns" v-bind:key=idx :class="[val]">
              <span class='mobile-title'>{{val}}:</span>
              <span>
                {{entry[val]}}
              </span>
            </div>
          </div>
        </div>
      </div>
    </div>

  • 50~60行目までが、行方向の繰り返しになります。オブジェクトプロパティ members 算出プロパティで得られるメンバ配列の長さ分 行方向にメンバを登録していきます。ここで entry は 一つの Memberオブジェクトを指し示し、Memberのプロパティに直接アクセスできる(Member.id を entry.id の様に記述できる)のがポイントです。このおかげで様々な条件付けができるようになります。

  • 53~57行目までが メンバ1件分(すなわち1行分)の構造になります。ヘッダと同様に columns 配列の長さ分繰り返し、メンバのプロパティを登録していきます。54行目で各行に列のタイトルを表示しています。

■ Scriptのポイント

members の算出プロパティがポイントです。

// 86:
  get members(): Member[] {
    let ret = this.memberList;
    const filterKey = this.searchQuery && this.searchQuery.toLowerCase();
    if (filterKey) {
      ret = ret.filter((row) => {
        return row.isIncluded(filterKey);
      });
    }
    const order = this.sortOrders.getOrder(this.sortKey) || -1;
    ret = ret.slice().sort((a: Member, b: Member) => {
      const aVal: string = a.getValue(this.sortKey);
      const bVal: string = b.getValue(this.sortKey);
      return (aVal === bVal ? 0 : aVal > bVal ? 1 : -1) * order;
    });
    return ret;
  }
このアプリの場合、疑似的にメンバのリストを作っているためメンバの配列(memberList)は変化しません。一方でフィルタやソートなどユーザー操作により表示される内容を動的に変えなければなりません。こうした場合、算出プロパティが有効です。フィルタ文字列やソートキー、ソートオーダーが変化した場合、算出プロパティを通して表示用の配列が更新されます。その処理を実装しているのが get members(): Member[] メソッドになります。

  • Vue CLI 3 で Typescriptを使ってコードを書く場合 、従来の算出プロパティ computed は get/set アクセッサとして定義します。

  • 91行目でフィルタ文字列をパラメタにMemberクラスのisIncludedメソッドを呼び出しています。また、96、97行目ではMemberクラスのgetValueメソッドを呼び出して列タイトルをキー文字列として値を取得、大小を比較し順序を計算しています。

  • 94行目ではSortOrderクラスのgetOrderを呼び出して列タイトルのソートオーダーを取得しています。

■ Styleのポイント 以下は Style タグからの抜粋になります。

.pc {
  display: block;
}
.mobile {
  display: none;
}
.mobile-title {
  display: none;
}
・・・・
@media screen and (max-device-width: 768px),screen and (max-width: 768px)
{
  .pc {
    display: none;
  }
  .mobile {
    display: inline;
  }
  .mobile-title {
    display: inline;
  }
}
・・・・
レスポンシブ対応とする為、メディアクエリを条件に切替る対象のクラスに対して display 属性 を切り替えて表示させている点がポイントでしょうか…
実はもっとエレガントな方法がありそうに思いますが、泥臭いですが、今回はこの実装としました。

Appコンポーネント

最後はアプリケーションのルートコンポーネントであるAppの変更です。 プロジェクト作成時のデフォルトの App は HelloWorld コンポーネントを前提にしていますので、この部分を ResponsiveScrollableGrid コンポーネントに置き換えます。 具体的には以下に示す様に HelloWorld の部分を ResponsiveScrollableGrid に置き換えます。

<template>
  <div id="app">
    <img alt="Vue logo" src="./assets/logo.png">
    <HelloWorld msg="Welcome to Your Vue.js + TypeScript App"/> <--- この行を削除する
    <ResponsiveScrollableGrid/>                                 <--- この行を追加する
  </div>
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import HelloWorld from './components/HelloWorld.vue'; <--- HellowWorld を
                                     ResponsiveScrollableGrid に
                                     書き換える
@Component({
  components: {
    HelloWorld,   <--- HellowWorld を ResponsiveScrollableGrid に書き換える
  },
})
export default class App extends Vue {}
</script>

出来上がり!

さぁビルドしてみましょう。

>npm run serve
正常に終了したらlocalhost:8080にアクセスしてみてください。エラーがなければ出来上がりはこんな感じと同じUIが表示されるはずです。

次への展開

長々と書きましたが最後まで読んでくださり、ありがとうございました。少しでも何かお役に立てたなら幸いです。
次はこれをベースにSVGアイコンやモーダルダイアログ、Http通信などを加えてより本格的なアプリに近づけてみたいと思います。

*1:this as anyで型指定なしで値を返していますが、これだとTypescriptの静的型チェックの本来のメリットが活かせません。一方で指定キーを文字列で判定するとコードが冗長化してしまいます。この辺りの実装方式は今後の課題と考えてます

*2:Memer.getValueメソッドと実装方式は同じく今後の課題です。