Vue2/3 通用指令化弹窗组件封装

2023 年 12 月 6 日 星期三
/
51
摘要
这段代码是一个Vue组件,用于创建一个模态框(Modal)。模态框有默认的标题、内容和底部,以及一个确定按钮。样式定义了按钮、卡片和模态框的外观。代码还包含了Vue2和Vue3版本的组件实现。

Vue2/3 通用指令化弹窗组件封装

Vue2.7解决方案

可用 Vue.extend 扩展出组件实例并挂载到DOM

//useCommandComp.ts
import Vue, { getCurrentInstance, type ComponentInstance } from 'vue';
import { hasOwn } from '@vueuse/core';
export interface Options {
  visible?: boolean;
  onclose?: () => void;
  appendTo?: HTMLElement | string;
  [key: string]: unknown;
}

export interface CommandComponent {
  (options: Options): ComponentInstance | null;
  close: () => void;
}

const getAppendToElement = (props: Options): HTMLElement => {
  let appendTo: HTMLElement | null = document.body;
  if (props.appendTo) {
    if (typeof props.appendTo === 'string') {
      appendTo = document.querySelector(props.appendTo);
    }
    if (props.appendTo instanceof HTMLElement) {
      appendTo = props.appendTo;
    }
    if (!(appendTo instanceof HTMLElement)) {
      appendTo = document.body;
    }
  }
  return appendTo;
};

export const useCommandComponent = (component: any): CommandComponent => {
  let instance: ComponentInstance | null = null;
  const parent = getCurrentInstance()?.proxy;
  const container = document.createElement('div');
  const close = () => {
    if (instance) {
      (instance as any).visible = false;
      // @ts-ignore
    }
  };
  const initInstance = (_component: any, props: Options) => {
    instance = new (Vue.extend(_component))({
      propsData: props
    });
    instance.$on('close', close);
    // 跟随父组件销毁而销毁
    parent?.$on('hook:beforeDestroy', () => {
      instance?.$el.remove();
      instance?.$destroy();
      instance = null;
    });
    getAppendToElement(props).appendChild(container);
    instance.$mount(container);
    return instance;
  };
  const commandComponent = (options: Options) => {
    if (!hasOwn(options, 'visible')) {
      options.visible = true;
    }
    if (typeof options.onclose !== 'function') {
      options.onclose = close;
    } else {
      const originOnClose = options.onclose;
      options.onclose = () => {
        originOnClose();
        close();
      };
    }
    if (!instance) {
      initInstance(component, options);
    } else {
      for (const prop in options) {
        if (hasOwn(options, prop) && hasOwn(instance?.$props || {}, prop)) {
          // @ts-ignore
          instance[prop] = options[prop];
        }
      }
    }
    for (const prop in options) {
      if (hasOwn(options, prop) && !hasOwn(instance?.$props || {}, prop)) {
        // @ts-ignore
        instance[prop] = options[prop];
      }
    }
    return instance;
  };
  // 可在外部关闭
  commandComponent.close = close;
  return commandComponent;
};
export default useCommandComponent;

Vue3解决方案

// useCommandComp.tsx
import { type AppContext, type Component, type ComponentPublicInstance, createVNode, getCurrentInstance, render, type VNode } from 'vue';
import { inBrowser, noop } from '@hgyn/utils';
export interface Options {
  visible?: boolean;
  onclose?: () => void;
  appendTo?: HTMLElement | string;
  [key: string]: unknown;
}

export interface CommandComponent {
  (options: Options): VNode;
  close: () => void;
}

const getAppendToElement = (props: Options): HTMLElement => {
  let appendTo: HTMLElement | null = document.body;
  if (props.appendTo) {
    if (typeof props.appendTo === 'string') {
      appendTo = document.querySelector<HTMLElement>(props.appendTo);
    }
    if (props.appendTo instanceof HTMLElement) {
      appendTo = props.appendTo;
    }
    if (!(appendTo instanceof HTMLElement)) {
      appendTo = document.body;
    }
  }
  return appendTo;
};
const initInstance = <T extends Component>(Component: T, props: Options, container: HTMLElement, appContext: AppContext | null = null) => {
  const vNode = createVNode(Component, props);
  vNode.appContext = appContext;
  render(vNode, container);

  getAppendToElement(props).appendChild(container);
  return vNode;
};

export const useCommandComponent = <T extends Component>(Component: T): CommandComponent | (() => void) => {
  if (!inBrowser) return noop;
  const appContext = getCurrentInstance()?.appContext;
  // 补丁:Component中获取当前组件树的provides
  if (appContext) {
    const currentProvides = (getCurrentInstance() as any)?.provides;
    Reflect.set(appContext, 'provides', { ...appContext.provides, ...currentProvides });
  }

  const container = document.createElement('div');

  const close = () => {
    render(null, container);
    container.parentNode?.removeChild(container);
  };

  const CommandComponent = (options: Options): VNode => {
    if (!Reflect.has(options, 'visible')) {
      options.visible = true;
    }
    if (typeof options.onClose !== 'function') {
      options.onClose = close;
    } else {
      const originOnClose = options.onClose;
      options.onClose = () => {
        originOnClose();
        close();
      };
    }
    const vNode = initInstance<T>(Component, options, container, appContext);
    const vm = vNode.component?.proxy as ComponentPublicInstance<Options>;
    for (const prop in options) {
      if (Reflect.has(options, prop) && !Reflect.has(vm.$props, prop)) {
        vm[prop as any] = options[prop];
      }
    }
    return vNode;
  };

  CommandComponent.close = close;

  return CommandComponent;
};

export default useCommandComponent;

命令式组件定制规范

本项目已封装对弹窗组件的命令式调用hook useCommandComp。无论组件的实现方式是sfc还是tsx,只要遵循简单的约定即可完成命令式调用。

使用示例

<script setup lang="ts">
import Dialog from '@/common/dialog'
import useCommandComponent from '@/composables/useCommandComp';
const dialogCaller = useCommandComponent(Dialog)
dialogCaller({
   content: '您将退出阅读,请确认是否已读?',
   confirmText: '完成阅读',
   cancelText: '继续阅读',
});
</script>

须遵循的约定

  • 弹窗组件内部须提供名为 visibleprops ,用于控制组件的显隐。
  • 弹窗组件关闭时须 emit 一个 close 事件。
  • useCommandComponent 须在 setup 函数内调用。

执行流程概述

  1. 父组件调用useCommandComponenthook 传入弹窗组件,于hook内部创建实例缓存变量,返回 commandComponent 方法。
  2. 调用commandComponent方法,传入所需参数。
  3. 判断当前弹窗组件是否已实例化,如果已实例化,则更新 props。否则实例化组件。
  4. 组件实例化,将入参作为 props 传入组件内部,监听弹窗 close 关闭事件,监听父组件 beforeDestroy钩子 用于执行弹窗销毁。(这样只要上层组件有一个触发了beforeDestroy事件,后续组件就将递归销毁。所以在命令式组件中嵌套命令式组件也是可以的。)
  5. 组件挂载

使用社交账号登录

  • Loading...
  • Loading...
  • Loading...
  • Loading...
  • Loading...