Vue.js defineCustomElementで子コンポーネントのスタイルを反映させるためのViteプラグインを書いてみる

Vue.js では、 defineCustomElement 関数を用いてVueコンポーネントをカスタム要素にラップできます

しかしながら、Vueコンポーネントが子Vueコンポーネントを持っている場合、子Vueコンポーネントのスタイルが適用されないという問題があります。

たとえば、以下のような二つのコンポーネントについて

// Container.vue

<script setup lang="ts">
import ContainerInner from './ContainerInner.vue'
</script>

<template>
<div class="container-outer">
  <p>outer</p>
  <container-inner>inner</container-inner>  
</div>
</template>

<style scoped>
.container-outer {
  background-color: gainsboro;
}
</style>
// ContainerInner.vue

<script setup lang="ts">
</script>

<template>
  <div class="container-inner">
    <slot></slot>
  </div>
</template>

<style scoped>
.container-inner {
  color: red;
}
</style>

以下のようにカスタム要素化し、 <my-container> タグを設置したとします。

import Container from './Container.vue'
import { defineCustomElement } from 'vue'

const ce = defineCustomElement(Container)
customElements.define('my-container', ce)

この場合、画面に表示される「inner」の文字は、ContainerInnerコンポーネントに記述されたCSSによって赤くなることが期待されますが、以下のように黒いままです。

レンダリング結果

この問題はVue.jsリポジトリにもIssueが投稿されていたり、いくつかのPull Requestで修正案が提案されているようですが、解決されていません。

ざっくりとソースコードリーディングをしてみると、以下のことがわかります。

  • defineCustomElement を呼ぶと、VueCustomElementクラスが返却される (ref)
  • VueCustomElementクラスはVueElementクラスを継承している。継承元のコンストラクタを呼ぶ際に渡すオブジェクトは InnerComponentDef
  • InnerComponentDef 型は styles?: string[] のキーバリューを持っている

ということで、 defineCustomElement 関数に渡すオブジェクトは、Vueコンポーネントに styles 配列を渡したものになります。渡された styles 配列は、カスタム要素のShadow DOM内にインラインスタイルとして読み込まれます。 このとき、 styles 配列に含まれるCSSが最も外側のコンポーネントのみになっているため、子Vueコンポーネント以下のコンポーネントのスタイルが適用されないということのようです。

これを解決できそうな方法を考えてみます。

すべての子孫コンポーネントのスタイルを含む配列をつくる

styles 配列にすべての子孫コンポーネントのスタイルも含まれていれば、問題が解消されそうです。ということで、コンポーネントの依存関係から必要なCSSすべてを返却するためのツールセットを考えます。 (このプロジェクトはViteによってビルドすることを前提に話を進めますが、ほかのバンドラーも同様の手法で解決できるかもしれません。)

Viteプラグインを書けば、JavaScript成果物をビルドする過程に介入してソースコードを操作できます。以下のような方針で考えてみます。

  • Vite(が内部で使っているRollup)が依存関係を解決したとき、指定されたVueコンポーネントに依存するCSSを得る
  • CSS文字列をすべて結合したものをソースコードで参照できるようにする
import Container from './components/Container.vue'
import { defineCustomElement } from 'vue'
import { getCombinedCss } from './CssCombinedPluginBrowser'

const css = getCombinedCss('./components/Container.vue')
const ce = defineCustomElement({...Container, styles: [css]} as any)
customElements.define('my-container', ce)

Vueコンポーネントをカスタム要素化する上記のソースコードについて考えます。getCombinedCss 関数において、引数に渡されたVueコンポーネントおよび、その子孫コンポーネントの分まで結合されたCSS文字列を取得し、defineCustomElementstyles プロパティを渡すことを考えます。

まず、 getCombinedCss 関数を定義します。これは以下のように示されます。

// CssCombinedPluginBrowser.ts
export const getCombinedCss = (modulePath: string) => `.combined_css_${modulePath} {}`

このままだと空のスタイルを返却するだけの関数です。これをViteプラグインを使って「(ビルドの過程を経ることで)子孫コンポーネントのCSSを返却する」関数にしていきます。

※これから実装するViteプラグインは、ビルドフェーズとアウトプットフェーズが結合しており、Viteプラグインの要件を完全には満たしていません。Viteは開発サーバの起動時にはアウトプットフェーズを呼び出さないので、開発時には正しくCSSが埋め込まれないということになります。このため、カスタム要素としてビルドする際には必ず vite build を実行する必要があります。(この問題をうまく解決する方法があったら是非教えてください) このため、開発サーバ上ではVueコンポーネントをそのまま参照し、ソースコードをビルドする際にはカスタム要素としてラップする、などの工夫が必要になります。

ステップ1: getCombinedCss関数を使っている箇所にマーカーを設置する

以下の空のViteプラグインを拡張していきます。

import { Plugin } from 'vite'

export const CssCombinedPlugin = (): Plugin  => {
  return {
    name: 'css-combined-plugin'
  }
}

Rollupの transform フック で、各モジュールごとに getCombinedCss 関数の存在をチェックします。

// ... 省略

type ReplaceMarker = {
  extractingComponentId: string,
  marker: string,
}

export const CssCombinedPlugin = (): Plugin  => {
  const replaceMarkers: ReplaceMarker[] = []
  let count = 0

  return {
    name: 'css-combined-plugin',

    transform(code, id) {
      // getCombinedCss 関数の存在を確認する
      const hasCssFunction = code.match(new RegExp(`${FUNC_NAME}\((.*)\)`))

      // 置換対象の関数の文字列がない場合は何もしない
      if (!hasCssFunction) return null

      // acorn でパースしてASTを得る
      let transformedCode = code
      const parsed = this.parse(code) as any

      // getCombinedCss がインポートされていることを確認する
      const hasIncluded = parsed.body.some((item: any) => item.type === 'ImportDeclaration' && item.specifiers.find((item: any) => item.type === 'ImportSpecifier' && item.imported.name === FUNC_NAME))

      // 置換対象の関数をインポートしていない場合は何もしない
      if (!hasIncluded) return null

      const nodes: any[] = []

      // getCombinedCss 関数が呼ばれている箇所をチェックする
      simple(parsed, {
        CallExpression(node: any) {
          if (node.callee.name === FUNC_NAME && hasIncluded) {
            nodes.push(node)
          }
        }
      })

      // ソースコードを置換する(マーカーを置く)
      // ソースコードを後ろから置換することで、置換箇所のインデックスがずれないようにする
      nodes.sort((a: any, b: any) => b.start - a.start).forEach((node: any) => {
        const { start, end } = node

        if (node.callee.name === FUNC_NAME) {
          const marker = `'' /* CssCombinedPluginPlace -- ${count++} */`
          transformedCode = transformedCode.slice(0, start) + marker + transformedCode.slice(end)

          replaceMarkers.push({
            extractingComponentId: join(dirname(id), node.arguments[0].value),
            marker
          })
        }
      })

      return transformedCode
    },
  }
}

上記ソースコードは、概ね以下のことをします。

  • 各モジュールに対して、getCombinedCss 関数がImportされていることをチェックして処理を続行
  • getCombinedCss 関数をある文字列で置き換える(これを「マーカーを設置する」と呼ぶことにする)
  • マーカーと、getCombinedCss 関数の引数に与えられたVueコンポーネントのペアを replaceMarkers に記録

たとえば、以下のソースコードに対してマーカーの設置が完了すると

import { getCombinedCss } from './CssCombinedPluginBrowser'

const css = getCombinedCss('./components/Container.vue')

以下のように getCombinedCss 関数をマーカーで置換します。

import { getCombinedCss } from './CssCombinedPluginBrowser'

const css = '' /* CssCombinedPluginPlace -- 0 */

このとき、マークした部分とVueコンポーネントのペアを記録する配列 replaceMarkers の中身は以下のようになります。

[
  {
    extractingComponentId: '/path/to/Container.vue',
    marker: "'' /* CssCombinedPluginPlace -- 0 */"
  }
]

ステップ2: Vueコンポーネントが依存するCSSリストを取得する

Vueコンポーネントが依存するCSSをすべて列挙します。 Rollupの buildEnd フック が呼ばれるタイミングでは依存関係が解決しており、 this.getModuleInfo() メソッドで取得できるプロパティで確認できます。

// ... 省略

export const CssCombinedPlugin = (): Plugin  => {
  const replaceMarkers: ReplaceMarker[] = []
  const cssDependencies = new Map<string, string[]>()

  return {
    name: 'css-combined-plugin',

    // ... 省略

    buildEnd() {
      // 指定したモジュールに依存する CSS ファイルを取得する
      const getCssDependencies = (currentModle: ModuleInfo): string[] => {
        return [
          ...currentModle.importedIds.filter(id => id.match(/\.css$/)),
            ...currentModle.importedIds.map(id => {
            const nextModule = this.getModuleInfo(id)
            if (!nextModule) return ''

            return getCssDependencies(nextModule)
          }).filter(item => !!item).flat()
        ]
      }

      // 抽出するVueコンポーネントが依存するCSSを検索して記録する
      replaceMarkers.forEach(({extractingComponentId}) => {
        const moduleInfo = this.getModuleInfo(id)
        if (!moduleInfo) return
        cssDependencies.set(id, getCssDependencies(moduleInfo))
      })
    }
  }
}

Vueコンポーネントが依存するCSSリストを cssDependencies にセットしていきます。 cssDependencies の中身は以下のようになります。

Map(1) {
  '/path/to/Container.vue' => [
    '/path/to/Container.vue?used&vue&type=style&index=0&inline&scoped=xxxxx&lang.css',
    '/path/to/ContainerInner.vue?used&vue&type=style&index=0&inline&scoped=xxxxx&lang.css'
  ]
}

ステップ3: マーカーを設置した箇所にCSSを埋め込む

ステップ1で設置したマーカーに、ステップ2で取得した「VueコンポーネントごとのCSS」をあてはめていきます。マーカーと埋め込むCSSの関係はステップ1でセットした replaceMarkers 配列に格納されているので、マーカーをCSSで置換できます。

export const CssCombinedPlugin = (): Plugin  => {
  const replaceMarkers: ReplaceMarker[] = []
  const cssDependencies = new Map<string, string[]>()
  let count = 0

  return {
    name: 'css-combined-plugin',

    // ... 省略

    renderChunk(code, _chunk) {
      // 設置されたマーカーを置換する
      return replaceMarkers.reduce((acc, { extractingComponentId, marker }) => {
        if (!code.includes(marker)) return acc

        // CSSコードには `export default` 文が含まれるのでこれを取りのぞきつつ結合する
        const css = cssDependencies.get(extractingComponentId)?.reduce((acc, id) => acc + this.getModuleInfo(id)?.code?.replace(/export default "(.*)"/, '$1'), '') || '""'
        return acc.replace(marker, `"${css}"`)
      }, code)
    }
  }
}

ステップ1の状態でのトランスパイル結果は

const css = '' /* CssCombinedPluginPlace -- 0 */

ステップ3で以下のようになります。

const css = "\n.container-outer[data-v-64fb13b2] {\n  background-color: gainsboro;\n}\n\n.container-inner[data-v-5a114263] {\n  color: red;\n}\n";

つまり、冒頭で説明した「Vueコンポーネントをカスタム要素化するソースコード」は

import Container from './components/Container.vue'
import { defineCustomElement } from 'vue'
import { getCombinedCss } from './CssCombinedPluginBrowser'

const css = getCombinedCss('./components/Container.vue')
const ce = defineCustomElement({...Container, styles: [css]} as any)
customElements.define('my-container', ce)

以下のようになり、最終的に子孫コンポーネントを含むCSSが defineCustomElement に渡されます。

import Container from './components/Container.vue'
import { defineCustomElement } from 'vue'
import { getCombinedCss } from './CssCombinedPluginBrowser'

const css = "\n.container-outer[data-v-64fb13b2] {\n  background-color: gainsboro;\n}\n\n.container-inner[data-v-5a114263] {\n  color: red;\n}\n";
const ce = defineCustomElement({...Container, styles: [css]} as any)
customElements.define('my-container', ce)

ビルドして再び確認すると、期待通り「inner」の文字部分が赤くなりました。

レンダリング結果

最終的なプラグインのソースコード

ここまでの成果物をGitHubに置きました。 https://github.com/piyoppi/vuejs-combined-css-playground/tree/main/packages/combined-css-sample

非同期ロードにも対応させる

エントリファイルのバンドルサイズを小さくしたいなどの理由で、たとえば以下のようにDynamic Import的にCSSをロードしたいかもしれません。

import { defineCustomElement } from 'vue'
import { getCombinedCssPromise } from './CssCombinedPluginBrowser'

getCombinedCssPromise('./Container.vue').then(css => {
  const ce = defineCustomElement({...Container, styles: [css.default]} as any)
  customElements.define('my-container', ce)
})

先ほどのプラグインにもう少し手を加えてみます。まずは先ほどと同様に、空の getCombinedCssPromise 関数を定義します。

// CssCombinedPluginBrowser.ts
export type CssModule = {
  default: string
}

export const getCombinedCssPromise = (modulePath: string) => Promise.resolve({default: `.combined_css_${modulePath} {}`})

ビルド時に getCombinedCssPromise 関数に適切なCSSがセットされるようにViteプラグインに手を加えていきます。

ステップ1: getCombinedCssPromise関数をDynamic Importに置換する

まずは、 transform フックに以下のように処理を追加し、 getCombinedCssPromise 関数を import 構文に置き換えていきます。

  // ... 省略

  const CSS_ID_PREFIX = 'CssCombinedPluginPromise'
  const createCssImportId = () => `${CSS_ID_PREFIX}${count++}`

  return {
    transform(code, id) {
      // ... 省略

      nodes.sort((a: any, b: any) => b.start - a.start).forEach((node: any) => {
        const { start, end } = node

        // ... 省略

        if (node.callee.name === FUNC_NAME_PROMISE) {
          const importId = createCssImportId()

          // 動的インポートに置換する
          const importStatement = `import('${importId}')`
          transformedCode = transformedCode.slice(0, start) + importStatement + transformedCode.slice(end)

          cssImports.set(importId, {id: id, argument: node.arguments[0].value as string})
        }
      })
    }

    // ... 省略

以下のようなソースコードが

import Container from './components/Container.vue'
import { defineCustomElement } from 'vue'
import { getCombinedCssPromise } from './CssCombinedPluginBrowser'

getCombinedCssPromise('./components/Container.vue').then(css => {
  const ce = defineCustomElement({...Container, styles: [css.default]} as any)
  customElements.define('my-container', ce)
})

以下のようにトランスパイルされます。

import Container from './components/Container.vue'
import { defineCustomElement } from 'vue'
import { getCombinedCssPromise } from './CssCombinedPluginBrowser'

import('CssCombinedPluginPromise0').then(css => {
  const ce = defineCustomElement({...Container, styles: [css.default]} as any)
  customElements.define('my-container', ce)
})

ステップ2: 追加したDynamic Importの依存を解決する

RollupのBuild Hooks を見ると、 transform フックの後に resolveDynamicImport フックが呼ばれ、その後(モジュールを解決できない場合は) resolveId フックが呼ばれることでモジュールが解決されるフローが確認できます。

ステップ1で置き換えた import も、このフローをたどりモジュール解決を行うようにします。以下のように resolveId フックと load フックを追加します。

export const CssCombinedPlugin = (): Plugin  => {
  // ... 省略

  const isReplacedCssImport = (id: string) => cssImports.has(id)

  return {
    name: 'css-combined-plugin',

    // ... 省略

    resolveId(id) {
      if (isReplacedCssImport(id)) {
        return id
      }

      return null
    },

    load(id) {
      if (isReplacedCssImport(id)) {
        const item = cssImports.get(id) 

        if (!item) return

        const marker = `'' /* CssCombinedPluginPlace(Promise) -- ${count++} */`

        replaceMarkers.push({
          extractingComponentId: join(dirname(item.id), item.argument),
          marker
        })

        return `export default ${marker};`
      }
    },

    // ... 省略

ステップ1で追加した import で読み込まれることになるスクリプトが、 resolveId を経て load で実体化します。ここで、load フックで実体化するコンテンツの中身を以下のようにします。

export default '' /* CssCombinedPluginPlace(Promise) -- 0 */

このあと、先ほど追加したbuildEndフック及びrenderChunkフックによって /* CssCombinedPluginPlace(Promise) -- 0 */ 部分がCSS文字列に置き換えられます。

ビルドしてみる

ビルドしてみると、以下のようなファイルが出力されます。 Dynamic Import によってCSS dist/CssCombinedPluginPromise0-f82f332a.js がチャンク分割されて出力されていることがわかります。

>>> % npm run build

> vue-tsc && vite build

vite v4.3.1 building for production...
✓ 14 modules transformed.
dist/main.css                                 0.12 kB │ gzip:  0.11 kB
dist/CssCombinedPluginPromise0-f82f332a.js    0.22 kB │ gzip:  0.17 kB
dist/main.es.js                             149.12 kB │ gzip: 34.89 kB
✓ built in 495ms

最終的なプラグインのソースコード

ここまでの成果物をGitHubに置きました。 https://github.com/piyoppi/vuejs-combined-css-playground/tree/main/packages/with-async-import

まとめ

Viteプラグインを書いて子孫コンポーネントを含むCSSを取得できるようにしたことで、カスタム要素のスタイルが期待通りに適用されるようになりました。

先のIssueにはImportantラベルがついていたり、いくつかの修正Pull Requestが上がっていたりしているので、ゆくゆくはVue.js側で解決されそうですが、それまでの間はこのような手法でもワークアラウンドを提供できそうであることを確認できました。

※ソースコードはあくまで概念検証段階のものになりますので、参照は自己責任でお願いいたします。

とはいえ、Viteプラグインとしての要件を満たしておらず、もう少しうまいやり方ができる気もする...という気持ちもあります。

Vue.jsのWeb Comopnentsサポートは「既存のプロダクトに徐々にVue.jsを取り入れていく」必要があるプロジェクトにとってはありがたい機能なので、今後も便利に使えるようになるといいなと思いました。

ではでは。

このカウンタは @piyoppi/counter-tools を使っています。

クリックすると匿名でいいねできます。