进来看看 Toast 组件的特殊实现方法 ~

前言

在业务开发中,特别是移动端的业务,Toast 使用非常频繁,几乎涵盖所有操作结果反馈的交互:如提交表单成功、验证表单失败提示、loading 态提醒…,这种轻量且使用较为频繁的组件,我们要求它使用足够简单,不侵入业务代码,即用即丢,基于这些要求,Toast 组件的实现方式也与其他组件有着不一样的关键点,这也是本篇博客的存在意义。

关键点

使用足够简单

因为使用非常频繁,且要求其随地可用,因此,我们希望只用一行代码:

1
Toast.info('this is a toast', 1000);

无需手动插入组件容器

我们使用其他诸如 antd 组件时,大部分的组件需要注入到业务 Dom 中,例如:

1
2
3
4
5
6
7
8
9
10
render() {
return (
<div>other components...</div>
<Dropdown overlay={menu}>
<a className="ant-dropdown-link" href="#">
Hover me <Icon type="down" />
</a>
</Dropdown>,
)
}

然而因为 Toast 组件无需常驻页面当中,即用即丢,且使用的位置千变万化,假如需要每次都在需要 Toast 的页面当中手动注入组件的话,会非常影响效率和业务代码的可维护性。

多个 Toast 互不影响

业务中往往会存在多个提示同时发出的场景,比如两个接口同时请求失败需要同事提示错误原因,那么 Toast 就要求不能产生冲突。

mini 版 toast

按照我们书写组件的惯性思维,我们实现一版最简单的 toast,只需要满足其基本使用,实现后,我们再分析其存在的问题。

实现

再不考虑上述关键点的情况下,我们书写如下代码来实现最简单粗暴的 Toast:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
class App extends React.Component {

state = {
isToastShow: false, // 是否展示 Toast
toastText: '', // Toast 文字内容
}

// 设置 Toast 属性
handleToastShow = (toastText, showTime) => {
this.setState({
isToastShow: true,
toastText
});
// 定时销毁 Toast Dom
setTimeout(() => {
this.setState({
isToastShow: false
})
}, showTime)
}

// 显示 Toast
handleShowToast = () => {
this.handleToastShow('this is a toast', 3000)
}

render() {
const { isToastShow, toastText } = this.state;
return (
<div>
<button onClick={this.handleShowToast}>show toast</button>
{isToastShow && <div className="toast-wrap">
<div className="toast-mask" />
<div className="toast-text">{toastText}</div>
</div>}
</div>
)
}
}

问题

这里我们发现了几个问题:

  1. 一个简单的 Toast 竟然需要定义两个 state,增大了维护业务逻辑的心智,可维护性降低。
  2. 需要将 Toast 逻辑和 Dom,甚至是样式,注入到业务代码中,降低业务代码的可读性。
  3. 不能同时显示多个 Toast

针对这些问题,接下来我们逐步实现一个使用简单方便的 Toast。

完整版实现

项目源码地址: Vincedream/easy-toast

调用方法

在讲解组件实现前,我们简单地阅览实现后的调用方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import React from 'react';
import Toast from './Toast';

function App () {
const handleClick1 = () => {
Toast.info('test111', 2000);
}

const handleClick2 = () => {
Toast.info('test222', 1000, true);
}

const handleClick3 = () => {
Toast.info('test333', 1000, true);
Toast.info('test long duration', 4000, true);
}

const handleHideAllToast = () => {
Toast.hide();
}

return(
<div>
<button onClick={handleClick1}>no mask Toast</button><br/>
<button onClick={handleClick2}>with mask Toast</button><br/>
<button onClick={handleClick3}>long duration</button><br/>
<button onClick={handleHideAllToast}>hideAllToast</button>
</div>
)
}

export default App;

效果:

image

这里,我们调用了 Toast.info()后,动态地注入组件到 Dom 中,并没有将 Toast 任何逻辑在业务容器中的 Dom 或者 Style 中注入。

动态注入 Dom 关键方法

我们如何在不侵入容器的条件下,动态地注入 Dom,难道是像十年前 jQuery 时代去手动操作 Dom 吗?肯定不是的。这里有个关键的方法:ReactDom.render(<组件/>, 真实 Dom),下面我们看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class App extends React.Component {

handleAddDom = () => {
// 在真实 dom 上创建一个真的的 div 宿主节点,并将其加入到页面根节点 body 当中
const containerDiv = document.createElement('div');
document.body.appendChild(containerDiv);
// 这里返回的是对该组件的引用
const TestCompInstance = ReactDom.render(<TestComp />, containerDiv);
console.log(TestCompInstance);
// 这里可以调用任何 TestCompInstance 上的方法,并且能够访问到其 this
TestCompInstance.sayName();
}

render() {
return (
<div>
<button onClick={this.handleAddDom}>add Dom</button>
</div>
)
}
}

执行结果:

image

从上面的例子我们可以看出,我们可以在 js 逻辑代码中直接创建注入一个 React 组件到真实的 dom 中,并且可以任意操控该组件,理解这点后,我们便得到了编写 Toast 组件最核心的方法。

具体实现

首先,我们创建一个 Toast 容器组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// ToastContainer.js
class ToastContainer extends Component {
state = {
isShowMask: false, // 当前 mask 是否显示
toastList: [] // 当前 Toast item 列表
}

// 将新的 toast push 到 toastContainer 中
pushToast = (toastProps) => {
const { type, text, duration, isShowMask = false } = toastProps;
const { toastList } = this.state;
toastList.push({
id: getUuid(),
type,
text,
duration,
isShowMask
});
this.setState({
toastList,
isShowMask
});
}


render() {
const { toastList, isShowMask } = this.state;
return (
<div className="toast-container">
{isShowMask && <div className="mask"/>}
<div className="toast-wrap">
{toastList.reverse().map((item) => (
<Toast {...item} key={item.id} />
))}
</div>
</div>
);
}
}

这个容器用来存放多个 Toast Item,用来控制 Toast 的显示个数和是否展示 mask,并且将其渲染到容器当中,这里面逻辑非常简单。

接着我们创建真正用来展示的 Toast Item 组件:

1
2
3
4
5
6
7
8
9
10
11
// ToastItem.js
class ToastItem extends Component {
render() {
const { text } = this.props;
return (
<div className="toast-item">
{text}
</div>
);
}
}

两个关键组件已经创建完成,我们需要“动态注入”将其渲染到 dom 中,使用上面讲解的 ReactDom.render() 方法,为此,我们在创建一个 Toast 统一入口文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// index.js
import React from 'react';
import ReactDom from 'react-dom';

import ToastContainer from './ToastContainer';

// 在真实 dom 中创建一个 div 节点,并且注入到 body 根结点中,该节点用来存放下面的 React 组件
const toastContainerDiv = document.createElement('div');
document.body.appendChild(toastContainerDiv);

// 这里返回的是 ToastContainer 组件引用
const getToastContainerRef = () => {
// 将 <ToastContainer /> React 组件,渲染到 toastContainerDiv 中,并且返回了 <ToastContainer /> 的引用
return ReactDom.render(<ToastContainer />, toastContainerDiv);
}

// 这里是 <ToastContainer /> 的引用
let toastContainer = getToastContainerRef();


export default {
info: (text, duration, isShowMask) => (toastContainer.pushToast({type: 'info', text, duration, isShowMask})),
};

这里,我们按照上面讲解的 ReactDom.render() 方法, 将 <ToastContainer /> 渲染到 dom 中,并且获得了其引用,我们只需要在这里调用 <ToastContainer /> 中的 pushToast 方法,便能展示出 Toast 提示。

到这里,我们便完成了一个最简化版的 动态注入版 Toast 组件,接下来的一节中,我们将为其添加以下两个功能:

  1. 定时隐藏 Toast
  2. 强制隐藏 Toast

完善功能

定时隐藏 Toast

首先我们改造 ToastContainer 容器组件,添加一个隐藏 mask 方法,并将其传入到 <ToastItem /> 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class ToastContainer extends Component {
...

// 将被销毁的 toast 剔除
popToast = (id, isShowMask) => {
const { toastList } = this.state;
const newList = toastList.filter(item => item.id !== id);
this.setState({
toastList: newList,
});
// 该 toast item 是否为 toastList 中 duration 最长的 item
let isTheMaxDuration = true;
// 该 toast item 的 duration
const targetDuration = toastList.find(item => item.id === id).duration;
// 遍历 toastList 检查是否为最长 duration
toastList.forEach(item => {
if (item.isShowMask && item.duration > targetDuration) {
isTheMaxDuration = false
}
return null;
});

// 隐藏 mask
if (isShowMask && isTheMaxDuration) {
this.setState({
isShowMask: false
})
}
}

render() {
...
<ToastItem onClose={this.popToast} {...item} key={item.id} />
...
}
}

接着,我们改造 <ToastItem />,在起 componentDidMount 中设置一个定时器,根据传入的 duration 参数,设置隐藏 Toast 的定时器,并且在组件销毁前,将定时器清除。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// ToastItem.js
class ToastItem extends Component {
componentDidMount() {
const { id, duration, onClose, isShowMask } = this.props;
this.timer = setTimeout(() => {
if (onClose) {
onClose(id, isShowMask);
}
}, duration)
}
// 卸载组件后,清除定时器
componentWillUnmount() {
clearTimeout(this.timer)
}
render() {
...
}
}

这里我们便完成了隐藏 Toast 的功能,其细节在代码中有详细的解释,这里不再做赘述。

强制隐藏 Toast

如何强制的隐藏已经出现的 Toast 呢?这里我们依旧使用到 ReactDom 的 api:ReactDom.unmountComponentAtNode(container),这个方法的作用是从 Dom 中卸载组件,会将其事件处理器(event handlers)和 state 一并清除:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// index.js
...
// 这里返回的是 ToastContainer 组件引用
const getToastContainerRef = () => {
// 将 <ToastContainer /> React 组件,渲染到 toastContainerDiv 中,并且返回了 <ToastContainer /> 的引用
return ReactDom.render(<ToastContainer />, toastContainerDiv);
}
// 这里是 <ToastContainer /> 的引用
let toastContainer = getToastContainerRef();
const destroy = () => {
// 将 <ToastContainer /> 组件 unMount,卸载组件
ReactDom.unmountComponentAtNode(toastContainerDiv);
// 再次创建新的 <ToastContainer /> 引用,以便再次触发 Toast
toastContainer = getToastContainerRef();
}


export default {
...
hide: destroy
};

需要注意的是,卸载 <ToastContainer /> 后,需要再次创建一个新的、空的 <ToastContainer /> 组件,以便后续再次调用 Toast。

总结

本篇文章我们用了新的一种方法来创建一个特殊的 React 组件,实践了一些你或许没有使用过的 ReactDom 方法,除了 Toast 组件,我们还能用同样的思路编写其他的组件,如 Modal、Notification 等组件。

参考:

ReactDOM

项目源码地址: Vincedream/easy-toast