VueのRef糖衣構文とSvelte
はじめに
10月28日に尤雨溪が1つのRFCを提案した。これはref宣言の構文において、JavaScriptのラベル文(label statement)を使うことでさらに簡略化できるようにするというものだ。 この構文はSvelteと瓜二つであり、ここで僕の考えを記録しておこうと思う。
まずはサンプルコードを見てみよう:
<script setup>
// 透過 label statement 語法宣告 ref
ref: count = 1
function inc() {
// 可以直接取用變數
count++
}
// 或是直接使用 $ 當作前綴來拿 ref
console.log($count.value)
</script>
<template>
<button @click="inc">{{ count }}</button>
</template>
Vue 3では、Composition APIによって、refを使って変数をリアクティブ(reactive)にすることができる。宣言方法は以下の通りだ(コードはドキュメントのサンプル):
const count = ref(0)
console.log(count.value) // 0
count.value++
console.log(count.value) // 1
refで宣言するとリアクティブになり、template内で使用すれば画面もリアルタイムに更新される(コードはドキュメントのサンプル):
<template>
<div>{{ count }}</div>
</template>
<script>
export default {
setup() {
return {
count: ref(0)
}
}
}
</script>
ここで注意すべき点として、毎回refを宣言した後に値へアクセスするには.valueを付ける必要がある。大した問題ではないとはいえ、やはり多少のオーバーヘッドをもたらす。ドキュメントでも次のように言及されている:
- ユーザーは自分が今使っているのが通常の変数なのか、それとも
refでラップされた変数なのかを把握する必要がある(ただし、これは命名規則やTypeScriptによって解決可能だ) - 値を読み取るたびに別途
.valueが必要になる
そこで作者はRef Sugarという提案を出した:
<script setup>
// 透過 label statement 語法宣告 ref
ref: count = 1
function inc() {
// 可以直接取用變數
count++
}
// 或是直接使用 $ 當作前綴來拿 ref
console.log($count.value)
</script>
<template>
<button @click="inc">{{ count }}</button>
</template>
上記のコードは変換後に次のようになる:
<script setup>
const count = ref(1);
function inc() {
count.value++;
}
console.log(count.value)
</script>
<template>
<button @click="inc">{{ count }}</button>
</template>
ご覧の通り、この糖衣構文(シンタックスシュガー)はref自体の宣言を隠蔽し、同時に.valueを使って読み取る必要なく直接変数にアクセスできるようになり、開発者の認知負荷を軽減している。
現在、コミュニティの多くは反対意見を示しており、大体以下のような点だ:
- これは正当なJavaScript構文ではない
- Vueがラベル文(label statement)の意味論(セマンティクス)を再定義してしまっている
- letやconstを使わずに変数を宣言している
- 1段階のコンパイルを経る必要があり、余計な「マジック」が増える
- …
興味がある人はRFCを見に行ってみてほしい。かなり多くの議論の切り口があって面白い。
作者本人も、このアイデアはSvelteを参考にしたと述べている。では、Svelteがどのようにこの構文を使っているかを見てみよう:
<script>
let name = 'world';
// 每次 name 有更新時重新執行 console.log(name)
$: console.log(name);
// 每次 name 有更新時賦值給 name2
$: name2 = name.toUpperCase();
// ???
$: console.log('');
</script>
$をラベルとして使用したコードは、Svelteによって別途次のようにコンパイルされる:
...
let name2;
$$self.$$.update = () => {
if ($$self.$$.dirty & /*name*/ 1) {
$: console.log(name);
}
if ($$self.$$.dirty & /*name*/ 1) {
$: name2 = name.toUpperCase();
}
};
$: console.log("");
ここの構文は一旦置いておくとして、Svelteはラベルの後に宣言されたコードをコンパイル後にupdateという関数内に配置する。コンポーネントが更新されるとこの関数が実行され、同時にSvelteはその中で変数が更新されたかどうかをチェックしてからコードを実行するかどうかを判断する。
同時に、一番下のconsole.log("")からもわかるように、$:のコードスニペット内でコンポーネント内の変数を使用していない場合は、update関数内には配置されない。これはSvelteがコンパイル時に行う最適化の一つだ。
2つ以上の変数がある場合、Svelteはどの変数が変化したかを把握し、対応するコードを実行できる:
let name = 'world';
let count = 0;
// 只有 name 有變化時才會執行
$: console.log(name);
// 只有 count 有變化時才會執行
$: console.log(count);
詳しい原理は別の記事で紹介する
ここからわかるように、Svelteは可能な限り依存関係を追跡する。もしReactの構文に変換するなら、おおよそ次のようになる:
const [name, ] = useState('world');
const [count, ] = useState(0);
useEffect(() => {
console.log(name);
}, [name]);
useEffect(() => {
console.log(count)
}, [count])
つまりSvelteは、コンパイルを通じて内部にどのような依存関係があるかを事前に把握し、それを処理してくれるため、ランタイムで明示的に依存関係を宣言する必要がない。もちろんこれには一長一短があり、一連のトレードオフも含まれるが、それについてはまた後日詳しく触れよう。
ここからわかるのは、構文とコンパイルの支援によってコードを大幅に簡素化でき、簡潔なコードは開発者の認知負荷を軽減できるということだ。
だからこそ、**魔法を使っている(magic)**と見なされがちだ。議論をより集中させるために、まずは「魔法」の定義を明確にする必要がある。そこで僕はこの場での「魔法」を次のように定義する:
- コードの挙動が予想と異なる(例:ラベルの意味論を書き換える)
- 書いたコードと実際にコンパイルされたコードとの間に処理ステップが多すぎる(ラベルのコードがコンパイル後に全く別物になる)
この2つをベースにすると、さまざまな議論へと発展させることができる:
- JSXやtemplate、SFCが多くのステップを経て最終的にJavaScriptコードへと変換されるのは、魔法と言えるのだろうか? なぜ開発者はこうした構文を広く受け入れているのだろうか?
v-if、v-show、v-forなど、フレームワークが提供するテンプレート構文は魔法と言えるのだろうか?- Reactの
onChangeイベントがネイティブブラウザのonChangeと異なるのは、標準を再定義したと言えるのだろうか?(参考)
したがって、単に「魔法」という観点だけで見れば、どのフレームワークにも多かれ少なかれ魔法は存在しており、「魔法だからダメだ」という論点だけでは少し弱いように思える。
ただし、VueのRef Sugar単体で見ると、この糖衣構文がもたらすメリットは実はかなり限定的だ。その効果はSvelteとは異なる。Svelteの$:の意味論は「内部の依存関係が変化したときにコードを再実行する」というものだが、Vueのref:は単にref変数を宣言しているに過ぎない。構文は似ているものの、機能的にはまったく別物だ。
また、Svelteではコンポーネント内の<script>におけるすべての変数宣言がリアクティブになるが、コンポーネントの外部で同様に宣言する仕組みは提供されていない。つまり、Svelteでは次のように書くことはできるが:
// Component.svelte
<script>
// reactive by default
let state1 = 0;
// reactive by default
let state2 = 1;
function doSomething() {
let state3 = 0; // not reactive
}
</script>
<span>{state1}</span>
<span>{state2}</span>
しかし、state1とstate2を外部へ切り出すことはできない:
// 假的,沒有這種 API
export const useStates = () => {
const state1 = makeReactive(0);
const state2 = makeReactive(1);
return [state1, state2];
};
// Svelte 沒辦法這樣寫
<script>
import { useStates } from './useStates';
const [state1, state2] = useStates();
</script>
<span>{state1}</span>
<span>{state2}</span>
同様の効果を得るには、Svelteのstoreを使って宣言する必要があるが、スコープに違いが出てくる。Store自体はグローバルであり、subscribeされると他のコンポーネントと共有されるが、Composition API自体はコンポーネントの外部で機能し、独立した関数として宣言して使用できる。
すべての変数をSvelteコンポーネント内に置かなければならないため、変数の数が増えると切り出しが難しくなり、保守性もやや低下する。特にコーディング習慣が良くないと、あちこちに変数を無秩序に宣言してしまい、かえって管理が難しくなってしまう。Svelteにも将来的に、コンポーネント外部でリアクティブ変数を宣言してコンポーネント内の状態を簡素化できるような仕組みができることを期待している。
関連記事
- Three.js で僕の部屋を表現する 僕が React Three Fiber を使って実際の自分の部屋をブラウザ上に再現し、実世界のオブジェクトを目録に見立て、空間の記憶を通してここ数年の生活と仕事について語った話。
- フロントエンドで画像を扱う際に注意すべきこと Jake Archibaldの記事を起点に、現代のレスポンシブ画像の書き方を整理する。なぜwidth/heightを付ける必要があるのか、CSSのaspect-ratioはいつ使うべきか、AVIFとWebPの選び方、そしてpicture/source/srcsetを使ったモバイル向け画像の切り替えについて。
- CSS field-sizing — たった1行のCSSでフォーム要素を自動リサイズする かつてtextareaの自動高さ調整は、JavaScriptでscrollHeightを監視するしかなかった。しかしCSSのfield-sizing: contentなら、わずか1行で代替でき、textarea、input、selectに対応している。本記事では従来のやり方のペインポイントと、field-sizingの使い方をまとめる。
- リンクの下線をもっと見栄え良くする:text-underline-offset デフォルトでは下線と文字が近すぎて、このスタイルを好まないデザイナーもいるし、僕自身もあまり綺麗ではないと感じていた。