一、概述
1.1 性能對業務的影響
大部分網站的作用是:產品信息載體、用戶交互工具或商品流通渠道。這就要求網站與更多用戶建立聯系,同時還要保持良好的用戶黏性,所以網站就不能只關注自我表達,而不顧及用戶是否喜歡。看看網站性能會造成哪些方面的影響:
- 用戶留存:Google 營銷平臺曾指出,如果網站加載時間超過 3 秒,就會有 53% 的移動網站訪問遭到用戶拋棄;BBC 發現網站加載時長每增加 1 秒,就會有 10% 的用戶流失;影響用戶留存的因素不止性能這一方面,優化網站性能卻是一項保證用戶留存率的必要措施。
- 網站轉化率:根據著名電子商務優化平臺 Mobify 的調研,發現商品結賬頁面加載時間每減少 100 毫秒,商品購買訪問的轉化率就會增加 1.55%,這個比率對大型電商網站來講,其所帶來的年均收入增長將會是上千萬元;根據 Google 營銷平臺的統計,得出加載時間在 5 秒內的網站會比 20 秒內的網站廣告收入多一倍,目前大部分互聯網都在實施精準化的廣告營銷(根據導流后產生的用戶交易數據計算廣告費用),網站性能不僅影響用戶體驗,還影響廣告主以及廣告商的經濟利益。
- 體驗與傳播:用戶瀏覽網站通常根據流量數據的字節數進行收費,從2G、3G再到4G及5G,運營商收取的流量費用單價一直在下降,網站頁面所承載的內容卻更加豐富以及多元化。在這樣的發展趨勢下,如果網站包含的資源文件過大或者冗余,用戶會浪費過多的網絡資費,同時過大的資源傳輸量也會延長請求響應時間,最終損害用戶體驗。性能問題引發的用戶體驗差,會被用戶差評甚至失去這個用戶,更壞的情況是因拒絕向周邊朋友介紹該網站,失去更多潛在用戶的可能性。
1.2 性能評估模型
Google Chrome 團隊于 2015 年提出一種用于提升瀏覽器內的用戶體驗和性能的 RAIL 模型(Response、Animation、Idle 和 Load 的首字母縮寫,如圖1-1所示),該模型的理念是不是單純追求技術指標(加載速度、FPS等),而是通過優化用戶的實際體驗(點擊響應、動畫流暢度等)來提升滿意度。即使網站在高性能設備上運行很快,如果用戶感知到卡頓或延遲,仍然會被定義為體驗糟糕。
- 響應:快速確認并反饋用戶的交互(比如鼠標懸停或按下按鈕)非常重要,最好在 50 毫秒內完成,最長不要超過 100 毫秒。反應時間超過 100 毫秒會導致用戶交互和響應之間脫節,如果響應需要超過 100 毫秒才能完成,需要提供某種形式的反饋(比如倒計時或進度條)通知用戶交互已發生。
- 動畫:任何低于 60fps 的動畫(滾動、拖動及其他動效),尤其是不均勻或變化的幀速率,都會使頁面顯得卡頓。為了保證動畫流暢性和視覺連續性,內容重繪應以 60fps 的速度進行,即每 16.7 毫秒一次(包括腳本執行、重排和重繪)。瀏覽器渲染一幀大約需要 6 毫秒,因此盡量在 10 毫秒內生成動畫的每一幀。
- 空閑:瀏覽器是單線程的(Web Worker可以支持后臺線程),這意味著用戶交互、繪制和腳本執行都在同一個線程上。如果線程忙于執行復雜的 JavaScript 代碼,主線程將無法對用戶輸入(例如按下按鈕)做出反應。因此腳本要劃分為可以在 50 毫秒或更短時間內執行的代碼塊,使得線程可以及時響應用戶交互產生的任務。
- 加載:在 1 秒內向用戶反饋網站請求已發出并將加載(顯示頁面標題以及背景色等),否則用戶注意力會游離,焦點很容易離開網站。很少有網站能在 1 秒內完成加載,最好根據設備、網絡等條件制定目標:中配 3G 網絡手機加載網站不超過 5 秒,辦公室 T1 線路加載網站不超過 1.5 秒,并以更快的速度加載后續頁面。
二、前端頁面生命周期
經典面試題:從瀏覽器地址欄輸入URL后,到頁面渲染出來,整個過程都發生了什么?
2.1 網絡請求
2.1.1 DNS解析
DNS(Domain Name System)解析是將域名轉換為 IP 地址的過程(如圖 2-1 所示),其解析速度直接影響用戶的網絡體驗:如果解析速度較慢,用戶會感受到網站加載延遲,看起來“卡”一下。
提高 DNS 解析速度可以采取以下措施:
- dns-prefetch:在網頁頭部使用
<link>
標簽指定瀏覽器預解析可能會被訪問的域名,當用戶點擊鏈接時目標網址可能已被解析,減少用戶等待時間。
<link rel="dns-prefetch" href="//dss0.bdstatic.com">
- 簡化 DNS 記錄:盡可能使用 A 記錄(對于 IPv6 使用 AAAA 記錄)直接指向 IP 地址,減少使用 CNAME 記錄(CNAME 記錄將一個域名解析到另一個域名),避免額外的 DNS 查詢發生;定期審查和清理無用或過時的 DNS 記錄,避免解析過程中非必要的查詢。
? ~ dig A www.baidu.com;; QUESTION SECTION:
;www.baidu.com. IN A;; ANSWER SECTION:
www.baidu.com. 1200 IN CNAME www.a.shifen.com.
www.a.shifen.com. 110 IN A 220.181.111.1
www.a.shifen.com. 110 IN A 220.181.111.232
- 基建設施:選擇可靠的 DNS 服務提供商,啟用 DNS 緩存,定期監控 DNS 解析性能。
2.1.2 網絡模型
獲取到目標服務器 IP 地址后,就可以建立網絡連接進行資源的請求與響應。國際標準化組織提出了 OSI 網絡架構模型(Open System Interconnect,即開放式系統互連),將網絡從物理層(網絡設備底層)到應用層(瀏覽器)共劃分 7 層。
OSI 層級 | 具體作用 |
應用層 | 負責給應用程序提供接口,使其可以使用網絡服務,HTTP協議就位于該層。 |
表示層 | 負責數據的編碼與解碼、加密和解密、壓縮和解壓縮。 |
會話層 | 負責協調系統之間的通信過程。 |
傳輸層 | 負責端到端連接的建立,使報文能在端到端之間進行傳輸,TCP/UDP協議位于該層。 |
網絡層 | 為網絡設備提供邏輯地址,使位于不同地理位置的主機之間擁有可訪問的連接和路徑。 |
數據鏈路層 | 在不可靠的物理鏈路上,提供可靠的數據傳輸服務。包括組幀、物理編址、流量控制、差錯控制、接入控制等。 |
物理層 | 定義網絡的物理拓撲、物理設備的標準(如介質傳輸速率、網線或光纖的接口模型等)、比特的表示以及信號的傳輸模式。 |
2.2 瀏覽器關鍵渲染路徑
瀏覽器頁面的渲染是一個復雜且涉及多個步驟的過程(如圖 2-1 所示),
- 解析HTML:瀏覽器讀取 HTML 文件(從服務器或本地讀取文件),然后將 HTML 標簽解析為 DOM(文檔對象模型)。
- 解析CSS:將外部 CSS 文件、HTML 內部 CSS 以及元素內聯的樣式數據解析為 CSSOM(CSS對象模型),定義頁面中所有元素的樣式。
- 合成渲染樹:DOM 和 CSSOM 會結合生成渲染樹,每個節點都對應一個
RenderObject
(保存繪制 DOM 節點所需要的各種信息);對于不可見元素,例如<script>
、<meta>
或者 display 為 none 的元素,則不會在渲染樹中出現。 - 布局(Layout):布局是指渲染樹創建完成后,瀏覽器計算每個節點的確切位置和大小的過程。布局必須考慮視口大小、元素的尺寸、盒模型等多種因素。
- 繪制(Paint):繪制是指將布局的結果輸出到屏幕的過程。通常繪制會分為多個
RenderLayer
(圖層)進行處理,這一步涉及填充像素、繪制文本、顏色、圖片、邊框和陰影等。圖層存在的目的是讓頁面元素以正確的順序組合,從而正確顯示重疊內容、半透明元素等。如果RenderObject
滿足以下條件之一,創建對應的圖層,否則使用其祖先(第一個具有圖層的祖先)的圖層:
-
- 有明確 CSS 定位信息(relative、absolute)或者 transform 屬性的節點
- 透明節點
- 有 overflow、mask-image 或者 box-reflect 屬性的節點
- 有 filter 屬性的節點
webgl
或者有硬件加速 2D 上下文的canvas
<video>
- 合成(Composite):獲得每個圖層的信息后,需要將其合并到同一個圖像上,這個過程就是合成。軟件渲染是按照從前到后的順序在同一個內存空間完成每一層的繪制,實際上不需要合成。在現代瀏覽器中(尤其是移動端設備),使用 GPU 完成的硬件加速繪圖更為常見,硬件加速繪圖需要合成(合成都是使用 GPU 完成的),整個過程稱之為硬件加速的合成化渲染(如圖 2-2 所示),相關原理可以參考:GPU Accelerated Compositing in Chrome。對于常見的 2D 繪圖操作,例如繪制文字、點、線等,使用 GPU 來繪圖不一定比 CPU 在性能上有優勢,原因是 CPU 使用緩存機制可以有效減少重復繪制的開銷而不需要 GPU 并行性。為了節省 GPU 的顯存資源,瀏覽器會按照一定的規則將一些圖層組合在一起,形成一個有后端存儲的新層,稱之為
GraphicsLayer
(合成層),用于之后的合成。使用 Chrome 的 DevTools 可以方便查看頁面的合成層以及創建原因(如圖 2-3 所示)。如果一個圖層具有以下特征之一,創建對應的合成層,否則使用其祖先(第一個具有合成層的祖先)的合成層:
-
- 使用 3D 或者透視變換的 CSS 屬性
- 使用硬件加速視頻解碼技術的
<video>
- 使用
webgl
或者有硬件加速 2D 上下文的canvas
- 有 opacity 或者transform 屬性變化的動畫
- 有硬件加速 filter 屬性的節點
- 后代包含一個合成層
- 有一個
z-index
比自身小的兄弟節點且這個兄弟節點為合成層
三、性能測量
3.1 Performance面板
Chrome Performance面板可以對網站運行時的性能表現進行檢測與分析,推薦在無痕模式下使用,避免既有緩存和Chrome上安裝的插件影響性能分析結果,如圖3-1所示。
性能報告會對主線程相關活動進行記錄(Main區域),由于圖表長得像一團團倒立的火焰,也被稱為火焰圖。火焰圖的橫軸代表時間,縱軸代表調用堆棧,如圖 3-2 所示。最上方的灰色長條表示一個由瀏覽器調度和執行的任務,其長度表示這個任務執行時間的跨度。有的任務部分被紅色密集實線覆蓋,同時右上角還有一個紅色的三角形,用作標識長任務(超過 50 毫秒的部分會用這種紅色密集實線覆蓋)。長任務會阻塞主線程,導致頁面卡頓、無法及時響應用戶輸入等,是需要開發者重點關注的對象。
性能報告中的核心指標,可以衡量頁面加載以及渲染的性能狀況。
指標 | 定義 |
DCL(DOMContentLoaded) | DOM解析完畢 |
FCP(First Contentful Paint) | 首次內容渲染時間(內容可以是文本、圖片、Canvas) |
LCP(Largest Contentful Paint) | 最大內容渲染時間 |
L(Load) | 頁面依賴的所有資源加載完畢 |
3.2 Lighthouse
Lighthouse可以監控和檢測網站各方面的性能表現,為開發者提供用戶體驗和網站性能優化的指導建議。在 Chrome應用商店搜索 Lighthouse 擴展程序并進行安裝,如圖 3-3 所示。
Lighthouse會對性能、可訪問性(對于殘障用戶的可用性)、最佳實踐(常見安全和性能的最佳實踐)和搜索引擎優化這 4 個維度進行評估得分,同時附帶相關優化建議,分數越高說明該維度表現越好,如圖 3-4 所示。
四、性能優化策略
4.1 構建優化
頁面在渲染之前需要請求很多資源文件,如:HTML文件、CSS文件、JavaScript文件及圖片等其他資源,如何更快速地請求到資源是一個非常值得關注的優化點。主要有兩個思路:一方面是減少 HTTP 請求資源的大小,另一方面是減少 HTTP 的請求數量。本節以主流構建工具 Webpack 為例進行講解。
- 壓縮與合并:
文件類型 | 優化方向 | 目的 | 方案 |
HTML | 壓縮 | 刪除格式化字符以及注釋 | 使用 |
CSS | 壓縮 | 刪除格式化字符以及注釋、語義合并(合并選擇器、簡寫屬性值等) | 使用 |
合并 | 將 CSS 提取到一個文件中 | 使用 | |
JavaScript | 壓縮混淆 | 刪除格式化字符及注釋、混淆變量與方法命名; | 使用 |
合并 | 多個文件打成一個 | 支持根據入口分析文件依賴 |
- 可視化分析:合并和壓縮之后,如果還是覺得性能不佳,又不知道哪里出了問題,可以使用
webpack-bundle-analyzer
對打包結果進行可視化分析,如圖 4-1 所示。開發人員可以快速通過直觀的界面了解構建產物的組成部分,以及每個模塊的大小、占比和依賴關系。識別出那些占用空間較大的模塊,并采取相應的優化措施。
4.2 加載優化
4.2.1 CSS/JavaScript
瀏覽器向網絡請求到的所有數據,并非每個字節都具有相同的優先級或重要性。所以瀏覽器會對所要加載的內容先進行推測,將相對重要的信息優先呈現給用戶,比如:一般會先加載 CSS 文件,然后再加載 JavaScript 文件和圖片。使用 Chrome 的 DevTools可以看到分配給不同資源的優先級(如圖 4-2 所示),分為Highest、High、Medium、Low、Lowest這 5 個等級。
根據瀏覽器為資源分配下載優先級的方式,可以針對實際業務場景適當做一些調整:
- 調整順序:根據期望的資源下載順序放置資源標簽,例如
<script>
和<link>
,具有相同優先級的資源通常按照被放置的順序加載。 - 異步加載:
<script>
下載和執行會阻塞 HTML 解析,建議使用async
或defer
下載首屏不需要阻塞的 JavaScript 文件;如圖 4-3 所示,async
完成下載后會立即執行,而defer
會在HTML解析完成之后再執行。 - 預加載:使用
<link rel="preload">
提前下載必要的資源,比如自定義字體、關鍵圖片及樣式文件等,避免頁面出現 FOUC 現象(Flash of Unstyled Text:無樣式內容閃爍)。
<link rel="preload" as="font" crossorigin href="https://at.alicdn.com/t/font_zck90zmlh7hf47vi.woff">
4.2.2 圖片
當用戶在瀏覽一個內容豐富的網站時,由于屏幕尺寸的限制,每次只能查看到視窗中的那部分內容,滾動頁面后屏幕視窗才會依次展示出全部內容。為了提高首屏內容的加載速度,短時間內讓用戶對網站產生興趣,必須對非首屏之外的圖片做懶加載處理,這個優化策略在業界已經被廣泛使用。
- 原生支持懶加載:從 Chrome 75 版本開始,
<img>
可以通過loading
原生支持懶加載;
<img src="https://images.pexels.com/photos/23719800/pexels-photo-23719800.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2" loading="lazy" alt="示例圖片">
- 自定義懶加載:更好的做法是圖片即將滾動出現在屏幕視窗之前一段距離,就提前加載圖片。否則,如果存在網絡延遲或圖片資源過大,用戶會看到占位圖以及目標圖片加載的全過程。可以使用
IntersectionObserver
對懶加載進行更細粒度的控制。
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Document</title><style>*{margin: 0;padding: 0;box-sizing: border-box;}ul{list-style: none;}.box{width: 80%;margin: 0 auto;display: flex;flex-wrap: wrap;}.item{width: calc( 33.33% - 20px );margin: 10px;min-height: 200px;}.item img{width: 100%;}</style>
</head>
<body><div class="box"></div>
</body>
<script>// 定義默認占位圖const defaultURL = 'https://www.baidu.com/img/PCtm_d9c8750bed0b3c7d089fa7d55720d6cf.png';// 定義圖片網絡地址let imgList = ['https://images.pexels.com/photos/23719800/pexels-photo-23719800.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2','https://images.pexels.com/photos/20569931/pexels-photo-20569931.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2','https://images.pexels.com/photos/23105903/pexels-photo-23105903.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2','https://images.pexels.com/photos/22816073/pexels-photo-22816073.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2','https://images.pexels.com/photos/18197764/pexels-photo-18197764.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2','https://images.pexels.com/photos/24031902/pexels-photo-24031902.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2','https://images.pexels.com/photos/23914518/pexels-photo-23914518.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2','https://images.pexels.com/photos/16118941/pexels-photo-16118941.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2','https://images.pexels.com/photos/22937531/pexels-photo-22937531.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2','https://images.pexels.com/photos/21915597/pexels-photo-21915597.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2','https://images.pexels.com/photos/16703290/pexels-photo-16703290.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2','https://images.pexels.com/photos/13298586/pexels-photo-13298586.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2','https://images.pexels.com/photos/18109714/pexels-photo-18109714.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2','https://images.pexels.com/photos/22670156/pexels-photo-22670156.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2','https://images.pexels.com/photos/21852583/pexels-photo-21852583.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2','https://images.pexels.com/photos/21367366/pexels-photo-21367366.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2','https://images.pexels.com/photos/17102067/pexels-photo-17102067.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2','https://images.pexels.com/photos/24038436/pexels-photo-24038436.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2','https://images.pexels.com/photos/19473669/pexels-photo-19473669.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2','https://images.pexels.com/photos/20470948/pexels-photo-20470948.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2','https://images.pexels.com/photos/22469105/pexels-photo-22469105.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2','https://images.pexels.com/photos/13743557/pexels-photo-13743557.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2','https://images.pexels.com/photos/23230661/pexels-photo-23230661.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2',];// 定義圖片列表容器let box = document.querySelector('.box');// 工具函數: 定義渲染頁面結構function initImg(list){for( let i = 0; i < list.length; i++ ){let div = document.createElement('div');div.className = 'item';let img = document.createElement('img');img.src = defaultURL;img.dataset.url= list[i];div.appendChild(img);box.appendChild(div);}}// 工具函數: 懶加載圖片function Observer(list){let observer = new IntersectionObserver(function(entries, self){for( let n = 0; n < entries.length; n++ ){if( entries[n].isIntersecting && entries[n].target.children[0].src.includes( "www.baidu.com" ) ){entries[n].target.children[0].src = entries[n].target.children[0].dataset.url;self.unobserve(entries[n].target);}}}, {rootMargin: '50px 0px', // 視圖范圍擴大 50px 觸發threshold: 0.3 // 觸發閾值設置為 30%})for( let i = 0; i < list.length; i++ ){observer.observe(list[i])}}// 初始化圖片列表initImg(imgList); // 待頁面加載完畢再進行圖片懶加載window.onload = function() {let items = document.querySelectorAll('.item');Observer(items);}
</script>
</html>
4.2.3 視頻
視頻資源體積一般比較大,網絡請求相對耗時。要防止提前加載過多視頻資源阻塞頁面渲染,避免對頁面性能造成負面影響。
- 阻止視頻預加載:將
preload
設置為 none 可以阻止視頻自動預加載,盡早觸發load
事件。
<video preload="none" width="600" height="400" controls><source src="http://runoob.com/try/demo_source/movie.mp4" type="video/mp4">
</video>
- 替代GIF動畫:GIF動畫在輸出文件大小、圖像色彩質量等許多方面的表現均不如視頻,建議將視頻代替尺寸過大的GIF動畫。HTTP1.1協議規定同一域名下的最大連接數為6,如果資源請求連接超上限,多余連接會被掛起,必須控制首屏視頻加載的數量。
<video width="600" height="400" autoplay muted loop playsinline><source src="http://runoob.com/try/demo_source/movie.mp4" type="video/mp4">
</video>
4.3 渲染優化
4.3.1 計算樣式優化
構建渲染樹時,對于每個 DOM 元素,必須在所有樣式規則中查詢符合的選擇器,并將對應的規則進行合并。渲染樹構建完成后,要經歷一個布局的過程,即為每個節點提供其在屏幕上應出現的準確坐標,然后遍歷渲染樹并在頁面繪制每個節點。
- 減少計算樣式的元素數量:為了提高查詢效率,瀏覽器使用
逆向匹配
原則(從右向左)來判斷某個選擇器是否匹配當前 DOM 元素,這與我們通常從左向右的書寫習慣相反。
-
- 使用
類選擇器
替代標簽選擇器
:減少從整個頁面中查找標簽元素的范圍;
- 使用
/* 錯誤 */
.product-list li{}/* 正確 */
.product-list_li{}
-
- 避免使用通配符做選擇器:使用通配符清除默認樣式是一個很差的習慣,會導致計算開銷大;
/* 錯誤 */
* {margin:0;padding:0;border:0;font-size: 12px;vertical-align: baseline;
}
- 降低選擇器的復雜性:項目在長期迭代和維護的過程中,系統復雜性逐步變高,樣式規則也會不斷被擴展。
-
- 對確定元素使用單一的
類選擇器
:
- 對確定元素使用單一的
/* 錯誤 */
.container:nth-last-child(1).content {}/* 正確 */
.final-container-content {}
-
- 推薦使用
BEM規范
對選擇器進行命名:保證所有元素都被單一的類選擇器
修飾;
- 推薦使用
/* 常規寫法 */
.my-list {}
.my-list .item {}/* BEM寫法 */
.my-list__item_big {} /* 大尺寸 */
.my-list__item_normal {} /* 中尺寸 */
.my-list__item_small {} /* 小尺寸 */
.my-list__item_size-10 {} /* 自定義 */
4.3.2 JavaScript執行優化
4.3.2.1 數據讀取
- 警惕作用域鏈過長:變量位于作用域鏈中的位置越深,被 JavaScript 引擎訪問到所需的時間就越長,所以要留心對作用域鏈的管理。
-
- 如果一個非局部變量在函數中的使用次數不止一次,最好使用局部變量進行存儲,以提升其在作用域鏈中的查找順序。
with
可以提供一種讀取對象屬性的快捷方式,會在運行時創建新的作用域。在實際開發中卻不推薦:with
的動態性導致 JavaScript 引擎無法在編譯時對作用域查找進行優化,代碼運行變慢、性能下降;另外,在嚴格模式下也被禁止使用。
/** 錯誤 **/
function process() {const target = document.getElementById('target');const imgs = document.getElementsByClassName('img');for (let i = 0; i < imgs.length; i++) {const img = imgs[i];// 省略相關處理流程...target.appendChild(img);}
}/** 正確 **/
function process() {const doc = document; // 全局變量 document 在函數中不止使用一次: 聲明為局部變量提升其在作用域鏈中的查找順序const target = doc.getElementById('target');const imgs = doc.getElementsByClassName('img');const len = imgs.length; // 在 for 循環 length 屬性被讀取多次:從讀取速度來看變量要快于對象屬性for (let i = 0; i < len; i++) {const img = imgs[i];// 省略相關處理流程...target.appendChild(img);}
}
- 合理使用閉包:閉包指的是有權訪問另一個函數作用域中的變量的函數,可以被理解為定義在一個函數內部的函數,內部函數可以訪問到外部函數的局部變量。閉包是 JavaScript 中的一個強大特性,常用于創建私有變量和封裝功能。函數執行完成后,其中局部變量所占用的空間會被釋放。如果閉包本身被持續引用,就會延長外部函數中局部變量的生命周期,帶來更大的內存開銷及甚至內存泄漏。出于對性能的考慮,當閉包不再被需要時,將其賦值為
null
,從而確保其內存可以在適當的時機回收。
function add () {var count = 0;return function fn() {count++;console.log(count);}
}var a = add(); // 產生閉包
a(); // 1
a(); // 2
a = null; // 解除 a 與 fn 的聯系: 瀏覽器可以回收相應內存空間
4.3.2.2 流程控制
- 條件判斷:通常對于多個離散值的取值條件判斷,使用
switch
會比if-else
具有更高的性能表現。關于if-else
有兩種優化思路:
-
- 思路一:預估條件被匹配到的概率,按照概率降序來排列
if-else
語句,讓匹配概率高的條件更快執行,在整體上降低程序花費在條件判斷上的時間。 - 思路二:利用二分法的思想,當不能預估條件被匹配到的概率時,對取值區間的邊界進行明確劃分,降低匹配條件的執行次數;缺點是代碼可讀性稍低。
- 思路一:預估條件被匹配到的概率,按照概率降序來排列
/** 優化思路一:按概率降序排列 **/
if (value === 8) {// 匹配到8的概率最高
} else if (value === 7) {// 匹配到7的概率僅次于8
} else if (value === 6) {// 匹配到6的概最低
} else {// 不需要對6之外的條件進行判斷
}/** 優化思路二:二分法 **/
if (value < 4) {if (value < 2) {// 值在2到下界之間取值} else {// 值在2或3之間}
} else {if (value < 6) {//值在4或5之間} else {//值在6到上界之間取值}
}
- 循環:一個循環語句的執行次數直接影響程序的時間復雜度,對程序執行性能的影響很大。如果代碼中存在缺陷導致循環不能及時停止,從而造成死循環,給用戶帶來非常糟糕的使用體驗。當循環次數非常多時,
for
的執行性能要明顯強于for-in
、for-of
以及forEach
。 - 遞歸:遞歸是一種通過空間換時間的算法,內存的開銷將與遞歸次數成正比。瀏覽器會限制 JavaScript 調用棧的大小,超出限制遞歸執行便會失敗。以構造斐波那契數列為例,遞歸有兩種優化思路,避免重復計算顯著提升執行性能,且防止 JavaScript 調用棧溢出:
-
- 思路一:改用迭代方式
- 思路二:用數組對中間結果做存儲(性能與迭代方式接近)
/** 不推薦:遞歸方式 **/
function fibonacci_Recursive(n) {count++;if (n <= 0) {return 0;} else if (n === 1) {return 1;} else {return fibonacci_Recursive(n - 1) + fibonacci_Recursive(n - 2);}
}/** 優化思路一:迭代方式 **/
function fibonacci_Iteration(n) {count++;if (n <= 0) {return 0;} else if (n === 1) {return 1;} else {let a = 0;let b = 1;let temp;for (let i = 2; i <= n; i++) {temp = a + b;a = b;b = temp;}return b;}
}/** 優化思路二:數組存儲 **/
function fibonacci_Array(n) {count++;let fib = [0, 1];for (let i = 2; i <= n; i++) {fib[i] = fib[i - 1] + fib[i - 2];}return fib[n];
}
4.3.2.3 字符串處理
處理大量數據或復雜匹配模式時,草率地編寫正則表達式有可能因為回溯
失控導致性能問題發生。回溯
是正則表達式引擎在嘗試匹配字符串時使用的一種機制,引擎會從左到右逐步檢查正則表達式的每個組件是否與字符串的相應部分匹配。如果在某個點上,后續的字符串無法匹配當前正則表達式的組件,引擎則會退回到之前的匹配點,并嘗試其他可能的匹配選項。這種機制允許正則表達式處理復雜的情況,如可選元素、重復元素和條件分支等。優化正則表達式并減少不必要的回溯,可以考慮以下方法:
- 使用非捕獲組:
前瞻斷言
可以檢查某個模式之后是否緊跟著指定的模式,而不需要引擎回溯到先前的位置;后顧斷言
(ES2018中新增的功能)也同理,檢查左側是否為特定模式的文本。
let str = 'JackSprat';/** 正向前瞻斷言 **/
str.match(/Jack(?=Sprat)/); // 匹配后面緊跟'Sprat'的'Jack'/** 負向前瞻斷言 **/
str.match(/Jack(?!Sprat)/); // 匹配后面不緊跟'Sprat'的'Jack'/** 正向后顧斷言 **/
str.match(/(?<=Jack)Sprat/); // 匹配前面有'Jack'的'Sprat'/** 負向后顧斷言 **/
str.match(/(?<!Jack)Sprat/); // 匹配前面沒有'Jack'的'Sprat'
- 避免嵌套重復:當字符串只有部分符合模式時,為了找到正確的匹配,引擎會不斷嘗試所有可能的組合,這可能導致極端的性能問題,甚至使應用程序凍結或崩潰。
let str = '11ab1111111111111111111';/** 錯誤 **/
str.match(/a(.+)+b/); // 耗時: 41.423 ms/** 正確 **/
str.match(/a(.+)b/); // 耗時: 0.065 ms
- 避免過于寬泛的匹配:在匹配大量文本時,避免使用如 .* 或 .+ 這類可能導致大量
回溯
的模式,應該使用更具體的字符集,對可能的匹配進行嚴格界定。
4.3.2.4 處理大量計算
現代瀏覽器架構使用沙箱模式
管理,通常為每個標簽頁單獨分配一個渲染進程。每個渲染進程都有 JavaScript 引擎線程和 GUI 渲染線程,由于JavaScript 可以操縱 DOM 元素,為了防止渲染出現不可預期的結果,瀏覽器將 JavaScript 引擎線程和 GUI 渲染線程設置為互斥的關系, 當 JavaScript 引擎執行時 GUI 渲染線程會被掛起。假設 JavaScript 引擎正在進行大量計算,此時就算 GUI 有更新,也會被保存到隊列中,等待 JavaScript 引擎空閑后執行。如果 JavaScript 運行時間過長,必然就會阻塞頁面渲染更新,造成頁面丟幀、渲染不連貫甚至嚴重卡頓。有兩個解決思路:
- 創建 Web Worker 子線程:如果有非常耗時的工作(大量計算),可以在 Web Worker 子線程進行處理,通過
postMessage
與JavaScript 引擎線程進行消息通信(傳輸序列化對象)。在 Web Worker 子線程無法操作 DOM,并要求執行的代碼文件與 JavaScript 引擎線程的代碼文件同源。
const myWorker = new Worker('./worker.js');myWorker.onmessage = function (e) {console.log('Fibonacci result:', e.data)}myWorker.postMessage(40); // 請求計算斐波那契數列第 40 項: 102334155
self.onmessage = function (e) {const n = e.data;let a = 0, b = 1, temp;for (let i = 2; i <= n; i++) {temp = a;a = b;b = temp + b;}self.postMessage(b);
}
- 將大型任務拆分為多個小任務:如果要處理的任務必須在 JavaScript 引擎線程上完成,可以考慮將一個大型任務拆分為多個小任務,盡量讓每個小任務處理的耗時在幾毫秒之內,避免因執行任務導致頁面不能刷新以及無法響應用戶的交互操作。
Fiber
算法就使用了類似的設計思想:將reconciler
過程拆分成多個小任務,并在小任務執行后暫停執行代碼,檢查頁面是否有需要更新的內容和響應的事件,做出相應處理后再繼續執行代碼,解決了React
在動畫和復雜更新中的性能瓶頸,顯著增強大型應用的性能和使用體驗。
-
- 方法一:
requestAnimationFrame
會在下一幀重繪之前執行回調函數,回調函數的執行頻率通常與顯示器的刷新率相匹配。 - 方法二:
requestIdleCallback
會在當前幀空閑時執行回調函數,若當前幀沒有足夠的空閑時間,等待時間已超時也會立即執行回調函數;特別適合處理對時間要求不嚴格的低優先級任務,為了防止無限等待,強烈建議設置等待時間。
- 方法一:
// 定義100個事件的隊列: 每個事件可設置限時
const todoList = [];
for (let i = 1; i <= 100; i++) {const fn = (function (index) {return mes => {console.log(`執行任務${index}: ${mes}`);};})(i);todoList.push({fn,timeout: 20, //ms});
}/** 方法一:requestAnimationFrame **/
let frameIndex = 0;
function lazyQueueRAF(queue, onComplete = () => {}) {const iterator = queue[Symbol.iterator]();let task = iterator.next();function execTask(DOMHighResTimeStamp) {frameIndex++;let taskStartTime = window.performance.now();let taskFinishTime;do {const { fn } = task.value;if (typeof fn === 'function') {fn(`第 ${frameIndex} 幀 上一幀渲染的結束時間為 ${DOMHighResTimeStamp}`);}task = iterator.next();taskFinishTime = window.performance.now();} while (taskFinishTime - taskStartTime < 3 && !task.done);if (!task.done) {requestAnimationFrame(execTask);} else {onComplete();}}requestAnimationFrame(execTask);
}
const start_RAF = Date.now();
lazyQueueRAF(todoList, () => {console.log(`所有任務完成了: 耗時${Date.now() - start_RAF}ms`);
});/** 方法二:requestIdleCallback **/
function lazyQueueRIC(queue, onComplete = () => {}) {const iterator = queue[Symbol.iterator]();let task = iterator.next();function execTask(deadline) {// 利用空閑時間執行while (deadline.timeRemaining() && !task.done) {const { fn } = task.value;if (typeof fn === 'function') {fn('空閑');}task = iterator.next();}// 超過等待時間立即執行if (deadline?.didTimeout && !task.done) {const { fn } = task.value;if (typeof fn === 'function') {fn('超時');}task = iterator.next();}if (!task.done) {const { timeout } = task.value;requestIdleCallback(execTask, {timeout,});} else {onComplete();}}requestIdleCallback(execTask, {timeout: task.value.timeout,});
}
const start_RIC = Date.now();
lazyQueueRIC(todoList, () => {console.log(`所有任務完成了: 耗時${Date.now() - start_RIC}ms`);
});
4.3.2.5 事件節流和防抖
當用戶發生交互的過程中,勢必會有鼠標移動、鍵盤輸入、頁面滾動及縮放的操作頻繁發生,導致相應 DOM 事件的回調函數被大量計算,進而引發頁面抖動甚至卡頓。采用事件節流和防抖技術,可以稀釋回調函數的執行頻率,更快對用戶的交互進行響應、避免回調函數在短時間內高頻執行引發的各種性能問題。
概念 | 定義 | 適用場景 |
節流 | 在單位時間內觸發多次函數,只有一次能生效。 | 高頻率事件觸發:頁面滾動、鼠標移動等。 |
防抖 | 當事件觸發結束后,延時一段時間再執行函數。如果延時期間再次觸發該事件,則重新計算延時時間。 | 連續事件觸發:搜索框輸入、滾動加載更多等。 |
/** 節流 **/
function throttle(func, limit) {let inThrottle;return function() { const context = this; const args = arguments;if (!inThrottle) { func.apply(context, args); inThrottle = true; setTimeout(function() { inThrottle = false; }, limit); } };
}/** 防抖 **/
function debounce(func, wait) { let timeout; return function() { const context = this; const args = arguments; clearTimeout(timeout); timeout = setTimeout(function() { func.apply(context, args); }, wait); };
}
4.3.3 重排和重繪優化
在瀏覽器中,重排和重繪是兩個與頁面渲染相關的兩個概念。重排是一種較為昂貴的操作,會導致頁面性能下降。重繪的開銷通常比重排小,但仍然會影響性能。需要注意的是,重繪不一定需要重排,重排必然導致重繪。
概念 | 定義 | 觸發操作 |
重排 | 當渲染樹的一部分必須更新并且節點的尺寸發生了變化,瀏覽器會使渲染樹中受到影響的部分失效,并重新構造渲染樹。 |
|
重繪 | 元素外觀被改變所觸發的瀏覽器行為,瀏覽器會根據元素的新屬性重新繪制,使元素呈現新的外觀。 |
|
- 使用類去修改樣式:逐行修改元素樣式是非常糟糕的編碼方式,每行都會觸發一次對渲染樹的更改,會導致重新計算頁面布局而帶來巨大的性能開銷。合理的做法是將多行的樣式修改合并到一個 CSS 類名中,僅在 JavaScript 代碼中添加或更改類名即可。
/** 錯誤 **/
const div = document.getElementById('mydiv');
div.style.height = '100px';
div.style.width = '100px';
div.style.border = '2px solid blue';/** 正確 **/
const div = document.getElementById('mydiv');
mydiv.classList.add('my-div');
.my-div {height: 100px;width: 100px;border: 2px solid blue;
}
- 緩存敏感屬性的計算:有些場景需要通過多次計算來獲得某個元素在頁面中的布局位置,賦值環節以及讀取敏感屬性都會觸發頁面布局的重新計算,這樣頁面的性能會非常差。合理的做法是將敏感屬性通過變量的形式緩存起來,等計算完成后再統一進行賦值。
/** 錯誤 **/
const list = document.getElementById('list');
for (let i = 0; i < 10; i++){list.style.top = `${list.offsetTop + 10}px`;list.style.left = `${list.offsetTop + 10}px`;
}/** 正確 **/
const list = document.getElementById('list');
// 將敏感屬性緩存起來
let offsetTop = list.offsetTop
let offsetLeft = list.offsetLeft;
for (let i= 0; i < 10; i++) {offsetTop += 10;offsetleft += 10;
}
// 計算完成后統一賦值觸發重排
list.style.left = offsetLeft;
list.style.top = offsetTop;
4.3.4 合成處理
合成處理是將已繪制的不同圖層放在一起,最終在屏幕上渲染出來的過程。將固定區域和動畫區域拆分到不同圖層上進行繪制,可以實現繪制區域最小化,能夠降低繪制復雜度。
創建新圖層的方式可以參考見 2.2 小節,同時要控制創建圖層的數量,否則會為瀏覽器帶來過多的內存分配及管理開銷,導致網站性能變得更差。
4.4 數據緩存
如果網站一直重復請求服務器獲取相同的數據,不僅浪費網絡帶寬,頁面內容更新也會延遲,從而影響用戶的使用體驗。如果用戶使用按流量計費的方式訪問網站,多余的請求還會造成網絡流量資費增加。因此使用緩存技術對已獲取的數據進行重用,是一種提升網站性能與體驗的有效策略。緩存主要分兩大類:共享緩存和私有緩存。
- 共享緩存:可以被多個用戶訪問的緩存,這類緩存通常位于網絡中的代理服務器或者
CDN
節點上,主要目的是減少服務器負載、加速內容分發,減少從源服務器到用戶之間的延遲。 - 私有緩存:只供單一用戶使用的緩存,這類緩存通常存在于用戶的設備中,主要目的是提高單個用戶的數據訪問速度和使用體驗。
4.4.1 HTTP緩存
強制緩存和協商緩存都是 HTTP 協議中的緩存機制,用于提高網站的性能和響應速度,同時確保用戶可以看到最新內容。
- 強制緩存:服務器會將緩存相關的頭部信息(cache-control 優先級高于 expires)發送給瀏覽器,告訴瀏覽器在一定時間內可以從緩存中獲取文件。當瀏覽器判斷本地緩存未過期時,狀態碼返回 200 并直接讀取本地緩存,無須向服務器發起 HTTP 請求。適用于不經常變動的靜態資源(圖片、CSS 及 JavaScript 文件等)。
- 協商緩存:如果沒有命中強緩存,瀏覽器會發送請求到服務器,并攜帶首次請求返回有關緩存的頭部信息(Etag/If-None-Match 優先級高于 Last-Modified/If-Modified-Since),服務器判斷請求資源是否被修改。如果沒有修改,狀態碼返回 304 并告知瀏覽器直接從緩存獲取文件;反之,狀態碼返回 200 并響應最新的資源內容。適用于頻繁變更的資源(HTML頁面、接口動態數據等)。注意:如果 HTTP 響應頭中 ETag 值有改變,不一定是文件內容有更改,也可能是文件最后修改時間有變化,比如編輯文件卻未更改文件內容、修改文件最后修改時間等行為。
4.4.2 瀏覽器存儲
網站的數據存儲是一個常見需求,無論是偏好設置、應用狀態,或是表單提交前用戶填寫的數據等,都可以在瀏覽器進行保存。瀏覽器提供了多種存儲方案,每種方案都有其特性和適用場景,具體異同如下:
存儲方式 | 存儲限制 | 數據持久性 | 是否自動發送到服務器 | 實時性 | 支持的數據類型 | 同源策略 |
LocalStorage | 5-10 MB | 永久 | 否 | 同步 | 字符串 | 是 |
SessionStorage | 5-10 MB | 會話期間 | 否 | 同步 | 字符串 | 是 |
Cookies | 單條4 KB | 可配置 | 是 | 同步 | 字符串 | 是 |
IndexedDB | 不少于250MB(甚至沒有上限) | 永久 | 否 | 異步 | 所有JavaScript類型 | 是 |
- LocalStorage:提供持久化的鍵值對存儲機制,數據沒有過期時間,除非被手動清除,否則將一直存在。
- SessionStorage:類似LocalStorage,數據僅在當前會話期間有效,關閉標簽頁或瀏覽器后數據將被清除。
- Cookies:數據會在每次 HTTP 請求中自動發送到服務器,主要存儲用戶身份憑據、會話狀態等敏感數據。
- IndexedDB:提供在瀏覽器存儲大量結構化數據的能力,支持高性能檢索數據以及事務操作,在使用時要注意數據庫版本升級以及錯誤的兼容處理。
4.4.3 CDN緩存
若想提升首次請求資源的響應速度,除了采用資源壓縮、預加載等方式,還可以借助CDN(內容分發網絡)緩存技術。CDN通過緩存站點的內容在全球各地的節點(或稱為邊緣服務器),使得用戶的請求可以就近訪問到數據,從而減少延遲和提高訪問速度,圖 4-4 對具體原理進行了解釋。
當前 CDN上托管資源非常簡便,根據性能、可靠性、覆蓋地區、成本等維度選擇 CDN 供應商(Amazon CloudFront、Google Cloud CDN、阿里云、百度云等)。通常做法如下:
- CI/CD集成:項目構建結束后通過 CDN 供應商提供的工具將需要緩存的資源上傳到 Bucket(源站),經過充分測試后在主站更新 HTML 文件對相關資源的引用。
- 域名設置:主站域名要與 CDN 服務器域名要有區分,避免靜態資源請求攜帶不必要的 Cookie 信息,繞開瀏覽器對同一域名下并發請求的限制。
- 容災處理:一旦 CDN 服務出現異常,立即將 CDN 服務器域名的 CNAME 修改為主站域名IP(保證主站服務器有資源備份),實現快速止損。
4.5 服務端渲染模式
4.5.1 CSR局限性
前端工程師在處理性能優化問題時,需要站在全棧角度去審視系統的每個細節。Vue
、React
等前端框架出現后,基于MVVM
及組件化的開發模式逐漸取代原來的MVC
,常見的客戶端渲染模式(CSR)也存在以下問題:
- 首屏加載速度慢:前端框架包含的特性越多,其代碼包尺寸就越大。如果網站頁面依賴于框架代碼加載完成后,再對相關組件進行初始化和渲染,無疑會增加用戶從打開網站到看到頁面內容的等待時間。等待期間網站頁面一直處于空白狀態,這種首屏體驗是非常糟糕的。
- SEO支持度差:搜索引擎爬蟲在抓取網頁時,可能看不到完整的頁面,會遇到內容抓取不完全或更新延遲。
- 異步請求初始化數據:首頁加載完成后才會異步請求初始化數據,導致頁面渲染和數據獲取不同步,用戶可操作時間變長。
4.5.2 SSR工作原理
服務端渲染模式(SSR)可以解決以上問題。當用戶在瀏覽器發起某個網站請求時,服務端將需要的 HTML 文本組裝好再返回給瀏覽器。這個 HTML 文本被瀏覽器解析之后,不需要再執行 JavaScript 腳本,可以直接構建 DOM 樹并在頁面渲染。以React
為例,具體工作流程如下:
- 脫水:服務端將組件變成 HTML 字符串,并在
window
上掛載一個變量存放store
中的數據,共同返回給瀏覽器。
import express from 'express';
import { renderToString } from 'react-dom/server';
import { Home } from '../component/home';
import { getStore } from '../store';const app = express();app.get('/', (req, res) => {// 讀取dist文件夾中js 文件const jsFiles = fs.readdirSync(path.join(__dirname, '../dist')).filter((file) => file.endsWith('.js'));const jsScripts = jsFiles.map((file) => `<script src="${file}" defer></script>`).join('\n');const content = renderToString(<Home />);res.send(`<html><head><title>React SSR</title>${jsScripts}<script>window.INITIAL_STATE =${JSON.stringify(store.getState())}</script></head><body><div id="root">${content}</div> </body></html>`);});
- 注水:瀏覽器將組件邏輯連接到服務端生成的 HTML 中進行交互事件綁定(不會二次加載),并將服務端返回的數據傳入
store
作為初始值。
import { hydrateRoot } from 'react-dom/client';
import { Home } from '../component/home';hydrateRoot(document.getElementById('root'), <Home />;
import { configureStore } from '@reduxjs/toolkit';
import usersReducer, { UserState } from './user-slice';export const getStore = () => {return configureStore({// reducer是必需的:它指定了應用程序的根reducerreducer: {users: usersReducer,},// 對象,它包含應用程序的初始狀態preloadedState: {users: typeof window !== 'undefined' ?window.INITIAL_STATE?.users :({status: 'idle',list: [],} as UserState),},});
};
隨著React
升級到18版本,服務端渲染模式增加更多新特性:
- 流式HTML:盡早發送一些HTML片段到瀏覽器,待其他耗時內容準備完成后再發送到客戶端,更加快速、平滑地顯示內容。
- 局部水合:使用代碼分包,讓頁面就緒的內容更早開始水合,使其達到可交互狀態;支持優先水合用戶正在交互的內容。
五、結語
性能優化是無止境的,但時間與成本是有限的。如何取舍以達到最佳的性能體驗效果,這是每位讀者在日后的實際工作中,通過實踐和思考來不斷積累提升的能力。