美文网首页vueVueVue3
Vue3丨TS丨7 个思路封装一个灵活的 Modal 对话框

Vue3丨TS丨7 个思路封装一个灵活的 Modal 对话框

作者: 前端精 | 来源:发表于2021-01-11 13:01 被阅读0次

    先讲段话

    一个灵活的,足够抽象的组件可以使我们提高编码效率,规范代码,统一 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.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 组件,如:

    • 使用 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.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 该如何去处理点击事件呢?让我们带着疑问往下看。

    基于 API 事件的处理

    我们在封装 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-cancelon-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 ,于是我们这里留了一手。默认为中文的 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 };
    }
    

    与 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.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));
    }
    

    一些Demo

    深入

    喜欢封装组件的小伙伴还可以去尝试以下:

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

    总结

    API的调用形式可以较为固定,它的目的是简单,频繁的调用组件,如果有涉及到复杂场景的话就要用普通调用组件的方式。本文意在为如何封装一个灵活的组件提供封装思路。当我们的思路和实现有了,便可以举一反十~

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

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

    相关文章

      网友评论

        本文标题:Vue3丨TS丨7 个思路封装一个灵活的 Modal 对话框

        本文链接:https://www.haomeiwen.com/subject/qdnmnktx.html