如何实现一个mini版的webpack

代码仓库: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;
};

关键步骤:

  1. 创建 Compiler:实例化编译器,设置上下文和选项
  2. 应用 NodeEnvironmentPlugin:为编译器提供文件系统读写能力
  3. 注册用户插件:遍历用户配置的插件并应用
  4. 注册内置插件:处理入口配置,创建入口插件

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

编译步骤:

  1. beforeCompile:触发 编译前阶段 钩子
  2. compile:触发 编译阶段 钩子
  3. newCompilation:创建一个 Compilation 对象,用于开始具体的编译流程。
  4. make:触发 make 钩子,开始编译阶段具体的模块处理
  5. 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);
    });
  }
}

处理步骤:

  1. AST 解析:将源码解析成 AST
  2. 遍历 AST:查找 CallExpression 节点
  3. 识别 require:找到 require 函数调用
  4. 路径解析:解析模块路径和 ID
  5. 依赖记录:将依赖信息添加到 dependencies 数组
  6. 代码替换 :将 require 替换为 webpack_require,替换路径
  7. 代码生成:将修改后的 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();
}

步骤:

  1. 触发 seal 钩子:开始封装过程
  2. 遍历入口模块:为每个入口创建 Chunk
  3. 分配模块:将相关模块分配给对应的 Chunk
  4. 生成资源 :调用 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();
    });
  }

步骤:

  1. 遍历 Chunks:为每个 Chunk 生成对应的文件
  2. 读取模板:读取 EJS 模板文件
  3. 渲染数据:将模块数据渲染到模板中
  4. 输出资源:将生成的代码保存到 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主要是讲了他的四个阶段的构建过程:

  1. 初始化阶段 (lgpack.js)
  • 创建 Compiler 实例
  • 应用 NodeEnvironmentPlugin
  • 挂载用户插件
  • 应用内置插件
  1. 编译阶段 (Compiler.js)
  • beforeRun → run → beforeCompile → compile → make → afterCompile
  1. 模块处理阶段 (Compilation.js)
  • 创建入口模块
  • 递归处理依赖
  • 构建模块依赖图
  • 生成 chunks
  1. 输出阶段
  • seal → emit → 生成最终代码 → 写入文件系统
相关推荐
前端搬运侠12 分钟前
📝从零到一封装 React 表格:基于 antd Table 实现多条件搜索 + 动态列配置,代码可直接复用
前端
歪歪10015 分钟前
Vue原理与高级开发技巧详解
开发语言·前端·javascript·vue.js·前端框架·集成学习
zabr15 分钟前
我让AI一把撸了个算命网站,结果它比我还懂玄学
前端·aigc·ai编程
xianxin_16 分钟前
CSS Fonts(字体)
前端
用户25191624271116 分钟前
Canvas之画图板
前端·javascript·canvas
快起来别睡了43 分钟前
前端设计模式:让代码更优雅的“万能钥匙”
前端·设计模式
EndingCoder1 小时前
Next.js API 路由:构建后端端点
开发语言·前端·javascript·ecmascript·全栈·next.js·api路由
2301_810970391 小时前
wed前端第三次作业
前端
程序猿阿伟1 小时前
《深度解构:React与Redux构建复杂表单的底层逻辑与实践》
前端·react.js·前端框架
酒酿小圆子~1 小时前
【Agent】ReAct:最经典的Agent设计框架
前端·react.js·前端框架