外部でビルドしたコンポーネントをNuxt3アプリケーションに読み込む

Nuxt3で作成したWebアプリケーションにおいて、Webアプリケーションの本体は運用したままで、 プラグインのように、あとから開発したコンポーネントを読み込んで表示する必要があったのでその方法を模索してました。

# サマリ

  • .vueファイルをUMD形式でビルドして.jsファイルを生成する
  • 本体アプリケーションからこの.jsファイルを読み込むように<script>要素を生成
  • Vue3のcomponent要素のis属性で読み込んだコンポーネントを指定

サンプルアプリケーション (opens new window)

# 経緯

仕事でよくGrafana (opens new window)を使うのですが、このアプリケーションはよくできていて、プラグイン形式であとから開発したWebコンポーネントを追加して画面に表示することができます。 GrafanaはReactベースで実装されているのですが、私のとこのプロダクトではNuxt(Vue3)を使ってるので似たようなことがVue3でもできないか調べてみました。

Vue3のドキュメントには動的&非同期コンポーネント (opens new window) という項目があります。 ですが、is属性は動的にコンポーネントの切り替えを行うための機能ですし、 defineAsyncComponentの説明はvueファイルを動的にimportするように見えますが、一般的なVue3の使い方だと 事前にビルドしておくことになるので、結局ビルド時にはコンポーネントが用意されていなければならなくなってしまいます。

# 実現方法

簡単に説明すると以下の手順になります。

  • 外部コンポーネントの用意

    1. .vueファイルをumd形式でビルドして.jsファイルを生成する このとき、Vueモジュールはグローバルに定義されたものを使用するように設定
    2. .jsファイルを、http等で公開する(本体のアプリケーションから参照できるならローカル配置でもよし)
  • 本体アプリケーションから外部コンポーネントの読み込み

    1. 実行時にVueモジュールをグローバル(windowオブジェクト)にバインドしておく
    2. 外部コンポーネントが必要になったとき、<script>要素を生成して所望のコンポーネントの.jsファイルをロードする (ロードした結果はwindow[...]に登録される)
    3. Vue3のdefineComponent()APIで読み込まれたコンポーネントを定義し、is属性で対象の要素のコンポーネントを切り替える

# 動くサンプル

こちら (opens new window) に動作するサンプルアプリケーションを置いてます。 実行方法はREADMEを見てください。

  • ./component-building-example
    .vueファイルをビルドして、ブラウザで読み込める形式にしてhttpで公開するサンプルです
  • ./component-loading-example
    外部でビルドされたコンポーネントをを読み込んで描画するNuxtアプリケーションのサンプルです

# 細かいところ

# .vueファイルのコンパイル

viteをimportしてbuild()関数で.vueファイルをビルドします。 サンプルプログラムでは4つの.vueファイルをビルドするようにbuild.js (opens new window)を書きました。

import { build } from 'vite';
   :
const targets = [
  { 'input': 'src/HelloWorld.vue',       'name': 'hello-world',         'output': 'hello-world.umd.js' },
  { 'input': 'src/Yellow.vue',           'name': 'yellow',              'output': 'yellow.umd.js' },
  { 'input': 'src/Pink.vue',             'name': 'pink',                'output': 'pink.umd.js' },
  { 'input': 'src/Lime.vue',             'name': 'lime',                'output': 'lime.umd.js' },
];
  :
  for (const target of targets) {
    await build({
      configFile: false,
      plugins: [vue()],
      build: {
        outDir: path.join(__dirname,'public'),
        emptyOutDir: false,
        lib: {
          entry: path.join(path.dirname(target.input), target.output),
          name: target.name,
          formats: [ 'umd' ],
          fileName: (format) => target.output,
        },
        cssCodeSplit: true,
        rollupOptions: {
          input: { main: fileURLToPath(new URL(target.input, import.meta.url)), },
          external: ['vue'],
          output: {
            exports: "named",
            globals: { vue: 'Vue', },
          },
        },
      },
    });
  }

# 生成した.jsファイルの公開

サンプルではhttp-serverを使って.jsファイルをhttpで取得できるようにしました。

{
  :
  "scripts": {
     :
    "start": "http-server public -p 3001 --cors",
     :
  },

# .jsファイルの動的読み込み

Webアプリケーション本体から別サイトにある.jsファイルを読み込む部分です。 <script>要素を動的に生成してsrc属性を設定しています。 (同一サイト内に.jsファイルがあるならそのパスを指定すればよいですが、別サイトであれば別サイト側でCORS設定をしておく必要があります)

export default async function (url) {
  const name = url.split(`/`).reverse()[0].match(/^(.*?)\.umd/)[1];
  if (window[name]) return window[name];

  window[name] = await new Promise((resolve, reject) => {
    const script = document.createElement(`script`);
    script.async = true;
    script.addEventListener(`load`, () => { resolve(window[name]); });
    script.addEventListener(`error`, () => { reject(new Error(`Error loading ${url}`)); });
    script.src = url;
    document.head.appendChild(script);
  });
  return window[name];
}

window[name]の代入部分がややこしいですが、読み込んだ.jsの内部でも、そのexportしたものがwindow[name]に代入されていることに注意です。 これはUMD形式の仕様と思うのですが、詳しくは調べてないです。

この関数はNuxt3のutilsフォルダに配置しています。 external-component.js (opens new window)

# 外部コンポーネントの表示

実際に実行時ロードした外部コンポーネントを表示するコンポーネント。 propsurlで指定されたURLの外部コンポーネントを読み込んでそれを動的に表示しています。

<script setup>
import externalComponent from "../utils/external-component";
const NoContent = resolveComponent('NoContent');

const props = defineProps({ url: String, });

// An component to be displayed. use 'no-content' when the no target loaded.
const ext_comp = ref(NoContent);

watch(() => props.url, async (current, prev) => {
  try {
    // Load component and set it to 'ext_comp' when the property 'url' changed.
    const newCompModule = await externalComponent(current);
    ext_comp.value = defineComponent(newCompModule.default);
  } catch(e) {
    if (current !== "") console.error(e);
    ext_comp.value = NoContent;
  }
})
</script>

<template>
  <ClientOnly>
    <component :is="ext_comp" msg="aiueo"/>
  </ClientOnly>
</template>

# まとめ

以上、Nuxt3で外部ビルドしたコンポーネントの読み込む方法の説明でした。 最初やろうと思ったときは非同期コンポーネントとか使えばぱっとできるんじゃないかと思ったのですが、 やってみるといろいろと必要な知識が芋づる式に出てきて、結構時間がかかっちゃいました。 同じようなことをしようとしている人の一助になれば幸いです。

# 参考

以下のサイトを参考にしました。