2. 做一个极简 UI 库之Toast 组件

项目地址: https://github.com/mrxu0/simple-ui

效果

image.png

API 设计

先设计好了 API 写起来代码才不会犯迷糊

Toast(message: string; otherParams?: ToastParams): ToastReturn

interface ToastParams {
  time?: number;
  appendTo?: string | HTMLElement;
  dangerouslyUseHTMLString?: boolean;
}

interface ToastReturn {
  close():void
}

ToastParams 详解

属性 说明 类型 默认值
time Toast 存在的时长, 单位秒, -1 代表永不消失 number 2
appendTo 将 Toast 放到那个 dom 下 string 或 HTMLElement document.body
dangerouslyUseHTMLString 是否将 message 解析为 html 片段 boolean false

ToastReturn 详解

属性 说明 类型 默认值
close 关闭 Toast Function

难点说明

先说一下实现一个居中的 Toast 提示的基本思路:

div {
  position: fixed;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
}

有了这个核心样式 Toast 就有了。再继续往下做其实还需要俩步:

  1. 解决覆盖(遮挡)问题:需要放到顶级元素 body 下
  2. 如何用 js 代码进行这个组件的调用

先说第一个

fixed 的设置是有限制的,就是不能有任何祖先元素设置了 transformperspective 或者 filter 样式属性。也就是说如果我们想要用 CSS transform 为祖先节点 <div class="outer"> 设置动画,就会不小心破坏模态框的布局!

z-index 受限于它的容器元素。如果有其他元素与 <div class="outer"> 重叠并有更高的 z-index,则它会覆盖住我们的模态框。

vue 中提供了 <Teleport> 来将元素插入到需要的元素上,实际上你也可以用这种方式来使用 ToastMessage.vue 组件。只是需要用 v-if 来控制

第二个问题

如何用 js 直接调用 ToastMessage.vue 组件呢?好像必须要写在 template 里面才行吧?这里其实就要用到一些高级技巧了:

import { createVNode, render } from "vue";
import ToastMessage from "ToastMessage.vue";
    
// 将组件变为 vnode 节点, 这个就是写 jsx 的时候的 h 方法
const vnode = createVNode(ToastMessage, ...);
// 将 vnode 节点挂载到浏览器的 dom 元素上
render(vnode, document.body)

这是关键的俩步,将这俩步封装成一个 Function 就可以四处调用了

注意事项

defineProps

我们平常使用 defineProps 可能是这个样子的:

const props = defineProps<{
  message: string;
  dangerouslyUseHTMLString?: boolean
}>();

基于上面的思路我就想到了把 defineProps 接受的类型单独拿出来变成这样方便处理,结果新的事情发生了:

// type.ts
export interface ToastMessageProps {
  message: string;
  dangerouslyUseHTMLString?: boolean
}
// ToastMessage.vue
import { ToastMessageProps } from "./type"
const props = defineProps<ToastMessageProps>();

这个时候就会出现下面的错误:
image.png

然后我又想到了思路二:

// ToastMessage.vue
export interface ToastMessageProps {
  message: string;
  dangerouslyUseHTMLString?: boolean
}

const props = defineProps<ToastMessageProps>();

这个时候其实类型 ToastMessageProps 对于 ts 来说是识别不到的,因为 .vue 文件在处理的时候是统一处理的。并没有其他类型, 可以看到 env.d.ts

/// <reference types="vite/client" />

declare module "*.vue" {
  import type { DefineComponent } from "vue";
  // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types
  const component: DefineComponent<{}, {}, any>;
  export default component;
}

因此最后的实现方式是:

// type.ts
export const toastMessageProps = {
  message: {
    type: String,
    default: "",
  },
  dangerouslyUseHTMLString: {
    type: Boolean,
    default: false,
  },
};
export type ToastMessageProps = ExtractPropTypes<typeof toastMessageProps>;

// ToastMessage.vue
const props = defineProps(toastMessageProps);

采用了 vue 最原始的定义参数的方式,然后 vue 提供了 ExtractPropTypes 类型来帮你获得需要的类型,也是很方便的。

两个文件间的引用

目前我的文件划分是(也是正确的划分方式):

ToastMessage.vue // 实现 Toast 组件的地方
type.ts // 定义了 ToastMessage 相关的一些类型(props emit defineExpose)
index.ts // 实现非组件化调用 Toast 的地方

之前的文件划分方式:

ToastMessage.vue // 实现 Toast 的地方
index.ts // 实现非组件化调用 Toast 的地方 和 ToastMessage 的相关类型

这种方式就存在了下面的问题

// ToastMessage.vue
import { toastMessageProps } from './index.ts'
const props = defineProps(toastMessageProps);
// index.ts
import ToastMessage from "./ToastMessage.vue"
export const toastMessageProps = { ... };

function Toast( ... ) {
  createVNode(ToastMessage, props);
}

简单而言就是 index.ts 依赖的导入 ToastMessage.vue 文件又依赖了 index.ts 的导出。这个时候就会出现:

image.png

其他逻辑

查看源码

这里教一个小技巧,在 github 的项目界面按: SHIFT + . 可以进入 github.dev 网站,简单来说就是用浏览器端的 vscode 打开你的项目。

总结

这里主要是对于 vue 的 createVNode render 的运用。然后还可以学到 ExtractPropTypes 这个类型处理的方法。当然了我走过的坑小伙伴们就别再走一次了

© 版权声明
THE END
喜欢就支持一下吧
点赞6 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容