【譯】Google - 使用 webpack 進行 web 效能優化(一):減小前端資源大小

閱文前端團隊發表於2018-09-11

介紹

現代 web 應用經常使用打包工具來建立生產環境的“打包”檔案(指令碼、樣式等等),這些檔案經過優化壓縮之後能夠極快的被使用者下載。在使用 webpack 進行 web 效能優化系列文章中,我們將介紹如何使用 webpack 高效的優化站點資源。這將會幫助使用者更快的載入網站以及互動。

webpack logo

webpack 是當下最流行的打包工具之一。我們可以利用其特性來優化程式碼,通過程式碼拆分可以將指令碼拆分為核心和非核心部分,並且去除無用的程式碼(這僅僅是一小部分的優化案例),從而確保你的應用具有最小的網路負擔和處理成本。

Before and after applying JavaScript
  optimizations. Time-to-Interactive is improved

受 Susie Lu 的在 Bundle Buddy 中進行程式碼拆分的啟發。

⭐️ 注意: 我們建立了一個可供練習的應用來演示這篇文章中講到的內容。請充分利用它來練習這些技巧:webpack-training-project

讓我們從現今應用中最耗費資源之一的 JavaScript 開始優化。

第一篇:減小前端資源大小

當你正在優化一個應用時,第一件事就是儘可能地減少它的大小。這裡介紹如何利用 webpack 來實現。

使用生產模式(僅限 webpack4)

webpack 4 引入了新的 mode 標誌。你可以將這個標誌設定為 'development' 或者 'production' 來告訴 webpack 你正在為特定環境構建應用:

// webpack.config.js
module.exports = {
  mode: 'production',
};
複製程式碼

當構建生產環境的應用時,請確保你開啟了 production 模式。 這將讓 webpack 開啟它的優化項,比如:縮小尺寸、移除庫中只在開發者模式才有的程式碼等等

擴充套件閱讀

啟用最小化

⭐️ 注意: 這些大部分只適用於 webpack 3。如果你在 webpack 4 中開啟了 production 模式,bundle-level 最小化已經啟用 – 你只需要啟用 loader 特定(loader-specific)的選項

最小化尺寸是在你通過移除多餘的空格、縮短變數的命名等方式壓縮程式碼的時候進行的。例如這樣:

// 原來的程式碼
function map(array, iteratee) {
  let index = -1;
  const length = array == null ? 0 : array.length;
  const result = new Array(length);

  while (++index < length) {
    result[index] = iteratee(array[index], index, array);
  }
  return result;
}
複製程式碼

// 最小化後的程式碼
function map(n,r){let t=-1;for(const a=null==n?0:n.length,l=Array(a);++t<a;)l[t]=r(n[t],t,n);return l} 
複製程式碼

webpack 支援兩種方式最小化程式碼:bundle-level 最小化loader 特定的選項。它們應該同時使用。

Bundle-level 最小化

當編譯完成後,bundle-level 最小化功能會壓縮整個 bundle。這裡展示了它是如何工作的:

  1. 你的程式碼是這樣的:

    // comments.js
    import './comments.css';
    export function render(data, target) {
      console.log('Rendered!');
    }
    複製程式碼
  2. webpack 大致會將其編譯成如下內容:

    // bundle.js (part of)
    "use strict";
    Object.defineProperty(__webpack_exports__, "__esModule", { value: true });
    /* harmony export (immutable) */ __webpack_exports__["render"] = render;
    /* harmony import */ var __WEBPACK_IMPORTED_MODULE_0__comments_css__ = __webpack_require__(1);
    /* harmony import */ var __WEBPACK_IMPORTED_MODULE_0__comments_css_js___default =
    __webpack_require__.n(__WEBPACK_IMPORTED_MODULE_0__comments_css__);
    
    function render(data, target) {
      console.log('Rendered!');
    }
    複製程式碼
  3. minifier 大致會壓縮成下面那樣:

    // 最小化過的 bundle.js (part of)
    "use strict";function t(e,n){console.log("Rendered!")}
    Object.defineProperty(n,"__esModule",{value:!0}),n.render=t;var o=r(1);r.n(o)
    複製程式碼

在 webpack 4 中, bundle-level 最小化功能是自動開啟的 – 無論是否在生產模式。它在底層使用的是 UglifyJS 最小化。(如果你需要禁用最小化,只要使用開發模式或者將 optimization.minimize 選項設定為false 。)

在 webpack 3 中, 你需要直接使用 UglifyJS 外掛。這個外掛是 webpack 自帶的;將它新增到配置的 plugins 部分即可啟用:

// webpack.config.js
const webpack = require('webpack');

module.exports = {
  plugins: [
    new webpack.optimize.UglifyJsPlugin(),
  ],
};  
複製程式碼

⭐️ 注意: 在 webpack 3 中,UglifyJS 外掛不能編譯版本超過 ES2015 (即 ES6) 的程式碼。這意味著如果你的程式碼使用了類、箭頭函式或者其它新的語言特性,你不能將它們編譯成 ES5 版本的程式碼, 否則外掛將丟擲一個錯誤。

如果你需要編譯包含新的語法(的程式碼),使用 uglifyjs-webpack-plugin 外掛。 這同樣是 webpack 自帶的外掛,但是版本更新,並且可以編譯 ES2015+ 的程式碼。

loader 特定(loader-specific)的選項

最小化程式碼的第二種方法是 loader 特定的選項(loader 是什麼)。利用 loader 選項,你可以壓縮 minifier 不能最小化的東西。例如,當你利用 css-loader 匯入一個 CSS 檔案時,該檔案會被編譯成一個字串:

/* comments.css */  
.comment {  
  color: black;  
}  
複製程式碼

// 最小化後的 bundle.js (部分程式碼)
exports=module.exports=__webpack_require__(1)(),
exports.push([module.i,".comment {\r\n  color: black;\r\n}",""]);
複製程式碼

Minifier 不能壓縮該程式碼,因為它是一個字串。為了最小化檔案內容,我們需要像這樣配置 loader:

// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          'style-loader',
          { loader: 'css-loader', options: { minimize: true } },
        ],
      },
    ],
  },
};
複製程式碼

擴充套件閱讀

指定 NODE_ENV=production

⭐️ 注意: 這隻適用於 webpack 3。如果你在 production 模式下使用 webpack 4NODE_ENV=production 優化已啟用 – 可自由選擇地跳過該部分。

減少前端大小的另一種方法是在你的程式碼中將 NODE_ENV 環境變數 設定為 production

庫會讀取 NODE_ENV 變數以檢測它們應該在哪個模式下工作 – 在開發或生產中。 有些庫基於該變數而有不同的表現。例如,當 NODE_ENV 沒有設定為 production,Vue.js 會做額外的檢查並列印警告:

// vue/dist/vue.runtime.esm.js
// …
if (process.env.NODE_ENV !== 'production') {
  warn('props must be strings when using array syntax.');
}
// … 
複製程式碼

React 表現類似 – 它載入包含警告的開發環境構建:

// react/index.js
if (process.env.NODE_ENV === 'production') {
  module.exports = require('./cjs/react.production.min.js');
} else {
  module.exports = require('./cjs/react.development.js');
}

// react/cjs/react.development.js
// …
warning$3(
  componentClass.getDefaultProps.isReactClassApproved,
  'getDefaultProps is only used on classic React.createClass ' +
  'definitions. Use a static property named `defaultProps` instead.'
);
// … 
複製程式碼

在生產環境中通常不需要這些檢查和警告,但是它們還是存在於程式碼中並增加了庫的大小。 在 webpack 4 中, 通過新增 optimization.nodeEnv: 'production' 選項以移除它們:

// webpack.config.js (基於 webpack 4)
module.exports = {
  optimization: {
    nodeEnv: 'production',
    minimize: true,
  },
}; 
複製程式碼

在 webpack 3 中, 則使用 DefinePlugin 來替代:

// webpack.config.js (基於 webpack 3)
const webpack = require('webpack');

module.exports = {
  plugins: [
    new webpack.DefinePlugin({
      'process.env.NODE_ENV': '"production"',
    }),
    new webpack.optimize.UglifyJsPlugin(),
  ],
}; 
複製程式碼

optimization.nodeEnv 選項和 DefinePlugin 工作方式相同 – 它們會用某個特定的值取代所有在執行的 process.env.NODE_ENV。通過上面的配置:

  1. Webpack 會將所有存在的 process.env.NODE_ENV 替換成 "production"

    // vue/dist/vue.runtime.esm.js
    if (typeof val === 'string') {
      name = camelize(val);
      res[name] = { type: null };
    } else if (process.env.NODE_ENV !== 'production') {
      warn('props must be strings when using array syntax.');
    }
    複製程式碼

    // vue/dist/vue.runtime.esm.js
    if (typeof val === 'string') {
      name = camelize(val);
      res[name] = { type: null };
    } else if ("production" !== 'production') {
      warn('props must be strings when using array syntax.');
    }
    複製程式碼
  2. 然後 minifier 將會移除所有像 if 這樣的分支 – 因為 "production" !== 'production' 總是錯誤的,外掛明白這些分支中的程式碼永遠不會執行:

    // vue/dist/vue.runtime.esm.js
    if (typeof val === 'string') {
      name = camelize(val);
      res[name] = { type: null };
    } else if ("production" !== 'production') {
      warn('props must be strings when using array syntax.');
    }
    複製程式碼

    // vue/dist/vue.runtime.esm.js (without minification)
    if (typeof val === 'string') {
      name = camelize(val);
      res[name] = { type: null };
    }
    複製程式碼

擴充套件閱讀

使用 ES 模組(module)

減小前端尺寸的另一種方法是使用 ES 模組

當你使用 ES 模組, webpack 就可以進行 tree-shaking。Tree-shaking 是當 bundler 遍歷整個依賴樹時,檢查使用了什麼依賴,並移除無用的。所以,如果你使用了 ES 模組語法, webpack 可以去掉未使用的程式碼:

  1. 你寫了一個帶有多個 export 的檔案,但是應用只使用它們其中的一個:

    // comments.js
    export const render = () => { return 'Rendered!'; };
    export const commentRestEndpoint = '/rest/comments';
    
    // index.js
    import { render } from './comments.js';
    render();
    複製程式碼
  2. webpack 明白 commentRestEndpoint 沒有用到並且不會在 bundle 中生成單獨的 export:

    // bundle.js (和 comments.js 有關聯的部分)
    (function(module, __webpack_exports__, __webpack_require__) {
      "use strict";
      const render = () => { return 'Rendered!'; };
      /* harmony export (immutable) */ __webpack_exports__["a"] = render;
    
      const commentRestEndpoint = '/rest/comments';
      /* unused harmony export commentRestEndpoint */
    })
    複製程式碼
  3. minifier 移除未使用的變數:

    // bundle.js (part that corresponds to comments.js)
    (function(n,e){"use strict";var r=function(){return"Rendered!"};e.b=r})
    複製程式碼

即使是對用 ES 模組寫成的庫也是有效的。

⭐️ 注意: 在 webpack 中,tree-shaking 沒有 minifier 是不會起作用的。Webpack 僅僅移除沒有被用到的 export 變數;是 minifier 移除未使用的程式碼的。所以,如果你在沒有使用 minifier 的情況下編譯 bundle,是不會減小的。

然而,你不需要特定使用 webpack 內建的 minifier (UglifyJsPlugin)。任意的 minifier 都支援移除無用程式碼(例如 Babel Minify pluginGoogle Closure Compiler plugin) 都可以奏效。

警告: 不要將 ES 模組編譯為 CommonJS 模組。

如果你使用 Babel 的 babel-preset-envbabel-preset-es2015, 檢查它們預先的設定。預設情況下, 它們將 ES 的 importexport 轉譯為 CommonJS 的 requiremodule.exports通過 { modules: false } 選項來禁用它。

與 TypeScript 相同:記得在你的 tsconfig.json 中設定 { "compilerOptions": { "module": "es2015" } }

擴充套件閱讀

優化圖片

圖片佔頁面大小的一半以上。 儘管它們不如 JavaScript 關鍵(例如,它們不會阻塞渲染),但仍然消耗了頻寬的一大部分。可以在 webpack 中使用 url-loadersvg-url-loaderimage-webpack-loader 來優化它們。

url-loader 將小的靜態檔案內聯進應用。沒有配置的話,它需要通過傳遞檔案,將它放在編譯後的打包 bundle 內並返回一個這個檔案的 url。然而,如果我們指定了 limit 選項,它會將檔案編碼成比無配置更小的 Base64 的資料 url 並將該 url 返回。這樣可以將圖片內聯進 JavaScript 程式碼中,並節省一次 HTTP 請求:

// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.(jpe?g|png|gif)$/,
        loader: 'url-loader',
        options: {
          // 小於 10kB(10240位元組)的內聯檔案
          limit: 10 * 1024,
        },
      },
    ],
  }
};
複製程式碼
// index.js
import imageUrl from './image.png';
// → 如果圖片小於 10kB, `imageUrl` 將包含
// 編碼後的圖片: 'data:image/png;base64,iVBORw0KGg…'
// → 如果圖片大於 10B,該 loader 將建立一個新檔案,
// 並且 `imageUrl` 將會包含它的 url: `/2fcd56a1920be.png`
複製程式碼

⭐️ 注意: 內聯圖片減少了單獨請求的數量,這是好的(即使通過 HTTP/2),但是增加了 bundle 和記憶體消耗的下載/解析時間。確保不要嵌入大的或者很多的圖片,否則增加的 bundle 時間可能超過內聯帶來的好處。

svg-url-loader 的工作原理類似於 url-loader – 除了它利用 URL encoding 而不是 Base64 對檔案編碼。對於 SVG 圖片這是有效的 – 因為 SVG 檔案恰好是純文字,這種編碼規模效應更加明顯:

// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.svg$/,
        loader: 'svg-url-loader',
        options: {
          // 小於 10kB(10240位元組)的內聯檔案
          limit: 10 * 1024,
          // 移除 url 中的引號
          // (在大多數情況下它們都不是必要的)
          noquotes: true,
        },
      },
    ],
  },
};
複製程式碼

⭐️ 注意: svg-url-loader 擁有改善 IE 瀏覽器支援的選項,但是在其他瀏覽器中更糟糕。如果你需要相容 IE 瀏覽器,設定 iesafe: true 選項

image-webpack-loader 會壓縮檢查到的所有圖片。它支援 JPG、PNG、GIF 和 SVG 格式的圖片,因此我們在碰到所有這些型別的圖片都會使用它。

這個 loader 不能將圖片嵌入應用,所以它必須和 url-loader 以及 svg-url-loader 一起使用。為了避免同時將它複製貼上到兩個規則中(一個針對 JPG/PNG/GIF 圖片, 另一個針對 SVG ),我們使用 enforce: 'pre' 作為單獨的規則涵蓋在這個 loader:

 // webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.(jpe?g|png|gif|svg)$/,
        loader: 'image-webpack-loader',
        // 這會應用該 loader,在其它之前
        enforce: 'pre',
      },
    ],
  },
};
複製程式碼

載入器的預設設定已經很好了 - 但是如果你想更進一步去配置它,參考外掛選項。要選擇指定選項,請檢視 Addy Osmani 的影象優化指南

擴充套件閱讀

優化依賴

平均一半以上的 Javascript 體積大小來源於依賴包,並且這其中的一部分可能都不是必要的。

例如,Lodash (自 v4.17.4 版本起) 增加了 72KB 的最小化程式碼到 bundle 中。但是如果你僅僅用到它的 20 種方法,那麼大約 65KB 的程式碼是無用的。

另一個例子是 Moment.js。2.19.1 版本有 223KB 大小,這是巨大的 - 截至 2017 年 10 月,一個頁面的 JavaScript 平均體積是 452 KB。然而,其中的 170KB 是本地化檔案。如果你沒有用到多語言版 Moment.js,這些檔案都將毫無目的地使 bundle 更臃腫。

所有的這些依賴都可以輕易地優化。我們已經在 GitHub 倉庫中收集了優化方法 - 來看一下!

為 ES 模組啟用模組串聯(又稱作用域提升)

⭐️ 注意: 如果在生產模式下使用 webpack 4,模組串聯已經啟用。自由地跳過該部分。

當你構建 bundle 時,webpack 將每個 module 包裝進一個函式中:

// index.js
import {render} from './comments.js';
render();

// comments.js
export function render(data, target) {
  console.log('Rendered!');
}
複製程式碼

// bundle.js (part  of)
/* 0 */
(function(module, __webpack_exports__, __webpack_require__) {

  "use strict";
  Object.defineProperty(__webpack_exports__, "__esModule", { value: true });
  var __WEBPACK_IMPORTED_MODULE_0__comments_js__ = __webpack_require__(1);
  Object(__WEBPACK_IMPORTED_MODULE_0__comments_js__["a" /* render */])();

}),
/* 1 */
(function(module, __webpack_exports__, __webpack_require__) {

  "use strict";
  __webpack_exports__["a"] = render;
  function render(data, target) {
    console.log('Rendered!');
  }

})
複製程式碼

過去,需要將 CommonJS/AMD 模組相互隔離。然而,這增加了每個模組的大小和效能開支。

webpack 2 引入了對 ES 模組的支援,不同於 CommonJS 和 AMD module,它們可以在不將每個模組都封裝進函式中的情況下進行打包。並且 webpack 3 使這樣的捆綁變得可能 - 通過模組連線。這是模組連線的工作原理:

// index.js
import {render} from './comments.js';
render();

// comments.js
export function render(data, target) {
  console.log('Rendered!');
}
複製程式碼

// 與前面的程式碼段不同,此包只有一個模組
// 它包含來自兩個檔案的程式碼

// bundle.js (部分; 通過 ModuleConcatenationPlugin 編譯)
/* 0 */
(function(module, __webpack_exports__, __webpack_require__) {

  "use strict";
  Object.defineProperty(__webpack_exports__, "__esModule", { value: true });

  // 級聯模組: ./comments.js
  function render(data, target) {
    console.log('Rendered!');
  }

  // 級聯模組: ./index.js
  render();

})
複製程式碼

看到不同了嗎?在普通繫結中,模組 0 需要模組 1 的 render。使用模組連線,require 只需用所需要的功能替換,模組 1 就被移除了。bundle 擁有更小的模組 – 以及更少的模組開支!

要在 webpack 4 中開啟這個功能,啟用 optimization.concatenateModules 選項即可:

// webpack.config.js (for webpack 4)
module.exports = {
  optimization: {
    concatenateModules: true,
  },
};
複製程式碼

webpack 3 中,使用 ModuleConcatenationPlugin 外掛:

// webpack.config.js (for webpack 3)
const webpack = require('webpack');

module.exports = {
  plugins: [
    new webpack.optimize.ModuleConcatenationPlugin(),
  ],
};
複製程式碼

⭐️ 注意: 想知道為什麼預設不啟用這個行為嗎?連線模組是很棒, 但是它增加了構建時間並破壞了模組熱替換。這是為什麼它只在生產下啟用。

擴充套件閱讀

使用 externals ,如果同時包含 webpack 和非 webpack 程式碼

你可能有一個大的專案,其中有些程式碼是用 webpack 編譯的,有些不是。類似於視訊託管網站,播放器小部件可能是 webpack 構建的,而周圍的頁面不是:

視訊託管網站螢幕快照

(完全隨機的視訊託管網站)

如果程式碼塊有公共的依賴,你可以共享它們以避免多次下載其程式碼。這是通過 webpack 的 externals 選項完成的 – 它通過變數或其它的額外匯入來替換模組。

如果依賴在 window 中可獲得

如果你的 non-webpack 程式碼依賴於某些依賴,這些依賴在 window 中可以作為變數獲得,將依賴名別名為變數名:

// webpack.config.js
module.exports = {
  externals: {
    'react': 'React',
    'react-dom': 'ReactDOM',
  },
};
複製程式碼

通過這個配置, webpack 不會打包 reactreact-dom 包。相反,它們將被替換成下面這樣的東西:

// bundle.js (part of)
(function(module, exports) {
  // 匯出 `window.React` 的模組。 沒有 `externals`,
  // 這個模組會包含整個的 React 包
  module.exports = React;
}),
(function(module, exports) {
  // 匯出 `window.React` 的模組。 沒有 `externals`,
  // 這個模組會包含整個的 ReactDOM 包
  module.exports = ReactDOM;
})
複製程式碼

如果依賴是當做 AMD 包被載入的情況

如果你的 non-webpack 程式碼沒有將依賴暴露於 window,事情就變得更加複雜。然而,如果這些非 webpack 程式碼將這些依賴作為 AMD 包,你仍然可以避免相同的程式碼載入兩次。

具體如何做呢,將 webpack 程式碼編譯成一個 AMD bundle ,同時將模組別名為庫的 URLs:

// webpack.config.js
module.exports = {
  output: { libraryTarget: 'amd' },

  externals: {
    'react': { amd: '/libraries/react.min.js' },
    'react-dom': { amd: '/libraries/react-dom.min.js' },
  },
};
複製程式碼

webpack 將把 bundle 包裝進 define() 並讓其依賴於這些 URLs:

// bundle.js (開始)
define(["/libraries/react.min.js", "/libraries/react-dom.min.js"], function () { … });
複製程式碼

如果 non-webpack 程式碼使用了相同的 URLs 來載入它的依賴,那麼這些檔案只會載入一次 - 額外的請求將使用載入器快取。

⭐️ 注意: Webpack 僅替換那些明確匹配 externals 物件的鍵的匯入。這意味著如果你編寫 import React from 'react/umd/react.production.min.js',這個庫不會從 bundle 中排除。這是合理的 - webpack 不知道 import 'react'import 'react/umd/react.production.min.js' 是否是同一個東西 - 所以保持小心。

擴充套件閱讀

總結

  • 如果使用 webpack 4,請啟用生產模式
  • 最小化你的程式碼,通過 bundle-level 最小化和 loader 選項
  • 移除僅在開發環境使用的程式碼,通過將 NODE_ENV 替換成 production
  • 使用 ES 模組以啟用 tree shaking
  • 壓縮圖片
  • 啟用特定依賴優化
  • 啟用模組連線
  • 使用 externals,如果這對你有效果

更多分享,請關注YFE:

【譯】Google - 使用 webpack 進行 web 效能優化(一):減小前端資源大小

下一篇:譯】Google - 使用 webpack 進行 web 效能優化(二):利用好持久化快取

相關文章