因為工作專案使用 monorepo 的關係,對於 shared component 有一些想法/作法,自己覺得不錯的分享出來。
有些 component 例如 Dialog、Modal 通常都會讓他出現在 SPA 所掛載的 DOM element 之後。 這些各大 UI component library 通常都幫你做好好了, 使用方式基本都是擺進去 page component 中,定義當作開關的變數,v-model
搞定。 但有些像是
- 在等待某件事情完成之前、強制使用者不能繼續操作的
loading mask
- 使用者操作 feedback 的 Snackbar
這類元件可能是擺在 App.vue
等比較屬於上層的 component 裡,並把開關儲存在 store
裡, 利用 store.action
在程式碼任何角落使用。
但這類元件我希望他在專案間共用的時候不是太方便,因為得在 monorepo 底下各個 project 中都建立開關、都把元件擺進去。 我想讓這些東西使用上能更像一個 library、plugin。 最好使用的時候是可以簡單的 import、簡單的呼叫就好。
快速瀏覽作法:
對於 vue/vite 很熟的人可以快速瀏覽這裡:
- 找個地方可以 import plugin
- 載入 plugin 時額外建立一個順序在
div#app
之後的 DOM element - 建立一個包含上述兩種元件的 vm,掛載在剛剛建立的 DOM element
- 寫一個能夠提供操作介面的 composable function
- 使用時呼叫 composable function 取得可以操作開關的 method,操作。
建立額外的 vm 當作 Global Level Component
vue3 提供了可以安裝 plugin 的接口 這裡很適合去建立額外的 DOM element。
plugin 有一定的起手式
export default {
install: (app, options) => {
// Plugin code goes here
},
}
先確定目標:
- 可以在任何角落簡單呼叫 loading mask
- 不要使用的時候得在各個專案建立特定的檔案
我的想法是額外建立一個 vm,把相關邏輯都包裝在這個 vm,不要四散
開始寫:
// customPlugin.js
const rootElementID = 'globalComponentRoot'
let vm: any
export function createVM() {
if (vm) return // 確保不會建立多個
const rootElement = document.createElement('div')
rootElement.id = rootElementID
document.body.appendChild(rootElement)
vm = createApp().mount(rootElement)
}
export default {
install: (app, options) => {
// Plugin code goes here
},
}
這樣在 main.js
時就可以如此這般安裝
// main.js
import customPlugin from './customPlugin.js'
app.use(customPlugin)
Loading mask / Snackbar component 實作
再來處理這個額外的 vm 裡面的邏輯
// customPlugin.js
const rootElementID = 'globalComponentRoot'
let vm
// loading mask 只需要一個簡單的開關
const showLoading = ref(false)
const on = () => showLoading.value = true
const off = () => showLoading.value = false
// snackbar 想要做成“出現後過幾秒自己消失”
const ms = 5000
const snackbarStack = reactive([])
function push(message: string) {
const item = h(
'div',
// 在 vue build-in component - <TransitionGroup> 底下需要給 unique key 才可以正確地運作
{ key: Date.now(), class: [...] },
message
)
setTimeout(() => snackbarStack.shift(), ms)
snackbarStack.push(item)
}
export function createVM() {
if (vm) return // 確保不會建立多個
const rootElement = document.createElement('div')
rootElement.id = rootElementID
document.body.appendChild(rootElement)
const rootComponent = defineComponent({
name: rootElementID,
setup() {
return { // 得 return render 出來的 Vnode 才可以拿到並使用
showLoading,
snackbarStack,
}
},
// 因為不想另外再開 SFC 檔案,直接用 render function 寫
render() {
return [
h(
Transition,
{
enterActiveClass: '...',
leaveActiveClass: '...',
enterFromClass: '...',
leaveToClass: '...',
},
() => (this.showLoading ? h(LoadingContent) : undefined)
),
h(
TransitionGroup,
{
tag: 'div', // TransitionGroup default 會被渲染為 template
class: '...',
moveClass: '...',
enterActiveClass: '...',
leaveActiveClass: '...',
enterFromClass: '...',
leaveToClass: '...',
},
() => snackbarStack
),
]
},
})
vm = createApp().mount(rootElement)
}
export default {
install: (app, options) => {
// Plugin code goes here
}
}
// 提供介面的 composable function
export function useLoading() {
return {
on,
off,
isOn: () => showLoading.value,
}
}
export function useSnackbar() {
return {
push,
}
}
TIP
L151, L164 是 function 還是已經建立好的 Vnode 其實不太要緊,這邊的使用情境,元件是 Global 的,使用頻率會很高。
不是 function 的話會有這句警告
Non-function value encountered for default slot. Prefer function slots for better performance.
詳細請看 Stackoverflow