如何实现全局图片监控

为什么要做这个?

  1. 图片过大占用 CDN 资源
  2. 拖慢加载速度,体验不好

要怎么做?

PerformanceObserver 可以获取已缓存图片的 entry 信息,多个相同请求 entry 只会报告一次,能够拿到 decodedBodySize。但在跨域且未使用 Timing-Allow-Origin ​HTTP 相应标头情况下,这个值为 0 。

主要拿到图片的原始宽高和图片显示的实际宽高,超出一定比例,图片大小超过一定阈值(比如 1M),基本可以判断图片不太符合规范,上报该数据。上报的数据可以是图片的 DOM 路径,用于定位排查。

实现思路

针对不同情况,有不同的监控手段

一些工具函数

const getNodeKey = (src: string, path: string[]) => `${src}::${path.join('/')}`;
const getNodeName = (node: Node) => node.nodeName?.toLowerCase() ?? 'unknown';

const isElement = (node: any): node is Element => !!(node.tagName && node.classList);
const isHTMLImageElement = (node: Node): node is HTMLImageElement => getNodeName(node) === 'img';

const getNodePath = (node: Node, path: string[] = []): string[] => {
if (!isElement(node)) {
return path;
}

const nodeName = getNodeName(node);
const { id } = node;
const { className } = node;

const key = `${nodeName}${id ? `#${id}` : ''}${className ? `.${className}` : ''}`;
path.push(key);
return node.parentElement ? getNodePath(node.parentElement, path) : path;
};

利用 PerformanceObserver 获取图片大小

const perfWatchSet = new Set(['img', 'css', 'body']);
const perfObserver = new PerformanceObserver(
list => {
const entries = list.getEntries();
for (let index = 0, len = entries.length; index < len; index++) {
const entry = entries[index] as PerformanceResourceTiming;
const { initiatorType, encodedBodySize, decodedBodySize, transferSize, name } = entry;
const src = filterImgSrc(name);
if (perfWatchSet.has(initiatorType) && src && decodedBodySize > 0) {
perfEntries.set(src, entry);
if (transferSize === 0 && encodedBodySize > 0) {
// 处理逻辑
}
},
});

// 浏览器默认是250,不设置大点前面的会被丢弃,监听不到
performance.setResourceTimingBufferSize(2000);
perfObserver.observe({ type: 'resource', buffered: true });

场景一:在 HTML DOM 上的 <img> 标签

分为初始处理和增量处理


// 工具函数
const getImgSrc = (node: HTMLImageElement) => node.src;
const getBgSrc = (node: Element): string => {
const { backgroundImage } = window.getComputedStyle(node);
return ((backgroundImage && regex4BgImage.exec(backgroundImage)) || [])[1] || '';
};

const handleNode = (node: Node) => {
// 处理 img.src
handleImageElements(node);
// 处理backgorundimg style
handleBgImageElements(node);
};

const visitedNodeSet = new WeakSet<Node>();
const handleNodes = (nodeList: ArrayLike<Node>) => {
for (let index = 0, len = nodeList.length; index < len; index++) {
const node = nodeList[index];

if (visitedNodeSet.has(node)) {
continue;
}

visitedNodeSet.add(node);

if (isElement(node)) {
handleNodes(node.children);
handleNode(node);
}
}
}

// 1、初始处理
handleNodes([document.documentElement]);

// 2、增量处理
const observer = new MutationObserver(
(mutations: MutationRecord[])=> {
for (let index = 0, len = mutations.length; index < len; index++) {
const mutation = mutations[index];
handleNodes(mutation.addedNodes);
}
});

observer.observe(document.documentElement, { attributes: false, childList: true, subtree: true });

还有 img.src 属性变化的情况,也需要用 MutationObserver 监听处理一下

// 省略处理流程
const imgSrcObserver = new MutationObserver(() => {});

const handleImageElements = (node: Node) => {
if (isHTMLImageElement(node)) {
const src = filterImgSrc(getImgSrc(node));
if (src) {
imgByImageElement.push({ node, src });
}
imgSrcObserver.observe(node, { attributeFilter: ['src'] });
//
}
};

场景二:添加到 HTML DOM 上的有 backgroundImage 的标签

const handleBgImageElements = (node: Node) => {
if (isElement(node)) {
const src = filterImgSrc(getBgSrc(node));
if (src) {
imgByBgImageElement.push({ src, node });
}
//
}
};

// 其他代码参考上面场景一

场景三: 使用 API 动态创建

拦截并重写 原生方法

const oCreateElement = document.createElement.bind(document);
document.createElement = function (tagName: string, options?: ElementCreationOptions) {
const newElement = oCreateElement(tagName, options);

if (isHTMLImageElement(newElement)) {
handleLoaded(newElement, 'createElement');
}

return newElement;
};

const oCreateElementNS = document.createElementNS.bind(document);
document.createElementNS = function (
namespaceURI: string,
qualifiedName: string,
options?: ElementCreationOptions,
) {
const newElement = oCreateElementNS(namespaceURI, qualifiedName, options);

if (isHTMLImageElement(newElement)) {
handleLoaded(newElement, 'createElementNS');
}

return newElement;
} as typeof document.createElementNS;

const oImage = window.Image;
window.Image = function (width?: number, height?: number) {
const newImage: HTMLImageElement = new oImage(width, height);
handleLoaded(newImage, 'Image');

return newImage;
} as unknown as typeof window.Image;

// Preserve static properties
Object.assign(window.Image, oImage);

handleLoaded

 function handleLoaded(node: HTMLImageElement, sourceFrom: string) {
const loadListener = (event: Event) => {
onLoaded(event, sourceFrom);
};

const errorListener = () => {
//
};

node.addEventListener('load', loadListener, { once: true });
node.addEventListener('error', errorListener, { once: true });
}

以上是基本框架,还有一些上报逻辑,缓存清理逻辑需要补充,完成后便可以得到一个全局的图片监控。

性能

因为全局的监听图片的使用,拿图片的 width、height 还有 backgroundImage 等信息时会强制触发重排重绘,会影响到加载和操作性能,所以不能大批量的全部监控,可以每天挑选部分高性能用户开启,降低影响面。