教你如何手写 webpack 源码,其实并没那难

844 阅读7分钟

# 前言

我们在研究一些源码时,并不是为了能够写出一个一模一样的源码。比如你研究Vue源码并不是为了你能借此有所启发写出另一个前端框架(当然这也不排除有这种可能性),而是为了能更好的理解源码,理解我们所用的语言或框架的思想和逻辑,以便在日后的开发过程中更好的应用。

通过研究源码我们学习到了某种思想,某种处理逻辑,这样长此以往不断运用到自己的项目中,你才能更好的成长,最终向大佬更进一步。

今天我们一起来探究一下webpack源码,看是否对你有帮助~~

# 准备工作

创建 package.json 文件,内容如下

{
    "name": "source-code",
    "version": "1.0.0",
    "main": "index.js",
    "license": "MIT",
    "bin": {
        "mypack": "./bin/mypack.js"
    }
}

注 解:

bin:指在node环境下配置的命令行
"mypack": "./bin/mypack.js":指运行mypack命令时运行./bin/mypack.js文件

在 bin 文件夹下创建 mypack.js 文件,内容如下

#! /usr/bin/env node

console.log('start')

注 解:

#! /usr/bin/env node:指运行的环境是node

当前目录结构为:

1650006147(1).png

这时需要将我们的这个源码包映射到全局上,可以让其他的项目可以使用mypack命令,即运行npm link命令进行映射。

映射完成后,我们需要将源码包的那个全局命令映射到开发项目里,这样在项目里就可以使用npx mypack命令了,即运行npm link mypack即可,映射成功后,结果如下: image.png 这时运行npx mypack命令会输出start即将我们的源码包开发项目映射成功,nice ~~

# 打包逻辑分析

在我们运行npx mypck打包命令后,大概是做这么几件事:

  1. 找到当前运行路径下的配置文件webpack.config.js
  2. 通过Compiler核心类根据拿到的配置文件处理打包逻辑
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);
// 通过 run 方法启动打包
compiler.run();

bin文件夹同级创建lib文件夹来存放我们核心的处理逻辑类文件

Compiler.js 文件

const { resolve } = require('path');

class Compiler {
    constructor(config) {
        // 配置文件
        this.config = config;
        // 入口文件路径
        this.extryId;
        // 所有模块依赖
        this.modules = {};
        // 入口路径
        this.entry = config.entry;
        // 当前工作路径
        this.root = process.cwd();
    };
    /**
     * 创建模块的依赖关系
     * @param {*} modulePath:入口的绝对路径
     * @param {*} isEntry:是否是入口文件
     * @memberof Compiler
     */
    buildModule(modulePath, isEntry) {

    };
    // 发射一个打包后的文件
    emitFile() {

    };
    // 执行打包
    run() {
        // 1. 创建模块的依赖关系
        this.buildModule(resolve(this.root, this.entry), true);

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

module.exports = Compiler;

# 完善 Compoiler 中的 buildModule 方法

Compiler.js 文件

const { resolve, relative, dirname } = require('path');
const fs = require('fs');

class Compiler {
    constructor(config) {
        // 配置文件
        this.config = config;
        // 入口文件路径
        this.extryId;
        // 所有模块依赖
        this.modules = {};
        // 入口路径
        this.entry = config.entry;
        // 当前工作路径
        this.root = process.cwd();
    };
    // 读取路径下的模块内容
    getSoure(modulePath) {
        return fs.readFileSync(modulePath, 'utf8');
    };
    /**
     * 源码解析
     * @param {*} source:源码
     * @param {*} moduleName:路径
     */
    parseCode(source, moduleName) {
        console.log('开始解析源码了');

        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));

        console.log(sourceCode, dependencies);
    };
    // 发射一个打包后的文件
    emitFile() {

    };
    // 执行打包
    run() {
        // 1. 创建模块的依赖关系
        this.buildModule(resolve(this.root, this.entry), true);

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

module.exports = Compiler;

写到这里,运行命令npx mypack会输出如下内容: 1650012685(1).png
其实到现在为止就做了两件事:

  • 第一:拿到配置文件webpack.config.js,并且通过读取内容获得entry入口路径
  • 第二:通过root工作路径等一些操作,拿到入口文件的相对路径src/index.js再拼上./得到终的./src/index.js,以及入口文件的父路径src

具体parseCode方法是怎么解析源码的,我们接着往下看 ~~

# 完善 Compoiler 中的 parseCode 方法

这里要实现parseCode方法实现AST语法书需要借助单个模块,如下:

npm install babylon // 把源码转换成 ast
npm install @babel/traverse // 遍历节点
npm install @bebel/types    // 把节点替换掉
npm install @babel/generator

安装完成后,完善parseCodebuildModule两个方法,代码如下:

const { resolve, relative, dirname, extname, join } = require('path');
const fs = require('fs');

const babylon = require('babylon');
const traverse = require('@babel/traverse').default;
const t = require('@babel/types');
const generator = require('@babel/generator').default;

class Compiler {
    constructor(config) {
        // 配置文件
        this.config = config;
        // 入口文件路径
        this.extryId;
        // 所有模块依赖
        this.modules = {};
        // 入口路径
        this.entry = config.entry;
        // 当前工作路径
        this.root = process.cwd();
    };
    // 读取路径下的模块内容
    getSoure(modulePath) {
        return fs.readFileSync(modulePath, 'utf8');
    };
    /**
     * 源码解析
     * @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() {

    };
    // 执行打包
    run() {
        // 1. 创建模块的依赖关系
        this.buildModule(resolve(this.root, this.entry), true);
        // modules是依赖对象,this.entryId 是入口文件
        console.log(this.modules, this.entryId);
        // 2. 发射一个打包后的文件
        this.emitFile();
    }
}

module.exports = Compiler;

最后能在run方法里输出console.log(this.modules, this.entryId),nice ~~

# 完善 emitFile 方法

这个方法主要是拿到输出的目录,并把文件输出,为了省事不拼接字符串,我们利用ejs模块

npm install ejs

lib文件夹下创建模板文件main.ejs,内容如下:

 (function(modules) { // webpackBootstrap
     var installedModules = {};

     function __webpack_require__(moduleId) {
         if (installedModules[moduleId]) {
             return installedModules[moduleId].exports;
         }
         var module = installedModules[moduleId] = {
             i: moduleId,
             l: false,
             exports: {}
         };
         modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
         module.l = true;
         return module.exports;
     }
     __webpack_require__.m = modules;
     __webpack_require__.c = installedModules;
     __webpack_require__.d = function(exports, name, getter) {
         if (!__webpack_require__.o(exports, name)) {
             Object.defineProperty(exports, name, { enumerable: true, get: getter });
         }
     };
     __webpack_require__.r = function(exports) {
         if (typeof Symbol !== 'undefined' && Symbol.toStringTag) {
             Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
         }
         Object.defineProperty(exports, '__esModule', { value: true });
     };
     __webpack_require__.t = function(value, mode) {
         if (mode & 1) value = __webpack_require__(value);
         if (mode & 8) return value;
         if ((mode & 4) && typeof value === 'object' && value && value.__esModule) return value;
         var ns = Object.create(null);
         __webpack_require__.r(ns);
         Object.defineProperty(ns, 'default', { enumerable: true, value: value });
         if (mode & 2 && typeof value != 'string')
             for (var key in value) __webpack_require__.d(ns, key, function(key) { return value[key]; }.bind(null, key));
         return ns;
     };
     __webpack_require__.n = function(module) {
         var getter = module && module.__esModule ?
             function getDefault() { return module['default']; } :
             function getModuleExports() { return module; };
         __webpack_require__.d(getter, 'a', getter);
         return getter;
     };
     __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };
     __webpack_require__.p = "";
  
     return __webpack_require__(__webpack_require__.s = "<%-entryId%>");
 })
 ({
    <%for(let key in modules){%>
     "<%-key%>":
        (function(module, exports,__webpack_require__) {
            eval(`<%-modules[key]%>`);
        }),
    <%}%>
 });

完整的Compiler.js如下:

const { resolve, relative, dirname, extname, join } = require('path');
const fs = require('fs');

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();
    };
    // 读取路径下的模块内容
    getSoure(modulePath) {
        return fs.readFileSync(modulePath, 'utf8');
    };
    /**
     * 源码解析
     * @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'));
        console.log(templateStr)
            // 根据模板和代码输出编译后的代码块
        let code = ejs.render(templateStr, { entryId: this.entryId, modules: this.modules });
        this.assets = {};
        // 资源中路径对应的代码
        this.assets[main] = code;
        // 文件写入到哪
        fs.writeFileSync(main, this.assets[main]);
    };
    // 执行打包
    run() {
        // 1. 创建模块的依赖关系
        this.buildModule(resolve(this.root, this.entry), true);
        // 2. 发射一个打包后的文件
        this.emitFile();
    }
}

module.exports = Compiler;

至此,我们写的包可以处理简单的js模块打包了,但很弱,还需要加上loaderplugins等处理,我们下篇继续,nice ~~