2
头图

前言: 最近接手了一个 UI 需求,设计师想让实现一个 popup,它可以在屏幕任意方向弹出一个自定义的样式。我首先考虑到的就是使用 vant 这个组件库中的 popup,但是发现它不是特别能满足我的需求。

个人是非常不喜欢使用 \<v-if> 或者 \<v-show> 这种方式去弹出一个其实和页面主体内容无关的组件的,因为这样会造成模板中充斥着各种 isModalShowisPopupShow 等和业务逻辑代码其实丝毫无关的变量。

于是乎顺手实现了一个高度自定义的函数式 popup,顺便分享一下组件的设计思路🎁。

注意: 本文侧重点不在于教你怎么写代码,而是帮你理清设计一个组件的思路,故内容有些许冗长,希望你耐心看完后,结合你已有的知识,你可以写出比我功能更全面的组件,才是本文的目的。☕️


一. 实现效果

11.gif

二. 前提准备

你只需要准备出两个文件,一个 .ts 类型,一个 .vue 类型,名字随你怎么喜欢。

image.png

三. 理清思路

  1. Stop,Stop,第一步千万不要着急写代码,敲键盘上的26个字母是开发过程中最简单的一步,最关键的步骤是理清我们整体的思路。
  2. 让我们先观察一下 popup 在整个文档中出现到消失的过程。

    1.gif
  3. 我们可以得到以下信息:

    • ① 整个文档的背景颜色暗了下来
    • ② 底部浮现出一个空白的元素
    • ③ 点击阴影部分,底部元素消失
  4. 对,就这么多,但是就这三个条件就足够了。这里我想说的是,其实创建这种组件原理很简单,但是大多数情况下我们的思维过度被框架组件库限制了,如果脱离了框架本身,就感觉无从下手了。
  5. 什么意思呢?如果我们回归到最初,没有任何框架和库,用纯 HTML 和 纯 JS 来实现这个需求,你会怎么考虑呢?
  6. 我们假设这是纯 HTML 文件写的一个静态页面。页面上有一个 id 为 appdiv 元素,它没有任何子元素,背景颜色也是白色。

    Tips: 这里的背景色是为了方便大家看清楚高度和宽度我鼠标移动上去造成的,并且请忽略页面上 vue 的相关编译结果,我们目前不会利用到任何一个 vue 的特性
    image.png
  7. 让我们直接在控制台上实现上面第一①和第三个③需求。别惊讶,就是这么简单。
  8. 我们鼠标先听到 #app 上,然后右键选择复制元素(duplicate element)。

    2.gif
  9. 此时你的页面上应该出现了两个 idappdiv 元素。

    image.png
  10. 我们选择其中一个(随便你喜欢哪一个),这里我选择第一个为接下来要操作的元素,并且把他 id 改为我的名字,以便接下来的操作。

    3.gif
  11. 你发现了吗?上面整个过程,我仅仅只写了几行样式就完成了第一个屏幕变暗的需求。

    image.png
  12. 那么接下来就该实现 ③ 点击阴影部分,底部元素消失 这个需求了。
  13. 在之前右键复制元素的时候,聪明的你可能也看到了另外的一个方法。(delete element) No,No,No。别忘了我们的需求是点击阴影处才消失,所以我们不能耍小聪明哦🎊。
  14. 其实也非常非常简单。你只需给这个元素添加一个 onclick 事件,然后触发这个元素的 remove 方法即可。

    4.gif

    没听说过 remove 方法的还不赶快 MDN 补课去?
    image.png
  15. ok,接下来我们就要想办法去实现 ② 底部浮现出一个空白的元素,其实它也可以在控制台实现,只不过本章节只是让大家理解这个 popup 出现的逻辑其实很简单,不要想复杂了。接下来我们配合 vue 来实现。

四. 完成 PopupWrapper 组件

让我们打开 PopupWrapper.vue 文件,根据上面复制元素操作的思路,我们可以快速写出一个十分简单样式的组件。

    <script lang="ts" setup></script>
<template>
  <div class="wrapper"></div>
</template>

<style scoped>
.wrapper {
  width: 100vw;
  height: 100vh;
  background-color: rgba(0, 0, 0, 0.5);
  z-index: 9999;
  position: absolute;
  top:0px
}
</style> 

五. 设计 popupCreator 函数

  1. 接下来打开 usePopup.ts 文件,这个函数需要两个内部函数,openclose

    import { h, render } from "vue";
    //h: 创建虚拟 dom  render: 挂载虚拟dom 到真实 dom节点
    
    import PopupWrapper from "./PopupWrapper.vue"; 
    // 引入刚刚写好的 PopupWrapper 组件
    
    export function popupCreator() {
      function open() {}
    
      function close() {}
    
      return {
        open,
        close,
      };
    }
  2. 这里有两个关键函数,hrender,这两个函数的讲解在我之前的文章中多次提及,这次就不过多赘述。

  3. 简单来说就是 h 函数会接收我们的 PopupWrapper 作为第一个参数,它的返回值是一个虚拟 dom。然后 render 函数接受这个 虚拟dom作为第一个参数,之后我们创建出一个真实dom 作为 render 的第二个参数,使其可以正确渲染到界面上。

    import { h, render } from "vue";
    //h: 创建虚拟 dom  render: 挂载虚拟dom 到真实 dom节点
    
    import PopupWrapper from "./PopupWrapper.vue";
    // 引入刚刚写好的 PopupWrapper 组件
    
    export function popupCreator() {
      const container = document.createElement("div"); //创建 container
      function open() {
        const vnode = h(PopupWrapper);
        render(vnode, container);
        document.body.appendChild(container);//将container 作为 body 的第二个子元素(和 #app 元素是兄弟元素)
      }
    
      function close() {
        container.remove();//移除 container
      }
    
      return {
        open,
        close,
      };
    }
  4. 接下来随便找个页面写一个按钮来测试一下。

    image.png

    5.gif

六. h 函数的第二个参数

  1. 此时 openclose 两个函数对应的功能已经完成,但是目前有一个很大的缺陷,我们没办法在点击阴影处,也就是PopupWrapper 组件以后关闭遮罩层,只能倒计时关闭。接下来我们就要给 h 函数传递第二个参数。这个参数可以作为组件的 props 或者 emit 事件传递到组件内部。如果是以 on 驼峰开头的属性,将作为 emit
  2. 那么此时你的 open 应该是这样

    image.png
  3. 于此同时,你的 Popupwrapper.vue 应该是这样的。

    image.png

    此时页面效果:

    7.gif

七. h 函数的第三个参数

  1. 万事俱备,现在只剩下如何让底部弹出我们的内容组件了。因为这个内容组件是和业务页面在一起的,所以我们假设有一个页面需要调用 popupCreator ,那么我会在当前页面创建一个 component 来放下这个内容组件。
  2. 那么我们的 popupCreator 将接收这个组件作为参数,来供函数内部使用

    image.png
  3. 既然是 slot 那么我们 PopupWrapper.vue组件 就需要留一个位置给这个组件。

    image.png
  4. 在使用 popupCreator 的时候需要把相对应的 contentPage 传递给它。

    image.png

    此时页面效果:

    9.gif

八. 控制 ContentPage 弹出的方向

  1. 接下来就是自定义弹出的方向问题了,这个比较简单,方法比较多。在 PopupWrapper.vue 组件加一层 div 然后根据需求调整即可。

    image.png
  2. 新的问题来了,我们代码写死了,没办法动态控制,该如何解决这个问题呢?请回顾标题六
    image.png
  3. 那么我们 PopupWrapper.vue 内部就可以根据 props 来动态调整方向。比较基础的知识,不再过多介绍。

    image.png

九. 源码

组件样式使用了 unocss 但是不影响阅读。

import { Component, h, render } from "vue";
//h: 创建虚拟 dom  render: 挂载虚拟dom 到真实 dom节点

import PopupWrapper from "./PopupWrapper.vue";
// 引入刚刚写好的 PopupWrapper 组件

interface WrapperOption {
  //根据需要写一个 interface
  direction: "top" | "bottom";
}

export function popupCreator(ContentPage: Component, option?: WrapperOption) {
  const container = document.createElement("div");
  function open() {
    const vnode = h(
      PopupWrapper,
      {
        direction: option?.direction,
        onClose: close, //作为第二个参数传入 close 函数
      },
      {
        default: () => h(ContentPage), //第三个参数将作为 slot 传入 PopupWrapper
      }
    );
    render(vnode, container);
    document.body.appendChild(container);
  }

  function close() {
    container.remove();
  }

  return {
    open,
    close,
  };
}
<script lang="ts" setup>
defineProps<{
  direction?: "top" | "bottom";
}>();

const emit = defineEmits<{
  (e: "close"): void;
}>();
</script>
<template>
  <div class="wrapper" @click="emit('close')">
    <div
      class="w-full h-full flex"
      :class="direction === 'top' ? '' : 'flex-col-reverse'"
    >
      <slot name="default" />
    </div>
  </div>
</template>

<style scoped>
.wrapper {
  width: 100vw;
  height: 100vh;
  background-color: rgba(0, 0, 0, 0.5);
  z-index: 9999;
  position: absolute;
  top: 0px;
}
</style>

附赠一份 tsx 版本,这个版本实现了动画效果,如果要使用,请定义相应的动画效果。

import { defineComponent, ref, h, nextTick, render, onMounted } from 'vue';
import type { Component, VNodeProps } from 'vue';

//动画过度效果的时间
const baseAnimationDuration = 200;

const PopupWrapper = defineComponent({
    emits: ['close'],
    setup(props, ctx) {
        const contentEl = ref<HTMLDivElement>();
        const wrapperEl = ref<HTMLDivElement>();

        function clickMask() {
            //wrapper-open & wrapper-close 样式写在  ui/src/style.scss 文件下
            wrapperEl.value!.style.animation = `wrapper-close ease ${baseAnimationDuration}ms`;
            ctx.emit('close');
        }

        onMounted(() => {
            if (!contentEl.value || !wrapperEl.value) return;
            wrapperEl.value.style.animation = `wrapper-open ease ${baseAnimationDuration}ms`;
            wrapperEl.value.style.animationFillMode = 'forwards';
            contentEl.value.style.animation = `conent-open ease ${baseAnimationDuration}ms`;
        });
        return () => (
            <div onClick={() => clickMask()} class="popup_wrapper" ref={wrapperEl}>
                <div class="w-full h-full flex items-end">
                    <div ref={contentEl} class="content w-fit" id="$popupContent">
                        {ctx.slots.default?.()}
                    </div>
                </div>
            </div>
        );
    },
});

type ExtractComponentProps<TC> = TC extends new (...arg: any) => {
    $props: infer P;
}
    ? P
    : never;

export function popupCreator<C extends Component>(component: C, props?: ExtractComponentProps<C>) {
    const container = document.createElement('div');
    container.style.width = '100%';
    container.style.height = '100%';
    container.style.position = 'absolute';
    container.style.zIndex = '9999999';
    const isShow = ref(false);
    function open() {
        if (isShow.value) return;
        const vnode = h(
            PopupWrapper,
            { onClose: close },
            {
                default: () => h(component, props as VNodeProps),
            },
        );
        render(vnode, container);
        document.body.appendChild(container);
        isShow.value = true;
    }
    function close() {
        const contentElement: HTMLDivElement = document.getElementById('$popupContent') as HTMLDivElement;
        if (!contentElement) return;
        contentElement.style.animation = `content-close ease ${baseAnimationDuration}ms`;
        setTimeout(() => {
            container.remove();
            isShow.value = false;
        }, baseAnimationDuration - 20);
    }
    return {
        open,
        close,
        isShow,
    };
}

FFF方
453 声望12 粉丝