淺嘗webpack

吐槽一下

webpack 自出現時,一直備受青睞。作爲強大的打包工具,它只是出現在項目初始或優化的階段。如果沒有參與項目的構建,接觸的機會幾乎爲零。即使是參與了,但也會因爲項目的週期短,從網上東拼西湊草草了事。

縱觀網上的 webpack 教程,要麼是蜻蜓點水,科普了一些常規配置項;要麼是過於深入原理,於實際操作無益。最近一段時間,我把 webpack 的官方文檔來來回回地看了幾遍,結果發現,真香。中文版的官方文檔,通俗易懂,很感謝翻譯組的辛勤奉獻。看完之後,雖然達不到爐火純青的地步,但也不會捉襟見肘,疲於應付。

對於這種工具類的博文,依然沿襲 用Type馴化JavaScript 的風格,串聯各個概念。至於細節,就是官方文檔的事了。

本文基於 webpack v4.31.0 版本。

Tapable

Tapable 是一個小型的庫,允許你對一個 javascript 模塊添加和應用插件。它可以被繼承或混入到其他模塊中。類似於 NodeJS 的 EventEmitter 類,專注於自定義事件的觸發和處理。除此之外,Tapable 還允許你通過回調函數的參數,訪問事件的“觸發者(emittee)”或“提供者(producer)”。

tapable 是 webpack 的核心,webpack 中的很多對象(compile, compilation等)都擴展自tapable,包括 webpack 也是 tapable 的實例。擴展自 tapable 的對象內部會有很多鉤子,它們貫穿了 webpack 構建的整個過程。我們可以利用這些鉤子,在其被觸發時,做一些我們想做的事情。

拋開 webpack 不談,先看看 tapable 的簡單使用。

// Main.js
const {
  SyncHook
} = require("tapable");
class Main {
  constructor(options) {
    this.hooks = {
      init: new SyncHook(['init'])
    };
    this.plugins = options.plugins;
    this.init();
  }
  init() {
    this.beforeInit();
    if (Array.isArray(this.plugins)) {
      this.plugins.forEach(plugin => {
        plugin.apply(this);
      })
    }
    this.hooks.init.call('初始化中。。。');
    this.afterInit();
  }
  beforeInit() {
    console.log('初始化前。。。');
  }
  afterInit() {
    console.log('初始化後。。。');
  }
}
module.exports = Main;
// MyPlugin.js
class MyPlugin {
  apply(main) {
    main.hooks.init.tap('MyPlugin', param => {
      console.log('init 鉤子,做些啥;', param);
    });
  }
};
module.exports = MyPlugin;
// index.js
const Main = require('./Main');
const MyPlugin = require('./MyPlugin');
let myPlugin = new MyPlugin();
new Main({ plugins: [myPlugin] });

// 初始化前。。。
// init 鉤子,做些啥; 初始化中。。。
// 初始化後。。。

理解起來很簡單,就是在 init 處觸發鉤子,this.hooks.init.call(params) 類似於我們熟悉的 EventEmitter.emit('init', params)main.hooks.init.tap 類似於 EventEmitter.on('init', callback),在 init鉤子上綁定一些我們想做的事情。在後面將要說的 webpack 自定義插件,就是在 webpack 中的某個鉤子處,插入自定義的事。

理清概念

  • 依賴圖
    在單頁面應用中,只要有一個入口文件,就可以把散落在項目下的各個文件整合到一起。何謂依賴,當前文件需要什麼,什麼就是當前文件的依賴。依賴引入的形式有如下:

    • ES2015 import 語句
    • CommonJS require() 語句
    • AMD definerequire 語句
    • 樣式(url(...))或 HTML 文件(<img src=...>)中的圖片鏈接
  • 入口(entry)
    入口起點(entry point)指示 webpack 應該使用哪個模塊,來作爲構建其內部依賴圖(dependency graph)的開始。
  • 輸出(output)
    output 屬性告訴 webpack 在哪裏輸出它所創建的 bundle,以及如何命名這些文件。
  • 模塊(module)
    決定了如何處理項目中的不同類型的模塊。比如設置 loader,處理各種模塊。設置 noParse,忽略無需 webpack 解析的模塊。
  • 解析(resolve)
    設置模塊如何被解析。引用依賴時,需要知道依賴間的路徑關係,應遵循何種解析規則。比如給路徑設置別名(alias),解析模塊的搜索目錄(modules),解析 loader 包路徑(resolveLoader)等。
  • 外部擴展(externals)
    防止將某些 import 的包(package)打包到 bundle 中,而是在運行時(runtime)再去從外部獲取這些擴展依賴。比如說,項目中引用了 jQuery 的CDN資源,在使用 import $ from 'jquery';時,webpack 會把 jQuery 打包進 bundle,其實這是沒有必要的,此時需要配置 externals: {jquery: 'jQuery'},將其剔除 bundle。
  • 插件(plugins)
    用於以各種方式自定義 webpack 構建過程。可以利用 webpack 中的鉤子,做些優化或者搞些小動作。
  • 開發設置(devServer)
    顧名思義,就是開發時用到的選項。比如,開發服務根路徑(contentBase),模塊熱替換(hot,需配合 HotModuleReplacementPlugin 使用),代理(proxy)等。
  • 模式(mode)
    提供 mode 配置選項,告知 webpack 使用相應環境的內置優化。具體可見 模式(mode)
  • 優化(optimization)
    從 webpack 4 開始,會根據你選擇的 mode 來執行不同的優化,不過所有的優化還是可以手動配置和重寫。比如,CommonsChunkPluginoptimization.splitChunks 取代。

webpack 差不多就是這幾個配置項,搞清楚這幾個概念,上手還是比較容易的。

代碼分離

現在的前端項目越來越複雜,如果最終導出爲一個 bundle,會極大地影響加載速度。切割 bundle,控制資源加載優先級,按需加載或並行加載,合理應用就會大大縮短加載時間。官方文檔提供了三種常見的代碼分離方法:

  • 入口起點
    配置多個入口文件,然後將最終生成的過個 bundle 出入到 HTML 中。

    // webpack.config.js
    entry: {
        index: './src/index.js',
        vendor: './src/vendor.js'
    }
    output: {
        filename: '[name].bundle.js',
    },
    plugins: [
    new HtmlWebpackPlugin({
        chunks: ['vendor', 'index']
    })
    ]

    不過如果這兩個文件中存在相同的模塊,這就意味着相同的模塊被加載了兩次。此時,我們就需要提取出重複的模塊。

  • 防止重複
    在 webpack 老的版本中,CommonsChunkPlugin 常用來提取公共的模塊。新版本中 SplitChunksPlugin 取而代之,可以通過 optimization.splitChunks 設置,多見於多頁面應用。
  • 動態導入
    就是在需要時再去加載模塊,而不是一股腦的全部加載。webpack 還提供了預取和預加載的方式。非入口 chunk,我們可以通過 chunkFilename 爲其命名。常見的如,vue 路由動態導入。

    // webpack.config.js
    output: {
      chunkFilename: '[name].bundle.js',
    }
    // index.js
    import(/* webpackChunkName: "someJs" */ 'someJs');
    import(/* webpackPrefetch: true */ 'someJs');
    import(/* webpackPreload: true */ 'someJs');

緩存

基於瀏覽器的緩存策略,我們知道如果本地緩存命中,則無需再次請求資源。對於改動不頻繁或基本不會再做改動的模塊,可以剝離出來。

  // webpack.config.js
  output: {
    filename: '[name].[contenthash].js',
  }

按照我們的想法,只要模塊的內容沒有變化,對應的名字也就不會發生變化,這樣緩存就會起作用了。事實上並非如此,webpack 打包後的文件,並非只有用戶自己的代碼,還包括管理用戶代碼的代碼,如 runtime 和 manifest。

模塊依賴間的整合並不是簡單的代碼拼接,其中包括模塊的加載和解析邏輯。注入的 runtime 和 manifest 在每次構建後都會發生變化。這就導致了即使用戶代碼沒有變化,某些 hash 還是發生了改變。通過 optimization.runtimeChunk 提取 runtime 代碼。通過 optimization.splitChunks 剝離第三方庫。比如, react,react-dom。

module.exports = {
  //...
  optimization: {
    splitChunks: {
      cacheGroups: {
        vendor: {
          test: /[\\/]node_modules[\\/](react|react-dom)[\\/]/,
          name: 'vendor',
          chunks: 'all',
        }
      }
    }
  }
};

最後使用 HashedModuleIdsPlugin 來消除因模塊 ID 變動帶來的影響。

loader

loader 用於對模塊的源代碼進行轉換。loader 是導出爲一個函數的 node 模塊。該函數在 loader 轉換資源的時候調用。給定的函數將調用 loader API,並通過 this 上下文訪問。

// loader API;
this.callback(
  err: Error | null,
  content: string | Buffer,
  sourceMap?: SourceMap,
  meta?: any
);
// sync loader
module.exports = function(content, map, meta){
  this.callback(null, syncOperation(content, map, meta));
  return;
}
// async loader
module.exports = function(content, map, meta){
  let callback = this.async();
  asyncOperation(content, (error, result) => {
    if(error) callback(error);
    callback(null, result, map, meta);
    return;
  })
}

多個 loader 串行時,在從右向左執行 loader 之前,會向從左到右調用 loader 上的 pitch 方法。如果在 pitch 中返回了結果,則會跳過後續 loader。

|- a-loader `pitch`
  |- b-loader `pitch`
    |- c-loader `pitch`
      |- requested module is picked up as a dependency
    |- c-loader normal execution
  |- b-loader normal execution
|- a-loader normal execution

<!-- pitch 中返回結果 -->

|- a-loader `pitch`
  |- b-loader `pitch` returns a module
|- a-loader normal execution

plugins

webpack 的自定義插件和本文開頭 Tapable 中的差不多。webpack 插件是一個具有 apply 方法的 JavaScript 對象。apply 方法會被 webpack compiler 調用,並且 compiler 對象可在整個編譯生命週期訪問。鉤子有同步的,也有異步的,這需要根據 webpack 提供的 API 文檔。

// 官方例子
class FileListPlugin {
  apply(compiler) {
    // emit 是異步 hook,使用 tapAsync 觸及它,還可以使用 tapPromise/tap(同步)
    compiler.hooks.emit.tapAsync('FileListPlugin', (compilation, callback) => {
      // 在生成文件中,創建一個頭部字符串:
      var filelist = 'In this build:\n\n';
      // 遍歷所有編譯過的資源文件,
      // 對於每個文件名稱,都添加一行內容。
      for (var filename in compilation.assets) {
        filelist += '- ' + filename + '\n';
      }
      // 將這個列表作爲一個新的文件資源,插入到 webpack 構建中:
      compilation.assets['filelist.md'] = {
        source: function() {
          return filelist;
        },
        size: function() {
          return filelist.length;
        }
      };
      callback();
    });
  }
}
module.exports = FileListPlugin;
  • ProvidePlugin
    自動加載模塊,無需處處引用。有點類似 expose-loader

    // webpack.config.js
    new webpack.ProvidePlugin({
      $: 'jquery',
    })
    // some.js
    $('#item');
  • DllPlugin
    將基礎模塊打包進動態鏈接庫,當依賴的模塊存在於動態鏈接庫中時,無需再次打包,而是直接從動態鏈接庫中獲取。DLLPlugin 負責打包出動態鏈接庫,DllReferencePlugin 負責從主要配置文件中引入 DllPlugin 插件打包好的動態鏈接庫文件。

    // webpack-dll-config.js
    // 先執行該配置文件
    output: {
      path: path.join(__dirname, "dist"),
      filename: "MyDll.[name].js",
      library: "[name]_[hash]"
    },
    plugins: [
      new webpack.DllPlugin({
        path: path.join(__dirname, "dist", "[name]-manifest.json"),
        name: "[name]_[hash]"
      })
    ]
    // webpack-config.js
    // 後執行該配置文件
    plugins: [
      new webpack.DllReferencePlugin({
        manifest: require("../dll/dist/alpha-manifest.json")
      }),
    ]
  • HappyPack
    啓動子進程處理任務,充分利用資源。不過進程間的通訊比較耗資源,要酌情處理。

    const HappyPack = require('happypack');
    // loader
    {
      test: /\.js$/,
      use: ['happypack/loader?id=babel'],
      exclude: path.resolve(__dirname, 'node_modules'),
    },
    // plugins
    new HappyPack({
      id: 'babel',
      loaders: ['babel-loader?cacheDirectory'],
    }),
  • webpack-bundle-analyzer
    webpack 打包後的分析工具。

webpack 告一段落,淺嘗輒止。

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