普通视图

发现新文章,点击刷新页面。
昨天 — 2026年3月27日首页

不懂模块化就别谈前端工程化

作者 Moment
2026年3月27日 07:14

大家好 👋,我是 Moment,目前正在使用 Next.js、NestJS、LangChain 开发 DocFlow。这是一个面向 AI 场景的协同文档平台,集成了基于 Tiptap 的富文本编辑、NestJS 后端服务、实时协作与智能化工作流等核心模块。

在这个项目的持续打磨过程中,我积累了不少实战经验,不只是 Tiptap 的深度定制、编辑器性能优化和协同方案设计,也包括前端工程化建设、React 源码理解以及复杂项目架构实践。

如果你对 AI 全栈开发、文档编辑器、前端工程化或者 React 源码相关内容感兴趣,欢迎添加我的微信 yunmz777 一起交流。觉得项目还不错的话,也欢迎给 DocFlow 点个 star ⭐

image.png

前端工程化最基本的一步就是先学会模块化。简单来说,模块化就是把一大坨代码,拆成一个个小块,每个小块只做一件事,这样写起来和维护都方便多了。而且模块化还能让代码更容易被重复使用,像写好的请求封装、表单验证啥的,以后就不用再重新写一遍。多人合作的时候,模块化能让大家各做各的,互相不踩脚。更重要的是,像 Webpack 这种打包工具,都是基于模块化才能更好工作。常见的模块化写法有 CommonJS、ES Module 这些,学会了它们,工程化就有底子了。掌握模块化,等于给前端工程化打好地基!

什么是模块化

模块化的概念并不是一开始就有的。早期的网页都靠一个个大文件堆在一起,代码混乱又难维护。后来,项目越来越大,大家发现这样不行,得把功能拆分开。于是就有了“模块化”的想法:把代码分成小模块,每个模块只干一件事。这样一来,改东西的时候不容易出错,也能更好地复用代码。模块化也让多人一起开发的时候更有条理,减少冲突。现在常见的模块化方式有 CommonJS、ES Module 这些,都是让代码更清晰、管理更方便。掌握模块化,写项目会省心多了!

模块化的发展历程

石器时代

我们把这个过程称之为石器时代,因为这是最原始阶段,也是 JavaScript 刚被发明的时候(1995 年),它最早是被用来给网页加点动态效果,并没有考虑模块化。这就导致了一个很严重的问题:

  1. 全局变量污染

  2. 难以管理依赖

  3. 代码组织混乱

如下代码所示:

// a.js
const moment = 1;

// b.js
const moment = 2;

在 html 文件中我们有这样的代码来导入它们:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <script src="./a.js"></script>
    <script src="./b.js"></script>
  </body>
</html>

很多时候我们会直接在文件里定义变量,无论是自己写的代码、和其他开发成员合作时不同文件里的变量,还是引入的第三方库中的全局变量,都会在全局作用域中共享同一个空间,这种方式在 <script> 标签默认的全局执行环境下非常常见,也因此容易产生变量冲突或被覆盖,导致全局污染和命名冲突,正是因为这样的问题,后续才会有模块化方案来解决作用域隔离和依赖管理的痛点。

20250526193143

这样的问题就非常容易产生了。

IIFE

IIFE(Immediately Invoked Function Expression)的全称是立即执行函数表达式,意思是定义完毕立即执行的函数。它是 JavaScript 中的一种非常常见的语法结构,用来创建一个立即执行的函数作用域,避免污染全局变量。

它的基本语法如下所示:

(function () {
  // 这里是局部作用域
  var a = 1;
  console.log(a);
})(); // 立即执行

// 或者
(function () {
  var b = 2;
  console.log(b);
})();

这是借助了函数作用域,创建了一个私有空间(闭包)。在函数里定义的变量、函数,只在这个作用域可见,外部无法访问。

(function () {
  // 这里是局部作用域
  var a = 1;
  console.log(a);
})(); // 立即执行

// 或者
(function () {
  var b = 2;
  console.log(b);
})();

console.log(typeof a);

最终输出结果如下图所示:

20250526195433

通过这种方式,IIFE 可以避免全局污染,并且能把内部变量封装起来,外部无法访问;不过,它不如模块化方案直观易读,在模块化需求较多时,代码结构容易变得混乱。

CommonJs

为了解决 JavaScript 缺少模块化体系的问题,CommonJS 标准被提出了。它主要就是给 JavaScript 提供了一个模块化的规范,让我们可以像在其他语言里那样按需引入、按需导出,把大项目拆成小块再拼装起来。

Node.js 正是借助 CommonJS 的模块体系,才让模块化管理变得井井有条。比如:

// a.js
const moment = require("moment"); // 引入模块

module.exports = { sayHi: () => console.log("hi") }; // 导出模块

这样做,变量和功能都被封装在自己的模块里,不会再跑到全局作用域里去乱七八糟。

AMD

2011 年前后,浏览器端模块化火了,出现了 AMD(代表:RequireJS),它的出现最主要的一个原因就是浏览器端加载文件是异步的,不能再用 CommonJs 的同步方式了。

AMD"Asynchronous Module Definition" 的缩写,意思就是"异步模块定义"。它采用异步方式加载模块,模块的加载不影响它后面语句的运行。所有依赖这个模块的语句,都定义在一个回调函数中,等到加载完成之后,这个回调函数才会运行。

AMD 也采用 require()语句加载模块,但是不同于 CommonJS,它要求两个参数:

require([module], callback);

第一个参数 [module],是一个数组,里面的成员就是要加载的模块;第二个参数 callback,则是加载成功之后的回调函数。如果将前面的代码改写成 AMD 形式,就是下面这样:

require(["math"], function (math) {
  math.add(2, 3);
});

math.add()math 模块加载不是同步的,浏览器不会发生假死。所以很显然,AMD 比较适合浏览器环境。

接下来编写一个完整的 AMD 来实现这个完整的示例,如下代码:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <script
      data-main="main"
      src="https://cdnjs.cloudflare.com/ajax/libs/require.js/2.3.6/require.min.js"
    ></script>
  </head>
  <body>
    <h1>AMD 示例页面</h1>
  </body>
</html>

在这里的代码中使用的是 RequireJS CDN,它的关键点是 data-main="main",它告诉 RequireJS:页面加载完后去找 main.js 作为入口。

// math.js
define([], function () {
  // 这是一个模块
  return {
    add: function (a, b) {
      return a + b;
    },
    multiply: function (a, b) {
      return a * b;
    },
  };
});

这里用到了 define(),定义了一个模块,暴露 add 和 multiply 方法。

// main.js
require(["math"], function (math) {
  // 这里 math 就是 math.js 返回的模块对象
  var sum = math.add(3, 4);
  var product = math.multiply(3, 4);

  console.log("3 + 4 =", sum);
  console.log("3 * 4 =", product);

  // 也可以在页面显示
  var resultDiv = document.createElement("div");
  resultDiv.textContent = `3 + 4 = ${sum}, 3 * 4 = ${product}`;
  document.body.appendChild(resultDiv);
});

console.log(111222);

通过使用 require(['math'], callback),浏览器遇到后会异步加载 math.js,加载完毕后再执行回调,在回调里就能拿到 math 模块的内容,进行使用。

最终输出结果如下图所示:

20250528080714

UMD

CommonJS 和 AMD 在各自的领域(服务器端和浏览器端)都很好地解决了模块化问题,但它们之间存在兼容性问题。CommonJS 是同步加载模块的,适合服务器端,因为文件都在本地,加载速度快;而 AMD 是异步加载模块的,适合浏览器端,因为网络请求是异步的。这就导致了一个问题:如何编写一份代码,既能在 Node.js 环境下运行,又能在浏览器环境下运行,同时还能兼容 RequireJS 等 AMD 加载器?

为了解决这个问题,UMD(Universal Module Definition)应运而生。它是一种通用的模块定义规范,旨在创建一个能够兼容 CommonJS、AMD 和全局变量这三种模块化方案的代码模式。它的核心思想是,通过一套条件判断逻辑,检测当前运行环境支持哪种模块化方案,然后以对应的方式来定义和导出模块。这样,开发者就可以编写一份代码,无需修改就能在多种环境下使用。

那什么情况下是需要 UMD 呢?

  1. 跨环境兼容性: 如果你想编写一个 JavaScript 库,既希望它能在 Node.js 项目中使用(通过 CommonJS 模块),也希望它能在浏览器中直接作为 <script> 标签引入(暴露全局变量),同时还能被 RequireJS 等 AMD 加载器识别,那么 UMD 是一个非常理想的选择。

  2. 解决 CommonJS 和 AMD 的冲突: CommonJS 是同步加载的,而 AMD 是异步加载的。直接使用其中一种方案会导致在另一种环境中无法正常工作。UMD 通过判断环境来选择最合适的加载方式。

  3. 简化开发流程: 避免为不同的环境编写多份模块代码,提高代码复用性。

接下来我们将借助 Rollup 来帮我们来实现一个这种 UMD 格式的模块,首先安装所需要的模块:

pnpm add rollup @rollup/plugin-node-resolve @rollup/plugin-commonjs -D

接下来我们再 src 目录下分别创建一个 index.js 文件和 utils.js 文件,并编写如下代码:

// utils.js
export function add(a, b) {
  return a + b;
}

// index.js
import { add } from "./utils";

export function greet(name) {
  return `Hello, ${name}! The sum is ${add(2, 3)}.`;
}

export function farewell(name) {
  return `Goodbye, ${name}!`;
}

代码编写完成之后我们要在根目录下创建一个 Rollup 配置文件:

// rollup.config.js
import resolve from "@rollup/plugin-node-resolve";
import commonjs from "@rollup/plugin-commonjs";

export default {
  input: "src/index.js",
  output: {
    file: "dist/moment.umd.js",
    format: "umd",
    name: "Moment",
    globals: {
      // 如果你的库有外部依赖但不想打包进去,可以在这里配置
      // 'dayjs': 'dayjs' // 例如,如果依赖 dayjs,并且希望从全局变量获取
    },
  },
  plugins: [resolve(), commonjs()],
};

这个时候我们需要在 package.json 中添加一个大包脚本:

  "scripts": {
    "build": "rollup -c"
  },

这个时候我们就可以使用 pnpm build 来执行这些打包了,最终会输出一个 dist 目录:

20250528083501

最终输出的产物如下代码所示:

(function (global, factory) {
  typeof exports === "object" && typeof module !== "undefined"
    ? factory(exports)
    : typeof define === "function" && define.amd
    ? define(["exports"], factory)
    : ((global =
        typeof globalThis !== "undefined" ? globalThis : global || self),
      factory((global.Moment = {})));
})(this, function (exports) {
  "use strict";

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

  function greet(name) {
    return `Hello, ${name}! The sum is ${add(2, 3)}.`;
  }

  function farewell(name) {
    return `Goodbye, ${name}!`;
  }

  exports.farewell = farewell;
  exports.greet = greet;
});

上面这个代码片段就是是一个经典的 UMD(Universal Module Definition) 模式构建产物。

它能够检测当前运行环境,并以最合适的方式导出模块:

  1. CommonJS 环境 (如 Node.js):通过 module.exports 导出 farewell 和 greet 函数。

  2. AMD 环境 (如 RequireJS):通过 define(["exports"], factory) 异步定义并导出模块。

  3. 浏览器全局环境 (无模块加载器):将模块内容挂载到全局对象 global.Moment 上。

简而言之,这份代码让我们的 JavaScript 库能够无缝地在 Node.js、支持 AMD 的浏览器以及普通浏览器环境中使用,极大地提高了兼容性。

当我们在 HTML 文件中直接通过 <script src="./dist/moment.umd.js"></script> 引入这份 UMD 文件时,它会检测到当前是浏览器环境,并将模块内容挂载到全局对象 global.Moment 上。你就可以像使用任何全局变量一样使用它:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <h1>umd 示例页面</h1>
    <script src="./dist/moment.umd.js"></script>
    <script>
      console.log(Moment);
    </script>
  </body>
</html>

最终输出结果如下图所示:

20250528084015

尽管 ES Module 已经成为现代 JavaScript 模块化的主流,并在现代浏览器和 Node.js 中得到了原生支持,但 UMD 在向后兼容和跨环境发布库的场景中仍然占有一席之地。理解 UMD 有助于我们更好地理解 JavaScript 模块化的发展历程以及不同模块化方案之间的兼容性问题。

ESM

ES Module,也称为 ECMAScript 模块,是 JavaScript 语言本身在 ES2015 (ES6) 标准中正式引入的官方模块化方案。它旨在成为 JavaScript 模块化的标准,在浏览器和 Node.js 环境中都能原生支持。

与 CommonJS 和 AMD 这种由社区提出的规范不同,ESM 是语言层面的原生支持,这使得它在语法、语义和性能上都具有独特的优势。

深入理解 CommonJS

在 CommonJS 中,每一个被 require 的文件,在 Node.js 内部都会被封装成一个 Module 类的实例。这个 Module 实例携带了该模块的唯一标识(ID)、文件路径、父模块信息、子模块依赖、是否已加载等元数据。

最重要的,它提供了一个 exports 对象,你的模块代码就是通过操作这个对象来决定要向外部暴露什么内容的。当你 require 这个模块时,你得到的就是这个 Module 实例的 exports 属性。

// 此类继承的是 WeakMap
const moduleParentCache = new SafeWeakMap();

function Module(id = "", parent) {
  this.id = id; // 模块的识别符,通常是带有绝对路径的模块文件名
  this.path = path.dirname(id); // 文件当前的路径

  /
   * 相当于给构造函数 Module 上添加了一个 exports 为空对象
   * 等同于这样的写法 Module.exports = {};
   */
  setOwnProperty(this, "exports", {});

  // 返回一个弱引用对象,表示调用该模块的模块
  moduleParentCache.set(this, parent);
  updateChildren(parent, this, false);

  this.filename = null; // 模块的文件名,带有绝对路径
  this.loaded = false; // 是否已经被加载过,用作缓存
  this.children = []; // 返回一个数组,表示该模块要用到的其他模块
}

我们编写如下代码:

const foo = 1;

module.exports = { foo };

console.log(module);

当我们通过直接打印 module,终端上会有如下输出:

20250528093757

你看到的这个 module 对象,是 Node.js 在运行你的 index.js 文件时,专门为这个文件创建的一个“档案袋”或者说“容器”。这个档案袋里装着关于你这个文件(模块)的所有重要信息:

  • id: '.': 这就好像你的文件在这个程序里的“身份证号码”。当你是直接运行 node index.js 时,这个 index.js 就是主入口,它的 id 会被标记为 .,表示它是整个程序的“根”。

  • path: '/Users/macmini/Desktop/前端工程化': 这就是你的文件所在的文件夹路径。Node.js 在寻找你 require 的其他模块时,会用到这个路径来确定从哪里开始查找。

  • exports: { foo: 1 }: 这是最重要的!它是一个空盒子。你在这个 index.js 文件里写的所有 module.exports = ... 或者 exports.xxx = ... 的代码,都是在往这个盒子里装东西。当其他文件 require 你的 index.js 时,它们拿到的就是这个 exports 盒子里的内容。

  • filename: '/Users/macmini/Desktop/前端工程化/index.js': 这是你的文件的完整名字和路径,就像你的文件在这个电脑里的完整地址一样。

  • loaded: false: 这个告诉我们你的文件是否已经执行完毕。因为 console.log(module) 这行代码是在文件执行过程中打印的,所以此时模块还没有“加载完成”,还在运行,因此显示 false。等整个文件代码都运行完了,它才会变成 true

  • children: []: 如果你的 index.js 里有 require('其他文件') 的话,那些“其他文件”的 module 对象就会出现在这个数组里,表明你的文件依赖了哪些模块。现在它是空的,说明你的 index.js 没有直接 require 其他文件。

  • paths: [...]: 这是 Node.js 在你 require('第三方库名') (比如 require('lodash')) 时,会去依次查找这些目录来找到 node_modules 文件夹。它从你文件所在的目录开始,逐级向上查找。

  • Symbol(...) 开头的属性: 这些是 Node.js 内部使用的一些特殊标记。例如,kIsMainModule: true 再次强调你的文件是程序的主入口;kIsExecuting: true 则表示你的文件代码正在运行中。这些通常对开发者来说是内部实现细节,但也能帮助我们理解模块的生命周期。

简而言之,这个 module 对象就是 Node.js 对你的文件在模块系统中的“档案”,包含了它的身份信息、当前状态、以及如何与外部世界交互(通过 exports)的关键数据。

之所有会有这样的输出,主要是在 NodeJs 源码 中有这样的实现:

function Module(id = "", parent) {}

/** @type {Record<string, Module>} */

Module._cache = { __proto__: null };

/** @type {Record<string, string>} */

Module._pathCache = { __proto__: null };

/** @type {Record<string, (module: Module, filename: string) => void>} */

Module._extensions = { __proto__: null };

/** @type {string[]} */

let modulePaths = [];

/** @type {string[]} */

Module.globalPaths = [];

let patched = false;

let wrap = function (script) {
  return Module.wrapper[0] + script + Module.wrapper[1];
};

const wrapper = [
  "(function (exports, require, module, __filename, __dirname) { ",
  "\n});",
];

let wrapperProxy = new Proxy(wrapper, {
  __proto__: null,

  set(target, property, value, receiver) {
    patched = true;

    return ReflectSet(target, property, value, receiver);
  },

  defineProperty(target, property, descriptor) {
    patched = true;

    return ObjectDefineProperty(target, property, descriptor);
  },
});

在上面的代码中, Module._cache 是一个缓存区,存储所有已经加载并执行过的模块实例。当你 require 一个模块时,Node.js 会先检查这个缓存,如果模块已经存在,就直接返回缓存中的实例,避免重复加载和执行,确保模块是单例的。 它存储在 Node.js 进程的全局 JavaScript 堆内存中,作为 Module 这个构造函数(或类)的一个静态属性(Module._cache),这意味着它不属于任何特定的模块实例,而是所有模块共享的一个全局数据结构。

wrap 函数和 wrapper 数组是 CommonJS 模块机制的核心,wrapper 数组包含了两个字符串 (function (exports, require, module, filename, dirname) { 是函数体的开始部分,'\n});' 是函数体的结束部分。

这个封装后的函数就是每个 CommonJS 模块被执行时所处的环境。它为你的模块提供了私有的作用域,并且注入了 exportsrequiremodule__filename__dirname 这些局部变量,这样你在模块里才能直接使用它们,而不会污染全局作用域。

module.exports 和 exports 的关系

我们继续来到这里的代码,这相当于给构造函数 Module 上添加了一个 exports 为空对象,等同于这样的写法 Module.exports = {},我们再来到这个文件代码的后面。

20250528203423

_compile 原型方法上定义了一个 exports 用来保存 Module.exports ,所以这也就是为什么 module.exports === exports 的原因了,实际上是它们共享同一块内存空间。

20250528203750

虽然他们共享的是同一块内存空间,但是最终被导出的是 module.exports 而不是 exports。值得注意的是 CommonJs 导出的是对象的引用,通过 require 之后 可以对其进行修改。

如下代码所示:

// utils.js

const object = {
  moment: "Moment",
};

setTimeout(() => {
  object.moment = "靓仔";
}, 2000);

module.exports = {
  object,
};

// main.js
const bar = require("./utils");

console.log("main.js", bar.object.moment); // main.js Moment

setTimeout(() => {
  console.log("2秒之后输出 ", bar.object.moment); // 2秒之后输出  靓仔
}, 2000);

最终的输出结果如下图所示:

20250528204706

验证了我们前面的说法。

CommonJs 读取的模块的缓存

在 Node.js 中,CommonJS 模块首次被 require() 后,其 module.exports 对象就会被缓存到内存中。这意味着,之后无论程序中何处再次 require() 同一个模块,Node.js 都不会重新加载和执行该模块的代码,而是直接返回缓存中的同一个实例。这种机制确保了模块只加载一次,并作为单例存在于整个应用生命周期中,从而优化了性能并避免了状态混乱。

如下代码所示:

// share.js
console.log("---- share.js 模块正在被加载和执行 ----");

let internalCounter = 0;

function increment() {
  internalCounter++;
}

function getCounter() {
  return internalCounter;
}

// 导出一些内容,包括一个时间戳,用于验证是否是同一个实例
module.exports = {
  increment,
  getCounter,
  loadTimestamp: new Date().toISOString(), // 记录模块被加载的时间
};

console.log("---- share.js 模块执行完毕 ----");

创建第一个使用共享模块的模块 (moduleA.js):

// moduleA.js

console.log("*** moduleA.js 开始执行 ***");

const shared = require("./share"); // 第一次 require share
shared.increment(); // 调用共享模块的方法
shared.increment(); // 再次调用,计数器应该增加到 2

console.log("moduleA.js 访问 share 计数器:", shared.getCounter());
console.log("moduleA.js 访问 share 加载时间:", shared.loadTimestamp);

console.log("*** moduleA.js 执行结束 ***");

// 导出 shared 模块的引用,方便 main.js 进一步验证
module.exports = { sharedModuleRef: shared };

创建第二个使用共享模块的模块 (moduleB.js):

// moduleB.js

console.log("*** moduleB.js 开始执行 ***");

const shared = require("./share"); // 第二次 require share (预期从缓存读取)
shared.increment(); // 再次调用共享模块的方法,计数器应该增加到 3

console.log("moduleB.js 访问 share 计数器:", shared.getCounter());
console.log("moduleB.js 访问 share 加载时间:", shared.loadTimestamp);

console.log("*** moduleB.js 执行结束 ***");

// 导出 shared 模块的引用
module.exports = { sharedModuleRef: shared };

接下来我们创建一个主入口文件 index.js:

// index.js

console.log("--- index.js 开始执行 ---");

const moduleAExports = require("./moduleA");
const moduleBExports = require("./moduleB");

console.log("\n--- 验证共享模块的实例 ---");

// 验证 moduleA 和 moduleB 得到的 share 引用是否相同
console.log(
  "moduleA.js 和 moduleB.js 获得的 share 是同一个引用:",
  moduleAExports.sharedModuleRef === moduleBExports.sharedModuleRef
);

// 验证最终的计数器值
console.log(
  "最终的共享模块计数器值:",
  moduleAExports.sharedModuleRef.getCounter()
); // 或者 moduleBExports.sharedModuleRef.getCounter()

console.log("--- index.js 执行结束 ---");

20250528210528

在上面的输出结果中 share.js 被多次 require() 但最终只执行了一次,说明的代码 share.js 只在 moduleA.js 第一次 require 它时被执行了,之后无论是 moduleB.js 再次 require 它,还是你后续再进行任何 require 操作,Node.js 都直接从缓存中拿取其导出的结果,不再重复执行模块文件。

还有一个最直接、最明确的证据。=== 运算符用于比较两个变量是否指向内存中的同一个对象。输出为 true 毫不含糊地表明 moduleArequire 到的 share 引用和 moduleBrequire 到的 share 引用,它们指向的是内存中的同一个 JavaScript 对象。

require 查找细节

require(X) 中的 X 指向一个核心模块时,Node.js 会直接返回对应的内置模块,并立即停止后续查找。这些核心模块,如 httpfsurlpathEvents,是用 C/C++ 编写的,因此在性能上表现优异。它们在 Node.js 编译时就被集成到二进制文件中,并在 Node 进程启动时直接加载到内存,无需额外的定位或编译过程,从而实现了极致的加载效率。

20250528211143

X 是一个路径(以 ./..// 开头)时,Node.js 会尝试解析它:

  • 如果 X 指向一个文件夹,Node.js 会依次查找该文件夹下的 index.jsindex.json,最后是 index.node 文件。

  • 如果 X 指向一个文件但没有后缀名,Node.js 则会尝试追加 .js.json.node 后缀来查找对应文件。

而当 X 既不是路径也不是核心模块(即一个裸模块名,如 lodash)时,Node.js 会从当前目录的 node_modules 文件夹开始,逐级向上查找父目录中的 node_modules,直到文件系统根目录。如果遍历所有这些路径后仍未找到该模块,系统将报错提示。

如下代码所示:

console.log(module.paths);

20250528211350

它会一层一层网上查找,如果没有查到,会报没有找到的错误:

20250528211516

有了路径之后,下面就是 Module.findPath() 的源码,用来确定哪个是正确的路径,其中以下代码有省略的:

Module._findPath = function (request, paths, isMain) {
  // 如果是绝对路径,则不在搜索,返回空
  const absoluteRequest = path.isAbsolute(request);
  if (absoluteRequest) {
    paths = [""];
  } else if (!paths || paths.length === 0) {
    return false;
  }

  // 第一步:如果当前路径已在缓存中,就直接返回缓存
  const cacheKey = request + "\x00" + ArrayPrototypeJoin(paths, "\x00");
  const entry = Module._pathCache[cacheKey];
  if (entry) return entry;

  let exts;
  // 是否有后缀的目录斜杠
  const trailingSlash = "..."; //省略了很多代码
  // 是否相对路径
  const isRelative = "..."; // 省略了很多代码
  let insidePath = true;
  if (isRelative) {
    const normalizedRequest = path.normalize(request);
    if (StringPrototypeStartsWith(normalizedRequest, "..")) {
      insidePath = false;
    }
  }

  // 遍历所有路径
  for (let i = 0; i < paths.length; i++) {
    const curPath = paths[i];
    if (insidePath && curPath && _stat(curPath) < 1) continue;

    if (!absoluteRequest) {
      const exportsResolved = resolveExports(curPath, request);
      if (exportsResolved) return exportsResolved;
    }

    const basePath = path.resolve(curPath, request);
    let filename;

    const rc = _stat(basePath);
    if (!trailingSlash) {
      if (rc === 0) {
        // File.
        if (!isMain) {
          if (preserveSymlinks) {
            filename = path.resolve(basePath);
          } else {
            filename = toRealPath(basePath);
          }
        } else if (preserveSymlinksMain) {
          filename = path.resolve(basePath);
        } else {
          filename = toRealPath(basePath);
        }
      }

      if (!filename) {
        if (exts === undefined) exts = ObjectKeys(Module._extensions);
        // 该模块文件加上后缀名,是否存在
        filename = tryExtensions(basePath, exts, isMain);
      }
    }

    if (!filename && rc === 1) {
      if (exts === undefined) exts = ObjectKeys(Module._extensions);
      // 目录中是否存在 package.json
      filename = tryPackage(basePath, exts, isMain, request);
    }

    if (filename) {
      // 将找到的文件路径存入返回缓存,然后返回
      Module._pathCache[cacheKey] = filename;
      return filename;
    }
  }
  // 如果没有找打返回 false
  return false;
};

我们已经了解了核心模块因 C/C++ 实现而拥有极高的加载速度。然而,为了让这些底层用 C/C++ 编写的内建模块能够无缝地融入 JavaScript 的 CommonJS 模块体系并被 require 函数调用,其内部引入流程却相当复杂。它需要经历多个层面的封装和定义,包括 C/C++ 层的内建模块定义、JavaScript 核心模块的适配和封装,最终才能在 (JavaScript) 文件模块层面被正常引入和使用,以此确保了兼容性和性能的最佳平衡。

20250528212004

整个流程是:用户在 JavaScript 中 require 一个核心模块 -> Node.js 的 JavaScript 层 NativeModule 识别并处理 -> NativeModule 调用 process.binding 进入 C++ 层 -> C++ 层查找并加载对应的预编译模块 -> C++ 模块将其功能以 JavaScript 对象的形式导出,最终返回给用户。这个复杂的分层设计,既保证了核心模块的极致性能,又使其能够无缝融入 Node.js 的 CommonJS 模块加载体系。

一旦 Node.js 确定了模块的准确路径,就可以着手加载它了。你可能会好奇:require 函数究竟从何而来,为何在每个模块中都能“凭空”使用?它背后又执行了哪些操作?

实际上,require 并非一个全局变量。它是 Node.js 在执行每个 CommonJS 模块之前,通过模块封装函数(就是我们之前提到的那个 (function (exports, require, module, __filename, __dirname) { ... });)作为局部参数,注入到该模块的作用域中的。

而这个注入的 require 函数,其核心功能正是来源于 Module 构造函数原型上的 require 方法,它负责执行模块的查找、加载、缓存以及最终返回导出内容的完整流程。

Module.prototype.require = function (id) {
  // 进行简单的 id 变量的判断,需要传入的 id 是一个 string 类型。
  validateString(id, "id");
  if (id === "") {
    throw new ERR_INVALID_ARG_VALUE("id", id, "must be a non-empty string");
  }
  // 默认为0,表示还没有使用过这个模块,每使用一次便自增一次

  requireDepth++;
  try {
    // 用于检查是否有缓存,有则从缓存里查找
    return Module._load(id, this, /* isMain */ false);
  } finally {
    // 每次结束后递减一个,用于判断递归的层次
    requireDepth--;
  }
};

看完了 require 的了,我们再看看构造函数的静态方法 _load:

Module._load = function (request, parent, isMain) {
  let relResolveCacheIdentifier;
  if (parent) {
    debug('Module._load REQUEST %s parent: %s', request, parent.id);
    relResolveCacheIdentifier = `${parent.path}\x00${request}`;
    // 以文件的绝对地址当成缓存 key
    const filename = relativeResolveCache[relResolveCacheIdentifier];
    reportModuleToWatchMode(filename);
    if (filename !== undefined) {
      // 先通过 key 从缓存中获取模块
      const cachedModule = Module._cache[filename];
      if (cachedModule !== undefined) {
        updateChildren(parent, cachedModule, true);
        if (!cachedModule.loaded)
          // 如果要加载的模块缓存已经存在,但是并没有完全加载好,这是解决循环引用的关键
          return getExportsForCircularRequire(cachedModule);

        // 已经加载好的模块,直接从缓存中读取返回
        return cachedModule.exports;
      }
      // 判断缓存是否存在父模块中,存在则删除
      delete relativeResolveCache[relResolveCacheIdentifier];
    }
  }

  // 判断是否为 node: 前缀的,也就是判断是否为原生模块
  if (StringPrototypeStartsWith(request, 'node:')) {
    // Slice 'node:' prefix
    const id = StringPrototypeSlice(request, 5);

    const module = loadBuiltinModule(id, request);
    if (!module?.canBeRequiredByUsers) {
      throw new ERR_UNKNOWN_BUILTIN_MODULE(request);
    }

    return module.exports;
  }

这个函数的核心逻辑是:它会首先检查请求的模块是否已经存在于内部缓存中——如果已缓存,则直接返回其 exports 对象。如果模块带有 node: 前缀(表明是显式引入的内置模块),则会调用专门的 loadBuiltinModule() 方法处理并返回结果。除此之外,对于所有其他尚未加载过的模块,它会创建一个新的模块实例,执行其代码,并将最终导出的结果保存到缓存中,以供后续快速访问。

CommonJS 通过在检测到循环引用时,立即从缓存中返回模块当前已有的 exports 对象来解决。这意味着,如果一个模块(A)在被 require 时发现它自己又 require 了另一个模块(B)而 B 又 require 了 A,它会立刻提供 A 当前已经导出的部分内容。尽管这个 exports 对象可能是不完整的(缺少尚未执行的代码所导出的属性),但这种机制避免了死锁,并允许模块执行继续进行。

小结

require 的流程图正如下图所示:

20250528213120

Node.js 的 require 模块加载流程包含五个主要阶段。首先是解析(Resolution),确定模块的精确路径;接着是加载(Loading),读取文件内容。然后是包装(Wrapping),将代码放入 CommonJS 函数封装中;随后进行执行(Evaluation),运行模块代码并生成导出内容。最后,模块的导出结果会被缓存(Caching)起来,以确保后续对同一模块的 require 调用能高效地直接获取缓存实例。

CommonJS 模块的加载是同步的,意味着它会阻塞后续代码执行,这在服务器端因文件本地加载速度快而高效,但在浏览器中可能引发阻塞问题。它通过 module.exports 以对象形式导出内容,并且对每个加载的模块都存在缓存,确保无论何时何地 require 同一个模块,都只会得到并操作同一个模块实例。这种缓存机制不仅提升了性能,也有效地处理了模块间的循环引用,避免了死锁。

深入理解 ES Modules

默认情况下,普通的 JavaScript 脚本(包括那些用于旧浏览器兼容的 nomodule 脚本)会阻塞 HTML 解析和页面渲染。为了避免这种阻塞行为,你可以为这些脚本添加 defer 属性。带有 defer 属性的脚本会在 HTML 文档完全解析完毕后才开始执行,并且会按照它们在文档中出现的顺序执行,有效避免了阻塞页面内容呈现。

20250528214610

deferasync 是脚本标签的互斥可选属性,用于控制脚本的加载与执行时机。

对于常规脚本(包括 <script nomodule> 脚本),defer 属性确保脚本在 HTML 解析完成后才按顺序执行,避免阻塞页面渲染;而 async 属性则允许脚本与 HTML 并行解析和下载,并在可用时立即执行,不保证其执行顺序。

至于模块脚本 (<script type="module">),它们的默认行为就类似于 defer,即异步获取并在 HTML 解析后执行。但如果为模块脚本明确指定 async 属性,它及其所有依赖项都将与 HTML 解析并行获取,并一旦可用便立即执行,此时模块的执行顺序不再得到保证。

当我们用 ES Module(import / export)来写前端代码时,JavaScript 引擎在背后会做很多“幕后工作”来帮我们管理这些模块。比如:模块要有自己的作用域(不能全都放到全局变量去乱七八糟),还要能让模块之间互相导入导出,保证变量不会乱改。

这些幕后工作就靠了模块记录(Module Record)和模块环境记录(Module Environment Record)这样的底层概念,它们属于 JavaScript 引擎内部的数据结构,帮我们管理和组织模块。

Module Record

模块记录(Module Record)用来封装一个模块的导入和导出等结构化信息。这些信息在模块链接时非常关键,用来把一个个模块的输入输出都串联起来。一个模块记录里通常包含四个字段:

  1. Realm:用来创建当前模块的作用域。

  2. Environment:模块顶层的绑定环境记录,在模块被链接时设置。

  3. Namespace:模块的命名空间对象,能让外部通过运行时属性访问模块的导出。这个对象本身是“外来对象”,并且没有构造函数。

  4. HostDefined:这个字段是留给宿主环境(host environments)用的,方便在模块中附加额外信息。

Module Environment Record

模块环境记录是 ECMAScript 中的一种特殊的声明性环境记录,用来表示模块的外部作用域。 和普通的作用域环境记录不太一样,它在支持普通变量绑定的同时,还特别提供了不可变的 import 绑定。这些 import 绑定让模块内部能间接访问另一个模块里的变量,但又保证了这些变量不能被修改。

换句话说,不可变绑定就是指模块引入别的模块时,虽然能使用这些导入的变量,但不能在当前模块中直接更改它们,这也是模块化语法的一大特色。

Es Module 的解析流程

在开始之前,我们先大概了解一下整个流程大概是怎么样的,先有一个大概的了解:

  1. 构建(Construction):浏览器根据模块的地址找到对应的 JS 文件,通过网络下载,并把代码解析成一个内部的模块记录(Module Record),为后续步骤做准备。

  2. 实例化(Instantiation):对模块进行实例化,分配内存空间,分析并处理模块里的 import 和 export 语句,让这些变量在内存中有了位置和映射关系。

  3. 执行(Evaluation):真正运行模块里的代码,计算值,并把值写入内存,模块就正式被执行起来了。

Construction 构建阶段

在这个阶段,loader(加载器)负责模块的寻址和下载。它首先从入口文件开始加载,通常在 HTML 中使用 <script type="module"></script> 标签来声明这是一个模块文件。加载器会根据这个入口,去查找并下载模块代码,准备后续的实例化和执行。

20250528215643

模块继续通过 import 语句来声明需要的依赖。在 import 声明中,有一个模块声明标识符(ModuleSpecifier),它告诉 loader 如何去查找下一个模块的地址。

20250528215735

每一个模块标识符都对应着一个模块记录(Module Record),而每个模块记录中包含了:

  • JavaScript 代码本身

  • 执行上下文

  • 以及四种重要的表项:ImportEntriesLocalExportEntriesIndirectExportEntriesStarExportEntries

其中,ImportEntries 是一个 ImportEntry Records 类型的结构,记录了模块里所有的 import 语句信息;

LocalExportEntriesIndirectExportEntriesStarExportEntries 都是 ExportEntry Records 类型的结构,记录了模块的各种导出方式。

ImportEntry Records

一个 ImportEntry Record 记录了当前模块中 import 语句的具体信息,它包含三个字段:

  1. ModuleRequest:模块标识符(ModuleSpecifier),告诉系统从哪里去找这个模块。

  2. ImportName:要从 ModuleRequest 指定的模块中导入的具体名称。值 namespace-object 表示这次导入的是目标模块的命名空间对象。

  3. LocalName:当前模块内部用来引用导入值的变量名,也就是在你自己模块里写的名字。

详情可参考下图:

20250528220153

下面这张表记录了使用 import 导入的 ImportEntry Records 字段的实例:

导入声明 (Import Statement From) 模块标识符 (ModuleRequest) 导入名 (ImportName) 本地名 (LocalName)
import React from "react"; "react" "default" "React"
import * as Moment from "react"; "react" namespace-obj "Moment"
import {useEffect} from "react"; "react" "useEffect" "useEffect"
import {useEffect as effect } from "react"; "react" "useEffect" "effect"
ExportEntry Records

一个 ExportEntry Record 记录了当前模块中的导出信息,它包含四个字段:

  1. ExportName:导出的名称,也就是别的模块在 import 时用到的名字。

  2. ModuleRequest:模块标识符(ModuleSpecifier),如果是间接导出(export { a } from 'x')时,指定从哪里引入。

  3. ImportName:当是间接导出时,要从 ModuleRequest 指定的模块中导出的具体名称。

  4. LocalName:当前模块里要导出的变量名。

ImportEntry Records 不同的是,ExportEntry Records 多了一个 ExportName,专门用来描述这个导出的名字。

下面这张表记录了使用 export 导出的 ExportEntry Records 字段的实例:

导出声明 导出名 模块标识符 导入名 本地名
export var v; "v" null null "v"
export default function f() {} "default" null null "f"
export default function () {} "default" null null "default"
export default 42; "default" null null "default"
export {x}; "x" null null "x"
export {v as x}; "x" null null "v"
export {x} from "mod"; "x" "mod" "x" null
export {v as x} from "mod"; "x" "mod" "v" null
export * from "mod"; null "mod" all-but-default null
export * as ns from "mod"; "ns "mod" all null

回到主题,只有当解析完当前的 Module Record 之后,才能知道当前模块依赖的是那些子模块,然后你需要 resolve 子模块,获取子模块,再解析子模块,不断的循环这个流程 resolving -> fetching -> parsing,结果如下图所示:

20250528220636

这个过程也被称为静态分析,它只会识别 exportimport 关键字,不会真正执行 JavaScript 代码。也正因为这样,import 语句只能出现在全局作用域中,动态导入(import())除外。

那如果多个文件同时依赖同一个模块,会不会引起死循环呢?答案是:不会。

这是因为 loader 使用了一个叫做 Module Map 的东西,来追踪和缓存全局范围内所有的 Module Record。这确保了每个模块只会被 fetch 一次,避免了重复加载或死循环的问题。并且,每个全局作用域都有自己的独立 Module Map。

Module Map 是一个 key/value 结构的映射对象,key 是一个 URL(模块的请求地址),value 是模块类型的字符串(比如 “javascript”)。 模块映射的值可以是模块脚本、null(表示获取失败),或者一个占位符 fetching(表示正在获取中)。

如下图所示:

20250528220855

linking 链接阶段

在所有 Module Record 解析完成后,接下来 JavaScript 引擎会对这些模块进行链接。引擎会从入口文件的 Module Record 开始,按照深度优先的顺序,递归地把依赖的模块链接起来。

在这个过程中,引擎会为每个 Module Record 创建一个 Module Environment Record,用来管理当前模块中声明的变量。

20250528221028

Module Environment Record 中有一个叫做 Binding 的东西,用来存放 Module Record 里导出的变量。比如在模块 main.js 中导出了一个名为 count 的变量,那么在 Module Environment Record 中的 Binding 就会包含一个 count,为这个变量分配内存空间,但初始值是 undefinednull

这个过程类似于 V8 在编译阶段时,先创建一个模块实例对象,并为其中的变量和方法分配内存空间。 当子模块 count.js 中通过 import 关键字导入 main.js 时,count.jsimport 变量和 main.jsexport 变量指向的内存位置是相同的,这样就把父子模块之间的关系联系在一起了。

如下图所示:

20250528221222

需要注意的是,我们称 export 导出的为父模块,import 引入的为子模块,父模块可以对变量进行修改,具有读写权限,而子模块只有读权限。

Evaluation 求值阶段

在所有模块完成链接后,JavaScript 引擎会进入求值阶段。这时,它会按照模块的依赖顺序,执行各个模块文件中的顶层作用域代码。 执行过程中,引擎会将之前在链接阶段中分配好内存空间的变量,赋予实际的运行时值。

这样,模块中声明的变量和导出的内容就真正填充到内存中,整个模块的功能也随之生效。求值阶段也是模块真正开始“工作”的时候,确保模块之间的导入导出关系和依赖都能正确执行。

ES Module 是如何解决循环引用的

在 ES Module 中,模块加载和执行过程通过五种状态来管理,分别是:unlinkedlinkinglinkedevaluatingevaluated

模块的状态存储在 循环模块记录(Cyclic Module Records)的 Status 字段中。通过这个状态,JavaScript 引擎可以判断一个模块是否已经被执行过,从而确保每个模块只会被执行一次。

这也是为什么引擎会使用 Module Map 来缓存全局的 Module Record,保证只在第一次加载时 fetch 并执行一次。

如果检测到一个模块的状态已经是 evaluated,下次再遇到它就会跳过执行,避免了死循环的发生。ES Module 会使用深度优先的方式遍历整个模块图,逐个执行模块的顶层代码,并且只会执行一次,从根本上避免了重复加载和死循环的问题。

深度优先搜索(Depth-First-Search,DFS)是一种常用的图遍历算法,它会尽可能深地搜索一个分支的节点,直到该分支的所有节点都被访问过,再回退到上一层继续探索其他分支。通过这种方式,ES Module 确保了每个模块都能被访问到一次,并且不会重复执行。

20250528221954

来看下面这个循环引用的例子,三个模块之间互相引用,但都只会执行一次:

// main.js
import { bar } from "./bar.js";
export const main = "main";
console.log("main");

// foo.js
import { main } from "./main.js";
export const foo = "foo";
console.log("foo");

// bar.js
import { foo } from "./foo.js";
export const bar = "bar";
console.log("bar");

在 Node.js 中运行 main.js,会得到下面的结果:

20250528221921

可以看到,每个模块只会输出一次,即使循环依赖也不会导致死循环。

总结

前端模块化是将大型代码拆分成独立小块的开发方式,每个模块专注单一功能,提高了代码的可维护性和复用性。模块化经历了从石器时代的全局变量污染,到 IIFE 函数作用域隔离,再到 CommonJS、AMD、UMD 等规范的发展历程。CommonJS 采用同步加载适合服务器端,通过 require/module.exports 实现模块导入导出并具有缓存机制;而 ES Module 是 JavaScript 官方标准,采用异步加载和静态分析,通过 import/export 语法提供更好的性能和树摇优化。掌握模块化是前端工程化的基础,为后续使用 Webpack 等构建工具奠定了重要基础。

昨天以前首页

手把手搭一套前端监控采集 SDK

作者 Moment
2026年3月25日 08:08

大家好 👋,我是 Moment,目前正在使用 Next.js、NestJS、LangChain 开发 DocFlow。这是一个面向 AI 场景的协同文档平台,集成了基于 Tiptap 的富文本编辑、NestJS 后端服务、实时协作与智能化工作流等核心模块。

在这个项目的持续打磨过程中,我积累了不少实战经验,不只是 Tiptap 的深度定制、编辑器性能优化和协同方案设计,也包括前端工程化建设、React 源码理解以及复杂项目架构实践。

如果你对 AI 全栈开发、文档编辑器、前端工程化或者 React 源码相关内容感兴趣,欢迎添加我的微信 yunmz777 一起交流。觉得项目还不错的话,也欢迎给 DocFlow 点个 star ⭐

image.png

完整的前端监控平台通常分成三块:采集与上报、整理与存储、展示与分析。本文只讲第一块,从 0 搭一个可运行的埋点 SDK,并把指标采集方式对齐到当前浏览器与 Core Web Vitals 的常见做法。

名字会影响记忆和传播。这里把 SDK 叫做"四维",英文 four-dimension,简写 FD,寓意尽量用上帝视角看清页面里发生的事。下文用 TypeScript 写示例,便于类型即文档。

自研采集层还要提前想好几条边界:是否采集可能含个人信息的字段、是否对错误栈与 URL 做脱敏、是否在低端机做采样。这些决定往往比多写一个 observer 更影响能不能上线。

整体结构

采集侧可以拆成四件事:配置、缓存与上报策略、各类 observer 与事件钩子、统一入口类。数据流与模块边界可以对照下图来记,和下面 Mermaid 图表达的是同一条主线。

如下图所示。

20260325075816

从页面事件到内存队列,再到空闲或离开时发往服务端的一整条链路。

20260325080415

配置与入口类

业务侧只需要改上报地址、应用标识等。配置对象建议可合并覆盖,避免散落魔法字符串。可预留 releaseenvironment 字段,方便和后端版本聚类对齐。userId 若涉及合规,建议只传哈希后的业务 id,或默认不传,由登录域自行下发自洽标识。

config.ts 中集中维护默认值,并导出 setConfig,便于在业务入口覆盖:

export interface MonitorConfig {
  reportUrl: string;
  appId: string;
  userId?: string;
  projectName?: string;
  release?: string;
  environment?: "development" | "staging" | "production";
  sampleRate?: number;
}

const config: MonitorConfig = {
  reportUrl: "http://localhost:8000/report",
  appId: "fd-example",
  projectName: "fd-example",
  environment: "development",
  sampleRate: 1,
};

export function setConfig(partial: Partial<MonitorConfig>): void {
  Object.assign(config, partial);
}

export function getConfig(): Readonly<MonitorConfig> {
  return config;
}

FourDimension 负责在构造时拉起各模块。初始化不要依赖构造参数时,可以保持无参构造,只在 init 里注册监听,避免重复调用时重复挂钩子。

import { initPerformance } from "./performance";
import { initBehavior } from "./behavior";
import { initError } from "./error";

export class FourDimension {
  private inited = false;

  init(): void {
    if (this.inited) return;
    this.inited = true;
    initPerformance();
    initError();
    initBehavior();
  }
}

业务里建议异步加载 SDK 脚本,初始化时 new FourDimension().init() 即可。若脚本可能被多次执行,务必保留类似 inited 的幂等守卫,否则 fetch 会被包一层又一层。

上报通道 sendBeacon、图片打点与 XHR

navigator.sendBeacon 适合监控:异步、不抢主线程、在页面卸载时仍有机会发出。注意它发的是 POST,适合带 Blob 指定 Content-Type,而不是假设服务端只收 GET 查询串。

限制也要心里有数:无响应体、旧环境可能不存在、单次 payload 有实际上限(常见讨论量级在数十 KB,宜压 body 体积)。实践里常见优先级是 sendBeacon 优先,其次 1x1 图片 GET(数据需压缩且控制长度),再次带 keepalive: truefetchXMLHttpRequestsendBeacon 返回 false 说明浏览器拒绝排队,应立刻换通道。

下面封装一个带降级的 sendReportsendBeacon 分支用 BlobJSON,图片分支再把数据塞进查询参数(注意浏览器对 URL 长度的限制)。

export function isSupportSendBeacon(): boolean {
  return (
    typeof navigator !== "undefined" &&
    typeof navigator.sendBeacon === "function"
  );
}

export function reportImage(url: string, payload: unknown): void {
  const qs = encodeURIComponent(JSON.stringify(payload));
  const img = new Image();
  img.src = `${url}?reportData=${qs}`;
}

export function reportWithXhr(url: string, body: string): void {
  const xhr = new XMLHttpRequest();
  xhr.open("POST", url);
  xhr.setRequestHeader("Content-Type", "application/json;charset=UTF-8");
  xhr.send(body);
}

export function sendReport(url: string, body: string): void {
  if (isSupportSendBeacon()) {
    const blob = new Blob([body], { type: "application/json" });
    const ok = navigator.sendBeacon(url, blob);
    if (ok) return;
  }
  reportImage(url, JSON.parse(body) as unknown);
}

真实项目里可以在 sendBeacon 返回 false 时再尝试 XHR,把失败样本写入 sessionStorage 下次补发。接收端要核实:网关是否允许 Content-Type: application/jsonPOST,是否对 OPTIONS 预检放行,否则 beacon 在跨域场景会静默失败,需在 Network 面板核对状态码。

上报降级顺序若画成一张小抄,方便和运维对口径。

如下图所示。

20260325075931

三种通道的优先顺序与跨域核对点。

缓存与上报时机

目标是对主线程影响尽量小。常见组合是:

  • 内存里先攒一批,再批量上报
  • requestIdleCallback 在空闲时 flush,不支持时用 setTimeout 兜底
  • 页面离开时把剩余队列一次性发出

离开页面时优先依赖 pagehidevisibilitychange,比单纯 beforeunload 更稳,尤其在移动端后台化场景。visibilitychange 在标签隐藏时就能先 flush 一轮,pagehide 在真正离开时再做最后一跳。两个事件都可能触发 flush 时,要么在 flushQueue 内做"空队列直接返回",要么加发送中锁,避免重复上报同一批。

bfcache 恢复的页面会再走 pageshowpersistedtrue 时会话可能延续,停留时长统计要把可见时间分段累加,不能假设一次进页到一次离开。

type ReportPayload = Record<string, unknown>;

const queue: ReportPayload[] = [];
let flushTimer: ReturnType<typeof setTimeout> | null = null;

export function enqueue(payload: ReportPayload): void {
  queue.push(payload);
}

export function flushQueue(reportUrl: string, immediate = false): void {
  if (!queue.length) return;
  const batch = queue.splice(0, queue.length);
  const body = JSON.stringify({ batch });
  if (immediate) {
    sendReport(reportUrl, body);
    return;
  }
  const run = () => sendReport(reportUrl, body);
  if (typeof requestIdleCallback === "function") {
    requestIdleCallback(run, { timeout: 3000 });
  } else {
    setTimeout(run, 0);
  }
}

export function scheduleFlush(reportUrl: string, delayMs = 2000): void {
  if (flushTimer) clearTimeout(flushTimer);
  flushTimer = setTimeout(() => {
    flushTimer = null;
    flushQueue(reportUrl, false);
  }, delayMs);
}

export function bindLifecycleFlush(reportUrl: string): void {
  const onHide = () => {
    if (document.visibilityState === "hidden") {
      flushQueue(reportUrl, true);
    }
  };
  window.addEventListener("pagehide", () => flushQueue(reportUrl, true));
  document.addEventListener("visibilitychange", onHide);
}

getCache 若要对调用方返回快照,需要深拷贝避免外部改数组。深拷贝实现注意处理循环引用以外的普通 JSON 友好结构即可。

性能指标用最新采集思路

PerformanceObserver 仍是采集绘制与布局类指标的主力,buffered: true 让你晚注入脚本也能拿到已经发生过的条目。导航类指标优先读 PerformanceNavigationTiming,比自己在事件里 performance.now() 更贴近浏览器统计。

在挂 observer 之前可以用静态方法探测当前环境到底支持哪些 entryTypes,避免 observe 直接抛错。下面是一段可放进工具模块的探测逻辑。

export function supportedPerfTypes(): string[] {
  if (typeof PerformanceObserver !== "function") return [];
  return PerformanceObserver.supportedEntryTypes ?? [];
}

export function canObserve(type: string): boolean {
  return supportedPerfTypes().includes(type);
}

Chrome DevToolsPerformanceLighthouse 里跑一遍同页,把面板里的 LCPCLS 与 SDK 打上去的值对比,数量级应一致。若差一个数量级,先查是否重复统计、是否在 iframe 里采集、是否混用了导航时间与绘制时间。

Core Web Vitals 对齐

截至 Google 面向站长的公开说明,Core Web Vitals 核心指标是 LCPINPCLSFID 已被 INP 取代,自研 SDK 仍可同时上报 FID 做历史对比,但产品解读应以 INP 为主。

指标 含义 推荐采集方式
LCP 视口内最大内容绘制完成时刻 PerformanceObservertype: 'largest-contentful-paint',通常取最后一次有效条目
INP 交互到下一帧绘制的延迟分布 PerformanceObservertype: 'interaction'(需较新 Chromium),或引入 web-vitals
CLS 累计布局偏移 PerformanceObservertype: 'layout-shift',且只统计 hadRecentInput === false 的条目并累加 value

FPFCP 仍可通过 type: 'paint' 观察,用于诊断首屏是否"空刷背景"与"首现有意义内容"的差异。

三个核心指标与采集入口的关系,适合印在团队 wiki 首页当速查图。

如下图所示。

20260325080035

LCPINPCLS 与对应 observer 类型名称的对应关系。

paint 与首屏绘制

下面示例合并监听 first-paintfirst-contentful-paint,并在拿到 FCP 后断开,避免重复回调。若你希望两种 paint 都上报,应在两种都见到后再 disconnect,或干脆不断开、由服务端按 paintName 去重。

import { enqueue, scheduleFlush } from "./queue";
import { getConfig } from "./config";

function safeObserverSupported(): boolean {
  return typeof PerformanceObserver !== "undefined";
}

export function observePaint(): void {
  if (!safeObserverSupported()) return;
  const obs = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      if (
        entry.name !== "first-paint" &&
        entry.name !== "first-contentful-paint"
      )
        continue;
      const json = entry.toJSON();
      enqueue({
        type: "performance",
        subType: "paint",
        paintName: entry.name,
        startTime: json.startTime,
        pageURL: location.href,
      });
      if (entry.name === "first-contentful-paint") {
        obs.disconnect();
        scheduleFlush(getConfig().reportUrl);
        break;
      }
    }
  });
  obs.observe({ type: "paint", buffered: true });
}

LCP 在页面生命周期内可能更新,规范语义是"最后一个汇报的 LCP 条目代表当前候选"。简单实现可以在回调里每次都上报最新一条,由服务端取同会话最后一次,或在客户端只保留最大 startTime 的那条再上报。注意 LCP 回调触发时 entry.element 可能已被移除,DOM 引用要谨慎,上报 tagName 与资源 URL 即可。

export function observeLcp(): void {
  if (!safeObserverSupported()) return;
  const obs = new PerformanceObserver((list) => {
    const entries = list.getEntries() as PerformanceEntry[];
    const last = entries[entries.length - 1] as LargestContentfulPaint &
      PerformanceEntry;
    const json = last.toJSON();
    enqueue({
      type: "performance",
      subType: "lcp",
      startTime: json.startTime,
      element: last.element?.tagName,
      url: "url" in last ? String((last as { url?: string }).url ?? "") : "",
      pageURL: location.href,
    });
    scheduleFlush(getConfig().reportUrl);
  });
  obs.observe({ type: "largest-contentful-paint", buffered: true });
}

上面用到 LargestContentfulPaint 时,若项目 lib.dom 较旧,可把 last 标成 PerformanceEntry 并谨慎读取可选字段。

CLSINP

CLS 需要过滤用户操作附近的偏移,避免把有意交互造成的布局变化算成体验问题。

export function observeCls(): void {
  if (!safeObserverSupported()) return;
  let clsScore = 0;
  const obs = new PerformanceObserver((list) => {
    for (const entry of list.getEntries() as PerformanceEntry[]) {
      const ls = entry as LayoutShift & {
        hadRecentInput?: boolean;
        value?: number;
      };
      if (ls.hadRecentInput) continue;
      clsScore += ls.value ?? 0;
      enqueue({
        type: "performance",
        subType: "cls",
        value: ls.value,
        cumulativeLayoutShift: clsScore,
        pageURL: location.href,
      });
    }
    scheduleFlush(getConfig().reportUrl);
  });
  obs.observe({ type: "layout-shift", buffered: true });
}

INP 依赖 type: 'interaction'PerformanceObserver,浏览器支持面仍在演进。生产环境若要省心,可直接使用 web-vitals 包,它会在不支持时降级或给出兼容策略。最小接入示意如下,真实项目里把 console.log 换成 enqueue 即可。

import { onINP } from "web-vitals";

onINP((metric) => {
  const v = metric.value;
  console.log("INP ms", v);
});

自研最小实现可以封装为"支持则订阅,不支持则不上报",避免把未定义行为写死进业务。

导航时间与 DOMContentLoadedload

更稳的做法是读取 performance.getEntriesByType('navigation')[0],得到 PerformanceNavigationTiming,用相对 fetchStartstartTime 的各阶段时刻算 DNSTCPTTFBDOM 解析等。字段含义以 MDN 上的 PerformanceNavigationTiming 为准,换公式前用一次 console.tablenav 打出来核对。

export function collectNavigationTiming(): void {
  const [nav] = performance.getEntriesByType(
    "navigation",
  ) as PerformanceNavigationTiming[];
  if (!nav) return;
  enqueue({
    type: "performance",
    subType: "navigation",
    dns: nav.domainLookupEnd - nav.domainLookupStart,
    tcp: nav.connectEnd - nav.connectStart,
    ttfb: nav.responseStart - nav.requestStart,
    domContentLoaded: nav.domContentLoadedEventEnd - nav.fetchStart,
    load: nav.loadEventEnd - nav.fetchStart,
    pageURL: location.href,
  });
  scheduleFlush(getConfig().reportUrl);
}

可在 load 事件触发后再调用一次,确保 loadEventEnd 已非 0。单页应用在客户端路由切换时不会产生新的 navigation 条目,若要监控"软导航",需要结合框架路由钩子或 Performance API 里仍在演进的软导航相关能力单独设计,不能把 PV 和导航耗时混在一条 navigation 记录里硬解释。

资源耗时

资源条目用 type: 'resource'。注意不要在每个 entry 上都 disconnect,否则只会收到第一条资源。更合理的是页面 load 后一次性读取 performance.getEntriesByType('resource'),或长期观察但在 disconnect 前处理完整批次。

跨域资源若没有正确的 Timing-Allow-Origin,多数细粒度时长在浏览器里会被抹成 0,这是安全策略不是 SDK 坏了。核实方式是对比同源静态资源与 CDN 资源的 transferSizedomainLookupStart 等是否突然全 0。

export function observeResources(): void {
  if (!safeObserverSupported()) return;
  const obs = new PerformanceObserver((list) => {
    for (const entry of list.getEntries() as PerformanceResourceTiming[]) {
      enqueue({
        type: "performance",
        subType: "resource",
        name: entry.name,
        initiatorType: entry.initiatorType,
        duration: entry.duration,
        dns: entry.domainLookupEnd - entry.domainLookupStart,
        tcp: entry.connectEnd - entry.connectStart,
        ttfb: entry.responseStart - entry.requestStart,
        protocol: entry.nextHopProtocol,
        transferSize: entry.transferSize,
        encodedBodySize: entry.encodedBodySize,
        decodedBodySize: entry.decodedBodySize,
        pageURL: location.href,
      });
    }
    scheduleFlush(getConfig().reportUrl);
  });
  obs.observe({ type: "resource", buffered: true });
}

若担心资源量过大,可在客户端按域名白名单或按耗时阈值过滤后再入队。也可按 config.sampleRate 随机丢弃非错误样本,只保留长尾。

接口耗时:fetchXHR

只劫持 XMLHttpRequest 会漏掉现代代码里大量的 fetch。可以同时包装 window.fetchXMLHttpRequest.prototype。包装 fetch 时不要假设调用方不克隆 Response 去读体,监控侧只读 status 与头即可,避免和消费方抢读同一个 body 流。

export function patchFetch(): void {
  const orig = window.fetch.bind(window);
  window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
    const start = performance.now();
    const req = input instanceof Request ? input : new Request(input, init);
    try {
      const res = await orig(req);
      const end = performance.now();
      enqueue({
        type: "performance",
        subType: "fetch",
        url: req.url,
        method: req.method,
        status: res.status,
        duration: end - start,
        pageURL: location.href,
      });
      scheduleFlush(getConfig().reportUrl);
      return res;
    } catch (err) {
      const end = performance.now();
      enqueue({
        type: "error",
        subType: "fetch",
        url: req.url,
        method: req.method,
        duration: end - start,
        message: err instanceof Error ? err.message : String(err),
        pageURL: location.href,
      });
      scheduleFlush(getConfig().reportUrl);
      throw err;
    }
  };
}

XHR 劫持仍可用 opensend 包装,在 loadend 上打点时间戳,与上文思路一致,此处不重复贴全。

错误上报

资源错误与 JS 运行时错误要分开通道。window.addEventListener('error', …, true) 在捕获阶段能拿到 scriptlinkimg 等加载失败,event.target 指向元素。纯 JS 语法与运行时错误同一事件里 target 往往为空,可配合 window.onerror 或同一监听里分支处理。ErrorEvent 上的 message 在跨域脚本且未正确配置 crossorigin 时可能是统一口令,需要和源站 CORS 配置一起核实。

Promise 未处理拒绝用 unhandledrejection。上报体里尽量带 reason 的栈信息,字符串化时注意大对象。

事件路径不要用已弃用的 event.path,改用 event.composedPath()

错误从页面钻进队列前,按类型分流,便于后端路由到不同看板。

如下图所示。

20260325080152

资源、脚本、Promise 三类错误进入同一条上报管道前的分流意象。

function elementPath(ev: Event): string[] {
  const path = typeof ev.composedPath === "function" ? ev.composedPath() : [];
  return path
    .filter((n): n is Element => n instanceof Element)
    .map((el) => el.tagName);
}

export function initGlobalErrorHandlers(): void {
  window.addEventListener(
    "error",
    (ev) => {
      const t = ev.target;
      if (
        t &&
        t instanceof HTMLElement &&
        (t instanceof HTMLImageElement ||
          t instanceof HTMLScriptElement ||
          t instanceof HTMLLinkElement)
      ) {
        const url =
          "src" in t && t.src ? t.src : "href" in t && t.href ? t.href : "";
        enqueue({
          type: "error",
          subType: "resource",
          url,
          tag: t.tagName,
          paths: elementPath(ev),
          pageURL: location.href,
        });
        scheduleFlush(getConfig().reportUrl);
        return;
      }
      if (!ev.message) return;
      enqueue({
        type: "error",
        subType: "js",
        message: ev.message,
        filename: ev.filename,
        lineno: ev.lineno,
        colno: ev.colno,
        stack: ev.error instanceof Error ? ev.error.stack : "",
        pageURL: location.href,
      });
      scheduleFlush(getConfig().reportUrl);
    },
    true,
  );

  window.addEventListener("unhandledrejection", (ev) => {
    const reason = ev.reason;
    enqueue({
      type: "error",
      subType: "promise",
      stack: reason instanceof Error ? reason.stack : String(reason),
      pageURL: location.href,
    });
    scheduleFlush(getConfig().reportUrl);
  });
}

若担心第三方脚本堆栈污染,可在入口做采样或域名过滤。生产环境应上传 source map 到私有桶,由服务端按 release 解析栈,而不是把完整文件路径暴露给前端库。

行为数据:PV、停留时长、点击

PV 在每次路由或首屏进入时打一条,带上 document.referrer 与本地生成的会话或设备标识。UV 必须在服务端用 cookie、登录 id 或可信指纹聚合,客户端只能提供匿名 id。单页应用要在路由变化时手动调一次 reportPv,仅依赖首屏加载会严重低估。

停留时长用 visibilitychange 记录可见累计时间,比只在 beforeunload 减一次更准,尤其是后台标签与 bfcache 场景。离开页面时再发一条汇总,字段里带 visibleMs 即可。下面是一段与队列解耦的计时思路,需与上文的 enqueueflushQueuegetConfig 同模块配合使用。

import { enqueue, flushQueue } from "./queue";
import { getConfig } from "./config";

let visibleAccum = 0;
let lastVisibleStart = performance.now();

document.addEventListener("visibilitychange", () => {
  const now = performance.now();
  if (document.visibilityState === "visible") {
    lastVisibleStart = now;
  } else {
    visibleAccum += now - lastVisibleStart;
  }
});

window.addEventListener("pagehide", () => {
  if (document.visibilityState === "visible") {
    visibleAccum += performance.now() - lastVisibleStart;
  }
  enqueue({
    type: "behavior",
    subType: "dwell",
    visibleMs: Math.round(visibleAccum),
    pageURL: location.href,
  });
  flushQueue(getConfig().reportUrl, true);
});

点击监听建议防抖,避免长按或滑动误触暴风上报。坐标与 outerHTML 体积要限长,防止队列爆炸。敏感页面不要上传完整 outerHTML,可只保留 data- 业务埋点键名。

下面用 sessionStorage 存会话 id,首次访问时用 crypto.randomUUID() 生成。若需兼容极老环境,可再降级到时间戳加长随机串。

function createSessionId(): string {
  if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
    return crypto.randomUUID();
  }
  return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}

let sessionId = sessionStorage.getItem("fd_sid") ?? "";
if (!sessionId) {
  sessionId = createSessionId();
  sessionStorage.setItem("fd_sid", sessionId);
}

export function reportPv(): void {
  enqueue({
    type: "behavior",
    subType: "pv",
    pageURL: location.href,
    referrer: document.referrer,
    sessionId,
  });
  scheduleFlush(getConfig().reportUrl);
}

export function reportClickDebounced(delayMs = 500): void {
  let timer: ReturnType<typeof setTimeout> | null = null;
  window.addEventListener("pointerdown", (ev) => {
    if (!(ev.target instanceof Element)) return;
    if (timer) clearTimeout(timer);
    timer = setTimeout(() => {
      timer = null;
      const el = ev.target;
      const r = el.getBoundingClientRect();
      enqueue({
        type: "behavior",
        subType: "click",
        tag: el.tagName,
        x: r.left,
        y: r.top,
        paths: elementPath(ev),
        pageURL: location.href,
        sessionId,
      });
      scheduleFlush(getConfig().reportUrl);
    }, delayMs);
  });
}

上线前建议核对的一张表

把下面几项当成发布前 checklist,在 Chrome 与一种目标内核(如 Safari 或内置浏览器)各测一遍。

核对项 怎么核实 常见坑
sendBeacon 是否到达 Network 里看 report 请求体与状态码 跨域未放行 POST413 体积过大
LCP 是否合理 Lighthouse 与 SDK 数值同页对比 iframe、影子根、元素已移除
资源耗时是否全 0 挑一条 CDN 资源看 responseStart Timing-Allow-Origin
软导航 PV 手动点路由后看是否产生新 pv 事件 只监听了首次 load
重复 flush 快速切换标签看上报条数是否翻倍 visibilitypagehide 未去重

小结

把上报做成"队列加空闲 flush 加离开兜底",用 sendBeacon 携带 JSON Blob,性能侧用 PerformanceObserverPerformanceNavigationTiming 对齐现代指标,并补上 CLSINP 的采集意识,错误侧区分资源与脚本并改用 composedPath,行为侧把 PV、软导航与可见停留时间说清楚,就是一个可演进的最小监控采集层。存储与查询、告警与大盘属于下一篇文章。

开源一年,我的 AI 全栈项目 AI 协同编辑器终于有 1.1 k star了 😍😍😍

作者 Moment
2026年3月24日 10:20

大家好 👋,我是 Moment,目前正在使用 Next.js、NestJS、LangChain 开发 DocFlow。这是一个面向 AI 场景的协同文档平台,集成了基于 Tiptap 的富文本编辑、NestJS 后端服务、实时协作与智能化工作流等核心模块。

在这个项目的持续打磨过程中,我积累了不少实战经验,不只是 Tiptap 的深度定制、编辑器性能优化和协同方案设计,也包括前端工程化建设、React 源码理解以及复杂项目架构实践。

如果你对 AI 全栈开发、文档编辑器、前端工程化或者 React 源码相关内容感兴趣,欢迎添加我的微信 yunmz777 一起交流。觉得项目还不错的话,也欢迎给 DocFlow 点个 star ⭐

image.png

📖 简介

DocFlow 是一款面向团队协作的块级文档编辑器。它融合了 Notion 的灵活性与飞书的协作能力,通过块级内容架构、实时协同编辑和 AI 辅助功能,帮助团队高效完成文档创作与知识管理。

我们希望通过技术手段减少协作摩擦,让文档编辑更接近团队的真实工作流。无论是产品规划文档、技术方案设计,还是会议记录整理,DocFlow 都能提供流畅的创作体验。

✨ 核心特性

DocFlow 参考了 Notion 与飞书的设计理念,将内容以块为单位进行组织。每个块都是独立的编辑单元,可以灵活组合与调整,同时支持实时协作与 AI 辅助。

  • 🧱 块级编辑器:支持文本、标题、列表、代码块、表格、图片、视频等 20+ 种内容类型,通过拖拽即可调整块级元素的顺序与层级关系。

  • ⚡ 实时协作:基于 Yjs CRDT 算法实现多人同步编辑,自动处理编辑冲突。支持实时光标跟踪、成员在线状态与历史版本回溯。

  • 🤖 AI 功能:内置 AI 助手,支持头脑风暴、内容润色、文档续写与智能问答。可根据上下文生成结构化内容建议。

技术选型

DocFlow 采用全栈 TypeScript 架构,前端基于 Next.js 构建,后端使用 NestJS 框架。通过统一的类型系统和现代化的工程实践,保证了代码质量与开发效率。

🎨 前端架构 (Client-side)

Next.js

项目基于 Next.js App Router 架构,利用 React Server Components 优化首屏渲染性能。通过 Server Actions 实现前后端通信,确保类型安全的同时简化了数据流转。

Tiptap

编辑器核心采用 Tiptap 框架,基于 ProseMirror 构建。通过扩展机制实现了丰富的块级编辑能力,支持自定义节点与快捷命令,为用户提供接近 Notion 的编辑体验。

Yjs

协作功能基于 Yjs CRDT 算法实现,能够自动处理多人编辑时的冲突,保证数据最终一致性。配合 Awareness 模块,实现了实时光标追踪与在线状态同步。

⚙️ 后端架构 (Server-side)

NestJS & Prisma

后端使用 NestJS 模块化框架,通过依赖注入实现业务逻辑解耦。Prisma ORM 提供类型安全的数据访问层,支持高效的数据库查询与迁移管理。

Hocuspocus

Hocuspocus 作为 Yjs 的 WebSocket 服务端,负责协调文档协作会话,处理客户端连接与数据同步。通过拦截器机制实现权限控制与数据持久化。

Prometheus & Grafana

集成 Prometheus 进行指标采集,通过 Grafana 可视化展示系统运行状态。监控包括 API 响应时间、数据库查询性能、WebSocket 连接数等核心指标。

20260203091658

Grafana 监控面板实时展示系统各项性能指标,包括请求量、响应时间、错误率等关键数据,帮助快速定位性能瓶颈。

ELK Stack (Elasticsearch & Kibana)

使用 Elasticsearch 存储和检索日志数据,Kibana 提供日志分析与可视化能力。支持全文搜索、日志聚合与异常检测,便于问题排查与系统审计。

日志分析系统

Kibana 日志分析界面,支持按时间、日志级别、服务模块等维度查询和过滤日志,提供结构化的问题排查路径。

MinIO & RabbitMQ

MinIO 提供对象存储服务,用于存储用户上传的图片、视频等文件。RabbitMQ 作为消息队列,处理异步任务如图片压缩、邮件发送等,避免阻塞主业务流程。

功能介绍

DocFlow 将 AI 能力集成到编辑器中,通过理解文档上下文来辅助内容创作。AI 不是简单的文本生成工具,而是能够理解语义、提供决策建议的智能助手。

AI 头脑风暴

当你有一个初步想法但不知如何展开时,AI 头脑风暴可以帮助拓展思路。输入核心概念后,AI 会从不同角度生成 3-6 个结构化方案,每个方案都包含具体的实施思路。

AI 头脑风暴输入界面

在编辑器中输入头脑风暴主题,AI 会基于输入内容理解你的需求场景。

AI 头脑风暴结果展示

AI 生成的多个方案以卡片形式展示,每个方案都有清晰的标题和详细说明。你可以选择任意方案插入到文档中,或者继续优化调整。

这不只是简单的内容生成,AI 会根据上下文理解你的意图。无论是产品功能设计、内容分类规划,还是业务流程优化,AI 都能提供可行的思路参考,帮助快速决策。

AI 文本润色

AI 文本润色功能

选中需要优化的文本段落,AI 会分析文本结构与表达方式,提供更清晰、更专业的改写建议。支持调整语气风格,如正式、简洁、友好等。

AI 续写

AI 续写功能会根据前文内容自然延续写作。当前文内容较长时,系统通过 RAG (检索增强生成) 技术,从文档中检索相关段落,确保续写内容与上下文保持逻辑一致,避免偏离主题。

AI 续写功能演示

AI 续写时会参考前文的写作风格、用词习惯和逻辑结构,生成连贯自然的后续内容。你可以继续编辑生成的文本,或者重新生成。

AI 聊天

目前 AI 聊天功能作为独立页面存在,后续会集成到编辑器侧边栏,与文档内容深度关联。未来计划实现 Agent 模式,类似 Cursor 那样能够自动编辑文档内容。

7a8ba58a4ab3b592bb7fae1b45634648

协同编辑

多人协同编辑

多人同时编辑时,每个用户都有独立的光标颜色标识。文档修改实时同步,冲突自动合并。右侧显示当前在线成员列表与他们的编辑位置。

未来计划

DocFlow 将持续优化协作体验与 AI 能力,同时加强工程化建设,提升系统可扩展性。

🏗️ 工程化体系深度重构

  • 迈向 Monorepo 架构:计划基于 pnpm workspaces 和 Turborepo 将项目重构为 Monorepo。前后端代码分离,共享类型定义与工具函数,提升代码复用率与构建效率。

  • 组件库与插件生态开放:将 Tiptap 自定义扩展(如代码沙箱、交互式图表等)提取为独立 npm 包,开放给社区使用。同时建立插件开发规范,支持第三方开发者扩展编辑器能力。

🎙️ 多维协同体验升级

  • 集成 LiveKit 实时音视频:在文档协作场景中引入实时音视频通话。团队成员可以边看文档边讨论,提升复杂决策场景下的沟通效率。

LiveKit 集成方案

  • 实时群聊系统:在文档侧边栏集成实时聊天功能,支持针对文档内容发起讨论。消息可以关联到具体的文档块,形成完整的协作反馈闭环。

🤖 智能内核的跨越式进化

  • 基于 RAG 的私有知识库:引入 RAG (Retrieval-Augmented Generation) 技术,让 AI 能够检索用户的历史文档。AI 回答问题时会参考团队沉淀的知识资产,提供更精准的决策支持。

  • 从 Copilot 迈向 Agent:探索 AI Agent 在文档场景的应用。未来 AI 将能够自主执行任务,例如从会议纪要中提取待办事项,自动同步到第三方工具,实现从辅助创作到自动化办公的升级。

🚀 快速开始

环境要求

  • Node.js >= 24
  • pnpm >= 10.28.2

本地开发

  1. 克隆仓库
git clone https://github.com/xun082/DocFlow.git
cd DocFlow
  1. 安装依赖
pnpm install
  1. 启动开发服务器
pnpm dev
  1. 打开浏览器访问
http://localhost:3000

🐳 Docker 部署

方式一:使用 Docker Compose(推荐)

# 使用预构建镜像
docker-compose up -d

# 访问应用
http://localhost:3000

方式二:手动构建

  1. 构建镜像
docker build -t docflow:latest .
  1. 运行容器
docker run -d \
  --name docflow \
  -p 3000:3000 \
  -e NODE_ENV=production \
  docflow:latest
  1. 访问应用
http://localhost:3000

健康检查

容器内置健康检查端点:

curl http://localhost:3000/api/health

🤝 贡献指南

欢迎提交 Issue 和 Pull Request!

在提交代码前,请确保:

  • 运行 pnpm type-check 通过类型检查

  • 运行 pnpm lint 通过代码检查

  • 运行 pnpm format 格式化代码

  • 遵循项目的代码规范和提交规范

详见 CONTRIBUTING.md

📬 联系方式

TypeScript 要换芯了,6.0 竟是旧编译器的最后一舞

作者 Moment
2026年3月24日 08:42

大家好 👋,我是 Moment,目前正在使用 Next.js、NestJS、LangChain 开发 DocFlow。这是一个面向 AI 场景的协同文档平台,集成了基于 Tiptap 的富文本编辑、NestJS 后端服务、实时协作与智能化工作流等核心模块。

在这个项目的持续打磨过程中,我积累了不少实战经验,不只是 Tiptap 的深度定制、编辑器性能优化和协同方案设计,也包括前端工程化建设、React 源码理解以及复杂项目架构实践。

如果你对 AI 全栈开发、文档编辑器、前端工程化或者 React 源码相关内容感兴趣,欢迎添加我的微信 yunmz777 一起交流。觉得项目还不错的话,也欢迎给 DocFlow 点个 star ⭐

image.png

微软 TypeScript 团队近日宣布 typescript 6.0 已在 npm 上可用。这一版在路线图上的位置很特殊,官方明确将其定位为基于现有 JavaScript 技术栈实现的编译器的最后一次大版本迭代。团队正用 Go 重写编译器与语言服务,新版本将支撑未来的 TypeScript 7.0 及之后版本,并利用原生性能与多线程共享内存。

20260324083243

对日常写业务代码的开发者来说,6.0 既是可立刻升级的正式版,也是面向 7.0 的排练场。官方称 7.0 已非常接近完成,VS Code 与 npm 上均可试用原生预览。若能顺利升到 6.0,团队建议同时尝试 7.0 预览,以便提前暴露工程上的问题。

安装方式与往常一样。

npm install -D typescript

去年起微软已多次公开 Go 版编译器计划,近期说明见 TypeScript 官方博客索引。下文按"报道视角"整理升级价值、新特性要点,以及可能踩坑的破坏性变更与迁移提示。

6.0 在整条链路里扮演什么角色

5.9 与未来的 7.0 之间,6.0 主要做两件事。一是把语言与编译器行为尽量向 7.0 对齐,减少将来一次性换引擎时的撕裂感。二是在此基础上仍交付一批独立有价值的功能与修正,而不只是"为对齐而对齐"。

自 6.0 beta、RC 以来,正式版相对预览版还有几处值得注意的收紧,多数也是为了与 7.0 一致。包括在泛型调用里对函数表达式(尤其泛型 JSX)的类型检查更严,可能多报一些问题,部分泛型调用需要手写类型实参。import 断言语法在动态 import() 上的弃用范围也扩大了。DOM 类型则跟进最新 Web 标准,含对 Temporal 相关声明的调整。

类型推断里对无 this 方法更友好

这是一个典型"修边角但真能省时间"的改动。过去在对象字面量里,若 produceconsume 这类成员用方法简写而参数又没写类型,推断顺序和"上下文敏感函数"规则叠在一起时,consume 里参数有时会被推断成 unknown,而箭头函数写法却正常。根因之一是方法形参隐含 this,类型系统曾一律把这类函数当成上下文敏感,即使函数体根本没用到 this

6.0 的规则是,若函数体内从未真正使用 this,就不再按上下文敏感那套低优先级处理,推断会更符合直觉。对大量写对象字面量回调的代码库,升级后有望少写一批显式注解。实现归功于社区贡献者 Mateusz Burzyński。

Node 子路径导入支持 #/ 前缀

Node 的 package.jsonimports 字段允许包内用 # 开头的别名,避免深相对路径。此前规范要求 # 后面必须还有一段(例如 #root/),和很多人习惯的 @/ 式别名心智不完全一致。较新的 Node.js 20 已支持以 #/ 直接映射(例如 "#/*": "./dist/*")。TypeScript 在 --moduleResolutionnodenextbundler 时已跟进。相关工作由 magic-akari 等人推动。

bundler 解析可以和 commonjs 模块目标一起用

以前 --moduleResolution bundler 只能和 esnextpreserve 等模块格式搭配。随着旧的 node10 式解析被弃用,不少项目需要一条现实的升级路径。6.0 允许把 bundler 解析与 --module commonjs 组合使用。长期仍建议按项目形态规划,要么走向 preservebundler,要么走向 nodenext,取决于你是打包 Web、跑 Bun 还是直出 Node。

--stableTypeOrdering 是给对比测试用的开关

这是为 6.0 与 7.0 并行对照专门准备的选项。TypeScript 内部会给类型分配 ID,联合类型字面量顺序、声明文件里展示顺序等会受程序里声明先后影响。7.0 引入并行检查后,必须用确定性排序避免同一份代码在不同次检查里产出不一致的 .d.ts 或偶发错误。6.0 的 --stableTypeOrdering 让排序行为贴近 7.0,方便你做 diff 和排查。

代价是类型检查可能明显变慢,官方提到极端情况下可达约四分之一量级的额外耗时,因此不建议作为日常默认配置。若打开后出现新错误,往往是此前推断"碰巧"依赖了旧顺序,可通过显式类型实参或变量注解收紧。该标志只用于迁移期诊断,不是长期功能。

标准库与类型方面

  • targetlib 新增 es2025。ES2025 本身没有新语法,但会带上诸如 RegExp.escape 等内置 API 类型,并把部分原先在 esnext 的声明收进 es2025(例如 Promise.try、若干 IteratorSet 方法)。
  • Temporal 已到 stage 4,6.0 内置类型。可通过 esnext 或更细的 esnext.temporal 使用。运行时是否可用仍取决于引擎。
  • MapWeakMapgetOrInsertgetOrInsertComputed 随 ECMAScript upsert 提案进入 esnext lib。
  • RegExp.escapees2025 lib 可用。
  • dom 这一档 lib 现已内置原先 dom.iterabledom.asynciterable 的内容,现代浏览器场景下多数项目可只写 "dom",少一层配置心智负担。

破坏性变更与默认值,为什么和你有关

官方把 6.0 定义为过渡版,与 5.9 仍保持 API 兼容,但默认行为与弃用项会动到大量存量项目。可在 tsconfig 里暂时写 "ignoreDeprecations": "6.0" 压制弃用提示,但 7.0 将移除这些兼容,迟早要直面。

几个最可能影响升级体验的默认变化如下。

strict 默认为 true。以前靠隐式非严格的项目需要显式写 "strict": false 才能维持旧行为。

module 默认 esnexttarget 默认跟到当前支持的年份规格(文中写作现阶段为 es2025),整体假设是面向常青运行时。

noUncheckedSideEffectImports 默认开启,纯副作用导入更容易因笔误报错。

libReplacement 默认关闭,减轻 watch 模式下的解析与监视负担;新空项目里通常也感知不强。

rootDir 默认变为配置文件所在目录(.),不再自动从所有输入文件推公共根。若你过去依赖推断且输出目录里突然出现多一层 src,需要显式设 "rootDir": "./src" 等。

types 默认改为空数组 [],不再自动把 node_modules/@types 下所有包全灌进全局。这是构建提速的关键之一,官方称不少项目仅这一项就有约两成到五成的编译时间改善。代价是若你习惯"不写 types 也能全局拿到 Node、Jest 等全局",升级后会大量报找不到 processdescribe 等,需要在 compilerOptions.types 里显式列出 nodejest 等。若必须完全恢复旧行为,可设 "types": ["*"],但不推荐作为长期方案。

其他已弃用或移除项包括:target: es5--downlevelIteration--moduleResolution node(node10)、amd/umd/systemjs/none 等模块格式、--baseUrl 作为解析根、moduleResolution: classicesModuleInteropallowSyntheticDefaultImports 设为 falsealwaysStrictfalse--outFile、命名空间用旧关键字 module 写的语法、import assertions 的 asserts 写法(应改用 with)、no-default-lib 指令等。命令行在已有 tsconfig.json 的目录里若仍传文件列表,6.0 会报错,需加 --ignoreConfig 才能恢复"只编单个文件、忽略配置"的旧习惯。

细节与示例仍建议对照官方原文与后续发行说明。

对团队与编辑的实操建议

若你负责维护中大型仓库,比较稳妥的顺序是,先在分支上升级 6.0,打开完整类型检查与 CI,按报错逐项补 types、调整 rootDir 与路径映射,再视需要跑社区里的迁移辅助工具(文中提到的实验性 ts5to6 可处理部分 baseUrlrootDir 相关调整)。有声明文件快照测试或依赖联合类型顺序的库作者,可用 --stableTypeOrdering 与 7.0 预览做对照,避免把顺序噪声当成逻辑 bug。

对只关心应用交付的团队,优先确认 Node 版本、测试全局与构建脚本是否在升级后仍能通过,再安排一次集中处理弃用警告,避免卡在 7.0 正式落地的前一刻。

小结

TypeScript 6.0 在功能上仍有 Temporal、RegExp.escapegetOrInsert、子路径 #/ 等可感知的更新,但舆论与工程上的主轴很清楚,就是为 Go 重写、并行类型检查与更确定的编译器行为做准备。微软预计 7.0 在数月内趋于稳定,并已在内外部大型代码库上验证。对中文技术社区而言,这一版最值得传播的不只是特性列表,而是"默认更严、类型包显式化、旧模块体系退场"三条主线,它们会共同决定接下来一两年前端与 Node 工具链的升级节奏。

前端工程化 + AI 赋能,从需求到运维一条龙怎么搭 ❓❓❓

作者 Moment
2026年3月22日 19:01

大家好 👋,我是 Moment,目前正在使用 Next.js、NestJS、LangChain 开发 DocFlow。这是一个面向 AI 场景的协同文档平台,集成了基于 Tiptap 的富文本编辑、NestJS 后端服务、实时协作与智能化工作流等核心模块。

在这个项目的持续打磨过程中,我积累了不少实战经验,不只是 Tiptap 的深度定制、编辑器性能优化和协同方案设计,也包括前端工程化建设、React 源码理解以及复杂项目架构实践。

如果你对 AI 全栈开发、文档编辑器、前端工程化或者 React 源码相关内容感兴趣,欢迎添加我的微信 yunmz777 一起交流。觉得项目还不错的话,也欢迎给 DocFlow 点个 star ⭐

image.png

企业级前端工程化的本质,是把"人肉重复、靠经验兜底"的开发方式,收敛成可复用、可度量、可演进的一套体系。从零搭建前端时,先想清楚要解决什么、要什么结果,再选工具和流程,会少走很多弯路。

工程化主要针对三类问题:

image.png

把这三块从"人治"变成"机制",工程化才算真正落地。

落到团队层面,能带来几件事。流程上,标准化、能自动化的尽量自动化,关键环节可以借 AI 提效,结果上,开发成本下来、迭代速度上去,代码质量和可维护性提高,bug 和线上风险更容易被提前拦住。这些都不是单点工具能完成的,需要从需求到上线的整条链路一起设计。

接下来我们按常见阶段展开,依次是需求与规范、开发与联调、测试与优化、构建与部署、运维与监控。每个阶段会写目标、推荐流程、常用工具、典型场景,以及适合用 AI 或自动化做得更好的地方。

前端工程化总览

整条链路可以概括为五个阶段,从需求规范到运维监控依次串联。整体追求三个结果,稳(高可用、可回滚)、快(敏捷交付、自动化流水线)、省(低成本工具链、资源复用),下面用一张流程图把阶段关系画清楚。

20260225100028

各阶段侧重不同。需求规范阶段重在建立统一标准、预防潜在风险、提升协作效率,常见动作包括需求与接口规范、文档沉淀与知识库、以及用 AI 做文档自动化。开发联调阶段和测试优化阶段共同指向高效协作、减少阻塞、保障代码质量,前者覆盖基础框架与脚手架、组件与物料库管理、工程化工具链、前后端接口联调与 Mock,后者覆盖单元与 E2E 自动化测试、性能与体积优化、合规与安全扫描、埋点与数据上报。构建部署阶段和运维监控阶段则共同强调高效交付、稳定发布、灵活回滚,构建部署侧重构建与打包优化、CI/CD 部署方案、灰度发布与一键回滚,运维监控侧重性能与可用性监控、异常与错误追踪、用户行为与转化分析、大屏可视化与告警,目标是实时感知风险、快速定位原因、持续优化体验。

下图是同一套阶段与目标的示意,便于对照查阅。

image.png

需求规范阶段

需求规范阶段是整条链路的起点。先把这一步打牢,后面的开发联调、测试优化才不会一路踩坑。这里要做的事,本质上是把团队里各自为战的习惯和经验,沉淀成一套大家都认可的统一标准,既预防潜在风险,又减少日常协作里的摩擦。为了方便梳理,可以把这一阶段拆成三块,对应代码与接口、文档与知识库,以及用 AI 做文档自动化。

下图是这一阶段的手绘示意,可以当作后文三小节的导航来对照着看。

image.png

需求与接口规范

落到开发这侧,最直观的感受就是,大家写出来的代码和提交流程要像是一个团队,而不是各写各的。第一步是把代码规范和协作流程统一,用一套约定来消除协作摩擦。代码这一块,可以用 ESLintPrettier 配合 Husky 去强制约束代码风格,缩进、命名这些细节交给工具,提交前自动跑一遍,不通过就推不上去,讨论就能更多回到设计和实现本身。

协作流程方面,建议一开始就说清楚 Git 分支策略(例如简化版 Git Flow)和 Commit 信息格式,例如用 Commitizen 这样的工具来规范提交说明。久而久之,提交历史会变成一本可以查账的项目日记,谁在什么时间、因为什么调整了哪些代码,一目了然。

这里有两类问题,最好在一开始就通过规范挡住。一类是随手写 fix bugupdate 这类没有信息量的提交信息,事后谁也看不出当时改动的动机。另一类是没经过 Code Review 就把改动直接合进主分支,质量风险一路带到线上。有些团队会要求,所有人都基于 master 拉分支开发,在 testuatrelease 这些共享环境分支以及 master 上都禁止直接 push,只能通过合并请求进入,这样一旦出问题,也能顺着合并记录快速定位到具体改动。

文档沉淀与知识库

文档沉淀这块,目标是打破信息孤岛,让新人靠看文档也能尽量还原当时的需求背景和取舍过程。需求如果只散落在聊天记录里,过一阵子连原作者自己都很难说清楚当时为什么要这么定。比较实用的做法,是用语雀、飞书文档把业务需求拆成技术方案,把功能边界和验收标准写清楚,再准备一套固定的需求文档模板,背景、原型、接口定义这些模块都预留好位置,后面类似需求直接套用,既省事又不容易漏。

接口和设计的配合,同样可以通过工具来固化。可以用 Apifox 维护接口文档,后端接口还没完全就绪时,前端先基于 Mock 数据开发,不必干等。与此同时,联动 Figma、即时设计这类工具里的设计稿标注,让 API 与设计稿保持同步,很多本来要靠口头解释的细节,直接在文档和设计稿里就能对齐。

AI 赋能文档自动化

如果完全手写,一份中等复杂度的技术文档,往往要花上两到四个小时,写着写着还容易走神。现在可以把这种重复性工作交给 AI。例如用 Writely(飞书 AI),输入 PRD 里的关键词(例如"用户管理系统"),让它先生成一份大致合理的技术文档目录和示例代码片段,你再根据实际业务补充细节、删掉不适用的部分。

实际体验下来,传统纯手写从零到一可能要两到四个小时,而让 AI 先搭好骨架、再人工完善,大多数情况下半小时左右就能收工。这样的方式尤其适合需求说明、接口说明、技术方案骨架这类重复度很高的文档,一方面整体结构更统一,另一方面也把时间留给那些必须由人来判断的业务决策和权衡。

开发联调阶段

开发联调阶段是前端工程化真正动手写代码、跑起来的那一段,目标很清晰,就是高效协作、减少阻塞、保障代码质量,让前后端和设计之间尽量无缝衔接。下面按基础框架、物料复用、工程化流水线、前后端协作四块来说,最后补几条联调时容易踩的坑。

image.png

基础框架搭建

框架选型决定了团队未来几年的技术底座,选好了能少踩很多坑。轻量一点、迭代快的项目,可以用 Vue 3ReactVite,开发体验好、上手也快,Vite 后续会集成 Rust 实现的 Rolldown,生产构建会更快。业务比较复杂、偏中后台或需要 SSR 的,可以看 Next.jsRsbuild 等,Next.js 开发环境已支持 Turbopack,大仓冷启和 HMR 更猛。超大体量或需要兼容现有 Webpack 生态的,可以看 Rust 系的 Rspack。运行时除了 Node.js,也可按需选 Bun 做脚本和工具链。要是还有小程序、H5 等多端需求,可以看 TaroUni-App 这类跨端方案,一套代码多端跑。

选完框架,最好再准备一套模板仓库,新项目直接基于模板拉,而不是每次从零配。例如预置好 ESLintPrettier 的脚手架,或者用 Next.jsRsbuild 等自带的脚手架快速生成项目,再按需加权限、数据流等。如果团队里会有多个应用、共享组件库或公共包需要一起维护,可以提前考虑是否采用 Monorepo 架构(例如用 pnpm workspace、TurborepoNx 等),把多包放在一个仓库里统一依赖和构建,能减少后期拆仓、版本对齐的折腾。这一步也可以交给 AI 省时间,例如在 Cursor 里输入"创建 NextJs + TypeScript 项目",让它生成基础配置。

物料库管理

组件、工具函数、页面模板如果能复用,重复开发会少很多。有条件的团队会做企业自研组件库,常见两条路。一条是在 Ant DesignElement Plus 这类开源组件库上做二次封装,贴合自家业务和设计规范,再用 Bit 这类工具管理组件版本和依赖,甚至支持私有化部署。

另一条是,如果团队已经在用 Tailwind CSS,并且用过 shadcn/ui 这类"拷贝即用"的组件方案,可以在现有基础上做二开,例如统一品牌色、间距和圆角等设计 token,把常用变体收拢成团队约定,再补一份内部文档(哪些组件可直接用、哪些改过、使用示例和注意事项),这样既保留 Tailwind 的灵活度,又有一致的设计和可维护的物料沉淀。Tailwind CSS v4 已发布,构建更快、配置更简单,新项目可以直接上 v4。工具函数这块,用 lodashdayjs 等成熟库即可,不必自己造。

AI 在这块也能帮上忙。例如即时 AI 可以把 Figma 设计稿转成 VueReact 组件代码,减少从设计到代码的重复劳动。CodeGeeX 可以根据组件的 Props 描述自动生成单元测试用例。当然,小团队或小公司不一定要自建组件库和物料体系,先把业务跑稳、再按需沉淀组件和模板,会更现实。

工程化系统

工程化系统说白了就是通过工具链把创建项目、检查、构建、部署串成一条流水线,减少人工操作。创建项目阶段,现在普遍用 Vite 创建 VueReact 项目(create-vite 或各框架官方模板),或用 Next.js 自带的脚手架起手,预置好规范与配置即可。到了持续集成和部署,可以用 GitHub ActionsGitLab CI 在提交后自动跑代码检查、构建和部署,或者用 Jenkins 做更复杂的多环境流水线。如果希望需求、开发、部署都在一个平台里完成,可以选阿里云效这类一站式 DevOps 平台,功能全、上手相对简单,也支持私有化部署,不少团队的实际项目就是用云效搭的流水线。

前后端协作

前后端联调最容易出问题的地方,往往是接口约定不一致、文档滞后、环境对不齐。接口文档建议用 ApifoxApidog 这类工具维护,支持 OpenAPI 规范、自动 Mock 和接口测试。很多平台还能根据接口文档自动生成前端的请求代码和 TypeScript 类型,文档一改、类型跟着变,减少手写接口定义。后端接口还没好时,前端可以先用 Mock.jsFaker.js 生成贴近真实的测试数据,或者用 MSW(Mock Service Worker)在浏览器层做请求拦截,配合 TypeScript 做类型安全的 Mock,适合单测和本地联调。全栈都是 TypeScript 的项目,还可以考虑 tRPC 或更轻量的 Hono RPC,前后端共享类型定义,服务端改接口、客户端立刻有类型提示,无需单独维护一份接口文档和类型。Hono RPChc 客户端加 Zod 校验即可实现类型安全,适合前后端同仓或协作紧密的团队。

当接口多了、前端需要聚合多个接口或按需拉字段时,可以加一层 BFF(Backend For Frontend),用 Node.js 中间层(例如 NestJSMidway.jsExpress)聚合多接口,或者用 GraphQL(如 Apollo Server)让前端按需定制响应字段。BFF 可以由后端团队维护,也可以由前端团队自建,实现真正的接口层解耦。

接口文档若能通过统一协议进到开发环境里,前后端对接会轻松很多。可以把后端的 OpenAPI 规范用 MCP(Model Context Protocol)暴露出来,例如用 OpenAPI MCP Server 把接口定义转成 MCP 的 tools、resources,在 Cursor、VS Code 等 IDE 里配置好 MCP 后,就能在写代码时直接读到最新接口文档、让 AI 按文档生成请求代码或类型,避免文档和实现脱节。

阿里云、腾讯云等也有 OpenAPI MCP Server,适合把云产品 API 接到 IDE。自建后端可以用 @reapi/mcp-openapi、FastMCP 的 from_openapi() 等从 OpenAPI 规范生成 MCP 服务,前后端共用同一份文档,联调时接口变更能更快同步到前端。

AI 也能参与进来,例如 ApifoxAI 可以根据接口文档自动生成 Mock 规则和测试用例,CodeGeeX 可以根据现有 RESTful 接口生成一层 GraphQL 包装代码,减少手写胶水代码。

联调时还有几点值得注意。一是接口变更要及时同步,用 Apifox 这类工具把最新接口定义推给前端,或通过 OpenAPI 自动生成类型,避免文档和实现各说各的。二是开发、测试、生产环境要隔离,用 .env.development.env.production 等把配置拆开,别在本地写死生产地址。三是依赖版本要锁死,用 pnpm 等包管理器严格锁定依赖,能少很多"在我机器上是好的"这类问题。

测试优化阶段

测试优化阶段的目标很明确,就是提前暴露风险、保障线上稳定、提升用户体验,用分层测试把核心场景兜住,减少漏测和线上事故。从人工点点点到自动化、再配合 AI 生成用例,测试效率会明显上去。

20260226091000

自动化测试

建议按单元测试、E2E、视觉回归三层建体系,而不是一上来就全押 E2E。单元测试负责验证组件逻辑和工具函数,用 JestVitestReact Testing Library 即可,VitestVite 同源、冷启和 HMR 更快,适合在每次提交时跑。组件层若要在真实浏览器里跑,可用 Vitest 的 Browser Mode 配 Playwright 驱动。例如下面这段,用 render 渲染按钮组件、screen.getByRole 找到按钮并模拟点击,再断言传入的 onClick 被调用了一次,用来保证点击回调不会丢。

test("Button 点击触发事件", async () => {
  const handleClick = vi.fn<[], void>();
  render(<MyButton onClick={handleClick} />);
  await userEvent.click(screen.getByRole("button"));
  expect(handleClick).toHaveBeenCalledTimes(1);
});

E2E 测试覆盖真实用户路径,在浏览器里跑完整流程。Playwright 支持 Chromium、WebKit、Firefox 多端,自带录制回放,适合做跨浏览器回归。Cypress 的可视化调试和时间旅行对复杂交互(例如购物车、多步表单)很友好,按团队习惯二选一或搭配用即可。

视觉回归测试解决的是"功能没坏、但界面悄悄变了"的问题。改了一处样式或依赖升级导致组件渲染异常,单测和 E2E 不一定能发现,视觉回归通过对比页面或组件的截图,先拍一份基准快照,后续每次跑用例时再拍一张,和基准做像素或区域对比,有差异就报出来,由人确认是预期改动还是误伤。可以用 BackstopJS 在本地或 CI 里跑,配置好要截的 URL 或组件,生成基准后纳入版本管理,以后每次 PR 自动跑一遍对比。组件库或设计系统也可以用 ChromaticPercy 这类托管服务,和 Storybook 结合,每个 Story 自动做视觉回归。适合对 UI 稳定性要求高的首页、关键流程页和公共组件,基准图多了之后要注意维护,避免无关改动带来大量噪点。

AI 也能参与测试用例的生成和验证。一类是依据行为数据生成脚本,例如 Testin AI 分析用户行为日志,把高频操作转成 E2E 用例,先覆盖核心路径再补边缘场景。另一类是让 AI 直接连上真实浏览器做调试和验证,例如 Chrome 官方的 Chrome DevTools MCP,在 Cursor、Claude 等里配置好 MCP 后,AI 可以调 DevTools 能力做性能追踪、网络与 Console 排查、DOM 与样式检查、表单与用户行为模拟,并在浏览器里实时验证改动的效果,相当于"边写代码边在真机里跑一遍"。和 Playwright MCP 搭配时,Playwright 负责 UI 自动化与用例执行,DevTools MCP 补足性能与运行时观测,适合做智能回归和 Core Web Vitals 等自动化检查。

性能优化

测试通过之后,还要保证页面秒开、交互不卡。可以给自己定一个简单目标,例如首屏可交互 FCP 控制在 1.5 秒内、首次输入延迟 FID 在 100ms 以内。性能检测方面,用 Lighthouse CI 把跑分集成进 CI 流水线,分数低于阈值(例如 90)就拦掉合并,避免性能劣化代码进主干。真实用户数据用 Google Analytics 4 或阿里云 ARMS 采集 Web Vitals,看线上实际表现而不是只看本机。

优化手段按资源、代码、分发来拆。资源上,构建阶段自动压缩图片,例如用 vite-plugin-imagemin 在打包时处理;代码上,用 React.lazySuspense 做路由级懒加载,首屏只拉当前路由需要的 chunk。分发上,静态资源扔到阿里云 OSS 再挂 CDN,利用全球节点做加速。AI 也能参与,例如阿里云 ARMS 的智能诊断会根据性能数据推荐优化项(如未压缩图片列表),部分构建工具已支持基于预测的 Tree Shaking 策略,进一步剔除无效代码。

合规与安全

合规与安全要从代码和数据两头抓,避免法律风险和用户隐私问题。代码侧,用 SonarQube 做静态扫描,揪出 XSS、SQL 注入等常见漏洞。依赖侧,用阿里云安全中心等扫描已知漏洞(例如 Log4j、老旧版本的 lodash),有风险就升级或替换。隐私合规方面,用腾讯云合规助手这类工具检查隐私政策是否满足 GDPR、个保法等要求。日志里对手机号、身份证号等做脱敏,例如通过 log4js 等插件的过滤规则自动打码,避免敏感数据落盘。

AI 可以辅助安全扫描,例如用大模型扫描代码里的敏感信息(如硬编码的 API 密钥)。部分 AI 代码助手能自动把不安全写法替换成更安全的实现(如将 eval 改为 Function),适合在 Code Review 前跑一遍。

数据埋点

埋点做得好,产品迭代才有数据支撑,否则容易变成"盲人摸象"。埋点大致分无埋点和自定义埋点。若注重隐私或希望数据自托管,可以用 Umami 这类开源方案,无 Cookie、符合 GDPR,脚本轻量(约 2KB),支持页面浏览与自定义事件,可 Docker 自建或使用官方云,适合中小站点和不想依赖第三方统计的场景。

无埋点还可用 GrowingIO 等方案自动采集页面点击、曝光等事件,接入简单、覆盖面大。自定义埋点用神策数据等 SDK 在关键行为(如按钮点击、表单提交)上手动上报,灵活、可针对业务做分析。数据进来之后,用 Metabase 这类开源 BI 做 SQL 自助分析,或用阿里 DataV 做大屏展示核心指标(如 DAU、转化率)。AI 也能参与,例如 GrowingIO 的智能推荐会根据用户路径建议高价值埋点事件,神策的聚类分析能自动识别用户分群(如高流失风险用户),方便做精细化运营。

测试与优化阶段还有几点容易踩坑。一是别盲目追求 100% 测试覆盖率,优先把核心链路(登录、支付等)兜住,再按需补边缘场景。二是性能优化别撒胡椒面,内部管理后台等低频页面不必死磕,把资源留给用户高频访问的页面。三是埋点必须拿到用户授权,禁止收集设备 ID、IMEI 等敏感信息,否则会踩数据隐私的雷。

构建部署阶段

构建与部署阶段是前端工程化的交付出口,目标是高效交付、稳定发布、灵活回滚,让代码从开发环境到生产环境顺畅流转。下面按构建优化、部署方案、灰度与回滚三块说。

20260226094131

构建优化

构建工具在技术选型阶段通常已经定好了(例如 ViteWebpack 5RspackNext.js),这里侧重在既定工具上的优化策略。Vite 新版本已接入 Rust 实现的 Rolldown 做生产打包,构建耗时明显下降。选 Next.js 的可以用 Turbopack 做开发和生产构建,冷启和增量构建更快。Rspack 等 Rust 系方案在大仓下同样有优势。

优化时先把 Tree Shaking 开满,在库和业务里合理配置 sideEffects: false,让打包器删掉未引用代码。代码拆分用动态 importReact.lazy 把非首屏做成按需加载,再用 manualChunks 把大依赖(如 monaco-editor、图表库)单独拆包,避免首屏 chunk 过大。产物体积可用 rollup-plugin-visualizervite-plugin-perfsee 做分析,一眼看出谁在占空间。线上传输用 vite-plugin-compression 做 Gzip 或 Brotli,Nginx 侧开 gzip_static 即可。

部分构建工具已支持基于 AI 的智能缓存和构建日志分析,自动推荐合并重复 Chunk、优化依赖顺序等,可在 CI 里跑一遍看报告。

部署方案

部署从手动发包走向一键发布、多环境隔离,才能做到分钟级回滚。静态资源托管最常见,用阿里云 OSS 挂 CDN 按量付费、支持缓存刷新,或选托管平台:Vercel 和 Git 深度集成、推分支即发布,适合 Next.js。Cloudflare Pages 边缘节点多、免费带宽大,已支持 Docker 和 @opennextjs/cloudflare 跑 Next,还有 Workers AI 做边缘推理。Netlify 在组合式架构和 CMS 集成上比较顺手。需要极快 git 部署、少建站过程的可以看 Deno Deploy,代码直传边缘、无需拉机子做长构建,适合接口或中间层。

需要跑 Node 或做 SSR 的,用 Docker 多阶段构建把镜像压到几十 MB,再配合 Kubernetes(如阿里云 ACK)做集群。不想管机器的用 Serverless,阿里云函数计算、Vercel Edge Functions 等按需执行、边缘就近跑。

AI 也能参与,例如 GitLab Code Suggestions 可根据项目生成 DockerfileCI 脚本,观测云等能根据资源负载推荐扩缩容策略。

灰度与回滚

发布要可控,灰度把风险压到最小,回滚要能快速切回去。灰度本质是流量逐步切到新版本,常见做法有 Nginx 按 IP 或 Cookie 分流,先给 5%~10% 用户上新版,观察一段时间再放量。阿里云 EDAS 支持全链路灰度,应用和数据库都能隔离。云原生 API 网关也支持蓝绿、金丝雀发布,按比例或规则切流量。除了流量灰度,还可以用特性开关(Feature Flags),在代码里用开关控制功能是否露出,用 ConfigCat、LaunchDarkly 等或自研,发版和上线解耦,随时可关。

灰度期间要有可观测,接 Prometheus、Grafana 或现有监控,盯错误率、响应时间,一旦超阈值(例如错误率 >0.5%)自动回滚或告警。回滚要提前准备好,在 GitLab CI 或 GitHub Actions 里做基于版本 Tag 的回滚脚本,出问题一键切回上一版。静态资源用 OSS 版本控制保留历史,回滚时改 CDN 回源即可。

AI 也能参与,例如阿里云 AHAS 可根据历史流量推荐灰度比例,Sentry 等可在错误率突增时自动触发回滚或通知,减少人工判断时间。

运维监控阶段

运维与监控是前端工程化的最后一道防线,目标是实时感知风险、快速定位原因、持续优化体验,让线上系统稳定、用户行为可观测。下面按性能监控、异常监控、用户行为分析、可视化与告警四块说,最后补一版低成本与大型企业的工具链参考,以及几条容易踩的坑。

20260226095212

性能监控

性能监控要保障 Web Vitals 等核心体验指标达标,并持续发现瓶颈。核心指标用 Google Analytics 4 或阿里云 ARMS 等采集真实用户数据(RUM),关注 LCP(最大内容绘制)、INP(交互到下一帧,已逐步替代 FID)、CLS(累计布局偏移)等,可配合 web-vitals 库在端上采集后上报。除了平台自动采集,关键链路可以加自定义性能埋点,例如在页面加载完成后取 performance.timing 算出加载耗时并上报,方便按页面或版本对比。下面示例在 load 事件后计算从导航开始到加载结束的耗时,并通过自有 SDK 上报,用于做首屏性能趋势分析。

const timing: PerformanceTiming = performance.timing;
const loadTime: number = timing.loadEventEnd - timing.navigationStart;
SDK.report({ type: "page_load", duration: loadTime });

资源侧可以看 CDN 日志分析请求成功率、缓存命中率(如阿里云 CDN)。接口耗时用 SkyWalking、Zipkin 或 OpenTelemetry 做链路追踪,约定 P99 等目标(例如 500ms 以内)。Sentry 等已支持与 OpenTelemetry 对接,前端错误和接口链路可以串成一条 trace,排查时从页面一路跟到后端。AI 也能参与,例如阿里云 ARMS 智能诊断会关联 JS 错误与接口超时,给出根因建议。New Relic 等可根据历史数据预测流量峰值,辅助提前扩容。

异常监控

异常监控要争取分钟级发现线上问题,把 MTTR(平均修复时间)压下去。错误追踪用 Sentry 捕获前端 JS 错误、自动聚合相似问题,并支持 SourceMap 解析还原源码位置。国内团队也可以用支持微信、钉钉实时告警的国产方案,和现有协作习惯对齐。日志分析用阿里云 SLS 做 Nginx 访问日志的实时分析,快速发现 5xx 突增等异常。自建可选 Loki 配 Grafana,资源占用比传统 ELK 小,用 LogQL 查"近 1 小时 404 TOP10"这类问题很顺手。

AI 可以辅助降噪和归因,例如 Sentry 的智能聚类能把大量错误归成少量根因(如未捕获的 TypeError)。基于 Elasticsearch Machine Learning 或类似能力可以做日志模式异常检测,例如发现突然出现大量非常规 UA 或异常请求路径,提前发现爬虫或攻击。

用户行为分析

用户行为数据用来驱动产品优化和转化率提升。无埋点用 GrowingIO 等自动采集页面点击、跳转、停留时长,并生成热力图。自定义分析用神策等做事件与漏斗(如注册流程各步转化)。关键业务节点需要自定义埋点时,在按钮或流程节点上打点上报事件和业务参数,例如下单按钮点击时上报商品 ID 和价格,便于后续做转化和营收分析。下面示例在购买按钮点击时上报事件名和业务字段,接入方替换成实际 SDK 即可。

document.getElementById("buy-button")?.addEventListener("click", () => {
  SDK.track("purchase_click", { product_id: "123", price: 299 });
});

AI 能参与分析结论的产出,例如神策的智能路径分析用户流失点并给出优化建议,GrowingIO 可根据行为聚类生成推荐或运营策略参数。

可视化与告警

监控数据要通过大屏和告警变成可执行的决策。可视化用 Grafana 做自定义监控面板,或用阿里云 DataV 搭实时运维大屏。告警用 Prometheus 配 Alertmanager 配置阈值(如 CPU 使用率 >80% 持续 5 分钟),告警事件通过钉钉、飞书机器人推到协作群,并支持 @ 负责人。AI 可以用于智能阈值和降噪,例如根据历史数据动态计算合理阈值(如凌晨自动放宽延迟阈值),或把重复告警合并成一条,减少告警风暴。

工具链参考

中小团队、预算有限时,可以组合:监控用 Prometheus 自建 + Grafana,告警接微信或钉钉。日志用 Loki 替代 ELK,资源消耗更低。再搭配阿里云 ARMS 免费版做基础性能分析、或开源组件的异常检测能力,整体月成本可控。对高可用要求高、数据量大的团队,可以用阿里云 ARMS 做全链路、SLS 做 PB 级日志,配合 DataV 大屏和自研或第三方 AI 分析平台。

运维监控还有几点要注意。一是避免过度监控,只采核心业务相关指标,否则存储和告警成本都会上去。二是告警要设静默期,同一类告警在 30 分钟内不重复推送,减少告警疲劳。三是日志必须脱敏,避免原始敏感数据泄露。

❌
❌