| /** |
| * 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 = 2000; // 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, |
| }); |
| this.calculateFinalScore(); |
| } |
| private calculateFinalScore() { |
| if (!this.flag) { |
| return; |
| } |
| if (!MutationObserver) { |
| return; |
| } |
| 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] && 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] && 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; |