一个灵活的,足够抽象的组件可使咱们提升编码效率,规范代码,统一 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 便可获取本文源码相关~