縮小前端大小

如何使用 webpack 盡可能縮小應用程式

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

使用正式版模式 (僅限 webpack 4)

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 就能執行樹狀圖搖晃。樹狀圖搖動是指 bundler 會遍歷整個依附元件樹狀圖,檢查所使用的依附元件,並移除未使用的依附元件。因此,如果您使用 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 (截至 v4.17.4) 會將 72 KB 的經過精簡的程式碼新增至套件。但如果您只使用其中 20 個方法,則約 65 KB 的經過精簡的程式碼就會完全無用。

另一個例子是 Moment.js。其 2.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 模組不同,ES 模組可以進行捆綁,而不需要用函式包裝每個模組。而 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