大厂常见问题解决方案
可视区域判断
// 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");
}
});
});数据埋点方案、监控方案
代码埋点
优点:
- 灵活, 因为是代码埋点, 可以手动写到代码里的任何位置, 上报任何数据
缺点:
- 每一次对于埋点的修改, 都需要耗费研发的人力
- 产品 -> 文档 -> 研发, 比较低效 -> 发布上线
封装一套代码埋点的基础库, track.ts, npm, script
埋点的标识信息, [立即报名]按钮, '/click/report/buy', click, event
eventId: 唯一的, 可以用来唯一标识一个埋点, 比如 'click/report/buy'
eventType: 一个枚举, 列举出所有的埋点的类型, 比如 click 点击事件, event 曝光事件
业务自定义的信息, [立即报名]按钮, status: 未登录的时候上报 0, 已登录的时候上报 1, 已登录不符合 xxx 条件的时候上报 2
report(eventId, options) options: { eventType, [key: string]: string | number | boolean }通用的参数通常都有什么呢?
- userId, 已登录
- useragent, 浏览器版本; 安卓/iOS; 系统版本; 手机型号; 当前所在的 app 的特殊标识; NetType
- deviceId, cookie 里种一个 deviceId, 设置一个超长几乎永远不会过期的过期时间
- timestamp
- location, 当前页面的路径
- refer 当前页面是从哪里来的
怎么上报?
实时上报, 业务方调用发送埋点的 api 后, 立即发出上报请求
延时上报, sdk 收集要上报的信息, 在浏览器空闲时间或者页面卸载前统一上报, 上报失败会做一些补偿措施
pool -> ['/click/report/buy'] -> ['/click/report/buy']
实现
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
实现
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++;
}
}
}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
}
}window.addEventlistener("click", function (evnet) {
const target = event.target;
const xPath = getXpath(target);
const offset = getOffset(event);
track(xxxxx);
}, true);列表无限滚动方案
首先要想到两点:
- 下拉到底, 继续加载数据并且拼接
- 数据太多, 要做虚拟列表的展示
虚拟列表
只加载可视区域内需要渲染的列表项.
监听滚动事件, 渲染出需要渲染的列表项, 并将非可视区域内的列表项删除
- 计算当前可视区域起始数据索引
- 计算当前可视区域结束数据索引
- 计算当前可是区域内的所有数据, 并渲染到页面中
- 计算起始索引对应的数据在整个列表中的偏移位置, 并且设置到列表上
滚动
容器 + 撑开高度的元素 + 真正展示内容的元素
监听滚动
scrollTop
得出最终想要的数据
列表总高度: listHeight = listData.length * itemSize
可显示的列表项数: visibleCount = Math.ceil(screenHeight / itemSize)
数据的起始索引: startIndex = Math.floor(scrollTop / itemSize)
数据的结束索引: endIndex = startIndex + visibleCount
真正展示的列表数据: listData.slice(startIndex, endIndex)
无限滚动
在滚动的监听事件里, 去判断 end 现在是否已经到达了所有数据的末尾
重新获取新的数据, 拼接到 listData 上
代码
<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>