blob: 3bd2e00f1f95330ad29ffa983314c73e57471c38 [file] [log] [blame]
/**
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ICalScore, ElementList } from './type';
const getStyle = (element: Element | any, attr: any) => {
if (window.getComputedStyle) {
return window.getComputedStyle(element, null)[attr];
} else {
return element.currentStyle[attr];
}
};
// element weight for calculate score
enum ELE_WEIGHT {
SVG = 2,
IMG = 2,
CANVAS = 4,
OBJECT = 4,
EMBED = 4,
VIDEO = 4,
}
const START_TIME: number = performance.now();
const IGNORE_TAG_SET: string[] = ['SCRIPT', 'STYLE', 'META', 'HEAD', 'LINK'];
const LIMIT: number = 3000;
const WW: number = window.innerWidth;
const WH: number = window.innerHeight;
const DELAY: number = 500; // fmp retry interval
class FMPTiming {
public fmpTime: number = 0;
private statusCollector: Array<{ time: number }> = []; // nodes change time
private flag: boolean = true;
private observer: MutationObserver = null;
private callbackCount: number = 0;
private entries: any = {};
constructor() {
if (!performance || !performance.getEntries) {
console.log('your browser do not support performance.getEntries');
return;
}
this.initObserver();
}
private getFirstSnapShot() {
const time: number = performance.now();
const $body: HTMLElement = document.body;
if ($body) {
this.setTag($body, this.callbackCount);
}
this.statusCollector.push({
time,
});
}
private initObserver() {
this.getFirstSnapShot();
this.observer = new MutationObserver(() => {
this.callbackCount += 1;
const time = performance.now();
const $body: HTMLElement = document.body;
if ($body) {
this.setTag($body, this.callbackCount);
}
this.statusCollector.push({
time,
});
});
// observe all child nodes
this.observer.observe(document, {
childList: true,
subtree: true,
});
// calculate score when page loaded
if (document.readyState === 'complete') {
this.calculateFinalScore();
} else {
window.addEventListener(
'load',
() => {
this.calculateFinalScore();
},
false,
);
}
}
private calculateFinalScore() {
if (MutationEvent && this.flag) {
if (this.checkNeedCancel(START_TIME)) {
// cancel observer for dom change
this.observer.disconnect();
this.flag = false;
const res = this.getTreeScore(document.body);
let tp: ICalScore = null;
for (const item of res.dpss) {
if (tp && tp.st) {
if (tp.st < item.st) {
tp = item;
}
} else {
tp = item;
}
}
// Get all of soures load time
performance.getEntries().forEach((item: PerformanceResourceTiming) => {
this.entries[item.name] = item.responseEnd;
});
if (!tp) {
return false;
}
const resultEls: ElementList = this.filterResult(tp.els);
const fmpTiming: number = this.getFmpTime(resultEls);
this.fmpTime = fmpTiming;
} else {
setTimeout(() => {
this.calculateFinalScore();
}, DELAY);
}
}
}
private getFmpTime(resultEls: ElementList): number {
let rt = 0;
for (const item of resultEls) {
let time: number = 0;
if (item.weight === 1) {
const index: number = parseInt(item.ele.getAttribute('fmp_c'), 10);
time = this.statusCollector[index].time;
} else if (item.weight === 2) {
if (item.ele.tagName === 'IMG') {
time = this.entries[(item.ele as HTMLImageElement).src];
} else if (item.ele.tagName === 'SVG') {
const index: number = parseInt(item.ele.getAttribute('fmp_c'), 10);
time = this.statusCollector[index].time;
} else {
const match = getStyle(item.ele, 'background-image').match(/url\(\"(.*?)\"\)/);
let url: string = '';
if (match && match[1]) {
url = match[1];
}
if (!url.includes('http')) {
url = location.protocol + match[1];
}
time = this.entries[url];
}
} else if (item.weight === 4) {
if (item.ele.tagName === 'CANVAS') {
const index: number = parseInt(item.ele.getAttribute('fmp_c'), 10);
time = this.statusCollector[index] && this.statusCollector[index].time;
} else if (item.ele.tagName === 'VIDEO') {
time = this.entries[(item.ele as HTMLVideoElement).src];
if (!time) {
time = this.entries[(item.ele as HTMLVideoElement).poster];
}
}
}
if (typeof time !== 'number') {
time = 0;
}
if (rt < time) {
rt = time;
}
}
return rt;
}
/**
* The nodes with the highest score in the visible area are collected and the average value is taken,
* and the low score ones are eliminated
*/
private filterResult(els: ElementList): ElementList {
if (els.length === 1) {
return els;
}
let sum: number = 0;
els.forEach((item: any) => {
sum += item.st;
});
const avg: number = sum / els.length;
return els.filter((item: any) => {
return item.st > avg;
});
}
private checkNeedCancel(start: number): boolean {
const time: number = performance.now() - start;
const lastCalTime: number =
this.statusCollector.length > 0 ? this.statusCollector[this.statusCollector.length - 1].time : 0;
return time > LIMIT || time - lastCalTime > 1000;
}
private getTreeScore(node: Element): ICalScore | any {
if (!node) {
return {};
}
const dpss = [];
const children: any = node.children;
for (const child of children) {
// Only calculate marked elements
if (!child.getAttribute('fmp_c')) {
continue;
}
const s = this.getTreeScore(child);
if (s.st) {
dpss.push(s);
}
}
return this.calcaulteGrades(node, dpss);
}
private calcaulteGrades(ele: Element, dpss: ICalScore[]): ICalScore {
const { width, height, left, top } = ele.getBoundingClientRect();
let isInViewPort: boolean = true;
if (WH < top || WW < left) {
isInViewPort = false;
}
let sdp: number = 0;
dpss.forEach((item: any) => {
sdp += item.st;
});
let weight: number = Number(ELE_WEIGHT[ele.tagName as any]) || 1;
// If there is a common element of the background image, it is calculated according to the picture
if (
weight === 1 &&
getStyle(ele, 'background-image') &&
getStyle(ele, 'background-image') !== 'initial' &&
getStyle(ele, 'background-image') !== 'none'
) {
weight = ELE_WEIGHT.IMG;
}
// score = the area of element
let st: number = isInViewPort ? width * height * weight : 0;
let els = [{ ele, st, weight }];
const root = ele;
// The percentage of the current element in the viewport
const areaPercent = this.calculateAreaParent(ele);
// If the sum of the child's weights is greater than the parent's true weight
if (sdp > st * areaPercent || areaPercent === 0) {
st = sdp;
els = [];
for (const item of dpss) {
els = els.concat(item.els);
}
}
return {
dpss,
st,
els,
root,
};
}
private calculateAreaParent(ele: Element): number {
const { left, right, top, bottom, width, height } = ele.getBoundingClientRect();
const winLeft: number = 0;
const winTop: number = 0;
const winRight: number = WW;
const winBottom: number = WH;
const overlapX = right - left + (winRight - winLeft) - (Math.max(right, winRight) - Math.min(left, winLeft));
const overlapY = bottom - top + (winBottom - winTop) - (Math.max(bottom, winBottom) - Math.min(top, winTop));
if (overlapX <= 0 || overlapY <= 0) {
return 0;
}
return (overlapX * overlapY) / (width * height);
}
// Depth first traversal to mark nodes
private setTag(target: Element, callbackCount: number): void {
const tagName: string = target.tagName;
if (IGNORE_TAG_SET.indexOf(tagName) === -1) {
const $children: HTMLCollection = target.children;
if ($children && $children.length > 0) {
for (let i = $children.length - 1; i >= 0; i--) {
const $child: Element = $children[i];
const hasSetTag = $child.getAttribute('fmp_c') !== null;
// If it is not marked, whether the marking condition is met is detected
if (!hasSetTag) {
const { left, top, width, height } = $child.getBoundingClientRect();
if (WH < top || WW < left || width === 0 || height === 0) {
continue;
}
$child.setAttribute('fmp_c', `${callbackCount}`);
}
this.setTag($child, callbackCount);
}
}
}
}
}
export default FMPTiming;