问题
以加载 less 为例。
// example.jsrequire('./style.less');// style.less@color: #000fff;.content {width: 50px;height: 50px;background-color: @color;}
按照官方文档,想要加载 less 文件,我们需要配置三个 loader:style-loader!css-loader!less-loader。
该从什么地方着手研究呢? → 仔细观察最终生成的 output.js ,如下图所示。
由此我们进行以下思考:
既然最终 css 代码会被插入到 head 标签中,那么一定是模块2在起作用。但是,项目中并不包含这部分代码,经过排查,发现源自于 node-modules/style-loader/addStyle.js ,也就是说,是由 style-loader 引入的。(后面我们再考察是如何引入的)
观察模块3,那应该是 less 代码经过 less-loader 的转换之后,再包装一层 module.exports,成为一个 JS module。
style-loader 和 less-loader 的作用已经明了,但是,css-loader 发挥什么作用呢?虽然我一直按照官方文档配置三个 loader,但我从未真正理解为什么需要 css-loader。后来我在 css-loader 的文档中找到了答案。
@import and url() are interpreted like import and will be resolved by the css-loader.
来源:https://github.com/webpack-contrib/css-loader#options
既然如此,为了降低实现的难度,我们暂时不予考虑 import 和 url 的情况,也就无需实现 css-loader 了。
- 观察模块1,
require(2)(require(3)),很显然:”模块3的导出作为模块2的输入参数,执行模块2“,也就是说:“将模块3中的 css 代码插入到 head 标签中“。理解这个逻辑不难,难点在于:webpack 如何知道应该拼接成require(2)(require(3)),而不是别的什么。也就说,如何控制拼接出require(2)(require(3))?
思路
思路进行到这儿,似乎走不下去了。看来只分析 output.js 还不足以理清,那么,让我们更进一步,观察 depTree,如下图所示。(图片较大,请点击放大查看)

问题在于:为什么凭空多出来2个模块?到底是哪里起了作用呢?→ 我在 style-loader 的源码中找到了答案。
style-loader 的再 require
// style-loader/index.jsconst path = require('path');module.exports = function (content) {// content 的值为:/Users/youngwind/www/fake-webpack/node_modules/style-loader-fake/index.js!/Users/youngwind/www/fake-webpack/node_modules/less-loader-fake/index.js!/Users/youngwind/www/fake-webpack/examples/loader/style.lesslet loaderSign = this.request.indexOf("!");let rawCss = this.request.substr(loaderSign);// rawCss 的值为:/Users/youngwind/www/fake-webpack/node_modules/less-loader-fake/index.js!/Users/youngwind/www/fake-webpack/examples/loader/style.lessreturn "require(" + JSON.stringify(path.join(__dirname, 'addStyle')) + ")" +"(require(" + JSON.stringify(rawCss) + "))";};
观察源码,我们发现:style-loader 返回的字符串里面又包含了2个 require,分别 require 了 addStyle 和 less-loader!style.less,由此,我们终于找到了突破口。→ loader 本质上是一个函数,输入参数是一个字符串,输出参数也是一个字符串。当然,输出的参数会被当成是 JS 代码,从而被 esprima 解析成 AST,触发进一步的依赖解析。 这就是多引入2个模块的原因。
loaders 的拆解与运行
loaders 就像首尾相接的管道那样,从右到左地被依次运行。对应的代码如下:
// buildDep.js/*** 运算文件类型对应的 loaders,比如: less 文件对应 style-loader 和 less-loader* 这些 loaders 本质上是一些处理字符串的函数,输入是一个字符串,输出是另一个字符串,从右到左串行执行。* @param {string} request 相当于 filenamesWithLoader ,比如 /Users/youngwind/www/fake-webpack/node_modules/fake-style-loader/index.js!/Users/youngwind/www/fake-webpack/node_modules/fake-less-loader/index.js!/Users/youngwind/www/fake-webpack/examples/loader/style.less* @param {array} loaders 此类型文件对应的loaders* @param {string} content 文件内容* @param {object} options 选项* @returns {Promise}*/function execLoaders(request, loaders, content, options) {return new Promise((resolve, reject) => {// 当所有 loader 都执行完了,输出最终的字符串if (!loaders.length) {resolve(content);return;}let loaderFunctions = [];loaders.forEach(loaderName => {let loader = require(loaderName);// 每个loader 本质上是一个函数loaderFunctions.push(loader);});nextLoader(content);/**** 调用下一个 loader* @param {string} content 上一个loader的输出字符串*/function nextLoader(content) {if (!loaderFunctions.length) {resolve(content);return;}// 请注意: loader有同步和异步两种类型。对于异步loader,如 less-loader,// 需要执行 async() 和 callback(),以修改标志位和回传字符串let async = false;let context = {request,async: () => {async = true;},callback: (content) => {nextLoader(content);}};// 就是在这儿逐个调用 loaderlet ret = loaderFunctions.pop().call(context, content);if(!async) {// 递归调用下一个 loadernextLoader(ret);}}});}
请注意:loader 也是分为同步和异步两种的,比如 style-loader 是同步的(看源码就知道,直接 return);而 less-loader 却是异步的,为什么呢?
异步的 less-loader
// less-loaderconst less = require('less');module.exports = function (source) {// 声明此 loader 是异步的this.async();let resultCb = this.callback;less.render(source, (e, output) => {if (e) {throw `less解析出现错误: ${e}, ${e.stack}`;}resultCb("module.exports = " + JSON.stringify(output.css));});}
由代码我们可以看出:less-loader 本质上只是调用了 less 本身的 render 方法,由于 less.render 是异步的,less-loader 肯定也得异步,所以需要通过回调函数来获取其解析之后的 css 代码。
node-modules 的逐级查找
还差最后一点,我们就能完成 loader 机制了。
试想以下情景:webpack 检测到当前为 less 文件,需要找到 style-loader 和 less-loader 运行。但是,webpack 怎么知道这两个 loader 藏在哪个目录下面呢?他们可能藏在 example.js 所在目录的任意上层文件夹的 node-modules 中。 说到底,我们还是得实现之前提到过的 node-modules 的逐级查找功能。 核心代码如下:
// resolve.js/*** 根据 loaders / 模块名,生成待查找的路径集合* @param {string} context 入口文件所在目录* @param {array} identifiers 可能是loader的集合,也可能是模块名* @returns {Array}*/function generateDirs(context, identifiers) {let dirs = [];for (let identifier of identifiers) {if (path.isAbsolute(identifier)) {// 绝对路径if (!path.extname(identifier)) {identifier += '.js';}dirs.push(identifier);} else if (identifier.startsWith('./') || identifier.startsWith('../')) {// 相对路径dirs.push(path.resolve(context, identifier));} else {// 模块名,需要逐级生成目录let ext = path.extname(identifier);if (!ext) {ext = '.js';}let paths = context.split(path.sep);let tempPaths = paths.slice();for (let folder of tempPaths) {let newContext = paths.join(path.sep);dirs.push(path.resolve(newContext, './node_modules', `./${identifier}-loader-fake`, `index${ext}`));paths.pop();}}}return dirs;}
举个例子,对于 style-loader 来说,生成的查找路径集合如下:
["/Users/youngwind/www/fake-webpack/examples/loader/node_modules/style-loader-fake/index.js","/Users/youngwind/www/fake-webpack/examples/node_modules/style-loader-fake/index.js","/Users/youngwind/www/fake-webpack/node_modules/style-loader-fake/index.js","/Users/youngwind/www/node_modules/style-loader-fake/index.js","/Users/youngwind/node_modules/style-loader-fake/index.js","/Users/node_modules/style-loader-fake/index.js",]
程序按照这个顺序依次查找,直到找到为止或者最终找不到抛出错误。

