Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

webpack源码学习系列之三:loader 机制 #101

Open
youngwind opened this issue Feb 28, 2017 · 8 comments
Open

webpack源码学习系列之三:loader 机制 #101

youngwind opened this issue Feb 28, 2017 · 8 comments

Comments

@youngwind
Copy link
Owner

youngwind commented Feb 28, 2017

前言

在上一篇 #100 中,我们实现了 webpack 的 code-splitting 功能。今天,我们来探索 loader 机制,最终实现的代码版本参考这里(参考的 webpack 版本是这个

问题

以加载 less 为例。

// example.js
require('./style.less');
// style.less
@color: #000fff;
.content {
    width: 50px;
    height: 50px;
    background-color: @color;
}

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

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

image

由此我们进行以下思考:

  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 了。

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

思路

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

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

style-loader 的再 require

// style-loader/index.js
const 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.less
    let 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.less
    return "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);
                }
            };

            // 就是在这儿逐个调用 loader
            let ret = loaderFunctions.pop().call(context, content);
            if(!async) {
                // 递归调用下一个 loader
                nextLoader(ret);
            }
        }
    });

}

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

异步的 less-loader

// less-loader
const 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",
]

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

后话

至此,我们就完成了一个非常简单的 loader 机制,可以通过 style-loader 和 less-loader 处理加载 less 文件。当然,还有很多可以完善的地方,比如:

  1. 实现 css-loader,以处理 import 和 url 的情况
  2. 给 loader 传递选项参数,以控制是否压缩代码等等特性
  3. ……

----------- EOF ------------

@mosikoo
Copy link

mosikoo commented Jul 17, 2017

文中的depTree是从哪里生成的?@youngwind

@youngwind
Copy link
Owner Author

请先看第一篇 #99 ,里面有提到。 @mosikoo

@Thinking80s
Copy link

学习了

@mariotong
Copy link

mariotong commented Oct 11, 2017 via email

@Indomite
Copy link

get

@lyndon-graffiti
Copy link

博主,loader的pitch过程没说呀。。。

@Cloverao
Copy link

大佬,你打包出来的文件为什么没有那么多的换行符之类的东西,看起来很干净?

@BoringDay
Copy link

BoringDay commented Jan 11, 2022 via email

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

8 participants