Vue 3.6で何が変わるのか?
注意: 以下は執筆時点の公開情報(Issue / RFC / canary/nightly 等)をもとにした整理です。バージョン、API 名、対応状況は変更される可能性があります。
Vue 3.6 では、主に次の2点が議論・開発されています。
-
Vapor Mode
- 仮想 DOM(VDOM)を介さないレンダリングパスを追加し、更新性能やメモリ効率の改善を狙う取り組みです。
- SFC では
<script setup vapor>のような形で opt-in することができます。 - JSX/TSX から Vapor コンポーネントを定義する仕組みとして
vue-jsx-vaporが提供されています。 - Vue Router / Vue Test Utils など周辺エコシステムは対応が進行中で、現時点では未対応・制約があります(後述)。
-
Reactivity(リアクティビティ)実装の改善
- Alien Signals などの知見を取り込んだ最適化が進んでいます。
- アルゴリズムの説明としてはVue3.5までは「pull 型」のアルゴリズムを使用されていましたが、 「push-pull 型」のアルゴリズムに変更されました(詳しくは参考リンク参照)。
Vapor Modeを触ってみた
仮想DOMってなに?
仮想 DOM(Virtual DOM)とは、メモリ上に保持する UI 表現です。UI 更新時に仮想 DOM 同士の差分を計算し、その差分を実 DOM に反映するために使われます。React や Vue などで広く採用されていますが、差分計算やオブジェクト生成により、状況によっては CPU・メモリのコストが増えることがあります。
その点を補うために、SolidJS や Svelte のように仮想 DOM を前提としないレンダリング方式を採るライブラリもあります。
Vapor Modeとは?
Vapor Mode は、仮想 DOM を介さず、より直接的な DOM 更新コードへコンパイルすることで、更新コストの削減を狙うモードです。方向性としては「必要な箇所だけを更新する」特性に寄せ、SolidJS のようなパフォーマンス特性を目指す文脈で語られています。
Vapor Modeの開発自体は以前から議論されていましたが、最近になって Vue 3.6 の beta が公開され、正式リリースが近づいています。
実際の性能
js-framework-benchmark では、条件ごとの計測結果が公開されています。Vue3.6のベンチマーク結果が載っていたので記載してみました。ベンチマーク上ではvue-vapor-v3.6.0 が Vapor Mode 使用時の Vue の結果で、vue-v3.6 が Vapor Mode を使用していないとき(従来)の結果です。この2つを比べると、確かに Vapor Mode でものによって 30% 程度改善しているのがわかります。
また、パフォーマンスで有名な SolidJS や Svelte と比べてみると、UI 変更時のパフォーマンスと**メモリ割り当て(allocation)**については、指標によっては引けを取らない結果になっています。一方で first paint や ファイルサイズなどは、まだ SolidJS に及ばないケースがあります。
導入方法
Vapor Mode は互換性を意識して設計されており、大きく次の2つの導入パターンが想定されています。
- アプリ全体を Vapor に寄せる
- 従来の VDOM と Vapor を混在させる(段階導入)
アプリ全体で有効にする場合は createVaporApp を使い、コンポーネント側を <script setup vapor> にします:
import { createVaporApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
const app = createVaporApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
部分的に導入する場合は、プラグイン経由で Vapor コンポーネントを混在させます(Vapor を使いたいコンポーネントだけに vapor を付与します):
import { createApp, vaporInteropPlugin } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(vaporInteropPlugin)
app.use(createPinia())
app.use(router)
app.mount('#app')
<script setup vapor lang="ts"></script>
<template>
<section class="home">
<h1>Vue Performance Playground</h1>
<p>重いレンダリングの再現と計測のためのサンプルです。</p>
</section>
</template>
JSXでもVapor Mode
Vue を JSX/TSX で実装する方法は以前からありますが、Vapor Mode 向けの仕組みとして vue-jsx-vapor が提供されています。
またただvapor modeに対応しただけではなく、Reactに近い記法で実装することができるようになりました。(Reactでいいなんて言わないで)
Vue の JSX/TSX には、たとえば次の特徴があります(詳細は公式 README を参照してください)。
- vueのディレクティブ(
v-ifなど)を使用することができる - 拡張子が
.vueではなく.tsxで実装できる -
defineVaporComponentのような API で Vapor コンポーネントを定義できる
というわけで、React っぽいコンポーネント例を Vue と TSX で書き換えてみます。
import { useEffect, useState, useMemo } from "react";
type User = {
id: number;
name: string;
email: string;
};
type Props = {
search: string;
};
export const UserList: React.FC<Props> = ({ search }) => {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(false);
// データ取得(副作用)
useEffect(() => {
const fetchUsers = async () => {
setLoading(true);
try {
const res = await fetch("/api/users");
const data: User[] = await res.json();
setUsers(data);
} catch (e) {
console.error("failed to fetch users", e);
} finally {
setLoading(false);
}
};
fetchUsers();
}, []);
// メモ化されたフィルタ処理
const filteredUsers = useMemo(() => {
return users.filter((user) =>
user.name.toLowerCase().includes(search.toLowerCase())
);
}, [users, search]);
if (loading) {
return <div>Loading...</div>;
}
return (
<div>
<h2>User List</h2>
{filteredUsers.length === 0 ? (
<EmptyState />
) : (
<ul>
{filteredUsers.map((user) => (
<UserItem key={user.id} user={user} />
))}
</ul>
)}
</div>
);
};
// 小さいコンポーネントに分割
const UserItem: React.FC<{ user: User }> = ({ user }) => {
return (
<li style={{ marginBottom: 8 }}>
<div>{user.name}</div>
<small>{user.email}</small>
</li>
);
};
// プレゼンテーション用コンポーネント
const EmptyState = () => {
return <div>No users found</div>;
};
まず、通常の Vue(SFC + TypeScript)だと次のような形になります(例として最小限にしています)。
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
type User = {
id: number
name: string
email: string
}
type Props = {
search: string
}
const props = defineProps<Props>()
const users = ref<User[]>([])
const loading = ref(false)
const fetchUsers = async () => {
loading.value = true
try {
const res = await fetch('/api/users')
users.value = (await res.json()) as User[]
} catch (e) {
console.error('failed to fetch users', e)
} finally {
loading.value = false
}
}
onMounted(fetchUsers)
const filteredUsers = computed(() => {
const q = props.search.toLowerCase()
return users.value.filter((u) => u.name.toLowerCase().includes(q))
})
</script>
<template>
<div v-if="loading">Loading...</div>
<div v-else>
<h2>User List</h2>
<div v-if="filteredUsers.length === 0">No users found</div>
<ul v-else>
<li v-for="user in filteredUsers" :key="user.id" style="margin-bottom: 8px">
<div>{{ user.name }}</div>
<small>{{ user.email }}</small>
</li>
</ul>
</div>
</template>
次に vue-jsx-vapor を使った TSX 例です。UI とロジックを同じスコープにまとめやすく、同一ファイル内で小さなコンポーネント分割もしやすいです。
import { computed, onMounted, ref } from 'vue'
import { defineVaporComponent } from 'vue-jsx-vapor'
type User = {
id: number
name: string
email: string
}
type Props = {
search: string
}
const UserItem = defineVaporComponent((props: { user: User }) => {
const { user } = props
return (
<li style={{ marginBottom: '8px' }}>
<div>{user.name}</div>
<small>{user.email}</small>
</li>
)
})
const EmptyState = defineVaporComponent(() => {
return <div>No users found</div>
})
export default defineVaporComponent((props: Props) => {
const users = ref<User[]>([])
const loading = ref(false)
const fetchUsers = async () => {
loading.value = true
try {
const res = await fetch('/api/users')
users.value = (await res.json()) as User[]
} finally {
loading.value = false
}
}
onMounted(fetchUsers)
const filteredUsers = computed(() => {
const q = props.search.toLowerCase()
return users.value.filter((u) => u.name.toLowerCase().includes(q))
})
/*
// vueのディレクティブを使用した書き方(例)
return (
<>
<div v-if={loading.value}>Loading...</div>
<div v-else>
<h2>User List</h2>
<EmptyState v-if={filteredUsers.value.length === 0} />
<ul v-else>
<UserItem v-for={(item, index) in filteredUsers.value} key={index} user={item} />
</ul>
</div>
</>
)
*/
// 純粋なtsxでの書き方
return (
<div>
{loading.value ? (
<div>Loading...</div>
) : (
<div>
<h2>User List</h2>
{filteredUsers.value.length === 0 ? (
<EmptyState />
) : (
<ul>
{filteredUsers.value.map((u) => (
<UserItem key={u.id} user={u} />
))}
</ul>
)}
</div>
)}
</div>
)
})
実際にVapor Modeで小規模アプリを作ってみた
というわけで、実際に Vapor Mode を使って小規模なデモアプリを作ってみました。Vue 公式でも Vapor Mode はまだ不安定だったり、エッジケースを考慮できていない部分があると言及されています。適用する場合は、小規模アプリで全体に適用するか、既存アプリの一部を段階的に置き換えるのがよさそうです。
今回は Vue + TypeScript + Vitest + Storybook + MSW で、パフォーマンスを測定できそうな画面を作成しました。作成時に詰まった点を下にまとめます。流れは次のとおりです。
- AI で簡単なデモサイトを従来どおりに開発
- アプリ全体で Vapor Mode を有効化
- 動かなくなったところを修正
Vue Routerはまだ制約がある
執筆時点では Vapor Mode と Vue Router の組み合わせに制約があり、Roadmap 上でも WIP になっています。対応状況は以下の Issue を参照してください。
開いている PR を見ると VaporRouterView と VaporRouterLink として実装される想定のようです。現時点では回避が難しいため、デモアプリは単一ページで検証しました。
Vitestのテストが動かないケースがある
Roadmap の箇所でも触れましたが、Vue Test Utils 側が未対応のため、Vapor コンポーネントを mount するユニットテストは動かないケースがありました。公式リポジトリでもサーバを起動した E2E テストが中心のため、対応前に移行して検証するなら E2E を優先するのが現実的です。
// 動かくなくなるコード例
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import HomeView from '../views/HomeView.vue'
import { vaporInteropPlugin } from 'vue'
const mountHomeView = () => {
return mount(HomeView, {
global: {
plugins: [vaporInteropPlugin],
},
})
}
describe('HomeView', () => {
it('タイトルを表示する', () => {
const wrapper = mountHomeView()
expect(wrapper.text()).toContain('Vue Performance Playground')
})
it('説明文を表示する', () => {
const wrapper = mountHomeView()
expect(wrapper.text()).toContain('重いレンダリングの再現と計測のためのサンプルです。')
})
it('section.home が存在する', () => {
const wrapper = mountHomeView()
expect(wrapper.find('section.home').exists()).toBe(true)
})
})
ちなみに、Vitest の browser mode を使ってコンポーネントを直接レンダリングするテストは動作しました。
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { page } from 'vitest/browser'
import { createApp, defineComponent, h } from 'vue'
import { vaporInteropPlugin } from 'vue'
import VaporButton from '../components/VaporButton.vue'
/**
* Vapor コンポーネントは通常の createApp のルートにできないため、
* 通常の Vue ラッパーコンポーネント経由でマウントする。
*/
function mountVapor(component: unknown, props: Record<string, unknown>) {
const container = document.createElement('div')
document.body.appendChild(container)
const Wrapper = defineComponent({
render() {
return h(component as Parameters<typeof h>[0], props)
},
})
const app = createApp(Wrapper)
app.use(vaporInteropPlugin)
app.mount(container)
return {
container,
cleanup: () => {
app.unmount()
container.remove()
},
}
}
describe('VaporButton (browser mode)', () => {
let cleanup: (() => void) | undefined
afterEach(() => {
cleanup?.()
cleanup = undefined
})
it('渡した label を表示する', async () => {
;({ cleanup } = mountVapor(VaporButton, { label: '保存' }))
await expect.element(page.getByText('保存')).toBeInTheDocument()
})
it('button とクラス名を持つ', async () => {
const { container, cleanup: c } = mountVapor(VaporButton, { label: '送信' })
cleanup = c
await expect.element(page.getByRole('button', { name: '送信' })).toBeInTheDocument()
const button = container.querySelector('button.vapor-button')
expect(button).not.toBeNull()
})
})
Storybookは問題なく動く
Pinia のプラグインを渡すときと同様に、vaporInteropPlugin を渡せば動作しました。
import type { Preview } from '@storybook/vue3-vite'
import { setup } from '@storybook/vue3-vite'
import { vaporInteropPlugin } from 'vue'
setup((app) => {
app.use(vaporInteropPlugin)
// app.use(router) みたいに他の plugin も可
})
const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
}
export default preview
さいごに
今回は Vue の Vapor Mode について、現時点で触れる範囲をまとめました。未対応の周辺ツールはありますが、互換性を意識した設計のため段階導入の選択肢がある点は試しやすく、小規模であれば上記で書いた点以外は問題なく動作していました。今後の対応状況を追いつつ、いずれはVapor Modeが当たり前の世界になっていけばVueのエコシステムを享受したまま、パフォーマンスの高いサイトが作成できると感じています。
参考
- Reactivity in Vue
- https://speakerdeck.com/hiranuma/alien-signals-noshi-jian-topahuomansuzui-shi-hua
- State of Vue 2026
- https://github.com/stackblitz/alien-signals?tab=readme-ov-file


