我們如何改進 SmashingMag 性能
已發表: 2022-03-10本文得到了我們在 Media Temple 的親愛的朋友的大力支持,他們為設計師、開發人員和您的客戶提供了全方位的網絡託管解決方案。 謝謝親愛的朋友們!
每個網絡性能故事都是相似的,不是嗎? 它總是從期待已久的網站大修開始。 一天,一個經過全面打磨和精心優化的項目啟動,在 Lighthouse 和 WebPageTest 中排名很高並飆升至性能得分之上。 空氣中瀰漫著一種慶祝和全心全意的成就感——完美地反映在轉發、評論、新聞通訊和 Slack 線程中。
然而隨著時間的流逝,興奮慢慢消退,緊急調整、急需的功能和新的業務需求悄悄湧現。突然,在你不知不覺中,代碼庫變得有點超重和碎片化,第三方腳本必須稍早加載,閃亮的新動態內容會通過第四方腳本的後門及其不速之客進入 DOM。
我們也去過 Smashing。 知道的人不多,但我們是一個非常小的團隊,大約 12 人,其中許多人是兼職工作,其中大多數人通常在某一天戴著許多不同的帽子。 近十年來,儘管性能一直是我們的目標,但我們從未真正擁有過專門的性能團隊。
在 2017 年末進行了最新的重新設計之後,Ilya Pukhalski 在 JavaScript 方面(兼職),Michael Riethmueller 在 CSS 方面(每週幾個小時),以及你真正的,用關鍵的 CSS 玩智力遊戲並試圖兼顧一些太多的事情。

碰巧的是,我們在忙碌的日常工作中失去了表現。 我們正在設計和構建東西,設置新產品,重構組件並發布文章。 所以到 2020 年底,事情有點失控了,黃紅色的燈塔分數慢慢地全面出現。 我們必須解決這個問題。
那就是我們所在的地方
你們中的一些人可能知道我們在 JAMStack 上運行,所有文章和頁面都存儲為 Markdown 文件,Sass 文件編譯成 CSS,JavaScript 使用 Webpack 分割成塊,Hugo 構建靜態頁面,然後我們直接從 Edge CDN 提供服務。 早在 2017 年,我們就使用 Preact 構建了整個網站,但隨後在 2019 年轉移到了 React——並將其與一些用於搜索、評論、身份驗證和結帳的 API 一起使用。
整個網站在構建時考慮到了漸進式增強,這意味著親愛的讀者,您可以完整閱讀每篇 Smashing 文章,而無需啟動應用程序。 這也不足為奇——最後,一篇發表的文章多年來並沒有太大變化,而諸如會員身份驗證和結帳之類的動態部分需要應用程序運行。
目前,部署大約 2500 篇文章的整個構建過程大約需要6 分鐘。 隨著時間的推移,構建過程本身也變得相當糟糕,包括關鍵的 CSS 注入、Webpack 的代碼拆分、廣告和功能面板的動態插入、RSS(重新)生成以及最終的邊緣 A/B 測試。
2020 年初,我們開始對 CSS 佈局組件進行大規模重構。 我們從未使用過 CSS-in-JS 或 styled-components,而是使用了一個很好的基於組件的 Sass 模塊系統,它可以編譯成 CSS。 早在 2017 年,整個佈局是使用 Flexbox 構建的,並在 2019 年中期使用 CSS Grid 和 CSS 自定義屬性進行了重建。 但是,由於新的廣告位和新的產品面板,一些頁面需要特殊處理。 因此,當佈局工作時,它工作得不是很好,而且很難維護。
此外,帶有主導航的標題必須更改以適應我們想要動態顯示的更多項目。 另外,我們想重構站點中使用的一些常用組件,並且那裡使用的 CSS 也需要進行一些修改——通訊框是最顯著的罪魁禍首。 我們從使用實用程序優先的 CSS 重構一些組件開始,但我們從未達到在整個網站上一致使用它的地步。
更大的問題是大型 JavaScript 包——這並不奇怪——阻塞了主線程數百毫秒。 一個大的 JavaScript 包在僅僅發表文章的雜誌上可能看起來不合適,但實際上,在幕後發生了大量的腳本。
我們為經過身份驗證和未經身份驗證的客戶提供各種組件狀態。 登錄後,我們希望以最終價格顯示所有產品,並且當您將書籍添加到購物車時,我們希望通過點擊按鈕保持購物車可訪問 - 無論您在哪個頁面上。 廣告需要在不引起破壞性佈局變化的情況下快速進入,突出我們產品的原生產品面板也是如此。 加上一個服務工作者,它緩存所有靜態資產並為它們提供重複視圖,以及讀者已經訪問過的文章的緩存版本。
所以所有這些腳本都必須在某個時候發生,即使腳本來得很晚,它也會消耗閱讀體驗。 坦率地說,我們在網站和新組件上煞費苦心,沒有密切關注性能(2020 年我們還有其他一些事情要記住)。 轉折點出乎意料地來了。 Harry Roberts 將他的(出色的)Web 性能大師班作為在線研討會與我們一起舉辦,在整個研討會期間,他以 Smashing 為例,強調我們遇到的問題並建議解決這些問題的方法以及有用的工具和指南。
在整個研討會期間,我一直在努力記筆記並重新訪問代碼庫。 在研討會舉行時,我們的 Lighthouse 得分在首頁上為 60-68,在文章頁面上約為 40-60——在移動設備上顯然更差。 研討會結束後,我們開始工作。
識別瓶頸
我們通常傾向於依靠特定的分數來了解我們的表現如何,但往往單一的分數並不能提供完整的畫面。 正如 David East 在他的文章中雄辯地指出的那樣,Web 性能不是一個單一的價值。 這是一個分佈。 即使 Web 體驗是全面優化的全面性能,也不能只是快速。 對某些訪問者來說可能很快,但最終對其他一些訪問者來說也會更慢(或慢)。
造成這種情況的原因很多,但最重要的一個是全球網絡條件和設備硬件的巨大差異。 很多時候,我們無法真正影響這些事情,所以我們必須確保我們的經驗能夠適應它們。
本質上,我們的工作就是增加快速體驗的比例並減少緩慢體驗的比例。 但為此,我們需要正確了解分佈的實際情況。 現在,分析工具和性能監控工具將在需要時提供這些數據,但我們專門研究了 CrUX,Chrome 用戶體驗報告。 CrUX 生成隨時間推移的性能分佈概覽,並從 Chrome 用戶那裡收集流量。 其中大部分數據與穀歌在 2020 年宣布的 Core Web Vitals 相關,這些數據也有助於 Lighthouse 並在 Lighthouse 中公開。

我們注意到,總體而言,我們的業績全年大幅下降,尤其是在 8 月和 9 月左右下降。 一旦我們看到這些圖表,我們就可以回顧我們當時推出的一些 PR,以研究實際發生的情況。
很快就發現,就在這些時候,我們實時推出了一個新的導航欄。 該導航欄——用於所有頁面——依靠 JavaScript 在點擊或點擊時在菜單中顯示導航項,但它的 JavaScript 部分實際上是捆綁在app.js包中的。 為了改進 Time To Interactive,我們決定從包中提取導航腳本並內聯提供它。
大約在同一時間,我們從(過時的)手動創建的關鍵 CSS文件切換到為每個模板(主頁、文章、產品頁面、活動、工作板等)生成關鍵 CSS 的自動化系統,並在期間內聯關鍵 CSS構建時間。 然而,我們並沒有真正意識到自動生成的關鍵 CSS 有多麼沉重。 我們不得不更詳細地探索它。
大約在同一時間,我們正在調整網絡字體加載,嘗試通過預加載等資源提示更積極地推送網絡字體。 不過,這似乎對我們的性能工作產生了反作用,因為網絡字體延遲了內容的呈現,在完整的 CSS 文件旁邊被過度優先考慮。
現在,回歸的一個常見原因是 JavaScript 的高成本,因此我們還研究了 Webpack Bundle Analyzer 和 Simon Hearne 的請求圖,以直觀地了解我們的 JavaScript 依賴關係。 一開始它看起來很健康。

一些請求來自 CDN、cookie 同意服務 Cookiebot、Google Analytics,以及我們用於提供產品面板和定制廣告的內部服務。 看起來並沒有很多瓶頸——直到我們更仔細地觀察。
在性能工作中,通常會查看一些關鍵頁面的性能——最有可能是主頁,最有可能是一些文章/產品頁面。 然而,雖然只有一個主頁,但可能會有很多不同的產品頁面,所以我們需要選擇能夠代表我們受眾的頁面。
事實上,由於我們在 SmashingMag 上發表了大量代碼密集和設計密集的文章,多年來我們已經積累了數千篇包含大量 GIF、語法高亮代碼片段、CodePen 嵌入、視頻/音頻的文章嵌入和嵌套線程的永無止境的評論。
當它們結合在一起時,它們中的許多都會導致DOM 大小的爆炸以及過多的主線程工作——減慢了數千頁的體驗。 更不用說隨著廣告的出現,一些 DOM 元素在頁面生命週期的後期被注入,導致一連串的樣式重新計算和重新繪製——這也是可以產生長時間任務的昂貴任務。
所有這些都沒有顯示在我們為上圖中的一個非常輕量級的文章頁面生成的地圖中。 所以我們選擇了我們擁有的最重的頁面——全能的主頁、最長的主頁、嵌入很多視頻的網頁和嵌入很多 CodePen 的網頁——並決定盡可能優化它們。 畢竟,如果它們很快,那麼嵌入單個 CodePen 的頁面也應該更快。
考慮到這些頁面,地圖看起來有點不同。 請注意指向 Vimeo 播放器和 Vimeo CDN 的粗線,其中 78 個請求來自 Smashing 文章。

為了研究對主線程的影響,我們深入研究了 DevTools 中的性能面板。 更具體地說,我們正在尋找持續時間超過 50 毫秒的任務(用右上角的紅色矩形突出顯示)和包含重新計算樣式的任務(紫色條)。 第一個表明 JavaScript 執行成本很高,而後者會暴露由 DOM 中的內容動態注入和次優 CSS 引起的樣式失效。 這給了我們一些可操作的指示,告訴我們從哪裡開始。 例如,我們很快發現我們的網絡字體加載需要大量的重繪成本,而 JavaScript 塊仍然很重,足以阻塞主線程。

作為基線,我們非常仔細地研究了 Core Web Vitals,試圖確保我們在所有這些方面都取得了良好的成績。 我們選擇專注於慢速移動設備——3G 速度慢、RTT 為 400ms 和傳輸速度為 400kbps,只是出於悲觀的考慮。 毫不奇怪,Lighthouse 對我們的網站也不是很滿意,為最重的文章提供完全穩定的紅色分數,並不知疲倦地抱怨未使用的 JavaScript、CSS、屏幕外圖像及其大小。

一旦我們有了一些數據,我們就可以專注於優化三個最重的文章頁面,重點是關鍵(和非關鍵)CSS、JavaScript 包、長任務、Web 字體加載、佈局轉換和第三方-嵌入。 稍後我們還將修改代碼庫以刪除遺留代碼並使用新的現代瀏覽器功能。 看起來有很多工作要做,事實上我們在接下來的幾個月裡都很忙。
改善<head>
中的資產順序
具有諷刺意味的是,我們研究的第一件事甚至與我們上面確定的所有任務都沒有密切關係。 在性能研討會上,Harry 花了相當多的時間來解釋每個頁面<head>
中的資產順序,並指出快速交付關鍵內容意味著非常有策略地關注源代碼中資產的排序方式.
現在,關鍵的 CSS 對 Web 性能有益,這不應該成為一個重大的啟示。 然而,令人驚訝的是,所有其他資產(資源提示、網絡字體預加載、同步和異步腳本、完整 CSS 和元數據)的順序有多大差異。
我們將整個<head>
上下顛倒,將關鍵 CSS放在所有異步腳本和所有預加載資產(如字體、圖像等)之前。我們已經分解了我們將預連接或通過模板預加載的資產和文件類型,以便僅針對特定類型的文章和頁面提前請求關鍵圖像、語法突出顯示和視頻嵌入。
總的來說,我們精心編排了<head>
中的順序,減少了爭奪帶寬的預加載資源的數量,並專注於正確處理關鍵 CSS。 如果您想深入了解<head>
順序的一些關鍵注意事項,Harry 在關於 CSS 和網絡性能的文章中重點介紹了它們。 僅這一變化就為我們帶來了大約 3-4 的 Lighthouse 得分。
從自動關鍵 CSS 回到手動關鍵 CSS
不過,移動<head>
標籤只是故事的一個簡單部分。 更困難的是關鍵 CSS 文件的生成和管理。 早在 2017 年,我們通過收集在所有屏幕寬度上呈現前 1000 個像素高度所需的所有樣式,為每個模板手動手工製作關鍵 CSS。 這當然是一項繁瑣且略顯乏味的任務,更不用說馴服整個關鍵 CSS 文件家族和完整 CSS 文件的維護問題了。
因此,我們研究了將此過程自動化作為構建例程的一部分的選項。 可用的工具並不缺乏,所以我們測試了一些並決定運行一些測試。 我們已經設法將它們設置好并快速運行。 輸出對於自動化流程來說似乎已經足夠好了,因此在進行了一些配置調整後,我們將其插入並推送到生產環境中。 這發生在去年 7 月至 8 月左右,在上面 CrUX 數據的峰值和性能下降中很好地體現了這一點。 我們不斷地來回調整配置,經常遇到簡單的問題,例如添加特定樣式或刪除其他樣式。 例如,除非 cookie 腳本已初始化,否則不會真正包含在頁面中的 cookie 同意提示樣式。
在 10 月,我們對網站進行了一些重大的佈局更改,在研究關鍵的 CSS 時,我們又遇到了完全相同的問題——生成的結果非常冗長,並不是我們想要的. 因此,作為 10 月下旬的一項實驗,我們都集中力量重新審視我們的關鍵 CSS 方法,並研究手工製作的關鍵 CSS會小得多。 我們深吸一口氣,在關鍵頁面上的代碼覆蓋工具上花了幾天時間。 我們手動對 CSS 規則進行分組,並刪除了兩個地方的重複代碼和遺留代碼——關鍵 CSS 和主要 CSS。 這確實是一項急需的清理工作,因為多年來編寫的許多樣式在 2017-2018 年已經過時。
結果,我們最終得到了三個手工製作的關鍵 CSS 文件,以及另外三個目前正在進行中的文件:
- critical-homepage-manual.css (8.2 KB, Brotlified)
- critical-article-manual.css (8 KB, Brotlified)
- critical-articles-manual.css (6 KB, Brotlified)
- critical-books-manual.css(待完成的工作)
- critical-events-manual.css(待完成的工作)
- critical-job-board-manual.css(要完成的工作)
這些文件內聯在每個模板的頭部,目前它們在包含網站上曾經使用過(或不再真正使用)的所有內容的整體 CSS 包中復制。 目前,我們正在考慮將完整的 CSS 包分解為幾個 CSS 包,這樣雜誌的讀者就不會從工作板或書籍頁面下載樣式,但是當到達這些頁面時會得到快速渲染使用關鍵 CSS 並異步獲取該頁面的其餘 CSS - 僅在該頁面上。
誠然,手工製作的關鍵 CSS 文件的大小並沒有小很多:我們將關鍵 CSS 文件的大小減少了大約 14% 。 但是,它們以正確的順序從頭到尾包含了我們需要的一切,沒有重複和覆蓋樣式。 這似乎是朝著正確方向邁出的一步,它使我們的 Lighthouse 又增加了 3-4 分。 我們正在取得進展。
更改 Web 字體加載
font-display
觸手可及,字體加載在過去似乎是個問題。 不幸的是,在我們的情況下它並不完全正確。 親愛的讀者,您似乎訪問了 Smashing Magazine 上的許多文章。 您還經常返回該站點閱讀另一篇文章 - 可能是幾個小時或幾天后,或者可能是一周後。 我們在網站上使用font-display
時遇到的一個問題是,對於經常在文章之間移動的讀者,我們注意到備用字體和網絡字體之間有很多閃爍(這通常不應該發生,因為字體會正確緩存)。
這感覺不像是一個體面的用戶體驗,所以我們研究了選項。 在 Smashing 上,我們使用兩種主要字體——Mija 用於標題,Elena 用於正文。 Mija 有兩種粗細(Regular 和 Bold),而 Elena 有三種粗細(Regular、Italic、Bold)。 幾年前,我們在重新設計期間放棄了 Elena 的 Bold Italic,因為我們只在幾頁上使用了它。 我們通過刪除未使用的字符和 Unicode 範圍來子集其他字體。
我們的文章大多以文本形式呈現,因此我們發現,在網站上的大部分時間裡,最大內容的繪畫要么是文章的第一段文字,要么是作者的照片。 這意味著我們需要特別注意確保第一段以備用字體快速出現,同時優雅地切換到 Web 字體,並減少重排。
仔細看一下首頁的初始加載體驗(慢了三倍):
在找出解決方案時,我們有四個主要目標:
- 在第一次訪問時,立即使用備用字體呈現文本;
- 匹配後備字體和網絡字體的字體指標,以最大限度地減少佈局變化;
- 異步加載所有網絡字體並一次性應用它們(最多 1 次重排);
- 在隨後的訪問中,直接以網絡字體呈現所有文本(沒有任何閃爍或重排)。
最初,我們實際上嘗試在font-face
上使用font-display: swap 。 這似乎是最簡單的選擇,但是,如上所述,一些讀者會訪問許多頁面,因此我們最終會在整個網站上渲染的六種字體出現很多閃爍。 此外,僅使用font-display ,我們無法對請求或重繪進行分組。
另一個想法是在初次訪問時以備用字體呈現所有內容,然後異步請求和緩存所有字體,並且僅在後續訪問時直接從緩存中提供 Web 字體。 這種方法的問題在於,許多讀者來自搜索引擎,至少其中一些人只會看到一個頁面——而且我們不想僅以系統字體呈現一篇文章。
那又是什麼呢?
自 2017 年以來,我們一直在使用兩階段渲染方法進行 Web 字體加載,它基本上描述了兩個渲染階段:一個具有最小的 Web 字體子集,另一個具有完整的字體權重系列。 過去,我們創建了 Mija Bold 和 Elena Regular 的最小子集,它們是網站上最常用的權重。 兩個子集都只包含拉丁字符、標點符號、數字和一些特殊字符。 這些字體( ElenaInitial.woff2和MijaInitial.woff2 )非常小——通常只有 10-15 KB 左右。 我們在字體渲染的第一階段為它們提供服務,以這兩種字體顯示整個頁面。

我們使用 Font Loading API 來做到這一點,它為我們提供了有關哪些字體已成功加載以及哪些尚未成功加載的信息。 在幕後,它通過向body添加一個類.wf-loaded-stage1來實現,樣式以這些字體呈現內容:
.wf-loaded-stage1 article, .wf-loaded-stage1 promo-box, .wf-loaded-stage1 comments { font-family: ElenaInitial,sans-serif; } .wf-loaded-stage1 h1, .wf-loaded-stage1 h2, .wf-loaded-stage1 .btn { font-family: MijaInitial,sans-serif; }
因為字體文件很小,希望它們能很快通過網絡。 然後當讀者可以真正開始閱讀文章時,我們異步加載字體的全部權重,並將.wf-loaded-stage2添加到body :
.wf-loaded-stage2 article, .wf-loaded-stage2 promo-box, .wf-loaded-stage2 comments { font-family: Elena,sans-serif; } .wf-loaded-stage2 h1, .wf-loaded-stage2 h2, .wf-loaded-stage2 .btn { font-family: Mija,sans-serif; }
因此,在加載頁面時,讀者會首先快速獲得一個小的子集網絡字體,然後我們切換到完整的字體系列。 現在,默認情況下,後備字體和網絡字體之間的這些切換是隨機發生的,基於首先通過網絡出現的任何內容。 當您開始閱讀一篇文章時,這可能會讓人感到非常混亂。 因此,我們沒有讓瀏覽器決定何時切換字體,而是將 repaints 分組,將回流影響降至最低。
/* Loading web fonts with Font Loading API to avoid multiple repaints. With help by Irina Lipovaya. */ /* Credit to initial work by Zach Leatherman: https://noti.st/zachleat/KNaZEg/the-five-whys-of-web-font-loading-performance#sWkN4u4 */ // If the Font Loading API is supported... // (If not, we stick to fallback fonts) if ("fonts" in document) { // Create new FontFace objects, one for each font let ElenaRegular = new FontFace( "Elena", "url(/fonts/ElenaWebRegular/ElenaWebRegular.woff2) format('woff2')" ); let ElenaBold = new FontFace( "Elena", "url(/fonts/ElenaWebBold/ElenaWebBold.woff2) format('woff2')", { weight: "700" } ); let ElenaItalic = new FontFace( "Elena", "url(/fonts/ElenaWebRegularItalic/ElenaWebRegularItalic.woff2) format('woff2')", { style: "italic" } ); let MijaBold = new FontFace( "Mija", "url(/fonts/MijaBold/Mija_Bold-webfont.woff2) format('woff2')", { weight: "700" } ); // Load all the fonts but render them at once // if they have successfully loaded let loadedFonts = Promise.all([ ElenaRegular.load(), ElenaBold.load(), ElenaItalic.load(), MijaBold.load() ]).then(result => { result.forEach(font => document.fonts.add(font)); document.documentElement.classList.add('wf-loaded-stage2'); // Used for repeat views sessionStorage.foutFontsStage2Loaded = true; }).catch(error => { throw new Error(`Error caught: ${error}`); }); }
但是,如果第一個小字體子集沒有快速通過網絡怎麼辦? 我們注意到這似乎比我們想要的更頻繁地發生。 在這種情況下,在 3s 超時後,現代瀏覽器會退回到系統字體(在我們的字體堆棧中,它將是 Arial),然後切換到ElenaInitial或MijaInitial ,稍後分別切換到完整的 Elena 或 Mija . 這在我們的品嚐中產生了太多的閃光。 我們最初考慮只為慢速網絡刪除第一階段渲染(通過網絡信息 API),但後來我們決定完全刪除它。
所以在 10 月,我們連同中間階段一起移除了子集。 每當客戶端成功下載 Elena 和Mija字體的所有權重並準備好應用時,我們就會啟動第 2 階段並立即重新繪製所有內容。 為了讓回流變得不那麼明顯,我們花了一些時間來匹配後備字體和網絡字體。 這主要意味著對頁面第一個可見部分中繪製的元素應用稍微不同的字體大小和行高。
為此,我們使用了font-style-matcher
和 (ahem, ahem) 一些幻數。 這也是我們最初使用-apple-system和 Arial 作為全局後備字體的原因; 舊金山(通過-apple-system渲染)似乎比 Arial 好一點,但如果它不可用,我們選擇使用 Arial 只是因為它廣泛分佈在大多數操作系統中。
在 CSS 中,它看起來像這樣:
.article__summary { font-family: -apple-system,Arial,BlinkMacSystemFont,Roboto Slab,Droid Serif,Segoe UI,Ubuntu,Cantarell,Georgia,sans-serif; font-style: italic; /* Warning: magic numbers ahead! */ /* San Francisco Italic and Arial Italic have larger x-height, compared to Elena */ font-size: 0.9213em; line-height: 1.487em; } .wf-loaded-stage2 .article__summary { font-family: Elena,sans-serif; font-size: 1em; /* Original font-size for Elena Italic */ line-height: 1.55em; /* Original line-height for Elena Italic */ }
這工作得相當好。 我們確實會立即顯示文本,並且 Web 字體會分組顯示在屏幕上,理想情況下會在第一個視圖中準確地導致一次重排,而在後續視圖中完全沒有重排。
下載字體後,我們將它們存儲在service worker 的緩存中。 在隨後的訪問中,我們首先檢查字體是否已經在緩存中。 如果是,我們從 service worker 的緩存中檢索它們並立即應用它們。 如果沒有,我們從fallback-web-font-switcheroo重新開始。
該解決方案在相對較快的連接上將重排次數減少到最少(一次),同時還將字體持久且可靠地保留在緩存中。 在未來,我們真誠地希望用 f-mods 代替幻數。 也許扎克·萊瑟曼會感到自豪。

識別和分解單體 JS
當我們研究 DevTools 的性能面板中的主線程時,我們確切地知道我們需要做什麼。 有 8 個耗時在 70 毫秒到 580 毫秒之間的長任務,阻塞了界面並使其無響應。 一般來說,這些是成本最高的腳本:
- uc.js , cookie 提示腳本 (70ms)
- 由傳入的full.css文件 (176ms) 引起的樣式重新計算(關鍵 CSS 不包含所有視口中低於 1000px 高度的樣式)
- 在加載事件上運行的廣告腳本以管理面板、購物車等 + 樣式重新計算 (276ms)
- 網頁字體切換,樣式重新計算(290ms)
- app.js評估(580 毫秒)
我們首先關注最有害的那些——可以說是最長的長期任務。

第一個是由於字體更改(從備用字體到網絡字體)導致的昂貴的佈局重新計算而發生的,導致超過 290 毫秒的額外工作(在快速筆記本電腦和快速連接上)。 通過僅從字體加載中刪除第一階段,我們能夠獲得大約 80 毫秒的時間。 這還不夠好,因為遠遠超出了 50 毫秒的預算。 所以我們開始深入挖掘。
發生重新計算的主要原因僅僅是因為備用字體和網絡字體之間的巨大差異。 通過匹配後備字體和網絡字體的行高和大小,我們能夠避免許多情況,即一行文本會在後備字體的新行上換行,但隨後會稍微變小並適合前一行,導致整個頁面的幾何形狀發生重大變化,從而導致大量佈局變化。 我們也玩過letter-spacing
和word-spacing
,但效果不佳。
通過這些更改,我們能夠再減少 50-80 毫秒,但我們無法將其減少到 120 毫秒以下而不以後備字體顯示內容並隨後以 Web 字體顯示內容。 顯然,它應該只影響第一次訪問者,因為隨後的頁面視圖將使用直接從 service worker 緩存中檢索的字體呈現,而不會由於字體切換而導致代價高昂的重排。
順便說一句,很重要的一點是,在我們的案例中,我們注意到大多數 Long Tasks 不是由大量 JavaScript 引起的,而是由Layout Recalculations和 CSS 解析引起的,這意味著我們需要做一些 CSS cleaning, especially watching out for situations when styles are overwritten. In some way, it was good news because we didn't have to deal with complex JavaScript issues that much. However, it turned out not to be straightforward as we are still cleaning up the CSS this very day. We were able to remove two Long Tasks for good, but we still have a few outstanding ones and quite a way to go. Fortunately, most of the time we aren't way above the magical 50ms threshold.
The much bigger issue was the JavaScript bundle we were serving, occupying the main thread for a whopping 580ms. Most of this time was spent in booting up app.js which contains React, Redux, Lodash, and a Webpack module loader. The only way to improve performance with this massive beast was to break it down into smaller pieces. So we looked into doing just that.
With Webpack, we've split up the monolithic bundle into smaller chunks with code-splitting , about 30Kb per chunk. We did some package.json cleansing and version upgrade for all production dependencies, adjusted the browserlistrc setup to address the two latest browser versions, upgraded to Webpack and Babel to the latest versions, moved to Terser for minification, and used ES2017 (+ browserlistrc) as a target for script compilation.
We also used BabelEsmPlugin to generate modern versions of existing dependencies. Finally, we've added prefetch links to the header for all necessary script chunks and refactored the service worker, migrating to Workbox with Webpack (workbox-webpack-plugin).

Remember when we switched to the new navigation back in mid-2020, just to see a huge performance penalty as a result? The reason for it was quite simple. While in the past the navigation was just static plain HTML and a bit of CSS, with the new navigation, we needed a bit of JavaScript to act on opening and closing of the menu on mobile and on desktop. That was causing rage clicks when you would click on the navigation menu and nothing would happen, and of course, had a penalty cost in Time-To-Interactive scores in Lighthouse.
We removed the script from the bundle and extracted it as a separate script . Additionally, we did the same thing for other standalone scripts that were used rarely — for syntax highlighting, tables, video embeds and code embeds — and removed them from the main bundle; instead, we granularly load them only when needed.

However, what we didn't notice for months was that although we removed the navigation script from the bundle, it was loading after the entire app.js bundle was evaluated, which wasn't really helping Time-To-Interactive (see image above). We fixed it by preloading nav.js and deferring it to execute in the order of appearance in the DOM, and managed to save another 100ms with that operation alone. By the end, with everything in place we were able to bring the task to around 220ms.

We managed to get some improvement in place, but still have quite a way to go, with further React and Webpack optimizations on our to-do list. At the moment we still have three major Long Tasks — font switch (120ms), app.js execution (220ms) and style recalculations due to the size of full CSS (140ms). For us, it means cleaning up and breaking up the monolithic CSS next.
It's worth mentioning that these results are really the best-scenario- results. On a given article page we might have a large number of code embeds and video embeds, along with other third-party scripts and customer's browser extensions that would require a separate conversation.
Dealing With 3rd-Parties
Fortunately, our third-party scripts footprint (and the impact of their friends' fourth-party-scripts) wasn't huge from the start. But when these third-party scripts accumulated, they would drive performance down significantly. This goes especially for video embedding scripts , but also syntax highlighting, advertising scripts, promo panels scripts and any external iframe embeds.
Obviously, we defer all of these scripts to start loading after the DOMContentLoaded event, but once they finally come on stage, they cause quite a bit of work on the main thread. This shows up especially on article pages, which are obviously the vast majority of content on the site.
The first thing we did was allocating proper space to all assets that are being injected into the DOM after the initial page render. It meant width
and height
for all advertising images and the styling of code snippets. We found out that because all the scripts were deferred, new styles were invalidating existing styles, causing massive layout shifts for every code snippet that was displayed. We fixed that by adding the necessary styles to the critical CSS on the article pages.
We've re-established a strategy for optimizing images (preferably AVIF or WebP — still work in progress though). All images below the 1000px height threshold are natively lazy-loaded (with <img loading=lazy>
), while the ones on the top are prioritized ( <img loading=eager>
). The same goes for all third-party embeds.
We replaced some dynamic parts with their static counterparts — eg while a note about an article saved for offline reading was appearing dynamically after the article was added to the service worker's cache, now it appears statically as we are, well, a bit optimistic and expect it to be happening in all modern browsers.
As of the moment of writing, we're preparing facades for code embeds and video embeds as well. Plus, all images that are offscreen will get decoding=async
attribute, so the browser has a free reign over when and how it loads images offscreen, asynchronously and in parallel.

To ensure that our images always include width and height attributes, we've also modified Harry Roberts' snippet and Tim Kadlec's diagnostics CSS to highlight whenever an image isn't served properly. It's used in development and editing but obviously not in production.
One technique that we used frequently to track what exactly is happening as the page is being loaded, was slow-motion loading .
First, we've added a simple line of code to the diagnostics CSS, which provides a noticeable outline for all elements on the page.
* { outline: 3px solid red }
* { outline: 3px solid red }

* { outline: 3px red }
and observing the boxes as the browser is rendering the page. (大預覽)Then we record a video of the page loaded on a slow and fast connection. Then we rewatch the video by slowing down the playback and moving back and forward to identify where massive layout shifts happen.
Here's the recording of a page being loaded on a fast connection:
And here's the recording of a recording being played to study what happens with the layout:
By auditing the layout shifts this way, we were able to quickly notice what's not quite right on the page, and where massive recalculation costs are happening. As you probably have noticed, adjusting the line-height
and font-size
on headings might go a long way to avoid large shifts.
With these simple changes alone, we were able to boost performance score by a whopping 25 Lighthouse points for the video-heaviest article, and gain a few points for code embeds.
Enhancing The Experience
We've tried to be quite strategic in pretty much everything from loading web fonts to serving critical CSS. However, we've done our best to use some of the new technologies that have become available last year.
We are planning on using AVIF by default to serve images on SmashingMag, but we aren't quite there yet, as many of our images are served from Cloudinary (which already has beta support for AVIF), but many are directly from our CDN yet we don't really have a logic in place just yet to generate AVIFs on the fly. That would need to be a manual process for now.
We're lazy rendering some of the offset components of the page with content-visibility: auto . For example, the footer, the comments section, as well as the panels way below the first 1000px height threshold, are all rendered later after the visible portion of each page has been rendered.
我們已經玩了一些link rel="prefetch"
甚至link rel="prerender"
(NoPush prefetch) 頁面的一些很可能用於進一步導航的部分 - 例如,為第一個預取資產頭版上的文章(仍在討論中)。
我們還預加載作者圖像以減少最大內容繪製,以及每個頁面上使用的一些關鍵資產,例如跳舞的貓圖像(用於導航)和用於所有作者圖像的陰影。 但是,只有當讀者碰巧在更大的屏幕(>800px)上時,它們才會被預加載,儘管我們正在研究使用網絡信息 API 來更準確。
我們還通過刪除遺留代碼、重構許多組件以及刪除文本陰影技巧來減小完整 CSS 和所有關鍵 CSS 文件的大小,這些技巧是我們用來通過結合text-decoration-skip來實現完美下劃線的-ink和text-decoration-thickness (終於!)。
待完成的工作
我們已經花費了大量時間來解決網站上的所有次要和主要更改。 我們注意到台式機的顯著改進和移動設備的顯著提升。 在撰寫本文時,我們的文章在桌面上的 Lighthouse 得分平均在 90 到 100 之間,在移動設備上的平均得分在 65-80之間。


在移動設備上得分不佳的原因顯然是由於應用程序的啟動和完整 CSS 文件的大小導致交互時間和總阻塞時間不佳。 所以那裡還有一些工作要做。
至於接下來的步驟,我們目前正在研究進一步減小 CSS 的大小,並專門將其分解為模塊,類似於 JavaScript,僅在當需要。
我們還探索了在移動設備上進一步捆綁實驗的選項,以減少app.js對性能的影響,儘管目前這似乎並不簡單。 最後,我們將研究 cookie 提示解決方案的替代方案,使用 CSS clamp()
重建我們的容器,用aspect-ratio
替換填充底部比率技術,並研究在 AVIF 中提供盡可能多的圖像。
就是這樣,伙計們!
希望這個小案例研究對您有用,也許您可以立即將一兩種技術應用到您的項目中。 最後,性能就是所有細節的總和,這些細節加起來會影響或破壞客戶的體驗。
雖然我們非常致力於提高性能,但我們也致力於改善網站的可訪問性和內容。 因此,如果您發現任何不正確的地方或我們可以做的任何事情來進一步改進 Smashing Magazine,請在本文的評論中告訴我們。
最後,如果您想了解此類文章的最新信息,請訂閱我們的電子郵件通訊,以獲取友好的網絡提示、好東西、工具和文章,以及 Smashing cat 的季節性選擇。