首先新建两个文件夹:第一个用于存放等待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
可以做到
- 在collin-pack目录下执行
yarn link
- 在webpack-dev目录下执行
yarn link collin-pack
- 再源码目录中执行
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();