共计 9782 个字符,预计需要花费 25 分钟才能阅读完成。
置信大家或多或少都在各种网站上应用过老手疏导,当网站提供的性能有点简单时,这是一个对老手十分敌对的性能,能够追随老手疏导一步一步理解网站的各种性能,咱们要做的只是点击下一步或者上一步,网站就能滚动到指定地位,而后高亮页面的一部分,并且配以一些图文介绍。
目前有很多帮你实现这种性能的开源库,当然,本人实现一个也不难,而且外围就是一个简略的 css
款式,不信你接着往下看。
基本思路
假如咱们的老手疏导库是一个类,名为NoviceGuide
,咱们能够这样应用它:
new NoviceGuide({ | |
steps: [ | |
{ | |
element: '',// 页面上的元素,能够是节点,也能够是节点的选择器 | |
text: '我是第一步', | |
img: '我是第一步的图片' | |
}, | |
{ | |
element: '', | |
text: '我是第二步' | |
} | |
] | |
}).start() |
咱们略微思考一下就会发现,实现原理其实很简略,只有找到某一步指定节点的地位和宽高,而后将页面滚动到该节点的地位,最初高亮它,并且在旁边显示信息即可。
咱们的类根本构造如下:
class NoviceGuide {constructor(options) { | |
this.options = options | |
// 步骤数据 | |
this.steps = [] | |
// 以后所在步骤 | |
this.currentStepIndex = -1 | |
// 解决步骤数据 | |
this.initSteps()} | |
initSteps() {this.options.steps.forEach((step) => { | |
this.steps.push({ | |
...step, | |
element: | |
typeof step.element === "string" | |
? document.querySelector(step.element) | |
: step.element, | |
}) | |
}) | |
} | |
start() {this.next() | |
} | |
next() {} | |
} |
滚动到指标元素
获取到以后步骤的元素,而后再获取它的地位,最初再滚动页面,让指标元素居中即可。
class NoviceGuide {next() { | |
// 曾经是最初一步,那么完结疏导 | |
if (this.currentStepIndex + 1 >= this.steps.length) {return this.done() | |
} | |
this.currentStepIndex++ | |
this.to()} | |
to() { | |
// 以后步骤 | |
const currentStep = this.steps[this.currentStepIndex] | |
// 以后步骤元素的尺寸和地位信息 | |
const rect = currentStep.element.getBoundingClientRect() | |
const windowHeight = window.innerHeight | |
// 浏览器窗口滚动到元素所在位置 | |
window.scrollBy(0, rect.top - (windowHeight / 2 - rect.height / 2)) | |
} | |
done() {} | |
} |
应用 window.scrollBy
滚动绝对间隔,间隔的计算能够参考下图:
不过如果元素曾经在可视窗口内,其实不须要将它居中,否则如果多个步骤都在一个窗口内,那么切换步骤会频繁的滚动页面,体验反而不好,所以先判断一下元素是否在视口内:
class NoviceGuide {to() {const currentStep = this.steps[this.currentStepIndex] | |
const rect = currentStep.element.getBoundingClientRect() | |
const windowHeight = window.innerHeight | |
if (!this.elementIsInView(currentStep.element)) {window.scrollBy(0, rect.top - (windowHeight - rect.height) / 2) | |
} | |
} | |
elementIsInView(el) {const rect = el.getBoundingClientRect() | |
return ( | |
rect.top >= 0 && | |
rect.left >= 0 && | |
rect.bottom <= window.innerHeight && | |
rect.right <= window.innerWidth | |
) | |
} | |
} |
高亮元素
指标元素可见了,接下来要做的是高亮它,具体的成果就是页面上只有指标元素是亮的,其余中央都是暗的,这个实现形式我思考过应用 svg
、canvas
等,比方 canvas
实现:
class NoviceGuide {to() { | |
// ... | |
this.highlightElement(currentStep.element) | |
} | |
highlightElement(el) {const rect = el.getBoundingClientRect(); | |
const canvas = document.createElement('canvas') | |
document.body.appendChild(canvas) | |
const ctx = canvas.getContext('2d') | |
canvas.width = window.innerWidth | |
canvas.height = window.innerHeight | |
canvas.style.cssText = ` | |
position: fixed; | |
left: 0; | |
top: 0; | |
z-index: 99999999; | |
` | |
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)' | |
ctx.fillRect(0, 0, window.innerWidth, window.innerHeight) | |
ctx.clearRect(rect.left, rect.top, rect.width, rect.height) | |
} | |
} |
原理很简略,创立一个和窗口一样大的canvas
,而后全副填充成半透明,最初再革除掉指标元素所在位置的绘制,就达到了高亮的成果:
不过这种形式想要成果更好一点比拟麻烦,起初在其余库中看到一个很简略的实现,应用一个 box-shadow
属性即可:
class NoviceGuide {highlightElement(el) {const rect = el.getBoundingClientRect() | |
if (!this.highlightEl) {this.highlightEl = document.createElement("div") | |
this.highlightEl.style.cssText = ` | |
position: absolute; | |
box-shadow: 0 0 0 5000px rgba(0, 0, 0, 0.5); | |
z-index: 99999999; | |
border-radius: 5px; | |
transition: all 0.3s ease-out; | |
` | |
document.body.appendChild(this.highlightEl) | |
} | |
this.highlightEl.style.left = rect.left + window.pageXOffset + "px" | |
this.highlightEl.style.top = rect.top + window.pageYOffset + "px" | |
this.highlightEl.style.width = rect.width + "px" | |
this.highlightEl.style.height = rect.height + "px" | |
} | |
} |
外围就是 box-shadow: 0 0 0 5000px rgba(0, 0, 0, 0.5);
这一行款式,创立一个和指标元素一样大小的元素,而后盖在它下面,而后把这个元素的暗影大小设置成十分大,这样除了这个元素的外部,页面其余中央都是它的暗影,就达到了高亮的成果,果然是 css
学的好,每天上班早。
应用 DOM
简略很多,批改款式比拟不便,另外只有设置transition
,就能轻松实现切换步骤时高亮的过渡动画成果。
另外为什么这里没有应用固定定位,而是应用相对定位,其实是因为如果应用固定定位,页面能够滚动,然而高亮框并不会滚动,那么就对不上了。
切换步骤
接下来,咱们创立一个新元素用来寄存信息和上一步下一步的按钮:
class NoviceGuide {constructor(options) { | |
// ... | |
this.infoEl = null | |
} | |
to() { | |
// ... | |
this.showStepInfo(currentStep) | |
} | |
showStepInfo(step) {if (!this.infoEl) {this.infoEl = document.createElement("div") | |
this.infoEl.style.cssText = ` | |
position: absolute; | |
z-index: 99999999; | |
background-color: #fff; | |
border-radius: 5px; | |
` | |
document.body.appendChild(this.infoEl) | |
// 绑定单击事件 | |
this.infoEl.addEventListener("click", (e) => {let type = e.target.getAttribute("data-type") | |
if (type) {if (type === "prev") {this.prev() | |
} | |
if (type === "next") {this.next() | |
} | |
} | |
}) | |
} | |
this.infoEl.innerHTML = ` | |
<div> | |
${ | |
step.img ? `<div> | |
<img src="${step.img}" style="width: 250px" /> | |
</div>` : '' | |
} | |
<div>${step.text}</div> | |
</div> | |
<div> | |
<button data-type="prev"> 上一步 </button> | |
<button data-type="next"> 下一步 </button> | |
</div> | |
` | |
const rect = step.element.getBoundingClientRect() | |
this.infoEl.style.left = rect.left + window.pageXOffset + "px" | |
this.infoEl.style.top = rect.bottom + window.pageXOffset + "px" | |
} | |
} |
很简略,同样是创立一个相对定位的元素,外面寄存信息、图片、按钮,而后监听一下点击事件,判断点击的是上一步还是下一步,补充一下上一步和完结的逻辑:
class NoviceGuide {prev() {if (this.currentStepIndex - 1 < 0) {return} | |
this.currentStepIndex-- | |
this.to()} | |
done() {document.body.removeChild(this.highlightEl) | |
document.body.removeChild(this.infoEl) | |
this.currentStepIndex = -1 | |
} | |
} |
完结的话间接删除创立的两个元素就能够了,看看目前的成果:
优化
加点内边距
目前视觉上不是很难看,高亮框和指标元素大小是齐全一样的,高亮框和信息框齐全挨着,信息框没有内边距,所以优化一下:
class NoviceGuide {constructor(options) { | |
this.options = Object.assign( | |
{ | |
padding: 10, | |
margin: 10 | |
}, | |
options | |
) | |
} | |
highlightElement(el) { | |
// ... | |
let {padding} = this.options | |
this.highlightEl.style.left = rect.left + window.pageXOffset - padding + "px" | |
this.highlightEl.style.top = rect.top + window.pageYOffset - padding + "px" | |
this.highlightEl.style.width = rect.width + padding * 2 + "px" | |
this.highlightEl.style.height = rect.height + padding * 2 + "px" | |
} | |
showStepInfo(step) {let { padding, margin} = this.options | |
if (!this.infoEl) { | |
this.infoEl.style.cssText = ` | |
padding: ${padding}px; | |
` | |
} | |
// ... | |
this.infoEl.style.left = rect.left + window.pageXOffset - padding + "px" | |
this.infoEl.style.top = rect.bottom + window.pageYOffset + padding + margin + "px" | |
} | |
} |
反对某个步骤没有元素
某些步骤可能是纯信息,不须要元素,这种状况间接显示在页面两头即可:
class NoviceGuide {to() {const currentStep = this.steps[this.currentStepIndex] | |
if (!currentStep.element) { | |
// 以后步骤没有元素 | |
this.highlightElement() | |
this.showStepInfo(currentStep) | |
return | |
} | |
// ... | |
} | |
highlightElement(el) { | |
// ... | |
if (el) {const rect = el.getBoundingClientRect() | |
let {padding} = this.options | |
// ... | |
// 原有逻辑 | |
} else { | |
// 以后步骤没有元素高亮元素的宽高设置成 0,并且间接定位在窗口两头 | |
this.highlightEl.style.left = window.innerWidth / 2 + window.pageXOffset + "px" | |
this.highlightEl.style.top = window.innerHeight / 2 + window.pageYOffset + "px" | |
this.highlightEl.style.width = 0 + "px" | |
this.highlightEl.style.height = 0 + "px" | |
} | |
} | |
showStepInfo(step) { | |
// ... | |
if (step.element) {const rect = step.element.getBoundingClientRect() | |
// ... | |
// 原有逻辑 | |
} else { | |
// 以后步骤没有元素,信息框定位在窗口两头 | |
const rect = this.infoEl.getBoundingClientRect() | |
this.infoEl.style.left = (window.innerWidth - rect.width) / 2 + window.pageXOffset + "px" | |
this.infoEl.style.top = (window.innerHeight - rect.height) / 2 + window.pageYOffset + "px" | |
} | |
} | |
} |
当然,上述实现还是有点问题的,比方网速慢的时候,或者图片比拟大时,图片还没加载进去,那么获取到的信息框的大小是不对的,导致定位会呈现偏差,这个问题本文就不思考了。
动静计算信息的地位
目前咱们的信息框是默认显示在高亮元素下方的,这样显然是有问题的,比方高亮元素刚好在屏幕底部,或者信息框的高度很高,底部无奈齐全显示,这种状况,咱们就须要改成动静计算的形式,具体来说就是顺次判断信息框是否在高亮元素下方、上方、左方、右方四个方向显示,如果都不行的话,还要尝试调整页面滚动的地位使高亮框和信息框都能显示。
class NoviceGuide {showStepInfo(step) { | |
// ... | |
if (step.element) {this.computeInfoPosition(step) | |
} else {// ...} | |
} | |
} |
计算的逻辑咱们放到一个新函数里:
class NoviceGuide {computeInfoPosition(step) {const { padding, margin} = this.options | |
const windowWidth = window.innerWidth | |
const windowHeight = window.innerHeight | |
const windowPageXOffset = window.pageXOffset | |
const windowPageYOffset = window.pageYOffset | |
const rect = step.element.getBoundingClientRect() | |
const infoRect = this.infoEl.getBoundingClientRect() | |
// ... | |
} | |
} |
获取和保留一些根本信息,持续:
class NoviceGuide {computeInfoPosition(step) { | |
let left = 0 | |
let top = 0 | |
const adjustLeft = () => { | |
// 优先和高亮框左对齐 | |
if (windowWidth - rect.left - padding >= infoRect.width) {return rect.left - padding + windowPageXOffset} else { | |
// 否则程度居中显示 | |
return (windowWidth - infoRect.width) / 2 + windowPageXOffset | |
} | |
}; | |
if ( | |
rect.bottom + padding + margin + infoRect.height <= windowHeight && // 下方宽度能够包容 | |
infoRect.width <= windowWidth // 信息框宽度比浏览器窗口小 | |
) { | |
// 能够在下方显示 | |
left = adjustLeft() | |
top = rect.bottom + padding + margin + windowPageYOffset | |
} else if ( | |
rect.top - padding - margin >= infoRect.height && | |
infoRect.width <= windowWidth | |
) { | |
// 能够在上方显示 | |
left = adjustLeft() | |
top = rect.top - padding - margin - infoRect.height + windowPageYOffset | |
} | |
// 省略后续两个判断 | |
} | |
} |
判断高亮框的下方和上方的残余空间是否包容信息框,另外还要判断一下信息框的宽度是否比浏览器窗口小。
对于信息框的程度地位,咱们优先让它和高亮框左对齐,如果空间不够,那么就让信息框在浏览器窗口程度居中。
对于左侧和右侧的判断也是相似的,残缺代码能够去文末的仓库里查看。
当上下左右四个方向都无奈满足条件时,咱们还能够再查看一种状况,也就是高亮框和信息框的总高度是否比浏览器窗口高度小,是的话咱们能够通过滚动页面地位来达到残缺显示的目标:
class NoviceGuide {computeInfoPosition(step) { | |
// ... | |
else { | |
// 否则查看高亮框高度 + 信息框高度是否小于窗口高度 | |
let totalHeightLessThenWindow = | |
rect.height + padding * 2 + margin + infoRect.height <= windowHeight | |
if ( | |
totalHeightLessThenWindow && | |
Math.max(rect.width + padding * 2, infoRect.width) <= windowWidth | |
) { | |
// 高低排列能够搁置 | |
// 滚动页面,居中显示两者整体 | |
let newTop = | |
(windowHeight - | |
(rect.height + padding * 2 + margin + infoRect.height)) / | |
2 | |
window.scrollBy(0, rect.top - newTop) | |
} else { | |
// 恕我无能为力 | |
// 回到默认地位 | |
} | |
left = adjustLeft() | |
top = rect.bottom + padding + margin + windowPageYOffset | |
} | |
this.infoEl.style.left = left + "px" | |
this.infoEl.style.top = top + "px" | |
} | |
} |
如果总高度小于窗口高度,那么能够调整页面滚动地位,否则就不做任何解决,这两种状况对于信息框来说,都是显示在高亮框下方。
如果指标元素位于可滚动元素内
这个问题是什么意思呢,比方咱们想高亮下图中红框内的元素:
它所在的可滚动父元素并不是 document.body
,事实上这个页面body
元素压根无奈滚动,宽高是和窗口宽高统一的,而咱们的实现逻辑是通过滚动 body
来使元素可见的,那么咱们就做不到让这个元素呈现在视口。
解决这个问题能够这么思考,咱们先找到指标元素的最近的可滚动的先人元素,如果元素不在该先人元素的可视区域内,那么就滚动父元素让元素可见,当然这样还没完,因为该先人元素也可能存在一个可滚动的先人元素,它也不肯定是在它的先人元素内可见,所以还得判断和让它可见,很显著,这是一个向上递归的过程,始终查看到 body
元素为止。
先来写一个获取最近的可滚动先人元素的办法:
class NoviceGuide {getScrollAncestor(el) {let style = window.getComputedStyle(el) | |
const isAbsolute = style.position === 'absolute' | |
const isFixed = style.position === 'fixed' | |
const reg = /(auto|scroll)/ | |
// 如果元素是固定定位,那么可滚动先人元素为 body | |
if (isFixed) return document.body | |
let parent = el.parentElement | |
while (parent) {style = window.getComputedStyle(parent) | |
// 如果是相对定位,那么可滚动的先人元素必须是有定位的才行 | |
if (!(isAbsolute && style.position === 'static')) { | |
// 如果某个先人元素的 overflow 属性为 auto 或 scroll 则代表是可滚动的 | |
if (reg.test(style.overflow + style.overflowX + style.overflowY)) {return parent} | |
} | |
parent = parent.parentElement | |
} | |
return document.body | |
} | |
} |
就是一直向上递归,接下来批改一下 to
办法,在获取指标元素尺寸地位信息之前先让它可见:
class NoviceGuide {to() { | |
// ... | |
this.scrollAncestorToElement(currentStep.element) | |
const rect = currentStep.element.getBoundingClientRect() | |
// ... | |
} | |
scrollAncestorToElement(element) { | |
// 获取可滚动的先人元素 | |
const parent = this.getScrollAncestor(element) | |
if (parent === document.body) return | |
// 先人元素和指标元素的尺寸地位信息 | |
let parentRect = parent.getBoundingClientRect() | |
let rect = element.getBoundingClientRect() | |
// 滚动先人元素,让指标元素可见 | |
parent.scrollTop = parent.scrollTop + rect.top - parentRect.top | |
// 持续向上递归 | |
this.scrollAncestorToElement(parent) | |
} | |
} |
结尾
本文具体的介绍了如何实现一个老手疏导的性能,可能还有没有思考到的问题或者实现上的缺点,欢送留言指出。
残缺代码:https://github.com/wanglin2/simple-novice-guide。
在线示例:https://wanglin2.github.io/simple-novice-guide/。