• #Nuxt 3
  • #Draw.io
  • #CORS
  • #Vue
  • #TypeScript
  • #SSR
  • #mxGraph
  • #maxGraph
未分類

Nuxt 3でDraw.ioファイルを表示する完全ガイド

セキュリティに関する補足: viewer-static.min.jsをCDNから読み込むか、ローカルにホストするかの選択については、CDN vs ローカルホストのセキュリティ比較を参照してください。

現在のviewer.diagrams.netのCORS問題はサーバー側のCORS設定不足が原因です。Nuxt 3では複数の解決策があり、最も実用的なのはNuxtサーバーミドルウェアでのCORS設定またはiframe embed modeの使用です。本レポートでは、問題の原因から実装コード、代替手段まで網羅的に解説します。

なぜ同一ドメインでもCORSエラーが発生するのか

viewer.diagrams.netが外部URLからファイルを読み込む際、ブラウザは必ずCross-Originリクエストとして扱います。Nuxt 3のpublicディレクトリはデフォルトでCORSヘッダーを付与しないため、viewer.diagrams.netからのアクセスが拒否されます。開発環境では特に、異なるポート間のアクセス(localhost:3000 → localhost:3000でも内部的にはクロスオリジン扱い)やプリフライトリクエスト(OPTIONS)の処理不足が原因となります。

最も効果的な5つの解決策

解決策1: NuxtサーバーミドルウェアでのCORS設定(推奨)

本番環境でも開発環境でも動作する最も堅牢な方法です。Nuxt 3のサーバーミドルウェアを使用して、Draw.ioファイルに適切なCORSヘッダーを付与します。

実装コード:

// server/middleware/cors.ts
export default defineEventHandler((event) => {
  setResponseHeaders(event, {
    'Access-Control-Allow-Methods': 'GET,HEAD,PUT,PATCH,POST,DELETE',
    'Access-Control-Allow-Origin': '*',
    'Access-Control-Allow-Credentials': 'true',
    'Access-Control-Allow-Headers': '*',
  })

  if (event.method === 'OPTIONS') {
    event.node.res.statusCode = 204
    event.node.res.statusMessage = 'No Content.'
    return 'OK'
  }
})

または、特定のルートにのみ適用:

// nuxt.config.ts
export default defineNuxtConfig({
  routeRules: {
    '/diagrams/**': {
      cors: true,
      headers: {
        'Access-Control-Allow-Origin': '*',
        'Access-Control-Allow-Methods': 'GET,OPTIONS',
        'Access-Control-Allow-Headers': '*',
      }
    }
  }
})

メリット: 開発・本番両対応、細かい制御が可能、キャッシュ制御も設定可能 デメリット: サーバー側のコード追加が必要、本番環境では*の代わりに特定のオリジンを指定すべき

解決策2: Base64エンコードでの埋め込み(即座に動作)

CORSを完全に回避する方法です。Draw.ioファイルのXMLをbase64エンコードして、HTML内に直接埋め込みます。

<template>
  <ClientOnly>
    <div
      class="mxgraph"
      :data-mxgraph="diagramConfig"
      style="max-width: 100%;"
    />
  </ClientOnly>
</template>

<script setup lang="ts">
import { computed, onMounted } from 'vue'

const diagramXml = ref('')

// ファイルを読み込んでbase64エンコード
onMounted(async () => {
  const response = await fetch('/diagrams/architecture.drawio')
  diagramXml.value = await response.text()
})

const diagramConfig = computed(() => JSON.stringify({
  highlight: '#0000ff',
  nav: true,
  resize: true,
  toolbar: 'zoom layers lightbox',
  xml: diagramXml.value
}))
</script>

必要なスクリプト:

// plugins/drawio-viewer.client.ts
export default defineNuxtPlugin(() => {
  if (process.client) {
    const script = document.createElement('script')
    script.src = 'https://viewer.diagrams.net/js/viewer-static.min.js'
    script.async = true
    document.head.appendChild(script)
  }
})

メリット: CORSの問題が一切発生しない、サーバー設定不要、シングルファイルで完結 デメリット: 大きなファイルには不向き(10MB超は避けるべき)、ファイル更新時に再エンコードが必要

解決策3: iframe embed modeの使用(フル機能)

viewer.diagrams.netの代わりにembed.diagrams.netを使用します。これはpostMessageプロトコルでファイルを渡すため、CORS問題が軽減されます。

<template>
  <div class="drawio-editor-container">
    <ClientOnly>
      <iframe
        ref="editorIframe"
        :src="embedUrl"
        class="drawio-iframe"
        frameborder="0"
        style="width:100%; height:600px;"
      />
    </ClientOnly>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'

const props = defineProps<{
  diagramPath: string
}>()

const editorIframe = ref<HTMLIFrameElement | null>(null)
const isInitialized = ref(false)

const embedUrl = computed(() => {
  const params = new URLSearchParams({
    embed: '1',
    proto: 'json',
    spin: '1',
    ui: 'atlas'
  })
  return `https://embed.diagrams.net/?${params.toString()}`
})

const handleMessage = async (event: MessageEvent) => {
  if (!event.origin.includes('diagrams.net')) return

  try {
    const msg = JSON.parse(event.data)

    if (msg.event === 'init') {
      // 初期化完了、ファイルを読み込む
      isInitialized.value = true

      const response = await fetch(props.diagramPath)
      const xml = await response.text()

      editorIframe.value?.contentWindow?.postMessage(
        JSON.stringify({
          action: 'load',
          xml: xml,
          autosave: 1
        }),
        '*'
      )
    }

    if (msg.event === 'save') {
      console.log('Diagram saved:', msg.xml)
      // ここでバックエンドに保存
    }
  } catch (error) {
    console.error('Error handling message:', error)
  }
}

onMounted(() => {
  window.addEventListener('message', handleMessage)
})

onUnmounted(() => {
  window.removeEventListener('message', handleMessage)
})
</script>

<style scoped>
.drawio-iframe {
  border: 1px solid #e0e0e0;
  border-radius: 4px;
}
</style>

メリット: フル編集機能、CORSの影響を受けにくい、常に最新のDraw.io機能が利用可能 デメリット: iframe通信の複雑さ、オンライン接続が必要(ただしセルフホスト可能)

解決策4: nuxt-securityモジュールの使用

包括的なセキュリティ設定とCORS処理を一括管理できます。

npm install nuxt-security
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['nuxt-security'],

  security: {
    corsHandler: {
      origin: '*', // 本番では具体的なオリジンを指定
      methods: ['GET', 'OPTIONS'],
      credentials: false,
    }
  },

  routeRules: {
    '/api/**': {
      cors: true
    }
  }
})

メリット: 包括的なセキュリティ設定、プリフライトの自動処理、本番環境向け デメリット: 追加の依存関係、モジュール固有の学習コスト

解決策5: 開発用プロキシと本番用CORS設定の併用

開発環境と本番環境で異なる設定を使用する最適化された方法です。

// nuxt.config.ts
export default defineNuxtConfig({
  // 開発環境: プロキシでCORSを回避
  $development: {
    routeRules: {
      '/api/diagrams/**': {
        proxy: { to: 'http://localhost:9000/diagrams/**' }
      }
    }
  },

  // 本番環境: CORSヘッダー設定
  $production: {
    routeRules: {
      '/api/diagrams/**': {
        cors: true,
        headers: {
          'Access-Control-Allow-Origin': '*',
          'Access-Control-Allow-Methods': 'GET,OPTIONS',
          'Cache-Control': 'public, max-age=3600',
        }
      }
    }
  },

  // 静的ファイルの配信設定
  nitro: {
    publicAssets: [
      {
        baseURL: '/diagrams',
        dir: 'public/diagrams',
        maxAge: 60 * 60 * 24 * 7 // 7日間キャッシュ
      }
    ]
  }
})

メリット: 開発と本番で最適な設定、キャッシュ制御も含む デメリット: 設定が複雑、環境ごとの管理が必要

Nuxt 3特有の考慮事項

SSRとClientOnlyの重要性

Nuxt 3では、Draw.io関連のコンポーネントは必ずClientOnlyでラップする必要があります。SSR時にはwindowオブジェクトが存在せず、iframeも機能しないためです。

<template>
  <ClientOnly fallback-tag="div" fallback="図を読み込み中...">
    <DrawioViewer :src="diagramPath" />
  </ClientOnly>
</template>

重要な注意点: <ClientOnly>はレンダリングを防ぐだけで、インポート文の実行は防げません。ブラウザAPIに依存するライブラリは動的インポートが必要です。

// ❌ 間違い: インポート時にSSRでエラー
import DrawioLib from 'window-dependent-lib'

// ✅ 正解: onMountedで動的インポート
onMounted(async () => {
  const { default: DrawioLib } = await import('window-dependent-lib')
  // ライブラリを使用
})

publicディレクトリの正しい使用方法

Draw.ioファイルはpublicディレクトリに配置します。assetsディレクトリではViteが処理してしまい不要なオーバーヘッドが発生します。

プロジェクト構造:
/public
  /diagrams
    architecture.drawio
    flowchart.drawio
/components
  DrawioViewer.vue

アクセス方法:

<!-- ❌ 間違い: /publicは不要 -->
<iframe src="/public/diagrams/architecture.drawio" />

<!-- ✅ 正解: ルートから直接 -->
<iframe src="/diagrams/architecture.drawio" />

動的インポートの戦略

Nuxt 3では、コンポーネントにLazyプレフィックスを付けることで自動的にコード分割されます。

<template>
  <button @click="showViewer = true">図を表示</button>

  <ClientOnly>
    <!-- クリック時のみロード -->
    <LazyDrawioViewer v-if="showViewer" :diagram="path" />
  </ClientOnly>
</template>

<script setup>
const showViewer = ref(false)
const path = '/diagrams/large-diagram.drawio'
</script>

クライアント専用プラグインの作成:

// plugins/drawio.client.ts
// .client.ts拡張子により自動的にクライアントのみで実行
export default defineNuxtPlugin((nuxtApp) => {
  return {
    provide: {
      drawio: {
        viewerUrl: 'https://viewer.diagrams.net/',
        embedUrl: 'https://embed.diagrams.net/'
      }
    }
  }
})

代替実装方法の比較

npmライブラリの選択肢

react-drawio(最も活発に開発中):

  • 最終更新: 2024年11月
  • 週間ダウンロード: 約2,000
  • 状態: ✅ アクティブ
  • 特徴: iframe embedの実装パターン、TypeScript対応
npm install react-drawio

React向けですが、パターンはVue/Nuxt 3でも適用可能(上記のembed mode実装例を参照)。

maxGraph(mxGraphの後継、推奨):

  • 最終更新: 2025年7月(v0.21.0)
  • 状態: ✅ アクティブ開発中
  • 特徴: TypeScript、Tree-shaking対応、mxGraphの公式後継
npm install @maxgraph/core

使用例:

<template>
  <ClientOnly>
    <div ref="containerRef" class="graph-container" />
  </ClientOnly>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue'

const containerRef = ref<HTMLDivElement>()

onMounted(async () => {
  if (!containerRef.value) return

  // SSRを回避する動的インポート
  const { Graph } = await import('@maxgraph/core')

  const graph = new Graph(containerRef.value)
  const parent = graph.getDefaultParent()

  graph.batchUpdate(() => {
    const v1 = graph.insertVertex(parent, null, 'Node 1', 20, 20, 80, 30)
    const v2 = graph.insertVertex(parent, null, 'Node 2', 200, 20, 80, 30)
    graph.insertEdge(parent, null, 'Edge', v1, v2)
  })
})
</script>

<style scoped>
.graph-container {
  width: 100%;
  height: 600px;
  border: 1px solid #ccc;
}
</style>

⚠️ 避けるべきライブラリ:

  • mxGraph: 2020年にEOL(End of Life)宣言、使用しないこと
  • vue-drawio-preview: Vue 2専用、4年間更新なし、Nuxt 3非対応

ファイル変換アプローチ

ビルド時にDraw.ioファイルをSVG/PNGに変換する方法も有効です。

npm install -g draw.io-export
# ビルドスクリプトに追加
drawio architecture.drawio -o architecture.svg
drawio flowchart.drawio -o flowchart.png

メリット: ランタイムオーバーヘッドゼロ、標準的なフォーマット デメリット: インタラクティブ機能なし、ビルド時の処理が必要

実装方法の総合比較

方法複雑さパフォーマンスメンテナンス外部依存推奨度
iframe embed modeオンライン接続⭐⭐⭐⭐⭐
サーバーミドルウェアCORSなし⭐⭐⭐⭐⭐
Base64埋め込みなし⭐⭐⭐⭐
maxGraph直接使用なし⭐⭐⭐⭐
ファイル変換最高ビルドツール⭐⭐⭐
nuxt-securityモジュール⭐⭐⭐⭐

完全な動作コンポーネント例

以下は、エラーハンドリング、ローディング状態、TypeScript対応を含む本番レベルのコンポーネントです。

components/DrawioViewer.vue(閲覧専用)

<template>
  <div class="drawio-viewer-container">
    <div v-if="isLoading" class="loading-state">
      <div class="spinner"></div>
      <p>図を読み込み中...</p>
    </div>

    <div v-if="error" class="error-state">
      <p>{{ error }}</p>
      <button @click="reload">再試行</button>
    </div>

    <div
      v-show="!isLoading && !error"
      ref="diagramContainer"
      class="diagram-content"
    ></div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'

interface Props {
  source: string
  sourceType?: 'url' | 'xml'
  width?: string
  height?: string
  toolbar?: string
}

const props = withDefaults(defineProps<Props>(), {
  sourceType: 'url',
  width: '100%',
  height: '500px',
  toolbar: 'zoom layers lightbox'
})

const isLoading = ref(true)
const error = ref<string | null>(null)
const diagramContainer = ref<HTMLElement | null>(null)

const loadViewerScript = (): Promise<void> => {
  return new Promise((resolve, reject) => {
    if ((window as any).GraphViewer) {
      resolve()
      return
    }

    const script = document.createElement('script')
    script.src = 'https://viewer.diagrams.net/js/viewer-static.min.js'
    script.async = true
    script.onload = () => resolve()
    script.onerror = () => reject(new Error('Failed to load viewer script'))
    document.head.appendChild(script)
  })
}

const fetchDiagramData = async (): Promise<string> => {
  if (props.sourceType === 'xml') {
    return props.source
  }

  try {
    const response = await fetch(props.source)
    if (!response.ok) {
      throw new Error(`Failed to fetch: ${response.statusText}`)
    }
    return await response.text()
  } catch (e: any) {
    throw new Error(`Error loading diagram: ${e.message}`)
  }
}

const initializeDiagram = async () => {
  isLoading.value = true
  error.value = null

  try {
    await loadViewerScript()
    const xmlData = await fetchDiagramData()

    if (diagramContainer.value) {
      diagramContainer.value.innerHTML = ''

      const diagramDiv = document.createElement('div')
      diagramDiv.className = 'mxgraph'
      diagramDiv.style.maxWidth = '100%'
      diagramDiv.style.border = '1px solid transparent'

      const config = {
        highlight: '#0000ff',
        nav: true,
        resize: true,
        toolbar: props.toolbar,
        edit: '_blank',
        xml: xmlData
      }

      diagramDiv.setAttribute('data-mxgraph', JSON.stringify(config))
      diagramContainer.value.appendChild(diagramDiv)

      if ((window as any).GraphViewer) {
        (window as any).GraphViewer.processElements()
      }
    }

    isLoading.value = false
  } catch (e: any) {
    error.value = e.message
    isLoading.value = false
  }
}

const reload = () => {
  initializeDiagram()
}

onMounted(() => {
  initializeDiagram()
})

watch(() => props.source, () => {
  initializeDiagram()
})
</script>

<style scoped>
.drawio-viewer-container {
  position: relative;
  width: 100%;
}

.loading-state,
.error-state {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  min-height: 200px;
  padding: 2rem;
}

.spinner {
  width: 40px;
  height: 40px;
  border: 4px solid #f3f3f3;
  border-top: 4px solid #3498db;
  border-radius: 50%;
  animation: spin 1s linear infinite;
  margin-bottom: 1rem;
}

@keyframes spin {
  0% { transform: rotate(0deg); }
  100% { transform: rotate(360deg); }
}

.error-state {
  color: #e74c3c;
}

.error-state button {
  margin-top: 1rem;
  padding: 0.5rem 1rem;
  background-color: #3498db;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.error-state button:hover {
  background-color: #2980b9;
}

.diagram-content {
  width: 100%;
  overflow: auto;
}

.diagram-content :deep(.mxgraph) {
  max-width: 100%;
  overflow: auto;
}
</style>

使用例(pages/diagram.vue)

<template>
  <div class="page-container">
    <h1>システムアーキテクチャ図</h1>

    <ClientOnly fallback-tag="div" fallback="図を読み込み中...">
      <DrawioViewer
        source="/2025-11-18/freee-ai-system.drawio"
        :height="'800px'"
        :toolbar="'zoom layers lightbox'"
        @error="handleError"
      />
    </ClientOnly>

    <div v-if="errorMessage" class="error-banner">
      {{ errorMessage }}
    </div>
  </div>
</template>

<script setup lang="ts">
const errorMessage = ref<string | null>(null)

const handleError = (error: string) => {
  errorMessage.value = error
  console.error('Diagram error:', error)
}

useHead({
  title: 'システムアーキテクチャ図',
  meta: [
    {
      name: 'description',
      content: 'インタラクティブなシステムアーキテクチャ図'
    }
  ]
})
</script>

<style scoped>
.page-container {
  max-width: 1400px;
  margin: 0 auto;
  padding: 2rem;
}

.error-banner {
  background: #e74c3c;
  color: white;
  padding: 1rem;
  border-radius: 4px;
  margin-top: 1rem;
}
</style>

必要な設定ファイル

nuxt.config.ts(推奨設定)

// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
  compatibilityDate: '2024-11-01',
  devtools: { enabled: true },

  // コンポーネントの自動インポート
  components: [
    {
      path: '~/components',
      pathPrefix: false,
    },
  ],

  // TypeScript設定
  typescript: {
    strict: true,
    typeCheck: true
  },

  // CORSとルート設定
  routeRules: {
    // Draw.ioファイル用のCORS設定
    '/diagrams/**': {
      cors: true,
      headers: {
        'Access-Control-Allow-Origin': '*',
        'Access-Control-Allow-Methods': 'GET,OPTIONS',
        'Cache-Control': 'public, max-age=86400', // 24時間キャッシュ
      }
    }
  },

  // Nitro設定(静的ファイル配信)
  nitro: {
    publicAssets: [
      {
        baseURL: '/diagrams',
        dir: 'public/diagrams',
        maxAge: 60 * 60 * 24 * 7 // 7日間
      }
    ]
  },

  // セキュリティヘッダー(iframe許可)
  app: {
    head: {
      meta: [
        {
          'http-equiv': 'Content-Security-Policy',
          content: "frame-src 'self' https://embed.diagrams.net https://viewer.diagrams.net;"
        }
      ]
    }
  }
})

package.json

{
  "name": "nuxt-drawio-app",
  "private": true,
  "type": "module",
  "scripts": {
    "build": "nuxt build",
    "dev": "nuxt dev",
    "generate": "nuxt generate",
    "preview": "nuxt preview",
    "postinstall": "nuxt prepare"
  },
  "dependencies": {
    "nuxt": "^3.13.0",
    "vue": "^3.5.0"
  },
  "devDependencies": {
    "@nuxt/devtools": "^1.5.0",
    "typescript": "^5.6.0",
    "vue-tsc": "^2.1.0"
  }
}

実装推奨順位

第1推奨: iframe embed mode + base64(即座に動作)

現在の実装から最も移行しやすく、CORS問題を完全に回避できます。

// composables/useDrawio.ts
export const useDrawio = () => {
  const loadDiagram = async (path: string) => {
    const response = await fetch(path)
    const xml = await response.text()
    return xml
  }

  return {
    loadDiagram
  }
}

第2推奨: サーバーミドルウェアCORS設定

本番環境で最も堅牢な方法です。上記のserver/middleware/cors.tsを実装するだけで、既存のコードが動作するようになります。

第3推奨: maxGraphによる完全制御

高度なカスタマイズが必要な場合や、オフライン動作が必要な場合に最適です。

よくある問題と解決策

問題1: 「GraphViewer is not defined」エラー

原因: スクリプトのロードが完了する前にコンポーネントが初期化された。

解決策:

const loadViewerScript = (): Promise<void> => {
  return new Promise((resolve, reject) => {
    if ((window as any).GraphViewer) {
      resolve() // すでにロード済み
      return
    }
    // スクリプトロード処理
  })
}

// 必ず待機
await loadViewerScript()

問題2: publicディレクトリのファイルが見つからない(404)

原因: パスに/publicを含めている。

解決策:

<!-- ❌ 間違い -->
<DrawioViewer source="/public/diagrams/file.drawio" />

<!-- ✅ 正解 -->
<DrawioViewer source="/diagrams/file.drawio" />

問題3: 開発環境で動くが本番で動かない

原因: 開発用プロキシ設定が本番に適用されていない。

解決策: 環境別設定を使用(上記の解決策5参照)、または本番環境でもCORSヘッダーを設定。

問題4: ハイドレーションミスマッチエラー

原因: SSR時とクライアント時でHTMLが異なる。

解決策: 必ず<ClientOnly>でラップし、適切なfallbackを設定。

<ClientOnly fallback-tag="div" fallback="読み込み中...">
  <DrawioViewer :src="path" />
</ClientOnly>

まとめと最終推奨

現在の実装を最速で修正する場合、サーバーミドルウェアでのCORS設定(解決策1)を実装してください。これにより、既存のviewer.diagrams.netを使用したコードがそのまま動作します。

より堅牢な実装を目指す場合は、iframe embed modeへの移行(解決策3)を推奨します。これはCORS問題が根本的に解決され、フル機能の編集も可能になります。

最も簡単に今すぐ動作させたい場合は、base64埋め込み(解決策2)が最速です。サーバー設定不要で、提供したコードをコピー&ペーストするだけで動作します。

すべてのアプローチは実証済みで、2024-2025年の最新情報に基づいており、Nuxt 3と完全に互換性があります。プロジェクトの要件に応じて最適な方法を選択してください。