问题

以加载 less 为例。

  1. // example.js
  2. require('./style.less');
  3. // style.less
  4. @color: #000fff;
  5. .content {
  6. width: 50px;
  7. height: 50px;
  8. background-color: @color;
  9. }

按照官方文档,想要加载 less 文件,我们需要配置三个 loader:style-loader!css-loader!less-loader。

该从什么地方着手研究呢? → 仔细观察最终生成的 output.js ,如下图所示。

webpack loader机制 - 图1

由此我们进行以下思考:

  1. 既然最终 css 代码会被插入到 head 标签中,那么一定是模块2在起作用。但是,项目中并不包含这部分代码,经过排查,发现源自于 node-modules/style-loader/addStyle.js ,也就是说,是由 style-loader 引入的。(后面我们再考察是如何引入的)

  2. 观察模块3,那应该是 less 代码经过 less-loader 的转换之后,再包装一层 module.exports,成为一个 JS module。

  3. 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. 观察模块1,require(2)(require(3)),很显然:”模块3的导出作为模块2的输入参数,执行模块2“,也就是说:“将模块3中的 css 代码插入到 head 标签中“。理解这个逻辑不难,难点在于:webpack 如何知道应该拼接成 require(2)(require(3)),而不是别的什么。也就说,如何控制拼接出 require(2)(require(3))

思路

思路进行到这儿,似乎走不下去了。看来只分析 output.js 还不足以理清,那么,让我们更进一步,观察 depTree,如下图所示。(图片较大,请点击放大查看)
webpack loader机制 - 图2

问题在于:为什么凭空多出来2个模块?到底是哪里起了作用呢?→ 我在 style-loader 的源码中找到了答案。

style-loader 的再 require

  1. // style-loader/index.js
  2. const path = require('path');
  3. module.exports = function (content) {
  4. // 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.less
  5. let loaderSign = this.request.indexOf("!");
  6. let rawCss = this.request.substr(loaderSign);
  7. // rawCss 的值为:/Users/youngwind/www/fake-webpack/node_modules/less-loader-fake/index.js!/Users/youngwind/www/fake-webpack/examples/loader/style.less
  8. return "require(" + JSON.stringify(path.join(__dirname, 'addStyle')) + ")" +
  9. "(require(" + JSON.stringify(rawCss) + "))";
  10. };

观察源码,我们发现:style-loader 返回的字符串里面又包含了2个 require,分别 require 了 addStyle 和 less-loader!style.less,由此,我们终于找到了突破口。→ loader 本质上是一个函数,输入参数是一个字符串,输出参数也是一个字符串。当然,输出的参数会被当成是 JS 代码,从而被 esprima 解析成 AST,触发进一步的依赖解析。 这就是多引入2个模块的原因。

loaders 的拆解与运行

loaders 就像首尾相接的管道那样,从右到左地被依次运行。对应的代码如下:

  1. // buildDep.js
  2. /**
  3. * 运算文件类型对应的 loaders,比如: less 文件对应 style-loader 和 less-loader
  4. * 这些 loaders 本质上是一些处理字符串的函数,输入是一个字符串,输出是另一个字符串,从右到左串行执行。
  5. * @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
  6. * @param {array} loaders 此类型文件对应的loaders
  7. * @param {string} content 文件内容
  8. * @param {object} options 选项
  9. * @returns {Promise}
  10. */
  11. function execLoaders(request, loaders, content, options) {
  12. return new Promise((resolve, reject) => {
  13. // 当所有 loader 都执行完了,输出最终的字符串
  14. if (!loaders.length) {
  15. resolve(content);
  16. return;
  17. }
  18. let loaderFunctions = [];
  19. loaders.forEach(loaderName => {
  20. let loader = require(loaderName);
  21. // 每个loader 本质上是一个函数
  22. loaderFunctions.push(loader);
  23. });
  24. nextLoader(content);
  25. /***
  26. * 调用下一个 loader
  27. * @param {string} content 上一个loader的输出字符串
  28. */
  29. function nextLoader(content) {
  30. if (!loaderFunctions.length) {
  31. resolve(content);
  32. return;
  33. }
  34. // 请注意: loader有同步和异步两种类型。对于异步loader,如 less-loader,
  35. // 需要执行 async() 和 callback(),以修改标志位和回传字符串
  36. let async = false;
  37. let context = {
  38. request,
  39. async: () => {
  40. async = true;
  41. },
  42. callback: (content) => {
  43. nextLoader(content);
  44. }
  45. };
  46. // 就是在这儿逐个调用 loader
  47. let ret = loaderFunctions.pop().call(context, content);
  48. if(!async) {
  49. // 递归调用下一个 loader
  50. nextLoader(ret);
  51. }
  52. }
  53. });
  54. }

请注意:loader 也是分为同步和异步两种的,比如 style-loader 是同步的(看源码就知道,直接 return);而 less-loader 却是异步的,为什么呢?

异步的 less-loader

  1. // less-loader
  2. const less = require('less');
  3. module.exports = function (source) {
  4. // 声明此 loader 是异步的
  5. this.async();
  6. let resultCb = this.callback;
  7. less.render(source, (e, output) => {
  8. if (e) {
  9. throw `less解析出现错误: ${e}, ${e.stack}`;
  10. }
  11. resultCb("module.exports = " + JSON.stringify(output.css));
  12. });
  13. }

由代码我们可以看出: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 的逐级查找功能。 核心代码如下:

  1. // resolve.js
  2. /**
  3. * 根据 loaders / 模块名,生成待查找的路径集合
  4. * @param {string} context 入口文件所在目录
  5. * @param {array} identifiers 可能是loader的集合,也可能是模块名
  6. * @returns {Array}
  7. */
  8. function generateDirs(context, identifiers) {
  9. let dirs = [];
  10. for (let identifier of identifiers) {
  11. if (path.isAbsolute(identifier)) {
  12. // 绝对路径
  13. if (!path.extname(identifier)) {
  14. identifier += '.js';
  15. }
  16. dirs.push(identifier);
  17. } else if (identifier.startsWith('./') || identifier.startsWith('../')) {
  18. // 相对路径
  19. dirs.push(path.resolve(context, identifier));
  20. } else {
  21. // 模块名,需要逐级生成目录
  22. let ext = path.extname(identifier);
  23. if (!ext) {
  24. ext = '.js';
  25. }
  26. let paths = context.split(path.sep);
  27. let tempPaths = paths.slice();
  28. for (let folder of tempPaths) {
  29. let newContext = paths.join(path.sep);
  30. dirs.push(path.resolve(newContext, './node_modules', `./${identifier}-loader-fake`, `index${ext}`));
  31. paths.pop();
  32. }
  33. }
  34. }
  35. return dirs;
  36. }

举个例子,对于 style-loader 来说,生成的查找路径集合如下:

  1. [
  2. "/Users/youngwind/www/fake-webpack/examples/loader/node_modules/style-loader-fake/index.js",
  3. "/Users/youngwind/www/fake-webpack/examples/node_modules/style-loader-fake/index.js",
  4. "/Users/youngwind/www/fake-webpack/node_modules/style-loader-fake/index.js",
  5. "/Users/youngwind/www/node_modules/style-loader-fake/index.js",
  6. "/Users/youngwind/node_modules/style-loader-fake/index.js",
  7. "/Users/node_modules/style-loader-fake/index.js",
  8. ]

程序按照这个顺序依次查找,直到找到为止或者最终找不到抛出错误。