Webpack的前世今生和极简原理

686 阅读15分钟

前言

掘金上有太多非常好的写webpack的文章了,就不班门弄斧了,这里快速聊一下,webpack是怎么一回事。

文前求赞,写得很辛苦,如果对您有帮助,麻烦点赞、收藏、评论三连。谢谢了!🥲🥲🥲

webpack 是啥?

webpack就是用来搭建前端工程的,它运行在node环境中,它所做的事情,简单来说,就是打包

具体来说,就是以某个模块作为入口,根据入口分析出所有模块的依赖关系,然后对各种模块进行合并、压缩,形成最终的打包结果。

image.png

webpack 解决了什么问题呢?为什么要用它?

现代前端开发已经变得十分的复杂,所以我们开发过程中会遇到如下的问题:

  • 需要通过模块化的方式来开发
  • 使用一些高级的特性来加快我们的开发效率或者安全性,比如通过ES6+、TypeScript开发脚本逻辑,通过sass、less等方式来编写css样式代码
  • 监听文件的变化来并且反映到浏览器上,提高开发的效率
  • JavaScript 代码需要模块化,HTML 和 CSS 这些资源文件也会面临需要被模块化的问题
  • 开发完成后我们还需要将代码进行压缩、合并以及其他相关的优化

webpack恰巧可以解决以上问题,也就是提供:模块打包、语法兼容性,开发支持,性能优化等核心功能。

怎么用webpack?

核心概念如下:

  • Entry:编译入口,webpack编译的起点
  • Compiler:编译管理器,webpack 启动后会创建 compiler 对象,该对象一直存活直到结束退出
  • Compilation:单次编辑过程的管理器,比如 watch = true 时,运行过程中只有一个 compiler但每次文件变更触发重新编译时,都会创建一个新的 compilation对象
  • Dependence:依赖对象,webpack 基于该类型记录模块间依赖关系
  • Modulewebpack 内部所有资源都会以“module”对象形式存在,所有关于资源的操作、转译、合并都是以 “module” 为基本单位进行的
  • Chunk:编译完成准备输出时,webpack 会将 module 按特定的规则组织成一个一个的 chunk,这些 chunk 某种程度上跟最终输出一一对应
  • Loader:资源内容转换器,其实就是实现从内容A转换B的转换器
  • Plugin:webpack构建过程中,会在特定的时机广播对应的事件,插件监听这些事件,在特定时间点介入编译过程

entry point(入口起点)

入口起点(entry point) 指示 webpack 应该使用哪个模块,来作为构建其内部 依赖图(dependency graph)的开始。进入入口起点后,webpack 会找出有哪些模块和库是入口起点(直接和间接)依赖的。

module.exports = { entry: "./src/index.js"};

output

output 属性告诉 webpack 在哪里输出它所创建的 bundle,以及如何命名这些文件。主要输出文件的默认值是 ./dist/main.js,其他生成文件默认放置在 ./dist 文件夹中。

const HtmlWebpackPlugin = require('html-webpack-plugin')
const path = require("path");

module.exports = {
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'main.bundle.js',
  },
}

Loader加载器

loader 用于对模块的源代码进行转换。loader 可以使你在 import 或"load(加载)"模块时预处理文件。默认只能处理json与js,其他文件需要通过专⻔的加载器处理。

module: {     
  rules: [{ test: /\.txt$/, use: 'raw-loader' }], 
}

loader是链式传递的,对文件资源从上一个loader传递到下一个,而loader的处理也遵循着从下到上的顺序,我们简单了解一下loader的开发原则:

  • 单一原则:每个 Loader 只做一件事,简单易用,便于维护;
  • 链式调用:Webpack 会按顺序链式调用每个Loader;
  • 统一原则:遵循 Webpack 制定的设计规则和结构,输入与输出均为字符串,各个 Loader 完全独立,即插即用;
  • 无状态原则:在转换不同模块时,不应该在loader中保留状态;

plugin插件

扩展插件,在 webpack 构建过程的特定时机注入扩展逻辑,用来改变或优化构建结果;

const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {    
  plugins: [new HtmlWebpackPlugin({ template: './src/index.html' })],
}

实现一个plugin的基本框架:

const fs = require('fs')
var http = require('http');
class UploadSourceMapWebpackPlugin {  
  constructor(options) {    
    this.options =options  
  }  
  apply(compiler) {
    //打包结束后执行
    compiler.hooks.done.tap("upload-sourcemap-plugin", status=> {
      console.log('webpack runing')    
    });  
  }
}
  
module.exports = UploadSourceMapWebpackPlugin;

配置示例

来看一个基础配置示例,适用于开发和生产环境。它整合了代码分割、缓存优化、Tree Shaking 等性能提升技术:

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin'); // 自动生成 HTML 文件
const MiniCssExtractPlugin = require('mini-css-extract-plugin'); // 抽离 CSS
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin'); // 压缩 CSS
const TerserPlugin = require('terser-webpack-plugin'); // 压缩 JS
const { CleanWebpackPlugin } = require('clean-webpack-plugin'); // 清理旧的构建文件

module.exports = (env, argv) => {
  const isProduction = argv.mode === 'production';

  return {
    // 入口文件
    entry: './src/index.js',

    // 输出文件
    output: {
      path: path.resolve(__dirname, 'dist'),
      filename: isProduction ? 'js/[name].[contenthash:8].js' : 'js/[name].js',
      assetModuleFilename: 'assets/[hash][ext][query]', // 资源文件的输出路径
      clean: true, // 自动清理旧文件(Webpack 5 内置)
    },

    // 模式配置
    mode: isProduction ? 'production' : 'development',

    // 开发服务器
    devServer: {
      static: path.join(__dirname, 'dist'),
      compress: true,
      port: 3000,
      open: true,
      hot: true, // 启用 HMR 热更新
    },

    // 模块解析规则
    module: {
      rules: [
        {
          test: /\.js$/, // 处理 JS 文件
          exclude: /node_modules/,
          use: {
            loader: 'babel-loader',
            options: {
              presets: ['@babel/preset-env'], // 转换为兼容性较好的 JS 代码
            },
          },
        },
        {
          test: /\.css$/, // 处理 CSS 文件
          use: [
            isProduction ? MiniCssExtractPlugin.loader : 'style-loader',
            'css-loader',
            'postcss-loader', // 自动加前缀
          ],
        },
        {
          test: /\.(png|jpe?g|gif|svg)$/i, // 处理图片资源
          type: 'asset', // 根据资源大小选择输出方式
          parser: {
            dataUrlCondition: {
              maxSize: 8 * 1024, // 小于 8KB 的文件转换为 Base64
            },
          },
        },
        {
          test: /\.(woff2?|eot|ttf|otf)$/i, // 处理字体资源
          type: 'asset/resource',
        },
      ],
    },

    // 插件配置
    plugins: [
      new HtmlWebpackPlugin({
        template: './src/index.html', // HTML 模板文件
        minify: isProduction
          ? {
              collapseWhitespace: true, // 移除空格
              removeComments: true, // 移除注释
              removeRedundantAttributes: true, // 移除多余的属性
              useShortDoctype: true, // 使用短文档类型
            }
          : false,
      }),
      new CleanWebpackPlugin(), // 清理构建目录
      ...(isProduction
        ? [new MiniCssExtractPlugin({ filename: 'css/[name].[contenthash:8].css' })]
        : []),
    ],

    // 性能优化
    optimization: {
      minimize: isProduction, // 仅在生产环境启用压缩
      minimizer: [
        new TerserPlugin({
          parallel: true, // 多线程压缩
          terserOptions: {
            compress: {
              drop_console: true, // 移除 console
            },
          },
        }),
        new CssMinimizerPlugin(),
      ],
      splitChunks: {
        chunks: 'all', // 提取公共模块
        cacheGroups: {
          vendors: {
            test: /[\\/]node_modules[\\/]/,
            name: 'vendors',
            chunks: 'all',
          },
        },
      },
      runtimeChunk: 'single', // 提取运行时代码
    },

    // 路径解析
    resolve: {
      alias: {
        '@': path.resolve(__dirname, 'src'), // 配置路径别名
      },
      extensions: ['.js', '.json', '.css'], // 自动解析的文件扩展名
    },

    // 开启 Source Map(开发环境)
    devtool: isProduction ? false : 'source-map',
  };
};

配置详解
  1. 基础功能

    • 入口文件:src/index.js
    • 输出目录:dist,包含哈希文件名以支持缓存。
  2. 开发环境优化

    • 启用 HMR 热更新。
    • 使用 source-map 提供源码调试支持。
  3. 生产环境优化

    • CSS 抽离与压缩: 使用 MiniCssExtractPluginCssMinimizerPlugin 处理样式文件。
    • JS 压缩: 使用 TerserPlugin 压缩并移除多余代码(如 console.log)。
    • 代码分割: 使用 splitChunks 提取公共模块,优化加载速度。
    • 缓存优化: 文件名带有 contenthash,方便浏览器缓存。
  4. 资源处理

    • 小于 8KB 的图片转换为 Base64,减少 HTTP 请求。
    • 字体文件作为独立资源输出。
  5. 插件功能

    • 自动生成 HTML 文件。
    • 清理旧的构建文件。
    • 支持路径别名,简化文件导入。
优化效果
  • 开发环境: 提升启动速度,支持实时更新,增强调试体验。
  • 生产环境: 减小打包文件体积,优化加载性能,确保浏览器缓存利用率最大化。
  • 通用支持: 灵活处理各种资源文件,适应多样化项目需求。

webpack的构建流程

webpack 的运行流程是一个串行的过程,它的工作流程就是将各个插件串联起来

在运行过程中会广播事件,插件只需要监听它所关心的事件,就能加入到这条webpack机制中,去改变webpack的运作,使得整个系统扩展性良好

从启动到结束会依次执行以下三大步骤:

  • 初始化流程:从配置文件和 Shell 语句中读取与合并参数,并初始化需要使用的插件和配置插件等执行环境所需要的参数
  • 编译构建流程:从 Entry 发出,针对每个 Module 串行调用对应的 Loader 去翻译文件内容,再找到该 Module 依赖的 Module,递归地进行编译处理
  • 输出流程:对编译后的 Module 组合成 Chunk,把 Chunk 转换成文件,输出到文件系统

image.png

初始化流程

从配置文件和 Shell 语句中读取与合并参数,得出最终的参数

配置文件默认下为webpack.config.js,也或者通过命令的形式指定配置文件,主要作用是用于激活webpack的加载项和插件

关于文件配置内容分析,如下注释:

var node_modules = path.resolve(__dirname, 'node_modules');
var pathToReact = path.resolve(node_modules, 'react/dist/react.min.js');

module.exports = {
  // 入口文件,是模块构建的起点,同时每一个入口文件对应最后生成的一个 chunk。
  entry: './path/to/my/entry/file.js'// 文件路径指向(可加快打包过程)。
  resolve: {
    alias: {
      'react': pathToReact
    }
  },
  // 生成文件,是模块构建的终点,包括输出文件与输出路径。
  output: {
    path: path.resolve(__dirname, 'build'),
    filename: '[name].js'
  },
  // 这里配置了处理各模块的 loader ,包括 css 预处理 loader ,es6 编译 loader,图片处理 loader。
  module: {
    loaders: [
      {
        test: /\.js$/,
        loader: 'babel',
        query: {
          presets: ['es2015', 'react']
        }
      }
    ],
    noParse: [pathToReact]
  },
  // webpack 各插件对象,在 webpack 的事件流中执行对应的方法。
  plugins: [
    new webpack.HotModuleReplacementPlugin()
  ]
};

webpack 将 webpack.config.js 中的各个配置项拷贝到 options 对象中,并加载用户配置的 plugins

完成上述步骤之后,则开始初始化Compiler编译对象,该对象掌控者webpack声明周期,不执行具体的任务,只是进行一些调度工作

class Compiler extends Tapable {
    constructor(context) {
        super();
        this.hooks = {
            beforeCompile: new AsyncSeriesHook(["params"]),
            compile: new SyncHook(["params"]),
            afterCompile: new AsyncSeriesHook(["compilation"]),
            make: new AsyncParallelHook(["compilation"]),
            entryOption: new SyncBailHook(["context", "entry"])
            // 定义了很多不同类型的钩子
        };
        // ...
    }
}

function webpack(options) {
  var compiler = new Compiler();
  ...// 检查options,若watch字段为true,则开启watch线程
  return compiler;
}
...

Compiler 对象继承自 Tapable,初始化时定义了很多钩子函数

编译构建流程

根据配置中的 entry 找出所有的入口文件

module.exports = { 
  entry: './src/file.js' 
}

初始化完成后会调用Compilerrun来真正启动webpack编译构建流程,主要流程如下:

  • compile 开始编译
  • make 从入口点分析模块及其依赖的模块,创建这些模块对象
  • build-module 构建模块
  • seal 封装构建结果
  • emit 把各个chunk输出到结果文件

compile 编译

执行了run方法后,首先会触发compile,主要是构建一个Compilation对象

该对象是编译阶段的主要执行者,主要会依次下述流程:执行模块创建、依赖收集、分块、打包等主要任务的对象

make 编译模块

当完成了上述的compilation对象后,就开始从Entry入口文件开始读取,主要执行_addModuleChain()函数,如下:

_addModuleChain(context, dependency, onModule, callback) {
   ...
   // 根据依赖查找对应的工厂函数
   const Dep = /** @type {DepConstructor} */ (dependency.constructor);
   const moduleFactory = this.dependencyFactories.get(Dep);
   
   // 调用工厂函数NormalModuleFactory的create来生成一个空的NormalModule对象
   moduleFactory.create({
       dependencies: [dependency]
       ...
   }, (err, module) => {
       ...
       const afterBuild = () => {
        this.processModuleDependencies(module, err => {
         if (err) return callback(err);
         callback(null, module);
           });
    };
       
       this.buildModule(module, false, null, null, err => {
           ...
           afterBuild();
       })
   })
}

过程如下:

_addModuleChain中接收参数dependency传入的入口依赖,使用对应的工厂函数NormalModuleFactory.create方法生成一个空的module对象

回调中会把此module存入compilation.modules对象和dependencies.module对象中,由于是入口文件,也会存入compilation.entries

随后执行buildModule进入真正的构建模块module内容的过程

build module 完成模块编译

这里主要调用配置的loaders,将我们的模块转成标准的JS模块

在用 Loader 对一个模块转换完后,使用 acorn 解析转换后的内容,输出对应的抽象语法树(AST),以方便 Webpack 后面对代码的分析

从配置的入口模块开始,分析其 AST,当遇到require等导入其它模块语句时,便将其加入到依赖的模块列表,同时对新找出的依赖模块递归分析,最终搞清所有模块的依赖关系

输出流程

seal 输出资源

seal方法主要是要生成chunks,对chunks进行一系列的优化操作,并生成要输出的代码

webpack 中的 chunk ,可以理解为配置在 entry 中的模块,或者是动态引入的模块

根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk,再把每个 Chunk 转换成一个单独的文件加入到输出列表

emit 输出完成

在确定好输出内容后,根据配置确定输出的路径和文件名

output: {
    path: path.resolve(__dirname, 'build'),
    filename: '[name].js'
}

在 Compiler 开始生成文件前,钩子 emit 会被执行,这是我们修改最终文件的最后一个机会

从而webpack整个打包过程则结束了

image.png

webpack 的核心原理是啥? 如何手写一个mini-webpack?

我们直接来实现一个最简化版的webpack,帮助我们理解webpack的核心原理。

1.解析文件,生成AST

假设我们有如下目录:

-src
    - add.js
    - minus.js
    - index.js
-index.html

文件内容如下,在 index.js 中引入 add.jsminus.js ,再在 index.html 中引入 index.js

//add.js
export default (a,b)=>{return a+b;}

//minus.js
export const minus = (a,b)=>{return a-b }

//index.js
import add from "./add.js";
import {minus} from "./minus.js";
const sum =add(1,2);
const division = minus(2,1);
console.log(sum);
console.log(division);

//html
<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8">
        <title>Title</title>
    </head>
    <body>
        <script src="./src/index.js"></script>
    </body>
</html>

我们来写一个简易版webpack把上面的资源进行打包

//获取主入口文件
const fs = require('fs');
const getModuleInfo = (file)=>{   
  const body = fs.readFileSync(file,'utf-8'); 
  console.log(body);
}

getModuleInfo("./src/index.js");

打印内容如下,就是index.js的内容

image.png

继续拓展,引入@babel/parse

npm install @babel/parse
const fs = require('fs')
const parser = require('@babel/parser')

const getModuleInfo = file => {
  const body = fs.readFileSync(file, 'utf-8')
  const ast = parser.parse(body, { 
    sourceType: 'module' //表示解析es模块
  })
  console.log(ast)
}

getModuleInfo('./src/index.js')

我们再来看打印结果如下,这个时候babel/parser就把我们的资源解析成一个ast语法树了。

image.png 这棵 AST 是对 ./src/index.js 的完整语法表示,File 是根节点,Program 包含程序主体,body 包括各个语法结构的细节。AST 的作用是便于工具对代码进行分析和转换(如编译、代码检查、代码优化等)。

2. 收集依赖

完成语法树的解析后我们再来引入一个东西:@babel/traverse

npm install @babel/traverse
const fs = require('fs');
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const path = require('path');
const babel = require('@babel/core');

const getModuleInfo = file => {
  const body = fs.readFileSync(file, 'utf-8'); // 读取文件内容
  const ast = parser.parse(body, { sourceType: 'module' }); // 解析为 AST

  const deps = {};
  traverse(ast, {
    ImportDeclaration({ node }) {
      const dirname = path.dirname(file);
      const abspath = './' + path.resolve(dirname, node.source.value); // 修正路径拼接问题
      deps[node.source.value] = abspath;
    }
  });

  const { code } = babel.transformFromAst(ast, null, {         
    presets: ["@babel/preset-env"]     
  });

  const moduleInfo = { file, deps, code };
  console.log(moduleInfo); // 打印模块信息
  return moduleInfo;
}

getModuleInfo('./src/index.js');

解读一下以上代码:

引入必要模块

const fs = require('fs');
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const path = require('path');
  • fs:用于文件读取。
  • @babel/parser:解析代码为 AST(抽象语法树)。
  • @babel/traverse:用于遍历和操作 AST。
  • path:处理文件路径。

函数 getModuleInfo

定义一个函数来解析文件的模块信息,包括依赖关系和编译后的代码:

const getModuleInfo = file => {
  const body = fs.readFileSync(file, 'utf-8'); // 读取文件内容
  const ast = parser.parse(body, { sourceType: 'module' }); // 解析为 AST
  • file:文件路径。
  • body:读取的文件内容。
  • ast:用 Babel 将文件内容解析为抽象语法树。

提取依赖

const deps = {};
traverse(ast, {
  ImportDeclaration({ node }) {
    const dirname = path.dirname(file);
    const abspath = './' + path.join(dirname, node.source.value);
    deps[node.source.value] = abspath;
  }
});
  • deps:一个对象,存储模块依赖的映射关系。
  • traverse:遍历 AST 中的 ImportDeclaration 节点(即 import 语句)。
  • node.source.value:模块导入的路径,例如 ./module.js
  • dirname:当前文件所在目录。
  • abspath:将模块路径解析为绝对路径。

结果:deps 对象以模块路径为键、绝对路径为值,形如:

{
  './module.js': './src/module.js'
}

代码转换

const { code } = babel.transformFromAst(ast, null, {         
  presets: ["@babel/preset-env"]     
});
  • 使用 Babel 将 AST 转换为 ES5 代码。
  • presets:指定转换规则,这里使用 @babel/preset-env 将现代 JavaScript 转换为较低版本的兼容代码。

注意:这里的 babel 没有定义,需要通过 @babel/core 模块引入。

构建模块信息

const moduleInfo = { file, deps, code };
return moduleInfo;
  • moduleInfo:一个对象,包含:

    • file:文件路径。
    • deps:模块依赖映射。
    • code:转换后的代码。

调用函数

getModuleInfo('./src/index.js');
  • 对指定文件 ./src/index.js 调用 getModuleInfo,解析其依赖和代码。

输出示例

假设 ./src/index.js 内容为:

import fs from 'fs';
import path from 'path';

运行后输出:

{
  file: './src/index.js',
  deps: {
    'fs': './fs',
    'path': './path'
  },
  code: '"use strict";\n\nvar _fs = _interopRequireDefault(require("fs"));\n\nvar _path = _interopRequireDefault(require("path"));\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }'
}

3. 加载所有文件

我们使用 parseModules("./src/index.js") 来代替 getModuleInfo 作为入口函数调用,整体代码如下:

const fs = require('fs');
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const path = require('path');
const babel = require('@babel/core');

const getModuleInfo = file => {
  const body = fs.readFileSync(file, 'utf-8'); // 读取文件内容
  const ast = parser.parse(body, { sourceType: 'module' }); // 解析为 AST

  const deps = {};
  traverse(ast, {
    ImportDeclaration({ node }) {
      const dirname = path.dirname(file);
      const abspath = './' + path.resolve(dirname, node.source.value); // 修正路径拼接问题
      deps[node.source.value] = abspath;
    }
  });

  const { code } = babel.transformFromAst(ast, null, {         
    presets: ["@babel/preset-env"]     
  });

  const moduleInfo = { file, deps, code };
  console.log(moduleInfo); // 打印模块信息
  return moduleInfo;
}

//新增的代码
const parseModules = file => {
  const entry = getModuleInfo(file) //在这里调用getModuleInfo
  const temp = [entry]
  for (let i = 0; i < temp.length; i++) {
    const deps = temp[i].deps
    if (deps) {
      for (const key in deps) {
        if (deps.hasOwnProperty(key)) {
          temp.push(getModuleInfo(deps[key]))
        }
      }
    }
    console.log(temp)
    const depsGraph = {}
    temp.forEach(moduleInfo => {
      depsGraph[moduleInfo.file] = {
        deps: moduleInfo.deps,
        code: moduleInfo.code,
      }
    })
    console.log(depsGraph)
    return depsGraph
  }
}

parseModules("./src/index.js")

我们来逐行解析一下这个新增的函数parseModules, 这段代码的主要功能是:

  1. 解析指定入口文件及其所有依赖的模块信息。
  2. 构建一个依赖图对象(depsGraph),其中每个模块的信息包含依赖关系和代码。

函数定义与入口模块解析

const parseModules = file => {
  const entry = getModuleInfo(file); // 调用 getModuleInfo 解析入口模块信息
  const temp = [entry]; // 初始化数组存储模块信息,从入口模块开始
  • file:入口文件路径。
  • getModuleInfo(file) :之前定义的函数,用于解析模块信息,返回一个包含文件路径、依赖关系和代码的对象。
  • temp:数组,用于存储所有模块的解析结果,初始只包含入口模块。

遍历并解析模块依赖

  for (let i = 0; i < temp.length; i++) {
    const deps = temp[i].deps; // 当前模块的依赖关系对象
    if (deps) {
      for (const key in deps) { // 遍历模块的依赖
        if (deps.hasOwnProperty(key)) { // 确保是对象自身的属性
          temp.push(getModuleInfo(deps[key])); // 调用 getModuleInfo 解析依赖模块,并加入 temp 数组
        }
      }
    }
  • 遍历依赖:循环处理 temp 数组中的每个模块,获取其依赖信息(deps)。

  • deps:一个对象,存储当前模块的依赖路径(键)及其绝对路径(值)。

  • 对每个依赖:

    • 使用 getModuleInfo 解析依赖模块信息。
    • 将解析后的模块信息添加到 temp 数组。
  • 动态增长temp 的长度会随着解析新增模块而增加,确保所有递归依赖模块都被解析。

构建依赖图


    console.log(temp); // 打印当前所有已解析的模块信息

    const depsGraph = {};
    temp.forEach(moduleInfo => {
      depsGraph[moduleInfo.file] = {
        deps: moduleInfo.deps, // 模块的依赖关系
        code: moduleInfo.code, // 模块转换后的代码
      };
    });
    console.log(depsGraph); // 打印最终的依赖图
    return depsGraph; // 返回依赖图
  • depsGraph:一个对象,表示依赖图,键是文件路径,值是一个对象,包含模块的依赖和代码。

  • temp.forEach:遍历 temp 数组,逐个处理模块信息,将其转换为依赖图的节点。

  • 每个模块节点结构:

    {
      "模块文件路径": {
        deps: { "依赖路径": "依赖绝对路径" },
        code: "转换后的代码"
      }
    }
    
  • 返回值:最终返回 depsGraph 对象,用于表示整个依赖关系和代码内容。

调用函数

parseModules("./src/index.js");
  • 调用 parseModules,以 ./src/index.js 作为入口文件。
  • 函数将解析入口文件及其递归依赖,并构建依赖图。

输出解析

假设入口文件和依赖如下:

index.js:
  import './a.js';
  import './b.js';

a.js:
  import './c.js';

b.js:
  (无依赖)

c.js:
  (无依赖)

解析过程:

  1. 入口文件 index.js 被解析,添加到 temp
  2. 解析 index.js 的依赖 a.jsb.js,将它们添加到 temp
  3. 解析 a.js 的依赖 c.js,将其添加到 temp
  4. b.jsc.js 无依赖,结束解析。

最终输出的 depsGraph

{
  './src/index.js': {
    deps: { './a.js': './src/a.js', './b.js': './src/b.js' },
    code: '转换后的 index.js 代码'
  },
  './src/a.js': {
    deps: { './c.js': './src/c.js' },
    code: '转换后的 a.js 代码'
  },
  './src/b.js': {
    deps: {},
    code: '转换后的 b.js 代码'
  },
  './src/c.js': {
    deps: {},
    code: '转换后的 c.js 代码'
  }
}

4. 构建最终的资源,补充require和exports

我们拿到了资源图谱之后,我们要去构建最终的资源 我们引入一个函数bundle,使用这个函数来做新的入口函数,然后继续包住上面的parseModules 构建最终的资源

const bundle = file => {
  const depsGraph = JSON.stringify(parseModules(file))
  return `(
    function (graph) {
    
      function require(file) {
        function absRequire(relPath) {
          return require(graph[file].deps[relPath])
        }
        var exports = {}
        (function (require,exports,code) {
            eval(code)
        })(absRequire,exports,graph[file].code)
        return exports
      }

      require('${file}')    
    })(${depsGraph})`
}


const content = bundle('./src/index.js')
console.log(content);
//写入到我们的dist目录下
fs.mkdirSync('./dist');
fs.writeFileSync('./dist/bundle.js',content)

完整解析一下,这段代码的主要功能是:

  1. 通过调用 parseModules(file) 解析入口文件及其依赖,生成依赖图。
  2. 将依赖图和自定义的模块加载器打包成一个自执行函数。
  3. 打包后的代码会写入到项目的 ./dist/bundle.js 文件中,用于浏览器或其他运行环境。

生成依赖图

const depsGraph = JSON.stringify(parseModules(file));
  • parseModules(file) :调用之前定义的函数,解析入口文件及所有依赖模块,返回模块依赖图(depsGraph)。
  • JSON.stringify(depsGraph) :将 depsGraph 转换为字符串格式,以便嵌入到打包代码中。

假设依赖图结构(示例):

{
  "./src/index.js": {
    deps: { "./a.js": "./src/a.js" },
    code: "console.log('index'); require('./a.js');"
  },
  "./src/a.js": {
    deps: {},
    code: "console.log('a');"
  }
}

定义自执行函数

最终的bundle里面的内容就是如下格式

return `(
  function (graph) {
    ...
  }
)(${depsGraph})`;
  • 自执行函数(IIFE) :用来运行打包后的代码。
  • graph:作为参数传入的模块依赖图(depsGraph)。
  • 这个函数的逻辑主要用于加载和执行模块代码。

模拟模块加载

function require(file) {
  function absRequire(relPath) {
    return require(graph[file].deps[relPath]);
  }
  
  var exports = {};
  (function (require, exports, code) {
    eval(code);
  })(absRequire, exports, graph[file].code);
  
  return exports;
}

模块加载的核心逻辑:

  1. require(file) :接收一个模块路径,加载并执行模块代码。

  2. absRequire(relPath)

    • 用于加载相对路径的依赖模块。
    • 根据依赖图中当前模块的 deps 属性,找到依赖模块的路径,然后递归调用 require
  3. exports 对象

    • 用于存储模块的导出内容。
    • 执行模块代码时,将 exports 作为上下文传递,模块可以通过修改它导出内容。
  4. 执行模块代码

    • 使用 (function(require, exports, code) { eval(code); }) 执行模块代码。
    • 模拟 CommonJS 的模块系统,传递 requireexports

执行入口模块

require('${file}');
  • 使用入口文件路径作为参数,启动模块加载过程。
  • 从入口模块开始,递归加载并执行所有依赖模块。

生成打包代码并输出

console.log(content);

// 写入到 dist 目录下
fs.mkdirSync('./dist');
fs.writeFileSync('./dist/bundle.js', content);

步骤说明:

  1. fs.mkdirSync('./dist')

    • 创建一个名为 dist 的目录,用于存储打包后的文件。
    • 如果目录已经存在,会抛出错误;可以用 fs.mkdirSync('./dist', { recursive: true }) 来避免错误。
  2. fs.writeFileSync('./dist/bundle.js', content)

    • 将打包代码写入到 ./dist/bundle.js 文件中。
    • 如果文件已存在,会覆盖原内容。

打包代码示例

假设入口文件和依赖如下:

  • index.js:

    console.log('index');
    require('./a.js');
    
  • a.js:

    javascript
    复制代码
    console.log('a');
    

生成的打包代码内容:

(
  function (graph) {
    function require(file) {
      function absRequire(relPath) {
        return require(graph[file].deps[relPath]);
      }
      var exports = {};
      (function (require, exports, code) {
        eval(code);
      })(absRequire, exports, graph[file].code);
      return exports;
    }
    require('./src/index.js');
  }
)({
  "./src/index.js": {
    deps: { "./a.js": "./src/a.js" },
    code: "console.log('index'); require('./a.js');"
  },
  "./src/a.js": {
    deps: {},
    code: "console.log('a');"
  }
});

运行结果:

  1. 调用 require('./src/index.js'),打印 index
  2. 加载依赖模块 a.js,打印 a

总结

主要有以下功能

  1. 依赖图生成:通过 parseModules 构建模块依赖关系。
  2. 模块加载器:实现了一个自定义的 require 函数,支持递归加载执行。
  3. 打包输出:将模块加载逻辑和依赖图写入到 dist/bundle.js 文件中。

5. 完整代码

以下就是一个mini-webpack的实现完整代码

const fs = require('fs');
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const path = require('path');
const babel = require('@babel/core');

const getModuleInfo = file => {
  const body = fs.readFileSync(file, 'utf-8'); // 读取文件内容
  const ast = parser.parse(body, { sourceType: 'module' }); // 解析为 AST

  const deps = {};
  traverse(ast, {
    ImportDeclaration({ node }) {
      const dirname = path.dirname(file);
      const abspath = './' + path.resolve(dirname, node.source.value); // 修正路径拼接问题
      deps[node.source.value] = abspath;
    }
  });

  const { code } = babel.transformFromAst(ast, null, {         
    presets: ["@babel/preset-env"]     
  });

  const moduleInfo = { file, deps, code };
  console.log(moduleInfo); // 打印模块信息
  return moduleInfo;
}


const parseModules = file => {
  const entry = getModuleInfo(file) //在这里调用getModuleInfo
  const temp = [entry]
  for (let i = 0; i < temp.length; i++) {
    const deps = temp[i].deps
    if (deps) {
      for (const key in deps) {
        if (deps.hasOwnProperty(key)) {
          temp.push(getModuleInfo(deps[key]))
        }
      }
    }
    console.log(temp)
    const depsGraph = {}
    temp.forEach(moduleInfo => {
      depsGraph[moduleInfo.file] = {
        deps: moduleInfo.deps,
        code: moduleInfo.code,
      }
    })
    console.log(depsGraph)
    return depsGraph
  }
}


const bundle = (file) => {
  const depsGraph = JSON.stringify(parseModules(file))
  const content= `(
    function (graph) {
    
      function require(file) {
        function absRequire(relPath) {
          return require(graph[file].deps[relPath])
        }
        var exports = {}
        (function (require,exports,code) {
            eval(code)
        })(absRequire,exports,graph[file].code)
        return exports
      }

      require('${file}')    
    })(${depsGraph})`


  console.log(content);

  // 创建 dist 目录并写入文件
  if (!fs.existsSync('./dist')) {
    fs.mkdirSync('./dist');
  }
  fs.writeFileSync('./dist/bundle.js', content);
};

bundle('./src/index.js');