LoginSignup
28
31

More than 3 years have passed since last update.

【Svelte + TypeScript + tailwindcss】本検索サイト チュートリアル

Last updated at Posted at 2021-02-07

はじめに

この記事でははSvelte + TypeScript + tailwindcssで本検索サイトを作成します。

成果物は以下のようなアプリケーションです。

本の検索ページ

svelte-book.gif

本の詳細ページ

スクリーンショット 2021-02-07 19.11.10.png

Svelteを使ってアプリケーションを作成1から作成することができます。
以下のことが学べます。

  • Svelteの基礎文法
  • Svelteのルーティング
  • Svelteのストア

HTML・CSS・JavaScriptの基礎的な理解がある人が対象です。

また、完成したソースコードはこちらから参照できます。

0 Node.jsのインストール

環境構築にはNode.jsのインストールが必要です。
もしNode.jsがインストールされていない場合には、こちらからインストールを済ませていおいてください。

Svelteプロジェクトの準備

まずはSvelteプロジェクトを作成しましょう。
本書ではCSSフレームワークとしてtailwingcssを利用しますが、はじめから組み込まれているテンプレートが存在するのでこちらを利用します。

npx degit sarioglu/svelte-tailwindcss-template svelte-book-review-app
cd svelte-book-review-app

TypeScriptに変換します。

node scripts/setupTypeScript.js

依存関係をインストールします。

npm install

2021/02/06の時点でこのままですとエラーになる箇所が存在するので修正を行います。

rollup.config.jsの以下の部分を削除してください。。

import svelte from 'rollup-plugin-svelte';
import commonjs from '@rollup/plugin-commonjs';
import resolve from '@rollup/plugin-node-resolve';
import livereload from 'rollup-plugin-livereload';
import { terser } from 'rollup-plugin-terser';
import sveltePreprocess from 'svelte-preprocess';
import typescript from '@rollup/plugin-typescript';
import css from 'rollup-plugin-css-only';
- import sveltePreprocess from 'svelte-preprocess';

const production = !process.env.ROLLUP_WATCH;

function serve() {
    let server;

    function toExit() {
        if (server) server.kill(0);
    }

    return {
        writeBundle() {
            if (server) return;
            server = require('child_process').spawn('npm', ['run', 'start', '--', '--dev'], {
                stdio: ['ignore', 'inherit', 'inherit'],
                shell: true
            });

            process.on('SIGTERM', toExit);
            process.on('exit', toExit);
        }
    };
}

export default {
    input: 'src/main.ts',
    output: {
        sourcemap: true,
        format: 'iife',
        name: 'app',
        file: 'public/build/bundle.js'
    },
    plugins: [
        svelte({
            preprocess: sveltePreprocess({ postcss: true }),
-           preprocess: sveltePreprocess(),
            compilerOptions: {
                // enable run-time checks when not in production
                dev: !production
            }
        }),
        // we'll extract any component CSS out into
        // a separate file - better for performance
        css({ output: 'bundle.css' }),

        // If you have external dependencies installed from
        // npm, you'll most likely need these plugins. In
        // some cases you'll need additional configuration -
        // consult the documentation for details:
        // https://github.com/rollup/plugins/tree/master/packages/commonjs
        resolve({
            browser: true,
            dedupe: ['svelte']
        }),
        commonjs(),
        typescript({
            sourceMap: !production,
            inlineSources: !production
        }),

        // In dev mode, call `npm run start` once
        // the bundle has been generated
        !production && serve(),

        // Watch the `public` directory and refresh the
        // browser on changes when not in production
        !production && livereload('public'),

        // If we're building for production (npm run build
        // instead of npm run dev), minify
        production && terser()
    ],
    watch: {
        clearScreen: false
    }
};

ここまで完了したら、アプリケーションを起動してみましょう!

npm run dev

http://localhost:5000 をブラウザで開いてください!次の画面が表示されているはずです。
スクリーンショット 2021-02-06 14.29.31.png

おすすめのvscode拡張

Svelteの基礎の確認

最初に自動生成されたコードを確認してみましょう。
Svelteのコンポーネントは.svelteという拡張子が使われています。
App.svelteファイルを開きます。

App.svelte
<script lang="ts">
    import Tailwindcss from './Tailwindcss.svelte';

    export let name: string;
</script>

<Tailwindcss />
<main>
    <h1>Hello {name}!</h1>
    <p>Visit the <a href="https://svelte.dev/tutorial">Svelte tutorial</a> to learn how to build Svelte apps.</p>
</main>

<style>
    main {
        text-align: center;
        padding: 1em;
        max-width: 240px;
        margin: 0 auto;
    }

    h1 {
        color: #ff3e00;
        text-transform: uppercase;
        font-size: 4em;
        font-weight: 100;
    }

    @media (min-width: 640px) {
        main {
            max-width: none;
        }
    }
</style>

1つのファイルにHTML・JavaScript・CSSがまとまっており、Vue.jsの単一ファイルコンポーネントによく似ています。

<style>タグは、常にscopedとして扱われる - そのコンポーネント内のみに適応される - ことに注意してください。

ざっくりとした説明として、<script>タグ内で宣言された変数はテンプレート内で{}で囲って参照できます。このとき、変数の値は常にリアクティブであり、変数の値が変更されるたびにHTMLでの描画が更新されます。

ここで一つ奇妙なのが、たしかにnameという変数は宣言されているもののそこに値は代入されていないことです。なぜWORLDと表示されているのでしょうか?

実は、exportされている変数はpropsとして外から値を渡すことができます。実際にApp.svelteを生成しているmain.tsのコードを見てみましょう。

main.ts
import App from './App.svelte';

const app = new App({
    target: document.body,
    props: {
        name: 'world'
    }
});

export default app;

確かに、propsとしてname: 'world'が渡されています。main.ts<body>要素に対してApp.svelteをマウントするコードです。

ここまで理解したところで、試しにnameの値を変更してみましょう。
画面の表示も変更された値に切り替わるはずです。

main.ts
import App from './App.svelte';

const app = new App({
    target: document.body,
    props: {
        name: 'svelte'
    }
});

export default app;

スクリーンショット 2021-02-06 14.51.40.png

ヘッダーコンポーネントの作成

それでは、一番最初のコンポーネントを作成しましょう。
srcフォルダ配下にcomponentsディレクトリを作成してHeader.svelteファイルを作成します。

Header.svelte
<header class="top-0 lef-0 w-full z-40 bg-gray-900 shadow fixed border-b border-gray-200">
  <div class="container mx-auto px-6 h-16 flex justify-between items-center">
    <span class="font-semibold text-xl text-white tracking-tight">
      ブックレビュー App
    </span>
  </div>
</header>

App.svelteからヘッダーコンポーネントを利用します。
作成したsvelteファイルをimportしてHTMLタグのように利用します。

App.svelte
<script lang="ts">
  import Tailwindcss from './Tailwindcss.svelte';
  import Header from './components/Header.svelte'

    export let name: string;
</script>

<Tailwindcss />
<Header />
<main class="pt-16">
  <div class="container mx-auto px-6 my-4">
    <h1>Hello {name}!</h1>
    <p>Visit the <a href="https://svelte.dev/tutorial">Svelte tutorial</a> to learn how to build Svelte apps.</p>
  </div>
</main>

このとき、もともと存在していた<style>タグは不要なので消してしまいましょう。
public/global.cssも同様に削除します。

rm public/global.css

ここまでの変更で、次のような画面になっています。
スクリーンショット 2021-02-06 15.39.40.png

svelte-spa-routerのインストール

このアプリケーションでは複数ページを扱う予定ですので、Vue RouterやReact Routerのようなルーティングライブラリを導入します。

今回はsvelte-spa-routerを利用します。
https://github.com/ItalyPaleAle/svelte-spa-router

svelte-spa-routerは、ハッシュを用いてルーティングを管理します。
例えば/books/123へルーティングをさせたい場合にはhttp://localhost:5000/#/books/123 というURLにマッチすることになります。

下記コマンドでインストールしましょう。

npm install svelte-spa-router

ルーティングの定義の作成

どのコンポーネントをどのルーティングに結びつけるのかの対応を作成します。
srcフォルダ配下にrouter/index.tsを作成します。

router/index.ts
import SearchBook from '../pages/SearchBook.svelte'

export const routes = {
  '/': SearchBook,
}

/というパスにアクセスした場合に、SearchBookコンポーネントにマッチするように定義します。

/pages/SearchBook.svelteも作成しておきましょう。

pages/SearchBook.svelte
<div>
  本を探す
</div>

Router Viewの作成

ルートを定義したら、定義したルートを描画するようにしましょう。
App.svelteを修正します。

App.svelte
<script lang="ts">
  import Tailwindcss from './Tailwindcss.svelte';
  import Header from './components/Header.svelte'
  import Router from 'svelte-spa-router'
  import { routes } from './router'
</script>

<Tailwindcss />
<Header />
<main class="pt-16">
  <div class="container mx-auto px-6 my-4">
    <Router {routes} />
  </div>
</main>

Routerコンポーネントに、先程作成したルート定義をroutespropとして渡します。

Svelteでは、propsと渡す変数名が一致するとき省略記法を利用することができます。
つまり、次のように記述は同一です。

<Router routes={routes} />
<Router {routes} />

ここまで進めたら、SearchBookコンポーネントの内容が表示されているはずです。

スクリーンショット 2021-02-06 16.19.25.png

Book Repositoryの作成

それでは、機能を実装していきましょう。
まずは本の一覧を検索するページからです。

本のデータはGoogle Books APIを利用して取得します。
通常の利用ではAPI KEYなどは必要ありません。
https://developers.google.com/books

Web APIを利用するにあたって、本書ではRepositoryFactory パターンを採用します。

APIとの通信を行うAxiosのようなライブラリと後述するストアから直接利用すると以下のような問題が生じます。

  • 単体テストがやりずらい
  • モックに処理を置き換えづらい
  • ストアが肥大化する
  • 再利用しづらい
  • エンドポイントなどが変わったときに変更箇所が多くなる

そこで、APIとの通信をRepositoryによって抽象化することでこれらの問題の解決を図ります。
RepositoryFactory パターンについてはこちらの記事が詳しいです。

httpClientの作成

まずはAxiosをラップする単純なモジュールを作成します。
まずはAxiosをインストールします。

npm install axios

repositoriesフォルダを作成して、その中にhttpClient.tsファイルを作成します。
httpClient.tsではaxiosのbaseURLやheadersなどを設定して作成されたインスタンスを返します。

repositories/httpClient.ts
import axios from 'axios'

const httpClient = axios.create({
  baseURL: 'https://www.googleapis.com/books/v1/volumes'
})

export { httpClient }

Book Repositoryの作成

個別のモデルごとにRepositoryを作成していきます。
repositories/bookディレクトリを作成して、以下の3ファイルを作成します。

  • types.ts
  • BookRepository.ts
  • index.ts

types.ts

types.tsでは対応するモデルに関する型定義やRepositoryのインターフェースを提供します。
Repository自体のインターフェースを公開することで環境によってモックRepositoryに置き換える際にも実装の詳細を抽象化することができます。

repositories/book/types.ts
/**
 * Google Books APIのレスポンス
 */
export interface Result {
  items: BookItem[];
  kind: string;
  totalItems: number;
}

/**
 * 本の情報
 */
export interface BookItem {
  id: string;
  volumeInfo: {
    title: string;
    authors?: string[];
    publishedDate?: string;
    description?: string;
    publisher?: string;
    imageLinks?: {
      smallThumbnail: string;
      thumbnail: string;
    };
    pageCount: number;
    previewLink?: string;
  };
  saleInfo?: {
    listPrice: {
      amount: number;
    };
  };
}

/**
 * query parameters
 */
export interface Params {
  q: string;
  startIndex?: number;
}

export interface BookRepositoryInterface {
  get(params: Params): Promise<Result>;
  find(id: string): Promise<BookItem>;
}

BookRepository.ts

BookRepository.tsに実装の詳細を記述していきます。
BookRepositoryInterfaceを継承するようにします。

repositories/book/BookRepository.ts
import type { BookItem, BookRepositoryInterface, Params, Result } from './types'
import { httpClient } from '../httpClient'

export class BookRepository implements BookRepositoryInterface {
  async get(params: Params) {
    const { data } = await httpClient.get<Result>('/', { params })
    return data
  }

  async find(id: string) {
    const { data } = await httpClient.get<BookItem>(`/${id}`)
    return data
  }
}

index.ts

index.tsで作成したモジュールを一つにまとめてエクポートします。

repositories/book/index.ts
export * from './types'
export * from './BookRepository'

Repository Factoryの作成

作成したRepositoryはすべてRepository Factoryからインポートして利用するようにします。
repositoriesフォルダ配下にRepositoryFactory.tsを作成します。

repositories/RepositoryFactory.ts
import { BookRepository, BookRepositoryInterface } from './book'

export const BOOK = Symbol('book')

export interface Repositories {
  [BOOK]: BookRepositoryInterface;
}

export default {
  [BOOK]: new BookRepository()
} as Repositories

Book Repositoryを利用するときには、以下のように使います。

import RepositoryFactory { BOOK } from '../repositories/RepositoryFactory.ts'

const BookRepository = RepositoryFactory[BOOK]

const book = async BookRespotirosy.get(id)

Repository Factoryを介してRepositoryを生成することによって、環境によって実装を取り替えやすくなります。
例えば、test環境でモックデータを返すRepositoryを使用する場合には、次のように記述することができます。

repositories/RepositoryFactory.ts
import { BookRepository, BookRepositoryInterface, MockBookRepository } from './book'

export const BOOK = Symbol('book')

export interface Repositories {
  [BOOK]: BookRepositoryInterface;
}

const isMock = process.env.NODE_ENV === 'test'

export default {
  [BOOK]: isMock ? new MockBookRepository() new BookRepository()
} as Repositories

本記事ではMockRepositoryは作成しないので、元の状態ままで構いません。

SearchBar コンポーネント

少々回り道をしましたが、画面の実装を進めていきましょう。

SearchBarコンポーネントを作成します。

components/SearchBar.svelte
<div class="shadow flex">
  <input class="w-full rounded p-2" type="text" placeholder="Search...">
  <button class="bg-white w-auto flex justify-end items-center text-blue-500 p-2 hover:text-blue-400">
    <svg fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24" class="w-6 h-6">
      <path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
    </svg>
  </button>
</div>
pages/SearchBook.svelte
<script lang="ts">
  import SearchBar from '../components/SearchBar.svelte'
</script>

<form>
  <SearchBar />
</from>

スクリーンショット 2021-02-06 18.46.00.png

値のバインディング

見た目はこれでよさそうなので、親子間で値を受け渡しできるようにロジックを実装していきます。
まずはpropsとしてSearchBar コンポーネントが値を受け取れるようにしましょう。

propsはexportする変数として定義するのでした。

SearchBar.svelte
<script lang="ts">
+  export let value = '' // propsが渡されなかった時の初期値を定義できます。
</script>

<div class="shadow flex">
-  <input class="w-full rounded p-2" type="text" placeholder="Search...">
+  <input {value} class="w-full rounded p-2" type="text" placeholder="Search...">
  <button class="bg-white w-auto flex justify-end items-center text-blue-500 p-2 hover:text-blue-400">
    <svg fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24" class="w-6 h-6">
      <path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
    </svg>
  </button>
</div>

親コンポーネントから適当な値を渡してみましょう。

SearchBool.svelte
<script lang="ts">
  import SearchBar from '../components/SearchBar.svelte'

  let q = 'JavaScript'
</script>

<form>
  <SearchBar value={q} />
</form>

<div class="text-center mt-4">
  { q }
</div>

propsとして渡した値が初期値として表示されています。

スクリーンショット 2021-02-06 18.49.35.png

親から子に値を渡すことに成功したので、次は子コンポーネントで入力が行われたときに親に値を渡せるようにしましょう。

<input>の入力をバインドさせる - Vue.jsにおけるv-modelに相当するもの - にはbind:valueディレクティブを使用します。

SearchBar.svelte
<script lang="ts">
  export let value = ''
</script>

<div class="shadow flex">
-  <input {value} class="w-full rounded p-2" type="text" placeholder="Search...">
+  <input bind:value class="w-full rounded p-2" type="text" placeholder="Search...">
  <button class="bg-white w-auto flex justify-end items-center text-blue-500 p-2 hover:text-blue-400">
    <svg fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24" class="w-6 h-6">
      <path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
    </svg>
  </button>
</div>

bind:valuebind:value={value}のショートハンドです。

子コンポーネントで発生したイベントは親コンポーネントへフォワーディングさせることができます。
親コンポーネントでbind:valueイベントを受け取ります。

SearchBook.svelte
<script lang="ts">
  import SearchBar from '../components/SearchBar.svelte'

  let q = 'JavaScript'
</script>

<form>
-  <SearchBar value={q} />
+  <SearchBar bind:value={q} />
</form>

<div class="text-center mt-4">
  { q }
</div>

これで、入力した値をバインディングさせることができました。

svelte-binding.gif

本一覧を取得する

続いて入力した値に応じてWeb APIから本一覧を取得してみましょう。

まずはsubmitイベントを購読しましょう。

SearchBool.svelte
<script lang="ts">
  import SearchBar from '../components/SearchBar.svelte'

  let q = 'JavaScript'

+  const handleSubmit = () => {
+    console.log('handleSubmit')
+    console.log(q)
+  }
</script>

- <form>
+ <form on:submit|preventDefault={handleSubmit}>
  <SearchBar bind:value={q} />
</form>

<div class="text-center mt-4">
  { q }
</div>

DOMイベントはon:ディレクティブで受け取ります。
さらに、on:ディレクティブにパイプ|でイベント修飾子を付け加えることができます。

今回のようにon:submit|preventDefaultとpreventDefault修飾子を付け加えるとevent.preventDefault()を実行する前に呼び出します。

それでは、BookRepositoryから本一覧を取得しましょう。
以下のような実装になります。

SearchBook.svelte
<script lang="ts">
  import SearchBar from '../components/SearchBar.svelte'
  import type { BookItem, Result } from '../repositories/book';
  import RepositoryFactory, { BOOK } from '../repositories/RepositoryFactory'
  const BookRepository = RepositoryFactory[BOOK]

  let q = ''
  let empty = false
  let books: BookItem[] = []
  let promise: Promise<void>

  const handleSubmit = () => {
    if (!q.trim()) return
    promise = getBooks()
  }

  const getBooks = async () => {
    books = []
    empty = false
    const result = await BookRepository.get({ q })
    empty = result.totalItems === 0
    books = result.items
  }
</script>

<form on:submit|preventDefault={handleSubmit}>
  <SearchBar bind:value={q} />
</form>

<div class="text-center mt-4">
  {#if empty}
    <div>検索結果が見つかりませんでした。</div>
  {:else}
    {#each books as book (book.id)}
      <div>{book.volumeInfo.title}</div>
    {/each}
  {/if}
  {#await promise}
    <div>loading...</div>
    {:catch e}
      <span class="text-red-600 text-sm">
        {e.message}
      </span>
  {/await}
</div>

いくつか興味深い構文が出現しています。
一つづつ確認してみましょう。

{#if ...}

ifブロックは分岐処理を提供します。
{#if condition}で始まり、{/if}で終了します。
conditionの値がtrueを返す場合のみブロックの内容を描画します。

ifブロックの間に{:else}{:else if}を挿入することができます。

{#each ...}

eachブロックは繰り返し処理を提供します。
一番基本的な文法は以下の通りです。

{#each expression as name} {/each}

expressionに渡せるのはarray likeな変数、つまりlengthプロパティを持つオブジェクトに限られます。

配列のインデックスを取得することができます。

{#each expression as name, index} {/each}

さらに、ループの中で一意の値となるキーを指定することができます。
キーが提供されている場合には配列の要素が変更されたときにそれを最後に追加・削除するのではなく、リストを比較するために使用されます。

{#each expression as name, index, (key)} {/each}

さらに、eachブロックは{:else}を挿入することができます。
{:else}は渡された配列の要素が空の場合に描画されます。

{#each todos as todo}
  <p>{todo.text}</p>
{:else}
  <p>No tasks today!</p>
{/each}

{#await ...}

awaitブロックは、Promiseの3つの状態(pending・fulfilled・rejected)によって分岐されます。

{#await expression} {:then name} {:catch error}

expressionにはPromise要素を渡す必要があります。

awaitブロックを利用すると、loading・errorのような状態変数を持つ必要がなくなるので便利です。

実際に検索結果を取得できるかどうか試してみてください。

search-book.gif

コンポーネントに切り出す

Spinner.svelte

ローディングアニメーションをSpinnerコンポーネントとして作成します。

components/Spinner.svelte
<svg class="animate-spin h-20 w-20" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>

いい感じに中央寄せしておきましょう。

pages/SearchBooks.svelte
<script lang="ts">
  // 省略
</script>

<form on:submit|preventDefault={handleSubmit}>
  <SearchBar bind:value={q} />
</form>

<div class="text-center mt-4">
  {#if empty}
    <div>検索結果が見つかりませんでした。</div>
  {:else}
    {#each books as book (book.id)}
      <div>{book.volumeInfo.title}</div>
    {/each}
  {/if}
  {#await promise}
    <div class="flex justify-center">
      <Spinner />
    </div>
    {:catch e}
      <span class="text-red-600 text-sm">
        {e.message}
      </span>
  {/await}
</div>

BookCardコンポーネント

次にBookCardコンポーネントを作成します。
componentsフォルダにBookCard.svelteファイルを作成します。

components/BookCard.svelte
<script lang="ts">
  import type { BookItem } from '../repositories/book'
  export let book: BookItem

  $: src = book.volumeInfo.imageLinks 
    ? book.volumeInfo.imageLinks.smallThumbnail
    : 'http://placehold.jp/eeeeee/cccccc/160x120.png?text=No%20Image'

  $: description = book.volumeInfo.description
    ? `${book.volumeInfo.description.slice(0, 100)}...`
    : ''

</script>

<div class="w-full sm:flex">
  <div class="h-96 sm:h-auto sm:w-48 flex-none bg-cover rounded-t sm:rounded-t-none sm:rounded-l text-center overflow-hidden" style={`background-image: url('${src}')`}>
  </div>
  <div class="border-r border-b border-l border-grey-light sm:border-l-0 sm:border-t sm:border-grey-light bg-white rounded-b sm:rounded-b-none sm:rounded-r p-4 flex flex-col justify-between leading-normal w-100 sm:w-9/12 lg:w-7/12">
    <div class="my-4">
      <div class="text-black font-bold text-xl mb-2">{book.volumeInfo.title}</div>
      <p class="text-grey-darker text-sm break-words w-9/12 m-auto">
        {description}
      </p>
    </div>
  </div>
</div>

本の情報を表示するのでbookという変数でpropsを親から受け取ります。

さて、$:という見慣れない構文が出現しました。
これはラベルと呼ばれる構文で$:ラベルが付与された式や文は、変数の更新のたびに再計算されるようになります。

Vue.jsにおけるcomuptedwatchなどの役割に近い処理をはたしてくれます。

このコンポーネントをループでしようするように修正しましょう。

pages/SearchBook.svelte
<script lang="ts">
  // 省略
</script>

<form on:submit|preventDefault={handleSubmit}>
  <SearchBar bind:value={q} />
</form>

<div class="text-center mt-4">
  {#if empty}
    <div>検索結果が見つかりませんでした。</div>
  {:else}
  <div class="grid grid-cols-1 gap-2 lg:grid-cols-2">
    {#each books as book (book.id)}
      <BookCard {book} />
      {/each}
  </div>
  {/if}
  {#await promise}
-    <div>{book.volumeInfo.title}</div>
+    <div class="flex justify-center">
+      <Spinner />
*    </div>
    {:catch e}
      <span class="text-red-600 text-sm">
        {e.message}
      </span>
  {/await}
</div>

ここまでの変更で次のような画面になりました!

スクリーンショット 2021-02-07 13.58.36.png

ここまでに実装でどうやら正しく検索処理が働き描画できているようです!

しかしながらすでにお気づきかもしれないですが、取得結果がいくつであろうと得られるのは最初の10件だけです。
追加のデータをさらに取得できるようにページネーション処理を実装しましょう。

ライブラリのインストール

無限スクロールによるページネーションを実装するために、こちらのライブラリをインストールします。

npm i svelte-infinite-scroll

次のように使います。
<InfinteScroll>要素が検出された際にloadMoreイベントが発火します。

pages/SearchBook.svelte
<script lang="ts">
  import SearchBar from '../components/SearchBar.svelte'
  import Spinner from '../components/Spinner.svelte'
  import BookCard from '../components/BookCard.svelte'
  import type { BookItem } from '../repositories/book';
  import RepositoryFactory, { BOOK } from '../repositories/RepositoryFactory'
+  import InfiniteScroll from "svelte-infinite-scroll"
  const BookRepository = RepositoryFactory[BOOK]

  let q = ''
  let empty = false
  let books: BookItem[] = []
  let promise: Promise<void>

  const handleSubmit = () => {
    if (!q.trim()) return
    promise = getBooks()
  }

  const getBooks = async () => {
    books = []
    empty = false
    const result = await BookRepository.get({ q })
    empty = result.totalItems === 0
    books = result.items
  }

+  const handleLoadMore = () => {
+    console.log('handleLoadMore')
+  }
</script>

<form on:submit|preventDefault={handleSubmit}>
  <SearchBar bind:value={q} />
</form>
<div class="text-center mt-4">
  {#if empty}
    <div>検索結果が見つかりませんでした。</div>
  {:else}
  <div class="grid grid-cols-1 gap-2 lg:grid-cols-2">
    {#each books as book (book.id)}
      <BookCard {book} />
    {/each}
  </div>
+  <InfiniteScroll window on:loadMore={handleLoadMore} />
  {/if}
  {#await promise}
    <div class="flex justify-center">
      <Spinner />
    </div>
    {:catch e}
      <span class="text-red-600 text-sm">
        {e.message}
      </span>
  {/await}
</div>

次の要素を取得して配列に追加する

handleLoadMore関数が呼び出されることが確認できたら、処理を実装していきましょう。

Google Books APIのドキュメントを見ると、ページネーションをするにはstartIndexをパラメータに渡せば良さそうです。

Pagination
You can paginate the volumes list by specifying two values in the parameters for the request:

startIndex - The position in the collection at which to start. The index of the first item is 0.
maxResults - The maximum number of results to return. The default is 10, and the maximum allowable value is 40.

startIndexは0から開始するようです。
startIndex変数を定義して初期値には0を設定しておきましょう。

let startIndex = 0

handleLoadMore関数が呼ばれる度に、startIndexの値をmaxResults(ここでは10固定)を加算していけば良さそうですね。

また、getNextPage関数を作成してそこでAPIから取得する処理を実装し、初回取得時と同じように返り値をpromise変数に代入してます。

Svelteでは配列の値をリアクティブにするには必ず変数を直接置き換えなければならないことに注意してください。
つまり、リアクティブな配列に対してpush()・splice()`などで操作しても自動更新されません。

  const handleLoadMore = () => {
    startIndex += 10
    promise = getNextPage()
  }

  const getNextPage = async () => {
    const result = await BookRepository.get({ q, startIndex })

    // 取得データが既に存在するものを含む可能性があるので、idでフィルタリングしてます。
    const bookIds = books.map(book => book.id)
    const filteredItems = result.items.filter(item => {
      return !bookIds.includes(item.id)
    })
    books = [...books, ...filteredItems]
  }

フォームのsubmitによる初回取得時にはstartIndexの値を0に戻さなければいけないことを忘れないようにしてください。

  const getBooks = async () => {
    books = []
    empty = false
+    startIndex = 0
    const result = await BookRepository.get({ q })
    empty = result.totalItems === 0
    books = result.items
  }

これ以上データが存在するかどうかhasMore変数を$:ラベルで定義します。
totalItems変数を定義しておき、現在の取得数がtotalItems以上なら、これ以上はデータが存在しないということにします。

+  let totalItems = 0

+  $: hasMore = totalItems > books.length

  const getBooks = async () => {
    books = []
    empty = false
    startIndex = 0
    const result = await BookRepository.get({ q, startIndex })
    empty = result.totalItems === 0
+    totalItems = result.totalItems
    books = result.items
  }

  // 省略

  // <InfiniteScroll>にhasMoreをpropsとして渡す
  <InfiniteScroll window threshold={100} on:loadMore={handleLoadMore} {hasMore} />

それでは確認してみましょう!

infinte-scroll.gif

一旦本を探す画面はこれでよさそうです。

今度はコンポーネント内で扱っていた状態管理をストアで行うように修正しましょう。

Svelteのストアはすべて本体に取り込まれているので、追加のライブラリのインストールは不要です。

ストアの作成

まずはストアを作成します。

srcフォルダにstore/book/index.tsファイルを作成します。

store/book/index.ts
import { writable } from 'svelte/store'
import type { BookItem } from '../../repositories/book'

const dummyBooks = [
  {
    id: '1',
    volumeInfo: {
      title: 'title1',
      description: 'lorem ipsm'
    }
  },
  {
    id: '2',
    volumeInfo: {
      title: 'title2',
      description: 'lorem ipsm'
    }
  },
  {
    id: '3',
    volumeInfo: {
      title: 'title3',
      description: 'lorem ipsm'
    }
  },
] as BookItem[]

export const books = writable<BookItem[]>(dummyBooks)

writableによってストアのオブジェクトを作成します。
ひとまずwritableで作成したオブジェクトを表示できるかどうか確認するためにダミーデータを渡しています。

ストアをコンポーネントから使用する

これを次のように使用します。

pages/SearchBooks.svelte
<script lang="ts">
  import BookCard from '../components/BookCard.svelte'
  import { books } from '../store/book'
  import { onDestroy } from 'svelte'
  import type { BookItem } from '../repositories/book'

  let _books: BookItem[]

  const unsubscribe = books.subscribe(value => _books = value)

  onDestroy(unsubscribe)
</script>

<div class="grid grid-cols-1 gap-2 lg:grid-cols-2">
  {#each _books as book (book.id)}
    <BookCard {book} />
  {/each}
</div>

ストアからwritableで作成したオブジェクトを、svelteからonDestroy関数をインポートします。

onDestroyはコンポーネントが破棄されたときに呼ばれるライフサイクルフックです。

さらに、コンポーネント内で使用する変数として_booksを定義しました。

writableオブジェクトに対してsubscribeメソッドを呼び事でストアのオブジェクトを監視します。ストアのオブジェクトに変更がある度にコールバックが呼ばれるので、その値をコンポーネント内で定義した_books変数に代入することによってストアのオブジェクトの値を利用します。

subscribeメソッドは返り値としてストアのオブジェクトの監視を破棄するメソッドを返します。onDestory関数でコンポーネントが破棄された際に呼び出すことで確実に監視を取りやめるようにします。

ストアの糖衣構文

ストアを利用する度に毎回同じような処理を記述するのは退屈です。
喜ばしいことに、Svelteはストアの糖衣構文を用意しています。

以下の構文は先程のものと同じ処理を行います。

pages/SearchBook.svelte
<script lang="ts">
  import BookCard from '../components/BookCard.svelte'
  import { books } from '../store/book'

</script>

<div class="grid grid-cols-1 gap-2 lg:grid-cols-2">
  {#each $books as book (book.id)}
    <BookCard {book} />
  {/each}
</div>

ストアのオブジェクトにプレフィックスとして$を付与することで、同様にリアクティブな値を手に入れることができます!

以下のように、ダミーで用意したデータが表示されています。

スクリーンショット 2021-02-07 16.14.30.png

ストアに値をセットする

ストアの糖衣構文を覚えたならこの後の処理は本当に簡単です!

ひとまずストアのダミーデータは不要なので削除しておきましょう。

store/book/index.ts
import { writable } from 'svelte/store'
import type { BookItem } from '../../repositories/book'

export const books = writable<BookItem[]>([])

$プレフィックスをつけたストアの変数は、コンポーネントの変数を扱うのとほとんど同じように使用できます。
つまりは、books変数を$booksに一括置換するだけでこの章の作業はほどんど終わりです!

pages/SearchBook.svelte
<script lang="ts">
  import SearchBar from '../components/SearchBar.svelte'
  import Spinner from '../components/Spinner.svelte'
  import BookCard from '../components/BookCard.svelte'
-  import type { BookItem } from '../repositories/book';
  import RepositoryFactory, { BOOK } from '../repositories/RepositoryFactory'
  import InfiniteScroll from "svelte-infinite-scroll"
+ import { books } from '../store/book'
  const BookRepository = RepositoryFactory[BOOK]

  let q = ''
  let empty = false
-  let books: BookItem[] = []
  let promise: Promise<void>
  let totalItems = 0
  let startIndex = 0

  $: hasMore = totalItems > $books.length

  const handleSubmit = () => {
    if (!q.trim()) return
    promise = getbooks()
  }

  const getBooks = async () => {
-    books = []
+    $books = []
    empty = false
    startIndex = 0
    const result = await BookRepository.get({ q, startIndex })
    empty = result.totalItems === 0
    totalItems = result.totalItems
-    books = result.itmem
+    $books = result.items
  }

  const handleLoadMore = () => {
    startIndex += 10
    promise = getNextPage()
  }

  const getNextPage = async () => {
    const result = await BookRepository.get({ q, startIndex })

    // 取得データが既に存在するものを含む可能性があるので、フィルタリングしてます。
-    const bookIds = books.map(book => book.id)
+    const bookIds = $books.map(book => book.id)
    const filteredItems = result.items.filter(item => {
      return !bookIds.includes(item.id)
    })
-    $books = [...$books, ...filteredItems]
+    $books = [...$books, ...filteredItems]
  }
</script>

<form on:submit|preventDefault={handleSubmit}>
  <SearchBar bind:value={q} />
</form>
<div class="text-center mt-4">
  {#if empty}
    <div>検索結果が見つかりませんでした。</div>
  {:else}
  <div class="grid grid-cols-1 gap-2 lg:grid-cols-2">
-    {#each books as book (book.id}  
+    {#each $books as book (book.id)}
      <BookCard {book} />
    {/each}
  </div>
  <InfiniteScroll window threshold={100} on:loadMore={handleLoadMore} {hasMore} />
  {/if}
  {#await promise}
    <div class="flex justify-center">
      <Spinner />
    </div>
    {:catch e}
      <span class="text-red-600 text-sm">
        {e.message}
      </span>
  {/await}
</div>

$プレフィックスの変数に値を代入している場合には、それへwritableオブジェクトに対してset()メソッドを呼び出しているのと同じことになります。

前回までの実装を見て、きっと他のJavaScriptフレームワークのストアを実装したことがある人ならコンポーネントから直接状態を更新しているをを見て不安で仕方がないことでしょう。

この章ではストアを外部から直接更新できなくするようにリファクタリングしていきます。

ストアを更新不可能にする

ストアを更新できなくする方法は簡単です。set()update()のように値を状態を更新するメソッドを外部へ公開しなければよいのです。

ストアを作成する処理を修正します。

store/books/index.ts
import { writable } from 'svelte/store'
import type { BookItem } from '../../repositories/book'

const useBookStore = () => {
  const { subscribe } = writable<BookItem[]>([])
  return { subscribe }
}

export const books = useBookStore()

このように、ストアを作成する際に関数でラップしてラップした関数からはsubscribeメソッドのみを返すようにしています。

ご覧のように、set()メソッドが存在しないためコンポーネントから値を更新することができなくなりました。

スクリーンショット 2021-02-07 16.54.02.png

更新メソッドを公開する

ストアを更新するメソッドをuseBookStore内に定義します。
useBookStore関数内ではset()update関数を使うことができます。

更新メソッドのみをreturnするようにしましょう。

store/book/index.ts
import { writable } from 'svelte/store'
import type { BookItem } from '../../repositories/book'

const useBookStore = () => {
  const { subscribe, set, update } = writable<BookItem[]>([])
  const reset = () => set([])
  const add = (newBooks: BookItem[]) => update((books: BookItem[]) => {
    return [...books, ...newBooks]
  })
  return { 
    subscribe,
    reset,
    add
  }
}

export const books = useBookStore()

更新メソッドを利用するように変更しましょう。

pages/SearchBooks
<script lang="ts">
  import SearchBar from '../components/SearchBar.svelte'
  import Spinner from '../components/Spinner.svelte'
  import BookCard from '../components/BookCard.svelte'
  import RepositoryFactory, { BOOK } from '../repositories/RepositoryFactory'
  import InfiniteScroll from "svelte-infinite-scroll"
  import { books } from '../store/book'
  const BookRepository = RepositoryFactory[BOOK]

  let q = ''
  let empty = false
  let promise: Promise<void>
  let totalItems = 0
  let startIndex = 0

  $: hasMore = totalItems > $books.length

  const handleSubmit = () => {
    if (!q.trim()) return
    promise = getbooks()
  }

  const getbooks = async () => {
-    $books = []
+    books.reset()
    empty = false
    startIndex = 0
    const result = await BookRepository.get({ q, startIndex })
    empty = result.totalItems === 0
    totalItems = result.totalItems
-    $books = retuls.items    
+    books.add(result.items)
  }

  const handleLoadMore = () => {
    startIndex += 10
    promise = getNextPage()
  }

  const getNextPage = async () => {
    const result = await BookRepository.get({ q, startIndex })

    // 取得データが既に存在するものを含む可能性があるので、フィルタリングしてます。
    const bookIds = $books.map(book => book.id)
    const filteredItems = result.items.filter(item => {
      return !bookIds.includes(item.id)
    })
-    $books = [...$books, ...filterdItems]    
+    books.add(filteredItems)
  }
</script>

<form on:submit|preventDefault={handleSubmit}>
  <SearchBar bind:value={q} />
</form>
<div class="text-center mt-4">
  {#if empty}
    <div>検索結果が見つかりませんでした。</div>
  {:else}
  <div class="grid grid-cols-1 gap-2 lg:grid-cols-2">
    {#each $books as book (book.id)}
      <BookCard {book} />
    {/each}
  </div>
  <InfiniteScroll window threshold={100} on:loadMore={handleLoadMore} {hasMore} />
  {/if}
  {#await promise}
    <div class="flex justify-center">
      <Spinner />
    </div>
    {:catch e}
      <span class="text-red-600 text-sm">
        {e.message}
      </span>
  {/await}
</div>

ルーティングの追加

最後に、本の詳細ページを実装しましょう。
まずは、詳細ページへのルーティングを追加します。

routing/index.ts
import SearchBook from '../pages/SearchBook.svelte'
+ import DetailsBook from '../pages/DetailsBook.svelte'

export const routes = {
  '/': SearchBook,
+  '/books/:id': DetailsBook,
}

/books/:idというパスを追加します。
動的なセグメントは:コロンを使って使って表します。この値はコンポーネントから取得することができます。

pages/DetailsBook.svelteを作成しましょう。

pages/DetailsBook.svelte
<script lang="ts">
  type Params = { id: string }
  export let params: Params
</script>

<div>
  { params.id }
</div>

paramsという名前のpropsを公開することでURLパラメータを受け取ることができます。

http://localhost:5000/#/books/123 にアクセスしてみてください。

スクリーンショット 2021-02-07 17.29.26.png

リンクを追加する

本一覧ページから詳細ページへ遷移できるようにリンクを追加しましょう。
components/BookCard.svelteを修正します。

components/BookCard.svelte
<script lang="ts">
  import type { BookItem } from '../repositories/book'
+  import { link } from 'svelte-spa-router'
  export let book: BookItem

  $: src = book.volumeInfo.imageLinks 
    ? book.volumeInfo.imageLinks.smallThumbnail
    : 'http://placehold.jp/eeeeee/cccccc/160x120.png?text=No%20Image'

  $: description = book.volumeInfo.description
    ? `${book.volumeInfo.description.slice(0, 100)}...`
    : ''

</script>

<div class="w-full sm:flex">
  <div class="h-96 sm:h-auto sm:w-48 flex-none bg-cover rounded-t sm:rounded-t-none sm:rounded-l text-center overflow-hidden" style={`background-image: url('${src}')`}>
  </div>
  <div class="border-r border-b border-l border-grey-light sm:border-l-0 sm:border-t sm:border-grey-light bg-white rounded-b sm:rounded-b-none sm:rounded-r p-4 flex flex-col justify-between leading-normal w-100 sm:w-9/12 lg:w-7/12">
    <div class="my-4">
+      <a href={`/books/${book.id}`} use:link>
+        <div class="text-black font-bold text-xl mb-2">{book.volumeInfo.title}</div>
+      </a>
      <p class="text-grey-darker text-sm break-words w-9/12 m-auto">
        {description}
      </p>
    </div>
  </div>
</div>

リンクは<a>タグを使用します。linksvelte-spa-routerからインポートしてuse:link属性を与えることによってハッシュ付きのルートにマッチさせることができます。

/books/${book.id} => /#/books/${book.id}

IDから本を取得

ルーティングパラメータによって取得したIDを使ってストアから本を取得しましょう。

derivedというメソッドを使用して、ストアにIDによって本を取得する処理を追加します。
derivedwritableの値から他の値を取得する関数で、Vue.jsにおけるgettersに相当します。

store/book/index.ts
- import { writable } from 'svelte/store'
+ import { writable, derived } from 'svelte/store'
import type { BookItem } from '../../repositories/book'

const useBookStore = () => {
  const { subscribe, set, update } = writable<BookItem[]>([])
  const reset = () => set([])
  const add = (newBooks: BookItem[]) => update((books: BookItem[]) => {
    return [...books, ...newBooks]
  })

  return { 
    subscribe,
    reset,
    add
  }
}

export const books = useBookStore()

+ export const find = (id: string) => {
+  return derived(books, $books => $books.find(book => book.id === id))
+ }

pages/DetailsBook.svelteを修正しましょう。
ストアに存在しなかった場合には、APIから取得してストアに追加するのを待つ必要があります。

そのため、{#await}ブロックの{/then}の中に$bookを配置しています。

pages/DetailsBook.svelte
<script lang="ts">
  import Spinner from "../components/Spinner.svelte";
  import type { Readable } from "svelte/store";
  import type { BookItem } from "../repositories/book";
  import RepositoryFactory, { BOOK } from "../repositories/RepositoryFactory";
  import { find, books } from "../store/book";
  const BookRepository = RepositoryFactory[BOOK];

  type Params = { id: string };
  export let params: Params;
  let book: Readable<BookItem>;
  let promise: Promise<void>;

  const findById = async (id: string) => {
    const book = await BookRepository.find(id);
    books.add([book]);
  };

  book = find(params.id);
  if (!$book) {
    promise = findById(params.id);
  }
</script>

<div>
  {#await promise}
    <div class="flex justify-center">
      <Spinner />
    </div>
  {:then}
    {$book.volumeInfo.title}
  {:catch e}
    <span class="text-red-600 text-sm">
      {e.message}
    </span>
  {/await}
</div>

BookInfo コンポーネント

詳細情報を表示するBookInfoコンポーネントを作成しましょう。
ここでは特に新しい要素は出現しません。

components/BookInfo.svelte
<script lang="ts">
  import Row from './Row.svelte'
  import type { BookItem } from "../repositories/book";
  export let book: BookItem

  const formatter = new Intl.NumberFormat('ja-JP', {
    style: 'currency',
    currency: 'JPY'
  })
  $: price = book.saleInfo?.listPrice?.amount
    ? formatter.format(book.saleInfo.listPrice.amount)
    : ''

  $: src = book.volumeInfo.imageLinks 
    ? book.volumeInfo.imageLinks.thumbnail
    : 'http://placehold.jp/eeeeee/cccccc/160x120.png?text=No%20Image'

</script>

<div class="grid grid-cols-1 gap-2 md:grid-cols-3">

  <div class="cente">
    <img class="h-72 w-auto mx-auto" {src} alt="thumnail">
  </div>
  <div class="bg-white shadow overflow-hidden sm:rounded-lg col-span-2">
    <div class="px-4 py-5 sm:px-6">
      <h3 class="text-black font-bold text-xl mb-2">
        {book.volumeInfo.title}
      </h3>
    </div>
    <div class="border-t border-gray-200">
      <dl>
        <Row dt="著者">
          {book.volumeInfo.authors?.join(',')}
        </Row>
        <Row dt="概要">
          {book.volumeInfo.description}
        </Row>
        <Row dt="価格">
          {price}
        </Row>
        <Row dt="ページ数">
          {book.volumeInfo.pageCount}
        </Row>  
        <Row dt="出版日">
          {book.volumeInfo.publishedDate}
        </Row>
        <Row dt="出版社">
          {book.volumeInfo.publisher}
        </Row>  
        <Row dt="プレビュー">
          {#if book.volumeInfo.previewLink}
            <a href={book.volumeInfo.previewLink} class="text-blue-400">
              {book.volumeInfo.previewLink}
            </a>
          {/if}
        </Row>
      </dl>
    </div>
  </div>
</div>

さらに、Rowコンポーネントは次のようになります。

components/Row.svelte
<script lang="ts">
  export let dt: string
</script>

<div class="bg-gray-50 border-b border-gray-200 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
  <dt class="text-sm font-medium text-gray-500">
    {dt}
  </dt>
  <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
    <slot />
  </dd>
</div>

ここでは、<slot />という要素が出てきました。
スロットは、HTMLタグの中に子要素を入れるように、コンポーネント中に子要素を入れることができる仕組みです。

<Row>{book.volumeInfo.description}</Row>のようにタグの中にある要素が、<Row>コンポーネント内の<slot />に置換されて描画されます。

これで詳細ページも完成です!
お疲れさまでした!

スクリーンショット 2021-02-07 19.11.10.png

28
31
1

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
28
31