普通视图

发现新文章,点击刷新页面。
昨天 — 2026年3月18日掘金 前端

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

作者 烛阴
2026年3月18日 22:11

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 辅助开发真正高效起来

作者 牛奶
2026年3月18日 21:30

先问个问题

你有没有这种感觉:每天和 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的内测码,然后沉默了。

作者 why技术
2026年3月18日 20:26

你好呀,我是歪歪。

在前几天 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 开源项目推荐

作者 柳杉
2026年3月18日 19:11

作者:柳杉前端
标签:#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 学会了编程,变量函数循环全都安排上

作者 kyriewen
2026年3月18日 18:05

昨天我们用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 深拷贝全解析

作者 小金鱼Y
2026年3月18日 18:00

在 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 开始走向自我进化

作者 Moment
2026年3月18日 17:47

我正在开发 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核心原理+实战用法全解析

2026年3月18日 17:41

前言

无论是单页面应用(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% 的人不知道

作者 yuxi2020
2026年3月18日 17:38

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 安全地生成界面

作者 王小酱
2026年3月18日 17:36

引言:当 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 个预构建组件)

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

2026年3月18日 17:30

结合调研数据,核心比例结论先明确:仅 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 缓存加速与调度原理

2026年3月18日 17:16

前言

在前端性能优化中,静态资源加载速度往往是首屏渲染的瓶颈。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 操控员

作者 王小酱
2026年3月18日 17:14

一、从一个问题说起:为什么需要"页面内"的 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 "说出"用户界面的开放协议

作者 王小酱
2026年3月18日 17:11

引言: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学习 - 守卫

作者 web_bee
2026年3月18日 16:40

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?

作者 前端Hardy
2026年3月17日 17:20

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 技术栈使用总结

作者 三年三月
2026年3月17日 09:53

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、箭头函数)。

2026年3月18日 15:27

今天我们来聊一聊 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。)

欢迎留言你的答案和理解!我们下期再见。

#前端、#前端面试、#干货

如果这篇这篇文章对您有帮助?关注、点赞、收藏,三连支持一下。
有疑问或想法?评论区见

小程序-下拉刷新不走回调函数

作者 喂_balabala
2026年3月18日 15:15

下拉刷新

配置与回调

  • .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;
}
❌
❌