代码仓库:github.com/echovie/lgp...
概述
Lgpack 是本人实现的一个轻量级的模块打包器实现,模仿了 Webpack 的核心架构和功能。
整个项目采用 monorepo 结构,使用 pnpm workspace 管理多个包。
项目结构
bash
lgpack/
├── packages/
│ ├── lgpack/ # 核心打包器
│ ├── lgpack-cli/ # 命令行工具
│ ├── lgpack-dev-server/ # 插件 - 提供一个本地web服务器,运行和调试前端代码
│ ├── html-lgpack-plugin/ # 插件 - 生成HTML
│ └── playground/ # 示例项目
├── package.json
└── pnpm-workspace.yaml
核心包分析
@lgpack/playground
javascript
// title.js
module.exports = 'title'
// index.js
let title = require("./title");
console.log("Hello from playground!");
console.log("title", title);
console.log("执行了");
index.html
xml
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Lgpack Dev Server</title>
</head>
<body>
<div class="container">
<h1>🚀 Lgpack Dev Server</h1>
<p>This page is served from the <code>public</code> directory.</p>
<p>The dev server is working correctly!</p>
<div id="root"></div>
</div>
</body>
</html>
package.json
perl
{
"name": "@lgpack/playground",
"version": "1.0.0",
"description": "Playground for testing lgpack plugin and lgpack",
"private": true,
"scripts": {
"dev": "lgpack dev"
},
"keywords": [
"playground",
"demo"
],
"author": "",
"license": "ISC",
"dependencies": {
"@lgpack/lgpack-cli": "workspace:*",
"@lgpack/html-lgpack-plugin": "workspace:*",
"@lgpack/lgpack-dev-server": "workspace:*"
}
}
@lgpack/lgpack-cli
提供可执行命令,让用户可以方便地使用打包器。
项目解析
package.json
perl
{
"name": "@lgpack/lgpack-cli",
// ...
"bin": {
"lgpack": "./bin/cli.js"
},
}
- 当包被 全局安装 时,会在系统的 环境变量文件 创建 lgpack可执行命令软链,这个命令会指向 @lgpack/lgpack-cli这个包的bin/cli.js 文件
- 当包被 本地安装 时,会在 node_modules/.bin/ 目录下创建 lgpack命令的符号链接,指向 @lgpack/lgpack-cli这个包的bin/cli.js 文件。
bin/cli.js
javascript
#!/usr/bin/env node
const { program } = require("commander");
program.version("1.0.0").description("A lightweight lgpack-like bundler CLI");
// dev 命令
program
.command("dev")
.action(async (options) => {
try {
// 1. 根据命令行参数配置和宿主项目lgpack.config.js配置获取到最后的配置信息
// 2. lgpack(config), 调用@lgpack/lgpack包的lgpack方法,实例化一个compiler对象,注册插件
// 3. compiler.run(), 进行构建、打包、运行
} catch (error) {
console.error("Build failed:", error);
process.exit(1);
}
});
program.parse(process.argv);
@lgpack/lgpack
文件结构
bash
lgpack/
├── lib/
│ ├── lgpack.js # 主入口文件
│ ├── Compiler.js # 编译器主类
│ ├── Compilation.js # 编译过程管理
│ ├── NormalModule.js # 模块处理
│ ├── NormalModuleFactory.js # 模块工厂
│ ├── Chunk.js # Chunk 管理
│ ├── Parser.js # 代码解析器
│ ├── Stats.js # 构建统计
│ ├── temp/
│ │ └── main.ejs # 代码生成模板
│ └── node/
│ └── NodeEnvironmentPlugin.js
构建流程
markdown
1. 初始化阶段 (lgpack.js)
├── 创建 Compiler 实例
├── 应用 NodeEnvironmentPlugin
├── 挂载用户插件
└── 应用内置插件
2. 编译阶段 (Compiler.js)
├── beforeRun 钩子
├── run 钩子
├── beforeCompile 钩子
├── compile 钩子
├── make 钩子 (模块创建)
└── afterCompile 钩子
3. 模块处理阶段 (Compilation.js)
├── 创建入口模块
├── 递归处理依赖
├── 构建模块依赖图
└── 生成 chunks
4. 输出阶段
├── seal 钩子
├── emit 钩子
├── 生成最终代码
└── 写入文件系统
初始化阶段 (lgpack.js)
scss
const lgpack = function (options) {
// 01 实例化 compiler 对象
let compiler = new Compiler(options.context);
compiler.options = options;
// 02 初始化 NodeEnvironmentPlugin(让compiler具体文件读写能力)
new NodeEnvironmentPlugin().apply(compiler);
// 03 注册所有 用户注册的 插件至 compiler 对象身上
if (options.plugins && Array.isArray(options.plugins)) {
for (const plugin of options.plugins) {
plugin.apply(compiler);
}
}
// 04 注册所有 lgpack 内置的插件(入口)
new LgpackOptionsApply().process(options, compiler);
// 05 返回 compiler 对象即可
return compiler;
};
关键步骤:
- 创建 Compiler:实例化编译器,设置上下文和选项
- 应用 NodeEnvironmentPlugin:为编译器提供文件系统读写能力
- 注册用户插件:遍历用户配置的插件并应用
- 注册内置插件:处理入口配置,创建入口插件
compiler.js
scala
class Compiler extends Tapable {
constructor(context) {
super();
this.context = context;
this.hooks = {
// 入口配置钩子:在处理入口配置时触发。常用于插件读取或修改入口参数。如 EntryOptionPlugin 读取 entry 配置,或插件动态修改入口。
entryOption: new SyncBailHook(["context", "entry"]),
// 编译开始前钩子:在编译流程正式开始前异步触发。插件可在此做准备工作,如清理缓存、初始化环境等。
beforeRun: new AsyncSeriesHook(["compiler"]),
// 编译开始钩子:在 run 方法被调用时异步触发。插件可在此记录编译启动日志、统计信息等。
run: new AsyncSeriesHook(["compiler"]),
// 创建 compilation 对象前钩子:同步触发,可用于初始化 compilation。插件可在此为 compilation 注入自定义属性或资源。
thisCompilation: new SyncHook(["compilation", "params"]),
// compilation 创建后钩子:同步触发,通知 compilation 已经创建。插件可在此注册 compilation 级别的钩子,参与后续构建流程。
compilation: new SyncHook(["compilation", "params"]),
// 编译前钩子:在编译流程正式开始前异步触发(可用于准备编译参数)。插件可在此异步准备 loader、plugin 相关资源。
beforeCompile: new AsyncSeriesHook(["params"]),
// 编译钩子:同步触发,表示编译流程正式开始。插件可在此记录编译参数、初始化编译状态。
compile: new SyncHook(["params"]),
// 构建模块钩子:并行异步触发,主要用于模块的构建过程。插件可在此参与模块依赖分析、动态添加依赖等。
make: new AsyncParallelHook(["compilation"]),
// 编译后钩子:编译流程结束后异步触发。插件可在此处理编译结果、生成额外资源、分析依赖等。
afterCompile: new AsyncSeriesHook(["compilation"]),
// 资源输出前钩子:在输出文件前异步触发,可用于修改输出内容。插件可在此修改、压缩、替换输出资源,如 banner 插件、压缩插件等。
emit: new AsyncSeriesHook(["compilation"]),
// 编译完成钩子:所有流程结束后异步触发,可用于最终统计和清理。插件可在此输出编译统计、清理临时文件、通知构建完成等。
done: new AsyncSeriesHook(["stats"]),
};
}
}
编译器阶段 (Compiler.js)
编译流程 (Compiler.js)
执行 run方法
javascript
run(callback) {
console.log("run 方法执行了~~~~");
const finalCallback = function (err, stats) {
callback(err, stats);
};
const onCompiled = (err, compilation) => {
// 最终在这里将处理好的 chunk 写入到指定的文件然后输出至 dist
this.emitAssets(compilation, (err) => {
let stats = new Stats(compilation);
finalCallback(err, stats);
});
};
this.hooks.beforeRun.callAsync(this, (err) => {
this.hooks.run.callAsync(this, (err) => {
this.compile(onCompiled);
});
});
}
2. 编译过程 (Compiler.js)
Compiler:整个打包流程的"大管家",负责调度、管理所有构建流程。
javascript
compile(callback) {
const params = this.newCompilationParams();
this.hooks.beforeCompile.callAsync(params, (err) => {
this.hooks.compile.call(params);
const compilation = this.newCompilation(params);
this.hooks.make.callAsync(compilation, (err) => {
// 在这里我们开始处理 chunk
compilation.seal((err) => {
this.hooks.afterCompile.callAsync(compilation, (err) => {
callback(err, compilation);
});
});
});
});
编译步骤:
- beforeCompile:触发 编译前阶段 钩子
- compile:触发 编译阶段 钩子
- newCompilation:创建一个 Compilation 对象,用于开始具体的编译流程。
- make:触发 make 钩子,开始编译阶段具体的模块处理
- afterCompile:触发 编译后阶段 钩子
模块处理阶段(Compilation阶段)
Compilation:代表一次具体的构建任务,管理本次构建中所有的模块、依赖、资源、chunk 等
1. 创建构建对象,开启模块构建流程
Compilation.js
ini
class Compilation extends Tapable {
constructor(compiler) {
super();
this.compiler = compiler;
this.context = compiler.context;
this.options = compiler.options;
this.inputFileSystem = compiler.inputFileSystem;
this.outputFileSystem = compiler.outputFileSystem;
this.entries = []; // 存入所有入口模块的数组
this.modules = []; // 存放所有模块的数据
this.chunks = []; // 存放当前次打包过程中所产出的 chunk
this.assets = [];
this.files = [];
}
}
核心职责:
- 管理模块的创建和构建
- 处理模块依赖关系
- 生成 chunks
- 管理输出资源
2. 模块的创建和构建
NormalModule.js
创建模块,从入口文件开始构建模块,及其依赖模块树。
kotlin
class NormalModule {
constructor(data) {
this.context = data.context;
this.name = data.name;
this.moduleId = data.moduleId;
this.rawRequest = data.rawRequest;
this.parser = data.parser;
this.resource = data.resource;
this._source; // 存放某个模块的源代码
this._ast; // 存放某个模板源代码对应的 ast
this.dependencies = []; // 定义一个空数组用于保存被依赖加载的模块信息
}
build(compilation, callback) {
this.doBuild(compilation, (err) => {
this._ast = this.parser.parse(this._source);
// 这里的 _ast 就是当前 module 的语法树,我们可以对它进行修改
traverse(this._ast, {
CallExpression: (nodePath) => {
let node = nodePath.node;
// 定位 require 所在的节点
if (node.callee.name === "require") {
// 获取原始请求路径
let modulePath = node.arguments[0].value; // './title'
// 取出当前被加载的模块名称
let moduleName = modulePath.split(path.posix.sep).pop(); // title
// [当前我们的打包器只处理 js ]
let extName = moduleName.indexOf(".") == -1 ? ".js" : "";
moduleName += extName; // title.js
// 【最终我们想要读取当前js里的内容】 所以我们需要个绝对路径
let depResource = path.posix.join(
path.posix.dirname(this.resource),
moduleName
);
// 【将当前模块的 id 定义OK】
let depModuleId =
"./" + path.posix.relative(this.context, depResource); // ./src/title.js
// 记录当前被依赖模块的信息,方便后面递归加载
this.dependencies.push({
name: this.name,
context: process.cwd(),
rawRequest: moduleName,
moduleId: depModuleId,
resource: depResource,
});
// 替换内容
node.callee.name = "__webpack_require__";
node.arguments = [types.stringLiteral(depModuleId)];
}
},
});
// 将修改后的 ast 转回成 code
let { code } = generator(this._ast);
this._source = code;
callback(err);
});
}
}
处理步骤:
- AST 解析:将源码解析成 AST
- 遍历 AST:查找 CallExpression 节点
- 识别 require:找到 require 函数调用
- 路径解析:解析模块路径和 ID
- 依赖记录:将依赖信息添加到 dependencies 数组
- 代码替换 :将 require 替换为 webpack_require,替换路径
- 代码生成:将修改后的 AST 转换回代码
模块依赖处理
Compilation.js
javascript
processDependencies(module, callback) {
let dependencies = module.dependencies;
async.forEach(
dependencies,
(dependency, done) => {
this.createModule(
{
parser,
name: dependency.name,
context: dependency.context,
rawRequest: dependency.rawRequest,
moduleId: dependency.moduleId,
resource: dependency.resource,
},
null,
done
);
},
callback
);
}
处理过程:
- 使用
neo-async.forEach
并行处理所有依赖 - 为每个依赖创建新的模块
- 递归调用
createModule
处理子依赖 - 等待所有依赖处理完成后执行回调
输出阶段
创建Chunk对象封装
chunk.js
kotlin
class Chunk {
constructor(entryModule) {
this.entryModule = entryModule
this.name = entryModule.name
this.files = [] // 记录每个 chunk的文件信息
this.modules = [] // 记录每个 chunk 里的所包含的 module
}
}
从入口模块出发,寻找它的所有依赖模块,将它们的源代码合并
Compilation.js
kotlin
seal(callback) {
this.hooks.seal.call();
this.hooks.beforeChunks.call();
// 01 当前所有的入口模块都被存放在了 compilation 对象的 entries 数组里
// 02 所谓封装 chunk 指的就是依据某个入口,然后找到它的所有依赖,将它们的源代码放在一起,之后再做合并
for (const entryModule of this.entries) {
// 核心: 创建模块加载已有模块的内容,同时记录模块信息
const chunk = new Chunk(entryModule);
// 保存 chunk 信息
this.chunks.push(chunk);
// 给 chunk 属性赋值
chunk.modules = this.modules.filter(
(module) => module.name === chunk.name
);
}
// chunk 流程梳理之后就进入到 chunk 代码处理环节
this.hooks.afterChunks.call(this.chunks);
// 生成代码内容
this.createChunkAssets();
callback();
}
步骤:
- 触发 seal 钩子:开始封装过程
- 遍历入口模块:为每个入口创建 Chunk
- 分配模块:将相关模块分配给对应的 Chunk
- 生成资源 :调用
createChunkAssets
生成最终代码
代码生成、输出
ini
createChunkAssets() {
for (let i = 0; i < this.chunks.length; i++) {
const chunk = this.chunks[i];
const fileName = chunk.name + ".js";
chunk.files.push(fileName);
// 01 获取模板文件的路径
let tempPath = path.posix.join(__dirname, "temp/main.ejs");
// 02 读取模块文件中的内容
let tempCode = this.inputFileSystem.readFileSync(tempPath, "utf8");
// 03 获取渲染函数
let tempRender = ejs.compile(tempCode);
// 04 按ejs的语法渲染数据
let source = tempRender({
entryModuleId: chunk.entryModule.moduleId,
modules: chunk.modules,
});
// 输出文件
this.emitAssets(fileName, source);
}
}
emitAssets(compilation, callback) {
const emitFlies = (err) => {
const assets = compilation.assets;
let outputPath = this.options.output.path;
for (let file in assets) {
let source = assets[file];
let targetPath = path.posix.join(outputPath, file);
this.outputFileSystem.writeFileSync(targetPath, source, "utf8");
}
// 文件写入完成后,触发 done 钩子
this.hooks.done.callAsync(compilation, (err) => {
callback(err);
});
};
this.hooks.emit.callAsync(compilation, (err) => {
// 01 创建dist
mkdirp.sync(this.options.output.path);
// 02 在目录创建完成之后执行文件的写操作
emitFlies();
});
}
步骤:
- 遍历 Chunks:为每个 Chunk 生成对应的文件
- 读取模板:读取 EJS 模板文件
- 渲染数据:将模块数据渲染到模板中
- 输出资源:将生成的代码保存到 assets 中
@lgpack/html-lgpack-plugin
自动生成 HTML 文件,并注入打包后的 JavaScript 文件。
项目解析
javascript
const fs = require("fs");
const path = require("path");
const defaultTemplate =`<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<div id="root"></div>
</body>
</html>`;
class HtmlLgpackPlugin {
constructor(options = {}) {}
getTemplateContent() {
// 寻找并返回模板文件内容
if (fs.existsSync(path.resolve(process.cwd(), "index.html"))) {
return fs.readFileSync(path.resolve(process.cwd(), "index.html"), "utf8");
}
// 否则使用默认模板或传入的模板字符串
return defaultTemplate;
}
apply(compiler) {
// 注册异步插件,在 emit 阶段统一被触发(也就是chunk生成完成准备写入磁盘时)
compiler.hooks.emit.tapAsync(
"HtmlLgpackPlugin",
(compilation, callback) => {
// 获取所有 JS 文件
const jsFiles = Object.keys(compilation.assets).filter((asset) =>
asset.endsWith(".js")
);
// 生成 script 标签
const scripts = jsFiles
.map((file) => `<script src="${file}"></script>`)
.join("\n ");
// 获取模板内容
let html = this.getTemplateContent();
// 替换标题
html = html.replace(/{{title}}/g, this.options.title);
// 注入 script 标签
if (scripts) {
html = html.replace("</body>", ` ${scripts}\n</body>`);
}
// 添加到输出
compilation.assets[this.options.filename] = html;
callback();
}
);
}
}
module.exports = HtmlLgpackPlugin;
@lgpack/lgpack-dev-server
文件结构
r
lgpack-dev-server/
├── index.js # 主入口文件
├── browser-opener.js # 浏览器打开工具
└── package.json # 包配置
项目解析
kotlin
const http = require("http");
const fs = require("fs");
const path = require("path");
const BrowserOpener = require("./browser-opener");
/**
* 可以作为 webpack 插件使用,在编译完成后自动启动服务器
*/
class LgpackDevServer {
constructor(options = {}) {
// 合并默认配置和用户配置
this.options = {
port: options.port || 8080,
host: options.host || "localhost",
static: options.static || "./dist",
open: options.open || false,
...options,
};
this.server = null; // HTTP 服务器实例
this.browserOpener = new BrowserOpener(); // 浏览器打开工具
}
apply(compiler) {
// 注册异步插件,在编译完成且文件也写入磁盘时被触发
compiler.hooks.done.tap("LgpackDevServer", () => {
this.startServer();
});
}
startServer() {
// 防止重复启动服务器
if (this.server) {
console.log("Dev server already running");
return;
}
// 创建 HTTP 服务器
this.server = http.createServer((req, res) => {
this.handleRequest(req, res);
});
// 启动服务器监听
this.server.listen(this.options.port, this.options.host, () => {
const url = `http://${this.options.host}:${this.options.port}`;
console.log(`🚀 Dev server running at ${url}`);
// 如果配置了自动打开浏览器,延迟 500ms 后打开
if (this.options.open) {
this.browserOpener.openDelayed(url, 500);
}
});
}
handleRequest(req, res) {
// 处理根路径请求,默认返回 index.html
const pathname = req.url === "/" ? "/index.html" : req.url;
// 构建完整的文件路径
const filePath = path.join(process.cwd(), this.options.static, pathname);
// 检查文件是否存在
if (!fs.existsSync(filePath)) {
res.writeHead(404, { "Content-Type": "text/plain" });
res.end("File not found");
return;
}
// 异步读取文件内容
fs.readFile(filePath, (err, data) => {
if (err) {
// 文件读取失败,返回 500 错误
res.writeHead(500, { "Content-Type": "text/plain" });
res.end("Internal server error");
return;
}
// 根据文件扩展名设置正确的 Content-Type
const ext = path.extname(filePath);
const contentType = this.getContentType(ext);
// 返回文件内容
res.writeHead(200, { "Content-Type": contentType });
res.end(data);
});
}
/**
* 关闭 HTTP 服务器并清理资源
*/
stop() {
if (this.server) {
this.server.close();
this.server = null;
console.log("🛑 Dev server stopped");
}
}
// 根据文件扩展名获取对应的 MIME 类型
getContentType(ext) {
const contentTypes = {
".html": "text/html",
".js": "application/javascript",
".css": "text/css",
".json": "application/json",
".png": "image/png",
".jpg": "image/jpeg",
".gif": "image/gif",
".svg": "image/svg+xml",
};
// 返回对应的 MIME 类型,如果没有找到则返回 text/plain
return contentTypes[ext] || "text/plain";
}
}
module.exports = LgpackDevServer;
browser-opener.js
javascript
const { exec } = require("child_process");
/**
* 提供跨平台的浏览器自动打开功能
* 支持多种浏览器和备用方案
*/
class BrowserOpener {
constructor(options = {}) {
this.options = {
delay: options.delay || 1000, // 延迟打开,确保服务器启动
...options,
};
}
open(url, silent = false) {
const { platform } = process;
// 执行浏览器打开命令, 这里省略了区分不同浏览器的命令
exec(`open "${url}"`, (error, stdout, stderr) => {
if (!silent) {
console.log("🌐 Browser opened successfully!");
}
});
}
// 延迟打开浏览器
openDelayed(url, delay = this.options.delay) {
setTimeout(() => {
this.open(url);
}, delay);
}
module.exports = BrowserOpener;
🎯 总结
本次技术分享分为两部分:一道回溯算法题和本人开发的一个mini版的webpack讲解。
mini版的webpack主要是讲了他的四个阶段的构建过程:
- 初始化阶段 (lgpack.js)
- 创建 Compiler 实例
- 应用 NodeEnvironmentPlugin
- 挂载用户插件
- 应用内置插件
- 编译阶段 (Compiler.js)
- beforeRun → run → beforeCompile → compile → make → afterCompile
- 模块处理阶段 (Compilation.js)
- 创建入口模块
- 递归处理依赖
- 构建模块依赖图
- 生成 chunks
- 输出阶段
- seal → emit → 生成最终代码 → 写入文件系统