【脱仮想 DOM !?】Vue.js が控えている進化 "Vapor Mode" の詳細 (2023/12)
※本記事は Qiita / All I know about Vue 3's Vapor Mode Details (2023/12) のミラーです
⚡️ Vapor Mode
ってご存知でしょうか? 🤔
少しでも聞いたことがあるようであれば,おそらくあなたは日常的に情報を収集している熱心な方でしょう.
というのも,現在(2023/12),Vapor Mode について日本語での言及はほぼありません.
かといって英語圏に情報が出回っているかというと,そうでもありません.(後述)
今回は現時点で筆者が知っている Vapor Mode の詳細について,前提知識も整理しつつ理解していければと思います.
😋 初めに
🎯 お品書き (何を理解するか)
-
改めて Vue.js とは
これから Vue.js を学び始める方や,Vue.js をのぞいてみるのは随分と久しぶりだという方もいるかと思うので,ざっくりおさらいします -
現在の Vue.js
現在の Vue.js がどのような実装で実現されているのか,
Vapor Mode に関わってくる部分を抽出し,ソースコードベースでおさらいします. -
Vapor Mode の概要と近況について
これまでの知識を踏まえて,Vapor Mode とは何なのか,今どのような状況でどこで誰が何をやっているのか,進捗はどうなのかなどを説明します. -
Vapor Mode はどのように実装されているのか
Vapor Mode の概要の理解をもとに,実際にどのようなソースコードで実装されている(されていくのか)を説明します.
⚠️ 注意書き
Vapor Mode は現在 R&D (研究開発) のフェーズにあります.
この記事の内容は 2023/12 時点のものになります.
したがって,これから開発が進むにつれ,方針や API の形式,実装方法,スケジュールに大きな変更が加わる可能性がとても高いです.
その点をご留意いただければと思います 🙏
また説明の都合上,本記事で扱う GitHub のリンクのほとんどは permalink になっているので,必ず最新版の方も並べて確認することを推奨します.
🔰 現在 Vue.js を支える技術 (前程知識)
まずは Vapor Mode を理解するための前程知識からです.
すでにご存知の方は飛ばしてもらってもいいかもしれません.
❓ Vue.js とは
Vue は Web アプリケーションを開発するためのフレームワークです.
HTML, CSS, JavaScript を基本として,それらで Web アプリを構築するための環境を提供します.
Vue はコンポーネント指向のフレームワークであり,コンポーネントを記述するための言語 や 状態管理に必要なライブラリ を提供してくれたり,裏側ではスケジューリングや コンパイラ最適化 などの最適化の実装も行われています.
Vue.js で実装された簡単なコンポーネントを見てみましょう.
Vue をみたことがない方でも,HTML, CSS, JavaScript が読める方なら雰囲気で読めるかと思います.
Vue でコンポーネントを実装する方法についてはいくつかありますが,今回の記事では主に,Single File Component
と Composition API
にフォーカスします.
それはなぜかというと,Vapor Mode の出発点がこの2つだからです. (後述)
🛠️ 主な技術
さて,現在の Vue.js を実現するためにはいくつかの機能が必要です.
- コンパイラ
- リアクティビティシステム
- 仮想 DOM
これらについて少し深ぼってみましょう.
★ コンパイラ
コンパイラとはなんでしょうか.
まず前提として,ブラウザで動作するのは基本 HTML, CSS, JavaScript です.
(wasm など,他にもあるが本質ではないので割愛)
ここで,先ほど例に挙げたコンポーネントを思い出して欲しいのですが,これには明らかに HTML, CSS, JavaScript ではないものが含まれています.
いくつかそのポイントを挙げてみましょう.
🤔 明らかに HTML, CSS, JavaScript の機能ではなさそうなもの
- そもそも,script, template, style をあのように一つのファイルで記述する方法はない.
※ script と style に関しては,タグはあるもの,「一つのコンポーネント」という境界を表すことはあれだけはできない. - script タグにある,
setup
ってなんだ? - template にある
{{ count }}
という構文はなんだ? - template にある
@click
という構文はなんだ? - そもそも template になぜ script で書いた変数がそのまま書けるのか?
- style タグにある
scoped
ってなんだ?
はい.たくさんありましたね.
これらの面倒を全て見ているのが コンパイラ (Compiler) です.
これらの記述が最終的にはブラウザで動作する形の HTML, CSS, JavaScript に変換されており,この変換作業のことを コンパイル と言い,コンパイルするための実装のことを コンパイラ と呼んでいます.(正確には HTML は出力しません.(JS で表現されるので))
なぜコンパイラが必要なのか?
なぜこんなにややこしいことをしているかと言うと,大きな目標としては DX の向上 です.
Vue ユーザーにとって親しみやすいインタフェースを整えて,あとはそれをブラウザで動かすためにコードをこねくり回しているわけです.
もう一つの目標としては,コンパイラ最適化 です.
これは,この Single File Component をインタフェースとして Vue ユーザーが記述するコードと実際に動作するコードが分離されることで達成されます.
コンパイラが賢くコードを解析し,効率の良い HTML, CSS, JavaScript に変換することができれば,Vue ユーザーは何も実装を変えなくともパフォーマンスが向上することになります.(正確にはランタイムを含む JavaScript の効率を上げることがメインです)
そして多くの場合,機能を拡張し,かつパフォーマンスに注力したコードというのは人間にとっては読みにくいです.
試しに,先ほどの template
部分のコンパイル結果を見てみましょう.
とてもじゃないですが,このようなコードを人の手で書きながらアプリケーションを作っていくのは嫌ですよね. 😓
しかしこのコードはシステムにとっては実行可能で,かつ効率の良いコードになっているわけです.
もっと複雑な例
実際はもっと複雑です.
以下は簡単な Todo アプリの例ですが,頑張って追ってみると,
静的部分のホイスティングやイベントのキャッシングなどのコードが生成されています.
つまり,表向きはシンプルでわかりやすい記述を提供し,コンパイラがブラウザに向けてパフォーマンスに注力したコードを生成できると言うのが大きなメリットです.
この考え方は Vapor Mode でもかなり重要になってくるものなので,是非とも押さえておきたいです.
公式ドキュメントの方にも,なぜ SFC なのか という項目があるのでぜひご覧ください.
★ リアクティビティシステム
続いてはリアクティビティシステムの機能についてです.
こちらはいくつかのチュートリアルをやった時点でも用語としてはちょこちょこ登場すると思うので多くの人にとってより身近なものだと思います.
先ほどのコンポーネントの以下の部分
const count = ref(0);
const increment = () => {
count.value++;
}
に注目して欲しいのですが,increment が実行された際には画面が更新されるはずです.
count.value
と言う値を変更しただけなのに,どうしてこれが実現可能なのでしょうか?
これを実現しているのがリアクティビティシステムという機能です.その名の通り,"反応性"です.
ここで,察しの良い方はこんなことを思うかもしれません.
え? コンパイラではなくて?
もちろん,この考え方もあります.
コンパイラの方で値の変更を追跡し,画面を更新するようなコードを生成するというのももちろんありますが,Vue.js はそうではありません.
Vue.js に関して言えば,「リアクティビティシステムはコンパイラを必要としない」です.
Vue.js のリアクティビティシステムは一種の JavaScript ライブラリだと思ってもらって差し支えないです.
コンパイラやコンポーネントのランタイムに依存したものではないと言うことです.
例えば,以下のコードは正常に動作します.
ここで注意して欲しいのは,これは Single File Component ではないプレーンな HTML であり,CDN 経由で vue を読み込み,ref, watch と言うリアクティビティシステムに関連する関数だけを使用したものであると言うことです.
今すぐにでも html ファイルを作成し,このコードを書いてブラウザへドラッグ&ドロップしてもらえれば何事もなく動くはずです.
先ほどの例だと,以下の部分
const count = ref(0);
watchEffect(() => {
window.alert(`count is updated! ${count.value}`)
})
で以下の 4 ステップでリアクティビティシステムが構成されます
-
watchEffect が実行される
-
watchEffect に渡された
コールバック関数
をactiveEffect
としてマーク
activeEffect
は追跡対象の関数を格納する Vue 内部の変数です
https://github.com/vuejs/core/blob/04d2c05054c26b02fbc1d84839b0ed5cd36455b6/packages/reactivity/src/effect.ts#L48 -
コールバック関数
が実行される -
count.value
が読み取られるタイミングでactiveEffect
をcount.value
に登録 (track)
count.value
に対して コールバック関数
が登録されたので,count.value
に値がセットされた時に登録されていたものを実行 (trigger) するようにしておけば,いつもの挙動になります.
track と trigger の処理は class の getter/setter 関数であったり,Proxy で実現されています.
この,track/trigger, activeEffect をいうものを提供しているのが Vue のリアクティビティシステムという機能です.
コンパイラは関係ない,あくまで JavaScript の実装であるということがわかったはずです.
★ 仮想 DOM
さて,Vapor Mode の記事なはずなのにここまで全くその話をしていないので,そろそろ多くの方が退屈になってきた頃だとは思いますが,最後に,Vapor Mode を語る上でやはり外せない知識である仮想 DOM について説明させてください. :sweat:
Vue.js は仮想 DOM のパッチと言う手法でレンダリングを実装しています.
上記の図で言うと,trigger re-render
〜 patch
の部分です.
おさらいですが,仮想 DOM はただの JavaScript オブジェクトです.
タグ名や属性,子要素など,レンダリングに必要な情報だけを持つオブジェクトです.
Vue.js で仮想 DOM を生成する最も単純な方法は h
関数を使うことです.
import { h } from "vue";
const vdom = h(
"div",
{ id: "my-app" },
[
h("p", { class: "message" }, "hello"),
]
);
ただし,もちろんですが仮想 DOM を生成しただけでは意味がありません.
Vue.js のコンポーネントは render
と言う関数を持っています.
先ほどの図で言うと render function code
の部分です.
これはそのコンポーネントの現時点の状態の仮想 DOM を生成する関数です.
そして,コンポーネントは現在の仮想 DOM をインスタンス内に保持します.
つまり,前回の仮想 DOM と今の仮想 DOM を扱うことができるので,この 2 つを patch
と言う関数に投げます.
内部実装のイメージ
const nextVNode = componentInstance.render()
// 2 つの仮想 DOM の差分をもとにレンダリングする (初回、subTree は null なので mount が実行される)
patch(componentInstance.subTree, nextVNode)
componentInstance.subTree = nextVNode
この一連の流れを行なっているのが componentUpdateFn
という関数です.
const componentUpdateFn = () => {
const nextVNode = componentInstance.render()
// 2 つの仮想 DOM の差分をもとにレンダリングする (初回、subTree は null なので mount が実行される)
patch(componentInstance.subTree, nextVNode)
componentInstance.subTree = nextVNode
}
リアクティビティシステム との関係
そして,リアクティビティシステムによってこの componentUpdateFn
とステート(テンプレートで参照されたもののみ)を関連づけます.
つまり,ステートに変更があった際には componentUpdateFn
が呼ばれ, コンポーネントが持つ render
関数によって新たな仮想 DOM が生成され, patch 関数によって差分比較してレンダリングを行うのです.
これは Vapor Mode が解決する課題に関連してくる話ですが,ステートが更新されるたびに大きな仮想 DOM を生成し,その 2 つの Tree を元に差分を見つけると言う作業はパフォーマンス的なオーバーヘッドがあります.
実際に DOM 操作が行われる,といった点で言えば,差分があったところだけなので効率が良いように見えますが,実際はその差分を探すために全てのツリーを比較する必要がある のでこの部分は大きなオーバーヘッドです.
当たり前ですが,直接 DOM 操作をピンポイントに行えるならその方がパフォーマンスは良いです.
コンパイラとの関係
仮想 DOM の生成の方法について h
関数とコンポーネントの render
関数を紹介しましたが,
これらのコードをコンパイラによって template から生成します.
現在の Vue.js を支えている技術のおさらい
それぞれについて理解したところで,軽く全体の流れをおさらいしておきましょう.
需要な機能は主に以下の 3 つでした.
- コンパイラ
- リアクティビティシステム
- 仮想 DOM
Vue ユーザーは Single File Component で 快適にコンポーネントを書くことができます.
ユーザーはリアクティビティ API (ref
や reactive
など) を利用して,状態管理を行うことができます.
そして,その Single File Component をコンパイラがコンパイルします.
ここで,仮想 DOM を生成する関数や,それらを最適化したコードを出力します.
これによって ブラウザ上で動作する パフォーマンスの良いコードを出力することができます.
以上で Vue を支えている技術の概要が把握できたのではないでしょうか.
それでは,これからは待望 (?) の「Vapor Mode と言う Vue.js が控えている進化」について深ぼりしていこうと思います.
🌩️ Vapor Mode の概要
前置きが長くなってしまいましたが,ここから, Vue.js が控えている進化の一つである,「Vapor Mode」について話していければと思います.
まず,基本的なコンセプトと開発の近況をお伝えしつつ最後に Vapor Mode の実現方法についてソースコードベースで説明していこうと思います.
⚡️ どのようなコンセプトか
Vapor Mode は Vue.js の新しいコンパイル戦略です.
ざっくり,脱仮想 DOM を目指すものだという理解をしてもらえれば OK です.
先ほども言った通り,仮想 DOM はパフォーマンス的に大きなオーバーヘッドになります.
Vue.js が持つ コンパイラやリアクティビティシステムを最大限活用して,仮想 DOM を使わないコンポーネントを使えるようにしよう と言うのが大きな目標です.
(※ 仮想 DOM を使用する従来のコンポーネントは引き続き利用可能です)
Vapor Mode の情報はほとんど世(特に日本語圏)に出回っておらず,主に Vue.js の作者でもある,Evan You 氏の講演やインタビューでの情報がほぼ全てです.
もちろん,ほとんどの講演はアーカイブが残っているので閲覧可能ですが,それらの情報に自分から触れに行くのはかなり熱狂的な Vue.js ファンくらいでしょう. (特に日本語圏の場合は)
しかし,情報整理の救世主が現れました. :icarus.gk さんです.
彼が今年(2023) の 9 月末に,これらの講演やインタビューの内容を一つのブログに体系的にまとめてくださいました.
そのブログがこちらです.
こちらは 2023 年現在,Vapor Mode についての言及のうち,おそらく最も体系的で網羅的なブログです.
(最後に参考文献も全て載っているので,そこから Evan 氏の講演などにはリンクすることができます.)
実は先日,筆者は このブログの日本語訳 をアドベントカレンダーに投稿しました.
なのでここまで読んで,Vue.js の現在はどのような技術で成り立っているのかということを理解した方は,まずこちらのブログ(オリジナルでも翻訳版でも構いません)を読んで欲しいです.
ここまで読んだ方なら,Vapor Mode では何をしたいのか,と言うことがよくわかるはずです.
そして,本記事のタイトルを見て察した方もいるかと思いますが,本記事はこのブログの続編です.
元のブログは 2023 年の 9 月 時点のものであり,これは後述しますが, Vapor Mode の R&D は 2023 年の 11 月末 に公開され開発が開始しました.
そこからやく 1 ヶ月の間,議論や開発が進み,いろいろなものが具体的に見えてきました.
そして,筆者はこの Vapor Mode の R&D の collaborator です.
いちコントリビュータという立場から,現在の Vapor Mode の最新情報についてまとめていけたらと思います.
2023 の Vapor Mode の締めくくりです!
🚴 今どのような状況なのか (概要)
先ほどもお伝えしたとおり,Vapor Mode は今 R&D (研究開発) のフェーズにあります.
詳しい時期間は不明ですが,話を聞く限りおそらく半年から 1 年程前から,コアチームのメンバーの中でも一部のメンバー (Evan 氏 + 数名) の間で,プライベートに議論が進んでいたようです.(議論内容は公開されていません)
Vue: What to Expect in 2023 by Evan You - Vue.js Nation 2023
こちらは,2023 年に予定している Vue.js の動向に関する Evan 氏の講演 (2 月) ですが,
この時点では 2023 年の Q3~Q4 (7~12 月) に Vapor Mode にフォーカスする旨の話が登場していました.
しかし,10 月に行われた Vue Fes Japan 2023 の Evan 氏の基調講演では,
2024 年の Q1~Q2 (1~6 月)にずれ込む という発表がありました.
この基調講演のスライドや講演のリンクは非公開なので,共有することができませんが,筆者ははっきりとこの目で観測しました.(実は Vue Fes のスタッフとして参加していました.)
おそらくもう数ヶ月後に講演のアーカイブが公開されると思うので,是非チェックしてみてください!
Evan 氏は Vite の作者でもあり,こちらはこちらで Rolldown へのマイグレーションであったり,かなり忙しそうなので少しスケジュールが押したのではないかと推測しています.(こちらは筆者の推測でしかありません)
🕵️ どこで誰が作っているのか
どこで?
少しスケジュールがずれ込むという発表があったのが 2023 年の 10 月末なのですが,その次の月の末 (11 月末) に R&D 用のリポジトリが公開されました.
(正確には 11 月上旬にリポジトリは存在してましたが,色々準備中でした.)
このリポジトリは vuejs/core のフォークで,Vapor Mode に関連する実装を追加していくリポジトリになります.
誰が?
中心人物
Vapor Mode の開発の中心に立っているのは三咲智子 (Kevin Deng) さんです.
彼は Vue.js の Core Team Member の一人で,中国の杭州を拠点に活動されている方です.
おそらく,Vue Macros や VueUse で知らず知らずの間に皆さんもお世話になっているはずです.もちろん,vuejs/core にも参加されています.
Elk という Mastodon のクライアントもやられています.(Nuxt.js で作られています)
他にも様々なコントリビュートをされていますが,主に中心メンバーとして活動されているものを紹介しました.
この,core-vapor というリポジトリを作ったのはこの Kevin さんで,基本的にレビューやメインブランチへの取り込みなどは彼が行なっています.
実は,先日の Vue Fes Japan 2023 にもいらっしゃっていて,筆者も実際にお会いして色々お話しできる機会がありました.(筆者の皆無な英語力にも優しく対応してくださって,とっても良い方でした)
その他のメンバー
Kevin さんを中心に,Vue.js メンバーから ByWu さんや,あとは何人かのコミュニティメンバーで開発が進められています.筆者もコラボレーターの 1 人です.
え? Evan は?
という疑問があるかもしれません.
実は,Evan 氏は core-vapor に 直接は 顔を全く出していません.
issue のコメントにすら登場しません.
これは,筆者の推測であり不確かな情報ですが,Evan 氏は最近 Vue.js チームのスケールアップというか, Evan 氏自身への属人性を下げるような取り組みに力を入れているように感じます.
Vapor Mode に限った話ではないですが,先日の Vue Fes Japan 2023 でも,「自分がいなくてもリクエスト受けながらリリースを行なっていけるようにしていく予定だ」と述べていました.
Evan 氏は Vite の作者でもあり,そちらはそちらでかなり忙しそうです.
それもあってなのか,属人性に関してはかなり気にしている様子が伺えました.
直接は顔を出してはいないとは言いつつも,裏(プライベートな場)では,Kevin さんとコミュニケーションをとってディスカッションが進められているようです.
つまりは,Vapor Mode の R&D に関しては,Kevin さんに表舞台を委譲し,自分がいなくても進められるような状態を作るというのが裏テーマとして存在しているように見えます.(筆者の主観です)
そして,先日,Vue2 の EOL に関する Evan 氏のブログを見るに,きちんと裏で遠くから見守ってくれているようでした.
We are also making good progress on Vapor Mode.
👫 今やっていることと方向性
何をやっているかというと,メインの開発としては コンパイラの実装 です.
これはまたおいおい説明するのですが,Vapor Mode 自体は Single File Component の新しいコンパイル戦略なので,その部分を丸っと全て作り直す必要があります (若干の語弊あり,後述)
The previous work (mostly runtime) was in a private repo. The compiler side will be done in the open.
とは言いつつ,ランタイム部分がないとコンパイラを進められないところもあるので,
ランタイム (主にコンポーネントとランタイムディレクティブ) に関しては同リポジトリで並行して進んでいます.
Vapor Mode のランタイムって何? コンパイラって何? という部分に関しては後で説明します.
ランタイムにしろ,コンパイラにしろ,とにかく「既存の機能との互換性を保つ」ということを強く目指しています.
これは, Vue Fes での Evan 氏の基調講演でもかなり強調されていました.
Evan 氏の基調講演の話は Vapor Mode に限った話ではありませんが, Vue2 -> Vue3 の変更の失敗について触れられていて,とにかく少しづづ移行し,互換性を保ちながら deprecate/opt-in -> remove というサイクルをきちんと作るべきであるという言及がありました.
もう 2->3 のような大きな変更の仕方はしないとも言及しており,「安定性」「シームレス」についてフォーカスするという意向でした.
Vapor Mode の R&D でもそれらを大切にしています.
So the to-do is: To implement Basic APIs and LifeCycles first and align the behavior with the core as much as possible.
The essence of designing Vapor is: that we need to maximize compatibility with the existing behavior of Vue core.
しかし,?
Vapor Mode では一部の機能に対応しないかもしれない,と言う話はちらほら出ています.
いくつか紹介します.
Options API
少なくとも, vuejs/core-vapor では現在 Options API に関連する実装は行われていません.
もちろん,今後の開発が進むにつれて最後に統合されるなどの可能性はありますが,今の方針に関連するコメントを少し以下に抜粋します.
Twitter での言及
おそらくこれが初めてオープンに言及されたものだと思います.
このツイートは core-vapor が公開される前に Kevin さんが事前に教えてくれた情報です.
can now reveal some ideas (only for Vapor and subject to change).
- Drop Options API and mixins
GitHub の issue のコメントで
GitHub で props の option について相談していた際に, data
や computed
などの options を実装する必要はないという説明がありました.
(Composision API の computed の話ではない)
We need to support options like props, emits, and others, as well as the setup function. However, there's no need to implement the Options API such as data, created, computed, etc.
コンポーネントランタイムの軽量化
先ほどのツイートにも,
Lighter component runtime
と言う旨の内容がありますが,今の Vue.js ランタイムの複雑さは気にするところがあるみたいで, Vapor Mode の実装について色々と精査する方向のようです.
Vue.js は古くから使える機能が多くありますが,SFC や script setup, TypeScript などの登場で必要のない機能というのがいくつかあるようです.
例えば ランタイム上での Type Check は実装しないとか
In vapor I don't think we should implement type check (at least for now). TS will be the default language of SFC.
これらはおそらく今後実装していく中で徐々に精査されると思いますが,何かしら簡略化・軽量化を目指しているようです.
GitHub での言及
However, I guess maybe we can have another version of functional components that are lightweight and cheap cost (in the future).
One of the goals is to simplify the current complexity. Maybe we have to explore it in mainline (Vue Core) first...
🏊 進捗
さて,Vapor Mode の概要や開発が行われている場所,方向性についての話をしてきましたが,「全体的の進捗としては一体どれくらいなの?」という話をしていきます.
やるべきことのリストとしては大きく 2 種類のものがあります.
全体進捗
まず 1 つは README に記載されているものです.
これは Kevin さんがこのリポジトリを作った際に最初に作った Todo をアップデートしながら育てているものです.
こちらを見てみるに,いわゆる Structural Directives と呼ばれるものと Runtime Directives と呼ばれるもの以外はかなり進んできています.(1 ヶ月ほどの進捗としては順調じゃないでしょうか.)
v-model
や v-show
といった Runtime Directives と呼ばれるものはコンパイラだけでなく,先にランタイムの設計や実装を行う必要があるため,他のディレクティブに比べて実装が後回しになっています.
v-if
や v-for
は,他のディレクティブ(のコンパイラの実装)よりも少し重めのタスクではあるので後回しにされているのではないかなと推測しています.
例えば,v-for
に関して言えば今までは,新旧 2 つの仮想 DOM とそれらの要素の key 属性を元に変更があった要素だけを効率的に更新するようなアルゴリズムが実装されていますが,今回のレンダリング戦略では同じようなアルゴリズムを適応することが難しいため効率的に更新するためのアルゴリズムを設計する必要がありそうです.
これから設計され,実装されていくでしょう.
core-vapor リポジトリでは,まだその作業には取り掛かっていません.
(Kevin さんのアサインになっているのでもしかすると,裏で話が進んでいる可能性はある)
Component Runtime の進捗
そして,もう 1 つの Todo は Component のランタイムに関するタスクです.
これは筆者が作成した issue がベースに進められています.
こちらもぼちぼち PR が待機されており,そろそろ Component に関連するコンパイラの実装をしていくか? と言う話が出ているくらいの進捗です.
ですが,チェックが付いているものの細かいところでやるべきところはまだまだあるので,こちらはベース issue として,ある程度実装が進んでまとまってきたらさらに詳細な Todo や issue が作成されていくと予想しています.
まだまだこれから洗練段階といったところです.
📸 Vapor Mode の実装の解説
さて,ここまでで Vapor Mode とはなんなのか,どのような状況なのか,誰がどこで作っていて,どう言う方針で進捗はどうかと言う話をしてきました.
全体の流れはわかったはずなので,ここからはいよいよ具体的な実装について詳しく見ていきましょう.
⚔️ 基本構成
まず,これは Vapor Mode に限った話ではないのですが,Vue.js は大きく runtime と reactivity と compiler と言う 3 つのパッケージに分かれます.
前半で説明した話で言うと,現在は仮想 DOM やレンダリングアルゴリズム,コンポーネントの実装などは runtime に,リアクティビティシステムは reactivity に,コンパイラは compiler に実装されています.
これらの区分けに加え,環境依存であるものとそうで無いもの, コンパイラの種類などによってもう少し細かく分かれますが,大きくはこのような分類です.
core-vapor のリポジトリではこれらに加えて,runtime-vapor と compiler-vapor という 2 つのディレクトリが追加されています.
最終的なパッケージのエントリポイントとして,vue-vapor というディレクトリもありますが,主には runtime と compiler の組み合わせを export しているだけです.
ここで気付いたかもしれませんが,reactivity に関しては今までと同じものを使っています.
基本コンセプトでも取り上げられているように, Vapor Mode はリアクティビティシステムによって DOM を直接更新するような仕組みであるわけですが,リアクティビティシステムについては特に何も変更がありません.
また,実装方針としては「Vapor Mode 以前のランタイムの実装は使用しない」という方針で実装されています.
コンポーネントやスケジューラに関しては,おそらく既存の実装と大きく被る部分もあるのですが,そういった部分も今は重複して実装する方針で開発が進んでいます.
Vapor Mode は Varpor Mode としての実装に完全に切り離され,メインストリームに更新があった時でも問題ないようにしています.おそらく今後,Vapor Mode が完成に近づいたところで既存実装との統合などが検討されていくと思います.
🏃 ランタイム
まずはランタイムの実装を見て見ましょう.
なぜランタイムの方から見るかというと,コンパイラの出力するコードはここがわからないとよくわからないからです.
コンパイラは,Vapor Mode のランタイムのコードを出力します.
ランタイムの主要な要素
ランタイムを構成する重要な要素についてです.
主には以下の 2 つです.
- render
- component
コンポーネントに関しては実はそれほど変わりがありません.
render 部分のランタイムが変わってしまうので,それに合わせた実装が必要なくらいで,コンポーネント実装の大きな目的は「既存のものと挙動をそろえる」であり,具体的には issue の TODO になってる通り,ライフサイクルや props/emit/attr/slot/provide/inject などです.
これらは Vapor Mode だろうがそうじゃなかろうが,実装する内容はそれほど変わりがありません.(なので,詳しく説明はしません.)
しかし,やはり最も仕組みが違うのはレンダリング部分です.
Vapor Mode は仮想 DOM を使用しないモードなので,VDOM の実装もなければ,patch rendering の実装ももちろんありません.
そこに置き換わる部分の仕組みについて少し説明します.
レンダリングを形成する単位
VaporMode では,Block
と呼ばれる単位を扱います.
export type Block = Node | Fragment | Block[]
export type Fragment = { nodes: Block; anchor: Node }
複数のルートノードを扱うための Fragment にも対応しているので,再帰的な表現にはなっていますが,基本的には「DOM Node を表すもの」という理解で問題ないです.
この Block
は具体的にどのようにどんなものが生成されるかというと,ただの DOM Node です.
// このように作る
const t0 = template('<button type="button">click me!</button>');
const n0 = t0();
Vapor Mode の基本的な考え方として,この Block (が持つ Node) に対して,イベントハンドラを登録したり,Block のテキストや属性を更新する effect の生成を行います.
つまりは,従来の実装では仮想 DOM Node がレンダリングの単位であり,2 つの異なる VNode の差分をもとに実際の DOM に反映していたわけですが,Vapor Mode ではそもそもの基本単位が実際の DOM であり,その DOM を直接操作しているということです.
イベントハンドラの登録
こちらはとても単純です.
イベントを登録するための,on
という関数で行います.
on(n0, 'click', () => {/* ユーザが template で定義した関数 */})
中身の実装もただ addEventListener しているだけです.
(まだ簡易的な実装なので,remove などはおいおい実装されるでしょう.)
テキストの更新や属性などの更新
これは,更新自体は setText や setAttr という関数で行われます.
setText(n0, void 0, count.value)
こちらも,基本的にはただの DOM 操作です.
しかし,ここで肝になっているのがリアクティビティシステムです.
これらの更新作業はステートが更新されるたびに発火されなくてはいけません.
一見一工夫必要そうですが,ref や reactive を実装した値であれば,読み取るだけで自動でトラッキングができるので,既存の effect
という関数で包んであげれば一発です.
(watchEffect のようなものだと思ってもらえれば良いです.)
effect(() => {
setText(n0, void 0, count.value)
})
このコードによって,count.value
が読み取られることにより,count.value
に対して effect に渡されるコールバック関数が登録され,count.value
に変更があった際には登録されたこのコールバック関数が実行されることになります.
(つまり count.value が更新されたときに画面が更新される)
ここは,Composition API の設計がうまくマッチしたと言う意味で感動ポイントです.
Vapor Mode を実装する身としても,従来ののリアクティビティに基づいたコードを出力するだけでこんなことが 簡単に 実現できてしまうのです.
ユーザーがリアクティブなステートを定義する方式なので,コンパイラがそれに関連する DOM 操作を解析し,その操作を track するだけで実現できてしまいます.(すごい)
ランタイム部分のまとめ
基本的にはただの DOM 操作であり,それをリアクティビティシステムによってその実行をトラップしているだけというイメージができたでしょうか.
本当にただ,ステートが更新されたときにそのステートに関連した部分の DOM 操作が起きるだけなので,仮想 DOM の時と比べると大幅にオーバーヘッドを削減することができます.
🖨️ コンパイラ
Vapor Mode のコンパイラの実装で必要なこと
さて,続いてはコンパイラです.
「Single File Component の AST から リアクティビティを利用して DOM を更新できるような JavaScript コードを出力する」
というのが最も大きなタスクです.
「リアクティビティを利用して DOM を更新できるような JavaScript コード」というのは先ほど説明したランタイムのコードのことです.
わざわざ,「Single File Component の AST から」と表現したのには理由があります.
それはなぜかというと,「Single File Component の AST はすでに存在しているし,それを作るためのパーサもすでに存在している」からです.
前半で,現在の Vue.js はコンパイラによって仮想 DOM を生成するためのコンパイラはすでに実装してあると言いました.
ここで,Vapor Mode 以前のコンパイラがやっていることの流れについてですが,
- SFC/template のパース (AST の生成)
- transform (AST を変換して別の AST を生成 (ここでディレクティブなどがハンドルされます))
- codegen (仮想 DOM を生成するためのコードに変換)
という流れになっています.
Vapor Mode ではこの最初のステップの AST の生成は従来と全く同じものを使います.
これは,言い換えると transform~codegen で生成される画面の動きを内部的に一致させることができれば,Vue ユーザーは何もソースコードを変更せずに Vapor Mode の恩恵を受けることができるとも言えます.(SFC と言うインタフェースは共通なので)
ちなみに AST の定義は,
あたりにあります.
そして, Vapor Mode で新しく作るべきものとしては,
- AST から Vapor IR への変換
IR は Intermediate representation (中間表現) です - codegen
IR を元に,リアクティビティを利用して DOM を更新できるような JavaScript コードを出力します
の 2 ステップになります.
IR については,icarus 氏のブログ にも登場していましたが,JSX からのコンパイルも視野に入れての構成になっているようです.(ちなみに jsx の方は公開されているものとしてはまだ何も進捗がありません.(少なくとも,筆者は知りません))
そして,この Vapor IR は,
に実装されています.
既存の AST をこの IR に変換することができれば,あとはこの IR を元に JavaScript コードを出力するだけです.
この変換の作業は,従来の概念と同じく,transform
という概念で表現されており,
で実装されています.
codegen は
で実装されています.
実際にどのようなコードを出力するのか
出力コードの紹介
さて,IR が「リアクティビティを利用して DOM を更新できるような JavaScript コード」を表現しているということと,コンパイラはそれを出力するように実装するというのが Vapor Mode の具体的なコンパイラ開発のタスクであることがわかりましたが,実際にはどのようなコード出力を目指しているのでしょうか.
ここに関して,いくつかの例を紹介します.
その後で,確認方法について説明するので,より詳細なものが見て見たければ自分の手で確認することができます.
例1
input
<script setup lang="ts">
import { ref } from 'vue/vapor'
const count = ref(1)
const handleClick = () => {
count.value++
}
</script>
<template>
<button @click="handleClick">
{{ count }}
</button>
</template>
output
import { defineComponent as _defineComponent } from "packages/vue/src/runtime.ts";
import { ref } from "packages/vue/vapor/index.mjs";
import {
template as _template,
children as _children,
on as _on,
effect as _effect,
setText as _setText,
} from "packages/vue/vapor/index.mjs";
const _sfc_main = /* @__PURE__ */ _defineComponent({
__name: "App-root",
setup(__props, { expose: __expose }) {
__expose();
const count = ref(1);
const handleClick = () => {
count.value++;
};
const __returned__ = { count, handleClick };
Object.defineProperty(__returned__, "__isScriptSetup", {
enumerable: false,
value: true,
});
return __returned__;
},
});
function _sfc_render(_ctx) {
const t0 = _template("<button></button>");
const n0 = t0();
const {
0: [n1],
} = _children(n0);
_on(
n1,
"click",
(...args) => _ctx.handleClick && _ctx.handleClick(...args)
);
_effect(() => {
_setText(n1, void 0, _ctx.count);
});
return n0;
}
import _export_sfc from "/@id/__x00__plugin-vue:export-helper";
export default _export_sfc(_sfc_main, [["render", _sfc_render]]);
例2
input
<script setup lang="ts">
import {
ref,
computed,
onMounted,
onBeforeMount,
getCurrentInstance
} from 'vue/vapor'
const instance = getCurrentInstance()!
const count = ref(1)
const double = computed(() => count.value * 2)
const html = computed(() => `<button>HTML! ${count.value}</button>`)
const inc = () => count.value++
const dec = () => count.value--
onBeforeMount(() => {
console.log('onBeforeMount', instance.isMounted)
})
onMounted(() => {
console.log('onMounted', instance.isMounted)
})
onMounted(() => {
setTimeout(() => {
count.value++
}, 1000)
})
</script>
<template>
<div>
<h1 class="red">Counter</h1>
<div>The number is {{ count }}.</div>
<div>{{ count }} * 2 = {{ double }}</div>
<div style="display: flex; gap: 8px">
<button @click="inc">inc</button>
<button @click="dec">dec</button>
</div>
<div v-html="html" />
<div v-text="html" />
<div v-once>once: {{ count }}</div>
<div v-pre>{{ count }}</div>
<div v-cloak>{{ count }}</div>
</div>
</template>
<style>
.red {
color: red;
}
html {
color-scheme: dark;
background-color: #000;
padding: 10px;
}
</style>
output
import { defineComponent as _defineComponent } from "/@fs/packages/vue/src/runtime.ts";
import {
ref,
computed,
onMounted,
onBeforeMount,
getCurrentInstance,
} from "/@fs/packages/vue/vapor/index.mjs";
import {
template as _template,
children as _children,
createTextNode as _createTextNode,
insert as _insert,
prepend as _prepend,
append as _append,
on as _on,
setText as _setText,
watchEffect as _watchEffect,
setHtml as _setHtml,
} from "/@fs/packages/vue/vapor/index.mjs";
const _sfc_main = _defineComponent({
setup(__props, { expose: __expose }) {
__expose();
const instance = getCurrentInstance();
const count = ref(1);
const double = computed(() => count.value * 2);
const html = computed(
() => `<button>HTML! ${count.value}</button>`
);
const inc = () => count.value++;
const dec = () => count.value--;
onBeforeMount(() => {
console.log("onBeforeMount", instance.isMounted);
});
onMounted(() => {
console.log("onMounted", instance.isMounted);
});
onMounted(() => {
setTimeout(() => {
count.value++;
}, 1e3);
});
const __returned__ = { instance, count, double, html, inc, dec };
Object.defineProperty(__returned__, "__isScriptSetup", {
enumerable: false,
value: true,
});
return __returned__;
},
});
function _sfc_render(_ctx) {
const t0 = _template(
'<div><h1 class="red">Counter</h1><div>The number is <!>.</div><div> * 2 = </div><div style="display: flex; gap: 8px"><button>inc</button><button>dec</button></div><div></div><div></div><div>once: </div><div>{{ count }}</div><div></div></div>'
);
const n0 = t0();
const {
0: [
,
{
1: [
n3,
{
1: [n2],
},
],
2: [n6],
3: [
,
{
0: [n7],
1: [n8],
},
],
4: [n9],
5: [n10],
6: [n12],
8: [n13],
},
],
} = _children(n0);
const n1 = _createTextNode(_ctx.count);
_insert(n1, n3, n2);
const n4 = _createTextNode(_ctx.count);
const n5 = _createTextNode(_ctx.double);
_prepend(n6, n4);
_append(n6, n5);
_on(n7, "click", (...args) => _ctx.inc && _ctx.inc(...args));
_on(n8, "click", (...args) => _ctx.dec && _ctx.dec(...args));
const n11 = _createTextNode(_ctx.count);
_setText(n11, void 0, _ctx.count);
_append(n12, n11);
_watchEffect(() => {
_setText(n1, void 0, _ctx.count);
_setText(n4, void 0, _ctx.count);
_setText(n13, void 0, _ctx.count);
});
_watchEffect(() => {
_setText(n5, void 0, _ctx.double);
});
_watchEffect(() => {
_setHtml(n9, void 0, _ctx.html);
_setText(n10, void 0, _ctx.html);
});
return n0;
}
import _export_sfc from "/@id/__x00__plugin-vue:export-helper";
export default _export_sfc(_sfc_main, [["render", _sfc_render]]);
驚くべきことに(当たり前ですが), DOM 操作のコード込みでこのサイズ感なので,従来のように裏側に VNode の path を行うための JavaScript などはもちろん存在しません.
従来の output
一応参考までに,同じコード(例 2)を従来のコンパイラでコンパイルしたものを見てみましょう.
従来のコンポーネントはあくまで仮想 DOM を生成する関数になっていることがわかるかと思います.
(Vapor Mode と見比べてみましょう! )
output
import { defineComponent as _defineComponent } from "vue";
import {
createElementVNode as _createElementVNode,
toDisplayString as _toDisplayString,
setBlockTracking as _setBlockTracking,
createTextVNode as _createTextVNode,
openBlock as _openBlock,
createElementBlock as _createElementBlock,
} from "vue";
const _hoisted_1 = /*#__PURE__*/ _createElementVNode(
"h1",
{ class: "red" },
"Counter",
-1 /* HOISTED */
);
const _hoisted_2 = ["innerHTML"];
const _hoisted_3 = ["textContent"];
const _hoisted_4 = /*#__PURE__*/ _createElementVNode(
"div",
null,
"{{ count }}",
-1 /* HOISTED */
);
import {
ref,
computed,
onMounted,
onBeforeMount,
getCurrentInstance,
} from "vue";
const __sfc__ = _defineComponent({
__name: "App",
setup(__props) {
const instance = getCurrentInstance();
const count = ref(1);
const double = computed(() => count.value * 2);
const html = computed(
() => `<button>HTML! ${count.value}</button>`
);
const inc = () => count.value++;
const dec = () => count.value--;
onBeforeMount(() => {
console.log("onBeforeMount", instance.isMounted);
});
onMounted(() => {
console.log("onMounted", instance.isMounted);
});
onMounted(() => {
setTimeout(() => {
count.value++;
}, 1000);
});
return (_ctx, _cache) => {
return (
_openBlock(),
_createElementBlock("div", null, [
_hoisted_1,
_createElementVNode(
"div",
null,
"The number is " + _toDisplayString(count.value) + ".",
1 /* TEXT */
),
_createElementVNode(
"div",
null,
_toDisplayString(count.value) +
" * 2 = " +
_toDisplayString(double.value),
1 /* TEXT */
),
_createElementVNode(
"div",
{ style: { display: "flex", gap: "8px" } },
[
_createElementVNode("button", { onClick: inc }, "inc"),
_createElementVNode("button", { onClick: dec }, "dec"),
]
),
_createElementVNode(
"div",
{ innerHTML: html.value },
null,
8 /* PROPS */,
_hoisted_2
),
_createElementVNode(
"div",
{
textContent: _toDisplayString(html.value),
},
null,
8 /* PROPS */,
_hoisted_3
),
_cache[0] ||
(_setBlockTracking(-1),
(_cache[0] = _createElementVNode("div", null, [
_createTextVNode(
"once: " + _toDisplayString(count.value),
1 /* TEXT */
),
])),
_setBlockTracking(1),
_cache[0]),
_hoisted_4,
_createElementVNode(
"div",
null,
_toDisplayString(count.value),
1 /* TEXT */
),
])
);
};
},
});
__sfc__.__file = "src/App.vue";
export default __sfc__;
これだけ見ると驚くほどの差はないように見えますが,注意するべきことは,
この出力は 仮想DOM を生成するためだけのコードであり,それを扱うレンダリングアルゴリズムは別の実装である と言う点です.
出力コードの確認方法
確認方法はいくつかあります.
- IR を把握して,codegen の実装を読んでみる
- スナップショットテストを眺めてみる
- プレイグラウンドを起動して,source タブでコンパイル結果を覗いてみる
まず一つ目ですが,これまでに紹介したファイルやディレクトリの実装を追うことができれば,どのようなコードを出力するかを把握することができます.
続いての方法は,スナップショットテストを眺めてみることです.
コンパイラの実装は概ねスナップショットテストを実装しつつ進められます.(まだまだこれから追加される予定)
ツールとしては Vitest を使っています.
スナップショットテストの実装は compiler-vapor の __test__ ディレクトリに実装されています.
もう一つの方法としては,プレイグラウンドです.(SFC Playgroud ではありません)
core-vapor のリポジトリでは,開発中の Vapor Mode を実際に動かせるようにプレイグラウンドが実装されています.
git clone https://github.com/vuejs/core-vapor/tree/main
pnpm install
# ローカルホストで起動されます
pnpm run dev-vapor
こちらの playground の実装は,
にあります.使い方としては,このプレイグラウンドの src
配下に SFC の実装がいくつかありますが,起動したローカルホストのパスとして試したいファイルのファイル名を与えるとそのページに切り替わるようになります.
例えば,src/event-modifier.vue
を試したければ,http://localhost:5173/event-modifier.vue
となります.
このプレイグラウンドでは,Vapor Mode のコンパイラが使用されるので,source タブを見にいくと Vapor Mode のコンパイラが出力した JavaScript コードを確認することができます.
是非是非,今までの方針やコンセプトを理解しながら,いろんな出力コードを確認してみましょう.
Vapor Mode が現実になってきているのを肌で感じることができます :relaxed:
🚩 おさらい
さて,今回の記事では現在の Vue.js を支えている技術のおさらいと,今後控えている進化である Vapor Mode の概要と状況,実現方法などについて長々と見てきました.
Vapor Mode がパフォーマンスという観点で素晴らしいものであるのはもちろんのこと,全体を踏まえて Vue.js は何がすごいのか? なぜこんなことができるのか? という総括をしておくと,
今回の Vue.js の進化は,
- Single File Component が Vue ユーザーと内部構造のインタフェースになっている
- リアクティビティシステムが最小限の作用を提供可能な設計になっている
- Composition API という API 設計が柔軟である (Vue を開発する人の目線でも)
といった,Vue.js が歴史的に進化を重ねてきた結果がこれほどのパラダイムシフト(脱仮想 DOM)を可能にするほど美しい設計になっている.というのが感動ポイントです.
Vapor Mode もまだまだリポジトリが公開されて一ヶ月も経っていないので,これからどんどん開発が進んでくると思いますが,何か大きなニュースがあれば日本語話者向けに情報をまとめてお知らせしていければなと考えております.
Vapor Mode に限らず,Vue.js はずっと進化を続けていて,活発で,これからも目が離せません.年内には Vue 3.4 もリリースされそうです.(リアクティビティの改善や,パーサの改善が含まれます)
ぜひ皆さんも 最新のVue.js の動向をチェックして,コミュニティを盛り上げていきましょう!
最後に私ごとではありますが,こうやって Vue.js の進化に携われているのは Vue Fes Japan 2023 を通して世界中の Vue コミュニティにいる温かい方々に背中を押されたことが大きなきっかけとなっているので,kazupon さん率いる Vue.js の日本コミュニティの皆さん,Vue Fes Japan のスタッフ,海外で活躍されているのコミュニティの皆さん,そして Vue.js チームメンバーの皆さんには大変感謝しております.
これからも一緒に Vue.js をどんどん進化させて盛り上げていきましょう! ! !!
完
Discussion