Vapor Mode で変わる Vue JSX 体験
Vue Advent Calendar 2025 25 日目(シーズン 2)の記事です。
はじめに
ナイトウ(@naitokosuke)と申します。
今回は vue-jsx-vapor を紹介します。
Vue 3.6 が 2025 年 12 月 23 日にリリースされました。
Vapor Mode についてはおそらく一度は目にしたことがあるのではないかと思います。
JSX と Vapor Mode についての情報はあまり目にしたことがないと思い今回調べてみました。
こういう JSX がほしい!と思った方は要 check です!
まずはこれを見てほしい
import { ref } from "vue";
export default () => {
const count = ref(0);
return <button onClick={() => count.value++}>{count.value}</button>;
};
これが vue-jsx-vapor のコードです。JSX ですよね。
React など JSX を用いるライブラリ・フレームワークをお使いの方はしっくりくるんじゃないかと。
Vue ではこれまでも JSX を使用できましたが、 Vue で JSX を使われてきた方はもしかすると驚くかもしれません。
Vue Vapor Mode ?
Vue Vapor Mode は仮想 DOM を使用しない新しいレンダリングモードです。
SFC で Vapor Mode を有効にするには、<script setup vapor> と記述します。
<script setup vapor>
import { ref } from "vue";
const count = ref(0);
</script>
<template>
<button @click="count++">{{ count }}</button>
</template>
vue-jsx-vapor ?
vue-jsx-vapor は Vue の JSX の Vapor Mode 用コンパイラ & ビルドツールプラグインです。
これによって Vue JSX で Vapor Mode を利用できます。
キーマンはこの方
vue-jsx-vapor は zhiyuanzmj さんが中心となって開発しています。
(お名前の読み方が分からないのでこのアイコンで覚えさせていただいています 😇)

zhiyuanzmj さんは Oxc の Vue サポート強化にも取り組んでいます。
eslint-plugin-vue は独自のコンパイラを使用しているため Oxc との互換性に課題がありました。
zhiyuanzmj さんは vue-oxc-parser を開発し、Vue ファイルを Oxc の解析に統合する実装を進めています。
主な特徴

- Rust(Oxc)でコンパイラを実装(Babel 比で 20 倍の性能向上)
- 仮想 DOM と Vapor DOM の両方の生成をサポート
- 全ての Vue 組み込みディレクティブをサポート
- Vue のほとんどのマクロをサポート(JSX フレンドリー)
- Volar プラグインによる型安全性(VS Code 拡張機能 TS Macro)
- ESLint プラグインによるディレクティブとマクロの自動フォーマット
Getting Started
公式ドキュメントを見れば問題なく vue-jsx-vapor を始められます。
注意点としては以下です。
- Vue 3.6 以上
- VS Code 拡張機能 TS Macro
- 型チェックは @ts-macro/tsc(
tscではなくtsmc)
次世代の Vue JSX の書き方
import { ref } from "vue";
export default function Counter() {
const count = ref(0);
return <button onClick={() => count.value++}>{count.value}</button>;
}
関数コンポーネントとして定義し、Vue のリアクティブな値をそのまま使用できます。
以前の Vue JSX では、リアクティブな更新を正しく行うために defineComponent() でラップし、render 関数を返す必要がありました。
import { defineComponent, ref } from "vue";
export default defineComponent(() => {
const count = ref(0);
return () => <button onClick={() => count.value++}>{count.value}</button>;
});
defineComponent() の使用だけでなく、JSX を返す関数を返す二重構造のような記法となっていました。
vue-jsx-vapor では JSX を直接返すだけでよく、React の関数コンポーネントに近い書き心地で開発できます。
defineVaporComponent()
Vapor についても defineVaporComponent() という API が存在します。(Vue 3.6 から)
関数コンポーネントで props を分割代入するとリアクティビティが失われます。
props destructure をするためには defineVaporComponent() を使う必要があります。
import { defineVaporComponent } from "vue";
export default defineVaporComponent(({ name }: { name: string }) => {
return <p>Hello, {name}!</p>;
});
Interop(Vapor と仮想 DOM の相互運用)
SFC と同様に、vue-jsx-vapor でも Vapor と仮想 DOM を併用できます。
2025 年 12 月 25 日にリリースされた v3.1 で仮想 DOM 生成機能が追加されました。
v3.1 より前の vue-jsx-vapor は Vapor DOM のみを生成していました。
そのため仮想 DOM 部分は @vitejs/plugin-vue-jsx(さらに内部で @vue/babel-plugin-jsx を使用)など別のプラグインに委譲する必要がありました。
v3.1 以降は vue-jsx-vapor 単体で Vapor と仮想 DOM の両方を生成できます。
defineVaporComponent 内の JSX は Vapor DOM として、それ以外の JSX は仮想 DOM としてコンパイルされます。
仮想 DOM 内で Vapor コンポーネントを使用したり、Vapor コンポーネントの中で仮想 DOM を使用できたりします。
import { createApp, vaporInteropPlugin } from "vue";
import App from "./App.tsx";
createApp(App).use(vaporInteropPlugin).mount("#app");
import {
defineComponent,
defineVaporComponent,
computed,
ref,
shallowRef as useRef,
} from "vue";
const Comp = defineVaporComponent(({ count = 0 }) => {
defineExpose({
double: computed(() => count * 2),
});
return <span> x 2 = </span>;
});
export default defineComponent(() => {
const count = ref(1);
const compRef = useRef();
return () => (
<>
<input v-model={count.value} />
<Comp count={count.value} ref={compRef}></Comp>
<span v-if={compRef.value}>{compRef.value.double}</span>
</>
);
});
Vapor コンポーネントと仮想 DOM コンポーネントは相互に利用できます。
既存の仮想 DOM プロジェクトにパフォーマンスが重要な部分だけ Vapor を導入する、といった段階的な移行が可能です。
Vue のディレクティブのサポート
vue-jsx-vapor では全ての Vue 組み込みディレクティブをサポートしています。
| Directive | Vue | Volar |
|---|---|---|
| v-if, v-else-if, v-else | ✅ | ✅ |
| v-slot, v-slots | ✅ | ✅ |
| v-for | ✅ | ✅ |
| v-model | ✅ | ✅ |
| v-html, v-text | ✅ | / |
| v-once | ✅ | / |
(以下はディレクティブの一部です。)
import { ref } from "vue";
export default () => {
const visible = ref(false);
const items = ref(["Apple", "Banana", "Cherry"]);
return (
<div>
<p v-if={visible.value}>This is visible.</p>
<input v-model={visible.value} type="checkbox" />
<ul>
{items.value.map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
</div>
);
};
defineStyle マクロ
JSX でスタイルを扱うのは煩雑になりがちな印象ですが、vue-jsx-vapor の defineStyle は Vue SFC の <style scoped> に近い体験を提供します。
この機能が普段 SFC で <style scoped> を使用している僕にとってはとても魅力的に思えます。
function Button({ color = "blue" }) {
defineStyle(`
button {
background: ${color};
&:hover {
opacity: 0.8;
}
}
`);
return <button>Click</button>;
}
デフォルトでは関数内で定義した場合は scoped、ファイルのトップレベルで定義した場合は global になります。
defineComponent() から関数コンポーネントへの記法の変化
vue-jsx-vapor による JSX のコードをいくつか見てきました。
これまでに Vue で JSX を使用されてきた方は少し混乱されたかもしれません。
この issue で従来の defineComponent から Vapor Mode の関数コンポーネントへの移行について議論されています。
import { defineComponent, ref } from "vue";
export default defineComponent(() => {
const count = ref(0);
// render 関数(JSX を返す関数)を返す
return () => <button onClick={() => count.value++}>{count.value}</button>;
});
仮想 DOM では defineComponent 内で setup と render を区別するために、関数を返すという二重構造が必要でした。
メンテナーの zhiyuanzmj さんは issue で次のように説明しています。
defineComponent is VDOM era's compromise that required returning a method
(defineComponent は仮想 DOM 時代の妥協であり、メソッドを返す必要があった)
仮想 DOM では render 関数が繰り返し呼ばれ、そのたびに仮想 DOM ツリーを再構築します。
そのため setup(1 回だけ実行)と render(繰り返し実行)を分離する必要がありました。
一方、Vapor Mode では仮想 DOM ツリーを再構築するという概念自体がありません。
コンパイル時にテンプレートや JSX が実 DOM を直接操作するコードに変換され、リアクティブな値が変わると該当する DOM ノードだけが更新されます。
render 関数を繰り返し呼び出す必要がないため、setup と render を分離する二重構造も不要になりました。
まとめ
vue-jsx-vapor を紹介しました。
vue-jsx-vapor は Vue の JSX で Vapor Mode を利用するためのコンパイラ & ビルドツールプラグインです。
Rust(Oxc)で実装されており、従来の Babel ベースと比較して 20 倍の性能向上を実現しています。
従来の Vue JSX では defineComponent() 内で render 関数を返す二重構造が必要でしたが、Vapor Mode では仮想 DOM を使用しないため、JSX を直接返す関数コンポーネントとして記述できるようになりました。
また、defineStyle マクロにより SFC の <style scoped> のような体験も得られます。
v3.1 からは Vapor と仮想 DOM の両方を生成でき、既存プロジェクトへの段階的な導入も可能です。
Vue 3.6 以上が必要でまだ実験的な段階ですが、JSX で Vue を書きたい方はぜひチェックしてみてください。
最後に
最後まで読んでいただきありがとうございました!
Discussion