Vue3封装组件(带回调事件)


前言

这篇文章接着上一篇文章(Vue3封装全局函数式组件)继续分享vue3的组件封装,上一篇文章是以Toast提示为例子封装的组件,此外还有Dialog 弹出框这种组件我们常常会封装起来调用,它们之间区别不大,主要就是多了点击按钮能触发回调,所以这篇文章介绍一下函数式组件中回调事件的写法,希望对大家有所启发。


一、思路

首先组件调用需要满足链式调用,想实现形如以下的写法:

okToast.show()
  .then(res => {
    
    
    console.log('点击了确认');
  })
  .catch(err => {
    
    
    console.log('点击了取消');
  })

在 then 后面处理的是点击确认的逻辑,在 catch 后面处理的是点击取消的逻辑,并且在 then 后面可以继续调起第二个弹窗满足链式调用。
那么很自然能想到的就是采用 Promise 实现,Promise 可以把异步操作执行后的状态及时传递回来使得回调函数能够及时调用。

二、代码示例

1. vue文件

代码如下(示例):

<template>
  <transition name="toast" @after-leave="onAfterLeave">
    <div class="toast" v-if="isShow" :style="{ width: toastWidth }">
      <div
        v-if="time < 0 && type !== 'confirm' && type !== 'confirmAndcancel'"
        class="cancel"
        @click="hidden"
      ></div>
      <img
        v-if="type === 'success' || type === 'icon'"
        class="img"
        src="../../assets/images/[email protected]"
        alt="success"
      />
      <img
        v-if="type === 'warn'"
        class="img"
        src="../../assets/images/7vip_web_toast_warn.png"
        alt="warn"
      />
      <div v-if="content && type !== 'icon'" class="content" :style="{ textAlign }">
        {
   
   { content }}
      </div>
      <!-- 主要在这里增加了两种样式 -->
      <!-- 这是只有一个确定按钮的 -->
      <div class="operation" v-if="type === 'confirm'">
        <div class="confirm" @click="successHandle">{
   
   { successText }}</div>
      </div>
      <!-- 这是同时有确定与取消按钮的 -->
      <div class="operation" v-if="type === 'confirmAndcancel'">
        <div class="close" @click="cancelHandle">{
   
   { cancelText }}</div>
        <div class="confirm" @click="successHandle">{
   
   { successText }}</div>
      </div>
    </div>
  </transition>
</template>
<script setup>
import {
      
       ref, computed, nextTick } from 'vue';

const props = defineProps({
      
      
  content: {
      
      
    type: String,
    default: 'success'
  },
  time: {
      
      
    type: Number,
    default: 2000
  },
  width: {
      
      
    default: 310
  },
  textAlign: {
      
      
    type: String,
    default: 'center'
  },
  type: {
      
      
    type: String,
    default: 'success'
  },
  hide: {
      
      
    type: Function,
    default: () => {
      
      }
  },
  successText: {
      
      
    type: String,
    default: '确认'
  },
  cancelText: {
      
      
    type: String,
    default: '取消'
  },
  successBtn: {
      
      
    type: Function,
    default: () => {
      
      }
  },
  cancelBtn: {
      
      
    type: Function,
    default: () => {
      
      }
  }
});
const isShow = ref(false);
const toastWidth = computed(
  () => (parseInt(props.width.toString()) / 750) * document.documentElement.clientWidth + 'px'
);

const show = () => {
      
      
  isShow.value = true;
  if (props.time >= 0) {
      
      
    setTimeout(() => {
      
      
      // isShow.value = false;
      successHandle();
    }, props.time);
  }
};
defineExpose({
      
      
  show
});

const hidden = () => {
      
      
  isShow.value = false;
};
const onAfterLeave = () => {
      
      
  props.hide();
};

//新增处理确认的方法
const successHandle = () => {
      
      
  props.successBtn();
  nextTick(() => {
      
      
    hidden();
  });
};
//新增的处理取消的方法
const cancelHandle = () => {
      
      
  props.cancelBtn();
  nextTick(() => {
      
      
    hidden();
  });
};
</script>

<style lang="scss" scoped>
.toast-enter-active,
.toast-leave-active {
      
      
  transition: opacity 0.3s ease-out;
}
.toast-enter-from,
.toast-leave-to {
      
      
  opacity: 0;
}
.toast {
      
      
  position: fixed;
  top: 45%;
  left: 50%;
  transform: translate(-50%, -50%);
  z-index: 99;
  background: #333333;
  border-radius: 20px;
  padding: 20px;
  text-align: center;
  .cancel {
      
      
    background: url('../../assets/images/[email protected]') no-repeat center / contain;
    position: absolute;
    top: 10px;
    right: 10px;
    width: 20px;
    height: 20px;
    &::before {
      
      
      content: '';
      position: absolute;
      top: -10px;
      right: -10px;
      bottom: -10px;
      left: -10px;
    }
  }
  .img {
      
      
    width: 40px;
    height: 40px;
  }
  .content {
      
      
    margin-top: 10px;
    font-size: 16px;
    color: #ffcc99;
    text-align: initial;
  }
  .operation {
      
      
    display: flex;
    justify-content: space-around;
    align-items: center;
    margin-top: 20px;
    .confirm {
      
      
      color: white;
      opacity: 0.9;
    }
    .close {
      
      
      color: #ffcc99;
      opacity: 0.9;
    }
  }
}
</style>

说明 ①:

const successHandle = () => {
    
    
  props.successBtn();
  nextTick(() => {
    
    
    hidden();
  });
};

这里有个细节,在 vue 文件里的 successHandle 方法里由于点击后需要关闭弹窗,所以调用 hidden 方法,但是得先执行 successBtn 方法,即 Promise 里的 resolve() ,然后处理完回调后的逻辑再进行关闭卸载的逻辑,否则可能回调还没执行完弹窗就因为 hidden 的原因被关掉了,所以这个执行顺序很重要,此处加入vue中的 nextTick 方法保证了 hidden 是在所有异步任务的最后再触发。cancelHandle 方法同理。

说明 ②:

const show = () => {
    
    
  isShow.value = true;
  if (props.time >= 0) {
    
    
    setTimeout(() => {
    
    
      // isShow.value = false;
      successHandle();
    }, props.time);
  }
};

show方法里面不直接关闭弹窗了,而是调用传进来的方法,这样使得普通写法即 proxy.$okToast() 也可以使用链式调用。

2. js文件

代码如下(示例):

import {
    
     createApp } from 'vue';
import OkToast from './okToast.vue';

let rootNode = null;
let app = null;

const okToast = options => {
    
    
  const dom = document.body.querySelector('.my-dialog');
  if (!dom) {
    
    
    // 创建元素节点
    rootNode = document.createElement('div');
    rootNode.className = `my-dialog`;
    // 在body标签内部插入此元素
    document.body.appendChild(rootNode);
  } else {
    
    
    app.unmount();
  }
  // 创建应用实例(第一个参数是根组件。第二个参数可选,它是要传递给根组件的 props)
  app = createApp(OkToast, {
    
    
    ...options,
    hide() {
    
    
      // 卸载已挂载的应用实例
      if (app) {
    
    
        app.unmount();
        app = null;
      }
      // 删除rootNode节点
      if (rootNode) {
    
    
        document.body.removeChild(rootNode);
        rootNode = null;
      }
    }
  });
  // 将应用实例挂载到创建的 DOM 元素上
  return app.mount(rootNode);
};

// 需要给options设默认值,否则直接调用okToast()会出错
const okFun = (options = {
     
     }) => {
    
    
  return new Promise((resolve, reject) => {
    
    
    options.successBtn = () => {
    
    
      resolve();
    };
    options.cancelBtn = () => {
    
    
      reject();
    };
    okToast(options).show();
  });
};

okToast.install = app => {
    
    
  // 注册全局组件
  // app.component("Toast", OkToast);
  // 注册全局属性,类似于 Vue2 的 Vue.prototype
  // app.config.globalProperties.$okToast = options => okToast(options).show();
  app.config.globalProperties.$okToast = options => okFun(options);
};
// 定义show方法用于直接调用
// okToast.show = options => okToast(options).show();
okToast.show = options => okFun(options);

export default okToast;

说明 ①:

原本的写法是okToast(options).show(),现在是调用okFun方法,在okFun方法里返回的是一个 Promise 对象,并且利用 createApp 方法其第二个参数是可以传递给根组件的,那么就能往参数上定义两个方法,这两个方法上分别绑定在确定及取消按钮上,当按钮点击后触发此处事件那么就能改变 Promise 的状态从而触发回调了,very interesting!


三、使用方法及效果展示

传入 type 为 “confirmAndcancel” 则有两个按钮,传入 type 为 “confirm” 则只有一个,time 需要传 -1 以便弹窗停留在窗口,详细参数可看上面的 vue 文件里的props。同时由于 Promise 支持 finally 方法,所以作为示例也加上了。代码如下(示例):

proxy
  .$okToast({
    
    
    time: -1,
    width: 500,
    type: 'confirmAndcancel',
    content: '如果解决方法是丑陋的,那就肯定还有更好的解决方法,只是还没有发现而已。',
  })
  .then(res => {
    
    
    console.log('点击了确认1');
    proxy
      .$okToast({
    
    
        time: -1,
        width: 500,
        type: 'confirm',
        content: '再点一下弹窗就消失了',
        successText: 'OK'
      })
      .then(res => {
    
    
        console.log('点击了确认2');
      });
  })
  .catch(err => {
    
    
    console.log('点击了取消');
    proxy.$okToast({
    
    
      time: -1,
      width: 500,
      type: 'confirm',
      content: '再点一下弹窗就消失了',
      successText: 'OK',
    });
  })
  .finally(() => {
    
    
    console.log('finally');
  });

效果展示:
请添加图片描述

可以看到链式调用的回调是正常生效的。

四、加入一点细节,完善效果

上面的效果大体上已经实现了Dialog 的弹窗效果了,正当我以为大功告成的时候,对比UI组件库上的 Dialog ,我才意识到少了点东西。。。没错就是遮罩层,遮罩层除了样式还有背景禁止滚动的逻辑,好吧,赶紧补上去。

  1. 首先定义遮罩层的样式,代码如下(示例):
.my-overlay {
    
    
  position: fixed;
  top: 0;
  left: 0;
  z-index: 99;
  width: 100%;
  height: 100%;
  background: rgba(0, 0, 0, 0.6);
}
  1. 然后创建弹窗 DOM 的时候把遮罩层元素也添加进去。一开始想法很简单也实现了效果,就是直接监听元素的 “touchmove” 事件阻止滚动,比如这样:
let overlayNode = null;
let rootNode = null;
let app = null;
// 阻止默认事件
let noScroll = e => {
    
    
  e.preventDefault();
};

const okToast = options => {
    
    
  const dom = document.body.querySelector(".my-dialog");
  if (!dom) {
    
    
    if (options.type === "confirmAndcancel" || options.type === "confirm") {
    
    
      // 创建遮罩层
      overlayNode = document.createElement("div");
      overlayNode.className = `my-overlay`;
      document.body.appendChild(overlayNode);
      // 监听滚动事件
      document.body.querySelector(".my-overlay").addEventListener("touchmove", noScroll);
    }
    rootNode = document.createElement("div");
    rootNode.className = `my-dialog`;
    document.body.appendChild(rootNode);
    // 监听滚动事件
    document.body.querySelector(".my-dialog").addEventListener("touchmove", noScroll);
  } else {
    
    
    app.unmount();
  }
  app = createApp(OkToast, {
    
    
    ...options,
    hide() {
    
    
      if (options.type === "confirmAndcancel" || options.type === "confirm") {
    
    
        // 解除监听
        document.body.querySelector(".my-overlay").removeEventListener("touchmove", noScroll);
        document.body.querySelector(".my-dialog").removeEventListener("touchmove", noScroll);
        if (overlayNode) {
    
    
          document.body.removeChild(overlayNode);
          overlayNode = null;
        }
      }
      if (app) {
    
    
        app.unmount();
        app = null;
      }
      if (rootNode) {
    
    
        document.body.removeChild(rootNode);
        rootNode = null;
      }
    },
  });
  return app.mount(rootNode);
};

存在的问题:
但是这样的话,不仅把背景禁止滚动了也把弹窗里的内容给禁止滚动了,如果弹窗文字内容太多,那弹窗高度就需要限制,弹窗内容做滚动处理,而在考虑这种背景不能滚动而弹窗内能滚动的情况时,遇到了移动端的滚动背景穿透问题,又得考虑兼容性问题,还真是一波三折。

解决办法:
尝试了各种写法后,还好最终找到了一种比较简单的写法实现了效果,就是给同级的 Vue 创建的 app 元素 加 position = "fixed",弹窗出现的时候固定定位使得背景层页面不能滚动, bottom 值定位在当前页面滚动距离免得页面位置变化,最后弹窗消失的时候取消 fixed 定位,这个时候页面会自动为于顶部,不过没关系,再将页面的 scrollTop 值还原回去就可以了,这种方法虽然不能保证100%成功,但是也经过了我手上安卓及苹果机的测试,如果遇到问题欢迎在评论区指出。
代码如下(示例):

let overlayNode = null;
let rootNode = null;
let app = null;
let scrollTop = 0;
const createOverlay = () => {
    
    
  const app= document.querySelector('#app');
  if (!scrollTop) {
    
    
    scrollTop = app.scrollTop;
  }
  // 禁止app元素滚动
  app.style.position = 'fixed';
  app.style.bottom = scrollTop + 'px';
  // 兼容ios手机fixed定位不生效,得加overflow
  app.style.overflow = 'visible';

  // 创建遮罩层
  overlayNode = document.createElement('div');
  overlayNode.className = `my-overlay`;
  // // 在body标签内部插入此元素
  document.body.appendChild(overlayNode);
};
const deleteOverlay = () => {
    
    
  // 删除overlayNode节点
  if (overlayNode) {
    
    
    document.body.removeChild(overlayNode);
    overlayNode = null;
  }
  // 解除app元素滚动,移除样式
  const app =document.querySelector('#app')
  app.style.removeProperty('position')
  app.style.removeProperty('bottom')
  app.style.removeProperty('overflow')
  // 恢复页面滚动距离
  app.scrollTop = scrollTop;
  // 恢复默认值
  scrollTop = 0;
};

const okToast = options => {
    
    
  const dom = document.body.querySelector('.my-dialog');
  if (!dom) {
    
    
  	// 将type与遮罩层关系解耦,根据传入的time参数确定遮罩层显示或隐藏,time小于0则是Dialog,大于0或者不传time则是Toast,当然更好的方式是区分开这两个组件,然后单独做配置项,这里只是为了兼容之前的Toast逻辑
    // if (options.type === "confirmAndcancel" || options.type === "confirm") {
    
    
    if (options.time && options.time < 0) {
    
    
      createOverlay();
    }
    // 创建元素节点
    rootNode = document.createElement('div');
    rootNode.className = `my-dialog`;
    document.body.appendChild(rootNode);
  } else {
    
    
    app.unmount();
    // 根据传入配置去掉遮罩层
    if (!options.time || options.time > 0) {
    
    
      deleteOverlay();
    }
  }
  app = createApp(OkToast, {
    
    
    ...options,
    hide() {
    
    
      if (options.time && options.time < 0) {
    
    
        deleteOverlay();
      }
      if (app) {
    
    
        app.unmount();
        app = null;
      }
      if (rootNode) {
    
    
        document.body.removeChild(rootNode);
        rootNode = null;
      }
    }
  });
  // 将应用实例挂载到创建的 DOM 元素上
  return app.mount(rootNode);
};

另外 vue 文件也改一下样式,满足内容区的滚动效果

.content {
    
    
  margin-top: 10px;
  font-size: 32px;
  color: #ffcc99;
  text-align: initial;
  max-height: 50vh;
  overflow-y: scroll;
}
  1. 效果展示:
    请添加图片描述

总结

以上就是全部内容,本文通过封装 Dialog 弹出框组件探索了 Vue3 函数式组件的封装方法,同时解决了移动端弹出框背景滚动的相关问题。在此次探索学习的过程中,我深刻体会到在遇到问题的时候,可以多转换下思路,多尝试,同时找找资料,学习一下别人的经验和技巧,因为有些普遍存在的问题肯定有前人遇到过,那么站在巨人的肩膀上,加上自己的思考,问题就迎刃而解了。

如果此篇文章对您有帮助,欢迎您【点赞】、【收藏】!也欢迎您【评论】留下宝贵意见,共同探讨一起学习~

猜你喜欢

转载自blog.csdn.net/m0_55119483/article/details/130289309
今日推荐