聊聊“前端引导操作”(慎用fixedrelativeabsolute组合技)

Posted 恪愚

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了聊聊“前端引导操作”(慎用fixedrelativeabsolute组合技)相关的知识,希望对你有一定的参考价值。

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

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

前端并不能代表用户,但前端也是用户。
(本文的代码和效果截图将采用定制封装之前的初版代码,如果有需要可以自取并定制化修改)

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

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

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

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

  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 + 10px;
                        top:$y <= halfClientHeight ? y : y - halfClientHeight + height / 2px;
                    `;
                
            ,

            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>
    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: "下一步",
                tip_s_w: 0,
                tip_s_h: 0,
                tip_s_x: 0,
                tip_s_y: 0,
            
        ,
        computed: 
            message() 
                return this.selectors[this.index] && this.selectors[this.index].message;
            
        ,
        created() 
            document.documentElement.style.overflow = "hidden"
            // 页面内容发生变化时,重新计算位置
            window.addEventListener("resize", () => this.genGuide());
        ,
        mounted() 
            this.genGuide();
        ,
        beforeDestroy() 
            document.documentElement.style.overflow = "scroll";
        ,
        methods: 
            genGuide() 
                // 所有指引完毕
                if(this.index == this.selectors.length - 1) 
                    this.lastBtn = "结束";
                else 
                    this.lastBtn = "下一步";
                
                if (this.index > this.selectors.length - 1) 
                    this.show = false;
                    document.documentElement.style.overflow = "scroll"
                    return;
                

                // 获取目标节点信息
                const target = preNode = document.querySelector(this.selectors[this.index].selector);
                const  x, y, width, height  = target.getBoundingClientRect();

                this.tip_s_x = x;
                this.tip_s_y = y;
                this.tip_s_w = width;
                this.tip_s_h = height;
                
                // 指引相关
                if (this.$refs.guideBoxRef) 
                    const halfClientHeight = this.$refs.guideBoxRef.clientHeight / 2;
                    this.$refs.guideBoxRef.style = `
                        left:$x + width + 10px;
                        top:$y <= halfClientHeight ? y : y - halfClientHeight + height / 2px;
                    `;
                
            ,

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

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

然后我们加入“边界判断”。
修改genGuide函数:

genGuide() 
    // 所有指引完毕
    if(this.index == this.selectors.length - 1) 
        this.lastBtn = "结束";
    else 
        this.lastBtn = "下一步";
    
    if (this.index > this.selectors.length - 1) 
        this.show = false;
        document.documentElement.style.overflow = "scroll"
        return;
    

    // 获取目标节点信息
    const target = preNode = document.querySelector(this.selectors[this.index].selector);
    const  x, y, width, height  = target.getBoundingClientRect();

    this.tip_s_x = x - 4;
    this.tip_s_y = y - 4;
    this.tip_s_w = width + 8;
    this.tip_s_h = height + 8;
    
    // 指引相关
    if (this.$refs.guideBoxRef) 
        const g_clientHeight = this.$refs.guideBoxRef.clientHeight;
        const g_clientWidth = this.$refs.guideBoxRef.clientWidth;
        const halfClientHeight = g_clientHeight / 2;
        let g_top = (y <= halfClientHeight ? y : y - halfClientHeight + height / 2);
        this.$refs.guideBoxRef.style = `
            left:$x + width + 14px;
            top:$g_toppx;
        `;
		
		//边界判断
        if((x + width + 14 > document.documentElement.clientWidth) && (g_top + g_clientHeight <= document.documentElement.clientHeight)) 
            this.$refs.guideBoxRef.style = `
                left:$x - g_clientWidth - 14px;
                top:$y <= halfClientHeight ? y : y - halfClientHeight + height / 2px;
            以上是关于聊聊“前端引导操作”(慎用fixedrelativeabsolute组合技)的主要内容,如果未能解决你的问题,请参考以下文章

聊聊“前端引导操作”(慎用fixedrelativeabsolute组合技)

Clickhouse 分布式子查询——global in/join(慎用慎用)

慎用框架

慎用jQuery中的submit()方法

慎用DDD

git push -f 慎用