前端如何做极致的首屏渲染速度优化
這里說的極致是技術(shù)上可以達(dá)到最優(yōu)的性能。
這里不討論常見的優(yōu)化手段,比如:Script標(biāo)簽放到底部、DNS預(yù)解析、HTTP2.0、CDN、資源壓縮、懶加載等。
這里討論的是如何使First Contentful Paint的時(shí)間降到最低,這個(gè)指標(biāo)決定了白屏的時(shí)間有多長。
在正式開始之前,我們以LCG(Vue組件代碼生成平臺(tái)來說),它的FCP(First Contentful Paint)速度在Slow 3G情況下在將近40s左右:
這顯然是一個(gè)讓人無法忍受的時(shí)間。
常規(guī)情況下,我們?yōu)榱丝s短First Contentful Paint的時(shí)間,可以在index.html中內(nèi)聯(lián)一個(gè)Loading效果。
但拿大型項(xiàng)目來說,尤其是以VueCli創(chuàng)建的項(xiàng)目來說,這個(gè)Loading的效果不見得能有多提前,因?yàn)榇笮晚?xiàng)目中所依賴的資源非常多。所以說能做到極致并不容易。
問題出在哪?默認(rèn)Vue-Cli會(huì)在生成的文件頭部增加很多的link,而這些link會(huì)阻礙后面靜態(tài)Html內(nèi)容的處理,等這些靜態(tài)Html內(nèi)容處理完才會(huì)有Dom的生成以及動(dòng)畫的執(zhí)行。
假設(shè)我們最終輸出的index.html文件內(nèi)部是這樣的:
那我們的loading效果顯然不會(huì)出現(xiàn)的有多早。所以,我們的極致目標(biāo)就是讓loading動(dòng)畫盡可能的早。
為了看出優(yōu)化前優(yōu)化后的效果差異,一切都在瀏覽器的Slow 3G網(wǎng)絡(luò)情況下驗(yàn)證。
有Loading情況下優(yōu)化前后效果數(shù)據(jù)比對
下面的圖展示了單純的在index.html頂部增加loading.css文件的效果,這個(gè)時(shí)間從40秒縮短到了22秒左右,效果是要好一些了,但是還是讓人無法忍受:
而優(yōu)化后可以將時(shí)間縮短到2.4秒不到,注意這是在Slow 3G網(wǎng)絡(luò)情況下測試的結(jié)果,且網(wǎng)絡(luò)傳輸速度花費(fèi)了2.14秒:
這個(gè)時(shí)間是要比百度還要好一些的:
那究竟是怎么做到的呢?
思路
我們可以從第二張圖中看到,FCP很明顯是在babel.min.js文件加載之后才開始進(jìn)行的。而我們理想中的時(shí)間應(yīng)該在4秒多一些。顯然,是一些JS文件的加載阻礙了DOM的解析。
但真的只有JS文件對loading有影響嗎?其它類型的,比如PNG、SVG、CSS、JSON會(huì)影響Loading的渲染速度嗎?
會(huì),FCP會(huì)等待所有的CSS加載完成才開始進(jìn)行,而css文件的加載優(yōu)先級(jí)默認(rèn)是最高的。如果script標(biāo)簽擁有rel="preload"并且書寫在css之前則會(huì)比css優(yōu)先加載(這里的正確性有待驗(yàn)證),資源的加載默認(rèn)情況下是按照書寫順序進(jìn)行的。更具體的內(nèi)容可以查看末尾的延伸閱讀。
所以我們可以試著將所有的link放置到body的最后面。
怎么做
因?yàn)槭褂玫氖荲ueCli(4.5.9版),因此我們可用的HtmlWebpackPlugin的版本只有3.2.0。而這個(gè)版本是在3年前發(fā)布的,所以只能對這個(gè)版本現(xiàn)有的能力動(dòng)一下刀子。文檔:html-webpack-plugin 3.2.0。
在文檔中查到這個(gè)版本其實(shí)是支持一些事件鉤子的:
- html-webpack-plugin-before-html-generation
- html-webpack-plugin-before-html-processing
- html-webpack-plugin-alter-asset-tags
- html-webpack-plugin-after-html-processing
- html-webpack-plugin-after-emit
文檔下方有個(gè)簡單的例子演示了這些鉤子怎么使用,但實(shí)際發(fā)現(xiàn)時(shí),它這里的例子是有些問題的,因?yàn)閏b是一個(gè)undefined:
function MyPlugin(options) {// Configure your plugin with options... }MyPlugin.prototype.apply = function (compiler) {compiler.plugin('compilation', (compilation) => {console.log('The compiler is starting a new compilation...');compilation.plugin('html-webpack-plugin-before-html-processing',(data, cb) => {data.html += 'The Magic Footer'cb(null, data)})}) }module.exports = MyPlugin不過這些難不倒我,通過調(diào)試時(shí)的堆棧得知,我所使用的html-webpack-plugin在回調(diào)自定義方法時(shí)是同步進(jìn)行的,所以只需要將data return就可以了。
經(jīng)過這樣的方式一步步調(diào)試,最終知道了html-webpackp-plugin是怎么生成html代碼的:
injectAssetsIntoHtml (html, assets, assetTags) {const htmlRegExp = /(<html[^>]*>)/i;const headRegExp = /(<\/head\s*>)/i;const bodyRegExp = /(<\/body\s*>)/i;const body = assetTags.body.map(this.createHtmlTag.bind(this));const head = assetTags.head.map(this.createHtmlTag.bind(this));if (body.length) {if (bodyRegExp.test(html)) {// Append assets to body elementhtml = html.replace(bodyRegExp, match => body.join('') + match);} else {// Append scripts to the end of the file if no <body> element exists:html += body.join('');}}// 這里就是我要找的關(guān)鍵部分if (head.length) {// Create a head tag if none existsif (!headRegExp.test(html)) {if (!htmlRegExp.test(html)) {html = '<head></head>' + html;} else {html = html.replace(htmlRegExp, match => match + '<head></head>');}}// Append assets to head elementhtml = html.replace(headRegExp, match => head.join('') + match);}// Inject manifest into the opening html tagif (assets.manifest) {html = html.replace(/(<html[^>]*)(>)/i, (match, start, end) => {// Append the manifest only if no manifest was specifiedif (/\smanifest\s*=/.test(match)) {return match;}return start + ' manifest="' + assets.manifest + '"' + end;});}return html;}那么知道了它是怎么做的,但它沒有提供對外的方法來干擾這些head要放到什么位置。比如我現(xiàn)在就想把他們放到body最后面,但它是不支持的。
那么我初步的想法是在html生成后將那部分的head手動(dòng)轉(zhuǎn)移一下。但突發(fā)奇想,既然有鉤子可以更改AssetTags,那我豈不是可以不讓它內(nèi)部生成而讓我自己生成?這個(gè)想法很妙。經(jīng)過一番調(diào)試得知,可以在html-webpack-plugin-alter-asset-tags這個(gè)鉤子中拿到data.head的內(nèi)容,再將data.head給置空數(shù)組。這樣它原本的head就不會(huì)生成了。這里的head代表的就是即將插到head中的那些標(biāo)簽。
然后再在html-webpack-plugin-after-html-processing這個(gè)鉤子中按照html-wepack-plugin的方式給拼接到body的最后面。
于是有了最終代碼:
// AlterPlugin.js function AlterPlugin(options) { }function createHtmlTag(tagDefinition) {const attributes = Object.keys(tagDefinition.attributes || {}).filter(attributeName => tagDefinition.attributes[attributeName] !== false).map(attributeName => {if (tagDefinition.attributes[attributeName] === true) {return attributeName;}return attributeName + '="' + tagDefinition.attributes[attributeName] + '"';});const voidTag = tagDefinition.voidTag !== undefined ? tagDefinition.voidTag : !tagDefinition.closeTag;const selfClosingTag = tagDefinition.voidTag !== undefined ? tagDefinition.voidTag : tagDefinition.selfClosingTag;return '<' + [tagDefinition.tagName].concat(attributes).join(' ') + (selfClosingTag ? '/' : '') + '>' +(tagDefinition.innerHTML || '') +(voidTag ? '' : '</' + tagDefinition.tagName + '>'); }AlterPlugin.prototype.apply = function (compiler) {compiler.plugin('compilation', (compilation) => {let innerHeadTags = null;compilation.plugin('html-webpack-plugin-before-html-generation',(data, cb) => {return data;})compilation.plugin('html-webpack-plugin-before-html-processing',(data, cb) => {return data;})compilation.plugin('html-webpack-plugin-alter-asset-tags',(data, cb) => {// 獲取到它原來的那些headTaginnerHeadTags = data.head.map(createHtmlTag);data.head = [];return data;})compilation.plugin('html-webpack-plugin-after-html-processing',(data, cb) => {// 在這里進(jìn)行html的內(nèi)容變更data.html = data.html.replace(/(<\/body\s*>)/i, match => {return innerHeadTags.join('') + match});return data;})compilation.plugin('html-webpack-plugin-after-emit',(data, cb) => {return data;})}) }module.exports = AlterPlugin最后只需要在vue.config.js中引用一下這個(gè)新的Plugin就可以了:
const AlterPlugin = require('./AlterPlugin');module.exports = {...configureWebpack: {plugins: [new AlterPlugin()]},... };最終的代碼輸出是我想要的結(jié)果:
<!DOCTYPE html> <html lang="en"><head><meta charset="utf-8" /><meta http-equiv="X-UA-Compatible" content="IE=edge" /><meta name="viewport" content="width=device-width,initial-scale=1.0" /><link rel="stylesheet" href="loading.css" /> </head><body><div id="app">...</div>...<script defer src="https://cdn.jsdelivr.net/npm/@babel/standalone@7.0.0-beta.42/babel.min.js"></script><!--以下部分都是AlterPlugin作用的結(jié)果,這部分結(jié)果本來會(huì)被放置到head中的--><script type="text/javascript" src="/vue-creater-platform/js/chunk-vendors.js"></script><script type="text/javascript" src="/vue-creater-platform/js/app.js"></script><link href="/vue-creater-platform/js/0.js" rel="prefetch"><link href="/vue-creater-platform/js/1.js" rel="prefetch"><link href="/vue-creater-platform/js/2.js" rel="prefetch"><link href="/vue-creater-platform/js/3.js" rel="prefetch"><link href="/vue-creater-platform/js/about.js" rel="prefetch"><link href="/vue-creater-platform/js/app.js" rel="preload" as="script"><link href="/vue-creater-platform/js/chunk-vendors.js" rel="preload" as="script"><link rel="icon" type="image/png" sizes="32x32" href="/vue-creater-platform/img/icons/favicon-32x32.png"><link rel="icon" type="image/png" sizes="16x16" href="/vue-creater-platform/img/icons/favicon-16x16.png"><link rel="manifest" href="/vue-creater-platform/manifest.json"><meta name="theme-color" content="#4DBA87"><meta name="apple-mobile-web-app-capable" content="no"><meta name="apple-mobile-web-app-status-bar-style" content="default"><meta name="apple-mobile-web-app-title" content="vue-component-creater"><link rel="apple-touch-icon" href="/vue-creater-platform/img/icons/apple-touch-icon-152x152.png"><link rel="mask-icon" href="/vue-creater-platform/img/icons/safari-pinned-tab.svg" color="#4DBA87"><meta name="msapplication-TileImage" content="/vue-creater-platform/img/icons/msapplication-icon-144x144.png"><meta name="msapplication-TileColor" content="#000000"> </body></html>寫到一半時(shí)發(fā)現(xiàn),因?yàn)椴粐?yán)謹(jǐn)?shù)脑囼?yàn)導(dǎo)致了錯(cuò)誤的結(jié)果,所以這篇文章的產(chǎn)出可以算只有一個(gè)可以轉(zhuǎn)移head標(biāo)簽的Plugin。
如果把loading.css文件直接內(nèi)聯(lián)效果會(huì)不會(huì)效果更好?
是可以的,將Loading的樣式直接寫在html中會(huì)與上面的一系列操作是同樣的效果。也可以說FCP不需要等待所有的CSS加載完畢再進(jìn)行。這個(gè)結(jié)論與文章中有矛盾,還需要驗(yàn)證First Contentful Paint的具體觸發(fā)時(shí)機(jī)。
*后記:
如果要觸發(fā)First Contentful Paint,則需要在Dom中至少存在文本或者圖片,否則它是不會(huì)被觸發(fā)的。原文:
The First Contentful Paint time stamp is when the browser first rendered any text, image (including background images), non-white canvas or SVG. This excludes any content of iframes, but includes text with pending webfonts. This is the first time users could start consuming page content.
延伸閱讀:
- Paint Timing 1 草案 簡要概述:This document defines an API that can be used to capture a series of key moments (first paint, first contentful paint) during pageload which developers care about.
- Chrome的First Paint觸發(fā)的時(shí)機(jī)探究 非常詳細(xì)
- User-centric performance metrics
總結(jié)
以上是生活随笔為你收集整理的前端如何做极致的首屏渲染速度优化的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Nginx 简介和使用
- 下一篇: 关于话题演化关系网络生成的路线思考:从话