阅读视图

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

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

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

前言

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

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

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

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

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

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

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

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

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

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

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

3. @forward:合并转发

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

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

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

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

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

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

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

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

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

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

$primary: #8A2BE2;

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

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

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

2. 数字函数:算清楚

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

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

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

3. 字符串函数:玩文字

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

4. 检查函数:知己知彼

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

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

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

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

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

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

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

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

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

2. @for:循环造样式

你写过这样的代码吗?

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

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

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

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

3. @each:遍历列表

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

编译成:

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

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

2. 使用@error做校验

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

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

3. 使用@debug和@warn

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

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

六、总结

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

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

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

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

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

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

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

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

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

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

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

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

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

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

核心特点

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

常用实现方式

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

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

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

    const newArr = [].concat(arr);
    

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

  • toReversed()reverse() 方法

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

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

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

    核心语法

Object.assign(target, ...sources)

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

代码示例:

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

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

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

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

核心特点

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

常用实现方式

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

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

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

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

2. structuredClone()

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

const newObj = structuredClone(obj);

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

四、总结

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

这次发布意味着什么

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

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

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

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

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

前言

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

一、 路由的本质:Hash vs History

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

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

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

2. History 模式 (window.history)

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

二、 底层原理实现

1. Hash 模式实现链路

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

2. History 模式实现链路

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

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

  • 操作记录

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

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


三、 Vue 路由跳转实战

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

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

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

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

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

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

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

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

方法二:编程式导航 useRouter

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

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

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

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

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

四、 Vue 路由监听三大方法

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

1. 使用 watch + useRoute

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

const route = useRoute();

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

2. 路由守卫 onBeforeRouteUpdate

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

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

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

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

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

window.addEventListener('popstate', callback)


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

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

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

前言

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

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

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

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

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

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

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

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

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

这样做的好处:

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

进阶技巧

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

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

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

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

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

传统方式

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

Composer 方式

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

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


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

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

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

为什么重要

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

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


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

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

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

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

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

# 项目规则

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

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

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

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

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

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

这个功能我用得最频繁。

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

传统方式

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

Inline Edit 方式

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

效率对比

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

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

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

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

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

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

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


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

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

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

传统方式

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

Cursor 方式

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

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


避坑指南

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

坑 1:过度依赖 AI

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

后果

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

建议

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

坑 2:不审查就提交

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

后果

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

建议

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

坑 3:忽视上下文限制

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

后果

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

建议

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

效率对比

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

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

平均效率提升: 65%

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

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

结语

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

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

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

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

最后

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

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


互动

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

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

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

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

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

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


一、传统方式的困境

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

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

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

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

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


二、全局架构总览

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

2.1 核心工作流(三步走)

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

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

2.2 包架构全景图

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

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

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


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

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

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

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

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

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


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

4.1 安装

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

4.2 最简三步

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

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

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

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

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

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

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

4.3 渲染流程图解

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

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


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

5.1 数据流全景

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

5.2 服务端:API Route

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

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

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

  return result.toTextStreamResponse();
}

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

5.3 客户端:流式渲染

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

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

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

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

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

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

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

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

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

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

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

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


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

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

6.1 表达式速查表

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

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

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

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

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

6.3 实战:repeat 列表渲染

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

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

6.4 表达式解析流程

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

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


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

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

7.1 简单条件

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

/form/hasErrors 为真值时显示。

7.2 组合条件(AND + OR)

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

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

7.3 条件求值引擎

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

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

8.1 Watchers + $computed:级联选择器

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

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

交互流程图:

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

注册 $computed 函数:

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

8.2 跨字段表单验证 + validateForm

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

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

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

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

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

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

8.3 Inline 模式:聊天中的 Generative UI

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

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

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

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

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

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

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

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

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

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

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

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

9.1 内置 StateStore 的工作原理

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

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

9.2 接入外部状态管理

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

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

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

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


十、跨平台能力矩阵

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

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

生成 PDF 示例:

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

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

const buffer = await renderToBuffer(spec);

生成 OG 图片:

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

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

十一、两种生成模式对比

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

十二、设计哲学总结

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

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

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

13.1 项目结构

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

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

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

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

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

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

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

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

13.4 API Route:对接 AI

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

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

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

  return result.toTextStreamResponse();
}

13.5 前端页面:组装一切

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

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

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

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

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

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

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

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

十四、与其他方案的对比

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

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


十五、适用场景速查

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

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

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

结语

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

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

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

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

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

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

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

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

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

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

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

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

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

这套体系的核心价值:

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

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

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

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

示例

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

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

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

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

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

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

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

2.4 配置步骤

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

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

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

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

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

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

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

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

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

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

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

三、AI 系统 SLO 落地案例

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

3.4.3 跳转链路配置

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

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

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

3.6 案例落地效果

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

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

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

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

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

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

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

*ST恒久:董事长代行董事会秘书职责

36氪获悉,*ST恒久公告,因原董事会秘书张冬云辞职后公司董秘空缺已超过三个月,根据《深圳证券交易所股票上市规则》规定,自本公告披露日起,由董事长刘荣代行董事会秘书职责,直至公司聘任新的董事会秘书。公司此前已指定董事李勇在空缺初期代行相关职责,并将按规定尽快选聘新任董事会秘书。

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

前言

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

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

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

1. 解决的痛点

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

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

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

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

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

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

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

  5. 资源获取与回源

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

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


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

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

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

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

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

1. CDN 预热 (Pre-warming)

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

2. CDN 刷新 (Refresh)

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

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

1. 第三方库托管

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

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

2. 静态资源部署

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

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

六、核心总结

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

小米已注册多个龙字商标

36氪获悉,3月17日,小米创始人雷军宣布,新一代小米SU7将搭载“小米蛟龙底盘”。爱企查App显示,小米科技有限责任公司已注册多个“龙”字商标,包括“小米龙骨”“小米龙鳞”“小米龙晶”“小米龙铠”“小米龙脊”,国际分类包括科学仪器、金属材料、橡胶制品、绳网袋篷等。

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

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

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

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

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

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

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


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

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

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

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

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

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

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

import { PageAgent } from 'page-agent'

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  return { status, history, activity }
}

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

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

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

🔴 高级:对接外部 Agent 系统

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

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

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

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


三、Monorepo 架构全景图

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

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

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


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

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

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

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

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

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

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

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

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

4.2 MacroTool:强制"先想后做"

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

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

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

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

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

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


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

5.1 纯文本路线 vs 截图路线

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

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

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

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

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

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

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

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

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

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

clickElement(element) 的执行序列:

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

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


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

6.1 OpenAI 兼容协议统一天下

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

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

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

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

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

6.3 AutoFixer:当 LLM 不守规矩时

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

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

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

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

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

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

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

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


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

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

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

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

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


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

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

onDispose    ◀── agent.dispose()

配合 transformPageContent(数据脱敏)和 customSystemPrompt(完全自定义提示词),开发者拥有对 Agent 行为的完全控制权。


九、使用限制:诚实面对能力边界

PageAgent 选择了"纯文本 DOM"路线,这意味着:

能做的:点击、文本输入、下拉选择、表单提交、页面滚动、焦点切换、执行 JavaScript。

做不到的:悬停(hover)、拖拽、右键菜单、键盘快捷键、坐标定位操作、图片/Canvas/WebGL/SVG 等视觉内容识别、Monaco/CodeMirror 等特殊编辑器。

语义化的 HTML 和良好的可访问性(ARIA 标签等)会显著提升 Agent 效果。反常识的交互逻辑、纯视觉的操作提示则会降低成功率。


十、总结:一个务实的工程决策

通读源码后,PageAgent 的核心设计哲学可以归纳为三个词:

务实——纯文本 DOM 而非截图,牺牲视觉理解换来对普通模型的兼容性和更低的成本。MacroTool 强制"先想后做",在可控性和灵活性之间找到平衡。

容错——从 AutoFixer 对畸形输出的修复,到 modelPatch 对不同模型的适配,到提示词中鼓励"可以失败",整个系统对不确定性有很高包容度。

解耦——Core、PageController、UI、LLMs 四层分明,任何一层可独立替换。你可以只用 Core 做无头自动化,也可以换上自己的 React UI,还可以把它嵌入你现有的 Agent 系统作为"手和眼"。

对于 SaaS 开发者想快速给产品加 AI Copilot、企业想做管理后台的智能化改造、或者无障碍增强场景,PageAgent 提供了目前门槛最低的入口——一行 <script> 标签,你的网页就有了一个 AI 操作员。

乘联分会:3月1-15日全国乘用车新能源市场零售28.5万辆,同比下降28%

36氪获悉,乘联分会公布数据显示,3月1-15日,全国乘用车新能源市场零售28.5万辆,同比去年3月同期下降28%,较上月同期增长36%,今年以来累计零售134.5万辆,同比下降26%;3月1-15日,全国乘用车厂商新能源批发32.5万辆,同比去年3月同期下降19%,较上月同期增长47%,今年以来累计批发191.4万辆,同比下降10%。

乘联分会:3月1-15日全国乘用车市场零售56.1万辆,同比下降21%

36氪获悉,乘联分会公布数据显示,3月1-15日,全国乘用车市场零售56.1万辆,同比去年3月同期下降21%,较上月同期增长2%,今年以来累计零售314万辆,同比下降19%;3月1-15日,全国乘用车厂商批发64.8万辆,同比去年3月同期下降20%,较上月同期增长36%,今年以来累计批发414.1万辆,同比下降12%。

A2UI 深度解读:让 AI Agent "说出"用户界面的开放协议

引言:Agent 时代的 UI 困境

想象这样一个场景——你对一个 AI 助手说:"帮我订一张明天晚上 7 点的两人桌。" 如果 Agent 只能回复文本,接下来将是:

用户: "帮我订一张明天晚上7点的两人桌"
Agent: "好的,请问几位用餐?"
用户: "两位"
Agent: "请问哪天?"
用户: "明天"
Agent: "什么时间?"
用户: "晚上7点"
Agent: "有什么忌口吗?"
...(五六个回合后终于订完)

更好的方式是:Agent 直接生成一个表单——日期选择器、时间选择器、人数输入框、提交按钮,一步搞定。但传统方案(Agent 返回 HTML/JS 塞进 iframe)笨重、割裂、不安全。

A2UI(Agent-to-User Interface) 就是为此而生的 Google 开源协议:Agent 发送声明式 JSON 描述界面意图,客户端用自己的原生组件渲染。安全如数据,表达如代码。


一、A2UI 全景架构图

先来一张图看全貌——A2UI 的核心是把 UI 生成和 UI 执行彻底解耦:

┌──────────────────────────────────────────────────────────────┐
│                        用户 (User)                           │
│    输入:"帮我找纽约的中餐馆"    │    看到原生渲染的卡片列表    │
└───────────────┬──────────────────────────────▲───────────────┘
                │ 文字请求                      │ 原生 UI
                ▼                              │
┌───────────────────────────────────────────────────────────────┐
│                   客户端应用 (Client App)                      │
│  ┌─────────────┐   ┌──────────────┐   ┌──────────────────┐   │
│  │  传输层      │   │ A2UI 渲染器   │   │  组件目录         │   │
│  │  (Transport) │──▶│  (Renderer)  │◀──│  (Catalog)       │   │
│  │  A2A/WS/SSE │   │  Lit/Angular │   │  Button, Card... │   │
│  └──────┬──────┘   │  /Flutter    │   └──────────────────┘   │
│         │          └──────────────┘                           │
└─────────┼────────────────────────────────────────────────────┘
          │ JSON 消息流 (JSONL)
          │
┌─────────▼─────────────────────────────────────────────────────┐
│                     AI Agent (后端)                             │
│  ┌───────────────┐    ┌──────────────────┐                     │
│  │  业务逻辑      │───▶│  A2UI 生成器      │                     │
│  │  (Tools/API)  │    │  (LLM 生成 JSON) │                     │
│  └───────────────┘    └──────────────────┘                     │
│                              │                                 │
│                     ┌────────▼────────┐                        │
│                     │   Gemini / GPT  │                        │
│                     │   等 LLM 模型    │                        │
│                     └─────────────────┘                        │
└────────────────────────────────────────────────────────────────┘

关键洞察:Agent 永远不会执行代码或操控 DOM。它只能从客户端预批准的"组件目录"中选取组件来组合界面——就像只能用菜单上的菜来点餐,不能自己跑进厨房。


二、三分钟理解核心概念

2.1 五个关键词

┌─────────────────────────────────────────────────────────────┐
│                    A2UI 五大核心概念                          │
├─────────────┬───────────────────────────────────────────────┤
│  Surface    │ 画布/容器,承载一组组件(如一个表单、一个卡片)  │
│  Component  │ UI 元素(Button, Text, Card, TextField...)    │
│  Data Model │ 应用状态,组件通过路径绑定到它                  │
│  Catalog    │ 组件目录,定义 Agent 能用哪些组件               │
│  Message    │ JSON 消息(创建画布/更新组件/更新数据/删除画布) │
└─────────────┴───────────────────────────────────────────────┘

2.2 邻接表模型:为什么是扁平列表而非嵌套树?

这是 A2UI 最独特的设计。传统 UI 描述用嵌套 JSON 树,但 LLM 生成深层嵌套时极易出错、难以流式传输。A2UI 把组件展平为一个列表,通过 ID 引用建立父子关系:

传统嵌套树(LLM 容易搞乱括号)        A2UI 邻接表(扁平 + ID 引用)
─────────────────────────            ──────────────────────────
{                                    components: [
  "Column": {                          { id: "root",    → Column, children: ["title","btn"] },
    "children": [                      { id: "title",   → Text, text: "Hello" },
      { "Text": { "Hello" } },        { id: "btn",     → Button, child: "btn-text" },
      { "Button": {                    { id: "btn-text",→ Text, text: "OK" }
        "child": {                   ]
          "Text": { "OK" }
        }
      }}
    ]
  }
}

层层嵌套,一个括号没对上就全废了         所有组件平铺,随时增量发送、按 ID 更新

2.3 数据绑定:结构与状态分离

组件定义"长什么样",数据模型定义"展示什么内容"。两者通过 JSON Pointer 路径连接:

         组件结构                              数据模型
    ┌──────────────┐                    ┌──────────────────┐
    │ Text          │                   │ {                │
    │ text: ────────┼───path───────────▶│   "user": {      │
    │   path:       │  "/user/name""name":"Alice"│
    │   "/user/name"│                   │   }              │
    └──────────────┘                    └──────────────────┘
                                              │
    当数据模型更新为 "Bob" 时 ──────────────────┘
    Text 自动显示 "Bob",无需重发组件定义!

三、消息生命周期图解

以一个完整的餐厅预订流程为例,看 A2UI 消息如何流转:

 用户                        客户端                         Agent
  │                            │                              │
  │  "订两人桌"                 │                              │
  │ ──────────────────────────▶│                              │
  │                            │  将用户消息转发给 Agent        │
  │                            │ ─────────────────────────────▶│
  │                            │                              │
  │                            │   ① createSurface            │
  │                            │◀─ (创建画布,指定 Catalog)──── │
  │                            │                              │
  │                            │   ② updateComponents         │
  │                            │◀─ (标题+人数框+日期框+按钮)── │
  │  看到表单渐进式渲染          │                              │
  │◀───────────────────────── │   ③ updateDataModel           │
  │                            │◀─ (日期="明天", 人数="2") ──── │
  │                            │                              │
  │  修改人数为 "3"             │                              │
  │ ──────────────────────────▶│  本地数据模型自动更新           │
  │                            │  /reservation/guests = "3"   │
  │                            │                              │
  │  点击「确认预订」            │                              │
  │ ──────────────────────────▶│                              │
  │                            │   ④ action                   │
  │                            │ ─(name:"confirm",context)───▶│
  │                            │                              │
  │                            │   ⑤ deleteSurface            │
  │  看到"预订成功"确认界面      │◀─ + 新 surface (确认卡片) ── │
  │◀───────────────────────── │                              │

四、实战示例:由浅入深

🟢 入门级:Hello World — 一张静态信息卡

适合人群:想快速了解 A2UI JSON 长什么样的开发者

这是最简单的 A2UI 示例——展示一张带标题和描述的卡片,没有交互,没有数据绑定,纯静态内容。

// 消息 1:创建画布
{
  "version": "v0.9",
  "createSurface": {
    "surfaceId": "hello-card",
    "catalogId": "https://a2ui.org/specification/v0_9/basic_catalog.json"
  }
}

// 消息 2:定义组件
{
  "version": "v0.9",
  "updateComponents": {
    "surfaceId": "hello-card",
    "components": [
      {
        "id": "root",
        "component": "Card",
        "child": "content"
      },
      {
        "id": "content",
        "component": "Column",
        "children": ["title", "desc"]
      },
      {
        "id": "title",
        "component": "Text",
        "text": "👋 欢迎使用 A2UI",
        "variant": "h1"
      },
      {
        "id": "desc",
        "component": "Text",
        "text": "这是一张由 Agent 生成的卡片,渲染为你应用的原生组件。"
      }
    ]
  }
}

解读如下——整个过程只需两条消息。createSurface 告诉客户端"我要创建一个画布,用基础组件目录"。updateComponents 发送四个组件:Card 是容器,Column 纵向排列子组件,两个 Text 分别是标题和正文。所有组件平铺在一个列表里,通过 childchildren 引用彼此的 ID。

渲染效果示意:

┌──────────────────────────┐
│ ┌──────────────────────┐ │
│ │  👋 欢迎使用 A2UI     │ │   ← h1 标题
│ │                      │ │
│ │  这是一张由 Agent     │ │   ← 正文描述
│ │  生成的卡片...        │ │
│ └──────────────────────┘ │
└──────────────────────────┘
         Card 容器

🟡 进阶级:带数据绑定的用户资料卡

适合人群:需要理解数据绑定、响应式更新的前端/全栈开发者

这个示例展示数据绑定的核心能力——组件不写死内容,而是绑定到数据模型的路径。当数据变化时,UI 自动刷新。

// 消息 1:创建画布
{
  "version": "v0.9",
  "createSurface": {
    "surfaceId": "profile",
    "catalogId": "https://a2ui.org/specification/v0_9/basic_catalog.json"
  }
}

// 消息 2:定义组件(结构)
{
  "version": "v0.9",
  "updateComponents": {
    "surfaceId": "profile",
    "components": [
      {
        "id": "root",
        "component": "Card",
        "child": "layout"
      },
      {
        "id": "layout",
        "component": "Column",
        "children": ["avatar", "name", "email", "role"]
      },
      {
        "id": "avatar",
        "component": "Image",
        "url": { "path": "/user/avatar" },
        "fit": "cover"
      },
      {
        "id": "name",
        "component": "Text",
        "text": { "path": "/user/name" },
        "variant": "h2"
      },
      {
        "id": "email",
        "component": "Text",
        "text": { "path": "/user/email" }
      },
      {
        "id": "role",
        "component": "Text",
        "text": { "path": "/user/role" },
        "variant": "caption"
      }
    ]
  }
}

// 消息 3:填充数据
{
  "version": "v0.9",
  "updateDataModel": {
    "surfaceId": "profile",
    "path": "/user",
    "value": {
      "name": "Sarah Chen",
      "email": "sarah@techco.com",
      "role": "Product Designer",
      "avatar": "https://example.com/sarah.jpg"
    }
  }
}

关键点在于,组件中的 { "path": "/user/name" } 就是数据绑定语法。渲染器看到它会去数据模型中读取 /user/name 的值来显示。当 Agent 后续发送新的 updateDataModel/user/name 改成 "Bob Lee" 时,名字自动变化,不需要重新发送组件定义。这就是结构与状态分离带来的高效更新。

   组件定义(不变)                  数据模型(可随时更新)
┌──────────────────┐          ┌────────────────────────┐
│ Text              │          │ { "user": {            │
│   text:           │─bindTo──▶│     "name": "Sarah"    │──▶ 显示 "Sarah"path:/user/name│         │   }                    │
└──────────────────┘          └────────────────────────┘
                                       │ Agent 发送数据更新
                              ┌────────▼───────────────┐
                              │ { "user": {            │
                              │     "name": "Bob"      │──▶ 自动显示 "Bob"
                              │   }                    │
                              └────────────────────────┘

🟡 进阶级:带表单交互的餐厅预订

适合人群:需要理解双向绑定和 Action 机制的开发者

这是官方 Demo 的核心场景——Agent 生成一个预订表单,用户填写后提交,Agent 收到数据进行处理。

// 消息 1:创建画布
{
  "version": "v0.9",
  "createSurface": {
    "surfaceId": "booking",
    "catalogId": "https://a2ui.org/specification/v0_9/basic_catalog.json"
  }
}

// 消息 2:定义表单组件
{
  "version": "v0.9",
  "updateComponents": {
    "surfaceId": "booking",
    "components": [
      {
        "id": "root",
        "component": "Column",
        "children": ["title", "img", "party-size", "datetime", "dietary", "submit-btn"]
      },
      {
        "id": "title",
        "component": "Text",
        "text": { "path": "/title" },
        "variant": "h2"
      },
      {
        "id": "img",
        "component": "Image",
        "url": { "path": "/imageUrl" }
      },
      {
        "id": "party-size",
        "component": "TextField",
        "label": "用餐人数",
        "value": { "path": "/partySize" },
        "textFieldType": "number"
      },
      {
        "id": "datetime",
        "component": "DateTimeInput",
        "label": "日期和时间",
        "value": { "path": "/reservationTime" },
        "enableDate": true,
        "enableTime": true
      },
      {
        "id": "dietary",
        "component": "TextField",
        "label": "饮食要求",
        "value": { "path": "/dietary" }
      },
      {
        "id": "submit-btn",
        "component": "Button",
        "child": "submit-text",
        "variant": "primary",
        "action": {
          "event": {
            "name": "submit_booking",
            "context": {
              "restaurant": { "path": "/restaurantName" },
              "partySize":  { "path": "/partySize" },
              "time":       { "path": "/reservationTime" },
              "dietary":    { "path": "/dietary" }
            }
          }
        }
      },
      {
        "id": "submit-text",
        "component": "Text",
        "text": "确认预订"
      }
    ]
  }
}

// 消息 3:填充初始数据
{
  "version": "v0.9",
  "updateDataModel": {
    "surfaceId": "booking",
    "path": "/",
    "value": {
      "title": "预订 - 西安名吃",
      "restaurantName": "西安名吃",
      "imageUrl": "https://example.com/xian.jpg",
      "partySize": "2",
      "reservationTime": "",
      "dietary": ""
    }
  }
}

这里有三个关键交互机制值得注意。

双向绑定——TextField 的 value 绑定到 /partySize,用户输入 "4" 时,本地数据模型立即更新为 {"partySize": "4"},完全在客户端本地完成,没有网络请求。

Action 的 context——Button 的 action.event.context 定义了提交时要携带哪些数据。每个 key 的 value 用 path 指向数据模型,客户端在点击时解析出当前值。

当用户点击"确认预订",客户端发送的消息如下:

{
  "version": "v0.9",
  "action": {
    "name": "submit_booking",
    "surfaceId": "booking",
    "sourceComponentId": "submit-btn",
    "timestamp": "2026-03-18T19:30:00Z",
    "context": {
      "restaurant": "西安名吃",
      "partySize": "4",
      "time": "2026-03-19T19:00:00Z",
      "dietary": "不吃辣"
    }
  }
}

Agent 端 Python 处理代码类似:

if action_name == "submit_booking":
    restaurant = context.get("restaurant")
    party_size = context.get("partySize")
    time = context.get("time")
    # 让 LLM 处理
    query = f"用户预订了 {restaurant}{party_size} 人,时间 {time}"
    response = await llm.generate(query)

🔴 高级:动态列表 + 模板渲染

适合人群:需要高效渲染大量数据的架构师和高级开发者

当 Agent 返回一组搜索结果时,不需要为每条结果分别定义组件——用一个模板 + 数据数组即可自动渲染:

// 组件定义:一个模板驱动的列表
{
  "version": "v0.9",
  "updateComponents": {
    "surfaceId": "search-results",
    "components": [
      {
        "id": "root",
        "component": "Column",
        "children": ["result-header", "result-list"]
      },
      {
        "id": "result-header",
        "component": "Text",
        "text": "为你找到以下餐厅:",
        "variant": "h2"
      },
      {
        "id": "result-list",
        "component": "List",
        "children": {
          "componentId": "restaurant-card",
          "path": "/restaurants"
        },
        "direction": "vertical"
      },
      {
        "id": "restaurant-card",
        "component": "Card",
        "child": "card-layout"
      },
      {
        "id": "card-layout",
        "component": "Row",
        "children": ["card-img", "card-info"]
      },
      {
        "id": "card-img",
        "component": "Image",
        "url": { "path": "/imageUrl" },
        "fit": "cover"
      },
      {
        "id": "card-info",
        "component": "Column",
        "children": ["card-name", "card-rating", "card-detail"]
      },
      {
        "id": "card-name",
        "component": "Text",
        "text": { "path": "/name" },
        "variant": "h3"
      },
      {
        "id": "card-rating",
        "component": "Text",
        "text": { "path": "/rating" },
        "variant": "caption"
      },
      {
        "id": "card-detail",
        "component": "Text",
        "text": { "path": "/detail" }
      }
    ]
  }
}

// 数据模型:一个数组,有多少项就渲染多少张卡片
{
  "version": "v0.9",
  "updateDataModel": {
    "surfaceId": "search-results",
    "path": "/restaurants",
    "value": [
      {
        "name": "西安名吃",
        "detail": "正宗手拉面,香辣可口",
        "rating": "★★★★☆",
        "imageUrl": "https://example.com/xian.jpg"
      },
      {
        "name": "韩朝",
        "detail": "地道四川菜",
        "rating": "★★★★☆",
        "imageUrl": "https://example.com/han.jpg"
      },
      {
        "name": "红农场",
        "detail": "现代中餐,农场直供",
        "rating": "★★★★☆",
        "imageUrl": "https://example.com/red.jpg"
      }
    ]
  }
}

核心原理是作用域路径。模板中的 { "path": "/name" } 不是指向全局根路径,而是自动限定到当前数组项。第一张卡片的 /name 解析为 /restaurants/0/name,即 "西安名吃";第二张解析为 /restaurants/1/name,即 "韩朝"。

 数据:/restaurants = [ {name:"西安名吃"}, {name:"韩朝"}, {name:"红农场"} ]
                          │                   │                │
 模板自动实例化            ▼                   ▼                ▼
 ┌─────────────┐  ┌─────────────┐  ┌─────────────┐
 │ 🖼️ 西安名吃  │  │ 🖼️ 韩朝      │  │ 🖼️ 红农场    │
 │ ★★★★☆      │  │ ★★★★☆      │  │ ★★★★☆      │
 │ 正宗手拉面   │  │ 地道四川菜   │  │ 现代中餐     │
 └─────────────┘  └─────────────┘  └─────────────┘

 新增一项到数组 → 自动多渲染一张卡片,无需修改组件定义!

🔴 高级:多 Agent 编排(Orchestrator)

适合人群:构建企业级多 Agent 系统的架构师

在真实的企业场景中,一个主协调器(Orchestrator)管理多个专业子 Agent,每个子 Agent 负责自己领域的 UI。这是仓库里 samples/agent/adk/orchestrator 示例所展示的架构:

                           ┌───────────────────┐
              用户问题       │   Orchestrator     │
         ───────────────── ▶│   (主协调 Agent)    │
                            │                   │
                            │  ① 意图识别        │
                            │  "找中餐" → 路由到  │
                            │   餐厅 Agent       │
                            └──┬──────┬─────┬──┘
                               │      │     │
               ┌───────────────┘      │     └───────────────┐
               ▼                      ▼                     ▼
   ┌───────────────────┐  ┌──────────────────┐  ┌──────────────────┐
   │  餐厅查找 Agent     │  │  联系人查找 Agent  │  │  数据图表 Agent   │
   │  (port 10003)      │  │  (port 10004)     │  │  (port 10005)    │
   │                    │  │                   │  │                  │
   │  返回:餐厅列表 UI  │  │  返回:联系人卡片  │  │  返回:图表 UI    │
   │  (A2UI JSON)       │  │  (A2UI JSON)      │  │  (A2UI JSON)     │
   └────────────────────┘  └───────────────────┘  └──────────────────┘

Orchestrator 需要处理两个关键安全问题:

Surface 所有权映射——当子 Agent 创建 Surface 时,Orchestrator 记录"这个 surfaceId 属于哪个子 Agent"。当用户在 UI 上操作触发 Action 时,Orchestrator 根据 surfaceId 把请求路由回正确的子 Agent。

数据模型隔离——当 sendDataModel: true 启用时,客户端会在每条消息元数据中附带所有 Surface 的数据模型。Orchestrator 必须在转发给子 Agent 前剥离其他 Agent 的数据,否则会导致跨 Agent 的数据泄露。

 客户端发来的元数据(包含所有 Surface 的数据):
 ┌──────────────────────────────────────┐
 │ a2uiClientDataModel: {              │
 │   surfaces: {                       │
 │     "restaurant-list": {...},  ◀─── 属于餐厅 Agent
 │     "contact-card":   {...},  ◀─── 属于联系人 Agent
 │     "sales-chart":    {...}   ◀─── 属于图表 Agent
 │   }                                 │
 │ }                                   │
 └──────────────────────────────────────┘
            │
    Orchestrator 必须 strip
            │
            ▼  转发给餐厅 Agent 时只保留:
 ┌──────────────────────────────────────┐
 │ a2uiClientDataModel: {              │
 │   surfaces: {                       │
 │     "restaurant-list": {...}        │  ✅ 只有自己的数据
 │   }                                 │
 │ }                                   │
 └──────────────────────────────────────┘

🔴 高级:自定义组件 Catalog

适合人群:需要扩展 A2UI 到特定业务领域的团队

标准 Catalog 只有通用组件。如果你需要地图、图表、股票行情等,就需要自定义 Catalog:

{
  "$id": "https://mycompany.com/catalogs/dashboard/v1/catalog.json",
  "components": {
    "allOf": [
      { "$ref": "basic_catalog.json#/components" },
      {
        "SalesChart": {
          "type": "object",
          "description": "交互式销售数据图表",
          "properties": {
            "chartType": {
              "type": "string",
              "enum": ["bar", "line", "pie"],
              "description": "图表类型"
            },
            "data": {
              "description": "绑定到数据模型的图表数据路径"
            },
            "title": {
              "type": "string",
              "description": "图表标题"
            }
          },
          "required": ["chartType", "data"]
        },
        "GoogleMap": {
          "type": "object",
          "description": "显示指定位置的 Google 地图",
          "properties": {
            "latitude":  { "type": "number" },
            "longitude": { "type": "number" },
            "zoom":      { "type": "integer", "default": 14 }
          },
          "required": ["latitude", "longitude"]
        }
      }
    ]
  }
}

然后 Agent 就可以这样使用自定义组件:

{
  "version": "v0.9",
  "updateComponents": {
    "surfaceId": "dashboard",
    "components": [
      {
        "id": "root",
        "component": "Column",
        "children": ["chart", "map"]
      },
      {
        "id": "chart",
        "component": "SalesChart",
        "chartType": "bar",
        "data": { "path": "/sales/quarterly" },
        "title": "Q4 销售数据"
      },
      {
        "id": "map",
        "component": "GoogleMap",
        "latitude": 31.2304,
        "longitude": 121.4737,
        "zoom": 12
      }
    ]
  }
}

整个协商流程如下:

 客户端                                     Agent
   │                                          │
   │  "我支持这些 Catalog":                     │
   │  [basic_catalog, dashboard/v1]           │
   │ ────────────────────────────────────────▶ │
   │                                          │
   │                      Agent 选择最佳匹配    │
   │                      dashboard/v1 ✅      │
   │                                          │
   │  createSurface:                          │
   │    catalogId: "dashboard/v1"             │
   │ ◀──────────────────────────────────────── │
   │                                          │
   │  此后该 Surface 只能用                     │
   │  dashboard/v1 中定义的组件                 │

五、v0.8 vs v0.9 差异速查表

两个版本的核心差异一图了然。如果你是新项目,建议直接用 v0.9;如果要维护旧代码,参考此表迁移。

       v0.8 (稳定版)                          v0.9 (草案版)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
组件格式:                                组件格式:
"component": {                          "component": "Text",
  "Text": {                             "text": "Hello"
    "text": {"literalString":"Hello"}
  }                                     ← 更扁平、更少 token
}

子组件:                                  子组件:
"children": {                           "children": ["a", "b"]
  "explicitList": ["a", "b"]
}                                        ← 标准数组

数据更新:                                数据更新:
[{"key":"name","valueString":"Alice"}]  {"name": "Alice"}
                                         ← 标准 JSON 对象

画布创建:                                画布创建:
beginRendering + surfaceUpdate          createSurface (含 catalogId)
                                         ← 显式目录协商

按钮样式:                                按钮样式:
"primary": true                         "variant": "primary"
                                         ← 更灵活的枚举

Action 格式:                             Action 格式:
{"name": "submit"}                      {"event": {"name": "submit"}}
                                         ← 支持 event/functionCall 区分

版本标识:                                版本标识:
无                                      每条消息含 "version": "v0.9"

六、安全模型图解

A2UI 的安全是多层防御体系,这是它区别于传统 iframe 方案的核心优势:

┌────────────────────────────────────────────────────────────┐
│                      安全防御层级                            │
├────────────────────────────────────────────────────────────┤
│                                                            │
│  第 1 层:声明式格式 ─ 不是代码,是数据                       │
│  ────────────────────────────────────────                  │
│  Agent 发送的是 JSON 描述,不是 HTML/JS                      │
│  客户端永远不会 eval() 任何 Agent 内容                       │
│                                                            │
│  第 2 层:组件目录白名单 ─ 只能用"菜单上的菜"                 │
│  ────────────────────────────────────────                  │
│  Agent 只能请求 Catalog 中预定义的组件                       │
│  未知组件类型直接被忽略或降级为占位符                          │
│                                                            │
│  第 3 层:双端 Schema 验证 ─ Agent 端 + 客户端都检查          │
│  ────────────────────────────────────────                  │
│  Agent 端:发送前验证 JSON 是否合法                           │
│  客户端:接收后再验证一次,不合法就报错给 Agent               │
│                                                            │
│  第 4 层:VALIDATION_FAILED 反馈 ─ LLM 自我纠正              │
│  ────────────────────────────────────────                  │
│  客户端告诉 Agent "你的 JSON 第X处不对"                       │
│  Agent 据此修正并重新生成                                    │
│                                                            │
│  第 5 层:Orchestrator 数据隔离 ─ 多 Agent 不互相窥探         │
│  ────────────────────────────────────────                  │
│  必须剥离其他 Agent 的数据模型后再转发                        │
│                                                            │
└────────────────────────────────────────────────────────────┘

七、与同类方案的对比一览

                 A2UI              MCP Apps           AG UI
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
本质          UI 描述格式        预构建 HTML          传输协议
                                (iframe)
                                
渲染方式

追觅AWE 2026全球首发AI智能泳镜

3月12日至15日,中国家电及消费电子博览会(AWE 2026)在上海启幕。展会期间,追觅AI智能泳镜迎来全球首发,同步亮相的还有追觅Flo S2骨传导运动耳机。据了解,追觅AI 智能泳镜搭载衍射光波导技术,实现同级最大30°视场角(FOV)与 640*480同级最高分辨率。产品同时搭载自研运动健康大模型,基于用户实时运动数据,生成赛事训练级专属运动方案。

微博:2025年总营收125.76亿元

36氪获悉,微博发布2025年第四季度及全年财报。财报显示,四季度微博总营收4.733亿美元,约合人民币33.37亿元;四季度调整后运营利润1.004亿美元,约合人民币7.02亿元。2025年全年,微博总营收达到17.6亿美元,约合人民币125.76亿元;归属于微博股东的净利润为4.49亿美元,约合人民币32.24亿元,同比增长49%。用户方面,截至四季度末,微博的月活跃用户达到5.67亿,日活跃用户达到2.52亿。

Docker Compose: Define and Run Multi-Container Apps

Docker Compose is a tool for defining and running multi-container applications. Instead of starting each container separately with docker run , you describe all of your services, networks, and volumes in a single YAML file and bring the entire environment up with one command.

This guide explains how Docker Compose V2 works, walks through a practical example with a SvelteKit development server and PostgreSQL database, and covers the most commonly used Compose directives and commands.

Quick Reference

For a printable quick reference, see the Docker cheatsheet .

Command Description
docker compose up Start all services (foreground)
docker compose up -d Start all services in detached mode
docker compose down Stop and remove containers and networks
docker compose down -v Also remove named volumes
docker compose ps List running services
docker compose logs SERVICE View logs for a service
docker compose logs -f SERVICE Follow logs in real time
docker compose exec SERVICE sh Open a shell in a running container
docker compose stop Stop containers without removing them
docker compose build Build or rebuild images
docker compose pull Pull the latest images

Prerequisites

The examples in this guide require Docker with the Compose plugin installed. Docker Desktop includes Compose by default. On Linux, install the docker-compose-plugin package alongside Docker Engine.

To verify Compose is available, run:

Terminal
docker compose version
output
Docker Compose version v2.x.x

Compose V2 runs as docker compose (with a space), not docker-compose. The old V1 binary is end-of-life and not covered in this guide.

The Compose File

Modern Docker Compose prefers a file named compose.yaml, though docker-compose.yml is still supported for compatibility. In this guide, we will use docker-compose.yml because many readers still recognize that name first.

A Compose file describes your application’s environment using three top-level keys:

  • services — defines each container: its image, ports, volumes, environment variables, and dependencies
  • volumes — declares named volumes that persist data across container restarts
  • networks — defines custom networks for service communication (Compose creates a default network automatically)

Compose V2 does not require a version: field at the top of the file. If you see version: '3' in older guides, that is legacy syntax kept for backward compatibility, not something new Compose files need.

YAML uses indentation to define structure. Use spaces, not tabs.

Setting Up the Example

In this example, we will run a SvelteKit development server alongside a PostgreSQL 16 database using Docker Compose.

Start by creating a new SvelteKit project. The npm create command launches an interactive wizard — select “Skeleton project” when prompted, then choose your preferred options for TypeScript and linting:

Terminal
npm create svelte@latest myapp
cd myapp

If you already have a Node.js project, skip this step and use your project directory instead.

Next, create a docker-compose.yml file in the project root:

docker-compose.ymlyaml
services:
 app:
 image: node:20-alpine
 working_dir: /app
 volumes:
 - .:/app
 - node_modules:/app/node_modules
 ports:
 - "5173:5173"
 environment:
 DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD}@db:5432/myapp
 depends_on:
 db:
 condition: service_healthy
 command: sh -c "npm install && npm run dev -- --host"

 db:
 image: postgres:16-alpine
 volumes:
 - postgres_data:/var/lib/postgresql/data
 environment:
 POSTGRES_USER: postgres
 POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
 POSTGRES_DB: myapp
 healthcheck:
 test: ["CMD-SHELL", "pg_isready -U postgres"]
 interval: 5s
 timeout: 5s
 retries: 5

volumes:
 postgres_data:
 node_modules:

Compose V2 automatically reads a .env file in the project directory and substitutes ${VAR} references in the compose file. Create a .env file to define the database password:

.envtxt
POSTGRES_PASSWORD=changeme
Warning
Do not commit .env to version control. Add it to your .gitignore file to keep credentials out of your repository.

The password above is for local development only. We will walk through each directive in the compose file in the next section.

Understanding the Compose File

Let us go through each directive in the compose file.

services

The services key is the core of any compose file. Each entry under services defines one container. The key name (app, db) becomes the service name, and Compose also uses it as the hostname for inter-service communication — so the app container can reach the database at db:5432.

image

The image directive tells Compose which Docker image to pull. Both node:20-alpine and postgres:16-alpine are pulled from Docker Hub if they are not already present on your machine. The alpine variant uses Alpine Linux as a base, which keeps image sizes small.

working_dir

The working_dir directive sets the working directory inside the container. All commands run in /app, which is where we mount the project files.

volumes

The app service uses two volume entries:

txt
- .:/app
- node_modules:/app/node_modules

The first entry is a bind mount. It maps the current directory on your host (.) to /app inside the container. Any file you edit on your host is immediately reflected inside the container, which is what enables hot-reload during development.

The second entry is a named volume. Without it, the bind mount would overwrite /app/node_modules with whatever is in your host directory — which may be empty or incompatible. The node_modules named volume tells Docker to keep a separate copy of node_modules inside the container so the bind mount does not interfere with it.

The db service uses a named volume for its data directory:

txt
- postgres_data:/var/lib/postgresql/data

This ensures that your database data persists across container restarts. When you run docker compose down, the postgres_data volume is kept. Only docker compose down -v removes it.

Named volumes must be declared at the top level under volumes:. Both postgres_data and node_modules are listed there with no additional configuration, which tells Docker to manage them using its default storage driver.

ports

The ports directive maps ports between the host machine and the container in "HOST:CONTAINER" format. The entry "5173:5173" exposes the Vite development server so you can open http://localhost:5173 in your browser.

environment

The environment directive sets environment variables inside the container. The app service receives the DATABASE_URL connection string, which a SvelteKit application can read to connect to the database.

Notice the ${POSTGRES_PASSWORD} syntax. Compose reads this value from the .env file and substitutes it before starting the container. This keeps credentials out of the compose file itself.

depends_on

By default, depends_on only controls the order in which containers start — it does not wait for a service to be ready. Compose V2 supports a long-form syntax with a condition key that changes this behavior:

yaml
depends_on:
 db:
 condition: service_healthy

With condition: service_healthy, Compose waits until the db service passes its healthcheck before starting app. This prevents the Node.js process from trying to connect to PostgreSQL before the database is accepting connections.

command

The command directive overrides the default command defined in the image. Here it runs npm install to install dependencies, then starts the Vite dev server with --host to bind to 0.0.0.0 inside the container (required for Docker to forward the port to your host):

txt
sh -c "npm install && npm run dev -- --host"

Running npm install on every container start is slow but convenient for development — you do not need to install dependencies manually before running docker compose up. For production, use a multi-stage Dockerfile to pre-install dependencies and build the application.

healthcheck

The healthcheck directive defines a command Compose runs inside the container to determine whether the service is ready. The pg_isready utility checks whether PostgreSQL is accepting connections:

yaml
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5

Compose runs this check every 5 seconds. Once the service passes its first check, it is marked as healthy and any services using condition: service_healthy are allowed to start. If the check fails 5 consecutive times, the service is marked as unhealthy.

Managing the Application

Run all commands from the project directory — the directory where your docker-compose.yml is located.

Starting Services

To start all services and stream their logs to your terminal, run:

Terminal
docker compose up

Compose pulls any missing images, creates the network, starts the db container, waits for it to pass its healthcheck, then starts the app container. Press Ctrl+C to stop.

To start in detached mode and run the services in the background:

Terminal
docker compose up -d

Viewing Status and Logs

To list running services and their current state:

Terminal
docker compose ps
output
NAME IMAGE COMMAND SERVICE STATUS PORTS
myapp-app-1 node:20-alpine "docker-entrypoint.s…" app Up 2 minutes 0.0.0.0:5173->5173/tcp
myapp-db-1 postgres:16-alpine "docker-entrypoint.s…" db Up 2 minutes 5432/tcp

To view the logs for a specific service:

Terminal
docker compose logs app

To follow the logs in real time (like tail -f):

Terminal
docker compose logs -f app

Press Ctrl+C to stop following.

Running Commands Inside a Container

To open an interactive shell inside the running app container:

Terminal
docker compose exec app sh

This is useful for running one-off commands, inspecting the file system, or debugging. Type exit to leave the shell.

Stopping and Removing Services

To stop running containers without removing them — preserving named volumes and the network:

Terminal
docker compose stop

To start them again after stopping:

Terminal
docker compose start

To stop containers and remove them along with the network:

Terminal
docker compose down

Named volumes (postgres_data, node_modules) are preserved. Your database data is safe.

To also remove all named volumes — this deletes your database data:

Terminal
docker compose down -v

Use this when you want a completely clean environment.

Common Directives Reference

The following directives are not used in the example above but are commonly needed in real projects.

restart

The restart directive controls what Compose does when a container exits:

yaml
services:
 app:
 restart: unless-stopped

The available policies are:

  • no — do not restart (default)
  • always — always restart, including on system reboot
  • unless-stopped — restart unless the container was explicitly stopped
  • on-failure — restart only when the container exits with a non-zero status

Use unless-stopped for long-running services in single-host deployments.

build

Instead of pulling a pre-built image, build tells Compose to build the image from a local Dockerfile :

yaml
services:
 app:
 build:
 context: .
 dockerfile: Dockerfile

context is the directory Compose sends to the Docker daemon as the build context. dockerfile specifies the Dockerfile path relative to context. If both the image and a build context exist, build takes precedence.

env_file

The env_file directive injects environment variables into a container from a file at runtime:

yaml
services:
 app:
 env_file:
 - .env.local

This is different from Compose’s native .env substitution. The .env file at the project root is read by Compose itself to substitute ${VAR} references in the compose file. The env_file directive injects variables directly into the container’s environment. Both can be used together.

networks

Compose creates a default bridge network connecting all services automatically. You can define custom networks to isolate groups of services or control how containers communicate:

yaml
services:
 app:
 networks:
 - frontend
 - backend
 db:
 networks:
 - backend

networks:
 frontend:
 backend:

A service only communicates with other services on the same network. In this example, app can reach db because both share the backend network. A service attached only to frontend — not backend — has no route to db.

profiles

Profiles let you define optional services that only start when explicitly requested. This is useful for development tools, debuggers, or admin interfaces that you do not want running all the time:

yaml
services:
 app:
 image: node:20-alpine

 adminer:
 image: adminer
 profiles:
 - tools
 ports:
 - "8080:8080"

Running docker compose up starts only app. To also start adminer, pass the profile flag:

Terminal
docker compose --profile tools up

Troubleshooting

Port is already in use
Another process on your host is using the same port. Change the host-side port in the ports: mapping. For example, change "5173:5173" to "5174:5173" to expose the container on port 5174 instead.

Service cannot connect to the database
If you are not using condition: service_healthy, depends_on only ensures the db container starts before app — it does not wait for PostgreSQL to be ready to accept connections. Add a healthcheck to the db service and set condition: service_healthy in depends_on as shown in the example above.

node_modules is empty inside the container
The bind mount .:/app maps your host directory to /app, which overwrites /app/node_modules with whatever is on your host. If your host directory has no node_modules, the container sees none either. Fix this by adding a named volume entry node_modules:/app/node_modules as shown in the example. Docker preserves the named volume’s contents and the bind mount does not overwrite it.

FAQ

What is the difference between docker compose down and docker compose stop?
docker compose stop stops the running containers but leaves them on disk along with the network and volumes. You can restart them with docker compose start. docker compose down removes the containers and the network. Named volumes are kept unless you add the -v flag.

How do I view logs for a specific service?
Run docker compose logs SERVICE, replacing SERVICE with the service name defined in your compose file (for example, docker compose logs app). To follow logs in real time, add the -f flag: docker compose logs -f app.

How do I pass secrets without hardcoding them in the Compose file?
Create a .env file in the project directory with your credentials (for example, POSTGRES_PASSWORD=changeme) and reference them in the compose file using ${VAR} syntax. Compose reads the .env file automatically. Add .env to your .gitignore so credentials are never committed to version control.

Can I use Docker Compose in production?
Compose works well for single-host deployments — it is simpler to operate than Kubernetes when you only have one server. Use restart: unless-stopped to keep services running after reboots, and keep secrets in environment variables rather than hardcoded values. For multi-host deployments, look at Docker Swarm or Kubernetes.

What is the difference between a bind mount and a named volume?
A bind mount (./host-path:/container-path) maps a specific directory from your host into the container. Changes on either side are immediately visible on the other. A named volume (volume-name:/container-path) is managed entirely by Docker — it persists data independently of the host directory structure and is not tied to a specific path on your machine. Use bind mounts for source code (so edits take effect immediately) and named volumes for database data (so it persists reliably).

Conclusion

Docker Compose gives you a straightforward way to define and run multi-container development environments with a single YAML file. Once you are comfortable with the basics, the next step is writing custom Dockerfiles to build your own images instead of relying on generic ones.

❌