split-time
Version:
A JavaScript library for measuring FCP, LCP. Report real user measurements to tracking tool.
340 lines (285 loc) • 7.81 kB
text/typescript
interface LargestContentfulPaint extends PerformanceEntry {
readonly duration: number
element: HTMLElement
readonly entryType: string
id: string
loadTime: number
readonly name: string
renderTime: number
size: number
startTime: number
url: string
}
const MutationObserver = self.MutationObserver || self.WebKitMutationObserver
const TAG_WEIGHT_MAP = {
SVG: 2,
IMG: 2,
CANVAS: 4,
OBJECT: 4,
EMBED: 4,
VIDEO: 4
}
const WW = self.innerWidth
const WH = self.innerHeight
function getStyle (element, att) {
return self.getComputedStyle(element)[att]
}
export const observe = (): Promise<Array<LargestContentfulPaint>> => {
const endpoints = []
const mp = {}
let options = {}
let LCPResolver = null
let mutationCount = 0
let scheduleTimerTasks = false
let isChecking = false
let timerId = null
const mutationObserver = new MutationObserver((mutations) => {
getSnapshot()
})
mutationObserver.observe(document, { childList: true, subtree: true })
function getSnapshot () {
const { navigationStart } = self.performance.timing
const timestamp = Date.now() - navigationStart
setMutationRecord(document, mutationCount++)
endpoints.push({ timestamp })
}
function setMutationRecord (target, mutationCount) {
const tagName = target.tagName
const IGNORE_TAG_SET = new Set(['META', 'LINK', 'STYLE', 'SCRIPT', 'NOSCRIPT'])
if (!IGNORE_TAG_SET.has(tagName) && target.children) {
for (let child = target.children, i = target.children.length - 1; i >= 0; i--) {
if (child[i].getAttribute('mr_c') === null) {
child[i].setAttribute('mr_c', mutationCount)
}
setMutationRecord(child[i], mutationCount)
}
}
}
function ifRipe (start) {
const LIMIT = 1000
const ti = Date.now() - start
return ti > LIMIT || ti - (endpoints[endpoints.length - 1].timestamp) > 1000
}
function rescheduleTimer () {
const { navigationStart } = self.performance.timing
const DELAY = 500
if (!scheduleTimerTasks) return
if (!isChecking && ifRipe(navigationStart)) {
checkLCP()
} else {
clearTimeout(timerId)
timerId = setTimeout(() => {
rescheduleTimer()
}, DELAY)
}
}
function checkLCP () {
isChecking = true
let res = deepTraversal(document.body)
let tp
res.dpss.forEach(item => {
if (tp && tp.st) {
if (tp.st < item.st) {
tp = item
}
} else {
tp = item
}
})
initResourceMap()
let resultSet = filterTheResultSet(tp.els)
let LCPOptions = calResult(resultSet)
LCPResolver([new LargestContentfulPaint(LCPOptions)])
disable()
}
function deepTraversal (node) {
if (node) {
let dpss = []
for (let i = 0, child; (child = node.children[i]); i++) {
let s = deepTraversal(child)
if (s.st) {
dpss.push(s)
}
}
return calScore(node, dpss)
}
return {}
}
function calScore (node, dpss) {
let {
width,
height,
left,
top,
bottom,
right
} = node.getBoundingClientRect()
let f = 1
if (WH < top || WW < left) {
// 不在可视viewport中
f = 0
}
let sdp = 0
dpss.forEach(item => {
sdp += item.st
})
let weight = TAG_WEIGHT_MAP[node.tagName] || 1
if (
weight === 1 &&
getStyle(node, 'background-image') &&
getStyle(node, 'background-image') !== 'initial' &&
getStyle(node, 'background-image') !== 'none'
) {
weight = TAG_WEIGHT_MAP['IMG'] // 将有图片背景的普通元素 权重设置为img
}
let st = width * height * weight * f
let els = [{ node, st, weight }]
let areaPercent = calAreaPercent(node)
if (sdp > st * areaPercent || areaPercent === 0) {
st = sdp
els = []
dpss.forEach(item => {
els = els.concat(item.els)
})
}
return {
dpss,
st,
els
}
}
function calAreaPercent (node) {
let {
left,
right,
top,
bottom,
width,
height
} = node.getBoundingClientRect()
let wl = 0
let wt = 0
let wr = WW
let wb = WH
let overlapX =
right - left + (wr - wl) - (Math.max(right, wr) - Math.min(left, wl))
if (overlapX <= 0) {
// x 轴无交点
return 0
}
let overlapY =
bottom - top + (wb - wt) - (Math.max(bottom, wb) - Math.min(top, wt))
if (overlapY <= 0) {
return 0
}
return (overlapX * overlapY) / (width * height)
}
function initResourceMap () {
self.performance.getEntries().forEach(item => {
mp[item.name] = item.responseEnd
})
}
function filterTheResultSet (els) {
let sum = 0
els.forEach(item => {
sum += item.st
})
let avg = sum / els.length
return els.filter(item => {
return item.st >= avg
})
}
function calResult (resultSet) {
let result = null
let rt = 0
resultSet.forEach(item => {
let t = 0
if (item.weight === 1) {
let index = +item.node.getAttribute('mr_c') - 1
t = endpoints[index].timestamp
} else if (item.weight === 2) {
if (item.node.tagName === 'IMG') {
t = mp[item.node.src]
} else if (item.node.tagName === 'SVG') {
let index = +item.node.getAttribute('mr_c') - 1
t = endpoints[index].timestamp
} else {
// background image
let match = getStyle(item.node, 'background-image').match(/url\(\"(.*?)\"\)/)
let s
if (match && match[1]) {
s = match[1]
}
if (s.indexOf('http') == -1) {
s = location.protocol + match[1]
}
t = mp[s]
}
} else if (item.weight === 4) {
if (item.node.tagName === 'CANVAS') {
let index = +item.node.getAttribute('mr_c') - 1
t = endpoints[index].timestamp
} else if (item.node.tagName === 'VIDEO') {
t = mp[item.node.src]
!t && (t = mp[item.node.poster])
}
}
rt < t && (rt = t, result = item)
})
options = {
element: result.node,
id: result.node.id || '',
renderTime: rt,
size: result.st,
startTime: endpoints[+result.node.getAttribute('mr_c') - 1].timestamp,
url: result.node.src || ''
}
return options
}
function disable () {
clearTimeout(timerId)
scheduleTimerTasks = false
unregisterListeners()
}
function unregisterListeners () {
if (mutationObserver) mutationObserver.disconnect()
}
return new Promise((resolve, reject) => {
LCPResolver = resolve
if (document.readyState === 'complete') {
scheduleTimerTasks = true
rescheduleTimer()
} else {
document.addEventListener('readystatechange', () => {
if (document.readyState === 'complete') {
scheduleTimerTasks = true
rescheduleTimer()
}
}, true)
}
})
}
class LargestContentfulPaint implements LargestContentfulPaint {
readonly duration: number = 0
public element: HTMLElement
readonly entryType: string = 'largest-contentful-paint'
public id: string = ''
public loadTime: number = 0
readonly name: string = ''
public renderTime: number = 0
public size: number = 0
public startTime: number = 0
public url: string = ''
public constructor (options) {
this.element = options.element
this.id = options.id
this.loadTime = options.loadTime || 0
this.renderTime = options.renderTime
this.startTime = options.startTime
this.url = options.url || ''
}
public toJSON(): string {
return JSON.stringify(this)
}
}
export default LargestContentfulPaint