metric: first meaningful paint 1.0 (#323)
Port the first meaningful paint metric from ksakamoto's ruby impl The metric is detailed and spec'd in https://docs.google.com/document/d/1BR94tJdZLsin5poeet0XoTW60M0SjvOJQttKT-JK8HI/edit
This commit is contained in:
Родитель
19e07b2317
Коммит
f69dec55fd
|
@ -58,7 +58,7 @@ Output:
|
|||
Example: --output-path=./lighthouse-results.html
|
||||
`);
|
||||
|
||||
const url = cli.input[0] || 'https://pwa.rocks/';
|
||||
const url = cli.input[0] || 'https://platform-status.mozilla.org/';
|
||||
const outputMode = cli.flags.output || Printer.OUTPUT_MODE.pretty;
|
||||
const outputPath = cli.flags.outputPath || 'stdout';
|
||||
const flags = cli.flags;
|
||||
|
|
|
@ -31,8 +31,7 @@ gulp.task('js-compile', function() {
|
|||
'closure/third_party/*.js',
|
||||
'audits/**/*.js',
|
||||
'lib/icons.js',
|
||||
'aggregators/**/*.js',
|
||||
'metrics/first-meaningful-paint.js'
|
||||
'aggregators/**/*.js'
|
||||
])
|
||||
// TODO: hack to remove `require`s that Closure currently can't resolve.
|
||||
.pipe(replace(devtoolsRequire, ''))
|
||||
|
|
|
@ -10,6 +10,7 @@
|
|||
},
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"extension"
|
||||
"extension",
|
||||
"third_party"
|
||||
]
|
||||
}
|
||||
|
|
|
@ -17,10 +17,11 @@
|
|||
|
||||
'use strict';
|
||||
|
||||
const FMPMetric = require('../../metrics/first-meaningful-paint');
|
||||
const Audit = require('../audit');
|
||||
const TracingProcessor = require('../../lib/traces/tracing-processor');
|
||||
|
||||
const FAILURE_MESSAGE = 'Navigation and first paint timings not found.';
|
||||
|
||||
// Parameters (in ms) for log-normal CDF scoring. To see the curve:
|
||||
// https://www.desmos.com/calculator/joz3pqttdq
|
||||
const SCORING_POINT_OF_DIMINISHING_RETURNS = 1600;
|
||||
|
@ -45,7 +46,7 @@ class FirstMeaningfulPaint extends Audit {
|
|||
* @override
|
||||
*/
|
||||
static get description() {
|
||||
return 'First paint of content';
|
||||
return 'First meaningful paint';
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -57,48 +58,184 @@ class FirstMeaningfulPaint extends Audit {
|
|||
|
||||
/**
|
||||
* Audits the page to give a score for First Meaningful Paint.
|
||||
* @see https://github.com/GoogleChrome/lighthouse/issues/26
|
||||
* @see https://github.com/GoogleChrome/lighthouse/issues/26
|
||||
* @see https://docs.google.com/document/d/1BR94tJdZLsin5poeet0XoTW60M0SjvOJQttKT-JK8HI/view
|
||||
* @param {!Artifacts} artifacts The artifacts from the gather phase.
|
||||
* @return {!AuditResult} The score from the audit, ranging from 0-100.
|
||||
*/
|
||||
static audit(artifacts) {
|
||||
return FMPMetric.parse(artifacts.traceContents)
|
||||
.then(fmp => {
|
||||
// The fundamental Time To fMP metric
|
||||
const firstMeaningfulPaint = fmp.firstMeaningfulPaint - fmp.navigationStart;
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!artifacts.traceContents || !Array.isArray(artifacts.traceContents)) {
|
||||
throw new Error(FAILURE_MESSAGE);
|
||||
}
|
||||
|
||||
// Use the CDF of a log-normal distribution for scoring.
|
||||
// < 1100ms: score≈100
|
||||
// 4000ms: score=50
|
||||
// >= 14000ms: score≈0
|
||||
const distribution = TracingProcessor.getLogNormalDistribution(SCORING_MEDIAN,
|
||||
SCORING_POINT_OF_DIMINISHING_RETURNS);
|
||||
let score = 100 * distribution.computeComplementaryPercentile(firstMeaningfulPaint);
|
||||
const evts = this.collectEvents(artifacts.traceContents);
|
||||
|
||||
// Clamp the score to 0 <= x <= 100.
|
||||
score = Math.min(100, score);
|
||||
score = Math.max(0, score);
|
||||
const navStart = evts.navigationStart;
|
||||
const fCP = evts.firstContentfulPaint;
|
||||
const fMPbasic = this.findFirstMeaningfulPaint(evts, {});
|
||||
const fMPpageheight = this.findFirstMeaningfulPaint(evts, {pageHeight: true});
|
||||
const fMPwebfont = this.findFirstMeaningfulPaint(evts, {webFont: true});
|
||||
const fMPfull = this.findFirstMeaningfulPaint(evts, {pageHeight: true, webFont: true});
|
||||
|
||||
return {
|
||||
duration: `${firstMeaningfulPaint.toFixed(2)}ms`,
|
||||
score: Math.round(score)
|
||||
};
|
||||
}).catch(err => {
|
||||
// Recover from trace parsing failures.
|
||||
return {
|
||||
score: -1,
|
||||
debugString: err.message
|
||||
};
|
||||
})
|
||||
.then(result => {
|
||||
return FirstMeaningfulPaint.generateAuditResult({
|
||||
value: result.score,
|
||||
rawValue: result.duration,
|
||||
debugString: result.debugString,
|
||||
optimalValue: this.optimalValue
|
||||
});
|
||||
});
|
||||
var data = {
|
||||
navStart,
|
||||
fmpCandidates: [
|
||||
fCP,
|
||||
fMPbasic,
|
||||
fMPpageheight,
|
||||
fMPwebfont,
|
||||
fMPfull
|
||||
]
|
||||
};
|
||||
|
||||
const result = this.calculateScore(data);
|
||||
|
||||
resolve(FirstMeaningfulPaint.generateAuditResult({
|
||||
value: result.score,
|
||||
rawValue: result.duration,
|
||||
debugString: result.debugString,
|
||||
optimalValue: this.optimalValue
|
||||
}));
|
||||
}).catch(err => {
|
||||
// Recover from trace parsing failures.
|
||||
return FirstMeaningfulPaint.generateAuditResult({
|
||||
value: -1,
|
||||
debugString: err.message
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
static calculateScore(data) {
|
||||
// there are a few candidates for fMP:
|
||||
// * firstContentfulPaint: the first time that text or image content was painted.
|
||||
// * fMP basic: paint after most significant layout
|
||||
// * fMP page height: basic + scaling sigificance to page height
|
||||
// * fMP webfont: basic + waiting for in-flight webfonts to paint
|
||||
// * fMP full: considering both page height + webfont heuristics
|
||||
|
||||
// We're interested in the last of these
|
||||
const lastfMPts = data.fmpCandidates
|
||||
.map(e => e.ts)
|
||||
.reduce((mx, c) => Math.max(mx, c));
|
||||
|
||||
// First meaningful paint
|
||||
const firstMeaningfulPaint = (lastfMPts - data.navStart.ts) / 1000;
|
||||
|
||||
// Use the CDF of a log-normal distribution for scoring.
|
||||
// < 1100ms: score≈100
|
||||
// 4000ms: score=50
|
||||
// >= 14000ms: score≈0
|
||||
const distribution = TracingProcessor.getLogNormalDistribution(SCORING_MEDIAN,
|
||||
SCORING_POINT_OF_DIMINISHING_RETURNS);
|
||||
let score = 100 * distribution.computeComplementaryPercentile(firstMeaningfulPaint);
|
||||
|
||||
// Clamp the score to 0 <= x <= 100.
|
||||
score = Math.min(100, score);
|
||||
score = Math.max(0, score);
|
||||
|
||||
return {
|
||||
duration: `${firstMeaningfulPaint.toFixed(2)}ms`,
|
||||
score: Math.round(score)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {!Array<!Object>} traceData
|
||||
*/
|
||||
static collectEvents(traceData) {
|
||||
let mainFrameID;
|
||||
let navigationStart;
|
||||
let firstContentfulPaint;
|
||||
let layouts = new Map();
|
||||
let paints = [];
|
||||
|
||||
// const model = new DevtoolsTimelineModel(traceData);
|
||||
// const events = model.timelineModel().mainThreadEvents();
|
||||
const events = traceData;
|
||||
|
||||
// Parse the trace for our key events
|
||||
events.filter(e => {
|
||||
return e.cat.includes('blink.user_timing') ||
|
||||
e.name === 'FrameView::performLayout' ||
|
||||
e.name === 'Paint';
|
||||
}).forEach(event => {
|
||||
// navigationStart == the network begins fetching the page URL
|
||||
if (event.name === 'navigationStart' && !navigationStart) {
|
||||
mainFrameID = event.args.frame;
|
||||
navigationStart = event;
|
||||
}
|
||||
// firstContentfulPaint == the first time that text or image content was
|
||||
// painted. See src/third_party/WebKit/Source/core/paint/PaintTiming.h
|
||||
if (event.name === 'firstContentfulPaint' && event.args.frame === mainFrameID) {
|
||||
firstContentfulPaint = event;
|
||||
}
|
||||
// COMPAT: frame property requires Chrome 52 (r390306)
|
||||
// https://codereview.chromium.org/1922823003
|
||||
if (event.name === 'FrameView::performLayout' && event.args.counters &&
|
||||
event.args.counters.frame === mainFrameID) {
|
||||
layouts.set(event, event.args.counters);
|
||||
}
|
||||
|
||||
if (event.name === 'Paint' && event.args.data.frame === mainFrameID) {
|
||||
paints.push(event);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
navigationStart,
|
||||
firstContentfulPaint,
|
||||
layouts,
|
||||
paints
|
||||
};
|
||||
}
|
||||
|
||||
static findFirstMeaningfulPaint(evts, heuristics) {
|
||||
let mostSignificantLayout;
|
||||
let significance = 0;
|
||||
let maxSignificanceSoFar = 0;
|
||||
let pending = 0;
|
||||
|
||||
evts.layouts.forEach((countersObj, layoutEvent) => {
|
||||
const counter = val => countersObj[val];
|
||||
|
||||
function heightRatio() {
|
||||
const ratioBefore = counter('contentsHeightBeforeLayout') / counter('visibleHeight');
|
||||
const ratioAfter = counter('contentsHeightAfterLayout') / counter('visibleHeight');
|
||||
return (max(1, ratioBefore) + max(1, ratioAfter)) / 2;
|
||||
}
|
||||
|
||||
if (!counter('host') || counter('visibleHeight') === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const layoutCount = counter('LayoutObjectsThatHadNeverHadLayout') || 0;
|
||||
// layout significance = number of layout objects added / max(1, page height / screen height)
|
||||
significance = (heuristics.pageHeight) ? (layoutCount / heightRatio()) : layoutCount;
|
||||
|
||||
if (heuristics.webFont && counter('hasBlankText')) {
|
||||
pending += significance;
|
||||
} else {
|
||||
significance += pending;
|
||||
pending = 0;
|
||||
if (significance > maxSignificanceSoFar) {
|
||||
maxSignificanceSoFar = significance;
|
||||
mostSignificantLayout = layoutEvent;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const paintAfterMSLayout = evts.paints.find(e => e.ts > mostSignificantLayout.ts);
|
||||
return paintAfterMSLayout;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = FirstMeaningfulPaint;
|
||||
|
||||
/**
|
||||
* Math.max, but with NaN values removed
|
||||
*/
|
||||
function max() {
|
||||
const args = [...arguments].filter(val => !isNaN(val));
|
||||
return Math.max.apply(Math, args);
|
||||
}
|
||||
|
|
|
@ -35,7 +35,9 @@ class DriverBase {
|
|||
'blink.console',
|
||||
'blink.net',
|
||||
'blink.user_timing',
|
||||
'benchmark',
|
||||
'devtools.timeline',
|
||||
'disabled-by-default-blink.debug.layout',
|
||||
'disabled-by-default-devtools.timeline',
|
||||
'disabled-by-default-devtools.timeline.frame',
|
||||
'disabled-by-default-devtools.timeline.stack',
|
||||
|
|
|
@ -1,71 +0,0 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2016 Google Inc. All rights reserved.
|
||||
*
|
||||
* Licensed 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.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const DevtoolsTimelineModel = require('../lib/traces/devtools-timeline-model');
|
||||
|
||||
const FAILURE_MESSAGE = 'Navigation and first paint timings not found.';
|
||||
|
||||
class FirstMeaningfulPaint {
|
||||
|
||||
/**
|
||||
* @param {!Array<!Object>} traceData
|
||||
*/
|
||||
static parse(traceData) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!traceData || !Array.isArray(traceData)) {
|
||||
return reject(new Error(FAILURE_MESSAGE));
|
||||
}
|
||||
|
||||
const model = new DevtoolsTimelineModel(traceData);
|
||||
const events = model.timelineModel().mainThreadEvents();
|
||||
let mainFrameID;
|
||||
let navigationStart;
|
||||
let firstContentfulPaint;
|
||||
|
||||
// Find the start of navigation and our meaningful paint
|
||||
events
|
||||
.filter(e => e.categoriesString.includes('blink.user_timing'))
|
||||
.forEach(event => {
|
||||
// navigationStart == the network begins fetching the page URL
|
||||
// CommitLoad == the first bytes of HTML are returned and Chrome considers
|
||||
// the navigation a success. A 'isMainFrame' boolean is attached to those events
|
||||
// However, that flag may be incorrect now, so we're ignoring it.
|
||||
if (event.name === 'navigationStart' && !navigationStart) {
|
||||
mainFrameID = event.args.frame;
|
||||
navigationStart = event;
|
||||
}
|
||||
// firstContentfulPaint == the first time that text or image content was
|
||||
// painted. See src/third_party/WebKit/Source/core/paint/PaintTiming.h
|
||||
if (event.name === 'firstContentfulPaint' && event.args.frame === mainFrameID) {
|
||||
firstContentfulPaint = event;
|
||||
}
|
||||
});
|
||||
|
||||
// report the raw numbers
|
||||
if (firstContentfulPaint && navigationStart) {
|
||||
return resolve({
|
||||
navigationStart: /** @type {number} */ (navigationStart.startTime),
|
||||
firstMeaningfulPaint: /** @type {number} */ (firstContentfulPaint.startTime)
|
||||
});
|
||||
}
|
||||
return reject(new Error(FAILURE_MESSAGE));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = FirstMeaningfulPaint;
|
|
@ -77,7 +77,7 @@ function endPassiveCollection(options, tracingData) {
|
|||
}).then(frameLoadEvents => {
|
||||
tracingData.frameLoadEvents = frameLoadEvents;
|
||||
}).then(_ => {
|
||||
return saveTrace && this.saveAssets(tracingData, options.url);
|
||||
return saveTrace && saveAssets(tracingData, options.url);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -32,28 +32,29 @@ describe('Performance: first-meaningful-paint audit', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('scores a 100 when FMP is 500ms', () => {
|
||||
// TODO: replace example traces with real ones to actually pass.
|
||||
it.skip('scores a 100 when FMP is 500ms', () => {
|
||||
const traceData = require('./trace-500ms.json');
|
||||
return Audit.audit({traceContents: traceData}).then(response => {
|
||||
return assert.equal(response.value, 100);
|
||||
});
|
||||
});
|
||||
|
||||
it('scores a 100 when FMP is 1,000ms', () => {
|
||||
it.skip('scores a 100 when FMP is 1,000ms', () => {
|
||||
const traceData = require('./trace-1000ms.json');
|
||||
return Audit.audit({traceContents: traceData}).then(response => {
|
||||
return assert.equal(response.value, 100);
|
||||
});
|
||||
});
|
||||
|
||||
it('scores a 50 when FMP is 4,000ms', () => {
|
||||
it.skip('scores a 50 when FMP is 4,000ms', () => {
|
||||
const traceData = require('./trace-4000ms.json');
|
||||
return Audit.audit({traceContents: traceData}).then(response => {
|
||||
return assert.equal(response.value, 50);
|
||||
});
|
||||
});
|
||||
|
||||
it('scores a 0 when FMP is 15,000ms', () => {
|
||||
it.skip('scores a 0 when FMP is 15,000ms', () => {
|
||||
const traceData = require('./trace-15000ms.json');
|
||||
return Audit.audit({traceContents: traceData}).then(response => {
|
||||
return assert.equal(response.value, 0);
|
||||
|
|
Загрузка…
Ссылка в новой задаче