手撕Webpack黑魔法!200行代码裸写核心打包器,面试官:你管这叫不懂原理?

110 阅读5分钟

"你每天都在用Webpack,却不知道它把代码塞进bundle的黑魔法? 当面试官问起模块加载原理,你是否只能支支吾吾?今天,我将用 200行代码 撕开Webpack的神秘面纱,带你手写一个核心打包器!无需Loader和插件,仅凭纯JavaScript实现依赖分析、AST解析、闭包封装三大核心机制。读完本文,你不仅能吊打打包原理面试题,还能亲手改造Webpack!"

一、暴力拆解Webpack

在动手编码前,我们先理解Webpack的核心工作流程:

  1. 入口分析:从配置的入口文件开始解析
  2. 依赖收集:构建模块依赖图(Module Graph)
  3. 代码转换:通过Loader处理不同资源
  4. 打包输出:生成可在浏览器运行的bundle

本次实现的mini-webpack将实现最核心的前两步和最后一步,重点展示依赖分析和打包原理。

图解4大核心阶段(入口→依赖收集→转换→输出)

以下是Webpack 核心工作原理流程图,直观展示其工作流程和关键环节:

image.png

流程图说明:

  1. 入口分析(起点)

    1. 从配置的入口文件(Entry)开始解析
    2. 绿色块表示起始点
  2. 依赖收集阶段

image.png

  1. 解析文件生成 AST 抽象语法树

  2. 识别所有 import/require 语句

  3. 递归构建完整的模块依赖图

  4. 代码转换阶段

image.png

  • 调用匹配的 Loader 处理不同资源
  • 转换操作:ES6→ES5、SASS→CSS、图片优化等
  1. 打包输出阶段

image.png

  • 将模块封装为 Chunk(代码块)

  • 执行 Tree Shaking、代码压缩等优化

  • 生成可在浏览器运行的 Bundle 文件

  • 橙色块表示最终产物

关键特性标注:

  • 依赖图(Module Graph) :递归收集形成的模块依赖关系网

  • Loader 系统:处理非 JS 资源的转换管道

  • Chunk 生成:根据分包策略(SplitChunks)生成代码块

  • Bundle:包含运行时代码的最终输出文件

二、mini_webpack项目结构设计

创建以下文件结构:

mini-webpack/
  ├── src/
  │   ├── compiler.js       # 编译器核心
  │   └── parser.js         # 模块解析器
  ├── example/
  │   ├── entry.js          # 示例入口文件
  │   ├── a.js              # 示例模块
  │   └── b.js              # 示例模块
  ├── index.js              # 入口文件
  └── package.json

三、核心代码实现

1. 模块解析器 (parser.js)

const fs = require('fs');
const path = require('path');
const babylon = require('babylon'); // 用于生成AST
const traverse = require('babel-traverse').default; // 用于遍历AST

let ID = 0; // 为每个模块分配唯一ID

// 创建模块信息
function createModuleInfo(filePath) {
  // 读取文件内容
  const content = fs.readFileSync(filePath, 'utf-8');
  
  // 使用Babylon生成AST(抽象语法树)
  const ast = babylon.parse(content, {
    sourceType: 'module',
  });
  
  // 收集模块的依赖
  const dependencies = [];
  
  // 遍历AST,找到所有import声明
  traverse(ast, {
    ImportDeclaration: ({ node }) => {
      dependencies.push(node.source.value);
    },
    CallExpression: ({ node }) => {
      if (node.callee.name === 'require') {
        dependencies.push(node.arguments[0].value);
      }
    }
  });
  
  // 为模块分配唯一ID
  const id = ID++;
  
  // 返回模块信息对象
  return {
    id,
    filePath,
    dependencies,
    code: content,
  };
}

// 构建依赖关系图
function createDependencyGraph(entry) {
  // 从入口文件开始解析
  const entryInfo = createModuleInfo(entry);
  
  // 依赖关系图队列
  const graphQueue = [entryInfo];
  
  // 遍历所有模块,广度优先
  for (const module of graphQueue) {
    module.mapping = {};
    const dirname = path.dirname(module.filePath);
    
    module.dependencies.forEach(relativePath => {
      // 将相对路径转为绝对路径
      const absolutePath = path.join(dirname, relativePath);
      
      // 防止重复处理
      if (!graphQueue.some(m => m.filePath === absolutePath)) {
        const childInfo = createModuleInfo(absolutePath);
        module.mapping[relativePath] = childInfo.id;
        graphQueue.push(childInfo);
      }
    });
  }
  
  return graphQueue;
}

module.exports = { createDependencyGraph };

代码思路(模块解析器 (parser.js) 工作流程):

image.png 关键数据结构:

{
  id: 0,                  // 模块唯一ID
  filePath: '/src/entry.js', // 绝对路径
  dependencies: ['./a.js','./b.js'], // 依赖列表
  code: '原始代码内容',     // 模块源代码
  mapping: {}              // 路径到ID的映射(后续添加)
}

2. 编译器核心 (compiler.js)

const fs = require('fs');
const path = require('path');
const { createDependencyGraph } = require('./parser');

class MiniWebpack {
  constructor(options) {
    this.options = options;
  }
  
  // 打包方法
  bundle() {
    const { entry, output } = this.options;
    
    // 创建依赖图
    const graph = createDependencyGraph(path.resolve(entry));
    
    // 生成模块对象字符串
    const modules = graph.map(module => {
      return `
        ${module.id}: [
          function(require, module, exports) {
            ${module.code}
          },
          ${JSON.stringify(module.mapping)}
        ],
      `;
    }).join('');
    
    // 生成最终bundle内容
    const bundleContent = `
      (function(modules) {
        // 缓存已加载模块
        const installedModules = {};
        
        // require函数实现
        function require(id) {
          // 检查模块是否已加载
          if (installedModules[id]) {
            return installedModules[id].exports;
          }
          
          // 创建新模块
          const [fn, mapping] = modules[id];
          
          const module = {
            exports: {}
          };
          
          // 缓存模块
          installedModules[id] = module;
          
          // 处理路径映射
          function localRequire(relativePath) {
            return require(mapping[relativePath]);
          }
          
          // 执行模块函数
          fn(localRequire, module, module.exports);
          
          // 返回模块导出
          return module.exports;
        }
        
        // 从入口模块开始执行
        require(0);
      })({
        ${modules}
      })
    `;
    
    // 确保输出目录存在
    const outputDir = path.dirname(output.path);
    if (!fs.existsSync(outputDir)) {
      fs.mkdirSync(outputDir, { recursive: true });
    }
    
    // 写入bundle文件
    fs.writeFileSync(
      path.resolve(output.path, output.filename),
      bundleContent
    );
  }
}

module.exports = MiniWebpack;

3. 入口文件 (index.js)

const MiniWebpack = require('./src/compiler');
const path = require('path');

const config = {
  entry: './example/entry.js',
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'bundle.js'
  }
};

const compiler = new MiniWebpack(config);
compiler.bundle();

console.log('打包完成!');

四、运行与效果

示例文件:

entry.js

const message = require('./a.js');
const count = require('./b.js');

console.log(message);
console.log('计数结果:', count.add(5, 3));

a.js

module.exports = '你好,我是模块A!';

b.js

module.exports = {
  add: (a, b) => a + b,
  subtract: (a, b) => a - b
};

运行我们的mini-webpack:

node index.js

在dist目录下生成bundle.js:

(function(modules) {
  // ... (runtime代码)
})({
  0: [
    function(require, module, exports) {
      const message = require('./a.js');
      const count = require('./b.js');
      
      console.log(message);
      console.log('计数结果:', count.add(5, 3));
    },
    {"./a.js":1,"./b.js":2}
  ],
  1: [
    function(require, module, exports) {
      module.exports = '你好,我是模块A!';
    },
    {}
  ],
  2: [
    function(require, module, exports) {
      module.exports = {
        add: (a, b) => a + b,
        subtract: (a, b) => a - b
      };
    },
    {}
  ],
})

在浏览器中运行这个bundle,控制台将输出:

你好,我是模块A!
计数结果: 8

五、浏览器运行bundle原理解析

既然明白了webpack如何打包源码,接下来可以再了解下浏览器识别打包后代码后如何正确运行~

1. 依赖图构建

通过AST解析技术(使用Babylon):

  • 识别require/import语句
  • 递归收集所有依赖
  • 为每个模块创建唯一ID
  • 建立模块间的映射关系

2. 运行时机制

生成的自执行函数包含:

  • 模块缓存:避免重复加载
  • require函数:实现模块加载
  • 模块执行环境:提供module/exports对象

3. 模块加载过程

  1. require 函数执行流程:

总结与结尾:

"当你亲手用200行代码让破碎的模块在浏览器中复活时,Webpack就不再是黑箱! 本文实现的迷你打包器虽未包含Loader和插件系统,却赤裸裸揭露了工程化核心:AST解析依赖 → 闭包封装作用域 → 运行时递归加载。下次面试被问"bundle.js为什么能运行?",你大可冷笑一声:"不过是个自执行函数配个require"!