🕌

サードパーティスクリプトの極限環境向け Svelte

2020/12/21に公開
1

この記事は、 Svelte Advent Calendar 2020 - Qiita の 22 日目です。

昨今では、フロントエンドの JS を減らす圧が強くなってきています。とくに来年 4 月に導入される Core WebVital は SEO に関わるため、 マーケティング文脈でもフロントエンドの改善施策として、パフォーマンスを上げる圧が強くなっています。

で、ユーザー体験を遅くするものとしてやり玉に上げられるのが、サードパーティスクリプトという、サイト外から読み込まれる第三者の script です。代表的なものが Google Tag Manager や Twitter や Facebook の埋め込み Widget です。社内でかち合ってる Google の社内政治もなかなかにつらそうです。

追記: 後付の社内チェックが通ったので追記。本記事は Plaid のエンジニアとしてサードパーティの埋め込みタグの改善として取り組んでいる内容になります。 Karte にはユーザーが自由に記述できるウィジェットがあり、この基盤として、ある程度の自由度を保証しつつビルドサイズが小さい Svelte を採用を検討しています。 - 採用情報 | 株式会社プレイド

そういう事情を鑑みて、サードパーティスクリプトを提供するにあたっては限界まで容量を減らすことが求められます。 Webpack の提案するビルドサイズは gzip 済みで 240kb ですが、サードパーティという立場上、そのうち一つのスクリプトが許されるバジェットは、多く見積もっても 15kb ぐらいでしょうか…?

とりあえず、指標として、async で読み込まれるとして、空白のページにこのスクリプトを読み込んでみた際のパフォーマンスが、 Lighthouse で 100 点をだせることを目標とします。

その前提で、 minify や gzip はもちろんのこと、 CDN から配信することが暗に要求されます。そのへんは、今回の記事では割愛しますが、その上でアプリケーションロジックも減らしていく必要があります。

…しかし、我々 SPA に甘やかされたフロントエンドエンジニアとしては、可能な限りリッチな開発体験を維持したい… 色々検証した結果、そんなときに Svelte が役に立つ、というのを、この記事で紹介します。

なぜ svelte が 3rd party script に適しているか

フロントエンド系の中では、 preact や lit-html と匹敵する小ささです。

gzip 前の minified で比較します。

また、svelte-check が便利で、パフォーマンスを改善するのに適したユーティリティを提供してくれます。

  • typescript の型チェック
  • 未使用変数の検知
  • CSS の 未使用セレクタの検知
  • HTML の a11y の Lint

svelte-check - npm

経験上、js/ts に関しては静的解析で色々と計測して減らすことができるんですが、開発が続くにつれて CSS のデッドコードが膨らみがちで、色々と工夫しないと肥大化していきます。svelte-check はそれが組み込まれているのでなかなかに便利です。

a11y まで警告してくれるのがモダン〜って感じですね。

Svelte 自体の Pros/Cons

  • Pros
    • 出力サイズが小さい
    • 学習コストが少ない
    • 公式プレイグラウンドがよくできてる
  • Cons
    • 採用事例が少ない

採用事例が少ない問題は、公式のチュートリアルとプレイグラウンドの完成度が高いのでクリアできるという気がしています。ただし英語で読む必要はありますが…

自分は 1 日ぐらいチュートリアルを眺めたら書けるようになりました。 mustache 風の変数埋め込み、EcmaScript の標準仕様を活用した文法で、簡潔です。とくに Vue の data と methods と computed のような摩訶不思議なアクセッサ定義がないのが好印象です。

環境構築

というわけで、極限環境向け Svelte をやってきましょう。

Svelte の開発にあたって、自分は VSCode と svelte-check を見ながら開発することを推奨しています。

Svelte 用拡張を VS Code にインストールします。

Svelte for VS Code - Visual Studio Marketplace

また、 ライブラリサイズを意識するために Import Cost の導入を推奨しています。

Import Cost - Visual Studio Marketplace

Prettier が Svelte に対応しているので、Prettier 拡張も入れましょう。

Prettier - Code formatter - Visual Studio Marketplace

今回は、 webpack ではなく、 rollup を使います。

$ mkdir hard-svelte && cd hard-svelte
$ npm init -y
$ npm install --save svelte
$ npm install --save-dev @babel/core @babel/preset-env @babel/preset-typescript @tsconfig/svelte@ @wessberg/rollup-plugin-ts rollup rollup-plugin-node-resolve rollup-plugin-svelte svelte-preprocess typescript svelte-check rollup-plugin-terser rollup-plugin-analyzer es-check bundlesize

これだけだと再現しないかもしれないので、リポジトリも用意しておきました mizchi/svelte-starter

devDependencies がとにかく多いですが、 rollup は薄い設計思想で、 typescript と最適化周りのユーティリティを入れてまわるとこんな感じになります。

公式の @rollup/plugin-typescript がどうにも使いづらいので、 wessberg/rollup-plugin-ts を使います。これは babel 経由で TypeScript をコンパイルしつつ、babel 向けの transform をかましたりできます。

今回は @babel/preset-env を使って、 last 2 version を指定します。

tsconfig.json は svelte-check の期待してる設定と合わせるために、 @tsconfig/svelte を extends します。

{
  "extends": "@tsconfig/svelte/tsconfig.json"
}

.browserslistrc に サポートブラウザを書きます。

last 2 versions
ie 11

.browserslistrc にビルド上限を書きます。今回は 10kb としておきます。

{
  "files": [
    {
      "path": "./dist/index.js",
      "maxSize": "10 kB"
    }
  ]
}

rollup でビルドする設定のために、 rollup.config.js を書きます。

import svelte from "rollup-plugin-svelte";
import sveltePreprocess from "svelte-preprocess";
import resolve from "rollup-plugin-node-resolve";
import ts from "@wessberg/rollup-plugin-ts";
import { terser } from "rollup-plugin-terser";
import analyze from "rollup-plugin-analyzer";

export default {
  plugins: [
    svelte({
      preprocess: sveltePreprocess({
        postcss: {
          plugins: [
            require("autoprefixer")({
              grid: "autoplace",
            }),
          ],
        },
      }),
      emitCss: false,
    }),
    ts({
      transpiler: "babel",
      babelConfig: {
        presets: [
          "@babel/typescript",
          ["@babel/env", { modules: false, loose: true }],
        ],
      },
      tsconfig: "tsconfig.json",
    }),
    resolve(),
    !!process.env.ANALYZE &&
      analyze({
        summaryOnly: true,
      }),
    process.env.NODE_ENV === "production" && terser(),
  ],
  input: "src/index.ts",
  output: {
    dir: "dist",
    sourcemap: true,
    format: "umd",
    name: "app",
  },
};

簡単な Svelte のコードを置いて動作チェックしてみましょう。

src/App.svelte

<script lang="ts">
  const message = "hello";
</script>
<div>{message}</div>

src/index.ts

import App from "./App.svelte";

new App({
  target: document.querySelector("#root"),
});

これは、 <div id="root"> の要素に対して、 App.svelte をマウントするコードです。

terser を有効にしてビルドします

$ NODE_ENV=production npx rollup -c

生成物の確認と Lint

サーバーを建てるのが面倒だったので、今回は直接開く html を配置しました。 dist/index.html に次の HTML を配置します。

<html>
  <body>
    <div id="root"></div>
    <script src="index.js"></script>
  </body>
</html>

Mac なら次の環境で規定のブラウザで開いてプレビューできます。

$ open dist/index.html

動きましたか?駄目だったら GitHub のリポジトリを clone して動かしてみてください。

dist に吐かれた生成物のサイズを確認してみましょう。

$ la # ls -al
total 136
drwxr-xr-x   4 mizchi  staff   128B Dec 21 13:32 .
drwxr-xr-x  12 mizchi  staff   384B Dec 21 14:30 ..
-rw-r--r--   1 mizchi  staff   3.1K Dec 21 14:31 index.js
-rw-r--r--   1 mizchi  staff    61K Dec 21 14:31 index.js.map

es-check を使って、生成物が es5 (IE で動く水準) になっているか確認します。

$ npx es-check es5 ./dist/index.js
ES-Check: there were no ES version matching errors!  🎉

パスしました。

svelte-check を実行

$ npx svelte-check

Loading svelte-check in workspace: /Users/mizchi/gh/github.com/mizchi/svelte-starter/dist
Getting Svelte diagnostics...
====================================

====================================
svelte-check found 0 errors, 0 warnings and 0 hints

この時点では、通ってます。

npm scripts にこれらのタスクを追加します。

  "scripts": {
    "build": "NODE_ENV=production rollup -c",
    "watch": "rollup -c -w",
    "analyze": "NODE_ENV=production ANALYZE=true rollup -c",
    "test": "npm run test:lint && npm run test:build",
    "test:lint": "svelte-check",
    "test:build": "npm run build && es-check es5 dist/index.js && bundlesize"
  },

npm run analyze でビルドサイズの構成要素を検証してみましょう

-----------------------------
Rollup File Analysis
-----------------------------
bundle size:    7.874 KB
original size:  55.373 KB
code reduction: 85.78 %
module count:   5

/node_modules/svelte/internal/index.mjs
██████████████████████████████████████████░░░░░░░░ 85.71 % (6.749 KB)
/src/App.svelte
████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 8.94 % (704 Bytes)
/node_modules/@babel/runtime/helpers/esm/inheritsLoose.js
█░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 2.4 % (189 Bytes)
/node_modules/@babel/runtime/helpers/esm/assertThisInitialized.js
█░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 2.25 % (177 Bytes)
/src/index.ts
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0.7 % (55 Bytes)

created dist in 2.1s

rollup は、ランタイムサイズが少ない、というのが長所だと聞いていましたが、この時点ではさすがにランタイムの svelte/internal がビルドサイズのほとんどを閉めています

Svelte テンプレートの書き方(と内部実装をみてみる)

サンプルコードとして使った App.svelte を解説します。

<script lang="ts">
  const message = "hello";
</script>
<div>{message}</div>
  • script lang=ts が TypeScript を有効にする構文です。
  • Vue と違って <template> がなく、普通の html に近いです。
  • script のスコープの変数を、 {message} という構文で参照できます。

イベントハンドラを書いてみましょう。

<script lang="ts">
  let counter = 0;
  const onClick = () => ++counter;
</script>

<button on:click={onClick}>counter: {counter}</button>

こうなります。

  • let counter = 0; で宣言された counter 変数が書き換わると、 HTML が更新される
  • イベントハンドラは on:click={onClick} で登録する

この時点で npm run build すると、3.4k です。ちょっとだけコードが増えていますね。

ここで、npx rollup -c で terser を使ってない未圧縮のコードを覗いてみます。どうなっているでしょうか。

onClick が svelte コンパイラによって次のように書き換えられています。

var onClick = function onClick() {
  return $$invalidate(0, ++counter);
};

そして、テンプレート部分を svelte コンパイラが展開した結果がこちら。

function create_fragment(ctx) {
  var button;
  var t0;
  var t1;
  var mounted;
  var dispose;
  return {
    c: function c() {
      button = element("button");
      t0 = text("counter: ");
      t1 = text(
        /*counter*/
        ctx[0]
      );
    },
    m: function m(target, anchor) {
      insert(target, button, anchor);
      append(button, t0);
      append(button, t1);

      if (!mounted) {
        dispose = listen(
          button,
          "click",
          /*onClick*/
          ctx[1]
        );
        mounted = true;
      }
    },
    p: function p(ctx, _ref) {
      var dirty = _ref[0];
      if (
        dirty &
        /*counter*/
        1
      )
        set_data(
          t1,
          /*counter*/
          ctx[0]
        );
    },
    i: noop,
    o: noop,
    d: function d(detaching) {
      if (detaching) detach(button);
      mounted = false;
      dispose();
    },
  };
}

まだ、雰囲気で読んでますが、 $$invalidate() で変更が通知されて、実際の DOM が更新される、みたいな感じですね。

React のような Reconcilation があるという感じよりかは、 Angular の dirty check に近い実装に見えます。

props: テンプレートの外側から値を渡す

中身を察したので、次に行きましょう。 new App(...) する際に、テンプレートの外側から値を渡してみます。

src/App.svelte で値を宣言せずに、 export を付けます。

<script lang="ts">
  export let counter;
  const onClick = () => ++counter;
</script>

<button on:click={onClick}>counter: {counter}</button>

src/index.ts で、props: { counter: 5 } として値を渡します。

import App from "./App.svelte";

new App({
  target: document.querySelector("#root"),
  props: {
    counter: 5,
  },
});

export let counter; で 外側に対して参照を公開し、 props プロパティはその値に対して値をしています。

この結果、 counter の初期値が 5 になります。

現状の Svelte の残念な点として、この props に対して TypeScript の型チェックが効きません。ここは我慢して使っています。

computed property

Svelte には Vue のような computed property があります。

App.svelte$: doubled = counte * 2 を追加して、それを参照するコードを書いてみます。

<script lang="ts">
  export let counter;
  const onClick = () => ++counter;

  $: doubled = counter * 2;
</script>

<button on:click={onClick}>counter: {counter}</button>
<span>doubled: {doubled}</span>

これは、JavaScript としては一応適切な構文で、ラベル構文というのを使っています。switch の中で使ってるやつです。厳密には、同じスコープで 2 つ以上同じラベルを使うと構文エラーなのですが、 svelte はそこをコンパイル時に書き換えるという前提の元、許容しています。

(Vue でこの文法を真似しようとしたけど、無理だった、みたいな Issue が)

export let counter;
const onClick = () => ++counter;

$: doubled = counter * 2;
$: if (counter === 15) {
  alert(`fizzbuzz`);
}

15 のときだけ fizzbuzz という alert を出します。

if

<script lang="ts">
  const value = Math.random();
</script>

{#if value > 0.5}big{:else}small{/if}

{#if} - {:each} - {/if} で条件分岐が書けます。

このコードでは、リロードするたびに値が変わります。

each

繰り返しの構文です

<script lang="ts">
  const items = [0, 1, 2, 3];
</script>

{#each items as item}
  <div>{item}</div>
{/each}
<div>0</div>
<div>1</div>
<div>2</div>
<div>3</div>

と出力されます

コンポーネントの分割

React や Vue の components: {...} と同じように、Component から別の Component を呼ぶことができます。

新しく svelte ファイルを作って、先程の exportprops と同じ要領で値を渡します。

src/Greeting.svelte

<script lang="ts">
  export let name;
</script>

<div>Hello, {name}</div>

これを src/App.svelte から呼んでみます。

<script lang="ts">
  import Greeting from "./Greeting.svelte";
</script>

<Greeting name="John" />

CSS

style 属性で、 css を記述します。

<style>
  .red {
    color: red;
  }
</style>
<div class="red">text</div>

デフォルトで scoped なセレクタに変化されるので、他の component に影響しません。

このときの HTML を見てみるとこんな感じになっています。

<html>
  <head>
    <style id="svelte-1wj5erj-style">
      .red.svelte-1wj5erj {
        color: red;
      }
    </style>
  </head>
  <body>
    <div id="root"><div class="red svelte-1wj5erj">text</div></div>
    <script src="index.js"></script>
  </body>
</html>

嬉しいのがデッドコード検知で、未使用セレクタを警告してくれます。

$ npx svelte-check

Loading svelte-check in workspace: /Users/mizchi/gh/github.com/mizchi/svelte-starter
Getting Svelte diagnostics...
====================================

/Users/mizchi/gh/github.com/mizchi/svelte-starter/src/App.svelte:5:3
Warn: Unused CSS selector ".unused" (svelte)
  }
  .unused {
  }


/Users/mizchi/gh/github.com/mizchi/svelte-starter/src/App.svelte:5:3
Warn: Do not use empty rulesets (css)
  }
  .unused {
  }


====================================
svelte-check found 0 errors, 2 warnings and 0 hints

ただ、次が残念感ある部分なんですが、 script から style に値を受け渡すところが、公式ドキュメントでは CSS Variables と使えとなっていて、ここがなんとも言えない感じになります。

<script lang="ts">
  const themeColor = "blue";
</script>

<style>
  .text {
    color: var(--theme-color);
  }
</style>

<div style="--theme-color: {themeColor}"><span class="text">text</span></div>

https://svelte.dev/repl/4b1c649bc75f44eb9142dadc0322eccd?version=3.6.7

これはなんとかなってほしいですね…

svelte-check の a11y

css のデッドコード検知を見たついでに svelte-check に組み込まれている a11y の lint を見てみましょう。

src/App.svelte にあえて a11y 的に良くないコードを書いてみます。

<script lang="ts">
  const onClick = () => {};
</script>

<img src="/cat.png" />
<a on:click="{onClick}">aaaa</a>

svelte-check を実行してみます。

$ npx svelte-check                                                                             1s 951ms

Loading svelte-check in workspace: /Users/mizchi/gh/github.com/mizchi/svelte-starter
Getting Svelte diagnostics...
====================================

/Users/mizchi/gh/github.com/mizchi/svelte-starter/src/App.svelte:5:1
Warn: A11y: <img> element should have an alt attribute (svelte)

<img src="/cat.png" />
<a on:click={onClick}>aaaa</a>


/Users/mizchi/gh/github.com/mizchi/svelte-starter/src/App.svelte:6:1
Warn: A11y: <a> element should have an href attribute (svelte)
<img src="/cat.png" />
<a on:click={onClick}>aaaa</a>


====================================
svelte-check found 0 errors, 2 warnings and 0 hints

a11y は意識しないとすぐ悪いコードを書かれてしまうのですが、CI に svelte-check を挟んでおくと検知されて便利です。

おわり

svelte-check と typescript は自分が拡張した部分ですが、今みたいな部分を Introduction / Basics • Svelte Tutorial で学べます。チュートリアルも良くできています。 Reactive assignments • Svelte Examples

正直なところ、svelte/internal のサイズを見た時点では、 React が得意な自分は preact でいいじゃん、とも思いました。ただ、 svelte-check による各種デッドコード検知は、 (p)react 環境で作るのは結構だるいので、 svelte ならではのメリットではあります。

また、 vue と雰囲気が似ているので、Vue ユーザーの心理的な障壁を下げつつ、 3rd party script を書く、という状況で使えそうです。

極限環境と銘打ちましたが、本当に極限環境なら DOM API を触って実装します。ある程度のプロダクティビティを担保して、かつ 15kb ぐらいに抑える、という選択肢として、Svelte が有用だと思います。

参考:

Discussion

Yuku KotaniYuku Kotani

勉強になりました & typo ぽいのを見つけました

-rollup は、ランタイムサイズが少ない、というのが長所だと聞いていましたが、この時点ではさすがにランタイムの svelte/internal がビルドサイズのほとんどを閉めています
+svelte は、ランタイムサイズが少ない、というのが長所だと聞いていましたが、この時点ではさすがにランタイムの svelte/internal がビルドサイズのほとんどを占めています