聊聊“前端引导操作”(慎用fixed、relative、absolute组合技)

25 阅读2分钟

说来惭愧,一直到产品要加一个单独引导页的需求,我才仿佛第一次了解般细想这个功能。因为我发现,对这个“引导页”,我第一时间也是迷茫的,有些按钮询问了产品才知道是干啥用的。

当用户遇到和这个时候类似的场景发生时,“引导操作”的重要性就凸显出来了!

基于此,我封装了一个“引导操作”组件,传入「唯一的」id / class 和展示引导文案列表,即可产生效果。就像这样: 微店卖家侧-pc-优惠套餐

除此之外,图中高亮的按钮是可以操作的 —— 这就要考虑是否能操作?操作的时候是否需要“认为用户不需要引导”,直接取消“引导操作”?

但这不重要,本文只讨论弹窗中的一些“特殊点”。

刚开始选择实现方式时,有两种方案摆在我面前:

  1. cloneNode + position + transition
  2. z-index + position + transition

第一种方式,保证了元素的“独立性”,但是缺失了元素的“可交互性”。但这不是最重要的,每次的clone会带来繁琐的操作,我们应该避免它!

所以笔者采用了第二种方式:

<template>
    <div>
        <div v-if="show" ref="guideModalRef" class="guide-modal">
            <div ref="guideBoxRef" class="guide-box">
                <div>{{ message }}</div>
                <button class="btn" :disabled="index === 0" @click="changeStep(true)">
                上一步
                </button>
                <button class="btn" @click="changeStep(false)">{{lastBtn}}</button>
            </div>
        </div>
    </div>
</template>
  
<style scoped>
  .guide-modal {
    position: fixed;
    z-index: 99999;
    left: 0;
    right: 0;
    top: 0;
    bottom: 0;
    background-color: rgba(0, 0, 0, 0.3);
  }
  .guide-box {
    width: 150px;
    min-height: 10px;
    border-radius: 5px;
    background-color: #fff;
    position: absolute;
    transition: 0.5s;
    padding: 10px;
    text-align: center;
    z-index: 99999;
  }
  .btn {
    margin: 20px 5px 5px 5px;
  }
</style>
<script>
    let preNode = null;
    export default {
        props: {
            selectors: {
                type: Array,
                default: []
            },
            close: {
                type: Boolean,
                default: false
            }
        },
        watch: {
            close: {
                handler(val) {
                    if(val) this.show = false;
                },
                immediate: true
            }
        },
        data() {
            return {
                guideModalRef: null,
                guideBoxRef: null,
                index: 0,
                show: true,
                lastBtn: "下一步",
            }
        },
        computed: {
            message() {
                return this.selectors[this.index] && this.selectors[this.index].message;
            }
        },
        mounted() {
            this.genGuide();
        },
        methods: {
            genGuide() {
                // 所有指引完毕
                if(this.index == this.selectors.length - 1) {
                    this.lastBtn = "结束";
                }else {
                    this.lastBtn = "下一步";
                }
                if (this.index > this.selectors.length - 1) {
                    this.show = false;
                    return;
                }

                // 修改上一个节点的 z-index
                if (preNode) preNode.style = `z-index: 0;`;

                // 获取目标节点信息
                const target = preNode = document.querySelector(this.selectors[this.index].selector);
                target.style = `
                    position: relative; 
                    z-index: 1000;
                `;
                const { x, y, width, height } = target.getBoundingClientRect();
                
                // 指引相关
                if (this.$refs.guideBoxRef) {
                    const halfClientHeight = this.$refs.guideBoxRef.clientHeight / 2;
                    this.$refs.guideBoxRef.style = `
                        left:${x + width + 10}px;
                        top:${y <= halfClientHeight ? y : y - halfClientHeight + height / 2}px;
                    `;
                }
            },

            changeStep(isPre) {
                isPre ? this.index-- : this.index++;
                this.genGuide();
            },
        }
    }
</script>

我们制造了一个遮罩层以避免对其他元素产生影响。然后动态地给“被高亮”的元素一个position,和高 z-index ,让他高于遮罩层。并在一旁通过 absolute 一个弹窗显示引导信息。 然后在created中使用节流函数注册监听事件,在每次有改动的时候去重新判断位置和高亮:

created() {
    // 页面内容发生变化时,重新计算位置
    window.addEventListener("resize", () => this.onScroll());
    window.addEventListener("scroll", () => this.onScroll());
},

//在methods中:
onScroll() {
	let frame = window.requestAnimationFrame;
	if(!frame) {
		throttle(function() { //组内封装的节流函数
			this.genGuide();
		}, 16);
	} else {
		frame(this.genGuide());
	}
}

似乎结束了? 不,我发现了一件有趣的事:当目标元素的父元素 position: fixed | absolute | sticky 时,目标元素的 z-index 无法超过蒙版层。(这也是目前一些流行的引导功能库中都具有的功能缺陷)

进而笔者发现:父元素为 fixed 时,具有 relative 的子元素会“丧失”高渲染层,表现上就像一个「普通元素」

除此之外,transform 这些可能会改变渲染层的属性也会对元素的定位有影响!

这里放一张图,这是Safari控制台具有的一项功能,可以查看页面的层级结构: 修改样式前后页面层级

几乎所有的相关文章都在说 relative 会如何影响到 absolute 和 fixed,但似乎笔者没有见到有反过来研究的,也可能是我阅读的少,如有见谅。

那有没有一种东西,既能铺满整个页面(充当遮罩层),又能让指定位置“空出来”? SVG

SVG 可编码,利用 SVG 来实现蒙版效果,并预留出目标元素的高亮区间(即 SVG 不需要绘制的部分),这样就解决了使用 z-index 可能会失效的问题。

于是笔者改写了代码:

<template>
    <div>
        <svg v-if="show" style="position: absolute;top: 0;left: 0;width: 100%;height: 100%;z-index: 9999;">
            <defs>
                <mask id="myMask">
                    <rect x="0" y="0" width="100%" height="100%" style="stroke:none; fill: #ccc"></rect>
                    <rect id="circle1" :width="tip_s_w" :height="tip_s_h" :x='tip_s_x' :y="tip_s_y" style="fill: #000" />
                </mask>
            </defs>
            <rect x="0" y="0" width="100%" height="100%" style="stroke: none; fill: rgba(0, 0, 0, 0.6); mask: url(#myMask)"></rect>
        </svg>
        <div v-if="show" ref="guideModalRef"><!--  class="guide-modal" -->
            <div ref="guideBoxRef" class="guide-box">
                <div>{{ message }}</div>
                <button class="btn" :disabled="index === 0" @click="changeStep(true)">
                上一步
                </button>
                <button class="btn" @click="changeStep(false)">{{lastBtn}}</button>
            </div>
        </div>
    </div>
</template>
  
<style scoped>
  .guide-box {
    width: 150px;
    min-height: 10px;
    border-radius: 5px;
    background-color: #fff;
    position: fixed;
    transition: 0.5s;
    padding: 10px;
    text-align: center;
    z-index: 99999;
  }
  .btn {
    margin: 20px 5px 5px 5px;
  }
</style>
// script代码

当然,这种方法也是有遗憾的:由于这时候“高亮”也是“画”出来的,所以这时候切记不可让页面滚动 —— 页面操作是有延迟的!

然后我们加入“边界判断”。在genGuide函数最后增加:

	//边界判断
        if((x + width + 14 > document.documentElement.clientWidth) && (g_top + g_clientHeight <= document.documentElement.clientHeight)) {
            this.$refs.guideBoxRef.style = `
                left:${x - g_clientWidth - 14}px;
                top:${y <= halfClientHeight ? y : y - halfClientHeight + height / 2}px;
            `;
        } else if((g_top + g_clientHeight > document.documentElement.clientHeight) && (x + width + 14 <= document.documentElement.clientWidth)) {
            this.$refs.guideBoxRef.style = `
                left:${x + width + 14}px;
                top:${y - g_clientHeight + 8}px;
            `;
        } else if((x + width + 14 > document.documentElement.clientWidth) && (g_top + g_clientHeight > document.documentElement.clientHeight)) {
            this.$refs.guideBoxRef.style = `
                left:${x -g_clientWidth - 14}px;
                top:${y - g_clientHeight + 8}px;
            `;
        }

使用时传入这样的结构即可:

[
  {
    selector: ".combo-btn-tab-s2",
    message: "当前基础版,最多选择5个商品",
  },
  {
    selector: ".combo-btn-tab-s1",
    message: "点此开启包邮城市选择!",
  },
  {
    selector: ".combo-btn-tab-s3",
    message: "什么也不修改,返回列表页",
  },
],

欢迎交流!一起进步!也欢迎访问和关注笔者微信公众号“前端Code新谈”,目前在其中设置了“webRTC系列”、“前端面试系列”和新增的“用户体验系列”文章。目前已有些许文章才敢宣传哈哈~