Svelteで作るTODOアプリ

### 追記 2020/12/10 

続きを書きました
[Svelteで作るTODOアプリ - 状態管理編](https://qiita.com/nabekou29/items/dba59de91812a500060b)

---

Svelte のチュートリアルを一通り試したので練習がてら TODO アプリの作成をしていきたいと思います。

1. [チュートリアル](https://svelte.dev/tutorial/basics)を一通り行う
2. [Examples](https://svelte.dev/examples#hello-world)を見て復習

Svelteについては以下が参考になります。
- [公式](https://svelte.dev/)
- [React と Vue を改善した Svelte というライブラリーについて](https://qiita.com/so99ynoodles/items/a8144ced1a21467c6300)
- [Svelte で Web サイトを作ってみて感じた魅力](https://qiita.com/nishinoshake/items/46a64591c6411af68af1)

## 新規のプロジェクトを作成する

```sh
$ npx degit sveltejs/template svelte-todo-app
$ cd svelte-todo-app
$ node scripts/setupTypeScript.js
$ yarn install
```

```sh
$ yarn run dev
# http://localhost:5000 にアクセスするとデフォルトの画面が表示されます
```

|<img width="480" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/258777/8b5b0689-426e-2882-f675-0a91014ea259.png">|
|:-:|

### 各ファイルについて

- index.html

    アプリのベースとなる html。

```html:index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width,initial-scale=1" />

    <title>Svelte app</title>

    <link rel="icon" type="image/png" href="/favicon.png" />
    <link rel="stylesheet" href="/global.css" />
    <link rel="stylesheet" href="/build/bundle.css" />

    <script defer src="/build/bundle.js"></script>
  </head>

  <body></body>
</html>
```

- App.svelte

    App コンポーネント。

```html:App.svelte
<script lang="ts">
  export let name: string; // exportすることで外部から値を設定できる
</script>

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

<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>
```

- main.ts

    起点となる App コンポーネントのインスタンス化を行う。

    - `target` : コンポーネントの差込先
    - `props` : コンポーネントに渡す値

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

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

export default app;
```

## TODO アプリの実装

### 事前準備

1.  `main.ts` を修正

    props は使わないので消してしまいます。

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

1.  VSCode に拡張機能を入れる

    VSCode で開発するので [Svelte for VS Code](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode) をいれる。

### 入力欄を作成

|![input_bind.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/258777/a51edfe4-7868-5dd4-c23a-16d86f89ef90.png)|
|:-:|

```html:App.svelte
<script lang="ts">
  let title = '';
</script>

<div>
  <label>
    タイトル
    <input bind:value={title} />
  </label>
  {title}
</div>
```

`bind:value`を使用することで、入力した値を変数にバインドできます。

### 作成ボタンを追加

|![create_button.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/258777/4c74e1ab-572d-9ac7-bd13-0806ccc4851d.png)|
|:-:|

```html:App.svelte
<script lang="ts">
  let title = '';

  // 作成ボタンを押したときの処理
  const handleClickCreateButton = () => {
    alert(title);
    title = '';
  };
</script>

<div>
  <form>
    <label>
      タイトル
      <input bind:value={title} />
    </label>
    <button on:click={handleClickCreateButton}>作成</button>
  </form>
</div>
```

onClick などのイベントは `on:eventname` に関数を渡すことで拾うことができます。

ちなみに `on:click|once` のようにすると、一度のみ実行するようにできます。詳細は[こちら](https://svelte.dev/docs#on_element_event)。

### リストを表示

|![todo_list.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/258777/e5ffd9c9-edd2-15cd-668a-a85619e75755.png)|
|:-:|

```html:App.svelte
<script lang="ts">
  let title = '';
  let todoList: string[] = ['やること１', 'やること２', 'やること３'];

  // 省略
</script>

<div>
  <!-- 省略 -->
</div>
<ul>
  {#each todoList as todoItem}
    <li>{todoItem}</li>
  {/each}
</ul>
```

each ブロックを使うことでリストを扱えます。

次に、リストが空の場合はメッセージを表示するようにします。

|![todo_list_empty.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/258777/e75a1a58-5eac-c7a9-d354-32885935bf9d.png)|
|:-:|

```html:App.svelte
<script lang="ts">
  let title = '';
  let todoList: string[] = [];

  // 省略
</script>

<div>
  <!-- 省略 -->
</div>
{#if todoList.length === 0}
  <div>アイテムを作成してください</div>
{:else}
  <ul>
    {#each todoList as todoItem}
      <li>{todoItem}</li>
    {/each}
  </ul>
{/if}
```

条件による出し分けは if ブロックで行います。

ブロックの開始タグには `#`、中間タグには `:`、終了タグには `/`を使います。

### アイテムを追加できるようにする

```html:App.svelte
<script lang="ts">
  let title = '';
  let todoList: string[] = [];

  // 作成ボタンを押したときの処理
  const handleClickCreateButton = () => {
    todoList = [...todoList, title]; // ここを修正
    title = '';
  };
</script>
```

なお、

```ts
todoList.push(title);
```

このような書き方では、画面が更新されません。これは Svelte が代入をトリガーにしていることに起因します。
そのため、

```ts
todoList.push(title);
todoList = todoList;
```

と書くことでも正しい挙動を得ることができます。

### タイトルが空の場合にボタンを押せなくする

```html:App.svelte
<button disabled={title === ''} on:click={handleClickCreateButton}>
  作成
</button>
```

とすれば動きますが、このままだと条件が複雑な場合に読みづらいため、条件式を外に出します。

```html:App.svelte
<script lang="ts">
  // 省略

  $: disabledCreateButton = title === '';
</script>

<div>
  <!-- 省略 -->
  <button disabled={disabledCreateButton} on:click={handleClickCreateButton}>
    作成
  </button>
</div>
```

トップレベルの文の手前に`$:`を付けると、依存する値が更新された際に再評価が行われるようになります。

:bulb: `$:`は [label 文](https://developer.mozilla.org/ja/docs/Web/JavaScript/Guide/Loop_Statements/label_Statement)。

### アイテムを削除できるようにする

```html:App.svelte
<script lang="ts">
  let title = '';
  let todoList: string[] = [];

  $: disabledCreateButton = title === '';

  // 作成ボタンを押したときの処理
  const handleClickCreateButton = () => {
    todoList = [...todoList, title];
    title = '';
  };

  // 完了処理
  const completeItem = (index) => {
    todoList = todoList.filter((_, i) => i !== index);
  };
</script>

<div>
  <form>
    <label>
      タイトル
      <input bind:value={title} />
    </label>
    <button disabled={disabledCreateButton} on:click={handleClickCreateButton}>
      作成
    </button>
  </form>
</div>
{#if todoList.length === 0}
  <div>アイテムを作成してください</div>
{:else}
  <ul>
    {#each todoList as todoItem, index}
      <li on:click={() => completeItem(index)}>{todoItem}</li>
    {/each}
  </ul>
{/if}
```

とりあえず、これで TODO リストとしての最低限の機能は作れました。

### 見た目を整える

見た目を整えるために CDN で [Bootstrap](https://getbootstrap.com/) を導入します。`global.css`はファイルごと削除。

```diff:index.html
-  <link rel='stylesheet' href='/global.css'>
+  <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" integrity="sha384-JcKb8q3iqJ61gNV9KGb8thSsNjpSL0n8PARn9HuZOnIxN0hoP+VmmDGMN5t9UJ0Z" crossorigin="anonymous">
```

|<img width="480" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/258777/5ac70d01-917e-c643-5f66-7d7d9d44373e.png">|
|:-:|

<details><summary>App.svelte</summary><div>

```html:App.svelte
<script lang="ts">
  let title = '';
  let todoList: string[] = [];

  $: disabledCreateButton = title === '';

  // 作成ボタンを押したときの処理
  const handleClickCreateButton = () => {
    todoList = [...todoList, title];
    title = '';
  };

  // 完了処理
  const completeItem = (index) => {
    todoList = todoList.filter((_, i) => i !== index);
  };
</script>

<div class="container mt-3">
  <div class="row justify-content-center">
    <div class="col-lg-6 col-md-8">
      <h2>TODO アプリ</h2>
      <form>
        <div class="form-group">
          <label for="title">タイトル</label>
          <input id="title" class="form-control" bind:value={title} />
        </div>
        <div class="form-group text-center">
          <button
            class="btn btn-primary px-5"
            disabled={disabledCreateButton}
            on:click={handleClickCreateButton}>
            作成
          </button>
        </div>
      </form>
    </div>
  </div>
  <div class="row justify-content-center">
    <div class="col-lg-6 col-md-8">
      {#if todoList.length === 0}
        <div>アイテムを作成してください</div>
      {:else}
        <ul class="list-group">
          {#each todoList as todoItem, index}
            <li class="list-group-item align-middle">
              {todoItem}
              <button
                class="btn btn-sm btn-success float-right"
                on:click={() => completeItem(index)}>
                ×
              </button>
            </li>
          {/each}
        </ul>
      {/if}
    </div>
  </div>
</div>
```

</div></details>


良い感じになりました。

## 機能を追加

### アニメーションを追加

```html:App.svelte
<script lang="ts">
  import { slide } from 'svelte/transition';
  // 省略
</script>

<div class="container mt-3">
  <!-- 省略 -->
          {#each todoList as todoItem, index}
            <li transition:slide class="list-group-item align-middle">
              <!-- 省略 -->
            </li>
          {/each}
  <!-- 省略 -->
</div>
```

`transition:slide` を付けるだけです。簡単！

`slide`以外にも、`fade`や`scale`などが用意されています。

しかしこれでは、どのアイテムを完了しても一番下のアイテムにアニメーションが効いてしまいます。
そこで `each` で `key` を指定するようにします。

```html:App.svelte
<script lang="ts">
  import { slide } from 'svelte/transition';

  type TodoItem = {
    id: number;
    title: string;
  };

  let title = '';
  let todoList: TodoItem[] = [];

  $: disabledCreateButton = title === '';

  // 作成ボタンを押したときの処理
  const handleClickCreateButton = () => {
    const id = new Date().getTime();
    todoList = [...todoList, { id, title }];
    title = '';
  };

  // 完了処理
  const completeItem = (index) => {
    todoList = todoList.filter((_, i) => i !== index);
  };
</script>
```

```diff
- {#each todoList as todoItem, index}
+ {#each todoList as todoItem, index (todoItem.id)}
```

これでアニメーションが正しく動くようになりました。

### 優先度を追加

最後に優先度を登録できるようにして完成です。

|![todo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/258777/574da688-02e4-2486-b53b-84d2310053a4.gif)|
|:-:|

<details><summary>App.svelte</summary><div>

```html:App.svelte
<script lang="ts">
  import { slide } from 'svelte/transition';

  const Priorities = {
    High: 1,
    Mid: 2,
    Low: 3,
  } as const;
  type Priority = typeof Priorities[keyof typeof Priorities];

  const priorityNames = {
    [Priorities.High]: '高',
    [Priorities.Mid]: '中',
    [Priorities.Low]: '低',
  } as const;

  type TodoItem = {
    id: number;
    title: string;
    priority: Priority;
  };

  let title = '';
  let priority: number = 2;
  let todoList: TodoItem[] = [];

  $: disabledCreateButton = title === '';
  $: sortedTodoList = todoList.sort(
    (item1, item2) => item1.priority - item2.priority
  );

  // 作成ボタンを押したときの処理
  const handleClickCreateButton = () => {
    const id = new Date().getTime();
    if (priority === 1 || priority === 2 || priority === 3) {
      todoList = [...todoList, { id, title, priority }];
    }
    title = '';
  };

  // 完了処理
  const completeItem = (id: number) => {
    todoList = todoList.filter((todo) => todo.id !== id);
  };
</script>

<div class="container mt-3">
  <div class="row justify-content-center">
    <div class="col-lg-6 col-md-8">
      <h2>TODO アプリ</h2>
      <form>
        <div class="form-group">
          <label for="title">タイトル</label>
          <input id="title" class="form-control" bind:value={title} />
        </div>
        <div class="form-group">
          <label for="priority">優先度</label>
          <select id="priority" class="form-control" bind:value={priority}>
            {#each Object.entries(priorityNames) as [value, label]}
              <option value={Number(value)}>{label}</option>
            {/each}
          </select>
        </div>
        <div class="form-group text-center">
          <button
            class="btn btn-primary px-5"
            disabled={disabledCreateButton}
            on:click={handleClickCreateButton}>
            作成
          </button>
        </div>
      </form>
    </div>
  </div>
  <div class="row justify-content-center">
    <div class="col-lg-6 col-md-8">
      {#if todoList.length === 0}
        <div>アイテムを作成してください</div>
      {:else}
        <ul class="list-group">
          {#each sortedTodoList as todoItem (todoItem.id)}
            <li transition:slide class="list-group-item align-middle">
              <span
                class="badge m-1"
                class:badge-danger={todoItem.priority === Priorities.High}
                class:badge-warning={todoItem.priority === Priorities.Mid}
                class:badge-success={todoItem.priority === Priorities.Low}>
                {priorityNames[todoItem.priority]}
              </span>
              {todoItem.title}
              <button
                class="btn btn-sm btn-success float-right"
                on:click={() => completeItem(todoItem.id)}>
                ×
              </button>
            </li>
          {/each}
        </ul>
      {/if}
    </div>
  </div>
</div>
```

</div></details>

## 所感

書き方は Vue に近い感じですね。ほとんど書いたことがないので細かい比較はできませんが。
使ってみた感触はかなり良かったです。class の出し分けのしやすさ、アニメーションの手軽さ、for/if の構文も自分好みで良かったです。VSCodeとの相性も良好でした。
普段は React を書くことが多いですが、Svelte を使うことも視野に入れたいと思いました。もう少し大きいアプリに挑戦したいです。

チュートリアルは日本語には対応していませんがわかりやすく、今回使用した以外にも多くの機能があるので、ぜひ覗いてみてください。

## 参考

- [React と Vue を改善した Svelte というライブラリーについて](https://qiita.com/so99ynoodles/items/a8144ced1a21467c6300)
- [Svelte で Web サイトを作ってみて感じた魅力](https://qiita.com/nishinoshake/items/46a64591c6411af68af1)
- [Svelte が TypeScript を公式サポートしたので試してみる！](https://qiita.com/oekazuma/items/387b856ac67ab05d71e9)
- [Svelte が公式に TypeScript に対応しました](https://qiita.com/myLifeAsaDog/items/92b0a89110d14bf59dba)