webpack系列之四loader詳解2

滴滴WebApp架構組發表於2019-02-21

系列作者:肖磊

GitHub: github.com/CommanderXL

上篇文章主要講了 loader 的配置,匹配相關的機制。這篇主要會講當一個 module 被建立之後,使用 loader 去處理這個 module 內容的流程機制。首先我們來總體的看下整個的流程:

runLoader

在 module 一開始構建的過程中,首先會建立一個 loaderContext 物件,它和這個 module 是一一對應的關係,而這個 module 所使用的所有 loaders 都會共享這個 loaderContext 物件,每個 loader 執行的時候上下文就是這個 loaderContext 物件,所以可以在我們寫的 loader 裡面通過 this 來訪問。

// NormalModule.js

const { runLoaders } = require('loader-runner')

class NormalModule extends Module {
  ...
  createLoaderContext(resolver, options, compilation, fs) {
    const requestShortener = compilation.runtimeTemplate.requestShortener;
    // 初始化 loaderContext 物件,這些初始欄位的具體內容解釋在文件上有具體的解釋(https://webpack.docschina.org/api/loaders/#this-data)
		const loaderContext = {
			version: 2,
			emitWarning: warning => {...},
			emitError: error => {...},
			exec: (code, filename) => {...},
			resolve(context, request, callback) {...},
			getResolve(options) {...},
			emitFile: (name, content, sourceMap) => {...},
			rootContext: options.context, // 專案的根路徑
			webpack: true,
			sourceMap: !!this.useSourceMap,
			_module: this,
			_compilation: compilation,
			_compiler: compilation.compiler,
			fs: fs
		};

    // 觸發 normalModuleLoader 的鉤子函式,開發者可以利用這個鉤子來對 loaderContext 進行擴充
		compilation.hooks.normalModuleLoader.call(loaderContext, this);
		if (options.loader) {
			Object.assign(loaderContext, options.loader);
		}

		return loaderContext;
  }

  doBuild(options, compilation, resolver, fs, callback) {
    // 建立 loaderContext 上下文
		const loaderContext = this.createLoaderContext(
			resolver,
			options,
			compilation,
			fs
    )
    
    runLoaders(
      {
        resource: this.resource, // 這個模組的路徑
				loaders: this.loaders, // 模組所使用的 loaders
				context: loaderContext, // loaderContext 上下文
				readResource: fs.readFile.bind(fs) // 讀取檔案的 node api
      },
      (err, result) => {
        // do something
      }
    )
  }
  ...
}
複製程式碼

當 loaderContext 初始化完成後,開始呼叫 runLoaders 方法,這個時候進入到了 loaders 的執行階段。runLoaders 方法是由loader-runner這個獨立的 NPM 包提供的方法,那我們就一起來看下 runLoaders 方法內部是如何執行的。

首先根據傳入的引數完成進一步的處理,同時對於 loaderContext 物件上的屬性做進一步的擴充:

exports.runLoaders = function runLoaders(options, callback) {
  // read options
	var resource = options.resource || ""; // 模組的路徑
	var loaders = options.loaders || []; // 模組所需要使用的 loaders
	var loaderContext = options.context || {}; // 在 normalModule 裡面建立的 loaderContext
	var readResource = options.readResource || readFile;

	var splittedResource = resource && splitQuery(resource);
	var resourcePath = splittedResource ? splittedResource[0] : undefined; // 模組實際路徑
	var resourceQuery = splittedResource ? splittedResource[1] : undefined; // 模組路徑 query 引數
	var contextDirectory = resourcePath ? dirname(resourcePath) : null; // 模組的父路徑

	// execution state
	var requestCacheable = true;
	var fileDependencies = [];
	var contextDependencies = [];

	// prepare loader objects
	loaders = loaders.map(createLoaderObject); // 處理 loaders 

  // 擴充 loaderContext 的屬性
	loaderContext.context = contextDirectory;
	loaderContext.loaderIndex = 0; // 當前正在執行的 loader 索引
	loaderContext.loaders = loaders;
	loaderContext.resourcePath = resourcePath;
	loaderContext.resourceQuery = resourceQuery;
	loaderContext.async = null; // 非同步 loader
  loaderContext.callback = null;

  ...

  // 需要被構建的模組路徑,將 loaderContext.resource -> getter/setter
  // 例如 /abc/resource.js?rrr
  Object.defineProperty(loaderContext, "resource", {
		enumerable: true,
		get: function() {
			if(loaderContext.resourcePath === undefined)
				return undefined;
			return loaderContext.resourcePath + loaderContext.resourceQuery;
		},
		set: function(value) {
			var splittedResource = value && splitQuery(value);
			loaderContext.resourcePath = splittedResource ? splittedResource[0] : undefined;
			loaderContext.resourceQuery = splittedResource ? splittedResource[1] : undefined;
		}
  });

  // 構建這個 module 所有的 loader 及這個模組的 resouce 所組成的 request 字串
  // 例如:/abc/loader1.js?xyz!/abc/node_modules/loader2/index.js!/abc/resource.js?rrr
	Object.defineProperty(loaderContext, "request", {
		enumerable: true,
		get: function() {
			return loaderContext.loaders.map(function(o) {
				return o.request;
			}).concat(loaderContext.resource || "").join("!");
		}
  });
  // 在執行 loader 提供的 pitch 函式階段傳入的引數之一,剩下還未被呼叫的 loader.pitch 所組成的 request 字串
	Object.defineProperty(loaderContext, "remainingRequest", {
		enumerable: true,
		get: function() {
			if(loaderContext.loaderIndex >= loaderContext.loaders.length - 1 && !loaderContext.resource)
				return "";
			return loaderContext.loaders.slice(loaderContext.loaderIndex + 1).map(function(o) {
				return o.request;
			}).concat(loaderContext.resource || "").join("!");
		}
  });
  // 在執行 loader 提供的 pitch 函式階段傳入的引數之一,包含當前 loader.pitch 所組成的 request 字串
	Object.defineProperty(loaderContext, "currentRequest", {
		enumerable: true,
		get: function() {
			return loaderContext.loaders.slice(loaderContext.loaderIndex).map(function(o) {
				return o.request;
			}).concat(loaderContext.resource || "").join("!");
		}
  });
  // 在執行 loader 提供的 pitch 函式階段傳入的引數之一,包含已經被執行的 loader.pitch 所組成的 request 字串
	Object.defineProperty(loaderContext, "previousRequest", {
		enumerable: true,
		get: function() {
			return loaderContext.loaders.slice(0, loaderContext.loaderIndex).map(function(o) {
				return o.request;
			}).join("!");
		}
  });
  // 獲取當前正在執行的 loader 的query引數
  // 如果這個 loader 配置了 options 物件的話,this.query 就指向這個 option 物件
  // 如果 loader 中沒有 options,而是以 query 字串作為引數呼叫時,this.query 就是一個以 ? 開頭的字串
	Object.defineProperty(loaderContext, "query", {
		enumerable: true,
		get: function() {
			var entry = loaderContext.loaders[loaderContext.loaderIndex];
			return entry.options && typeof entry.options === "object" ? entry.options : entry.query;
		}
  });
  // 每個 loader 在 pitch 階段和正常執行階段都可以共享的 data 資料
	Object.defineProperty(loaderContext, "data", {
		enumerable: true,
		get: function() {
			return loaderContext.loaders[loaderContext.loaderIndex].data;
		}
  });
  
  var processOptions = {
		resourceBuffer: null, // module 的內容 buffer
		readResource: readResource
  };
  // 開始執行每個 loader 上的 pitch 函式
	iteratePitchingLoaders(processOptions, loaderContext, function(err, result) {
    // do something...
  });
}
複製程式碼

這裡稍微總結下就是在 runLoaders 方法的初期會對相關引數進行初始化的操作,特別是將 loaderContext 上的部分屬性改寫為 getter/setter 函式,這樣在不同的 loader 執行的階段可以動態的獲取一些引數。

接下來開始呼叫 iteratePitchingLoaders 方法執行每個 loader 上提供的 pitch 函式。大家寫過 loader 的話應該都清楚,每個 loader 可以掛載一個 pitch 函式,每個 loader 提供的 pitch 方法和 loader 實際的執行順序正好相反。這塊的內容在 webpack 文件上也有詳細的說明(請戳我)。

這些 pitch 函式並不是用來實際處理 module 的內容的,主要是可以利用 module 的 request,來做一些攔截處理的工作,從而達到在 loader 處理流程當中的一些定製化的處理需要,有關 pitch 函式具體的實戰可以參見下一篇文件

function iteratePitchingLoaders() {
  // abort after last loader
	if(loaderContext.loaderIndex >= loaderContext.loaders.length)
		return processResource(options, loaderContext, callback);

  // 根據 loaderIndex 來獲取當前需要執行的 loader
	var currentLoaderObject = loaderContext.loaders[loaderContext.loaderIndex];

  // iterate
  // 如果被執行過,那麼直接跳過這個 loader 的 pitch 函式
	if(currentLoaderObject.pitchExecuted) {
		loaderContext.loaderIndex++;
		return iteratePitchingLoaders(options, loaderContext, callback);
	}

	// 載入 loader 模組
	// load loader module
	loadLoader(currentLoaderObject, function(err) {
		// do something ...
	});
}
複製程式碼

每次執行 pitch 函式前,首先根據 loaderIndex 來獲取當前需要執行的 loader (currentLoaderObject),呼叫 loadLoader 函式來載入這個 loader,loadLoader 內部相容了 SystemJS,ES Module,CommonJs 這些模組定義,最終會將 loader 提供的 pitch 方法和普通方法賦值到 currentLoaderObject 上:

// loadLoader.js
module.exports = function (loader, callback) {
  ...
  var module = require(loader.path)
 
  ...
  loader.normal = module

  loader.pitch = module.pitch

  loader.raw = module.raw

  callback()
  ...
}
複製程式碼

當 loader 載入完後,開始執行 loadLoader 的回撥:

loadLoader(currentLoaderObject, function(err) {
  var fn = currentLoaderObject.pitch; // 獲取 pitch 函式
  currentLoaderObject.pitchExecuted = true;
  if(!fn) return iteratePitchingLoaders(options, loaderContext, callback); // 如果這個 loader 沒有提供 pitch 函式,那麼直接跳過

  // 開始執行 pitch 函式
  runSyncOrAsync(
    fn,
    loaderContext, [loaderContext.remainingRequest, loaderContext.previousRequest, currentLoaderObject.data = {}],
    function(err) {
      if(err) return callback(err);
      var args = Array.prototype.slice.call(arguments, 1);
      // Determine whether to continue the pitching process based on
      // argument values (as opposed to argument presence) in order
      // to support synchronous and asynchronous usages.
      // 根據是否有引數返回來判斷是否向下繼續進行 pitch 函式的執行
      var hasArg = args.some(function(value) {
        return value !== undefined;
      });
      if(hasArg) {
        loaderContext.loaderIndex--;
        iterateNormalLoaders(options, loaderContext, args, callback);
      } else {
        iteratePitchingLoaders(options, loaderContext, callback);
      }
    }
  );
})
複製程式碼

這裡出現了一個 runSyncOrAsync 方法,放到後文去講,開始執行 pitch 函式,當 pitch 函式執行完後,執行傳入的回撥函式。我們看到回撥函式裡面會判斷接收到的引數的個數,除了第一個 err 引數外,如果還有其他的引數(這些引數是 pitch 函式執行完後傳入回撥函式的),那麼會直接進入 loader 的 normal 方法執行階段,並且會直接跳過後面的 loader 執行階段。如果 pitch 函式沒有返回值的話,那麼進入到下一個 loader 的 pitch 函式的執行階段。讓我們再回到 iteratePitchingLoaders 方法內部,當所有 loader 上面的 pitch 函式都執行完後,即 loaderIndex 索引值 >= loader 陣列長度的時候:

function iteratePitchingLoaders () {
  ...

  if(loaderContext.loaderIndex >= loaderContext.loaders.length)
    return processResource(options, loaderContext, callback);

  ...
}

function processResource(options, loaderContext, callback) {
	// set loader index to last loader
	loaderContext.loaderIndex = loaderContext.loaders.length - 1;

	var resourcePath = loaderContext.resourcePath;
	if(resourcePath) {
		loaderContext.addDependency(resourcePath); // 新增依賴
		options.readResource(resourcePath, function(err, buffer) {
			if(err) return callback(err);
			options.resourceBuffer = buffer;
			iterateNormalLoaders(options, loaderContext, [buffer], callback);
		});
	} else {
		iterateNormalLoaders(options, loaderContext, [null], callback);
	}
}
複製程式碼

在 processResouce 方法內部呼叫 node API readResouce 讀取 module 對應路徑的文字內容,呼叫 iterateNormalLoaders 方法,開始進入 loader normal 方法的執行階段。

function iterateNormalLoaders () {
  if(loaderContext.loaderIndex < 0)
		return callback(null, args);

	var currentLoaderObject = loaderContext.loaders[loaderContext.loaderIndex];

	// iterate
	if(currentLoaderObject.normalExecuted) {
		loaderContext.loaderIndex--;
		return iterateNormalLoaders(options, loaderContext, args, callback);
	}

	var fn = currentLoaderObject.normal;
	currentLoaderObject.normalExecuted = true;
	if(!fn) {
		return iterateNormalLoaders(options, loaderContext, args, callback);
	}

  // buffer 和 utf8 string 之間的轉化
	convertArgs(args, currentLoaderObject.raw);

	runSyncOrAsync(fn, loaderContext, args, function(err) {
		if(err) return callback(err);

		var args = Array.prototype.slice.call(arguments, 1);
		iterateNormalLoaders(options, loaderContext, args, callback);
	});
}
複製程式碼

在 iterateNormalLoaders 方法內部就是依照從右到左的順序(正好與 pitch 方法執行順序相反)依次執行每個 loader 上的 normal 方法。loader 不管是 pitch 方法還是 normal 方法的執行可為同步的,也可設為非同步的。這裡說下 normal 方法的,一般如果你寫的 loader 裡面可能涉及到計算量較大的情況時,可將你的 loader 非同步化,在你 loader 方法裡面呼叫this.async方法,返回非同步的回撥函式,當你 loader 內部實際的內容執行完後,可呼叫這個非同步的回撥來進入下一個 loader 的執行。

module.exports = function (content) {
  const callback = this.async()
  someAsyncOperation(content, function(err, result) {
    if (err) return callback(err);
    callback(null, result);
  });
}
複製程式碼

除了呼叫 this.async 來非同步化 loader 之外,還有一種方式就是在你的 loader 裡面去返回一個 promise,只有當這個 promise 被 resolve 之後,才會呼叫下一個 loader(具體實現機制見下文):

module.exports = function (content) {
  return new Promise(resolve => {
    someAsyncOpertion(content, function(err, result) {
      if (err) resolve(err)
      resolve(null, result)
    })
  })
}
複製程式碼

這裡還有一個地方需要注意的就是,上下游 loader 之間的資料傳遞過程中,如果下游的 loader 接收到的引數為一個,那麼可以在上一個 loader 執行結束後,如果是同步就直接 return 出去:

module.exports = function (content) {
  // do something
  return content
}
複製程式碼

如果是非同步就直接呼叫非同步回撥傳遞下去(參見上面 loader 非同步化)。如果下游 loader 接收的引數多於一個,那麼上一個 loader 執行結束後,如果是同步那麼就需要呼叫 loaderContext 提供的 callback 函式:

module.exports = function (content) {
  // do something
  this.callback(null, content, argA, argB)
}
複製程式碼

如果是非同步的還是繼續呼叫非同步回撥函式傳遞下去(參見上面 loader 非同步化)。具體的執行機制涉及到上文還沒講到的 runSyncOrAsync 方法,它提供了上下游 loader 呼叫的介面:

function runSyncOrAsync(fn, context, args, callback) {
	var isSync = true; // 是否為同步
	var isDone = false;
	var isError = false; // internal error
	var reportedError = false;
	// 給 loaderContext 上下文賦值 async 函式,用以將 loader 非同步化,並返回非同步回撥
	context.async = function async() {
		if(isDone) {
			if(reportedError) return; // ignore
			throw new Error("async(): The callback was already called.");
		}
		isSync = false; // 同步標誌位置為 false
		return innerCallback;
  };
  // callback 的形式可以向下一個 loader 多個引數
	var innerCallback = context.callback = function() {
		if(isDone) {
			if(reportedError) return; // ignore
			throw new Error("callback(): The callback was already called.");
		}
		isDone = true;
		isSync = false;
		try {
			callback.apply(null, arguments);
		} catch(e) {
			isError = true;
			throw e;
		}
	};
	try {
		// 開始執行 loader
		var result = (function LOADER_EXECUTION() {
			return fn.apply(context, args);
    }());
    // 如果為同步的執行
		if(isSync) {
      isDone = true;
      // 如果 loader 執行後沒有返回值,執行 callback 開始下一個 loader 執行
			if(result === undefined)
        return callback();
      // loader 返回值為一個 promise 例項,待這個例項被resolve或者reject後執行下一個 loader。這也是 loader 非同步化的一種方式
			if(result && typeof result === "object" && typeof result.then === "function") {
				return result.catch(callback).then(function(r) {
					callback(null, r);
				});
      }
      // 如果 loader 執行後有返回值,執行 callback 開始下一個 loader 執行
			return callback(null, result);
		}
	} catch(e) {
		// do something
	}
}
複製程式碼

以上就是對於 module 在構建過程中 loader 執行流程的原始碼分析。可能平時在使用 webpack 過程瞭解相關的 loader 執行規則和策略,再配合這篇對於內部機制的分析,應該會對 webpack loader 的使用有更加深刻的印象。

相關文章