從輸入 URL 到頁面加載完成,發生了什麼?
首先咱們須要經過 DNS(域名解析系統)將 URL 解析爲對應的 IP 地址,而後與這個 IP 地址肯定的那臺服務器創建起 TCP 網絡鏈接,隨後咱們向服務端拋出咱們的 HTTP 請求,服務端處理完咱們的請求以後,把目標數據放在 HTTP 響應裏返回給客戶端,拿到響應數據的瀏覽器就能夠開始走一個渲染的流程。渲染完畢,頁面便呈現給了用戶,並時刻等待響應用戶的操做
各個優化
DNS 解析花時間,能不能儘可能減小解析次數或者把解析前置?能——瀏覽器 DNS 緩存和 DNS prefetch
TCP 每次的三次握手都急死人,有沒有解決方案?有——長鏈接、預鏈接、接入 SPDY 協議
這兩個過程的優化每每須要咱們和團隊的服務端工程師協做完成,
HTTP 請求 減小請求次數和減少請求體積方面css
瀏覽器端的性能優化——這部分涉及資源加載優化、服務端渲染、瀏覽器緩存機制的利用、DOM 樹的構建、網頁排版和渲染過程、迴流與重繪的考量、DOM 操做的合理規避等等html
從輸入 URL 到顯示頁面這個過程當中,涉及到網絡層面的,有三個主要過程:前端
- DNS 解析
- TCP 鏈接
- HTTP 請求/響應
對於 DNS 解析和 TCP 鏈接兩個步驟,咱們前端能夠作的努力很是有限。相比之下,HTTP 鏈接這一層面的優化纔是咱們網絡優化的核心
HTTP 優化有兩個大的方向:vue
- 減小請求次數
- 減小單次請求所花費的時間
指向了咱們平常開發中很是常見的操做——資源的壓縮與合併
這就是咱們用構建工具在作的事情node
webpack 的優化瓶頸,主要是兩個方面:react
babel-loader 無疑是強大的,但它也是慢的。webpack
最多見的優化方式是,用 include 或 exclude 來幫咱們避免沒必要要的轉譯,好比 webpack 官方在介紹 babel-loader 時給出的示例ios
module: { rules: [ { test: /\.js$/, exclude: /(node_modules|bower_components)/, use: { loader: 'babel-loader', options: { presets: ['@babel/preset-env'] } } } ] }
這段代碼幫咱們規避了對龐大的 node_modules 文件夾或者 bower_components 文件夾的處理。但經過限定文件範圍帶來的性能提高是有限的。除此以外,若是咱們選擇開啓緩存將轉譯結果緩存至文件系統,則至少能夠將 babel-loader 的工做效率提高兩倍。要作到這點,咱們只須要爲 loader 增長相應的參數設定:git
loader: 'babel-loader?cacheDirectory=true'
這個規則僅做用於這個 loader,像一些相似 UglifyJsPlugin 的 webpack 插件在工做時依然會被這些龐大的第三方庫拖累,webpack 構建速度依然會所以大打折扣。程序員
處理第三方庫的姿式有不少,其中,Externals 不夠聰明,一些狀況下會引起重複打包的問題;而 CommonsChunkPlugin 每次構建時都會從新構建一次 vendor;出於對效率的考慮,咱們這裏爲你們推薦 DllPlugin。
DllPlugin 是基於 Windows 動態連接庫(dll)的思想被創做出來的。這個插件會把第三方庫單獨打包到一個文件中,這個文件就是一個單純的依賴庫。這個依賴庫不會跟着你的業務代碼一塊兒被從新打包,只有當依賴自身發生版本變化時纔會從新打包。
用 DllPlugin 處理文件,要分兩步走:
const path = require('path') const webpack = require('webpack') module.exports = { entry: { // 依賴的庫數組 vendor: [ 'prop-types', 'babel-polyfill', 'react', 'react-dom', 'react-router-dom', ] }, output: { path: path.join(__dirname, 'dist'), filename: '[name].js', library: '[name]_[hash]', }, plugins: [ new webpack.DllPlugin({ // DllPlugin的name屬性須要和libary保持一致 name: '[name]_[hash]', path: path.join(__dirname, 'dist', '[name]-manifest.json'), // context須要和webpack.config.js保持一致 context: __dirname, }), ], }
編寫完成以後,運行這個配置文件,咱們的 dist 文件夾裏會出現這樣兩個文件:
vendor-manifest.json vendor.js
vendor.js 沒必要解釋,是咱們第三方庫打包的結果。這個多出來的 vendor-manifest.json,則用於描述每一個第三方庫對應的具體路徑,我這裏截取一部分給你們看下:
{ "name": "vendor_397f9e25e49947b8675d", "content": { "./node_modules/core-js/modules/_export.js": { "id": 0, "buildMeta": { "providedExports": true } }, "./node_modules/prop-types/index.js": { "id": 1, "buildMeta": { "providedExports": true } }, ... } }
隨後,咱們只需在 webpack.config.js 裏針對 dll 稍做配置:
const path = require('path'); const webpack = require('webpack') module.exports = { mode: 'production', // 編譯入口 entry: { main: './src/index.js' }, // 目標文件 output: { path: path.join(__dirname, 'dist/'), filename: '[name].js' }, // dll相關配置 plugins: [ new webpack.DllReferencePlugin({ context: __dirname, // manifest就是咱們第一步中打包出來的json文件 manifest: require('./dist/vendor-manifest.json'), }) ] }
以上也可用有些繁瑣也可用AutoDllPlugin替代
npm install --save-dev autodll-webpack-plugin
使用
const AutoDllPlugin = require('autodll-webpack-plugin'); plugins: [ new AutoDllPlugin({ inject: true, // will inject the DLL bundles to html context: path.join(__dirname, '..'), filename: '[name]_[hash].dll.js', path: 'res/js', plugins: mode === 'online' ? [ new UglifyJsPlugin({ uglifyOptions: { compress: { warnings: false } }, sourceMap: config.build.productionSourceMap, parallel: true }) ] : [], entry: { vendor: ['vue/dist/vue.esm.js', 'vuex', 'axios', 'vue-router', 'babel-polyfill', 'lodash'] } }) ]
一次基於 dll 的 webpack 構建過程優化,便大功告成了!
你們知道,webpack 是單線程的,就算此刻存在多個任務,你也只能排隊一個接一個地等待處理。這是 webpack 的缺點,好在咱們的 CPU 是多核的,Happypack 會充分釋放 CPU 在多核併發方面的優點,幫咱們把任務分解給多個子進程去併發執行,大大提高打包效率。
HappyPack 的使用方法也很是簡單,只須要咱們把對 loader 的配置轉移到 HappyPack 中去就好,咱們能夠手動告訴 HappyPack 咱們須要多少個併發的進程
const HappyPack = require('happypack') // 手動建立進程池 const happyThreadPool = HappyPack.ThreadPool({ size: os.cpus().length }) module.exports = { module: { rules: [ ... { test: /\.js$/, // 問號後面的查詢參數指定了處理這類文件的HappyPack實例的名字 loader: 'happypack/loader?id=happyBabel', ... }, ], }, plugins: [ ... new HappyPack({ // 這個HappyPack的「名字」就叫作happyBabel,和樓上的查詢參數遙相呼應 id: 'happyBabel', // 指定進程池 threadPool: happyThreadPool, loaders: ['babel-loader?cacheDirectory'] }) ], }
這裏爲你們介紹一個很是好用的包組成可視化工具——webpack-bundle-analyzer,配置方法和普通的 plugin 無異,它會以矩形樹圖的形式將包內各個模塊的大小和依賴關係呈現出來,格局如官方所提供這張圖所示:
!------)
在使用時,咱們只須要將其以插件的形式引入
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; module.exports = { plugins: [ new BundleAnalyzerPlugin() ] }
這點仍然圍繞 DllPlugin 展開
一個比較典型的應用,就是 Tree-Shaking
基於 import/export 語法,Tree-Shaking 能夠在編譯的過程當中獲悉哪些模塊並無真正被使用,這些沒用的代碼,在最後打包的時候會被去除。
適合用來處理模塊級別的冗餘代碼。至於粒度更細的冗餘代碼的去除,每每會被整合進 JS 或 CSS 的壓縮或分離過程當中。
這裏咱們以當下接受度較高的 UglifyJsPlugin 爲例,看一下如何在壓縮過程當中對碎片化的冗餘代碼(如 console 語句、註釋等)進行自動化刪除:
const UglifyJsPlugin = require('uglifyjs-webpack-plugin'); module.exports = { plugins: [ new UglifyJsPlugin({ // 容許併發 parallel: true, // 開啓緩存 cache: true, compress: { // 刪除全部的console語句 drop_console: true, // 把使用屢次的靜態值自動定義爲變量 reduce_vars: true, }, output: { // 不保留註釋 comment: false, // 使輸出的代碼儘量緊湊 beautify: false } }) ] }
webpack4 中,咱們是經過配置 optimization.minimize 與 optimization.minimizer 來自定義壓縮相關的操做的。
當咱們不須要按需加載的時候,咱們的代碼是這樣的:
import BugComponent from '../pages/BugComponent' ... <Route path="/bug" component={BugComponent}>
爲了開啓按需加載,咱們要稍做改動。
首先 webpack 的配置文件要走起來:
output: { path: path.join(__dirname, '/../dist'), filename: 'app.js', publicPath: defaultSettings.publicPath, // 指定 chunkFilename chunkFilename: '[name].[chunkhash:5].chunk.js', },
路由處的代碼也要作一下配合;
const getComponent => (location, cb) { require.ensure([], (require) => { cb(null, require('../pages/BugComponent').default) }, 'bug') }, <Route path="/bug" getComponent={getComponent}>
核心就是這個方法:
require.ensure(dependencies, callback, chunkName)
這是一個異步的方法,webpack 在打包時,BugComponent 會被單獨打成一個文件,只有在咱們跳轉 bug 這個路由的時候,這個異步方法的回調纔會生效,纔會真正地去獲取 BugComponent 的內容。這就是按需加載。
按需加載的粒度,還能夠繼續細化,細化到更小的組件、細化到某個功能點,都是 ok 的。
開啓 Gzip。
具體的作法很是簡單,只須要你在你的 request headers 中加上這麼一句:
accept-encoding:gzip
咱們前端關係更密切的話題:HTTP 壓縮。
HTTP 壓縮是一種內置到網頁服務器和網頁客戶端中以改進傳輸速度和帶寬利用率的方式。在使用 HTTP 壓縮的狀況下,HTTP 數據在從服務器發送前就已壓縮:兼容的瀏覽器將在下載所需的格式前宣告支持何種方法給服務器;不支持壓縮方法的瀏覽器將下載未經壓縮的數據。最多見的壓縮方案包括 Gzip 和 Deflate。
HTTP 壓縮就是以縮小體積爲目的,對 HTTP 內容進行從新編碼的過程
Gzip 的內核就是 Deflate,目前咱們壓縮文件用得最多的就是 Gzip。能夠說,Gzip 就是 HTTP 壓縮的經典例題。
壓縮 Gzip,服務端要花時間;解壓 Gzip,瀏覽器要花時間。中間節省出來的傳輸時間,真的那麼可觀嗎?
咱們處理的都是具有必定規模的項目文件。實踐證實,這種狀況下壓縮和解壓帶來的時間開銷相對於傳輸過程當中節省下的時間開銷來講,能夠說是微不足道的。
首先要認可 Gzip 是高效的,壓縮後一般能幫咱們減小響應 70% 左右的大小。
但它並不是萬能。Gzip 並不保證針對每個文件的壓縮都會使其變小。
Gzip 壓縮背後的原理,是在一個文本文件中找出一些重複出現的字符串、臨時替換它們,從而使整個文件變小。根據這個原理,文件中代碼的重複率越高,那麼壓縮的效率就越高,使用 Gzip 的收益也就越大。反之亦然。
通常來講,Gzip 壓縮是服務器的活兒:服務器瞭解到咱們這邊有一個 Gzip 壓縮的需求,它會啓動本身的 CPU 去爲咱們完成這個任務。而壓縮文件這個過程自己是須要耗費時間的,你們能夠理解爲咱們以服務器壓縮的時間開銷和 CPU 開銷(以及瀏覽器解析壓縮文件的開銷)爲代價,省下了一些傳輸過程當中的時間開銷。
既然存在着這樣的交換,那麼就要求咱們學會權衡。服務器的 CPU 性能不是無限的,若是存在大量的壓縮需求,服務器也扛不住的。服務器一旦所以慢下來了,用戶仍是要等。Webpack 中 Gzip 壓縮操做的存在,事實上就是爲了在構建過程當中去作一部分服務器的工做,爲服務器分壓。
所以,這兩個地方的 Gzip 壓縮,誰也不能替代誰。它們必須和平共處,好好合做。做爲開發者,咱們也應該結合業務壓力的實際強度狀況,去作好這其中的權衡。
圖片是電商平臺的重要資源,甚至有人說「作電商就是作圖片」。
就圖片這塊來講,與其說咱們是在作「優化」,不如說咱們是在作「權衡」。由於咱們要作的事情,就是去壓縮圖片的體積(或者一開始就選取體積較小的圖片格式)。但這個優化操做,是以犧牲一部分紅像質量爲代價的。所以咱們的主要任務,是儘量地去尋求一個質量與性能之間的平衡點。
時下應用較爲普遍的 Web 圖片格式有 JPEG/JPG、PNG、WebP、Base6四、SVG 等
不談業務場景的選型都是耍流氓
在計算機中,像素用二進制數來表示。不一樣的圖片格式中像素與二進制位數之間的對應關係是不一樣的。一個像素對應的二進制位數越多,它能夠表示的顏色種類就越多,成像效果也就越細膩,文件體積相應也會越大。
一個二進制位表示兩種顏色(0|1 對應黑|白),若是一種圖片格式對應的二進制位數有 n 個,那麼它就能夠呈現 2^n 種顏色。
關鍵字:有損壓縮、體積小、加載快、不支持透明
JPG 最大的特色是有損壓縮
。這種高效的壓縮算法使它成爲了一種很是輕巧的圖片格式。另外一方面,即便被稱爲「有損」壓縮,JPG的壓縮方式仍然是一種高質量的壓縮方式:當咱們把圖片體積壓縮至原有體積的 50% 如下時,JPG 仍然能夠保持住 60% 的品質。此外,JPG 格式以 24 位存儲單個圖,能夠呈現多達 1600 萬種顏色,足以應對大多數場景下對色彩的要求,這一點決定了它壓縮先後的質量損耗並不容易被咱們人類的肉眼所察覺
JPG 適用於呈現色彩豐富的圖片,在咱們平常開發中,JPG 圖片常常做爲大的背景圖、輪播圖或 Banner 圖出現。
有損壓縮在上文所展現的輪播圖上確實很難露出馬腳,但當它處理矢量圖形和 Logo 等線條感較強、顏色對比強烈的圖像時,人爲壓縮致使的圖片模糊會至關明顯。
此外,JPEG 圖像不支持透明度處理,透明圖片須要召喚 PNG 來呈現。
關鍵字:無損壓縮、質量高、體積大、支持透明
PNG(可移植網絡圖形格式)是一種無損壓縮的高保真的圖片格式。8 和 24,這裏都是二進制數的位數。按照咱們前置知識裏提到的對應關係,8 位的 PNG 最多支持 256 種顏色,而 24 位的能夠呈現約 1600 萬種顏色。
PNG 圖片具備比 JPG 更強的色彩表現力,對線條的處理更加細膩,對透明度有良好的支持。它彌補了上文咱們提到的 JPG 的侷限性,惟一的 BUG 就是體積太大。
前面咱們提到,複雜的、色彩層次豐富的圖片,用 PNG 來處理的話,成本會比較高,咱們通常會交給 JPG 去存儲。
考慮到 PNG 在處理線條和顏色對比度方面的優點,咱們主要用它來呈現小的 Logo、顏色簡單且對比強烈的圖片或背景等。
文本文件、體積小、不失真、兼容性好
SVG(可縮放矢量圖形)是一種基於 XML 語法的圖像格式。它和本文說起的其它圖片種類有着本質的不一樣:SVG 對圖像的處理不是基於像素點,而是是基於對圖像的形狀描述。
和性能關係最密切的一點就是:SVG 與 PNG 和 JPG 相比,文件體積更小,可壓縮性更強。
固然,做爲矢量圖,它最顯著的優點仍是在於圖片可無限放大而不失真這一點上。這使得 SVG 即便是被放到視網膜屏幕上,也能夠一如既往地展示出較好的成像品質——1 張 SVG 足以適配 n 種分辨率。
此外,SVG 是文本文件。咱們既能夠像寫代碼同樣定義 SVG,把它寫在 HTML 裏、成爲 DOM 的一部分,也能夠把對圖形的描述寫入以 .svg 爲後綴的獨立文件(SVG 文件在使用上與普通圖片文件無異)。這使得 SVG 文件能夠被很是多的工具讀取和修改,具備較強的靈活性。
SVG 的侷限性主要有兩個方面,一方面是它的渲染成本比較高,這點對性能來講是很不利的。另外一方面,SVG 存在着其它圖片格式所沒有的學習成本(它是可編程的)
SVG 是文本文件,咱們既能夠像寫代碼同樣定義 SVG,把它寫在 HTML 裏、成爲 DOM 的一部分,也能夠把對圖形的描述寫入以 .svg 爲後綴的獨立文件(SVG 文件在使用上與普通圖片文件無異)。
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title></title> </head> <body> <svg xmlns="http://www.w3.org/2000/svg" width="200" height="200"> <circle cx="50" cy="50" r="50" /> </svg> </body> </html>
將 SVG 寫入獨立文件後引入 HTML:
<img src="文件名.svg" alt="">
在實際開發中,咱們更多用到的是後者。不少狀況下設計師會給到咱們 SVG 文件,就算沒有設計師,咱們還有很是好用的 在線矢量圖形庫。對於矢量圖,咱們無須深究過多,只須要對其核心特性有所掌握、往後在應用時作到有跡可循便可。
一種將小圖標和背景圖像合併到一張圖片上,而後利用 CSS 的背景定位來顯示其中的每一部分的技術。
被運用於衆多使用大量小圖標的網頁應用之上。它可取圖像的一部分來使用,使得使用一個圖像文件替代多個小文件成爲可能。相較於一個小圖標一個圖像文件,單獨一張圖片所需的 HTTP 請求更少,對內存和帶寬更加友好。
不難看出,每次加載圖片,都是須要單獨向服務器請求這個圖片對應的資源的——這也就意味着一次 HTTP 請求的開銷。
Base64 是一種用於傳輸 8Bit 字節碼的編碼方式,經過對圖片進行 Base64 編碼,咱們能夠直接將編碼結果寫入 HTML 或者寫入 CSS,從而減小 HTTP 請求的次數
按照一向的思路,咱們加載圖片須要把圖片連接寫入 img 標籤:
<img src="https://user-gold-cdn.xitu.io/2018/9/15/165db7e94699824b?w=22&h=22&f=png&s=3680">
瀏覽器就會針對咱們的圖片連接去發起一個資源請求.
可是若是咱們對這個圖片進行 Base64 編碼,咱們會獲得一個這樣的字符串:
data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABYAAAAWCAYAAADEtGw7AAAMJGlDQ1BJQ0MgUHJvZmlsZQAASImVlwdUU8kagOeWJCQktEAEpITeBCnSpdfQpQo2QhJIKDEkBBU7uqjgWlARwYquitjWAshiw14Wwd4fiKgo62LBhsqbFNDV89477z9n7v3yzz9/mcydMwOAehxbJMpFNQDIExaI48MCmeNT05ikR4AECIAKRgEamyMRBcTFRQEoQ+9/yrubAJG9r9nLfP3c/19Fk8uTcABA4iBncCWcPMiHAMDdOCJxAQCEXqg3m1YggkyEWQJtMUwQsrmMsxTsIeMMBUfJbRLjgyCnA6BCZbPFWQCoyfJiFnKyoB+1pZAdhVyB
字符串比較長,咱們能夠直接用這個字符串替換掉上文中的連接地址。你會發現瀏覽器原來是能夠理解這個字符串的,它自動就將這個字符串解碼爲了一個圖片,而不需再去發送 HTTP 請求
上面這個實例,其實源自咱們 掘金 網站 Header 部分的搜索欄 Logo:
Base64 編碼後,圖片大小會膨脹爲原文件的 4/3(這是由 Base64 的編碼原理決定的)。若是咱們把大圖也編碼到 HTML 或 CSS 文件中,後者的體積會明顯增長,即使咱們減小了 HTTP 請求,也沒法彌補這龐大的體積帶來的性能開銷,得不償失。
在傳輸很是小的圖片的時候,Base64 帶來的文件體積膨脹、以及瀏覽器解析 Base64 的時間開銷,與它節省掉的 HTTP 請求開銷相比,能夠忽略不計,這時候才能真正體現出它在性能方面的優點。
所以,Base64 並不是萬全之策,咱們每每在一張圖片知足如下條件時會對它應用 Base64 編碼:
圖片的更新頻率很是低(不需咱們重複編碼和修改文件內容,維護成本較低)
這裏最推薦的是利用 webpack 來進行 Base64 的編碼——webpack 的 url-loader 很是聰明,它除了具有基本的 Base64 轉碼能力,還能夠結合文件大小,幫咱們判斷圖片是否有必要進行 Base64 編碼。
除此以外,市面上免費的 Base64 編解碼工具種類是很是多樣化的,有不少網站都提供在線編解碼的服務,你們選取本身認爲順手的工具就好。
WebP 像 JPEG 同樣對細節豐富的圖片信手拈來,像 PNG 同樣支持透明,像 GIF 同樣能夠顯示動態圖片——它集多種圖片文件格式的優勢於一身。
與 PNG 相比,WebP 無損圖像的尺寸縮小了 26%。在等效的 SSIM 質量指數下,WebP 有損圖像比同類 JPEG 圖像小 25-34%。
無損 WebP 支持透明度(也稱爲 alpha 通道),僅需 22% 的額外字節。對於有損 RGB 壓縮可接受的狀況,有損 WebP 也支持透明度,與 PNG 相比,一般提供 3 倍的文件大小。
WebP 縱有千般好 都逃不開兼容性的大坑
此外,WebP 還會增長服務器的負擔——和編碼 JPG 文件相比,編碼一樣質量的 WebP 文件會佔用更多的計算資源。
如今限制咱們使用 WebP 的最大問題不是「這個圖片是否適合用 WebP 呈現」的問題,而是「瀏覽器是否容許 WebP」的問題,即咱們上文談到的兼容性問題。具體來講,一旦咱們選擇了 WebP,就要考慮在 Safari 等瀏覽器下它沒法顯示的問題,也就是說咱們須要準備 PlanB,準備降級方案。
目前真正把 WebP 格式落地到網頁中的網站並非不少,這其中淘寶首頁對 WebP 兼容性問題的處理方式就很是有趣。咱們能夠打開 Chrome 的開發者工具搜索其源碼裏的 WebP 關鍵字
<img src="//img.alicdn.com/tps/i4/TB1CKSgIpXXXXccXXXX07tlTXXX-200-200.png_60x60.jpg_.webp" alt="手機app - 聚划算" class="app-icon">
.webp 前面,還跟了一個 .jpg 後綴!
這個圖片應該至少存在 jpg 和 webp 兩種格式,程序會根據瀏覽器的型號、以及該型號是否支持 WebP 這些信息來決定當前瀏覽器顯示的是 .webp 後綴仍是 .jpg 後綴。帶着這個預判,咱們打開並不支持 WebP 格式的 Safari 來進入一樣的頁面,再次搜索 WebP 關鍵字:
Safari 提示咱們找不到,這也是情理之中。咱們定位到剛剛示例的 WebP 圖片所在的元素,查看一下它在 Safari 裏的圖片連接
<img src="//img.alicdn.com/tps/i4/TB1CKSgIpXXXXccXXXX07tlTXXX-200-200.png_60x60.jpg" alt="手機app - 聚划算" class="app-icon">
在 Safari 中的後綴從 .webp 變成了 .jpg!
站點確實是先進行了兼容性的預判,在瀏覽器環境支持 WebP 的狀況下,優先使用 WebP 格式,不然就把圖片降級爲 JPG 格式(本質是對圖片的連接地址做簡單的字符串切割)。
此外,還有另外一個維護性更強、更加靈活的方案——把判斷工做交給後端,由服務器根據 HTTP 請求頭部的 Accept 字段來決定返回什麼格式的圖片。當 Accept 字段包含 image/webp 時,就返回 WebP 格式的圖片,不然返回原圖。這種作法的好處是,當瀏覽器對 WebP 格式圖片的兼容支持發生改變時,咱們也不用再去更新本身的兼容斷定代碼,只須要服務端像往常同樣對 Accept 字段進行檢查便可。
由此也能夠看出,咱們 WebP 格式的侷限性確實比較明顯,若是決定使用 WebP,兼容性處理是必不可少的
緩存能夠提升網絡IO消耗 提升訪問速度
經過網絡獲取內容既速度緩慢又開銷巨大。較大的響應須要在客戶端與服務器之間進行屢次往返通訊,這會延遲瀏覽器得到和處理內容的時間,還會增長訪問者的流量費用。所以,緩存並重複利用以前獲取的資源的能力成爲性能優化的一個關鍵方面。
瀏覽器緩存機制有四個方面,它們按照獲取資源時請求的優先級依次排列以下
分爲強緩存和協商緩存。優先級較高的是強緩存,在命中強緩存失敗的狀況下,纔會走協商緩存。
強緩存是利用 http 頭中的 Expires 和 Cache-Control 兩個字段來控制的。強緩存中,當請求再次發出時,瀏覽器會根據其中的 expires 和 cache-control 判斷目標資源是否「命中」強緩存,若命中則直接從緩存中獲取資源,不會再與服務端發生通訊。
命中強緩存的狀況下,返回的 HTTP 狀態碼爲 200
實現強緩存,過去咱們一直用 expires。
當服務器返回響應時,在 Response Headers 中將過時時間寫入 expires 字段。像這樣:
expires: Wed, 11 Sep 2019 16:12:18 GMT
expires 是一個時間戳,接下來若是咱們試圖再次向服務器請求資源,瀏覽器就會先對比本地時間和 expires 的時間戳,若是本地時間小於 expires 設定的過時時間,那麼就直接去緩存中取這個資源。
expires 是有問題的,它最大的問題在於對「本地時間」的依賴。若是服務端和客戶端的時間設置可能不一樣,或者我直接手動去把客戶端的時間改掉,那麼 expires 將沒法達到咱們的預期。
考慮到 expires 的侷限性,HTTP1.1 新增了 Cache-Control 字段來完成 expires 的任務。
expires 能作的事情,Cache-Control 都能作;expires 完成不了的事情,Cache-Control 也能作。所以,Cache-Control 能夠視做是 expires 的徹底替代方案。在當下的前端實踐裏,咱們繼續使用 expires 的惟一目的就是向下兼容。
如今咱們給 Cache-Control 字段一個特寫:
cache-control: max-age=31536000
經過 max-age 來控制資源的有效期。max-age 不是一個時間戳,而是一個時間長度。在本例中,max-age 是 31536000 秒,它意味着該資源在 31536000 秒之內都是有效的,完美地規避了時間戳帶來的潛在問題。
Cache-Control 相對於 expires 更加準確,它的優先級也更高。當 Cache-Control 與 expires 同時出現時,咱們以 Cache-Control 爲準。
Cache-Control 的神通,可不止於這一個小小的 max-age。以下的用法也很是常見
cache-control: max-age=3600, s-maxage=31536000
s-maxage 優先級高於 max-age,二者同時出現時,優先考慮 s-maxage。若是 s-maxage 未過時,則向代理服務器請求其緩存內容。
在項目不是特別大的場景下,max-age 足夠用了。但在依賴各類代理的大型架構中,咱們不得不考慮代理服務器的緩存問題。s-maxage 就是用於表示 cache 服務器上(好比 cache CDN)的緩存的有效時間的,並只對 public 緩存有效。
那麼什麼是 public 緩存呢
public 與 private 是針對資源是否可以被代理服務緩存而存在的一組對立概念。
若是咱們爲資源設置了 public,那麼它既能夠被瀏覽器緩存,也能夠被代理服務器緩存;若是咱們設置了 private,則該資源只能被瀏覽器緩存。private 爲默認值。但多數狀況下,public 並不須要咱們手動設置,好比有不少線上網站的 cache-control 是這樣的:
設置了 s-maxage,沒設置 public,那麼 CDN 還能夠緩存這個資源嗎?答案是確定的。由於明確的緩存信息(例如「max-age」)已表示響應是能夠緩存的。
no-cache 繞開了瀏覽器:咱們爲資源設置了 no-cache 後,每一次發起請求都不會再去詢問瀏覽器的緩存狀況,而是直接向服務端去確認該資源是否過時
no-store 比較絕情,顧名思義就是不使用任何緩存策略。在 no-cache 的基礎上,它連服務端的緩存確認也繞開了,只容許你直接向服務端發送請求、並下載完整的響應。
協商緩存依賴於服務端與瀏覽器之間的通訊。
協商緩存機制下,瀏覽器須要向服務器去詢問緩存的相關信息,進而判斷是從新發起請求、下載完整的響應,仍是從本地獲取緩存的資源。
若是服務端提示緩存資源未改動(Not Modified),資源會被重定向到瀏覽器緩存,這種狀況下網絡請求對應的狀態碼是 304(以下圖)
Last-Modified 是一個時間戳,若是咱們啓用了協商緩存,它會在首次請求時隨着 Response Headers 返回:
Last-Modified: Fri, 27 Oct 2017 06:35:57 GMT
隨後咱們每次請求時,會帶上一個叫 If-Modified-Since 的時間戳字段,它的值正是上一次 response 返回給它的 last-modified 值:
If-Modified-Since: Fri, 27 Oct 2017 06:35:57 GMT
服務器接收到這個時間戳後,會比對該時間戳和資源在服務器上的最後修改時間是否一致,從而判斷資源是否發生了變化。若是發生了變化,就會返回一個完整的響應內容,並在 Response Headers 中添加新的 Last-Modified 值;不然,返回如上圖的 304 響應,Response Headers 不會再添加 Last-Modified 字段。
使用 Last-Modified 存在一些弊端,這其中最多見的就是這樣兩個場景:
1 咱們編輯了文件,但文件的內容沒有改變。服務端並不清楚咱們是否真正改變了文件,它仍然經過最後編輯時間進行判斷。所以這個資源在再次被請求時,會被當作新資源,進而引起一次完整的響應——不應從新請求的時候,也會從新請求。
2 當咱們修改文件的速度過快時(好比花了 100ms 完成了改動),因爲 If-Modified-Since 只能檢查到以秒爲最小計量單位的時間差,因此它是感知不到這個改動的——該從新請求的時候,反而沒有從新請求了。
這兩個場景其實指向了同一個 bug——服務器並無正確感知文件的變化。爲了解決這樣的問題,Etag 做爲 Last-Modified 的補充出現了。
Etag 是由服務器爲每一個資源生成的惟一的標識字符串,這個標識字符串是基於文件內容編碼的,只要文件內容不一樣,它們對應的 Etag 就是不一樣的,反之亦然。所以 Etag 可以精準地感知文件的變化
Etag 和 Last-Modified 相似,當首次請求時,咱們會在響應頭裏獲取到一個最初的標識符字符串,舉個🌰,它能夠是這樣的:
ETag: W/"2a3b-1602480f459"
那麼下一次請求時,請求頭裏就會帶上一個值相同的、名爲 if-None-Match 的字符串供服務端比對了:
If-None-Match: W/"2a3b-1602480f459"
Etag 的生成過程須要服務器額外付出開銷,會影響服務端的性能,這是它的弊端。所以啓用 Etag 須要咱們審時度勢。正如咱們剛剛所提到的——Etag 並不能替代 Last-Modified,它只能做爲 Last-Modified 的補充和強化存在。
Etag 在感知文件變化上比 Last-Modified 更加準確,優先級也更高。當 Etag 和 Last-Modified 同時存在時,以 Etag 爲準。
解讀一下這張流程圖
當咱們的資源內容不可複用時,直接爲 Cache-Control 設置 no-store,拒絕一切形式的緩存;不然考慮是否每次都須要向服務器進行緩存有效確認,若是須要,那麼設 Cache-Control 的值爲 no-cache;不然考慮該資源是否能夠被代理服務器緩存,根據其結果決定是設置爲 private 仍是 public;而後考慮該資源的過時時間,設置對應的 max-age 和 s-maxage 值;最後,配置協商緩存須要用到的 Etag、Last-Modified 等參數。
MemoryCache,是指存在內存中的緩存。從優先級上來講,它是瀏覽器最早嘗試去命中的一種緩存。從效率上來講,它是響應速度最快的一種緩存。
內存緩存是快的,也是「短命」的。它和渲染進程「生死相依」,當進程結束後,也就是 tab 關閉之後,內存裏的數據也將不復存在。
資源存不存內存,瀏覽器秉承的是「節約原則」。咱們發現,Base64 格式的圖片,幾乎永遠能夠被塞進 memory cache,這能夠視做瀏覽器爲節省渲染開銷的「自保行爲」;此外,體積不大的 JS、CSS 文件,也有較大地被寫入內存的概率——相比之下,較大的 JS、CSS 文件就沒有這個待遇了,內存資源是有限的,它們每每被直接甩進磁盤
Service Worker 是一種獨立於主線程以外的 Javascript 線程。它脫離於瀏覽器窗體,所以沒法直接訪問 DOM。
這樣獨立的個性使得 Service Worker 的「我的行爲」沒法干擾頁面的性能,這個「幕後工做者」能夠幫咱們實現離線緩存、消息推送和網絡代理等功能。
咱們藉助 Service worker 實現的離線緩存就稱爲 Service Worker Cache
Service Worker 的生命週期包括 install、active、working 三個階段。一旦 Service Worker 被 install,它將始終存在,只會在 active 與 working 之間切換,除非咱們主動終止它。這是它能夠用來實現離線存儲的重要先決條件。
Service Worker 如何爲咱們實現離線緩存(注意看註釋):入口文件中插入這樣一段 JS 代碼,用以判斷和引入 Service Worker:
window.navigator.serviceWorker.register('/test.js').then(()=>{ console.log('註冊成功') }).catch((error)=>{ console.log('註冊失敗') })
在 test.js 中,咱們進行緩存的處理。假設咱們須要緩存的文件分別是 test.html,test.css 和 test.js:
self.addEventListener('install',event=>{ event.waitUntill( // 考慮到緩存也須要更新, open內傳入的參數爲緩存的版本號 caches.open('test-v1').then(cache=>{ return cache.addAll([ //此處傳入指定的需緩存的文件名 '/test.html', '/test.css', 'test.js' ]) }) ) }) //Service Worker會監聽全部的網絡請求,網絡請求的產生觸發的是fetch事件,咱們能夠在其對應的監聽函數中實現對請求的攔截 //進而判斷是否對應到該請求的緩存 實現從Service Worker中取緩存的目的 self.addEventListener('fetch',event=>{ event.respondWith( //嘗試匹配該請求對應的緩存值 caches.match(event.request).then(res=>{ //若是匹配到了,調用Server Worker緩存 if(res){ return res } //若是沒有匹配到 向服務器發起這個資源請求 return fetch(event.request).then(response=>{ if(!response||response.status!==200){ return response } //請求成功的話,將請求緩存起來 caches.open('test-v1').then((cache)=>{ cache.put(event.request,response) }) return response.clone() }) }) ) })
Server Worker 對協議是有要求的,必須以 https 協議爲前提。
https://jakearchibald.com/201...
Push Cache 是指 HTTP2 在 server push 階段存在的緩存。這塊的知識比較新,應用也還處於萌芽階段,
但應用範圍有限不表明不重要——HTTP2 是趨勢、是將來。
*Push Cache 是緩存的最後一道防線。瀏覽器只有在 Memory Cache、HTTP Cache 和 Service Worker Cache 均未命中的狀況下才會去詢問 Push Cache。*Push Cache 是一種存在於會話階段的緩存,當 session 終止時,緩存也隨之釋放。
*不一樣的頁面只要共享了同一個 HTTP2 鏈接,那麼它們就能夠共享同一個 Push Cache。
HTTP 協議是一個無狀態協議,服務器接收客戶端的請求,返回一個響應 服務器並無記錄下關於客戶端的任何信息。
Cookie 說白了就是一個存儲在瀏覽器裏的一個小小的文本文件,它附着在 HTTP 請求上,在瀏覽器和服務器之間「飛來飛去」。它能夠攜帶用戶信息,當服務器檢查 Cookie 的時候,即可以獲取到客戶端的狀態。
Cookie 不夠大
Cookie 是有體積上限的,它最大隻能有 4KB。當 Cookie 超過 4KB 時,它將面臨被裁切的命運。這樣看來,Cookie 只能用來存取少許的信息。
Cookie 是緊跟域名的。咱們經過響應頭裏的 Set-Cookie 指定要存儲的 Cookie 值。默認狀況下,domain 被設置爲設置 Cookie 頁面的主機名,咱們也能夠手動設置 domain 的值:
Set-Cookie: name=xiuyan; domain=xiuyan.me
同一個域名下的全部請求,都會攜帶 Cookie
請求一張圖片或者一個 CSS 文件,咱們也要攜帶一個 Cookie 跑來跑去(關鍵是 Cookie 裏存儲的信息我如今並不須要),這是一件多麼勞民傷財的事情。Cookie 雖然小,請求卻能夠有不少,隨着請求的疊加,這樣的沒必要要的 Cookie 帶來的開銷將是沒法想象的。
Web Storage 是 HTML5 專門爲瀏覽器存儲而提供的數據存儲機制。它又分爲 Local Storage 與 Session Storage。
二者的區別在於生命週期與做用域的不一樣。
Web Storage 保存的數據內容和 Cookie 同樣,是文本內容,以鍵值對的形式存在。Local Storage 與 Session Storage 在 API 方面無異,這裏咱們以 localStorage 爲例:
localStorage.setItem('user_name', 'xiuyan')
localStorage.getItem('user_name')
localStorage.removeItem('user_name')
localStorage.clear()
傾向於用它來存儲一些內容穩定的資源。好比圖片內容豐富的電商網站會用它來存儲 Base64 格式的圖片字符串:
有的網站還會用它存儲一些不常常更新的 CSS、JS 等靜態資源。
Session Storage 更適合用來存儲生命週期和它同步的會話級別的信息。這些信息只適用於當前會話,當你開啓新的會話時,它也須要相應的更新或釋放。好比微博的 Session Storage 就主要是存儲你本次會話的瀏覽足跡:
lasturl 對應的就是你上一次訪問的 URL 地址,這個地址是即時的。當你切換 URL 時,它隨之更新,當你關閉頁面時,留着它也確實沒有什麼意義了,乾脆釋放吧。這樣的數據用 Session Storage 來處理再合適不過
Web Storage 是一個從定義到使用都很是簡單的東西。它使用鍵值對的形式進行存儲,這種模式有點相似於對象,卻甚至連對象都不是——它只能存儲字符串,要想獲得對象,咱們還須要先對字符串進行一輪解析。
說到底,Web Storage 是對 Cookie 的拓展,它只能用於存儲少許的簡單數據。當遇到大規模的、結構複雜的數據時,Web Storage 也心有餘而力不足了。這時候咱們就要清楚咱們的終極大 boss——IndexDB!
IndexDB 是一個運行在瀏覽器上的非關係型數據庫。既然是數據庫了,那就不是 5M、10M 這樣小打小鬧級別了。理論上來講,IndexDB 是沒有存儲上限的(通常來講不會小於 250M)。它不只能夠存儲字符串,還能夠存儲二進制數據。
遵循 MDN 推薦的操做模式 操做一個基本的 IndexDB 使用流程
1 打開/建立一個 IndexDB 數據庫(當該數據庫不存在時,open 方法會直接建立一個名爲 xiaoceDB 新數據庫)。
//後面的回調中 咱們能夠經過event.target.result拿到數據庫實例 let db //參數1位數據庫名 參數2爲版本號 const request = window.indexedDB.open('xiaoceDB',1) //使用IndexDB失敗時的監聽函數 request.onerror = function(event){ console.log('沒法使用IndexDB'); } //成功 request.onsuccess = function(event){ //此處就能夠獲取到db實例 db = event.target.result console.log('您打開了IndexDB'); }
2 建立一個object store(object store對標到數據庫中的表單位)
//onupgradeneeded事件會在初始化數據庫/版本發生更新時調用,咱們在它的監聽函數中建立object store request.onupgradeneeded = function(event){ let objectStore //若是同名表未被建立過 則新建test表 if(!db.objectStoreNames.contains('test')){ objectStore = db.createObjectStore('test',{keyPath:'id'}) } }
3 構建一個事務來執行一些數據庫操做,像增長或提取數據等。
//建立事務 指定表格名稱和讀寫功能 const transaction = db.transaction(["test"],"readwrite") // 拿到Object Store對象 const objectStore = transaction.objectStore("test") //向表格寫入數據 objectStore.add({id:1,name:'xiuyan'})
4 經過監聽正確類型的事件以等待操做完成。
// 操做完成時的監聽函數 transaction.oncomplete = function(event){ console.log('操做完成') } // 操做失敗時的監聽函數 transaction.onerror = function(event){ console.log('這裏有一個error') }
在 IndexDB 中,咱們能夠建立多個數據庫,一個數據庫中建立多張表,一張表中存儲多條數據——這足以 hold 住複雜的結構性數據。IndexDB 能夠看作是 LocalStorage 的一個升級,當數據的複雜度和規模上升到了 LocalStorage 沒法解決的程度,咱們毫無疑問能夠請出 IndexDB 來幫忙。
瀏覽器緩存/存儲技術的出現和發展,爲咱們的前端應用帶來了無限的起色。近年來基於緩存/存儲技術的第三方庫層出不絕,此外還衍生出了 PWA 這樣優秀的 Web 應用模型。能夠說,現代前端應用,尤爲是移動端應用,之因此能夠發展到在體驗上叫板 Native 的地步,主要就是仰仗緩存/存儲立下的汗馬功勞
CDN (Content Delivery Network,即內容分發網絡)指的是一組分佈在各個地區的服務器。這些服務器存儲着數據的副本,所以服務器能夠根據哪些服務器與用戶距離最近,來知足數據的請求。 CDN 提供快速服務,較少受高流量影響
緩存、本地存儲帶來的性能提高,是否是隻能在「獲取到資源並把它們存起來」這件事情發生以後?也就是說,首次請求資源的時候,這些招數都是救不了咱們的。要提高首次請求的響應能力,咱們還須要藉助 CDN 的能力
*假設個人根服務器在杭州
此時有一位北京的用戶向我請求資源。在網絡帶寬小、用戶訪問量大的狀況下,杭州的這一臺服務器或許不那麼給力,不能給用戶很是快的響應速度。因而我靈機一動,把這批資源 copy 了一批放在北京的機房裏。當用戶請求資源時,就近請求北京的服務器,北京這臺服務器低頭一看,這個資源我存了,離得這麼近,響應速度確定噌噌的!那若是北京這臺服務器沒有 copy 這批資源呢?它會再向杭州的根服務器去要這個資源。在這個過程當中,北京這臺服務器就扮演着 CDN 的角色。*
CDN 的核心點有兩個,一個是緩存,一個是回源。
「緩存」就是說咱們把資源 copy 一份到 CDN 服務器上這個過程,「回源」就是說 CDN 發現本身沒有這個資源(通常是緩存的數據過時了),轉頭向根服務器(或者它的上層服務器)去要這個資源的過程。
CDN 每每被用來存放靜態資源。上文中咱們舉例所提到的「根服務器」本質上是業務服務器,它的核心任務在於生成動態頁面或返回非純靜態頁面,這兩種過程都是須要計算的。業務服務器彷彿一個車間,車間裏運轉的機器轟鳴着爲咱們產出所需的資源;相比之下,CDN 服務器則像一個倉庫,它只充當資源的「棲息地」和「搬運工」。
所謂「靜態資源」,就是像 JS、CSS、圖片等不須要業務服務器進行計算即得的資源。而「動態資源」,顧名思義是須要後端實時動態生成的資源,較爲常見的就是 JSP、ASP 或者依賴服務端渲染獲得的 HTML 頁面。
什麼是「非純靜態資源」呢?它是指須要服務器在頁面以外做額外計算的 HTML 頁面。具體來講,當我打開某一網站以前,該網站須要經過權限認證等一系列手段確認個人身份、進而決定是否要把 HTML 頁面呈現給我。這種狀況下 HTML 確實是靜態的,但它和業務服務器的操做耦合,咱們把它丟到CDN 上顯然是不合適的。
靜態資源自己具備訪問頻率高、承接流量大的特色,所以靜態資源加載速度始終是前端性能的一個很是關鍵的指標。CDN 是靜態資源提速的重要手段,在許多一線的互聯網公司,「靜態資源走 CDN」並非一個建議,而是一個規定。
好比以淘寶爲表明的阿里系產品,就遵循着這個「規定」。
打開淘寶首頁,咱們能夠在 Network 面板中看到,「非純靜態」的 HTML 頁面,是向業務服務器請求來的:
咱們點擊 preview,能夠看到業務服務器確實是返回給了咱們一個還沒有被靜態資源加持過的簡單 HTML 頁面,全部的圖片內容都是先以一個 div 佔位:
相應地,咱們隨便點開一個靜態資源,能夠看到它都是從 CDN 服務器上請求來的。
好比說圖片:
再好比 JS、CSS 文件:
如何讓 CDN 的效用最大化?這又是須要先後端程序員一塊兒思考的龐大命題。它涉及到 CDN 服務器自己的性能優化、CDN 節點的地址選取等。談離前端最近的這部分細節:CDN 的域名選取。
淘寶首頁的例子,咱們注意到業務服務器的域名是這個:
www.taobao.com
而 CDN 服務器的域名是這個
g.alicdn.com
咱們講到 Cookie 的時候,爲了凸顯 Local Storage 的優越性,曾經提到過
同一個域名下的請求會不分青紅皁白地攜帶 Cookie,而靜態資源每每並不須要 Cookie 攜帶什麼認證信息。把靜態資源和主頁面置於不一樣的域名下,完美地避免了沒必要要的 Cookie 的出現!
看起來是一個不起眼的小細節,但帶來的效用倒是驚人的。以電商網站靜態資源的流量之龐大,若是沒把這個多餘的 Cookie 拿下來,不只用戶體驗會大打折扣,每一年因性能浪費帶來的經濟開銷也將是一個很是恐怖的數字。
如此看來,性能優化還真是要步步爲營!
客戶端渲染模式下,服務端會把渲染須要的靜態文件發送給客戶端,客戶端加載過來以後,本身在瀏覽器裏跑一遍 JS,根據 JS 的運行結果,生成相應的 DOM。這種特性使得客戶端渲染的源代碼老是特別簡潔,
<!doctype html> <html> <head> <title>我是客戶端渲染的頁面</title> </head> <body> <div id='root'></div> <script src='index.js'></script> </body> </html>
根節點下究竟是什麼內容呢?你不知道,我不知道,只有瀏覽器把 index.js 跑過一遍後才知道,這就是典型的客戶端渲染。
頁面上呈現的內容,你在 html 源文件裏裏找不到——這正是它的特色。
服務端渲染的模式下,當用戶第一次請求頁面時,由服務器把須要的組件或頁面渲染成 HTML 字符串,而後把它返回給客戶端。客戶端拿到手的,是能夠直接渲染而後呈現給用戶的 HTML 內容,不須要爲了生成 DOM 內容本身再去跑一遍 JS 代碼。
使用服務端渲染的網站,能夠說是「所見即所得」,頁面上呈現的內容,咱們在 html 源文件裏也能找到。
好比知乎就是典型的服務端渲染案例:
事實上,不少網站是出於效益的考慮才啓用服務端渲染,性能卻是在其次。
假設 A 網站頁面中有一個關鍵字叫「前端性能優化」,這個關鍵字是 JS 代碼跑過一遍後添加到 HTML 頁面中的。那麼客戶端渲染模式下,咱們在搜索引擎搜索這個關鍵字,是找不到 A 網站的——搜索引擎只會查找現成的內容,不會幫你跑 JS 代碼。A 網站的運營方見此情形,感到很頭大:搜索引擎搜不出來,用戶找不到咱們,誰還會用個人網站呢?爲了把「現成的內容」拿給搜索引擎看,A 網站不得不啓用服務端渲染。
但性能在其次,不表明性能不重要。服務端渲染解決了一個很是關鍵的性能問題——首屏加載速度過慢。在客戶端渲染模式下,咱們除了加載 HTML,還要等渲染所需的這部分 JS 加載完,以後還得把這部分 JS 在瀏覽器上再跑一遍。這一切都是發生在用戶點擊了咱們的連接以後的事情,在這個過程結束以前,用戶始終見不到咱們網頁的廬山真面目,也就是說用戶一直在等!相比之下,服務端渲染模式下,服務器給到客戶端的已是一個直接能夠拿來呈現給用戶的網頁,中間環節早在服務端就幫咱們作掉了,用戶豈不「美滋滋」?
先來看一下在一個 React 項目裏,服務端渲染是怎麼實現的。本例中,咱們使用 Express 搭建後端服務。
項目中有一個叫作 VDom 的 React 組件,它的內容以下。
VDom.js:
import React from 'react' const VDom = ()=>{ return <div>我是一個被渲染爲真是DOM的虛擬DOM</div> } export default VDom
在服務端的入口文件中,我引入這個組件,對它進行渲染:
import express from 'express' import React from 'react' import {renderToString} from 'react-dom/server' import VDom from './VDom' // 建立一個express應用 const app = express() //renderToString 是把虛擬DOM轉化爲真實DOM內容 const Page = ` <html> <head> <title>test</title> </head> <body> <span>服務端渲染出了真實DOM:</span> ${RDom} </body> </html> ` //配置HTML內容對應的路由 app.get('/index',function(req,res){ res.send(Page) }) // 配置端口號 const server = app.listen(8000)
根據咱們的路由配置,當我訪問 http://localhost:8000/index 時,就能夠呈現出服務端渲染的結果了:
咱們能夠看到,VDom 組件已經被 renderToString 轉化爲了一個內容爲<div data-reactroot="">我是一個被渲染爲真實DOM的虛擬DOM</div>
的字符串,這個字符串被插入 HTML 代碼,成爲了真實 DOM 樹的一部分。
那麼 Vue 是如何實現服務端渲染的呢?
該示例直接將 Vue 實例整合進了服務端的入口文件中:
const Vue = require('vue') // 建立一個express應用 const server = require('express')() //提取出renderer實例 const renderer = require('vue-server-renderer').createRenderer() server.get('*',(req,res)=>{ // 編寫Vue實例(虛擬DOM節點) const app = new Vue({ data:{ url:req.url }, // 編寫模板HTML的內容 template:`<div>訪問的URL是:{{url}}</div>` }) // renderToString是把Vue實例轉換爲真實DOM的關鍵方法 renderer.renderToString(app,(err,html)=>{ if(err){ res.status(500).end("Internal Server Error") return } // 把渲染出來的真實DOM字符串插入HTML模板中 res.end(` <!DOCTYPE HTML> <html> <head> <title>hello</title> </head> <body> ${html} </body> </html> `) }) }) server.listen(8080)
實際項目比這些複雜不少,但萬變不離其宗。強調的只有兩點:一是這個 renderToString() 方法;二是把轉化結果「塞」進模板裏的這一步。這兩個操做是服務端渲染的靈魂操做。在虛擬 DOM「橫行」的當下,服務端渲染再也不是早年 JSP 裏簡單粗暴的字符串拼接過程,它還要求這一端要具有將虛擬 DOM 轉化爲真實 DOM 的能力。與其說是「把 JS 在服務器上先跑一遍」,不如說是「把 Vue、React 等框架代碼先在 Node 上跑一遍」。
服務端渲染本質上是本該瀏覽器作的事情,分擔給服務器去作。這樣當資源抵達瀏覽器時,它呈現的速度就快了。乍一看好像很合理:瀏覽器性能畢竟有限,服務器多牛逼!能者多勞,就該讓服務器多幹點活!
但仔細想一想,在這個網民遍地的時代,幾乎有多少個用戶就有多少臺瀏覽器。用戶擁有的瀏覽器總量多到數不清,那麼一個公司的服務器又有多少臺呢?咱們把這麼多臺瀏覽器的渲染壓力集中起來,分散給相比之下數量並很少的服務器,服務器確定是承受不住的。服務端渲染也並不是萬全之策。
在實踐中,建議你們先忘記服務端渲染這個事情——服務器稀少而寶貴,但首屏渲染體驗和 SEO 的優化方案卻不少——咱們最好先把能用的低成本「大招」都用完。除非網頁對性能要求過高了,以致於全部的招式都用完了,性能表現仍是不盡人意,這時候咱們就能夠考慮向老闆多申請幾臺服務器,把服務端渲染搞起來了~
目前市面上常見的瀏覽器內核能夠分爲這四種:Trident(IE)、Gecko(火狐)、Blink(Chrome、Opera)、Webkit(Safari)。
可能會據說過 Chrome 的內核就是 Webkit,卻不知 Chrome 內核早已迭代爲了 Blink。可是換湯不換藥,Blink 其實也是基於 Webkit 衍生而來的一個分支,所以,Webkit 內核仍然是當下瀏覽器世界真正的霸主。
什麼是渲染過程?簡單來講,渲染引擎根據 HTML 文件描述構建相應的數學模型,調用瀏覽器各個零部件,從而將網頁資源代碼轉換爲圖像結果,這個過程就是渲染過程
咱們最須要關注的,就是HTML 解釋器、CSS 解釋器、圖層佈局計算模塊、視圖繪製模塊與JavaScript 引擎這幾大模塊:
在這一步瀏覽器執行了全部的加載解析邏輯,在解析 HTML 的過程當中發出了頁面渲染所需的各類外部資源請求。
瀏覽器將識別並加載全部的 CSS 樣式信息與 DOM 樹合併,最終生成頁面 render 樹(:after :before 這樣的僞元素會在這個環節被構建到 DOM 樹中)。
計算圖層佈局
頁面中全部元素的相對位置信息,大小等信息均在這一步獲得計算。
在這一步中瀏覽器會根據咱們的 DOM 代碼結果,把每個頁面圖層轉換爲像素,並對全部的媒體文件進行解碼。
最後一步瀏覽器會合併合各個圖層,將數據由 CPU 輸出給 GPU 最終繪製在屏幕上。(複雜的視圖層會給這個階段的 GPU 計算帶來一些壓力,在實際應用中爲了優化動畫性能,咱們有時會手動區分不一樣的圖層)。
段的 GPU 計算帶來一些壓力,在實際應用中爲了優化動畫性能,咱們有時會手動區分不一樣的圖層)。
幾棵重要的「樹」
渲染過程說白了,首先是基於 HTML 構建一個 DOM 樹,這棵 DOM 樹與 CSS 解釋器解析出的 CSSOM 相結合,就有了佈局渲染樹。最後瀏覽器以佈局渲染樹爲藍本,去計算佈局並繪製圖像,咱們頁面的初次渲染就大功告成了。
基於渲染流程的 CSS 優化建議
CSS 引擎查找樣式表,對每條規則都按從右到左的順序去匹配。 看以下規則:
#myList li {}
習慣了從左到右閱讀的文字閱讀方式,會本能地覺得瀏覽器也是從左到右匹配 CSS 選擇器的,所以會推測這個選擇器並不會費多少力氣:#myList 是一個 id 選擇器,它對應的元素只有一個,查找起來應該很快。定位到了 myList 元素,等因而縮小了範圍後再去查找它後代中的 li 元素,沒毛病。
事實上,CSS 選擇符是從右到左進行匹配的。咱們這個看似「沒毛病」的選擇器,實際開銷至關高:瀏覽器必須遍歷頁面上每一個 li 元素,而且每次都要去確認這個 li 元素的父元素 id 是否是 myList
總結出以下性能提高的方案:
- #myList li{}
正確:
.myList_li {}
DOM 和 CSSOM 協力才能構建渲染樹。這一點會給性能形成嚴重影響:默認狀況下,CSS 是阻塞的資源。瀏覽器在構建 CSSOM 的過程當中,不會渲染任何已處理的內容。即使 DOM 已經解析完畢了,只要 CSSOM 不 OK,那麼渲染這個事情就不 OK(這主要是爲了不沒有 CSS 的 HTML 頁面醜陋地「裸奔」在用戶眼前)。
只有當咱們開始解析 HTML 後、解析到 link 標籤或者 style 標籤時,CSS 才登場,CSSOM 的構建纔開始。不少時候,DOM 不得不等待 CSSOM。
CSS 是阻塞渲染的資源。須要將它儘早、儘快地下載到客戶端,以便縮短首次渲染的時間。
JS 的做用在於修改,它幫助咱們修改網頁的方方面面:內容、樣式以及它如何響應用戶交互。這「方方面面」的修改,本質上都是對 DOM 和 CSSDOM 進行修改。所以 JS 的執行會阻止 CSSOM,在咱們不做顯式聲明的狀況下,它也會阻塞 DOM。
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title>JS阻塞測試</title> <style> #container { background-color: yellow; width: 100px; height: 100px; } </style> <script> // 嘗試獲取container元素 var container = document.getElementById("container") console.log('container', container) </script> </head> <body> <div id="container"></div> <script> // 嘗試獲取container元素 var container = document.getElementById("container") console.log('container', container) // 輸出container元素此刻的背景色 console.log('container bgColor', getComputedStyle(container).backgroundColor) </script> <style> #container { background-color: blue; } </style> </body> </html>
三個 console 的結果分別爲:
第一次嘗試獲取 id 爲 container 的 DOM 失敗,這說明 JS 執行時阻塞了 DOM,後續的 DOM 沒法構建;第二次才成功,這說明腳本塊只能找到在它前面構建好的元素。這二者結合起來,「阻塞 DOM」獲得了驗證。再看第三個 console,嘗試獲取 CSS 樣式,獲取到的是在 JS 代碼執行前的背景色(yellow),而非後續設定的新樣式(blue),說明 CSSOM 也被阻塞了。
JS 引擎是獨立於渲染引擎存在的。咱們的 JS 代碼在文檔的何處插入,就在何處執行。當 HTML 解析器遇到一個 script 標籤時,它會暫停渲染過程,將控制權交給 JS 引擎。JS 引擎對內聯的 JS 代碼會直接執行,對外部 JS 文件還要先獲取到腳本、再進行執行。等 JS 引擎運行完畢,瀏覽器又會把控制權還給渲染引擎,繼續 CSSOM 和 DOM 的構建。 所以與其說是 JS 把 CSS 和 HTML 阻塞了,不如說是 JS 引擎搶走了渲染引擎的控制權。
能夠經過對它使用 defer 和 async 來避免沒必要要的阻塞,這裏咱們就引出了外部 JS 的三種加載方式。
- 正常模式:
<script src="index.js"></script>
這種狀況下 JS 會阻塞瀏覽器,瀏覽器必須等待 index.js 加載和執行完畢才能去作其它事情
async 模式:
<script async src="index.js"></script>
async 模式下,JS 不會阻塞瀏覽器作任何其它的事情。它的加載是異步的,當它加載結束,JS 腳本會當即執行。
defer 模式:
<script defer src="index.js"></script>
defer 模式下,JS 的加載是異步的,執行是被推遲的。等整個文檔解析完成、DOMContentLoaded 事件即將被觸發時,被標記了 defer 的 JS 文件纔會開始依次執行。
腳本與 DOM 元素和其它腳本之間的依賴關係不強時,咱們會選用 async;當腳本依賴於 DOM 元素和其它腳本的執行結果時,咱們會選用 defer。
經過審時度勢地向 script 標籤添加 async/defer,咱們就能夠告訴瀏覽器在等待腳本可用期間不阻止其它的工做,這樣能夠顯著提高性能。
把 DOM 和 JavaScript 各自想象成一個島嶼,它們之間用收費橋樑鏈接
JS 是很快的,在 JS 中修改 DOM 對象也是很快的。在JS的世界裏,一切是簡單的、迅速的。但 DOM 操做並不是 JS 一我的的獨舞,而是兩個模塊之間的協做。
JS 引擎和渲染引擎(瀏覽器內核)是獨立實現的。當咱們用 JS 去操做 DOM 時,本質上是 JS 引擎和渲染引擎之間進行了「跨界交流」。這個「跨界交流」的實現並不簡單,它依賴了橋接接口做爲「橋樑」
過「橋」要收費——這個開銷自己就是不可忽略的。咱們每操做一次 DOM(無論是爲了修改仍是僅僅爲了訪問其值),都要過一次「橋」。過「橋」的次數一多,就會產生比較明顯的性能問題
過橋很慢,到了橋對岸,咱們的更改操做帶來的結果也很慢。
不少時候,咱們對 DOM 的操做都不會侷限於訪問,而是爲了修改它。當咱們對 DOM 的修改會引起它外觀(樣式)上的改變時,就會觸發迴流或重繪。
個過程本質上仍是由於咱們對 DOM 的修改觸發了渲染樹(Render Tree)的變化所致使的:
迴流:當咱們對 DOM 的修改引起了 DOM 幾何尺寸的變化(好比修改元素的寬、高或隱藏元素等)時,瀏覽器須要從新計算元素的幾何屬性(其餘元素的幾何屬性和位置也會所以受到影響),而後再將計算的結果繪製出來。這個過程就是迴流(也叫重排)。
重繪:當咱們對 DOM 的修改致使了樣式的變化、卻並未影響其幾何屬性(好比修改了顏色或背景色)時,瀏覽器不需從新計算元素的幾何屬性、直接爲該元素繪製新的樣式(跳過了上圖所示的迴流環節)。這個過程叫作重繪。
由此咱們能夠看出,重繪不必定致使迴流,迴流必定會致使重繪。硬要比較的話,迴流比重繪作的事情更多,帶來的開銷也更大。
減小 DOM 操做:少交「過路費」、避免過分渲染
<!DOCTYPE html> <html lang="en"><head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title>DOM操做測試</title> </head> <body> <div id="container"></div> </body> </html>
此時我有一個假需求——我想往 container 元素裏寫 10000 句同樣的話。若是我這麼作:
for(var count=0;count<10000;count++){ document.getElementById('container').innerHTML+='<span>我是一個小測試</span>' }
這段代碼有兩個明顯的可優化點。
第一點,過路費交太多了。咱們每一次循環都調用 DOM 接口從新獲取了一次 container 元素,至關於每次循環都交了一次過路費。先後交了 10000 次過路費,但其中 9999 次過路費均可以用緩存變量的方式節省下來:
// 只獲取一次container let container = document.getElementById('container') for(let count=0;count<10000;count++){ container.innerHTML += '<span>我是一個小測試</span>' }
第二點,沒必要要的 DOM 更改太多了。咱們的 10000 次循環裏,修改了 10000 次 DOM 樹。咱們前面說過,對 DOM 的修改會引起渲染樹的改變、進而去走一個(可能的)迴流或重繪的過程,而這個過程的開銷是很「貴」的。這麼貴的操做,咱們居然重複執行了 N 屢次!其實咱們能夠經過就事論事的方式節省下來沒必要要的渲染:
let container = document.getElementById('container') let content = '' for(let count=0;count<10000;count++){ // 先對內容進行操做 content += '<span>我是一個小測試</span>' } // 內容處理好了,最後再觸發DOM的更改 container.innerHTML = content
JS 層面的事情,JS 本身去處理,處理好了,再來找 DOM 打報告
事實上,考慮JS 的運行速度,比 DOM 快得多這個特性。咱們減小 DOM 操做的核心思路,就是讓 JS 去給 DOM 分壓。
這個思路,在 DOM Fragment 中體現得淋漓盡致。
DocumentFragment 接口表示一個沒有父級文件的最小文檔對象。它被當作一個輕量版的 Document 使用,用於存儲已排好版的或還沒有打理好格式的XML片斷。由於 DocumentFragment 不是真實 DOM 樹的一部分,它的變化不會引發 DOM 樹的從新渲染的操做(reflow),且不會致使性能等問題
在咱們上面的例子裏,字符串變量 content 就扮演着一個 DOM Fragment 的角色。其實不管字符串變量也好,DOM Fragment 也罷,它們本質上都做爲脫離了真實 DOM 樹的容器出現,用於緩存批量化的 DOM 操做。
前面咱們直接用 innerHTML 去拼接目標內容,這樣作當然有用,但卻不夠優雅。相比之下,DOM Fragment 能夠幫助咱們用更加結構化的方式去達成一樣的目的,從而在維持性能的同時,保住咱們代碼的可拓展和可維護性。咱們如今用 DOM Fragment 來改寫上面的例子:
let container = document.getElementById('container') // 建立一個DOM Fragment 對象做爲容器 let content = document.createDocumentFragment() for(let count = 0;count<1000;count++){ // span此時能夠經過DOM API去建立 let oSpan = document.createElement("span") oSpan.innerHTML = "我是一個小測試" // 像操做真實DOM同樣操做DOM Fragment對象 content.appendChild(oSpan) } // 內容處理好了 最後再觸發真實的DOM的更改 container.appendChild(content)
DOM Fragment 對象容許咱們像操做真實 DOM 同樣去調用各類各樣的 DOM API,咱們的代碼質量所以獲得了保證。而且它的身份也很是純粹:當咱們試圖將其 append 進真實 DOM 時,它會在乖乖交出自身緩存的全部後代節點後全身而退,完美地完成一個容器的使命,而不會出如今真實的 DOM 結構中。這種結構化、乾淨利落的特性,使得 DOM Fragment 做爲經典的性能優化手段大受歡迎,這一點在 jQuery、Vue 等優秀前端框架的源碼中均有體現。
事件循環中的異步隊列有兩種:macro(宏任務)隊列和 micro(微任務)隊列。
常見的 macro-task 好比: setTimeout、setInterval、 setImmediate、script(總體代碼)、 I/O 操做、UI 渲染等。
常見的 micro-task 好比: process.nextTick、Promise、MutationObserver 等。
一個完整的 Event Loop 過程,能夠歸納爲如下階段:
初始狀態:調用棧空。micro 隊列空,macro 隊列裏有且只有一個 script 腳本(總體代碼)。
全局上下文(script 標籤)被推入調用棧,同步代碼執行。在執行的過程當中,經過對一些接口的調用,能夠產生新的 macro-task 與 micro-task,它們會分別被推入各自的任務隊列裏。同步代碼執行完了,script 腳本會被移出 macro 隊列,這個過程本質上是隊列的 macro-task 的執行和出隊的過程。
上一步咱們出隊的是一個 macro-task,這一步咱們處理的是 micro-task。但須要注意的是:當 macro-task 出隊時,任務是一個一個執行的;而 micro-task 出隊時,任務是一隊一隊執行的(以下圖所示)。所以,咱們處理 micro 隊列這一步,會逐個執行隊列中的任務並把它出隊,直到隊列被清空。
(上述過程循環往復,直到兩個隊列都清空)
咱們總結一下,每一次循環都是一個這樣的過程:
假如我想要在異步任務裏進行DOM更新,我該把它包裝成 micro 仍是 macro 呢?
咱們先假設它是一個 macro 任務,好比我在 script 腳本中用 setTimeout 來處理它:
// task是一個用於修改DOM的回調 setTimeout(task, 0)
如今 task 被推入的 macro 隊列。但由於 script 腳本自己是一個 macro 任務,因此本次執行完 script 腳本以後,下一個步驟就要去處理 micro 隊列了,再往下就去執行了一次 render,對不對?
但本次render個人目標task其實並無執行,想要修改的DOM也沒有修改,所以這一次的render實際上是一次無效的render。
macro 不 ok ,咱們轉向 micro 試試看。我用 Promise 來把 task 包裝成是一個 micro 任務:
Promise.resolve().then(task)
咱們更新 DOM 的時間點,應該儘量靠近渲染的時機。當咱們須要在異步任務中實現 DOM 修改時,把它包裝成 micro 任務是相對明智的選擇。
什麼是異步更新?
當咱們使用 Vue 或 React 提供的接口去更新數據時,這個更新並不會當即生效,而是會被推入到一個隊列裏。待到適當的時機,隊列中的更新任務會被批量觸發。這就是異步更新。
異步更新能夠幫助咱們避免過分渲染,是咱們上節提到的「讓 JS 爲 DOM 分壓」的典範之一。
異步更新的特性在於它只看結果,所以渲染引擎不須要爲過程買單。
最典型的例子,好比有時咱們會遇到這樣的狀況:
// 任務一 this.content = '第一次測試' // 任務二 this.content = '第二次測試' // 任務三 this.content = '第三次測試'
咱們在三個更新任務中對同一個狀態修改了三次,若是咱們採起傳統的同步更新策略,那麼就要操做三次 DOM。但本質上須要呈現給用戶的目標內容其實只是第三次的結果,也就是說只有第三次的操做是有意義的——咱們白白浪費了兩次計算。
但若是咱們把這三個任務塞進異步更新隊列裏,它們會先在 JS 的層面上被批量執行完畢。當流程走到渲染這一步時,它僅僅須要針對有意義的計算結果操做一次 DOM——這就是異步更新的妙處。
Vue 每次想要更新一個狀態的時候,會先把它這個更新操做給包裝成一個異步操做派發出去。這件事情,在源碼中是由一個叫作 nextTick 的函數來完成的:
export function nextTick(cb?:Function, ctx?:Object){ let _resolve callbacks.push(()=>{ if(cb){ try{ cb.call(ctx) }catch(e){ handleError(e,ctx,'nextTick') } }else if(_resolve){ _resolve(ctx) } }) // 檢查上一個異步任務隊列(即名爲callbacks的任務數組)是否派發和執行完畢了 pending此處至關於一個鎖 if(!pending){ // 若上一個異步任務隊列已經執行完畢 則將pending設爲true(把鎖鎖上) pending = true // 是否要求必定要派發爲macro任務 if(useMacroTask){ macroTimerFunc() }else{ // 若是不說明必定要marco 大家就全都是micro microTimerFunc() } } // $flow-disable-line if(!cb && typeof Promise !== 'undefined'){ return new Promise(resolve => { _resolve = resolve }) } }
Vue 的異步任務默認狀況下都是用 Promise 來包裝的,也就是是說它們都是 micro-task。這一點和咱們「前置知識」中的渲染時機的分析不謀而合。
細化解析一下 macroTimeFunc() 和 microTimeFunc() 兩個方法。
macroTimeFunc() 是這麼實現的:
// macro首選setImmediate 這個兼容性最差 if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) { macroTimerFunc = () => { setImmediate(flushCallbacks) } } else if (typeof MessageChannel !== 'undefined' && ( isNative(MessageChannel) || // PhantomJS MessageChannel.toString() === '[object MessageChannelConstructor]' )) { const channel = new MessageChannel() const port = channel.port2 channel.port1.onmessage = flushCallbacks macroTimerFunc = () => { port.postMessage(1) } } else { // 兼容性最好的派發方式是setTimeout macroTimerFunc = () => { setTimeout(flushCallbacks, 0) } }
microTimeFunc() 是這麼實現的:
// 簡單粗暴 不是ios全都給我去Promise 若是不兼容promise 那麼你只能將就一下變成marco了 if(typeof Promise !== 'undefined'&& isNative(Promise)){ const p = Promise.resolve() microTimerFunc=()=>{ p.then(flushCallbacks) if(isIOS)setTimeout(noop) } }else{ // 若是沒法派發micro 就退而求次派發爲macro microTimerFunc = macroTimerFunc }
咱們注意到,不管是派發 macro 任務仍是派發 micro 任務,派發的任務對象都是一個叫作 flushCallbacks 的東西,這個東西作了什麼呢?
flushCallbacks 源碼以下:
function flushCallbacks(){ pending = false //callbacks在nextick中出現過 它是任務數組(隊列) const copies = callbacks.slice(0) callbacks.length = 0 //將callback中的任務逐個取出執行 for(let i =0;i<copies.length;i++){ copies[i]() } }
Vue 中每產生一個狀態更新任務,它就會被塞進一個叫 callbacks 的數組(此處是任務隊列的實現形式)中。這個任務隊列在被丟進 micro 或 macro 隊列以前,會先去檢查當前是否有異步更新任務正在執行(即檢查 pending 鎖)。若是確認 pending 鎖是開着的(false),就把它設置爲鎖上(true),而後對當前 callbacks 數組的任務進行派發(丟進 micro 或 macro 隊列)和執行。設置 pending 鎖的意義在於保證狀態更新任務的有序進行,避免發生混亂
迴流:當咱們對 DOM 的修改引起了 DOM 幾何尺寸的變化(好比修改元素的寬、高或隱藏元素等)時,瀏覽器須要從新計算元素的幾何屬性(其餘元素的幾何屬性和位置也會所以受到影響),而後再將計算的結果繪製出來。這個過程就是迴流(也叫重排)。
重繪:當咱們對 DOM 的修改致使了樣式的變化、卻並未影響其幾何屬性(好比修改了顏色或背景色)時,瀏覽器不需從新計算元素的幾何屬性、直接爲該元素繪製新的樣式(跳過了上圖所示的迴流環節)。這個過程叫作重繪。
重繪不必定致使迴流,迴流必定會致使重繪。硬要比較的話,迴流比重繪作的事情更多,帶來的開銷也更大
最「貴」的操做:改變 DOM 元素的幾何屬性
這個改變幾乎能夠說是「牽一髮動全身」——當一個DOM元素的幾何屬性發生變化時,全部和它相關的節點(好比父子節點、兄弟節點等)的幾何屬性都須要進行從新計算,它會帶來巨大的計算量。
常見的幾何屬性有 width、height、padding、margin、left、top、border 等等
「價格適中」的操做:改變 DOM 樹的結構
這裏主要指的是節點的增減、移動等操做。瀏覽器引擎佈局的過程,順序上能夠類比於樹的前序遍歷——它是一個從上到下、從左到右的過程。一般在這個過程當中,當前元素不會再影響其前面已經遍歷過的元素。
最容易被忽略的操做:獲取一些特定屬性的值
當你要用到像這樣的屬性:offsetTop、offsetLeft、 offsetWidth、offsetHeight、scrollTop、scrollLeft、scrollWidth、scrollHeight、clientTop、clientLeft、clientWidth、clientHeight 時,你就要注意了!
「像這樣」的屬性,究竟是像什麼樣?——這些值有一個共性,就是須要經過即時計算獲得。所以瀏覽器爲了獲取這些值,也會進行迴流。
除此以外,當咱們調用了 getComputedStyle 方法,或者 IE 裏的 currentStyle 時,也會觸發迴流。原理是同樣的,都爲求一個「即時性」和「準確性」。
有時咱們想要經過屢次計算獲得一個元素的佈局位置,咱們可能會這樣作:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Document</title> </head> <style> #el{ width:100%; height:100%; background-color: yellow; position:absolute; } </style> <body> <div id="el"></div> <script> // 獲取el元素 const el = document.getElementById('el') // 這裏循環斷定比較簡單實際中獲取會拓展出比較複雜的斷定需求 for(let i =0;i<10;i++){ el.style.top = el.offsetHeight + 10 + 'px' el.style.left = el.offsetLeft + 10 + 'px' } </script> </body> </html>
這樣作,每次循環都須要獲取屢次「敏感屬性」,是比較糟糕的。咱們能夠將其以 JS 變量的形式緩存起來,待計算完畢再提交給瀏覽器發出重計算請求:
// 緩存offsetLeft與offsetTop的值 const el = document.getElementById('el') let offLeft = el.offsetLeft,offTop = el.offsetTop // 在js層面進行計算 for(let i =0;i<10;i++){ offsetLeft += 10 offsetTop += 10 } // 一次性將計算結果應用到DOM上 el.style.left = offLeft + 'px' el.style.top = offTop + 'px'
好比咱們能夠把這段單純的代碼
const container = document.getElementById('container') container.style.width = '100px' container.style.height = '200px' container.style.border = '10px solid red' container.style.color = 'red'
優化成一個有 class 加持的樣子:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Document</title> </head> <style> .basic_style { width: 100px; height: 200px; border: 10px solid red; color:red; } </style> <body> <div id="container"></div> <script> let container = document.getElementById('container') container.classList.add('basic_style') </script> </body> </html>
前者每次單獨操做,都去觸發一次渲染樹更改,從而致使相應的迴流與重繪過程。
合併以後,等於咱們將全部的更改一次性發出,用一個 style 請求解決掉了。
所說的迴流和重繪,都是在「該元素位於頁面上」的前提下會發生的。一旦咱們給元素設置 display: none,將其從頁面上「拿掉」,那麼咱們的後續操做,將沒法觸發迴流與重繪——這個將元素「拿掉」的操做,就叫作 DOM 離線化。
仍以這段代碼片斷爲例:
const container = document.getElementById('container') container.style.width = '100px' container.style.height = '200px' container.style.border = '10px solid red' container.style.color = 'red'
離線化後就是這樣:
const container = document.getElementById('container') container.style.display = 'none' container.style.width = '100px' container.style.height = '200px' container.style.border = '10px solid red' container.style.color = 'red' ..... container.style.display = 'block'
把它拿下來了,後續無論我操做這個元素多少次,每一步的操做成本都會很是低。當咱們只須要進行不多的 DOM 操做時,DOM 離線化的優越性確實不太明顯。一旦操做頻繁起來,這「拿掉」和「放回」的開銷都將會是很是值得的。
let container = document.getElementById('container') container.style.width = '100px' container.style.height = '200px' container.style.border = '10px solid red' container.style.color = 'red'
這段代碼裏,瀏覽器進行了多少次的迴流或重繪呢
「width、height、border是幾何屬性,各觸發一次迴流;color只形成外觀的變化,會觸發一次重繪。」
由於現代瀏覽器是很聰明的。瀏覽器本身也清楚,若是每次 DOM 操做都即時地反饋一次迴流或重繪,那麼性能上來講是扛不住的。因而它本身緩存了一個 flush 隊列,把咱們觸發的迴流與重繪任務都塞進去,待到隊列裏的任務多起來、或者達到了必定的時間間隔,或者「不得已」的時候,再將這些任務一口氣出隊。所以咱們看到,上面就算咱們進行了 4 次 DOM 更改,也只觸發了一次 Layout 和一次 Paint。
提到過有一類屬性很特別,它們有很強的「即時性」。當咱們訪問這些屬性時,瀏覽器會爲了得到此時此刻的、最準確的屬性值,而提早將 flush 隊列的任務出隊——這就是所謂的「不得已」時刻。
並非全部的瀏覽器都是聰明的。Chrome 裏行得通的東西,到了別處(好比 IE)就不必定行得通了。而咱們並不知道用戶會使用什麼樣的瀏覽器。若是不手動作優化,那麼一個頁面在不一樣的環境下就會呈現不一樣的性能效果,這對咱們、對用戶都是不利的。所以,養成良好的編碼習慣、從根源上解決問題,仍然是最周全的方法。
Lazy-Load,翻譯過來是「懶加載」。它是針對圖片加載時機的優化:在一些圖片量比較大的網站,
,若是咱們嘗試在用戶打開頁面的時候,就把全部的圖片資源加載完畢,那麼極可能會形成白屏、卡頓等現象,由於圖片真的太多了,一口氣處理這麼多任務,瀏覽器作不到啊!
只要咱們能夠在頁面打開的時候把首屏的圖片資源加載出來,用戶就會認爲頁面是沒問題的。至於下面的圖片,咱們徹底能夠等用戶下拉的瞬間再即時去請求、即時呈現給他。這樣一來,性能的壓力小了,用戶的體驗卻沒有變差——這個延遲加載的過程,就是 Lazy-Load。
Lazy-Load 的思路及實現方式爲大廠面試常考題
咱們在 index.html 中,爲這些圖片預置 img 標籤:
<!-- * @Author: yang * @Date: 2020-11-29 15:02:59 * @LastEditors: yang * @LastEditTime: 2020-11-29 15:28:43 * @FilePath: \gloud-h5-demo\src\component\index\index.html --> <!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> .img{ width: 200px; height: 200px; background-color: gray; } </style> </head> <body> <div class="img"> <img class="pic" alt="加載中" data-src="./images/1.png"> </div> <div class="img"> <img class="pic" alt="加載中" data-src="./images/2.png"> </div> <div class="img"> <img class="pic" alt="加載中" data-src="./images/3.png"> </div> <div class="img"> <img class="pic" alt="加載中" data-src="./images/4.png"> </div> <div class="img"> <img class="pic" alt="加載中" data-src="./images/5.png"> </div> <div class="img"> <img class="pic" alt="加載中" data-src="./images/6.png"> </div> <div class="img"> <img class="pic" alt="加載中" data-src="./images/7.png"> </div> <div class="img"> <img class="pic" alt="加載中" data-src="./images/8.png"> </div> <div class="img"> <img class="pic" alt="加載中" data-src="./images/9.png"> </div> </body> </html>
在懶加載的實現中,有兩個關鍵的數值:一個是當前可視區域的高度,另外一個是元素距離可視區域頂部的高度。
當前可視區域的高度, 在和現代瀏覽器及 IE9 以上的瀏覽器中,能夠用 window.innerHeight 屬性獲取。在低版本 IE 的標準模式中,能夠用 document.documentElement.clientHeight 獲取,這裏咱們兼容兩種狀況:
const viewHeight = window.innerHeight||document.documentElement.clientHeight
而元素距離可視區域頂部的高度,咱們這裏選用 getBoundingClientRect() 方法來獲取返回元素的大小及其相對於視口的位置
(DOMRect 對象包含了一組用於描述邊框的只讀屬性——left、top、right 和 bottom,單位爲像素。除了 width 和 height 外的屬性都是相對於視口的左上角位置而言的。)
能夠看出,top 屬性表明了元素距離可視區域頂部的高度,正好能夠爲咱們所用
Lazy-Load 方法開工
// 獲取全部的圖片標籤 const imgs = document.getElementsByTagName('img') // 獲取可視區域的高度 const viewHeight = window.innerHeight || document.documentElement.clientHeight // num用於統計當前顯示到了哪一張圖片 避免每次都從第一張圖片開始檢查是否漏出 let num = 0 function lazyload(){ for(let i = num;i<imgs.length;i++){ // 用可視區域高度減去元素頂部據可視區域頂部的高度 let distance = viewHeight - imgs[i].getBoundingClientRect().top // 若是可視區域高度大於元素頂部距離可視區域頂部的高度,說明元素露出 if(distance>=0){ //給元素寫入真實的src 展現圖片 imgs[i].src = imgs[i].getAttribute('data-src') // 前i張圖片已經加載完畢 下次從i+1張開始檢查是否露出 num = i+1 } } } // 監聽Scroll事件 window.addEventListener('scroll',lazyload,false)
這個 scroll 事件,是一個危險的事件——它太容易被觸發了。試想,用戶在訪問網頁的時候,是否是能夠無限次地去觸發滾動?尤爲是一個頁面死活加載不出來的時候,瘋狂調戲鼠標滾輪(或者瀏覽器滾動條)的用戶可不在少數啊!
再回頭看看咱們上面寫的代碼。按照咱們的邏輯,用戶的每一次滾動都將觸發咱們的監聽函數。函數執行是吃性能的,頻繁地響應某個事件將形成大量沒必要要的頁面計算。所以,咱們須要針對那些有可能被頻繁觸發的事件做進一步地優化。
scroll 事件是一個很是容易被反覆觸發的事件。其實不止 scroll 事件,resize 事件、鼠標事件(好比 mousemove、mouseover 等)、鍵盤事件(keyup、keydown 等)都存在被頻繁觸發的風險。
頻繁觸發回調致使的大量計算會引起頁面的抖動甚至卡頓。爲了規避這種狀況,咱們須要一些手段來控制事件被觸發的頻率。就是在這樣的背景下,throttle(事件節流)和 debounce(事件防抖)出現了。
這兩個東西都以閉包的形式存在。
它們經過對事件對應的回調函數進行包裹、以自由變量的形式緩存時間信息,最後用 setTimeout 來控制事件的觸發頻率。
throttle 的中心思想在於:在某段時間內,無論你觸發了多少次回調,我都只認第一次,並在計時結束時給予響應。
所謂的「節流」,是經過在一段時間內無視後來產生的回調請求來實現的。
每當用戶觸發了一次 scroll 事件,咱們就爲這個觸發操做開啓計時器。一段時間內,後續全部的 scroll 事件都會被看成「一輛車的乘客」——它們沒法觸發新的 scroll 回調。直到「一段時間」到了,第一次觸發的 scroll 事件對應的回調纔會執行,而「一段時間內」觸發的後續的 scroll 回調都會被節流閥無視掉。
// fn是咱們須要包裝的事件回調 interval是時間間隔的閾值 function throttle(fn,interval){ // last爲上一次觸發回調的時間、 let last = 0 // 將throwttle處理結果當作函數返回 return function(){ // 保留調用時的this上下文 let context = this // 保留調用時傳入的參數 let args = arguments //記錄本次觸發回調的時間 let now = + new Date() //判斷上次觸發的時間和本次觸發的時間差是否小於時間間隔的閥值 if(now - last >= interval){ // 若是時間間隔大於咱們設定的時間間隔閥值 則執行回調 last = now fn.apply(context,args) } } } // 用throwttle來包裝scroll的回調 document.addEventListener('scroll',throttle(()=>console.log('觸發了滾動事件'),1000))
防抖的中心思想在於:我會等你到底。在某段時間內,無論你觸發了多少次回調,我都只認最後一次。
// fn是須要包裝的事件回調 delay是每次推遲執行的等待時間 function debounce(fn,delay){ //定時器 let timer = null // 將debounce處理結果當作函數返回 return function(){ //保留調用時的this上下文 let context = this // 保留調用時傳入的參數 let args = arguments //每次事件被觸發時 都去清除以前的舊定時器 if(timer){ clearTimeout(timer) } // 設立新定時器 timer = setTimeout(function(){ fn.apply(context,args) },delay) } } document.addEventListener('scroll',debounce(()=>console.log('觸發了滾動事件'),1000))
debounce 的問題在於它「太有耐心了」。試想,若是用戶的操做十分頻繁——他每次都不等 debounce 設置的 delay 時間結束就進行下一次操做,因而每次 debounce 都爲該用戶從新生成定時器,回調函數被延遲了不可勝數次。頻繁的延遲會致使用戶遲遲得不到響應,用戶一樣會產生「這個頁面卡死了」的觀感。
爲了不弄巧成拙,咱們須要借力 throttle 的思想,打造一個「有底線」的 debounce——等你能夠,但我有個人原則:delay 時間內,我能夠爲你從新生成定時器;但只要delay的時間到了,我必需要給用戶一個響應。這個 throttle 與 debounce 「合體」思路,已經被不少成熟的前端庫應用到了它們的增強版 throttle 函數的實現中:
// fn是咱們須要包裝的事件回調, delay是時間間隔的閥值 function throttle(fn,delay){ // last 爲上次觸發回調的事件 timer是定時器 let last = 0,timer = null; // 將throttle處理結果當作函數返回 return function(){ // 保留調用時的this上下文 let context = this // 保留調用時傳入的參數 let args = arguments // 記錄本次觸發回調的時間 let now = +new Date() // 判斷上次觸發時間和本地觸發的時間差是否小於時間間隔的閥值 if(now-last>delay){ // 若是時間間隔小於咱們設定的時間間隔閥值 則爲本次觸發操做設立一個新的定時器 clearTimeout(timer) timer = setTimeout(function(){ last = now fn.apply(contxt,args) },delay) }else{ // 若是時間間隔超出了咱們設定的時間間隔閥值 那就不等了 不管如何要反饋給用戶一次響應 last = now fn.apply(context,args) } } } // 用新的throttle包裝scroll的回調 document.addEventListener('scroll',throttle(()=>console.log('觸發了滾動事件'),1000))
Performance是Chrome
提供給咱們的開發者工具,用於記錄和分析咱們的應用在運行時的全部活動。它呈現的數據具備實時性、多維度的特色,能夠幫助咱們很好地定位性能問題。
開始記錄
右鍵打開開發者工具,選中咱們的
Performance
面板:
當咱們選中圖中所標示的實心圓按鈕,Performance
會開始幫咱們記錄咱們後續的交互操做;當咱們選中圓箭頭按鈕,Performance
會將頁面從新加載,計算加載過程當中的性能表現。
tips:使用
Performance
工具時,爲了規避其它
Chrome
插件對頁面的性能影響,咱們最好在無痕模式下打開頁面
看 Main 欄目下的火焰圖和 Summary 提供給咱們的餅圖——這二者和概述面板中的 CPU 一欄結合,能夠幫咱們迅速定位性能瓶頸
從上到下,依次爲概述面板、詳情面板
觀察一下概述面板
FPS:這是一個和動畫性能密切相關的指標,它表示每一秒的幀數。圖中綠色柱狀越高表示幀率越高,體驗就越流暢。若出現紅色塊,則表明長時間幀,極可能會出現卡頓。圖中以綠色爲主,偶爾出現紅塊,說明網頁性能並不糟糕,但仍有可優化的空間。
CPU:表示CPU的使用狀況,不一樣的顏色片斷表明着消耗CPU資源的不一樣事件類型。這部分的圖像和下文詳情面板中的Summary內容有對應關係,咱們能夠結合這二者挖掘性能瓶頸。
NET:粗略的展現了各請求的耗時與先後順序。這個指標通常來講幫助不大。
先看 CPU 圖表和 Summary 餅圖。CPU 圖表中,咱們能夠根據顏色填充的飽滿程度,肯定 CPU 的忙閒,進而瞭解該頁面的總的任務量。而 Summary 餅圖則以一種直觀的方式告訴了咱們,哪一個類型的任務最耗時(從本例來看是腳本執行過程)。這樣咱們在優化的時候,就能夠抓到「主要矛盾」,進而有的放矢地開展後續的工做了。
再看 Main 提供給咱們的火焰圖。這個火焰圖很是關鍵,它展現了整個運行時主進程所作的每一件事情(包括加載、腳本運行、渲染、佈局、繪製等)。x 軸表示隨時間的記錄。每一個長條就表明一個活動。更寬的條形意味着事件須要更長時間。y 軸表示調用堆棧,咱們能夠看到事件是相互堆疊的,上層的事件觸發了下層的事件。
CPU 圖標和 Summary 圖都是按照「類型」給咱們提供性能信息,而 Main 火焰圖則將粒度細化到了每個函數的調用。究竟是從哪一個過程開始出問題、是哪一個函數拖了後腿、又是哪一個事件觸發了這個函數,這些具體的、細緻的問題都將在 Main 火焰圖中獲得解答。
Performance 無疑能夠爲咱們提供不少有價值的信息,但它的展現做用大於分析做用。它要求使用者對工具自己及其所展現的信息有充分的理解,可以將晦澀的數據「翻譯」成具體的性能問題。
程序員們許了個願:若是工具能幫助咱們把頁面的問題也分析出來就行了!上帝聽到了這個願望,因而給了咱們 LightHouse:
Lighthouse 是一個開源的自動化工具,用於改進網絡應用的質量。 你能夠將其做爲一個 Chrome 擴展程序運行,或從命令行運行。 爲Lighthouse 提供一個須要審查的網址,它將針對此頁面運行一連串的測試,而後生成一個有關頁面性能的報告。
首先在 Chrome 的應用商店裏下載一個 LightHouse。這一步 OK 以後,咱們瀏覽器右上角會出現一個小小的燈塔 ICON。打開咱們須要測試的那個頁面,點擊這個 ICON,喚起以下的面板:
而後點擊「Generate report」按鈕,只需靜候數秒,LightHouse 就會爲咱們輸出一個完美的性能報告。
這裏我拿掘金小冊首頁「開刀」:
稍事片刻,Report 便輸出成功了,LightHouse 默認會幫咱們打開一個新的標籤頁來展現報告內容。報告內容很是豐富,首先咱們看到的是總體的跑分狀況:
上述分別是頁面性能、PWA(漸進式 Web 應用)、可訪問性(無障礙)、最佳實踐、SEO 五項指標的跑分。孰強孰弱,咱們一看便知。
向下拉動 Report 頁,咱們還能夠看到每個指標的細化評估:
在「Opportunities」中,LightHouse 甚至針對咱們的性能問題給出了可行的建議、以及每一項優化操做預期會幫咱們節省的時間。這份報告的可操做性是很強的——咱們只須要對着 LightHouse 給出的建議,一條一條地去嘗試,就能夠看到本身的頁面,在一秒一秒地變快。
除了直接下載,咱們還能夠經過命令行使用 LightHouse:
npm install -g lighthouse lighthouse https://juejin.im/books
一樣能夠獲得掘金小冊的性能報告。
此外,從 Chrome 60 開始,DevTools 中直接加入了基於 LightHouse 的 Audits 面板:
W3C 規範爲咱們提供了 Performance 相關的接口。它容許咱們獲取到用戶訪問一個頁面的每一個階段的精確時間,從而對性能進行分析。咱們能夠將其理解爲 Performance 面板的進一步細化與可編程化。
當下的前端世界裏,數據可視化的概念已經被炒得很是熱了,Performance 面板就是數據可視化的典範。那麼爲何要把已經可視化的數據再掏出來處理一遍呢?這是由於,須要這些數據的人不止咱們前端——不少狀況下,後端也須要咱們提供性能信息的上報。此外,Performance 提供的可視化結果並不必定可以知足咱們實際的業務需求,只有拿到了真實的數據,咱們才能夠對它進行二次處理,去作一個更加深層次的可視化。
在這種需求背景下,咱們就不得不祭出 Performance API了。
訪問 performance 對象
performance 是一個全局對象。咱們在控制檯裏輸入 window.performance,就可一窺其全貌:
關鍵時間節點
在 performance 的 timing 屬性中,咱們能夠查看到以下的時間戳:
這些時間戳與頁面整個加載流程中的關鍵時間節點有着一一對應的關係
經過求兩個時間點之間的差值,咱們能夠得出某個過程花費的時間,舉個🌰:
const timing = window.performance.timing // DNS查詢耗時 timing.domainLookupEnd - timing.domainLookupStart // TCP鏈接耗時 timing.connectEnd - timing.connectStart // 內容加載耗時 timing.responseEnd - timing.requestStart ···
除了這些常見的耗時狀況,咱們更應該去關注一些關鍵性能指標:firstbyte、fpt、tti、ready 和 load 時間。這些指標數據與真實的用戶體驗息息相關,是咱們平常業務性能監測中不可或缺的一部分:
// firstbyte:首包時間 timing.responseStart – timing.domainLookupStart // fpt:First Paint Time, 首次渲染時間 / 白屏時間 timing.responseEnd – timing.fetchStart // tti:Time to Interact,首次可交互時間 timing.domInteractive – timing.fetchStart // ready:HTML 加載完成時間,即 DOM 就位的時間 timing.domContentLoaded – timing.fetchStart // load:頁面徹底加載時間 timing.loadEventStart – timing.fetchStart
以上這些經過 Performance API 獲取到的時間信息都具備較高的準確度。咱們能夠對此進行一番格式處理以後上報給服務端,也能夠基於此去製做相應的統計圖表,從而實現更加精準、更加個性化的性能耗時統計。
此外,經過訪問 performance 的 memory 屬性,咱們還能夠獲取到內存佔用相關的數據;經過對 performance 的其它屬性方法的靈活運用,咱們還能夠把它耦合進業務裏,實現更加多樣化的性能監測需求——靈活,是可編程化方案最大的優勢。