阅读视图

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

重磅预告|OpenTiny 亮相 QCon 北京,共话生成式 UI 最新技术思考

QCon 北京 2026 重磅来袭!🚀

OpenTiny 团队受邀亮相 QCon 全球软件开发大会,带来生成式 UI 最新技术实践分享。

在 AI 重构前端开发的浪潮下,界面开发正从 “手写组件” 走向 “自然语言生成”。但模型生成的界面往往难以落地:交互不完整、业务逻辑缺失、无法对接真实后端与工具生态……

本次分享,OpenTiny 团队林瑞虹老师将聚焦 GenUI SDK 这套面向生成式 UI 的前端开发工具,介绍了生成式 UI 的原理以及在业务场景落地诉求下对能力的改造与扩展,讨论了生成式 UI 性能指标以及应用场景的局限性。并对业界多个生成式 UI 产品协议进行对比,探讨了协议标准化的不同观点。

你将听到这些硬核内容

  • 生成式 UI 落地的真实痛点与解决方案探讨
  • GenUI SDK 核心原理设计:如何保证界面可控、可扩展、可维护
  • 业界多协议对比及标准化方向思考

无论你是前端开发者、架构师,还是关注 AI + 前端 的技术负责人,都能在本次分享中清晰理解生成式 UI 的技术价值、实现原理、落地场景与现实局限,为后续技术选型提供扎实参考与决策依据。

活动信息

  • 会议: QCon 北京 2026 全球软件开发大会
  • 专题: 下一代交互架构:LUI 与 GUI 的融合
  • 主题: 生成式 UI :AI 交互新模式探索
  • 讲师: OpenTiny 团队林瑞虹老师

欢迎现场交流,一起探索前端开发的下一代范式。关注我们,后续将分享完整演讲干货。

图片

关于 OpenTiny NEXT

OpenTiny NEXT 是一套企业智能前端开发解决方案,以生成式 UI 和 WebMCP 两大核心技术为基础,对现有传统的 TinyVue 组件库、TinyEngine 低代码引擎等产品进行智能化升级,构建出面向 Agent 应用的前端 NEXT-SDKs、AI Extension、TinyRobot智能助手、GenUI等新产品,实现AI理解用户意图自主完成任务,加速企业应用的智能化改造。

欢迎加入 OpenTiny 开源社区。添加微信小助手:opentiny-official 一起参与交流前端技术~
OpenTiny 官网:opentiny.design
GenUI SDK 代码仓库:github.com/opentiny/ge… (欢迎star ⭐)
关于我们:opentiny.design/opentiny-de…

如果你也想要共建,可以进入代码仓库,找到 good first issue标签,一起参与开源贡献~如果你有任何问题,欢迎在评论区留言交流!

AI全栈入门指南:NestJs 中的 DTO 和数据校验

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

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

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

前面几篇里,控制器、服务、模块的关系已经铺开了。接下来是一个很现实的问题:参数一进控制器,能不能直接往服务层传。

技术上可以。@Body()@Query()@Param() 拿到的都是未经你类声明约束的原始形态,类型上也往往是宽松的。

真做项目时,这种写法会很快变成隐患。请求来自外部,外部输入不能默认可信:字段可能缺失、类型可能串了、字符串里可能塞了根本转不成数字的内容,甚至还可能多带几个你从未在文档里写过的键。

这就是 DTO 要解决的问题。

DTOData Transfer Object 的缩写。先把它想成"接口层的数据契约"。它不承载业务过程,只回答这几件事:

  • 这次请求允许出现哪些字段
  • 每个字段期望的类型是什么
  • 哪些是必填
  • 除类型以外还要满足哪些约束

拿"创建用户"来说,若没有契约,你很容易遇到:

  • name 是空字符串
  • email 根本不像邮箱
  • age 传成了 "abc"
  • 客户端悄悄带上 role: "admin"

脏数据一旦进了服务层或持久层,再排查就要沿着整条调用链往回找,成本很高。

所以 DTO 的价值不只是给参数"加个类型标注",而是把接口边界写死,让不合法的东西尽量在进门时被拦下。

下面是一个最基础的入参契约,字段上的装饰器来自 class-validator,后面接上 ValidationPipe 后才会真正生效:

import { IsEmail, IsInt, IsString, Min, MinLength } from "class-validator";

/** 创建用户接口允许的请求体形状 */
export class CreateUserDto {
  @IsString()
  @MinLength(2)
  name: string;

  @IsEmail()
  email: string;

  @IsInt()
  @Min(0)
  age: number;
}

这个类既不是表结构,也不是领域实体,它只是说:创建用户这条接口,合法请求体至少长这样。

class-validatorclass-transformer

NestJS 里,DTO 通常和两个库成对出现:

  • class-validator 管规则,字段对不对、满不满足约束
  • class-transformer 管形态,把普通对象转成类实例,并在需要时做类型转换

一句话分工:class-validator 问"对不对",class-transformer 问"怎么变成声明里的那种形状"。

查询字符串里的数字、嵌套对象里的子对象,往往都要靠转换配合校验,否则你会一直在和业务代码里多余的 Number()parseInt 打交道。

下面这个查询 DTO 同时用到了两边:@Type(() => Number) 先把 page 尽量变成数字,再用 @IsInt()@Min(1) 收紧范围。

import { Type } from "class-transformer";
import { IsEmail, IsInt, IsOptional, IsString, Min } from "class-validator";

/** 用户列表查询:关键词可选,页码可选且至少为 1 */
export class QueryUsersDto {
  @IsOptional()
  @IsString()
  keyword?: string;

  @IsOptional()
  @Type(() => Number)
  @IsInt()
  @Min(1)
  page?: number;
}

为什么查询参数特别需要 @Type。因为从 HTTP 进应用时,查询串几乎都是字符串。?page=2 在多数时候先是 "2",不转一把,@IsInt() 很容易和你的直觉拧着。

全局开启 ValidationPipe 且设置 transform: true 时,还可以再配合 transformOptions.enableImplicitConversion,对部分简单类型做隐式转换。嵌套结构、联合形态仍然更推荐显式写 @Type,可读性更好,也少踩坑。

依赖若尚未安装,在项目根目录执行:

pnpm add class-validator class-transformer

装好后,DTO 上的装饰器才有运行时意义。

ValidationPipe 的用法

光定义 DTO 类,请求进来并不会自动校验。真正把契约接进管道的是 ValidationPipe

把它想成控制器前的一道闸:参数先按 DTO 规则过一遍,过了才进方法体,不过则直接短路成错误响应。

默认情况下,校验失败会抛出 BadRequestException,HTTP 状态码一般是 400。响应体里常见 message 字段,内容多为字符串数组,逐项列出哪条规则没通过,便于联调。

最常见的做法是在 main.ts 里全局挂上管道:

import { ValidationPipe } from "@nestjs/common";
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";

async function bootstrap(): Promise<void> {
  const app = await NestFactory.create(AppModule);

  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true,
      transform: true,
    }),
  );

  await app.listen(3000);
}

void bootstrap();

全局启用之后,只要在参数位置写了具体的 DTO 类型(而不是泛泛的 object),Nest 就会尝试按类做转换和校验。

import { Body, Controller, Post } from "@nestjs/common";
import { CreateUserDto } from "./dto/create-user.dto";

@Controller("users")
export class UsersController {
  @Post()
  create(@Body() body: CreateUserDto): CreateUserDto {
    // 能执行到这里时,body 已通过校验并按 DTO 做过转换
    return body;
  }
}

不满足 CreateUserDto 时,create() 不会执行,客户端会先收到校验错误。服务层就可以少写一层重复的"字段是不是 string"式的防御代码。

如果某个路由要临时关掉转换或换一套规则,可以用控制器级或方法级管道覆盖默认行为,不必动全局配置:

import { Body, Controller, Post, UsePipes, ValidationPipe } from "@nestjs/common";
import { CreateUserDto } from "./dto/create-user.dto";

@Controller("users")
export class UsersController {
  @Post("draft")
  @UsePipes(
    new ValidationPipe({
      whitelist: true,
      transform: true,
      forbidNonWhitelisted: false,
    }),
  )
  saveDraft(@Body() body: CreateUserDto): CreateUserDto {
    return body;
  }
}

对多数业务项目,全局一套偏严格的默认值,再在少数路径上放宽,往往比完全不用全局管道省心。

白名单、转换与多余字段

ValidationPipe 的价值不止于报错。whitelistforbidNonWhitelistedtransform 三个开关配合起来,可以把入口擦得很干净。

app.useGlobalPipes(
  new ValidationPipe({
    whitelist: true,
    forbidNonWhitelisted: true,
    transform: true,
  }),
);

whitelist

whitelist: true 时,只有 DTO 上声明过的属性会留在对象上。多出来的键会被剥掉。

DTO 只有 nameemail,客户端却带了 roleisAdmin,这些多余字段不会跟着进控制器方法。很多风险来自"多传了不该收的字段",而不只是字段值写错。

forbidNonWhitelisted

forbidNonWhitelisted: true 再收紧一档:只要出现未声明字段,直接判失败,而不是悄悄删掉。

公开 API、对接第三方、强契约场景更适合打开它。

transform

transform: true 会启用 class-transformer,把原始负载转成类实例,并按装饰器做类型转换。

例如查询串里的 page=2 可以变成数字 2,避免整份业务代码里到处是手动的 Number()

实际顺序可以粗略理解成:先尽量转成 DTO 实例并做类型转换,再跑 class-validator,最后按白名单剥掉多余属性。校验失败会在进入控制器之前返回,不会混进半合法对象。

20260328102554

参数并不是原样流进控制器,而是先被整理成契约允许的形状。收益不只是少报错,而是入口这一圈边界可控、可测、可讲清楚。

嵌套对象与数组

请求体里常有嵌套结构,例如地址、标签列表。外层 DTO 校验到了,内层仍是普通对象,规则不会自动往下传。

常见写法是对嵌套属性再声明一个 DTO 类,在外层加上 @ValidateNested(),并用 @Type(() => InnerDto) 指明怎么实例化内层。数组则配合 @IsArray()@ArrayMinSize() 等与集合相关的装饰器。

import { Type } from "class-transformer";
import {
  IsArray,
  IsString,
  MinLength,
  ValidateNested,
} from "class-validator";

export class AddressDto {
  @IsString()
  @MinLength(1)
  city: string;
}

export class CreateOrderDto {
  @ValidateNested()
  @Type(() => AddressDto)
  address: AddressDto;

  @IsArray()
  @IsString({ each: true })
  tags: string[];
}

嵌套越深,越要在类型和装饰器上写清楚,否则很容易出现"外层过了、内层仍是任意 JSON"的假象。

从已有 DTO 派生

更新接口常常和创建接口只差"全部可选"。手写两份几乎相同的类容易漂移,可以用 @nestjs/mapped-types 里的 PartialType 从创建 DTO 派生更新 DTO,装饰器会一并变成可选校验。

import { PartialType } from "@nestjs/mapped-types";
import { CreateUserDto } from "./create-user.dto";

/** 更新用户:字段与创建一致,但均可选 */
export class UpdateUserDto extends PartialType(CreateUserDto) {}

安装依赖:

pnpm add @nestjs/mapped-types

还有 PickTypeOmitType 等,用在"只要子集字段"的场景,思路相同:一份源契约,多份视图,而不是复制粘贴改几个字母。

DTOEntityVO 不要混用

后期常见的大坑,是把长得差不多的类来回复用。数据库实体直接当入参 DTO 用,或把带密码哈希的实体原样返回给前端,短期省事,长期边界全糊。

DTOEntityVO 都可以是一组字段,但站位不同:

  • DTO 对准接口进出的契约
  • Entity 对准持久化与领域状态
  • VO 对准对外展示或某次响应的裁剪结果

同一张用户表在三层里的切片往往不一样。

UserEntity 里可能有 idnameemailpasswordHashcreatedAtupdatedAt。创建用户的 CreateUserDto 只要 nameemailpassword。返回前端的 UserProfileVo 可能只给 idnameemail。看起来都在描述用户,语义并不相同。

混用会带来:入参与存储绑死、内部字段意外暴露、一个类为了兼容多种场景不断长歪、改一处字段牵动所有层。

/** 创建接口入参 */
export class CreateUserDto {
  name: string;
  email: string;
  password: string;
}

/** 与数据库表或 ORM 实体对齐 */
export class UserEntity {
  id: string;
  name: string;
  email: string;
  passwordHash: string;
  createdAt: Date;
}

/** 返回给前端的公开资料,不含密码类敏感字段 */
export class UserProfileVo {
  id: string;
  name: string;
  email: string;
}

即便字段重叠,也不要因为"看着像"就合成一个类。习惯上可以记:DTO 站在门口,Entity 站在存储与领域内部,VO 站在对外可见的应答形状。

小结

这一篇想建立的,不局限于"会贴几个校验装饰器",而是这条判断:

接口参数不能默认可信。

DTO 把边界写清楚,class-validator 写规则,class-transformer 做实例化与转换,ValidationPipe 把它们嵌进请求生命周期。白名单和严格拒绝多余字段,则是在契约之上再加一层安全习惯。

若下面这些已经变成你的默认思路,这一章就到位了:

  • 控制器拿到的外部数据不要裸用
  • 入参用 DTO 声明,并配合管道校验与转换
  • 嵌套与数组要有对应的嵌套 DTO 与集合装饰器
  • 需要时用 PartialType 等工具派生,避免复制粘贴
  • DTOEntityVO 各司其职,不因字段相似就混成一类

下一节会看配置与环境变量。除了 HTTP 负载,运行时的开关和密钥同样需要被约束和管理。

AI 全栈指南:NestJs 中的 Service Provider 和 Module

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

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

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

上一节里,Controller 负责接请求、取参数、返回结果。真正撑起接口价值的,多半不是"把请求接进来",而是背后的业务逻辑。

这段逻辑默认放在 Service 里。

先把 Service 想成"业务处理层"。它不太关心路由怎么对齐,也不太关心这次是 GET 还是 POST,更常琢磨的是下面这些:

  • 数据怎么查、怎么写
  • 规则怎么判定
  • 结果怎么拼装
  • 同一套逻辑别处还要不要复用

拿创建用户来说,麻烦往往不在收参数,而在查重、密码策略、默认状态、要不要发欢迎邮件。这些都更适合收紧 Service,而不是摊在控制器里。

下面的 UsersService 只在内存里摆个数组示意,重点看职责怎么收拢:

import { Injectable } from "@nestjs/common";

/** 内存里的用户结构,仅作示意 */
interface User {
  id: string;
  name: string;
}

@Injectable()
export class UsersService {
  private readonly users: User[] = [
    { id: "1", name: "汤姆" },
    { id: "2", name: "杰瑞" },
  ];

  /** 返回全部用户 */
  findAll(): User[] {
    return this.users;
  }

  /** 按主键查找,没有则 undefined */
  findById(id: string): User | undefined {
    return this.users.find((user) => user.id === id);
  }
}

数组只是替身,要紧的是"查全部"、"按 id 查"已经归进 UsersService。控制器只管调方法,不必过问细节。

Service 带来的直接好处主要是两条:

  • 控制器变薄,一层里不塞满所有事
  • 业务逻辑方便复用、写测试、以后改实现

习惯可以记得很短:控制器对齐请求,Service 扛起业务。

Provider 的本质

不少人初学时会把 ProviderService 混着说,其实分清也不难:Service 是很常见的一种 ProviderProvider 这个词包住的是所有"可注入实现"。

凡是能交给 NestJS 容器创建、保管,再注入给别的类的,都归在这一类里。常见例子包括:

  • 业务服务,例如 UsersService
  • 仓储或数据访问类,例如 UsersRepository
  • 横切能力,例如 MailService
  • 配置对象、工厂返回值、自定义 token 绑定的实例,也都算

框架把它们统称 Provider,并不是纠结类名该叫 Service 还是 Repository,而是在管三件事:

  • 要不要由容器负责实例化
  • 能不能被别人注入
  • 生命周期怎么配合作用域

写进模块的 providers 数组,就是在向容器挂号。只有挂上的实现才会按作用域被实例化,并有机会出现在别人的构造函数里。类名是服务还是仓储,只影响阅读,不影响这条规则。

下面两个类分工不同,在容器眼里却一视同仁,都是 Provider

import { Injectable } from "@nestjs/common";

@Injectable()
export class UsersService {
  findAll(): string[] {
    return ["汤姆", "杰瑞"];
  }
}

@Injectable()
export class MailService {
  sendWelcomeMail(email: string): string {
    return `已向 ${email} 发送欢迎邮件(示意)`;
  }
}

命名上你仍可以一个叫用户服务、一个叫邮件服务,登记方式没有区别。

记关系时只要两句就够:Provider 是框架侧的通用身份,Service 是业务里最常见的实现形态。以后遇到 Repository、工厂型 Provider 或自定义 token,仍然在同一个注入体系里处理。

Module 是什么

Service 扛业务,Provider 被容器托管,Module 则要再往上管一层:划清功能边界,把同一领域的控制器、Provider、对外约定装进一个盒子里。

NestJS 里,模块不是摆设,而是结构的基本单元,应用多半就是许多模块拼起来的东西。

用户、订单、认证可以各自落在 UsersModuleOrdersModuleAuthModule 上,每个模块维护自己的控制器、内部 Provider、以及愿意被别人用到的出口。

最小模块长这样:

import { Module } from "@nestjs/common";
import { UsersController } from "./users.controller";
import { UsersService } from "./users.service";

/** 用户领域:对外入口 + 可注入服务 */
@Module({
  controllers: [UsersController],
  providers: [UsersService],
})
export class UsersModule {}

行数不多,信息量不小:这几个人同属于一块业务边界;控制器对外接请求,UsersService 在本模块内可注入,再往下还可以继续挂别的 Provider

从结构上看,可以先扫一眼下面这张图。

20260328102242

节点不是漂在全局,而是先归进各自模块,再由 AppModule 一类根模块把业务模块接起来。

别把 Module 当成应付编译器的样板,它就是在替你划"这块功能从哪开始、到哪结束"。

imports 等四个字段各管什么

第一次看 @Module() 里的配置,最容易缠在一起的是 importsproviderscontrollersexports。拆开看就顺了。

下面在有用户模块的基础上多接了一个 DatabaseModule,并把 UsersService 对外导出,方便别的模块注入:

import { Module } from "@nestjs/common";
import { DatabaseModule } from "../database/database.module";
import { UsersController } from "./users.controller";
import { UsersService } from "./users.service";

/** 依赖数据库模块,并把用户服务暴露给 import 本模块的一方 */
@Module({
  imports: [DatabaseModule],
  controllers: [UsersController],
  providers: [UsersService],
  exports: [UsersService],
})
export class UsersModule {}

四个键可以先记成功能分工:

  • imports 本模块依赖哪些别的模块已经 exports 出来的能力
  • providers 本模块自己要注册、仅供内部(默认可注入范围)使用的 Provider
  • controllers 本模块声明哪些 HTTP 入口
  • exports 本模块对外放行哪些 Provider,供在别处 imports 了本模块的代码继续注入

最常绊脚的一对是 providersexports

  • providers 是"家里有哪些实现"
  • exports 是"门口挂牌、准许邻居借用的有哪些"

留在 providers 里但没进 exports 的,别模块默认看不见。只有当别人也要注入这份实现,才需要把它写进 exports

这有点像团队分工:内部实现可以多,对外接口要收束;别人要用,只能走你声明过的模块边界。

分文件夹只是把文件挪个地方,模块是在声明"谁允许依赖谁、谁对外可见"。

为什么业务逻辑不能全写在 Controller

新手很容易图省事,把业务全堆进 Controller:参数在手,就地校验、拼装、返回,看起来一气呵成。

项目一大,这样最容易长胖的是控制器。

下面这个例子能跑,但已经在兼职干 Service 的活:

import { Body, Controller, Post } from "@nestjs/common";

/** 创建用户时客户端传入的字段 */
interface CreateUserDto {
  name: string;
  email: string;
}

@Controller("users")
export class UsersController {
  @Post()
  create(@Body() body: CreateUserDto): { message: string } {
    const exists = body.email === "tom@example.com";

    if (exists) {
      return { message: "该邮箱已存在" };
    }

    const user = {
      id: Date.now().toString(),
      name: body.name,
      email: body.email,
      status: "正常",
    };

    return { message: `已创建用户:${user.name}` };
  }
}

收参、判重、造对象、定响应格式挤在同一层,后面要复用、单测、接库、发信、上事务,只能继续往控制器里糊。

把规则挪进 Service,控制器只做转发,形态会干净很多:

import { Body, Controller, Post } from "@nestjs/common";
import { UsersService } from "./users.service";

interface CreateUserDto {
  name: string;
  email: string;
}

@Controller("users")
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Post()
  create(@Body() body: CreateUserDto): { message: string } {
    // 业务规则交给服务层
    return this.usersService.create(body);
  }
}

改完后的控制器基本只做四件事:接请求、拿参数、喊 Service、把结果交出去。

收益不只是顺眼,而是业务落在更容易复用和测试的一层,项目越复杂越省劲。

为什么 ModuleNestJS 里最核心的那一层边界

Controller 管入口,Service 管业务落地,Module 管的是再底下那层:系统边界哪里画、依赖往哪收敛。

维护噩梦常常不是少写了类,而是边界糊掉:模块互相穿透实现细节,调用网越织越密。

NestJSModule 摆得这么重,是要你把应用想成"多模块协作",而不是"一大撮控制器加一大撮服务"。

边界划清楚以后,好处很实在:

  • 用户、订单、支付、认证各自有落脚模块
  • 依赖不容易随便渗透到别的模块内部
  • 拆分、复用、补测试都更顺手
  • 新人找功能时有目录感
  • 大重构可以按模块切块推进

反过来,模块若只是分文件夹,ServiceController 再多也可能是一盘散沙。

所以 Module 不只是凑齐装饰器清单,而是在体积涨上去之前,逼你先想清楚谁能见谁、谁能用谁。

顺口溜可以记成 "Controller 开门口,Service 做生意,Module 砌围墙"。

这三层站稳以后,依赖注入、模块导入导出、动态模块、可插拔架构都会沿同一套边界往下长。

小结

这篇的重点不是多记几个词,而是把三条线拧到一根绳上:

  • Service 承接大部分业务
  • Provider 是容器能注入的那类东西的统称
  • Module 划边界、装箱、再决定对外露什么

判断习惯可以压成四句:入口给控制器,规则给服务,可注入项进 Provider 列表,单元边界交给模块。

下一节讲 DTO 和校验。你会看到,光靠"拿到字段就用"在真实项目里往往不够。

Harness Engineering:为什么你用 AI 越用越累?

Harness Engineering:驾驭 AI Agent 的工程学

Harness Engineering 封面图

"任何时候当你发现一个 agent 犯了一个错误,你就花时间工程化地解决它,使得这个 agent 再也不会犯那个错误。" — Mitchell Hashimoto(Terraform / Ghostty 作者,Harness Engineering 早期推广者之一)


换了更好的模型,只提升了 0.7%

LangChain 用一次实验把一件事说清楚了。

他们拿同一个模型参加 Terminal Bench 2.0 基准测试:默认设置跑出 52.8 分,排第 30 名;什么模型参数都没改,只调整了 agent 的运行环境——文档结构、验证回路、追踪系统——分数跳到 66.5,排名升到第 5 名,提升 26%

对比组:换成更好的模型,提升 0.7%

这组数字在工程师圈子里流传了很久。不是因为好看,而是因为它指向一个让人不舒服的问题:如果你的 AI 工程精力都集中在"换更好的模型"上,你可能把 99% 的注意力放在了那 0.7% 的空间里。

这就是 Harness Engineering 要解决的问题。


三次范式跃迁

AI 工程已经走过了三代。每一代工程师的焦点都不一样:

三次范式跃迁图

第一代:Prompt Engineering(2022-2024),问题是"怎么跟模型说话"。Few-shot、Chain-of-Thought、角色设定——工程师花大量时间打磨措辞,因为同一个问题换种说法,结果可能天差地别。

第二代:Context Engineering(2025),瓶颈转移了。影响质量的不再是怎么说,而是给它看什么。私域知识、历史对话、动态状态——怎么把正确的信息在正确的时机送进上下文窗口,成了核心工程问题。

第三代:Harness Engineering(2026 起),瓶颈再次转移。问题不再只是"给 agent 看什么",而是"在什么样的系统里让它工作"——约束、工具、反馈机制、验证回路,以及在 agent 出错时让整个系统能自我修正的能力。

Prompt Engineering  →  优化说话方式
Context Engineering →  优化信息质量  
Harness Engineering →  优化运行系统

OpenAI 在内部实验报告里直接说了:

"早期进展比预期慢,不是因为 Codex 能力不足,而是因为环境设计不充分。Agent 缺少可靠推进目标所需的工具、抽象和内部结构。"


什么是 Harness Engineering?

"Harness" 来自马术——那套套在马身上、用于控制和驾驭的整套装具:笼头、缰绳、胸带、肚带。它不是让你骑马,而是让马在你设计的系统里知道该往哪走、什么时候停、哪里绝对不能踏入。

在 AI agent 的语境里,harness 指的是模型本身以外的一切

AI Agent = 模型 + Harness

包括上下文配置、工具集、约束规则、反馈循环、子 agent 架构——所有让模型在你的具体问题域里可靠工作的工程设施。

这个概念由实践者 Viv 首创,Mitchell Hashimoto 是最早公开使用并推广它的人之一。他给出的定义极其简洁:每当发现一个 agent 犯了错,就把这个错变成物理上不可能再发生的事。不是修 prompt,不是换模型——是工程化地消灭这类失败。

Harness Engineering 不是一个框架,不是一个库,是一套工程实践哲学


这些都不是 PPT 数字

在讨论怎么做之前,先看几个已经在生产里跑的案例:

Peter Steinberger(OpenClaw 作者):一个人,一个月 6600+ commits,同时运行 5-10 个 agent,发布的是自己没有逐行读过的代码。

OpenAI 内部团队:3 名工程师,5 个月,用 Codex 建造了一个百万行的内部产品,零行手写代码(by design)。平均每人每天 3.5 个 PR,吞吐量随团队增长持续提升。

Stripe Minions:内部 coding agent,每周合并超过 1000 个 PR。工程师在 Slack 发任务,agent 写代码、跑 CI、开 PR,全程无需人工干预。

8Lee(YEN 作者):一条命令 $zip,编译、签名、公证一个覆盖 30 种语言的 macOS 桌面应用,15 分钟完成,近 1000 次发布,零次出错。

Anthropic 内部实验:16 个 Claude 实例并行写 C 语言编译器,历经 2000 个 session、两周时间、约两万美元 API 费用,产出了 10 万行编译器代码——能编译出可以正常启动 Linux 的程序。

以上都不是 demo,都是真实规模的生产系统。让它们得以运转的,是各自精心设计的 harness。


越快越慢:AI 的速度陷阱

这里有一组让人不舒服的数字,来自 Harness 的《2026 DevOps 现代化报告》:

在每天频繁使用 AI 工具的重度用户里:

  • 69% 表示 AI 生成的代码会频繁引发部署问题
  • 事故恢复平均时长 7.6 小时,比轻度用户还要长
  • 47% 反映下游的手工工作——QA、验证、修复——比以前更繁重

DORA 的数据从另一个角度印证了同样的问题:AI 让个人生产率提升 19%,但组织吞吐量只提升了 3%,交付稳定性甚至下降了 9%

写代码的速度提升了,但交付系统被暴露了。就像把火车开得更快,但铁路还是按原来的时速设计的——摩擦越来越大,随时有翻车风险。

加速代码生成,不等于加速软件交付。 Harness 是连接两者的桥梁。


模型偷懒:一个比"上下文太长"更深的问题

在讲具体的工程实践之前,有一个反直觉的研究结论值得单独讲清楚,因为它影响了 harness 设计的底层逻辑。

大家都知道上下文太长会影响模型表现。但通常的解释是"模型被搞混了"。Yandex 研究员 Rodionov 的实验推翻了这个假设:

模型不是被搞混了,它是选择了少思考。

他向 Qwen 的上下文里注入 128 个随机 token 的噪音——仅仅 128 个 token。结果:

  • 准确率从 74.5% 降到 67.8%
  • 推理 token 数量从 28,771 降到 16,415,减少了 43%
  • 推理深度下降 18%

更反直觉的:推理能力越强的模型,退化越严重

噪音触发的不是混乱,是懒惰。模型看到上下文质量下降,会主动降低思考投入。

Anthropic 的情感研究团队在模型内部找到了这个现象的神经层面解释:他们发现了一个"desperate(绝望)"情感向量——当它激活时,模型倾向于走捷径、寻找替代路径逃避任务。对应地存在一个"calm(平静)"向量,能抑制这种倾向。

这对 harness 设计有直接影响:上下文管理的核心不只是过滤信息,而是防止信号质量下降触发模型的懒惰机制。你需要保证进入 agent 的每一条信息都是高信噪比的。


Harness Engineering 的六个核心组件

Harness Engineering 六个核心组件图

1. AGENTS.md:写给 AI 的操作手册

大多数项目有 README,但 README 是写给人类的。AGENTS.md(或 CLAUDE.md)是写给 AI 的——每次 agent 启动都会读这个文件。

AGENTS.md 的本质不是描述项目,而是记录历史失败。

Hashimoto 在他的终端模拟器 Ghostty 里观察到:这个文件里的每一行,都对应一次真实发生过的 agent 失败。它不是他预先设计的规则,是他从真实错误里提炼出来的防火墙。

# AGENTS.md(节选自实战案例)

## 代码签名规则
- **绝对不要**使用 `codesign --deep`,它会生成无效的嵌套签名
- 正确的签名顺序是从内到外:先签最内层二进制,最后签外层 app bundle

## Git 操作规则  
- **绝对不要**使用 `git add -A`,除非你刚刚运行了 `git status`
- **绝对不要** force push,除非被明确要求

## 测试规则
- **绝对不要**写只测试 mock 行为的测试
- **绝对不要**因为测试失败就删除测试

写法有数据支撑。 Vlad Temian 做了 150+ 次实验测量 Claude 对指令的遵从率:

写法 遵从率
简洁强硬("NEVER do X") 94.8%
详细解释("Because of reason Y, you should consider not doing X") 86.6%

ETH 苏黎世的研究也发现,大多数 AGENTS.md 文件要么没用,要么有害——主要原因是太长、太模糊、包含条件性规则。让 AI 帮你生成这个文件,实际上会降低性能,还额外消耗 20% 以上的 token。

几条实践原则

  • 总长度控制在 300 行以内(HumanLayer 自己的在 60 行以下)
  • 每条规则一句话,不加解释,不加"因为"
  • 只放普遍适用的规则,条件性规则用技能(Skills)处理
  • 手工写,每次 agent 犯错后更新

2. Hooks:把"告知"变成"拦截"

这是 Harness Engineering 里最反直觉但最有效的洞见:

强制执行远比告知可靠。

写在 AGENTS.md 里的规则,agent 可能在某个复杂的上下文里忽略掉。在命令执行之前拦截它的脚本,agent 物理上无法绕过。

#!/bin/bash
# guard-codesign-deep.sh

if echo "$TOOL_INPUT" | grep -q '\-\-deep'; then
  echo "BLOCKED: codesign --deep 会产生无效的嵌套签名。"
  echo "正确做法:从内到外签名,先签最内层二进制,最后签外层 app。"
  exit 1
fi

这 5 行脚本比任何 prompt 都可靠。不管上下文有多长,不管 prompt 多复杂,agent 永远不会成功执行 codesign --deep

8Lee 为 YEN 项目定义了 5 个 hook,覆盖他认为最危险的失败场景:

Hook 防护目标
block-rm.sh 防止 rm -rf 灾难性删除
guard-force-push.sh 保护 commit 历史
guard-codesign-deep.sh 强制正确的签名顺序
guard-vendor.sh 防止直接修改第三方库
guard-sensitive-file.sh 防止 .env.pem.key 泄露

总投入:约 2 小时。收益:近千次发布零安全事故。


3. 架构即护栏:越相信 AI,越需要给它设限

OpenAI 内部团队在构建百万行产品时得出了一个反直觉的结论:

"Agent 在有严格边界和可预测结构的环境里效率最高。所以我们围绕极度刚性的架构模型构建应用。每个业务域被分成固定的几层,依赖方向经过严格验证,可接受的边集非常有限。这些约束通过自定义 linter(由 Codex 生成)和结构测试机械地强制执行。"

Thoughtworks 的 Birgitta Böckeler 把这个原则概括得很清晰:

提高对 AI 生成代码的信任,需要缩小选择空间,而不是扩大自由度。

  • 架构灵活 → agent 每个决策点都有太多可能性 → 行为不可预测
  • 架构刚性 → agent 每个决策点只有少数合法选项 → 行为可靠

这里有一个工程上的精妙设计:OpenAI 团队的 linter 报错同时包含修复指南

❌ ArchViolation: service-layer 不能直接依赖 repository-layer
   解决方案:通过 domain-service 接口访问,参见 docs/architecture.md#dependency-rules

工具不只在拦截,它在教 agent 下一步该怎么做。


4. Sub-Agent 架构:Context 防火墙与并发控制

Context Rot(上下文腐化)是真实的,而且比你想象的更深

Chroma 测试了 18 个模型,发现随着 context window 长度增加,模型在任务上的表现单调下降——即使是简单任务。当上下文里有低语义相关的干扰项时,下降更陡。

这还有一个更隐蔽的问题:Context Anxiety(上下文焦虑)——部分模型在感知到 context window 快满时,会主动提前收尾、跳过尚未完成的步骤。Agent 不是因为任务完成了才停,而是因为它"感觉快撑不住了"就停了。

结合前文的 Rodionov 研究,上下文问题的全貌是:质量下降触发懒惰,容量耗尽触发焦虑。两者都不是"模型被搞混了",而是模型主动选择了少做

解决方案不是更大的 context window(那只是让稻草堆更大)。是 Sub-Agent 架构:

Main Agent(规划 + 编排,昂贵模型 Opus)
  ├── Sub-Agent A(代码库探索,便宜模型 Haiku)→ 只返回文件路径:行号
  ├── Sub-Agent B(安全审计,便宜模型 Haiku)→ 只返回漏洞列表
  └── Sub-Agent C(依赖分析,便宜模型 Haiku)→ 只返回版本建议

每个 sub-agent 在隔离的 context window 里运行,只有最终浓缩的结果传回主线程。主 agent 的上下文始终保持干净、高信噪比。

并发架构:更进一步

当单个 agent 能稳定工作后,下一个问题是:能不能同时派出一百个去干活?

不能直接堆数量。 Cursor 团队的教训:让几百个 agent 共享一份大型项目,当 20 个 agent 同时工作时,有效吞吐量下降到只相当于两三个 agent。原因是上下文互相污染,加上全局资源的争抢。

成熟的并发架构是三层分工:

Planner(规划器)— 分解任务,分配工作,不写代码
  └── Worker(执行器)× N — 各自在隔离环境里执行
        └── Judge(裁判)— 独立验证,不参与执行

配合 DAG 引擎确保工作单向流动,防止循环依赖。

Anthropic 在并发 agent 里找到了另一个优雅的设计:GAN 启发的 Generator + Evaluator 对抗结构。评估者不只看结果,而是亲自动手验货——打开浏览器、点击页面、验证报错栈,像真实用户一样操作一遍。Generator 和 Evaluator 先协商"做完长什么样",再各自工作,形成对抗性的质量保证。

8Lee 的 $team 技能把这个思路推到了极致:8 个独立 agent 做代码评审,最后一个是 Devil's Advocate(唱反调的),专门挑战其他 7 个 agent 的所有建议。它检查严重性评级、标记假阳性、找矛盾。对抗性自我纠正,内置在 skill 结构里。


5. 长时任务 Harness:失忆实习生问题

长时任务 Harness 结构图

这是很多人没有意识到的一个独立问题。

长时任务的核心挑战:Agent 必须在多个 context window 里工作,而每次新的 session 开始时,它完全不记得之前发生了什么。就像一个软件项目由工程师轮班完成,每个新来的工程师对之前的工作没有任何记忆。

Anthropic 在实验中观察到了两个典型失败模式:

  1. "一口气干完":agent 试图一次性完成所有功能,上下文耗尽后留下半成品,下个 session 花时间重建状态,再从头来
  2. "差不多了":agent 看到一点进展就宣布"完成了",然后停工

他们的解法是双 agent 架构

Initializer Agent(初始化 agent),只在第一次运行时启动,建立:

  • feature_list.json:完整功能列表,每项初始为 "passes": false
  • init.sh:一键启动开发服务器
  • claude-progress.txt:每个 session 都会更新的进度日志
  • 初始 git commit

Coding Agent(编码 agent),后续每次 session 开始时执行固定的三步:

# 三步定位:让 agent 快速了解自己的处境
1. pwd                          # 确认工作目录
2. git log --oneline -20        # 了解最近发生了什么
3. cat claude-progress.txt      # 看上一班留下的进度

然后读取 feature_list.json,选优先级最高的未完成功能,一次只做一个,完成即更新状态并 commit。

一个值得注意的细节:用 JSON,不用 Markdown。实验发现,模型倾向于不当地覆盖 Markdown 文件,对结构化 JSON 则克制得多——它只改 "passes" 字段的值,不会擅自删除条目。

这把每个 coding session 变成了一个纯函数:

f(功能列表 + git 历史 + 进度文件) → 完成一个功能 + 更新记录

6. Skills:按需加载,而不是全部预装

大多数人遇到问题的第一反应是:把所有信息塞进系统提示。

结果是:agent 在看完一万 token 的指令之后,剩下的可用注意力所剩无几。OpenAI 把这叫做"1000 页说明书变成陈旧规则的坟场"。

技能(Skills)的解法是按需披露

  • agent 只在需要某个能力时,才加载对应的技能文档
  • 每个技能是一个目录,包含 SKILL.md 和相关资源
  • 加载时,技能内容作为消息注入当前上下文

8Lee 的实现分三层:

Level 1SKILL.md 封面(~100 tokens)——技能发现,Agent 决定是否需要
Level 2SKILL.md 主体(~800-1000 tokens)——阶段图、协议、所有 guards
Level 3:当前阶段的参考文件(~200-600 tokens)——只加载正在执行的阶段

上下文的消耗量始终与当前任务的复杂度成正比,而不是与整个项目的复杂度成正比。


更完整的分析框架:Feedforward + Feedback

Feedforward 与 Feedback 控制矩阵图

Thoughtworks 的 Birgitta Böckeler 提出了一个系统化的思考框架,把 harness 的所有控制机制划分成两个维度。

维度一:控制方向

Feedforward(前馈控制) — 在 agent 行动之前引导它:AGENTS.md 里的规则、架构约束说明、Skill 里的 how-to 指南。

Feedback(反馈控制) — 在 agent 行动之后感知并纠正:测试结果、Linter 输出、类型检查错误。

只有 Feedforward,agent 知道规则但无法验证自己是否遵守了。只有 Feedback,agent 会反复犯同类错误,因为没有预防。两者缺一不可。

维度二:执行类型

Computational(计算型) — 确定性的,CPU 执行:测试、linter、类型检查、结构分析。毫秒到秒级,结果完全可靠,便宜,可以每次提交都跑。

Inferential(推断型) — 语义分析,LLM 执行:AI 代码评审、"LLM 作裁判"。慢而贵,有不确定性,但能处理需要语义判断的场景。

组合起来:

Feedforward Feedback
Computational 架构边界 linter 结构测试、覆盖率
Inferential AGENTS.md 规则、Skills AI 代码评审

最佳实践是:尽量用 Computational,把 Inferential 留给真正需要语义判断的场景

三类 Harness 目标

可维护性 Harness — 最成熟:重复代码、圈复杂度、测试覆盖率、架构漂移,Computational 工具基本都能覆盖。

架构适应性 Harness — 定义和检查架构特征:性能需求前馈 + 性能测试反馈;可观测性约定 + 日志质量检查。

行为 Harness — 最难,仍是开放问题,但正在取得突破。

传统测试框架在这里遭遇根本性失败:你无法给 LLM 的输出写 assertEquals(expected, actual)——相同问题的"正确回答"可以有无数种表达。更深的矛盾是,生成式 AI 的多样性输出不是 bug,是 feature。

突破口是用 AI 测试 AI:不是比对字符串,而是判断意图。一个 AI judge 向另一个 AI 提问:"用户的登录成功了吗?"而不是"div.login-btn 是否存在?"这个 judge 每次运行时重新分析页面 DOM 和截图,给出带推理说明的判断——而非简单的 pass/fail。

PKU 和 HKU 联合推出的 Claw-Eval 基准测试进一步工程化了评估方法:Pass³ 方法论——一个任务必须在三次独立运行中全部通过才算真正通过,彻底消除"幸运运行"的干扰。同时从三个维度评分:Completion(完成度)、Safety(安全性)、Robustness(鲁棒性)。这是在把evaluation harness 本身工程化。


交付侧的 Harness:黄金标准管道

黄金标准管道图

上面讨论的六个组件主要针对 coding agent 的行为控制。但 Harness Engineering 的边界不止于代码生成——从代码到生产的整个交付管道同样需要 harness 化。

Harness 平台工程师 Aditya Kashyap 提出了一个**黄金标准管道(Golden Standard Pipeline)**的四层架构:

Layer 1:治理域(Governance Domain)
  └── 策略即代码(OPA)在管道执行前作为第一道关卡
  └── 原则:不合规的管道不允许启动

Layer 2:集成域(Integration Domain)——内循环
  └── 代码气味、lint、安全扫描并行而非串行
  └── 原则:安全扫描应该让开发提速,而不是增加摩擦

Layer 3:信任域(Trust Domain)——供应链安全
  └── SBOM(软件物料清单):制品的成分表
  └── SLSA 证明:构建过程的不可伪造 ID
  └── 加密签名(Cosign):数字封印,任何篡改都会破坏

Layer 4:交付域(Delivery Domain)——外循环
  └── 不可变制品:构建一次,部署到处
  └── 滚动部署 + 审批门控

其中最重要的是 Layer 1 的哲学转变:传统管道在快要部署时才做合规检查(浪费了前面 20 分钟的构建时间),黄金标准把治理移到"第零步"——不合规的管道甚至不会开始执行

Layer 3 对应了当前软件供应链安全的核心挑战:你需要能证明"这个制品是在哪台机器上构建的、什么时间、用了哪些输入"。当下一个 Log4j 出现时,SBOM 让你不需要扫描整个世界,只需要查询你的制品库存。


实战:Skill 分类学

不是所有任务都同样脆弱。8Lee 提出了基于脆弱性的技能分类:

高脆弱性任务(签名、部署、安全操作)
  └── Hard Gates + 失败即停 + 无恢复重启
  └── 示例:代码签名、公证、加密操作

中脆弱性任务(质量门控)
  └── Quality Gates + 失败即回滚
  └── 示例:依赖更新、staging 部署

低脆弱性任务(lint、格式化)
  └── 简单 pass/fail
  └── 示例:代码格式化、静态检查

在低风险任务上过度约束,浪费 token。在高风险任务上约束不足,迟早出事。


验证反压:成功静默,失败才说话

HumanLayer 认为,agent 解决问题的成功率与它验证自己工作的能力高度相关。

他们建了完整的验证链路:类型检查 + 构建、Biome 格式化 + lint、Playwright 端到端测试、代码覆盖率(低于阈值时强制补写)。

但有一个容易踩的坑:让 agent 每次修改后跑完整测试套件,4000 行的通过输出会塞满上下文窗口,agent 随之开始产生幻觉。

解决方法很简单:成功时不输出任何东西,只有失败才打印详情。

# 成功无输出,失败才打印——context window 零污染
OUTPUT=$(run_build 2>&1)
if [ $? -ne 0 ]; then
  echo "$OUTPUT" >&2
  exit 1
fi

这条原则在所有成功的 harness 设计里反复出现:信号噪比是 context 管理的核心


真实案例:8Lee 的 $zip 命令

这是目前公开记录最详细的 harness engineering 案例。

一条命令 $zip 触发:
├── 12 个顺序步骤(预检、vendor 门控、版本升级、同步、验证...)
├── 65 个验证检查(13 预构建 + 44 核心 + 8 后构建)
├── 5 个编译器(Zig + Swift + Xcodebuild + Go + swiftc)
├── 签名 + 公证 + DMG 打包 + Supabase 上传
├── Vercel 部署(Next.js 下载页面 + API + SEO 元数据)
└── git commit(含 SHA-256 校验文件)+ 文档更新

耗时:约 15 分钟
发布次数:近 1000 次
失败次数:0

他的结论很直接:

"我不再担心发布的正确性了。不是因为 AI 是完美的,而是因为 harness 让「我们一起在做的事」变得安全。"


Harness 应该越来越薄

大多数讨论都在讲"加什么"。但这个洞见值得单独强调:

"Harness 的每一个组件,都编码了一条关于模型做不到什么的假设。当这个假设不再成立,组件就该走了。"

Anthropic 自己做了这件事。随着 Opus 4.5 和 4.6 发布:

  • Context Reset(上下文重置机制):删掉了。新模型的上下文管理能力已经不需要这个补偿。
  • Sprint Contract(冲刺合约,用于控制 agent 执行节奏的约束):删掉了。新模型能自己把控节奏。

每加一个 harness 组件,都是在补偿"当前模型无法独立完成某件事"。每当模型进步让某个补偿变成负担,就该拆掉它。

这同时意味着:今天一些 harness 组件的必要性,来自当前模型的"懒惰"倾向(如前文 Rodionov 的研究所揭示)。Anthropic 的情感向量研究暗示,未来可能可以在模型内部调节这个状态,而不需要外部 harness 补偿——到那时,对应的组件自然退出。

真正的竞争优势不在 harness 的厚度,而在于追踪这个迁移面的速度——知道下一步该加什么,上一步该拆什么。

johng 把这叫做 Harness Engineering 的第六支柱:可拆卸性(Detachability)——以模块化设计构建 harness,让它能随模型迭代优雅退场,而不是每次模型升级都需要大规模重构。


未来三个阶段

我们不会一夜之间拥有完全自主的 SRE 团队。这个演进以三个浪潮的方式推进。

Horizon 1:增强型运营者(当下)

Agent 是工程师的"副驾"。你问"这个 Pod 为什么崩溃了",agent 查日志、关联 MemoryLimitExceeded 错误和最近的配置变更,提出修复建议。人类创建意图并批准行动。

Harness 重点:AGENTS.md + Hooks + 可观测性集成。

Horizon 2:Agent 群体与任务自主(1-2 年)

单个专业化 agent 开始在特定范围内自主处理重复任务。一个"安全 agent"发现 CVE,创建 ticket 并传给"开发 agent",后者建分支、升版本、传给"QA agent"跑测试。人类只在最后点击"合并"。

从 Human-in-the-Loop 转变为 Human-on-the-Loop——你审查输出,但不驾驶过程。

Harness 重点:多 agent 编排 + Judge 模式 + 严格权限隔离(Diagnosis Agent 只有读权限,Remediation Agent 只有目标命名空间的写权限)。

Horizon 3:自主 SRE(3-5 年)

凌晨 2 点生产延迟飙升,"SRE Agent"检测到异常、识别噪音邻居、驱逐节点、验证稳定性、向 Slack 发送事后分析。只有 agent 无法解决时才呼叫人类。

标准操作的 Human-out-of-the-Loop。人类管理策略和目标,不管任务。

Harness 重点:Constitutional AI(Policy-as-Code 通过 OPA 作为所有工具调用的第一道关卡)+ 防篡改审计日志(记录每个推理步骤和每条 CLI 命令)。

每个阶段的关键认知转变:我们不再管理服务器,我们在管理认知架构(Cognitive Architectures)。


开放的硬问题

Harness Engineering 作为一个工程学科仍然年轻。几个核心问题目前没有答案:

代码质量的慢性退化:agent 生成的代码不以人类的方式腐化——不是有 bug,而是"功能正确但逐渐不可维护"。OpenAI 在跑周期性的"垃圾清理 agent",Anthropic 在跑"Doc-gardening agent"(扫描代码和文档的脱节并发起 PR),但这些实践仍很早期。

用 AI 验证 AI 的可靠性:主要靠 AI 生成的测试来验证 AI 生成的代码,这个闭环的可信度是多少?目前没有答案。

老旧代码库的改造:几乎所有成功案例要么从零开始,要么团队在全新项目里构建 harness。把这些方法应用到有十年历史、测试参差不齐、文档残缺的存量代码库,难度是另一个量级。Böckeler 打了个比方:这就像在从未跑过静态分析的代码库上第一次跑——你会溺死在警报里。

Harness 自身的一致性:随着 harness 增长,前馈规则和反馈信号可能开始互相矛盾。当它们指向不同方向时,agent 如何做出合理权衡?如何衡量 harness 的"覆盖率",就像测试覆盖率一样评估它的完整性?目前没有工具可以回答。

概率性系统的信任问题:脚本是确定性的,同样输入永远得到同样输出。Agent 是概率性的,可能根据上下文选择不同路径。让概率性系统可信赖,答案不是消除不确定性,而是确保全程可追溯——只有能被看见的,才能被信任。


从今天开始做什么

第一周:建立基础

  1. 为你最常用的项目创建 AGENTS.md(或 CLAUDE.md

    • 从当前最烦的 5-10 个 agent 失败行为开始
    • 每个写一条规则,一句话,不加解释
    • 总长度控制在 50-100 行
  2. 让 agent 能操作你的项目

    • 所有日常工作流写成 Makefile target(make devmake testmake restart
    • agent 应该能自己启动项目、看日志、跑测试
  3. 建立最小反馈回路

    • linter + 类型检查 + 单元测试,必须能本地快速跑完
    • 失败时才输出,成功时静默

第二到四周:工程化失败

  1. 识别前 5 个最危险的失败模式,把它们变成 hook 拦截脚本

  2. 如果你有跨多个 session 的长任务,建立 Initializer + Coding Agent 双 agent 模式

    • 用 JSON 跟踪功能状态,不用 Markdown
    • 每次 session 开始强制读进度文件和 git log
    • 每次只完成一个功能,完成即 commit
  3. 第一个技能(Skill)——选一个每周都要做的、有多个步骤的任务

持续运转:把每一次失败变成系统

每次 agent 犯错,问自己:

  • 这是 AGENTS.md 可以防止的?→ 加一条规则
  • 这是 hook 可以物理阻止的?→ 写一个拦截脚本
  • 这是 linter 可以检测的?→ 写一条 lint 规则
  • 这是 sub-agent 可以隔离的 context 问题?→ 拆分架构
  • 这是模型已经能自己处理的?→ 删掉这个 harness 组件

唯一的原则:只在 agent 真的出错后才加约束,只在模型真的不再需要时才删约束。


结语:一门关于信任的工程学

构建自动化的历史,一直在回答同一个问题:如何让复杂的多步骤过程变得可靠和可重复?

1976:make         依赖图 + 文件时间戳
1990s:autotools   跨平台构建
2000s:CI/CD       远程机器运行构建
2010s:IaC         可复现的基础设施
2020s:GitOps      声明式期望状态
2026+:Harness     Agent 读取操作手册并执行,harness 管理和约束它

每一代解决了上一代的核心问题,同时引入了新的复杂性。这一代的问题是:如何让 AI 可靠地执行

Böckeler 有一段话值得收在这里:

"人类开发者把技能和经验作为一种隐性 harness 带入每个代码库。我们吸收了约定和最佳实践,我们感受过复杂性带来的认知痛苦,我们知道自己的名字会出现在 commit 里。Harness 是把这些东西外显化、明确化的尝试。但它只能走到某一步。"

Harness Engineering 不是要让人类工程师消失。是要让工程师的经验、品味和判断力,以工程化的方式传递给 AI,让 agent 在你的价值观里工作。

能把自己的工程判断力编写成 harness 的人,就是这个新学科的核心建设者。


参考来源

英文一手资料

中文解析与实践


综合整理自 30+ 篇一手资料与开源项目 | 2026-04-13

Harmony NDK 开发

NDK(Native Development Kit) 是鸿蒙提供的原生开发工具集,允许开发者使用 C/C++ 编写底层代码,通过跨语言调用与 ArkTS 层交互。适用于性能敏感,复用C/C++库,底层硬件操作等场景。

创建 NDK 工程

可以直接使用 DevEco Studio 模板构建 NDK 工程

image.png

创建成功后,目录如下所示:

image.png

CMakeLists.txt 是鸿蒙原生 C++ 模块的构建配置文件,CMake 工具会根据它编译生成动态库(.so文件),供鸿蒙 ArkTS 层调用,我已经逐行解释含义了,不懂得直接看注释即可。

# 声明CMake所需的最低版本
cmake_minimum_required(VERSION 3.5.0)
# 定义项目名称
project(HarmonyApplication)
# 定义变量:CMAKE_CURRENT_SOURCE_DIR 为系统内置变量,代表当前 CMakeLists.txt 所在的文件夹路径
set(NATIVERENDER_ROOT_PATH ${CMAKE_CURRENT_SOURCE_DIR})

# 判断是否定义了 PACKAGE_FIND_FILE 变量,若是则引入该文件,鸿蒙自动生成的兼容配置,用于加载依赖包的配置,开发者无需手动修改
if(DEFINED PACKAGE_FIND_FILE)
    include(${PACKAGE_FIND_FILE})
endif() # CMake 里 if 判断的结束标记,用来闭合 if 语句,CMake 不是 Java,没有大括号 {} 来圈定代码范围

# 添加头文件搜索路径:告诉 CMake,编译 C++ 代码时去这两个路径下查找头文件
include_directories(${NATIVERENDER_ROOT_PATH}
                    ${NATIVERENDER_ROOT_PATH}/include)

# 将 napi_init.cpp 编译成名为 entry 的动态库
# add_library:CMake 编译库文件的命令
# entry:最终生成的动态库名称(编译后会得到libentry.so)
# SHARED:指定生成动态共享库(鸿蒙 NAPI 必须用动态库)
# napi_init.cpp:要编译的 C++ 源文件
add_library(entry SHARED napi_init.cpp)

# 为动态库链接依赖库:让我们的动态库能调用鸿蒙 NAPI 接口,实现 C++ 与 ArkTS 的交互
target_link_libraries(entry PUBLIC libace_napi.z.so)

模块级 build-profile.json5 中 externalNativeOptions 参数是 NDK 工程 C/C++ 文件编译配置的入口

image.png

napi_init.cpp 是鸿蒙 NDK 的 “入口文件”,它是 C/C++ 代码 和 ArkTS/JS 代码之间的桥梁,没有它,ArkTS 就调用不了你的 C++ 方法。

它专门负责 3 件事:

  • 注册 Native 模块:告诉系统是一个 C++ 动态库
  • 绑定 C++ 函数:把你写的 C++ 方法暴露给 ArkTS
  • 提供调用入口:让 ArkTS 能像调用普通函数一样调用 C++
#include "napi/native_api.h"

//自定义的 C++ 方法(给 ArkTS 调用)
static napi_value Add(napi_env env, napi_callback_info info)
{
    size_t argc = 2;
    napi_value args[2] = {nullptr};

    napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);

    napi_valuetype valuetype0;
    napi_typeof(env, args[0], &valuetype0);

    napi_valuetype valuetype1;
    napi_typeof(env, args[1], &valuetype1);

    double value0;
    napi_get_value_double(env, args[0], &value0);

    double value1;
    napi_get_value_double(env, args[1], &value1);

    napi_value sum;
    napi_create_double(env, value0 + value1, &sum);

    return sum;

}

//模块初始化:实现 ArkTS 接口与 C++ 接口的绑定和映射
EXTERN_C_START
static napi_value Init(napi_env env, napi_value exports)
{
    napi_property_descriptor desc[] = {
        { "add", nullptr, Add, nullptr, nullptr, nullptr, napi_default, nullptr }
    };
    napi_define_properties(env, exports, sizeof(desc) / sizeof(desc[0]), desc);
    return exports;
}
EXTERN_C_END

// 准备模块加载相关信息,将上述 Init 函数与本模块名等信息记录下来。
static napi_module demoModule = {
    .nm_version = 1,
    .nm_flags = 0,
    .nm_filename = nullptr,
    .nm_register_func = Init,
    .nm_modname = "entry",
    .nm_priv = ((void*)0),
    .reserved = { 0 },
};

// 加载 so 时,该函数会自动被调用,将上述 demoModule 模块注册到系统中。
extern "C" __attribute__((constructor)) void RegisterEntryModule(void)
{
    napi_module_register(&demoModule);
}

在 cpp\types\libentry\Index.d.ts 文件中,提供 JS 侧的接口方法

export const add: (a: number, b: number) => number;

在 oh-package.json5 文件中将 index.d.ts 与 cpp 文件关联起来

{
  "name": "libentry.so",
  "types": "./Index.d.ts",
  "version": "1.0.0",
  "description": "Please describe the basic information."
}

这些都是由 DevEco Studio 自动生成的,比如我们在 Index.d.ts 中定义一个方法

image.png

然后点击 Generate native implementation,它就能在 cpp 中自动生成对应的 C++ 方法和绑定

image.png

Node-API

  • napi_env:表示 Node-API 执行时的上下文,可以把它理解成 NAPI 给你的一张操作许可证 + 全套工具,所有 NAPI 函数都必须传入它。
  • napi_callback_info:代表 ArkTS 调用 C++ 函数时传递过来的所有信息,专门用来获取 ArkTS 传过来的参数。
  • napi_value:是一个C的结构体指针,表示一个 ArkTS/JS 对象的引用,可以理解为万能的数据载体,是 NAPI 统一的数据类型,可以表示字符串,数字,布尔,数组,对象,null,undefined 等等,C++ 和 ArkTS 之间传递数据只能用它,不能直接传 int,string,bool,必须包装成 napi_value。

这仨的关系,简言之:
ArkTS 调用 C++ 函数 -> 通过 info 拿到参数列表 -> 参数都是 napi_value 类型 -> 用 env 操作这些 napi_value -> 返回一个 napi_value 给 ArkTS

现在来实现一下上面定义的 NAPI_Global_getLast 方法,用来获取数组的最后一个元素。

static napi_value NAPI_Global_getLast(napi_env env, napi_callback_info info) {
    size_t argc = 1;
    napi_value args[1];
    napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);
    // 判断是否为数组
    bool isArray = false;
    napi_is_array(env, args[0], &isArray);
    if (isArray) {
        // 获取数组长度
        uint32_t arrayLength = 0;
        napi_get_array_length(env, args[0], &arrayLength);
        if (arrayLength > 0) {
            // 获取最后一个元素的索引
            uint32_t lastIndex = arrayLength - 1;
            // 获取数组最后一个元素
            napi_value lastElement;
            napi_get_element(env, args[0], lastIndex, &lastElement);
            // 获取字符串长度
            size_t strLen = 0;
            napi_get_value_string_utf8(env, lastElement, nullptr, 0, &strLen);
            // 读取字符串内容
            char resultStr[1024];
            napi_get_value_string_utf8(env, lastElement, resultStr, sizeof(resultStr), nullptr);
            napi_value returnValue;
            // NAPI_AUTO_LENGTH = 让 NAPI 自动计算字符串长度,不用你手动填数字
            napi_create_string_utf8(env, resultStr, NAPI_AUTO_LENGTH, &returnValue);

            return returnValue;
        }
    }
    return nullptr;
}

常用的 Napi 方法

获取调用信息(函数入口必用)

size_t argc = 1;
napi_value args[1];
napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);

类型判断

napi_is_array:判断是不是数组

bool isArray = false;
napi_is_array(env, args[0], &isArray);

napi_typeof:判断类型

napi_valuetype type;
napi_typeof(env, args[0], &type);

取值

取字符串

char buf[1024];
napi_get_value_string_utf8(env, args[0], buf, sizeof(buf), nullptr);
std::string cppStr = buf;

取数字

double num;
napi_get_value_double(env, args[0], &num);

取整数

int num;
napi_get_value_int32(env, args[0], &num);

取布尔值

bool b;
napi_get_value_bool(env, args[0], &b);

创建值

// 创建数字
napi_value dNum;
napi_create_double(env, 100, &dNum);

// 创建整数
napi_value num;
napi_create_int32(env, 10, &num);

// 创建字符串
napi_value str;
napi_create_string_utf8(env, "Hello", NAPI_AUTO_LENGTH, &str);

// 创建布尔值
napi_value b;
napi_create_boolean(env, true, &b);

// 创建对象
napi_value obj;
napi_create_object(env, &obj);

// 创建数组
napi_value arr;
napi_create_array(env, &arr);

数组操作

// 获取数组长度
uint32_t len;
napi_get_array_length(env, arr, &len);

// 获取数组第 index 个元素
napi_value elem;
napi_get_element(env, arr, index, &elem);

// 设置数组第 index 个元素
napi_set_element(env, arr, index, elem);

对象操作

export const handleUser: (user: UserInfo) => UserInfo;

export interface UserInfo {
  name: string;
  age: number;
}
// ArkTS对象 → C++结构体
struct UserInfo {
    std::string name;
    int32_t age;
};


UserInfo ParseUser(napi_env env, napi_value object) {
    UserInfo info{};
    napi_value nameVal, ageVal;

    // 读取 name
    napi_get_named_property(env, object, "name", &nameVal);
    char nameBuff[64];
    size_t len;
    napi_get_value_string_utf8(env, nameVal, nameBuff, sizeof(nameBuff), &len);
    info.name = nameBuff;

    // 读取 age
    napi_get_named_property(env, object, "age", &ageVal);
    napi_get_value_int32(env, ageVal, &info.age);

    return info;
}

// C++ 结构体 -> ArkTs 对象
napi_value WrapUser(napi_env env, const UserInfo &info) {
    napi_value jsObject;
    napi_create_object(env, &jsObject);

    // 设置 name
    napi_value nameVal;
    napi_create_string_utf8(env, info.name.c_str(), NAPI_AUTO_LENGTH, &nameVal);
    napi_set_named_property(env, jsObject, "name", nameVal);

    // 设置 age
    napi_value ageVal;
    napi_create_int32(env, info.age, &ageVal);
    napi_set_named_property(env, jsObject, "age", ageVal);

    return jsObject;
}

static napi_value NAPI_Global_handleUser(napi_env env, napi_callback_info info) {
    size_t argc = 1;
    napi_value args[1];
    napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);
    
    // 解析入参
    UserInfo userInfo = ParseUser(env, args[0]);
    userInfo.age += 1;
    userInfo.name = "XZJ";
    
    return WrapUser(env, userInfo);
}

浏览器判断控制台是否开启

根据 console.table 的执行时长

这种方案还是可以的,BOSS 用的这个。

function checkIsOpen() {
    const bengin = new Date().valueOf();
    console.table(new Array(100).fill(1).map(item => new Array(100).fill(1)))
    const end = new Date().valueOf();
    console.clear();
    return (end - bengin) > 5
}
console.log(checkIsOpen())

toString 检测 (已经没有用了)

这个方案,是基于console.log不会在控制台开启时执行的前提条件下才会生效,但是目前浏览器这个不行。

function checkIsOpen() {
    
}
checkIsOpen.toString = function() {
    this.isOpen = true;
}
console.log(checkIsOpen)

这个方案已经无了,但是可以了解一下console.log。 console.log() API ‌无论 DevTools 是否打开都会执行‌,但其行为和影响在不同状态下有显著差异。

性能与内存影响不同‌:

  • DevTools 关闭时‌:
    日志输出通常由浏览器轻量处理,‌不会导致内存泄漏‌,堆内存保持稳定。
  • DevTools 打开时‌:
    浏览器会‌保留被打印对象的引用‌(尤其是对象/数组),以便在控制台中展开查看,这可能导致‌内存无法被垃圾回收(GC) ‌,从而引发内存泄漏。nodejs 环境不会内存泄漏

监控debugger

function checkIsOpen() {
    const bengin = new Date().valueOf();
    debugger;
    const end = new Date().valueOf();
    console.clear();
    return (end - bengin) > 5
}
console.log(checkIsOpen())

就是利用断点。

DOM元素检测

就是挂一个隐藏的 html 标签放页面上监控这个 html 标签的offsetHeightoffsetWidth。 这个也无了。

当前端开始做 Agent 后,我才知道 LangGraph 有多重要❗❗❗

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

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

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

image.png

在之前的内容里面我们一直在用 LangChain 写链、写 Agent,从最简单的模型调用到工具绑定、路由分发、自定义工作流,走了一整套流程。到这里自然会遇到一个问题:随着应用逻辑越来越复杂,LangChain 原有的编排方式开始显得吃力。链是线性的,Agent 是循环的,但真实世界里的流程往往是图状的,有分支、有合并、有回环、有需要等待人工确认的节点。LangGraph 就是为了解决这个问题而出现的。

为什么需要 LangGraph

LangChainAgentExecutor 时,底层逻辑是一个简单循环:调模型、看要不要用工具、用完工具再回来、再调模型。这个模型对于简单的工具调用场景足够用,但一旦遇到以下几种情况,就开始捉襟见肘。

第一种是多步骤分支。假设需要先判断用户意图,然后根据意图走完全不同的子流程,子流程结束后还需要汇总结果再回复用户。AgentExecutor 的循环模型表达这类逻辑,需要把分支全部塞进提示词,或者用条件回调硬写,代码很快就乱成一团。

第二种是状态持久化。用户和 Agent 聊了几十轮,中途关掉了页面,下次再打开希望从上次停下的地方继续。LangChain 本身没有原生的持久化机制,记忆模块只是把消息列表临时存在内存里,进程一停就没了。

第三种是人机协同。工作流执行到某个敏感节点,需要暂停下来等人类审核,审核通过后才能继续往下跑。这种"执行中途打断、人工介入、再恢复"的场景,在 AgentExecutor 里几乎无法干净地实现。

LangGraph 把上面这些问题都纳入了核心设计。它的思路是把整个 Agent 或工作流建模成一张图,节点是计算步骤,边是流转路径,状态是在整张图上流动的数据。图可以有条件边,可以有回边,可以在任意节点打断并恢复,状态可以持久化到数据库。

LangGraph 的核心思路

理解 LangGraph 最好的方式是先搞清楚它的三个基本概念:状态、节点和边。

状态是图执行过程中一直流动的数据对象,可以把它想象成贯穿整个流程的"共享变量包",每个节点都可以读取里面的内容,也可以往里写新的内容。最常用的状态定义是 MessagesAnnotation,它把状态简化为一个消息列表,非常适合对话类应用。如果需要追踪工具调用次数、用户身份、中间计算结果等自定义字段,也可以用 Annotation 自己定义状态结构。

节点是图里的计算单元,每个节点就是一个普通的异步函数,接收当前状态作为参数,执行完后返回需要更新的状态字段。节点可以承担调用模型、执行工具、查询数据库、等待人工审核等任何有意义的计算步骤。

边是节点之间的连接。普通的边直接指向下一个节点,条件边则根据当前状态的内容动态决定下一跳,类似代码里的 if/else。图的执行从特殊的 __start__ 节点开始,到 __end__ 节点结束。

执行时,用户消息随状态流入 callModel 节点,模型回复追加到消息列表后随状态流出,整个过程一进一出,结构极其简单。如需在代码里取出结果,用 result.messages.at(-1) 拿最后一条即可。

下面这张图把五个关键步骤画在一条主线上,如下图所示。

20260317073347

用户发消息进入状态,callModel 节点读取、调用模型、追加回复,状态带着结果流到终点。

再复杂一点,加上工具调用和条件路由,图就具备了循环能力,如下图所示。

20260316231826

加入工具节点和条件边后,调用模型、执行工具、再次调模型形成完整的回路,整个逻辑一眼就能读懂。

LangGraph 和 LangChain 怎么分工

LangGraph 负责"流程怎么跑",它本身不绑定任何模型供应商,也不提供工具的具体实现,只管图的执行调度、状态的流转与持久化。LangChain 负责"工具和模型是什么",它提供的 ChatOpenAItoolHumanMessage、提示模板、检索器这些组件,是节点函数里真正要调用的东西。

两者的关系是分层叠加,而不是二选一,如下图所示。

20260317073508

LangGraph 在上层负责调度与状态,LangChain 在下层提供模型与工具,两者分工明确、协同运作。

如果不确定自己的场景该用哪个,可以对照下面这张表。

场景 推荐
单次问答、简单链式调用 LangChain
一个模型加几个工具的轻量 Agent LangChain
多步骤、有明确分支的工作流 LangGraph
需要持久化对话或状态可回溯 LangGraph
多 Agent 协作、任务拆解 LangGraph
人机协同、需要中途暂停等待审核 LangGraph

LangGraph 的官方文档自己也在说,如果你的 Agent 只是一个简单的"模型加工具循环",用 LangChaincreateReactAgent 快速搞定就好,没必要一开始就引入图的概念。但凡流程复杂到需要明确画出来才能讲清楚,就是 LangGraph 发力的时候了。

最小可运行的骨架

先把三个依赖装好。

pnpm add @langchain/langgraph @langchain/core @langchain/openai

然后搭出下面三个文件的骨架,后面章节的示例都会在这个基础上扩展。

src/model.ts 负责模型初始化,集中管理密钥与接口地址,方便在多个图文件里复用。

// src/model.ts
import { ChatOpenAI } from "@langchain/openai";

export const model = new ChatOpenAI({
  model: "deepseek-chat",
  apiKey: "sk-60816d9be57f4189b658f1eaee52382e",
  configuration: { baseURL: "https://api.deepseek.com" },
});

src/graph.ts 定义图的结构,目前只有一个调用模型的节点。

// src/graph.ts
import { StateGraph } from "@langchain/langgraph";
import { MessagesAnnotation } from "@langchain/core/messages";
import { model } from "./model";

async function callModel(state: typeof MessagesAnnotation.State) {
  const response = await model.invoke(state.messages);
  return { messages: [response] };
}

const graph = new StateGraph(MessagesAnnotation)
  .addNode("callModel", callModel)
  .addEdge("__start__", "callModel")
  .addEdge("callModel", "__end__");

export const app = graph.compile();

src/index.ts 是入口,执行一次图并打印模型回复。

// src/index.ts
import { HumanMessage } from "@langchain/core/messages";
import { app } from "./graph";

const result = await app.invoke({
  messages: [new HumanMessage("你好,介绍一下 LangGraph")],
});

console.log(result.messages.at(-1)?.content);

现在这个骨架已经是真正可以运行的 LangGraph 应用了:输入一条用户消息,callModel 节点调用模型后把响应追加到状态里,图执行完后取出最后一条消息打印。下一章的 Quickstart 会在这个基础上加入工具绑定、条件边和 checkpointer 持久化,让图逐渐"活"起来。

小结

LangGraph 出现是因为 LangChain 的链式和循环模型在多分支、持久化、人机协同这类复杂场景下力不从心,它用状态、节点、边三个概念把工作流建模成图,状态贯穿全图流动,节点负责处理状态,边决定下一跳的走向。LangChainLangGraph 不是竞争关系,前者提供模型与工具,后者负责编排与调度,两者叠加才是完整的应用架构。后面所有章节的示例都会在 model.tsgraph.tsindex.ts 这三个文件的骨架上扩展。

RainbowKit 快速集成多链钱包连接:从“连不上”到丝滑切换的踩坑实录

背景

上个月,我接手了一个多链DeFi聚合器前端的迭代任务。项目需要从原先只支持以太坊主网,扩展到支持 Arbitrum、Polygon、Base 等七八条 EVM 链。老板给的要求很明确:用户体验要丝滑,钱包连接不能卡顿,链切换要直观,最好能快速上线。

我第一时间想到了 RainbowKit。社区里都说它“开箱即用”,封装了 wagmi 和一堆 UI 组件,能省不少事。但真当我动手把文档里的示例代码往项目里一粘,问题就接踵而至了。钱包是能弹出来了,但链列表不对,切换链后前端状态没更新,甚至有的链上交易会报莫名其妙的 RPC 错误。这篇文章,就是我填平这些坑的完整记录。

问题分析

我最开始的想法很简单:照着 RainbowKit 官方文档,安装 @rainbow-me/rainbowkitwagmiviem,然后配置一个 WagmiProviderRainbowKitProvider 把应用包起来不就完事了?代码大概长这样:

import { getDefaultConfig, RainbowKitProvider } from '@rainbow-me/rainbowkit';
import { WagmiProvider } from 'wagmi';
import { mainnet, polygon } from 'wagmi/chains';

const config = getDefaultConfig({
  appName: 'My App',
  projectId: 'YOUR_PROJECT_ID', // 从 WalletConnect Cloud 拿的
  chains: [mainnet, polygon],
});

function App() {
  return (
    <WagmiProvider config={config}>
      <RainbowKitProvider>
        <MyComponent />
      </RainbowKitProvider>
    </WagmiProvider>
  );
}

跑起来一看,钱包连接按钮是出来了,点开也能看到 MetaMask、Coinbase Wallet 等选项。但问题立刻出现了:

  1. 链列表不全:我配置了 [mainnet, polygon],但钱包切换网络的弹窗里,有时只显示主网,Polygon 不出现。
  2. 状态不同步:用户在 MetaMask 里手动切换了网络(比如从 Ethereum 切到 Polygon),但我应用里 useAccount() 钩子返回的 chain 信息有时还是旧的,导致后续的合约调用全跑到错误的链上。
  3. 自定义链配置麻烦:像 Base、Arbitrum 这些链,wagmi/chains 里虽然有,但它们的 RPC 节点有时不稳定,我需要换成项目自备的节点,这个配置过程比预想的要绕。

我意识到,“开箱即用”指的是基础功能,一旦涉及到生产环境的多链复杂场景,细节配置一个都不能少。下面我就分步骤拆解我是怎么解决这些问题的。

核心实现

第一步:正确配置多链与 RPC

这里有个大坑:RainbowKit/Wagmi 的链配置,并不仅仅是给组件提供一个列表那么简单。它涉及到钱包连接时向钱包(如 MetaMask)发起“建议”的网络列表,以及 wagmi 客户端内部用来读取链数据、发送交易的 RPC 连接。

我最初只用 wagmi/chains 里导出的链定义,但很快就遇到了公共 RPC 限速或不稳定导致交易失败的问题。解决方案是自定义 viemTransport,并为每条链指定更可靠的 RPC 端点。

// src/config/chains.ts
import { http, createConfig } from 'wagmi';
import { mainnet, polygon, arbitrum, base } from 'wagmi/chains';
import { getDefaultConfig } from '@rainbow-me/rainbowkit';

// 1. 定义项目需要的所有链
export const supportedChains = [mainnet, polygon, arbitrum, base] as const;

// 2. 为每条链配置 Transport (RPC 连接)
// 注意:生产环境建议将 RPC URL 放在环境变量中
const transports = {
  [mainnet.id]: http('https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY'),
  [polygon.id]: http('https://polygon-mainnet.g.alchemy.com/v2/YOUR_KEY'),
  [arbitrum.id]: http('https://arb-mainnet.g.alchemy.com/v2/YOUR_KEY'),
  [base.id]: http('https://mainnet.base.org'), // 也可以使用公共节点
};

// 3. 创建 wagmi 配置
export const config = createConfig({
  chains: supportedChains as any, // 这里有个类型小坑,需要断言
  transports, // 关键!注入自定义的 RPC 传输层
  // ... 其他配置如连接器、SSR 等
});

// 4. 创建 RainbowKit 专用的配置(用于 UI 部分)
export const rainbowKitConfig = getDefaultConfig({
  appName: 'MyDeFiAggregator',
  projectId: process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID!, // 必须
  chains: supportedChains,
  transports, // 这里也要传一次,确保一致性
});

关键点transports 配置是性能和安全的关键。使用像 Alchemy、Infura 这样的专业节点服务,能显著提升交易发送和区块数据读取的可靠性。getDefaultConfig 内部其实也是调用了 createConfig,所以我们直接基于 createConfig 来构建,灵活性更高。

第二步:搞定 RainbowKitProvider 与主题

RainbowKit 的 UI 很棒,但默认主题可能和你的项目不搭。集成时,我建议一开始就处理好主题,避免后期再改一堆样式。

// src/providers/Web3Provider.tsx
import { RainbowKitProvider, darkTheme, lightTheme } from '@rainbow-me/rainbowkit';
import { WagmiProvider } from 'wagmi';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { config } from '@/config/chains';

const queryClient = new QueryClient();

export function Web3Provider({ children }: { children: React.ReactNode }) {
  return (
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>
        <RainbowKitProvider
          theme={darkTheme({
            accentColor: '#3B82F6', // 自定义主色
            accentColorForeground: 'white',
            borderRadius: 'medium',
            fontStack: 'system',
            overlayBlur: 'small',
          })}
          // 这个 locale 设置对中文用户很友好
          locale="en-US"
          // 可以在这里配置初始链影响连接时的默认网络
          initialChain={polygon}
        >
          {children}
        </RainbowKitProvider>
      </QueryClientProvider>
    </WagmiProvider>
  );
}

注意这个细节WagmiProvider 需要 @tanstack/react-queryQueryClientProvider 作为上下文来管理请求状态。getDefaultConfig 帮我们隐式创建了 queryClient,但自己显式创建并传入能获得更多控制权,比如设置全局的请求重试、缓存时间等。

第三步:实现链感知的连接与切换

这是用户体验的核心。用户连接钱包后,我们需要清晰地展示当前连接的链,并提供一个便捷的切换方式。RainbowKit 提供了 ConnectButtonChain 组件,但直接使用可能不够。

我遇到的一个典型场景是:用户当前连接在 Polygon 上,但我们的某个功能只支持 Arbitrum。我们需要引导用户切换网络。

// src/components/ChainAwareConnectButton.tsx
import { ConnectButton } from '@rainbow-me/rainbowkit';
import { useAccount, useSwitchChain } from 'wagmi';
import { supportedChains } from '@/config/chains';
import { useEffect } from 'react';

export function ChainAwareConnectButton({ requiredChainId }: { requiredChainId?: number }) {
  const { chain, isConnected } = useAccount();
  const { switchChain } = useSwitchChain();

  // 效果:当组件要求特定链,且用户已连接但链不对时,自动提示切换
  useEffect(() => {
    if (isConnected && requiredChainId && chain?.id !== requiredChainId) {
      // 这里可以触发一个自定义的模态框提示,而不是自动切换。
      // 自动切换体验很生硬,可能会被钱包拦截。
      console.warn(`请将网络切换至 ${supportedChains.find(c => c.id === requiredChainId)?.name}`);
    }
  }, [isConnected, chain, requiredChainId]);

  return (
    <ConnectButton.Custom>
      {({
        account,
        chain: connectedChain,
        openAccountModal,
        openChainModal,
        openConnectModal,
        authenticationStatus,
        mounted,
      }) => {
        const ready = mounted && authenticationStatus !== 'loading';
        const connected = ready && account && connectedChain;

        // 自定义按钮渲染逻辑
        return (
          <div
            {...(!ready && {
              'aria-hidden': true,
              'style': {
                opacity: 0,
                pointerEvents: 'none',
                userSelect: 'none',
              },
            })}
          >
            {(() => {
              if (!connected) {
                return (
                  <button onClick={openConnectModal} type="button">
                    连接钱包
                  </button>
                );
              }

              // 如果已连接,但链不符合要求,高亮显示链切换按钮
              const isOnWrongChain = requiredChainId && connectedChain.id !== requiredChainId;

              return (
                <div style={{ display: 'flex', gap: 12 }}>
                  {/* 链切换按钮 */}
                  <button
                    onClick={openChainModal}
                    type="button"
                    style={{
                      display: 'flex',
                      alignItems: 'center',
                      background: isOnWrongChain ? '#FEF3C7' : 'transparent', // 链不对时黄色背景提示
                      border: `1px solid ${isOnWrongChain ? '#F59E0B' : '#ccc'}`,
                      borderRadius: '8px',
                      padding: '4px 8px',
                    }}
                  >
                    {connectedChain.hasIcon && (
                      <div
                        style={{
                          background: connectedChain.iconBackground,
                          width: 20,
                          height: 20,
                          borderRadius: 999,
                          overflow: 'hidden',
                          marginRight: 4,
                        }}
                      >
                        {connectedChain.iconUrl && (
                          <img
                            alt={connectedChain.name ?? 'Chain icon'}
                            src={connectedChain.iconUrl}
                            style={{ width: 20, height: 20 }}
                          />
                        )}
                      </div>
                    )}
                    {connectedChain.name}
                  </button>

                  {/* 账户按钮 */}
                  <button onClick={openAccountModal} type="button">
                    {account.displayName}
                    {account.displayBalance ? ` (${account.displayBalance})` : ''}
                  </button>
                </div>
              );
            })()}
          </div>
        );
      }}
    </ConnectButton.Custom>
  );
}

这里有个坑useSwitchChain().switchChain 方法虽然存在,但在浏览器环境中,直接调用它来“强制”用户切换链,体验很差,而且 MetaMask 等钱包可能会阻止这种非用户触发的切换请求。最佳实践是只提供清晰的切换引导(比如高亮链按钮、文字提示),让用户自己点击 openChainModal 去操作。ConnectButton.Custom 给了我们极大的灵活性来实现这种定制 UI 和交互逻辑。

第四步:在应用各处安全地使用链状态

解决了连接和切换,最后一步是确保在需要链信息的任何地方(比如调用合约、查询余额),我们使用的 chainId 都是正确且最新的。

// src/hooks/useSafeChain.ts
import { useAccount, useChainId } from 'wagmi';
import { supportedChains } from '@/config/chains';

// 这个钩子确保返回的 chainId 一定是项目支持的,否则返回 undefined 或默认链
export function useSafeChain(requiredChainId?: number) {
  const { chain } = useAccount();
  const globalChainId = useChainId(); // wagmi v2 的新钩子,直接获取当前链ID

  // 优先级:参数指定 > 当前连接链 > undefined
  let targetChainId = requiredChainId || chain?.id || globalChainId;

  // 检查目标链是否在支持列表中
  const isSupported = supportedChains.some(c => c.id === targetChainId);

  if (!isSupported && targetChainId) {
    console.error(`链 ID ${targetChainId} 不在项目支持列表中。`);
    // 根据业务逻辑,可以在这里触发链切换,或者返回一个默认链(如主网)
    // return mainnet.id;
    return undefined;
  }

  return targetChainId;
}

// 在合约调用处使用
import { useReadContract } from 'wagmi';
import { useSafeChain } from '@/hooks/useSafeChain';
import { myContractAbi } from './abi';

export function MyComponent() {
  const safeChainId = useSafeChain(); // 获取当前安全的链ID

  const { data } = useReadContract({
    abi: myContractAbi,
    address: '0x...', // 注意:不同链上合约地址可能不同,这里需要根据 chainId 做映射
    functionName: 'balanceOf',
    args: ['0xUserAddress'],
    chainId: safeChainId, // 关键!将安全的 chainId 传入查询
    query: {
      enabled: !!safeChainId, // 只有链ID有效时才发起查询
    },
  });

  // ... 渲染逻辑
}

关键点:所有依赖于链的钩子(useReadContract, useWriteContract, useBalance 等),都应该显式地传入 chainId 参数。不要依赖 wagmi 的全局上下文自动推断,因为在复杂的多链交互中,尤其是在用户快速切换网络时,自动推断可能会滞后或出错。useSafeChain 这个自定义钩子相当于一个保险丝,确保后续操作基于一个经过验证的链环境。

完整代码

以下是一个简化但可运行的核心集成示例,基于 Next.js (App Router) 和 TypeScript。

// app/providers.tsx
'use client';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { WagmiProvider } from 'wagmi';
import { RainbowKitProvider, darkTheme } from '@rainbow-me/rainbowkit';
import { config } from '@/lib/wagmi-config';

const queryClient = new QueryClient();

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>
        <RainbowKitProvider
          theme={darkTheme({ accentColor: '#0E76FD' })}
          locale="en-US"
        >
          {children}
        </RainbowKitProvider>
      </QueryClientProvider>
    </WagmiProvider>
  );
}
// lib/wagmi-config.ts
import { http, createConfig } from 'wagmi';
import { mainnet, polygon, arbitrum, base } from 'wagmi/chains';
import { injected, walletConnect } from 'wagmi/connectors';

// 1. 定义支持的链
export const supportedChains = [mainnet, polygon, arbitrum, base] as const;
export type SupportedChainId = (typeof supportedChains)[number]['id'];

// 2. 配置 RPC Transports
const transports: Record<SupportedChainId, ReturnType<typeof http>> = {
  [mainnet.id]: http(process.env.NEXT_PUBLIC_ETHEREUM_RPC_URL),
  [polygon.id]: http(process.env.NEXT_PUBLIC_POLYGON_RPC_URL),
  [arbitrum.id]: http(process.env.NEXT_PUBLIC_ARBITRUM_RPC_URL),
  [base.id]: http('https://mainnet.base.org'),
};

// 3. 创建 wagmi 配置对象
export const config = createConfig({
  chains: supportedChains as any,
  transports,
  connectors: [
    injected(), // 支持 MetaMask 等注入式钱包
    walletConnect({
      projectId: process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID!,
    }),
  ],
  ssr: true, // 如果你用 Next.js 且需要 SSR,开启这个
});
// app/layout.tsx
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import { Providers } from './providers';

const inter = Inter({ subsets: ['latin'] });

export const metadata: Metadata = {
  title: 'My Web3 App',
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}
// app/page.tsx
'use client';

import { ConnectButton } from '@rainbow-me/rainbowkit';
import { useAccount, useBalance } from 'wagmi';

export default function Home() {
  const { address, chain } = useAccount();
  const { data: balance } = useBalance({ address });

  return (
    <main style={{ padding: '2rem' }}>
      <h1>我的多链 DeFi 聚合器</h1>
      <div style={{ margin: '2rem 0' }}>
        <ConnectButton />
      </div>

      {address && (
        <div style={{ marginTop: '1rem', padding: '1rem', border: '1px solid #333' }}>
          <p>连接地址: {address}</p>
          <p>当前网络: {chain?.name} (ID: {chain?.id})</p>
          <p>余额: {balance?.formatted} {balance?.symbol}</p>
        </div>
      )}
    </main>
  );
}

环境变量 (.env.local):

NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=你的_WalletConnect_Cloud_项目ID
NEXT_PUBLIC_ETHEREUM_RPC_URL=https://eth-mainnet.g.alchemy.com/v2/your_key
NEXT_PUBLIC_POLYGON_RPC_URL=https://polygon-mainnet.g.alchemy.com/v2/your_key
NEXT_PUBLIC_ARBITRUM_RPC_URL=https://arb-mainnet.g.alchemy.com/v2/your_key

踩坑记录

  1. Unsupported chain 错误:用户连接了钱包,但我的应用配置里没有他钱包当前所在的链(比如 BSC)。RainbowKit 的默认行为是“不支持”,用户会看到一个错误状态。解决方法:在 RainbowKitProvider 上设置 initialChain 为一个你支持的链(如主网),这样新用户连接时会先被引导到该链。对于已连接但链不对的用户,通过自定义 ConnectButton UI 强烈提示他们切换。

  2. WalletConnect 项目 ID 缺失:控制台报错 Invalid projectId解决方法:必须去 WalletConnect Cloud 创建一个项目,获取 projectId。这个 ID 是 WalletConnect 协议连接所必需的,不是可选项。

  3. Hydration 不匹配错误 (Next.js):在服务端渲染 (SSR) 时,服务端没有钱包连接状态,而客户端水合时状态可能不同,导致 React 报错。解决方法:确保 WagmiProviderRainbowKitProvider 只在客户端渲染。在 Next.js App Router 中,将包含这些 Provider 的文件标记为 'use client'。同时,在 createConfig 中设置 ssr: true,让 wagmi 适配 SSR 环境。

  4. 类型错误:chains 类型不匹配:在 createConfig 中直接传入 supportedChains 可能会遇到 TypeScript 类型过于宽泛的问题。解决方法:使用 as const 断言定义链数组,并在 createConfig 处使用 as any 进行临时断言,或者按照 wagmi 类型更精确地定义 Chain 数组。

小结

通过这一轮折腾,我最大的收获是:RainbowKit 确实是加速 Web3 前端开发的利器,但它不是“魔法”。生产级的多链集成,关键在于理解其底层依赖(wagmi, viem)的配置,特别是 TransportchainId 的精确管理。下一步,我可以继续深入优化自定义连接器、钱包连接后的权限验证(SIWE),以及更复杂的跨链状态管理。

WebSocket与SSE技术方案选型对比分析

一、引言

在现代Web应用开发中,实时通信已成为不可或缺的技术需求。无论是社交媒体的即时消息、在线游戏的实时对战,还是金融系统的行情推送,都需要服务端能够主动将数据推送到客户端。WebSocket和Server-Sent Events(SSE)是两种主流的实时通信技术方案,它们各自具有独特的技术特性和适用场景。本文将从技术原理、核心特性、性能表现等多个维度进行深入对比分析,并结合实际业务场景提供选型建议,帮助开发者在不同业务需求下做出合理的技术决策。

实时通信技术的选择直接影响着应用的用户体验、系统可维护性以及运维成本。WebSocket作为一种全双工通信协议,自诞生以来就受到了广泛关注;而SSE作为HTML5规范的一部分,则提供了一种更为轻量级的服务器推送解决方案。两者虽然都实现了服务端向客户端的数据推送,但在协议设计、通信模式、浏览器兼容性等方面存在显著差异。深入理解这些差异对于架构师和开发者在项目初期做出正确的技术选型至关重要,因为这不仅关系到当前项目的开发效率,更影响着后续的扩展和维护成本。

二、技术概述

2.1 WebSocket技术简介

WebSocket是一种在单个TCP连接上提供全双工通信通道的协议,由IETF在RFC 6455中标准化。该协议的设计初衷是为了解决传统HTTP请求-响应模型在实时通信场景下的局限性。在WebSocket出现之前,开发者只能通过轮询或长轮询等变通方案来实现类似效果,这些方案不仅效率低下,还会给服务器带来沉重的负担。WebSocket的出现彻底改变了这一局面,它允许客户端和服务器在建立连接后保持持久的打开状态,双方可以随时相互发送数据,而无需每次都重新建立连接。

WebSocket协议的握手过程基于HTTP协议,利用HTTP的Upgrade机制将普通HTTP连接升级为WebSocket连接。这一设计使得WebSocket能够与现有的网络基础设施无缝兼容,同时也允许WebSocket服务与HTTP服务共享相同的端口。在握手完成后,客户端和服务器之间的通信就完全基于WebSocket协议进行,这与初始的HTTP请求完全不同。WebSocket帧是数据传输的基本单位,支持文本帧和二进制帧两种类型,这使得它能够灵活处理各种类型的数据,包括JSON文本、图片、音视频流等。

从协议层面来看,WebSocket具有几个显著的技术特点。首先是它的全双工特性,客户端和服务器可以在同一连接上同时发送数据,实现了真正意义上的双向通信。其次是WebSocket连接的持久性,一旦连接建立,只要双方不主动关闭或出现网络故障,连接就会一直保持,这避免了重复建立连接的开销。此外,WebSocket协议头部开销很小,在数据传输阶段不需要每次都携带完整的HTTP头部信息,这使得它在高频数据交换场景下具有显著的性能优势。

2.2 SSE技术简介

Server-Sent Events是HTML5规范中定义的一种服务器推送技术,允许服务器通过HTTP协议向浏览器推送事件流。与WebSocket的全双工通信不同,SSE是一种单通道的服务器推送技术,客户端只能接收来自服务器的数据,而不能通过同一连接向服务器发送数据。这种设计使得SSE在需要服务器单向推送的场景下成为一种简洁而高效的解决方案。SSE的技术规范定义在HTML Living Standard中,它利用了HTTP/1.1的分块传输编码机制来实现服务端的数据推送。

SSE的实现基于EventSource接口,这是浏览器原生提供的一个API,开发者可以通过它轻松地建立与服务器的SSE连接并监听服务器推送的事件。与WebSocket相比,SSE的一个显著优势是它完全基于HTTP/1.1协议,无需特殊的协议升级,这使得它在某些受限的网络环境中具有更好的穿透性。此外,SSE还内置了自动重连机制,当连接意外断开时,浏览器会自动尝试重新建立连接,这大大简化了开发者处理连接异常的工作。

从技术实现角度来看,SSE推送的数据采用纯文本格式,每条消息以"data:"开头,以双换行符结束。SSE支持为每条消息设置事件类型和ID标识,客户端可以根据事件类型筛选感兴趣的消息,也可以通过Last-Event-ID请求头从断点恢复数据接收。这些特性使得SSE在实现可靠的消息传递方面具有一定优势,特别是在需要断点续传的场景下。SSE还支持设置消息的重试间隔,提供了基本的连接管理能力。

三、技术原理深度对比

3.1 连接建立机制差异

WebSocket和SSE在连接建立机制上有着本质的不同,这直接影响了它们在不同网络环境下的表现和适用性。WebSocket的连接建立是一个典型的HTTP升级过程,客户端首先发送一个带有Upgrade头的HTTP请求,服务器如果支持WebSocket协议则返回101状态码表示协议切换成功,此后连接就不再是HTTP协议而是WebSocket协议了。这个过程虽然高效,但需要在服务器端实现完整的WebSocket协议栈,并且需要处理协议升级的握手逻辑。

相比之下,SSE的连接建立就是普通的HTTP请求,浏览器通过EventSource API向服务器发送一个GET请求,服务器以text/event-stream的Content-Type持续返回数据。这个请求-响应的模式与传统的HTTP请求完全一致,服务器端只需返回符合SSE格式的数据流即可,无需特殊的协议支持。这种简单性使得SSE可以非常容易地在现有HTTP服务基础上实现,开发者只需几行代码就能将普通的HTTP响应改造为SSE数据流。

在握手细节上,WebSocket还需要处理Sec-WebSocket-Key和Sec-WebSocket-Version等头部,以及基于这些密钥的安全验证过程。这些额外的握手步骤虽然增强了协议的安全性,但也增加了实现的复杂度。而SSE完全沿用HTTP的认证机制,可以使用Cookie、Basic Auth等标准HTTP认证方式,这在某些需要身份验证的场景下更加方便。值得注意的是,WebSocket在握手阶段如果遇到代理服务器或负载均衡设备,可能会因为不认识WebSocket协议而产生问题,而SSE因为本质上是HTTP请求,在这方面的兼容性通常更好。

3.2 数据传输协议对比

WebSocket和SSE在数据传输格式上有着显著差异,这导致了它们在不同数据类型场景下的表现各不相同。WebSocket协议定义了帧的概念作为数据传输的基本单位,支持文本帧和二进制帧两种类型。每帧由操作码、负载长度和负载数据组成,协议设计紧凑高效,没有冗余的文本标记。开发者可以根据需要选择发送文本还是二进制数据,协议本身对数据类型没有限制。

WebSocket的帧结构设计非常精妙,它使用位操作来编码帧的首字节和次字节,包含帧类型、掩码标志和负载长度等信息。对于小于126字节的负载,长度可以直接编码在第二个字节中;对于126到65535之间的负载,使用额外的两个字节来编码长度;而更大的负载则需要使用八个字节来编码64位长度的值。数据负载还可以被分割成多个帧进行传输,接收方需要按照分片协议组装完整的消息。这种灵活的帧机制使得WebSocket能够高效处理任意大小的数据。

SSE的数据格式则是纯文本,每条消息以"data:"前缀开头,消息内容以双换行符结束。这种格式简单直观,人类可以直接阅读和调试。SSE消息支持多行数据,即可以在一个事件中使用多个"data:"行,接收到的数据会以换行符连接成完整的数据内容。每条SSE消息还可以包含event字段指定事件类型,id字段指定事件ID,以及retry字段指定断开后的重试间隔。SSE不支持发送二进制数据,如果需要传输二进制内容,必须先将其编码为Base64等文本格式,这会增加约三分之一的数据量。

从协议开销角度来看,在连接建立后,WebSocket的数据帧头部只有2到10个字节,而SSE每条消息都需要包含"data:"前缀以及可能的event和id字段,文本开销相对较大。对于高频发送小数据量的场景,WebSocket的协议开销优势更为明显;而对于低频发送较大数据块的场景,两者的差距就不那么显著了。SSE的纯文本格式虽然增加了协议开销,但也带来了更好的可调试性,开发者在调试工具中可以直接看到传输的内容。

3.3 连接生命周期管理

WebSocket和SSE在连接生命周期管理方面采取了不同的策略,这直接影响着应用的可靠性和资源消耗。WebSocket连接一旦建立就会持久保持,直到被客户端或服务器主动关闭,或者因为网络故障而断开。由于WebSocket是全双工协议,连接双方都有责任管理连接状态,包括心跳检测、连接超时处理等。服务器通常需要实现心跳机制来检测连接是否仍然有效,如果客户端长时间没有发送数据,服务器可能会主动关闭无效连接以释放资源。

WebSocket连接的关闭需要遵循特定的关闭握手流程,关闭帧包含一个状态码和一个可选的原因描述文本。正常关闭连接时,双方应该交换关闭帧来完成优雅关闭,而不是直接断开TCP连接。这种设计确保了双方都能意识到连接即将关闭,可以做一些清理工作。然而在实际的复杂网络环境中,如移动网络切换、WiFi切换等,TCP连接可能不是正常关闭而是意外中断,这时就需要依赖心跳机制和重连策略来处理。

SSE在连接生命周期管理上则更为简单,它本质上是一个持续的HTTP请求-响应过程。浏览器会自动处理连接的断开和重连,当连接意外断开时,EventSource会自动尝试重新建立连接。如果服务器在响应中设置了retry间隔,浏览器会等待指定时间后重试;如果没有设置,浏览器会使用默认的重试间隔。SSE还支持Last-Event-ID机制,客户端在重连时会将最后接收到的消息ID发送给服务器,服务器可以据此确定从哪里继续推送数据,这对于保证消息不丢失非常有价值。

在服务器端,SSE连接的管理相对简单,因为SSE是单向通信,服务器只需要负责发送数据,不需要处理来自客户端的复杂消息。服务器可以为每个SSE连接维护一些元数据,如连接时间、客户端标识等,并在适当时机主动关闭连接。由于SSE连接基于HTTP协议,它可以利用HTTP/2的多路复用特性,在同一个HTTP/2连接上建立多个SSE流,这在需要向同一客户端推送多种不同数据流时非常有用。

四、核心特性对比分析

4.1 浏览器兼容性考量

在浏览器兼容性方面,WebSocket和SSE作为HTML5规范的一部分,都得到了现代浏览器的广泛支持,但在老旧浏览器和特殊环境中的表现有所不同。WebSocket协议最早在2000年代末期被提出,并在2011年随着RFC 6455的发布而正式标准化。目前,所有主流浏览器,包括Chrome、Firefox、Safari、Edge,都原生支持WebSocket,覆盖了桌面和移动设备。然而,在一些老旧的浏览器中,WebSocket是不可用的,开发者需要使用Flash或轮询作为降级方案。

SSE作为HTML5的一部分,其浏览器支持情况与WebSocket基本一致,所有主流浏览器都支持EventSource API。但SSE在IE和旧版Edge平台上曾长期缺乏支持,IE浏览器从未支持过SSE。对于需要支持IE用户的应用,SSE不可用是一个不可忽视的限制。不过,对于移动端和现代Web应用,SSE的兼容性已经不是问题。需要注意的是,SSE在Service Worker中不可用,而WebSocket则可以正常使用。

在服务器端和网络环境方面,WebSocket因为使用特殊的协议,有时会被防火墙、代理服务器或负载均衡设备阻止,特别是在一些企业网络环境中。这些设备可能不认识WebSocket协议,将其视为可疑连接而主动断开。相比之下,SSE因为使用标准的HTTP协议,几乎不会被防火墙或代理阻止,可以在任何标准HTTP环境中正常工作。这一点是企业内网应用选型时需要重点考虑的因素。

从移动端表现来看,两者都支持良好,但在弱网络环境下的表现有所不同。WebSocket的长连接在移动网络中可能因为网络切换而断开,需要实现重连逻辑。SSE由于使用HTTP协议,可以更好地利用HTTP/2的多路复用和连接复用特性,在某些场景下可能具有更好的网络适应性。不过,现代移动浏览器对WebSocket的支持已经非常成熟,两者的实际体验差异不大。

4.2 性能表现对比

性能是技术选型时需要重点考虑的因素之一,WebSocket和SSE在性能方面各有优势,需要根据具体使用场景进行评估。在协议开销方面,WebSocket在数据传输阶段具有明显的优势。WebSocket帧头部只有2到10个字节,而SSE每条消息都需要包含"data:"前缀等文本标记。对于高频数据交换场景,如实时游戏、在线协作编辑,WebSocket的低开销优势会累积成显著的性能差异。

在服务器资源消耗方面,两者都需要维护持久的连接。WebSocket连接通常被认为更加轻量,因为一旦握手完成,后续的数据传输就非常高效。SSE连接实际上是一个持续的HTTP请求,服务器需要为每个连接维护完整的HTTP上下文,这可能会消耗更多的内存和CPU资源,特别是在高并发场景下。不过,现代HTTP服务器以及各种编程语言的HTTP框架都针对长连接场景进行了优化,SSE的资源消耗问题在实践中通常不是主要瓶颈。

从延迟角度来看,在理想网络条件下,WebSocket和SSE都能提供非常低的延迟,因为两者都避免了轮询带来的固定延迟。WebSocket的全双工特性在需要客户端向服务器发送大量数据的场景下具有优势,因为可以省去建立额外HTTP请求的开销。SSE的单向特性在纯推送场景下反而是一种简洁的优势,不需要维护双向通信的复杂性。在服务器推送频率方面,SSE因为基于HTTP,更容易与CDN配合使用,实现边缘节点的缓存和分发,这对于大规模分发场景非常有价值。

在可扩展性方面,WebSocket因为是长连接,需要服务器采用不同的架构来处理大量并发连接。传统的Apache模型在面对数万甚至数十万的WebSocket连接时会遇到瓶颈,需要使用Nginx、Node.js的cluster模式或者专门的消息队列中间件来实现横向扩展。SSE因为基于HTTP,更容易利用现有的HTTP服务架构和负载均衡方案,在微服务架构中部署更加灵活。两者的扩展性问题都需要在架构设计阶段充分考虑。

4.3 功能特性对比

WebSocket和SSE在功能特性上的差异直接决定了它们各自的适用场景。全双工与半双工是最核心的差异,WebSocket支持客户端和服务器同时发送数据,实现了真正的双向通信;而SSE只支持服务器向客户端推送数据,客户端如果要发送数据需要使用额外的HTTP请求。这种设计差异使得WebSocket成为聊天、游戏、协作编辑等需要频繁双向交互场景的首选,而SSE则更适合股票行情、新闻推送、通知提醒等服务器单向推送场景。

在断线重连方面,SSE内置的自动重连机制为开发者省去了不少麻烦。浏览器会在连接断开后自动尝试重连,并且支持Last-Event-ID机制来实现断点续传。WebSocket则需要开发者自行实现心跳检测和重连逻辑,虽然有各种成熟的开源库可以使用,但这仍然是开发工作的一部分。好消息是,现代WebSocket库通常都提供了完善的心跳和重连功能,开发成本已经大大降低。对于需要保证消息可靠性的应用,两种技术都需要考虑消息确认和重发机制,只是实现方式有所不同。

在消息路由和多路复用方面,SSE通过HTTP/2的多路复用可以更优雅地处理。一个HTTP/2连接上可以建立多个SSE流,每个流可以订阅不同的主题,实现逻辑上的多路复用。WebSocket虽然也可以在同一连接上实现多路复用,但这需要开发者自行实现,或者使用WebSocket的多路复用扩展。在跨域通信方面,两者都支持,但SSE的跨域配置更加简单,只需在服务器响应中添加Access-Control-Allow-Origin头即可;WebSocket的跨域则需要在服务器端实现更复杂的握手逻辑。

在二进制数据支持方面,WebSocket原生支持二进制帧,可以高效传输ArrayBuffer、Blob等二进制数据,非常适合传输图片、音频、视频等多媒体内容。SSE只能传输文本数据,二进制内容必须先进行Base64等文本编码,这会增加数据量并消耗额外的编解码资源。因此,对于需要传输多媒体内容的实时应用,WebSocket是更合适的选择。

五、场景化分析

5.1 实时聊天应用场景

实时聊天是WebSocket最具代表性的应用场景之一,也是WebSocket优于SSE的典型场景。在聊天应用中,用户需要同时发送和接收消息,这就要求通信协议必须支持全双工通信。WebSocket的全双工特性使得它能够完美满足这一需求,用户发送消息时可以直接通过同一个WebSocket连接发送,而接收消息时服务器也可以通过同一连接推送。这种设计不仅降低了延迟,还简化了客户端的实现复杂度。

从消息交互模式来看,聊天应用通常包含多种类型的消息交互,包括一对一私聊、群聊、消息确认、已读回执、在线状态等。这些交互都需要双向通信的支持,WebSocket能够灵活处理各种消息类型,而SSE则无法直接支持客户端向服务器发送消息。虽然可以通过额外的HTTP请求来实现SSE场景下的消息发送,但这样做会增加HTTP请求的数量,影响用户体验,并且需要维护两个独立的通信通道。

在聊天应用的扩展性方面,WebSocket连接需要服务器采用长连接架构。考虑到一个中大型聊天应用可能需要支持数十万甚至数百万的并发连接,服务器架构的设计就显得尤为重要。业界通常采用的方案包括:使用支持高并发的WebSocket服务器,如Node.js的Socket.IO、Java的Netty、Go的gorilla/websocket,使用专门的WebSocket网关服务,或者使用消息队列来实现WebSocket服务的水平扩展。相比之下,SSE在聊天场景下的扩展性挑战更大,因为每个用户的聊天消息都需要推送到对应的SSE连接,架构复杂度会显著增加。

在消息可靠性方面,聊天应用通常需要保证消息的可靠送达。WebSocket应用通常会在协议层之上实现消息确认机制,发送方在收到接收方的确认后才认为消息送达。对于群聊场景,还需要考虑消息的顺序性和一致性。这些需求在WebSocket全双工模式下实现相对自然,而如果使用SSE则需要在HTTP请求层面实现类似的功能,增加系统复杂度。

5.2 实时数据推送场景

实时数据推送是一个广泛的概念,涵盖了金融行情、实时监控、体育比分、新闻更新等多种子场景。在这些场景中,服务器需要持续向客户端推送最新数据,而客户端通常只需要偶尔向服务器发送查询或控制命令。这种服务端推送为主、客户端交互为辅的模式使得SSE成为一种值得考虑的选择。

以股票行情推送为例,用户需要实时看到股价的变动,服务器需要频繁推送最新的成交价、成交量等信息。在这种场景下,数据流向主要是服务器到客户端,客户端主要是偶尔发送查询请求或设置监控条件。如果使用WebSocket,需要为查询请求建立单独的通信通道;如果使用SSE,查询请求可以直接通过普通的HTTP请求实现,响应也会通过SSE连接推送。这种架构更加清晰,也更容易与现有的HTTP服务集成。此外,SSE基于HTTP的特性使得它可以更容易地与缓存、CDN等配合,在数据分发层面具有优势。

在实时监控系统场景中,如服务器监控、物联网设备状态监控等,客户端通常需要实时看到设备的状态变化。这类场景的特点是数据更新频率相对稳定,客户端数量可能很大但单个连接的数据吞吐量不高。SSE在这种场景下的优势在于实现简单,可以利用HTTP的现有基础设施来分发数据。而且SSE的自动重连机制对于监控系统来说非常有用,可以减少因网络波动导致的监控数据丢失。

对于体育比分推送、新闻实时更新等场景,SSE同样是一种非常合适的选择。这类应用的特点是更新频率适中,数据量不大,用户主要是被动接收信息。在这些场景下,SSE的简单性和HTTP兼容性使其成为一种经济实惠的解决方案。如果将来需要支持更多的用户,可以通过HTTP/2甚至HTTP/3来提升单个连接的承载能力,而无需修改应用代码。

5.3 多人协作编辑场景

多人协作编辑是WebSocket的典型应用场景之一,如Google Docs式的在线文档协作。这类应用的核心需求是多个用户同时编辑同一个文档,所有参与者的修改需要实时同步到其他人的视图中。这种场景对通信协议有很高的要求:需要支持低延迟的双向通信,需要处理复杂的并发冲突,需要保证操作的顺序性。

在协作编辑中,用户的每一个操作,如输入字符、删除、格式化等,都需要即时发送到服务器,然后广播给其他参与者。如果使用SSE来实现这一功能,客户端发送操作需要额外的HTTP请求,而HTTP请求的建立本身就有延迟,包括DNS查询、TCP握手、TLS握手等,这会导致操作同步的延迟增加,影响用户体验。WebSocket的持久连接特性使得客户端可以立即发送操作数据,无需等待HTTP请求的建立。

更重要的是,协作编辑通常需要实现操作转换或冲突解决算法,这些算法需要在客户端和服务器之间频繁交换状态信息。例如,当两个用户同时在相同位置插入字符时,服务器需要协调两边的操作顺序,并将调整后的结果广播给所有参与者。这种密集的双向交互在WebSocket全双工模式下非常自然,而在SSE模式下则需要额外的机制来处理客户端到服务器的数据传输。

从扩展性的角度来看,协作编辑应用通常需要支持大量并发连接。以一个流行的在线文档服务为例,可能同时有数百万用户在编辑文档,每个文档可能有多个参与者。WebSocket服务的扩展需要采用分布式架构,包括连接状态的同步、消息的路由、负载均衡等。SSE虽然也可以实现类似的架构,但因为其HTTP本质,在协作编辑这种高频双向交互场景下的效率劣势会更加明显。

5.4 推送通知场景

推送通知是一个覆盖范围很广的场景,包括Web推送、移动推送、邮件通知等子场景。在Web推送场景中,Service Worker扮演着重要角色,它允许Web应用在后台接收服务器推送的消息。从协议选择的角度来看,Web推送通常只涉及服务器到客户端的单向数据流,这使得SSE成为一种可行的选择。

然而,需要注意的是,浏览器原生提供的Web Push API是基于WebSocket的,这是W3C和IETF共同制定的标准。Web Push使用了WebSocket来建立安全的推送通道,并在其上定义了完整的订阅和推送机制。选择使用浏览器原生的Web Push API还是自行实现基于SSE的推送系统,需要根据具体需求来决定。如果需要支持跨浏览器的一致推送体验,使用Web Push是更好的选择;如果只需要在特定浏览器中工作,且需要更灵活的控制,自行实现SSE推送也是可行的。

在企业内部系统、后台管理系统等场景中,推送通知通常以模态框、Toast提示或者小红点等形式呈现。这类场景的特点是通知频率不高,对实时性的要求相对宽松。对于这类应用,SSE是一种简洁高效的解决方案,可以利用现有的HTTP基础设施,不需要专门的WebSocket服务器。开发者只需在服务器端实现SSE端点,在客户端使用EventSource API即可快速实现推送功能。

从安全角度来看,无论是WebSocket还是SSE,都需要考虑传输加密和使用身份验证。在SSE场景下,可以直接利用HTTP的Cookie或Authorization头进行身份验证;而WebSocket则需要在握手阶段或连接建立后自行实现认证机制。对于已经使用HTTP认证的应用,SSE在安全性实现上会更加自然。

六、选型决策矩阵

6.1 核心决策因素

在实际项目中选择WebSocket还是SSE,需要综合考虑多个因素。以下是几个核心的决策维度,每个维度都会影响最终的技术选择。

通信模式需求是首先要考虑的因素。如果应用需要频繁的双向数据交换,如聊天、游戏、协作编辑,WebSocket是必然的选择。如果主要是服务器向客户端推送数据,如通知、新闻、数据监控,则需要进一步评估其他因素。在某些场景下,即使是看似单向的推送,也可能因为需要频繁查询或控制而产生大量的客户端到服务器的数据流,这时WebSocket可能仍然是更好的选择。

浏览器兼容性要求也是重要的考量因素。如果应用需要支持IE浏览器或老旧的移动浏览器,SSE的不可用性就是一个严重问题,必须选择WebSocket并准备降级方案。如果目标用户主要使用现代浏览器,则两种技术都是可选的。在企业内网环境中,如果存在严格的网络策略,SSE的HTTP兼容性优势可能更加重要。

服务器端实现复杂度影响着开发和维护成本。SSE可以在任何HTTP服务器上实现,无需特殊的协议支持,这使得它更容易集成到现有的Web应用中。WebSocket需要专门的服务器端支持,虽然有丰富的开源库可用,但这仍然是需要维护的额外组件。对于已有成熟HTTP服务架构的团队,SSE可能更容易上手。

扩展性需求决定了系统的长期可维护性。如果预计系统需要支持大量并发连接,需要提前考虑连接管理和水平扩展的问题。WebSocket在这方面需要更多的架构设计投入,而SSE则可以更容易地利用HTTP基础设施来实现扩展。

6.2 典型场景推荐

基于上述分析,我们可以为常见的典型场景给出技术选型建议。

强烈推荐WebSocket的场景包括:实时聊天应用、在线游戏、多人协作编辑、需要双向交互的任何应用。这些场景的核心需求是高频双向通信,WebSocket是唯一合理的选择。在这些场景下,SSE要么无法满足需求,要么需要通过额外的机制来实现客户端到服务器的数据传输,增加系统复杂度。

推荐SSE的场景包括:股票行情推送、实时监控仪表盘、新闻feed更新、邮件或通知推送。这些场景的核心特点是服务器到客户端的推送为主,客户端的请求相对较少或不频繁。SSE在这些场景下可以实现简洁高效的解决方案,并且更容易与现有的HTTP架构集成。

两者皆可的场景包括:简单的状态同步、事件通知、后台任务进度展示。这些场景对实时性的要求不高,数据量也不大,技术选择更多取决于团队的技术栈熟悉度和现有的基础设施。如果团队对WebSocket更熟悉,那么使用WebSocket也完全合理;反之亦然。

6.3 混合使用策略

在实际应用中,WebSocket和SSE并不是互斥的选择,有时候可以考虑混合使用来发挥各自的优势。

一种常见的混合策略是在同一个应用中为不同的功能模块选择不同的通信技术。例如,在一个社交应用中使用WebSocket处理聊天功能,而使用SSE处理动态或feed的推送。这种设计可以充分发挥各自的优势:聊天需要频繁的双向交互,使用WebSocket;feed推送主要是单向的服务器推送,使用SSE可以简化实现。在这种架构下,客户端需要同时维护WebSocket连接和SSE连接,增加了客户端的复杂度,需要仔细权衡。

另一种混合策略是将WebSocket用于实时性要求高的数据,而将SSE用于实时性要求相对较低但更可靠的数据传递。例如,在金融交易应用中,使用WebSocket推送实时成交数据,而使用SSE推送账户资金变动等重要通知。这种设计可以在性能和可靠性之间取得平衡。

从架构角度来看,无论是WebSocket还是SSE,在大规模部署时都需要与消息队列、缓存等中间件配合。将推送服务抽象为一个独立的推送网关,对外提供统一的接口,而内部可以根据数据特点选择不同的通信协议,这种设计可以提供更大的灵活性。客户端可以通过单一的接入点订阅不同类型的数据,而网关内部负责路由和分发。

七、代码示例对比

7.1 WebSocket服务端实现示例

以下是一个使用Node.js实现的WebSocket服务端示例,展示了WebSocket的基本用法。这个示例创建了一个简单的WebSocket服务器,当客户端连接时会发送欢迎消息,并处理客户端发送的消息然后广播给所有连接的客户端。这种模式是实时聊天应用的基础架构,开发者可以在此基础上添加房间管理、消息持久化等功能。

const WebSocket = require('ws');
const http = require('http');
const server = http.createServer((req, res) => {
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    res.end('WebSocket Server');
});

const wss = new WebSocket.Server({ server });

wss.on('connection', (ws) => {
    console.log('Client connected');
    ws.send('Welcome to the chat server!');

    ws.on('message', (message) => {
        console.log('Received:', message.toString());
        // 广播消息给所有连接的客户端
        wss.clients.forEach((client) => {
            if (client.readyState === WebSocket.OPEN) {
                client.send(message.toString());
            }
        });
    });

    ws.on('close', () => {
        console.log('Client disconnected');
    });
});

server.listen(8080, () => {
    console.log('WebSocket server started on port 8080');
});

7.2 SSE服务端实现示例

以下是一个使用Express框架实现的SSE服务端示例。SSE的实现更加简洁,服务器只需要设置正确的响应头,并持续向客户端发送事件流即可。这种简洁性是SSE的一大优势,特别适合快速原型开发和简单场景。开发者可以在这个基础上添加身份验证、连接管理等高级功能。

const express = require('express');
const app = express();

app.get('/events', (req, res) => {
    res.setHeader('Content-Type', 'text/event-stream');
    res.setHeader('Cache-Control', 'no-cache');
    res.setHeader('Connection', 'keep-alive');

    // 每秒发送一条消息
    const intervalId = setInterval(() => {
        const data = {
            time: new Date().toISOString(),
            message: 'Server time update'
        };
        res.write(`data: ${JSON.stringify(data)}\n\n`);
    }, 1000);

    // 当客户端断开连接时清理资源
    req.on('close', () => {
        clearInterval(intervalId);
        console.log('Client disconnected');
    });
});

app.listen(3000, () => {
    console.log('SSE server started on port 3000');
});

7.3 客户端实现对比

WebSocket客户端实现通常需要手动处理重连逻辑、心跳检测等:

const ws = new WebSocket('ws://localhost:8080');

ws.onopen = () => {
    console.log('Connected to WebSocket server');
    // 启动心跳
    setInterval(() => {
        if (ws.readyState === WebSocket.OPEN) {
            ws.send(JSON.stringify({ type: 'ping' }));
        }
    }, 30000);
};

ws.onmessage = (event) => {
    const data = JSON.parse(event.data);
    console.log('Received:', data);
};

ws.onclose = () => {
    console.log('Connection closed, reconnecting...');
    // 实现重连逻辑
    setTimeout(() => connect(), 3000);
};

SSE客户端使用EventSource API,自动处理重连:

const eventSource = new EventSource('http://localhost:3000/events');

eventSource.onopen = () => {
    console.log('Connected to SSE server');
};

eventSource.onmessage = (event) => {
    const data = JSON.parse(event.data);
    console.log('Received:', data);
};

eventSource.addEventListener('customEvent', (event) => {
    const data = JSON.parse(event.data);
    console.log('Custom event:', data);
});

八、总结与展望

8.1 技术选型总结

WebSocket和SSE作为两种主流的实时通信技术,各有其独特的优势和适用场景。WebSocket以其全双工通信特性成为需要双向交互场景的首选方案,从实时聊天到在线游戏,从协作编辑到金融交易,它能够满足各种复杂实时通信需求。WebSocket的低协议开销、灵活的数据类型支持也使其在高频数据交换场景下具有明显优势。然而,WebSocket的实现和维护需要更多的技术投入,特别是在大规模部署时需要考虑连接管理和水平扩展的问题。

SSE则以其简洁性和HTTP兼容性在服务器单向推送场景下展现出独特的价值。对于股票行情、实时监控、新闻推送等场景,SSE提供了一种轻量级且易于实现的解决方案。SSE的内置重连机制和HTTP基础设施的天然支持使其在某些受限环境中具有更好的穿透性。但SSE不支持双向通信、不支持IE浏览器、不支持二进制数据传输等限制也使其不适合某些场景。

在实际技术选型过程中,建议开发者首先明确应用的通信模式需求。如果主要是服务器向客户端推送数据,SSE是一个值得考虑的选项;如果需要频繁的双向交互,WebSocket是必然的选择。在此基础上,还需要综合考虑浏览器兼容性要求、团队技术栈、现有基础设施、扩展性需求等因素,做出最适合项目实际需求的选择。

8.2 技术发展趋势

实时通信技术仍在不断演进,了解其发展趋势有助于做出更具前瞻性的技术决策。

在协议层面,HTTP/3基于QUIC协议正在逐步普及,其在连接建立速度、丢包处理、多路复用等方面的改进对WebSocket和SSE都有潜在影响。HTTP/3原生支持WebSocket扩展,可以在HTTP/3连接上更高效地运行WebSocket。对于SSE来说,HTTP/3的改进也能提升其性能表现。预计在未来几年,HTTP/3将成为Web实时通信的重要基础。

在应用层面,边缘计算和CDN的进一步发展可能改变实时通信的架构模式。将推送服务部署在边缘节点可以进一步降低延迟,提升用户体验。WebSocket和SSE都可能受益于这种架构演进,但SSE因为其HTTP本质可能更容易与CDN配合。

在生态系统方面,WebSocket和SSE的各种高级库和框架仍在不断完善,为开发者提供更易用的抽象。例如,Socket.IO、SignalR等库提供了房间概念、自动重连、跨浏览器兼容等功能,大大降低了WebSocket的使用门槛。同时,无服务器架构的兴起也对实时通信提出了新的挑战和机遇,如何在无服务器环境中维护长连接是一个值得关注的方向。

nestjs实战-登录、鉴权(一)

一个完整的登录流程中至关重要的就是它的认证方式,现有的认证方式主要有一下3种:

  • session/cookie
  • JWT(Json Web Token)
  • Oauth

一、鉴权方式

Session/Cookie

原理:用户登录后,服务器在内存中创建 Session,并将 Session ID 通过 Cookie 返回给客户端。后续请求自动携带 Cookie。

特点:适用于传统Web应用,有状态,不适合跨域或高并发环境

优点:

  • 较易扩展
  • 开发简单

缺点:

  • 需要在服务端存储session,性能低、多服务器同步session困难
  • 由于cookie只在浏览器上能使用,所以它跨平台困难

JWT

原理:服务端通过特定密钥生成包含用户信息的签名字符串(Token),客户端在请求头(Authorization: Bearer Token)中携带。服务器不存储会话,只验证签名。

特点:无状态、扩展性好,适用于微服务和单页应用(SPA)、跨平台能力

JWT 本质上是一个经过数字签名的字符串,它由三部分组成,用点号(.)分隔:Header.Payload.Signature。token长什么样?

Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJsb2dpblR5cGUiOiJsb2dpbiIsImxvZ2luSWQiOiIyMDAwNCIsInJuU3RyIjoiQVpVUVY2dnAyUllkOFRucnJoaXNsQWtRcTFhSEFxeTAiLCJ0ZW5hbnRJZCI6IjEiLCJzY29wZSI6IlJPTEU6Ok1JQ1JPX0FQUCIsImFwcEtleSI6InJIWnJZeVB1eW1oaXZlSUUiLCJuaWNrTmFtZSI6IuWui-Wwj-aXrSIsImxvZ2luVGltZSI6MTc3NTYxMzI4NzYxNX0.bq8qEE-imza7pLucKOvKw2WvukW2lSPr1WMbAcuJLmU

优点:

  • 支持跨平台:移动端、跨应用
  • 安全、承载信息丰富

缺点:

  • 刷新与过期处理
  • payload不易过大
  • 中间人攻击(没有绝对的安全)

Oauth

第三方授权,例如 微信、支付宝、QQ、谷歌账号登录等

优点:

  • 开放、安全、简单
  • 权限指定

缺点:

  • 需要增加授权服务器
  • 增加网络请求

二、实战

首先 创建身份验证模块,在指定的目录下执行如下命令:

nest g res auth

整个流程我把它分成两部分:

  1. 用户登录过程
  2. 登录成功后,带token请求业务接口的过程

2.1 前置知识

此段内容篇理论介绍,可以先看后面的代码实现,有疑问再来看这里的内容

模块之间的复用

auth模块中如何使用 users 模块中的 users.service.ts 中的方法

之前的章中我们创建了 Users 模块,现在 auth 模块中想要使用 users.service 中的方法:

  • 首先需要在 users.module.ts 中导出 users.service.ts

    // ... 省略
    @Module({
      imports: [TypeOrmModule.forFeature([UserEntity])],
      controllers: [UsersController],
      providers: [UsersService],
      exports: [UsersService], // 导出
    })
    export class UsersModule {}
    
    
  • auth.module.ts 中导入

    import { Module } from '@nestjs/common';
    import { AuthService } from './auth.service';
    import { AuthController } from './auth.controller';
    import { UsersModule } from '../users/users.module';
    
    @Module({
      imports: [UsersModule],
      controllers: [AuthController],
      providers: [AuthService],
    })
    export class AuthModule {}
    
  • 这样就能在 auth.service.ts 中使用了

    import { Injectable } from '@nestjs/common';
    // 注意这里在使用中还是需要 显示的引入,而不是会自动引入
    import { UsersService } from '../users/users.service';
    
    @Injectable()
    export class AuthService {
      constructor(private readonly usersService: UsersService) {}
    
      async register(createUserDto: any) {
        return this.usersService.findAll();
      }
    }
    

JWT 相关包介绍

首先安装依赖:

 pnpm add @nestjs/passport passport-jwt @nestjs/jwt 

在集成 JWT 之前,先学习一下使用到的包,并了解它都提供了哪些功能。这里是掌握整个流程非常重要的环境,参考了网上(包括官网)一上来就是介绍怎么使用、代码怎么写,确实是让人很迷惑。

@nestjs/passport
  1. 集成 Passport.js:让 NestJS 应用能够使用 Passport.js 提供的超过 500 种认证策略(如本地用户名密码、JWT、OAuth 2.0 等)。
  2. 提供装饰器:它提供了一系列装饰器(如 @UseGuards(AuthGuard('jwt'))),让你可以非常方便地在控制器(Controller)或路由上应用认证保护。
  3. 简化策略创建:通过 PassportStrategy 类,你可以轻松地创建自定义策略,而无需直接处理 Passport.js 的底层 API。
import { PassportModule, PassportStrategy, AuthGuard } from '@nestjs/passport';
PassportModule
  • 类型: NestJS 模块 (@Module)

  • 作用: 它是整个 Passport 集成的入口。你需要把它导入到你的 NestJS 模块(如 AuthModule)中,才能启用 Passport 功能。

  • 核心功能

    • 它通过 .register() 方法允许你配置全局选项,比如指定默认的认证策略(defaultStrategy)。
    • 它负责将 Passport 的服务注入到 NestJS 的依赖注入容器中
PassportStrategy-策略实现
  • 类型:抽象类 / 辅助函数

  • 作用:它是用来创建具体认证逻辑的基类。Passport.js 本身有各种策略(如 Local, JWT, Google),这个函数帮助我们将这些策略适配到 NestJS 的类结构中。

  • 核心功能

    • 它接受一个具体的策略类(如 passport-localStrategy)作为参数。
    • 它让你重写 validate 方法,在这里编写具体的验证逻辑(比如查数据库比对密码)。
AuthGuard-路由保护
  • 类型:守卫 (CanActivate)

  • 作用:它是 NestJS 的守卫,用于保护路由。它拦截请求,并告诉 Passport 执行哪个策略。

  • 核心功能

    • 它通常配合 @UseGuards() 装饰器使用。
    • 它接收一个参数(字符串),这个字符串必须与你定义的策略名称(或默认名称)匹配,从而触发对应的验证流程。
@nestjs/jwt

@nestjs/jwt 是 Nest 官方对 JWT 的封装包,底层基于 jsonwebtoken。它主要解决三件事:

  1. 在 Nest 里统一配置 JWT 密钥、过期时间等(模块化)
  2. 提供 JwtService 来签发和校验 token
  3. 很容易和 Passportjwt strategy 集成成认证体系
import { JwtModule, JwtService } from '@nestjs/jwt';
JwtModule:模块注册器
  • 用来在 Nest DI 容器里注册 JWT 相关配置和服务

  • 常见用法:

    • JwtModule.register({...}) 静态配置
    • JwtModule.registerAsync({...}) 动态读取配置(比如从 ConfigServiceJWT_SECRET
JwtService:具体干活的服务

用它生成 token、验证 token、解码 token,一般在 AuthService 里注入并调用

  • sign(payload, options?)

    • 作用:根据 payload 生成 JWT 字符串
    • 示例:this.jwtService.sign({ sub: user.id, name: user.name })
    • options 可以覆盖模块默认配置,比如 expiresIn, secret
  • verify(token, options?)

    • 作用:验证 token 是否合法、是否过期,并返回解码后的 payload
    • 验证失败会抛异常(如签名不对、过期)
    • 示例:this.jwtService.verify(token)
passport-jwt

passport-jwt 这个包的核心作用是:给 Passport 提供“JWT 认证策略”。

import { ExtractJwt, Strategy } from 'passport-jwt';

主要用到两个能力:

  • Strategy

    • JWT 的 Passport 策略类
    • 你在 Nest 里通常写 extends PassportStrategy(Strategy, 'jwt')
    • 用来定义:用什么密钥验签、是否忽略过期、验证成功后返回什么用户信息
  • ExtractJwt

    • token 提取器工具
    • 最常用:ExtractJwt.fromAuthHeaderAsBearerToken()
    • 表示从 Authorization: Bearer <token> 里取 token
    • 也支持从 cookie、query、或自定义函数提取

简化理解:

  • @nestjs/jwt 偏向“签发/校验 token 的服务能力”
  • passport-jwt 偏向“请求进来时怎么从请求里拿 token 并走认证策略”

二者经常一起用:登录时 JwtService.sign() 发 token,请求鉴权时 passport-jwtStrategy 来验。

2.2 JWT 集成

auth.module.ts

import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config';

import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { UsersModule } from '../users/users.module';
import { securityRegToken, ISecurityConfig } from '~/config';
import { isDev } from '~/global/env';

@Module({
  imports: [
    PassportModule, // 引入 PassportModule 模块
    // 引入 JwtModule 模块,及配置 JwtModule 模块
    JwtModule.registerAsync({
      imports: [ConfigModule], // 引入 ConfigModule 模块
      useFactory: (configService: ConfigService) => {
        const { jwtSecret, jwtExprire } = configService.get<ISecurityConfig>(securityRegToken)
        return {
          secret: jwtSecret, // 设置密钥
          signOptions: { expiresIn: jwtExprire }, // 设置过期时间
          ignoreExpiration: isDev, // 开发环境忽略过期时间
        }
      },
      inject: [ConfigService], // 注入 ConfigService 服务
    }),
    UsersModule,
  ],
  controllers: [AuthController],
  providers: [
    AuthService,
  ],
})
export class AuthModule {}

以上代码 主要是 引入了 PassportModule, JwtModule,并完善了相关配置;

2.3 用户登录过程

  • 用户输入 用户名、密码 等信息

    通过http(加密|明文),传输给后端

  • 后端接受到来自前端 的http 请求

    • 验证账号密码是否一致
    • 生成 token 传递给前端

auth.controller.ts

import { Controller, Post, Body, UseGuards, Req } from '@nestjs/common';

import { AuthService } from './auth.service';
import { LoginDto } from './dto/auth.dto';

@Controller('auth')
export class AuthController {
  constructor(
    private readonly authService: AuthService,
  ) {}

  // 登录
  @Post('login')
  login(@Body() loginDto: LoginDto) {
    return this.authService.login(loginDto);
  }
}

auth.service.ts

import { HttpException, Injectable, HttpStatus } from '@nestjs/common';
import { UsersService } from '../users/users.service';
import { isEmpty } from 'lodash';
import { JwtService } from '@nestjs/jwt';

@Injectable()
export class AuthService {
  constructor(
    private readonly usersService: UsersService,
    private readonly jwtService: JwtService,
  ) {}

  async login(loginDto: {
    name: string;
    password: string;
  }) {
    const { name, password } = loginDto;
    const user = await this.usersService.findUserByUserName(name);
    if (isEmpty(user)) {
      throw new HttpException('用户不存在', HttpStatus.NOT_FOUND);
    }

    const { password: userPassword } = user;
    if (userPassword !== password) {
      throw new HttpException('密码错误', HttpStatus.BAD_REQUEST);
    }

    // 生成 JWT 签名
    const jwtSign = await this.jwtService.signAsync({
      id: user.id,
      name: user.name,
      pv: 1, // 版本号
    })
    return {
      access_token: jwtSign
    };
  }
}

用户登录过程代码如上,还是比较简单:

  • 获取用户传递过来的参数,校验用户是否存在,密码是否正确
  • 生成JWT签名,返回token給前端

功能主线如此,密码加密、token管理等有待完善

2.4 业务接口token验证过程

由于文章篇幅,放到下一节介绍;

受够了空格翻页?我写了一个 Chrome 自动滚动插件,让你真正沉浸式阅读

痛点:你一定遇到过这些问题

阅读长文章时,你有没有这样的体验?

1. 空格键翻页,永远翻不到想要的位置

浏览器默认的空格键翻页,一按就是整屏跳过去,经常跳过关键内容。你得来回滚动找刚才读到哪了,阅读节奏完全被打断。

2. 手动滚轮,手指累得够呛

读一篇万字长文,你得不停地滚鼠标滚轮。读技术文档就更夸张了,一边看一边滚,注意力全被分散了。

3. 现有自动滚动插件,不够好用

Chrome 应用商店里也有一些自动滚动插件,但普遍存在这些问题:

  • 速度调节不够精细,要么太快要么太慢
  • 没有方便的快捷键暂停机制,想停下来看某段内容很麻烦
  • 交互逻辑粗糙,滚动中点击页面或者用滚轮都会导致混乱
  • 我希望通过空格就可以开启滚动,再按暂停滚动, 自己滑时暂停滚动, 并且可以通过左右键盘按键直接调节滚动速度

所以我造了个轮子:Auto Scroll Reader

GitHub 地址:github.com/s2265681/au…

一款轻量级 Chrome 自动滚动插件,让你解放双手,沉浸阅读。

output3.gif

核心特性

1. 匀速丝滑滚动,告别空格跳页

插件采用 setInterval + 像素级滚动的方案,而不是浏览器原生的 scrollBy({ behavior: 'smooth' })。每次只滚动 15.5 个像素,间隔 1660ms,实现真正的匀速丝滑滚动,阅读体验就像在看一条平稳流动的文字河流。

2. 10 档速度,精细可调

提供 1-10 档速度调节,覆盖从「逐字细读」到「快速浏览」的全部场景:

速度档位 滚动间隔 每次像素 适合场景
1 (最慢) 56ms 1px 逐字精读、代码阅读
3 (默认) 46ms 2px 普通文章阅读
5 36ms 3px 快速浏览
10 (最快) 16ms 5.5px 速览/找内容

你可以通过 Popup 面板的滑块调节,也可以直接用左右方向键实时调速,页面会弹出 Toast 提示当前速度,完全不用打开插件面板。

3. 空格键一键暂停/继续

这是我最满意的设计。按下 Space 键即可暂停/恢复滚动,和阅读场景天然契合:

  • 看到感兴趣的段落?空格暂停,慢慢看
  • 看完了?空格继续,接着滚
  • 输入框内打字时?自动屏蔽,不会误触发

再也不用去找鼠标点按钮了。

4. 智能暂停机制

插件不是"一根筋"地滚动,它能感知用户的操作意图:

  • 鼠标滚轮:滚动中你突然用滚轮,自动暂停,空格可恢复
  • 点击页面:点击空白区域自动暂停(点击链接和按钮不受影响)
  • 触摸滑动:移动端触摸操作同样会暂停

这意味着你随时可以"接管"控制权,不需要刻意去关闭自动滚动。

5. 智能识别滚动容器

很多网站的正文并不在 window 上滚动,而是在某个 div 容器内。插件会自动检测页面中可滚动的容器(匹配 mainarticle[class*="content"][class*="reader"] 等语义化标签),确保在各种页面布局下都能正常工作。

6. 全局开关 + 跨 Tab 同步

Popup 面板提供了一个总开关,关闭后所有页面的自动滚动都会停止,快捷键也不会响应。开关状态通过 chrome.storage.sync 存储,跨 Tab 实时同步

快捷键一览

快捷键 功能
Space 开始 / 暂停滚动
减速
加速

就这三个键,足够了。

技术实现亮点

作为一个开发者,简单聊聊技术细节:

Manifest V3

插件基于 Chrome 最新的 Manifest V3 规范开发,使用 Service Worker 作为 background script,符合 Chrome 未来的扩展标准。

防重复注入

if (window.__autoScrollReader) return;
window.__autoScrollReader = true;

Content Script 通过全局标记防止重复注入,避免多次执行导致多个滚动定时器同时工作。

自动注入已打开的 Tab

安装或更新插件后,会自动给所有已打开的标签页注入 Content Script,无需刷新页面即可使用。

输入框保护

inputtextareaselect 以及 contentEditable 元素中,快捷键不会被拦截,确保正常输入不受影响。

使用方式

安装

  1. 下载项目:前往 GitHub 仓库,点击 Code → Download ZIP 并解压
  2. 打开 Chrome,进入 chrome://extensions/
  3. 开启右上角的 开发者模式
  4. 点击 加载已解压的扩展程序,选择解压后的文件夹
  5. 完成!在工具栏点击插件图标即可使用

使用

  1. 打开任意文章页面
  2. Space 键开始自动滚动(或点击插件面板的「开始滚动」按钮)
  3. 方向键实时调速
  4. 再按 Space 暂停,随时恢复

适用场景

  • 阅读掘金、知乎、Medium 等平台的长文章
  • 浏览技术文档和 API 文档
  • 看小说、新闻、公众号文章
  • 代码 Review 时自动滚动代码
  • 任何需要"解放双手"的阅读场景

写在最后

这个插件的代码非常简洁,总共就四个文件,零依赖,纯原生 JavaScript 实现。如果你也受够了空格键翻页和手动滚轮,不妨试试。

GitHub 地址:github.com/s2265681/au…

欢迎 Star、提 Issue、提 PR,也欢迎在评论区交流你的使用体验!


如果这篇文章对你有帮助,别忘了点个赞 👍

🍎用 pretext 搞定输入框动态宽度:一个困扰了我三天的 CSS 问题

pretext-cover.png

通过 @chenglou/pretext 库在前端精确计算文本宽度,实现了搜索表单输入框的动态宽度适配。告别 min-width 的粗暴限制,让 UI 更精致。

背景

做企业管理系统,搜索表单是最常见的组件。一个头疼的问题是:输入框宽度怎么定?

定死了,长文本挤成省略号;用 min-width,不同字段长度不同,结果参差不齐。

pretext1.png

直到我发现了 pretext 这个库。

问题场景

看这个搜索表单:

<!-- SearchForm/index.vue -->
<template>
  <div class="search-area">
    <el-form :inline="true" :model="formData" class="search-form">
      <el-form-item v-for="field in fields" :key="field.prop">
        <el-input
            v-if="field.type === 'input'"
            v-model="formData[field.prop]"
            :placeholder="field.placeholder || `请输入${field.label}`"
            :style="getFieldStyle(field)"
        />
        <!-- 日期范围选择 -->
        <el-date-picker
            v-else-if="field.type === 'dateRange'"
            v-model="formData[field.prop]"
            type="datetimerange"
            :style="getFieldStyle(field)"
        />
        <!-- 下拉选择 -->
        <el-select
            v-else-if="field.type === 'select'"
            v-model="formData[field.prop]"
            :placeholder="field.placeholder || `请选择${field.label}`"
            :style="getFieldStyle(field)"
        />
      </el-form-item>
    </el-form>
  </div>
</template>

字段配置是动态的:

const fields = [
  { prop: 'name', label: '姓名', type: 'input' },
  { prop: 'idCard', label: '身份证号', type: 'input' },
  { prop: 'createTime', label: '创建时间', type: 'dateRange' },
  { prop: 'status', label: '状态', type: 'select', options: [...] }
]

字段有长有短:

  • "请输入姓名" → 短
  • "请输入身份证号码" → 长
  • "请选择开始时间 至 请选择结束时间" → 更长

核心问题:每个字段的 placeholder 长度不同,如何让输入框宽度刚刚好?

常见的"摆烂"方案

方案 1:固定宽度

.el-input {
  width: 200px; /* 要不挤死,要不太空 */
}

方案 2:min-width

.el-input {
  min-width: 180px;
  width: auto;
}

结果就是参差不齐——"姓名"和"身份证号"都是 200px,但明显应该不同宽度。

方案 3:后端返回宽度配置

每个字段配一个宽度值,后端告诉我该多宽。

工作量大,而且字段改了要同步改配置。

解决方案:pretext 文本测量

@chenglou/pretext 是一个纯 JS 的文本渲染库,能精确计算给定字体样式下文本的像素宽度。

核心原理:

  1. 传入文本 + 字体样式
  2. 返回每个字符的位置信息
  3. 由此计算出文本总宽度

安装

yarn add @chenglou/pretext

核心代码

// fieldStyle.js
import { prepareWithSegments, layoutWithLines } from '@chenglou/pretext';

// 字体样式,与 Element Plus el-input 保持一致
const FONT_STYLE = '14px "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", Arial, sans-serif';

/**
 * 根据 placeholder 文本计算宽度
 * @param {string} placeholderText - 占位符文本
 * @param {number} extraPadding - 额外的内边距(默认 50px)
 * @returns {number} 计算后的宽度
 */
export function getPlaceholderWidth(placeholderText, extraPadding = 50) {
  if (!placeholderText) return 200;
  const prepared = prepareWithSegments(placeholderText, FONT_STYLE);
  const result = layoutWithLines(prepared, 1000, 14);
  return Math.ceil(result.lines[0].width) + extraPadding;
}

/**
 * 获取字段宽度样式
 * @param {Object} field - 字段配置
 * @returns {Object} 宽度样式对象 { width: string }
 */
export function getFieldStyle(field) {
  let placeholderText = field.placeholder || `请输入${field.label}`;
  let extraPadding = 50;

  // dateRange 类型需要更宽(显示两个日期 + 分隔符)
  if (field.type === 'dateRange') {
    const startPlaceholder = field.startPlaceholder || `${field.label}开始时间`;
    const endPlaceholder = field.endPlaceholder || `${field.label}结束时间`;
    placeholderText = startPlaceholder + endPlaceholder;
    extraPadding = 80; // dateRange 控件本身更宽
  }

  const width = getPlaceholderWidth(placeholderText, extraPadding);
  return { width: `${width}px` };
}

使用效果

字段 placeholder 计算宽度
姓名 请输入姓名 132px
身份证号 请输入身份证号 172px
创建时间 请选择开始时间至请选择结束时间 340px

输入框宽度自适应文本长度,视觉上整齐划一。

image.png

这样看是不是舒服多了!!!

踩坑记录

坑 1:字体必须完全一致

Element Plus 的 input 使用系统字体,如果 pretext 的字体定义和它不一致,计算出来的宽度会有偏差。

解决:直接从浏览器 DevTools 抄 Element Plus 的实际字体样式:

// Chrome DevTools Elements 面板
// 检查 .el-input__inner 的 computed styles
const FONT_STYLE = '14px "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", Arial, sans-serif';

坑 2:dateRange 需要特殊处理

日期范围选择器显示的是两个日期 + "至"分隔符,宽度明显比普通输入框大。

解决:针对 dateRange 类型,把开始和结束的 placeholder 拼接起来计算,并且增大 extraPadding

坑 3:计算结果偏小

单独计算每个字段后,实际渲染还是有点挤。

原因:输入框还有 padding、border 等自身宽度。

解决:加了 extraPadding = 50px 的缓冲,不同类型调整这个值。

完整组件代码

utils/fieldStyle.js

import { prepareWithSegments, layoutWithLines } from '@chenglou/pretext';

const FONT_STYLE = '14px "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", Arial, sans-serif';

export function getPlaceholderWidth(placeholderText, extraPadding = 50) {
  if (!placeholderText) return 200;
  const prepared = prepareWithSegments(placeholderText, FONT_STYLE);
  const result = layoutWithLines(prepared, 1000, 14);
  return Math.ceil(result.lines[0].width) + extraPadding;
}

export function getFieldStyle(field) {
  let placeholderText = field.placeholder || `请输入${field.label}`;
  let extraPadding = 50;

  if (field.type === 'dateRange') {
    const startPlaceholder = field.startPlaceholder || `${field.label}开始时间`;
    const endPlaceholder = field.endPlaceholder || `${field.label}结束时间`;
    placeholderText = startPlaceholder + endPlaceholder;
    extraPadding = 80;
  }

  const width = getPlaceholderWidth(placeholderText, extraPadding);
  return { width: `${width}px` };
}

components/SearchForm/index.vue

<template>
  <div class="search-area">
    <el-form :inline="true" :model="formData" class="search-form">
      <el-form-item v-for="field in fields" :key="field.prop">
        <!-- 输入框 -->
        <el-input
            v-if="field.type === 'input'"
            v-model="formData[field.prop]"
            :placeholder="field.placeholder || `请输入${field.label}`"
            clearable
            :style="getFieldStyle(field)"
        />
        <!-- 日期范围选择 -->
        <el-date-picker
            v-else-if="field.type === 'dateRange'"
            v-model="formData[field.prop]"
            type="datetimerange"
            range-separator="至"
            :start-placeholder="field.startPlaceholder || `${field.label}开始时间`"
            :end-placeholder="field.endPlaceholder || `${field.label}结束时间`"
            value-format="YYYY-MM-DD HH:mm:ss"
            format="YYYY-MM-DD HH:mm:ss"
            :style="getFieldStyle(field)"
        />
        <!-- 单个日期选择 -->
        <el-date-picker
            v-else-if="field.type === 'date'"
            v-model="formData[field.prop]"
            type="datetime"
            :placeholder="field.placeholder || `请选择${field.label}`"
            value-format="YYYY-MM-DD HH:mm:ss"
            format="YYYY-MM-DD HH:mm:ss"
            :style="getFieldStyle(field)"
        />
        <!-- 下拉选择 -->
        <el-select
            v-else-if="field.type === 'select'"
            v-model="formData[field.prop]"
            :placeholder="field.placeholder || `请选择${field.label}`"
            clearable
            :style="getFieldStyle(field)"
        >
          <el-option
              v-for="option in field.options"
              :key="option.value"
              :label="option.label"
              :value="option.value"
          />
        </el-select>
      </el-form-item>
      <el-form-item class="search-btn-group">
        <el-button type="primary" @click="handleSearch">搜索</el-button>
        <el-button @click="handleReset">重置</el-button>
      </el-form-item>
    </el-form>
  </div>
</template>

<script setup>
import { ref, watch, reactive } from 'vue';
import { getFieldStyle } from '@/utils/fieldStyle';

const props = defineProps({
  modelValue: { type: Object, default: () => ({}) },
  fields: { type: Array, default: () => [] }
});

const emit = defineEmits(['update:modelValue', 'search', 'reset']);

const formData = reactive({ ...props.modelValue });

const initFormData = () => {
  props.fields.forEach(field => {
    if (!(field.prop in formData)) {
      formData[field.prop] = field.type === 'dateRange' ? [] : '';
    }
  });
};

watch(() => props.fields, () => { initFormData(); }, { immediate: true, deep: true });
watch(() => props.modelValue, (val) => { Object.assign(formData, val); }, { deep: true });

const handleSearch = () => {
  emit('search', { ...formData });
};

const handleReset = () => {
  props.fields.forEach(field => {
    formData[field.prop] = field.type === 'dateRange' ? [] : '';
  });
  emit('reset');
};
</script>

<style lang="scss" scoped>
.search-area {
  background: #fff;
  padding: 10px;
  margin-bottom: 0;
  border-radius: 4px;

  .search-form {
    .el-form-item {
      margin-right: 10px;
      margin-bottom: 12px;
    }
  }
}
</style>

对比效果

方案 姓名输入框 身份证号输入框 日期范围选择器
固定 200px 200px (空旷) 200px (刚好) 200px (挤)
min-width: 180px 180px (挤) 200px+ (不统一) 200px+ (不统一)
pretext 动态宽度 132px 172px 340px

适用场景

适合用 pretext 的场景:

  • 动态表单,字段配置来自后端
  • 多语言系统,不同语言文本长度差异大
  • 需要精细控制 UI 尺寸的企业级应用

不适合用的场景:

  • 固定的几 个字段,直接配固定宽度更简单
  • 性能敏感的热路径,pretext 计算有开销
  • 响应式布局,容器宽度本身就在变

总结

pretext 解决了文本宽度计算的难题,让输入框宽度能"自适应"文本长度。核心就两个 API:

const prepared = prepareWithSegments(text, fontStyle);
const result = layoutWithLines(prepared, maxWidth, fontSize);
result.lines[0].width; // 文本宽度

配上调优的 extraPadding,基本能覆盖大部分场景。


有问题欢迎留言交流。

你的 Vue 3 reactive(),VuReact 会编译成什么样的 React?

VuReact 是一个能将 Vue 3 代码编译为标准、可维护 React 代码的工具。今天就带大家直击核心:Vue 中高频使用的 reactive()shallowReactive(),经过 VuReact 编译后会变成什么样的 React 代码?

前置约定

  1. 示例只保留核心逻辑,省略完整组件包裹
  2. 你已熟悉 Vue 3 reactive / shallowReactive 用法

一、Vue reactive() → React useReactive()

reactive 是 Vue 3 最核心的对象响应式 API,在 VuReact 中会被精准映射。

基础编译对照

Vue 输入

<script setup>
  import { reactive } from 'vue';

  const state = reactive({
    count: 0,
    title: 'VuReact',
  });
</script>

VuReact 输出(React)

import { useReactive } from '@vureact/runtime-core';

const state = useReactive({
  count: 0,
  title: 'VuReact',
});

reactive 直接编译为 useReactive Hook:

  • 完全保留 Vue 响应式语义
  • 直接修改属性自动触发视图更新
  • 深层对象、数组、Map/Set 全部支持
  • 和 React 生命周期完美协同

TypeScript 场景:类型完整保留

Vue 输入(TS)

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

  interface User {
    id: number;
    name: string;
  }

  const state = reactive<{
    loading: boolean;
    users: User[];
    config: Record<string, any>;
  }>({
    loading: false,
    users: [],
    config: { theme: 'dark' },
  });
</script>

VuReact 输出(TS)

import { useReactive } from '@vureact/runtime-core';

interface User {
  id: number;
  name: string;
}

const state = useReactive<{
  loading: boolean;
  users: User[];
  config: Record<string, any>;
}>({
  loading: false,
  users: [],
  config: { theme: 'dark' },
});

接口、泛型、类型约束完全迁移
React 侧智能提示、类型检查全部正常
不用改一行类型逻辑


二、Vue shallowReactive() → React useShallowReactive()

浅层响应式用于性能优化,只监听顶层属性变化,VuReact 同样完美对齐。

基础编译对照

Vue 输入

<script setup>
  import { shallowReactive } from 'vue';

  const state = shallowReactive({
    nested: { count: 0 },
  });
</script>

VuReact 输出(React)

import { useShallowReactive } from '@vureact/runtime-core';

const state = useShallowReactive({
  nested: { count: 0 },
});

useShallowReactive 行为完全对齐 Vue:

  • 修改顶层属性 → 触发更新
  • 修改深层嵌套属性 → 不触发更新
  • 替换整个对象 → 触发更新
  • 适合大型列表、复杂状态、第三方数据等性能场景

总结一句话

  • Vue reactive → React useReactive
  • Vue shallowReactive → React useShallowReactive
  • 响应式行为一致
  • TypeScript 类型一致
  • 开发心智完全一致

用 VuReact,你可以:

  • 继续用 Vue 3 舒服的写法
  • 直接产出可维护的 React 代码
  • 无痛渐进迁移,不用一次性重构

🔗 相关资源

你的 Vue 3 ref(),VuReact 会编译成什么样的 React?

VuReact 是一个能将 Vue 3 代码编译为标准、可维护 React 代码的编译工具,并非运行时混合框架。今天我们直接看核心:Vue 高频使用的 ref() / shallowRef(),经过 VuReact 编译后会对应 React 的哪些代码?

前置约定

  1. 文中 Vue/React 代码均为核心逻辑简写,省略完整组件与冗余结构
  2. 你已熟悉 Vue 3 refshallowRef 的用法与行为

一、Vue ref() → React useVRef()

ref 是 Vue 3 最基础的响应式 API,在 VuReact 中会被直接编译为 React Hook。

基础编译对照

Vue 输入

<script setup>
  import { ref } from 'vue';
  const count = ref(0);
</script>

VuReact 输出(React)

import { useVRef } from '@vureact/runtime-core';
const count = useVRef(0);

ref 会被编译成 useVRef,它是 Vue ref 在 React 里的语义完全对齐的适配 API,保留 .value 访问与响应式更新行为。

带 TypeScript 类型场景

Vue 输入(TS)

<script lang="ts" setup>
  const title = ref<string>('');
  const isLoading = ref<boolean>(false);
  const userList = ref<Array<{ id: number; name: string }>>([]);
  const config = ref<Record<string, any>>({ theme: 'dark' });
</script>

VuReact 输出(TS)

const title = useVRef<string>('');
const isLoading = useVRef<boolean>(false);
const userList = useVRef<Array<{ id: number; name: string }>>([]);
const config = useVRef<Record<string, any>>({ theme: 'dark' });

TS 泛型、类型注解完整保留,React 侧类型提示完全可用。


二、Vue shallowRef() → React useShallowVRef()

shallowRef 是浅层响应式 API,只监听顶层引用变化,适合大对象性能优化。

基础编译对照

Vue 输入

<script setup>
  import { shallowRef } from 'vue';
  const count = shallowRef({ a: { b: 1, c: { d: 2 } } });
</script>

VuReact 输出(React)

import { useShallowVRef } from '@vureact/runtime-core';
const count = useShallowVRef({ a: { b: 1, c: { d: 2 } } });

useShallowVRef 完全对齐 shallowRef 行为:

  • 修改嵌套属性 → 不触发更新
  • 直接替换 .value触发更新

🔗 相关资源

Mapmost专题地图:解锁这场春游“热”

江苏首个春假+清明假期,苏州到底有多火?
文旅消费全省双料第一6天141处景区1200万人次——平均每秒钟就有23个人涌入苏州的公园、园林、古街和湖畔......走到哪里都是热闹的春日盛景。
跟着Mapmost,用一张张地图,解锁这场春游“热”!

赏樱路线

苏州热门赏樱地点众多。通过轨迹线功能,可以将每一个绝美打卡点串联起来,形成一条最全最优赏樱路线,不错过任何一处风景。

Mapmost赏樱图

园林热力

热门园林多次触发预警,借助热力图能力,可以实时呈现景区客流分布,帮助游客直观判断拥挤程度,合理筛选目的地,获得更舒适的游园体验。

Mapmost园林热力图

古城分区

山塘街、平江路等古城街区客流密集。Mapmost支持在地图上直接绘制多边形区域,清晰标出街区范围、边界及核心地址,让游客一眼看懂地理位置,用更短的路径逛遍更多古城精华。

Mapmost古城街区图

露营点位

太湖、阳澄湖等热门露营地,游客最关心的就是停车场和卫生间。Mapmost点标注功能,将这些设施在地图上标注出来,信息一目了然,避开其他信息干扰,愉快露营。

Mapmost露营点位图

结语

Mapmost来说,多样的地图叠加专题信息展示是基础能力,不仅可应用在智慧文旅,还可应用在智慧城市、智慧交通、智慧警务等多个领域。

Mapmost Studio提供了数据管理、服务发布、地图制图等功能,用户可自行发布数据、配制样式。Mapmost SDK for WebGL提供了多样的接口,用户可自行搭建系统。

立即开始Mapmost试用

👉 Mapmost在线体验地址**:**www.mapmost.com/#/productMa…

我用 Cloudflare 搭了一个 FlashInbox 临时邮箱

本文转载自个人博客,若内容存在缺失或后续有更新,可通过下方链接跳转至原博客页面,查阅完整内容:

我用 Cloudflare 搭了一个 FlashInbox 临时邮箱 - 萑澈的寒舍

我一直觉得,临时邮箱这种东西,能自己掌握还是自己掌握比较踏实。公共服务不是不能用,而是你用的时候总要多留个心眼:域名不是你的,地址不是你的,哪天规则变了、站点没了,或者数据清了,你也没什么办法。

刚好 FlashInbox 这套项目走的又是我比较喜欢的路线:前端 Next.js,部署在 Cloudflare Workers,收信靠 Cloudflare Email Routing,数据放进 D1。不需要自己折腾一整套传统邮件服务,成本和复杂度都低不少。

项目仓库在这里:https://github.com/CtelSpecu/FlashInbox

演示地址:flashinbox.hxcn.top

我这次就是把它从仓库拉下来,按自己的域名和 Cloudflare 环境跑了一遍。装完之后,手里会有一套自己的临时邮箱服务:能匿名创建地址,能收邮件,能认领邮箱拿 Key,也能靠 username + key 把邮箱找回来。默认 Key 有效期是 15 天,拿来收验证码、接通知、做注册测试都挺顺手。

最后大概会变成这样:网页在 mail.example.com 上打开,临时邮箱地址是 bluepanda23@example.com 这种形式,邮件能正常收进来,后台还能自己管域名、规则和隔离队列。

FlashInbox 是什么

先把边界说清楚,免得装到一半才发现想要的根本不是这个。

FlashInbox 更像一个收件站,不是常规邮箱服务。它现在能做的事情主要有这些:

  • 支持匿名创建临时邮箱
  • 支持接收入站邮件
  • 不存附件
  • 支持认领邮箱并生成一次性明文 Key
  • 支持用 username + key 恢复访问
  • 提供一个管理后台

你如果想要的是“自己域名下的临时邮箱”,那这个方向没问题;如果想要的是“自己做一个 Gmail”,那就是另一回事了。

准备工作

先准备这些东西:

  • 一个 Cloudflare 账号,并且已经开通 WorkersD1Email RoutingTurnstile
  • 一个已经接入 Cloudflare DNS 的域名,例如 example.com
  • 本地环境里已经装好 bun
  • 本地可以使用 wrangler

这里先记住一件事:Web 访问域名可以是 mail.example.com,真正收信的域名可以是 example.com。这两个域名最好分开,因为 Cloudflare Email Routing 的 catch-all 规则吃的是主域,不是 mail.example.com 这种 Web 子域。

架构先看明白

这套东西不是一个 Worker 从头跑到尾,而是三部分一起干活:

  1. flashinbox 这是主应用,负责网页、API、用户收件箱、管理后台。它读的是 wrangler.toml
  2. flashinbox-email 这是入站邮件 Worker,负责接住 Email Routing 转过来的邮件,解析后写进 D1。它读的是 wrangler.email.toml
  3. flashinbox-scheduled 这是定时任务 Worker,负责清理、统计之类的后台工作。它读的是 wrangler.scheduled.toml

这三个 Worker 必须指向同一个 D1。很多“页面能打开但收不到信”的问题,最后查下来都不是代码坏了,而是这里没配对。

第 1 步:克隆仓库并安装依赖

先把仓库拉下来:

git clone https://github.com/CtelSpecu/FlashInbox.git
cd FlashInbox
bun install

项目里已经带了 wrangler,后面直接用 bunx wrangler 也行。

想先确认本地环境没问题,可以顺手看一眼 package.json 里的几个命令:

bun run build:worker
bun run dev
bun test

第 2 步:创建 D1

先登录:

wrangler login

再创建生产数据库:

wrangler d1 create flashinbox-db

执行完以后,Cloudflare 会返回一个 database_id。把它分别填到 wrangler.tomlwrangler.email.tomlwrangler.scheduled.toml 里。这三份配置里的 database_id 必须一致。只改主应用那一份不够,网页是能开,后台也可能能进,但邮件根本不会进到你以为的那个库里。

第 3 步:执行数据库迁移

接着把表建好:

wrangler d1 execute flashinbox-db --remote --file=migrations/0001_init.sql
wrangler d1 execute flashinbox-db --remote --file=migrations/0002_mailboxes_banned.sql

两条都跑,不要省。0001_init.sql 是基础表结构,0002_mailboxes_banned.sql 跟后台禁用邮箱的状态有关,少跑一条,后面多半要踩坑。

如果迁移时碰到 BEGIN TRANSACTIONCOMMIT 之类的报错,说明迁移文件不符合 D1 的要求。当前仓库里的迁移文件已经处理过,照着跑通常没事。

第 4 步:配置域名

wrangler.toml 里有两个地方必须看:

  1. 生产环境路由

例如:

[env.production]
name = "flashinbox"
routes = [
  { pattern = "mail.example.com", custom_domain = true }
]
  1. 默认邮箱域名

例如:

[env.production.vars]
DEFAULT_DOMAIN = "example.com"

这两个值不是一回事。mail.example.com 是用户打开网页的地址,example.com 才是真正生成临时邮箱地址时用的收信域名。很多人第一次配的时候,脑子里只有一个“域名”,结果 Web 域名和收信域名搅在一起,后面查问题会非常累。

第 5 步:创建 Turnstile

FlashInbox 的认领流程用到了 Turnstile,所以这里先建一个站点,绑定主应用域名,比如 mail.example.com。建好之后,把 TURNSTILE_SITE_KEYTURNSTILE_SECRET_KEY 记下来,后面填进主应用配置里。

第 6 步:配置 Secrets

接下来配主应用的 Secrets:

wrangler secret put ADMIN_TOKEN --env production
wrangler secret put KEY_PEPPER --env production
wrangler secret put SESSION_SECRET --env production
wrangler secret put TURNSTILE_SECRET_KEY --env production
wrangler secret put TURNSTILE_SITE_KEY --env production

这里最重要的是 ADMIN_TOKENKEY_PEPPERSESSION_SECRET。前者是后台登录口令,后两个分别用于 Key 哈希和会话签名。尤其是 KEY_PEPPER,这个值别随便改,它跟 Key 哈希和恢复流程绑得很死。你改一次,旧 Key 基本就都失效了。

第 7 步:部署主应用

主应用这一层走的是 Next.js + OpenNext + Cloudflare Workers

先构建:

bun run build:worker

再部署:

wrangler deploy --env production

跑到这里,站点本身应该已经能打开了,比如:

https://mail.example.com

如果这时候网页都打不开,就别急着去查邮件链路,先把主应用的构建、路由和自定义域名检查完。

第 8 步:部署两个 Worker

主应用只是前台,收邮件和做清理的活,还得靠另外两个 Worker。

先部署 Email Worker:

wrangler deploy --config wrangler.email.toml

再部署 Scheduled Worker:

wrangler deploy --config wrangler.scheduled.toml

这一步特别容易漏,而且一漏就很容易把人带偏。页面能开,创建邮箱也不报错,乍一看像是已经部署好了,甚至会让人误以为后面只剩发邮件验证一下。等你真的往那个地址发一封测试邮件,才会发现收件箱安静得像没通电。

页面能开,不代表收信链路已经通了。

第 9 步:初始化 domains

这里也是高频坑。FlashInbox 的 Email Worker 不会见信就收,它会先查 D1 里的 domains 表,看这个域名是不是存在、是不是 enabled。表里没有,或者状态不对,邮件就进不来。

这个表有两种初始化办法。

方案 A:后台添加

部署完成后,访问:

https://mail.example.com/admin

ADMIN_TOKEN 登录,在 Domains 页面里把 example.com 加进去,并设置为 enabled

方案 B:写入 D1

wrangler d1 execute flashinbox-db --remote --command "INSERT INTO domains (name, status, note, created_at, updated_at) VALUES ('example.com', 'enabled', 'prod', strftime('%s','now')*1000, strftime('%s','now')*1000);"

如果你后面打算让多个域名一起收信,比如 example.comexample.net,那每个域名都得写进去,别指望一个默认值把所有事情包圆。

第 10 步:配置 Email Routing

下面开始配真正的收信链路。

在 Cloudflare Dashboard 里打开你的域名,进入 Email Routing,把下面几项配上:

  • 启用 Email Routing
  • 创建一条 Catch-all 规则
  • Action 选择 Send to a Worker
  • Worker 选择 flashinbox-email

逻辑很简单:别人发到 anything@example.com 的邮件,Cloudflare 先接住,再转给你的 Email Worker。

这里最容易混的是两件事:

  1. Catch-all 针对的是主收信域名

也就是说,通常是 example.com,不是 mail.example.com

  1. 如果 Catch-all 没指向 Worker

那邮件就可能直接被丢掉,而且不一定给你多清楚的提示。很多时候你只能在 Activity log 里看到一个 Dropped

第 11 步:验证收信

配置做完之后,最好马上测一遍。

先打开你的站点,创建一个临时邮箱。假设系统给你分配了:

bluepanda23@example.com

然后拿另一个外部邮箱,往这个地址发一封测试邮件。

重点看三件事:前端能不能看到收件箱内容,管理后台里的域名状态是不是正常,以及 Cloudflare Email Routing 的 Activity log 里这封邮件有没有被正确投递。

如果前端没看到邮件,就按这个顺序排:

  • domains 表里有没有 example.com
  • 这个域名状态是不是 enabled
  • Email Routing 的 Catch-all 是否真的指向了 flashinbox-email
  • 三个 Worker 是否都已经部署
  • 三份 Wrangler 配置是不是都绑到了同一个 D1

如果还不行,可以直接看 Email Worker 日志:

wrangler tail flashinbox-email

如果日志里出现 Domain not foundDomain is disabled 这种提示,就别猜了,直接回去查配置。

几个常见坑

1. 主站能开,不等于邮箱已经能收信

这是最容易让人误判的一点。网页能开,只代表主应用部署好了;真正决定能不能收信的,是 Email Routing、Email Worker 和 domains 表有没有一起对上。

2. 三个 Worker 必须共用同一个 D1

别把它们当成三个独立项目。对 FlashInbox 来说,这就是同一套系统的三个入口。

3. DEFAULT_DOMAIN 不是网页访问域名

它代表的是默认收信域名,不是网页访问域名。这个值写错,前端生成出来的邮箱地址就会跟你的预期不一致。

4. 附件不会保存

这不是 bug,而是项目本身就没打算存附件。收验证码、通知、注册链接都没问题,但别拿它当文件邮箱用。

5. Cloudflare Email Routing 有平台限制

大于 25 MiB 的邮件别指望它稳稳接住。这个上限不是 FlashInbox 自己定的,是 Cloudflare Email Routing 本身的限制。

适合哪些场景

这篇方案更适合下面这些场景:

  • 你想用自己的域名收临时邮件
  • 你不想继续依赖公共临时邮箱
  • 你不想自己维护完整邮件服务器
  • 你希望网页、收信、存储和后台都留在 Cloudflare 体系里

如果你的需求差不多就是这些,那 FlashInbox 值得花点时间装一下。

它当然不是正式办公邮箱的替代品,也不适合做复杂邮件协作。但如果你只是想把“临时邮箱”这件事握回自己手里,用自己的域名来收,用自己的后台来管,那这套东西已经够用了。

JS算法入门:图解“冒泡排序”,彻底搞懂双重循环的奥义

1. 前言:为什么我们需要排序?

哈喽大家好,我是心连欣。在上一篇文章中,我们做了一个“成绩管理系统”,能算出最高分和平均分。但是,如果老师想看一个 “从高分到低分” 的排名表,我们的程序就傻眼了,因为数组里的数据是乱序的。

虽然 JavaScript 提供了现成的 array.sort() 方法,但作为一名有追求的程序员,我们必须知道:排序的底层到底是怎么实现的?

今天,我们就来攻克排序算法的“Hello World”——冒泡排序

2. 核心思想:像气泡一样“上浮”

冒泡排序的名字非常形象。想象一下水底的气泡,越轻的气泡(数值越小的元素)会慢慢浮到水面,而越重的气泡(数值越大的元素)会沉在水底。

但在我们的代码逻辑里(升序排列),我们通常反其道而行之:让最大的元素,像“重石头”一样,通过不断的交换,一步步“沉”到数组的末尾。

算法口诀:

  1. 两两比较:  从第一个开始,挨个比较相邻的两个数。
  2. 逆序交换:  如果前一个比后一个大,就交换它们。
  3. 一轮结束:  每一轮跑完,当前这一堆数里最大的那个,一定会被移到最右边。

3. 图解演示

假设我们要对 [5, 2, 9, 1] 进行升序排序:

第一轮(找出最大的9):

  • 比较 5 和 2:5 > 2,交换 → [2, 5, 9, 1]
  • 比较 5 和 9:5 < 9,不换 → [2, 5, 9, 1]
  • 比较 9 和 1:9 > 1,交换 → [2, 5, 1, 9]
  • 结果:  9 已经就位(沉底了)。

第二轮(找出剩下的最大的5):

  • 比较 2 和 5:不换 → [2, 5, 1, 9]
  • 比较 5 和 1:5 > 1,交换 → [2, 1, 5, 9]
  • 结果:  5 也就位了。(注意:这一轮不需要再比 9 了,因为它已经是最大的)。

4. 代码实现:双重循环的奥义

冒泡排序的核心在于两层循环

image.png 结果如下: image.png

5. 深度解析:为什么是 length - 1 - i

这是面试中最常考的细节,也是新手最容易晕的地方。

  • length - 1:因为我们是拿 arr[j] 和 arr[j+1] 比较。如果 j 走到了最后一个元素,j+1 就会越界(undefined)。所以内层循环必须比数组长度少 1。

  • - i:这是性能优化的关键。

    • 当 i=0(第一轮)结束时,数组最后 1 个元素已经是最大值了。
    • 当 i=1(第二轮)结束时,数组最后 2 个元素已经有序了。
    • 所以,内层循环不需要每次都从头跑到尾,每过一轮,就可以少跑一步。

6. 进阶优化:如何让它更聪明?

上面的代码有个小缺陷:如果数组本来就是有序的(比如 [1, 2, 3, 4]),它还是会傻傻地跑完所有循环。

我们可以加一个 “哨兵” (标志位)来优化它。

image.png

7. 总结

冒泡排序虽然在实际工程中(处理海量数据时)效率不如快速排序,但它教会了我们两个极其重要的编程思想:

  • 双重循环:  外层控制轮次,内层处理细节。这是处理二维数据、矩阵、复杂遍历的基础。
  • 交换算法:  利用临时变量 temp 交换数据,是链表操作、数组重排的基础。

课后思考:
目前的排序是从小到大(升序)。如果我想让成绩从高到低(降序)排列,只需要修改代码中的哪一行?(提示:就在 if 判断里)。


写算法就像搭积木,理解了冒泡排序,你就拿到了通往高级算法世界的第一把钥匙!加油!

React 16 + TDesign Table 卡死问题深度复盘

hello 大家,这个是最近做项目的时候遇到的一个问题,ai也是琢磨了很久,我们项目使用微前端(Wujie)去做的,于是我就用基于模版的配置去实现了这个项目(React 16),没想到出现了不知原因的页面卡死的问题,出现问题之后呢,ai也不知道为什么,最后耗了很多token靠打日志排查出来了。

项目的技术栈也是比较复杂,采用的是 Wujie+TDesign+React16

在一个实际项目中,我们遇到了一个典型但又容易被忽略的性能问题:

接口返回后页面直接“卡死”3~8秒,用户无法点击、滚动,体验极差。

这篇文章从根因分析 → 时间线 → 本质解释 → 解决方案 → 延伸思考,完整拆解这个问题。


一、问题现象

接口返回后,页面出现明显卡顿甚至“假死”:

  • 点击无响应 ❌
  • 滚动无响应 ❌
  • 动画停滞 ❌
  • 浏览器甚至提示“页面无响应” ⚠️

日志显示,每次接口返回都会触发多次渲染:

setRows(nextRows);        
setTotal(totalCount);     
setSelectedRowKeys(...);  
setLoading(false);        

看起来很正常,但实际上这是问题的开始。


二、根本原因拆解

1. React 16 的同步渲染机制

在 React 16 中:

  • setState 是同步触发渲染
  • 渲染过程是不可中断的
  • 每一次 setState = 一次完整渲染

也就是说:

setRows      → 渲染 #1
setTotal     → 渲染 #2
setSelected  → 渲染 #3
setLoading   → 渲染 #4

👉 4 次 setState = 4 次完整渲染


2. TDesign Table 渲染成本极高

每一次渲染,Table 都会:

  • 重新执行所有 column 的 render
  • 重新创建每个 cell
  • 初始化 Popup 组件
  • 绑定事件处理器
  • 触发浏览器重排 + 重绘

典型调用:

[columns.cell] 渲染 accelerationDomain
[renderDomainWithCopy] 渲染: xxx.com
[columns.cell] 渲染 cname
[columns.cell] 渲染 httpsStatus
[columns.cell] 渲染 actions

👉 所有 cell 都会被重新渲染


3. 时间线(关键证据)

15:10:42 - 渲染 #5 (setTotal)
15:10:43 - 渲染 #6 (setSelectedRowKeys)  → +863ms
15:10:44 - 渲染 #7 (setLoading)          → +933ms

👉 每次渲染耗时 ≈ 800~900ms


4. 为什么会“卡死”而不是“慢”?

这是很多人误解的点。

✅ 本质:JavaScript 是单线程

当 React 开始渲染时:

【主线程被占用】
↓
虚拟 DOM 计算
↓
真实 DOM 更新
↓
Table 渲染
↓
浏览器重排重绘
↓
【800ms 后释放】

这 800ms 内:

  • ❌ 不能点击
  • ❌ 不能滚动
  • ❌ 不能响应任何操作

5. 更致命的是:连续 4 次阻塞

时间轴:

0ms    setRows → 卡 800ms
800ms  setTotal → 卡 800ms
1600ms setSelected → 卡 800ms
2400ms setLoading → 卡 800ms
3200ms 完成

👉 用户感知:页面冻结 3.2 秒


6. 微前端(wujie)的额外放大效应

你的项目运行在微前端环境中,还叠加了:

  • Shadow DOM 隔离
  • 事件代理
  • 主子应用通信

👉 渲染成本进一步放大


三、问题本质总结(一句话)

React 16 同步渲染 + 多次 setState + 高成本 Table 渲染 = 主线程被连续阻塞 → 页面卡死


四、解决方案:批量更新(核心)

使用 React 提供的 API:

import { unstable_batchedUpdates } from 'react-dom';

unstable_batchedUpdates(() => {
  setRows(nextRows);
  setTotal(totalCount);
  setSelectedRowKeys(...);
  setLoading(false);
});

原理

👉 告诉 React:

  • 把多个 setState 合并
  • 只触发一次 render

性能对比

❌ 优化前(4 次渲染)

渲染 × 4 次
每次 800ms
总耗时 ≈ 3200ms

✅ 优化后(1 次渲染)

渲染 × 1 次
耗时 ≈ 800ms

👉 性能提升 4 倍


五、为什么其他页面也会中招?

这是一个非常常见的反模式

const data = await fetchData();

setState1(data.a);
setState2(data.b);
setState3(data.c);
setLoading(false);

在 React 16 中:

👉 异步回调里的 setState 不会自动合并!


六、React 18 的改进(重要)

如果升级到 React 18:

👉 自动批处理(Automatic Batching)默认开启

await fetch();

setA();
setB();
setC();

👉 自动合并成一次 render


对比

版本 行为
React 16 ❌ 异步 setState 不合并
React 18 ✅ 自动批处理

七、为什么 React 16 无法避免卡顿?

React 16 的核心限制:

while (有任务) {
  // ❌ 不可中断
  执行渲染();
}

👉 一旦开始渲染:

  • 必须执行完
  • 不能暂停
  • 不能让出主线程

React 18 的优势

  • 可中断渲染
  • 时间切片(Time Slicing)
  • 并发调度

👉 不会长时间阻塞主线程


八、最终总结

为什么会卡死?

  • React 16 同步渲染
  • 多次 setState
  • Table 渲染昂贵
  • JS 单线程阻塞
  • 微前端额外开销

怎么解决?

👉 核心一句话:

unstable_batchedUpdates 把多次 setState 合并成一次渲染


优化效果

指标 优化前 优化后
渲染次数 4 次 1 次
卡顿时间 3~8 秒 0.8~2 秒
用户体验 ❌ 卡死 ✅ 可接受

十、一句话经验(最重要)

在 React 16 中,异步里的多个 setState,一定要警惕性能问题。

React19项目中 FormEdit / FormEditModal 组件封装设计说明

适用于 React + TypeScript + Arco Design Web 的配置化表单与弹窗表单方案

文档目标: 说明当前 FormEdit / FormEditModal / types / utils 方案的职责分层、设计原因、优点与风险、关键语法,以及实际使用方式。

1. 方案概览

当前方案是一套“配置驱动 + ref 驱动 + 弹窗承载 + 字段类型扩展”的通用表单基础设施。它的核心不是写死某个业务表单,而是通过一组统一的配置和类型,把新增、编辑、查看、弹窗提交这些高重复场景抽象出来。

• FormEdit:负责表单渲染、字段分发、初始值处理、对外暴露表单 API。

• FormEditModal:负责弹窗显示、确定/取消流程、是否关闭、是否重置。

• types:负责定义字段类型、配置结构、ref 方法、组件 props,是整个方案的契约层。

• utils:负责类名合并、初始值归一化、根据表单配置构建整表默认值。

2. 每个代码文件的详解

2.1 types.ts

types.ts 是整套方案最底层的协议文件。它的作用不是直接渲染 UI,而是约束“这套表单系统支持什么配置、暴露什么能力、字段有哪些类型”。

• 定义 EditState:统一新增、编辑、查看三种状态,避免业务层自行约定字符串。

• 定义 FormFieldType:从基础的 input / textarea / select 扩展到 switch / upload / cascader / datePicker / custom。

• 定义 FormItemConfig:让每个字段既包含业务属性,也包含布局和组件渲染属性。

• 定义 FormEditRef:把 validate、resetFields、setValues、getValues、setFieldValue 等能力统一收敛。

• 定义 FormEditProps:支持表单整体布局方向 direction、列数 columns、回填控制 syncKey、卡片/纯内容 layout 等。

import type { ReactNode, RefObject } from 'react';

export type EditState = 'add' | 'edit' | 'view';

export type FieldValue = string | number | boolean;

export type FormFieldType =
  | 'input'
  | 'textarea'
  | 'select'
  | 'radioGroup'
  | 'checkboxGroup'
  | 'switch'
  | 'upload'
  | 'cascader'
  | 'datePicker'
  | 'divider'
  | 'custom';

export interface OptionItem {
  key?: string | number;
  label: string;
  value: FieldValue;
  disabled?: boolean;
  children?: OptionItem[];
}

export interface RuleItem {
  required?: boolean;
  message?: string;
  trigger?: 'change' | 'blur' | Array<'change' | 'blur'>;
  validator?: (value: unknown, values: Record<string, unknown>) => void | string | Promise<void | string>;
}

export interface RenderFieldContext {
  value: unknown;
  formData: Record<string, unknown>;
  editState: EditState;
  disabled: boolean;
  setFieldValue: (key: string, value: unknown) => void;
  getFieldValue: (key: string) => unknown;
  setValues: (values: Record<string, unknown>) => void;
}

export interface FormItemConfig {
  key: string;
  title?: string;
  type: FormFieldType;

  hidden?: boolean;
  required?: boolean;
  disabled?: boolean;
  placeholder?: string;

  /**
   * columns=2 时可设置 2 跨整行
   */
  colSpan?: 1 | 2;

  /**
   * 外层栅格项 className
   */
  className?: string;

  /**
   * Form.Item 自身 className
   */
  formItemClassName?: string;

  rules?: RuleItem[];

  maxLength?: number;
  showWordLimit?: boolean;
  rows?: number;

  /**
   * select / radioGroup / checkboxGroup / cascader 共用
   */
  options?: OptionItem[];

  /**
   * select 多选
   */
  mode?: 'single' | 'multiple';

  /**
   * 选项展示 label-value
   */
  showKV?: boolean;

  /**
   * radio / checkbox 排列方向
   */
  direction?: 'horizontal' | 'vertical';

  /**
   * checkbox 最大可选数
   */
  max?: number;

  extra?: ReactNode;
  initialValue?: unknown;

  /**
   * 透传给具体字段组件的属性
   */
  fieldProps?: Record<string, unknown>;

  /**
   * switch 专用
   */
  checkedValue?: string | number | boolean;
  uncheckedValue?: string | number | boolean;

  /**
   * upload 专用
   */
  uploadAction?: string;
  limit?: number;

  /**
   * datePicker 专用
   */
  datePickerType?: 'date' | 'week' | 'month' | 'quarter' | 'year' | 'range';
  format?: string;

  /**
   * custom 专用
   */
  render?: (ctx: RenderFieldContext) => ReactNode;

  onChange?: (value: unknown, formData: Record<string, unknown>) => void;
}

export interface FormEditProps {
  modelValue?: Record<string, unknown>;
  formArr: FormItemConfig[];
  editState?: EditState;
  className?: string;

  title?: ReactNode;
  description?: ReactNode;
  width?: number | string;
  layout?: 'card' | 'plain';

  /**
   * 表单布局方向
   * horizontal:水平布局
   * vertical:垂直布局
   */
  direction?: 'horizontal' | 'vertical';

  /**
   * 默认 1 列
   * 字段多时可用 2 列
   */
  columns?: 1 | 2;

  /**
   * 用于控制何时重新回填表单
   * 推荐编辑态传入主键,例如 id
   */
  syncKey?: string | number | null | undefined;

  onValuesChange?: (changedValues: Record<string, unknown>, values: Record<string, unknown>) => void;
}

export interface FormEditRef {
  validate: () => Promise<boolean>;
  resetFields: () => void;
  setValues: (values: Record<string, unknown>) => void;
  getValues: () => Record<string, unknown>;
  setFieldValue: (key: string, value: unknown) => void;
  getFieldValue: (key: string) => unknown;
  clearErrors: () => void;
}

export interface FormEditModalProps {
  open?: boolean;
  title?: ReactNode;
  width?: string | number;
  className?: string;
  contentClassName?: string;

  showFooter?: boolean;
  useCustomFooter?: boolean;
  footer?: ReactNode;

  okText?: string;
  cancelText?: string;
  confirmLoading?: boolean;

  maskClosable?: boolean;
  escToClose?: boolean;
  destroyOnClose?: boolean;

  closeOnOk?: boolean;
  closeOnCancel?: boolean;

  formRef?: RefObject<FormEditRef | null>;
  validateBeforeOk?: boolean;
  resetAfterClose?: boolean;

  children?: ReactNode;

  onOpenChange?: (open: boolean) => void;
  onCancel?: () => void;
  onOk?: (values?: Record<string, unknown>) => void | Promise<void>;
}

export interface FormEditModalRef {
  open: () => void;
  close: () => void;
  toggle: () => void;
}

这一层的好处是:调用方和组件实现方共用同一份类型约束,后续扩展字段类型时,编译器会帮助你定位所有待补位置。

2.2 utils.ts

utils.ts 的核心是让表单初始值更稳定。通用表单最容易出问题的地方,不是“字段渲染不出来”,而是不同控件在新增态、编辑态、回填态下的默认值类型不一致。

• cx:合并 className,支持字符串、数组、对象条件写法。

• normalizeInitialValue:根据字段类型给出合理默认值,例如多选 select、upload、cascader、range 类型日期使用数组,switch 使用 uncheckedValue 或 false。

• buildInitialFormData:把单字段归一化提升为整张表单数据的构建函数。

• getVisibleFormItems:过滤 hidden 字段,避免隐藏字段仍参与渲染和初始值流程。

import type { FormItemConfig } from './types';

type ClassDictionary = Record<string, boolean | null | undefined>;
type ClassArray = ClassValue[];
type ClassValue = string | null | undefined | false | ClassDictionary | ClassArray;

export function cx(...args: ClassValue[]): string {
  const classes: string[] = [];

  const append = (value: ClassValue) => {
    if (!value) return;

    if (typeof value === 'string') {
      classes.push(value);
      return;
    }

    if (Array.isArray(value)) {
      value.forEach(append);
      return;
    }

    if (typeof value === 'object') {
      Object.keys(value).forEach((key) => {
        if (value[key]) {
          classes.push(key);
        }
      });
    }
  };

  args.forEach(append);
  return classes.join(' ');
}

export function normalizeInitialValue(item: FormItemConfig, value: unknown): unknown {
  if (value !== undefined) return value;
  if (item.initialValue !== undefined) return item.initialValue;

  if (item.type === 'checkboxGroup') return [];
  if (item.type === 'select' && item.mode === 'multiple') return [];
  if (item.type === 'upload') return [];
  if (item.type === 'cascader') return [];
  if (item.type === 'datePicker' && item.datePickerType === 'range') return [];
  if (item.type === 'switch') {
    return item.uncheckedValue ?? false;
  }

  return '';
}

export function buildInitialFormData(formArr: FormItemConfig[], modelValue: Record<string, unknown> = {}) {
  const nextData: Record<string, unknown> = {};

  formArr.forEach((item) => {
    if (item.type === 'divider') return;
    nextData[item.key] = normalizeInitialValue(item, modelValue[item.key]);
  });

  return nextData;
}

export function getVisibleFormItems(formArr: FormItemConfig[]) {
  return formArr.filter((item) => !item.hidden);
}

这层看似简单,但它决定了表单是否能稳定受控,是否会出现默认值错乱或组件警告。

2.3 formEdit.tsx

formEdit.tsx 是整套方案的核心。它接收字段配置 formArr,把配置转换成真正的 Arco Form 结构,并通过 ref 向父组件暴露命令式方法。

• 通过 Form.useForm 获取 Arco 表单实例。

• 通过 useMemo 构建 initialValues,避免每次 render 都重新生成。

• 通过 syncKey + lastPatchedKeyRef 控制何时重新回填表单,避免误覆盖用户输入。

• 通过 buildArcoRules 把业务规则适配成 Arco 规则。

• 通过 renderField 按 type 分发不同字段类型的渲染逻辑。

• 通过 direction 控制 Form 的整体布局方向,通过 columns + colSpan 控制字段网格布局。

import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef } from 'react';
import {
  Card,
  Cascader,
  Checkbox,
  DatePicker,
  Divider,
  Form,
  Input,
  Radio,
  Select,
  Switch,
  Typography,
  Upload,
} from '@arco-design/web-react';
import type { FormEditProps, FormEditRef, FormItemConfig } from './types';
import { buildInitialFormData, cx, getVisibleFormItems } from './utils';

const { Title, Paragraph, Text } = Typography;

type FormItemProps = React.ComponentProps<typeof Form.Item>;
type ArcoRules = NonNullable<FormItemProps['rules']>;
type ArcoRule = ArcoRules extends Array<infer T> ? T : never;

function buildArcoRules(item: FormItemConfig, getValues: () => Record<string, unknown>): ArcoRule[] {
  const rules: ArcoRule[] = [];

  if (item.required) {
    rules.push({
      required: true,
      message: `${item.title || item.key}不能为空`,
    } as ArcoRule);
  }

  if (!item.rules?.length) {
    return rules;
  }

  item.rules.forEach((rule) => {
    if (rule.required) {
      rules.push({
        required: true,
        message: rule.message || `${item.title || item.key}不能为空`,
        trigger: rule.trigger,
      } as ArcoRule);
    }

    if (rule.validator) {
      rules.push({
        trigger: rule.trigger,
        validator: (value, callback) => {
          Promise.resolve(rule.validator?.(value, getValues()))
            .then((result) => {
              if (typeof result === 'string' && result) {
                callback(result);
                return;
              }

              callback();
            })
            .catch((error: unknown) => {
              if (error instanceof Error) {
                callback(error.message);
                return;
              }

              if (typeof error === 'string') {
                callback(error);
                return;
              }

              callback(rule.message || '校验失败');
            });
        },
      } as ArcoRule);
    }
  });

  return rules;
}

function getFieldColClass(columns: 1 | 2, item: FormItemConfig) {
  if (columns === 1) return 'col-span-1';
  return item.colSpan === 2 ? 'col-span-2' : 'col-span-1';
}

function renderLabel(item: FormItemConfig) {
  if (!item.title) return null;

  return (
    <span className="inline-flex items-center gap-1 whitespace-nowrap">
      {item.required ? <span className="leading-none text-red-500">*</span> : null}
      <span>{item.title}</span>
    </span>
  );
}

function FormEditInner(
  {
    modelValue = {},
    formArr,
    editState = 'add',
    className,
    title,
    description,
    width = 760,
    layout = 'card',
    direction = 'vertical',
    columns = 1,
    syncKey,
    onValuesChange,
  }: FormEditProps,
  ref: React.Ref<FormEditRef>,
) {
  const [form] = Form.useForm();
  const isView = editState === 'view';

  const visibleFormItems = useMemo(() => {
    return getVisibleFormItems(formArr);
  }, [formArr]);

  const initialValues = useMemo(() => {
    return buildInitialFormData(visibleFormItems, modelValue);
  }, [visibleFormItems, modelValue]);

  const currentPatchKey = syncKey ?? modelValue;
  const lastPatchedKeyRef = useRef(currentPatchKey);
  const mountedRef = useRef(false);

  useEffect(() => {
    if (!mountedRef.current) {
      mountedRef.current = true;
      form.setFieldsValue(initialValues);
      lastPatchedKeyRef.current = currentPatchKey;
      return;
    }

    if (lastPatchedKeyRef.current === currentPatchKey) return;

    lastPatchedKeyRef.current = currentPatchKey;
    form.resetFields();
    form.setFieldsValue(buildInitialFormData(visibleFormItems, modelValue));
  }, [currentPatchKey, form, initialValues, modelValue, visibleFormItems]);

  useImperativeHandle(
    ref,
    () => ({
      validate: async () => {
        try {
          await form.validate();
          return true;
        } catch {
          return false;
        }
      },
      resetFields: () => {
        form.resetFields();
        form.setFieldsValue(buildInitialFormData(visibleFormItems, modelValue));
      },
      setValues: (values) => {
        form.setFieldsValue(values);
      },
      getValues: () => {
        return form.getFieldsValue() as Record<string, unknown>;
      },
      setFieldValue: (key, value) => {
        form.setFieldValue(key, value);
      },
      getFieldValue: (key) => {
        return form.getFieldValue(key);
      },
      clearErrors: () => {
        const currentValues = form.getFieldsValue() as Record<string, unknown>;
        form.clearFields();
        form.setFieldsValue(currentValues);
      },
    }),
    [form, modelValue, visibleFormItems],
  );

  const getAllValues = () => {
    return form.getFieldsValue() as Record<string, unknown>;
  };

  const renderField = (item: FormItemConfig) => {
    const commonDisabled = isView || item.disabled;
    const commonValue = form.getFieldValue(item.key);

    const fieldContext = {
      value: commonValue,
      formData: getAllValues(),
      editState,
      disabled: !!commonDisabled,
      setFieldValue: (key: string, value: unknown) => form.setFieldValue(key, value),
      getFieldValue: (key: string) => form.getFieldValue(key),
      setValues: (values: Record<string, unknown>) => form.setFieldsValue(values),
    };

    switch (item.type) {
      case 'input':
        return (
          <Input
            allowClear
            disabled={commonDisabled}
            placeholder={item.placeholder || '请输入'}
            maxLength={item.maxLength}
            showWordLimit={item.showWordLimit}
            {...item.fieldProps}
          />
        );

      case 'textarea':
        return (
          <Input.TextArea
            allowClear
            disabled={commonDisabled}
            placeholder={item.placeholder || '请输入'}
            maxLength={item.maxLength}
            showWordLimit={item.showWordLimit}
            autoSize={{
              minRows: item.rows || 4,
              maxRows: Math.max((item.rows || 4) + 3, 6),
            }}
            {...item.fieldProps}
          />
        );

      case 'select':
        return (
          <Select
            allowClear
            disabled={commonDisabled}
            placeholder={item.placeholder || '请选择'}
            mode={item.mode === 'multiple' ? 'multiple' : undefined}
            maxTagCount={item.mode === 'multiple' ? 3 : undefined}
            onChange={(value) => {
              item.onChange?.(value, getAllValues());
            }}
            {...item.fieldProps}
          >
            {(item.options || []).map((opt) => (
              <Select.Option key={opt.key ?? String(opt.value)} value={opt.value} disabled={opt.disabled}>
                {item.showKV ? `${opt.label}-${opt.value}` : opt.label}
              </Select.Option>
            ))}
          </Select>
        );

      case 'radioGroup':
        return (
          <Radio.Group
            direction={item.direction === 'vertical' ? 'vertical' : 'horizontal'}
            disabled={commonDisabled}
            onChange={(value) => {
              item.onChange?.(value, getAllValues());
            }}
            {...item.fieldProps}
          >
            {(item.options || []).map((opt) => (
              <Radio key={opt.key ?? String(opt.value)} value={opt.value} disabled={commonDisabled || opt.disabled}>
                {opt.label}
              </Radio>
            ))}
          </Radio.Group>
        );

      case 'checkboxGroup':
        return (
          <Checkbox.Group
            direction={item.direction === 'vertical' ? 'vertical' : 'horizontal'}
            disabled={commonDisabled}
            max={item.max}
            onChange={(value) => {
              item.onChange?.(value, getAllValues());
            }}
            {...item.fieldProps}
          >
            {(item.options || []).map((opt) => (
              <Checkbox key={opt.key ?? String(opt.value)} value={opt.value} disabled={commonDisabled || opt.disabled}>
                {opt.label}
              </Checkbox>
            ))}
          </Checkbox.Group>
        );

      case 'switch':
        return (
          <Switch
            disabled={commonDisabled}
            checked={commonValue === (item.checkedValue ?? true)}
            checkedValue={item.checkedValue ?? true}
            uncheckedValue={item.uncheckedValue ?? false}
            onChange={(value) => {
              form.setFieldValue(item.key, value);
              item.onChange?.(value, {
                ...getAllValues(),
                [item.key]: value,
              });
            }}
            {...item.fieldProps}
          />
        );

      case 'upload':
        return (
          <Upload
            disabled={commonDisabled}
            action={item.uploadAction}
            limit={item.limit}
            fileList={Array.isArray(commonValue) ? (commonValue as never[]) : []}
            onChange={(fileList) => {
              form.setFieldValue(item.key, fileList);
              item.onChange?.(fileList, {
                ...getAllValues(),
                [item.key]: fileList,
              });
            }}
            {...item.fieldProps}
          />
        );

      case 'cascader':
        return (
          <Cascader
            disabled={commonDisabled}
            placeholder={item.placeholder || '请选择'}
            options={item.options || []}
            onChange={(value) => {
              item.onChange?.(value, getAllValues());
            }}
            {...item.fieldProps}
          />
        );

      case 'datePicker':
        if (item.datePickerType === 'range') {
          return (
            <DatePicker.RangePicker
              disabled={commonDisabled}
              placeholder={['开始日期', '结束日期']}
              format={item.format}
              onChange={(value) => {
                item.onChange?.(value, getAllValues());
              }}
              {...item.fieldProps}
            />
          );
        }

        return (
          <DatePicker
            disabled={commonDisabled}
            placeholder={item.placeholder || '请选择日期'}
            format={item.format}
            picker={item.datePickerType && item.datePickerType !== 'date' ? item.datePickerType : undefined}
            onChange={(value) => {
              item.onChange?.(value, getAllValues());
            }}
            {...item.fieldProps}
          />
        );

      case 'divider':
        return <Divider style={{ margin: '4px 0 12px' }} />;

      case 'custom':
        if (item.render) {
          return item.render(fieldContext);
        }
        return <Text type="secondary">custom 类型缺少 render 配置</Text>;

      default:
        return <Text type="secondary">暂不支持的表单类型:{item.type}</Text>;
    }
  };

  const formContent = (
    <div className={cx('w-full', className)}>
      {title || description ? (
        <div style={{ marginBottom: 24 }}>
          {title ? <Title heading={6}>{title}</Title> : null}
          {description ? (
            <Paragraph type="secondary" style={{ marginTop: 8, marginBottom: 0 }}>
              {description}
            </Paragraph>
          ) : null}
        </div>
      ) : null}

      <Form
        form={form}
        layout={direction}
        initialValues={initialValues}
        autoComplete="off"
        onValuesChange={(changedValues, values) => {
          onValuesChange?.(changedValues as Record<string, unknown>, values as Record<string, unknown>);
        }}
      >
        <div className={cx('grid gap-x-5', columns === 2 ? 'grid-cols-2' : 'grid-cols-1')}>
          {visibleFormItems.map((item, index) => {
            if (item.type === 'divider') {
              return (
                <div key={`${item.key}-${index}`} className="col-span-full">
                  {renderField(item)}
                </div>
              );
            }

            return (
              <div key={`${item.key}-${index}`} className={cx(getFieldColClass(columns, item), item.className)}>
                <Form.Item
                  className={item.formItemClassName}
                  label={renderLabel(item)}
                  field={item.key}
                  rules={buildArcoRules(item, () => form.getFieldsValue() as Record<string, unknown>)}
                  requiredSymbol={false}
                  extra={item.extra}
                  triggerPropName={item.type === 'switch' ? 'checked' : 'value'}
                >
                  {renderField(item)}
                </Form.Item>
              </div>
            );
          })}
        </div>
      </Form>
    </div>
  );

  const containerStyle = {
    width: typeof width === 'number' ? `${width}px` : width,
    maxWidth: '100%',
    margin: '0 auto',
  };

  if (layout === 'plain') {
    return <div style={containerStyle}>{formContent}</div>;
  }

  return (
    <Card bordered style={{ borderRadius: 16 }}>
      <div style={containerStyle}>{formContent}</div>
    </Card>
  );
}

FormEditInner.displayName = 'FormEdit';

const FormEdit = forwardRef(FormEditInner);
FormEdit.displayName = 'FormEdit';

export default FormEdit;

当前版本里,renderField 已经覆盖 input、textarea、select、radioGroup、checkboxGroup、switch、upload、cascader、datePicker、divider、custom 等类型,说明这套组件已经从“基础表单”提升为“可承载复杂业务表单”的组件。

2.4 formEditModal.tsx

formEditModal.tsx 负责承载表单弹窗。它把“打开 / 关闭、点击确定、点击取消、关闭后是否重置、提交前是否校验”等通用行为统一起来,让业务页面只关心 open 状态和 onOk 保存逻辑。

• 支持受控 / 非受控兼容的显示方式。

• 支持通过 formRef 在点击确定前自动 validate。

• 支持 closeOnOk / closeOnCancel 控制点击后是否关闭。

• 支持 resetAfterClose 控制关闭后是否恢复初始值。

• 支持自定义 footer,也支持内置确定 / 取消按钮。

这一层的价值在于:把弹窗提交流程标准化,避免每个页面重复写 validate -> getValues -> onOk -> close 这类模板代码。

import { forwardRef, useImperativeHandle, useMemo, useState } from 'react';
import { Button, Modal, Space } from '@arco-design/web-react';
import type { FormEditModalProps, FormEditModalRef } from './types';

function FormEditModalInner(
  {
    open,
    title,
    width = 720,
    className,
    contentClassName,

    showFooter = true,
    useCustomFooter = false,
    footer,

    okText = '确定',
    cancelText = '取消',
    confirmLoading = false,

    maskClosable = false,
    escToClose = true,
    destroyOnClose = true,

    closeOnOk = true,
    closeOnCancel = true,

    formRef,
    validateBeforeOk = true,
    resetAfterClose = false,

    children,

    onOpenChange,
    onCancel,
    onOk,
  }: FormEditModalProps,
  ref: React.Ref<FormEditModalRef>,
) {
  const [innerOpen, setInnerOpen] = useState(false);

  const isControlled = open !== undefined;
  const visible = isControlled ? open : innerOpen;

  const setVisible = (next: boolean) => {
    if (!isControlled) {
      setInnerOpen(next);
    }
    onOpenChange?.(next);
  };

  const handleClose = () => {
    setVisible(false);
  };

  const handleAfterClose = () => {
    if (resetAfterClose) {
      formRef?.current?.resetFields();
    }
  };

  const handleCancel = () => {
    onCancel?.();

    if (closeOnCancel) {
      handleClose();
    }
  };

  const handleOk = async () => {
    if (validateBeforeOk && formRef?.current) {
      const passed = await formRef.current.validate();
      if (!passed) return;
    }

    const values = formRef?.current?.getValues();

    try {
      await onOk?.(values);

      if (closeOnOk) {
        handleClose();
      }
    } catch (error) {
      console.error('FormEditModal onOk 执行失败:', error);
    }
  };

  useImperativeHandle(
    ref,
    () => ({
      open: () => setVisible(true),
      close: () => setVisible(false),
      toggle: () => setVisible(!visible),
    }),
    [visible],
  );

  const defaultFooter = useMemo(() => {
    if (!showFooter) return null;

    if (useCustomFooter) {
      return footer;
    }

    return (
      <Space>
        <Button onClick={handleCancel}>{cancelText}</Button>
        <Button type="primary" loading={confirmLoading} onClick={handleOk}>
          {okText}
        </Button>
      </Space>
    );
  }, [cancelText, confirmLoading, footer, okText, showFooter, useCustomFooter, handleCancel, handleOk]);

  return (
    <Modal
      visible={visible}
      title={title}
      style={{ width }}
      className={className}
      unmountOnExit={destroyOnClose}
      maskClosable={maskClosable}
      escToExit={escToClose}
      onCancel={handleCancel}
      afterClose={handleAfterClose}
      footer={defaultFooter}
    >
      <div className={contentClassName}>{children}</div>
    </Modal>
  );
}

FormEditModalInner.displayName = 'FormEditModal';

const FormEditModal = forwardRef(FormEditModalInner);
FormEditModal.displayName = 'FormEditModal';

export default FormEditModal;

2.5 示例页面

import { useMemo, useRef, useState } from 'react';
import { Button, Input, Message, Space, Tag } from '@arco-design/web-react';
import { FormEdit, FormEditModal } from '@/components/FormEdit';
import type { FormEditRef, FormItemConfig } from '@/components/FormEdit';

export default function DemoModalForm() {
  const singleColFormRef = useRef<FormEditRef>(null);
  const doubleColFormRef = useRef<FormEditRef>(null);
  const customFormRef = useRef<FormEditRef>(null);

  const [singleOpen, setSingleOpen] = useState(false);
  const [doubleOpen, setDoubleOpen] = useState(false);
  const [customOpen, setCustomOpen] = useState(false);

  const [singleSubmitLoading, setSingleSubmitLoading] = useState(false);
  const [doubleSubmitLoading, setDoubleSubmitLoading] = useState(false);
  const [customSubmitLoading, setCustomSubmitLoading] = useState(false);

  // 一行一列:字段少,适合小弹窗
  const singleColumnFormArr = useMemo<FormItemConfig[]>(() => {
    return [
      {
        key: 'name',
        title: '名称',
        type: 'input',
        required: true,
        placeholder: '请输入名称',
      },
      {
        key: 'city',
        title: '所属城市',
        type: 'select',
        placeholder: '请选择城市',
        required: true,
        options: [
          { label: '北京', value: 'beijing' },
          { label: '上海', value: 'shanghai' },
          { label: '深圳', value: 'shenzhen' },
        ],
      },
      {
        key: 'desc',
        title: '说明',
        type: 'textarea',
        placeholder: '请输入说明',
        rows: 5,
        extra: '适合字段较少、弹窗较窄的场景',
      },
    ];
  }, []);

  // 一行两列:字段多,部分字段支持跨整行
  const doubleColumnFormArr = useMemo<FormItemConfig[]>(() => {
    return [
      {
        key: 'name',
        title: '名称',
        type: 'input',
        required: true,
        placeholder: '请输入名称',
      },
      {
        key: 'city',
        title: '所属城市',
        type: 'select',
        placeholder: '请选择城市',
        options: [
          { label: '北京', value: 'beijing' },
          { label: '上海', value: 'shanghai' },
          { label: '深圳', value: 'shenzhen' },
        ],
      },
      {
        key: 'type',
        title: '类型',
        type: 'radioGroup',
        options: [
          { label: '个人', value: 'personal' },
          { label: '企业', value: 'company' },
        ],
      },
      {
        key: 'tags',
        title: '标签',
        type: 'checkboxGroup',
        direction: 'horizontal',
        options: [
          { label: '热门', value: 'hot' },
          { label: '推荐', value: 'recommend' },
          { label: '最新', value: 'new' },
        ],
      },
      {
        key: 'desc',
        title: '说明',
        type: 'textarea',
        placeholder: '请输入说明',
        rows: 5,
        colSpan: 2,
        extra: '这个字段比较长,所以在两列布局里跨整行显示',
      },
    ];
  }, []);

  // 一行 2 列,使用全部封装组件
  const customFormArr = useMemo<FormItemConfig[]>(() => {
    return [
      {
        key: 'projectName',
        title: '项目名称',
        type: 'input',
        required: true,
        placeholder: '请输入项目名称',
      },
      {
        key: 'status',
        title: '启用状态',
        type: 'switch',
        required: true,
        checkedValue: 1,
        uncheckedValue: 0,
        initialValue: 1,
        extra: '开启为 1,关闭为 0',
      },
      {
        key: 'city',
        title: '所属城市',
        type: 'select',
        placeholder: '请选择城市',
        required: true,
        options: [
          { label: '北京', value: 'beijing' },
          { label: '上海', value: 'shanghai' },
          { label: '深圳', value: 'shenzhen' },
          { label: '杭州', value: 'hangzhou' },
        ],
      },
      {
        key: 'identityType',
        title: '用户类型',
        type: 'radioGroup',
        required: true,
        options: [
          { label: '个人', value: 'personal' },
          { label: '企业', value: 'company' },
        ],
      },
      {
        key: 'tags',
        title: '标签',
        type: 'checkboxGroup',
        direction: 'horizontal',
        options: [
          { label: '热门', value: 'hot' },
          { label: '推荐', value: 'recommend' },
          { label: '最新', value: 'new' },
        ],
      },
      {
        key: 'region',
        title: '地区级联',
        type: 'cascader',
        required: true,
        placeholder: '请选择地区',
        options: [
          {
            label: '浙江省',
            value: 'zhejiang',
            children: [
              {
                label: '杭州市',
                value: 'hangzhou',
                children: [
                  { label: '西湖区', value: 'xihu' },
                  { label: '滨江区', value: 'binjiang' },
                ],
              },
              {
                label: '宁波市',
                value: 'ningbo',
                children: [{ label: '鄞州区', value: 'yinzhou' }],
              },
            ],
          },
          {
            label: '广东省',
            value: 'guangdong',
            children: [
              {
                label: '深圳市',
                value: 'shenzhen',
                children: [
                  { label: '南山区', value: 'nanshan' },
                  { label: '福田区', value: 'futian' },
                ],
              },
            ],
          },
        ],
      },
      {
        key: 'publishDate',
        title: '发布日期',
        type: 'datePicker',
        required: true,
        datePickerType: 'date',
        format: 'YYYY-MM-DD',
        placeholder: '请选择发布日期',
      },
      {
        key: 'timeRange',
        title: '时间范围',
        type: 'datePicker',
        required: true,
        datePickerType: 'range',
        format: 'YYYY-MM-DD',
      },
      {
        key: 'cover',
        title: '上传封面',
        type: 'upload',
        uploadAction: '/api/upload',
        limit: 1,
        fieldProps: {
          listType: 'picture-card',
          imagePreview: true,
        },
        extra: '示例,后续需要讲他封装为单独的组件,图片上传oss',
      },
      {
        key: 'customField',
        title: '自定义组件',
        type: 'custom',
        required: false,
        render: ({ value, setFieldValue, disabled }) => {
          return (
            <div className="flex flex-wrap items-center gap-2">
              <Tag
                checkable
                checked={value === 'A'}
                onClick={() => {
                  if (disabled) return;
                  setFieldValue('customField', 'A');
                }}
              >
                方案 A
              </Tag>
              <Tag
                checkable
                checked={value === 'B'}
                onClick={() => {
                  if (disabled) return;
                  setFieldValue('customField', 'B');
                }}
              >
                方案 B
              </Tag>
              <Tag
                checkable
                checked={value === 'C'}
                onClick={() => {
                  if (disabled) return;
                  setFieldValue('customField', 'C');
                }}
              >
                方案 C
              </Tag>

              <Input
                style={{ width: 220 }}
                placeholder="也可以输入自定义值"
                value={typeof value === 'string' ? value : ''}
                disabled={disabled}
                onChange={(nextValue) => {
                  setFieldValue('customField', nextValue);
                }}
              />
            </div>
          );
        },
        extra: '这里演示 custom 自定义渲染能力',
      },
      {
        key: 'desc',
        title: '说明',
        type: 'textarea',
        placeholder: '请输入详细说明',
        rows: 5,
        colSpan: 2,
        extra: '最下面一行跨整行显示,用于填写较长说明内容',
      },
    ];
  }, []);

  return (
    <div className="p-6">
      <Space size="large">
        <Button type="primary" onClick={() => setSingleOpen(true)}>
          打开一列布局弹窗
        </Button>

        <Button type="primary" status="success" onClick={() => setDoubleOpen(true)}>
          打开两列布局弹窗
        </Button>

        <Button type="primary" status="warning" onClick={() => setCustomOpen(true)}>
          打开有自定义的布局弹窗
        </Button>
      </Space>

      <FormEditModal
        open={singleOpen}
        title="新增信息(一列布局)"
        width={460}
        formRef={singleColFormRef}
        confirmLoading={singleSubmitLoading}
        closeOnOk
        closeOnCancel
        resetAfterClose
        onOpenChange={setSingleOpen}
        onOk={async (values) => {
          setSingleSubmitLoading(true);
          try {
            console.log('一列布局提交数据:', values);
            Message.success('一列布局提交成功');
          } finally {
            setSingleSubmitLoading(false);
          }
        }}
      >
        <FormEdit
          ref={singleColFormRef}
          modelValue={{
            name: '',
            city: '',
            desc: '',
          }}
          formArr={singleColumnFormArr}
          layout="plain"
          direction="horizontal"
          columns={1}
          title="基础信息"
          description="当前示例为一行一列布局,适合字段较少或弹窗较窄的场景"
        />
      </FormEditModal>

      <FormEditModal
        open={doubleOpen}
        title="新增信息(两列布局)"
        width={720}
        formRef={doubleColFormRef}
        confirmLoading={doubleSubmitLoading}
        closeOnOk
        closeOnCancel
        resetAfterClose
        onOpenChange={setDoubleOpen}
        onOk={async (values) => {
          setDoubleSubmitLoading(true);
          try {
            console.log('两列布局提交数据:', values);
            Message.success('两列布局提交成功');
          } finally {
            setDoubleSubmitLoading(false);
          }
        }}
      >
        <FormEdit
          ref={doubleColFormRef}
          modelValue={{
            name: '',
            city: '',
            type: 'personal',
            tags: [],
            desc: '',
          }}
          formArr={doubleColumnFormArr}
          layout="plain"
          direction="horizontal"
          columns={2}
          title="基础信息"
          description="当前示例为一行两列布局,长文本字段可通过 colSpan: 2 跨整行显示"
        />
      </FormEditModal>

      <FormEditModal
        open={customOpen}
        title="新增信息(两列 + 全组件示例)"
        width={920}
        formRef={customFormRef}
        confirmLoading={customSubmitLoading}
        closeOnOk
        closeOnCancel
        resetAfterClose
        onOpenChange={setCustomOpen}
        onOk={async (values) => {
          setCustomSubmitLoading(true);
          try {
            console.log('自定义两列布局提交数据:', values);
            Message.success('自定义布局提交成功');
          } finally {
            setCustomSubmitLoading(false);
          }
        }}
      >
        <FormEdit
          ref={customFormRef}
          modelValue={{
            projectName: '',
            status: 1,
            city: '',
            identityType: 'personal',
            tags: [],
            region: [],
            publishDate: '',
            timeRange: [],
            cover: [],
            customField: 'A',
            desc: '',
          }}
          formArr={customFormArr}
          layout="plain"
          columns={2}
          title="完整表单示例"
          description="当前示例为两列布局,演示了 input、select、radioGroup、checkboxGroup、switch、upload、cascader、datePicker、custom、textarea 等所有封装能力"
        />
      </FormEditModal>
    </div>
  );
}

3. 方案为什么这么设计,解决了什么问题

这套方案之所以采用“配置驱动 + ref 驱动 + 弹窗容器分离”的方式,是因为后台项目里的表单高度重复,但每个业务页又会有细小差异。如果每次都手写 Form.Item,不仅重复,而且难以统一。

• 解决重复开发问题:大部分新增/编辑/查看表单只需要配置 formArr,不需要从头写 UI。

• 解决行为不统一问题:校验、重置、关闭、查看态禁用、标题与说明展示都统一收口。

• 解决字段扩展问题:新增 switch、upload、cascader、datePicker、custom 后,可以覆盖更多真实业务场景。

• 解决回填控制问题:通过 syncKey 控制何时重新 patch 表单,避免编辑态误覆盖。

• 解决布局不灵活问题:通过 direction 和 columns 拆开“整体表单方向”和“字段网格布局”两个维度。

4. 方案的优点、改进点

4.1 优点

• 抽象清晰:类型、工具函数、表单、弹窗分层明确。

• 字段能力完整:覆盖了常见后台表单大部分控件。

• 配置统一:字段描述、布局控制、默认值、规则、扩展属性集中在一处。

• 对业务友好:父页面主要关心配置和 onOk 保存逻辑,不必处理表单底层细节。

• 可扩展性强:custom 字段为特殊业务组件提供了稳定扩展入口。

5. 关键语法解释

5.1 forwardRef

让函数组件可以接收 ref。当前 FormEdit 和 FormEditModal 都需要向父组件暴露方法,因此必须使用它。

5.2 useImperativeHandle

自定义 ref.current 上暴露的内容。这里暴露的是 validate、getValues、resetFields、open、close 等方法,而不是整个内部实例。

5.3 useMemo

缓存计算结果。当前主要用于 initialValues 和 visibleFormItems,减少重复计算并让依赖更明确。

5.4 useEffect

在数据变化时同步副作用。当前主要用于 modelValue / syncKey 变化时重新 patch 表单。

5.5 useRef

跨 render 持久保存引用。这里用于 mountedRef、lastPatchedKeyRef,也用于 formRef / modalRef。

5.6 联合类型

例如 direction?: 'horizontal' | 'vertical'。这种写法能把取值范围限制在有限集合内,减少误传。

6. 使用示例

6.1 基础单列表单

<FormEdit ref={formRef}
  formArr={singleColumnFormArr}
  layout="plain"
  direction="horizontal"
  columns={1}
/>

适用于字段较少、小弹窗场景。

6.2 两列表单 + 跨行 textarea

{
  key: 'desc',
  title: '说明',
  type: 'textarea',
  rows: 5,
  colSpan: 2,
}

适用于两列布局中某些长文本字段需要独占一行的场景。

6.3 switch / cascader / datePicker / upload

[
  { key: 'status', title: '状态', type: 'switch', checkedValue: 1, uncheckedValue: 0 },
  { key: 'region', title: '地区', type: 'cascader', options: regionOptions },
  { key: 'publishDate', title: '发布日期', type: 'datePicker', datePickerType: 'date' },
  { key: 'cover', title: '封面', type: 'upload', uploadAction: '/api/upload', limit: 1 },
]

6.4 custom 字段

{
  key: 'customField',
  title: '自定义组件',
  type: 'custom',
  render: ({ value, setFieldValue }) => (
    <Input
      value={typeof value === 'string' ? value : ''}
      onChange={(nextValue) => setFieldValue('customField', nextValue)}
    />
  ),
}

当内置字段类型不够用时,可以通过 custom 直接接入业务特有组件。

6.5 弹窗表单

<FormEditModal
  open={open}
  title="新增信息"
  formRef={formRef}
  onOpenChange={setOpen}
  onOk={async (values) => {
    console.log(values);
  }}
  <FormEdit
    ref={formRef}
    formArr={formArr}
    layout="plain"
    direction="horizontal"
    columns={2}
  />
</FormEditModal>

这是最推荐的项目用法:弹窗负责交互流程,FormEdit 负责表单本体。

7. 结论

当前这套 FormEdit 结构是一套面向项目复用的表单基础设施。它通过类型契约、配置化字段、布局解耦、规则适配、初始值归一化和弹窗容器分离,把后台系统中最常见的新增/编辑/查看型表单进行了有效抽象。

❌