实现一个 mini-webpack 熟悉打包原理

webpack 是一个 JS 应用程序的静态模块打包器。它会递归的构建一个依赖关系图,包含应用程序需要的每个模块,然后将所有这些模块打包成一个或多个bundle。 Webpack 的核心原理是将应用程序的所有模块视为一个图形结构,图中的每一个点代表一个模块,边代表模块之间的依赖关系。webpack 通过分析模块之间的依赖关系,生成一个包含所有模块的图谱,并将其转换为浏览器可以理解的代码。

webpack 的工作流程可以分为以下几个阶段

webpack 的运行流程是一个串行的过程,从启动到结束会一次执行以下流程:

  1. 初始化参数: 从配置文件和Shell 语句中读取与合并参数,得出最终的参数
  2. 开始编译:用上一步得到的参数初始化 Compiler 对象,加载所有配置的插件,执行对象的 run 方法开始编辑
  3. 确定入口:根据配置中的 entry 找出所有的入口文件
  4. 编译模块:从入口文件出发,调用所有配置的loader对模块进行翻译,在找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经历过本步骤的处理
  5. 完成模块编译:在经历第4步使用 loader 翻译完所有模块后,得到了每一个模块的最终代码和依赖关系图
  6. 输出资源:根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk,再把每个 Chunk 转成一个单独的文件加入到输出列表中,这一步是可以修改输出内容的最后机会
  7. 输出完成:在确定好输出内容后,根据配置好的输出路径和文件名,写入到文件系统中

在以上过程中,webpack 会在特定的时间点广播出特定的事件,插件在监听到事件后可以做出相应的处理,并且插件可以调用 webpack 提供的 API 来修改 webpack 的运行结果。webpack 的插件机制是基于事件驱动的,使用了 Tapable 库来实现。

专有名词

EnTry

  • 入口起点指示webpack 应该使用哪个模块来开始构建其内部依赖图。一个入口起点可以是一个字符串、数组或对象。webpack会从这个入口起点找出有那些模块和库有直接或间接的依赖关系。

配置示例如下:

js 复制代码
module.exports = {
  entry: {
    main: './src/index.js',
    vendor: './src/vendor.js'
  }
}

Output

  • output 属性高搜 webpack 在哪里输出他所创建的bundles,以及如何命名这些文件,默认是 ./dist。基本上,整个应用程序结构,都会被你编译到你指定的输出路径的文件中

Module

  • 在 webpack 中,模块是指任何可以被 webpack 处理的文件。webpack 支持多种类型的模块,包括JavaScript、css、图片、字体等。其中原生支持的类型为:js和json,其他类型的文件需要通过 loader 来处理。

Chunk

  • chunk 是 webpack 在编译过程中生成的一个代码块,它可以包含一个或多个模块。webpack 会根据模块之间的依赖关系,将它们打包成一个或多个 chunk

Loader

Loader 让 webpack 能够去处理那些非 JavaScript 文件。提供了webpack 处理非 JavaScript 文件的能力。loader 可以将文件转换为有效模块,webpack 也可以处理这些模块,并将它们添加到依赖图中。

比如说我们需要处理css文件,可以使用 css-loaderstyle-loader 来处理 css 文件。

css-loader 负责解析 css 文件中的 import 和 url() style-loader 负责将 css 插入到 DOM 中

js 复制代码
module.exports = {
  module: {
    rules: [
      {
        test: /\.css$/,
        use: ['style-loader', 'css-loader']
      }
    ]
  }
}

或者是处理.vue文件,使用 vue-loader 来处理 vue 文件

js 复制代码
module.exports = {
  module: {
    rules: [
      {
        test: /\.vue$/,
        use: 'vue-loader'
      }
    ]
  }
}

Plugin

loader 被用于转换某些类型的模块,而插件则可以用于执行范围更广的任务。它是对webpack功能的扩展

插件的范围包括,从打包优化和压缩,一直到重新定义环境中的变量。插件借卡功能机器强大,可以用来处理各种各样的任务。插件的使用方式是通过 apply 方法来注册插件

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

class MyPlugin {
  apply(compiler) {
    // 在这里注册插件
    compiler.hooks.emit.tap('MyPlugin', (compilation) => {
      console.log('编译完成');
    });
  }
}
module.exports = {
  plugins: [
    new MyPlugin()
  ]
}

重点环节详解

1️⃣ 初始化阶段

  • 读取配置文件(webpack.config.js)
  • 合并命令行参数
  • 初始化默认参数
  • ⭐关键点:插件挂载

手写一个简易 Webpack

这里实现一个简单的 webpack 帮助理解原理,实现的mini-webpack需求如下:

以下是一个简单的项目结构,用来测试我们的迷你 webpack: entry为 src/index.js,需要对该代码进行打包输出静态资源,能在浏览器正常执行,代码如下:

javascript 复制代码
import { add } from './math.js';

console.log(add(1, 2));

math.js 文件如下:

javascript 复制代码
export function add(a, b) {
  return a + b;
}

export function subtract(a, b) {
  return a - b;
}

最终执行迷你 webpack 输出结果:

bash 复制代码
node mini-webpack.js

如何实现以上需求呢??

很明显的它有下面的特性:

  1. 入口文件,并且可以引用其他模块的资源,资源下面又会引用其他的模块资源,那么就能形成一个数结构。如下:

左侧是当前的需求的模块结构,右侧是可能会扩展的模块结构

观察发现:需要处理一个根节点,多个子节点的树结构,那么在开发过程中估计需要使用到递归的方式来处理

当前的项目文件就是入口为src/index.js的一个树结构的数据,其中文件内容为节点的value,文件内容的import或者require函数里面的引用文件是指向子节点的指针,所有的操作都是在操作这棵文件树

  1. 代码的执行需要有完整的上下文环境,才能执行成功,也就是说引用的模块资源需要在执行时能被找到并且使用。最简单暴力的实现方式就是把所有的模块都放在一个文件中执行,这样就能保证执行时能找到引用的模块资源。但是会有命名冲突的问题,因为都在同为一个上下文。

解法是:使用函数作用域来隔离模块的上下文环境,使用闭包来实现模块的隔离,方式很妙


实现如下:

mini-webpack.js

javascript 复制代码
// 本次实现的webpack是一个简化版的webpack,主要用于学习和理解webpack的原理
// 主要功能:
// 1. 功能简化:只实现最基本的模块打包功能
// 2. 无加载器系统:无法处理非js文件
// 3. 无插件系统:缺乏扩展能力
// 4. 无代码拆分:不支持代码分割和动态导入
// 5. 无热更新:不支持开发环境的实时重载

// 通过一以下示例,我们可以理解webpack的核心工作原理:
// - 构建依赖图
// - 模块转换
// - 代码生成
// - 运行时模拟

//  开始编写
const fs = require("fs");
const path = require("path");
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const babel = require("@babel/core");

// 分析模块:
// 1. 读取文件
// 2. 使用@babel/parser 将代码转换为抽象语法树 (AST)
// 3. 通过 @babel/traverse 遍历AST,找出所有的import语句和require语句,并且收集依赖
// 4. 使用@babel/core 将代码转换为ES5代码
// 5. 返回模块的文件名、依赖关系和转换后的代码
function parserModule(filename) {
  // 读取文件内容
  const content = fs.readFileSync(filename, "utf-8");

  // 将代码转换为ast抽象语法树
  const ast = parser.parse(content, {
    sourceType: "module",
  });
  const dependencies = {};

  traverse(ast, {
    ImportDeclaration({ node }) {
      const dirname = path.dirname(filename);
      const newFile = path.join(dirname, node.source.value);
      debugger;
      // 保存依赖
      dependencies[node.source.value] = newFile;
    },
  });

  // 将ast转换为es5代码, import 语句将会被转换为 require 语句
  const { code } = babel.transformFromAstSync(ast, null, {
    presets: ["@babel/preset-env"],
  });

  return {
    filename,
    dependencies,
    code: code,
  };
}
const entry = "./src/index.js";
console.log(parserModule(entry));
/** 
{
  filename: './src/index.js',
  dependencies: { './math.js': 'src/math.js' },
  code: '"use strict";\n' +
    '\n' +
    'var _math = require("./math.js");\n' +
    'console.log((0, _math.add)(1, 2));'
}
 */

// 依赖图构建(makeDependencyGraph)
// 1. 从入口文件开始,递归分析所有依赖
// 2. 将分析结果构建成一个依赖图对象,其中键是模块路径,值是模块的依赖和代码

function makeDependenciesGraph(entry) {
  const entryModule = parserModule(entry);
  const graphArray = [entryModule];

  for (let index = 0; index < graphArray.length; index++) {
    // 获取当前模块的依赖记录对象
    const { dependencies } = graphArray[index];

    Object.values(dependencies).forEach((dependencyPath) => {
      const childModule = parserModule(dependencyPath);
      graphArray.push(childModule);
    });
    // 将数组转换为图结构:
    // {
    //   'filename': {
    //     'dependencies': {
    //       'path': 'filename',
    //     },
    //     code: 'code',
    //   }
    // }

    const graph = {};
    graphArray.forEach((module) => {
      graph[module.filename] = {
        dependencies: module.dependencies,
        code: module.code,
      };
    });
    return graph;
  }
}

console.log(makeDependenciesGraph(entry));
/** 
{
  './src/index.js': {
    dependencies: { './math.js': 'src/math.js' },
    code: '"use strict";\n' +
      '\n' +
      'var _math = require("./math.js");\n' +
      'console.log((0, _math.add)(1, 2));'
  },
  'src/math.js': {
    dependencies: {},
    code: '"use strict";\n' +
      '\n' +
      'Object.defineProperty(exports, "__esModule", {\n' +
      '  value: true\n' +
      '});\n' +
      'exports.add = add;\n' +
      'exports.subtract = subtract;\n' +
      'function add(a, b) {\n' +
      '  return a + b;\n' +
      '}\n' +
      'function subtract(a, b) {\n' +
      '  return a - b;\n' +
      '}'
  }
}
 */

// 代码生成(generateCode)
// 1. 将依赖图转换为可执行的js代码
// 2. 创建一个运行时环境,通过自定义的require函数来加载模块
// 3. 模拟CommonJS的模块加载机制

function generateCode(entry) {
  const graph = JSON.stringify(makeDependenciesGraph(entry));

  // 闭包 + 自执行
  return `
  (function (graph) {
    // 模块加载函数
    function require(module) {
      // 相对路径转为决定路径
      function localRequire(relativePath) {
        return require(graph[module].dependencies[relativePath]);
      }

      // 模块导出对象
      const exports = {};

      (function (require, exports, code) {
        eval(code);
      })(localRequire, exports, graph[module].code);

      return exports;
    }
    require('${entry}');
  })(${graph});
  `;
}

const entryFile = "./src/index.js";
const output = generateCode(entryFile);
console.log(output)

fs.writeFileSync('./bundle.js', output, 'utf-8')

输出结果如下,肉眼看是如何运行的:

javascript 复制代码
(function (graph) {
  // 模块加载函数
  function require(module) {
    // 相对路径转为决定路径
    function localRequire(relativePath) {
      return require(graph[module].dependencies[relativePath]);
    }

    // 模块导出对象
    const exports = {};

    (function (require, exports, code) {
      eval(code);
    })(localRequire, exports, graph[module].code);

    return exports;
  }
  require("./src/index.js");
})({
  "./src/index.js": {
    dependencies: { "./math.js": "src/math.js" },
    code: '"use strict";\n\nvar _math = require("./math.js");\nconsole.log((0, _math.add)(1, 2));',
  },
  "src/math.js": {
    dependencies: {},
    code: '"use strict";\n\nObject.defineProperty(exports, "__esModule", {\n  value: true\n});\nexports.add = add;\nexports.subtract = subtract;\nfunction add(a, b) {\n  return a + b;\n}\nfunction subtract(a, b) {\n  return a - b;\n}',
  },
});

上面👆🏻的代码可以直接在控制台执行输出哦

会发现import的模块转换成了require的形式,模块的导出内容执行后会放到require函数的exports对象上

模块的代码处理为节点的value,并且收集散落在代码中的import和require语句,构建节点的指针数据。然后从根出发将树数据转换为数组数据,再将数组数据扁平为一个便于索引的对象数据

妙哉,算法的用处在这儿体现的非常的好

总结

webpack 是一个巨大的node.js应用,完整的webpack有非常多的功能和插件,在使用过程中只需要了解其整体架构和部分细节即可

它把复杂的视线影藏了起来,给我们暴露的是一些简单易用的API,让用户可以快速达成目的。同时整体架构设计合理,扩展性高,开发扩展难度不高,通过社区的支持,补足了大量缺失的功能,让webpack 几乎能胜任任何场景

相关推荐
ak啊5 小时前
Webpack 构建阶段:模块解析流程
前端·webpack·源码
前端与小赵6 小时前
webpack和vite之间的区别
前端·webpack·vite
Moment19 小时前
从 Webpack 源码来深入学习 Tree Shaking 实现原理 🤗🤗🤗
前端·javascript·webpack
henujolly20 小时前
优化webpack打包体积思路
webpack
EricXJ21 小时前
为什么选择 tsup?
前端·webpack·typescript
SuperherRo1 天前
Web开发-JS应用&WebPack构建&打包Mode&映射DevTool&源码泄漏&识别还原
前端·javascript·webpack·源码泄露·识别还原
小浣熊喜欢揍臭臭1 天前
webpack配置详解+项目实战
前端·webpack·node.js
晴空9692 天前
对webpack工程化的理解
webpack
蓝桉柒72 天前
安装Webpack并创建vue项目
前端·vue.js·webpack
梦想CAD控件3 天前
(在线CAD集成)网页CAD二次开发中配置属性的详细教程
前端·javascript·webpack