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文字列を取得し、defineCustomElement
に styles
プロパティを渡すことを考えます。
まず、 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 を使っています )