本组件已开源,源码可见:github.com/bytedance/g…
组件背景不管是老用户还是新用户,在产品发布新版本、有新功能上线、或是现有功能更新的场景下,都需要一定的指导。功能引导组件就是互联网产品中的指示牌,它旨在带领用户参观产品,帮助用户熟悉新的界面、交互与功能。与FAQs、产品介绍视频、使用手册、以及UI组件帮助信息不同的是,功能引导组件与产品UI融合为一体,不会给用户割裂的交互感受,并且不需要用户主动进行触发操作,就会展示在用户眼前。
图片比文字更加具象,以下是两种典型的新手引导组件,你是不是一看就明白功能引导组件是什么了呢?
功能简介分步引导Guide组件以分步引导为核心,像指路牌一样,一节一节地引导用户从起点到终点。这种引导适用于交互流程较长的新功能,或是界面比较复杂的产品。它带领用户体验了完整的操作链路,并快速地了解各个功能点的位置。
呈现方式蒙层模式顾名思义,蒙层引导是指在产品上用一个半透明的黑色进行遮罩,蒙层上方对界面进行高亮,旁边配以弹窗进行讲解。这种引导方式阻断了用户与界面的交互,让用户的注意力聚焦在所圈注的功能点上,不被其他元素所干扰。
弹窗模式很多场景下,为了不干扰用户,我们并不想使用蒙层。这时,我们可以使用无蒙层模式,即在功能点旁边弹出一个简单的窗口引导。
精准定位初始定位Guide提供了12种对齐方式,将弹窗引导加载到所选择的元素上。同时,还允许自定义横纵向偏差值,对弹窗的位置进行调整。下图分别展示了定位为top-left和right-bottom的弹窗:
并且当用户缩放或者滚动页面时,弹窗的定位依然是准确的。
自动滚动在很多情境中,我们都需要对距离较远的几个页面元素进行功能说明,串联成一个完整的引导路径。当下一步要圈注的功能点不在用户视野中时,Guide会自动滚动页面至合适的位置,并弹出引导窗口。
键盘操作当Guide引导组件弹出时,我们希望用户的注意力被完全吸引过来。为了让使用辅助阅读器的用户也能够感知到Guide的出现,我们将页面焦点移动到弹窗上,并且让弹窗里的每一个可读元素都能够聚焦。同时,用户可以用键盘(tab或tabshift)依次聚焦弹窗里的内容,也可以按escape键退出引导。
下图中,用户用tab键在弹窗中移动焦点,被聚焦的元素用虚线框标识出来。当聚焦到“下一步”按钮时,敲击shift键,便可跳至下一步引导。
技术实现总体流程在展示组件的步骤前我们会先判断是否过期,判断是否过期的标准有两个:一个是该引导组件在localStorage
中存储唯一key是否为true,为true则为该组件步骤执行完毕。第二个是组件接收一个props.expireDate
,如果当前时间大于expireDate
则代表组件已经过期则不会继续展示。
当组件没有过期时,会展示传入的props.steps
相应的内容,steps结构如下:
interfaceStep{selector:string;title:string;content:React.Element|string;placement:'top'|'bottom'|'left'|'right'|'top-left'|'top-right'|'bottom-left'|'bottom-right',offset:Record<'top'|'bottom'|'left'|'right',number>}conststeps=Step[]
根据step.selector
获取高亮元素,再根据step.placement
将弹窗展示到高亮元素相关的具体位置。点击下一步会按序展示下个step,当所有步骤展示完毕之后我们会将该引导组件在localStorage
中存储唯一key置为true
,下次进来将不再展示。
下面来看看引导组件的具体细节实现吧。
蒙层模式当前的引导组件支持有无蒙层两种模式,有蒙层的展示效果如下图所示。
蒙层很好实现,就是一个撑满屏幕的div,但是我们怎么才能让它做到高亮出中间的selector元素并且还支持圆角呢??,真相只有一个,那就是——border-width
我们拿到了selector元素的offsetTop
,offsetRight
,offsetBottom
,offsetLeft
,并相应地设置为高亮框的border-width
,再把border-color
设置为灰色,一个带有高亮框的蒙层就实现啦!在给这个高亮框div加个pseudo-element::after
来赋予它border-radius,完美!
用户使用Guide时,传入了步骤信息,每一步都包括了所要进行引导说明的界面元素的CSS选择器。我们将所要标注的元素叫做“锚元素”。Guide需要根据锚元素的位置信息,准确地定位弹窗。
每一个HTML元素都有一个只读属性offsetParent,它指向最近的(指包含层级上的最近)包含该元素的定位元素或者最近的table,td,th,body
元素。每个元素都是根据它的offsetParent元素进行定位的。比如说,一个absolute定位的元素,是根据它最近的、非static定位的上级元素进行偏移的,这个上级元素,就是其的offsetParent。
所以我们想到将弹窗元素放进锚元素的offsetParent中,再对其位置进行调整。同时,为了不让锚元素offsetParent中的其它元素产生位移,我们设定弹窗元素为absolute绝对定位。
定位步骤弹窗的定位计算流程大致如下:
步骤1.得到锚元素通过传给Guide的步骤信息中的selector,即CSSselector,我们可以由下述代码拿到锚元素:
constanchor=document.querySelector(selector);
如何拿到anchor的offsetParent呢?这一步其实并没有想象中那么简单。下面我们就来详细地讲一讲这一步吧。
步骤2.获取offsetParent一般来说,拿到锚元素的offsetParent,也只需要简单的一行代码:
constparent=anchor.offsetParent;
但是这行代码并不能涵盖所有的场景,我们需要考虑一些特殊的情况。
场景一:锚元素为fixed定位并不是所有的HTMLElement都有offsetParent属性。当锚元素为fixed定位时,其offsetParent
返回null
。这时,我们就需要使用其包含块(containingblock)代替offsetParent了。
包含块是什么呢?大多数情况下,包含块就是这个元素最近的祖先块元素的内容区,但也不是总是这样。一个元素的包含块是由其position属性决定的。
- 如果position属性是
fixed
,包含块通常是document.documentElement
。
如果position属性是
fixed
,包含块也可能是由满足以下条件的最近父级元素的内边距区的边缘组成的:transform
或perspective
的值不是none
will-change
的值是transform
或perspective
filter
的值不是none
或will-change
的值是filter
(只在Firefox下生效).contain
的值是paint
(例如:contain:paint;)
因此,我们可以从锚元素开始,递归地向上寻找符合上述条件的父级元素,如果找不到,那么就返回document.documentElement
。
下面是Guide中用来寻找包含块的代码:
constgetContainingBlock=node=>{letcurrentNode=getDocument(node).documentElement;while(isHTMLElement(currentNode)&&!['html','body'].includes(getNodeName(currentNode))){constcss=getComputedStyle(currentNode);if(css.transform!=='none'||css.perspective!=='none'||(css.willChange&&css.willChange!=='auto')){returncurrentNode;}currentNode=currentNode.parentNode;}returncurrentNode;};
场景二:在iframe中使用Guide在Guide的代码中,我们常常用到window
对象。比如说,我们需要在window
对象上调用getComputedStyle()
获取元素的样式,我们还需要window
对象作为元素offsetParent
的兜底。但是我们并不能直接使用window
对象,为什么呢?这时,我们需要考虑iframe的情况。
想象一下,如果我们在一个内嵌了iframe的应用中使用Guide组件,Guide组件代码在iframe外面,而被引导的功能点在iframe里面,那么在使用Window对象提供的方法是,我们一定是想在所圈注的功能点所在的Window对象上进行调用,而非当前代码运行的Window。
因此,我们通过下面的getWindow
方法,确保拿到的是参数node所在的Window。
//Getthewindowobjectusingthisfunctionratherthensimplyuse`window`because//therearecaseswherethewindowobjectweareseekingtoreferenceisnotin//thesamewindowscopeasthecodewearerunning.(https://stackoverflow.com/a/37638629)constgetWindow=node=>{//ifnodeisnotthewindowobjectif(node.toString()!=='[objectWindow]'){//getthetop-leveldocumentobjectofthenode,ornullifnodeisadocument.const{ownerDocument}=node;//getthewindowobjectassociatedwiththedocument,ornullifnoneisavailable.returnownerDocument?ownerDocument.defaultView||window:window;}returnnode;};
在line8,我们看到一个属性ownerDocument。如果node是一个DOMElement,那么它具有一个属性ownerDocument
,此属性返回的document对象是在实际的HTML文档中的所有子节点所属的主对象。如果在文档节点自身上使用此属性,则结果是null
。当node为Window对象时,我们返回window
;当node为Document对象时,我们返回了ownerDocument.defaultView
。这样,getWindow
函数便涵盖了参数node的所有可能性。
如下代码所示,我们常常遇到的使用场景是,在组件A中渲染Guide,让其去标注的元素却在组件B、组件C中。
//组件AconstA=props=>(<><Guidesteps={[{......selector:'#btn1'},{......selector:'#btn2'},{......selector:'#btn3'}]}/><buttonid="btn1">Button1</button></>)
//组件BconstB=props=>(<buttonid="btn2">Button2</button>)
//组件CconstC=props=>(<buttonid="btn3">Button3</button>)
上述代码中,Guide会自然而然地渲染在A组件DOM结构下,我们怎样将其挂载到组件B、C的offsetParent中呢?这时候就要给大家介绍一下强大却少为人知的ReactPortals了。
ReactPortals当我们需要把一个组件渲染到其父节点所在的DOM树结构之外时,我们首先应该考虑使用ReactPortals。Portals最适用于这种需要将子节点从视觉上渲染到其父节点之外的场景了,在Antd的Modal、Popover、Tooltip组件实现中,我们也可以看到Portal的应用。
我们使用ReactDOM.createPortal(child,container)
创建一个Portal。child是我们要挂载的组件,container则是child要挂载到的容器组件。
虽然Portal是渲染在其父元素DOM结构之外的,但是它并不会创建一个完全独立的ReactDOM树。一个Portal与React树中其它子节点相同,都可以拿到父组件的传来的props和context,也都可以进行事件冒泡。
另外,与ReactDOM.render所创建的ReactDOM树不同,ReactDOM.createPortal是应用在组件的render函数中的,因此不需要手动卸载。
在Guide中,每跳一步,上一步的弹窗便会卸载掉,新的弹窗会被加载到这一步要圈注的元素的offsetParent里。伪代码如下:
constModal=props=>(ReactDOM.createPortal(<div>......</div>,offsetParent);)
将弹窗渲染进offsetParent后,Guide的下一步工作便是计算弹窗相对于offsetParent的偏移量。这一步非常复杂,并且要考虑一些特殊情况。下面就让我们就仔细地讲解这部分计算吧。
步骤4.偏移量计算以一个placement=left
,即需要在功能点左侧展示的弹窗引导为例。如果我们直接把弹窗通过ReactPortal挂载到锚元素的offsetParent中,并赋予其绝对定位,其位置会如下图所示——左上角与offsetParent的左上角对齐。
_下图中,用蓝色框表示的考拉图片是Guide需要标注的元素,即锚元素;红色框则标识出这个锚元素的offsetParent元素。
而我们预想的定位结果如下:
参考下图,将弹窗从初始位置移动至预期位置,我们需要在y轴上向下移动弹窗offsetToph1/2-h2/2px
。其中,h1
为锚元素的高度,h2
为弹窗的高度。
但是,上述计算依然忽略了一种场景,那就是当锚元素定位为fixed时。若锚元素定位为fixed,那么无论锚元素所在的界面怎样滑动,锚元素相对于屏幕视口(viewport)的位置是固定的。自然,用来对fixed锚元素进行引导的弹窗也需要具有这些特性,即同样需要为fixed定位。
Arrow实现及定位arrow
是modal
的子元素且相对于modal
绝对定位,如下图所示有十二种展示位置,我们把十二种定位分为两类情况:
- 紫色的四种居中情况;
- 黄色的其余八种斜角。
对于第一类情况
箭头始终是相对弹窗边缘居中的位置,出对于top、bottom,箭头的right值始终是(modal.width-arrow.diagonalWidth)/2
,而top或bottom值始终为-arrow.diagonalWidth/2
。
对于left、right,箭头的top值是(modal.height-arrow.diagonalWidth)/2
,而left或right为-arrow.diagonalWidth/2
。
注:diagonalWidth
为对角线宽度,getReversePosition\(placement\)
为获取传入参数的reverse位置,top对应bottom,left对应right。
伪代码如下:
constplacement='top'|'bottom'|'left'|'right';constdiagonalWidth=10;conststyle={right:['bottom','top'].includes(placement)?(modal.width-diagonalWidth)/2:'',top:['left','right'].includes(placement)?(modal.height-diagonalWidth)/2:'',[getReversePosition(placement)]:-diagonalWidth/2,};
对于第二类情况
对于A-B的位置,通过下图可以发现,B的位移总是固定值。比如对于placement值为top-left的弹窗,箭头left值总是固定的,而bottom值为-arrow.diagonalWidth/2
。
以下为伪代码:
const[firstPlacement,lastPlacement]=placement.split('-');constdiagonalWidth=10;constmargin=24;conststyle={[lastPlacement]:margin,[getReversePosition(placement)]:-diagonalWidth/2,}
Hotspot实现及定位引导组件支持hotspot
功能,通过给一个div
元素加上动画改变其box-shadow
大小实现呼吸灯的效果,效果如下图所示,其中热点的定位是相对箭头的位置计算的,这里便不赘述了。
在Guide的开发初期,我们并没有想到这样一个小组件需要考虑到以上这些技术点。可见,再小的组件,让其适用于所有场景,做到足够通用都是件难事,需要不断地尝试与反思。
招聘硬广我们团队招人啦!!!
欢迎加入字节跳动商业变现前端团队,我们在做的技术建设有:前端工程化体系升级、团队Node基建搭建、前端一键式CI发布工具、组件服务化支持、前端国际化通用解决方案、重依赖业务系统微前端改造、可视化页面搭建系统、商业智能BI系统、前端自动化测试等等等等,拥有近百号人的北上杭大前端团队,一定会有你感兴趣的领域!
如果你想要加入我们,欢迎点击我们的内推通道吧:
✨✨✨✨✨
校招专属入口:字节跳动校招内推码:WAU8ZHR
如果你想了解我们部门的日常生(dòu)活(bī)以及工作环(fú)境(lì)
也可以点击阅读原文了解噢~
✨✨✨✨✨