阅读视图

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

心路散文 - 转职遇到AI浪潮,AIGC时刻人的价值是什么?

大家好我是Joney, 从去年12月开始的 Agent 编程技能 适用性大爆发以来,我深受震撼. 这一篇稿子是谢雨去年的12月底,但是一直没有发出来,今天我完善了它 以我的视角带来一次简单的记录吧, 这是一篇散文不是技术文章

心路

身为一名在代码废墟与绿洲中行走了七年的开发者,我的职业生涯恰好横跨了移动互联网的余晖与人工智能的黎明。从2019年那个满地黄金的红利期,到2026年这个被AI深度重塑的奇点,这七年,我换了无数个键盘,重塑了无数次系统,但最深刻的重装,发生在我对“职业”和“存在”的理解里。

image.png

2019年,是我职业生涯的起点,也是我记忆中最后的“慢生活”。

那时候,前端的疆域正在急速扩张。jQuery的老旧代码还在某些角落喘息,而Vue和React已经开启了它们的长达数年的统治 刚入行的时候Vue还在2.0 发布前夜,React也还没有Hook,TS也还在完善... 我记得在Newegg(新蛋)的那段日子,那是一个对业务逻辑有着极致追求的电商世界。我每天在B2B和B2C的业务闭环中穿梭,研究如何在高并发下保住那一秒的响应速度。要不就是在和 React的Hook进行各种搏斗,

我沉迷于.NET Core的中间件管道,在消息队列的起伏中寻找系统的节奏感。Redis不仅仅是缓存,它是我对抗系统崩溃的坚实护盾。那时候,我在掘金上开设NestJS和React Native的专栏,一字一句地写下对架构的理解。那时的成就感是具体的:手写一个复杂的组件,调通一个跨端的Bug,或者优化一个数据库查询。

在那时,我们坚信技术是有“护城河”的。一个能手写Redux源码、能搞定分布式事务的工程师,就是大厂争抢的“金领”。代码是我们的铁饭碗,每一行手敲出来的逻辑,都是我们对抗不确定性的筹码。也非常愿意相信“只要了解底层只要搞定算法 我就能进大厂”, 可惜了这么多年这个梦想一直没有实现 (可能我就是菜鸡吧...)时代不一样 个人抉择也不一样 很多机会是世代赋予的,抓住了就能上抓不住就什么都没有了,同时个人的积累也非常重要,机会永远只会抛给有准备的人,这一点我有深刻的理解,记得23年前后 成都的抖音发来了橄榄枝 可惜当时积累不太行...哎 时也命也 有时候人生就是这样

然而,某种本能的嗅觉让我没有在大前端的舒适区里躺平 继续找前端工作,继续找全栈工作 但是由于职业经历全栈的职位不太好找。转折在 2025年9月前端,我选择了跳出Web的二维平面,撞进了Unreal Engine和C++的三维世界。那时候团队变更 业务变更,我知道是又一次的 “选择和机遇” , 我始终相信一句话“选择比努力更重要”

image.png

这是一次近乎于“自毁式”的转型。从动态脚本语言回到指针、内存管理、多线程同步的硬核世界,那种感觉像是习惯了驾驶自动挡的人突然被扔上了一架超音速战斗机。在UE的世界里,我不止一次地在Shader的数学公式中迷失,也不止一次地因为C++的一个内存泄露而熬到凌晨四点。

但正是这次转型,让我提前接触到了“重度资产”和“复杂系统”的生产逻辑。它让我明白:界面的本质是交互,而交互的底色是数学和性能。复杂系统的构建是有迹可循的 这段经历,让我对复杂系统的理解有了更深入的了解和认识。

2025-2026交替之际,AI编程奇点爆发了。这不是预言,而是我在网易(NetEase)每天都在经历的现实。 短短几个月的一年,我目睹了整个集团生产模式的“地震”。CodeMaker[网易自己的AI插件]不再仅仅是一个辅助插件,它更像是一个拥有资深经验的数字分身。在《天下》这种量级的工程里,AI辅助生产已经渗透到了每一个毛孔。 以前,我们需要一个由主策、主美、资深前端和后端组成的精英小队,花上三四个月的时间去磨合、去撕逼、去联调,才能产出一个勉强可以看的MVP(最小可行性产品)。 以前, 我们需要在对话框中一个一个的问AI 使用 Vibe Coding,一点点的编码,结果往往是结构混乱 规范混乱,最后调试半天才做好一个功能。 而现在,利用SDD(系统驱动开发),直接全程自动...,竟然能在三四天内拉起一个同样质量的项目。 这不仅仅是效率的提升,这是对“人力成本”这个词的重新定义。

我必须说出那个令人不安的真相:传统的前端领域,已经坍塌了。 曾几何时,我们讨论面试要考闭包、考原型链、考微任务。在2026年,这些讨论显得如此滑稽。当AI能够以接近零成本的速度生成完美的代码库时,纯粹的“编码手艺”就不再具备议价能力。 初中级前端已经彻底失去了生存空间。AI不仅能写UI,它还能在毫秒间完成跨端适配和交互优化。那些依赖“接接口、画界面”生存的高级前端,也正处于岌岌可危的边缘。因为AI已经学会了理解“业务感”,它知道电商的支付链路需要什么样的容错,也知道社交应用需要什么样的滑动手感。

我们曾经引以为傲的“护城河”,在AI的暴力计算和无限记忆面前,不过是一道浅浅的排水沟。

AIGC 时刻,人的价值是什么

那么,未来留给我们的是什么?(想法,是最后的货币)

我越来越深刻地意识到:在一个代码无限供应的时代,唯一稀缺的资产是人类的“想法”和“审美”。

在Newegg磨练的业务逻辑,在掘金写专栏时的系统思考,在网易处理复杂项目时的权衡取舍——这些不再是技能,而是一种 “元能力” 。这种能力让你知道该如何给AI下指令,知道在AI产出的十个方案中,哪一个才是真正符合人性、符合商业逻辑的。

未来,是属于“超级个体”的时代。 你会看到,一个懂电商闭环、懂UE渲染、懂AI调优的个体,通过驾驭一套成熟的AI工作流,能够对抗以前一百人的团队。这种生产力的极致释放,将让我们的身份发生根本性的位移:

我们从**“建筑工人” (代码的搬运者),变成了“建筑师” (系统的设计者),甚至是“导演”**(意图的表达者)。

2026年的这场奇点爆发,是我职业生涯中最宏大的演出。 回望这七年,我感慨万千。我感谢那个在2019年疯狂学习全栈知识的自己,感谢那个在转型UE时痛不欲生的自己。如果没有那些日积月累的“重体力活”,今天的我,可能也会沦为那些在AI巨浪前手足无措的人之一。

现在的我,不再恐惧AI。我把它看作是我最强悍的僚机,是我思维的延伸。我过去积累了非常多的非常多的想法今天可以非常低成本的实现的时候 ,我就知道我的机会来了,我必须抓住它! 我们要做的,不是在旧的战场上死守,而是带着我们积累的“见识”和“审美”,去开拓那些AI尚未触及的荒原。我们要学会和AI谈恋爱,学会和它吵架,学会让它成为我们手中最锋利的剑。

前端们,或者说所有开发者们,请早做打算。不要死磕那一两行语法了,去理解业务,去磨练审美,去拥抱那个能让你一个人成为一支军队的工具。 奇点已至,众神归位。在这场代码的葬礼上,我看见了创造力的重生。

对抗AI是一个愚蠢的行为,掌握AI理解AI,我们应该是要学会共存实现自己的利益最大化

未来是属于我们的,诸君共勉!无限进步

【React】19 深度解析:掌握新一代 React 特性

【React】19 深度解析:掌握新一代 React 特性

从 Actions、use()、ref 作 prop 到表单与 Server Components,一文梳理 React 19 稳定版核心特性与落地方式。


一、React 19 是什么

React 19 是 React 团队在 2024 年 12 月 5 日 发布的稳定版本,在 npm 上可直接安装使用。它在保留 React 18 并发与 Suspense 能力的基础上,重点增强了异步数据与表单(Actions)、在渲染中消费资源use())、ref 与 Context 用法,并正式纳入 Server Components / Server Actions 的稳定能力,方便与支持全栈架构的框架(如 Next.js)配合使用。

与 18 相比,19 更强调「用声明式方式处理异步与表单」,减少手写 pending/error、减少 forwardRef 与 Context 的样板代码,同时改进水合报错、文档元数据等开发体验。


二、核心新特性概览

方向 内容
Actions 异步函数放进 useTransition,自动管理 pending、错误、表单重置;配合 useActionStateuseOptimistic<form action={fn}>
use() 在渲染中读取 Promise 或 Context,可条件调用,需配合 Suspense
ref 函数组件可直接用 ref 作 prop,不再强制 forwardRef
表单 <form> 支持 action/formAction 为函数;useFormStatus 读父级表单状态;requestFormReset 手动重置
Context 可用 <Context value={...}> 作 Provider,替代 <Context.Provider>
Server Server Components、Server Actions 在 React 19 中稳定,需框架/打包器支持

下面按块说明用法与注意点。


三、Actions:异步与表单一体化

Actions 指在 transition 里跑的异步函数:React 自动提供 pending 状态、错误边界内的错误处理、表单提交后重置(非受控)、以及乐观更新的回滚。

用 useTransition 跑异步

以前要自己用 useStateisPendingerror;现在把异步逻辑放进 startTransition 即可,pending 由 React 管理:

function UpdateName() {
  const [name, setName] = useState("");
  const [error, setError] = useState(null);
  const [isPending, startTransition] = useTransition();

  const handleSubmit = () => {
    startTransition(async () => {
      const err = await updateName(name);
      if (err) {
        setError(err);
        return;
      }
      redirect("/path");
    });
  };

  return (
    <>
      <input value={name} onChange={(e) => setName(e.target.value)} />
      <button onClick={handleSubmit} disabled={isPending}>Update</button>
      {error && <p>{error}</p>}
    </>
  );
}

useActionState:封装 Action + 状态

把「服务端/异步操作 + 上一次结果 + pending」打包成一个 Hook,适合表单提交:

function ChangeName({ name, setName }) {
  const [error, submitAction, isPending] = useActionState(
    async (previousState, formData) => {
      const err = await updateName(formData.get("name"));
      if (err) return err;
      redirect("/path");
      return null;
    },
    null
  );

  return (
    <form action={submitAction}>
      <input type="text" name="name" defaultValue={name} />
      <button type="submit" disabled={isPending}>Update</button>
      {error && <p>{error}</p>}
    </form>
  );
}

要点useActionState 第一个参数是 Action(可接收 previousState 与表单等入参),返回的 submitAction 可直接作为 <form action={submitAction}>,提交成功后 React 会重置非受控表单。

useOptimistic:乐观更新

在请求尚未返回前先更新 UI,失败时 React 会回滚到真实状态。需在 Action 内调用 setOptimisticXxx

function ChangeName({ currentName, onUpdateName }) {
  const [optimisticName, setOptimisticName] = useOptimistic(currentName);

  const submitAction = async (formData) => {
    const newName = formData.get("name");
    setOptimisticName(newName);
    const updated = await updateName(newName);
    onUpdateName(updated);
  };

  return (
    <form action={submitAction}>
      <p>Your name is: {optimisticName}</p>
      <input type="text" name="name" disabled={currentName !== optimisticName} />
    </form>
  );
}

useFormStatus(react-dom)

父级 <form> 内的子组件里,用 useFormStatus() 拿到该表单的 pending 等状态,无需层层传 props:

import { useFormStatus } from "react-dom";

function SubmitButton() {
  const { pending } = useFormStatus();
  return <button type="submit" disabled={pending}>Submit</button>;
}

function MyForm() {
  return (
    <form action={someAction}>
      <input name="field" />
      <SubmitButton />
    </form>
  );
}

四、use():在渲染中读 Promise / Context

use(resource) 可在组件渲染时消费两种资源:

  1. Promise:未 resolve 时组件挂起,由上层 <Suspense> 显示 fallback;resolve 后得到数据再渲染。
  2. Context:等价于「可条件调用的 useContext」,可在 early return 之后调用,这是和 Hooks 规则的重要区别。

读 Promise(配 Suspense)

import { use } from "react";

function Comments({ commentsPromise }) {
  const comments = use(commentsPromise);
  return comments.map((c) => <p key={c.id}>{c.text}</p>);
}

function Page({ commentsPromise }) {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <Comments commentsPromise={commentsPromise} />
    </Suspense>
  );
}

注意:不要在渲染里现场 new Promise 再传给 use(),React 会警告「uncached promise」。应使用支持缓存的数据源(如框架或 Suspense 兼容库提供的 promise)。

读 Context(可条件调用)

import { use } from "react";

function Heading({ children }) {
  if (children == null) return null;
  const theme = use(ThemeContext);  // 在 return 之后调用,useContext 做不到
  return <h1 style={{ color: theme.color }}>{children}</h1>;
}

五、ref 作为 prop、Context 作为 Provider

ref 直接当 prop

函数组件不再必须用 forwardRef,可直接声明 ref 并转发给 DOM 或子组件:

function MyInput({ placeholder, ref }) {
  return <input placeholder={placeholder} ref={ref} />;
}
// 使用:<MyInput ref={ref} />

未来版本中 forwardRef 将被弃用,官方会提供 codemod 协助迁移。

Context 直接当 Provider

可以用 <Context value={...}> 替代 <Context.Provider value={...}>

const ThemeContext = createContext("");

function App({ children }) {
  return (
    <ThemeContext value="dark">
      {children}
    </ThemeContext>
  );
}

六、文档元数据、ref 清理与其它

  • 文档元数据:在组件里直接渲染 <title><meta> 等,React 会提升到 <head>(需在支持该能力的框架/环境中使用)。
  • ref 回调清理:ref 回调可返回一个清理函数,在节点从 DOM 移除时执行。
  • 水合错误react-dom 对水合不匹配的报错做了改进,会给出更清晰的 diff 与说明链接。
  • React DOM 静态 APIreact-dom/static 提供 prerenderprerenderToNodeStream 等,用于在等待数据加载后输出静态 HTML 流。
  • Server Components / Server Actions:在 React 19 中稳定,需由支持全栈架构的框架(如 Next.js)或自定义打包器实现;"use server" 仅用于标记 Server Action,不用于标记 Server Component。

七、如何升级与参考


总结

  • Actions:用 useTransition 跑异步、useActionState 包表单、useOptimistic 做乐观更新,<form action={fn}>useFormStatus 简化表单状态。
  • use():在渲染中读 Promise(配 Suspense)或 Context,且 use() 可条件调用。
  • ref / Context:函数组件可直接用 ref 作 prop;可用 <Context value={...}> 作 Provider。
  • Server:Server Components 与 Server Actions 在 React 19 稳定,需框架支持;文档元数据、静态预渲染等需在对应环境中使用。

若对你有用,欢迎点赞、收藏;你若有 React 19 落地或迁移经验,也欢迎在评论区分享。

前端配环境配到崩溃?这个一键脚手架让我少掉了一把头发


我要讲一个你可能经历过的故事。

某个周一早上,产品找你说:"我们要做一个新项目,本周五能出第一版吗?"

你说:"没问题,这个功能不复杂。"

然后打开终端,开始配环境。


一个现实的周一早上

create-react-app?已经半废了,不用。Vite 直接初始化?好,装上。然后 TypeScript,装上。然后路由,用 React Router 还是 TanStack Router?选了半天,TanStack Router。好,装上配置,但路由类型怎么搞……

午饭后继续。接口请求用 Axios 还是 React Query?React Query 更现代,装上。但 QueryClient 配在哪?翻文档。然后样式,Tailwind 吧,装上,PostCSS 配置再弄一下。

下午,代码质量工具。ESLint,装上。Prettier,装上。然后两个打架了,查 StackOverflow,用 eslint-config-prettier 协调。Git hooks,Husky,lint-staged,commitlint,一个一个装,一个一个配。

到了晚上,终于能 pnpm dev 跑起来了。

周五?先把环境配好再说吧。


TanVite 是什么

这就是为什么我觉得 TanVite 值得专门写一篇文章介绍。

一句话:它是一个 React 19 的生产级脚手架,把上面那一堆事全帮你做完了

npm create tanvite@latest my-app
cd my-app
pnpm install
pnpm dev

四条命令,打开 http://localhost:4319,你会看到一个完整运行的应用,带着:

  • React 19 + Vite 5 + TypeScript
  • TanStack Router(文件路由)
  • TanStack Query(数据请求)
  • Tailwind CSS + shadcn/ui 工具
  • Biome(lint + format)
  • Vitest + Playwright(单元测试 + E2E 测试)
  • MSW + Prism(Mock 服务)
  • Husky + commitlint(Git 提交规范)
  • GitHub Actions CI
  • ……

不是"装了但没配",是真的配好了,能直接用的那种。


让我最惊喜的几个细节

1. OpenAPI 自动生成代码,这不是魔法

前后端对接接口是个慢性病。接口文档过时了,字段名拼错了,类型对不上……这些 bug 往往在联调时才发现,整个链路追下来浪费的时间是一个函数(然后程序员互相甩锅)。

TanVite 内置了 Orval 的 OpenAPI 工作流:

# 在 .env.local 里配你们后端的 Swagger 地址
OPENAPI_SCHEMA_URL=http://your-backend/api-docs

# 一条命令,自动生成:
# - 所有接口的 TypeScript 类型
# - 封装好的请求函数
# - React Query 的 useQuery/useMutation hooks
# - MSW Mock 处理函数
pnpm openapi:generate

后端把接口定义好(写 Swagger),前端跑一次生成命令,所有代码就出来了。

后端改了字段名?重新生成,TypeScript 编译器会告诉你哪里要改,不需要靠记忆和文档。这种感觉,就像从手工制砖突然用上了砖机。

2. Mock 体系想得很周全

pnpm dev:mock     # Vite + MSW,浏览器级别拦截接口
pnpm openapi:mock # 启动独立的 Prism Server(端口 4010)

这两种 Mock 方式解决的是不同场景:

  • MSW:适合"我想快速看看 UI 长啥样",不需要开额外进程
  • Prism:适合"需要真实 HTTP 交互",比如测试前端的 loading 状态、网络错误处理

后端还没写完、你也不想等?两种 Mock 随时顶上。

3. Biome 真的快到让人感动

以前用 ESLint + Prettier,在大项目里保存一下文件格式化要等 1-2 秒,pre-commit 跑完 lint 要等 10 几秒。时间长了,你会开始怀疑自己的人生选择。

Biome 用 Rust 写的,承担了 lint 和 format 两件事,速度快了不是一点半点。在 TanVite 里只有一个 biome.json,没有 .eslintrc.js.prettierrc 来来回回那一套。

保存,秒格式化。提交,秒通过检查。开发体验好很多。

4. TanStack Router 的类型是真的香

用过 React Router 的人可能都有这个经历:路由参数拿出来是 string | undefined,自己要转类型,一不小心就 undefined is not a number

TanStack Router 从设计之初就是 TypeScript-first,路由参数、搜索参数(query string)全都有完整的类型推断:

// 路由定义时就声明类型
const Route = createFileRoute('/product/$id')({
  validateSearch: (search) => ({
    page: Number(search.page ?? 1),
    sort: search.sort as 'asc' | 'desc' ?? 'asc',
  }),
})

// 组件里用的时候,类型是对的
const { page, sort } = Route.useSearch()
// page: number, sort: 'asc' | 'desc'
// 不是 string,不需要手动转换

IDE 会告诉你哪些参数可以传,传错了直接报错。这是"类型安全"真正体现价值的地方。


AI 协作这件事,TanVite 做了一个很有意思的尝试

.agents/skills/     # 给 Codex(GitHub Copilot)的技能包
.claude/skills/     # 给 Claude Code 的工作流指引
.claude/commands/   # OpenSpec 快捷命令

这些文件夹里存的是什么?是项目的规范——设计规范、代码风格、测试要求、Git 工作流。

这个设计背后的逻辑是:现在大家都在用 AI 工具写代码,但 AI 不了解你的项目上下文,给出的建议往往是泛泛的通用写法,不符合团队规范,还得人工修改。

TanVite 把规范写进这些技能包,AI 工具(Claude Code 或者 Codex)在生成代码的时候会自动读取这些上下文,输出的东西更贴合项目实际情况。

"与其让人去监督 AI,不如先把规范教给 AI。"

这个思路我觉得很超前,也很实用。


OpenSpec:把需求文档也纳入版本控制

这是一个稍微高阶一点的功能,但我觉得值得一提。

TanVite 内置了 OpenSpec,一套轻量的规格管理工作流:

openspec/
├── changes/    # 进行中的需求变更提案
├── specs/      # 基准功能规格
└── config.yaml
pnpm openspec:new feat/用户资料页重设计
# 在 openspec/changes/ 里创建变更提案

很多团队的需求文档活在飞书或者 Notion 里,和代码库完全脱节。OpenSpec 的思路是把功能规格和变更提案也放进代码仓库,和代码一起做版本控制。

三个月后回来看这段代码是干嘛的?找对应的 openspec change,一清二楚。


项目结构是清晰的

src/
├── lib/
│   ├── api/          # OpenAPI 生成的代码放这里
│   ├── query-client.ts
│   └── utils.ts      # cn() helper
├── mocks/            # MSW 配置
├── routes/           # 文件路由,新建文件自动注册
└── types/            # 全局类型定义

结构约定很清晰,新同学接手项目知道该把代码放哪。这种"约定"的价值,在团队大了之后会越来越明显。


一些实际的数字

  • 脚本命令pnpm routes:generatepnpm openapi:generatepnpm dev:mock……17 个常用脚本,覆盖开发、测试、构建、预览的全流程
  • 测试层次:单元测试(Vitest)+ E2E 测试(Playwright),两层都有配置好的
  • CI 流程:push 自动触发 lint 检查 + 单元测试,PR 才能合并
  • Node 要求:Node.js 20+,pnpm 10+,要求不高

怎么上手

新建项目(推荐方式):

# 基础版
npm create tanvite@latest my-app

# 完整版(包含 OpenSpec、OpenAPI、Playwright、AI 技能包等)
npm create tanvite@latest my-app -- --preset full

只是想看看代码学习

直接克隆源码仓库:github.com/YangsonHung…


最后

我不是说配环境这件事以后就不用管了——每个项目都有自己特殊的需求,总有些地方要自己动手。

但有一套经过深思熟虑的基础在,你从第一天开始就站在了一个更高的起点:不用从零考虑技术选型,不用从零配 lint 和测试,不用从零写 GitHub Actions。

省下来的时间,写功能它不香吗?


🔗 项目地址:github.com/YangsonHung…
🌐 在线 Demo:yangsonhung.github.io/tanvite/


如果觉得有用,欢迎给项目点个 Star ⭐ 也欢迎在评论区说说你们团队是怎么解决工程化问题的。

新框架electronbun项目入门指南,解决electron体积大的难题,Electrobun:Electron 的轻量级革命 —— 12MB 应用 +

Electrobun:Electron 的轻量级革命 —— 12MB 应用 + 全栈 TypeScript 深度解析与 5 分钟实战入门

2026 年,桌面应用开发迎来了一次真正的“瘦身革命”。Electron 曾经是跨平台神器,但 150MB+ 的体积、惊人的内存占用和缓慢启动,让无数开发者头疼不已。而现在,一个基于 Bun + Zig + 系统 Webview 的新框架——Electrobun——横空出世,把“Hello World”应用体积直接砍到 12MB,增量更新仅需 14KB,启动速度秒级,全栈 TypeScript 开箱即用。

本文结合最新深度解析与实战入门指南,带你从“为什么选它”到“5 分钟跑通 Hello World”,再到生产级对比与迁移建议,一次性吃透 Electrobun!

一、Electron 的痛点与 Electrobun 的诞生

Electron 统治桌面开发十年(VS Code、Slack、Discord 都在用它),但典型问题早已被吐槽无数:

  • 体积臃肿:一个简单 Hello World 打包后 150MB+(Chromium + Node.js 全家桶)
  • 内存吃紧:每个窗口独立 Chromium 实例
  • 启动缓慢:冷启动动辄 2-3 秒
  • 更新笨重:每次都要下载完整安装包

2026 年初,GitHub Trending 爆火的项目 Electrobun(官方仓库:blackboardsh/electrobun)给出了答案。官方口号:

Build ultra fast, tiny, and cross-platform desktop apps with TypeScript.

核心承诺:

  • ✅ 应用体积 ~12MB(使用系统 Webview)
  • ✅ 增量更新最小 14KB(bsdiff 算法)
  • ✅ 冷启动 0.5 秒
  • ✅ 全栈 TypeScript + 类型化 RPC
  • ✅ 跨平台(macOS/Windows/Linux)

二、Electrobun 到底是什么?技术栈一目了然

Electrobun 是一个“开箱即用”的桌面应用全栈解决方案,主进程用 Bun 执行,UI 用系统原生 Webview 渲染,原生绑定用 Zig 编写。

架构图(极简版):

Electrobun App
├── Main Process (Bun + TypeScript)
│   ├── 业务逻辑
│   ├── Zig 原生绑定(文件/通知/系统 API)
│   └── 类型化 RPC Server
└── Webview Process(系统自带)
    ├── HTML/CSS + React/Vue/Svelte
    └── 类型化 RPC Client

为什么这么轻?

  • 不捆绑 Chromium → 使用 macOS WKWebView / Windows WebView2 / Linux WebKitGTK
  • Bun 运行时比 Node.js 小 50%,启动快 3 倍
  • Zig 绑定性能接近 C++,编译速度飞快

三、四大核心优势:数据说话

1. 体积:12MB vs 150MB(减少 92%)

Electron Hello World(macOS .app):

  • Electron Framework + Chromium + Node.js ≈ 150MB

Electrobun Hello World:

  • Bun Runtime(10MB)+ Zig 绑定(1.5MB)+ 代码(0.5MB)≈ 12MB
  • 启动时自解压,磁盘占用更小

2. 增量更新:14KB vs 完整包

传统 Electron:下载 150MB+ 重装
Electrobun:后台检查 → bsdiff 二进制差异 → 下载 14KB-500KB → 无缝热更新(无需重启)

真实案例:改 5 行代码,更新包仅 14KB!

3. 性能:启动 + 内存碾压

(MacBook Pro M2 测试)

指标 Electron(冷启动) Electrobun(冷启动) 提升
首次启动 2.5s 0.8s 3x
热启动 1.8s 0.5s 3.6x
初始内存 120MB 35MB -70%
运行 10 分钟 180MB 50MB -72%

4. 开发体验:全栈 TypeScript,无需配置

Electron:主进程 Node.js + Renderer 需要 webpack/vite
Electrobun:主进程 + Webview 全部 TypeScript,开箱即用,编译期类型检查 RPC!

四、5 分钟实战:构建你的第一个 Hello World(官方最新流程)

步骤 1:安装 Bun(全局一次)

# macOS / Linux
curl -fsSL https://bun.sh/install | bash
# Windows
powershell -c "irm bun.sh/install.ps1 | iex"

步骤 2:创建项目(官方推荐)

npx electrobun init my-app
cd my-app

项目结构(精简):

my-app/
├── src/
│   ├── main/          # 主进程(Bun + TS)
│   └── webview/       # UI(HTML + TS)
├── electrobun.config.ts
└── package.json

步骤 3:开发模式

bun dev
  • 修改主进程 → 自动重启
  • 修改 Webview → 自动刷新

步骤 4:编写核心代码(src/main/index.ts 示例)

import { BrowserWindow } from "electrobun/bun";

const win = new BrowserWindow({
  title: "Hello Electrobun",
  url: "https://electrobun.dev",   // 或本地 views://main/index.html
  width: 800,
  height: 600,
});

步骤 5:打包发布

bun build:mac     # 生成 .app(自动签名可选)
bun build:win     # .exe
bun build:linux   # .AppImage

增量更新配置(electrobun.config.ts):

export default {
  updater: {
    url: "https://your-server.com/updates",
    checkInterval: 3600,
    autoDownload: true,
  }
};

5 分钟后,你就拥有一个 12MB 的跨平台桌面应用!

五、真实生产案例

  1. Audio TTS(文本转语音)

    • 集成 Qwen3-TTS 本地模型
    • 体积 18MB(含模型)
    • 启动 0.6s,内存 45MB
  2. Co(lab)(混合浏览器 + 代码编辑器)

    • 多窗口 Webview + Monaco Editor
    • 2 人团队 2 周上线
    • 支持 Vim 模式、插件系统

六、Electrobun vs Electron vs Tauri 终极对比

维度 Electron Tauri Electrobun(胜出)
体积 150MB+ ~10MB 12MB
增量更新 完整包 需手动实现 14KB bsdiff
启动速度 2.5s 1.2s 0.5s
语言 JS/TS + C++ Rust + TS 全栈 TS(学习成本最低)
生态 ★★★★★ ★★★★ ★★★(快速增长)
成熟度 生产级 成熟 v1 已稳定(2026)

选型建议

  • 小工具、内部应用、追求极致体积/速度 → 强烈推荐 Electrobun
  • 超复杂企业工具、大型生态依赖 → 继续 Electron
  • 极致性能 + 愿意学 Rust → Tauri

七、常见问题解答

Q1:稳定吗?能用于生产?
当前(2026 年 3 月)已发布 v1,社区已有多个生产应用(Audio TTS、Co(lab))。小型/内部工具完全可用,商业产品建议等插件市场更完善。

Q2:如何从 Electron 迁移?

  1. 替换 ipcMain/ipcRenderer 为类型化 RPC
  2. BrowserWindow API 几乎一致
  3. 测试系统 Webview 兼容性(macOS 14+、Windows 11+ 最佳)

Q3:性能提升真实吗?
社区真实反馈:体积平均减少 90%,启动提升 3 倍,内存减少 70%。

八、未来展望

官方路线图已明确:

  • 短期:Windows/Linux 稳定性 + 更多系统 API
  • 中期:1.0 正式版 + 可视化调试 + 应用商店支持
  • 长期:移动端(iOS/Android) + 云端构建

总结
Electrobun 用 Bun + Zig + 系统 Webview 重新定义了桌面应用开发:在体积、速度、开发体验上实现了对 Electron 的降维打击。

如果你还在为 Electron 的臃肿烦恼,现在就是切换的最佳时机!

立即行动

  1. npx electrobun init my-app
  2. 5 分钟跑通 Hello World
  3. 感受 12MB 的丝滑!

参考资料

欢迎在评论区分享你的 Electrobun 项目!我们一起见证桌面开发的“轻量化”时代 🚀

(本文综合 2026 年最新官方文档与社区实战经验整理,如有更新以官方为准)

今日开发反思:编辑器大纲跳转与数据持久化实践

今天来复盘一下我在编辑器项目中的开发过程与思考,主要做了两件核心事情:大纲跳转功能实现和编辑区域数据持久化,过程中踩了不少坑,也对架构设计有了更直观的理解,纯真实开发心路......菜鸟,大佬见谅...

一、大纲功能跳转编辑器主页面

首先是实现大纲点击跳转编辑器主页面的功能。

一开始我想直接使用 BlockNote 原生的 API 来完成跳转,但是失败了。最终的方案还是采用了外部的 API,直接修正后进行 scrollIntoView。

我在想一开始 BlockNote 原生是否支持这个操作,后面失败了——因为 BlockNote 底层的逻辑里是不支持空标题跳转,但实际上 UX(用户体验)的设计里面,空标题也应该也要跳转。

这是一个很小的内容,用常规修正的方法,之前我已经写过文章了,这一部分已经总结好了。

这次真正花时间的是:为了实现需求,不得不跳出 React 框架的抽象层,采用浏览器的原生 DOM API。

问了 AI 之后我才更清晰:操作原生 DOM 其实是我们降级(Fallback)的方案,但是基于业务需求,框架原生 API 不能完全覆盖所有交互场景,那我们只能降级用 DOM API 来直接操纵节点。

而且 BlockNote 本身并不自带大纲视图,我现在用的大纲 UI 都是自己手写的。

我看了一下,现在做这个跳转逻辑其实非常简单,一共就两三行代码:

const handleJump = (block: Block) => {
  // 通过属性选择器定位到具体的 DOM 节点
  const element = document.querySelector(`[data-id="${block.id}"]`);
  // 使用 ScrollIntoView 实现平滑滚动至视口中心
  element?.scrollIntoView({ behavior: "smooth", block: "center" });
};

回头看才发现,之前一直在查 API 查了很久,其实都没抓到关键点,最后用最朴素的浏览器 API 就解决了。

二、数据持久化:刷新不丢失编辑内容

今天着重做的地方是数据持久化,刷新以后,当前的编辑区域文字不能丢失,页面不能丢失。

持久化本质就是把内存中的状态(State)同步到非易失性存储(Non-volatile Storage),这里我采用的是存入 IndexedDB,按专业的话来讲就是在组件挂载(Mounting)阶段将其恢复。

核心原则:UI 的渲染要纯粹,数据访问层(Data Access Layer)与 UI 渲染必须分离,避免逻辑耦合。

副作用管理:useEffect 是 React 中处理副作用(Side Effects)的标准手段。在处理大量数据或异步操作时,我考虑了竞态条件(Race Condition)的问题——即异步操作的响应顺序可能与预期不符。结论依然是:应将数据同步逻辑与视图渲染逻辑彻底解耦。

这里我今天做的持久化分了两层:

  • 编辑区的文本:因为后续有云端导出的功能需求,数据量大且需要事务支持,所以采用 IndexedDB(MDN 定义:浏览器内置的基于键值对的非关系型数据库,适合存储大量结构化数据)。
  • 左侧边栏文档 / 大纲的选择:状态简单,仅需存储少量布尔值或字符串,用 localStorage 存储(MDN 定义:浏览器 Web Storage API,提供持久化的 Key-Value 存储,数据无过期时间,仅在同源策略下可见)。

我现在做的代码文件干了两个事情:一个是定义好了数据库的事务操作、初始化,还有一个是用 TypeScript 定义好类型(Type Definition)。

另外也要自定义一个 Hook,把持久化的逻辑封装好,让 UI 组件实现关注点分离(Separation of Concerns),仅负责渲染。但是 UI 怎样拿到 ID?怎样联通这个 Hook?是我今天做的比较久的一个部分。

最后的解决办法也只是在官方 BlockNote 持久化的 API 里面,把那一段代码文件找出来,然后再喂给 AI,叫 AI 帮我写…

三、对代码、Hook、架构的理解

现在来看一下这个代码文件是怎么做的。

import { BlockNoteView } from "@blocknote/mantine";
import "@blocknote/mantine/style.css";
import "@blocknote/core/fonts/inter.css";
import { BlockNoteEditor } from "@blocknote/core";
import { useCallback } from "react";

interface EditorProps {
  editor: BlockNoteEditor;
  onSave?: (content: any) => void;
  noteId?: string;
}

export default function Editor({ editor, onSave, noteId }: EditorProps) {
  // 使用 useCallback 缓存函数引用,避免在组件重渲染时触发不必要的子组件更新
  const handleSave = useCallback(() => {
    if (onSave && noteId) {
      onSave({
        id: noteId,
        content: editor.document,
        updatedAt: Date.now(),
      });
    }
  }, [onSave, noteId, editor.document]);

  return (
    <BlockNoteView
      editor={editor}
      onChange={handleSave}
    />
  );
}

原来的编辑器只是单纯渲染了一个视口,之前只是调用了 Quick Start 里面如何渲染出编辑器界面并处理内容变化,没有接上 Hook,没有涉及到 Data 的持久化链路。

现在是当用户在编辑器中进行修改的时候,handleSave 这个函数会被调用,采用的 useCallback 将最新的内容传给父组件以便保存。

这个最终代码并不是我写的,但我更深一步理解好了 Hook。我看了 React 原版文章的章节,我自己的理解是:

  • Hook 是一个处理状态逻辑(State Logic)的函数。
  • 如果这个逻辑不涉及状态管理或生命周期,那它就不叫 Hook。
  • Hook 抽离出来,是本身被复用的、单独的、复杂的一种响应式逻辑(Reactive Logic),是容器化的手段。
  • Hook 不是存储状态本身,它只是封装了一套状态变更的逻辑链路。

接下来我的做法是先做好初始化的存储协议,再做好 Hook 的定义。我今天比较大的收获应该就是:数据层整个架构应该怎样组织,但具体代码我自己还写不来,但是我能看得明白。

还有一个就是 TS 里面的类型定义,我虽然没有很仔细地每一行代码都敲过去,但是通过类型断言(Type Assertion)、显式类型(Explicit Type),我稍微感受到了一点工程上对于类型的严谨。

四、工程化细节:Loading 状态与数据流

Hook 的设计逻辑是去管理副作用,这里 AI 开始乱七八糟,一直在做死循环。今天的 AI 不是很聪明,我用得也不是很熟练。

我只是稍微明白了一点:工程模式在没有拿到 data 的时候,要有一个 Loading 中间状态来避免报错或者初始化的失败,就是要定义哪几个状态我比较清楚了,但具体怎么实现我还是不会写…

后面还有一个重渲染的问题:在保存的时候不需要加载状态,如果一直在触发状态更新的话,就会导致循环保存。重置后也不需要 Loading。

梳理好的完整数据流:

用户输入 → BlockNote 内部状态 change 触发 → save 函数执行 → 写入 IndexedDB → IndexedDB 初始化 → 从 data 中加载数据 → 回显到编辑器 UI

五、今日总结

我的天…这样看下来,我今天一天都在忙活什么?总结下来,自己亲手搓代码也不是很多,无非就是我在传各种 state,再传各种变量进去而已,那我今天在干什么?!!

但冷静下来想,这一天并不是无效忙碌:

  • 学会了不强行依赖库原生 API:必要时用原生 DOM 降级解决(实用开发思路)。
  • 真正理解了数据层、服务层、UI 层分离的意义:工程化思维提升。
  • 搞懂了自定义 Hook 的职责与设计思路:React 基础能力巩固。
  • 理清了编辑器从输入到持久化的完整数据流:业务逻辑梳理能力提升。
  • 对 TS 类型、Loading 状态、避免多余重渲染有了工程化意识:细节把控进步。

虽然很多代码还不能完全独立从零实现,但至少看得懂、理得清、知道为什么这么写,这对我来说就是进步!

参考文献

一次视频会议的“生命旅程”:从点击加入到大屏相见,Mediasoup 背后发生了什么?

一、故事的开端:你有没有想过?

当你在腾讯会议、Zoom、飞书会议里点击"加入会议"后,几秒钟内就能看到其他人的画面、听到他们的声音——这背后发生了什么?

微信图片_20260307224848_5604_6.png 最简单的方案是"点对点"连接,但10个人开会就需要45个连接!更好的方案是 SFU(选择性转发单元) :大家把视频发给服务器,服务器转发给其他人。Mediasoup 就是这样的服务器。本文讲基于Mediasoup讲述这背后服务之间是如何进行配合的。

二、三个角色,各司其职

image.png

服务 比喻 职责
mediasoup-ui 电视机 采集画面、播放声音、用户交互
signal-bridge 信号转换器 协议翻译(JSON ↔ protoo)
signal-server 播控中心 管理房间、转发媒体流

三、一次视频会议的"生命旅程"

让我们跟随一个用户"小马"的视角,看看他从加入会议到看到其他人画面的完整过程:

第一步:小马打开网页 📺

sequenceDiagram
小马->>UI: 点击加入会议
UI->>Server: 建立websocket连接
Server-->>小马: 准备好接收和发送媒体流

第二步:获取"电视频道列表" 📋

// 小马问服务器:你们支持哪些视频格式?
const routerRtpCapabilities = await this.signaling.request('getRouterRtpCapabilities');

// 小马的浏览器检查:这些格式我支持吗?
this.device = new mediasoupClient.Device();
await this.device.load({ routerRtpCapabilities });
// 如果没有报错,说明可以正常通信!

通俗解释:就像你买了一个新电视,先要检查能不能收到当地电视台的信号格式(高清还是标清)。

第三步:铺设"信号线" 🔌

小马需要两条"线":

  • 发送线:把小马的画面传给服务器
  • 接收线:从服务器接收其他人的画面
async createTransports() {
    // 📤 创建发送线
    const sendInfo = await this.signaling.request('createWebRtcTransport', {
        forceTcp: false,
        appData: { direction: 'producer' },  // 我是生产者
    });

    this.sendTransport = this.device.createSendTransport({
        id: sendInfo.transportId,
        iceParameters: sendInfo.iceParameters,      // 冰块参数(网络地址)
        iceCandidates: sendInfo.iceCandidates,      // 候选地址列表
        dtlsParameters: sendInfo.dtlsParameters,    // 加密参数
    });

    // 📥 创建接收线(代码类似)
    const recvInfo = await this.signaling.request('createWebRtcTransport', {
        appData: { direction: 'consumer' },  // 我是消费者
    });
    this.recvTransport = this.device.createRecvTransport({...});
}

Transport: 就像一根水管,你需要两根——一根往里注水(发送),一根往外放水(接收)。

第四步:服务器端铺设"水管" 🏗️

服务器收到请求后,在 mediasoup 里创建真正的 Transport:

// signal-server/Room.ts
const transport = await mediasoupRouter.createWebRtcTransport({
    webRtcServer: mediasoupWebRtcServer,  // 共享端口服务器
    enableUdp: true,   // 支持UDP(更快)
    enableTcp: true,   // 支持TCP(更稳定)
    appData: { direction },  // 记录这是发送还是接收
});

// 返回给客户端
resolve({
    transportId: transport.id,
    iceParameters: transport.iceParameters,
    iceCandidates: transport.iceCandidates,
    dtlsParameters: transport.dtlsParameters,
});

第五步:小马打开摄像头 📹

async enableMic({ stream } = {}) {
    // 1. 向浏览器申请摄像头/麦克风权限
    const localStream = await navigator.mediaDevices.getUserMedia({ 
        audio: true, 
        video: false 
    });
    const track = localStream.getAudioTracks()[0];

    // 2. 通过发送线,把画面发出去
    this.micProducer = await this.sendTransport.produce({ track });
}

关键来了!  当调用 produce() 时,会触发一个事件:

// 监听 'produce' 事件 - 这是 WebRTC 的核心!
this.sendTransport.on('produce', async ({ kind, rtpParameters }, callback) => {
    // 通知服务器:我要发送一个媒体流
    const { producerId } = await this.signaling.request('produce', {
        transportId: this.sendTransport.id,
        kind,              // 'audio' 或 'video'
        rtpParameters,     // 编码参数
    });
    
    // 告诉本地 Transport:服务器已经准备好了
    callback({ id: producerId });
});

第六步:服务器创建 Producer 🎙️

服务器收到请求后,创建一个"生产者"对象:

// signal-server/Peer.ts
case 'produce': {
    const { transportId, kind, rtpParameters, appData } = data;
    const transport = this.getTransport(transportId);
    
    // 🎯 核心API:创建 Producer
    const producer = await transport.produce({
        kind,           // 音频还是视频
        rtpParameters,  // 编码参数
        appData: { 
            peerId: this.id,    // 是谁发的
            source: 'mic',      // 来源是什么
        },
    });

    // 🔔 重要:触发事件,通知房间里其他人
    this.emit('new-producer', { producer });
    
    // 返回 Producer ID 给客户端
    accept({ producerId: producer.id });
}

第七步:其他用户收到小马的画面 👥

Room 监听到 new-producer 事件后,会为其他用户创建 Consumer:

// signal-server/Room.ts
peer.on('new-producer', async ({ producer }) => {
    // 获取房间里除了小明以外的所有人
    const otherPeers = this.getOtherPeers(peer);
    
    // 为每个人创建 Consumer(消费者)
    for (const otherPeer of otherPeers) {
        await otherPeer.consume({ producer });
    }
});

创建 Consumer 的详细过程:

// signal-server/Peer.ts
async consume({ producer }) {
    const transport = this.getRecvTransport();
    
    // 🎯 创建消费者(初始暂停状态)
    const consumer = await transport.consume({
        producerId: producer.id,
        rtpCapabilities: this.rtpCapabilities,
        paused: true,  // 先暂停,等客户端准备好
    });

    // 📢 通知客户端:有新的媒体流可以消费
    await this.request('newConsumer', {
        peerId: producer.appData.peerId,   // 谁发的
        consumerId: consumer.id,
        producerId: producer.id,
        kind: consumer.kind,               // 音频还是视频
        rtpParameters: consumer.rtpParameters,
    });

    // 客户端确认后,恢复传输
    await consumer.resume();
}

第八步:小王的浏览器显示小马的画面 🖥️

// mediasoup-ui 处理 newConsumer 请求
async handleServerRequest(request) {
    if (request.method === 'newConsumer') {
        const { consumerId, producerId, kind, rtpParameters } = request.data;
        
        // 📥 消费这个媒体流
        const consumer = await this.recvTransport.consume({
            id: consumerId,
            producerId,
            kind,
            rtpParameters,
        });

        // 🎬 获取媒体轨道,创建可播放的流
        const stream = new MediaStream([consumer.track]);
        
        // 把流绑定到 video/audio 标签
        const videoElement = document.getElementById('remote-video');
        videoElement.srcObject = stream;
        
        // 接受请求,服务器开始传输
        request.accept();
    }
}

四、完整流程图

sequenceDiagram
    participant UI as mediasoup-ui<br/>(小马浏览器)
    participant Bridge as signal-bridge<br/>(协议转换)
    participant Server as signal-server<br/>(媒体服务器)

    Note over UI,Server: 1️⃣ 建立连接
    UI->>Bridge: WebSocket 连接
    Bridge->>Server: protoo 连接
    Server-->>Bridge: 连接成功
    Bridge-->>UI: protooOpen

    Note over UI,Server: 2️⃣ 获取路由能力
    UI->>Bridge: getRouterRtpCapabilities
    Bridge->>Server: 转发请求
    Server-->>Bridge: router.rtpCapabilities
    Bridge-->>UI: 返回能力
    UI->>UI: Device.load()

    Note over UI,Server: 3️⃣ 创建传输通道
    UI->>Bridge: createWebRtcTransport
    Bridge->>Server: 转发请求
    Server->>Server: 创建 Transport
    Server-->>UI: {transportId, iceParams...}
    UI->>UI: 创建 SendTransport/RecvTransport

    Note over UI,Server: 4️⃣ 加入房间
    UI->>Bridge: join {displayName, rtpCapabilities}
    Bridge->>Server: 转发请求
    Server->>Server: 创建 Peer
    Server-->>UI: {peers: [已在线用户]}

    Note over UI,Server: 5️⃣ 打开摄像头
    UI->>UI: getUserMedia()
    UI->>UI: sendTransport.produce()
    UI->>Bridge: produce {kind, rtpParameters}
    Bridge->>Server: 转发请求
    Server->>Server: 创建 Producer
    Server-->>UI: {producerId}

    Note over UI,Server: 6️⃣ 其他用户接收
    Server->>Server: 触发 new-producer 事件
    Server->>Server: 为其他 Peer 创建 Consumer
    Server-->>UI: newConsumer 请求
    UI->>UI: recvTransport.consume()
    UI-->>Server: accept
    Server->>Server: consumer.resume()

五、媒体流路由示意图

image.png

六、信令 vs 媒体

flowchart TB
    subgraph Signaling[信令通道 - 控制面]
        S1[WebSocket]
        S2[JSON/protoo 协议]
        S3[传输控制消息]
    end

    subgraph Media[媒体通道 - 数据面]
        M1[WebRTC]
        M2[ICE/DTLS/SRTP]
        M3[传输音视频数据]
    end

    Client[客户端] --> S1
    Client --> M1
    S1 --> Server[服务器]
    M1 --> Server
类型 协议 传输内容
信令 WebSocket + JSON 控制消息(加入房间、创建Transport等)
媒体 WebRTC (ICE/DTLS/SRTP) 音视频数据流

七 关键 API 速查表

mediasoup-client(浏览器端)

API 说明 使用场景
new Device() 创建设备对象 初始化时
device.load({ routerRtpCapabilities }) 加载服务器能力 加入房间前
device.createSendTransport() 创建发送通道 准备发送媒体
device.createRecvTransport() 创建接收通道 准备接收媒体
transport.produce({ track }) 生产媒体流 打开摄像头/麦克风
transport.consume({ id, ... }) 消费媒体流 接收远程媒体

mediasoup(服务器端)

API 说明 使用场景
worker.createRouter({ mediaCodecs }) 创建路由器 创建房间时
router.createWebRtcTransport() 创建传输通道 用户加入时
transport.produce({ kind, rtpParameters }) 创建生产者 用户发送媒体
transport.consume({ producerId, rtpCapabilities }) 创建消费者 分发媒体给其他人
router.pipeToRouter({ producerId, router }) 跨路由传输 高级场景,分离生产/消费

八、写在最后

理解 Mediasoup 的关键点:

  1. SFU 架构:服务器只转发,不编解码,所以延迟低
  2. Transport 是核心:一切媒体传输都通过 Transport
  3. Producer/Consumer 模式:一人生产,多人消费
  4. 信令与媒体分离:WebSocket 传控制消息,WebRTC 传媒体数据
  5. 事件驱动new-producer 事件触发 consume,形成完整链路

前端架构模式思考

首先,如何开始一个项目?

在一个项目开始前,一般会得到一个需求文档,一个设计文档,知道项目用户,项目的目标,项目截止日期等信息。当拥有了这些信息后,就需要做一些关键决策了,如何部署,采用单页面应用还是多页面应用,是否需要服务端渲染,使用哪个框架,React/Vue/Angular等,使用哪种架构模式。

然后,需要考虑一个核心问题,项目的可扩展性。项目后续可能会如何发展。

在一般的工作流程中,很多人在面对一个新项目时,几乎是下意识的根据公式技术栈,使用脚手架工具,直接创建一个新项目,并进入开发,并没有项目架构,只是根据经验进行需求开发。

那为什么需要架构呢?

在解释这个问题之前,需要明确 React/Vue/Angular 这些现在前端开发框架都是负责组件渲染、局部状态管理、用户交互和用户界面生命周期,但是它们不提供构建完整应用所需的所有内容。例如:数据转换的模式、身份验证、日志记录等。

但在实际开发中,框架负责的组件所负责的工作早已超出了UI渲染这个范围,经常会把业务逻辑放在组件中,比如:API调用,执行业务规则。随着应用程序的增长,这会使维护变得更加困难。如果需要进行业务逻辑的测试,在执行测试用例时也需要挂载组件,需要一个完整的React环境。如果需要切换框架,那么在业务逻辑和组件耦合的情况下,需要重写所有逻辑。

什么样的架构才是理想的架构?

  • 可以在没有用户界面组件的情况下进行业务逻辑测试
  • 可以随意替换框架,并且不用重写业务逻辑
  • 新加入的开发者可以快速理解代码并开始执行开发任务
  • 数据源改变不会影响业务逻辑
  • 添加新的功能不会影响现有的功能
  • 不同的团队可以在不同的层和模块上独立工作
  • 工具选择根据团队需求决定,不会因为架构强制要求使用特定工具

如何尽量靠拢理想架构?

要做到以上几点,需要团队的技术自律。

  • 业务逻辑应该存在于框架无关的模型里,只有使用TyprScript或者JavaScript实现的纯逻辑
  • 要根据实际的复杂度进行工具选择而不是盲目跟随社区推荐
  • 数据访问和业务逻辑隔离
  • 业务逻辑在任何框架中都一样适用
  • 视图层只负责展示,组件要保持简洁,它们只负责接收数据,展示数据,获取用户输入,并将这些输入传递给下一个环节
  • CLI test:要做到可以在不重复逻辑的情况下更换用户界面

架构模型

在逻辑上,可以把前端项目分为3层。视图层、逻辑层、数据层。

image.png

视图层负责UI组件的处理,这一层用于显示信息并捕捉用户的交互。这一层没有业务逻辑和API调用,纯粹用于展示。

逻辑层负责管理业务逻辑和规则。这一层负责验证业务逻辑和协调操作。

数据层负责管理API的集成。同时也负责外部数据源的状态管理。这一层会获取数据并对其进行转换并进行缓存。

这样分层的好处是,缩小了每一层关注点,在每一层只需关注一个问题,测试独立,每一层可以单独测试。让代码库更容易维护。

这里有一个重要的概念,这些分层不是物理分层而是逻辑分层。它们可以分布在不同的设备上。可以视图层在浏览器中,逻辑层和数据层在服务器上,也可以都在浏览器中部署。这需要根据业务需求灵活调整。

对于前端应用逻辑层和数据层是可选的,应该在需要它们解决实际问题时再加上,而不是为了分层而分层,比如一个静态网站,逻辑层和数据层明显就是不需要的。

同时在一个团队中,一致性是非常重要的,一致性让代码库变得更可预测,团队间沟通更清晰。

架构扩展

扩展方式主要有两种:垂直扩展和水平扩展

这两个术语来自后端开发,在需要扩展时,后端可以通过增加更多服务器来实现水平扩展,或者在一个服务器上增加CPU或者内存来实现垂直扩展。是物理层面的扩展。

在前端场景,无法在用户的设备上增加更多的CPU或内存。这里主要指架构的可扩展性,是逻辑层面的扩展。如果代码库可以很容易的增加功能,那么说明代码库的可扩展性就很好。

垂直扩展指增加更多的层次,让层次更深,在以上三个层次中再细分层次。

水平扩展就是增加更多的切片,增加宽度。根据功能进行区分。

垂直扩展和水平扩展可以同时使用,在多数情况下,水平扩展更有价值,因为它可以实现并行开发,这在团队开发中尤其重要。

这样的方式在一定程度上让代码库向高内聚和低耦合靠拢。

前端工程化落地指南:pnpm workspace + Monorepo 核心用法与实践

在前端工程化落地过程中,pnpm workspace + Monorepo 已成为主流架构方案,有效解决了公共组件版本污染、定制化困难的行业痛点。本文,系统分享这一套技术方案的核心用法,聚焦 pnpm workspace、Monorepo 与 shadcn 的核心逻辑,帮大家快速掌握并落地到实际项目中。

本文将用「新手视角+实操例子」,拆解这三个核心概念的底层逻辑,全程大白话+可直接复制的JS demo,兼顾专业性与易懂性,帮大家快速搞懂 pnpm workspace + Monorepo + shadcn,轻松落地到实际开发中。

一、先解决核心困惑:3个概念到底是什么?(新手友好版)

很多开发者初次接触这三个概念时会觉得抽象,结合实际项目结构就能快速理解——核心就是“一个仓库装所有,工具帮你管依赖,源码可控不踩坑”,这也是选择这套架构的核心原因。

1. Monorepo:不是“随便一个文件夹”,是前端团队的“统一代码仓库”

很多人初次接触 Monorepo 会有这样的疑问:不就是一个文件夹里放了所有项目、工具、代码吗?要用直接 import 就行~

✅ 纠正+补充:大方向完全对,但不是“随便放”,是有规范结构+工具加持的「单Git仓库」,用来管理团队所有相关的项目和代码,区别于传统的“一个项目一个仓库”(Multirepo)。Monorepo核心是解决多项目复用、版本协同、跨团队协作的效率问题。

举个生活化类比(秒懂):

  • Multirepo(传统方式):你有多个抽屉,每个抽屉只放一类东西(袜子、内衣、裤子),拿裤子要开裤子抽屉,找袜子要开袜子抽屉,跨抽屉拿东西超麻烦;
  • Monorepo(现在主流):你有一个超大衣柜,里面分区域(袜子区、内衣区、裤子区),所有东西都在一个衣柜里,搭一身衣服伸手就能拿,不用来回开关多个抽屉。

前端实操例子:

your-team-monorepo/  # 这就是Monorepo根目录(一个Git仓库)
├── apps/           # 业务项目区(团队所有业务都在这,大厂通常按业务域划分)
│   ├── admin-web/  # 后台管理系统(完整前端项目)
│   └── shopping-web/ # 购物H5项目
├── packages/       # 公共代码区(可复用的组件、工具,大厂核心复用层)
│   ├── ui-components/ # 公共UI组件(按钮、表格等,统一设计规范)
│   ├── utils/         # 工具函数(时间格式化、请求封装,跨项目复用)
│   └── hooks/         # 公共Hooks(useRequest、useStorage,统一逻辑)
├── .eslintrc.js    # 根目录统一ESLint配置(大厂规范,统一代码风格)
├── pnpm-workspace.yaml # pnpm的Monorepo配置文件(关键!)
└── README.md       # 仓库说明(大厂必备,含架构文档、启动指南)

核心好处:apps里的两个项目,想用到packages里的组件,直接 import 就行,不用去npm下载,改组件源码也能实时生效,不用发包升级;同时统一代码规范、依赖版本,避免跨项目“重复造轮子”,提升团队协作效率。

2. pnpm workspace:pnpm自带的“Monorepo管家”

很多开发者只用过 pnpm install 装依赖,对 --filter 功能很陌生,其实它是 pnpm 专门用来管理 Monorepo 的“神器”,相比npm/yarn,pnpm 的软链接机制、依赖复用能力,更适配中大型团队的 Monorepo 场景。

✅ 大白话定义:pnpm(比npm/yarn更快、更节省空间的包管理器)内置的功能,帮你解决“一个仓库里多项目、多包”的依赖安装、脚本执行、包引用问题,无需额外安装lerna等Monorepo工具。

核心作用(实操,一看就会):

  1. 统一安装依赖:在Monorepo根目录执行 pnpm install,pnpm会自动识别所有子项目的依赖,只装一次(避免重复安装,节省磁盘空间,速度翻倍);
  2. 软链接关联内部包:apps/admin-web 引用 packages/ui-components 时,pnpm不会复制代码,而是建一个“快捷方式”,改组件源码,业务项目实时生效(核心诉求:快速迭代、实时同步);
  3. 精准执行脚本(重点!--filter 用法):用 pnpm --filter 子项目名 脚本名,实现多项目独立运行,互不干扰(同时维护多个业务项目,精准启动/构建)。

举个实操例子(对应上面的项目结构):

# 只启动 admin-web 开发服务(不影响shopping-web,大厂日常开发常用)
pnpm --filter admin-web dev

# 只启动 shopping-web 开发服务
pnpm --filter shopping-web dev

# 只给 packages/ui-components 装依赖(比如lodash)
pnpm --filter ui-components add lodash

# 批量构建所有业务项目(大厂部署常用)
pnpm --filter "./apps/*" run build

很多人会疑惑:“两个项目分开独立执行,直接各自启动不行吗?” 其实不然:如果没有 --filter,在根目录执行 pnpm dev,会同时启动所有子项目,既浪费资源,也没必要——--filter 就是帮你“精准操作”,想动哪个项目就动哪个,这也是提升开发、部署效率的关键技巧。

3. shadcn/ui:不是“装包”,是“复制源码”的组件库

很多开发者初次接触 shadcn/ui,会误以为它是普通组件库(比如Antd、Element UI),直到实际使用才发现,它的复用模式和传统组件库完全不同——这种“源码级复用”模式,正是解决“公共组件定制化困难”的常用方案(业务复杂,传统组件库难以满足所有定制需求)。

✅ 核心区别(用表格对比,一目了然,结合大厂实践补充):

对比维度 传统组件库(Antd/Element UI) shadcn/ui(源码级复用)
使用方式 npm install 下载包,引用 node_modules 里的产物 npx 复制源码到项目,引用项目内的源码文件
源码可控性 源码不在项目里,改不了,只能等组件库更新,定制化困难 源码在项目里,可直接修改,完全可控,适配定制化需求
版本问题 可能出现多项目版本不一致,导致冲突,维护成本高 无版本概念,直接用源码,无冲突,适配Monorepo复用场景
大厂适配度 适合快速开发,定制化场景需二次封装,维护成本高 适合中大型团队,可统一定制,适配多业务线差异化需求

实操例子(shadcn/ui 引入按钮组件,贴合实际用法):

# 执行命令,复制button源码到项目(常用:按需引入,避免冗余)
npx shadcn-ui add button

执行后,你的项目里会多出这些文件(源码直接在你项目里,可直接定制):

your-project/
└── components/
    └── ui/
        ├── button.js  # button组件源码(可直接改,适配业务定制需求)
        └── button.css  # 样式文件(可同步修改,统一设计规范)

引用时直接 import 项目内的源码:import { Button } from '@/components/ui/button' —— 这种模式的核心是“可控”,不用依赖npm包,改源码直接生效,避免版本污染,完美适配多业务线、高定制化的需求。

二、新手最常踩的3个坑(结合实践,帮你避坑)

结合开源文档和实际项目实践,整理了3个新手最容易困惑的点,每个点都配JS实操解答,看完少走弯路,贴合大厂实际开发场景!

坑1:node_modules 里的包也能改,为什么还要用 shadcn 这种方式?

很多新手会有这样的疑问:“node_modules 下载的不也是源码吗?找到文件改了不就行了?”

❌ 大错特错!node_modules 里的包,90%都是“编译后的产物”(压缩、混淆后的js),不是可直接改的源码;就算是源码包,改了也没用——别人拉代码、重新 install、部署环境,都会覆盖你的修改,相当于白改,完全不可控,这也是大厂绝对禁止的操作。

✅ 正确做法:像 shadcn 那样,把源码复制到项目里(或Monorepo的packages里),改了提交到Git,所有人拉代码都能看到,部署也会生效,这才是真的“可控”;同时配合代码审核,确保修改符合团队规范。

坑2:两个项目引用同一个公共组件,代码层面怎么实现?

这是实际开发中最常遇到的实操问题:packages/ui-components 里的Button,怎么让 admin-web 和 shopping-web 都能正常引用,且实时同步更新?

步骤1:完善配置文件

① 根目录 pnpm-workspace.yaml(告诉pnpm哪些是子项目):

packages:
  - "apps/**"    # 匹配所有业务项目
  - "packages/**" # 匹配所有公共包
  - "!**/node_modules" # 排除node_modules
  - "!**/dist" # 排除构建产物(避免依赖污染)

② packages/ui-components/package.json(公共组件包配置,关键是包名,常用@团队名/包名规范):

{
  "name": "@your-team/ui-components", // 包名,引用时要用,规范命名
  "version": "1.0.0",
  "main": "src/index.js", // 入口文件,改为JS
  "type": "module",
  "scripts": {
    "lint": "eslint src/**/*.js" // 新增lint脚本,代码规范必备
  }
}

③ packages/ui-components/src/index.js(组件导出,改为JS,规范导出):

// 导出Button,供业务项目引用,规范:统一导出,便于维护
export { Button } from './button/button';

④ packages/ui-components/src/button/button.js(Button组件源码,改为JS,删除TS类型,可直接运行):

import './button.css';

// 去掉TS接口,直接定义组件,贴合JS项目实操
export const Button = ({ children, type = 'default' }) => {
  return (
    <button className={{children}
  );
};

⑤ apps/admin-web/package.json(声明内部包依赖,核心!):

{
  "name": "admin-web",
  "version": "1.0.0",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "lint": "eslint src/**/*.js"
  },
  "dependencies": {
    "@your-team/ui-components": "workspace:*", // 关键语法!引用内部包
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },
  "devDependencies": {
    "vite": "^5.0.0",
    "@vitejs/plugin-react": "^4.2.1",
    "eslint": "^8.57.0" // 新增ESLint,代码规范必备
  }
}

✨ 重点:workspace:* 是pnpm的特殊语法,表示“引用仓库内这个包的最新版本”,不用写具体版本号,自动同步最新源码,这是Monorepo内部包引用的标准写法。

步骤2:业务项目引用组件

apps/admin-web/src/App.js(修复语法错误,可直接运行):

import { Button } from '@your-team/ui-components'; // 直接引用内部包

function App() {
  return (
    后台管理系统
      <Button type="primary">提交</Button>
    
  );
}

export default App;

apps/shopping-web/src/App.js 引用方式完全一样,改 packages 里的Button源码,两个项目会实时同步更新,不用重启、不用发包,这正是多项目复用的核心效率优势!

坑3:内部包版本只在仓库内生效,怎么就不会冲突了?

很多开发者会疑惑:“项目是独立的,引入的包版本不都一样吗?怎么会冲突?”

✅ 用“可乐类比”秒懂(:

  • 传统npm方式:给admin-web买一瓶可乐(1.0.0版本),给shopping-web买另一瓶可乐(2.0.0版本),两瓶独立,口味可能不一样,维护起来麻烦,多项目场景下会出现版本混乱;
  • Monorepo方式:只有一瓶可乐(packages里的组件源码),两个项目都直接喝这一瓶,口味完全一样,改可乐配方(改源码),两边喝到的都是新口味,从根源避免版本冲突,这也是选择Monorepo的核心原因之一。

核心逻辑:内部包不用发布到npm,版本只在仓库内生效,所有业务项目引用的是“同一个源码文件”,不存在“多版本”的可能,从根源避免版本污染;同时配合PR审核,确保源码修改可追溯、可控制。

三、团队协作必看:如何避免模块污染+同步更新通知?

实际团队开发中,难免会遇到“其他业务组改了公共组件,未及时通知,导致项目异常”的问题,以下是常用的解决方案。

1. 避免模块污染:规范+工具双保险

  • 规范层面:公共组件(packages/)的代码必须走 PR 审核,任何人改组件,都要提合并请求,由组件维护者(或架构组)审核,禁止乱改;同时制定公共组件开发规范(如组件命名、参数设计),均有明确的规范文档。
  • 工具层面:用 changesets 工具(pnpm生态标配),改组件时执行 pnpm changeset,选择修改的包、版本类型(major/minor/patch),自动生成变更日志;合并代码时,changesets 会自动更新包版本,所有人都能看到“谁改了什么、改了哪个版本”。
  • 禁止业务项目直接改 packages 里的代码:如果业务有特殊需求,先提需求文档,由组件维护者统一评估、修改公共组件,避免各改各的导致污染,这是Monorepo维护的核心规范。

2. 有效通知:让所有人知道组件更新(大厂实操方案)

  1. Git机器人通知:在Gitlab/Github配置机器人(如飞书机器人、企业微信机器人),packages目录代码合并后,自动推送到团队群,通知“xx组件已更新,变更内容:xxx,影响项目:xxx”,大厂均采用这种自动化通知方式。
  2. 组件文档站:用Storybook部署公共组件文档,更新组件时同步更新文档,添加“更新公告”,业务开发时能直接看到组件的最新用法、变更记录。
  3. 变更日志强制写:所有改组件的PR,必须写清晰的变更日志(比如“Button新增disabled属性,修复圆角样式问题”),不写不让合并,确保变更可追溯。

四、总结:实践总结+快速上手指南

pnpm workspace + Monorepo + shadcn 这套方案,核心是“高效复用、源码可控、协作便捷”,也是中大型前端团队的主流选择,核心就3句话,记下来就能快速上手,落地到实际项目:

  1. Monorepo:一个Git仓库装所有项目和公共代码,解决多项目复用、跨团队协作难题,核心诉求是“统一规范、提升效率”;
  2. pnpm workspace:帮你管理这个仓库,用 --filter 精准操作子项目,用 workspace:* 关联内部包,轻量配置、高效运行,首选Monorepo工具;
  3. shadcn模式:公共代码走“源码级复用”,不用发包,完全可控,适配大厂多业务线定制化需求,解决传统组件库定制困难的痛点。

这些看似抽象的概念,只要结合实际项目结构和JS实操例子,其实很简单——而且这确实是现在前端团队的主流架构,掌握后能有效提升项目维护效率,减少依赖冲突,适配中大型团队协作需求,也是前端工程师必备的技能之一。

补充:开源参考项目(可直接参考学习):字节 monorepo-template、阿里 umi-monorepo、腾讯 tencent/monorepo,可直接克隆源码,学习大厂的配置规范和实践细节。

深度拆解:基于面向对象思维的“就地编辑”组件全模块解析

深度拆解:基于面向对象思维的“就地编辑”组件全模块解析

在现代Web前端开发中,代码的可维护性与用户体验同样重要。本项目通过 EditInPlace 类,展示了一个完整的、基于原生JavaScript的“就地编辑”(Edit-in-Place)组件实现。该组件允许用户直接点击页面上的文本进行修改,而无需跳转页面或弹出繁琐的对话框。

为了全面理解这一精妙的设计,我们将摒弃泛泛而谈,深入代码肌理,按照功能模块edit_in_place.js 中的逻辑拆解为六大核心部分,结合 index.html 的挂载方式与 readme.md 的设计理念,进行全景式的技术剖析。


模块一:实例初始化与状态定义 (Constructor & State)

一切始于构造函数。这是组件的生命起点,负责接收外部配置并初始化内部状态。

1.1 核心代码逻辑

function EditInPlace(id, value, parentElement) {
  this.id = id;
  // 防御性编程:若未传入value,则赋予默认提示语
  this.value = value || '这个家伙很懒,什么都没有留下';
  this.parentElement = parentElement;
  
  // 预定义DOM元素引用,初始化为null
  this.containerElement = null;
  this.saveButton = null;
  this.cancelButton = null;
  this.fieldElement = null;
  this.staticElement = null;

  // 启动构建流程
  this.createElement(); 
  this.attachEvent();   
}

1.2 设计深度解析

  • 参数契约:构造函数严格依赖三个参数:id(唯一标识,用于后端更新)、value(当前显示内容)、parentElement(父容器,决定组件在DOM树中的位置)。这种设计使得组件完全独立于全局作用域。
  • 默认值处理this.value = value || '...' 体现了健壮的容错机制。即使调用者忘记传值,界面也不会崩坏,而是显示友好的占位符。
  • 状态预占位:提前声明所有可能用到的DOM节点变量(saveButton, fieldElement等)并置为 null。这不仅明确了组件所需的资源清单,也避免了后续操作中因变量未定义而导致的运行时错误。
  • 自动化引导:构造函数的最后两行自动触发 createElementattachEvent,意味着一旦实例化(new EditInPlace(...)),组件即刻完成渲染并具备交互能力,实现了“开箱即用”。

模块二:动态DOM架构构建 (DOM Construction)

本模块负责“无中生有”,通过原生JS API动态创建组件所需的所有HTML结构,而非硬编码在HTML文件中。

2.1 核心代码逻辑

createElement: function() {
  // 1. 创建外层容器
  this.containerElement = document.createElement('div');
  
  // 2. 创建静态文本展示区 (span)
  this.staticElement = document.createElement('span');
  this.staticElement.textContent = this.value;
  this.staticElement.style.cursor = 'pointer'; // 暗示可点击
  this.staticElement.title = '点击进行编辑';   // 提供Tooltip提示
  
  // 3. 创建编辑输入框 (input)
  this.fieldElement = document.createElement('input');
  this.fieldElement.type = 'text';
  
  // 4. 创建操作按钮组
  this.saveButton = document.createElement('button');
  this.saveButton.textContent = '保存';
  
  this.cancelButton = document.createElement('button');
  this.cancelButton.textContent = '取消';
  
  // 5. 组装DOM树
  this.containerElement.appendChild(this.staticElement);
  this.containerElement.appendChild(this.fieldElement);
  this.containerElement.appendChild(this.saveButton);
  this.containerElement.appendChild(this.cancelButton);
  
  // 6. 挂载到父容器
  this.parentElement.appendChild(this.containerElement);
  
  // 7. 初始化视图状态:默认为文本模式
  this.convertToText();
}

2.2 设计深度解析

  • 结构解耦:HTML文件 (index.html) 中只需要一个空的容器(如 <div id="app"></div>),具体的编辑结构完全由JS生成。这使得组件可以灵活地插入到页面的任何位置。
  • 语义化与辅助功能
    • 使用 span 包裹静态文本,符合行内元素的语义。
    • 设置 cursor: pointertitle 属性,从视觉和提示两个维度告知用户“此处可交互”,极大提升了可用性(UX)。
  • 组装顺序:先创建所有子元素,再统一 appendChild 到容器,最后一次性挂载到父节点。这种“文档片段”式的构建思路(虽然未显式使用DocumentFragment,但逻辑一致)减少了浏览器的重绘(Reflow)次数,优化了性能。
  • 初始状态锁定:构建完成后立即调用 convertToText(),确保组件加载时处于“只读”状态,隐藏输入框和按钮,符合用户预期。

模块三:视图状态切换引擎 (View State Switching)

这是组件交互的核心引擎,负责在“查看模式”和“编辑模式”之间无缝切换。

3.1 核心代码逻辑

// 切换到文本显示模式
convertToText: function() {
  this.fieldElement.style.display = 'none';
  this.saveButton.style.display = 'none';
  this.cancelButton.style.display = 'none';
  
  this.staticElement.style.display = 'inline';
  this.staticElement.textContent = this.value; // 同步最新数据
},

// 切换到编辑输入模式
convertToField: function() {
  this.staticElement.style.display = 'none';
  
  this.fieldElement.style.display = 'inline';
  this.fieldElement.value = this.value; // 将当前值回填到输入框
  
  this.saveButton.style.display = 'inline';
  this.cancelButton.style.display = 'inline';
  
  // 可选优化:自动聚焦输入框
  // this.fieldElement.focus(); 
}

3.2 设计深度解析

  • 互斥显示逻辑:通过控制 CSS display 属性(none vs inline),实现两组UI元素(文本组 vs 输入+按钮组)的互斥显示。这种方式比销毁重建DOM更高效。
  • 数据单向同步
    • convertToText 中:this.staticElement.textContent = this.value。确保界面上显示的文本永远是内存中 this.value 的最新状态(无论是初始值还是刚保存的值)。
    • convertToField 中:this.fieldElement.value = this.value。确保用户进入编辑模式时,输入框内预填充的是当前最新数据,而不是空白。
  • 状态原子性:这两个方法构成了状态机的两个原子操作,保证了视图状态的一致性,不会出现“既显示输入框又显示文本”的中间态。

模块四:事件监听与交互绑定 (Event Binding)

本模块将用户的鼠标/键盘行为转化为组件的内部逻辑调用,是连接用户与代码的桥梁。

4.1 核心代码逻辑

attachEvent: function() {
  const self = this; // 闭包保存this引用(或使用箭头函数)

  // 1. 点击文本 -> 进入编辑模式
  this.staticElement.addEventListener('click', function() {
    self.convertToField();
  });

  // 2. 点击保存 -> 执行保存逻辑
  this.saveButton.addEventListener('click', function() {
    self.save();
  });

  // 3. 点击取消 -> 执行取消逻辑
  this.cancelButton.addEventListener('click', function() {
    self.cancel();
  });
  
  // 4. (可选) 监听回车键 -> 快捷保存
  this.fieldElement.addEventListener('keydown', function(e) {
    if (e.key === 'Enter') {
      self.save();
    }
  });
}

4.2 设计深度解析

  • 上下文保持 (self = this):在旧式函数写法中,事件回调函数内的 this 指向会发生改变(指向触发事件的DOM元素)。通过 const self = this 闭包技巧,确保回调内部能正确访问组件实例的方法(如 self.save())。注:现代JS可使用箭头函数自动解决此问题。
  • 职责分离:事件监听器只做一件事——调用对应的业务逻辑方法(save, cancel, convertToField)。监听层不包含具体业务代码,保持了代码的清晰度。
  • 交互增强:除了点击事件,代码还预留了 keydown 监听(通常用于监听Enter键),允许用户通过键盘快捷操作,进一步提升专业度。

模块五:业务逻辑与数据持久化 (Business Logic & Persistence)

这是组件的“大脑”,处理数据的更新、验证以及与后端的通信。

5.1 核心代码逻辑

save: function() {
  // 1. 获取输入框的新值
  const newValue = this.fieldElement.value.trim();
  
  // 2. 简单校验:不允许为空
  if (!newValue) {
    alert('内容不能为空!');
    this.fieldElement.focus();
    return;
  }

  // 3. 更新本地状态
  this.value = newValue;
  
  // 4. 切换回文本视图
  this.convertToText();
  
  // 5. 异步持久化 (模拟API调用)
  // 在实际项目中,这里会使用 fetch 或 axios 发送请求
  /*
  fetch('/api/update', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({ id: this.id, value: this.value })
  }).then(response => {
    if(!response.ok) throw new Error('保存失败');
    console.log('保存成功');
  }).catch(err => {
    console.error(err);
    alert('网络错误,保存失败');
    // 失败回滚逻辑...
  });
  */
  console.log(`ID: ${this.id}, New Value: ${this.value} (已模拟保存)`);
},

cancel: function() {
  // 直接丢弃修改,恢复视图,不更新 this.value
  this.convertToText();
}

5.2 设计深度解析

  • 数据校验save 方法首先进行 trim() 和非空检查。这是防止脏数据入库的第一道防线,体现了严谨的数据治理思维。
  • 乐观更新 vs 悲观更新
    • 当前代码采用了乐观更新策略:先更新本地 this.value 并切换视图,给用户“瞬间完成”的快感,然后在后台异步发送请求。
    • 注释中的 fetch 代码展示了如何处理悲观情况:如果网络请求失败,应有相应的错误提示甚至回滚机制(虽然示例中未完全展开回滚逻辑,但架构上预留了位置)。
  • 取消操作的纯粹性cancel 方法非常简单,它不修改 this.value,直接调用 convertToText。由于 convertToText 会将 this.value 重新渲染到界面上,因此未保存的修改自然消失,完美实现了“撤销”功能。

模块六:底层原理深潜与调试 (Deep Dive & Debugging)

在代码注释中,有一行关于类型检测的代码值得单独拿出来讲解,它揭示了JS底层对象模型的一个关键特性。

6.1 核心代码逻辑

// Object.prototype.toString.apply(this.containerElement)

6.2 技术原理解析

  • 问题背景:在JavaScript中,typeof 操作符对于对象类型的判断非常粗糙。typeof nulltypeof []typeof DOM元素 统统返回 "object"。这在需要精确区分数据类型(特别是区分不同宿主对象,如 HTMLDivElement)时显得无能为力。
  • 解决方案Object.prototype.toString 是JS中判断类型的“终极武器”。每个对象内部都有一个 [[Class]] 属性(ES6后映射为 Symbol.toStringTag)。
  • Apply 的作用
    • 直接调用 this.containerElement.toString() 可能会因为对象重写了 toString 方法而得到非标准结果。
    • 使用 Object.prototype.toString.apply(context) 强制借用原生对象的 toString 方法,并将 this 上下文绑定到目标对象(这里是 containerElement)。
  • 输出结果
    • div 元素执行此代码,返回 "[object HTMLDivElement]"
    • 对数组执行,返回 "[object Array]"
    • 对普通对象执行,返回 "[object Object]"
  • 应用场景:虽然在本项目的运行逻辑中这行代码被注释掉了,但它通常用于:
    1. 调试:确认创建的DOM元素类型是否符合预期。
    2. 库开发:在编写通用工具库时,用于编写健壮的类型判断函数(如 isArray, isElement)。
    3. 防御性编程:在执行特定DOM操作前,严格校验对象类型,防止报错。

总结:从代码到工程艺术

通过对 EditInPlace 组件的六大模块拆解,我们看到的不仅仅是一个简单的编辑功能,而是一套完整的前端工程化实践:

  1. 封装性:所有逻辑被包裹在类中,外部只需关心 new 和参数,内部实现细节(DOM创建、事件绑定)对外透明。
  2. 复用性:基于类的设计,使得该组件可以在页面的任何地方被无限次实例化,且实例间互不干扰。
  3. 用户体验优先:从默认的占位符提示、鼠标悬停样式、到无刷新保存,每一个细节都旨在减少用户的认知负荷。
  4. 扩展性:代码结构清晰,预留了API接口位置和键盘事件钩子,便于未来功能的迭代(如富文本支持、防抖优化等)。

这个项目完美诠释了 “一个文件一个类” 的理念:。它将复杂的交互逻辑收敛为一个独立的单元,是现代前端组件化开发的经典缩影。

后端字段又改了?我撸了一个 BFF 数据适配器,从此再也不怕接口“屎山”!

副标题:适配 Bun 运行时,万级数据映射性能提升 200%,前后端解耦的终极方案。

01. 痛点共鸣:你是不是也在写这种代码?

很多前端同学在处理接口时,业务代码里塞满了这种逻辑:

const name = res.data.u_info_v2_name || "未知";
const status = res.data.state === 1;
const tags = res.data.raw_str ? res.data.raw_str.split(",") : [];

这种硬编码的后果:

  1. 脆弱:后端改一个字段名,前端全屏报错。
  2. 难看:业务逻辑被数据清洗逻辑淹没。
  3. 性能:大规模循环转换时,Node.js 的 GC 压力巨大。

02. 核心方案:BFFDataAdapter 架构逻辑

这不是简单“封装几个工具函数”,而是一层可维护、可演进的数据契约层:

  • Schema 驱动:字段映射配置化,后端字段变化时优先改配置。
  • 双向转换:toClient 负责展示态,toAPI 负责提交态。
  • 内置校验:字段缺失、格式错误直接在转换层拦截。
flowchart LR
  A[后端原始 JSON] --> B[BFFDataAdapter]
  B --> C[Schema 映射]
  C --> D[Transformer 转换]
  D --> E[Validator 校验]
  E --> F[前端干净对象]
  F -->|提交| B
  B --> G[API 请求体]

03. 场景化 Demo:电商订单数据处理

我们把后端杂乱字段映射为前端可读结构,同时做金额格式化和手机号校验。

核心代码

1-bff/BFFDataAdapter.js 提供适配器能力:

  • registerSchema(name, schema):注册数据契约。
  • registerTransformer(name, fn):注册转换器。
  • registerValidator(name, fn):注册校验器。
  • toClient(schema, payload):后端 -> 前端。
  • toAPI(schema, payload):前端 -> 后端。

同时提供 TypeScript 类型定义文件:1-bff/BFFDataAdapter.d.ts

1-bff/example-order.js 是完整示例,直接运行可看到双向转换效果。

JSON 自动生成 Schema

为了减少手写映射成本,提供了 CLI:1-bff/generate-schema.js

# 方式 1:直接传 JSON 字符串
bun 1-bff/generate-schema.js '{"order_id_long":"123","amount_fen":19900,"user":{"profile":{"nickname":"iDao"}}}' --name orderDetail

# 方式 2:从文件读取 JSON
bun 1-bff/generate-schema.js --file ./payload.json --name orderDetail

输出是可直接复制的 schema JSON(包含 schemaNameschema.fields)。

运行方式

# 运行业务示例
bun 1-bff/example-order.js

# JSON 自动生成 Schema
bun 1-bff/generate-schema.js '{"foo_bar":1,"user":{"name":"A"}}' --name demoSchema

# 跑万级数据压测(默认 10000 条)
bun 1-bff/benchmark.js

# 自定义条数
bun 1-bff/benchmark.js 50000

如果你想和 Node.js 对比:

node 1-bff/benchmark.js 10000
bun 1-bff/benchmark.js 10000

04. 性能进阶:为什么 Bun 环境更猛?

在 BFF 层做大批量数据映射时,性能瓶颈通常来自两块:

  • JSON 解析与对象分配
  • 字段级转换与校验循环

在这类场景里,Bun 在 JSON 处理和整体运行时开销上通常更有优势。你可以直接用 1-bff/benchmark.js 在本机得到真实数字,避免“玄学优化”。

提示:真实性能和机器配置、数据形态、转换逻辑复杂度相关。建议在你的真实数据样本上测。

05. 代码示例(节选)

import { createBFFAdapter } from "./BFFDataAdapter.js";

const adapter = createBFFAdapter();
adapter.registerTransformer("money", (val) => `¥${(val / 100).toFixed(2)}`);

adapter.registerSchema("orderDetail", {
  fields: {
    id: { source: "order_id_long", required: true },
    price: { source: "amount_fen", transform: "money" },
    customerName: { source: "user.profile.nickname", default: "匿名用户" },
    contact: { source: "service_phone", validate: "phone" },
    statusText: {
      source: "state_code",
      transform: (val) => (val === 1 ? "待发货" : "已完成"),
    },
  },
});

获取完整“全栈提效包”

我已经把这套适配器整理成支持 TypeScript 自动推导的进阶版(告别 any)。

资料包内含:

  1. BFFDataAdapter 完整源码及 TS 类型定义。
  2. 自动生成工具:输入一段 JSON,自动生成 Schema 配置。
  3. 性能压测脚本:亲自对比 Node vs Bun 的极限。

关注我的掘金/公众号 [iDao技术魔方],后台私信回复关键字 "BFF",立刻获取隐藏仓库地址。

如何优雅地处理 iframe 跨域通信?这是我的开源方案

一、开篇破局:被误解的iframe,从未真正退场

在微前端大行其道的今天,很多人觉得 iframe 已经过时了。但每当业务遇到绝对的安全沙箱隔离、第三方老旧系统接入、跨域广告/挂件嵌入时,大家转了一圈还是会乖乖回到 iframe 的怀抱——毕竟它是浏览器原生的、最彻底的隔离方案。 究其原因,无外乎它是浏览器原生支持、隔离性最彻底的方案,没有之一。但凡事皆有两面性,iframe的隔离有多极致,跨域通信就有多棘手,这也是无数开发者对它又爱又恨的核心原因。

但是,iframe 的隔离有多完美,它的跨域通信就有多让人头疼! 但凡用原生window.postMessage开发过稍复杂的跨域业务,大概率都踩过这些让人崩溃的坑,堪称前端开发的“隐形绊脚石”:

  • 回调地狱:发出去了消息,不知道对方收没收到,只能满屏幕写 addEventListener 去匹配消息 ID。

  • 时序问题:父页面急着发数据,子页面还没 onload,消息直接石沉大海。

  • 恶心的双滚动条:子页面内容变多被撑开,父页面无法感知,高度死活对不上。

  • 状态同步灾难:父页面切了深色模式,子页面还是亮瞎眼的白色,状态完全割裂。

“原生长篇大论的事件监听代码” vs “iframe-js 一行 await 代码” 的对比截图对比:

// 原生 postMessage 跨域获取数据
function fetchRemoteData(userId) {
    return new Promise((resolve, reject) => {
        const messageId = 'req_' + Date.now();

        // 1. 必须注册全局监听器
        const handler = (event) => {
            // 安全第一:手动死磕 origin 校验
            if (event.origin !== 'https://target-domain.com') return;

            // 必须通过唯一 ID 匹配,不然会串线
            if (event.data?.id === messageId && event.data?.action === 'USER_INFO_RES') {
                clearTimeout(timer);
                window.removeEventListener('message', handler); // 极易忘写导致内存泄漏
                resolve(event.data.result);
            }
        };
        window.addEventListener('message', handler);

        // 2. 发送请求
        const targetIframe = document.getElementById('my-iframe').contentWindow;
        targetIframe.postMessage({
            action: 'USER_INFO_REQ',
            id: messageId,
            payload: { userId }
        }, 'https://target-domain.com');

        // 3. 手动处理超时逻辑
        const timer = setTimeout(() => {
            window.removeEventListener('message', handler);
            reject(new Error('跨域请求超时'));
        }, 5000);
    });
}
// 使用 iframe-js 的 RPC 远程调用
async function fetchRemoteData(userId) {
    try {
        // 就像调用本地异步函数一样丝滑!
        const userInfo = await iframeApp.callRemote('getUserInfo', { userId }, 5000);
        return userInfo;
    } catch (error) {
        // 完美捕获超时或对方抛出的异常
        console.error('调用失败:', error.message);
    }
}

二、破局方案:iframe-js 2.2.1开源,降维打击通信痛点

为了彻底消灭这些恶心人的痛点,我重构并开源了 iframe-js(目前最新版本 2.2.1)。它不是对 postMessage 的简单封装,而是将 iframe 通信直接拉升到了现代前端工程化的标准。iframe-js 的四大杀手锏功能

他的核心思路就是抛弃传统的发布订阅,直接用现代前端的思维(RPC、状态机、Promise 回执)去降维打击这些痛点。今天开源出来,给大家分享一下。

三、四大核心功能:彻底解决iframe通信难题

1. 像调用本地函数一样跨域:RPC 远程调用

这是我个人最喜欢的功能。以前你想让子页面去查个数据,得先 postMessage 过去,子页面查完再 postMessage 回来,逻辑被严重撕裂。 现在,你可以用 RPC (Remote Procedure Call) 模式,直接用 async/await 拿到跨域函数的返回值!

提供方(如父页面):

// 暴露一个名为 'getUserInfo' 的异步服务
iframeApp.expose('getUserInfo', async (params) => {
  const res = await fetch(`/api/user/${params.id}`);
  return await res.json(); // 直接 return 即可!
});

调用方(如子页面):

// 像调用本地函数一样丝滑,天然支持超时控制和 try/catch 错误穿透!
try {
  const userInfo = await childApp.callRemote('getUserInfo', { id: 1001 }, 5000);
  console.log('跨域拿到数据啦:', userInfo);
} catch(err) {
  console.error('调用超时或报错:', err);
}

2. 彻底告别双滚动条:自动高度适应 (Auto Resize)

同域下我们可以直接读 DOM 高度,跨域下怎么办?iframe-js 内置了基于现代浏览器 ResizeObserver 的高度同步机制。性能极致,零 CPU 轮询消耗,甚至连 display: none 导致的 0px 高度塌陷陷阱都在底层帮你规避了。

父页面一行代码授权:

iframeApp.enableAutoResize();

子页面一行代码开启探测:

// 当内部存在图片懒加载、列表下拉导致 DOM 撑开时,父页面的 iframe 标签会自动随之伸缩!
childApp.startAutoResizer({ offset: 20 }); // 还能额外补偿 20px 底部间距

3. 跨越 Iframe 的状态机:全局状态共享 (State Sync)

业务里经常遇到父子页面需要共享上下文的情况(主题色、语言包、当前登录用户信息)。与其用事件发来发去,不如直接用微缩版“Pinia/Vuex”。 不管子页面加载有多慢,只要它一 onload,父页面的最新状态就会自动全量同步过去。

// 父页面随时更新状态
iframeApp.setState({ theme: 'dark', lang: 'zh-CN' });

// 子页面响应式监听
childApp.onStateChange((newState) => {
  if (newState.theme === 'dark') {
    document.body.classList.add('dark-mode');
  }
});

4. 绝对可靠的送达:Promise ACK 与内置队列

原生的 postMessage 是典型的“Fire-and-Forget(发后不理)”。 而在 iframe-js 中,你可以使用 emitWithAck。底层会自动为你分配唯一 ID 并追踪回执。

// 如果返回 true,说明不仅发过去了,而且对方的代码已经成功执行了业务逻辑!
const isSuccess = await parentApp.emitToChildWithAck('updateData', { a: 1 });

更绝的是内置队列机制:如果父页面初始化后立刻发消息,而子页面还没准备好,消息绝不会丢!

iframe-js 会自动将消息存入内存队列,等子页面打通连接的瞬间,依次重发。 怎么用?

四、极简上手:开箱即用,全链路TS支持

iframe-js无需复杂配置,开箱即用,全面支持TypeScript类型推导,兼顾开发效率与类型安全,一行命令即可安装:

npm install iframe-js

五、Live Demo实测:眼见为实,上手即体验

文字描述再详尽,不如直接上手实操。我针对核心功能打造了3大极限测试场景Demo,打开F12控制台查看底层日志,更能直观感受通信流程的丝滑:

六、写在最后

开发iframe-js的初衷,就是想让开发者在处理微前端嵌套、低代码平台渲染区、第三方系统接入等场景时,摆脱iframe跨域通信的繁琐痛点,少踩坑、少加班,专注核心业务开发。

跨域场景复杂多变,如果你在使用过程中遇到奇葩报错,或是有点击穿透拦截、快捷键透传等个性化需求,欢迎前往GitHub仓库提Issue交流,一起完善工具生态。

开源地址: github.com/1503963513/…,如果这款工具帮你解决了实际问题,欢迎点亮Star支持!

前端架构演进与模块化设计实践

 引言:从"能运行"到"好维护"的转变

在快速迭代的业务需求面前,我们是否经常遇到这样的场景:新功能不敢轻易开发,因为担心影响现有业务;代码修改牵一发而动全身;不同业务模块间耦合严重,难以独立部署和测试。这些问题背后,反映的是前端架构设计的重要性。

1. 架构设计的核心目标

1.1 可持续性

  • 代码应易于理解和扩展
  • 新成员能够快速融入开发
  • 技术债务可控

1.2 可维护性

  • 模块职责清晰明确
  • 变更影响范围可控
  • 调试和定位问题高效

1.3 可测试性

  • 组件能够独立测试
  • 模拟各种业务场景
  • 自动化测试覆盖核心流程

2. 现代前端架构模式实践

2.1 分层架构设计

whiteboard_exported_image.png

实践案例:用户管理模块

// 表现层 - UserList.tsx
const UserList: React.FC = () => {
  const { users, loading, error } = useUserManagement();
  
  if (loading) return <Loading />;
  if (error) return <Error message={error} />;
  
  return (
    <div>
      {users.map(user => (
        <UserCard key={user.id} user={user} />
      ))}
    </div>
  );
};

// 业务层 - useUserManagement.ts
export const useUserManagement = () => {
  const [state, setState] = useState<UserState>(initialState);
  
  const fetchUsers = async () => {
    try {
      setState(prev => ({ ...prev, loading: true }));
      const users = await userAPI.fetchAll();
      setState(prev => ({ ...prev, users, loading: false }));
    } catch (error) {
      setState(prev => ({ ...prev, error: error.message, loading: false }));
    }
  };
  
  return { ...state, fetchUsers };
};

// 数据层 - userAPI.ts
export const userAPI = {
  fetchAll: async (): Promise<User[]> => {
    const response = await fetch('/api/users');
    return response.json();
  }
};

2.2 微前端架构实践

场景:大型管理平台,多个团队协作开发

解决方案:

// 主应用 - 路由配置
const routes = [
  {
    path: '/order/*',
    component: () => import('order-app/OrderModule'),
  },
  {
    path: '/user/*', 
    component: () => import('user-app/UserModule'),
  },
  {
    path: '/product/*',
    component: () => import('product-app/ProductModule'),
  }
];

// 模块联邦配置 - webpack.config.js
module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'host',
      remotes: {
        orderApp: 'order@http://localhost:3001/remoteEntry.js',
        userApp: 'user@http://localhost:3002/remoteEntry.js',
      },
      shared: {
        react: { singleton: true },
        'react-dom': { singleton: true }
      }
    })
  ]
};

3. 模块化设计原则

3.1 单一职责原则

// ❌ 违反单一职责
class UserService {
  async getUser(id: string) { /* ... */ }
  validateEmail(email: string) { /* ... */ }
  sendEmail(content: string) { /* ... */ }
  formatUserData(user: User) { /* ... */ }
}

// ✅ 符合单一职责
class UserRepository {
  async getUser(id: string) { /* ... */ }
}

class ValidationService {
  validateEmail(email: string) { /* ... */ }
}

class EmailService {
  sendEmail(content: string) { /* ... */ }
}

class UserFormatter {
  formatUserData(user: User) { /* ... */ }
}

3.2 依赖倒置原则

// 定义抽象接口
interface UserStorage {
  save(user: User): Promise<void>;
  findById(id: string): Promise<User | null>;
}

// 具体实现
class LocalStorageUser implements UserStorage {
  async save(user: User) {
    localStorage.setItem(`user_${user.id}`, JSON.stringify(user));
  }
  
  async findById(id: string) {
    const data = localStorage.getItem(`user_${id}`);
    return data ? JSON.parse(data) : null;
  }
}

class APITUserStorage implements UserStorage {
  async save(user: User) {
    await fetch('/api/users', {
      method: 'POST',
      body: JSON.stringify(user)
    });
  }
  
  async findById(id: string) {
    const response = await fetch(`/api/users/${id}`);
    return response.json();
  }
}

// 业务逻辑依赖于抽象,而非具体实现
class UserService {
  constructor(private storage: UserStorage) {}
  
  async updateUser(user: User) {
    // 业务逻辑
    await this.storage.save(user);
  }
}

4. 状态管理架构演进

4.1 状态分类与管理策略

// 1. 本地状态 - 使用 useState/useReducer
const [formData, setFormData] = useState(initialFormData);

// 2. 全局状态 - 使用 Zustand(推荐轻量级方案)
const useUserStore = create((set, get) => ({
  users: [],
  loading: false,
  fetchUsers: async () => {
    set({ loading: true });
    const users = await userAPI.fetchAll();
    set({ users, loading: false });
  },
  addUser: (user: User) => {
    set(state => ({ 
      users: [...state.users, user] 
    }));
  }
}));

// 3. 服务端状态 - 使用 React Query/SWR
const { data: users, isLoading, error } = useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,
  staleTime: 5 * 60 * 1000, // 5分钟
});

4.2 状态规范化

//  嵌套深、难以更新的状态
const state = {
  posts: [
    {
      id: 1,
      title: 'Post 1',
      author: {
        id: 1,
        name: 'John',
        avatar: '...'
      },
      comments: [
        {
          id: 1,
          text: 'Great!',
          user: {
            id: 2,
            name: 'Alice'
          }
        }
      ]
    }
  ]
};

//  规范化状态
const normalizedState = {
  posts: {
    byId: {
      1: { id: 1, title: 'Post 1', author: 1, comments: [1] }
    },
    allIds: [1]
  },
  users: {
    byId: {
      1: { id: 1, name: 'John', avatar: '...' },
      2: { id: 2, name: 'Alice' }
    },
    allIds: [1, 2]
  },
  comments: {
    byId: {
      1: { id: 1, text: 'Great!', user: 2, post: 1 }
    },
    allIds: [1]
  }
};

5. 构建可测试的架构

5.1 依赖注入与测试

// 业务逻辑
class OrderService {
  constructor(
    private paymentGateway: PaymentGateway,
    private notificationService: NotificationService,
    private inventoryService: InventoryService
  ) {}
  
  async processOrder(order: Order) {
    // 1. 扣减库存
    await this.inventoryService.reserve(order.items);
    
    // 2. 处理支付
    const paymentResult = await this.paymentGateway.charge(order.total);
    
    // 3. 发送通知
    if (paymentResult.success) {
      await this.notificationService.sendOrderConfirmation(order);
    }
    
    return paymentResult;
  }
}

// 单元测试
describe('OrderService', () => {
  it('should process order successfully', async () => {
    // 准备测试替身
    const mockPaymentGateway = {
      charge: jest.fn().mockResolvedValue({ success: true })
    };
    const mockNotificationService = {
      sendOrderConfirmation: jest.fn().mockResolvedValue(undefined)
    };
    const mockInventoryService = {
      reserve: jest.fn().mockResolvedValue(undefined)
    };
    
    // 创建被测试实例
    const orderService = new OrderService(
      mockPaymentGateway,
      mockNotificationService,
      mockInventoryService
    );
    
    // 执行测试
    const result = await orderService.processOrder(testOrder);
    
    // 验证行为
    expect(result.success).toBe(true);
    expect(mockInventoryService.reserve).toHaveBeenCalledWith(testOrder.items);
    expect(mockPaymentGateway.charge).toHaveBeenCalledWith(testOrder.total);
    expect(mockNotificationService.sendOrderConfirmation).toHaveBeenCalledWith(testOrder);
  });
});

6. 架构质量度量与改进

6.1 代码质量指标

// 使用 ESLint 插件监控架构质量
module.exports = {
  rules: {
    'max-dependencies': ['error', 10], // 单个模块最大依赖数
    'cyclic-dependency': 'error',       // 禁止循环依赖
    'no-relative-import': 'error',      // 禁止相对导入
    'feature-envy': 'error'             // 禁止特性依恋
  }
};

// package.json 依赖治理
{
  "scripts": {
    "analyze:deps": "madge --image deps-graph.svg src/",
    "analyze:complexity": "complexity-report src/",
    "check:circular": "dpdm --circular src/**/*.ts"
  }
}

7. 结语

好的前端架构不是一蹴而就的,而是随着业务发展和团队成长不断演进的过程。它需要在过度设计与缺乏设计之间找到平衡,在满足当前需求的同时为未来变化留出空间。

架构的终极目标不是构建完美的系统,而是创建能够优雅演进的系统。希望本文的实践经验能够为团队在架构设计方面提供有价值的参考,欢迎大家共同探讨和改进我们的前端架构实践。

8. 团队介绍

智慧家技术平台-应用软件框架开发」主要负责设计工具的研发,包括营销设计工具、家电VR设计和展示、水电暖通前置设计能力,研发并沉淀素材库,构建家居家装素材库,集成户型库、全品类产品库、设计方案库、生产工艺模型,打造基于户型和风格的AI设计能力,快速生成算量和报价;同时研发了门店设计师中心和项目中心,包括设计师管理能力和项目经理管理能力。实现了场景全生命周期管理,同时为水,空气,厨房等产业提供商机管理工具,从而实现了以场景贯穿的B端C端全流程系统。

模块化与组件化:90%的前端开发者都没搞懂的本质区别

一位刚入职不久的网友留言问我:"我们一直在说模块化开发、组件化设计,这两个概念到底有什么区别?我感觉它们不就是把代码拆分开来吗?"

今天,我想从自己的角度,聊聊我对这两个概念的深度理解。

什么是模块化?

模块化是代码组织层面的哲学,关注的是"职责边界"。

简单来说,模块化就是把一个复杂的系统,按照功能职责拆分成独立的文件或代码单元。每个模块负责完成特定的功能,对外暴露必要的接口,隐藏内部实现细节。

看一个最朴素的例子:

// math.js - 一个纯粹的数学计算模块
export function add(a, b) {
  return a + b;
}

export function multiply(a, b) {
  return a * b;
}

// 内部实现细节,不对外暴露
function validateNumber(num) {
  if (typeof num !== 'number') {
    throw new Error('参数必须是数字');
  }
}
// app.js - 使用模块
import { add, multiply } from './math.js';

console.log(add(5, 3)); // 8

模块化的核心特征是:

  1. 高内聚:相关功能紧密放在一起
  2. 低耦合:模块之间通过明确定义的接口通信
  3. 封装性:隐藏内部实现细节
  4. 关注点分离:每个模块解决一个特定问题

在ES6之前,我们通过IIFE实现模块化,现在有了原生的ES Module,模块化已经成为JavaScript的基础设施。

什么是组件化?

组件化是UI构建层面的哲学,关注的是"呈现与交互"。

组件化将用户界面拆分成独立的、可复用的部件。每个组件封装了自己的结构(HTML)、样式(CSS)和行为(JavaScript),可以被组合成更复杂的界面。

看一个React组件的例子:

// Button.jsx - 一个UI组件
import React from 'react';
import './Button.css'; // 组件自己的样式

const Button = ({ variant = 'primary', size = 'medium', children, onClick }) => {
  // 内部状态管理
  const [isHovered, setIsHovered] = useState(false);
  
  // 内部逻辑处理
  const handleMouseEnter = () => setIsHovered(true);
  const handleMouseLeave = () => setIsHovered(false);
  
  return (
    <button
      className={`btn btn-${variant} btn-${size} ${isHovered ? 'hovered' : ''}`}
      onClick={onClick}
      onMouseEnter={handleMouseEnter}
      onMouseLeave={handleMouseLeave}
    >
      {children}
    </button>
  );
};

export default Button;
// App.jsx - 组合组件
import React from 'react';
import Button from './Button';

const App = () => {
  return (
    <div>
      <Button variant="primary" size="large" onClick={() => alert('点击')}>
        主要按钮
      </Button>
      <Button variant="secondary" size="small">
        次要按钮
      </Button>
    </div>
  );
};

组件化的核心特征是:

  1. 可组合性:组件可以嵌套组合成复杂界面
  2. 可复用性:同一组件可在不同地方重复使用
  3. 自包含:组件包含自身所需的资源
  4. 接口明确:通过props定义清晰的输入输出

本质区别:一个思想实验

假设我们要开发一个电商网站的用户中心页面。

模块化视角

  • 把用户相关的API请求封装成 userAPI.js 模块
  • 把价格格式化功能封装成 priceFormatter.js 模块
  • 把购物车计算逻辑封装成 cartCalculator.js 模块
  • 这些模块可以在任何地方使用,甚至不在浏览器环境

组件化视角

  • 把用户头像区域做成 UserAvatar 组件
  • 把订单列表做成 OrderList 组件
  • 把商品卡片做成 ProductCard 组件
  • 这些组件组合在一起形成完整的页面

现在,最关键的区别来了:

模块化解决的是"如何组织代码"的问题,组件化解决的是"如何构建界面"的问题。

更本质地说:

  • 模块化的最小单位是函数或文件,关注的是逻辑、数据、功能的封装
  • 组件化的最小单位是UI元素,关注的是视图、交互、样式的封装

但最深刻的认识是:模块化是组件化的基础,组件化是模块化在UI层的具体体现。

实战中的混淆与重构

让我用一个真实的重构案例来说明这两者的区别。

重构前(混淆概念)

// UserProfile.jsx - 一个"组件",但实际上什么都做
import React, { useState, useEffect } from 'react';

const UserProfile = ({ userId }) => {
  const [user, setUser] = useState(null);
  const [orders, setOrders] = useState([]);
  
  // 直接在这里写API调用
  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(setUser);
      
    fetch(`/api/users/${userId}/orders`)
      .then(res => res.json())
      .then(setOrders);
  }, [userId]);
  
  // 直接在这里写复杂的数据处理
  const totalSpent = orders.reduce((sum, order) => {
    // 各种复杂的价格计算逻辑
    return sum + order.amount;
  }, 0);
  
  // 格式化函数直接写在组件里
  const formatDate = (dateStr) => {
    const date = new Date(dateStr);
    return `${date.getFullYear()}-${date.getMonth()+1}-${date.getDate()}`;
  };
  
  return (
    <div>
      <h1>{user?.name}</h1>
      <p>总消费: ¥{totalSpent}</p>
      <div>
        {orders.map(order => (
          <div key={order.id}>
            <span>{formatDate(order.createdAt)}</span>
            <span>¥{order.amount}</span>
          </div>
        ))}
      </div>
    </div>
  );
};

这个"组件"的问题在于:它混淆了组件化和模块化的边界,导致:

  • 组件臃肿难以维护
  • 业务逻辑无法复用
  • 难以测试
  • 代码重复

重构后(明确职责)

// modules/userAPI.js - 纯模块,处理用户数据获取
export const fetchUser = async (userId) => {
  const response = await fetch(`/api/users/${userId}`);
  return response.json();
};

export const fetchUserOrders = async (userId) => {
  const response = await fetch(`/api/users/${userId}/orders`);
  return response.json();
};
// modules/orderCalculator.js - 纯模块,处理订单计算逻辑
export const calculateTotalSpent = (orders) => {
  return orders.reduce((sum, order) => sum + order.amount, 0);
};

export const formatCurrency = (amount) => {
  return new Intl.NumberFormat('zh-CN', { 
    style: 'currency', 
    currency: 'CNY' 
  }).format(amount);
};
// modules/dateFormatter.js - 纯模块,处理日期格式化
export const formatDate = (dateStr, format = 'simple') => {
  const date = new Date(dateStr);
  if (format === 'simple') {
    return `${date.getFullYear()}-${date.getMonth()+1}-${date.getDate()}`;
  }
  // 其他格式...
  return date.toLocaleDateString();
};
// components/OrderItem.jsx - 纯粹的展示组件
const OrderItem = ({ order }) => {
  return (
    <div className="order-item">
      <span>{formatDate(order.createdAt)}</span>
      <span>{formatCurrency(order.amount)}</span>
    </div>
  );
};
// components/UserProfile.jsx - 组合组件,只负责组合和状态管理
import React, { useState, useEffect } from 'react';
import { fetchUser, fetchUserOrders } from '../modules/userAPI';
import { calculateTotalSpent, formatCurrency } from '../modules/orderCalculator';
import OrderItem from './OrderItem';

const UserProfile = ({ userId }) => {
  const [user, setUser] = useState(null);
  const [orders, setOrders] = useState([]);
  
  useEffect(() => {
    fetchUser(userId).then(setUser);
    fetchUserOrders(userId).then(setOrders);
  }, [userId]);
  
  const totalSpent = calculateTotalSpent(orders);
  
  return (
    <div className="user-profile">
      <h1>{user?.name}</h1>
      <p className="total-spent">
        总消费: {formatCurrency(totalSpent)}
      </p>
      <div className="order-list">
        {orders.map(order => (
          <OrderItem key={order.id} order={order} />
        ))}
      </div>
    </div>
  );
};

重构后的代码清晰地体现了:

  • 模块负责数据获取、计算逻辑、格式化等非UI相关的功能
  • 组件负责UI渲染和交互逻辑
  • 模块可以在任何地方使用(甚至在Node.js环境)
  • 组件专注于界面呈现,通过props接收数据和回调

总结

回到最初的问题:模块化和组件化的本质区别是什么?

模块化是一种代码组织思想,它让我们能够将复杂的系统分解成独立的、可维护的代码单元。它关注的是功能的内聚和依赖的管理,解决的是"代码怎么写才不乱"的问题。

组件化是一种UI构建思想,它让我们能够将界面分解成独立的、可复用的部件。它关注的是视图的拆分和组合,解决的是"界面怎么搭才灵活"的问题。

当你能清晰区分这两个概念,你的代码会变得更清晰、更可维护、更容易测试。

互动

看完这篇文章,你对模块化和组件化有了新的认识吗?欢迎在评论区分享你的想法。

如果你觉得这篇文章对你有帮助,点赞、收藏、转发给更多需要的朋友。我们下期再见!

你的项目真的需要SSR吗?还是只是你的简历需要?

技术选型不是为了简历好看,是为了解决问题

上周和一个前同事约饭,他现在在一家创业公司带前端团队。

聊到一半他突然问我:“我们准备把项目重构成 Next.js,上服务端渲染,你觉得怎么样?”

我放下筷子:“你们现在遇到什么坑了要重构?”

他挠挠头:“坑倒是没有……就是现在出去面试,人家都问有没有 Next.js 经验,我们不用会不会显得技术栈太旧了。”

“那你们产品要 SEO 吗?用户网络环境咋样?”

“用户都在一二线城市,网速挺好的。SEO 的话……产品得登录才能用,搜索引擎也爬不到。”

我看着他,不知道该说什么。

又一个被技术流行绑架的老同事。


一、SSR不是银弹,它是一把双刃剑

这两年,Next.js、Nuxt.js确实火得不行。

打开技术社区,满屏都是“从CSR迁移到SSR后,FCP提升50%”、“SSR才是前端正确的打开方式”。

但你有没有发现,很少有人告诉你:为了这点性能提升,你和你团队接下来半年要填多少坑。

1. 服务器账单:以前不要钱,现在要钱了

纯静态页面放OSS上,流量不大时一年可能就几百块。

上了SSR呢?

  • 你需要一台服务器,或者云函数实例
  • 你需要考虑并发,一台扛不住要上负载均衡
  • 你还要担心服务器宕机,得配监控、告警、容灾

我一个朋友的项目,上了SSR后第一个月,云账单从300涨到了3000。老板拿着账单问他:“用户感受到变快了吗?多出来的钱能从收入里赚回来吗?”

他答不上来。

更扎心的是,后来发现大部分用户都是从首页跳详情页,首屏优化的收益根本覆盖不了成本

2. 复杂度转移:以前是纯前端问题,现在是全栈问题

CSR项目,前端只管写页面,接口调不通那是后端的事。

SSR项目呢?

// 以前写页面,岁月静好
function UserProfile({ userId }) {
  const [user, setUser] = useState(null)
  
  useEffect(() => {
    fetch(`/api/user/${userId}`).then(setUser)
  }, [userId])
  
  return <div>{user?.name}</div>
}
// 上了SSR之后,噩梦开始
export async function getServerSideProps({ params }) {
  try {
    // 要处理接口超时
    const controller = new AbortController()
    const timeoutId = setTimeout(() => controller.abort(), 3000)
    
    const res = await fetch(`http://internal-api/user/${params.id}`, {
      signal: controller.signal
    }).catch(() => null)
    
    clearTimeout(timeoutId)
    
    if (!res || !res.ok) {
      // 降级策略怎么写?
      return { props: { user: null, fallback: true } }
    }
    
    const user = await res.json()
    return { props: { user } }
  } catch (error) {
    // 服务端报错,用户看到什么?
    return { notFound: true }
  }
}

function UserProfile({ user, fallback }) {
  // 客户端还要再校验一遍状态
  const [mounted, setMounted] = useState(false)
  
  useEffect(() => {
    setMounted(true)
  }, [])
  
  if (!mounted) {
    return <div>加载中...</div> // 防止hydrate报错
  }
  
  return <div>{user?.name}</div>
}

数据在服务端取,取不到怎么办?页面直接500还是降级成CSR? 服务端取的接口超时了,是要等待还是超时返回? 服务端和客户端的状态怎么对齐?一不小心就 hydrate 报错

我一个同事转做Next.js项目后发朋友圈:“自从上了SSR,我不仅要写React,还要会配Nginx、懂PM2、会分析内存泄漏。工资没涨,责任翻倍。”

底下点赞的全是前端。

3. 开发体验的割裂感

以前写CSR,window、document随便用,反正都在浏览器里。

写了SSR之后:

// 以前一行代码
const width = window.innerWidth
const token = localStorage.getItem('token')
const height = document.getElementById('app').offsetHeight
// 现在
const width = typeof window !== 'undefined' ? window.innerWidth : 1024
const token = typeof window !== 'undefined' 
  ? localStorage.getItem('token') 
  : null
const [height, setHeight] = useState(0)

useEffect(() => {
  setHeight(document.getElementById('app')?.offsetHeight || 0)
}, [])
  • 第三方库如果不兼容服务端渲染,要动态导入
  • localStorage、sessionStorage都不能直接用
  • 路由跳转要小心,服务端没有history API

原本简单的逻辑,现在要写一堆防御代码。你在给代码做安检,但业务逻辑一点没变复杂。


二、你真的需要SSR吗?问自己三个扎心的问题

每次有人问我该不该上SSR,我都让他先回答三个问题。回答完,80%的人自己就放弃了。

问题一:你的产品靠搜索引擎吃饭吗?

这是最硬性的指标,也是最容易被拿来当借口的。

如果你的产品是:

  • 内容型网站(博客、新闻、官网)
  • 电商网站(需要被搜索引擎收录商品页)

那SSR确实有必要。因为爬虫可能不执行JS,或者执行不完整。

但如果你的产品是:

  • 需要登录的后台管理系统
  • 工具类、游戏类H5
  • B端SaaS应用
  • 社区类App的H5版(用户得先登录)

搜索引擎根本爬不到,SEO就是伪需求。

别拿SEO当借口,你只是想让简历里多一行Next.js。

问题二:你的首屏速度真的慢到不能忍了吗?

很多时候,我们觉得首屏慢,其实不是因为CSR不行,是代码写得太糙。

我见过一个“慢”的项目,分析下来:

// 问题代码示例
function App() {
  const [data, setData] = useState(null)
  const [user, setUser] = useState(null)
  const [config, setConfig] = useState(null)
  
  useEffect(() => {
    // 串行调用,一个等一个
    fetch('/api/data').then(res => res.json()).then(data => {
      setData(data)
      return fetch('/api/user')
    }).then(res => res.json()).then(user => {
      setUser(user)
      return fetch('/api/config')
    }).then(res => res.json()).then(setConfig)
    
    // 图片没处理
    new Image().src = 'https://example.com/big-banner.png'
    
    // 第三方脚本同步加载
    const script = document.createElement('script')
    script.src = 'https://analytics.com/sdk.js'
    document.head.appendChild(script)
  }, [])
  
  return <div>...</div>
}

这些问题,优化代码比换架构性价比高得多

我去年优化过一个Vue2项目,纯CSR,首屏从3.2秒优化到1.1秒,只做了四件事:

// 优化后
useEffect(() => {
  // 1. 并行调用
  Promise.all([
    fetch('/api/data'),
    fetch('/api/user'),
    fetch('/api/config')
  ]).then(...)
  
  // 2. 图片转WebP + 懒加载
  // 3. 第三方脚本异步
  const script = document.createElement('script')
  script.async = true
  script.src = 'https://analytics.com/sdk.js'
  
  // 4. 路由懒加载
  const List = lazy(() => import('./pages/List'))
}, [])

没动架构,没重构,没加班。

问题三:你的团队准备好了吗?

这是最容易被忽略的,也是上线后最痛苦的。

上SSR意味着你的前端团队要开始写服务端代码:

  • 有人写过Node.js吗?
  • 有人配过Nginx吗?
  • 有人处理过内存泄漏吗?
# 线上出问题了,你能处理吗?
curl -X POST https://your-site.com/api/user -H "Content-Type: application/json" -d '{"id":123}'

# 如果返回 502
# 是Node进程挂了?Nginx配置错了?接口超时了?

# 登录服务器
ssh user@your-server
pm2 logs
df -h # 磁盘满了?
free -m # 内存泄漏?
top # CPU爆了?
  • 线上出问题了,有人能在凌晨两点爬起来回滚吗?

如果答案都是“没有”,那SSR上线的那天,就是团队噩梦的开始。

技术选型不仅要看技术好不好,还要看团队接不接得住。


三、那些比SSR更香的选择

如果你确实有性能痛点,但又不是非要SSR,其实有很多折中方案。

方案一:静态站点生成(SSG)

如果你的内容是静态的,或者更新不频繁,SSG是完美的选择。

// next.config.js
module.exports = {
  // 构建时生成HTML
  exportPathMap: async function() {
    return {
      '/': { page: '/' },
      '/about': { page: '/about' },
      '/blog/1': { page: '/blog/[id]', query: { id: '1' } },
    }
  }
}
  • 构建时生成HTML,部署在CDN上
  • 首屏速度极快,SEO友好
  • 没有服务器成本,没有运维负担

Next.js、Nuxt.js、VitePress都支持。既有了SSR的首屏优势,又保留了CSR的简单部署。

方案二:静态部署 + 客户端渲染

大部分场景,这才是最优解。

// index.html
<!DOCTYPE html>
<html>
<head>
  <!-- 骨架屏,让用户看到东西 -->
  <style>
    .skeleton { background: #f0f0f0; height: 20px; margin: 10px; }
  </style>
</head>
<body>
  <div id="root">
    <div class="skeleton"></div>
    <div class="skeleton"></div>
    <div class="skeleton"></div>
  </div>
  
  <!-- 资源放CDN,并行加载 -->
  <link rel="preconnect" href="https://api.example.com">
  <script src="https://cdn.example.com/react.js" async></script>
  <script src="https://cdn.example.com/app.js" async></script>
</body>
</html>
  • HTML放CDN,全球加速
  • 接口走API网关,BFF层做聚合
  • 配合预加载、懒加载、骨架屏,体验一点都不差

我现在的项目就是这种方案:React + Vite,打包后放OSS,CDN加速。首屏1.2秒,月PV百万,服务器成本主要是BFF层的几个云函数,加起来不到500块。

够用就好,别为了炫技给自己挖坑。

方案三:部分页面SSR,大部分页面CSR

如果你确实有少数页面需要SEO(比如官网、 landing page),其他页面需要登录。

// next.config.js
module.exports = {
  // 只有这些页面走SSR
  pageExtensions: ['ssr.js', 'page.js'],
  
  async rewrites() {
    return [
      // landing page走SSR
      {
        source: '/',
        destination: '/landing.ssr',
      },
      // 其他页面走静态CSR
      {
        source: '/app/:path*',
        destination: '/app.html',
      }
    ]
  }
}

那可以只把这几个页面抽出来做SSR,剩下的保持CSR。

Next.js支持多页应用模式,Vue也有混合渲染方案。没必要为了10%的页面,让90%的页面承担复杂度。


四、写在最后:技术选型不是为了简历好看

写这篇文章,不是为了否定SSR。

SSR是好技术,Next.js是好框架。我在合适的项目里也用它,确实能解决问题。

但我不喜欢一种风气:明明是个简单的H5活动页,非要上Next.js;明明是个后台管理系统,非要搞服务端渲染;明明首屏已经1.5秒了,非要重构到1.2秒。

为了那0.5秒的优化,搭进去团队半年的维护成本,值吗?

技术选型的标准,不是“别人都用”,也不是“大厂在用”,而是:

  • 能解决我们现在的痛点吗?
  • 团队能驾驭吗?
  • 维护成本扛得住吗?
  • 换来的收益对得起付出的代价吗?

别让技术选型,变成一场给简历镀金的表演。


最后想问问大家: 你们见过哪些“没必要上SSR但硬上”的项目?踩过哪些坑?欢迎在评论区互相伤害。

如果你也看不惯那些为了炫技而复杂化的技术选型,欢迎关注我,一起聊聊真实的前端。

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比数平台功能介绍及实现原理|得物技术

文 /寇森

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

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

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

❌