Mike

30 June, 2025

使用 Vue3 開發 Web Component 入門

透過 vite + vue3 快速開發 Web Component 共用組件。

為什麼不直接寫原生的 Web Component ?
因為透過 vite + vue3 的方式來開發 web component ,過程會簡單很多,而且可以使用 Vue SFC 的方式來開發,程式碼看起來比較簡潔,可以達到跟原生 Web Component 一樣的效果。

有關 web component 相關介紹在請各位看一下 MDN,連結幫各位附上了。

https://developer.mozilla.org/en-US/docs/Web/Web_Components


需求

假設今天我需要開發一個 component 給很多人專案使用,而這些專案可能有的是用 Vue、React、Svelte 等等,但是同一個樣式的組件卻要寫好幾個版本,很容易造成溝通及維護上的問題,所以我的想法就是把共用的組件包成 web component ,這樣一來就可以在任何專案上面來使用這個組件。

那要怎麼做呢? Vue 提供了一個 API 叫 defineCustomElement,可以透過這個 API 來使用 Vue SFC 開發 web component。

https://cn.vuejs.org/guide/extras/web-components.html#vue-and-web-components

 

  1. 首先我們先來 create 一個新的 Vite 專案,記得框架選 Vue
npm create vite@latest

 

2. 然後先來設定 build 的細節,這邊我們選擇 build 出去的是使用 Library Mode (庫模式)

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  build: {
    lib: {
      entry: 'src/main.js',
      formats: ['es', 'cjs'],
      fileName: 'index',
    }
  },
  plugins: [
    vue(),
  ]
})

 

3. 然後刪除專案內的 App.vue,因為我們現在是要開發 UI 組件,所以就不需要 App.vue 了。

 

4. 新建立一個 .ce.vue 的 component,使用 SFC 就要這樣命名。
我們先假設今天共用的組件是我們的 header ,所以我要新增一個組件叫做 Header.ce.vue

<script setup>
  const emit = defineEmits(['onTrigger']);
</script>
<template>
  <header class="header-bar">
    <button @click="emit('onTrigger', { event: 'click' })">click</button>
  </header>
</template>

 

5. 改寫你的 main.js

import { defineCustomElement } from 'vue'
import Header from "./components/Header.ce.vue";

const mHeader = defineCustomElement(Header);

// 將組件導出 (但是還沒有使用 customElements 註冊)
export { mHeader }

// 註冊組件的函式
export function register() {
    customElements.define('m-header', mHeader);
}

這邊就不是去 createApp 註冊 Vue,而我們是使用 defineCustomElement 來將 SFC 轉譯成 web component,這邊我們有兩個導出的東西,一個是直接組件的導出,不幫你在網站上註冊這個 web component ,你可以先做完你要的事情後再單獨使用, customElements.define 註冊,或是寫一個 register 函式,讓外部直接 call 就可以註冊寫好的 web component。

在這邊我把我的組件名稱註冊為 m-header 所以等等我使用的時候就是

<m-header></m-header>

 

6. 執行 npm run build,將 vue 的 SFC 打包成 web component。

vite.svg 是 public 有的,所以會一起打包過來,如果不想要可以刪掉 public 資料夾裡面的 vite.svg。


如何使用 Web Component?

好了之後我們就可以使用原本專案內的 html 搭配 vscode 的套件 Live Server 來搭配測試,在一般非框架的環境下要如何使用。

<!-- 我們註冊的 web component -->
<m-header></m-header>

<script type="module">
  // 引入 module
  import { register } from "./dist/index.js";

  // 註冊 web component
  register();
</script>

因為我們這邊是使用 ES module 的模組來導出,但我們還沒有使用其他自動化工具來編譯,所以這邊我們在 index.html 上面加上 type="module" ,可以直接使用 import。

然後我們導出 register 函式,然後註冊它,我們就可以在 html 上面使用這個 web component。

這時候打開瀏覽器的開發者工具可能會看到這個錯誤

這是因為環境變數找不到的錯誤,所以我們可以在 vite.config.js 加入環境變數的設定

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  define: {
    'process.env': {}
  },
  build: {
    lib: {
      entry: 'src/main.js',
      formats: ['es', 'cjs'],
      fileName: 'index',
    }
  },
  plugins: [
    vue(),
  ]
})

在執行一次 npm run build 就可以就可以解決這問題,你看我的 header 在index.html 給 render 出來。

header 被 render 出來

header 被 render 出來

如何偵聽 emit 事件

畢竟現在 build 成 web component,所以像是 vue 的 v-on:emit的方式就不能在其他地方使用,所以我們要在其他專案內要接收來自 emit 的事件及 return 的資料,我們可以使用 addEventListener 來監聽這個 web component。

<!-- 我們註冊的 web component -->
<m-header></m-header>

<script type="module">
    // 引入 module
    import { register } from "./dist/index.js";

    // 註冊 web component
    register();

    // 監聽 onTrigger 的 emit 事件
    document.querySelector('m-header').addEventListener('onTrigger', (e)=> {
      console.log(e.detail);
    })
</script>

透過 document.querySelector(‘m-header’) 的方式取得它的實體,然後偵聽onTrigger 這個 event,當今天只要 web component 內部的觸發 emit ,外部的偵聽就會觸發回傳我需要的內容,所以我在 header.ce.vue 內,是在 click button 後觸發 emit 。

<!-- Header.ce.vue -->
<button @click="emit('onTrigger', { event: 'click' })">
  click
</button>


關於使用 props

我們先對 Header.ce.vue 新增 props,我希望可以由外部來控制這個組件的顯示模式,就是現在很流行的 dark mode,所以我的 class 新增了 light 跟 dark ,透過 props.mode 來進行切換style 。

// Header.en.vue
<script setup>
  const props = defineProps({
    mode: {
      type: String,
      default: 'light' // light or dark
    }
  });
</script>
// Header.en.vue
<template>
  <header :class="['header-bar', props.mode]">
    {{ props }}
  </header>
</template>

然後我在外部使用的時候就像這樣帶入 props,就可以成功的帶入到這個組件了。

<m-header mode="dark"></m-header>

如果要用 JavaScript 動態去 帶入 / 修改 props,我們可以使用 document.querySelector 去抓去 DOM 中的 m-header ,然後塞值進去

document.querySelector('m-header').mode = 'dark'

這麼一來也可以給任何框架中去使用。

設計 props 的時候最好都以原始型別為主( String、Number、Boolean) 比較好,因為如果傳入 Object 的話在 SFC 定義就會有點問題,像是下面這樣…

<script setup>
  const props = defineProps({
    obj: {
      type: Object,
      default: ()=> ({})
    }
  });
  console.log(typeof props.obj);
</script>
<template>
  <header class="header-bar">
    {{ props }}
  </header>
</template>

然後我傳入物件

<m-header obj="{name: 'mike'}"></m-header>

 

但是因為我們不是在 Vue 的環境中開發,所以現在這樣帶入其實是帶入字串,而不是真的物件格式

我們可以看到 typeof props.obj 回傳的是一個 string

console.log(typeof props.obj);  -> string
// Bad (X) 
<m-header obj="{name: 'mike'}"></m-header>
// Good (O)
<m-header obj='{"name": "mike"}'></m-header>

以下是更改後的 Header.en.vue

// Header.en.vue
<script setup>
import { computed } from 'vue';
  const props = defineProps({
    obj: {
      type: String,
      default: ''
    }
  });
  
  // 切割傳入的字串
  const stringArr =  computed(()=> props.obj.split(''));
  const propsValue = computed(()=> {
    // 判斷字首字尾是不是 JSON 物件
    const isObject = stringArr.value[0] === '{' && stringArr.value[stringArr.value.length - 1] === '}';
    if(isObject) {
      return JSON.parse(props.obj);
    } else {
      return 'Type is not Object'
    }
  });
</script>
// Header.en.vue
<template>
  <header class="header-bar">
    {{ propsValue }}
  </header>
</template>

這樣的話我們就可以確保傳入的資料就可以變成我們要的物件。


特別注意

我們Vue官方文件上面會看到這個傳入物件的寫法(官方文件),但是這個傳入物件的寫法是給使用 Vite 或是 Vue-cli 開發 Vue 的時候可以使用的方式

<!-- 傳入複雜資料格式-->
<my-element :user.prop="{ name: 'Mike' }"></my-element>
<!-- 縮寫 -->
<my-element .user="{ name: 'Mike' }"></my-element>

要在 Vite 或 Vue-cli 使用的時候還需要在 config 中做一些設定

// vite.config.js
import vue from '@vitejs/plugin-vue'
export default {
  plugins: [
    vue({
      template: {
        compilerOptions: {
          // 将所有带短横线的标签名都视为自定义元素
          isCustomElement: (tag) => tag.includes('-')
        }
      }
    })
  ]
}
// vue.config.js
module.exports = {
  chainWebpack: config => {
    config.module
      .rule('vue')
      .use('vue-loader')
      .tap(options => ({
        ...options,
        compilerOptions: {
          // 将所有带 ion- 的标签名都视为自定义元素
          isCustomElement: tag => tag.startsWith('ion-')
        }
      }))
  }
}

因為預設情況下,Vue 會將任何非原生的 HTML 標籤優先當作 Vue 組件處理,所以這會在開發時導致 Vue 噴出一個 解析組件失敗 的警告。我們可以指定 compilerOptions.isCustomElement 這個選項,讓 Vue 知道特定元素應該被視為自定義元件並跳過組件解析。


另外一種傳入複雜資料類型的方式

如果我們不透過 props,而是去定義 web component 本身的參數以及修改參數的方法,再由外部去執行,就可以達到一樣的效果。

<script setup>
  import { ref } from 'vue';
  const defaultData = ref({
    name: '',
    age: 0,
    address: ''
  })
  const setPropsData = (obj) => {
    defaultData.value = obj;
  }
  
  defineExpose({ setPropsData });
</script>
<template>
    <h1>{{defaultData}}</h1>
</template>

我定義了一個 defaultData 的 ref 物件,還有一個 setPropsData 函式負責去修改 defaultData,然後外部需要透過 setPropsData 去修改 defaultData,所以我用 defineExpose 把 setPropsData 給丟出去,讓外部可以使用。

現在畫面上面可以看到我預設定義好的資料 !

現在我在外部可以直接調用 setPropsData 來修改這個資料

// 抓取 web component 實體
const headerRef = document.querySelector('m-header');

// 調用 setPropsData 函式
headerRef._instance.exposed.setPropsData({
  name: 'Mike',
  age: 18,
  address: '台灣 Taiwan'
});

現在可以看到我們畫面上 render 我們丟進去的內容

是不是非常的方便!這樣子就可以輕易開發 web component 的組件,在各種不同環境的專案使用,Vue3.2 提供了 defineCustomElement 這個 API,大幅度簡化 Vue 開發者開發 web component 麻煩度。

基本上我目前在專案上面的開發沒有遇到什麼太大的問題,像是 composables 、一些輕量的第三方 JS 套件、非同步處理都完美運行,不過我看網路上有些人在整合一些 CSS framework 有遇到一些狀況,不過我就沒有特別去測試了,有需求的朋友在自己整合看看吧,我自己目前是整合了 UnoCSS 來搭配開發,目前沒啥問題就是了…