教你如何手写 webpack 源码:loader 与 plugins 的实现

266 阅读4分钟

# 前言

上篇文章-教你如何手写 webpack 源码,其实并没那难中主要完成了JS模块的打包,那么想要处理非JS的语言就要用到了loaderplugins,实现的大概思路我们一起探讨下。

# loader 的实现

其实一个loader就是一个函数,我们先在项目代码里创建一个loader文件夹,再分别创建less-loader.jsstyle-loader.js,代码如下:

less-loader.js 文件

const less = require('less');

function loader(source) {
    let css = '';
    less.render(source, function(err, c) {
        css = c.css;
    })
    css = css.replace(/\n/g, '\\n')
    return css;
}

module.exports = loader;
style-loader.js 文件

function loader(source) {
    let style = `
        let style = document.createElement('style');
        style.innerHTML = ${JSON.stringify(source)}
        document.head.appendChild(style);
    `
    return style;
}

module.exports = loader;

项目代码里,配置webpack.config.js引入loader,如下:


const path = require('path');

module.exports = {
    mode: "development",
    entry: './src/index.js',
    output: {
        filename: 'bundle.js',
        path: path.resolve(__dirname, 'dist')
    },
    module: {
        rolues: [{
            test: /\.less$/,
            // 引入我们手写的 loader
            use: [
                path.resolve(__dirname, 'loader', 'style-loader.js'),
                path.resolve(__dirname, 'loader', 'less-loader.js')
            ]
        }]
    }
}

那么接下来我们就开始写打包的代码,需要在拿源码的函数getSoure里做处理:

Compiler.js 文件 ==> 只更新了 getSoure 方法,其他代码相同

// 读取路径下的模块内容
getSoure(modulePath) {
    let rules = this.config.module.rules;
    let content = fs.readFileSync(modulePath, 'utf8');
    // 循环遍历每一个规则
    for (let i = 0; i < rules.length; i++) {
        let rule = rules[i];
        let { test, use } = rule;
        let len = use.length - 1;
        // 判断模块是不是需要 loader 来转换
        if (test.test(modulePath)) {
            function normalLoader() {
                let loader = require(use[len--]);
                // 递归调用 laoder 实现转化功能
                content = loader(content)
                if (len >= 0) {
                    normalLoader();
                }
            }
            normalLoader();
        }
    }

    return content;
};

这时运行npx mypack打包后,运行代码查看,样式已经生效了,noce ~~

# plugins 的实现

我们每次再用plugin的时候都是new一下,由此可见,每个plugin都是一个构造函数,我们在实现plugin的时候,需要借助我们之前说的 tapable 模块,需要先安装下:

npm install tapable

安装完成后,需要进一步完善我们的Compoler.js如下:

Compiler.js 文件

const { resolve, relative, dirname, extname, join } = require('path');
const fs = require('fs');
const { SyncHook } = require('tapable');
const babylon = require('babylon');
const traverse = require('@babel/traverse').default;
const t = require('@babel/types');
const generator = require('@babel/generator').default;
const ejs = require('ejs');
class Compiler {
    constructor(config) {
        // 配置文件
        this.config = config;
        // 入口文件路径
        this.extryId;
        // 所有模块依赖
        this.modules = {};
        // 入口路径
        this.entry = config.entry;
        // 当前工作路径
        this.root = process.cwd();
        this.hooks = {
            entryOption: new SyncHook(), // 入口时调用
            compile: new SyncHook(), // 编译时调用
            afterCompile: new SyncHook(), // 编译之后
            afterPlugins: new SyncHook(), //  调用插件之后
            run: new SyncHook(), // 启动时
            emit: new SyncHook(), // 发射文件时
            done: new SyncHook() // 结束之后
        }

        // 取出所有的 plugin
        let plugins = this.config.plugins;
        if (Array.isArray(plugins)) {
            // 遍历 逐个执行
            plugins.forEach(plugin => {
                plugin.apply(this);
            })
        }
        this.hooks.afterPlugins.call();
    };
    // 读取路径下的模块内容
    getSoure(modulePath) {
        let rules = this.config.module.rules;
        let content = fs.readFileSync(modulePath, 'utf8');
        // 循环遍历每一个规则
        for (let i = 0; i < rules.length; i++) {
            let rule = rules[i];
            let { test, use } = rule;
            let len = use.length - 1;
            // 判断模块是不是需要 loader 来转换
            if (test.test(modulePath)) {
                function normalLoader() {
                    let loader = require(use[len--]);
                    // 递归调用 laoder 实现转化功能
                    content = loader(content)
                    if (len >= 0) {
                        normalLoader();
                    }
                }
                normalLoader();
            }
        }

        return content;
    };
    /**
     * 源码解析
     * @param {*} source:源码
     * @param {*} parentPath:路径
     */
    parseCode(source, parentPath) {
        // 把源码解析成ast
        let ast = babylon.parse(source);
        let dependencies = []; // 依赖数组
        // 遍历ast
        traverse(ast, {
                CallExpression(p) {
                    let node = p.node;
                    if (node.callee.name == 'require') {
                        // 重命名
                        node.callee.name = '__webpack_require__';
                        // 拿到模块的引用名字
                        let moduleName = node.arguments[0].value;
                        // 拿到模块的依赖
                        moduleName = moduleName + (extname(moduleName) ? '' : '.js');
                        moduleName = './' + join(parentPath, moduleName);
                        // 把所有依赖放在数组里
                        dependencies.push(moduleName);
                        // 替换依赖内容
                        node.arguments = [t.stringLiteral(moduleName)]
                    }
                }
            })
            // 把转换后的生成源码
        let sourceCode = generator(ast).code;
        return { sourceCode, dependencies }
    };
    /**
     * 创建模块的依赖关系
     * @param {*} modulePath:入口的绝对路径
     * @param {*} isEntry:是否是入口文件
     * @memberof Compiler
     */
    buildModule(modulePath, isEntry) {
        // 拿到模块内容
        const source = this.getSoure(modulePath);
        // 拿到模块的相对路径 = modulePath - this.root
        // relative(this.root, modulePath):获得相对路径
        const moduleName = './' + relative(this.root, modulePath);
        // 保存入口的名字
        if (isEntry) {
            this.entryId = moduleName;
        }
        // 把 source 源码进行改造,并返回一个依赖列表
        // dirname(moduleName):获得 moduleName 的父路径
        const { sourceCode, dependencies } = this.parseCode(source, dirname(moduleName));
        // 把相对路径和模块中的内容对应起来
        this.modules[moduleName] = sourceCode;
        // 遍历所有依赖项
        dependencies.forEach(dep => {
            // 递归加载附属模块
            this.buildModule(join(this.root, dep), false);
        })
    };
    // 发射一个打包后的文件
    emitFile() {
        // 输出路径
        let main = join(this.config.output.path, this.config.output.filename);
        // 模板路径
        let templateStr = this.getSoure(join(__dirname, 'main.ejs'));

        // 根据模板和代码输出编译后的代码块
        let code = ejs.render(templateStr, { entryId: this.entryId, modules: this.modules });
        this.assets = {};
        // 资源中路径对应的代码
        this.assets[main] = code;
        // 文件写入到哪
        fs.writeFileSync(main, this.assets[main]);
    };
    // 执行打包
    run() {
        this.hooks.run.call();

        this.hooks.compile.call();
        // 1. 创建模块的依赖关系
        this.buildModule(resolve(this.root, this.entry), true);
        this.hooks.afterCompile.call();

        // 2. 发射一个打包后的文件
        this.emitFile();

        this.hooks.emit.call()
        this.hooks.done.call()
    }
}

module.exports = Compiler;

这时还需要在我们的主文件bin/mypack.js文件里,调用一下入口的钩子,如下

mypack.js 文件

#! /usr/bin/env node

const { resolve } = require('path');
const Compiler = require('../lib/Compiler');

// 找到当前路径下的 webpack.config.js 文件
const config = require(resolve('webpack.config.js'));

// 通过配置文件处理打包逻辑
const compiler = new Compiler(config);
// 调用入口钩子
compiler.hooks.entryOption.call(); 
// 通过 run 方法启动打包
compiler.run();

现在我的打包文件准备好了,需要在项目文件里创建两个模拟的插件,创建一个plugins文件夹,并创建Test1.jsTest2.js两个插件文件,内如如下:

Test1.js 文件

class Test1 {
    apply(compile) {
        compile.hooks.emit.tap('emit', function() {
            console.log('我是 Test1');
        })
    }
}

module.exports = Test1;

Test2.js 文件

class Test2 {
    apply(compile) {
        compile.hooks.emit.tap('emit', function() {
            console.log('我是 Test2');
        })
    }
}

module.exports = Test2;

webpack.config.js文件里引入两个插件,如下:

webpack.config.js 文件

const path = require('path');
const Test1 = require('./src/plugins/Test1')
const Test2 = require('./src/plugins/Test2')

module.exports = {
    mode: "development",
    entry: './src/index.js',
    output: {
        filename: 'bundle.js',
        path: path.resolve(__dirname, 'dist')
    },
    module: {
        rules: [{
            test: /\.less$/,
            use: [
                path.resolve(__dirname, 'src/loaders', 'style-loader.js'),
                path.resolve(__dirname, 'src/loaders', 'less-loader.js')
            ]
        }]
    },
    plugins: [
        new Test1(),
        new Test2()
    ]
}

这时运行打包命令npx mypack会输出如下结果:

1650348962(1).jpg

到这里我们手写webpack的源码主要实现了js模块的打包,loaderplugin的实现原理,都是大致模拟真实webpack的实现原理,希望对你理解webpack有所帮助,nice ~~