手写简易版webpack

315 阅读5分钟

首先新建两个文件夹:第一个用于存放等待webpack打包的项目源码和webpack配置文件,模拟平常写代码,第二个为webpack实现目录:

目录结构

webpack-dev 项目源码目录结构(用于测试自己手写的collin-pack)
.
├── dist                                  //用于存放打包后的产物,在webpack.config.js中配置好      
├── loader                                //用于规划存放后期的手写的loader
├── src                                   //用于存放待打包的源码      
|   ├── base
|   |   └── b.js
|	├── a.js
|   └── index.js
├── package.json                         
└── webpack.config.js                    //webpack的config文件

collin-pack 手写webpack库目录如下:
.
├── bin                                 //当执行打包命令时的执行文件存放目录
|   └── collin-pack.js                  //执行文件 
├── lib									//打包实现文件							
|	├── main.ejs						//打包用的模板
|   └── Compiler.js						//打包主要实现文件				
└── package.json

两个目录的package.json均使用yarn init -y生成

项目初始

实现准备的简单的源码和webpack配置如下:

// src/index.js
let str = require("./a.js");
console.log(str);

// src/a.js
let b = require('./base/b.js')
module.exports = 'a' + b

// src/base/b.js
module.exports = 'b';

// webpack.config.js
module.exports = {
  mode: "development",
  entry: "./src/index.js",
  output: {
    filename: "bundle.js",
    path: path.resolve(__dirname, "dist"),
  },
};

编写简单的console.log

待打包的项目源码和webpack配置已经准备好啦,来编写我们自己的webpack,首先编写执行文件,当我们运行npx collin-pack打包命令时首先找的就是这个文件

let path = require("path");

// 需要找到当前执行的路径,拿到webpack.conf.js
// 编译命令运行在项目的根目录下,因此path.resolve返回的是 项目根目录/webpack.config.js
let config = require(path.resolve("webpack.config.js"));

console.log(config)

简单的console.log,如何在源码目录可以使用自定义打包命令呢,两个不同的目录如何进行编译调试呢,使用yarn link可以做到

  1. 在collin-pack目录下执行yarn link
  2. 在webpack-dev目录下执行yarn link collin-pack
  3. 再源码目录中执行npx collin-pack,就能发现打印出来config配置啦

编写compiler

首先分析需求,源码之所以无法在浏览器中运行是由于浏览器不支持 CommonJS 规范,因此我们需要将CommonJS中模块导入导出语句自己实现,分析webpack打包后的文件可以发现(webpack打包原理),webpack自己实现了一套引用方法__webpack_require__,将es5中的require全部替换成了__webpack_require__

因此我们要做的事情便是将自己写的源码读取出来,将其中的require全部替换掉,生成一个object,key为模块名(即模块路径),value为需要运行的代码函数。最终将这个object注入到模板中,用ejs进行模板拼接,然后写入到最终的bundle.js文件中。

首先确定模板文件,在之前的webpack打包原理文章中生成的文件拷贝过来,替换掉与源码有关的内容,得到如下模板,即为我们的main.ejs。

(function (modules) {
  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]%>`);

      }),
    <%}%>

  });

main.ejs中需要传入两个变量:入口文件entryId和我们替换后生成的modules对象。接下来生成此对象。

Compiler.js 具体内容:

let path = require("path");
let fs = require("fs");
let t = require("@babel/types");
let babylon = require("babylon");
let traverse = require("@babel/traverse").default;
let generator = require("@babel/generator").default;
let ejs = require("ejs");
// babylon 是把源码转换成ast
// @babel/traverse 用于遍历生成的ast
// @babel/types 用于替换ast里面的内容
// @babel/generator 用于将替换后的结果生成

class Compiler {
  constructor(config) {
    // webpack config
    this.config = config;

    // 入口文件路径
    this.entryId; //常用的是 './src/index.js'

    // 保存所有的模块的对象
    this.modules = {};
    // 入口文件
    this.entry = config.entry;
    // 工作路径
    this.root = process.cwd();
  }
  
  // 公共函数,用于读取文件内容
  getSource(path) {
    let content = fs.readFileSync(path, "utf-8");
    return content;
  }

  // 将源码中的require全部替换为__webpack_require__
  parse(content, parentDir) {
    // 将文件内容转为ast
    let ast = babylon.parse(content);
    let dependencies = [];
    // 遍历ast,将ast中的require改成__webpack_require__,原因是浏览器无法解析require,同时由于平常写代码时会省略扩展名,修改require后扩展名也要加回来
    traverse(ast, {
      // 遍历ast 中的节点,遇到CallExpression时判断,CallExpression指的是函数调用语句类似a(), fn()
      // 这里取出了所有的require,require时会有大量的相对路劲,如./a.js
      // 相对路径都需要转化为相对webpack.config.js的相对路径,因为buildModule会递归调用,
      // path.join(this.root, dependency) 如果dependency为./a.js这种会出大问题
      CallExpression(p) {
        let node = p.node;
        if (node.callee.name === "require") {
          node.callee.name = "__webpack_require__";
          // 取出require中传入的参数,即为模块名
          let moduleName = node.arguments[0].value;
          // 判断是否有扩展名,没有默认加上js
          moduleName = moduleName + (path.extname(moduleName) ? "" : ".js");
          // path.join并不是将路径单纯相加,这里是获取路径的准确相对位置
          // moduleName = './' + path.join(parentDir, moduleName);
          moduleName = "./" + path.join(parentDir, moduleName);
          // 保存这个依赖
          console.log("moduleName", moduleName);
          dependencies.push(moduleName);
          // stringLiteral创建了一个ast节点。替代调原来的
          node.arguments = [t.stringLiteral(moduleName)];
        }
      },
    });
    // 将ast生成源码
    let sourceCode = generator(ast).code;
    return { sourceCode, dependencies };
  }

  // 生成moudles对象的主要函数
  buildModule(modulePath, isEntry) {
    // 读取文件内容,modulePath为文件的绝对路径,方便读取文件的内容
    let content = this.getSource(modulePath);
    // 获取模块名称

    let moduleId = `./${path.relative(this.root, modulePath)}`;

    if (isEntry) {
      this.entryId = moduleId;
    }
    // 解析源码,返回源码和依赖数组
    let { sourceCode, dependencies } = this.parse(
      content,
      path.dirname(moduleId)
    ); //传入文件内容,以及文件所在目录,方便解析依赖项时使用
    // 将源码和模块名对应起来
    this.modules[moduleId] = sourceCode;
    dependencies.forEach((dependency) => {
      this.buildModule(path.join(this.root, dependency), false);
    });
  }

  //emitFile用于将模板和生成的modules结合,然后写入到output文件中
  emitFile() {
    let main = path.join(this.config.output.path, this.config.output.filename);
    let templateStr = this.getSource(path.join(__dirname, "main.ejs"));
    let code = ejs.render(templateStr, {
      entryId: this.entryId,
      modules: this.modules,
    });
    this.assests = {};
    this.assests[main] = code;
    fs.writeFileSync(main, this.assests[main]);
  }
  run() {
    // 执行并创建模块的依赖关系
    this.buildModule(path.resolve(this.root, this.entry), true);
    // 将打包后的文件发射出去
    this.emitFile();
  }
}
module.exports = Compiler;

解析: Compiler主要分为三个函数

buildModule: 首先找到入口文件,在parse函数中解析入口文件的require,将所有的require内容转为相对根路径的相对路径后,存入dependencies数组中,然后parse函数返回转化后的源码和依赖, 再递归解决依赖。

emitFile:用于将模板和生成的modules结合,然后写入到output文件中

run:入口函数

collin-pack.js中调用Compiler.js:

#! /usr/bin/env node
let path = require("path");

// 需要找到当前执行的路径,拿到webpack.conf.js
// 编译命令运行在项目的根目录下,因此返回的是 项目根目录/webpack.config.js
let config = require(path.resolve("webpack.config.js"));

let Compiler = require("../lib/Compiler");
let compiler = new Compiler(config);

// 表示运行编译
compiler.run();