阅读视图

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

Claude CLI AskUserQuestion 工具详解:让 AI 开口问你

Gemini_Generated_Image_xnf0boxnf0boxnf0.png

在使用 Claude Code 处理复杂任务时,你是否遇到过这样的场景:AI 默默地做了一大堆工作,最终交出的结果却完全不是你想要的?或者,AI 在关键节点做出了一个错误的假设,导致整个方向跑偏?

AskUserQuestion 工具正是为了解决这个问题而存在的。它让 Claude 具备了"开口提问"的能力,在模糊、关键或多选的节点上主动与你确认,而不是自行猜测。


什么是 AskUserQuestion?

AskUserQuestion 是 Claude Code 内置的一个工具(Tool),专门用于在任务执行过程中向用户收集信息或决策

与普通的文字对话不同,AskUserQuestion 会渲染一个结构化的交互组件,提供:

  • 清晰的问题说明
  • 预设的可选答案(2-4 个选项)
  • 可选的 Markdown 预览(适合对比代码、UI 布局等)
  • 多选模式(允许用户选择多个选项)
  • 用户始终可以选择"Other"并输入自定义文本

从用户体验角度看,这远比 AI 在聊天框里输出一段文字问题更加清晰、高效。


核心参数解析

AskUserQuestion 接受如下结构的参数:

{
  "questions": [
    {
      "question": "你希望使用哪种认证方式?",
      "header": "Auth method",
      "multiSelect": false,
      "options": [
        {
          "label": "JWT Token",
          "description": "无状态,适合分布式系统,前端自行管理 token。",
          "markdown": "// JWT 示例\nconst token = jwt.sign({ userId }, SECRET, { expiresIn: '7d' });"
        },
        {
          "label": "Session Cookie",
          "description": "服务端管理状态,适合传统 Web 应用。"
        },
        {
          "label": "OAuth 2.0",
          "description": "支持第三方登录,适合需要集成 GitHub/Google 等场景。"
        }
      ]
    }
  ]
}

关键字段说明

字段 类型 说明
question string 完整的问题文本,应以问号结尾
header string 显示为标签/芯片的简短标题,最多 12 个字符
multiSelect boolean 是否允许多选,默认 false
options array 2-4 个选项,每项包含 label、description,可选 markdown
options.markdown string 可选的预览内容,支持代码片段、ASCII 图、配置示例

适用场景

Claude 在以下情况下应当使用 AskUserQuestion:

1. 需求模糊,存在多种合理解读

用户说"优化数据库查询"——是加索引?改 ORM 写法?引入缓存?还是拆分查询?

在动手之前,先问清楚,省去反复返工。

2. 存在多个同等合理的技术方案

实现实时通信,可以选 WebSocket、Server-Sent Events 或轮询。

每种方案各有取舍,让用户做决策,而不是 AI 擅自选一个。

3. 即将执行高风险或不可逆操作

即将删除数据库表、强制推送 git、覆盖现有配置文件……

这类操作应当暂停并明确征得用户同意,即使用户之前说过"自动执行"。

4. 偏好问题,没有标准答案

代码风格选 Prettier 还是 ESLint?注释用中文还是英文?

5. 发现了预期之外的状态

发现了不认识的配置文件、未知分支、异常的文件结构——先问再动。


Claude 在什么时候不该使用 AskUserQuestion?

工具有使用边界,滥用反而会让 AI 显得犹豫不决、烦人啰嗦。

  • 任务指令已经非常明确,不存在歧义
  • 问题可以通过读取项目文件、配置、文档自行推断
  • 属于显而易见的单步操作(比如"修复拼写错误")
  • 询问"我的计划可以吗?"——这种确认场景应该使用 ExitPlanMode,而不是 AskUserQuestion

在 Windows 平台上使用 Claude Code

安装 Claude Code

Claude Code 基于 Node.js,在 Windows 上通过 npm 全局安装:

# 全局安装 Claude Code
npm install -g @anthropic-ai/claude-code

# 验证安装
claude --version

推荐:在 Windows 上使用 Windows Terminal + PowerShell 7,获得更好的 ANSI 颜色支持和 Unicode 渲染效果。

配置 API Key

# 设置环境变量(当前会话)
$env:ANTHROPIC_API_KEY = "sk-ant-xxxxx"

# 或永久写入用户环境变量
[System.Environment]::SetEnvironmentVariable("ANTHROPIC_API_KEY", "sk-ant-xxxxx", "User")

也可以通过 Claude Code 内置的认证流程:

claude
# 按照提示完成认证

启动并体验 AskUserQuestion

在项目目录下启动 Claude Code:

cd C:\Projects\MyApp
claude

当 Claude 遇到需要你决策的节点时,AskUserQuestion 工具会被自动调用,终端中将渲染出交互式选项界面。


实战示例:AskUserQuestion 改变工作流的场景

场景一:添加用户认证功能

用户:给我的 Express 应用添加用户认证

没有 AskUserQuestion,AI 可能默默实现了一套基于 Session 的认证,而你其实想要 JWT。

有了 AskUserQuestion,Claude 会先问:

? 请选择认证方式
  ① JWT Token(无状态,适合前后端分离)
  ② Session Cookie(有状态,适合传统 Web)
  ③ OAuth 2.0(第三方登录)
  > Other

你的一次点击,节省了可能数小时的返工。


总结

维度 要点
本质 Claude Code 的内置交互工具,渲染结构化问答 UI
触发时机 模糊需求、多方案抉择、高风险操作、用户偏好
Windows 配置 Node.js + npm 安装,推荐 Windows Terminal
核心价值 在关键节点引入人类判断,减少猜测,降低返工
使用边界 不适用于需求明确、可自行推断、单步操作场景

分享一个开源项目,让 AI 辅助开发真正高效起来

先问个问题

你有没有这种感觉:每天和 AI 说的那些"重复的话",加起来比和同事说的还多?

"用 TypeScript" "记得加类型" "Tailwind CSS" "暗色模式" "响应式布局" "ESLint 规范" "React 19 新特性" "Next.js 15"

一天下来,少说 50 句。

算过吗?一年下来,这些"复读"时间有多久?


痛点:AI 没有记忆

AI 很强大,但有个致命缺陷:每次对话都是全新的开始

  • ❌ 不记得你的技术栈
  • ❌ 不记得你的编码规范
  • ❌ 不记得你踩过的坑
  • ❌ 不记得你的项目结构

就像一个天才,却患有老年痴呆。


我的解法

与其每天重复说,不如把这些配置集中管理

awesome-ai-dev/
├── .cursor/     # Cursor IDE 配置中心
   ├── rules/   # 开发规范(React/Vue/Next.js...)
   ├── skills/  # 专业技能(数据库/安全/性能...)
   ├── hooks/   # 自动化脚本(Pre-commit/Post-merge...)
   ├── agents/  # AI 代理(架构师/审查员/调试专家)
├── ai-tools/   # AI 工具推荐
└── prompts/    # 提示词模板

现在我只需要说:

@agent architect 帮我设计个电商系统

AI 自动懂了:TypeScript ✅、Next.js 15 ✅、Tailwind ✅、响应式 ✅

终于不用复读了。


这个项目有什么

Rules — 开发规范

告诉 AI 你的规范,一次配置,长期生效:

框架 规范内容
React React 19 + TypeScript + App Router
Vue Vue 3 + Composition API
Next.js Next.js 15 + Server Components
NestJS NestJS 11 + Prisma
Go Go + Gin
Rust Rust + Actix-web
... 20+ 框架规范

Skills — 专业技能

技能 用途
@skill database 数据库优化不求人
@skill security 安全问题都懂
@skill performance 性能优化专家
@skill docker 容器化部署
@skill testing 单元测试、E2E 测试

Agents — AI 助手

Agent 角色
@agent architect 架构师在线
@agent code-review 代码审查员
@agent debugger Bug 终结者
@agent fullstack 全栈开发
@agent technical-writer 文档专员

Prompts — 场景提示词

不知道怎么问?直接用模板:

@prompt 单元测试   # 生成测试用例
@prompt Bug修复    # 快速定位问题
@prompt 重构优化    # 优化建议

项目统计

类别 数量 说明
Rules 规范 20+ 覆盖主流框架和通用技术
Skills 技能 15+ 包含脚本、文档、配置
Agents 代理 14 专业领域的 AI 助手
Prompts 提示词 20+ 场景化开发提示词
AI Tools 工具 11 按类别整理的 AI 工具

效果如何?

场景 之前 之后
新项目 30 分钟重复配置 1 分钟加载
代码审查 逐行人工 review @agent code-review
Bug 定位 描述半天上下文 @agent debugger
单元测试 手动一个个写 @prompt 单元测试

每天节省 2 小时,一年 ≈ 500 小时 = 62 个工作日!

相当于每年多出了 2 个月 的自由时间。


为什么开源?

坦白说,这个项目远未完善

AI 辅助开发这个领域太大了:

  • MCP 的能力还没完全挖掘
  • Agents 的最佳实践还在摸索
  • LangChainRAG向量数据库...
  • 新的 AI 工具层出不穷

一个人研究太慢,不如一起学。

与其闭门造车,我更希望:

  • 你踩过的坑,写成 Rules 让别人少踩
  • 你学到的新东西,补充到 Skills 大家一起用
  • 你不理解的知识点,在 Issues 里讨论
  • 你发现的工具,推荐到 ai-tools 共享

这不仅是一个配置库,更希望成为一个学习社区。


欢迎加入

你可以

  • Star 支持一下
  • 🐛 报告 Bug
  • 💡 提出建议
  • 📝 完善文档
  • 🔧 提交代码 (Rules/Skills/Agents/Prompts)
  • 📚 分享经验
  • 🌱 一起学习

项目地址

GitHub: https://github.com/awesome-ai-dev/awesome-ai-dev
Gitee:  https://gitee.com/awesome-ai-dev/awesome-ai-dev

我的博客

墨渊书肆

最后

用 AI 辅助开发,配置做得好,下班走得早。

学习这件事,一个人走得快,一群人走得远

希望在这里遇到志同道合的你,一起完善这个项目,一起学习 AI 开发,一起成长。

有问题欢迎提 Issue,期待你的加入!

我拿到了腾讯QClaw的内测码,然后沉默了。

你好呀,我是歪歪。

在前几天 OpenClaw 火的一塌糊涂的时候,各大厂商都疯狂蹭热度,纷纷推出了自己一键安装版本的“龙虾”。

歪师傅知道的就有腾讯的 QClaw,智谱的 AutoClaw、字节的 ArkClaw、百度的 DuClaw、月之暗面的 Kimi Claw、MiniMax 的 MaxClaw 等等。

其中最特殊的我觉得是腾讯的 QClaw。

就是这玩意:

抛开具体的模型不说,其他的各类产品至少都是支持直接下载,给点免费额度,然后开箱即用。

但是就腾讯的 QClaw 它要内测,必须要邀请码,这就激起了我的好奇心了。

歪师傅就是容易“犯贱”。

可以直接下载的不用,反而非得想体验一下这个必须要排队等邀请码的。

其实在这之前我已经在一台闲置的 Windows 电脑上安装好了原生的 Openclaw。

那台电脑确实是上了年纪了,我安装的时候也是遇到了各种各样的麻烦,我就这么给你说吧:我第一天没装上,第二天还是没装上,最后想着反正是闲置的电脑,里面也没啥数据,索性直接重新装了一遍系统,之后才丝滑装好。

当然了,我这个属于个例。

我相信如果在我的主力机上装的话,肯定也会非常丝滑。

当我一切就绪,坐在电脑面前,通过飞书发送一条消息,让它操作我的电脑,在桌面写一个文档,最后顺利完成的那一瞬间的感觉,怎么说呢?

说实在的,那一瞬间,我的内心毫无波动,甚至还有点想笑。

我折腾了一番之后,确实有了一个“龙虾”,但是我确实是没有找到合适的使用场景。

有折腾的这个时间,我觉得我甚至不如去学学如何更好的使用 Claude Code。

我也是被巨大的流量蒙住了双眼。

看着大家都在讨论这个东西,也想着去看看。

但实际上,就像我前面说的,在折腾它之前,我完全没有想过我的使用场景是什么。

总之,我目前觉得对我个人而言,之前花在折腾 OpenClaw 的时间不太值得。

在一切顺利的情况下,不管你是在 Windows 还是 Mac 上装 OpenClaw,都不麻烦。

但是我作为一个相关从业者,在一台闲置的 Windows 电脑上装的时候都费了不小的劲儿。

更别说一些纯小白用户了。

所以,前面提到的各大厂商就是看到了这个痛点。

纷纷推出了自己的一键安装版本的“OpenClaw”。

只不过这个“OpenClaw”换上了各大厂的“专属皮肤”和“专用通道”。

对于想要体验一下的小白用户来说,我觉得够用了。

而这里面,我觉得对于小白用户来说,其中最值得期待的就是腾讯的 QClaw。

因为它的优势太明显了:微信对话框就是它的入口。

微信,国民软件了。

上到九十九,下到刚会走,这个范围内的人,可能没有飞书、没有钉钉、没有 QQ,但是大概率会有一个微信。

可以说是用户体验成本极低了。

但是这玩意它现在还处于内测阶段。

诶,巧了。

昨天,歪师傅申请到了一个内测码:

在 QClaw 的安装过程的页面,我看到了这样一句话:

默认大模型下限时免费提供国产优质稳定模型

其实我是有点纳闷的。

腾讯自研的大模型不就是混元大模型吗。

QClaw 这玩意肯定是使用自家的混元大模型呀,难道还会去用一下其他友商的免费额度?

不知道为什么不直接提到混元大模型,给自家的产品露露脸。

安装完成,输入内测码之后,你就可以通过扫码关联微信,然后就能通过手机操控电脑了:

但是在这个页面,也有一句话值得注意:

扫码后可通过微信与客服号对话发送指令操控电脑。

客服号,在微信中也就是以这样的形态存在的:

它并不是以一个微信好友的形式存在。

你不能把它拉进某个群里,也不能让它帮你回复一些微信消息。

那能不能在我登录了电脑微信的情况下,直接把电脑上的文件通过文件传输助手直接发给我呢?

这其实是我很关心的一个功能。

然而,很遗憾,并不能:

在官方的案例中,传文件是需要通过邮箱传递的,我觉得这个方案绕了一大圈,就没去尝试:

接着,我给了它一个简单的任务,测试一下它在电脑本地创建文件的能力:

这是一个基础能力,它并没有翻车。

这是它生成的文档:

但是它并没有把文档发给我:

前面说了,它目前没有这个能力。

随后,我又硬想出了一个这样的应用场景:

因为微信的提醒是强提醒,所以我可以让它提醒我按时喝水和不要久坐。

看它的项目路径下确实多了一个 jobs.json 的配置文件。

里面的内容点开看也是没毛病的:

就看明天会不会正常触发了。

按理来说这个不会有什么大问题,定时触发也算是“龙虾”的一个基础功能了。

我还测试了一下,让它打开微信公众号页面,把登录二维码截图发给我。

它是这样回复我的:

而我把类似的任务扔给我安装的原生 OpenClaw,大模型使用的是 MiniMax M2.5。

也是在没有安装任何 Skills 的情况下,它能截图并尝试通过飞书发给我。

我只是引导它,让它把图片变成互联网可访问的链接:

它就能直接去想办法,最后找到了一个我都不知道的免费的临时文件托管服务,搞定了我看不到图片的问题:

所以,“原生虾”和“封装虾”之间还是有些差异。当然了,最大的差异点还是在使用不同的大模型上。

QClaw 从功能上看,也是支持自定义大模型的:

但是,可能是我的打开方式不对。

我买的 MiniMax 的 Coding Plan 月度套餐,对应的 API Key 配上去之后,它识别不出来具体是什么模型。

我输入“MiniMax M2.5”呢,它又说不知道这个模型:

导致我用不了自定义大模型。

总之,我觉得目前的 QClaw 能力还很弱,和我心目中“QClaw” 还有一定的距离,还需要继续努力,拿出一些“诚意”出来。

巧了,刚写完这句话没几分钟,我就收到了 QClaw 的更新提示:

更新完成之后,我发现多了这几个东西:

首先是在右上角多了一个“龙虾管家安全沙箱”,点过去之后是“腾讯电脑管家”的页面:

呃,怎么说呢?

引流嘛,不寒碜。

左下角多了一个“灵感广场”:

这里面就是各种各样的官方 Skills。

而输入框里面的“虾灵感”,里面默认了几个选项,其实就是教你如何使用“灵感广场”里面的技能:

比如,选“学习打卡督促”:

最后,还多了一个“设置”页面:

是的,在刚开始的版本里面,连个设置入口都没有。

在设置里面,可以看到 Token 的消耗:

还可以进行一些技能管理:

在记忆模块里面,可以写一些全局的 prompt 提示词:

通过这次更新,可以看到腾讯电脑管家在 QClaw 这个产品上在持续发力。

只要在内部赛马比赛中能突出重围,在后续一些大的迭代中推出一些解决用户实际痛点的功能,会进一步巩固微信生态。

但是有一说一,难,很难,我感觉 OpenClaw 这波热度很快就要过去了。

当热度过去之后 QClaw 不知道将会何去何从。

现在的 QClaw 确实还很稚嫩,它像是一个刚学会走路的孩子,有着无限可能,但还需要时间成长。

不能传文件、不能进群聊、不能帮我回微信,但是这些“不能”的背后,其实还有一个更深层的问题:

当 AI 能力被植入到一个已经高度成熟的产品生态中,它的定位是什么?

是做一个安静的助手,还是成为一个 AI 好友,或者是其他什么形态?

最后,我还是推荐你去下载一个 QClaw 体验一下,毕竟在你下载完成扫码授权之后,它就活在你的微信里面,你不需要下载其他任何的 APP 就能进行体验。

方便程度还是很高的。

写在最后

其实在 OpenClaw 的这波浪潮之中,我感觉整个网络环境对于 OpenClaw 是很亢奋的,它确实完全破圈了,有好几个不做技术的朋友都来问我 OpenClaw 相关的事情。

但是整个过程我又很平静,远远没有我第一次使用 Claude Code,它给我解决实际问题后的兴奋。以及我周末的早上都不睡懒觉,就想去电脑前把玩它的新鲜感。

而 OpenClaw 呢?

我觉得回看 OpenClaw 这波浪潮,最值得玩味的不是技术本身,而是我们面对新技术时的集体心理,特别是这个新技术还达到了全民讨论的高度。

从无数人熬夜折腾安装配置,到上门收费安装“龙虾”,再到大厂以地推的方式免费安装,最后各大厂连夜推出的一键安装“龙虾”,这里面藏着一个很有趣的现象:

我们追逐的往往不是工具本身,而是一种“不能掉队”的安全感。

这种安全感焦虑,在上面一波又一波的媒体造势中,被放大了无数倍。

我也不想掉队,所以我在还没想清楚应用场景的情况下就去养了一只“龙虾”,配置完成的那一刻,我会有一瞬间的感觉到:真好,这波我又没有掉队。

这是一种自欺欺人的感觉。

但是这也没什么不好。

人嘛,总要靠点错觉活着。

只要别一直活在错觉里就行。

两款惊艳的 WebGL 开源项目推荐

作者:柳杉前端
标签:#Three.js #WebGL #开源项目 #游戏开发 #Shader特效

大家好,我是柳杉!今天给大家带来两个极具视觉冲击力的开源项目测评——一个是把经典台球游戏搬进太空的 Cosmic Pool,一个是用纯手写 Shader 打造的 Force Shield VFX。这两个项目分别代表了 WebGL 在游戏和视觉特效领域的巅峰玩法,无论你是想做游戏还是想学 Shader,都值得深入研究!


🎱 项目一:Cosmic Pool —— 太空台球美学巅峰

截屏2026-03-18 18.45.12.png

截屏2026-03-18 18.44.41.png

开源地址: github.com/lespuch-v/c…

项目简介

Cosmic Pool 是一款基于浏览器和 Three.js 的 8 球台球游戏原型。但别被"原型"二字骗了——它把传统台球桌搬进了浩瀚宇宙,用星球取代了普通球,整个视觉风格是太空奢华风 (Space-Luxury),看一眼就让人上头!

技术栈

技术 版本/说明
构建工具 Vite
语言 TypeScript
3D 引擎 Three.js
物理引擎 自定义台球物理系统
代码规范 ESLint

核心玩法测评

1. 拖拽式击球系统

// 核心交互逻辑:鼠标拖拽控制力度和方向
this.interactionController = new GameInteractionController({
  mountElement: this.mountElement,
  // 拖拽时实时计算力度
  strikeCueBall: (direction, shotPower) =>
    this.billiardsWorld.strikeCueBall(direction, shotPower),
  // 精准瞄准模式(按住 Shift)
  precisionAimEnabled: this.interactionController.isPrecisionAimEnabled(),
})

体验点评: 拖拽力度条的设计很直观,按住 Shift 进入精准模式时会降低鼠标灵敏度,这个细节非常贴心。整个击球手感流畅自然,球杆动画跟手性很强。

2. 太空主题视觉系统

项目最核心的亮点在于多层视觉叠加

🌌 星空穹顶 (Cosmic Dome)

// 旋转的星空背景
this.cosmicDome.mesh.rotation.y += delta * 0.0012

⭐ 动态星场 (Starfield)

// 内外双层星场漂移效果
this.starfield.rotation.y += delta * GAME_CONFIG.environment.innerStarfield.driftSpeed.y
this.starfield.rotation.x = Math.sin(elapsedTime * 0.12) * 
  GAME_CONFIG.environment.innerStarfield.driftSpeed.x

🌠 银河系背景 (Milky Way)

台球桌本身也很讲究:

  • 边缘线条带 Bloom 辉光效果
  • 星球球体表面纹理精致(从截图看有类似木星、火星的纹理)
  • 桌布采用深空色系,与环境融为一体

3. 物理引擎实现

项目没有使用现成的物理引擎,而是手写了一套台球物理系统

// BilliardsWorld 核心类
export class BilliardsWorld {
  // 球体碰撞检测
  update(delta: number): void
  // 球袋检测
  consumeEvents(): BilliardsEvent[]
  // 白球放置验证
  previewCueBallPlacement(position: Vector2): boolean
}

包含的物理特性:

  • 球与球的弹性碰撞
  • 球与桌边的反弹
  • 球袋判定(Pocket Detection)
  • 白球自由放置(Cue Ball in Hand)
  • 球体静止检测(Settled Detection)

4. AI 对战系统

CPU 对手的智能度可以调节(简单/普通/困难):

// CPU 决策流程
export interface CpuTurnState {
  currentTurn: 'human' | 'cpu'
  humanGroup: 'solids' | 'stripes' | null
  cpuGroup: 'solids' | 'stripes' | null
}

// AI 找最佳击球路线
const bestShot = findBestShot(balls, rulesState, difficulty)
// 构建安全球策略
const safeShot = buildSafeShot(balls, rulesState)

AI 行为特点:

  • 难度影响口袋容错率和瞄准精度
  • 会主动寻找最佳进球路线
  • 无法进攻时会尝试打安全球
  • 思考时间模拟(1.2秒延迟)

5. 后处理特效管线

// Bloom 辉光效果实现
const BLOOM_LAYER = 1
// 星星和特效启用 Bloom 层
this.starfield.traverse((obj) => obj.layers.enable(BLOOM_LAYER))

// EffectComposer 管线
this.bloomComposer = new EffectComposer(renderer)
this.bloomComposer.addPass(new UnrealBloomPass(/* ... */))
this.bloomComposer.addPass(new SMAAPass(/* 抗锯齿 */))

特效分层处理:

  1. 先渲染需要 Bloom 的物体到单独层
  2. UnrealBloomPass 提取高亮部分
  3. SMAAPass 抗锯齿
  4. OutputPass 输出最终画面

项目架构亮点

src/
├── entities/          # 游戏实体(球、球桌)
├── environment/       # 环境(星空、银河)
├── gameplay/          # 游戏玩法逻辑
├── physics/           # 物理引擎
├── rules/             # 8球规则引擎
├── cpu/               # AI 逻辑
├── effects/           # 特效系统
├── audio/             # 音频管理
└── ui/                # HUD 控制器

设计模式亮点:

  • 状态机模式:InteractionState 管理游戏流程
  • 组件化设计:Ball、Table、CueStick 都是独立实体
  • 观察者模式:BilliardsEvent 事件系统解耦物理与逻辑

可玩性评分

维度 评分 评价
视觉效果 ⭐⭐⭐⭐⭐ 太空主题执行得非常到位
操作手感 ⭐⭐⭐⭐ 流畅自然,精准模式加分
游戏性 ⭐⭐⭐ 基础规则完整,但内容较浅
代码质量 ⭐⭐⭐⭐ TypeScript + 模块化,结构清晰
学习价值 ⭐⭐⭐⭐⭐ 物理引擎实现值得学习

🛡️ 项目二:Force Shield VFX —— 电影级能量盾 Shader

开源地址: github.com/cortiz2894/…

项目简介

这是一个生产级品质的 WebGL 能量盾特效,用纯手写 GLSL Shader 实现。作者 Christian Ortiz 是创意开发领域的顶尖高手,这个项目被设计为学习资源和创作起点,代码注释详尽,架构优雅。

先看效果:

截屏2026-03-18 18.45.44.png

截屏2026-03-18 18.46.19.png

截屏2026-03-18 18.48.23.png

技术栈

技术 版本/说明
框架 Next.js 16.1 (App Router)
3D 引擎 Three.js + React Three Fiber
Shader 手写 GLSL (Vertex + Fragment)
后处理 @react-three/postprocessing
GUI 控制 Leva
样式 Tailwind CSS 4

Shader 核心实现解析

1. 六边形网格 (Hex Grid)

能量盾最标志性的特征就是蜂窝状网格:

// 三平面投影 (Tri-planar Projection)
// 从三个轴向投影六边形纹理,避免球面 UV 变形
vec3 hexTriplanar(vec3 pos, vec3 normal, float scale) {
  vec3 xPlane = hexGrid(pos.yz * scale);
  vec3 yPlane = hexGrid(pos.xz * scale);
  vec3 zPlane = hexGrid(pos.xy * scale);
  
  // 根据法线方向混合三个平面
  vec3 weights = abs(normal);
  weights = pow(weights, sharpness);
  weights /= dot(weights, vec3(1.0));
  
  return xPlane * weights.x + yPlane * weights.y + zPlane * weights.z;
}

技术难点: 球面上直接用 UV 映射六边形会产生拉伸变形。三平面投影从 XYZ 三个方向分别投影,然后根据表面法线混合,完美解决这个问题!

2. 菲涅尔发光 (Fresnel Glow)

边缘高亮是能量盾的灵魂:

// 菲涅尔效应 - 视线与法线夹角越大越亮
float fresnel = pow(1.0 - abs(dot(viewDirection, normal)), fresnelPower);
vec3 glowColor = baseColor * fresnel * fresnelIntensity;

3. 流动噪波 (Flow Noise)

// 多层 Simplex 噪波叠加
float flowNoise(vec3 pos, float time) {
  float noise = 0.0;
  float amplitude = 1.0;
  float frequency = 1.0;
  
  for(int i = 0; i < 4; i++) {
    noise += simplexNoise(pos * frequency + time * flowSpeed) * amplitude;
    amplitude *= 0.5;
    frequency *= 2.0;
  }
  return noise;
}

FBM (Fractal Brownian Motion) 分形布朗运动,通过叠加不同频率的噪波创造丰富的表面细节。

4. 受击系统 (Hit System)

这是我最惊艳的部分——最多同时追踪 6 个受击点

// Ring Buffer 存储受击信息
uniform vec3 hitPositions[6];    // 受击位置
uniform float hitTimes[6];        // 受击时间
uniform float hitStrengths[6];    // 受击强度

// 每个受击点产生扩散的噪波环
float calculateHitWave(vec3 worldPos, vec3 hitPos, float time, float hitTime) {
  float elapsed = time - hitTime;
  float distance = length(worldPos - hitPos);
  float waveRadius = elapsed * expansionSpeed;
  
  // 噪波扰动的环形波
  float wave = smoothstep(waveRadius + thickness, waveRadius, distance);
  wave *= smoothstep(waveRadius - thickness, waveRadius, distance);
  wave *= noise(worldPos * 10.0 + elapsed); // 添加噪波扰动
  
  return wave * exp(-elapsed * decayRate); // 随时间衰减
}

效果拆解:

  1. Geodesic Wavefront - 从受击点向外扩散的噪波环
  2. Hex Highlight Zone - 受击点附近六边形格子高亮
  3. Life Damage - 每次受击扣减护盾生命值
  4. 颜色渐变 - 护盾从蓝色渐变为红色表示受损

5. 显隐动画 (Reveal Animation)

// 基于 Simplex 噪波的溶解效果
float dissolveMask = simplexNoise(worldPos * dissolveScale) + (revealProgress * 2.0 - 1.0);
float edgeGlow = smoothstep(0.0, edgeWidth, dissolveMask) * smoothstep(edgeWidth * 2.0, edgeWidth, dissolveMask);

// 边缘发光
if (dissolveMask > 0.0 && dissolveMask < edgeThreshold) {
  emission += edgeColor * edgeIntensity;
}

discard; // 完全溶解的像素丢弃

参数化控制

作者用 Leva 做了完整的 GUI 控制面板,所有视觉属性都可以实时调节

参数类别 可调项
网格 六边形大小、缝隙宽度、闪烁频率
材质 基础色、发光强度、透明度、底部淡出
噪波 流动速度、噪波层级、扰动强度
受击 波扩散速度、衰减率、六边形高亮范围
生命系统 总血量、单次受伤比例、恢复速度
后处理 Bloom 强度、阈值、胶片噪点

场景预设 (Presets)

项目提供了两个精心调教的场景预设:

  1. Default - 漂浮的能量球,中性展示场景
  2. Droideka - 星球大战中毁灭者的护盾效果,环绕在机器人模型周围

切换预设会同时修改 Shader 参数、光照设置和相机角度,一键体验完全不同的视觉风格。

代码架构赏析

app/
├── shaders/
│   ├── vertex.glsl      # 顶点着色器
│   ├── fragment.glsl    # 片段着色器
│   └── uniforms.ts      # Shader 参数定义
├── components/
│   ├── ShieldMesh.tsx   # 护盾网格组件
│   └── Scene.tsx        # 3D 场景
├── controls/
│   └── useShieldControls.ts  # Leva 控制器
└── presets/
    ├── default.ts
    └── droideka.ts

架构亮点:

  • Shader 代码完全手写,无外部依赖,学习价值极高
  • 参数与 UI 通过 Leva 绑定,实时调试体验极佳
  • 预设系统方便保存和分享配置

学习价值评分

维度 评分 评价
视觉效果 ⭐⭐⭐⭐⭐ 电影级品质,可直接用于项目
Shader 技术 ⭐⭐⭐⭐⭐ 涵盖多个高级技巧
代码质量 ⭐⭐⭐⭐⭐ 注释详尽,结构清晰
可扩展性 ⭐⭐⭐⭐ 参数化程度高
学习价值 ⭐⭐⭐⭐⭐ 最佳 Shader 学习资源之一

🎯 两个项目的对比与选型建议

维度 Cosmic Pool Force Shield VFX
核心领域 游戏开发 视觉特效
技术重点 物理引擎、游戏逻辑 Shader、图形学
学习难度 中等 较高
适用场景 休闲游戏、原型开发 游戏特效、展示页面
亮点 完整游戏循环 生产级 Shader

你应该选哪个?

选择 Cosmic Pool 如果你:

  • 想学习 Three.js 游戏开发
  • 需要了解物理引擎的实现原理
  • 想做休闲类游戏项目
  • 喜欢太空/科幻主题

选择 Force Shield VFX 如果你:

  • 想深入学习 GLSL Shader
  • 需要炫酷的护盾/能量特效
  • 想了解后处理管线
  • 追求视觉品质

🚀 快速上手指南

Cosmic Pool

# 克隆仓库
git clone https://github.com/lespuch-v/cosmic-pool.git
cd cosmic-pool

# 安装依赖
npm install

# 启动开发服务器
npm run dev

# 打开 http://localhost:5173

Force Shield VFX

# 克隆仓库
git clone https://github.com/cortiz2894/flow-shield-effect.git
cd flow-shield-effect

# 安装依赖(推荐用 pnpm)
pnpm install

# 启动开发服务器
pnpm dev

# 打开 http://localhost:3000

💡 最后的碎碎念

这两个项目代表了 WebGL 领域的两种极致追求

  • Cosmic Pool 证明了用 Web 技术也能做出有品质的 3D 游戏,它的物理引擎实现虽然不如 Cannon.js 完善,但对于台球这种相对简单的场景完全够用。

  • Force Shield VFX 则展示了 Shader 的强大魅力——同样的几何体,通过不同的着色器可以呈现完全不同的视觉效果。作者 Christian Ortiz 的代码风格非常优雅,注释详尽,是学习 Shader 的绝佳素材。

如果你也对 WebGL 感兴趣,强烈推荐:

  1. 先把两个项目跑起来体验效果
  2. 然后啃一遍核心代码
  3. 尝试修改参数,看效果变化
  4. 最后尝试移植到你自己的项目中

希望这篇测评对你有帮助!如果觉得不错,点个在看支持一下~ 🌟


📌 开源地址汇总:

👨‍💻 作者信息:

  • Cosmic Pool: @lespuch-v
  • Force Shield VFX: Christian Ortiz (@cortiz2894)

本文完,感谢阅读!有任何问题欢迎在评论区交流~

Sass 进阶:当 CSS 学会了编程,变量函数循环全都安排上

昨天我们用Sass告别了手工作坊,学会了变量和嵌套。今天咱们继续深入,看看Sass还有哪些骚操作——模块化、内置函数、条件循环,让你的CSS代码像程序一样聪明。准备好,我们要从“会用”进化到“玩出花”了。

前言

还记得你第一次写CSS时的样子吗?一个属性一个属性地敲,一个颜色一个颜色地复制,改个主题色就像在玩“大家来找茬”。昨天学了Sass基础,你已经能像模像样地用变量和嵌套了,感觉自己有点东西了是吧?

但Sass的真正威力远不止于此。今天我们要学的这些东西,会让你忍不住喊出:“卧槽,CSS还能这样写?”——模块化让你的代码像乐高一样拼装,内置函数让颜色和数字自动计算,循环让你批量生成样式就像打印传单。准备好了吗?上车!

一、模块化:别再写一个几千行的巨型文件了

你有没有见过那种一个文件几千行的CSS?看到就想吐对吧?维护起来更是噩梦。Sass的模块化功能就是来拯救你的。

1. @use:新一代的模块引入

以前Sass用@import,但@import有个问题:它会把你引入的所有东西都混到一个全局作用域里,变量重名就覆盖,混乱不堪。现在推荐用@use,它创建了命名空间,隔离了变量。

// _variables.scss
$primary-color: #8A2BE2;
$font-stack: 'Helvetica', sans-serif;

// _buttons.scss
@use 'variables';
.button {
  background: variables.$primary-color;  // 通过命名空间访问
  font-family: variables.$font-stack;
}

看到没,通过variables.前缀访问,清清楚楚,妈妈再也不用担心我变量重名了。

2. 下划线开头的“部分文件”

你发现上面文件名是_variables.scss,有个下划线。这是Sass的约定:下划线开头的文件是“部分文件”,不会被单独编译成CSS,只用来被别的文件引入。就像厨房里的半成品食材,不直接上桌,但做菜要用。

3. @forward:合并转发

如果你有一堆工具函数,想打包成一个入口文件让别人用,就用@forward

// _mixins.scss
@mixin flex-center { ... }

// _variables.scss
$primary: #8A2BE2;

// _index.scss
@forward 'variables';
@forward 'mixins';

// main.scss
@use 'index' as *;  // 直接使用所有转发的成员,无需命名空间

这样别人只要引入index,就能用你所有的变量和混入,方便又优雅。

二、内置函数:Sass自带的神兵利器

Sass内置了很多函数,让你能操作颜色、数字、字符串,就像JavaScript一样。这些函数能帮你省掉无数计算和手工调整。

1. 颜色函数:调色盘在手

颜色是CSS里最烦人的东西之一。你要一个颜色变暗10%?用darken()。变亮?用lighten()。混合两个颜色?用mix()

$primary: #8A2BE2;

.btn {
  background: $primary;
  
  &:hover {
    background: darken($primary, 10%);  // 变暗10%
  }
  
  &.disabled {
    background: lighten($primary, 20%);  // 变亮20%
  }
}

.card {
  border: 1px solid rgba($primary, 0.3);  // 转成半透明
}

还有adjust-hue()调整色相,saturate()增加饱和度,desaturate()降低饱和度……总之,调色不再靠肉眼,全交给Sass计算。

2. 数字函数:算清楚

percentage(0.3) 转成30%,round(3.14) 取整,min(1, 2, 3) 取最小值,max()取最大值。这些函数在处理响应式尺寸时尤其好用。

$container-width: 1200px;
$gutter: 20px;

.item {
  width: percentage(1/3);  // 33.33333%
  margin-right: $gutter;
  
  &:nth-child(3n) {
    margin-right: 0;
  }
}

3. 字符串函数:玩文字

quote()加引号,unquote()去引号,str-index()查找位置,str-insert()插入。虽然不是天天用,但需要的时候真香。

4. 检查函数:知己知彼

type-of($var)返回变量类型,unit(10px)返回单位,unitless(10px)判断是否有单位。写mixin时常用到,比如:

@mixin size($value) {
  @if unitless($value) {
    // 如果没单位,默认px
    width: #{$value}px;
  } @else {
    width: $value;
  }
}

三、控制指令:让CSS长脑子

这才是Sass最像编程语言的地方——有了条件判断和循环,你就能批量生成样式,再也不用一个一个手写了。

1. @if:聪明的条件判断

根据不同的情况输出不同的样式。

@mixin theme($mode) {
  @if $mode == 'light' {
    background: white;
    color: black;
  } @else if $mode == 'dark' {
    background: #333;
    color: white;
  } @else {
    background: gray;
    color: black;
  }
}

.light-theme { @include theme(light); }
.dark-theme { @include theme(dark); }

这个例子在真实项目中很有用,比如根据主题切换颜色。

2. @for:循环造样式

你写过这样的代码吗?

.m-1 { margin: 4px; }
.m-2 { margin: 8px; }
.m-3 { margin: 12px; }
.m-4 { margin: 16px; }
.m-5 { margin: 20px; }

写了五行手就酸了。用@for:

@for $i from 1 through 5 {
  .m-#{$i} {
    margin: #{$i * 4}px;
  }
}

一行代码生成了五个类,想生成到100也是分分钟的事。through包括结束值,to不包括。

3. @each:遍历列表

如果你要基于一个列表生成样式,比如不同颜色的按钮:

$colors: (primary: #8A2BE2, success: #28a745, danger: #dc3545);

@each $name, $color in $colors {
  .btn-#{$name} {
    background: $color;
    color: white;
    
    &:hover {
      background: darken($color, 10%);
    }
  }
}

一键生成三个按钮样式,想加新的颜色?往列表里加一项就行。

4. @while:用条件控制循环

虽然不如for常用,但遇到动态条件时很有用。比如生成一个步长递增的系列:

$i: 6;
@while $i > 0 {
  .item-#{$i} {
    width: 2px * $i;
  }
  $i: $i - 2;
}

四、实战:用Sass生成一个完整的工具类库

我们来做点实在的。比如你要做一个工具类库,包含外边距、内边距、文字颜色、背景色,而且要有不同的尺寸和状态。手动写?那得写到明年。用Sass的循环和函数,分分钟搞定。

// 定义配置
$spacing-sizes: (0, 4, 8, 12, 16, 20, 24);
$colors: (
  primary: #8A2BE2,
  success: #28a745,
  danger: #dc3545,
  warning: #ffc107
);

// 生成外边距工具类
@each $size in $spacing-sizes {
  .m-#{$size} {
    margin: #{$size}px !important;
  }
  
  .mt-#{$size} {
    margin-top: #{$size}px !important;
  }
  
  .mb-#{$size} {
    margin-bottom: #{$size}px !important;
  }
  
  .ml-#{$size} {
    margin-left: #{$size}px !important;
  }
  
  .mr-#{$size} {
    margin-right: #{$size}px !important;
  }
  
  .mx-#{$size} {
    margin-left: #{$size}px !important;
    margin-right: #{$size}px !important;
  }
  
  .my-#{$size} {
    margin-top: #{$size}px !important;
    margin-bottom: #{$size}px !important;
  }
}

// 生成颜色工具类
@each $name, $color in $colors {
  .text-#{$name} {
    color: $color !important;
  }
  
  .bg-#{$name} {
    background-color: $color !important;
  }
  
  .border-#{$name} {
    border-color: $color !important;
  }
}

这段代码编译后会生成上百个工具类,够你在项目里用一辈子。而且改一个配置,所有类自动更新,爽不爽?

五、进阶技巧:让Sass更上一层楼

1. 使用&选择器的高级玩法

&代表父选择器,除了用在伪类,还能用来生成BEM风格的类名。

.block {
  background: #f5f5f5;
  
  &__element {
    padding: 10px;
  }
  
  &--modifier {
    border: 1px solid red;
  }
}

编译成:

.block { background: #f5f5f5; }
.block__element { padding: 10px; }
.block--modifier { border: 1px solid red; }

完美符合BEM命名规范,还不用手写冗长的类名。

2. 使用@error做校验

在mixin里加参数校验,提前报错,省得调试半天不知道错在哪。

@mixin size($width, $height: $width) {
  @if unitless($width) or unitless($height) {
    @error "width和height必须带单位!";
  }
  
  width: $width;
  height: $height;
}

3. 使用@debug和@warn

调试时输出变量值,或者在即将弃用的样式上给警告。

@debug $primary-color;  // 控制台输出变量值
@warn "这个mixin快过期了,别用了";  // 警告信息

六、总结

Sass真正强大之处,在于它把CSS从“描述语言”变成了“编程语言”。通过今天的内容,你学会了:

  • 模块化:用@use@forward组织代码,告别混乱
  • 内置函数:操作颜色、数字、字符串,让样式自动计算
  • 控制指令@if判断,@for循环,@each遍历,批量生成样式
  • 高级技巧&的妙用,参数校验,调试工具

掌握了这些,你写CSS的效率能提升好几倍。更重要的是,你的样式代码会变得像程序一样有逻辑、可维护。别人还在手动改颜色,你已经用循环生成了整个主题;别人还在复制粘贴,你已经用mixin封装了所有复用逻辑。

明天我们将进入JavaScript的世界,从基础开始重新认识这门“前端灵魂语言”。无论你是想巩固基础,还是查漏补缺,都值得期待。

如果你觉得今天的文章够骚够实用,点个赞让更多人看到。有问题评论区见,我们明天见!

别再乱拷贝了!JS 浅拷贝 vs 深拷贝全解析

在 JavaScript 开发中,对象拷贝是一个绕不开的核心话题。无论是状态管理、数据缓存还是函数参数传递,我们都需要谨慎处理数据的复制方式,避免因引用共享导致意外的数据修改。

本文将结合实际开发场景,详细拆解浅拷贝与深拷贝的区别、实现方式及适用场景。

一、拷贝的本质:引用 vs 新对象

JavaScript中的对象(包括数组、函数等)属于引用类型,变量存储时存储的并非是对象本身,而是对象的引用地址

  • 原始类型拷贝:直接复制值,两个变量互不影响。
  • 引用类型拷贝:如果只是简单赋值(const newObj = obj),本质是复制了对象的引用地址,新旧对象指向同一块内存,修改其中一个会直接影响另一个。

真正的 “拷贝”,是基于原对象创建一个新对象,使新对象与原对象在内存上相互独立。根据拷贝的深度,又分为浅拷贝深拷贝

二、浅拷贝:只复制第一层

浅拷贝(Shallow Copy)只会复制对象的第一层属性,如果属性值是引用类型(如子对象、数组),则仍然复制其引用地址。

核心特点

  • 新对象的第一层属性与原对象隔离。
  • 嵌套的子对象 / 数组仍共享引用,修改子对象会影响原对象

常用实现方式

1. 数组专用方法
  • Array.prototype.slice(0) :创建原数组的浅拷贝。

    const arr = [1, 2, { a: 3 }];
    const newArr = arr.slice(0);
    newArr[2].a = 4; // 会修改原数组的 arr[2].a
    
  • 扩展运算符 ... :ES6 新增,语法更简洁。

    const newArr = [...arr];
    
  • Array.prototype.concat() :合并数组并返回新数组。

    const newArr = [].concat(arr);
    

在一个空数组后拼接原数组并赋值给新数组,这个新数组就可以说是由原数组拷贝所得到的。

  • toReversed()reverse() 方法

toReversed() 反转数组,得到一个新数组reverse() 反转数组,改变原数组。通过这两个方法组合,我们就可以实现浅拷贝的效果。

const newArr=arr.toReversed().reverse()
2. 对象通用方法
  • Object.assign({}, obj) :将原对象的可枚举属性复制到新对象。

    const obj = { a: 1, b: { c: 2 } };
    const newObj = Object.assign({}, obj);
    newObj.b.c = 3; // 原对象 obj.b.c 也会变为 3
    
  • Object.assign():是 JavaScript 中用于对象属性复制与合并的核心方法,它能将一个或多个源对象可枚举属性复制到目标对象中,并返回修改后的目标对象。

    核心语法

Object.assign(target, ...sources)

target是接受属性的目标修改对象,...sources是一个或多个提供属性的对象

代码示例:

const target = { a: 1, b: 2 };
const source = { b: 4, c: 5 }; 
Object.assign(target, source); 
console.log(target); // { a: 1, b: 4, c: 5 }

若目标对象与源对象存在同名属性,后面的源对象属性会覆盖前面的

三、深拷贝:彻底隔离数据

深拷贝(Deep Copy)会递归复制对象的所有层级,包括嵌套的子对象、数组等,最终得到一个与原对象完全独立的新对象,修改新对象不会对原对象产生任何影响。

核心特点

  • 新对象与原对象在内存上完全隔离。
  • 无论修改哪一层属性,都不会影响对方。

常用实现方式

1. JSON.parse(JSON.stringify(obj))

这是最常用的 “民间” 深拷贝方案,先将对象序列化为 JSON 字符串,再反序列化为新对象。

const obj = { a: 1, b: { c: 2 } };
const newObj = JSON.parse(JSON.stringify(obj));
newObj.b.c = 3; // 原对象不受影响

局限性:无法处理函数、SymbolBigIntundefinedNaNInfinityfunction 等特殊类型,且会丢失原型链。

2. structuredClone()

浏览器原生 API,现代浏览器和 Node.js 17+ 支持,是更标准的深拷贝方案。

const newObj = structuredClone(obj);

局限性:无法拷贝函数、Symbol,也不能处理带有循环引用的对象。

四、总结

  • 浅拷贝:高效、轻量,适合处理扁平结构数据,但要注意嵌套引用的问题。
  • 深拷贝:彻底隔离数据,避免副作用,但性能开销更大。
  • 核心原则:根据数据结构和业务场景选择合适的拷贝方式,避免过度设计。

在实际开发中,我们应优先使用浅拷贝保证性能,只有在数据结构复杂且需要完全隔离时,才考虑深拷贝。理解拷贝的本质,是写出健壮、可维护的 JavaScript 代码的关键一步。

MiniMax 发布 M2.7,Agent 开始走向自我进化

我正在开发 DocFlow,它是一个完整的 AI 全栈协同文档平台。该项目融合了多个技术栈,包括基于 Tiptap 的富文本编辑器、NestJs 后端服务、AI 集成功能和实时协作。在开发过程中,我积累了丰富的实战经验,涵盖了 Tiptap 的深度定制、性能优化和协作功能的实现等核心难点。

如果你对 AI 全栈开发、Tiptap 富文本编辑器定制或 DocFlow 项目的完整技术方案感兴趣,欢迎加我微信 yunmz777 进行私聊咨询,获取详细的技术分享和最佳实践。

如果你对 AI全栈 感兴趣,也欢迎添加我微信,我拉你进交流群

3月18日,MiniMax 发布新一代 Agent 旗舰大模型 M2.7。如果只看表面,这像是一次常规的模型升级。但从公开信息来看,M2.7 真正值得关注的,不只是分数更高、能力更强,而是它首次对外展示了一条更具代表性的技术路线,也就是"模型自我进化"。

根据 3 月 18 日公开报道,M2.7 通过构建 Agent Harness 体系,让模型深度参与自身训练与优化流程。在部分研发场景中,这套机制已经可以承担 30% 到 50% 的工作量,并在内部评测集上带来约 30% 的效果提升。这个表述背后释放出的信号很明确,AI 正在从"回答问题的模型"迈向"能够参与迭代自身能力的系统"。

这次发布最重要的,不只是性能提升

过去很长一段时间,行业讨论大模型,重点往往集中在参数规模、训练成本、推理速度以及 benchmark 排名上。但 Agent 时代的竞争逻辑已经开始变化。真正决定模型价值的,越来越不是单点能力,而是它能不能进入真实工作流,承担连续任务,并在执行过程中形成可积累、可复用、可优化的闭环。

M2.7 这次最大的不同,就在于它不再只是研发流程中的被优化对象,而开始成为研发流程中的参与者。

所谓 Agent Harness,可以把它理解为围绕 Agent 构建的一整套执行、反馈、优化机制。模型不只是完成任务,还会进入任务分解、流程回放、错误暴露、策略修正和样本反馈等环节。这样做的意义不在于概念新,而在于它把模型能力提升从"一次性训练结果"推进到了"持续演化过程"。

换句话说,过去我们更熟悉的是"训练一个更强的模型",而 M2.7 想展示的是"让模型参与把自己变得更强"。

工程能力,已经开始逼近一线水位

从公开成绩来看,M2.7 在工程场景上的表现是这次发布的另一大看点。

根据当日披露数据,M2.7SWE-bench Pro 上取得了 56.22% 的成绩。这个指标之所以重要,是因为它衡量的不是简单补全代码,而是真实软件工程环境中的问题理解、代码修改、上下文追踪和任务闭环能力。能在这个测试里打出有竞争力的成绩,说明模型已经不只是"会写代码",而是更接近"能参与工程"。

与此同时,M2.7VIBE-ProTerminal Bench 2 等更接近真实研发流程的测试中也有突出表现。公开说法中提到,它已经能够支持端到端项目交付与复杂系统理解。这一点比单个 benchmark 分数更值得重视,因为真实企业环境看重的从来不是一道题做对,而是模型能否在复杂上下文里持续完成任务。

从研发团队视角看,这意味着 Agent 的角色正在发生变化。它不再只是辅助写一段函数、解释一条报错,而是开始承担更完整的工作单元,比如理解项目结构、分析系统依赖、处理跨文件修改,甚至在终端和工程环境中完成连续操作。

如果这个趋势持续下去,开发团队对 AI 的期待也会随之改变。未来最有价值的模型,不一定是最会答题的那个,而是最能稳定交付结果的那个。

办公场景,开始成为另一条主战线

除了工程能力,M2.7 在办公场景上的提升也非常值得注意。

公开信息显示,它在 GDPval-AA 上取得了 1495 的 ELO 得分,并被描述为开源最高。同时,模型在 Office 文档处理、多轮编辑、复杂内容整理等任务上的表现也有明显增强。

这背后其实说明了一件事,MiniMax 对 M2.7 的定位,并不是单纯的代码模型,而是更偏向通用生产力 Agent。它既要能进入开发流程,也要能进入知识工作和协作流程。因为在真实企业场景里,研发、产品、运营、文档、汇报、分析并不是割裂存在的,大家需要的是一个能够跨场景接手任务的系统,而不是一个只能在单点场景里亮眼的模型。

从这个角度看,办公能力的提升并不是"附加项",而是 Agent 真正走向大规模落地的必要条件。

为什么“自我进化”这四个字值得单独拎出来看

这次发布里,最值得继续观察的,仍然是"模型自我进化"这条路线。

过去行业谈 Agent,经常会关注几个关键词,比如工具调用、长任务拆解、环境感知、记忆能力、多智能体协作。这些能力当然都很重要,但如果只停留在"会不会调用工具"这一层,Agent 的上限其实并不高。

更深的问题在于,当模型已经能完成任务之后,它能不能利用任务执行过程反过来优化自己。

如果答案是可以,那么大模型的发展路径就会发生结构性变化。未来领先的,不只是训练出一个更强基础模型的公司,而是能建立一套完整演化系统的公司。模型做任务,任务产反馈,反馈进入优化,优化再反哺下一轮任务执行。这样的闭环一旦跑顺,AI 的进步速度就不再完全依赖人工标注和传统训练流程,而会更多来自系统自身在真实世界里的持续学习能力。

这也是 M2.7 这次发布最有想象空间的地方。它传递的已经不是简单的"又一个更强模型来了",而是 Agent 正在从工具形态向系统形态迁移。

这次发布意味着什么

M2.7 目前已经在 MiniMax Agent 与开放平台上线。对开发者来说,这意味着相关能力不再只是实验室概念,而是已经开始进入可调用、可接入、可验证的产品阶段。对行业来说,这次发布的意义可能也不止于一次模型升级。

它更像一个明确信号,AI 竞争正在从"谁的模型更会说"进入"谁的系统更会做"。而在"会做"之后,下一个更关键的问题就是,谁能最先构建出真正有效的自我演化闭环。

如果说过去的大模型更像工具,那么 M2.7 想证明的是,Agent 正在变成系统。再往前一步,它甚至可能变成一种具备持续自我改进能力的数字生产力基础设施。

这或许才是 3 月 18 日这场发布最值得被记住的地方。

Vue-Vue Router核心原理+实战用法全解析

前言

无论是单页面应用(SPA)还是复杂的后台管理系统,路由(Router)都是其灵魂。它通过 URL 映射组件,实现了无刷新的页面切换。本文将从底层原生 API 出发,带你彻底弄懂 Vue Router 的运行机制。

一、 路由的本质:Hash vs History

前端路由的核心是:改变 URL,页面不刷新,但渲染不同的组件。 Vue Router 本质上是基于浏览器原生的 window.location.hashhistory API 实现的,通过监听 URL 变化,动态匹配路由规则并渲染对应组件,无需后端参与页面切换。

1. Hash 模式 (window.location.hash)

  • URL 特征:路径中携带# 符号,例如 http://xxx.com/#/homehttp://xxx.com/#/about
  • 底层依赖window.location.hash
  • 核心特性:URL 中 # 后的内容属于锚点定位,不会发送到服务器端,所有前端路由请求最终都会指向 域名/index.html,服务器只需返回首页文件即可。
  • 优势:无需额外配置服务器,刷新页面、直接访问子路由都不会出现 404 错误,兼容性极强。

2. History 模式 (window.history)

  • URL 特征:路径中无 # 符号,形态更简洁,例如 http://xxx.com/homehttp://xxx.com/about
  • 底层依赖:浏览器原生 history API
  • 核心坑点:当用户刷新页面、直接访问子路由时,浏览器会向服务器发送对应路径的 GET 请求(如请求 /home),如果服务器未配置路由指向,会直接返回 404 错误。
  • 解决方案:必须在 Nginx 等服务器中配置规则,将所有路由请求都指向项目入口 index.html,由前端路由接管匹配逻辑。
location / {
  root   /usr/share/nginx/html;
  index  index.html index.htm;
  # 关键:找不到资源时返回 index.html
  try_files $uri $uri/ /index.html; 
}

二、 底层原理实现

1. Hash 模式实现链路

  • 监听变化:基于 windowhashchange 事件,监听 URL 中 hash 值的变化。
  • 设置值:修改 location.hash手动修改路由路径。
  • 跳转:使用 location.assign()实现路由跳转。
  • 获取当前路径:通过 location.hreflocation.hash 解析。

2. History 模式实现链路

  • 监听变化:基于浏览器原生 popstate 事件,仅监听浏览器前进/后退操作触发的路由变化。

    ⚠️ 避坑点:调用 history.pushStatereplaceState 改变 URL 时,并不会触发 popstate。Vue Router 内部通过劫持这些方法手动触发了更新。

  • 操作记录

    • pushState(stateObj, title, url):添加历史记录。
    • replaceState(stateObj, title, url):替换当前记录。
  • 获取路径:基于 window.location.pathname获取纯路径部分。

  • 状态存储:通过 history.state 获取传给 pushState 的自定义对象。


三、 Vue 路由跳转实战

方法一:声明式导航 <router-link>

这是日常开发中最常用的方式,本质是对 <a> 标签的封装,默认无刷新跳转,语法简洁且支持路由参数传递。核心参数如下:

  • to(必传) :目标路由路径,支持字符串格式和对象格式

    • 字符串格式:<router-link to="/home">首页</router-link>
    • 对象格式:可搭配 name、query、params 实现精细化跳转
  • name:通过路由名称跳转(推荐,避免路径硬编码),示例::to="{ name: 'About' }"

  • query:传递查询参数,参数会拼接在 URL 中(刷新不丢失),示例::to="{ name: 'About', query: { name: 'test' } }",最终 URL:/about?name=test

  • params:传递动态路由参数,参数不会拼接在 URL(刷新会丢失),必须配合 name 使用,示例::to="{ name: 'About', params: { id: 123 } }"

    注意:若路由规则中未定义动态参数(如 :id),仅通过 name + params 传参,刷新页面后 params 会丢失;

    解决办法:在路由规则中添加 :id(必传)或 :id?(可选),例如 path: '/about/:id?'

方法二:编程式导航 useRouter

通过 useRouter 获取路由实例,用代码控制路由跳转,适合非点击触发的场景(如接口请求成功后跳转、条件判断跳转、定时器跳转等)

<script setup>
import { useRouter } from 'vue-router'
// 获取路由实例
const router = useRouter()

// 编程式跳转
const goToPage = () => {
  // 1. push 跳转(新增历史记录,可返回)
  router.push('/home')
  // 对象格式跳转
  router.push({ name: 'About', query: { name: 'test' } })

  // 2. replace 跳转(替换历史记录,不可返回)
  router.replace('/about')

  // 3. 路由前进/后退
  router.go(-1) // 后退一页
  router.back() // 后退一页(等价 go(-1))
  router.forward() // 前进一页(等价 go(1))
}
</script>

四、 Vue 路由监听三大方法

Vue 监听路由变化,本质是监听 route 对象(包含 path/params/query 等属性)的变化,触发自定义回调函数,常用于路由切换时更新数据、重置状态等场景.

1. 使用 watch + useRoute

通过 useRoute 获取当前路由对象,搭配 watch 监听器实现路由变化监听,支持立即执行、深度监听,适用性最广。

const route = useRoute();

watch(
  () => route.query,
  (newQuery) => {
    console.log('搜索参数变了:', newQuery);
  },
  { immediate: true, deep: true } // immediate 确保初始化时执行
);

2. 路由守卫 onBeforeRouteUpdate

Vue Router 提供的导航守卫,仅在组件复用时触发(例如 /detail/123/detail/456),路由跳转到其他组件时不会触发,适合列表页跳转详情页等场景。

  • 优点:不需要 watch 那么大的开销,专门针对参数更新。
  • 局限:离开该组件或首次进入时不触发。
<script setup>
import { onBeforeRouteUpdate } from 'vue-router'

// 组件复用时触发
onBeforeRouteUpdate((to, from) => {
  console.log('即将跳转至:', to.path)
  console.log('从:', from.path, '跳转而来')
  // 可在此处更新组件数据
})
</script>

3. 原生监听(底层方案)

直接监听浏览器原生路由事件,脱离 Vue Router API 实现监听,适合特殊定制场景,需注意事件解绑避免内存泄漏。

window.addEventListener('popstate', callback)


Cursor 的 7 个隐藏功能,90% 的人不知道

Cursor 的 7 个隐藏功能,90% 的人不知道

用了 Cursor 半年,从入门到离不开,今天把这些压箱底的技巧都分享给你。

前言

半年前,我第一次打开 Cursor 时,内心是拒绝的。

"不就是加了个 Copilot 的 VS Code 吗?能有多神奇?"

结果现在,我的日常开发已经完全离不开它了。不是因为懒,而是因为它真的能提升效率

但很多人用 Cursor,只停留在最基础的代码补全和聊天功能。这就像买了一辆法拉利,却只用来在小区里代步。

今天,我把这半年来摸索出来的7 个隐藏功能分享给你。每一个都能让你的开发效率提升一个档次。

功能 1:@ 文件引用 —— 精准对话的秘诀

很多人用 Cursor 聊天时,只会说"帮我改一下这个 bug",然后期待 AI 能读懂你的心思。

正确用法:在聊天框中输入 @,然后选择具体的文件。

@src/components/Button.tsx 帮我把这个组件改成支持 dark mode

这样做的好处:

  • AI 只读取你指定的文件,响应更快
  • 避免上下文污染,答案更精准
  • 可以同时引用多个文件,处理复杂问题

进阶技巧

  • @folder/ 引用整个文件夹
  • @docs 引用项目文档
  • @terminal 引用终端输出

功能 2:Composer 模式 —— 多文件同时编辑

这是 Cursor 最被低估的功能,没有之一。

场景:你需要同时修改多个相关文件,比如:

  • 改了一个 API 接口,需要同步更新前端调用
  • 重构了一个函数,需要更新所有引用处
  • 添加新功能,需要同时修改路由、组件、样式

传统方式

  1. 打开文件 A,修改,保存
  2. 打开文件 B,修改,保存
  3. 打开文件 C,修改,保存
  4. 反复切换,生怕漏了哪里

Composer 方式

  1. Cmd+I 打开 Composer
  2. 输入需求:"添加用户注销功能,包括 API、按钮和路由"
  3. Cursor 自动分析需要修改的文件
  4. 一次性生成所有修改,统一预览,统一确认

真实案例: 我上次重构一个认证模块,涉及 12 个文件。用 Composer,15 分钟搞定,而且没有遗漏任何地方。


功能 3:Diff 预览 —— 改代码前先看一眼

Cursor 修改代码时,会生成一个差异预览,让你清楚地看到:

  • 哪些代码会被删除(红色)
  • 哪些代码会被添加(绿色)
  • 哪些代码保持不变

为什么重要

  • 避免 AI 瞎改,把控质量
  • 快速理解修改内容,学习机会
  • 发现潜在问题,提前规避

我的习惯: 无论多小的修改,都会先仔细看 Diff。有几次就是靠这个发现了 AI 的"想当然"错误。


功能 4:Rules 规则 —— 让 AI 记住你的偏好

这是 Cursor 的杀手级功能,但很多人不知道。

问题:每次聊天都要重复说明:

  • "用 TypeScript"
  • "遵循我们的代码规范"
  • "不要用 any 类型"
  • "函数要有 JSDoc 注释"

解决方案:在项目根目录创建 .cursorrules 文件:

# 项目规则

## 技术栈
- React 18 + TypeScript
- Tailwind CSS
- Zustand 状态管理

## 代码规范
- 所有函数必须有类型注解
- 禁止使用 any 类型
- 组件用函数式写法
- 用 ES Module 导入导出

## 命名规范
- 组件用 PascalCase
- 函数用 camelCase
- 常量用 UPPER_SNAKE_CASE

## 注释要求
- 公共 API 必须有 JSDoc
- 复杂逻辑要有行内注释

效果: 从此以后,AI 生成的所有代码都会自动遵循这些规则,不需要每次重复说明。

功能 5:Inline Edit —— 选中即改

这个功能我用得最频繁。

场景:看到一段代码不顺眼,想优化一下。

传统方式

  1. 复制代码
  2. 打开聊天窗口
  3. 粘贴
  4. 说明需求
  5. 等待生成
  6. 复制回来
  7. 替换

Inline Edit 方式

  1. 选中代码
  2. Cmd+K
  3. 输入"优化这段代码,提高可读性"
  4. 直接原地替换

效率对比

  • 传统方式:2-3 分钟
  • Inline Edit:30 秒

功能 6:Chat with Codebase —— 理解整个项目

接手新项目时,这个功能能帮你快速上手

用法: 在聊天框中输入 /codebase,然后提问:

/codebase 这个项目的认证流程是怎么实现的?
/codebase 帮我找到所有调用用户 API 的地方
/codebase 项目的目录结构是怎样的?

原理: Cursor 会索引整个项目的代码结构,然后基于索引回答问题。

真实体验: 我上次接手一个 5 万行代码的项目,用这个功能,2 小时就搞懂了核心逻辑。换作以前,至少需要一周。


功能 7:Debug with AI —— 边调试边问

这是最近新增的功能,但已经成了我的调试首选

场景:代码报错了,不知道哪里出了问题。

传统方式

  1. 看错误信息
  2. 猜可能的问题
  3. 加 console.log
  4. 重新运行
  5. 重复以上步骤 N 次

Cursor 方式

  1. 选中错误代码
  2. Cmd+K → "帮我分析这个错误"
  3. AI 直接指出问题所在,并给出修复建议

真实案例: 上周遇到一个诡异的 TypeScript 类型错误,我自己看了半小时没头绪。用 Cursor,30 秒定位问题:是一个泛型约束写错了。


避坑指南

用了半年,也踩过不少坑。分享几个注意事项:

坑 1:过度依赖 AI

问题:什么都让 AI 写,自己不动脑。

后果

  • 代码能力退化
  • 遇到复杂问题不会自己解决
  • 面试时露馅

建议

  • AI 生成的代码,一定要看懂
  • 核心逻辑,自己写
  • AI 用来提效,不是替代

坑 2:不审查就提交

问题:AI 生成的代码直接 commit。

后果

  • 引入隐蔽 bug
  • 代码风格不统一
  • 技术债务累积

建议

  • 所有 AI 代码,必须审查
  • 运行测试,确保通过
  • 不符合规范,手动调整

坑 3:忽视上下文限制

问题:一次性扔给 AI 太多代码。

后果

  • AI 记不住前面的内容
  • 回答质量下降
  • 浪费 token

建议

  • @ 精准引用相关文件
  • 复杂问题分步提问
  • 定期清空对话,重新开始

效率对比

用这 7 个功能前后,我的开发效率变化:

任务 之前 之后 提升
新功能开发 4 小时 1.5 小时 62%
Bug 修复 1 小时 20 分钟 67%
代码审查 30 分钟 10 分钟 67%
接手新项目 1 周 2 天 60%

平均效率提升: 65%

这不是说我可以少工作了,而是可以把时间花在更有价值的事情上:

  • 架构设计
  • 性能优化
  • 技术调研
  • 当然,还有摸鱼 😄

结语

Cursor 不是银弹,但它确实是目前最好用的 AI 编程工具

关键是,你要会用,而不是乱用

今天分享的这 7 个功能,每一个都是我亲测有效的。建议你:

  1. 先挑 1-2 个最感兴趣的试试
  2. 用顺手了再尝试其他的
  3. 形成自己的工作流

最后

工具再好,也只是工具。真正的核心竞争力,还是你的技术功底解决问题的能力

AI 是用来放大你的能力,不是替代你的思考。


互动

  • 你用过 Cursor 吗?最喜欢哪个功能?
  • 你还知道哪些隐藏技巧?评论区分享一下!
  • 想看更多 AI 编程实战内容?点赞 + 关注,持续更新!

json-render:Generative UI 的终极框架 —— 让 AI 安全地生成界面

引言:当 AI 想要"画"界面

如果你用过 ChatGPT 或 Claude,你会发现它们回复的都是文字——无论多复杂的数据,最终呈现给用户的要么是 Markdown,要么是代码块。这就像请了一个天才设计师,却只允许他用打字机工作。

如果 AI 能直接生成界面本身呢? 不是生成描述界面的代码,而是生成一个可以立即渲染的 UI 结构?

这就是 Generative UI 的愿景,也正是 Vercel 开源的 json-render 要解决的核心问题。


一、传统方式的困境

为什么不直接让 AI 生成 React 代码?

最直觉的做法是让 LLM 直接输出 JSX 或 HTML,然后 eval 执行。但这条路有三个致命缺陷:

❌ 安全性  → AI 生成的代码可能包含任意 JavaScript 执行
❌ 可预测性 → LLM 可能"幻觉"出不存在的组件、无效的属性
❌ 跨平台  → React 代码无法直接跑在 React Native / Vue / Svelte 上

json-render 的核心洞察是:不要让 AI 生成代码,让它生成数据(JSON)。这份 JSON 严格约束在你预定义的组件范围内,然后由各平台的渲染器将其转化为原生 UI。

一句话概括:你设置围栏,AI 在围栏里自由发挥。


二、全局架构总览

在深入细节之前,先用两张图建立全局认知。

2.1 核心工作流(三步走)

┌─────────────┐    ┌─────────────────┐    ┌───────────────┐    ┌──────────────┐
│  用户 Prompt │───▶│ AI + Catalog     │───▶│  JSON Spec    │───▶│  Renderer    │
│ "创建仪表盘" │    │ (受限生成)       │    │ (结构化数据)   │    │ (原生UI)     │
└─────────────┘    └─────────────────┘    └───────────────┘    └──────────────┘
                        │                       │                     │
                   ✅ 有围栏的              ✅ 可预测的           ✅ 可流式的

第一步:你定义 Catalog("AI 能用什么组件和动作") 第二步:AI 根据 Catalog 的约束生成 JSON Spec("用这些组件搭出什么界面") 第三步:Renderer 把 JSON Spec 渲染成原生 UI("在屏幕上画出来")

2.2 包架构全景图

                        ┌─────────────────────────────────┐
                        │       @json-render/core          │
                        │  (Schema, Catalog, Prompt,       │
                        │   Props, Visibility, State,      │
                        │   SpecStream, Validation)        │
                        └───────────────┬─────────────────┘
                                        │
              ┌────────────┬────────────┼────────────┬──────────────┐
              ▼            ▼            ▼            ▼              ▼
     ┌──────────────┐ ┌────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐
     │@json-render/ │ │  /vue  │ │ /svelte  │ │  /solid  │ │ /react-native│
     │    react     │ │        │ │          │ │          │ │              │
     └──────┬───────┘ └────────┘ └──────────┘ └──────────┘ └──────────────┘
            │
   ┌────────┼──────────┬───────────┬──────────┬────────────┐
   ▼        ▼          ▼           ▼          ▼            ▼
┌───────┐┌────────┐┌─────────┐┌────────┐┌──────────┐┌────────────┐
│/shadcn││/remotion││/react-  ││/react- ││  /image  ││/react-three│
│(36个  ││(视频)   ││  pdf    ││ email  ││(SVG/PNG) ││  -fiber    │
│组件)  ││        ││(PDF)    ││(邮件)  ││          ││  (3D)      │
└───────┘└────────┘└─────────┘└────────┘└──────────┘└────────────┘

状态管理适配器: /redux  /zustand  /jotai  /xstate
其他工具:       /codegen  /mcp  /yaml

@json-render/core 是与框架无关的核心层,包含所有共享逻辑。各渲染器只负责将 JSON Spec 映射为各自平台的原生组件。


三、Schema / Catalog / Spec —— 先搞清这三兄弟

这三个概念经常被混淆,用一个类比就能记住:

┌──────────────────────────────────────────────────────┐
│  类比:写作文                                         │
│                                                      │
│  Schema  = 语法规则(主谓宾怎么排列)                   │
│  Catalog = 词汇表 (你能用哪些词)                      │
│  Spec    = 作文本身(AI 按语法用词汇写出的文章)          │
└──────────────────────────────────────────────────────┘

Schema 定义 JSON 的骨架结构。内置的 React schema 使用扁平元素树:一个 root 键 + 一个 elements map。这种扁平结构是刻意设计的——比深层嵌套更适合 AI 生成和流式传输。

Catalog 定义"词汇"——有哪些组件、各自接受什么属性、有哪些可用动作。用 Zod 做类型约束。

Spec 就是 AI 最终产出的 JSON 文档,遵守 Schema 的结构,使用 Catalog 中的组件。


四、从零开始:Hello World(🌱 入门级)

4.1 安装

npm install @json-render/core @json-render/react

4.2 最简三步

// ① 定义 Catalog —— "AI 能用什么"
import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react/schema';
import { z } from 'zod';

const catalog = defineCatalog(schema, {
  components: {
    Card: {
      props: z.object({ title: z.string() }),
      slots: ["default"],
      description: "容器卡片",
    },
    Text: {
      props: z.object({ content: z.string() }),
      description: "文本段落",
    },
  },
  actions: {},
});

// ② 定义 Registry —— "组件长什么样"
import { defineRegistry, Renderer } from '@json-render/react';

const { registry } = defineRegistry(catalog, {
  components: {
    Card: ({ props, children }) => (
      <div style={{ border: '1px solid #ddd', padding: 16, borderRadius: 8 }}>
        <h2>{props.title}</h2>
        {children}
      </div>
    ),
    Text: ({ props }) => <p>{props.content}</p>,
  },
});

// ③ 渲染一份手写的 Spec
const spec = {
  root: "card-1",
  elements: {
    "card-1": {
      type: "Card",
      props: { title: "Hello json-render!" },
      children: ["text-1"],
    },
    "text-1": {
      type: "Text",
      props: { content: "这是我的第一个 json-render 界面" },
      children: [],
    },
  },
};

function App() {
  return <Renderer spec={spec} registry={registry} />;
}

这就是全部!即使不接入 AI,json-render 也能作为一个 JSON 驱动的 UI 渲染引擎使用。

4.3 渲染流程图解

spec (JSON)
  │
  ├── root: "card-1"
  │
  └── elements:
        │
        ├── "card-1" ──▶ type: "Card" ──▶ registry 查到 Card 组件 ──▶ <div>
        │                 props.title: "Hello"                        <h2>Hello</h2>
        │                 children: ["text-1"]                        ↓ 递归渲染子节点
        │
        └── "text-1" ──▶ type: "Text" ──▶ registry 查到 Text 组件 ──▶ <p>第一个界面</p>

Renderer 读取 root,在 elements map 中查找该元素,匹配 registry 中的组件实现,递归渲染 children 引用的子元素。


五、接入 AI 生成(🌿 进阶级)

5.1 数据流全景

┌──────────┐  prompt   ┌───────────┐  system prompt   ┌──────────┐
│  浏览器   │─────────▶│  API Route │───────────────▶│   LLM    │
│  (React)  │          │  (Next.js) │                 │(Claude等)│
│           │◀─────────│            │◀────────────────│          │
│  useUI    │ JSONL    │  stream    │  JSONL patches  │          │
│  Stream   │ patches  │  Text     │                 │          │
└──────────┘          └───────────┘                 └──────────┘
     │
     ▼
  Renderer ──▶ 原生 UI(边生成边渲染)

5.2 服务端:API Route

// app/api/generate/route.ts
import { streamText } from 'ai';
import { catalog } from '@/lib/catalog';

export async function POST(req: Request) {
  const { prompt } = await req.json();

  const result = streamText({
    model: 'anthropic/claude-haiku-4.5',
    system: catalog.prompt(),   // ← 自动从 Catalog 生成 system prompt
    prompt,
  });

  return result.toTextStreamResponse();
}

catalog.prompt() 是关键——它把你的组件定义、属性约束、可用动作全部转化为 LLM 能理解的 system prompt,告诉 AI "你只能用这些积木"。

5.3 客户端:流式渲染

'use client';
import { Renderer, StateProvider, VisibilityProvider, useUIStream } from '@json-render/react';
import { registry } from '@/lib/registry';

export default function Page() {
  const { spec, isStreaming, send } = useUIStream({
    api: '/api/generate',
  });

  return (
    <StateProvider initialState={{}}>
      <VisibilityProvider>
        <input
          placeholder="描述你想要的界面..."
          onKeyDown={(e) => {
            if (e.key === 'Enter') send(e.currentTarget.value);
          }}
        />
        <Renderer spec={spec} registry={registry} loading={isStreaming} />
      </VisibilityProvider>
    </StateProvider>
  );
}

用户输入 "创建一个登录表单",AI 会流式输出类似这样的 JSONL:

{"op":"add","path":"/root","value":"card-1"}
{"op":"add","path":"/elements/card-1","value":{"type":"Card","props":{"title":"登录"},"children":["email","pwd","btn"]}}
{"op":"add","path":"/elements/email","value":{"type":"Input","props":{"label":"邮箱","name":"email","type":"email"}}}
{"op":"add","path":"/elements/pwd","value":{"type":"Input","props":{"label":"密码","name":"password","type":"password"}}}
{"op":"add","path":"/elements/btn","value":{"type":"Button","props":{"label":"登录"}}}

每一行到达,UI 就多渲染一个组件,用户看到界面在眼前"生长"出来。

5.4 秒用 shadcn/ui —— 36 个开箱即用组件

不想从头写组件?直接用预构建的 shadcn/ui 套件:

import { shadcnComponentDefinitions } from '@json-render/shadcn/catalog';
import { shadcnComponents } from '@json-render/shadcn';

// Catalog:从 36 个组件中挑选你需要的
const catalog = defineCatalog(schema, {
  components: {
    Card: shadcnComponentDefinitions.Card,
    Button: shadcnComponentDefinitions.Button,
    Input: shadcnComponentDefinitions.Input,
    Table: shadcnComponentDefinitions.Table,
    // ... 一共 36 个可选
  },
  actions: {},
});

// Registry:对应实现一一映射
const { registry } = defineRegistry(catalog, {
  components: {
    Card: shadcnComponents.Card,
    Button: shadcnComponents.Button,
    Input: shadcnComponents.Input,
    Table: shadcnComponents.Table,
  },
});

从 Accordion 到 Tooltip,Table 到 LineGraph,基本覆盖了 Web 应用的全部常见 UI 元素。


六、数据绑定 —— 让界面活起来(🌿 进阶级)

静态 JSON 只是起点。json-render 的表达式系统让 AI 生成的界面能绑定到运行时数据。

6.1 表达式速查表

┌──────────────┬──────────────────────────────────────┬──────────────┐
│  表达式       │  语法                                │  用途         │
├──────────────┼──────────────────────────────────────┼──────────────┤
│  $state      │  { "$state": "/user/name" }          │  读取状态     │
│  $bindState  │  { "$bindState": "/form/email" }     │  双向绑定     │
│  $item       │  { "$item": "title" }                │  列表项字段   │
│  $index      │  { "$index": true }                  │  列表项索引   │
│  $cond       │  { "$cond":..,"$then":..,"$else":..} │  条件选择     │
│  $template   │  { "$template": "Hi, ${/user/name}!" │  字符串插值   │
│  $computed   │  { "$computed": "fn", "args": {...} } │  计算函数     │
└──────────────┴──────────────────────────────────────┴──────────────┘

6.2 实战:带状态的设置表单

这个例子展示了 $bindState 双向绑定——表单组件既能读取状态,也能写回状态:

{
  "root": "card",
  "state": {
    "name": "Ada Lovelace",
    "email": "ada@example.com",
    "notifications": true
  },
  "elements": {
    "card": {
      "type": "Card",
      "props": { "title": "账户设置" },
      "children": ["nameInput", "emailInput", "notifSwitch"]
    },
    "nameInput": {
      "type": "Input",
      "props": {
        "label": "姓名",
        "name": "name",
        "value": { "$bindState": "/name" }
      }
    },
    "emailInput": {
      "type": "Input",
      "props": {
        "label": "邮箱",
        "name": "email",
        "type": "email",
        "value": { "$bindState": "/email" }
      }
    },
    "notifSwitch": {
      "type": "Switch",
      "props": {
        "label": "接收邮件通知",
        "name": "notifications",
        "checked": { "$bindState": "/notifications" }
      }
    }
  }
}

所有路径都是 JSON Pointer(RFC 6901):/name 指向 state.name/notifications 指向 state.notifications。用户在输入框里修改内容,状态自动更新;状态变化后,所有引用该路径的组件自动重新渲染。

6.3 实战:repeat 列表渲染

{
  "root": "todo-list",
  "state": {
    "todos": [
      { "id": "1", "title": "买牛奶", "done": false },
      { "id": "2", "title": "遛狗",   "done": true }
    ]
  },
  "elements": {
    "todo-list": {
      "type": "Stack",
      "props": { "direction": "vertical", "gap": "sm" },
      "repeat": { "statePath": "/todos", "key": "id" },
      "children": ["todo-item"]
    },
    "todo-item": {
      "type": "Card",
      "props": {
        "title": { "$item": "title" }
      },
      "children": ["toggle"]
    },
    "toggle": {
      "type": "Switch",
      "props": {
        "label": "完成",
        "checked": { "$bindItem": "done" }
      }
    }
  }
}

repeat 告诉渲染器:"遍历 /todos 数组,每一项都渲染 todo-item 和它的子元素"。$item 读取当前项的字段,$bindItem 实现列表项内的双向绑定。

6.4 表达式解析流程

原始 props(含表达式)
  │
  ▼
resolvePropValue()  ← core/props.ts 中的核心函数
  │
  ├── 是 { $state } ?  → getByPath(stateModel, path) 读值
  ├── 是 { $bindState } ?  → 读值 + 暴露路径给组件写回
  ├── 是 { $item } ?  → 从 repeatItem 中读字段
  ├── 是 { $index } ?  → 返回当前循环索引
  ├── 是 { $cond } ?  → evaluateVisibility(条件) → 选 $then$else
  ├── 是 { $template } ?  → 正则替换 ${/path} 为状态值
  ├── 是 { $computed } ?  → 找到注册函数 → 递归解析 args → 调用函数
  ├── 是数组?  → 递归解析每个元素
  ├── 是普通对象?  → 递归解析每个值
  └── 其他  → 原样返回(字面量)

所有表达式的解析在单次遍历中完成,且支持任意嵌套深度。


七、条件可见性(🌿 进阶级)

visible 字段让 AI 生成的界面可以根据状态条件显示/隐藏元素,而不需要写一行逻辑代码。

7.1 简单条件

{
  "type": "Alert",
  "props": { "message": "表单有错误" },
  "visible": { "$state": "/form/hasErrors" }
}

/form/hasErrors 为真值时显示。

7.2 组合条件(AND + OR)

{
  "type": "Button",
  "props": { "label": "退款" },
  "visible": [
    { "$state": "/auth/isSignedIn" },
    { "$state": "/user/role", "eq": "support" },
    { "$state": "/order/amount", "gt": 0 },
    { "$state": "/order/isRefunded", "not": true }
  ]
}

数组 = 隐式 AND。这个按钮只在"已登录 + 角色为客服 + 订单金额 > 0 + 未被退款"时才可见。

7.3 条件求值引擎

visible 条件
  │
  ▼  evaluateVisibility() ← core/visibility.ts
  │
  ├── undefined → true(无条件 = 可见)
  ├── boolean → 直接返回
  ├── 数组 → 隐式 AND(every)
  ├── { $and } → 显式 AND(every,支持嵌套)
  ├── { $or }  → OR(some,支持嵌套)
  └── 单条件 → evaluateCondition()
                  │
                  ├── 无运算符 → Boolean(value) 真值判断
                  ├── eq / neq → 相等 / 不等
                  ├── gt / gte / lt / lte → 数值比较
                  └── not: true → 对结果取反

八、高级特性实战(🔥 高级)

8.1 Watchers + $computed:级联选择器

这是仓库 examples/no-ai 中的真实示例。当用户选择国家时,城市列表自动更新:

{
  "root": "card",
  "state": {
    "form": { "country": "", "city": "" },
    "availableCities": []
  },
  "elements": {
    "card": {
      "type": "Card",
      "props": { "title": "收货地址" },
      "children": ["countrySelect", "citySelect", "preview"]
    },
    "countrySelect": {
      "type": "Select",
      "props": {
        "label": "国家",
        "options": ["US", "Canada", "UK", "Germany", "Japan"],
        "value": { "$bindState": "/form/country" }
      },
      "watch": {
        "/form/country": [
          {
            "action": "setState",
            "params": {
              "statePath": "/availableCities",
              "value": {
                "$computed": "citiesForCountry",
                "args": { "country": { "$state": "/form/country" } }
              }
            }
          },
          {
            "action": "setState",
            "params": { "statePath": "/form/city", "value": "" }
          }
        ]
      }
    },
    "citySelect": {
      "type": "Select",
      "props": {
        "label": "城市",
        "options": { "$state": "/availableCities" },
        "value": { "$bindState": "/form/city" }
      }
    },
    "preview": {
      "type": "Heading",
      "props": {
        "text": {
          "$computed": "formatAddress",
          "args": {
            "city": { "$state": "/form/city" },
            "country": { "$state": "/form/country" }
          }
        },
        "level": "h3"
      }
    }
  }
}

交互流程图:

用户选择 "Japan"
  │
  ▼ $bindState 写入 /form/country = "Japan"
  │
  ▼ watch 触发
  │
  ├── ① setState: /availableCities = citiesForCountry("Japan")
  │                                   → ["Tokyo","Osaka","Kyoto",...]
  │
  └── ② setState: /form/city = "" (重置城市选择)
  │
  ▼ citySelect 的 options 读取 $state: /availableCities → 下拉更新
  ▼ preview 的 $computed: formatAddress 重新计算 → 显示 "Japan"

注册 $computed 函数:

const computedFunctions = {
  citiesForCountry: (args) => {
    const cityData = { US: ["New York", "LA"], Japan: ["Tokyo", "Osaka"] };
    return cityData[args.country] ?? [];
  },
  formatAddress: (args) => {
    if (!args.city && !args.country) return "未选择地址";
    if (!args.city) return args.country;
    return `${args.city}, ${args.country}`;
  },
};

8.2 跨字段表单验证 + validateForm

注册表单示例,展示了 json-render 的完整表单能力:

{
  "type": "Input",
  "props": {
    "label": "确认密码",
    "type": "password",
    "value": { "$bindState": "/form/confirmPassword" },
    "checks": [
      { "type": "required", "message": "请确认密码" },
      {
        "type": "matches",
        "args": { "other": { "$state": "/form/password" } },
        "message": "两次密码不一致"
      }
    ],
    "validateOn": "blur"
  }
}

提交按钮使用内置的 validateForm 动作一键校验所有字段:

{
  "type": "Button",
  "props": { "label": "注册" },
  "on": {
    "press": [
      { "action": "validateForm", "params": { "statePath": "/result" } }
    ]
  }
}

验证结果写入 /result,然后用 $cond 条件显示不同的提示:

{
  "type": "Alert",
  "props": {
    "title": "验证结果",
    "message": {
      "$cond": { "$state": "/result/valid", "eq": true },
      "$then": "所有字段验证通过,可以提交!",
      "$else": "请修正上方的错误后再提交。"
    },
    "type": {
      "$cond": { "$state": "/result/valid", "eq": true },
      "$then": "success",
      "$else": "error"
    }
  },
  "visible": { "$state": "/result", "neq": null }
}

8.3 Inline 模式:聊天中的 Generative UI

仓库的 examples/chat 展示了最接近生产的用法——AI 聊天机器人在对话中嵌入动态 UI:

┌──────────────────────────────────────────────────┐
  用户: 比较纽约、伦敦和东京的天气                     
├──────────────────────────────────────────────────┤
  AI: 这是三个城市的实时天气对比:                     
                                                  
  ┌────────────┐ ┌────────────┐ ┌────────────┐   
    New York      London       Tokyo        
     22°C ☀️      15°C 🌧      28°C       
    Humidity:     Humidity:    Humidity:    
      65%           82%          70%        
  └────────────┘ └────────────┘ └────────────┘   
                                                  
  纽约今天晴朗适合户外活动...                         
└──────────────────────────────────────────────────┘

服务端使用 pipeJsonRender 分离文字和 JSONL patch:

import { pipeJsonRender } from '@json-render/core';

const stream = createUIMessageStream({
  execute: async ({ writer }) => {
    writer.merge(pipeJsonRender(result.toUIMessageStream()));
  },
});

客户端用 useJsonRenderMessage 从聊天消息中提取 spec:

function ChatMessage({ message }) {
  const { spec, text, hasSpec } = useJsonRenderMessage(message.parts);

  return (
    <div>
      {/* 文字部分正常渲染 */}
      {text && <p>{text}</p>}
      {/* UI 部分用 Renderer 渲染 */}
      {hasSpec && <Renderer spec={spec} registry={registry} />}
    </div>
  );
}

8.4 自定义 Action Handler:安全的交互模型

Actions 是 json-render 安全性的关键。AI 不生成代码,只声明意图:

┌──────────┐  JSON声明      ┌──────────────┐  实际执行     ┌──────────┐
│   AI     │───────────────▶│  Action 名称  │──────────────▶│ 你的代码  │
│"触发     │ { action:      │ "submitForm"  │ handler 里    │ fetch()  │
│ submit""submitForm" }│              │ 才有真正逻辑   │ 处理业务  │
└──────────┘               └──────────────┘              └──────────┘
     ❌ 不生成代码               ✅ 只是个名字              ✅ 你完全控制
const { registry, handlers } = defineRegistry(catalog, {
  components: { /* ... */ },
  actions: {
    submitForm: async (params, setState) => {
      const res = await fetch('/api/submit', {
        method: 'POST',
        body: JSON.stringify(params),
      });
      const result = await res.json();
      setState((prev) => ({ ...prev, formResult: result }));
    },
    confetti: () => {
      // 放烟花!🎉
      confettiListener?.();
    },
  },
});

九、状态管理深潜(🔥 高级)

9.1 内置 StateStore 的工作原理

immutableSetByPath("/user/name", "Bob")
  │
  ├── 解析 JSON Pointer → ["user", "name"]
  ├── 浅拷贝 root → { ...root }
  ├── 浅拷贝 root.user → { ...root.user }  ← 只拷贝受影响路径
  ├── 设置 root.user.name = "Bob"
  └── 通知所有订阅者 → React 重新渲染

使用结构共享(structural sharing),只浅拷贝变更路径上的对象,未改变的分支保持原引用。这意味着 React 的 === 比较能正确跳过未变化部分。

9.2 接入外部状态管理

通过 createStoreAdapter 可以接入任何外部状态库,只需提供三个回调:

import { createStoreAdapter } from '@json-render/core';

// 只需实现 3 个方法
const store = createStoreAdapter({
  getSnapshot: () => myZustandStore.getState(),
  setSnapshot: (next) => myZustandStore.setState(next),
  subscribe: (listener) => myZustandStore.subscribe(listener),
});

官方已提供 Redux、Zustand、Jotai、XState 四个适配器包。


十、跨平台能力矩阵

同一份 Catalog 定义,可以驱动完全不同的输出:

┌─────────────┬────────────────────────────────────┐
│  渲染器      │  输出                              │
├─────────────┼────────────────────────────────────┤
│  /react     │  浏览器 DOM                         │
│  /vue       │  Vue 3 组件树                       │
│  /svelte    │  Svelte 5 组件树(runes 响应式)     │
│  /solid     │  SolidJS 细粒度响应式组件            │
│  /react-native │  iOS/Android 原生视图            │
│  /shadcn    │  36 个精美预构建组件(Radix+Tailwind)│
│  /react-pdf │  PDF 文档(发票、报告)              │
│  /react-email│ HTML 邮件                          │
│  /remotion  │  视频合成(时间轴+轨道+转场)        │
│  /image     │  SVG/PNG(OG 图、社交卡片)          │
│  /react-three-fiber │ 3D 场景(19 个内置组件)    │
└─────────────┴────────────────────────────────────┘

生成 PDF 示例:

import { renderToBuffer } from '@json-render/react-pdf';

const spec = {
  root: "doc",
  elements: {
    doc: { type: "Document", props: { title: "发票" }, children: ["page-1"] },
    "page-1": { type: "Page", props: { size: "A4" }, children: ["heading", "table"] },
    heading: { type: "Heading", props: { text: "发票 #1234", level: "h1" } },
    table: {
      type: "Table",
      props: {
        columns: [
          { header: "商品", width: "60%" },
          { header: "价格", width: "40%", align: "right" },
        ],
        rows: [["Widget A", "¥68.00"], ["Widget B", "¥172.00"]],
      },
    },
  },
};

const buffer = await renderToBuffer(spec);

生成 OG 图片:

import { renderToPng } from '@json-render/image/render';

const png = await renderToPng(spec, { fonts });

十一、两种生成模式对比

┌────────────────────┬────────────────────────────────┐
│   Standalone 模式   │        Inline 模式             │
├────────────────────┼────────────────────────────────┤
│  AI 只输出 JSONL    │  AI 先写文字,需要时嵌入 JSONL   │
│  整个页面都是 UIUI 内嵌在聊天对话中              │
│  适合:Playground   │  适合:聊天机器人 / Copilot      │
│       仪表盘构建器   │       教育助手 / 智能客服        │
│       表单生成器     │                                │
├────────────────────┼────────────────────────────────┤
│  catalog.prompt()   │  catalog.prompt({mode:"inline"})│
│  useUIStream        │  pipeJsonRender + useChat       │
└────────────────────┴────────────────────────────────┘

十二、设计哲学总结

┌──────────────────────────────────────────────────────────────┐
│                    json-render 设计原则                        │
├──────────────┬───────────────────────────────────────────────┤
│  数据非代码   │ AI 生成 JSON 而非可执行代码,消除安全风险        │
│  契约优先    │ Catalog = AI 与应用之间的严格契约,              │
│             │ Zod schema 保证编译时 + 运行时双重类型安全        │
│  渐进增强    │ 从最简单的静态渲染开始,逐步加入数据绑定、        │
│             │ 条件可见性、动作处理、表单验证等能力              │
│  平台无关核心 │ core 包含所有共享逻辑(表达式解析、可见性        │
│             │ 求值、状态管理、流编译),渲染器只做组件映射       │
│  声明式交互   │ AI 声明意图(action 名称),开发者提供实现,     │
│             │ 永远不会有未经授权的代码执行                     │
└──────────────┴───────────────────────────────────────────────┘

十三、完整实战:从零搭建一个 AI Dashboard Builder

下面把所有知识串起来,用一个完整示例展示 json-render 在真实项目中的全貌。

13.1 项目结构

my-dashboard/
├── app/
│   ├── api/generate/route.ts    ← AI 生成接口
│   └── page.tsx                 ← 前端页面
├── lib/
│   ├── catalog.ts               ← 组件目录定义
│   └── registry.tsx             ← 组件实现 + 动作处理
└── package.json

13.2 Catalog:定义 AI 的"工具箱"

// lib/catalog.ts
import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react/schema';
import { shadcnComponentDefinitions } from '@json-render/shadcn/catalog';
import { z } from 'zod';

export const catalog = defineCatalog(schema, {
  components: {
    // 布局类
    Card:    shadcnComponentDefinitions.Card,
    Stack:   shadcnComponentDefinitions.Stack,
    Grid:    shadcnComponentDefinitions.Grid,
    // 展示类
    Heading: shadcnComponentDefinitions.Heading,
    Text:    shadcnComponentDefinitions.Text,
    Badge:   shadcnComponentDefinitions.Badge,
    Table:   shadcnComponentDefinitions.Table,
    // 图表类
    BarGraph:  shadcnComponentDefinitions.BarGraph,
    LineGraph: shadcnComponentDefinitions.LineGraph,
    // 交互类
    Button:  shadcnComponentDefinitions.Button,
    Input:   shadcnComponentDefinitions.Input,
    Select:  shadcnComponentDefinitions.Select,
    // 反馈类
    Alert:   shadcnComponentDefinitions.Alert,
    Progress: shadcnComponentDefinitions.Progress,
  },
  actions: {
    refresh_data: {
      params: z.object({ source: z.string() }),
      description: '刷新指定数据源',
    },
    export_report: {
      params: z.object({ format: z.enum(['csv', 'pdf']) }),
      description: '导出报告',
    },
  },
  functions: {
    formatCurrency: {
      description: '将数字格式化为货币',
    },
  },
});

13.3 Registry:组件实现 + 动作处理

// lib/registry.tsx
import { defineRegistry } from '@json-render/react';
import { shadcnComponents } from '@json-render/shadcn';
import { catalog } from './catalog';
import type { ComputedFunction } from '@json-render/core';

export const { registry, handlers } = defineRegistry(catalog, {
  components: {
    Card:      shadcnComponents.Card,
    Stack:     shadcnComponents.Stack,
    Grid:      shadcnComponents.Grid,
    Heading:   shadcnComponents.Heading,
    Text:      shadcnComponents.Text,
    Badge:     shadcnComponents.Badge,
    Table:     shadcnComponents.Table,
    BarGraph:  shadcnComponents.BarGraph,
    LineGraph: shadcnComponents.LineGraph,
    Button:    shadcnComponents.Button,
    Input:     shadcnComponents.Input,
    Select:    shadcnComponents.Select,
    Alert:     shadcnComponents.Alert,
    Progress:  shadcnComponents.Progress,
  },
  actions: {
    refresh_data: async (params, setState) => {
      const res = await fetch(`/api/data?source=${params.source}`);
      const data = await res.json();
      setState((prev) => ({ ...prev, [params.source]: data }));
    },
    export_report: async (params) => {
      const blob = await fetch(`/api/export?format=${params.format}`)
        .then(r => r.blob());
      const url = URL.createObjectURL(blob);
      window.open(url);
    },
  },
});

export const computedFunctions: Record<string, ComputedFunction> = {
  formatCurrency: (args) => {
    const value = Number(args.value ?? 0);
    return new Intl.NumberFormat('zh-CN', {
      style: 'currency',
      currency: 'CNY',
    }).format(value);
  },
};

13.4 API Route:对接 AI

// app/api/generate/route.ts
import { streamText } from 'ai';
import { catalog } from '@/lib/catalog';

export async function POST(req: Request) {
  const { prompt } = await req.json();

  const result = streamText({
    model: 'anthropic/claude-haiku-4.5',
    system: catalog.prompt({
      customRules: [
        '用 Card 作为每个独立区块的容器',
        '用 Grid 做多列布局,columns 根据内容数量合理选择',
        '数值指标使用 Text + Badge 组合展示',
        '始终提供 refresh_data 按钮让用户刷新数据',
      ],
    }),
    prompt,
  });

  return result.toTextStreamResponse();
}

13.5 前端页面:组装一切

// app/page.tsx
'use client';
import { useState } from 'react';
import {
  Renderer, JSONUIProvider, useUIStream,
} from '@json-render/react';
import { registry, handlers, computedFunctions } from '@/lib/registry';

export default function DashboardBuilder() {
  const [prompt, setPrompt] = useState('');
  const { spec, isStreaming, send, clear } = useUIStream({
    api: '/api/generate',
  });

  return (
    <div className="min-h-screen bg-gray-50">
      {/* 顶部输入栏 */}
      <header className="border-b bg-white px-6 py-4">
        <div className="max-w-4xl mx-auto flex gap-3">
          <input
            value={prompt}
            onChange={(e) => setPrompt(e.target.value)}
            onKeyDown={(e) => {
              if (e.key === 'Enter' && !isStreaming) {
                send(prompt);
                setPrompt('');
              }
            }}
            placeholder="描述你想要的仪表盘,比如:创建一个电商销售数据看板..."
            className="flex-1 border rounded-lg px-4 py-2"
          />
          <button
            onClick={() => { send(prompt); setPrompt(''); }}
            disabled={isStreaming || !prompt.trim()}
            className="px-6 py-2 bg-blue-600 text-white rounded-lg disabled:opacity-50"
          >
            {isStreaming ? '生成中...' : '生成'}
          </button>
          <button onClick={clear} className="px-4 py-2 border rounded-lg">
            重置
          </button>
        </div>
      </header>

      {/* 渲染区域 */}
      <main className="max-w-6xl mx-auto p-6">
        <JSONUIProvider
          registry={registry}
          initialState={spec?.state ?? {}}
          handlers={handlers}
          functions={computedFunctions}
        >
          <Renderer spec={spec} registry={registry} loading={isStreaming} />
        </JSONUIProvider>
      </main>
    </div>
  );
}

13.6 效果:用户输入 → AI 生成 → 即时渲染

用户输入: "创建一个电商销售数据看板,包含总收入、订单量、转化率,
          以及最近7天的销售趋势图和热销商品排行表"

AI 逐行输出 JSONL patch:
  → /root = "dashboard"
  → /elements/dashboard = Grid(columns:2) [metrics, chart, table]
  → /elements/metrics = Stack [revenue, orders, conversion]
  → /elements/revenue = Card > Text "总收入 ¥128,450"
  → /elements/orders = Card > Text "订单量 1,234"       ← 每行到达,UI 多一块
  → /elements/conversion = Card > Badge "转化率 3.2%"
  → /elements/chart = Card > LineGraph(7天趋势)
  → /elements/table = Card > Table(热销商品)
  → /elements/refresh = Button "刷新数据"

整个过程:用户看到界面在屏幕上一块一块地"生长"出来

十四、与其他方案的对比

┌────────────────┬───────────────┬──────────────┬──────────────┐
│                │  json-render  │ AI 生成代码   │  AI 填充数据  │
│                │  (Generative  │ (v0/Bolt     │  (传统方式)    │
│                │   UI)         │  等)          │              │
├────────────────┼───────────────┼──────────────┼──────────────┤
│ AI 生成的是什么 │ JSON 数据     │ 源代码       │ 文本/数据     │
│ 运行时安全     │ ✅ 无代码执行  │ ❌ 需沙箱    │ ✅ 安全       │
│ 实时流式渲染   │ ✅ 逐行渲染   │ ❌ 整体编译  │ N/A          │
│ UI 可变性      │ ✅ 每次不同   │ ✅ 每次不同  │ ❌ 固定布局   │
│ 跨平台         │ ✅ 12+ 渲染器 │ ❌ 单平台    │ ❌ 单平台     │
│ 类型安全       │ ✅ Zod + TS   │ ⚠️ 不确定    │ ✅ 可控       │
│ 适合场景       │ 运行时动态UI  │ 开发时生成   │ 数据展示      │
└────────────────┴───────────────┴──────────────┴──────────────┘

json-render 的定位是运行时的 Generative UI——界面在用户使用过程中由 AI 实时生成,而不是在开发阶段生成代码。这与 v0 等代码生成工具互补而非竞争。


十五、适用场景速查

 非常适合:
   AI 聊天机器人需要展示丰富 UI(不只是文字)
   动态仪表盘 / 数据看板生成器
   表单生成器(AI 根据需求自动构建表单)
   CMS 后台(JSON 驱动的页面渲染)
   多端统一(同一份 Spec 驱动 Web + Mobile + PDF + Email)

⚠️ 需要评估:
   高度定制化的交互(复杂拖拽画布编辑器等)
   性能极致敏感的场景(每次渲染都经过表达式解析层)

 不太适合:
   完全静态的不需要动态生成的页面
   需要像素级精确控制的设计稿还原

结语

json-render 代表了一种有趣的范式转移:从"AI 辅助开发者写代码"到"AI 直接为用户生成界面"。它的核心智慧在于找到了一个平衡点——让 AI 拥有足够的创造自由(可以自由组合组件、选择布局、绑定数据),同时保持绝对的安全边界(只能用你定义的组件、只能触发你实现的动作)。

如果你正在构建 AI 驱动的产品,json-render 至少值得你花一个下午深入了解。从一个简单的 Renderer + 手写 Spec 开始,逐步加入 AI 生成和流式渲染,你会发现这套"JSON 驱动 UI"的思路打开了一个全新的产品设计空间。

🔗 GitHub: github.com/vercel-labs… 🔗 官方文档: json-render.dev 📦 核心安装: npm install @json-render/core @json-render/react 📦 快速体验: npm install @json-render/shadcn(36 个预构建组件)

别等用户吐槽!开发者该如何证明自己的程序 “好用”?

结合调研数据,核心比例结论先明确:仅 35% 的用户会主动反馈软件 “慢 / 难用”,65% 的用户选择不反馈(含 “默默忍受” 或 “直接卸载”) ,且不同场景下比例会有差异,具体拆解如下:

从上边结果来看,当我们加班加点把软件系统(一个 APP 或者一个 Java 微服务或者一个 Web 商城网站)开发好上线后,大部分用户不会主动反馈问题,系统再卡顿、体验再差,也很少会说,只会默默选择不用、卸载或离开。

那么如何解决这个问题呢?

系统 SLO——将 “系统好不好用、用户体验佳不佳” 的模糊感知,转化为可量化、可监控、可告警的 SLO 指标体系,再将 SLO 拆解为落地可测的 KPI,既解决领导对系统价值的量化判断难题,也摆脱 “靠用户反馈发现问题” 的被动局面。

名词 描述
SLA 即 Service-Level Agreement,服务等级协议,指系统服务提供者(Provider)对客户(Customer)的服务承诺。您可以对服务商的服务质量 SLA 评分,实时监测服务的达标率
SLI 即 Service Level Indicator,测量指标,指选择用于衡量系统稳定性的指标。观测云 SLI 支持基于监控器设定一个或多个测量指标
SLO 即 Service Level Objective,观测云进行 SLA 评分处理的最小单元,是一个时间窗口内 SLI 累积成功数的目标。而我们又经常把 SLO 转化为错误预算,用于计算可容忍的错误数,在每一个检测周期内出现异常事件的时间将在可容错时长中扣除

这里 SLO 全生命周期管理(定义、监控、告警、复盘)能力,能完美承接这套体系的落地,实现从 “被动救火” 到 “主动防控” 的转变,通过几个维度讲透如何基于 SLO 做系统量化评估,适配企业内所有业务系统 / 技术系统。

一、核心逻辑:SLO 是桥梁,连接 “用户体验” 与 “技术 KPI”

很多企业的痛点是技术指标与用户体验脱节:技术团队盯着 CPU、QPS 等纯技术指标,却不知道这些指标背后对应什么样的用户体验;领导判断系统好不好用,只能靠 “用户有没有投诉、业务有没有提需求”,缺乏客观标准。

  • SLO:站在用户 / 业务视角定义的服务等级目标,是 “系统好不好用” 的核心衡量标准(比如 “核心交易接口 99.99% 的请求在 200ms 内响应”“页面 99.9% 的加载请求在 1.5s 内完成”),直接对应用户体验;
  • KPI:站在技术视角拆解的落地指标,是实现 SLO 的具体技术保障(比如 “接口 99 分位响应耗时≤200ms”“服务器 CPU 峰值利用率≤70%”),可通过直接采集监控;

这套体系的核心价值:

  • 给领导量化判断依据:无需靠主观感受,打开 SLO 大盘,就能看到每个系统的 SLO 达成率、核心 KPI 达标情况,直接判断系统是否 “好用”;
  • 变被动为主动:对 SLO/KPI 做实时监控,一旦指标偏离阈值,提前触发告警,技术团队在用户感知到问题前就介入解决,彻底摆脱 “靠用户反馈发现问题” 的被动;
  • 技术工作对齐业务价值:技术团队的工作不再是 “为了调优指标而调优”,而是围绕 “达成 SLO、提升用户体验” 展开,所有技术优化都有明确的业务目标;
  • 问题可归因、优化可验证:的全链路可观测能力(指标、日志、链路、追踪),能在 SLO 未达标时快速定位根因,优化后也能通过 SLO/KPI 的变化,量化验证优化效果。

二、体系搭建核心步骤:从用户视角出发,基于落地

2.1 明确用户视角的核心体验点

先对企业内所有系统做分层分类,明确每个系统的核心用户(C 端用户 / 业务端用户 / 内部研发 / 运营)和用户最关注的体验点—— 这是定义 SLO 的基础,避免 SLO 与用户体验脱节。针对每个系统,梳理用户在使用系统时的核心动作,并提炼对应的体验诉求,这是 SLO 的 “用户侧源头”。

示例

  • 电商交易系统(C 端用户):核心动作是 “下单支付、商品查询、页面浏览”,体验诉求是 “下单不卡顿、支付不掉线、页面加载快”;
  • 业务中台系统(业务研发用户):核心动作是 “调用接口、配置参数、查看返回结果”,体验诉求是 “接口调用成功、响应快、参数配置生效及时”;
  • 运营后台(运营用户):核心动作是 “查询数据、导出报表、操作工单”,体验诉求是 “数据查询不超时、报表导出快、操作不报错”。

2.2 基于 SLO 模型定义各系统的核心

以下三类 SLO 是的核心能力覆盖范围,无需二次开发,可直接在平台内配置监控、告警、复盘,也是最能反映 “系统好不好用” 的核心维度。

SLO 类型 定义 对应用户体验 能力支撑
可用性 SLO 统计周期内,系统 / 功能 / 接口可用时长占比(扣除计划内维护) 系统 “不宕机、能正常访问”,是用户体验的基础 主机监控、服务监控、心跳检测,精准统计可用 / 不可用时长
性能 SLO 统计周期内,符合用户体验的请求响应占比(如 “200ms 内响应的请求占比”) 系统 “不卡顿、加载快”,是用户体验的核心 接口监控、链路追踪、前端性能监控,按分位值 / 固定阈值统计性能达标请求占比
成功率 SLO 统计周期内,系统 / 接口 / 功能成功执行的请求占比(如 “交易成功请求占比”“页面加载成功占比”) 系统 “操作不报错、执行有结果”,是用户体验的关键 日志分析、接口监控、业务埋点,精准统计成功 / 失败请求数

2.3 核心 SLO-KPI 拆解模型(基于采集能力,可直接复用)

结合的全维度可观测指标库,将 3 类核心 SLO 拆解为通用 KPI,不同系统可根据实际情况微调,所有 KPI 均可直接配置监控

核心 SLO 类型 核心拆解 KPI KPI 定义 采集方式 通用目标值(核心系统 / 一般系统)
可用性 SLO 系统服务运行率 统计周期内,系统核心服务正常运行时长 / 统计总时长 服务监控:采集服务启动 / 停止状态、心跳检测结果 核心≥99.99% / 一般≥99.9%
可用性 SLO 主机在线率 统计周期内,系统部署主机正常在线时长 / 统计总时长 主机监控:采集主机 CPU、内存、网络心跳,判定在线状态 核心≥99.99% / 一般≥99.9%
性能 SLO 接口 99 分位响应耗时 系统核心接口请求响应耗时的 99 分位值 接口监控 / 链路追踪:采集接口每次请求的响应耗时,计算分位值 核心≤200ms / 一般≤500ms
性能 SLO 页面首屏加载耗时 前端页面首屏内容渲染完成的平均耗时 前端性能监控:埋点采集页面加载各阶段耗时 核心≤1.5s(移动端)/ 一般≤3s
性能 SLO 数据库 99 分位读写耗时 核心数据库 SELECT/INSERT 操作的 99 分位耗时 数据库监控:采集数据库执行语句的耗时 核心≤50ms(读)/≤100ms(写)
成功率 SLO 核心接口成功率 统计周期内,核心接口成功请求数 / 总请求数 接口监控:按返回码(200 为成功)统计 核心≥99.99% / 一般≥99.9%
成功率 SLO 前端页面加载成功率 统计周期内,页面成功加载次数 / 总请求次数 前端监控 / 日志分析:统计页面加载失败(4xx/5xx)次数 核心≥99.9% / 一般≥99%
成功率 SLO 业务操作成功率 统计周期内,核心业务操作(交易 / 下单 / 导出)成功次数 / 总次数 业务埋点 / 日志分析:按业务日志关键字(“成功 / 失败”)统计 核心≥99.99% / 一般≥99.9%

2.4 配置步骤

2.4.1 基础配置:确保能采集所有 KPI 数据

先完成数据采集接入,确保所有拆解的 KPI 都能被自动采集,无数据盲区 —— 支持多维度采集方式,适配所有技术栈,操作简单:

  • 基础设施采集:通过 Agent 接入主机、容器、云服务器,采集 CPU、内存、磁盘等主机 KPI;
  • 服务 / 接口采集:通过 SDK/APM 接入微服务、HTTP 接口,采集接口响应耗时、成功率等 KPI;
  • 前端采集:通过前端埋点 SDK,接入 H5/APP/小程序,采集页面加载耗时、成功率等 KPI;
  • 中间件 / 数据库采集:通过专属插件,接入 Redis、MQ、MySQL、PostgreSQL,采集缓存命中率、数据库读写耗时等 KPI;
  • 业务采集:通过自定义埋点 / 日志采集,接入业务操作成功率等自定义 KPI(支持日志关键字提取、自定义指标上报)。

2.4.2 核心配置:在定义 SLO,关联 KPI 指标

SLO 模块支持自定义 SLO 规则、关联指标、自动计算 SLO 达成率,直接对接前面定义的 SLO,步骤如下:

  • 登录平台,进入「SLO 管理」→「新建 SLO」;
  • 填写 SLO 基本信息:名称、所属系统、SLO 类型(可用性 / 性能 / 成功率)、目标值、统计周期;
  • 关联 KPI 指标:从指标库中选择已采集的 KPI,设置 SLO 计算规则(如 “成功率 SLO = 接口成功请求数 / 总请求数,排除压测流量标签”);
  • 设置SLO 告警阈值:建议设置 “预警阈值(如 99.9%)+ 告警阈值(如 99.8%)”,提前触发预警,避免 SLO 达标率跌破目标;
  • 保存并启用 SLO:将自动实时计算 SLO 达成率,关联的 KPI 指标发生变化时,SLO 达成率同步更新。

2.4.3 关键配置:设置分级告警,摆脱被动响应

基于的告警模块,为 SLO/KPI 设置分级告警规则,确保异常在用户感知前被发现,技术团队主动介入,核心是 “按 SLO 重要性分级,匹配不同的告警方式和响应时效”:

告警等级 触发条件 告警方式(支持) 响应时效 责任主体
P0(紧急) 核心系统核心 SLO 达成率跌破目标值(如 99.99%→99.5%),或核心 KPI 严重异常(如接口成功率骤降) 电话 + 短信 + 企业微信 / 钉钉 @所有人 + 平台红字告警 5 分钟内响应,30 分钟内解决 技术负责人 + 核心研发 + 运维
P1(重要) 核心系统辅助 SLO/KPI 异常,或重要系统核心 SLO 达成率跌破预警阈值 企业微信 / 钉钉 @项目组 + 平台告警 15 分钟内响应,1 小时内解决 项目研发 + 运维
P2(一般) 重要系统辅助 SLO/KPI 异常,或一般系统 SLO/KPI 异常 企业微信 / 钉钉单聊通知责任人 + 平台告警 30 分钟内响应,2 小时内解决 对应模块研发 / 运维

2.4.4 最终呈现:打造可视化大盘,一键判断系统好坏

基于的可视化模块,打造3 级可视化大盘,满足领导、技术管理、一线研发的不同查看需求,大盘支持实时刷新、钻取分析、多维度筛选,让 “系统好不好用” 一目了然。

三、AI 系统 SLO 落地案例

以下结合 AI 系统的实操案例,详细说明大盘搭建与 SLO 配置的完整流程(该案例已落地验证,可直接复用配置逻辑):

3.1 前置准备:统一规范与标签体系

为确保监控与 SLO 的统一性和可追溯性,首先建立标准化的标签与命名规范:

  • 全局标签:为 AI系统 配置专属全局标签(如df_label=AI系统),关联service(服务名)、http_route(接口路由)、pod_name(容器名)等维度,便于指标筛选与聚合;
  • 命名规范:所有监控器、SLO、看板均以 “项目名开头”,确保辨识度,例如 “智慧供应链服务请求错误率大于 80%”“AI系统 ”。

3.2 步骤 1:创建核心监控器(SLI 数据来源)

监控器是 SLO 的基础数据支撑(即 SLI,服务等级指标),需针对系统核心 KPI 配置监控规则,具体要求如下:

  • 监控器配置维度:覆盖错误率、响应时间、请求量、资源使用率等核心场景,例如:

    • 服务请求错误率监控:AI系统 请求错误率大于 80%(检测频率 1 分钟,检测区间最近 5 分钟);
    • 响应时间监控:AI系统 平均响应时间大于 3 秒、P95 响应时间过长、响应时间突增;
    • 业务异常监控:代理 24 小时未发货、请求数突增、请求失败率突增;
  • 配置注意事项:避免选择高基数字段作为检测维度,防止告警过于宽松引发频繁告警;检测频率与区间可自定义(如 20m、2h、1d),核心指标建议按分钟级检测。

3.3 步骤 2:创建系统专属 SLO

基于已配置的监控器(SLI),创建项目组专属 SLO,实现 “监控指标→SLO 目标” 的关联:

  • SLO 创建规则:每个项目组对应 1 个核心 SLO,直接关联第一步创建的监控告警(如错误率监控、响应时间监控),无需额外重复配置数据来源;
  • SLO 命名格式:统一为 “xxxxSLO”,例如 “AI系统 SLO”,目标值设置为 95%(结合业务实际设定,全年 SLA 目标 99.7427%);
  • 统计配置:采用最近 5 分钟作为检测区间,与监控器检测频率保持一致,确保数据同步性。

3.4 步骤 3:搭建三级可视化大盘

3.4.1 企业级总览大盘:xxx系统健康度大屏

  • 核心功能:展示全公司所有系统的 SLO 达成率总览,包含 AI系统 在内的 17 个系统健康度数据(如 SLO 达成率、告警次数、请求量),核心指标(如 100% 达成率)突出显示,支持领导快速掌握全局状态;
  • 配置要点:将 AI系统 纳入总览大盘,关联 “最近 5 分钟”“全年 SLA” 两个时间维度,直观展示短期表现与长期稳定性(案例中该系统全年 SLA 达 99.7427%)。

3.4.2 系统级详情大盘:AI系统 - SLO 健康度大屏

通过克隆基础看板并自定义修改,打造项目专属详情页:

  • 视图变量修改:将看板的视图变量替换为 AI系统 的专属信息(如app_idproject);
  • 标题与内容规范:标题统一格式为 “大屏详情 - xxxx-SLO”(例:大屏详情 - AI系统 - SLO);
  • 核心展示内容:最近 5 分钟 SLO 达成率、全年 SLA、告警事件列表(关联df_label=AI系统标签)、核心 KPI 趋势图(错误率、响应时间、请求量);
  • 交互配置:支持分页查看告警事件(默认 50 条),显示当前查询的起止时间,便于追溯异常时段。

3.4.3 跳转链路配置

建立 “总览大盘→详情大盘” 的跳转链接:在《xxx系统健康度大屏》中,为 AI系统 的 SLO 指标配置跳转规则,点击后直接进入该系统的 SLO 详情看板,实现 “全局→局部” 的快速钻取。

3.5 步骤 4:告警与 SLI 关联优化

  • 告警 SLI 适配:修改详情看板中告警模块的df_label为系统全局标签(AI系统),确保告警事件仅展示当前系统相关内容,避免跨系统干扰;
  • 静默与抑制配置:结合的静默管理、告警策略管理功能,设置告警抑制规则,避免同一根因引发的告警风暴(如接口超时导致的错误率告警与响应时间告警,仅触发 1 条核心告警)。

3.6 案例落地效果

  • 领导视角:通过《AI 系统健康度大屏》,1 秒查看 AI系统 的 SLO 达成率(如 100%)与全年 SLA,无需关注技术细节即可判断系统是否 “好用”;
  • 技术视角:通过详情看板,实时监控错误率、响应时间等核心指标,结合告警快速定位异常(如请求错误率突增),在用户反馈前介入解决;
  • 管理视角:统一的命名与标签体系,便于跨项目对比与批量管理,17 个系统的健康度数据集中展示,简化运维管理成本。

3.7 大盘层级与核心展示内容

大盘层级 面向人群 核心展示内容(通用模板 +案例适配)
企业级总览大盘 领导 / 技术负责人 所有系统 SLO 达成率、告警总览、Top3 异常系统 案例中展示 17 个系统的健康度数据,核心系统 SLO 达成率突出显示
系统级详情大盘 项目负责人 / 技术管理 单个系统 SLO 达成率、核心 KPI 趋势、告警记录、链路拓扑;案例中包含 AI系统 的错误率、响应时间、业务异常等维度
模块 / 接口级大盘 一线研发 / 运维 具体接口 KPI 实时数据、日志详情、链路追踪;案例中可钻取到单个接口的错误日志、Pod 运行状态

四、总结:基于 SLO,让系统评估有标准、问题响应变主动

基于 SLO 构建的系统量化评估体系,本质是用的技术能力,解决 “系统好不好用无法量化、问题发现靠用户反馈” 的企业痛点,核心价值体现在三个方面:

  • 给领导的量化判断依据:的 SLO 总览大盘,让领导无需靠主观感受,一键掌握所有系统的状态,SLO 达成率高 = 系统好用、用户体验好,决策更有依据;
  • 技术团队的工作方向标:所有技术工作都围绕 “达成 SLO、提升用户体验” 展开,技术优化不再是 “无的放矢”,而是有明确的业务目标和用户价值;
  • 从被动救火到主动防控:的实时监控、分级告警能力,让技术团队在用户感知到问题前就介入解决,彻底摆脱 “靠用户反馈发现问题” 的被动局面,提升用户体验的同时,也降低了业务损失。

后续的核心工作,就是按步骤落地配置,配套保障措施,持续复盘优化,让 SLO-KPI 体系成为企业评估系统、优化系统的 “标准工具”,让每个系统的 “好用与否”,都有明确的量化答案。

性能优化:CDN 缓存加速与调度原理

前言

在前端性能优化中,静态资源加载速度往往是首屏渲染的瓶颈。CDN(Content Delivery Network) 通过将资源分发至全球各地的边缘节点,实现了“物理距离”上的访问加速。本文将带你深入 CDN 的内部,看它是如何通过 DNS 调度实现就近访问的。

一、 核心概念:什么是 CDN?

CDN 是一种分布式网络构建。它通过在全国各地(乃至全球)部署海量边缘节点服务器,缓解因用户地域差异、带宽不同、服务器距离过远导致的访问延迟问题,让用户就近获取所需资源,大幅提升网站响应速度、访问成功率,同时减轻源服务器压力。

1. 解决的痛点

  • 物理距离过远:跨国、跨省访问带来的高延迟。
  • 运营商带宽瓶颈:跨运营商(如电信访问联通)的互联互通问题。
  • 源站压力过大:热点资源引发的服务器并发冲击。

二、 深度拆解:CDN 的通信与调度流程

当用户在浏览器输入一个使用了 CDN 的域名时,背后的解析流程比普通 DNS 复杂得多,CDN具体通信调度流程如下:

  1. 域名解析请求:用户在浏览器输入域名,浏览器向本地DNS服务器请求解析,获取对应IP地址。

  2. CNAME 指向:DNS服务器不会直接返回源站IP,而是返回一个CNAME(别名记录) ,该记录指向CDN专用的全局负载均衡(GSLB)系统。。

  3. 智能调度计算:浏览器重新向CDN全局负载均衡系统发起请求。GSLB 会根据以下维度进行综合计算:

    • 地理位置:用户 IP 距离哪个节点最近?
    • 运营商环境:用户是移动还是电信?选择匹配的线路。
    • 节点健康度:目标服务器当前的负载和带宽是否充足?
    • 资源命中情况:请求的资源在哪个节点有缓存?
  4. 返回边缘节点 IP:GSLB 选择一个最优的区域负载均衡设备(SLB) ,并将这个边缘节点的IP地址返回给用户浏览器。

  5. 资源获取与回源

    • 命中(Hit) :用户向该 IP 请求,边缘节点直接返回资源。
    • 回源(Miss) :如果该节点无缓存,则逐级向上寻找,直至回到源站服务器拉取内容并缓存到本地。

核心逻辑:用户永远不直接访问源站,而是访问CDN边缘节点,源站只负责提供原始资源,极大降低源站压力。


三、 评价指标:如何衡量 CDN 的服务质量?

CDN 的核心价值在于“命中”,我们通常用以下两个指标来评估:

指标 定义 理想状态
命中率 (Hit Rate) 用户访问的资源恰好在CDN节点缓存系统中的比例 越高越好。代表 CDN 拦截了大部分请求,减轻了源站压力。
回源率 (Origin Pull Rate) 用户访问的资源CDN节点无缓存/缓存过期,必须向上级节点或源站请求资源的次数,占总访问次数的比例。 越低越好。高回源率可能导致源站带宽瞬间爆满。

四、 进阶实战:CDN 预热与刷新

在实际项目部署中,我们经常会听到两个核心操作:

1. CDN 预热 (Pre-warming)

  • 场景:大版本上线或活动开启前(如双 11)。
  • 操作:主动将源站资源推送到全国各地的 CDN 节点。
  • 效果:用户在第一波访问时就能直接“命中”,避免瞬间大量请求涌向源站导致崩溃。

2. CDN 刷新 (Refresh)

  • 场景:修复了紧急 Bug,更新了相同文件名的静态资源。
  • 操作:强制清除节点上的缓存。用户下次访问时将触发回源。
  • 优化:推荐在打包时使用 Content Hash(如 main.v123.js),通过文件名变更自然失效,而非手动刷新。

五、 最佳实践:前端如何使用 CDN?

1. 第三方库托管

对于成熟的库(Vue, React, Echarts, Axios),直接使用公共 CDN(如 cdnjs, unpkg, 静态资源库)。

  • 优点:减少自建服务器带宽压力;利用浏览器缓存(如果用户在别的网站也加载过同一个 CDN 链接,则无需下载)。
    <!-- 示例:CDN引入Vue、Axios、ECharts -->
    <script src="https://cdn.jsdelivr.net/npm/vue@3.4.0/dist/vue.global.prod.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/axios@1.6.0/dist/axios.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
    

2. 静态资源部署

将打包生成的 dist 目录(JS、CSS、图片)直接部署至云厂商的 对象存储(如阿里云 OSS, 腾讯云 COS) 并绑定 CDN 加速。

  • 策略:HTML 放在自己的服务器(防止缓存无法更新),而静态资源全走 CDN。

六、核心总结

  • CDN本质:分布式节点+就近访问+缓存加速,解决远程访问延迟、源服务器压力大的问题
  • 调度核心:DNS解析→CNAME指向→负载均衡选最优节点→节点缓存响应
  • 质量关键:命中率越高、回源率越低,CDN加速效果越好
  • 前端用法:第三方库直引、项目dist资源上传部署,是必备性能优化手段

PageAgent-住在网页里的 AI 操控员

一、从一个问题说起:为什么需要"页面内"的 AI Agent?

过去两年,浏览器自动化领域热闹非凡。browser-use、Playwright MCP、各类 Headless 方案层出不穷,但它们都有一个共同特征——需要一个"外部大脑":Python 后端、无头浏览器实例、或浏览器扩展的特殊权限。

阿里巴巴开源的 PageAgent 提出了一个极为简洁的逆向思路:不从外部操控浏览器,让 AI Agent 直接"住在"网页里。 一行 <script> 标签,Agent 就在当前页面的 JavaScript 上下文中运行——不要 Python,不要无头浏览器,不要截图和多模态模型,甚至不要浏览器扩展。

下面这张图能直观地感受到区别:

┌─────────────────────────────────────────────────────────────────┐
│                    传统方案 vs PageAgent                         │
├─────────────────────────────┬───────────────────────────────────┤
│  browser-use / Playwright   │         PageAgent                 │
│                             │                                   │
│  ┌───────────┐              │  ┌─────────────────────────────┐  │
│  │ Python    │──WebSocket──▶│  │         你的网页              │  │
│  │ 后端服务   │  / CDP      │  │  ┌─────────────────────┐    │  │
│  └───────────┘              │  │  │  PageAgent (JS)     │    │  │
│       │                     │  │  │  ┌───────┐ ┌──────┐ │    │  │
│       ▼                     │  │  │  │ Agent │→│ DOM  │ │    │  │
│  ┌───────────┐              │  │  │  │ 循环  │ │ 操控 │ │    │  │
│  │ Headless  │              │  │  │  └───┬───┘ └──────┘ │    │  │
│  │ Browser   │              │  │  │      │  ↕ LLM API   │    │  │
│  └───────────┘              │  │  └──────┼──────────────┘    │  │
│                             │  └─────────┼───────────────────┘  │
│  需要: Python + 无头浏览器    │  只需: 一行 <script> 标签          │
└─────────────────────────────┴───────────────────────────────────┘

这篇文章将从最简单的用法出发,逐层深入到源码架构的核心设计,配有丰富示例和图解,帮你完整理解 PageAgent 的工作原理。


二、实战示例:从入门到高级

🟢 入门级:一行代码,5 秒体验

如果你只想快速感受效果,把下面这行代码贴到任意网页的控制台或 HTML 里:

<script src="https://cdn.jsdelivr.net/npm/page-agent@1.5.9/dist/iife/page-agent.demo.js" crossorigin="true"></script>

页面右下角会出现一个对话面板,输入自然语言指令即可操作页面。这个 Demo CDN 自带免费测试 LLM,开箱即用。

🟡 进阶级:NPM 集成 + 自选模型

实际项目中,你需要接入自己的 LLM:

import { PageAgent } from 'page-agent'

const agent = new PageAgent({
  model: 'qwen3.5-plus',
  baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
  apiKey: 'YOUR_API_KEY',
  language: 'zh-CN',
})

// 方式一:程序化执行
const result = await agent.execute('在搜索框输入 "iPhone 16",然后点击搜索按钮')
console.log(result.success)  // true / false
console.log(result.data)     // Agent 的执行总结

// 方式二:弹出对话面板,让用户自行输入
agent.panel.show()

支持的模型非常丰富——OpenAI GPT 系列、Claude、Qwen、DeepSeek、Gemini、Grok、MiniMax、Kimi、GLM,甚至通过 Ollama 本地部署的开源模型都可以。只要兼容 OpenAI 的 /chat/completions 接口即可。

🟡 进阶级:知识注入——让 AI 懂你的业务

裸用 Agent 时,它只知道页面上有什么元素,但不了解你的业务规则。通过 instructions 你可以注入领域知识:

const agent = new PageAgent({
  // ...LLM config
  instructions: {
    // 全局指令:所有页面生效
    system: `
      你是一个专业的电商运营助手。
      规则:
      - 提交订单前必须先确认价格和数量
      - 遇到错误时立即停止,不要盲目重试
      - 优先使用筛选器缩小搜索范围
    `,
    // 页面级指令:根据 URL 动态返回
    getPageInstructions: (url) => {
      if (url.includes('/checkout')) {
        return '这是结算页面。请先核对收货地址,再检查是否有优惠券可用。'
      }
      if (url.includes('/products')) {
        return '这是商品列表页。先使用左侧筛选器缩小范围,再帮用户选择商品。'
      }
      return undefined
    }
  }
})

指令的工作方式如下图所示:

每一步执行前,prompt 的组装结构:

┌────────────────────────────────────────┐
│  <instructions>                        │
│    <system_instructions>               │
│      你是电商运营助手...                  │
│    </system_instructions>              │
│    <page_instructions>                 │  ← 仅当 URL 匹配时才出现
│      这是结算页面...                     │
│    </page_instructions>                │
│  </instructions>                       │
│                                        │
│  <agent_state>                         │
│    用户请求 + 步数信息                    │
│  </agent_state>                        │
│                                        │
│  <agent_history>                       │
│    之前每步的反思 + 动作结果               │
│  </agent_history>                      │
│                                        │
│  <browser_state>                       │
│    当前页面 URL、可交互元素、滚动位置       │
│  </browser_state>                      │
└────────────────────────────────────────┘

🟡 进阶级:数据脱敏——敏感信息不出页面

在把页面内容发送给 LLM 之前,transformPageContent 钩子允许你过滤敏感数据:

const agent = new PageAgent({
  // ...LLM config
  transformPageContent: async (content) => {
    // 手机号脱敏:138****1234
    content = content.replace(/\b(1[3-9]\d)(\d{4})(\d{4})\b/g, '$1****$3')
    // 邮箱脱敏
    content = content.replace(
      /\b([a-zA-Z0-9._%+-])[^@]*(@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})\b/g,
      '$1***$2'
    )
    // 银行卡号脱敏
    content = content.replace(/\b(\d{4})\d{8,11}(\d{4})\b/g, '$1********$2')
    return content
  }
})

LLM 看到的是脱敏后的内容,但页面上的真实数据不受影响,Agent 的操作仍然作用于原始 DOM 元素。

🔴 高级:自定义工具——给 AI 接上后端 API

内置工具只能操作 DOM,但通过 customTools 你可以让 Agent 调用任意业务接口:

import { z } from 'zod/v4'
import { PageAgent, tool } from 'page-agent'

const agent = new PageAgent({
  // ...LLM config
  customTools: {
    // 添加购物车工具:AI 可以直接调 API 而非点按钮
    add_to_cart: tool({
      description: '通过商品 ID 添加到购物车',
      inputSchema: z.object({
        productId: z.string(),
        quantity: z.number().min(1).default(1),
      }),
      execute: async function (input) {
        await fetch('/api/cart', {
          method: 'POST',
          body: JSON.stringify(input),
        })
        return `✅ 已添加 ${input.quantity}${input.productId} 到购物车`
      },
    }),

    // 搜索知识库工具:让 AI 先查资料再操作
    search_kb: tool({
      description: '搜索内部知识库',
      inputSchema: z.object({
        query: z.string(),
        limit: z.number().max(10).default(3),
      }),
      execute: async function (input) {
        const res = await fetch(`/api/kb?q=${encodeURIComponent(input.query)}&limit=${input.limit}`)
        return JSON.stringify(await res.json())
      },
    }),

    // 移除内置工具:比如禁止 AI 向用户提问
    ask_user: null,
  },
})

🔴 高级:完全自定义 UI(React 示例)

不想用内置面板?核心逻辑和 UI 完全解耦,你可以用 React/Vue/任何框架搭建自己的界面:

import { PageAgentCore } from '@page-agent/core'
import { PageController } from '@page-agent/page-controller'
import { useState, useEffect } from 'react'

// 1. 自定义 React Hook 监听 Agent 事件
function useAgent(agent) {
  const [status, setStatus] = useState(agent.status)
  const [history, setHistory] = useState(agent.history)
  const [activity, setActivity] = useState(null)

  useEffect(() => {
    const onStatus = () => setStatus(agent.status)
    const onHistory = () => setHistory([...agent.history])
    const onActivity = (e) => setActivity(e.detail)

    agent.addEventListener('statuschange', onStatus)
    agent.addEventListener('historychange', onHistory)
    agent.addEventListener('activity', onActivity)
    return () => {
      agent.removeEventListener('statuschange', onStatus)
      agent.removeEventListener('historychange', onHistory)
      agent.removeEventListener('activity', onActivity)
    }
  }, [agent])

  return { status, history, activity }
}

// 2. 创建无 UI 的 Core Agent
const agent = new PageAgentCore({
  pageController: new PageController({ enableMask: true }),
  baseURL: 'https://api.openai.com/v1',
  apiKey: 'your-key',
  model: 'gpt-5.1',
})

// 3. 你的自定义 UI 组件
function MyAgentPanel() {
  const { status, history, activity } = useAgent(agent)

  return (
    <div className="my-agent-ui">
      <div>状态: {status}</div>
      {activity?.type === 'thinking' && <div>🧠 思考中...</div>}
      {activity?.type === 'executing' && <div>⚡ 执行: {activity.tool}</div>}
      {history.filter(e => e.type === 'step').map((step, i) => (
        <div key={i}>步骤 {i+1}: {step.action.name} → {step.action.output}</div>
      ))}
    </div>
  )
}

🔴 高级:对接外部 Agent 系统

把 PageAgent 作为工具注册到你现有的 AI 客服/助手系统中:

// 你的主 Agent 系统中
const pageAgentTool = {
  name: 'operate_webpage',
  description: '在当前网页上执行操作,如点击、填写表单、查询信息',
  parameters: {
    type: 'object',
    properties: {
      instruction: { type: 'string', description: '操作指令' }
    },
    required: ['instruction']
  },
  execute: async (params) => {
    const result = await pageAgent.execute(params.instruction)
    return { success: result.success, message: result.data }
  }
}

// 注册到你的 Agent 框架...

这样你的客服机器人就不再只会说"请点击左上角的设置按钮",而是直接帮用户操作。


三、Monorepo 架构全景图

PageAgent 采用 monorepo 结构,packages/ 下 7 个子包分层清晰:

┌─────────────────────────────────────────────────────────────┐
│                      用户代码                                │
│              import { PageAgent } from 'page-agent'         │
└──────────────────────────┬──────────────────────────────────┘
                           │
                           ▼
┌─────────────────────────────────────────────────────────────┐
│    📦 page-agent (门面层,28行代码)                          │
│    组装 Core + PageController + UI Panel                     │
└───────┬───────────────────┬──────────────────┬──────────────┘
        │                   │                  │
        ▼                   ▼                  ▼
┌──────────────┐  ┌──────────────────┐  ┌────────────┐
│  📦 core     │  │  📦 page-        │  │  📦 ui     │
│  Agent 循环   │  │   controller     │  │  交互面板   │
│  提示词工程   │  │  DOM 提取与简化   │  │            │
│  工具系统     │  │  元素动作模拟     │  │            │
│  AutoFixer   │  │  遮罩层管理       │  │            │
└──────┬───────┘  └──────────────────┘  └────────────┘
       │
       ▼
┌──────────────┐
│  📦 llms     │     📦 extension (可选)
│  OpenAI 协议  │     Chrome 扩展,多标签页
│  模型补丁     │
│  重试机制     │     📦 website
└──────────────┘     官方文档站

核心设计原则:core 不依赖 uipage-controller 不依赖 core,任何一层都可以独立替换。想换 UI?用 PageAgentCore 监听事件自己画。想换 DOM 操作方式?实现 PageController 接口即可。


四、核心引擎:Re-Act Agent 循环

PageAgent 的灵魂在 PageAgentCore 类中。它实现了经典的 Re-Act(Reasoning + Acting)循环

4.1 一次任务的完整生命周期

agent.execute("填写上周五出差的报销单")
         │
         ▼
┌─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
│          while (step < maxSteps)                          │
│                                                           │
│  ┌──────────┐    ┌──────────┐    ┌──────────┐            │
│  │ 1.Observe│───▶│ 2.Think  │───▶│ 3.Act    │──┐         │
│  │ 观察页面  │    │ LLM 推理  │    │ 执行动作  │  │         │
│  └──────────┘    └──────────┘    └──────────┘  │         │
│       ▲                                        │         │
│       │           ┌──────────┐                 │         │
│       └───────────│ 4.Record │◀────────────────┘         │
│                   │ 记录历史  │                            │
│                   └──────────┘                            │
│                        │                                  │
│               action == 'done'?                           │
│                 ├── Yes → 返回结果                          │
│                 └── No  → 继续循环                          │
└─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘

第一阶段 ObservePageController.getBrowserState() 扫描 DOM 树,提取所有可交互元素并编号索引,输出一份 LLM 可读的简化文本。同时进行环境感知——URL 是否变化?累计等待时间是否过长?剩余步数是否告急?这些观察被推入历史流。

第二阶段 Think:系统提示词 + 用户提示词 + 浏览器状态 + 完整历史事件被一起发送给 LLM。这里有一个核心设计——MacroTool(详见下节)。

第三阶段 Act:从 LLM 输出中解析出动作名和参数,通过 PageController 在页面上执行真实的 DOM 操作。

第四阶段 Record:执行结果、LLM 的反思内容、token 用量等打包成 AgentStepEvent 推入历史数组,下一轮循环时回传给 LLM 形成连续记忆。

循环终止条件有三个:LLM 调用 done(任务完成)、步数超过 maxSteps(默认 40)、或不可恢复错误。

4.2 MacroTool:强制"先想后做"

传统方案让 LLM 从多个工具中自由选择。PageAgent 走了一条不同的路——把所有工具合并成一个叫 AgentOutput 的巨型工具:

┌────────────────────────────────────────────────┐
│              MacroTool: AgentOutput             │
│                                                │
│  {                                             │
│    evaluation_previous_goal: "上一步成功了...",   │  ← 反思
│    memory: "已找到搜索框,index=5...",            │  ← 记忆
│    next_goal: "在搜索框输入关键词",               │  ← 规划
│    action: {                                   │
│      input_text: { index: 5, text: "iPhone" }  │  ← 动作
│    }                 ▲                         │
│  }                   │                         │
│                      │                         │
│       action 字段是所有内置工具的联合类型:          │
│       click_element_by_index | input_text |     │
│       scroll | select_dropdown_option |         │
│       wait | done | ask_user | ...              │
└────────────────────────────────────────────────┘

源码中用 Zod 的 z.union 将所有工具的 inputSchema 合并成 action 字段的类型。LLM 每次调用 AgentOutput 时,必须同时输出反思和具体动作。这种设计大幅减少了"冲动行为"——Agent 不会跳过思考直接行动。

4.3 两条事件流:记忆 vs 反馈

┌───────────────────────────────────────────────────────┐
│                  PageAgentCore                        │
│                                                       │
│   Historical Events                Activity Events    │
│   (historychange)                   (activity)        │
│                                                       │
│   ┌─────────────┐                ┌─────────────┐     │
│   │ step        │                │ thinking    │     │
│   │ observation  │                │ executing   │     │
│   │ user_takeover│                │ executed    │     │
│   │ retry       │                │ retrying    │     │
│   │ error       │                │ error       │     │
│   └──────┬──────┘                └──────┬──────┘     │
│          │                              │             │
│    持久化 │ 传给 LLM               瞬态 │ 仅 UI 用     │
│          ▼                              ▼             │
│   agent.history[]               UI 状态动画/loading    │
└───────────────────────────────────────────────────────┘

History Events 构成 Agent 的"记忆",每轮都发送给 LLM。Activity Events 是瞬态 UI 反馈("正在思考"/"正在点击按钮"),不进入 LLM 上下文。这种分离保证了 LLM 的上下文始终干净。


五、DOM 翻译官:不靠截图的页面理解

5.1 纯文本路线 vs 截图路线

截图路线 (Claude Computer Use 等)        文本路线 (PageAgent)
                                        
  页面 → 截图 → 多模态LLM               页面 → DOM树 → 简化文本 → 文本LLM
                                        
  ✓ 能看到图片/Canvas                    ✗ 看不到图片/Canvas
  ✗ 需要多模态模型                       ✓ 普通文本模型即可
  ✗ 截图=更多token≈更贵                   ✓ token 更少更便宜
  ✗ 需要特殊权限                         ✓ 零权限

对于大多数 SaaS 后台、表单填写、数据录入场景,文本路线是极为务实的选择。

5.2 DOM 提取:从真实页面到 LLM 可读文本

PageController.getBrowserState() 是整条链路的入口。它的内部流程:

真实 DOM 树
    │
    ▼  getFlatTree()
遍历 DOM,识别可交互元素,分配数字索引
标记新出现的元素 (WeakMap 缓存)
    │
    ▼  flatTreeToString()
转换为 LLM 友好的文本格式
    │
    ▼  组装 BrowserState
    
  header: "Current Page: [商品列表](https://...)
           Page info: 1920x1080px viewport...
           [Start of page]"

  content: "[0]<a aria-label=首页 />
            [1]<input placeholder=搜索商品... />
            [2]<button>搜索</button>
            今日推荐
            *[3]<div>iPhone 16 Pro ¥7999</div>     ← * 号表示新出现
            *[4]<button>加入购物车</button>
            [5]<select>选择颜色</select>"

  footer: "... 1200 pixels below (2.3 pages) - scroll to see more ..."

flatTreeToString 做了大量优化细节:去除重复属性(aria-label 与文本内容相同时只保留一个)、截断过长属性、标注可滚动容器的滚动距离、缩进表示 DOM 层级关系。

5.3 动作模拟:为什么不用 .click()

简单调用 element.click() 在很多前端框架中不能正确触发事件。PageAgent 的 clickElement 模拟了完整的用户行为链:

clickElement(element) 的执行序列:

  scrollIntoView    ← 确保元素可见
       ↓
  movePointerTo     ← 移动指针到元素中心(触发UI动画)
       ↓
  mouseenter        ← 模拟鼠标进入
  mouseover
       ↓
  mousedown         ← 模拟按下
       ↓
  focus             ← 聚焦(确保 React 等框架的事件能触发)
       ↓
  mouseup           ← 模拟释放
       ↓
  click             ← 最终点击事件

文本输入更复杂——对 contenteditable 富文本编辑器,按顺序派发 beforeinput(清空)→ 修改 innerText → 派发 input(插入),以兼容 React 受控组件和 Quill 等编辑器。对普通 input/textarea,则使用原生 value setter 绕过框架拦截,再手动触发 input 事件。


六、LLM 层:兼容万家,容错为先

6.1 OpenAI 兼容协议统一天下

@page-agent/llms 没引入任何 LLM SDK,直接用 fetch 调 /chat/completions 接口。如今几乎所有主流模型商都支持这套协议,因此 PageAgent 天然兼容数十种模型。

6.2 模型补丁:实战踩坑的结晶

源码中的 modelPatch 函数根据模型名称动态调整请求参数:

模型                    补丁内容
─────────────────────────────────────────────────
Qwen 系列      →  temperature ≥ 1.0,关闭 thinking
Claude 系列    →  tool_choice 格式转换为 Claude 风格
Grok 系列      →  删除 tool_choice,禁用 reasoning
GPT-5 系列     →  reasoning_effort = 'low'
GPT-5-mini     →  reasoning_effort = 'low', temperature = 1
Gemini 系列    →  reasoning_effort = 'minimal'
MiniMax 系列   →  temperature 钳位到 (0, 1],删除 parallel_tool_calls

这些全是真实环境下踩坑后的总结,对多模型兼容开发极有参考价值。

6.3 AutoFixer:当 LLM 不守规矩时

不同 LLM 的输出格式千差万别。normalizeResponse 穷举了各种异常并逐一修复:

LLM 的常见"不规矩"输出              AutoFixer 的修复

把 JSON 放在 content 里              → 提取 JSON,包装成 tool_calls
而不是 tool_calls

返回动作层级而非                     → 包装一层 { action: ... }
AgentOutput 完整结构

双重 JSON 字符串化                    → 递归 JSON.parse
"{ \"action\": \"...\" }"

原始值输入                            → 根据 Zod schema 推断字段名
{ click_element_by_index: 2 }         → { click_element_by_index: { index: 2 } }

content 里还套了一层                   → 解析嵌套的 function 结构
function wrapper

这套容错机制是 PageAgent 能稳定兼容这么多模型的关键原因之一。


七、提示词工程:Agent 的"岗位说明书"

系统提示词(system_prompt.md)详细规定了 Agent 的输入格式、行为准则和能力边界。几个值得注意的设计:

输入格式约定:交互元素的格式是 [index]<type>text</type>,只有带数字索引的元素才可操作。新出现的元素用 *[ 标记。缩进表示 DOM 层级。

行为规则亮点:不要重复同一动作超过 3 次;输入文本后如果被中断,很可能弹出了建议列表(要去选择);遇到验证码告知用户无法解决;区分"精确步骤"和"开放式任务"两种模式。

"示弱"设计——这是最有意思的部分:明确告知 LLM "可以失败"、"用户可能是错的"、"网页可能有 bug"、"过度尝试可能有害"。避免 Agent 在无法完成任务时陷入无意义的死循环。


八、生命周期钩子:完整的可观测性

onBeforeTask ──▶ ┌───────────────────────────────┐
                 │  onBeforeStep ──▶ step ──▶ onAfterStep  │  × N 步
                 └───────────────────────────────┘
onAfterTask  ◀── 返回 ExecutionResult { success, data, history }

onDispose    ◀── agent.dispose()

配合 transformPageContent(数据脱敏)和 customSystemPrompt(完全自定义提示词),开发者拥有对 Agent 行为的完全控制权。


九、使用限制:诚实面对能力边界

PageAgent 选择了"纯文本 DOM"路线,这意味着:

能做的:点击、文本输入、下拉选择、表单提交、页面滚动、焦点切换、执行 JavaScript。

做不到的:悬停(hover)、拖拽、右键菜单、键盘快捷键、坐标定位操作、图片/Canvas/WebGL/SVG 等视觉内容识别、Monaco/CodeMirror 等特殊编辑器。

语义化的 HTML 和良好的可访问性(ARIA 标签等)会显著提升 Agent 效果。反常识的交互逻辑、纯视觉的操作提示则会降低成功率。


十、总结:一个务实的工程决策

通读源码后,PageAgent 的核心设计哲学可以归纳为三个词:

务实——纯文本 DOM 而非截图,牺牲视觉理解换来对普通模型的兼容性和更低的成本。MacroTool 强制"先想后做",在可控性和灵活性之间找到平衡。

容错——从 AutoFixer 对畸形输出的修复,到 modelPatch 对不同模型的适配,到提示词中鼓励"可以失败",整个系统对不确定性有很高包容度。

解耦——Core、PageController、UI、LLMs 四层分明,任何一层可独立替换。你可以只用 Core 做无头自动化,也可以换上自己的 React UI,还可以把它嵌入你现有的 Agent 系统作为"手和眼"。

对于 SaaS 开发者想快速给产品加 AI Copilot、企业想做管理后台的智能化改造、或者无障碍增强场景,PageAgent 提供了目前门槛最低的入口——一行 <script> 标签,你的网页就有了一个 AI 操作员。

A2UI 深度解读:让 AI Agent "说出"用户界面的开放协议

引言:Agent 时代的 UI 困境

想象这样一个场景——你对一个 AI 助手说:"帮我订一张明天晚上 7 点的两人桌。" 如果 Agent 只能回复文本,接下来将是:

用户: "帮我订一张明天晚上7点的两人桌"
Agent: "好的,请问几位用餐?"
用户: "两位"
Agent: "请问哪天?"
用户: "明天"
Agent: "什么时间?"
用户: "晚上7点"
Agent: "有什么忌口吗?"
...(五六个回合后终于订完)

更好的方式是:Agent 直接生成一个表单——日期选择器、时间选择器、人数输入框、提交按钮,一步搞定。但传统方案(Agent 返回 HTML/JS 塞进 iframe)笨重、割裂、不安全。

A2UI(Agent-to-User Interface) 就是为此而生的 Google 开源协议:Agent 发送声明式 JSON 描述界面意图,客户端用自己的原生组件渲染。安全如数据,表达如代码。


一、A2UI 全景架构图

先来一张图看全貌——A2UI 的核心是把 UI 生成和 UI 执行彻底解耦:

┌──────────────────────────────────────────────────────────────┐
│                        用户 (User)                           │
│    输入:"帮我找纽约的中餐馆"    │    看到原生渲染的卡片列表    │
└───────────────┬──────────────────────────────▲───────────────┘
                │ 文字请求                      │ 原生 UI
                ▼                              │
┌───────────────────────────────────────────────────────────────┐
│                   客户端应用 (Client App)                      │
│  ┌─────────────┐   ┌──────────────┐   ┌──────────────────┐   │
│  │  传输层      │   │ A2UI 渲染器   │   │  组件目录         │   │
│  │  (Transport) │──▶│  (Renderer)  │◀──│  (Catalog)       │   │
│  │  A2A/WS/SSE │   │  Lit/Angular │   │  Button, Card... │   │
│  └──────┬──────┘   │  /Flutter    │   └──────────────────┘   │
│         │          └──────────────┘                           │
└─────────┼────────────────────────────────────────────────────┘
          │ JSON 消息流 (JSONL)
          │
┌─────────▼─────────────────────────────────────────────────────┐
│                     AI Agent (后端)                             │
│  ┌───────────────┐    ┌──────────────────┐                     │
│  │  业务逻辑      │───▶│  A2UI 生成器      │                     │
│  │  (Tools/API)  │    │  (LLM 生成 JSON) │                     │
│  └───────────────┘    └──────────────────┘                     │
│                              │                                 │
│                     ┌────────▼────────┐                        │
│                     │   Gemini / GPT  │                        │
│                     │   等 LLM 模型    │                        │
│                     └─────────────────┘                        │
└────────────────────────────────────────────────────────────────┘

关键洞察:Agent 永远不会执行代码或操控 DOM。它只能从客户端预批准的"组件目录"中选取组件来组合界面——就像只能用菜单上的菜来点餐,不能自己跑进厨房。


二、三分钟理解核心概念

2.1 五个关键词

┌─────────────────────────────────────────────────────────────┐
│                    A2UI 五大核心概念                          │
├─────────────┬───────────────────────────────────────────────┤
│  Surface    │ 画布/容器,承载一组组件(如一个表单、一个卡片)  │
│  Component  │ UI 元素(Button, Text, Card, TextField...)    │
│  Data Model │ 应用状态,组件通过路径绑定到它                  │
│  Catalog    │ 组件目录,定义 Agent 能用哪些组件               │
│  Message    │ JSON 消息(创建画布/更新组件/更新数据/删除画布) │
└─────────────┴───────────────────────────────────────────────┘

2.2 邻接表模型:为什么是扁平列表而非嵌套树?

这是 A2UI 最独特的设计。传统 UI 描述用嵌套 JSON 树,但 LLM 生成深层嵌套时极易出错、难以流式传输。A2UI 把组件展平为一个列表,通过 ID 引用建立父子关系:

传统嵌套树(LLM 容易搞乱括号)        A2UI 邻接表(扁平 + ID 引用)
─────────────────────────            ──────────────────────────
{                                    components: [
  "Column": {                          { id: "root",    → Column, children: ["title","btn"] },
    "children": [                      { id: "title",   → Text, text: "Hello" },
      { "Text": { "Hello" } },        { id: "btn",     → Button, child: "btn-text" },
      { "Button": {                    { id: "btn-text",→ Text, text: "OK" }
        "child": {                   ]
          "Text": { "OK" }
        }
      }}
    ]
  }
}

层层嵌套,一个括号没对上就全废了         所有组件平铺,随时增量发送、按 ID 更新

2.3 数据绑定:结构与状态分离

组件定义"长什么样",数据模型定义"展示什么内容"。两者通过 JSON Pointer 路径连接:

         组件结构                              数据模型
    ┌──────────────┐                    ┌──────────────────┐
    │ Text          │                   │ {                │
    │ text: ────────┼───path───────────▶│   "user": {      │
    │   path:       │  "/user/name""name":"Alice"│
    │   "/user/name"│                   │   }              │
    └──────────────┘                    └──────────────────┘
                                              │
    当数据模型更新为 "Bob" 时 ──────────────────┘
    Text 自动显示 "Bob",无需重发组件定义!

三、消息生命周期图解

以一个完整的餐厅预订流程为例,看 A2UI 消息如何流转:

 用户                        客户端                         Agent
  │                            │                              │
  │  "订两人桌"                 │                              │
  │ ──────────────────────────▶│                              │
  │                            │  将用户消息转发给 Agent        │
  │                            │ ─────────────────────────────▶│
  │                            │                              │
  │                            │   ① createSurface            │
  │                            │◀─ (创建画布,指定 Catalog)──── │
  │                            │                              │
  │                            │   ② updateComponents         │
  │                            │◀─ (标题+人数框+日期框+按钮)── │
  │  看到表单渐进式渲染          │                              │
  │◀───────────────────────── │   ③ updateDataModel           │
  │                            │◀─ (日期="明天", 人数="2") ──── │
  │                            │                              │
  │  修改人数为 "3"             │                              │
  │ ──────────────────────────▶│  本地数据模型自动更新           │
  │                            │  /reservation/guests = "3"   │
  │                            │                              │
  │  点击「确认预订」            │                              │
  │ ──────────────────────────▶│                              │
  │                            │   ④ action                   │
  │                            │ ─(name:"confirm",context)───▶│
  │                            │                              │
  │                            │   ⑤ deleteSurface            │
  │  看到"预订成功"确认界面      │◀─ + 新 surface (确认卡片) ── │
  │◀───────────────────────── │                              │

四、实战示例:由浅入深

🟢 入门级:Hello World — 一张静态信息卡

适合人群:想快速了解 A2UI JSON 长什么样的开发者

这是最简单的 A2UI 示例——展示一张带标题和描述的卡片,没有交互,没有数据绑定,纯静态内容。

// 消息 1:创建画布
{
  "version": "v0.9",
  "createSurface": {
    "surfaceId": "hello-card",
    "catalogId": "https://a2ui.org/specification/v0_9/basic_catalog.json"
  }
}

// 消息 2:定义组件
{
  "version": "v0.9",
  "updateComponents": {
    "surfaceId": "hello-card",
    "components": [
      {
        "id": "root",
        "component": "Card",
        "child": "content"
      },
      {
        "id": "content",
        "component": "Column",
        "children": ["title", "desc"]
      },
      {
        "id": "title",
        "component": "Text",
        "text": "👋 欢迎使用 A2UI",
        "variant": "h1"
      },
      {
        "id": "desc",
        "component": "Text",
        "text": "这是一张由 Agent 生成的卡片,渲染为你应用的原生组件。"
      }
    ]
  }
}

解读如下——整个过程只需两条消息。createSurface 告诉客户端"我要创建一个画布,用基础组件目录"。updateComponents 发送四个组件:Card 是容器,Column 纵向排列子组件,两个 Text 分别是标题和正文。所有组件平铺在一个列表里,通过 childchildren 引用彼此的 ID。

渲染效果示意:

┌──────────────────────────┐
│ ┌──────────────────────┐ │
│ │  👋 欢迎使用 A2UI     │ │   ← h1 标题
│ │                      │ │
│ │  这是一张由 Agent     │ │   ← 正文描述
│ │  生成的卡片...        │ │
│ └──────────────────────┘ │
└──────────────────────────┘
         Card 容器

🟡 进阶级:带数据绑定的用户资料卡

适合人群:需要理解数据绑定、响应式更新的前端/全栈开发者

这个示例展示数据绑定的核心能力——组件不写死内容,而是绑定到数据模型的路径。当数据变化时,UI 自动刷新。

// 消息 1:创建画布
{
  "version": "v0.9",
  "createSurface": {
    "surfaceId": "profile",
    "catalogId": "https://a2ui.org/specification/v0_9/basic_catalog.json"
  }
}

// 消息 2:定义组件(结构)
{
  "version": "v0.9",
  "updateComponents": {
    "surfaceId": "profile",
    "components": [
      {
        "id": "root",
        "component": "Card",
        "child": "layout"
      },
      {
        "id": "layout",
        "component": "Column",
        "children": ["avatar", "name", "email", "role"]
      },
      {
        "id": "avatar",
        "component": "Image",
        "url": { "path": "/user/avatar" },
        "fit": "cover"
      },
      {
        "id": "name",
        "component": "Text",
        "text": { "path": "/user/name" },
        "variant": "h2"
      },
      {
        "id": "email",
        "component": "Text",
        "text": { "path": "/user/email" }
      },
      {
        "id": "role",
        "component": "Text",
        "text": { "path": "/user/role" },
        "variant": "caption"
      }
    ]
  }
}

// 消息 3:填充数据
{
  "version": "v0.9",
  "updateDataModel": {
    "surfaceId": "profile",
    "path": "/user",
    "value": {
      "name": "Sarah Chen",
      "email": "sarah@techco.com",
      "role": "Product Designer",
      "avatar": "https://example.com/sarah.jpg"
    }
  }
}

关键点在于,组件中的 { "path": "/user/name" } 就是数据绑定语法。渲染器看到它会去数据模型中读取 /user/name 的值来显示。当 Agent 后续发送新的 updateDataModel/user/name 改成 "Bob Lee" 时,名字自动变化,不需要重新发送组件定义。这就是结构与状态分离带来的高效更新。

   组件定义(不变)                  数据模型(可随时更新)
┌──────────────────┐          ┌────────────────────────┐
│ Text              │          │ { "user": {            │
│   text:           │─bindTo──▶│     "name": "Sarah"    │──▶ 显示 "Sarah"path:/user/name│         │   }                    │
└──────────────────┘          └────────────────────────┘
                                       │ Agent 发送数据更新
                              ┌────────▼───────────────┐
                              │ { "user": {            │
                              │     "name": "Bob"      │──▶ 自动显示 "Bob"
                              │   }                    │
                              └────────────────────────┘

🟡 进阶级:带表单交互的餐厅预订

适合人群:需要理解双向绑定和 Action 机制的开发者

这是官方 Demo 的核心场景——Agent 生成一个预订表单,用户填写后提交,Agent 收到数据进行处理。

// 消息 1:创建画布
{
  "version": "v0.9",
  "createSurface": {
    "surfaceId": "booking",
    "catalogId": "https://a2ui.org/specification/v0_9/basic_catalog.json"
  }
}

// 消息 2:定义表单组件
{
  "version": "v0.9",
  "updateComponents": {
    "surfaceId": "booking",
    "components": [
      {
        "id": "root",
        "component": "Column",
        "children": ["title", "img", "party-size", "datetime", "dietary", "submit-btn"]
      },
      {
        "id": "title",
        "component": "Text",
        "text": { "path": "/title" },
        "variant": "h2"
      },
      {
        "id": "img",
        "component": "Image",
        "url": { "path": "/imageUrl" }
      },
      {
        "id": "party-size",
        "component": "TextField",
        "label": "用餐人数",
        "value": { "path": "/partySize" },
        "textFieldType": "number"
      },
      {
        "id": "datetime",
        "component": "DateTimeInput",
        "label": "日期和时间",
        "value": { "path": "/reservationTime" },
        "enableDate": true,
        "enableTime": true
      },
      {
        "id": "dietary",
        "component": "TextField",
        "label": "饮食要求",
        "value": { "path": "/dietary" }
      },
      {
        "id": "submit-btn",
        "component": "Button",
        "child": "submit-text",
        "variant": "primary",
        "action": {
          "event": {
            "name": "submit_booking",
            "context": {
              "restaurant": { "path": "/restaurantName" },
              "partySize":  { "path": "/partySize" },
              "time":       { "path": "/reservationTime" },
              "dietary":    { "path": "/dietary" }
            }
          }
        }
      },
      {
        "id": "submit-text",
        "component": "Text",
        "text": "确认预订"
      }
    ]
  }
}

// 消息 3:填充初始数据
{
  "version": "v0.9",
  "updateDataModel": {
    "surfaceId": "booking",
    "path": "/",
    "value": {
      "title": "预订 - 西安名吃",
      "restaurantName": "西安名吃",
      "imageUrl": "https://example.com/xian.jpg",
      "partySize": "2",
      "reservationTime": "",
      "dietary": ""
    }
  }
}

这里有三个关键交互机制值得注意。

双向绑定——TextField 的 value 绑定到 /partySize,用户输入 "4" 时,本地数据模型立即更新为 {"partySize": "4"},完全在客户端本地完成,没有网络请求。

Action 的 context——Button 的 action.event.context 定义了提交时要携带哪些数据。每个 key 的 value 用 path 指向数据模型,客户端在点击时解析出当前值。

当用户点击"确认预订",客户端发送的消息如下:

{
  "version": "v0.9",
  "action": {
    "name": "submit_booking",
    "surfaceId": "booking",
    "sourceComponentId": "submit-btn",
    "timestamp": "2026-03-18T19:30:00Z",
    "context": {
      "restaurant": "西安名吃",
      "partySize": "4",
      "time": "2026-03-19T19:00:00Z",
      "dietary": "不吃辣"
    }
  }
}

Agent 端 Python 处理代码类似:

if action_name == "submit_booking":
    restaurant = context.get("restaurant")
    party_size = context.get("partySize")
    time = context.get("time")
    # 让 LLM 处理
    query = f"用户预订了 {restaurant}{party_size} 人,时间 {time}"
    response = await llm.generate(query)

🔴 高级:动态列表 + 模板渲染

适合人群:需要高效渲染大量数据的架构师和高级开发者

当 Agent 返回一组搜索结果时,不需要为每条结果分别定义组件——用一个模板 + 数据数组即可自动渲染:

// 组件定义:一个模板驱动的列表
{
  "version": "v0.9",
  "updateComponents": {
    "surfaceId": "search-results",
    "components": [
      {
        "id": "root",
        "component": "Column",
        "children": ["result-header", "result-list"]
      },
      {
        "id": "result-header",
        "component": "Text",
        "text": "为你找到以下餐厅:",
        "variant": "h2"
      },
      {
        "id": "result-list",
        "component": "List",
        "children": {
          "componentId": "restaurant-card",
          "path": "/restaurants"
        },
        "direction": "vertical"
      },
      {
        "id": "restaurant-card",
        "component": "Card",
        "child": "card-layout"
      },
      {
        "id": "card-layout",
        "component": "Row",
        "children": ["card-img", "card-info"]
      },
      {
        "id": "card-img",
        "component": "Image",
        "url": { "path": "/imageUrl" },
        "fit": "cover"
      },
      {
        "id": "card-info",
        "component": "Column",
        "children": ["card-name", "card-rating", "card-detail"]
      },
      {
        "id": "card-name",
        "component": "Text",
        "text": { "path": "/name" },
        "variant": "h3"
      },
      {
        "id": "card-rating",
        "component": "Text",
        "text": { "path": "/rating" },
        "variant": "caption"
      },
      {
        "id": "card-detail",
        "component": "Text",
        "text": { "path": "/detail" }
      }
    ]
  }
}

// 数据模型:一个数组,有多少项就渲染多少张卡片
{
  "version": "v0.9",
  "updateDataModel": {
    "surfaceId": "search-results",
    "path": "/restaurants",
    "value": [
      {
        "name": "西安名吃",
        "detail": "正宗手拉面,香辣可口",
        "rating": "★★★★☆",
        "imageUrl": "https://example.com/xian.jpg"
      },
      {
        "name": "韩朝",
        "detail": "地道四川菜",
        "rating": "★★★★☆",
        "imageUrl": "https://example.com/han.jpg"
      },
      {
        "name": "红农场",
        "detail": "现代中餐,农场直供",
        "rating": "★★★★☆",
        "imageUrl": "https://example.com/red.jpg"
      }
    ]
  }
}

核心原理是作用域路径。模板中的 { "path": "/name" } 不是指向全局根路径,而是自动限定到当前数组项。第一张卡片的 /name 解析为 /restaurants/0/name,即 "西安名吃";第二张解析为 /restaurants/1/name,即 "韩朝"。

 数据:/restaurants = [ {name:"西安名吃"}, {name:"韩朝"}, {name:"红农场"} ]
                          │                   │                │
 模板自动实例化            ▼                   ▼                ▼
 ┌─────────────┐  ┌─────────────┐  ┌─────────────┐
 │ 🖼️ 西安名吃  │  │ 🖼️ 韩朝      │  │ 🖼️ 红农场    │
 │ ★★★★☆      │  │ ★★★★☆      │  │ ★★★★☆      │
 │ 正宗手拉面   │  │ 地道四川菜   │  │ 现代中餐     │
 └─────────────┘  └─────────────┘  └─────────────┘

 新增一项到数组 → 自动多渲染一张卡片,无需修改组件定义!

🔴 高级:多 Agent 编排(Orchestrator)

适合人群:构建企业级多 Agent 系统的架构师

在真实的企业场景中,一个主协调器(Orchestrator)管理多个专业子 Agent,每个子 Agent 负责自己领域的 UI。这是仓库里 samples/agent/adk/orchestrator 示例所展示的架构:

                           ┌───────────────────┐
              用户问题       │   Orchestrator     │
         ───────────────── ▶│   (主协调 Agent)    │
                            │                   │
                            │  ① 意图识别        │
                            │  "找中餐" → 路由到  │
                            │   餐厅 Agent       │
                            └──┬──────┬─────┬──┘
                               │      │     │
               ┌───────────────┘      │     └───────────────┐
               ▼                      ▼                     ▼
   ┌───────────────────┐  ┌──────────────────┐  ┌──────────────────┐
   │  餐厅查找 Agent     │  │  联系人查找 Agent  │  │  数据图表 Agent   │
   │  (port 10003)      │  │  (port 10004)     │  │  (port 10005)    │
   │                    │  │                   │  │                  │
   │  返回:餐厅列表 UI  │  │  返回:联系人卡片  │  │  返回:图表 UI    │
   │  (A2UI JSON)       │  │  (A2UI JSON)      │  │  (A2UI JSON)     │
   └────────────────────┘  └───────────────────┘  └──────────────────┘

Orchestrator 需要处理两个关键安全问题:

Surface 所有权映射——当子 Agent 创建 Surface 时,Orchestrator 记录"这个 surfaceId 属于哪个子 Agent"。当用户在 UI 上操作触发 Action 时,Orchestrator 根据 surfaceId 把请求路由回正确的子 Agent。

数据模型隔离——当 sendDataModel: true 启用时,客户端会在每条消息元数据中附带所有 Surface 的数据模型。Orchestrator 必须在转发给子 Agent 前剥离其他 Agent 的数据,否则会导致跨 Agent 的数据泄露。

 客户端发来的元数据(包含所有 Surface 的数据):
 ┌──────────────────────────────────────┐
 │ a2uiClientDataModel: {              │
 │   surfaces: {                       │
 │     "restaurant-list": {...},  ◀─── 属于餐厅 Agent
 │     "contact-card":   {...},  ◀─── 属于联系人 Agent
 │     "sales-chart":    {...}   ◀─── 属于图表 Agent
 │   }                                 │
 │ }                                   │
 └──────────────────────────────────────┘
            │
    Orchestrator 必须 strip
            │
            ▼  转发给餐厅 Agent 时只保留:
 ┌──────────────────────────────────────┐
 │ a2uiClientDataModel: {              │
 │   surfaces: {                       │
 │     "restaurant-list": {...}        │  ✅ 只有自己的数据
 │   }                                 │
 │ }                                   │
 └──────────────────────────────────────┘

🔴 高级:自定义组件 Catalog

适合人群:需要扩展 A2UI 到特定业务领域的团队

标准 Catalog 只有通用组件。如果你需要地图、图表、股票行情等,就需要自定义 Catalog:

{
  "$id": "https://mycompany.com/catalogs/dashboard/v1/catalog.json",
  "components": {
    "allOf": [
      { "$ref": "basic_catalog.json#/components" },
      {
        "SalesChart": {
          "type": "object",
          "description": "交互式销售数据图表",
          "properties": {
            "chartType": {
              "type": "string",
              "enum": ["bar", "line", "pie"],
              "description": "图表类型"
            },
            "data": {
              "description": "绑定到数据模型的图表数据路径"
            },
            "title": {
              "type": "string",
              "description": "图表标题"
            }
          },
          "required": ["chartType", "data"]
        },
        "GoogleMap": {
          "type": "object",
          "description": "显示指定位置的 Google 地图",
          "properties": {
            "latitude":  { "type": "number" },
            "longitude": { "type": "number" },
            "zoom":      { "type": "integer", "default": 14 }
          },
          "required": ["latitude", "longitude"]
        }
      }
    ]
  }
}

然后 Agent 就可以这样使用自定义组件:

{
  "version": "v0.9",
  "updateComponents": {
    "surfaceId": "dashboard",
    "components": [
      {
        "id": "root",
        "component": "Column",
        "children": ["chart", "map"]
      },
      {
        "id": "chart",
        "component": "SalesChart",
        "chartType": "bar",
        "data": { "path": "/sales/quarterly" },
        "title": "Q4 销售数据"
      },
      {
        "id": "map",
        "component": "GoogleMap",
        "latitude": 31.2304,
        "longitude": 121.4737,
        "zoom": 12
      }
    ]
  }
}

整个协商流程如下:

 客户端                                     Agent
   │                                          │
   │  "我支持这些 Catalog":                     │
   │  [basic_catalog, dashboard/v1]           │
   │ ────────────────────────────────────────▶ │
   │                                          │
   │                      Agent 选择最佳匹配    │
   │                      dashboard/v1 ✅      │
   │                                          │
   │  createSurface:                          │
   │    catalogId: "dashboard/v1"             │
   │ ◀──────────────────────────────────────── │
   │                                          │
   │  此后该 Surface 只能用                     │
   │  dashboard/v1 中定义的组件                 │

五、v0.8 vs v0.9 差异速查表

两个版本的核心差异一图了然。如果你是新项目,建议直接用 v0.9;如果要维护旧代码,参考此表迁移。

       v0.8 (稳定版)                          v0.9 (草案版)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
组件格式:                                组件格式:
"component": {                          "component": "Text",
  "Text": {                             "text": "Hello"
    "text": {"literalString":"Hello"}
  }                                     ← 更扁平、更少 token
}

子组件:                                  子组件:
"children": {                           "children": ["a", "b"]
  "explicitList": ["a", "b"]
}                                        ← 标准数组

数据更新:                                数据更新:
[{"key":"name","valueString":"Alice"}]  {"name": "Alice"}
                                         ← 标准 JSON 对象

画布创建:                                画布创建:
beginRendering + surfaceUpdate          createSurface (含 catalogId)
                                         ← 显式目录协商

按钮样式:                                按钮样式:
"primary": true                         "variant": "primary"
                                         ← 更灵活的枚举

Action 格式:                             Action 格式:
{"name": "submit"}                      {"event": {"name": "submit"}}
                                         ← 支持 event/functionCall 区分

版本标识:                                版本标识:
无                                      每条消息含 "version": "v0.9"

六、安全模型图解

A2UI 的安全是多层防御体系,这是它区别于传统 iframe 方案的核心优势:

┌────────────────────────────────────────────────────────────┐
│                      安全防御层级                            │
├────────────────────────────────────────────────────────────┤
│                                                            │
│  第 1 层:声明式格式 ─ 不是代码,是数据                       │
│  ────────────────────────────────────────                  │
│  Agent 发送的是 JSON 描述,不是 HTML/JS                      │
│  客户端永远不会 eval() 任何 Agent 内容                       │
│                                                            │
│  第 2 层:组件目录白名单 ─ 只能用"菜单上的菜"                 │
│  ────────────────────────────────────────                  │
│  Agent 只能请求 Catalog 中预定义的组件                       │
│  未知组件类型直接被忽略或降级为占位符                          │
│                                                            │
│  第 3 层:双端 Schema 验证 ─ Agent 端 + 客户端都检查          │
│  ────────────────────────────────────────                  │
│  Agent 端:发送前验证 JSON 是否合法                           │
│  客户端:接收后再验证一次,不合法就报错给 Agent               │
│                                                            │
│  第 4 层:VALIDATION_FAILED 反馈 ─ LLM 自我纠正              │
│  ────────────────────────────────────────                  │
│  客户端告诉 Agent "你的 JSON 第X处不对"                       │
│  Agent 据此修正并重新生成                                    │
│                                                            │
│  第 5 层:Orchestrator 数据隔离 ─ 多 Agent 不互相窥探         │
│  ────────────────────────────────────────                  │
│  必须剥离其他 Agent 的数据模型后再转发                        │
│                                                            │
└────────────────────────────────────────────────────────────┘

七、与同类方案的对比一览

                 A2UI              MCP Apps           AG UI
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
本质          UI 描述格式        预构建 HTML          传输协议
                                (iframe)

渲染方式      原生组件            iframe 沙箱         开发者自定义
              (Flutter/Lit/      (远程服务器控制      (任何框架)
               Angular...)        外观)

样式控制      客户端掌控          远程服务器掌控       客户端掌控
              继承宿主应用风格    与宿主应用割裂       与宿主应用一致

安全模型      声明式数据          iframe 隔离         信任域内代码
              无代码执行          沙箱隔离             应用内信任

多 Agent      ✅ 跨信任边界       ✅ 多 MCP 服务器    ⚠️ 主要单 Agent

跨平台        ✅ Web/Mobile/      ⚠️ Web 为主         ✅ 协议层无关
              Desktop/Native     (iframe)

LLM 生成      ✅ 专为流式          ❌ 服务器预构建      ✅ 通过 A2UI
              输出设计                                 集成

关系          ── 互补 ──          ── 互补 ──          ── 互补 ──
              A2UI 是"内容"       MCP Apps 适合       AG UI 是"管道"
              AG UI 是"管道"      服务器掌控全部UI     A2UI 是"内容"
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

三者不是竞争关系,而是互补。典型的组合方式:

  ┌─────────────────────────────────────────────────┐
  │           常见技术栈组合                          │
  ├─────────────────────────────────────────────────┤
  │                                                 │
  │  组合 1:A2UI + A2A                              │
  │  ───────────────                                │
  │  用 A2A 协议在多 Agent 间传递 A2UI 消息           │
  │  适合:企业级多 Agent 系统                        │
  │                                                 │
  │  组合 2:A2UI + AG UI                            │
  │  ───────────────                                │
  │  AG UI 做传输和状态同步,A2UI 做 UI 格式          │
  │  适合:React 全栈应用                             │
  │                                                 │
  │  组合 3:A2UI + Flutter GenUI                    │
  │  ───────────────                                │
  │  GenUI SDK 底层就是 A2UI                         │
  │  适合:跨平台移动/桌面应用                        │
  │                                                 │
  └─────────────────────────────────────────────────┘

八、5 分钟跑起来:Quickstart

前置条件

Node.js (v18+)、uv (Python 包管理器)、Gemini API Key

四步启动

# 1. 克隆仓库
git clone https://github.com/google/a2ui.git
cd a2ui

# 2. 设置 API Key
export GEMINI_API_KEY="your_key_here"

# 3. 进入 Lit 客户端目录
cd samples/client/lit

# 4. 一键启动(安装依赖 + 构建渲染器 + 启动 Agent + 启动前端)
npm install
npm run demo:all

浏览器打开 http://localhost:5173,试试这些提示词:

"Find Chinese restaurants in NYC"    → 看 Agent 生成餐厅卡片列表
"Book a table for 2"                 → 看 Agent 生成预订表单
"What are your hours?"               → 看 Agent 选择不同 UI 布局

背后发生了什么:

你输入文字  ──▶  A2A Agent (Python)  ──▶  Gemini API
                      │
              生成 A2UI JSON (JSONL 流)
                      │
                      ▼
              Lit Web App  ──▶  A2UI Renderer  ──▶  原生 Web Components
                                                          │
                                                    你看到的 UI ✨

如果只想看组件长什么样(不需要 Agent 和 API Key):

cd samples/client/lit
npm install
npm start -- gallery

这会启动一个组件展览馆,展示所有标准组件的实际渲染效果。


九、用 ADK 构建你的第一个 A2UI Agent

以下是一个最小可运行的 Agent 端代码结构,使用 Google ADK:

# agent.py - 最小 A2UI Agent
from a2ui.core.schema.constants import VERSION_0_8
from a2ui.core.schema.manager import A2uiSchemaManager
from a2ui.basic_catalog.provider import BasicCatalog
from a2ui.core.schema.common_modifiers import remove_strict_validation
from google.adk.agents.llm_agent import Agent

# 1. 构建系统提示词(包含 A2UI Schema + 示例)
prompt = A2uiSchemaManager(
    VERSION_0_8,
    catalogs=[BasicCatalog.get_config(
        version=VERSION_0_8,
        examples_path="examples"    # 放 JSON 示例的目录
    )],
    schema_modifiers=[remove_strict_validation],
).generate_system_prompt(
    role_description="你是一个餐厅推荐助手,输出 A2UI JSON。",
    ui_description="用卡片列表展示餐厅。",
    include_schema=True,
    include_examples=True,
)

# 2. 定义工具
def get_restaurants(tool_context) -> str:
    import json
    return json.dumps([
        {"name": "西安名吃", "detail": "手拉面", "rating": "★★★★☆"},
        {"name": "韩朝",     "detail": "四川菜",  "rating": "★★★★☆"},
    ])

# 3. 创建 Agent
root_agent = Agent(
    model='gemini-2.5-flash',
    name="restaurant_agent",
    instruction=prompt,
    tools=[get_restaurants],
)

Agent 的输出会是两部分:文字回复 + A2UI JSON,用分隔符 ---a2ui_JSON--- 隔开。框架负责解析和流式发送给客户端。

整体开发流程:

  ┌──────────────┐     ┌──────────────┐     ┌──────────────┐
  │  ① 定义工具   │────▶│ ② 编写提示词  │────▶│ ③ 创建 Agent │
  │  get_data()  │     │  + Schema    │     │  ADK Agent   │
  │  book_table()│     │  + 示例 JSON  │     │              │
  └──────────────┘     └──────────────┘     └──────┬───────┘
                                                    │
                                                    ▼
  ┌──────────────┐     ┌──────────────┐     ┌──────────────┐
  │  ⑥ 处理 Action│◀────│ ⑤ 流式发送   │◀────│ ④ LLM 生成   │
  │  用户点了按钮 │     │  JSONL 到客户端│     │  A2UI JSON   │
  └──────────────┘     └──────────────┘     └──────────────┘

十、真实生产案例

A2UI 不是纸上协议——以下是已经在生产环境使用的项目:

┌─────────────────────────────────────────────────────────────┐
│                     生产部署案例                              │
├─────────────────┬───────────────────────────────────────────┤
│ Google Opal     │ AI 小应用平台,数十万人用自然语言创建应用     │
│                 │ A2UI 驱动动态生成式 UI                      │
├─────────────────┼───────────────────────────────────────────┤
│ Gemini          │ 企业级 AI Agent 平台                       │
│ Enterprise      │ Agent 生成审批面板、数据录入表单等           │
├─────────────────┼───────────────────────────────────────────┤
│ Flutter GenUI   │ 跨平台移动/桌面 SDK                        │
│ SDK             │ 底层使用 A2UI 协议                         │
├─────────────────┼───────────────────────────────────────────┤
│ Google ADK      │ Agent 开发框架                             │
│                 │ 内置 A2UI 渲染 + A2A 消息转换               │
├─────────────────┼───────────────────────────────────────────┤
│ AG UI /         │ React 全栈框架                             │
│ CopilotKit      │ Day-zero A2UI 兼容                        │
└─────────────────┴───────────────────────────────────────────┘

十一、路线图:通往 v1.0

  当前                        近期                        未来
  v0.8 (稳定) + v0.9 (草案)    ────────────────────▶      v1.0
  ┌───────────────────┐       ┌───────────────────┐    ┌────────────┐
  │ ✅ Lit 渲染器      │       │ 🔜 React 渲染器    │    │ 规范稳定化  │
  │ ✅ Angular 渲染器  │       │ 🔜 Jetpack Compose │    │ 更多传输层  │
  │ ✅ Flutter GenUI   │       │ 🔜 SwiftUI 渲染器  │    │ REST/gRPC  │
  │ ✅ Markdown 渲染器 │       │ 🔜 Svelte 渲染器   │    │ 更多 Agent  │
  │ ✅ A2A 传输        │       │ 🔜 REST 传输       │    │ 框架集成    │
  │ ✅ AG UI 传输      │       │ 🔜 WebSocket 传输  │    │            │
  └───────────────────┘       └───────────────────┘    └────────────┘

结语

A2UI 解决的问题看似简单——让 Agent 能"说 UI"——但背后的设计决策蕴含了对安全、LLM 能力边界、跨平台兼容性的深思熟虑。

如果你正在构建 AI Agent 驱动的应用,这张决策图或许能帮你判断 A2UI 是否适合:

你的 Agent 需要向用户展示丰富 UI 吗?
    │
    ├── 不需要,纯文本就够 ──▶ 不需要 A2UI
    │
    └── 需要 ──▶ Agent 和 UI 在同一信任域内吗?
                    │
                    ├── 是,同一个应用 ──▶ 直接用框架渲染也行
                    │
                    └── 否,跨信任边界 / 远程 Agent ──▶ ✅ A2UI 正是为此设计
                              │
                              └── 需要跨平台 (Web + Mobile + Desktop)?
                                       │
                                       └── ✅ A2UI 的框架无关性是核心优势

作为 Google 发起、Apache 2.0 许可的开源项目(目前 GitHub 上已有 13.3k Star、47 位贡献者),A2UI 正在积极迈向 v1.0。无论你是前端开发者、Agent 构建者还是架构师,现在都是参与的好时机。

仓库地址:github.com/google/A2UI 官方文档:a2ui.org 快速体验:git clone https://github.com/google/a2ui.git && cd a2ui/samples/client/lit && npm install && npm run demo:all

nestjs学习 - 守卫

NestJS 守卫是一个实现了 CanActivate 接口的类。

一、它是什么?

在 NestJS 里,「守卫(Guard)」是一种用来控制请求是否能进入路由处理器(Controller 方法) 的机制。

通俗点说:

守卫就是“门卫”——每次请求进来之前,它会先检查一下你有没有资格进去。

  • 通过进入下一步
  • 未通过❌,请求拒绝(比如返回 403 Forbidden)

核心职责:主要关注 授权(Authorization) 。虽然也可以做认证(Authentication),但通常认证由中间件或 Passport 策略处理,而守卫用于更细粒度的权限控制(如:只有管理员才能删除文章)。

在框架生命周期中,守卫的执行时机是:

请求进入 → 中间件 → 守卫 → 拦截器 → 管道 → 控制器 → 服务

二、怎么用?

创建守卫

import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';

@Injectable()
export class AuthGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    const request = context.switchToHttp().getRequest();

    // 简单示例:如果请求头中有 token,就放行
    const token = request.headers['authorization'];
    if (token) {
      return true; // 放行
    }

    // 否则拒绝
    return false;
  }
}

canActivate() 方法返回:

  • true → 允许进入控制器;
  • false → 阻止访问(会返回 403 Forbidden);
  • 也可以返回一个 Promise<boolean>Observable<boolean>(支持异步)。

应用守卫

你可以在三个层级使用守卫:

1. 方法级

import { UseGuards, Controller, Get } from '@nestjs/common';
import { AuthGuard } from './auth.guard';

@Controller('user')
export class UserController {
  @Get('profile')
  @UseGuards(AuthGuard)
  getProfile() {
    return { msg: '用户资料' };
  }
}

2. 控制器级

@UseGuards(AuthGuard)
@Controller('admin')
export class AdminController {
  @Get()
  getAdminData() {
    return '后台数据';
  }
}

3. 全局守卫

// main.ts
import { AppModule } from './app.module';
import { AuthGuard } from './auth.guard';
import { NestFactory } from '@nestjs/core';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalGuards(new AuthGuard());
  await app.listen(3000);
}
bootstrap();

三、使用场景

守卫最常见的用途是:权限控制 / 身份验证

  1. 身份验证(Authentication) :检查用户是否已登录(例如验证 JWT Token 是否存在且有效)。
  2. 角色授权(Role-based Authorization) :检查当前用户是否拥有特定角色(如 admin, editor)。
  3. 权限控制(Permission-based Authorization) :检查用户是否有执行特定操作的权限(如:user:delete)。
  4. IP 地址过滤:只允许特定 IP 段的请求访问。
  5. 功能开关:根据配置动态开启或关闭某些接口。
  6. 请求时间限制(比如只允许工作时间访问)

你可以把它理解为:

在“进入接口之前”的最后一道防线。

四、中间件 vs 守卫

1. 中间件

中间件 是通用的“流水线工人”,负责处理请求的通用逻辑(如日志、解析数据),它不知道具体的业务逻辑是什么。

中间件的盲区: 当中间件运行时,NestJS 还没有确定最终由哪个 Controller 的哪个方法来处理请求。因此,中间件无法知道当前请求是否需要“管理员权限”。你无法在中间件里写:“如果这个路由用了 @Roles('admin') 装饰器,则检查角色”。

2. 守卫:

守卫 是专业的“安检员”,专门负责授权决策(能不能进),它完全知道即将执行哪个具体的控制器方法,并能根据元数据做判断。

守卫的全知视角

守卫接收 ExecutionContext 对象。通过这个对象,你可以拿到:

  • context.getClass(): 当前的 Controller 类。
  • context.getHandler(): 当前正在执行的方法。
  • 结合 Reflector,你可以读取该方法上所有的装饰器元数据(例如 @Roles('admin'))。
  • 结论:凡是需要根据路由元数据(装饰器)来做判断的逻辑,必须用守卫。

3. 核心区别对比表

特性 中间件 (Middleware) 守卫 (Guard)
主要职责 通用逻辑:日志、压缩、Cookie 解析、原始请求预处理。 授权 (Authorization) :决定请求是否允许执行特定的 Handler。
执行时机 最早。在守卫、拦截器、管道之前执行。 中间。在中间件之后,拦截器和管道之前执行。
上下文感知 。只知道 reqres不知道具体要调用哪个 Controller 或哪个方法。 。拥有 ExecutionContext,知道具体的 Class、Handler 方法、参数类型等。
访问元数据 无法直接访问路由装饰器(如 @Roles, @Get)定义的元数据。 可以访问。配合 Reflector 可以轻松读取路由上的自定义元数据。
返回值/控制流 必须调用 next() 才能继续,或者直接 res.end() 结束响应。 返回 boolean (或 Promise/Observable)。true 放行,false 拒绝(抛出异常)。
依赖注入 支持,但配置稍显繁琐(通常通过 forRoot 或模块配置)。 完美支持,像普通 Service 一样注入依赖。
适用场景 记录所有请求日志、解析 JSON/Cookie、设置 CORS、Gzip 压缩。 检查 JWT、验证用户角色、IP 白名单、基于权限的访问控制。

为什么资深前端都在悄悄学 WebAssembly?

2026 年,WebAssembly(WASM)早已不是“前端黑科技”。
它正以静默的方式,重构前端工程师的能力边界

你可能以为 WASM 只是用来加速图像处理或跑个游戏引擎。
但真相是:顶尖团队用 WASM 解决的,从来不是“快一点”的问题,而是“能不能做”的问题

以下是资深前端不敢公开说、却在疯狂投入的 4 个真实原因。


原因1:JavaScript 的“能力天花板”,正在被 WASM 击穿

JavaScript 无法做这些事:

  • 直接操作二进制数据流(如解析 .zip、.pdf、.dwg)
  • 实现确定性浮点运算(金融/科学计算要求 IEEE 754 严格一致)
  • 运行成熟的 C/C++/Rust 生态库(如 OpenCV、FFmpeg、TensorFlow Lite)

而 WASM 可以。

案例:某在线 CAD 平台,将 Autodesk 的 C++ 渲染引擎编译为 WASM,直接在浏览器中打开 500MB 的工程图纸——过去这只能靠桌面软件。

这意味着什么?
前端不再只是“调 API + 写 UI”,而是能构建真正的生产力工具:视频剪辑器、3D 建模器、代码编译器、甚至操作系统模拟器。


原因2:WASM 是对抗“框架内卷”的终极武器

React、Vue、Svelte……框架月月新,API 天天变。
但 WASM 模块一旦编译,十年后仍可运行

更关键的是:WASM 与框架无关
你用 Rust 写的核心算法模块,今天嵌入 React,明天迁到 Svelte,后天跑在 Deno Edge Runtime——零改造成本

资深前端的焦虑,不是学不动新框架,而是怕自己变成“API 搬运工”。
而 WASM 让你沉淀可复用、跨平台、高壁垒的核心逻辑


原因3:隐私合规时代,WASM 是“数据不出浏览器”的唯一解

GDPR、CCPA、中国《个人信息保护法》……全球监管趋严。
用户数据一旦传到服务器,就是法律风险。

而 WASM 允许你在浏览器沙箱内完成敏感计算

  • 人脸模糊(不上传原始照片)
  • 医疗影像分析(DICOM 文件本地处理)
  • 财务报表加密(密钥永不离开设备)

某欧洲银行用 WASM 实现本地 KYC 验证,用户上传身份证 → 浏览器内 OCR + 活体检测 → 仅上传验证结果
合规成本下降 70%,用户信任度飙升。


原因4:WASM 正在成为“全栈统一语言”的桥梁

过去:前端写 JS,后端写 Go/Rust,算法写 Python——三套代码,三套部署,三套调试。

现在:用 Rust 写一次核心逻辑,编译成 WASM(前端用) + Native(后端用) + CLI(运维用)

案例:一个加密货币钱包项目

  • 浏览器端:WASM 运行签名算法
  • 移动端:Rust Native 库
  • 后台服务:同一份 Rust 代码编译为 gRPC 服务
    三端逻辑 100% 一致,漏洞率下降 90%

这不仅是效率提升,更是工程可靠性的质变


但别被 hype 蒙蔽:WASM 不是万能药

资深前端之所以“悄悄学”,是因为他们清楚 WASM 的边界:

  • 不能操作 DOM(必须通过 JS 调用)
  • 启动有冷启动开销(不适合高频小函数)
  • 调试体验仍弱于 JS(但 DevTools 已支持 WASM Source Map)

所以,WASM 的正确姿势是:JS 负责交互,WASM 负责计算——两者协同,而非替代。


如何开始?三条务实路径(2026 年最新)

  1. 从“痛点场景”切入

    • 图片/视频处理 → 试试 ffmpeg.wasm
    • 加密/哈希 → 用 wasm-crypto
    • 数学计算 → 编译 Eigen(C++ 线性代数库)
  2. 选择友好语言

    • Rust + wasm-pack(生态最成熟)
    • AssemblyScript(TypeScript 子集,学习曲线平缓)
    • C/C++ + Emscripten(适合移植现有库)
  3. 集成现代工具链

    // Vite / Webpack 5 原生支持 .wasm
    import init, { run_algorithm } from './pkg/my_wasm.js';
    await init();
    const result = run_algorithm(input);
    

结语:WASM 不是前端的终点,而是“能力主权”的起点

当别人还在争论“React vs Vue”,
聪明人已经用 WASM 把浏览器变成了通用计算终端

2026 年,前端工程师的价值,不再由“会几个框架”定义,
而由“能否用 WASM 解决别人解决不了的问题”决定。

未来的全栈开发者,左手 JS,右手 WASM。

学 WASM,不是为了取代 JavaScript,
而是为了让 JavaScript,只做它该做的事。


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

Redux 技术栈使用总结

Redux 技术栈使用总结

1. Redux 基本概念

Redux 是一个用于管理 JavaScript 应用状态的容器,遵循单向数据流原则。

核心概念

  • Store: 存储应用状态的单一数据源
  • Action: 描述状态变化的对象(必须包含 type 属性)
  • Reducer: 纯函数,根据当前状态和 action 返回新状态
  • Dispatch: 触发 action 的方法
  • Subscribe: 监听状态变化的方法

基本工作流程

View -> Dispatch Action -> Reducer -> Store -> View

简单示例

// Action Types
const INCREMENT = 'INCREMENT'
const DECREMENT = 'DECREMENT'

// Action Creators
function increment() {
  return { type: INCREMENT }
}

function decrement() {
  return { type: DECREMENT }
}

// Reducer
function counterReducer(state = { count: 0 }, action) {
  switch (action.type) {
    case INCREMENT:
      return { count: state.count + 1 }
    case DECREMENT:
      return { count: state.count - 1 }
    default:
      return state
  }
}

// Store
import { createStore } from 'redux'
const store = createStore(counterReducer)

// 使用
store.dispatch(increment())
console.log(store.getState()) // { count: 1 }

2. React-Redux 在 React 组件中的使用

React-Redux 是 Redux 的官方 React 绑定库,提供了将 Redux 状态连接到 React 组件的方法。

核心 API

  • Provider: 包裹应用,提供 Redux store
  • connect: 高阶组件,将组件连接到 Redux store

使用示例

import React from 'react'
import { Provider, connect } from 'react-redux'
import { createStore } from 'redux'
import counterReducer from './reducers'

// 创建 store
const store = createStore(counterReducer)

// 展示组件
class CounterComponent extends React.Component {
  render() {
    const { count, increment, decrement } = this.props
    return (
      <div>
        <p>Count: {count}</p>
        <button onClick={increment}>+</button>
        <button onClick={decrement}>-</button>
      </div>
    )
  }
}

// 映射 state 到 props
const mapStateToProps = (state) => {
  return { count: state.count }
}

// 映射 dispatch 到 props
const mapDispatchToProps = (dispatch) => {
  return {
    increment: () => dispatch({ type: 'INCREMENT' }),
    decrement: () => dispatch({ type: 'DECREMENT' })
  }
}

// 连接组件
const Counter = connect(mapStateToProps, mapDispatchToProps)(CounterComponent)

// 应用入口
const App = () => (
  <Provider store={store}>
    <Counter />
  </Provider>
)

3. React-Redux 在 React Hooks 中的使用

React-Redux 提供了 Hooks API,使函数组件可以更方便地使用 Redux。

核心 Hooks

  • useSelector: 从 Redux store 中获取状态
  • useDispatch: 获取 dispatch 函数

使用示例

import React from 'react'
import { Provider, useSelector, useDispatch } from 'react-redux'
import { createStore } from 'redux'
import counterReducer from './reducers'

// 创建 store
const store = createStore(counterReducer)

// 函数组件
const Counter = () => {
  // 获取状态
  const count = useSelector(state => state.count)
  // 获取 dispatch
  const dispatch = useDispatch()

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => dispatch({ type: 'INCREMENT' })}>+</button>
      <button onClick={() => dispatch({ type: 'DECREMENT' })}>-</button>
    </div>
  )
}

// 应用入口
const App = () => (
  <Provider store={store}>
    <Counter />
  </Provider>
)

4. Redux Thunk 的使用

Redux Thunk 是 Redux 的中间件,用于处理异步操作。

核心概念

  • 允许 action creator 返回函数而不是普通对象
  • 这个函数可以接收 dispatchgetState 作为参数
  • 可以在函数内部执行异步操作,完成后分发普通 action

使用示例

import { createStore, applyMiddleware } from 'redux'
import thunk from 'redux-thunk'
import counterReducer from './reducers'

// 创建带有 thunk 中间件的 store
const store = createStore(counterReducer, applyMiddleware(thunk))

// 异步 Action Creator
const fetchUser = (userId) => {
  return async (dispatch, getState) => {
    // 分发请求开始的 action
    dispatch({ type: 'FETCH_USER_REQUEST' })
    
    try {
      // 执行异步操作
      const response = await fetch(`https://api.example.com/users/${userId}`)
      const user = await response.json()
      
      // 分发请求成功的 action
      dispatch({ type: 'FETCH_USER_SUCCESS', payload: user })
    } catch (error) {
      // 分发请求失败的 action
      dispatch({ type: 'FETCH_USER_FAILURE', payload: error.message })
    }
  }
}

// 使用
dispatch(fetchUser(123))

5. Redux Toolkit 的概念和使用

Redux Toolkit 是官方推荐的 Redux 开发工具集,旨在简化 Redux 开发。

核心概念和 API

  • configureStore: 简化 store 创建,自动配置中间件和 devtools
  • createSlice: 自动生成 reducer 和 action creators
  • createAsyncThunk: 创建异步 thunk,自动处理 pending/fulfilled/rejected 状态
  • Immer 集成: 允许直接修改状态对象,自动转换为不可变更新

使用示例

import { configureStore, createSlice, createAsyncThunk } from '@reduxjs/toolkit'

// 异步 thunk
const fetchUser = createAsyncThunk(
  'user/fetchUser',
  async (userId) => {
    const response = await fetch(`https://api.example.com/users/${userId}`)
    return response.json()
  }
)

// 创建 slice
const counterSlice = createSlice({
  name: 'counter',
  initialState: {
    value: 0,
    user: null,
    loading: false,
    error: null
  },
  // 同步 reducers
  reducers: {
    increment: (state) => {
      state.value += 1 // 直接修改状态
    },
    decrement: (state) => {
      state.value -= 1
    }
  },
  // 异步状态处理
  extraReducers: (builder) => {
    builder
      .addCase(fetchUser.pending, (state) => {
        state.loading = true
      })
      .addCase(fetchUser.fulfilled, (state, action) => {
        state.loading = false
        state.user = action.payload
      })
      .addCase(fetchUser.rejected, (state, action) => {
        state.loading = false
        state.error = action.payload
      })
  }
})

// 自动生成的 action creators
export const { increment, decrement } = counterSlice.actions

// 创建 store
const store = configureStore({
  reducer: {
    counter: counterSlice.reducer
  }
})

// 在组件中使用
import { useSelector, useDispatch } from 'react-redux'
import { increment, decrement, fetchUser } from './counterSlice'

const Component = () => {
  const { value, user, loading } = useSelector(state => state.counter)
  const dispatch = useDispatch()

  return (
    <div>
      <p>Count: {value}</p>
      <button onClick={() => dispatch(increment())}>+</button>
      <button onClick={() => dispatch(decrement())}>-</button>
      <button onClick={() => dispatch(fetchUser(123))} disabled={loading}>
        {loading ? 'Loading...' : 'Fetch User'}
      </button>
      {user && <p>User: {user.name}</p>}
    </div>
  )
}

总结

  • Redux: 管理应用状态的容器,遵循单向数据流
  • React-Redux: 将 Redux 与 React 组件连接,提供 Providerconnect 等 API
  • React-Redux Hooks: 提供 useSelectoruseDispatch,简化函数组件使用 Redux
  • Redux Thunk: 处理异步操作的中间件,允许 action creator 返回函数
  • Redux Toolkit: 官方推荐的开发工具集,简化 Redux 开发,减少样板代码

Redux Toolkit 是当前 Redux 开发的最佳实践,它整合了多种常用的 Redux 工具和最佳实践,大大减少了样板代码,提高了开发效率。

【JavaScript面试题-this 绑定】请说明 `this` 在不同场景下的指向(默认、隐式、显式、new、箭头函数)。

今天我们来聊一聊 JavaScript 中一个既基础又让人头疼的概念——this

一、this 是什么?

简单来说,this 是函数执行时内部自动生成的一个对象,它指向调用该函数的上下文。你可以把它理解为函数内部的“环境变量”,代表了当前函数运行时所处的对象。

一个形象的比喻

想象一下,你有一个“自我介绍”的功能,不同的人调用它时,“我”这个字指向不同的人:

  • 小明说“叫小明”,这里的“我”就是小明。
  • 小红说“叫小红”,这里的“我”就是小红。

在 JavaScript 中,this 就像这句话里的“我”,而那个自我介绍的函数就像一句模板:“我叫 xxx”。这个模板里的 this.name 会根据是谁在调用而自动替换成对应的人名。

用代码表示:

javascript

function introduce() {
  console.log(`我叫 ${this.name}`);
}

const ming = { name: '小明', introduce };
const hong = { name: '小红', introduce };

ming.introduce(); // 我叫 小明(this 指向 ming)
hong.introduce(); // 我叫 小红(this 指向 hong)

这里的 introduce 函数内部的 this 就像“我”一样,随着调用者(ming 或 hong)不同,指向也不同。这就是 this 的动态性——它是在函数执行时,根据调用它的对象确定的。

二、this 能做什么?

理解了 this 是动态上下文,那么它能为我们做什么呢?

  • 让同一个函数服务于不同的对象,实现代码复用;
  • 在构造函数中初始化实例属性
  • 在事件处理中方便地访问触发元素
  • 显式地指定上下文,借用其他对象的方法
  • 在回调函数中优雅地保留外层 this

下面我们就通过一个个实战场景,来体会 this 的妙用。


三、实战场景一网打尽

场景1:对象方法中的 this —— 隐式绑定

假设我们有一个用户对象,需要输出用户的名称:

javascript

const user1 = {
  name: '小明',
  greet() {
    console.log(`大家好,我是 ${this.name}`);
  }
};

user1.greet(); // 大家好,我是 小明

当 greet 作为 user1 的方法被调用时,this 指向 user1,所以能正确访问 name

能做什么:我们可以定义多个类似的对象,使用同一个方法结构,轻松访问各自的数据。

陷阱:如果把方法赋值给一个变量再调用,this 就会丢失:

javascript

const fn = user1.greet;
fn(); // 大家好,我是 undefined (非严格模式下 this 指向 window,没有 name 属性)

解决方法:使用 bind 强制绑定 this,或者用箭头函数(后面会讲)。


场景2:构造函数中的 this —— new 绑定

在面向对象编程中,我们经常用构造函数来创建对象:

javascript

function Person(name, age) {
  this.name = name;
  this.age = age;
  this.intro = function() {
    console.log(`我叫 ${this.name},今年 ${this.age} 岁。`);
  };
}

const p1 = new Person('小红', 20);
p1.intro(); // 我叫 小红,今年 20 岁。

当使用 new 调用 Person 时,this 指向新创建的空对象,然后我们往这个对象上添加属性,最后返回这个对象。

能做什么:轻松批量创建结构相似的对象,并且每个对象的方法都能正确访问自己的属性。

注意:如果忘记写 newthis 会指向全局对象,导致全局变量污染。所以构造函数通常首字母大写,提醒自己用 new 调用。


场景3:DOM 事件处理中的 this

在浏览器中处理事件时,this 通常指向触发事件的 DOM 元素:

html

<button id="myBtn">点我</button>
<script>
  const btn = document.getElementById('myBtn');
  btn.addEventListener('click', function() {
    console.log(this); // <button id="myBtn">点我</button>
    this.textContent = '已点击';
  });
</script>

能做什么:在事件回调中直接通过 this 操作当前元素,非常方便。

注意:如果回调使用箭头函数,this 就会指向外层作用域(比如 window),无法直接操作元素。所以事件回调一般用普通函数。


场景4:显式指定 this —— call / apply / bind

有时候我们需要手动指定函数的 this,比如“借用”其他对象的方法。

javascript

const user2 = { name: '小刚' };
const user3 = { name: '小丽' };

function introduce(hobby) {
  console.log(`我是 ${this.name},喜欢 ${hobby}`);
}

introduce.call(user2, '篮球'); // 我是 小刚,喜欢 篮球
introduce.apply(user3, ['跳舞']); // 我是 小丽,喜欢 跳舞

const introduceXiaoGang = introduce.bind(user2, '足球');
introduceXiaoGang(); // 我是 小刚,喜欢 足球
  • call 和 apply 立即调用函数,区别是传参方式不同。
  • bind 返回一个新函数,永久绑定 this,可用于后续调用。

能做什么:实现函数复用,动态改变上下文;也可以用于“函数借用”,比如数组方法借用给类数组对象。


场景5:回调函数中保持 this —— 箭头函数的妙用

在异步回调或定时器中,我们经常需要访问外层的 this,但普通函数的 this 会指向全局(或 undefined 严格模式),导致无法访问期望的对象。

传统解决方式是用 var self = this 缓存,或者用 bind

javascript

function Counter() {
  this.count = 0;
  setInterval(function() {
    this.count++; // 这里的 this 指向 window,无法更新 count
    console.log(this.count);
  }, 1000);
}
new Counter(); // 输出 NaN 或 undefined

用 bind 修正:

javascript

function Counter() {
  this.count = 0;
  setInterval(function() {
    this.count++;
    console.log(this.count);
  }.bind(this), 1000);
}
new Counter(); // 1 2 3 ...

而箭头函数让这一切变得简单:箭头函数没有自己的 this,它会捕获定义时外层作用域的 this

javascript

function Counter() {
  this.count = 0;
  setInterval(() => {
    this.count++; // 这里的 this 继承自 Counter 实例
    console.log(this.count);
  }, 1000);
}
new Counter(); // 1 2 3 ...

能做什么:在回调、事件监听、Promise 等场景中,优雅地保留外层 this,避免繁琐的 self = this 或 bind

注意:箭头函数的 this 一旦确定,就无法通过 call/apply/bind 改变,所以不能用于动态上下文。


场景6:嵌套函数中的 this 问题

在对象方法内部定义普通函数,这个普通函数的 this 会指向全局(或 undefined),这常常让人困惑:

javascript

const obj = {
  name: 'obj',
  foo() {
    function bar() {
      console.log(this.name);
    }
    bar(); // 非严格模式输出 undefined 或 window.name
  }
};
obj.foo();

如何让 bar 也能访问 obj 的 name?有几种方法:

  • 用箭头函数(推荐):

    javascript

    foo() {
      const bar = () => {
        console.log(this.name);
      };
      bar(); // obj
    }
    
  • 在外层保存 this

    javascript

    foo() {
      const self = this;
      function bar() {
        console.log(self.name);
      }
      bar();
    }
    
  • 用 bind

    javascript

    foo() {
      function bar() {
        console.log(this.name);
      }
      bar.bind(this)();
    }
    

能做什么:保证嵌套函数也能访问外层对象的属性,避免作用域丢失。


四、this 绑定规则优先级(一句话总结)

当多种规则同时适用时,this 的绑定优先级是:

new 绑定 > 显式绑定(call/apply/bind) > 隐式绑定(对象方法) > 默认绑定(独立调用)

箭头函数不参与这个优先级,它完全由外层作用域决定。


五、总结与思考

回到最初的问题:this 能做什么?

  • 它让函数灵活地适应不同的调用对象,实现代码复用;
  • 它在构造函数中帮助我们初始化实例;
  • 它在事件处理中方便操作当前元素;
  • 它通过显式绑定让我们能动态指定上下文;
  • 它配合箭头函数,优雅地解决了回调中的 this 保持问题。

掌握 this 的关键,不是死记硬背规则,而是在写代码时问自己:这个函数是怎么被调用的?  调用方式决定了 this 的指向。

希望这篇文章能帮你从“this 是什么”的困惑,走向“this 能做什么”的熟练应用。如果你有更多关于 this 的实战经验或疑惑,欢迎在评论区留言讨论!


最后留个思考题:下面代码的输出是什么?为什么?

javascript

const length = 10;
function fn() {
  console.log(this.length);
}
const obj = {
  length: 5,
  method(fn) {
    fn();
    arguments[0]();
  }
};
obj.method(fn, 1);

(答案:先输出 10(或 undefined),然后输出 2。因为第一次调用 fn() 是默认绑定,第二次 arguments[0]() 是隐式绑定,this 指向 arguments 对象,其 length 是传入的参数个数,即 2。)

欢迎留言你的答案和理解!我们下期再见。

#前端、#前端面试、#干货

如果这篇这篇文章对您有帮助?关注、点赞、收藏,三连支持一下。
有疑问或想法?评论区见

小程序-下拉刷新不走回调函数

下拉刷新

配置与回调

  • .json 文件中添加配置开启下拉刷新
{
  "enablePullDownRefresh": true,//开启下拉刷新
  "backgroundTextStyle": "dark" //配置颜色
}
  • onPullDownRefresh 是下拉刷新的回调函数
/**
* 页面相关事件处理函数--监听用户下拉动作
*/
onPullDownRefresh() {
    wx.showNavigationBarLoading();
},
  • stopPullDownRefresh 是自己写的停止下拉动效函数
stopPullDownRefresh() {
    wx.stopPullDownRefresh();
    wx.hideNavigationBarLoading();
},

Question

Q1: 下拉动效出来了,但是没有触发回调函数
原因: 页面问题:页面高度 = 屏幕高度,没有任何可滚动空间
  • 代码里的布局逻辑(必然是有这种结构):
page { height: 100%; }
.container { height: 100vh; }
.full_screen_container { height: 100%; }
  • 这种写法会导致:

  • 页面高度 = 手机屏幕高度 → 页面无法滚动 → 系统认为 “没有下拉动作” → 不触发 onPullDownRefresh 回调

  • 但!系统依然会播放下拉动画(因为配置开着)。

  • 下拉刷新动画是 【系统全局自动触发】 的,只要配置了 enablePullDownRefresh:true,不管页面能不能滚动、不管回调写没写,动画都会出现!

  • onPullDownRefresh ()回调函数是业务逻辑触发,必须满足页面存在可滚动区域 + 页面真的发生了下拉滚动行为才会执行!

解决方案
方案一:
  • 把根容器改成这样
/* 必须去掉固定 100% 高度!!! */
page {
  height: auto; /* 关键 */
  min-height: 100%;
}

.container {
  min-height: 100vh; /* 不能写死 height */
  overflow: visible;
}
方案二:
/* 给页面加一个看不见的高度,强制让页面可滚动 */
page::after {
  content: '';
  display: block;
  height: 1rpx;
}
❌