一個靈活的,足夠抽象的組件可使咱們提升編碼效率,規範代碼,統一 UI 風格...,在 Vue3 中,咱們以常見的 Modal 對話框組件爲例,探討幾個思路點與實現。html
Teleport
內置組件包裹。import Modal from '@/Modal'
,比較繁瑣。考慮還能夠採用 API 的形式,如在 Vue2 中:this.$modal.show({ /* 選項 */ })
。h
函數,或jsx
語法進行渲染。vue-i18n
糅合,即:若是沒有引入vue-i18n
默認顯示中文版,反之,則會用 vue-i18n
的 t
方法來切換語言。思路有了就讓咱們來作行動的巨人~前端
├── plugins
│ └── modal
│ ├── Content.tsx // 維護 Modal 的內容,用於 h 函數和 jsx 語法
│ ├── Modal.vue // 基礎組件
│ ├── config.ts // 全局默認配置
│ ├── index.ts // 入口
│ ├── locale // 國際化相關
│ │ ├── index.ts
│ │ └── lang
│ │ ├── en-US.ts
│ │ ├── zh-CN.ts
│ │ └── zh-TW.ts
│ └── modal.type.ts // ts類型聲明相關
複製代碼
說明:由於 Modal 會被 app.use(Modal)
調用做爲一個插件,因此咱們把它放在 plugins 目錄下。vue
<template>
<Teleport to="body" :disabled="!isTeleport">>
<div v-if="modelValue" class="modal">
<div class="mask" :style="style" @click="handleCancel"></div>
<div class="modal__main">
<div class="modal__title">
<span>{{title||'系統提示'}}</span>
<span v-if="close" title="關閉" class="close" @click="handleCancel">✕</span>
</div>
<div class="modal__content">
<Content v-if="typeof content==='function'" :render="content" />
<slot v-else>
{{content}}
</slot>
</div>
<div class="modal__btns">
<button :disabled="loading" @click="handleConfirm">
<span class="loading" v-if="loading"> ❍ </span>肯定
</button>
<button @click="handleCancel">取消</button>
</div>
</div>
</div>
</Teleport>
</template>
複製代碼
說明:從 template 咱們能夠看到,Modal 的 dom 結構,有遮罩層、標題、內容、和底部按鈕幾部分。這幾塊咱們均可以定義並接收對應 prop 進行不一樣的樣式或行爲配置。node
如今讓咱們關注於 content(內容)這塊:web
<div class="modal__content">
<Content v-if="typeof content==='function'" :render="content" />
<slot v-else>
{{content}}
</slot>
</div>
複製代碼
<Content />
是一個函數式組件:markdown
// Content.tsx
import { h } from 'vue';
const Content = (props: { render: (h: any) => void }) => props.render(h);
Content.props = ['render'];
export default Content;
複製代碼
場景1:基於 API 形式的調用,當 content 是一個方法,就調用 Content 組件,如:app
h
函數:$modal.show({
title: '演示 h 函數',
content(h) {
return h(
'div',
{
style: 'color:red;',
onClick: ($event: Event) => console.log('clicked', $event.target)
},
'hello world ~'
);
}
});
複製代碼
$modal.show({
title: '演示 jsx 語法',
content() {
return (
<div onClick={($event: Event) => console.log('clicked', $event.target)} > hello world ~ </div>
);
}
});
複製代碼
場景2:傳統的調用組件方式,當 content 不是一個方法(在 v-else 分支),如:dom
<Modal v-model="show" title="演示 slot">
<div>hello world~</div>
</Modal>
複製代碼
<Modal v-model="show" title="演示 content" content="hello world~" />
複製代碼
如上,一個 Modal 的內容就能夠支持咱們用 4 種方式 來寫。異步
在 Vue2 中咱們要 API 化一個組件用Vue.extend
的方式,來獲取一個組件的實例,而後動態 append 到 body,如:async
import Modal from './Modal.vue';
const ComponentClass = Vue.extend(Modal);
const instance = new ComponentClass({ el: document.createElement("div") });
document.body.appendChild(instance.$el);
複製代碼
在 Vue3 移除了 Vue.extend
方法,但咱們能夠這樣作
import Modal from './Modal.vue';
const container = document.createElement('div');
const vnode = createVNode(Modal);
render(vnode, container);
const instance = vnode.component;
document.body.appendChild(container);
複製代碼
把 Modal 組件轉換爲虛擬 dom,經過渲染函數,渲染到 div(當組件被控制爲顯示時 )。再動態 append 到 body。
來看具體代碼(省略掉部分,詳細請看註釋):
// index.ts
import { App, createVNode, render } from 'vue';
import Modal from './Modal.vue';
import config from './config';
// 新增 Modal 的 install 方法,爲了能夠被 `app.use(Modal)`(Vue使用插件的的規則)
Modal.install = (app: App, options) => {
// 可覆蓋默認的全局配置
Object.assign(config.props, options.props || {});
// 註冊全局組件 Modal
app.component(Modal.name, Modal);
// 註冊全局 API
app.config.globalProperties.$modal = {
show({ title = '', content = '', close = config.props!.close }) {
const container = document.createElement('div');
const vnode = createVNode(Modal);
render(vnode, container);
const instance = vnode.component;
document.body.appendChild(container);
// 獲取實例的 props ,進行傳遞 props
const { props } = instance;
Object.assign(props, {
isTeleport: false,
// 在父組件上咱們用 v-model 來控制顯示,語法糖對應的 prop 爲 modelValue
modelValue: true,
title,
content,
close
});
}
};
};
export default Modal;
複製代碼
細心的小夥伴就會問,那 API 調用 Modal 該如何去處理點擊事件呢?讓咱們帶着疑問往下看。
咱們在封裝 Modal.vue 時,已經寫好了對應的「肯定」「取消」事件:
// Modal.vue
setup(props, ctx) {
let instance = getCurrentInstance();
onBeforeMount(() => {
instance._hub = {
'on-cancel': () => {},
'on-confirm': () => {}
};
});
const handleConfirm = () => {
ctx.emit('on-confirm');
instance._hub['on-confirm']();
};
const handleCancel = () => {
ctx.emit('on-cancel');
ctx.emit('update:modelValue', false);
instance._hub['on-cancel']();
};
return {
handleConfirm,
handleCancel
};
}
複製代碼
這裏的 ctx.emit
只是讓咱們在父組件中調用組件時使用@on-confirm
的形式來監聽。那咱們怎麼樣才能在 API 裏監聽呢?換句話來說,咱們怎麼樣才能在 $modal.show
方法裏「監聽」。
// index.ts
app.config.globalProperties.$modal = {
show({}) {
/* 監聽 肯定、取消 事件 */
}
}
複製代碼
咱們能夠看到在 上面的 setup
方法內部,獲取了當前組件的實例,在組件掛載前,咱們擅自添加了一個屬性 _hub
(且叫它事件處理中心吧~),而且添加了兩個空語句方法 on-cancel
,on-confirm
,且在點擊事件裏都有被對應的調用到了。
這裏咱們給本身加了一些 「難度」,咱們要實現點擊肯定,若是肯定事件是一個異步操做,那咱們須要在肯定按鈕上顯示 loading 圖標,且禁用按鈕,來等待異步完成。
直接看代碼:
// index.ts
app.config.globalProperties.$modal = {
show({ /* 其餘選項 */ onConfirm, onCancel }) {
/* ... */
const { props, _hub } = instance;
const _closeModal = () => {
props.modelValue = false;
container.parentNode!.removeChild(container);
};
// 往 _hub 新增事件的具體實現
Object.assign(_hub, {
async 'on-confirm'() {
if (onConfirm) {
const fn = onConfirm();
// 當方法返回爲 Promise
if (fn && fn.then) {
try {
props.loading = true;
await fn;
props.loading = false;
_closeModal();
} catch (err) {
// 發生錯誤時,不關閉彈框
console.error(err);
props.loading = false;
}
} else {
_closeModal();
}
} else {
_closeModal();
}
},
'on-cancel'() {
onCancel && onCancel();
_closeModal();
}
});
/* ... */
}
};
複製代碼
考慮到咱們的組件也可能作 i18n ,因而咱們這裏留了一手。默認爲中文的 i18n 配置,翻到上面 Modal.vue 的基礎封裝 能夠看到,有 4 個常量是咱們須要進行配置的,如:
<span>{{title||'系統提示'}}</span>
title="關閉"
<button @click="handleConfirm">肯定</button>
<button @click="handleCancel">取消</button>
複製代碼
需替換成
<span>{{title||t('r.title')}}</span>
:title="t('r.close')"
<button @click="handleConfirm">{{t('r.confirm')}}</button>
<button @click="handleCancel">{{t('r.cancel')}}</button>
複製代碼
咱們還須要封裝一個方法 t
:
// locale/index.ts
import { getCurrentInstance } from 'vue';
import defaultLang from './lang/zh-CN';
export const t = (...args: any[]): string => {
const instance = getCurrentInstance();
// 當存在 vue-i18n 的 t 方法時,就直接使用它
const _t = instance._hub.t;
if (_t) return _t(...args);
const [path] = args;
const arr = path.split('.');
let current: any = defaultLang,
value: string = '',
key: string;
for (let i = 0, len = arr.length; i < len; i++) {
key = arr[i];
value = current[key];
if (i === len - 1) return value;
if (!value) return '';
current = value;
}
return '';
};
複製代碼
使用這個 t
方法,咱們只需在 Modal.vue 這樣作:
// Modal.vue
import { t } from './locale';
/* ... */
setup(props, ctx) {
/* ... */
return { t };
}
複製代碼
咱們能夠看到上面有一行代碼 const _t = instance._hub.t;
,這個 .t
是這樣來的:
vue-i18n
的 $t
方法setup(props, ctx) {
let instance = getCurrentInstance();
onBeforeMount(() => {
instance._hub = {
t: instance.appContext.config.globalProperties.$t,
/* ... */
};
});
}
複製代碼
app.use
回調方法的參數 appModal.install = (app: App, options) => {
app.config.globalProperties.$modal = {
show() {
/* ... */
const { props, _hub } = instance;
Object.assign(_hub, {
t: app.config.globalProperties.$t
});
/* ... */
}
};
};
複製代碼
切記,若是要與 vue-i18n 糅合,還須要有一個步驟,就是把 Modal 的語言包合併到項目工程的語言包。
const messages = {
'zh-CN': { ...zhCN, ...modal_zhCN },
'zh-TW': { ...zhTW, ...modal_zhTW },
'en-US': { ...enUS, ...modal_enUS }
};
複製代碼
咱們以「在 Vue3 要怎麼樣用 API 的形式調用 Modal 組件」展開這個話題。 Vue3 的 setup 中已經沒有 this 概念了,須要這樣來調用一個掛載到全局的 API,如:
const {
appContext: {
config: { globalProperties }
}
} = getCurrentInstance()!;
// 調用 $modal
globalProperties.$modal.show({
title: '基於 API 的調用',
content: 'hello world~'
});
複製代碼
這樣的調用方式,我的認爲有兩個缺點:
globalProperties
globalProperties
這個屬性就 「斷層」 了,也就是說咱們須要自定義一個 interface 去擴展咱們在項目中新建一個文件夾 hooks
// hooks/useGlobal.ts
import { getCurrentInstance } from 'vue';
export default function useGlobal() {
const {
appContext: {
config: { globalProperties }
}
} = (getCurrentInstance() as unknown) as ICurrentInstance;
return globalProperties;
}
複製代碼
還須要新建全局的 ts 聲明文件 global.d.ts,而後這樣來寫 ICurrentInstance
接口:
// global.d.ts
import { ComponentInternalInstance } from 'vue';
import { IModal } from '@/plugins/modal/modal.type';
declare global {
interface IGlobalAPI {
$modal: IModal;
// 一些其餘
$request: any;
$xxx: any;
}
// 繼承 ComponentInternalInstance 接口
interface ICurrentInstance extends ComponentInternalInstance {
appContext: {
config: { globalProperties: IGlobalAPI };
};
}
}
export {};
複製代碼
如上,咱們繼承了原來的 ComponentInternalInstance
接口,就能夠彌補這個 「斷層」。
因此在頁面級中使用 API 調用 Modal 組件的正確方式爲:
// Home.vue
setup() {
const { $modal } = useGlobal();
const handleShowModal = () => {
$modal.show({
title: '演示',
close: true,
content: 'hello world~',
onConfirm() {
console.log('點擊肯定');
},
onCancel() {
console.log('點擊取消');
}
});
};
return {
handleShowModal
};
}
複製代碼
其實 useGlobal
方法是參考了 Vue3 的一個 useContext 方法:
// Vue3 源碼部分
export function useContext() {
const i = getCurrentInstance();
if ((process.env.NODE_ENV !== 'production') && !i) {
warn(`useContext() called without active instance.`);
}
return i.setupContext || (i.setupContext = createSetupContext(i));
}
複製代碼
喜歡封裝組件的小夥伴還能夠去嘗試如下:
API的調用形式能夠較爲固定,它的目的是簡單,頻繁的調用組件,若是有涉及到複雜場景的話就要用普通調用組件的方式。本文意在爲如何封裝一個靈活的組件提供封裝思路。當咱們的思路和實現有了,即可以舉一反十~
公衆號關注「前端精」,回覆 1 便可獲取本文源碼相關~