Search code examples
javascriptvue.jswebpackvuejs3shadow-dom

How do I create a Vue 3 custom element, including child component styles?


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?


Solution

  • 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():

      1. Create a temporary application instance that adds a mixin for the mounted and unmounted lifecycle hooks.
      2. In the 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.
      3. In the unmounted hook, remove the <style> tag that was inserted from mounted.
      4. Copy the temporary application instance's _context into the current application context from getCurrentInstance().
      5. Return a render function for the component.
      // 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))
      

    demo