Vue3如何实现支持API式调用的Toast组件

Toast组件大家都在用,以vue3的vant@4框架为例,最简单的用法是这样的:showToast('message')。组件大家都会封装,但是可能很多人并不清楚像Toast这种支持API调用式的组件是如何实现的。为此我去读了vant的源码,大致弄懂了实现思路。惊叹其源码很多巧思之余,我也借鉴了部分源码,一步步实现了一个支持API式调用的简单的组件。本文将分享这个过程,文中代码会基于typescript和vue3的<script setup>语法,每一步都都会提供代码的在线演示。

实现组件

首先VanToast组件也是支持组件式使用的,所以我们先来实现组件,这部分很简单。

首先使用vue3.3+的APIdefineOptions()声明组件的name。

ts 复制代码
defineOptions({
  name: 'my-toast',
})

然后我们声明组件的props,目前我们只需要show,message,duration三个属性,分别用来控制是否显示、显示的文本,以及显示的持续时间。

ts 复制代码
withDefaults(
  defineProps<{
    duration?: number
    show?: boolean
    message?: string
  }>(),
  {
    duration: 2000,
    show: false,
    message: '',
  }
)

三个props中的show我希望父组件可以通过v-model:show的方式来使用,我们可以使用vue3.4+的APIdefineModel()来便捷的实现这一点。

ts 复制代码
const localShow = defineModel<boolean>('show', { default: false })

最后把DOM结构和样式补充完整。

html 复制代码
<div
  v-if="localShow"
  class="my-toast"
>
  <div class="my-toast__text">{{ message }}</div>
</div>
css 复制代码
.my-toast {
  position: fixed;
  max-height: 100%;
  top: 50%;
  left: 0;
  right: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  box-sizing: content-box;
  transition: all 0.2s;
  width: -webkit-fit-content;
  width: fit-content;
  min-width: 96px;
  min-height: 0;
  max-width: 70%;
  padding: 8px 12px;
  color: #fff;
  font-size: 14px;
  line-height: 20px;
  white-space: pre-wrap;
  word-break: break-all;
  text-align: center;
  background: rgba(0, 0, 0, 0.7);
  border-radius: 8px;
  transition: transform 0.3s;
  -webkit-overflow-scrolling: touch;
  overflow-y: auto;
  margin: 0 auto;
  transform: translateY(-50%);
}

在父组件中使用这个组件:

vue 复制代码
<template>
  <MyToast v-model:show="show1" message="hello" />
  <button @click="switchShow1">组件式调用</button>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import MyToast from './MyToast.vue'

const show1 = ref(false)
const switchShow1 = () => show1.value = !show1.value
</script>

现在就可以通过点击按钮控制MyToast组件的显示隐藏了。戳我查看效果

实现和props同名的slots

查看vant文档可以发现Toast作为组件使用时除了支持message这个prop外,还支持同名的slot,而且slot的优先级要高于prop。也就是说下面的代码会显示bar而非foo,我们要怎么实现这个功能呢?

html 复制代码
<vant-toast message="foo">
  <template #message>bar</template>
</van-toast>

首先,我们使用vue3.3+的APIdefineSlots()来声明message这个slot,这样TS会约束我们在模板中只能使用message这个slot,视图使用其它名称的slot时vscode会提示类型错误。同时还能约束作用域插槽中prop的类型,只不过这里我们用不到作用域插槽。

ts 复制代码
defineSlots<{
  message(props: any): any
}>()

那么如何实现slot和prop同时生效,而且slot的优先级高于prop呢?我们来看看vant源码中是如何实现的:

ts 复制代码
const renderMessage = () => {
  const { type, message } = props;

  if (slots.message) {
    return <div class={bem('text')}>{slots.message()}</div>;
  }

  if (isDef(message) && message !== '') {
    return type === 'html' ? (
      <div key={0} class={bem('text')} innerHTML={String(message)} />
    ) : (
      <div class={bem('text')}>{message}</div>
    );
  }
};

vant的源码使用了tsx文件,先判断如果slots.message存在则渲染slots.message(),不存在则使用props渲染。tsx非常适合在组件库中使用,优点是特别灵活,复用性强。但其实用单文件组件也能实现同样的功能:

html 复制代码
<div
  v-if="localShow"
  class="my-toast"
>
  <div v-if="$slots.message" class="my-toast__text">
    <slot name="message"></slot>
  </div>
  <div v-else class="my-toast__text">{{ message }}</div>
</div>

在模板中尝试一下:

xml 复制代码
<MyToast v-model:show="show1" message="foo">
  <template #message>bar</template>
</MyToast>
<button @click="switchShow1">组件式调用</button>

这个时候点击按钮显示的将会是bar而非foo戳我查看效果

实现lazyRender

仔细观察一下VanToast显示隐藏前后的DOM变化会发现,VanToast第一次显示时,DOM会被插入文档中,当VanToast隐藏后,DOM并不会被移除,而是使用display:none来隐藏了这个DOM元素。这样在初始化时,文档中不会有不必要的DOM结构存在,而后续的显隐是通过样式控制的,避免了DOM元素的创建和销毁,是一种比较高效的做法。

而我目前是使用v-if="localShow"来实现的,这在初始时确实不会在文档中插入DOM,但后续每次显隐都需要经历创建 > 销毁的过程。那如果改用v-show="localShow"呢?其实也不行,因为这样会在初始时就在文档中插入DOM,还是与VanToast的实现不符。那么vant源码中是如何解决这个问题的呢?

VanToast组件内部是引用了Popup组件的,答案就在Popup.tsx文件内,它使用了lazyRender,而lazyRender又是来自于它内部实现的一个composables use-lazy-render,代码就不贴了,感兴趣的可以点进去看一看。

其实思路也很简单,就是维护一个变量初始为inited = ref(false),然后监听组件显示了以后,就把其设为true:

ts 复制代码
const useLazyRender = () => {
  let inited = ref(false)
  watch(
    localShow,
    (value) => {
      if (value) {
        inited.value = true
      }
    },
    { immediate: true }
  )
  return {
    inited
  }
}
const { inited } = useLazyRender()

在模板中用v-show替换v-if,然后再外面包裹一层v-if="inited",说白了就是v-ifv-show结合使用:

html 复制代码
<template v-if="inited">
  <div
    v-show="localShow"
    class="my-toast"
  >
    <div v-if="$slots.message" class="my-toast__text">
      <slot name="message"></slot>
    </div>
    <div v-else class="my-toast__text">{{ message }}</div>
  </div>
</template>

这样我们就实现了组件的lazyRender,戳我查看效果

加上过渡动画

目前组件的显示隐藏还是有点过于生硬了,一般都会在显示隐藏时加上淡入淡出的过渡效果,好在vue提供了<Transition>组件可以实现这一点,在现在的组件template外包裹一层<Transition>组件,并命名my-fade

html 复制代码
<Transition name="my-fade">
  <template v-if="inited">
    <div
      v-show="localShow"
      class="my-toast"
    >
      <div v-if="$slots.message" class="my-toast__text">
        <slot name="message"></slot>
      </div>
      <div v-else class="my-toast__text">{{ message }}</div>
    </div>
  </template>
</Transition>

然后再补充上过渡的样式。

css 复制代码
@keyframes my-fade-in {
  from {
    opacity: 0;
  }

  to {
    opacity: 1;
  }
}
@keyframes my-fade-out {
  from {
    opacity: 1;
  }

  to {
    opacity: 0;
  }
}
.my-fade-enter-active {
  animation: .3s my-fade-in both ease-out;
}
.my-fade-leave-active {
  animation: .3s my-fade-out both ease-in;
}

现在组件的显示隐藏就有淡入淡出的过渡效果了,戳我查看效果

自动隐藏

目前我们实现的组件还不会在duration时间到了后自动隐藏,要实现这一点也很简单,监听props的变更使用定时器实现即可:

ts 复制代码
let timer: ReturnType<typeof setTimeout>
watch(
  () => [props.show, props.duration, props.message],
  () => {
    clearTimeout(timer)
    if (localShow.value && props.duration > 0) {
      timer = setTimeout(() => {
        localShow.value = false
      }, props.duration)
    }
  }
)

戳我查看效果

实现API式调用

组件实现完成后,我们终于来到重头戏,看看如何实现API式调用组件。

showToast的源码可以了解到,实现API式调用Toast组件的关键,是使用了vue的APIcreateApp()来实例化Toast组件,然后把这个组件mount()到一个div上,再把这个div插入到文档中就完成了。源码的实现比较复杂,要考虑很多细节,我们先从简单处开始。

我们日常使用showToast有两种用法,一种是直接传入一个字符串showToast('message'),另一种是传入一个配置参数showToast({}),所以这个函数的签名我们可以像下面这样设计,返回值是Toast实例。

ts 复制代码
declare function showToast(options: string | ToastOptions): ToastInstance

新建一个index.ts文件,声明默认参数值,并使用参数归一化 把两种不同类型的参数转化为同一类型,然后按上面说的思路使用createApp()实例化组件,mount()后插入页面中。

ts 复制代码
import { createApp } from 'vue'
import MyToast from './MyToast.vue'

export type ToastOptions = {
  duration?: number
  show?: boolean
  message?: string
}

const defaultOptions: ToastOptions = {
  duration: 2000,
  show: false,
  message: '',
}

export function showToast(options: string | ToastOptions = {}) {
  const parsedOptions: ToastOptions =
    typeof options === 'string' ? { message: options } : options
  const toastOptions = Object.assign({}, defaultOptions, parsedOptions, {
    show: true
  })
  const root = document.createElement('div')
  const instance = createApp(MyToast, toastOptions).mount(root)
  document.body.appendChild(root)
  return instance
}

在父组件中调用一下:

html 复制代码
<button @click="showToast('hello')">API式调用</button>

点击按钮后确实显示了hello,戳我查看效果

解决无法自动隐藏的问题

上面的代码虽然初步实现了API式调用,但还是有一些问题,比如现在点击按钮显示文本后,却无法在duration时间到后自动隐藏。这是为什么呢,明明我们在实现组件式使用方式的时候已经解决了这个问题的啊。

我们来回顾一下当时实现的代码,如果我们在watch回调中加上一行log语句,会发现我们在点击按钮后,log并没有打印。这是为什么呢?

diff 复制代码
let timer: ReturnType<typeof setTimeout>
watch(
  () => [props.show, props.duration, props.message],
  () => {
+   console.log("🚀 ~ watch")
    clearTimeout(timer)
    if (localShow.value && props.duration > 0) {
      timer = setTimeout(() => {
        localShow.value = false
      }, props.duration)
    }
  }
)

原因在于下面这行高亮的代码:

diff 复制代码
export function showToast(options: string | ToastOptions = {}) {
  const parsedOptions: ToastOptions =
    typeof options === 'string' ? { message: options } : options
  const toastOptions = Object.assign({}, defaultOptions, parsedOptions, {
+   show: true
  })
  const root = document.createElement('div')
  const instance = createApp(MyToast, toastOptions).mount(root)
  document.body.appendChild(root)
  return instance
}

我们在createApp()实例化组件时传递的参数中show就是true,参数并没有变化过,自然就监听不到变化,执行定时器隐藏的逻辑了。所以我们需要先使用响应式数据state = reactive({show: false})来实例化组件,然后再设置state.show = true,这样就正常了。

ts 复制代码
import { createApp, Component, ComponentPublicInstance, reactive, h, getCurrentInstance } from 'vue'

function mountComponent(RootComponent: Component) {
  const app = createApp(RootComponent)
  const root = document.createElement('div')

  document.body.appendChild(root)

  return {
    instance: app.mount(root),
    unmount() {
      app.unmount()
      document.body.removeChild(root)
    },
  }
}

export type ToastWrapperInstance = ComponentPublicInstance<
  { message: string },
  {
    close: () => void
    open: (props: Record<string, any>) => void
  }
>

function createInstance() {
  const { instance } = mountComponent({
    setup() {
      const state = reactive({
        show: false,
      })
      const open = (props: Record<string, any>) => {
        // open方法把传入的props合并到state中
        Object.assign(state, props)
        state.show = true
      }
      const close = () => {
        state.show = false
      }
      const instance = getCurrentInstance()
      if (instance) {
        // 给实例添加open和close方法
        Object.assign(instance.proxy as object, {
          open,
          close,
        })
      }
      const render = () => {
        return h(MyToast, {
          ...state,
          'onUpdate:show': (val: boolean) => {
            state.show = val
          },
        })
      }
      // 重写实例的render函数
      ;(getCurrentInstance() as any).render = render
      return {
        open,
        close,
      }
    },
  })
  return instance as ToastWrapperInstance
}

export function showToast(options: string | ToastOptions = {}) {
  const parsedOptions: ToastOptions =
    typeof options === 'string' ? { message: options } : options
  const toastOptions = Object.assign({}, defaultOptions, parsedOptions)
  const instance = createInstance()
  instance.open(toastOptions)
  return instance
}

上面这一步的跨度比较大,新增了比较多的代码,主要是使用h()函数重写了实例的render函数,render函数里在组件上绑定响应式的数据state,然后调用instance.open()来设置响应式数据state.show = true'onUpdate:show': (val: boolean) => { state.show = val }是为了实现双向绑定。

点下按钮试试,已经可以实现自动隐藏了,戳我查看效果

实现单例模式

目前实现的功能其实还有一个问题:如果连续多次showToast(),会生成多个实例插入DOM中,表现形式上就是后触发的Toast会叠加在前面的Toast上显示。而VanToast默认是单例模式的,也就是连续多次showToast(),只会生成同一个实例插入DOM中,如果多次的message不同,只是替换message显示。

我们需要维护一个Toast实例的队列:

ts 复制代码
const queue: ToastWrapperInstance[] = []

然后声明一个getInstance函数用来获取实例,初始队列为空,会创建一个实例,push到队列中维护,后续总是会返回队列中最后一个实例,在单例模式下,也就是初始创建的实例,这样就实现了单例模式。

ts 复制代码
function getInstance() {
  if (!queue.length) {
    const instance = createInstance()
    queue.push(instance)
  }
  return queue[queue.length - 1]
}

你可能会问,既然是单例模式,为什么还要用队列来维护呢?这是因为VanToast提供了allowMultipleToast()API来让用户设置是否关闭单例模式,如果关闭单例模式,就需要用队列了。

有了getInstance函数后,把showToast里的createInstance()getInstance()替换即可。

diff 复制代码
export function showToast(options: string | ToastOptions = {}) {
  const parsedOptions: ToastOptions =
    typeof options === 'string' ? { message: options } : options
  const toastOptions = Object.assign({}, defaultOptions, parsedOptions)
- const instance = createInstance()
+ const instance = getInstance()
  instance.open(toastOptions)
  return instance
}

在父组件中我们让按钮每次点击都显示一个随机数:

html 复制代码
<button @click="showToast(Math.random() + '')">API式调用</button>

点击试一下,确实实现了单例模式,戳我查看效果

总结

至此,一个支持通过组件式使用、也支持API式调用的Toast组件就封装完成了。限于文章篇幅,上面的代码算是实现了一个MVP(最小化可行产品),vant官方的Toast组件支持的功能要丰富许多许多,代码量自然也是不少的。而阅读vant的源码也让我学到了很多,比如文中提到的lazyRender,比如bem是如何创建的,这个其实都可以单独拿出来写一篇文章了。

相关推荐
一个处女座的程序猿O(∩_∩)O2 小时前
小型 Vue 项目,该不该用 Pinia 、Vuex呢?
前端·javascript·vue.js
燃先生._.8 小时前
Day-03 Vue(生命周期、生命周期钩子八个函数、工程化开发和脚手架、组件化开发、根组件、局部注册和全局注册的步骤)
前端·javascript·vue.js
2401_8576009511 小时前
SSM 与 Vue 共筑电脑测评系统:精准洞察电脑世界
前端·javascript·vue.js
2401_8576009511 小时前
数字时代的医疗挂号变革:SSM+Vue 系统设计与实现之道
前端·javascript·vue.js
GDAL11 小时前
vue入门教程:组件透传 Attributes
前端·javascript·vue.js
轻口味11 小时前
Vue.js 核心概念:模板、指令、数据绑定
vue.js
2402_8575834911 小时前
基于 SSM 框架的 Vue 电脑测评系统:照亮电脑品质之路
前端·javascript·vue.js
java_heartLake12 小时前
Vue3之性能优化
javascript·vue.js·性能优化
ddd君3177413 小时前
组件的声明、创建、渲染
vue.js
前端没钱14 小时前
从 Vue 迈向 React:平滑过渡与关键注意点全解析
前端·vue.js·react.js