阅读视图

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

elpis 全栈里程碑一总结

elpis 里程碑一总结

1.项目架构设计

image.png

1-1.聚焦buff层

buff分三个层级:

  1. 接入层: router接口路由分发, router-schema路由规则校验 ,middleware路由中间件
  2. 业务层: controller处理器, env环境分发,config提取,extend服务拓展,schedule定时任务
  3. 服务层: service处理器

1-2.而elpis-core就是基于buff层设计的用node.js+koa

2.对于elpis-core的理解

graph TD
运行前磁盘文件 --> 解析器  -->  运行时内存

1.elpis-core是一个自动把文件挂载到koa实例上的一个引擎,也是一个轻量版的egg.js内核,他的设计理念是约定式加载,通过写好的各种loader,从预先约定好的目录结构中读取各种js 文件,按照一定的顺序挂载到由koa 创建的app 实例上面,可通过app.midddleware.${目录}.${文件}访问

  • 例如:
 *   app/service
 *     |
 *     |-- custom-module
 *          |
 *          |-- custom-service.js
 *  => app.service.customModule.customservice

3.elpis-core的结构

loader 说明
server.js 业务模块的自动加载
extend.js 自动加载扩展,例:外部日志工具log4js
router.js 引入了一个Koa挂载 extend 到 app 上,这个extend可以用来引入日志工具,先把所有 app/router的文件加载到KoaRouter 下,再将路由注册到 app下
routerSchema.js 对应router的参数的一个具体校验解释文件,
controller.js 控制器自动加载
config.js 配置区分 本地/测试/生产环境,通过env环境读取不同文件配置 env.config,然后通过 env.config 覆盖 default.config 加载到 app.config 中
middleware.js 引入自定义中间件loader,如模板渲染中间件等,让中间件自动加载

4.koa内部模型(洋葱圈模型)

image.png

什么是洋葱圈模型? 中间件执行流程的形象比喻,通过 next() 让代码先一层层进入,再一层层退出,像切开的洋葱一样。
为什么是先进后出? 因为 next() 会暂停当前函数,调用下一个函数,这符合调用栈的后进先出(LIFO) 特性。
有什么好处? 每个中间件可以在请求前响应后都执行逻辑,实现对称处理,非常适合日志、认证、错误处理等场景。

5.相关中间件

中间件 说明
koa-static 解决静态资源的加载,可以在app/public目录下自动加载相关的静态资源,如:css、png等。
koa-nunjucks-2 用于服务器端渲染 HTML(SSR), 全局中间件中引入了koa-nunjucks-2,挂载到了ctx上,从而使得ctx上有render方法
koa-bodyparser 用于解析 HTTP 请求体,并将数据挂载到 ctx.request.body ,因为Koa 默认无法直接获取请求体中的 body 数据。
log4js 为日志工具属于Extend,通过 app.logger.info 记录日志并落地磁盘。
api-params-verify 参数校验基于 JSON-Schema 和 Ajv,配合中间件使用,确保接口数据安全。
api-sign-verify 接口签名防止数据篡改。前后端约定 Key,通过 MD5(参数+Key) 校验合法性。
error-handler 对一些异常的报错进行处理,避免用户请求服务出问题,返回一些不必要的内容

6.SSR

BFF部署在服务器内网,向后端多个服务发起请求延迟极低,甚至可以并行请求。这远比在浏览器端一个一个请求后端API要快得多,所以BFF层提供了一个完美的地方来做SSR所需要的数据准备工作

1.当前项目提供SSR,在router中调用controller中方法,在controller中写了renderPage方法,因为引入了koaNunjucks,在ctx中可以拿到render方法,所以可以直接在浏览器中输入路由地址渲染出所对应的页面

//middleware
module.exports = (app)=>{

    // 配置静态根目录
    const koaStatic = require('koa-static');
    app.use(koaStatic(path.resolve(process.cwd(), `./app/public`)));
    
        // 模板渲染引擎
        const koaNunjucks = require('koa-nunjucks-2');
        app.use(koaNunjucks({
            ext: 'tpl',
            path: path.resolve(process.cwd(), `./app/public`),
            nunjucksConfig: {
                noCache:true,
                trimBlocks: true
            }
        }));
}
//controller
module.exports = (app) => { 
    return class ViewController {
        /**
         * 渲染页面
         * @param {*} ctx 上下文
         */
        async renderPage(ctx){
            await ctx.render(`dist/entry.${ctx.params.page}`,{
                name:app.option?.name,
                env:app.env.get(),
                option:JSON.stringify(app.option),
            });
        }
    }
}
//router
module.exports = (app,router) => { 
    const {view: ViewController } = app.controller;
    // 用户输入http://ip:
    router.get('/view/:page',ViewController.renderPage.bind(ViewController))
}

7.为何 Controller 和 Service 要用class?

在 Elpis-Core 中,Controller 和 Service 被设计为 Class(类) ,这带来了极高的可扩展性。

  • 复用性:可以定义一个 BaseController(基类),封装通用方法(如 success 成功返回、fail 错误返回)。
  • 继承性:业务 Controller(如 ProjectController)继承基类,直接复用公共方法。 总结: BFF层级(后端)其实主要由解析引擎以及业务模块两大板块组成

8.总结

解析引擎elpis-core的主要作用是将各个业务模块聚合在一起并保证其在程序内存中正常运行。这也是例如Eggjs、Nextjs、Nestjs等框架做的事情,也是这些框架设计背后的思想

node服务器是什么

Node 服务器是什么

核心概念

Node.js 基础

txt
Node.js = JavaScript 运行环境 + 服务端能力

通俗理解

  • JavaScript 本来只能在浏览器里运行
  • Node.js 让 JavaScript 可以在服务器(你的电脑)上运行
  • Node 服务器 就是用 Node.js 编写的 HTTP 服务器程序

类比理解

传统认知

txt
服务器 = 一台远程电脑 + Apache/Nginx 软件
         ↓
       处理网页请求

Node 服务器

txt
Node 服务器 = 你的电脑 + Node.js 程序
              ↓
            处理网页请求

最简单的 Node 服务器代码

javascript
// server.js
const http = require('http');
// 创建服务器
const server = http.createServer((req, res) => {
  // 有人访问时的处理逻辑
  res.writeHead(200, { 'Content-Type''text/html' });
  res.end('<h1>Hello World!</h1>');
});
// 监听 3000 端口
server.listen(3000() => {
  console.log('服务器运行在 http://localhost:3000');
});

执行

bash
$ node server.js
服务器运行在 http://localhost:3000
# 浏览器访问 localhost:3000 就能看到 "Hello World!"

Node 服务器 vs 传统服务器

特性 传统服务器 (Apache/Nginx) Node 服务器
编程语言 C/C++ 编写 JavaScript 编写
配置方式 配置文件 (.conf) 代码控制
动态功能 需要 PHP/Python 等后端语言 JavaScript 一站式
适用场景 静态文件托管、反向代理 全栈开发、实时应用

什么是node.js 小白也能看明白

Node.js = JavaScript 运行环境

核心概念:什么是"运行环境"

类比 1:人类语言

txt
中文        需要      中国人/翻译官      才能理解
JavaScript  需要      运行环境          才能执行

类比 2:播放器

txt
MP4 视频文件  →  需要视频播放器  →  才能播放
.js 代码文件  →  需要 JS 运行环境  →  才能执行

JavaScript 的两个运行环境

1. 浏览器(传统环境)

javascript
// test.js
console.log('Hello');
alert('弹窗');
document.body.style.color = 'red';

怎么运行

html
<script src="test.js"></script>

浏览器提供的能力

  • window 对象
  • document 对象(操作页面)
  • alert() 函数
  • fetch() 网络请求
  • localStorage 本地存储

2. Node.js(服务端环境)

javascript
// test.js
console.log('Hello');
const fs = require('fs'); // 读写文件
const http = require('http'); // 创建服务器

怎么运行

bash
$ node test.js
Hello

Node.js 提供的能力

  • fs 模块(读写文件)
  • http 模块(网络服务)
  • path 模块(路径处理)
  • process 对象(进程信息)
  • 访问操作系统 API

对比理解

特性 浏览器环境 Node.js 环境
执行方式 嵌入 HTML 中 命令行执行 node xxx.js
核心能力 操作网页 DOM 操作文件系统、网络
全局对象 window global
用途 前端开发 后端开发、工具开发
典型 API alert(), document fs.readFile(), http.createServer()

深入理解:Node.js 的组成

txt
Node.js = V8 引擎 + C++ 扩展库 + JavaScript 标准库

1. V8 引擎(核心)

  • Google 开发的 JavaScript 引擎
  • 将 JS 代码编译成机器码执行
  • 浏览器 Chrome 也用 V8

2. C++ 扩展库

  • 文件系统操作(libuv)
  • 网络通信
  • 加密/解密
  • 压缩/解压

3. JavaScript 标准库

javascript
const fs = require('fs');      // 文件系统
const http = require('http');  // HTTP 服务
const path = require('path');  // 路径处理

为什么 JavaScript 需要"运行环境"

JavaScript 特点

javascript
// JavaScript 本身只是"语法规则"
let a = 1;
function add(x, y) { return x + y; }

JavaScript 不能直接

  • ❌ 读写文件
  • ❌ 创建服务器
  • ❌ 操作网页
  • ❌ 访问数据库

运行环境提供"超能力"

javascript
// 浏览器环境提供的能力
document.getElementById('app'); // ← document 是浏览器给的
// Node.js 环境提供的能力
require('fs').readFileSync('file.txt'); // ← fs 是 Node.js 给的

实际例子

代码:same.js

javascript
console.log('当前时间:'new Date());
console.log('1 + 1 ='1 + 1);

在浏览器运行

html
<script src="same.js"></script>

输出到浏览器控制台 ✅

在 Node.js 运行

bash
$ node same.js

输出到终端命令行 ✅


代码:browser-only.js

javascript
alert('Hello')// ← 浏览器专属 API

在浏览器运行:✅ 弹窗
在 Node.js 运行:❌ 报错 alert is not defined


代码:node-only.js

javascript
const fs = require('fs'); // ← Node.js 专属 API
console.log(fs.readFileSync('test.txt''utf-8'));

在 Node.js 运行:✅ 输出文件内容
在浏览器运行:❌ 报错 require is not defined

Node.js 软件的安装

bash
# 查看是否安装
$ node -v
v18.17.0
# 查看安装路径

Node.js 就是一个可执行程序

txt
/usr/local/bin/node  ← 这是一个软件
    ↓
类似于:
/Applications/Chrome.app
/Applications/VSCode.app

核心总结

概念 解释
JavaScript 编程语言(只是语法规则)
运行环境 能够"执行"JS 代码的程序
Node.js 一个可以在服务器/电脑上运行 JS 的软件
npm start 用 Node.js 执行 ice.js 工具

一句话理解

Node.js 就像是一个"翻译官",它能读懂 JavaScript 代码,并把代码变成计算机能执行的指令。

txt
你写的 JS 代码(人类语言)
        ↓
    Node.js(翻译官)
        ↓
  机器码(计算机语言)

没有 Node.js,你的 .js 文件就只是一个文本文件,无法运行。
就像没有视频播放器,你的 .mp4 文件只能看文件图标,无法播放一样。

Node.js 如何判断入口文件:从 require.main 到 ES Module 实现

目标:判断当前文件是否被直接执行(而不是被 import)


一、CommonJS(对照)

if (require.main === module) {
  main();
}

二、ES Module 写法(核心)

import { fileURLToPath } from 'url';

if (process.argv[1] === fileURLToPath(import.meta.url)) {
  main();
}

三、关键点(只记这 3 个)

  • import.meta.url → 当前文件(URL)
  • fileURLToPath() → 转为本地路径
  • process.argv[1] → 启动入口文件路径

👉 判断本质:
入口路径 === 当前文件路径

四、推荐封装

import { fileURLToPath } from 'url';

export const isMain = (meta) => process.argv[1] === fileURLToPath(meta.url);

// 使用:
if (isMain(import.meta)) {
  main();
}

五、关键技术点拆解(用于深入理解)

1️⃣ import.meta.url

返回当前模块的 URL,例如:

file:///Users/demo/index.js

2️⃣ fileURLToPath

file:// URL 转换为本地路径:

/Users/demo/index.js

3️⃣ process.argv[1]

Node 启动时的入口文件路径:

node index.js

# 得到:/Users/demo/index.js

React Dev Inspector 架构深度解析:从浏览器到 IDE 的链路之旅

引言:点击页面元素,IDE 自动打开源码——这背后发生了什么?

想象一下:你在浏览器里看到一个 React 组件,按下 Ctrl+Shift+Command+C,鼠标悬停在元素上,点击一下——VSCode 自动打开了对应组件的源码文件,光标精准定位到组件定义处。这个看似简单的功能,背后涉及编译时代码转换运行时 Fiber 遍历跨层数据传递服务端进程调用等多个技术环节。

本文将沿着一次完整的"inspect"操作,深入剖析 react-dev-inspector 的架构设计与实现原理。


第一章:编译时准备——Babel Plugin 如何埋入源码坐标

1.1 JSX 元素的"坐标标记"

react-dev-inspector 的第一步发生在编译阶段。@react-dev-inspector/babel-plugin 会在 JSX 元素上注入 data-inspector-* 属性,记录该元素在源码中的位置信息。

// packages/babel-plugin/src/visitor.ts
const doJSXOpeningElement: NodeHandler<
  JSXOpeningElement,
  { relativePath: string }
> = (node, option) => {
  const { stop } = doJSXPathName(node.name)
  if (stop) return { stop }

  const { relativePath } = option
  const line = node.loc?.start.line
  const column = node.loc?.start.column

  const lineAttr: JSXAttribute | null = isNil(line)
    ? null
    : jsxAttribute(
      jsxIdentifier('data-inspector-line'),
      stringLiteral(line.toString()),
    )
  // ... columnAttr, relativePathAttr

  const attributes = [lineAttr, columnAttr, relativePathAttr] as JSXAttribute[]
  if (attributes.every(Boolean)) {
    node.attributes.unshift(...attributes)
  }
  return { result: node }
}

Why this design?

在编译时注入坐标信息是最可靠的方式。因为:

  1. 编译时拥有完整的 AST 和 sourcemap 信息
  2. 运行时可以通过 DOM 元素的 props 直接读取,无需额外计算
  3. 相比 @babel/plugin-transform-react-jsx-source 注入的 _debugSource,这种方式提供了相对路径,更适合 monorepo 场景

What if alternative?

如果不使用 Babel Plugin,也可以依赖 React 内置的 _debugSource(由 @babel/plugin-transform-react-jsx-source 提供),但它只包含绝对路径。在服务端需要额外的路径映射逻辑来处理不同操作系统和项目结构。

1.2 数据流:编译时 → 运行时

graph LR
    A[源码 JSX] --> B[Babel Plugin]
    B --> C{是否 Fragment}
    C -->|是| D[跳过处理]
    C -->|否| E[注入 data-inspector-*]
    E --> F[编译后代码]
    F --> G[浏览器运行]
    G --> H[DOM 元素携带坐标属性]

第二章:运行时核心——Inspector 组件的状态管理

2.1 受控与非受控的双模式设计

Inspector 组件支持两种使用模式:

// packages/inspector/src/Inspector/hooks/use-controlled-active.ts
export const useControlledActive = ({
  controlledActive,
  onActiveChange,
  onActivate,
  onDeactivate,
  disable,
}: {
  controlledActive?: boolean;
  onActiveChange?: (active: boolean) => void;
  onActivate?: () => void;
  onDeactivate?: () => void;
  disable?: boolean;
}) => {
  const [isActive, setActive] = useState<boolean>(controlledActive ?? false)
  const activeRef = useRef<boolean>(isActive)

  // sync state as controlled component
  useLayoutEffect(() => {
    if (controlledActive !== undefined) {
      activeRef.current = controlledActive
      setActive(activeRef.current)
    }
  }, [controlledActive])
  // ...
}

Why this design?

双模式设计让组件既可以直接使用(非受控,通过快捷键触发),也可以被外部状态控制(受控,适合自定义 UI 集成)。activeRef 的存在是为了在事件回调中同步读取最新状态,避免闭包陷阱。

What if alternative?

如果只支持受控模式,用户需要自行管理状态;如果只支持非受控模式,则无法与外部 UI 联动。双模式虽然增加了复杂度,但提供了最大的灵活性。

2.2 快捷键系统与事件拦截

// packages/inspector/src/Inspector/hooks/use-hotkey-toggle.ts
export const useHotkeyToggle = ({
  keys,
  disable,
  activate,
  deactivate,
  activeRef,
}: {
  keys?: string[] | null;
  disable?: boolean;
  activate: () => void;
  deactivate: () => void;
  activeRef: MutableRefObject<boolean>;
}) => {
  const hotkey: string | null = keys === null
    ? null
    : (keys ?? []).join('+')

  useEffect(() => {
    const handleHotKeys = (event?: KeyboardEvent) => {
      event?.preventDefault()
      event?.stopImmediatePropagation()
      activeRef.current ? deactivate() : activate()
    }

    const bindKey = (hotkey === null || disable)
      ? null
      : (hotkey || defaultHotkeys().join('+'))

    if (bindKey) {
      hotkeys(bindKey, { capture: true, element: window as any }, handleHotKeys)
      return () => { hotkeys.unbind(bindKey, handleHotKeys) }
    }
  }, [hotkey, disable])
}

默认快捷键在 macOS 上是 Ctrl+Shift+Command+C,其他平台是 Ctrl+Shift+Alt+C。使用 capture: true 确保事件在捕获阶段被拦截,避免被页面其他逻辑阻止。


第三章:Agent 架构——可扩展的检测代理层

3.1 InspectAgent 接口设计

react-dev-inspector v2.1.0 引入了 InspectAgent 架构,将检测逻辑从 React DOM 抽象出来,支持 React Native、React Three.js 等不同渲染器。

// packages/inspector/src/Inspector/types.ts
export interface InspectAgent<Element> {
  activate: (params: {
    onHover: (params: { element: Element; pointer: PointerEvent }) => void;
    onPointerDown: (params: { element?: Element; pointer: PointerEvent }) => void;
    onClick: (params: { element?: Element; pointer: PointerEvent }) => void;
  }) => void;

  deactivate: () => void;

  getTopElementFromPointer?: (pointer: Pointer) => MaybePromise<Element | undefined | null>;
  getTopElementsFromPointer?: (pointer: Pointer) => MaybePromise<Element[]>;

  isAgentElement: (element: unknown) => element is Element;

  getRenderChain(element: Element): InspectChainGenerator<Element>;
  getSourceChain(element: Element): InspectChainGenerator<Element>;

  getNameInfo: (element: Element) => { name: string; title: string } | undefined;
  findCodeInfo: (element: Element) => CodeInfo | undefined;
  findElementFiber?: (element: Element) => Fiber | undefined;

  indicate: (params: { element: Element; codeInfo?: CodeInfo; pointer?: PointerEvent; name?: string; title?: string }) => void;
  removeIndicate: () => void;
}

Why this design?

Agent 架构的核心思想是"分离关注点":

  • Inspector 组件负责状态管理和生命周期
  • InspectAgent 负责特定渲染器的元素检测和交互
  • 通过泛型 Element 支持不同类型的渲染目标(DOM 元素、3D 对象等)

What if alternative?

如果不使用 Agent 架构,所有检测逻辑会耦合在 Inspector 组件中,难以扩展。Agent 架构让社区可以为不同渲染器贡献检测能力,而无需修改核心代码。

3.2 DOMInspectAgent 的实现

// packages/inspector/src/Inspector/DOMInspectAgent/DOMInspectAgent.ts
export class DOMInspectAgent implements InspectAgent<DOMElement> {
  #overlay?: Overlay
  #unsubscribeListener?: () => void

  public activate = ({ onHover, onPointerDown, onClick }) => {
    this.deactivate()
    this.#overlay = new Overlay()

    this.#unsubscribeListener = setupPointerListener({
      onPointerOver: onHover,
      onPointerDown,
      onClick,
      preventEvents: this.#preventEvents,
    })
  }

  public getTopElementFromPointer = (pointer: Pointer): DOMElement | undefined | null => {
    return document.elementFromPoint(pointer.clientX, pointer.clientY) as DOMElement | undefined
  }

  public *getRenderChain(element: DOMElement): Generator<InspectChainItem<DOMElement>, unknown, void> {
    let fiber: Fiber | undefined | null

    while (element) {
      fiber = getElementFiber(element)
      if (fiber) break

      yield {
        agent: this,
        element,
        title: element.nodeName.toLowerCase(),
        tags: getDOMElementTags(element),
      }
      element = element.parentElement as DOMElement
    }

    function *fiberChain(): Generator<Fiber, void, void> {
      while (fiber) {
        yield fiber
        if (fiber.return === fiber) return
        fiber = fiber.return
      }
    }

    return yield * genInspectChainFromFibers<DOMElement>({
      agent: this,
      fibers: fiberChain(),
      isAgentElement: this.isAgentElement,
      getElementTags: getDOMElementTags,
    })
  }
}

getRenderChain 是一个生成器函数,它从目标元素向上遍历:

  1. 首先遍历 DOM 树,直到找到带有 Fiber 的节点
  2. 然后遍历 Fiber 树(通过 fiber.return
  3. 每个节点生成一个 InspectChainItem,包含显示名称、标签、源码信息等

第四章:Fiber 遍历——React 内部结构的探索

4.1 从 DOM 元素获取 Fiber

React 在 DOM 元素上存储了对应的 Fiber 引用,键名随版本变化:

// packages/inspector/src/Inspector/utils/fiber.ts
export const getElementFiber = (_element?: Element): Fiber | undefined => {
  const element = _element as ElementWithFiber
  if (!element) return undefined

  // 优先通过 React DevTools Hook 获取
  const fiberByDevtoolHook = getFiberWithDevtoolHook(element)
  if (fiberByDevtoolHook) return fiberByDevtoolHook

  // 缓存已知的 fiber key,避免重复遍历
  for (const cachedFiberKey of cachedFiberKeys) {
    if (element[cachedFiberKey]) return element[cachedFiberKey] as Fiber
  }

  // 查找 fiber key(React >= v16.14.0 使用 __reactFiber$)
  const fiberKey = Object.keys(element).find(key => (
    key.startsWith('__reactFiber$') ||
    key.startsWith('__reactInternalInstance$')
  ))

  if (fiberKey) {
    cachedFiberKeys.add(fiberKey)
    return element[fiberKey] as Fiber
  }
  return undefined
}

Why this design?

直接访问 React 内部属性看似"hacky",但这是官方 DevTools 也在使用的方式。缓存机制避免了重复遍历对象键,提升了性能。

4.2 获取 Reference Fiber(智能组件识别)

// packages/inspector/src/Inspector/utils/inspect.ts
export const getReferenceFiber = (baseFiber?: Fiber): Fiber | undefined => {
  if (!baseFiber) return undefined

  const directParent = getDirectParentFiber(baseFiber)
  if (!directParent) return undefined

  const isParentNative = isNativeTagFiber(directParent)
  const isOnlyOneChild = !directParent.child!.sibling

  let referenceFiber = (!isParentNative && isOnlyOneChild)
    ? directParent
    : baseFiber

  const originReferenceFiber = referenceFiber

  // 向上查找直到找到有源码信息的 Fiber
  while (referenceFiber) {
    if (getCodeInfoFromFiber(referenceFiber))
      return referenceFiber
    referenceFiber = referenceFiber.return!
  }

  return originReferenceFiber
}

这个函数解决了一个关键问题:用户点击的是 DOM 元素(如 <div>),但想跳转到对应的 React 组件(如 <Button>)。判断逻辑是:

  • 如果父节点是原生标签(如 div),则返回当前 Fiber
  • 如果父节点是组件且只有一个子节点,则返回父组件(因为当前元素可能是组件的"外壳")

What if alternative?

如果不做这种智能识别,用户点击 <Button> 组件渲染的 <button> 元素时,可能会跳转到 button 标签的位置,而不是 Button 组件的定义处。

4.3 Render Chain vs Source Chain

// packages/inspector/src/Inspector/DOMInspectAgent/DOMInspectAgent.ts
public *getRenderChain(element: DOMElement): Generator<InspectChainItem<DOMElement>, unknown, void> {
  // 通过 fiber.return 遍历渲染树
  function *fiberChain(): Generator<Fiber, void, void> {
    while (fiber) {
      yield fiber
      if (fiber.return === fiber) return
      fiber = fiber.return
    }
  }
  // ...
}

public *getSourceChain(element: DOMElement): Generator<InspectChainItem<DOMElement>, unknown, void> {
  function *fiberChain(): Generator<Fiber, void, void> {
    while (fiber) {
      yield fiber
      if (fiber.return === fiber || fiber._debugOwner === fiber) return
      fiber = fiber._debugOwner ?? fiber.return  // 优先使用 _debugOwner
    }
  }
  // ...
}
  • Render Chain:按照组件渲染层次遍历(父组件 → 子组件)
  • Source Chain:按照源码定义层次遍历(_debugOwner 指向 JSX 中定义该组件的父组件)

两者的区别在处理 HOC、ForwardRef、Context 等场景时尤为重要。


第五章:服务端链路——从 HTTP 请求到 IDE 进程

5.1 客户端发起请求

// packages/inspector/src/Inspector/utils/editor.ts
export const gotoServerEditor = (_codeInfo?: CodeInfoLike, options?: { editor?: TrustedEditor }) => {
  if (!_codeInfo) return
  const codeInfo = getCodeInfo(_codeInfo)

  const { lineNumber, columnNumber, relativePath, absolutePath } = codeInfo
  const isRelative = Boolean(relativePath)
  const fileName = isRelative ? relativePath : absolutePath

  const launchParams: LaunchEditorParams = {
    fileName,
    lineNumber,
    colNumber: columnNumber,
    editor: options?.editor,
  }

  const urlParams = new URLSearchParams(
    Object.entries(launchParams).filter(([, value]) => Boolean(value)) as [string, string][]
  )

  fetchToServerEditor({
    apiUrl: launchEditorEndpoint,  // '/__inspect-open-in-editor'
    urlParams,
    fallbackUrl: reactDevUtilsLaunchEditorEndpoint,  // 兼容旧版本
  })
}

5.2 服务端 Middleware 处理

// packages/middleware/src/launch-editor.ts
export const launchEditorMiddleware: NextHandleFunction = (req: IncomingRequest, res, next) => {
  if (!req.url?.startsWith(launchEditorEndpoint)) {
    return next()
  }

  const url = new URL(req.url, 'https://placeholder.domain')
  const params = Object.fromEntries(url.searchParams.entries()) as unknown as LaunchEditorParams

  if (!params.fileName) {
    res.statusCode = 400
    return res.end(`[launch-editor-middleware]: required query param "fileName" is missing.`)
  }

  const fileName = path.resolve(process.cwd(), params.fileName)

  let filePathWithLines = fileName
  if (params.lineNumber) {
    filePathWithLines += `:${params.lineNumber}`
    if (params.colNumber) {
      filePathWithLines += `:${params.colNumber}`
    }
  }

  // 编辑器优先级:请求参数 > LAUNCH_EDITOR 环境变量 > REACT_EDITOR 环境变量 > 默认 VSCode
  const editor = params.editor
    ? params.editor
    : (process.env.LAUNCH_EDITOR || process.env.REACT_EDITOR || TrustedEditor.VSCode)

  launchEditor(filePathWithLines, editor)
  res.end()
}

Why this design?

使用 HTTP 请求作为客户端与服务端的通信方式有以下优势:

  1. 简单通用,不依赖特定的构建工具
  2. 可以跨域(如果 IDE 和浏览器在不同环境)
  3. 易于调试和监控

What if alternative?

也可以使用 WebSocket 或 Electron IPC(如果是 Electron 应用),但 HTTP 是最通用、最易于集成的方式。

5.3 完整的调用链路

sequenceDiagram
    participant User as 用户
    participant Browser as 浏览器
    participant Inspector as Inspector组件
    participant Agent as DOMInspectAgent
    participant FiberUtils as Fiber工具函数
    participant Middleware as Express Middleware
    participant IDE as VSCode/IDE

    User->>Browser: 按下快捷键 Cmd+Shift+Ctrl+C
    Browser->>Inspector: 触发 activate
    Inspector->>Agent: activate({ onHover, onClick })
    Agent->>Browser: 注册 pointerover/click 事件监听

    User->>Browser: 鼠标悬停/点击元素
    Browser->>Agent: 触发 onHover/onClick
    Agent->>FiberUtils: getElementFiber(element)
    FiberUtils-->>Agent: 返回 Fiber
    Agent->>FiberUtils: getReferenceFiber(fiber)
    FiberUtils-->>Agent: 返回 referenceFiber
    Agent->>FiberUtils: getCodeInfoFromFiber(fiber)
    FiberUtils-->>Agent: 返回 CodeInfo

    Agent->>Inspector: 回调 onInspectElement
    Inspector->>Browser: fetch('/__inspect-open-in-editor?fileName=...')
    Browser->>Middleware: HTTP GET 请求
    Middleware->>Middleware: 解析 fileName, lineNumber, colNumber
    Middleware->>IDE: launchEditor(filePath, editor)
    IDE-->>User: 打开文件并定位到指定行列

第六章:Web Components——跨框架的 UI 层

6.1 Overlay 高亮组件

// packages/web-components/src/Overlay/Overlay.ts
export class Overlay {
  window: Window
  overlay: InspectorOverlayElement

  constructor() {
    customElement(InspectorOverlayTagName, InspectorOverlay)

    const currentWindow = window.__REACT_DEVTOOLS_TARGET_WINDOW__ || window
    this.window = currentWindow

    const doc = currentWindow.document
    this.overlay = document.createElement(InspectorOverlayTagName)
    doc.body.appendChild(this.overlay)
  }

  public inspect<Element = HTMLElement>({
    element,
    title,
    info,
  }: {
    element?: Element;
    title?: string;
    info?: string;
  }) {
    return this.overlay.inspect({ element, title, info })
  }
}

使用 Web Components(基于 Solid.js 的 solid-element)实现 UI 层,有以下好处:

  1. 框架无关,可以在任何前端框架中使用
  2. 样式隔离,避免与宿主应用冲突
  3. 原生 API,无需额外的运行时依赖

6.2 InspectContextPanel 右键菜单

右键点击时显示的层级面板,让用户可以选择具体的组件层级:

// packages/web-components/src/InspectContextPanel/InspectContextPanel.ts
export class InspectContextPanel<Item extends ItemInfo = ItemInfo> {
  #panel: InspectContextPanelElement<Item> | undefined
  #clickOutsideCallbacks = new Set<() => void>()

  public show(params: InspectContextPanelShowParams<Item> & { onClickOutside?: () => void }) {
    this.#panel?.show(params)
    if (!params.onClickOutside) return
    this.#clickOutsideCallbacks.add(params.onClickOutside)
    this.listenClickOutside()
  }

  private listenClickOutside = () => {
    this.#clickOutsideSubscription = fromEvent<MouseEvent>(window, 'pointerdown', { capture: true })
      .pipe(
        filter(this.checkPointerOutside),
        tap(stopAndPreventEvent),
        switchMap(() => merge(
          fromEvent(window, 'pointerup', { capture: true }),
          fromEvent(window, 'click', { capture: true }).pipe(
            tap(() => {
              this.#clickOutsideCallbacks.forEach(callback => callback())
            }),
          ),
        )),
      ).subscribe()
  }
}

第七章:设计模式总结

7.1 分层架构

┌─────────────────────────────────────────────────────────────┐
│                      Presentation Layer                      │
│  ┌─────────────┐  ┌─────────────────┐  ┌─────────────────┐  │
│  │   Overlay   │  │ InspectContext  │  │   Indicator     │  │
│  │  (Web Comp) │  │    Panel        │  │    (Web Comp)   │  │
│  └─────────────┘  └─────────────────┘  └─────────────────┘  │
├─────────────────────────────────────────────────────────────┤
│                       Agent Layer                            │
│  ┌─────────────────┐  ┌─────────────────┐  ┌─────────────┐  │
│  │  DOMInspectAgent│  │  RNInspectAgent │  │  Custom...  │  │
│  │   (React DOM)   │  │ (React Native)  │  │             │  │
│  └─────────────────┘  └─────────────────┘  └─────────────┘  │
├─────────────────────────────────────────────────────────────┤
│                      Core Logic Layer                        │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────────────┐  │
│  │   Fiber     │  │   Inspect   │  │   Chain Generator   │  │
│  │   Utils     │  │    Utils    │  │                     │  │
│  └─────────────┘  └─────────────┘  └─────────────────────┘  │
├─────────────────────────────────────────────────────────────┤
│                     Server Layer                             │
│  ┌─────────────────┐  ┌─────────────────────────────────┐   │
│  │   Middleware    │  │      launch-editor (npm)        │   │
│  │ (Express/Vite)  │  │                                 │   │
│  └─────────────────┘  └─────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────┘

7.2 关键设计决策

决策点 选择 原因
坐标信息来源 Babel Plugin + _debugSource 双保险,优先使用 Plugin 的相对路径
Agent 架构 接口抽象 + 泛型 支持多渲染器,保持核心代码简洁
UI 实现 Web Components 框架无关,样式隔离
服务端通信 HTTP Middleware 通用、易集成、可调试
Fiber 获取 内部属性 + DevTools Hook 可靠且被官方认可的方式

总结:可借鉴的架构模式

  1. 编译时 + 运行时双管齐下:在编译时埋入元数据,在运行时读取并处理,是很多开发工具的核心模式

  2. Agent 架构解耦渲染器:通过接口抽象,让核心逻辑与具体渲染技术解耦

  3. 生成器函数处理层级遍历getRenderChaingetSourceChain 使用 Generator,既惰性又清晰

  4. 双模式组件设计:受控/非受控双模式让组件既易用又灵活

  5. Web Components 作为 UI 层:在 React 生态中使用 Web Components,实现真正的框架无关


参考链接

复刻字节 AI 开发流:实践 Node.js 通用脚手架

前言

最近与前同事深入交流,他现为字节某组的 TL(组长),团队规模近 10 人。在讨论他们团队的 AI 工作流实践中,也得到一些八卦信息。

第一个是: 人才储备变化

他们团队已基本停止招聘实习生,今年仅招一人 个人解读: AI 协作模式下,新人的边际效应大幅下降

第二个是:组织预期转变

业内多位跟他同级的 Leader 私下评估:今年可能出现大规模调整 个人解读: 生成式 AI 在降本增效上的潜力被普遍看好,提前做好裁员准备

第三个是:开发方法论的周期性

  • 层出不穷的新概念:Vibe Coding、SDD 规范、Harness Engineering……
  • 理性看待:这些都是过渡阶段产物,随着大模型升级,这些方法论的生命周期可能只有几个月 个人建议: 不必投入过多精力去追赶,因为等你熟练一种方法论后,可能用不了多久就被淘汰了

第四个是:招聘标准演变 前端岗位招聘标准很多 JD 上开始冠以"全栈开发" 的名义招聘了 实际面试:侧重前端能力考察,但开始要求一些后端基础能力了 个人解读: AI 协作工具提效下,企业希望前端承担更多职能

好了,八卦之后,关于他们的 ai 工作流的核心如下:

你得教 AI,不断沉淀并固化它的规范

具体实践方法:

  • 识别问题:第一次遇到某类问题,AI 执行效果不理想
  • 人工介入:手动处理并解决问题
  • 规则沉淀:将解决方案固化为规则或 Skill
  • 迭代积累:问题解决越多,效率提升越明显,也就是后期 ai 执行的只要不是新的场景和复杂业务,基本不会出错

本质: 通过持续的反馈循环来训练和优化 AI 的工作能力,而不是一开始就寻求完美的工作流

这个理念在字节技术专家杨晨的全软软件开发大会分享中也提出过:Prompt = 可训练资产(像模型一样优化)

让后我个人抽象了这个单 Agent 的工作流程图如下:

image.png

接下来我会解释每个步骤的思路和如何落地:

步骤 1:项目初始化 + 全局规则设计

项目初始化是指,大多数现代框架都提供了开箱即用的脚手架工具,例如:

# Vue/React 生态
npm create vite@latest

# Nest.js
nest new project-name

# Hono.js
pnpm create hono@latest

初始化完成后,立即在根目录创建规则文件,通常命名为 CLAUDE.md 或 AGENTS.md。

这个文件是 AI 协作的宪法,应包含例如项目定位,技术栈清单,核心哲学(例如测试先行,采用 TDD 测试方案),项目结构示例,AI 协作原则等等内容。有兴趣的同学可以找我要这个项目的全局规则。

两个关键注意事项动态迭代 规则文件不是一成不变的。当发现 AI 写的代码不规范时,要想到如何抽离抽象的规则来约束,而不是一味手动修改

SKILL 机制(规则的模块化) 当规则内容过多时,抽象可复用的 Skill 文件。好处是 AI 按需加载,不会每次都把全局规则加入上下文,大大减少 token 用量

规则中引用 SKILL 的示例:

## 3. 核心哲学:测试先行 (TDD)

参考 `.trae/skills/tdd-first/SKILL.md` 中的测试驱动开发规范。

所有新功能开发或 Bug 修复必须遵循 **「红-绿-重构」** 循环。**严禁**在没有对应测试用例的情况下提交业务逻辑代码。
---

## 4. 响应格式

参考 `.trae/skills/response-standard/SKILL.md` 中的响应格式规范。

**[强制]** 所有接口统一返回 JSON,使用 `@/utils/response` 中的工具函数生成响应。

---

步骤 2:需求分析

如果你很清楚自己要做什么,可以直接下发任务。但如果只有模糊想法,建议先和 AI 一起做需求分析。

推荐提示词

你好!现在的任务是:我们要从零开始设计并实现 `hono.js boilerplate`。
 
你现在是资深的 Node.js 工程师。我有一个初步的想法,需要你通过向我提问,帮助我澄清需求、挖掘边缘场景,最终目标是理清我做一个通用后端功能的脚手架需要实现哪些功能。并按顺序输出一个实现这些功能的大纲。
 
请开始你的提问。

效果: Claude 会像资深 PM 一样向你提问,你逐一回答这些问题后,AI 会生成一份完整的功能清单文档。


步骤 3:测试先行 + AI 执行代码

这是整个工作流的核心环节,必须让 AI 严格按照 TDD 测试方案执行代码。

执行策略

  1. 任务拆解 → 将需求拆分为最小可测试单元
  2. 红阶段 → 先编写测试用例(此时测试应当失败)
  3. 绿阶段 → 编写最小化实现代码,使测试通过
  4. 重构阶段 → 在测试通过的基础上,优化代码结构和性能

关键约束

在全局规则中强制要求 AI:

  • ✅ 任何业务代码提交前,必须有对应的单元测试覆盖
  • ✅ 测试用例需包含正常场景 + 边界场景 + 异常场景
  • ✅ 测试执行必须通过,覆盖率不低于 80%
  • ✅ 严禁为了赶进度而跳过测试环节

步骤 4:代码审核 + 规则反馈循环

AI 执行代码后,进入审核阶段,分为 AI 自动审核和人工审核两部分。

AI 自动审核

AI 每次完成任务时,需要自动进行:

  • Eslint 校验
  • TypeScript 类型校验

如果报错,AI 自己修复直到通过(可以限制修复次数,避免死循环)

人工审核

前期必须进行人工审核,一旦发现问题,思考如何抽象成规则或 Skill,避免 AI 再次犯错。

核心发现: 你会发现后期 AI 执行的效果会越来越好。对于 CRUD 场景,基本不需要人审核了,大概看一下产出代码就知道没问题。


步骤 5:持续迭代与精准化

随着迭代轮次增加,形成正向循环:

迭代轮数 ↑
    ↓
规则精确度 ↑  → AI 执行准确率 ↑
    ↓
处理边界场景的能力 ↑
    ↓
人工介入频率 ↓
    ↓
开发效率 ↑

这就是 AI 时代的竞争力所在: 不是盲目信任 AI,而是通过不断的反馈循环来「教」AI,逐步固化和优化工作规范。


我举个自己实践中的迭代案例:

  • 例如我让 ai 实现超时中间件的时候,ai 是自己原生实现的,然后我感觉这种很常用的功能一般都有现成的库,就查了一下,果然是有 'hono/timeout' 这个库,然后就在全局规则加入了类似:”优先使用社区成熟,稳定的库解决问题“。
  • 例如我在设计后端的 url 的时候,突然想起 K8s 有一个类似的 url 设计规范,可以跟权限结合起来,例如,/api/v1/roles/{roleId},其中 roles 代表就是资源,roleId 代表的是子资源。
resources: ["roles"] # 操作的资源
verbs: ["get"] # 操作类型,增删改查
resourceNames: ["{roleId}"] # 可选(精确到某个子资源)

简单就是说一个 url 代表对什么资源的什么操作。

HTTP 请求 RBAC
GET /roles/{id} verbs: ["get"]
GET /roles verbs: ["list"]
POST /roles verbs: ["create"]
DELETE /roles/{id} verbs: ["delete"]

然后就可以结合我们的 rbac 模型(权限模型),用来标识一个权限是什么,简单来说就是资源名 + 操作,就能标识一个权限。

然后我干脆让 ai 把这个 url 规范抽象为一个 skill,下次 ai 定义 url 的时候就会调用这个规则。

  • 还有很多例子就不一一列举了...

需要注意的是,字节内部的复杂度是更高的,我们后期也会探索,例如字节内部还有:

  • 多 Agent 系统,比如主 Agent 来负责 plan 的制定,有 Coder Agent 负责编码,还有测试 Agent, test Agent 等等,这种多 Agent 协作,我个人估计字节迟早会推出一个开源库让我们使用的,所以不必着急。
  • 评测体系,会对 AI 输出质量进行打分,也就是评测 AI 的输出质量,这条我们目前这一版是靠人工来识别,但我觉得还好,前期人工介入,后期规则越来越好,模型能力越来越强,就可以进入 AI 自我评测阶段了。
  • 可观测性体系:就是对于 AI 写错的地方,是否能知道哪一步错了,然后根据这个让 AI 自动修正 Prompt,然后自动修正全局规则或者抽象为 SKILL。

上面要这么玩,目前个人还是很难的,需要大的平台支持,本文主要是先走通一个小循环,也欢迎大家一起交流(如果觉得不错,感谢点赞关注哦,欢迎入群交流~)。

项目实战:通用后端脚手架

技术栈: Hono.js + Drizzle ORM + PostgreSQL 数据库

前置声明: 整个工作流仅使用免费版工具(Trace + GLM5/豆包模型)即可高质量完成,充分说明该方法论的实际效果令人满意,而非纸上谈兵。

源码获取: 因外链限流问题,需要的朋友可以联系我获取完整代码(github)

脚手架目前的架构图如下:

image.png

第二部分:企业级技术要点

分享上述实战过程中,网上 Node.js 教程很少提及的企业级最佳实践。

优雅关闭(Graceful Shutdown)

无论部署在 Kubernetes、Docker Compose 还是物理机,都需要实现优雅关闭逻辑。

为什么重要?

当应用报错或升级时,容器编排系统会执行关闭流程:

应用故障/升级 → 容器启动关闭流程
  ↓
向 PID 1 进程发送 SIGTERM 信号
  ↓
开启倒计时(默认 10 秒)
  ↓
如果 10 秒后进程未退出,发送 SIGKILL(强制杀死)

问题场景

例:电商扣款业务
 
1. 扣除用户余额 ✅
2. Docker 信号来了,进程被强杀 🔥
3. 积分增加 ❌(未执行)
 
结果:用户钱扣了但没到账,投诉炸裂。

问题根源: Docker 强杀是瞬间的,Node.js 无法执行完事件循环中的剩余回调。

解决方案

优雅关闭可以让你在收到信号后,停止接收新请求,但把内存中已排队的写操作执行完。同时及时释放系统资源(如数据库连接),避免占满最大连接数。


链路追踪(TraceId)

TraceId 是请求在系统中的唯一标识符,从进入系统到返回响应,始终伴随整个生命周期。

为什么需要?

场景:前端用户报错
用户说:"我提交表单后,收到错误 ID:abc123def456"
 
后端排查:
❌ 日志有 1000 条,怎么找到那条错误?
✅ 按 traceId = abc123def456 过滤,立即定位问题

Node.js 的特殊性

与 Java/Go 等多线程模型相比,Node.js 的单进程模型在处理 TraceId 时有本质区别:

语言 模型 上下文隔离方案 难度
Java/Go 多线程/协程 ThreadLocal ⭐ 简单
Node.js 单进程 AsyncLocalStorage ⭐⭐⭐ 复杂

错误做法

方案 1:全局变量

let traceId;  // 全局变量
 
app.use((req, res, next) => {
  traceId = generateId();  // 请求 A 的 traceId
  next();
});
 
// 问题:请求 B 来了,traceId 被覆盖,日志全乱

方案 2:函数传参

// controller → service → dao,每层都要传 traceId
// 代码极其丑陋,难以维护
 
async function getUserOrder(traceId, userId) {
  const user = await getUser(traceId, userId);
  const order = await getOrder(traceId, user.id);
  return { user, order };
}

正确方案:AsyncLocalStorage

Node.js 官方在 async_hooks 基础上,封装了更高级、性能更优的 API:

import { AsyncLocalStorage } from 'async_hooks';
 
const traceIdStorage = new AsyncLocalStorage();
 
// 在请求中间件中创建隔离上下文
app.use((req, res, next) => {
  const traceId = generateId();
  
  // 在当前上下文中存储 traceId(自动隔离)
  traceIdStorage.run(traceId, () => {
    next();
  });
});
 
// 任何地方都可以取,不用传参
function getTraceId() {
  return traceIdStorage.getStore();
}
 
// 使用示例
async function getUserOrder(userId) {
  const traceId = getTraceId();  // 直接取,无需传参
  logger.info(`[${traceId}] Fetching user`, { userId });
  
  const user = await getUser(userId);
  logger.info(`[${traceId}] User fetched`, { userId: user.id });
  
  return user;
}

日志集成

const logger = createLogger((level, msg, meta) => {
  const traceId = getTraceId();
  const logEntry = {
    timestamp: new Date().toISOString(),
    level,
    traceId,    // 自动注入
    message: msg,
    ...meta,
  };
  console.log(JSON.stringify(logEntry));
});

TDD 测试驱动开发

TDD 是企业级后端项目的核心质量保障手段,在 AI 协作开发模式下更是确保代码质量的关键。

核心流程:红-绿-重构

  1. 红阶段 → 编写测试用例,预期会失败(功能未实现)
  2. 绿阶段 → 实现最小化代码,使测试通过
  3. 重构阶段 → 优化代码结构,保持测试通过

Hono.js 项目中的实践

采用 Hono 原生集成测试方案,结合 Vitest 测试框架:

// test/user.test.ts
import { describe, it, expect } from 'vitest';
import app from '../src/app';
 
describe('User API', () => {
  it('should return 404 for non-existent user', async () => {
    const res = await app.request('/api/users/9999', {
      method: 'GET'
    });
    
    expect(res.status).toBe(404);
    const data = await res.json();
    expect(data.code).toBe(0);
    expect(data.message).toBe('User not found');
  });
  
  it('should create a new user', async () => {
    const res = await app.request('/api/users', {
      method: 'POST',
      body: JSON.stringify({
        name: '测试用户',
        email: 'test@example.com',
        password: 'password123'
      }),
      headers: {
        'Content-Type': 'application/json'
      }
    });
    
    expect(res.status).toBe(200);
    const data = await res.json();
    expect(data.code).toBe(1);
    expect(data.data.name).toBe('测试用户');
  });
});

表格驱动测试

对于多分支逻辑和边界情况,采用表格驱动测试风格:

describe('User Validation', () => {
  const testCases = [
    {
      desc: '缺少必填字段',
      body: { name: '测试用户' },
      expectedStatus: 400,
      expectedMessage: 'Email is required'
    },
    {
      desc: '邮箱格式错误',
      body: { name: '测试用户', email: 'invalid-email' },
      expectedStatus: 400,
      expectedMessage: 'Invalid email format'
    },
    {
      desc: '密码长度不足',
      body: { name: '测试用户', email: 'test@example.com', password: '123' },
      expectedStatus: 400,
      expectedMessage: 'Password must be at least 6 characters'
    }
  ];
  
  test.each(testCases)('$desc', async ({ body, expectedStatus, expectedMessage }) => {
    const res = await app.request('/api/users', {
      method: 'POST',
      body: JSON.stringify(body),
      headers: { 'Content-Type': 'application/json' }
    });
    
    expect(res.status).toBe(expectedStatus);
    const data = await res.json();
    expect(data.message).toBe(expectedMessage);
  });
});

请求超时处理

请求超时处理是后端服务稳定性的重要保障,可以防止长时间运行的请求占用系统资源。

为什么需要?

  • 保护用户体验:与其让用户等待 30 秒,不如在 5 秒内返回"请求超时"
  • 防止系统雪崩:大量超时请求堆积会导致 CPU/内存被迅速耗尽

API 接口级超时

利用 Hono 自带的 timeout 中间件:

import { timeout } from 'hono/timeout'
 
// 1. 全局配置:所有请求默认 5 秒超时
app.use('/api/*', timeout(5000))
 
// 2. 局部配置:针对耗时操作,允许更长时间
app.get('/api/export', timeout(30000), async (c) => {
  // 执行耗时操作...
  return c.json({ success: true })
})
 
// 3. 自定义超时后的逻辑
const customTimeout = timeout(5000, {
  onTimeout: (c) => {
    return c.json({ code: 0, message: '服务器繁忙,请稍后再试' }, 408)
  }
})

数据库级超时

API 层超时只是"切断了回传给用户的路",但数据库内部的任务可能仍在运行。需要更细粒度的控制:

// Drizzle ORM 配置:通过底层驱动设置超时
import { drizzle } from 'drizzle-orm/postgres-js'
import postgres from 'postgres'
 
const queryClient = postgres(process.env.DATABASE_URL, {
  timeout: 5,          // 建立连接超时 (秒)
  idle_timeout: 20,    // 空闲连接释放
  max_lifetime: 60 * 30 // 连接存活最大时间
})
 
// 在业务代码中手动控制单次查询超时
async function getSlowData() {
  return await db.select().from(users).execute();
}

全局错误处理

在复杂的后端系统中,错误可能来自业务逻辑、数据库约束、第三方 API 失败或语法错误。没有统一处理的话,返回给前端的可能是难看的堆栈信息。

设计原则

  1. 收口原则 → 业务代码通过 throw 抛出错误,由顶层中间件统一拦截处理
  2. 分类分级 → 区分"预期内错误"和"预期外错误"
  3. 安全性 → 生产环境下严禁将详细 Stack 返回给客户端

实现方案

步骤 1:定义标准错误类

// src/utils/errors.ts
export class AppError extends Error {
  constructor(
    public statusCode: number,
    public message: string,
    public code: number = 0 // 自定义业务状态码
  ) {
    super(message);
    this.name = 'AppError';
  }
}

步骤 2:配置全局捕获钩子

import { Hono } from 'hono';
import { AppError } from './utils/errors';
 
const app = new Hono();
 
app.onError((err, c) => {
  const traceId = c.get('traceId') || 'unknown';
  
  // 1. 处理已知业务异常
  if (err instanceof AppError) {
    return c.json({
      code: err.code,
      message: err.message,
      traceId
    }, err.statusCode as any);
  }
 
  // 2. 处理参数校验错误
  if (err.name === 'ZodError') {
    return c.json({
      code: 400,
      message: '参数验证失败',
      details: err,
      traceId
    }, 400);
  }
 
  // 3. 处理未知错误
  console.error(`[Fatal Error] [${traceId}]:`, err);
 
  return c.json({
    code: 500,
    message: process.env.NODE_ENV === 'production' 
      ? '服务器内部错误' 
      : err.message,
    traceId
  }, 500);
});

步骤 3:业务层使用

export async function deleteUser(id: string) {
  const user = await db.findUser(id);
  
  if (!user) {
    throw new AppError(404, '用户不存在', 10001);
  }
  
  return db.delete(id);
}

RBAC 权限控制

RBAC(基于角色的访问控制)是中后台系统最通用的权限模型。通过"用户-角色-权限"的关联,实现权限的解耦。

为什么不直接判断角色?

如果代码里写 if (user.role === 'admin'),当新增一个"超级编辑"角色也需要此权限时,得修改所有代码。判断权限点(Permission)而非角色名,才是系统扩展性的关键。

核心概念

  • 用户 (User) → 拥有一个或多个角色
  • 角色 (Role) → 如 Admin、Editor、Viewer
  • 权限 (Permission) → 如 user:create、order:delete

实现方案

步骤 1:定义数据模型

// 简化版 schema
export const users = pgTable('users', {
  id: serial('id').primaryKey(),
  role: text('role').default('viewer'),
});
 
// 权限映射表
const ROLE_PERMISSIONS = {
  admin: ['user:all', 'post:all'],
  editor: ['post:edit', 'post:create'],
  viewer: ['post:read'],
} as const;

步骤 2:实现 RBAC 中间件

// middleware/rbac.ts
import { createMiddleware } from 'hono/factory';
import { AppError } from '../utils/errors';
 
export const checkPermission = (requiredPermission: string) => {
  return createMiddleware(async (c, next) => {
    const user = c.get('user');
    
    if (!user) {
      throw new AppError(401, '未授权访问');
    }
 
    const userPermissions = ROLE_PERMISSIONS[user.role] || [];
    
    // 支持通配符或精确匹配
    const hasPermission = userPermissions.some(p => 
      p === requiredPermission || p === `${requiredPermission.split(':')[0]}:all`
    );
 
    if (!hasPermission) {
      throw new AppError(403, '权限不足,无法执行此操作');
    }
 
    await next();
  });
};

步骤 3:在路由层应用

const api = new Hono();
 
// 只有拥有 post:create 权限的角色才能访问
api.post('/posts', checkPermission('post:create'), async (c) => {
  return c.json({ message: '发布成功' });
});
 
// 管理员专属接口
api.get('/admin/stats', checkPermission('user:all'), async (c) => {
  return c.json({ stats: '...' });
});

日志轮转

在生产环境中,如果所有日志都无限制地写入同一个文件,最终会导致磁盘爆满和日志文件难以打开。

核心目的

  1. 防止单个文件过大(难以检索、占用磁盘空间)
  2. 自动化归档(按日期分类)
  3. 过期清理(例如只保留最近 14 天的日志)

实现方案:Winston + Daily Rotate File

import winston from 'winston';
import 'winston-daily-rotate-file';
 
const transport = new winston.transports.DailyRotateFile({
  filename: 'logs/application-%DATE%.log',
  datePattern: 'YYYY-MM-DD',
  zippedArchive: true,           // 历史日志压缩
  maxSize: '20m',                // 单个文件超过 20MB 也会切分
  maxFiles: '14d',               // 只保留最近 14 天的日志
  level: 'info',
});
 
const logger = winston.createLogger({
  transports: [
    transport,
    new winston.transports.Console()
  ]
});

应对 DDoS 攻击

DDoS 攻击的本质是发大量垃圾请求,导致带宽占满、CPU/内存耗尽、连接数耗尽。

现实: 普通企业很难防住大规模 DDoS,目的是提高攻击成本。

限流

在接入层(Nginx)—— 粗筛

性能极高,在流量进入 Node.js 之前就拦截:

limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
limit_req zone=api burst=20;

在应用层(Middleware)—— 精滤

灵活度高,根据业务维度限流:

// 限制某个登录用户每分钟只能发 5 条评论
app.use(rateLimit({
  windowMs: 60 * 1000,
  max: 5,
  keyGenerator: (c) => c.get('user').id
}));

限制请求体大小

防止内存溢出 (OOM):

// 攻击场景:发送 2GB 垃圾字符的 JSON POST 请求
// 后果:Node.js 进程尝试分配 2GB 内存,很快就 Out of Memory
 
// 解决:在 Nginx 层配置
client_max_body_size 1m;

Helmet 安全头

Helmet 通过设置各种 HTTP 响应头,自动防御常见的 Web 漏洞(XSS、点击劫持、MIME 类型嗅探等)。

性价比最高的安全加固方案。

Hono.js 官方支持 hono/helmet 中间件,在入口文件 src/app.ts 中引入即可:

import { helmet } from 'hono/helmet';
 
app.use(helmet());

告警机制

告警机制是"及时发现问题"的关键,通过监控关键指标,在异常情况下主动通知相关人员。

告警规则设计

根据应用的 SLA,定义不同严重等级:

export const alertRules = [
  {
    name: 'High Error Rate',
    condition: 'error_rate > 5%',
    severity: 'critical',
    duration: '5m',
    action: 'page_oncall',  // 立即电话/Slack 通知
  },
  {
    name: 'High Response Latency',
    condition: 'p95_latency > 1000ms',
    severity: 'warning',
    duration: '10m',
    action: 'send_to_slack',
  },
  {
    name: 'Database Connection Pool Exhausted',
    condition: 'db_connections > 90%',
    severity: 'critical',
    duration: '1m',
    action: 'page_oncall',
  }
];

与监控系统集成

使用 Prometheus + Alertmanager:

# prometheus.yml
global:
  scrape_interval: 15s
 
scrape_configs:
  - job_name: 'hono-app'
    static_configs:
      - targets: ['localhost:3000']
    metrics_path: '/metrics'
 
alerting:
  alertmanagers:
    - static_configs:
        - targets: ['localhost:9093']

多渠道通知

export async function sendAlert(
  title: string,
  message: string,
  severity: 'critical' | 'warning' | 'info'
) {
  const timestamp = new Date().toISOString();
 
  // 1. Slack 通知
  if (severity === 'critical' || severity === 'warning') {
    await axios.post(process.env.SLACK_WEBHOOK_URL, {
      text: `[${severity.toUpperCase()}] ${title}`,
      attachments: [{
        color: severity === 'critical' ? 'danger' : 'warning',
        text: message,
        ts: Math.floor(new Date().getTime() / 1000),
      }],
    });
  }
 
  // 2. 邮件通知(仅限 critical)
  if (severity === 'critical') {
    await sendEmail({
      to: process.env.ALERT_EMAIL,
      subject: `🚨 CRITICAL: ${title}`,
      html: `<h2>${title}</h2><p>${message}</p><p>${timestamp}</p>`,
    });
  }
 
  // 3. 记录到数据库
  await db.insert(alerts).values({
    title,
    message,
    severity,
    createdAt: new Date(),
  });
}

性能测试

性能测试是确保应用在生产环境中稳定运行的最后一道防线。

基准测试(Benchmarking)

使用 Autocannon 进行简单的吞吐量和延迟测试:

# 安装 Autocannon
npm install -g autocannon
 
# 基准测试:100 并发,持续 30 秒
autocannon -c 100 -d 30 http://localhost:3000/api/users
 
# 输出示例
# Req/Sec: 1234
# Latency: { mean: 45.2, p50: 42, p95: 78, p99: 120 }

压力测试(Load Testing)

使用 K6 模拟真实用户行为:

// load-test.js
import http from 'k6/http';
import { check, sleep, group } from 'k6';
 
export const options = {
  stages: [
    { duration: '2m', target: 100 },
    { duration: '5m', target: 100 },
    { duration: '2m', target: 200 },
    { duration: '5m', target: 200 },
    { duration: '2m', target: 0 },
  ],
};
 
export default function () {
  group('User API', () => {
    // 测试获取用户列表
    let listRes = http.get('http://localhost:3000/api/users');
    check(listRes, {
      'list status is 200': (r) => r.status === 200,
      'list response time < 100ms': (r) => r.timings.duration < 100,
    });
 
    // 测试创建用户
    let createRes = http.post('http://localhost:3000/api/users', {
      name: `user-${__VU}-${__ITER}`,
      email: `user-${__VU}-${__ITER}@example.com`,
      password: 'password123',
    });
    check(createRes, {
      'create status is 200': (r) => r.status === 200,
    });
 
    sleep(1);
  });
}

运行压力测试:

# 安装 K6
npm install -g k6
 
# 执行测试
k6 run load-test.js

数据库性能测试

// src/tests/db-performance.test.ts
import { describe, it, expect } from 'vitest';
import { db } from '../db';
 
describe('Database Performance', () => {
  it('should query 10k users in < 500ms', async () => {
    const start = performance.now();
    const users = await db.query.users.findMany({ limit: 10000 });
    const duration = performance.now() - start;
 
    expect(users.length).toBe(10000);
    expect(duration).toBeLessThan(500);
  });
 
  it('should create 1k users in batch < 2s', async () => {
    const data = Array.from({ length: 1000 }, (_, i) => ({
      name: `user-${i}`,
      email: `user-${i}@example.com`,
      password: 'hashed-password',
    }));
 
    const start = performance.now();
    await db.insert(users).values(data);
    const duration = performance.now() - start;
 
    expect(duration).toBeLessThan(2000);
  });
});

数据持久化与备份

数据持久化本质上解决的是:当系统崩溃、误操作、甚至被攻击时,数据还能不能恢复?

重要认知: 数据库 ≠ 数据安全。数据库只是"存储",而备份 + 恢复能力才是安全的核心。

备份脚本示例

#!/bin/bash
set -o pipefail  # 核心:捕获管道中任何一步的错误
 
DB_NAME="your_db"
BACKUP_FILE="/data/backups/db_$(date +%Y%m%d).sql.gz"
 
# 执行备份
pg_dump -U admin -d $DB_NAME | gzip -1 > $BACKUP_FILE
 
# 检查备份是否成功
if [ $? -ne 0 ]; then
    echo "❌ 备份失败!清理空文件..."
    rm -f $BACKUP_FILE
    # 调用告警机制
    # sendAlert "Database Backup Failed" "pg_dump connection error" "critical"
    exit 1
else
    echo "✅ 备份成功"
fi

可观测性(Observability)

可观测性与监控的区别:

  • 监控 → 告诉你"系统出了问题"(基于预定义的指标和阈值)
  • 可观测性 → 告诉你"系统为什么出了问题"(通过日志、指标、链路追踪)

可观测性的三大支柱

支柱 1:结构化日志

// src/utils/logger.ts
import winston from 'winston';
 
const logger = winston.createLogger({
  format: winston.format.combine(
    winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
    winston.format.errors({ stack: true }),
    // 自定义格式化,确保输出为结构化 JSON
    winston.format.printf(({ timestamp, level, message, ...meta }) => {
      return JSON.stringify({
        timestamp,
        level,
        traceId,
        message,
        ...meta,
      });
    })
  ),
  transports: [
    new winston.transports.Console(),
    new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
    new winston.transports.File({ filename: 'logs/combined.log' }),
  ],
});

支柱 2:指标收集(Metrics)

使用 Prometheus 收集性能指标:

// src/utils/metrics.ts
import promClient from 'prom-client';
 
// 创建指标
export const httpRequestDuration = new promClient.Histogram({
  name: 'http_request_duration_seconds',
  help: 'HTTP request latency',
  labelNames: ['method', 'route', 'status_code'],
  buckets: [0.1, 0.5, 1, 2, 5],
});
 
export const dbQueryDuration = new promClient.Histogram({
  name: 'db_query_duration_seconds',
  help: 'Database query latency',
  labelNames: ['operation', 'table'],
  buckets: [0.01, 0.05, 0.1, 0.5, 1],
});
 
// 暴露 Prometheus 指标端点
export function registerMetricsRoute(app: Hono) {
  app.get('/metrics', (c) => {
    return c.text(promClient.register.metrics());
  });
}

支柱 3:链路追踪(Traces)

已在前面的 TraceId 部分详细说明。


最后

这套工作流的核心理念就是 持续反馈、不断优化。感谢您的阅读,建议点赞收藏,我们下期再见!

Vite8 关于 vite build 命令构建过程

在 Vite 8 中,vite build 命令已经从传统的 Rollup 打包,彻底转向了由 Rust 驱动的全新工具链。

Vite 8 最大的改变,是其构建流程的底层核心被完全重写,统一使用 Rust 生态的工具。

  • 单一打包器 Rolldown:此前,Vite 在开发环境使用 esbuild 追求速度,在生产环境使用 Rollup 追求能力,这导致了行为不一致。Vite 8 使用一个名为 Rolldown 的 Rust 打包器,统一了开发和生产环境的构建链路。它完全兼容 Rollup 的插件 API,使得绝大多数现有 Vite 插件无需修改即可在 Vite 8 中运行。
  • 高性能引擎 Oxc:Rolldown 本身构建于 Oxc(另一个用 Rust 编写的工具集)之上。Oxc 为 Rolldown 提供了极快的解析、转换能力,使其在处理 TypeScript 和 JSX 文件时性能大幅领先。

vite build 有哪些命令行参数?

// build
cli
  .command('build [root]', 'build for production')
  .option(
    '--target <target>',
    `[string] transpile target (default: 'baseline-widely-available')`,
  )
  .option('--outDir <dir>', `[string] output directory (default: dist)`)
  .option(
    '--assetsDir <dir>',
    `[string] directory under outDir to place assets in (default: assets)`,
  )
  .option(
    '--assetsInlineLimit <number>',
    `[number] static asset base64 inline threshold in bytes (default: 4096)`,
  )
  .option(
    '--ssr [entry]',
    `[string] build specified entry for server-side rendering`,
  )
  .option(
    '--sourcemap [output]',
    `[boolean | "inline" | "hidden"] output source maps for build (default: false)`,
  )
  .option(
    '--minify [minifier]',
    `[boolean | "terser" | "esbuild"] enable/disable minification, ` +
      `or specify minifier to use (default: esbuild)`,
  )
  .option('--manifest [name]', `[boolean | string] emit build manifest json`)
  .option('--ssrManifest [name]', `[boolean | string] emit ssr manifest json`)
  .option(
    '--emptyOutDir',
    `[boolean] force empty outDir when it's outside of root`,
  )
  .option('-w, --watch', `[boolean] rebuilds when modules have changed on disk`)
  .option('--app', `[boolean] same as \`builder: {}\``)

vite build 接收的 options 有哪些?

image.png

源码

createBuilder

/**
 * Creates a ViteBuilder to orchestrate building multiple environments.
 * 创建和配置 vite构建器
 * @experimental
 * params inlineConfig 行内配置
 * params useLegacyBuilder 是否使用旧版构建器
 */
export async function createBuilder(
  inlineConfig: InlineConfig = {},
  useLegacyBuilder: null | boolean = false,
): Promise<ViteBuilder> {

  // 处理旧版兼容
  const patchConfig = (resolved: ResolvedConfig) => {
    if (!(useLegacyBuilder ?? !resolved.builder)) return

    // Until the ecosystem updates to use `environment.config.build` instead of `config.build`,
    // we need to make override `config.build` for the current environment.
    // We can deprecate `config.build` in ResolvedConfig and push everyone to upgrade, and later
    // remove the default values that shouldn't be used at all once the config is resolved
    const environmentName = resolved.build.ssr ? 'ssr' : 'client'
    ;(resolved.build as ResolvedBuildOptions) = {
      ...resolved.environments[environmentName].build,
    }
  }
  // 配置解析
  const config = await resolveConfigToBuild(inlineConfig, patchConfig)
  // 是否使用旧版构建器
  useLegacyBuilder ??= !config.builder
  // 构建器配置
  const configBuilder = config.builder ?? resolveBuilderOptions({})!

  const environments: Record<string, BuildEnvironment> = {}

  // 创建 ViteBuilder 对象
  const builder: ViteBuilder = {
    environments,
    config,
    /**
     * 构建整个应用
     */
    async buildApp() {
      // 创建插件上下文
      const pluginContext = new BasicMinimalPluginContext(
        { ...basePluginContextMeta, watchMode: false },
        config.logger,
      )

      // order 'pre' and 'normal' hooks are run first, then config.builder.buildApp, then 'post' hooks
      // 是否已调用配置构建器的 buildApp 方法
      let configBuilderBuildAppCalled = false

      // 执行插件的 buildApp 钩子
      for (const p of config.getSortedPlugins('buildApp')) {
        const hook = p.buildApp
        if (
          !configBuilderBuildAppCalled &&
          typeof hook === 'object' &&
          hook.order === 'post' // 只在 post 阶段调用
        ) {
          configBuilderBuildAppCalled = true
          await configBuilder.buildApp(builder)
        }
        const handler = getHookHandler(hook)
        await handler.call(pluginContext, builder)
      }
      // 如果未调用配置构建器的 buildApp 方法,调用默认 buildApp 方法
      if (!configBuilderBuildAppCalled) {
        await configBuilder.buildApp(builder)
      }
      // fallback to building all environments if no environments have been built
      // 检查是否有环境被构建
      if (
        Object.values(builder.environments).every(
          (environment) => !environment.isBuilt,
        )
      ) {
        for (const environment of Object.values(builder.environments)) {
          // 构建所有环境
          await builder.build(environment)
        }
      }
    },
    /**
     * 构建环境
     * @param environment 
     * @returns 
     */
    async build(
      environment: BuildEnvironment,
    ): Promise<RolldownOutput | RolldownOutput[] | RolldownWatcher> {
      const output = await buildEnvironment(environment)
      environment.isBuilt = true
      return output
    },
    async runDevTools() {
      const devtoolsConfig = config.devtools
      if (devtoolsConfig.enabled) {
        try {
          const { start } = await import(`@vitejs/devtools/cli-commands`)
          await start(devtoolsConfig.config)
        } catch (e) {
          config.logger.error(
            colors.red(`Failed to run Vite DevTools: ${e.message || e.stack}`),
            { error: e },
          )
        }
      }
    },
  }

  /**
   * 环境设置函数
   */
  async function setupEnvironment(name: string, config: ResolvedConfig) {
    const environment = await config.build.createEnvironment(name, config)
    await environment.init()
    environments[name] = environment
  }

  // 环境初始化
  // 使用旧版构建器
  if (useLegacyBuilder) {
    await setupEnvironment(config.build.ssr ? 'ssr' : 'client', config)
  } else {
    // 新版构建器
    const environmentConfigs: [string, ResolvedConfig][] = []

    for (const environmentName of Object.keys(config.environments)) {
      // We need to resolve the config again so we can properly merge options
      // and get a new set of plugins for each build environment. The ecosystem
      // expects plugins to be run for the same environment once they are created
      // and to process a single bundle at a time (contrary to dev mode where
      // plugins are built to handle multiple environments concurrently).
      let environmentConfig = config
      if (!configBuilder.sharedConfigBuild) {
        const patchConfig = (resolved: ResolvedConfig) => {
          // Until the ecosystem updates to use `environment.config.build` instead of `config.build`,
          // we need to make override `config.build` for the current environment.
          // We can deprecate `config.build` in ResolvedConfig and push everyone to upgrade, and later
          // remove the default values that shouldn't be used at all once the config is resolved
          ;(resolved.build as ResolvedBuildOptions) = {
            ...resolved.environments[environmentName].build,
          }
        }
        const patchPlugins = (resolvedPlugins: Plugin[]) => {
          // Force opt-in shared plugins
          let j = 0
          for (let i = 0; i < resolvedPlugins.length; i++) {
            const environmentPlugin = resolvedPlugins[i]
            if (
              configBuilder.sharedPlugins ||
              environmentPlugin.sharedDuringBuild
            ) {
              for (let k = j; k < config.plugins.length; k++) {
                if (environmentPlugin.name === config.plugins[k].name) {
                  resolvedPlugins[i] = config.plugins[k]
                  j = k + 1
                  break
                }
              }
            }
          }
        }
        // 为每个环境名称创建环境配置
        environmentConfig = await resolveConfigToBuild(
          inlineConfig,
          patchConfig,
          patchPlugins,
        )
      }
      
      environmentConfigs.push([environmentName, environmentConfig])
    }
    // 并行初始化所有环境
    await Promise.all(
      environmentConfigs.map(
        async ([environmentName, environmentConfig]) =>
          await setupEnvironment(environmentName, environmentConfig),
      ),
    )
  }

  return builder
}

image.png

image.png

buildEnvironment

buildEnvironment 函数是 Vite 8 中为单个环境(如 client 或 ssr)执行生产构建的核心函数:

  1. 首先解析 Rolldown 打包配置。
  2. 然后根据是否开启监听模式(options.watch)分别创建 Rolldown 的 watcher 以持续构建并监听文件变化,或一次性调用 Rolldown 完成打包。
  3. 构建过程中会收集每个输出 chunk 的元数据,支持多输出配置(如同时输出 ESM 和 CJS),并最终将产物写入磁盘或返回结果对象。
  4. 同时提供详细的日志输出和错误增强处理,在结束前确保关闭 Rolldown 实例以释放资源。

/**
 * Build an App environment, or a App library (if libraryOptions is provided)
 * Vite 8 中负责生产构建单个环境(如 client、ssr)的核心函数。
 * 基于 Rolldown(Rust 打包器)执行打包,支持普通构建和监听模式(watch)
 **/
async function buildEnvironment(
  environment: BuildEnvironment,
): Promise<RolldownOutput | RolldownOutput[] | RolldownWatcher> {
  const { logger, config } = environment
  const { root, build: options } = config

  // 记录开始构建的日志
  logger.info(
    colors.cyan(
      `vite v${VERSION} ${colors.green(
        `building ${environment.name} environment for ${environment.config.mode}...`,
      )}`,
    ),
  )

  let bundle: RolldownBuild | undefined
  let startTime: number | undefined
  try {
    // 收集每个输出 chunk 的元数据(如模块 ID、文件大小等)
    const chunkMetadataMap = new ChunkMetadataMap()
    // 解析 Rolldown 选项
    const rollupOptions = resolveRolldownOptions(environment, chunkMetadataMap)

    // watch file changes with rollup
    // 监视文件变化
    if (options.watch) {
      logger.info(colors.cyan(`\nwatching for file changes...`))

      const resolvedOutDirs = getResolvedOutDirs(
        root,
        options.outDir,
        options.rollupOptions.output,
      )
      const emptyOutDir = resolveEmptyOutDir(
        options.emptyOutDir,
        root,
        resolvedOutDirs,
        logger,
      )
      const resolvedChokidarOptions = resolveChokidarOptions(
        {
          // @ts-expect-error chokidar option does not exist in rolldown but used for backward compat
          ...(rollupOptions.watch || {}).chokidar,
          // @ts-expect-error chokidar option does not exist in rolldown but used for backward compat
          ...options.watch.chokidar,
        },
        resolvedOutDirs,
        emptyOutDir,
        environment.config.cacheDir,
      )

      const { watch } = await import('rolldown')
      // 调用 rolldown.watch 创建监听器
      const watcher = watch({
        ...rollupOptions,
        watch: {
          ...rollupOptions.watch,
          ...options.watch,
          notify: convertToNotifyOptions(resolvedChokidarOptions),
        },
      })

      watcher.on('event', (event) => {
        if (event.code === 'BUNDLE_START') {
          logger.info(colors.cyan(`\nbuild started...`))
          chunkMetadataMap.clearResetChunks()
        } else if (event.code === 'BUNDLE_END') {
          event.result.close()
          logger.info(colors.cyan(`built in ${event.duration}ms.`))
        } else if (event.code === 'ERROR') {
          const e = event.error
          enhanceRollupError(e)
          clearLine()
          logger.error(e.message, { error: e })
        }
      })

      return watcher
    }

    // 普通构建
    // write or generate files with rolldown
    const { rolldown } = await import('rolldown')
    startTime = Date.now()
    // 创建 Rolldown 构建实例
    bundle = await rolldown(rollupOptions)

    // 多个输出配置
    const res: RolldownOutput[] = []

    for (const output of arraify(rollupOptions.output!)) {
      // bundle.write(outputOptions) 将产物写入磁盘
      // bundle.generate(outputOptions) 仅返回产物对象
      res.push(await bundle[options.write ? 'write' : 'generate'](output))
    }
    for (const output of res) {
      for (const chunk of output.output) {
        // 注入 chunk 元数据
        injectChunkMetadata(chunkMetadataMap, chunk)
      }
    }
    logger.info(
      `${colors.green(`✓ built in ${displayTime(Date.now() - startTime)}`)}`,
    )

    // 返回构建结果
    return Array.isArray(rollupOptions.output) ? res : res[0]
  } catch (e) {
    enhanceRollupError(e)
    clearLine()
    if (startTime) {
      logger.error(
        `${colors.red('✗')} Build failed in ${displayTime(Date.now() - startTime)}`,
      )
      startTime = undefined
    }
    throw e
  } finally {
    // 关闭 Rolldown 构建实例
    if (bundle) await bundle.close()
  }
}

image.png

image.png

image.png

Vite 8 的生产构建底层完全基于 Rolldown(Rust 打包器),支持两种构建模式:一次性打包(默认 vite build)和 监听打包vite build --watch)。

image.png

命令分析

"build": "run-p type-check \"build-only {@}\" --"
"build-only": "vite build",

run-p:来自 npm-run-all,表示并行执行后面的脚本

  • type-check:第一个要运行的脚本(通常用于 TypeScript 类型检查)。
  • "build-only {@}" :第二个要运行的脚本。
    • build-only 是另一个 npm 脚本(自定义,例如 vite build)。
    • {@} 是 npm-run-all 的特殊占位符,代表传递给当前 build 命令的所有原始参数

测试

{
    build: {
    emptyOutDir:true, // 清空目录
    copyPublicDir: true,
    reportCompressedSize: true,//启用/禁用 gzip 压缩大小报告
    chunkSizeWarningLimit:500,// 规定触发警告的 chunk 大小。(以 kB 为单位)。
    assetsInlineLimit:4096,// 4kb 小于此阈值的导入或引用资源将内联为 base64 编码,以避免额外的 http 请求
    // baseline-widely-available 具体来说,它是 `['chrome111', 'edge111', 'firefox114', 'safari16.4']`
    // esnext —— 即假设有原生动态导入支持,并只执行最低限度的转译。
    target: 'baseline-widely-available',
    // 如果禁用,整个项目中的所有 CSS 将被提取到一个 CSS 文件中。
    cssCodeSplit: true,// 启用,在异步 chunk 中导入的 CSS 将内联到异步 chunk 本身,并在其被加载时一并获取。
    cssMinify: 'lightningcss',// Vite 默认使用 [Lightning CSS](https://lightningcss.dev/minification.html) 来压缩 CSS
    // true,将会创建一个独立的 source map 文件
    // inline,source map 将作为一个 data URI 附加在输出文件中
    sourcemap:false,
    license:true, // true,构建过程将生成一个 .vite/license.md文件,
    }
}

示例 build.outDir 、build.assetsDir

build.outDir默认值 dist,build.assetsDir默认值 assets

image.png

  build: {
    outDir: 'dist-cube',
    assetsDir: 'public',
  },

image.png

image.png

示例 build.minify

1、默认情况。

minify 默认压缩,客户端构建默认为'oxc'

image.png

2、配置不压缩。

  build: {
    outDir: 'dist-cube',
    assetsDir: 'public',
    minify: false, // 不压缩
  },

image.png

3、配置 esbuild 。

  build: {
    outDir: 'dist-cube',
    assetsDir: 'public',
    minify: 'esbuild',
  },

提示 在 vite8 中 esbuild 已废弃。建议使用 oxc 。

image.png

4、 配置 terser。

  build: {
    outDir: 'dist-cube',
    assetsDir: 'public',
    minify: 'terser',
  },

image.png

当设置为 'esbuild' 或 'terser' 时,必须分别安装 esbuild 或 Terser。

npm add -D esbuild
npm add -D terser

示例 build.manifest / ssrManifest

manifest 设置为 true 时,路径将是 .vite/manifest.json
ssrManifest 设置为 true 时,路径将是 .vite/ssr-manifest.json

image.png

image.png

vue3-vite-cube/dist-cube/index.html

<!doctype html>
<html lang="">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" href="/favicon.ico" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite App菜单</title>
    <script type="module" crossorigin src="/public/index-B9iM-AOo.js"></script>
    <link rel="modulepreload" crossorigin href="/public/runtime-core.esm-bundler-HXD8ebTp.js">
    <link rel="stylesheet" crossorigin href="/public/index-DuS5nk76.css">
  </head>
  <body>
    <div id="app"></div>
  </body>
</html>

最后

  1. rolldown 配置
  2. vite 配置

从一个截图函数到一个 npm 包——pdf-snapshot 的诞生记

一个 PDF 文档页面截图工具的渐进式演化之路

背景

事情要从一个内部知识库项目说起。

产品同学提了一个需求:知识库里存了大量 PDF 文档,在预览列表页希望能展示文档的缩略图,用户点击缩略图后再打开完整的 PDF 文件。听起来很简单对吧?但问题是——库里只有 PDF 文件,没有缩略图。

于是摆在我面前的问题就很清晰了:如何从 PDF 文件中生成缩略图

一番调研后发现,Node.js 生态里虽然有一些 PDF 相关的库,但要么功能太重(整个 PDF 编辑器级别)、要么只能跑在浏览器端、要么 API 设计不太友好。最后决定基于 pdf-parse 封装一个轻量级的截图工具。

本以为写个工具函数就完事了,没想到这个小需求最终演变成了一个完整的 npm 包。下面就来聊聊这个渐进式的演化过程。

渐进式方案演进

阶段一:一个 utils 函数

最初的需求很简单——给知识库用,能生成缩略图就行。

于是我在项目里写了个 utils/pdfSnapshot.ts,核心逻辑大概长这样:

import { PDFParse } from 'pdf-parse';

export async function snapshotPdf(filePath: string, pages: number[]) {
  const pdfBuffer = await readFile(filePath);
  const pdfParser = new PDFParse({ data: pdfBuffer });
  
  const result = await pdfParser.getScreenshot({
    partial: pages,
    scale: 1.5,
    imageBuffer: true,
  });
  
  return result.pages.map(page => ({
    page: page.pageNumber,
    data: Buffer.from(page.data),
  }));
}

嗯,几十行代码,需求搞定,下班!

阶段二:抽成独立模块

好景不长,没过多久,隔壁组的同事找过来了:

"嘿,听说你写了个 PDF 截图的工具?我们这边有个文档预处理服务也需要这个功能,能不能给我们用用?"

于是我把这个函数从业务项目里抽出来,放到了一个独立的内部模块里。

但抽离的过程中发现了一些问题:

  1. 内存泄漏风险pdfjs-distpdf-parse 的底层依赖)会在内存里缓存解析结果,大量 PDF 处理后内存蹭蹭往上涨
  2. 缺少取消机制:处理几百页的大文件时,用户等不及想取消,但没有中断的能力
  3. 输入格式单一:只支持文件路径,不支持 Buffer 和流式输入

既然要给其他模块用了,这些问题就得解决。于是开始了第一次重构:

  • 引入子进程隔离,PDF 渲染跑在独立进程里,进程退出后内存自动释放
  • 支持 AbortController 取消操作
  • 支持文件路径 / Buffer / ReadableStream 三种输入格式

阶段三:发布为 npm 包

又过了一段时间,其他团队的同事也找过来了:

"你们那个 PDF 截图工具挺好用的,我们想在另一个项目里用,能不能发个 npm 包?" "对了,我们有个批量处理的场景,能不能加个进度回调?" "还有,我们运维同学想在脚本里用,能不能支持命令行?"

好家伙,需求越来越多了。

既然要发 npm 包,那就得认真对待了。于是有了这次比较彻底的重构:

  • 完善的 TypeScript 类型定义
  • 进度回调机制(onProgress
  • CLI 工具支持,方便脚本调用和 AI Agent 集成
  • 多种输出格式:Buffer / Base64 / 文件路径
  • 超时控制,避免子进程卡死

最终,这个工具从一个几十行的函数,演变成了一个结构完整的 npm 包——@guangmingz/pdf-snapshot

设计思路与实现框架

聊完演化过程,来深入剖析一下 pdf-snapshot 的设计思路。

核心设计原则

在设计这个工具时,我遵循了几个核心原则:

  1. 主进程零污染:PDF 渲染是内存大户,不能污染主进程
  2. 输入输出灵活:支持多种输入格式和输出格式,适应不同场景
  3. 可控性强:支持取消、超时、进度回调
  4. API 简洁:一个函数搞定,不需要复杂的初始化流程

模块架构

整个项目的目录结构如下:

src/
├── core/
│   ├── snapshot.ts      # 核心截图函数(主进程)
│   ├── pdf-info.ts      # 获取 PDF 信息
│   └── worker.ts        # 子进程 Worker(实际渲染)
├── utils/
│   ├── input-normalizer.ts   # 输入归一化
│   ├── page-resolver.ts      # 页码解析
│   ├── output-formatter.ts   # 输出格式化
│   └── worker-manager.ts     # 子进程管理
├── cli/
│   └── index.ts         # 命令行入口
├── types.ts             # 类型定义
├── errors.ts            # 错误类
├── constants.ts         # 常量
└── index.ts             # 导出入口

可以看到,模块划分还是比较清晰的:

  • core:核心逻辑,包括主进程入口和子进程 Worker
  • utils:工具函数,处理输入输出和子进程管理
  • cli:命令行接口

子进程隔离:内存泄漏的终极解法

这是整个设计中最关键的一环。

为什么要用子进程?因为 pdfjs-dist 在解析 PDF 时会在 V8 堆上分配大量内存,即使调用了 destroy() 方法,也很难完全释放。如果在主进程里处理大量 PDF,内存会越积越多,最终 OOM。

解法很简单也很粗暴——用子进程。子进程退出后,操作系统会自动回收它占用的所有内存,干净利落。

整个流程如下:

┌─────────────────────────────────────────────────────────────────┐
│                        主进程 (Main Process)                     │
├─────────────────────────────────────────────────────────────────┤
│  1. 接收输入 (文件路径 / Buffer / Stream)                         │
│  2. 归一化为临时文件路径                                          │
│  3. 解析页码参数                                                  │
│  4. Fork 子进程,传递任务参数                                      │
│  5. 等待子进程完成,接收结果文件路径                                │
│  6. 根据 output 参数格式化输出                                    │
│  7. 清理临时文件                                                  │
└───────────────────────────┬─────────────────────────────────────┘
                            │ IPC 通信(传递路径,不传 Buffer)
                            ▼
┌─────────────────────────────────────────────────────────────────┐
│                        子进程 (Worker Process)                   │
├─────────────────────────────────────────────────────────────────┤
│  1. 读取 PDF 文件                                                │
│  2. 调用 pdf-parse 渲染指定页面                                   │
│  3. 将截图写入临时目录                                            │
│  4. 返回文件路径 + 元数据                                         │
│  5. 退出进程(内存自动释放)                                       │
└─────────────────────────────────────────────────────────────────┘

这里有个细节值得一提:IPC 通信只传文件路径,不传 Buffer

为什么?因为 IPC 传输大数据很慢,一张截图可能有几 MB,如果通过 IPC 传 Buffer,性能会很差。所以我们让子进程把截图写到临时目录,IPC 只传路径和元数据(宽高、大小),主进程再按需读取。

子进程的核心代码:

process.on('message', async (msg: WorkerRequest) => {
  const { pdfPath, pages, scale, outputDir } = msg;
  let pdfParser: PDFParse | null = null;

  try {
    const pdfBuffer = await readFile(pdfPath);
    pdfParser = new PDFParse({ data: pdfBuffer });

    // 一次性传入所有页码,避免重复解析 PDF
    const screenshotResult = await pdfParser.getScreenshot({
      partial: pages,
      scale,
      imageBuffer: true,
    });

    const results: PageInfo[] = [];
    for (const page of screenshotResult.pages) {
      const filePath = join(outputDir, `page-${page.pageNumber}.png`);
      await writeFile(filePath, Buffer.from(page.data));
      results.push({ pageNumber: page.pageNumber, filePath, width: page.width, height: page.height });
    }

    process.send!({ success: true, pages: results });
  } catch (error) {
    process.send!({ success: false, error: error.message });
  } finally {
    await pdfParser?.destroy();
    process.exit(0);  // 退出进程,内存自动释放
  }
});

输入归一化:统一处理多种输入格式

为了支持文件路径、Buffer、ReadableStream 三种输入格式,我设计了一个「输入归一化」层:

export async function normalizeInput(input: PdfInput): Promise<{ path: string; isTempFile: boolean }> {
  // 文件路径:直接使用
  if (typeof input === 'string') {
    return { path: input, isTempFile: false };
  }
  
  // Buffer / Stream:写入临时文件
  const tempPath = join(tmpdir(), `pdf-${randomUUID()}.pdf`);
  
  if (Buffer.isBuffer(input)) {
    await writeFile(tempPath, input);
  } else {
    // Stream
    const chunks: Buffer[] = [];
    for await (const chunk of input) {
      chunks.push(chunk);
    }
    await writeFile(tempPath, Buffer.concat(chunks));
  }
  
  return { path: tempPath, isTempFile: true };
}

不管用户传什么格式,最终都归一化为文件路径,后续逻辑只需要处理文件路径即可。这种「归一化」的设计模式在很多场景下都很实用。

取消与超时:让操作可控

处理大文件时,用户可能等不及想取消;或者子进程卡死了需要超时兜底。这两个能力是生产环境必备的。

取消能力基于标准的 AbortController

const controller = new AbortController();
setTimeout(() => controller.abort(), 5000);  // 5 秒后取消

try {
  await snapshotPdf('./large.pdf', { signal: controller.signal });
} catch (error) {
  if (error instanceof SnapshotAbortedError) {
    console.log('操作被取消');
  }
}

超时控制在子进程管理器里实现:

const timer = setTimeout(() => {
  child.kill('SIGKILL');  // 强制杀死子进程
  reject(new SnapshotTimeoutError(timeout));
}, timeout);

进度回调:让等待不再焦虑

批量处理时,用户需要知道当前进度。虽然子进程是一次性处理所有页面的,但我们至少可以在「开始」和「完成」两个时机通知用户:

await snapshotPdf('./document.pdf', {
  pageRange: [1, 100],
  onProgress: (progress) => {
    // progress.stage: 'preparing' | 'completed'
    // progress.percent: 0 | 100
    console.log(`[${progress.stage}] ${progress.percent}%`);
  },
});

为什么不支持逐页进度?因为 pdf-parsegetScreenshot 是一次性处理所有页面的,中间没有回调钩子。如果要实现逐页进度,需要改成逐页调用,但这样会有性能问题(每次调用都要重新解析 PDF)。权衡之下,选择了「阶段进度」的方案。

CLI 工具:让 AI 也能用

最后聊聊 CLI 工具。

为什么要做 CLI?除了方便运维同学写脚本,还有一个重要原因——方便 AI Agent 调用

现在各种 AI 编程助手越来越流行,它们通常通过命令行来调用工具。如果你的工具只有 API 没有 CLI,AI 就很难直接使用。

pdf-snapshot 的 CLI 使用起来很简单:

# 截取第 1-10 页
pdf-snapshot -r 1-10 -o ./output document.pdf

# 截取指定页
pdf-snapshot -p 1,5,10 document.pdf

# 从标准输入读取(支持管道)
cat document.pdf | pdf-snapshot -o ./output -r 1-5 -

# 仅查看 PDF 信息
pdf-snapshot --info document.pdf

CLI 的实现基于 commander,核心是把命令行参数映射到 snapshotPdf 的 options:

program
  .argument('<input>', 'PDF 文件路径')
  .option('-o, --output <dir>', '输出目录', './pdf-screenshots')
  .option('-p, --pages <pages>', '离散页码')
  .option('-r, --range <range>', '页码范围')
  .option('-s, --scale <number>', '缩放比例', '1.5')
  .action(async (input, opts) => {
    const results = await snapshotPdf(input, {
      output: 'file',
      outputDir: opts.output,
      pageRange: parseRange(opts.range),
      pages: parsePages(opts.pages),
      scale: parseFloat(opts.scale),
    });
    console.log(`✅ 完成!已保存 ${results.length} 张截图`);
  });

还贴心地加了进度条:

⏳ 正在截图...
  [████████████████████████████████████████] 100% | 50/50 页

✅ 完成!已保存 50 张截图到 ./pdf-screenshots

总结

回顾 pdf-snapshot 的演化过程:

  1. 阶段一:一个 utils 函数,解决单点需求
  2. 阶段二:抽成独立模块,解决内存泄漏、支持取消和多种输入格式
  3. 阶段三:发布 npm 包,增加进度回调、CLI 工具、完善类型定义

这个过程其实挺有代表性的。很多时候我们写的工具函数,一开始只是为了解决眼前的问题,但随着需求的增加和使用场景的扩展,它会逐渐演化成一个更通用、更健壮的模块。

关键是要在演化过程中保持代码的可维护性可扩展性。子进程隔离、输入归一化、取消超时机制……这些设计不是一开始就有的,而是在实际使用中逐步发现问题、解决问题后沉淀下来的。

最后,如果你也有 PDF 截图的需求,欢迎试试 pdf-snapshot!

GitHub 地址:pdf-snapshot

有问题欢迎提 Issue,有改进想法欢迎 PR!

❌