从零开始手写mini-webpack

写在前面 :本文旨在从零手写 mini-webpack,帮助读者理解 Webpack 的核心原理与设计思想。为聚焦关键流程,部分实现做了简化处理(如 Tree Shaking 采用正则匹配、loader 链执行简化、代码分割仅处理单入口等),并非生产级实现。完整代码已上传至 GitHub,欢迎查阅交流:my-mini-webpack

一、准备工作

  1. 准备一个项目文件 mini-webpack-1.0
tex 复制代码
mini-webpack-1.0\
├── src\
│   ├── const.js
│   ├── info.js
│   └── main.js
├── mini-webpack.js
├── package-lock.json
└── package.json
  1. 初始化项目并安装必要依赖
bash 复制代码
npm init -y ; npm install @babel/parser @babel/traverse @babel/core @babel/preset-env
  1. 准备三个文件:

    1. const.js
    js 复制代码
    export const name = '我的世界'
    1. info.js
    js 复制代码
    import { name } from './const.js'
    export default `欢迎来到${name}`
    1. main.js
    js 复制代码
    import info from './info.js'
    console.log('main:', info)

二、手写mini-webpack 1.0

【步骤】

  • 读取文件
  • 构建AST
  • 收集依赖
  • ES6 → ES5
  • 生成依赖图
  • 打包
  • 输出文件

(1)读取文件代码:

js 复制代码
const fs = require('fs')

// 读取文件内容
function readFile(filePath) {
  const content = fs.readFileSync(filePath, 'utf-8')
  return content
}

console.log(readFile('./src/main.js'))

根目录下运行:node .\mini-webpack.js

bash 复制代码
# 控制台输出:
import info from './info.js'

console.log('main:', info)

(2)构建AST

js 复制代码
const parser = require('@babel/parser')  // 解析器

// 转成AST
function parse(content) {
  const AST = parser.parse(content, { sourceType: 'module'})
  return AST
}
const content = readFile('./src/main.js')
console.log(parse(content));
bash 复制代码
# 控制台输出AST抽象语法树:
Node {
  type: 'File',
  start: 0,
  end: 58,
  loc: SourceLocation {
    start: Position { line: 1, column: 0, index: 0 },
    end: Position { line: 3, column: 26, index: 58 },
    filename: undefined,
    identifierName: undefined
  },
  errors: [],
  program: Node {
    type: 'Program',
    start: 0,
    end: 58,
    loc: SourceLocation {
      start: [Position],
      end: [Position],
      filename: undefined,
      identifierName: undefined
    },
    sourceType: 'module',
    interpreter: null,
    body: [ [Node], [Node] ],
    directives: [],
    extra: { topLevelAwait: false }
  },
  comments: []
}

(3)收集依赖

js 复制代码
const traverse = require('@babel/traverse').default;  // 遍历器

// 遍历AST,收集依赖模块
function getDependencies(AST) {
  const dependencies = []
  traverse(AST, {
    ImportDeclaration({ node }) {
      dependencies.push(node.source.value)  // 将依赖模块路径添加到dependencies数组中
    }
  })
  return dependencies
}

// 测试一下:
const content = readFile('./src/main.js')
const AST = parse(content)
const dependencies = getDependencies(AST)
console.log(dependencies);
bash 复制代码
# 控制台输出:
[ './info.js' ]

输出解释:因为 读取的是 ./src/main.js,这个文件依赖了 './info.js'模块

(4) ES6 → ES5

js 复制代码
const babel = require('@babel/core')  // 编译器

// ES6 → ES5
function transformToES5(AST) {
  const { code } = babel.transformFromAstSync(AST, null, { presets: ['@babel/preset-env'] })
  return code
}

// 测试一下: 
const content = readFile('./src/main.js')
const AST = parse(content)
const es5Code = transformToES5(AST)
console.log(es5Code);
bash 复制代码
# 控制台输出:
"use strict";

var _info = _interopRequireDefault(require("./info.js"));
function _interopRequireDefault(e) { return e && e.__esModule ? e : { "default": e }; }
console.log('main:', _info["default"]); 

(5)生成依赖图谱

  1. 构建一个辅助函数,专门用来解析模块,返回该模块的信息:
js 复制代码
// 每个模块都有一个ID,用于标识该模块,并且初始模块ID为零
function parseModule(filePath, ID=0) {
  const content = readFile(filePath)
  const AST = parse(content)
  const dependencies = getDependencies(AST)
  const code = transformToES5(AST)
  return {
    ID,
    code,
    filePath,
    dependencies,
  }
}

// 测试一下:
const mainModule = parseModule('./src/main.js')
console.log(mainModule)
bash 复制代码
# 控制台输出:
{
  ID: 0,
  code: '"use strict";\n' +
    '\n' +
    'var _info = _interopRequireDefault(require("./info.js"));\n' +
    'function _interopRequireDefault(e) { return e && e.__esModule ? e : { "default": e }; }\n' +
    `console.log('main:', _info["default"]);`,
  filePath: './src/main.js',
  dependencies: [ './info.js' ]
}
  • parseModule解析模块,返回这个模块的:ID、代码、模块的路径、模块的依赖包
  • 这里传入一个ID,方便后面进行处理
遍历所有依赖,生成依赖图谱(也就是一个对象数组罢了)
js 复制代码
// 构建依赖图
function buildDependencyGraph() {
  const mainModule = parseModule('./src/main.js') // 解析入口模块
  const graph = [mainModule] // 依赖图谱
  let moduleID = 1

  // 遍历依赖图中的模块,递归处理依赖
  for (const module of graph) {
    const dirname = path.dirname(module.filePath)
    module.mapping = {} // 给当前模块多加一个mapping属性,存储依赖模块的相对路径到ID的映射

    // 遍历当前模块的依赖
    module.dependencies.forEach((dependencyFilePath) => {
      const absolutePath = path.join(dirname, dependencyFilePath)
      const childModule = parseModule(absolutePath, moduleID)

      module.mapping[dependencyFilePath] = moduleID
      graph.push(childModule)
      moduleID++
    })
  }

  return graph // 返回构建好的依赖图
}

// 测试一下:
console.log(buildDependencyGraph())
bash 复制代码
# 控制台输出:
[
  {
    ID: 0,
    code: '"use strict";\n' +
      '\n' +
      'var _info = _interopRequireDefault(require("./info.js"));\n' +
      'function _interopRequireDefault(e) { return e && e.__esModule ? e : { "default": e }; }\n' +
      `console.log('main:', _info["default"]);`,
    filePath: './src/main.js',
    dependencies: [ './info.js' ],
    mapping: { './info.js': 1 }
  },
  {
    ID: 1,
    code: '"use strict";\n' +
      '\n' +
      'Object.defineProperty(exports, "__esModule", {\n' +
      '  value: true\n' +
      '});\n' +
      'exports["default"] = void 0;\n' +
      'var _const = require("./const.js");\n' +
      'var _default = exports["default"] = "\\u6B22\\u8FCE\\u6765\\u5230".concat(_const.name);',
    filePath: 'src\\info.js',
    dependencies: [ './const.js' ],
    mapping: { './const.js': 2 }
  },
  {
    ID: 2,
    code: '"use strict";\n' +
      '\n' +
      'Object.defineProperty(exports, "__esModule", {\n' +
      '  value: true\n' +
      '});\n' +
      'exports.name = void 0;\n' +
      "var name = exports.name = '我的世界';",
    filePath: 'src\\const.js',
    dependencies: [],
    mapping: {}
  }
]

(6)打包

js 复制代码
// 打包模块
function bundle(graph) {
  let modules = ''
  /**
   * 构建模块映射表
   * 遍历依赖图中的每个模块,生成一个字符串形式的模块映射
   * 格式为:
   * {
   *   ID: [
   *     	function(require, module, exports) { 代码 },
   *     	{ '依赖相对路径': 模块ID, ... }
   *   	],
   *   ...
   * }
   * 作用:为后续的模块加载和执行提供映射关系
   */
  graph.forEach(mod => {
    modules += `
      ${mod.ID}: [
        	function(require, module, exports) {${mod.code} }, 
        	${JSON.stringify(mod.mapping)}
      ],
    `
  })

  const result = `
    (function(modules) {
        function require(id) {
            const [fn, mapping] = modules[id];
            function localRequire(relativePath) {
                return require(mapping[relativePath])
            }
            const module = {exports: {}}
            fn(localRequire, module, module.exports)
            return module.exports
        }
        require(0)
    })({${modules}})
  `
  return result
}

解释一下:

  1. 【构建模块映射表】:这一步是将原本是:

    js 复制代码
    {
        ID: 0,
        code: '"use strict";\n' +
          '\n' +
          'var _info = _interopRequireDefault(require("./info.js"));\n' +
          'function _interopRequireDefault(e) { return e && e.__esModule ? e : { "default": e }; }\n' 		+
          `console.log('main:', _info["default"]);`,
        filePath: './src/main.js',
        dependencies: [ './info.js' ],
        mapping: { './info.js': 1 }
    },

    这样的对象结构变成:

    js 复制代码
    0: [
        function(require, module, exports) {
            "use strict";
            var _info = _interopRequireDefault(require("./info.js"));
            function _interopRequireDefault(e) {return e && e.__esModule ? e : { "default": e};}
            console.log('main:', _info["default"]); 
        }, 
        {"./info.js":1}
    ],
  2. 【result】部分:

    js 复制代码
    (function(modules) {
     	/**
     	 * require函数作用:根据模块ID,拿到对应的代码和映射关系
     	 * 比方:require(0) → 拿到ID为零的模块,他的代码以及,他的代码所依赖的那个模块的路径和ID的映射
     	 * 		ID:0 → fn(){ ...require("./info.js") }    这个ID为零的模块依赖了一个叫做./info.js的模块
     	 *       而mapping:{"./info.js": 1} 就说明了,这个模块他对应的ID是1,那么接下来就需要去require(1)
     	 *       ...重复上述步骤
         */
       	function require(id) {
            const [fn, mapping] = modules[id];
        	function require(id) {
          		const [fn, mapping] = modules[id];
              /**
               * localRequire作用:根据相对路径,拿到对应的模块ID,递归调用require函数,加载依赖模块
               * 为啥需要这个函数呢?
               * 
               * 首先咱们看一下【构建模块映射表】的结果(随便取一个模块):
               * {
               *   ID: 0,
               *   code: 'fn(require, module, exports) { 
               *            ...
               *            var _info = _interopRequireDefault(require("./info.js"));
               *            ...
               *          }',
               *   mapping: { './info.js': 1 }
               * }
               * 可以看到,fn函数第一个参数是 require,这个函数在后面使用到了:require("./info.js")
               * 他存在的意义是:传入一个相对路径,然后加载对应的模块:
               * 
               * 1. 传入相对路径:"./info.js"  
               * 2. 然后根据mapping,找到对应的模块ID
               * 3. 递归调用require函数,根据模块ID加载依赖模块
               */
              function localRequire(relativePath) {
                return require(mapping[relativePath])
              }
              const module = { exports: {} }
              // 把 localRequire 、module、exports传递进去
              fn(localRequire, module, module.exports)
              return module.exports
            }
        require(0)
    })({${modules}})

打包完成之后

bash 复制代码
# 控制台输出:
(function(modules) {
        function require(id) {
            const [fn, mapping] = modules[id];
            function localRequire(relativePath) {
                return require(mapping[relativePath])
            }
            const module = {exports: {}}
            fn(localRequire, module, module.exports)
            return module.exports
        }
        require(0)
    })
(
    {
      0: [
        function(require, module, exports) {
        	"use strict";
			var _info = _interopRequireDefault(require("./info.js"));
			function _interopRequireDefault(e) { return e && e.__esModule ? e : { "default": e }; }
			console.log('main:', _info["default"]); 
		}, 
        {"./info.js":1}
      ],

      1: [
        function(require, module, exports) {
        	"use strict";
			Object.defineProperty(exports, "__esModule", { value: true });
			exports["default"] = void 0;
			var _const = require("./const.js");
			var _default = exports["default"] = "\u6B22\u8FCE\u6765\u5230".concat(_const.name); 
		},
        {"./const.js":2}
      ],

      2: [
        function(require, module, exports) {
        	"use strict";
			Object.defineProperty(exports, "__esModule", {value: true });
			exports.name = void 0;
			var name = exports.name = '我的世界'; 
		},
        {}
      ],
	}
)

控制台输出的打包结果可以直接放到浏览器控制台或者node环境中运行,结果会输出:

(7)输出文件

js 复制代码
// 写入dist目录
function run() {
  const graph = buildDependencyGraph()
  const bundleCode = bundle(graph)

  // 直接创建dist目录(如果不存在),recursive: true 确保自动创建父目录
  fs.mkdirSync('./dist', { recursive: true })
  fs.writeFileSync('./dist/bundle.js', bundleCode)
  console.log('build success ✔')
}

// 执行一下:
run()
bash 复制代码
# 控制台输出:
build success ✔

(8)配置 npm命令

  1. 打开根目录下的:package.json文件
  2. scripts配置下,增加一行命令: "build": "node mini-webpack.js"并保存
  3. 控制台输入:npm run build
  4. 输出跟webpack一样的提示:build success ✔
  5. 此时根目录下就会多一个dist 文件夹,内部有一个bundle.js就是打包完成后的代码

至此:基础版本的 mini-webpack书写完毕


三、进阶

mini-webpack 1.0简单的实现了webpack的核心流程,但它还缺少了:

  • 可配置性 :支持 webpack.config.js
  • Loader 机制:处理非 JS 资源(CSS、图片等)
  • Plugin 机制:在构建流程中注入自定义逻辑
  • Tree Shaking:消除未使用的导出,减小打包体积
  • 热更新(HMR):开发环境下实时更新模块,无需刷新页面
  • 代码分割:动态导入,按需加载
  1. 准备一个项目文件 mini-webpack-2.0
tex 复制代码
mini-webpack-2.0/
├── src/
│   ├── main.js
│   ├── info.js
│   ├── const.js
|   |------ rubish.js
│   └── style.css
├── loaders/
│   └── css-loader.js
├── plugins/
│   └── time-plugin.js
├── mini-webpack.js
|------ compiler.js
├── webpack.config.js
├── package.json
└── package-lock.json
  1. 初始化项目并安装必要依赖
bash 复制代码
npm init -y
npm install @babel/parser @babel/traverse @babel/core @babel/preset-env terser
  1. 准备示例文件(沿用 1.0 的文件,并增加 style.cssasync-module.js)。

    1. style.css
    css 复制代码
    body { background: #f0f0f0; }
    1. const.js更新
    js 复制代码
    export const name = '我的世界';
    export const version = '2.0'; // const.js被使用了,但是内部的version未被使用,将被 tree shaking 掉
    1. main.js更新
    js 复制代码
    import info from './info.js';
    import './style.css'; 
    console.log('main:', info);
    1. rubish.js
    js 复制代码
    // 整个模块都没有被使用过
    export default tools = () => console.log('这个模块不会被使用,会被 tree shaking 掉');
    1. time-plugin.js
    js 复制代码
    // Plugin 的本质就是一个带有apply方法的类,他作用webpack构建的整个生命周期
    // 这个Plugin就是记录webpack整个打包构建过程所花的时间
    class TimePlugin {
      apply(compiler) {
        compiler.hooks.beforeRun.tap('TimePlugin', () => {
          this.startTime = Date.now();
        });
        compiler.hooks.afterCompile.tap('TimePlugin', () => {
          const ms = Date.now() - this.startTime;
          console.log('\x1b[32m%s\x1b[0m', ` 构建完成,耗时 \x1b[1m${ms}ms\x1b[0m`); // 有颜色的输出
        });
      }
    }
    module.exports = TimePlugin;
  2. 创建配置文件:webpack.config.js

js 复制代码
const path = require('path');
const TimePlugin = require('./plugins/time-plugin');

module.exports = {
  entry: './src/main.js',
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'bundle.js'
  },
  module: {
    rules: [
      { test: /\.css$/, use: ['css-loader'] }
    ]
  },
  plugins: [new TimePlugin()],
  mode: 'production'
};

四、mini-webpack 2.0

当我们在控制台输入npm run build后,Node 启动 Webpack,读取 webpack.config.js 配置文件开启,webpack 整体构建流程:

【步骤】

  • 启动阶段:读取配置文件
  • 从入口构建依赖图谱:递归解析模块,生成依赖图
  • 模块翻译:用 Loader 处理非 JS 文件
  • 模块合并为 Chunks:根据入口和动态导入拆分代码块
  • 优化压缩 + Tree Shaking:删除未使用代码,压缩体积
  • 打包构建:代码打包并输出到文件
  • Compiler :Compiler 是打包流程的总调度 。调用 compiler.run() 一键启动构建,内部自动按顺序驱动所有步骤

(1)读取配置文件

js 复制代码
const path = require('path');

function loadConfig(configPath = './webpack.config.js') {
  const configFile = path.resolve(configPath);
  return require(configFile);
}

// 测试
console.log('配置加载完成', loadConfig());

根目录下运行:node .\mini-webpack.js

bash 复制代码
# 控制台输出:
配置加载完成 {
  entry: './src/main.js',
  output: {
    path: 'D:\\Code\\AllCode\\手写mini-webpack\\mini-webpack-2.0\\dist',
    filename: 'bundle.js'
  },
  module: { rules: [ [Object] ] },
  plugins: [],
  mode: 'development'
}

(2)从入口构建依赖图谱

js 复制代码
// 解析模块
function parseModule(filePath, ID) {
  const content = fs.readFileSync(filePath, 'utf-8'); 			// 读取文件
  const ast = parser.parse(content, { sourceType: 'module' }); 	 // 转AST
  const dependencies = [];  		                            // 收集依赖
  traverse(ast, {
    ImportDeclaration({ node }) {
      dependencies.push(node.source.value);
    },
  });
  const {code} = babel.transformFromAstSync(ast, null, { presets: ['@babel/preset-env'] }); // 转ES5
  return {ID, filePath, dependencies, code, mapping: {}};
}


// 构建依赖图
function buildGraph(entryPath) {
  let ID = 0;
  const entryModule = parseModule(entryPath, ID++);
  const graph = [entryModule];

  for (const module of graph) {
    const dirname = path.dirname(module.filePath)
    module.mapping = {}
    module.dependencies.forEach((dependencyFilePath) => {
      const absolutePath = path.join(dirname, dependencyFilePath)
      const childModule = parseModule(absolutePath, ID)
      module.mapping[dependencyFilePath] = ID
      graph.push(childModule)
      ID++
    })
  }
  return graph;
}

// 测试一下
const config = loadConfig()
const graph = buildGraph(config.entry)
console.log(graph)

根目录下运行:node .\mini-webpack.js 发现会报错,原因是:

在 入口文件(main.js)中,引入了css文件,而Babel 解析器只能解析 JavaScript 代码,无法解析 CSS 语法,

所以为了演示顺利,需要将main.js中的css导入部分先注释掉再运行

bash 复制代码
# 控制台输出(部分示例):
[
 {
    ID: 0,
    filePath: './src/main.js',
    dependencies: [ './info.js' ],
    code: '"use strict";\n' +
      '\n' +
      'var _info = _interopRequireDefault(require("./info.js"));\n' +
      'function _interopRequireDefault(e) { return e && e.__esModule ? e : { "default": e }; }\n' +
      "// import './style.css';\n" +
      `console.log('main:', _info["default"]);`,
    mapping: { './info.js': 1 }
  },
  ...
]

(3)模块翻译

刚刚发现了一个问题:Babel 解析器由于只能解析js部分的内容,遇到css直接撂挑子不干了,所以需要一个翻译器,将非 JS 文件,就交给 Loader 处理成Bable能看懂的JS文件

在根目录下,创建loader文件夹,写一个css-loader.js

js 复制代码
// loaders/css-loader.js
module.exports = function (source) {
  const cleanedCSS = source.replace(/\n/g, ' ').replace(/\s+/g, ' ').trim(); // 清理 CSS 内容
  return `
    const style = document.createElement('style');
    style.innerHTML = '${cleanedCSS}';
    document.head.appendChild(style);
  `;
}

继续在mini-webpack.js中:

js 复制代码
// loader翻译器
function runLoaders(filePath, source, rules) {
  // 1. 找到匹配的 loader 规则
  const rule = rules.find(r => r.test.test(filePath));
  if (!rule) return source;

  // 2. 处理 loader 数组
  const loaders = Array.isArray(rule.use) ? rule.use : [rule.use];

  // 3. 从右向左执行 loader
  // 因为 loader 的处理是管道式的,后写的 loader 先拿到原始内容,前写的 loader 后处理。
  return loaders.reverse().reduce((result, loader) => {
    const loaderPath = path.resolve('./loaders', loader + '.js');
    const loaderFn = require(loaderPath);
    return loaderFn(result);
  }, source);
}

//测试一下loader
const config = loadConfig()
const source = fs.readFileSync('./src/style.css', 'utf-8')
const result = runLoaders('./src/style.css', source, config.module.rules)
console.log(result)
bash 复制代码
# 控制台输出(可见,这里将css直接处理成了js):
const style = document.createElement('style');
style.innerHTML = 'body { background: #f0f0f0; }';     
document.head.appendChild(style);

此时需要更新一下parseModule,让他支持导入css模块

js 复制代码
// 解析模块
function parseModule(filePath, ID, rules) {
  let content = fs.readFileSync(filePath, 'utf-8'); 			  // 读取文件
  const config = loadConfig();                                    // 读取用户配置
  const rules = config.module.rules;                              // 读取用户配置的loader规则
  content = runLoaders(filePath, content, rules);                 // 执行loader翻译器
  const ast = parser.parse(content, { sourceType: 'module' }); 	  // 转AST
  const dependencies = [];  		                              // 收集依赖
  traverse(ast, {
    ImportDeclaration({ node }) {
      dependencies.push(node.source.value);
    },
  });
  const { code } = babel.transformFromAstSync(ast, null, { presets: ['@babel/preset-env'] }); 
  return { ID, filePath, dependencies, code, mapping: {} };
}


// 构建依赖图
function buildGraph(entryPath, rules) {
  let ID = 0;
  const entryModule = parseModule(entryPath, ID++, rules); // 将ruler传递下去
  const graph = [entryModule];

  for (const module of graph) {
    const dirname = path.dirname(module.filePath)
    module.mapping = {}
    module.dependencies.forEach((dependencyFilePath) => {
      const absolutePath = path.join(dirname, dependencyFilePath)
      const childModule = parseModule(absolutePath, ID, rules)
      module.mapping[dependencyFilePath] = ID
      graph.push(childModule)
      ID++
    })
  }
  return graph;
}

// 测试一下:
const config = loadConfig();
const rules = config.rules;
const graph = buildGraph(config.entry, rules);
console.log(graph);
bash 复制代码
# 控制台输出(部分示例,有删改):
[
	{
        ID: 0,
        filePath: './src/main.js',
        dependencies: [ './info.js', './style.css' ],
        code: '简化掉....',
        mapping: { './info.js': 1, './style.css': 2 }
     },
	{
        ID: 1,
        filePath: 'src\\info.js',
        dependencies: [ './const.js' ],
        code: '',
        mapping: { './const.js': 3 }
    },
    {
        ID: 2,
        filePath: 'src\\style.css',
        dependencies: [],
        code: '"use strict";\n' +
          '\n' +
          "var style = document.createElement('style');\n" +
          "style.innerHTML = 'body { background: #f0f0f0; }';\n" +
          'document.head.appendChild(style);',
        mapping: {}
  	},
  	...
]

css模块也被引入进来了,并且已经被loader处理成了js模块

(4)模块合并为chunk

说明 :真实 webpack 会根据入口配置和 splitChunks 选项,将模块分割为多个 chunk(例如公共模块提取、多入口分离、动态导入的异步 chunk)。本示例为了简化,只生成一个主 chunk(所有模块合并输出到一个文件)。

js 复制代码
// 模块合并和代码分割
function createChunks(graph) {
  const mainChunk = {
    id: 0,
    name: 'main',
    modules: graph,
    async: false
  };
  return [mainChunk];
}

// 测试一下:
const config = loadConfig();
const rules = config.rules;
const graph = buildGraph(config.entry, rules);
const chunks = createChunks(graph);
console.log(chunks);
bash 复制代码
# 控制台输出:
[
  { id: 0, name: 'main', modules: [ [Object], [Object], [Object], [Object] ],async: false}
]

(5)优化压缩

  1. TreeShaking

    【说明】

    • 为了简化,这里我们使用的是正则标记出所有exportimport的模块,找到未被使用的模块,直接通过正则删除掉,这种实现忽略了很多复杂情况(如多行导出、导出别名、注释干扰等),但足以展示 Tree Shaking 的原理。

    • 真实 Webpack 使用 AST 进行精确分析,支持更复杂的语法和边界情况,但原理相同:收集使用,移除未使用。

js 复制代码
function treeShaking(graph) {
  // 1. 拼接所有源码,正则提取被使用的导出名
  const allSource = graph.map(m => m.filePath.endsWith('.js') ? fs.readFileSync(m.filePath, 'utf-8') : '').join('\n');
  const usedNames = new Set();
  for (const m of allSource.matchAll(/import\s*(?:\{([^}]+)\}|(\w+))\s+from/g)) {
    (m[1] ? m[1].split(',') : [m[2]]).forEach(n => usedNames.add(n.trim()));
  }

  // 2. 遍历模块,正则删除未被引用的 export 声明行
  const exportRe = /^export\s+(?:const|let|var|function|class)\s+(\w+)[^\n]*/gm;
  graph.forEach(mod => {
    if (!mod.filePath.endsWith('.js')) return;
    const source = fs.readFileSync(mod.filePath, 'utf-8');
    const newSource = source.replace(exportRe, (_, name) => usedNames.has(name) ? _ : '');
    if (newSource !== source) {
      mod.code = babel.transformFromAstSync(parser.parse(newSource, { sourceType: 'module' }), null, { presets: ['@babel/preset-env'] }).code;
    }
  });
  return graph;
}

// 测试一下:
const config = loadConfig();
const rules = config.rules;
const graph = buildGraph(config.entry, rules);
const treeShakeGraph = treeShake(graph);
console.log(treeShakeGraph);
bash 复制代码
# 控制台输出(部分示例):
[
 ...
  {
    ID: 3,
    filePath: 'src\\const.js',
    dependencies: [],
    code: '"use strict";\n' +
      '\n' +
      'Object.defineProperty(exports, "__esModule", {\n' +
      '  value: true\n' +
      '});\n' +
      'exports.name = void 0;\n' +
      "var name = exports.name = '我的世界';",
    mapping: {}
  }
]

这里发现:

js 复制代码
// const.js
export const name = '我的世界';
export const version = '2.0'; // 未被使用,将被 tree shaking 掉

这里的version已经没有出现在依赖图谱中了,同样rubish.js也没有出现在依赖图谱中,被TreeShaking掉了

  1. 代码压缩
js 复制代码
// 生产环境就直接引入 terser 进行代码压缩
async function optimizeGraph(graph, mode) {
  if (mode === 'production') {
    const { minify } = require('terser');
    for (const module of graph) {
      const result = await minify(module.code);
      module.code = result.code;
    }
  }
  return graph;
}


// 测试一下:
(async () => {
  const config = loadConfig();
  const rules = config.rules;
  const graph = buildGraph(config.entry, rules);
  const graphAfterTreeShaking = treeShake(graph);
  const optimizedGraph = await optimizeGraph(graphAfterTreeShaking, config.mode);
  console.log(optimizedGraph);
})();
bash 复制代码
# 控制台输出(部分示例):
[
  {
    ID: 0,
    filePath: './src/main.js',
    dependencies: [ './info.js', './style.css' ],
    code: '"use strict";var _info=_interopRequireDefault(require("./info.js"));function _interopRequireDefault(e){return e&&e.__esModule?e:{default:e}}require("./style.css"),console.log("main:",_info.default);',
    mapping: { './info.js': 1, './style.css': 2 }
  },
  ...
]

可以看到代码成功被压缩了:删掉了换行,多余的空格等

(6)打包构建

js 复制代码
function bundle(graph) {
  let modules = ''
  graph.forEach(mod => {
    modules += `
      ${mod.ID}: [
        function(require, module, exports) {${mod.code} }, 
        ${JSON.stringify(mod.mapping)}
      ],
    `
  })

  const result = `
    (function(modules) {
        function require(id) {
            const [fn, mapping] = modules[id];
            function localRequire(relativePath) {
                return require(mapping[relativePath])
            }
            const module = {exports: {}}
            fn(localRequire, module, module.exports)
            return module.exports
        }
        require(0)
    })({${modules}})
  `
  return result
}

(7)Compiler

Compiler 是打包流程的总调度 。调用 compiler.run() 一键启动构建,内部自动按顺序驱动所有步骤,并在关键节点触发钩子(hooks),让插件有机会介入处理,这使得Plugin运行在整个打包构建生命周期

对应真实 Webpack 中,npx webpack 本质上就是创建 Compiler 并调用 run()

js 复制代码
// compiler.js - 编译器 & 钩子系统

const fs = require('fs');
const path = require('path');

// ======================== Hook(简易 Tapable) ========================

class Hook {
  constructor() { this.taps = []; }
  tap(_, fn) { this.taps.push(fn); }
  call(...args) { this.taps.forEach(fn => fn(...args)); }
}

// ======================== Compiler ========================

class Compiler {
  constructor(config, buildSteps) {
    this.config = config;
    this.buildSteps = buildSteps;

    // 生命周期钩子
    this.hooks = {
      beforeRun: new Hook(),     // 构建开始前
      afterCompile: new Hook(),  // 编译完成后
      emit: new Hook(),          // 输出文件前
      done: new Hook(),          // 全部完成
    };

    // 注册插件
    (config.plugins || []).forEach(plugin => plugin.apply(this));
  }

  async run() {
    // 1. 构建前
    this.hooks.beforeRun.call(this);

    // 2. 构建依赖图 → TreeShaking → 压缩 → 分 chunk → 打包
    const graph = this.buildSteps.buildGraph(this.config.entry, this.config.module.rules);
    this.buildSteps.treeShake(graph);
    const optimizedGraph = await this.buildSteps.optimizeGraph(graph, this.config.mode);
    const chunks = this.buildSteps.createChunks(optimizedGraph);
    const bundleCode = this.buildSteps.bundle(chunks);

    // 3. 编译完成
    this.hooks.afterCompile.call(this);

    // 4. 输出文件
    this.hooks.emit.call(this);
    if (bundleCode) {
      const { path: outputPath, filename } = this.config.output;
      fs.mkdirSync(outputPath, { recursive: true });
      fs.writeFileSync(path.join(outputPath, filename), bundleCode);
    }

    // 5. 全部完成
    this.hooks.done.call(this);
  }
}

module.exports = Compiler;

(8)启动构建

js 复制代码
// 入口
async function build () {
  const config = loadConfig();
  const compiler = new Compiler(config, { buildGraph, treeShake, optimizeGraph, createChunks, bundle });
  await compiler.run();
};

build();

(9)配置 npm命令

  1. 打开根目录下的:package.json文件
  2. scripts配置下,增加一行命令: "build": "node mini-webpack.js"并保存
  3. 控制台输入:npm run build
  4. 输出跟webpack一样的提示:build success ✔
  5. 此时根目录下就会多一个dist 文件夹,内部有一个bundle.js就是打包完成后的代码

五、热更新机制

HMR 的本质就是一件事:用新模块代码替换旧模块代码,尽量不刷新页面。整个机制分两端、四个步骤。

两端协作

  • 服务端(Dev Server):负责发现变化、编译变化、通知变化
  • 浏览器端(HMR Runtime):负责拉取更新、替换模块、决定是否刷新

四个步骤

  • 监听 + 增量编译
  • 生成更新清单
  • 通知浏览器拉取代码

1. 监听+增量编译

文件一改,Dev Server 不是从头编译所有模块,而是只重新编译变化的模块和它的下游依赖 ,其余模块直接用缓存。编译完后根据变更内容算出一个新的 Hash,作为这次更新的版本号。

2. 生成更新清单

编译完成后,Webpack 在内存中生成两个东西:

  • manifest(更新清单):一个 JSON 清单,记录这次 Hash 是什么、哪些模块ID变了
  • hot-update chunk 文件:每个变更模块对应的新代码
  1. 清单:
json 复制代码
{
  "c": { "0": true, "2": true },   // 模块ID=0 和 ID=2 发生了变化
  "h": "abc123def456"               // 本次编译的 hash
}
  1. 新代码
js 复制代码
webpackHotUpdate("main", {
  0: function(module, exports, require) {
    console.log('我是更新后的代码');
  }
});

3. 通知浏览器拉取代码

Dev Server 通过 WebSocket 给浏览器发一条轻量消息,只包含新 Hash:

json 复制代码
{ "type": "hash", "data": "abc123def456" }

浏览器端 HMR Runtime收到后,通过 HTTP 请求 按需拉取更新清单和模块代码:

复制代码
GET /abc123def456.hot-update.json           → 知道模块0和2变了
GET /0.abc123def456.hot-update.js           → 拉模块0的新代码
GET /2.abc123def456.hot-update.js           → 拉模块2的新代码

之所以不用 WebSocket 直接发代码,是因为代码量大,WebSocket 保持长连接只适合传轻量信号,具体数据走 HTTP 更合理。

4. 模块替换+冒泡

浏览器拿到新模块代码后:

  1. 先调旧模块的 dispose 清理副作用(定时器、事件监听等)
  2. 把新代码写入模块缓存
  3. 从变更模块开始往上冒泡 问:"你能接受这个变化吗?"
    • 有人接(accept)→ 执行回调,局部更新,完事
    • 没人接,一路问到入口模块都没人管 → 没办法安全替换,整页刷新

5. 错误处理

  • 如果热替换过程中出现运行时错误,Webpack 会保留旧模块的代码继续运行
  • 控制台会显示错误信息,并在下次成功更新后自动恢复

全流程:文件变化 → 增量编译(只编变更模块) → 算Hash → 生成更新清单(内存中) → WebSocket推Hash → 浏览器HTTP拉新代码 → 替换模块 → accept冒泡 → 局部更新或整页刷新


六、总结

本文从零实现了一个mini-webpack,基本复刻了Webpack的核心流程:

从命令 npm run build 触发开始,Webpack 首先读取并合并配置文件,创建唯一的 Compiler 实例,初始化内置钩子与Plugin系统;随后从入口模块出发,递归解析依赖,通过 Loader 将各类非 JS 资源转化为标准模块,构建出完整的依赖图谱;在此基础上进行 Tree Shaking 、作用域提升、代码压缩等优化,并按入口与分割规则将模块聚合为多个 Chunk ;最终通过 Plugin 对 Chunk 进行二次加工(如生成 HTML、抽离 CSS),根据输出配置将每个 Chunk 写入文件系统,完成打包。整个流程由 Compiler 统一调度,Plugin通过钩子贯穿各个阶段,实现了高度可扩展的工程化闭环

在开发阶段,Webpack 的 热更新(HMR) 机制通过文件监听、增量编译、Hash 版本控制、WebSocket 通信、模块级替换与 accept 冒泡机制,实现了修改代码后无需刷新页面即可看到更新的开发体验

构建的本质,是让混沌归于有序,让分散凝为整体。删繁就简之后,剩下的才配叫价值。

相关推荐
cch89182 小时前
PHP vs 易语言:Web开发与桌面编程大对决
开发语言·前端·php
百撕可乐2 小时前
NextJS官网实战02:项目的基础骨架搭建
前端·javascript·react.js
陈天伟教授2 小时前
人工智能应用- 人工智能风险与伦理:01.数据安全
前端·人工智能·安全·xss·csrf
用户69371750013842 小时前
Android 17 完整更新详解:Beta 3 已达平台稳定,这些新功能值得期待
android·前端·android studio
fengci.3 小时前
Polar春季个人挑战赛WEB简单部分
android·前端
张元清3 小时前
不用 WebSocket 库,在 React 中构建实时功能
前端·javascript·面试
李白你好3 小时前
浏览器插件 | 信息收集、统一指纹识别 、DOM XSS 检测 、漏洞报告生成与管理
前端·xss
渔民小镇3 小时前
不用前端也能测试 —— 模拟客户端请求模块详解
java·服务器·前端·分布式·游戏
SuperEugene3 小时前
Python + venv + VSCode:前端工程师 AI 转型入门 | 基础篇
前端·人工智能·vscode·python