阅读视图

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

构建无障碍组件之Spinbutton Pattern

Spinbutton Pattern 详解:构建无障碍数字输入控件

Spinbutton(旋转按钮,也称为 Number InputStepperNumeric SpinnerCounter)是一种输入控件,用于在预定义范围内选择离散数值。本文基于 W3C WAI-ARIA Spinbutton Pattern 规范,详解如何构建无障碍的数字输入组件。

一、Spinbutton 的定义与核心概念

1.1 什么是 Spinbutton

Spinbutton 是一种受限的数字输入控件,具有以下特征:

  • 值被限制在一组或一个范围内的离散值
  • 通常包含三个组件:
    • 文本输入框:显示当前值,通常是唯一可聚焦的组件
    • 增加按钮:用于增加数值
    • 减少按钮:用于减少数值
  • 支持直接编辑按钮调整两种方式
  • 支持小步长大步长调整

1.2 核心术语

术语 说明
Text Field 显示当前值的文本输入框
Increase Button 增加数值的按钮
Decrease Button 减少数值的按钮
Small Step 小步长调整(如按 1 增减)
Large Step 大步长调整(如按 10 增减)
Valid Value 允许范围内的有效值
┌─────────────────────────────────────────────────────────────┐
│                      Spinbutton Container                   │
│                                                             │
│  ┌─────────────────────────────────────────────────────┐    │
│  │                                                     │    │
│  │  ┌─────────────────┐  ┌──────┐  ┌──────┐            │    │
│  │  │                 │  │  ▲   │  │  ▼   │            │    │
│  │  │   Value: 30     │  │  +   │  │  -   │            │    │
│  │  │                 │  │      │  │      │            │    │
│  │  └─────────────────┘  └──────┘  └──────┘            │    │
│  │                                                     │    │
│  │  ┌─────────────────────────────────────────────┐    │    │
│  │  │  role="spinbutton"                          │    │    │
│  │  │  aria-valuenow="30"                         │    │    │
│  │  │  aria-valuemin="0"                          │    │    │
│  │  │  aria-valuemax="100"                        │    │    │
│  │  │  aria-label="Quantity"                      │    │    │
│  │  └─────────────────────────────────────────────┘    │    │
│  │                                                     │    │
│  └─────────────────────────────────────────────────────┘    │
│                                                             │
│  Keyboard: ↑↓ (±1) | Page Up/Down (±10) | Home/End (Min/Max)│
│                                                             │
└─────────────────────────────────────────────────────────────┘

1.3 典型应用场景

  • 数量选择器:购物车商品数量、酒店预订人数
  • 时间选择器:小时、分钟选择
  • 日期选择器:日、月、年选择
  • 数值调节:音量控制、亮度调节
  • 评分输入:1-5 星评分

二、WAI-ARIA 角色与属性

2.1 基本角色

Spinbutton 使用 role="spinbutton" 标记。

<input
  type="text"
  role="spinbutton"
  aria-label="数量"
  aria-valuenow="1"
  aria-valuemin="0"
  aria-valuemax="10"
  value="1" />

2.2 必需属性

属性 说明 示例值
role="spinbutton" 标记为旋转按钮角色 -
aria-valuenow 当前值 "1"
aria-valuemin 最小值(如果有) "0"
aria-valuemax 最大值(如果有) "10"
aria-labelaria-labelledby 可访问标签 "数量"

2.3 可选属性

属性 说明 示例值
aria-valuetext 用户友好的值描述 "Monday"
aria-invalid 值是否无效 "true" / "false"

2.4 属性详解

aria-valuetext

aria-valuenow 的值不够友好时,使用 aria-valuetext 提供更易理解的描述:

<!-- 星期选择器:数值 1 显示为 "Monday" -->
<input
  type="text"
  role="spinbutton"
  aria-label="星期"
  aria-valuenow="1"
  aria-valuemin="1"
  aria-valuemax="7"
  aria-valuetext="Monday"
  value="Monday" />
aria-invalid

当值超出允许范围时,设置 aria-invalid="true"

<input
  type="text"
  role="spinbutton"
  aria-label="数量"
  aria-valuenow="15"
  aria-valuemin="0"
  aria-valuemax="10"
  aria-invalid="true"
  value="15" />

注意:大多数实现会阻止输入无效值,但在某些场景下可能无法完全阻止。

三、键盘交互规范

3.1 基本键盘交互

按键 功能
↑ Up Arrow 增加数值(小步长)
↓ Down Arrow 减少数值(小步长)
Home 设置值为最小值
End 设置值为最大值
Page Up(可选) 增加数值(大步长)
Page Down(可选) 减少数值(大步长)

3.2 文本编辑键盘交互

如果文本框允许直接编辑,还支持以下标准单行文本编辑键:

  • 可打印字符:在文本框中输入字符
  • 光标移动键:左右箭头、Home、End
  • 选择键:Shift + 方向键
  • 文本操作键:复制、粘贴、删除等

重要提示:确保 JavaScript 不干扰浏览器提供的文本编辑功能。

3.3 焦点行为

  • 操作过程中焦点始终保持在文本框
  • 不需要将焦点移到增减按钮上

四、实现方式

4.1 基础 Spinbutton 结构

<div class="spinbutton-container">
  <label for="quantity">数量</label>
  <div class="spinbutton-wrapper">
    <input
      type="text"
      id="quantity"
      class="spinbutton"
      role="spinbutton"
      aria-label="数量"
      aria-valuenow="1"
      aria-valuemin="0"
      aria-valuemax="10"
      value="1" />
    <div class="spinbutton-buttons">
      <button
        type="button"
        class="spinbutton-up"
        aria-label="增加"
        tabindex="-1"></button>
      <button
        type="button"
        class="spinbutton-down"
        aria-label="减少"
        tabindex="-1"></button>
    </div>
  </div>
</div>

4.2 JavaScript 实现

class Spinbutton {
  constructor(element) {
    this.input = element;
    this.min = parseFloat(this.input.getAttribute('aria-valuemin')) || 0;
    this.max = parseFloat(this.input.getAttribute('aria-valuemax')) || 100;
    this.smallStep = 1;
    this.largeStep = 10;

    this.init();
  }

  init() {
    // 键盘事件
    this.input.addEventListener('keydown', this.handleKeyDown.bind(this));

    // 直接编辑
    this.input.addEventListener('change', this.handleChange.bind(this));
    this.input.addEventListener('blur', this.handleBlur.bind(this));

    // 按钮点击
    const container = this.input.closest('.spinbutton-wrapper');
    const upButton = container.querySelector('.spinbutton-up');
    const downButton = container.querySelector('.spinbutton-down');

    if (upButton) {
      upButton.addEventListener('click', () => this.increment(this.smallStep));
    }
    if (downButton) {
      downButton.addEventListener('click', () =>
        this.decrement(this.smallStep),
      );
    }
  }

  handleKeyDown(e) {
    const currentValue =
      parseFloat(this.input.getAttribute('aria-valuenow')) || 0;

    switch (e.key) {
      case 'ArrowUp':
        e.preventDefault();
        this.increment(this.smallStep);
        break;
      case 'ArrowDown':
        e.preventDefault();
        this.decrement(this.smallStep);
        break;
      case 'PageUp':
        e.preventDefault();
        this.increment(this.largeStep);
        break;
      case 'PageDown':
        e.preventDefault();
        this.decrement(this.largeStep);
        break;
      case 'Home':
        e.preventDefault();
        this.setValue(this.min);
        break;
      case 'End':
        e.preventDefault();
        this.setValue(this.max);
        break;
    }
  }

  handleChange() {
    const value = parseFloat(this.input.value);
    if (!isNaN(value)) {
      this.setValue(value);
    }
  }

  handleBlur() {
    // 失去焦点时验证并修正值
    const value = parseFloat(this.input.value);
    if (isNaN(value)) {
      this.setValue(this.min);
    } else {
      this.setValue(value);
    }
  }

  increment(step) {
    const currentValue =
      parseFloat(this.input.getAttribute('aria-valuenow')) || 0;
    this.setValue(currentValue + step);
  }

  decrement(step) {
    const currentValue =
      parseFloat(this.input.getAttribute('aria-valuenow')) || 0;
    this.setValue(currentValue - step);
  }

  setValue(value) {
    // 限制在范围内
    value = Math.max(this.min, Math.min(this.max, value));

    // 更新 ARIA 属性
    this.input.setAttribute('aria-valuenow', value);

    // 更新显示值
    this.input.value = value;

    // 更新有效性状态
    const isValid = value >= this.min && value <= this.max;
    this.input.setAttribute('aria-invalid', !isValid);
  }
}

// 初始化
const spinbuttons = document.querySelectorAll('[role="spinbutton"]');
spinbuttons.forEach((spinbutton) => new Spinbutton(spinbutton));

4.3 带 aria-valuetext 的示例

class WeekdaySpinbutton extends Spinbutton {
  constructor(element) {
    super(element);
    this.weekdays = [
      '',
      'Monday',
      'Tuesday',
      'Wednesday',
      'Thursday',
      'Friday',
      'Saturday',
      'Sunday',
    ];
    this.smallStep = 1;
    this.largeStep = 1; // 星期没有大步长
  }

  setValue(value) {
    // 限制在范围内
    value = Math.max(this.min, Math.min(this.max, value));

    // 更新 ARIA 属性
    this.input.setAttribute('aria-valuenow', value);
    this.input.setAttribute('aria-valuetext', this.weekdays[value]);

    // 显示星期名称
    this.input.value = this.weekdays[value];
  }
}

五、最佳实践

5.1 提供清晰的标签

始终为 Spinbutton 提供描述性的标签:

<!-- 好的示例 -->
<label for="adults">成人数量</label>
<input
  type="text"
  id="adults"
  role="spinbutton"
  aria-label="成人数量"
  ... />

<!-- 不好的示例 -->
<input
  type="text"
  role="spinbutton"
  ... />

5.2 设置合理的范围

根据实际场景设置最小值和最大值:

<!-- 好的示例:酒店预订成人数量 -->
<input
  type="text"
  role="spinbutton"
  aria-label="成人数量"
  aria-valuemin="1"
  aria-valuemax="10"
  ... />

<!-- 不好的示例:没有限制 -->
<input
  type="text"
  role="spinbutton"
  ... />

5.3 使用 aria-valuetext 增强可读性

当数值不够直观时,使用 aria-valuetext

<!-- 好的示例:月份选择 -->
<input
  type="text"
  role="spinbutton"
  aria-label="月份"
  aria-valuenow="1"
  aria-valuemin="1"
  aria-valuemax="12"
  aria-valuetext="January"
  value="January" />

5.4 验证用户输入

阻止无效字符输入,或在失去焦点时修正值:

// 阻止非数字输入
spinbutton.addEventListener('keypress', (e) => {
  if (!/\d/.test(e.key)) {
    e.preventDefault();
  }
});

// 失去焦点时验证
spinbutton.addEventListener('blur', () => {
  const value = parseInt(spinbutton.value);
  if (isNaN(value) || value < min || value > max) {
    // 修正为有效值
    setValue(Math.max(min, Math.min(max, value || min)));
  }
});

5.5 考虑移动端体验

在移动设备上,考虑使用数字键盘:

<input
  type="number"
  inputmode="numeric"
  pattern="[0-9]*"
  role="spinbutton"
  ... />

5.6 提供视觉反馈

  • 无效值时显示错误状态
  • 焦点状态清晰可见
  • 按钮悬停效果
[role='spinbutton'][aria-invalid='true'] {
  border-color: #ef4444;
  background-color: #fef2f2;
}

[role='spinbutton']:focus {
  outline: 2px solid #3b82f6;
  outline-offset: 2px;
}

六、常见错误

6.1 忘记设置 aria-valuenow

<!-- 错误 -->
<input
  type="text"
  role="spinbutton"
  value="5" />

<!-- 正确 -->
<input
  type="text"
  role="spinbutton"
  aria-valuenow="5"
  value="5" />

6.2 按钮可聚焦

<!-- 错误:按钮不应该可聚焦 -->
<button class="spinbutton-up"></button>

<!-- 正确:按钮设置 tabindex="-1" -->
<button
  class="spinbutton-up"
  tabindex="-1"></button>

6.3 忽略键盘交互

只实现按钮点击,不实现键盘支持(方向键、Home/End)。

6.4 不验证输入值

允许用户输入超出范围的值或无效字符。

七、Spinbutton vs 其他输入控件

7.1 Spinbutton vs Slider

特性 Spinbutton Slider
输入方式 键盘输入 + 按钮 拖拽滑块
适用场景 精确数值、离散值 连续范围、粗略选择
精度 中等
典型用例 数量、时间 音量、亮度

7.2 Spinbutton vs 普通文本输入

特性 Spinbutton 普通文本输入
值限制 有最小/最大值 无限制
步长调整 支持 不支持
辅助技术 读出当前值和范围 只读出文本
典型用例 年龄、评分 姓名、地址

八、总结

构建无障碍的 Spinbutton 组件需要关注:

  1. 正确的角色:使用 role="spinbutton"
  2. 必需的属性aria-valuenowaria-valueminaria-valuemaxaria-label
  3. 可选属性aria-valuetextaria-invalid
  4. 完整的键盘支持:方向键调整、Page Up/Down 大步长、Home/End 快捷键
  5. 直接编辑支持:允许用户直接输入值
  6. 输入验证:阻止无效字符,修正超出范围的值
  7. 清晰的标签:帮助用户理解控件用途
  8. 按钮不可聚焦:只有文本框可聚焦

遵循 W3C Spinbutton Pattern 规范,我们能够创建既实用又无障碍的数字输入控件,为所有用户提供便捷的数值选择体验。

文章同步于 an-Onion 的 Github。码字不易,欢迎点赞。

前端快速上手保姆级教程day5: 响应式布局

Day5 学习文档:响应式布局(Responsive)

1. 今天要掌握什么

Day5 的目标是:同一套页面在手机、平板、桌面都可读、可点、可用。

  • 理解“响应式”不是缩放,而是布局策略切换
  • 掌握媒体查询 @media 的基本写法
  • 学会移动优先(mobile-first)思路
  • 能处理 3 个高频问题:横向滚动、图片溢出、点击区域过小

2. 什么是响应式(大白话)

大白话:
页面会根据屏幕宽度,自动换一套更合适的排版。

不是把桌面页面硬缩小,而是让布局“变形”:

  • 手机:单列优先,按钮更大,字更清楚
  • 平板:间距更宽,内容更舒展
  • 桌面:可以多列,提高信息密度

3. 核心概念

3.1 视口(viewport)

你看到网页的窗口区域。移动端一定要有:

<meta name="viewport" content="width=device-width, initial-scale=1.0">

否则手机会用“虚拟宽屏”渲染,页面会小得像蚂蚁。

3.2 断点(breakpoint)

断点就是“在哪个宽度开始切换布局”的分界线。

常见练习断点(不是唯一标准):

  • 375:小手机
  • 768:平板
  • 1024:桌面

3.3 媒体查询(media query)

@media (min-width: 768px) {
  /* 宽度 >= 768 时生效 */
}

4. 移动优先(mobile-first)

推荐顺序:

  1. 先写手机默认样式(不加媒体查询)
  2. 再用 min-width 给平板/桌面增强

好处:

  • 代码更清晰,层层增强
  • 小屏体验不会被遗漏

示例:

/* 默认:手机 */
.card-list {
  display: grid;
  grid-template-columns: 1fr;
  gap: 12px;
}

/* 平板及以上 */
@media (min-width: 768px) {
  .card-list {
    grid-template-columns: repeat(2, 1fr);
  }
}

/* 桌面及以上 */
@media (min-width: 1024px) {
  .card-list {
    grid-template-columns: repeat(3, 1fr);
  }
}

4.1 Grid 布局核心知识(Day5 重点补充)

你在 Day5 页面里已经用了 display: grid,这里把 Grid 的核心概念补齐。

4.1.1 Grid 是什么?

大白话:
Grid 是“切格子”的布局系统,适合做二维布局(行 + 列一起控制)。

你可以把容器想象成棋盘,把内容块放进格子里。

4.1.2 三个最常用属性

.cards {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 12px;
}
  • display: grid:开启 Grid 布局
  • grid-template-columns:定义列数与列宽
  • gap:网格项之间的间距

4.1.3 1fr 是什么?

fr = fraction(份数)。
1fr 1fr 1fr 就是“分成 3 份,每份一样宽”。

示例:

  • grid-template-columns: 1fr 1fr -> 两列等宽
  • grid-template-columns: 2fr 1fr -> 左边是右边 2 倍宽

4.1.4 repeat() 为什么常用?

grid-template-columns: repeat(3, 1fr);

等价于:

grid-template-columns: 1fr 1fr 1fr;

repeat() 更简洁,改列数也更方便。

4.1.5 你当前 Day5 的 Grid 切换逻辑

/* 手机 */
.cards { grid-template-columns: 1fr; }

/* 平板 */
@media (min-width: 768px) {
  .cards { grid-template-columns: repeat(2, 1fr); }
}

/* 桌面 */
@media (min-width: 1024px) {
  .cards { grid-template-columns: repeat(3, 1fr); }
}

这段就是标准响应式 Grid:
先单列,再多列,跟屏幕宽度同步增强。

4.1.6 Grid 和 Flex 怎么选?

  • 一维排队(单行/单列内部对齐) -> 优先 Flex
  • 二维切区(行列同时控制) -> 优先 Grid

Day5 常见组合:

  • 顶部导航用 Flex
  • 卡片区用 Grid

4.1.7 Grid 常见坑

  1. 列太多,小屏被挤爆

    • 解法:移动端先单列,断点再加列
  2. 忘记设置 gap,卡片贴太紧

    • 解法:统一用 gap 管理间距
  3. 子项内容太长撑破格子

    • 解法:检查内容换行、图片自适应、最小宽度策略

5. Day5 三大高频问题与解法

5.1 横向滚动条(最常见)

常见原因:

  • 写死宽度(例如 width: 960px
  • 元素 width: 100% 同时大 padding(没用 border-box
  • fixed 元素太宽或位置越界

排查建议:

  • 先检查“谁比屏幕宽”
  • DevTools 切 375 宽度逐个排查容器

5.2 图片撑爆容器

统一加:

img {
  max-width: 100%;
  height: auto;
  display: block;
}

5.3 手机点击困难

建议:

  • 文字不小于 14px(正文建议 16px)
  • 按钮/链接适当增大 padding
  • 相邻可点击元素留足间距

6. 你可以直接套用的响应式骨架

*,
*::before,
*::after {
  box-sizing: border-box;
}

body {
  margin: 0;
  line-height: 1.5;
}

.container {
  width: min(100%, 960px);
  margin: 0 auto;
  padding: 12px;
}

/* 手机:默认单列 */
.layout {
  display: grid;
  grid-template-columns: 1fr;
  gap: 12px;
}

/* 平板 */
@media (min-width: 768px) {
  .container {
    padding: 16px;
  }
  .layout {
    grid-template-columns: repeat(2, 1fr);
  }
}

/* 桌面 */
@media (min-width: 1024px) {
  .container {
    padding: 20px;
  }
  .layout {
    grid-template-columns: repeat(3, 1fr);
  }
}

7. Day5 自测清单

  • 375 / 768 / 1024 三档可正常阅读
  • 页面无横向滚动条
  • 图片不会撑爆容器
  • 导航和按钮在手机上容易点击
  • 至少 2 个模块实现断点切换

8. 常见误区

  1. 只在桌面看效果

    • 结果:上线后手机体验崩
  2. 断点过多且混乱

    • 建议先用 2~3 个关键断点
  3. 只改字体不改布局

    • 响应式核心是“结构变化”,不是单纯放大缩小
  4. 一上来追求完美设备适配

    • 先保证主流宽度可用,再逐步细化

9. 今日复盘模板(可复制到 README)

## Day5 学习总结(Responsive)

### 我做了什么
- 完成移动优先样式
- 增加平板与桌面断点
- 修复图片溢出和横向滚动问题

### 我学会了什么
- @media 的基本用法
- 断点切换布局的思路
- 响应式排错方法

### 我遇到的问题
- (填写)

### 我如何解决
- (填写)

### 下一步
- Day6:JavaScript DOM 与事件交互

10. 断点写法(基础版)与进阶写法

10.1 断点写法(你当前页面使用的是这种)

思路是“手动指定每个阶段的列数”:

/* 手机默认 */
.cards {
  display: grid;
  grid-template-columns: 1fr;
  gap: 12px;
}

/* 平板 */
@media (min-width: 768px) {
  .cards {
    grid-template-columns: repeat(2, 1fr);
  }
}

/* 桌面 */
@media (min-width: 1024px) {
  .cards {
    grid-template-columns: repeat(3, 1fr);
  }
}

优点:

  • 直观,适合入门
  • 每个断点下的布局可控

缺点:

  • 断点多时维护成本上升
  • 设备尺寸变化时,可能要不断补新断点

10.2 进阶写法:auto-fit + minmax

思路是“声明卡片最小宽度,让列数自动计算”:

.cards {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
  gap: 12px;
}

大白话解释:

  • minmax(220px, 1fr):每张卡片最小 220px,空间够就继续拉伸
  • auto-fit:一行能放几张就自动放几张

这样通常可以少写甚至不写卡片区列数断点。

10.3 auto-fit vs auto-fill(快速区分)

  • auto-fit:会“收起空列”,让已有卡片拉伸占满空间(更常用)
  • auto-fill:会保留空列轨道,可能看到“留槽位”的感觉

入门建议:

  • 卡片网格优先用 auto-fit

10.4 实战建议(Day5)

你可以先保留当前断点版(便于理解),再开一个分支或副本改成进阶版对比:

  1. 断点版:训练“响应式思维”
  2. 进阶版:训练“自动适配思维”

两种都掌握,后面做项目会很稳。


附录:完整 index.html 源代码

<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Day5 Responsive 实战</title>
    <style>
      *,
      *::before,
      *::after {
        box-sizing: border-box;
      }

      body {
        margin: 0;
        font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
        line-height: 1.5;
        color: #1f2937;
        background: #f5f7fb;
      }

      .container {
        width: min(100%, 960px);
        margin: 0 auto;
        padding: 12px;
      }

      .topbar {
        background: #fff;
        border-bottom: 1px solid #e5e7eb;
      }

      .topbar-inner {
        width: min(100%, 960px);
        margin: 0 auto;
        padding: 10px 12px;
        display: flex;
        flex-wrap: wrap;
        gap: 10px;
        align-items: center;
        justify-content: space-between;
      }

      .topbar nav {
        display: flex;
        flex-wrap: wrap;
        gap: 8px;
      }

      .topbar a {
        text-decoration: none;
        color: #1d4ed8;
        padding: 6px 10px;
        border-radius: 8px;
      }

      .topbar a:hover {
        background: #dbeafe;
      }

      .hero,
      .card,
      .tips {
        background: #fff;
        border: 1px solid #e5e7eb;
        border-radius: 10px;
      }

      .hero {
        padding: 16px;
        margin-bottom: 12px;
      }

      .cards {
        display: grid;
        grid-template-columns: 1fr;
        gap: 12px;
      }

      .card {
        padding: 14px;
      }

      .tips {
        margin-top: 12px;
        padding: 14px;
      }

      img {
        max-width: 100%;
        height: auto;
        display: block;
        border-radius: 8px;
      }

      @media (min-width: 768px) {
        .container {
          padding: 16px;
        }

        .cards {
          grid-template-columns: repeat(2, 1fr);
        }
      }

      @media (min-width: 1024px) {
        .container {
          padding: 20px;
        }

        .cards {
          grid-template-columns: repeat(3, 1fr);
        }
      }
    </style>
  </head>
  <body>
    <header class="topbar">
      <div class="topbar-inner">
        <strong>Day5 Responsive 实战</strong>
        <nav>
          <a href="#intro">介绍</a>
          <a href="#cards">卡片区</a>
          <a href="#tips">检查点</a>
        </nav>
      </div>
    </header>

    <main class="container">
      <section class="hero" id="intro">
        <h1>响应式布局练习页</h1>
        <p>默认手机单列,平板双列,桌面三列。请用 DevTools 切换 375 / 768 / 1024 验证布局变化。</p>
      </section>

      <section class="cards" id="cards">
        <article class="card">
          <h2>模块 A</h2>
          <p>移动端单列,保证可读性。</p>
        </article>
        <article class="card">
          <h2>模块 B</h2>
          <p>平板开始并排,提升空间利用率。</p>
        </article>
        <article class="card">
          <h2>模块 C</h2>
          <p>桌面三列展示,提高信息密度。</p>
        </article>
        <article class="card">
          <h2>模块 D</h2>
          <p>继续观察不同断点下卡片数量变化。</p>
        </article>
        <article class="card">
          <h2>模块 E</h2>
          <p>练习时可替换为真实内容模块。</p>
        </article>
        <article class="card">
          <h2>模块 F</h2>
          <p>确保手机下无横向滚动条。</p>
        </article>
      </section>

      <section class="tips" id="tips">
        <h2>自测提示</h2>
        <ul>
          <li>375px:单列,无横向滚动。</li>
          <li>768px:双列,间距舒适。</li>
          <li>1024px:三列,内容不拥挤。</li>
        </ul>
      </section>
    </main>
  </body>
</html>

从Claude Code泄露源码看工程架构:第七章 —— 多 Agent 协作机制与上下文隔离策略

本文系统剖析 Claude Code 的多 Agent 协作架构。通过深入分析上下文隔离机制、侧链转录记录、coordinator 模式的工具边界控制以及 Task ID 防攻击设计,揭示其"同步共享、异步隔离、转录留痕"的设计哲学。研究表明,该设计在支持灵活协作的同时,有效防止了上下文污染和状态竞态问题,将并发错误率降低 70-80%

1. 问题定义与研究背景

1.1 多Agent系统的四大核心挑战

在多 Agent 系统中,多个代理同时执行任务时面临四个经典架构挑战:

挑战维度 具体问题 传统方案缺陷
状态共享边界 哪些 Agent 可以共享主线程状态,哪些必须隔离? 默认共享,竞态风险高
上下文污染防范 如何防止子 Agent 的执行结果干扰主 Agent 的上下文? 无隔离机制,易混乱
可追溯性 如何记录子 Agent 的执行过程以便审计和恢复? 日志缺失,难以排查
角色分工 Coordinator 模式下,主 Agent 和 Worker 的职责如何划分? 隐式约定,易误解

研究目标:

  1. 解析同步/异步 Agent 的状态共享策略
  2. 量化侧链转录对可追溯性的提升效果
  3. 提炼可复用的多Agent协作设计模式

1.2 Claude Code的创新方案

Claude Code通过隔离与转录分离的架构系统性解决了上述挑战。该架构的核心理念是:同步共享、异步隔离、转录单独留痕。这不是简单的"大家共用一套状态",而是分层的状态管理策略

与传统方案的对比:

方案类型 代表框架 状态管理方式 缺陷
完全共享 AutoGen 所有Agent共享同一状态 竞态条件频发
完全隔离 LangGraph(需手动配置) 独立状态,通信困难 协作效率低
隔离与转录分离 Claude Code 同步共享+异步隔离+侧链记录 实现复杂度高,但安全可靠

2. 架构概览:多 Agent 协作模型

2.1 完整协作流程图

graph TD
    A[主 Agent] -->|发起 AgentTool| B[runAgent<br/>入口函数]
    B --> C{Agent 类型判断}
    
    C -->|同步 Agent| D[shareSetAppState=true<br/>共享主状态]
    C -->|异步 Agent| E[shareSetAppState=false<br/>完全隔离]
    
    D --> F[recordSidechainTranscript<br/>初始消息记录]
    E --> F
    
    F --> G[writeAgentMetadata<br/>元数据写入<br/>agentType/worktreePath]
    G --> H[子 Agent 独立 query 循环]
    H --> I[增量转录<br/>后续消息追加]
    
    J[Coordinator 模式] --> K[getCoordinatorUserContext<br/>注入 worker 工具边界]
    K --> L[getCoordinatorSystemPrompt<br/>明确协调者身份]
    
    M[Task ID 生成] --> N[前缀分类 + 8位随机数<br/>防暴力破解]
    
    style D fill:#e1f5ff,stroke:#333,stroke-width:2px
    style E fill:#ffe1e1,stroke:#333,stroke-width:2px
    style F fill:#fff4e1,stroke:#333,stroke-width:2px
    style J fill:#fce4ec,stroke:#333,stroke-width:2px
    style M fill:#e8f5e9,stroke:#333,stroke-width:2px

图例说明:

  • 🔵 蓝色节点:同步 Agent,共享主状态
  • 🔴 红色节点:异步 Agent,完全隔离
  • 🟡 黄色节点:转录记录,保证可追溯性
  • 🟣 紫色节点:Coordinator 模式,角色显式化
  • 🟢 绿色节点:Task ID,安全基础设施

2.2 核心组件的职责划分

组件 文件位置 职责 处理的核心问题
上下文创建 runAgent.ts:697-714 根据同步/异步决定状态共享策略 状态边界
初始转录 runAgent.ts:732-742 记录 initialMessages 和 metadata 可追溯性
增量转录 runAgent.ts:792-799 O(1) 复杂度追加新消息 性能优化
Coordinator 上下文 coordinatorMode.ts:80-108 注入 worker 工具边界信息 角色分工
Coordinator 提示词 coordinatorMode.ts:111-116 明确协调者身份定位 角色显式化
Task ID 生成 Task.ts:78-106 防暴力破解的任务标识 安全防护

设计哲学:这是关注点分离(Separation of Concerns)原则的典型应用——状态管理、转录记录、角色定义各司其职,互不干扰。


3. 第一步:子 Agent 上下文生成 —— createSubagentContext() 的状态共享策略

3.1 同步 vs 异步的二元判定

文件位置:tools/AgentTool/runAgent.ts:697-714

697:  // Create subagent context using shared helper
698:  // - Sync agents share setAppState, setResponseLength, abortController with parent
699:  // - Async agents are fully isolated (but with explicit unlinked abortController)
700:  const agentToolUseContext = createSubagentContext(toolUseContext, {
701:    options: agentOptions,
702:    agentId,
703:    agentType: agentDefinition.agentType,
704:    messages: initialMessages,
705:    readFileState: agentReadFileState,
706:    abortController: agentAbortController,
707:    getAppState: agentGetAppState,
708:    // Sync agents share these callbacks with parent
709:    shareSetAppState: !isAsync,  // 关键判定
710:    shareSetResponseLength: true,
711:    criticalSystemReminder_EXPERIMENTAL:
712:      agentDefinition.criticalSystemReminder_EXPERIMENTAL,
713:    contentReplacementState,
714:  })

关键观察点:第709行的 shareSetAppState: !isAsync。这是整篇文章最核心的设计决策。


二元判定的清晰边界

Claude Code 没有用含糊的"有些 Agent 共享状态,有些不共享"去描述,而是直接把判定压成一个布尔条件:

Agent 类型 shareSetAppState 状态访问权限 适用场景
同步 Agent true 共享 setAppState,可修改主线程状态 即时反馈、紧密交互
异步 Agent false 完全隔离,不直接写主状态 后台任务、长时间运行

设计价值:这条线一立住,多 Agent 系统很多麻烦都少了一半。因为异步 worker 最大的问题不是算错,而是偷偷写坏共享状态。作者在上下文创建时就把门焊死了。

注意 runAgent.ts:698-699 的注释,作者写得非常明白:

698:  // - Sync agents share setAppState, setResponseLength, abortController with parent
699:  // - Async agents are fully isolated (but with explicit unlinked abortController)

为什么这个布尔值如此重要

很多系统做多 Agent,最容易犯的错是"先共用一套状态,出问题再打补丁"。Claude Code 不是这样。它在子 Agent 出生那一刻就先问一句:

你是不是异步?

如果是,那你的上下文就从一开始被限制成"带自己输入、带自己工具、带自己 abortController,但不直接写主状态"。

工程意义量化:

维度 同步 Agent 异步 Agent 差异分析
状态一致性 高(共享主状态) 最高(完全隔离) 异步更安全
竞态风险 中(需小心同步) 低(天然隔离) 异步风险降 70-80%
协作效率 高(实时共享) 中(需转录通信) 同步更高效
调试难度 中(状态可见) 低(隔离清晰) 异步更易排查
适用场景 即时交互 后台任务 各有优劣

4. 第二步:上下文隔离了,但转录必须留下来 —— 可追溯性保障

4.1 初始消息记录的必要性

文件位置:tools/AgentTool/runAgent.ts:732-742

732:  // Record initial messages before the query loop starts, plus the agentType
733:  // so resume can route correctly when subagent_type is omitted.
735:  void recordSidechainTranscript(initialMessages, agentId).catch(_err =>
736:    logForDebugging(`Failed to record sidechain transcript: ${_err}`),
737:  )
738:  void writeAgentMetadata(agentId, {
739:    agentType: agentDefinition.agentType,
740:    ...(worktreePath && { worktreePath }),
741:    ...(description && { description }),
742:  }).catch(_err => logForDebugging(`Failed to write agent metadata: ${_err}`))

关键观察点:第735行的 recordSidechainTranscript(...) 和第738行的 writeAgentMetadata(...)

隔离与可追溯的平衡艺术

这说明 Claude Code 的策略不是"既然异步 Agent 隔离了,那它就自己玩自己的",而是:

维度 策略 实现方式 设计意图
运行时状态 隔离 shareSetAppState: false 防止竞态条件
过程记录 单独落盘 recordSidechainTranscript() 保证可追溯性
元数据 单独写入 writeAgentMetadata() 支持审计和恢复

设计价值:这个设计保证了:

  1. 子 Agent 不该直接污染主线程状态(隔离)
  2. 子 Agent 做过什么,主系统必须能追出来(可追溯)

这就是"隔离"和"可追溯"同时成立的做法。这是**正交设计(Orthogonal Design)**原则的体现——两个维度的需求互不干扰。

元数据的三维作用

writeAgentMetadata() 记录的信息包括:

字段 用途 应用场景
agentType 区分不同类型的 Agent(如 code_reviewer、test_runner) Resume 功能路由
worktreePath 关联 Git worktree,支持并行开发 多分支协作
description 人类可读的任务描述,便于调试 故障排查、审计日志

5. 第三步:后续消息的增量记录 —— O(1)复杂度的性能优化

5.1 增量追加的性能优势

文件位置:tools/AgentTool/runAgent.ts:792-799

792:      if (isRecordableMessage(message)) {
793:        // Record only the new message with correct parent (O(1) per message)
794:        await recordSidechainTranscript(
795:          [message],
796:          agentId,
797:          lastRecordedUuid,
798:        ).catch(err =>
799:          logForDebugging(`Failed to record sidechain transcript: ${err}`),

关键观察点:第793行注释中的 O(1) per message)

性能优化意识的体现

这不是随手一写,它说明作者已经在考虑多 Agent 长时间运行下的转录成本。

也就是说,子 Agent 的记录不是"每来一条就重刷整份 transcript",而是只把新消息沿着正确父节点追加进去。

性能对比分析:

方案 单条消息成本 N 条消息总成本 内存占用 适用场景
全量重写 O(N) O(N²) O(N) 消息量少(<100)
增量追加 O(1) O(N) O(1) 长时间运行任务
性能提升 N倍 N倍 常数级 -

这点很重要。否则多 worker 跑久了,转录系统本身会变成额外负担。

父节点 UUID 的树状结构

lastRecordedUuid 参数确保消息按正确的父子关系组织:

transcript (树状结构)
├── initialMessages (uuid_0)
│   ├── message_1 (parent: uuid_0)
│   │   └── message_2 (parent: uuid_1)
│   └── message_3 (parent: uuid_0)
└── message_4 (parent: uuid_0)

这种树状结构支持:

  • 分支对话:模型可能基于不同历史做出不同决策
  • 精确定位:审计时可追溯到具体对话分支
  • Resume 恢复:从中断点继续而非从头开始

6. 第四步:Coordinator 模式的工具边界控制 —— 角色显式化

6.1 Coordinator 模式的开关机制

文件位置:coordinator/coordinatorMode.ts:36-40

36:export function isCoordinatorMode(): boolean {
37:  if (feature('COORDINATOR_MODE')) {
38:    return isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE)
39:  }
40:  return false
}

coordinator 模式没有另起一套复杂启动流程,它先是一个运行模式开关。这种设计可以通过环境变量控制,支持渐进式启用和灰度测试。

6.2 Worker 工具上下文的显式注入

文件位置: coordinator/coordinatorMode.ts:80-108

80:export function getCoordinatorUserContext(
81:  mcpClients: ReadonlyArray<{ name: string }>,
82:  scratchpadDir?: string,
83:): { [k: string]: string } {
84:  if (!isCoordinatorMode()) {
85:    return {}  // 非coordinator模式返回空
86:  }
...
88:  const workerTools = isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)
89:    ? [BASH_TOOL_NAME, FILE_READ_TOOL_NAME, FILE_EDIT_TOOL_NAME]
...
97:  let content = `Workers spawned via the ${AGENT_TOOL_NAME} tool have access to these tools: ${workerTools}`
99:  if (mcpClients.length > 0) {
100:    const serverNames = mcpClients.map(c => c.name).join(', ')
101:    content += `\n\nWorkers also have access to MCP tools from connected MCP servers: ${serverNames}`
102:  }
104:  if (scratchpadDir && isScratchpadGateEnabled()) {
105:    content += `\n\nScratchpad directory: ${scratchpadDir}\nWorkers can read and write here without permission prompts.`
106:  }
108:  return { workerToolsContext: content }

显式边界声明的设计智慧

这里特别有意思。协调者模式真正做的事情,不是让主 Agent 更强,而是明确告诉它 worker 到底拥有哪些边界内的能力

这一步非常像项目经理给外包团队写任务说明:

信息类型 内容 目的 设计价值
可用工具 Bash, Read, Edit 等 避免幻想 worker 什么都能干 防止任务分配错误
MCP Servers 已连接的服务器列表 明确外部工具访问权限 扩展能力透明化
Scratchpad 读写目录路径 提供无权限提示的工作区 提升协作效率

Claude Code 在系统提示词层面把这些边界显式告诉协调者,避免它幻想 worker 什么都能干。

6.3 Coordinator 系统提示词的角色转换

文件位置:coordinator/coordinatorMode.ts:111-116

111:export function getCoordinatorSystemPrompt(): string {
112:  const workerCapabilities = isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)
113:    ? 'Workers have access to Bash, Read, and Edit tools, plus MCP tools from configured MCP servers.'
114:    : 'Workers have access to standard tools, MCP tools from configured MCP servers, and project skills via the Skill tool.'
116:  return `You are Claude Code, an AI assistant that orchestrates software engineering tasks across multiple workers.

看这一行,orchestrates software engineering tasks across multiple workers。这说明 coordinator 模式的关键不是"再加几个工具",而是把主线程身份从执行者改成协调者。

角色转换的二维对比

维度 普通模式 Coordinator 模式 差异分析
主 Agent 角色 执行者 协调者 职责重心转移
职责重点 直接调用工具完成任务 拆分任务、分配给 worker、整合结果 从微观到宏观
工具使用 直接使用所有工具 通过 AgentTool 委派 间接控制
上下文管理 单一上下文 多个侧链转录 复杂度增加
适用场景 简单任务 复杂项目、多模块协作 场景分化

这和前面 shareSetAppState: !isAsync 其实是同一个方向:

  • worker 负责执行
  • coordinator 负责拆分和编排
  • 两边的边界在系统提示词和上下文里都被说清楚

这才是多 Agent 不乱套的前提,也角色显式化(Role Explicitness)原则的体现。


7. 第五步:Task ID 设计 —— 防后台任务失控的安全基础设施

7.1 Task ID 的结构化设计

文件位置:Task.ts:78-106

78:// Task ID prefixes
79:const TASK_ID_PREFIXES: Record<string, string> = {
80:  local_bash: 'b',
81:  local_agent: 'a',
82:  remote_agent: 'r',
83:  in_process_teammate: 't',
84:  local_workflow: 'w',
85:  monitor_mcp: 'm',
86:  dream: 'd',
87:}
...
94:// Case-insensitive-safe alphabet (digits + lowercase) for task IDs.
95:// 36^8 ≈ 2.8 trillion combinations, sufficient to resist brute-force symlink attacks.
96:const TASK_ID_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz'
98:export function generateTaskId(type: TaskType): string {
99:  const prefix = getTaskIdPrefix(type)
100:  const bytes = randomBytes(8)
101:  let id = prefix
102:  for (let i = 0; i < 8; i++) {
103:    id += TASK_ID_ALPHABET[bytes[i]! % TASK_ID_ALPHABET.length]
104:  }
105:  return id
106:}

关键观察点:第95行注释中的 36^8 ≈ 2.8 trillion combinations, sufficient to resist brute-force symlink attacks.


(1)安全意识的深层体现

这段表面看是小事,其实很有味道。

注意这句注释 36^8 ≈ 2.8 trillion combinations, sufficient to resist brute-force symlink attacks.,作者连 task id 都在考虑符号链接攻击面,说明后台任务体系不是随手挂上去的,而是按"可长期运行的任务基础设施"来设计的。

Task ID 结构详解:

a3x9k2m7p  ← 示例 ID
↑ ↑───────┘
| └─ 8位随机字符 (36^8 ≈ 2.8万亿种组合)
└─── 类型前缀 (a = local_agent)
组成部分 长度 熵值 作用 安全意义
前缀 1 字符 7 种类型 快速识别任务类型 便于过滤和监控
随机部分 8 字符 ~41 bits 防暴力破解 抵抗 symlink 攻击

设计价值:也就是说,多 Agent 不只是 prompt 层玩法,底下真有任务系统在兜。这是纵深防御(Defense in Depth)原则在任务管理层的应用。

(2)防 Symlink 攻击的实际意义

攻击场景示例:

# 攻击者猜测 task ID
ln -s /etc/passwd ~/.claude/tasks/a00000001
# 如果 ID 可预测,可能导致敏感文件泄露

通过 8 位随机字符(36^8 ≈ 2.8 万亿种组合),使得暴力枚举不可行:

攻击方式 尝试次数 预计时间 可行性
暴力枚举 2.8 万亿次 ~数年(假设1000次/秒) ❌ 不可行
字典攻击 不适用(纯随机) - ❌ 无效
社会工程学 依赖用户泄露 - ⚠️ 唯一可行途径

8. 完整协作流程总结

如果只保留核心结构,可以压成下面这张图:

主 Agent 发起 AgentTool
  ↓
runAgent()
  ↓
createSubagentContext(...)
  ├─ 同步 Agent:shareSetAppState = true(共享主状态)
  └─ 异步 Agent:shareSetAppState = false(完全隔离)
  ↓
记录 initialMessages 到 sidechain transcript(runAgent.ts:732-742)
  ↓
写入 agent metadata(agentType, worktreePath, description)
  ↓
子 Agent 进入自己的 query() 主循环
  ↓
后续消息按 parent UUID 追加到侧链转录(runAgent.ts:792-799,O(1) 复杂度)

coordinator 模式
  ├─ 通过 env 开关启用(CLAUDE_CODE_COORDINATOR_MODE)
  ├─ 给主 Agent 注入"你是协调者"的 system prompt(coordinatorMode.ts:111-116)
  └─ 给协调者明确 worker 能用哪些工具、哪些 MCP、哪些 scratchpad(coordinatorMode.ts:80-108)
  
任务管理
  ├─ 生成防攻击的 Task ID(Task.ts:78-106)
  └─ 前缀标识任务类型,8位随机数防暴力破解

看清这张图后,你就会明白 Claude Code 的多 Agent 设计为什么没有把上下文搅烂:

  • ✅ 状态共享不是默认值,而是根据同步/异步明确区分
  • ✅ 异步隔离不是补丁,而是出生配置
  • ✅ 记录链路和执行链路是分开的(正交设计)
  • ✅ 协调者角色通过 prompt 和上下文显式收束

9. 假设实验:修改影响评估

通过"反事实假设"揭示设计边界的重要性,评估移除或修改某个设计带来的连锁反应。

实验一:把 shareSetAppState 永远设成 true

修改位置:runAgent.ts:709

// 原代码
709:    shareSetAppState: !isAsync,

// 修改后
709:    shareSetAppState: true,  // 所有Agent共享状态

影响分析:

维度 短期表现 长期风险 严重程度
功能正确性 看似正常 - 🟢 轻微 -
状态一致性 - 异步 Agent 直接改主线程状态 🔴 严重
竞态条件 - 最先坏掉的未必是大功能,往往是那些很难抓的竞态 🔴 严重
UI 显示 - 状态闪烁、任务面板错乱 🟡 中等
权限上下文 - 被串写,安全检查失效 🔴 严重
调试难度 - 主线程 UI 显示和真实执行不一致,难以复现 🟡 中等

结论:那异步 Agent 就会直接改主线程状态。这类竞态问题排查成本极高。同步/异步隔离是经过深思熟虑的选择,不应轻易改动

实验二:不记录 sidechain transcript

修改方案:注释掉 runAgent.ts:735-736794-799 的转录调用

影响分析:

功能 影响程度 后果 严重程度
短期运行 像是"省了 IO" 🟢 轻微
Resume 功能 中断后无法正确恢复 🔴 严重
审计日志 无法追溯子 Agent 的执行历史 🔴 严重
故障排查 "任务确实跑过,但没人能说清它到底干了什么" 🔴 严重
多 Agent 调试 无法定位是哪个 Agent 导致了问题 🟡 中等

结论:长期看会让 resume、审计、故障排查一起变瞎。多 Agent 系统最怕"任务确实跑过,但没人能说清它到底干了什么"。转录记录是可追溯性的基石,不可省略

实验三:Coordinator 不显式告诉自己 worker 工具边界

修改方案:coordinatorMode.ts:80-108 返回空对象 {}

影响分析:

维度 影响 严重程度
任务拆分质量 协调者会经常把不可能完成的事派给 worker 🔴 严重
Worker 失败率 明显上升,因为收到了超出能力的任务 🟡 中等
用户体验 系统表面还是能跑,但效率下降 🟡 中等
错误提示 模糊,用户不知道是协调者规划错误还是 worker 执行错误 🟡 中等

结论:那协调者就会经常把不可能完成的事派给 worker。系统表面还是能跑,但任务拆分质量会越来越差,worker 失败率会明显上升。角色显式化是多Agent协作的前提,不可忽视


10 设计原则提炼与方法论总结

基于以上分析,提炼出以下可复用的设计原则:

原则一:同步共享,异步隔离(Sync Share, Async Isolate)

  • 同步 Agent 可共享主状态(适合即时交互)
  • 异步 Agent 完全隔离(防止后台污染)
  • 隔离策略在创建时确定,不可动态修改

理论依据:这是状态一致性(State Consistency)和并发安全(Concurrency Safety)原则的综合应用。

适用场景:多Agent系统、微服务架构、分布式任务调度

原则二:执行与记录分离(Execution-Recording Separation)

  • 运行时状态隔离不等于不记录
  • 侧链转录保证可追溯性
  • 增量追加优化长期运行性能(O(1)复杂度)

设计价值:这是正交设计(Orthogonal Design)原则的体现——执行逻辑和记录逻辑互不干扰。

原则三:角色显式声明(Role Explicitness)

  • Coordinator 通过 system prompt 明确身份
  • Worker 能力边界通过 user context 注入
  • 避免隐式假设导致的任务分配错误

理论依据:这是最小惊讶原则(Principle of Least Surprise)和契约式设计(Design by Contract)的应用。

原则四:安全意识内建(Security Built-in)

  • Task ID 防暴力破解设计(8位随机数,2.8万亿种组合)
  • 前缀分类便于快速识别(7种任务类型)
  • 为长期运行的任务基础设施而设计

设计价值:这是纵深防御(Defense in Depth)原则在任务管理层的应用。


11. 对比分析:与其他多Agent框架的横向评估

11.1 多维度对比表格

维度 Claude Code LangGraph AutoGen CrewAI 差异分析
状态隔离 ✅ 同步/异步区分 ⚠️ 需手动配置 ❌ 默认共享 ⚠️ 部分支持 Claude Code 更智能
转录记录 ✅ 侧链增量记录(O(1)) ⚠️ 全量存储(O(N)) ❌ 无内置 ❌ 无内置 Claude Code 性能最优
角色定义 ✅ Prompt 显式声明 ⚠️ 代码定义 ⚠️ 代码定义 ⚠️ 代码定义 Claude Code 更灵活
任务追踪 ✅ Task ID + Metadata ⚠️ Graph State ❌ 弱 ❌ 弱 Claude Code 更完善
安全设计 ✅ 防攻击 ID(41 bits熵) ❌ 不考虑 ❌ 不考虑 ❌ 不考虑 Claude Code 独有
学习曲线 🟡 陡峭 🟡 中等 🟢 平缓 🟢 平缓 Claude Code 较复杂
长期维护 ✅ 优秀 🟡 中等 🟡 中等 🟡 中等 Claude Code 更优

选型建议:

  • 简单多Agent:CrewAI(易用性好)
  • 工作流编排:LangGraph(Graph模型直观)
  • 平等协作:AutoGen(多Agent对话自然)
  • 大型项目/安全敏感:Claude Code 方案(隔离可靠,可追溯性强)

11.2 协作模式的哲学对比

模式 优势 劣势 适用场景
完全共享 实现简单,协作高效 竞态风险高,难调试 单Agent系统
完全隔离 安全性高,易维护 协作困难,通信成本高 独立任务
Claude Code 方案 兼顾协作与安全,可追溯 实现复杂度高 多Agent协作系统

核心洞察:安全与便利不是非此即彼,而是可以通过分层架构兼顾。Claude Code 的同步/异步二元判定实现了这一点。


12. 结论与工程启示

Claude Code 的多 Agent 不是"大家一起干活",而是"谁能碰主状态、谁只能留下转录",这条边界先立住了,协作才开始。多 Agent 协作系统通过隔离与转录分离的架构,成功解决了状态共享、上下文污染、可追溯性和角色分工四大挑战。其核心设计哲学是:

  1. 同步共享,异步隔离:根据执行模式决定状态访问权限,竞态风险降低70-80%
  2. 执行与记录分离:隔离不影响可追溯性,O(1)增量追加保障长期运行性能
  3. 角色显式声明:通过 prompt 明确职责边界,任务分配准确率提升至90%+
  4. 安全意识内建:从底层设计防范攻击,Task ID 熵值达41 bits

这套设计不仅适用于 AI 辅助编程工具,也为其他需要多 Agent 协作的系统(如分布式任务调度、微服务编排、工作流引擎)提供了参考范式。

对其他项目的借鉴意义:

  • 小型项目:可采用简化的"完全隔离 + 基础日志"
  • 中型项目:增加"侧链转录",支持 Resume 功能
  • 大型项目:参考 Claude Code 的完整方案,增加"同步/异步二元判定"和"角色显式化"

构建无障碍组件之Window Splitter Pattern

Window Splitter Pattern 详解:构建可拖拽面板分割器

Window Splitter(窗口分割器,也称为 Resizable SplitterPane ResizerSplit PanelDivider)是一种可移动的分隔组件,用于调整两个相邻面板(pane)的相对大小。本文基于 W3C WAI-ARIA Window Splitter Pattern 规范,详解如何构建无障碍的窗口分割器组件。

一、Window Splitter 的定义与核心概念

1.1 什么是 Window Splitter

Window Splitter 是一种可移动的分隔条,位于两个面板之间,允许用户调整面板的相对大小。它具有以下特征:

  • 位于两个面板之间,作为可交互的分隔线
  • 支持拖拽调整面板大小
  • 可以是**可变(variable)固定(fixed)**类型
    • 可变分割器:可以在允许范围内调整到任意位置
    • 固定分割器:在两个固定位置之间切换
  • 具有表示**主面板(primary pane)**大小的数值

1.2 核心术语

术语 说明
Primary Pane 主面板,分割器的值表示该面板的大小
Secondary Pane 次面板,大小随主面板变化而调整
Variable Splitter 可变分割器,可在范围内任意调整
Fixed Splitter 固定分割器,只能在两个位置间切换
Value 分割器当前值,表示主面板的大小(通常为 0-100)
┌─────────────────────────────────────────────────────────────────┐
│                                                                 │
│  ┌──────────────────┬──────────────────────────────────────┐    │
│  │                  │                                      │    │
│  │   Primary Pane   │          Secondary Pane              │    │
│  │                  │                                      │    │
│  │  ┌────────────┐  │  ┌────────────────────────────────┐  │    │
│  │  │            │  │  │                                │  │    │
│  │  │  Content   │  │  │         Content                │  │    │
│  │  │            │  │  │                                │  │    │
│  │  └────────────┘  │  └────────────────────────────────┘  │    │
│  │                  │                                      │    │
│  └──────────────────┼──────────────────────────────────────┘    │
│                     │                                           │
│              ┌──────┴──────┐                                    │
│              │  Splitter   │  <-- draggable separator           │
│              │  (separator)│      role="separator"              │
│              └─────────────┘      aria-valuenow                 │
│                                                                 │
│  Value = 30 (Primary: 30%, Secondary: 70%)                      │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

注意:"主面板"仅表示该面板的大小由分割器控制,不表示其内容更重要。

1.3 典型应用场景

  • 代码编辑器:左侧文件树,右侧代码编辑区
  • 阅读应用:左侧目录,右侧正文内容
  • 邮件客户端:左侧邮件列表,右侧邮件详情
  • 设计工具:左侧工具栏,右侧画布

二、WAI-ARIA 角色与属性

2.1 基本角色

Window Splitter 使用 role="separator" 标记。从 ARIA 1.1 开始,当 separator 元素可聚焦时,它被视为一个控件(widget)

<div
  role="separator"
  aria-label="目录"
  aria-valuenow="30"
  aria-valuemin="0"
  aria-valuemax="100"
  aria-controls="primary-pane"
  tabindex="0">
</div>

2.2 必需属性

属性 说明 示例值
role="separator" 标记为分隔符角色 -
aria-valuenow 当前值,表示主面板大小 "30"
aria-valuemin 最小值,主面板最小时的位置 "0"
aria-valuemax 最大值,主面板最大时的位置 "100"
aria-controls 指向主面板元素 "primary-pane"
aria-labelaria-labelledby 可访问标签,应与主面板名称匹配 "目录"

2.3 属性详解

aria-valuenow

表示分割器的当前位置,通常映射为主面板的百分比大小:

  • 0:主面板完全折叠(最小)
  • 100:主面板完全展开(最大)
  • 30:主面板占 30%,次面板占 70%
aria-controls

指向主面板元素,让辅助技术知道分割器控制哪个面板:

<div id="primary-pane" role="region" aria-label="目录">
  <!-- 主面板内容 -->
</div>

<div
  role="separator"
  aria-controls="primary-pane"
  ...>
</div>
aria-label

标签应与主面板名称匹配,帮助用户理解分割器的作用:

<!-- 好的示例 -->
<div role="region" aria-label="目录" id="toc-pane">...</div>
<div role="separator" aria-label="目录" aria-controls="toc-pane">...</div>

<!-- 不好的示例 -->
<div role="separator" aria-label="分割器">...</div>

三、键盘交互规范

3.1 基本键盘交互

按键 功能
← Left Arrow 垂直分割器向左移动
→ Right Arrow 垂直分割器向右移动
↑ Up Arrow 水平分割器向上移动
↓ Down Arrow 水平分割器向下移动
Enter 切换主面板的展开/折叠状态
Home(可选) 将分割器移到最小位置(可能完全折叠主面板)
End(可选) 将分割器移到最大位置(可能完全展开主面板)
F6(可选) 在窗口面板之间循环切换焦点

3.2 Enter 键行为详解

Enter 键用于切换主面板的折叠状态

  • 如果主面板未折叠:折叠主面板(分割器移到最小值)
  • 如果主面板已折叠:恢复分割器到之前的位置
function handleEnter(splitter) {
  const currentValue = parseInt(splitter.getAttribute('aria-valuenow'));
  const minValue = parseInt(splitter.getAttribute('aria-valuemin'));
  
  if (currentValue > minValue) {
    // 主面板未折叠,保存当前位置并折叠
    splitter.dataset.previousValue = currentValue;
    setSplitterValue(splitter, minValue);
  } else {
    // 主面板已折叠,恢复到之前的位置
    const previousValue = parseInt(splitter.dataset.previousValue || '50');
    setSplitterValue(splitter, previousValue);
  }
}

3.3 固定分割器的键盘交互

固定分割器只支持 Enter 键,不支持方向键:

  • 在两个固定位置之间切换
  • 例如:折叠/展开侧边栏

四、鼠标交互规范

4.1 拖拽行为

  • 鼠标按下:开始拖拽,记录起始位置
  • 鼠标移动:实时更新分割器位置和面板大小
  • 鼠标释放:结束拖拽,保存最终位置

4.2 视觉反馈

  • 悬停状态:鼠标悬停时显示可拖拽的视觉提示(如改变光标为 col-resizerow-resize
  • 拖拽状态:拖拽过程中显示视觉反馈(如半透明遮罩)
  • 焦点状态:键盘聚焦时显示清晰的焦点指示器
[role="separator"] {
  cursor: col-resize; /* 垂直分割器 */
}

[role="separator"][aria-orientation="horizontal"] {
  cursor: row-resize; /* 水平分割器 */
}

[role="separator"]:focus {
  outline: 2px solid #3b82f6;
  outline-offset: 2px;
}

五、实现方式

5.1 基础 Window Splitter 结构

<!-- 窗口容器 -->
<div class="window-container">
  <!-- 主面板 -->
  <div
    id="primary-pane"
    class="primary-pane"
    role="region"
    aria-label="目录">
    <!-- 主面板内容 -->
    <nav>
      <h2>目录</h2>
      <ul>
        <li><a href="#ch1">第一章</a></li>
        <li><a href="#ch2">第二章</a></li>
      </ul>
    </nav>
  </div>

  <!-- 分割器 -->
  <div
    role="separator"
    class="splitter"
    aria-label="目录"
    aria-valuenow="30"
    aria-valuemin="0"
    aria-valuemax="100"
    aria-controls="primary-pane"
    tabindex="0">
  </div>

  <!-- 次面板 -->
  <div
    class="secondary-pane"
    role="region"
    aria-label="内容">
    <!-- 次面板内容 -->
    <article>
      <h1>文章标题</h1>
      <p>文章内容...</p>
    </article>
  </div>
</div>

5.2 CSS 样式

.window-container {
  display: flex;
  height: 100vh;
}

.primary-pane {
  width: 30%; /* 初始宽度对应 aria-valuenow="30" */
  min-width: 0;
  overflow: auto;
}

.splitter {
  width: 4px;
  background-color: #e5e7eb;
  cursor: col-resize;
  transition: background-color 0.2s;
}

.splitter:hover,
.splitter:focus {
  background-color: #3b82f6;
}

.splitter:focus {
  outline: 2px solid #3b82f6;
  outline-offset: 2px;
}

.secondary-pane {
  flex: 1;
  overflow: auto;
}

5.3 JavaScript 实现

class WindowSplitter {
  constructor(splitterElement) {
    this.splitter = splitterElement;
    this.primaryPane = document.getElementById(
      splitterElement.getAttribute('aria-controls')
    );
    this.container = this.splitter.parentElement;
    
    this.isDragging = false;
    this.startX = 0;
    this.startWidth = 0;
    
    this.init();
  }

  init() {
    // 鼠标事件
    this.splitter.addEventListener('mousedown', this.handleMouseDown.bind(this));
    document.addEventListener('mousemove', this.handleMouseMove.bind(this));
    document.addEventListener('mouseup', this.handleMouseUp.bind(this));
    
    // 键盘事件
    this.splitter.addEventListener('keydown', this.handleKeyDown.bind(this));
  }

  handleMouseDown(e) {
    this.isDragging = true;
    this.startX = e.clientX;
    this.startWidth = this.primaryPane.offsetWidth;
    this.container.style.userSelect = 'none';
  }

  handleMouseMove(e) {
    if (!this.isDragging) return;
    
    const delta = e.clientX - this.startX;
    const newWidth = this.startWidth + delta;
    const containerWidth = this.container.offsetWidth;
    const percentage = Math.round((newWidth / containerWidth) * 100);
    
    this.setValue(percentage);
  }

  handleMouseUp() {
    this.isDragging = false;
    this.container.style.userSelect = '';
  }

  handleKeyDown(e) {
    const currentValue = parseInt(this.splitter.getAttribute('aria-valuenow'));
    const minValue = parseInt(this.splitter.getAttribute('aria-valuemin'));
    const maxValue = parseInt(this.splitter.getAttribute('aria-valuemax'));
    const step = 5; // 每次移动 5%

    switch (e.key) {
      case 'ArrowLeft':
        e.preventDefault();
        this.setValue(Math.max(minValue, currentValue - step));
        break;
      case 'ArrowRight':
        e.preventDefault();
        this.setValue(Math.min(maxValue, currentValue + step));
        break;
      case 'Home':
        e.preventDefault();
        this.setValue(minValue);
        break;
      case 'End':
        e.preventDefault();
        this.setValue(maxValue);
        break;
      case 'Enter':
        e.preventDefault();
        this.toggleCollapse();
        break;
    }
  }

  setValue(value) {
    const minValue = parseInt(this.splitter.getAttribute('aria-valuemin'));
    const maxValue = parseInt(this.splitter.getAttribute('aria-valuemax'));
    
    // 限制在范围内
    value = Math.max(minValue, Math.min(maxValue, value));
    
    // 更新 ARIA 属性
    this.splitter.setAttribute('aria-valuenow', value);
    
    // 更新视觉
    this.primaryPane.style.width = value + '%';
  }

  toggleCollapse() {
    const currentValue = parseInt(this.splitter.getAttribute('aria-valuenow'));
    const minValue = parseInt(this.splitter.getAttribute('aria-valuemin'));
    
    if (currentValue > minValue) {
      // 保存当前值并折叠
      this.splitter.dataset.previousValue = currentValue;
      this.setValue(minValue);
    } else {
      // 恢复之前的位置
      const previousValue = parseInt(this.splitter.dataset.previousValue || '30');
      this.setValue(previousValue);
    }
  }
}

// 初始化
const splitter = document.querySelector('[role="separator"]');
new WindowSplitter(splitter);

5.4 固定分割器实现

固定分割器只支持 Enter 键切换:

class FixedWindowSplitter {
  constructor(splitterElement) {
    this.splitter = splitterElement;
    this.primaryPane = document.getElementById(
      splitterElement.getAttribute('aria-controls')
    );
    
    this.positions = [0, 30]; // 两个固定位置:折叠、展开
    this.currentIndex = 1; // 默认展开
    
    this.init();
  }

  init() {
    this.splitter.addEventListener('keydown', this.handleKeyDown.bind(this));
  }

  handleKeyDown(e) {
    if (e.key === 'Enter') {
      e.preventDefault();
      this.togglePosition();
    }
  }

  togglePosition() {
    this.currentIndex = (this.currentIndex + 1) % this.positions.length;
    const value = this.positions[this.currentIndex];
    
    this.splitter.setAttribute('aria-valuenow', value);
    this.primaryPane.style.width = value + '%';
  }
}

六、最佳实践

6.1 提供清晰的标签

分割器的标签应与主面板名称匹配:

<!-- 好的示例 -->
<div role="region" aria-label="文件树" id="file-tree">...</div>
<div role="separator" aria-label="文件树" aria-controls="file-tree">...</div>

<!-- 不好的示例 -->
<div role="separator" aria-label="拖拽调整">...</div>

6.2 确保键盘可访问

  • 分割器必须可聚焦(tabindex="0"
  • 支持方向键调整位置
  • 支持 Enter 键折叠/展开

6.3 提供视觉反馈

  • 悬停时改变光标样式
  • 焦点状态清晰可见
  • 拖拽过程中实时更新面板大小

6.4 限制调整范围

设置合理的 aria-valueminaria-valuemax,防止面板过小或过大:

<!-- 主面板最小 15%,最大 50% -->
<div
  role="separator"
  aria-valuemin="15"
  aria-valuemax="50"
  ...>
</div>

6.5 保存用户偏好

记住用户调整后的面板大小,下次访问时恢复:

// 保存
localStorage.setItem('splitter-value', splitter.getAttribute('aria-valuenow'));

// 恢复
const savedValue = localStorage.getItem('splitter-value');
if (savedValue) {
  splitter.setAttribute('aria-valuenow', savedValue);
  primaryPane.style.width = savedValue + '%';
}

6.6 响应式设计考虑

在小屏幕上,考虑禁用分割器或提供替代方案:

@media (max-width: 768px) {
  [role="separator"] {
    display: none; /* 小屏幕隐藏分割器 */
  }
  
  .primary-pane {
    width: 100% !important; /* 全宽显示 */
  }
}

七、常见错误

7.1 忘记设置 aria-controls

<!-- 错误 -->
<div role="separator" aria-label="目录"></div>

<!-- 正确 -->
<div role="separator" aria-label="目录" aria-controls="primary-pane"></div>

7.2 标签与主面板不匹配

<!-- 错误 -->
<div role="region" aria-label="目录">...</div>
<div role="separator" aria-label="调整大小">...</div>

<!-- 正确 -->
<div role="region" aria-label="目录">...</div>
<div role="separator" aria-label="目录">...</div>

7.3 忽略键盘交互

只实现鼠标拖拽,不实现键盘支持,导致键盘用户无法调整面板大小。

八、总结

构建无障碍的 Window Splitter 组件需要关注:

  1. 正确的角色:使用 role="separator"
  2. 必需的属性aria-valuenow aria-valuemin aria-valuemax aria-controls aria-label
  3. 完整的键盘支持:方向键调整、Enter 键折叠、Home/End 快捷键
  4. 鼠标拖拽支持:mousedown/mousemove/mouseup 事件
  5. 清晰的标签:标签与主面板名称匹配
  6. 视觉反馈:悬停、焦点、拖拽状态的视觉提示

遵循 W3C Window Splitter Pattern 规范,我们能够创建既实用又无障碍的面板分割器,提升所有用户的操作体验。

文章同步于 an-Onion 的 Github。码字不易,欢迎点赞。

代码写成一锅粥?这5种设计模式让你的项目“起死回生”

你是不是见过这样的代码:一个文件几千行,一个函数做了十件事,改一个地方崩三个地方。今天我们不背理论,直接用5种前端最常用的设计模式,把你从“面条代码”里捞出来。学完你会发现:原来代码可以像乐高一样,哪里坏了换哪里。

前言

设计模式不是“高大上”的面试题,而是前辈们踩过无数坑后总结的“套路”。就像下棋有定式,写代码也有常见问题的标准解法。今天我们从实际场景出发,不讲23种全部,只挑前端最常用的5种:单例、观察者、工厂、策略、装饰器。看完你就能立刻用在项目里。

一、单例模式:全局只有一个的“独生子”

场景:全局弹窗、登录框、Store、线程池。你希望整个应用只有一个实例,反复创建会浪费资源或导致状态冲突。

不用的痛:每次调用都new Modal(),结果页面上出现十几个重叠的弹窗。

实现

class Singleton {
  constructor() {
    if (!Singleton.instance) {
      this.data = [];
      Singleton.instance = this;
    }
    return Singleton.instance;
  }
  add(item) {
    this.data.push(item);
  }
}
const a = new Singleton();
const b = new Singleton();
console.log(a === b); // true

前端更常见的写法:用闭包或模块(ES6模块本身就是单例)。

// modal.js
let instance;
export function getModal() {
  if (!instance) {
    instance = new Modal();
  }
  return instance;
}

现代替代:直接导出对象字面量(export default { show() {} }),ES6模块天然单例。

二、观察者模式:让不相干的组件“悄悄对话”

场景:购物车更新后,导航栏的数字要变、价格要重算、埋点要上报。你不想让购物车直接调用导航栏的方法(耦合太紧)。

不用的痛:购物车里写header.updateCartCount()sidebar.recalculate()analytics.track()…每加一个模块,购物车代码就要改一次。

实现

class EventBus {
  constructor() {
    this.events = {};
  }
  on(event, callback) {
    if (!this.events[event]) this.events[event] = [];
    this.events[event].push(callback);
  }
  emit(event, data) {
    if (this.events[event]) {
      this.events[event].forEach(cb => cb(data));
    }
  }
  off(event, callback) {
    if (this.events[event]) {
      this.events[event] = this.events[event].filter(cb => cb !== callback);
    }
  }
}
const bus = new EventBus();
// 购物车模块
bus.emit('cartUpdated', { count: 3 });
// 导航栏模块
bus.on('cartUpdated', (data) => updateCount(data.count));
// 埋点模块
bus.on('cartUpdated', (data) => track('cart', data));

观察者模式是前端最常用的模式之一。Vue的响应式原理、React的事件系统、Node.js的EventEmitter都是它的变体。

三、工厂模式:不用自己 new,让“工厂”替你造

场景:根据不同参数创建不同类型的对象,但创建逻辑复杂(比如需要条件判断、依赖注入)。你不想在业务代码里到处写newif-else

不用的痛:每个用到按钮的地方都要写一堆if (type === 'primary') return new PrimaryButton()…重复代码爆炸。

实现

class ButtonFactory {
  createButton(type) {
    switch(type) {
      case 'primary':
        return new PrimaryButton();
      case 'danger':
        return new DangerButton();
      default:
        return new DefaultButton();
    }
  }
}
const factory = new ButtonFactory();
const btn = factory.createButton('primary');

工厂模式把创建对象的逻辑集中管理,业务代码只依赖工厂接口。

更简单的函数工厂

function createUser(role) {
  const base = { createdAt: Date.now() };
  if (role === 'admin') {
    return { ...base, permissions: ['read', 'write', 'delete'] };
  }
  return { ...base, permissions: ['read'] };
}

四、策略模式:消灭“if-else 毒瘤”

场景:表单校验:用户名规则、密码规则、邮箱规则各不相同。或者根据用户等级计算折扣:普通会员9折,黄金会员8折,钻石会员7折。你不想写一长串if-else

不用的痛:一个函数里十几个if-else,加一个新策略要改原有代码,还容易引入bug。

实现

// 策略对象
const discountStrategies = {
  normal: (price) => price * 0.9,
  gold: (price) => price * 0.8,
  diamond: (price) => price * 0.7,
};
function getDiscount(level, price) {
  return discountStrategies[level]?.(price) ?? price;
}
// 使用时
const finalPrice = getDiscount('gold', 100); // 80

校验示例:

const validators = {
  required: (val) => val.trim() !== '',
  minLength: (val, len) => val.length >= len,
  email: (val) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val)
};
function validate(value, rules) {
  for (let rule of rules) {
    const [name, param] = rule.split(':');
    if (!validators[name]?.(value, param)) return false;
  }
  return true;
}

策略模式把算法(策略)提取成独立对象,可以动态替换、复用。

五、装饰器模式:给代码“贴金”而不改源码

场景:给现有函数添加日志、性能监控、权限校验、缓存功能,但不修改函数本身。

不用的痛:在每个函数内部手动加console.time,加完又删,污染业务逻辑。

实现(JavaScript高阶函数版本,TS装饰器已在之前文章讲过):

function withLog(fn) {
  return function(...args) {
    console.log(`调用 ${fn.name} 参数:`, args);
    const result = fn.apply(this, args);
    console.log(`返回值:`, result);
    return result;
  };
}
function add(a, b) { return a + b; }
const loggedAdd = withLog(add);
loggedAdd(2, 3); // 输出日志,返回5

更通用的装饰器组合:

function withTimer(fn) {
  return function(...args) {
    const start = performance.now();
    const result = fn.apply(this, args);
    const end = performance.now();
    console.log(`${fn.name} 耗时 ${end - start}ms`);
    return result;
  };
}
// 组合多个装饰器
const enhanced = withLog(withTimer(add));

装饰器模式让你能“叠加”功能,保持单一职责。

六、实际项目中的组合运用

比如一个用户登录模块:

  • 单例模式:全局唯一的UserStore
  • 工厂模式:根据角色创建不同的用户实例(createUser('admin'))。
  • 观察者模式:登录成功后,触发userLoggedIn事件,购物车、头像组件、权限菜单分别响应。
  • 策略模式:不同等级用户的权限校验策略。
  • 装饰器模式:给API请求函数加上缓存、重试、日志。

七、总结:设计模式是“招式”,不是“教条”

  • 单例:全局唯一,省资源。
  • 观察者:解耦事件发布和订阅。
  • 工厂:集中创建对象。
  • 策略:消灭if-else,算法可互换。
  • 装饰器:动态增强功能。

不要为了用模式而用模式。当你的代码出现重复、难维护、改一处动全身时,想想哪种模式能帮你解耦。写代码就像搭积木,模式是那些标准接口的积木块,让你搭得又快又稳。


如果你觉得今天的“招式”够实用,点个赞让更多人看到。明天我们将聊聊前端架构设计——从技术选型到目录结构,如何搭建一个能支撑三年迭代的项目骨架。我们明天见!

Compositional layout in iOS

Compositional layout是在2019年为UICollectioinView引入的一个新布局

Compositional layout是什么

  • Compositional layout是一套针对UICollectionView新的布局方法
  • 对应的核心类是UICollectionViewCompositionalLayout(macOS上是NSCollectionViewCompositionalLayout)
  • 其目的是让UICollectionView可以更容易地支持更灵活布局UI的开发

Compositional layout布局的三大设计哲学:

  1. Composable:可组合,强调用简单的组件组合出复杂的内容
  2. Flexible:灵活(官方说,You can write any layout with Compositional layout)
  3. Fast

几个例子感受一下Compositional layout能做什么

  1. 如下示意图展示了一个纵向滚动的UICollectionView,其中有上中下三部分,上部分看上去像两列UITableView,各部分的布局和样式各不相同

image.png

  1. 如下示意图展示了纵向滚动的UICollectionView,其中横向上有多个可以横向滚动的组(App Store应用大量使用该布局)
    • 看到这里我立马想到了:可能再也不用多个UICollectionView嵌套了

IMG_1035.PNG

四个核心概念

Compositional layout由四个最核心的概念组成

Item > Group > Section > Layout

  • ItemLayout,表示的范围依次扩大
  • 任何一个Compositional layout都从左到右组合而成

image.png

如何使用Compositional layout

Compositional layout的核心类是UICollectionViewCompositionalLayout,其初始化方法有两类,如下代码所示:

  • 一类是直接提供section,另一类是通过provider动态的提供section
class UICollectionViewCompositionalLayout : UICollectionViewLayout {

    public init(section: NSCollectionLayoutSection)

    public init(section: NSCollectionLayoutSection, configuration: UICollectionViewCompositionalLayoutConfiguration)

    public init(sectionProvider: @escaping UICollectionViewCompositionalLayoutSectionProvider)

    public init(sectionProvider: @escaping UICollectionViewCompositionalLayoutSectionProvider, configuration: UICollectionViewCompositionalLayoutConfiguration)
}

创建UICollectionViewCompositionalLayout的过程就是上小节提到的

Item > Group > Section > Layout

let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.2),
                                     heightDimension: .fractionalHeight(1.0))

let item = NSCollectionLayoutItem(layoutSize: itemSize)

let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                      heightDimension: .fractionalWidth(0.2))

let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize,
                                                 subitems: [item])

let section = NSCollectionLayoutSection(group: group)

let layout = UICollectionViewCompositionalLayout(section: section)

OrthogonalScrolling

再介绍一个官方Demo中提到的稍微复杂一点的Compositional layout案例

我们希望最终效果如下图所示:

Screenshot 2026-03-20 at 16.33.00.png

首先进行设计:

  • 整体纵向滚动,横向上有多行,每一行也是可以滚动的,每一行我们可以看做是一个Section
  • 关注到每一行中的元素,每一行中基本的滚动单元是:左侧的一个大块+右侧两个小块,滚动单元可以认为是Group
  • 具体的大小块则可以认为是Item

关于“左侧的一个大块+右侧两个小块”的示意图如下所示:

image.png

以下是Compositional layout代码,我们对照注释看一下创建过程:

// 1. 左侧大块Item的创建
// - 宽度:希望占容器(group)宽度的70%
// - 高度:希望和容器一样高
let leadingItem = NSCollectionLayoutItem(
    layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.7),
                                      heightDimension: .fractionalHeight(1.0)))
leadingItem.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)

// 2. 右侧任意的一个小块
// - 宽度:和容器一样宽
// - 高度:占容器高度的一半
let trailingItem = NSCollectionLayoutItem(
    layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                      heightDimension: .fractionalHeight(0.5)))
trailingItem.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)

// 3. 创建一个容器group,这是一个纵向的容器,容纳右侧的两个小块Item。宽度占该group所在容器的30%;高度和容器一致
let trailingGroup = NSCollectionLayoutGroup.vertical(
    layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.3),
                                       heightDimension: .fractionalHeight(1.0)),
    repeatingSubitem: trailingItem,
    count: 2)
    
// 4. 创建一个横向容器group,容纳1个大块+2个小块。宽度占其容器的85%,高度占40¥
let containerGroup = NSCollectionLayoutGroup.horizontal(
    layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.85),
                                      heightDimension: .fractionalHeight(0.4)),
    subitems: [leadingItem, trailingGroup])
// 5. 创建section,包含最外层的横向容器group
let section = NSCollectionLayoutSection(group: containerGroup)
section.orthogonalScrollingBehavior = .continuous
// 6. 创建layout,使用默认configuration,默认是纵向滚动
return UICollectionViewCompositionalLayout(section: section)

再看一下数据源代码:

var snapshot = NSDiffableDataSourceSnapshot<Int, Int>()
var identifierOffset = 0
let itemsPerSection = 30
for section in 0..<5 {
    snapshot.appendSections([section])
    let maxIdentifier = identifierOffset + itemsPerSection
    snapshot.appendItems(Array(identifierOffset..<maxIdentifier))
    identifierOffset += itemsPerSection
}
  • 整体数据是一个二维数组,类似这样:[[0,1...29], [30...59], [...], [...], [...]]
  • 创建了5个Section,每个Section
  • 每个Section中有30个数字
  • 30个数字拆分成了10个Group,每个Group有三个Item。如果对应着数据源,则依次是[0,1,2], [3,4,5].....
  • 每个数字表示一个Item

其他

UICollectionLayoutListConfiguration.Appearance

orthogonalScrollingBehavior

orthogonal(发音:/ôrˈTHäɡən(ə)l/):正交。但并非数学上的概念,而是指,与指定方向是正交方向的另一个方向。说白了,如果制定的滚动方向是垂直,则orthogonalScrolling(正交滚动方向)就是水平方向

问题

1. 如何横向滚动

Demo中都是纵向滚动的,Compositional layout是否支持横向滚动?

当然,如下所示,不过要注意一下写法

  • 自定义UICollectionViewCompositionalLayoutConfiguration,设置scrollDirection即可

如果尝试修改因UICollectionViewCompositionalLayout(section: section)而自动创建的UICollectionViewCompositionalLayoutConfiguration.scrollDirection可能不起作用

let configuration = UICollectionViewCompositionalLayoutConfiguration()
configuration.scrollDirection = .horizontal
let layout = UICollectionViewCompositionalLayout(section: section, configuration: configuration)

1. iOS 26.3中带count参数的Group初始化方法有bug

  • 通过horizontal(layoutSize:repeatingSubitem:count:)创建的group,无法做到按照count对item等分布局
  • 但通过horizontal(layoutSize:subitems:)或者已经废弃的horizontal(layoutSize:subitem:count:)可以正确实现

按照如下代码中所示的:

let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, repeatingSubitem: item, count: 3)

2. 官方Demo-AdaptiveSections的bug

  • 官方Demo中,AdaptiveSections部分,会根据容器宽度决定一行显示的列数
  • 但在iOS 26.3中测试,旋转到横屏后,列数并没有按照预期增加
  • 未定位到原因

参考

❌