全国免费咨询:

13245491521

VR图标白色 VR图标黑色
X

中高端软件定制开发服务商

与我们取得联系

13245491521     13245491521

2024-04-13_「转」手写一个 OnBoarding 组件

您的位置:首页 >> 新闻 >> 行业资讯

手写一个 OnBoarding 组件 点击关注公众号,“技术干货”及时达!当应用加了新功能的时候,都会通过这种方式来告诉用户怎么用: 这种组件叫做 OnBoarding 或者 Tour。 在 antd5 也加入了这种组件: 那它是怎么实现的呢? 调试下可以发现,遮罩层由 4 个 react 元素组成。 当点击上一步、下一步的时候,遮罩层的宽高会变化: 加上 transition,就产生了上面的动画效果。 其实还可以进一步简化一下: 用一个 div,设置 width、height 还有上下左右不同的 border-width。 点击上一步、下一步的时候,修改 width、height、border-width,也能达到一样的效果。 比起 antd 用 4 个 rect 来实现,更简洁一些。 原理就是这样,还是挺简单的。 下面我们来写一下: npxcreate-vite 创建个 vite + react 的项目。 进入项目,把 index.css 的样式去掉: 然后新建 OnBoarding/Mask.tsx importReact,{CSSProperties,useEffect,useState}from'react'; import{getMaskStyle}from'./getMaskStyle' interfaceMaskProps{ element:HTMLElement; container?:HTMLElement; renderMaskContent?:(wrapper:React.ReactNode)=React.ReactNode; } exportconstMask:React.FCMaskProps=(props)={ const{ element, renderMaskContent, container }=props; const[style,setStyle]=useStateCSSProperties useEffect(()={ if(!element){ return; } element.scrollIntoView({ block:'center', inline:'center' conststyle=getMaskStyle(element,container||document.documentElement); setStyle(style); },[element,container]); constgetContent=()={ if(!renderMaskContent){ returnnull; } returnrenderMaskContent( divclassName={'mask-content'}style={{width:'100%',height:'100%'}}/ return( div style={style} className='mask' {getContent()} /div }; 这里传入的 element、container 分别是目标元素、遮罩层所在的容器。 而 getMaskContent 是用来定制这部分内容的: 可以是 Popover 也可以是别的。 前面分析过,主要是确定目标元素的 width、height、border-width。 首先,把目标元素滚动到可视区域: 这个用 scrollIntoView 方法实现: 在 MDN 上可以看到它的介绍: 设置 block、inline 为 center 是把元素中心滚动到可视区域中心的意思: 滚动完成后,就可以拿到元素的位置,计算 width、height、border-width 的样式了: 新建 OnBoarding/getMaskStyle.ts exportconstgetMaskStyle=(element:HTMLElement,container:HTMLElement)={ if(!element){ return } const{height,width,left,top}=element.getBoundingClientRect(); constelementTopWithScroll=container.scrollTop+ constelementLeftWithScroll=container.scrollLeft+left; return{ width:container.scrollWidth, height:container.scrollHeight, borderTopWidth:Math.max(elementTopWithScroll,0), borderLeftWidth:Math.max(elementLeftWithScroll,0), borderBottomWidth:Math.max(container.scrollHeight-height-elementTopWithScroll,0), borderRightWidth:Math.max(container.scrollWidth-width-elementLeftWithScroll,0) }; width、height 就是容器的包含滚动区域的宽高。 然后 border-width 分为上下左右 4 个方向: top 和 left 的分别用 scrollTop、scrollLeft 和元素在可视区域里的 left、top 相加计算出来。 bottom 和 right 的就用容器的包含滚动区域的高度宽度 scrollHeight、scrollWidth 减去 height、width 再减去 scrollTop、scrollLeft 计算出来。 然后我们在内部又加了一个宽高为 100% 的 div,把它暴露出去,外部就可以用它来加 Popover 或者其他内容: 然后在 OnBoarding/index.scss 里写下样式: .mask{ position:absolute; left:0; top:0; z-index:999; border-style:solid; box-sizing:border-box; border-color:rgba(0,0,0,0.6); transition:all0.2sease-in-out; } mask 要绝对定位,然后设置下 border 的颜色。 我们先测试下现在的 Mark 组件: 把开发服务跑起来: npminstall npmrundev 我们就在 logo 上试一下吧: Mask element={document.getElementById('xxx')!} renderMaskContent={(wrapper)={ returnwrapper }} /Mask container 就是默认的根元素。 内容我们先不加 Popover。 看一下效果: 没啥问题。 然后加上 Popover 试试。 安装 antd: npm install --save antd 然后引入下: Mask element={document.getElementById('xxx')!} renderMaskContent={(wrapper)={ returnPopover content={ divstyle={{width:300}} phello/p Buttontype='primary'下一步/Button /div } open={true} {wrapper}/Popover }} /Mask 没啥问题。 接下来在外面包装一层,改下 Popover 的样式就行了。 我们希望 OnBoarding 组件可以这么用: 传入 steps,包含每一步在哪个元素(selector),显示什么内容(renderConent),在什么方位(placement)。 所以类型这样写: 并且还有 beforeForward、beforeBack 也就是点上一步、下一步的回调。 step 是可以直接指定显示第几步。 onStepsEnd 是在全部完成后的回调。 内部有一个 state 来记录 currentStep,点击上一步、下一步会切换: 在切换前也会调用 beforeBack、beforeForward 的回调。 然后准备下 Popover 的内容: 渲染下: 这里用 createPortal 把 mask 渲染到容器元素下,比如 document.body。 注意,我们要给元素加上引导,那得元素渲染完才行。 所以这里加个 setState,在 useEffect 里执行。 效果就是在 dom 渲染完之后,触发重新渲染,从而渲染这个 OnBoarding 组件: 第一次渲染的时候,元素是 null,触发重新渲染之后,就会渲染下面的 Mask 了: Onboarding/index.tsx 的全部代码如下: importReact,{FC,useEffect,useState}from'react'; import{createPortal}from'react-dom'; import{Button,Popover}from'antd'; import{Mask}from'./Mask' import{TooltipPlacement}from'antd/es/tooltip'; import'./index.scss'; exportinterfaceOnBoardingStepConfig{ selector:()=HTMLElement|null; placement?:TooltipPlacement; renderContent?:(currentStep:number)=React.ReactNode; beforeForward?:(currentStep:number)=void; beforeBack?:(currentStep:number)=void; } exportinterfaceOnBoardingProps{ step?:number; steps:OnBoardingStepConfig[]; getContainer?:()=HTMLElement; onStepsEnd?:()=void; } exportconstOnBoarding:FCOnBoardingProps=(props)={ const{ step=0, steps, onStepsEnd, getContainer }=props; const[currentStep,setCurrentStep]=useStatenumber(0); constcurrentSelectedElement=steps[currentStep]?.selector(); constcurrentContainerElement=getContainer?.()||document.documentElement; constgetCurrentStep=()={ returnsteps[currentStep]; constback=async()={ if(currentStep===0){ return; } const{beforeBack}=getCurrentStep(); awaitbeforeBack?.(currentStep); setCurrentStep(currentStep-1); constforward=async()={ if(currentStep===steps.length-1){ awaitonStepsEnd?.(); return; } const{beforeForward}=getCurrentStep(); awaitbeforeForward?.(currentStep); setCurrentStep(currentStep+1); useEffect(()={ setCurrentStep(step!); },[step]); constrenderPopover=(wrapper:React.ReactNode)={ constconfig=getCurrentStep(); if(!config){ returnwrapper; } const{renderContent}=config; constcontent=renderContent?renderContent(currentStep):null; constoperation=( divclassName={'onboarding-operation'} { currentStep!==0 Button className={'back'} onClick={()=back()} {'上一步'} /Button } Button className={'forward'} type={'primary'} onClick={()=forward()} {currentStep===steps.length-1?'我知道了':'下一步'} /Button /div return( Popover content={div {content} {operation} /div} open={true} placement={getCurrentStep()?.placement} {wrapper} /Popover const[,setRenderTick]=useStatenumber(0); useEffect(()={ setRenderTick(1) }, if(!currentSelectedElement){ returnnull; } constmask=Mask container={currentContainerElement} element={currentSelectedElement} renderMaskContent={(wrapper)=renderPopover(wrapper)} /; returncreatePortal(mask,currentContainerElement); } 其实这个组件主要就是切换上一步下一步用的。 然后加下上一步下一步按钮的样式: .onboarding-operation{ width:100%; display:flex; justify-content:flex-end; margin-top:12px; .back{ margin-right:12px; min-width:80px; } .forward{ min-width:80px; } } 在 App.tsx 里测试下: import{OnBoarding}from'./OnBoarding' import{Button,Flex}from'antd'; functionApp(){ returndivclassName='App' Flexgap="small"wrap="wrap"id="btn-group1" Buttontype="primary"PrimaryButton/Button ButtonDefaultButton/Button Buttontype="dashed"DashedButton/Button Buttontype="text"TextButton/Button Buttontype="link"LinkButton/Button /Flex divstyle={{height:'1000px'}}/div Flexwrap="wrap"gap="small" Buttontype="primary"danger Primary /Button ButtondangerDefault/Button Buttontype="dashed"dangerid="btn-group2" Dashed /Button Buttontype="text"danger Text /Button Buttontype="link"danger Link /Button /Flex divstyle={{height:'500px'}}/div Flexwrap="wrap"gap="small" Buttontype="primary"ghost Primary /Button ButtonghostDefault/Button Buttontype="dashed"ghost Dashed /Button Buttontype="primary"dangerghostid="btn-group3" Danger /Button /Flex OnBoarding steps={ [ { selector:()={ returndocument.getElementById('btn-group1'); }, renderContent:()={ return"神说要有光"; }, placement:'bottom' }, { selector:()={ returndocument.getElementById('btn-group2'); }, renderContent:()={ return"于是就有了光"; }, placement:'bottom' }, { selector:()={ returndocument.getElementById('btn-group3'); }, renderContent:()={ return"你相信光么"; }, placement:'bottom' } ] }/ /div } exportdefaultApp 我用 id 选中了三个元素: 指定三步的元素和渲染的内容: 跑一下: 没啥问题,选中的元素、mask 的样式都是对的。 只是现在结束后,mask 不会消失: 这个加个状态标识就好了: 此外,还有两个小问题: 一个是在窗口改变大小的时候,没有重新计算 mask 样式: 这个在 Mask 组件里用 ResizeObserver 监听下 container 大小改变就好了: useEffect(()={ constobserver=newResizeObserver(()={ conststyle=getMaskStyle(element,container||document.documentElement); setStyle(style); observer.observe(container||document.documentElement); }, 变了重新计算和设置 mask 的 style。 再就是现在 popover 位置会闪一下: 那是因为 mask 的样式变化有个动画的过程,要等动画结束计算的 style 才准确。 所以给 Mask 组件加一个动画开始和结束的回调: importReact,{CSSProperties,useEffect,useState}from'react'; import{getMaskStyle}from'./getMaskStyle' import'./index.scss'; interfaceMaskProps{ element:HTMLElement; container?:HTMLElement; renderMaskContent?:(wrapper:React.ReactNode)=React.ReactNode; onAnimationStart?:()=void; onAnimationEnd?:()=void; } exportconstMask:React.FCMaskProps=(props)={ const{ element, renderMaskContent, container, onAnimationStart, onAnimationEnd }=props; useEffect(()={ onAnimationStart?.(); consttimer=setTimeout(()={ onAnimationEnd?.(); },200); return()={ window.clearTimeout(timer); },[element]); const[style,setStyle]=useStateCSSProperties useEffect(()={ constobserver=newResizeObserver(()={ conststyle=getMaskStyle(element,container||document.documentElement); setStyle(style); observer.observe(container||document.documentElement); }, useEffect(()={ if(!element){ return; } element.scrollIntoView({ block:'center', inline:'center' conststyle=getMaskStyle(element,container||document.documentElement); setStyle(style); },[element,container]); constgetContent=()={ if(!renderMaskContent){ returnnull; } returnrenderMaskContent( divclassName={'mask-content'}style={{width:'100%',height:'100%'}}/ return( div style={style} className='mask' {getContent()} /div }; 然后在 OnBoarding 组件加一个 state: 动画开始和结束修改这个 state: 动画结束才会渲染 Popover: 这样 Popover 位置就不会闪了: importReact,{FC,useEffect,useState}from'react'; import{createPortal}from'react-dom'; import{Button,Popover}from'antd'; import{Mask}from'./Mask' import{TooltipPlacement}from'antd/es/tooltip'; exportinterfaceOnBoardingStepConfig{ selector:()=HTMLElement|null; placement?:TooltipPlacement; renderContent?:(currentStep:number)=React.ReactNode; beforeForward?:(currentStep:number)=void; beforeBack?:(currentStep:number)=void; } exportinterfaceOnBoardingProps{ step?:number; steps:OnBoardingStepConfig[]; getContainer?:()=HTMLElement; onStepsEnd?:()=void; } exportconstOnBoarding:FCOnBoardingProps=(props)={ const{ step=0, steps, onStepsEnd, getContainer }=props; const[currentStep,setCurrentStep]=useStatenumber(0); constcurrentSelectedElement=steps[currentStep]?.selector(); constcurrentContainerElement=getContainer?.()||document.documentElement; const[done,setDone]=useState(false); const[isMaskMoving,setIsMaskMoving]=useStateboolean(false); constgetCurrentStep=()={ returnsteps[currentStep]; constback=async()={ if(currentStep===0){ return; } const{beforeBack}=getCurrentStep(); awaitbeforeBack?.(currentStep); setCurrentStep(currentStep-1); constforward=async()={ if(currentStep===steps.length-1){ awaitonStepsEnd?.(); setDone(true); return; } const{beforeForward}=getCurrentStep(); awaitbeforeForward?.(currentStep); setCurrentStep(currentStep+1); useEffect(()={ setCurrentStep(step!); },[step]); constrenderPopover=(wrapper:React.ReactNode)={ constconfig=getCurrentStep(); if(!config){ returnwrapper; } const{renderContent}=config; constcontent=renderContent?renderContent(currentStep):null; constoperation=( divclassName={'onboarding-operation'} { currentStep!==0 Button className={'back'} onClick={()=back()} {'上一步'} /Button } Button className={'forward'} type={'primary'} onClick={()=forward()} {currentStep===steps.length-1?'我知道了':'下一步'} /Button /div return( isMaskMoving?wrapper:Popover content={div {content} {operation} /div} open={true} placement={getCurrentStep()?.placement} {wrapper} /Popover const[,setRenderTick]=useStatenumber(0); useEffect(()={ setRenderTick(1) }, if(!currentSelectedElement||done){ returnnull; } constmask=Mask onAnimationStart={()={ setIsMaskMoving(true); }} onAnimationEnd={()={ setIsMaskMoving(false); }} container={currentContainerElement} element={currentSelectedElement} renderMaskContent={(wrapper)=renderPopover(wrapper)} /; returncreatePortal(mask,currentContainerElement); } 案例代码上传了 react 小册仓库:https://github.com/QuarkGluonPlasma/react-course-code/tree/main/onboarding-component 总结今天我们实现了 OnBoarding 组件,就是 antd5 里加的 Tour 组件。 antd 里是用 4 个 rect 元素实现的,我们是用一个 div 设置 width、height、四个方向不同的 border-width 实现的。 通过设置 transition,然后改变 width、height、border-width 就可以实现 mask 移动的动画。 然后我们在外层封装了一层,加上了上一步下一步的切换。 并且用 ResizeObserver 在窗口改变的时候重新计算 mask 样式。 此外,还要注意,mask 需要在 dom 树渲染完之后才能拿到 dom 来计算样式,所以需要 useEffect + setState 来触发一次额外渲染。 这样,OnBoarding 组件就完成了。 ?更多内容可以看我的小册《React 通关秘籍》 ?点击关注公众号,“技术干货”及时达! 阅读原文

上一篇:2020-07-17_乘风破浪的博士:2019 ACM博士论文奖公布,清华姚班毕业生、MIT学霸吴佳俊获荣誉提名 下一篇:2021-09-07_「转」“副业”正在成为年轻人的新刚需

TAG标签:

17
网站开发网络凭借多年的网站建设经验,坚持以“帮助中小企业实现网络营销化”为宗旨,累计为4000多家客户提供品质建站服务,得到了客户的一致好评。如果您有网站建设网站改版域名注册主机空间手机网站建设网站备案等方面的需求...
请立即点击咨询我们或拨打咨询热线:13245491521 13245491521 ,我们会详细为你一一解答你心中的疑难。
项目经理在线

相关阅读 更多>>

猜您喜欢更多>>

我们已经准备好了,你呢?
2022我们与您携手共赢,为您的企业营销保驾护航!

不达标就退款

高性价比建站

免费网站代备案

1对1原创设计服务

7×24小时售后支持

 

全国免费咨询:

13245491521

业务咨询:13245491521 / 13245491521

节假值班:13245491521()

联系地址:

Copyright © 2019-2025      ICP备案:沪ICP备19027192号-6 法律顾问:律师XXX支持

在线
客服

技术在线服务时间:9:00-20:00

在网站开发,您对接的直接是技术员,而非客服传话!

电话
咨询

13245491521
7*24小时客服热线

13245491521
项目经理手机

微信
咨询

加微信获取报价