0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Vue3.6がそろそろ正式リリースされそうなので触ってみた

0
Posted at

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 を前提としないレンダリング方式を採るライブラリもあります。

Gemini_Generated_Image_9t6roz9t6roz9t6r.png

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 に及ばないケースがあります。

image.png

image.png

導入方法

Vapor Mode は互換性を意識して設計されており、大きく次の2つの導入パターンが想定されています。

  • アプリ全体を Vapor に寄せる
  • 従来の VDOM と Vapor を混在させる(段階導入)

アプリ全体で有効にする場合は createVaporApp を使い、コンポーネント側を <script setup vapor> にします:

main.ts
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 を付与します):

main.ts
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')
HomeView.vue
<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 で書き換えてみます。

React
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)だと次のような形になります(例として最小限にしています)。

Vue
<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 とロジックを同じスコープにまとめやすく、同一ファイル内で小さなコンポーネント分割もしやすいです。

vue-jsx-vapor
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 で、パフォーマンスを測定できそうな画面を作成しました。作成時に詰まった点を下にまとめます。流れは次のとおりです。

  1. AI で簡単なデモサイトを従来どおりに開発
  2. アプリ全体で Vapor Mode を有効化
  3. 動かなくなったところを修正

Vue Routerはまだ制約がある

執筆時点では Vapor Mode と Vue Router の組み合わせに制約があり、Roadmap 上でも WIP になっています。対応状況は以下の Issue を参照してください。

開いている PR を見ると VaporRouterViewVaporRouterLink として実装される想定のようです。現時点では回避が難しいため、デモアプリは単一ページで検証しました。

Vitestのテストが動かないケースがある

Roadmap の箇所でも触れましたが、Vue Test Utils 側が未対応のため、Vapor コンポーネントを mount するユニットテストは動かないケースがありました。公式リポジトリでもサーバを起動した E2E テストが中心のため、対応前に移行して検証するなら E2E を優先するのが現実的です。

HomeView.spec.ts
// 動かくなくなるコード例
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 を使ってコンポーネントを直接レンダリングするテストは動作しました。

VaporButton.browser.spec.ts
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 を渡せば動作しました。

preview.ts
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のエコシステムを享受したまま、パフォーマンスの高いサイトが作成できると感じています。

参考

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?