前端效能精進(七)——構建

2023-04-03 09:00:46

  前端構建是指通過工具自動化地處理那些繁瑣、重複而有意義的任務。

  這些任務包括語言編譯、檔案壓縮、模組打包、影象優化、單元測試等一切需要對原始碼進行處理的工作。

  在將這類任務交給工具後,開發人員被解放了生產力,得以集中精力去編寫程式碼業務,提高工作效率。

  構建工具從早期基於流的 gulp,再到靜態模組打包器 webpack,然後到現在炙手可熱的 Vite,一直在追求更極致的效能和體驗。

  構建工具的優化很大一部分其實就是對原始碼的優化,例如壓縮、合併、Tree Shaking、Code Splitting 等。

一、減少尺寸

  減少檔案尺寸的方法除了使用演演算法壓縮檔案之外,還有其他優化方式也可以減小檔案尺寸,例如優化編譯、打包等。

1)編譯

  在現代前端業務開發中,對指令碼的編譯是必不可少的,例如 ES8 語法通過 Babel 編譯成 ES5,Sass 語法編譯成 CSS 等。

  在編譯完成後,JavaScript 或 CSS 檔案的尺寸可能就會有所增加。

  關於指令碼檔案,若不需要相容古老的瀏覽器,那推薦直接使用新語法,不要再編譯成 ES5 語法。

  例如 ES6 的 Symbol 型別編譯成 ES5 語法,如下所示,程式碼量激增。

let func = () => {
  let value = Symbol();
  return typeof value;
};
// 經過 Babel 編譯後的程式碼
function _typeof(obj) {
  "@babel/helpers - typeof";
  return (
    (_typeof =
      "function" == typeof Symbol && "symbol" == typeof Symbol.iterator
        ? function (obj) {
            return typeof obj;
          }
        : function (obj) {
            return obj && "function" == typeof Symbol &&
              obj.constructor === Symbol && obj !== Symbol.prototype
              ? "symbol" : typeof obj;
          }),
    _typeof(obj)
  );
}
var func = function func() {
  var value = Symbol();
  return _typeof(value);
};

  為了增加編譯效率,需要將那些不需要編譯的目錄或檔案排除在外。

  例如 node_modules 中所依賴的包,設定如下所示。

module.exports = {
  module: {
    rules: [
      {
        test: /\.(js|jsx)$/,
        use: "babel-loader",
        exclude: /node_modules/
      },
    ]
  }
};

2)打包

  在 webpack 打包生成的 bundle 檔案中,除了業務程式碼和參照的第三方庫之外,還會包含管理模組互動的 runtime。

  runtime 是一段輔助程式碼,在模組互動時,能連線它們所需的載入和解析邏輯,下面是通過 webpack 4.34 生成的 runtime。

/******/ (function(modules) { // webpackBootstrap
/******/     // The module cache
/******/     var installedModules = {};
/******/
/******/     // The require function
/******/     function __webpack_require__(moduleId) {
/******/
/******/         // Check if module is in cache
/******/         if(installedModules[moduleId]) {
/******/             return installedModules[moduleId].exports;
/******/         }
/******/         // Create a new module (and put it into the cache)
/******/         var module = installedModules[moduleId] = {
/******/             i: moduleId,
/******/             l: false,
/******/             exports: {}
/******/         };
/******/
/******/         // Execute the module function
/******/         modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
/******/
/******/         // Flag the module as loaded
/******/         module.l = true;
/******/
/******/         // Return the exports of the module
/******/         return module.exports;
/******/     }
/******/
/******/
/******/     // expose the modules object (__webpack_modules__)
/******/     __webpack_require__.m = modules;
/******/
/******/     // expose the module cache
/******/     __webpack_require__.c = installedModules;
/******/
/******/     // define getter function for harmony exports
/******/     __webpack_require__.d = function(exports, name, getter) {
/******/         if(!__webpack_require__.o(exports, name)) {
/******/             Object.defineProperty(exports, name, { enumerable: true, get: getter });
/******/         }
/******/     };
/******/
/******/     // define __esModule on exports
/******/     __webpack_require__.r = function(exports) {
/******/         if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
/******/             Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
/******/         }
/******/         Object.defineProperty(exports, '__esModule', { value: true });
/******/     };
/******/
/******/     // create a fake namespace object
/******/     // mode & 1: value is a module id, require it
/******/     // mode & 2: merge all properties of value into the ns
/******/     // mode & 4: return value when already ns object
/******/     // mode & 8|1: behave like require
/******/     __webpack_require__.t = function(value, mode) {
/******/         if(mode & 1) value = __webpack_require__(value);
/******/         if(mode & 8) return value;
/******/         if((mode & 4) && typeof value === 'object' && value && value.__esModule) return value;
/******/         var ns = Object.create(null);
/******/         __webpack_require__.r(ns);
/******/         Object.defineProperty(ns, 'default', { enumerable: true, value: value });
/******/         if(mode & 2 && typeof value != 'string') for(var key in value) __webpack_require__.d(ns, key, function(key) { return value[key]; }.bind(null, key));
/******/         return ns;
/******/     };
/******/
/******/     // getDefaultExport function for compatibility with non-harmony modules
/******/     __webpack_require__.n = function(module) {
/******/         var getter = module && module.__esModule ?
/******/             function getDefault() { return module['default']; } :
/******/             function getModuleExports() { return module; };
/******/         __webpack_require__.d(getter, 'a', getter);
/******/         return getter;
/******/     };
/******/
/******/     // Object.prototype.hasOwnProperty.call
/******/     __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };
/******/
/******/     // __webpack_public_path__
/******/     __webpack_require__.p = "";
/******/
/******/
/******/     // Load entry module and return exports
/******/     return __webpack_require__(__webpack_require__.s = 0);
/******/ })
/************************************************************************/

  在程式碼中定義了一個載入模組的函數:__webpack_require__(),其引數是模組識別符號,還為它新增了多個私有屬性。

  在編寫的原始碼中所使用的 import、define() 或 require() 等模組匯入語法,都會被轉換成 __webpack_require__() 函數。

  也就是說,webpack 自己編寫 polyfill 來實現 CommonJS、ESM 等模組語法。

  這裡推薦另一個模組打包工具:rollup,它預設使用 ESM 模組標準,而非 CommonJS 和 AMD。

  所以,rollup 打包出的指令碼比較乾淨(如下所示),適合打包各類庫,React、Vue 等專案都是用 rollup 打包。

import { age } from './maths.js';
console.log(age + 1)
console.log(1234)
// maths.js 檔案中的程式碼
export const name = 'strick'
export const age = 30

// 經過 rollup 打包後的程式碼
const age = 30;
console.log(age + 1);
console.log(1234);

  目前,支援 ES6 語法的瀏覽器已達到 98.35%,如下圖所示,若不需要相容 IE6~IE10 等古老瀏覽器的話,rollup 是打包首選。

  

3)壓縮

  目前市面上有許多成熟的庫可對不同型別的檔案進行壓縮。

  例如壓縮 HTML 的 html-minifier,壓縮 JavaScript 的 uglify-js,壓縮 CSS 的 cssnano,壓縮影象的 imagemin

  壓縮後的檔案會被去除換行和空格,像指令碼還會修改變數名,部分流程替換成三目預算,刪除註釋或列印語句等。

  webpack 和 rollup 都支援外掛的擴充套件,在將上述壓縮指令碼封裝到外掛中後,就能在構建的過程中對檔案進行自動壓縮。

  以 webpack 的外掛為例,已提供了 ImageMinimizerPluginOptimizeCssPluginUglifyjsPlugin 等壓縮外掛,生態圈非常豐富。

4)Tree Shaking

  Tree Shaking 是一個術語,用於移除 JavaScript 中未被參照的死程式碼,依賴 ES6 模組語法的靜態結構特性。

  在執行 Tree Shaking 後,在檔案中就不存在冗餘的依賴和程式碼。在下面的範例中,ES 模組可以只匯入所需的 func1() 函數。

export function func1() {
  console.log('strick')
}
export function func2() {
  console.log('freedom')
}
// maths.js 檔案中的程式碼
import { func1 } from './maths.js';
func1();

// 經過 Tree Shaking 後的程式碼
function func1() {
  console.log('strick');
}
func1();

  Tree Shaking 最先在 rollup 中出現,webpack 在 2.0 版本中也引入了此概念。

5)Scope Hoisting

  Scope Hoisting 是指作用域提升,具體來說,就是在分析出模組之間的依賴關係後,將那些只被參照了一次的模組合併到一個函數中。

  下面是一個簡單的範例,action() 函數直接被注入到參照它的模組中。

import action from './maths.js';
const value = action();
// 經過 Scope Hoisting 後的程式碼
(function() {
  var action = function() { };
  var value = action();
});

  注意,由於 Scope Hoisting 依賴靜態分析,因此需要使用 ES6 模組語法。

  webpack 4 以上的版本可以在 optimization.concatenateModules 中設定 Scope Hoisting 的啟用狀態。

  比起常規的打包,在經過 Scope Hoisting 後,指令碼尺寸將變得更小。

二、合併打包

  模組打包器最重要的一個功能就是將分散在各個檔案中的程式碼合併到一起,組成一個檔案。

1)Code Splitting

  在實際開發中,會參照各種第三方庫,若將這些庫全部合併在一起,那麼這個檔案很有可能非常龐大,產生效能問題。

  常用的優化手段是 Code Splitting,即程式碼分離,將程式碼拆成多塊,分離到不同的檔案中,這些檔案既能按需載入,也能被瀏覽器快取。

  不僅如此,程式碼分離還能去除重複程式碼,減少檔案體積,優化載入時間。

  Vue 內建了一條命令,可以檢視每個指令碼的尺寸以及內部依賴包的尺寸。

  在下圖中,vendors.js 的原始尺寸是 3.76M,gzipped 壓縮後的尺寸是 442.02KB,比較大的包是 lottie、swiper、moment、lodash 等。

  

  這類比較大的包可以再單獨剝離,不用全部聚合在 vendors.js 中。

  在 vue.config.js 中,設定 config.optimization.splitChunks(),如下所示,引數含義可參考 SplitChunksPlugin 外掛。

config.optimization.splitChunks(
      {
        cacheGroups: {
          vendors: {
            name: 'chunk-vendors',
            test: /[\\/]node_modules[\\/]/,
            priority: -10,
            chunks: 'initial'
          },
          lottie: {
            name: 'chunk-lottie',
            test: /[\\/]node_modules[\\/]lottie-web[\\/]/,
            chunks: 'all',
            priority: 3,
            reuseExistingChunk: true,
            enforce: true
          },
          swiper: {
            name: 'chunk-swiper',
            test: /[\\/]node_modules[\\/][email protected]@swiper[\\/]/,
            chunks: 'all',
            priority: 3,
            reuseExistingChunk: true,
            enforce: true
          },
          lodash: {
            name: 'chunk-lodash',
            test: /[\\/]node_modules[\\/]lodash[\\/]/,
            chunks: 'all',
            priority: 3,
            reuseExistingChunk: true,
            enforce: true
          }
        }
      }
    )

  在經過一頓初步操作後,原始尺寸降到 2.4M,gzipped 壓縮後的尺寸是 308.64KB,比之前少了 100 多 KB,如下圖所示。

  

  其實有時候只是使用了開源庫的一個小功能,若不復雜,那完全可以自己用程式碼實現,這樣就不必依賴那個大包了。

  例如常用的 lodashunderscore,都是些短小而實用的工具方法,只要單獨提取並修改成相應的程式碼(參考此處),就能避免將整個庫引入。

2)資源內聯

  資源內聯會讓檔案尺寸變大,但是會減少網路通訊。

  像移動端螢幕適配指令碼,就比較適合內聯到 HTML 中,因為這類指令碼要最先執行,以免影響後面樣式的計算。

  若是通過域名請求,當請求失敗時,整個行動端頁面的佈局將是錯位的。

  webpack 的 InlineSourcePlugin 就提供了 JavaScript 和 CSS 的內聯功能。

  將小影象轉換成 Data URI 格式,也是內聯的一種應用,同樣也是減少通訊次數,但檔案是肯定會大一點。

  還有一種內聯是為資源增加破快取的隨機引數,以免讀取到舊內容。

  隨機引數既可以包含在檔名中,也可以包含在 URL 地址中,如下所示。

<script src="/js/chunk-vendors.e35b590f.js"></script>

  在 webpack.config.js 中,有個 output 欄位,用於設定輸出的資訊。

  它的 filename 屬性可宣告輸出的檔名,可以設定成唯一識別符號,如下所示。

module.exports = {
  output: {
    filename: "[name].[hash].bundle.js"
  }
};

總結

  在構建之前,也可以做一些前置優化。

  例如對瀏覽器相容性要求不高的場景,可以將編譯指令碼選擇 ES6 語法,用 rollup 打包。

  還可以將一些庫中的簡單功能單獨實現,以免引入整個庫。這部分優化後,打包出來的尺寸肯定會比原先小。

  在構建的過程中,可以對檔案進行壓縮、Tree Shaking 和 Scope Hoisting,以此來減小檔案尺寸。

  在合併時,可以將那些第三方庫提取到一起,組成一個單獨的檔案,這些檔案既能按需載入,也能被瀏覽器快取。

  資源內聯是另一種優化手段,雖然檔案尺寸會變大,但是能得到通訊次數變少,讀取的檔案是最新內容等收益。