Van.js のサイズが小さすぎる
- カテゴリ:
- JavaScript
- コメント数:
- Comments: 0
◆ 小さくするために色々工夫されてる
◆ state 機能があってリアクティブに更新できる
◆ state 機能があってリアクティブに更新できる
今月の頭に VanJS の 1.0 がリリースされたと聞いてどんなものなのか見てみました
React や Vue みたいな DOM を管理してくれるもののようですが ライブラリのサイズの小ささにかなりのこだわりがあるようでした
軽量というと Preact や lit-html や uhtml が思い浮かびますがそれらよりもさらに小さいです
公式サイトに記載されている minify+gzip のサイズはなんと 0.9kB です
minify のみで gzip なしの .js ファイルそのもののサイズです
バンドルが複数用意されているものは標準的な名前のものを選んでいるので UMD/CJS/ESM は多分揃ってないです
この種類でわずかに変動しますが数バイト~十数バイト程度です
またライセンス表記やソースマップの URL も入ってたり入ってなかったりですが 除外せずファイルそのもののサイズにしてます
圧倒的な小ささですね
記事にまとめようかなと思いながら後回しにしているうちに 1.1.0 がリリースされていて 少しファイルサイズが大きくなっていましたがそれでも 1,702byte です
minify 前のソースコードを見てみると元コードですらたった 136 行でした
https://github.com/vanjs-org/van/blob/1.0.2/src/van.js
バンドルサイズを小さくするために const を使わず let を使ったり Object や document を一旦変数に入れるなどかなりのこだわりようです
変数に入れると minify 時に 1 文字変数に置き換えてくれるので直接使うより文字数が減ります
そういうわずかなバイト数ですら減らそうとしているようです
1.0.2 リリース時のメッセージを見ると処理の最適化を行ったことでわずかにバンドルサイズが増加するとあり どれくらいかと思えば minify 状態で 4 バイトで gzip だと 2 バイトです
そんな誤差レベルで気にするほどでもないと思うところまでこだわってるようです
https://github.com/vanjs-org/van/discussions/72
使い方のほうを全然見ていなかったのですが 記法的に JSX のようなものはなく lit みたいにテンプレートリテラルを使うものでもなく HTML タグの名前の関数を使うというものでした
Hello world の例がこうなっています
React で JSX を使わず createElement 関数を使うような感じですね
共通関数の 1 つめの引数にタグを渡すのではなく タグが関数になっているので それよりは少し短く書けます
JSX みたいに HTML の見た目で書けるわけではないので 書きづらさや読みづらさはありますが コンパクトにするのが目的ならこういうものになるのでしょうね
ただ タグの関数を全 HTML タグ分作るとそれだけでサイズを取りそうです
どうやってるのかと思い コードを見てみると Proxy を使っていました
div というプロパティでアクセスがあれば div タグを作る関数を作るという感じで動的に関数を作っています
タグごとに動きを変えたりせず document.createElement に渡すだけなのでそれで十分ですね
昔 個人的に似たようなものを作ったときは全部のタグを直接ソースコードに含めていました
似たようなと言っても HTML タグの関数を用意するという使い方だけで リアクティブな機能はなく静的な HTML 要素を作るだけです
document.createElement を呼び出すのが面倒で省略できるようにしたくらいなものです
タグ名を直接書くとタグが増えたときにソースコードの修正が必要になるので その点でも Proxy を使う手段の方が良さそうです
と思いましたが 任意の名前のタグで要素を作れる状態なので補完されませんし タイプミスで間違って存在しないタグ名を使ってしまったりということはありそうです
一応 TypeScript の型定義では全タグをソースコードに記載しているので TypeScript なら補完やタイプミスもサポートしてくれてそうです
でも 結局それだとタグが増えた場合にメンテ不要とはならないですね
実行時の JavaScript コードのサイズを減らすことが目的で 型定義みたいなファイルサイズは気にしないようです
React 系だと画面を更新しようとするたびにコンポーネント関数を再実行して そこから得られる仮想 DOM やテンプレートをもとに実際の DOM を更新するという流れですが Van では関数の再実行は行われません
Solid.js みたいな感じです
そういう仕組みのため コンポーネントという扱いにする必要もないですし state はどこで作ってもいいです
React だとコンポーネントは React が呼び出すものでフックが使える特別な関数ですが Van では特別なことはないただの関数です
特別なことを考える必要がないので扱いやすいです
setter が用意されていてそのおかげで val プロパティ (state) の更新時にその state を使う部分も更新されます
プロパティや子要素に state そのものを入れる場合はオブジェクトで渡せばいいです
class プロパティや button に表示する文字列は color という state オブジェクトそのままです
class プロパティに他の class も追加したい場合など state を使って計算が必要なこともあります
そういうときは state.val 形式で実際の値を取得でき
のようなこともできるのですが これだと state であるということが Van に伝わらず state が変わっても変化しないものになってしまいます
そこで 関数でラップすることで可変部分であることを Van に伝えます
ただし onclick などのイベントリスナのプロパティの場合は 関数を渡すものなのでどっちとして扱うか判断ができません
そこでエスケープとして _ 関数を使います
ボタンを押したときの処理が num が 0 ならカウントアップして num が 0 以外ならカウントダウンするので 0 と 1 が交互に切り替わる動作になっています
この場合なら リスナとして設定する関数を置き換えるのではなく 関数は同じものにして押されたときの関数内の処理で分岐でもよく そうすれば _ 関数は不要になります
ですが Van のアプローチだと消すというのが少し特殊です
再レンダリングしたら実質的に消えていた ではなく意図的に消そうとしないといけないです
要素を削除するには 消す可能性がある部分を state 化しておき null/undefined に更新します
ボタンを押すと state が false になり 関数部分の結果が null になり 要素が削除されます
この仕組みだと また state を true にすると再表示できそうに思えますが 再表示はサポートされていません
DELETE ボタンでボタンを消してから RESTORE? ボタンを押しても何も起きません
この方法では追加や削除のできるリストなど可変個数のものを扱うときには扱いづらいです
例を見ていると コンテナ要素ごと置き換えているのをよく見るので 全体を置き換えてしまうのが良いのかもしれません
この例だとコンテナの div ごと置き換えてしまっています
また input などでは要素そのものを置き換えてしまうと意図したとおりに動かない場合があります
canvas や video だとリセットされてしまいます
特にソート時に React で key を指定するみたいに既存 DOM を使いまわしていい感じに並び替えてくれたりはしないです
その回避策として 直接 DOM 操作ができるようになっています
子要素として関数を渡すとき 前の状態の DOM が引数として受け取れます
この引数をそのまま返せば 更新処理を手動で行ったこととして Van による更新をスキップできます
この機能を使って自分で DOM を更新します
並びを反転させてみます
下側の再利用と書いてる方は 既存 DOM を並び替えてます
これを自分でやらないといけないのは少し面倒です
確認のために表示されたあとにこういうコードを実行します
item1 ~ item3 までのすべての要素に class をつけます
これで並び替えると 再生成の方は DOM が作り直されるので class は消えます
再利用の方は同じ要素を使い回すので class が残っています
配列は展開されて
と同じ動きです
それなら
もできそうに思うのですが 関数にする場合は返り値に配列を使えません
文字列化されてしまって「[object HTMLSpanElement]」みたいなものが表示されてしまいます
可変個数になると更新が難しいとかでしょうか
こういう場合は div などにラップする必要があります
例えば数字のみを入力できるようにするため 入力値から数字以外を消すようにします
abc などを打ってみると input に表示されています
その後に数字を打った時点で abc が消えます
abc を打ってもそれらが消されることで state に変化がないとみなされ input の value が更新されないためです
一旦入力値そのままで state を更新してから次に本来の state に更新することで対処できそうですが Van では更新を遅延するようになっているので
だと更新されません
にしないといけないです
遅延は setTimeout で行われているので queueMicrotask では先に実行されるので効果がありません
この方法で対処はできますが 一瞬文字が表示されてすぐに消えるという動作になるのでいまいちです
input を毎回作り直してしまうこともできますが その場合は入力中にカーソル位置がリセットされるなど別の問題が出ます
これの対処用の機能というものもなさそうだったので DOM 操作で更新することになりそうです
簡単なものを作るとき lit や uhtml に並ぶ選択肢としてありかもしれません
ただサイズの小ささ重視なので他ほど高機能ではなく 書きやすさや読みやすさ的にもベストとは言えないです
サイズの小ささが重要なら積極的に採用できますが 実際のところ小さい方がいいときでも Preact くらいのサイズも十分許容できますからね
とりあえず気が向けば使ってみようかなくらいなところです
React や Vue みたいな DOM を管理してくれるもののようですが ライブラリのサイズの小ささにかなりのこだわりがあるようでした
軽量というと Preact や lit-html や uhtml が思い浮かびますがそれらよりもさらに小さいです
公式サイトに記載されている minify+gzip のサイズはなんと 0.9kB です
サイズ比較
一応他と比較するとこうなりましたminify のみで gzip なしの .js ファイルそのもののサイズです
バンドルが複数用意されているものは標準的な名前のものを選んでいるので UMD/CJS/ESM は多分揃ってないです
この種類でわずかに変動しますが数バイト~十数バイト程度です
またライセンス表記やソースマップの URL も入ってたり入ってなかったりですが 除外せずファイルそのもののサイズにしてます
- preact@10.17.1
10,855byte
フックは含まない
https://www.unpkg.com/preact@10.17.1/dist/preact.min.js - lit-html@2.8.0
8,111byte
directive は含まない
https://www.unpkg.com/lit-html@2.8.0/lit-html.js - uhtml@3.2.1
5,890byte
https://www.unpkg.com/uhtml@3.2.1/init.js - vanjs-core@1.0.2
1,630bytes
https://cdn.jsdelivr.net/gh/vanjs-org/van/public/van-1.0.2.min.js
圧倒的な小ささですね
記事にまとめようかなと思いながら後回しにしているうちに 1.1.0 がリリースされていて 少しファイルサイズが大きくなっていましたがそれでも 1,702byte です
minify 前のソースコードを見てみると元コードですらたった 136 行でした
https://github.com/vanjs-org/van/blob/1.0.2/src/van.js
minify 用の工夫
しかも一番最初の部分はこうなっています// This file consistently uses `let` keyword instead of `const` for reducing the bundle size.
// Aliasing some builtin symbols to reduce the bundle size.
let Obj = Object, _undefined, protoOf = Obj.getPrototypeOf, doc = document
バンドルサイズを小さくするために const を使わず let を使ったり Object や document を一旦変数に入れるなどかなりのこだわりようです
変数に入れると minify 時に 1 文字変数に置き換えてくれるので直接使うより文字数が減ります
そういうわずかなバイト数ですら減らそうとしているようです
1.0.2 リリース時のメッセージを見ると処理の最適化を行ったことでわずかにバンドルサイズが増加するとあり どれくらいかと思えば minify 状態で 4 バイトで gzip だと 2 バイトです
そんな誤差レベルで気にするほどでもないと思うところまでこだわってるようです
https://github.com/vanjs-org/van/discussions/72
記法
どうやってこのサイズを実現してるのだろうと中身を見てみました使い方のほうを全然見ていなかったのですが 記法的に JSX のようなものはなく lit みたいにテンプレートリテラルを使うものでもなく HTML タグの名前の関数を使うというものでした
Hello world の例がこうなっています
const Hello = () => div(
p("👋Hello"),
ul(
li("🗺️World"),
li(a({href: "https://vanjs.org/"}, "🍦VanJS")),
),
)
React で JSX を使わず createElement 関数を使うような感じですね
共通関数の 1 つめの引数にタグを渡すのではなく タグが関数になっているので それよりは少し短く書けます
JSX みたいに HTML の見た目で書けるわけではないので 書きづらさや読みづらさはありますが コンパクトにするのが目的ならこういうものになるのでしょうね
ただ タグの関数を全 HTML タグ分作るとそれだけでサイズを取りそうです
どうやってるのかと思い コードを見てみると Proxy を使っていました
div というプロパティでアクセスがあれば div タグを作る関数を作るという感じで動的に関数を作っています
タグごとに動きを変えたりせず document.createElement に渡すだけなのでそれで十分ですね
昔 個人的に似たようなものを作ったときは全部のタグを直接ソースコードに含めていました
似たようなと言っても HTML タグの関数を用意するという使い方だけで リアクティブな機能はなく静的な HTML 要素を作るだけです
document.createElement を呼び出すのが面倒で省略できるようにしたくらいなものです
タグ名を直接書くとタグが増えたときにソースコードの修正が必要になるので その点でも Proxy を使う手段の方が良さそうです
と思いましたが 任意の名前のタグで要素を作れる状態なので補完されませんし タイプミスで間違って存在しないタグ名を使ってしまったりということはありそうです
一応 TypeScript の型定義では全タグをソースコードに記載しているので TypeScript なら補完やタイプミスもサポートしてくれてそうです
でも 結局それだとタグが増えた場合にメンテ不要とはならないですね
実行時の JavaScript コードのサイズを減らすことが目的で 型定義みたいなファイルサイズは気にしないようです
state
驚きなのは この小ささなのに state 機能があり state を更新することで対応する場所を自動で更新できますReact 系だと画面を更新しようとするたびにコンポーネント関数を再実行して そこから得られる仮想 DOM やテンプレートをもとに実際の DOM を更新するという流れですが Van では関数の再実行は行われません
Solid.js みたいな感じです
そういう仕組みのため コンポーネントという扱いにする必要もないですし state はどこで作ってもいいです
React だとコンポーネントは React が呼び出すものでフックが使える特別な関数ですが Van では特別なことはないただの関数です
特別なことを考える必要がないので扱いやすいです
<!doctype html>
<script type="module">
import van from "https://cdn.jsdelivr.net/gh/vanjs-org/van/public/van-1.0.2.min.js"
const { button } = van.tags
const state = van.state(0)
van.add(root,
button(
{ onclick: () => { state.val++ } },
state
)
)
</script>
<div id="root"></div>
<!doctype html>
<script type="module">
import van from "https://cdn.jsdelivr.net/gh/vanjs-org/van/public/van-1.0.2.min.js"
const { button } = van.tags
const CounterButton = () => {
const state = van.state(0)
return (
button(
{ onclick: () => { state.val++ } },
state
)
)
}
van.add(root, CounterButton())
</script>
<div id="root"></div>
state を使って計算する場合
作った state はオブジェクトになっていて val プロパティに実体が入っていますsetter が用意されていてそのおかげで val プロパティ (state) の更新時にその state を使う部分も更新されます
プロパティや子要素に state そのものを入れる場合はオブジェクトで渡せばいいです
<!doctype html>
<script type="module">
import van from "https://cdn.jsdelivr.net/gh/vanjs-org/van/public/van-1.0.2.min.js"
const { button } = van.tags
const color = van.state("red")
van.add(root,
button(
{
onclick: () => { color.val = color.val === "red" ? "blue" : "red" },
class: color
},
color
)
)
</script>
<style>
button {
color: white;
border: 5px solid #0004;
border-radius: 8px;
padding: 24px;
font-size: 2rem;
}
.red { background-color: red; }
.blue { background-color: blue; }
</style>
<div id="root"></div>
class プロパティや button に表示する文字列は color という state オブジェクトそのままです
class プロパティに他の class も追加したい場合など state を使って計算が必要なこともあります
そういうときは state.val 形式で実際の値を取得でき
button({ class: state.val + "class1" }, "BUTTON")
のようなこともできるのですが これだと state であるということが Van に伝わらず state が変わっても変化しないものになってしまいます
そこで 関数でラップすることで可変部分であることを Van に伝えます
<!doctype html>
<script type="module">
import van from "https://cdn.jsdelivr.net/gh/vanjs-org/van/public/van-1.0.2.min.js"
const { button } = van.tags
const num = van.state(1)
van.add(root,
button(
{
onclick: () => { num.val++ },
},
// ↓関数の返り値形式にする
() => "!".repeat(num.val)
)
)
</script>
<div id="root"></div>
ただし onclick などのイベントリスナのプロパティの場合は 関数を渡すものなのでどっちとして扱うか判断ができません
そこでエスケープとして _ 関数を使います
<!doctype html>
<script type="module">
import van from "https://cdn.jsdelivr.net/gh/vanjs-org/van/public/van-1.0.2.min.js"
const { div, button } = van.tags
const num = van.state(0)
van.add(root,
button(
{
onclick: van._(
() => num.val === 0
? () => num.val++
: () => num.val--
),
},
num,
)
)
</script>
<div id="root"></div>
ボタンを押したときの処理が num が 0 ならカウントアップして num が 0 以外ならカウントダウンするので 0 と 1 が交互に切り替わる動作になっています
この場合なら リスナとして設定する関数を置き換えるのではなく 関数は同じものにして押されたときの関数内の処理で分岐でもよく そうすれば _ 関数は不要になります
要素の削除
React みたいな再レンダリングするものだと 前回の差分から削除されたものがわかりますですが Van のアプローチだと消すというのが少し特殊です
再レンダリングしたら実質的に消えていた ではなく意図的に消そうとしないといけないです
要素を削除するには 消す可能性がある部分を state 化しておき null/undefined に更新します
<!doctype html>
<script type="module">
import van from "https://cdn.jsdelivr.net/gh/vanjs-org/van/public/van-1.0.2.min.js"
const { button } = van.tags
const state = van.state(true)
van.add(root,
() => state.val ? button({ onclick: () => { state.val = false } }, "DELETE") : null,
)
</script>
<div id="root"></div>
ボタンを押すと state が false になり 関数部分の結果が null になり 要素が削除されます
この仕組みだと また state を true にすると再表示できそうに思えますが 再表示はサポートされていません
<!doctype html>
<script type="module">
import van from "https://cdn.jsdelivr.net/gh/vanjs-org/van/public/van-1.0.2.min.js"
const { button } = van.tags
const state = van.state(true)
van.add(root,
() => state.val ? button({ onclick: () => { state.val = false } }, "DELETE") : null,
button({ onclick: () => { state.val = true } }, "RESTORE?"),
)
</script>
<div id="root"></div>
DELETE ボタンでボタンを消してから RESTORE? ボタンを押しても何も起きません
この方法では追加や削除のできるリストなど可変個数のものを扱うときには扱いづらいです
例を見ていると コンテナ要素ごと置き換えているのをよく見るので 全体を置き換えてしまうのが良いのかもしれません
<!doctype html>
<script type="module">
import van from "https://cdn.jsdelivr.net/gh/vanjs-org/van/public/van-1.0.2.min.js"
const { button, div } = van.tags
const state = van.state(["item1", "item2"])
const newValue = () => {
return Array.from(Array(10), (_, index) => `item${index + 1}`)
.filter(x => Math.random() > 0.5)
}
van.add(root,
button({ onclick: () => { state.val = newValue() } }, "RANDOM"),
() => div(
state.val.map(x => div(x))
)
)
</script>
<div id="root"></div>
この例だとコンテナの div ごと置き換えてしまっています
直接 DOM 操作
コンテナごと作り直す方法は楽な分 大きなリストなどではパフォーマンスが心配ですまた input などでは要素そのものを置き換えてしまうと意図したとおりに動かない場合があります
canvas や video だとリセットされてしまいます
特にソート時に React で key を指定するみたいに既存 DOM を使いまわしていい感じに並び替えてくれたりはしないです
その回避策として 直接 DOM 操作ができるようになっています
子要素として関数を渡すとき 前の状態の DOM が引数として受け取れます
この引数をそのまま返せば 更新処理を手動で行ったこととして Van による更新をスキップできます
この機能を使って自分で DOM を更新します
並びを反転させてみます
<!doctype html>
<script type="module">
import van from "https://cdn.jsdelivr.net/gh/vanjs-org/van/public/van-1.0.2.min.js"
const { button, div, h3 } = van.tags
const state = van.state(["item1", "item2", "item3"])
van.add(root,
button({ onclick: () => { state.val = state.val.toReversed() } }, "REVERSE"),
h3("再作成"),
() => {
return div(state.val.map(x => div({ "data-id": x }, x)))
},
h3("再利用"),
(dom) => {
if (dom) {
const id_to_elem = Object.fromEntries(
Array.from(
dom.querySelectorAll(":scope > [data-id]"),
x => [x.dataset.id, x]
)
)
const elems = state.val.map(x => id_to_elem[x]).filter(x => x)
dom.append(...elems)
return dom
}
return div(state.val.map(x => div({ "data-id": x }, x)))
}
)
</script>
<div id="root"></div>
下側の再利用と書いてる方は 既存 DOM を並び替えてます
これを自分でやらないといけないのは少し面倒です
確認のために表示されたあとにこういうコードを実行します
for (const elem of document.querySelectorAll("[data-id]")) {
elem.className = "a"
}
item1 ~ item3 までのすべての要素に class をつけます
これで並び替えると 再生成の方は DOM が作り直されるので class は消えます
再利用の方は同じ要素を使い回すので class が残っています
配列
タグ関数の引数には配列も使えますdiv(["foo", span("bar")], "baz", span("qux"))
配列は展開されて
div("foo", span("bar"), "baz", span("qux"))
と同じ動きです
それなら
div(() => [span("foo"), span("bar")], "baz")
もできそうに思うのですが 関数にする場合は返り値に配列を使えません
文字列化されてしまって「[object HTMLSpanElement]」みたいなものが表示されてしまいます
可変個数になると更新が難しいとかでしょうか
こういう場合は div などにラップする必要があります
input とキャッシュ
hyperhtml や lit-html でもあった問題ですが input に入力制限するとうまくいきません例えば数字のみを入力できるようにするため 入力値から数字以外を消すようにします
<!doctype html>
<script type="module">
import van from "https://cdn.jsdelivr.net/gh/vanjs-org/van/public/van-1.0.2.min.js"
const { div, input } = van.tags
const state = van.state("123")
van.add(root,
input({
oninput: (eve) => { state.val = eve.target.value.replace(/[^\d]/g, "") },
value: state
}),
div(state)
)
</script>
<div id="root"></div>
abc などを打ってみると input に表示されています
その後に数字を打った時点で abc が消えます
abc を打ってもそれらが消されることで state に変化がないとみなされ input の value が更新されないためです
一旦入力値そのままで state を更新してから次に本来の state に更新することで対処できそうですが Van では更新を遅延するようになっているので
(eve) => {
state.val = eve.target.value
state.val = eve.target.value.replace(/[^\d]/g, "")
}
だと更新されません
(eve) => {
state.val = eve.target.value
setTimeout(() => {
state.val = eve.target.value.replace(/[^\d]/g, "")
})
}
にしないといけないです
遅延は setTimeout で行われているので queueMicrotask では先に実行されるので効果がありません
この方法で対処はできますが 一瞬文字が表示されてすぐに消えるという動作になるのでいまいちです
input を毎回作り直してしまうこともできますが その場合は入力中にカーソル位置がリセットされるなど別の問題が出ます
これの対処用の機能というものもなさそうだったので DOM 操作で更新することになりそうです
<!doctype html>
<script type="module">
import van from "https://cdn.jsdelivr.net/gh/vanjs-org/van/public/van-1.0.2.min.js"
const { button, div, input } = van.tags
const state = van.state("123")
van.add(root,
input({
oninput: (eve) => {
// eve.target.value も更新する
state.val = eve.target.value = eve.target.value.replace(/[^\d]/g, "")
},
value: state,
}),
button({ onclick: () => { state.val = "123" } }, "RESET"),
div(state)
)
</script>
<div id="root"></div>
まとめ
かなり小さいライブラリなのにできることが多くて驚きですね簡単なものを作るとき lit や uhtml に並ぶ選択肢としてありかもしれません
ただサイズの小ささ重視なので他ほど高機能ではなく 書きやすさや読みやすさ的にもベストとは言えないです
サイズの小ささが重要なら積極的に採用できますが 実際のところ小さい方がいいときでも Preact くらいのサイズも十分許容できますからね
とりあえず気が向けば使ってみようかなくらいなところです