Skip to content

Toast 组件封装文档训练

📋 概述

本文档介绍了一个基于 Vue 3 h 函数实现的 Toast 组件封装,旨在解决 wot-design-uni Toast 组件需要在模板中手动放置 <wd-toast /> 的问题,实现在纯 TypeScript 文件中也能使用 Toast 功能。

🎯 代码

type 类型定义

ts
export interface ToastOptions {
  show: boolean;
  msg?: string;
  iconName?: "success" | "error" | "warning" | "info" | "loading";
  duration?: number;
  position?: "top" | "middle" | "bottom";
  cover?: boolean;
}

export interface Toast {
  show: (options: ToastOptions) => void;
  success: (msg: string, duration?: number) => void;
  error: (msg: string, duration?: number) => void;
  warning: (msg: string, duration?: number) => void;
  info: (msg: string, duration?: number) => void;
  loading: (msg: string) => void;
  hide: () => void;
  destroy: () => void;
}

渲染器

ts
import { h, ref, render, watch, type VNode } from "vue";
import type { ToastOptions } from "./toast-types";

/**
 * 基于 h 函数的 Toast 渲染器
 * 高度还原 wot-design-uni 的 wd-toast 样式和功能
 */
export class ToastRenderer {
  private container: HTMLElement | null = null;
  private vnode: VNode | null = null;
  private toastState = ref<ToastOptions>({ show: false });
  private timer: number | null = null;

  constructor() {
    this.init();
  }

  private init() {
    // 创建容器
    this.container = document.createElement("div");
    this.container.id = "toast-container";
    this.container.style.cssText = `
      position: fixed;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      pointer-events: none;
      z-index: 100;
    `;
    document.body.appendChild(this.container);

    // 监听状态变化,重新渲染
    watch(
      () => this.toastState.value,
      () => {
        this.renderToast();
      },
      { deep: true, immediate: true }
    );
  }

  private renderToast() {
    const state = this.toastState.value;

    if (!state.show) {
      // 隐藏 Toast
      if (this.vnode && this.container) {
        render(null, this.container);
        this.vnode = null;
      }
      return;
    }

    // 清除之前的定时器
    if (this.timer) {
      clearTimeout(this.timer);
      this.timer = null;
    }

    // 创建 Toast VNode
    this.vnode = this.createToastVNode(state);

    if (this.container) {
      render(this.vnode, this.container);
    }

    // 自动隐藏
    if (state.duration && state.duration > 0) {
      this.timer = setTimeout(() => {
        this.hide();
      }, state.duration) as any;
    }
  }

  private createToastVNode(state: ToastOptions): VNode {
    const { msg, iconName, position = "middle", cover = false } = state;

    const children = [];

    // 1. 遮罩层(如果需要)
    if (cover) {
      children.push(this.createOverlayVNode());
    }

    // 2. Toast 主体
    children.push(this.createToastMainVNode(state));

    return h("div", { style: { pointerEvents: "none" } }, children);
  }

  private createOverlayVNode(): VNode {
    const overlayStyle = {
      position: "fixed" as const,
      top: 0,
      left: 0,
      width: "100%",
      height: "100%",
      backgroundColor: "transparent",
      pointerEvents: "auto" as const,
      zIndex: 99,
    };

    return h("div", { style: overlayStyle });
  }

  private createToastMainVNode(state: ToastOptions): VNode {
    const { msg, iconName, position = "middle" } = state;

    // Toast 容器样式(完全模仿 wd-toast 的 transitionStyle)
    const containerStyle = {
      zIndex: 100,
      position: "fixed" as const,
      top: "50%",
      left: 0,
      width: "100%",
      transform: "translate(0, -50%)",
      textAlign: "center" as const,
      pointerEvents: "none" as const,
    };

    // 判断 Toast 类型(完全按照 wd-toast 的逻辑)
    const hasIcon = iconName && (iconName !== "loading" || msg);
    const isLoadingOnly = iconName === "loading" && !msg;

    // Toast 内容样式(高度还原 wd-toast 的样式)
    const toastStyle = {
      display: "inline-flex",
      alignItems: "center",
      justifyContent: "center",
      flexDirection: "row" as const,
      padding: isLoadingOnly ? "15px" : hasIcon ? "12px 16px" : "10px 16px",
      backgroundColor: "rgba(0, 0, 0, 0.8)",
      color: "#fff",
      borderRadius: "8px",
      fontSize: "14px",
      lineHeight: "20px",
      fontWeight: "400",
      maxWidth: isLoadingOnly ? "none" : "70%",
      wordWrap: "break-word" as const,
      boxSizing: "border-box" as const,
      minHeight: isLoadingOnly ? "48px" : "auto",
      minWidth: isLoadingOnly ? "48px" : "auto",
      whiteSpace: "pre-wrap" as const,
      textAlign: "left" as const,
      // 添加阴影效果
      boxShadow: "0 2px 8px rgba(0, 0, 0, 0.2)",
    };

    // 创建内容
    const children = [];

    // 图标
    if (iconName) {
      children.push(this.createIconVNode(iconName, !!msg));
    }

    // 文本
    if (msg) {
      children.push(this.createTextVNode(msg));
    }

    return h(
      "div",
      { style: containerStyle },
      h(
        "div",
        {
          style: toastStyle,
        },
        children
      )
    );
  }

  private createIconVNode(iconName: string, hasText: boolean): VNode {
    const baseIconStyle = {
      display: "inline-block",
      flexShrink: 0,
      verticalAlign: "middle",
    };

    // 根据图标类型和是否有文本调整样式
    const iconStyle = {
      ...baseIconStyle,
      width: iconName === "loading" && !hasText ? "24px" : "20px",
      height: iconName === "loading" && !hasText ? "24px" : "20px",
      marginRight: hasText ? "8px" : "0",
    };

    if (iconName === "loading") {
      return this.createLoadingVNode(iconStyle);
    }

    return this.createSvgIconVNode(iconName, iconStyle);
  }

  private createLoadingVNode(style: any): VNode {
    // 添加 loading 动画的 CSS
    this.addLoadingAnimation();

    const loadingStyle = {
      ...style,
      border: "2px solid rgba(255, 255, 255, 0.2)",
      borderTop: "2px solid #4D80F0", // 使用 wot-design-uni 的主色调
      borderRadius: "50%",
      animation: "wd-toast-loading 1s linear infinite",
    };

    return h("div", {
      style: loadingStyle,
    });
  }

  private createSvgIconVNode(iconName: string, style: any): VNode {
    // 使用与 wot-design-uni 相同的 SVG 图标
    const svgMap: Record<string, string> = {
      success: this.getSuccessSvg(),
      error: this.getErrorSvg(),
      warning: this.getWarningSvg(),
      info: this.getInfoSvg(),
    };

    const svgContent = svgMap[iconName] || "";

    return h("div", {
      style: {
        ...style,
        backgroundImage: `url("data:image/svg+xml;base64,${btoa(svgContent)}")`,
        backgroundSize: "contain",
        backgroundRepeat: "no-repeat",
        backgroundPosition: "center",
      },
    });
  }

  private createTextVNode(msg: string): VNode {
    return h(
      "span",
      {
        class: "wd-toast__msg",
        style: {
          wordBreak: "break-all" as const,
        },
      },
      msg
    );
  }

  private addLoadingAnimation() {
    const styleId = "wd-toast-loading-animation";
    if (document.getElementById(styleId)) return;

    const style = document.createElement("style");
    style.id = styleId;
    style.textContent = `
      @keyframes wd-toast-loading {
        0% { transform: rotate(0deg); }
        100% { transform: rotate(360deg); }
      }
    `;
    document.head.appendChild(style);
  }

  // SVG 图标(简化版,与 wot-design-uni 保持一致的视觉效果)
  private getSuccessSvg(): string {
    return `<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
      <circle cx="10" cy="10" r="10" fill="#34D19D"/>
      <path d="M6 10l2.5 2.5L14 7" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
    </svg>`;
  }

  private getErrorSvg(): string {
    return `<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
      <circle cx="10" cy="10" r="10" fill="#FA4350"/>
      <path d="M7 7l6 6M13 7l-6 6" stroke="white" stroke-width="2" stroke-linecap="round"/>
    </svg>`;
  }

  private getWarningSvg(): string {
    return `<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
      <circle cx="10" cy="10" r="10" fill="#F0883A"/>
      <path d="M10 6v4M10 14h.01" stroke="white" stroke-width="2" stroke-linecap="round"/>
    </svg>`;
  }

  private getInfoSvg(): string {
    return `<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
      <circle cx="10" cy="10" r="10" fill="#909CB7"/>
      <path d="M10 10v4M10 6h.01" stroke="white" stroke-width="2" stroke-linecap="round"/>
    </svg>`;
  }

  // 公共方法
  show(options: ToastOptions) {
    this.toastState.value = {
      ...options,
      show: true,
    };
  }

  hide() {
    this.toastState.value = { ...this.toastState.value, show: false };
  }

  success(msg: string, duration = 2000) {
    this.show({
      msg,
      iconName: "success",
      duration,
      show: true,
    });
  }

  error(msg: string, duration = 2000) {
    this.show({
      msg,
      iconName: "error",
      duration,
      show: true,
    });
  }

  warning(msg: string, duration = 2000) {
    this.show({
      msg,
      iconName: "warning",
      duration,
      show: true,
    });
  }

  info(msg: string, duration = 2000) {
    this.show({
      msg,
      iconName: "info",
      duration,
      show: true,
    });
  }

  loading(msg: string) {
    this.show({
      msg,
      iconName: "loading",
      duration: 0, // 不自动隐藏
      show: true,
    });
  }

  destroy() {
    if (this.container && this.container.parentNode) {
      this.container.parentNode.removeChild(this.container);
    }
    this.container = null;
    this.vnode = null;
  }
}

实例类

ts
import { ToastRenderer } from "./toast-renderer";
import { Toast } from "./toast-types";

/**
 * 全局 Toast 实例
 * 可在任何 TS 文件中直接使用,无需依赖 Vue 组件
 */
class GlobalToast implements Toast {
  private renderer: ToastRenderer | null = null;

  private getRenderer(): ToastRenderer {
    if (!this.renderer) {
      this.renderer = new ToastRenderer();
    }
    return this.renderer;
  }

  show(options: any) {
    this.getRenderer().show(options);
  }

  success(msg: string, duration = 2000) {
    this.getRenderer().success(msg, duration);
  }

  error(msg: string, duration = 2000) {
    this.getRenderer().error(msg, duration);
  }

  warning(msg: string, duration = 2000) {
    this.getRenderer().warning(msg, duration);
  }

  info(msg: string, duration = 2000) {
    this.getRenderer().info(msg, duration);
  }

  loading(msg: string) {
    this.getRenderer().loading(msg);
  }

  hide() {
    this.getRenderer().hide();
  }

  destroy() {
    if (this.renderer) {
      this.renderer.destroy();
      this.renderer = null;
    }
  }
}

// 导出全局实例
export const toast = new GlobalToast();

// 也可以导出类,支持创建多个实例
export { GlobalToast, ToastRenderer };

// 默认导出
export default toast;

使用示例

html
<template>
  <view class="toast-test-page">
    <view class="section">
      <text class="section-title">Toast 测试</text>
      <view class="button-group">
        <wd-button @click="testSuccess" type="primary">成功提示</wd-button>
        <wd-button @click="testError" type="error">错误提示</wd-button>
        <wd-button @click="testWarning" type="warning">警告提示</wd-button>
        <wd-button @click="testInfo">信息提示</wd-button>
        <wd-button @click="testLoading" type="primary">加载提示</wd-button>
        <wd-button @click="testLoadingOnly">仅加载图标</wd-button>
        <wd-button @click="testLongText">长文本测试</wd-button>
        <wd-button @click="testHide" plain>隐藏提示</wd-button>
      </view>
    </view>
  </view>
</template>

<script setup lang="ts">
  import toast from "@/utils/toast";
  import { useToast } from "wot-design-uni";

  // 自定义 Toast 测试
  const testSuccess = () => {
    toast.success("操作成功!");
  };

  const testError = () => {
    toast.error("操作失败,请重试");
  };

  const testWarning = () => {
    toast.warning("请注意检查输入内容");
  };

  const testInfo = () => {
    toast.info("这是一条信息提示");
  };

  const testLoading = () => {
    toast.loading("加载中,请稍候...");

    // 3秒后自动隐藏
    // setTimeout(() => {
    //   toast.hide()
    //   toast.success('加载完成')
    // }, 3000)
  };

  const testLoadingOnly = () => {
    toast.loading("");

    setTimeout(() => {
      toast.hide();
    }, 2000);
  };

  const testLongText = () => {
    toast.info("这是一个很长很长的文本内容,用来测试 Toast 组件在显示长文本时的表现效果,看看是否能够正确换行和显示");
  };

  const testHide = () => {
    toast.hide();
  };
</script>

<style lang="scss" scoped>
  .toast-test-page {
    padding: 20px;

    .section {
      margin-bottom: 40px;

      .section-title {
        display: block;
        font-size: 16px;
        font-weight: bold;
        margin-bottom: 16px;
        color: #333;
      }

      .button-group {
        display: flex;
        flex-direction: column;
        gap: 12px;
      }
    }
  }
</style>

Released under the MIT License.