Vue 3.5 + WebAssembly フロントエンドパフォーマンス極限最適化:リアクティブシステムとWasmモジュールのディープインテグレーション実践

前端开发

概要

  • Vue 3.5リアクティブシステムの基盤最適化メカニズムを習得し、Proxy-basedリアクティブとShallow Reactiveのパフォーマンス差異と選定戦略を理解する
  • Vue 3.5におけるWebAssemblyモジュールのインテグレーションパターンを深掘りし、計算集約型タスクのWasm高速化とメモリゼロコピー転送を実現する
  • プロダクションレベルのフロントエンドパフォーマンス最適化フルチェーン実践:Wasmモジュールオンデマンド読み込み、SharedArrayBufferマルチスレッド、Core Web Vitals達成ソリューション

目次


一、Vue 3.5リアクティブシステムパフォーマンス分析

1.1 Vue 3.5リアクティブシステムのコア変更

Vue 3.5はリアクティブシステムの大幅なリファクタリングを行い、Reactive Effect Scope最適化とより効率的な依存関係追跡メカニズムを導入した。これらの基盤変更を理解することは、パフォーマンス最適化の前提条件である。

依存関係追跡の最適化:Vue 3.5は依存関係収集を線形スキャンからビットマスク(Bitmask)方式に最適化し、依存関係追跡の時間計算量をO(n)からO(1)に削減した。大量のリアクティブ依存関係を持つコンポーネントにおいて、この最適化は顕著なレンダリングパフォーマンスの向上をもたらす。

Effect Scopeリファクタリング:Vue 3.5はよりきめ細かいEffect Scope管理を導入し、ネストされたScopeの自動クリーンアップをサポートし、メモリリークを回避する。また、computedプロパティのキャッシュ戦略が「ダーティチェック」から「依存バージョン番号比較」に変更され、不要な再計算が削減された。

import { reactive, computed, effectScope, shallowReactive } from 'vue'

interface DataTableConfig {
  rows: Record<string, unknown>[]
  columns: ColumnDef[]
  pageSize: number
}

export function useDataTable(config: DataTableConfig) {
  const scope = effectScope()

  const state = scope.run(() => {
    const internalData = shallowReactive(config.rows)
    const visibleColumns = reactive(new Set(config.columns.map(c => c.key)))
    const pagination = reactive({ page: 1, pageSize: config.pageSize })

    const filteredData = computed(() => {
      const start = (pagination.page - 1) * pagination.pageSize
      return internalData.slice(start, start + pagination.pageSize)
    })

    const columnStats = computed(() => {
      return config.columns
        .filter(col => visibleColumns.has(col.key))
        .map(col => ({
          key: col.key,
          type: col.type,
          uniqueValues: new Set(internalData.map(row => row[col.key])).size,
        }))
    })

    return { internalData, visibleColumns, pagination, filteredData, columnStats }
  })!

  function dispose() {
    scope.stop()
  }

  return { ...state, dispose }
}

1.2 Shallow Reactive vs Deep Reactiveの選定

Vue 3.5はreactiveshallowReactiveの2つのリアクティブAPIを提供している。不適切な選定は深刻なパフォーマンス問題を引き起こす可能性がある。

API リアクティブ深度 適用シナリオ パフォーマンス特性
reactive ディープリアクティブ フォーム、設定など深層追跡が必要なシナリオ 依存関係収集オーバーヘッド大
shallowReactive シャローリアクティブ 大規模データセット、APIレスポンスなど 依存関係収集オーバーヘッド小
readonly 読み取り専用プロキシ イミュータブルデータ表示 ほぼオーバーヘッドなし
shallowRef シャロー参照 大きなオブジェクトの全体置換 更新トリガーオーバーヘッド小

コア原則:1000要素を超えるデータリストに対しては、shallowReactiveまたはshallowRefを使用しなければならない。ディープリアクティブは各要素と各プロパティに依存関係を構築するため、初期化と更新時に多大なオーバーヘッドが発生する。

import { shallowRef, triggerRef, shallowReactive } from 'vue'

export function useLargeDataset<T>(initialData: T[]) {
  const data = shallowRef<T[]>(initialData)

  function updateItem(index: number, updater: (item: T) => T) {
    const newArray = [...data.value]
    newArray[index] = updater(newArray[index])
    data.value = newArray
  }

  function batchUpdate(updates: Map<number, (item: T) => T>) {
    const newArray = [...data.value]
    for (const [index, updater] of updates) {
      newArray[index] = updater(newArray[index])
    }
    data.value = newArray
  }

  function appendItems(items: T[]) {
    data.value = [...data.value, ...items]
  }

  return { data, updateItem, batchUpdate, appendItems }
}

1.3 Computedキャッシュと遅延評価

Vue 3.5のcomputedプロパティは遅延評価戦略を採用しており、読み取り時のみ計算される。ただし、computedのキャッシュ無効化メカニズムには特に注意が必要である:

  • 依存関係の変更で即座に無効化:computedが依存するリアクティブデータが変更されると、computedはdirtyとしてマークされ、次回の読み取り時に再計算される
  • 読み取り時のみ計算:dirtyとしてマークされても、コンシューマーが読み取らなければ計算はトリガーされない
  • キャッシュはコンポーネントレベル:異なるコンポーネントが同じcomputedを読み取る場合、それぞれがキャッシュを保持する

高頻度で変化する計算プロパティには、手動キャッシュ戦略の使用を推奨する:

import { ref, watch, computed } from 'vue'

export function useDebouncedCompute<T, R>(
  source: Ref<T>,
  computeFn: (value: T) => R,
  delayMs: number = 100
) {
  const result = ref<R>() as Ref<R>
  const isComputing = ref(false)
  let timer: ReturnType<typeof setTimeout>

  const debouncedCompute = () => {
    clearTimeout(timer)
    timer = setTimeout(() => {
      isComputing.value = true
      result.value = computeFn(source.value)
      isComputing.value = false
    }, delayMs)
  }

  watch(source, debouncedCompute, { immediate: true })

  return { result, isComputing }
}

二、Vue 3.5におけるWebAssemblyインテグレーションアーキテクチャ

2.1 Wasmインテグレーションパターンの分類

Vue 3.5アプリケーションにWebAssemblyをインテグレーションする際、3つのコアパターンが存在する:

コンピュートオフロードパターン(Compute Offloading):計算集約型タスク(画像処理、暗号化、データ圧縮など)をJavaScriptメインスレッドからWasmモジュールにオフロードして実行する。最も一般的なインテグレーションパターンであり、5〜50倍のパフォーマンス向上をもたらす。

データパイプラインパターン(Data Pipeline):データフローの重要なノードでWasm処理を使用する。例:CSVパース→Wasmフィルタリング→Wasm集計→Vueレンダリング。大規模データのフロントエンド分析シナリオに適している。

レンダリング高速化パターン(Render Acceleration):Wasmを使用してCanvas/WebGLのピクセルバッファを直接操作し、JavaScriptのDOM操作ボトルネックをバイパスする。データビジュアライゼーション、ゲームなどのシナリオに適している。

┌─────────────────────────────────────────────┐
│              Vue 3.5 Application             │
│  ┌─────────┐  ┌─────────┐  ┌─────────┐     │
│  │Component │  │Component │  │Component │     │
│  │   A      │  │   B      │  │   C      │     │
│  └────┬─────┘  └────┬─────┘  └────┬─────┘     │
│       │              │              │          │
│  ┌────▼──────────────▼──────────────▼────┐     │
│  │          Wasm Bridge Layer            │     │
│  │  シリアライズ · デシリアライズ ·       │     │
│  │  メモリ管理 · 型変換                   │     │
│  └────────────────┬──────────────────────┘     │
│                   │                            │
│  ┌────────────────▼──────────────────────┐     │
│  │          Wasm Module Pool             │     │
│  │  ImageProc · Crypto · CSVParser ·     │     │
│  │  DataAgg · CanvasRenderer             │     │
│  └───────────────────────────────────────┘     │
└─────────────────────────────────────────────┘

2.2 Wasm Bridge Layer設計

Wasm Bridge LayerはVue 3.5とWasmモジュール間のブリッジであり、データのシリアライズ/デシリアライズ、メモリ管理、型変換を担当する。優れたBridge Layerは以下の問題を解決する必要がある:

  • 型安全性:TypeScript型定義とWasmエクスポート関数の型を整合させる
  • メモリ管理:Wasmリニアメモリの割り当てと解放、メモリリークの回避
  • エラーハンドリング:Wasm内部のパニックをJavaScript例外に変換する
  • 非同期読み込み:Wasmモジュールのオンデマンド読み込みとインスタンスプール管理
import { ref, type Ref } from 'vue'

interface WasmModuleExports {
  memory: WebAssembly.Memory
  malloc(size: number): number
  free(ptr: number): void
  process_image(dataPtr: number, width: number, height: number): number
  get_result_length(): number
  get_result_ptr(): number
}

export class WasmBridge {
  private module: WebAssembly.Instance | null = null
  private exports: WasmModuleExports | null = null
  private isLoading = ref(false)
  private isReady = ref(false)

  get ready(): Ref<boolean> {
    return this.isReady
  }

  async load(moduleUrl: string): Promise<void> {
    if (this.module) return
    this.isLoading.value = true

    try {
      const response = await fetch(moduleUrl)
      const buffer = await response.arrayBuffer()
      const { instance } = await WebAssembly.instantiate(buffer, {
        env: {
          console_log: (ptr: number, len: number) => {
            const message = this.readString(ptr, len)
            console.log('[Wasm]', message)
          },
          performance_now: () => performance.now(),
        },
      })

      this.module = instance
      this.exports = instance.exports as unknown as WasmModuleExports
      this.isReady.value = true
    } finally {
      this.isLoading.value = false
    }
  }

  processImage(imageData: Uint8ClampedArray, width: number, height: number): Uint8ClampedArray {
    if (!this.exports) throw new Error('Wasmモジュールが読み込まれていません')

    const inputPtr = this.exports.malloc(imageData.length)
    const inputSlice = new Uint8Array(this.exports.memory.buffer, inputPtr, imageData.length)
    inputSlice.set(imageData)

    this.exports.process_image(inputPtr, width, height)

    const resultLen = this.exports.get_result_length()
    const resultPtr = this.exports.get_result_ptr()
    const result = new Uint8ClampedArray(this.exports.memory.buffer, resultPtr, resultLen)
    const output = new Uint8ClampedArray(result)

    this.exports.free(inputPtr)

    return output
  }

  private readString(ptr: number, len: number): string {
    if (!this.exports) return ''
    const slice = new Uint8Array(this.exports.memory.buffer, ptr, len)
    return new TextDecoder().decode(slice)
  }
}

2.3 Composableインテグレーションパターン

Wasm BridgeをVue 3.5 Composableとしてカプセル化し、宣言的なWasm呼び出しを実現する:

import { ref, onUnmounted, type Ref } from 'vue'

interface UseWasmOptions {
  moduleUrl: string
  lazy?: boolean
}

export function useWasm<T extends WasmBridge>(BridgeClass: new () => T, options: UseWasmOptions) {
  const bridge = new BridgeClass()
  const isReady: Ref<boolean> = bridge.ready
  const error: Ref<Error | null> = ref(null)

  async function init() {
    try {
      await bridge.load(options.moduleUrl)
    } catch (e) {
      error.value = e instanceof Error ? e : new Error(String(e))
    }
  }

  if (!options.lazy) {
    init()
  }

  onUnmounted(() => {
    bridge.dispose?.()
  })

  return {
    bridge,
    isReady,
    error,
    init,
  }
}

// 使用例
const { bridge, isReady } = useWasm(ImageProcBridge, {
  moduleUrl: '/wasm/image-processor.wasm',
  lazy: false,
})

watch(isReady, (ready) => {
  if (ready) {
    const result = bridge.processImage(imageData, width, height)
  }
})

三、Wasmモジュール読み込みとライフサイクル管理

3.1 オンデマンド読み込み戦略

Wasmモジュールは通常サイズが大きく(1〜10MB)、全量読み込みは初回画面のパフォーマンスに深刻な影響を与える。オンデマンド読み込み戦略には以下が含まれる:

ルートレベル読み込み:Vue RouterのbeforeEnterフックで対応ページに必要なWasmモジュールを読み込む。

コンポーネントレベル読み込み:コンポーネントのonMountedフックで読み込み、Suspenseと組み合わせて読み込み状態を表示する。

インタラクショントリガー読み込み:ユーザーが初めてWasmを必要とする機能をトリガーした時のみ読み込む。例:「PDFエクスポート」ボタンのクリックなど。

import { defineAsyncComponent, ref } from 'vue'
import type { RouteLocationNormalized } from 'vue-router'

const wasmModuleCache = new Map<string, Promise<WebAssembly.Instance>>()

async function loadWasmModule(moduleName: string): Promise<WebAssembly.Instance> {
  if (wasmModuleCache.has(moduleName)) {
    return wasmModuleCache.get(moduleName)!
  }

  const promise = (async () => {
    const response = await fetch(`/wasm/${moduleName}.wasm`)
    const buffer = await response.arrayBuffer()
    const { instance } = await WebAssembly.instantiate(buffer, {
      env: { /* imports */ },
    })
    return instance
  })()

  wasmModuleCache.set(moduleName, promise)
  return promise
}

const DataVisualization = defineAsyncComponent({
  loader: async () => {
    const [component, _] = await Promise.all([
      import('@/components/DataVisualization.vue'),
      loadWasmModule('canvas-renderer'),
    ])
    return component
  },
  loadingComponent: LoadingSpinner,
  delay: 200,
  timeout: 10000,
})

3.2 Streaming Compilation

WebAssemblyのStreaming Compilationは、Wasmバイナリファイルのダウンロードと同時にコンパイルを行うことを可能にし、総読み込み時間を大幅に短縮する。サーバーが正しいContent-Type: application/wasmレスポンスヘッダーを返す必要がある。

async function loadWasmStreaming(url: string): Promise<WebAssembly.Instance> {
  if (!WebAssembly.validate) {
    return loadWasmFallback(url)
  }

  try {
    const response = await fetch(url)
    if (!response.ok) throw new Error(`${url}の取得に失敗しました`)

    const { instance } = await WebAssembly.instantiateStreaming(response, {
      env: { /* imports */ },
    })
    return instance
  } catch {
    return loadWasmFallback(url)
  }
}

async function loadWasmFallback(url: string): Promise<WebAssembly.Instance> {
  const response = await fetch(url)
  const buffer = await response.arrayBuffer()
  const { instance } = await WebAssembly.instantiate(buffer, {
    env: { /* imports */ },
  })
  return instance
}

3.3 モジュールキャッシュとバージョン管理

Wasmモジュールのキャッシュ戦略では、バージョン更新とキャッシュ一貫性を考慮する必要がある:

  • HTTPキャッシュ:Cache-ControlとETagによるブラウザレベルのキャッシュ
  • Service Workerキャッシュ:Service WorkerでWasmファイルをキャッシュし、オフライン使用をサポート
  • IndexedDBキャッシュ:コンパイル済みのWebAssembly.ModuleオブジェクトをIndexedDBに保存し、再コンパイルを回避
const WASM_CACHE_DB = 'wasm-module-cache'
const WASM_CACHE_STORE = 'compiled-modules'

async function loadWasmWithCache(moduleName: string, version: string): Promise<WebAssembly.Instance> {
  const cacheKey = `${moduleName}@${version}`

  const cachedModule = await getCompiledModuleFromIndexedDB(cacheKey)
  if (cachedModule) {
    const instance = await WebAssembly.instantiate(cachedModule, { env: {} })
    return instance
  }

  const response = await fetch(`/wasm/${moduleName}.wasm?v=${version}`)
  const buffer = await response.arrayBuffer()
  const { instance, module } = await WebAssembly.instantiate(buffer, { env: {} })

  await saveCompiledModuleToIndexedDB(cacheKey, module)

  return instance
}

async function getCompiledModuleFromIndexedDB(key: string): Promise<WebAssembly.Module | null> {
  const db = await openDB(WASM_CACHE_DB, 1, {
    upgrade(db) {
      db.createObjectStore(WASM_CACHE_STORE)
    },
  })
  return db.get(WASM_CACHE_STORE, key)
}

async function saveCompiledModuleToIndexedDB(key: string, module: WebAssembly.Module): Promise<void> {
  const db = await openDB(WASM_CACHE_DB, 1, {
    upgrade(db) {
      db.createObjectStore(WASM_CACHE_STORE)
    },
  })
  await db.put(WASM_CACHE_STORE, module, key)
}

四、計算集約型タスクのWasm高速化

4.1 画像処理の高速化

画像処理はWebAssemblyの最も典型的な応用シナリオである。画像フィルタを例にすると、JavaScriptで4K画像を処理するには200〜500msかかるが、Wasmではわずか10〜30msである。

Rustを使用したWasm画像処理モジュールの実装:

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub struct ImageProcessor {
    width: u32,
    height: u32,
    data: Vec<u8>,
}

#[wasm_bindgen]
impl ImageProcessor {
    #[wasm_bindgen(constructor)]
    pub fn new(width: u32, height: u32) -> Self {
        let data = vec![0u8; (width * height * 4) as usize];
        ImageProcessor { width, height, data }
    }

    pub fn apply_grayscale(&mut self) {
        for chunk in self.data.chunks_exact_mut(4) {
            let r = chunk[0] as f32;
            let g = chunk[1] as f32;
            let b = chunk[2] as f32;
            let gray = (0.299 * r + 0.587 * g + 0.114 * b) as u8;
            chunk[0] = gray;
            chunk[1] = gray;
            chunk[2] = gray;
        }
    }

    pub fn apply_gaussian_blur(&mut self, radius: u32) {
        let kernel = Self::generate_gaussian_kernel(radius);
        let mut output = self.data.clone();
        let w = self.width as usize;
        let h = self.height as usize;
        let k_size = kernel.len();
        let half = k_size / 2;

        for y in 0..h {
            for x in 0..w {
                let mut r_sum = 0.0f32;
                let mut g_sum = 0.0f32;
                let mut b_sum = 0.0f32;
                let mut weight_sum = 0.0f32;

                for ky in 0..k_size {
                    for kx in 0..k_size {
                        let px = (x + kx).saturating_sub(half).min(w - 1);
                        let py = (y + ky).saturating_sub(half).min(h - 1);
                        let idx = (py * w + px) * 4;
                        let weight = kernel[ky] * kernel[kx];

                        r_sum += self.data[idx] as f32 * weight;
                        g_sum += self.data[idx + 1] as f32 * weight;
                        b_sum += self.data[idx + 2] as f32 * weight;
                        weight_sum += weight;
                    }
                }

                let out_idx = (y * w + x) * 4;
                output[out_idx] = (r_sum / weight_sum) as u8;
                output[out_idx + 1] = (g_sum / weight_sum) as u8;
                output[out_idx + 2] = (b_sum / weight_sum) as u8;
                output[out_idx + 3] = self.data[out_idx + 3];
            }
        }

        self.data = output;
    }

    fn generate_gaussian_kernel(radius: u32) -> Vec<f32> {
        let size = (radius * 2 + 1) as usize;
        let sigma = radius as f32 / 3.0;
        let mut kernel = Vec::with_capacity(size);
        let mut sum = 0.0f32;

        for i in 0..size {
            let x = i as f32 - radius as f32;
            let val = (-x * x / (2.0 * sigma * sigma)).exp();
            kernel.push(val);
            sum += val;
        }

        for val in &mut kernel {
            *val /= sum;
        }
        kernel
    }

    pub fn get_data_ptr(&mut self) -> *mut u8 {
        self.data.as_mut_ptr()
    }

    pub fn get_data_length(&self) -> usize {
        self.data.len()
    }
}

4.2 データ分析の高速化

フロントエンドデータ分析シナリオ(CSVパース、統計計算、データピボット)もWasmの得意分野である。以下はWasm高速化された統計計算モジュールである:

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub struct DataAnalyzer {
    columns: Vec<String>,
    rows: Vec<Vec<f64>>,
}

#[wasm_bindgen]
impl DataAnalyzer {
    #[wasm_bindgen(constructor)]
    pub fn new() -> Self {
        DataAnalyzer {
            columns: Vec::new(),
            rows: Vec::new(),
        }
    }

    pub fn load_csv(&mut self, csv_data: &str) -> usize {
        let mut lines = csv_data.lines();
        if let Some(header) = lines.next() {
            self.columns = header.split(',').map(String::from).collect();
        }
        self.rows = lines
            .filter(|line| !line.is_empty())
            .map(|line| {
                line.split(',')
                    .filter_map(|v| v.trim().parse::<f64>().ok())
                    .collect()
            })
            .filter(|row| !row.is_empty())
            .collect();
        self.rows.len()
    }

    pub fn compute_statistics(&self, column_index: usize) -> JsValue {
        let values: Vec<f64> = self.rows.iter()
            .filter_map(|row| row.get(column_index).copied())
            .collect();

        if values.is_empty() {
            return JsValue::NULL;
        }

        let n = values.len() as f64;
        let mean = values.iter().sum::<f64>() / n;
        let variance = values.iter()
            .map(|v| (v - mean).powi(2))
            .sum::<f64>() / n;
        let std_dev = variance.sqrt();
        let min = values.iter().fold(f64::INFINITY, |a, &b| a.min(b));
        let max = values.iter().fold(f64::NEG_INFINITY, |a, &b| a.max(b));
        let mut sorted = values.clone();
        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
        let median = if sorted.len() % 2 == 0 {
            (sorted[sorted.len() / 2 - 1] + sorted[sorted.len() / 2]) / 2.0
        } else {
            sorted[sorted.len() / 2]
        };

        let result = serde_json::json!({
            "count": values.len(),
            "mean": mean,
            "stdDev": std_dev,
            "min": min,
            "max": max,
            "median": median,
            "p25": sorted[sorted.len() / 4],
            "p75": sorted[sorted.len() * 3 / 4],
        });

        JsValue::from_str(&result.to_string())
    }

    pub fn compute_correlation(&self, col_a: usize, col_b: usize) -> f64 {
        let pairs: Vec<(f64, f64)> = self.rows.iter()
            .filter_map(|row| {
                let a = row.get(col_a)?;
                let b = row.get(col_b)?;
                Some((*a, *b))
            })
            .collect();

        if pairs.len() < 2 {
            return 0.0;
        }

        let n = pairs.len() as f64;
        let mean_a = pairs.iter().map(|(a, _)| a).sum::<f64>() / n;
        let mean_b = pairs.iter().map(|(_, b)| b).sum::<f64>() / n;

        let cov = pairs.iter()
            .map(|(a, b)| (a - mean_a) * (b - mean_b))
            .sum::<f64>() / n;

        let var_a = pairs.iter()
            .map(|(a, _)| (a - mean_a).powi(2))
            .sum::<f64>() / n;

        let var_b = pairs.iter()
            .map(|(_, b)| (b - mean_b).powi(2))
            .sum::<f64>() / n;

        if var_a == 0.0 || var_b == 0.0 {
            return 0.0;
        }

        cov / (var_a.sqrt() * var_b.sqrt())
    }
}

4.3 暗号化とハッシュ計算

Web Crypto APIは基本的な暗号化機能を提供しているが、特定のアルゴリズム(Argon2、BLAKE3など)についてはWasm実装が必要である。以下はWasmベースのBLAKE3ハッシュ計算インテグレーションである:

import { ref, type Ref } from 'vue'

export function useBlake3Hash() {
  const isReady = ref(false)
  const isComputing = ref(false)
  let wasmExports: any = null

  async function init() {
    const response = await fetch('/wasm/blake3.wasm')
    const { instance } = await WebAssembly.instantiate(await response.arrayBuffer(), {})
    wasmExports = instance.exports
    isReady.value = true
  }

  async function hash(data: Uint8Array): Promise<string> {
    if (!wasmExports) await init()
    isComputing.value = true

    try {
      const inputPtr = wasmExports.malloc(data.length)
      const inputMemory = new Uint8Array(wasmExports.memory.buffer, inputPtr, data.length)
      inputMemory.set(data)

      const outputPtr = wasmExports.blake3_hash(inputPtr, data.length)
      const outputMemory = new Uint8Array(wasmExports.memory.buffer, outputPtr, 32)

      const hashHex = Array.from(outputMemory)
        .map(b => b.toString(16).padStart(2, '0'))
        .join('')

      wasmExports.free(inputPtr)
      return hashHex
    } finally {
      isComputing.value = false
    }
  }

  return { isReady, isComputing, init, hash }
}

五、メモリ管理とゼロコピーデータ転送

5.1 Wasmリニアメモリモデル

WebAssemblyはリニアメモリモデルを使用し、すべてのデータは連続したメモリ領域に格納される。JavaScriptとWasm間のデータ転送は、本質的にこの共有メモリの読み書きである。

ゼロコピー転送のコアアイデアは、JavaScriptとWasm間でデータをコピーするのを避け、Wasmのリニアメモリを直接操作することである。TypedArrayのビューメカニズムを通じて、JavaScriptはWasmメモリ内のデータを直接読み書きできる。

export class ZeroCopyBuffer {
  private memory: WebAssembly.Memory
  private allocatedPtrs: Set<number> = []

  constructor(memory: WebAssembly.Memory) {
    this.memory = memory
  }

  writeArray(data: Float64Array): number {
    const byteLength = data.byteLength
    const ptr = this.malloc(byteLength)
    const view = new Float64Array(this.memory.buffer, ptr, data.length)
    view.set(data)
    return ptr
  }

  readArray(ptr: number, length: number): Float64Array {
    return new Float64Array(this.memory.buffer, ptr, length)
  }

  writeString(str: string): number {
    const encoder = new TextEncoder()
    const bytes = encoder.encode(str)
    const ptr = this.malloc(bytes.length)
    const view = new Uint8Array(this.memory.buffer, ptr, bytes.length)
    view.set(bytes)
    return ptr
  }

  readString(ptr: number, length: number): string {
    const view = new Uint8Array(this.memory.buffer, ptr, length)
    return new TextDecoder().decode(view)
  }

  malloc(size: number): number {
    const ptr = this.exports.malloc(size)
    this.allocatedPtrs.add(ptr)
    return ptr
  }

  free(ptr: number): void {
    this.exports.free(ptr)
    this.allocatedPtrs.delete(ptr)
  }

  freeAll(): void {
    for (const ptr of this.allocatedPtrs) {
      this.exports.free(ptr)
    }
    this.allocatedPtrs.clear()
  }

  private get exports(): any {
    return (this.memory as any).__wasmExports
  }
}

5.2 メモリリーク検出

WasmのリニアメモリはJavaScriptのガベージコレクションの管理対象外であり、手動で解放する必要がある。メモリリークはWasmインテグレーションで最も一般的な問題である。

export class WasmMemoryMonitor {
  private allocations: Map<number, { size: number; stack: string; timestamp: number }> = new Map()
  private totalAllocated = 0

  trackAlloc(ptr: number, size: number): void {
    this.allocations.set(ptr, {
      size,
      stack: new Error().stack ?? '',
      timestamp: Date.now(),
    })
    this.totalAllocated += size
  }

  trackFree(ptr: number): void {
    const alloc = this.allocations.get(ptr)
    if (alloc) {
      this.totalAllocated -= alloc.size
      this.allocations.delete(ptr)
    }
  }

  getLeakReport(): { ptr: number; size: number; age: number; stack: string }[] {
    const now = Date.now()
    return Array.from(this.allocations.entries())
      .filter(([_, alloc]) => now - alloc.timestamp > 30_000)
      .map(([ptr, alloc]) => ({
        ptr,
        size: alloc.size,
        age: now - alloc.timestamp,
        stack: alloc.stack,
      }))
  }

  getTotalAllocated(): number {
    return this.totalAllocated
  }

  getActiveAllocations(): number {
    return this.allocations.size
  }
}

六、SharedArrayBufferとマルチスレッドWasm

6.1 クロスオリジン分離設定

SharedArrayBufferを使用するには、ブラウザでクロスオリジン分離(Cross-Origin Isolation)を有効にする必要があり、サーバーで以下のHTTPレスポンスヘッダーを設定する必要がある:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

Nginxでの設定:

server {
    listen 443 ssl http2;
    server_name example.com;

    add_header Cross-Origin-Opener-Policy "same-origin" always;
    add_header Cross-Origin-Embedder-Policy "require-corp" always;

    location / {
        root /var/www/html;
        try_files $uri $uri/ /index.html;
    }

    location /wasm/ {
        root /var/www/html;
        types {
            application/wasm wasm;
        }
        add_header Cross-Origin-Resource-Policy "cross-origin" always;
    }
}

6.2 Web WorkerでのWasm実行

Wasm計算タスクをWeb Workerで実行し、メインスレッドのブロックを回避する:

// wasm-worker.ts
import type { WasmTaskMessage, WasmTaskResult } from './wasm-types'

let wasmInstance: WebAssembly.Instance | null = null

self.onmessage = async (event: MessageEvent<WasmTaskMessage>) => {
  const { taskId, taskType, payload } = event.data

  if (!wasmInstance) {
    const response = await fetch(payload.moduleUrl)
    const { instance } = await WebAssembly.instantiate(
      await response.arrayBuffer(),
      { env: {} }
    )
    wasmInstance = instance
  }

  const exports = wasmInstance.exports as any

  switch (taskType) {
    case 'process-image': {
      const { data, width, height, filter } = payload
      const inputPtr = exports.malloc(data.length)
      const inputView = new Uint8Array(exports.memory.buffer, inputPtr, data.length)
      inputView.set(new Uint8Array(data))

      exports.apply_filter(inputPtr, width, height, filter)

      const resultPtr = exports.get_result_ptr()
      const resultLen = exports.get_result_length()
      const resultView = new Uint8Array(exports.memory.buffer, resultPtr, resultLen)
      const result = resultView.slice()

      exports.free(inputPtr)

      const response: WasmTaskResult = {
        taskId,
        success: true,
        data: result.buffer,
      }
      self.postMessage(response, [result.buffer])
      break
    }
    default:
      self.postMessage({
        taskId,
        success: false,
        error: `不明なタスクタイプ: ${taskType}`,
      } as WasmTaskResult)
  }
}

6.3 スレッドプール管理

高頻度のWasm計算タスクには、Web Workerスレッドプールの管理が必要である:

export class WasmWorkerPool {
  private workers: Worker[] = []
  private taskQueue: Array<{
    task: WasmTaskMessage
    resolve: (result: WasmTaskResult) => void
    reject: (error: Error) => void
  }> = []
  private busyWorkers: Set<number> = new Set()

  constructor(poolSize: number = navigator.hardwareConcurrency ?? 4) {
    for (let i = 0; i < poolSize; i++) {
      const worker = new Worker(new URL('./wasm-worker.ts', import.meta.url), {
        type: 'module',
      })
      worker.onmessage = (event: MessageEvent<WasmTaskResult>) => {
        const { taskId } = event.data
        this.busyWorkers.delete(i)
        this.processQueue()
      }
      this.workers.push(worker)
    }
  }

  async execute(task: WasmTaskMessage): Promise<WasmTaskResult> {
    return new Promise((resolve, reject) => {
      this.taskQueue.push({ task, resolve, reject })
      this.processQueue()
    })
  }

  private processQueue(): void {
    while (this.taskQueue.length > 0 && this.busyWorkers.size < this.workers.length) {
      const workerIndex = this.workers.findIndex((_, i) => !this.busyWorkers.has(i))
      if (workerIndex === -1) break

      const { task, resolve, reject } = this.taskQueue.shift()!
      this.busyWorkers.add(workerIndex)

      const worker = this.workers[workerIndex]
      const handler = (event: MessageEvent<WasmTaskResult>) => {
        worker.removeEventListener('message', handler)
        this.busyWorkers.delete(workerIndex)
        if (event.data.success) {
          resolve(event.data)
        } else {
          reject(new Error(event.data.error ?? 'Wasmタスクが失敗しました'))
        }
        this.processQueue()
      }
      worker.addEventListener('message', handler)
      worker.postMessage(task)
    }
  }

  dispose(): void {
    for (const worker of this.workers) {
      worker.terminate()
    }
    this.workers = []
  }
}

七、Core Web Vitals達成実践

7.1 LCP最適化:初回画面レンダリングの高速化

Largest Contentful Paint(LCP)はCore Web Vitalsで最も重要な指標である。Vue 3.5 + WasmアプリケーションにおけるLCP最適化のコア戦略:

  • Wasmモジュールの遅延読み込み:初回画面に不要なWasmモジュールはインタラクション時まで遅延読み込みする
  • SSR/SSGプリレンダリング:Nuxt 4のSSRまたは静的生成を使用し、クライアントサイドレンダリングの空白待ちを回避する
  • クリティカルCSSインライン化:初回画面のクリティカルCSSをHTMLにインライン化し、レンダリングブロックを回避する
  • 画像最適化:WebP/AVIFフォーマットを使用し、loading="lazy"fetchpriority="high"を組み合わせる

7.2 INP最適化:インタラクション応答性

Interaction to Next Paint(INP)は2024年に追加されたCore Web Vitals指標であり、ユーザーインタラクションの応答性を測定する。Wasm計算タスクをWorkerに入れない場合、メインスレッドが直接ブロックされ、INPが悪化する。

コア戦略

  • 50msを超えるすべてのWasm計算はWeb Workerに入れる必要がある
  • requestIdleCallbackを使用して非緊急のWasm初期化をスケジュールする
  • Vue 3.5コンポーネントの更新でscheduler.yield()を使用してメインスレッドを明け渡す
import { nextTick } from 'vue'

export async function scheduleWasmTask(task: () => void, priority: 'high' | 'low' = 'low') {
  if (priority === 'high') {
    await nextTick()
    task()
  } else {
    if ('scheduler' in window && 'yield' in (window as any).scheduler) {
      await (window as any).scheduler.yield()
    } else {
      await new Promise(resolve => requestIdleCallback(resolve))
    }
    task()
  }
}

7.3 CLS最適化:レイアウト安定性

Cumulative Layout Shift(CLS)最適化は、Wasm駆動のデータビジュアライゼーションコンポーネントにおいて特に重要である:

  • Canvasコンテナに固定サイズを予約し、Wasmレンダリング完了後のレイアウトシフトを回避する
  • CSS aspect-ratioプロパティを使用してアスペクト比を維持する
  • 画像とプレースホルダーに同じサイズ仕様を使用する
<template>
  <div class="wasm-canvas-container" :style="containerStyle">
    <canvas
      ref="canvasRef"
      :width="canvasWidth"
      :height="canvasHeight"
      class="wasm-canvas"
    />
    <div v-if="!isReady" class="canvas-placeholder">
      <LoadingSpinner />
    </div>
  </div>
</template>

<style scoped>
.wasm-canvas-container {
  position: relative;
  width: 100%;
  aspect-ratio: 16 / 9;
  overflow: hidden;
}

.wasm-canvas {
  width: 100%;
  height: 100%;
  display: block;
}

.canvas-placeholder {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--surface-secondary);
}
</style>

八、まとめと展望

Vue 3.5とWebAssemblyのディープインテグレーションは、フロントエンドパフォーマンス最適化に全く新しい可能性をもたらした。本記事では、リアクティブシステム最適化、Wasmインテグレーションアーキテクチャ、モジュール読み込み管理、計算高速化、メモリ管理、マルチスレッド、Core Web Vitalsの7つの観点から、プロダクションレベルのVue 3.5 + Wasmアプリケーションの構築方法を体系的に解説した。

重要ポイントの振り返り:

  1. リアクティブ選定:大規模データセットにはshallowReactive/shallowRefを使用し、ディープリアクティブのパフォーマンスの罠を回避する
  2. Wasmインテグレーション:Bridge Layer + Composableパターンで宣言的なWasm呼び出しを実現する
  3. オンデマンド読み込み:ルートレベル/コンポーネントレベル/インタラクショントリガーの3段階読み込み戦略で初回画面パフォーマンスを保証する
  4. ゼロコピー転送:Wasmリニアメモリを直接操作し、JavaScriptとWasm間のデータコピーを回避する
  5. マルチスレッド実行:Web Workerスレッドプール + SharedArrayBufferでメインスレッドのブロックを回避する

将来的には、Component ModelとGC Proposalの標準化に伴い、WebAssemblyとJavaScriptの相互運用性はさらにシームレスになる。Wasmコンポーネントはnpmパッケージのように簡単にVue 3.5プロジェクトにインテグレーションできるようになり、フロントエンドパフォーマンス最適化の上限はさらに押し上げられるだろう。

関連記事

権威ある参考資料

ブラウザローカルツールを無料で試す →

#Vue3.5#WebAssembly#前端性能优化#Wasm模块#响应式系统#2026