前端性能優(yōu)化的 24 條建議(轉(zhuǎn))

轉(zhuǎn)自全棧修煉

作者:woai3c ,https://github.com/woai3c/Front-end-articles/blob/master/performance.md
性能優(yōu)化是把雙刃劍,有好的一面也有壞的一面。好的一面就是能提升網(wǎng)站性能,壞的一面就是配置麻煩,或者要遵守的規(guī)則太多。并且某些性能優(yōu)化規(guī)則并不適用所有場景,需要謹(jǐn)慎使用,請讀者帶著批判性的眼光來閱讀本文。

本文相關(guān)的優(yōu)化建議的引用資料出處均會(huì)在建議后面給出,或者放在文末(有些參考資料可能要梯子才能觀看)。

1. 減少 HTTP 請求

一個(gè)完整的 HTTP 請求需要經(jīng)歷 DNS 查找,TCP 握手,瀏覽器發(fā)出 HTTP 請求,服務(wù)器接收請求,服務(wù)器處理請求并發(fā)回響應(yīng),瀏覽器接收響應(yīng)等過程。接下來看一個(gè)具體的例子幫助理解 HTTP :

image

這是一個(gè) HTTP 請求,請求的文件大小為 28.4KB。

名詞解釋:

  • Queueing: 在請求隊(duì)列中的時(shí)間。
  • Stalled: 從TCP 連接建立完成,到真正可以傳輸數(shù)據(jù)之間的時(shí)間差,此時(shí)間包括代理協(xié)商時(shí)間。
  • Proxy negotiation: 與代理服務(wù)器連接進(jìn)行協(xié)商所花費(fèi)的時(shí)間。
  • DNS Lookup: 執(zhí)行DNS查找所花費(fèi)的時(shí)間,頁面上的每個(gè)不同的域都需要進(jìn)行DNS查找。
  • Initial Connection / Connecting: 建立連接所花費(fèi)的時(shí)間,包括TCP握手/重試和協(xié)商SSL。
  • SSL: 完成SSL握手所花費(fèi)的時(shí)間。
  • Request sent: 發(fā)出網(wǎng)絡(luò)請求所花費(fèi)的時(shí)間,通常為一毫秒的時(shí)間。
  • Waiting(TFFB): TFFB 是發(fā)出頁面請求到接收到應(yīng)答數(shù)據(jù)第一個(gè)字節(jié)的時(shí)間總和,它包含了 DNS 解析時(shí)間、 TCP 連接時(shí)間、發(fā)送 HTTP 請求時(shí)間和獲得響應(yīng)消息第一個(gè)字節(jié)的時(shí)間。
  • Content Download: 接收響應(yīng)數(shù)據(jù)所花費(fèi)的時(shí)間。

從這個(gè)例子可以看出,真正下載數(shù)據(jù)的時(shí)間占比為 13.05 / 204.16 = 6.39%,文件越小,這個(gè)比例越小,文件越大,比例就越高。這就是為什么要建議將多個(gè)小文件合并為一個(gè)大文件,從而減少 HTTP 請求次數(shù)的原因。

參考資料:

  • understanding-resource-timing

2. 使用 HTTP2

HTTP2 相比 HTTP1.1 有如下幾個(gè)優(yōu)點(diǎn):

解析速度快

服務(wù)器解析 HTTP1.1 的請求時(shí),必須不斷地讀入字節(jié),直到遇到分隔符 CRLF 為止。而解析 HTTP2 的請求就不用這么麻煩,因?yàn)?HTTP2 是基于幀的協(xié)議,每個(gè)幀都有表示幀長度的字段。

多路復(fù)用

HTTP1.1 如果要同時(shí)發(fā)起多個(gè)請求,就得建立多個(gè) TCP 連接,因?yàn)橐粋€(gè) TCP 連接同時(shí)只能處理一個(gè) HTTP1.1 的請求。

在 HTTP2 上,多個(gè)請求可以共用一個(gè) TCP 連接,這稱為多路復(fù)用。同一個(gè)請求和響應(yīng)用一個(gè)流來表示,并有唯一的流 ID 來標(biāo)識(shí)。多個(gè)請求和響應(yīng)在 TCP 連接中可以亂序發(fā)送,到達(dá)目的地后再通過流 ID 重新組建。

首部壓縮

HTTP2 提供了首部壓縮功能。

例如有如下兩個(gè)請求:

:authority: unpkg.zhimg.com:method: GET:path: /za-js-sdk@2.16.0/dist/zap.js:scheme: httpsaccept: */*accept-encoding: gzip, deflate, braccept-language: zh-CN,zh;q=0.9cache-control: no-cachepragma: no-cachereferer: https://www.zhihu.com/sec-fetch-dest: scriptsec-fetch-mode: no-corssec-fetch-site: cross-siteuser-agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.122 Safari/537.36
:authority: zz.bdstatic.com:method: GET:path: /linksubmit/push.js:scheme: httpsaccept: */*accept-encoding: gzip, deflate, braccept-language: zh-CN,zh;q=0.9cache-control: no-cachepragma: no-cachereferer: https://www.zhihu.com/sec-fetch-dest: scriptsec-fetch-mode: no-corssec-fetch-site: cross-siteuser-agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.122 Safari/537.36

從上面兩個(gè)請求可以看出來,有很多數(shù)據(jù)都是重復(fù)的。如果可以把相同的首部存儲(chǔ)起來,僅發(fā)送它們之間不同的部分,就可以節(jié)省不少的流量,加快請求的時(shí)間。

HTTP/2 在客戶端和服務(wù)器端使用“首部表”來跟蹤和存儲(chǔ)之前發(fā)送的鍵-值對(duì),對(duì)于相同的數(shù)據(jù),不再通過每次請求和響應(yīng)發(fā)送。

下面再來看一個(gè)簡化的例子,假設(shè)客戶端按順序發(fā)送如下請求首部:

Header1:fooHeader2:barHeader3:bat

當(dāng)客戶端發(fā)送請求時(shí),它會(huì)根據(jù)首部值創(chuàng)建一張表:

索引 首部名稱
62 Header1 foo
63 Header2 bar
64 Header3 bat

如果服務(wù)器收到了請求,它會(huì)照樣創(chuàng)建一張表。當(dāng)客戶端發(fā)送下一個(gè)請求的時(shí)候,如果首部相同,它可以直接發(fā)送這樣的首部塊:

62 63 64

服務(wù)器會(huì)查找先前建立的表格,并把這些數(shù)字還原成索引對(duì)應(yīng)的完整首部。

優(yōu)先級(jí)

HTTP2 可以對(duì)比較緊急的請求設(shè)置一個(gè)較高的優(yōu)先級(jí),服務(wù)器在收到這樣的請求后,可以優(yōu)先處理。

流量控制

由于一個(gè) TCP 連接流量帶寬(根據(jù)客戶端到服務(wù)器的網(wǎng)絡(luò)帶寬而定)是固定的,當(dāng)有多個(gè)請求并發(fā)時(shí),一個(gè)請求占的流量多,另一個(gè)請求占的流量就會(huì)少。流量控制可以對(duì)不同的流的流量進(jìn)行精確控制。

服務(wù)器推送

HTTP2 新增的一個(gè)強(qiáng)大的新功能,就是服務(wù)器可以對(duì)一個(gè)客戶端請求發(fā)送多個(gè)響應(yīng)。換句話說,除了對(duì)最初請求的響應(yīng)外,服務(wù)器還可以額外向客戶端推送資源,而無需客戶端明確地請求。

例如當(dāng)瀏覽器請求一個(gè)網(wǎng)站時(shí),除了返回 HTML 頁面外,服務(wù)器還可以根據(jù) HTML 頁面中的資源的 URL,來提前推送資源。

現(xiàn)在有很多網(wǎng)站已經(jīng)開始使用 HTTP2 了,例如知乎:

image

其中 h2 是指 HTTP2 協(xié)議,http/1.1 則是指 HTTP1.1 協(xié)議。

參考資料:

  • HTTP2 簡介
  • 半小時(shí)搞懂 HTTP、HTTPS和HTTP2

3. 使用服務(wù)端渲染

客戶端渲染: 獲取 HTML 文件,根據(jù)需要下載 JavaScript 文件,運(yùn)行文件,生成 DOM,再渲染。

服務(wù)端渲染:服務(wù)端返回 HTML 文件,客戶端只需解析 HTML。

  • 優(yōu)點(diǎn):首屏渲染快,SEO 好。
  • 缺點(diǎn):配置麻煩,增加了服務(wù)器的計(jì)算壓力。

參考資料:

  • vue-ssr-demo
  • Vue.js 服務(wù)器端渲染指南

4. 靜態(tài)資源使用 CDN

內(nèi)容分發(fā)網(wǎng)絡(luò)(CDN)是一組分布在多個(gè)不同地理位置的 Web 服務(wù)器。我們都知道,當(dāng)服務(wù)器離用戶越遠(yuǎn)時(shí),延遲越高。CDN 就是為了解決這一問題,在多個(gè)位置部署服務(wù)器,讓用戶離服務(wù)器更近,從而縮短請求時(shí)間。

CDN 原理

當(dāng)用戶訪問一個(gè)網(wǎng)站時(shí),如果沒有 CDN,過程是這樣的:

  1. 瀏覽器要將域名解析為 IP 地址,所以需要向本地 DNS 發(fā)出請求。
  2. 本地 DNS 依次向根服務(wù)器、頂級(jí)域名服務(wù)器、權(quán)限服務(wù)器發(fā)出請求,得到網(wǎng)站服務(wù)器的 IP 地址。
  3. 本地 DNS 將 IP 地址發(fā)回給瀏覽器,瀏覽器向網(wǎng)站服務(wù)器 IP 地址發(fā)出請求并得到資源。
image

如果用戶訪問的網(wǎng)站部署了 CDN,過程是這樣的:

  1. 瀏覽器要將域名解析為 IP 地址,所以需要向本地 DNS 發(fā)出請求。
  2. 本地 DNS 依次向根服務(wù)器、頂級(jí)域名服務(wù)器、權(quán)限服務(wù)器發(fā)出請求,得到全局負(fù)載均衡系統(tǒng)(GSLB)的 IP 地址。
  3. 本地 DNS 再向 GSLB 發(fā)出請求,GSLB 的主要功能是根據(jù)本地 DNS 的 IP 地址判斷用戶的位置,篩選出距離用戶較近的本地負(fù)載均衡系統(tǒng)(SLB),并將該 SLB 的 IP 地址作為結(jié)果返回給本地 DNS。
  4. 本地 DNS 將 SLB 的 IP 地址發(fā)回給瀏覽器,瀏覽器向 SLB 發(fā)出請求。
  5. SLB 根據(jù)瀏覽器請求的資源和地址,選出最優(yōu)的緩存服務(wù)器發(fā)回給瀏覽器。
  6. 瀏覽器再根據(jù) SLB 發(fā)回的地址重定向到緩存服務(wù)器。
  7. 如果緩存服務(wù)器有瀏覽器需要的資源,就將資源發(fā)回給瀏覽器。如果沒有,就向源服務(wù)器請求資源,再發(fā)給瀏覽器并緩存在本地。
image

參考資料:

  • CDN是什么?使用CDN有什么優(yōu)勢?
  • CDN原理簡析

5. 將 CSS 放在文件頭部,JavaScript 文件放在底部

所有放在 head 標(biāo)簽里的 CSS 和 JS 文件都會(huì)堵塞渲染。如果這些 CSS 和 JS 需要加載和解析很久的話,那么頁面就空白了。所以 JS 文件要放在底部,等 HTML 解析完了再加載 JS 文件。

那為什么 CSS 文件還要放在頭部呢?

因?yàn)橄燃虞d HTML 再加載 CSS,會(huì)讓用戶第一時(shí)間看到的頁面是沒有樣式的、“丑陋”的,為了避免這種情況發(fā)生,就要將 CSS 文件放在頭部了。

另外,JS 文件也不是不可以放在頭部,只要給 script 標(biāo)簽加上 defer 屬性就可以了,異步下載,延遲執(zhí)行。

6. 使用字體圖標(biāo) iconfont 代替圖片圖標(biāo)

字體圖標(biāo)就是將圖標(biāo)制作成一個(gè)字體,使用時(shí)就跟字體一樣,可以設(shè)置屬性,例如 font-size、color 等等,非常方便。并且字體圖標(biāo)是矢量圖,不會(huì)失真。還有一個(gè)優(yōu)點(diǎn)是生成的文件特別小。

參考資料:

  • Iconfont-阿里巴巴矢量圖標(biāo)庫

7. 善用緩存,不重復(fù)加載相同的資源

為了避免用戶每次訪問網(wǎng)站都得請求文件,我們可以通過添加 Expires 或 max-age 來控制這一行為。Expires 設(shè)置了一個(gè)時(shí)間,只要在這個(gè)時(shí)間之前,瀏覽器都不會(huì)請求文件,而是直接使用緩存。而 max-age 是一個(gè)相對(duì)時(shí)間,建議使用 max-age 代替 Expires 。

不過這樣會(huì)產(chǎn)生一個(gè)問題,當(dāng)文件更新了怎么辦?怎么通知瀏覽器重新請求文件?

可以通過更新頁面中引用的資源鏈接地址,讓瀏覽器主動(dòng)放棄緩存,加載新資源。

具體做法是把資源地址 URL 的修改與文件內(nèi)容關(guān)聯(lián)起來,也就是說,只有文件內(nèi)容變化,才會(huì)導(dǎo)致相應(yīng) URL 的變更,從而實(shí)現(xiàn)文件級(jí)別的精確緩存控制。什么東西與文件內(nèi)容相關(guān)呢?我們會(huì)很自然的聯(lián)想到利用數(shù)據(jù)摘要要算法對(duì)文件求摘要信息,摘要信息與文件內(nèi)容一一對(duì)應(yīng),就有了一種可以精確到單個(gè)文件粒度的緩存控制依據(jù)了。

參考資料:

  • webpack + express 實(shí)現(xiàn)文件精確緩存
  • webpack-緩存
  • 張?jiān)讫?-大公司里怎樣開發(fā)和部署前端代碼?

8. 壓縮文件

壓縮文件可以減少文件下載時(shí)間,讓用戶體驗(yàn)性更好。

得益于 webpack 和 node 的發(fā)展,現(xiàn)在壓縮文件已經(jīng)非常方便了。

在 webpack 可以使用如下插件進(jìn)行壓縮:

  • JavaScript:UglifyPlugin
  • CSS :MiniCssExtractPlugin
  • HTML:HtmlWebpackPlugin

其實(shí),我們還可以做得更好。那就是使用 gzip 壓縮。可以通過向 HTTP 請求頭中的 Accept-Encoding 頭添加 gzip 標(biāo)識(shí)來開啟這一功能。當(dāng)然,服務(wù)器也得支持這一功能。

gzip 是目前最流行和最有效的壓縮方法。舉個(gè)例子,我用 Vue 開發(fā)的項(xiàng)目構(gòu)建后生成的 app.js 文件大小為 1.4MB,使用 gzip 壓縮后只有 573KB,體積減少了將近 60%。

附上 webpack 和 node 配置 gzip 的使用方法。

下載插件

npm install compression-webpack-plugin --save-devnpm install compression

webpack 配置

const CompressionPlugin = require('compression-webpack-plugin');module.exports = {  plugins: [new CompressionPlugin()],}

node 配置

const compression = require('compression')// 在其他中間件前使用app.use(compression())

9. 圖片優(yōu)化

(1). 圖片延遲加載

在頁面中,先不給圖片設(shè)置路徑,只有當(dāng)圖片出現(xiàn)在瀏覽器的可視區(qū)域時(shí),才去加載真正的圖片,這就是延遲加載。對(duì)于圖片很多的網(wǎng)站來說,一次性加載全部圖片,會(huì)對(duì)用戶體驗(yàn)造成很大的影響,所以需要使用圖片延遲加載。

首先可以將圖片這樣設(shè)置,在頁面不可見時(shí)圖片不會(huì)加載:

<img data-src="https://avatars0.githubusercontent.com/u/22117876?s=460&u=7bd8f32788df6988833da6bd155c3cfbebc68006&v=4">

等頁面可見時(shí),使用 JS 加載圖片:

const img = document.querySelector('img')img.src = img.dataset.src

這樣圖片就加載出來了,完整的代碼可以看一下參考資料。

參考資料:

  • web 前端圖片懶加載實(shí)現(xiàn)原理

(2). 響應(yīng)式圖片

響應(yīng)式圖片的優(yōu)點(diǎn)是瀏覽器能夠根據(jù)屏幕大小自動(dòng)加載合適的圖片。

通過 picture 實(shí)現(xiàn)

<picture> <source srcset="banner_w1000.jpg" media="(min-width: 801px)"> <source srcset="banner_w800.jpg" media="(max-width: 800px)"> <img src="banner_w800.jpg" alt=""></picture>

通過 @media 實(shí)現(xiàn)

@media (min-width: 769px) { .bg {  background-image: url(bg1080.jpg); }}@media (max-width: 768px) { .bg {  background-image: url(bg768.jpg); }}

(3). 調(diào)整圖片大小

例如,你有一個(gè) 1920 * 1080 大小的圖片,用縮略圖的方式展示給用戶,并且當(dāng)用戶鼠標(biāo)懸停在上面時(shí)才展示全圖。如果用戶從未真正將鼠標(biāo)懸停在縮略圖上,則浪費(fèi)了下載圖片的時(shí)間。

所以,我們可以用兩張圖片來實(shí)行優(yōu)化。一開始,只加載縮略圖,當(dāng)用戶懸停在圖片上時(shí),才加載大圖。還有一種辦法,即對(duì)大圖進(jìn)行延遲加載,在所有元素都加載完成后手動(dòng)更改大圖的 src 進(jìn)行下載。

(4). 降低圖片質(zhì)量

例如 JPG 格式的圖片,100% 的質(zhì)量和 90% 質(zhì)量的通常看不出來區(qū)別,尤其是用來當(dāng)背景圖的時(shí)候。我經(jīng)常用 PS 切背景圖時(shí), 將圖片切成 JPG 格式,并且將它壓縮到 60% 的質(zhì)量,基本上看不出來區(qū)別。

壓縮方法有兩種,一是通過 webpack 插件 image-webpack-loader,二是通過在線網(wǎng)站進(jìn)行壓縮。

以下附上 webpack 插件 image-webpack-loader 的用法。

npm i -D image-webpack-loader

webpack 配置

{  test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,  use:[    {    loader: 'url-loader',    options: {      limit: 10000, /* 圖片大小小于1000字節(jié)限制時(shí)會(huì)自動(dòng)轉(zhuǎn)成 base64 碼引用*/      name: utils.assetsPath('img/[name].[hash:7].[ext]')      }    },    /*對(duì)圖片進(jìn)行壓縮*/    {      loader: 'image-webpack-loader',      options: {        bypassOnDebug: true,      }    }  ]}

(5). 盡可能利用 CSS3 效果代替圖片

有很多圖片使用 CSS 效果(漸變、陰影等)就能畫出來,這種情況選擇 CSS3 效果更好。因?yàn)榇a大小通常是圖片大小的幾分之一甚至幾十分之一。

參考資料:

  • img圖片在webpack中使用

10. 通過 webpack 按需加載代碼,提取第三庫代碼,減少 ES6 轉(zhuǎn)為 ES5 的冗余代碼

懶加載或者按需加載,是一種很好的優(yōu)化網(wǎng)頁或應(yīng)用的方式。這種方式實(shí)際上是先把你的代碼在一些邏輯斷點(diǎn)處分離開,然后在一些代碼塊中完成某些操作后,立即引用或即將引用另外一些新的代碼塊。這樣加快了應(yīng)用的初始加載速度,減輕了它的總體體積,因?yàn)槟承┐a塊可能永遠(yuǎn)不會(huì)被加載。

根據(jù)文件內(nèi)容生成文件名,結(jié)合 import 動(dòng)態(tài)引入組件實(shí)現(xiàn)按需加載

通過配置 output 的 filename 屬性可以實(shí)現(xiàn)這個(gè)需求。filename 屬性的值選項(xiàng)中有一個(gè) [contenthash],它將根據(jù)文件內(nèi)容創(chuàng)建出唯一 hash。當(dāng)文件內(nèi)容發(fā)生變化時(shí),[contenthash] 也會(huì)發(fā)生變化。

output: { filename: '[name].[contenthash].js',    chunkFilename: '[name].[contenthash].js',    path: path.resolve(__dirname, '../dist'),},

提取第三方庫

由于引入的第三方庫一般都比較穩(wěn)定,不會(huì)經(jīng)常改變。所以將它們單獨(dú)提取出來,作為長期緩存是一個(gè)更好的選擇。這里需要使用 webpack4 的 splitChunk 插件 cacheGroups 選項(xiàng)。

optimization: {   runtimeChunk: {        name: 'manifest' // 將 webpack 的 runtime 代碼拆分為一個(gè)單獨(dú)的 chunk。    },    splitChunks: {        cacheGroups: {            vendor: {                name: 'chunk-vendors',                test: /[\\/]node_modules[\\/]/,                priority: -10,                chunks: 'initial'            },            common: {                name: 'chunk-common',                minChunks: 2,                priority: -20,                chunks: 'initial',                reuseExistingChunk: true            }        },    }},
  • test: 用于控制哪些模塊被這個(gè)緩存組匹配到。原封不動(dòng)傳遞出去的話,它默認(rèn)會(huì)選擇所有的模塊。可以傳遞的值類型:RegExp、String和Function;
  • priority:表示抽取權(quán)重,數(shù)字越大表示優(yōu)先級(jí)越高。因?yàn)橐粋€(gè) module 可能會(huì)滿足多個(gè) cacheGroups 的條件,那么抽取到哪個(gè)就由權(quán)重最高的說了算;
  • reuseExistingChunk:表示是否使用已有的 chunk,如果為 true 則表示如果當(dāng)前的 chunk 包含的模塊已經(jīng)被抽取出去了,那么將不會(huì)重新生成新的。
  • minChunks(默認(rèn)是1):在分割之前,這個(gè)代碼塊最小應(yīng)該被引用的次數(shù)(譯注:保證代碼塊復(fù)用性,默認(rèn)配置的策略是不需要多次引用也可以被分割)
  • chunks (默認(rèn)是async) :initial、async和all
  • name(打包的chunks的名字):字符串或者函數(shù)(函數(shù)可以根據(jù)條件自定義名字)

減少 ES6 轉(zhuǎn)為 ES5 的冗余代碼

Babel 轉(zhuǎn)化后的代碼想要實(shí)現(xiàn)和原來代碼一樣的功能需要借助一些幫助函數(shù),比如:

class Person {}

會(huì)被轉(zhuǎn)換為:

"use strict";function _classCallCheck(instance, Constructor) {  if (!(instance instanceof Constructor)) {    throw new TypeError("Cannot call a class as a function");  }}var Person = function Person() {  _classCallCheck(this, Person);};

這里 _classCallCheck 就是一個(gè) helper 函數(shù),如果在很多文件里都聲明了類,那么就會(huì)產(chǎn)生很多個(gè)這樣的 helper 函數(shù)。

這里的 @babel/runtime 包就聲明了所有需要用到的幫助函數(shù),而 @babel/plugin-transform-runtime 的作用就是將所有需要 helper 函數(shù)的文件,從 @babel/runtime包 引進(jìn)來:

"use strict";var _classCallCheck2 = require("@babel/runtime/helpers/classCallCheck");var _classCallCheck3 = _interopRequireDefault(_classCallCheck2);function _interopRequireDefault(obj) {  return obj && obj.__esModule ? obj : { default: obj };}var Person = function Person() {  (0, _classCallCheck3.default)(this, Person);};

這里就沒有再編譯出 helper 函數(shù) classCallCheck 了,而是直接引用了 @babel/runtime 中的 helpers/classCallCheck

安裝

npm i -D @babel/plugin-transform-runtime @babel/runtime

使用.babelrc 文件中

"plugins": [        "@babel/plugin-transform-runtime"]

參考資料:

  • Babel 7.1介紹 transform-runtime polyfill env
  • 懶加載
  • Vue 路由懶加載
  • webpack 緩存
  • 一步一步的了解webpack4的splitChunk插件

11. 減少重繪重排

瀏覽器渲染過程

  1. 解析HTML生成DOM樹。
  2. 解析CSS生成CSSOM規(guī)則樹。
  3. 將DOM樹與CSSOM規(guī)則樹合并在一起生成渲染樹。
  4. 遍歷渲染樹開始布局,計(jì)算每個(gè)節(jié)點(diǎn)的位置大小信息。
  5. 將渲染樹每個(gè)節(jié)點(diǎn)繪制到屏幕。
image

重排

當(dāng)改變 DOM 元素位置或大小時(shí),會(huì)導(dǎo)致瀏覽器重新生成渲染樹,這個(gè)過程叫重排。

重繪

當(dāng)重新生成渲染樹后,就要將渲染樹每個(gè)節(jié)點(diǎn)繪制到屏幕,這個(gè)過程叫重繪。不是所有的動(dòng)作都會(huì)導(dǎo)致重排,例如改變字體顏色,只會(huì)導(dǎo)致重繪。記住,重排會(huì)導(dǎo)致重繪,重繪不會(huì)導(dǎo)致重排 。

重排和重繪這兩個(gè)操作都是非常昂貴的,因?yàn)?JavaScript 引擎線程與 GUI 渲染線程是互斥,它們同時(shí)只能一個(gè)在工作。

什么操作會(huì)導(dǎo)致重排?

  • 添加或刪除可見的 DOM 元素
  • 元素位置改變
  • 元素尺寸改變
  • 內(nèi)容改變
  • 瀏覽器窗口尺寸改變

如何減少重排重繪?

  • 用 JavaScript 修改樣式時(shí),最好不要直接寫樣式,而是替換 class 來改變樣式。
  • 如果要對(duì) DOM 元素執(zhí)行一系列操作,可以將 DOM 元素脫離文檔流,修改完成后,再將它帶回文檔。推薦使用隱藏元素(display:none)或文檔碎片(DocumentFragement),都能很好的實(shí)現(xiàn)這個(gè)方案。

12. 使用事件委托

事件委托利用了事件冒泡,只指定一個(gè)事件處理程序,就可以管理某一類型的所有事件。所有用到按鈕的事件(多數(shù)鼠標(biāo)事件和鍵盤事件)都適合采用事件委托技術(shù), 使用事件委托可以節(jié)省內(nèi)存。

<ul>  <li>蘋果</li>  <li>香蕉</li>  <li>鳳梨</li></ul>// gooddocument.querySelector('ul').onclick = (event) => {  const target = event.target  if (target.nodeName === 'LI') {    console.log(target.innerHTML)  }}// baddocument.querySelectorAll('li').forEach((e) => {  e.onclick = function() {    console.log(this.innerHTML)  }}) 

13. 注意程序的局部性

一個(gè)編寫良好的計(jì)算機(jī)程序常常具有良好的局部性,它們傾向于引用最近引用過的數(shù)據(jù)項(xiàng)附近的數(shù)據(jù)項(xiàng),或者最近引用過的數(shù)據(jù)項(xiàng)本身,這種傾向性,被稱為局部性原理。有良好局部性的程序比局部性差的程序運(yùn)行得更快。

局部性通常有兩種不同的形式:

  • 時(shí)間局部性:在一個(gè)具有良好時(shí)間局部性的程序中,被引用過一次的內(nèi)存位置很可能在不遠(yuǎn)的將來被多次引用。
  • 空間局部性 :在一個(gè)具有良好空間局部性的程序中,如果一個(gè)內(nèi)存位置被引用了一次,那么程序很可能在不遠(yuǎn)的將來引用附近的一個(gè)內(nèi)存位置。

時(shí)間局部性示例

function sum(arry) { let i, sum = 0 let len = arry.length for (i = 0; i < len; i++) {  sum += arry[i] } return sum}

在這個(gè)例子中,變量sum在每次循環(huán)迭代中被引用一次,因此,對(duì)于sum來說,具有良好的時(shí)間局部性

空間局部性示例

具有良好空間局部性的程序

// 二維數(shù)組 function sum1(arry, rows, cols) { let i, j, sum = 0 for (i = 0; i < rows; i++) {  for (j = 0; j < cols; j++) {   sum += arry[i][j]  } } return sum}

空間局部性差的程序

// 二維數(shù)組 function sum2(arry, rows, cols) { let i, j, sum = 0 for (j = 0; j < cols; j++) {  for (i = 0; i < rows; i++) {   sum += arry[i][j]  } } return sum}

看一下上面的兩個(gè)空間局部性示例,像示例中從每行開始按順序訪問數(shù)組每個(gè)元素的方式,稱為具有步長為1的引用模式。如果在數(shù)組中,每隔k個(gè)元素進(jìn)行訪問,就稱為步長為k的引用模式。一般而言,隨著步長的增加,空間局部性下降。

這兩個(gè)例子有什么區(qū)別?區(qū)別在于第一個(gè)示例是按行掃描數(shù)組,每掃描完一行再去掃下一行;第二個(gè)示例是按列來掃描數(shù)組,掃完一行中的一個(gè)元素,馬上就去掃下一行中的同一列元素。

數(shù)組在內(nèi)存中是按照行順序來存放的,結(jié)果就是逐行掃描數(shù)組的示例得到了步長為 1 引用模式,具有良好的空間局部性;而另一個(gè)示例步長為 rows,空間局部性極差。

性能測試

運(yùn)行環(huán)境:

  • cpu: i5-7400
  • 瀏覽器: chrome 70.0.3538.110

對(duì)一個(gè)長度為9000的二維數(shù)組(子數(shù)組長度也為9000)進(jìn)行10次空間局部性測試,時(shí)間(毫秒)取平均值,結(jié)果如下:

所用示例為上述兩個(gè)空間局部性示例

步長為 1 步長為 9000
124 2316

從以上測試結(jié)果來看,步長為 1 的數(shù)組執(zhí)行時(shí)間比步長為 9000 的數(shù)組快了一個(gè)數(shù)量級(jí)。

總結(jié):

  • 重復(fù)引用相同變量的程序具有良好的時(shí)間局部性
  • 對(duì)于具有步長為 k 的引用模式的程序,步長越小,空間局部性越好;而在內(nèi)存中以大步長跳來跳去的程序空間局部性會(huì)很差

參考資料:

  • 深入理解計(jì)算機(jī)系統(tǒng)

14. if-else 對(duì)比 switch

當(dāng)判斷條件數(shù)量越來越多時(shí),越傾向于使用 switch 而不是 if-else。

if (color == 'blue') {} else if (color == 'yellow') {} else if (color == 'white') {} else if (color == 'black') {} else if (color == 'green') {} else if (color == 'orange') {} else if (color == 'pink') {}switch (color) {    case 'blue':        break    case 'yellow':        break    case 'white':        break    case 'black':        break    case 'green':        break    case 'orange':        break    case 'pink':        break}

像以上這種情況,使用 switch 是最好的。假設(shè) color 的值為 pink,則 if-else 語句要進(jìn)行 7 次判斷,switch 只需要進(jìn)行一次判斷。從可讀性來說,switch 語句也更好。從使用時(shí)機(jī)來說,當(dāng)條件值大于兩個(gè)的時(shí)候,使用 switch 更好。

不過,switch 只能用于 case 值為常量的分支結(jié)構(gòu),而 if-else 更加靈活。

15. 查找表

當(dāng)條件語句特別多時(shí),使用 switch 和 if-else 不是最佳的選擇,這時(shí)不妨試一下查找表。查找表可以使用數(shù)組和對(duì)象來構(gòu)建。

switch (index) {    case '0':        return result0    case '1':        return result1    case '2':        return result2    case '3':        return result3    case '4':        return result4    case '5':        return result5    case '6':        return result6    case '7':        return result7    case '8':        return result8    case '9':        return result9    case '10':        return result10    case '11':        return result11}

可以將這個(gè) switch 語句轉(zhuǎn)換為查找表

const results = [result0,result1,result2,result3,result4,result5,result6,result7,result8,result9,result10,result11]return results[index]

如果條件語句不是數(shù)值而是字符串,可以用對(duì)象來建立查找表

const map = {  red: result0,  green: result1,}return map[color]

16. 避免頁面卡頓

60fps 與設(shè)備刷新率

目前大多數(shù)設(shè)備的屏幕刷新率為 60 次/秒。因此,如果在頁面中有一個(gè)動(dòng)畫或漸變效果,或者用戶正在滾動(dòng)頁面,那么瀏覽器渲染動(dòng)畫或頁面的每一幀的速率也需要跟設(shè)備屏幕的刷新率保持一致。其中每個(gè)幀的預(yù)算時(shí)間僅比 16 毫秒多一點(diǎn) (1 秒/ 60 = 16.66 毫秒)。但實(shí)際上,瀏覽器有整理工作要做,因此您的所有工作需要在 10 毫秒內(nèi)完成。如果無法符合此預(yù)算,幀率將下降,并且內(nèi)容會(huì)在屏幕上抖動(dòng)。此現(xiàn)象通常稱為卡頓,會(huì)對(duì)用戶體驗(yàn)產(chǎn)生負(fù)面影響。

image

假如你用 JavaScript 修改了 DOM,并觸發(fā)樣式修改,經(jīng)歷重排重繪最后畫到屏幕上。如果這其中任意一項(xiàng)的執(zhí)行時(shí)間過長,都會(huì)導(dǎo)致渲染這一幀的時(shí)間過長,平均幀率就會(huì)下降。假設(shè)這一幀花了 50 ms,那么此時(shí)的幀率為 1s / 50ms = 20fps,頁面看起來就像卡頓了一樣。

對(duì)于一些長時(shí)間運(yùn)行的 JavaScript,我們可以使用定時(shí)器進(jìn)行切分,延遲執(zhí)行。

for (let i = 0, len = arry.length; i < len; i++) { process(arry[i])}

假設(shè)上面的循環(huán)結(jié)構(gòu)由于 process() 復(fù)雜度過高或數(shù)組元素太多,甚至兩者都有,可以嘗試一下切分。

const todo = arry.concat()setTimeout(() => { process(todo.shift()) if (todo.length) {  setTimeout(arguments.callee, 25) } else {  callback(arry) }}, 25)

如果有興趣了解更多,可以查看一下高性能JavaScript第 6 章和高效前端:Web高效編程與優(yōu)化實(shí)踐第 3 章。

參考資料:

  • 渲染性能

17. 使用 requestAnimationFrame 來實(shí)現(xiàn)視覺變化

從第 16 點(diǎn)我們可以知道,大多數(shù)設(shè)備屏幕刷新率為 60 次/秒,也就是說每一幀的平均時(shí)間為 16.66 毫秒。在使用 JavaScript 實(shí)現(xiàn)動(dòng)畫效果的時(shí)候,最好的情況就是每次代碼都是在幀的開頭開始執(zhí)行。而保證 JavaScript 在幀開始時(shí)運(yùn)行的唯一方式是使用 requestAnimationFrame

/** * If run as a requestAnimationFrame callback, this * will be run at the start of the frame. */function updateScreen(time) {  // Make visual updates here.}requestAnimationFrame(updateScreen);

如果采取 setTimeoutsetInterval 來實(shí)現(xiàn)動(dòng)畫的話,回調(diào)函數(shù)將在幀中的某個(gè)時(shí)點(diǎn)運(yùn)行,可能剛好在末尾,而這可能經(jīng)常會(huì)使我們丟失幀,導(dǎo)致卡頓。

image

<figcaption style="margin: 5px 0px 0px; padding: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; text-align: center; color: rgb(136, 136, 136); font-size: 14px;">在這里插入圖片描述</figcaption>

參考資料:

  • 優(yōu)化 JavaScript 執(zhí)行

18. 使用 Web Workers

Web Worker 使用其他工作線程從而獨(dú)立于主線程之外,它可以執(zhí)行任務(wù)而不干擾用戶界面。一個(gè) worker 可以將消息發(fā)送到創(chuàng)建它的 JavaScript 代碼, 通過將消息發(fā)送到該代碼指定的事件處理程序(反之亦然)。

Web Worker 適用于那些處理純數(shù)據(jù),或者與瀏覽器 UI 無關(guān)的長時(shí)間運(yùn)行腳本。

創(chuàng)建一個(gè)新的 worker 很簡單,指定一個(gè)腳本的 URI 來執(zhí)行 worker 線程(main.js):

var myWorker = new Worker('worker.js');// 你可以通過postMessage() 方法和onmessage事件向worker發(fā)送消息。first.onchange = function() {  myWorker.postMessage([first.value,second.value]);  console.log('Message posted to worker');}second.onchange = function() {  myWorker.postMessage([first.value,second.value]);  console.log('Message posted to worker');}

在 worker 中接收到消息后,我們可以寫一個(gè)事件處理函數(shù)代碼作為響應(yīng)(worker.js):

onmessage = function(e) {  console.log('Message received from main script');  var workerResult = 'Result: ' + (e.data[0] * e.data[1]);  console.log('Posting message back to main script');  postMessage(workerResult);}

onmessage處理函數(shù)在接收到消息后馬上執(zhí)行,代碼中消息本身作為事件的data屬性進(jìn)行使用。這里我們簡單的對(duì)這2個(gè)數(shù)字作乘法處理并再次使用postMessage()方法,將結(jié)果回傳給主線程。

回到主線程,我們再次使用onmessage以響應(yīng)worker回傳的消息:

myWorker.onmessage = function(e) {  result.textContent = e.data;  console.log('Message received from worker');}

在這里我們獲取消息事件的data,并且將它設(shè)置為result的textContent,所以用戶可以直接看到運(yùn)算的結(jié)果。

不過在worker內(nèi),不能直接操作DOM節(jié)點(diǎn),也不能使用window對(duì)象的默認(rèn)方法和屬性。然而你可以使用大量window對(duì)象之下的東西,包括WebSockets,IndexedDB以及FireFox OS專用的Data Store API等數(shù)據(jù)存儲(chǔ)機(jī)制。

參考資料:

  • Web Workers

19. 使用位操作

JavaScript 中的數(shù)字都使用 IEEE-754 標(biāo)準(zhǔn)以 64 位格式存儲(chǔ)。但是在位操作中,數(shù)字被轉(zhuǎn)換為有符號(hào)的 32 位格式。即使需要轉(zhuǎn)換,位操作也比其他數(shù)學(xué)運(yùn)算和布爾操作快得多。

取模

由于偶數(shù)的最低位為 0,奇數(shù)為 1,所以取模運(yùn)算可以用位操作來代替。

if (value % 2) { // 奇數(shù)} else { // 偶數(shù) }// 位操作if (value & 1) { // 奇數(shù)} else { // 偶數(shù)}
取反
~~10.12 // 10~~10 // 10~~'1.5' // 1~~undefined // 0~~null // 0
位掩碼
const a = 1const b = 2const c = 4const options = a | b | c

通過定義這些選項(xiàng),可以用按位與操作來判斷 a/b/c 是否在 options 中。

// 選項(xiàng) b 是否在選項(xiàng)中if (b & options) { ...}

20. 不要覆蓋原生方法

無論你的 JavaScript 代碼如何優(yōu)化,都比不上原生方法。因?yàn)樵椒ㄊ怯玫图?jí)語言寫的(C/C++),并且被編譯成機(jī)器碼,成為瀏覽器的一部分。當(dāng)原生方法可用時(shí),盡量使用它們,特別是數(shù)學(xué)運(yùn)算和 DOM 操作。

21. 降低 CSS 選擇器的復(fù)雜性

(1). 瀏覽器讀取選擇器,遵循的原則是從選擇器的右邊到左邊讀取。

看個(gè)示例

#block .text p { color: red;}
  1. 查找所有 P 元素。
  2. 查找結(jié)果 1 中的元素是否有類名為 text 的父元素
  3. 查找結(jié)果 2 中的元素是否有 id 為 block 的父元素

(2). CSS 選擇器優(yōu)先級(jí)

內(nèi)聯(lián) > ID選擇器 > 類選擇器 > 標(biāo)簽選擇器

根據(jù)以上兩個(gè)信息可以得出結(jié)論。

  1. 選擇器越短越好。
  2. 盡量使用高優(yōu)先級(jí)的選擇器,例如 ID 和類選擇器。
  3. 避免使用通配符 *。

最后要說一句,據(jù)我查找的資料所得,CSS 選擇器沒有優(yōu)化的必要,因?yàn)樽盥吐斓倪x擇器性能差別非常小。

參考資料:

  • CSS selector performance
  • Optimizing CSS: ID Selectors and Other Myths

22. 使用 flexbox 而不是較早的布局模型

在早期的 CSS 布局方式中我們能對(duì)元素實(shí)行絕對(duì)定位、相對(duì)定位或浮動(dòng)定位。而現(xiàn)在,我們有了新的布局方式 flexbox,它比起早期的布局方式來說有個(gè)優(yōu)勢,那就是性能比較好。

下面的截圖顯示了在 1300 個(gè)框上使用浮動(dòng)的布局開銷:

image

然后我們用 flexbox 來重現(xiàn)這個(gè)例子:

image

現(xiàn)在,對(duì)于相同數(shù)量的元素和相同的視覺外觀,布局的時(shí)間要少得多(本例中為分別 3.5 毫秒和 14 毫秒)。

不過 flexbox 兼容性還是有點(diǎn)問題,不是所有瀏覽器都支持它,所以要謹(jǐn)慎使用。

各瀏覽器兼容性:

  • Chrome 29+
  • Firefox 28+
  • Internet Explorer 11
  • Opera 17+
  • Safari 6.1+ (prefixed with -webkit-)
  • Android 4.4+
  • iOS 7.1+ (prefixed with -webkit-)

參考資料:

  • 使用 flexbox 而不是較早的布局模型

23. 使用 transform 和 opacity 屬性更改來實(shí)現(xiàn)動(dòng)畫

在 CSS 中,transforms 和 opacity 這兩個(gè)屬性更改不會(huì)觸發(fā)重排與重繪,它們是可以由合成器(composite)單獨(dú)處理的屬性。

image

參考資料:

  • 使用 transform 和 opacity 屬性更改來實(shí)現(xiàn)動(dòng)畫

24. 合理使用規(guī)則,避免過度優(yōu)化

性能優(yōu)化主要分為兩類:

  1. 加載時(shí)優(yōu)化
  2. 運(yùn)行時(shí)優(yōu)化

上述 23 條建議中,屬于加載時(shí)優(yōu)化的是前面 10 條建議,屬于運(yùn)行時(shí)優(yōu)化的是后面 13 條建議。通常來說,沒有必要 23 條性能優(yōu)化規(guī)則都用上,根據(jù)網(wǎng)站用戶群體來做針對(duì)性的調(diào)整是最好的,節(jié)省精力,節(jié)省時(shí)間。

在解決問題之前,得先找出問題,否則無從下手。所以在做性能優(yōu)化之前,最好先調(diào)查一下網(wǎng)站的加載性能和運(yùn)行性能。

檢查加載性能

一個(gè)網(wǎng)站加載性能如何主要看白屏?xí)r間和首屏?xí)r間。

  • 白屏?xí)r間:指從輸入網(wǎng)址,到頁面開始顯示內(nèi)容的時(shí)間。
  • 首屏?xí)r間:指從輸入網(wǎng)址,到頁面完全渲染的時(shí)間。

將以下腳本放在 </head> 前面就能獲取白屏?xí)r間。

<script> new Date() - performance.timing.navigationStart</script>

首屏?xí)r間比較復(fù)雜,得考慮有圖片和沒有圖片的情況。

如果沒有圖片,則在 window.onload 事件里執(zhí)行 new Date() - performance.timing.navigationStart 即可獲取首屏?xí)r間。

如果有圖片,則要在最后一個(gè)在首屏渲染的圖片的 onload 事件里執(zhí)行 new Date() - performance.timing.navigationStart 獲取首屏?xí)r間,實(shí)施起來比較復(fù)雜,在這里限于篇幅就不說了。

檢查運(yùn)行性能

配合 chrome 的開發(fā)者工具,我們可以查看網(wǎng)站在運(yùn)行時(shí)的性能。

打開網(wǎng)站,按 F12 選擇 performance,點(diǎn)擊左上角的灰色圓點(diǎn),變成紅色就代表開始記錄了。這時(shí)可以模仿用戶使用網(wǎng)站,在使用完畢后,點(diǎn)擊 stop,然后你就能看到網(wǎng)站運(yùn)行期間的性能報(bào)告。如果有紅色的塊,代表有掉幀的情況;如果是綠色,則代表 FPS 很好。performance 的具體使用方法請用搜索引擎搜索一下,畢竟篇幅有限。

通過檢查加載和運(yùn)行性能,相信你對(duì)網(wǎng)站性能已經(jīng)有了大概了解。所以這時(shí)候要做的事情,就是使用上述 23 條建議盡情地去優(yōu)化你的網(wǎng)站,加油!

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,333評(píng)論 6 531
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 98,491評(píng)論 3 416
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,263評(píng)論 0 374
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經(jīng)常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,946評(píng)論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 71,708評(píng)論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 55,186評(píng)論 1 324
  • 那天,我揣著相機(jī)與錄音,去河邊找鬼。 笑死,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,255評(píng)論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 42,409評(píng)論 0 288
  • 序言:老撾萬榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個(gè)月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 48,939評(píng)論 1 335
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 40,774評(píng)論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 42,976評(píng)論 1 369
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,518評(píng)論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 44,209評(píng)論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,641評(píng)論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,872評(píng)論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 51,650評(píng)論 3 391
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 47,958評(píng)論 2 373