# 底层原理

# 一、如何编写一个loader

1、创建一个loader

// replaceLoader.js
const loaderUtils = require('loader-utils');

// 编写一个loader,注意只能是function,不能是箭头函数,因为this需指向webpack
module.exports = function (source) {
    //console.log(source) 源代码

    /********************取参数******************/
    // 方法1、从this.query取出从options里传递过来的参数,前提是options是一个对象
    //console.log('this.query',this.query);  // this.query包含了从options里传递过来的参数
    // 方法2、使用loaderUtils的方法取(推荐)
    const options = loaderUtils.getOptions(this);  // options就是一个对象
    //console.log('options.name',options.name)


    /********************处理结果******************/
    // 方法1,直接return
    //return source.replace(new RegExp(this.query.name,'gm'), 'lensh')

    // 方法2,使用this.callback,这种适合需要把sourceMap传递出去的情况
    //const result = source.replace(this.query.name, 'lensh');
    //this.callback(null, result);

    // 方法3,使用this.async,这种适合有异步操作的逻辑
    const callback = this.async();
    setTimeout(() => {
        const result = source.replace(this.query.name, 'lensh');
        callback(null, result);
    }, 1000)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

说明:

  • 如果webpack的配置文件里的options是一个对象,则直接使用this.query就可以取出options里面的内容。如果不是一个对象,则借助loader-utils来处理。

  • 如果除了要返回结果,还需要返回sourceMap之类的东西,则使用this.callback(null, result)

  • 如果loader里面有异步操作,则使用this.async()来处理。

  • 如果需要在异步loader执行完后再执行同步loader,则把先要处理的loader放在数组后面。

例如,下面的replaceLoader.js负责把lensh换成dell,而replaceLoaderAsync.js负责异步把dell换成lensh,现在想要先处理异步loader,处理完后再处理同步loader,则需要修改webpack的配置,把先要处理的loader放在数组后面。

// replaceLoader.js
module.exports = function (source) {
    return source.replace(new RegExp('lensh','gm'), 'dell');
}

// replaceLoaderAsync.js
module.exports = function (source) {
    const callback = this.async();
    setTimeout(() => {
        const result = source.replace(new RegExp('dell','gm'), 'lensh');
        callback(null, result);
    }, 2000)
}


// webpack.config.js
rules: [{
    test: /\.js$/,
    // 先处理的loader放在数组后面
    use: [{
        loader: path.resolve(__dirname, './loaders/replaceLoader.js'),
    }, {
        loader: path.resolve(__dirname, './loaders/replaceLoaderAsync.js'),
        options: {
            name: 'dell'
        }
    }]
}]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
  • 如果我们不想使用 path.resolve(__dirname, './loaders/replaceLoader.js') 这样的方式引入自定义的loader,而是直接使用replaceLoader,则使用resolveLoader定义loader的寻找方式即可。

2、在webpack配置文件里配置这个loader。

// webpack.config.js
const path = require('path');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');

module.exports = {
    mode: "development",
    entry: {
        index: './src/index.js'
    },
    output: {
        filename: '[name].[chunkhash:8].js',
        path: path.resolve(__dirname, './dist')
    },
    // 定义loader的寻找方式
    resolveLoader: {
        modules: ['node_modules', './loaders'] // 如果定义的loader在node_modules下找不到,则还去loaders下面寻找
    },
    module: {
        rules: [{
            test: /\.js$/,
            //use: [path.resolve(__dirname, './loaders/replaceLoader.js')]
            use: [{
                loader: 'replaceLoader',
            }, {
                loader: 'replaceLoaderAsync',
                options: {
                    name: 'dell'
                }
            }]
        }]
    },
    plugins: [
        new CleanWebpackPlugin()
    ]
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

参考webpack官网

# 二、如何编写一个plugin

loader处理模块,plugin在打包过程中某些时刻生效。

1、新建一个copyright-webpack-plugin.js

我们要做的功能是打包前把dist目录清空,然后打包后在dist文件夹下新增一个copyright.txt文件。

const fs = require('fs')
const path = require('path')

// 清空目录
const emptyDir = (fileUrl) => {
    const files = fs.readdirSync(fileUrl)
    files.map(file => {
        const filePath = fileUrl + '/' + file;
        if (fs.statSync(filePath).isDirectory()) {
            emptyDir(filePath)
        } else {
            fs.unlinkSync(filePath)
            console.log("删除文件" + filePath + "成功")
        }
    })
}

// 插件必须是一个类
class CopyrightWebpackPlugin {
    constructor(options) {
        // options是webpack.config.js里的plugin里面传递过来的 { name: 'Lee' }
        console.log(options)
    }
    apply(compiler) { // compiler是webpack的一个实例
        // 准备编译的时刻是compile,同步用tap,异步用tapAsync
        compiler.hooks.compile.tap('CopyrightWebpackPlugin', (compilation) => {
            console.log('准备打包');
            const directory = path.resolve(__dirname, '../dist')
            // 清空dist目录
            emptyDir(directory);
        });

        // 在打包完准备放到dist目录下的时刻是emit
        compiler.hooks.emit.tapAsync('CopyrightWebpackPlugin', (compilation, cb) => {
            console.log('打包完了,准备放到dist目录里了');
            //debugger;  //这里加断点
            console.log(compilation.assets); // compilation.assets包含了打包后的所有内容
            // 往dist里加一个文件
            compilation.assets['copyright.txt'] = {
                source: function () {  // source,即copyright.txt文件里面的内容
                    return 'copyright'
                },
                size: function () { // 说明文件的大小是20字节
                    return 20;
                }
            };
            cb();
        });
    }
}

module.exports = CopyrightWebpackPlugin;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52

2、修改webpack.config.js

const CopyrightWebpackPlugin = require('./plugins/copyright-webpack-plugin');

// 修改plugins
plugins: [
    new CopyrightWebpackPlugin({
        name: 'Lee'
    })
]
1
2
3
4
5
6
7
8

如果我们需要打印出compilation下包含什么内容,我们可能会用console.log(compilation) 去看,但是这样不直观。我们可以用 node --inspect --inspect-brk node_modules/webpack/bin/webpack.js 命令去执行分析。

这个 --inspect 作用是开启node的调试,--inspect-brk是在node的第一行打一个断点。

然后作以下配置:

// copyright-webpack-plugin.js (加一个debugger)
compiler.hooks.emit.tapAsync('CopyrightWebpackPlugin', (compilation, cb) => {
    console.log('打包完了,准备放到dist目录里了');
    debugger;   // 在这里打断点
    console.log(compilation.assets); // compilation.assets包含了打包后的所有内容
    // 往dist里加一个文件
    compilation.assets['copyright.txt'] = {
        source: function () {  // source,即copyright.txt文件里面的内容
            return 'copyright'
        },
        size: function () { // 说明文件的大小是20字节
            return 20;
        }
    };
    cb();
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// package.json(使用node命令)
  "scripts": {
    "debug": "node --inspect --inspect-brk node_modules/webpack/bin/webpack.js"
  },
1
2
3
4

运行npm run debug,然后打开chrome浏览器的控制台,在Elements左边会有一个图标,点击这个图标,然后就可以看到打印出的内容了。

更多钩子

# 三、Bundler 源码编写

先进行模块分析,然后根据依赖关系生成Dependencies Graph(依赖图谱),最后生成打包后的代码。

打包步骤:

  • 1、读取入口文件的内容,得到一个字符串
  • 2、利用@babel/parser将字符串转成ast
  • 3、使用@babel/traverse进行ast的分析
  • 4、利用@babel/core将ast进行转义,得到的code就是可以在浏览器运行的代码
  • 5、通过遍历入口文件的Dependencies生成依赖图谱
  • 6、生成代码

安装相关模块,用于分析源代码,npm i -D @babel/parser @babel/traverse @babel/core @babel/preset-env

bundle.js

const fs = require('fs');
const path = require('path');
const parse = require('@babel/parser');
const babel = require('@babel/core');
const traverse = require('@babel/traverse').default;

// 分析模块
const moduleAnalyser = (filename) => {
    // 读取入口文件的内容,得到一个字符串
    const content = fs.readFileSync(filename, 'utf-8');

    // 利用@babel/parser将字符串转成ast
    const ast = parse.parse(content, {
        sourceType: 'module'  // 模块引入方式
    });
    //console.log(ast.program.body)  // 分析后的内容

    // 使用@babel/traverse进行ast的分析,得到对应的依赖
    const dependencies = {}
    traverse(ast, {
        ImportDeclaration({ node }) {
            const dirname = path.dirname(filename);  // 当前文件所在的目录
            const newFile = path.join(dirname, node.source.value);  // 将文件目录和文件拼接起来
            dependencies[node.source.value] = newFile;
        }
    });

    // 利用@babel/core将ast进行转义,得到的code就是可以在浏览器运行的代码
    const { code } = babel.transformFromAst(ast, null, {
        presets: ["@babel/preset-env"]
    });

    return {
        filename,  // 入口文件
        dependencies, // 入口文件对应的依赖
        code  // 翻译后的代码
    }
}
// 生成依赖图谱
const makeDependenciesGraph = (entry) => {
    // 入口模块的分析
    const entryModule = moduleAnalyser(entry);
    const graphArr = [entryModule];
    for (let i = 0; i < graphArr.length; i++) {
        const item = graphArr[i];
        const { dependencies } = item;
        if (dependencies) {
            for (let j in dependencies) {
                graphArr.push(moduleAnalyser(dependencies[j]));
            }
        }
    }
    const graph = {}
    graphArr.map(item => {
        graph[item.filename] = {
            dependencies: item.dependencies,
            code: item.code
        }
    })
    return graph;
}
// 生成代码
const gengerateCode = (entry) => {
    const graph = JSON.stringify(makeDependenciesGraph(entry)); // 依赖图谱
    // 因为需要生成打包代码,所以需要把代码放在闭包里面
    const str = `
    (function (graph) {
        function require(module) {
            // localRequire将相对路径转换成绝对路径
            function localRequire(relativePath) {
                // 依赖图谱的dependencies里面存储了每个模块的相对路径和绝对路径
                return require(graph[module].dependencies[relativePath])
            }

            // 闭包解析code
            var exports = {};
            (function (require, exports, code) {
                eval(code);
            })(localRequire, exports, graph[module].code);
            return exports;  // 把exports导出,这样下一个模块才能拿到前一个模块的结果
        };
        require('${entry}');
    })(${graph});
    `
    return str;

}

console.log(gengerateCode('./src/index.js'));
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89

我们把打印出来的内容放在浏览器控制台运行:

(function (graph) {
        function require(module) {
            // localRequire将相对路径转换成绝对路径
            function localRequire(relativePath) {
                // 依赖图谱的dependencies里面存储了每个模块的相对路径和绝对路径
                return require(graph[module].dependencies[relativePath])
            }

            // 闭包解析code
            var exports = {};
            (function (require, exports, code) {
                eval(code);
            })(localRequire, exports, graph[module].code);
            return exports;  // 把exports导出,这样下一个模块才能拿到前一个模块的结果
        };
        require('./src/index.js');
    })({"./src/index.js":{"dependencies":{"./message.js":"src\\message.js"},"code":"\"use strict\";\n\nvar _message = _interopRequireDefault(require(\"./message.js\"));\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { \"default\": obj }; }\n\nconsole.log(_message[\"default\"]);"},"src\\message.js":{"dependencies":{"./word.js":"src\\word.js"},"code":"\"use strict\";\n\nObject.defineProperty(exports, \"__esModule\", {\n  value: true\n});\nexports[\"default\"] = void 0;\n\nvar _word = require(\"./word.js\");\n\nvar message = \"say dell \".concat(_word.word);\nvar _default = message;\nexports[\"default\"] = _default;"},"src\\word.js":{"dependencies":{},"code":"\"use strict\";\n\nObject.defineProperty(exports, \"__esModule\", {\n  value: true\n});\nexports.word = void 0;\nvar word = \"dell dell\";\nexports.word =word;"}});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

就会输出 say dell dell dell,说明我们打包成功了!

Last Updated: 3/26/2020, 4:20:55 PM