Svelteでカレンダーを作ってみる

この記事は[ラクス Advent Calendar 2020](https://qiita.com/advent-calendar/2020/rakus) 23日目の投稿です。

今回は、フロントエンド開発で最近注目されている**Svelte**を試してみた結果を記事にまとめます。

## Svelteって？
Svelte（スヴェルトと読むらしい）って何？と思う人も多いかもしれません。
が、すでにQiitaにもいくつか記事があるので、そちらに譲ります。

- [vueよりも満足度の高いjsフレームワーク、svelteについて](https://qiita.com/koji-kimura/items/592a2bc382ccd1e2b653)
- [ReactとVueを改善したSvelteというライブラリーについて](https://qiita.com/so99ynoodles/items/a8144ced1a21467c6300)

## Svelteでカレンダーを作ってみる
こういったチュートリアルの定番といえばTODOアプリですが、[すでに作っている方がいた](https://qiita.com/nabekou29/items/35822c39b31325b110eb)ので今回はカレンダーアプリを作ることにします。
ひとまず、今月1か月の日付を表示して、ボタンで前月・次月を行き来できるようにしてみます。

### 準備
公式サイトを参考に、Svelteで開発を始める準備をします。
https://svelte.dev/blog/svelte-and-typescript#Try_it_now

Node.jsとnpmはインストール済みだったので、サイトの記述どおりに下記のコマンドを実行します。
SvelteはJavaScriptでもTypeScriptでもコーディングできますが、今回はTypeScriptで開発することにしましょう。

```shell-session
$ npx degit sveltejs/template svelte-calendar
$ cd svelte-calendar
$ node scripts/setupTypeScript.js
$ npm install
```

エディタも公式サイトに従い、公式の拡張機能をインストールしたVS Codeを使います。

ここまで来たら`npm run dev`を実行し、`http://localhost:5000`にアクセスしてみます。以下のような画面が表示されるはずです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/120475/32dcb9d8-9bcf-c43a-0eae-03e1c1678448.png)

### コンポーネントを作る
それではカレンダーを実装していきます。
Svelteはコンポーネント指向のフレームワークですので、表示する各要素をコンポーネントに分けて作成していきます。
今回は、以下のように分けることにしました。

- カレンダー（全体）
- 週
- 日

#### Dayコンポーネント
まず最も小さい単位の"日"コンポーネントから作っていきましょう。
コンポーネントは、srcディレクトリの下に拡張子`.svelte`のファイルとして作成します。
実際に実装した`Day.svelte`はこのような形になりました。

```html:Day.svelte
<script lang="ts">
  export let date: Date;
</script>

<style>
  div {
    flex: 1;
    border: 1px solid #ccc;
    border-top-width: 0;
  }
  div:nth-child(n + 2) {
    border-left-width: 0;
  }
</style>

<div>
  {#if date.getDate() === 1}
    {date.getMonth() + 1}/{date.getDate()}
  {:else}
    {date.getDate()}
  {/if}
</div>
```

Svelteファイルの中身は、script + CSS + テンプレート になっています。

- `<script>`タグ
  - 変数をexportすることで、コンポーネントの外からデータを受け取ります。
  - 今回はTypeScriptを使っているので、`lang="ts"`を付けます。
- `<style>`タグ
  - このコンポーネントで利用するCSSを書きます。ここではセレクタにdivを使っていますが、これはコンポーネント外のdivタグには影響しません（描画時に自動でclass属性を追加してくれます）。
- テンプレート
  - 通常のHTMLの中に、変数や式を埋め込んだり、条件分岐やループを書けます。上記では、日付が1日の場合だけ`12/1`のように月も表示するように分岐しています。

#### Weekコンポーネント
次に"週"のコンポーネントを作っていきます。上で作ったDayコンポーネントを7日分並べるようにすればできそうです。初日の日付だけ外から受け取るようにしましょう。

```html:Week.svelte
<script lang="ts">
  import Day from "./Day.svelte";

  export let startDate: Date;

  // 1週間のDateオブジェクトの配列
  const week = Array.from(Array(7).keys(), (i) => {
    const date = new Date(startDate);
    date.setDate(startDate.getDate() + i);
    return date;
  });
</script>

<style>
  div {
    display: flex;
    flex: 1;
  }
</style>

<div class="week">
  {#each week as date}
    <Day {date} />
  {/each}
</div>
```

基本はDayコンポーネントと変わりませんが、他のコンポーネントを利用している点が先程と違います。

コンポーネントから他コンポーネントを利用するには、`import`で対象のコンポーネントを読み込みます。
そして、HTML部分にコンポーネント名のタグ`<Day />`を書けば、そのコンポーネントを呼び出すことができます。
ただし、Dayコンポーネントは、外部から`date`という変数を受け取る必要があります。変数は、コンポーネントのタグの属性として`変数名={データ}`とすることで渡せます。上の例で言うと`<Day date={date} />`なのですが、今回は変数名が一致しているので、=の前を省略できます。

#### Calendarコンポーネント
次にWeekコンポーネントを組み合わせてCalendarコンポーネントを作ります。
と言っても、表示する日付の範囲をDateオブジェクトをゴリゴリ操作して決めている以外はWeekコンポーネントと同じような書き方をしているので、折りたたんだ中にコードを載せるだけにしておきます。

<details>
<summary>`Calendar.svelte`のコード</summary><div>

```html:Calendar.svelte
<script lang="ts">
  import Week from "./Week.svelte";

  const today = new Date();

  // 今月1日
  const firstDayOfMonth = new Date(today);
  firstDayOfMonth.setDate(1);

  // 1日が属する週の日曜日
  const firstDayOfFirstWeek = new Date(firstDayOfMonth);
  firstDayOfFirstWeek.setDate(1 - firstDayOfMonth.getDay());

  // 表示するすべての日曜日
  const sundays = Array.from(Array(6).keys(), (i) => {
    const sunday = new Date(firstDayOfFirstWeek);
    sunday.setDate(sunday.getDate() + 7 * i);
    return sunday;
  }).filter((date, i) => i === 0 || date.getMonth() === today.getMonth());
</script>

<style>
  h1 {
    margin: 0;
  }
  .calendar {
    display: flex;
    flex-direction: column;
    flex: 1;
    padding-bottom: 18px;
  }
  .week-header {
    display: flex;
  }
  .dow {
    flex: 1;
    border: 1px solid #cccccc;
  }
  .dow:nth-child(n + 2) {
    border-left-width: 0;
  }
  .days {
    display: flex;
    flex-direction: column;
    flex: 1;
  }
</style>

<div>
  <h1>{today.getFullYear()}年{today.getMonth() + 1}月</h1>
</div>
<div class="calendar">
  <div class="week-header">
    {#each ['日', '月', '火', '水', '木', '金', '土'] as dow}
      <div class="dow">{dow}</div>
    {/each}
  </div>
  <div class="days">
    {#each sundays as sunday}
      <Week startDate={sunday} />
    {/each}
  </div>
</div>
```
</div>
</details>

あとは`App.svelte`からCalendarコンポーネントを呼び出すようにすれば、画面に今月のカレンダーが表示されるようになります！
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/120475/0d8abc52-d9ab-cdc8-de76-e7d8daa98715.png)

なお、ここまでで実装したコード全体は以下のURLから確認できます。
https://github.com/takaram/svelte-calendar-sample/tree/f51892c54bf4848d2041847219cb46e79022f0d3

### ページに動きをつける
このカレンダーを表示するだけであれば、PHPでHTMLを出力するのとさほど変わらないでしょう。
ここから、前月・次月に移動できるようにしていきましょう。

まず、CalendarコンポーネントのHTMLで利用している変数`today`と`sundays`を動的に変更できるよう、letでの宣言に変更します（ついでに`today`の変数名を`currentDay`に変更しました）。

```diff
-  const today = new Date();
+  let currentDay: Date;
+  let sundays: Date[];
```

そして、これらの変数へ代入する部分のコードを関数化します。

```ts
const setCurrentDay = (currentDay_: Date) => {
  currentDay = currentDay_;

  // 今月1日
  const firstDayOfMonth = new Date(currentDay);
  firstDayOfMonth.setDate(1);

  // 1日が属する週の日曜日
  const firstDayOfFirstWeek = new Date(firstDayOfMonth);
  firstDayOfFirstWeek.setDate(1 - firstDayOfMonth.getDay());

  // 表示するすべての日曜日
  sundays = Array.from(Array(6).keys(), (i) => {
    const sunday = new Date(firstDayOfFirstWeek);
    sunday.setDate(sunday.getDate() + 7 * i);
    return sunday;
  }).filter(
    (date, i) => i === 0 || date.getMonth() === currentDay.getMonth()
  );
};
```

この関数を使って、表示を前月・次月に切り替える関数を作ることができます。

```ts
const goToPrevMonth = () => {
  currentDay.setMonth(currentDay.getMonth() - 1);
  setCurrentDay(currentDay);
};
const goToNextMonth = () => {
  currentDay.setMonth(currentDay.getMonth() + 1);
  setCurrentDay(currentDay);
};
```

`currentDay`に破壊的に`setMonth()`しているので、`setCurrentDay`の1行目の`currentDay = currentDay_;`は不要なのでは？と思うかもしれませんが、これをコメントアウトすると上手く動きません。
これはSvelteが代入をトリガーに画面の再描画を行うためです[^trig]。

[^trig]: そのため、配列に要素を追加するような場合は`list.push(item)`ではダメで、`list = [...list, item]`のようにします。

あとは、月を移動するボタンを付けます。要素にクリックイベントを設定するには、`on:click={イベントハンドラ}`とします。この例は関数名ですが、直接関数リテラルを`on:click={() => ...}`のように書くこともできます。

```diff
+ <span class="month-control" role="button" on:click="{goToPrevMonth}">&lt;</span>
  <h1>{currentDay.getFullYear()}年{currentDay.getMonth() + 1}月</h1>
+ <span class="month-control" role="button" on:click="{goToNextMonth}">&gt;</span>
```

ここまで来れば完成……と思いきや、実はこれでは`YYYY年M月`のタイトル部分しか変わりません（私はここでハマりました）。
`Week.svelte`も以下のように変更する必要があります。

```diff
-  const week = Array.from(Array(7).keys(), (i) => {
+  $: week = Array.from(Array(7).keys(), (i) => {
     const date = new Date(startDate);
     date.setDate(startDate.getDate() + i);
     return date;
   });
```

代入文の頭に`$:`をつけると、右辺で使われている値が変更された際に再代入・再描画が行われます[^label]。

[^label]: `$:`は文法的には、多重ループを抜けるときなどに使う[ラベル文](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Statements/label)です。

これでようやく表示する月を変更できるようになりました。
完成したコードは以下のリポジトリで確認できます。
https://github.com/takaram/svelte-calendar-sample/tree/09f7dbe9bb588b661a5947aa83e8f9f8ce4e0ddf

## 所感
あまり他のフレームワークに詳しくないため比較はできませんが、比較的少ないコード量で実装できたのではないでしょうか。
表示される値の更新に多少クセがあるような気もしますが、慣れれば記述量が少なくて問題なさそうです。

ちなみに、開発中に編集したファイルを保存すると、変更が自動的に反映されるのがとても楽でした。ブラウザの更新ボタンすら押す必要がないので、行った変更を即座に確認することができます。

今度はもう少し複雑なアプリも作成してみたいですね。