· 15分で読了

Svelte — 何が僕を君に出会わせたのか

この記事は中国語から自動翻訳されたものです。翻訳によりニュアンスが失われている場合があります。

初めて 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(仮想DOM) — ページレンダリング時のパフォーマンスを確保する
  • Reactivity(リアクティビティ) — 値の変化を追跡する

フロントエンドフレームワークの中核となるのは、データフローとデータの変化の追跡だ。

仮想DOMを使用する理由

主な理由は、データ更新時に画面全体を再レンダリングするとパフォーマンスに大きな影響を与えるためだ。だからこそ、仮想DOMは通常、効率的な diff アルゴリズムを実装し、画面更新時に必要な変更だけを再レンダリングするように保証している。

しかし、ここで問題が生じる。安定した diff アルゴリズムと更新メカニズムを実装するには、膨大な労力が必要であり、ツリーの深さが深くなりすぎたときにパフォーマンスのボトルネックにならないよう配慮しなければならない。

リアクティビティ

React が値の変化(更新)を追跡できるように、React は setStateuseState を使って値の変化を追跡する。一方、Vue は Proxy を採用し、開発者が値にアクセスした際にリアクティビティのメカニズムを発火させてデータの変化を追跡する。

React において、React が値の変化を検知できるように僕たちは useStatethis.setState を使い、さらに不要な更新を防ぐための仕組み(batch updates)も備わっている。以下のコードを見てみよう:

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

コンポーネントが更新されるたびに useState が再実行され、handleClick 関数が再評価される。React 内部ではこれらの state の変更を保持・追跡し、適切な更新を行う。

この問題を解決するために、useMemo や各種の最適化手段が登場した:

  • shouldComponentUpdate
  • React.PureComponent
  • useMemo
  • useCallback

これらの仕組みの実装には非常に大きな労力が費やされている。画面が更新されたときにすべてを再レンダリングしないようにするため、そして仮想DOMのパフォーマンスを担保するための最適化オプションを開発者に提供するためだけにだ。この2つが合わさることで、react や react-dom のバンドルサイズは非常に巨大なものになっている。

さらに、作者自身も公式ブログで「Virtual DOM is pure overhead」という記事を公開し、仮想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 のツイートも、僕にとって非常に興味深い視点だった:

仮想DOMのメカニズム全体は、フレームワークが開発者に状態管理を意識させないために作られたものだ。しかし、なぜそのような仕組みをプラットフォーム(ブラウザ)側が提供しないのか? この視点は面白いと感じるが、実際に実装しようとするとどれほどの労力がかかるかわからないし、もしブラウザ側が実装するとなると…クロスブラウザの問題や標準化の壁に直面し、進化の速度はフレームワーク自身に遠く及ばないだろう。

Svelte

それほど桁外れに大きいわけではないものの、プロダクト自身のコードが加わると、このバンドルサイズはやはり少し驚きだ。

ここまでの考えをまとめると、現代のフロントエンドフレームワークの欠点は以下の点にある:

  • ランタイムでのリアクティビティ + 仮想DOMの diff メカニズム
  • 肥大化したバンドルサイズ

「どうして何でもかんでもバンドルサイズやパフォーマンスにこだわる必要があるのか?」と思う人もいるかもしれない。

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

僕自身も以前はよくそう考えていた。今や誰でも CPU は i5 や i7 以上が当たり前、ポケットから出すスマホは iPhone 11 Pro などのフラッグシップ機、ネットは使い放題が普通なのに、本当にそこまで気にする必要があるのだろうか?

最近、僕が日本で働き、暮らすようになってから、新しい考えを持つようになった:

  • 台湾のように、誰もが非常に安い価格でネット使い放題を利用できるわけではない。日本では通常、無制限のプランは少なく、通信量に制限がある上に恐ろしく高い。これは LINE モバイルのプランだが、主要 SNS がカウントフリーとはいえ、YouTube や Netflix をよく見る僕にとっては全然足りない。

  • 多くのユーザーが格安SIM(MVNO)を利用しており、基地局や通信速度がそこまで高速で安定しているわけではない。
  • 地下鉄に乗っているときは通信が明らかに遅くなり、台湾ほど安定していない。

これらが重なると、地下鉄乗車中にウェブページを開いたり、通信速度の遅い格安SIMを使ったりしたときに、速度の差を如実に感じる。どの国でも台湾のように恵まれた通信速度が当たり前にあるわけではないことを、身をもって痛感した。

また、Raspberry Pi(ラズベリーパイ)を使っていたときも、スペックの低い CPU でウェブページを動かすと明らかなカクつきを感じた。

IoT が台頭しつつある現代において、すべての人が強力な CPU や GPU でウェブを閲覧しているわけではない。このことも、僕がパフォーマンスやフレームワークの選定について考え直すきっかけとなった。では、Svelte にはこの問題がないのだろうか? 引き続き見ていこう。

作者は 19:15 のあたりで、以前 Dan が示した React の time slicing の例を Svelte で実装し直してみせている。そして、Svelte は Async モードや debounce を有効にする必要が一切なく、パフォーマンス面で 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 のように、それが良いか悪いかはユースケース次第だ。

抗えない魅力的な構文

僕自身、構文や言語レベルで問題を解決するのが大好きな人間だ。こうしたアプローチがあれば、開発者は useEffectuseMemouseCallback の裏側の仕組みや、なぜそれらを使わなければならないのかを理解するために無駄な時間を費やす必要がなくなる。

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 {...}

ここで使われている elementtext 関数に注目してほしい。これらは単に document.createElementelement.textContent のラッパーにすぎず、仮想DOMの概念はどこにもない。

では、Svelte はどのようにしてリアクティビティを実現しているのだろうか?

コンパイラには何ができるのか?

let a = '' の宣言時、実際にはコンパイラが a が宣言されたことを把握している。そのため、a = a + 1 のような処理を行うと、コンパイラは裏でそれを $$invalidate('a', a + 1) のような形に変換し、この値が変更されたことを Svelte に通知する。すると Svelte はその変数を dirty としてマークし、更新をスケジュールして、画面上の a を使用している部分を更新する。

もし 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];
}
// ...

もしタグ内で使われていれば:

<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>

詳細は公式ドキュメントを参照してほしいが、僕がここで伝えたいのは、これらの構文が非常にシンプルであり、経験のある開発者なら誰でも短時間で一通り習得できるということだ。コンパイルを行うため、ついでに構文チェックや静的解析を行えるのも利点だ。

一度使えば虜になる

最近、僕は『あつまれ どうぶつの森』の魚のデータをまとめて簡単に検索できるウェブページを作った。機能は以下の通りだ:

  • 検索(純粋なフロントエンド処理)
  • テーブルのソート
  • 設定(config)に基づくテーブルの描画

これだけだ。

(検索の挙動が少し怪しいが、時間があるときに直すつもりだ XD)

バニラな JavaScript で書くと少し手間がかかり、かといって React で書くのは牛刀をもって鶏を裂くような小さなプロジェクトだったので、思い切って Svelte を使ってみることにした。結果には非常に満足している。バンドル後の JavaScript は全体でわずか 8.2KB(gzip後)で、3G 回線でもわずか 160ms でロードできた。

Is it a magic?

Rethinking reactivity の 17:11 あたりで作者もこの点に言及している。Vue の作者である Evan You は「これはもはや JavaScript ではなく、SvelteScript ではないか」と述べた。

実際のところ、フレームワーク自体が多かれ少なかれ魔法を使っている(複雑な詳細を抽象化している)のだ。React は複雑な更新メカニズムを魔法にし、Vue はテンプレート構文を魔法に包んでいる。みんな魔法を使っているが、React や Vue がランタイムで魔法をかけているのに対し、Svelte はその役割をコンパイラに任せているという違いにすぎない。

充実した store と context の仕組み

Svelte には標準で store が組み込まれており、シンプルに利用できる。標準で用意されているため、あちこちで redux-xxx のような宗教論争をする必要もない。

インタラクション

Svelte には、さらに驚かされる構文や実装が用意されている。例えば、標準で組み込まれた transition や spring / fly / flip といった機能があり、開発者向けに対応するディレクティブが用意されている。インタラクション(マイクロアニメーション)を追加したいとき、どのライブラリを使うか悩む必要はなく、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 そのものをより柔軟に操作できる。

さらに、アニメーション制作で最も厄介な「中断メカニズム」についても優れている。通常、アニメーションの途中でユーザーが操作を行った場合、特別な処理をしていないと transition の完了を待ってから最初からやり直すことになり、ユーザー体験を大きく損ねてしまう。

Svelte の transition は元から中断(リバーシブル)可能になっている。詳細については transition の章を見てほしいが、チェックボックスをクリックするとフェードイン・フェードアウトのアニメーションが発生し、どんなに素早くクリックしても現在の進行地点からトランジションが再開される。

ここで注目すべきは、React のようにアニメーションを維持するためにコンポーネントをマウントさせ続ける必要がなく、{#if visible} のように直接トランジションを扱える点だ。

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 を組み合わせることで、ランタイムで行うべき煩雑な処理を極力減らすことができる。さらに、WebAssembly や ArrayBuffer といった低レベル API の進化に伴い、ウェブ開発は再び新たな局面を迎えようとしているのかもしれない。これからの時代、フロントエンドエンジニアであっても、簡単なコンパイラを自作する方法くらいは学ばなければならないのかもしれない。

関連記事

他のトピックを探索