uniapp实现全局Toast组件

4,911 阅读2分钟

uni Toast与Loading只能同时显示一个,uni.showLoaing会关闭toast。在一些场景下会导致toast一闪而过。

Loader方案

因为小程序App.vue是不支持写Template页面的,所以无法全局共享改组件,也没有document.bodyappend方式在使用的地方操作。

我们看一下 TDesignVant 怎么实现的

发现都得手动注入wxml页面,然后才能调用。如果需要脱离具体页面,在请求时需要弹出Toast(跟H5弹窗类似)相似的效果,这种方式行不通。

我的设想是通过插件,在编译阶段把组件自动注入到每个页面,这样就可以直接使用了。

在社区中发现有人实现了一种loader方案,接下来我们尝试接入项目:

使用 vue-inset-loader 全局注入页面根元素下,通过selectComponent获取组件实例,然后调用组件内方法即可:

安装 vue-inset-loader 插件

npm install vue-inset-loader --save-dev

配置 page.json 信息

"insetLoader": {
      "config":{
          "toast": "<Toast  id="t-toast"></Toast>"
      },
      "label":["toast"],
      "rootEle":"view"
  },

vue.config.js配置

const path = require('path');


module.exports = {
  configureWebpack: {
    module: {
      rules: [
        {
          test: /.vue$/,
          use: {
            // loader: 'vue-inset-loader', // 实现全局组件注册入页面
            // // 针对Hbuilder工具创建的uni-app项目 https://github.com/1977474741/vue-inset-loader/blob/main/utils.js
            loader: path.resolve(
              __dirname,
              './node_modules/vue-inset-loader'
            ),
            // // 支持自定义pages.json文件路径
            options: {
              pagesPath: path.resolve(__dirname, './pages.json'),
            },
          },
        },
      ],
    },
  },
};
// why ? 解决 uni.showToast 与 uni.loading 不能不同使用的问题;
// 参照 vant t-design 结合 Toast https://github.com/youzan/vant-weapp/blob/dev/packages/toast/toast.ts
// 使用 vue-inset-loader 全局注入页面根元素下 <Toast  id="t-toast"></Toast> 可以在 page.json中查看insetLoader属性配置
// 目前仅支持部分功能 icon mask  type(success,fail,loading) 功能暂不支持
function isObj(x) {
  const type = typeof x;
  return x !== null && (type === 'object' || type === 'function');
}
function getContext() {
  const pages = getCurrentPages();
  return pages[pages.length - 1];
}


const defaultOptions = {
  type: 'text',
  mask: false,
  message: '',
  visible: true,
  zIndex: 1000,
  duration: 3000,
  placement: 'middle',
  selector: '#t-toast',
};
let queue = [];
let currentOptions = { ...defaultOptions };
function parseOptions(message) {
  return isObj(message) ? message : { message };
}


function Toast(toastOptions) {
  const options = {
    ...currentOptions,
    ...parseOptions(toastOptions),
  };


  const context =
    (typeof options.context === 'function'
      ? options.context()
      : options.context) || getContext();
  const toast = context?.selectComponent?.(options.selector);


  if (!toast) {
    console.warn(
      '未找到 van-toast 节点,请确认 selector 及 context 是否正确'
    );
    return;
  }


  delete options.context;
  delete options.selector;


  toast.clear = () => {
    toast.setData({ visible: false });


    if (options.onClose) {
      options.onClose();
    }
  };
  let onHide = context.onHide;
  // eslint-disable-next-line no-unused-vars
  context.onHide = function (...arg) {
    // console.log('onHide#######');
    onHide.apply(this, arg);
    toast.clear();
    context.onHide = onHide;
  };
  queue.push(toast);
  toast.setData({
    visible: options.visible,
    duration: options.duration,
    message: options.message,
    placement: options.placement,
    preventScrollThrough: options.preventScrollThrough,
    theme: options.theme,
  });
  clearTimeout(toast.timer);
  if (options.duration != null && options.duration > 0) {
    toast.timer = setTimeout(() => {
      console.log('toast clear timeout ');
      toast.clear();
      queue = queue.filter((item) => item !== toast);
    }, options.duration);
  }


  return toast;
}
const createMethod = (type) => (options) =>
  Toast({
    type,
    ...parseOptions(options),
  });
Toast.loading = createMethod('loading');
Toast.success = createMethod('success');
Toast.fail = createMethod('fail');


Toast.clear = () => {
  queue.forEach((toast) => {
    toast.clear();
  });
  queue = [];
};


Toast.setDefaultOptions = (options) => {
  Object.assign(currentOptions, options);
};


Toast.resetDefaultOptions = () => {
  currentOptions = { ...defaultOptions };
};


export default Toast;
<template>
  <!-- 自定义toast组件 -->
  <view v-if="visible" :class="classNames" :style="styles">
    <view :class="contentClass">
      <slot name="icon" />
      <view aria-role="alert" :class="alertClass">{{ message }} </view>
      <slot name="message" />
    </view>
  </view>
</template>


<script>
// eslint-disable-next-line no-unused-vars
import _ from './utils';
export default {
  // props: {
  //  direction: {
  //    type: String,
  //    value: 'row',
  //  },
  //  /** 弹窗显示毫秒数 */
  //  duration: {
  //    type: Number,
  //    value: 2000,
  //  },
  //  /** 弹窗显示文字 */
  //  message: {
  //    type: String,
  //  },
  //  /** 弹窗展示位置 */
  //  placement: {
  //    type: String,
  //    value: 'middle',
  //  },
  //  /** 防止滚动穿透,即不允许点击和滚动 */
  //  preventScrollThrough: {
  //    type: Boolean,
  //    value: false,
  //  },
  //  /** 是否显示遮罩层 */
  //  showOverlay: {
  //    type: Boolean,
  //    value: false,
  //  },
  //  /** 提示类型 */
  //  theme: {
  //    type: String,
  //  },
  // },
  data() {
    return {
      prefix: 't',
      classPrefix: 't-toast',
      visible: false,
      direction: 'row',
      duration: 2000,
      message: '',
      placement: 'middle',
      preventScrollThrough: false,
      showOverlay: false,
      theme: '',
    };
  },
  computed: {
    classNames() {
      return `${_.cls(this.classPrefix, [
        this.direction,
        this.theme,
        ['with-text', this.message],
      ])}  class ${this.prefix}-class`;
    },
    styles() {
      return _._style([
        'top:' +
          (this.placement === 'top'
            ? '25%'
            : this.placement === 'bottom'
            ? '75%'
            : '45%'),
      ]);
    },
    contentClass() {
      return `${this.classPrefix}__content ${this.classPrefix}__content--${this.direction}`;
    },
    alertClass() {
      return `${this.classPrefix}__text ${this.classPrefix}__text--${this.direction}`;
    },
  },
  methods: {
    // show(options) {
    //  const props = options;
    //  const defaultOptions = {
    //    duration: props.duration,
    //    message: props.message,
    //    placement: props.placement,
    //    preventScrollThrough: props.preventScrollThrough,
    //    theme: props.theme,
    //  };
    //  const data = {
    //    ...defaultOptions,
    //    ...options,
    //    visible: true,
    //  };
    //  this.setData(data);
    // },
    // hide() {
    //  this.setData({ visible: false });
    //  this.data?.close?.();
    //  this.$emit('close');
    // },
    // clear() {
    //  this.hide();
    //  this.$emit('clear');
    // },
  },
};
</script>

注入全局组件

import Vue from 'vue';
import App from './App';
import Toast from '@/components/base/toast/BaseToast.vue';


Vue.config.productionTip = false;
Vue.component('Toast', Toast);
const app = new Vue({
  store,
  ...App,
});
app.$mount();

使用示例:

import Toast from '@/components/base/toast/toast';

t1 = Toast('Hello Toast'); // 3s 后自动关闭
t2 = Toast({message: 'Hello Toast2', duration: 0}); // 需要手动关闭
t3 = Toast('Hello Toast3');
t4 = Toast('Hello Toast4');
setTmieout(() => {
  // 手动关闭
	t2.clear();
}, 5000)
// 关闭所有Toast
Toast.clear()

补充 2.22.1版本

2.22.1之后可以根据hideLoading noConflict属性取消混用特性;Toast Loading目前还是只能显示一个,即Loading,Toast会关闭另一个弹窗,但是HideLoading可以通过该设置避免意外关闭正在显示的Toast,这在一些异步场景下很有帮助。

补充组件 onPageHide

发现uniapp有微信小程序(pageLifetime)类似方法可以实现在组件内监听页面hide、show,所以可以直接在组件内使用 onPageHide去关闭Toast,就不用重写page.onHide了。