🌲

Svelte テンプレートから React コンポーネント を生成するコンパイラを書いた (PoC)

2023/06/28に公開

欲しい物がなければ、自分で作るしかないシリーズ

https://github.com/mizchi/svelte2tsx-component

なぜ作ったか

.svelte のテンプレートは .tsx と違って JS/TS としてのプログラミング言語としての構文の影響下になく、プログラムをあまり書かないマークアップエンジニアとの連携に便利で気に入っています(リスト表示に .map で目を回すぐらいの人を想定しています)。シンプルな構文で TypeScript の型もつきやすく、IDE支援もそこそこで、 モダンな vue テンプレートという感じです。

しかしながら、 GitHub のオープンソースのトレンドを見ればわかるように、フロントエンドのエコシステムは基本的に jsx/tsx を中心に回っています。そして現代の View 層のライブラリは(WebComponents がライブラリ間のブリッジとして失敗しているので)同じライブラリ同士でしか接続できず、この現状に不満を持っていました。特に svelte は jsx ではない独自構文なので、 jsx エコシステムに乗ることができません。

しかし、 svelte のサブセットに絞れば react のコンポーネントに変換できるんじゃないか? と思い、途中まで作ってみたら結構動いたので紹介します。

使い方

$ npm install svelt2tsx-component -D

vite のプラグインを作ったので、こういう感じで使えます(簡単なパターンしか試してないです)

// vite.config.ts
import { defineConfig } from "vite";
import { plugin as svelteToTsx } from "svelte-to-tsx";
import ts from "typescript";

// svelte の plugin は使わない
export default defineConfig({
  plugins: [svelteToTsx({
    extensions: [".svelte"],
    tsCompilerOptions: {
      module: ts.ModuleKind.ESNext,
      target: ts.ScriptTarget.ESNext,
      jsx: ts.JsxEmit.ReactJSX,
    }
  })],
});

文字列からコード変換をする場合

import { svelteToTsx } from "./svelte2tsx-component";

const newCode = svelteToTsx(`<your template>`);

実現したこと

こういうキメラが動きます。

src/App.svelte
<script lang="ts">
  import { createEventDispatcher } from "svelte";
  export let name: string;
  const dispatch = createEventDispatcher<{
    message: {
      data: string;
    };
  }>();
  let counter = 0;

  const onClickMessage = () => {
    dispatch("message", {
      data: "hello",
    });
  };

  const onClickCounter = () => {
    counter = counter + 1;
  };

</script>

<div>
  <span class="red">
    Hello, {name}!
  </span>
  <div>
    <button on:click={onClickMessage}>Message</button>
  </div>

  <div>
    Counter: {counter}
    <button on:click={onClickCounter}>++</button>
  </div>

</div>

<style>
  .red {
    color: red;
  }
</style>

呼び出し側

src/index.tsx
import React from "react";
import App from "./App.svelte";
import { createRoot } from "react-dom/client";

const root = document.getElementById("root")!;

createRoot(root).render(<App
  name="svelte-app"
  onMessage={(data) => {
    console.log("message received", data)
  }
} />);

呼んでるのは ./App.svelte なのに React Component として render されています。 Svelte のコンポーネント同士でも使えますし(実体が React Component 同士なので)、内部の dispatch イベントもイベントハンドラに変換されます。

主に末端コンポーネントを svelte で書いてから、 React として import することを意図しています。

動いてるリポジトリはここ

https://github.com/mizchi/svelte2tsx-component-example

何をやっているか

  • svelte の中間AST から TypeScript AST に変換する過程で props の型を抽出
  • HTML テンプレート部分を JSX(TSX) に変換
  • onMountbeforeUpdate などは対応する React hooks のイディオムに変換
  • style ブロックと class からの参照は emotion によるセレクタに変換
<script lang="ts">
  export let foo: number; // foo: number 
  export let bar: number = 1; // bar?: number
</script>
<div>
  hello
</div>
<style>
  .red {
    color: red;
  }
</style>
import { css } from "@emotion/css";
export default ({foo, bar = 1}: { foo: number, bar?: number }) => {
  return <>
    <div className={selector$red}>hello</div>
  </>
}

const selector$red = css`
  color: red;
`;

やってみた感想

単純なコンポーネントなら全然動くので、適切なサポート範囲を明示したガイドさえあれば全然実用できそうな気がしてます。なんなら互換レイヤーを独自構文として勝手に定義してしまったほうがいい気がしてます。svelte エコシステムに相乗りしつつ、 lint された範囲で動く、みたいな感じで。

とはいえ、まだまだ全然サボっていて、やればやるほど svelte の知らない仕様が出てくるので、かなり勉強になりました。これをやる場合、 https://svelte.jp/docs を熟読することになります。

たぶん、エンジニアとマークアップの分業という観点だと、複雑な Context 関係のデータプロバイダを実装する必要はないはずで、代わりに .svelte テンプレートで嬉しいのは svelte/motionsvelte/animation の組み込みのアニメーションライブラリのサポートが手厚いことだと思っています。 これらのサポートや CSS の記述の自由度を上げるのが、次の目標になると考えています。

...しかし利用者として ComponentProps の型はほしい、という気持ちで作っていたので、ややチグハグになってる感じはあります。

作り始めた動機が、本気で使い物になるものを作るというより、react と svelte を両方使っていてこれなら変換できるはず? という思いつきと腕試しだったのがあるんですが、意外とうまくいったので、もう少し作り込むことも考えています(ここまで3日ぐらい)。

(あるいは、誰かやってくれないかなぁ...)

未実装の心残り

bind:property をどう実装するか

svelte と react でコンポーネントを実装するときの、最大のマインドセットの違いは、 React では親から子に props として値を渡した時、その値の変化を別のコールバックとして実装する必要があるのに対して、 svelte は bind で props の変化を共有できます。

例えば React の場合...

const onChangeValue = (newVal: number) => {
  console.log(newVale)
}
<Child value={value} onChangeValue={onChangeValue}>

これでコールバックで子の変化を明示的に通知するコードを実装します。よく書くコードだと思います。

Svelte の場合、親コンポーネントから子コンポーネントに bind すると...

<script>
	import Child from "./Child.svelte";
	let value = 0;
</script>

{value}
<Child bind:value={value} />

子コンポーネントからも value を書き換えられます。

<script>
	export let value;
	const onClick = () => value += 1;
</script>

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

https://svelte.dev/repl/11cf406d8f204b5390d922712237c785?version=4.0.0

現状、スコープに閉じた let 宣言は React hooks の useState() に変換しているんですが、 bind される場合に備えて onChange<Property> コールバックを生やすことを検討しています。とはいえ <Foo let:value> みたいな let directive 構文もあって一筋縄ではいかないです。

https://svelte.jp/docs#template-syntax-slot-slot-key-value

CSS 生成パターンの再考

現状, <style> ブロックでは .red {} みたいな単純なクラスセレクターだけサポートしていて .foo .bar {} みたいな複数セレクタをサポートできていません。ここが実用上のネックだと思っています。とはいえ、 <style> ブロックで未宣言のセレクタ名を参照する場合、変換せずにそのまま出力するので例えば tailwind と組み合わせる場合は問題ありません。

親子セレクタを実装するには、テンプレート内でセレクタを参照する親子間の関係を解析しないといけず、この実装が難しいのがわかっています。そもそも svelte の CSS はデフォルトで scoped なセレクタに変換されるので、セレクタ詳細度を気にするより個別の要素に1:1に対応するルールセットに指定するはず、と思ってサボっています。

でも追加で疑似クラスぐらいはサポートしたいですね。

Event Delegation の PropsType

svelte でカスタムイベントを定義した時、コールバックとして値を渡さない場合、そのまま親に dispatch されます。

<Foo on:mycustomevent>

これに型をつけたかったんですが、この mycustomevent の型シグネチャを知るには、定義が外にあるものを自分自身の型として再定義する必要があって、先に呼び出し先のコンポーネントの型定義を知っておく必要があります。つまりはモジュールの参照グラフを管理する必要が発生します。

実装の優先順位として複数ファイルにまたがる操作は後回しになっています。 bind:property が後回しなのも同じ理由。

サポートした構文

(作りながらメモったリスト)

  • Module: <script context=module>
  • Props Type: export let foo: number to {foo}: {foo: number}
  • Props Type: export let bar: number = 1 to {bar = 1}: {bar?: number}
  • svelte: onMount(() => ...) => useEffect(() => ..., [])
  • svelte: onDestroy(() => ...) => useEffect(() => { return () => ... }, [])
  • svelte: dispatch('foo', data) => onFoo?.(data)
  • svelte: beforeUpdate() => useEffect
  • svelte: afterUpdate() => useEffect (omit first change)
  • Let: let x = 1 => const [x, set$x] = setState(1)
  • Let: x = 1 => set$x(1);
  • Computed: $: added = v + 1;
  • Computed: $: document.title = title => useEffect(() => {document.title = title}, [title])
  • Computed: $: { document.title = title } => useEffect(() => {document.title = title}, [title])
  • Computed: $: <expr-or-block> => useEffect()
  • Template: <div>1</div> to <><div>1</div></>
  • Template: <div id="x"></div> to <><div id="x"></div></>
  • Template: <div id={v}></div> to <><div id={v}></div></>
  • Template: <div on:click={onClick}></div> to <div onClick={onClick}></div>
  • Template: {#if ...}
  • Template: {:else if ...}
  • Template: {/else}
  • Template: {#each items as item}
  • Template: {#each items as item, idx}
  • Template: {#key <expr>}
  • Template: with key {#each items as item (item.id)}
  • Template: Shorthand assignment {id}
  • Template: Spread {...v}
  • SpecialTag: RawMustacheTag {@html <expr}
  • SpecialTag: DebugTag {@debug "message"}
  • SpecialElements: default slot: <slot>
  • SpecialElements: <svelte:self>
  • SpecialElements: <svelte:component this={currentSelection.component} foo={bar} />
  • Template: attribute name converter like class => className, on:click => onClick
  • Style: <style> tag to @emotion/css
  • Style: option for import {css} from "..." importer
  • Plugin: transparent svelte to react loader for rollup or vite
  • Inline style property: <div style="..."> to <div style={{}}>

未サポート

  • Template: Await block {#await <expr>}
  • Computed: $: ({ name } = person)
  • Directive: <div contenteditable="true" bind:innerHTML={html}>
  • Directive: <img bind:naturalWidth bind:naturalHeight></img>
  • Directive: <div bind:this={element}>
  • Directive: class:name
  • Directive: style:property
  • Directive: use:action
  • SpecialElements: <svelte:window />
  • SpecialElements: <svelte:document />
  • SpecialElements: <svelte:body />
  • SpecialElements: <svelte:element this={expr} />
  • SpecialTag: ConstTag {@const v = 1}
  • Directive: <div on:click|preventDefault={onClick}></div>
  • Directive: <span bind:prop={}>
  • Directive: <Foo let:xxx>
  • Directive: event delegation <Foo on:trigger>
  • SpecialElements: <svelte:fragment>
  • SpecialElements: named slots: <slot name="...">
  • SpecialElements: $$slots
  • Generator: .d.ts (<name>.svelte with <name>.svelte.d.ts)
  • Generator: preact
  • Generator: qwik
  • Generator: solid
  • Generator: vue-tsx

おわり

PR 待ってます。やる気ある人いたら勝手に fork して実装してくれても構いません。

https://github.com/mizchi/svelte2tsx-component

Discussion