• Vue3优雅封装Modal对话框组件
  • 发布于 2个月前
  • 192 热度
    0 评论
对话框(弹窗)功能在前端的项目开发中使用频率非常高,无论是管理后台项目还是 C 端项目,大部分情况下都是直接使用组件库的对话框组件,极大的方便了我们的开发,使用起来是非常方便和简单的;但是如果我们想要自己设计一款对话框组件,其实是有挺多难点是需要解决的,当我们能够独立完成对话框组件的设计以及开发时,这将会大大提升我们的水平,那么这篇文章将给大家分享如何封装 Modal对话框组件!!

具体逻辑以及技术要点:
1. BEM命名规范实现样式隔离
对于组件化开发,不管是开发组件库还是封装组件,很重要的一点都是要做样式隔离,防止多个组件间的样式冲突,我们可以采用 CSS 命名规范——BEM 来实现,不了解该规范的可以自行百度,主要的逻辑代码如下:
const isObject = (val: unknown): val is object => {
  return val !== null && typeof val === "object";
};

const isArray = (val: unknown): val is string[] => {
  return Array.isArray(val);
};

const isString = (val: unknown): val is string => {
  return typeof val === "string";
};

type BEMElement = string;
type BEMModifier =
  | (string | undefined | false)[]
  | Record<string, boolean | string | undefined>;

const createModifier = (prefixClass: string, modifierObject?: BEMModifier) => {
  let modifiers: string[] = [];
  if (isArray(modifierObject)) {
    modifiers = modifierObject
      .map((modifier) => {
        if (!modifier) return "";
        return `${prefixClass}--${modifier}`;
      })
      .filter(Boolean);
  } else if (isObject(modifierObject)) {
    modifiers = Object.entries(modifierObject).map(([modifier, value]) => {
      if (!value) return "";
      return `${prefixClass}--${modifier}`;
    });
  }
  return modifiers;
};

/**
 * 堆代码 duidaima.com
 * CSS BEM
 */

export const createCssScope = (prefix: string, identity = "front") => {
  const prefixClass = `${identity}-${prefix.replace(identity, "")}`;

  return (
    elementOrModifier?: BEMElement | BEMModifier,
    modifier?: BEMModifier,
    modifierLater?: BEMModifier
  ) => {
    if (!elementOrModifier) return prefixClass;
    if (isString(elementOrModifier)) {
      const element = `${prefixClass}__${elementOrModifier}`;
      if (!modifier) return element;
      return [
        element,
        ...createModifier(element, modifier),
        ...createModifier(element, modifierLater),
      ];
    }
    return [
      prefixClass,
      ...createModifier(prefixClass, elementOrModifier),
      ...createModifier(prefixClass, modifier),
    ];
  };
};
/**
 * CSS BEM
 * @example  使用示例
 */
const bem = createCssScope('button')

bem()
// front-button
bem('label')
// front-button__label
bem({ disabled })
// front-button front-button--disabled
bem('label', { disabled })
// front-button__label front-button__label--disabled
bem(['disabled', 'primary'])
// front-button front-button--disabled front-button--primary
2. 使用Teleport渲染到指定位置
Teleport 是 Vue 3 中一个不太常见但非常有用的特性,它可以帮助我们在组件中的任意位置渲染内容,并将其挂载到 DOM 中的其他位置。

举个例子,假设我们有一个弹出框组件,希望将它的内容渲染到 <body> 标签下的某个元素中,而不是当前组件的父级元素,使用 Teleport,我们可以轻松实现这一需求。
<template>
  <div>
    <teleport to="body">
      <!-- 弹出框内容 -->
    </teleport>
  </div>
</template>
在上面的例子中,我们使用了 <teleport> 标签,并通过 to 属性指定了要渲染到的目标位置。这样,弹出框的内容就会被挂载到 <body> 下面,而不是当前组件的位置。

3. 使用Transition实现过渡效果
这里不介绍关于 Transition 的知识,想要了解的小伙伴请移步 Vue3 官网[1]
<template>
  <Teleport :to="target">
    <div>
      <Transition name="fade" appear>
        .........
      </Transition>
      <Transition name="zoom-in-top" appear>
        .........
      </Transition>
    </div>
  </Teleport>
</template>
4. 遮罩层、弹窗层的结构与样式
<template>
  <Teleport :to="target">
    <div>
      <!-- 遮罩层 -->
      <Transition name="fade" appear>
        <div
          v-show="modelValue"
          :class="[
            bem('mask'),
            bem({
              maskBg: showMask,
            }),
          ]"
        ></div>
      </Transition>
      <!-- 弹窗层 -->
      <Transition name="zoom-in-top" appear>
        <div
          v-show="modelValue"
          :class="bem('modal')"
          @click.self="closeMaskToCloseModal"
        >
          <!-- 主体区域 -->
          ................
        </div>
      </Transition>
    </div>
  </Teleport>
</template>
.front-modal__mask {
  position: fixed;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
  z-index: 999;
  margin: auto;
  width: 100%;
  height: 100%;
}
.front-modal--maskBg {
  background-color: #00000066;
}
.front-modal__modal {
  position: fixed;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
  z-index: 9999;
  display: flex;
  align-items: center;
  margin: auto;
  width: 100%;
  height: 100%;
}
5. 实现头部、底部、默认插槽

对于封装通用组件,为了实现自定义内容,需要准备好头部、底部、默认插槽,允许往里面插入各种需要的内容

<template>
  <Teleport :to="target">
    <div>
      <!-- 遮罩层 -->
      <Transition name="fade" appear>
        .......
      </Transition>
      <!-- 弹窗层 -->
      <Transition name="zoom-in-top" appear>
        <div
          v-show="modelValue"
          :class="bem('modal')"
          @click.self="closeMaskToCloseModal"
        >
          <!-- 主体区域 -->
          <div
            :class="[
              bem('main')
            ]"
          >
            <!-- 头部区域 —— header插槽 -->
            <div :class="bem('header')">
              <slot name="header">
                <div :class="bem('title')">
                  {{ title }}
                </div>
              </slot>
              <!-- 右上角关闭按钮 -->
              <div :class="bem('close')" @click="closeModal">
                <IconCloseOutline />
              </div>
            </div>
             <!-- 内容区域 —— 默认插槽 -->
            <div :class="bem('content')">
              <slot />
            </div>
            <!-- 底部区域 —— footer插槽 -->
            <div v-if="showFooter" :class="bem('footer')">
              <div class="front-modal-footer-option">
                <slot name="footer">
                  .......
                </slot>
              </div>
            </div>
          </div>
        </div>
      </Transition>
    </div>
  </Teleport>
</template>
6. 处理props属性以及目标元素
封装通用组件一定要提供足够多的属性,这样才便于扩展功能,而且要提供属性文档,注明每个属性的作用以及默认值

定义 props 的类型并附初始值:
export type modalBaseProps = {
  title: string;
  size?: "small" | "large";
  modelValue: boolean;
  to?: string;
  scrollable?: boolean;
  closeable?: boolean;
  showMask?: boolean;
  escapable?: boolean;
  showFooter?: boolean;
};

const props = withDefaults(defineProps<modalBaseProps>(), {
  modelValue: false,
  title: "",
  size: "large",
  showFooter: true,
  to: "body",
  scrollable: false,
  closeable: false,
  showMask: true,
  escapable: true,
});
接下来需要把对话框组件挂载到指定的结点,也就是 Teleport 的 to 属性指向的值,默认是将其挂载到 body 元素上
<template>
  <div>
    <teleport :to="target">
      <!-- 弹出框内容 -->
    </teleport>
  </div>
</template>

const target = ref<HTMLElement>(document.body);

const getElement = (selector: string): HTMLElement => {
  return document.querySelector(selector) ?? document.body;
};

const initScroll = () => {
  ...........
  if (props.to) {
    target.value = getElement(props.to || "body");
  }
};

watch(props, () => {
  initScroll();
});

onMounted(() => {
  initScroll();
});
7. 处理滚动条以及页面可否滚动
当出现 modal 的时候,如果此时 window 窗口有滚动条,滚动鼠标时可以发现底部的内容是会在往下滑的,这样会影响用户体验

一般情况我们需要隐藏滚动条并禁止滚动,解决方法就是给 body 上加一个样式 overfolow: hidden,这样用户就会更专注于此时的弹框

onUnmounted(() => {
  document.body.style.overflow = "";
});
 // 堆代码 duidaima.com
const initScroll = () => {
  if (props.modelValue) {
    .......
    !props.scrollable && (document.body.style.overflow = "hidden");
  } else {
    document.body.style.overflow = "";
  }
};
8. v-model 实现双向绑定
我们想要的组件使用方式如下,传入 visible 控制弹窗的显示与隐藏,并且想要使用 v-model,那么这时候可以把 v-model 拆分成属性绑定、事件绑定

父组件:
<template>
  <front-modal v-model="visible" title="这里是标题" @onCloseModal="closeModal" >
    <span>这一块是modal对话框的内容部分,这里支持各种标签,组件</span>
  </front-modal>
  <button @click="showModal">modal 对话框</yk-button>
</template>

<script setup lang="ts">
import { ref } from 'vue'
const visible = ref<boolean>(false)
const showModal = () => {
  visible.value = true
}

const closeModal = () => {
  ........
}
</script>
front-modal 组件:
<template>
  <Teleport :to="target">
    <div>
      <!-- 遮罩层 -->
      <Transition name="fade" appear>
        <div
          v-show="modelValue"
          :class="[
            bem('mask'),
            bem({
              maskBg: showMask,
            }),
          ]"
        ></div>
      </Transition>
      <!-- 弹窗层 -->
      <Transition name="zoom-in-top" appear>
        <div
          v-show="modelValue"
          :class="bem('modal')"
          @click.self="closeMaskToCloseModal"
        >
          <!-- 主体区域 -->
          <div
            :class="[
              bem('main'),
              bem({
                shadow: !props.showMask,
                size: size === 'small',
              }),
            ]"
          >
            <div :class="bem('header')">
              <slot name="header">
                <div :class="bem('title')">
                  {{ title }}
                </div>
              </slot>
              <!-- 右上角关闭按钮 -->
              <div :class="bem('close')" @click="closeModal">
                <IconCloseOutline />
              </div>
            </div>
            ............
          </div>
        </div>
      </Transition>
    </div>
  </Teleport>
</template>

<script setup lang="ts">
import { watch, onUnmounted, ref, onMounted } from "vue";
import { createCssScope } from "../../../utils";
import { modalBaseProps } from "./modal";

defineOptions({
  name: "FrontModal",
});

const props = withDefaults(defineProps<modalBaseProps>(), {
  modelValue: false,
  .........
});
const bem = createCssScope("modal");
const emit = defineEmits(["onCloseModal", "update:modelValue", "onSubmit"]);

const closeModal = () => {
  emit("update:modelValue", false);
  emit("onCloseModal");
};

const initScroll = () => {
  if (props.modelValue) {
    if (props.escapable) {
      document.body.addEventListener("keydown", escapeClose);
    }
    !props.scrollable && (document.body.style.overflow = "hidden");
  } else {
    document.body.style.overflow = "";
  }
};

watch(props, () => {
  initScroll();
});

onMounted(() => {
  initScroll();
});

// 按esc键关闭 modal
const escapeClose = (ev: KeyboardEvent) => {
  if (ev.key === "Escape") closeModal();
};

// 关闭弹窗的回调
const closeMaskToCloseModal = () => {
  props.closeable && emit("update:modelValue", false);
};

// 点击底部确定按钮的回调
const handleSubmit = () => {
  emit("onSubmit");
};
</script>
待解决的技术难点:
1. 如何实现函数调用
modal 组件是否暴露了增删改查 modal 内容的能力,这样 modal 的灵活性就会大大增加,比如关闭 modal 框的时候,我们希望先请求后端的接口校验,此时 modal 的确定按钮处于 loading 状态,如果后端校验通过才关闭 modal,不通过就不关闭。

组件调用:
<front-modal />
函数调用:
Modal.add
Modal.remove
Modal.update
Modal.removeAll
因为做过 b 端的同学都应该会有这个感受,一般弹框类的组件都是提示类的,提示类组件基本都是通过 onClick 触发,所以如果显示 Modal 也是函数调用就会非常方便,例如:
Modal.add({ ...xxx参数 });
2. 如何处理嵌套弹窗
嵌套 modal,也就是按钮弹出 modal 弹窗,弹窗里又有一个按钮,点击之后在之前的基础上又有一个 modal,一般情况,我们需要判断此时是否是最后一层 modal,如果是,才把 body 样式的滚动条隐藏,所以嵌套 modal 多于一个 modal 时,我们是不需要再次隐藏 body 样式的,而且最后一个 modal 关闭的时候,我们还需要把 body 原本的样式还原。

所以我们需要一个管理所有 modal 的管理器,去记录所有的 modal 目前有几个,正在打开的是第几个。

3. 实现锁定Tab切换焦点
什么是锁定焦点吗?当你打开 modal 的时候,在键盘上按下 tab 键,会将

上图所示的 focus 状态,会在你按下回车键的时候触发这个按钮的 onClick 事件。而且你一直按 Tab 键,焦点只会在当前 Modal 框里,不会移除到 Modal 框外,这种 focus 状态锁定技术是我们希望的。

而目前的效果是,当我在键盘上按下 tab 键时,focus 状态不在弹窗中,而是在里层的页面中移动

参考资料
[1]https://cn.vuejs.org/guide/built-ins/transition.html: https://link.juejin.cn?target=https%3A%2F%2Fcn.vuejs.org%2Fguide%2Fbuilt-ins%2Ftransition.html

用户评论