縮小前端大小

如何使用 Webpack 調整應用程式的大小

在最佳化應用程式時,第一件要做的事就是盡可能縮小應用程式大小。以下說明如何使用 webpack。

Webpack 4 推出了新的 mode 標記。您可以將這個標記設為 'development''production',以提示您要針對特定環境建構應用程式的 Webpack:

// webpack.config.js
module.exports = {
  mode: 'production',
};

建構正式版應用程式時,請務必啟用 production 模式。這樣一來,Webpack 就會套用最佳化功能,例如縮減程式碼、移除程式庫中的開發專用程式碼等

延伸閱讀

啟用精簡功能

壓縮是指透過移除多餘的空格、縮短變數名稱等方式壓縮程式碼時。如下所示:

// Original code
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;
}

↓。

// Minified code
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 支援兩種程式碼壓縮方式:套件層級壓縮載入器專屬選項。並同時使用這兩項工具。

套件層級壓縮

套件層級壓縮作業會在編譯後壓縮整個套件。運作方式如下:

  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. 壓縮器會將壓縮器壓縮成下列約略內容:

    // minified 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 中,套件層級的壓縮功能會自動啟用,無論是在實際工作環境或沒有設定的情況下皆然。會在內部使用 UglifyJS 壓縮工具。(如果您需要停用精簡功能,只要使用開發人員模式或將 false 傳遞至 optimization.minimize 選項即可)。

在 webpack 3 中:您必須直接使用 UglifyJS 外掛程式。外掛程式隨附 Webpack。如要啟用,請將外掛程式新增至設定的 plugins 區段:

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

module.exports = {
  plugins: [
    new webpack.optimize.UglifyJsPlugin(),
  ],
};

載入器相關選項

另一種壓縮程式碼的方法是載入器專屬選項 (載入器簡介)。您可以利用載入器選項,對壓縮器無法壓縮的內容進行壓縮。舉例來說,如果您使用 css-loader 匯入 CSS 檔案,檔案會編譯為字串:

/* comments.css */
.comment {
  color: black;
}
// minified bundle.js (part of)
exports=module.exports=__webpack_require__(1)(),
exports.push([module.i,".comment {\r\n  color: black;\r\n}",""]);

這個代碼是字串,因此壓縮器無法壓縮。為了壓縮檔案內容,我們必須設定載入器才能執行:

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

延伸閱讀

指定NODE_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 (for webpack 4)
module.exports = {
  optimization: {
    nodeEnv: 'production',
    minimize: true,
  },
};

在 webpack 3 中,請改用 DefinePlugin

// webpack.config.js (for 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. 然後,縮減器會移除所有這類 if 分支,因為 "production" !== 'production' 一律為 false,而外掛程式會瞭解這些分支版本中的程式碼永遠不會執行:

    // 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 模組

減少前端大小的另一個方法是使用 ES 模組

使用 ES 模組時,Webpack 能夠進行樹軸作業。樹狀結構觀察是指組合器掃遍整個依附元件樹狀結構、檢查使用的依附元件,以及移除未使用的依附元件時。因此,如果您使用 ES 模組語法,Webpack 就可以刪除未使用的程式碼:

  1. 您寫入含有多個匯出項目的檔案,但應用程式只使用其中一個:

    // comments.js
    export const render = () => { return 'Rendered!'; };
    export const commentRestEndpoint = '/rest/comments';
    
    // index.js
    import { render } from './comments.js';
    render();
    
  2. Webpack 瞭解 commentRestEndpoint 未使用,且不會在套件中產生獨立的匯出點:

    // bundle.js (part that corresponds to 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. 減號會移除未使用的變數:

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

即使程式庫是使用 ES 模組編寫,也能使用這項功能。

不過,你不需要使用精確的 webpack 的內建壓縮器 (UglifyJsPlugin)。 任何支援無效程式碼移除功能的壓縮工具 (例如 Babel Minify 外掛程式Google Closure Compiler 外掛程式) 都會正常運作。

延伸閱讀

最佳化圖片

圖片佔頁面大小的超過一半。雖然這些元素不像 JavaScript 那麼重要 (例如不會阻斷轉譯),但仍會佔用大量頻寬。使用 url-loadersvg-url-loaderimage-webpack-loader 在 Webpack 中對這些項目進行最佳化。

url-loader 會將小型靜態檔案內嵌至應用程式。如果沒有設定,它會取得傳遞的檔案,將檔案放在已編譯的套件旁邊,並傳回該檔案的網址。不過,如果我們指定 limit 選項,系統會將小於此限制的檔案編碼為 Base64 資料網址,並傳回此網址。這會將圖片內嵌至 JavaScript 程式碼,並儲存 HTTP 要求:

// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.(jpe?g|png|gif)$/,
        loader: 'url-loader',
        options: {
          // Inline files smaller than 10 kB (10240 bytes)
          limit: 10 * 1024,
        },
      },
    ],
  }
};
// index.js
import imageUrl from './image.png';
// → If image.png is smaller than 10 kB, `imageUrl` will include
// the encoded image: 'data:image/png;base64,iVBORw0KGg…'
// → If image.png is larger than 10 kB, the loader will create a new file,
// and `imageUrl` will include its url: `/2fcd56a1920be.png`

svg-url-loader 的運作方式與 url-loader 類似,差別在於它是透過網址編碼來編碼檔案,而非 Base64 編碼。這對 SVG 圖片很有幫助,因為 SVG 檔案只是純文字,因此這種編碼方式更節省空間。

module.exports = {
  module: {
    rules: [
      {
        test: /\.svg$/,
        loader: "svg-url-loader",
        options: {
          limit: 10 * 1024,
          noquotes: true
        }
      }
    ]
  }
};

image-webpack-loader 會壓縮破壞的圖片。它支援 JPG、PNG、GIF 和 SVG 圖片,因此我們會將其用於所有這些類型的圖片。

此載入器不會將圖片嵌入應用程式,因此必須與 url-loadersvg-url-loader 配對。為避免將其複製貼上至兩個規則 (一個用於 JPG/PNG/GIF 圖片,另一個用於 SVG 圖片),我們會使用 enforce: 'pre' 將此載入器納入為個別規則:

// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.(jpe?g|png|gif|svg)$/,
        loader: 'image-webpack-loader',
        // This will apply the loader before the other ones
        enforce: 'pre'
      }
    ]
  }
};

載入器的預設設定已可使用,但如果您想進一步設定,請參閱外掛程式選項。如要選擇應指定的選項,請參考 Addy Osmani 提供的絕佳圖片最佳化指南

延伸閱讀

最佳化依附元件

JavaScript 平均大小的一半超過一半,來自依附元件,可能不必要而有一部分大小。

舉例來說,Lodash (自 4.17.4 版起) 會在套件中加入 72 KB 的壓縮程式碼。但如果您只使用其中 20 個方法,那麼約 65 KB 的經過精簡的程式碼就會完全沒有作用。

另一個例子是 Moment.js2.19.1 版採用 223 KB 的壓縮程式碼,非常龐大,因為網頁上的 JavaScript 平均大小在 2017 年 10 月為 452 KB。不過,其中 170 KB 是本地化檔案。如果您不搭配多種語言使用 Moment.js,這些檔案就會以無目的方式佔用套件。

這些依附元件都可以輕鬆最佳化。我們已收集到 GitHub 存放區中的最佳化方法,歡迎一探究竟

為 ES 模組啟用模組串接功能 (又稱為範圍提升)

當您建構套件時,Webpack 會將每個模組納入一個函式中:

// 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 模組不同,您可以封裝,不必為每個模組納入函式。Webpack 3 透過模組串連來實現這樣的組合。以下是串連模組的功能:

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

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

↓。

// Unlike the previous snippet, this bundle has only one module
// which includes the code from both files

// bundle.js (part of; compiled with ModuleConcatenationPlugin)
/* 0 */
(function(module, __webpack_exports__, __webpack_require__) {
  "use strict";
  Object.defineProperty(__webpack_exports__, "__esModule", { value: true });

  // CONCATENATED MODULE: ./comments.js
    function render(data, target) {
    console.log('Rendered!');
  }

  // CONCATENATED MODULE: ./index.js
  render();
})

看出差異了嗎?在純套件中,模組 0 要求模組 1 中的 render。透過模組串連功能,require 會直接替換為必要函式,模組 1 則已移除。套件中的模組較少,因此模組的額外負擔也較少!

如要開啟這項行為,請在 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()
  ]
};

延伸閱讀

如果您同時有 webpack 和非 webpack 程式碼,請使用 externals

您可能有大型專案,其中有些程式碼是使用 webpack 編譯,有些則不是。例如影片代管網站,其中的播放器小工具可能會使用 webpack 建構,而周圍的網頁可能不會:

影片代管網站的螢幕截圖
(完全隨機的影片代管網站)

如果兩個程式碼都有共同的依附元件,您可以共用這些依附元件,避免多次下載程式碼。這項操作是透過 Webpack 的 externals 選項完成,該選項會將模組替換為變數或其他外部匯入項目。

如果 window 中提供依附元件

如果您的非 Webpack 程式碼依賴於 window 中做為變數的依附元件,請將別名依附元件名稱設為變數名稱:

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

使用這項設定後,webpack 就不會將 reactreact-dom 套件打包。而是會替換成類似以下的內容:

// bundle.js (part of)
(function(module, exports) {
  // A module that exports `window.React`. Without `externals`,
  // this module would include the whole React bundle
  module.exports = React;
}),
(function(module, exports) {
  // A module that exports `window.ReactDOM`. Without `externals`,
  // this module would include the whole ReactDOM bundle
  module.exports = ReactDOM;
})

依附元件是否會載入為 AMD 套件

如果您的非 webpack 程式碼不會將依附元件公開到 window,則比較複雜。不過,如果非 Webpack 程式碼以 AMD 套件的形式使用這些依附元件,您還是可以避免載入相同程式碼兩次。

如要這麼做,請將 webpack 程式碼編譯為 AMD 套件,並將別名模組編譯為程式庫網址:

// webpack.config.js
module.exports = {
  output: {
    libraryTarget: 'amd'
  },
  externals: {
    'react': {
      amd: '/libraries/react.min.js'
    },
    'react-dom': {
      amd: '/libraries/react-dom.min.js'
    }
  }
};

Webpack 會將套件包裝成 define(),並依附於下列網址:

// bundle.js (beginning)
define(["/libraries/react.min.js", "/libraries/react-dom.min.js"], function () {  });

如果非 Webpack 程式碼使用相同的網址載入其依附元件,則這些檔案只會載入一次,其他要求則會使用載入器快取。

延伸閱讀

總結

  • 如果使用 webpack 4,請啟用正式版模式
  • 使用套件層級的縮減器和載入器選項,縮減程式碼
  • NODE_ENV 替換為 production,移除開發專用程式碼
  • 使用 ES 模組啟用樹軸
  • 壓縮圖片
  • 套用特定依附元件的最佳化功能
  • 啟用模組連接
  • 如有需要,請使用 externals