I tried Vue's defineCustomElement()
to create a custom element, but the child component styles are not included in the shadow root for some reason.
I then tried to manually create my shadow root using the native Element.attachShadow()
API instead of using defineCustomElement()
(based on a Codesandbox), but then no styles were loaded at all:
Code: main.js
:
import { createApp } from "vue";
import App from "./App.vue";
import router from "./router";
import store from "./store";
let treeHead = document.querySelector("#app");
let holder = document.createElement("div");
let shadow = treeHead.attachShadow({ mode: "open" });
shadow.appendChild(holder);
createApp(App).use(store).use(router).mount(holder);
Code vue.config.js
:
module.exports = {
chainWebpack: (config) => {
config.module
.rule("vue")
.use("vue-loader")
.loader("vue-loader")
.tap((options) => {
options.shadowMode = true;
return options;
});
config.module
.rule("css")
.oneOf("vue-modules")
.use("vue-style-loader")
.tap((options) => {
options.shadowMode = true;
return options;
});
config.module
.rule("css")
.oneOf("vue")
.use("vue-style-loader")
.tap((options) => {
options.shadowMode = true;
return options;
});
},
};
Code package.json
:
{
"name": "shadow-root",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build"
},
"dependencies": {
"vue": "^3.2.20",
"vue-loader": "^16.8.2",
"vue-router": "^4.0.0-0",
"vue-style-loader": "^4.1.3",
"vuex": "^4.0.0-0"
},
"devDependencies": {
"@vue/cli-plugin-router": "~4.5.0",
"@vue/cli-plugin-vuex": "~4.5.0",
"@vue/cli-service": "~4.5.0",
"@vue/compiler-sfc": "^3.0.0",
"node-sass": "^4.12.0",
"sass-loader": "^8.0.2"
},
"browserslist": [
"> 1%",
"last 2 versions",
"not dead"
]
}
How I can create a custom element with all its styles in the shadow root?
That Vue config is not necessary in Vue 3. It was only needed by the dev server in Vue 2 to render the styles in custom elements.
Using defineCustomElement()
is the recommended way to register custom elements. However, there's an open issue when using defineCustomElement()
, where nested component styles are not rendered at all (@vuejs/vue-next#4462
).
A workaround is to import all components as custom elements so that the styles are attached to the component definition instead of being appended to <head>
, then insert those styles into the DOM upon mounting:
Enable vue-loader
's customElement
mode in vue.config.js
:
// vue.config.js
module.exports = {
chainWebpack: config => {
config.module
.rule('vue')
.use('vue-loader')
.tap(options => {
options.customElement = true
return options
})
}
}
Alternatively, rename all component filename extensions from .vue
to .ce.vue
.
Create a utility function that wraps Vue's defineCustomElement()
and does the following in a setup()
:
mixin
for the mounted
and unmounted
lifecycle hooks.mounted
hook, insert the component's own styles from this.$.type.styles
into the DOM in a <style>
tag. Do the same with the component definitions from the this.$options.components
map.unmounted
hook, remove the <style>
tag that was inserted from mounted
._context
into the current application context from getCurrentInstance()
.// defineCustomElementWithStyles.js
import { defineCustomElement as VueDefineCustomElement, h, createApp, getCurrentInstance } from 'vue'
const nearestElement = (el) => {
while (el?.nodeType !== 1 /* ELEMENT */) el = el.parentElement
return el
}
export const defineCustomElement = (component) =>
VueDefineCustomElement({
setup() {
const app = createApp()
1️⃣
app.mixin({
mounted() {
const insertStyles = (styles) => {
if (styles?.length) {
this.__style = document.createElement('style')
this.__style.innerText = styles.join().replace(/\n/g, '')
nearestElement(this.$el).prepend(this.__style)
}
}
2️⃣
insertStyles(this.$?.type.styles)
if (this.$options.components) {
for (const comp of Object.values(this.$options.components)) {
insertStyles(comp.styles)
}
}
},
unmounted() {
this.__style?.remove() 3️⃣
},
})
4️⃣
const inst = getCurrentInstance()
Object.assign(inst.appContext, app._context)
5️⃣
return () => h(component)
},
})
Edit public/index.html
to replace the <div id="app">
with a custom element (e.g., named "my-custom-element
"):
Before:
// public/index.html
<body>
<div id="app"></div>
</body>
After:
// public/index.html
<body>
<my-custom-element></my-custom-element>
</body>
Instead of createApp()
, use the defineCustomElement()
from above to create a custom element of your app:
Before:
// main.js
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')
After:
// main.js
import { defineCustomElement } from './defineCustomElementWithStyles'
import App from './App.vue'
customElements.define('my-custom-element', defineCustomElement(App))