Closed10

Svelte + TypeScript のベストプラクティスを考える

自分で Svelte + TypeScript を色々と書いてみたが、情報がまとまってなかったので、ここでまとめていく。

なぜ Svelte + TypeScript か

Svelte + TypeScript はセマンティクスが単純で型が付く軽量な Vue として気に入っている。ビルドが軽量で他と混ぜやすいのが特に気に入っていて、React や Vue の他のシステムに対しても、末端のコンポーネントとして混ぜやすい。Vue は歴史的経緯でデータバインディングの仕様が混沌としているが、Svelte はESM First で構文解析時の処理に仕様を寄せてるので、とてもシンプル。

webcomponents として配布するモードがあるのも気に入っている。Vue React は単体のビルドサイズが大きすぎて webcomponents の末端にするのは難しい。

やりたいこと

<script lang="ts">
  export let text: string;
</script>

<div>{text}</div>

このコードに

  • バンドル時にコンパイルする
  • typescript の型チェックを入れる
  • props の型の付与
  • dispatch の型
  • eslint を通す
  • ビルドして配布時に型情報を残す

余談: なぜ Svelte か

自分はReact が得意で、Vue をメインとする会社でいくつか React でプロトタイプを使ってみたが、 やはり Vue に慣れた人や、 Markup をメインとする得意な人に受けない印象を受けた。その再たる理由が JSX で、JSの構文の制御下にあるので、マークアップではなく、コードを書くという発想が最初に来ると、コードに対する不安感からか JSX が受け付けなくなる。あと単に CSS in JS ではない場合に多用する class が className になってるのが、タイプ量が増えてよくない。React の設計の失敗だと思う。

JSが制御の主体となった今でも、実際にはまだ ロジックとマークアップの分業が多く、HTML/CSS と script を強く分割したいという要請がある。そのための Vue SFC であり、Svelte のテンプレート構文がある。

Svelte の書き味は完全に Better Vue で、今後 React を Svelte が置き換えることはないが、Svelte と Vue は完全にかち合っていく、という印象を受けた。

どれぐらい 型が付くか。

型の付く度合いとしては、 React > Vue(TSX) > Svelte(Preprocess) > Vue(SFC) という感じ。セマンティクスが比較的よく練られていて、必要十分な型が付く。とはいえどうしてもSvelte + TS が表現できないものある。自分が書くものというより、デザインシステムの一部として書いてもらって統合するイメージを持っている。その際、統合を前提に、型情報は前提としたい。

React JSX で自然に書けて、Vue / Svelte で書きづらいパターンとして、.vue や .svelte が必ず一つのコンポーネントになってしまうので、React のように小さいコンポーネントをその場で定義できない問題がある。複雑なコンポーネントを注入したい際に、ファイルの分割単位で見通しが悪くなり、かなり不利と感じた。例えばなんらかの AST を UI として表現する時、自己再帰で nodeType で分岐してポリモーフィックにすることはできるが、特定のnodeTypeに依存するロジック層が一つに混ざってしまう、という問題が起きた。

ビルド設定: vite / rollup

TS の設定だけだとまだ動かない。 svelte-preprocess で、 script lang=ts を処理させる。

svelte を扱う場合、 webpack ではなく、 rollup / vite を使うことを推奨されると思う。 Evan You の作った vite は rollup ベースで、その rollup の作者 Rich Harris は svelte の作者でもある。 Rich Harris が今作っている sveltekit(next風svelte) も vite を採用していて、 rollup 側のがサポートが手厚い。 (webpack plugin もちゃんとあるが)。というわけで、今後 Vue / Svelte は Vite が主流になると思われる。

vite.config.js / rollup.config.js に svelte-preprocess を咬ませる。これはデフォルトで typescript, postcss, a11y linter が入っている。

npm install rollup svelte-preprocess 

rollup.config.js

import preprocess from "svelte-preprocess";
import svelte from "rollup-plugin-svelte";

module.exports = {
  plugins: [
    svelte({
      emitCss: false,
      preprocess: preprocess()
    })
  ]
}

vite.config.js

const svelte = require("rollup-plugin-svelte");
const preprocess = require("svelte-preprocess");

module.exports = {
  plugins: [
    svelte({
      emitCss: false,
      preprocess: preprocess(),
    }),
  ],
};

(vite の細かい設定は略. Getting Started | Vite を読んでください)

emitCss はビルド時に CSS を別ファイルとして取り出して処理したい時の設定で、SSRしたい時等の設定。最初はオフでいい。

vscode でもエラー警告が出るが、 CI 等で型チェックする svelte-check を入れる。

$ npm install svelte-check --save-dev
$ npx svelte-check

型や a11y 情報が lint される。

あとで書く: HMR

svelte + vite or rollup で HMR を有効にするためのプラグイン郡があるが、まだ experimental らしく自分は試していない。

https://github.com/rixo/rollup-plugin-hot
https://github.com/rixo/svelte-hmr
https://github.com/rixo/rollup-plugin-svelte-hot

TODO: named slot の型について書く

コードへの型の付け方

これらの設定を前提として、 svelte に型をつけていく。

props に型を付ける

export let

<!-- Foo.svelte -->
<script lang="ts">
  export let foo: string;
  export let bar: string = ""; // optional

</script>

デフォルト引数がある場合、オプショナルになる。

呼び出し側ではこうなる。

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

<!-- ここに型が付く -->
<Foo foo="hello" /> 

イベントに型を付ける

svelte では on:click のように、 on を prefix にしてイベントリスナを登録する。

React や Vue とは違って、 CustomEvent という Web 標準におけるユーザー定義イベントとして Event を定義する。

<!-- Foo.svelte -->
<script lang="ts">
  import {createEventDispatcher} from "svelte";
  const dispatch = createEventDispatcher<{ foo: number }>();
  const onClick = (_ev: MouseEvent) => {
    dispatch('foo', 1);
  }
</script>

<button on:click={onClick}>

このコンポーネントは svelte-check によって、 foo でカスタム detail の CustomEvent<number> を持つものとして推論される。

これは次のように呼び出せる。

<!-- Foo.svelte -->
<script lang="ts">
  import Foo from "./Foo.svelte";
  const onFoo = (ev: CustomEvent<number>) => {
     console.log("num", ev.detail);
  };
</script>

<!-- onFoo の型が通る -->
<Foo on:foo={onFoo}>

setContext と context=module

svelte は export フィールドが親からの props の受付として使われており、また default も Component を返却するために使われている。

それ以外に、コンポーネント以外を export したい時にどうしたらいいか。それが context=module で、新しく script block を追加する。

この用途として、Svelte には Context という機能があって、 React Context のように特定の親に依存したデータの伝搬ができる。これを一つのComponent で表現する時に、context=module を使うのが便利だった。

<!-- Foo.svelte -->
<script lang="ts" context="module">
   import { getContext } from "svelte";

   export type FooContextData = {
     foo: number;
   };

   const MARKER = "foo-marker";
   export getFooContext  = () => getContext<FooContextData>(MARKER);
</script>

<script lang="ts">
   import { setContext } from "svelte";
   setContext(MARKER, { foo: 1 });
</script>

<slot />

使う側 (Provider)

App.svelte

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

<Foo>
  <Child />
</Foo>

Child.svelte (Context の User)

<script lang="ts">
  import { getFooContext } from "./Foo.svelte";
  const {foo} = getFooContext();
</script>
<div>{foo}</div>

配布時の設定

preprocess を含む、 svelte component を配布する時のベストプラクティスは、調べた限り、まだ定まっていない。

現時点では、 .svelte のソースコード自体を配布することが主流だが、preprocess をすると、使う側にも同じ設定の preprocess を要求してしまう。逆に、コンパイルして配布すると、コンパイルされた時のオプション次第で振る舞いが変わる (ssr generate, hydratable, web components) ので、ユースケース次第で詰むことがある。例えば sapper は svelte source を要求する。

自分は考えた結果、 lang=ts を含む svelte を(脱出ハッチとして)そのまま配布しつつ、型定義を自分で書いた。 SvelteComponentTyped を index.d.ts を自分で書いた。これは vscode svelte でも正しく型が付いた。

/* eslint-disable */
/// <reference types="svelte" />
import { SvelteComponentTyped } from "svelte";

export * from "./src/types";

export class Flex extends SvelteComponentTyped<
  {
    direction?: "row" | "column";
    width?: PixelValue | PercentValue | "auto";
    height?: PixelValue | PercentValue | "auto";
  },
  {
    "change-flex": { children: FlexChildren };
  },
  { default: {} }
> {}

そのときの rollup.config.js がこれ。

import svelte from "rollup-plugin-svelte";
import autoPreprocess from "svelte-preprocess";
import ts from "@wessberg/rollup-plugin-ts";
import cjs from "@rollup/plugin-commonjs";
import nodeResolve from "@rollup/plugin-node-resolve";
import pkg from "./package.json";

export default {
  input: "src/index.ts",
  external: ["svelte", "svelte/internal", "svelte/store"],
  plugins: [
    nodeResolve({
      include: "node_modules/**",
    }),
    cjs(),
    ts(),
    svelte({
      emitCss: false,
      compilerOptions: {
        hydratable: true,
      },
      preprocess: autoPreprocess({}),
    }),
  ],
  output: [
    {
      format: "es",
      file: pkg.module,
    },
  ],
};

package.json 配布設定は files: ["index.d.ts", "src", "dist"] みたいになる。

consistent-type-import のための eslint

rollup + svelte でハマりがちな問題として、rollup-plugin-svelte は存在しない import がエラーになる。これが色々と問題を起こす。

このコードは TypeScript としては通るが、実行時にエラーになる。

import { SvelteComponentTyped } from "svelte";

例えばこの SvelteComponentTyped はコード上の実体はなく、型としてしか存在しない。webpack は単にこれを無視しているので見た目上はそのまま動くのだが、本来なら型情報かどうかを typescript としての静的解析から取り出して、型の import なので 消すかどうかを判断しないといけない。依存が 1 ホップなら簡単だが、 export * from "./data" みたいなコードを書かれた瞬間に rollup に要求する機能が静的解析器相当になってしまう。rollup + ts はここまで対応してない。

なので、型の import に type import を要求する設定が、 @tsconfig/svelte に入っている。

    /** 
      Svelte Preprocess cannot figure out whether you have a value or a type, so tell TypeScript
      to enforce using `import type` instead of `import` for Types.
     */
    "importsNotUsedAsValues": "error",

しかし、これだけだとまだ問題が起きる。typescript はこれを通してしまう。

import { SvelteComponentTyped, onMount } from "svelte";

型だけなら import type を要求するが、 実体を持つ関数 onMount と混ざると、それを許容してしまう。

この問題を避けるために、 importimport type を分割する必要がある。

import type { SvelteComponentTyped } from "svelte";
import { onMount } from "svelte";

で、これを lint 的に要求する機能が typescript-eslint の consistent-type-imports になる。

"@typescript-eslint/consistent-type-imports": [
      "error",
      { prefer: "type-imports" },
    ],

自分はこのように設定した。(依存パッケージの install は略)

// .eslintrc.js
module.exports = {
  extends: [
    "eslint:recommended",
    "plugin:@typescript-eslint/recommended",
    "prettier",
  ],
  parser: "@typescript-eslint/parser",
  parserOptions: {
    ecmaVersion: 2019,
    sourceType: "module",
    tsconfigRootDir: __dirname,
    project: ["./tsconfig.json"],
  },
  env: {
    es6: true,
    node: true,
    browser: true,
  },
  settings: {
    "svelte3/typescript": require("typescript"),
  },
  plugins: ["svelte3", "@typescript-eslint"],
  overrides: [
    {
      files: ["**/*.svelte"],
      processor: "svelte3/svelte3",
    },
  ],
  rules: {
    "@typescript-eslint/no-explicit-any": "off",
    "@typescript-eslint/ban-types": "off",
    "@typescript-eslint/ban-ts-comment": "off",
    "@typescript-eslint/no-non-null-assertion": "off",
    // for svelte
    "@typescript-eslint/consistent-type-imports": [
      "error",
      { prefer: "type-imports" },
    ],
  },
};

これは中身の話

どのようにSvelteに型が付いているか

svelte-js/langaugage-tools で、 lanugauge-server などが実装されている。

この中の svelte2tsx モジュールで svelte を補完するだけの tsx に変換して、その型情報を typescript に送り返している。

https://github.com/sveltejs/language-tools/tree/master/packages/svelte2tsx

サンプルによると、このような変換を行っている。

<script>
    export let world = 'name';
</script>

<h1>hello {world}</h1>
<></>;
function render() {
    let world = 'name';
    <>
        <h1>hello {world}</h1>
    </>;
    return { props: { world }, slots: {}, events: {} };
}

export default class _World_ extends createSvelte2TsxComponent(
    __sveltets_partial(__sveltets_with_any_event(render))
) {}

おそらく、これのせいだとは思うのだが、 script lang=ts 以外でも、 on:click={(ev: any) => console.log(ev)} というコードがテンプレート側で書けて、TypeScript として valid に動くが、コンパイルが通らない。これは実際の svelte-preprocess の範囲が script lang=ts のブロックだけであるため。

全く別のアプローチとして、 IBM の人が sveld というプロジェクトをやっている。これは svelte + jsdoc からよく定義されたTS用の型定義を生成する。

IBM/sveld: Generate TypeScript definitions for your Svelte components.

自分は jsdoc を書かないので見送ったが、sveld の人曰く、svelte のセマンティクスではちゃんと型がつかなくて不満があるとのこと。

以前ツイートされていたかと思うのですが、.svelte拡張子のファイルで.tsファイルからのimportができないという問題があったかと思います。

https://twitter.com/mizchi/status/1361931669620420609?s=21

自分も、共通化したい処理を.tsファイルに切り出してコンポーネント側から使おうとしたとき、同じ問題にぶつかって困りました(試したのは2ヶ月くらい前です)。
現在この問題は解決されているのでしょうか?

@Eringi_V3
当時の sveltekit は snowpack で、今は vite なんですが、vite はデフォルトでesbuild で ts をコンパイルしようとするので、この問題は起きてません。snowpack は ts => svelte は動いてたけど、なぜか svelte => ts が動きませんでした。理由は不明ですが snowpack の裏側で、拡張子ごとに include が決め打ちになってたんじゃないかと疑ってます。

素 の rollup の .ts の変換は svelte preprocess とは別に、自分で設定する必要があり、配布時の設定にある import ts from "@wessberg/rollup-plugin-ts" がこれに対応します。

contextの例がわかりやすくていいですね!
せっかくなので store の型付けの例も載せてもらえると嬉しいです。
イメージはこんな感じです。

// sample.ts
import { writable } from 'svelte/store';

export type FooData = {
     foo: number;
};

export const fooStore = writable<FooData>({foo: 1});
<script lang="ts">
	import { get } from "svelte/store";
	import type { FooData } from "./sample";
	import { fooStore } from "./sample";

	// $を使用するなら
	let foo1: FooData = $fooStore;

	// subscribeを使用するなら
	fooStore.subscribe( (value) => {
		let foo2: FooData = value;
	})

	// getを使用するなら
	let foo3: FooData = get(fooStore);

	// setを使用するなら
	fooStore.set({foo: 2});
</script>
このスクラップは2021/03/17にクローズされました
ログインするとコメントできます