透過 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
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。
好了之後我們就可以使用原本專案內的 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 出來。
畢竟現在 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>
我們先對 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 來搭配開發,目前沒啥問題就是了…