前端項目優化 -Web 開發常用優化方案、Vue & React 項目優化 ...
github
從輸入URL到頁面載入完成的整個過程
- 首先做 DNS 查詢,如果這一步做了智能 DNS 解析的話,會提供訪問速度最快的 IP 地址回來
- 接下來是 TCP 握手,應用層會下發數據給傳輸層,這裡 TCP 協議會指明兩端的埠號,然後下發給網路層。網路層中的 IP 協議會確定 IP 地址,並且指示了數據傳輸中如何跳轉路由器。然後包會再被封裝到數據鏈路層的數據幀結構中,最後就是物理層面的傳輸了
- TCP 握手結束後會進行 TLS 握手,然後就開始正式的傳輸數據(如果使用HTTPS)
- 數據在進入服務端之前,可能還會先經過負責負載均衡的伺服器,它的作用就是將請求合理的分發到多台伺服器上,這時假設服務端會響應一個 HTML 文件
- 首先瀏覽器會判斷狀態碼是什麼,如果是 200 那就繼續解析,如果 400 或 500 的話就會報錯,如果 300 的話會進行重定向,這裡會有個重定向計數器,避免過多次的重定向,超過次數也會報錯
- 瀏覽器開始解析文件,如果是 gzip 格式的話會先解壓一下,然後通過文件的編碼格式知道該如何去解碼文件
- 文件解碼成功後會正式開始渲染流程,先會根據 HTML 構建 DOM 樹,有 CSS 的話會去構建 CSSOM 樹。如果遇到
script
標簽的話,會判斷是否存在async
或者defer
,前者會並行進行下載並執行 JS,後者會先下載文件,然後等待 HTML 解析完成後順序執行,如果以上都沒有,就會阻塞住渲染流程直到 JS 執行完畢。遇到文件下載的會去下載文件,這裡如果使用 HTTP 2.0 協議的話會極大的提高多圖的下載效率。 - 初始的 HTML 被完全載入和解析後會觸發
DOMContentLoaded
事件 - CSSOM 樹和 DOM 樹構建完成後會開始生成 Render 樹,這一步就是確定頁面元素的佈局、樣式等等諸多方面的東西
- 在生成 Render 樹的過程中,瀏覽器就開始調用 GPU 繪製,合成圖層,將內容顯示在屏幕上了
- 沒有要傳輸的文件了,斷開TCP連接 4 次揮手
性能優化分析
根據上面的過程可以看到,頁面的載入過程主要分為下載、解析、渲染三個步驟,整體可以從兩個角度來考慮:
- 網頁的資源請求與載入階段
- 網頁渲染階段
網頁的資源請求與載入階段
我們可以打開 Chrome 的調試工具來分析此階段的性能指標
在建立 TCP 連接的階段(HTTP 協議是建立在 TCP 協議之上的)
- Queuing 和 Stalled 表示請求隊列以及請求等待的時間
- DNS Lookup 表示執行 DNS 查詢所用的時間。頁面上的每一個新域都需要完整的往返才能執行DNS查詢
- Initila connection 和 SSL 包括 TCP 握手重試和協商 SSL 以及 SSL 握手的時間。
在請求響應的階段
- Request sent 是發出網路請求所用的時間,通常不會超過 1ms
- Watiting(TTFB) 是等待初始響應所用的時間,也稱為等待返迴首個位元組的時間,該時間將捕捉到伺服器往返的延遲時間,以及等待伺服器傳送響應所用的時間。
- Content Download 則是從伺服器上接收數據的時間。
資源請求階段優化方案
依據上面的指標給出以下幾點優化方案(僅供參考)
1、劃分子域
條件:擁有多個功能變數名稱
Chrome 瀏覽器只允許每個源擁有 6 個 TCP 連接,因此可以通過劃分子域的方式,將多個資源分佈在不同子域上用來減少請求隊列的等待時間。然而,劃分子域並不是一勞永逸的方式,多個子域意味著更多的 DNS 查詢時間。通常劃分為 3 到 5 個比較合適。
對如何拆分資源有如下建議:
- 前端類:把項目業務本身的 html、css、js、圖標等歸為一類
- 靜態類:CDN 資源
- 動態類:後端 API
2、DNS 預解析
DNS 解析也是需要時間的,可以通過預解析的方式來預先獲得功能變數名稱所對應的 IP,方法是在 head 標簽里寫上幾個 link 標簽
<link rel="dns-prefetch" href="https://www.google.com">
<link rel="dns-prefetch" href="https://www.google-analytics.com">
對以上幾個網站提前解析,這個過程是並行的,不會阻塞頁面渲染。
3、預載入
在開發中,可能會遇到這樣的情況。有些資源不需要馬上用到,但是希望儘早獲取,這時候就可以使用預載入。
預載入其實是聲明式的 fetch,強制瀏覽器請求資源,並且不會阻塞 onload 事件,可以使用以下代碼開啟預載入:
<link rel="preload" href="http://example.com">
預載入可以一定程度上降低首屏的載入時間,因為可以將一些不影響首屏但重要的文件延後載入,唯一缺點就是相容性不好。
4、保持持久連接
HTTP 是一個無狀態的面向連接的協議,即每個 HTTP 請求都是獨立的。然而無狀態並不代表 HTTP 不能保持 TCP 連接,Keep-Alive 正是 HTTP 協議中保持 TCP 連接非常重要的一個屬性。 HTTP1.1 協議中,Keep-Alive 預設打開,使得通信雙方在完成一次通信後仍然保持一定時長的連接,因此瀏覽器可以在一個單獨的連接上進行多個請求,有效地降低建立 TCP 請求所消耗的時間。
5、CND 加速
使用 CND 加速可以減少客戶端到伺服器的網路距離。
- CDN 的意圖就是儘可能地減少資源在轉發、傳輸、鏈路抖動等情況下順利保障信息的連貫性;
- CDN 系統能夠實時地根據網路流量和各節點的連接、負載狀況以及到用戶的距離和響應時間等綜合信息將用戶的請求重新導向離用戶最近的服務節點上
- CDN 採用各節點緩存的機制,當我們項目的靜態資源修改後,如果 CDN 緩存沒有做相應更新,則看到的還是舊的網頁,解決的辦法是刷新緩存,七牛雲、騰訊雲都可單獨針對某個文件/目錄進行刷新;
- CDN 緩存需要合理地使用:圖片、常用 js 組件、css 重置樣式等,即不常改動的文件可走 CDN,包括項目內的一些介紹頁;
還有一種比較流行的做法是讓一些項目依賴走 CDN,比如 vuex、vue-router 這些插件通過外鏈的形式來引入,因為它們都有自己免費的 CDN,這樣可以減少打包後的文件體積。
6、設置緩存
緩存對於前端性能優化來說是個很重要的點,良好的緩存策略可以降低資源的重覆載入提高網頁的整體載入速度。
通常瀏覽器緩存策略分為兩種:強緩存 和 協商緩存。
- 強緩存:實現強緩存可以通過兩種響應頭實現:
Expires
和Cache-Control
強緩存表示在緩存期間不需要向伺服器發送請求 - 協商緩存:緩存過期了就是用協商緩存,其通過
Last-Modified/If-Modified-Since
和ETag/If-None-Match
實現
HTTP 頭中與緩存相關的屬性,主要有以下幾個:
(1) Expires: 指定緩存過期的時間,是一個絕對時間,但受客戶端和服務端時鐘和時區差異的影響,是 HTTP/1.0 的產物
形如Expires: Wed, 22 Oct 2018 08:41:00 GMT
(2) Cache-Control:比 Expires 策略更詳細,max-age 優先順序比 Expires 高,其值可以是以下五種情況
- no-cache: 強制所有緩存了該響應的緩存用戶,在使用已存儲的緩存數據前,發送請求到原始伺服器(進行過期認證),通常情況下,過期認證需要配合 etag 和 Last-Modified 進行一個比較
- no-store: 告訴客戶端不要響應緩存(禁止使用緩存,每一次都重新請求數據)
- public: 緩存響應,並可以在多用戶間共用(與中間代理伺服器相關)
- private: 緩存響應,但不能在多用戶間共用(與中間代理伺服器相關)
- max-age: 緩存在指定時間(單位為秒)後過期
(3) Last-Modified / If-Modified-Since: Last-Modified
表示本地文件最後修改日期,If-Modified-Since
會將上次從伺服器獲取的Last-Modified
的值發送給伺服器,詢問伺服器在該日期後資源是否有更新,有更新的話就會將新的資源發送回來。
但是如果(伺服器)在本地打開緩存文件(或者刪了個字元 a 後又填上去),就會造成Last-Modified
被修改,所以在 HTTP / 1.1 出現了ETag
。
(4) Etag / If-None-Match: ETag
類似於文件指紋,If-None-Match
會將當前ETag
發送給伺服器,詢問該資源ETag
是否變動,有變動的話就將新的資源發送回來。並且ETag
優先順序比Last-Modified
高。
由於 etag 要使用少數的字元表示一個不定大小的文件(如 etag: "58c4e2a1-f7"),所以 etag 是有重合的風險的,如果網站的信息特別重要,連很小的概率如百萬分之一都不允許,那麼就不要使用 etag 了。使用 etag 的代價是增加了伺服器的計算負擔,特別是當文件比較大時。
選擇合適的緩存策略
對於大部分的場景都可以使用強緩存配合協商緩存解決,但是在一些特殊的地方可能需要選擇特殊的緩存策略
- 對於某些不需要緩存的資源,可以使用
Cache-control: no-store
,表示該資源不需要緩存 - 對於頻繁變動的資源,可以使用
Cache-Control: no-cache
並配合ETag
使用,表示該資源已被緩存,但是每次都會發送請求詢問資源是否更新。 - 對於代碼文件來說,通常使用
Cache-Control: max-age=31536000
並配合策略緩存使用,然後對文件進行指紋處理,一旦文件名變動就會立刻下載新的文件。
7、使用 HTTP / 2.0
因為瀏覽器會有併發請求限制,在 HTTP / 1.1 時代,每個請求都需要建立和斷開,消耗了好幾個 RTT 時間,並且由於 TCP 慢啟動的原因,載入體積大的文件會需要更多的時間。
在 HTTP / 2.0 中引入了多路復用,能夠讓多個請求使用同一個 TCP 鏈接,極大的加快了網頁的載入速度。並且還支持 Header 壓縮,進一步的減少了請求的數據大小。
8、圖片和文件壓縮
這又涉及到很多知識點了,簡單來說,我們要儘可能地在保證我們的 App 能正常運行、圖片儘可能保證高質量的前提下去壓縮所有用到的文件的體積。比如圖片格式的選擇、去掉我們代碼中的註釋、空行、無關代碼等。
圖片相關優化
- 不用圖片。很多時候會使用到很多修飾類圖片,其實這類修飾圖片完全可以用 CSS 去代替。
- 對於移動端來說,屏幕寬度就那麼點,完全沒有必要去載入原圖浪費帶寬。一般圖片都用 CDN 載入,可以計算出適配屏幕的寬度,然後去請求相應裁剪好的圖片。
- 小圖使用 base64 格式
- 選擇正確的圖片格式:
- 對於能夠顯示 WebP 格式的瀏覽器儘量使用 WebP 格式。因為 WebP 格式具有更好的圖像數據壓縮演算法,能帶來更小的圖片體積,而且擁有肉眼識別無差異的圖像質量,缺點就是相容性並不好
- 小圖使用 PNG,其實對於大部分圖標這類圖片,完全可以使用 SVG 代替
- 照片使用 JPEG
構建工具的使用
- 對於 Webpack4,打包項目使用 production 模式,這樣會自動開啟代碼壓縮
- 使用 ES6 模塊來開啟 tree shaking,這個技術可以移除沒有使用的代碼
- 優化圖片,對於小圖可以使用 base64 的方式寫入文件中
- 按照路由拆分代碼,實現按需載入
- 給打包出來的文件名添加哈希,實現瀏覽器緩存文件(能及時更新)
- 啟用 gzip 壓縮(需要前後端支持)
- 各種 loader/plugin 的使用
壓縮 HTML 文件
可以把 HTML 的註釋去掉,把行前縮進刪掉,這樣處理的文件可以明顯減少 HTML 的體積;這樣做幾乎是沒有風險的,除了 pre 標簽不能夠去掉行首縮進之外,其他的都正常。
網頁渲染階段優化方案
1、<script>
標簽位置
渲染線程和 JS 引擎線程是互斥的,如果你想首屏渲染的越快,就越不應該在首屏就載入 JS 文件,因此建議將 script 標簽放在 body 標簽底部的原因。或者使用給 script 標簽添加 defer 或者 async 屬性 。
- defer 表示該文件會並行下載,但是會放到 HTML 解析完成後再順序執行;
- 對於沒有任何依賴的 JS 文件可以加上 async 屬性,表示載入和渲染後續文檔元素的過程將和 JS 文件的載入與執行並行無序進行
2、Webworker 的使用
執行 JS 代碼過長會卡住渲染,對於需要很多時間計算的代碼可以考慮使用 Webworker。Webworker 可以讓我們另開一個線程執行腳本(這並沒有改變 JS 單線程的本質,因為新開的線程受控於主線程且不得操作 DOM)而不影響渲染。
3、懶載入
懶載入就是將不關鍵的資源延後載入。
懶載入的原理就是只載入自定義區域(通常是可視區域,但也可以是即將進入可視區域)內需要載入的東西。對於圖片來說,先設置圖片標簽的src
屬性為一張占點陣圖,將真實的圖片資源放入一個自定義屬性中,當進入自定義區域時,就將自定義屬性替換為src
屬性,這樣圖片就會去下載資源,實現了圖片懶載入。
懶載入不僅可以用於圖片,也可以使用在別的資源上。比如進入可視區域才開始播放視頻等等。
4、預載入
- 圖片等靜態資源在使用之前的提前請求
- 資源使用到時能從緩存中載入,提升用戶體驗
- 頁面展示的依賴關係維護
使用場景比如抽獎動畫展示過程中預先載入其他內容,或者電子書閱讀章節的預載入可以使切換下一章節時更為流暢。
5、減少迴流與重繪
執行 JavaScript 的解析和 UI 渲染的兩個瀏覽器線程是互斥的,UI 渲染時 JS 代碼解析終止,反之亦然。
當 頁面佈局和幾何屬性 改變時,就會觸發 迴流。
當 需要更新的只是元素的某些外觀 時,就會觸發 重繪。
- 用 translate 替代 top 屬性:top 會觸發 reflow,但 translate 不會
- 不要一條一條地修改 DOM 的樣式,預先定義好 class,然後修改 DOM 的 className
- 把 DOM 離線後修改,比如:先把 DOM 給 display:none(有一次 reflow),然後你修改 100 次,然後再把它顯示出來
- 不要把 DOM 節點的屬性值放在一個迴圈里當成迴圈的變數
- offsetHeight、offsetWidth 每次都要刷新緩衝區,緩衝機制被破壞,先用變數存儲下來
- 不要使用 table 佈局,可能很小的一個小改動會造成整個 table 的重新佈局
- 動畫實現的速度的選擇:選擇合適的動畫速度
- 啟用 gpu 硬體加速(並行運算),gpu 加速意味著數據需要從 cpu 走匯流排到 gpu 傳輸,需要考慮傳輸損耗.
- transform:translateZ(0)
- transform:translate3D(0)
- 似乎現在瀏覽器能智能地分析 gpu 加速了?
6、編寫高效率的 CSS
使用 CSS 預處理器時註意不要有過多的嵌套,嵌套層次過深會影響瀏覽器查找選擇器的速度,且一定程度上會產生出很多冗餘的位元組。
7、減少 DOM 元素數量、減少 DOM 的操作
減少 DOM 元素數量,合理利用 :after、:before 等偽類,避免頁面過深的層級嵌套;
優化 JavaScript 性能,減少 DOM 操作次數(或集中操作),能有效規避頁面重繪/重排;
只能說儘可能去做優化,如數據分頁、首屏直出、按需載入等
8、函數節流
為觸發頻率較高的函數使用函數節流
其他
SPA SEO SSR
SPA:單頁面富應用
動態地重寫頁面的部分與用戶交互而不是載入新的頁面。
優點:① 前後端分離 ② 頁面之間切換快 ③ 後端只需提供 API
缺點:① 首屏速度慢,因為用戶首次載入 SPA 框架及應用程式的代碼然後才渲染頁面 ② 不利於 SEO
SEO(Search Engine Optimization):搜索引擎優化
常用技術:利用 <title> 標簽和 <meta> 標簽的 description
<html>
<head>
<title>標題內容</title>
<meta name="description" content="描述內容">
<meta name="keyword" content="關鍵字1,關鍵字2,—">
</head>
</html>
SPA 應用中,通常通過 AJAX 獲取數據,而這裡就難以保證我們的頁面能被搜索引擎正常收錄到。並且有一些搜索引擎不支持執行 JS 和通過 AJAX 獲取數據,那就更不用提 SEO 了。
對於有些網站而言,SEO 顯得至關重要,例如主要以內容輸出為主的 Quora、stackoverflow、知乎和豆瓣等等,那如何才能正常使用 SPA 而又不影響 SEO 呢 ?所以有了 SSR
SSR(Server-Side Rendering):服務端渲染
以下內容部分參考《深入淺出 React 與 Redux》- 程墨
為了量化網頁性能,我們定義兩個指標:
- TTFP(Time To First Paint):指的是從網頁 HTTP 請求發出,到用戶可以看到第一個有意義的內容渲染出來的時間差
- TTI(Time To Interactive):指的是從網頁 HTTP 請求發出,到用戶可以對網頁內容進行交互的時間
在一個 完全靠瀏覽器端渲染 的應用中,當用戶在瀏覽器中打開一個頁面的時候,最壞情況下沒有任何緩存,需要等待三個 HTTP 請求才能到達 TTFP 的時間點:
- 向伺服器獲取 HTML,這個 HTML 只是一個無內容的空架子,但是皮之不存毛將焉附,這個 HTML 就是皮,在其中運行的 JavaScript 就是毛,所以這個請求時不可省略的
- 獲取 JavaScript 文件,大部分情況下,如果這是瀏覽器第二次訪問這個網站,就可以直接讀取緩存,不會發出真正的 HTTP 請求
- 訪問 API 伺服器獲取數據,得到的數據將由 JavaScript 加工之後用來填充 DOM 樹,如果應用的是 React,那就是通過修改組件的狀態或者屬性來驅動渲染
而對於伺服器端渲染,因為獲取 HTTP 請求之後就會返回所有有內容的 HTML,所以在一個 HTTP 的周期之後就會提供給瀏覽器有意義的內容,所以首次渲染時間 TTFP 會優於完全依賴於瀏覽器端渲染的頁面。
除了更短的 TTFP,伺服器端渲染還有一個好處就是利於搜索引擎優化,雖然某些搜索引擎已經能夠索引瀏覽器端渲染的網頁,但是畢竟不是所有搜索引擎都能做到這一點,讓搜索引擎能夠索引到應用頁面的最直接方法就是提供完整 HTML
上面的性能對比只是理論上的分析,實際上,採用伺服器端渲染是否能獲得更好的 TTFP 有多方面因素。
1、伺服器端產生的 HTML 過大是否會影響性能?
因為伺服器端渲染返回的是完整的 HTML,那麼下載這個 HTML 的時間也會增長。
2、伺服器端渲染的運算消耗是否是伺服器能夠承擔得起的?
瀏覽器端渲染的方案下,伺服器只提供靜態資源,壓力被分攤到了訪問用戶的瀏覽器中;如果使用伺服器端渲染,每個頁面請求都要產生 HTML 頁面,這樣伺服器的壓力就會很大。
React 並不是給伺服器端渲染設計的,如果應用對 TTFP 要求不高,也不希望對 React 頁面進行搜索引擎優化,那麼沒有必要使用“同構”來增加開發難度;如果希望應用的性能能更進一步,而且伺服器運算資源充足,那麼可以嘗試。對 Vue 而言應該也是同樣的道理。
最後我們來總結下服務端渲染理論上的優缺點:
優點:
- 更快的響應時間、首屏載入時間,可以將 SEO 的關鍵信息直接在後臺渲染成 HTML,從而保證搜索引擎的爬蟲都能爬到關鍵數據
- 更快的內容到達時間,特別是對於緩慢的網路情況或運行緩慢的設備
- 無需等待所有的 JavaScript 都完成下載並執行,才顯示伺服器渲染的標記,所以用戶將會更快速地看到完整渲染的頁面,通常可以產生更好的用戶體驗
- 資源文件從本地請求(各種 bundle 什麼的),更快的下載速度
缺點:
- 占用伺服器更多的 CPU 和記憶體資源
- 一些常用的瀏覽器 API 可能無法使用,如 window、document、alert 等,如果需要使用的話需要對運行的環境加以判斷
- 開發難度加大
Vue 項目優化點
1、第三方庫走 cdn
例如:
<script src="//cdn.bootcss.com/vue/2.2.5/vue.min.js"></script>
<script src="//cdn.bootcss.com/vue-router/2.3.0/vue-router.min.js"></script>
<script src="//cdn.bootcss.com/vuex/2.2.1/vuex.min.js"></script>
<script src="//cdn.bootcss.com/axios/0.15.3/axios.min.js"></script>
在 webpack 里有個 externals 選項,可以忽略不需要打包的庫
https://webpack.js.org/configuration/externals/#root
const path = require('path')
module.exports = {
mode: 'production',
entry: './src/index.js',
externals: {
'vue': 'Vue',
'vue-router': 'VueRouter',
'vuex': 'Vuex',
'axios': 'axios'
},
output: {
...
}
}
2、路由懶載入
import Vue from 'vue'
import Router from 'vue-router'
Vue.use(Router)
export default new Router({
routes: [
{
path: '/ebook',
component: () => import('./views/ebook/index.vue'), // 路由懶載入,這裡用的是ES6的語法 import()函數是動態載入 import 是靜態載入
children: [ // 動態路由, 可以傳遞路徑參數
{
path: ':fileName',
component: () => import('./components/ebook/EbookReader.vue')
}
]
},
{
path: '/store',
component: () => import('./views/store/index.vue'),
redirect: '/store/shelf', // #/store -> #/store/home
...
}
]
})
3、使用懶載入插件 Vue-Loader
具體的使用可以參考 這篇文章 或者去看官方文檔
step1:cnpm install vue-lazyload --save
step2:main.js導入
import VueLazyLoad from 'vue-lazyload'
Vue.use(VueLazyload)
step3:<img class="item-pic" v-lazy="newItem.picUrl"/>
vue 文件中將需要懶載入的圖片綁定v-bind:src
修改為v-lazy
這隻是圖片懶載入,還有很多其他可選配置
4、v-if
與v-show
的選擇
一般來說,v-if
有更高的切換開銷,而v-show
有更高的初始渲染開銷。因此,如果需要非常頻繁地切換,則使用v-show
較好;如果在運行時條件很少改變,則使用v-if
較好。
React 項目優化點
1、單個組件的優化:更改 shouldComponentUpdate 函數的預設實現,根據每個 React 組件的內在邏輯定製其行為,減少不必要的重新渲染
shouldComponentUpdate(nextProps, nextState) {
// 假設影響渲染內容的 prop 只有 completed 和 text,只需要確保
// 這兩個 prop 沒有變化,函數就可以返回 false
return (nextProps.completed !== this.props.completed) ||
(nextProps.text !== this.props.text)
}
2、使用 immutable.js 解決複雜數據 diff、clone 等問題。
immutable.js 實現原理:持久化數據結構,也就是使用舊數據創建新數據時,要保證舊數據同時可用且不變。同時為了避免 deepCopy 把所有節點都複製一遍帶來的性能損耗,Immutable 使用了結構共用,即如果對象樹中一個節點發生變化,只修改這個節點和受它影響的父節點,其它節點則進行共用。
3、在 constructor() 里做 this 綁定
當在 render() 里使用事件處理方法時,提前在構造函數里把 this 綁定上去(如果需要的話),因為在每次 render 過程中, 再調用 bind 都會新建一個新的函數,浪費資源.
// bad
class App extends React.Component {
onClickDiv() {
// do stuff
}
render() {
return <div onClick={this.onClickDiv.bind(this)} />;
}
}
// good
class App extends React.Component {
constructor(props) {
super(props);
this.onClickDiv = this.onClickDiv.bind(this);
}
onClickDiv() {
// do stuff
}
render() {
return <div onClick={this.onClickDiv} />;
}
}
4、基於路由的代碼分割
使用React.lazy
和React Router
來配置基於路由的代碼分割
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
import React, { Suspense, lazy } from 'react';
const Home = lazy(() => import('./routes/Home'));
const About = lazy(() => import('./routes/About'));
const App = () => (
<Router>
<Suspense fallback={<div>Loading...</div>}>
<Switch>
<Route exact path="/" component={Home}/>
<Route path="/about" component={About}/>
</Switch>
</Suspense>
</Router>
);
參考資料
https://juejin.im/post/5c4a6fcd518825469414e062#heading-29
https://juejin.im/post/5cab64ce5188251b19486041#heading-6
https://juejin.im/post/5d548b83f265da03ab42471d
https://www.jianshu.com/p/333f390f2e84
https://yuchengkai.cn/docs/frontend/