[前端進階課] 構建自己的 webpack 知識體系

webpack

webpack 最出色的功能之一就是,除了 JavaScript,還可以通過 loader 引入任何其他類型的文件

Webpack 核心概念:

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

配置項

  1. 入口 Entry
entry: {
  a: "./app/entry-a",
  b: ["./app/entry-b1", "./app/entry-b2"]
},

多入口可以通過 HtmlWebpackPlugin 分開注入

plugins: [
  new HtmlWebpackPlugin({
    chunks: ['a'],
    filename: 'test.html',
    template: 'src/assets/test.html'
  })
]
  1. 出口 Output

修改路徑相關

  • publicPath:並不會對生成文件的目錄造成影響,主要是對你的頁面裏面引入的資源的路徑做對應的補全
  • filename:能修改文件名,也能更改文件目錄

導出庫相關

  • library: 導出庫的名稱
  • libraryTarget: 通用模板定義方式
  1. 模塊 Module

webpack 一切皆模塊,配置項 Module,定義模塊的各種操作,

Module 主要配置:

  • loader: 各種模塊轉換器
  • extensions:使用的擴展名
  • alias:別名、例如:vue-cli 常用的 @ 出自此處
  1. 其他
  • plugins: 插件列表
  • devServer:開發環境相關配置,譬如 proxy
  • externals:打包排除模塊
  • target:包應該運行的環境,默認 web

Webpack 執行流程

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

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

ps:由於 webpack 是根據依賴圖動態加載所有的依賴項,所以,每個模塊都可以明確表述自身的依賴,可以避免打包未使用的模塊。

Babel

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

Babel 內部所使用的語法解析器是 Babylon

主要功能

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

主要模塊

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

Webpack 打包結果

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

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

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

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

其中:

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

比較

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

實現一個 loader

loader 就是一個js文件,它導出了一個返回了一個 buffer 或者 string 的函數;

譬如:

// log-loader.js
module.exports = function (source) {
  console.log('test...', source)
  return source
}

在 use 時,如果 log-loader 並沒有在 node_modules 中,那麼可以使用路徑導入。

實現一個 plugin

plugin: 是一個含有 apply 方法的

譬如:

class DemoWebpackPlugin {
    constructor () {
        console.log('初始化 插件')
    }
    apply (compiler) {
    }
}

module.exports = DemoWebpackPlugin

apply 方法中接收一個 compiler 參數,也就是 webpack實例。由於該參數的存在 plugin 可以很好的運用 webpack 的生命週期鉤子,在不同的時間節點做一些操作。

Webpack 優化概況

Webpack 加快打包速度的方法

  1. 使用 includeexclude 加快文件查找速度
  2. 使用 HappyPack 開啓多進程 Loader 轉換
  3. 使用 ParallelUglifyPlugin 開啓多進程 JS 壓縮
  4. 使用 DllPlugin + DllReferencePlugin 分離打包
    1. 項目代碼 分離打包
    2. 需要 dll 映射文件
  5. 配置緩存(插件自帶 loader,不支持的可以用 cache-loader

Webpack 加快代碼運行速度方法

  1. 代碼壓縮
  2. 抽離公共模塊
  3. 懶加載模塊
  4. 將小圖片轉成 base64 以減少請求
  5. 預取(prefetch) || 預加載(preload)
  6. 精靈圖
  7. webpack-bundle-analyzer 代碼分析

Webpack 優化細節

webpack 4.6.0+增加了對預取和預加載的支持。

動態導入

  import(/* webpackChunkName: "lodash" */ 'lodash')

  // 註釋中的使用webpackChunkName。
  // 這將導致我們單獨的包被命名,lodash.bundle.js
  // 而不是just [id].bundle.js。

預取(prefetch):將來可能需要一些導航資源

  • 只要父chunk加載完成,webpack就會添加 prefetch
  import(/* webpackPrefetch: true */ 'LoginModal');

  // 將<link rel="prefetch" href="login-modal-chunk.js">其附加在頁面的開頭

預加載(preload):當前導航期間可能需要資源

  • preload chunk 會在父 chunk 加載時,以並行方式開始加載
  • 不正確地使用 webpackPreload 會有損性能,
  import(/* webpackPreload: true */ 'ChartingLibrary');

  // 在加載父 chunk 的同時
  // 還會通過 <link rel="preload"> 請求 charting-library-chunk
DllPlugin + DllReferencePlugin

爲了極大減少構建時間,進行分離打包。

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/UglifyJS2

使用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()
    ]
  }
test & include & exclude

減小文件搜索範圍,從而提升速度

示例

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

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

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

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

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()
      })
  }

最後

  1. 覺得有用的請點個贊
  2. 本文內容出自 https://github.com/zhongmeizhi/FED-note
  3. 歡迎關注公衆號「前端進階課」認真學前端,一起進階。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章