Skip to content
<

大厂常见问题解决方案

可视区域判断

js
// 1. el.offsetTop - doucmument.body.scrollTop <= viewPortHeight
const viewPortHeight = window.innerHeight || document.documentElement.clientHeight || document.body.clientHeight;

window.addEventListener("scroll", e => {
    isInViewPort1(e.target);
});

function isInViewPort1(el) {
    const offsetTop = el.offsetTop;
    const scrollTop = document.documentElement.scrollTop;
    const top = offsetTop - scrollTop;
    return top <= viewPortHeight;
}

// 2. el.getBoundingClientRect().top <= viewPortHeight
function isInViewPort2(el) {
    const rect = el.getBoundingClientRect();
    return rect.top <= viewPortHeight;
}

// 3. intersectionRatio > 0 && intersectionRatio <= 1
const io = new IntersectionObserver(ioes => {
    ioes.forEach(ioe => {
        const el = ioe.target;
        const intersectionRatio = ioe.intersectionRatio;

        if (intersectionRatio > 0 && intersectionRatio <= 1) {
            // TODO 在可视区域内
            el.classList.add("in-view-port");
        } else {
            el.classList.remove("in-view-port");
        }
    });
});

数据埋点方案、监控方案

代码埋点

优点:

  1. 灵活, 因为是代码埋点, 可以手动写到代码里的任何位置, 上报任何数据

缺点:

  1. 每一次对于埋点的修改, 都需要耗费研发的人力
  2. 产品 -> 文档 -> 研发, 比较低效 -> 发布上线

封装一套代码埋点的基础库, track.ts, npm, script

  1. 埋点的标识信息, [立即报名]按钮, '/click/report/buy', click, event

    eventId: 唯一的, 可以用来唯一标识一个埋点, 比如 'click/report/buy'

    eventType: 一个枚举, 列举出所有的埋点的类型, 比如 click 点击事件, event 曝光事件

  2. 业务自定义的信息, [立即报名]按钮, status: 未登录的时候上报 0, 已登录的时候上报 1, 已登录不符合 xxx 条件的时候上报 2

    report(eventId, options)
    options: {
    	eventType,
    	[key: string]: string | number | boolean
    }
  3. 通用的参数通常都有什么呢?

    • userId, 已登录
    • useragent, 浏览器版本; 安卓/iOS; 系统版本; 手机型号; 当前所在的 app 的特殊标识; NetType
    • deviceId, cookie 里种一个 deviceId, 设置一个超长几乎永远不会过期的过期时间
    • timestamp
    • location, 当前页面的路径
    • refer 当前页面是从哪里来的

怎么上报?

  1. 实时上报, 业务方调用发送埋点的 api 后, 立即发出上报请求

  2. 延时上报, sdk 收集要上报的信息, 在浏览器空闲时间或者页面卸载前统一上报, 上报失败会做一些补偿措施

    pool -> ['/click/report/buy'] -> ['/click/report/buy']

实现
typescript
import {queryString} from "query-string";
import {v4 as uuid} from "uuid";

type Params = {
    [key: string]: any;
};

let image: HTMLImageElement | null = null;

interface StorageType {
    eventId: string;
    data: Params;
}

/*
上报埋点的时候, 正常调用 track
如果失败了, 在页面卸载的时候, 会自动尝试重新上报
window.onload && beforeEach, beforeRouterEnter
 */

class TrackStorage {
    private static readonly STORAGE_KEY = "track";

    public static addStorageEvent(eventId: string, data: Params) {
        const existEvents = this.getStorageEvents();
        this.setStorage(JSON.stringify(existEvents.concat({eventId, data})));
    }

    public static getStorageEvents() {
        const storage = localStorage.getItem(TrackStorage.STORAGE_KEY);
        return (storage ? JSON.parse(storage) : []) as StorageType[];
    }

    public static removeStorageEvent(eventId: string) {
        const existEvents = this.getStorageEvents();
        const newEvents = existEvents.filter(event => event.eventId !== eventId);
        this.setStorage(JSON.stringify(newEvents));
    }

    public static getTraceId() {
        try {
            const traceKey = "trace_id";
            let traceId = localStorage.getItem(traceKey);
            if (!traceId) {
                traceId = uuid();
                localStorage.setItem(traceKey, traceId);
            }
            return traceId;
        } catch (e) {
            return "";
        }
    }

    private static setStorage(value: string) {
        localStorage.setItem(TrackStorage.STORAGE_KEY, value);
    }
}

export class BaseTrack {

    public static init() {
        if (window.addEventListener) {
            window.addEventListener("beforeunload", this.reportAllStorageEvent, false);
        } else if ((window as any).attachEvent) {
            (window as any).attachEvent("onbeforeunload", this.reportAllStorageEvent);
        }
    }

    public static track(eventId: string, params: Params, store: boolean = true) {
        const qs = queryString.stringify({
            timestamp: Date.now(),
            traceId: TrackStorage.getTraceId(),
            url: location.href,
            userId: this.getCookie("userId"),
            ...params
        });

        if (store) {
            const uniqueEventId = `${uuid()}_${eventId}`;
            TrackStorage.addStorageEvent(uniqueEventId, params);
            this.reportByImg(uniqueEventId, qs);
        } else {
            this.reportByImg(eventId, qs);
        }

        const uniqueEventId = `${uuid()}_${eventId}`;
        TrackStorage.addStorageEvent(uniqueEventId, params);
        this.reportByImg(uniqueEventId, qs);
    }

    private static serverUrl = "";

    private static reportByImg(uniqueEventId: string, qs: string, retryTimes: number = 3) {
        const eventId = uniqueEventId.split("_")[1];
        const retry = () => {
            image = null;
            if (retryTimes > 0) {
                this.reportByImg(uniqueEventId, qs, retryTimes - 1);
            }
        };

        return new Promise(resolve => {
            try {
                image = new Image();
                image.src = this.serverUrl + qs + "&eventId=" + eventId;
                image.onload = () => {
                    TrackStorage.removeStorageEvent(uniqueEventId);
                };
                image.onerror = () => {
                    retry();
                };
            } catch (e) {
                console.error(e);
            }
        });
    }

    private static getCookie(key: string) {
        if (document) {
            const list = document.cookie.match(new RegExp("(?:^| )" + encodeURIComponent(key) + "=([^;]+)"));
            return list && decodeURIComponent(list[1]);
        } else {
            return "";
        }
    }

    private static reportAllStorageEvent() {
        const storageEvents = TrackStorage.getStorageEvents();
        storageEvents.forEach(event => {
            this.track(event.eventId, event.data, false);
        });
    }
}

无埋点

GrowingIO 官网 - 国内领先的一站式数据增长引擎整体方案服务商

诸葛io,深入业务场景的用户行为数据分析 (zhugeio.com)

神策数据|大数据分析与营销科技解决方案服务商 (sensorsdata.cn)

不需要研发去手动埋点

监听页面上的所有事件

xPath

概念

iframe 嵌套了你的业务页面

错误监控: sentry

前端会引入各种第三方资源, 上线

性能监控: tp99, tp90

实现
js
function getXpath(element) {
    /* 如果元素有 id 属性, 直接返回 //*[@id="xxx"] */
    if (element.id) {
        return `//*[@id="${element.id}"]`;
    }
    
    /* 向上查找到 body, 结束查找, 直接返回 */
    if (element === document.body) {
        return `/html/${element.tagName.toLowerCase()}`;
    }
    
    let currentIndex = 1, siblings = element.parentNode.childNodes;
    
    for (let sibling of siblings) {
        if (sibling === element) {
            /* 确定了当前元素在兄弟节点中的索引, 继续向上查找 */
            return getXpath(element.parentNode) + "/" + element.tagName.toLowerCase() + `[${currentIndex}]`;
        } else if (sibling.tagName === element.tagName) {
            /* 继续寻找当前元素在兄弟节点中的索引 */
            currentIndex++;
        }
    }
}
js
function getOffset(event) {
    const rect = getBoundingClientRect(event.target);
    if (rect.width == 0 || rect.height == 0) {
        return;
    }
    
    const scrollX = document.scrollLeft;
    const scrollY = document.scrollTop;
    
    const pageX = event.pageX + scrollX;
    const pageY = event.pageY + scrollY;
    
    return {
        offsetX: ((pageX - rect.left - scrollX) / rect.width).toFixed(4),
        offsetY: ((pageY - rect.top - scrollY) / rect.height).toFixed(4
    }
}
js
window.addEventlistener("click", function (evnet) {
    const target = event.target;
    const xPath = getXpath(target);
    const offset = getOffset(event);
    
    track(xxxxx);
}, true);

列表无限滚动方案

首先要想到两点:

  1. 下拉到底, 继续加载数据并且拼接
  2. 数据太多, 要做虚拟列表的展示

虚拟列表

只加载可视区域内需要渲染的列表项.

监听滚动事件, 渲染出需要渲染的列表项, 并将非可视区域内的列表项删除

  1. 计算当前可视区域起始数据索引
  2. 计算当前可视区域结束数据索引
  3. 计算当前可是区域内的所有数据, 并渲染到页面中
  4. 计算起始索引对应的数据在整个列表中的偏移位置, 并且设置到列表上
滚动

容器 + 撑开高度的元素 + 真正展示内容的元素

监听滚动

scrollTop

得出最终想要的数据

列表总高度: listHeight = listData.length * itemSize

可显示的列表项数: visibleCount = Math.ceil(screenHeight / itemSize)

数据的起始索引: startIndex = Math.floor(scrollTop / itemSize)

数据的结束索引: endIndex = startIndex + visibleCount

真正展示的列表数据: listData.slice(startIndex, endIndex)

无限滚动

在滚动的监听事件里, 去判断 end 现在是否已经到达了所有数据的末尾

重新获取新的数据, 拼接到 listData 上

代码

vue
<template>
    <div class="infinite-list-container" ref="list" @scroll="scrollEvent">
        <div class="scrollTopBtn" @click="scrollToTop" v-show="end > 20">
            回到顶部
        </div>

        <div class="infinite-list-phantom" :style="{height: listHeight + 'px'}"></div>
        <div class="infinite-list" :style="{transform: getTransform}">
            <div
                class="infinite-list-item"
                v-for="item in visibleData"
                :key="item.id"
                :style="{height: itemSize + 'px', lineHeight: itemSize + 'px'}"
            >
                <div class="left-section">
                    {{ item.title[0] }}
                </div>
                <div class="right-section">
                    <div class="title">
                        {{ item.title }}
                    </div>
                    <div class="content">
                        {{ item.content }}
                    </div>
                </div>
            </div>
        </div>
</template>

<script lang="ts">
import {Vue, Component, Prop, Watch} from 'vue-property-decorator';
import Faker from 'faker';

/*
* 1. 真正展示渲染元素的区域
* 2. 撑开高度的隐藏元素
* 3. 容器
*
* 可视区域的高度
* 列表每项的高度
* 列表数据
* 当前的滚动位置
*
* 列表总高度 done
* 可显示的列表数
*/

interface Data {
    title: string;
    content: string;
    id: number | string;
}

@Component
export default class VirtualList extends Vue {
    public readonly itemSize: number = 100;

    public listData: Data[] = [];

    public screenHeight: number = document.documentElement.clientHeight || document.body.clientHeight;

    /**
     * 可视区域内可显示的数量
     */
    public visibleCount: number = Math.ceil(this.screenHeight / this.itemSize);

    /**
     * 顶部偏移量
     */
    public startOffset: number = 0;

    /**
     * 起始索引
     */
    public start: number = 0;

    /**
     * 结束索引
     */
    public end: number = 0;

    $refs: {
        list: any
    }

    get visibleData() {
        return this.listData.slice(this.start, Math.min(this.end, this.listData.length));
    }

    get getTransform() {
        return `translate3d(0, ${this.startOffset}px, 0)`;
    }

    get listHeight() {
        return this.listData.length * this.itemSize;
    }

    public getTenListData() {
        if (this.listData.length > 200) {
            return [];
        }

        return new Array(10).fill({}).map(item => ({
            id: Faker.random.uuid(),
            title: Faker.name.title(),
            content: Faker.random.words()
        }));
    }

    public scrollToTop() {
        this.$refs.list.scrollTo({
            top: 0,
            left: 0,
            behavior: 'smooth'
        });
    }

    created() {
        this.listData = this.getTenListData();
    }

    scrollEvent(e: any) {
        /* 当前的滚动位置 */
        const scrollTop = this.$refs.list.scrollTop;
        this.start = Math.floor(scrollTop / this.itemSize);
        this.end = this.start + this.visibleCount;

        if (this.end > this.listData.length) {
            this.listData = this.listData.concat(this.getTenListData());
        }

        this.startOffset = scrollTop - (scrollTop % this.itemSize);
    }
}
</script>

<style>
</style>