Svelte — 私がこんな風にあなたに会ったきっかけは

作成者:カランカラン
💡

質問やフィードバックがありましたら、フォームからお願いします

本文は台湾華語で、ChatGPT で翻訳している記事なので、不確かな部分や間違いがあるかもしれません。ご了承ください

初めてSvelteを見たとき、「うーん…また新しいフロントエンドフレームワークなのかな?」と思ってあまり気に留めていませんでした。しかし、後にブログ記事や多くのウェブサイトでの利用を目にするようになり、私の好奇心が引き起こされました。

公式サイトのチュートリアルやドキュメントを見たところ、一般的な「フロントエンドフレームワーク」とは異なり、構文レベルで取り組み、コンパイラを使ってシンプルで効率的なコードを書く手法だと気づきました。

Svelte

私を惹きつけた一文があります:

Svelte compiles your code to tiny, framework-less vanilla JS — your app starts fast and stays fast.

実際に書いてみて、Reactに慣れ親しんでいる私としては、一部の実装に戸惑うところもありましたが、全体として非常に気に入っていますし、他のフレームワーク(ReactやVueなど)とは全く異なるアプローチです。

皆さんにも、Svelteの作者がYGLFで行った講演「Rethinking reactivity」をぜひ見ていただきたいです。この講演では、フロントエンドフレームワークの本質について再考し、私たちができることを考えさせられます。以下はこの講演に関する私のメモと考察です。

リアクティビティとは何か?

The essence of functional reactive programming is to specify the dynamic behavior of a value completely at the time of declaration — Heinrich Apfelmus

フロントエンドフレームワークを使用する際には、通常、次の2つのことを考慮します:

  • Virtual DOM — ページレンダリング時のパフォーマンスを確保
  • Reactivity — 値の変化を追跡

フロントエンドフレームワークの最も重要な部分は、データフローとデータ変化の追跡です。

Virtual DOMを使用する理由

主な理由は、データ更新によって画面全体を再描画することがパフォーマンスに大きな影響を与えるためです。そのため、Virtual DOMは通常、効率的なdiffアルゴリズムを実装しており、画面更新時に必要な変更だけを再描画することを保証します。

しかし問題が生じます。安定したdiffアルゴリズムと更新メカニズムを実装するには、大きな労力が必要であり、パフォーマンスの考慮も必要です。そうしないと、ツリーの深さが過剰になるとパフォーマンスのボトルネックを引き起こします。

Reactivity

Reactは、データの更新(変化)を追跡するために、setStateuseStateを使用しています。一方、VueはProxyを採用し、開発者が値にアクセスする際にReactivityメカニズムをトリガーしてデータの変化を追跡します。

Reactでは、useStatethis.setStateを使用して、Reactが値の変化を感知できるようにしています。さらに、余分な更新が行われないようにするためのメカニズムもあります(バッチ更新)。以下のコードをご覧ください:

const Counter = () => {
  const [counter, setCounter] = useState(0);
	const handleClick = () => {
    setCounter(c => c + 1)
  }
	return <div onClick={handleClick}>{counter}</div>
}

コンポーネントが更新されるたびに、useStatehandleClick関数が再評価され、React内部ではこれらのstate変化を保存し、適切な更新が行われます。

この問題を解決するために、useMemoやさまざまな最適化手法が登場しました:

  • shouldComponentUpdate
  • React.PureComponent
  • useMemo
  • useCallback

これらのメカニズムの実装には非常に大きな労力がかかりますが、画面更新時に全てを再レンダリングせず、Virtual DOMのパフォーマンスを確保するために、開発者に最適化の選択肢を提供します。これら二つを組み合わせることで、reactおよびreact-domのバンドルサイズが非常に大きくなります。

さらに、作者自身が公式ブログでVirtual DOM is pure overheadという記事を公開し、Virtual DOMの利点と欠点について説明しています。記事の最後からの引用:

It's important to understand that virtual DOMisn't a feature. It's a means to an end, the end being declarative, state-driven UI development. Virtual DOM is valuable because it allows you to build apps without thinking about state transitions, with performance that isgenerally good enough. That means less buggy code, and more time spent on creative tasks instead of tedious ones.

But it turns out that we can achieve a similar programming model without using virtual DOM — and that's where Svelte comes in.

また、記事の中でのTwitterのツイートも興味深い視点でした:

Virtual DOMのメカニズムは、フレームワークが状態管理を気にせずに済むようにするためのものですが、なぜこのようなメカニズムがプラットフォームによって提供されないのかというのは興味深い視点です。ただし、実際に実装するにはどれほどの労力が必要かはわかりませんし、ブラウザが実装するとなると、クロスブラウザの問題や規格に直面することになり、進化のスピードはフレームワークそのものに比べて遅いかもしれません。

Svelte

それほど大きくはないものの、製品自身のコードを含めると、このバンドルサイズは少々驚くべきものです。

これらの考えをまとめると、現代のフロントエンドフレームワークの欠点は次の通りです:

  • ランタイムのリアクティビティ + Virtual DOM diffメカニズム
  • 大きなバンドルサイズ

もしかしたら、「バンドルサイズやパフォーマンスを気にする必要があるのか?」と思う人もいるかもしれません。

パフォーマンスを再考する

私自身も以前はそう考えていました。皆が使っているCPUはi5やi7が当たり前で、スマホはiPhone 11 Proやフラッグシップモデルばかり、ネットも無制限で本当に気にする必要があるのか?と思っていました。

最近、日本での生活や仕事から新たな考えが生まれました:

  • 台湾のように安価で無制限のネットが利用できるところは少なく、日本では通常、無制限プランは存在せず、ネットが限られた上に非常に高価です。これはLINE Mobileの携帯プランで、一般的なSNSはデータ量にカウントされませんが、YouTubeやNetflixの愛好者としては、やはり足りないと感じます。

  • 多くのユーザーが格安プランを利用しており、基地局の速度も安定していません。
  • 地下鉄に乗っているとき、ネットは明らかに遅くなり、台湾ほど安定していません。

これらの点を考慮すると、地下鉄に乗っているときにウェブページを開いたり、格安の不安定なネットを使用していると、速度の違いを実感します。私は、すべての国が台湾のように幸運にネット速度を享受できるわけではないことを深く理解しました。

また、Raspberry Piを使用しているときにも、低性能CPUがウェブページを表示する際に明らかな遅延を感じます。

IoTが急速に発展している時代において、誰もが強力なCPUやGPUを使ってウェブを利用しているわけではありません。この経験が、パフォーマンスやフレームワーク選択についての考えを促しました。Svelteもこの問題を抱えているのか?続きを見ていきましょう。

作者は19:15のところで、以前のDanのReactのタイムスライシングの例を引用し、Svelteで実装してみた結果、SvelteはAsyncモードやデバウンスを全く使用せず、パフォーマンスがReactを大きく上回ることが分かりました。

ネイティブAPIを受け入れる

チュートリアルドキュメントを見ていると、彼らのイベント伝達メカニズムは、実装の部分が完全にaddEventListenerCustomEventだけで構成されており、Synthetic Event(React)やEvent poolの概念は存在しません。全てがCustomEventです。

// https://github.com/sveltejs/svelte/blob/master/src/runtime/internal/dom.ts#L60-L63
export function listen(node: Node, event: string, handler: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions | EventListenerOptions) {
	node.addEventListener(event, handler, options);
	return () => node.removeEventListener(event, handler, options);
}

// https://github.com/sveltejs/svelte/blob/master/src/runtime/internal/dom.ts#L275-L279
export function custom_event<T=any>(type: string, detail?: T) {
	const e: CustomEvent<T> = document.createEvent('CustomEvent');
	e.initCustomEvent(type, false, false, detail);
	return e;
}

そのため、将来的にはさまざまなブラウザの違いによって引き起こされるバグや、フレームワーク内での最適化がSvelteでうまく解決されない可能性があり、それが開発者に委ねられることになります。例えば、Event DelegationReact Event Poolなど、良いか悪いかは使用シーンによるでしょう。

抵抗できない構文

私は、構文や言語の側面で問題を解決するのが好きです。これにより、開発者はuseEffect、useMemo、useCallbackの背後にある仕組みや理由を理解する時間を大幅に削減できます。

Svelteは、シンプルな構文(ほとんどがJavaScript)とテンプレート構文を提供し、コンパイラがコンパイルすることで、より低レベルのJavaScriptコードに変換されます。例えば:

<script>
  import { createEventDispatcher } from 'svelte';
  export let label;
	let a = 1;
	
	$: b = a * 2
  const dispatch = createEventDispatcher();
  function handleClick() {
    dispatch('toggle', {
      value
    });
  }
</script>

<button class="button" class:active on:click={handleClick}>{label}</button>

このSvelteのコードは、コンパイルされると以下のようになります:

import { createEventDispatcher } from "svelte";

function create_fragment(ctx) {
	let button;
	let t;
	let dispose;

	return {
		c() {
			button = element("button");
			t = text(/*label*/ ctx[0]);
			attr(button, "class", "button");
		},
		m(target, anchor, remount) {
			insert(target, button, anchor);
			append(button, t);
			if (remount) dispose();
			dispose = listen(button, "click", /*handleClick*/ ctx[1]);
		},
		p(ctx, [dirty]) {
			if (dirty & /*label*/ 1) set_data(t, /*label*/ ctx[0]);
		},
		i: noop,
		o: noop,
		d(detaching) {
			if (detaching) detach(button);
			dispose();
		}
	};
}

let a = 1;

function instance($$self, $$props, $$invalidate) {
	let { label } = $$props;
	const dispatch = createEventDispatcher();

	function handleClick() {
		dispatch("toggle", { value });
	}

	$$self.$set = $$props => {
		if ("label" in $$props) $$invalidate(0, label = $$props.label);
	};

	let b;
	$: b = a * 2;
	return [label, handleClick];
}

class App extends SvelteComponent {...}

ここでのelementとtext関数は、実際にはdocument.createElementelement.textContentのラッピングに過ぎず、Virtual DOMの概念はありません。

では、Svelteはどのようにリアクティビティのメカニズムを実現しているのでしょうか?

コンパイラができること

let a = ''を宣言する際、実際にはコンパイラがaが宣言されたことを認識します。そのため、a = a + 1のような操作を行うと、コンパイラはその後ろで$$invalidate('a', a + 1)のように書き換え、Svelteにこの値が変化したことを通知します。この時点でSvelteはこの変数をdirtyとしてマークし、更新をスケジュールします。画面上でaが使用されていなければ、Svelteは特にそれをコンパイルしないのです。たとえば:

<script>
	let name = 'world';
	
	function handleClick() {
		name += '?'
	}
</script>

<h1 on:click={handleClick}>Hello </h1>

function create_fragment(ctx) {
  //
}

function instance($$self) {
	let name = "world";

	function handleClick() {
		name += "?"; // << 注意、この行はtag内で使用されていないため、$$invalidateで置き換えられません
	}

	return [handleClick];
}
// ...

もしtag内で使用されていれば:

<script>
	let name = 'world';
	
	function handleClick() {
		name += '?'
	}
</script>
// 元のworldを変数nameで置き換え
<h1 on:click={handleClick}>Hello {name}</h1>

function create_fragment(ctx) {
	// ...
}

function instance($$self, $$props, $$invalidate) {
	let name = "world";

	function handleClick() {
		$$invalidate(0, name += "?"); // << $$invalidateで置き換え
	}

	return [name, handleClick];
}

class App extends SvelteComponent {
	constructor(options) {
		super();
		init(this, options, instance, create_fragment, safe_not_equal, {});
	}
}

export default App;

このようにして、不要な更新を避けることができ、コンパイル段階の利点の一つです。講演の中で次のように述べられています:

Compilers are the new frameworks

この文章では、コンパイラの力を通じて、ランタイムで実行されるコードを効果的に削減できるだけでなく、言語自体に縛られず、コンパイラにさらなる力を与えることができます。それはまるで静的言語のようです。

また、テンプレート構文も絶妙です。例えば:

<ul>
  {#each Object.values(list) as item}
    <li>{item.name}</li>
  {/each}
  {#if condition}
    <p>true!</p>
  {/if}
	<button on:click={handleClick}>click me</button>
</ul>

詳細は公式ドキュメントを参照できますが、これらの構文は非常にシンプルで、経験豊富な開発者なら短時間でこの一連の構文を習得できます。コンパイルにより、構文や静的検査も行うことができます。

一度試すと虜に

最近、私はウェブページを作成し、動物の森の魚類データを整理して検索しやすくしました。その機能には以下が含まれます:

  • 検索(純粋なフロントエンド)
  • テーブルのソート
  • 設定に基づいたテーブルのレンダリング

ただそれだけです。

(検索の動作が少し奇妙ですが、時間があるときに修正しますXD)

これは、純粋なJavaScriptで書くと時間がかかるかもしれませんが、Reactで書くにはオーバースペックすぎる小さなプロジェクトだったので、思い切ってSvelteを使用しました。結果には非常に満足しています。バンドル後のJavaScriptはすべて8.2KB(gzipped後)で、3Gネットワークでは160msで読み込むことができます。

それは魔法なのか?

Rethinking reactivityの17:11のところで、作者がこの視点に触れています。Evan(Vueの作者)は「これはJavaScriptではなく、SvelteScriptだ」と言っています。

実際、フレームワーク自体は多かれ少なかれ魔法をかけている(複雑な詳細を抽象化している)ものです。Reactは複雑な更新メカニズムを魔法に変え、Vueはテンプレート構文を魔法に包み込み、みんなが魔法を使っています。ReactやVueはランタイムでそれを行い、Svelteはその作業をコンパイラに任せています。

完全なストアとコンテキストのメカニズム

Svelteには、内蔵のストアがあり、簡単に使用できます。使い方の一例は以下の通りです。内蔵されているため、redux-xxxを探し回る必要もありません。

インタラクション

Svelteには、私が驚いた構文や実装がさらにたくさんあります。例えば、組み込みのトランジションやスプリング、フライ、フリップ機能があり、それに対応するディレクティブも用意されています。インタラクション(微アニメーション)を追加したい時は、どのライブラリを使用するか悩む必要がなく、Svelteから直接引用すれば良いのです。実際の書き方は以下のようになります:

// copied from https://svelte.dev/examples#tweened
<script>
	import { tweened } from 'svelte/motion';
	import { cubicOut } from 'svelte/easing';

	const progress = tweened(0, {
		duration: 400,
		easing: cubicOut
	});
</script>

<style>
	progress {
		display: block;
		width: 100%;
	}
</style>

<progress value={$progress}></progress>

書き方が非常に直感的で、Svelte内部のメカニズムは、開発者がDOM自体に対してより多くの操作を行うことを可能にします。

さらに、アニメーションで最も煩わしいのは中断メカニズムです。ユーザーがアニメーションの途中で操作を行った場合、特別な処理をしない限り、通常はトランジションが終了した後に再び実行され、ユーザーエクスペリエンスに大きな影響を与えます。

Svelteのトランジションは既に中断可能で、詳細はトランジションの章を参照してください。チェックボックスをクリックすると、fadeinとfadeoutアニメーションが実行されます。たとえユーザーが早くクリックしても、アニメーションは現在の進行状況から始まります。

ここで注意すべき点は、{#if visible}を使用してトランジションを行うことができ、Reactのようにコンポーネントをレンダリングし続ける必要がないことです。

Note that the transition is reversible — if you toggle the checkbox while the transition is ongoing, it transitions from the current point, rather than the beginning or the end.

これらの機能をReactで書き直す場合、react-transition-groupreact-springのようなライブラリを使用する必要があるかもしれませんが、Svelteではそれがライブラリに組み込まれており、別途パッケージをインストールする必要はありません。さらに、構文と結びついているため、書き方がより直感的です。

まとめ

ここまで多くの欠点を取り上げましたが、私はどのフレームワークも批判したいわけではなく、Svelteを持ち上げたいわけでもありません。Reactは間違いなくフロントエンドとウェブ開発の歴史を変えた存在であり、私もReact Hooksの概念が大好きです。ただ、Svelteのコンセプトと成果は非常に魅力的で、私はさらに深く研究したいと思っています。

また、バンドルサイズとパフォーマンスについての懸念に関しては、いつの日かハードウェアとネットワーク速度が徐々に追いつくと感じています。その時には、差別がそれほど顕著でなくなり、code-splittingやdynamic importの手法を使って初期のバンドルサイズを効果的に減らすことができるでしょう。

実際、Babelという強力なトランスパイラを組み合わせることで、JavaScriptにBabelの助けを借りて、ランタイムで行うべき複雑な作業をできる限り減らすことができます。WebAssemblyやArrayBufferのような低レベルAPIの進展により、ウェブの発展は新たな段階に入るかもしれません。この時代、フロントエンドだけでなく、簡単なコンパイラを自作することも学ぶ必要があるかもしれません。

この記事が役に立ったと思ったら、下のリンクからコーヒーを奢ってくれると嬉しいです ☕ 私の普通の一日が輝かしいものになります ✨

Buy me a coffee