😶‍🌫️

Vapor Mode で変わる Vue JSX 体験

に公開
2025/12/31

Vue Advent Calendar 2025 25 日目(シーズン 2)の記事です。

https://qiita.com/advent-calendar/2025/vue

はじめに

ナイトウ(@naitokosuke)と申します。

今回は vue-jsx-vapor を紹介します。

Vue 3.6 が 2025 年 12 月 23 日にリリースされました。
Vapor Mode についてはおそらく一度は目にしたことがあるのではないかと思います。
JSX と Vapor Mode についての情報はあまり目にしたことがないと思い今回調べてみました。

https://github.com/vuejs/vue-jsx-vapor

https://x.com/lcMenci/status/1990680368488591411

こういう JSX がほしい!と思った方は要 check です!

まずはこれを見てほしい

import { ref } from "vue";

export default () => {
  const count = ref(0);

  return <button onClick={() => count.value++}>{count.value}</button>;
};

https://repl.zmjs.dev/vue-jsx-vapor

これが 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>

https://speakerdeck.com/hiranuma/alien-signals-noshi-jian-topahuomansuzui-shi-hua

https://rirfee.com/blog/2025/12/vapor-modeをつまみぐいしてvue-3-6に備えよう/

vue-jsx-vapor ?

vue-jsx-vapor は Vue の JSX の Vapor Mode 用コンパイラ & ビルドツールプラグインです。
これによって Vue JSX で Vapor Mode を利用できます。

https://github.com/vuejs/vue-jsx-vapor

https://jsx-vapor.netlify.app

キーマンはこの方

vue-jsx-vapor は zhiyuanzmj さんが中心となって開発しています。
(お名前の読み方が分からないのでこのアイコンで覚えさせていただいています 😇)

zhiyuanzmj さんの GitHub プロフィール

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

https://github.com/oxc-project/oxc/issues/15761

主な特徴

Vue JSX Vapor 公式ドキュメントホームのスクショ

  • Rust(Oxc)でコンパイラを実装(Babel 比で 20 倍の性能向上)
  • 仮想 DOM と Vapor DOM の両方の生成をサポート
  • 全ての Vue 組み込みディレクティブをサポート
  • Vue のほとんどのマクロをサポート(JSX フレンドリー)
  • Volar プラグインによる型安全性(VS Code 拡張機能 TS Macro)
  • ESLint プラグインによるディレクティブとマクロの自動フォーマット

Getting Started

https://jsx-vapor.netlify.app/introduction/getting-started.html

公式ドキュメントを見れば問題なく 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 から)

https://jsx-vapor.netlify.app/introduction/migration.html

関数コンポーネントで 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 を併用できます。

https://jsx-vapor.netlify.app/introduction/interop.html

2025 年 12 月 25 日にリリースされた v3.1 で仮想 DOM 生成機能が追加されました。
https://x.com/zhiyuanzmj/status/2004109574841029117

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 を使用できたりします。

Vapor in 仮想 DOM playground

https://repl.zmjs.dev/vuejs/vapor-in-virtual-dom

index.ts
import { createApp, vaporInteropPlugin } from "vue";
import App from "./App.tsx";
createApp(App).use(vaporInteropPlugin).mount("#app");
App.tsx
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>
    </>
  );
});

仮想 DOM in Vapor playground

https://repl.zmjs.dev/vuejs/virtual-dom-in-vapor

Vapor コンポーネントと仮想 DOM コンポーネントは相互に利用できます。
既存の仮想 DOM プロジェクトにパフォーマンスが重要な部分だけ Vapor を導入する、といった段階的な移行が可能です。

Vue のディレクティブのサポート

vue-jsx-vapor では全ての Vue 組み込みディレクティブをサポートしています。

https://jsx-vapor.netlify.app/features/directives.html

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> を使用している僕にとってはとても魅力的に思えます。

https://jsx-vapor.netlify.app/features/macros.html

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 を使用されてきた方は少し混乱されたかもしれません。

https://github.com/vuejs/vue-jsx-vapor/issues/18

この 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 を書きたい方はぜひチェックしてみてください。

最後に

最後まで読んでいただきありがとうございました!

GitHubで編集を提案
Vue・Nuxt 情報が集まる広場 / Plaza for Vue・Nuxt.

Discussion