前端性能優化 24 條建議
性能優化是把雙刃劍,有好的一面也有壞的一面。好的一面就是能提升網站性能,壞的一面就是配置麻煩,或者要遵守的規則太多。並且某些性能優化規則並不適用所有場景,需要謹慎使用,請讀者帶着批判性的眼光來閱讀本文。
1. 減少 HTTP 請求
一個完整的 HTTP 請求需要經歷 DNS 查找,TCP 握手,瀏覽器發出 HTTP 請求,服務器接收請求,服務器處理請求併發迴響應,瀏覽器接收響應等過程。接下來看一個具體的例子幫助理解 HTTP :
這是一個 HTTP 請求,請求的文件大小爲 28.4KB。
名詞解釋:
-
Queueing: 在請求隊列中的時間。
-
Stalled: 從 TCP 連接建立完成,到真正可以傳輸數據之間的時間差,此時間包括代理協商時間。
-
Proxy negotiation: 與代理服務器連接進行協商所花費的時間。
-
DNS Lookup: 執行 DNS 查找所花費的時間,頁面上的每個不同的域都需要進行 DNS 查找。
-
Initial Connection / Connecting: 建立連接所花費的時間,包括 TCP 握手 / 重試和協商 SSL。
-
SSL: 完成 SSL 握手所花費的時間。
-
Request sent: 發出網絡請求所花費的時間,通常爲一毫秒的時間。
-
Waiting(TFFB): TFFB 是發出頁面請求到接收到應答數據第一個字節的時間。
-
Content Download: 接收響應數據所花費的時間。
從這個例子可以看出,真正下載數據的時間佔比爲 13.05 / 204.16 = 6.39%
,文件越小,這個比例越小,文件越大,比例就越高。這就是爲什麼要建議將多個小文件合併爲一個大文件,從而減少 HTTP 請求次數的原因。
2. 使用 HTTP2
HTTP2 相比 HTTP1.1 有如下幾個優點:
解析速度快
服務器解析 HTTP1.1 的請求時,必須不斷地讀入字節,直到遇到分隔符 CRLF 爲止。而解析 HTTP2 的請求就不用這麼麻煩,因爲 HTTP2 是基於幀的協議,每個幀都有表示幀長度的字段。
多路複用
HTTP1.1 如果要同時發起多個請求,就得建立多個 TCP 連接,因爲一個 TCP 連接同時只能處理一個 HTTP1.1 的請求。
在 HTTP2 上,多個請求可以共用一個 TCP 連接,這稱爲多路複用。同一個請求和響應用一個流來表示,並有唯一的流 ID 來標識。多個請求和響應在 TCP 連接中可以亂序發送,到達目的地後再通過流 ID 重新組建。
首部壓縮
HTTP2 提供了首部壓縮功能。
例如有如下兩個請求:
1:authority: unpkg.zhimg.com
2:method: GET
3:path: /za-js-sdk@2.16.0/dist/zap.js
4:scheme: https
5accept: */*
6accept-encoding: gzip, deflate, br
7accept-language: zh-CN,zh;q=0.9
8cache-control: no-cache
9pragma: no-cache
10referer: https://www.zhihu.com/
11sec-fetch-dest: script
12sec-fetch-mode: no-cors
13sec-fetch-site: cross-site
14user-agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.122 Safari/537.36
15
16
1:authority: zz.bdstatic.com
2:method: GET
3:path: /linksubmit/push.js
4:scheme: https
5accept: */*
6accept-encoding: gzip, deflate, br
7accept-language: zh-CN,zh;q=0.9
8cache-control: no-cache
9pragma: no-cache
10referer: https://www.zhihu.com/
11sec-fetch-dest: script
12sec-fetch-mode: no-cors
13sec-fetch-site: cross-site
14user-agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.122 Safari/537.36
15
16
從上面兩個請求可以看出來,有很多數據都是重複的。如果可以把相同的首部存儲起來,僅發送它們之間不同的部分,就可以節省不少的流量,加快請求的時間。
HTTP/2 在客戶端和服務器端使用 “首部表” 來跟蹤和存儲之前發送的鍵-值對,對於相同的數據,不再通過每次請求和響應發送。
下面再來看一個簡化的例子,假設客戶端按順序發送如下請求首部:
1Header1:foo
2Header2:bar
3Header3:bat
4
5
當客戶端發送請求時,它會根據首部值創建一張表:
如果服務器收到了請求,它會照樣創建一張表。當客戶端發送下一個請求的時候,如果首部相同,它可以直接發送這樣的首部塊:
162 63 64
2
3
服務器會查找先前建立的表格,並把這些數字還原成索引對應的完整首部。
優先級
HTTP2 可以對比較緊急的請求設置一個較高的優先級,服務器在收到這樣的請求後,可以優先處理。
流量控制
由於一個 TCP 連接流量帶寬(根據客戶端到服務器的網絡帶寬而定)是固定的,當有多個請求併發時,一個請求佔的流量多,另一個請求佔的流量就會少。流量控制可以對不同的流的流量進行精確控制。
服務器推送
HTTP2 新增的一個強大的新功能,就是服務器可以對一個客戶端請求發送多個響應。換句話說,除了對最初請求的響應外,服務器還可以額外向客戶端推送資源,而無需客戶端明確地請求。
例如當瀏覽器請求一個網站時,除了返回 HTML 頁面外,服務器還可以根據 HTML 頁面中的資源的 URL,來提前推送資源。
現在有很多網站已經開始使用 HTTP2 了,例如知乎:
其中 h2 是指 HTTP2 協議,http/1.1 則是指 HTTP1.1 協議。
3. 使用服務端渲染
客戶端渲染: 獲取 HTML 文件,根據需要下載 JavaScript 文件,運行文件,生成 DOM,再渲染。
服務端渲染:服務端返回 HTML 文件,客戶端只需解析 HTML。
-
優點:首屏渲染快,SEO 好。
-
缺點:配置麻煩,增加了服務器的計算壓力。
下面我用 Vue SSR 做示例,簡單的描述一下 SSR 過程。
客戶端渲染過程
-
訪問客戶端渲染的網站。
-
服務器返回一個包含了引入資源語句和
<div></div>
的 HTML 文件。 -
客戶端通過 HTTP 向服務器請求資源,當必要的資源都加載完畢後,執行
new Vue()
開始實例化並渲染頁面。
服務端渲染過程
-
訪問服務端渲染的網站。
-
服務器會查看當前路由組件需要哪些資源文件,然後將這些文件的內容填充到 HTML 文件。如果有 ajax 請求,就會執行它進行數據預取並填充到 HTML 文件裏,最後返回這個 HTML 頁面。
-
當客戶端接收到這個 HTML 頁面時,可以馬上就開始渲染頁面。與此同時,頁面也會加載資源,當必要的資源都加載完畢後,開始執行
new Vue()
開始實例化並接管頁面。
從上述兩個過程中可以看出,區別就在於第二步。客戶端渲染的網站會直接返回 HTML 文件,而服務端渲染的網站則會渲染完頁面再返回這個 HTML 文件。
這樣做的好處是什麼?是更快的內容到達時間 (time-to-content)。
假設你的網站需要加載完 abcd 四個文件才能渲染完畢。並且每個文件大小爲 1 M。
這樣一算:客戶端渲染的網站需要加載 4 個文件和 HTML 文件才能完成首頁渲染,總計大小爲 4M(忽略 HTML 文件大小)。而服務端渲染的網站只需要加載一個渲染完畢的 HTML 文件就能完成首頁渲染,總計大小爲已經渲染完畢的 HTML 文件(這種文件不會太大,一般爲幾百 K,我的個人博客網站(SSR)加載的 HTML 文件爲 400K)。這就是服務端渲染更快的原因。
4. 靜態資源使用 CDN
內容分發網絡(CDN)是一組分佈在多個不同地理位置的 Web 服務器。我們都知道,當服務器離用戶越遠時,延遲越高。CDN 就是爲了解決這一問題,在多個位置部署服務器,讓用戶離服務器更近,從而縮短請求時間。
CDN 原理
當用戶訪問一個網站時,如果沒有 CDN,過程是這樣的:
-
瀏覽器要將域名解析爲 IP 地址,所以需要向本地 DNS 發出請求。
-
本地 DNS 依次向根服務器、頂級域名服務器、權限服務器發出請求,得到網站服務器的 IP 地址。
-
本地 DNS 將 IP 地址發回給瀏覽器,瀏覽器向網站服務器 IP 地址發出請求並得到資源。
如果用戶訪問的網站部署了 CDN,過程是這樣的:
-
瀏覽器要將域名解析爲 IP 地址,所以需要向本地 DNS 發出請求。
-
本地 DNS 依次向根服務器、頂級域名服務器、權限服務器發出請求,得到全局負載均衡系統(GSLB)的 IP 地址。
-
本地 DNS 再向 GSLB 發出請求,GSLB 的主要功能是根據本地 DNS 的 IP 地址判斷用戶的位置,篩選出距離用戶較近的本地負載均衡系統(SLB),並將該 SLB 的 IP 地址作爲結果返回給本地 DNS。
-
本地 DNS 將 SLB 的 IP 地址發回給瀏覽器,瀏覽器向 SLB 發出請求。
-
SLB 根據瀏覽器請求的資源和地址,選出最優的緩存服務器發回給瀏覽器。
-
瀏覽器再根據 SLB 發回的地址重定向到緩存服務器。
-
如果緩存服務器有瀏覽器需要的資源,就將資源發回給瀏覽器。如果沒有,就向源服務器請求資源,再發給瀏覽器並緩存在本地。
- 將 CSS 放在文件頭部,JavaScript 文件放在底部
所有放在 head 標籤裏的 CSS 和 JS 文件都會堵塞渲染(CSS 不會阻塞 DOM 解析)。如果這些 CSS 和 JS 需要加載和解析很久的話,那麼頁面就空白了。所以 JS 文件要放在底部,等 HTML 解析完了再加載 JS 文件。
那爲什麼 CSS 文件還要放在頭部呢?
因爲先加載 HTML 再加載 CSS,會讓用戶第一時間看到的頁面是沒有樣式的、“醜陋” 的,爲了避免這種情況發生,就要將 CSS 文件放在頭部了。
另外,JS 文件也不是不可以放在頭部,只要給 script 標籤加上 defer 屬性就可以了,異步下載,延遲執行。
6. 使用字體圖標 iconfont 代替圖片圖標
字體圖標就是將圖標製作成一個字體,使用時就跟字體一樣,可以設置屬性,例如 font-size、color 等等,非常方便。並且字體圖標是矢量圖,不會失真。還有一個優點是生成的文件特別小。
壓縮字體文件
使用 fontmin-webpack 插件對字體文件進行壓縮。
- 善用緩存,不重複加載相同的資源
爲了避免用戶每次訪問網站都得請求文件,我們可以通過添加 Expires 或 max-age 來控制這一行爲。Expires 設置了一個時間,只要在這個時間之前,瀏覽器都不會請求文件,而是直接使用緩存。而 max-age 是一個相對時間,建議使用 max-age 代替 Expires 。
不過這樣會產生一個問題,當文件更新了怎麼辦?怎麼通知瀏覽器重新請求文件?
可以通過更新頁面中引用的資源鏈接地址,讓瀏覽器主動放棄緩存,加載新資源。
具體做法是把資源地址 URL 的修改與文件內容關聯起來,也就是說,只有文件內容變化,纔會導致相應 URL 的變更,從而實現文件級別的精確緩存控制。什麼東西與文件內容相關呢?我們會很自然的聯想到利用數據摘要要算法對文件求摘要信息,摘要信息與文件內容一一對應,就有了一種可以精確到單個文件粒度的緩存控制依據了。
- 壓縮文件
壓縮文件可以減少文件下載時間,讓用戶體驗性更好。
得益於 webpack 和 node 的發展,現在壓縮文件已經非常方便了。
在 webpack 可以使用如下插件進行壓縮:
-
JavaScript:UglifyPlugin
-
CSS :MiniCssExtractPlugin
-
HTML:HtmlWebpackPlugin
其實,我們還可以做得更好。那就是使用 gzip 壓縮。可以通過向 HTTP 請求頭中的 Accept-Encoding 頭添加 gzip 標識來開啓這一功能。當然,服務器也得支持這一功能。
gzip 是目前最流行和最有效的壓縮方法。舉個例子,我用 Vue 開發的項目構建後生成的 app.js 文件大小爲 1.4MB,使用 gzip 壓縮後只有 573KB,體積減少了將近 60%。
附上 webpack 和 node 配置 gzip 的使用方法。
下載插件
1npm install compression-webpack-plugin --save-dev
2npm install compression
3
4
webpack 配置
1const CompressionPlugin = require('compression-webpack-plugin');
2
3module.exports = {
4 plugins: [new CompressionPlugin()],
5}
6
7
node 配置
1const compression = require('compression')
2// 在其他中間件前使用
3app.use(compression())
4
5
9. 圖片優化
(1). 圖片延遲加載
在頁面中,先不給圖片設置路徑,只有當圖片出現在瀏覽器的可視區域時,纔去加載真正的圖片,這就是延遲加載。對於圖片很多的網站來說,一次性加載全部圖片,會對用戶體驗造成很大的影響,所以需要使用圖片延遲加載。
首先可以將圖片這樣設置,在頁面不可見時圖片不會加載:
1<img src="https://avatars0.githubusercontent.com/u/22117876?s=460&u=7bd8f32788df6988833da6bd155c3cfbebc68006&v=4">
2
3
等頁面可見時,使用 JS 加載圖片:
1const img = document.querySelector('img')
2img.src = img.dataset.src
3
4
這樣圖片就加載出來了。
(2). 響應式圖片
響應式圖片的優點是瀏覽器能夠根據屏幕大小自動加載合適的圖片。
通過 picture
實現
1<picture>
2 <source srcset="banner_w1000.jpg" media="(min-width: 801px)">
3 <source srcset="banner_w800.jpg" media="(max-width: 800px)">
4 <img src="banner_w800.jpg" alt="">
5</picture>
6
7
通過 @media
實現
1@media (min-width: 769px) {
2 .bg {
3 background-image: url(bg1080.jpg);
4 }
5}
6@media (max-width: 768px) {
7 .bg {
8 background-image: url(bg768.jpg);
9 }
10}
11
12
(3). 調整圖片大小
例如,你有一個 1920 * 1080 大小的圖片,用縮略圖的方式展示給用戶,並且當用戶鼠標懸停在上面時才展示全圖。如果用戶從未真正將鼠標懸停在縮略圖上,則浪費了下載圖片的時間。
所以,我們可以用兩張圖片來實行優化。一開始,只加載縮略圖,當用戶懸停在圖片上時,才加載大圖。還有一種辦法,即對大圖進行延遲加載,在所有元素都加載完成後手動更改大圖的 src 進行下載。
(4). 降低圖片質量
例如 JPG 格式的圖片,100% 的質量和 90% 質量的通常看不出來區別,尤其是用來當背景圖的時候。我經常用 PS 切背景圖時, 將圖片切成 JPG 格式,並且將它壓縮到 60% 的質量,基本上看不出來區別。
壓縮方法有兩種,一是通過 webpack 插件 image-webpack-loader
,二是通過在線網站進行壓縮。
以下附上 webpack 插件 image-webpack-loader
的用法。
1npm i -D image-webpack-loader
2
3
webpack 配置
1{
2 test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
3 use:[
4 {
5 loader: 'url-loader',
6 options: {
7 limit: 10000, /* 圖片大小小於1000字節限制時會自動轉成 base64 碼引用*/
8 name: utils.assetsPath('img/[name].[hash:7].[ext]')
9 }
10 },
11 /*對圖片進行壓縮*/
12 {
13 loader: 'image-webpack-loader',
14 options: {
15 bypassOnDebug: true,
16 }
17 }
18 ]
19}
20
21
(5). 儘可能利用 CSS3 效果代替圖片
有很多圖片使用 CSS 效果(漸變、陰影等)就能畫出來,這種情況選擇 CSS3 效果更好。因爲代碼大小通常是圖片大小的幾分之一甚至幾十分之一。
(6). 使用 webp 格式的圖片
WebP 的優勢體現在它具有更優的圖像數據壓縮算法,能帶來更小的圖片體積,而且擁有肉眼識別無差異的圖像質量;同時具備了無損和有損的壓縮模式、Alpha 透明以及動畫的特性,在 JPEG 和 PNG 上的轉化效果都相當優秀、穩定和統一。
參考資料:
- WebP 相對於 PNG、JPG 有什麼優勢?
https://www.zhihu.com/question/27201061
10. 通過 webpack 按需加載代碼,提取第三庫代碼,減少 ES6 轉爲 ES5 的冗餘代碼
懶加載或者按需加載,是一種很好的優化網頁或應用的方式。這種方式實際上是先把你的代碼在一些邏輯斷點處分離開,然後在一些代碼塊中完成某些操作後,立即引用或即將引用另外一些新的代碼塊。這樣加快了應用的初始加載速度,減輕了它的總體體積,因爲某些代碼塊可能永遠不會被加載。
根據文件內容生成文件名,結合 import 動態引入組件實現按需加載
通過配置 output 的 filename 屬性可以實現這個需求。filename 屬性的值選項中有一個 [contenthash],它將根據文件內容創建出唯一 hash。當文件內容發生變化時,[contenthash] 也會發生變化。
1output: {
2 filename: '[name].[contenthash].js',
3 chunkFilename: '[name].[contenthash].js',
4 path: path.resolve(__dirname, '../dist'),
5},
6
7
提取第三方庫
由於引入的第三方庫一般都比較穩定,不會經常改變。所以將它們單獨提取出來,作爲長期緩存是一個更好的選擇。這裏需要使用 webpack4 的 splitChunk 插件 cacheGroups 選項。
1optimization: {
2 runtimeChunk: {
3 name: 'manifest' // 將 webpack 的 runtime 代碼拆分爲一個單獨的 chunk。
4 },
5 splitChunks: {
6 cacheGroups: {
7 vendor: {
8 name: 'chunk-vendors',
9 test: /[\\/]node_modules[\\/]/,
10 priority: -10,
11 chunks: 'initial'
12 },
13 common: {
14 name: 'chunk-common',
15 minChunks: 2,
16 priority: -20,
17 chunks: 'initial',
18 reuseExistingChunk: true
19 }
20 },
21 }
22},
23
24
-
test: 用於控制哪些模塊被這個緩存組匹配到。原封不動傳遞出去的話,它默認會選擇所有的模塊。可以傳遞的值類型:RegExp、String 和 Function;
-
priority:表示抽取權重,數字越大表示優先級越高。因爲一個 module 可能會滿足多個 cacheGroups 的條件,那麼抽取到哪個就由權重最高的說了算;
-
reuseExistingChunk:表示是否使用已有的 chunk,如果爲 true 則表示如果當前的 chunk 包含的模塊已經被抽取出去了,那麼將不會重新生成新的。
-
minChunks(默認是 1):在分割之前,這個代碼塊最小應該被引用的次數(譯註:保證代碼塊複用性,默認配置的策略是不需要多次引用也可以被分割)
-
chunks (默認是 async) :initial、async 和 all
-
name(打包的 chunks 的名字):字符串或者函數 (函數可以根據條件自定義名字)
減少 ES6 轉爲 ES5 的冗餘代碼
Babel 轉化後的代碼想要實現和原來代碼一樣的功能需要藉助一些幫助函數,比如:
1class Person {}
2
3
會被轉換爲:
1"use strict";
2
3function _classCallCheck(instance, Constructor) {
4 if (!(instance instanceof Constructor)) {
5 throw new TypeError("Cannot call a class as a function");
6 }
7}
8
9var Person = function Person() {
10 _classCallCheck(this, Person);
11};
12
13
這裏 _classCallCheck
就是一個 helper
函數,如果在很多文件裏都聲明瞭類,那麼就會產生很多個這樣的 helper
函數。
這裏的 @babel/runtime
包就聲明瞭所有需要用到的幫助函數,而 @babel/plugin-transform-runtime
的作用就是將所有需要 helper
函數的文件,從 @babel/runtime包
引進來:
1"use strict";
2
3var _classCallCheck2 = require("@babel/runtime/helpers/classCallCheck");
4
5var _classCallCheck3 = _interopRequireDefault(_classCallCheck2);
6
7function _interopRequireDefault(obj) {
8 return obj && obj.__esModule ? obj : { default: obj };
9}
10
11var Person = function Person() {
12 (0, _classCallCheck3.default)(this, Person);
13};
14
15
這裏就沒有再編譯出 helper
函數 classCallCheck
了,而是直接引用了 @babel/runtime
中的 helpers/classCallCheck
。
安裝
1npm i -D @babel/plugin-transform-runtime @babel/runtime
2
3
使用 在 .babelrc
文件中
1"plugins": [
2 "@babel/plugin-transform-runtime"
3]
4
5
- 減少重繪重排
瀏覽器渲染過程
-
解析 HTML 生成 DOM 樹。
-
解析 CSS 生成 CSSOM 規則樹。
-
解析 JS,操作 DOM 樹和 CSSOM 規則樹。
-
將 DOM 樹與 CSSOM 規則樹合併在一起生成渲染樹。
-
遍歷渲染樹開始佈局,計算每個節點的位置大小信息。
-
瀏覽器將所有圖層的數據發送給 GPU,GPU 將圖層合成並顯示在屏幕上。
重排
當改變 DOM 元素位置或大小時,會導致瀏覽器重新生成渲染樹,這個過程叫重排。
重繪
當重新生成渲染樹後,就要將渲染樹每個節點繪製到屏幕,這個過程叫重繪。不是所有的動作都會導致重排,例如改變字體顏色,只會導致重繪。記住,重排會導致重繪,重繪不會導致重排 。
重排和重繪這兩個操作都是非常昂貴的,因爲 JavaScript 引擎線程與 GUI 渲染線程是互斥,它們同時只能一個在工作。
什麼操作會導致重排?
-
添加或刪除可見的 DOM 元素
-
元素位置改變
-
元素尺寸改變
-
內容改變
-
瀏覽器窗口尺寸改變
如何減少重排重繪?
-
用 JavaScript 修改樣式時,最好不要直接寫樣式,而是替換 class 來改變樣式。
-
如果要對 DOM 元素執行一系列操作,可以將 DOM 元素脫離文檔流,修改完成後,再將它帶回文檔。推薦使用隱藏元素(display:none)或文檔碎片(DocumentFragement),都能很好的實現這個方案。
12. 使用事件委託
事件委託利用了事件冒泡,只指定一個事件處理程序,就可以管理某一類型的所有事件。所有用到按鈕的事件(多數鼠標事件和鍵盤事件)都適合採用事件委託技術, 使用事件委託可以節省內存。
1<ul>
2 <li>蘋果</li>
3 <li>香蕉</li>
4 <li>鳳梨</li>
5</ul>
6
7// good
8document.querySelector('ul').onclick = (event) => {
9 const target = event.target
10 if (target.nodeName === 'LI') {
11 console.log(target.innerHTML)
12 }
13}
14
15// bad
16document.querySelectorAll('li').forEach((e) => {
17 e.onclick = function() {
18 console.log(this.innerHTML)
19 }
20})
21
22
13. 注意程序的局部性
一個編寫良好的計算機程序常常具有良好的局部性,它們傾向於引用最近引用過的數據項附近的數據項,或者最近引用過的數據項本身,這種傾向性,被稱爲局部性原理。有良好局部性的程序比局部性差的程序運行得更快。
局部性通常有兩種不同的形式:
-
時間局部性:在一個具有良好時間局部性的程序中,被引用過一次的內存位置很可能在不遠的將來被多次引用。
-
空間局部性 :在一個具有良好空間局部性的程序中,如果一個內存位置被引用了一次,那麼程序很可能在不遠的將來引用附近的一個內存位置。
時間局部性示例
1function sum(arry) {
2 let i, sum = 0
3 let len = arry.length
4
5 for (i = 0; i < len; i++) {
6 sum += arry[i]
7 }
8
9 return sum
10}
11
12
在這個例子中,變量 sum 在每次循環迭代中被引用一次,因此,對於 sum 來說,具有良好的時間局部性
空間局部性示例
具有良好空間局部性的程序
1// 二維數組
2function sum1(arry, rows, cols) {
3 let i, j, sum = 0
4
5 for (i = 0; i < rows; i++) {
6 for (j = 0; j < cols; j++) {
7 sum += arry[i][j]
8 }
9 }
10 return sum
11}
12
13
空間局部性差的程序
1// 二維數組
2function sum2(arry, rows, cols) {
3 let i, j, sum = 0
4
5 for (j = 0; j < cols; j++) {
6 for (i = 0; i < rows; i++) {
7 sum += arry[i][j]
8 }
9 }
10 return sum
11}
12
13
看一下上面的兩個空間局部性示例,像示例中從每行開始按順序訪問數組每個元素的方式,稱爲具有步長爲 1 的引用模式。如果在數組中,每隔 k 個元素進行訪問,就稱爲步長爲 k 的引用模式。一般而言,隨着步長的增加,空間局部性下降。
這兩個例子有什麼區別?區別在於第一個示例是按行掃描數組,每掃描完一行再去掃下一行;第二個示例是按列來掃描數組,掃完一行中的一個元素,馬上就去掃下一行中的同一列元素。
數組在內存中是按照行順序來存放的,結果就是逐行掃描數組的示例得到了步長爲 1 引用模式,具有良好的空間局部性;而另一個示例步長爲 rows,空間局部性極差。
性能測試
運行環境:
-
cpu: i5-7400
-
瀏覽器: chrome 70.0.3538.110
對一個長度爲 9000 的二維數組(子數組長度也爲 9000)進行 10 次空間局部性測試,時間(毫秒)取平均值,結果如下:
所用示例爲上述兩個空間局部性示例
從以上測試結果來看,步長爲 1 的數組執行時間比步長爲 9000 的數組快了一個數量級。
總結:
-
重複引用相同變量的程序具有良好的時間局部性
-
對於具有步長爲 k 的引用模式的程序,步長越小,空間局部性越好;而在內存中以大步長跳來跳去的程序空間局部性會很差
14. if-else 對比 switch
當判斷條件數量越來越多時,越傾向於使用 switch 而不是 if-else。
1if (color == 'blue') {
2
3} else if (color == 'yellow') {
4
5} else if (color == 'white') {
6
7} else if (color == 'black') {
8
9} else if (color == 'green') {
10
11} else if (color == 'orange') {
12
13} else if (color == 'pink') {
14
15}
16
17switch (color) {
18 case 'blue':
19
20 break
21 case 'yellow':
22
23 break
24 case 'white':
25
26 break
27 case 'black':
28
29 break
30 case 'green':
31
32 break
33 case 'orange':
34
35 break
36 case 'pink':
37
38 break
39}
40
41
像以上這種情況,使用 switch 是最好的。假設 color 的值爲 pink,則 if-else 語句要進行 7 次判斷,switch 只需要進行一次判斷。從可讀性來說,switch 語句也更好。
從使用時機來說,當條件值大於兩個的時候,使用 switch 更好。不過 if-else 也有 switch 無法做到的事情,例如有多個判斷條件的情況下,無法使用 switch。
15. 查找表
當條件語句特別多時,使用 switch 和 if-else 不是最佳的選擇,這時不妨試一下查找表。查找表可以使用數組和對象來構建。
1switch (index) {
2 case '0':
3 return result0
4 case '1':
5 return result1
6 case '2':
7 return result2
8 case '3':
9 return result3
10 case '4':
11 return result4
12 case '5':
13 return result5
14 case '6':
15 return result6
16 case '7':
17 return result7
18 case '8':
19 return result8
20 case '9':
21 return result9
22 case '10':
23 return result10
24 case '11':
25 return result11
26}
27
28
可以將這個 switch 語句轉換爲查找表
1const results = [result0,result1,result2,result3,result4,result5,result6,result7,result8,result9,result10,result11]
2
3return results[index]
4
5
如果條件語句不是數值而是字符串,可以用對象來建立查找表
1const map = {
2 red: result0,
3 green: result1,
4}
5
6return map[color]
7
8
16. 避免頁面卡頓
60fps 與設備刷新率
目前大多數設備的屏幕刷新率爲 60 次 / 秒。因此,如果在頁面中有一個動畫或漸變效果,或者用戶正在滾動頁面,那麼瀏覽器渲染動畫或頁面的每一幀的速率也需要跟設備屏幕的刷新率保持一致。
其中每個幀的預算時間僅比 16 毫秒多一點 (1 秒 / 60 = 16.66 毫秒)。但實際上,瀏覽器有整理工作要做,因此您的所有工作需要在 10 毫秒內完成。如果無法符合此預算,幀率將下降,並且內容會在屏幕上抖動。此現象通常稱爲卡頓,會對用戶體驗產生負面影響。
假如你用 JavaScript 修改了 DOM,並觸發樣式修改,經歷重排重繪最後畫到屏幕上。如果這其中任意一項的執行時間過長,都會導致渲染這一幀的時間過長,平均幀率就會下降。假設這一幀花了 50 ms,那麼此時的幀率爲 1s / 50ms = 20fps,頁面看起來就像卡頓了一樣。
對於一些長時間運行的 JavaScript,我們可以使用定時器進行切分,延遲執行。
1for (let i = 0, len = arry.length; i < len; i++) {
2 process(arry[i])
3}
4
5
假設上面的循環結構由於 process() 複雜度過高或數組元素太多,甚至兩者都有,可以嘗試一下切分。
1const todo = arry.concat()
2setTimeout(function() {
3 process(todo.shift())
4 if (todo.length) {
5 setTimeout(arguments.callee, 25)
6 } else {
7 callback(arry)
8 }
9}, 25)
10
11
如果有興趣瞭解更多,可以查看一下高性能 JavaScript 第 6 章和高效前端:Web 高效編程與優化實踐第 3 章。
17. 使用 requestAnimationFrame 來實現視覺變化
從第 16 點我們可以知道,大多數設備屏幕刷新率爲 60 次 / 秒,也就是說每一幀的平均時間爲 16.66 毫秒。在使用 JavaScript 實現動畫效果的時候,最好的情況就是每次代碼都是在幀的開頭開始執行。而保證 JavaScript 在幀開始時運行的唯一方式是使用 requestAnimationFrame
。
1/**
2 * If run as a requestAnimationFrame callback, this
3 * will be run at the start of the frame.
4 */
5function updateScreen(time) {
6 // Make visual updates here.
7}
8
9requestAnimationFrame(updateScreen);
10
11
如果採取 setTimeout
或 setInterval
來實現動畫的話,回調函數將在幀中的某個時點運行,可能剛好在末尾,而這可能經常會使我們丟失幀,導致卡頓。
18. 使用 Web Workers
Web Worker 使用其他工作線程從而獨立於主線程之外,它可以執行任務而不干擾用戶界面。一個 worker 可以將消息發送到創建它的 JavaScript 代碼, 通過將消息發送到該代碼指定的事件處理程序(反之亦然)。
Web Worker 適用於那些處理純數據,或者與瀏覽器 UI 無關的長時間運行腳本。
創建一個新的 worker 很簡單,指定一個腳本的 URI 來執行 worker 線程(main.js):
1var myWorker = new Worker('worker.js');
2// 你可以通過postMessage() 方法和onmessage事件向worker發送消息。
3first.onchange = function() {
4 myWorker.postMessage([first.value,second.value]);
5 console.log('Message posted to worker');
6}
7
8second.onchange = function() {
9 myWorker.postMessage([first.value,second.value]);
10 console.log('Message posted to worker');
11}
12
13
在 worker 中接收到消息後,我們可以寫一個事件處理函數代碼作爲響應(worker.js):
1onmessage = function(e) {
2 console.log('Message received from main script');
3 var workerResult = 'Result: ' + (e.data[0] * e.data[1]);
4 console.log('Posting message back to main script');
5 postMessage(workerResult);
6}
7
8
onmessage 處理函數在接收到消息後馬上執行,代碼中消息本身作爲事件的 data 屬性進行使用。這裏我們簡單的對這 2 個數字作乘法處理並再次使用 postMessage() 方法,將結果回傳給主線程。
回到主線程,我們再次使用 onmessage 以響應 worker 回傳的消息:
1myWorker.onmessage = function(e) {
2 result.textContent = e.data;
3 console.log('Message received from worker');
4}
5
6
在這裏我們獲取消息事件的 data,並且將它設置爲 result 的 textContent,所以用戶可以直接看到運算的結果。
不過在 worker 內,不能直接操作 DOM 節點,也不能使用 window 對象的默認方法和屬性。然而你可以使用大量 window 對象之下的東西,包括 WebSockets,IndexedDB 以及 FireFox OS 專用的 Data Store API 等數據存儲機制。
19. 使用位操作
JavaScript 中的數字都使用 IEEE-754 標準以 64 位格式存儲。但是在位操作中,數字被轉換爲有符號的 32 位格式。即使需要轉換,位操作也比其他數學運算和布爾操作快得多。
取模
由於偶數的最低位爲 0,奇數爲 1,所以取模運算可以用位操作來代替。
1if (value % 2) {
2 // 奇數
3} else {
4 // 偶數
5}
6// 位操作
7if (value & 1) {
8 // 奇數
9} else {
10 // 偶數
11}
12
13
取整
1~~10.12 // 10
2~~10 // 10
3~~'1.5' // 1
4~~undefined // 0
5~~null // 0
6
7
位掩碼
1const a = 1
2const b = 2
3const c = 4
4const options = a | b | c
5
6
通過定義這些選項,可以用按位與操作來判斷 a/b/c 是否在 options 中。
1// 選項 b 是否在選項中
2if (b & options) {
3 ...
4}
5
6
20. 不要覆蓋原生方法
無論你的 JavaScript 代碼如何優化,都比不上原生方法。因爲原生方法是用低級語言寫的(C/C++),並且被編譯成機器碼,成爲瀏覽器的一部分。當原生方法可用時,儘量使用它們,特別是數學運算和 DOM 操作。
21. 降低 CSS 選擇器的複雜性
(1). 瀏覽器讀取選擇器,遵循的原則是從選擇器的右邊到左邊讀取。
看個示例
1#block .text p {
2 color: red;
3}
4
5
-
查找所有 P 元素。
-
查找結果 1 中的元素是否有類名爲 text 的父元素
-
查找結果 2 中的元素是否有 id 爲 block 的父元素
(2). CSS 選擇器優先級
1內聯 > ID選擇器 > 類選擇器 > 標籤選擇器
2
3
根據以上兩個信息可以得出結論。
-
選擇器越短越好。
-
儘量使用高優先級的選擇器,例如 ID 和類選擇器。
-
避免使用通配符 *。
最後要說一句,據我查找的資料所得,CSS 選擇器沒有優化的必要,因爲最慢和慢快的選擇器性能差別非常小。
22. 使用 flexbox 而不是較早的佈局模型
在早期的 CSS 佈局方式中我們能對元素實行絕對定位、相對定位或浮動定位。而現在,我們有了新的佈局方式 flexbox,它比起早期的佈局方式來說有個優勢,那就是性能比較好。
下面的截圖顯示了在 1300 個框上使用浮動的佈局開銷:
然後我們用 flexbox 來重現這個例子:
現在,對於相同數量的元素和相同的視覺外觀,佈局的時間要少得多(本例中爲分別 3.5 毫秒和 14 毫秒)。
不過 flexbox 兼容性還是有點問題,不是所有瀏覽器都支持它,所以要謹慎使用。
各瀏覽器兼容性:
-
Chrome 29+
-
Firefox 28+
-
Internet Explorer 11
-
Opera 17+
-
Safari 6.1+ (prefixed with -webkit-)
-
Android 4.4+
-
iOS 7.1+ (prefixed with -webkit-)
23. 使用 transform 和 opacity 屬性更改來實現動畫
在 CSS 中,transforms 和 opacity 這兩個屬性更改不會觸發重排與重繪,它們是可以由合成器(composite)單獨處理的屬性。
參考資料:
- 使用 transform 和 opacity 屬性更改來實現動畫
24. 合理使用規則,避免過度優化
性能優化主要分爲兩類:
-
加載時優化
-
運行時優化
上述 23 條建議中,屬於加載時優化的是前面 10 條建議,屬於運行時優化的是後面 13 條建議。通常來說,沒有必要 23 條性能優化規則都用上,根據網站用戶羣體來做針對性的調整是最好的,節省精力,節省時間。
在解決問題之前,得先找出問題,否則無從下手。所以在做性能優化之前,最好先調查一下網站的加載性能和運行性能。
檢查加載性能
一個網站加載性能如何主要看白屏時間和首屏時間。
-
白屏時間:指從輸入網址,到頁面開始顯示內容的時間。
-
首屏時間:指從輸入網址,到頁面完全渲染的時間。
將以下腳本放在 </head>
前面就能獲取白屏時間。
1<script>
2 new Date() - performance.timing.navigationStart
3</script>
4
5
在 window.onload
事件裏執行 new Date() \- performance.timing.navigationStart
即可獲取首屏時間。
檢查運行性能
配合 chrome 的開發者工具,我們可以查看網站在運行時的性能。
打開網站,按 F12 選擇 performance,點擊左上角的灰色圓點,變成紅色就代表開始記錄了。這時可以模仿用戶使用網站,在使用完畢後,點擊 stop,然後你就能看到網站運行期間的性能報告。如果有紅色的塊,代表有掉幀的情況;如果是綠色,則代表 FPS 很好。performance 的具體使用方法請用搜索引擎搜索一下,畢竟篇幅有限。
通過檢查加載和運行性能,相信你對網站性能已經有了大概瞭解。所以這時候要做的事情,就是使用上述 23 條建議盡情地去優化你的網站,加油!
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/RF8Y4fCJ62dP4-xbL1Xr-w