webpack 原理分析與性能優化(2w字精華)

webpack

webpack 最出色的功能之一就是,除了 JavaScript,還能夠經過 loader 引入 任何其餘類型的文件

Webpack 核心概念:

  • Entry(入口):Webpack 執行構建的第一步將從 Entry 開始,可抽象成輸入。
  • Module(模塊):在 Webpack 裏一切皆模塊,一個模塊對應着一個文件。Webpack 會從配置的 Entry 開始遞歸找出全部依賴的模塊。
  • Chunk(代碼塊):一個 Chunk 由多個模塊組合而成,用於代碼合併與分割。
  • Loader(模塊轉換器):用於把模塊原內容按照需求轉換成新內容。
  • Plugin(擴展插件):在 Webpack 構建流程中的特定時機會廣播出對應的事件,插件能夠監聽這些事件,並改變輸出結果

Webpack 執行流程

webpack從啓動到結束會依次執行如下流程:css

  1. 初始化:解析webpack配置參數,生產 Compiler 實例
  2. 註冊插件:調用插件的apply方法,給插件傳入compiler實例的引用,插件經過compiler調用Webpack提供的API,讓插件能夠監聽後續的全部事件節點。
  3. 開始編譯:讀取入口文件
  4. 解析文件:使用loader將文件解析成抽象語法樹 AST
  5. 生成依賴圖譜:找出每一個文件的依賴項(遍歷)
  6. 輸出:根據轉換好的代碼,生成 chunk
  7. 生成最後打包的文件

ps:因爲 webpack 是根據依賴圖動態加載全部的依賴項,因此,每一個模塊均可以明確表述自身的依賴,能夠避免打包未使用的模塊。前端

Babel

Babel 是一個工具鏈,主要用於將 ECMAScript 2015+ 版本的代碼轉換爲向後兼容的 JavaScript 語法,以便可以運行在當前和舊版本的瀏覽器或其餘環境中:vue

主要功能node

  • 語法轉換
  • 經過 Polyfill 方式在目標環境中添加缺失的特性 (經過 @babel/polyfill 模塊)
  • 源碼轉換 (codemods)

主要模塊jquery

  • @babel/parser:負責將代碼解析爲抽象語法樹
  • @babel/traverse:遍歷抽象語法樹的工具,咱們能夠在語法樹中解析特定的節點,而後作一些操做
  • @babel/core:代碼轉換,如ES6的代碼轉爲ES5的模式

Webpack 打包結果

在使用 webpack 構建的典型應用程序或站點中,有三種主要的代碼類型:webpack

  1. 源碼:你或你的團隊編寫的源碼。
  2. 依賴:你的源碼會依賴的任何第三方的 library 或 "vendor" 代碼。
  3. 管理文件:webpackruntime 使用 manifest 管理全部模塊的交互。

runtime:在模塊交互時,鏈接模塊所需的加載和解析邏輯。包括瀏覽器中的已加載模塊的鏈接,以及懶加載模塊的執行邏輯。git

manifest:當編譯器(compiler)開始執行、解析和映射應用程序時,它會保留全部模塊的詳細要點。這個數據集合稱爲 "Manifest",
當完成打包併發送到瀏覽器時,會在運行時經過 Manifest 來解析和加載模塊。不管你選擇哪一種模塊語法,那些 import 或 require 語句如今都已經轉換爲 webpack_require 方法,此方法指向模塊標識符(module identifier)。經過使用 manifest 中的數據,runtime 將可以查詢模塊標識符,檢索出背後對應的模塊。github

其中:web

  • importrequire 語句會轉換爲 __webpack_require__
  • 異步導入會轉換爲 require.ensure(在Webpack 4 中會使用 Promise 封裝)

比較

  • gulp 是任務執行器(task runner):就是用來自動化處理常見的開發任務,例如項目的檢查(lint)、構建(build)、測試(test)
  • webpack 是打包器(bundler):幫助你取得準備用於部署的 JavaScript 和樣式表,將它們轉換爲適合瀏覽器的可用格式。例如,JavaScript 能夠壓縮、拆分 chunk 和懶加載,

Webpack 優化

DllPlugin + DllReferencePlugin

爲了極大減小構建時間,進行分離打包。vue-router

DllReferencePlugin 和 DLL插件DllPlugin 都是在_另外_的 webpack 設置中使用的。

DllPlugin這個插件是在一個額外的獨立的 webpack 設置中建立一個只有 dll 的 bundle(dll-only-bundle)。 這個插件會生成一個名爲 manifest.json 的文件,這個文件是用來讓 DLLReferencePlugin 映射到相關的依賴上去的。

webpack.vendor.config.js

new webpack.DllPlugin({
    context: __dirname,
    name: "[name]_[hash]",
    path: path.join(__dirname, "manifest.json"),
  })

webpack.app.config.js

new webpack.DllReferencePlugin({
    context: __dirname,
    manifest: require("./manifest.json"),
    name: "./my-dll.js",
    scope: "xyz",
    sourceType: "commonjs2"
  })
CommonsChunkPlugin

經過將公共模塊拆出來,最終合成的文件可以在最開始的時候加載一次,便存到緩存中供後續使用。這個帶來速度上的提高,由於瀏覽器會迅速將公共的代碼從緩存中取出來,而不是每次訪問一個新頁面時,再去加載一個更大的文件。

若是把公共文件提取出一個文件,那麼當用戶訪問了一個網頁,加載了這個公共文件,再訪問其餘依賴公共文件的網頁時,就直接使用文件在瀏覽器的緩存,這樣公共文件就只用被傳輸一次。

entry: {
    vendor: ["jquery", "other-lib"], // 明確第三方庫
    app: "./entry"
  },
  plugins: [
    new webpack.optimize.CommonsChunkPlugin({
      name: "vendor",
      // filename: "vendor.js"
      // (給 chunk 一個不一樣的名字)

      minChunks: Infinity,
      // (隨着 entry chunk 愈來愈多,
      // 這個配置保證沒其它的模塊會打包進 vendor chunk)
    })
  ]

  // 打包後的文件
  <script src="vendor.js" charset="utf-8"></script>
  <script src="app.js" charset="utf-8"></script>
UglifyJSPlugin

基本上腳手架都包含了該插件,該插件會分析JS代碼語法樹,理解代碼的含義,從而作到去掉無效代碼、去掉日誌輸入代碼、縮短變量名等優化。

const UglifyJSPlugin = require('webpack/lib/optimize/UglifyJsPlugin');
  //...
  plugins: [
      new UglifyJSPlugin({
          compress: {
              warnings: false,  //刪除無用代碼時不輸出警告
              drop_console: true,  //刪除全部console語句,能夠兼容IE
              collapse_vars: true,  //內嵌已定義但只使用一次的變量
              reduce_vars: true,  //提取使用屢次但沒定義的靜態值到變量
          },
          output: {
              beautify: false, //最緊湊的輸出,不保留空格和製表符
              comments: false, //刪除全部註釋
          }
      })
  ]
ExtractTextPlugin + PurifyCSSPlugin

ExtractTextPlugin 從 bundle 中提取文本(CSS)到單獨的文件,PurifyCSSPlugin純化CSS(其實用處沒多大)

module.exports = {
    module: {
      rules: [
        {
          test: /\.css$/,
          loader: ExtractTextPlugin.extract({
            fallback: 'style-loader',
            use: [
              {
                loader: 'css-loader',
                options: {
                  localIdentName: 'purify_[hash:base64:5]',
                  modules: true
                }
              }
            ]
          })
        }
      ]
    },
    plugins: [
      ...,
      new PurifyCSSPlugin({
        purifyOptions: {
          whitelist: ['*purify*']
        }
      })
    ]
  };
DefinePlugin
DefinePlugin可以自動檢測環境變化,效率高效。

在前端開發中,在不一樣的應用環境中,須要不一樣的配置。如:開發環境的API Mocker、測試流程中的數據僞造、打印調試信息。若是使用人工處理這些配置信息,不只麻煩,並且容易出錯。

使用DefinePlugin配置的全局常量

注意,由於這個插件直接執行文本替換,給定的值必須包含字符串自己內的實際引號。一般,有兩種方式來達到這個效果,使用 ' "production" ', 或者使用 JSON.stringify('production')

new webpack.DefinePlugin({

        // 固然,在運行node服務器的時候就應該按環境來配置文件
        // 下面模擬的測試環境運行配置

        'process.env':JSON.stringify('dev'),
        WP_CONF: JSON.stringify('dev'),
    }),

測試DefinePlugin:編寫

if (WP_CONF === 'dev') {
        console.log('This is dev');
    } else {
        console.log('This is prod');
    }

打包後WP_CONF === 'dev'會編譯爲false

if (false) {
        console.log('This is dev');
    } else {
        console.log('This is prod');
    }
清除不可達代碼

當使用了DefinePlugin插件後,打包後的代碼會有不少冗餘。能夠經過UglifyJsPlugin清除不可達代碼

[
        new UglifyJsPlugin({
            uglifyOptions: {
            compress: {
                warnings: false, // 去除warning警告
                dead_code: true, // 去除不可達代碼
            },
            warnings: false
            }
        })
    ]

最後的打包打包代碼會變成console.log('This is prod')

附Uglify文檔:https://github.com/mishoo/Ugl...

使用DefinePlugin區分環境 + UglifyJsPlugin清除不可達代碼,以減輕打包代碼體積

HappyPack

HappyPack能夠開啓多進程Loader轉換,將任務分解給多個子進程,最後將結果發給主進程。

使用

exports.plugins = [
    new HappyPack({
      id: 'jsx',
      threads: 4,
      loaders: [ 'babel-loader' ]
    }),

    new HappyPack({
      id: 'styles',
      threads: 2,
      loaders: [ 'style-loader', 'css-loader', 'less-loader' ]
    })
  ];

  exports.module.rules = [
    {
      test: /\.js$/,
      use: 'happypack/loader?id=jsx'
    },

    {
      test: /\.less$/,
      use: 'happypack/loader?id=styles'
    },
  ]
ParallelUglifyPlugin

ParallelUglifyPlugin能夠開啓多進程壓縮JS文件

import ParallelUglifyPlugin from 'webpack-parallel-uglify-plugin';

  module.exports = {
    plugins: [
      new ParallelUglifyPlugin({
        test,
        include,
        exclude,
        cacheDir,
        workerCount,
        sourceMap,
        uglifyJS: {
        },
        uglifyES: {
        }
      }),
    ],
  };
BundleAnalyzerPlugin

webpack打包結果分析插件

const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
 
  module.exports = {
    plugins: [
      new BundleAnalyzerPlugin()
    ]
  }
外部擴展(externals)

這玩意不是插件,是wenpack的配置選項

externals 配置選項提供了「從輸出的 bundle 中排除依賴」的方法。相反,所建立的 bundle 依賴於那些存在於用戶環境(consumer's environment)中的依賴。此功能一般對 library 開發人員來講是最有用的,然而也會有各類各樣的應用程序用到它。

entry: {
    entry: './src/main.js',
    vendor: ['vue', 'vue-router', 'vuex']
  },
  externals: {
    // 從輸出的 bundle 中排除 echarts 依賴
    echarts: 'echarts',
  }
test & include & exclude

減少文件搜索範圍,從而提高速度

示例

{
    test: /\.css$/,
    include: [
      path.resolve(__dirname, "app/styles"),
      path.resolve(__dirname, "vendor/styles")
    ]
  }

Webpack HMR 原理解析

Hot Module Replacement(簡稱 HMR)

包含如下內容:

  1. 熱更新圖
  2. 熱更新步驟講解

第一步:webpack 對文件系統進行 watch 打包到內存中

webpack-dev-middleware 調用 webpack 的 api 對文件系統 watch,當文件發生改變後,webpack 從新對文件進行編譯打包,而後保存到內存中。

webpack 將 bundle.js 文件打包到了內存中,不生成文件的緣由就在於訪問內存中的代碼比訪問文件系統中的文件更快,並且也減小了代碼寫入文件的開銷。

這一切都歸功於memory-fs,memory-fs 是 webpack-dev-middleware 的一個依賴庫,webpack-dev-middleware 將 webpack 本來的 outputFileSystem 替換成了MemoryFileSystem 實例,這樣代碼就將輸出到內存中。

webpack-dev-middleware 中該部分源碼以下:

// compiler
  // webpack-dev-middleware/lib/Shared.js
  var isMemoryFs = !compiler.compilers &&
                  compiler.outputFileSystem instanceof MemoryFileSystem;
  if(isMemoryFs) {
      fs = compiler.outputFileSystem;
  } else {
      fs = compiler.outputFileSystem = new MemoryFileSystem();
  }
第二步:devServer 通知瀏覽器端文件發生改變

在啓動 devServer 的時候,sockjs) 在服務端和瀏覽器端創建了一個 webSocket 長鏈接,以便將 webpack 編譯和打包的各個階段狀態告知瀏覽器,最關鍵的步驟仍是 webpack-dev-server 調用 webpack api 監聽 compile的 done 事件,當compile 完成後,webpack-dev-server經過 _sendStatus 方法將編譯打包後的新模塊 hash 值發送到瀏覽器端。

// webpack-dev-server/lib/Server.js
  compiler.plugin('done', (stats) => {
    // stats.hash 是最新打包文件的 hash 值
    this._sendStats(this.sockets, stats.toJson(clientStats));
    this._stats = stats;
  });
  ...
  Server.prototype._sendStats = function (sockets, stats, force) {
    if (!force && stats &&
    (!stats.errors || stats.errors.length === 0) && stats.assets &&
    stats.assets.every(asset => !asset.emitted)
    ) { return this.sockWrite(sockets, 'still-ok'); }
    // 調用 sockWrite 方法將 hash 值經過 websocket 發送到瀏覽器端
    this.sockWrite(sockets, 'hash', stats.hash);
    if (stats.errors.length > 0) { this.sockWrite(sockets, 'errors', stats.errors); } 
    else if (stats.warnings.length > 0) { this.sockWrite(sockets, 'warnings', stats.warnings); }      else { this.sockWrite(sockets, 'ok'); }
  };
第三步:webpack-dev-server/client 接收到服務端消息作出響應

webpack-dev-server 修改了webpack 配置中的 entry 屬性,在裏面添加了 webpack-dev-client 的代碼,這樣在最後的 bundle.js 文件中就會接收 websocket 消息的代碼了。

webpack-dev-server/client 當接收到 type 爲 hash 消息後會將 hash 值暫存起來,當接收到 type 爲 ok 的消息後對應用執行 reload 操做。

在 reload 操做中,webpack-dev-server/client 會根據 hot 配置決定是刷新瀏覽器仍是對代碼進行熱更新(HMR)。代碼以下:

// webpack-dev-server/client/index.js
  hash: function msgHash(hash) {
      currentHash = hash;
  },
  ok: function msgOk() {
      // ...
      reloadApp();
  },
  // ...
  function reloadApp() {
    // ...
    if (hot) {
      log.info('[WDS] App hot update...');
      const hotEmitter = require('webpack/hot/emitter');
      hotEmitter.emit('webpackHotUpdate', currentHash);
      // ...
    } else {
      log.info('[WDS] App updated. Reloading...');
      self.location.reload();
    }
  }
第四步:webpack 接收到最新 hash 值驗證並請求模塊代碼

首先 webpack/hot/dev-server(如下簡稱 dev-server) 監聽第三步 webpack-dev-server/client 發送的 webpackHotUpdate 消息,調用 webpack/lib/HotModuleReplacement.runtime(簡稱 HMR runtime)中的 check 方法,檢測是否有新的更新。

在 check 過程當中會利用 webpack/lib/JsonpMainTemplate.runtime(簡稱 jsonp runtime)中的兩個方法 hotDownloadManifest 和 hotDownloadUpdateChunk。

hotDownloadManifest 是調用 AJAX 向服務端請求是否有更新的文件,若是有將發更新的文件列表返回瀏覽器端。該方法返回的是最新的 hash 值。

hotDownloadUpdateChunk 是經過 jsonp 請求最新的模塊代碼,而後將代碼返回給 HMR runtime,HMR runtime 會根據返回的新模塊代碼作進一步處理,多是刷新頁面,也多是對模塊進行熱更新。該 方法返回的就是最新 hash 值對應的代碼塊。

最後將新的代碼塊返回給 HMR runtime,進行模塊熱更新。

附:爲何更新模塊的代碼不直接在第三步經過 websocket 發送到瀏覽器端,而是經過 jsonp 來獲取呢?

個人理解是,功能塊的解耦,各個模塊各司其職,dev-server/client 只負責消息的傳遞而不負責新模塊的獲取,而這些工做應該有 HMR runtime 來完成,HMR runtime 才應該是獲取新代碼的地方。再就是由於不使用 webpack-dev-server 的前提,使用 webpack-hot-middleware 和 webpack 配合也能夠完成模塊熱更新流程,在使用 webpack-hot-middleware 中有件有意思的事,它沒有使用 websocket,而是使用的 EventSource。綜上所述,HMR 的工做流中,不該該把新模塊代碼放在 websocket 消息中。

第五步:HotModuleReplacement.runtime 對模塊進行熱更新

這一步是整個模塊熱更新(HMR)的關鍵步驟,並且模塊熱更新都是發生在HMR runtime 中的 hotApply 方法中

// webpack/lib/HotModuleReplacement.runtime
  function hotApply() {
      // ...
      var idx;
      var queue = outdatedModules.slice();
      while(queue.length > 0) {
          moduleId = queue.pop();
          module = installedModules[moduleId];
          // ...
          // remove module from cache
          delete installedModules[moduleId];
          // when disposing there is no need to call dispose handler
          delete outdatedDependencies[moduleId];
          // remove "parents" references from all children
          for(j = 0; j < module.children.length; j++) {
              var child = installedModules[module.children[j]];
              if(!child) continue;
              idx = child.parents.indexOf(moduleId);
              if(idx >= 0) {
                  child.parents.splice(idx, 1);
              }
          }
      }
      // ...
      // insert new code
      for(moduleId in appliedUpdate) {
          if(Object.prototype.hasOwnProperty.call(appliedUpdate, moduleId)) {
              modules[moduleId] = appliedUpdate[moduleId];
          }
      }
      // ...
  }

模塊熱更新的錯誤處理,若是在熱更新過程當中出現錯誤,熱更新將回退到刷新瀏覽器,這部分代碼在 dev-server 代碼中,簡要代碼以下:

module.hot.check(true).then(function(updatedModules) {
    if(!updatedModules) {
        return window.location.reload();
    }
    // ...
  }).catch(function(err) {
      var status = module.hot.status();
      if(["abort", "fail"].indexOf(status) >= 0) {
          window.location.reload();
      }
  });
第六步:業務代碼須要作些什麼?

當用新的模塊代碼替換老的模塊後,可是咱們的業務代碼並不能知道代碼已經發生變化,也就是說,當 hello.js 文件修改後,咱們須要在 index.js 文件中調用 HMR 的 accept 方法,添加模塊更新後的處理函數,及時將 hello 方法的返回值插入到頁面中。代碼以下

// index.js
  if(module.hot) {
      module.hot.accept('./hello.js', function() {
          div.innerHTML = hello()
      })
  }

關注微信號"前端進階課,文章將不按期發送閱讀紅包,敬請期待

相關文章
相關標籤/搜索