先讲段话

一个灵便的,足够形象的组件能够使咱们进步编码效率,标准代码,对立 UI 格调...,在 Vue3 中,咱们以常见的 Modal 对话框组件为例,探讨几个思路点与实现。

思考丨7个思路

  1. ✅ 一个对话框须要的基本要素「题目,内容,确定/勾销按钮」。内容须要灵便,所以能够是字符串,或一段 html 代码(也就是 slot )。
  2. ✅ 对话框须要“跳出”,防止来自父组件的“解放”,用 Vue3 Teleport 内置组件包裹。
  3. ✅ 调用对话框须要在每个父组件都进行引入 import Modal from '@/Modal',比拟繁琐。思考还能够采纳 API 的模式,如在 Vue2 中:this.$modal.show({ /* 选项 */ })
  4. ✅ API 的模式调用,内容能够是字符串,灵便的 h 函数,或jsx语法进行渲染。
  5. ✅ 可全局配置对话框的款式,或者行为...,部分配置可进行笼罩。
  6. ✅ 国际化,可灵便与 vue-i18n 糅合,即:如果没有引入vue-i18n默认显示中文版,反之,则会用 vue-i18nt 办法来切换语言。
  7. ✅ 与 ts 联合,使基于 API 的模式调用更敌对。

思路有了就让咱们来做口头的伟人~

实际

Modal 组件相干的目录构造

├── 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 目录下。

Modal.vue 的根底封装(只展现template)

<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 进行不同的款式或行为配置。

当初让咱们关注于 content(内容)这块:

<div class="modal__content">  <Content v-if="typeof content==='function'"            :render="content" />  <slot v-else>    {{content}}  </slot></div>

<Content /> 是一个函数式组件:

// Content.tsximport { h } from 'vue';const Content = (props: { render: (h: any) => void }) => props.render(h);Content.props = ['render'];export default Content;

场景1:基于 API 模式的调用,当 content 是一个办法,就调用 Content 组件,如:

  • 应用 h 函数:
$modal.show({  title: '演示 h 函数',  content(h) {    return h(      'div',      {        style: 'color:red;',        onClick: ($event: Event) => console.log('clicked', $event.target)      },      'hello world ~'    );  }});
  • 应用便捷的 jsx 语法
$modal.show({  title: '演示 jsx 语法',  content() {    return (      <div        onClick={($event: Event) => console.log('clicked', $event.target)}      >        hello world ~      </div>    );  }});

场景2:传统的调用组件形式,当 content 不是一个办法(在 v-else 分支),如:

  • default slot
<Modal v-model="show"          title="演示 slot">  <div>hello world~</div></Modal>
  • 间接传递 content 属性
<Modal v-model="show"          title="演示 content"          content="hello world~" />

如上,一个 Modal 的内容就能够反对咱们用 4 种形式 来写。

API 化

在 Vue2 中咱们要 API 化一个组件用Vue.extend的形式,来获取一个组件的实例,而后动静 append 到 body,如:

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.tsimport { 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 该如何去解决点击事件呢?让咱们带着疑难往下看。

基于 API 事件的解决

咱们在封装 Modal.vue 时,曾经写好了对应的「确定」「勾销」事件:

// Modal.vuesetup(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.tsapp.config.globalProperties.$modal = {   show({}) {     /* 监听 确定、勾销 事件 */   }}

咱们能够看到在 下面的 setup 办法外部,获取了以后组件的实例,在组件挂载前,咱们擅自增加了一个属性 _hub(且叫它事件处理核心吧~),并且增加了两个空语句办法 on-cancelon-confirm,且在点击事件里都有被对应的调用到了。

这里咱们给本人加了一些 “难度”,咱们要实现点击确定,如果确定事件是一个异步操作,那咱们须要在确定按钮上显示 loading 图标,且禁用按钮,来期待异步实现。

间接看代码:

// index.tsapp.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 ,于是咱们这里留了一手。默认为中文的 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.tsimport { 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.vueimport { t } from './locale';/* ... */setup(props, ctx) {  /* ... */  return { t };}

与 vue-i18n 糅合

咱们能够看到下面有一行代码 const _t = instance._hub.t; ,这个 .t 是这样来的:

  • 在 Modal.vue 中,获取挂载到全局的 vue-i18n$t 办法
setup(props, ctx) {  let instance = getCurrentInstance();  onBeforeMount(() => {    instance._hub = {      t: instance.appContext.config.globalProperties.$t,      /* ... */    };  });}
  • 在全局属性注册中,间接应用 app.use 回调办法的参数 app
Modal.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 }};

与ts

咱们以「在 Vue3 要怎么样用 API 的模式调用 Modal 组件」开展这个话题。
Vue3 的 setup 中曾经没有 this 概念了,须要这样来调用一个挂载到全局的 API,如:

const {  appContext: {    config: { globalProperties }  }} = getCurrentInstance()!;// 调用 $modal globalProperties.$modal.show({  title: '基于 API 的调用',  content: 'hello world~'});

这样的调用形式,集体认为有两个毛病:

  1. 在每个页面调用时都要深刻获取到 globalProperties
  2. ts 推导类型在 globalProperties 这个属性就 “断层” 了,也就是说咱们须要自定义一个 interface 去扩大

咱们在我的项目中新建一个文件夹 hooks

// hooks/useGlobal.tsimport { 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.tsimport { 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.vuesetup() {  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));}

一些Demo

深刻

喜爱封装组件的小伙伴还能够去尝试以下:

  • “确定”,“勾销” 按钮的文案实现可配置
  • 是否显示 “勾销” 按钮 或 是否显示底部所有按钮
  • 内容超过肯定的长度,显示滚动条
  • 简略的字符串内容的文本居中形式,left/center/right
  • 可被拖拽
  • ...

总结

API的调用模式能够较为固定,它的目标是简略,频繁的调用组件,如果有波及到简单场景的话就要用一般调用组件的形式。本文意在为如何封装一个灵便的组件提供封装思路。当咱们的思路和实现有了,便能够举一反十~

???? 2021年,「前端精」求关注

公众号关注「前端精」,回复 1 即可获取本文源码相干~