JavaScript フレームワーク「Svelte」で以前作ったサイトに色々と手を加えた話

この記事は Svelte Advent Calendar 2021 11日目の記事です。

皆さんは Svelte をご存知でしょうか。
JavaScript のフレームワークで、近い存在の Vue や React に比べれば知名度はまだまだですが、少しずつ人気が出てきています(参考)。

以前の記事 でも少し触れたのですが、今年の GW ごろに Svelte を使ってちょっとしたサイトを作りました。

今回はそこに機能をいくつか足したのでそのあたりをご紹介します。
リポジトリは以下です。

まずは Svelte について

Svelte は 2016年にリリースされました。
命令的ではなく宣言的にコードが書けること、実行時のファイルサイズが小さいことなどが特徴です。

拡張子は svelte で、基本的に単一ファイルコンポーネント (以下 SFC) で html/css/js を書くことになります。
SFC については賛否両論あると思いますが、個人的には機能に関する実装が1つのファイルで完結するのはとてもわかりやすいので気に入っています(テストも書きやすいですし)。
以下はボタンを押すと表示されている数値を increment する簡単なコンポーネントです。

<script>
  let count = 0;
  const increment = () => count += 1; // React の useCallback や Vue の methods に相当
  $: isEven = count % 2 === 0; // React の useMemo や Vue の computed に相当
</script>

<div class="{isEven ? 'count even': 'count odd'}">{count}</div>
<button on:click="{increment}">Increment!!</button>

<style>
.count {
  font-weight: bold;
}
.odd {
  color: red;
}
.even {
  color: blue;
}
</style>

動作サンプル

とにかくシンプルです。
テンプレートに JavaScript の制御文を書く際は {} で囲います。

  • テンプレート (html) の区画が明示されていない
  • script タグ内にはほぼ素の JavaScript を書ける(Svelte ならではの書き方が少ない)

といった点が React や Vue と異なりますが、素の html/JavaScript っぽく書けるのはとっつきやすさとして十分なのではと思っています。

追加した機能について

ここからが本題です。
以下3点の対応についてご紹介します。

今回、リポジトリテンプレートとして以下を利用しました。

チャットを足した

Firebase Realtime Database を使い、チャット機能を実装しました。
上記を選んだ経緯は大きく2つです。

  • このアプリが現状 Firebase Hosting 上で動作している
  • チャットの性質上リアルタイムな同期が必要だった

今回はビルドシステムに rollup を使っていますが、環境変数の扱いがちょっと特殊だったため rollup.config.js も修正しています。

# dotenv は環境変数の読み込みに、dayjs は日付の表示フォーマットをカスタマイズするために追加
$ npm install firebase dotenv dayjs
// rollup.config.js
import replace from "@rollup/plugin-replace";

export default {
  // 中略
  plugins: [
    replace({
      // アプリから process.env が利用できなかったので、
      // DATABASE_URL という名前が使われた場合に replace する処理を追加
      DATABASE_URL: JSON.stringify(process.env.DATABASE_URL),
    }),
  ],
  // 中略
};
// main.js
import firebase from "firebase/app";
import App from "./App.svelte";

// Firebase の設定
const config = {
  databaseURL: DATABASE_URL, // rollup.config.js で設定した名前を指定
};
firebase.initializeApp(config);

// Svelte のアプリケーションを html へマウントする
const app = new App({
  target: document.body,
});

チャット部分についても数十行のコードで実装できました。
スクリプト部分のみ抜粋していますので、コード全体については上述のリポジトリをご覧ください。

import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
import { onMount } from "svelte";
import firebase from "firebase/app";
import "firebase/database";

dayjs.extend(timezone);
dayjs.tz.setDefault("Asia/Tokyo");

let username = null;
let content = null;
const submit = () => {
  if (username == null || username.length === 0) {
    alert("※ユーザー名は必須です!");
    return;
  }
  if (content == null || content.length === 0) {
    alert("※メッセージは必須です!");
    return;
  }
  const ref = firebase.database().ref("messages");
  const timestamp = Date.now();
  return ref
    .push({ username, content, timestamp, sortKey: timestamp * -1 })
    .then(() => {
      // コンテンツは投稿のたびクリアする
      content = null;
    })
    .catch(console.error);
};
let messages = [];
// コンポーネントがマウントされた際に実行する処理
onMount(async () => {
  const messagesRef = await firebase
    .database()
    .ref("messages")
    .orderByChild("sortKey")
    .limitToLast(10);
  messagesRef.on("value", (snapshot) => {
    const r = [];
    snapshot.forEach((c) => {
      r.push(c.val());
    });
    messages = r.slice();
  });
});

Svelte ならではのコードがほとんど無い(プレーンな JavaScript コードを書いているのとそこまで変わらない)ので、JavaScript がわかる方なら特に違和感無く読めるのではと思います。

TypeScript 化した

もともとは JavaScript を使ってコードを書いていましたが、TypeScript もサポートされていることを知り、せっかくなので使うことにしました。
ただ、(React はともかく Vue で似たような対応をしたこともあり)途中から導入するのは面倒な印象もありました。
結果ーー

$ node scripts/setupTypeScript.js
$ npm install

これでほとんど終わりです。
上記スクリプトが package.json や rollup.config.js を書き換えてくれ、tsconfig.json など TypeScript 開発環境特有のファイルも作成してくれました。
(細かい修正は他にもあるものの)自分で修正したのは以下ぐらいです。

  • エントリポイントの拡張子を js から ts に変更
  • SFC 内の script タグに lang="ts" を追加
  • 先の定数 DATABASE_URLprocess.env.DATABASE_URL に変更

Svelte というよりもテンプレート側のすごいところではありますが、差分を見る限り自分でやったとしてもそこまで難しいものでは無いように感じました。

PWA 化した

プログレッシブウェブアプリ (PWA) についても対応を行いました。
簡単にいうと既存の Web サイトをネイティブアプリケーションのように表示するような仕組みです。
サイトを表示した際に「ホームへ追加しますか?」みたいなメッセージ(PC ブラウザだと、アドレスバー横にアイコンが表示されているかもしれません)が表示される場合、そのサイトは PWA 対応していると考えて良いです。

<!-- index.html -->
<head>
  <link rel="manifest" href="/manifest.json" />
</head>
{
  "name": "Inishie",
  "short_name": "Inishie",
  "icons": [
    {
      "src": "/images/logo_192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    }
  ],
  "start_url": "/",
  "display": "standalone",
  "background_color": "#646870",
  "theme_color": "#81868f"
}
// serviceWorker.js

// キャッシュに保存するアセットファイル一覧
const staticAssets = [
  "./",
  "./global.css",
  "./build/bundle.css",
  "./build/bundle.js",
];

// リクエスト発生時のイベント
self.addEventListener("fetch", async event => {
  // GET リクエスト以外は処理しない
  if (event.request.method != "GET") {
    return;
  }

  const { request } = event;

  // 自前の処理を行いたいので respondWith() を使う
  event.respondWith(() => {
    const cache = await caches.open("inishie-cache");
    const cachedResponse = await caches.match(request)

    // キャッシュが存在した場合は更新しておく
    if (cachedResponse) {
      event.waitUntil(cache.add(event.request));
      return cachedResponse;
    }

    // キャッシュが存在しなかった場合はリソースを取得
    return fetch(request)
  });
});
// main.js

// serviceWorker が利用できるブラウザの場合 serviceWorker.js を登録する
// これにより、ユーザにアプリのインストールを促すようになる
if ("serviceWorker" in navigator) {
  navigator.serviceWorker
    .register("/serviceWorker.js")
    .then((registration) => {
      if (typeof registration.update == "function") {
        registration.update();
      }
    })
    .catch((error) => {
      console.log("Error Log: " + error);
    });
}

Inishie へアクセスすると実際の挙動を確認できますので、ぜひ試してみてください。

まとめ

いかがでしたでしょうか。
ほとんど Svelte 以外の内容になってしまった気がしますが、これを機に Svelte に興味を持っていただけると嬉しいです。