普通视图

发现新文章,点击刷新页面。
昨天 — 2026年5月23日首页

写组件文档写到吐?我用AI自动生成Storybook,同事以后直接抄

作者 kyriewen
2026年5月23日 21:42

我们公司的组件库有一百多个组件,但文档几乎为零。新同事来了,不知道每个组件怎么用、支持哪些props,只能翻源码。我每周至少被问三次:“这个按钮的size参数是‘large’还是‘lg’?” 我烦了,写了个脚本:用AI读取组件的TypeScript类型定义,自动生成Storybook文档和示例代码。现在每次提交组件,CI自动跑一遍,文档实时更新。同事再也不问我了,CTO看到说:“这个自动化做得好,省了半个前端的人力。”

前言

组件文档的重要性,每个前端都懂。但现实是:业务需求压过来,谁有时间写文档?结果就是组件库越来越膨胀,文档越来越荒废。新人来了,要么猜,要么问,要么翻源码。

我试过手动写Storybook,一个组件写半小时,一百个组件写完,我可能已经离职了。后来我想:能不能让AI自动生成?组件的props类型、默认值、描述,不都写在代码里了吗?让AI提取出来,再套个模板,不就完事了?

今天我把这套自动化流程拆给你看:怎么用AI解析TS类型,怎么生成Storybook的stories文件,怎么集成到CI。以后你只需要写好组件,文档的事交给AI。

金句:最好的文档不是“人写的”,而是“代码里长出来的”。

一、痛点:手动写文档的三大坑

痛点 具体表现
耗时长 一个组件平均花30分钟写文档(props表格+示例代码+说明)
不同步 改了组件props,忘了改文档,文档变成“废纸”
没人愿意写 团队集体拖延,文档库永远是“建设中”

我们团队曾试图用react-docgen-typescript自动提取props,但它只能生成原始JSON,还得人工转成Markdown或Storybook。而且它不懂业务描述,比如“size的large表示大号按钮,用于主操作”,这种描述还是得人写。

AI的出现正好填补了这个缺口:它既能解析类型,又能生成自然语言描述,还能帮你写示例代码。

二、我是怎么做的?三步全自动

第一步:提取组件类型信息

我用react-docgen-typescript把组件的props、类型、默认值、是否必填提取成JSON。写一个脚本extract-props.ts

import * as docgen from 'react-docgen-typescript';

const options = {
  savePropValueAsString: true,
  shouldExtractLiteralValuesFromEnum: true,
};

const parser = docgen.withCustomConfig('./tsconfig.json', options);
const docs = parser.parse('./src/components/Button/index.tsx');

// docs[0] 结构:
// {
//   displayName: 'Button',
//   props: {
//     size: { type: { name: "'small' | 'medium' | 'large'" }, required: false, defaultValue: 'medium' },
//     children: { type: { name: 'ReactNode' }, required: true }
//   }
// }

第二步:用AI生成Storybook内容

把提取的JSON喂给AI,提示词:

你是一名前端技术文档专家。请根据以下组件的props信息,生成一份Storybook的stories文件代码。

要求:

  1. 为每个prop生成控制台(controls)配置
  2. 生成至少3个示例:基础用法、不同尺寸、自定义样式
  3. 用Markdown格式输出,包含组件描述和Props表格

组件信息:

[粘贴上一步的JSON]

AI会输出类似这样的Storybook代码:

// Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';

const meta: Meta<typeof Button> = {
  title: 'Components/Button',
  component: Button,
  parameters: {
    docs: {
      description: {
        component: '通用按钮组件,支持三种尺寸和两种主题色。主要用于表单提交、弹窗确认等操作。'
      }
    }
  },
  argTypes: {
    size: {
      control: 'radio',
      options: ['small', 'medium', 'large'],
      description: '按钮尺寸'
    },
    children: { control: 'text', description: '按钮内容' }
  }
};

export default meta;

export const Default: StoryObj<typeof Button> = {
  args: { children: '按钮', size: 'medium' },
};

export const Large: StoryObj<typeof Button> = {
  args: { children: '大按钮', size: 'large' },
};

export const Small: StoryObj<typeof Button> = {
  args: { children: '小按钮', size: 'small' },
};

第三步:集成到CI

我在项目的.github/workflows/docs.yml里加了一个job:

- name: Auto generate Storybook
  run: |
    npm run extract:props
    npm run ai:generate-stories
    npm run build:storybook
  env:
    OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}

每次git push到main分支,GitHub Actions自动跑一遍,重新生成文档并部署到GitHub Pages。从此文档永远最新。

金句:AI自动生成文档,让“文档过时”成为历史。

三、实测效果:节省了多少时间?

我们组件库有87个组件,人工写一个平均30分钟,总计43.5小时。用AI自动生成后,每个组件约2分钟(人工review和微调),总计约3小时。节省了93%的时间

指标 手工 AI辅助 变化
单组件文档耗时 30分钟 2分钟 ↓ 93%
文档与代码同步率 经常滞后 实时同步 -
新人上手询问次数(月均) 12次 2次 ↓ 83%

同事现在想查组件用法,直接打开Storybook。没人再问我了,我可以安心写代码。

四、注意事项(坑点)

  • AI会脑补不存在的props:有时会生成示例里用了你没提供的prop,必须人工删掉。
  • 复杂泛型可能解析错react-docgen-typescript对高阶组件、泛型组件的解析不够准,需要手动修正输入JSON。
  • 保护公司组件库隐私:不要直接把整个组件库代码喂给云端AI。可以用本地模型(如Ollama + CodeLlama),或者只传类型JSON(不传实现细节)。
  • 示例代码要人工跑一遍:AI生成的示例可能无法直接运行(比如忘记import样式),你至少编译一次确保没语法错误。

五、完整的Prompt模板(复制可用)

# 角色
你是一名前端技术文档专家,擅长为React组件生成Storybook文档。

# 任务
根据以下组件的props信息,生成一个完整的Storybook stories文件。

# 输入格式
JSON对象,包含displayName和props

# 输出要求
1. 使用TypeScript语法
2. 包含meta配置(title, component, parameters, argTypes)
3. 生成至少3个Story:Default, Large, Small(或其他合适的变体)
4. 在parameters.docs.description.component中写一段组件用途说明
5. 每个argType的control要合理(radio/select/text等)

# 组件信息
[粘贴JSON]

六、写在最后

以前我觉得文档是“良心活”,写了加分,不写也没人扣分。现在AI帮我自动生成,我才发现:文档不是负担,而是杠杆——它让组件的复用率大大提升,团队效率自然就上去了。

如果你也被组件文档折磨过,或者想知道怎么把AI接入你的工程化流程,点个赞让我看到

🧠 Prisma 表名大写 vs SQL 导出小写问题深度解析(附踩坑与解决方案)

作者 excel
2026年5月23日 21:33

一、背景:你看到的“诡异现象”

在使用 Prisma + MySQL 的过程中,经常会遇到一个非常困惑的问题:

👉 Prisma 里是 Permission / Role(大写开头)
👉 但数据库导出后变成 permission / role(小写)

甚至更极端:

  • Prisma 查询正常
  • SQL 手写查询报错
  • join 表对不上
  • 表名忽大忽小

二、问题本质:这不是 Prisma bug,而是“数据库命名策略差异”

我们拆开看三层:


1️⃣ Prisma 层:模型是“逻辑名称”

model Permission {
  id   Int @id @default(autoincrement())
  name String @unique
}

👉 这里的 Permission 是:

  • 逻辑模型名

  • TypeScript 类型名

  • Prisma Client API 名

✔ 它只是“代码世界的名字”


2️⃣ Prisma 到数据库:通过 @@map 控制物理表名

如果你写:

model Permission {
  @@map("Permission")
}

👉 数据库就会是:

Permission

但如果你不写:

👉 Prisma 默认会做“平台适配转换”


3️⃣ MySQL 层:默认行为(关键坑点)

MySQL 在不同系统上行为不同:

系统 表名是否区分大小写
Linux ✅ 区分
Windows ❌ 不区分
Docker/Server 通常区分

而 MySQL 默认还有一个配置:

lower_case_table_names

三、真正导致“大小写混乱”的根本原因

你现在的问题通常来自三种情况:


❌ 情况 1:Prisma 自动映射

Prisma 有时会:

Permission → permission
Role → role

❌ 情况 2:MySQL 自动降级

导出 SQL 时:

Permission → permission

❌ 情况 3:手动建表 + Prisma 混用

比如你现在这种情况:

Permission(Prisma)
permission(历史遗留)

👉 直接导致双表混乱


四、最危险的后果(你已经踩过)

如果大小写混乱,会导致:


❌ 1. JOIN 永远 0 rows

JOIN Permission p

但真实数据在:

permission

❌ 2. Prisma connect 失败

connect: [{ name: "xxx" }]

👉 查错表 = 连接失败


❌ 3. 外键直接报错

Cannot add or update child row

五、为什么 Prisma 不直接统一大小写?

因为 Prisma 设计目标是:

✔ 跨数据库兼容(PostgreSQL / MySQL / SQLite)

而不同数据库规则不同:

数据库 命名规则
PostgreSQL 全小写
MySQL 依赖系统
SQLite 不敏感

👉 所以 Prisma 不强制统一大小写


六、正确解决方案(重点)


✅ 方案 1:强制统一表名(推荐)

在 Prisma 明确写:

model Permission {
  @@map("Permission")
}

model Role {
  @@map("Role")
}

✔ 强制数据库保持一致


✅ 方案 2:全部改为小写(更推荐生产)

@@map("permission")
@@map("role")

👉 统一行业标准(推荐)


✅ 方案 3:彻底禁止混用(最重要)

必须做到:

❌ 不允许 Permission + permission 共存


🚨 清理 SQL:

DROP TABLE permission;

或迁移:

INSERT INTO Permission (name)
SELECT name FROM permission;

七、最佳实践(企业级 RBAC 推荐)


✔ 表命名规范

permission
role
user
role_permission

👉 全部小写


✔ Prisma 写法

@@map("permission")
@@map("role")

✔ 外键表显式建模(避免隐式表)

model RolePermission {
  roleId Int
  permissionId Int
}

八、你这个项目的真实问题总结

你现在的问题不是 Prisma bug,而是:

❗ “隐式 join 表 + 大小写不统一 + 手动 SQL 混用” 三重冲突


九、一句话总结

Prisma 的 Model 名是逻辑层(大写),数据库表名是物理层(大小写敏感),如果不统一映射规则,就会导致 SQL / Prisma / join 全部错位。


十、最终建议(非常重要)

如果你要彻底解决:

✔ 做三件事:

  1. 统一表名(建议全部小写)

  2. 删除重复 permission / Permission

  3. Prisma 加 @@map 固定映射

  4. 不再手写 join table SQL


📌 如果你下一步要,我可以帮你做:

  • 🔥 直接帮你重构 RBAC(企业级标准)

  • 🔥 帮你清理现在数据库所有冲突表

  • 🔥 或帮你改成“完全不会踩坑的 Prisma 权限系统”


📎 本文部分内容借助 AI 辅助生成,并由作者整理审核。

Cocos Creator 3.x 装饰器实战:让你的代码优雅 10 倍

作者 烛阴
2026年5月23日 21:08

一、为什么必须懂装饰器

Cocos Creator 3.x 全面拥抱 TypeScript,最直接的红利就是装饰器(Decorator)。从 2.x 的 cc.Class({ ... }) 到 3.x 的 @ccclass,看似只是语法糖,实际上是组件开发模式的彻底重构。

先看一组对比,你立刻就能感受到差距。

Cocos Creator 2.x 写法:

cc.Class({
    extends: cc.Component,
    properties: {
        speed: {
            default: 100,
            type: cc.Float,
            tooltip: '移动速度',
            range: [0, 1000]
        },
        target: {
            default: null,
            type: cc.Node
        }
    },
    onLoad() { /* ... */ },
    start() { /* ... */ }
});

Cocos Creator 3.x 写法:

@ccclass('PlayerController')
export class PlayerController extends Component {
    @property({ tooltip: '移动速度', range: [0, 1000] })
    speed: number = 100;

    @property(Node)
    target: Node = null!;

    onLoad() { /* ... */ }
    start() { /* ... */ }
}

差异点很明显:

  • 类型推断:3.x 直接用 TypeScript 类型,IDE 智能提示拉满
  • 代码结构:属性声明和方法声明位置一致,可读性远超 2.x
  • 关注点分离:装饰器是元数据,逻辑是逻辑,互不干扰
  • 可组合:多个装饰器可以叠加,灵活程度跃升一个量级

二、装饰器基础速通

2.1 什么是装饰器

装饰器(Decorator)是 TypeScript 提供的元编程能力,本质是一个函数,它能在运行时附加在类、属性、方法、参数上,修改其行为或添加元数据。

// 装饰器就是一个函数
function MyDecorator(target: any) {
    console.log('我被附加到了', target.name);
}

@MyDecorator
class MyClass { }
// 输出:我被附加到了 MyClass

2.2 装饰器的四种形态

类型 附加位置 Cocos 典型代表
类装饰器 @ccclass
属性装饰器 属性 @property
方法装饰器 方法 自定义节流、日志等
参数装饰器 参数 依赖注入框架常用

2.3 在 Cocos Creator 3.x 中导入装饰器

所有内置装饰器都从 cc 模块导入:

import { _decorator, Component, Node } from 'cc';
const { ccclass, property, executeInEditMode, menu, requireComponent } = _decorator;

这是 3.x 项目的标配头部。_decorator 是命名空间,里面装了所有官方装饰器。

小贴士:用 Cocos Creator 编辑器创建脚本时,会自动生成 _decorator 导入语句,无需手动写。


三、@ccclass 类装饰器深度解析

@ccclass 是 Cocos Creator 3.x 最基础也最重要的装饰器,任何继承 Component 的脚本必须使用它,否则引擎无法识别该类。

3.1 基础用法

@ccclass('PlayerController')
export class PlayerController extends Component {
    // ...
}

参数是一个字符串,作为类在引擎中的注册名称。这个名称用于序列化,一旦使用就不要随意修改,否则旧的预制体或场景会丢失引用。

3.2 命名空间策略

对于大型项目,建议加上命名空间避免冲突:

@ccclass('Game.UI.MainMenu')
export class MainMenu extends Component { }

@ccclass('Game.Battle.PlayerController')
export class PlayerController extends Component { }

这样在编辑器组件菜单和资源面板中,会有清晰的层级显示。

3.3 不传参数的简写

@ccclass
export class MyComponent extends Component { }

不传参数时,类名会自动作为注册名。生产环境不推荐这种写法,因为 minify 之后类名会变,导致序列化失效。


四、@property 属性装饰器全攻略

@property 是 Cocos Creator 3.x 中使用频率最高的装饰器,决定属性是否序列化、是否显示在编辑器、显示样式如何。

4.1 七种核心用法

用法 1:纯类型推断

@property
hp: number = 100;

TypeScript 自动推断为 number,序列化也按 number 处理。简单字段推荐这种写法

用法 2:显式类型声明

@property(Node)
target: Node = null!;

@property(Prefab)
bulletPrefab: Prefab = null!;

当属性是引用类型(Node、Prefab、SpriteFrame 等),必须显式声明类型,编辑器才能在面板显示正确的拖拽控件。

用法 3:完整 options 配置

@property({
    type: Node,
    tooltip: '主摄像机节点',
    displayName: '相机',
    visible: true,
    serializable: true,
})
mainCamera: Node = null!;

options 对象支持非常多配置项,下面逐一展开。

用法 4:数组类型

@property({ type: [Node] })
enemies: Node[] = [];

@property({ type: [Prefab] })
weaponPrefabs: Prefab[] = [];

数组类型必须用方括号包裹元素类型,否则编辑器无法识别。

用法 5:枚举类型

enum WeaponType {
    Sword = 0,
    Bow = 1,
    Staff = 2,
}

@ccclass('Weapon')
export class Weapon extends Component {
    @property({ type: Enum(WeaponType) })
    weaponType: WeaponType = WeaponType.Sword;
}

枚举必须用 Enum() 包装,编辑器会展示为下拉框。

用法 6:自定义类(非 Component)

@ccclass('SkillData')
export class SkillData {
    @property
    name: string = '';

    @property
    damage: number = 0;
}

@ccclass('Player')
export class Player extends Component {
    @property({ type: SkillData })
    skill: SkillData = new SkillData();

    @property({ type: [SkillData] })
    skills: SkillData[] = [];
}

数据类不继承 Component,但只要加上 @ccclass + @property,就能在编辑器面板里编辑。

用法 7:getter/setter

@ccclass('HealthBar')
export class HealthBar extends Component {
    private _hp: number = 100;

    @property
    get hp(): number {
        return this._hp;
    }

    set hp(value: number) {
        this._hp = clamp(value, 0, 100);
        this.updateUI();
    }

    private updateUI() { /* ... */ }
}

通过 getter/setter 可以在属性变更时自动触发逻辑,比如更新 UI、播放动画等,远比手动调用 setHP() 优雅。

4.2 options 完整字段速查

字段 类型 作用
type Constructor 显式声明类型
default any 默认值(推荐直接初始化字段,不用此项)
serializable boolean 是否序列化保存,默认 true
visible boolean / Function 编辑器是否可见,可以传函数动态决定
displayName string 编辑器显示的名字(覆盖字段名)
displayOrder number 编辑器面板中的显示顺序
tooltip string 鼠标悬停时的提示文字
readonly boolean 是否只读
range [min, max, step] 数值范围(带步长)
slide boolean 是否显示为滑动条
group string / object 属性分组
min / max number 数值上下限(不带步长)
step number 数值步长
unit string 单位(显示在值后面)
multiline boolean 字符串多行编辑

4.3 高级技巧:visible 函数

@ccclass('Weapon')
export class Weapon extends Component {
    @property({ type: Enum(WeaponType) })
    weaponType: WeaponType = WeaponType.Sword;

    // 仅当武器类型是 Bow 时显示这个字段
    @property({
        visible: function (this: Weapon) {
            return this.weaponType === WeaponType.Bow;
        }
    })
    arrowSpeed: number = 500;
}

这种条件显示特性可以让编辑器面板更整洁,根据当前配置动态展示相关字段。

4.4 高级技巧:分组管理

@ccclass('Player')
export class Player extends Component {
    @property({ group: { name: '基础属性', id: '1' } })
    hp: number = 100;

    @property({ group: { name: '基础属性', id: '1' } })
    mp: number = 50;

    @property({ group: { name: '战斗属性', id: '2' } })
    attack: number = 10;

    @property({ group: { name: '战斗属性', id: '2' } })
    defense: number = 5;
}

通过 group 字段,可以把相关属性聚合在一起,编辑器面板呈现折叠分组,对于大型组件至关重要


五、编辑器行为装饰器

这一类装饰器决定组件在编辑器中的行为,是提升团队协作效率的利器。

5.1 @executeInEditMode:编辑器实时预览

@ccclass('PreviewBox')
@executeInEditMode
export class PreviewBox extends Component {
    @property
    size: number = 100;

    update() {
        // 编辑器中也会运行
        this.node.setScale(this.size, this.size, 1);
    }
}

加了这个装饰器,updateonLoad 等生命周期在编辑器环境也会触发。适合做实时预览效果,比如调整参数立即看到结果。

注意:不要在 executeInEditMode 的组件里访问运行时才存在的资源(比如远端加载的图片)。

5.2 @menu:自定义菜单分类

@ccclass('SmokeEffect')
@menu('特效/烟雾效果')
export class SmokeEffect extends Component { }

@ccclass('ExplosionEffect')
@menu('特效/爆炸效果')
export class ExplosionEffect extends Component { }

在编辑器 添加组件 菜单里,组件会按 菜单路径 分类显示。大项目必备,避免菜单一堆杂乱的组件。

5.3 @requireComponent:依赖声明

@ccclass('Mover')
@requireComponent(RigidBody2D)
export class Mover extends Component {
    private rb: RigidBody2D = null!;

    onLoad() {
        // 一定能拿到,不需要判空
        this.rb = this.getComponent(RigidBody2D)!;
    }
}

Mover 被添加到节点时,如果节点上没有 RigidBody2D,引擎会自动添加。同时移除时如果有其他组件依赖它,会阻止移除。

5.4 @disallowMultiple:禁止重复添加

@ccclass('UniqueController')
@disallowMultiple
export class UniqueController extends Component { }

防止一个节点上添加多个相同组件。所有单例职责的组件都应该加这个,防止策划误操作。

5.5 @executionOrder:执行顺序

@ccclass('GameManager')
@executionOrder(-1000)
export class GameManager extends Component {
    onLoad() {
        // 在所有默认组件之前执行
    }
}

@ccclass('UIManager')
@executionOrder(1000)
export class UIManager extends Component {
    onLoad() {
        // 在所有默认组件之后执行
    }
}

数字越小,执行越靠前。默认是 0,负数提前,正数延后。常用于全局管理类组件,确保它们先于业务组件初始化。

5.6 @help:帮助文档链接

@ccclass('ComplexComponent')
@help('https://docs.your-game.com/components/complex')
export class ComplexComponent extends Component { }

编辑器面板右上角会出现帮助按钮,点击跳转到指定 URL。


六、自定义装饰器:通用能力封装

6.1 单例模式装饰器

function singleton<T extends new (...args: any[]) => any>(constructor: T) {
    let instance: InstanceType<T> | null = null;

    return class extends constructor {
        constructor(...args: any[]) {
            if (instance) {
                return instance;
            }
            super(...args);
            instance = this as InstanceType<T>;
        }
    };
}

@singleton
class GameManager {
    private _data: any = {};

    setData(key: string, value: any) {
        this._data[key] = value;
    }

    getData(key: string) {
        return this._data[key];
    }
}

const a = new GameManager();
const b = new GameManager();
console.log(a === b); // true

注意:单例装饰器不适合 Component 类,Component 必须挂在节点上。这种模式更适合纯数据管理类。

6.2 节流装饰器

防止某些方法被频繁调用(比如按钮点击、网络请求):

function throttle(delay: number = 1000) {
    return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
        const original = descriptor.value;
        const lastCallMap = new WeakMap<object, number>();

        descriptor.value = function (...args: any[]) {
            const now = Date.now();
            const last = lastCallMap.get(this) || 0;

            if (now - last >= delay) {
                lastCallMap.set(this, now);
                return original.apply(this, args);
            } else {
                console.log(`[throttle] ${propertyKey} 节流中`);
            }
        };

        return descriptor;
    };
}

@ccclass('SkillController')
export class SkillController extends Component {
    @throttle(500)
    castFireball() {
        console.log('火球术发射!');
        // 真实业务逻辑
    }

    @throttle(2000)
    castUltimate() {
        console.log('大招发动!');
    }
}

亮点@throttle(500) 一行注解,整个方法自动具备节流能力,业务代码零侵入。

6.3 性能监控装饰器

function measureTime(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const original = descriptor.value;

    descriptor.value = function (...args: any[]) {
        const start = performance.now();
        const result = original.apply(this, args);
        const cost = performance.now() - start;

        if (cost > 16) {
            console.warn(`[Performance] ${propertyKey} 耗时 ${cost.toFixed(2)}ms(超过一帧)`);
        }

        return result;
    };

    return descriptor;
}

@ccclass('PathFinder')
export class PathFinder extends Component {
    @measureTime
    findPath(start: Vec3, end: Vec3) {
        // 复杂的 A* 寻路算法
    }

    @measureTime
    updateMap() {
        // 更新地图数据
    }
}

亮点:开发期发现性能瓶颈,无需手动加计时代码。


七、多装饰器叠加顺序

7.1 多装饰器叠加顺序

@ccclass('GameCore')
@executeInEditMode
@disallowMultiple
@menu('系统/游戏核心')
@executionOrder(-9999)
export class GameCore extends Component { }

多个装饰器从下往上执行(最靠近类的先生效)。建议顺序:

  1. @ccclass(必须最上面)
  2. 行为类装饰器(@executeInEditMode@disallowMultiple
  3. 菜单和元数据(@menu@help
  4. 执行控制(@executionOrder

你敢信这是非Native页面写出来的渐变效果吗🌝(底层原理解析

作者 July_lly
2026年5月23日 20:48

前言

最近主包由于写RN业务的原因,需要实现一个跟随Native半屏容器拉起到全屏时会渐变的效果,这里面涉及到与原生的交互能力,同时由于业务需要提高性能能力,能适配低端机用户。

效果:

550513572529ad2a9d243fdb46d955ed.gif

看着卡了,可以自己去体验一下。

踩坑

肯定很多同学想到了直接监听我们的容器滚动时间,能这么简单的话不需要文章/文档记录了。当然应该也有同学听过一些解决方法,就是ali的BindingX方案。当然这个方案肯定可以的,但是在我们需要适配地端容器,且需要大量的变化时候渐变的元素时候,前端的视角变得尤为重要了。

下面我们不废话,直接将过程and知识点。

什么是 BindingX?

BindingX 是阿里推出的前端动画引擎,主要用于实现跨端的复杂手势交互与动画绑定。其核心原理可从三个维度理解:

数据绑定:将事件与属性变化关联起来

事件驱动:基于手势、时序、滚动等事件触发

数学插值:通过声明式表达式描述动画曲线

它的设计目标是让开发者 "通过声明式绑定,将手势事件、动画和属性变化关联起来" ,避免手写大量命令式(imperative)动画代码。


为什么需要 BindingX?

在 RN 页面中,当 RN_Panel 容器上拉全屏时,常见需求是让页面元素跟随滑动手势产生联动动画。

传统方案的问题:

通过监听浮层滚动进度通知(enable_progress_notification)再回调 JS 逻辑,存在以下瓶颈:

JSB 通信打满执行栈,无法实时拿到当前进度

大量动画导致页面明显卡顿

典型卡顿场景分析:

即使只通知一次(enable_state_notification),大量同时变化的元素也会造成明显卡顿,原因在于:

顶部图片容器高度突然变化 → 触发回流

背景色或渐变瞬时变化 → 触发大量重绘

大量文字颜色同时变化 → 触发 CPU 密集型抗锯齿计算,每帧多次回流

实测发现:约 30 个图标和文字同时变化,是影响动画流畅性的主要瓶颈。

BindingX 通过将动画逻辑下沉到 Native 侧执行,彻底绕开 JS Bridge 通信瓶颈,实现丝滑动画效果。


如何使用 BindingX

基础用法示例

import { useRef } from 'react';
import { View } from 'react-native';
import { useBindingX } from './useBindingX';
function FadeBox() {
  const boxRef = useRef<View>(null);
  useBindingX({
    eventType: 'progress',
    anchor: 'panel_progress',
    props: [
      { element: boxRef, property: 'opacity', expression: 'p' },
    ],
  });
  return <View ref={boxRef} collapsable={false} style={{ opacity: 0 }} />;
}

常用可控属性:opacity / color / height 等。

useBindingX 实现可以直接按照下面的API让你们客户端写好了,或者自己去调JSB封装一下即可


API 参考

UseBindingXOptions 参数

参数 类型 默认值 说明
eventType 'progress' | 'scroll' | 'timing' | 'pan' | 'orientation' 'progress' 事件类型
anchor string | React.RefObject 锚点,含义取决于 eventType
props BindingXProp[] 必填 绑定属性列表
enabled boolean true 是否启用绑定
exitExpression string 退出条件表达式(仅 timing 有效)

支持的事件类型

eventType 表达式变量 anchor 典型场景
progress p (0~1) token 字符串 面板拖拽、进度条、外部驱动
scroll x, y (px) ScrollView ref 滚动视差、Header 折叠
timing t (ms) 不需要 入场动画、脉冲动画
pan x, y (px) 手势视图 ref 拖拽交互
orientation alpha, beta, gamma 不需要 陀螺仪控制

可绑定属性速查表

Transform(变换)

属性 说明 示例表达式
opacity 透明度 p / max(1-y/150,0)
transform.translateX X 平移 -30+60*p
transform.translateY Y 平移 -y*0.4
transform.scale 等比缩放 0.5+0.5*p
transform.scaleX X 缩放 0.5+p
transform.scaleY Y 缩放 0.5+p
transform.rotate Z 轴旋转(度) 360*p
transform.rotateX X 轴旋转 180*p
transform.rotateY Y 轴旋转 180*p

Layout(布局)

属性 说明 示例表达式
width 宽度 30+70*p
height 高度 50+20*min(t/800,1)
left/right/top/bottom 定位偏移 24*p

Spacing(间距)

属性 说明 示例表达式
marginLeft 左外边距 min(y*0.15,30)
marginRight 右外边距 24*p
marginTop 上外边距 18*p
marginBottom 下外边距 18*p
paddingLeft 左内边距 min(y*0.08,16)
paddingRight 右内边距 24*p
paddingTop 上内边距 24*p
paddingBottom 下内边距 24*p

兼容写法:margin-left、margin_left、margin.left 均可,推荐使用 camelCase。

Visual(视觉)

属性 说明 示例表达式
backgroundColor 背景色 rgb(255-255p,50,255p)
color 文字颜色 rgb(min(y,255),0,0)
borderRadius 圆角 4+16*p

兼容写法:background-color、border-radius,推荐使用 camelCase。

Scroll(滚动)

属性 说明 示例表达式
scroll.contentOffsetX 水平滚动偏移 400*p
scroll.contentOffsetY 垂直滚动偏移 400*p

表达式语法

表达式在 Native 侧执行,不经过 JS Bridge,主要都是常见基本表达式。

BindingX 表达式支持以下语法:

运算符:+ - * / %

比较:> < >= <= == !=

逻辑:&& || !

三元:condition ? a : b

内置函数:min(a,b)、max(a,b)、abs(x)、sin(x)、cos(x)、rgb(r,g,b)


最佳实践

iOS vs Android 渲染机制对比

在使用 BindingX 处理颜色/透明度动画时,iOS 和 Android 的底层机制存在显著差异,必须区别对待。

iOS 渲染机制

iOS 使用 Core Animation(CA)和图层(Layer)系统:

颜色变化由 GPU 在图层上处理(Layer-backed 渲染),对简单背景色动画几乎是零 CPU 消耗

注意:颜色变化若同时伴随布局变化(height/width),仍会触发回流和重绘

iOS 对渐变背景或 mask 图层处理相对昂贵

Android 渲染机制

Android 基于 Skia + GPU 渲染:

颜色变化算作一次重绘(repaint) ,简单情况下 GPU 处理能力强,不会引起明显卡顿

高危场景

大面积渐变或半透明叠加层 ⚠️

同时有大量布局属性变化(height、margin、transform 等)→ 可能让 CPU/GPU 瓶颈显现

背景色踩坑:三层透明叠加问题

页面顶部存在三层透明背景叠加的情况,在 Android 上直接使用 BindingX 控制背景色会导致页面卡死(Android bindingX 包优化修复前)。

Android 侧修复方案:

将 CPU 开销转移到 GPU 处理,通过 RN View 属性 renderToHardwareTextureAndroid 将动画图层缓存为 GPU texture,减少每帧 CPU 混合:

<View renderToHardwareTextureAndroid={true}>
  {/* 动画内容 */}
</View>

字体变色踩坑与解决方案

问题根源

直接对文字颜色使用 BindingX 渐变,即使去除底色/图片的渐变叠加,依旧十分卡顿:

文字颜色变化会直接触发回流

CPU 计算抗锯齿消耗巨大

解决方案:双层 Text + View opacity

核心思路:不再改变字体颜色,而是将颜色提前确定好,在变化过程中通过 opacity 控制透明度,实现黑→白渐变。过程中不再触发高密集型的 CPU 计算,而是将图层交由 GPU 合成。

对比维度 方案 A:直接修改 Text color 方案 B:双层 Text + View opacity
动画驱动 每帧更新颜色 每帧更新 View opacity
渲染开销 触发回流 + Layout & Paint 跳过重绘,布局与颜色固定
计算方式 CPU 文本栅格化(高开销) GPU 透明混合,仅图层合成(低开销)
最终效果 卡顿 / 低帧率 ❌ 流畅 / 高帧率 ✅
CPU 占用
GPU 占用 中等

使用总结与注意事项

优先使用 opacity 代替颜色渐变:将颜色变化转为透明度变化,GPU 友好,避免 CPU 瓶颈

Android 额外关注:Android 对半透明叠加、大面积渐变的处理成本显著高于 iOS

使用 renderToHardwareTextureAndroid:对动画图层启用 GPU texture 缓存,减少每帧 CPU 混合

避免同时变化 Layout 属性:height、margin 等布局属性变化会触发回流,尽量避免在动画中同时使用

表达式在 Native 执行:BindingX 表达式不经过 JS Bridge,充分利用这一特性实现高性能动画

连载10-Sub-agents 深度解析:从源码理解 Claude Code 的分身术

2026年5月23日 19:33

cover_09_subagents.png

Sub-agents 深度解析:从源码理解 Claude Code 的分身术

AI Coding 系列第 09 篇 · 多 Agent 编排


这篇文章讲到最后只有一句话:Sub-agent 不是一个人,是一套机械规则。 你在后面三节遇到的每个"为什么它会这样做",答案都不在 prompt 工程里——在 runAgent.ts 的几行 if 里。带着这句话往下读,这篇 9000 字的源码分析会变成它的验证过程。


你已经会用 Claude Code 完成单轮任务,也了解 Skill 和 Hook 如何定义知识和自动化规则。但当你面对一个需要"同时做三件事"的复杂项目——比如一边重构后端接口、一边更新文档、一边跑测试——你本能地想让 Claude"分身"去并行处理。

这篇文章从源码层面拆解 Sub-agents 的运行机制。不是教你"可以并行"这种显而易见的话,而是帮你理解:Claude Code 内部是怎么 fork 一个 Agent 的,Agent 之间的上下文隔离到底意味着什么,以及 worktree 隔离、权限继承、生命周期管理这些你在官方文档里找不到细节的东西。


一、先建立正确的心智模型

很多人把 Sub-agent 理解成"多线程"。这个类比有误导性。

更准确的说法是:Sub-agent 是一个独立的 LLM 会话,拥有自己的消息历史、工具集、中止控制器和文件状态缓存,但与父 Agent 共享同一个 AppState 状态树。

看一眼 runAgent.ts 中创建子 Agent 上下文的核心代码:

📂 展开源码:createSubagentContext 调用
// src/tools/AgentTool/runAgent.ts
const agentToolUseContext = createSubagentContext(toolUseContext, {
  options: agentOptions,
  agentId,
  agentType: agentDefinition.agentType,
  messages: initialMessages,
  readFileState: agentReadFileState,
  abortController: agentAbortController,
  getAppState: agentGetAppState,
  shareSetAppState: !isAsync,  // 同步 Agent 与父共享状态写入
  shareSetResponseLength: true,
})

关键细节:

  • readFileState 是从父 Agent 克隆的,不是共享引用。子 Agent 读文件时走自己的缓存,不会污染父 Agent 的文件视图。
  • abortController 对异步 Agent 是全新的(new AbortController()),对同步 Agent 是共享父 Agent 的。这意味着你 Ctrl+C 取消父 Agent 时,同步子 Agent 也会被取消,但后台运行的异步 Agent 不会。
  • shareSetAppState: !isAsync 这行很关键——同步 Agent 能直接写入父 Agent 的状态树,异步 Agent 则完全隔离。

这不是多线程,更像是 Unix 的 fork():创建时复制父进程的内存快照,之后各走各路。

理解这个模型之后,你就能回答一个更根本的问题——为什么必须用 Sub-agent 而不是在主对话里多写几个 prompt?

答案不在"聪明"或"更强",而在结构。主对话的上下文是线性追加的,500 行测试日志、200 行 grep 结果、一堆中间推理——这些信息对"当下执行"是必要的,但对"后续决策"是噪声。Claude Code 不会自动区分临时执行数据和长期决策记忆,默认全当作重要信息存着。

而 Sub-agent 是 Claude Code 里唯一一个结构上允许"执行完即丢弃"的东西。它的上下文窗口独立——噪声进去,结论出来,窗口销毁。主对话永远看不到中间过程。不是优化,是架构层面的隔离。

09_subagent_fork_model.png


二、何时用 Sub-agent:四个问题搞定决策

你不需要读完剩余 700 行源码分析再做决定。问自己四个问题就够了。

09_decision_matrix.png

问题一:主对话真的需要这些中间过程吗?

如果任务的输出超过 50 行,而你只关心里面不到 10 行的内容——用子代理。

  • 跑测试:300 行日志 → 你只需要"通过/失败,哪个挂了" → 信噪比 1%
  • 代码搜索:grep 返回 50 个匹配 → 你需要 3 个关键文件 → 信噪比 6%
  • 日志分析:1000 行错误 → 你需要 1 条根因判断 → 信噪比 0.5%

噪声留在主对话的不是"看着乱",是 token 膨胀。一次 npm test 输出 300 行 ≈ 4500 tokens——后续每轮对话都要重新发送这些噪声。子代理把这些吞下去,吐回 200 tokens 的精炼摘要。从 8800 tokens 压到 3700 tokens,主对话瘦身 58%。

记一条规则:如果你想让主对话记住什么,就别让不重要的东西进入它的上下文。 这就是 Sub-agent 唯一不可替代的价值——结构上允许"执行完即丢弃"。

问题二:子 Agent 需要继承父 Agent 的上下文吗?

  • 需要 → 用 Fork。省略 subagent_type,子 Agent 继承父 Agent 的对话历史和系统提示。共享 prompt cache,便宜。适合"帮我分担当前任务的一部分"。
  • 不需要 → 用 Named Agent。指定 subagent_type,子 Agent 从零开始,只看你写在 prompt 里的信息。适合"帮我做一件独立的事"。

Fork 和 Named Agent 不是高级/低级的区别,是两种通信模式。Fork 是"你继续做这个,我分个身帮你分担";Named Agent 是"你去把这件事做了,我不管你之前干了什么"。

问题三:子 Agent 的修改会污染我当前的编辑工作吗?

  • → 加 isolation: "worktree"。子 Agent 在独立的 git worktree 里工作,修改不碰你的文件。完成后无变更则自动清理,有变更则保留分支让你 review 后合并。
  • 不会(只读/搜索/分析等不写代码的任务)→ 不需要。

Worktree 的附加代价:每个 worktree 借用一个 git branch;node_modules 等大目录通过 symlink 共享(但如果子 Agent npm install 了新包,注意不要污染主仓库的依赖)。详见第五节。

问题四:这笔账划得来吗?

每个 Sub-agent 启动有 1-3 秒开销:克隆文件缓存、构建系统提示、加载 Skills、连接 MCP。同时,它的上下文隔离帮你省下几千到几万 tokens 的噪声搬运。

结论不是"Sub-agent 很贵"也不是"很值",而是——值不值取决于任务信噪比。低信噪比任务(跑测试、搜代码、分析日志)用 Sub-agent 绝对划算;高信噪比任务(直接的对话互动)不需要画蛇添足。

经验法则:

子任务预计耗时 决策
> 3 分钟 启动成本忽略不计,大胆用
30 秒 ~ 3 分钟 信噪比判断决定
< 30 秒 不值得,主对话直接做完

实战:怎么在对话中调用子 Agent

讲的都是"什么时候用",现在说"怎么用"。Claude Code CLI 里只能输入自然语言。 文章中出现的 Agent({subagent_type: "...", ...}) 是 Claude 内部的工具调用格式,不是让读者直接在终端敲的——Claude 读自然语言,帮读者生成这些调用。

你说自然语言 → Claude 解析意图 → Claude 内部生成 Agent() 工具调用 → 子 Agent 干活 → 结果展示给你。

# 触发内置 Explore Agent
帮我找一下项目中所有和 JWT token 验证相关的代码

# 触发你定义的自定义 Agent(如果 .claude/agents/ 里有 code-reviewer.md)
用 code-reviewer 审查 src/auth/ 的安全问题

# 触发 Fork(Claude 判断需要继承上下文时)
重构好了,帮我顺便写一下这三个函数的单元测试

# 流水线(Claude 顺序执行多个 Agent)
用 bug-locator 找到 token 验证失败的原因,然后让 bug-analyzer 分析根因

Claude 内部做的事:匹配你提到的名字(如 code-reviewer)到 .claude/agents/ 或内置 Agent → 生成 Agent() 工具调用(参数是 Claude 自己根据你的描述推断的)→ 子 Agent 启动 → 完成后直接把结果展示给你。你不会看到中间的 Agent({...}) 调用,只看到最终的文字回复。

如果想约束子 Agent 的行为(工具、权限、模型),要么在 .claude/agents/ 里预先配好(推荐),要么在自然语言里说清楚。话说得越具体,Claude 生成的调用参数越精确:

# 粗粒度(Claude 自己判断一切)
帮我审查代码

# 细粒度(你指定 Agent、范围、关注点)
用 code-reviewer 审查 src/auth/tokenValidator.ts,
重点关注硬编码密钥、缺少输入校验、auth 绕过风险,
用 sonnet 模型,最多调 20 轮工具

# 带 worktree 隔离(并行修改不要互相污染)
用 bug-fixer 修复 tokenValidator.ts:42-68 的竞态条件,
用 worktree 隔离,改完跑测试验证

Claude 会把"用 sonnet 模型"翻译成 model: "sonnet","最多调 20 轮工具"翻译成 maxTurns: 20。不是说自然语言万能——有些参数 Claude 可能理解偏差。关键的权限边界和工具白名单建议在 .claude/agents/ 配置里锁死,不要依赖自然语言。

常见疑问:如果同时有 code-review Skill 和 code-reviewer Sub-agent,Claude 选哪个?

源码里没有硬编码优先级。Claude 根据自己的判断二选一——它从系统提示里同时看到"可用 Skill 列表"和"可用 Agent 列表",靠任务特征自行裁量。简单规则性任务(如格式化输出)倾向 Skill;复杂多步骤、需要上下文隔离的任务倾向 Sub-agent。

但有一个非显而易见的耦合:Skill 的 frontmatter 里可以设 context: fork 这种情况下,Skill 的实际执行会被路由到 Sub-agent——Claude 表面在"调用 Skill",底层却启动了一个独立上下文的子 Agent。从这个角度看,Skill 和 Sub-agent 不是互斥选项——context: fork 的 Skill 就是用 Sub-agent 跑的 Skill。

调用后,后台的流程是透明的:

  1. Claude 把自然语言翻译成 Agent() 工具调用 → 源码根据 subagent_type(Claude 判断的)路由到对应 Agent 定义
  2. 创建独立的 LLM 会话——克隆文件缓存、构建系统提示、加载 Skills、连接 MCP
  3. 子 Agent 执行任务,它的工具调用和思考过程不会出现在你的对话里
  4. 完成后,只把最终的文字回复展示在你的对话中

你感知到的:子 Agent 执行期间终端可能显示它的工具调用(如果你开了详细输出),但它返回给你对话的内容只有最终的文字结果。500 行 grep 输出被子 Agent 吞掉了,你只看到"找到了 3 个相关文件,路径如下"。

(本文中的 Agent({...}) 代码示例展示的是 Claude 内部的工具调用格式,方便你理解参数含义。你不是在 CLI 里敲这些代码——这些是 Claude 在你的自然语言指令下生成的。)

下面走进源码,看这些机制具体怎么实现。


三、四种内置 Agent 类型:各有各的活法

09_agent_types_compare.png

Claude Code 不是只有一种 Sub-agent。打开 builtInAgents.ts,你会看到内置 Agent 的注册逻辑:

📂 展开源码:内置 Agent 注册
// src/tools/AgentTool/builtInAgents.ts
const agents: AgentDefinition[] = [
  GENERAL_PURPOSE_AGENT,
  STATUSLINE_SETUP_AGENT,
]

if (areExplorePlanAgentsEnabled()) {
  agents.push(EXPLORE_AGENT, PLAN_AGENT)
}

这段代码展示了本文重点关注的四种 Agent。完整源码中还有 CLAUDE_CODE_GUIDE_AGENT(回答 Claude Code 使用问题)和 VERIFICATION_AGENT(feature gate 控制的验证 Agent),以及 Coordinator Mode 下的动态 Agent 编排分支——它们各有专门的场景,不影响对核心四种的理解。

Explore Agent——只读搜索专家

Explore Agent 的系统提示开头就是一堵墙:

📂 展开源码:Explore Agent 的系统提示(只读限制)
=== CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS ===
This is a READ-ONLY exploration task. You are STRICTLY PROHIBITED from:
- Creating new files (no Write, touch, or file creation of any kind)
- Modifying existing files (no Edit operations)
...

它的 disallowedTools 直接禁掉了 AgentFileEditFileWriteNotebookEditExitPlanMode。这不是"建议"只读,而是工具级别的硬限制——Explore Agent 连尝试写文件的机会都没有。

注意这里的设计原则:安全感不是靠 prompt 劝说 Agent "请不要修改文件",而是从工具层把 Write 和 Edit 物理删掉。Agent 没有"自觉性"——唯一可依赖的,是它能调用哪些函数。这不是信任问题,是机械限制。

有两个值得注意的源码细节:

1. 它不带 CLAUDE.md:

📂 展开源码:omitClaudeMd 检查逻辑
// src/tools/AgentTool/runAgent.ts
const shouldOmitClaudeMd =
  agentDefinition.omitClaudeMd &&
  !override?.userContext

Explore Agent 设了 omitClaudeMd: true。原因是 Explore 只做搜索,commit 规范、PR 模板、lint 规则这些 CLAUDE.md 里的指令对它毫无意义。Anthropic 在代码注释里说这个优化"saves ~5-15 Gtok/week across 34M+ Explore spawns"——每周节省约 5-15 Gtok,覆盖 3400 万次以上 Explore 调用,每次省几千 token,累计节约量级惊人。

2. 它也不带 gitStatus:

📂 展开源码:gitStatus 省略逻辑
const { gitStatus: _omittedGitStatus, ...systemContextNoGit } =
  baseSystemContext
const resolvedSystemContext =
  agentDefinition.agentType === 'Explore' ||
  agentDefinition.agentType === 'Plan'
    ? systemContextNoGit
    : baseSystemContext

Explore 和 Plan 不需要会话开始时的 git status 快照(最多 40KB)。如果它们需要 git 信息,会自己跑 git status 获取实时数据,而不是依赖可能已经过期的快照。

Plan Agent——只读架构师

Plan Agent 和 Explore 共享同一套只读限制,但角色定位不同。它的系统提示要求输出结构化的实施方案:

End your response with:
### Critical Files for Implementation
List 3-5 files most critical for implementing this plan

Plan Agent 用 model: 'inherit',继承父 Agent 的模型。Explore 对外部用户默认用 Haiku(追求速度),Plan 则需要和父 Agent 一样强的推理能力。

General-Purpose Agent——全能型选手

tools: ['*'] 意味着它能使用所有工具(写代码、跑测试、执行 bash 命令),是真正的"全能分身"。注意:当 fork 功能开启时,省略 subagent_type 触发的是 Fork 而非 General-Purpose(见下文 Fork 小节)。

📂 展开源码:General-Purpose Agent 定义
export const GENERAL_PURPOSE_AGENT: BuiltInAgentDefinition = {
  agentType: 'general-purpose',
  tools: ['*'],     // 全部工具
  source: 'built-in',
  baseDir: 'built-in',
  // model is intentionally omitted - uses getDefaultSubagentModel()
  getSystemPrompt: getGeneralPurposeSystemPrompt,
}

Fork Agent——上下文继承的分身

Fork 的触发机制不是语义分析——是参数缺失时的默认路由:当 Claude 生成 Agent() 调用但省略了 subagent_type,且 fork feature gate 开启时,自动走 Fork 路径(AgentTool.tsx:322)。它的特征是继承父对话的完整上下文——你不需要在 prompt 里重复"我刚才重构了哪些文件",Fork 子 Agent 从对话历史里自己知道。

和 Named Agent 的区别一目了然:

你:重构 src/auth/ 的 token 验证逻辑,改了三个函数
Claude:完成
你:顺便帮我写一下这三个函数的单元测试吧
   → Claude 判断:子 Agent 需要知道"刚才重构了哪些函数"
   → 触发 Fork:子 Agent 从对话中直接知道,不需要你重复说

你:检查 src/auth/ 有没有安全问题
   → Claude 判断:审查独立于之前的对话
   → 使用 code-reviewer(Named Agent):从零开始,只看你给的信息

Fork 不能嵌套——Fork 子 Agent 不能再创建自己的子 Agent。多层编排的工作由主对话负责。

📂 展开源码:Fork 的定义、触发路由、消息构建和防递归机制
// src/tools/AgentTool/forkSubagent.ts
// Not registered in builtInAgents — used only when !subagent_type
export const FORK_AGENT = {
  agentType: 'fork',
  tools: ['*'],
  maxTurns: 200,
  model: 'inherit',
  permissionMode: 'bubble',
  getSystemPrompt: () => '',  // 继承父 Agent 的系统提示
}

触发路由(AgentTool.tsx:322):

const effectiveType = subagent_type ??
  (isForkSubagentEnabled() ? undefined : 'general-purpose')
// subagent_type 缺失 + fork 门开启 → Fork 路径

Fork 子 Agent 产生字节级相同的 API 请求前缀,共享 prompt cache 所以比新建 Agent 便宜。防递归靠 isInForkChild() 检测对话中的 <fork_boilerplate> 标记直接拒绝。

四种 Agent 的分工很清楚:Explore 找,Plan 想,General-Purpose 做。Fork 省略 subagent_type 即可触发——需要继承上下文时不加字段走 fork,独立任务时指定类型走 Named Agent。不能在 .claude/agents/ 里定义 fork 类型的自定义 Agent。


四、.claude/agents/ 自定义 Agent:你只需要关注四个字段

除了内置 Agent,你可以在 .claude/agents/ 目录下用 Markdown + YAML frontmatter 定义自己的 Agent。

先说不该做的事:把 Zod Schema 里所有 14 个字段背下来。你真正每次都要认真设计的只有四个——description(何时触发)、tools / disallowedTools(权限边界)、model(成本决策)。其余字段有需要时再查。

一个接近实战的配置示例(不是模板,是设计思路的载体):

---
name: code-reviewer
description: "Reviews code changes for quality, security, and consistency with project conventions. Use when you want a second opinion on code before committing."
model: inherit
permissionMode: dontAsk
tools:
  - Read
  - Glob
  - Grep
  - Bash
disallowedTools:
  - Write
  - Edit
  - NotebookEdit
skills:
  - code-review
hooks:
  SubagentStop:
    - hooks:
        - type: command
          command: "echo 'Code review completed at $(date)' >> .claude/review-log.txt"
maxTurns: 30
---

You are a code review specialist. Your job is to analyze code changes and provide actionable feedback.

Focus on:
- Security vulnerabilities (injection, auth bypass, data exposure)
- Performance issues (N+1 queries, unnecessary allocations, blocking calls)
- Error handling gaps (missing try-catch, unhandled promise rejections)
- Consistency with existing patterns in the codebase

Be specific: cite file paths and line numbers. Don't flag style issues unless they affect readability.

四个需要认真设计的字段:

  • description:不是你写给人看的注释——是 Claude 判断"何时自动调用这个 Agent"的唯一依据。写清楚做什么什么时候用。关键词 proactively 会鼓励 Claude 在合适的时机主动委派。
  • tools vs disallowedTools:白名单黑名单二选一,不要同时用。只读审查用 tools: [Read, Grep, Glob];需要大部分工具但排除个别危险的用 disallowedTools: [Write, Edit]。原则:最小权限——能用 Read 完成的就不要给 Edit。
  • model:不是越强越好。代码审查/分析推理 → sonnet;执行固定流程/模式匹配 → haiku;需要和主对话同等推理 → inherit。选错模型比选错工具更贵——Anthropic 的研究表明,升级模型的性能提升往往超过翻倍 token 预算的效果。
  • skills:Agent 不会自动继承主对话的 Skill。如果子 Agent 需要某个 Skill 的知识(如链路的 SLA 约束、历史事故记录),必须在 skills 字段显式列出,Skill 内容会在 Agent 启动时注入为 isMeta: true 的系统消息。

其余字段说明(有需要再看):

字段 用途 何时需要考虑
permissionMode 覆盖权限确认行为 异步 Agent 建议设 dontAsk
maxTurns 限制工具调用轮数 防止跑飞,建议 20-50
background 强制后台运行 长时间任务的非阻塞执行
isolation worktree 隔离 并行修改不同模块时启用
mcpServers Agent 专属 MCP 连接 需要访问特定外部服务
hooks Agent 生命周期的自动动作 SubagentStop 写日志等
initialPrompt 首轮额外注入的提示 给 Agent 额外的任务约束
memory 记忆作用域 跨会话共享知识

Agent 来源优先级

同名 Agent,后加载的覆盖先加载的:

// 覆盖链:内置 → 插件 → 用户级(~/.claude/agents/) → 项目级(.claude/agents/) → 企业管理策略
const agentMap = new Map<string, AgentDefinition>()
for (const agents of [builtIn, plugin, user, project, flag, managed]) {
  for (const agent of agents) {
    agentMap.set(agent.agentType, agent)  // 后写入覆盖先写入
  }
}

这意味着:你在 .claude/agents/ 里定义的 general-purpose Agent 会替换内置的通用 Agent。企业管理员可以通过策略设置强制覆盖所有 Agent 定义。

所以你应该怎么做:配置完 Agent 后,用一个简单任务测试 description 是否能正确触发。如果 Claude 该用的时候不用,大概率是 description 写得像"自我介绍"而不是"使用条件"。


五、Worktree 隔离:给 Agent 一个独立的代码沙箱

当你设置 isolation: "worktree" 时,子 Agent 会在一个独立的 git worktree 中工作。先理解概念:git worktree 让你在同一个仓库里同时 checkout 出多个分支到不同目录——每个目录像一个独立的仓库副本,有各自的 HEAD,但共享同一个 .git 目录。你不必为了在新分支上工作而 stash 当前修改。

09_worktree_architecture.png

本质上就是 fork() + chroot():共享同一个 .git,但每个 Agent 看到的文件系统是独立的隔离视图。

创建流程

📂 展开源码:Worktree 创建流程 (createAgentWorktree)
// src/utils/worktree.ts - createAgentWorktree
export async function createAgentWorktree(slug: string): Promise<{
  worktreePath: string
  worktreeBranch?: string
  headCommit?: string
  gitRoot?: string
}> {
  validateWorktreeSlug(slug)

  // 关键:使用 findCanonicalGitRoot 而不是 findGitRoot
  // 确保 Agent worktree 总是创建在主仓库的 .claude/worktrees/ 下
  // 而不是嵌套在某个会话 worktree 的 .claude/worktrees/ 里
  const gitRoot = findCanonicalGitRoot(getCwd())

  const { worktreePath, worktreeBranch, headCommit, existed } =
    await getOrCreateWorktree(gitRoot, slug)

  if (!existed) {
    await performPostCreationSetup(gitRoot, worktreePath)
  }
  return { worktreePath, worktreeBranch, headCommit, gitRoot }
}

创建后的自动化设置

performPostCreationSetup 做了一系列你手动操作很容易遗漏的事:

  1. 复制 settings.local.json:本地设置可能包含敏感配置,需要传播到 worktree
  2. 配置 git hooks 路径:让 worktree 复用主仓库的 .husky.git/hooks,避免 pre-commit hook 失效
  3. 符号链接大目录:根据 settings.worktree.symlinkDirectories 配置,symlink node_modules 等目录避免磁盘膨胀
  4. 复制 .worktreeinclude 指定的文件:gitignore 的文件(如 .env、build 产物)不在 worktree 中,但可以通过 .worktreeinclude 声明需要哪些

Worktree 的生命周期管理

Agent worktree 有一个优雅的"按需保留"机制:

📂 展开源码:Worktree 变更检查 (hasWorktreeChanges)
// 检查 worktree 是否有变更
export async function hasWorktreeChanges(
  worktreePath: string,
  headCommit: string,
): Promise<boolean> {
  // 检查 1: 有没有未提交的改动
  const status = await execFileNoThrowWithCwd(
    gitExe(), ['status', '--porcelain'], { cwd: worktreePath })
  if (statusOutput.trim().length > 0) return true

  // 检查 2: 有没有新的 commit
  const revList = await execFileNoThrowWithCwd(
    gitExe(), ['rev-list', '--count', `${headCommit}..HEAD`], { cwd: worktreePath })
  if (parseInt(revListOutput.trim(), 10) > 0) return true

  return false
}

如果子 Agent 完成后没有任何变更,worktree 会被自动清理。如果有变更(新 commit 或未提交的修改),worktree 和分支会保留,返回路径和分支名让你后续处理。

还有一个后台清理机制,定期扫描过期的临时 worktree:

📂 展开源码:临时 Worktree 清理模式 (EPHEMERAL_WORKTREE_PATTERNS)
const EPHEMERAL_WORKTREE_PATTERNS = [
  /^agent-a[0-9a-f]{7}$/,           // AgentTool 创建的
  /^wf_[0-9a-f]{8}-[0-9a-f]{3}-\d+$/, // WorkflowTool 创建的
  /^bridge-[A-Za-z0-9_]+(-[A-Za-z0-9_]+)*$/, // Bridge 创建的
]

只有匹配这些模式的 worktree 才会被自动清理——你手动通过 EnterWorktree 创建的 worktree(比如 feature-redesign)永远不会被误删。

Fork + Worktree 的组合

当 Fork 子 Agent 在 worktree 中运行时,会收到一条特殊的上下文通知:

📂 展开源码:Worktree 上下文通知 (buildWorktreeNotice)
// src/tools/AgentTool/forkSubagent.ts
export function buildWorktreeNotice(
  parentCwd: string, worktreeCwd: string,
): string {
  return `You've inherited the conversation context above from a parent
agent working in ${parentCwd}. You are operating in an isolated git
worktree at ${worktreeCwd} — same repository, same relative file
structure, separate working copy. Paths in the inherited context refer
to the parent's working directory; translate them to your worktree root.
Re-read files before editing if the parent may have modified them...`
}

这段提示告诉 Fork 子 Agent:你继承的上下文里的文件路径指向父 Agent 的工作目录,你需要把路径"翻译"到自己的 worktree 里。这是一个容易被忽略但非常关键的细节。

并行修改不同模块时启用 worktree,每个模块独立分支互不干扰。只读探索不需要。如果子 Agent 要在 worktree 里 npm install 新依赖,记得在 .worktreeinclude 里声明 .env 等被 gitignore 的关键文件。


六、权限模型:谁能做什么

Sub-agent 的权限控制是分层的,不是简单的"继承父 Agent 权限"。

权限模式覆盖

📂 展开源码:权限模式覆盖逻辑
// src/tools/AgentTool/runAgent.ts
const agentGetAppState = () => {
  const state = toolUseContext.getAppState()
  let toolPermissionContext = state.toolPermissionContext

  // Agent 定义的权限模式可以覆盖父 Agent 的
  // 但 bypassPermissions 和 acceptEdits 模式永远不会被覆盖
  if (
    agentPermissionMode &&
    state.toolPermissionContext.mode !== 'bypassPermissions' &&
    state.toolPermissionContext.mode !== 'acceptEdits'
  ) {
    toolPermissionContext = {
      ...toolPermissionContext,
      mode: agentPermissionMode,
    }
  }

  // 异步 Agent 不能显示权限弹窗——自动拒绝需要确认的操作
  if (shouldAvoidPrompts) {
    toolPermissionContext = {
      ...toolPermissionContext,
      shouldAvoidPermissionPrompts: true,
    }
  }
}

几条规则:

  • bypassPermissions(SDK 模式)和 acceptEdits 永远优先——子 Agent 不能收窄这两种宽松模式
  • 异步 Agent 设置 shouldAvoidPermissionPrompts: true,遇到需要用户确认的操作会自动拒绝
  • permissionMode: 'bubble' 是 Fork 的默认模式,权限请求会"冒泡"到父 Agent 的终端

工具过滤

📂 展开源码:工具过滤器 (filterToolsForAgent)
// src/tools/AgentTool/agentToolUtils.ts
export function filterToolsForAgent({ tools, isBuiltIn, isAsync }): Tools {
  return tools.filter(tool => {
    if (tool.name.startsWith('mcp__')) return true  // MCP 工具不受限
    if (ALL_AGENT_DISALLOWED_TOOLS.has(tool.name)) return false
    if (!isBuiltIn && CUSTOM_AGENT_DISALLOWED_TOOLS.has(tool.name)) return false
    if (isAsync && !ASYNC_AGENT_ALLOWED_TOOLS.has(tool.name)) return false
    return true
  })
}

三层过滤:

  1. 所有 Agent 禁用的工具ALL_AGENT_DISALLOWED_TOOLS):比如 ExitPlanMode——子 Agent 不应该改变父 Agent 的计划模式
  2. 自定义 Agent 额外禁用的CUSTOM_AGENT_DISALLOWED_TOOLS):用户定义的 Agent 比内置 Agent 受限更多
  3. 异步 Agent 的白名单:后台运行的 Agent 只能使用一个限定的工具子集

MCP 工具(mcp__ 前缀)不受这些限制,始终可用。

allowedTools 的权限隔离

📂 展开源码:allowedTools 权限隔离
// 父 Agent 的 session-level 权限不会泄露到子 Agent
if (allowedTools !== undefined) {
  toolPermissionContext = {
    ...toolPermissionContext,
    alwaysAllowRules: {
      cliArg: state.toolPermissionContext.alwaysAllowRules.cliArg, // 保留 SDK 级权限
      session: [...allowedTools], // 替换为子 Agent 自己的权限
    },
  }
}

注意这里的 cliArgsession 的区分:SDK 通过 --allowedTools 传入的权限(cliArg)是全局的,所有 Agent 都继承;而会话级别的权限(session)在子 Agent 创建时会被重置,防止父 Agent 运行时积累的权限无意间泄露给子 Agent。

自定义 Agent 首选白名单(tools),明确列出允许的工具,而不是依赖 disallowedTools 排除。异步 Agent 必须配合 permissionMode: 'dontAsk'bubble——否则需要确认的操作被静默拒绝,Agent 不知道原因就反复重试,看起来像卡住了。


七、Agent 的完整生命周期与 Hook 联动

Hook 不是独立于生命周期的外挂——SubagentStartSubagentStop 本身就是生命周期的两个关卡。先看 Hook 怎么嵌入,再看完整流程。

Hook 如何在生命周期中触发

在 Hooks 篇里讲过 SubagentStartSubagentStop 事件,这里从 Agent 源码看触发机制。

SubagentStart:启动前的注入

📂 展开源码:SubagentStart Hook 注入
// src/tools/AgentTool/runAgent.ts
// 执行 SubagentStart hooks 并收集额外上下文
const additionalContexts: string[] = []
for await (const hookResult of executeSubagentStartHooks(
  agentId, agentDefinition.agentType, agentAbortController.signal,
)) {
  if (hookResult.additionalContexts?.length > 0) {
    additionalContexts.push(...hookResult.additionalContexts)
  }
}

// 把 Hook 注入的上下文作为用户消息添加到初始对话中
if (additionalContexts.length > 0) {
  const contextMessage = createAttachmentMessage({
    type: 'hook_additional_context',
    content: additionalContexts,
    hookName: 'SubagentStart',
    ...
  })
  initialMessages.push(contextMessage)
}

SubagentStart Hook 可以向子 Agent 注入额外的上下文信息——比如团队编码规范的摘要、当前 Sprint 的约束条件、或者从 CI 系统拉取的最新构建状态。

Agent 自带的 Hooks

Agent 定义的 frontmatter 可以声明自己的 hooks,这些 hooks 会在 Agent 启动时注册为 session hooks,Agent 结束时自动清理:

📂 展开源码:Agent 专属 Hooks 注册/清理
// 注册 Agent frontmatter 中的 hooks
// isAgent=true 会把 Stop hooks 转换为 SubagentStop
if (agentDefinition.hooks && hooksAllowedForThisAgent) {
  registerFrontmatterHooks(
    rootSetAppState,
    agentId,
    agentDefinition.hooks,
    `agent '${agentDefinition.agentType}'`,
    true,  // isAgent - converts Stop to SubagentStop
  )
}

// ... Agent 运行 ...

// 清理
finally {
  if (agentDefinition.hooks) {
    clearSessionHooks(rootSetAppState, agentId)
  }
}

isAgent = true 这个参数把 Agent frontmatter 里声明的 Stop hooks 自动转换成 SubagentStop hooks。因为子 Agent 完成时触发的不是 Stop(那是主会话结束时的事件),而是 SubagentStop

完整流程:从创建到销毁

一个 Sub-agent 从创建到销毁经历的完整流程:

启动阶段:

  1. 生成唯一的 agentIdcreateAgentId()
  2. 解析模型选择(Agent 定义 → 父 Agent 模型 → 默认模型)
  3. 如果启用了 Perfetto tracing,在追踪树中注册
  4. 克隆父 Agent 的 readFileState(文件缓存隔离)
  5. 构建上下文:Explore/Plan 去掉 CLAUDE.md 和 gitStatus
  6. 执行 SubagentStart hooks,收集额外上下文
  7. 注册 frontmatter hooks(Stop → SubagentStop 转换)
  8. 预加载 frontmatter 中声明的 Skills
  9. 初始化 Agent 专属的 MCP Servers
  10. 记录初始消息到 sidechain transcript

运行阶段:

📂 展开源码:生命周期:运行阶段 (query loop)
for await (const message of query({
  messages: initialMessages,
  systemPrompt: agentSystemPrompt,
  canUseTool: hasPermissionsToUseTool,
  toolUseContext: agentToolUseContext,
  querySource,
  maxTurns: maxTurns ?? agentDefinition.maxTurns,
})) {
  // 转发 API metrics 到父 Agent 的显示
  // 记录每条消息到 sidechain transcript
  // 检测 max_turns_reached 信号
  yield message  // 流式输出给父 Agent
}

清理阶段(finally 块):

📂 展开源码:生命周期:清理阶段 (finally cleanup)
finally {
  await mcpCleanup()                          // 清理 Agent 专属 MCP 连接
  clearSessionHooks(rootSetAppState, agentId)  // 清理 session hooks
  cleanupAgentTracking(agentId)               // 清理 prompt cache 追踪
  agentToolUseContext.readFileState.clear()    // 释放文件缓存内存
  initialMessages.length = 0                  // 释放 fork 上下文消息
  unregisterPerfettoAgent(agentId)            // 释放 Perfetto 注册
  clearAgentTranscriptSubdir(agentId)         // 释放 transcript 映射
  // 清理 AppState.todos 中的孤儿条目
  // 杀死 Agent 启动的后台 bash 任务
  // 杀死 Agent 启动的 Monitor 任务
}

每一步清理都有明确的必要性。比如最后两步——如果不杀死 Agent 启动的后台 shell 循环(run_in_background 的任务),这些进程的父进程退出后会被 init 进程(PID=1)接管,变成"僵尸进程",在主会话退出后依然残留运行。


八、异步 Agent vs 同步 Agent:不只是"后台运行"

这不是简单的"加个 run_in_background: true"的区别。两种模式在架构上有本质不同。

fork() 的术语来说:同步 Agent 是 fork() + waitpid()——父进程阻塞等子进程结束;异步 Agent 是 fork() + detach——子进程独立运行,父进程继续干活。

09_sync_vs_async.png

维度 同步 Agent 异步 Agent
AbortController 共享父 Agent 的 独立的新实例
setAppState 共享父 Agent 的 隔离(通过 rootSetAppState 间接写入)
权限弹窗 可以显示 自动拒绝(shouldAvoidPermissionPrompts
工具集 完整(经过过滤) ASYNC_AGENT_ALLOWED_TOOLS 白名单
非交互模式 继承父 Agent 强制 isNonInteractiveSession: true
thinking 禁用 禁用
完成通知 直接返回结果 通过 enqueueAgentNotification

一个容易踩坑的点:异步 Agent 用 bubble 权限模式时,权限请求会冒泡到父 Agent 的终端,看起来像是同步的权限请求,但其实来自一个后台 Agent。这在同时运行多个异步 Agent 时可能造成困惑。

还有一个更隐蔽的坑:Agent 被静默拒绝后,不会告诉你"我卡在权限上了"。它只知道"操作失败了",然后用同样的方式重试。

所以你应该怎么做:短任务用同步(能直接看输出),长任务(>2 分钟)用异步(不阻塞主对话)。异步 Agent 启动前确保配了 permissionMode: 'dontAsk'bubble,并限定工具白名单——否则背景 Agent 会因权限不足静默失败,反复重试你也不知道为什么。


九、实战案例:基于源码理解的正确用法

本节中的 Agent({...}) 示例是 Claude 内部生成的工具调用格式,展示参数含义。CLI 中实际输入的是自然语言——Claude 帮你翻译成这些调用。

下面四个案例从简单到复杂递进:单一 Agent 审查 → 探索+实现串行流水线 → 多 Agent worktree 并行重构 → 影响面分析的事前拦截。

案例 1:并行代码审查

最基础的用法——四个完全独立的只读任务,并行执行。你要审查一个大 PR,涉及四个模块。每个模块的审查完全独立——审查 auth 的结果不影响审查 payment 的判断。

# 你在 CLI 里说:
用 code-reviewer 同时审查 src/auth/、src/payment/、src/order/、src/user/
四个模块的最新改动,每个模块独立审查,汇总成一份安全报告。

Claude 内部会把这一句话拆成四个并行的 Agent({subagent_type: "code-reviewer", ...}) 调用,四个审查 Agent 同时启动,各自只读分析自己负责的模块。

为什么这里必须用 Named Agent 而不是 Fork?因为审查 Agent 不需要知道你之前和 Claude 聊了什么——它只需要知道"去读哪几个文件"。Named Agent 从零开始,干净;Fork 继承你的对话历史,多余。

案例 2:探索 + 实现的流水线

案例 1 是"四个任务互不依赖"的并行模式。但现实中有很多任务是串行依赖的——先探索再实现,后一步需要前一步的输出。

# ❌ 错误:你说"帮我把 auth token 验证改成用 JWT,同时探索一下现在怎么实现的"
# → Claude 可能并行启动搜索和实现 → 实现 Agent 不知道搜索的发现

# ✅ 正确:
# 第一步:先搜索
用 Explore 找到项目中所有和 auth token 验证相关的实现,返回文件路径和函数名。

# 第二步:拿到搜索结果后,基于结果去改
# Claude 返回:tokenValidator.ts:42 用自定义 HMAC,session.ts:18 管理令牌生命周期
基于刚才 Explore 的结果,用 general-purpose Agent 把 tokenValidator.ts:42
的 HMAC 验证改成 JWT,同时更新 session.ts:18 的令牌生命周期逻辑。
# 注意这里 Claude 继承了对话上下文(Fork),知道 Explore 返回了什么

案例 3:Worktree 隔离的并行重构

案例 2 是串行流水线。现在回到并行——但这次每个 Agent 都会改文件,不再是只读。四模块重构可以并行,但需要各自独立的分支,互不污染。

# 你在 CLI 里说:
用四个 Agent 并行重构 user、product、order、payment 模块,
都改成 repository 模式。每个 Agent 用 worktree 隔离,
在自己的 git 分支上改。完成后告诉我各自的分支名。

Claude 内部给四个 Agent 各加 isolation: "worktree"。完成后每个 Agent 的改动在各自的临时分支上——你可以逐个 git diff 审查,不满意的直接删分支。四个重构互不干扰,也不用 stash 你当前的工作。

案例 4:影响面分析——堵住"正确代码、错误后果"的漏洞

前三个案例关注的是"怎么做"。案例 4 关注的是"该不该做"——用 Agent 在代码动工之前完成安全检查。

一个真实线上事故:开发者让 AI 对存量系统做功能迭代。代码本身没 bug,逻辑完全正确。上线后用户端 7 秒拿不到返回结果——新加的数据库查询增加了约 200ms 延迟,压垮了一个只剩 500ms 余量的 SLA 链路。

根因不是代码质量——是设计阶段缺少影响面分析

# 你说:
我准备重构 src/auth/tokenValidator.ts 的令牌验证逻辑。
先用 impact-analyzer 检查这个改动会影响哪些调用链,有没有 SLA 风险。

Claude 启动 impact-analyzer——这个 Agent 通过 skills: ["chain-knowledge"] 预加载了链路拓扑和 SLA 约束,能追踪每一层调用关系。它返回的分析报告会告诉你:这个改动会影响订单服务和支付回调链,SLA 余量只剩 300ms,你的改动可能让端到端延迟超限。

只有当影响面分析通过后,才启动修改 Agent。 这个流程把 Sub-agent 从一个"事后审查"的辅助角色,升级成了"事前拦截"的工程防线——不是代码写好后再检查,而是代码还没写就先堵漏洞。

流水线中的交接契约

案例 2 展示了一条串行流水线——Explore 找 → General-Purpose 改。当流水线拉长到三四个阶段时,上下游之间需要交接契约(Handoff Contract):上游为下游准备的结构化信息,让下游不需要重复任何搜索就能开始自己的分析。

反面教材:Bug Locator 输出"bug 可能在 auth 模块里" → Analyzer 收到后不得不自己又搜了一遍 → 流水线形同虚设。合格交接至少包含:具体文件路径、函数名、行号范围、搜索证据(搜过什么、排除了什么)、为什么怀疑这个位置。

扩展视角:从子代理到 Agent Teams

本文的 Sub-agent 有一个硬约束:子代理只能向主对话汇报,不能互相通信。 打破这个限制的是 Claude Code 的实验性功能 Agent Teams——下篇详解。


十、常见失败模式与源码级诊断

每个失败模式背后都有一个被源码证实了的心理误判。知道"为什么掉坑"比知道"坑在哪"更有用。

失败模式 1:Agent 消耗 token 却不返回有用结果

症状:Agent 运行了很久,做了很多工具调用,最终报告里信息很少——像是做了一大堆工作但没有总结。

心理根因:你以为 Agent 会"自然地"在最后做总结。LLM 没有总结本能——它只是在生成下一个 token。如果最后一轮恰好是工具调用,它不会"觉得"自己需要再补一段文字总结。

源码级原因finalizeAgentTool 优先提取最后一条 assistant 消息中的 text block(agentToolUtils.ts:301-303)。如果为空,会反向遍历所有历史 assistant 消息找第一个有 text 的(agentToolUtils.ts:307-315)——这个 fallback 能兜住一部分情况,但当 fallback 命中的是一条中间过程的思考而不是最终总结时,仍然拿不到有用的结果。根源还是 LLM 本身没有总结本能,以工具调用结束时不会自觉补一段文字。

解决方案:在 Agent 的 prompt 里明确要求"最后一条消息必须是文字总结,不要以工具调用结束"。不是你提示写得不够好——是提取逻辑本身只看最后一条消息。

失败模式 2:异步 Agent 被权限请求卡住

症状:异步 Agent 看起来卡住了,没有错误信息,没有进度,就像"死掉了"。

心理根因:你以为"后台运行 = 自动获得所有权限"。实际上后台运行的真相是"不能弹窗问你 → 自动拒绝 → Agent 不理解为什么被拒 → 重试 → 再次被拒 → 无限循环"。Agent 不会告诉你"我被权限卡住了",因为它的上下文里只有"操作失败了"。

源码级原因:当 shouldAvoidPermissionPrompts 为 true 时,需要权限确认的操作会被自动拒绝。Agent 不理解"拒绝"和"失败"的区别,继续用相同方式重试。

解决方案

  • 给异步 Agent 配置 permissionMode: 'dontAsk' 加上明确的 allowedTools(治本)
  • 或者用 permissionMode: 'bubble' 让权限请求冒泡到你的终端,但多 Agent 并行时一堆弹窗会让你困惑(治标)

失败模式 3:Fork 子 Agent 试图再 fork

症状:Fork 子 Agent 的对话突然终止,没有输出,也没有错误提示。

心理根因:你以为 Fork 就是"一个普通的 Agent,可以再调 Agent"。但 Fork 的本质是"克隆了主对话的上下文,带上防递归标记"。它的设计意图就是"只执行,不分发"——分发的责任在主对话。

源码级原因isInForkChild() 检测到对话中的 <fork_boilerplate> 标记,拒绝了 fork 请求。这不是 bug——所有编排必须由主对话完成,子 Agent 不能嵌套。

解决方案:Fork 子 Agent 收到的 boilerplate 里已经说了"Do NOT spawn sub-agents; execute directly"。如果你的任务确实需要多层 Agent 协作,用 Named Agent 而不是 Fork——让主对话作为唯一的编排者逐阶段调用。

这三个失败模式的共同根因:你把 Agent 当成了,但源码里它是一套机械规则。它不会"觉得该总结了"、"理解权限为什么被拒"、"知道不该再 fork"。每当 Agent 的行为不如预期,第一反应不是改进 prompt,而是去查对应的源码逻辑——通常答案就在几行代码里。


本篇实践任务

任务 1:解剖你项目中的 Agent 调用

在一个中等复杂度的项目上,让 Claude Code 做一个涉及搜索 + 修改的任务(比如"找到所有硬编码的 API URL 并替换为环境变量")。观察它是否主动使用了 Sub-agent,用的是哪种类型,prompt 是怎么写的。对比它的选择和你的直觉。

任务 2:写一个自定义 Agent 配置

.claude/agents/ 下创建一个只读的代码审查 Agent,配置 disallowedToolspermissionModemaxTurns。然后用它审查你最近的一次 commit,观察它的行为是否被配置正确约束了。

任务 3:测试 Worktree 隔离

对一个有测试的项目,启动两个 isolation: "worktree" 的 Agent 并行修改不同模块。完成后检查:各自的 worktree 分支是否独立?git log 是否只包含各自模块的修改?合并时是否有冲突?


下篇预告

第 10 篇:Agent Teams——当子 Agent 开始互相说话

本文讲的 Sub-agent 有一个硬约束:子 Agent 只能向主对话汇报,不能互相通信。而 Claude Code 的实验性功能 Agent Teams 打破了这个限制——Teammates 可以直接发消息、互相挑战结论、共享发现。下一篇讲 Agent Teams 的源码实现和四种核心协作模式:竞争假设、分层评审、模块化开发、规划审批。


AI Coding 系列持续更新。Sub-agent 不是让 Claude 做更多,而是让它记更少——噪声隔离有边界,编排决策有框架。

【节点】[Distance节点]原理解析与实际应用

作者 SmalBox
2026年5月23日 19:08

【Unity Shader Graph 使用与特效实现】专栏-直达

Distance 节点是 Unity URP Shader Graph 中的一个重要数学运算节点,它计算两个输入向量之间的欧几里德距离。欧几里德距离是几何学中最常见的距离度量方式,表示在 n 维空间中两点之间的直线距离。

在计算机图形学和着色器编程中,Distance 节点具有广泛的应用场景。它不仅用于基本的距离计算,还是实现各种高级视觉效果的基础工具。该节点特别适用于计算有符号距离函数(Signed Distance Function,SDF),这是现代实时渲染中用于描述几何形状边界的重要数学工具。

Distance 节点的工作原理基于欧几里德距离公式。对于二维空间中的两点 A(x₁, y₁) 和 B(x₂, y₂),距离计算公式为:√[(x₂-x₁)² + (y₂-y₁)²]。对于三维空间,公式扩展为:√[(x₂-x₁)² + (y₂-y₁)² + (z₂-z₁)²]。Shader Graph 中的 Distance 节点自动处理这些计算,支持从一维到四维的向量输入。

该节点在视觉效果创作中的重要性体现在多个方面:

  • 它是创建基于距离的渐变、过渡效果的基础
  • 用于实现物体边缘发光、轮廓检测等效果
  • 在程序化生成内容中用于形状描述和空间划分
  • 是许多高级渲染技术如距离场渲染、光线步进的基础构建块

数学原理

欧几里德距离基础

欧几里德距离是衡量空间中两点间"直线"距离的标准方法。在着色器编程中理解其数学原理对于有效使用 Distance 节点至关重要。

对于不同维度的向量,距离计算略有不同:

  • 一维向量:Distance = |A - B|
  • 二维向量:Distance = √[(A.x - B.x)² + (A.y - B.y)²]
  • 三维向量:Distance = √[(A.x - B.x)² + (A.y - B.y)² + (A.z - B.z)²]
  • 四维向量:Distance = √[(A.x - B.x)² + (A.y - B.y)² + (A.z - B.z)² + (A.w - B.w)²]

在 Shader Graph 中,无论输入向量的维度如何,Distance 节点始终输出一个浮点数值,表示两个输入向量之间的绝对距离。

距离计算的实际考虑

在实际着色器应用中,出于性能考虑,有时会使用距离的平方而不是实际距离。这是因为平方根计算在 GPU 上相对昂贵。Distance 节点内部确实计算了完整的欧几里德距离,包括平方根操作,但在某些性能敏感的场景中,开发者可能会选择手动计算平方距离来避免平方根开销。

例如,当只需要比较距离大小时(如找出最近的点),使用平方距离就足够了,因为平方根函数是单调递增的,距离的大小关系与平方距离的大小关系一致。

端口详解

输入端口

A 端口

  • 方向:输入
  • 类型:动态矢量(Float,Vector2,Vector3,Vector4)
  • 描述:第一个输入向量,代表空间中的一个点或位置。这个端口可以接受不同维度的向量输入,根据连接的数据类型自动适应。在实际应用中,A 端口通常代表需要计算距离的起始点或参考点。

B 端口

  • 方向:输入
  • 类型:动态矢量(Float,Vector2,Vector3,Vector4)
  • 描述:第二个输入向量,代表空间中的另一个点或位置。与 A 端口一样,B 端口也支持动态向量类型,但必须与 A 端口保持相同的维度。B 端口通常代表目标点或需要测量距离的终点。

输出端口

Out 端口

  • 方向:输出
  • 类型:Float
  • 描述:输出 A 和 B 之间的欧几里德距离,始终为标量值。无论输入向量的维度如何,输出都是单个浮点数,表示两点之间的绝对距离。这个值总是非负的,因为距离没有方向性。

端口连接规范

Distance 节点对输入端口有一些重要的连接要求:

  • A 和 B 端口必须连接相同维度的向量类型
  • 如果连接不同维度的向量,Shader Graph 会显示编译错误
  • 输入端口支持直接连接常量值、属性、其他节点的输出或图形输入节点
  • 输出端口可以连接到任何接受浮点数输入的端口

使用方法和技巧

基本连接方法

使用 Distance 节点的基本步骤很简单:

  • 将需要计算距离的两个向量分别连接到 A 和 B 端口
  • 将 Out 端口连接到需要使用距离值的后续节点
  • 根据需要调整后续节点的处理逻辑

典型的基本设置包括:

  • 连接两个 Position 节点来计算空间中两点的距离
  • 连接 UV 坐标和固定点来计算基于纹理坐标的距离
  • 连接时间动画的向量来创建动态距离效果

性能优化技巧

虽然 Distance 节点使用方便,但在性能关键的场景中需要考虑优化:

  • 在片段着色器中频繁使用 Distance 节点可能影响性能,特别是移动平台
  • 对于只需要距离比较的场景,考虑使用点积运算手动计算平方距离
  • 在顶点着色器中预计算距离然后插值到片段着色器可以提高性能
  • 对于静态场景,考虑将距离计算烘焙到纹理中

常见应用模式

Distance 节点在着色器创作中有几种经典的应用模式:

径向渐变模式:

  • 使用 Distance 节点计算当前片段到中心点的距离
  • 将距离值映射到 0-1 范围作为渐变系数
  • 使用渐变系数混合颜色或透明度

边缘检测模式:

  • 计算到边界或特定位置的距离
  • 使用步进或平滑步进函数创建清晰的边缘
  • 可以用于创建描边、发光边界等效果

距离场渲染模式:

  • 使用 Distance 节点计算到多个物体的距离
  • 通过距离函数组合创建复杂形状
  • 利用有符号距离函数实现高级几何渲染

实际应用案例

案例一:创建径向渐变着色器

径向渐变是 Distance 节点最直接的应用之一。以下是创建简单径向渐变的步骤:

  • 在 Shader Graph 中创建新的 Unlit Graph
  • 添加 Position 节点并设置为 Absolute World
  • 添加 Vector3 属性作为渐变中心点,默认值设为 (0,0,0)
  • 将 Position 和中心点属性连接到 Distance 节点的 A 和 B 端口
  • 添加 Divide 节点将距离值除以外半径值进行标准化
  • 添加 Saturate 节点确保结果在 0-1 范围内
  • 使用标准化后的距离值作为 Lerp 节点的系数,混合内外颜色
  • 连接到 Fragment 节点的 Base Color 端口

这种技术可以扩展为创建复杂的径向背景、能量护盾效果或聚焦光照效果。

案例二:实现物体边缘发光

使用 Distance 节点可以检测物体边缘并添加发光效果:

  • 使用 Object 节点的 Position 输出作为基础位置
  • 添加 Camera 节点的 World Position 作为观察点参考
  • 计算物体表面点到摄像机方向的垂直距离
  • 当距离小于阈值时应用发光颜色
  • 使用指数函数控制发光的衰减曲线
  • 将发光效果与基础颜色相加混合

这种方法可以创建科幻风格的轮廓光、危险物品警示效果或魔法特效。

案例三:制作交互式溶解效果

Distance 节点非常适合创建基于距离的溶解效果:

  • 计算每个片段到交互点(如玩家位置)的距离
  • 将距离与阈值比较,决定是否溶解
  • 使用噪声纹理为溶解边缘添加细节
  • 根据距离控制溶解边缘的发光强度
  • 添加动画使溶解效果随时间传播

这种效果常用于角色死亡、物体破坏或魔法传送等游戏场景。

与其他节点的配合使用

与数学节点配合

Distance 节点经常与各种数学节点结合使用以实现更复杂的效果:

  • 与 Divide 节点配合:标准化距离值,将其映射到特定范围
  • 与 Multiply 节点配合:调整距离的影响强度或创建重复模式
  • 与 Add/Subtract 节点配合:偏移距离基准点或创建距离偏移效果
  • 与 Power 节点配合:创建非线性的距离衰减曲线

与高级函数节点配合

Distance 节点与一些特殊函数节点结合可以创建专业级效果:

  • 与 Remap 节点配合:将距离从原始范围重新映射到新范围
  • 与 Smoothstep 节点配合:创建平滑的距离过渡区域
  • 与 Fraction 节点配合:基于距离创建重复图案
  • 与 Noise 节点配合:为距离效果添加有机变化

在节点组中的角色

在复杂的着色器中,Distance 节点通常作为更大节点网络的一部分:

  • 在距离场着色器中作为基础距离计算单元
  • 在光照模型中作为衰减计算的基础
  • 在后期处理效果中作为空间遮罩生成器
  • 在程序化生成中作为形状描述的基本操作

生成的代码示例分析

代码结构解析

Distance 节点生成的 HLSL 代码反映了其核心功能:

HLSL

void Unity_Distance_float4(float4 A, float4 B, out float Out)
{
    Out = distance(A, B);
}

这段代码展示了一个典型的四维向量距离计算函数。分析代码结构:

  • 函数名为 Unity_Distance_float4,表明处理的是 float4 类型
  • 接受两个 float4 参数 A 和 B
  • 通过 out 参数返回计算结果
  • 使用 HLSL 内置的 distance() 函数进行实际计算

内置 distance 函数

HLSL 中的 distance() 函数是 Distance 节点的核心实现:

HLSL

// HLSL 内置 distance 函数的近似实现
float distance(float4 a, float4 b)
{
    float4 diff = a - b;
    return sqrt(dot(diff, diff));
}

这个实现展示了欧几里德距离的实际计算过程:

  • 首先计算两个向量的差值
  • 然后计算差值的点积(即各分量平方和)
  • 最后对点积结果取平方根得到实际距离

不同维度的变体

Shader Graph 会根据输入向量维度生成不同的函数变体:

对于二维向量:

HLSL

void Unity_Distance_float2(float2 A, float2 B, out float Out)
{
    Out = distance(A, B);
}

对于三维向量:

HLSL

void Unity_Distance_float3(float3 A, float3 B, out float Out)
{
    Out = distance(A, B);
}

这些变体确保了无论输入数据维度如何,都能正确计算距离。

故障排除和常见问题

编译错误和解决方案

在使用 Distance 节点时可能遇到的一些常见编译错误:

维度不匹配错误:

  • 问题:A 和 B 端口连接了不同维度的向量
  • 解决方案:确保两个输入端口使用相同维度的向量类型

类型不兼容错误:

  • 问题:尝试连接不支持的数据类型到输入端口
  • 解决方案:只使用浮点数类型的向量(Float/Vector2/Vector3/Vector4)

循环依赖错误:

  • 问题:节点连接形成了循环引用
  • 解决方案:检查节点连接,确保数据流向是单向的

性能问题诊断

Distance 节点可能引起的性能问题及解决方法:

片段着色器过载:

  • 症状:在片段着色器中大量使用 Distance 节点导致帧率下降
  • 解决方案:将计算移至顶点着色器,或使用简化距离计算

精度问题:

  • 症状:在远距离时出现精度误差或闪烁
  • 解决方案:使用更高精度的浮点数,或重新设计距离计算范围

移动端性能问题:

  • 症状:在移动设备上性能显著下降
  • 解决方案:减少 Distance 节点使用频率,使用近似计算或预计算

视觉效果问题

使用 Distance 节点时可能遇到的视觉效果问题:

距离计算不准确:

  • 问题:计算的距离与预期不符
  • 检查点:确认使用的坐标空间是否正确,检查向量分量是否完整

渐变效果不连续:

  • 问题:基于距离的渐变出现明显边界或断层
  • 解决方案:检查距离标准化过程,确保使用正确的插值函数

边缘效果闪烁:

  • 问题:基于距离的边缘效果在摄像机移动时闪烁
  • 解决方案:为距离计算添加适当的偏导数或使用屏幕空间技术

高级应用和创意用法

有符号距离函数(SDF)应用

Distance 节点是实现有符号距离函数的基础。SDF 是描述几何形状的强大数学工具:

基本 SDF 形状:

  • 球体 SDF:distance(p, center) - radius
  • 盒子 SDF:计算点到立方体边界的有符号距离
  • 平面 SDF:dot(p, normal) - distance

SDF 布尔运算:

  • 并集:min(d1, d2)
  • 交集:max(d1, d2)
  • 差集:max(d1, -d2)

SDF 扭曲和变形:

  • 通过噪声函数扭曲距离场
  • 使用三角函数创建重复图案
  • 结合时间变量创建动画 SDF

程序化动画和交互

Distance 节点可以驱动各种程序化动画效果:

波浪传播效果:

  • 基于到源点的距离控制波浪相位
  • 使用正弦函数创建波浪形状
  • 结合时间变量创建传播动画

粒子吸引/排斥系统:

  • 计算粒子到吸引点的距离
  • 根据距离决定作用力强度和方向
  • 创建自然的粒子运动行为

交互式变形效果:

  • 基于到交互点的距离变形网格
  • 使用距离控制变形强度和范围
  • 创建响应玩家操作的动态环境

高级渲染技术

Distance 节点在现代渲染技术中扮演重要角色:

距离场软阴影:

  • 使用距离场信息计算柔和阴影
  • 通过多次距离查询估计遮挡程度
  • 创建高质量的场景阴影效果

光线步进渲染:

  • 使用距离场指导光线前进步骤
  • 大幅提高复杂几何体的渲染效率
  • 实现实时体渲染和复杂参数曲面渲染

全局光照近似:

  • 基于距离估计间接光照贡献
  • 创建简化的环境光遮蔽效果
  • 实现性能友好的全局光照近似

【Unity Shader Graph 使用与特效实现】专栏-直达 (欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)

CryptoJS:数据安全的JavaScript加密利器

2026年5月23日 18:56

image

前言

  在传统的客户端-服务器交互中,用户在前端输入的敏感信息(如用户名、密码、信用卡号等)通常会以明文通过 HTTPS 提交到后台,即便在 HTTPS 保护下,仍有安全隐患。如果用户的浏览器或网络受到攻击,可能篡改或窃取表单数据,甚至被浏览器插件劫持。而且如果后端在日志中意外记录了明文敏感信息,可能存在泄露风险。因此,在前端对敏感数据进行加密,并在后端对其解密,能够为安全防护增加一道“保险层”,即便数据在传输层被截获,也难以被攻击者直接获取明文。

一、CryptoJS 快速入门

1.1 CryptoJS 是什么

  随着前端技术的不断发展,安全性问题越来越受到重视。在这样的背景下,加密技术成为了保护数据安全的重要手段。crypto-js 是一个功能强大的 JavaScript 加密库,它提供了多种加密算法,包括AES、DES、MD5等。这些算法可以帮助开发者轻松地实现数据的加密和解密操作,从而保护敏感数据的安全性。

⚠️ CryptoJS 作为一个成熟的 JavaScript 加密库,虽然官方已停止维护,但其稳定性和丰富的功能使其仍然是许多项目的可靠选择。通过合理的使用和配置,可以在项目中构建强大的安全防护体系。

CryptoJS 官方文档:cryptojs.gitbook.io/

1.2 主要功能概览

  CryptoJS 是一套纯 JavaScript 实现的常用加密算法库,包含以下常见模块:

graph TD
A[输入数据]
A-->B{加密类型}
B-->C1[哈希算法]
B-->C2[对称加密]
B-->C3[编解码]
C1-->D1["MD5/SHA1/SHA256"]
C2-->D2["AES/DES/Rabbit"]
C3-->D3["Base64/Hex"]
D1-->E1[输出哈希值]
D2-->E2[输出密文]
D3-->E3[输出编码结果]

style B fill:#FFA500,stroke:#333,stroke-width:2px;

  由于 CryptoJS 纯前端可用,不依赖于 Node 内置模块,体积较小、使用方便,常用于浏览器环境的数据加密、签名和哈希操作。

1.3 在 Vue 中安装并引入

安装 CryptoJS

  要在 Vue 项目中使用 crypto-js,首先需要通过 npm 将其安装到项目中。打开终端,进入项目目录,执行以下命令:

npm install crypto-js --save-dev

# # 安装核心库与类型声明
npm install @types/crypto-js --save-dev

⚠️ crypto-js 4.x 版本依赖原生 crypto 模块,不支持IE10及以下浏览器。如需支持旧浏览器,建议使用 3.x 版本

在组件中引入 CryptoJS

  在需要进行加密操作的 Vue 组件中,引入相关模块。

import CryptoJS from 'crypto-js';

  引入后,我们就能得到 CryptoJS 这个对象,它包含了各种各样的加密算法。

1.4 CryptoJS 加密模式

  在 CryptoJS 中,加密模式指的是在加密过程中使用的特定算法模式。这些模式决定了如何组织和处理明文和密钥,以及如何生成密文。CryptoJS 支持多种加密模式,每种模式都有其特定的用途和安全性。以下是一些常见的加密模式:

加密模式 简要说明
ECB 将明文分割成独立的块,然后使用相同的密钥对每个块进行独立加密。
安全性较低,因为相同的明文块会产生相同的密文块,在实际生产中不推荐使用
CBC 最常用的 AES 模式,通常用于加密较长的数据。
它需要 IV(初始化向量),并且每个数据块的加密依赖于前一个数据块
广泛使用,适用于大多数需要一定安全性的应用。
CFB 提供与CBC相似的安全性,但实现起来可能更复杂
OFB 使用一个初始向量(IV)和一个密钥流生成器。
密钥流生成器基于密钥和一系列迭代产生的输出。
提供与CFB相似的安全性,但实现上更简单
CTR 使用一个计数器来生成密钥流。
每个块的加密都是独立的,与前一个块无关。
提供高强度的安全性,并且易于实现并行处理
GCM 结合了计数器模式和Galois乘法的认证加密模式。
提供认证加密功能,即同时保证数据的机密性和完整性。
是目前推荐用于需要同时保证数据安全和完整性的应用(如TLS)

二、编码转换

  在 CryptoJS 中,编码转换是加密操作的基础环节,它负责在不同数据表示形式之间进行转换。CryptoJS 提供了完整的编码器体系,其中Base64、Hex、UTF-8、UTF-16是最常用的编码方式。

2.1 UTF-8、UTF-16

  在现代 Web 开发和密码学应用中,字符编码处理是至关重要的基础环节。CryptoJS 提供了强大的 UTF-8 和 UTF-16 编码支持,使得我们能够轻松处理多语言文本的加密和解密操作。这些编码器不仅支持基本的 ASCII 字符,还能够正确处理复杂的 Unicode 字符,包括 emoji 表情和特殊符号。

// 字符串转换为WordArray
const wordArray = CryptoJS.enc.Utf8.parse("Hello 世界");
console.log("WordArray:", wordArray.toString());
 
// WordArray转换回字符串
const originalString = CryptoJS.enc.Utf8.stringify(wordArray);
console.log("Original String:", originalString);

2.2 Base64编解码

  在Web开发过程中,Base64 编码是用于传输 8bit 字节数据的常见编码方式之一,能够将二进制数据转换为 ASCII 字符序列,广泛应用于数据加密、文件传输和图片处理等场景。CryptoJS 库提供了完整且高效的 Base64 加解密功能,包括Base64编码和解码。

方法 参数 返回值 说明
CryptoJS.enc.Utf8.parse() 字符串 WordArray 将字符串转换为WordArray格式
CryptoJS.enc.Base64.stringify() WordArray Base64字符串 执行Base64编码
CryptoJS.enc.Base64.parse() Base64字符串 WordArray 解析Base64字符串
toString() 编码类型 字符串 将WordArray转换为指定编码的字符串
// 待编码的字符串
const originalText = "Hello World";

// 字符串转 WordArray
const wordArray = CryptoJS.enc.Utf8.parse(originalText);

// Base64 编码(解决特殊字符传输问题)
const base64Encoded = CryptoJS.enc.Base64.stringify(wordArray);
console.log("Base64 编码:", base64Encoded);

const parsedWordArray = CryptoJS.enc.Base64.parse(base64Encoded);
// Base64 解码
const base64Decoded = parsedWordArray.toString(CryptoJS.enc.Utf8);
console.log("Base64 解码:", base64Decoded);

⚠️ 需要确保在编码和解码过程中使用相同的编码方式(如UTF-8),以避免出现乱码。虽然 Base64 编码可以用于加密数据的传输,但它本身并不提供加密功能。

2.3 Hex

  在密码学和数据安全领域,十六进制(Hex)编码扮演着至关重要的角色。CryptoJS 库中的 CryptoJS.enc.Hex 编码器专门用于处理二进制数据与十六进制字符串之间的转换,这种编码方式在多个关键场景中发挥着不可替代的作用。以下是 Hex 编码解码的完整使用示例:

const message = "Hello World";

// Hex 编码
const hexEncoded = CryptoJS.enc.Hex.stringify(CryptoJS.enc.Utf8.parse(message));
console.log("Hex 编码:", hexEncoded);

// Hex 解码
const hexDecoded = CryptoJS.enc.Hex.parse(hexEncoded).toString(CryptoJS.enc.Utf8);
console.log("Hex 解码:", hexDecoded);

三、哈希算法

  哈希函数主要用于生成数据的“数字指纹”,常用于密码存储(需配合加盐)和数据完整性校验,保证信息在传输过程中不被篡改。

3.1 MD5 加密

  MD5 是一种广泛使用的散列函数,可以产生出一个128位(16字节)的不可逆的散列值,用于确保信息传输完整一致。它被用于各种安全应用,也通常用于校验文件的完整性。MD5算法具有以下特点:

特点 简要说明
压缩性 任意长度的消息都可以被压缩成一个128位的摘要
容易计算 MD5 算法的计算速度比较快,适用于对大量数据进行哈希计算
抗修改性 对原始数据进行任何修改,都会导致哈希值的变化
抗碰撞性 对不同的原始数据,哈希值相同的概率非常小

  在CryptoJS中,MD5加密非常简单,以下是 CryptoJS 实现 MD5 算法的示例代码:

const message = 'Hello, World!';
const encrypted = CryptoJS.MD5(message).toString();
// 输出MD5加密后的字符串
console.log("MD5:", encrypted);

  上述代码的意思是将字符串“hello world”使用 MD5 算法进行加密,并将结果以字符串的形式输出到控制台中。需要注意的是,在输出字符串时,需要使用 toString 方法将加密结果转换为字符串,否则将无法正常输出。

  传入CryptoJS.MD5 的参数除了字符串外,还可以是 CryptoJS 定义的一种叫做 WordArray 的数据类型。比如:

const wordArray = CryptoJS.enc.Utf8.parse('Hello World');
CryptoJS.MD5(wordArray).toString();

⚠️ 虽然 MD5 计算速度快,但已被证实存在安全隐患,仅建议用于非安全场景的本地文件校验。

3.2 SHA-1 加密

  SHA1 是一种常用的哈希算法,用于将任意长度的消息压缩成一个160位的摘要。SHA1算法具有以下特点:

特点 简要说明
压缩性 任意长度的消息都可以被压缩成一个160位的摘要
容易计算 SHA1 算法的计算速度比较快,适用于对大量数据进行哈希计算
抗修改性 对原始数据进行任何修改,都会导致哈希值的变化
抗碰撞性 对不同的原始数据,哈希值相同的概率非常小

  以下是 CryptoJS 实现 SHA1 算法的示例代码:

const wordArray = CryptoJS.enc.Utf8.parse('Hello World');
console.log("SHA1:", CryptoJS.SHA1(message).toString());

3.3 SHA-2 加密

  SHA-224、SHA-256、SHA-384和SHA-512合称为SHA-2,虽然 SHA-2 提供了更好的安全性,但是它的应用不如 SHA-1 广泛,通常用于数据签名和身份验证等场合。

SHA-256

  SHA256 是一种比较常见的哈希算法,它是一种单向加密算法,不提供解密方法,用于将任意长度的消息压缩成一个256位的摘要。SHA256算法具有以下特点:

特点 简要说明
压缩性 任意长度的消息都可以被压缩成一个256位的摘要
容易计算 SHA256 算法的计算速度比较快,适用于对大量数据进行哈希计算
抗修改性 对原始数据进行任何修改,都会导致哈希值的变化
抗碰撞性 对不同的原始数据,哈希值相同的概率非常小

  以下是 CryptoJS 实现 SHA3 算法的示例代码:

const message = "Hello World";
console.log("SHA3:", CryptoJS.SHA3(message).toString());

SHA-512

  SHA3 是一种比较常见的哈希算法,用于将任意长度的消息压缩成一个固定长度的摘要。。SHA256算法具有以下特点:

特点 简要说明
压缩性 任意长度的消息都可以被压缩成一个固定长度的摘要
容易计算 SHA512 算法的计算速度比较快,适用于对大量数据进行哈希计算
抗修改性 对原始数据进行任何修改,都会导致哈希值的变化
抗碰撞性 对不同的原始数据,哈希值相同的概率非常小

  以下是 CryptoJS 实现 SHA256 算法的示例代码:

const message = "Hello World";
console.log("SHA512:", CryptoJS.SHA512(message).toString());

3.4 SHA-3 加密

  SHA512 是一种比较常见的哈希算法,它是一种单向加密算法,不提供解密方法,用于将任意长度的消息压缩成一个 512 位的摘要。SHA256算法具有以下特点:

特点 简要说明
压缩性 任意长度的消息都可以被压缩成一个 512 位的摘要
容易计算 SHA512 算法的计算速度比较快,适用于对大量数据进行哈希计算
抗修改性 对原始数据进行任何修改,都会导致哈希值的变化
抗碰撞性 对不同的原始数据,哈希值相同的概率非常小

  以下是 CryptoJS 实现 SHA256 算法的示例代码:

const message = "Hello World";
console.log("SHA512:", CryptoJS.SHA512(message).toString());

四、对称加密算法

  加密是为了保证数据安全传输,使得其他人不能获取的具体信息内容。以某种特殊的算法,将原本信息数据进行改变,使得即使没有权限的人看到消息也不能从中得到任何有用信息,但是加密的信息是保证可逆的,即可加密必可解密(其长度与目标文本成正比)。所谓对称,指的是加密和解密使用的是相同的秘钥,常见的有 DES、3DES、AES等。对称加密主要用于对敏感数据进行加密和解密,确保数据的机密性。其加解密速度快、计算量小,适合对大量数据进行加密处理。

4.1 AES加密解密的实现

  AES 是一种常见的对称加密算法,通过相同的密钥进行加密和解密,常用于数据保护和机密信息存储等场合。AES 的出现,是用于取代已经被证明不安全的 DES 算法。AES 或者说对称加密算法的优点是速度快,缺点就是不安全。为了最大程度地兼容性与安全性,我们采用 AES-256-CBC 模式对称加密。AES 算法具有以下特点:

特点 简要说明
安全性高 AES 算法使用固定长度的密钥进行加密和解密,可以有效防止数据被破解
灵活性强 AES 算法可以使用多种密钥长度,如128位、192位或256位
计算速度快 AES 算法的计算速度比较快,适用于对大量数据进行加密和解密
// 加密:数据 → 密钥 → 密文
const ciphertext = CryptoJS.AES.encrypt("要加密的敏感数据", "自定义密钥").toString();

// 解密:密文 → 密钥 → 原始数据
const bytes = CryptoJS.AES.decrypt(ciphertext, "自定义密钥");
const plaintext = bytes.toString(CryptoJS.enc.Utf8);

密钥和偏移量的设置

  加密需要一把“钥匙”,这把钥匙就是密钥。另外还有一个叫“偏移量”的东西,它可以帮助我们更好地加密信息。AES 加密需要密钥(Key)和初始向量(IV),这些参数可以自定义,以确保加密解密的正确性。

  • key是对称加密算法的核心参数,同一个明文和密钥加密后得到的密文是相同的,因此密钥必须保密并且不易被破解。key的长度可以是128位、192位或256位,不同长度的key对应着不同的安全级别。
  • iv是用于增加加密强度的参数,它需要与key一起作为输入参数传递给加密算法。iv的长度为128位,它在每次加密时都会改变,并与key一起参与加密过程。iv的作用是将相同的明文使用不同的iv加密后生成不同的密文,从而增加破解的难度和安全性。
// 密钥
const secretKey = CryptoJS.enc.Utf8.parse("12345678901234567890123456789012");
// 偏移量
const secretIv = CryptoJS.enc.Utf8.parse("abcdefghijklmnop");

⚠️ 注意:生产环境中,密钥(key)和初始向量(iv)强烈建议从后端接口动态获取,与后端开发保持一致,绝对不要硬编码在前端!

加密解密函数封装

  我们需要创建一个加密函数来加密信息,这个函数接收一段明文(也就是正常能看懂的文字),然后返回加密后的文字。使用 CryptoJS 的 AES 模块对数据进行加密,使用相同的密钥和配置参数创建解密函数。

/**
 * 使用 AES-256-CBC 进行加密
 * @param originalText 待加密的数据,支持字符串、对象等
 * @param secretKey - 加密密钥
 * @param secretIv - 初始向量
 * @returns 加密后的 Base64 字符串
 */
export const encryptAES = (originalText: any, secretKey: string, secretIv: string): string => {
    if (!originalText) {
        return "";
    }
    const key = CryptoJS.enc.Utf8.parse(secretKey);
    const iv = CryptoJS.enc.Utf8.parse(secretIv);

    // 统一将数据转为 JSON 字符串
    const dataStr = typeof originalText === 'string' ? originalText : JSON.stringify(originalText);

    // 使用AES算法进行加密
    const encrypted = CryptoJS.AES.encrypt(dataStr, key, {
        iv: iv,
        mode: CryptoJS.mode.CBC,
        padding: CryptoJS.pad.Pkcs7
    });

    // encrypted.toString() 默认返回 Base64 编码
    return encrypted.toString();
}

  我们还需要一个函数来解密信息,解密的写法和加密差不多,只是把 encrypt 方法名改为 decrypt。这个函数接收加密后的文字,然后返回正常的明文。

/**
 * 使用 AES-256-CBC 进行解密
 * @param cipherText 加密后的字符串
 * @param secretKey - 解密密钥
 * @param secretIv - 初始向量
 * @returns {any} 解密后的原始数据
 */
export const decryptAES = (cipherText: string, secretKey: string, secretIv: string): any => {
    if (!cipherText) {
        return "";
    }
    // 将 Key 与 IV 转成 WordArray
    const key = CryptoJS.enc.Utf8.parse(secretKey);
    const iv = CryptoJS.enc.Utf8.parse(secretIv);

    // 执行解密
    const decrypted = CryptoJS.AES.decrypt(cipherText, key, {
        iv: iv,
        mode: CryptoJS.mode.CBC,
    });
    // 将解密结果转为 UTF-8 字符串
    const decryptedStr = decrypted.toString(CryptoJS.enc.Utf8);
    // 尝试还原为 JSON 对象,如果不是对象则直接返回字符串
    try {
        return JSON.parse(decryptedStr);
    } catch {
        return decryptedStr;
    }
}

⚠️ 如果之前在加密时没有将明文进行 parse 而是直接传入的,那么在解密时,传入 toString() 的解析方式就是写默认的 CryptoJS.enc.Utf8。

  既然有了上面的加密和解密函数,现在要在 Vue 项目中使用它们。创建一个简单的 Vue 组件,让用户输入一些信息,然后可以加密和解密。

<script setup lang="ts">
import { ref } from 'vue';
import {decryptAES, encryptAES} from "@/hooks/CryptoJSUtils.ts";

const plaintext = ref('');
const ciphertext = ref('');
const decryptedText = ref('');
// ⚠️ 注意:生产环境中,密钥(key)和初始向量(iv)强烈建议从后端接口动态获取,绝对不要硬编码在前端!
const secretKey = "12345678901234567890123456789012";
const secretIv = "abcdefghijklmnop";

// 加密
const handleEncrypt = () => {
  ciphertext.value = encryptAES(plaintext.value, secretKey, secretIv);
}

// 解密
const handleDecrypt = () => {
  decryptedText.value = decryptAES(ciphertext.value, secretKey, secretIv);
}
</script>

<template>
  <div>
    <input type="text" v-model="plaintext" placeholder="请输入明文" />
    <button @click="handleEncrypt">加密</button>
    <button @click="handleDecrypt">解密</button>
    <p>加密后的文本: {{ ciphertext }}</p>
    <p>解密后的文本: {{ decryptedText }}</p>
  </div>
</template>

Java 加解密基础

  Java 中的加解密 API 集中在 javax.crypto 包内,核心类包括:

核心类 简要说明
Cipher 加解密的核心类,指定算法、模式、填充方式后,可调用 init、doFinal 进行加密解密
SecretKeySpec 用来将字节数组转换成对称密钥(SecretKey)
IvParameterSpec 用来封装初始化向量(IV)
Base64 Java 8 内置的 Base64 编解码类

  如果使用 Spring Boot,可在 pom.xml 中引入 Web 依赖即可,无需额外加密库,因为 JCE 已内置于 JDK。创建一个工具类 EncryptUtils,封装 AES 解密方法:

import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.Base64;

public class EncryptUtils {
  // 加密算法
    private static String algorithm = "AES";
  // 加解密算法/工作模式/填充方式
    private static String algorithmProvider = "AES/CBC/PKCS5Padding";
  
  public static String encrypt(String src, String uniqueKey) {
      
    }
  
    /**
     * 使用 AES/CBC/PKCS5Padding 对 Base64 编码的密文进行解密
     *
     * @param base64CipherText 前端加密后的 Base64 密文
     * @param aesKey           与前端约定的 32 字节(256 位)Key
     * @param aesIv            与前端约定的 16 字节 (128 位) IV
     * @return 解密后的明文字符串
     */
    public static String decryptAES(String base64CipherText, String aesKey, String aesIv) {
        try {
            // 1. 将 Base64 密文解码成字节数组
            byte[] cipherBytes = Base64.getDecoder().decode(base64CipherText);

            // 2. 准备 Key 和 IV
            byte[] keyBytes = aesKey.getBytes(StandardCharsets.UTF_8);
            byte[] ivBytes = aesIv.getBytes(StandardCharsets.UTF_8);
            SecretKeySpec keySpec = new SecretKeySpec(keyBytes, algorithm);
            IvParameterSpec ivSpec = new IvParameterSpec(ivBytes);

            // 3. 初始化 Cipher
            Cipher cipher = Cipher.getInstance(algorithmProvider);
            cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec);

            // 4. 执行解密
            byte[] plainBytes = cipher.doFinal(cipherBytes);

            // 5. 转为字符串并返回
            return new String(plainBytes, StandardCharsets.UTF_8);
        } catch (Exception e) {
            e.printStackTrace();
            return null; // 解密失败返回 null,可根据实际情况抛出异常
        }
    }
}

⚠️ 注意:Java 默认使用 PKCS5Padding,而 CryptoJS 使用的是 PKCS7Padding。二者在实现上是兼容的,所以无需额外配置即可互通。

4.2 3DES加解密

  在现代的互联网时代,数据安全性备受关注。为了保护敏感数据的机密性,对称加密算法是一种常用的方法。3DES(Triple DES)一种常用的对称加密算法,是 DES 加密算法的一种增强版本,通过对数据进行三次DES加密来提高安全性。TripleDES 算法的全称是“三重数据加密标准”,它使用固定长度的密钥对数据进行加密和解密,密钥长度为192位。TripleDES 算法具有以下特点:

特点 简要说明
安全性较高 TripleDES 算法使用三个不同的密钥进行加密和解密,密钥长度较长,安全性较高
灵活性较差 TripleDES 算法只能使用168位的密钥长度,不够灵活
计算速度较慢 TripleDES 算法的计算速度比较慢,适用于对数据进行加密和解密

3DES加密的基本用法

  在 CryptoJS 中,3DES 加密需要指定密钥和加密模式。以下是一个简单的 3DES 加密示例:

export const encrypt3DES = (originalText: string, publicKey: string, secretIv: string, mode: string) => {
  if (!originalText || !publicKey) {
    throw new Error('内容和密钥不能为空');
  }
  if (publicKey.length !== 8) {
    throw new Error('密钥必须为8个字符');
  }

  const options = {
    mode: mode === 'ECB' ? CryptoJS.mode.ECB : CryptoJS.mode.CBC,
    padding: CryptoJS.pad.Pkcs7
  }
  if (secretIv) {
    if (secretIv.length !== 8) {
      throw new Error('IV必须为8个字符')
    }
    options.iv = CryptoJS.enc.Utf8.parse(secretIv)
  }

  // 执行加密
  const encryptData = CryptoJS.TripleDES.encrypt(originalText, publicKey, options)

  // 返回 Base64 格式的密文
  return encryptData.toString()
}

3DES解密的基本用法

  3DES 解密与加密类似,只是调用的是 decrypt 方法:

export const decrypt3DES = (ciphertext: string, publicKey: string, secretIv: string, mode: string) => {
  if (!ciphertext || !publicKey) {
    throw new Error('密文和密钥不能为空')
  }
  if (publicKey.length !== 8) {
    throw new Error('密钥必须为8个字符')
  }

  const options = {
    mode: mode === 'ECB' ? CryptoJS.mode.ECB : CryptoJS.mode.CBC,
    padding: CryptoJS.pad.Pkcs7
  }

  if (secretIv) {
    if (secretIv.length !== 8) {
      throw new Error('IV必须为8个字符')
    }
    options.iv = CryptoJS.enc.Utf8.parse(secretIv)
  }

  // 执行解密
  const encryptData = CryptoJS.TripleDES.decrypt(ciphertext, publicKey, options)

  // 将解密后的数据转换为 UTF-8 字符串
  return encryptData.toString(CryptoJS.enc.Utf8)
}

加密解密工具组件

<script setup lang="ts">
import CryptoJS from 'crypto-js';
import { ref} from 'vue';
import {decrypt3DES, encrypt3DES} from "@/hooks/CryptoJSUtils.ts";

const mode=ref('ECB');
const key= ref('12345678');
const iv=ref('');
const plainText= ref('');
const encryptedText= ref('');
const decryptedText= ref('');

/**
 * 生成随机密钥
 */
const generateKey = () => {
  key.value = CryptoJS.lib.WordArray.random(8).toString();
}

/**
 * 加密
 */
const encrypt = () => {
  encryptedText.value = encrypt3DES(plainText.value, key.value, iv.value,mode.value)
}

/**
 * 解密
 */
const decrypt = () => {
  decryptedText.value = decrypt3DES(encryptedText.value, key.value, iv.value,mode.value)
}
</script>

<template>
  <div class="container mx-auto p-6 max-w-2xl">
    <h1 class="text-2xl font-bold mb-6 text-blue-600">3DES 加密解密工具</h1>

    <!-- 加密模式选择 -->
    <div class="bg-white rounded-lg shadow-md p-6 mb-6">
      <div class="mb-4">
        <label class="block text-gray-700 mb-2">加密模式</label>
        <select v-model="mode" class="w-full p-2 border rounded">
          <option value="ECB">ECB模式</option>
          <option value="CBC">CBC模式</option>
        </select>
      </div>

      <!-- 密钥生成 -->
      <div class="mb-4">
        <label class="block text-gray-700 mb-2">密钥 (8字节)</label>
        <div class="flex">
          <input v-model="key" type="text" class="flex-1 p-2 border rounded-l" placeholder="输入8位密钥">
          <button @click="generateKey" class="bg-blue-500 text-white px-4 rounded-r hover:bg-blue-600">
            <i class="fas fa-sync-alt"></i> 生成
          </button>
        </div>
      </div>

      <!-- CBC模式IV -->
      <div v-if="mode === 'CBC'" class="mb-4">
        <label class="block text-gray-700 mb-2">IV偏移量 (8字节)</label>
        <input v-model="iv" type="text" class="w-full p-2 border rounded" placeholder="输入8位IV">
      </div>

      <!-- 文本输入 -->
      <div class="mb-4">
        <label class="block text-gray-700 mb-2">原始文本</label>
        <textarea v-model="plainText" rows="3" class="w-full p-2 border rounded" placeholder="输入要加密的内容"></textarea>
      </div>

      <!-- 操作按钮 -->
      <div class="flex space-x-3 mb-6">
        <button @click="encrypt" class="flex-1 bg-green-500 text-white py-2 rounded hover:bg-green-600">
          <i class="fas fa-lock"></i> 加密
        </button>
        <button @click="decrypt" class="flex-1 bg-purple-500 text-white py-2 rounded hover:bg-purple-600">
          <i class="fas fa-unlock"></i> 解密
        </button>
      </div>

      <!-- 结果展示 -->
      <div class="mb-4">
        <label class="block text-gray-700 mb-2">加密结果</label>
        <textarea v-model="encryptedText" rows="3" readonly class="w-full p-2 border rounded bg-gray-50"></textarea>
      </div>

      <div class="mb-4">
        <label class="block text-gray-700 mb-2">解密结果</label>
        <textarea v-model="decryptedText" rows="3" readonly class="w-full p-2 border rounded bg-gray-50"></textarea>
      </div>
    </div>
  </div>
</template>

<style>
@import url('https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css');
@import url('https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css');

body {
  background-color: #f7fafc;
}
</style>

小结

  在 3DES 加密中,加密模式和填充方式的选择会影响加密结果的安全性。常见的加密模式有ECB、CBC等,填充方式有Pkcs7、ZeroPadding等。在实际应用中,应根据具体需求选择合适的加密模式和填充方式。虽然 3DES 比 DES 更安全,但在现代加密标准中,3DES已经逐渐被AES等更安全的算法取代。如果对安全性要求较高,建议使用AES等更现代的加密算法。|

五、HMAC 数据验证

5.1 HMAC加密

  对称加密只能保证机密性,即对手无法从密文恢复明文,但并不能保证数据在传输过程中未被篡改。为此,可在密文外层再加一层签名(HMAC)。HMAC 是一种基于密钥的消息认证码算法,用于验证消息在传输过程中是否被篡改,以及确认消息来源的真实性,通常用于消息身份验证、API 签名等。HMAC 算法具有以下特点:

特点 简要说明
安全性高 HMAC 算法使用密钥对原始数据进行加密,可以有效防止数据被篡改
灵活性强 HMAC 算法可以使用多种哈希函数,如SHA256、SHA512等
计算速度快 HMAC 算法的计算速度比较快,适用于对大量数据进行认证计算

  以下是 CryptoJS 实现 HMAC 算法的示例代码:

const plaintitle = 'hello world'
const key = CryptoJS.enc.Utf8.parse('1234567890123456')
const hmac = CryptoJS.HmacSHA256(plaintitle , key).toString()

5.2 PBKDF2加密

  PBKDF2 是一种常用的密码加密算法,用于将用户密码转换成一个固定长度的密钥。PBKDF2 算法的全称是“基于密码的密钥派生函数”,它通过在用户密码上附加一个随机盐值,然后对附加了盐值的密码进行多次哈希计算,最后将计算结果作为密钥。PBKDF2 算法具有以下特点:

特点 简要说明
安全性高 PBKDF2 算法使用随机盐值和多次哈希计算,可以有效防止密码被破解
灵活性强 PBKDF2 算法可以使用多种哈希函数,如SHA256、SHA512等
计算速度慢 PBKDF2 算法的计算速度比较慢,适用于对密码进行加密计算

  PBKDF2 基于伪随机函数(PRF)构建,通过对密码和盐值进行多次迭代计算来增强密钥的安全性。其数学表达式可以表示为:

PBKDF2(password: WordArray | string, salt: WordArray | string, cfg?: KDFOption)
配置项 简要说明
password 用户输入的密码
salt 随机盐值,确保即使是相同密码产生不同的派生密钥,有效防止预计算攻击
cfg
keySize 密钥大小(以字为单位)
hasher 使用的哈希算法
iterations 迭代次数
// 使用默认配置(SHA256, 250000次迭代)
var derivedKey = CryptoJS.PBKDF2("password", "somesalt");
 
// 自定义配置
var customKey = CryptoJS.PBKDF2("password", "somesalt", {
    keySize: 256/32,       // 256位密钥
    iterations: 1000000,   // 100万次迭代
    hasher: CryptoJS.algo.SHA512  // 使用SHA512
});

六、总结

  CryptoJS 解决的是在不受控的客户端环境中实施可控安全策略的矛盾命题,它无法替代 HTTPS 传输层加密,也不能弥补弱密钥管理、明文密钥硬编码、缺乏服务端二次校验等架构缺陷,其真正价值在于赋能开发者在“最小信任模型”下构建纵深防御。因此,通过合理地引入和使用 crypto-js,我们可以有效地保护前端数据的安全性,为用户提供更加安全、可靠的服务。同时,对crypto-js进行封装可以让我们更好地组织和管理代码,提高开发效率和代码质量。

一文搞懂 Vite 处理CommonJS包、按需编译逻辑及 Rollup 插件兼容规则

2026年5月23日 18:26

💡 引言

在如今的前端面试和日常开发中,Vite 的名字可以说是如雷贯耳。大家都知道它“快”,知道它“基于原生 ESM”。

但是,如果面试官再往深处追问: “既然基于原生 ESM,那面对 npm 生态里堆积如山的 CommonJS 历史老包,Vite 是如何让浏览器不报错的?”、“它宣称的按需编译,到底在什么时候触发,编译了什么?”、“Vite 的插件为什么能无缝复用 Rollup 的生态?”

如果你对这些底层的组合拳还感到模糊,没关系。本文将用最纯粹、最直观的语言,为你彻底拆解这三大核心机制的幕后真相。

一、 破局历史包袱:Vite 如何处理 CommonJS 包?

核心结论: Vite 处理 CommonJS 包的核心逻辑是:开发环境通过 esbuild 预构建将 CommonJS 转化为 ESM;而生产环境则是通过 Rollup 插件将 CommonJS 转化为 ESM。

1. 开发环境:esbuild 依赖预构建

在本地开发阶段,Vite 遇到 CommonJS 包时的整体处理流程如下:

  • 依赖扫描:Vite 启动时会先扫描项目中的依赖,找出 CommonJS 相关的第三方包(例如:包的 package.jsonmain / exports / module 字段指向的文件是 CJS 格式,或者代码中含有裸导入)。
  • 格式重构:Vite 使用 esbuild 对其进行预构建,转换成 ESM 规范输出。在转换过程中,esbuild 会深度分析 require() 调用和 module.exports 对象,将其精准映射成 ESM 的 importexport 语法。
  • 写盘缓存:最终把转换后的产物输出成 node_modules/.vite/deps 下的缓存文件,供浏览器直接加载。

2. 生产环境:Rollup 插件化处理

生产环境 Vite 采用 Rollup 进行整体打包,此时处理 CJS 包的逻辑转移到了插件层,主要通过 @rollup/plugin-commonjs 插件来实现:

  • 全量扫描:Rollup 扫描项目中所有的依赖,精准识别未被预构建或代码中潜藏的 CJS 模块。
  • 静态转换:该插件将 CJS 的独有语法(如 module.exportsexportsrequire静态转换为标准 ESM 语法
  • 极致剪枝:转换完成后,进一步结合 Rollup 强大的 Tree-Shaking(树摇) 机制剔除无用代码,最终完美打包到生产产物中。

二、 极致性能的秘密:Vite 如何实现按需编译?

概念: Vite 按需编译的核心是基于浏览器原生的 ESMWebSocket 文件监听实现的。Vite 在开发阶段不会全量编译打包任何业务代码,只有当文件被修改或者首次请求时,才会触发编译,且编译粒度极致精准到单文件

[浏览器解析 import] ──> [发起标准 HTTP 请求] ──> [Vite 服务器拦截] ──> [实时单文件编译] ──> [注入内存缓存并返回]

1. “按需”的核心触发条件

浏览器原生 ESM 会自动解析代码中的 import 语句。每遇到一个未加载的模块,浏览器就会向 Vite 开发服务器发起一个新的 HTTP 请求。

  • 这意味着,只有在当前页面真正运行、并执行到该导入语句时,请求才会发出。
  • 内存缓存:编译后的文件会直接缓存到内存(Memory)而非磁盘中。后续如果发起相同的请求,Vite 将直接返回内存缓存,完美避免了重复编译。

2. 哪些文件会触发编译?

文件的编译主要可以划分为以下两大触发场景:

触发场景 涉及的核心文件类型
首次请求触发 入口相关文件(如 index.htmlmain.js/main.ts)、业务代码文件.vue.tsx.jsx)、以及样式文件.css.less.scss)等。只要被页面 import,即刻触发。
文件修改触发 当本地文件发生改动时,Vite 监听并进行单文件重新编译,通过 WebSocket 精准推送更新。

三、 繁荣生态的基石:Vite 的插件体系规范

Vite 的插件体系并没有完全另起炉灶,而是选择直接继承了 Rollup 插件规范,并在此基础上扩展了一些 Vite 独有的专属钩子。

1. 完美继承:Rollup 核心结构与钩子

Vite 直接集成了 Rollup 插件的核心结构和生命周期,这使得开发者编写 Vite 插件的上手成本极低:

  • 插件结构:Rollup 插件是一个返回对象的普通函数,Vite 插件完全沿用该标准结构。

  • 核心构建钩子(Build Hooks)

    • resolveId:拦截并解析模块路径。
    • load:负责加载模块的具体内容。
    • transform:对模块代码进行核心转换(如将特定语法转为 JS)。
  • 核心输出钩子(Output Generation Hooks)

    • generateBundle:在生成产物、打包结束前修改产物内容。
    • writeBundle:在产物成功写入磁盘后进行后续处理。

💡 如何注册? 无论是 Vite 专属插件还是 Rollup 兼容插件,直接在 vite.config.jsplugins 数组中进行注册即可。

2. 特色增强:Vite 扩展的独有钩子

为了适应本地高效开发以及特有的开发服务器环境,Vite 额外扩展了以下专属生命周期钩子:

  • config:允许在插件内部修改或合并 Vite 的最终配置。
  • configureServer:用于配置开发服务器(如添加自定义中间件、拦截特定的路由请求)。
  • handleHotUpdate:专门用于自定义 HMR(热更新)的拦截与处理。

3. 与 Rollup 插件的兼容性如何?

  • 高兼容度:由于 Vite 内部的 build(生产环境打包)阶段仍完全使用 Rollup,大部分只使用 Rollup 核心构建钩子的插件,可以直接在 Vite 中无缝使用
  • 需额外处理的情况:如果某些特定的 Rollup 插件深度依赖了 Rollup 独有的早期生命周期钩子(例如 moduleParsed 模块解析完成钩子),由于 Vite 开发环境按需编译、不会全量解析的特性,这种插件在 Vite 中就需要进行额外的适配和处理。

📌 总结

Vite 的精妙之处在于既尊重历史,又拥抱未来。它通过双引擎(esbuild + Rollup)天衣无缝地抹平了 CommonJS 的历史鸿沟,借浏览器之手实现了真正的按需编译,同时近乎完美地继承了 Rollup 的庞大生态。理解了这三套组合拳,你在面对任何构建优化问题时都能游刃有余。

写了个y-mxgraph:给 draw.io 接上了 Yjs,顺便解决了部署在 iframe 里的一堆问题

作者 Edwardwu
2026年5月23日 18:17

一句话:Yjs binding for draw.io

它把 draw.io 的 XML 文件格式映射到 Yjs 的 shared types,实现双向同步:

  • 源码地址:github.com/mizuka-wu/y-mxgraph
  • 在线 Demo 预览:mizuka-wu.github.io/y-mxgraph/demo/
import * as Y from 'yjs';
import { Binding } from 'y-mxgraph';

const doc = new Y.Doc();
const provider = new WebrtcProvider('my-room', doc);

App.main((app) => {
  const binding = new Binding(app.currentFile, { doc });
});

几行代码,你的 draw.io 编辑器就变成了多人协作白板。其他人的光标会实时显示,编辑内容秒级同步,undo/redo 跨客户端工作。


为什么要做 iframe-bridge

上面的代码是「单页模式」——draw.io 直接跑在当前页面里。但很多场景下这行不通:

1. draw.io 的全局污染

draw.io 的脚本加载后会在 window 上挂载大量全局变量(mxBasePathEditormxConstants 等)。如果你的主应用也是一个复杂的前端项目(比如 React + 各种第三方库),这些全局变量可能会和 draw.io 冲突。更糟糕的是,draw.io 的 CSS 也是全局的,样式覆盖问题很难根治。

2. 多实例需求

你可能需要在一个页面里并排放两个画板(比如对比两个版本的架构图)。没有 iframe,两个 draw.io 实例会共享同一个 window,状态互相干扰。

3. 安全边界

在 SaaS 产品中,你不希望用户的 JavaScript 能直接访问 draw.io 的内部 API。iframe 提供了天然的执行环境隔离。

所以问题变成了:iframe 里的 draw.io 怎么和 iframe 外的 Yjs 同步?


iframe-bridge 的设计

y-mxgraph 提供了一个 iframe-bridge 子包,把 Yjs 同步的逻辑封装成两个角色:

  • Server(parent page):持有真实的 Y.Doc、Awareness 和网络 Provider(y-webrtc / y-websocket)
  • Provider(iframe child):维护一份本地 Y.Doc,通过 postMessage 和 Server 交换 update
// Parent page
import { createIframeBridgeServer } from 'y-mxgraph/iframe-bridge/server';

const bridge = createIframeBridgeServer(iframe, doc, awareness);

// Iframe child
import { createIframeBridgeProvider } from 'y-mxgraph/iframe-bridge/provider';

const bridge = createIframeBridgeProvider(doc);
const binding = new Binding(file, { doc, awareness: bridge.awareness });

这段看起来简单的代码,背后处理了几个不简单的脏活:

Awareness clientID 映射:iframe 和 parent 各自有独立的 Y.Doc,clientID 不同。直接透传 awareness update 会导致 iframe 里的协作光标显示异常(颜色/用户名错乱)。bridge 在底层做了二进制级别的 clientID remap,把 parent 的 clientID 映射到 iframe 本地。

undo/redo 跨 iframe:draw.io 自带的 undoManager 只在单个实例内工作。bridge 通过 takeoverUndoManager 把它代理到 parent 的 Y.UndoManager,这样 A 在 iframe-1 里 undo,iframe-2 里也会同步回退。

基线过滤:iframe 启动时从 parent 接收的初始状态不能进 undo 栈,否则用户按 Ctrl+Z 会把文档撤销成空文档。bridge 用 BASELINE_ORIGIN 标记区分初始同步和真实编辑。

连接重试:iframe 加载通常比 parent 快,provider 启动后如果 server 还没创建好,会自动重试 init 直到连上。


除了 iframe-bridge,y-mxgraph 还做了什么

  • 多页 diagram 同步:draw.io 支持多页文件(一个 .drawio 里多个 diagram),y-mxgraph 用 Y.Map + Y.Array 完整映射了这种结构
  • 初始内容策略:replace / merge-remote / merge-client,应对多人同时打开空文档的场景
  • 绕过 draw.io 的 save 对话框:用 file.ui.setFileData() 而不是 file.setData(),避免 Yjs 已经处理持久化的情况下还弹出「Save diagrams to:」
  • TypeScript 全链路:从 binding 到 transform 到 bridge,全部是类型安全的

快速体验

git clone https://github.com/mizuka-wu/y-mxgraph.git
cd y-mxgraph
pnpm install
pnpm --filter y-mxgraph build

# 单页模式
pnpm --filter @y-mxgraph/demo dev

# iframe 模式(打开 http://localhost:5173/iframe.html)

开两个浏览器窗口,进同一个 room,实时协作效果立即可见。


适合谁用

  • 已经在用 draw.io 的团队,想加实时协作但不想换工具
  • 做低代码/流程编排平台的开发者,需要内嵌可协作的图形编辑器
  • 需要在 iframe 里安全 embed draw.io 的 SaaS 产品

y-mxgraph 不是又一个白板工具,它是「让 draw.io 长出协作能力」的 adapter。如果你的技术栈里已经有 draw.io 和 Yjs,这个库能让它们连在一起。


标签

Yjs draw.io 实时协作 iframe 开源 前端工具库

Vite 开发预构建机制详解,搞懂 esbuild 与 Rollup 分工差异

2026年5月23日 17:43

💡 引言

在传统构建工具(如 Webpack)统治的时代,项目一旦变大,本地开发启动和热更新往往需要数秒甚至更久。Vite 凭借“天生不用打包”的原生 ESM(ES Modules)机制横空出世,带来了毫秒级的极致体验。

然而,很多同学在刚接触 Vite 时都会有一个疑问:既然 Vite 宣称是不打包的构建工具,为什么在开发阶段启动时,终端里总会雷打不动地出现一行 Pre-bundling dependencies(依赖预构建)?

事实上,面对庞大复杂的第三方生态(node_modules),Vite 宁可破坏“不打包”的纯洁性,也要引入 esbuild 进行一次特殊的预加工。这既是 Vite 的妥协,也是它极其精妙的工程化智慧。本文将带你一层层剥开预构建的底层外衣,看透它的运行机制与破局之策。

一、 什么是依赖预构建?

概念: 依赖预构建是指 Vite 在开发阶段启动时,对第三方 node_modules 包进行一次“预加工”的过程。也就是利用基于 Go 语言编写的 esbuild 将第三方依赖文件转化为符合浏览器规范的 ESM 格式并进行打包合并。

核心作用:它解决了什么问题?

  1. 格式统一(兼容 CommonJS / UMD) :浏览器原生的 ESM 无法识别 CommonJS 或 UMD 格式的包。像 React 等依然采用传统格式的古董包,如果直接扔给浏览器会直接报错。预构建能将它们统一转化为标准 ESM 格式,解决了非 ESM 包无法被浏览器识别的问题。
  2. 减少 HTTP 请求数(提升页面加载速度) :部分 ESM 包(如 lodash-es)内部极其零碎,包含几百个小文件。如果直接在浏览器中加载,会触发几百个并发 HTTP 请求,直接卡死浏览器。预构建将这些相关依赖文件合并成一个或几个特定的 ESM 文件,突破了浏览器的网络并发限制,大幅提升了开发环境的页面加载速度。

二、 依赖预构建的四大核心流程

Vite 的依赖预构建是一个严密的流水线,整体流程分为四步:

[服务器启动] ──> 1. 缓存判断 (有效则直读)
             (缓存失效) │
               2. 依赖扫描 (esbuild 虚假构建)
                       │
               3. 依赖打包 (CJS转ESM/模块合并)
                       │
               4. 信息写盘 (_metadata.json)

1. 缓存判断(Cache Validation)

在服务器启动的一瞬间,Vite 首先会检查之前的预构建成果是否依然有效,以避免重复操作:

  • Vite 会查看 node_modules/.vite 文件夹是否存在,以及其中的 _metadata.json 元数据文件。
  • Vite 会根据 package.json 的 dependencies 依赖、包管理器的锁定文件(如 package-lock.json)以及 vite.config.ts 中的相关配置,计算出一个 Hash 值
  • 如果 Hash 值未变,Vite 直接跳过后续步骤,从本地磁盘读取缓存,实现秒级启动。

2. 依赖扫描(Dependency Scanning)

如果缓存失效或不存在,Vite 必须找出代码中到底用到了哪些第三方包,确定出一份精确的依赖清单(例如:vue, axios, lodash-es):

  • Vite 会从 index.html 入口开始,递归扫描所有的源代码(JS/TS/Vue/JSX)。
  • 扫描过程中,Vite 使用 esbuild 作为扫描器,根据代码中的 import 语句以及包含在 optimizeDeps.include 中的项,快速进行一次“虚假构建”。这次构建不生成最终代码,只为了捞出所有裸导入(Bare Imports)的依赖名称。

3. 依赖打包(Dependency Bundling)

这是预编译中最核心的一步。Vite 会调用 esbuild 对依赖清单进行格式转换和模块合并:

  • 极致速度:得益于 Go 语言编写的 esbuild,这一步比传统的 Webpack 快 10 到 100 倍
  • 格式转换:将 CommonJS 或 UMD 格式的包(如 React)转换为标准 ESM 格式,使浏览器能直接识别。
  • 模块合并:将 lodash-es 这种内部有几百个小文件的包,打包成一个或几个特定的 ESM 文件,突破浏览器并发请求限制。

4. 构建信息写入磁盘(Writing Metadata)

最后一步是将打包后的依赖存入 node_modules/.vite/deps 目录下,方便下次对比和加载,并更新元数据:

  • 元数据文件:更新 _metadata.json 文件。这个文件记录了文件的 Hash 值、源代码中的原始导入路径、以及预构建后的文件路径的映射关系
  • 路径重写:当服务器运行时,Vite 会拦截浏览器的请求,根据这份元数据将路径重写为指向 .vite/deps 缓存文件的路径。

💡 主动触发与强制刷新

  • 自动触发:开发中如果修改了 package.json 的依赖或 optimizeDeps 配置,Vite 会自动触发重新预构建。
  • 手动强制触发:如果需要手动强制重新预构建,可以删除 node_modules/.vite 目录,或执行 vite optimize 命令(也可以在启动时执行 vite --force)。

三、 深度拷问:为什么开发用 esbuild,生产用 Rollup?

这是大厂前端面试高频出现的经典架构题。Vite 采用双引擎架构(esbuild + Rollup),背后有着深层的工程化考量。

1. Vite 开发环境选择 esbuild 的原因

Vite 在开发环境选择 esbuild 主要是看中了其绝对的速度优势和开发效率

  • 编译速度极快:esbuild 是使用 Go 语言开发的,直接编译为机器码,执行效率比 JS 编写的构建工具快很多,通常快 10-100 倍。
  • 完美支持 ESM 转换:esbuild 可将 CommonJS、UMD 直接转化为 ESM,适合浏览器按需加载。而 Rollup 对 CommonJS 还需要使用插件 @rollup/plugin-commonjs,配置比较复杂,还会进一步降低速度。
  • 架构轻量化:esbuild 在这里仅关注依赖的转换,而不像 Rollup 需要完整的依赖构建图。
  • 极致的文件快译与低延迟 HMR:Vite 在开发环境基于原生 ES Modules 进行模块热更新(只更新被修改模块及其依赖,更新延迟低)。在这个过程中,esbuild 仅负责单个文件的快速转译(如 TS 转 JS),不涉及复杂的打包和全量依赖图重构。而相比之下,Rollup 需基于完整依赖图重新打包,热更新耗时久,并不适合高频更新的开发场景。

2. Vite 生产环境使用 Rollup 打包原因

既然 esbuild 这么快,为什么 Vite 生产环境依然选择 Rollup 打包?主要原因是生产环境构建需要更多的优化和兼容性支持。Rollup 在长期的工程化沉淀中拥有明显的应用优势:

  • 更强大的产物优化:Rollup 在生产环境下的 Tree-shaking(树摇) 、代码分割(Code Splitting)、代码合并的能力是远优于 esbuild 的,能生成体积较小的精简 bundle。
  • 灵活的拆包策略:Rollup 支持 manualChunks 和动态导入的优化策略,可极其灵活地拆分大型应用,减少首屏加载时间。
  • 庞大完善的插件生态:Rollup 的插件生态非常丰富,可以支持多种类型资源的处理(JS、CSS、图片、SVG、字体资源打包、打包产物压缩等),而 esbuild 的插件生态和定制化能力目前还较为年轻。
  • 稳健的浏览器兼容性:Rollup 对浏览器兼容性较好,能配合构建链轻松生成兼容旧版浏览器的代码,确保线上产物的绝对稳定性。

📌 总结

Vite 的双引擎架构是前端工程化的一种精妙平衡:在开发阶段,它追求极致的速度,因此用 esbuild 大刀阔斧地做快速转换;在生产环境,它追求最终产物的体积与兼容性,因此用 Rollup 慢工出细活。 这种“两头通吃”的策略,才成就了如今 Vite 无法撼动的统治地位。

树上挂苹果还是挂玻璃球?Three.js 程序化果实的完整实现指南

2026年5月23日 16:06

树上挂苹果还是挂玻璃球?Three.js 程序化果实的完整实现指南

基于 EZ-Tree 扩展:从扫描贴图苹果到物理玻璃球,一套 InstancedMesh 架构搞定两种风格。

项目源码github.com/qdcxj/ez-tr…


前言:为什么这件事比「加个 Sphere」难得多?

我在一个 Three.js 程序化树 项目里做挂果功能,一开始想法很简单:

分支上 new SphereGeometry,贴一张苹果图,完事。

结果踩了三个大坑:

  1. 苹果贴图不是普通 UV 图,而是扫描仪输出的 Atlas 图集——两个圆盘(果柄端 + 花萼端)+ 一条果柄,背景全黑。
  2. 直接贴到球体上,苹果变成「红黑斑马纹」,UV 越界采到了黑色背景。
  3. 后来加了 玻璃球,发现不能和苹果各写一套逻辑——否则 UI、分支采样、实例化渲染全得复制一遍。

最终方案是:一套挂果管线 + 两种材质策略,UI 里一键切换 Apple / GlassBall

本文把完整过程拆开讲,你可以直接抄到自己的 Three.js 项目里。


最终效果一览

preview-apple.png

preview-glass-ball.png

左:Apple 扫描 Atlas 贴图苹果 · 右:GlassBall 程序化物理玻璃球

类型 几何体 材质 贴图 果柄
Apple SphereGeometry + 自定义 UV MeshPhongMaterial 扫描 Atlas
GlassBall SphereGeometry 默认 UV MeshPhysicalMaterial 无(纯程序化)

两种类型共用:分支采样、实例数据、InstancedMesh 渲染、UI 参数面板


整体架构

flowchart TB
    subgraph UI["UI 面板 (ui.js)"]
        Type["Type: apple | glassBall"]
        Common["Count / Size / BranchLevel ..."]
        AppleOnly["Shininess / CapScale"]
        GlassOnly["Transmission / IOR / Thickness"]
    end

    subgraph Options["配置 (options.js)"]
        Fruits["tree.options.fruits"]
    end

    subgraph Tree["树生成 (tree.js)"]
        Gen["generateFruits()"]
        Inst["fruits.instances[]"]
        Mesh["createFruitsGeometry()"]
    end

    subgraph Material["材质 (fruitMaterial.js)"]
        AppleUV["applyAppleSphereUVs()"]
        AppleMat["createAppleBodyMaterial()"]
        GlassMat["createGlassBallMaterial()"]
    end

    subgraph Tex["纹理 (textures.js)"]
        Load["ensureFruitMaps() — 仅 apple"]
    end

    Type --> Fruits
    Common --> Fruits
    Fruits --> Gen --> Inst --> Mesh
    Load --> Fruits
    Mesh -->|apple| AppleUV --> AppleMat
    Mesh -->|glassBall| GlassMat

设计原则:

  • 生成逻辑统一:不管苹果还是玻璃球,都在同一层分支上、用同一套随机采样。
  • 渲染逻辑分叉:只在 createFruitsGeometry() 里根据 type 选材质和是否写 UV。
  • 纹理按需加载:玻璃球不请求任何贴图,避免无意义的 10MB 下载。

第一步:定义配置项

options.js 里扩展 fruits 字段,把「类型无关」和「类型相关」参数放在一起:

this.fruits = {
  enabled: true,
  type: 'apple',          // 'apple' | 'glassBall'

  // 共用:位置与大小
  branchLevel: 3,
  count: 8,
  start: 0.3,
  size: 0.6,
  sizeVariance: 0.2,
  tint: 0xffffff,
  segments: 10,

  // Apple 专用
  shininess: 40,
  capScale: 1.0,

  // GlassBall 专用 — MeshPhysicalMaterial
  transmission: 1.0,
  roughness: 0.05,
  ior: 1.5,
  thickness: 0.35,
  clearcoat: 1.0,
  clearcoatRoughness: 0.03,
};

💡 掘金干货:把两种类型的参数放在同一个 options 对象里,UI 切换时不需要 merge 两套配置,预设 JSON 也能直接序列化。


第二步:在分支上「长」出果实

2.1 挂果时机

tree.js 的分支递归里,当 branch.level === fruits.branchLevel 时调用 generateFruits()。通常和叶子同一层(细枝),视觉上最自然。

2.2 采样算法(和叶子同源)

generateFruits() 复用了叶片的 分层随机采样

generateFruits(sections) {
  const count = this.options.fruits.count;
  const startMin = this.options.fruits.start;
  const heightStep = (1.0 - startMin) / count;
  const angleSlots = this.shuffledIndices(count);

  for (let i = 0; i < count; i++) {
    // 1. 沿分支长度插值得到 origin
    // 2. 用四元数 slerp 得到朝向
    // 3. 径向角 + 抖动,让果实围成一圈而不是排成线
    this.generateFruit(fruitOrigin, fruitOrientation, sectionA.radius);
  }
}

2.3 单个实例数据

每个果实只存三样东西,后面 InstancedMesh 直接用:

this.fruits.instances.push({
  position,   // 从分支点向下悬垂
  rotation,   // 三轴随机,避免「同一个朝向的假球」
  scale: size // 带 sizeVariance 的半径
});

悬垂距离:branchRadius + size * 1.8,让果实「挂在枝上」而不是穿进树干。


第三步:InstancedMesh 批量渲染

100 棵树 × 每树 8 个果实,如果用独立 Mesh 性能会崩。InstancedMesh 是标准答案:

createFruitsGeometry() {
  const geometry = new THREE.SphereGeometry(1, segments, segments);
  const mesh = new THREE.InstancedMesh(geometry, material, instances.length);

  mesh.frustumCulled = false;  // ⚠️ 重要,见下文踩坑
  mesh.renderOrder = isGlassBall ? 3 : 2;

  instances.forEach((instance, index) => {
    dummy.position.copy(instance.position);
    dummy.rotation.copy(instance.rotation);
    dummy.scale.setScalar(instance.scale);
    dummy.updateMatrix();
    mesh.setMatrixAt(index, dummy.matrix);
  });

  mesh.instanceMatrix.needsUpdate = true;
}

踩坑:InstancedMesh 被视锥剔除了

症状:控制台无报错,但 果实完全不显示

原因:实例分散在树的不同位置,几何体本身的 boundingSphere 在原点,相机视锥认为「不在视野内」。

解决:

mesh.frustumCulled = false;
// 或者用所有实例的 AABB 手动设置 boundingSphere

第四步:Apple —— 扫描 Atlas 贴图苹果

4.1 认识贴图布局

苹果用的是 photogrammetry 扫描色贴图 FruitAppleRedWhole001_COL_VAR1_HIRES.jpg

┌─────────────────────────────────┐
│  ● topCap(果柄端)              │
│         ▬ stem(果柄条)         │
│                    ● bottomCap  │
│                      (花萼端)  │
└─────────────────────────────────┘
         背景 = 纯黑

这不是 SphereGeometry 能直接用的等距圆柱 UV,必须手动写 UV。

4.2 标定 Atlas 圆心(别靠肉眼猜)

用脚本对贴图缩略图做像素聚类,得到归一化坐标:

export const AppleAtlas = {
  topCap:    { center: [0.282, 0.720], radius: 0.178 },
  bottomCap: { center: [0.719, 0.277], radius: 0.178 },
  stem:      { center: [0.335, 0.455], size: [0.10, 0.065] },
};

实测比「大概 0.25 / 0.75」精确得多,差 0.03 就会采到黑边。

4.3 核心:半球极投影(Stereographic Projection)

对每个球面顶点,按半球分别投影到对应圆盘:

// 北半球 → topCap(+Y 为果柄端)
if (y >= 0) {
  const sx = x / (1 + y);
  const sz = -z / (1 + y);
  mapped = mapCapUV(sx, sz, atlas.topCap, scale);
}
// 南半球 → bottomCap(-Y 为花萼端)
else {
  const sx = -x / (1 - y);
  const sz = z / (1 - y);
  mapped = mapCapUV(sx, sz, atlas.bottomCap, scale);
}

mapCapUV 里做两件事:

  1. Clamp 到单位圆盘maxRadius = 0.95),防止赤道附近 UV 飞出圆盘。
  2. 映射到 Atlas:u = cx + sx * radius * capScale

4.4 血泪踩坑:红黑斑马纹是怎么来的?

错误写法 后果
radius * scale * 2 赤道 UV 偏移翻倍,飞出圆盘,采到黑色背景
不 Clamp 圆盘 极投影在赤道趋向无穷,必然越界
RepeatWrapping 边缘 UV 重复采样,出现鬼影
在球顶采样 stem 区域 和果柄圆柱冲突,UV 跳变

正确组合:

// 1. 去掉 * 2
u = clamped.sx * cap.radius * scale + cap.center.x;

// 2. 纹理 Clamp
texture.wrapS = THREE.ClampToEdgeWrapping;
texture.wrapT = THREE.ClampToEdgeWrapping;

// 3. 果柄单独用 CylinderGeometry + applyStemUVs()

验证脚本结果:4225 个顶点,0 个落在圆盘外

4.5 果柄:第二个 InstancedMesh

const stemGeometry = new THREE.CylinderGeometry(0.35, 0.5, 1, 6);
applyStemUVs(stemGeometry);  // 映射到 Atlas 的 stem 岛

// 放在球顶 +Y 方向,略微露出表面
stemDummy.position.add(
  up.clone().multiplyScalar(instance.scale * 1.02).applyEuler(instance.rotation)
);

苹果 = 球体 InstancedMesh + 果柄 InstancedMesh,共用一个 colorMap。


第五步:GlassBall —— 纯 Three.js 玻璃球

玻璃球走完全不同的路:不写 UV、不加载贴图、不加果柄

5.1 材质:MeshPhysicalMaterial

export function createGlassBallMaterial({
  tint, transmission, roughness, ior, thickness, clearcoat, clearcoatRoughness,
}) {
  const color = new THREE.Color(tint);

  return new THREE.MeshPhysicalMaterial({
    color,
    metalness: 0,
    roughness,
    transmission,       // 透射,1 = 全透明玻璃
    thickness,          // 玻璃厚度,影响折射深度
    ior,                // 折射率,玻璃约 1.5
    transparent: true,
    clearcoat,          // 清漆高光层
    clearcoatRoughness,
    attenuationColor: color,   // 玻璃染色
    attenuationDistance: 0.6,
    depthWrite: false,         // 透明物体标准写法
  });
}

5.2 和 Apple 的分叉点

const isGlassBall = fruits.type === 'glassBall';

if (isGlassBall) {
  material = createGlassBallMaterial(materialOpts);
} else {
  applyAppleSphereUVs(geometry, materialOpts.capScale);
  material = createAppleBodyMaterial(colorMap, materialOpts);
}

5.3 玻璃球调参指南

参数 效果 推荐范围
Transmission 透明度 0.85 ~ 1.0
Roughness 磨砂感 0 ~ 0.1
IOR 折射强度 1.45 ~ 1.52(玻璃)
Thickness 颜色饱和度 0.2 ~ 0.8
Tint 玻璃颜色 #ffffff 无色,#88ccff 淡蓝
Segments 曲面光滑度 16 ~ 32

玻璃球建议把 Segments 拉到 16+,低面数球体 + 折射会有明显折痕。


第六步:纹理加载策略

export const FruitType = {
  Apple: 'apple',
  GlassBall: 'glassBall',
};

// 只有 apple 有贴图路径
const FruitTexturePaths = {
  apple: {
    color: '/textures/guoshi/apple/FruitAppleRedWhole001_COL_VAR1_HIRES.jpg',
  },
};

// 预加载时按类型判断
if (tree.options.fruits?.type === 'apple') {
  tasks.push(ensureFruitMaps('apple'));
}

玻璃球切换时 assignFruitMaps(null),不会残留苹果贴图。


第七步:UI 面板 —— 一个 Fruits 区搞定

const fruitTypeSelect = createSelect('Type', FruitType, ...);

// Apple 专属控件
const appleFruitControls = [fruitShininessSlider.element, fruitCapScaleSlider.element];

// GlassBall 专属控件
const glassFruitControls = [
  fruitTransmissionSlider.element,
  fruitRoughnessSlider.element,
  fruitIorSlider.element,
  fruitThicknessSlider.element,
  fruitClearcoatSlider.element,
];

function updateFruitTypeControls() {
  const isGlassBall = tree.options.fruits.type === 'glassBall';
  appleFruitControls.forEach(el => el.style.display = isGlassBall ? 'none' : '');
  glassFruitControls.forEach(el => el.style.display = isGlassBall ? '' : 'none');
}

切换类型 → 更新控件可见性 → preloadTreeTextures()tree.generate() 重建。


文件清单(抄作业用)

src/lib/
├── options.js          # fruits 配置项
├── tree.js             # generateFruits / createFruitsGeometry
└── fruitMaterial.js    # UV 映射 + 两种材质工厂

src/app/
├── textures.js         # FruitType、贴图预加载
└── ui.js               # Fruits 面板

完整数据流(一图流)

用户选 Type
    ↓
options.fruits.type = 'apple' | 'glassBall'
    ↓
preloadTreeTextures()  (apple 才加载贴图)
    ↓
tree.generate()
    ↓
generateFruits() → fruits.instances[]createFruitsGeometry()
    ├── apple:    SphereUV + Phong + Stem
    └── glassBall: PhysicalMaterial, 无 Stem
    ↓
InstancedMesh × N 挂到树上

经验总结:7 条可复用的 Three.js 技巧

  1. 扫描贴图 ≠ 普通 UV,Atlas 类资源先分析布局再写映射,别直接 map = texture
  2. 极投影 + 圆盘 Clamp 是「双帽扫描贴图 → 球体」的通用解法。
  3. InstancedMesh 记得关 frustumCulled,或者手动算 boundingSphere。
  4. 透明/玻璃材质设 depthWrite: false,并适当提高 renderOrder
  5. 多种视觉风格共用一套实例管线,只在材质层分叉,代码量减半。
  6. 纹理按需加载,程序化材质不绑贴图路径,启动更快。
  7. UI 控件按类型显隐,比做两个面板维护成本低。

还可以怎么玩?

  • 给 Apple 接上 NRM / GLOSS 扫描图,升级 MeshStandardMaterial
  • 玻璃球加 envMap(HDR 环境贴图),反射更真实
  • 新增 orangepeach 类型:换 Atlas 常量 + 贴图路径即可
  • 果实随风摇摆:在 tree.update(t) 里写 instance matrix 动画

总结

问题 方案
苹果贴图红黑条纹 精确 Atlas 标定 + 极投影 + 圆盘 Clamp + ClampToEdge
100 棵树性能 InstancedMesh 批量渲染
苹果 vs 玻璃球 统一 generateFruits,createFruitsGeometry 处分叉
UI 怎么切换 FruitType 下拉 + 条件显隐控件

从「加个球」到「扫描级苹果 + 物理玻璃球」,核心不是某个神奇 Shader,而是 把生成、实例化、材质三件事解耦

完整代码见:github.com/qdcxj/ez-tr…


如果这篇文章对你有帮助,欢迎点赞收藏。 有问题可以在评论区贴你的 Atlas 布局或截图,一起讨论 UV 映射。


标签:#Three.js #WebGL #程序化生成 #3D #前端可视化 #EZ-Tree #InstancedMesh #PBR

vue自定义指令封装-是否点击当前元素以外区域

作者 恋爱脑
2026年5月23日 15:01

elementui中有对应指令封装,直接引入使用即可(下面有使用示例),如果不想依赖组件库,可以使用一下简单版本

1. 版本环境

  • vue2
  • 工程化脚手架

2. v-clickoutside 指令代码

// 点击外部的事件处理函数
function handleClickOutside(el, binding, e) {
    // 判断点击的目标元素 是否 在绑定指令的元素 内部
    if (el.contains(e.target)) return;
    // 如果指令绑定的值为 false(比如弹出元素还未加载) 直接不执行
    if (!binding.value || el.contains(e.target)) return;
    // 触发绑定的回调函数(binding.value 就是指令绑定的方法)
    if (typeof binding.value === "function") {
        binding.value(e);
    }
}

export default {
    // 指令绑定到元素时触发,只执行一次
    bind(el, binding) {
        // 把事件处理函数挂载到元素实例上 方便后续解绑
        // 避免多个指令实例共用一个函数导致冲突
        el.__vueClickOutside__ = (e) => handleClickOutside(el, binding, e);
        // 全局监听鼠标按下事件  mousedown 而非 click,响应更快无延迟 
        document.addEventListener("mousedown", el.__vueClickOutside__);
    },

    // 指令与元素解绑时触发 防止内存泄漏
    unbind(el) {
        // 移除全局事件监听
        document.removeEventListener("mousedown", el.__vueClickOutside__);
        // 删除元素上的自定义属性
        delete el.__vueClickOutside__;
    },
};

3. vu中 注册 指令

  • main.js vue项目入口文件
import Vue from 'vue'
import App from './App.vue'

import clickoutside from '@/directives/clickoutside' // 替换为你的指令js文件所在目录

Vue.directive('clickoutside', clickoutside) // 注册指令



new Vue({
  render: h => h(App),
}).$mount('#app')

4. 页面/组件中使用


<template>
  <div>
  
  <button @click="showPopup=true">打开弹出层</button>
  
  <div class="popups" v-if="showPopup" v-clickoutside="showPopup && handleClose">
     弹出层内容
  </div>
  
  </div>


</template>

<script>

export default {

    data() {
        return {
            showPopup: false,
        }
    },
    
    methods:{
        handleClose() {
            this.showPopup = false
        },
    
    
    }


}


</script>

element ui的指令使用示例

  • vue3版本换成对应的语法 即可

<template>
    <!-- 给需要监听外部点击的容器绑定指令 -->
    <div class="custom-dropdown" v-clickoutside="closeMenu">
        <el-button @click="isOpen = !isOpen">展开菜单</el-button>
        <!-- 下拉内容 -->
        <div v-show="isOpen" class="menu">
            <p>菜单选项1</p>
            <p>菜单选项2</p>
        </div>
    </div>
</template>
<script>

 // import clickoutside from "element-ui/src/utils/clickoutside"; // 默认全局已注册,可单独引入

export default {

 //   directives: {
 //       clickoutside
 //   },
 
    data() {
        return {
            isOpen: false // 控制菜单显示
        }
    },
    methods: {
        // 点击外部区域触发:关闭菜单
        closeMenu() {
            this.isOpen = false
        }

    }
}
</script>

<style scoped>
.custom-dropdown {
    position: relative;
    display: inline-block;
}

.menu {
    position: absolute;
    top: 40px;
    left: 0;
    width: 200px;
    padding: 10px;
    border: 1px solid #eee;
    background: #fff;
}
</style>

《前端三权分立:HTML、CSS、JS为什么不能“乱搞”》

作者 烬羽
2026年5月23日 14:50

0.1秒价值1000万美元:一个时钟教会我的前端性能课

为什么有些网站打开就像“丝滑咖啡”,有些像“水泥搅拌机”?答案藏在HTML、CSS、JS的“三权分立”里

185cd7436fe0d81862578179c69fe314.jpg

⏰ 一个让CEO暴怒的下午

去年在某电商公司,我亲历了一个魔幻场景:

大促前夜,CTO盯着监控大屏,脸色铁青——页面加载时间从0.8秒飙升到了1.9秒

“每慢0.1秒,转化率掉7%!你们知道这意味什么吗?”CEO在会议上拍着桌子。

我后来算了一笔账:按该平台日活5000万、客单价200元计算,0.1秒的延迟=每天损失700万元

亚马逊更早算过:每100毫秒的延迟,年损失16亿美元

这让我重新思考一个看似简单的问题:一个网页,到底应该怎么“组装”才对?

🎯 从一个时钟开始

假设我们要做一个CSS时钟,就像掘金上那些炫酷的教程:

一个圆形的表盘
三根指针(时、分、秒)
指针会真实地转动

普通开发者可能随手就写了,但专业和业余的区别,藏在代码的组织方式里

🏛️ 前端三权分立:不是政治,是性能

前端的“三权分立”不是孟德斯鸠说的,但道理一样:

权力 掌管 负责 隐喻
📄 HTML 结构 页面骨架、盒子层级 宪法(规定有什么)
🎨 CSS 样式 颜色、大小、位置、动画 法律实施细则(规定长什么样)
⚡ JS 行为 交互、数据处理、动态效果 执法机构(规定怎么动)

一个优秀的前端,会让这三者各司其职,互不越界。

❌ 反模式:混乱的“屎山”

<!-- 样式内联,CSS和HTML混在一起 -->
<div style="color: red; font-size: 20px;" onclick="alert('hi')">
  点我
</div>

✅ 正确姿势:职责分离

<!-- clock.html:只有结构 -->
<div class="clock">
  <div class="clock-face">
    <div class="hand hour-hand"></div>
    <div class="hand min-hand"></div>
    <div class="hand second-hand"></div>
  </div>
</div>
/* clock.css:只有样式 */
.clock {
  width: 300px;
  height: 300px;
  background: #f0f0f0;
  border-radius: 50%;
}

.hand {
  position: absolute;
  background: #333;
  transform-origin: 100%;
}
// clock.js:只有行为
function setDate() {
  const now = new Date();
  const seconds = now.getSeconds();
  // 更新指针角度...
}

为什么要这么麻烦?

当你一个月后回来改代码,或者团队里其他人接手时,清晰的结构意味着:

  • 找样式去CSS文件
  • 改结构去HTML
  • 修交互去JS

就像图书馆的书按编号排列,闭着眼睛都能找到。

📥 文件加载顺序:被忽视的性能刺客

浏览器解析HTML是从上到下、一行一行的。

这就引出一个关键问题:CSS和JS,应该放在哪里?

🎨 CSS:放头部,越快越好

<!DOCTYPE html>
<html>
<head>
  <!-- ✅ CSS放这里! -->
  <link rel="stylesheet" href="clock.css">
</head>
<body>
  <!-- 页面内容 -->
</body>
</html>

为什么?

浏览器读到<link>就会去下载CSS,同时继续解析后面的HTML。CSS下载完成后,会把样式应用到已经解析的元素上。

这样用户看到的是:结构出现 → 立刻穿上衣服(样式)

如果CSS放底部呢?

<!-- ❌ CSS放底部 -->
<body>
  <div class="clock">...</div>
  <link rel="stylesheet" href="clock.css">
</body>

浏览器先画出没有样式的“裸奔”HTML,然后突然“穿上衣服”——用户会看到内容闪烁、布局跳动,体验极差。

这叫 FOUC(Flash of Unstyled Content),前端界的常见病。

⚡ JS:放底部,别挡路

<body>
  <!-- 页面内容 -->
  <div class="clock">...</div>
  
  <!-- ✅ JS放body结束前 -->
  <script src="clock.js"></script>
</body>

为什么?

JS有一个“恶习”:它会阻塞HTML解析

当浏览器遇到<script>标签时,它会:

  1. 停止解析HTML
  2. 下载并执行JS
  3. 执行完毕后,继续解析HTML

如果JS放在<head>里:

<head>
  <script src="huge-library.js"></script> <!-- 这个文件要下载300ms -->
</head>

在这300ms里,用户看到的是一片空白——浏览器被JS“卡住”了,连HTML结构都没来得及画

如果JS放底部:

  • HTML和CSS先加载完毕
  • 用户看到完整的静态页面
  • JS最后加载,给页面添加交互能力

这叫“渐进增强”策略:先让用户看到东西,再让它动起来。

![对比图:JS在头部 vs JS在尾部的加载时序](上面画着时间轴,JS在头部时有一段空白期;JS在尾部时,结构和样式先出现,JS最后悄无声息地加上)

📊 性能的残酷经济学

你可能会想:“差个0.1秒,至于吗?”

让我们算一笔真实的账:

场景 延迟 转化率影响 日损失(5000万日活,客单价200元)
慢0.1秒 100ms -7% 700万人民币
慢0.3秒 300ms -15% 1500万人民币
慢1秒 1000ms -28% 2800万人民币

这不是理论数据。Google的报告显示:移动端加载时间从1秒增加到3秒,跳出率增加32%。

亚马逊的工程师曾分享:每100ms延迟,年损失16亿美元

沃尔玛更夸张:页面加载时间每减少1秒,转化率提升2%

所以,当你纠结“要不要优化这0.1秒”时,想想背后可能是几百万的生意。

🛠️ Emmet:写结构的“快枪手”

说到HTML结构,不得不提一个效率神器——Emmet

刚才那个时钟的结构,手写需要十几行。但用Emmet,一行搞定:

.clock>.clock-face>(.hand*3)

按一下Tab键,展开为:

<div class="clock">
  <div class="clock-face">
    <div class="hand"></div>
    <div class="hand"></div>
    <div class="hand"></div>
  </div>
</div>

Emmet语法速查:

符号 含义 示例 输出
. class类名 .box <div class="box">
# id #header <div id="header">
> 子元素 ul>li <ul><li>
* 重复 li*3 三个<li>
+ 兄弟元素 div+p <div><p>
() 分组 (header>h1)+main 复杂嵌套结构

学会Emmet,你的HTML手速提升3倍。

🎯 从时钟到大型项目:不变的原则

一个时钟遵循的原则,和淘宝首页、微信网页版是一样的:

1️⃣ HTML:语义化结构

<!-- 好:语义清晰 -->
<header>导航</header>
<main>内容</main>
<footer>版权</footer>

<!-- 差:div地狱 -->
<div class="top">导航</div>
<div class="center">内容</div>
<div class="bottom">版权</div>

2️⃣ CSS:避免“样式污染”

/* 好:类名有作用域 */
.clock .hand { }

/* 差:全局裸奔 */
.hand { }  /* 可能影响页面其他.hand */

3️⃣ JS:DOM操作最小化

// 好:批量修改,只触发一次重绘
document.querySelector('.clock').classList.add('tick');

// 差:循环里逐个修改(触发N次重绘)
for(let i=0; i<100; i++) {
  document.querySelector('.hand').style.transform = `rotate(${i}deg)`;
}

📈 一个曾经巨慢的网站,后来怎样了?

还是开头那家电商公司。

我们做了三件事:

  1. CSS全部移到<head>,消除FOUC
  2. JS拆分,核心逻辑放底部,非关键JS用async/defer延迟加载
  3. 优化关键渲染路径,首屏只加载首屏需要的代码

结果:

  • 加载时间:1.9秒 → 0.7秒(优化63%
  • 转化率:回升12%
  • 大促当天GMV:比预期多卖了4700万

CTO后来在复盘会上说:“我们没加一台服务器,没改一行业务代码,只是把东西放在了该放的位置。”

💡 一句话总结

前端性能优化的第一步,不是压缩代码、不是CDN加速,而是把HTML、CSS、JS放在它们该在的位置。

CSS去头部,别让页面“裸奔”
JS去底部,别阻塞渲染
三者各司其职,互不越界

这看起来简单,但价值千万。

image.png

互动时间:你有没有遇到过“打开一个网页,等了3秒还是白屏”的经历?在评论区吐槽一下那些“水泥搅拌机”式的网站吧!


💡 彩蛋:如果你想知道自己的网站性能如何,打开Chrome DevTools → Lighthouse,跑一次评分,它会告诉你优化建议。试试看,可能吓你一跳。

Flutter进阶:OverlayEntry 插入图层管理器 NOverlayZIndexManager

作者 SoaringHeart
2026年5月22日 20:55

一、需求来源

最近遇到一个需求:当使用 OverlayEntry 实现 Dialog & Sheet & Drawer & Toast 效果时,无法控制显示 Z 轴上的显示顺序 zIndex. 经过思考初步实现,用 模型封装实现, 模型如下:

class NOverlayZIndexItem {
  NOverlayZIndexItem({
    required this.entry,
    required this.zIndex,
    this.key,
  });

  /// 视图
  final OverlayEntry entry;

  /// z 轴层次
  final int zIndex;

  /// 唯一 key
  final String? key;
}

zIndex 决定在z 轴上视图层次,越大的在屏幕最上方(同数值上方叠加)。 key 如果相视图刷新而非新建。

二、使用示例

zIndex = 100 视图在最底下; zIndex = 150 视图在中间; zIndex = 200 视图在最上边。

var zIndex = 100;

void showContent({
  required int zIndex,
  Alignment? alignment,
  Color? color,
  Widget? child,
}) {
  final key = "999";

  final offset = NOverlayZIndexManager.instance.items.length * 20.0;
  OverlayEntry? entry;
  entry = OverlayEntry(builder: (_) {
    final message = [zIndex, key].join(", ");
    DLog.d(message);
    return Positioned(
      left: offset,
      right: 0,
      top: 400.0 + offset,
      child: Align(
        alignment: Alignment.center,
        child: GestureDetector(
          onTap: () {
            NOverlayZIndexManager.instance.removeWhere((e) => e.entry == entry);
          },
          child: child ??
              Container(
                width: 100,
                height: 100,
                alignment: alignment ?? Alignment.topLeft,
                decoration: BoxDecoration(
                  color: color ?? ColorEx.random,
                  border: Border.all(color: Colors.blue),
                ),
                child: Text(message),
              ),
        ),
      ),
    );
  });

  NOverlayZIndexManager.instance.show(
    context: context,
    entry: entry,
    zIndex: zIndex,
    // key: key,
  );
}
/// 插入视图1
onOverlayOne() async {
  zIndex = 100;
  displayContent(zIndex: zIndex, alignment: Alignment.bottomCenter, color: Colors.green);
}
/// 插入视图2
onOverlayTwo() async {
  zIndex = 200;
  displayContent(
    zIndex: zIndex,
    color: Colors.yellow,
    child: ElevatedButton(
      onPressed: () {},
      child: Text("ElevatedButton"),
    ),
  );
}
/// 插入视图3
onOverlayThree() async {
  zIndex = 150;
  displayContent(
    zIndex: zIndex,
    color: Colors.red,
    child: ElevatedButton(
      onPressed: () {},
      child: FlutterLogo(),
    ),
  );
}

onClear() async {
  NOverlayZIndexManager.instance.clear();
}

三、源码 NOverlayZIndexManager

//
//  NOverlayZIndexManager`.dart
//  projects
//
//  Created by shang on 2026/5/6 11:27.
//  Copyright © 2026/5/6 shang. All rights reserved.
//

import 'package:flutter/widgets.dart';

class NOverlayZIndexItem {
  NOverlayZIndexItem({
    required this.entry,
    required this.zIndex,
    this.key,
  });

  final OverlayEntry entry;
  final int zIndex;
  final String? key;
}

/// 全局 Overlay 层次 ZIndex 管理
class NOverlayZIndexManager {
  NOverlayZIndexManager._();

  static final instance = NOverlayZIndexManager._();

  // final context = AppNavigator.navigatorKey.currentContext!;
  // late final overlayState = Overlay.of(context, rootOverlay: true);

  final List<NOverlayZIndexItem> _items = [];
  List<NOverlayZIndexItem> get items => _items;

  void clear() {
    for (var i = 0; i < items.length; i++) {
      items[i].entry.remove();
    }
    _items.clear();
  }

  /// 插入(核心)
  NOverlayZIndexItem show({
    required BuildContext context,
    required OverlayEntry entry,
    required int zIndex,
    String? key,
  }) {
    // context ??= AppNavigator.navigatorKey.currentContext!;

    /// ✅ 1. 已存在 → 更新
    if (key != null) {
      final existIndex = _items.indexWhere((e) => e.key == key);
      if (existIndex != -1) {
        final existItem = _items[existIndex];
        existItem.entry.markNeedsBuild();

        // 👉 zIndex 变化才移动
        // if (existItem.zIndex != zIndex) {
        //   updateZIndex(key: key, newZIndex: zIndex);
        // }
        return existItem;
      }
    }

    /// 1️⃣ 先找插入位置(有序)
    int insertIndex = _items.indexWhere((e) => e.zIndex > zIndex);
    if (insertIndex == -1) {
      insertIndex = _items.length;
    }

    /// 2️⃣ 插入到本地列表
    final item = NOverlayZIndexItem(
      entry: entry,
      zIndex: zIndex,
      key: key,
    );

    _items.insert(insertIndex, item);
    return _insertOverlay(context: context, item: item, index: insertIndex);
  }

  NOverlayZIndexItem _insertOverlay({
    required BuildContext context,
    required NOverlayZIndexItem item,
    required int index,
  }) {
    final overlayState = Overlay.of(context, rootOverlay: true);

    NOverlayZIndexItem? below;
    NOverlayZIndexItem? above;

    /// 找“下方”元素(zIndex 更小)
    if (index > 0) {
      below = _items[index - 1];
    }

    /// 找“上方”元素(zIndex 更大)
    if (index < _items.length - 1) {
      above = _items[index + 1];
    }

    if (above != null) {
      overlayState.insert(item.entry, below: above.entry);
      return item;
    }

    /// 优先使用 below(更稳定)
    if (below != null) {
      overlayState.insert(item.entry, above: below.entry);
      return item;
    }

    /// 第一个元素
    overlayState.insert(item.entry);
    return item;
  }

  /// 删除
  void removeWhere(bool Function(NOverlayZIndexItem e) test, [int start = 0]) {
    final index = _items.indexWhere(test, start);
    if (index == -1) {
      return;
    }

    final item = _items.removeAt(index);
    item.entry.remove();
  }

  /// 根据 key 删除
  void removeByKey(String key) {
    final targets = _items.where((e) => e.key == key).toList();
    for (final item in targets) {
      item.entry.remove();
      _items.remove(item);
    }
  }

  /// 更新 UI(不动层级)
  void markNeedsBuild(String key) {
    final item = _items.where((e) => e.key == key).firstOrNull;
    item?.entry.markNeedsBuild();
  }

  /// 修改 zIndex(关键:移动位置)
  void updateZIndex({required BuildContext context, required String key, required int newZIndex}) {
    final overlayState = Overlay.of(context, rootOverlay: true);

    final index = _items.indexWhere((e) => e.key == key);
    if (index == -1) {
      return;
    }

    /// 重新计算位置
    var insertIndex = _items.indexWhere((e) => e.zIndex > newZIndex);
    if (insertIndex == -1) {
      insertIndex = _items.length;
    }

    final item = _items.removeAt(index);
    final itemNew = NOverlayZIndexItem(entry: item.entry, zIndex: newZIndex, key: key);
    _items.insert(insertIndex, itemNew);

    /// ⚠️ 关键:用 rearrange,而不是 insert
    overlayState.rearrange(_items.map((e) => e.entry));
  }
}

总结

核心是基于 OverlayState 方法封装:

void insert(OverlayEntry entry, { OverlayEntry? below, OverlayEntry? above }) 

void rearrange(Iterable<OverlayEntry> newEntries, { OverlayEntry? below, OverlayEntry? above }) 

n_overlay_zindex_manager

万星入坞·其三:SDK 轻量组件如何优雅地"点亮"

作者 码云之上
2026年5月22日 17:19

在前两篇:

我们分别拆解了壳层和子应用的设计。壳层是"坞",子应用是拥有独立路由段的"大星",但还有一种插件形态——它不占路由段,却既能提供纯逻辑能力(如鉴权守卫),又能渲染 UI 组件(如区域选择器)。这就是 SDK,星坞三层体系中的"小星"。

如果你用过微前端框架,可能会有这样的困惑:插件要么是纯逻辑,要么是完整页面,中间地带怎么办? 鉴权守卫不需要页面,但需要在每个路由跳转前拦截;区域选择器不是独立业务,却需要在 Header 和面包屑同时渲染 UI。如果强行归入子应用,会引入不必要的路由和加载开销;如果复制到每个子应用,则违背 DRY 原则。SDK 就是解决这个矛盾的轻量形态。

本文的核心思路是:描述符声明 UI 契约,上下文裁剪最小权限,UI 渲染三板斧各取所需。 下面逐个拆解。


SDK 的定位

先明确 SDK 在星坞三层体系中的位置:

graph LR
  Shell["Shell 壳层"] -->|"提供 SdkContext"| SDK["SDK 轻量插件"]
  Shell -->|"提供 AppContext"| App["App 子应用"]
  App -->|"ctx.sdk.load()"| SDK
  SDK -.->|"禁止直接 import"| App
维度 App(子应用) SDK(轻量插件)
路由 拥有路由段(如 /product/* 无独立路由段,不参与路由分发
UI 渲染完整页面/视图 可纯逻辑,也可提供 UI 组件供宿主渲染
生命周期 完整 mount → update → unmount activate → deactivate
加载时机 路由匹配时按需加载 按需或预加载
独立开发 可独立启动开发服务器 通常在壳层内调试

一句话总结:SDK 是不占路由段的轻量插件,能纯逻辑、能提供 UI、能两者兼有。

SDK 的形态光谱

SDK 并非非此即彼,而是有一个从"纯逻辑"到"含 UI"的形态光谱:

graph LR
  Pure["纯逻辑"] --> Mixed["含 UI 组件"]
  Pure --- Auth["auth-guard\n鉴权拦截\n无 UI"]
  Pure --- I18n["i18n-provider\n翻译包\n无 UI"]
  Mixed --- Region["region-selector\n区域选择器\n逻辑 + UI"]
  Mixed --- Audit["audit-log\n审计日志面板\n逻辑 + UI"]

  style Pure fill:#fff3cd
  style Mixed fill:#d4edda
  • 纯逻辑 SDK:仅提供 API/拦截器/数据转换,不渲染任何 UI(如 auth-guard
  • 含 UI SDK:除 API 外还提供 UI 组件,支持两种互补渲染能力(如 region-selector

这种设计填补了传统微前端"纯逻辑或纯页面"之间的空白,是星坞相比其他框架的一个亮点。


描述符声明 UI 契约

壳层在加载 SDK 模块之前,需要先知道"这个 SDK 叫什么、有没有 UI 组件、挂载到哪个插槽"。这些信息由 插件描述符(PluginDescriptor) 提供——它和子应用的描述符是同一个类型,但 SDK 有几个专属字段。

graph TD
  Desc["SDK 描述符\nPluginDescriptor"] -->|"壳层读取"| Preload["预加载判断\npreload"]
  Desc -->|"壳层读取"| UI["UI 组件注册\nuiComponents"]
  Desc -->|"壳层读取"| Style["样式隔离策略\nstyleStrategy"]
  Desc -->|"运行时"| Entry["import(entry)\n加载模块"]
  Desc -->|"子应用读取"| Export["导出声明\nexports"]

  style Desc fill:#e8f4fd
  style Entry fill:#d4edda

SDK 专有字段

字段 必填 说明
preload 是否预加载(SDK 独有,App 按路由加载无需此字段)
exports 导出的 API 声明列表
uiComponents UI 组件声明数组(纯逻辑 SDK 无此字段)
styleStrategy 样式隔离策略:css-modules(默认)/ css-in-js / shadow-dom

其中 uiComponents 是 SDK 与宿主之间的静态 UI 契约,作用类似 React 的 propTypes

子字段 说明
name 组件唯一标识,需与 getComponents() 返回的 key 对应
slot 期望的挂载位置,壳层 SdkSlotHost 据此决定渲染位置
propsSchema 组件 props 的 JSON Schema 约束,宿主侧可据此生成 TypeScript 类型
description 组件用途描述,方便文档生成

来看两个实际例子。

纯逻辑 SDK 描述符

// packages/sdks/auth-guard/plugin.config.ts
const descriptor: PluginDescriptor = {
  name: 'auth-guard',
  type: 'sdk',
  version: '1.2.0',
  entry: './src/index.ts',
  preload: true,                    // 预加载——鉴权守卫必须首屏就绪
  exports: ['AuthGuardApi'],        // 声明导出的 API
  configSchema: {
    type: 'object',
    properties: {
      enableSessionGuard: { type: 'boolean', default: true },
      enableOwnerGuard: { type: 'boolean', default: true },
    },
  },
};

没有 uiComponents,壳层就知道这个 SDK 不需要渲染 UI。

含 UI SDK 描述符

// packages/sdks/region-selector/plugin.config.ts
const descriptor: PluginDescriptor = {
  name: 'region-selector',
  type: 'sdk',
  version: '2.1.0',
  entry: './src/index.tsx',
  preload: true,
  exports: ['RegionSelectorApi'],
  uiComponents: [
    {
      name: 'RegionPicker',
      description: '区域选择器下拉组件',
      slot: 'header-slot',           // 挂载到 Header 插槽
      propsSchema: { type: 'object', properties: { regions: { type: 'array' }, onChange: { typeof: 'function' } } },
    },
    {
      name: 'RegionBreadcrumb',
      description: '区域面包屑导航',
      slot: 'breadcrumb',            // 挂载到面包屑插槽
    },
  ],
  styleStrategy: 'css-modules',
  configSchema: {
    type: 'object',
    properties: {
      defaultRegion: { type: 'string' },
    },
  },
};

注意两个 uiComponents 声明了不同的 slot——壳层据此知道 RegionPicker 渲染到 Header,RegionBreadcrumb 渲染到面包屑。声明时绑定,无需运行时协商。


上下文裁剪——最小权限

SDK 通过 SdkContext 消费壳层能力,但 SdkContextAppContext受约束子集。这不是偷懒少写几行代码,而是有意裁剪——基于最小权限原则。

graph TB
  subgraph AppCtx["AppContext"]
    A1["descriptor"]
    A2["config"]
    A3["sharedState"]
    A4["router"]
    A5["sdk"]
    A6["infra.net"]
    A7["infra.permission"]
    A8["infra.monitor"]
    A9["infra.i18n"]
    A10["container"]
  end

  subgraph SdkCtx["SdkContext"]
    S1["descriptor ✅"]
    S2["config ✅"]
    S3["sharedState ✅"]
    S4["router ❌"]
    S5["sdk ❌"]
    S6["infra.net ❌"]
    S7["infra.permission ❌"]
    S8["infra.monitor ✅"]
    S9["infra.i18n ✅"]
    S10["ui ✅(仅含 UI SDK)"]
  end

  style S4 fill:#f8d7da
  style S5 fill:#f8d7da
  style S6 fill:#f8d7da
  style S7 fill:#f8d7da
  style S10 fill:#d4edda
能力 AppContext SdkContext 裁剪原因
路由 router SDK 不参与路由分发,不应干预导航
SDK 引用 sdk 避免循环依赖(A → B → A)
网络请求 infra.net 避免不可控网络行为,应通过 API 封装
权限检查 infra.permission 权限是 App 层关注点
监控 错误上报是基础能力
国际化 SDK 可能需要翻译
UI 能力 ui SDK 独有:getSlot / requestRerender

为什么 SDK 不能引用其他 SDK?想象一下:SDK A 加载 SDK B,SDK B 又加载 SDK A——循环依赖一形成,加载顺序就崩了。所以 SdkContext 故意拿掉了 sdk 字段,SDK 之间只能通过 SharedStateBus 间接通信。

SdkContext 的构建

SdkContextSdkRegistry.buildSdkContext() 动态构建:

// packages/shell/src/sdk-registry.ts
private buildSdkContext(name: string): SdkContext {
  const descriptor = this.registry.getDescriptor(name);
  const hasUi = (descriptor.uiComponents?.length ?? 0) > 0;

  return {
    descriptor,
    config: this.deps.configCenter.forPlugin(name),  // 插件级配置作用域
    sharedState: this.deps.sharedState,
    infra: { monitor: this.deps.monitor, i18n: this.deps.i18n },
    ui: hasUi
      ? {
          getSlot(slotName) {
            const decl = descriptor.uiComponents?.find(c => c.slot === slotName);
            return decl ? { name: slotName, type: 'slot' } : undefined;
          },
          requestRerender: (componentName) => {
            this.emitRerender(name, componentName);
          },
        }
      : undefined,  // 纯逻辑 SDK 拿不到 ui 对象
  };
}

注意最后那个 ui: hasUi ? ... : undefined——只有声明了 uiComponents 的 SDK 才能拿到 ui 对象。纯逻辑 SDK 试图调用 ctx.ui.requestRerender() 会直接报 Cannot read properties of undefined,从源头上杜绝误用。


生命周期——简洁即克制

SDK 的生命周期比 App 简洁得多:

stateDiagram-v2
  state App {
    [*] --> BeforeMount: 路由匹配
    BeforeMount --> Mount: 钩子通过
    Mount --> AfterMount: 挂载完成
    AfterMount --> Update: 路由参数变化
    Update --> Update: 参数再次变化
    AfterMount --> BeforeUnmount: 路由离开
    BeforeUnmount --> Unmount: 钩子通过 / 超时熔断
    Unmount --> [*]
  }

  state SDK {
    [*] --> Activate: 预加载 / 按需加载
    Activate --> Render: 壳层调用 renderTo()
    Render --> Active: 活跃使用中
    Active --> Rerender: requestRerender
    Rerender --> Active
    Active --> Unrender: 插槽卸载 / SDK 停用
    Render --> Active
    Unrender --> Deactivate: 壳层卸载
    Activate --> Active: 纯逻辑 SDK
    Active --> Deactivate: 壳层卸载
    Deactivate --> [*]
  }
方法 必填 说明
activate(ctx) 初始化并发布 API 到 SharedStateBus
deactivate(ctx) 清理共享状态与副作用
onError(error, ctx) 错误上报
getComponents(ctx) 返回 UI 组件映射
render(container, ctx) SDK 自主将 UI 渲染到宿主 DOM
unrender(container, ctx) 卸载 React Root,与 render 成对

为什么 SDK 没有 update?因为它不参与路由分发,不会因 URL 变化触发框架级更新。为什么没有 beforeUnmount?因为 SDK 的 deactivate 是壳层主动调用的(不是用户行为触发的),不存在"表单未保存"这类需要中断的场景。

简洁即克制——SDK 只保留必要的生命周期,不多不少。


SDK 入口实战

理论说完了,来看两个 SDK 的入口实现。

纯逻辑 SDK:auth-guard

// packages/sdks/auth-guard/src/index.ts
const lifecycle: SdkLifecycle = {
  async activate(ctx: SdkContext) {
    const api = new AuthGuardApi(ctx);
    ctx.sharedState.setState('auth-guard.api', api);   // 发布 API
    ctx.sharedState.setState('auth-guard.ready', true);
  },
  async deactivate(ctx: SdkContext) {
    ctx.sharedState.setState('auth-guard.api', undefined);  // 清理 API
    ctx.sharedState.setState('auth-guard.ready', undefined);
  },
  onError(error, ctx) {
    ctx.infra.monitor.reportError('sdk-auth-guard-error', error);
  },
};

export default lifecycle;
export { AuthGuardApi } from '@/api';

整个入口就这么简洁——activate 里创建 API 实例并发布到 SharedStateBusdeactivate 里清理。子应用通过 ctx.sdk.load('auth-guard') 即可拿到 AuthGuardApi 实例。

AuthGuardApi 内部提供 Session 守卫、Owner 守卫等纯逻辑能力:

// packages/sdks/auth-guard/src/api.ts
export class AuthGuardApi {
  private sessionGuardEnabled: boolean;
  private ownerGuardEnabled: boolean;

  constructor(ctx: SdkContext) {
    const config = ctx.config.get<{ enableSessionGuard?: boolean }>('auth-guard') || {};
    this.sessionGuardEnabled = config.enableSessionGuard ?? true;
  }

  async checkSession(): Promise<boolean> {
    if (!this.sessionGuardEnabled) return true;
    return document.cookie.includes('session_id');
  }

  async checkAll(): Promise<{ session: boolean; owner: boolean }> {
    const [session, owner] = await Promise.all([this.checkSession(), this.checkOwner()]);
    return { session, owner };
  }
}

注意 API 的构造函数接收 SdkContext,通过 ctx.config 读取配置——这就是描述符中 configSchema 的作用:SDK 在 activate 阶段拿到配置,行为由配置驱动,而非硬编码。

含 UI SDK:region-selector

含 UI 的 SDK 在 activate / deactivate 之外,还要实现 getComponentsrenderunrender

// packages/sdks/region-selector/src/index.tsx
const lifecycle: SdkLifecycle = {
  async activate(ctx: SdkContext) {
    const regions = ctx.config.get<Array<{ id: string; name: string }>>('regions') || [
      { id: 'cn-east', name: '华东' },
      { id: 'cn-south', name: '华南' },
      { id: 'cn-north', name: '华北' },
      { id: 'cn-west', name: '西南' },
    ];
    const api = new RegionSelectorApi(regions, ctx);
    ctx.sharedState.setState('region-selector.api', api);
  },
  async deactivate(ctx: SdkContext) {
    ctx.sharedState.setState('region-selector.api', undefined);
  },
  onError(error, ctx) {
    ctx.infra.monitor.reportError('sdk-region-selector-error', error);
  },
  getComponents(_ctx: SdkContext) {
    return { RegionPicker, RegionBreadcrumb };   // 组件映射
  },
  render(container, ctx) {
    return renderSdkUi(container, ctx);          // 自主渲染
  },
  unrender(container) {
    return unrenderSdkUi(container);             // 自主卸载
  },
};

export default lifecycle;
export { RegionSelectorApi, RegionPicker, RegionBreadcrumb };

三种能力各司其职:

能力 方法 消费方 场景
API 发布 activate 内写入 SharedStateBus SdkRegistry.get() 纯逻辑交互
组件映射 getComponents() SdkRegistry.getComponent() 子应用显式引用
自主渲染 render(container, ctx) 壳层 SdkSlotHost 壳层插槽渲染

三种能力互不冲突,SDK 可按需组合——纯逻辑 SDK 只实现 activate / deactivate,含 UI 的 SDK 可以同时实现 render(壳层插槽)和 getComponents(子应用复用)。


UI 渲染三板斧

SDK 的 UI 渲染是本文的重头戏。传统微前端方案中,插件要么是纯逻辑,要么是纯页面,无法表达"提供可复用 UI 片段"的需求。星坞的 SDK 通过三种互补方式解决了这一问题:

flowchart LR
  subgraph sdk_internal ["SDK 内部"]
    Logic["逻辑能力 Api"]
    UI["UI 组件 Components"]
  end

  Logic -->|"方式三:仅消费 API"| App1["子应用:直接调用 Api"]
  UI -->|"方式一:壳层插槽自主渲染"| Slot["SdkSlotHost<br/>宿主定位置 · SDK 定内容"]
  UI -->|"方式二:子应用显式引用"| App2["子应用:getComponent<br/>自行放入 JSX"]

方式一:壳层插槽自主渲染(推荐)

壳层在布局中预留 SdkSlotHost,SDK 通过 render(container, ctx) 将 UI 渲染到宿主提供的 DOM。宿主决定"UI 出现在哪",SDK 决定"插槽里画什么"。

// Shell 布局中预留插槽
<SdkSlotHost shell={shell} sdkName="region-selector" slot="header-slot" />

SdkSlotHost 是一个精巧的 React 组件,内部管理三个 effect:

graph TD
  Mount["Effect 1:挂载/卸载"] -->|"sdkName 或 slot 变化"| RenderTo["sdkRegistry.renderTo()"]
  RenderTo -->|"cleanup"| UnrenderFrom["sdkRegistry.unrenderFrom()"]

  Rerender["Effect 2:requestRerender 订阅"] -->|"SDK 内部状态变更"| Incr["renderVersion++"]
  Incr --> Refresh["Effect 3:原地刷新"]

  style RenderTo fill:#d4edda
  style Refresh fill:#fff3cd

来看 SdkSlotHost 的实现精髓:

// packages/shell/src/layout/SdkSlotHost.tsx
export function SdkSlotHost({ shell, sdkName, slot, className }: SdkSlotHostProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  const [renderVersion, setRenderVersion] = useState(0);

  // Effect 1:订阅 SDK 的 requestRerender 通知
  useEffect(() => {
    return shell.sdkRegistry.onRerender(sdkName, () => {
      // 触发条件:SDK 在 render 阶段调用 requestRerender
      // 与正常路径差异:同步 setState 会导致 effect cleanup 在 React 渲染中 unmount Root
      // 修复原因:推迟到微任务,刷新走独立 effect,不触发卸载 cleanup
      queueMicrotask(() => {
        setRenderVersion((v) => v + 1);
      });
    });
  }, [shell, sdkName]);

  // Effect 2:挂载 / 卸载(仅随插槽或 SDK 变化)
  useEffect(() => {
    const el = containerRef.current;
    if (!el) return;
    let cancelled = false;

    void (async () => {
      await shell.sdkRegistry.renderTo(sdkName, el, { slot });
    })();

    return () => {
      cancelled = true;
      // 推迟卸载,避免在 React commit 阶段同步 unmount 嵌套 Root
      queueMicrotask(() => {
        void shell.sdkRegistry.unrenderFrom(sdkName, container).catch(console.error);
      });
    };
  }, [shell, sdkName, slot]);

  // Effect 3:requestRerender 触发的原地刷新
  useEffect(() => {
    if (renderVersion === 0) return;
    const el = containerRef.current;
    if (!el) return;
    void shell.sdkRegistry.renderTo(sdkName, el, { slot });
  }, [renderVersion, shell, sdkName, slot]);

  return <div ref={containerRef} className={className} data-xingwu-slot={slot} />;
}

这里有两个容易踩坑的设计决策:

踩坑 1:queueMicrotask 推迟 state 更新。SDK 调用 requestRerender 时可能正处于 React 渲染流程中,如果同步 setState,会导致 effect cleanup 在渲染中被触发,尝试 unmount 一个正在渲染的 React Root——直接崩溃。推迟到微任务后,刷新走独立的 effect,与当前渲染互不干扰。

踩坑 2:卸载也用 queueMicrotask。React 在 commit 阶段同步执行 effect cleanup,如果此时同步调用 root.unmount(),等于在 React 内部渲染流程中卸载另一个 Root——同样会崩溃。

SDK 侧的 render 实现

SDK 侧的 render 实现通过 container.dataset.xingwuSlot 识别插槽,映射到对应组件:

// packages/sdks/region-selector/src/sdkRender.tsx
const roots = new WeakMap<HTMLElement, Root>();
const regionListeners = new WeakMap<HTMLElement, () => void>();

function renderIntoContainer(container: HTMLElement, ctx: SdkContext): void {
  const slot = container.dataset.xingwuSlot ?? '';   // 读取宿主标记的 slot
  const api = ctx.sharedState.getState<RegionSelectorApi>('region-selector.api');
  if (!api) return;

  const regions = api.getAvailableRegions();
  const currentRegion = api.getCurrentRegion();

  let element: ReactNode = null;
  if (slot === 'header-slot') {
    element = <RegionPicker regions={regions} currentRegion={currentRegion}
               onChange={(region) => api.setCurrentRegion(region.id)} />;
  } else if (slot === 'breadcrumb') {
    element = <RegionBreadcrumb regions={regions} currentRegion={currentRegion} />;
  }
  if (!element) return;

  let root = roots.get(container);
  if (!root) {
    root = createRoot(container);      // 复用已有 Root
    roots.set(container, root);
  }
  root.render(element);

  // 订阅区域变更,通知宿主重新渲染
  regionListeners.get(container)?.();
  const unsub = api.onRegionsUpdated(() => {
    ctx.ui?.requestRerender(slotComponentName(slot));   // 触发 SdkSlotHost 刷新
  });
  regionListeners.set(container, unsub);
}

这段代码体现了几个关键设计:

  • WeakMap 管理 Root:用 WeakMap<HTMLElement, Root> 而不是 Map,当 DOM 元素被移除时 Root 引用自动释放,不会内存泄漏
  • slot → 组件映射:SDK 内部决定哪个 slot 渲染哪个组件,宿主只负责提供 DOM 和标记 slot 名称
  • requestRerender 闭环:SDK 监听 API 状态变更 → 调用 ctx.ui.requestRerender()SdkSlotHost 收到通知 → 递增 renderVersion → 触发刷新 effect → 重新调用 renderTo → SDK 的 render 读取最新 API 状态 → root.render 更新 UI

方式二:子应用显式引用

子应用通过 ctx.sdk.getComponent('region-selector', 'RegionPicker') 获取组件,自行放入 JSX 树:

// 子应用内部
const RegionPicker = ctx.sdk.getComponent<typeof import('xingwu-sdk-region-selector').RegionPicker>(
  'region-selector', 'RegionPicker'
);

// 自行控制位置和 props
<RegionPicker regions={regions} currentRegion={current} onChange={handleRegionChange} />

这种方式适用于需要精细控制位置与 props 的场景——比如子应用想把区域选择器放在自己的侧边栏里,而不是壳层 Header。

getComponent 背后是 SdkRegistry 的组件缓存:

// packages/shell/src/sdk-registry.ts
getComponent<T>(sdkName: string, componentName: string): T | undefined {
  const cached = this.componentCache.get(sdkName);
  if (cached?.[componentName]) return cached[componentName] as T;

  // 降级:从 PluginInstance 中提取
  const instance = this.registry.getInstance(sdkName);
  return instance?.uiComponents?.[componentName] as T | undefined;
}

组件缓存在 activate 后一次性提取,避免每次 getComponent() 重新调用 getComponents()

方式三:仅消费 API

不渲染 UI,只调用逻辑能力:

// 子应用内部
const api = await ctx.sdk.load<RegionSelectorApi>('region-selector');
const currentRegion = api.getCurrentRegion();

适用于不需要 UI 交互、只需数据的场景——比如商品列表读取当前区域作为查询条件。


SdkRegistry——门面不只是转发

前面说了 SdkRegistryPluginRegistry 的门面,但它的门面不是简单的方法转发。在三个关键点增加了业务语义:

graph TD
  PR["PluginRegistry\n(全量 API)"] -->|"门面裁剪"| SR["SdkRegistry\n(消费侧子集)"]

  SR -->|"get()"| Bus["SharedStateBus\n读取 {name}.api"]
  SR -->|"getComponent()"| Cache["componentCache\nactivate 后一次性缓存"]
  SR -->|"load()"| Full["resolve + activate\n预加载 = 首屏即用"]
  SR -->|"renderTo()"| Render["load + renderSdk\n注入 data-xingwu-slot"]
  SR -->|"reload()"| Reload["deactivate → activate\n灰度切换"]

  style SR fill:#e8f4fd
方法 语义 设计要点
get(name) 获取已激活 SDK 的 API 不返回模块导出,而是从 SharedStateBus 读取 {name}.api
load(name) 加载并激活 SDK resolve + activateSdk + 缓存组件,确保返回可用 API
preload(names) 批量预加载 预加载 = resolve + activate,首屏即可用
reload(name) 灰度重载 deactivate → activate,重建组件缓存,不清除描述符
getComponent(sdk, name) 获取 UI 组件 优先读缓存,降级读 PluginInstance
renderTo(sdk, container, { slot }) SDK 自主渲染 load 确保激活,再 renderSdk
unrenderFrom(sdk, container) 卸载 SDK UI 调用 lifecycle.unrender
onRerender(sdk, callback) 订阅重渲染 SDK requestRerender 触发

reload 的设计值得一提。当灰度策略切换 SDK 版本时,不需要重新加载描述符——只需 deactivate 旧实例、activate 新实例、重建组件缓存。因为描述符中的 uiComponents 契约不变(同一 SDK 的不同版本),只有模块实现变了。


宿主 UI 共享——SDK 的"借船出海"

含 UI 的 SDK 有一个特殊挑战:它不能直接 import antd,否则会和壳层的 antd 产生双实例问题。 和 React 双实例问题类似,两份 antd 的 Context 无法共享,样式也会重复加载。

星坞的解法是"借船出海"——SDK 从壳层注入的全局对象中借用 UI 组件:

// packages/sdks/region-selector/src/shims/host-antd.ts
export interface HostAntdSubset {
  Breadcrumb: typeof import('antd').Breadcrumb;
  Button: typeof import('antd').Button;
  Empty: typeof import('antd').Empty;
  Select: typeof import('antd').Select;
  Space: typeof import('antd').Space;
  Typography: typeof import('antd').Typography;
}

export function getHostAntd(): HostAntdSubset {
  const mod = window.__ANTD_SHARED__?.antd;
  if (!mod) {
    throw new Error('[region-selector] 未找到 window.__ANTD_SHARED__.antd。请由 Shell 先注入后再加载本 SDK。');
  }
  return mod;
}

SDK 的组件通过 useMemo(() => getHostAntd(), []) 获取宿主 antd 组件:

// packages/sdks/region-selector/src/components/RegionPicker.tsx
export function RegionPicker({ regions, currentRegion, onChange }: RegionPickerProps) {
  const { Empty, Select } = useMemo(() => getHostAntd(), []);
  const { GlobalOutlined } = useMemo(() => getHostIcons(), []);

  if (!regions.length) {
    return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无可用区域" />;
  }

  return (
    <Select value={currentRegion?.id} options={...}
      suffixIcon={<GlobalOutlined />} onChange={...} />
  );
}

这样 SDK 使用的 SelectEmpty 等组件和壳层是同一份实例,样式、Context、主题完全共享。壳层在启动时注入:

// packages/shell/src/main.tsx
(window as any).__ANTD_SHARED__ = {
  antd: { Breadcrumb, Button, Dropdown, Empty, Select, Space, Typography },
  icons: { GlobalOutlined },
};

踩坑 3:Shim 不可省略。如果 SDK 直接 import { Select } from 'antd',Vite 构建时会把 antd 打进 SDK 产物(因为 antd 不在 external 列表中),导致 SDK 体积膨胀且出现双实例问题。Shim 层强制 SDK 从全局获取,既保证了实例唯一,又减小了产物体积。


样式隔离——渐进策略

样式隔离不是一个技术问题,而是信任与成本的权衡。星坞不强制所有 SDK 使用最严格的隔离策略,而是通过 styleStrategy 让 SDK 自行声明:

策略 适用场景 优点 缺点
css-modules(默认) L1 受信内部插件 零运行时开销、构建时哈希 全局选择器需注意
css-in-js L2 半信插件,需主题注入 运行时动态、与宿主主题集成 运行时开销
shadow-dom L3 不信插件 完全隔离、无冲突 事件冒泡需处理、表单兼容性
graph LR
  L1["L1 受信\n内部 Monorepo"] -->|"css-modules"| Zero["零运行时开销"]
  L2["L2 半信\n跨团队"] -->|"css-in-js"| Theme["主题集成"]
  L3["L3 不信\n第三方"] -->|"shadow-dom"| Strict["严格隔离"]

  style L1 fill:#d4edda
  style L2 fill:#fff3cd
  style L3 fill:#f8d7da

实际上,首版实现中的两个 SDK(auth-guard 纯逻辑、region-selector 含 UI)都使用 css-modules——它们都在内部 Monorepo 中,构建时哈希足以避免无意冲突。未来接入第三方插件时,再按需升级隔离策略。


构建配置——与子应用同源不同流

SDK 的构建配置与子应用类似,但有几个差异点值得关注。

纯逻辑 SDK 构建

// packages/sdks/auth-guard/vite.config.ts
export default defineConfig({
  resolve: { alias: { '@': path.resolve(__dirname, 'src') } },
  server: { port: 5175, cors: true },
  build: {
    lib: { entry: 'src/index.ts', formats: ['es'], fileName: 'auth-guard' },
    rollupOptions: { external: ['@xingwu/types'] },  // 只需 external types
  },
});

纯逻辑 SDK 不引入 React,external 列表只需 @xingwu/types

含 UI SDK 构建

// packages/sdks/region-selector/vite.config.ts
export default defineConfig({
  plugins: [createSharedReactPlugin(), react()],
  resolve: {
    alias: { '@': '...', '@components': '...', '@styles': '...' },
    dedupe: ['react', 'react-dom', 'react-dom/client', 'react/jsx-runtime', 'react/jsx-dev-runtime'],
  },
  optimizeDeps: { disabled: true },   // 禁用预构建,确保共享 React 插件生效
  css: { postcss: { plugins: [tailwindcss(...), autoprefixer()] } },
  server: { port: 5176, strictPort: true, host: true, cors: true },
  build: {
    lib: { entry: 'src/index.tsx', formats: ['es'], fileName: 'region-selector' },
    rollupOptions: { external: ['react', 'react-dom', 'react-dom/client', '@xingwu/types'] },
  },
});

与纯逻辑 SDK 相比,含 UI SDK 多了三个关键配置:

  1. createSharedReactPlugin():开发模式下拦截 react 系裸导入,从 window.__REACT_SHARED__ 获取宿主 React 实例
  2. resolve.dedupe:确保 Vite 始终使用同一份 React 模块实例
  3. optimizeDeps.disabled: true:禁用依赖预构建,让共享 React 插件能拦截所有裸导入

其中 optimizeDeps.disabled: true 最容易被忽略。如果不禁用预构建,Vite 会把 react 预构建成一份 ESM 缓存,createSharedReactPluginresolveId 钩子根本不会触发——SDK 拿到的是 Vite 缓存里的另一份 React,Hooks 照崩不误。

共享 React 插件的核心逻辑

这个插件是含 UI SDK 能在开发模式下正常工作的关键,值得展开说说:

function createSharedReactPlugin(): Plugin {
  const virtualReact = '\0virtual:shared-react';
  const virtualReactDOMClient = '\0virtual:shared-react-dom-client';
  // ... 其他虚拟模块

  return {
    name: 'use-shared-react',
    enforce: 'pre',

    resolveId(source) {
      if (!this.meta.watchMode) return null;  // 生产构建不走虚拟模块
      if (source === 'react') return virtualReact;
      if (source === 'react-dom/client') return virtualReactDOMClient;
      // ...
    },

    load(id) {
      if (id === virtualReact) {
        return `const R = window.__REACT_SHARED__?.React;
if (!R) throw new Error('[SDK] Shared React not found.');
export default R;
export const useState = R.useState;
export const useEffect = R.useEffect;
// ... 逐一导出 Hooks
`;
      }
      if (id === virtualReactDOMClient) {
        return `const RD = window.__REACT_SHARED__?.ReactDOM;
if (!RD) throw new Error('[SDK] Shared ReactDOM not found.');
export const createRoot = RD.createRoot;
export const hydrateRoot = RD.hydrateRoot;
`;
      }
      // ...
    },
  };
}

react-dom/client 必须单独拦截。 这是最容易踩坑的地方——如果不单独拦截,Shell 通过 import() 动态加载 SDK 时,react-dom/client 会落到 Vite 的 CJS→ESM 预构建路径,而预构建转换无法正确暴露 createRoot 命名导出,运行时会报:

SyntaxError: The requested module '.../react-dom/client.js' does not provide an export named 'createRoot'

这个坑笔者踩了整整一个下午才定位到。排查思路是:在浏览器 DevTools 的 Network 面板中查看 SDK 加载的 react-dom/client 实际 URL——如果是 /@fs/... 开头,说明走了 Vite 预构建路径,共享 React 插件没有拦截到。


目录结构一览

最后给一个 SDK 的标准目录结构,方便新 SDK 快速搭建。

纯逻辑 SDK

packages/sdks/auth-guard/
├── package.json
├── tsconfig.json
├── vite.config.ts
├── plugin.config.ts           # 描述符声明
└── src/
    ├── index.ts               # SdkLifecycle 入口
    └── api.ts                 # 对外暴露的 API

含 UI SDK

packages/sdks/region-selector/
├── package.json
├── tsconfig.json
├── vite.config.ts             # 含 createSharedReactPlugin + Tailwind
├── plugin.config.ts           # 描述符声明(含 uiComponents)
└── src/
    ├── index.tsx              # SdkLifecycle 入口 + 具名导出
    ├── api.ts                 # 对外暴露的 API
    ├── sdkRender.tsx          # render / unrender 实现
    ├── components/
    │   ├── RegionPicker.tsx
    │   ├── RegionPicker.module.css
    │   ├── RegionBreadcrumb.tsx
    │   └── RegionBreadcrumb.module.css
    └── shims/
        ├── host-antd.ts       # 从 window.__ANTD_SHARED__ 借用宿主 antd
        └── host-icons.ts      # 从 window.__ANTD_SHARED__ 借用宿主图标

注意 shims/ 目录——这是含 UI SDK 独有的,用于从宿主借用 UI 组件,避免双实例问题。


小结

  1. SDK 填补了"纯逻辑或纯页面"之间的空白——描述符声明 UI 契约(uiComponents),上下文裁剪最小权限(SdkContextAppContext 的受约束子集),UI 渲染三板斧(壳层插槽 / 子应用引用 / 仅 API)各取所需
  2. 布局权与渲染权分离是 SDK UI 机制的核心哲学——宿主决定"UI 出现在哪"(SdkSlotHost + data-xingwu-slot),SDK 决定"插槽里画什么"(render 内组件映射与 createRoot
  3. 门面模式不只是方法转发——SdkRegistryget(API 语义)、getComponent(组件缓存)、load(预加载 = resolve + activate)三个关键点增加了业务语义
  4. 共享实例是含 UI SDK 的命门——React 双实例、antd 双实例、react-dom/client 预构建陷阱,每一个都能让你 debug 一个下午
  5. 样式隔离是信任与成本的权衡——css-modules(L1)→ css-in-js(L2)→ shadow-dom(L3),渐进策略让框架不强迫所有 SDK 使用最严格隔离

如果你也在做微前端的插件化设计,希望 SDK 的"轻量但不止于逻辑"思路能给你一些启发。

SDK完整示例传送门:sdks

鸿蒙聊天 Demo 练习 04:聊天历史本地缓存,实现消息记录持久化

作者 lcy453
2026年5月22日 15:46

鸿蒙聊天 Demo 练习 04:聊天历史本地缓存,实现消息记录持久化

一、本次分支

feature/chat-local-storage

二、本次目标

本次在原有聊天 Demo 的基础上,给聊天页面新增本地历史记录缓存能力。

之前聊天消息只保存在页面状态 chatList 里,只要退出页面、刷新页面或者重启应用,聊天记录就会丢失。

本次要把聊天记录保存到鸿蒙本地 Preferences 中,让聊天记录可以持久化保存。

本次完成的核心流程:

  1. 新增 ChatStorage.ets,专门封装聊天记录本地缓存。
  2. 使用 Preferences 保存 conversationIdchatList
  3. 页面进入时读取本地缓存并恢复聊天记录。
  4. 用户发送消息后,立即保存用户消息。
  5. 后端返回 assistant 回复后,再次保存完整聊天记录。
  6. 请求失败时,也把错误提示消息保存下来。
  7. Header 区域新增“清空”按钮。
  8. 点击清空后,同时清空页面状态和本地缓存。

最终效果:

用户发送消息
↓
页面展示用户消息
↓
保存到本地缓存
↓
调用后端接口
↓
页面展示 assistant 回复
↓
再次保存到本地缓存
↓
退出页面 / 重启应用
↓
再次进入聊天页
↓
自动恢复历史聊天记录

本次还没有做多会话列表,也没有接入数据库,只是先完成单个会话的本地持久化,为后续登录、token 保存、会话列表和数据库历史消息打基础。

三、涉及文件

entry/src/main/ets/models/ChatModel.ets
entry/src/main/ets/utils/ChatStorage.ets
entry/src/main/ets/pages/Setting.ets
docs/04-chat-local-storage.md

四、为什么要做聊天历史缓存

之前聊天 Demo 的数据流是:

用户输入
↓
创建用户消息
↓
追加到 chatList
↓
请求后端
↓
创建 assistant 消息
↓
追加到 chatList

这个流程可以完成聊天展示,但是有一个明显问题:

chatList 只是页面内存状态,不是持久化数据。

也就是说:

页面还在,消息就在
页面销毁,消息就没了
应用重启,消息也没了

真实项目中,聊天记录、用户信息、token、草稿、设置项等数据,很多都需要本地保存。

所以本次把聊天流程改造成:

鸿蒙页面
↓
chatList 状态更新
↓
ChatStorage 保存到 Preferences
↓
页面重新进入时读取 Preferences
↓
恢复聊天记录

这样 Demo 就从“临时页面状态”升级成了“有本地持久化能力”的应用。

五、项目结构变化

本次主要新增了一个 utils 工具目录,用来放本地缓存逻辑。

entry/src/main/ets
├── api
│   └── ChatApi.ets
│
├── constants
│   ├── ApiConstants.ets
│   └── RouteConstants.ets
│
├── models
│   └── ChatModel.ets
│
├── pages
│   └── Setting.ets
│
├── stores
│   └── TabState.ets
│
└── utils
    └── ChatStorage.ets

现在聊天相关代码大概可以分成三层:

pages/Setting.ets
  页面层,负责 UI 展示、输入、点击、调用方法

api/ChatApi.ets
  接口层,负责请求 Next.js 后端

utils/ChatStorage.ets
  本地存储层,负责保存和读取聊天历史

models/ChatModel.ets
  类型层,负责统一消息结构

这次的重点不是单纯会用 Preferences,而是把缓存逻辑从页面里拆出来,让页面不要越来越臃肿。

六、聊天消息模型

文件:

entry/src/main/ets/models/ChatModel.ets

代码:

export interface ChatMessage {
  id: number
  role: 'user' | 'assistant'
  content: string
  createTime: number
}

这个类型表示页面里真正要展示的一条聊天消息。

字段说明:

id:消息唯一标识
role:消息角色,用户消息是 user,AI 回复是 assistant
content:消息内容
createTime:消息创建时间

这里统一使用:

role: 'user' | 'assistant'

而不是:

type: 'user' | 'ai'

原因是 role 更接近真实聊天接口设计,后续接入真实 AI 接口、数据库消息表、OpenAI 风格接口时更容易对齐。

七、新增 ChatStorage 本地缓存工具

文件:

entry/src/main/ets/utils/ChatStorage.ets

完整代码:

import { preferences } from '@kit.ArkData'
import { common } from '@kit.AbilityKit'
import { ChatMessage } from '../models/ChatModel'

interface ChatCacheData {
  conversationId: number
  chatList: ChatMessage[]
}

export class ChatStorage {
  private static readonly STORE_NAME: string = 'chat_storage'
  private static readonly CHAT_CACHE_KEY: string = 'chat_cache'

  static async saveChatCache(
    context: common.UIAbilityContext,
    conversationId: number,
    chatList: ChatMessage[]
  ): Promise<void> {
    const pref = await preferences.getPreferences(context, ChatStorage.STORE_NAME)

    const cacheData: ChatCacheData = {
      conversationId,
      chatList
    }

    await pref.put(ChatStorage.CHAT_CACHE_KEY, JSON.stringify(cacheData))
    await pref.flush()
  }

  static async getChatCache(context: common.UIAbilityContext): Promise<ChatCacheData> {
    const pref = await preferences.getPreferences(context, ChatStorage.STORE_NAME)
    const cacheValue = await pref.get(ChatStorage.CHAT_CACHE_KEY, '')

    if (typeof cacheValue !== 'string' || cacheValue.length === 0) {
      return {
        conversationId: 0,
        chatList: []
      }
    }

    try {
      const cacheData = JSON.parse(cacheValue) as ChatCacheData

      return {
        conversationId: cacheData.conversationId || 0,
        chatList: cacheData.chatList || []
      }
    } catch (error) {
      console.error(`parse chat cache error: ${JSON.stringify(error)}`)

      return {
        conversationId: 0,
        chatList: []
      }
    }
  }

  static async clearChatCache(context: common.UIAbilityContext): Promise<void> {
    const pref = await preferences.getPreferences(context, ChatStorage.STORE_NAME)
    await pref.delete(ChatStorage.CHAT_CACHE_KEY)
    await pref.flush()
  }
}

八、ChatStorage 的职责

ChatStorage 只做三件事:

saveChatCache:保存聊天缓存
getChatCache:读取聊天缓存
clearChatCache:清空聊天缓存

页面不需要关心:

Preferences 怎么创建
缓存 key 是什么
数据怎么 JSON.stringify
数据怎么 JSON.parse
异常时怎么兜底

页面只需要调用:

await ChatStorage.saveChatCache(context, this.conversationId, this.chatList)

这样就完成了页面层和存储层的解耦。

九、为什么要同时保存 conversationId 和 chatList

这次不是只保存消息列表,而是保存了:

conversationId
chatList

原因是当前聊天接口已经支持 conversationId

如果只保存 chatList,不保存 conversationId,就会出现一个问题:

页面看起来恢复了旧聊天记录
但是下一次发消息时,后端不知道属于哪个会话

所以本地缓存结构设计成:

interface ChatCacheData {
  conversationId: number
  chatList: ChatMessage[]
}

这样页面恢复时可以同时恢复:

当前会话 ID
当前会话消息列表

十、Preferences 的基本使用流程

本次使用的是鸿蒙的 Preferences

核心流程是:

获取 Preferences 实例
↓
put 写入数据
↓
flush 持久化

保存缓存:

const pref = await preferences.getPreferences(context, ChatStorage.STORE_NAME)

await pref.put(ChatStorage.CHAT_CACHE_KEY, JSON.stringify(cacheData))
await pref.flush()

读取缓存:

const pref = await preferences.getPreferences(context, ChatStorage.STORE_NAME)
const cacheValue = await pref.get(ChatStorage.CHAT_CACHE_KEY, '')

删除缓存:

const pref = await preferences.getPreferences(context, ChatStorage.STORE_NAME)
await pref.delete(ChatStorage.CHAT_CACHE_KEY)
await pref.flush()

这里要注意:

put 之后要 flush
delete 之后也要 flush

否则数据可能只是更新到了内存里,没有真正持久化到本地。

十一、为什么要 JSON.stringify

Preferences 适合保存简单数据。

但是这次要保存的是一个对象:

{
  conversationId: 123,
  chatList: []
}

所以需要先转成字符串:

JSON.stringify(cacheData)

读取出来之后再转回对象:

JSON.parse(cacheValue)

整体流程:

对象
↓
JSON.stringify
↓
字符串
↓
Preferences
↓
字符串
↓
JSON.parse
↓
对象

十二、修改 Setting 页面

文件:

entry/src/main/ets/pages/Setting.ets

本次在 Setting.ets 中主要做了这些改动:

  1. 引入 common
  2. 引入 ChatMessage
  3. 引入 ChatStorage
  4. 页面进入时读取缓存。
  5. 发送消息后保存缓存。
  6. assistant 回复后保存缓存。
  7. 请求失败时保存错误提示。
  8. 新增清空聊天历史方法。
  9. Header 增加“清空”按钮。

十三、Setting.ets 新增引用

import { common } from '@kit.AbilityKit'

import { ChatMessage } from '../models/ChatModel'
import { ChatStorage } from '../utils/ChatStorage'

common.UIAbilityContext 用来给 Preferences 提供上下文。

ChatMessage 用来统一聊天消息类型。

ChatStorage 用来读写本地聊天缓存。

十四、chatList 类型调整

原来如果页面里自己定义了 ChatItem

interface ChatItem {
  id: number
  role: 'user' | 'assistant'
  content: string
  createTime: number
}

现在可以删掉,统一使用模型文件里的 ChatMessage

@Local chatList: ChatMessage[] = []

这样做的好处是:

页面展示使用 ChatMessage
本地缓存使用 ChatMessage
后续数据库消息也可以参考 ChatMessage

类型统一之后,后面维护会更简单。

十五、页面进入时读取本地缓存

aboutToAppear 中调用:

aboutToAppear(): void {
  globalTabState.setCurrentTab(RouteConstants.SETTING)

  this.getUIContext().setKeyboardAvoidMode(KeyboardAvoidMode.RESIZE)

  this.loadChatCache()
}

新增读取方法:

async loadChatCache(): Promise<void> {
  const context = getContext(this) as common.UIAbilityContext
  const cacheData = await ChatStorage.getChatCache(context)

  this.conversationId = cacheData.conversationId
  this.chatList = cacheData.chatList

  this.scrollToBottom()
}

这里没有把 aboutToAppear 直接写成 async,而是单独封装了 loadChatCache

这样写更清晰:

aboutToAppear:负责生命周期入口
loadChatCache:负责异步读取缓存

十六、保存聊天缓存方法

新增方法:

async saveChatCache(): Promise<void> {
  const context = getContext(this) as common.UIAbilityContext
  await ChatStorage.saveChatCache(context, this.conversationId, this.chatList)
}

这样页面里每次需要保存时,只需要写:

await this.saveChatCache()

不用每次都重复写:

getContext
ChatStorage.saveChatCache
conversationId
chatList

十七、发送用户消息后保存

原来发送消息时,只是把用户消息追加到 chatList

this.chatList = this.chatList.concat([tempUserMessage])
this.scrollToBottom()

现在改成:

this.chatList = this.chatList.concat([tempUserMessage])
await this.saveChatCache()
this.scrollToBottom()

这样做的好处是:

用户消息先展示
用户消息立即保存
即使后端请求失败,用户刚才发的内容也不会丢

十八、assistant 回复后保存

拿到后端返回的 assistant 消息后:

this.chatList = this.chatList.concat(assistantMessages)
await this.saveChatCache()
this.scrollToBottom()

这一步保存的是完整聊天记录:

用户消息
assistant 回复
conversationId

这样下次进入页面时,聊天上下文可以完整恢复。

十九、请求失败时也保存错误消息

如果后端返回异常:

const failMessage: ChatMessage = {
  id: Date.now(),
  role: 'assistant',
  content: res.message || '后端返回异常,请稍后重试。',
  createTime: Date.now()
}

this.chatList = this.chatList.concat([failMessage])
await this.saveChatCache()
this.scrollToBottom()

如果请求直接失败:

const errorMessage: ChatMessage = {
  id: Date.now(),
  role: 'assistant',
  content: '请求后端失败,请检查 Next.js 服务是否启动,以及接口地址是否正确。',
  createTime: Date.now()
}

this.chatList = this.chatList.concat([errorMessage])
await this.saveChatCache()
this.scrollToBottom()

这样做的原因是:

错误提示也是聊天页面的一部分
用户下次进入页面时,能看到上次失败的上下文
方便排查问题

二十、新增清空聊天历史功能

新增方法:

async clearChatHistory(): Promise<void> {
  const context = getContext(this) as common.UIAbilityContext

  this.chatList = []
  this.conversationId = 0

  await ChatStorage.clearChatCache(context)
}

这里需要同时清空三个东西:

chatList:页面上的消息
conversationId:当前会话 ID
Preferences:本地缓存

不能只清空 chatList

如果只清空页面消息,不清空 conversationId,下一次发送消息时还可能继续沿用旧会话 ID。

二十一、Header 新增清空按钮

原来的 Header 只有标题。

本次改成:

@Builder
Header() {
  Row() {
    Text('聊天 Demo')
      .fontSize(22)
      .fontWeight(FontWeight.Bold)
      .fontColor('#222222')

    Blank()

    Button('清空')
      .height(32)
      .fontSize(14)
      .enabled(this.chatList.length > 0 && !this.isSending)
      .onClick(() => {
        this.clearChatHistory()
      })
  }
  .width('100%')
  .height(56)
  .padding({ left: 16, right: 16 })
  .backgroundColor(Color.White)
  .alignItems(VerticalAlign.Center)
}

这里用了:

Blank()

让标题靠左,按钮靠右。

按钮禁用条件是:

.enabled(this.chatList.length > 0 && !this.isSending)

意思是:

没有聊天记录时不能点
正在发送消息时不能点

这样可以避免一些异常操作。

二十二、完整聊天流程

1. 页面初始化流程

进入 Setting 页面
↓
aboutToAppear 执行
↓
调用 loadChatCache
↓
读取 Preferences
↓
恢复 conversationId
↓
恢复 chatList
↓
滚动到底部

2. 发送消息流程

用户输入内容
↓
点击发送
↓
校验内容是否为空
↓
设置 isSending = true
↓
清空输入框
↓
创建用户消息
↓
追加到 chatList
↓
保存本地缓存
↓
调用 sendChatMessage
↓
拿到后端返回
↓
更新 conversationId
↓
过滤 assistant 消息
↓
追加到 chatList
↓
再次保存本地缓存
↓
滚动到底部
↓
设置 isSending = false

3. 清空历史流程

点击清空按钮
↓
chatList = []
↓
conversationId = 0
↓
删除 Preferences 缓存

二十三、为什么还是用 concat

这次继续使用:

this.chatList = this.chatList.concat([newMessage])

而不是:

this.chatList.push(newMessage)

原因是:

concat 会返回一个新数组
push 是在原数组上修改

在 ArkUI 状态更新里,使用新数组赋值更容易触发 UI 刷新。

也就是说:

this.chatList = this.chatList.concat([tempUserMessage])

这行代码的意思是:

基于旧数组生成一个新数组
再把新数组重新赋值给 chatList

这比直接 push 更适合响应式页面状态更新。

二十四、ArkTS 类型注意点

这次依然要注意 ArkTS 的类型严格性。

不建议直接写复杂匿名对象到函数参数里:

await ChatStorage.saveChatCache(context, this.conversationId, this.chatList)

这个没问题,因为参数类型明确。

但是如果是请求参数,最好不要写成:

const res = await sendChatMessage({
  conversationId: this.conversationId || undefined,
  content
})

更推荐:

const requestParams: ChatRequest = {
  content: content
}

if (this.conversationId > 0) {
  requestParams.conversationId = this.conversationId
}

const res = await sendChatMessage(requestParams)

这也是之前遇到 Object literal must correspond to some explicitly declared class or interface 后总结出来的经验。

二十五、可能遇到的问题

1. Cannot find module '../utils/ChatStorage'

原因:

ChatStorage.ets 文件没有创建
路径写错
utils 目录位置不对

检查文件是否在:

entry/src/main/ets/utils/ChatStorage.ets

引用路径应该是:

import { ChatStorage } from '../utils/ChatStorage'

2. Cannot find module '../models/ChatModel'

原因:

ChatModel.ets 文件不存在
或者里面没有导出 ChatMessage

确认文件内容:

export interface ChatMessage {
  id: number
  role: 'user' | 'assistant'
  content: string
  createTime: number
}

3. Preferences 读取后没有恢复消息

排查顺序:

1. 发送消息后是否调用了 saveChatCache
2. saveChatCache 里是否调用了 pref.flush()
3. getChatCache 是否正确读取 CHAT_CACHE_KEY
4. JSON.parse 是否报错
5. chatList 是否重新赋值

可以加日志:

console.info(`chat cache data: ${JSON.stringify(cacheData)}`)

4. 清空后重新进入页面又恢复旧数据

可能原因:

只清空了 chatList,没有删除 Preferences
delete 后没有 flush
清空的是错误的 key

确认清空方法里有:

await pref.delete(ChatStorage.CHAT_CACHE_KEY)
await pref.flush()

5. 点击清空后下一次聊天还沿用旧会话

原因:

清空时没有把 conversationId 重置为 0

正确做法:

this.chatList = []
this.conversationId = 0
await ChatStorage.clearChatCache(context)

二十六、测试步骤

1. 启动后端

如果当前聊天接口依赖 Next.js 后端,先启动后端:

cd server
npm run dev

如果第一次启动,需要先安装依赖:

cd server
npm install
npm run dev

2. 启动鸿蒙应用

用 DevEco Studio 运行到模拟器或真机。

3. 测试发送消息

输入:

你好

预期页面展示:

用户消息:你好
assistant 回复:这是 Next.js 后端返回的模拟回复:你好

4. 测试返回页面后恢复

操作:

切到其他页面
再回到聊天页面

预期:

刚才的聊天记录还在

5. 测试重启应用后恢复

操作:

关闭应用
重新打开应用
进入聊天页

预期:

历史聊天记录仍然存在

6. 测试清空聊天记录

点击右上角:

清空

预期:

页面消息清空
清空按钮禁用
重新进入页面后仍然为空

7. 测试清空后重新发送

再次输入:

重新开始

预期:

可以正常发送
conversationId 从新的会话开始
历史旧消息不会恢复

二十七、本次知识点总结

本次练习涉及以下知识点:

  1. 鸿蒙 Preferences 本地存储。
  2. preferences.getPreferences 获取本地存储实例。
  3. pref.put 写入缓存。
  4. pref.get 读取缓存。
  5. pref.delete 删除缓存。
  6. pref.flush 持久化缓存变更。
  7. 使用 JSON.stringify 保存复杂对象。
  8. 使用 JSON.parse 恢复复杂对象。
  9. 页面进入时通过 aboutToAppear 初始化数据。
  10. 异步生命周期逻辑可以拆成单独方法。
  11. chatList 使用 concat 触发 UI 更新。
  12. 页面层和存储层解耦。
  13. conversationId 和消息列表要一起保存。
  14. 清空历史时要同时清空页面状态和本地缓存。
  15. 为后续 token、本地用户信息、会话列表缓存打基础。

二十八、表达

这个功能可以这样说:

我在鸿蒙聊天 Demo 中新增了聊天历史本地持久化能力。之前聊天记录只保存在页面的 chatList 状态里,页面销毁或应用重启后数据就会丢失。为了解决这个问题,我新增了 ChatStorage.ets,使用鸿蒙 Preferences 保存 conversationIdchatList,并在页面 aboutToAppear 时读取缓存,恢复历史聊天记录。发送用户消息、收到 assistant 回复以及请求失败生成错误消息后,都会同步更新本地缓存。另外我还在 Header 中新增了清空按钮,点击后会同时清空页面消息、重置 conversationId,并删除本地缓存。这个功能让我练习了鸿蒙本地存储、页面生命周期、异步初始化、JSON 序列化和页面层与存储层的职责拆分。

二十九、本次提交命令

git add entry/src/main/ets/models/ChatModel.ets
git add entry/src/main/ets/utils/ChatStorage.ets
git add entry/src/main/ets/pages/Setting.ets
git add docs/04-chat-local-storage.md

git commit -m "feat: add chat local storage"
git push origin feature/chat-local-storage

如果合并到 main:

git checkout main
git pull
git merge feature/chat-local-storage
git push

删除本地分支:

git branch -d feature/chat-local-storage

删除远程分支:

git push origin --delete feature/chat-local-storage

三十、本次练习总结

这一节的重点不是做一个复杂的聊天系统,而是补齐聊天 Demo 中非常关键的一环:

页面状态
↓
本地缓存
↓
重新进入页面
↓
状态恢复

通过这次练习,我理解了几个关键点:

  1. @Local 状态只适合页面运行时展示,不适合长期保存。
  2. 需要持久化的数据应该放到本地存储或数据库中。
  3. Preferences 适合保存轻量级本地数据。
  4. 复杂对象要通过 JSON.stringify 转成字符串保存。
  5. 读取缓存时要做好空值和 JSON 解析异常兜底。
  6. 页面不要直接堆太多存储逻辑,应该抽成 ChatStorage
  7. 清空聊天记录时,不仅要清空页面,还要清空缓存和会话 ID。
  8. 本地缓存能力可以继续复用到登录 token、用户信息、主题设置等功能。

目前 Demo 已经具备了基础聊天、后端接口请求和本地历史缓存能力。

后续如果继续扩展,可以进入下一节:

请求封装升级:抽离通用 Request 工具

再往后就可以继续做:

登录页
登录状态保存
路由登录拦截
会话列表
后端数据库

这样整个 Demo 会越来越接近真实业务项目。

React Hook采用环形链表的原因

作者 卷帘依旧
2026年5月22日 15:02

React Hooks 更新采用环形链表的原因

React Hooks 的更新队列采用环形链表结构,这是一个精心设计的决策。让我从源码层面解释为什么。

1. 环形链表的核心优势

优势一:O(1) 时间的合并操作

// 环形链表结构
type UpdateQueue<T> = {
  pending: Update<T> | null,  // 指向最后一个更新
}

type Update<T> = {
  action: T | ((T) => T),
  next: Update<T> | null,
}

// 添加新更新 - O(1) 时间复杂度
function appendUpdate(queue, update) {
  const pending = queue.pending
  
  if (pending === null) {
    // 第一个更新,指向自己形成环
    update.next = update
  } else {
    // 插入到环形链表的头部
    // pending 指向最后一个节点
    // pending.next 指向第一个节点
    update.next = pending.next  // 新节点的 next 指向第一个节点
    pending.next = update       // 最后一个节点的 next 指向新节点
  }
  queue.pending = update  // 更新 pending 指向新节点(新的最后一个)
}

// 如果是单向链表(非环形)
function appendUpdateLinear(head, update) {
  // 需要遍历到末尾才能添加 - O(n) 时间复杂度
  if (head === null) {
    return update
  }
  let current = head
  while (current.next !== null) {  // 遍历!
    current = current.next
  }
  current.next = update
  return head
}

实际性能对比

// React 中频繁的批量更新场景
function handleClick() {
  // 同一个状态连续更新多次
  setCount(1)
  setCount(2)
  setCount(3)
  setCount(4)
  setCount(5)
}

// 环形链表:每次 O(1),5次操作 = 5个单位时间
// 单向链表:第1次 O(1),第2次 O(2),第3次 O(3)... 总计 O(n²)

优势二:高效的双向遍历能力

// React 处理更新时的遍历
function processUpdateQueue(queue) {
  const pending = queue.pending
  
  if (pending !== null) {
    // 关键:通过 pending.next 获取第一个更新
    const first = pending.next  // O(1) 获取头部
    let newState = currentState
    
    // 正向遍历所有更新
    let update = first
    do {
      newState = applyUpdate(newState, update.action)
      update = update.next
    } while (update !== first)  // 回到起点,遍历完成
    
    // 如果需要反向遍历(比如优先级调度)
    // 也可以轻松实现
    let last = pending
    let prev = first
    while (prev.next !== last) {
      // 反向遍历逻辑
    }
  }
}

2. 解决并发渲染中的问题

问题场景:高优先级更新打断

// 环形链表在并发渲染中的优势
function concurrentUpdateExample() {
  const [count, setCount] = useState(0)
  
  // 场景:用户快速点击,产生多个更新
  setCount(1)  // 低优先级更新 A
  setCount(2)  // 高优先级更新 B(打断 A)
  setCount(3)  // 低优先级更新 C
  
  // 环形链表的处理方式:
  // pending → [C] → [B] → [A] → (回到 [C])
  //            ↑_______________|
  // 
  // 渲染时可以从任意节点开始,灵活调整优先级顺序
}

React 的实际实现

// React 源码中的环形链表实现(简化)
function dispatchSetState(fiber, queue, action) {
  const update = {
    action,
    next: null,
    priority: getCurrentPriorityLevel(),
  }
  
  // 获取当前待处理的更新环
  const pending = queue.pending
  
  if (pending === null) {
    // 第一个更新,形成环
    update.next = update
  } else {
    // 插入到环中
    update.next = pending.next
    pending.next = update
  }
  
  queue.pending = update
  
  // 并发渲染时可以安全地 fork 更新队列
  if (fiber.lanes !== NoLanes) {
    // 如果正在进行渲染,创建 interleaved 队列
    const interleaved = queue.interleaved
    if (interleaved === null) {
      queue.interleaved = update
    } else {
      update.next = interleaved.next
      interleaved.next = update
    }
    queue.interleaved = update
  }
  
  scheduleUpdateOnFiber(fiber)
}

// 处理并发更新时的队列合并
function mergeQueues(baseQueue, interleavedQueue) {
  if (baseQueue === null) {
    return interleavedQueue
  }
  if (interleavedQueue === null) {
    return baseQueue
  }
  
  // 环形链表的合并:O(1) 完成
  // baseQueue: ... → last1 → first1 → ...
  // interleavedQueue: ... → last2 → first2 → ...
  
  const first1 = baseQueue.next
  const last1 = baseQueue
  const first2 = interleavedQueue.next
  const last2 = interleavedQueue
  
  // 将两个环连接成一个环
  last1.next = first2
  last2.next = first1
  
  return interleavedQueue  // 返回新的尾部
}

3. 批量更新与状态计算

批量更新机制

// React 18 的自动批处理
function batchUpdate() {
  // 所有更新被收集到环形链表
  setCount(1)  // update1
  setCount(2)  // update2
  setCount(3)  // update3
  setName('John')  // 另一个 Hook 的更新
  
  // 环形链表结构:
  // pending → update3 → update2 → update1 → (回到 update3)
  //            ↑____________________|
  
  // 一次渲染处理所有更新
  // 遍历环形链表只需 O(n) 时间
}

// 处理环形链表的代码
function processUpdateQueue(workInProgress, queue) {
  let pending = queue.pending
  
  if (pending !== null) {
    // 关键:解除环形,变成单向链表方便处理
    const first = pending.next
    let last = pending
    let newState = currentState
    
    // 断开环形
    last.next = null
    
    // 现在变成了单向链表,可以安全遍历
    let update = first
    while (update !== null) {
      newState = applyUpdate(newState, update.action)
      update = update.next
    }
    
    return newState
  }
}

4. 与单向链表的对比

// 性能对比测试
function benchmark() {
  const updates = Array(1000).fill().map((_, i) => ({ action: i }))
  
  // 环形链表插入
  console.time('Circular Linked List')
  let circularQueue = null
  for (let update of updates) {
    if (circularQueue === null) {
      update.next = update
      circularQueue = update
    } else {
      update.next = circularQueue.next
      circularQueue.next = update
      circularQueue = update
    }
  }
  console.timeEnd('Circular Linked List')  // ~0.1ms
  
  // 单向链表插入
  console.time('Singly Linked List')
  let linearHead = null
  let linearTail = null
  for (let update of updates) {
    if (linearHead === null) {
      linearHead = update
      linearTail = update
    } else {
      linearTail.next = update
      linearTail = update
    }
  }
  console.timeEnd('Singly Linked List')  // ~0.15ms(略慢)
  
  // 但环形链表在特定操作上优势明显
  // 比如:获取第一个和最后一个元素都是 O(1)
}

5. 实际应用场景

场景一:优先级提升

// React 中的优先级提升机制
function promoteUpdatePriority(queue, targetPriority) {
  const pending = queue.pending
  if (pending === null) return
  
  // 环形链表可以轻松调整顺序
  let update = pending.next
  let highestPriorityUpdate = null
  
  do {
    if (update.priority > targetPriority) {
      // 找到高优先级更新,提升它
      highestPriorityUpdate = update
      break
    }
    update = update.next
  } while (update !== pending.next)
  
  if (highestPriorityUpdate) {
    // 将高优先级更新移到环的头部
    // 这样渲染时会优先处理
    queue.pending = highestPriorityUpdate
  }
}

场景二:状态回滚

// 时间切片中的状态回滚
function rollbackUpdates(queue, rollbackPoint) {
  const pending = queue.pending
  if (pending === null) return
  
  // 找到回滚点
  let update = pending.next
  let found = false
  
  do {
    if (update === rollbackPoint) {
      found = true
      break
    }
    update = update.next
  } while (update !== pending.next)
  
  if (found) {
    // 截断环形链表,丢弃回滚点之后的更新
    queue.pending = rollbackPoint
    rollbackPoint.next = rollbackPoint  // 重新形成环
  }
}

6. 内存和 GC 优势

// 环形链表的垃圾回收优势
function cleanupQueue(queue) {
  // 断开环形引用,帮助 GC
  const pending = queue.pending
  
  if (pending !== null) {
    // 打破循环引用
    const first = pending.next
    pending.next = null  // 断开环
    
    // 现在可以安全地清理
    let update = first
    while (update !== null) {
      const next = update.next
      update.next = null  // 帮助 GC
      update = next
    }
  }
  
  queue.pending = null
}

// 单向链表需要更多遍历才能完全清理

总结

React Hooks 采用环形链表的核心原因:

  1. 性能优化:O(1) 的头部和尾部访问,O(1) 的合并操作
  2. 并发安全:便于 fork 和合并更新队列,支持优先级调度
  3. 灵活性:可以从任意节点开始遍历,方便实现各种调度策略
  4. 内存效率:无需维护额外的头尾指针,单个指针就能定位整个队列
  5. 批量更新:天然支持环形遍历,适合处理批量状态更新

这种设计是 React 团队在性能和功能之间做出的最优权衡,既满足了并发渲染的需求,又保持了良好的性能特性。

为什么React Hooks不能用在if/for等条件/循环语句中

作者 卷帘依旧
2026年5月22日 14:52

在 React 中,Hooks 不能直接用在 iffor 或其他条件/循环语句中

原因

React 要求 Hooks 必须在组件的顶层被调用,不能在条件语句、循环或嵌套函数中使用。这背后有两个关键原因:

1. 保证 Hooks 的调用顺序一致

React 内部通过调用顺序来追踪每个 Hook 对应的 state。例如:

// 第一次渲染
useState('name')  // 第1个Hook → name state
useState(0)       // 第2个Hook → age state
useEffect(() => {}) // 第3个Hook → effect

// 第二次渲染时,React 按顺序分配:
// 第1个调用 → name state
// 第2个调用 → age state  
// 第3个调用 → effect

如果在 if 中使用 Hook:

// ❌ 错误示例
if (someCondition) {
  useState('name')  // 条件为 false 时,这个 Hook 不执行
}
useState(0)         // 原本的第2个 Hook 变成了第1个

这会导致 Hook 顺序错乱,React 无法正确匹配 state。

2. 避免状态混乱

// ❌ 错误示例
function MyComponent({ id }) {
  if (id) {
    useEffect(() => {
      fetchData(id)
    }, [id])  // 危险!Hook 在条件中
  }
  
  const [count, setCount] = useState(0)  // 顺序可能改变
  // ...
}

解决方案

在条件中使用 Hooks 的正确方式

// ✅ 将条件写在 Hook 内部
function MyComponent({ id }) {
  useEffect(() => {
    if (id) {
      fetchData(id)
    }
  }, [id])
  
  // 或者在条件中使用 Hook 的返回值
  const [count, setCount] = useState(0)
  
  if (count > 5) {
    return <div>超过限制</div>  // 可以在条件中提前 return
  }
  
  return <div>{count}</div>
}

在循环中使用

// ✅ 不能这样,但可以:
// 错误:在循环中调用 Hook
for (let i = 0; i < 5; i++) {
  useState(i)  // ❌ 错误
}

// 正确:将 Hook 的结果用于循环
const [items, setItems] = useState([])  // ✅ Hook 在顶层
const renderedItems = items.map(item => <Item key={item.id} />)

总结

  • 可以做的:在 Hooks 内部写条件判断
  • 不能做的:在条件语句或循环中调用 Hooks

这个规则是 React 的设计原则,目的是保持 Hooks 调用顺序的一致性,确保状态正确管理。


React Hooks 的完整管理方式

从源码层面详细讲解 React 如何管理 Hooks,包括创建、更新、销毁的完整流程。

1. 三层数据结构

React 使用三层结构管理 Hooks:

// 第一层:Fiber 节点(每个组件实例)
type Fiber = {
  memoizedState: Hook | null,        // 指向第一个 Hook
  updateQueue: UpdateQueue | null,    // 更新队列
  stateNode: any,                     // 真实 DOM 节点
  alternate: Fiber | null,            // 双缓冲副本
  // ... 其他 Fiber 属性
}

// 第二层:Hook 节点(链表结构)
type Hook = {
  memoizedState: any,          // 当前 state/effect
  baseState: any,              // 基础 state(用于计算)
  baseQueue: Update | null,    // 基础更新队列
  queue: UpdateQueue | null,    // 更新队列
  next: Hook | null,           // 指向下一个 Hook
}

// 第三层:更新队列(环形链表)
type UpdateQueue<S, A> = {
  pending: Update<A> | null,   // 待处理的更新(环形链表)
  interleaved: Update<A> | null,
  lanes: Lanes,
  dispatch: (A => mixed) | null,
  lastRenderedReducer: ((S, A) => S) | null,
  lastRenderedState: S | null,
}

type Update<A> = {
  action: A,                   // setState 传入的值
  next: Update<A> | null,      // 下一个更新
  priority: number,            // 优先级
  // ...
}

2. Hooks 的完整生命周期

阶段一:挂载阶段(Mount)

// React 内部实现(简化版)
function mountWorkInProgressHook(): Hook {
  const hook: Hook = {
    memoizedState: null,
    baseState: null,
    baseQueue: null,
    queue: null,
    next: null,
  }

  if (workInProgressHook === null) {
    // 第一个 Hook
    currentlyRenderingFiber.memoizedState = workInProgressHook = hook
  } else {
    // 后续 Hook,添加到链表末尾
    workInProgressHook = workInProgressHook.next = hook
  }
  
  return workInProgressHook
}

// mountState 实现
function mountState(initialState) {
  // 1. 创建 Hook 节点
  const hook = mountWorkInProgressHook()
  
  // 2. 初始化 state
  if (typeof initialState === 'function') {
    hook.memoizedState = initialState()
  } else {
    hook.memoizedState = initialState
  }
  
  // 3. 创建更新队列
  const queue = {
    pending: null,
    dispatch: null,
    lastRenderedReducer: basicStateReducer,
    lastRenderedState: initialState,
  }
  hook.queue = queue
  
  // 4. 创建 dispatch 函数
  const dispatch = (queue.dispatch = dispatchAction.bind(
    null,
    currentlyRenderingFiber,
    queue,
  ))
  
  return [hook.memoizedState, dispatch]
}

阶段二:更新阶段(Update)

// updateWorkInProgressHook - 复用旧 Hook
function updateWorkInProgressHook(): Hook {
  let nextCurrentHook: Hook | null
  
  if (currentHook === null) {
    // 获取 Fiber 对应的旧 Hook 链表的头节点
    const current = currentlyRenderingFiber.alternate
    if (current !== null) {
      nextCurrentHook = current.memoizedState
    } else {
      nextCurrentHook = null
    }
  } else {
    // 获取下一个旧 Hook
    nextCurrentHook = currentHook.next
  }
  
  // 关键检测:Hook 数量不一致
  if (nextCurrentHook === null) {
    throw new Error(
      'Rendered more hooks than during the previous render.'
    )
  }
  
  currentHook = nextCurrentHook
  
  // 创建新的 Hook 节点,复用旧数据
  const newHook: Hook = {
    memoizedState: currentHook.memoizedState,
    baseState: currentHook.baseState,
    baseQueue: currentHook.baseQueue,
    queue: currentHook.queue,
    next: null,
  }
  
  if (workInProgressHook === null) {
    currentlyRenderingFiber.memoizedState = workInProgressHook = newHook
  } else {
    workInProgressHook = workInProgressHook.next = newHook
  }
  
  return workInProgressHook
}

// updateState 实现
function updateState(initialState) {
  const hook = updateWorkInProgressHook()
  const queue = hook.queue
  const pending = queue.pending
  
  if (pending !== null) {
    // 处理所有待处理的更新
    let firstUpdate = pending.next
    let newState = hook.memoizedState
    
    // 遍历环形链表,应用所有更新
    let update = firstUpdate
    do {
      const action = update.action
      newState = action(newState)  // 或者直接赋值
      update = update.next
    } while (update !== null && update !== firstUpdate)
    
    hook.memoizedState = newState
    queue.pending = null
  }
  
  return [hook.memoizedState, queue.dispatch]
}

3. 双缓冲机制(Fiber 双缓冲)

React 使用双缓冲技术管理 Hooks 状态:

// 双缓冲工作原理
function renderWithHooks(current, workInProgress, Component, props) {
  // current: 当前屏幕显示的 Fiber 树(旧)
  // workInProgress: 正在构建的 Fiber 树(新)
  
  currentlyRenderingFiber = workInProgress
  
  // 重置 Hooks 状态
  workInProgressHook = null
  currentHook = null
  
  // 将 current 树的 Hooks 复制到 workInProgress
  if (current !== null) {
    currentHook = current.memoizedState  // 从旧树获取 Hooks
  }
  
  // 执行组件,内部会调用各种 Hooks
  let children = Component(props)
  
  // 渲染完成后,workInProgress 树成为新的 current
  finishRendering()
  
  return children
}

4. 状态更新的调度流程

// dispatchAction - setState 的实现
function dispatchAction(fiber, queue, action) {
  // 1. 创建更新对象
  const update = {
    action,
    next: null,
    priority: getCurrentPriorityLevel(),
  }
  
  // 2. 将更新添加到环形链表
  const pending = queue.pending
  if (pending === null) {
    // 第一个更新,指向自己形成环形
    update.next = update
  } else {
    update.next = pending.next
    pending.next = update
  }
  queue.pending = update
  
  // 3. 调度更新
  scheduleUpdateOnFiber(fiber)
}

// 处理更新队列
function processUpdateQueue(workInProgress) {
  let queue = workInProgress.updateQueue
  let pending = queue.pending
  
  if (pending !== null) {
    // 移除环形链表的循环引用
    const first = pending.next
    let last = pending
    let newState = workInProgress.memoizedState
    
    // 处理所有更新
    let update = first
    do {
      const action = update.action
      if (typeof action === 'function') {
        newState = action(newState)
      } else {
        newState = action
      }
      update = update.next
    } while (update !== null && update !== first)
    
    workInProgress.memoizedState = newState
    queue.pending = null  // 清空队列
  }
}

5. 不同类型 Hooks 的管理

useState 管理

function useState(initialState) {
  const dispatcher = resolveDispatcher()
  return dispatcher.useState(initialState)
}

// 不同阶段的 dispatcher
const HooksDispatcherOnMount = {
  useState: mountState,
  useEffect: mountEffect,
  useContext: readContext,
  // ...
}

const HooksDispatcherOnUpdate = {
  useState: updateState,
  useEffect: updateEffect,
  useContext: readContext,
  // ...
}

useEffect 管理

function mountEffect(create, deps) {
  const hook = mountWorkInProgressHook()
  const nextDeps = deps === undefined ? null : deps
  
  // Effect 有特殊的存储结构
  hook.memoizedState = {
    create,     // effect 函数
    destroy: null,  // cleanup 函数
    deps: nextDeps,
  }
  
  // 将 effect 添加到 fiber 的 updateQueue 中
  pushEffect(
    HookPassive | HookHasEffect,
    create,
    undefined,
    nextDeps,
  )
  
  return
}

function updateEffect(create, deps) {
  const hook = updateWorkInProgressHook()
  const nextDeps = deps === undefined ? null : deps
  const prevState = hook.memoizedState
  
  // 比较依赖是否变化
  if (prevState !== null && nextDeps !== null) {
    const prevDeps = prevState.deps
    if (areHookInputsEqual(nextDeps, prevDeps)) {
      // 依赖没变,跳过执行
      pushEffect(HookPassive, create, undefined, nextDeps)
      return
    }
  }
  
  // 依赖变化,需要执行
  hook.memoizedState = {
    create,
    destroy: prevState?.destroy,
    deps: nextDeps,
  }
  pushEffect(HookPassive | HookHasEffect, create, undefined, nextDeps)
}

6. 完整的执行示例

// 示例组件
function App() {
  const [count, setCount] = useState(0)
  const [name, setName] = useState('John')
  
  useEffect(() => {
    console.log('Count changed:', count)
  }, [count])
  
  const handleClick = () => {
    setCount(count + 1)
    setCount(c => c + 1)  // 函数式更新
  }
  
  return <button onClick={handleClick}>{count}</button>
}

// 第一次渲染(Mount):
// 1. useState(0) → 创建 Hook1,memoizedState = 0
// 2. useState('John') → 创建 Hook2,memoizedState = 'John'  
// 3. useEffect → 创建 Hook3,添加到 effect 队列
// 链表结构:Hook1 → Hook2 → Hook3

// 点击按钮后:
// 1. setCount(1) 创建 update1
// 2. setCount(c => c + 1) 创建 update2
// 更新队列(环形链表):update1 → update2 → update1

// 第二次渲染(Update):
// 1. useState(0) → 复用 Hook1,应用两个更新,memoizedState = 2
// 2. useState('John') → 复用 Hook2,memoizedState = 'John'
// 3. useEffect → 比较依赖,count 变化,执行 effect

7. 内存管理与清理

// Fiber 删除时的 Hook 清理
function safelyCallDestroy(current, nearestMountedAncestor, destroy) {
  try {
    destroy()  // 执行 useEffect 返回的 cleanup 函数
  } catch (error) {
    // 错误处理
  }
}

// 组件卸载时清理所有 Hooks
function commitUnmount(fiber) {
  // 遍历 Hook 链表,执行所有 cleanup
  let nextEffect = fiber.memoizedState
  while (nextEffect !== null) {
    const effect = nextEffect.memoizedState
    if (effect !== null && effect.destroy !== undefined) {
      safelyCallDestroy(fiber, null, effect.destroy)
    }
    nextEffect = nextEffect.next
  }
}

总结

React Hooks 的管理方式核心特点:

  1. 链表结构:使用单向链表存储 Hook 节点,依赖固定的调用顺序
  2. 双缓冲:通过 Fiber 双缓冲机制支持并发渲染
  3. 环形更新队列:高效处理状态更新的批量操作
  4. 分阶段调度:区分 mount 和 update 阶段,使用不同的 dispatcher
  5. 优先级机制:支持高优先级更新打断低优先级更新

这种设计使 React 能够在保证性能的同时,提供简单易用的 Hooks API。

从 HarmonyOS AI 聊天模块理解工程化架构:MVVM、Controller、Provider、请求封装与 SSE

作者 lcy453
2026年5月22日 14:02

从 HarmonyOS AI 聊天模块理解工程化架构:MVVM、Controller、Provider、请求封装与 SSE

一、前言

这几天主要在看一个 HarmonyOS AI 聊天模块的源码。

一开始看这种项目,最大的感受就是文件很多、层级很多:页面、组件、ViewModel、Controller、Provider、HttpClient、Parser、Model 都有。直接从某个方法开始逐行看,很容易看着看着就迷路。

后来我发现,读这种业务模块不能一上来就陷进细节,而是要先把整体链路理清楚。

这个聊天模块表面上是一个 AI 对话页面,但实际包含了页面入口、聊天 UI、状态管理、业务流程控制、AI 平台适配、请求封装、SSE 流式响应、会话管理、业务卡片渲染等能力。

如果按架构分层来看,大致可以抽象成这样:

页面入口
  ↓
聊天组件
  ↓
状态中心
  ↓
业务流程控制器
  ↓
AI 平台适配层
  ↓
网络请求封装
  ↓
SSE 流式响应
  ↓
结果回写状态
  ↓
UI 自动刷新

换成代码里的概念,大概就是:

Page
  ↓
View
  ↓
ViewModel
  ↓
Controller
  ↓
Provider
  ↓
HttpClient

这篇文章主要记录我目前对这个 AI 聊天模块的理解,重点不是某个具体业务,而是总结其中体现出来的一些工程化思想:MVVM、组件化、解耦、Provider 适配器、请求统一封装、SSE 流式处理、状态驱动 UI。


二、整体架构概览

一个完整的 AI 聊天模块,通常不会只写在一个页面文件里,而是会拆成多个层次。

大概可以理解为:

pages/
  页面入口,负责路由注册和业务配置

view/
  UI 组件层,负责聊天页面展示

viewmodel/
  状态管理层,负责保存页面状态

controller/
  业务流程编排层,负责发送消息、会话切换、语音输入等流程

api/
  AI 平台适配层,负责对接不同 AI 平台

utils/
  工具封装层,负责请求、解析、转换等能力

model/
  数据模型层,负责定义消息、会话、卡片等结构

constant/
  常量和协议层,负责统一管理接口路径、事件类型、状态码等

可以用一句话概括:

View 负责展示,ViewModel 负责状态,Controller 负责流程,Provider 负责平台适配,HttpClient 负责请求。

这样拆分之后,每一层的职责会更清楚,不会把 UI、状态、请求、协议解析、错误处理全部堆在一个页面文件里。


三、页面入口层:负责装配,不负责核心逻辑

页面入口层通常负责几件事:

  1. 注册路由
  2. 创建 AI Provider
  3. 配置聊天组件参数
  4. 注入业务回调
  5. 渲染聊天组件

可以简单理解成,页面入口不是聊天逻辑的核心,它更像是一个“组装器”。

例如:

@ComponentV2
export struct ChatPage {
  @Local provider: AgentProvider | null = null

  aboutToAppear(): void {
    const config = new ProviderConfig()
    config.userId = 'current_user_id'
    this.provider = new SomeAIProvider(config)
  }

  build() {
    AgentChatComp({
      provider: this.provider,
      chatConfig: this.buildChatConfig(),
      cardsBuilder: this.cardsBuilder,
      loadingBuilder: this.loadingBuilder
    })
  }

  private buildChatConfig(): ChatConfig {
    const config = new ChatConfig()
    config.welcomeMessage = '你好,我是你的 AI 助手'
    config.quickPhrases = ['推荐问题 1', '推荐问题 2']
    return config
  }
}

这里比较重要的一点是:

页面入口只负责装配,不负责聊天核心逻辑。

它不应该直接处理发送 AI 请求、解析 SSE、维护消息数组、处理会话分页、解析卡片 JSON、上传附件等逻辑。

这些复杂逻辑应该交给后面的组件层、状态层、Controller 层和 Provider 层。

页面入口主要做的是:

创建 Provider
配置 ChatConfig
传入 Builder
挂载聊天组件

这样页面会比较轻,后期业务变化时也更好维护。


四、聊天组件层:UI 总容器

聊天组件层负责搭建聊天页面的整体 UI。

一个完整聊天页面通常包括:

  • 消息列表
  • 底部输入框
  • 推荐问题
  • 会话抽屉
  • 加载蒙层
  • 语音输入蒙层
  • 浮动按钮
  • 业务卡片

结构大概可以理解为:

Stack 根容器
├── 背景层
├── 主内容层
│   ├── MessageList
│   ├── QuickQuestionsCard
│   ├── FloatingButtons
│   ├── InputBar
│   ├── VoiceMaskOverlay
│   └── LoadingOverlay
└── ConversationDrawer

聊天组件一般会接收外部传入的配置:

@Param provider: AgentProvider | null = null
@Param chatConfig: ChatConfig = new ChatConfig()
@Param bgColor: ResourceColor = ''
@BuilderParam cardsBuilder: (cards: AgentCard[]) => void = emptyCardsBuilder
@BuilderParam loadingBuilder: () => void = defaultLoadingBuilder

同时,组件内部会创建一个 ViewModel:

@Local vm: ChatViewModel = new ChatViewModel()

然后把这个 vm 传给各个子组件:

MessageList({ vm: this.vm })
InputBar({ vm: this.vm })
ConversationDrawer({ vm: this.vm })
LoadingOverlay({ vm: this.vm })
VoiceMaskOverlay({ vm: this.vm })

这说明聊天组件本身的职责主要是:

接收外部能力
创建状态中心
初始化 Controller
组合聊天 UI
把 ViewModel 分发给子组件

它本身不直接发请求,也不直接解析 AI 协议。


五、MVVM:UI 和业务之间加一层状态中介

这个模块里最明显的架构思想就是 MVVM。

MVVM 可以拆成:

Model       数据模型
View        UI 展示
ViewModel   状态和交互中介

放到聊天模块里,可以对应成:

View:
  AgentChatComp
  MessageList
  InputBar
  BotBubble
  ConversationDrawer

ViewModel:
  ChatViewModel

Model:
  ChatItem
  ChatMessage
  AgentCard
  ConversationInfo
  ChatConfig

我目前对 MVVM 的理解是:

UI 不直接干业务,业务也不直接操作 UI,中间通过 ViewModel 传状态和方法。

比如用户点击发送按钮时,UI 组件不应该自己去拼请求、调接口、解析数据,而是调用 ViewModel 暴露的方法:

this.vm.sendMessage()

然后 ViewModel 再把操作交给 Controller:

async sendMessage(): Promise<void> {
  if (this.chatController !== null) {
    await this.chatController.sendMessage()
  }
}

业务处理完成后,Controller 再回写 ViewModel:

this.vm.chatHistory = nextMessages
this.vm.loading = false

ViewModel 状态变化后,UI 自动刷新。

所以 MVVM 的重点不是“多建一个类”,而是把 UI 和业务流程隔开。

可以记成一句话:

ViewModel 负责把用户操作转成业务动作,再把业务结果转成 UI 可以直接使用的状态。


六、ChatViewModel:聊天页面的状态中心

ViewModel 是整个聊天页面的状态中心。

它通常会保存这些状态:

  • 用户输入内容
  • 聊天消息列表
  • 当前会话 ID
  • 会话列表
  • loading 状态
  • 初始化状态
  • 错误状态
  • 推荐问题
  • 待发送附件
  • 语音面板状态
  • 滚动状态

例如:

@ObservedV2
export class ChatViewModel {
  @Trace userInput: string = ''
  @Trace chatHistory: ChatItem[] = []
  @Trace loading: boolean = false
  @Trace conversationId: string = ''
  @Trace conversations: ConversationInfo[] = []
  @Trace quickPhrases: string[] = []
  @Trace pendingAttachments: AttachmentInfo[] = []
  @Trace showDrawer: boolean = false
  @Trace initialLoaded: boolean = false
  @Trace loadFailed: boolean = false
}

这里有两个比较关键的点:

@ObservedV2 修饰类
@Trace 修饰需要被 UI 追踪的状态字段

也就是说:

ViewModel 里的状态变化
  ↓
依赖它的 UI 组件自动刷新

例如:

InputBar 修改 vm.userInput
MessageList 读取 vm.chatHistory
ConversationDrawer 读取 vm.conversations
LoadingOverlay 读取 vm.initialLoaded / vm.loadFailed
VoiceMaskOverlay 读取 vm.showVoicePanel

这就是状态驱动 UI。

可以记成一句话:

UI 看 vm,Controller 改 vm。


七、Controller 层:复杂业务流程不要塞进 ViewModel

在简单页面里,ViewModel 可能直接写一些业务逻辑。

但是在 AI 聊天这种模块里,如果所有逻辑都写进 ViewModel,很容易变成一个特别大的类。

因为一次发送消息可能涉及很多步骤:

校验输入
处理附件
清空输入框
追加用户消息
设置 loading
创建 AI 回复占位
调用 AI 接口
处理 SSE delta
处理完整消息
解析卡片
处理停止生成
处理失败重试
同步会话
收集埋点
恢复状态

如果这些都写在 ViewModel 里,ViewModel 后期会非常难维护。

所以这里单独拆出 Controller 层:

ChatController
  负责发送消息、停止生成、重试、流式回复

ConversationController
  负责会话列表、会话切换、删除会话、分页加载

VoiceInputController
  负责语音输入、录音状态、语音识别

ProgressiveRevealController
  负责卡片或内容的渐进展示

我对这个分层的理解是:

ViewModel 管状态,Controller 管流程。

发送消息的大致流程可以理解为:

InputBar 点击发送
  ↓
vm.sendMessage()
  ↓
ChatController.sendMessage()
  ↓
读取 vm.userInput
  ↓
创建用户消息
  ↓
写入 vm.chatHistory
  ↓
设置 vm.loading = true
  ↓
调用 provider.sendMessage()
  ↓
onDelta 更新 AI 气泡
  ↓
onMessage 处理完整消息和卡片
  ↓
onReplyComplete 收尾
  ↓
vm.loading = false

这样做的好处是:

  • UI 不关心请求细节
  • ViewModel 不承载复杂流程
  • Controller 专门负责把一次业务动作跑完整

八、Provider 层:平台适配与面向接口编程

AI 聊天模块可能接入不同平台,例如 Coze、Dify、OpenAI、MockProvider,或者公司内部 AI 服务。

如果 UI 组件直接依赖某个平台实现,就会产生强耦合。

例如下面这种写法就不太好:

const provider = new CozeProvider()
provider.sendMessage()

这样组件就和 Coze 绑死了。以后如果要换成 Dify,或者换成内部 AI 服务,就要改组件代码。

更好的方式是抽象一个统一接口或抽象类:

export abstract class AgentProvider {
  abstract getName(): string

  abstract sendMessage(
    message: string,
    conversationId: string,
    attachments: AgentAttachment[],
    onDelta: (delta: string, fullText: string) => void,
    onStatus?: (status: string) => void,
    onMessage?: (content: string, msgId: string) => void,
    onReplyComplete?: () => void
  ): Promise<AgentResult>
}

然后具体平台去继承它:

export class CozeProvider extends AgentProvider {
  getName(): string {
    return 'Coze'
  }

  async sendMessage(...): Promise<AgentResult> {
    // 这里写 Coze 平台的具体请求逻辑
  }
}

上层组件只依赖抽象:

@Param provider: AgentProvider | null = null

这样就变成:

传 CozeProvider,就接 Coze
传 DifyProvider,就接 Dify
传 MockProvider,就可以做测试

这就是典型的解耦。

可以总结为:

组件不关心具体实现类,只关心传入对象是否满足它依赖的接口或抽象类。


九、abstract 的意义:只定规则,不干具体活

在 Provider 抽象中,经常会看到 abstract 关键字。

例如:

abstract class AgentProvider {
  abstract getName(): string
  abstract sendMessage(...): Promise<AgentResult>
}

abstract 的意思是:

抽象的,只定义规范,不提供完整实现。

抽象类不能直接 new

const provider = new AgentProvider() // 不允许

它的作用是规定子类必须实现哪些方法。

比如:

任何 AI Provider 都必须有 getName()
任何 AI Provider 都必须有 sendMessage()

具体实现交给子类:

class CozeProvider extends AgentProvider {
  getName(): string {
    return 'Coze'
  }

  async sendMessage(...): Promise<AgentResult> {
    // 具体平台逻辑
  }
}

所以 abstract 在架构上的作用是:

定义统一规范
约束子类实现
让上层依赖抽象,而不是依赖具体类

一句话记忆:

abstract = 只定规则,不干具体活。


十、解耦:依赖抽象,而不是依赖具体实现

解耦不是简单地“多拆几个文件”。

真正的解耦,是降低模块之间的依赖关系。

一个比较好的分层应该是:

UI 层不关心请求细节
Controller 不关心底层 HTTP 实现
Provider 不关心 UI 怎么展示
HttpClient 不关心业务含义
Parser 不关心卡片怎么显示

例如:

ChatController:
我要发送消息,但我不关心你是 Coze 还是 OpenAI。

Provider:
我知道某个平台接口怎么调用,但我不关心 UI 怎么展示。

HttpClient:
我只负责发请求和解析基础流,不关心这是不是 AI 消息。

MessageList:
我只负责展示消息,不关心这条消息怎么从服务端来的。

如果不解耦,代码很容易变成:

一个页面里写 UI
一个页面里写状态
一个页面里写请求
一个页面里写 SSE
一个页面里写卡片解析
一个页面里写错误处理
一个页面里写埋点

短期可能能跑,但后期基本不好维护。

解耦后的结构是:

Page        入口和配置
View        展示
ViewModel   状态
Controller  流程
Provider    平台协议
HttpClient  请求
Parser      解析
Model       数据结构

我现在对解耦的理解是:

解耦就是让每一层只知道自己必须知道的东西。


十一、HAR 共享包:模块复用的工程结构

在 HarmonyOS 工程里,AI 聊天模块可以做成 HAR 共享包。

HAR 可以理解成:

HarmonyOS 里的共享代码包 / 组件库包 / 模块包。

它不是 exe,也不是可以双击运行的程序。

它更接近于:

前端里的 npm package
Android 里的 AAR
Java 里的 JAR

HAR 通常可以用来封装:

  • 公共组件
  • 工具方法
  • 业务模块
  • 页面能力
  • 网络请求封装
  • 数据模型
  • 资源文件

例如在模块入口统一导出能力:

export { AgentChatComp } from './view/AgentChatComp'
export { AgentProvider } from './api/AgentProvider'
export { CozeProvider } from './api/CozeProvider'
export { HttpClient } from './utils/HttpClient'
export { ChatViewModel } from './viewmodel/ChatViewModel'

主项目里只需要这样使用:

import { AgentChatComp, CozeProvider } from 'ai_chat_module'

HAR 的价值主要是:

复用
模块化
隔离业务
减少重复代码
方便维护

这里也可以区分一下 HAR 和解耦:

HAR 是工程结构上的模块拆分
解耦是代码设计上的职责拆分

也就是说:

HAR 让代码从工程上独立出来,解耦让代码从职责上独立出来。


十二、HttpClient:网络请求统一出口

在真实项目里,不应该到处直接写 HTTP 请求,而是应该有统一的请求封装层。

一个请求封装类通常会提供:

get()
post()
put()
upload()
stream()
abortStream()

它负责处理:

  • 普通请求
  • 文件上传
  • SSE 流式请求
  • 请求中断
  • 请求日志
  • 敏感信息脱敏
  • 超时处理
  • 错误处理

也就是说:

Provider 不直接碰底层 HTTP,Provider 只调用 HttpClient。

这样可以统一处理请求头、日志、错误、超时、中断等公共问题。

1. 普通请求

普通请求比较好理解:

传入 URL
传入 header
传入 body
发出请求
拿到响应
返回结果

2. 文件上传

文件上传通常会走 multipart/form-data

流程大概是:

读取本地文件
  ↓
构造 multipart/form-data
  ↓
上传到文件接口
  ↓
拿到 file_id
  ↓
聊天请求里传 file_id

也就是说,带附件聊天时,通常不是直接把本地路径传给 AI 接口,而是:

先上传文件,拿到 file_id,再把 file_id 放进聊天请求。

3. SSE 流式请求

AI 打字机效果一般不是前端自己用定时器假装输出,而是服务端通过 SSE 不断推送内容。

普通 HTTP 是:

请求一次
返回一次
结束

SSE 更像是:

请求一次
服务端不断返回 event
前端不断解析并更新 UI

十三、SSE 流式响应:AI 打字机效果的底层基础

SSE 全称是:

Server-Sent Events

常见格式大概是:

event: conversation.message.delta
data: {"content":"你好"}

event: conversation.message.delta
data: {"content":",我是 AI 助手"}

event: conversation.message.completed
data: {"finish_reason":"stop"}

AI 流式回复大概是:

服务端返回一小段
  ↓
前端解析一小段
  ↓
更新 AI 气泡
  ↓
再返回一小段
  ↓
再更新 AI 气泡

用户看到的效果就是“AI 正在打字”。

但是这里有一个细节比较重要:

网络流返回的数据块,不一定刚好是一个完整的 SSE 事件。

服务端可能发送的是一个完整事件:

event: xxx
data: {"content":"你好"}

但是客户端实际收到时,可能会被拆成几块:

第 1 块:event: x
第 2 块:xx\ndata: {"content"
第 3 块::"你好"}\n\n

所以前端不能拿到一块数据就直接解析,而是要做 buffer 拼包。

大概流程是:

1. 接收二进制数据块
2. 转成字符串
3. 放进 sseBuffer
4. 按空行 \n\n 拆完整事件
5. 解析 event 和 data
6. 回调给 Provider

这就是流式请求封装层的核心。

可以总结成一句话:

网络数据块不等于完整 SSE 事件,所以要先拼包再解析。


十四、停止生成:不是只改 UI 状态

AI 聊天里常见一个功能:停止生成。

一开始很容易以为停止生成只是:

loading = false

但实际上这不完整。

一个更完整的停止流程应该是:

用户点击停止生成
  ↓
Controller 调用 stopGenerate()
  ↓
Provider 调用 cancelChat()
  ↓
HttpClient.abortStream() 中断本地 SSE
  ↓
Provider 通知服务端取消当前生成
  ↓
保留已经生成的部分文本
  ↓
恢复 loading 状态

这里还要区分两种情况:

用户主动停止
网络异常中断

用户主动停止时,不应该提示“网络错误”。

网络异常中断时,才需要提示失败或者允许重试。

因此可以定义一个特殊错误类型:

StreamAbortedError

用来表示用户主动取消。


十五、请求日志与敏感信息脱敏

真实项目里,请求日志很重要。

但是日志不能随便打印敏感信息。

常见敏感字段有:

authorization
cookie
token
openId
x-api-key
set-cookie

请求封装层应该统一做脱敏处理。

例如日志中只显示:

authorization: *** (len=32)

而不是打印真实 token。

这里体现的是工程安全意识:

日志要能帮助排查问题,但不能泄露用户身份、token、cookie 等敏感信息。

这也是 Demo 代码和真实业务代码的一个明显区别。


十六、数据模型:让消息、会话、卡片结构清晰

AI 聊天模块里常见的数据模型包括:

ChatItem
  UI 上展示的一条消息

ChatMessage
  普通消息数据

AgentCard
  AI 返回的业务卡片

ConversationInfo
  会话信息

ChatConfig
  聊天组件配置

AgentResult
  AI 返回结果

AgentAttachment
  附件信息

这些模型的意义是:

不要让所有数据都用 any 或 JSON 字符串到处乱传,而是定义清楚每种数据长什么样。

例如:

export class ChatItem {
  role: string = ''
  content: string = ''
  time: string = ''
  cards: AgentCard[] = []
  attachments: AttachmentInfo[] = []
}

这样 UI 层、Controller 层、Provider 层之间传递数据时会更清楚。


十七、数组更新与响应式刷新

在 ArkUI 响应式场景中,数组更新要特别注意。

例如直接这样写:

this.chatHistory.push(newItem)

有时不如替换数组引用稳定。

更推荐:

this.chatHistory = this.chatHistory.concat(newItem)

或者:

this.chatHistory = this.chatHistory.map(item => {
  if (item.id === targetId) {
    return {
      ...item,
      content: newContent
    }
  }
  return item
})

核心思想是:

通过生成新数组,改变引用,触发 UI 更稳定刷新。

这和聊天打字机效果也有关系。

例如 AI 回复时,如果只是修改同一个对象里的 content,但列表 key 没变,有时 UI 不一定按预期刷新。

可以通过下面几种方式保证刷新稳定:

更新数组引用
更新对象引用
合理设置 ForEach key

十八、Builder 参数:让组件支持自定义 UI

聊天模块中经常需要支持业务卡片。

不同业务返回的卡片可能完全不同:

路线卡片
景点卡片
票务卡片
商品卡片
订单卡片
推荐问题卡片

如果聊天组件内部写死所有卡片 UI,组件就很难复用。

更好的方式是通过 Builder 参数传入:

@BuilderParam cardsBuilder: (cards: AgentCard[]) => void

外部页面自己决定这些卡片怎么画:

@Builder
cardsBuilder(cards: AgentCard[]) {
  RouteCardList({
    cards: cards.filter(item => item.cardType === 'route')
  })

  PoiCardList({
    cards: cards.filter(item => item.cardType === 'poi')
  })
}

这样聊天组件只负责:

我有一批 cards
我把它交给外部 cardsBuilder

它不关心具体业务卡片长什么样。

这也是一种解耦:

聊天组件负责通用聊天能力
业务页面负责具体业务卡片展示

十九、ChatConfig:把业务差异配置化

一个通用聊天组件不应该写死所有业务行为。

可以通过 ChatConfig 注入不同业务需要的配置:

  • 欢迎语
  • 推荐问题
  • 动态推荐问题加载
  • 抽屉配置
  • 链接点击回调
  • 埋点回调
  • 业务跳转回调
  • loading 样式
  • 错误样式

例如:

const config = new ChatConfig()
config.welcomeMessage = '你好,我是 AI 助手'
config.quickPhrases = ['问题 1', '问题 2']
config.onLinkClick = (url, text) => {
  // 业务页面决定怎么打开链接
}
config.onTrackEvent = (event) => {
  // 业务页面决定怎么上报埋点
}

这样组件内部不用关心具体业务平台怎么跳转、怎么埋点、怎么生成推荐问题。

可以总结为:

通用组件通过配置和回调接入业务能力。


二十、完整主链路

到这里,可以把整个 AI 聊天模块的主链路串起来:

用户输入问题
  ↓
InputBar 触发发送
  ↓
ChatViewModel 暴露 sendMessage 入口
  ↓
ChatController 编排发送流程
  ↓
创建用户消息,写入 chatHistory
  ↓
设置 loading 状态
  ↓
调用 AgentProvider.sendMessage
  ↓
具体 Provider 构造平台请求
  ↓
HttpClient.stream 发起 SSE 请求
  ↓
服务端不断返回 event/data
  ↓
HttpClient 解析出 eventType 和 eventData
  ↓
Provider 解析平台协议
  ↓
onDelta 回调给 ChatController
  ↓
ChatController 更新 AI 消息内容
  ↓
ChatViewModel 状态变化
  ↓
MessageList / BotBubble 自动刷新
  ↓
回复完成后收尾,恢复 loading

这条链路很重要。

只要能把这条链路讲清楚,大多数 AI 聊天项目的结构就不会看乱。


二十一、这套架构体现的核心思想

1. MVVM

View 负责展示
ViewModel 负责状态
Model 负责数据结构

2. Controller 编排

复杂业务流程不要全部塞进 ViewModel。

Controller 负责把一次完整业务流程串起来。

3. Provider 适配器

上层依赖统一接口。

不同 AI 平台各自实现自己的 Provider。

4. 请求统一封装

所有网络请求集中在 HttpClient。

统一处理:

日志
错误
上传
SSE
中断
超时
脱敏

5. 组件化

大页面拆成:

消息列表
输入栏
抽屉
气泡
卡片
蒙层

每个组件只负责自己的展示和交互。

6. 配置化

业务差异通过 ChatConfigBuilderParam 注入。

通用组件不写死业务逻辑。

7. 解耦

UI 不关心请求
请求不关心 UI
Provider 不关心展示
HttpClient 不关心业务语义

二十二、可以背下来的架构口诀

Page 负责入口
Comp 负责 UI
ViewModel 负责状态
Controller 负责编排
Provider 负责平台适配
HttpClient 负责请求
Parser 负责解析
Model 负责数据结构
Card 负责展示

再短一点:

UI 看 vm,Controller 改 vm,Provider 接平台,HttpClient 发请求。

再总结成一句:

把用户看到的 UI、页面状态、业务流程、平台协议和网络请求拆开,各自负责自己的事情。


二十三、实习阶段应该怎么读这种项目

第一次看公司项目,不要试图一次看懂所有文件。

我觉得可以按这个顺序来:

第一遍:看目录结构,知道每个目录大概干什么
第二遍:看页面入口,知道从哪里进来
第三遍:看核心组件,知道 UI 怎么组合
第四遍:看 ViewModel,知道状态有哪些
第五遍:看 Controller,知道一次业务流程怎么跑
第六遍:看 Provider,知道接口平台怎么适配
第七遍:看 HttpClient,知道请求和 SSE 怎么封装
第八遍:看 Parser 和 Card,知道数据怎么渲染成业务 UI

不要一开始就陷进某个很长的文件里。

应该先建立地图:

入口在哪?
状态在哪?
发送从哪开始?
请求在哪发?
结果回到哪里?
UI 怎么刷新?

有了主线之后,再逐个文件深入。


二十四、当前阶段学习总结

通过这一阶段的源码阅读,我对一个 AI 聊天模块的工程化设计有了更清楚的认识。

它并不是简单地在页面里写一个输入框和消息数组,而是通过多层架构把复杂能力拆开:

页面入口负责装配
聊天组件负责 UI 总装
ViewModel 负责状态管理
Controller 负责业务流程
Provider 负责 AI 平台适配
HttpClient 负责网络请求和 SSE
Model 负责数据结构
Builder 负责业务 UI 扩展

这套结构里体现了很多常见的工程化思想:

MVVM
解耦
面向接口编程
组件化
配置化
请求统一封装
状态驱动 UI

对于实习阶段来说,我觉得目前最重要的不是马上把每个方法都背下来,而是先理解:

为什么要这么分层?
每一层负责什么?
一次发送消息从 UI 到请求再回到 UI 是怎么流转的?

理解了这些,再去看具体方法实现,就不会那么容易迷路。


二十五、最终总结

一个复杂 AI 聊天模块的核心,不只是“页面怎么画”,而是如何把 UI、状态、业务流程、接口协议、网络请求、数据解析拆清楚。

其中:

MVVM 解决 UI 和状态之间的关系
Controller 解决复杂业务流程编排的问题
Provider 解决不同 AI 平台适配的问题
HttpClient 解决请求统一封装和 SSE 流式响应的问题
Builder 和 Config 解决组件可扩展和业务差异注入的问题
HAR 共享包解决模块级复用的问题

最终形成的结构可以概括为:

通过 ViewModel 打通 UI 和状态
通过 Controller 编排业务流程
通过 Provider 屏蔽平台差异
通过 HttpClient 统一网络请求
通过 Config 和 Builder 保持组件可复用

这就是目前从 AI 聊天模块源码中总结出来的主要架构理解。

❌
❌