进来看看 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> ) } }
问题 这里我们发现了几个问题:
一个简单的 Toast 竟然需要定义两个 state,增大了维护业务逻辑的心智,可维护性降低。
需要将 Toast 逻辑和 Dom,甚至是样式,注入到业务代码中,降低业务代码的可读性。
不能同时显示多个 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;
效果:
这里,我们调用了 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> ) } }
执行结果:
从上面的例子我们可以看出,我们可以在 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 组件 ,接下来的一节中,我们将为其添加以下两个功能:
定时隐藏 Toast
强制隐藏 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