はじめに
この記事でははSvelte + TypeScript + tailwindcssで本検索サイトを作成します。
成果物は以下のようなアプリケーションです。
本の検索ページ
本の詳細ページ
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 をブラウザで開いてください!次の画面が表示されているはずです。
おすすめのvscode拡張
Svelteの基礎の確認
最初に自動生成されたコードを確認してみましょう。
Svelteのコンポーネントは.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のコードを見てみましょう。
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の値を変更してみましょう。
画面の表示も変更された値に切り替わるはずです。
import App from './App.svelte';
const app = new App({
target: document.body,
props: {
name: 'svelte'
}
});
export default app;
ヘッダーコンポーネントの作成
それでは、一番最初のコンポーネントを作成しましょう。
src
フォルダ配下にcomponents
ディレクトリを作成して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タグのように利用します。
<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
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
を作成します。
import SearchBook from '../pages/SearchBook.svelte'
export const routes = {
'/': SearchBook,
}
/
というパスにアクセスした場合に、SearchBook
コンポーネントにマッチするように定義します。
/pages/SearchBook.svelte
も作成しておきましょう。
<div>
本を探す
</div>
Router Viewの作成
ルートを定義したら、定義したルートを描画するようにしましょう。
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
コンポーネントに、先程作成したルート定義をroutes
propとして渡します。
Svelteでは、propsと渡す変数名が一致するとき省略記法を利用することができます。
つまり、次のように記述は同一です。
<Router routes={routes} />
<Router {routes} />
ここまで進めたら、SearchBookコンポーネントの内容が表示されているはずです。
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などを設定して作成されたインスタンスを返します。
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に置き換える際にも実装の詳細を抽象化することができます。
/**
* 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
を継承するようにします。
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
で作成したモジュールを一つにまとめてエクポートします。
export * from './types'
export * from './BookRepository'
Repository Factoryの作成
作成したRepositoryはすべてRepository Factoryからインポートして利用するようにします。
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を使用する場合には、次のように記述することができます。
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コンポーネントを作成します。
<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>
<script lang="ts">
import SearchBar from '../components/SearchBar.svelte'
</script>
<form>
<SearchBar />
</from>
値のバインディング
見た目はこれでよさそうなので、親子間で値を受け渡しできるようにロジックを実装していきます。
まずはpropsとしてSearchBar コンポーネントが値を受け取れるようにしましょう。
propsはexportする変数として定義するのでした。
<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>
親コンポーネントから適当な値を渡してみましょう。
<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として渡した値が初期値として表示されています。
親から子に値を渡すことに成功したので、次は子コンポーネントで入力が行われたときに親に値を渡せるようにしましょう。
<input>
の入力をバインドさせる - Vue.jsにおけるv-modelに相当するもの - にはbind:value
ディレクティブを使用します。
<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:value
はbind:value={value}
のショートハンドです。
子コンポーネントで発生したイベントは親コンポーネントへフォワーディングさせることができます。
親コンポーネントでbind:value
イベントを受け取ります。
<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>
これで、入力した値をバインディングさせることができました。
本一覧を取得する
続いて入力した値に応じてWeb APIから本一覧を取得してみましょう。
まずはsubmitイベントを購読しましょう。
<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から本一覧を取得しましょう。
以下のような実装になります。
<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のような状態変数を持つ必要がなくなるので便利です。
実際に検索結果を取得できるかどうか試してみてください。
コンポーネントに切り出す
Spinner.svelte
ローディングアニメーションをSpinnerコンポーネントとして作成します。
<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>
いい感じに中央寄せしておきましょう。
<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
ファイルを作成します。
<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におけるcomupted
やwatch
などの役割に近い処理をはたしてくれます。
このコンポーネントをループでしようするように修正しましょう。
<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>
ここまでの変更で次のような画面になりました!
ここまでに実装でどうやら正しく検索処理が働き描画できているようです!
しかしながらすでにお気づきかもしれないですが、取得結果がいくつであろうと得られるのは最初の10件だけです。
追加のデータをさらに取得できるようにページネーション処理を実装しましょう。
ライブラリのインストール
無限スクロールによるページネーションを実装するために、こちらのライブラリをインストールします。
npm i svelte-infinite-scroll
次のように使います。
<InfinteScroll>
要素が検出された際にloadMore
イベントが発火します。
<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} />
それでは確認してみましょう!
一旦本を探す画面はこれでよさそうです。
今度はコンポーネント内で扱っていた状態管理をストアで行うように修正しましょう。
Svelteのストアはすべて本体に取り込まれているので、追加のライブラリのインストールは不要です。
ストアの作成
まずはストアを作成します。
src
フォルダに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
で作成したオブジェクトを表示できるかどうか確認するためにダミーデータを渡しています。
ストアをコンポーネントから使用する
これを次のように使用します。
<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はストアの糖衣構文を用意しています。
以下の構文は先程のものと同じ処理を行います。
<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>
ストアのオブジェクトにプレフィックスとして$
を付与することで、同様にリアクティブな値を手に入れることができます!
以下のように、ダミーで用意したデータが表示されています。
ストアに値をセットする
ストアの糖衣構文を覚えたならこの後の処理は本当に簡単です!
ひとまずストアのダミーデータは不要なので削除しておきましょう。
import { writable } from 'svelte/store'
import type { BookItem } from '../../repositories/book'
export const books = writable<BookItem[]>([])
$
プレフィックスをつけたストアの変数は、コンポーネントの変数を扱うのとほとんど同じように使用できます。
つまりは、books
変数を$books
に一括置換するだけでこの章の作業はほどんど終わりです!
<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()
のように値を状態を更新するメソッドを外部へ公開しなければよいのです。
ストアを作成する処理を修正します。
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()
メソッドが存在しないためコンポーネントから値を更新することができなくなりました。
更新メソッドを公開する
ストアを更新するメソッドをuseBookStore
内に定義します。
useBookStore
関数内ではset()
・update
関数を使うことができます。
更新メソッドのみをreturnするようにしましょう。
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()
更新メソッドを利用するように変更しましょう。
<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>
ルーティングの追加
最後に、本の詳細ページを実装しましょう。
まずは、詳細ページへのルーティングを追加します。
import SearchBook from '../pages/SearchBook.svelte'
+ import DetailsBook from '../pages/DetailsBook.svelte'
export const routes = {
'/': SearchBook,
+ '/books/:id': DetailsBook,
}
/books/:id
というパスを追加します。
動的なセグメントは:
コロンを使って使って表します。この値はコンポーネントから取得することができます。
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 にアクセスしてみてください。
リンクを追加する
本一覧ページから詳細ページへ遷移できるようにリンクを追加しましょう。
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>
タグを使用します。link
をsvelte-spa-router
からインポートしてuse:link
属性を与えることによってハッシュ付きのルートにマッチさせることができます。
例 /books/${book.id}
=> /#/books/${book.id}
IDから本を取得
ルーティングパラメータによって取得したIDを使ってストアから本を取得しましょう。
derived
というメソッドを使用して、ストアにIDによって本を取得する処理を追加します。
derived
はwritable
の値から他の値を取得する関数で、Vue.jsにおけるgetters
に相当します。
- 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
を配置しています。
<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コンポーネントを作成しましょう。
ここでは特に新しい要素は出現しません。
<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
コンポーネントは次のようになります。
<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 />
に置換されて描画されます。
これで詳細ページも完成です!
お疲れさまでした!