likes
comments
collection
share

这才是vue弹窗组件的正确使用姿势!

作者站长头像
站长
· 阅读数 8

前言

Dialog组件是我们日常开发中使用最多的组件之一,怎样使用才能让代码更加优雅是我们一直讨论的问题,特别是现在VueCompositionApi更加方便我们逻辑的拆分,在这种背景下,我们怎么更好的使用弹窗才能使代码更加清晰呢?

日常开发现状

通过状态去驱动弹窗的状态

这才是vue弹窗组件的正确使用姿势!

思考

如果按上面的做法,每写一个弹窗组件则父组件里面需要引入组件在模板中使用组件声明visible声明data声明点击显示以及关闭的事件。虽然这样看似没什么问题,但如果弹窗有多个呢?visible1、visible2...吗?业务再复杂点呢?这简直是恐怖片,毕竟开发过程中最困扰我的就是变量的命名。

那我们要怎么去优化这些痛点呢?其实visible的值到底是什么我们并不关注,我们只需要显示关闭的方法而已,而data这个变量一般都是为了将数据传给Dialog组件使用而已,如果可以直接传给子组件的话,我们都不需要这个变量。当然,如果可以不在父组件引入这些Dialog组件那就更好了,这样逻辑拆分就可以更加彻底.

我期望的

  • 尽可能少的命名变量。
  • 使用一个函数暴露出来关闭/显示的方法。
  • 不需要声明一个变量来传递数据给Dialog组件
  • 是否能不在模板中挂载组件,这样逻辑更加聚焦,可以的话一个hook就是一个弹窗相关的所有代码,而使用者只需要关注怎么关闭/打开弹窗就行。
  • 类型推导更加便捷,因为我是typescript重度患者

实现思路

封装一个hook管理弹窗的显示/隐藏,并且暴露出开启关闭的方法。同时将弹窗的内容用函数来渲染(h函数或者JSX都行,重点推荐JSX)。

那么还有一个问题,弹窗不想主动挂载到模板中要怎么实现?这里我借鉴了大多组件库的思路,将大量公共的东西使用Provide挂载在顶级节点。每次使用hook就会主动在最顶层创建一个弹窗,并且管理起来。

实现方案

// Provider.tsx
import { defineComponent, ref, provide } from 'vue';
import { ElDialog, ElButton } from 'element-plus';
import {
  IDialogRefsItem,
  ICreateDialogOptions,
  DialogInjectProps,
} from './type';

export default defineComponent({
  name: 'Provider',
  components: {
    ElDialog,
    ElButton,
  },
  setup() {
    const dialogRefs = ref<IDialogRefsItem[]>([]);
    const createId = () => `${Date.now()}_${Math.random().toString(16)}`;
    const create = (config: () => ICreateDialogOptions) => {
      const id = createId();
      dialogRefs.value.push({
        id,
        config,
        visible: false,
      });
      return id;
    };
    const open = (id: string) => {
      const row = dialogRefs.value.find(v => v.id === id);
      if (row) {
        row.visible = true;
      }
    };
    const close = (id: string) => {
      const row = dialogRefs.value.find(v => v.id === id);
      if (row) {
        row.visible = false;
        const { onClose } = row.config();
        onClose?.();
      }
    };
    const destroy = (id: string) => {
      const index = dialogRefs.value.findIndex(v => v.id === id);
      if (index !== -1) {
        dialogRefs.value.splice(index, 1);
      }
    };
    provide<DialogInjectProps>('DIALOG_INJECT_KEY', {
      create,
      open,
      close,
      destroy,
    });
    return {
      dialogRefs,
      close,
    };
  },
  render() {
    return (
      <>
        {this.$slots?.default?.()}
        {this.dialogRefs.map((v) => {
          const { config, id, visible } = v;
          const {
            showCancelBtn = true,
            showConfirmBtn = true,
            confirmText = '确定',
            cancelText = '取消',
            onConfirm = () => {},
            render,
            renderHeader,
            renderFooter,
            content,
            onClose,
            onCancel = () => {
              this.close(id);
            },
            ...rest
          } = config();
          return (
            <el-dialog
              {...rest}
              model-value={visible}
              onClose={() => this.close(id)}
              v-slots={{
                default: render ?? (() => content),
                header: renderHeader ?? undefined,
                footer:
                  renderFooter ?? (() => (
                    <>
                      {showCancelBtn && (
                        <el-button onClick={onCancel}>{cancelText}</el-button>
                      )}
                      {showConfirmBtn && (
                        <el-button type='primary' onClick={onConfirm}>
                          {confirmText}
                        </el-button>
                      )}
                    </>
                  )),
              }}
            />
          );
        })}
      </>
    );
  },
});

// useDialog.ts
import { inject, onBeforeUnmount } from 'vue';
import {
  DialogInjectProps,
  ICreateDialogOptions,
} from './type';

export const useDialog = (options: () => ICreateDialogOptions) => {
  const dialogInjectProps = inject<DialogInjectProps>('DIALOG_INJECT_KEY');
  const dialogId = dialogInjectProps?.create(options);
  if (!dialogInjectProps) {
    console.error('useDialog需要配合DialogProvider使用');
  }
  /** 打开弹窗 */
  const openDialog = () => {
    if (dialogId) {
      dialogInjectProps?.open(dialogId);
    }
  };
  /** 关闭弹窗 */
  const closeDialog = () => {
    if (dialogId) {
      dialogInjectProps?.close(dialogId);
    }
  };
  /** 组件销毁时销毁弹窗 */
  onBeforeUnmount(() => {
    if (dialogId) {
      dialogInjectProps?.destroy(dialogId);
    }
  });
  return {
    openDialog,
    closeDialog,
  };
};

// type.ts
import type { DialogProps } from 'element-plus';

export interface ICreateDialogOptions extends DialogProps {
  /** Dialog 对话框 Dialog 的内容 */
  content?: string;
  /** 当关闭 Dialog 时,销毁其中的元素 */
  render?: () => JSX.Element | null | string | number | JSX.Element[];
  /** 当关闭 Dialog 时,销毁其中的元素 */
  renderHeader?: () => JSX.Element | null | string | number | JSX.Element[];
  /** 当关闭 Dialog 时,销毁其中的元素 */
  renderFooter?: () => JSX.Element | null | string | number | JSX.Element[];
  /** 弹窗关闭回调事件 */
  onClose?: () => void;
  /** 确定按钮文案 */
  confirmText?: string;
  /** 点击确定按钮 */
  onConfirm?: () => void;
  /** 取消按钮文案 */
  cancelText?: string;
  /** 点击取消按钮 */
  onCancel?: () => void;
  /** 是否显示确定按钮 */
  showConfirmBtn?: boolean;
  /** 是否显示取消按钮 */
  showCancelBtn?: boolean;
}

export interface IDialogRefsItem {
  id: string;
  visible: boolean;
  config: () => ICreateDialogOptions;
}

export interface DialogInjectProps {
  /** 创建弹窗 */
  create: (options: () => ICreateDialogOptions) => string;
  /** 打开弹窗 */
  open: (id: string) => void;
  /** 关闭弹窗 */
  close: (id: string) => void;
  /** 销毁弹窗 */
  destroy: (id: string) => void;
}

效果展示

App.vue挂载Provider

<template>
  <Provider>
    <router-view />
  </Provider>
</template>

父组件

<template>
  <el-button @click="handleUpdate">修改</el-button>
</template>
<script>
const { handleUpdate } = useUpdate();
</script>

对应编辑的hook

export const useUpdate = () => {
  // 表单数据
  const formData = ref({
    userId: '',
    username: '',
  });
  const { openDialog, closeDialog } = useDialog(() => ({
    title: '编辑账号',
    width: 600,
    render() {
      return (
        <el-form />
      );
    },
  }));
  const handleUpdate = async (data) => {
    formData.value.userId = data.userId;
    formData.value.username = data.username ?? '';
    openDialog();
  };
  return {
    handleUpdate
  };
};

结语

本方案未必是最优解,只是暂时对我来说相对比较方便,当然还有一些小问题,比如因为挂载在Provider下导致ref='el'的语法糖失效,还有作用域失效,在render中非全局引入组件只能直接引入组件使用(<x-button/>无法resolve,只能<XButton/>),具体要怎么解决有兴趣的可以探索一下,类似element-plusMessageBox中的修改appContext的方法或许可行。