阅读视图

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

Get 这波之后,我把 Flutter 状态管理重新看了一遍:新项目到底该选谁?

前言

昨天 get 的删库跑路之后发,社区和公司部门内部基本可以说是原地炸了。(早上作者说是他的github账户被风控,但是get本身的问题已经很多了...)

问得最多的,不是“这个包现在还能不能装”,而是另一类更扎心的问题:

“以后 Flutter 项目状态管理到底该怎么选?”

“Get 还能不能继续用?”

“Provider、Riverpod、Bloc 这些,到底谁更靠谱?”

“你如果现在开一个新项目,你会选谁?”

我仔细思考了,发现很多讨论都有两个毛病:

  1. 只聊 API,不聊项目演进后的维护成本。
  2. 只聊自己喜欢什么,不聊团队、业务、复杂度、学习成本这些现实问题。

所以这篇文章,我想和大家聊聊我的看法。

我想做的事情很简单:

借着这次 get 的风波,把 Flutter 生态里主流的状态管理方案,重新摆到桌面上,按真实项目的标准,认认真真聊一遍。

不是聊“哪个最优雅”,而是聊:

  • 它到底解决什么问题
  • 它的边界在哪
  • 它为什么有人爱,也为什么有人骂
  • 如果现在重新开一个项目,我会怎么选

为了避免这篇文章变成“空对空”,我还顺手把同一个业务场景做成了一个开源 Demo,把 Provider / Riverpod / Cubit / Bloc Event 全都落地了一遍。

开源地址在这里: state_manages

后面我文中提到的一些对比,不只是嘴上说说,基本都能在这个仓库里对上代码。

screenshot-20260415-151857.png

先说结论

我先把结论放前面,免得大家看半天最后发现和自己预想差不多。

如果你现在问我:

“2026 年这个时间点,Flutter 新项目状态管理怎么选?”

我的答案是:

  • 小项目、单人项目、快速起步:Provider
  • 中大型新项目、我个人最愿意推荐的平衡方案:Riverpod
  • 多人协作、复杂业务、强调规范和状态流可追踪:Cubit / Bloc
  • 老项目已经深度绑定 GetX:先稳住,不要一激动就全量重构
  • 新项目再从 0 开始选 GetX:我个人会明显更谨慎

注意,我这里不是说 GetX 技术上突然一夜归零了。

而是说,这次事情把一个以前很多人不愿意正视的问题,硬生生摊开了:

状态管理从来不是“代码写起来爽不爽”这么简单,它还关乎维护、协作、生态稳定性、升级路线、团队兜底能力。

说得再直白一点:

以前大家觉得“能跑就行”, 这次很多人才开始意识到:

“哦,原来依赖生态稳定,也是技术选型的一部分。”


篇章一:先把问题掰直,状态管理到底在管什么

很多人聊状态管理,一上来就对比 API:

  • setState
  • notifyListeners
  • ref.watch
  • emit
  • Obx

但这其实是表象。

状态管理真正要解决的,不是“你用哪个函数刷新页面”,而是下面这几件事:

  1. 状态放在哪里
  2. 状态变化后,谁来通知 UI
  3. 异步请求、空状态、错误状态怎么建模
  4. 页面越来越复杂之后,代码会不会开始失控
  5. 团队里第二个人、第三个人接手时,还看不看得懂

你会发现,一个状态管理方案,真正的价值,不在于它能不能写出页面。

因为大家都能写。

真正拉开差距的,是当业务变成这样时:

  • 页面有列表
  • 列表要刷新
  • 请求会失败
  • 有搜索
  • 有筛选
  • 有排序
  • 还有弹 Toast、弹 Dialog、跳详情页这种一次性副作用

这时候你再看,方案之间的差异就出来了。

也就是说:

状态管理的核心,不是“能不能更新 UI”,而是“当 UI 和业务越来越复杂时,这套结构还能不能顶住”。


篇章二:Provider,Flutter 状态管理里的“老实人”

如果让我给 Provider 起个外号,我会叫它:

“老实人方案。”

它最大的特点就是:

  • 不花
  • 不绕
  • 不装神秘
  • 你基本一眼就能知道状态在哪、怎么改、谁在监听

这也是为什么,很多 Flutter 新人第一个真正上手的状态管理,都是它。

Provider 到底在干嘛

最常见的写法其实很直白:

class UserViewModel extends ChangeNotifier {
  bool loading = false;
  List<String> users = [];

  Future<void> loadUsers() async {
    loading = true;
    notifyListeners();

    await Future.delayed(const Duration(seconds: 1));
    users = ['Ava', 'Noah', 'Mia'];

    loading = false;
    notifyListeners();
  }
}

// 页面里用:

ChangeNotifierProvider(
  create: (_) => UserViewModel()..loadUsers(),
  child: Consumer<UserViewModel>(
    builder: (_, vm, __) {
      if (vm.loading) {
        return const CircularProgressIndicator();
      }
      return ListView(
        children: vm.users.map(Text.new).toList(),
      );
    },
  ),
)

这套东西的优点几乎不用解释:

  • 好懂
  • 上手快
  • 学习门槛低
  • 代码量不大
  • 对小页面非常够用

Provider 为什么好用

因为它特别符合人脑最朴素的思路:

  • 我有一个对象
  • 对象里放状态
  • 改完状态
  • 通知页面刷新

这套逻辑没有什么抽象负担。

对于很多简单页面来说,这种方案不仅够用,而且其实是最划算的。

你非要拿一个很轻的用户列表页,上来就写一堆事件、状态类、派生结构,很多时候反而是技术过剩。

Provider 的问题到底在哪

问题不在它不能用,而在它太容易一路“长歪”。

最开始你只放两个字段:

  • loading
  • users

后面慢慢加:

  • errorMessage
  • query
  • selectedFilter
  • sortType
  • showVipOnly
  • currentTab
  • hasMore
  • isRefreshing

再往后还会加一堆方法:

  • loadUsers
  • refreshUsers
  • retry
  • updateQuery
  • toggleVip
  • changeSort
  • openDetail
  • showErrorToast

写着写着,一个 ChangeNotifier 就变成了一个“巨型大管家”。

它不是不能维护,但它特别考验开发者的自觉。

Provider 最大的问题,不是功能弱,而是结构约束弱。

你写得好,它很好用。 你写得随便,它也很容易烂。

所以 Provider 适合谁

我会这样建议:

适合:

  • Flutter 初学者
  • 小型项目
  • 页面级逻辑不复杂的业务
  • 想先把状态管理基本感觉建立起来的人

不太适合:

  • 中大型复杂项目当唯一主状态管理方案
  • 多人长期协作、对结构一致性要求很高的团队
  • 派生状态很多、异步链路复杂的模块

———

篇章三:Riverpod,我最愿意推荐给新项目的方案

如果说 Provider 是“老实人”,那 Riverpod 在我眼里更像:

“脑子清楚、结构现代、能打硬仗的中生代主力。”

我为什么这么说?

因为 Riverpod 真正厉害的地方,不是“语法多高级”,而是:

它很擅长把依赖关系和状态关系拆清楚。

这点在项目越做越大时,价值会越来越明显。

Riverpod 和 Provider 最本质的区别

很多人会把 Riverpod 理解成“升级版 Provider”。

这么说不算全错,但也太粗暴了。

Provider 更像是:

  • 往 Widget Tree 里塞对象
  • 下层从树里读对象
  • 对象变了,通知相关 Widget 刷新

Riverpod 更像是:

  • 先把状态和依赖拆成一个个 provider 节点
  • provider 和 provider 之间可以互相组合
  • 页面只是去消费这些节点

你把它想象成一张依赖图,会更容易理解。

Riverpod 为什么在异步场景特别舒服

我觉得 Riverpod 最讨喜的一点,是它对异步状态的表达非常自然。

比如一个最常见的异步列表:

  final usersProvider =
      AsyncNotifierProvider<UsersNotifier, List<String>>(UsersNotifier.new);

  class UsersNotifier extends AsyncNotifier<List<String>> {
    @override
    Future<List<String>> build() async {
      await Future.delayed(const Duration(seconds: 1));
      return ['Ava', 'Noah', 'Mia'];
    }

    Future<void> refreshUsers() async {
      state = const AsyncLoading();
      state = await AsyncValue.guard(() async {
        await Future.delayed(const Duration(seconds: 1));
        return ['Ava', 'Noah', 'Mia'];
      });
    }
  }

//   页面里:

  final asyncUsers = ref.watch(usersProvider);

  return asyncUsers.when(
    loading: () => const CircularProgressIndicator(),
    error: (e, _) => Text('出错了:$e'),
    data: (users) => ListView(
      children: users.map(Text.new).toList(),
    ),
  );

你会发现这里有个很明显的优势:

异步状态本身就是框架的一等公民。

不是你自己去维护:

  • isLoading
  • errorMessage
  • hasData

而是用 AsyncValue 直接把这些状态表达出来。

这个对实际开发体验影响很大。

Riverpod 真正强的,不是异步,而是“组合能力”

如果你只拿一个简单异步列表示例去看 Riverpod,其实还没看到它最强的地方。

它真正强的是这种场景:

  • 原始用户列表一个 provider
  • 搜索关键词一个 provider
  • VIP 开关一个 provider
  • 排序方式一个 provider
  • 最终可见列表再是一个派生 provider
  • 用户详情页再用 family

也就是说:

Riverpod 不是鼓励你写一个“大而全的状态类”,而是鼓励你把不同职责拆成多个 provider,再组合起来。

这会带来两个很现实的好处:

  1. 结构更清楚
  2. 重建范围更好控制

Riverpod 有什么代价

它当然也不是白给的。

代价主要有三个:

1. 学习曲线比 Provider 高

你第一次看 Riverpod,脑子里经常会冒出几个问题:

  • 为什么一个页面拆这么多 provider
  • 为什么这里 watch,那边 read
  • 为什么这里要 listen
  • 为什么 provider 还要依赖 provider

这很正常,因为 Riverpod 不是在教你“存一个对象”,而是在教你“组织一组状态节点”。

2. 写不好会显得很碎

Riverpod 的优点是可拆分,但坏处也正是可拆分。

如果一个团队没有统一规范,很容易出现:

  • provider 命名混乱
  • 分层过细
  • 逻辑散落到各处

最后导致不是“结构清晰”,而是“文件一大堆,人都找不到”。

3. 对抽象能力有要求

Riverpod 更适合那种愿意先想清楚状态边界,再写代码的人。

如果一个人习惯先堆功能,再慢慢补结构,那 Riverpod 反而不一定让他更轻松。

所以 Riverpod 适合谁

我的建议是:

非常适合:

  • 中大型新项目
  • 需要长期维护的项目
  • 依赖关系复杂、派生状态较多的模块
  • 想把局部注入、测试隔离做得更清晰的团队

如果你问我现在新项目更倾向推荐谁,

我个人会优先推荐 Riverpod。

不是因为它最火,也不是因为它“最优雅”,而是因为它在:

  • 开发体验
  • 异步表达
  • 可组合性
  • 可维护性
  • 模块化能力

这几个维度上,整体太均衡了。

篇章四:Bloc / Cubit,这套东西不是“重”,而是“规矩大”

很多人一提到 Bloc,第一反应就是:

“太重了。”

这句话不能说错,但我觉得它只说了一半。

更准确一点的说法应该是:

Bloc 不是单纯地重,它是规矩大。

它会逼着你把一些以前可以“糊着写”的东西,全部摊开来写清楚。

比如:

  • 页面到底触发了什么动作
  • 动作进来后,状态怎么变
  • 哪些地方是副作用
  • 哪些地方只是纯渲染

这套思路,在简单页面里确实显得重。

但一旦业务复杂起来,它的价值就会越来越大。

先说 Cubit,它比你想象中实用

我其实很想先替 Cubit 正个名。

因为很多人把 Bloc 体系一股脑都理解成:

  • 一堆 event
  • 一堆 state
  • 一堆 boilerplate

但 Cubit 不是这样的。

Cubit 更像是:

“有明确状态对象的、工程化一点的 ViewModel。”

比如:

  class UsersCubit extends Cubit<UsersState> {
    UsersCubit() : super(const UsersState());

    Future<void> loadUsers() async {
      emit(state.copyWith(loading: true));

      await Future.delayed(const Duration(seconds: 1));

      emit(
        state.copyWith(
          loading: false,
          users: ['Ava', 'Noah', 'Mia'],
        ),
      );
    }
  }

你看,它其实很好懂:

  • 有状态类
  • 有方法
  • 改状态时 emit
  • 页面用 BlocBuilder 监听

对很多简单到中等复杂度页面来说,Cubit 是非常实用的。

它比 Provider 更有“状态层”的味道, 又比完整 Bloc Event 版轻很多。

那为什么还需要 Bloc Event 版

因为业务一复杂,方法驱动就开始不够清楚了。

比如一个页面同时有:

  • 首次加载
  • 刷新
  • 重试
  • 搜索变更
  • 排序切换
  • 筛选切换
  • Toast 提示
  • 并发请求控制

这时候你再全靠方法名去表达,就会慢慢开始乱。

而 Event 版会逼你把事情说清楚:

  • UsersRequested
  • UsersRefreshed
  • UsersRetried
  • SearchChanged
  • FilterChanged

这不是为了多写几个类,而是为了让状态变化路径可追踪。

尤其在多人协作里,这点特别重要。

Bloc 这套方案最值钱的地方

我觉得有三点。

1. 事件语义明确

一眼就能看出:

“页面现在到底发生了什么业务动作。”

2. 副作用边界清楚

用 BlocBuilder 渲染 UI, 用 BlocListener 处理 Toast、Dialog、路由跳转。

这比很多项目里“状态逻辑和副作用搅成一锅粥”的写法,要清爽太多。

3. 复杂交互下更稳

比如并发控制。

Bloc 生态里你可以明确去处理:

  • 重复点击刷新怎么办
  • 搜索输入连发怎么办
  • 模式切换时要保留最后一次还是顺序执行

这些东西,在复杂项目里不是“锦上添花”,而是迟早会遇到的坑。

Bloc 的代价是什么

说实话,代价也很明显:

  • 样板代码更多
  • 初学者上手成本更高
  • 简单页面里容易显得重炮打蚊子

所以我不会无脑推荐所有人都上 Bloc Event 版。

但如果你在的团队是这种风格:

  • 人多
  • 业务重
  • 状态复杂
  • 强调规范
  • 维护周期长

那 Bloc 的价值,真的会越来越明显。

所以 Bloc / Cubit 适合谁

我的建议是:

  • 简单页面:Cubit 很香
  • 复杂业务模块:Bloc Event 版很稳
  • 团队已经全套 flutter_bloc:不要轻易引第二套主状态管理
  • 如果只是个人小项目,别一上来就给自己加戏

篇章五:GetX,到底该怎么重新看

这部分我不想写成“清算大会”。

因为说实话,GetX 当年能火,不是没原因的。

它确实帮很多 Flutter 开发者解决过实际问题。

尤其是早几年,Flutter 生态还没现在这么成熟时,GetX 的那种“开箱快、上手爽、什么都给你带一点”的感觉,对很多人真的很有吸引力。

GetX 当年为什么能打

原因其实很现实:

  • 学起来快
  • 写起来省事
  • 状态管理、路由、依赖注入几乎一把梭
  • 对很多从前端框架过来的人很有亲和力

你写页面时会感觉:

“卧槽,这也太快了吧。”

这就是 GetX 当年最强的传播力来源。

但它的问题也一直存在

只不过以前很多人选择忽略。

我自己总结,主要有这几类:

1. 职责容易混在一起

GetX 很容易让人一路写成这种结构:

  • 状态也在 controller
  • 路由也在 controller
  • 依赖注入也在 controller
  • 页面副作用也在 controller
  • 工具方法也在 controller

最后 controller 既像 ViewModel,又像 Service,又像 Router。

短期开发很爽,长期看边界其实很容易糊。

2. “魔法感”很强

很多写法前期很丝滑,但越往后越容易出现一种感觉:

项目能跑,但你说不清它到底靠什么机制在跑。

这对个人项目问题不大, 但对团队维护来说,是个隐患。

3. 风险被低估了

以前大家更多讨论的是:

  • 性能
  • 写法
  • 学习曲线

这次事情之后,大家终于被迫意识到另一层风险:

生态稳定性和治理能力,也是技术选型的一部分。

所以我现在对 GetX 的态度是:

  • 我不会否认它历史上的价值
  • 我也不会说所有 GetX 项目都得马上重构
  • 但如果你现在让我从 0 开一个新项目,我会明显更谨慎

篇章六:如果今天重新开一个项目,我会怎么选

这一段我尽量说人话,不打太极。

场景一:单人项目、验证想法、快速上线

我会优先考虑:

  • Provider
  • Cubit

原因很简单:

  • 成本低
  • 起步快
  • 心智负担小

别把事情搞太复杂。

场景二:中小团队新项目,业务会持续增长

我会优先考虑:

  • Riverpod

原因是它太平衡了。

它既没有 Bloc Event 那么重, 又比 Provider 更容易把结构撑住。

如果团队里成员整体水平还不错,Riverpod 是一个非常舒服的主状态管理方案。

场景三:复杂业务、多人协作、强调规范

我会优先考虑:

  • Cubit + Bloc Event

简单模块用 Cubit, 复杂模块上 Bloc Event。

这种搭配很实用。

场景四:老项目已经深度用了 GetX

我不会建议你们一夜重构。

我的建议是:

  1. 先锁版本
  2. 先备份依赖来源
  3. 先把最关键模块稳住
  4. 新模块逐步减少对 GetX 的继续扩散
  5. 再考虑渐进迁移

因为状态管理迁移这种事,一旦上头,很容易把“风险治理”做成“二次事故”。

———

最后

这次 get 的事情,对我来说最大的提醒,不是“某个包危险”,而是另一件更本质的事:

技术选型从来不是一锤子买卖。

你今天选一个方案,不只是选它今天写起来爽不爽, 你其实是在选:

  • 三个月后它还好不好改
  • 半年后新人能不能接
  • 一年后团队还能不能稳稳维护
  • 真出事时,你们有没有兜底能力

所以如果你现在问我:

“Get 这波之后,Flutter 状态管理该怎么重新看?”

我的答案是:

  • Provider,适合入门和轻量场景
  • Riverpod,是我目前最愿意推荐给新项目的平衡方案
  • Cubit / Bloc,适合复杂业务和多人协作
  • GetX,不是不能用,但以后别再只看“写起来爽不爽”了

说到底,真正好的状态管理方案,不是“最酷”的那个。

而是当项目做大之后,它还能让你回答清楚下面这句话:

“状态从哪来,为什么变,谁在监听,副作用在哪发生。”

如果这个问题它还能帮你解释清楚,那它就值钱。 如果它让这些东西越来越糊,那它迟早会反噬你。

这也是我这两天重新看 Flutter 状态管理生态之后,最真实的感受。

如果你们团队现在也在重新评估状态管理路线,希望这篇能帮你少踩几个坑。

———

如果你想让我继续写

这篇如果大家爱看,后面我可以继续写三篇:

  1. Provider、Riverpod、Bloc,我做了一个同业务 Demo,带你看真实代码差异
  2. GetX 老项目怎么渐进式迁移,不推倒重来
  3. Flutter 状态管理怎么选,别只看 API,得看团队结构

如果你觉得有用,评论区告诉我,我就继续更。

往期文章回顾

Get 删库风波

Web 前端转 Flutter

Flutter 图片编辑器

Flutter 全链路监控 SDK

微前端入门:qiankun + Vue 3 + Vite 从0搭建第一个微前端应用

前言

随着业务规模不断扩大,前端应用体积越来越大,单一巨石应用的开发维护成本越来越高:

  • 多人协作开发,代码冲突频繁
  • 整体编译打包时间越来越长
  • 技术栈陈旧,无法引入新技术
  • 发布一个小改动需要整个应用重新上线

微前端架构应运而生,通过将巨石应用拆分为多个独立可交付的微应用,实现团队自治、独立开发、独立部署,从架构层面解决这些问题。

目前社区中比较成熟的微前端方案里,qiankun 是蚂蚁集团开源的企业级方案,经过大量业务验证,生态成熟,api 友好,是落地微前端最稳妥的选择。

本文带你一步步从 0 搭建第一个 qiankun 微前端应用,基于最新的 Vue 3 + Vite 技术栈,解决了网上大部分教程配置错误的问题。读完本文你就能跑通一个可工作的微前端应用。

技术选型

层级 选型 理由
微前端框架 qiankun 成熟稳定,社区案例多,坑少
前端框架 Vue 3 主流稳定,性能优秀,生态完善
构建工具 Vite 开发启动快,热更新体验好,是当前主流趋势
路由 Vue Router 4 官方标准,配合 qiankun 路由联动方案成熟

整体架构

graph TD
    A[主应用/MainApp] --> B[微应用1/App1]
    A --> C[微应用2/App2]
    style A fill:#f9f,stroke:#333,stroke-width:2px
    style B fill:#bbf,stroke:#333,stroke-width:1px
    style C fill:#bbf,stroke:#333,stroke-width:1px
  • 主应用(基座): 负责微应用注册与生命周期管理、全局导航布局、公共依赖加载。
  • 微应用: 各个业务模块独立开发、独立运行、独立部署。
  • 最终效果:用户在浏览器中切换不同业务模块,感觉就像在同一个应用里,实际上每个模块都是独立的。

项目初始化

我们需要创建三个项目:一个主应用,两个微应用。

# 创建主应用
npm create vite@latest main-app -- --template vue

# 创建微应用一
npm create vite@latest micro-app-vue1 -- --template vue

# 创建微应用二
npm create vite@latest micro-app-vue2 -- --template vue

最终目录结构:

micro-front/
├── main-app/          # 主应用(基座)
├── micro-app-vue1/    # 微应用1
├── micro-app-vue2/    # 微应用2
└── README.md

本文档对应的完整代码已经开源在 github.com/wenbiyou/mi…,你可以直接克隆运行。

第一步:主应用配置 qiankun

安装依赖

cd main-app
npm install qiankun vue-router@4

qiankun 注册与启动

修改 src/main.js

import { createApp } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
import { registerMicroApps, start } from 'qiankun'
import App from './App.vue'

const app = createApp(App)
const router = createRouter({
  history: createWebHistory('/'),
  routes: [
    { path: '/', redirect: '/app1' },
    { path: '/:pathMatch(.*)*', component: () => import('./views/NotFound.vue') }
  ]
})

app.use(router)
app.mount('#app')

// 微应用配置
const microApps = [
  {
    name: 'micro-app-vue1',
    entry: '//localhost:7100', // 开发环境入口
    activeRule: '/app1', // 激活规则:路径以 /app1 开头时激活
    container: '#micro-container' // 挂载容器
  },
  {
    name: 'micro-app-vue2',
    entry: '//localhost:7101',
    activeRule: '/app2',
    container: '#micro-container'
  }
]

// 注册微应用
registerMicroApps(microApps, {
  beforeLoad: [app => console.log('before load', app.name)],
  beforeMount: [app => console.log('before mount', app.name)],
  afterMount: [app => console.log('after mount', app.name)],
  afterUnmount: [app => console.log('after unmount', app.name)]
})

// 启动 qiankun
start({
  sandbox: {
    strictStyleIsolation: false,
    experimentalStyleIsolation: true // 开启实验性样式隔离,兼容性更佳
  },
  prefetch: 'all' // 预加载微应用静态资源
})

添加主应用布局

修改 src/App.vue

<template>
  <div id="main-app">
    <header class="main-header">
      <div class="logo">
        <h1>微前端主应用</h1>
      </div>
      <nav class="nav">
        <router-link to="/app1">应用一</router-link>
        <router-link to="/app2">应用二</router-link>
      </nav>
    </header>
    <!-- qiankun 挂载容器 -->
    <div id="micro-container" class="micro-container"></div>
  </div>
</template>

<style scoped>
.main-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 0 20px;
  background: #2c3e50;
  color: white;
}
.nav {
  display: flex;
  gap: 20px;
}
.nav a {
  color: white;
  text-decoration: none;
}
.nav a.router-link-exact-active {
  color: #42b983;
  font-weight: bold;
}
.micro-container {
  padding: 20px;
}
</style>

Vite 配置

修改 vite.config.js

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  server: {
    port: 7099, // 主应用端口,避免和微应用冲突
    cors: true // 开启跨域,允许加载微应用资源
  }
})

主应用配置完成!接下来配置微应用。

第二步:微应用适配 qiankun

micro-app-vue1 为例,micro-app-vue2 只需要修改端口和名称即可。

安装依赖

cd micro-app-vue1
npm install vite-plugin-qiankun

修改入口文件适配生命周期

修改 src/main.js

import { createApp } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
import { renderWithQiankun, qiankunWindow } from 'vite-plugin-qiankun/es/helper'
import App from './App.vue'
import './style.css'

let app = null

function render(props = {}) {
  const { container } = props
  const mountNode = container ? container.querySelector('#app') : '#app'

  // 每次渲染新建 router,避免状态污染
  const router = createRouter({
    history: createWebHistory(qiankunWindow.__POWERED_BY_QIANKUN__ ? '/app1' : '/'),
    routes: [
      { path: '/', component: () => import('./views/Home.vue') },
      { path: '/about', component: () => import('./views/About.vue') }
    ]
  })

  app = createApp(App)
  app.use(router)
  app.mount(mountNode)
}

// 使用 renderWithQiankun 包裹生命周期
renderWithQiankun({
  bootstrap() {
    console.log('[micro-app-vue1] bootstrap')
  },
  mount(props) {
    console.log('[micro-app-vue1] mount', props)
    render(props)
  },
  unmount() {
    console.log('[micro-app-vue1] unmount')
    app?.unmount()
    app = null
  }
})

// 独立运行
if (!qiankunWindow.__POWERED_BY_QIANKUN__) {
  render()
}

关键 Vite 配置

修改 vite.config.js

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import qiankun from 'vite-plugin-qiankun'

export default defineConfig({
  base: '/app1/', //  开发环境基路径,生产环境请根据部署路径动态设置
  plugins: [
    vue(),
    qiankun('micro-app-vue1', {
      useDevMode: true // 开发模式必须开启
    })
  ],
  server: {
    port: 7100, // 微应用端口
    cors: true, // 必须开启跨域,主应用才能访问
    headers: {
      'Access-Control-Allow-Origin': '*'
    },
    origin: 'http://localhost:7100' // 必须配置完整的 origin,qiankun 才能正确获取资源
  },
  build: {
    // 生产构建必须配置 UMD 格式供 qiankun 加载
    lib: {
      entry: './src/main.js',
      name: 'micro-app-vue1',
      formats: ['umd'],
      fileName: () => 'index.js'
    }
  }
})

关键提示: 这是网上大多数教程配置错误的地方!

  1. server.origin 必须是完整 URL,否则 qiankun 无法正确加载微应用资源
  2. base 必须和 activeRule 保持一致
  3. 生产构建必须配置 build.lib 输出 UMD 格式

第二个微应用 micro-app-vue2 配置类似,只需要修改:

  • 端口改为 7101
  • base 改为 /app2/
  • name 改为 micro-app-vue2

本地运行验证

我们需要三个终端分别启动:

# 终端一:主应用
cd main-app && npm install && npm run dev
# 访问 http://localhost:7099

# 终端二:微应用一
cd micro-app-vue1 && npm install && npm run dev
# 监听 http://localhost:7100

# 终端三:微应用二
cd micro-app-vue2 && npm install && npm run dev
# 监听 http://localhost:7101

打开浏览器访问 http://localhost:7099,你应该能看到:

  1. 主应用头部导航显示
  2. 微应用一正常加载3.点击导航切换到微应用二,微应用二正常加载
  3. 切换回微应用一,微应用一再次正常加载

如果看到这个效果,恭喜你!你的第一个 qiankun 微前端应用已经跑通了!

FAQ

Q: 按照步骤配置后,微应用还是加载不出来,怎么办?

A: 先检查这几点:

  1. micro-app-vue1vite.config.jsserver.origin 是否配置了完整 URL (http://localhost:7100)
  2. 端口是否被占用,三个服务都正常启动了吗?
  3. 浏览器控制台有没有报错?常见报错我们在第三篇文章会详细讲解。

Q: 微应用可以独立运行吗?

A: 可以!直接访问 http://localhost:7100 就能独立运行微应用一,这是最佳实践——每个微应用必须能够独立运行,方便开发调试。

Q: 为什么每次 mount 都要重新创建路由?

A: 为了避免状态污染。如果路由只创建一次重复使用,上次的状态会残留到下次挂载,重新创建可以保证每次挂载都是干净的状态。

Q: 生产环境部署和开发环境有什么不同?

A: 打包优化、部署方案、常见坑点我们放在第三篇文章详细讲解。第二篇我们先把核心概念讲清楚。

本章小结

你已经完成了:

  1. 创建了主应用和两个微应用的项目结构
  2. 主应用注册并启动了 qiankun
  3. 微应用适配了 qiankun 生命周期
  4. 配置了正确的 Vite 配置(解决了大多数教程的错误)
  5. 本地运行验证可以正常切换

下一篇我们深入讲解 qiankun 核心概念:路由联动、样式隔离、跨应用通信

大人工智能时代下前端界面全新开发模式的思考(三)

第三章:范式的跃迁——从组件驱动到意图驱动

工具的变革只是表象,更深层的变革发生在开发范式层面。前端开发正在经历从"组件驱动"到"意图驱动"的范式跃迁,这不仅是技术的变化,更是思维方式、能力模型和职业价值的根本性重构。

这一章我们将深入探讨这场范式转变的内涵、影响和实践路径。


3.1 代码范式的对比:两种世界观的碰撞

让我们通过具体的代码示例,来感受组件驱动和意图驱动这两种范式的根本差异。

3.1.1 场景:实现一个用户管理功能

需求描述

  • 展示用户列表
  • 支持搜索(按姓名或邮箱)
  • 支持按角色筛选
  • 支持分页
  • 支持行内编辑
  • 响应式布局
  • 加载状态和空状态处理

组件驱动模式(传统方式)

// UserManagement.tsx - 约150行代码
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { 
  Card, 
  CardHeader, 
  CardTitle, 
  CardContent,
  CardFooter 
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import {
  Pagination,
  PaginationContent,
  PaginationEllipsis,
  PaginationItem,
  PaginationLink,
  PaginationNext,
  PaginationPrevious,
} from "@/components/ui/pagination";
import { Skeleton } from '@/components/ui/skeleton';
import { toast } from '@/components/ui/use-toast';
import { Search, Edit2, Save, X } from 'lucide-react';
import { debounce } from 'lodash';

interface User {
  id: string;
  name: string;
  email: string;
  role: 'admin' | 'editor' | 'viewer';
  status: 'active' | 'inactive';
  createdAt: string;
}

interface Filters {
  search: string;
  role: string;
  page: number;
  pageSize: number;
}

export function UserManagement() {
  // 状态管理
  const [filters, setFilters] = useState<Filters>({
    search: '',
    role: 'all',
    page: 1,
    pageSize: 10
  });
  
  const [editingId, setEditingId] = useState<string | null>(null);
  const [editForm, setEditForm] = useState<Partial<User>>({});
  
  const queryClient = useQueryClient();
  
  // 数据获取
  const { data, isLoading, error } = useQuery({
    queryKey: ['users', filters],
    queryFn: async () => {
      const params = new URLSearchParams();
      if (filters.search) params.append('search', filters.search);
      if (filters.role !== 'all') params.append('role', filters.role);
      params.append('page', String(filters.page));
      params.append('pageSize', String(filters.pageSize));
      
      const response = await fetch(`/api/users?${params}`);
      if (!response.ok) throw new Error('Failed to fetch users');
      return response.json();
    }
  });
  
  // 更新用户mutation
  const updateUser = useMutation({
    mutationFn: async (user: Partial<User> & { id: string }) => {
      const response = await fetch(`/api/users/${user.id}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(user)
      });
      if (!response.ok) throw new Error('Failed to update user');
      return response.json();
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['users'] });
      toast({ title: 'User updated successfully' });
      setEditingId(null);
    },
    onError: (error) => {
      toast({ 
        title: 'Failed to update user', 
        variant: 'destructive',
        description: error.message
      });
    }
  });
  
  // 防抖搜索
  const debouncedSearch = useMemo(
    () => debounce((value: string) => {
      setFilters(prev => ({ ...prev, search: value, page: 1 }));
    }, 300),
    []
  );
  
  // 事件处理
  const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    debouncedSearch(e.target.value);
  };
  
  const handleRoleChange = (value: string) => {
    setFilters(prev => ({ ...prev, role: value, page: 1 }));
  };
  
  const handlePageChange = (page: number) => {
    setFilters(prev => ({ ...prev, page }));
  };
  
  const handleEdit = (user: User) => {
    setEditingId(user.id);
    setEditForm(user);
  };
  
  const handleSave = () => {
    if (editingId && editForm) {
      updateUser.mutate({ id: editingId, ...editForm });
    }
  };
  
  const handleCancel = () => {
    setEditingId(null);
    setEditForm({});
  };
  
  // 计算分页
  const totalPages = Math.ceil((data?.total || 0) / filters.pageSize);
  
  if (error) {
    return (
      <Card className="w-full">
        <CardContent className="pt-6">
          <div className="text-center text-red-600">
            <p className="text-lg font-semibold">Error loading users</p>
            <p className="text-sm">{error.message}</p>
            <Button 
              onClick={() => queryClient.invalidateQueries({ queryKey: ['users'] })}
              className="mt-4"
            >
              Retry
            </Button>
          </div>
        </CardContent>
      </Card>
    );
  }
  
  return (
    <Card className="w-full">
      <CardHeader>
        <CardTitle className="text-2xl font-bold">User Management</CardTitle>
      </CardHeader>
      
      <CardContent className="space-y-6">
        {/* 过滤器 */}
        <div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center">
          <div className="relative w-full sm:w-64">
            <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
            <Input
              placeholder="Search users..."
              onChange={handleSearchChange}
              className="pl-10"
            />
          </div>
          
          <Select value={filters.role} onValueChange={handleRoleChange}>
            <SelectTrigger className="w-full sm:w-40">
              <SelectValue placeholder="Filter by role" />
            </SelectTrigger>
            <SelectContent>
              <SelectItem value="all">All Roles</SelectItem>
              <SelectItem value="admin">Admin</SelectItem>
              <SelectItem value="editor">Editor</SelectItem>
              <SelectItem value="viewer">Viewer</SelectItem>
            </SelectContent>
          </Select>
        </div>
        
        {/* 表格 */}
        <div className="border rounded-lg overflow-hidden">
          <Table>
            <TableHeader>
              <TableRow>
                <TableHead>Name</TableHead>
                <TableHead>Email</TableHead>
                <TableHead>Role</TableHead>
                <TableHead>Status</TableHead>
                <TableHead className="text-right">Actions</TableHead>
              </TableRow>
            </TableHeader>
            <TableBody>
              {isLoading ? (
                // 加载状态
                Array.from({ length: 5 }).map((_, i) => (
                  <TableRow key={i}>
                    <TableCell><Skeleton className="h-4 w-32" /></TableCell>
                    <TableCell><Skeleton className="h-4 w-48" /></TableCell>
                    <TableCell><Skeleton className="h-4 w-20" /></TableCell>
                    <TableCell><Skeleton className="h-4 w-16" /></TableCell>
                    <TableCell><Skeleton className="h-8 w-20 ml-auto" /></TableCell>
                  </TableRow>
                ))
              ) : data?.users.length === 0 ? (
                // 空状态
                <TableRow>
                  <TableCell colSpan={5} className="text-center py-8 text-gray-500">
                    No users found
                  </TableCell>
                </TableRow>
              ) : (
                // 数据展示
                data?.users.map((user: User) => (
                  <TableRow key={user.id}>
                    <TableCell>
                      {editingId === user.id ? (
                        <Input
                          value={editForm.name || ''}
                          onChange={(e) => setEditForm(prev => ({ ...prev, name: e.target.value }))}
                          className="w-40"
                        />
                      ) : (
                        <span className="font-medium">{user.name}</span>
                      )}
                    </TableCell>
                    <TableCell>
                      {editingId === user.id ? (
                        <Input
                          value={editForm.email || ''}
                          onChange={(e) => setEditForm(prev => ({ ...prev, email: e.target.value }))}
                          className="w-56"
                        />
                      ) : (
                        user.email
                      )}
                    </TableCell>
                    <TableCell>
                      <span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
                        user.role === 'admin' ? 'bg-purple-100 text-purple-800' :
                        user.role === 'editor' ? 'bg-blue-100 text-blue-800' :
                        'bg-gray-100 text-gray-800'
                      }`}>
                        {user.role}
                      </span>
                    </TableCell>
                    <TableCell>
                      <span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
                        user.status === 'active' 
                          ? 'bg-green-100 text-green-800' 
                          : 'bg-red-100 text-red-800'
                      }`}>
                        {user.status}
                      </span>
                    </TableCell>
                    <TableCell className="text-right">
                      {editingId === user.id ? (
                        <div className="flex justify-end gap-2">
                          <Button 
                            size="sm" 
                            onClick={handleSave}
                            disabled={updateUser.isPending}
                          >
                            <Save className="w-4 h-4 mr-1" />
                            Save
                          </Button>
                          <Button 
                            size="sm" 
                            variant="outline"
                            onClick={handleCancel}
                          >
                            <X className="w-4 h-4 mr-1" />
                            Cancel
                          </Button>
                        </div>
                      ) : (
                        <Button 
                          size="sm" 
                          variant="ghost"
                          onClick={() => handleEdit(user)}
                        >
                          <Edit2 className="w-4 h-4 mr-1" />
                          Edit
                        </Button>
                      )}
                    </TableCell>
                  </TableRow>
                ))
              )}
            </TableBody>
          </Table>
        </div>
        
        {/* 分页 */}
        {totalPages > 1 && (
          <Pagination>
            <PaginationContent>
              <PaginationItem>
                <PaginationPrevious 
                  onClick={() => handlePageChange(Math.max(1, filters.page - 1))}
                  className={filters.page === 1 ? 'pointer-events-none opacity-50' : ''}
                />
              </PaginationItem>
              
              {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
                <PaginationItem key={page}>
                  <PaginationLink
                    onClick={() => handlePageChange(page)}
                    isActive={page === filters.page}
                  >
                    {page}
                  </PaginationLink>
                </PaginationItem>
              ))}
              
              <PaginationItem>
                <PaginationNext 
                  onClick={() => handlePageChange(Math.min(totalPages, filters.page + 1))}
                  className={filters.page === totalPages ? 'pointer-events-none opacity-50' : ''}
                />
              </PaginationItem>
            </PaginationContent>
          </Pagination>
        )}
      </CardContent>
    </Card>
  );
}

传统方式的特点

  • 代码量大:约150行,还不算类型定义和样式
  • 关注点分散:需要同时处理UI、状态、数据获取、错误处理、加载状态
  • 依赖众多:需要熟悉React Query、UI组件库、Lodash等多个库
  • 调试复杂:状态流转复杂,bug定位困难
  • 但是:完全可控,每一行代码都理解其作用

意图驱动模式(AI生成)

提示词:
"创建一个用户管理表格组件,要求:
1. 从 /api/users 获取数据,使用React Query
2. 支持按姓名或邮箱搜索(防抖300ms)
3. 支持按角色筛选(admin/editor/viewer)
4. 分页功能,每页10条
5. 行内编辑功能,可修改姓名和邮箱
6. 加载状态显示骨架屏
7. 空状态提示
8. 错误处理,显示重试按钮
9. 使用Tailwind CSS和shadcn/ui组件
10. 响应式布局,移动端友好
11. 添加适当的类型定义"

→ AI生成完整实现(约150行,与手写相当)

意图驱动方式的特点

  • 代码量相当:AI生成的代码也是约150行
  • 关注点集中:开发者只需要关注"要什么",不需要关注"怎么实现"
  • 实现细节黑盒:搜索防抖、分页逻辑、状态管理都由AI处理
  • 快速迭代:需要修改时,修改提示词重新生成,而非修改代码
  • 但是:不完全理解实现细节,调试困难,可维护性存疑

3.1.2 关键差异分析

维度 组件驱动 意图驱动
关注点 如何组装组件、管理状态、处理副作用 需要实现什么功能、满足什么需求
代码所有权 精心编写、深度理解、长期维护 一次性使用、黑盒理解、按需重新生成
调试方式 阅读代码、理解逻辑、定位问题 与AI对话、重新生成、试错迭代
学习曲线 陡峭(需要掌握语法、框架、模式) 平缓(需要学会与AI沟通)
代码质量 依赖开发者水平,质量可控 依赖AI能力和Prompt质量,波动较大
维护成本 高(需要持续维护代码) 低(可以重新生成),但长期可能更高
创新性 高(完全自定义,可实现任何想法) 中(受限于AI的理解和能力)

3.1.3 范式转变的本质

这两种模式的差异,本质上是"控制"与"委托"的权衡:

  • 组件驱动:开发者完全控制实现细节,但需要投入大量时间和精力
  • 意图驱动:开发者委托AI处理实现细节,但需要接受一定的不可控性

这不是非此即彼的选择,而是一个连续谱。实际开发中,我们往往在两者之间找到平衡点:

高控制 ←─────────────────────────────→ 高委托
        组件驱动    混合模式    意图驱动
        (手动编写)  (AI辅助)   (AI主导)
        
适用场景:
- 核心功能 → 手动编写
- 工具函数 → AI生成+审查
- 样板代码 → AI生成
- 原型验证 → AI主导

3.2 架构层面的三大转变

从组件驱动到意图驱动的转变,不仅仅是编码方式的变化,更是架构层面的根本性重构。

3.2.1 从"声明式UI"到"生成式UI"

声明式UI(传统)

开发者声明UI应该是什么样,框架负责将其渲染到DOM。

// 声明式:我声明这个div应该是什么样
function App() {
  const [count, setCount] = useState(0);
  
  return (
    <div className="p-4 bg-blue-500 text-white rounded hover:bg-blue-600">
      <p>Count: {count}</p>
      <button onClick={() => setCount(c => c + 1)}>
        Increment
      </button>
    </div>
  );
}

开发者明确声明:

  • 这是一个div
  • padding是1rem(p-4)
  • 背景是蓝色(bg-blue-500)
  • 文字是白色(text-white)
  • 圆角(rounded)
  • 悬停时背景变深(hover:bg-blue-600)

生成式UI(AI驱动)

开发者描述意图,AI生成UI。

提示词:"创建一个计数器组件,蓝色主题,有悬停效果"

→ AI生成代码(可能不完全符合预期,需要迭代)

关键区别

维度 声明式UI 生成式UI
确定性 高(代码即UI) 低(AI可能生成不同结果)
可预测性 高(相同输入,相同输出) 中(相同提示词,可能不同结果)
控制精度 像素级控制 意图级控制
开发速度 慢(需要手动编写每一行) 快(AI批量生成)
调试难度 中(理解代码即可) 高(需要理解AI的"思维")

实践建议

生产环境中,建议采用"混合模式":

// 核心UI手动声明(确保精确控制)
function CoreLayout() {
  return (
    <div className="min-h-screen flex">
      <Sidebar />
      <main className="flex-1 p-6">
        <AIContent /> {/* AI生成的内容区域 */}
      </main>
    </div>
  );
}

// AI生成内容(非关键路径)
function AIContent() {
  const { content } = useAI({
    prompt: "根据当前页面上下文生成合适的内容"
  });
  
  return <div dangerouslySetInnerHTML={{ __html: content }} />;
}

3.2.2 从"状态驱动"到"对话驱动"

状态驱动(传统)

前端架构的核心是状态管理。

数据流向:
用户操作 → Action → Dispatcher → Reducer → State → UI重新渲染

示例:
点击按钮 → dispatch({ type: 'INCREMENT' }) → 
Reducer处理 → State.count++ → UI显示新数值

React的useState、Redux、Vuex,都是围绕"状态"设计的。

对话驱动(AI应用)

状态依然存在,但不再是架构的核心。**对话历史(Conversation History)**成为新的状态载体。

// Vercel AI SDK的useChat管理的是消息历史
function ChatComponent() {
  const { messages, input, handleSubmit } = useChat();
  
  // messages就是新的"状态",它驱动UI的展示
  return (
    <div>
      {messages.map(m => (
        <Message key={m.id} role={m.role} content={m.content} />
      ))}
      
      <form onSubmit={handleSubmit}>
        <input
          value={input}
          onChange={handleInputChange}
          placeholder="输入消息..."
        />
        <button type="submit">发送</button>
      </form>
    </div>
  );
}

对话驱动的特点

  1. 上下文保持:AI记住之前的对话,可以基于上下文理解用户意图
  2. 多轮交互:不是一次性操作,而是通过多轮对话逐步完成任务
  3. 不确定性:同样的输入,可能因为上下文不同而产生不同输出
  4. 流式响应:AI的响应是流式的,UI需要支持渐进式更新

架构变化

传统应用架构:
用户操作 → 状态更新 → UI渲染

AI应用架构:
用户输入 → AI理解 → 生成响应 → 流式展示 → 用户反馈 → 下一轮...
            ↑_________↓
              上下文循环

3.2.3 从"静态组件"到"智能组件"

静态组件(传统)

给定相同的props,永远渲染相同的UI。

// 静态组件:纯函数,确定性输出
function Button({ children, onClick, variant }: ButtonProps) {
  return (
    <button 
      className={`btn btn-${variant}`}
      onClick={onClick}
    >
      {children}
    </button>
  );
}

// 相同输入,相同输出
<Button variant="primary">Click me</Button> // 总是渲染相同的按钮

智能组件(AI驱动)

组件具备AI能力,能根据上下文自动调整行为。

// 智能组件概念示例
function AdaptiveButton({ intent, context }) {
  // 组件理解上下文,自动调整行为
  const { variant, size, icon, label, confirmation } = useAI({
    prompt: `根据意图"${intent}"和上下文${JSON.stringify(context)},
             生成最合适的按钮配置`,
    constraints: {
      variants: ['primary', 'secondary', 'danger', 'ghost'],
      sizes: ['sm', 'md', 'lg', 'xl'],
      requireConfirmation: ['delete', 'irreversible']
    }
  });
  
  const handleClick = () => {
    if (confirmation) {
      showConfirmationDialog(confirmation.message, executeAction);
    } else {
      executeAction();
    }
  };
  
  return (
    <Button variant={variant} size={size} onClick={handleClick}>
      {icon && <Icon name={icon} />}
      {label}
    </Button>
  );
}

// 使用:组件自动根据场景调整
<AdaptiveButton 
  intent="删除用户账户"
  context={{ userRole: 'admin', targetUser: 'VIP客户', irreversible: true }}
/>
// AI理解这是危险且不可逆的操作
// 自动选择danger变体,添加确认对话框,显示警告信息

智能组件的特征

  1. 自适应:根据用户行为、设备环境、网络状况自动调整
  2. 自优化:根据使用数据自动优化性能(如自动代码分割、懒加载)
  3. 自解释:能够解释自己的行为,帮助用户理解和调试
  4. 个性化:根据用户偏好和历史行为提供个性化体验

3.3 Prompt工程的新角色

在AI驱动的前端开发中,Prompt Engineering(提示工程)扮演着越来越重要的角色。它不再是一个"技巧",而是一个核心技能。

3.3.1 Prompt即接口(Prompt as Interface)

在传统开发中,我们定义函数接口:

// 传统接口定义
interface CreateUserParams {
  name: string;
  email: string;
  role: 'admin' | 'user' | 'guest';
}

function createUser(params: CreateUserParams): Promise<User> {
  // 实现...
}

在AI驱动开发中,Prompt成为新的"接口":

// Prompt即接口
interface ComponentGenerationPrompt {
  role: "前端开发专家";
  task: {
    componentName: string;
    requirements: string[];     // 功能需求列表
    techStack: {
      framework: 'React' | 'Vue' | 'Angular';
      styling: 'Tailwind' | 'CSS Modules' | 'Styled';
      typescript: boolean;
    };
    designSpec: DesignTokens;   // 设计规范
    context: {
      existingHooks: string[];  // 已有Hooks
      uiLibrary: string;        // UI组件库
      conventions: string[];    // 代码规范
    };
  };
  output: {
    code: string;              // 完整组件代码
    tests: string;             // 测试用例
    examples: string;          // 使用示例
    docs: string;              // Props文档
  };
}

// 用这个"接口"生成组件
const prompt: ComponentGenerationPrompt = {
  role: "前端开发专家",
  task: {
    componentName: "UserProfileCard",
    requirements: [
      "展示用户头像、姓名、职位",
      "悬停显示更多详情",
      "支持点击跳转到用户详情页",
      "响应式布局"
    ],
    techStack: {
      framework: "React",
      styling: "Tailwind",
      typescript: true
    },
    designSpec: designSystem.tokens,
    context: {
      existingHooks: ["useUser", "useRouter"],
      uiLibrary: "shadcn/ui",
      conventions: ["使用函数组件", "Props类型使用interface"]
    }
  }
};

const component = await generateComponent(prompt);

3.3.2 Prompt资产化管理

随着Prompt越来越多,团队需要建立Prompt资产库:

prompts/
├── README.md                    # 使用指南
├── guidelines/
│   └── writing-effective-prompts.md  # Prompt编写规范
├── templates/
│   ├── code-generation/         # 代码生成模板
│   │   ├── react-component.md
│   │   ├── vue-component.md
│   │   ├── utility-function.md
│   │   ├── custom-hook.md
│   │   └── api-client.md
│   ├── code-review/             # 代码审查模板
│   │   ├── security-check.md
│   │   ├── performance-review.md
│   │   ├── accessibility-check.md
│   │   └── style-guide-check.md
│   ├── debugging/               # 调试排错模板
│   │   ├── error-analysis.md
│   │   ├── performance-debug.md
│   │   └── memory-leak-debug.md
│   ├── documentation/           # 文档生成模板
│   │   ├── component-docs.md
│   │   ├── api-docs.md
│   │   └── readme-generator.md
│   └── architecture/            # 架构设计模板
│       ├── system-design.md
│       ├── data-modeling.md
│       └── api-design.md
├── examples/                    # 示例Prompt
│   ├── good-examples/           # 优秀案例
│   └── bad-examples/            # 反面教材
└── snippets/                    # 可复用的Prompt片段
    ├── tech-stack-definitions.md
    ├── code-conventions.md
    └── design-system-tokens.md

Prompt模板示例

<!-- prompts/templates/code-generation/react-component.md -->

# React组件生成模板

## 角色
你是资深前端工程师,精通React、TypeScript和现代前端工程化。

## 任务
根据以下要求生成高质量的React组件代码。

## 输入
- 组件名称:{{componentName}}
- 功能需求:{{requirements}}
- 技术栈:{{techStack}}
- 设计规范:{{designSpec}}
- 上下文:{{context}}

## 输出要求
1. 使用函数组件和TypeScript
2. 完整的Props类型定义
3. 包含JSDoc注释
4. 处理加载状态和错误状态
5. 遵循{{techStack.conventions}}代码规范
6. 使用{{techStack.uiLibrary}}组件库
7. 可访问性支持(aria属性、键盘导航)

## 代码结构

import React from 'react';
// 导入语句

// Props类型定义
interface {{componentName}}Props {
  // ...
}

/**
 * {{componentName}}组件
 * @description {{description}}
 */
export function {{componentName}}(props: {{componentName}}Props) {
  // 实现代码
}

## 示例
{{examples}}

3.3.3 Prompt工程最佳实践

1. 结构化Prompt

好的Prompt应该结构清晰、信息完整:

❌ 不好的Prompt:
"写一个用户表单"

✅ 好的Prompt:
"创建一个用户注册表单组件

角色:前端开发专家
技术栈:React + TypeScript + Tailwind CSS + shadcn/ui

功能要求:
1. 表单字段:用户名(必填,3-20字符)、邮箱(必填,有效格式)、密码(必填,8+字符,包含大小写和数字)
2. 实时验证:失去焦点时验证,显示错误信息
3. 提交处理:调用/api/register,显示加载状态
4. 成功处理:清空表单,显示成功消息
5. 错误处理:显示服务器返回的错误信息

UI要求:
1. 使用Card布局,最大宽度480px,居中
2. 输入框使用shadcn/ui的Input组件
3. 错误信息使用红色文字,显示在输入框下方
4. 提交按钮显示加载Spinner

可访问性:
1. 所有输入框关联label
2. 错误信息使用aria-describedby关联
3. 支持键盘导航"

2. 渐进式细化策略

与AI协作的最佳实践是"渐进式细化":

Round 1: 生成骨架
"创建一个用户管理页面,包含表格和基本CRUD操作"
→ AI生成基础结构

Round 2: 添加功能
"在表格上方添加搜索框和筛选器,支持按姓名和角色筛选"
→ AI添加筛选功能

Round 3: 优化细节
"搜索框添加防抖处理,筛选器使用下拉菜单,表格添加分页"
→ AI优化交互细节

Round 4: 完善体验
"添加加载状态、空状态、错误处理,优化移动端显示"
→ AI完善用户体验

3. 示例驱动(Few-Shot Learning)

提供示例可以帮助AI理解预期输出:

"创建一个格式化日期函数,要求:
1. 输入:Date对象或时间戳
2. 输出:'YYYY年MM月DD日 HH:mm'格式
3. 处理无效输入

示例:
输入:new Date('2024-03-15 14:30:00')
输出:'2024031514:30'

输入:null
输出:'无效日期'

请实现这个函数:"

4. 约束和边界

明确指定约束条件,避免AI生成不符合要求的代码:

"实现一个节流函数,约束条件:
1. 使用TypeScript,完整类型定义
2. 支持leading和trailing选项
3. 使用requestAnimationFrame优化性能
4. 不要使用lodash或其他库
5. 包含单元测试"

3.4 新抽象层的出现:意图层(Intent Layer)

AI的引入,在前端架构中增加了一个新的抽象层。

3.4.1 传统架构 vs AI增强架构

传统前端架构

用户操作 → 事件处理 → 状态更新 → 组件重新渲染
    ↑________________________________↓
              循环

开发者直接控制每一个环节。

AI增强架构

用户意图 → AI理解 → 决策/生成 → 状态更新 → 组件重新渲染
    ↑________________________↓
           反馈循环

在"用户意图"和"实现代码"之间,增加了AI处理层。

3.4.2 意图层带来的变化

1. 更高的抽象级别

开发者描述意图,AI处理实现细节。

传统方式:
"我需要创建一个div,className是p-4 bg-blue-500..."

AI方式:
"创建一个蓝色卡片组件"

2. 更好的用户体验

AI可以根据上下文提供智能化建议。

// AI可以根据用户角色自动调整界面
function Dashboard() {
  const { user } = useAuth();
  
  // AI根据用户角色和历史行为,生成个性化的仪表板布局
  const { layout, widgets } = useAI({
    prompt: `为${user.role}生成个性化的仪表板布局`,
    context: {
      userRole: user.role,
      permissions: user.permissions,
      frequentlyUsed: user.metrics.frequentlyUsedFeatures,
      recentActivity: user.metrics.recentActivity
    }
  });
  
  return <AdaptiveLayout layout={layout} widgets={widgets} />;
}

3. 更大的不确定性

AI的输出不是完全确定的,需要处理各种边界情况。

function AIGeneratedComponent({ prompt }) {
  const [result, setResult] = useState(null);
  const [error, setError] = useState(null);
  const [loading, setLoading] = useState(true);
  
  useEffect(() => {
    generateCode(prompt)
      .then(code => {
        // 验证生成的代码
        if (!isValidCode(code)) {
          throw new Error('Generated code is invalid');
        }
        setResult(code);
      })
      .catch(err => {
        setError(err);
        // 记录错误,用于改进AI模型
        logError(prompt, err);
      })
      .finally(() => setLoading(false));
  }, [prompt]);
  
  if (loading) return <LoadingState />;
  if (error) return <ErrorState error={error} onRetry={() => window.location.reload()} />;
  
  return <RenderedComponent code={result} />;
}

3.4.3 意图层的边界和风险

何时使用意图层?

适合使用AI

  • 样板代码生成
  • 快速原型验证
  • 探索性开发
  • 文档生成
  • 测试用例生成

不适合使用AI

  • 核心算法实现
  • 安全敏感代码
  • 性能关键路径
  • 需要严格合规的代码
  • 创新性设计

风险控制

AI代码进入生产环境的门禁:

1. 自动检查层
   ├─ 语法检查(ESLint/TypeScript)
   ├─ 安全检查(SAST扫描)
   ├─ 性能检查(Bundle分析)
   └─ 可访问性检查(axe-core)

2. 人工审查层(必须)
   ├─ 逻辑正确性审查
   ├─ 安全漏洞审查
   ├─ 性能影响评估
   └─ 可维护性评估

3. 测试验证层
   ├─ 单元测试通过率>80%
   ├─ 集成测试通过
   ├─ 端到端测试通过
   └─ 视觉回归测试通过

4. 灰度发布层
   ├─ 5%流量验证
   ├─ 监控错误率
   ├─ 监控性能指标
   └─ 全量发布

3.5 小结:拥抱范式转变

从组件驱动到意图驱动的转变,是前端开发范式的一次重大跃迁。这不仅仅是工具的升级,更是思维方式的重构。

关键转变总结

维度 传统模式 新模式 应对策略
关注点 如何组装组件 如何描述意图 学习Prompt工程
代码所有权 精心维护 按需生成 建立质量门禁
调试方式 理解代码逻辑 与AI对话迭代 保留核心能力
技能重点 框架和API 需求拆解和沟通 培养软技能
架构思维 状态管理 意图管理和AI编排 学习AI架构

未来的前端工程师

将是一个混合角色

  • 50%的架构师:设计系统、把控质量、做出关键决策
  • 30%的Prompt工程师:与AI高效沟通,生成高质量代码
  • 20%的产品设计师:理解用户需求,创造优秀体验

这个转变不会一夜之间完成,而是一个渐进的过程。现在开始学习和适应,才能在未来保持竞争力。


下章预告

第四章《锋利的双刃剑——批判性审视AI生成代码》将深入探讨:

  • AI生成代码的可访问性危机及解决方案
  • 性能陷阱和技术债的累积模式
  • 安全漏洞的隐蔽性和防护措施
  • 工程师能力退化的风险及防范
  • 真实案例分析:过度依赖AI的教训

总篇:iframe沙盒存储隔离:从紧急补丁到企业级防御体系的完整指南

在微前端与第三方组件集成的浪潮下,iframe 沙盒环境中的 SessionStorage 安全问题,正从一个隐秘的"技术细节"演变为可能引发数据泄露、权限逃逸的"阿喀琉斯之踵"。本系列文章将为你完整呈现:我们如何从一次真实攻防演练中发现致命漏洞,到构建一套经过生产验证的三层纵深防御体系的全过程。 在这里插入图片描述


缘起:一次攻防演练暴露的"沙盒幻象"

在一次内部红蓝对抗中,一个看似平常的设定引发了我们的警觉:一个嵌入在同源 iframe 中、完全受控的第三方图表组件,竟能悄无声息地读取并篡改主应用存储在 sessionStorage 中的用户令牌(authToken)、管理员权限(userRole)及核心业务数据。

核心漏洞

浏览器同源策略保护的是"源"而非"上下文"。当 iframe 的 sandbox 属性包含 allow-same-origin 时,它与主应用被视为同一源,从而共享同一份 sessionStorage 物理存储

这不是浏览器的 Bug,而是其安全模型的一个特性——却也成了攻击者眼中的"特性":

// 恶意代码可轻易在 iframe 内执行
const stolenToken = sessionStorage.getItem('authToken');  // 窃取令牌
sessionStorage.setItem('userRole', 'super_admin');        // 权限提升

我们意识到:这不仅是代码冲突,更是严重的安全漏洞。任何一个被嵌入的第三方组件(即使来源可信),一旦被 XSS 攻击或自身存在恶意代码,都可能成为突破"沙盒"的跳板。


破局:构建纵深防御的思维演进

面对这一问题,简单的"禁用某个属性"或"期望对方整改"并不可靠。我们需要的是一套可自主掌控、可持续演进的技术方案。解决思路经历了三次关键进化:

层级 策略 核心手段 定位
L1 快速止血 通信层修复 移除 allow-same-origin,通过严格的 postMessage 替代直接存储访问 紧急响应,治标不治本
L2 核心隔离 代理层隔离 运行时拦截:动态代理 sessionStorage API,自动添加命名空间前缀(如 ns_app1_ 性价比最高的方案
L3 体系防御 监控层防护 存储访问代理层 + 运行时行为监控 + 安全策略执行层,支持异常检测、自动阻断、灰度发布 企业级基础设施

系列导航:你将在这三篇文章中获得什么

本系列分为上、中、下三篇,由浅入深,带你走完从认知漏洞到建立堡垒的完整路径。

📘 上篇(总篇):《iframe沙盒存储隔离:从紧急补丁到企业级防御体系的完整指南》

(首篇发布)

  • 核心价值:建立完整认知,提供可立即执行的紧急修复方案
  • 你会学到
    • 同源策略与存储共享机制的底层原理
    • allow-same-origin + allow-scripts 组合的致命风险
    • L1 快速止血方案:postMessage 通信改造的最佳实践
    • 如何评估现有系统的暴露面与风险等级
  • 适合读者:所有使用 iframe 的前端开发者、技术经理、安全工程师

🔰 中篇:《手把手拦截——iframe 沙盒 SessionStorage 隔离的轻量级实践》

(第二篇发布)

  • 核心价值:给你一套"开箱即用"的代码,立即解决数据污染问题
  • 你会学到
    • Monkey Patch(猴子补丁)技术:优雅劫持 iframe 内的存储 API
    • 完整的 Vue/React 示例代码(前缀隔离、安全的 clear 方法改造)
    • 嵌套 iframe、Storage 事件监听等边界情况的处理
  • 适合读者:一线前端工程师、团队技术骨干,寻求快速有效解决方案的实践者

🛡️ 下篇:《从漏洞到堡垒——构建企业级 iframe 存储安全纵深防御体系》

(第三篇发布)

  • 核心价值:呈现可应对复杂攻击、支撑大型工程的安全架构蓝本
  • 你会学到
    • 基于 Proxy 与 MutationObserver 的健壮代理实现(防绕过)
    • 生产级部署:灰度发布、监控指标、性能测试与回滚方案
    • 与 W3C Storage Access API 的对比与融合路径
    • 开源安全框架的设计思路
  • 适合读者:前端架构师、技术负责人、安全工程师,关注高可用、高安全、可演进架构的决策者

为什么你需要关注这个系列?

  1. 问题普遍性:只要你使用了同源 iframe 嵌入(微前端、第三方 SDK、多团队协作),就可能面临此风险
  2. 方案完整性:从"救火"的 50 行代码,到"防火"的系统工程,提供不同阶段的解决方案
  3. 实战参考性:所有方案均源于真实攻防演练与生产环境迭代,包含踩坑记录与决策权衡
  4. 视野前瞻性:不止于解决当下问题,更探讨与 Web 标准接轨的未来演进路径

安全不是可选项,而是现代 Web 应用的默认值。 对 iframe 沙盒存储漏洞的忽视,可能让精心构建的应用防线从内部被攻破。

本系列文章正是为你厘清风险、提供武器、建立防线的实战指南。敬请期待后续的深度解析。


[下篇预告]:,我们将直接切入实战,剖析漏洞原理,并附上一段可直接复制使用的代码,让你能在半小时内为你的 iframe 应用穿上第一件"隔离衣"。


大人工智能时代下前端界面全新开发模式的思考(二)

第二章:工具的盛宴——主流AI前端开发生态深度解析

当变革来临时,最直观的体现就是工具的爆发。在AI前端开发领域,我们看到了一场真正的"工具的盛宴":从IDE插件到全栈生成平台,从设计转代码到运行时AI能力,各种工具百花齐放,各显神通。

据统计,2024年GitHub上新增的AI编程相关项目超过10万个,Star数总计超过1000万。这是一个前所未有的繁荣时代,也是一个令人困惑的时代——工具太多,选择变得困难。

本章将深入解析主流AI前端工具的架构原理、使用场景和技术差异,帮助你在这个纷繁复杂的生态中找到最适合自己的工具组合。


2.1 工具分层与定位矩阵

为了理清这个复杂的生态,我们可以将当前主流工具分为四个层次。这种分层不是人为的划分,而是基于工具的抽象层次和能力边界自然形成的。

2.1.1 四层工具架构

层级 代表工具 核心能力 技术架构 适用场景 学习曲线
IDE集成层 Cursor、Windsurf、GitHub Copilot 实时代码补全、重构、解释、多文件编辑 IDE插件 + LLM API + AST解析 日常开发主力、代码审查、重构
设计转代码层 v0.dev、Screenshot-to-Code、Galileo AI 设计稿→代码、截图→代码、文本描述→UI 视觉识别模型 + LLM生成 + 设计系统 快速原型、设计还原、探索性开发
全栈生成层 Bolt.new、Lovable、Replit Agent 自然语言→完整应用、零配置开发环境 WebContainer + AI Agent + 运行时 MVP验证、学习实验、全栈原型
运行时层 Vercel AI SDK、LangChain、LlamaIndex Streaming UI、Tool Calling、Agent编排 Provider抽象层 + 消息协议 + 流式传输 生产级AI应用、对话式界面、Agent系统

这四个层次并非互斥,而是互补。一个完整的前端AI开发工作流,往往需要同时使用多个层次的工具。

工具组合示例

实际项目工作流:

需求分析阶段:
  ├─ 使用ChatGPT/Claude进行需求梳理和架构讨论
  └─ 使用Whimsical/Miro进行概念设计

设计阶段:
  ├─ 使用v0.dev快速生成UI原型
  ├─ 使用Figma进行精细设计
  └─ 使用Screenshot-to-Code还原设计稿

开发阶段:
  ├─ 使用Cursor进行日常编码
  ├─ 使用GitHub Copilot加速样板代码编写
  ├─ 使用团队Prompt库标准化代码生成
  └─ 使用Vercel AI SDK集成AI功能

验证阶段:
  ├─ 使用Bolt.new快速验证完整流程
  └─ 使用Storybook测试组件

部署阶段:
  ├─ 使用Vercel/Netlify自动部署
  └─ 使用AI监控工具检测异常

2.1.2 选择工具的决策框架

面对众多工具,如何做出选择?建议使用以下决策框架:

Step 1: 明确需求场景

  • 是日常开发还是原型验证?
  • 是个人使用还是团队协作?
  • 是前端开发还是全栈开发?
  • 需要集成到现有项目还是从零开始?

Step 2: 评估工具维度

维度 权重 评估标准
功能匹配度 30% 是否满足核心需求?
学习成本 20% 上手难度如何?
生态成熟度 20% 社区活跃度、文档质量
成本效益 15% 免费/付费?性价比如何?
可迁移性 15% 是否容易迁移到其他工具?

Step 3: 小规模试验

  • 不要一次性全面采用新工具
  • 选择一个小项目或功能模块试用
  • 收集团队反馈,评估实际效果

Step 4: 渐进式推广

  • 从愿意尝试的早期采用者开始
  • 建立使用规范和最佳实践
  • 逐步扩大到整个团队

2.2 IDE集成层:AI增强的编码体验

IDE集成层是开发者接触最频繁的工具层。它们深度集成到开发环境,提供实时的AI辅助。

2.2.1 GitHub Copilot:开发生态的颠覆者

GitHub Copilot是最早大规模商用的AI编程助手,也是目前市场占有率最高的工具。

技术架构

GitHub Copilot架构:

IDE (VS Code/JetBrains/Vim/Neovim)
    ↓ 上下文信息
Copilot Extension
    ├─ 代码上下文提取(当前文件、光标位置、相关文件)
    ├─ 代码风格学习(项目特定的命名习惯、模式)
    └─ 用户习惯学习(常用API、个人偏好)
    ↓ HTTP请求
GitHub Copilot Service
    ├─ 上下文处理
    ├─ Prompt构建
    └─ 缓存优化
    ↓ API调用
OpenAI Codex Model
    ├─ 代码生成
    └─ 多候选生成
    ↓ 响应
Suggestion Ranking & Filtering
    ├─ 安全过滤(避免生成漏洞代码)
    ├─ 质量评分
    └─ 个性化排序
    ↓
IDE展示建议

核心能力详解

1. 实时代码补全

// 场景1:根据注释生成代码
// 计算购物车总价,包含折扣逻辑
function calculateCartTotal(cart: Cart): number {
  // Copilot生成的代码:
  const subtotal = cart.items.reduce((sum, item) => {
    return sum + item.price * item.quantity;
  }, 0);
  
  const discount = cart.discountCode 
    ? applyDiscount(subtotal, cart.discountCode)
    : 0;
    
  return subtotal - discount;
}

// 场景2:根据函数名生成实现
function debounce<T extends (...args: any[]) => any>(
  func: T,
  wait: number
): (...args: Parameters<T>) => void {
  // Copilot生成的代码:
  let timeout: ReturnType<typeof setTimeout> | null = null;
  
  return function executedFunction(...args: Parameters<T>) {
    const later = () => {
      timeout = null;
      func(...args);
    };
    
    if (timeout) clearTimeout(timeout);
    timeout = setTimeout(later, wait);
  };
}

// 场景3:模式识别和补全
function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);
  
  useEffect(() => {
    // 开发者输入 fetchUser(userId) 后,Copilot自动建议:
    fetchUser(userId)
      .then(data => {
        setUser(data);
        setLoading(false);
      })
      .catch(error => {
        console.error('Failed to fetch user:', error);
        setLoading(false);
      });
  }, [userId]);
  
  // ...
}

2. 上下文感知能力

Copilot的上下文感知包括多个维度:

  • 文件内上下文:当前文件的内容、导入的模块、定义的函数
  • 项目上下文:项目使用的框架(React/Vue/Angular)、技术栈、代码风格
  • 跨文件上下文:打开的相关文件、最近修改的文件
  • 用户习惯:个人编码风格、常用API、偏好模式
// 示例:项目使用特定的API风格
// 如果项目中普遍使用axios进行HTTP请求:
import axios from 'axios';

// Copilot会建议axios风格的代码
async function fetchUser(id: string) {
  // 而不是fetch
  const response = await axios.get(`/api/users/${id}`);
  return response.data;
}

3. 聊天功能(Copilot Chat)

开发者:@workspace 解释一下这个函数的作用

Copilot:这个函数是一个防抖(debounce)实现,用于限制函数的执行频率。

工作原理:
1. 当函数被调用时,不立即执行,而是设置一个定时器
2. 如果在等待时间内再次调用,清除之前的定时器,重新开始计时
3. 只有等待时间结束且没有新的调用时,才真正执行函数

使用场景:
- 搜索框输入时的自动补全
- 窗口resize事件的处理
- 表单验证的实时反馈

代码分析:
- 使用泛型T保持原函数的类型签名
- 使用ReturnType<typeof setTimeout>获得准确的类型
- 通过闭包保存timeout状态

使用建议

DO(推荐做法)

  • 使用描述性变量名和注释,帮助Copilot理解意图
  • 审查AI生成的代码,不要直接接受
  • 使用Copilot处理样板代码,保留精力处理核心业务逻辑
  • 通过接受/拒绝建议训练Copilot理解你的偏好

DON'T(避免做法)

  • 不要过度依赖Copilot处理复杂算法或安全敏感代码
  • 不要接受不理解的代码
  • 不要忽视Copilot生成的潜在性能问题
  • 不要在保密项目中使用(代码会被发送到云端)

定价与许可

  • 个人版:10/月或10/月或100/年(免费试用30天)
  • 商业版:$19/用户/月(包含管理功能)
  • 开源项目维护者和学生:免费

2.2.2 Cursor:AI原生编辑器

Cursor不是IDE插件,而是一个完整的AI原生编辑器。它基于VS Code fork,将AI能力深度集成到编辑器的每个环节。

核心功能对比

功能 Cursor GitHub Copilot
代码补全 ⭐⭐⭐⭐⭐(更智能) ⭐⭐⭐⭐
聊天功能 ⭐⭐⭐⭐⭐(内置Composer) ⭐⭐⭐⭐(需要切换窗口)
多文件编辑 ⭐⭐⭐⭐⭐(Composer模式) ⭐⭐(需手动切换)
Agent模式 ⭐⭐⭐⭐⭐(自动执行命令) ⭐⭐(不支持)
代码解释 ⭐⭐⭐⭐⭐(@codebase查询) ⭐⭐⭐
价格 有免费版 需要订阅

四大核心模式详解

1. Tab补全(比Copilot更智能)

Cursor的Tab补全不仅基于当前行,还考虑了更多上下文:

// Cursor能跨行预测
function processUserData(users: User[]) {
  const result = users
    .filter(u => u.isActive)
    .map(u => ({          // 按Tab后,Cursor预测:
      name: u.name,       // 自动补全属性
      email: u.email,
      lastLogin: u.lastLogin
    }))
    .sort((a, b) =>       // 按Tab后,Cursor预测排序逻辑
      new Date(b.lastLogin).getTime() - new Date(a.lastLogin).getTime()
    );
  
  return result;
}

2. Chat模式(Cmd+L)

在编辑器内直接与AI对话,无需切换窗口。

开发者:如何优化这个函数的性能?

Cursor:分析代码后,建议以下优化:

1. 使用Map替代find,时间复杂度从O(n²)降到O(n)
2. 避免重复计算,缓存中间结果
3. 使用Web Workers处理大数据集

需要我帮你实施这些优化吗?

[Apply All] [Apply 1] [Apply 2] [Apply 3] [Cancel]

3. Composer模式(Cmd+I)

这是Cursor的杀手锏功能——AI可以同时编辑多个文件。

开发者:"添加用户认证功能,使用JWT token"

Cursor Composer自动执行:

1. 创建 auth.ts 配置文件
2. 安装 jsonwebtoken 依赖(npm install jsonwebtoken @types/jsonwebtoken)
3. 修改 src/App.tsx 添加AuthProvider
4. 创建 src/components/LoginForm.tsx
5. 创建 src/hooks/useAuth.ts
6. 修改 src/api/client.ts 添加token拦截器
7. 创建 src/middleware/auth.ts
8. 运行测试验证功能

[Accept All Changes] [Review Changes] [Cancel]

Composer的技术实现:

Composer Architecture:

User Intent
    ↓
Intent Understanding
    ├─ 识别需要修改的文件
    ├─ 确定修改顺序(依赖关系分析)
    └─ 生成修改计划
    ↓
File-by-File Modification
    ├─ 读取文件内容
    ├─ 生成修改方案
    ├─ 应用修改(使用diff算法)
    └─ 验证语法正确性
    ↓
Cross-File Consistency Check
    ├─ 检查类型一致性
    ├─ 检查导入/导出关系
    └─ 检查命名一致性
    ↓
Present to User

4. Agent模式(实验性功能)

Agent模式让AI拥有更高的自主性,可以执行命令、读写文件、甚至运行测试。

开发者:"重构整个项目的错误处理,统一使用自定义的AppError类"

Cursor Agent执行:

Step 1: 分析项目结构
  ├─ 扫描所有.ts/.tsx文件
  ├─ 识别所有try-catch块
  └─ 识别所有throw语句

Step 2: 创建AppError类
  ├─ 在src/errors/AppError.ts创建基类
  ├─ 支持错误码、HTTP状态码、元数据
  └─ 使用Zod进行运行时类型检查

Step 3: 逐个文件重构
  ├─ src/api/users.ts: 替换3处
  ├─ src/api/orders.ts: 替换5处
  ├─ src/components/ErrorBoundary.tsx: 更新错误处理逻辑
  └─ ...共修改42个文件

Step 4: 运行测试
  ├─ 执行npm test
  ├─ 发现3个失败的测试用例
  └─ 自动修复测试用例

Step 5: 生成总结报告
  ├─ 修改统计:42个文件,156处替换
  ├─ 潜在风险:2处需要人工审查
  └─ 性能影响:无显著影响

[Accept All] [Review Changes] [Run Tests Again] [Rollback]

Cursor的@符号魔法

Cursor提供了强大的上下文引用能力:

@file:src/components/Button.tsx  - 引用特定文件
@folder:src/hooks               - 引用整个文件夹
@codebase                       - 引用整个代码库
@web:React hooks best practices - 搜索网络资料
@docs:project-guidelines        - 引用项目文档

示例:
"@file:src/types.ts 根据这里的类型定义,@file:src/api/client.ts 添加对应的API调用函数"

定价策略

  • Hobby版:免费(每月100次慢速请求,500次Tab补全)
  • Pro版:$20/月(无限快速请求,无限Tab补全)
  • Business版:$40/用户/月(团队协作功能)

2.2.3 Windsurf:Agentic IDE的先行者

Windsurf(原Codeium)提出了"Agentic IDE"的概念,强调AI Agent的自主性。

Cascade多Agent架构

Windsurf的核心创新是Cascade——一个多Agent协作系统:

Cascade Architecture:

Orchestrator Agent(编排器)
    ├─ 理解用户意图
    ├─ 分解任务为子任务
    ├─ 协调其他Agent
    └─ 监控执行进度
    ↓
┌──────────────┬──────────────┬──────────────┐
│  Plan Agent  │  Code Agent  │ Review Agent │
│  (规划)     │  (编码)     │  (审查)     │
└──────────────┴──────────────┴──────────────┘
    ↓
Execution Engine
    ├─ 文件系统操作
    ├─ 命令执行
    ├─ 代码搜索
    └─ 测试运行

实际使用场景

用户:"实现一个完整的用户管理系统,包括注册、登录、权限控制"

Cascade执行过程:

Phase 1: 需求分析(Plan Agent)
  ├─ 识别需要实现的功能点
  ├─ 确定技术栈(从项目配置推断)
  ├─ 生成实施计划
  └─ 输出:任务列表和依赖关系图

Phase 2: 架构设计(Plan Agent)
  ├─ 设计数据库schema
  ├─ 设计API接口
  ├─ 设计组件结构
  └─ 输出:架构文档和数据流图

Phase 3: 并行开发(Code Agent × 多个)
  ├─ Agent A: 实现数据库模型和迁移
  ├─ Agent B: 实现API路由和控制器
  ├─ Agent C: 实现前端页面和组件
  └─ Agent D: 实现认证和授权逻辑

Phase 4: 集成测试(Review Agent)
  ├─ 检查接口一致性
  ├─ 运行单元测试
  ├─ 检查安全漏洞
  └─ 生成测试报告

Phase 5: 优化建议(Review Agent)
  ├─ 性能优化建议
  ├─ 代码质量评分
  └─ 可维护性评估

总耗时:约15分钟(人工开发通常需要2-3天)

Windsurf的独特功能

  1. Supercomplete(超级补全)

    • 不仅补全代码,还补全整个函数、甚至多文件修改
    • 基于项目上下文的深度理解
  2. Explain(代码解释)

    选中一段代码,Windsurf会生成详细的解释:
    - 这段代码的功能是什么
    - 使用了哪些设计模式
    - 可能的性能影响
    - 潜在的改进点
    
  3. Refactor(智能重构)

    • 自动识别代码坏味道
    • 提供重构方案并自动实施
    • 确保重构后行为一致

定价

  • 免费版:基础功能,有限使用次数
  • Pro版:$12/月,无限使用
  • Teams版:$20/用户/月

2.2.4 IDE层工具选型建议

如果你重视代码补全质量:Cursor > GitHub Copilot > Windsurf 如果你需要多文件编辑:Cursor Composer > Windsurf Cascade > Copilot 如果你预算有限:Windsurf免费版 或 Cursor Hobby版 如果你是团队使用:GitHub Copilot Business(管理功能最全)

推荐组合

  • 个人开发者:Cursor Pro(主力)+ GitHub Copilot(备用)
  • 小型团队:Cursor Business + GitHub Copilot Business
  • 大型企业:GitHub Copilot Enterprise(合规性最好)

2.3 设计转代码层:从视觉到实现的跨越

设计转代码工具试图弥合设计师和开发者之间的鸿沟。它们可以将设计稿、截图甚至自然语言描述转换为可运行的代码。

2.3.1 v0.dev:Vercel的AI UI生成器

v0.dev是Vercel推出的AI UI生成工具,它基于Tailwind CSS和shadcn/ui组件库,能够根据自然语言描述生成可交互的React组件。

技术架构解析

v0.dev技术栈:

用户输入层
    ├─ 自然语言描述
    ├─ 参考图片上传
    └─ 交互式迭代对话
    ↓
意图理解层
    ├─ LLM解析需求
    ├─ 提取关键要素:
    │   ├─ 组件类型(表单、表格、卡片等)
    │   ├─ 功能需求(搜索、分页、筛选等)
    │   ├─ 视觉风格(现代、极简、企业级等)
    │   └─ 技术约束(React、TypeScript等)
    ↓
设计系统匹配层
    ├─ 从shadcn/ui选择基础组件
    ├─ 应用Tailwind CSS设计Tokens
    └─ 生成主题配置
    ↓
代码生成层
    ├─ 生成组件结构
    ├─ 实现交互逻辑
    ├─ 添加类型定义
    └─ 优化代码风格
    ↓
预览与迭代层
    ├─ 实时渲染预览
    ├─ 支持交互操作
    └─ 对话式修改

为什么v0选择shadcn/ui + Tailwind CSS?

这个技术栈选择非常有代表性:

1. Tailwind CSS:AI友好的样式方案

<!-- 传统CSS(AI难以理解) -->
<style>
  .user-card {
    padding: 1rem;
    background-color: #f3f4f6;
    border-radius: 0.5rem;
    box-shadow: 0 1px 3px rgba(0,0,0,0.1);
  }
</style>

<!-- Tailwind CSS(AI容易理解和生成) -->
<div class="p-4 bg-gray-100 rounded-lg shadow-sm">

Tailwind的原子化类名具有以下特点:

  • 语义明确p-4表示padding 1rem,比padding: 1rem更易被AI理解
  • 组合性强:通过组合类名实现复杂样式,类似编程中的函数组合
  • 一致性:设计系统被编码在类名中(如text-smtext-basetext-lg
  • 无需命名:不需要为样式起类名,减少了AI的决策负担

2. shadcn/ui:无头组件库的优势

// shadcn/ui组件结构
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"

const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger

const DialogContent = React.forwardRef<
  React.ElementRef<typeof DialogPrimitive.Content>,
  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
  <DialogPrimitive.Portal>
    <DialogPrimitive.Overlay className="fixed inset-0 bg-black/50" />
    <DialogPrimitive.Content
      ref={ref}
      className={cn(
        "fixed left-[50%] top-[50%] z-50 translate-x-[-50%] translate-y-[-50%]",
        "bg-white rounded-lg shadow-lg p-6",
        className
      )}
      {...props}
    >
      {children}
      <DialogPrimitive.Close className="absolute right-4 top-4">
        <X className="h-4 w-4" />
      </DialogPrimitive.Close>
    </DialogPrimitive.Content>
  </DialogPrimitive.Portal>
))

shadcn/ui的特点:

  • 无头组件:提供逻辑,不提供样式,样式完全可定制
  • Radix UI基础:基于成熟的headless UI库,可访问性良好
  • 代码即组件:组件代码直接复制到项目,而非通过npm安装
  • TypeScript优先:完整的类型定义

v0.dev的实际使用流程

Step 1: 输入需求
用户:"创建一个用户管理表格,包含搜索、分页和筛选功能,
深色主题,现代简洁风格"

Step 2: v0生成初稿(约10秒)
├─ 生成Table组件
├─ 集成Pagination组件
├─ 添加Search输入框
├─ 实现筛选Dropdown
├─ 应用深色主题
└─ 生成模拟数据

Step 3: 交互预览
├─ 用户可以在预览中交互
├─ 测试搜索功能
├─ 测试分页功能
└─ 查看响应式效果

Step 4: 迭代优化
用户:"搜索框放到右侧,添加一个'新增用户'按钮"
v0:实时更新预览

Step 5: 获取代码
├─ 一键复制代码
├─ 支持导出为Next.js项目
└─ 自动安装依赖指引

生成的代码示例

import { useState } from 'react';
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";

interface User {
  id: string;
  name: string;
  email: string;
  role: string;
  status: 'active' | 'inactive';
}

export function UserManagementTable() {
  const [searchQuery, setSearchQuery] = useState('');
  const [selectedRole, setSelectedRole] = useState<string>('all');
  
  // AI生成的模拟数据
  const users: User[] = [
    { id: '1', name: 'Alice Johnson', email: 'alice@example.com', role: 'Admin', status: 'active' },
    { id: '2', name: 'Bob Smith', email: 'bob@example.com', role: 'User', status: 'active' },
    // ...更多数据
  ];
  
  // AI生成的筛选逻辑
  const filteredUsers = users.filter(user => {
    const matchesSearch = user.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
                         user.email.toLowerCase().includes(searchQuery.toLowerCase());
    const matchesRole = selectedRole === 'all' || user.role === selectedRole;
    return matchesSearch && matchesRole;
  });
  
  return (
    <div className="w-full max-w-6xl mx-auto p-6 space-y-4">
      {/* AI生成的工具栏布局 */}
      <div className="flex items-center justify-between">
        <div className="flex items-center gap-4">
          <Input
            placeholder="Search users..."
            value={searchQuery}
            onChange={(e) => setSearchQuery(e.target.value)}
            className="w-64"
          />
          <DropdownMenu>
            <DropdownMenuTrigger asChild>
              <Button variant="outline">
                Role: {selectedRole === 'all' ? 'All' : selectedRole}
              </Button>
            </DropdownMenuTrigger>
            <DropdownMenuContent>
              <DropdownMenuItem onClick={() => setSelectedRole('all')}>
                All Roles
              </DropdownMenuItem>
              <DropdownMenuItem onClick={() => setSelectedRole('Admin')}>
                Admin
              </DropdownMenuItem>
              <DropdownMenuItem onClick={() => setSelectedRole('User')}>
                User
              </DropdownMenuItem>
            </DropdownMenuContent>
          </DropdownMenu>
        </div>
        <Button>Add User</Button>
      </div>
      
      {/* AI生成的表格 */}
      <div className="border rounded-lg">
        <Table>
          <TableHeader>
            <TableRow>
              <TableHead>Name</TableHead>
              <TableHead>Email</TableHead>
              <TableHead>Role</TableHead>
              <TableHead>Status</TableHead>
              <TableHead>Actions</TableHead>
            </TableRow>
          </TableHeader>
          <TableBody>
            {filteredUsers.map((user) => (
              <TableRow key={user.id}>
                <TableCell className="font-medium">{user.name}</TableCell>
                <TableCell>{user.email}</TableCell>
                <TableCell>{user.role}</TableCell>
                <TableCell>
                  <span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
                    user.status === 'active' 
                      ? 'bg-green-100 text-green-800' 
                      : 'bg-gray-100 text-gray-800'
                  }`}>
                    {user.status}
                  </span>
                </TableCell>
                <TableCell>
                  <Button variant="ghost" size="sm">Edit</Button>
                </TableCell>
              </TableRow>
            ))}
          </TableBody>
        </Table>
      </div>
    </div>
  );
}

v0.dev的局限性

  1. 可访问性缺失:生成的代码往往需要人工补充aria属性
  2. 业务逻辑空白:只生成UI,不生成API调用和业务逻辑
  3. 复杂交互限制:对于复杂的状态管理和动画,能力有限
  4. 设计系统锁定:必须使用shadcn/ui,迁移到其他组件库需要大量修改

2.3.2 Screenshot-to-Code:开源的视觉转代码标杆

Screenshot-to-Code是GitHub上68,000+ Star的开源项目,由Abi Raja开发。它可以将截图或Figma设计稿转换为代码,支持7种技术栈。

技术架构深度解析

Screenshot-to-Code架构:

输入层
    ├─ 图片上传(PNG/JPG)
    ├─ Figma URL导入
    └─ 视频上传(实验性)
    ↓
视觉解析层(Vision Parser)
    ├─ 多模态模型(GPT-4V/Claude 3/Gemini 2.5 Pro)
    ├─ 分析内容:
    │   ├─ 布局结构(Flex/Grid/Positioning)
    │   ├─ 组件识别(Button/Input/Card等)
    │   ├─ 样式提取(Color/Typography/Spacing)
    │   ├─ 图片检测(需要提取的资源)
    │   └─ 文本内容(OCR提取)
    ↓
布局还原层(Layout Engine)
    ├─ 计算元素位置和尺寸
    ├─ 识别父子关系和层级
    ├─ 推断布局策略
    └─ 生成DOM结构
    ↓
代码生成层(Code Generator)
    ├─ 技术栈选择(React/Vue/Angular/HTML等)
    ├─ 样式方案选择(Tailwind/Inline CSS/CSS Modules)
    ├─ 生成组件代码
    └─ 优化代码结构
    ↓
迭代优化层(Refinement)
    ├─ 多模型并行生成(2个变体)
    ├─ 用户选择和反馈
    └─ 对话式微调

多模型并行生成策略

这是Screenshot-to-Code的核心创新之一:

并行生成流程:

用户上传图片
    ↓
[Thread 1]              [Thread 2]
GPT-4 Vision            Claude 3 Opus
    ↓                       ↓
生成代码变体A          生成代码变体B
(注重精确度)          (注重语义化)
    ↓                       ↓
    └──────────┬──────────┘
               ↓
          展示给用户
               ↓
    ┌──────────┴──────────┐
选择变体A              选择变体B
    ↓                       ↓
系统学习偏好          系统学习偏好
(后续优先使用      (后续优先使用
 GPT-4)              Claude)

支持的7种技术栈

  1. HTML + Tailwind CSS(最常用)
  2. React + Tailwind CSS
  3. Vue + Tailwind CSS
  4. Angular
  5. Svelte
  6. React + Bootstrap
  7. Plain HTML + CSS

实际使用案例

场景:将一个登录页面截图转换为代码

输入:Login Page Screenshot
    ├─ Logo(顶部居中)
    ├─ Email输入框
    ├─ Password输入框
    ├─ "Remember me"复选框
    ├─ "Forgot password?"链接
    ├─ "Sign In"按钮
    └─ "Don't have an account? Sign up"链接

处理过程:
1. GPT-4V分析图片,提取所有视觉元素
2. Layout Engine推断:垂直居中布局,表单宽度约400px
3. Code Generator生成React代码:

```tsx
export default function LoginPage() {
  return (
    <div className="min-h-screen bg-gray-50 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
      <div className="max-w-md w-full space-y-8">
        {/* Logo */}
        <div className="text-center">
          <img className="mx-auto h-12 w-auto" src="/logo.svg" alt="Company" />
          <h2 className="mt-6 text-3xl font-extrabold text-gray-900">
            Sign in to your account
          </h2>
        </div>
        
        {/* Form */}
        <form className="mt-8 space-y-6" action="#" method="POST">
          <div className="rounded-md shadow-sm -space-y-px">
            <div>
              <label htmlFor="email-address" className="sr-only">
                Email address
              </label>
              <input
                id="email-address"
                name="email"
                type="email"
                autoComplete="email"
                required
                className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
                placeholder="Email address"
              />
            </div>
            <div>
              <label htmlFor="password" className="sr-only">
                Password
              </label>
              <input
                id="password"
                name="password"
                type="password"
                autoComplete="current-password"
                required
                className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
                placeholder="Password"
              />
            </div>
          </div>

          <div className="flex items-center justify-between">
            <div className="flex items-center">
              <input
                id="remember-me"
                name="remember-me"
                type="checkbox"
                className="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
              />
              <label htmlFor="remember-me" className="ml-2 block text-sm text-gray-900">
                Remember me
              </label>
            </div>

            <div className="text-sm">
              <a href="#" className="font-medium text-indigo-600 hover:text-indigo-500">
                Forgot your password?
              </a>
            </div>
          </div>

          <div>
            <button
              type="submit"
              className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
            >
              Sign in
            </button>
          </div>
        </form>
        
        {/* Footer */}
        <p className="mt-2 text-center text-sm text-gray-600">
          Don't have an account?{' '}
          <a href="#" className="font-medium text-indigo-600 hover:text-indigo-500">
            Sign up
          </a>
        </p>
      </div>
    </div>
  );
}

精度评估

根据项目文档和用户反馈:

  • 布局还原度:90-95%(像素级精确)
  • 颜色匹配度:95%+(使用Tailwind最接近的颜色)
  • 字体匹配度:85%(依赖系统字体)
  • 交互功能:30%(仅静态HTML,无JS逻辑)
  • 可访问性:60%(需要人工补充aria属性)

Screenshot-to-Code的局限

  1. 静态代码:生成的代码是静态HTML,没有交互逻辑
  2. 图片资源:无法自动提取和上传图片资源
  3. 响应式:主要还原截图的特定尺寸,其他尺寸需要手动调整
  4. 复杂动画:无法还原复杂的CSS动画和过渡效果

2.3.3 设计转代码层工具对比

工具 开源 技术栈支持 交互生成 迭代能力 价格
v0.dev React only 基础 免费+付费
Screenshot-to-Code 7种 免费
Galileo AI React/HTML 基础 付费
Uizard React/HTML 付费
Anima React/Vue/Angular 付费

选型建议

  • 快速原型:v0.dev(质量最高)
  • 设计还原:Screenshot-to-Code(免费且开源)
  • 团队协作:Figma-to-Code插件(与Figma工作流集成)

2.4 全栈生成层:从想法到应用的一站式体验

如果说IDE集成层是"辅助开发",设计转代码层是"生成UI",那么全栈生成层则是"生成完整应用"。这一层的工具不仅可以生成前端代码,还能处理后端逻辑、数据库、部署等全流程。

2.4.1 Bolt.new:WebContainer技术的革命

Bolt.new是StackBlitz团队推出的AI开发环境,自2024年9月发布以来迅速获得16,000+ Star。它的核心创新是WebContainer技术——在浏览器内运行完整Node.js环境,实现了真正的"零配置即时开发"。

WebContainer技术深度解析

什么是WebContainer?

WebContainer是StackBlitz开发的一项革命性技术,它允许在浏览器中运行完整的Node.js运行时环境。这不是模拟或转译,而是真正的Node.js在浏览器中运行。

WebContainer架构:

传统开发环境:              WebContainer环境:
┌─────────────┐           ┌─────────────────────┐
│   本地OS     │           │      浏览器          │
│  ┌───────┐  │           │  ┌───────────────┐  │
│  │Node.js│  │           │  │  WebContainer  │  │
│  │├─V8  │  │           │  │  ├─Node.js运行时│  │
│  │├─libuv│  │           │  │  ├─文件系统    │  │
│  │├─npm │  │           │  │  ├─npm/yarn   │  │
│  │└─... │  │           │  │  ├─Dev Server │  │
│  └───────┘  │           │  │  └─Terminal    │  │
└─────────────┘           │  └───────────────┘  │
                          └─────────────────────┘
                                  ↑
                            浏览器安全沙箱

技术实现原理

  1. WebAssembly编译:将Node.js核心模块编译为WebAssembly,在浏览器中运行
  2. 虚拟文件系统:在浏览器内存中模拟完整的文件系统,支持读写操作
  3. 进程模拟:使用Web Workers模拟Node.js的多进程能力
  4. 网络拦截:拦截网络请求,模拟HTTP/HTTPS服务端能力
// WebContainer核心API示例
import { WebContainer } from '@webcontainer/api';

// 启动WebContainer实例
const webcontainer = await WebContainer.boot();

// 挂载文件系统
await webcontainer.mount({
  'package.json': {
    file: {
      contents: JSON.stringify({
        name: 'my-app',
        dependencies: { 'next': 'latest' }
      })
    }
  },
  'pages/index.js': {
    file: {
      contents: 'export default function Home() { return <h1>Hello</h1>; }'
    }
  }
});

// 安装依赖
const installProcess = await webcontainer.spawn('npm', ['install']);
installProcess.output.pipeTo(new WritableStream({
  write(data) { console.log(data); }
}));

// 启动开发服务器
const devProcess = await webcontainer.spawn('npm', ['run', 'dev']);

// 监听端口
webcontainer.on('port', (port, url) => {
  console.log(`Server ready at ${url}`);
});

WebContainer vs 传统方案对比

特性 本地Node.js 云端虚拟机 WebContainer
启动时间 秒级 分钟级 毫秒级
网络依赖 需要网络 强依赖 离线可用
资源占用 低(浏览器沙箱)
安全性 依赖系统安全 依赖云端隔离 浏览器安全沙箱
成本 免费 按量付费 免费(客户端运行)
可分享性 需要环境配置 需要账号权限 URL即可分享
Bolt.new的AI集成

Bolt.new将WebContainer与AI深度集成,实现了"对话式全栈开发":

Bolt.new工作流程:

用户输入:"创建一个待办事项应用,使用Next.js和Prisma"
    ↓
AI理解需求
    ├─ 识别技术栈:Next.js + React + TypeScript
    ├─ 识别数据库:Prisma + SQLite
    ├─ 识别功能:CRUD操作、状态管理
    └─ 生成项目结构和文件清单
    ↓
生成代码文件
    ├─ package.json(依赖配置)
    ├─ prisma/schema.prisma(数据模型)
    ├─ src/app/page.tsx(主页面)
    ├─ src/components/TodoList.tsx(组件)
    ├─ src/lib/prisma.ts(数据库客户端)
    └─ API路由文件
    ↓
WebContainer执行
    ├─ 挂载文件到虚拟文件系统
    ├─ 运行npm install(在浏览器中!)
    ├─ 运行prisma migrate(创建数据库)
    ├─ 启动Next.js开发服务器
    └─ 在iframe中展示预览
    ↓
实时预览和迭代
    ├─ 用户查看运行中的应用
    ├─ 用户提出修改:"添加分类功能"
    └─ AI理解、生成代码、热更新

实际案例演示

场景:开发一个博客系统

用户:"创建一个博客应用,功能包括:
1. 文章列表展示
2. 文章详情页
3. 评论功能
4. 使用Markdown写文章
5. 暗色主题支持"

Bolt.new执行过程(总计约3分钟):

[00:00-00:30] 项目初始化
├─ 创建Next.js 14项目(App Router)
├─ 配置TypeScript
├─ 安装依赖:
│   ├─ next@14
│   ├─ react@18
│   ├─ @tailwindcss/typography(Markdown样式)
│   ├─ react-markdown(Markdown渲染)
│   ├─ gray-matter(Frontmatter解析)
│   └─ date-fns(日期格式化)
└─ 配置Tailwind CSS和暗色模式

[00:30-01:30] 核心功能实现
├─ 创建文件系统:
│   ├─ app/page.tsx(文章列表)
│   ├─ app/posts/[slug]/page.tsx(文章详情)
│   ├─ components/PostCard.tsx(文章卡片)
│   ├─ components/CommentSection.tsx(评论组件)
│   ├─ lib/posts.ts(文章数据获取)
│   └─ content/posts/(Markdown文章目录)
├─ 实现功能:
│   ├─ 读取Markdown文件
│   ├─ 解析Frontmatter(标题、日期、标签)
│   ├─ 渲染Markdown内容
│   ├─ 评论提交和展示
│   └─ 暗色模式切换
└─ 添加示例文章

[01:30-02:30] 样式和优化
├─ 设计暗色主题配色
├─ 响应式布局优化
├─ 添加加载动画
├─ 优化字体和排版
└─ 添加SEO元数据

[02:30-03:00] 部署准备
├─ 配置Vercel部署
├─ 生成部署链接
└─ 提供一键部署按钮

结果:
✓ 可运行的博客应用
✓ 在线预览URL
✓ 可下载源代码
✓ 一键部署到Vercel

Bolt.new的技术优势

  1. 真正的即时开发

    • 无需安装Node.js
    • 无需配置开发环境
    • 打开浏览器即可开始
    • 适合教学、演示、快速原型
  2. 完整的开发体验

    • 终端访问(npm、git等命令)
    • 文件系统操作
    • 开发服务器运行
    • 热更新(HMR)
  3. AI深度集成

    • 理解自然语言需求
    • 生成完整项目结构
    • 自动安装依赖
    • 自动运行和调试
    • 对话式迭代修改
  4. 一键部署

    • 直接部署到Vercel、Netlify
    • 生成可分享的URL
    • 支持自定义域名

Bolt.new的局限性

  1. 性能限制

    • 浏览器内存限制(通常<4GB)
    • 大型项目可能运行缓慢
    • 不适合计算密集型任务
  2. 功能限制

    • 无法访问本地文件系统
    • 某些原生模块无法使用
    • 数据库限于SQLite(文件型)
  3. 网络依赖

    • 首次加载需要下载WebContainer运行时
    • npm包需要从registry下载
    • 离线功能有限

适用场景

  • ✅ 教学和学习(零配置环境)
  • ✅ 快速原型验证
  • ✅ 代码演示和分享
  • ✅ 面试编程测试
  • ❌ 大型企业级项目
  • ❌ 高性能计算需求
  • ❌ 本地资源依赖型项目

2.4.2 Lovable:面向非技术用户的AI开发平台

Lovable(原名GPT Engineer)定位为"AI软件工程师",它更进一步,让非技术用户也能创建应用。

产品定位分析

目标用户群体:
├─ 产品经理(快速验证想法)
├─ 设计师(将设计转化为应用)
├─ 创业者(MVP开发)
├─ 小型企业主(内部工具)
└─ 非技术背景的个人用户

核心卖点:
├─ 无需编写代码
├─ 自然语言描述需求
├─ 全流程自动化(设计→开发→部署)
├─ 可视化编辑和迭代
└─ 一键发布上线

工作流程

Step 1: 需求对话
用户:"我想做一个记账应用,可以记录收入和支出,
       按分类统计,有图表展示"

Lovable AI:
├─ 追问澄清:"需要多用户支持吗?"
├─ 追问澄清:"需要什么类型的图表?"
├─ 追问澄清:"需要数据导出功能吗?"
└─ 生成需求文档

Step 2: 技术方案
Lovable AI:
├─ 推荐技术栈:React + Tailwind + Recharts
├─ 推荐数据库:Firebase(简单易用)
├─ 展示原型设计
└─ 用户确认

Step 3: 自动生成
Lovable AI:
├─ 生成项目结构
├─ 生成所有组件代码
├─ 配置数据库连接
├─ 实现认证(如需要)
└─ 生成测试数据

Step 4: 可视化编辑
用户:
├─ 查看实时预览
├─ 拖拽调整布局
├─ 点击修改文案
├─ 选择更换配色
└─ 对话式功能调整

Step 5: 一键部署
Lovable:
├─ 自动构建优化
├─ 部署到云端
├─ 生成可访问的URL
├─ 配置自定义域名(可选)
└─ 提供后续维护支持

与Bolt.new的区别

维度 Bolt.new Lovable
目标用户 开发者 非技术用户
交互方式 代码为主,AI辅助 自然语言+可视化
技术栈 用户指定 AI推荐+用户选择
自定义程度 高(可编辑所有代码) 中(模板+配置)
部署 多平台选择 一体化托管
价格 免费(基础功能) 付费(按项目)

市场影响分析

Lovable代表了一种新的趋势——"无代码+AI"的结合:

传统无代码平台的问题:
├─ 灵活性受限(只能拖拽预设组件)
├─ 学习曲线陡峭(需要理解平台逻辑)
├─ 扩展困难(超出平台能力就无法实现)
└─ 性能问题(生成的代码质量不高)

AI增强的无代码平台:
├─ 灵活性提升(自然语言描述任意功能)
├─ 学习曲线平缓(对话式交互)
├─ 扩展性强(AI可以生成自定义代码)
└─ 代码质量改善(AI生成的代码越来越高质量)

长期影响:
├─ 简单应用开发完全 democratized(民主化)
├─ 专业开发者专注复杂系统和创新
├─ 外包市场萎缩(简单需求被AI满足)
└─ "产品经理+AI"可以替代初级开发者

2.4.3 全栈生成层工具对比

工具 技术栈 数据库支持 部署能力 目标用户 价格
Bolt.new 任意(浏览器运行) SQLite Vercel/Netlify 开发者 免费+付费
Lovable React为主 Firebase/Supabase 托管部署 非技术用户 付费
Replit Agent 多语言 ReplitDB Replit托管 学习者 免费+付费
V0.dev Full Next.js 任意(需配置) Vercel 开发者 免费+付费

选型建议

  • 开发者快速原型:Bolt.new
  • 非技术用户:Lovable
  • 教学场景:Replit Agent
  • Vercel生态:v0.dev

2.5 运行时层:Vercel AI SDK的深度解析

如果说其他工具是"AI辅助开发",Vercel AI SDK则是"AI原生开发"的基础设施。它提供了将AI能力集成到前端应用的完整技术栈。

2.5.1 Provider抽象:统一多模型的架构设计

问题背景

不同的AI供应商(OpenAI、Anthropic、Google等)有不同的API格式和参数,切换供应商需要大量修改代码。

// 直接使用OpenAI API(供应商锁定)
import OpenAI from 'openai';

const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

const completion = await openai.chat.completions.create({
  model: 'gpt-4',
  messages: [{ role: 'user', content: 'Hello' }],
});

// 如果要切换到Anthropic,需要完全重写这部分代码
// API格式、参数名、响应结构都不同

Vercel AI SDK的解决方案

Vercel AI SDK提供了统一的Provider抽象层,通过四层消息架构实现跨模型供应商的无缝切换。

四层消息架构(4-Level Message Architecture):

┌─────────────────────────────────────────────────────────────┐
│  Layer 4: UI Messages (前端渲染层)                           │
│  - 用于React/Vue/Angular/Svelte组件渲染                     │
│  - 包含text、reasoning、tool、file等Part类型                │
│  - 支持渐进式流式渲染                                        │
├─────────────────────────────────────────────────────────────┤
│  Layer 3: Model Messages (开发者体验层)                      │
│  - 用户友好的抽象,用于generate/stream调用                  │
│  - 简化的接口设计                                           │
├─────────────────────────────────────────────────────────────┤
│  Layer 2: Language Model Messages (标准化层)                 │
│  - LanguageModelV4接口规范                                  │
│  - 跨Provider稳定的标准格式                                 │
│  - 统一的Tool Calling规范                                   │
├─────────────────────────────────────────────────────────────┤
│  Layer 1: Provider Messages (供应商适配层)                   │
│  - OpenAI/Anthropic/Google等具体API格式                     │
│  - 各供应商特有的参数和格式转换                              │
└─────────────────────────────────────────────────────────────┘

代码示例

// Vercel AI SDK - Provider抽象
import { generateText } from 'ai';
import { openai } from '@ai-sdk/openai';
import { anthropic } from '@ai-sdk/anthropic';
import { google } from '@ai-sdk/google';

// 使用OpenAI
const result1 = await generateText({
  model: openai('gpt-4-turbo'),
  prompt: 'What is the meaning of life?',
});

// 切换到Anthropic(只需要改这一行)
const result2 = await generateText({
  model: anthropic('claude-3-opus-20240229'),
  prompt: 'What is the meaning of life?',
});

// 切换到Google(同样简单)
const result3 = await generateText({
  model: google('gemini-1.5-pro-latest'),
  prompt: 'What is the meaning of life?',
});

// 其他代码完全不变!

支持的Provider(截至2024年):

// 主流供应商
import { openai } from '@ai-sdk/openai';           // OpenAI
import { anthropic } from '@ai-sdk/anthropic';     // Anthropic
import { google } from '@ai-sdk/google';           // Google
import { azure } from '@ai-sdk/azure';             // Azure OpenAI
import { bedrock } from '@ai-sdk/amazon-bedrock';  // AWS Bedrock

// 开源模型
import { ollama } from 'ollama-ai-provider';       // Ollama本地模型
import { mistral } from '@ai-sdk/mistral';         // Mistral AI
import { groq } from '@ai-sdk/groq';               // Groq
import { perplexity } from '@ai-sdk/perplexity';   // Perplexity

// 国内供应商
import { deepseek } from '@ai-sdk/deepseek';       // DeepSeek
import { qwen } from '@ai-sdk/qwen';               // 通义千问

// 自定义Provider
const customProvider = createProvider({
  apiKey: process.env.CUSTOM_API_KEY,
  baseURL: 'https://api.custom.ai/v1',
  // ...其他配置
});

Provider抽象的技术价值

  1. 无供应商锁定:随时切换AI供应商,无需重写业务逻辑
  2. 成本优化:根据不同任务选择性价比最高的模型
  3. 风险分散:某个供应商服务中断时,可快速切换
  4. 实验便利:方便对比不同模型的效果

2.5.2 Streaming架构:实时交互体验的核心

为什么需要Streaming?

传统AI调用是阻塞式的:等待完整响应后才能展示,用户体验差(等待时间长)。

Streaming让AI响应像打字一样实时展示,极大提升用户体验。

对比:

传统方式(阻塞):
用户发送消息 → 等待5秒 → 一次性显示完整回复
(用户感觉卡顿,不知道是否在处理)

Streaming方式(流式):
用户发送消息 → 立即开始显示 → 逐字出现 → 完整回复
(用户感知响应快,有实时反馈)

技术实现

// 服务端:流式生成
import { streamText } from 'ai';
import { openai } from '@ai-sdk/openai';

export async function POST(req: Request) {
  const { messages } = await req.json();
  
  const result = streamText({
    model: openai('gpt-4-turbo'),
    messages,
  });
  
  // 返回流式响应
  return result.toDataStreamResponse();
}
// 客户端:流式消费
import { useChat } from '@ai-sdk/react';

function ChatComponent() {
  const { messages, input, handleInputChange, handleSubmit, isLoading } = useChat();
  
  return (
    <div className="chat-container">
      {/* 消息列表 */}
      {messages.map(message => (
        <div key={message.id} className={`message ${message.role}`}>
          {/* 消息内容逐字显示 */}
          {message.content}
          
          {/* 流式状态指示 */}
          {message.role === 'assistant' && 
           message.status === 'streaming' && (
            <span className="cursor-blink"></span>
          )}
        </div>
      ))}
      
      {/* 输入框 */}
      <form onSubmit={handleSubmit}>
        <input
          value={input}
          onChange={handleInputChange}
          placeholder="输入消息..."
          disabled={isLoading}
        />
        <button type="submit" disabled={isLoading}>
          发送
        </button>
      </form>
    </div>
  );
}

Streaming协议详解

数据传输格式:

1. Server-Sent Events (SSE)
   Content-Type: text/event-stream
   
   data: {"type":"text","content":"Hello"}
   
   data: {"type":"text","content":" world"}
   
   data: {"type":"finish","reason":"stop"}

2. 支持的消息类型
   ├─ text: 文本内容
   ├─ reasoning: 推理过程(如o1模型的思维链)
   ├─ tool_call: 工具调用请求
   ├─ tool_result: 工具调用结果
   ├─ error: 错误信息
   └─ finish: 完成信号

高级Streaming功能

// 1. 带工具调用的流式响应
const result = streamText({
  model: openai('gpt-4-turbo'),
  messages,
  tools: {  // 定义工具
    getWeather: {
      description: '获取天气信息',
      parameters: z.object({
        city: z.string(),
        date: z.string().optional(),
      }),
      execute: async ({ city, date }) => {
        return await fetchWeather(city, date);
      },
    },
  },
  // 工具调用时的回调
  onToolCall: ({ toolCall }) => {
    console.log(`调用工具: ${toolCall.toolName}`);
  },
});

// 2. 对象流式生成(JSON Stream)
const result = streamObject({
  model: openai('gpt-4-turbo'),
  schema: z.object({
    title: z.string(),
    description: z.string(),
    steps: z.array(z.object({
      step: z.number(),
      action: z.string(),
    })),
  }),
  prompt: '生成一个学习计划',
});

// 流式获取部分解析的JSON对象
for await (const partialObject of result.partialObjectStream) {
  console.log(partialObject); 
  // 可能输出: { title: "学习计划" }
  // 然后: { title: "学习计划", description: "为期3个月的学习计划" }
  // 渐进式完善...
}

2.5.3 Tool Calling:连接AI与外部世界的桥梁

什么是Tool Calling?

Tool Calling(工具调用/函数调用)允许AI在生成内容的过程中,调用外部函数来获取数据或执行操作。

这让AI从"只能对话"变为"可以行动"。

使用场景:

用户:"北京今天天气怎么样?"

没有Tool Calling:
AI:"抱歉,我无法获取实时天气信息。"

有Tool Calling:
AI → 调用getWeather工具(city: "北京") → 获取数据
AI:"北京今天晴天,25°C,适合出行。"

基本用法

import { generateText, tool } from 'ai';
import { openai } from '@ai-sdk/openai';
import { z } from 'zod';

// 定义工具
const weatherTool = tool({
  description: '获取指定城市的天气信息',
  parameters: z.object({
    city: z.string().describe('城市名称,如"北京"、"上海"'),
    date: z.string().optional().describe('日期,格式:YYYY-MM-DD,默认为今天'),
  }),
  execute: async ({ city, date }) => {
    // 调用天气API
    const response = await fetch(
      `https://api.weather.com/v1/current?city=${city}&date=${date || 'today'}`
    );
    return response.json();
  },
});

const calculatorTool = tool({
  description: '执行数学计算',
  parameters: z.object({
    expression: z.string().describe('数学表达式,如"2+2"、"sqrt(16)"'),
  }),
  execute: async ({ expression }) => {
    // 安全计算
    return safeEvaluate(expression);
  },
});

// AI对话中使用工具
const result = await generateText({
  model: openai('gpt-4-turbo'),
  messages: [
    { role: 'user', content: '北京今天天气怎么样?适合穿什么衣服?' }
  ],
  tools: {
    weather: weatherTool,
    calculator: calculatorTool,
  },
  // 最多允许10轮工具调用
  maxToolRoundtrips: 10,
});

console.log(result.text);
// 输出:"北京今天晴天,气温25°C。建议穿短袖加薄外套。"

多工具协作

// 复杂的工具协作场景
const result = await generateText({
  model: openai('gpt-4-turbo'),
  messages: [{ 
    role: 'user', 
    content: '帮我订一张明天北京到上海的机票,要早上出发的' 
  }],
  tools: {
    // 工具1:查询航班
    searchFlights: tool({
      description: '搜索航班',
      parameters: z.object({
        from: z.string(),
        to: z.string(),
        date: z.string(),
        preferredTime: z.enum(['morning', 'afternoon', 'evening']),
      }),
      execute: async (params) => {
        return await flightAPI.search(params);
      },
    }),
    
    // 工具2:获取用户信息(用于自动填充)
    getUserInfo: tool({
      description: '获取当前用户信息',
      parameters: z.object({}),
      execute: async () => {
        return await getCurrentUser();
      },
    }),
    
    // 工具3:预订航班
    bookFlight: tool({
      description: '预订航班',
      parameters: z.object({
        flightId: z.string(),
        passengerInfo: z.object({
          name: z.string(),
          idCard: z.string(),
          phone: z.string(),
        }),
      }),
      execute: async (params) => {
        return await flightAPI.book(params);
      },
    }),
  },
});

// AI会自动:
// 1. 调用getUserInfo获取用户信息
// 2. 调用searchFlights搜索明天早上的航班
// 3. 向用户确认具体航班
// 4. 调用bookFlight完成预订

前端UI中的Tool Calling

// Tool Calling的可视化展示
function ChatWithTools() {
  const { messages, input, handleSubmit } = useChat({
    api: '/api/chat',
  });
  
  return (
    <div>
      {messages.map(message => (
        <div key={message.id}>
          {/* 文本内容 */}
          {message.content && (
            <div className="message-content">{message.content}</div>
          )}
          
          {/* 工具调用展示 */}
          {message.toolCalls?.map(toolCall => (
            <ToolCallCard 
              key={toolCall.toolCallId}
              toolCall={toolCall}
              toolResult={message.toolResults?.find(
                r => r.toolCallId === toolCall.toolCallId
              )}
            />
          ))}
        </div>
      ))}
    </div>
  );
}

// 工具调用卡片组件
function ToolCallCard({ toolCall, toolResult }) {
  return (
    <div className="tool-call-card">
      <div className="tool-header">
        <span className="tool-icon">🔧</span>
        <span className="tool-name">{toolCall.toolName}</span>
        <span className="tool-status">
          {toolResult ? '✓ 完成' : '⏳ 执行中...'}
        </span>
      </div>
      
      <div className="tool-args">
        <details>
          <summary>参数</summary>
          <pre>{JSON.stringify(toolCall.args, null, 2)}</pre>
        </details>
      </div>
      
      {toolResult && (
        <div className="tool-result">
          <details>
            <summary>结果</summary>
            <pre>{JSON.stringify(toolResult.result, null, 2)}</pre>
          </details>
        </div>
      )}
    </div>
  );
}

2.5.4 Vercel AI SDK的生态系统

框架集成

// React
import { useChat, useCompletion, useObject } from '@ai-sdk/react';

// Vue
import { useChat } from '@ai-sdk/vue';

// Svelte
import { useChat } from '@ai-sdk/svelte';

// Angular
import { useChat } from '@ai-sdk/angular';

// Solid
import { useChat } from '@ai-sdk/solid';

高级功能

// 1. 多模态(图片、音频、视频)
const result = await generateText({
  model: openai('gpt-4-vision-preview'),
  messages: [
    {
      role: 'user',
      content: [
        { type: 'text', text: '描述这张图片' },
        { type: 'image', image: new URL('https://example.com/image.jpg') },
      ],
    },
  ],
});

// 2. 嵌入(Embedding)
const { embedding } = await embed({
  model: openai.embedding('text-embedding-3-small'),
  value: '需要向量化的文本',
});

// 3. 图像生成
const { image } = await generateImage({
  model: openai.image('dall-e-3'),
  prompt: '一只猫在太空',
});

// 4. 语音转文字
const { text } = await transcribe({
  model: openai.transcription('whisper-1'),
  audio: audioFile,
});

2.6 技术选型决策框架和实际案例分析

2.6.1 决策框架

面对众多AI工具,如何做出选择?以下是系统化的决策框架。

第一步:明确需求场景

问题清单:
□ 是日常开发还是原型验证?
□ 是个人使用还是团队协作?
□ 是前端开发还是全栈开发?
□ 需要集成到现有项目还是从零开始?
□ 对代码质量的要求是?(探索性/生产级)
□ 团队的技术水平是?(初级/高级)

第二步:评估维度矩阵

维度 权重 评估标准 评分(1-5)
功能匹配度 30% 是否满足核心需求? ⭐⭐⭐⭐⭐
学习成本 20% 上手难度如何? ⭐⭐⭐
生态成熟度 20% 社区活跃度、文档质量 ⭐⭐⭐⭐
成本效益 15% 免费/付费?性价比? ⭐⭐⭐⭐
可迁移性 15% 是否容易迁移? ⭐⭐⭐

第三步:场景化选型指南

场景1:企业级生产项目
├─ IDE:Cursor(代码质量高)
├─ 运行时:Vercel AI SDK(稳定性好)
├─ UI生成:v0.dev(与Next.js配合好)
└─ 避免:Bolt.new(性能限制)

场景2:快速原型验证
├─ 全栈生成:Bolt.new(最快)
├─ UI生成:v0.dev(质量高)
├─ 代码辅助:Copilot(通用)
└─ 部署:Vercel(一键部署)

场景3:教学演示
├─ 环境:Bolt.new(零配置)
├─ 演示:v0.dev(可视化好)
└─ 文档:AI生成(效率高)

场景4:开源项目
├─ IDE:Cursor(免费版够用)
├─ 辅助:GitHub Copilot(开源免费)
└─ 避免:付费工具(成本控制)

2.6.2 实际案例分析

案例:电商后台管理系统

项目背景:
├─ 团队:5人前端团队
├─ 技术栈:Next.js + TypeScript + Tailwind
├─ 周期:3个月
├─ 需求:商品管理、订单管理、用户管理、数据分析
└─ 质量要求:生产级,高可维护性

工具选型决策:

1. 日常开发:Cursor Pro
   理由:
   ├─ Composer模式支持多文件编辑,适合复杂功能
   ├─ 与VS Code生态兼容,团队迁移成本低
   ├─ 代码质量高,适合生产代码
   └─ 成本:$20/人/月,团队$100/月

2. AI功能集成:Vercel AI SDK
   理由:
   ├─ 与Next.js深度集成(同一团队)
   ├─ Provider抽象,避免供应商锁定
   ├─ TypeScript支持好
   └─ 开源免费,无额外成本

3. UI原型:v0.dev
   理由:
   ├─ 生成shadcn/ui组件,与项目技术栈一致
   ├─ 质量高,减少修改工作量
   └─ 免费使用,成本为0

4. 排除:
   ├─ Bolt.new:性能限制,不适合大型项目
   ├─ Lovable:定制化不足
   └─ Windsurf:团队已有Cursor,功能重复

实施效果:
├─ 开发效率提升:40%
├─ Bug数量:持平(质量把控严格)
├─ 团队满意度:高
└─ 总成本:$100/月(可接受)

案例:创业公司MVP开发

项目背景:
├─ 团队:2人(创始人+设计师,均非技术背景)
├─ 需求:验证产品想法,快速上线
├─ 功能:用户注册、内容发布、评论、支付
├─ 时间:2周
└─ 质量要求:可用即可,后续重构

工具选型决策:

1. 全栈开发:Lovable
   理由:
   ├─ 非技术用户友好
   ├─ 全流程自动化,无需懂代码
   ├─ 一键部署上线
   └─ 成本:$50/月,2周使用成本低

2. 辅助验证:Bolt.new
   理由:
   ├─ 快速验证技术可行性
   ├─ 免费使用
   └─ 可以导出代码供后续开发

3. 排除:
   ├─ Cursor:学习曲线陡峭
   ├─ Vercel AI SDK:需要代码能力
   └─ v0.dev:仅生成UI,不解决全栈需求

实施效果:
├─ 2周内完成MVP上线
├─ 成功验证产品想法
├─ 获得种子轮融资
└─ 后续聘请专业团队重构

2.6.3 成本效益分析

AI工具投资回报率(ROI)计算:

假设:
├─ 开发者年薪:$100,000
├─ 工作小时:2,000小时/年
├─ 时薪:$50
├─ AI工具成本:$50/月 = $600/年

场景1:效率提升20%
├─ 节省时间:400小时/年
├─ 节省成本:400 × $50 = $20,000
├─ ROI:($20,000 - $600) / $600 = 3,233%

场景2:效率提升50%
├─ 节省时间:1,000小时/年
├─ 节省成本:1,000 × $50 = $50,000
├─ ROI:($50,000 - $600) / $600 = 8,233%

结论:AI工具的投资回报率极高,即使效率只提升20%,ROI也超过30倍。

小结

第二章详细介绍了AI前端开发的四层工具生态:

  1. IDE集成层:Cursor、Copilot、Windsurf提供实时代码辅助
  2. 设计转代码层:v0.dev、Screenshot-to-Code弥合设计与开发的鸿沟
  3. 全栈生成层:Bolt.new(WebContainer技术)、Lovable实现零配置开发
  4. 运行时层:Vercel AI SDK提供生产级的AI能力集成

技术选型建议:

  • 生产级项目:Cursor + Vercel AI SDK
  • 快速原型:Bolt.new + v0.dev
  • 非技术用户:Lovable
  • 教学演示:Bolt.new

工具的投资回报率极高,建议团队根据自身情况选择合适的工具组合。


下章预告

第三章《范式的跃迁——从组件驱动到意图驱动》将探讨:

  • 组件驱动 vs 意图驱动的代码范式对比
  • 架构层面的三大转变(声明式→生成式、状态驱动→对话驱动、静态→智能)
  • Prompt工程的新角色和最佳实践
  • 意图层(Intent Layer)的出现和影响

React Router 实战指南:构建现代化前端路由系统

React Router 实战指南:构建现代化前端路由系统

一、前端路由的演进与重要性

在 Web 开发的早期,路由完全由后端控制,前端开发人员主要负责"切图"和静态页面制作。随着前后端分离架构的兴起,前端路由成为了现代单页应用(SPA)的核心组件,它使得前端能够独立管理页面导航,提供更流畅的用户体验。

React Router 作为 React 生态系统中最流行的路由解决方案,为我们提供了一套完整的路由管理工具,让我们能够轻松构建复杂的单页应用。

二、React Router 的两种实现方式

React Router 提供了两种主要的路由实现方式,各有其特点和适用场景:

HashRouter:兼容性优先

  • URL 格式:使用 # 符号作为路由分隔符,如 http://example.com/#/about
  • 实现原理:基于浏览器的锚点机制,通过监听 window.location.hash 的变化来触发路由更新
  • 优势
    • 兼容性极佳,支持所有现代浏览器
    • 无需服务器端配置,部署简单
    • 适合静态网站托管(如 GitHub Pages)
  • 劣势:URL 中包含 #,视觉上不够美观,不符合 RESTful 设计规范

BrowserRouter:现代性优先

  • URL 格式:使用标准的 URL 路径,如 http://example.com/about
  • 实现原理:基于 HTML5 History API,通过 pushStatereplaceState 方法管理路由
  • 优势
    • URL 更干净、美观,符合 RESTful 设计
    • 更好的 SEO 支持
    • 更符合现代 Web 应用的 URL 规范
  • 劣势
    • 需要服务器端配置,确保所有请求都指向同一个入口文件
    • 依赖 HTML5 History API,对旧浏览器支持有限(IE11 之前不兼容)

在实际项目中,我们通常使用 as Router 语法来提高代码可读性:

import { BrowserRouter as Router } from 'react-router-dom';

function App() {
  return (
    <Router>
      {/* 应用内容 */}
    </Router>
  );
}

三、路由类型详解

React Router 支持多种类型的路由,满足不同场景的需求:

1. 普通路由

最基础的路由类型,用于匹配固定路径:

<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />

2. 动态路由

通过参数捕获实现,适用于需要根据 ID 或其他参数显示不同内容的场景:

<Route path="/user/:id" element={<UserProfile />} />
<Route path="/product/:productID" element={<ProductDetail />} />

在组件中可以通过 useParams Hook 获取路由参数:

import { useParams } from 'react-router-dom';

function UserProfile() {
  const { id } = useParams();
  return <div>用户 ID: {id}</div>;
}

3. 通配路由

使用 * 匹配任意路径,常用于 404 页面:

<Route path="*" element={<NotFound />} />

4. 嵌套路由

通过 Outlet 组件实现路由嵌套,适用于复杂的页面结构:

<Route path="/product" element={<Product />}>
  <Route path=":productID" element={<ProductDetail />} />
  <Route path="new" element={<NewProduct />} />
</Route>

在父组件中使用 Outlet 渲染子路由:

import { Outlet } from 'react-router-dom';

function Product() {
  return (
    <div>
      <h1>产品列表</h1>
      <Outlet /> {/* 渲染子路由内容 */}
    </div>
  );
}

5. 鉴权路由

通过自定义组件实现路由守卫,控制页面访问权限:

<Route path="/pay" element={
  <ProtectRoute>
    <Pay />
  </ProtectRoute>
} />

6. 重定向路由

使用 Navigate 组件实现路由重定向:

<Route path="/old-path" element={<Navigate replace to="/new-path" />} />

四、路由优化策略

1. 组件懒加载

通过 React.lazySuspense 实现组件的按需加载,提高应用性能:

import { lazy, Suspense } from 'react';

const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));

function RouterConfig() {
  return (
    <Suspense fallback={<LoadingFallback />}>
      <Routes>
        {/* 路由配置 */}
      </Routes>
    </Suspense>
  );
}

2. 导航高亮

使用 useResolvedPathuseMatch 实现导航菜单的动态高亮:

const isActive = (to) => {
  const resolvedPath = useResolvedPath(to);
  const match = useMatch({
    path: resolvedPath.pathname,
    end: true
  });
  return match ? 'active' : '';
};

3. 路由历史管理

  • push:向历史栈添加新记录,用户可以通过浏览器的后退按钮返回
  • replace:替换当前历史记录,用户无法通过后退按钮返回上一个页面

五、单页应用的优势

单页应用(SPA)通过前端路由实现了以下优势:

  1. 更好的用户体验:页面切换无需重新加载,避免了页面"白屏"现象
  2. 更快的响应速度:只更新需要变化的部分,减少了网络请求
  3. 前后端分离:前端负责用户界面,后端负责数据处理,职责更加清晰
  4. 代码组织:通过组件化和路由管理,代码结构更加清晰

六、实际应用案例

让我们通过一个完整的导航组件来展示 React Router 的实际应用:

import { Link, useResolvedPath, useMatch } from 'react-router-dom';

function Navigation() {
  const isActive = (to) => {
    const resolvedPath = useResolvedPath(to);
    const match = useMatch({
      path: resolvedPath.pathname,
      end: true
    });
    return match ? 'active' : '';
  };

  return (
    <nav>
      <ul>
        <li className={isActive('/')}>
          <Link to="/">首页</Link>
        </li>
        <li className={isActive('/about')}>
          <Link to="/about">关于</Link>
        </li>
        <li className={isActive('/product')}>
          <Link to="/product">产品</Link>
        </li>
        <li className={isActive('/product/new')}>
          <Link to="/product/new">新产品</Link>
        </li>
        <li className={isActive('/product/123')}>
          <Link to="/product/123">产品详情</Link>
        </li>
      </ul>
    </nav>
  );
}

七、总结与展望

React Router 作为现代前端开发的核心工具之一,为我们提供了一套完整、灵活的路由解决方案。通过合理使用不同类型的路由和优化策略,我们可以构建出体验优秀、性能出色的单页应用。

随着 React 生态系统的不断发展,React Router 也在持续演进,为开发者提供更多强大的功能。未来,我们可以期待它在服务端渲染、微前端等领域发挥更大的作用,为前端开发带来更多可能性。

掌握 React Router 不仅是前端开发的基本技能,也是构建现代化 Web 应用的必要条件。通过不断实践和探索,我们可以充分发挥其潜力,创造出更加出色的用户体验。

立正请站好:一个组件复用 Skill 的工程化实践|得物技术

一、背景:为什么要做这个 Skill

做这个 Skill 的初衷很直接,也很现实:功能开发时容易"顺手新建一个",而不是先复用已有组件,造成组件库越来越臃肿。这件事对团队的伤害其实是复利型的:

  • 重复组件越来越多;
  • 维护成本越来越高;
  • UI/交互一致性越来越差;
  • AI 生成代码时也更容易继续复制混乱。

所以做这个 Skill 的目标不是"帮 AI 搜索一下",而是:把"复用优先"的思考过程流程化,让 AI 在写代码前先走一遍"查索引 → 判断是否复用 → 命不中再新建"的路径。

二、想解决的不是搜索问题,而是“思考顺序”问题

一开始很容易把问题理解成:"做个组件搜索工具给 AI 用就好了"。但实际落地后发现,真正的问题不是工具有没有,而是:

  • AI 会不会主动用;
  • AI 什么时候用;
  • AI 用完之后是否还能回到项目上下文;
  • AI 能不能稳定走同一条流程。

这和 Vercel 在他们的 agent 评测里观察到的现象很像:skills 本身不是没用,而是 agent 往往不会稳定触发;而把基础知识放进 AGENTS.md 这种"被动上下文"后,稳定性反而更高。Vercel 的实验里,默认 skill 触发并没有提升通过率,加入显式指令后才明显改善,而 AGENTS.md 文档索引方案表现更稳定。这给了我一个很关键的设计方向:先解决 AI 的"决策点"问题,再解决 AI 的"能力"问题。

三、核心设计思路:AGENTS.md + Hook + Skill(三层结构)

最终采用的是三层结构:

AGENTS.md:放基础上下文(常驻)

把"组件复用优先"的规则、组件索引入口、扫描后需要做的事情,放进 AGENTS.md(或同类常驻上下文机制)里。目的不是塞满文档,而是让 AI 每轮都知道:

  • 这个仓库有组件复用机制;
  • 默认应该先查可复用组件;
  • 查不到再考虑新建;
  • 扫描后还有描述补全流程需要继续执行。

这层解决的是:AI 根本不知道你有这套机制。不写进去,AI 主动使用 skill 的概率确实会很低(这点我踩过坑)。

Hook:做路由增强(提高触发概率)

如果运行环境支持 hooks(例如 Claude Code 的 UserPromptSubmit 支持在用户 prompt 处理前注入额外上下文),就可以做一层"意图路由增强":在用户提到"组件复用 / 是否有现成组件 / 封装组件 / 查组件"等语义时,给 AI 注入提示,让它优先走组件复用流程。Claude 的文档明确写了 UserPromptSubmit 会在处理前触发,并且可通过 additionalContext 注入上下文。这层解决的是:AI 知道有 skill,但不一定想起来用。

Skill:提供流程和工具(真正执行)

Skill 不是只写说明文档,而是要提供:

  • 明确的调用入口;
  • 稳定的输出格式;
  • 可执行脚本;
  • 失败时的兜底逻辑。

OpenAI 的 Codex Skills 文档里提到 skills 是"渐进披露"机制:运行时先看到 skill 的元信息(尤其是 description),只有决定使用时才加载完整 SKILL.md;而且隐式触发高度依赖 description。这也是为什么 skill 的触发边界和描述要写得非常清楚。这层解决的是:AI 想用了,但执行过程不稳定。

四、这套 Skill 在源码里是怎么落地的(我的实现)

下面是我这次组件复用 Skill 的几个关键实现点:

先把"入口"收敛成一个:find-component.js

我在 SKILL.md 里明确规定:Agent 必须调用统一入口find-component.js。这样做的原因很简单:

  • 避免 AI 在多个脚本之间犹豫(scan-components、match-component、resolve-scope……);
  • 避免 AI 漏掉前置步骤(比如索引不存在时先扫描);
  • 避免 AI 调用路径不一致导致结果不稳定。

统一入口做了几件事(都在 find-component.js 里):接收查询词(query)、仓库根路径(repoRoot)、当前聚焦路径(startDir)。

  • 如果 components.csv 缺失,内部自动触发run-scan.js;
  • 调用 resolve-scope 计算当前应用和允许搜索范围;
  • 调用 match-component 做匹配排序;
  • 命中时记录使用(用于后续加权);
  • 按固定 JSON 协议返回结果(成功/失败/无匹配/是否触发扫描等)。

这一步本质上是把分散逻辑聚合成"一个业务动作":"查一下有没有可复用组件",而不是"先算 scope,再查 CSV,再排序,再补扫,再记 usage"。这对 AI 很关键。

不是"全仓库乱搜",而是"当前应用 + 根级共享"优先

在 monorepo 场景里,组件复用很容易踩两个坑:

  • 只搜当前 app,漏掉根级共享组件;
  • 全仓乱搜,结果太多太噪音。

所以我在 resolve-scope.js 里做了一个比较工程化的范围解析策略:

  • 读取 pnpm-workspace.yaml 解析 workspace 包;
  • 根据当前聚焦文件/目录反推 currentAppRoot;
  • 再结合 root_scope_patterns(例如 apps/_share/、packages/ 等)构建允许范围;
  • 最终形成一个搜索集合:当前应用 + 根作用域共享包。

如果没有聚焦子项目(比如 startDir 就是 repo root),则切换为全量 scope。这个设计很像人类工程师的查找策略:先看"我这个业务应用里有没有",再看"全局共享有没有",而不是直接在整个 monorepo 海里捞针。

匹配不是纯关键字:我做了"多因素加权"

组件匹配如果只做字符串包含,很快就会变成垃圾召回器。我在 match-component.js + fuzzy-match.js 里做了一个组合评分,核心包括:

  • 名称精确/包含匹配;
  • 模糊匹配(编辑距离);
  • Token 重叠;
  • 首字母缩写匹配(例如 dlp 匹配 DateLinkPicker);
  • 当前应用加权(当前 app 的组件优先);
  • 使用频率加权(常用组件更靠前);
  • 来源质量加权(README 推断质量高于纯 inferred);
  • 存在性校验(文件不存在则降权/过滤);
  • 记录类型权重(组件优先于依赖)。

这一步的目标不是追求"算法先进",而是让排序更符合团队真实使用习惯:"更可能被复用的组件排在前面"。此外我还加了一个低分阈值(NO_MATCH_SCORE_THRESHOLD):

  • 如果最高分太低,就认为是噪音命中;
  • 可以触发一次扫描后再查;
  • 还是低分则按"无匹配"返回,不把噪音结果塞给 AI。

这个点很重要,因为 AI 一旦拿到一些低质量候选,很容易"将错就错"。

把"索引构建"做成可复用流水线,而不是一次性脚本

很多类似方案停在“扫一遍生成 CSV”,然后就过时了。我这次把扫描做成了 run-scan.js -> index-manager -> enrich 的流水线,核心考虑是持续维护:

run-scan.js 负责编排流程

  • resolve-scope;
  • updateIndex;
  • 自动触发 autoEnrich(可配置)。

index-manager.js 负责索引更新策略

  • 保留历史记录并合并;
  • 根据 source_hash 跳过未变化组件;
  • 记录 last-scan-changed-ids.json;
  • 支持并行扫描(包数量较多时启用);
  • 对缺失文件支持标记 exists=0(在查找阶段也会回写)。

扫描后进入 Agent 富化(enrich)流程

  • 读取 agent-enrich-prompts.json;
  • 找出 summary 占位符项;
  • 按 id 回到 components.csv;
  • 读取源码/README;
  • 生成 summary + keywords;
  • 再通过 update-component-summary.js 写回。

更关键的是在配置里启用了:

  • agent_mode_no_fallback = true。

也就是说,在 Agent 模式下不走规则引擎降级,而是要求 Agent 必须完成这一步。这其实就是"流程化思考"的精髓:不是建议,而是纳入主流程。

让 Skill 不只是"搜索器",还是"反馈回路"

一个很容易被忽视的点是:查找命中后,我还记录了使用行为(usage-tracker)。这意味着系统不是静态的,它会逐步学习团队偏好:

  • 哪些组件经常被复用;
  • 哪些组件在某个 app 里更常出现;
  • 哪些结果应该在排序中更靠前。

这是一种很轻量但非常实用的反馈机制——不需要搞复杂训练,也能提升 AI 下一次推荐质量。

五、这次实现里,总结出"让 AI 流程化"的 3 条原则

这也是我最想分享的部分:

原则 1:把基础上下文放进 AGENTS.md(或用 Hook 注入)

如果不这样做,AI 主动使用 skill 的概率很低。原因不是 AI 笨,而是 agent 的执行是有"决策成本"的:

  • 它要先意识到有 skill;
  • 再判断该不该用;
  • 再决定什么时候用。

而把基础上下文放进 AGENTS.md 或通过 hook 提前注入,本质上是在减少决策点。Vercel 的评测结果说明了这种"被动上下文"在某些场景下会更稳定。

原则 2:Skill 需要直接提供工具函数给 AI 调

只写一堆说明文档不够。AI 在工程任务里最需要的是:

  • 一个可以直接执行的入口;
  • 明确的参数;
  • 稳定的返回结构。

所以我把 find-component.js 做成统一入口,并定义了固定 JSON 输出(ok / matches / noMatch / scanTriggered / hint / error 等),这会明显提升 AI 的执行稳定性。

原则 3:显式告诉 AI 调哪些函数,并把分散逻辑聚合到一个入口

这是最容易被忽略、也是最影响稳定性的一点。如果给 AI 暴露一堆脚本:

  • resolve-scope.js;
  • match-component.js;
  • run-scan.js;
  • scan-components.js;
  • index-manager.js。

它理论上能拼起来,但实践里很容易漏步骤、顺序错、参数错。所以我在 Skill 里显式规定:

  • 查找时用 find-component.js;
  • 构建时用 run-scan.js;
  • 更新描述时用 update-component-summary.js。

把复杂系统收敛成几个明确入口,AI 才容易稳定执行。

六、这次实践里一个很重要的认知转变

我原来以为"写 skill"是在给 AI 增加能力。现在更像是在做:给 AI 增加"默认工作方式"。换句话说,skill 不只是能力包(capability bundle),也是流程控制器(workflow controller)。

  • AGENTS.md 负责"告诉 AI 世界观";
  • Hook 负责"提醒 AI 现在该用哪套流程";
  • Skill 负责"把动作做完,并且做得稳定";
  • 日志/CSV/usage 负责"让系统可观测、可迭代"。

这套思路不只适用于组件复用,后面也可以迁移到:

  • 任务优化闭环;
  • 日志分析标准化;
  • 策略诊断流程;
  • 代码规范治理。

七、这套方案当前的价值

  • AI 开发前先查可复用组件,而不是直接新建;
  • monorepo 下按"当前应用 + 共享组件"范围检索;
  • 索引缺失自动扫描;
  • 组件描述富化进入主流程;
  • 匹配质量有加权与反馈回路;
  • 整体流程有明确入口和输出协议。

八、结语:让 AI 少一点"即兴发挥",多一点"工程纪律"

这次组件复用 Skill 的开发过程,对我最大的启发不是"AI 能帮我写多少代码",而是:AI 其实非常适合被放进一套清晰流程里工作。只要把下面三件事做好:

  • 基础上下文(AGENTS.md / hooks);
  • 可执行入口(工具函数);
  • 明确流程边界(统一入口 + 输出协议)。

AI 就不会只是"一个会说话的代码补全器",而会更像一个遵守团队规范的工程协作者。而这,才是我做这个 Skill 真正想要的结果。

引用文档: vercel.com/blog/agents…

往期回顾

1.财务数仓 Claude AI Coding 应用实战|得物技术

2.日志诊断 Skill:用 AI + MCP 一键解决BUG|得物技术

3.Redis 自动化运维最佳实践|得物技术

4.Claude在得物App数仓的深度集成与效能演进

5.Claude Code + OpenSpec 正在加速 AICoding 落地:从模型博弈到工程化的范式转移|得物技术

文 /魏无涯

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

写代码不出事故的底层方法:边界、兜底与默认值

引言

软件系统的稳定性并非偶然,而是建立在对各种异常情况充分预判和处理的基础之上。优秀的代码不仅要能正确处理happy path,更要能在边界条件下保持健壮,在系统出现意外状况时优雅降级,在缺乏配置时拥有合理的默认行为。这三个维度——边界、兜底与默认值——构成了防御性编程的基石,也是资深工程师与初级开发者之间最显著的差距所在。

很多线上事故的根源都可以追溯到对边界条件的忽视:一个数组越界、一次空指针调用、一个未被处理的异常向上传播,最终导致整个系统不可用。这些问题在测试环境往往难以复现,却在生产环境的高并发、大数据量、多样化输入面前暴露无遗。理解并实践边界、兜底与默认值的理念,是从“能跑就行”迈向“稳定可靠”的必经之路。

一、边界:认识问题的第一道防线

1.1 边界问题的本质

边界问题之所以被称为“边界”,是因为它们发生在正常操作与异常操作的交界处。在数学上,边界可能是最大值、最小值、零、空集;在业务逻辑中,边界可能是首批用户、最后一批订单、零金额交易、长文本截断点。边界问题的危险之处在于,它们往往处于“理论上应该存在但实际很少被触发”的灰色地带,常规测试难以覆盖,却在特定条件下必然触发。

以一个简单的分页查询为例,假设系统支持分页获取用户列表,页面大小为每页20条。当数据库中存在恰好20条记录时,请求第一页会返回全部数据,请求第二页应该返回空列表,这是正常逻辑。但如果代码中错误地使用了“小于等于”作为分页起始索引的判断条件,就可能在某些边界情况下计算出负数的起始位置,导致数据库查询失败或返回错误的数据。类似地,当用户传入的分页参数为负数或超出实际页数范围时,系统是否做了正确的校验和处理,直接决定了这个接口的健壮性。

1.2 边界类型与处理策略

边界问题可以按照数据类型和业务场景进行分类,每种类型都需要相应的处理策略。

数值边界是最常见的边界类型之一,包括整数的最大值与最小值、浮点数的精度限制、数值的正负零等。在处理整数运算时,必须考虑溢出的可能性。例如,在Java中,如果两个Integer.MAX_VALUE相加,结果会变成负数,这可能导致库存扣减、金额计算等场景出现严重的逻辑错误。正确的做法是使用BigInteger或BigDecimal进行精确运算,或者在运算前进行溢出检查。一种常用的溢出检测模式是:在加法运算前检查其中一个数是否大于目标类型最大值减去另一个数。

集合边界同样需要谨慎处理。数组的索引越界、列表的越界访问、集合的空集合操作,都是常见的边界问题。在遍历集合时,应该特别注意集合在遍历过程中是否可能被修改——这在多线程环境下尤其危险,即ConcurrentModificationException的常见原因。对于可能为空的集合,安全的做法是在遍历前进行非空检查,或者使用空集合替代null进行后续处理。

字符串边界包括空字符串、仅有空白字符的字符串、超长字符串、包含特殊字符的字符串等。在进行字符串长度校验时,需要明确是按照字符数还是字节数进行计算,因为在中英文混合的场景下,两者的差异可能导致意想不到的问题。字符串截断操作也属于边界处理的一部分,当需要将超长文本截断显示时,是直接截断还是按照单词边界截断,是完全截断还是添加省略号,都是需要根据业务场景做出的选择。

时间边界涉及时区转换、夏令时切换、闰年处理、Unix时间戳的2038年问题等。日期时间的比较和计算尤其容易出错,因为时区的存在使得“同一天”可能有着不同的起止时刻。在处理时间相关的业务逻辑时,应该尽可能使用UTC时间进行内部存储和计算,只在需要展示时才转换为用户所在时区。

1.3 边界检查的实现原则

边界检查不应该被视为对正常流程的干扰,而应该被理解为正常流程的一部分。优秀的边界检查应该是防御性的、无副作用的,并且与业务逻辑清晰分离。

前置条件校验应该在函数或方法的入口处进行,确保传入的参数符合预期的约束条件。这种校验通常是强制性的——如果前置条件不满足,函数应该立即失败并返回明确的错误信息,而不是尝试继续执行可能产生未定义行为的逻辑。Java中的Objects.requireNonNull、Guava的Preconditions类,都是用于前置条件校验的工具。

后置条件校验用于确保函数的输出符合预期。这种检查通常在函数执行完毕后、返回结果之前进行,可以帮助开发者在早期发现逻辑错误。例如,一个排序函数在完成后可以检查输出数组是否真的有序;一个累加函数可以检查最终结果是否等于各个加数的和。

不变量校验用于确保对象在整个生命周期中都处于合法状态。不变量是对象构造完成后、每次方法调用前后都应该保持为真的条件。例如,一个栈的不变量是“栈中的元素数量永远不为负”,以及“栈顶指针永远指向下一个可写入的位置”。在每次可能改变对象状态的操作后验证不变量,可以在第一时间发现状态被破坏的情况。

1.4 边界检查的反面:过度防御

强调边界检查的重要性并不意味着要走向另一个极端——过度防御同样是有害的。过度防御的表现形式包括:对每一个参数都进行详尽无遗的校验,即使这些参数来自可信的内部调用;在已经进行过校验的地方重复校验,浪费计算资源;使用过于宽泛的异常捕获,掩盖了本应被发现的真正问题。

过度防御的危害在于,它会增加代码的复杂性,降低可读性,使得真正的问题被掩盖。同时,过度的校验会带来不必要的性能开销,在高并发场景下这种开销可能累积成显著的系统负担。因此,进行边界检查时应该遵循一个原则:只检查真正需要的、可能出错的、后果严重的边界条件。

二、兜底:系统健壮性的关键保障

2.1 兜底思维的本质

兜底是一种兜底预案思维,它假设任何可能出错的环节都一定会出错,并为此准备备用的响应方案。这里的“出错”不仅包括代码逻辑错误或系统故障,还包括各种外部依赖的不可用、网络通信的不可靠、资源的暂时耗尽等。在分布式系统和微服务架构盛行的今天,任何一个环节的故障都可能导致级联失败,而兜底机制正是防止这种级联效应的关键手段。

以一个典型的电商系统为例,用户下单时需要调用库存服务扣减库存、调用支付服务完成支付、调用物流服务预订配送。如果库存服务在某个时刻响应变慢或暂时不可用,系统是否应该直接拒绝用户的下单请求?还是应该返回一个“库存锁定中,请稍后再试”的友好提示,并在一段时间后自动重试?更进一步,如果库存服务长时间不可用,是否应该允许用户先完成下单,后续再处理库存不足的情况?这些问题的答案取决于具体的业务场景和系统的可用性要求,但无论如何,系统都不应该因为某个依赖的故障而直接崩溃或返回难以理解的错误信息。

2.2 兜底的层次与策略

兜底策略可以从不同层次进行设计,每一层都有其特定的应用场景和实现方式。

服务降级是最常见的兜底策略之一。当某个非核心服务不可用时,系统可以关闭该服务提供的功能,保证核心功能的正常运行。例如,在一个内容平台中,评论功能可以降级为只读,用户仍然可以浏览内容,但暂时无法发表评论;广告展示功能可以降级为展示公益广告或默认图片;推荐算法可以降级为展示热门内容而非个性化推荐。服务降级的关键在于明确区分核心功能和非核心功能,并确保降级后的用户体验仍然是可接受的。

熔断机制是防止级联故障的重要手段。当某个服务的错误率超过阈值时,熔断器会“跳闸”,后续对该服务的调用会直接返回预设的降级结果,而不会真正发送到目标服务。这避免了持续向一个已经故障的服务发送请求,浪费资源的同时也给了故障服务恢复的时间窗口。熔断器会周期性地尝试放行少量请求来探测服务是否已经恢复,如果探测成功则关闭熔断器恢复正常调用。Netflix的Hystrix、Alibaba的Sentinel都是常用的熔断实现框架。

超时控制是兜底策略中容易被忽视但极其重要的一环。很多系统在设计时假设外部调用会正常返回,却忘记了网络是不可靠的——一个TCP连接可能因为网络分区而永久挂起,导致调用线程无限期等待。设置合理的超时时间是防止这种“线程卡死”的基本手段。超时时间的设置需要平衡两个因素:太长则无法及时发现故障,太短则可能误判正常但较慢的服务为故障。一种常用的做法是设置“连接超时”和“读取超时”两个参数,前者控制建立连接的时间,后者控制等待响应的时间。

重试机制是处理临时性故障的有效手段。当一个服务调用因为网络抖动或服务器短暂过载而失败时,立即重试往往能够成功。但重试也有其风险:它可能加剧被调用服务的负载、在某些场景下导致重复操作(如重复扣款)、在故障恢复时产生惊群效应。因此,重试机制通常需要配合退避策略(如指数退避)、重试次数限制、以及幂等性保证一起使用。

2.3 兜底实现的最佳实践

实现有效的兜底机制需要遵循一些基本原则和最佳实践。

** Fail Fast 与 Fail Safe 的选择**是设计兜底策略时首先需要明确的问题。Fail Fast(快速失败)是指在检测到错误时立即失败并返回,常用于核心功能的校验、不可恢复的错误等情况。Fail Safe(失败安全)是指在错误发生时执行预设的默认行为,保证系统继续运行,常用于非核心功能或无法确定错误影响的情况。选择哪种策略取决于功能的重要性和错误的性质。

兜底结果的设计直接影响用户体验。一个好的兜底结果应该是:可识别的(用户能够理解系统当前的状态)、有意义的(提供了替代的信息或功能)、最小的(不会造成额外的问题)。例如,当推荐系统降级时,展示“热门内容”比展示空白或报错要好得多;当支付系统暂时不可用时,显示“支付服务繁忙,请稍后再试”比显示一串技术错误代码要好得多。

兜底日志与监控是确保兜底机制有效运行的重要保障。当系统进入降级状态时,应该记录详细的日志,包括触发降级的原因、持续时间、影响的请求数量等。这些日志对于事后分析和系统优化至关重要。同时,应该建立相应的监控告警机制,当系统频繁触发兜底逻辑时及时通知运维人员介入处理。

2.4 常见兜底场景与处理

在实际开发中,有一些常见的兜底场景值得特别关注。

网络请求的兜底需要考虑网络的各种异常情况:连接超时、读取超时、连接被重置、DNS解析失败等。对于HTTP请求,应该设置合理的超时时间,并处理各种可能的异常情况。对于重要的数据获取请求,可以考虑设置本地缓存作为兜底,当远程请求失败时返回缓存数据(即使可能稍有过期)。

数据库操作的兜底主要关注连接池耗尽、查询超时、锁等待超时等场景。在高并发场景下,数据库往往是系统中最容易成为瓶颈的组件。当数据库响应变慢时,连接池可能迅速耗尽,导致后续请求无法获取连接。处理这种情况可以采用连接获取超时、查询超时、熔断降级等策略。

第三方服务的兜底需要特别谨慎,因为第三方服务的可用性和性能不受我们控制。对于关键的第三方依赖,应该实现多级降级策略:优先调用主服务,失败后尝试备用服务,再次失败后返回本地缓存或默认值。同时,应该对第三方调用设置较短的超时时间,避免被第三方服务拖慢整个系统。

三、默认值:系统自愈的起点

3.1 默认值的意义

默认值是在没有显式指定时自动使用的值。一个设计良好的默认值系统可以显著降低系统的故障率,因为它在用户没有做出任何选择的情况下也能提供合理的体验。默认值的重要性体现在以下几个方面:首先,它简化了用户操作,用户不需要了解每一个配置项的含义,系统就能正常工作;其次,它防止了空值或未初始化状态引发的各种问题,将null这样危险的“特殊情况”转化为正常的“默认值情况”;最后,它使得系统的行为更加可预测,有助于调试和问题排查。

考虑一个用户配置系统的例子。用户可以设置自己的通知偏好,包括邮件通知、短信通知、App推送通知等。如果系统在用户未设置任何偏好时将这些字段都设为null或undefined,那么在后续发送通知时就需要大量的null检查来避免空指针错误。但如果系统将默认值设为“全部开启”,那么未设置偏好的用户会正常收到通知,后续的代码逻辑也会简单得多——只需要在用户明确关闭某类通知时才跳过发送。

3.2 默认值的类型与设计

默认值可以根据其来源和用途分为不同的类型,每种类型都有其适用的场景。

程序内置默认值是最基础的默认值类型,它们被硬编码在程序中,是系统在没有外部配置时的默认行为。这些默认值通常经过深思熟虑的选择,代表了系统设计者认为的“最合理”的行为。例如,一个限流器的默认QPS设置、一个缓存的默认过期时间、一个重试机制的默认重试次数,都属于程序内置默认值。这类默认值应该在代码中有明确的注释说明其选择理由,并定期根据实际运行情况进行调整。

配置文件默认值允许在不提供配置文件或配置项缺失时使用预设的默认值。与程序内置默认值相比,配置文件默认值具有更好的灵活性,可以通过修改配置文件来改变默认行为而无需重新编译程序。良好的配置系统应该区分“未配置”和“显式配置为空”两种情况,前者使用默认值,后者使用空值(如果业务逻辑允许空值的话)。

运行时推断默认值是根据当前环境或上下文自动计算的默认值。例如,一个连接池的默认大小可以根据服务器的CPU核心数来确定;一个批量处理任务的默认批次大小可以根据可用内存来计算。这类默认值的好处是能够自适应不同的运行环境,但缺点是可能产生难以预料的行为,应该谨慎使用。

3.3 空值处理与空对象模式

空值(null或undefined)是编程中最常见的错误来源之一,著名的“null引用十亿美金错误”揭示了空值处理的困难。处理空值的方法主要有两种策略。

空值检查是最直接的处理方式,在访问对象属性或调用方法前检查对象是否为null。这需要开发者有良好的习惯,在每一个可能为null的地方都进行检查。但这种方式容易导致代码中出现大量的嵌套if语句,降低可读性。Java 8引入的Optional类提供了一种更优雅的空值处理方式,它强制调用者显式地处理值不存在的情况,而不是默认抛出一个难以追踪的空指针异常。

空对象模式是一种更彻底的解决方案,它用一个“不做任何事的对象”来替代null,从而避免大量的空值检查。例如,一个日志记录器接口可以有NullLogger实现类,这个实现类的所有方法都不做任何事,当系统没有配置日志记录器时使用NullLogger替代,后面的代码就不需要检查日志记录器是否为null了。空对象模式的好处是简化了调用方的代码,坏处是可能掩盖一些本应被发现的配置问题。

3.4 默认值的最佳实践

设计和使用默认值时应该遵循一些最佳实践。

**选择“有意义的默认值”**是关键原则。默认值应该是“大多数情况下正确的值”,而不是简单的0、空字符串或false。例如,对于一个布尔类型的配置项,如果其语义是“功能开关”,那么默认开启还是默认关闭需要根据功能的性质来判断——一个可能影响核心流程的功能应该默认关闭,让用户主动选择开启;一个安全相关的功能应该默认开启,防止用户因疏忽而暴露安全风险。

**提供“配置提示”**可以帮助用户理解默认值的行为。当系统使用默认值时,应该通过日志、文档或用户界面的方式告知用户当前使用的是默认值,以及这个默认值是什么。这有助于用户在遇到问题时理解系统的行为,也方便他们在需要时主动去修改配置。

保持默认值的一致性可以减少混淆。如果在代码的不同位置使用了不同的默认值,可能导致难以理解的边界行为。建议将默认值集中管理在一个地方(如配置常量类),确保整个系统使用相同的默认值定义。

3.5 配置膨胀与默认值的管理

随着系统功能的增加,配置项往往会越来越多,如何管理这些配置及其默认值成为一个挑战。

分层配置是一种有效的管理策略。可以将配置分为“框架配置”、“系统配置”、“业务配置”三个层次,每层配置都有其对应的默认值。上层配置可以覆盖下层配置,最终生效的配置是各层叠加的结果。这种分层设计既保证了灵活性,又避免了配置项的混乱。

配置校验是防止错误默认值影响系统的重要手段。在系统启动或配置变更时,应该对所有配置项进行校验,确保它们的值在合理的范围内。对于不合理的配置值,系统应该拒绝启动或发出警告,而不是静默使用可能错误的默认值。

配置的文档化对于团队协作至关重要。每一个配置项都应该有清晰的文档说明,包括其用途、合法值范围、默认值、修改的影响等。良好的配置文档可以帮助新加入的开发者快速理解系统,也是生产环境问题排查的重要参考。

四、综合实践:三位一体的防御体系

4.1 三者的协同关系

边界、兜底与默认值这三个概念并非相互独立,而是构成了一个完整的防御体系。在这个体系中,边界定义了什么情况是“正常的”,兜底定义了当“不正常”情况发生时系统应该如何响应,而默认值则提供了在没有明确指定时系统的默认行为。

以一个用户权限校验的场景为例。边界检查确保传入的用户ID是有效的正整数,角色参数是预定义的有效值之一;兜底机制确保当权限服务不可用时系统不会直接拒绝所有请求,而是可以根据配置决定是拒绝还是放行;默认值则定义了当用户没有任何角色标签时,应该赋予其“普通用户”的默认权限。三个机制协同工作,既保证了系统的健壮性,又提供了合理的默认体验。

4.2 实践案例分析

让我们通过一个具体的业务场景来展示三个概念的综合运用。

考虑一个在线教育平台的课程推荐系统。系统需要根据用户的年级、学科偏好、历史学习记录等信息,从课程库中筛选并推荐合适的课程。

边界层面,系统需要检查用户的年级是否在1到12之间的有效整数、学科偏好列表是否为空或长度合理、请求的推荐数量是否在1到50之间的合理范围、用户的身份标识是否有效等。如果任何边界条件不满足,系统应该返回明确的错误信息,而不是尝试处理无效输入。

兜底层面,当推荐算法服务响应超时时,系统应该返回预设的兜底推荐列表(如平台热门课程),而不是返回错误或空结果;当课程库的某些数据暂时不可用时,系统应该跳过这些数据继续处理可用的课程;当推荐结果为空时,系统应该返回一条友好的提示信息。

默认值层面,如果用户没有设置年级信息,默认使用“全部年级”范围进行推荐;如果用户没有设置学科偏好,默认使用用户历史学习记录中出现最多的学科作为偏好;如果用户请求的推荐数量超出限制,默认返回允许的最大数量;当没有任何偏好信息时,默认推荐平台的精选课程。

4.3 代码层面的实现建议

在代码实现层面,有一些具体的建议可以帮助实践这三个概念。

使用强类型和泛型约束可以在编译期捕获很多潜在的边界问题。将用户输入转换为强类型后,类型系统可以帮助我们发现很多类型不匹配的问题。泛型约束可以限制一个方法接受的参数类型,减少运行时检查的需要。

使用不可变对象可以简化兜底逻辑和默认值处理。不可变对象一旦创建就不能被修改,这使得它们天然就是线程安全的,也避免了因为对象状态被意外修改而导致的复杂问题。如果需要修改对象的状态,应该创建新的对象而不是修改原有对象。

使用配置对象替代大量参数可以简化函数签名,使得默认值的管理更加集中。一个接受20个参数的函数调用远不如一个接受配置对象的函数调用可读,后者可以清晰地展示每个参数的名字和默认值。

统一的异常处理机制是兜底策略的重要组成部分。应该定义清晰的异常层次结构,区分可恢复的异常和不可恢复的异常,并为每种异常类型定义合适的处理策略。在系统的入口处统一处理异常,可以避免异常处理逻辑在代码各处重复。

4.4 测试与验证

防御性代码同样需要测试来验证其正确性。对于边界条件,应该编写针对边界值的单元测试,确保边界检查在临界点处行为正确。对于兜底逻辑,应该模拟各种故障场景(如服务超时、服务不可用、数据格式错误等),验证系统的降级行为是否符合预期。对于默认值,应该验证在各种配置缺失的情况下,系统是否使用了正确的默认值。

除了单元测试,还应该进行混沌工程实验,在生产环境或类生产环境中主动注入故障,验证系统的容错能力。这种实验可以帮助发现那些只有在真实故障场景下才会暴露的问题,是保障系统稳定性的重要手段。

五、总结

边界、兜底与默认值,这三个看似简单的概念,构成了软件防御性编程的核心框架。边界的精髓在于“知其边界”,明确系统能够处理的输入范围,并在边界处设置清晰的校验和拒绝机制。兜底的精髓在于“备有后手”,假设任何依赖都可能失败,并为每种可能的失败情况准备合适的降级方案。默认值的精髓在于“善解人意”,在没有明确指定时提供合理的行为,让系统能够优雅地应对未知的场景。

这三种方法的力量不仅在于它们各自的作用,更在于它们的协同效应。一个仅有边界检查而没有兜底机制的系统,在遇到边界外的情况时会直接崩溃;一个有兜底机制但没有良好默认值的系统,兜底逻辑可能会返回难以理解的空结果;一个只有默认值而没有边界检查的系统,可能在边界情况下产生不可预测的行为。

在实际开发中,培养防御性编程的思维习惯比掌握特定的技术技巧更为重要。每写一段代码,都应该问自己几个问题:这个函数的输入有什么限制条件?这些限制条件被满足了吗?如果外部依赖失败了会怎样?如果某个配置项没有设置会使用什么值?通过这种持续的自我审视,可以逐步建立起对系统脆弱点的敏感度,写出更加健壮的代码。

最终,代码的稳定性不是靠事后的打补丁和紧急修复来保障的,而是靠在设计和实现阶段就充分考虑各种异常情况来实现的。边界、兜底与默认值,这三个底层方法,正是这种设计理念的具体体现。它们不会让代码变得更加“炫酷”,却能让代码在面对现实世界的各种意外时表现得更加可靠。对于追求工程卓越的开发者来说,深入理解和熟练运用这三个概念,是从优秀走向卓越的必经之路

从零实现富文本编辑器#13-React非编辑节点的内容渲染

先前我们讨论了是编辑节点的组件预设,包括零宽字符、Embed节点、Void节点等,接下来我们需要讨论的是非编辑节点内容渲染,也就是占位节点、只读模式、插件模式、外部节点挂载等。这些节点类型在编辑器的设计中处于常见的外部节点,例如占位符号、弹出层等。

从零实现富文本编辑器系列文章

Placeholder 占位节点

在编辑器中,在内容为空的情况下,通常需要渲染一个占位节点来提示用户输入内容。在浏览器的inputtextarea中,都存在原生的占位节点实现。而在编辑器中,这部分占位节点就需要自行实现,浏览器在ContentEditable模式并不存在原生的占位节点。

在开源的编辑器中,quillslate都提供了占位节点的实现,并且还是属于典型的实现。quill的占位节点是使用CSS的伪元素来实现的,使用伪元素的好处是,完全不会影响到浏览器的DOM结构,这样也就不会影响到选区模型等设计,整体结构类似下面的内容。

<div data-placeholder="请输入内容">
  ::before
  <div data-node><span data-leaf>&ZeroWidthSpace;</span></div>
</div>
.block-kit-x-editable div[data-block][data-placeholder]::before {
  color: #bbbfc4;
  content: attr(data-placeholder);
  height: 0;
  pointer-events: none;
  position: absolute;
}

在这里,content是可以直接将DOM上的属性值渲染到占位节点上的,即data-placeholder属性值,这样就可以通过Js来控制属性值,进而处理占位节点的内容了。absolute主要是为了使其脱离DOM文档流,不影响选区的定位,pointer-events则是为了避免事件交互。

其实用伪元素实现的最重要的点是,在ContentEditable模式下,浏览器不会让用户编辑::before::after伪元素生成的内容。我们无法选中伪元素,其也不会参与光标、选区的计算。因为伪元素不属于DOM树,而ContentEditable只作用于真实的DOM节点及其文本内容。

而类似slate的实现,则存在两部分特殊的设计。首先是将占位节点直接渲染到Editable编辑区域内,这样就可以复用React的渲染节点作为整个占位节点。再者是占位节点是渲染在leaf区域内,这也就意味着编辑器的文本样式也会应用到占位节点上。

针对React占位节点的渲染,理论上而言之需要将其作为参数渲染到Editable编辑区域内即可。但是我们需要实现类似上述伪元素的实现,来确保占位节点的内容不会被用户编辑,那么这部分就需要用CSS来控制,即position + user-select + pointer-events

<div
  {...{ [PLACEHOLDER_KEY]: true }}
  style={{
    position: "absolute",
    opacity: "0.3",
    userSelect: "none",
    pointerEvents: "none",
  }}
>
  {props.placeholder}
</div>

接下来是设置的文本样式应用问题,这里的差异主要在于文本节点的放置位置。类似于上述的伪元素实现,如果直接放在容器直属元素下的话,设置的样式自然是不会应用到占位节点上的。而若是放在leaf区域内,自然就可以将样式应用到占位节点上。

<div>
  <span>请输入内容</span> <!-- 无法应用样式的占位节点内容 -->
  <div data-node>
    <span data-leaf>&ZeroWidthSpace;</span>
    <span>请输入内容</span> <!-- 可以应用样式的占位节点内容 -->
  </div>
</div>

此外,还有个特别需要关注的点,在IME进行Composing的时候,理论上是不应该显示占位节点的。而此时如果直接在编辑区域监听composing事件,则会导致选区模型重新计算,此时输入内容则会出现选区模型异常的情况。因此在这里需要独立抽离组件,避免上层的layout effect

/**
 * 占位符组件
 * - 抽离组件的主要目标是避免父组件的 LayoutEffect 执行
 */
export const Placeholder: FC<{
  editor: Editor;
  lines: LineState[];
  placeholder: React.ReactNode | undefined;
}> = props => {
  const { isComposing } = useComposing(props.editor);
  return props.placeholder &&
    !isComposing &&
    props.lines.length === 1 &&
    isEmptyLine(props.lines[0], true) ? (
    <div {...{ [PLACEHOLDER_KEY]: true }}>
      {props.placeholder}
    </div>
  ) : null;
};

Readonly 只读模式

在我们的编辑器中,编辑模式主要是依赖于ContentEditable的属性值,那么在只读模式下,之需要将ContentEditable的属性值设置为false即可。理论上而言这完全是视图层的行为,之需要在React中实现DOM属性控制即可。

<div
  {...{ [EDITOR_KEY]: true }}
  contentEditable={!readonly}
>
  <BlockModel></BlockModel>
</div>

除此之外,在诸如工具栏、图片、Mention等模块中,通常需要额外的控制面板来编辑相关内容,那么在只读模式下,就需要感知到状态的变化。而在React中,我们可以直接通过Context来感知到状态的变化,从而可以实现状态变化的感知。

<ReadonlyContext.Provider value={!!readonly}>
  {children}
</ReadonlyContext.Provider>
const ReadonlyContext = createContext<boolean>(false);
ReadonlyContext.displayName = "Readonly";

const useReadonly = () => {
  const readonly = React.useContext(ReadonlyContext);
  return { readonly };
};

const { readonly } = useReadonly();

理论上而言,编辑器的只读状态变更是需要被感知到的,否则会导致编辑器的状态不一致。不过在实际应用中,暂时还没有需要的场景,因此这里还没有实现,当前主要是在视图只读状态变化之后,设置编辑器的只读状态,而没有触发相关事件。

export const BlockKit: React.FC<BlockKitProps> = props => {
  if (editor.state.get(EDITOR_STATE.READONLY) !== readonly) {
    editor.state.set(EDITOR_STATE.READONLY, readonly || false);
  }
}

Plugin 渲染插件模式

Core核心服务中,我们已经实现了一套插件的渲染模式,这部分插件模式对于基本类型的样式是没什么问题的。然而,在实现诸如超链接、引用块这些需要组合类型的插件时,就需要特殊处理,这些类型的节点不需要持有状态,只需要在渲染时根据状态来渲染即可。

举个例子,当实现超链接时,按照基本的拆离文本节点的方式来渲染,那么就会出现下面的情况。特别是,如果是加粗或者斜体等样式,那么就会出现拆离内容的情况,虽然并不会造成特别大的影响,但是体验上会稍显差一些,例如hover上去出现的下划线是一段段的而非整体。

<b><a href="xx">part a</a></b>
<i><a href="xx">part b</a></i>

因此理论上而言,超链接的渲染需要特殊处理,a标签整个需要被渲染到一个容器中,而不是拆离文本节点的方式来渲染。当然,在实际输入的过程中,a标签在IME输入的时候,本身会破坏DOM结构,这部分内容可以参考本系列#8的包装节点部分。

<a href="xx">
  <b>part a</b>
  <i>part b</i>
</a>

因此在React中,我们还需要实现一套渲染时的插件模式,也就是在渲染时根据状态来渲染插件。在这里之需要扩展Core核心服务中的插件模式,然后在React渲染组件中调度这部分模块。不过在此之前,还需要设计一个渲染包装模式的策略。

如果仅仅是单个key来实现渲染时嵌套并不是什么复杂问题,而同时存在多个key则变成了令人费解的问题。如下面的例子中,如果将34单独合并b,外层再包裹a似乎是合理的,但是将34先包裹a后再合并5b也是合理的,甚至有没有办法将67一并合并,因为其都存在b标签。

1 2 3  4  5 6  7 8 9 0
a a ab ab b bc b c c c

这个问题比较复杂,本着简单可扩展的原则,最终想到了个简单的实现,对于需要wrapper的元素,如果其合并listkeyvalue全部相同的话,那么就作为同一个值来合并。那么这种情况下就变的简单了很多,我们将其认为是一个组合值,而不是单独的值,在大部分场景下是足够的。

1 2 3  4  5 6  7 8 9 0
a a ab ab b bc b c c c
12 34 5 6 7 890

那么接下来就需要按照这部分模式来处理渲染,首先这是一套纯渲染模式,那么我们就需要实现一个Map来映射渲染的jsxstate。而为什么不是state映射jsx,则是为了兼容现有的elements - jsx返回值。

const elements = useMemo(() => {
  const leaves = lineState.getLeaves();
  // 首先渲染所有非 EOL 的叶子节点
  const textLeaves = leaves.slice(0, -1);
  const nodes = textLeaves.map(n => {
    const node = <LeafModel key={n.key} editor={editor} leafState={n} />;
    JSX_TO_STATE.set(node, n);
    return node;
  });
  return nodes;
}, [editor, lineState]);

接下来,就根据elements的顺序来组合包装节点了,在这里之需要一个O(n)的遍历即可。我们需要为状态设置一个key值,以便于判断当前节点和二级的遍历节点是否需要合并,如何需要合并则进入合并逻辑。

export const getWrapSymbol = (keys: string[], el: JSX.Element | undefined): string | null => {
  const attrs = state.op.attributes;
  const suite: string[] = [];
  for (const key of keys) {
    attrs[key] && suite.push(`${key}${attrs[key]}`);
  }
  const symbol = suite.join("");
  return symbol;
};

紧接着就可以遍历elements来组合包装节点了,每个节点都需要判断下一个节点是否需要合并。顺序进行二次迭代,当出现连续的symbol相等时,说明是需要合并的,这里特别注意如果下一个节点不能合并,则需要回退i,以便于外层主循环时重新检查。

// 执行到此处说明需要包装相关节点(即使仅单个节点)
const nodes: JSX.Element[] = [element];
for (let k = i + 1; k < len; ++k) {
  const next = elements[k];
  const nextSymbol = getWrapSymbol(keys, next);
  if (!next || !nextSymbol || nextSymbol !== symbol) {
    // 回退到上一个值, 以便下次循环时重新检查
    i = k - 1;
    break;
  }
  nodes.push(next);
  i = k;
}

最后,我们之需要调度插件来渲染具体的React节点就可以了,这部分就是完全依靠React的渲染机制来实现,而其中key值目前则是直接使用了起始和结束的索引值。不过后续这个key值可能需要根据symbol来生成,以确保在合并时能够正确处理。

// 通过插件渲染包装节点
let wrapper: React.ReactNode = nodes;
const op = line.op;
for (const plugin of plugins) {
  // 这里的状态以首个节点为准
  const context: ReactWrapLineContext = {
    lineState: line,
    children: wrapper,
  };
  if (plugin.match(line.op.attributes || {}, op) && plugin.wrapLine) {
    wrapper = plugin.wrapLine(context);
  }
}
const key = `${i - nodes.length + 1}-${i}`;
wrapped.push(<React.Fragment key={key}>{wrapper}</React.Fragment>);

Portal 外部节点挂载

在实现诸如Mention、划词改写等模块时,通常需要额外的辅助节点来渲染面板,例如Mention需要唤醒额外的面板来选择要at的对象,并且需要在此基础上实现诸如上下选择、回车等交互。

这种情况下,Mention面板通常是不会渲染在编辑器内部的,需要额外的节点来渲染这个面板。因此在实现编辑器模块时,是额外渲染了一个mount-dom作为辅助节点的容器,以此作为原始的DOM结构提供给ReactDOM来渲染。

const onMountRef = (e: HTMLElement | null) => {
  e && MountNode.set(editor, e);
};

<BlockKit editor={editor} readonly={readonly}>
  <div className="block-kit-editable-container">
    <div className="block-kit-mount-dom" ref={onMountRef}></div>
    <Editable></Editable>
  </div>
</BlockKit>

ReactDOM.render来渲染节点时,是不能够直接将该节点作为容器的,因为调用时并非直接追加React节点到DOM节点,而是直接将React节点渲染到该节点上。因此这种情况下,若是存在多个需要挂载的辅助节点,是无法完成的。

ReactDOM.render("string", document.getElementById("root"));

因此这里渲染辅助元素时,需要先将此节点作为容器,创建一个新的容器子节点,然后将该节点作为容器调用ReactDOM.render方法来渲染React节点。在最开始的时候,编辑器中的Mention面板是类似下面的实现:

if (!this.mountSuggestNode) {
  this.mountSuggestNode = document.createElement("div");
  this.mountSuggestNode.dataset.type = "mention";
  MountNode.get(this.editor).appendChild(this.mountSuggestNode);
}
const top = this.rect.top;
const left = this.rect.left;
const dom = this.mountSuggestNode!;
this.isMountSuggest = true;
ReactDOM.render(<Suggest controller={this} top={top} left={left} text={text} />, dom);

然后我们需要思考一个问题,在我们使用ReactDOM.createPortal来传送到目标节点时,更加类似于追加节点的方式来实现,而不是需要向上述的方式一样先创建容器再渲染节点,并且此时还可以使用Context来传递编辑器的状态。

但是createPortal没有办法像render方法那样可以直接渲染节点,其只是创建了一个Portal节点,而不是实际进行了渲染行为。因此,最终还是无法避免需要一个实际渲染的行为,相互配合起来类似于下面的实现,这样就可以将元素实际创建到body上。

const portal = ReactDOM.createPortal(
  <Suggest controller={this} top={top} left={left} text={text} />,
  document.body,
);
ReactDOM.render(portal, this.mountSuggestNode!);

那么如果类似于先前聊的Lexical的实现方式,独立控制一个Portals占位来渲染辅助节点,就可以避免使用render方法来渲染节点,并且可以直接在mount-dom追加节点而不需要再创建子容器,并且直接使用这种方法可以避免React 18createRoot方法Breaking Change

const PortalView: FC<{ editor: Editor }> = props => {
  const [portals, setPortals] = useState<O.Map<ReactPortal>>({});
  EDITOR_TO_PORTAL.set(props.editor, setPortals);

  return (
    <Fragment key="block-kit-portal-model">
      {Object.entries(portals).map(([key, node]) => (
        <Fragment key={key}>{node}</Fragment>
      ))}
    </Fragment>
  );
};

总结

先前我们讨论了零宽字符、Embed节点、Void节点等,主要是可编辑节点的组件预设。在本文中则主要讨论的是非编辑节点内容渲染,也就是占位节点、只读模式、插件模式、外部节点挂载等,主要是实现编辑器的外部节点,例如占位符号、弹出层结构等。

那么至此我们实现的编辑器的React视图层适配已经完成了,以此可以复用React的生态组件,降低了开发视图层的成本。接下来我们需要再处理Core服务的核心模块,其共同处理了编辑器的交互逻辑,例如剪贴板Clipboard、历史记录History、状态管理State等等。

每日一题

参考

Tauri 应用苹果签名踩坑实录

昨天终于心一横开了苹果开发者,一大早开了,想着我要一天搞定上架提交!然而,钱是付了,等到晚上九点多,才成功开通。好嘛,那第二天再努力吧,带着兴奋入睡,第二天一早起来开干。

事实是我把这想得太简单,出了舒适区,就真的想踏入泥沼一样寸步难行。搞了大半天才终于把不用上架的版本签好,晚上之前也把 pkg 打出来了。不容易啊!

下面讲讲一些值得注意的点吧。如果你也打算入坑 Tauri 开发,而且打算构建 macOS 应用或者 iOS 应用,记得收藏,以后会用到的。

证书

你先得搞清楚,CSR、CER、P12 这些概念分别是啥,不然肯定被 N 个证书搞得晕头转向。

在数字证书和公钥基础设施(PKI)领域,这三个缩写分别代表了证书申请、证书本身以及证书存储的不同阶段和格式。 简单来说,它们的关系可以看作是一个从申请到签发再到打包的过程。

CSR

本质:申请表

当你需要一个正式的 SSL/TLS 证书时,你首先要在服务器上生成一对密钥(私钥和公钥)。CSR 就是由你的公钥和一些身份信息(如域名、公司名称、国家等)组成的请求文件。

  • 作用: 你把这个文件发给证书颁发机构(CA,如 Digicert, Let's Encrypt)。
  • 隐喻: 办护照时填写的“申请表”。表上有你的照片(公钥)和个人资料,但它还不是护照。
  • 包含: 公钥 + 身份信息 + 数字签名。

CER

本质:正式证件

CA 收到你的 CSR 并核实身份后,会用他们的私钥对你的信息进行签名,生成一个证书文件,通常后缀是 .cer.crt

  • 作用: 安装在服务器上,向客户端证明你的身份并提供公钥。
  • 隐喻: 已经盖了章的正式“护照”。
  • 包含: 你的公钥、CA 的签名、有效期、颁发者信息。它不包含私钥。

P12

本质:全家桶安装包

.p12 是一种二进制格式的容器,它可以把私钥公钥(CER)以及中间证书链全部打包在一个文件里,并且通常由密码保护。

  • 作用: 方便迁移。比如你想把证书从一台服务器搬到另一台服务器,直接导出一个 P12 文件即可。在 iOS 开发或 Java 服务中非常常见。
  • 隐喻: 你的“保险箱”,里面装着护照(证书)、开启护照配套的钥匙(私钥)以及其他证明文件。
  • 包含: 证书 + 私钥 + (可选) 证书链。

在解决了证书本质上区别之后,你还要搞清楚苹果自己的 N 种证书。Developer ID Application 用于不上架的分发,上架还要用到 Distribution 和 Mac Installer Distribution 两个证书。

机子里证书有两个,一个 Apple Development,一个 Developer ID Application,不小心把证书导错了一次,排查又卡住。

其次 Tauri 一定程度上有点黑盒,加上对苹果应用开发不熟悉,从 Tauri 那不算太完整的文档里逐步实现签名。而且关键是这些信息还散落在 macOS Application BundlemacOS Code SigningApp Store 三个页面。

为了理清这三个页面的内容,又得把一堆苹果开发流程中的重要概念搞清楚。

概念

Entitlements

它是一组 key-value 对(权利字典),告诉操作系统“这个 App 允许使用哪些特殊能力”。例如:访问 iCloud、HomeKit、推送通知、相机、App Sandbox 等。这些权利会嵌入到 App 的二进制代码签名里。

iOS / macOS 上架时必须正确声明;Xcode 会自动生成 .entitlements 文件,签名时合并进去。

Notarization

Notarization 翻译过来就是做公证。你把已签名的 macOS App 上传给苹果,它会扫描恶意代码、检查签名问题。

扫描通过后,苹果给你的 App 发一个“公证票据”(ticket),你可以把它“钉”(staple)到 App 上。macOS Gatekeeper(门卫)看到有公证票据,就会放心让用户运行,而不会弹出应用损坏的错误

使用 Developer ID 证书在 App Store 外分发的 macOS App 必须公证。

Provisioning Profile

一个由苹果服务器签名的 .mobileprovision / .provisionprofile 文件,里面包含:

  • App ID(Bundle ID)
  • 开发者证书
  • 授权的设备列表(开发阶段)
  • 允许使用的 Entitlements 和服务

上架必须,非上架不需要。

双生配置

在搞清楚上面的概念之后就大概能明白了,Tauri 的构建配置必须分两种。

之前的一个卡点是,签名成功了也公证了,结果反而打不开,签名之前还能用 xattr -cr,现在用了都不行。

{
  // ...
  "macOS": {
    "entitlements": "./entitlements.plist",
    "signingIdentity": "Developer ID Application: XXX"
  }
  // ...
}

问了一轮 AI 以为是不知道什么原因导致的 entitlements 没写进去。但是后来又发现即使通过 codesign --force --deep --options runtime 手动把 entitlements 写进去了,依然打不开。

最后才恍然大悟,Tauri 文档上写的分开 tauri.appstore.conf 文件的必要性……

实际上打包非上架包的时候应该把 entitlements 删掉,这样反倒是打出来的包可以正常运行。于是!Mind Elixir v1.7.0 终于不用绕过安全策略,支持直接运行啦!

App Store 版本

然后发布 App Store 的版本我们外加一个配置文件 tauri.appstore.conf.json

{
  "bundle": {
    "macOS": {
      "entitlements": "./entitlements.plist",
      "signingIdentity": "Apple Distribution: Dexter Chow (9J69XMW5FC)",
      "files": {
        "embedded.provisionprofile": "./provisioning/MindElixirMac.provisionprofile"
      }
    }
  }
}

构建时运行:

pnpm tauri build --config src-tauri/tauri.appstore.conf.json --target universal-apple-darwin

App Store 版本不需要公正,跑公正只会提示你需要用 Developer ID Application 证书。因此我们需要把环境变量里公正用到的值清空,这样 Tauri 就不会自动公正了。

pnpm tauri build 之后拿到了 .app,接着还要用 pkgbuild 打包成 pkg。

这两步就用到了上面提到的两个证书:

  • Apple Distribution → 签 App 本身(.app)
  • Mac Installer Distribution → 签 安装包(.pkg)

最后使用 Transporter 上传 pkg 包(开了虚拟网卡 Transporter 传不了),注意打包兼容 Intel 芯片的 universal 包,如果不想兼容 Intel 芯片,系统要限定在 12.0 以上。

后话

我真的不敢想象没 AI 我看这些文档要看到何年何月。但是做好了,又觉得其实没那么难。所以确实,一件事做到过和没做到过就是完全不一样。没做到过你会怀疑每一个细节有问题,脑子炸炸的,做到了你就知道大致什么是没问题的。后续再处理问题就简单多了。

在 Usubeni Fantasy 阅读:ssshooter.com/tauri-mac-s…

丢掉沉重的记忆:Codex、Claude Code 与 OpenCode 的上下文压缩术

丢掉沉重的记忆:Codex、Claude Code 与 OpenCode 的上下文压缩术

在使用 AI Agent 深度参与编程任务时,你一定遇到过这种窘境:起初 AI 反应敏捷,指哪打哪;但随着对话轮次增加,它似乎开始变得越来越笨。

上下文快用完的时候,AI会着急完成导致效果不佳,社区中称作 Context Anxiety (上下文焦虑),和我们人一样,着急就容易出错。为了维持对话,Agent 必须丢掉一部分记忆(压缩 - Compact)。但怎么丢、丢掉谁、丢掉后怎么补救,成了衡量一个 Agent 运行时(Runtime)是否成熟的分水岭之一。

今天笔者就带大家拆解三款主流 CLI Agent——Codex CLIClaude CodeOpenCode。看看它们在面对同一个登录 Bug 时,是如何施展各自的"压缩大法"的。

注:本文分析基于 Codex CLI 与 OpenCode 的开源仓库逻辑,以及社区逆向研究与泄露源码对 Claude Code 运行时的验证。

场景回放:一场价值 15,400 Tokens 的登录修复

假设你正在修复一个登录接口报 401 Unauthorized 的 Bug。你召唤了 AI Agent,并经历了一番激烈的排查。

下面是这段对话的完整记录:

编号 角色 内容摘要 预估 Token
#1 System 系统提示词(含 40+ 工具定义) ~800
#2 User "登录页面报 401,帮我排查下" ~100
#3 Assistant "我先搜一下认证相关的逻辑" ~150
#4 Tool Call grep "auth" --include="*.ts" ~50
#5 Tool Result (返回 50 处搜索结果) ~2,000
#6 Assistant "搜到几处,我看看 auth.ts" ~150
#7 Tool Call read_file "src/auth.ts" ~50
#8 Tool Result (完整文件内容,约 300 行) ~3,500
#9 Assistant "找到了,token 校验没处理过期" ~300
#10 User "好,但要兼容旧的 Session 方式" ~80
#11 Assistant "明白,我再看看 middleware" ~150
#12 Tool Call read_file "src/middleware.ts" ~50
#13 Tool Result (middleware 完整内容) ~2,500
#14 Assistant "middleware 也要改,我来处理" ~200
#15 Tool Call edit_file "src/auth.ts" (patch) ~100
#16 Tool Result "Successfully applied." ~30
#17 Tool Call edit_file "src/middleware.ts" (patch) ~100
#18 Tool Result "Successfully applied." ~30
#19 Assistant "代码改好了,跑个测试看看" ~100
#20 Tool Call bash "npm test" ~50
#21 Tool Result (3 个测试失败,含堆栈) ~3,000
#22 Assistant "有 3 个测试挂了,我修一下测试用例" ~200
#23 Tool Call edit_file "src/auth.test.ts" (patch) ~150
#24 Tool Result "Successfully applied." ~30
#25 Tool Call bash "npm test" ~50
#26 Tool Result (测试全部通过,含完整输出) ~1,500

看起来不过 26 条消息,但已经吃掉了约 15,400 tokens。其中加粗的五条工具结果(#5, #8, #13, #21, #26)合计约 12,500 tokens,占了 81% 。这些数据在排查时至关重要,但 Bug 修好后,它们就变成了上下文里沉重的负担。如果不处理,下一轮对话可能因为窗口溢出而丢掉系统提示词或用户的核心需求。

Codex CLI:写一份干练的"工作交接单"

OpenAI 的 Codex CLI(源码,Rust 实现)走的是一种非常符合人类直觉的路线:总结与替换

它的核心思想可以用一句话概括:把之前的全部对话交给 LLM 写一份"工作交接摘要",然后用这份摘要替换掉原始历史。

双路径设计

Codex 提供了两条压缩路径:

  1. 本地路径compact.rs):在客户端调用 LLM 生成摘要,适用于所有模型提供商。
  2. 远程路径compact_remote.rs):直接调用 OpenAI 的内部 API 端点 responses/compact,让服务器完成压缩。仅限 OpenAI 自家模型。

注意,这里的"本地"和"远程"指的不是是否需要调用 LLM——两条路径都需要 LLM 参与,区别在于 "生成摘要"这个核心步骤跑在哪里。本地路径下,客户端自己构造摘要 Prompt(从内置模板 templates/compact/prompt.md 加载)、通过 ModelClientSession 流式调用 LLM API、再处理返回结果,整个编排流程都在你的机器上完成,所以它能对接任意模型提供商。远程路径下,客户端把准备好的对话历史和工具定义发给 OpenAI 的 compact_conversation_history 端点,由服务器完成摘要生成——但客户端并非"甩手掌柜",它在调用前后仍然承担了大量工作:调用前要修剪过长的函数调用历史、构建包含工具规范和系统指令的完整 Prompt 对象;调用后要过滤返回结果(比如丢弃过时的 developer 角色消息、只保留真实的用户和助手内容)、恢复用于 /undo 功能的 ghost snapshots、以及重新计算 token 用量。

简单说,远程路径只是把 "压缩"这一步外包给了 OpenAI 服务器,前处理和后处理仍由客户端完成。这种设计的优势在于:OpenAI 服务端很可能对这个端点做了专门优化(比如使用更经济的模型或内部缓存),这些是客户端走通用 API 做不到的。这体现了 OpenAI 对自家基础设施的垂直整合。

压缩的具体流程

当走本地路径时,Codex 会先提取最近的用户消息(硬上限约 20,000 tokens),然后发送一段简短的 Summarization Prompt 给 LLM。这段 Prompt 只有 4 个核心要点:

你正在执行一次"上下文检查点压缩"。请为另一个将接续任务的 LLM 生成一份交接摘要,包含:当前进展和关键决策、重要的约束和用户偏好、剩余待办事项、继续工作所需的关键数据。

关键词是 "交接"(Handoff) ——它不是在写会议纪要,而是在写一份让下一个人(模型)能直接上手的工作简报。

用我们的登录 Bug 场景来看:

Codex CLI 压缩前后对比

思路拆解:

注意看压缩前后的变化——所有消息变成了 4 条。Codex 极其尊重"用户意图",它会物理删除所有的 Assistant 回复和 Tool 相关消息,但会原封不动地保留所有 User 消息(#2 和 #10)。

随后,它插入一条伪造的 Assistant 消息,内容是一份结构化的交接总结。这份总结包含了任务目标、已完成项、关键架构决策和剩余待办。对于新模型来说,它不需要看那些大段的文件内容和测试堆栈,它只需要知道"测试已经修好了"就足够了。

自动触发与兜底

当 Token 用量接近模型上下文窗口上限时,Codex 会自动触发压缩(不需要用户手动执行 /compact)。如果压缩后空间还是不够,它会退而采取更激进的"头部修剪"——直接从最早的消息开始砍,确保对话能继续下去。

笔者觉得 Codex 的方案最大的优点是直觉性:交接摘要这个概念每个职场人都能理解。缺点是它比较"一刀切"——所有 AI 回复和工具结果都被替换成一段摘要,如果那段摘要遗漏了某个关键细节,就真的找不回来了。

Claude Code:三层递进的"精密遗忘"

Anthropic 出品的 Claude Code 逻辑更为细腻。它不追求一步到位的物理删除,而是设计了三层逐级加强的清理机制——从轻到重,能不动 LLM 就不动 LLM。

注:Claude Code 非开源项目,以下分析基于社区逆向工程和公开资料,具体实现可能随版本变化。

第一层:工具结果修剪(无 LLM 开销)

这是最频繁、也最轻量的一层。不需要调用 LLM,纯粹是本地的规则引擎。它在每次请求前都会自动执行。

它的逻辑很简单:

  • 始终保护最近若干个工具调用的结果(正在用的东西不能删)
  • 超出保护范围的旧工具结果 → 替换为 [Old tool result content cleared] 占位符

用我们的场景来看:

Claude Code 第一层压缩

这种做法极其聪明:它维护了 AI 的"心流"。AI 记得自己搜过代码(#4 的 tool_call 还在),也记得自己读过文件(#7 的 tool_call 还在),只是不记得搜到了什么、文件内容是什么。如果它之后真的需要再次查看,它会自己重新发起 read_file

笔者认为这一层的设计极为精妙——它实现了 "选择性失忆"而非"全面遗忘" 。就像你记得去年读过一本好书,但忘了具体内容,需要的时候再翻就好。

第二层:缓存友好策略(Prompt Cache)

这是 Claude Code 的看家本领,也是三者中独有的差异化优势

Anthropic 的 API 支持 Prompt Cache——如果你发给 API 的消息前缀和上一次请求相同,服务器可以复用之前的计算结果,大幅降低成本和延迟。

这意味着什么?在清理消息时,Claude Code 会尽量避免修改消息序列的前半部分。它采用"手术式"方案:只在尾部进行修整,确保消息开头部分保持绝对一致。这样做的代价是清理效率略低,但换来的是缓存命中率的最大化

用我们的场景来看。假设经过第一层清理后,消息序列是 #1-#26(工具结果已替换为占位符)。现在上下文仍然超标,需要进一步裁剪。一个"朴素"的做法是从最早的消息开始删——但 Claude Code 不这么干

缓存策略对比

左边的朴素策略虽然删掉了最旧的消息,看起来很合理,但代价是整个前缀都变了——API 缓存全部失效,下次请求要从头计算。右边的 Claude Code 策略则相反:它宁可少删一些,也要保证消息序列的前缀部分和上一次请求完全相同,让 Anthropic API 的 Prompt Cache 能够命中。

在长时间运行的任务中(比如你连续让 AI 帮你重构一整个模块),这种策略能带来可观的成本节省——因为每次 API 请求的大部分内容都能命中缓存,只需要为新增的尾部内容付费。

第三层:9 部分结构化 LLM 总结(最后手段)

当前两层都无法阻止上下文继续膨胀时,系统触发最终的全量总结。根据源码,自动压缩的触发阈值为 有效上下文窗口 - 13,000 tokens(其中有效窗口 = 模型上下文窗口 - min(最大输出 tokens, 20,000))。

不过,即使达到了阈值,系统也不会直接跳到 LLM 总结。自动压缩触发时,系统会优先尝试 Session Memory Compact——利用 session memory(会话记忆)中已有的结构化信息来替代完整的 LLM 调用。这意味着大多数自动压缩甚至不需要 LLM 调用。只有当 session memory 路径不可用或不够时,系统才会回退到传统的 LLM 总结流程,生成一份包含 9 个固定部分的结构化摘要

  1. 用户的原始意图
  2. 核心技术概念
  3. 关注的文件和代码
  4. 遇到的错误及修复方式
  5. 解决问题的逻辑链
  6. 所有用户消息的摘要
  7. 待办事项
  8. 当前正在做什么
  9. 建议的下一步

这份摘要的要求极其严格——Prompt 中会要求模型直接引用原文关键短语,而不是全部用自己的话改写。这是为了防止"语境漂移"(模型在复述过程中微妙地偏离原意)。

用我们的场景来看:

Claude Code 第三层压缩

压缩完成后,Claude Code 还会做一系列善后工作,笔者把它叫做 "状态重构"

  • 在新对话开头注入引导语("本次会话延续自上一段对话...")
  • 自动重新读取最近编辑过的文件(最多 5 个文件,总预算 50,000 tokens,单文件上限 5,000 tokens),确保 AI 手里有最新代码
  • 重新声明工具和技能定义
  • CLAUDE.md 中的项目规范作为系统提示语的一部分,始终常驻,不受压缩影响

用户还可以在手动压缩时附加自定义指令,比如 /compact Focus on API changes,引导压缩侧重于特定方向。

此外,系统还有一条被动兜底路径:当 API 返回 prompt_too_long 错误时,系统会自动启动一次反应式压缩并重试请求,确保用户不会因为上下文溢出而直接遇到错误中断。同时,为防止压缩反复失败导致的死循环,连续 3 次自动压缩失败后系统会暂停自动压缩功能。

Claude Code 的方案是三者中最复杂的,但也是最"省钱"的——大多数时候它只需要执行第一层的规则引擎清理,或者通过 Session Memory 路径完成压缩,根本不需要额外的 LLM 调用。

OpenCode:先修剪,再摘要的"阶梯治理"

开源界的新秀 OpenCode(源码,TypeScript + Effect-TS 实现)则提供了一种更为平衡的策略。它在 session/compaction.ts 中实现了一套阶梯式的治理流程:先用低成本手段尽可能腾空间,实在不够再动用 LLM。

第一步:Prune(标记隐藏,非物理删除)

OpenCode 的第一个动作不是删除,而是"标记"。它的规则非常清晰:

  • 只有当修剪能释放超过 20,000 tokens 时才执行(小修小补不值得折腾)
  • 始终保留最近的 40,000 tokens 作为"安全垫"(正在进行的工作不能动)
  • skill 类型的工具输出永远不修剪(因为里面包含操作指令)
  • 保护最近 2 个用户回合的完整内容

关键设计:和 Claude Code 的占位符替换不同,OpenCode 的修剪不是物理删除,而是给旧消息打上一个 compacted = Date.now() 的时间戳标记,让它们在后续请求中"不可见"。数据其实还在数据库里,只是被隐藏了。

OpenCode Prune

关键点: 数据并没有真正丢掉。这为未来可能的历史回溯功能留下了空间——如果开发者需要审计,或者 Agent 触发了某种回溯逻辑,这些数据是可以被重新拉回上下文的。这是一个很有前瞻性的设计。

第二步:LLM 5 标题摘要

如果 Prune 之后还是太臃肿,OpenCode 会用一个隐藏的、专门的 Agent(不干扰用户当前的交互)来调用 LLM 生成一份摘要。这份摘要有一个固定的 5 标题结构:

OpenCode LLM 摘要

OpenCode 在摘要后有一个非常温馨的设计:它会自动重放最后一条用户消息。这能确保 Agent 的最后记忆点始终停留在用户的最新指令上,而不是停留在一段冷冰冰的摘要总结里。用户完全感知不到压缩的发生——你说的最后一句话会被重新发送,AI 继续回答,好像什么都没发生过。

另一个亮点:OpenCode 会跟随用户的语言。如果你一直用中文交流,它的摘要也会是中文的。这对非英语母语的开发者来说,是一个很友好的设计。

笔者觉得 OpenCode 的方案在三者中最"开发者友好"——代码全开源(TypeScript),架构现代(Effect-TS),非物理删除的设计为扩展留足空间。如果你想深度定制压缩行为,OpenCode 是最容易上手的。

三剑客同台竞技

我们将三者的方案放在一起并排观察:

输入:26 条消息, ~15,400 tokens(同一个"修登录 bug"场景)

三剑客对比

维度 Codex CLI Claude Code OpenCode
压缩层次 单层(摘要) 三层(修剪/缓存/摘要) 两层(隐藏/摘要)
LLM 调用 必须 仅在第三层 仅在第二步
用户消息 永久保留原始内容 摘要化(第三层) 摘要化 + 重放最后一条
工具结果处理 物理删除 占位符替换 时间戳标记隐藏
缓存优化 无特殊设计 深度集成 Prompt Cache 侧重减少重复读取
压缩后行为 被动等待 主动重读相关文件 自动重放最后指令

一些值得展开说的差异

关于"要不要保留用户原话" :Codex 选择保留用户消息、只压缩模型回复,这样做的好处是 AI 永远能回看你说过什么,但代价是当用户消息本身很长时,压缩效率会打折扣。Claude Code 和 OpenCode 则选择全部压缩为摘要,更激进但更节省空间。

关于缓存:这是 Claude Code 最独特的优势。其他两家在压缩后,API 请求的内容会发生很大变化,之前的缓存基本作废。而 Claude Code 刻意维持消息前缀的稳定性,使得压缩后的请求依然能复用之前的缓存。在长时间运行的任务中,这意味着可观的成本节省。

关于"非物理删除" :OpenCode 的时间戳标记方式是个很有前瞻性的设计。虽然当前版本并没有实现历史回溯功能,但数据没有真正丢失,为未来留下了可能性。而 Codex 和 Claude Code 的压缩都是不可逆的。

最后

如果用一个类比来形容这三位:

  • Codex CLI 像是一个写交接单的资深员工。他直接撕掉之前的草稿纸,给你一张写的清清楚楚的现状说明,虽然简单粗暴,但非常有效。
  • Claude Code 像是一个拥有精密遗忘能力的学者。他优先划掉书上的细碎批注,只有在书架实在堆不下时,才会把整本书浓缩成一页大纲。他非常在意翻书的效率(缓存)。
  • OpenCode 像是一个务实的阶梯治理者。他先给旧文件打包贴上标签(隐藏),实在不行才做总结。他最贴心的地方在于,总结完后还会提醒你:"你刚才最后说的是这件事对吧?"

归根结底,在 2026 年,最好的上下文管理并不是无止境地扩大 LLM 的记忆容量,而是学会如何精密地遗忘。毕竟,一个什么都记得住的 Agent,往往也最容易被噪音干扰。


参考来源:

前端性能优化:从"术"到"道"的完整修炼指南

前端性能优化:从"术"到"道"的完整修炼指南

摘要: 针对前端工程师在性能优化中"背了技巧却不会用"的普遍困境,本文提出"术道结合"的双层学习路径。"术"篇聚焦代码实战,通过一个电商详情页从5.2秒优化到1.9秒的完整案例,提供可直接复用的Vue 3/Vite配置、Web Worker组件和缓存策略;"道"篇升华至方法论层面,建立"网络-资源-渲染-计算"四层优化模型,涵盖从测量、验证到监控的闭环体系,并给出面试话术与避坑指南。两篇文章形成"先动手、后动脑"的认知递进,帮助读者既解决眼前问题,又建立长期可迁移的优化能力。

在这里插入图片描述在这里插入图片描述

写在前面:性能优化是前端工程师的必修课,但很多人困在"背了20个技巧却不知道何时用"的窘境。这两篇文章,一篇教你动手做,一篇教你动脑想,形成从代码到思维的完整闭环。


为什么性能优化总让人"似懂非懂"

面试时侃侃而谈"懒加载、CDN、压缩合并",真遇到首屏5秒的白屏页面却无从下手——这是前端工程师的常见困境。

根源在于:市面上的教程大多是"技巧清单",缺乏两个关键维度:

缺失维度 后果
没有分层思维 分不清是网络慢、代码慢还是渲染慢,优化打不到七寸
没有闭环意识 优化完不测、不监控,三个月后性能又劣化到原点
没有边界判断 为了追1秒的加载时间,写出难以维护的晦涩代码

这两篇文章,就是为解决这三个"没有"而写。


两篇文章的定位与阅读指南

我们将性能优化的能力拆解为两个层次:

┌─────────────────────────────────────────┐
│  第二层:"道"篇 —— 思维框架与工程哲学        │
│  《前端性能优化的底层逻辑》                 │
│                                        │
│  • 四层优化模型(网络→资源→渲染→计算)    │
│  • 从测量到监控的完整闭环                 │
│  • 知道"何时不做优化"的边界判断           │
│  • 面试话术与团队规范建设                 │
└─────────────────────────────────────────┘
                    ▲
                    │ 升华
┌─────────────────────────────────────────┐
│  第一层:"术"篇 —— 手把手的代码教程        │
│  《从5.2秒到1.9秒的代码级改造全记录》      │
│                                         │
│  • Vue 3 + Vite 的 manualChunks 实战     │
│  • Web Worker + Service Worker 完整代码    │
│  • 响应式图片组件(可直接复用)            │
│  • Lighthouse 从32分到89分的具体数据       │
└─────────────────────────────────────────┘

阅读建议

  • 急用先学:直接读"术"篇,抄代码解决眼前问题
  • 长期建设:再读"道"篇,建立可迁移的优化思维
  • 面试准备:重点读"道"篇的面试话术章节,用"术"篇的数据做支撑
第一篇 第二篇
角色定位 "术"篇:手把手的代码教程 "道"篇:思维框架与工程哲学
读者收获 拿到可直接用的配置和组件 建立可迁移的优化分析能力
阅读顺序 先读:解决"怎么做" 后读:理解"为什么做"和"何时不做"
预告 "下一篇将分享如何建立优化思维框架,避免过度优化陷阱" 开篇回顾:"在上一篇的实战基础上,今天我们跳出代码,聊聊性能优化的底层逻辑"

第一篇预告:"术"篇

摘要:《前端性能优化实战:从5.2秒到1.9秒的代码级改造全记录》 记录了一个电商商品详情页的性能优化完整过程。针对首屏加载5.2秒、Lighthouse评分32分的现状,采用分层优化策略:资源层通过智能图片组件实现WebP格式自适应与懒加载,构建层借助Webpack/Vite的manualChunks精细化分割代码,渲染层利用Web Workers将长任务从主线程剥离,网络层实施Service Worker的Stale-While-Revalidate缓存与关键资源预加载。优化后LCP降至1.9秒,Lighthouse提升至89分,跳出率下降28%。文中提供全部可运行代码,包括OptimizedImage组件、Worker通信逻辑及Vite配置,可直接应用于生产环境。

核心内容

  • 一个电商商品详情页的完整优化实录
  • 分层优化的具体代码:资源层(图片)、构建层(Webpack/Vite)、渲染层(Worker)、网络层(预加载)
  • 可直接复制的组件:OptimizedImage、Service Worker 缓存策略、Vue 3 异步组件

你将获得:一份能直接运行的"性能优化工具箱"


第二篇预告:"道"篇

摘要:《前端性能优化的底层逻辑:从"会写代码"到"会诊断问题"的进阶之路》 跳出具体技术栈,本文构建了一套前端性能优化的通用方法论。首先阐述关键渲染路径(CRP)原理,提出"最小改动验证"的科学优化流程;继而建立"网络层-资源层-渲染层-计算层"四层分析模型,明确各层优化手段与边界;随后完善从本地Lighthouse验证、线上真实用户监控(RUM)到性能预算防控的完整闭环;最后针对preconnect滥用、Web Worker序列化开销、虚拟滚动限制等场景给出避坑指南,并提供可直接使用的面试STAR话术。本文适用于React/Vue双栈开发者及准备前端面试的工程师,帮助建立"诊断-分层-验证-监控"的系统化优化思维。

核心内容

  • 关键渲染路径(CRP)原理与最小验证法
  • 四层优化模型的抽象与应用
  • 性能监控闭环:本地验证 → 线上RUM → 性能预算
  • 避坑指南:preconnect滥用、Worker开销、过度优化陷阱
  • 面试话术:如何用STAR法则讲一个完整的性能优化故事

你将获得:一套能应对任何技术栈的"性能优化方法论"


一句话总结

"术"篇让你能把眼前项目的性能优化到1.9秒,"道"篇让你能回答"为什么是1.9秒而不是0.9秒,以及怎么保证三个月后还是1.9秒"。

两篇文章,从代码到思维,从实战到哲学,构成前端性能优化的完整修炼路径。

接下来,我们先从"术"篇开始。


总章完

---# 前端性能优化:从"术"到"道"的完整修炼指南

摘要: 针对前端工程师在性能优化中"背了技巧却不会用"的普遍困境,本文提出"术道结合"的双层学习路径。"术"篇聚焦代码实战,通过一个电商详情页从5.2秒优化到1.9秒的完整案例,提供可直接复用的Vue 3/Vite配置、Web Worker组件和缓存策略;"道"篇升华至方法论层面,建立"网络-资源-渲染-计算"四层优化模型,涵盖从测量、验证到监控的闭环体系,并给出面试话术与避坑指南。两篇文章形成"先动手、后动脑"的认知递进,帮助读者既解决眼前问题,又建立长期可迁移的优化能力。

在这里插入图片描述在这里插入图片描述

写在前面:性能优化是前端工程师的必修课,但很多人困在"背了20个技巧却不知道何时用"的窘境。这两篇文章,一篇教你动手做,一篇教你动脑想,形成从代码到思维的完整闭环。


为什么性能优化总让人"似懂非懂"

面试时侃侃而谈"懒加载、CDN、压缩合并",真遇到首屏5秒的白屏页面却无从下手——这是前端工程师的常见困境。

根源在于:市面上的教程大多是"技巧清单",缺乏两个关键维度:

缺失维度 后果
没有分层思维 分不清是网络慢、代码慢还是渲染慢,优化打不到七寸
没有闭环意识 优化完不测、不监控,三个月后性能又劣化到原点
没有边界判断 为了追1秒的加载时间,写出难以维护的晦涩代码

这两篇文章,就是为解决这三个"没有"而写。


两篇文章的定位与阅读指南

我们将性能优化的能力拆解为两个层次:

┌─────────────────────────────────────────┐
│  第二层:"道"篇 —— 思维框架与工程哲学        │
│  《前端性能优化的底层逻辑》                 │
│                                        │
│  • 四层优化模型(网络→资源→渲染→计算)    │
│  • 从测量到监控的完整闭环                 │
│  • 知道"何时不做优化"的边界判断           │
│  • 面试话术与团队规范建设                 │
└─────────────────────────────────────────┘
                    ▲
                    │ 升华
┌─────────────────────────────────────────┐
│  第一层:"术"篇 —— 手把手的代码教程        │
│  《从5.2秒到1.9秒的代码级改造全记录》      │
│                                         │
│  • Vue 3 + Vite 的 manualChunks 实战     │
│  • Web Worker + Service Worker 完整代码    │
│  • 响应式图片组件(可直接复用)            │
│  • Lighthouse 从32分到89分的具体数据       │
└─────────────────────────────────────────┘

阅读建议

  • 急用先学:直接读"术"篇,抄代码解决眼前问题
  • 长期建设:再读"道"篇,建立可迁移的优化思维
  • 面试准备:重点读"道"篇的面试话术章节,用"术"篇的数据做支撑
第一篇 第二篇
角色定位 "术"篇:手把手的代码教程 "道"篇:思维框架与工程哲学
读者收获 拿到可直接用的配置和组件 建立可迁移的优化分析能力
阅读顺序 先读:解决"怎么做" 后读:理解"为什么做"和"何时不做"
预告 "下一篇将分享如何建立优化思维框架,避免过度优化陷阱" 开篇回顾:"在上一篇的实战基础上,今天我们跳出代码,聊聊性能优化的底层逻辑"

第一篇预告:"术"篇

摘要:《前端性能优化实战:从5.2秒到1.9秒的代码级改造全记录》 记录了一个电商商品详情页的性能优化完整过程。针对首屏加载5.2秒、Lighthouse评分32分的现状,采用分层优化策略:资源层通过智能图片组件实现WebP格式自适应与懒加载,构建层借助Webpack/Vite的manualChunks精细化分割代码,渲染层利用Web Workers将长任务从主线程剥离,网络层实施Service Worker的Stale-While-Revalidate缓存与关键资源预加载。优化后LCP降至1.9秒,Lighthouse提升至89分,跳出率下降28%。文中提供全部可运行代码,包括OptimizedImage组件、Worker通信逻辑及Vite配置,可直接应用于生产环境。

核心内容

  • 一个电商商品详情页的完整优化实录
  • 分层优化的具体代码:资源层(图片)、构建层(Webpack/Vite)、渲染层(Worker)、网络层(预加载)
  • 可直接复制的组件:OptimizedImage、Service Worker 缓存策略、Vue 3 异步组件

你将获得:一份能直接运行的"性能优化工具箱"


第二篇预告:"道"篇

摘要:《前端性能优化的底层逻辑:从"会写代码"到"会诊断问题"的进阶之路》 跳出具体技术栈,本文构建了一套前端性能优化的通用方法论。首先阐述关键渲染路径(CRP)原理,提出"最小改动验证"的科学优化流程;继而建立"网络层-资源层-渲染层-计算层"四层分析模型,明确各层优化手段与边界;随后完善从本地Lighthouse验证、线上真实用户监控(RUM)到性能预算防控的完整闭环;最后针对preconnect滥用、Web Worker序列化开销、虚拟滚动限制等场景给出避坑指南,并提供可直接使用的面试STAR话术。本文适用于React/Vue双栈开发者及准备前端面试的工程师,帮助建立"诊断-分层-验证-监控"的系统化优化思维。

核心内容

  • 关键渲染路径(CRP)原理与最小验证法
  • 四层优化模型的抽象与应用
  • 性能监控闭环:本地验证 → 线上RUM → 性能预算
  • 避坑指南:preconnect滥用、Worker开销、过度优化陷阱
  • 面试话术:如何用STAR法则讲一个完整的性能优化故事

你将获得:一套能应对任何技术栈的"性能优化方法论"


一句话总结

"术"篇让你能把眼前项目的性能优化到1.9秒,"道"篇让你能回答"为什么是1.9秒而不是0.9秒,以及怎么保证三个月后还是1.9秒"。

两篇文章,从代码到思维,从实战到哲学,构成前端性能优化的完整修炼路径。

接下来,我们先从"术"篇开始。


总章完

---# 前端性能优化:从"术"到"道"的完整修炼指南

摘要: 针对前端工程师在性能优化中"背了技巧却不会用"的普遍困境,本文提出"术道结合"的双层学习路径。"术"篇聚焦代码实战,通过一个电商详情页从5.2秒优化到1.9秒的完整案例,提供可直接复用的Vue 3/Vite配置、Web Worker组件和缓存策略;"道"篇升华至方法论层面,建立"网络-资源-渲染-计算"四层优化模型,涵盖从测量、验证到监控的闭环体系,并给出面试话术与避坑指南。两篇文章形成"先动手、后动脑"的认知递进,帮助读者既解决眼前问题,又建立长期可迁移的优化能力。

在这里插入图片描述在这里插入图片描述

写在前面:性能优化是前端工程师的必修课,但很多人困在"背了20个技巧却不知道何时用"的窘境。这两篇文章,一篇教你动手做,一篇教你动脑想,形成从代码到思维的完整闭环。


为什么性能优化总让人"似懂非懂"

面试时侃侃而谈"懒加载、CDN、压缩合并",真遇到首屏5秒的白屏页面却无从下手——这是前端工程师的常见困境。

根源在于:市面上的教程大多是"技巧清单",缺乏两个关键维度:

缺失维度 后果
没有分层思维 分不清是网络慢、代码慢还是渲染慢,优化打不到七寸
没有闭环意识 优化完不测、不监控,三个月后性能又劣化到原点
没有边界判断 为了追1秒的加载时间,写出难以维护的晦涩代码

这两篇文章,就是为解决这三个"没有"而写。


两篇文章的定位与阅读指南

我们将性能优化的能力拆解为两个层次:

┌─────────────────────────────────────────┐
│  第二层:"道"篇 —— 思维框架与工程哲学        │
│  《前端性能优化的底层逻辑》                 │
│                                        │
│  • 四层优化模型(网络→资源→渲染→计算)    │
│  • 从测量到监控的完整闭环                 │
│  • 知道"何时不做优化"的边界判断           │
│  • 面试话术与团队规范建设                 │
└─────────────────────────────────────────┘
                    ▲
                    │ 升华
┌─────────────────────────────────────────┐
│  第一层:"术"篇 —— 手把手的代码教程        │
│  《从5.2秒到1.9秒的代码级改造全记录》      │
│                                         │
│  • Vue 3 + Vite 的 manualChunks 实战     │
│  • Web Worker + Service Worker 完整代码    │
│  • 响应式图片组件(可直接复用)            │
│  • Lighthouse 从32分到89分的具体数据       │
└─────────────────────────────────────────┘

阅读建议

  • 急用先学:直接读"术"篇,抄代码解决眼前问题
  • 长期建设:再读"道"篇,建立可迁移的优化思维
  • 面试准备:重点读"道"篇的面试话术章节,用"术"篇的数据做支撑
第一篇 第二篇
角色定位 "术"篇:手把手的代码教程 "道"篇:思维框架与工程哲学
读者收获 拿到可直接用的配置和组件 建立可迁移的优化分析能力
阅读顺序 先读:解决"怎么做" 后读:理解"为什么做"和"何时不做"
预告 "下一篇将分享如何建立优化思维框架,避免过度优化陷阱" 开篇回顾:"在上一篇的实战基础上,今天我们跳出代码,聊聊性能优化的底层逻辑"

第一篇预告:"术"篇

摘要:《前端性能优化实战:从5.2秒到1.9秒的代码级改造全记录》 记录了一个电商商品详情页的性能优化完整过程。针对首屏加载5.2秒、Lighthouse评分32分的现状,采用分层优化策略:资源层通过智能图片组件实现WebP格式自适应与懒加载,构建层借助Webpack/Vite的manualChunks精细化分割代码,渲染层利用Web Workers将长任务从主线程剥离,网络层实施Service Worker的Stale-While-Revalidate缓存与关键资源预加载。优化后LCP降至1.9秒,Lighthouse提升至89分,跳出率下降28%。文中提供全部可运行代码,包括OptimizedImage组件、Worker通信逻辑及Vite配置,可直接应用于生产环境。

核心内容

  • 一个电商商品详情页的完整优化实录
  • 分层优化的具体代码:资源层(图片)、构建层(Webpack/Vite)、渲染层(Worker)、网络层(预加载)
  • 可直接复制的组件:OptimizedImage、Service Worker 缓存策略、Vue 3 异步组件

你将获得:一份能直接运行的"性能优化工具箱"


第二篇预告:"道"篇

摘要:《前端性能优化的底层逻辑:从"会写代码"到"会诊断问题"的进阶之路》 跳出具体技术栈,本文构建了一套前端性能优化的通用方法论。首先阐述关键渲染路径(CRP)原理,提出"最小改动验证"的科学优化流程;继而建立"网络层-资源层-渲染层-计算层"四层分析模型,明确各层优化手段与边界;随后完善从本地Lighthouse验证、线上真实用户监控(RUM)到性能预算防控的完整闭环;最后针对preconnect滥用、Web Worker序列化开销、虚拟滚动限制等场景给出避坑指南,并提供可直接使用的面试STAR话术。本文适用于React/Vue双栈开发者及准备前端面试的工程师,帮助建立"诊断-分层-验证-监控"的系统化优化思维。

核心内容

  • 关键渲染路径(CRP)原理与最小验证法
  • 四层优化模型的抽象与应用
  • 性能监控闭环:本地验证 → 线上RUM → 性能预算
  • 避坑指南:preconnect滥用、Worker开销、过度优化陷阱
  • 面试话术:如何用STAR法则讲一个完整的性能优化故事

你将获得:一套能应对任何技术栈的"性能优化方法论"


一句话总结

"术"篇让你能把眼前项目的性能优化到1.9秒,"道"篇让你能回答"为什么是1.9秒而不是0.9秒,以及怎么保证三个月后还是1.9秒"。

两篇文章,从代码到思维,从实战到哲学,构成前端性能优化的完整修炼路径。

接下来,我们先从"术"篇开始。


总章完

---# 前端性能优化:从"术"到"道"的完整修炼指南

摘要: 针对前端工程师在性能优化中"背了技巧却不会用"的普遍困境,本文提出"术道结合"的双层学习路径。"术"篇聚焦代码实战,通过一个电商详情页从5.2秒优化到1.9秒的完整案例,提供可直接复用的Vue 3/Vite配置、Web Worker组件和缓存策略;"道"篇升华至方法论层面,建立"网络-资源-渲染-计算"四层优化模型,涵盖从测量、验证到监控的闭环体系,并给出面试话术与避坑指南。两篇文章形成"先动手、后动脑"的认知递进,帮助读者既解决眼前问题,又建立长期可迁移的优化能力。

在这里插入图片描述在这里插入图片描述

写在前面:性能优化是前端工程师的必修课,但很多人困在"背了20个技巧却不知道何时用"的窘境。这两篇文章,一篇教你动手做,一篇教你动脑想,形成从代码到思维的完整闭环。


为什么性能优化总让人"似懂非懂"

面试时侃侃而谈"懒加载、CDN、压缩合并",真遇到首屏5秒的白屏页面却无从下手——这是前端工程师的常见困境。

根源在于:市面上的教程大多是"技巧清单",缺乏两个关键维度:

缺失维度 后果
没有分层思维 分不清是网络慢、代码慢还是渲染慢,优化打不到七寸
没有闭环意识 优化完不测、不监控,三个月后性能又劣化到原点
没有边界判断 为了追1秒的加载时间,写出难以维护的晦涩代码

这两篇文章,就是为解决这三个"没有"而写。


两篇文章的定位与阅读指南

我们将性能优化的能力拆解为两个层次:

┌─────────────────────────────────────────┐
│  第二层:"道"篇 —— 思维框架与工程哲学        │
│  《前端性能优化的底层逻辑》                 │
│                                        │
│  • 四层优化模型(网络→资源→渲染→计算)    │
│  • 从测量到监控的完整闭环                 │
│  • 知道"何时不做优化"的边界判断           │
│  • 面试话术与团队规范建设                 │
└─────────────────────────────────────────┘
                    ▲
                    │ 升华
┌─────────────────────────────────────────┐
│  第一层:"术"篇 —— 手把手的代码教程        │
│  《从5.2秒到1.9秒的代码级改造全记录》      │
│                                         │
│  • Vue 3 + Vite 的 manualChunks 实战     │
│  • Web Worker + Service Worker 完整代码    │
│  • 响应式图片组件(可直接复用)            │
│  • Lighthouse 从32分到89分的具体数据       │
└─────────────────────────────────────────┘

阅读建议

  • 急用先学:直接读"术"篇,抄代码解决眼前问题
  • 长期建设:再读"道"篇,建立可迁移的优化思维
  • 面试准备:重点读"道"篇的面试话术章节,用"术"篇的数据做支撑
第一篇 第二篇
角色定位 "术"篇:手把手的代码教程 "道"篇:思维框架与工程哲学
读者收获 拿到可直接用的配置和组件 建立可迁移的优化分析能力
阅读顺序 先读:解决"怎么做" 后读:理解"为什么做"和"何时不做"
预告 "下一篇将分享如何建立优化思维框架,避免过度优化陷阱" 开篇回顾:"在上一篇的实战基础上,今天我们跳出代码,聊聊性能优化的底层逻辑"

第一篇预告:"术"篇

摘要:《前端性能优化实战:从5.2秒到1.9秒的代码级改造全记录》 记录了一个电商商品详情页的性能优化完整过程。针对首屏加载5.2秒、Lighthouse评分32分的现状,采用分层优化策略:资源层通过智能图片组件实现WebP格式自适应与懒加载,构建层借助Webpack/Vite的manualChunks精细化分割代码,渲染层利用Web Workers将长任务从主线程剥离,网络层实施Service Worker的Stale-While-Revalidate缓存与关键资源预加载。优化后LCP降至1.9秒,Lighthouse提升至89分,跳出率下降28%。文中提供全部可运行代码,包括OptimizedImage组件、Worker通信逻辑及Vite配置,可直接应用于生产环境。

核心内容

  • 一个电商商品详情页的完整优化实录
  • 分层优化的具体代码:资源层(图片)、构建层(Webpack/Vite)、渲染层(Worker)、网络层(预加载)
  • 可直接复制的组件:OptimizedImage、Service Worker 缓存策略、Vue 3 异步组件

你将获得:一份能直接运行的"性能优化工具箱"


第二篇预告:"道"篇

摘要:《前端性能优化的底层逻辑:从"会写代码"到"会诊断问题"的进阶之路》 跳出具体技术栈,本文构建了一套前端性能优化的通用方法论。首先阐述关键渲染路径(CRP)原理,提出"最小改动验证"的科学优化流程;继而建立"网络层-资源层-渲染层-计算层"四层分析模型,明确各层优化手段与边界;随后完善从本地Lighthouse验证、线上真实用户监控(RUM)到性能预算防控的完整闭环;最后针对preconnect滥用、Web Worker序列化开销、虚拟滚动限制等场景给出避坑指南,并提供可直接使用的面试STAR话术。本文适用于React/Vue双栈开发者及准备前端面试的工程师,帮助建立"诊断-分层-验证-监控"的系统化优化思维。

核心内容

  • 关键渲染路径(CRP)原理与最小验证法
  • 四层优化模型的抽象与应用
  • 性能监控闭环:本地验证 → 线上RUM → 性能预算
  • 避坑指南:preconnect滥用、Worker开销、过度优化陷阱
  • 面试话术:如何用STAR法则讲一个完整的性能优化故事

你将获得:一套能应对任何技术栈的"性能优化方法论"


一句话总结

"术"篇让你能把眼前项目的性能优化到1.9秒,"道"篇让你能回答"为什么是1.9秒而不是0.9秒,以及怎么保证三个月后还是1.9秒"。

两篇文章,从代码到思维,从实战到哲学,构成前端性能优化的完整修炼路径。

接下来,我们先从"术"篇开始。


总章完


欢迎交流讨论,共同提升前端工程化水平。更多文章

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

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

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

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


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

1.1 JSX 元素的"坐标标记"

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

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

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

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

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

Why this design?

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

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

What if alternative?

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

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

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

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

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

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

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

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

Why this design?

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

What if alternative?

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

2.2 快捷键系统与事件拦截

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

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

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

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

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


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

3.1 InspectAgent 接口设计

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

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

  deactivate: () => void;

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

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

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

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

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

Why this design?

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

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

What if alternative?

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

3.2 DOMInspectAgent 的实现

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

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

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

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

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

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

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

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

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

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

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

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

4.1 从 DOM 元素获取 Fiber

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

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

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

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

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

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

Why this design?

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

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

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

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

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

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

  const originReferenceFiber = referenceFiber

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

  return originReferenceFiber
}

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

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

What if alternative?

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

4.3 Render Chain vs Source Chain

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

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

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


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

5.1 客户端发起请求

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

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

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

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

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

5.2 服务端 Middleware 处理

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

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

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

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

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

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

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

Why this design?

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

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

What if alternative?

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

5.3 完整的调用链路

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

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

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

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

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

6.1 Overlay 高亮组件

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

  constructor() {
    customElement(InspectorOverlayTagName, InspectorOverlay)

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

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

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

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

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

6.2 InspectContextPanel 右键菜单

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

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

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

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

第七章:设计模式总结

7.1 分层架构

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

7.2 关键设计决策

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

总结:可借鉴的架构模式

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

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

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

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

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


参考链接

当AI Agent开始"职场内卷":你需要一个Agent Harness来当"项目经理"

从"单兵作战"到"团队混乱",再到"有序协作"的进化之路


引言:从"单兵作战"到"团队混乱"

各位程序员老铁们,你们有没有遇到过这种情况?

刚开始用AI的时候,觉得ChatGPT简直是神队友。写代码、改BUG、写文档,样样精通。你让它干啥它干啥,从不抱怨,从不请假,更不会在代码评审会上跟你argue设计模式。

但是! 当你开始玩起"多Agent协作"的时候,事情就变得微妙了。

想象一下这个场景:

Agent A(代码生成专员):"我已经写好了一个用户登录模块,采用了最新的JWT+Redis方案,代码整洁,注释完善,可以合并。"

Agent B(安全审查专员):"等等!这代码有SQL注入风险,第45行直接拼接了用户输入,必须整改!"

Agent C(性能优化专员):"而且你们注意到没有?这个查询没有加索引,用户量一上来数据库就挂了!"

Agent D(架构师):"我觉得我们应该用微服务架构,把这个模块拆分成认证服务、用户服务、会话服务..."

Agent E(产品经理):"其实用户只需要一个简单的登录框,你们能不能先做出来让我看看效果?"

此时此刻,作为人类程序员的你,看着这五个AI在终端里吵得不可开交,内心只有一个念头:

"我特么只是想加一个登录功能啊!!!"

这就是我们今天要聊的Agent Harness——一个用来管理这些"AI职场人"的"项目经理框架"。


第一章:当AI们开始"各说各话"

1.1 多Agent的美好幻想 vs 残酷现实

在理想世界里,多Agent协作是这样的:

  • 需求分析Agent先出马,把需求文档写得明明白白
  • 架构设计Agent紧随其后,画出完美的架构图
  • 代码生成Agent撸起袖子就是干,代码质量杠杠的
  • 测试Agent自动补全测试用例,覆盖率100%
  • 文档Agent同步更新文档,一个字都不用你改

听起来很美好对吧?

但实际上,现实往往是这样的:

第一幕:需求理解分歧

  • 需求分析Agent:"用户需要一个电商系统。"
  • 产品经理Agent:"不对,用户要的是社交电商,要有分享功能!"
  • UX设计Agent:"我觉得应该先做个用户调研..."

第二幕:技术选型战争

  • 后端Agent:"用Node.js,全栈JavaScript!"
  • 另一个后端Agent:"开玩笑,这种项目必须用Go,高并发!"
  • 架构师Agent:"你们都错了,云原生+Service Mesh才是未来!"

第三幕:代码冲突大爆炸

  • Agent A生成了UserController.ts,用了Class风格
  • Agent B在同一时间生成了user-controller.ts,用了函数式风格
  • Agent C:"我觉得应该用Vue 3..."
  • :"等等,这是个后端项目!"

1.2 为什么AI们会"吵架"?

其实这不怪AI,怪的是我们没有给它们一个统一的指挥系统

就像你让五个程序员各自为战,没有项目经理、没有技术负责人、没有代码规范、没有版本控制,最后不打架才怪。

每个Agent都是"专家",但:

  • ❌ 它们不知道其他Agent在干什么
  • ❌ 它们不知道自己的工作在整体流程中的位置
  • ❌ 它们没有一个"主心骨"来拍板决策
  • ❌ 它们更不知道何时该停手,何时该协作

这就需要一个Agent Harness——一个能够统筹管理所有Agent的"中枢神经系统"。


第二章:Agent Harness是什么?

2.1 接地气的定义

简单来说,Agent Harness就是AI Agent们的:

  • 👔 项目经理 - 分配任务、把控进度
  • 🚦 交通警察 - 指挥调度、维持秩序
  • 🤝 和事佬 - 解决冲突、协调关系

它的职责包括:

  1. 任务分配 - "你!去写代码!你!去审查!你!去边上歇会儿!"
  2. 流程编排 - "必须等设计完成才能写代码,懂不懂 waterfall?"
  3. 冲突解决 - "都别吵了!听我的!用React!"
  4. 状态管理 - "记录一下,这个Agent上次改代码把生产环境搞挂了,给它打个标签"
  5. 质量控制 - "这段代码审查不通过,打回去重写!"
  6. 资源调度 - "这个Agent今天已经生成了10000行代码了,让它休息一下吧..."

2.2 架构设计:从"菜市场"到"交响乐团"

没有Harness的多Agent系统 = 菜市场

  • 🗣️ 每个人都在大声吆喝
  • 📢 信息传递靠吼
  • 💸 交易(数据交换)混乱
  • 👣 经常有人被踩脚(资源冲突)

有了Harness之后 = 交响乐团

  • 🎼 指挥(Harness)拿着小棒站在中间
  • 🎻 乐手(Agents)各司其职
  • 🎵 乐谱(Workflow)规定好了每个人的节奏
  • 🎶 演奏出来的音乐(最终结果)和谐统一

2.3 Agent Harness的核心架构

1. 编排引擎(Orchestrator)

class AgentOrchestrator:
    def run_workflow(self, task):
        # 1. 分析任务,决定需要哪些Agent
        agents = self.select_agents(task)
        
        # 2. 制定执行计划
        plan = self.create_plan(agents, task)
        
        # 3. 按顺序或并行执行
        for step in plan:
            if step.type == "sequential":
                self.execute_sequential(step.agents)
            else:
                self.execute_parallel(step.agents)
        
        # 4. 整合结果
        return self.consolidate_results()

2. 上下文管理器(Context Manager)

负责维护共享状态,确保所有Agent都在"同一个频道"上:

class SharedContext:
    def __init__(self):
        self.state = {}
        self.history = []
    
    def update(self, agent_id, key, value):
        # 记录哪个Agent修改了什么
        self.state[key] = value
        self.history.append({
            "agent": agent_id,
            "action": "update",
            "key": key,
            "timestamp": now()
        })

3. 冲突解决器(Conflict Resolver)

当Agent们意见不一致时,需要一个"和事佬":

class ConflictResolver:
    def resolve(self, agent_opinions):
        # 策略1:投票制
        if self.strategy == "voting":
            return self.vote(agent_opinions)
        
        # 策略2:优先级制
        elif self.strategy == "priority":
            return self.select_by_priority(agent_opinions)
        
        # 策略3:人类介入
        elif self.strategy == "human_in_loop":
            return self.ask_human(agent_opinions)

4. 质量门禁(Quality Gates)

防止"渣代码"流入生产环境:

class QualityGate:
    def check(self, artifact):
        checks = [
            self.syntax_check(artifact),
            self.security_check(artifact),
            self.performance_check(artifact),
            self.style_check(artifact)
        ]
        return all(checks)

第三章:实战案例 - 让AI们"有序内卷"

3.1 场景:开发一个"用户评论系统"

假设我们要开发一个"用户评论系统",看看Agent Harness如何指挥。

阶段1:需求分析

workflow:
  name: "Comment Feature Development"
  steps:
    - name: "requirement_analysis"
      agent: "BA_Agent"
      task: "分析用户评论系统需求"
      output: "PRD文档"
    
    - name: "architecture_design"
      agent: "Architect_Agent"
      input: "PRD文档"
      task: "设计系统架构"
      output: "架构设计文档"
      depends_on: ["requirement_analysis"]

Harness的工作:

  1. 先唤醒BA_Agent,给它需求背景
  2. 等待BA_Agent产出PRD
  3. PRD通过质量检查(格式、完整性)
  4. 再唤醒Architect_Agent,把PRD喂给它

阶段2:并行开发

    - name: "backend_development"
      agent: "Backend_Dev_Agent"
      input: "架构设计文档"
      task: "开发后端API"
      output: "API代码"
      depends_on: ["architecture_design"]
    
    - name: "frontend_development"
      agent: "Frontend_Dev_Agent"
      input: "架构设计文档"
      task: "开发前端页面"
      output: "UI代码"
      depends_on: ["architecture_design"]
      
    - name: "database_design"
      agent: "DBA_Agent"
      input: "架构设计文档"
      task: "设计数据库表"
      output: "Schema定义"
      depends_on: ["architecture_design"]

Harness的工作:

  1. 检查依赖是否满足(架构设计已完成)
  2. 并行启动三个Agent
  3. 监控每个Agent的进度
  4. 如果某个Agent失败,决定是否重试或中断整个流程

阶段3:代码审查

    - name: "code_review"
      agents: ["Security_Agent", "Performance_Agent", "Style_Agent"]
      input: "所有代码"
      task: "代码审查"
      output: "审查报告"
      depends_on: ["backend_development", "frontend_development", "database_design"]
      merge_strategy: "consolidate"

这里有个有趣的点:三个审查Agent并行运行,各自关注不同方面。Harness需要合并它们的审查意见:

def merge_review_reports(reports):
    issues = []
    for report in reports:
        issues.extend(report.issues)
    
    # 去重和分类
    critical = [i for i in issues if i.severity == "critical"]
    warnings = [i for i in issues if i.severity == "warning"]
    
    if critical:
        return "REJECT", critical
    elif warnings:
        return "WARNING", warnings
    else:
        return "APPROVE", []

阶段4:冲突解决(重头戏)

假设冲突场景:

  • Backend_Agent:用了REST API
  • Frontend_Agent:期望的是GraphQL
  • Security_Agent:说"必须用HTTPS"
  • Performance_Agent:说"要加Redis缓存"

Harness的冲突解决逻辑:

class ConflictResolver:
    def resolve_api_style(self, backend_pref, frontend_pref):
        # 策略:前后端不一致时,优先满足前端(用户体验更重要)
        if backend_pref != frontend_pref:
            return {
                "decision": "使用REST + GraphQL Gateway",
                "reason": "Backend保持REST,Frontend通过Gateway访问",
                "implementation": "引入Apollo Federation"
            }
    
    def resolve_security_vs_performance(self, security_req, perf_req):
        # 策略:安全优先,性能其次
        if security_req.conflicts_with(perf_req):
            return {
                "decision": "先满足安全要求",
                "compromise": "通过优化实现方式减少性能影响",
                "action": "Security_Agent提出具体方案,Performance_Agent优化"
            }

3.2 完整代码示例

下面是一个简化的Agent Harness实现:

from typing import List, Dict, Any
from dataclasses import dataclass
from enum import Enum

class AgentStatus(Enum):
    IDLE = "idle"
    RUNNING = "running"
    COMPLETED = "completed"
    FAILED = "failed"

@dataclass
class Agent:
    id: str
    name: str
    role: str
    capabilities: List[str]
    status: AgentStatus = AgentStatus.IDLE
    output: Any = None

class AgentHarness:
    def __init__(self):
        self.agents: Dict[str, Agent] = {}
        self.context = SharedContext()
        self.orchestrator = WorkflowOrchestrator()
        self.resolver = ConflictResolver()
    
    def register_agent(self, agent: Agent):
        """注册Agent到Harness"""
        self.agents[agent.id] = agent
        print(f"✅ Agent '{agent.name}' ({agent.role}) 已注册")
    
    def execute_workflow(self, workflow: Dict):
        """执行工作流"""
        print(f"🚀 开始执行工作流: {workflow['name']}")
        
        for step in workflow['steps']:
            result = self.execute_step(step)
            
            if result['status'] == 'failed':
                print(f"❌ 步骤 '{step['name']}' 失败")
                if not self.handle_failure(step, result):
                    break
            
            self.context.update(step['name'], result['output'])
        
        print("✨ 工作流执行完成")
        return self.context.get_final_output()
    
    def execute_step(self, step: Dict) -> Dict:
        """执行单个步骤"""
        agent_id = step.get('agent')
        agent_ids = step.get('agents', [])
        
        if agent_id:
            # 单Agent执行
            return self.run_single_agent(agent_id, step)
        else:
            # 多Agent并行执行
            return self.run_multi_agents(agent_ids, step)
    
    def run_single_agent(self, agent_id: str, step: Dict) -> Dict:
        """运行单个Agent"""
        agent = self.agents[agent_id]
        agent.status = AgentStatus.RUNNING
        
        print(f"🤖 Agent '{agent.name}' 开始工作: {step['task']}")
        
        try:
            # 实际调用Agent执行任务
            output = self.invoke_agent(agent, step)
            agent.status = AgentStatus.COMPLETED
            agent.output = output
            
            # 质量检查
            if not self.quality_gate.check(output):
                return {'status': 'failed', 'error': 'Quality check failed'}
            
            return {'status': 'completed', 'output': output}
        
        except Exception as e:
            agent.status = AgentStatus.FAILED
            return {'status': 'failed', 'error': str(e)}
    
    def run_multi_agents(self, agent_ids: List[str], step: Dict) -> Dict:
        """并行运行多个Agent"""
        print(f"👥 并行启动 {len(agent_ids)} 个Agent")
        
        results = []
        for agent_id in agent_ids:
            result = self.run_single_agent(agent_id, step)
            results.append(result)
        
        # 合并结果
        merge_strategy = step.get('merge_strategy', 'concat')
        merged = self.merge_results(results, merge_strategy)
        
        # 检查冲突
        if self.has_conflicts(results):
            print("⚠️ 检测到Agent间冲突,启动冲突解决...")
            resolution = self.resolver.resolve(results)
            merged = resolution
        
        return {'status': 'completed', 'output': merged}
    
    def has_conflicts(self, results: List[Dict]) -> bool:
        """检查结果之间是否有冲突"""
        outputs = [r['output'] for r in results if r['status'] == 'completed']
        
        for i, out1 in enumerate(outputs):
            for out2 in outputs[i+1:]:
                if self.detect_conflict(out1, out2):
                    return True
        return False
    
    def detect_conflict(self, output1, output2) -> bool:
        """检测两个输出是否冲突"""
        tech1 = output1.get('technology', '')
        tech2 = output2.get('technology', '')
        
        if tech1 and tech2 and tech1 != tech2:
            return True
        return False

第四章:最佳实践 - 如何让AI们"和谐共处"

4.1 给Agent们"定规矩"

就像人类团队需要代码规范一样,AI团队也需要"Agent规范"。

Agent行为准则

1. 单一职责原则

  • 每个Agent只做一件事
  • 不要搞"全栈Agent",容易精神分裂

2. 显式通信

  • 所有状态变更必须通过Harness
  • 禁止Agent之间"私聊"

3. 可追溯性

  • 每个决策都要记录理由
  • 方便出了问题"甩锅"(划掉)复盘

4. 优雅降级

  • Agent崩溃时,Harness要有备用方案
  • 实在不行就"人类介入"

4.2 Harness设计的坑与避坑指南

坑1:过度设计

错误示范:

# 为了"可扩展性",搞了复杂的插件系统
class AgentHarness:
    def __init__(self):
        self.plugin_manager = PluginManager()
        self.event_bus = EventBus()
        self.message_queue = MessageQueue()
        self.distributed_lock = DistributedLock()
        # ... 100行初始化代码

正确做法:

# 先实现核心功能,简单直接
class AgentHarness:
    def __init__(self):
        self.agents = {}
        self.context = {}
    
    def run(self, task):
        # 先能跑起来再说
        pass

坑2:忽视成本控制

💰 AI Agent每调用一次都是要花钱的! 一个设计不好的Workflow可能会让你的API账单爆炸。

优化策略:

  • 设置调用次数上限
  • 缓存Agent的输出
  • 对简单任务使用"廉价"模型(GPT-3.5)
  • 只在复杂任务上用"昂贵"模型(GPT-4)

坑3:完全自动化

⚠️ 记住:永远保留"人类介入"的开关

有些决策AI做不了,比如:

  • 这个需求合不合理?
  • 这个技术债要不要还?
  • 为了赶工期能不能先hack一下?
class HumanInTheLoop:
    def review(self, agent_decision):
        if agent_decision.confidence < 0.8:
            return self.ask_human(agent_decision)
        
        if agent_decision.risk_level == "high":
            return self.ask_human(agent_decision)
        
        return agent_decision

第五章:未来展望 - 当Harness学会"自我管理"

5.1 从"项目经理"到"CTO"

现在的Agent Harness还是个"项目经理",负责协调执行。

未来的Harness可能会进化成"CTO":

  • 自己决定招什么Agent(动态扩缩容)
  • 自己优化团队结构(Agent重组)
  • 自己制定技术战略(长期规划)
  • 甚至...自己解雇表现不好的Agent?
# 未来的AgentHarness
class SelfEvolvingHarness(AgentHarness):
    def optimize_team_structure(self):
        # 分析历史数据
        performance_data = self.analyze_performance()
        
        # 决定是否需要新Agent
        if performance_data.coverage < 0.9:
            new_agent = self.design_new_agent(
                capability_gap=performance_data.gaps
            )
            self.register_agent(new_agent)
        
        # 决定是否需要"裁员"
        for agent_id, perf in performance_data.items():
            if perf.efficiency < 0.3:
                self.retire_agent(agent_id)

5.2 从"单团队"到"多团队"

当系统复杂到一定程度,一个Harness管不过来了,就需要分层管理

  • Project Harness - 管理单个项目内的Agent
  • Department Harness - 管理多个项目的Harness
  • Company Harness - 管理整个公司的AI资源

这就形成了AI的"组织架构图"...


结语:让AI"卷"得更有序

说到底,Agent Harness解决的是一个古老的问题:

如何让多个智能体协作完成复杂任务

从人类团队到AI团队,道理是相通的:

  • 都需要明确的分工
  • 都需要有效的沟通
  • 都需要统一的指挥
  • 都需要质量控制

不同的是,AI们不会:

  • 抱怨加班
  • 要求涨薪
  • 在茶水间吐槽项目经理(至少现在不会)

所以,如果你也在玩多Agent系统,别再让它们"野蛮生长"了。给你的AI们配一个Harness吧,让它们"卷"得更有序、更高效。

毕竟,没有什么问题是一个好的管理层解决不了的,如果有,就加一层管理层——这句话对AI团队同样适用 😏

油猴脚本实现生产环境加载本地qiankun子应用

大家好,我是石小石~


qiankun架构下的调试困境

如果你公司的前端架构基于 qiankun,你一定遇到过这样一个问题:由于子应用脱离主应用独立运行,在本地开发阶段,很多和主应用的操作联动、样式交互都无法直接验证,只能把子应用部署到开发或测试环境后,才能排查这类问题。

尤其是在一些不需要做 JS 沙箱隔离的业务场景里,主子应用需要通过 eventBus 这类方式实现交互,子应用不部署上线,调试起来就非常麻烦。

那有没有办法让生产环境直接加载本地子应用来实现代码调试?

方法肯定是有的,比如在主应用里写一套便于调试的逻辑。

import { registerMicroApps, start } from 'qiankun';

// ============== 核心:根据环境变量加载 本地/线上 子应用 ==============
const isDev = process.env.IS_DEV; // webpack 注入的环境变量

// 子应用配置列表
const microApps = [
  {
    name: 'subapp-vue', // 子应用唯一名称
    // 本地开发:加载 localhost 地址;生产:加载线上地址
    entry: isDev ? 'http://localhost:8080/gcshi-web-demo' : '/gcshi-web-demo',
    container: '#subapp-container', // 子应用挂载的容器 id
    activeRule: '/vue', // 路由匹配规则
  },
];

这种写法确实可以通过特定方式触发生产环境加载本地子应用,方便调试。但不可避免地需要修改主应用代码,如果没有主应用代码权限,那就很尴尬了。

其实,针对上面这个问题,用油猴脚本就能轻松解决!

油猴脚本简介

油猴(Tampermonkey)是一款浏览器插件,允许用户在网页加载时注入自定义的 JavaScript 脚本,来增强、修改或自动化网页行为

通俗地说,借助油猴,你可以将自己的 JavaScript 代码“植入”任意网页,实现自动登录、抢单、签到、数据爬取、广告屏蔽等各种“开挂级”功能,彻底掌控页面行为。

它和谷歌插件能实现的效果几乎一致,不过更加简单。如果你是前端开发,可以直接使用油猴,因为它本质就是针对网页写js。

如果你对油猴脚本感兴趣,可以看看: 《油猴脚本实战指南》

使用油猴脚本实现生产环境加载本地子应用

如图,我用 npm run dev 启动了一个本地子应用服务。

开启插件后,页面上会出现油猴脚本的调试工具。

点击【开启代理】,主应用会自动刷新,从而加载本地子应用,全程不需要做任何额外配置。

而且它完美支持热更新,这意味着你修改本地子应用代码后,生产环境页面会同步更新,调试非常方便。

核心原理

实现生产环境加载本地子应用其实很简单:

用油猴脚本在主应用加载时进行拦截,把原本要加载的线上子应用地址,替换成本地服务地址。

你可以这么理解:主应用原本要加载 http://baidu.com/gcshi-web-demo,被脚本替换成了 http://localhost:8080/gcshi-web-demo

重写fetch

qiankun 底层依赖 import-html-entry 这个库,核心流程是通过 fetch 加载子应用 HTML 模板,再解析 CSS、JS。 所以我们只需要在页面加载早期,拦截并重写 fetch 即可。

参考:juejin.cn/post/757214…

那么问题很好解决了, 我们只需要在页面加载早期,拦截并重写 fetch 即可。


const oldFetch = window.fetch;
window.fetch = (url, ...args) => {
  // 替换域名
  if (url === 'http://baidu.com/gcshi-web-demo') {
    url = 'http://localhost:8080/gcshi-web-demo';
  }
  return oldFetch(url, ...args);
};

保证脚本最早运行

重写 window.fetch 的前提,是脚本必须比页面其他逻辑更早执行,否则重写会失效。

在油猴脚本中,可以通过添加元信息实现:

// @run-at       document-start

参考:油猴脚本的运行生命周期

我在油猴脚本里的 fetch 重写逻辑如下:

import $ from "../../gmTool/index";
const { unsafeWindow } = $;

type FetchInterceptor = (url: RequestInfo | URL, options?: RequestInit) => [RequestInfo | URL, RequestInit?] | void | false;

const win = unsafeWindow;
const rawFetch = win.fetch.bind(win);

export function onFetch(interceptor: FetchInterceptor) {
  // 如果已经被代理过,先复用原来的
  if (!(win.fetch as any).rawFetch) {
    const proxyFetch: typeof fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
      let nextInput = input;
      let nextInit = init;
      // 执行 interceptor
      try {
        const result = interceptor(nextInput, nextInit);
        if (result === false) {
          console.warn("[winnex-web-proxy] 😭😭😭😭 fetch请求被用户阻止了===========================>", nextInput);
          return Promise.reject(new Error("[winnex-web-proxy] 😭😭😭😭 fetch请求被用户阻止了"));
        }
        if (result && Array.isArray(result)) {
          nextInput = result[0];
          nextInit = result[1];
        }
      } catch (err) {
        console.error("[fetch] interceptor error:", err);
      }
      // 处理 Request 对象情况
      if (nextInput instanceof Request && nextInit) {
        nextInput = new Request(nextInput, nextInit);
        nextInit = undefined;
      }
      return rawFetch(nextInput, nextInit);
    };

    (proxyFetch as any).rawFetch = rawFetch;
    win.fetch = proxyFetch;
  }

  // 返回取消方法
  return function unProxyFetch() {
    if ((win.fetch as any).rawFetch) {
      win.fetch = rawFetch;
    }
  };
}
  • 基础使用(替换接口地址)
// 注册拦截器
const unProxy = onFetch((url, options) => {
  const u = url.toString();
  // 匹配并替换地址
  if (u === 'http://baidu.com/gcshi-web-demo') {
    return ['http://localhost:8080/gcshi-web-demo', options];
  }
});
  • 阻止某个请求
onFetch((url) => {
  if (url.toString().includes('/black-api')) {
    return false; // 拦截并拒绝
  }
});

解决跨域问题

生产环境页面加载本地 localhost:8080 可能会出现跨域,导致子应用加载失败。解决方法很简单,在 vite 或 webpack 中添加响应头配置:

  devServer: {
    headers: {
      'Access-Control-Allow-Origin': '*'
    },
  },

解决热更新

默认情况下,生产环境加载子应用时,热更新会失效。原因是热更新相关的 XHR 请求前缀被替换成了主应用域名。只需要拦截 XHR 请求,修正热更新接口前缀即可。以 webpack 热更新为例,修复 sockjs-nodehot-update 两个接口就行。

使用 ajax-hook 实现 XHR 拦截,代码如下:


const appOrigin = "http://localhost:8080"
const fixHotUpdateUrl = (config: any) => {
  if (config.url.includes("sockjs-node") && appOrigin) {
    config.url = fixSockJsUrl(config.url, appOrigin);
  }
  if (config.url.includes(appName) && config.url.includes("hot-update")) {
    config.url = fixHotUpdate(config.url, appName, appOrigin);
    console.log(`[winnex-web-proxy] 热更新🚀🚀===============================> ${config.url}`);
  }
};

export const xhrProxy = (enable: boolean) => {
  if (!enable) return;
  // xhr拦截
  proxy(
    {
      //请求发起前进入
      onRequest: (config, handler) => {
        fixHotUpdateUrl(config);
        handler.next(config);
      },
      //请求发生错误时进入,比如超时;注意,不包括http状态码错误,如404仍然会认为请求成功
      onError: (err, handler) => {
        handler.next(err);
      },
      //请求成功后进入
      onResponse: (response, handler) => {
        handler.next(response);
      }
    },
    unsafeWindow
  );
};

总结

在 qiankun 微前端架构中,本地子应用想要直接在生产环境调试,不必修改主应用代码、不必申请权限,通过油猴脚本重写 fetch劫持子应用入口地址,配合跨域配置XHR 拦截修复热更新,就能实现线上环境加载本地子应用,并且支持热更新,极大提升微前端联调效率。整个方案轻量、无侵入、开箱即用,非常适合前端日常调试。

多端项目太乱?我是这样用 Monorepo 重构的

🚀 如何用 Monorepo 管理多端项目?一套可落地方案

一、从“架构设计”到“工程落地”

在上一篇中,我们解决了一个核心问题:

多端架构应该如何设计?

我们得出的结论是:

用分层架构统一逻辑(UI / modules / services)

👉 那这一篇,我们解决另一个更现实的问题:

如何把这套架构真正落地?

二、为什么多端项目一定会“失控”?

当你开始同时维护 Web、小程序、App 等多个端时,传统的 Multi-repo(多仓库) 很容易演变成开发灾难:

  • 重复劳动:相同业务逻辑在多个仓库重复实现
  • 同步地狱:接口字段变更,需要手动同步多个项目
  • 维护混乱:修一个 Bug,要改多个仓库

🔥 本质问题:

代码没有统一的“抽象与复用边界”

👉 所以你需要一种机制:

既能共享代码,又能保持边界清晰

👉 这就是:

Monorepo(单仓多包)

三、为什么多端架构必须用 Monorepo?

相比传统 Multi-repo,Monorepo 的优势非常明显:

维度 Multi-repo Monorepo
代码复用 复制 / npm 发布 本地直接引用
类型共享 手动同步 自动同步
依赖管理 各自维护 统一管理
代码变更 多仓提交 原子提交

👉 对多端项目来说,它解决了最核心的问题:

让“可复用逻辑”有了统一载体

四、项目结构设计(核心)

这是 Monorepo 成败的关键。


📦 推荐结构(与架构分层一致):

my-repo/
├── apps/                  # 应用层(各端独立)
│   ├── web/               # Web(Next.js / React)
│   ├── mini/              # 原生小程序
│   └── admin/             # 管理后台
│
├── packages/              # 复用能力层
│   ├── services/          # API 层(OpenAPI)
│   ├── modules/           # 业务逻辑(核心 ⭐)
│   ├── request/           # 请求适配层
│   └── shared/            # 工具函数
│
├── package.json
└── pnpm-workspace.yaml

🔥 核心设计原则:

apps = 面向用户(不可复用)
packages = 面向复用(核心资产)

👉 最关键的一点:

所有“可复用逻辑”,必须进入 packages,而不是 apps

五、从 0 搭建 Monorepo(实操)


1️⃣ 初始化项目

mkdir my-repo && cd my-repo
pnpm init

2️⃣ 配置 workspace

创建 pnpm-workspace.yaml

packages:
  - "apps/*"
  - "packages/*"


3️⃣ 创建目录结构

mkdir -p apps/web
mkdir -p apps/mini
mkdir -p packages/services
mkdir -p packages/modules
mkdir -p packages/request
mkdir -p packages/shared


4️⃣ 初始化子包(以 modules 为例)

cd packages/modules
pnpm init

修改 package.json

{
  "name": "@repo/modules",
  "version": "1.0.0",
  "private": true,
  "main": "./index.ts",
  "types": "./index.ts"
}


5️⃣ 在应用中引用

apps/web 中执行:

pnpm add @repo/modules --workspace

然后即可直接使用:

import { useUser } from "@repo/modules"

六、依赖管理(核心原则)


🔥 原则一:依赖就近声明

在哪使用,就在哪声明依赖

❌ 错误做法:

所有依赖都装在根目录

👉 会导致:

依赖污染 + 隐式依赖


🔥 原则二:单向依赖

apps → modules → services → request

👉 严禁:

modules → apps
services → modules


🔥 原则三:公共依赖再提升

例如:

pnpm add -wD typescript eslint

七、TypeScript 与构建优化


1️⃣ 类型闭环(强烈推荐)

services 中定义 API 类型:

后端变更 → TS 报错 → 前端即时修复

👉 好处:

把“线上错误”变成“编译错误”


2️⃣ Turborepo(进阶优化)

安装:

pnpm add turbo -wD

配置 turbo.json

{
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    }
  }
}

👉 带来的能力:

并行构建 + 缓存加速

八、最常见的 3 个坑


❗ 1. 依赖循环(Circular Dependency)

modules → shared
shared → modules ❌

👉 解决:

抽象更底层包,保持单向依赖


❗ 2. 编译入口问题

部分环境(如小程序)不支持直接引用 TS 源码。


👉 解决:

配置 exports / alias / 构建输出


❗ 3. 配置冗余

每个包都写 tsconfig 很麻烦。


👉 解决:

// tsconfig.base.json

子包继承:

{
  "extends": "../../tsconfig.base.json"
}

九、这一步完成后,你得到了什么?


✅ 工程能力提升:

一次修改,多端生效
逻辑复用能力大幅提升
类型安全贯穿全链路


🔥 更重要的是:

你的代码开始“结构化”

十、总结一句话

Monorepo 不是工具,而是工程组织方式

🎯 结语

很多人觉得工程复杂,是因为工具太多。

但本质是:

代码没有边界

👉 Monorepo 的意义是:

让代码有“归属”,让复杂度可控

🚀 下一篇预告

到这里,你已经完成:

架构设计(第2篇)
+ 工程落地(第3篇)

👉 下一篇,我们进入最核心的一步:

多端架构最难的 3 个问题(request / modules / design system)

🔥 这一篇,会是整个系列的“认知分水岭”。

用 3100 个数字造一台计算机

你有没有想过,一台计算机最少需要什么?

不是说你桌上那台——那个有几十亿个晶体管、跑着操作系统和浏览器的庞然大物。我说的是最本质的那个东西:能算数、能画画、能放音乐、能响应你的键盘和鼠标。

答案可能会让你意外:一个数组就够了。

Little Virtual Computer 是一台用 TypeScript 写的虚拟计算机,原作者是 jsdf。我在他的基础上做了不少重构和优化——把代码拆分成了清晰的模块结构,加了音频系统、断点调试、内存追踪、中英文切换等功能。3100 个内存槽位,23 条指令,你可以在上面写汇编程序,画像素,甚至播放一首 Chocolate Rain。打开链接就能玩,不用装任何东西。

接下来聊聊拆解和重构这台计算机的过程中,那些让我觉得"原来如此"的时刻。

"硬件"就是一行代码

这台计算机的内存,就是这行:

static ram: number[] = new Array(3100).fill(0)

3100 个数字,所有东西都住在里面——变量、程序、输入设备、屏幕、声卡:

地址 用途
0 - 999 工作内存(变量)
1000 - 1999 程序代码
2000 - 2051 键盘、鼠标、随机数、时钟
2100 - 2999 屏幕(30x30 像素)
3000 - 3008 声卡(3 个通道)

这就是"内存映射 I/O"。真实计算机里,显卡有自己的显存,声卡有自己的缓冲区,键盘通过中断传递信号。但在这里,一切都是内存地址。想在屏幕左上角画一个红色像素?往地址 2100 写个 2。想让扬声器发出正弦波?往地址 3001 写频率,地址 3000 写 3

第一次把重构后的代码跑起来,盯着屏幕上亮起的那个像素,我突然理解了一件事:CPU 不需要"知道"什么是屏幕。它只是往一个地址写了个数字,恰好有人在监听那个地址。 输入输出不需要特殊的指令,读写内存就是一切。

CPU 其实在做一件很无聊的事

读原作者的 CPU 代码时,我以为会很复杂。结果核心逻辑是这样的:

static step(trace: boolean = true) {
  if (trace) Memory.beginTrace();       // 需要调试时才追踪
  const opcode = this.advanceProgramCounter();  // 从内存读一个数
  const instructionName = this.opcodesToInstructions.get(opcode); // 数字变指令名
  const operands = instruction.operands.map(() => this.advanceProgramCounter()); // 再读几个数当参数
  instruction.execute.apply(null, operands);  // 执行
  if (trace) this.lastStepTrace = Memory.endTrace();
}

程序计数器从地址 1000 开始。读一个数,往前走一步。读到 9010?那是 add,再读三个数当参数,加一下,写回去。然后继续读下一个。没有流水线,没有分支预测,没有乱序执行。一个 while 循环,一直读数字、执行、读数字、执行。

这就是冯·诺依曼架构的全部:程序和数据住在同一片内存里,CPU 按顺序取指令执行。 你桌上那台电脑的 CPU,不管它有多少核、多少级缓存,本质上也在做同样的事——只是快了几十亿倍。

23 条指令够写一个游戏吗

一开始觉得不够。23 条指令,连函数调用都没有,能干什么?

结果发现,不只是够了,还能写出让人意外的东西。这 23 条指令分成五类:

搬运数据(5 条)—— 把值从一个地址复制到另一个,或者写入一个常量。还有两条指针操作,让你可以"地址 A 里存着地址 B,去 B 里取值"——间接寻址,这是实现数组遍历的关键。

算术(10 条)—— 加减乘除取模,每种都有两个版本:两个地址相加,或者一个地址加一个常量。add_constant counter 1 counter 就是 counter++

比较(2 条)—— 比较两个值,结果是 -1、0 或 1。没有布尔值,没有大于小于等于,就一个三态数字。刚开始觉得别扭,后来发现这样反而更灵活。

跳转(5 条)—— jump_to 无条件跳转,branch_if_equal 条件跳转。没有 for 循环?跳回去就是循环。没有 if-else?跳过去就是 else。

系统(3 条)—— data 嵌入原始数据,break 暂停调试,halt 终止。

用这些东西,能写出画板程序、弹球、乒乓球游戏,甚至音乐播放器。

从文本到数字

手动往内存里填操作码太痛苦了,所以需要一个汇编器。你写这样的文本:

define counter 0
define limit 10
copy_to_from_constant counter 0
Loop:
  add_constant counter 1 counter
  branch_if_not_equal_constant counter limit Loop
halt

汇编器把它变成内存里的一串数字:9001 0 0 9011 0 1 0 9104 0 10 1003 9999

过程本身很有启发性。define 给地址起名字,Loop: 标记跳转目标。汇编器用经典的两遍扫描:第一遍收集所有标签的地址(这样你可以先 jump_to SomeLabel,后面再定义 SomeLabel:),第二遍把指令名替换成操作码,把标签和变量名替换成数字,逐个写入程序内存。

所谓"编译",最原始的形态就是这样——把人能读的东西翻译成机器能读的数字。

900 个像素的屏幕

30x30,900 个像素。听起来少得可怜。

但当你亲手用汇编一个像素一个像素地画出一个弹跳的小球时,你会对"像素"这个词产生全新的理解。每个像素就是一个内存地址,颜色就是 0 到 15 的一个数字。像素地址 = 2100 + y * 30 + x。16 种颜色:黑、白、红、绿、蓝、黄、青、品红、银、灰、栗、橄榄、深绿、紫、蓝绿、海军蓝。

渲染做了分场景优化:慢放模式下追踪"脏像素",只更新被写过的像素,被写入的像素还会短暂闪白,让你看到程序正在画什么——慢放下看着像素一个一个亮起来,有种看延时摄影的感觉。全速模式则跳过逐像素追踪,直接全量重绘,因为每帧都有大量像素变化,追踪反而是浪费。

用内存地址弹钢琴

音频部分是我最喜欢的设计。三个独立的振荡器通道,每个通道就是三个连续的内存地址:波形、频率、音量。

地址 3000: 波形 (0=方波, 1=锯齿波, 2=三角波, 3=正弦波)
地址 3001: 频率 (值 / 1000 = Hz)
地址 3002: 音量 (0-100)

往这几个地址写数字,声音就出来了。改个数字,音调就变了。

内置的 ChocolateRain 程序用两个通道演奏了一首完整的曲子。音乐数据全部用 data 指令嵌入在程序里——本质上就是一个大数组,记录着"第几拍、哪个通道、什么频率、多大音量"。程序读取当前时间,算出现在是第几拍,然后去数组里找对应的音符,写入音频内存。

一首歌,就是一个按时间索引的数组。

调试器:这才是重点

说实话,这台虚拟计算机最有价值的部分不是 CPU,不是显示器,不是音频——是调试器。

点"单步",程序计数器往前走一步。你能看到它读了哪个地址(蓝色高亮),写了哪个地址(橙色高亮)。设个断点,程序跑到那里自动停下来。把速度拉到慢放,看着弹球程序一帧一帧地擦掉旧位置、算出新位置、画上新像素。

我见过很多人学编程时卡在"不知道程序在干什么"。代码写完,跑起来,结果不对,然后就懵了。这台计算机的调试器让一切都暴露在外面:每一步读了什么、写了什么、程序计数器在哪里。没有黑箱,没有抽象层,你看到的就是全部。

六个程序,六种"原来如此"

内置的六个示例程序,每个都在教一件事:

Add —— 4 + 4 = 8。三行代码,结果存在地址 2。这是"指令怎么工作"的最小演示。

RandomPixels —— 用一个指针从地址 2100 扫到 2999,每个位置写一个随机颜色,然后从头再来。满屏闪烁的彩色像素,其实只是一个循环在往内存里写数字。

Paint —— 屏幕顶部一行是 16 色调色板,点击选色,然后在画布上画。鼠标位置就是一个内存地址里的数字,点击就是另一个地址从 0 变成 1。

BouncingBall —— 白色小球弹来弹去。用 Date.now() 控制帧率,每 60ms 更新一次位置,碰到边界就反转方向。这是"游戏循环"的最小实现。

MiniPong —— 乒乓球。两个挡板,一个球,碰到挡板反弹,错过就重置。这是最复杂的示例,用到了几乎所有指令。读完它的代码,你会对"游戏不过是一堆条件判断"有切身体会。

ChocolateRain —— 用汇编写的音乐播放器。理解这个程序怎么工作,就理解了数据驱动编程的本质。

重构与实现细节

原作者 jsdf 的实现是一个完整的单体,功能齐全但耦合度较高。我把它拆成了独立模块——CPU、内存、显示器、音频、输入、汇编器——通过内存这个"总线"连接,加了 TypeScript 类型系统。

拆的过程本身就是一次学习。当你必须决定"这个职责属于 CPU 还是属于 Memory"的时候,你对计算机架构的理解会变得非常具体。

架构

项目分成两个独立的 bundle:

src/index.ts    → dist/computer.module.js   (核心计算机)
src/simulator.ts → dist/simulator.module.js  (模拟器 UI)

index.ts 初始化所有硬件组件,返回一个 Computer 接口对象——这是两层之间唯一的契约。模拟器只通过这个接口操作计算机,不直接碰内部类。换掉整个计算机实现,只要接口不变,模拟器照常工作。

几个有意思的实现决策

内存布局用 const enum——MemoryPosition 定义所有地址常量,编译后直接内联为数字,零运行时开销。改一个数字,整台计算机的内存布局就变了。这就是"硬件规格"。

指令是数据驱动的——每条指令是一个对象,包含名称、操作码、操作数描述和执行函数。operands 数组不只是文档——汇编器用它验证操作数数量,调试器用它显示操作数含义。一份数据,三个用途。

流程控制指令直接改程序计数器——jump_to 的 execute 就是 CPU.programCounter = labelAddress。这形成了循环依赖(CPU → instructions → CPU),更"干净"的做法是把 CPU 状态作为参数传入,但在这个规模的项目里,简单直接比架构纯洁更重要。

性能:在不同场景下做不同的事

性能优化的核心思路不是"让代码更快",而是"在不同场景下做不同的事"——和真实系统的优化思路一样。

全速模式用帧预算策略:用 performance.now() 在每帧 14ms 的预算内尽量多跑 CPU 周期(留 2ms 给浏览器渲染和 GC),用 requestAnimationFrame 和屏幕刷新率同步。同时跳过内存追踪和调试面板更新,显示器切换到全量重绘。

慢放模式每次只执行一条指令,开启内存读写追踪,更新所有调试面板,显示器用脏像素增量重绘。

音频也做了状态缓存——用 state 对象记录上一次的参数值,只在值真正变化时才调用 Web Audio API,避免每帧 9 次无意义的 API 调用。CPU 停止时只需静音所有通道然后立即返回。

其他细节:内存重置用 Array.fill(0) 替代 for 循环;endTrace() 复用同一个对象避免每周期分配新数组;显示器用预计算的 Uint8Array 颜色查找表,位移 << 2 代替乘法索引;程序内存视图用虚拟滚动,只渲染可见区域 ± 10 行。

最后

折腾这台计算机的过程中,我反复体会到一件事:我们日常使用的那些抽象——变量、循环、函数、屏幕、声音——在最底层都是同一个东西:往一个地址读一个数字,或者写一个数字。

3100 个数字,23 条规则。这就是一台计算机的全部。

不信的话,打开试试:wsafight.github.io/little-virt…

点"单步",看看你的程序在做什么。


原项目:github.com/jsdf/little…

重构版源码:github.com/wsafight/li…

Sentinel Java客户端限流原理解析|得物技术

一、从一次 HTTP 请求开始

在一个生产环境中,服务节点通常暴露了成百上千个 HTTP 接口对外提供服务。为了保证系统的稳定性,核心 HTTP 接口往往需要配置限流规则。给 HTTP 接口配置限流,可以防止突发或恶意的高并发请求耗尽服务器资源(如 CPU、内存、数据库连接等),从而避免服务崩溃或引发雪崩效应。

基础示例

假设我们有下面这样一个 HTTP 接口,需要给它配置限流规则:

@RestController
@RequiredArgsConstructor
@RequestMapping("/demo")
public class DemoController {

    @RequestMapping("/hello")
    @SentinelResource("test_sentinel")
    public String hello() {
        return "hello world";
    }
}

使用起来非常简单。首先我们可以选择给接口加上 @SentinelResource 注解(也可以不加,如果不加 Sentinel 客户端会使用请求路径作为资源名,详细原理在后面章节讲解),然后到流控控制台给该资源配置流控规则即可。

二、限流规则的加载

限流规则的生效,是从限流规则的加载开始的。聚焦到客户端的 RuleLoader 类,可以看到它支持了多种规则的加载:

  • 流控规则;
  • 集群限流规则;
  • 熔断规则;
  • ......

RuleLoader 核心逻辑

RuleLoader 类的核心作用是将这些规则加载到缓存中,方便后续使用:

public class RuleLoader {

    /**
     * 加载所有 Sentinel 规则到内存缓存
     *
     * @param sentinelRules 包含各种规则的配置对象
     */
    public static void loadRule(SentinelRules sentinelRules) {
        if (sentinelRules == null) {
            return;
        }

        // 加载流控规则
        FlowRuleManager.loadRules(sentinelRules.getFlowRules());
        // 加载集群流控规则
        RuleManager.loadClusterFlowRule(sentinelRules.getFlowRules());

        // 加载参数流控规则
        ParamFlowRuleManager.loadRules(sentinelRules.getParamFlowRules());
        // 加载参数集群流控规则
        RuleManager.loadClusterParamFlowRule(sentinelRules.getParamFlowRules());

        // 加载熔断规则
        DegradeRuleManager.loadRules(sentinelRules.getDegradeRules());

        // 加载参数熔断规则
        ParamDegradeRuleManager.loadRules(sentinelRules.getParamDegradeRules());

        // 加载系统限流规则
        SystemRuleManager.loadRules(sentinelRules.getSystemRules());
    }
}

流控规则加载详情

以流控规则的加载为例深入FlowRuleManager.loadRules 方法可以看到其完整的加载逻辑:

public static void loadRules(List<FlowRule> rules) {
    // 通过动态配置属性更新规则值
    currentProperty.updateValue(rules);
}

updateValue 方法负责通知所有监听器配置变更:

public boolean updateValue(T newValue) {
    // 如果新旧值相同,无需更新
    if (isEqual(value, newValue)) {
        return false;
    }
    RecordLog.info("[DynamicSentinelProperty] Config will be updated to: " + newValue);

    // 更新配置值
    value = newValue;
    // 通知所有监听器配置已更新
    for (PropertyListener<T> listener : listeners) {
        listener.configUpdate(newValue);
    }
    return true;
}

FlowPropertyListener 是流控规则变更的具体监听器实现:

private static final class FlowPropertyListener implements PropertyListener<List<FlowRule>> {

    @Override
    public void configUpdate(List<FlowRule> value) {
        // 构建流控规则映射表(按资源名分组)
        Map<String, List<FlowRule>> rules = FlowRuleUtil.buildFlowRuleMap(value);
        if (rules != null) {
            // 清空旧规则
            flowRules.clear();
            // 加载新规则
            flowRules.putAll(rules);
        }
        RecordLog.info("[FlowRuleManager] Flow rules received: " + flowRules);
    }
}

三、SentinelServletFilter 过滤器

在 Sentinel 中,所有的资源都对应一个资源名称和一个 Entry。Entry 可以通过对主流框架的适配自动创建,也可以通过注解的方式或调用 API 显式创建。Entry 是限流的入口类,通过 @SentinelResource 注解的限流本质上也是通过 AOP 的方式进行了对 Entry 类的调用。

Entry 的编程范式

Entry 类的标准使用方式如下:

// 资源名可使用任意有业务语义的字符串,比如方法名、接口名或其它可唯一标识的字符串
try (Entry entry = SphU.entry("resourceName")) {
    // 被保护的业务逻辑
    // do something here...
} catch (BlockException ex) {
    // 资源访问阻止,被限流或被降级
    // 在此处进行相应的处理操作
}

Servlet Filter 拦截逻辑

对于一个 HTTP 资源,在没有显式标注 @SentinelResource 注解的情况下,会有一个 Servlet Filter 类 SentinelServletFilter 统一进行拦截:

public class SentinelServletFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        HttpServletRequest sRequest = (HttpServletRequest) request;
        Entry urlEntry = null;

        try {
            // 获取并清理请求路径
            String target = FilterUtil.filterTarget(sRequest);

            // 统一 URL 清理逻辑
            // 对于 RESTful API,必须对 URL 进行清理(例如将 /foo/1 和 /foo/2 统一为 /foo/:id),
            // 否则上下文和资源的数量会超过阈值
            SentinelUrlCleaner urlCleaner = SentinelUrlCleaner.SENTINEL_URL_CLEANER;
            if (urlCleaner != null) {
                target = urlCleaner.clean(sRequest, target);
            }

            // 如果请求路径不为空且非安全扫描,则进入限流逻辑
            if (!StringUtil.isEmpty(target) && !isSecScan) {
                // 解析来源标识(用于来源限流)
                String origin = parseOrigin(sRequest);
                // 确定上下文名称
                String contextName = webContextUnify
                    ? WebServletConfig.WEB_SERVLET_CONTEXT_NAME
                    : target;

                // 使用 WEB_SERVLET_CONTEXT_NAME 作为当前 Context 的名字
                ContextUtil.enter(contextName, origin);

                // 根据配置决定是否包含 HTTP 方法
                if (httpMethodSpecify) {
                    String pathWithHttpMethod = sRequest.getMethod().toUpperCase() + COLON + target;
                    // 实际进入到限流统计判断逻辑,资源名是 "方法:路径"
                    urlEntry = SphU.entry(pathWithHttpMethod, ResourceTypeConstants.COMMON_WEB, EntryType.IN);
                } else {
                    // 实际进入到限流统计判断逻辑,资源名是请求路径
                    urlEntry = SphU.entry(target, ResourceTypeConstants.COMMON_WEB, EntryType.IN);
                }
            }

            // 继续执行后续过滤器
            chain.doFilter(request, response);

        } catch (BlockException e) {
            // 处理被限流的情况
            HttpServletResponse sResponse = (HttpServletResponse) response;
            // 返回限流页面或重定向到其他 URL
            WebCallbackManager.getUrlBlockHandler().blocked(sRequest, sResponse, e);

        } catch (IOException | ServletException | RuntimeException e2) {
            // 记录异常信息用于统计
            Tracer.traceEntry(e2, urlEntry);
            throw e2;

        } finally {
            // 释放 Entry 资源
            if (urlEntry != null) {
                urlEntry.exit();
            }
            // 退出当前上下文
            ContextUtil.exit();
        }
    }
}

四、SentinelResourceAspect 切面

如果在接口上标注了 @SentinelResource 注解,还会有另外的逻辑处理。Sentinel 定义了一个单独的 AOP 切面 SentinelResourceAspect 专门用于处理注解限流。

SentinelResource 注解定义

先来看看 @SentinelResource 注解的完整定义:

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
public @interface SentinelResource {

    /**
     * Sentinel 资源的名称(即资源标识)
     * 必填项,不能为空
     */
    String value() default "";

    /**
     * 资源的入口类型(入站 IN 或出站 OUT)
     * 默认为出站(OUT)
     */
    EntryType entryType() default EntryType.OUT;

    /**
     * 资源的分类(类型)
     * 自 1.7.0 版本起支持
     */
    int resourceType() default 0;

    /**
     * 限流或熔断时调用的 block 异常处理方法的名称
     * 默认为空(即不指定)
     */
    String blockHandler() default "";

    /**
     * blockHandler 所在的类
     * 如果与原方法不在同一个类,需要指定此参数
     */
    Class<?>[] blockHandlerClass() default {};

    /**
     * 降级(fallback)方法的名称
     * 默认为空(即不指定)
     */
    String fallback() default "";

    /**
     * 用作通用的默认降级方法
     * 该方法不能接收任何参数,且返回类型需与原方法兼容
     */
    String defaultFallback() default "";

    /**
     * fallback 所在的类
     * 如果与原方法不在同一个类,需要指定此参数
     */
    Class<?>[] fallbackClass() default {};

    /**
     * 需要被追踪并触发 fallback 的异常类型列表
     * 默认为 Throwable(即所有异常都会触发 fallback)
     */
    Class<? extends Throwable>[] exceptionsToTrace() default {Throwable.class};

    /**
     * 指定需要忽略的异常类型(即这些异常不会触发 fallback)
     * 注意:exceptionsToTrace 和 exceptionsToIgnore 不应同时使用;
     * 若同时存在,exceptionsToIgnore 优先级更高
     */
    Class<? extends Throwable>[] exceptionsToIgnore() default {};
}

实际使用示例

下面是一个完整的使用示例,展示了 @SentinelResource 注解的各种配置方式:

@RestController
public class SentinelController {

    @Autowired
    private ISentinelService service;

    @GetMapping(value = "/hello/{s}")
    public String apiHello(@PathVariable long s) {
        return service.hello(s);
    }
}

public interface ISentinelService {
    String hello(long s);
}

@Service
@Slf4j
public class SentinelServiceImpl implements ISentinelService {

    /**
     * Sentinel 提供了 @SentinelResource 注解用于定义资源
     *
     * @param s 输入参数
     * @return 返回结果
     */
    @Override
    // value:资源名称,必需项(不能为空)
    // blockHandler:对应处理 BlockException 的函数名称
    // fallback:用于在抛出异常的时候提供 fallback 处理逻辑
    @SentinelResource(value = "hello", blockHandler = "exceptionHandler", fallback = "helloFallback")
    public String hello(long s) {
        log.error("hello:{}", s);
        return String.format("Hello at %d", s);
    }

    /**
     * Fallback 函数
     * 函数签名与原函数一致,或加一个 Throwable 类型的参数
     */
    public String helloFallback(long s) {
        log.error("helloFallback:{}", s);
        return String.format("Halooooo %d", s);
    }

    /**
     * Block 异常处理函数
     * 参数最后多一个 BlockException,其余与原函数一致
     */
    public String exceptionHandler(long s, BlockException ex) {
        // Do some log here.
        log.error("exceptionHandler:{}", s);
        ex.printStackTrace();
        return "Oops, error occurred at " + s;
    }
}

SentinelResourceAspect 核心逻辑

@SentinelResource 注解由 SentinelResourceAspect 切面处理,核心逻辑如下:

@Aspect
public class SentinelResourceAspect extends AbstractSentinelAspectSupport {

    @Pointcut("@annotation(com.alibaba.csp.sentinel.annotation.SentinelResource)")
    public void sentinelResourceAnnotationPointcut() {
    }

    @Around("sentinelResourceAnnotationPointcut()")
    public Object invokeResourceWithSentinel(ProceedingJoinPoint pjp) throws Throwable {
        // 获取目标方法
        Method originMethod = resolveMethod(pjp);

        // 获取注解信息
        SentinelResource annotation = originMethod.getAnnotation(SentinelResource.class);
        if (annotation == null) {
            throw new IllegalStateException("Wrong state for SentinelResource annotation");
        }

        // 获取资源配置信息
        String resourceName = getResourceName(annotation.value(), originMethod);
        EntryType entryType = annotation.entryType();
        int resourceType = annotation.resourceType();

        Entry entry = null;
        try {
            // 创建限流入口
            entry = SphU.entry(resourceName, resourceType, entryType, pjp.getArgs());
            // 执行原方法
            Object result = pjp.proceed();
            return result;

        } catch (BlockException ex) {
            // 处理被限流异常
            return handleBlockException(pjp, annotation, ex);

        } catch (Throwable ex) {
            // 处理业务异常
            Class<? extends Throwable>[] exceptionsToIgnore = annotation.exceptionsToIgnore();
            // 优先检查忽略列表
            if (exceptionsToIgnore.length > 0 && exceptionBelongsTo(ex, exceptionsToIgnore)) {
                throw ex;
            }
            // 检查异常是否在追踪列表中
            if (exceptionBelongsTo(ex, annotation.exceptionsToTrace())) {
                traceException(ex);
                // 执行 fallback 逻辑
                return handleFallback(pjp, annotation, ex);
            }

            // 没有 fallback 函数可以处理该异常,直接抛出
            throw ex;

        } finally {
            // 释放 Entry 资源
            if (entry != null) {
                entry.exit(1, pjp.getArgs());
            }
        }
    }

    /**
     * 处理 BlockException
     *
     * blockHandler / blockHandlerClass 说明:
     * - blockHandler:对应处理 BlockException 的函数名称,可选项
     * - blockHandler 函数签名:与原方法相匹配并且最后加一个额外的参数,类型为 BlockException
     * - blockHandler 函数默认需要和原方法在同一个类中
     * - 若希望使用其他类的函数,则可以指定 blockHandlerClass 为对应的类的 Class 对象
     * - 注意:blockHandlerClass 中对应的函数必须为 static 函数,否则无法解析
     */
    protected Object handleBlockException(ProceedingJoinPoint pjp, SentinelResource annotation, BlockException ex)
            throws Throwable {

        // 执行 blockHandler 方法(如果配置了的话)
        Method blockHandlerMethod = extractBlockHandlerMethod(pjp, annotation.blockHandler(),
                annotation.blockHandlerClass());

        if (blockHandlerMethod != null) {
            Object[] originArgs = pjp.getArgs();
            // 构造参数:原方法参数 + BlockException
            Object[] args = Arrays.copyOf(originArgs, originArgs.length + 1);
            args[args.length - 1] = ex;

            try {
                // 根据 static 方法与否进行不同的调用
                if (isStatic(blockHandlerMethod)) {
                    return blockHandlerMethod.invoke(null, args);
                }
                return blockHandlerMethod.invoke(pjp.getTarget(), args);
            } catch (InvocationTargetException e) {
                // 抛出实际的异常
                throw e.getTargetException();
            }
        }

        // 如果没有 blockHandler,则尝试执行 fallback
        return handleFallback(pjp, annotation, ex);
    }

    /**
     * 处理 Fallback 逻辑
     *
     * fallback / fallbackClass 说明:
     * - fallback:fallback 函数名称,可选项,用于在抛出异常的时候提供 fallback 处理逻辑
     * - fallback 函数可以针对所有类型的异常(除了 exceptionsToIgnore 里面排除掉的异常类型)进行处理
     *
     * fallback 函数签名和位置要求:
     * - 返回值类型必须与原函数返回值类型一致
     * - 方法参数列表需要和原函数一致,或者可以额外多一个 Throwable 类型的参数用于接收对应的异常
     * - fallback 函数默认需要和原方法在同一个类中
     * - 若希望使用其他类的函数,则可以指定 fallbackClass 为对应的类的 Class 对象
     * - 注意:fallbackClass 中对应的函数必须为 static 函数,否则无法解析
     */
    protected Object handleFallback(ProceedingJoinPoint pjp, String fallback, String defaultFallback,
                                    Class<?>[] fallbackClass, Throwable ex) throws Throwable {
        Object[] originArgs = pjp.getArgs();

        // 执行 fallback 函数(如果配置了的话)
        Method fallbackMethod = extractFallbackMethod(pjp, fallback, fallbackClass);

        if (fallbackMethod != null) {
            // 构造参数:根据 fallback 方法的参数数量决定是否添加异常参数
            int paramCount = fallbackMethod.getParameterTypes().length;
            Object[] args;
            if (paramCount == originArgs.length) {
                args = originArgs;
            } else {
                args = Arrays.copyOf(originArgs, originArgs.length + 1);
                args[args.length - 1] = ex;
            }

            try {
                // 根据 static 方法与否进行不同的调用
                if (isStatic(fallbackMethod)) {
                    return fallbackMethod.invoke(null, args);
                }
                return fallbackMethod.invoke(pjp.getTarget(), args);
            } catch (InvocationTargetException e) {
                // 抛出实际的异常
                throw e.getTargetException();
            }
        }

        // 如果没有 fallback,尝试使用 defaultFallback
        return handleDefaultFallback(pjp, defaultFallback, fallbackClass, ex);
    }
}

五、流控处理核心逻辑

从入口函数开始,我们深入到流控处理的核心逻辑。

入口函数调用链

public class SphU {

    /**
     * 创建限流入口
     *
     * @param name 资源名称
     * @param resourceType 资源类型
     * @param trafficType 流量类型(IN 或 OUT)
     * @param args 参数数组
     * @return Entry 对象
     * @throws BlockException 如果被限流则抛出此异常
     */
    public static Entry entry(String name, int resourceType, EntryType trafficType, Object[] args)
            throws BlockException {
        return Env.sph.entryWithType(name, resourceType, trafficType, 1, args);
    }

    public static Entry entry(String name, EntryType trafficType, int batchCount) throws BlockException {
        return Env.sph.entry(name, trafficType, batchCount, OBJECTS0);
    }
}
public class CtSph implements Sph {

    @Override
    public Entry entry(String name, EntryType type, int count, Object... args) throws BlockException {
        StringResourceWrapper resource = new StringResourceWrapper(name, type);
        return entry(resource, count, args);
    }

    public Entry entry(ResourceWrapper resourceWrapper, int count, Object... args) throws BlockException {
        return entryWithPriority(resourceWrapper, count, false, args);
    }

    /**
     * 带优先级的入口方法,这是限流的核心逻辑
     */
    private Entry entryWithPriority(ResourceWrapper resourceWrapper, int count, boolean prioritized, Object... args)
            throws BlockException {
        Context context = ContextUtil.getContext();

        // 如果上下文数量超过阈值,则不进行规则检查
        if (context instanceof NullContext) {
            // NullContext 表示上下文数量超过了阈值,这里只初始化 Entry,不进行规则检查
            return new CtEntry(resourceWrapper, null, context);
        }

        // 如果没有上下文,使用默认上下文
        if (context == null) {
            context = InternalContextUtil.internalEnter(Constants.CONTEXT_DEFAULT_NAME);
        }

        // 如果全局开关关闭,则不进行规则检查
        if (!Constants.ON) {
            return new CtEntry(resourceWrapper, null, context);
        }

        // 获取或创建 ProcessorSlotChain(责任链)
        ProcessorSlot<Object> chain = lookProcessChain(resourceWrapper);

        /*
         * 如果资源(slot chain)数量超过 {@link Constants.MAX_SLOT_CHAIN_SIZE},
         * 则不进行规则检查
         */
        if (chain == null) {
            return new CtEntry(resourceWrapper, null, context);
        }

        // 创建 Entry 对象
        Entry e = new CtEntry(resourceWrapper, chain, context);

        try {
            // 执行责任链进行规则检查
            chain.entry(context, resourceWrapper, null, count, prioritized, args);
        } catch (BlockException e1) {
            // 如果被限流,释放 Entry 并抛出异常
            e.exit(count, args);
            throw e1;
        } catch (Throwable e1) {
            // 这不应该发生,除非 Sentinel 内部存在错误
            log.warn("Sentinel unexpected exception,{}", e1.getMessage());
        }
        return e;
    }
}

ProcessorSlotChain 功能插槽链

lookProcessChain 方法实际创建了 ProcessorSlotChain 功能插槽链。ProcessorSlotChain 采用责任链模式,将不同的功能(限流、降级、系统保护)组合在一起。

SlotChain 的获取与创建

ProcessorSlot<Object> lookProcessChain(ResourceWrapper resourceWrapper) {
    // 先从缓存中获取
    ProcessorSlotChain chain = chainMap.get(resourceWrapper);

    if (chain == null) {
        // 双重检查锁,保证线程安全
        synchronized (LOCK) {
            chain = chainMap.get(resourceWrapper);
            if (chain == null) {
                // Entry 大小限制
                if (chainMap.size() >= Constants.MAX_SLOT_CHAIN_SIZE) {
                    return null;
                }

                // 创建新的 SlotChain
                chain = SlotChainProvider.newSlotChain();

                // 使用不可变模式更新缓存
                Map<ResourceWrapper, ProcessorSlotChain> newMap =
                    new HashMap<ResourceWrapper, ProcessorSlotChain>(chainMap.size() + 1);
                newMap.putAll(chainMap);
                newMap.put(resourceWrapper, chain);
                chainMap = newMap;
            }
        }
    }
    return chain;
}

SlotChain 的构建

public class DefaultSlotChainBuilder implements SlotChainBuilder {

    @Override
    public ProcessorSlotChain build() {
        ProcessorSlotChain chain = new DefaultProcessorSlotChain();

        // 通过 SPI 加载所有 ProcessorSlot 并排序
        List<ProcessorSlot> sortedSlotList = SpiLoader.loadPrototypeInstanceListSorted(ProcessorSlot.class);

        for (ProcessorSlot slot : sortedSlotList) {
            // 只处理继承自 AbstractLinkedProcessorSlot 的 Slot
            if (!(slot instanceof AbstractLinkedProcessorSlot)) {
                RecordLog.warn("The ProcessorSlot(" + slot.getClass().getCanonicalName() +
                    ") is not an instance of AbstractLinkedProcessorSlot, can't be added into ProcessorSlotChain");
                continue;
            }

            // 将 Slot 添加到责任链尾部
            chain.addLast((AbstractLinkedProcessorSlot<?>) slot);
        }

        return chain;
    }
}

SlotChain 的功能划分

Slot Chain 可以分为两部分:

  • 统计数据构建部分(statistic):负责收集各种指标数据;
  • 判断部分(rule checking):根据规则判断是否限流。

官方架构图很好地解释了各个 Slot 的作用及其负责的部分。目前 ProcessorSlotChain 的设计是一个资源对应一个,构建好后缓存起来,方便下次直接取用。

各 Slot 的执行顺序

以下是 Sentinel 中各个 Slot 的默认执行顺序:

NodeSelectorSlot
    ↓
ClusterBuilderSlot
    ↓
StatisticSlot
    ↓
ParamFlowSlot
    ↓
SystemSlot
    ↓
AuthoritySlot
    ↓
FlowSlot
    ↓
DegradeSlot

NodeSelectorSlot - 上下文节点选择

这个功能插槽主要为资源下不同的上下文创建对应的 DefaultNode(实际用于统计指标信息)。解释一下Sentinel中的Node是什么,简单来说就是每个资源统计指标存放的容器,只不过内部由于不同的统计口径(秒级、分钟及)而分别有不同的统计窗口。Node在Sentinel不是单一的结构,而是总体上形成父子关系的树形结构。

不同的调用会有不同的 context 名称,如在当前 MVC 场景下,上下文为 sentinel_web_servlet_context。

public class NodeSelectorSlot extends AbstractLinkedProcessorSlot<Object> {

    /**
     * 同一个资源在不同上下文中的 DefaultNode 映射
     */
    private volatile Map<String, DefaultNode> map = new HashMap<String, DefaultNode>(10);

    @Override
    public void entry(Context context, ResourceWrapper resourceWrapper, Object obj, int count,
                      boolean prioritized, Object... args) throws Throwable {
        // 从映射表中获取当前上下文对应的节点
        DefaultNode node = map.get(context.getName());

        if (node == null) {
            // 双重检查锁,保证线程安全
            synchronized (this) {
                node = map.get(context.getName());
                if (node == null) {
                    // 创建新的 DefaultNode
                    node = new DefaultNode(resourceWrapper, null);

                    // 使用写时复制更新缓存
                    HashMap<String, DefaultNode> cacheMap = new HashMap<String, DefaultNode>(map.size());
                    cacheMap.putAll(map);
                    cacheMap.put(context.getName(), node);
                    map = cacheMap;

                    // 构建调用树
                    ((DefaultNode) context.getLastNode()).addChild(node);
                }
            }
        }

        // 设置当前上下文的当前节点
        context.setCurNode(node);
        // 继续执行后续 Slot
        fireEntry(context, resourceWrapper, node, count, prioritized, args);
    }

    @Override
    public void exit(Context context, ResourceWrapper resourceWrapper, int count, Object... args) {
        fireExit(context, resourceWrapper, count, args);
    }
}

ClusterBuilderSlot - 集群节点构建

这个功能槽主要用于创建 ClusterNode。ClusterNode 和 DefaultNode 的区别是:

DefaultNode 是特定于上下文的(context-specific);

ClusterNode 是不区分上下文的(context-independent),用于统计该资源在所有上下文中的整体数据。

public class ClusterBuilderSlot extends AbstractLinkedProcessorSlot<DefaultNode> {

    /**
     * 全局 ClusterNode 映射表
     */
    private static volatile Map<ResourceWrapper, ClusterNode> clusterNodeMap = new HashMap<>();

    private static final Object lock = new Object();

    private volatile ClusterNode clusterNode = null;

    @Override
    public void entry(Context context, ResourceWrapper resourceWrapper, DefaultNode node, int count,
                      boolean prioritized, Object... args) throws Throwable {
        // 创建 ClusterNode(如果不存在)
        if (clusterNode == null) {
            synchronized (lock) {
                if (clusterNode == null) {
                    // 创建集群节点
                    clusterNode = new ClusterNode(resourceWrapper.getName(), resourceWrapper.getResourceType());

                    // 更新全局映射表
                    HashMap<ResourceWrapper, ClusterNode> newMap =
                        new HashMap<>(Math.max(clusterNodeMap.size(), 16));
                    newMap.putAll(clusterNodeMap);
                    newMap.put(node.getId(), clusterNode);

                    clusterNodeMap = newMap;
                }
            }
        }

        // 将 ClusterNode 设置到 DefaultNode 中
        node.setClusterNode(clusterNode);

        // 如果有来源标识,则创建 origin node
        if (!"".equals(context.getOrigin())) {
            Node originNode = node.getClusterNode().getOrCreateOriginNode(context.getOrigin());
            context.getCurEntry().setOriginNode(originNode);
        }

        // 继续执行后续 Slot
        fireEntry(context, resourceWrapper, node, count, prioritized, args);
    }
}

StatisticSlot - 统计插槽

StatisticSlot 是 Sentinel 最重要的类之一,用于根据规则判断结果进行相应的统计操作。

统计逻辑说明

entry 的时候:

依次执行后续的判断 Slot;

每个 Slot 触发流控会抛出异常(BlockException 的子类);

若有 BlockException 抛出,则记录 block 数据;

若无异常抛出则算作可通过(pass),记录 pass 数据。

exit 的时候:

若无 error(无论是业务异常还是流控异常),记录 complete(success)以及 RT,线程数 -1。

记录数据的维度:

线程数 +1;

记录当前 DefaultNode 数据;

记录对应的 originNode 数据(若存在 origin);

累计 IN 统计数据(若流量类型为 IN)。

public class StatisticSlot extends AbstractLinkedProcessorSlot<DefaultNode> {

    @Override
    public void entry(Context context, ResourceWrapper resourceWrapper, DefaultNode node, int count,
                      boolean prioritized, Object... args) throws Throwable {
        try {
            // 此位置会调用 SlotChain 中后续的所有 Slot,完成所有规则检测
            fireEntry(context, resourceWrapper, node, count, prioritized, args);

            // 请求通过,增加线程数和通过数
            // 代码运行到这个位置,就证明之前的所有 Slot 检测都通过了
            // 此时就可以统计请求的相应数据了

            // 增加线程数(+1)
            node.increaseThreadNum();
            // 增加通过请求的数量(这里涉及到滑动窗口算法)
            node.addPassRequest(count);

            // 省略其他统计逻辑...

        } catch (PriorityWaitException ex) {
            // 如果是优先级等待异常,记录优先级等待数
            node.increaseThreadNum();
            if (context.getCurEntry().getOriginNode() != null) {
                context.getCurEntry().getOriginNode().increaseThreadNum();
            }
            if (resourceWrapper.getEntryType() == EntryType.IN) {
                // 记录入站统计数据
                Constants.ENTRY_NODE.increaseThreadNum();
            }
            throw ex;

        } catch (BlockException e) {
            // 如果被限流,记录被限流数
            // 省略 block 统计逻辑...
            throw e;

        } catch (Throwable ex) {
            // 如果发生业务异常,记录异常数
            // 省略异常统计逻辑...
            throw ex;
        }
    }

    @Override
    public void exit(Context context, ResourceWrapper resourceWrapper, int count, Object... args) {
        // 若无 error(无论是业务异常还是流控异常),记录 complete(success)以及 RT,线程数-1
        // 记录数据的维度:线程数+1、记录当前 DefaultNode 数据、记录对应的 originNode 数据(若存在 origin)
        // 、累计 IN 统计数据(若流量类型为 IN)
        // 省略 exit 统计逻辑...
    }
}

StatisticNode 数据结构

到这里,StatisticSlot 的作用已经比较清晰了。接下来我们需要分析它的统计数据结构。fireEntry 调用向下的节点和之前的方式一样,剩下的节点主要包括:

  • ParamFlowSlot;
  • SystemSlot;
  • AuthoritySlot;
  • FlowSlot;
  • DegradeSlot;

其中比较常见的是流控和熔断:FlowSlot、DegradeSlot,所以下面我们着重分析 FlowSlot。

六、FlowSlot - 流控插槽

这个 Slot 主要根据预设的资源的统计信息,按照固定的次序依次生效。如果一个资源对应两条或者多条流控规则,则会根据如下次序依次检验,直到全部通过或者有一个规则生效为止。

FlowSlot 核心逻辑

@SpiOrder(-2000)
public class FlowSlot extends AbstractLinkedProcessorSlot<DefaultNode> {

    @Override
    public void entry(Context context, ResourceWrapper resourceWrapper, DefaultNode node, int count,
                      boolean prioritized, Object... args) throws Throwable {
        // 执行流控检查
        checkFlow(resourceWrapper, context, node, count, prioritized);

        // 继续执行后续 Slot
        fireEntry(context, resourceWrapper, node, count, prioritized, args);
    }

    // 省略其他方法...
}

checkFlow 方法详解

/**
 * 执行流控检查
 *
 * @param ruleProvider 规则提供者函数
 * @param resource 资源包装器
 * @param context 上下文
 * @param node 节点
 * @param count 请求数量
 * @param prioritized 是否优先
 * @throws BlockException 如果被限流则抛出异常
 */
public void checkFlow(Function<String, Collection<FlowRule>> ruleProvider, ResourceWrapper resource,
                      Context context, DefaultNode node, int count, boolean prioritized) throws BlockException {
    // 判断规则和资源不能为空
    if (ruleProvider == null || resource == null) {
        return;
    }

    // 获取指定资源的所有流控规则
    Collection<FlowRule> rules = ruleProvider.apply(resource.getName());

    // 逐个应用流控规则。若无法通过则抛出异常,后续规则不再应用
    if (rules != null) {
        for (FlowRule rule : rules) {
            if (!canPassCheck(rule, context, node, count, prioritized)) {
                // FlowException 继承 BlockException
                throw new FlowException(rule.getLimitApp(), rule);
            }
        }
    }
}

通过这里我们就可以得知,流控规则是通过 FlowRule 来完成的,数据来源是我们使用的流控控制台,也可以通过代码进行设置。

FlowRule 流控规则

每条流控规则主要由三个要素构成:

  • grade(阈值类型):按 QPS(每秒请求数)还是线程数进行限流;
  • strategy(调用关系策略):基于调用关系的流控策略;
  • controlBehavior(流控效果):当 QPS 超过阈值时的流量整形行为。
public class FlowRule extends AbstractRule {

    public FlowRule() {
        super();
        // 来源默认 Default
        setLimitApp(RuleConstant.LIMIT_APP_DEFAULT);
    }

    public FlowRule(String resourceName) {
        super();
        // 资源名称
        setResource(resourceName);
        setLimitApp(RuleConstant.LIMIT_APP_DEFAULT);
    }

    /**
     * 流控的阈值类型
     * 0: 线程数
     * 1: QPS
     */
    private int grade = RuleConstant.FLOW_GRADE_QPS;

    /**
     * 流控阈值
     */
    private double count;

    /**
     * 基于调用链的流控策略
     * STRATEGY_DIRECT: 直接流控(按来源)
     * STRATEGY_RELATE: 关联流控(关联资源)
     * STRATEGY_CHAIN: 链路流控(按入口资源)
     */
    private int strategy = RuleConstant.STRATEGY_DIRECT;

    /**
     * 关联流控模式下的关联资源
     */
    private String refResource;

    /**
     * 流控效果(流量整形行为)
     * 0: 默认(直接拒绝)
     * 1: 预热(Warm Up)
     * 2: 排队等待(Rate Limiter)
     * 3: 预热 + 排队等待(目前控制台没有)
     */
    private int controlBehavior = RuleConstant.CONTROL_BEHAVIOR_DEFAULT;

    /**
     * 预热时长(秒)
     */
    private int warmUpPeriodSec = 10;

    /**
     * 排队等待的最大超时时间(毫秒)
     */
    private int maxQueueingTimeMs = 500;

    /**
     * 是否为集群模式
     */
    private boolean clusterMode;

    /**
     * 集群模式配置
     */
    private ClusterFlowConfig clusterConfig;

    /**
     * 流量整形控制器
     */
    private TrafficShapingController controller;

    // 省略 getter/setter 方法...
}

七、滑动窗口算法

不管流控规则采用何种流控算法,在底层都需要有支持指标统计的数据结构作为支撑。在 Sentinel 中,用于支撑基于 QPS 等限流的数据结构是 StatisticNode。

StatisticNode 数据结构

public class StatisticNode implements Node {

    /**
     * 保存最近 1 秒内的统计数据
     * 每个桶(bucket)500ms,共 2 个桶
     */
    private transient volatile Metric rollingCounterInSecond =
        new ArrayMetric(SampleCountProperty.SAMPLE_COUNT, IntervalProperty.INTERVAL);

    /**
     * 保存最近 60 秒的统计数据
     * windowLengthInMs 被特意设置为 1000 毫秒,即每个桶代表 1 秒
     * 共 60 个桶,这样可以获得每秒精确的统计信息
     */
    private transient Metric rollingCounterInMinute =
        new ArrayMetric(60, 60 * 1000, false);

    // 省略其他字段和方法...
}

ArrayMetric 核心实现

ArrayMetric 是 Sentinel 中数据采集的核心,内部使用了 BucketLeapArray,即滑动窗口的思想进行数据的采集。

public class ArrayMetric implements Metric {

    /**
     * 滑动窗口数组
     */
    private final LeapArray<MetricBucket> data;

    public ArrayMetric(int sampleCount, int intervalInMs) {
        this.data = new OccupiableBucketLeapArray(sampleCount, intervalInMs);
    }

    public ArrayMetric(int sampleCount, int intervalInMs, boolean enableOccupy) {
        if (enableOccupy) {
            // 可抢占的滑动窗口,支持借用未来窗口的配额
            this.data = new OccupiableBucketLeapArray(sampleCount, intervalInMs);
        } else {
            // 普通滑动窗口
            this.data = new BucketLeapArray(sampleCount, intervalInMs);
        }
    }
}

这里有两种实现:

  • BucketLeapArray:普通滑动窗口,每个时间桶仅记录固定时间窗口内的指标数据;
  • OccupiableBucketLeapArray:扩展实现,支持"抢占"未来时间窗口的令牌或容量,在流量突发时允许借用后续窗口的配额,实现更平滑的限流效果。

BucketLeapArray - 滑动窗口实现

LeapArray 核心属性

LeapArray 是滑动窗口的基础类,其核心属性如下:

/**
 * 窗口大小(长度),单位:毫秒
 * 例如:1000ms
 */
private int windowLengthInMs;

/**
 * 样本数(桶的数量)
 * 例如:5(表示 5 个桶,每个 1000ms,总共 5 秒)
 */
private int sampleCount;

/**
 * 采集周期(总时间窗口长度),单位:毫秒
 * 例如:5 * 1000ms(5 秒)
 */
private int intervalInMs;

/**
 * 窗口数组,array 长度就是样本数 sampleCount
 */
protected final AtomicReferenceArray<WindowWrap<T>> array;

/**
 * 更新窗口数据的锁,保证数据的正确性
 */
private final ReentrantLock updateLock;

WindowWrap 窗口包装器

每个窗口包装器包含三个属性:

 public class WindowWrap<T> {

    /**
     * 窗口大小(长度),单位:毫秒
     * 与 LeapArray 中的 windowLengthInMs 一致
     */
    private final long windowLengthInMs;

    /**
     * 窗口开始时间戳
     * 它的值是 windowLengthInMs 的整数倍
     */
    private long windowStart;

    /**
     * 窗口数据(泛型 T)
     * Sentinel 目前只有 MetricBucket 类型,存储统计数据
     */
    private T value;
}

MetricBucket 指标桶

public class MetricBucket {

    /**
     * 计数器数组
     * 长度是需要统计的事件种类数,目前是 6 个
     * LongAdder 是线程安全的计数器,性能优于 AtomicLong
     */
    private final LongAdder[] counters;
    
    // 省略其他字段和方法...
}

滑动窗口工作原理

LeapArray 统计数据的基本思路:

创建一个长度为 n 的数组,数组元素就是窗口;

每个窗口包装了 1 个指标桶,桶中存放了该窗口时间范围内对应的请求统计数据;

可以想象成一个环形数组在时间轴上向右滚动;

请求到达时,会命中数组中的一个窗口,该请求的数据就会存到命中的这个窗口包含的指标桶中;

当数组转满一圈时,会回到数组的开头;

此时下标为 0 的元素需要重复使用,它里面的窗口数据过期了,需要重置,然后再使用。

获取当前窗口

LeapArray 获取当前时间窗口的方法:

 /**
 * 获取当前时间戳对应的窗口
 *
 * @return 当前时间的窗口
 */
public WindowWrap<T> currentWindow() {
    return currentWindow(TimeUtil.currentTimeMillis());
}

/**
 * 获取指定时间戳对应的窗口(核心方法)
 *
 * @param timeMillis 时间戳(毫秒)
 * @return 对应的窗口
 */
public WindowWrap<T> currentWindow(long timeMillis) {
    if (timeMillis < 0) {
        return null;
    }

    // 计算数组下标
    int idx = calculateTimeIdx(timeMillis);

    // 计算当前请求对应的窗口开始时间
    long windowStart = calculateWindowStart(timeMillis);

    // 无限循环,确保能够获取到窗口
    while (true) {
        // 取窗口
        WindowWrap<T> old = array.get(idx);

        if (old == null) {
            // 第一次使用,创建新窗口
            WindowWrap<T> window = new WindowWrap<T>(windowLengthInMs, windowStart, newEmptyBucket(timeMillis));

            // CAS 操作,确保只初始化一次
            if (array.compareAndSet(idx, null, window)) {
                // 成功更新,返回创建的窗口
                return window;
            } else {
                // CAS 失败,让出时间片,等待其他线程完成初始化
                Thread.yield();
            }

        } else if (windowStart == old.windowStart()) {
            // 命中:取出的窗口的开始时间和本次请求计算出的窗口开始时间一致
            return old;

        } else if (windowStart > old.windowStart()) {
            // 窗口过期:本次请求计算出的窗口开始时间大于取出的窗口
            // 说明取出的窗口过期了,需要重置
            if (updateLock.tryLock()) {
                try {
                    // 成功获取锁,更新窗口开始时间,计数器重置
                    return resetWindowTo(old, windowStart);
                } finally {
                    updateLock.unlock();
                }
            } else {
                // 获取锁失败,让出时间片,等待其他线程更新
                Thread.yield();
            }

        } else if (windowStart < old.windowStart()) {
            // 异常情况:机器时钟回拨等
            // 正常情况不会进入该分支
            return new WindowWrap<T>(windowLengthInMs, windowStart, newEmptyBucket(timeMillis));
        }
    }
}

数据存储

在获取到窗口之后,就可以存储数据了。ArrayMetric 实现了 Metric 中存取数据的接口方法。

示例:存储 RT(响应时间)

/**
 * 添加响应时间数据
 *
 * @param rt 响应时间(毫秒)
 */
public void addRT(long rt) {
    // 获取当前时间窗口,data 为 BucketLeapArray
    WindowWrap<MetricBucket> wrap = data.currentWindow();

    // 计数
    wrap.value().addRT(rt);
}

/**
 * MetricBucket 的 addRT 方法
 *
 * @param rt 响应时间
 */
public void addRT(long rt) {
    // 记录 RT 时间对 rt 值
    add(MetricEvent.RT, rt);

    // 记录最小响应时间(非线程安全,但没关系)
    if (rt < minRt) {
        minRt = rt;
    }
}

/**
 * 通用的计数方法
 *
 * @param event 事件类型
 * @param n 增加的数量
 * @return 当前桶
 */
public MetricBucket add(MetricEvent event, long n) {
    counters[event.ordinal()].add(n);
    return this;
}

数据读取

示例:读取 RT(响应时间)

/**
 * 获取总响应时间
 *
 * @return 总响应时间
 */
public long rt() {
    // 触发当前窗口更新(处理过期窗口)
    data.currentWindow();

    long rt = 0;
    // 取出所有的 bucket
    List<MetricBucket> list = data.values();

    for (MetricBucket window : list) {
        rt += window.rt(); // 求和
    }
    return rt;
}

/**
 * 获取所有有效的窗口
 *
 * @return 有效窗口列表
 */
public List<T> values() {
    return values(TimeUtil.currentTimeMillis());
}

/**
 * 获取指定时间之前的所有有效窗口
 *
 * @param timeMillis 时间戳
 * @return 有效窗口列表
 */
public List<T> values(long timeMillis) {
    if (timeMillis < 0) {
        return new ArrayList<T>(); // 正常情况不会到这里
    }

    int size = array.length();
    List<T> result = new ArrayList<T>(size);

    for (int i = 0; i < size; i++) {
        WindowWrap<T> windowWrap = array.get(i);

        // 过滤掉没有初始化过的窗口和过期的窗口
        if (windowWrap == null || isWindowDeprecated(timeMillis, windowWrap)) {
            continue;
        }

        result.add(windowWrap.value());
    }
    return result;
}

/**
 * 判断窗口是否过期
 *
 * @param time 给定时间(通常是当前时间)
 * @param windowWrap 窗口包装器
 * @return 如果过期返回 true
 */
public boolean isWindowDeprecated(long time, WindowWrap<T> windowWrap) {
    // 给定时间与窗口开始时间超过了一个采集周期
    return time - windowWrap.windowStart() > intervalInMs;
}

OccupiableBucketLeapArray - 可抢占窗口

为什么需要 OccupiableBucketLeapArray?

假设一个资源的访问 QPS 稳定是 10,请求是均匀分布的:

在时间 0.0-1.0 秒区间中,通过了 10 个请求;

在 1.1 秒的时候,观察到的 QPS 可能只有 5,因为此时第一个时间窗口被重置了,只有第二个时间窗口有值;

当在秒级统计的情形下,用 BucketLeapArray 会有 0~50%的数据误这时就要用 OccupiableBucketLeapArray 来解决这个问题。

OccupiableBucketLeapArray 实现

从上面我们可以看到在秒级统计 rollingCounterInSecond 中,初始化实例时有两种构造参数:

public class OccupiableBucketLeapArray extends LeapArray<MetricBucket> {

    /**
     * 借用未来窗口的数组
     */
    private final FutureBucketLeapArray borrowArray;

    public OccupiableBucketLeapArray(int sampleCount, int intervalInMs) {
        super(sampleCount, intervalInMs);
        // 创建借用窗口数组
        this.borrowArray = new FutureBucketLeapArray(sampleCount, intervalInMs);
    }

    /**
     * 创建新的空桶
     * 会从 borrowArray 中借用数据
     */
    @Override
    public MetricBucket newEmptyBucket(long time) {
        MetricBucket newBucket = new MetricBucket();

        // 获取借用窗口的数据
        MetricBucket borrowBucket = borrowArray.getWindowValue(time);
        if (borrowBucket != null) {
            // 将借用数据复制到新桶中
            newBucket.reset(borrowBucket);
        }

        return newBucket;
    }

    /**
     * 重置窗口
     * 会从 borrowArray 中借用 pass 数据
     */
    @Override
    protected WindowWrap<MetricBucket> resetWindowTo(WindowWrap<MetricBucket> w, long time) {
        // 更新开始时间并重置值
        w.resetTo(time);

        MetricBucket borrowBucket = borrowArray.getWindowValue(time);
        if (borrowBucket != null) {
            // 重置桶值并添加借用的 pass 数据
            w.value().reset();
            w.value().addPass((int) borrowBucket.pass());
        } else {
            w.value().reset();
        }

        return w;
    }

    /**
     * 获取当前等待中的请求数量
     */
    @Override
    public long currentWaiting() {
        borrowArray.currentWindow();
        long currentWaiting = 0;
        List<MetricBucket> list = borrowArray.values();

        for (MetricBucket window : list) {
            currentWaiting += window.pass();
        }
        return currentWaiting;
    }

    /**
     * 添加等待中的请求数量
     *
     * @param time 时间
     * @param acquireCount 获取数量
     */
    @Override
    public void addWaiting(long time, int acquireCount) {
        WindowWrap<MetricBucket> window = borrowArray.currentWindow(time);
        window.value().add(MetricEvent.PASS, acquireCount);
    }
}

八、总结

至此,Sentinel 的基本情况都已经分析完成。以上内容主要讲解了 Sentinel 的核心处理流程,包括:

核心流程总结

  1. 规则加载:
  • 通过 RuleLoader 将各种规则(流控、熔断、系统限流等)加载到内存缓存中。
  1. 请求拦截:
  • 通过 SentinelServletFilter 过滤器拦截 HTTP 请求;
  • 通过SentinelResourceAspect切面处理 @SentinelResource 注解。
  1. 责任链处理:
  • 使用 ProcessorSlotChain 责任链模式组合多个功能插槽;
  • 每个插槽负责特定的功能(统计、流控、熔断等)。
  1. 流控判断:
  • FlowSlot 根据流控规则判断是否限流;
  • 通过滑动窗口算法统计 QPS、线程数等指标。
  1. 异常处理:
  • 被限流时抛出 BlockException;
  • 通过 blockHandler 或 fallback 处理异常。

核心技术点

  1. 责任链模式:
  • 通过 ProcessorSlotChain 将不同的限流功能组合在一起。
  1. 滑动窗口算法:
  • LeapArray 实现环形滑动窗口;
  • BucketLeapArray 普通滑动窗口;
  • OccupiableBucketLeapArray 可抢占窗口,支持借用未来配额。
  1. 数据结构:
  • DefaultNode:特定于上下文的统计节点;
  • ClusterNode:不区分上下文的集群统计节点;
  • StatisticNode:核心统计节点,包含秒级和分钟级统计。
  1. 限流算法:
  • QPS 限流:通过滑动窗口统计 QPS;
  • 线程数限流:通过原子计数器统计线程数;
  • 流控效果:快速失败、预热、排队等待等;

Sentinel 通过精心设计的架构,实现了高效、灵活、可扩展的流量控制能力,为微服务系统提供了强大的保护机制。

往期回顾

1.社区推荐重排技术:双阶段框架的实践与演进|得物技术

2.Flink ClickHouse Sink:生产级高可用写入方案|得物技术

3.服务拆分之旅:测试过程全揭秘|得物技术

4.大模型网关:大模型时代的智能交通枢纽|得物技术

5.从“人治”到“机治”:得物离线数仓发布流水线质量门禁实践

文 /万钧

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

服务拆分之旅:测试过程全揭秘|得物技术

一、引言

代码越写越多怎么办?在线等挺急的! Bidding-interface服务代码库代码量已经达到100w行!!

Bidding-interface应用是出价域核心应用之一,主要面向B端商家。跟商家后台有关的出价功能都围绕其展开。是目前出价域代码量最多的服务。

随着出价业务最近几年来的快速发展,出价服务承接的流量虽然都是围绕卖家出价,但是已远远超过卖家出价功能范围。业务的快速迭代而频繁变更给出价核心链路高可用、高性能都带来了巨大的风险。

经总结有如下几个痛点:

  • 核心出价链路未隔离:

    出价链路各子业务模块间代码有不同程度的耦合,迭代开发可扩展性差,往往会侵入到出价主流程代码的改动。每个子模块缺乏独立的封装,而且存在大量重复的代码,每次业务规则调整,需要改动多处,容易出现漏改漏测的问题。

  • 大单体&功能模块定义混乱:

    历史原因上层业务层代码缺乏抽象,代码无法实现复用,需求开发代码量大,导致需求估时偏高,经常出现20+人日的大需求,需求开发中又写出大量重复代码,导致出价服务代码库快速膨胀,应用启动耗时过长,恶性循环。

  • B/C端链路未隔离:

    B端卖家出价链路流量与C端价格业务场景链路流量没有完全隔离,由于历史原因,有些B端出价链路接口代码还存在于price应用中,偶尔B端需求开发会对C端应用做代码变更。存在一定的代码管控和应用权限管控成本。

  • 发布效率影响:

    代码量庞大,导致编译速度缓慢。代码过多,类的依赖关系更为复杂,持续迭代逐步加大编译成本,随着持续迭代,新的代码逻辑 ,引入更多jar 依赖,间接导致项目部署时长变长蓝绿发布和紧急问题处理时长显著增加;同时由于编译与部署时间长,直接影响开发人员在日常迭代中的效率(自测,debug,部署)。

  • 业务抽象&分层不合理:

    历史原因出价基础能力领域不明确,出价底层和业务层分层模糊,业务层代码和出价底层代码耦合严重,出价底层能力缺乏抽象,上层业务扩展需求频繁改动出价底层能力代码。给出价核心链路代码质量把控带来较高的成本, 每次上线变更也带来一定的风险。

以上,对于Bidding服务的拆分和治理,已经箭在弦上不得不发。否则,持续的迭代会继续恶化服务的上述问题。

经过前期慎重的筹备,设计,排期,拆分,和测试。目前Bidding应用经过四期的拆分节奏,已经马上要接近尾声了。服务被拆分成三个全新的应用,目前在小流量灰度放量中。

本次拆分涉及:1000+Dubbo接口,300+个HTTP接口,200+ MQ消息,100+个TOC任务,10+个 DJob任务。

本人是出价域测试一枚,参与了一期-四期的拆分测试工作。

项目在全组研发+测试的ALL IN投入下,已接近尾声。值此之际输出一篇文章,从测试视角复盘下,Bidding服务的拆分与治理,也全过程揭秘下出价域内的拆分测试过程。

二、服务拆分的原则

首先,在细节性介绍Bidding拆分之前。先过大概过一下服务拆分原则:

  • 单一职责原则 (SRP):  每个服务应该只负责一项特定的业务功能,避免功能混杂。

  • 高内聚、低耦合:  服务内部高度内聚,服务之间松耦合,尽量减少服务之间的依赖关系。

  • 业务能力导向:  根据业务领域和功能边界进行服务拆分,确保每个服务都代表一个完整的业务能力。

拆分原则之下,还有不同的策略可以采纳:基于业务能力拆分、基于领域驱动设计 (DDD) 拆分、基于数据拆分等等。同时,拆分时应该注意:避免过度拆分、考虑服务之间的通信成本、设计合理的 API 接口。

服务拆分是微服务架构设计的关键步骤,需要根据具体的业务场景和团队情况进行综合考虑。合理的服务拆分可以提高系统的灵活性、可扩展性和可维护性,而不合理的服务拆分则会带来一系列问题。

三、Bidding服务拆分的设计

如引言介绍过。Bidding服务被拆分出三个新的应用,同时保留bidding应用本身。目前共拆分成四个应用:Bidding-foundtion,Bidding-interface,Bidding-operation和Bidding-biz。详情如下:

  • 出价基础服务-Bidding-foundation:

出价基础服务,对出价基础能力抽象,出价领域能力封装,基础能力沉淀。

  • 出价服务-Bidding-interfaces:

商家端出价,提供出价基础能力和出价工具,提供商家在各端出价链路能力,重点保障商家出价基础功能和出价体验。

  • 出价运营服务-Bidding-operation:

出价运营,重点支撑运营对出价业务相关规则的维护以及平台其他域业务变更对出价域数据变更的业务处理:

  1. 出价管理相关配置:出价规则配置、指定卖家规则管理、出价应急隐藏/下线管理工具等;
  2. 业务大任务:包括控价生效/失效,商研鉴别能力变更,商家直发资质变更,品牌方出价资质变更等大任务执行。
  • 业务扩展服务-Bidding-biz:

更多业务场景扩展,侧重业务场景的灵活扩展,可拆出的现有业务范围:国补采购单出价,空中成单业务,活动出价,直播出价,现订现采业务,预约抢购,新品上线预出价,入仓预出价。

应用拆分前后流量分布情况:

图片

四、Bidding拆分的节奏和目标收益

服务拆分是项大工程,对目前的线上质量存在极大的挑战。合理的排期和拆分计划是重点,可预期的收益目标是灵魂。

经过前期充分调研和规划。Bidding拆分被分成了四期,每期推进一个新应用。并按如下六大步进行:

图片

Bidding拆分目标

  • 解决Bidding大单体问题: 对Bidding应用进行合理规划,完成代码和应用拆分,解决一直以来Bidding大单体提供的服务多而混乱,维护成本高,应用编译部署慢,发布效率低等等问题。
  • 核心链路隔离&提升稳定性: 明确出价基础能力,对出价基础能力下沉,出价基础能力代码拆分出独立的代码库,并且部署在独立的新应用中,实现出价核心链路隔离,提升出价核心链路稳定性。
  • 提升迭代需求开发效率: 完成业务层代码抽象,业务层做组件化配置化,实现业务层抽象复用,降低版本迭代需求开发成本。
  • 实现出价业务应用合理规划: 各服务定位、职能明确,分层抽象合理,更好服务于企/个商家、不同业务线运营等不同角色业务推进。

预期的拆分收益

  • 出价服务应用结构优化:

    完成对Bidding大单体应用合理规划拆分,向下沉淀出出价基础服务应用层,降低出价基础能力维护成功;向上抽离出业务扩展应用层,能够实现上层业务的灵活扩展;同时把面向平台运营和面向卖家出价的能力独立维护;在代码库和应用层面隔离,有效减少版本迭代业务需求开发变更对应用的影响面,降低应用和代码库的维护成本。

  • 完成业务层整体设计,业务层抽象复用,业务层做组件化配置化,提升版本迭代需求开发效率,降低版本迭代需求开发成本:

    按业务类型对业务代码进行分类,统一设计方案,提高代码复用性,支持业务场景变化时快速扩展,以引导降价为例,当有类似降价换流量/降价换销量新的降价场景需求时,可以快速上线,类似情况每个需求可以减少10-20人日开发工作量。

  • 代码质量提升 :

    通过拆分出价基础服务和对出价流程代码做重构,将出价基础底层能力代码与上层业务层代码解耦,降低代码复杂度,降低代码冲突和维护难度,从而提高整体代码质量和可维护性。

  • 开发效率提升 :

    1. 缩短应用部署时间: 治理后的出价服务将加快编译和部署速度,缩短Bidding-interfaces应用发布(编译+部署)时间 由12分钟降低到6分钟,从而显著提升开发人员的工作效率,减少自测、调试和部署所需的时间。以Bidding服务T1环境目前一个月编译部署至少1500次计算,每个月可以节约150h应用发布时间。
    2. 提升问题定位效率: 出价基础服务层与上层业务逻辑层代码库&应用分开后,排查定位开发过程中遇到的问题和线上问题时可以有效缩小代码范围,快速定位问题代码位置。

五、测试计划设计

服务拆分的前期,研发团队投入了大量的心血。现在代码终于提测了,进入我们的测试环节:

为了能收获更好的质量效果,同时也为了不同研发、测试同学的分工。我们需要细化到最细粒度,即接口维度整理出一份详细的文档。基于此文档的基础,我们确定工作量和人员排期:

如本迭代,我们投入4位研发同学,2位测试同学。完成该200个Dubbo接口和100个HTTP接口,以及20个Topic迁移。对应的提测接口,标记上负责的研发、测试、测试进度、接口详细信息等内容。

基于该文档的基础上,我们的工作清晰而明确。一个大型的服务拆分,也变成了一步一步的里程碑任务。

接下来给大家看一下,关于Bidding拆分。我们团队整体的测试计划,我们一共设计了五道流程。

  • 第一关:自测接口对比:

    每批次拆分接口提测前,研发同学必须完成接口自测。基于新旧接口返回结果对比验证。验证通过后标记在文档中,再进入测试流程。

    对于拆分项目,自测卡的相对更加严格。由于仅做接口迁移,逻辑无变更,自测也更加容易开展。由研发同学做好接口自测,可以避免提测后新接口不通的低级问题。提高项目进度。

    在这个环节中。偶尔遇见自测不充分、新接口参数传丢、新Topic未配置等问题。(三期、四期测试中,我们加强了对研发自测的要求)。

  • 第二关:测试功能回归

    这一步骤基本属于测试的人工验证,同时重点需关注写接口数据验证。

    回归时要测的细致。每个接口,测试同学进行合理评估。尽量针对接口主流程,进行细致功能回归。由于迁移的接口数量多,历史逻辑重。一方面在接口测试任务分配时,要尽量选择对该业务熟悉的同学。另一方面,承接的同学也有做好历史逻辑梳理。尽量不要产生漏测造成的问题。

    该步骤测出的问题五花八门。另外由于Bidding拆分成多个新服务。两个新服务经常彼此间调用会出现问题。比如二期Bidding-foundation迁移完成后,Bidding-operation的接口在迁移时,依赖接口需要从Bidding替换成foundation的接口。

    灰度打开情况下,调用新接口报错仍然走老逻辑。(测试时,需要关注trace中是否走了新应用)。

  • 第三关:自动化用例

    出价域内沉淀了比较完善的接口自动化用例。在人工测试时,测试同学可以借助自动化能力,完成对迁移接口的回归功能验证。

    同时在发布前天,组内会特地多跑一轮全量自动化。一次是迁移接口开关全部打开,一次是迁移接口开关全部关闭即正常的自动化回归。然后全员进行排错。

    全量的自动化用例执行,对迁移接口问题拦截,有比较好的效果。因为会有一些功能点,人工测试时关联功能未考虑到,但在接口自动化覆盖下无所遁形。

  • 第四关:流量回放

    在拆分接口开关打开的情况下,在预发环境进行流量回放。

    线上录制流量的数据往往更加复杂,经常会测出一些意料之外的问题。

    迭代过程中,我们组内仍然会在沿用两次回放。迁移接口开关打开后回放一次,开关关闭后回放一次。(跟发布配置保持一致)。

  • 第五关:灰度过程中,关闭接口开关,功能回滚

    为保证线上生产质量,在迁移接口小流量灰度过程中。我们持续监测线上问题告警群。

    以上,就是出价域测试团队,针对服务拆分的测试流程。同时遵循可回滚的发布标准,拆分接口做了非常完善的灰度功能。下一段落进行介绍。

六、各流量类型灰度切量方案

出价流程切新应用灰度控制从几个维度控制:总开关,出价类型范围,channel范围,source范围,bidSource范围,uid白名单&uid百分比(0-10000):

  • 灰度策略
  • 支持 接口维度 ,按照百分比进行灰度切流;

  • 支持一键回切;

Dubbo接口、HTTP接口、TOC任务迁移、DMQ消息迁移分别配有不同的灰度策略。

七、结语

拆分的过程中,伴随着很多迭代需求的开发。为了提高迁移效率,我们会在需求排期后,并行处理迭代功能相关的接口,把服务拆分和迭代需求一起完成掉。

目前,我们的拆分已经进入尾声。迭代发布后,整体的技术项目就结束了。灰度节奏在按预期节奏进行~

值得一提的是,目前我们的流量迁移仍处于第一阶段,即拆分应用出价域内灰度迁移,上游不感知。目前所有的流量仍然通过bidding服务接口进行转发。后续第二阶段,灰度验证完成后,需要进行上游接口替换,流量直接请求拆分后的应用。

往期回顾

1.大模型网关:大模型时代的智能交通枢纽|得物技术

2.从“人治”到“机治”:得物离线数仓发布流水线质量门禁实践

3.AI编程实践:从Claude Code实践到团队协作的优化思考|得物技术

4.入选AAAI-PerFM|得物社区推荐之基于大语言模型的新颖性推荐算法

5.Galaxy比数平台功能介绍及实现原理|得物技术

文 /寇森

关注得物技术,每周一、三更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

❌