阅读视图

发现新文章,点击刷新页面。

前端知识体系总结-前端工程化(Babel篇)

Babel

手写一个简易编译器

Babel本质上就是一个编译器。把一种代码变成另一种代码。
我们将要实现一个最简单的Babel核心功能:将ES6的箭头函数转换为ES5的普通函数
我们不要去背那些复杂的概念,编译器的工作流程在任何语言里都是一样的,只有三个阶段:

  1. 解析(Parse):把代码字符串变成树结构(AST)。
  2. 转换(Transform):在树上修修补补,把“箭头函数节点”改成“普通函数节点”。
  3. 生成(Generate):把改好的树重新变回代码字符串。

一、为什么需要将代码解析为 AST

我们先看一个简单的代码:

const add = (a, b) => a + b;

如果不生成AST,直接用正则替换,你可能会写出 code.replace('=>', 'function')。 但如果代码是这样的:

const str = "这个箭头 => 是字符串不是代码";
const func = () => { return "=>"; };

正则就不管用了。它分不清哪个是语法,哪个是字符串内容。
只有通过某种方式把代码拆解成 树状结构 去进行表示,我们才能精准地知道每行代码的实际含义,比如这是一个变量声明,那是一个函数表达式。
这里我们使用 @babel/parser 来生成AST(因为手写词法分析器和语法分析器通过大量switch-case处理字符,逻辑虽简单但代码量太大,这里我们聚焦于核心的转换逻辑)。

二、AST长什么样

我们先看看上面那句 const add = (a, b) => a + b; 解析出来是什么东西。

{
  "type": "VariableDeclaration", // 变量声明
  "kind": "const",
  "declarations": [
    {
      "type": "VariableDeclarator",
      "id": { "type": "Identifier", "name": "add" },
      "init": {
        "type": "ArrowFunctionExpression", // 重点在这里:箭头函数表达式
        "params": [
          { "type": "Identifier", "name": "a" },
          { "type": "Identifier", "name": "b" }
        ],
        "body": {
          "type": "BinaryExpression", // 二进制表达式 (a + b)
          "left": { "type": "Identifier", "name": "a" },
          "operator": "+",
          "right": { "type": "Identifier", "name": "b" }
        }
      }
    }
  ]
}

转换的目标很明确:找到 ArrowFunctionExpression 类型的节点,把它替换成 FunctionExpression 类型的节点,同时处理一下函数体。

三、实现核心:遍历器(Traverser)

Babel最核心的部分不是解析,而是如何遍历这棵树。我们需要写一个函数,它能递归地访问树的每一个节点。当它遇到我们需要处理的节点时,调用我们提供的插件方法。 这是一个最基础的遍历器实现:

function traverse(ast, visitor) {
  // 遍历数组类型的属性(比如 body 里的多行代码)
  function traverseArray(array, parent) {
    array.forEach(child => traverseNode(child, parent));
  }

  // 遍历单个节点
  function traverseNode(node, parent) {
    if (!node || typeof node !== 'object') return;

    // 1. 如果visitor里定义了当前节点类型的处理函数,就执行它
    // 比如 visitor.ArrowFunctionExpression(node, parent)
    const method = visitor[node.type];
    if (method) {
      method(node, parent);
    }

    // 2. 递归遍历当前节点的所有属性
    // 比如遍历 body, params, left, right 等属性
    Object.keys(node).forEach(key => {
      const child = node[key];
      if (Array.isArray(child)) {
        traverseArray(child, node);
      } else {
        traverseNode(child, node);
      }
    });
  }

  traverseNode(ast, null);
}

这段代码的逻辑是:从根节点开始,先检查有没有对应的插件函数要执行,执行完后,继续递归找它的子节点。只要树没走完,就一直递归下去。

四、实现插件:转换箭头函数

现在我们有了遍历器,就可以写“插件”了。插件就是定义由于怎么修改节点。 我们要把箭头函数:

(a, b) => a + b

变成普通函数:

function(a, b) { return a + b; }

转换逻辑的具体步骤:

  1. 找到 ArrowFunctionExpression 节点。
  2. 保留它的 params (参数)。
  3. 处理 body。箭头函数如果直接返回表达式(没有花括号),变成普通函数时需要加 { return ... }
  4. 把节点类型改为 FunctionExpression
const transformer = {
  ArrowFunctionExpression(node) {
    // 1. 修改节点类型
    node.type = 'FunctionExpression';
    
    // 2. 处理函数体
    // 如果原体不是块语句(比如是 x => x + 1 这种直接返回的)
    // 我们需要把它包装成 { return x + 1; }
    if (node.body.type !== 'BlockStatement') {
      node.body = {
        type: 'BlockStatement',
        body: [{
          type: 'ReturnStatement',
          argument: node.body
        }]
      };
    }
    
    // 普通函数通常不需要 generator 或 async 属性,除非原样保留
    node.expression = false; 
  }
};

这里我们直接修改了 node 对象。因为AST本质上就是对象引用,直接修改树上的属性,整棵树的结构就变了。

五、代码生成(Generator)

树修改完了,最后一步是把树变回字符串。 这一步通常很繁琐,因为要处理缩进、括号、分号。为了演示核心逻辑,我们手写一个极简版的生成器,只处理我们涉及到的几种节点。

function generate(node) {
  switch (node.type) {
    case 'Program':
      return node.body.map(generate).join('\n');
      
    case 'VariableDeclaration':
      return `${node.kind} ${node.declarations.map(generate).join(', ')};`;
      
    case 'VariableDeclarator':
      return `${generate(node.id)} = ${generate(node.init)}`;
      
    case 'Identifier':
      return node.name;
      
    case 'FunctionExpression':
      // 组装函数字符串:function(参数) { 函数体 }
      const params = node.params.map(generate).join(', ');
      const body = generate(node.body);
      return `function(${params}) ${body}`;
      
    case 'BlockStatement':
      return `{\n${node.body.map(generate).join('\n')}\n}`;
      
    case 'ReturnStatement':
      return `return ${generate(node.argument)};`;
      
    case 'BinaryExpression':
      return `${generate(node.left)} ${node.operator} ${generate(node.right)}`;
      
    default:
      throw new Error(`Unknown node type: ${node.type}`);
  }
}

生成器逻辑:递归地拼接字符串。遇到 BinaryExpression 就拼左右两边,遇到 FunctionExpression 就拼关键字和参数。

六、串联整个流程(Compiler)

最后,我们把解析、转换、生成串起来,就是一个迷你版的 Babel。

const parser = require('@babel/parser'); // 借用parser,专注转换逻辑

function myBabelCompiler(code) {
  // 1. 解析 (Code -> AST)
  const ast = parser.parse(code);

  // 2. 转换 (AST -> New AST)
  // 传入我们的访问器对象
  traverse(ast, transformer);

  // 3. 生成 (New AST -> New Code)
  const output = generate(ast);

  return output;
}

// 测试
const sourceCode = "const add = (a, b) => a + b;";
const targetCode = myBabelCompiler(sourceCode);

console.log(targetCode);
// 输出结果:
// const add = function(a, b) {
// return a + b;
// };

总结

实现一个Babel,不要把问题想得太复杂,其实就是三个步骤:

  1. 对象化:代码是字符串,没法改,先变成对象(AST)。
  2. 递归:对象嵌套太深,必须用递归函数(Visitor)去一层层找。
  3. 还原:改完对象属性后,按照语法规则把字符串拼回去。 真正的Babel虽然庞大,因为它要处理几百种语法节点,还要处理作用域(Scope)和引用关系,但核心骨架就是上面这几十行代码。当你写Babel插件时,你其实就是在写那个 transformer 对象里的函数。

Babel工程化配置与使用

刚才我们手写了一个微型编译器,搞懂了原理。但在实际工作中,我们不可能自己去写AST遍历器和生成器。我们直接使用Babel官方提供的工具链。

这里有一个非常反直觉的事实:Babel本身什么都不做

如果你只安装 @babel/core 然后运行它,你把 ES6 代码丢进去,出来的还是 ES6 代码。它只是把代码解析成AST,然后又打印出来,中间没有任何修改。它不知道你要干什么。

要让它干活,必须明确告诉它:我要转换箭头函数,或者我要转换类(Class)。这些具体的转换功能,就是 Plugin(插件);而为了方便,把一堆常用的插件打包在一起,就是 Preset(预设)

一、基础配置:从零开始搭建

我们不讲虚的,直接看在一个空文件夹里怎么把 Babel 跑起来。

1. 初始化项目与安装核心库

你需要安装三个最基础的包:

  • @babel/core: 编译器核心,负责解析和生成。
  • @babel/cli: 命令行工具,让我们能在终端里运行 babel 命令。
  • @babel/preset-env: 这是一个智能预设,包含了所有现代 JS 语法的转换插件。
npm init -y
npm install --save-dev @babel/core @babel/cli @babel/preset-env

2. 编写配置文件

在项目根目录创建一个 babel.config.json 文件。这是控制 Babel 行为的大脑。最简单的配置只需要一行:告诉 Babel 使用 preset-env

{
  "presets": ["@babel/preset-env"]
}

3. 运行测试

创建一个 src/index.js,写点 ES6 代码:

const sayHello = () => console.log("Hello");

在终端运行编译命令:

npx babel src --out-dir dist

打开生成的 dist/index.js,你会发现箭头函数变成了 functionconst 变成了 var。这就是 preset-env 在起作用。它默认把所有新语法都转成了 ES5。

二、按需编译:Targets 的重要性

上面的默认配置有一个大问题:它太“笨”了。

它把所有代码都转成了 ES5,哪怕你只是跑在最新的 Chrome 浏览器上。现代浏览器原生支持 const 和箭头函数,强行转换只会让代码体积变大,运行变慢。

我们需要告诉 Babel 我们的代码要在什么环境下运行。

修改 babel.config.json

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "targets": {
          "chrome": "88",
          "ie": "11"
        }
      }
    ]
  ]
}

这里我们配置了 targets。 如果你把 ie: "11" 去掉,只保留 chrome: "88",再次编译,你会发现 const 和箭头函数被保留了,没有被转换。

这是因为 Babel 查表发现 Chrome 88 原生支持这些语法,所以它直接跳过了转换步骤。这是 Babel 配置中最核心的优化点:只转换目标环境不支持的语法

三、处理API:Polyfill (垫片)

这是新手最容易混淆的地方。Babel 有两类转换:

  1. 语法转换 (Syntax Transform):比如 => 转成 functionclass 转成 prototype。这是 preset-env 擅长的。
  2. API 添加 (Polyfill):比如 Array.fromnew Promise()Map

如果你在代码里写 new Promise(),Babel 默认是不处理的。因为从语法角度看,这就是创建了一个对象,语法没问题。但在 IE11 里运行会直接报错 Promise is not defined

我们需要引入 core-js 来实现这些缺少的 API。

不要全量引入,那样包会很大。我们要配置 Babel 自动按需引入。

首先安装 core-js:

npm install core-js

修改 babel.config.json,开启 useBuiltIns: "usage"

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "targets": {
          "chrome": "58",
          "ie": "11"
        },
        "useBuiltIns": "usage", // 关键配置:按需引入
        "corejs": 3             // 指定 core-js 版本
      }
    ]
  ]
}

现在,如果在你的代码里写了 new Promise(),Babel 编译时会自动在文件头部加上一句: require("core-js/modules/es.promise.js")

如果你没用到 Promise,它就不加。这就是 usage 模式的威力。

四、在 Webpack 中集成

在实际开发中,我们很少直接运行 npx babel。通常是配合 Webpack 打包时自动转换。这需要用到 babel-loader

这是 Webpack 和 Babel 的连接桥梁。Webpack 负责读取文件,发现是 .js 后,交给 babel-loaderbabel-loader 调用 @babel/core 进行转换,转换完把代码还给 Webpack。

webpack.config.js 配置示例:

module.exports = {
  mode: 'development',
  entry: './src/index.js',
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/, // 极其重要:千万别编译 node_modules,慢且容易出错
        use: {
          loader: 'babel-loader',
          // options 可以在这里写,也可以直接读取 babel.config.json
          // 推荐使用独立配置文件,更清晰
        }
      }
    ]
  }
};

只要项目根目录下有 babel.config.jsonbabel-loader 会自动读取它,不需要重复配置。

总结 Babel 使用的核心逻辑

  1. Babel 核心只是空壳,必须通过配置文件告诉它用什么插件。
  2. Preset-env 是万能钥匙,它根据 targets 决定要转换哪些语法,避免过度编译。
  3. 语法 != API=> 是语法,Promise 是 API。处理 API 需要配置 core-jsuseBuiltIns: "usage"
  4. exclude node_modules。在使用 Webpack 时,永远记得排除 node_modules,第三方包通常已经是编译好的,重复编译纯属浪费时间。

手写 Babel 插件:复刻 babel-plugin-import

我们要写一个真的能用的、在生产环境中极其常见的插件。

很多 UI 组件库(比如 Ant Design)或者工具库(比如 Lodash),都有一个痛点:文件太大。

当你写下这行代码时:

import { Button, Alert } from 'antd';

在没有优化的情况下,Webpack 会把整个 antd 库(几百个组件)全打包进去,哪怕你只用了两个组件。我们要写的插件,就是要把上面那一行代码,在编译时自动转换成:

import Button from 'antd/lib/button';
import Alert from 'antd/lib/alert';

这样就能按需加载,体积瞬间变小。这个插件逻辑非常经典,涉及了节点查找节点替换多节点生成这几个 Babel 插件最核心的操作。

一、准备工作

写插件的第一步永远不是写代码,而是对比 AST。我们要搞清楚,处理前的 AST 长什么样,处理后长什么样。

处理前 (import { Button } from 'antd'): 它是一个 ImportDeclaration 节点。

  • source: 值是 'antd'
  • specifiers: 这是一个数组。里面有一个 ImportSpecifier,它的 imported 属性是 Button(引入的名字),local 属性也是 Button(本地使用的名字)。

处理后 (import Button from 'antd/lib/button'): 变成了两个(或多个)ImportDeclaration 节点。

  • 每个节点都是 ImportDefaultSpecifier(注意这里变成了默认导入,因为具体的组件文件通常是 export default)。
  • source: 值变成了 'antd/lib/button'

处理方案:

  1. 监听:专门盯着 ImportDeclaration 类型的节点。
  2. 检查:看它的来源库是不是我们要优化的库(比如 'antd')。
  3. 提取:如果是,就把里面的 ButtonAlert 这些名字取出来。
  4. 构造:用这些名字生成新的 import 语句。
  5. 替换:用新生成的数组,替换掉原来那一个老节点。

二、开始编写插件代码

创建一个 my-import-plugin.js 文件。

Babel 插件的标准写法是一个函数,它接受一个 babel 对象作为参数。我们需要从这个对象里拿出 types,这是 Babel 提供的节点构造工厂。你可以把它想象成乐高积木的模具,用来生成新的 AST 节点。

module.exports = function(babel) {
  const { types: t } = babel; // 这是我们的工厂

  return {
    visitor: {
      // 我们只关心 import 语句
      ImportDeclaration(path, state) {
        const { node } = path;

        // 1. 检查:如果引入的库不是 'antd',直接跳过,不做处理
        // state.opts 是我们在配置文件里传给插件的参数
        // 这样插件就不仅仅能处理 antd,也能处理 lodash 等其他库
        const libraryName = state.opts.libraryName || 'antd';
        if (node.source.value !== libraryName) {
          return;
        }

        // 2. 检查:如果是默认导入 (import Antd from 'antd'),不仅没法按需加载,还说明用户可能真想引入全量
        // 我们只处理 { Button } 这种命名导入 (ImportSpecifier)
        if (!t.isImportSpecifier(node.specifiers[0])) {
          return;
        }

        // 3. 核心逻辑:遍历原来的 specifiers,生成新的 import 节点数组
        const newImports = node.specifiers.map(specifier => {
          // specifier.imported.name 是 "Button"
          // specifier.local.name 是我们代码里用的变量名 (通常也是 "Button")
          const componentName = specifier.imported.name;
          const localName = specifier.local.name;

          // 构造新的路径: 'antd/lib/button'
          // 这里简单的转成小写,实际工程中可能需要驼峰转连字符
          const newPath = `${libraryName}/lib/${componentName.toLowerCase()}`;

          // 使用 Babel 的 types 工具创建新节点
          // 生成: import localName from 'newPath'
          return t.importDeclaration(
            [t.importDefaultSpecifier(t.identifier(localName))],
            t.stringLiteral(newPath)
          );
        });

        // 4. 替换:用新的节点数组替换原来的一个节点
        // replaceWithMultiple 专门用来把一个节点变成一堆节点
        path.replaceWithMultiple(newImports);
      }
    }
  };
};

这段代码虽然短,但它展示了 Babel 插件最核心的逻辑:Path(路径)操作path 对象非常强大,它不只是当前节点,还包含了父节点、兄弟节点的信息,以及最重要的操作方法(比如 replaceWithMultiple, remove, insertBefore)。

三、调试与运行

插件写好了,怎么用呢?我们不需要把它发布到 npm,直接在本地引用测试。

在项目根目录下创建一个 .babelrc 或者 babel.config.json,配置上我们刚写的插件:

{
  "presets": ["@babel/preset-env"],
  "plugins": [
    [
      "./my-import-plugin.js", 
      {
        "libraryName": "antd" 
      }
    ]
  ]
}

这里我们用了相对路径 ./my-import-plugin.js,并且传入了参数 libraryName: "antd"

验证效果

创建一个 test.js

import { Button, Modal } from 'antd';
console.log(Button, Modal);

然后运行 Babel 编译(假设你已经安装了 @babel/cli):

npx babel test.js

你的控制台输出应该会变成这样:

import Button from "antd/lib/button";
import Modal from "antd/lib/modal";
console.log(Button, Modal);

四、进阶思考:为什么说这有难度?

刚才的代码是一个“乞丐版”实现。在真实场景中,情况会复杂得多,这也是为什么 babel-plugin-import 源码有几百行的原因。

1. 样式的处理 真正的按需加载,不仅仅是加载 JS,还要加载对应的 CSS。 你需要不仅生成 import Button from ...,还要顺便生成 import 'antd/lib/button/style/css'。这需要在 map 循环里多生成一个 importDeclaration 节点。

2. 作用域冲突 如果你在代码里已经定义了一个叫 Button 的变量,然后再 import { Button } from 'antd',Babel 插件如果不小心处理,可能会导致变量名冲突。虽然在这个场景下概率不大,但写通用插件时,通常需要用 path.scope.generateUidIdentifier 来生成唯一的变量名。

3. 路径转换规则 我们只用了简单的 .toLowerCase()。但有的组件叫 DatePicker,文件路径可能是 date-picker。这时候就需要引入更复杂的命名转换算法(Kebab Case)。

总结

写好一个 Babel 插件,其实就是三个步骤的循环:

  1. 看 AST:用 AST Explorer 这种在线工具,把你的源代码放进去,看它是怎么被解析的。
  2. 造节点:利用 babel.types (t) 构建你想要的新结构。
  3. 换节点:利用 path 提供的 API,把旧的换成新的。

当你掌握了 visitor 模式和 types 构建器,你就掌握了修改 JavaScript 语言本身的权力。

前端知识体系总结-前端工程化(Webpack篇)

Wepack实现

webpack打包功能实现

webpack打包与模块加载原理(从JS入口文件出发如何进行简单打包 -> __webpack_require__具体实现 -> 一个最基础的bundle.js至少具备的内容 -> 实现一个基本的webpack打包功能)

一、从JS文件打包说起

1.1 基本打包过程

当我们有以下文件结构时:

src/
  ├── a.js (入口文件)
  └── b.js (依赖文件)

a.js (入口文件):

import { getValue } from './b.js';
console.log(getValue());

b.js (依赖文件):

export function getValue() {
  return 'Hello from b.js';
}

1.2 打包后的结果(自测:请说出打包后的代码形式)

以a.js为入口进行打包后,生成的bundle.js会将每个模块包装成函数形式:

// 简化版的打包结果
{
  "./src/a.js": function(module, exports, __webpack_require__) {
    eval(`
      const { getValue } = __webpack_require__("./src/b.js");
      console.log(getValue());
    `);
  },
  "./src/b.js": function(module, exports, __webpack_require__) {
    eval(`
      function getValue() {
        return 'Hello from b.js';
      }
      exports.getValue = getValue;
    `);
  }
}

关键变化:

  • 原本的 import { getValue } from './b.js' 被转换为 __webpack_require__("./src/b.js")
  • 每个模块被包装在函数中,接收 module, exports, __webpack_require__ 参数

二、webpack_require 的实现原理(自测:说出核心代码实现)

2.1 函数签名与作用

function __webpack_require__(moduleId) {
  // 参数:moduleId - 模块的路径标识符(如 "./src/b.js")
  // 返回值:该模块的所有导出内容(exports对象)
}

2.2 完整实现过程

// 模块缓存对象
var __webpack_module_cache__ = {};

// 主要的模块加载函数
function __webpack_require__(moduleId) {
  // 1. 检查缓存,避免重复加载
  var cachedModule = __webpack_module_cache__[moduleId];
  if (cachedModule !== undefined) {
    return cachedModule.exports;
  }
  
  // 2. 创建新的模块对象并缓存
  var module = __webpack_module_cache__[moduleId] = {
    exports: {}
  };
  
  // 3. 执行模块函数,填充exports
  __webpack_modules__[moduleId](module, module.exports, __webpack_require__);
  
  // 4. 返回模块的导出内容
  return module.exports;
}

2.3 模块执行机制

关键在于这一行:

__webpack_modules__[moduleId](module, module.exports, __webpack_require__);

执行过程:

  1. __webpack_modules__ 对象中获取对应的模块函数
  2. 传入三个参数:module(模块对象)、module.exports(导出对象)、__webpack_require__(加载函数)
  3. 模块函数内部通过修改 module.exports 来导出内容
  4. 执行完成后返回填充好的 module.exports

三、Bundle.js的基本结构(自测:说出结构是什么以及为什么)

一个完整的bundle.js至少需要包含以下内容:

3.1 核心组件

// 1. 模块存储对象 - 存放所有模块函数
var __webpack_modules__ = {
  "./src/a.js": function(module, exports, __webpack_require__) { /* ... */ },
  "./src/b.js": function(module, exports, __webpack_require__) { /* ... */ }
};

// 2. 模块缓存对象
var __webpack_module_cache__ = {};

// 3. 模块加载函数
function __webpack_require__(moduleId) { /* ... */ }

// 4. 启动应用程序
__webpack_require__("./src/a.js");

3.2 完整示例

(function() {
  "use strict";
  
  var __webpack_modules__ = {
    "./src/a.js": function(module, exports, __webpack_require__) {
      eval(`
        const { getValue } = __webpack_require__("./src/b.js");
        console.log(getValue());
      `);
    },
    "./src/b.js": function(module, exports, __webpack_require__) {
      eval(`
        function getValue() {
          return 'Hello from b.js';
        }
        exports.getValue = getValue;
      `);
    }
  };
  
  var __webpack_module_cache__ = {};
  
  function __webpack_require__(moduleId) {
    var cachedModule = __webpack_module_cache__[moduleId];
    if (cachedModule !== undefined) {
      return cachedModule.exports;
    }
    
    var module = __webpack_module_cache__[moduleId] = {
      exports: {}
    };
    
    __webpack_modules__[moduleId](module, module.exports, __webpack_require__);
    
    return module.exports;
  }
  
  // 启动入口模块
  __webpack_require__("./src/a.js");
})();

总结

Webpack的核心打包原理:

  1. 模块化处理:将每个文件包装成函数,统一模块接口,并存储在全局webpack——modules中。
  2. 依赖管理:通过__webpack_require__实现模块间的加载和缓存,获取文件导出内容,并且缓存导出结果下次复用。
  3. 代码整合:将所有模块函数和运行时代码组装成单一文件 bundle.js 用立即执行函数进行运行。

这种设计让浏览器能够执行原本不支持的ES6模块语法,同时实现了高效的模块缓存和按需加载机制。

实现Webpack依赖分析(自测:如何实现分析依赖,两种优缺点)

实现步骤

  1. 依赖分析:从入口文件开始,递归找到所有依赖的文件
  2. 代码转换:将每个文件转换为模块函数格式
  3. 生成bundle:将所有模块函数组装成最终的bundle.js

依赖分析的两种方法

方法一:正则表达式

function findDependenciesByRegex(code) {
  const importRegex = /import\s+.*?\s+from\s+['"](.*?)['"];?/g;
  const dependencies = [];
  let match;
  
  while ((match = importRegex.exec(code)) !== null) {
    dependencies.push(match[1]);
  }
  
  return dependencies;
}

优点:

  • 实现简单,代码量少
  • 执行速度快

缺点:

  • 容易误匹配字符串中的内容
  • 无法处理复杂的import语法
  • 不够准确和可靠

问题示例:

// 这种情况会被错误匹配
const code = `
  console.log("import something from 'fake-module'");
  import { real } from './real-module';
`;

方法二:抽象语法树(AST)

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

function findDependenciesByAST(code) {
  const dependencies = [];
  
  // 将代码解析为AST
  const ast = babel.parse(code, {
    sourceType: 'module'
  });
  
  // 遍历AST节点找到import的路径
  traverse(ast, {
    ImportDeclaration(path) {
      dependencies.push(path.node.source.value);
    }
  });
  
  return dependencies;
}

优点:

  • 精确解析,不会误匹配字符串
  • 能处理各种复杂的import语法
  • 提供完整的语法信息

缺点:

  • 实现复杂度较高
  • 需要引入额外的解析库
  • 执行速度相对较慢

为什么AST更准确:

  • AST将代码解析为树形结构,每个import语句会生成专门的ImportDeclaration节点
  • 字符串内容不会被解析为import节点,从根本上避免了误匹配
  • 能够准确识别import语句的各个组成部分(导入内容、来源路径等)

手写实现抽象语法树与完整模块打包工具

一、获取JS文件依赖信息,获取依赖文件绝对路径:如何将代码解析为抽象语法树(AST)

1.1 使用@babel/parser解析代码

抽象语法树(Abstract Syntax Tree, AST)是源代码的抽象语法结构的树状表示。我们可以使用 @babel/parser(原名 Babylon)将 JavaScript 代码字符串解析为 AST 对象。

const parser = require('@babel/parser');

const code = `import React from 'react';`;
const ast = parser.parse(code, {
  sourceType: 'module' // 指定代码为模块化代码
});

console.log(ast);

解析后的 AST 本质上是一个 JavaScript 对象,其中包含描述代码结构的各种节点。当打印 AST 时,某些嵌套较深的属性会以其类型(如 Node、Position)代替显示,但直接转换为字符串可以看到完整结构。

1.2 手动遍历AST获取依赖

AST 的 program.body 属性是一个数组,包含了当前文件的所有顶级语句。我们可以遍历这个数组,找到所有类型为 ImportDeclaration 的节点,然后从中提取导入路径。

const dependencies = [];
ast.program.body.forEach(node => {
  if (node.type === 'ImportDeclaration') {
    dependencies.push(node.source.value);
  }
});

console.log(dependencies); // ['react']

这种方法虽然可行,但手动遍历 AST 结构繁琐且容易出错。

1.3 使用@babel/traverse简化遍历

@babel/traverse 提供了一个更便捷的方式来遍历 AST。我们可以使用它来查找特定类型的节点。

const traverse = require('@babel/traverse').default;

const dependencies = [];
traverse(ast, {
  ImportDeclaration(path) {
    dependencies.push(path.node.source.value);
  }
});

console.log(dependencies); // ['react']

这种方法更加简洁和可靠,我们只需要定义对特定节点类型的处理函数即可。

二、如何实现从入口文件开始自动化依赖分析所有依赖文件

2.1 单文件依赖分析

我们可以封装一个函数来分析单个文件的依赖:

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

function getDependencies(filename) {
  const content = fs.readFileSync(filename, 'utf-8');
  const ast = parser.parse(content, { sourceType: 'module' });

  const dependencies = [];
  traverse(ast, {
    ImportDeclaration(path) {
      const importPath = path.node.source.value;
      // 将相对路径转换为绝对路径
      const absolutePath = path.resolve(path.dirname(filename), importPath);
      dependencies.push(absolutePath);
    }
  });

  return {
    filename,
    dependencies
  };
}

2.2 广度优先搜索分析所有依赖

从入口文件开始,我们可以使用广度优先搜索(BFS)来分析整个项目的所有依赖:

function analyzeDependencies(entryFile) {
  const queue = [entryFile];
  const allDependencies = new Set();
  const dependencyGraph = new Map();

  while (queue.length > 0) {
    const currentFile = queue.shift();

    if (allDependencies.has(currentFile)) continue;
    allDependencies.add(currentFile);

    const { dependencies } = getDependencies(currentFile);
    dependencyGraph.set(currentFile, dependencies);

    dependencies.forEach(dep => {
      if (!allDependencies.has(dep)) {
        queue.push(dep);
      }
    });
  }

  return dependencyGraph;
}

这样我们就得到了一个包含所有模块及其依赖关系的映射表。

三、ES模块语法转换为CommonJS形式

为了使模块代码能在打包环境中运行,我们需要将 ES 模块语法转换为 CommonJS 形式。这包括处理 import 和 export 语句。

3.1 ImportDeclaration转换

对于不同类型的 import 语法,我们进行不同的转换:

const { transformFromAst } = require('@babel/core');
const t = require('@babel/types');

function transformImportDeclaration(ast, moduleIdMap) {
  traverse(ast, {
    ImportDeclaration(path) {
      const source = path.node.source.value;
      const absolutePath = path.resolve(path.dirname(path.hub.file.opts.filename), source);

      // 生成模块ID
      const moduleId = moduleIdMap.get(absolutePath) || generateModuleId(absolutePath);
      moduleIdMap.set(absolutePath, moduleId);

      const specifiers = path.node.specifiers;
      const imports = specifiers.map(spec => {
        if (t.isImportDefaultSpecifier(spec)) {
          // 默认导入:import foo from 'module' → const foo = webpack_require(moduleId)
          return t.variableDeclarator(
            t.identifier(spec.local.name),
            t.callExpression(t.identifier('webpack_require'), [t.numericLiteral(moduleId)])
          );
        } else if (t.isImportSpecifier(spec)) {
          // 命名导入:import { foo } from 'module' → const foo = webpack_require(moduleId).foo
          return t.variableDeclarator(
            t.identifier(spec.local.name),
            t.memberExpression(
              t.callExpression(t.identifier('webpack_require'), [t.numericLiteral(moduleId)]),
              t.identifier(spec.imported.name)
            )
          );
        }
      }).filter(Boolean);

      // 替换 import 语句为 const 声明
      path.replaceWith(t.variableDeclaration('const', imports));
    }
  });
}

3.2 ExportDefaultDeclaration转换

将 export default 语句转换为 CommonJS 形式:

function transformExportDefaultDeclaration(ast) {
  traverse(ast, {
    ExportDefaultDeclaration(path) {
      // 替换 export default foo 为 module.exports = foo
      path.replaceWith(
        t.expressionStatement(
          t.assignmentExpression(
            '=',
            t.memberExpression(t.identifier('module'), t.identifier('exports')),
            path.node.declaration
          )
        )
      );
    }
  });
}

3.3 ExportNamedDeclaration转换

将命名导出语句转换为 CommonJS 形式:

function transformExportNamedDeclaration(ast) {
  traverse(ast, {
    ExportNamedDeclaration(path) {
      // 替换 export { foo } 为 module.exports.foo = foo
      if (path.node.specifiers.length) {
        const exports = path.node.specifiers.map(spec => {
          return t.expressionStatement(
            t.assignmentExpression(
              '=',
              t.memberExpression(
                t.memberExpression(t.identifier('module'), t.identifier('exports')),
                t.identifier(spec.exported.name)
              ),
              t.identifier(spec.local.name)
            )
          );
        });
        path.replaceWithMultiple(exports);
      }
    }
  });
}

3.4 模块ID生成

我们使用一个简单的自增 ID 来标识每个模块:

const moduleIdMap = new Map();
let nextModuleId = 0;

function generateModuleId(modulePath) {
  if (!moduleIdMap.has(modulePath)) {
    moduleIdMap.set(modulePath, nextModuleId++);
  }
  return moduleIdMap.get(modulePath);
}

实际 Webpack 会使用更复杂的哈希算法生成模块 ID,以实现更好的缓存效果。

四、打包产物(bundle.js)工作原理,核心概念和结构

4.1 模块打包的核心概念

打包工具的核心功能包括:

  • 模块作用域隔离:通过函数作用域将每个模块封装
  • 模块导入导出:实现模块间的引用关系
  • 模块缓存:避免重复执行模块代码
  • 入口执行:从入口文件开始执行整个应用

4.2 简化版打包产物结构

一个简化版的打包产物(bundle.js)通常包含以下部分:

(function(modules) {
  // 模块缓存
  const installedModules = {};

  // 模拟webpack_require函数
  function webpack_require(moduleId) {
    // 检查缓存
    if (installedModules[moduleId]) {
      return installedModules[moduleId].exports;
    }

    // 创建新模块
    const module = installedModules[moduleId] = {
      exports: {}
    };

    // 执行模块函数
    modules[moduleId].call(
      module.exports,
      module,
      module.exports,
      webpack_require
    );

    return module.exports;
  }

  // 执行入口模块
  return webpack_require('<%= entryModuleId %>');
})({
  <% modules.forEach((module) => { %>
    '<%= module.id %>': function(module, exports, webpack_require) {
        <%= module.code %>
     },
  <% }); %>
});

注意:这里使用的是 webpack_require 而不是 require,以避免与 Node.js 的原生 require 混淆,他们不是一个函数

五、使用EJS动态生成打包产物

5.1 EJS模板基础

EJS 是一个简单的模板引擎,可以让我们用 JavaScript 生成 HTML 或其他文本格式。基本语法:

  • <%= variable %>:输出变量值
  • <% code %>:执行 JavaScript 代码

5.2 创建打包模板

我们可以创建一个 EJS 模板来动态生成打包产物:

const ejs = require('ejs');

const template = `
(function(modules) {
  const installedModules = {};

  function webpack_require(moduleId) {
    if (installedModules[moduleId]) {
      return installedModules[moduleId].exports;
    }

    const module = installedModules[moduleId] = {
      exports: {}
    };

    modules[moduleId].call(
      module.exports,
      module,
      module.exports,
      webpack_require
    );

    return module.exports;
  }

  return webpack_require(<%= entryModuleId %>);
})({
  <% modules.forEach((module) => { %>
    <%= module.id %>: function(module, exports, webpack_require) {
      <%= module.code %>
    },
  <% }); %>
});
`;

5.3 渲染打包产物

使用 EJS 渲染模板并生成最终的打包文件:

function generateBundle(modules, entryId) {
  const moduleList = Array.from(modules.values()).map(mod => ({
    id: mod.id,
    code: mod.code
  }));

  const bundleCode = ejs.render(template, {
    entryModuleId: entryId,
    modules: moduleList
  });

  return bundleCode;
}

六、完整的打包流程实现

整合所有步骤,实现完整的打包流程:

const fs = require('fs');
const path = require('path');
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const generate = require('@babel/generator').default;
const t = require('@babel/types');

// 模块 ID 映射表
const moduleIdMap = new Map();
let nextModuleId = 0;

// 生成模块 ID
function generateModuleId(modulePath) {
  if (!moduleIdMap.has(modulePath)) {
    moduleIdMap.set(modulePath, nextModuleId++);
  }
  return moduleIdMap.get(modulePath);
}

// 解析模块内容,提取依赖并转换代码
function parseModule(modulePath) {
  const filename = path.resolve(modulePath);
  const content = fs.readFileSync(filename, 'utf-8');

  // 解析 AST
  const ast = parser.parse(content, {
    sourceType: 'module',
  });

  const dependencies = [];

  // 遍历 AST,提取 import 依赖并转换为 webpack_require
  traverse(ast, {
    ImportDeclaration(p) {
      const source = p.node.source.value;
      const absolutePath = path.resolve(path.dirname(filename), source);

      // 记录依赖
      dependencies.push(absolutePath);

      // 生成模块 ID
      const moduleId = generateModuleId(absolutePath);

      // 替换 import 语句为 webpack_require
      const specifiers = p.node.specifiers;
      const imports = specifiers.map(spec => {
        if (t.isImportDefaultSpecifier(spec)) {
          // 默认导入:import foo from 'module' → const foo = webpack_require(moduleId)
          return t.variableDeclarator(
            t.identifier(spec.local.name),
            t.callExpression(t.identifier('webpack_require'), [t.numericLiteral(moduleId)])
          );
        } else if (t.isImportSpecifier(spec)) {
          // 命名导入:import { foo } from 'module' → const foo = webpack_require(moduleId).foo
          return t.variableDeclarator(
            t.identifier(spec.local.name),
            t.memberExpression(
              t.callExpression(t.identifier('webpack_require'), [t.numericLiteral(moduleId)]),
              t.identifier(spec.imported.name)
            )
          );
        }
      }).filter(Boolean);

      // 替换 import 语句为 const 声明
      p.replaceWith(t.variableDeclaration('const', imports));
    },

    ExportDefaultDeclaration(p) {
      // 替换 export default 为 module.exports
      p.replaceWith(
        t.expressionStatement(
          t.assignmentExpression(
            '=',
            t.memberExpression(t.identifier('module'), t.identifier('exports')),
            p.node.declaration
          )
        )
      );
    },

    ExportNamedDeclaration(p) {
      // 替换 export { foo } 为 module.exports.foo = foo
      if (p.node.specifiers.length) {
        const exports = p.node.specifiers.map(spec => {
          return t.expressionStatement(
            t.assignmentExpression(
              '=',
              t.memberExpression(
                t.memberExpression(t.identifier('module'), t.identifier('exports')),
                t.identifier(spec.exported.name)
              ),
              t.identifier(spec.local.name)
            )
          );
        });
        p.replaceWithMultiple(exports);
      }
    },
  });

  // 生成转换后的代码
  const { code } = generate(ast);

  return {
    id: generateModuleId(filename),
    filename,
    dependencies,
    code,
  };
}

// 递归分析所有依赖
function analyzeDependencies(entry) {
  const entryModule = parseModule(entry);
  const queue = [entryModule];
  const modules = new Map();

  modules.set(entryModule.id, entryModule);

  while (queue.length > 0) {
    const currentModule = queue.shift();

    currentModule.dependencies.forEach(depPath => {
      const depModule = parseModule(depPath);

      if (!modules.has(depModule.id)) {
        modules.set(depModule.id, depModule);
        queue.push(depModule);
      }
    });
  }

  return modules;
}

// 生成打包后的代码
function generateBundle(modules, entryId) {
  const moduleList = Array.from(modules.values()).map(mod => `
  ${mod.id}: function(module, exports, webpack_require) {
    ${mod.code}
  },
`).join('\n');

  return `
(function(modules) {
  const installedModules = {};

  function webpack_require(moduleId) {
    if (installedModules[moduleId]) {
      return installedModules[moduleId].exports;
    }

    const module = installedModules[moduleId] = {
      exports: {}
    };

    modules[moduleId].call(
      module.exports,
      module,
      webpack_require
    );

    return module.exports;
  }

  return webpack_require(${entryId});
})({
${moduleList}
});
`;
}

// 主打包函数
function bundle(entryFile, outputFile) {
  const modules = analyzeDependencies(entryFile);
  const entryModule = Array.from(modules.values()).find(mod => mod.filename === path.resolve(entryFile));
  const bundleCode = generateBundle(modules, entryModule.id);

  fs.writeFileSync(outputFile, bundleCode);
  console.log(`✅ 打包完成: ${outputFile}`);
}

// 使用示例
bundle('./src/index.js', './dist/bundle.js');

七、总结

通过以上步骤,我们实现了一个简化版的模块打包工具,核心流程包括:

  1. 使用 @babel/parser 将代码解析为 AST
  2. 使用 @babel/traverse 遍历 AST 提取依赖关系
  3. 将 ES 模块语法转换为 CommonJS 形式
    • 处理默认导入:import foo from 'module'const foo = webpack_require(moduleId)
    • 处理命名导入:import { foo } from 'module'const foo = webpack_require(moduleId).foo
    • 处理默认导出:export default foomodule.exports = foo
    • 处理命名导出:export { foo }module.exports.foo = foo
  1. 通过广度优先搜索分析整个项目的依赖图
  2. 使用模块 ID 优化和代码转换完善打包产物
  3. 动态生成最终的打包代码

Webpack Loader实现

一、Loader的基本概念

1.1 什么是Loader

Loader是Webpack的核心功能之一,它的作用是将非JavaScript文件转换为JavaScript模块,使得Webpack能够处理除了JS之外的各种类型的文件。

1.2 为什么需要Loader

原生Webpack的局限性

  • Webpack原生只能理解JavaScriptJSON文件
  • 当遇到其他格式文件时,需要转换为JavaScript语法才能被解析为AST(进行依赖分析也就是寻找import的子文件路径)

问题示例

// 以下代码会导致解析失败
import './styles.css';        // CSS文件不符合JS语法
import data from './data.json'; // JSON需要特殊处理

解析失败的原因

  • CSS文件内容如 .button { color: red; } 不符合JavaScript语法规范
  • 直接解析会在AST生成阶段报错
  • 需要先转换为有效的JavaScript导出语句

1.3 Loader的工作原理

Loader本质上是一个转换函数,它接收源文件内容,返回转换后的JavaScript代码:

// Loader的基本结构
module.exports = function(source) {
  // source: 原始文件内容字符串
  // 返回: 转换后的JavaScript代码字符串
  return `export default ${JSON.stringify(source)}`;
};

二、Loader的配置与执行机制

2.1 Webpack配置中的Loader

// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /.json$/,           // 正则匹配文件类型
        use: ['json-loader']       // 使用的loader数组
      },
      {
        test: /.css$/,
        use: ['style-loader', 'css-loader'] // 多个loader的执行顺序
      }
    ]
  }
};

2.2 Loader的执行顺序

关键特性:Loader从右到左(从后到前)执行

use: ['style-loader', 'css-loader']
// 执行顺序:
// 1. css-loader 处理 .css 文件
// 2. style-loader 处理 css-loader 的输出结果

执行流程图

原始CSS文件 → css-loader → JavaScript字符串 → style-loader → 最终JavaScript模块

2.3 在打包器中集成Loader机制

修改文件分析逻辑

class SimpleWebpack {
  constructor(entry, output, config = {}) {
    this.entry = entry;
    this.output = output;
    this.loaders = config.module?.rules || []; // 获取loader配置
    // ... 其他属性
  }

  /**
   * 应用匹配的loaders处理文件内容
   */
  applyLoaders(filePath, source) {
    let transformedSource = source;

    // 遍历所有loader规则
    for (const rule of this.loaders) {
      // 检查文件是否匹配当前规则
      if (rule.test.test(filePath)) {
        // 从右到左执行loaders
        const loaders = Array.isArray(rule.use) ? [...rule.use].reverse() : [rule.use];
        
        for (const loaderName of loaders) {
          const loader = this.loadLoader(loaderName);
          transformedSource = loader(transformedSource);
        }
        break; // 匹配到规则后停止检查其他规则
      }
    }

    return transformedSource;
  }

  /**
   * 加载并返回loader函数
   */
  loadLoader(loaderName) {
    // 在实际应用中,这里会从node_modules加载loader
    // 为了演示,我们使用内置的loader映射
    const builtinLoaders = {
      'json-loader': this.jsonLoader,
      'css-loader': this.cssLoader,
      'style-loader': this.styleLoader
    };

    return builtinLoaders[loaderName] || ((source) => source);
  }

  /**
   * 修改后的文件分析方法
   */
  analyzeFile(filePath) {
    if (this.modules.has(filePath)) {
      return this.modules.get(filePath);
    }

    let sourceCode = fs.readFileSync(filePath, 'utf-8');
    
    // 关键步骤:在AST解析前应用loaders
    sourceCode = this.applyLoaders(filePath, sourceCode);
    
    // 现在sourceCode已经是有效的JavaScript代码,可以安全解析为AST
    const ast = parser.parse(sourceCode, {
      sourceType: 'module'
    });

    // ... 后续AST分析逻辑
  }
}

三、简单实现json-loader

3.1 JSON文件处理需求

// 原始JSON文件 data.json
{
  "name": "webpack-demo",
  "version": "1.0.0"
}

// 期望的转换结果(JavaScript模块)
export default {
  "name": "webpack-demo", 
  "version": "1.0.0"
};

3.2 json-loader实现

/**
 * JSON Loader实现
 * 将JSON文件内容转换为JavaScript默认导出
 */
function jsonLoader(source) {
  // 验证JSON格式
  try {
    JSON.parse(source);
  } catch (error) {
    throw new Error(`Invalid JSON file: ${error.message}`);
  }

  // 转换为JavaScript模块导出语法
  return `export default ${source};`;
}

3.3 使用示例

// webpack配置
{
  test: /.json$/,
  use: ['json-loader']
}

// 在JavaScript中使用
import config from './config.json';
console.log(config.name); // "webpack-demo"

四、手写实现简易style-loader与css-loader

4.1 CSS文件处理的挑战

CSS文件无法直接被JavaScript引擎执行,需要通过DOM操作将样式注入到页面中。

处理策略

  1. css-loader:读取CSS内容并返回字符串
  2. style-loader:将CSS字符串通过DOM操作插入到页面

4.2 css-loader实现

/**
 * CSS Loader实现
 * 将CSS文件内容转换为JavaScript字符串导出
 */
function cssLoader(source) {
  // 简单版本:直接返回CSS内容作为字符串
  const cssString = JSON.stringify(source);
  return `export default ${cssString};`;
}

4.3 style-loader实现

/**
 * Style Loader实现  
 * 将CSS字符串通过DOM操作注入到页面中
 */
function styleLoader(source) {
  // 从css-loader的输出中提取CSS内容
  // css-loader输出格式:export default "css content here";
  
  return `
    // 从css-loader获取CSS内容
    ${source}
    
    // 创建并插入style标签的函数
    function insertCSS(css) {
      if (typeof document === 'undefined') return;
      
      const style = document.createElement('style');
      style.type = 'text/css';
      
      if (style.styleSheet) {
        // IE8及以下版本
        style.styleSheet.cssText = css;
      } else {
        // 现代浏览器
        style.innerHTML = css;
      }
      
      document.head.appendChild(style);
    }
    
    // 立即执行:将CSS插入页面
    insertCSS(__webpack_require__.default || __webpack_require__);
  `;
}

4.4 更完善的style-loader实现

function styleLoader(source) {
  return `
    ${source}
    
    (function() {
      // 获取CSS内容(来自css-loader的输出)
      const css = typeof exports === 'object' && exports.default || exports;
      
      if (typeof css === 'string') {
        // 创建style标签
        const style = document.createElement('style');
        style.type = 'text/css';
        
        // 添加CSS内容
        if (style.styleSheet) {
          style.styleSheet.cssText = css;
        } else {
          style.appendChild(document.createTextNode(css));
        }
        
        // 插入到head中
        document.head.appendChild(style);
        
        // 支持热更新时的样式移除
        if (module.hot) {
          module.hot.dispose(function() {
            document.head.removeChild(style);
          });
        }
      }
    })();
    
    // 导出空对象(CSS不需要导出内容)
    export default {};
  `;
}

4.5 CSS处理流程梳理

完整处理流程

1. 遇到 import './styles.css'
2. 匹配到 test: /.css$/, use: ['style-loader', 'css-loader']
3. 执行顺序(右到左):
   
   原始CSS文件内容:
   ".button { color: red; background: blue; }"
   
   ↓ css-loader处理
   
   "export default ".button { color: red; background: blue; }";"
   
   ↓ style-loader处理  
   
   "// 插入CSS到DOM的JavaScript代码
    const css = ".button { color: red; background: blue; }";
    const style = document.createElement('style');
    style.innerHTML = css;
    document.head.appendChild(style);
    export default {};"
    
4. 生成的JavaScript代码被webpack打包
5. 运行时执行,CSS被注入到页面中

五、总结与扩展

5.1 Loader机制的核心价值

  1. 扩展性:让Webpack能够处理任意类型的文件
  2. 模块化:每个Loader职责单一,可组合使用
  3. 标准化:统一的接口规范,便于开发和维护

5.2 常见Loader类型

  • 转译类:babel-loader, typescript-loader
  • 样式类:css-loader, style-loader, sass-loader
  • 文件类:file-loader, url-loader
  • 代码检查:eslint-loader
  • 模板类:html-loader, vue-loader

5.3 开发Loader的最佳实践

  1. 单一职责:每个Loader只做一件事
  2. 链式调用:设计时考虑与其他Loader的配合
  3. 错误处理:提供清晰的错误信息
  4. 性能优化:缓存计算结果,避免重复处理
  5. 选项支持:通过loader-utils获取用户配置

5.4 实际应用场景

  • 组件化开发:CSS Modules解决样式隔离问题
  • 预处理器:Sass/Less编译为CSS
  • 代码转换:ES6+转换为ES5兼容代码
  • 资源优化:图片压缩、文件合并

Webpack热更新(HMR)原理与实现(自测:说出具体原理和实现流程)

一、HMR解决的具体问题

在没有HMR(Hot Module Replacement)时,修改代码后的开发体验如下:

全量刷新 (Live Reload) :修改代码 -> Webpack重新打包 -> 浏览器自动刷新页面 (window.location.reload())。
问题: 重新打包所有资源并在浏览器重新加载以及状态丢失

HMR的效果
修改代码 -> 浏览器不刷新 -> 仅替换修改的模块代码 -> 保持当前页面状态不变。

二、HMR核心流程拆解

HMR不是单一功能,而是Webpack编译器(服务端)与浏览器运行时(客户端)配合的结果。

涉及的四个核心角色

  1. Webpack Compiler:负责监听文件,编译代码。
  2. HMR Server (通常集成在webpack-dev-server中):建立WebSocket连接,负责将更新通知推送到浏览器。
  3. Bundle Server:提供文件访问服务(http://localhost:8080/bundle.js)。
  4. HMR Runtime:注入到打包后的bundle.js中的一段JS代码,负责在浏览器端接收WebSocket消息,并执行代码替换。

完整更新流程

  1. 监听:Webpack Compiler 监听到文件变化(如 style.cssmath.js)。
  2. 增量编译:Webpack 不会重新打包所有文件,而是生成两个补丁文件:
  • Manifest (JSON) :描述哪些模块变了,新的hash值是多少。
  • Update Chunk (JS) :包含被修改模块的具体代码。
  1. 推送消息:HMR Server 通过 WebSocket 向浏览器发送消息:{"type": "hash", "data": "新的hash值"}{"type": "ok"}
  2. 检查更新:浏览器端的 HMR Runtime 收到消息,对比上一次的 hash,发现有更新。
  3. 请求补丁:Runtime 发起 AJAX 请求获取 Manifest,再通过 JSONP 请求获取 Update Chunk。
  4. 代码替换:Runtime 执行新下载的代码,替换掉 __webpack_modules__ 中对应的旧函数。 image.png

三、手写简易HMR实现逻辑

这里不展示完整的Webpack源码,而是实现HMR最核心的通信模块替换逻辑。

服务端:监听编译与WebSocket通知

在开发服务器启动时,需要注入WebSocket服务。

// server.js (模拟 webpack-dev-server)
const WebSocket = require('ws');
const webpack = require('webpack');
const config = require('./webpack.config.js');

const compiler = webpack(config);
const app = require('express')();
const server = require('http').createServer(app);

// 1. 启动 WebSocket 服务器
const wss = new WebSocket.Server({ server });

// 2. 监听 Webpack 编译完成钩子
compiler.hooks.done.tap('HMRPlugin', (stats) => {
  // 获取新生成的 hash
  const hash = stats.hash;
  
  // 3. 向所有连接的客户端广播消息
  wss.clients.forEach(client => {
    client.send(JSON.stringify({
      type: 'hash',
      data: hash
    }));
    client.send(JSON.stringify({
      type: 'ok'
    }));
  });
});

// 启动编译监视
compiler.watch({}, (err) => {
  console.log('Webpack is watching files...');
});

server.listen(8080);

客户端:Runtime代码注入

Webpack打包时,会将以下代码注入到 bundle.js 的入口处。

// bundle.js 中的注入代码 (简化版)

// 1. 建立连接
const socket = new WebSocket('ws://localhost:8080');
let currentHash = 'old_hash_value';

// 2. 监听消息
socket.onmessage = function(event) {
  const msg = JSON.parse(event.data);
  
  if (msg.type === 'hash') {
    currentHash = msg.data;
  } else if (msg.type === 'ok') {
    // 收到更新完成信号,开始热更新逻辑
    hotCheck();
  }
};

function hotCheck() {
  console.log('检测到更新,准备拉取新代码...');
  // 实际 Webpack 会在这里:
  // 1. fetch('/hash.hot-update.json') -> 拿到变动的模块ID
  // 2. loadScript('/hash.hot-update.js') -> 拿到新模块代码
  // 3. hotApply() -> 执行替换
  
  // 模拟热更新操作
  hotDownloadManifest().then(hotDownloadUpdateChunk);
}

核心:如何在浏览器端替换代码

这是HMR最关键的一步。回顾之前的打包结构,所有模块都存在 __webpack_modules__ 对象中。热更新的本质就是修改这个对象的键值对

假设更新前的 bundle.js 运行时结构:

var __webpack_modules__ = {
  "./src/title.js": function(module, exports) {
    module.exports = "Old Title";
  }
};
// 缓存
var __webpack_module_cache__ = {
  "./src/title.js": { exports: "Old Title", loaded: true }
};

更新发生时 (hotApply 的简化逻辑):

// 这是一个由 JSONP 加载的新代码块
function webpackHotUpdateCallback(chunkId, moreModules) {
  // moreModules 包含了新的模块代码
  // 例如: { "./src/title.js": function() { module.exports = "New Title"; } }
  
  for (let moduleId in moreModules) {
    // 1. 覆盖旧的模块定义
    __webpack_modules__[moduleId] = moreModules[moduleId];
    
    // 2. 删除旧的缓存(关键)
    // 下次 require 这个模块时,会重新执行新函数
    delete __webpack_module_cache__[moduleId];
    
    // 3. 执行 accept 回调(如果有)
    if (hot._acceptedDependencies[moduleId]) {
      hot._acceptedDependencies[moduleId]();
    }
  }
}

总结操作:

  1. 覆盖:用新函数覆盖 __webpack_modules__ 中的旧函数。
  2. 清缓存:删除 __webpack_module_cache__ 中的缓存。
  3. 重执行:当父模块再次执行 __webpack_require__('./src/title.js') 时,会拿到最新的代码。

四、module.hot.accept 与 冒泡机制

仅仅替换模块定义是不够的,如果页面已经渲染了 "Old Title",仅仅替换函数的定义,页面文字不会自动变。需要代码主动响应这个变化。

开发者代码中的设置

在入口文件(如 index.js)中:

import title from './title.js';

document.body.innerText = title;

// 必须添加这段代码才能实现 HMR,否则会回退到整页刷新
if (module.hot) {
  // 注册回调:当 title.js 发生变化时执行
  module.hot.accept(['./title.js'], () => {
    // 重新获取新内容
    const newTitle = require('./title.js'); 
    // 执行具体的 DOM 更新逻辑
    document.body.innerText = newTitle; 
  });
}

冒泡机制 (Bubbling)

如果 title.js 变了,但 title.js 没有 module.hot.accept,Webpack 会怎么做?

  1. 检查自身title.jsaccept 吗?没有。
  2. 向上查找:谁引用了 title.js?是 index.js
  3. 检查父级index.js 有没有 accept('./title.js')
    • :执行 index.js 中定义的回调。更新结束。
    • 没有:继续向上查找 index.js 的父级。
  1. 顶层失败:如果一直冒泡到入口文件(Entry)都没有被 accept 捕获,HMR 宣告失败,触发 window.location.reload() 进行全量刷新。

4.3 为什么Vue/React开发时不需要手写accept?

因为 vue-loaderreact-refresh 自动在编译时注入了 module.hot.accept 代码。

例如 vue-loader 转换后的代码大致如下:

// vue-loader 自动注入的代码
import { render } from './App.vue?vue&type=template';
// ...
export default component.exports;

if (module.hot) {
  module.hot.accept(); // 接受自身更新
  module.hot.accept('./App.vue?vue&type=template', () => {
    // 当模板更新时,重新渲染组件,保留状态
    api.rerender('component-id', render); 
  });
}

image.png

五、总结 Webpack HMR 实现链

  1. 监听:Compiler 监听到文件修改。
  2. 生成:Compiler 生成 Manifest 和 Update Chunk。
  3. 通知:Server 通过 WebSocket 通知 Client "有新 Hash"。
  4. 下载:Client 通过 JSONP 下载新代码块。
  5. 替换:Client 运行时更新 __webpack_modules__ 并清除缓存。
  6. 响应:通过 module.hot.accept 定义的回调函数,执行具体的业务逻辑更新(如重绘 DOM)。

Webpack Plugin实现

一、Plugin的核心作用与Loader的区别

1.1 什么是Plugin

Plugin不处理具体的模块内容,而是监听Webpack构建过程中的生命周期事件(Hooks),在特定的时刻执行特定的逻辑,从而改变构建结果。

1.2 Plugin与Loader的直观对比

特性 Loader Plugin
作用对象 单个文件 (如 .css, .vue) 整个构建过程 (Compiler)
功能 转换代码 (less -> css) 打包优化、资源管理、环境变量注入
运行时机 解析模块依赖时 构建流程的任意时刻 (启动、编译、发射、结束)
配置方式 module.rules 数组 plugins 数组

1.3 常见的Plugin功能

  • 打包前:清除 dist 目录 (CleanWebpackPlugin)。
  • 编译中:定义全局变量 (DefinePlugin)。
  • 打包后:生成 index.html 并自动插入JS脚本 (HtmlWebpackPlugin)。
  • 结束时:压缩CSS/JS代码,上传资源到CDN。

二、Plugin的基本结构(自测:说出Plugin的固定格式)

2.1 基础代码结构

Webpack的Plugin是一个类(Class),它必须包含一个 apply 方法。

class MyPlugin {
  // 1. 接收配置参数
  constructor(options) {
    this.options = options;
  }

  // 2. 必须包含 apply 方法,接收 compiler 对象
  apply(compiler) {
    // 3. 注册钩子,监听事件 (例如 'done' 表示构建完成)
    compiler.hooks.done.tap('MyPlugin', (stats) => {
      console.log('构建完成!');
    });
  }
}

module.exports = MyPlugin;

2.2 使用方式

// webpack.config.js
const MyPlugin = require('./MyPlugin');

module.exports = {
  plugins: [
    new MyPlugin({ param: 'value' }) // 实例化插件
  ]
};

三、两个核心对象:Compiler与Compilation

在编写Plugin时,必须区分两个对象:

3.1 Compiler (编译器)

  • 定义:代表了完整的 Webpack 环境配置。
  • 生命周期:Webpack 启动时创建,直到进程结束。它是全局唯一的。
  • 作用:可以访问所有的配置信息(entry, output, loaders等),用于注册全局级别的钩子。

3.2 Compilation (编译过程)

  • 定义:代表了一次具体的构建过程
  • 生命周期:每次检测到文件变化(热更新)时,都会创建一个新的 compilation 对象。
  • 作用:包含了当前的模块资源、编译生成的文件(assets)、依赖关系图。如果要修改打包输出的内容,必须操作 compilation。

四、手写实现一个文件清单插件 (FileListPlugin)

4.1 需求描述

我们需要实现一个插件,在打包生成文件之前,自动生成一个 file-list.md 文件。 该文件记录所有打包输出的文件名和文件大小。

4.2 实现步骤

  1. 监听钩子:使用 emit 钩子。这个时刻编译已完成,文件即将输出到磁盘,但还未输出。这是修改输出资源的最后机会。
  2. 获取资源:从 compilation.assets 获取所有待输出的文件。
  3. 生成内容:遍历资源,拼接文件名和大小。
  4. 添加资源:将新生成的 file-list.md 添加到 compilation.assets 中。

4.3 代码实现

class FileListPlugin {
  constructor(options) {
    // 允许用户配置输出的文件名,默认为 'file-list.md'
    this.filename = options && options.filename ? options.filename : 'file-list.md';
  }

  apply(compiler) {
    // 1. 注册 emit 钩子(这是一个异步钩子,使用 tapAsync)
    compiler.hooks.emit.tapAsync('FileListPlugin', (compilation, callback) => {
      
      let fileList = '# Bundled Files

';

      // 2. 遍历 compilation.assets (包含所有即将输出的文件)
      for (let filename in compilation.assets) {
        // 获取文件来源对象
        const source = compilation.assets[filename];
        // 获取文件大小
        const size = source.size();
        
        fileList += `- ${filename}: ${size} bytes
`;
      }

      // 3. 将生成的内容添加到输出资源列表
      compilation.assets[this.filename] = {
        // 返回文件内容
        source: function() {
          return fileList;
        },
        // 返回文件大小
        size: function() {
          return fileList.length;
        }
      };

      // 4. 异步处理完成,必须调用 callback 告诉 Webpack 继续执行
      callback();
    });
  }
}

module.exports = FileListPlugin;

4.4 模拟运行效果

假设打包输出了 bundle.js (1000 bytes) 和 style.css (500 bytes),配置插件后,dist 目录下会多出一个 file-list.md

# Bundled Files

- bundle.js: 1000 bytes
- style.css: 500 bytes

五、常用生命周期钩子(Hooks)一览

Webpack 基于 Tapable 库实现了事件流。以下是开发 Plugin 最常用的几个钩子:

钩子名称 归属对象 时机 常用场景 同步/异步
entryOption compiler 初始化配置后 读取或修改 Entry 配置 Sync
compile compiler 开始编译前 提示“开始构建” Sync
compilation compiler 编译过程创建时 注册更细粒度的 compilation 钩子 Sync
emit compiler 生成资源到目录前 修改文件内容、添加新文件 (最常用) Async
done compiler 编译完成 提示构建结束、上传资源、分析耗时 Async

注册方式的区别:

  • 同步钩子tap('PluginName', (params) => { ... })
  • 异步钩子
    • tapAsync('PluginName', (params, callback) => { ... callback(); })
    • tapPromise('PluginName', (params) => { return Promise.resolve(); })

六、总结

Webpack Plugin 的实现核心链条:

  1. 类结构:定义一个类,包含 apply(compiler) 方法。
  2. 事件监听:通过 compiler.hooks 监听 Webpack 的生命周期事件。
  3. 资源操作
    • 如果只关注流程监控(如 build 进度),操作 compiler
    • 如果要修改产物(如添加文件、压缩代码),操作 compilation.assets
  4. 流程控制:如果是异步钩子,处理完逻辑后必须调用 callback 或返回 Promise,否则构建会卡死。

Webpack 模块联邦 (Module Federation) 实现

一、解决的具体问题

在模块联邦出现之前,跨项目共享代码主要有两种方式,各有明显的弊端:

  1. NPM 包模式

    • 流程:项目 B 修改组件 -> 打包发布到 NPM -> 项目 A 更新 package.json -> 项目 A 重新安装依赖 -> 项目 A 重新打包发布。
    • 缺点:更新流程长,无法实现热插拔,所有依赖在构建时必须确定。
  2. Iframe 或 Script 标签引入

    • 流程:项目 A 直接加载项目 B 的打包文件。
    • 缺点:完全隔离(Iframe)导致上下文不通;或者没有依赖共享机制(Script 标签),导致项目 A 和项目 B 各自加载了一份 React,页面体积倍增,且可能导致 React 实例冲突(Hooks 报错)。

模块联邦解决的问题: 在浏览器运行时,项目 A 可以直接引用 项目 B 构建好的代码,并且双方共享底层的依赖(如 React),避免重复加载。

二、基础配置与概念

模块联邦引入了三个核心概念:Host(消费者)Remote(提供者)Shared(共享依赖)

假设场景:

  • App 1 (Remote): 端口 3001,提供一个 Button 组件。
  • App 2 (Host): 端口 3002,想要使用 App 1 的 Button

2.1 提供方 (App 1) 配置

// webpack.config.js (App 1)
const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
  // ...其他配置
  plugins: [
    new ModuleFederationPlugin({
      name: 'app1',                  // 唯一标识,对应全局变量 window.app1
      filename: 'remoteEntry.js',    // 暴露出的入口文件名称
      exposes: {
        './Button': './src/Button',  // 映射:外部引入路径 -> 内部文件路径
      },
      shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
    }),
  ],
};

2.2 消费方 (App 2) 配置

// webpack.config.js (App 2)
const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'app2',
      remotes: {
        // 键名 'app1':在代码中 import 的前缀
        // 键值 'app1@...':远程应用的 name + 远程应用的地址
        app1: 'app1@http://localhost:3001/remoteEntry.js',
      },
      shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
    }),
  ],
};

2.3 消费方代码使用

// App 2 的业务代码
import React, { Suspense } from 'react';

// 像引入本地模块一样引入远程模块
// 'app1' 对应配置中的 remotes 键名
// 'Button' 对应 App 1 exposes 的键名
const RemoteButton = React.lazy(() => import('app1/Button'));

function App() {
  return (
    <Suspense fallback="Loading...">
      <RemoteButton />
    </Suspense>
  );
}

三、核心原理:remoteEntry.js 是什么?

当 App 1 构建时,Webpack 会生成一个特殊的入口文件 remoteEntry.js。这是模块联邦通信的桥梁。

这个文件包含三个主要部分:

  1. 模块映射表 (Module Map):记录了 ./Button 对应的是哪个 chunk 文件(例如 src_Button_js.js)。
  2. 获取函数 (Get):用于根据路径加载对应的模块。
  3. 初始化函数 (Init):用于接收 Host 传递过来的共享依赖(Shared Scope)。

浏览器运行时流程:

  1. App 2 加载 http://localhost:3001/remoteEntry.js
  2. remoteEntry.js 执行,在全局 window 上挂载一个变量 app1
  3. App 2 调用 window.app1.init(),将自己(App 2)的 React 版本放入共享作用域。
  4. App 2 调用 window.app1.get('./Button')
  5. App 1 检查共享作用域,发现已有 React,便不再加载自己的 React,而是直接下载 Button 的代码并返回。

四、手写简易模块联邦实现

为了理解 Webpack 内部是如何实现的,我们模拟一下 Host 和 Remote 在浏览器端的交互逻辑。

4.1 模拟 Remote (App 1) 的 remoteEntry.js

这是一个立即执行函数,目的是在全局注册接口。

// 模拟 app1/remoteEntry.js
var app1_modules = {
  './Button': () => {
    // 实际场景这里是通过 JSONP 加载真实文件
    console.log("加载 App1 的 Button 组件");
    return {
      default: "我是来自 App1 的按钮"
    };
  }
};

// 共享作用域容器
var sharedScope = {};

// 在 window 上挂载全局对象
window.app1 = {
  // 1. get: 供 Host 获取模块
  get: function(moduleName) {
    return new Promise((resolve) => {
      if (app1_modules[moduleName]) {
        // 返回模块的工厂函数
        resolve(() => app1_modules[moduleName]());
      } else {
        resolve(null);
      }
    });
  },

  // 2. init: 供 Host 初始化共享依赖
  init: function(scope) {
    // 将 Host 传来的 scope 合并到自己的 scope 中
    sharedScope = scope;
    console.log("App1 初始化完成,已接收共享依赖", scope);
    return Promise.resolve();
  }
};

4.2 模拟 Host (App 2) 的加载逻辑

Host 需要先加载远程脚本,然后按顺序调用 initget

// 模拟 Webpack 内部加载远程模块的逻辑

// 1. 定义加载脚本的辅助函数
function loadScript(url) {
  return new Promise((resolve, reject) => {
    const script = document.createElement('script');
    script.src = url;
    script.onload = resolve;
    script.onerror = reject;
    document.head.appendChild(script);
  });
}

// 2. 主流程
(async function() {
  // 步骤 A: 初始化 Host 自身的共享作用域
  const hostSharedScope = {
    react: { version: '17.0.2', loaded: true }
  };
  
  // 步骤 B: 加载 Remote 的入口文件
  await loadScript('http://localhost:3001/remoteEntry.js');
  
  // 此时 window.app1 已经存在
  const container = window.app1;
  
  // 步骤 C: 初始化容器 (交换共享依赖)
  // 告诉 app1:"我有这些依赖,你看看能不能用,别自己重复加载了"
  await container.init(hostSharedScope);
  
  // 步骤 D: 获取组件
  const factory = await container.get('./Button');
  const module = factory();
  
  console.log("最终获取到的模块:", module.default);
})();

五、依赖共享的具体逻辑 (Singleton)

shared 配置中,最关键的是版本控制。Webpack 运行时会进行如下判断:

  1. Host 端:我有 React 17.0.2。
  2. Remote 端:我需要 React ^16.8.0。
  3. 握手阶段 (init):Remote 检查 Host 提供的 React 17.0.2 是否满足 ^16.8.0
    • 满足:Remote 丢弃自己的 React 依赖,使用 Host 提供的全局 React 对象。
    • 不满足:Remote 坚持加载自己打包的 React 副本(除非配置了 singleton: truestrictVersion: true,此时会报错)。

实现简述: Webpack 维护了一个全局对象 __webpack_share_scopes__init 函数的本质就是把不同应用的依赖对象合并到这个全局对象中,通过语义化版本(SemVer)比较函数来决定使用哪一个版本的库。

六、总结模块联邦

  1. 去中心化:没有所谓的“主应用”,任何应用都可以同时是 Host 和 Remote。
  2. 运行时加载:不同于 NPM 的构建时集成,WMF 是在页面打开时动态下载代码。
  3. 双向接口
    • init(scope):输入接口,接收外部环境的共享依赖。
    • get(path):输出接口,向外部暴露内部模块。
  4. 本质:通过全局变量(window.app_name)建立通信协议,实现不同构建产物之间的互操作。

前端知识体系总结-前端工程化(Vite篇)

实现 Vite 核心功能(自测:Vite 核心功能和运行原理有哪些,由最简讲起,具体是怎么实现的)

Webpack 是先打包好文件再放到 dev server 运行

而 Vite 是先运行 dev server ,之后浏览器请求什么文件就在 dev server 中动态编译后再返回。核心是基于浏览器原生支持的 ES Modules (<script type="module">),当浏览器解析到 import 语句时,会向服务器发送 HTTP 请求,服务器拦截这些请求并实时编译文件与响应。

一、搭建服务返回index.html与编译js文件

1.1 搭建基础开发服务器

我们需要一个能拦截请求的 HTTP 服务器。这里使用 Koa (Vite 内部使用 connect,逻辑类似)。

目录结构:

mini-vite/
  ├── src/
  │   ├── main.js
  │   └── App.vue
  ├── index.html
  ├── server.js  (我们将编写的代码)
  └── package.json

index.html: 关键在于 type="module",这告诉浏览器直接以 ES 模块方式加载 js。

<!DOCTYPE html>
<html lang="en">
<body>
  <div id="app"></div>
  <!-- 浏览器会发起 GET /src/main.js 请求 -->
  <script type="module" src="/src/main.js"></script>
</body>
</html>

server.js (第一步:静态文件服务): 浏览器请求 / 返回 HTML,请求 /src/main.js 返回 JS 内容。

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

const app = new Koa();

app.use(async (ctx) => {
  const url = ctx.request.url;
  
  // 1. 根路径返回 index.html
  if (url === '/') {
    ctx.type = 'text/html';
    ctx.body = fs.readFileSync('./index.html', 'utf-8');
    return;
  }
  
  // 2. JS文件请求处理 (如 /src/main.js)
  if (url.endsWith('.js')) {
    const p = path.join(__dirname, url);
    ctx.type = 'application/javascript';
    ctx.body = fs.readFileSync(p, 'utf-8');
    return;
  }
});

app.listen(3000, () => {
  console.log('Vite dev server running at http://localhost:3000');
});

二、实现第三方库导入处理

2.1 问题描述

src/main.js 中,我们通常这样写:

import { createApp } from 'vue'; // ❌ 浏览器报错
import App from './App.vue';

浏览器遇到 import ... from 'vue' 时会报错,因为它不知道 'vue' 在哪里。浏览器只认识相对路径 (./, ../) 或绝对路径 (/)。

2.2 解决方案:路径重写

服务器需要在返回 JS 文件内容给浏览器之前,把内容里的 'vue' 替换成 '/@modules/vue',给它一个特殊标识。

修改 server.js:

// 工具函数:把文件流转成字符串
function readStream(stream) {
  return new Promise((resolve, reject) => {
    let data = '';
    stream.on('data', chunk => data += chunk);
    stream.on('end', () => resolve(data));
  });
}

// 路径重写逻辑
function rewriteImport(content) {
  // 正则匹配: from 'vue' -> from '/@modules/vue'
  // s0: 匹配到的完整字符串
  // s1: 捕获组,即包名 'vue'
  return content.replace(/ from ['"](.*)['"]/g, (s0, s1) => {
    // 如果是相对路径 ./ 或 ../ 或 / 开头,不处理
    if (s1.startsWith('.') || s1.startsWith('/')) {
      return s0;
    }
    // 否则加上 /@modules/ 前缀
    return ` from '/@modules/${s1}'`;
  });
}

app.use(async (ctx) => {
  const url = ctx.request.url;

  if (url.endsWith('.js')) {
    const p = path.join(__dirname, url);
    const content = fs.readFileSync(p, 'utf-8');
    ctx.type = 'application/javascript';
    // 返回修改后的内容
    ctx.body = rewriteImport(content); 
    return;
  }
});

经过这一步,浏览器收到的代码变成了:

import { createApp } from '/@modules/vue'; // ✅ 浏览器会发起新请求
import App from './App.vue';

2.3 获取真实文件路径

当浏览器请求 http://localhost:3000/@modules/vue 时,服务器需要去 node_modules 里找到 vue 的入口文件。

查找步骤:

  1. 找到 node_modules/vue 文件夹。
  2. 读取 package.jsonmodule 字段 (ESM 入口) 或 main 字段。
  3. 读取该入口文件的内容返回。

2.4 server.js 新增逻辑

app.use(async (ctx) => {
  const url = ctx.request.url;

  // 3. 处理第三方模块请求
  if (url.startsWith('/@modules/')) {
    // 提取模块名,例如 'vue'
    const moduleName = url.replace('/@modules/', '');
    
    // 在 node_modules 中找到该模块文件夹
    const prefix = path.join(__dirname, './node_modules', moduleName);
    
    // 读取 package.json
    const packageJSON = require(path.join(prefix, 'package.json'));
    
    // 获取入口文件路径 (优先使用 module 字段,因为是 ESM)
    const entryPath = path.join(prefix, packageJSON.module);
    
    // 读取文件内容
    const content = fs.readFileSync(entryPath, 'utf-8');
    
    ctx.type = 'application/javascript';
    // 第三方库内部可能也引用了其他库,也需要重写路径
    ctx.body = rewriteImport(content);
    return;
  }
  
  // ... 其他逻辑
});

三、处理 .vue 单文件组件 (SFC)

3.1 浏览器不认识 .vue

浏览器请求 App.vue 时,服务器不能直接返回 Vue 源码,需要把 .vue 编译成 JS。

Vite 使用 vue 官方提供的 @vue/compiler-sfc 进行编译。

3.2 server.js 新增 Vue 处理逻辑

const compilerSfc = require('@vue/compiler-sfc');

app.use(async (ctx) => {
  const url = ctx.request.url;
  
  // 4. 处理 .vue 文件
 if (ctx.url.endsWith(".vue")) {
ctx.type = "application/javascript; utf-8";
const content = fs.readFileSync(path.join(__dirname, ctx.url), "utf-8");
const { descriptor } = compilerSfc.parse(content);

// 使用 inlineTemplate 选项,让 compileScript 直接生成包含 render 的完整组件
const compiled = compilerSfc.compileScript(descriptor, {
id: ctx.url,
inlineTemplate: true, // 关键:内联编译模板,setup 直接返回 render 函数
});

ctx.body = rewriteImport(compiled.content);
return;
}
});

四、Vite 核心功能总结

实现一个简易 Vite 只需要解决三个问题:

  1. 服务器:用 Koa 拦截浏览器发起的文件获取 HTTP 请求并实时编译与返回。
  2. JS 处理:遇到 import 'vue' 这种裸模块导入,重写路径为 /@modules/vue,并去 node_modules 里找文件返回。
  3. Vue 处理:遇到 .vue 文件,使用 compiler-sfc 编译。先把 Script 发给浏览器,再让浏览器回头取 Template 的编译结果,最后拼在一起。

这种模式下,开发环境启动速度与项目大小无关,因为只有当你点击了某个页面,浏览器发起了请求,服务器才开始编译那个页面用到的文件。

Vite HMR实现原理(自测:更新一个文件后,wbp和vite分别会经过什么流程进行网页的热更新)

一、先回顾 Webpack 热更新原理

假如你的项目有1000个JS模块,你修改了其中一个文件 src/components/Header.vue

Webpack的处理方式

  1. Wepack Compiler 监听工作区:Webpack监听到文件保存动作。
  2. 重新构建被修改的模块:loader 链转换文件为 JS 可执行代码 -> AST 解析代码并识别 import、export 代码进行依赖图的增加或删除 -> 对新发现的依赖进行递归处理
  3. 打包:生成 Manifest JSON 文件,告诉浏览器这次更新涉及哪些模块;生成 Update Chunk JS 文件,包含被修改那个模块的新代码。
  4. 推送:HMR Server通过WebSocket推送更新通知给浏览器。
  5. 替换:浏览器的 HMR Runtime 请求清单文件,并根据清单文件请求被更新的模块代码,接着找到模块是否有自己的 module.hot.accept ,否则冒泡沿着依赖图向上查找,在 accept 回调中 import 并执行新的JS代码进行视图的更新。

三、Vite HMR 具体工作流程

1. 建立连接

客户端(浏览器)连接 Vite 开发服务器的 WebSocket。

2. 文件修改与通知

当你保存 Header.vue 时:

  1. Vite 文件监听器检测到变化。
  2. 解析该文件导出内容,确定它是Vue组件。
  3. 通过 WebSocket 向客户端发送一段JSON消息。

消息内容示例:

{
  "type": "update",
  "updates": [
    {
      "type": "js-update",
      "timestamp": 1678888888,
      "path": "/src/components/Header.vue",
      "acceptedPath": "/src/components/Header.vue"
    }
  ]
}

3. 浏览器重新请求

Vite 在浏览器端注入的客户端代码(vite/client)收到消息。它不会像 Webpack 那样去执行一段新推过来的 JS 代码块,而是利用浏览器动态导入功能

具体操作: 浏览器构造一个新的 import URL,带上时间戳以强制让浏览器认为这是一个新文件,从而避开缓存。

// 浏览器端逻辑模拟
import('/src/components/Header.vue?t=1678888888')
  .then((newModule) => {
    // 获取到新的模块内容,进行替换
  });

4. 模块替换

对于Vue组件,Vite使用了 vue-loader 类似的逻辑(vite-plugin-vue)。

  • 旧的 Header.vue 组件实例还保留在内存中。
  • 新的模块加载后,框架(Vue/React)利用 HMR API 重新渲染该组件,保留组件内的 data/state 状态,仅更新 render 函数或样式。

四、Vite HMR API:import.meta.hot

Webpack使用 module.hot,而 Vite 使用 ESM 标准的 import.meta.hot

开发者的代码(通常由插件自动注入):

// src/components/Header.vue 编译后的JS代码
// ... 组件代码 ...

export default _sfc_main;

// HMR 逻辑
if (import.meta.hot) {
  // 接受自身更新
  import.meta.hot.accept((newModule) => {
    if (newModule) {
      // 执行组件重渲染逻辑
      __VUE_HMR_RUNTIME__.reload('组件HashID', newModule.default);
    }
  });
}

实现逻辑:

  1. import.meta.hot.accept:告诉 Vite,如果这个文件变了,不需要刷新页面,我自己能处理。
  2. 回调函数:当新文件被 import(...) 加载成功后,执行这个回调,传入新模块内容。

五、所以为什么 Vite HMR 速度快

  1. 无需重构依赖图:文件保存后无需重新分析依赖图的更改,本质是因为 Vite 不需要构建依赖图去生成 bundle,而是通过浏览器 ESM 能力提供所需文件即可。
  2. 无需打包:Vite 只需编译一次文件,而 Webpack 需要将受影响的模块及其相关依赖(修改模块本身、父节点可能更新对子模块的Module ID引用代码、所属Chunk)重新打包与合并,涉及 n 个文件的修改。
  3. 全量代码下发:Webpack 下发包含新代码的 HMR 更新包,而 Vite 只发送一个指向修改该文件的 HTTP 请求,由浏览器重新请求。

Vite Plugin 实现原理与实战(自测:实现一个vite-plugin-svg-icons)

在前文中,我们了解了 Webpack 的打包流程:读取入口 -> 分析 AST -> 递归依赖 -> 转换代码 -> 生成 Bundle

Vite 的工作方式完全不同。在开发环境下,Vite 不打包。它利用浏览器对 ES Modules 的原生支持。当浏览器发起请求(如 GET /src/main.js)时,Vite 服务器拦截请求,进行必要的代码转换,然后直接返回 JS 内容。

Vite 插件 就是用来拦截处理这些请求的工具。

Vite 插件基于 Rollup 的插件接口设计,同时扩展了一些 Vite 独有的钩子(Hooks)。

一、Vite 插件的核心钩子 (Hooks)

Webpack 将功能分为 Loader(转换文件)和 Plugin(监听构建生命周期)。Vite 将这两者合并了。一个 Vite 插件本质上是一个返回配置对象的函数

处理一个文件请求时,主要经过以下三个核心钩子:

  1. resolveId(source, importer): 找文件
    • 输入: 代码中的导入路径(如 import x from './a' 中的 './a')。
    • 作用: 告诉 Vite 这个文件的绝对路径在哪里,或者标记这是一个“虚拟模块”。
    • 返回: 文件的绝对路径或 ID。
  2. load(id): 读文件
    • 输入: resolveId 返回的绝对路径或 ID。
    • 作用: 读取文件内容。通常用于加载磁盘文件或生成虚拟文件内容。
    • 返回: 文件内容的字符串。
  3. transform(code, id): 改代码(相当于 Webpack Loader)
    • 输入: load 返回的代码字符串,以及文件 ID。
    • 作用: 将非 JS 代码(如 Vue, CSS, TS)转换为浏览器能识别的 JS 代码。
    • 返回: 转换后的 JS 代码。

二、实战:实现一个虚拟模块插件

场景:你需要在一个项目中引入一个并不存在于磁盘上的文件,比如构建时的环境变量信息。

目标代码

// main.js
import env from 'virtual:env'; // 这个文件在磁盘上不存在
console.log(env); 

插件实现

export default function myVirtualPlugin() {
  const virtualModuleId = 'virtual:env';
  const resolvedVirtualModuleId = '\0' + virtualModuleId; // \0 是 Rollup 的约定,表示这是一个虚拟模块,不要去磁盘找

  return {
    name: 'my-virtual-plugin', // 插件名称,必填

    // 1. 拦截 import
    resolveId(source) {
      if (source === virtualModuleId) {
        // 如果 import 的是 'virtual:env',返回我们自定义的 ID
        return resolvedVirtualModuleId;
      }
      return null; // 其他文件不管,交给 Vite 处理
    },

    // 2. 加载内容
    load(id) {
      if (id === resolvedVirtualModuleId) {
        // 匹配到自定义 ID,直接返回一段 JS 代码
        return `export default { 
            user: "admin", 
            buildTime: "${new Date().toISOString()}" 
        }`;
      }
      return null; // 其他文件不管,读取磁盘
    }
  };
}

配置 vite.config.js:

import myVirtualPlugin from './plugins/myVirtualPlugin';

export default {
  plugins: [myVirtualPlugin()]
};

三、实战:实现一个 vite-plugin-svg-icons

首先明确插件功能:扫描指定目录下的 SVG 文件 -> 转换<symbol> 标签并合并 -> 提供虚拟模块 virtual:svg-register import 注入页面 -> 支持 HMR 热更新。

为了更好理解插件功能,我们看看在实际场景中它的作用:

你正在开发一个企业级后台管理系统,设计师提供了一套自定义 SVG 图标(如 nav-order.svg, action-edit.svg),要求图标颜色能随文字颜色变化(如菜单 Hover 时变蓝),且会有数十个图标散落在各个页面。

使用img标签,第一个是无法改变颜色需要重新提供另一版本svg,并且还需要根据hover事件动态切换src,非常麻烦;使用内联svg代码,代码很臃肿,可读性差;使用手动import,若一个页面需要的svg很多,会产生大量import语句

我们的插件目标:

  1. 零配置引用:只需将 SVG 文件丢入 src/icons 文件夹,无需任何 import 语句,直接通过文件名即可使用。
  2. CSS 样式控制:插件生成的 SVG Sprite 支持 currentColor,图标就像文字一样,可以用 CSS 随意控制颜色大小
  3. 高性能:所有图标被合并成一段 JS 注入 HTML,零 HTTP 请求,且按需加载。

使用效果演示:

// main.ts
import 'virtual:svg-register' // 一行代码,所有图标自动打包注入
<!-- 无需 import,直接使用 -->
<svg class="icon" aria-hidden="true">
  <use xlink:href="#icon-nav-order" />
</svg>

<style>
.icon {
  color: grey;       /* 默认灰色 */
  font-size: 20px;   /* 控制大小 */
}
.icon:hover {
  color: blue;       /* 悬停自动变蓝,无需 JS */
}
</style>

可以封装为一个组件:

<!-- src/components/SvgIcon.vue -->
<template>
  <svg class="svg-icon" aria-hidden="true">
    <use :xlink:href="symbolId" />
  </svg>
</template>

<script setup lang="ts">
import { computed } from 'vue'

const props = defineProps({
  name: { type: String, required: true }, // 传入图标文件名,如 'truck'
  prefix: { type: String, default: 'icon' }
})

const symbolId = computed(() => `#${props.prefix}-${props.name}`)
</script>

<style scoped>
.svg-icon {
  width: 1em; height: 1em; /* 默认跟随字体大小 */
  vertical-align: -0.15em;
  fill: currentColor; /* 关键:让图标颜色跟随文字颜色 */
  overflow: hidden;
}
</style>

现在我们来实现这个插件功能

首先理解“虚拟模块”

你可以在浏览器端 import 一个不存在于文件系统中的文件

目标:用户在代码里写 import 'virtual:svg-register',插件可以正确识别和拦截。

具体实现:使用 resolvedId 属性进行配置,当文件路径是我们的虚拟模块时,直接返回不需要解析,并且在 load 阶段返回我们自定义的代码交给程序执行

第二步:实战代码编写

新建一个 my-svg-plugin.js

我们可以安装一个依赖来方便找文件:npm install fast-glob

// my-svg-plugin.js
import path from 'path'
import fs from 'fs'
import fg from 'fast-glob'

export default function mySvgPlugin(options) {
  // 1. 配置虚拟模块 ID
  const VIRTUAL_MODULE_ID = 'virtual:svg-register'
  const RESOLVED_VIRTUAL_MODULE_ID = '\0' + VIRTUAL_MODULE_ID

  return {
    name: 'vite-plugin-my-svg-sprite', // 插件名称

    // 2. resolveId: 告诉 Vite 这个 import 归我管
    resolveId(id) {
      if (id === VIRTUAL_MODULE_ID) {
        return RESOLVED_VIRTUAL_MODULE_ID
      }
    },

    // 3. load: 返回这个虚拟模块的具体代码
    async load(id) {
      if (id === RESOLVED_VIRTUAL_MODULE_ID) {
        
        // --- 核心逻辑开始 ---
        
        // A. 找到所有 SVG 文件
        const { iconDir } = options
        const svgFiles = await fg('**/*.svg', { cwd: iconDir, absolute: true })

        // B. 遍历并读取内容,拼接成 Symbol 字符串
        let symbols = ''
        
        svgFiles.forEach((file) => {
         if (file.endsWith(".svg")) {
 const content = fs.readFileSync(path.join(iconDir, file), "utf-8");
 const viewBox = content.match(/viewBox="([^"]+)"/)?.[1] || "0 0 24 24";
 const pathContent = content.match(/<svg[^>]*>(.*)<\/svg>/s)?.[1] || "";
 const iconName = file.replace(".svg", "");

 symbols += `<symbol id="icon-${iconName}" viewBox="${viewBox}">${pathContent}</symbol>`;
}
});

        // C. 构造最终的 JS 代码
        // 返回在页面中注入 SVG sprite 的代码
return `
                const svgSprite = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
     svgSprite.style.position = 'absolute';
     svgSprite.style.width = '0';
     svgSprite.style.height = '0';
     svgSprite.innerHTML = \`${symbols}\`;
     document.body.insertBefore(svgSprite, document.body.firstChild);
`;
        // --- 核心逻辑结束 ---
      }
    }
  }
}
第三步:在项目中使用 (验证效果)
  1. 配置 vite.config.js:

    import { defineConfig } from 'vite'
    import vue from '@vitejs/plugin-vue'
    import path from 'path'
    import mySvgPlugin from './my-svg-plugin' // 引入你写的插件
    
    export default defineConfig({
      plugins: [
        vue(),
        mySvgPlugin({ 
            iconDir: path.resolve(__dirname, 'src/icons') // 假设你的图标都在这里
        })
      ]
    })
    
  2. 准备素材: 在 src/icons 下放几个 svg 文件,比如 vue.svgreact.svg

  3. 引入注册: 在 src/main.js (或 main.ts) 中引入虚拟模块:

    import { createApp } from 'vue'
    import App from './App.vue'
    
    // 这一行会触发你插件的 resolveId -> load,
    // 然后在浏览器执行那段插入 DOM 的 JS 代码
    import 'virtual:svg-register' 
    
    createApp(App).mount('#app')
    
  4. 组件使用: 在 Vue 组件里写:

    <template>
      <div>
        <!-- 使用图标 -->
        <svg style="width: 50px; height: 50px; fill: red;">
          <use xlink:href="#icon-vue"></use>
        </svg>
        <svg style="width: 50px; height: 50px; fill: blue;">
          <use xlink:href="#icon-react"></use>
        </svg>
      </div>
    </template>
    

四、Vite 独有的钩子:configureServer

Vite 插件不仅仅是构建工具,还是一个开发服务器。configureServer 钩子允许我们在 Vite 的 Node.js 服务器(基于 connect 库)中添加中间件。这在 Webpack Plugin 中很难直接做到。

场景:实现一个简易的 API Mock 功能。当请求 /api/user 时,拦截请求并返回假数据,不经过后端。

插件实现

export default function myMockPlugin() {
  return {
    name: 'my-mock-plugin',

    configureServer(server) {
      // server 是 Vite 开发服务器实例
      // server.middlewares 是一个 connect 实例,用法类似 Express
      
      server.middlewares.use((req, res, next) => {
        // 拦截 /api/user 请求
        if (req.url === '/api/user') {
          res.setHeader('Content-Type', 'application/json');
          res.end(JSON.stringify({ id: 1, name: 'Mock User' }));
          return; // 结束请求
        }
        
        // 其他请求放行
        next();
      });
    }
  };
}

五、Vite 的热更新 (HMR) 钩子:handleHotUpdate

在 Webpack 中实现 HMR 需要修改打包逻辑。在 Vite 中,插件可以直接介入 HMR 流程。

场景:当 .txt 文件修改时,不刷新页面,只通过自定义事件通知浏览器更新。

插件实现 (服务端)

export default function myHmrPlugin() {
  return {
    name: 'my-hmr-plugin',

    handleHotUpdate({ file, server, modules }) {
      if (file.endsWith('.txt')) {
        // 1. 读取更新后的文件内容
        const content = require('fs').readFileSync(file, 'utf-8');

        // 2. 向浏览器发送自定义 Websocket 消息
        server.ws.send({
          type: 'custom',
          event: 'txt-update',
          data: { file, content } // 发送新内容
        });

        // 3. 返回空数组,告诉 Vite:这个文件我处理了,你不需要执行默认的 HMR 逻辑(默认逻辑通常是重新加载模块)
        return [];
      }
    }
  };
}

客户端代码 (Client)

// 在 main.js 中接收消息
if (import.meta.hot) {
  import.meta.hot.on('txt-update', (data) => {
    console.log(`文件 ${data.file} 变了,新内容是: ${data.content}`);
    // 在这里手动更新 DOM
    document.querySelector('#app').innerText = data.content;
  });
}

六、总结:Webpack vs Vite 插件开发对比

功能点 Webpack 实现方式 Vite 实现方式
引入非 JS 文件 Loader (如 css-loader) Plugintransform 钩子
寻找模块路径 resolve.alias 配置或 Resolver 插件 PluginresolveId 钩子
读取文件内容 Loader 读取 Pluginload 钩子
开发服务器拦截 devServer.before 配置 PluginconfigureServer 钩子
热更新控制 注入 Runtime 代码,较复杂 PluginhandleHotUpdate + import.meta.hot

开发思维转变:

  • Webpack 插件像是在一条已经铺好的流水线(Compiler Hooks)上安装传感器和机械臂。
  • Vite 插件更像是拦截器。浏览器请求文件 -> 你的插件拦截 -> 告诉你 ID -> 你给它内容 -> 你转换内容 -> 返回给浏览器。
❌