JS打包的核心原理

引言

简单来说,前端打包就是把一个项目里所有的文件(HTML、CSS、JavaScript、图片、字体等)整理一下,把它们合并成一个或几个文件。为啥要这么做呢?因为浏览器加载文件的时候,文件越多,加载就越慢。打包后,浏览器加载的文件少了,网页就能更快地显示出来。

打包之后能带来以下几个好处:

  • 加载快:文件少了,浏览器加载起来就快。
  • 代码兼容:有些新特性浏览器不认识,打包工具会把它们转换成浏览器能理解的代码。
  • 代码简洁:去掉多余的空格和注释,文件体积变小,加载更快。

前端打包涉及到多种资源类型,例如 JS、CSS、图片、字体等,本文通过实现 minipack 打包工具介绍 JS 资源的核心处理原理。

项目说明

MiniPack 是一个极简版的 JavaScript 模块打包工具,它模拟 Webpack 的核心功能:依赖分析、代码转换、模块打包。通过这个项目,可以深入理解现代打包工具的工作原理。

项目结构

bash 复制代码
minipack/
├── package.json         
├── readme.md            
├── source/             # 源码文件夹(待打包的代码)
│   ├── main.js         # 入口文件
│   ├── plus.js         # 数学运算模块
│   └── minus.js        # 减法模块
└── src/                # 打包工具源码
    └── index.js        # 打包工具实现代码

源文件

入口文件

main.js 作为入口文件,里面涉及了几种常见场景:

  1. es module 模块导入;
  2. export default 内容导入;
  3. commonjs 模块导入;
javascript 复制代码
//----------main
import {plus,minusOne} from './plus.js';
import test from './test.js'
const{a} = require('./test2.js');

let initCount = 1;
let fiberArr = [1,1];

for(let i=2;i<20;i++){
  fiberArr[i] = plus(fiberArr[i-1],fiberArr[i-2]) 
}
console.log(fiberArr);

console.log(minusOne(10))

console.log(a)
console.log('test',test)

plus.js

plus.js 作为 main 的依赖模块,它自己也会导入其他模块。

javascript 复制代码
//--------plus
import minus from './minus.js';
let test = require('./test2.js');
const plus = function(num1,num2){
  return num1+num2
}

const plusOne = function(num){
  return num+1
}

const minusOne = function(num){
  return minus(num,1)
}

export {
  plus,
  plusOne,
  minusOne,
  test
}

minipack 打包实现

处理步骤:

1. 模块收集

读取文件内容,使用 Babel 解析成 AST(抽象语法树),遍历 AST 找到所有 import 和 require 语句。

对 AST 使用 babel 进行转换。

ini 复制代码
function createModuleGraph(filePath){
  let exist = moduledPath.filter(item=>item.filePath===filePath);
  if(exist.length)return {...exist[0],exist:true}
  
  const dep=[];
  const content = fs.readFileSync(filePath, 'utf-8');
  const ast = babelParser.parse(content,{
    sourceType:'module'
  });
  
  // 遍历AST,找到所有依赖
  traverse.default(ast,{
    ImportDeclaration:function({node}){
      dep.push(node.source.value)  // 收集 import 依赖
    },
    CallExpression ({ node }) {
      if (node.callee.name === 'require') {
        dep.push(node.arguments[0].value);  // 收集 require 依赖
      }
    }
  })
  
  // 分配模块ID并转换代码
  let moduleId = id++;
  const { code } = transformFromAst(ast, null, {
    presets: ['@babel/env']
  });

  return {
    id:moduleId,
    filePath,
    dependencies:dep,
    code
  }
}

2. 依赖关系映射

从入口文件开始,广度优先遍历所有依赖,为每个模块建立 相对路径 → 模块ID 的映射关系

避免重复处理同一个文件。

ini 复制代码
let mainGraph = createModuleGraph('../source/main.js');
let modules = [mainGraph];
for(let graph of modules){
  graph.map = {};
  const dirname = path.dirname(graph.filePath);
  graph.dependencies.forEach(rePath=>{
    let absPath = path.join(dirname,rePath);
    let m = createModuleGraph(absPath);
    graph.map[rePath] = m.id;  // 建立路径到ID的映射
    if(!("exist" in m)){
      modules.push(m);
    }
  })
}

3. Bundle 生成

生成编译之后的文件。

java 复制代码
function createBundle(modules){
  return `(function(modules){
    const installedModules = {};
    
    function require(moduleId){
      // 模块缓存检查
      if(installedModules[moduleId]){
        return installedModules[moduleId].exports
      }
      
      // 创建模块对象
      var module = (installedModules[moduleId] = {
        i: moduleId,
        l: false,
        exports: {}
      })

      const [fn,map] = modules[moduleId]
      
      // 本地require函数,处理相对路径
      function localRequire(name){
        return require(map[name])
      }

      // 执行模块代码
      fn.call(module.exports, module, module.exports, localRequire)
      
      return module.exports
    }
    
    require(0)  // 启动入口模块
  })({${modules.map(item=>{
    return (`"${item.id}":[${wrapSource(item.code)},${util.inspect(item.map)}]`)
  })}})`
}

核心API介绍

babelParser.parse

babelParser.parse'@babel/parser' 中的API,用来将代码转化为 AST。

参数说明:

  • code: 字符串形式的 JavaScript 代码
  • options: 解析选项配置
    • sourceType: 解析模式,可选值 'module' | 'script' | 'unambiguous',使用 es6 的 import/export 语法时,应设为 module
    • plugins: 启用的插件。
    • 其他配置项参考官网 babel.dev/docs/babel-...

转换示例:

arduino 复制代码
// 输入代码
const code = `
import { sum } from './math.js';
const result = sum(1, 2);
export default result;
`;

// 解析为 AST
const ast = babelParser.parse(code, {
  sourceType: 'module'
});

// AST 结构(简化版)
{
  type: "File",
  program: {
    type: "Program",
    sourceType: "module",
    body: [
      {
        type: "ImportDeclaration",
        specifiers: [...],
        source: {
          type: "StringLiteral",
          value: "./math.js"
        }
      },
      // ... 其他节点
    ]
  }
}

traverse AST 遍历器

@babel/traverse 中的 traverse 提供了遍历和修改 AST 的能力,是 Babel 转换的核心工具。

php 复制代码
const { code } = transformFromAst(ast, null, {
    presets: ['@babel/env']
});

参数说明:

  • ast: 抽象语法树
  • source: 原始源码(可选,用于生成 source map)
  • options: babel 配置项
相关推荐
@大迁世界3 分钟前
TypeScript 的本质并非类型,而是信任
开发语言·前端·javascript·typescript·ecmascript
GIS之路12 分钟前
GDAL 实现矢量裁剪
前端·python·信息可视化
是一个Bug15 分钟前
后端开发者视角的前端开发面试题清单(50道)
前端
Amumu1213817 分钟前
React面向组件编程
开发语言·前端·javascript
持续升级打怪中39 分钟前
Vue3 中虚拟滚动与分页加载的实现原理与实践
前端·性能优化
GIS之路42 分钟前
GDAL 实现矢量合并
前端
hxjhnct1 小时前
React useContext的缺陷
前端·react.js·前端框架
前端 贾公子1 小时前
从入门到实践:前端 Monorepo 工程化实战(4)
前端
菩提小狗1 小时前
Sqlmap双击运行脚本,双击直接打开。
前端·笔记·安全·web安全
前端工作日常1 小时前
我学习到的AG-UI的概念
前端