UNPKG

@inchill/time-to-interactive

Version:

Easily measure performance metrics of time to interactive in JavaScript.

309 lines (302 loc) 9.85 kB
'use strict'; function _toConsumableArray(arr) { return _arrayWithoutHoles(arr) || _iterableToArray(arr) || _unsupportedIterableToArray(arr) || _nonIterableSpread(); } function _arrayWithoutHoles(arr) { if (Array.isArray(arr)) return _arrayLikeToArray(arr); } function _iterableToArray(iter) { if (typeof Symbol !== "undefined" && iter[Symbol.iterator] != null || iter["@@iterator"] != null) return Array.from(iter); } function _unsupportedIterableToArray(o, minLen) { if (!o) return; if (typeof o === "string") return _arrayLikeToArray(o, minLen); var n = Object.prototype.toString.call(o).slice(8, -1); if (n === "Object" && o.constructor) n = o.constructor.name; if (n === "Map" || n === "Set") return Array.from(o); if (n === "Arguments" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) return _arrayLikeToArray(o, minLen); } function _arrayLikeToArray(arr, len) { if (len == null || len > arr.length) len = arr.length; for (var i = 0, arr2 = new Array(len); i < len; i++) arr2[i] = arr[i]; return arr2; } function _nonIterableSpread() { throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } function _createForOfIteratorHelper(o, allowArrayLike) { var it = typeof Symbol !== "undefined" && o[Symbol.iterator] || o["@@iterator"]; if (!it) { if (Array.isArray(o) || (it = _unsupportedIterableToArray(o)) || allowArrayLike && o && typeof o.length === "number") { if (it) o = it; var i = 0; var F = function () {}; return { s: F, n: function () { if (i >= o.length) return { done: true }; return { done: false, value: o[i++] }; }, e: function (e) { throw e; }, f: F }; } throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } var normalCompletion = true, didErr = false, err; return { s: function () { it = it.call(o); }, n: function () { var step = it.next(); normalCompletion = step.done; return step; }, e: function (e) { didErr = true; err = e; }, f: function () { try { if (!normalCompletion && it.return != null) it.return(); } finally { if (didErr) throw err; } } }; } /** * Entry type name of fcp. */ var ENTRY_NAME_FCP = 'first-contentful-paint'; /** * Minimum quiet window duration(At least 5 seconds). */ var QUIET_WINDOW_DURATION = 5000; /** * TTI scores. */ var RATING = { mobile: { scoring: { p10: 3785, median: 7300 } }, desktop: { scoring: { p10: 2468, median: 4500 } } }; /** * Performantly generate a unique, 30-char string by combining a version * number, the current timestamp with a 13-digit number integer. * @return {string} */ var generateUniqueID = function generateUniqueID() { return "v3-".concat(Date.now(), "-").concat(Math.floor(Math.random() * (9e12 - 1)) + 1e12); }; var initMetric = function initMetric(name, value) { return { name: name, value: typeof value === 'undefined' ? -1 : value, rating: getRating(0), id: generateUniqueID() }; }; /** * * @param value number * @returns 'good' | 'needs-improvement' | 'poor' */ var getRating = function getRating(value) { if (value > RATING.mobile.scoring.median) { return 'poor'; } if (value > RATING.mobile.scoring.p10) { return 'needs-improvement'; } return 'good'; }; /** * Check if is's Safari browser. * @returns boolean */ function isSafari() { return /Safari/.test(navigator.userAgent) && !/Chrome/.test(navigator.userAgent); } var uniqueId = 0; /** * Override the open and send methods of XMLHttpRequest to track XHR requests * @param beforeRequestCb function * @param onRequestCompletedCb function */ var patchXMLHTTPRequest = function patchXMLHTTPRequest(beforeRequestCb, onRequestCompletedCb) { var open = window.XMLHttpRequest.prototype.open; window.XMLHttpRequest.prototype.open = function () { var requestId = uniqueId++; beforeRequestCb(requestId); this.addEventListener('loadend', function () { onRequestCompletedCb(requestId); }); for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) { args[_key] = arguments[_key]; } open.apply(this, [].concat(args)); }; }; /** * Override the Fetch function to track Fetch requests * @param beforeRequestCb function * @param onRequestCompletedCb function */ var patchFetch = function patchFetch(beforeRequestCb, onRequestCompletedCb) { var fetch = window.fetch; window.fetch = function () { var requestId = uniqueId++; beforeRequestCb(requestId); for (var _len2 = arguments.length, args = new Array(_len2), _key2 = 0; _key2 < _len2; _key2++) { args[_key2] = arguments[_key2]; } return fetch.apply(this, [].concat(args)).then(function (response) { onRequestCompletedCb(requestId); return response; }); }; }; /** * Calculates the [TTI](https://web.dev/tti/) value for the current page and * calls the `callback` function once the value is ready, along with the * relevant `paint` performance entry used to determine the value. The reported * value is a `DOMHighResTimeStamp`. */ var onTTI = function onTTI(onReport) { if (isSafari()) return; var fcpStartTime = 0; var longTasks = []; var networks = []; var quietWindowTimer; var metric = initMetric('TTI'); var inProgressRequestStartTimes = new Map(); /** * Mark active network requests. * @param requestId number */ var beforeRequestCallback = function beforeRequestCallback(requestId) { inProgressRequestStartTimes.set(requestId, performance.now()); }; /** * Remove finished network requests. * @param requestId number */ var afterRequestCallback = function afterRequestCallback(requestId) { inProgressRequestStartTimes["delete"](requestId); }; // Patch XHR and Fetch API to detect in-flight network requests. patchXMLHTTPRequest(beforeRequestCallback, afterRequestCallback); patchFetch(beforeRequestCallback, afterRequestCallback); /** * Track longtask and networks and check quiet window. * @param entries PerformanceObserverEntryList */ var handleEntries = function handleEntries(entries) { var _iterator = _createForOfIteratorHelper(entries.getEntries()), _step; try { for (_iterator.s(); !(_step = _iterator.n()).done;) { var entry = _step.value; if (entry.entryType === 'paint' && entry.name === ENTRY_NAME_FCP) { fcpStartTime = entry.startTime; } // Longtasks could not be detected in Safari. if (entry.entryType === 'longtask') { longTasks.push(entry); checkQuietWindow(); } if (entry.entryType === 'resource') { networks.push(entry); checkQuietWindow(); } } } catch (err) { _iterator.e(err); } finally { _iterator.f(); } }; /** * Register to observe. */ var observer = new PerformanceObserver(handleEntries); observer.observe({ entryTypes: ['paint', 'longtask', 'resource'] }); // There are some cases need to be handled. // 1. There are no long tasks after fcp but there are network requests. // 2. There are neither long tasks nor network requests after fcp // In such case we need onload event to handle tti. // 3. After fcp, there are both long tasks and network requests. // 4. There are long tasks after fcp but no network requests. var checkQuietWindow = function checkQuietWindow() { // Filter longtasks after fcp. var longTasksAfterFcp = longTasks.filter(function (item) { return item.startTime >= fcpStartTime; }); // If there are no longtasks after fcp, start the quiet window timer // and count the number of in-flight network requests for at least 5 seconds. if (longTasksAfterFcp.length === 0) { startQuietWindowTimer(fcpStartTime); } else { // Get the last longtask. var lastLongTask = longTasksAfterFcp[longTasksAfterFcp.length - 1]; // Turn on quiet window detection using the latest long task end time as the start time. startQuietWindowTimer(lastLongTask.startTime + lastLongTask.duration); } }; /** * Start quiet window detection timer. */ function startQuietWindowTimer(startTime) { // Clear the last detection timer. clearQuietWindowTimer(); quietWindowTimer = window.setTimeout(function () { checkTTI(startTime); }, QUIET_WINDOW_DURATION); } var checkTTI = function checkTTI(startTime) { // Count the number of in-flight network requests in 5 seconds. var inProgressRequestStarts = _toConsumableArray(inProgressRequestStartTimes.values()); // If the number of network requests is less than or equal to 2, // find the nearest long task before the quiet window. if (inProgressRequestStarts.length <= 2) { // If no longtask is found, the value of TTI is equal to FCP. // If find the nearest long task before the quiet window, and // its endTime is equal to TTI. metric.value = startTime; // Set the rating value based on the obtained tti time. metric.rating = getRating(metric.value); // Call the onReport callback if the quiet window was found. onReport(metric); // After reporting disconnect the PerformanceObserver. observer.disconnect(); clearQuietWindowTimer(); } }; // Clear timer. function clearQuietWindowTimer() { clearTimeout(quietWindowTimer); } }; exports.onTTI = onTTI;