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-if
和v-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
是如何创建的,这个其实都可以单独拿出来写一篇文章了。