[webpack]原始碼解讀:命令列輸入webpack的時候都發生了什麼?

滴滴出行·DDFE發表於2017-01-04

作者:滴滴公共前端團隊 - 水乙

我們在使用 webpack 的時候可以通過 webpack 這個命令配合一些引數來執行我們打包編譯的任務。我們想探究它的原始碼,從這個命令入手能夠比較容易讓我們瞭解整個程式碼的執行過程。那麼在執行這個命令的時候究竟發生了什麼呢?

注:本文中的 webpack 原始碼版本為1.13.3。本文中的原始碼分析主要關注的是程式碼的整體流程,因此一些我認為不是很重要的細節都會省略,以使得讀者不要陷入到細節中而 get 不到整體。按照官方文件,webpack.config.js 會通過 module.exports 暴露一個物件,下文中我們統一把這個物件稱為 webpack 編譯物件(Webpack compiler object)。

Step1:執行指令碼 bin/webpack.js

// bin/webpack.js

// 引入 nodejs 的 path 模組
var path = require ("path") ;

// 獲取 /bin/webpack.js 的絕對路徑
try {
  var localWebpack = require.resolve (path.join (process.cwd (), "node_modules", "webpack", "bin", "webpack.js")) ;
  if (__filename !== localWebpack) {}
} catch (e) {}

// 引入第三方命令列解析庫 optimist
// 解析 webpack 指令後面追加的與輸出顯示相關的引數(Display options)
var optimist = require ("optimist").usage ((("webpack " + require ("../package.json").version) + "\n") + "Usage: https://webpack.github.io/docs/cli.html") ;
require ("./config-optimist") (optimist) ;
optimist
  .boolean ("json").alias ("json", "j").describe ("json")
  .boolean ("colors").alias ("colors", "c")... ;

// 獲取解析後的引數並轉換格式
var argv = optimist.argv ;
var options = require ("./convert-argv") (optimist, argv) ;

// 判斷是否符合 argv 裡的引數,並執行該引數的回撥
function ifArg (name, fn, init) {...}

// 處理輸出相關(output)的配置引數,並執行編譯函式
function processOptions (options) {...}
// 執行
processOptions (options) ;複製程式碼

小結1.1:從上面的分析中我們可以比較清晰地看到執行 webpack 命令時會做什麼處理,主要就是解析命令列引數以及執行編譯。其中 processOptions 這個函式是整個 /bin/webpack.js 裡的核心函式。下面我們來仔細看一下這個函式:

function processOptions (options) {
 // 支援 Promise 風格的非同步回撥
  if ((typeof options.then) === "function") {...}

 // 處理傳入一個 webpack 編譯物件是陣列時的情況
  var firstOptions = (Array.isArray (options)) ? options[0]: options;

 // 設定輸出 options
  var outputOptions = Object.create ((options.stats || firstOptions.stats) || ({}));

 // 設定輸出的上下文 context
  if ((typeof outputOptions.context) === "undefined") outputOptions.context = firstOptions.context ;

  // 處理各種顯示相關的引數,從略
  ifArg ("json", 
    function (bool){...}
  );
  ...

  // 引入主入口模組 lib/webpack.js
  var webpack = require ("../lib/webpack.js") ;

  // 設定錯誤堆疊追蹤上限
  Error.stackTraceLimit = 30 ;
  var lastHash = null ;

 // 執行編譯
  var compiler = webpack (options) ;

 // 編譯結束後的回撥函式
  function compilerCallback (err, stats) {...}

 // 是否在編譯完成後繼續 watch 檔案變更
  if (options.watch) {...}
  else 
 // 執行編譯後的回撥函式
  compiler.run (compilerCallback) ;
}複製程式碼

小結1.2:從 processOptions 中我們看到,最核心的編譯一步,是使用的入口模組 lib/webpack.js 暴露處理的方法,所以我們的資料流接下來要從 bin/webpack.js 來到 lib/webpack.js 了,接下來我們看看 lib/webpack.js 裡將會發生什麼。

step2:執行 lib/webpack.js 中的方法開始編譯

// lib/webpack.js

// 引入 Compiler 模組
var Compiler = require ("./Compiler") ;

// 引入 MultiCompiler 模組,處理多個 webpack 配置檔案的情況
var MultiCompiler = require ("./MultiCompiler") ;

// 引入 node 環境外掛
var NodeEnvironmentPlugin = require ("./node/NodeEnvironmentPlugin") ;

// 引入 WebpackOptionsApply 模組,應用 webpack 配置檔案
var WebpackOptionsApply = require ("./WebpackOptionsApply") ;

// 引入 WebpackOptionsDefaulter 模組,應用 webpack 預設配置
var WebpackOptionsDefaulter = require ("./WebpackOptionsDefaulter") ;

// 核心函式,也是 ./bin/webpack.js 中引用的核心方法
function webpack (options, callback) {...}
exports = module.exports = webpack ;

// 在 webpack 物件上設定一些常用屬性
webpack.WebpackOptionsDefaulter = WebpackOptionsDefaulter ;
webpack.WebpackOptionsApply = WebpackOptionsApply ;
webpack.Compiler = Compiler ;
webpack.MultiCompiler = MultiCompiler ;
webpack.NodeEnvironmentPlugin = NodeEnvironmentPlugin ;

// 暴露一些外掛
function exportPlugins (exports, path, plugins) {...}
exportPlugins (exports, ".", ["DefinePlugin", "NormalModuleReplacementPlugin", ...]) ;複製程式碼

小結2.1lib/webpack.js 檔案裡的程式碼比較清晰,核心函式就是我們期待已久的 webpack,我們在 webpack.config.js 裡面引入的 webpack 模組就是這個檔案,下面我們再來仔細看看這個函式。

function webpack (options, callback) {
  var compiler ;
  if (Array.isArray (options)) {
    // 如果傳入了陣列型別的 webpack 編譯物件,則例項化一個 MultiCompiler 來處理
    compiler = new MultiCompiler (options.map(function (options) {
      return webpack (options) ; // 遞迴呼叫 webpack 函式
    })) ;
  } else if ((typeof options) === "object") {
   // 如果傳入了一個物件型別的 webpack 編譯物件

    // 例項化一個 WebpackOptionsDefaulter 來處理預設配置項
    new WebpackOptionsDefaulter ().process (options) ;

    // 例項化一個 Compiler,Compiler 會繼承一個 Tapable 外掛框架
    // Compiler 例項化後會繼承到 apply、plugin 等呼叫和繫結外掛的方法
    compiler = new Compiler () ;

   // 例項化一個 WebpackOptionsApply 來編譯處理 webpack 編譯物件
    compiler.options = options ; // 疑惑:為何兩次賦值 compiler.options?
    compiler.options = new WebpackOptionsApply ().process (options, compiler) ;

  // 應用 node 環境外掛
    new NodeEnvironmentPlugin ().apply (compiler) ;
    compiler.applyPlugins ("environment") ;
    compiler.applyPlugins ("after-environment") ;
  } else {
    // 丟擲錯誤
    throw new Error ("Invalid argument: options") ;
  }
}複製程式碼

小結2.2webpack 函式裡面有兩個地方值得關注一下。

一是 Compiler,例項化它會繼承 Tapable ,這個 Tapable 是一個外掛框架,通過繼承它的一系列方法來實現註冊和呼叫外掛,我們可以看到在 webpack 的原始碼中,存在大量的 compiler.apply、compiler.applyPlugins、compiler.plugin 等Tapable方法的呼叫。Webpack 的 plugin 註冊和呼叫方式,都是源自 Tapable 。Webpack 通過 plugin 的 apply 方法安裝該 plugin,同時傳入一個 webpack 編譯物件(Webpack compiler object)。

二是 WebpackOptionsApply 的例項方法 process (options, compiler),這個方法將會針對我們傳進去的webpack 編譯物件進行逐一編譯,接下來我們再來仔細看看這個模組。

step3:呼叫 lib/WebpackOptionsApply.js 模組的 process 方法來逐一編譯 webpack 編譯物件的各項。

// lib/WebpackOptionsApply.js

// ...此處省略一堆依賴引入

// 建立構造器函式 WebpackOptionsApply
function WebpackOptionsApply () {
  OptionsApply.call (this) ;
}

// 將構造器暴露
module.exports = WebpackOptionsApply ;

// 修改構造器的原型屬性指向
WebpackOptionsApply.prototype = Object.create (OptionsApply.prototype) ;

// 建立 WebpackOptionsApply 的例項方法 process
WebpackOptionsApply.prototype.process = function (options, compiler) {
 // 處理 context 屬性,根目錄
  compiler.context = options.context ;
 // 處理 plugins 屬性
  if (options.plugins && (Array.isArray (options.plugins))) {...}
// 快取輸入輸出的目錄地址等
  compiler.outputPath = options.output.path ;
  compiler.recordsInputPath = options.recordsInputPath || options.recordsPath ;
  compiler.recordsOutputPath = options.recordsOutputPath || options.recordsPath ;
  compiler.name = options.name ;
// 處理 target 屬性,該屬性決定包 (bundle) 應該執行的環境
  if ((typeof options.target) === "string") {...}
  else  if (options.target !== false) {...}
  else {...}
 // 處理 output.library 屬性,該屬性決定匯出庫 (exported library) 的名稱
  if (options.output.library || (options.output.libraryTarget !== "var")) {...}
 // 處理 externals 屬性,告訴 webpack 不要遵循/打包這些模組,而是在執行時從環境中請求他們
  if (options.externals) {...}
 // 處理 hot 屬性,它決定 webpack 瞭如何使用熱替換
  if (options.hot) {...}
// 處理 devtool 屬性,它決定了 webpack 的 sourceMap 模式
  if (options.devtool && (((options.devtool.indexOf ("sourcemap")) >= 0) || ((options.devtool.indexOf ("source-map")) >= 0))) {...}
  else if (options.devtool && ((options.devtool.indexOf ("eval")) >= 0)) {...}

// 以下是安裝並呼叫各種外掛 plugin,由於功能眾多個人閱歷有限,不能面面俱到

  compiler.apply (new EntryOptionPlugin ()) ; // 呼叫處理入口 entry 的外掛
  compiler.applyPluginsBailResult ("entry-option", options.context, options.entry) ;
  if (options.prefetch) {...}

  compiler.apply (new CompatibilityPlugin (),
                  new LoaderPlugin (), // 呼叫 loader 的外掛
                  new NodeStuffPlugin (options.node), // 呼叫 nodejs 環境相關的外掛
                  new RequireJsStuffPlugin (), // 呼叫 RequireJs 的外掛
                  new APIPlugin (), // 呼叫變數名的替換,webpack 編譯後的檔案裡隨處可見的 __webpack_require__ 變數名就是在此處理
                  new ConstPlugin (), // 呼叫一些 if 條件語句、三元運算子等語法相關的外掛
                  new RequireIncludePlugin (), // 呼叫 require.include 函式的外掛
                  new RequireEnsurePlugin (), // 呼叫 require.ensure 函式的外掛
                  new RequireContextPlugin(options.resolve.modulesDirectories, options.resolve.extensions),
                  new AMDPlugin (options.module, options.amd || ({})), // 呼叫處理符合 AMD 規範的外掛
                  new CommonJsPlugin (options.module)) ; // 呼叫處理符合 CommonJs 規範的外掛

  compiler.apply (new RemoveParentModulesPlugin (), // 呼叫移除父 Modules 的外掛
                  new RemoveEmptyChunksPlugin (), // 呼叫移除空 chunk 的外掛
                  new MergeDuplicateChunksPlugin (), // 呼叫合併重複多餘 chunk 的外掛
                  new FlagIncludedChunksPlugin ()) ; // 

  compiler.apply (new TemplatedPathPlugin ()) ;
  compiler.apply (new RecordIdsPlugin ()) ; // 呼叫記錄 Modules 的 Id 的外掛
  compiler.apply (new WarnCaseSensitiveModulesPlugin ()) ; // 呼叫警告大小寫敏感的外掛

  // 處理 webpack.optimize 屬性下的幾個方法
  if (options.optimize && options.optimize.occurenceOrder) {...} // 呼叫 OccurrenceOrderPlugin 外掛
  if (options.optimize && options.optimize.minChunkSize) {...} // 呼叫 MinChunkSizePlugin 外掛
  if (options.optimize && options.optimize.maxChunks) {...} // 呼叫 LimitChunkCountPlugin 外掛
  if (options.optimize.minimize) {...} // 呼叫 UglifyJsPlugin 外掛

  // 處理cache屬性(快取),該屬性在watch的模式下預設開啟快取
  if ((options.cache === undefined) ? options.watch: options.cache) {...}
  // 處理 provide 屬性,如果有則呼叫 ProvidePlugin 外掛,這個外掛可以讓一個 module 賦值為一個變數,從而能在每個 module 中以變數名訪問它
  if ((typeof options.provide) === "object") {...}
  // 處理define屬性,如果有這個屬性則呼叫 DefinePlugin 外掛,這個外掛可以定義全域性的常量
  if (options.define) {...}
  // 處理 defineDebug 屬性,呼叫並開啟 DefinePlugin 外掛的 debug 模式?
  if (options.defineDebug !== false) compiler.apply (new DefinePlugin ({...})) ; // 處理定義外掛的

 // 呼叫一些編譯完後的處理外掛
  compiler.applyPlugins ("after-plugins", compiler) ;
  compiler.resolvers.normal.apply (new UnsafeCachePlugin (options.resolve.unsafeCache)...) ;
  compiler.resolvers.context.apply (new UnsafeCachePlugin (options.resolve.unsafeCache)...) ;
  compiler.resolvers.loader.apply (new UnsafeCachePlugin (options.resolve.unsafeCache)...) ;
  compiler.applyPlugins ("after-resolvers", compiler) ;

 // 最後把處理過的 webpack 編譯物件返回
  return options;
};複製程式碼

小結3.1:我們可以在上面的程式碼中看到 webpack 文件中 Configuration 中介紹的各個屬性,同時看到了這些屬性對應的處理外掛都是誰。我個人看完這裡之後,熟悉了好幾個平常不怎麼用到,但是感覺還是很有用的東西,例如 externals 和 define 屬性。

step4:在 step3 中呼叫的各種外掛會按照 webpack 編譯物件的配置來構建出檔案

由於外掛繁多,切每個外掛都有不同的細節,我們這裡選擇一個大家可能比較熟悉的外掛 UglifyJsPlugin.js(壓縮程式碼外掛)來理解 webpack 的流程。

// lib/optimize/UglifyJsPlugin.js

// 引入一些依賴,主要是與 程式碼壓縮、sourceMap 相關
var SourceMapConsumer = require("webpack-core/lib/source-map").SourceMapConsumer;
var SourceMapSource = require("webpack-core/lib/SourceMapSource");
var RawSource = require("webpack-core/lib/RawSource");
var RequestShortener = require("../RequestShortener");
var ModuleFilenameHelpers = require("../ModuleFilenameHelpers");
var uglify = require("uglify-js");

// 定義構造器函式
function UglifyJsPlugin(options) {
    ...
}
// 將構造器暴露出去
module.exports = UglifyJsPlugin;

// 按照 Tapable 風格編寫外掛
UglifyJsPlugin.prototype.apply = function(compiler) {
    ...
    // 編譯器開始編譯
    compiler.plugin("compilation", function(compilation) {
        ...
        // 編譯器開始呼叫 "optimize-chunk-assets" 外掛編譯
        compilation.plugin("optimize-chunk-assets", function(chunks, callback) {
            var files = [];
            ...
            files.forEach(function(file) {
                ...
                try {
                    var asset = compilation.assets[file];
                    if(asset.__UglifyJsPlugin) {
                        compilation.assets[file] = asset.__UglifyJsPlugin;
                        return;
                    }
                    if(options.sourceMap !== false) {
                    // 需要 sourceMap 時要做的一些操作...
                    } else {
                        // 獲取讀取到的原始檔
                        var input = asset.source(); 
                        ...
                    }
                    // base54 編碼重置
                    uglify.base54.reset(); 
                    // 將原始檔生成語法樹
                    var ast = uglify.parse(input, {
                        filename: file
                    });
                    // 語法樹轉換為壓縮後的程式碼
                    if(options.compress !== false) {
                        ast.figure_out_scope();
                        var compress = uglify.Compressor(options.compress); // eslint-disable-line new-cap
                        ast = ast.transform(compress);
                    }
                    // 處理混淆變數名
                    if(options.mangle !== false) {
                        ast.figure_out_scope();
                        ast.compute_char_frequency(options.mangle || {});
                        ast.mangle_names(options.mangle || {});
                        if(options.mangle && options.mangle.props) {
                            uglify.mangle_properties(ast, options.mangle.props);
                        }
                    }
                    // 定義輸出變數名
                    var output = {};
                    // 處理輸出的註釋
                    output.comments = Object.prototype.hasOwnProperty.call(options, "comments") ? options.comments : /^\**!|@preserve|@license/;
                    // 處理輸出的美化
                    output.beautify = options.beautify;
                    for(var k in options.output) {
                        output[k] = options.output[k];
                    }
                    // 處理輸出的 sourceMap
                    if(options.sourceMap !== false) {
                        var map = uglify.SourceMap({ // eslint-disable-line new-cap
                            file: file,
                            root: ""
                        });
                        output.source_map = map; // eslint-disable-line camelcase
                    }
                    // 將壓縮後的資料輸出
                    var stream = uglify.OutputStream(output); // eslint-disable-line new-cap
                    ast.print(stream);
                    if(map) map = map + "";
                    stream = stream + "";
                    asset.__UglifyJsPlugin = compilation.assets[file] = (map ?
                        new SourceMapSource(stream, file, JSON.parse(map), input, inputSourceMap) :
                        new RawSource(stream));
                    if(warnings.length > 0) {
                        compilation.warnings.push(new Error(file + " from UglifyJs\n" + warnings.join("\n")));
                    }
                } catch(err) {
                    // 處理異常
                    ...
                } finally {
                    ...
                }
            });
            // 回撥函式
            callback();
        });
        compilation.plugin("normal-module-loader", function(context) {
            context.minimize = true;
        });
    });
};複製程式碼

小結4.1:從這個外掛的原始碼分析,我們可以基本看到 webpack 編譯時的讀寫過程大致是怎麼樣的:例項化外掛(如 UglifyJsPlugin )--> 讀取原始檔 --> 編譯並輸出

總結

現在我們回過頭來再看看整體流程,當我們在命令列輸入 webpack 命令,按下回車時都發生了什麼:

  1. 執行 bin 目錄下的 webpack.js 指令碼,解析命令列引數以及開始執行編譯。
  2. 呼叫 lib 目錄下的 webpack.js 檔案的核心函式 webpack ,例項化一個 Compiler,繼承 Tapable 外掛框架,實現註冊和呼叫一系列外掛。
  3. 呼叫 lib 目錄下的 /WebpackOptionsApply.js 模組的 process 方法,使用各種各樣的外掛來逐一編譯 webpack 編譯物件的各項。
  4. 在3中呼叫的各種外掛編譯並輸出新檔案。

歡迎關注DDFE
GITHUB:github.com/DDFE
微信公眾號:微信搜尋公眾號“DDFE”或掃描下面的二維碼

[webpack]原始碼解讀:命令列輸入webpack的時候都發生了什麼?

相關文章