阅读视图

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

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

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

在Unity URP Shader Graph中,Camera节点是一个功能强大的工具,它允许着色器访问当前渲染摄像机的各种属性和参数。这个节点为着色器提供了与摄像机交互的能力,使得开发者能够创建更加动态和响应式的视觉效果。通过Camera节点,着色器可以根据摄像机的状态、位置和投影特性来调整渲染行为,这在实现高级视觉效果如屏幕空间效果、距离相关效果和视角相关效果时尤为重要。

Camera节点的核心价值在于它打破了传统着色器与场景环境的隔离状态。在传统的着色器编程中,着色器通常只能处理传入的顶点和纹理数据,而无法直接感知场景中的摄像机状态。Camera节点填补了这一空白,为着色器提供了"感知"摄像机的能力,从而开启了更多创意可能性。

描述

Camera节点是Shader Graph中一个专门用于访问和利用摄像机属性的功能节点。它充当着色器与渲染摄像机之间的桥梁,提供了一系列与摄像机相关的数据输出端口。这些数据不仅包括摄像机游戏对象本身的基本属性,如在世界空间中的位置和朝向方向,还涵盖了摄像机的投影参数和渲染设置。

摄像机数据访问的深度解析

Camera节点提供的摄像机参数访问能力可以分为几个主要类别:

空间属性:包括摄像机的位置和方向矢量。这些属性对于实现基于视角的效果至关重要,比如菲涅耳效应、视差映射和动态环境映射。

投影属性:涵盖摄像机的投影类型(透视或正交)、近远平面距离以及Z缓冲区配置。这些参数在实现深度相关效果和屏幕空间效果时非常有用。

正交投影特定属性:专门针对正交摄像机的宽度和高度参数,可用于创建等距视角效果或2D渲染中的特定行为。

技术实现原理

在底层实现上,Camera节点实际上是对Unity内置着色器变量和函数的封装。这些变量包括_WorldSpaceCameraPos_ProjectionParamsunity_OrthoParams等。Shader Graph通过将这些底层变量暴露为节点端口,使得即使不熟悉底层着色器编程的开发者也能轻松使用这些功能。

应用场景广度

Camera节点的应用范围非常广泛,从简单的距离淡化效果到复杂的屏幕空间反射都能看到它的身影。在URP渲染管线中,由于渲染路径和特性集的限制,Camera节点提供的标准化访问方式显得尤为重要,它确保了在不同平台和设备上的一致行为。

支持的渲染管线

Camera节点在Unity的不同渲染管线中有不同的支持情况:

  • 通用渲染管线(URP):完全支持Camera节点,所有端口功能正常可用。URP的设计理念强调轻量化和跨平台兼容性,Camera节点在这一管线中发挥着关键作用,帮助开发者创建高性能的视觉效果。
  • 高清渲染管线(HDRP)支持此节点。HDRP拥有自己的一套摄像机数据访问机制和节点系统,这是由于其架构复杂性和功能集差异所决定的。HDRP提供了更专门的节点来处理摄像机交互,如HD Camera节点。

这种差异主要源于两种渲染管线的设计目标和架构差异。URP旨在提供轻量级、跨平台的渲染解决方案,而HDRP则专注于高端图形效果和物理精确的渲染。因此,在HDRP中,摄像机数据的访问方式更加精细和复杂,无法通过简单的Camera节点来涵盖所有功能。

端口

Camera节点提供了多个输出端口,每个端口都对应着摄像机的一个特定属性或参数。理解这些端口的含义和使用方法是有效利用Camera节点的关键。

Position(位置)端口

Position端口输出摄像机游戏对象在世界空间中的位置坐标,类型为Vector 3。

技术细节

  • 该端口对应内置着色器变量_WorldSpaceCameraPos
  • 返回的是世界空间中的绝对位置坐标
  • 在着色器中可以直接用于距离计算和方向向量构建

应用示例

  • 计算片段到摄像机的距离:float distance = length(WorldPos - _Camera_Position)
  • 创建基于距离的淡化效果
  • 实现视差遮挡映射时计算视角方向

使用技巧

HLSL

// 计算视角方向的标准方法
float3 viewDirection = normalize(_Camera_Position - IN.WorldSpacePosition);

Direction(方向)端口

Direction端口输出摄像机的前向矢量方向,类型为Vector 3。

技术实现

  • 该端口的计算相对复杂,涉及多个矩阵变换
  • 本质上表示摄像机观察方向的单位向量
  • 在世界空间中表示,可以直接用于光照计算和反射计算

核心应用

  • 反射效果中的视角向量计算
  • 基于视角的材质效果(如各向异性材质)
  • 屏幕空间效果的方向基准

重要注意事项

Direction端口输出的方向向量与常见的视角方向计算有所不同。传统上,视角方向计算为摄像机位置 - 表面位置,而Direction端口直接提供摄像机的前向方向。在使用时需要根据具体需求选择合适的向量。

Orthographic(正交)端口

Orthographic端口返回一个浮点值,用于指示摄像机当前是否处于正交模式。

返回值含义

  • 返回1.0表示摄像机是正交摄像机
  • 返回0.0表示摄像机是透视摄像机

技术背景

  • 对应unity_OrthoParams.w变量
  • 在渲染管线内部用于区分不同的投影计算方式

应用场景

  • 创建在透视和正交模式下表现一致的效果
  • 针对2D和3D不同场景的着色器优化
  • UI元素和世界空间元素的协调渲染

使用示例

HLSL

// 根据摄像机模式调整效果强度
float effectStrength = lerp(perspectiveStrength, orthographicStrength, _Camera_Orthographic);

Near Plane(近平面)端口

Near Plane端口输出摄像机的近裁剪平面距离,类型为Float。

技术细节

  • 对应_ProjectionParams.y变量
  • 表示从摄像机位置到近裁剪平面的距离
  • 在深度计算和雾效中起重要作用

主要应用

  • 深度值的重新映射和标准化
  • 基于距离的效果的起始点控制
  • 优化计算,避免处理过于接近摄像机的片段

实际使用

HLSL

// 计算标准化深度值
float linearDepth = (depth - _Camera_NearPlane) / (_Camera_FarPlane - _Camera_NearPlane);

Far Plane(远平面)端口

Far Plane端口输出摄像机的远裁剪平面距离,类型为Float。

技术关联

  • 对应_ProjectionParams.z变量
  • 与Near Plane配合使用定义摄像机的可视范围

核心用途

  • 深度缓冲区的范围定义
  • 雾效和大气效果的远距离控制
  • LOD(细节层次)系统的距离判断

典型应用模式

HLSL

// 判断片段是否在摄像机范围内
float inCameraRange = saturate((distanceToCamera - _Camera_NearPlane) / (_Camera_FarPlane - _Camera_NearPlane));

Z Buffer Sign(Z缓冲区符号)端口

Z Buffer Sign端口返回一个浮点值,指示当前使用的Z缓冲区方向。

返回值解释

  • 返回-1表示使用反转的Z缓冲区
  • 返回1表示使用传统的Z缓冲区

技术背景

  • 对应_ProjectionParams.x变量
  • 反转Z缓冲区是现代图形API中的常见优化技术
  • 影响深度值的比较和计算方式

应用重要性

  • 正确的深度值处理需要考虑到Z缓冲区方向
  • 自定义深度效果必须适应不同的Z缓冲区配置
  • 跨平台兼容性的关键因素

使用示例

HLSL

// 适应不同Z缓冲区配置的深度处理
float adjustedDepth = depth * _Camera_ZBufferSign;

Width(宽度)端口

Width端口输出正交摄像机的宽度值,类型为Float。

特定条件

  • 仅在正交摄像机模式下有实际意义
  • 对于透视摄像机,返回值可能不一致或为0

技术对应

  • 对应unity_OrthoParams.x变量
  • 表示正交摄像机在世界单位中的宽度覆盖范围

应用场景

  • 2D游戏中的像素完美渲染
  • UI元素的世界空间定位
  • 等距视角游戏中的坐标计算

Height(高度)端口

Height端口输出正交摄像机的高度值,类型为Float。

与Width端口的关联

  • 同样仅在正交模式下有效
  • 与Width共同定义正交摄像机的视口范围

实用价值

  • 计算正交摄像机下的屏幕比例
  • 实现响应式2D视觉效果
  • 世界坐标到屏幕坐标的转换

综合使用示例

HLSL

// 计算正交摄像机下的UV坐标
float2 orthoUV = (worldPos.xz - _Camera_Position.xz) / float2(_Camera_Width, _Camera_Height) + 0.5;

生成的代码示例

理解Camera节点在底层生成的代码对于高级着色器开发和调试非常重要。以下是对生成代码的详细解析:

完整代码结构

HLSL

float3 _Camera_Position = _WorldSpaceCameraPos;
float3 _Camera_Direction = -1 * mul(UNITY_MATRIX_M, transpose(mul(UNITY_MATRIX_I_M, UNITY_MATRIX_I_V)) [2].xyz);
float _Camera_Orthographic = unity_OrthoParams.w;
float _Camera_NearPlane = _ProjectionParams.y;
float _Camera_FarPlane = _ProjectionParams.z;
float _Camera_ZBufferSign = _ProjectionParams.x;
float _Camera_Width = unity_OrthoParams.x;
float _Camera_Height = unity_OrthoParams.y;

代码解析与优化建议

位置向量计算

HLSL

float3 _Camera_Position = _WorldSpaceCameraPos;

这是最直接的映射,_WorldSpaceCameraPos是Unity内置的全局变量,在所有着色器 passes 中都可用。

方向向量计算

HLSL

float3 _Camera_Direction = -1 * mul(UNITY_MATRIX_M, transpose(mul(UNITY_MATRIX_I_M, UNITY_MATRIX_I_V)) [2].xyz);

这个计算相对复杂,涉及多个矩阵运算:

  • UNITY_MATRIX_I_V是观察矩阵的逆矩阵
  • UNITY_MATRIX_I_M是模型矩阵的逆矩阵
  • 通过提取第三行([2].xyz)获取前向向量
  • 最后的矩阵乘法将其转换到合适空间

投影参数映射

HLSL

float _Camera_Orthographic = unity_OrthoParams.w;
float _Camera_NearPlane = _ProjectionParams.y;
float _Camera_FarPlane = _ProjectionParams.z;
float _Camera_ZBufferSign = _ProjectionParams.x;

_ProjectionParams是float4向量,各分量存储不同的投影参数:

  • x: Z缓冲区符号
  • y: 近平面距离
  • z: 远平面距离
  • w: 1.0 + Far/Near(用于深度计算)

正交参数访问

HLSL

float _Camera_Width = unity_OrthoParams.x;
float _Camera_Height = unity_OrthoParams.y;

unity_OrthoParams也是float4向量:

  • x: 正交摄像机宽度
  • y: 正交摄像机高度
  • z: 未使用
  • w: 正交模式标志

性能考虑与最佳实践

常量优化

大多数摄像机参数在单帧内是常量,可以考虑在SubShader级别或Pass级别进行预计算,避免逐片段计算。

条件编译

针对不同平台和渲染路径,可以使用条件编译来优化代码:

HLSL

#if defined(ORTHOGRAPHIC_CAMERA)
    // 使用正交特定优化
#else
    // 透视摄像机处理
#endif

矩阵运算优化

复杂的矩阵运算如方向计算可以考虑在顶点着色器中执行,然后通过插值传递给片段着色器,减少计算负担。

实际应用案例

案例1:基于距离的透明度渐变

需求场景

创建一个材质,使得物体在距离摄像机特定范围内逐渐变得透明,用于实现淡入淡出效果。

实现方案

HLSL

// 在Fragment着色器阶段
float3 cameraPos = _Camera_Position;
float nearFadeStart = _Camera_NearPlane + 1.0; // 近平面外1单位开始淡化
float nearFadeEnd = nearFadeStart + 2.0; // 2单位范围内完成淡化

float distanceToCamera = length(worldPos - cameraPos);
float nearAlpha = 1.0 - saturate((distanceToCamera - nearFadeStart) / (nearFadeEnd - nearFadeStart));

// 远距离淡化
float farFadeStart = _Camera_FarPlane - 5.0;
float farFadeEnd = _Camera_FarPlane;
float farAlpha = saturate((distanceToCamera - farFadeStart) / (farFadeEnd - farFadeStart));

float finalAlpha = nearAlpha * farAlpha;

案例2:屏幕空间雪花效果

需求场景

实现一个在下雪天气中,雪花似乎落在屏幕上的效果,而非3D空间中的真实雪花。

技术实现

HLSL

// 使用正交摄像机参数创建屏幕空间效果
float2 screenSpaceUV = IN.ScreenPosition.xy / IN.ScreenPosition.w;

// 根据摄像机模式调整效果
float isOrtho = _Camera_Orthographic;
float2 effectSize = lerp(float2(1.0, 1.0), float2(_Camera_Width, _Camera_Height), isOrtho);

// 创建雪花UV
float2 snowUV = screenSpaceUV * effectSize;
float snow = GenerateSnowPattern(snowUV, _Time.y);

// 混合到最终颜色
color.rgb = lerp(color.rgb, snowColor, snow * isOrtho);

案例3:自适应视差映射

需求场景

创建一种视差映射效果,能够根据摄像机是透视还是正交模式自动调整视差强度。

解决方案

HLSL

// 计算基础视差偏移
float2 parallaxOffset = CalculateParallaxOffset(texcoord, viewDir);

// 根据摄像机模式调整强度
float perspectiveStrength = 0.1;
float orthographicStrength = 0.02; // 正交模式下减弱效果

float adaptiveStrength = lerp(perspectiveStrength, orthographicStrength, _Camera_Orthographic);
parallaxOffset *= adaptiveStrength;

// 应用调整后的偏移
float2 newTexcoord = texcoord + parallaxOffset;

高级技巧与注意事项

性能优化策略

计算时机选择

  • 在顶点着色器中计算摄像机相关向量可以减少片段着色器的负担
  • 对于静态摄像机场景,可以考虑将摄像机参数作为常量传递

精度管理

  • 在世界空间很大的场景中,需要注意浮点精度问题
  • 可以考虑使用相对位置而非绝对位置进行计算

跨平台兼容性

移动平台考虑

  • 在移动设备上,复杂的矩阵运算可能影响性能
  • 建议使用简化计算或查找表方法

图形API差异

  • 不同图形API在Z缓冲区处理上可能有细微差异
  • 建议进行充分的跨平台测试

调试与故障排除

常见问题

  • 方向向量不正确:检查矩阵乘法顺序和空间转换
  • 深度计算错误:验证Z缓冲区符号和深度范围
  • 正交模式异常:确认摄像机设置和参数映射

调试技巧

  • 使用颜色编码可视化各个摄像机参数
  • 创建调试模式,单独测试每个端口的功能
  • 对比内置着色器变量与Camera节点输出的一致性

Camera节点作为URP Shader Graph中的重要组件,为着色器开发提供了强大的摄像机交互能力。通过深入理解其各个端口的功能和底层实现原理,开发者可以创建出更加动态、响应式和视觉丰富的效果。无论是简单的距离淡化还是复杂的屏幕空间效果,Camera节点都能提供必要的技术支持。掌握Camera节点的使用,将显著提升在URP管线中开发高级视觉效果的能力和效率。


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

ArcGIS Pro 中的 Notebooks 入门

^ 关注我,带你一起学GIS ^

前言

Python 脚本使自动化 ArcGIS Pro 中的工作流成为可能。

本教程来源于ESRI官方示例如何在ArcGIS Pro中学习使用Notebooks

文中以ArcGIS Pro3.5为例,默认你已经具备了Python的基础知识。

1. 开发环境

本文使用如下开发环境,以供参考。

时间:2026年

系统:Windows 11

ArcGIS Pro:3.5

Python:3.11.11

2. 数据准备

俗话说巧妇难为无米之炊,数据就是软件开发的基石,没有数据,再美好的设想都是空中楼阁。因此,第一步需要下载GIS数据。

别急,GIS之路公众号都给你准备好了

在公众号后台回复关键字:vector,获取数据下载链接。

而本文示例数据来源于ESRI官方教程,在此感谢ESRI相关工作人员的辛勤付出与免费共享。

数据下载地址https://arcgis.com/sharing/rest/content/items/efb53cb7d7dc4ed8af06821369cf196c/data

下载数据解压完成,将工程添加到地图,可在ArcGIS Pro目录窗口查看图层数据,存储在Toronto.gdb数据库中。

etobicoke、fire_stationsgreenspace要素类添加到当前地图中

对于ArcGIS Pro底图失效的同学,可查看以下文章进行解决。****

ArcGIS Pro 添加底图的方式

3. 创建 notebooks 并运行 Python 代码

ArcGIS Notebooks是一个基于JupyterLab构建的开源 web 应用程序 ,可用于创建和共享包含实时 Python 代码、可视化效果和叙事文本的文档(名为 Notebooks)。

详情请参考文章:ArcGIS Pro 中的 notebook 初识

在ArcGIS Pro的笔记本中创建并运行Python代码具有以下两种方式。

方式一: 点击插入选项卡,在工程窗口中选择New Notebook下拉菜单,然后点击New Notebook。或者存在保存过的笔记本的话,也可以通过Add and Open Notebook打开。

方式二: 点击分析选项卡,选择Python下拉菜单,点击Python Notebook

打开notebook笔记本窗口显示如下,由标题栏、工具栏和代码区组成,主要包括保存、新建、剪切、复制、运行等工具。

笔记本创建完成后将作为新视图在 ArcGIS Pro 的主窗口中显示。新笔记本将以.ipnyb 文件格式存储在工程主目录文件夹中。 新文件夹也会显示在目录窗格的笔记本文件夹下。

单击 Notebook 中的空单元格,轮廓变为蓝色,写入以下代码并运行。

print("Hello Notebook!")

其他使用方法请参考官方教程。

4. 管理单元格中的代码

Notebooks 中的代码将在单元格中运行。 单元格运行后,其顺序通过单元格旁的数字指示。 Notebooks 提供了用于管理单元格的工具。

下面以使用Python列表为例进行演示。

在单元格中定义一个数据列表mylist

mylist = [1, 2, 3, 4, 5]

列表是 Python 中的一种重要数据类型,其中包含一系列元素。 这里的元素为数值,但是列表也可以包含其他数据类型。 列表中的元素以逗号分隔。

在下一个空单元格中,输入下列代码行并运行单元格。

mylist[-1]

系统会对列表中的元素建立索引,索引编号从零开始。 可以使用元素的索引编号获得特定索引。 索引编号 -1 表示第一个元素从列表的末尾(即,最后一个元素)开始, 这会返回数值 6。索引编号为-2则会返回列表中倒数第二个元素,5

通过添加更多元素来更改定义 **mylist** 变量的单元格中的代码,但不要运行单元格。

mylist = [1, 2, 3, 4, 5, 6, 7, 8]

在此单元格下方添加新的单元格,使用代码 mylist[-1] 并单击运行按钮。结果是否与您的预期相符?结果为数值 6。 为什么不是数值 8

Notebook 中的代码逐个单元格输入,上一次使用的变量会存储在内存中。除非您使用重新定义 mylist 变量的代码来运行单元格,否则 mylist 值仍然是存储在内存中的值 [1, 2, 3, 4, 5,6],该列表中位置 -1 处的值仍然为 6

单击定义 mylist = [1, 2, 3, 4, 5, 6, 7, 8] 的行,并运行。然后单击调用代码 mylist[-1] 的单元格运行。输入结果如下,显示为8。

为此,可以选择单元格逐个运行,或者点击运行全部单元格按钮。

5. 在 notebook 中运行地理处理工具

通过以上内容你已对在 notebook 中输入代码进行了一些练习,现在可以打开一个新的笔记本运行一些地理处理工具。在代码开头导入arcpy包。

import arcpy

ArcPy 是 Python 包,它具有 Python 中的大部分可用 ArcGIS Pro 功能,包括地理处理功能。

以下是来自官方的建议。

由于是在 ArcGIS Pro 中使用此 notebook,因此,如果您未导入 ArcPy,使用地理处理工具的代码不会产生错误。 但是,建议您始终在地理处理代码的顶部包括 import arcpy,以便其在 ArcGIS Pro 以外可正常运行。

在同一单元格中添加新行运行以下代码。

GetCount 是 ArcPy 的函数,可运行数据管理工具工具箱中的获取计数地理处理工具。

结果显示在代码单元格下方。 要素类中有 84 行(要素)。 结果与使用 ArcGIS Pro 中的工具对话框运行工具时看到的消息非常相似。Notebooks 集成在 ArcGIS Pro 的地理处理框架中。

这表示在 notebook 中运行某一工具与使用工具对话框运行此工具类似。 在 notebook 中运行的任何工具也同时会显示在历史窗格中。

将 arcpy.GetCount 代码更改为如下所示,运行单元格。

arcpy.GetCount_management("ambulances")

此代码失败,并在消息的末尾,显示以下信息:

ExecuteError: Failed to execute. Parameters are not valid.
ERROR 000732: Input Rows: Dataset ambulances does not exist or is not supported
Failed to execute (GetCount).

很明显,代码运行失败了,用于获取消防站计数的代码在什么情况下正常运行?

要素类 fire_stations 是活动地图中的图层。 在 notebook 中,当使用地理处理工具的图形用户界面以交互方式运行地理处理工具时,可以通过活动地图中的图层名称引用数据集。

要素类 ambulances 未显示在活动地图的图层中,它也不是工程的默认地理数据库中的要素类。 对于不在活动地图或默认地理数据库中的要素类,可以通过指定指向此要素类的完整路径来引用它。

接下来,将查找指向 ambulances 要素类的路径。

目录窗格中,展开数据库部分,然后展开 Toronto.gdb

右键单击 ambulances,然后单击复制路径

即会复制 ambulances 要素类的文件路径。 该路径还包含要素类名称。

E:NotebookStartToronto.gdbambulances

再次运行计数代码,将要素类名称改为前面复制的数据路径。

arcpy.GetCount_management("E:NotebookStartToronto.gdbambulances")

当你以为成功的时候,跳出了最刺眼的红色,你知道废了。

路径看起来没问题,为什么仍然运行失败呢?因为缺少一些内容,还需要添加字母 r,告诉 Python 此路径是原始字符串。

Windows 计算机使用反斜线 () 作为路径分隔符。 在 Python 中,反斜线字符位于字符串中其他字符旁边时将作为转义符,对制表符、换行符或其他特殊字符进行编码。

这就意味着,当路径中 NotebookStart 旁边出现N 时,Python 读取字符串时会认为其中包含换行符。 在字符串之前放置字母 r 旨在告诉 Python 忽略转义符。

添加字母r并运行。

arcpy.GetCount_management(r"E:dataNotebookStartToronto.gdbambulances")

显示结果如下。

如果不想添加字母r,可在数据路径中使用斜杠"/"或者两个反斜杠"\"。以下为在Python中书写数据路径的方式。

  • r"E:\NotebookStart\Toronto.gdb\ambulances"
  • "E:/NotebookStart/Toronto.gdb/ambulances"
  • "E:\NotebookStart\Toronto.gdb\ambulances"

如果你不想为文件设置完整的数据路径,则可以使用工作空间。在导入arcpy包后定义workspace

arcpy.env.workspace = r"E:datashpNotebookStartNotebookStartToronto.gdb"

结果输出信息如下。

如果你想要知道地理数据库中每个要素类的计数。 可以复制单元格并编辑要素类的名称,然而,可以使用Python获取所有要素类的列表,然后对其运行 GetCount 函数。

# 展示工作空间中的要素类列表
fc_list = arcpy.ListFeatureClasses()
# 打印列表
print("##################要素类列表##################")
print(fc_list)
print("##################要素类列表##################n")
# 便利列表

print("##################要素类及其计数##################")
for fc in fc_list:
    # 要素类计数
    count = arcpy.GetCount_management(fc)
    # 打印要素类及其计数
    print(fc, count)
print("##################要素类及其计数##################")

在单元格中运行代码显示结果如下。

6. 使用 notebook 运行分析

本节将使用 notebook 进行一些 GIS 分析工作。 假设用户想要了解 Etobicoke 行政区内哪些区域距离消防站最远。 则可以在 notebook 中使用地理处理工具表示这些区域。

在代码中键入arcpy.,将光标置于"."之后,接着按下tab键可打开代码提示。接着输入模块或工具名称可对提示信息进行过滤,如输入Bu

单机Buffer_analysis工具,将光标至于Buffer_analysis后或者其中,按住【shift+tab】组合键,可以打开工具说明文档。Buffer_analysis 工具的三个必要参数为:输入要素类、输出要素类和缓冲距离。 还有其他可选参数,但只有上述三个为必需的参数。

代码中将缓冲 fire_stations 要素类,命名输出要素类 fire_buffer,使工具以 1000 米的距离建立消防站缓冲区。在单元格中输入以下代码并运行:

arcpy.Buffer_analysis("fire_stations","fire_buffer","1000 METERS")

生成的要素类会添加至当前地图,结果将显示落在距消防站 1000 米(即 1 千米)以内的区域,以及以外的区域。

将缓冲距离更改为 1750 米,并再次运行单元格。

arcpy.Buffer_analysis("fire_stations","fire_buffer","1750 METERS")

显示结果如下。

如果收到错误消息,“ExecuteError: 无法执行。 参数无效”,提到 fire_buffer 已经存在,然后您的 ArcGIS Pro 环境设置、地理处理选项未设置为允许覆盖现有要素类。要修复此问题,在单元格的 arcpy.Buffer_analysis 行前插入新行。 在新的行中,添加以下代码:arcpy.env.overwriteOutput = True,这将允许缓冲区工具覆盖以前的输出。 单元格现在应包含:

arcpy.env.overwriteOutput = True
arcpy.Buffer_analysis("fire_stations""fire_buffer""1750 METERS")

运行单元格。

添加一个新的单元格,并写入以下代码并运行。

arcpy.PairwiseErase_analysis("etobicoke""fire_buffer""no_service")

地图显示结果如下。

此代码会针对 etobicoke 要素类调用 PairwiseErase_analysis 工具,从中擦除 fire_buffer 要素类中的区域,并将结果写入名为 "no_service" 的新要素类。

在图层目录窗格中,右键单击 no_service 图层,然后单击缩放至图层,图层 no_service 将显示距离消防站较远的地方。

对于消防服务而言,得到的区域可能是受影响最大的区域。 通过在 notebook 中以单个单元格的方式运行多行代码可获得更新后的结果。 如果你已在工具的图形用户界面中使用过此工具,则需要再次运行缓冲区工具和成对擦除工具才能获得更新后的结果。

仅两个工具所节省的时间非常有限,但是包含更长工具序列的工作流会节省很多时间。 此外,对于针对多个输入运行同一流程的情况,在循环中运行一个或多个工具的功能,会让 Python 非常有用。

7. 参考资料

  • Notebooks 入门:https://learn.arcgis.com/zh-cn/projects/get-started-with-notebooks-in-arcgis-pro

GIS之路-开发示例数据下载,请在公众号后台回复:vector

全国信息化工程师-GIS 应用水平考试资料,请在公众号后台回复:GIS考试

GIS之路 公众号已经接入了智能 助手,可以在对话框进行提问,也可以直接搜索历史文章进行查看。

都看到这了,不要忘记点赞、收藏 + 关注

本号不定时更新有关 GIS开发 相关内容,欢迎关注 


    

GeoTools 开发合集(全)

OpenLayers 开发合集(全)

GDAL 开发合集(全)

GIS 影像数据源介绍

GeoJSON 数据源介绍

GIS 名词解释

ArcPy,一个基于 Python 的 GIS 开发库简介

GIS 开发库 Turf 介绍

GIS 开发库 GeoTools 介绍

GIS 开发库 GDAL 介绍

地图网站大全

从微信指数看当前GIS框架的趋势

Landsat 卫星数据介绍

OGC:开放地理空间联盟简介

中国地图 GeoJSON 数据集网站介绍

《vue 2 升级vue3 父组件 子组件 传值: value 和 v-model

🧩 v-model 与 value 的关系

当你在一个原生 <input> 上使用 v-model 时,Vue 会将其展开为以下代码:

html

预览

1<!-- 你写的代码 -->
2<input v-model="message">
3
4<!-- Vue 展开后的代码 -->
5<input :value="message" @input="message = $event.target.value">

可以看到,v-model 自动利用了 value 属性来展示数据,并监听 input 事件来更新数据。

🆚 Vue 2 与 Vue 3 的核心区别

1. 响应式原理的变革 (根本原因)

这是导致所有行为差异的根源。

  • Vue 2: 使用 Object.defineProperty。它只能劫持对象的已有属性,无法检测到对象属性的动态添加或删除5。
  • Vue 3: 使用 Proxy。它代理整个对象,可以检测到对象属性的任意变化(增、删、改)25。

2. 组件上 v-model 的默认行为

这是你在开发中感受最明显的区别,尤其是在封装自定义组件时。

表格

特性 Vue 2 Vue 3
默认 Prop value modelValue
默认事件 input update:modelValue
多 Model 支持 不支持,需使用 .sync 修饰符 原生支持多个 v-model

代码对比:

  • Vue 2 中的组件使用

    html

    预览

    1<!-- 父组件 -->
    2<MyComponent v-model="title" />
    3
    4<!-- MyComponent 内部 -->
    5<template>
    6  <!-- 接收 value,触发 input -->
    7  <input :value="value" @input="$emit('input', $event.target.value)" />
    8</template>
    9<script>
    10export default {
    11  props: ['value'] // 默认接收 value
    12}
    13</script>
    
  • Vue 3 中的组件使用

    html

    预览

    1<!-- 父组件 -->
    2<MyComponent v-model="title" />
    3
    4<!-- MyComponent 内部 -->
    5<template>
    6  <!-- 接收 modelValue,触发 update:modelValue -->
    7  <input :value="modelValue" @input="$emit('update:modelValue', $event.target.value)" />
    8</template>
    9<script setup>
    10defineProps(['modelValue']) // 默认接收 modelValue
    11</script>
    

3. v-model 修饰符与多绑定

Vue 3 的 v-model 变得更加强大和灵活。

  • 多个 v-model:Vue 3 允许你在同一个组件上绑定多个 v-model,通过参数名区分14。

    html

    预览

    1<!-- Vue 3 语法 -->
    2<UserEditor 
    3  v-model:name="userName" 
    4  v-model:age="userAge"
    5/>
    

    这在 Vue 2 中是无法直接实现的,通常需要配合 .sync 修饰符来模拟。

  • 自定义修饰符:Vue 3 支持更灵活的修饰符扩展2。

4. 在原生元素上的表现

对于原生的 <input><textarea><select> 等元素,v-model 的用法在 Vue 2 和 Vue 3 中基本一致,都用于简化双向绑定的代码。主要区别体现在自定义组件的封装上。


💡 总结与迁移建议

  1. 核心变化:Vue 3 将组件 v-model 的默认 prop 从 value 改为了 modelValue,事件从 input 改为了 update:modelValue
  2. 迁移注意:当你将 Vue 2 项目升级到 Vue 3 时,所有自定义表单组件如果依赖默认的 v-model 行为,都需要将 props 中的 value 改为 modelValue,并将 $emit('input') 改为 $emit('update:modelValue')1。
  3. 兼容写法:如果你在 Vue 3 中需要兼容旧的组件库(如 Ant Design Vue),它们可能仍然使用 value prop,这时你需要显式地使用 v-model:value 来绑定,而不是简写的 v-model3。

TinyVue 支持 Skills 啦!现在你可以让 AI 使用 TinyVue 组件搭建项目

你好,我是 Kagol,个人公众号:前端开源星球

一个月前,有用户建议 TinyVue 出几个 Skills,方便 AI 编程。

image.png

必须安排上!

目前 TinyVue 组件库和 TinyRobot AI 对话组件均已支持 Agent Skills,你可以在支持 Skills 的 IDE(比如 VSCode、Cursor、Trae 等) 上配置和使用。

1 演示视频

先看下使用效果(以 Trae 为例)。

TinyVue Skills:让 AI 使用 TinyVue 组件生成前端页面:www.bilibili.com/video/BV1d6…

以 Trae 为例,给大家介绍如何安装和配置 TinyVue Skills。

2 安装 TinyVue Skills

在命令行终端中执行以下命令:

npx skills add opentiny/agent-skills -g --skill tiny-vue-skill --agent trae

image.png

安装方式选择 Symlink (Recommended)

安装成功!

image.png

查看 Skills 是否安装成功:

npx skills list -g

查看全局skills.png

3 开启 TinyVue Skills

打开 Trae 的设置页面,在左侧的【规则和技能】菜单中找到【技能】,开启【tiny-vue-skill】这个技能即可。

Trae启用Skill.png

4 在 AI 对话框中使用 TinyVue Skills

在 Trae 中打开 AI 侧栏,输入以下内容:

使用TinyVue组件创建一个登录组件,并集成到App.vue中

AI 会去调用 tiny-vue-skill 技能,根据其中的 SKILL.md 中的描述,去查看对应的组件 API/Demo 文档,然后使用适当的 TinyVue 组件搭建你需要的页面。

这样比 AI 去海量互联网信息中寻找 TinyVue 的用法要准确得多,而且消耗更少的 Token,也不容易产生幻觉。

ai对话.png

如果你正在使用 TinyVue 组件库,强烈推荐你配置上 tiny-vue-skill,让 AI 辅助编码,效率更高!

如果你用的是 VSCode Copilot、Cursor 等其他 IDE也没关系,安装 TinyVue Skills 遵循类似的步骤,只需要把命令中的 --agent 修改成对应的 IDE 即可,以下是对应表格。

比如在 Cursor 中安装 tiny-vue-skill:

npx skills add opentiny/agent-skills -g --skill tiny-vue-skill --agent cursor
Agent --agent 项目内路径 全局路径
Amp amp .agents/skills/ ~/.config/agents/skills/
Antigravity antigravity .agent/skills/ ~/.gemini/antigravity/skills/
Claude Code claude-code .claude/skills/ ~/.claude/skills/
Clawdbot clawdbot skills/ ~/.clawdbot/skills/
Codex codex .codex/skills/ ~/.codex/skills/
Cursor cursor .cursor/skills/ ~/.cursor/skills/
Droid droid .factory/skills/ ~/.factory/skills/
Gemini CLI gemini-cli .gemini/skills/ ~/.gemini/skills/
GitHub Copilot github-copilot .github/skills/ ~/.copilot/skills/
Goose goose .goose/skills/ ~/.config/goose/skills/
Kilo Code kilo .kilocode/skills/ ~/.kilocode/skills/
Kiro CLI kiro-cli .kiro/skills/ ~/.kiro/skills/
OpenCode opencode .opencode/skills/ ~/.config/opencode/skills/
Roo Code roo .roo/skills/ ~/.roo/skills/
Trae trae .trae/skills/ ~/.trae/skills/
Windsurf windsurf .windsurf/skills/ ~/.codeium/windsurf/skills/

联系我们

GitHub:github.com/opentiny/ag…(欢迎 Star ⭐)

TinyVue 官网:opentiny.design/tiny-vue

个人博客:kagol.github.io/blogs/

小助手微信:opentiny-official

公众号:OpenTiny

Cursor配置MasterGo MCP:一键读取设计稿生成高还原度前端代码

通过在 Cursor 的 MCP 设置中添加命令 npx -y @mastergo/magic-mcp --token=YOUR_TOKEN,即可让 AI 直接读取 MasterGo 设计稿数据并自动生成高还原度的前端代码。

前言:告别像素级手磨代码

在传统的前端开发流程中,"从设计到代码"往往是最耗时的环节。开发者需要反复在设计工具和编辑器之间切换,手动测量间距、提取颜色值、计算布局比例。即便如此,最终的样式还原度也难以保证。随着 Cursor 引入了 MCP(Model Context Protocol)协议,MasterGo 官方推出的 Magic MCP 服务彻底打破了这一壁垒。它允许 Cursor AI 直接访问设计稿的底层 DSL 数据,这意味着 AI 不再是"看图说话",而是直接读取精确的设计规范,实现 99% 以上的样式还原。

第一步:获取 MasterGo 访问令牌 (Token)

要让 Cursor 有权限读取你的 MasterGo 文件,首先需要生成一个个人访问令牌。请登录 MasterGo 官网,点击右上角头像进入"个人设置",随后在"设置"菜单中找到"安全设置"选项。在这里你可以看到"生成令牌"的按钮,点击并为该令牌命名(如 Cursor-Integration)。系统会生成一段长字符串,请务必立即复制并妥善保存,因为出于安全考虑,该令牌在关闭窗口后将无法再次查看。 MasterGo MCP - MasterGo 帮助中心

第二步:环境准备与 Node.js 检查

MasterGo MCP 服务是基于 Node.js 运行的,因此在配置之前,请确保你的电脑已安装 Node.js 环境。你可以打开终端(Terminal 或 CMD),输入 node -v 来验证。如果显示版本号(建议 v18 及以上),则说明环境已就绪。如果尚未安装,请前往 Node.js 官网下载长期支持版(LTS)。此外,确保你的 Cursor 版本是最新的,以获得对 MCP 协议的最佳支持。 MasterGo MCP - MasterGo 帮助中心

第三步:在 Cursor 中激活 MasterGo MCP

Cursor 提供了两种配置 MCP Server 的方式,你可以根据自己的习惯选择:

方式一:通过 UI 界面配置(推荐新手)

打开 Cursor 的设置面板(快捷键 Ctrl + Shift + J 或点击右上角齿轮图标),导航至 Features 选项卡下的 MCP 栏目。点击 + Add New MCP Server 按钮,在弹出的窗口中填入以下信息:

  • Name: 建议填写 MasterGo
  • Type: 选择 command
  • Command: 输入 npx -y @mastergo/magic-mcp --token=你的Token(请将"你的Token"替换为第一步中获取的字符串)。

对于 Windows 用户,如果遇到执行权限问题,建议将 Command 栏的内容修改为 cmd /c npx -y @mastergo/magic-mcp --token=你的Token。点击保存后,如果看到绿色的连接状态,说明配置成功。

方式二:通过 mcp.json 配置文件(推荐进阶用户)

如果你更喜欢直接编辑配置文件,或者需要在多个项目中复用相同的 MCP 配置,可以手动创建或修改 mcp.json 文件。该文件通常位于以下位置之一:

  • 项目级别:.cursor/mcp.json(仅当前项目生效)
  • 全局级别:~/.cursor/mcp.json(所有项目生效)

将以下内容粘贴到配置文件中,并将 YOUR_TOKEN 替换为你实际获取的 Token:

{
  "mcpServers": {
    "mastergo-magic-mcp": {
      "command": "npx",
      "args": [
        "-y",
        "@mastergo/magic-mcp",
        "--token=YOUR_TOKEN",
        "--url=https://mastergo.com"
      ],
      "env": {
        "NPM_CONFIG_REGISTRY": "https://registry.npmjs.org/"
      }
    }
  }
}

这里的 env 字段配置了 npm 镜像源,可以有效避免国内网络环境下可能出现的包下载超时问题。保存文件后,重启 Cursor 使配置生效。 mastergo-design/mastergo-magic-mcp

第四步:实战演练——从设计稿到 React/Vue 组件

配置完成后,你就可以在 Cursor Chat 中通过链接直接### **通过在 Cursor 的 MCP 设置中添加命令 npx -y @mastergo/magic-mcp --token=YOUR_TOKEN,即可让 AI 直接读取 MasterGo 设计稿数据并自动生调用设计数据了。在 MasterGo 设计稿中,选中你想要生成的图层或容器,右键选择"复制链接"。回到 Cursor 的对话框,输入类似这样的指令:"参考这个设计稿链接 https://mastergo.com/file/xxxx?layer=yyyy,帮我用 React 和 Tailwind CSS 写一个响应式的商品卡片组件"。

此时,Cursor 会通过 MCP 插件调用 MasterGo 的 API,解析该图层的宽度、高度、圆角、阴影、字体样式以及 Flex 布局信息。它生成的代码将不再是模糊的猜测,而是带有精确像素值和 CSS 变量的高质量组件代码。

进阶技巧与常见问题排查

为了获得更佳的体验,你可以尝试一些高级配置。例如,在配置命令中添加 --cleanLayers=true 参数,可以自动过滤掉设计稿中冗余的编组层,让生成的代码结构更加扁平化。如果遇到连接超时,可以检查是否设置了网络代理,或者尝试在 env 中配置其他 npm 镜像源(如 https://registry.npmmirror.com)。

如果 Cursor 提示找不到工具,请检查 MCP 设置中的状态是否为 Active。有时重启 Cursor 能够解决大部分配置不生效的问题。此外,请确保你提供的 MasterGo 链接包含具体的 layer 参数,否则 AI 可能无法准确定位到具体的设计元素。 mastergo-design/mastergo-magic-mcp

希望这份指南能帮你成功开启 AI 驱动的设计开发新流。祝你的编码过程如丝般顺滑。

从零打造 AI 全球趋势监测大屏

前言

在 AI 浪潮席卷全球的今天,如何直观展示 AI 领域的发展态势成为一个有趣的课题。本文将分享一个「AI 全球趋势监测大屏」的完整技术实现,从技术选型到功能设计,带你一步步构建一个炫酷的数据可视化大屏。

截屏2026-03-03 18.29.52.png

技术栈选型

项目采用现代化的前端技术栈:

技术 版本 用途
React 19.2.3 组件化 UI 框架
TypeScript 5.9.3 类型安全
Vite 7.2.4 极速构建工具
ECharts 6.0.0 数据可视化引擎
Tailwind CSS 4.1.17 原子化 CSS
autofit.js 3.2.8 大屏自适应方案

为什么选择这套技术栈?

1. React 19 + TypeScript

  • 最新的 React 19 带来更好的并发渲染性能
  • TypeScript 提供完善的类型推导,开发体验极佳

2. Vite 7 极速构建

  • 冷启动时间 < 300ms
  • HMR 热更新几乎无感知
  • 生产构建优化,支持单文件打包

3. ECharts 6 数据可视化

  • 支持世界地图、飞线动效、涟漪散点
  • 丰富的交互能力和动画效果
  • 优秀的性能表现

4. autofit.js 大屏适配

  • 一行代码实现 1920×1080 等比缩放
  • 自动处理各种分辨率屏幕

功能模块解析

整个大屏采用经典的「左-中-右」三栏布局,包含 9 大核心组件

🗺️ 全球 AI 模型分布地图(WorldMap)

核心亮点:

  • GeoJSON 动态加载:从 CDN 加载世界地图数据
  • 涟漪散点效果:模型数量越多,散点越大
  • 飞线动画:展示各国 AI 技术交流路径
  • 优雅降级:地图加载失败时自动切换为散点图模式
typescript
// 涟漪效果配置
rippleEffect: {
  brushType: 'stroke',
  scale: 5,
  period: 4,
}

📊 AI 模型热度趋势(TrendChart)

展示 Claude、Gemini、GPT-5、DeepSeek 等主流模型的热度变化曲线:

  • 平滑曲线 + 渐变填充
  • 多系列对比展示
  • 交互式 Tooltip

🏆 各国 AI 模型数量排行(ModelRanking)

  • 动态进度条动画
  • 金银铜牌样式排名
  • 自动高亮轮播效果

🔥 火热模型排行榜(HotModels)

实时展示全球 Top 10 AI 模型:

  • 模型名称、所属公司、国旗标识
  • 涨跌趋势指示(▲/▼)
  • 自动轮播高亮

☁️ AI 热门词汇(HotTerms)

词云式展示当前 AI 领域热词:

  • 点击切换中英文显示
  • 字体大小反映热度权重
  • 浮动动画效果

📈 汇总统计卡片(StatsCards)

四大核心指标数字动画展示:

  • 全球 AI 模型总数
  • 覆盖国家数量
  • 模型参数总量
  • 日均调用量

采用缓动函数实现数字滚动动画:

typescript
const eased = 1 - Math.pow(1 - progress, 3); // 缓出动画

📰 AI 实时资讯(NewsTicker)

无缝滚动的新闻跑马灯:

  • CSS 动画驱动,性能优异
  • 渐变遮罩实现淡入淡出

项目架构

采用模块化组件设计,每个功能独立成文件:

plaintext
src/
├── components/
│   ├── DashboardCard.tsx    # 可复用卡片容器
│   ├── WorldMap.tsx         # 世界地图
│   ├── TrendChart.tsx       # 趋势图表
│   ├── ModelRanking.tsx     # 国家排行
│   ├── HotTerms.tsx         # 热门词汇
│   ├── HotModels.tsx        # 模型排行
│   ├── NewsTicker.tsx       # 新闻滚动
│   ├── StatsCards.tsx       # 统计卡片
│   └── index.tsx            # 统一导出
├── data.ts                  # 数据源
└── App.tsx                  # 主页面

视觉设计要点

科技感配色方案

  • 主色调:#00d4ff(科技蓝)
  • 辅助色:#00ff88(活力绿)、#ffdd00(警示黄)
  • 背景:深邃渐变 + 网格线装饰

细节打磨

  • 卡片四角装饰边框
  • 标题栏渐变 + 指示灯
  • 全局光晕效果
  • 脉冲呼吸动画

大屏适配方案

一行代码搞定所有分辨率:

typescript
autofit.init({
  el: 'body',
  dw: 1920,  // 设计稿宽度
  dh: 1080,  // 设计稿高度
  resize: true,
});

总结

本项目展示了如何利用现代前端技术栈,快速构建一个功能丰富、视觉炫酷的数据可视化大屏。核心要点:

  1. 技术选型:React + TypeScript + Vite + ECharts 黄金组合
  2. 组件化设计:高内聚、低耦合,便于维护扩展
  3. 视觉体验:科技感配色 + 丰富动效
  4. 适配方案:autofit.js 一键解决多分辨率问题

希望这篇文章对你有所启发,欢迎在评论区交流讨论!

欢迎 Star ⭐,一起探索智慧医疗可视化的无限可能!


我放在公众号(柳杉前端) 回复 AI全球趋势监测大屏 获取源码

#前端开发 #数据可视化 #React #智慧城市 #大屏设计

初学React:请求数据参数未更新 && 数据异步状态更新问题

 // 请求参数
const [params, setParams] = useState({
    page: 1,
    per_page: 4,
    begin_pubdate: null,
    end_pubdate: null,
    status: '',
    channel_id: null
  })
  // 点击按钮
 const onFinish = (formValue)=>{
    // 设置参数
    setParams({
      ...params,
      channel_id:formValue.channel_id,
      status: formValue.status,
      begin_pubdate: formValue.date ? formValue.date[0].format('YYYY-MM-DD') : null,
      end_pubdate:  formValue.date ? formValue.date[1].format('YYYY-MM-DD')  :  null
    })
  // 更新表格数据
    getTableList()
  }

首次点击按钮导致请求参数没有更新问题。

原因在于 React 的状态更新是异步的。在 onFinish 函数中,先调用了 setParams 更新筛选参数,然后立即调用 getTableList。但由于 setParams 不会立即修改 params 的值,此时 getTableList 内部读取的仍然是旧的 params,因此第一次请求没有带上新选择的筛选条件。第二次点击时,params 已经更新为上一次的值,所以请求能带上上次的条件,但这次又可能因为同样的原因滞后。

解决方案

1. 在 onFinish 中构造新参数并直接传给 getTableList(推荐)

修改 getTableList 使其接受参数,调用时传入最新的筛选条件。

// 修改 getTableList,增加参数
const getTableList = async (reqParams) => {
  // 如果没有传入参数,则使用当前 state 中的 params(用于首次加载)
  const finalParams = reqParams || params;
  try {
    const res = await http.get('/mp/articles', { params: finalParams });
    const { results, total_count } = res.data;
    setArticleTableList({
      list: results,
      count: total_count
    });
  } catch (error) {
    console.log(error);
  }
};

// 修改 onFinish
const onFinish = (formValue) => {
  // 基于当前 params 和表单值构造新参数对象
  const newParams = {
    ...params,
    channel_id: formValue.channel_id,
    status: formValue.status,
    begin_pubdate: formValue.date ? formValue.date[0].format('YYYY-MM-DD') : null,
    end_pubdate: formValue.date ? formValue.date[1].format('YYYY-MM-DD') : null
  };
  setParams(newParams);          // 更新状态用于后续操作(如分页)
  getTableList(newParams);       // 立即用新参数请求数据
};

2. 使用 useEffect 监听 params 变化自动请求

删除 onFinish 中手动调用 getTableList 的代码,改为依赖 params 的副作用。

useEffect(() => {
  getTableList();
}, [params]); // params 变化时重新请求

const onFinish = (formValue) => {
  setParams({
    ...params,
    channel_id: formValue.channel_id,
    status: formValue.status,
    begin_pubdate: formValue.date ? formValue.date[0].format('YYYY-MM-DD') : null,
    end_pubdate: formValue.date ? formValue.date[1].format('YYYY-MM-DD') : null
  });
  // 不需要再手动调用 getTableList
};

注意:使用 useEffect 时需要确保 params 的引用变化(每次更新都创建新对象),并且首次加载也会触发,因此初始 useEffect 中的手动调用可以移除。

总结

两种方式均可解决问题。第一种更直观,请求时机完全由开发者控制;第二种更符合 React 数据流,但需注意避免额外副作用。根据你的场景选择即可。

如何设计一个真正可扩展的表单生成器?

🧩 如何设计一个真正可扩展的表单生成器?

🧠 你写过多少次 CRUD 表单?登录表单、搜索表单、配置表单、后台管理表单……
有没有想过:为什么不抽象成一套“表单引擎”?

封装一个可以扩展的表单生成器,尤其是对一些中后台系统、低代码平台、企业级项目,会非常有价值。


🚀 一、从“写表单”到“设计表单系统”

我们先看一个普通的表单:

<el-form :model="form">
  <el-form-item label="用户名">
    <el-input v-model="form.username" />
  </el-form-item>

  <el-form-item label="年龄">
    <el-input-number v-model="form.age" />
  </el-form-item>
</el-form>

问题在哪?

  • ❌ 每个页面都写一遍
  • ❌ 字段变化要改代码
  • ❌ 无法动态生成
  • ❌ 后端配置驱动做不了

这时候我们会思考一个问题:

能不能用 JSON 描述表单?


📦 二、Schema 驱动:表单的核心抽象

理想状态是这样:

const schema = [
  {
    type: 'input',
    label: '用户名',
    field: 'username',
    props: {
      placeholder: '请输入用户名'
    }
  },
  {
    type: 'number',
    label: '年龄',
    field: 'age',
  }
]

然后我们写一个 <SchemaForm />

<SchemaForm :schema="schema" v-model="formData" />

这就是:

Schema Driven UI(配置驱动 UI)


🧠 三、设计表单生成器的核心架构

一个成熟的表单生成系统,至少包含 5 个层次:

Schema 配置层
      ↓
字段解析层
      ↓
组件映射层
      ↓
状态管理层
      ↓
渲染引擎层

我们逐层拆解。


🧩 四、组件映射系统设计(核心关键)

最重要的一步:

type → 组件映射

const componentMap = {
  input: ElInput,
  number: ElInputNumber,
  select: ElSelect,
}

渲染逻辑:

const Component = componentMap[item.type]

return h(Component, {
  ...item.props,
  modelValue: formData[item.field],
  'onUpdate:modelValue': (val) => {
    formData[item.field] = val
  }
})

这样我们就实现了:

  • ✅ 动态组件渲染
  • ✅ 双向绑定
  • ✅ 可扩展组件类型

🧱 五、真正高级的地方:扩展能力设计

一个“玩具级”表单生成器和一个“工程级”的区别在于:

可扩展能力

必须支持:

1️⃣ 动态显隐

{
  type: 'input',
  field: 'company',
  visible: (form) => form.role === 'admin'
}

解析时:

if (typeof item.visible === 'function') {
  return item.visible(formData)
}

2️⃣ 联动机制

{
  type: 'select',
  field: 'province',
  onChange: (val, form) => {
    form.city = ''
  }
}

3️⃣ 异步字段(远程选项)

{
  type: 'select',
  field: 'user',
  asyncOptions: () => fetchUserList()
}

4️⃣ 插槽扩展

<SchemaForm>
  <template #customField="{ field }">
    <MyCustomComponent />
  </template>
</SchemaForm>

🧠 六、状态管理怎么设计才优雅?

很多人会这样写:

const formData = reactive({})

但在复杂场景中:

  • 校验
  • dirty 状态
  • touched 状态
  • 提交状态
  • 异步 loading

你需要抽象出一个 FormStore

class FormStore {
  values = reactive({})
  errors = reactive({})
  touched = reactive({})

  setFieldValue(field, value) {
    this.values[field] = value
  }

  validate() {}
}

⚙️ 七、企业级表单生成器的高级能力

真正成熟的系统会支持:

能力 说明
嵌套表单 object / array 结构
动态增删字段 表单列表
表单分组 step 表单
表单布局系统 grid / col 配置
表单 JSON 导出 支持保存配置
拖拽编辑器 低代码场景
远程 schema 后端下发表单配置

🔥 八、进阶认知:为什么很多大厂都在做 Schema Form?

原因很简单:

  • 后端驱动 UI
  • 多系统复用
  • 业务快速迭代
  • 统一规范
  • 降低重复开发成本

Ant Design Pro、阿里飞冰、字节内部平台,核心都在做这件事。


🧠 九、总结:表单生成器的本质是什么?

不是为了“少写代码”。

而是:

把 UI 抽象成数据
把行为抽象成规则
把渲染抽象成引擎

模块化与组件化:90%的前端开发者都没搞懂的本质区别

一位刚入职不久的网友留言问我:"我们一直在说模块化开发、组件化设计,这两个概念到底有什么区别?我感觉它们不就是把代码拆分开来吗?"

今天,我想从自己的角度,聊聊我对这两个概念的深度理解。

什么是模块化?

模块化是代码组织层面的哲学,关注的是"职责边界"。

简单来说,模块化就是把一个复杂的系统,按照功能职责拆分成独立的文件或代码单元。每个模块负责完成特定的功能,对外暴露必要的接口,隐藏内部实现细节。

看一个最朴素的例子:

// math.js - 一个纯粹的数学计算模块
export function add(a, b) {
  return a + b;
}

export function multiply(a, b) {
  return a * b;
}

// 内部实现细节,不对外暴露
function validateNumber(num) {
  if (typeof num !== 'number') {
    throw new Error('参数必须是数字');
  }
}
// app.js - 使用模块
import { add, multiply } from './math.js';

console.log(add(5, 3)); // 8

模块化的核心特征是:

  1. 高内聚:相关功能紧密放在一起
  2. 低耦合:模块之间通过明确定义的接口通信
  3. 封装性:隐藏内部实现细节
  4. 关注点分离:每个模块解决一个特定问题

在ES6之前,我们通过IIFE实现模块化,现在有了原生的ES Module,模块化已经成为JavaScript的基础设施。

什么是组件化?

组件化是UI构建层面的哲学,关注的是"呈现与交互"。

组件化将用户界面拆分成独立的、可复用的部件。每个组件封装了自己的结构(HTML)、样式(CSS)和行为(JavaScript),可以被组合成更复杂的界面。

看一个React组件的例子:

// Button.jsx - 一个UI组件
import React from 'react';
import './Button.css'; // 组件自己的样式

const Button = ({ variant = 'primary', size = 'medium', children, onClick }) => {
  // 内部状态管理
  const [isHovered, setIsHovered] = useState(false);
  
  // 内部逻辑处理
  const handleMouseEnter = () => setIsHovered(true);
  const handleMouseLeave = () => setIsHovered(false);
  
  return (
    <button
      className={`btn btn-${variant} btn-${size} ${isHovered ? 'hovered' : ''}`}
      onClick={onClick}
      onMouseEnter={handleMouseEnter}
      onMouseLeave={handleMouseLeave}
    >
      {children}
    </button>
  );
};

export default Button;
// App.jsx - 组合组件
import React from 'react';
import Button from './Button';

const App = () => {
  return (
    <div>
      <Button variant="primary" size="large" onClick={() => alert('点击')}>
        主要按钮
      </Button>
      <Button variant="secondary" size="small">
        次要按钮
      </Button>
    </div>
  );
};

组件化的核心特征是:

  1. 可组合性:组件可以嵌套组合成复杂界面
  2. 可复用性:同一组件可在不同地方重复使用
  3. 自包含:组件包含自身所需的资源
  4. 接口明确:通过props定义清晰的输入输出

本质区别:一个思想实验

假设我们要开发一个电商网站的用户中心页面。

模块化视角

  • 把用户相关的API请求封装成 userAPI.js 模块
  • 把价格格式化功能封装成 priceFormatter.js 模块
  • 把购物车计算逻辑封装成 cartCalculator.js 模块
  • 这些模块可以在任何地方使用,甚至不在浏览器环境

组件化视角

  • 把用户头像区域做成 UserAvatar 组件
  • 把订单列表做成 OrderList 组件
  • 把商品卡片做成 ProductCard 组件
  • 这些组件组合在一起形成完整的页面

现在,最关键的区别来了:

模块化解决的是"如何组织代码"的问题,组件化解决的是"如何构建界面"的问题。

更本质地说:

  • 模块化的最小单位是函数或文件,关注的是逻辑、数据、功能的封装
  • 组件化的最小单位是UI元素,关注的是视图、交互、样式的封装

但最深刻的认识是:模块化是组件化的基础,组件化是模块化在UI层的具体体现。

实战中的混淆与重构

让我用一个真实的重构案例来说明这两者的区别。

重构前(混淆概念)

// UserProfile.jsx - 一个"组件",但实际上什么都做
import React, { useState, useEffect } from 'react';

const UserProfile = ({ userId }) => {
  const [user, setUser] = useState(null);
  const [orders, setOrders] = useState([]);
  
  // 直接在这里写API调用
  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(setUser);
      
    fetch(`/api/users/${userId}/orders`)
      .then(res => res.json())
      .then(setOrders);
  }, [userId]);
  
  // 直接在这里写复杂的数据处理
  const totalSpent = orders.reduce((sum, order) => {
    // 各种复杂的价格计算逻辑
    return sum + order.amount;
  }, 0);
  
  // 格式化函数直接写在组件里
  const formatDate = (dateStr) => {
    const date = new Date(dateStr);
    return `${date.getFullYear()}-${date.getMonth()+1}-${date.getDate()}`;
  };
  
  return (
    <div>
      <h1>{user?.name}</h1>
      <p>总消费: ¥{totalSpent}</p>
      <div>
        {orders.map(order => (
          <div key={order.id}>
            <span>{formatDate(order.createdAt)}</span>
            <span>¥{order.amount}</span>
          </div>
        ))}
      </div>
    </div>
  );
};

这个"组件"的问题在于:它混淆了组件化和模块化的边界,导致:

  • 组件臃肿难以维护
  • 业务逻辑无法复用
  • 难以测试
  • 代码重复

重构后(明确职责)

// modules/userAPI.js - 纯模块,处理用户数据获取
export const fetchUser = async (userId) => {
  const response = await fetch(`/api/users/${userId}`);
  return response.json();
};

export const fetchUserOrders = async (userId) => {
  const response = await fetch(`/api/users/${userId}/orders`);
  return response.json();
};
// modules/orderCalculator.js - 纯模块,处理订单计算逻辑
export const calculateTotalSpent = (orders) => {
  return orders.reduce((sum, order) => sum + order.amount, 0);
};

export const formatCurrency = (amount) => {
  return new Intl.NumberFormat('zh-CN', { 
    style: 'currency', 
    currency: 'CNY' 
  }).format(amount);
};
// modules/dateFormatter.js - 纯模块,处理日期格式化
export const formatDate = (dateStr, format = 'simple') => {
  const date = new Date(dateStr);
  if (format === 'simple') {
    return `${date.getFullYear()}-${date.getMonth()+1}-${date.getDate()}`;
  }
  // 其他格式...
  return date.toLocaleDateString();
};
// components/OrderItem.jsx - 纯粹的展示组件
const OrderItem = ({ order }) => {
  return (
    <div className="order-item">
      <span>{formatDate(order.createdAt)}</span>
      <span>{formatCurrency(order.amount)}</span>
    </div>
  );
};
// components/UserProfile.jsx - 组合组件,只负责组合和状态管理
import React, { useState, useEffect } from 'react';
import { fetchUser, fetchUserOrders } from '../modules/userAPI';
import { calculateTotalSpent, formatCurrency } from '../modules/orderCalculator';
import OrderItem from './OrderItem';

const UserProfile = ({ userId }) => {
  const [user, setUser] = useState(null);
  const [orders, setOrders] = useState([]);
  
  useEffect(() => {
    fetchUser(userId).then(setUser);
    fetchUserOrders(userId).then(setOrders);
  }, [userId]);
  
  const totalSpent = calculateTotalSpent(orders);
  
  return (
    <div className="user-profile">
      <h1>{user?.name}</h1>
      <p className="total-spent">
        总消费: {formatCurrency(totalSpent)}
      </p>
      <div className="order-list">
        {orders.map(order => (
          <OrderItem key={order.id} order={order} />
        ))}
      </div>
    </div>
  );
};

重构后的代码清晰地体现了:

  • 模块负责数据获取、计算逻辑、格式化等非UI相关的功能
  • 组件负责UI渲染和交互逻辑
  • 模块可以在任何地方使用(甚至在Node.js环境)
  • 组件专注于界面呈现,通过props接收数据和回调

总结

回到最初的问题:模块化和组件化的本质区别是什么?

模块化是一种代码组织思想,它让我们能够将复杂的系统分解成独立的、可维护的代码单元。它关注的是功能的内聚和依赖的管理,解决的是"代码怎么写才不乱"的问题。

组件化是一种UI构建思想,它让我们能够将界面分解成独立的、可复用的部件。它关注的是视图的拆分和组合,解决的是"界面怎么搭才灵活"的问题。

当你能清晰区分这两个概念,你的代码会变得更清晰、更可维护、更容易测试。

互动

看完这篇文章,你对模块化和组件化有了新的认识吗?欢迎在评论区分享你的想法。

如果你觉得这篇文章对你有帮助,点赞、收藏、转发给更多需要的朋友。我们下期再见!

Flutter 如何给图片添加多行文字水印

Flutter 如何给图片添加多行文字水印

最近在做一个工程评估的 App,需要给拍摄的现场照片批量加上多行水印(项目名称、时间、地点等信息),研究了一圈发现网上大多数方案要么太简陋,要么性能拉胯。折腾了几天,总算搞出一套还算满意的方案,记录一下。


效果目标

  • 图片右下角(或底部)显示多行水印文字
  • 文字带阴影,保证在亮色图片上也清晰可见
  • 批量处理时不卡顿,支持大图
  • 可以直接复制使用

实现方案有哪几种?

在 Flutter 里给图片加水印,大体上有三条路可以走,我逐一说一下优缺点,最后也说说我为什么选了第三种。

方案一:Widget 叠加(Stack + Positioned)

最直觉的做法,用 Stack 把水印 Text 覆盖在 Image 上面,用 RepaintBoundary + RenderRepaintBoundary.toImage() 截图导出。

// 示意
Stack(
  children: [
    Image.file(file),
    Positioned(
      bottom: 20,
      left: 20,
      child: Column(
        children: lines.map((l) => Text(l, style: style)).toList(),
      ),
    ),
  ],
)
// 截图导出
final boundary = key.currentContext!.findRenderObject() as RenderRepaintBoundary;
final image = await boundary.toImage(pixelRatio: 3.0);

优点: 写起来最简单,和 Flutter UI 完全一致。
缺点:

  • 必须把 Widget 渲染到屏幕(或离屏树)才能截图,流程繁琐
  • 分辨率受 pixelRatio 控制,原图是 4000px 的大图的话,截出来的质量无法保证
  • 批量处理多张图时,需要反复 build/dispose Widget,性能差

适用场景: 只需要截一张图、预览展示用,不在乎原始分辨率。


方案二:image 包纯 CPU 绘制

image 包自带的 drawString 直接在像素级别写文字。

import 'package:image/image.dart' as img;

final font = img.arial14;   // 内置字体,只有英文
img.drawString(
  imageFile,
  'Hello Watermark',
  font: font,
  x: 20,
  y: imageFile.height - 40,
  color: img.ColorRgb8(255, 255, 255),
);

优点: 纯 Dart 实现,不依赖 Flutter engine,可以丢进 Isolate 完全不阻塞 UI。
缺点:

  • 内置字体只有英文,中文默认无法显示
  • 支持中文需要提前用 BMFont / Hiero 等工具把汉字"烧"进位图字体(BitmapFont),生成 .fnt + atlas PNG 后打包进 assets 加载:
    final font = await img.BitmapFont.fromZip(await rootBundle.load('assets/fonts/chinese.zip'));
    img.drawString(imageFile, '项目名称', font: font, x: 20, y: 100);
    
    但这条路有三个硬伤:① 常用汉字 3500 个,一个字号的 atlas PNG 就可能超过 5MB;② 一个字号需要一套文件,无法动态缩放;③ 水印内容里出现图集里没收录的字,直接空白无报错。
  • 没有文字阴影、不支持自动换行等排版功能

适用场景: 纯英文水印、或水印汉字内容完全固定且字符集可控、同时对 Isolate 隔离有强需求的场景。


方案三:Canvas + TextPainter(本文方案)

借助 Flutter 的 CanvasTextPainter 绘制文字,最终通过 PictureRecorder 录制导出。

image_utils.Image → RGBA 像素 → ui.ImageCanvas 绘制 → JPEG 输出

优点:

  • 完美支持中文、自定义字体、文字阴影、换行等所有排版特性
  • 直接操作像素,输出分辨率和原图完全一致
  • 通过缓存 TextPainterTextStyle,批量处理性能优秀
  • 不需要把 Widget 渲染到屏幕

缺点:

  • 依赖 Flutter engine(dart:ui),不能用纯 Isolate 执行,需要在 UI 线程或 compute 配合使用
  • 代码比方案一复杂一些

适用场景: 需要中文水印、大图高质量输出、批量处理场景,也就是大多数实际业务需求。


三种方案对比

Widget 截图 image 包绘制 Canvas + TextPainter
中文支持
原始分辨率 ⚠️ 依赖 pixelRatio
批量性能
代码复杂度
可用 Isolate
文字阴影/换行

综合下来,方案三是实际项目里最合适的选择,下面直接看实现。


依赖

dependencies:
  image: ^4.0.0   # 用于图片编码/解码

pubspec.yaml 里加上 image 这个包,它提供了 JPEG 编解码能力。Flutter 自带的 dart:ui 负责 Canvas 绘制。


核心思路

整体流程如下:

原始图片字节 → image_utils.Image
     ↓
转为 ui.Image(避免二次编解码)
     ↓
用 Canvas + TextPainter 绘制多行文字
     ↓
录制 Picture → 转回 ui.Image
     ↓
导出 RGBA 字节 → 编码为 JPEG

关键点在于直接用像素数据构建 ui.Image,而不是把图片先编码成 JPEG 再解码,节省了一次无谓的编解码开销。


完整实现

import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:image/image.dart' as image_utils;

class WatermarkUtils {
  // 缓存 TextStyle,同字号复用同一个对象
  static final Map<String, TextStyle> _textStyleCache = {};

  // 缓存 TextPainter,相同文字+字号+宽度直接复用
  static final Map<String, TextPainter> _textPainterCache = {};

  // 复用 Paint 对象,避免重复创建
  static final Paint _imagePaint = Paint()
    ..filterQuality = FilterQuality.medium;

  /// 给 image_utils.Image 添加多行水印,返回 JPEG 字节
  static Future<Uint8List> addWatermark({
    required image_utils.Image imageFile,
    required List<String> lines,
  }) async {
    // 水印从下往上排,先把顺序反转
    final watermarkLines = lines.reversed.toList();

    // 字体大小按图片短边的 1/38 计算,自适应不同分辨率
    final int imageWidth =
        imageFile.width > imageFile.height ? imageFile.height : imageFile.width;
    final int fontSize = imageWidth ~/ 38;

    // Step 1: image_utils.Image → ui.Image(直接用像素,跳过编码)
    final ui.Image originalImage =
        await _createUIImageFromImageUtils(imageFile);

    // Step 2: 用 Canvas 绘制原图 + 水印文字
    final recorder = ui.PictureRecorder();
    final canvas = Canvas(recorder);

    canvas.drawImage(originalImage, Offset.zero, _imagePaint);

    _drawWatermarkTexts(
      canvas,
      watermarkLines,
      imageFile.height,
      fontSize,
      imageWidth,
    );

    // Step 3: 录制结束,生成带水印的 ui.Image
    final watermarkedImage = await recorder
        .endRecording()
        .toImage(originalImage.width, originalImage.height);

    // Step 4: 导出 RGBA 字节
    final ByteData? byteData =
        await watermarkedImage.toByteData(format: ui.ImageByteFormat.rawRgba);

    watermarkedImage.dispose();
    originalImage.dispose();

    if (byteData == null) throw Exception('图片数据转换失败');

    // Step 5: RGBA → JPEG
    return _rgbaToJPEG(
        byteData.buffer.asUint8List(), imageFile.width, imageFile.height);
  }

  // ──────────────────────────────────────────
  //  私有方法
  // ──────────────────────────────────────────

  /// image_utils.Image → ui.Image(不经过 JPEG 编解码)
  static Future<ui.Image> _createUIImageFromImageUtils(
      image_utils.Image img) async {
    final bytes = img.getBytes(order: image_utils.ChannelOrder.rgba);
    final buffer = await ui.ImmutableBuffer.fromUint8List(bytes);
    final descriptor = ui.ImageDescriptor.raw(
      buffer,
      width: img.width,
      height: img.height,
      pixelFormat: ui.PixelFormat.rgba8888,
    );
    final codec = await descriptor.instantiateCodec();
    final frameInfo = await codec.getNextFrame();

    descriptor.dispose();
    codec.dispose();

    return frameInfo.image;
  }

  /// 从底部向上逐行绘制水印文字
  static void _drawWatermarkTexts(
    Canvas canvas,
    List<String> lines,
    int imageHeight,
    int fontSize,
    int imageWidth,
  ) {
    double startY = imageHeight - (fontSize * 2.0);
    const double lineGap = 20;
    final double maxWidth = imageWidth - 40.0;

    for (final line in lines) {
      if (line.isNotEmpty) {
        final rect = _drawText(canvas, line, startY, fontSize,
            maxWidth: maxWidth);
        startY = rect.top - lineGap;
      }
    }
  }

  /// 绘制单行文字,返回绘制区域 Rect(用于计算下一行位置)
  static Rect _drawText(Canvas canvas, String text, double y, int fontSize,
      {double maxWidth = double.infinity}) {
    if (text.isEmpty) return Rect.zero;

    final cacheKey = '${text}_${fontSize}_${maxWidth.toInt()}';
    TextPainter? painter = _textPainterCache[cacheKey];

    if (painter == null) {
      final styleKey = fontSize.toString();
      TextStyle? style = _textStyleCache[styleKey];

      if (style == null) {
        style = TextStyle(
          color: Colors.white,
          fontSize: fontSize.toDouble(),
          shadows: [
            Shadow(
              offset: const Offset(1, 1),
              blurRadius: 3.0,
              color: Colors.black.withOpacity(0.5),
            ),
          ],
        );
        _textStyleCache[styleKey] = style;
      }

      painter = TextPainter(
        text: TextSpan(text: text, style: style),
        textDirection: TextDirection.ltr,
        maxLines: 2,
        textAlign: TextAlign.left,
      )..layout(maxWidth: maxWidth);

      // 缓存上限 50 条,超出时清理一半
      if (_textPainterCache.length >= 50) {
        final keys = _textPainterCache.keys.take(25).toList();
        for (final k in keys) {
          _textPainterCache.remove(k);
        }
      }
      _textPainterCache[cacheKey] = painter;
    }

    final offset = Offset(20, y - painter.height);
    painter.paint(canvas, offset);

    return Rect.fromLTWH(20, y - painter.height, painter.width, painter.height);
  }

  /// RGBA 字节 → JPEG Uint8List
  static Uint8List _rgbaToJPEG(Uint8List rgba, int width, int height) {
    final img = image_utils.Image.fromBytes(
      width: width,
      height: height,
      bytes: rgba.buffer,
      numChannels: 4,
    );
    return Uint8List.fromList(image_utils.encodeJpg(img, quality: 95));
  }

  /// 手动清理缓存(内存敏感场景可调用)
  static void clearCache() {
    _textStyleCache.clear();
    _textPainterCache.clear();
  }
}

调用方式

// 准备水印文字,每个元素一行
final lines = [
  '项目:XX大厦改造工程',
  '位置:3号楼-东立面',
  '时间:2024-06-18 14:32',
  '拍摄人:张三',
];

// imageFile 是通过 image.decodeJpg() 解码的 image_utils.Image
final Uint8List result = await WatermarkUtils.addWatermark(
  imageFile: imageFile,
  lines: lines,
);

// 写入文件
await File('/path/to/output.jpg').writeAsBytes(result);

几个细节说明

1. 为什么不直接用 drawImage + drawParagraph

Flutter 的 Canvas 是基于 ui.Image 工作的,而 image 包解码出来的是自己的 image_utils.Image
最朴素的做法是先把它 encodeJpgdecodeImageFromList,但这样白白多了一次编解码。
更好的方案是直接拿 RGBA 像素数据,通过 ui.ImageDescriptor.raw 构建 ui.Image,速度快很多。

2. 字体大小自适应

final int fontSize = imageWidth ~/ 38;

取图片短边除以 38,这个比例在 1000px~4000px 的图片上效果都比较好,文字不会太小也不会太大。根据实际效果可以调整这个除数。

3. TextPainter 缓存

TextPainter.layout() 是相对耗时的操作。在批量处理多张图片时,如果水印内容相同(比如同一个项目的照片),可以直接复用已经 layout 好的 TextPainter,避免重复计算。

缓存键由 文字内容 + 字号 + 最大宽度 组成,三者相同才复用。

4. 水印位置

目前是从图片底部向上排列,代码里 startYimageHeight - fontSize * 2 开始,每绘制一行就往上移一个文字高度 + 间距(20px)。

如果想改成右下角对齐,把 Offset(20, ...) 里的 20 换成 imageWidth - painter.width - 20 即可。


踩过的坑

  1. toByteData 必须在主线程(或 Isolate 里用 compute
    ui.Image.toByteData 是异步的,但它内部依赖 Flutter engine,不能随意放到普通 Isolate 里,否则会直接崩。

  2. image 包的 Image.fromBytes 默认通道顺序是 RGB
    Flutter 导出的是 RGBA,所以一定要加 numChannels: 4,否则颜色会错乱。

  3. 缓存要设上限
    TextPainter 持有 ParagraphBuilder 等原生资源,不加上限的话批量处理几百张图内存会飙升。


小结

核心就三步:用像素数据直接构建 ui.ImageCanvas 绘文字RGBA 转 JPEG
避开了多余的编解码,加上 TextPainter 缓存,即使批量处理几十张图也不会感觉到卡顿。

代码可以直接复制使用,有问题欢迎留言。

AI 代码审查工具链搭建:用 AST 解析 + LLM 实现自动化 Code Review 的前端工程方案

AI 代码审查工具链搭建:用 AST 解析 + LLM 实现自动化 Code Review 的前端工程方案

团队到了 15 人以上,Code Review 就开始变味了。

不是没人 review,而是 review 变成了"LGTM 流水线"——打开 PR,滚动两屏,留一句 "looks good to me",合并。真正的逻辑问题、潜在的性能隐患、不符合团队规范的写法,全靠运气。

人工 review 的瓶颈不是态度,是带宽。一个资深工程师一天能认真 review 多少个 PR?3 到 5 个,顶天了。剩下的要么排队,要么糊弄。

所以我们开始想:能不能让机器先过一遍,把"明显有问题"的地方标出来,人再去看真正需要判断力的部分?

这就是这篇文章要聊的事——用 AST 解析做结构化分析,用 LLM 做语义级审查,把两者串成一条自动化 Code Review 工具链。


先搞清楚:人工 Review 到底哪里不行?

不是人不行,是人干了太多不该干的活。

一次典型的 Code Review,reviewer 的注意力大概分布在这几个层面:

层面 举例 能否自动化
格式规范 缩进、命名、import 顺序 ESLint/Prettier 已解决
模式违规 组件里直接调 fetch、没用 hooks 封装 AST 可以搞定
逻辑隐患 useEffect 依赖缺失、竞态条件 AST + 规则引擎可以搞定
业务语义 这个字段不该在这里改、这段逻辑和需求不符 需要 LLM
架构决策 该不该拆微服务、该不该用新方案 需要人

ESLint 覆盖了第一层,但第二到第四层基本是裸奔状态。我们要做的,就是把中间这三层自动化掉。


整体架构:两阶段流水线

核心思路一句话:AST 做确定性分析,LLM 做模糊判断

┌─────────────┐     ┌──────────────────┐     ┌─────────────┐
│  Git Diff    │────▶│  AST 结构化分析   │────▶│  规则引擎    │
│  提取变更文件 │     │  提取函数/组件/依赖│     │  输出确定问题 │
└─────────────┘     └──────────────────┘     └──────┬──────┘
                                                     │
                                              ┌──────▼──────┐
                                              │  LLM 语义审查 │
                                              │  上下文 + Diff │
                                              └──────┬──────┘
                                                     │
                                              ┌──────▼──────┐
                                              │  结果聚合     │
                                              │  发 PR Comment│
                                              └─────────────┘

为什么不直接把代码丢给 LLM?后面讲,先看怎么搭。


第一阶段:AST 结构化分析

拿到 Diff,先别急着分析

第一步不是分析代码,是搞清楚改了什么

import { execSync } from 'child_process'

function getChangedFiles(baseBranch = 'main'): string[] {
  const output = execSync(
    `git diff --name-only --diff-filter=ACMR ${baseBranch}...HEAD`
  ).toString()

  return output
    .split('\n')
    .filter(f => f.endsWith('.ts') || f.endsWith('.tsx')) // 只关心 TS/TSX
    .filter(Boolean)
}

拿到文件列表后,逐个解析 AST。这里用 @typescript-eslint/typescript-estree,因为它对 TSX 的支持最好,而且输出的 AST 和 ESLint 生态兼容。

从 AST 中提取"审查素材"

我们不是要遍历整棵树,而是提取 reviewer 真正关心的结构信息:

import { parse } from '@typescript-eslint/typescript-estree'
import { simpleTraverse } from '@typescript-eslint/typescript-estree'

interface ComponentMeta {
  name: string
  hooks: string[]           // 用了哪些 hooks
  deps: string[]            // import 了什么
  stateCount: number        // 多少个 useState
  effectCount: number       // 多少个 useEffect
  lineCount: number         // 函数体行数
  hasCleanup: boolean[]     // useEffect 是否有清理函数
}

function extractComponentMeta(code: string): ComponentMeta[] {
  const ast = parse(code, { jsx: true, loc: true })
  const components: ComponentMeta[] = []

  simpleTraverse(ast, {
    enter(node) {
      // 找到函数组件(大写开头的函数声明/箭头函数)
      if (
        node.type === 'FunctionDeclaration' &&
        node.id?.name?.[0] === node.id?.name?.[0]?.toUpperCase()
      ) {
        const meta = analyzeComponentBody(node, code)
        components.push(meta)
      }
    },
  })

  return components
}

关键在 analyzeComponentBody 里,我们要识别几个高价值信号:

function analyzeComponentBody(node: any, code: string): ComponentMeta {
  const hooks: string[] = []
  let stateCount = 0
  let effectCount = 0
  const hasCleanup: boolean[] = []

  simpleTraverse(node, {
    enter(child) {
      if (
        child.type === 'CallExpression' &&
        child.callee.type === 'Identifier'
      ) {
        const name = child.callee.name

        if (name.startsWith('use')) hooks.push(name)
        if (name === 'useState') stateCount++
        if (name === 'useEffect') {
          effectCount++
          // 检查回调是否返回了清理函数
          const callback = child.arguments[0]
          if (callback?.type === 'ArrowFunctionExpression') {
            const body = callback.body
            // 简化判断:函数体内是否有 return 语句
            const hasReturn = code
              .slice(body.range![0], body.range![1])
              .includes('return')
            hasCleanup.push(hasReturn)
          }
        }
      }
    },
  })

  return {
    name: node.id?.name ?? 'Anonymous',
    hooks,
    deps: [], // 从 import 声明中单独提取
    stateCount,
    effectCount,
    lineCount: node.loc!.end.line - node.loc!.start.line,
    hasCleanup,
  }
}

规则引擎:把经验变成代码

有了结构化信息,规则就好写了。这不是玄学,就是把资深工程师脑子里的"直觉"翻译成条件判断:

interface ReviewIssue {
  level: 'error' | 'warning' | 'info'
  message: string
  file: string
  component: string
}

function applyRules(meta: ComponentMeta, file: string): ReviewIssue[] {
  const issues: ReviewIssue[] = []

  // 规则 1:组件超过 200 行,大概率该拆了
  if (meta.lineCount > 200) {
    issues.push({
      level: 'warning',
      message: `组件 ${meta.name}${meta.lineCount} 行,考虑拆分`,
      file,
      component: meta.name,
    })
  }

  // 规则 2:useState 超过 5 个 → 该用 useReducer 或抽 custom hook
  if (meta.stateCount > 5) {
    issues.push({
      level: 'warning',
      message: `${meta.name}${meta.stateCount} 个 useState,状态管理可能需要重构`,
      file,
      component: meta.name,
    })
  }

  // 规则 3:useEffect 没有清理函数 → 可能有内存泄漏
  meta.hasCleanup.forEach((has, i) => {
    if (!has) {
      issues.push({
        level: 'info',
        message: `${meta.name} 的第 ${i + 1} 个 useEffect 没有 cleanup,确认是否需要`,
        file,
        component: meta.name,
      })
    }
  })

  return issues
}

这一层的好处是零成本、零延迟、百分百确定性。不调 API,不花钱,跑一遍就是几百毫秒的事。


第二阶段:LLM 语义级审查

AST 能告诉你"这个 useEffect 没有 cleanup",但它没法告诉你"这段逻辑有竞态条件"或者"这个状态更新的时机不对"。

这就是 LLM 上场的地方。

Prompt 工程:别把整个文件丢进去

最常见的错误是把整个文件甚至整个 PR 一股脑扔给 LLM。这样做的问题:

  1. Token 浪费严重——一个 PR 改了 20 个文件,8000 行代码,光 input 就烧掉大量 token
  2. 注意力稀释——LLM 在长上下文里容易"走神",真正的问题反而漏掉
  3. 结果不可控——返回一堆格式/命名建议,全是噪音

正确的做法是只给 LLM 它该看的东西

interface LLMReviewContext {
  diff: string              // 只给变更部分,不给整文件
  componentMeta: ComponentMeta  // AST 阶段提取的结构信息
  astIssues: ReviewIssue[]  // 第一阶段已发现的问题(避免重复)
  projectContext: string    // 项目级约定(简短)
}

function buildPrompt(ctx: LLMReviewContext): string {
  return `你是一个资深前端工程师,正在 review 一个 React + TypeScript 项目的 PR。

## 项目约定
${ctx.projectContext}

## 已知问题(AST 分析已发现,不需要重复指出)
${ctx.astIssues.map(i => `- ${i.message}`).join('\n')}

## 组件结构信息
- 组件名:${ctx.componentMeta.name}
- 使用的 Hooks:${ctx.componentMeta.hooks.join(', ')}
- useState 数量:${ctx.componentMeta.stateCount}
- useEffect 数量:${ctx.componentMeta.effectCount}

## 代码变更(Diff)
\`\`\`diff
${ctx.diff}
\`\`\`

请从以下角度审查,只输出有价值的问题,不要指出格式或命名问题:
1. 是否存在竞态条件或时序问题
2. 状态更新逻辑是否正确
3. 是否有潜在的性能问题(不必要的重渲染等)
4. 错误处理是否完整
5. 是否有安全隐患(XSS、注入等)

输出格式:
- [严重程度: high/medium/low] 问题描述
- 涉及代码行
- 建议修改方式`
}

注意看,我们把 AST 阶段的分析结果也传进去了,明确告诉 LLM"这些我已经知道了,别重复说"。这是减少 LLM 输出噪音的关键手段。

调用层:流式 + 超时 + 降级

生产环境不能像 demo 那样裸调 API:

async function callLLMReview(
  prompt: string,
  options: { timeout?: number; model?: string } = {}
): Promise<string> {
  const { timeout = 30_000, model = 'claude-sonnet-4-6' } = options
  const controller = new AbortController()
  const timer = setTimeout(() => controller.abort(), timeout)

  try {
    const response = await fetch('https://api.anthropic.com/v1/messages', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'x-api-key': process.env.ANTHROPIC_API_KEY!,
        'anthropic-version': '2023-06-01',
      },
      body: JSON.stringify({
        model,
        max_tokens: 2000,    // review 结果不需要太长
        messages: [{ role: 'user', content: prompt }],
      }),
      signal: controller.signal,
    })

    const data = await response.json()
    return data.content[0].text
  } catch (err: any) {
    if (err.name === 'AbortError') {
      // 超时降级:只返回 AST 分析结果,LLM 部分跳过
      console.warn('LLM review timeout, falling back to AST-only')
      return ''
    }
    throw err
  } finally {
    clearTimeout(timer)
  }
}

超时不是异常,是常态。LLM 接口抖一下太正常了。降级策略必须在 Day 1 就写好,不是等线上出事再补。


结果聚合:发到 PR 评论里

两个阶段的结果合并后,通过 GitHub API 写回 PR:

async function postReviewComments(
  prNumber: number,
  issues: ReviewIssue[]
): Promise<void> {
  // 按严重程度排序,error 在前
  const sorted = issues.sort((a, b) => {
    const priority = { error: 0, warning: 1, info: 2 }
    return priority[a.level] - priority[b.level]
  })

  // 限制评论数量,超过 10 条就只保留 error 和 warning
  const filtered = sorted.length > 10
    ? sorted.filter(i => i.level !== 'info')
    : sorted

  const body = filtered
    .map(i => {
      const icon = { error: '🔴', warning: '🟡', info: '🔵' }[i.level]
      return `${icon} **[${i.level.toUpperCase()}]** ${i.message}\n> 📍 \`${i.file}\` - \`${i.component}\``
    })
    .join('\n\n---\n\n')

  await octokit.rest.issues.createComment({
    owner: 'your-org',
    repo: 'your-repo',
    issue_number: prNumber,
    body: `## 🤖 Auto Code Review\n\n${body}\n\n---\n*AST 分析 + LLM 审查 | 如有误报请标记 👎*`,
  })
}

为什么限制评论数量?因为一次性抛 30 条 review 意见,等于没说。 没人会看的。


设计权衡:为什么不直接全用 LLM?

这是被问最多的问题。答案很简单——成本、速度、确定性

维度 纯 LLM AST + LLM
单次 PR 成本 0.05 0.05 ~ 0.30 0.01 0.01 ~ 0.08
延迟 15~45 秒 AST < 1秒,LLM 10~30秒
确定性问题检出 可能漏,也可能幻觉 AST 部分 100% 准确
可调试性 黑盒 AST 规则可单步调试

用类比来说:AST 是安检机器,LLM 是安检员。 机器先过一遍,把明确违禁的拦下来;安检员再看机器标记可疑的,做人工判断。你不会让安检员一个一个翻包检查所有人,那队伍排到明年。

还有一个更实际的原因——LLM 会产生幻觉,AST 不会。 当 LLM 告诉你"这里有内存泄漏"的时候,你还得去验证。但 AST 告诉你"这个 useEffect 没有 cleanup",那就是没有,不用验证。


CI 集成:GitHub Actions 实现

# .github/workflows/ai-review.yml
name: AI Code Review
on:
  pull_request:
    types: [opened, synchronize]
    paths:
      - 'src/**/*.ts'
      - 'src/**/*.tsx'

jobs:
  review:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0   # 需要完整 git 历史来算 diff

      - uses: actions/setup-node@v4
        with:
          node-version: 20

      - run: npm ci
      - run: npx ts-node scripts/ai-review.ts
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          PR_NUMBER: ${{ github.event.pull_request.number }}

有一个细节:fetch-depth: 0。默认 checkout 只拉最新一个 commit,算不了 diff。写到这里我开始怀疑人生——每次都有人忘这个配置然后来问"为什么 git diff 是空的"。


可扩展性:从工具到平台

当这套东西跑稳了之后,自然会有新需求冒出来:

1. 规则可配置化

把 AST 规则从硬编码变成配置文件:

// .ai-review.json
{
  "rules": {
    "max-component-lines": { "level": "warning", "threshold": 200 },
    "max-useState-count": { "level": "warning", "threshold": 5 },
    "require-effect-cleanup": { "level": "info", "enabled": true }
  },
  "llm": {
    "model": "claude-sonnet-4-6",
    "maxTokens": 2000,
    "timeout": 30000,
    "focusAreas": ["race-conditions", "security", "performance"]
  },
  "ignore": ["**/*.test.ts", "**/*.spec.ts"]
}

2. 误报反馈闭环

在 PR 评论里加 👎 按钮,收集误报数据。积累到一定量后:

  • 调整 AST 规则阈值
  • 优化 LLM prompt(few-shot 加入真实误报案例)
  • 对特定模式建立白名单

3. 团队知识沉淀

把高频 review 意见提炼成团队规范文档,反哺到 AST 规则库。这不是一次性工具,是一个持续进化的系统。


边界与风险:这东西不是万能的

几个踩过的坑,提前说:

LLM 输出格式不稳定。 你让它按固定格式输出,它大部分时候听话,偶尔抽风。解析 LLM 返回结果时,必须做容错处理,不能假设格式永远正确。用 JSON mode 或者 structured output 会好很多,但也不是 100%。

跨文件分析是个深坑。 AST 解析天然是单文件粒度的。如果一个 PR 改了 A 文件的接口定义,又改了 B 文件的调用方,要关联分析就需要额外做依赖图。TypeScript 的 Language Service API 能帮上忙,但复杂度直接起飞。

不要试图替代人工 review。 这套工具是过滤器,不是替代品。架构决策、业务逻辑的合理性、代码的"品味"——这些东西目前还是得靠人。工具能做的是把 reviewer 的精力从"找明显问题"释放到"思考设计决策"上

成本控制。 一个活跃项目一天可能有几十个 PR,每个 PR 可能触发多次 review(每次 push 都触发)。按 $0.08/次算,一个月也是一笔钱。可以考虑:只在目标分支是 main/release 时触发、只分析变更超过一定行数的 PR、加缓存避免重复分析同一个 commit。


总结:这类问题的通用模型

退一步看,这其实是一个"结构化预处理 + 智能判断"的通用模式。

不只是 Code Review,很多场景都是这个套路:

  • 日志分析:正则提取结构 → LLM 判断根因
  • 文档审查:AST/Schema 校验格式 → LLM 检查内容质量
  • 测试生成:AST 提取函数签名 → LLM 生成测试用例

核心原则就一条:能确定性解决的,不要浪费智能;需要判断力的,不要硬编规则。

把确定性的事交给确定性的工具,把模糊的事交给擅长模糊推理的模型。两者的接缝处——也就是"AST 提取出来的结构化信息如何变成 LLM 的上下文"——才是真正考验工程能力的地方。

这不是什么前沿技术,就是把现有的东西用对地方。但往往最难的,就是"用对地方"这四个字。

[转][译] 从零开始构建 OpenClaw — 第五部分(对话压缩)

原文:Building Openclaw from Scratch — Part 5 (Conversation Compaction)

在本部分,我们添加了区分玩具智能体和真实智能体的功能:上下文窗口管理。~75 行 TypeScript 代码,零自定义摘要代码。

image.png

在第四部分,我们添加了工具循环检测。在本篇中,我们处理了基于 LLM 的任何智能体中最重要的资源限制:上下文窗口是有限的,你的智能体将撞上墙壁。

问题:上下文窗口作为硬资源限制

这里有一个在使用 AI 编程智能体进行实际工作时会立刻显现出来的问题:对话会不断增长。非常快。

一个典型的编程会话看起来是这样的:

Turn 1:  "Read the auth module and explain it"
         → agent reads 3 files (2,000 tokens of tool results)
Turn 2:  "Now refactor it to use JWT"
         → agent reads, edits, runs tests (8,000 tokens)
Turn 3:  "The tests are failing, fix them"
         → agent reads errors, edits 2 files, re-runs (6,000 tokens)
Turn 4:  "Add refresh token support"
         → agent reads docs, creates new file, edits 3 others (12,000 tokens)
...
Turn 15: "Now update the API docs"
         → ERROR: request_too_large

每一轮都会累积消息——用户提示词、助手响应、工具调用、工具结果。工具结果尤其占用空间:单个 bash("cat src/auth.ts") 就可能向上下文中倾倒 500+ 行内容。经过 15-20 轮的积极编程,你将耗尽整个 200K token 的上下文窗口。

这时,智能体就会崩溃。不是优雅地崩溃——它只是抛出一个 API 错误,而你的会话就结束了。

这是演示智能体和生产智能体之间的区别。演示智能体工作 5 轮。实际编码会话持续 50 轮。

为什么要压缩?内存类比

将上下文窗口视为工作内存,而不是存储。它是会议室中的白板——LLM 可以同时看到和推理的一切。当白板满时,你不能简单地贴上更多的白板。你需要擦除旧内容。

但你不能盲目擦除。如果你删除了四小时架构讨论中前一个小时的笔记,你会做出矛盾的决定。你实际做的事情是任何好的记录员都会做的:总结要点,存档细节,继续进行。

这正是对话压缩的作用。大型语言模型将旧轮次总结为精简的回顾,丢弃原始消息,并继续以摘要作为上下文。智能体“记住”发生了什么——做出的决定、修改的文件、遇到的错误——而无需携带完整的对话记录。

这里有一个重要的权衡需要理解:

                    ┌─────────────────┐
                    │  Session Length │
                    └────────┬────────┘
                             │
              ┌──────────────┼──────────────┐
              │              │              │
    ┌─────────▼──────┐   ┌───▼────┐   ┌─────▼──────┐
    │Context Fidelity│   │  Cost  │   │  Latency   │
    └────────────────┘   └────────┘   └────────────┘
  • 更长的会话需要更多的压缩,这意味着更多的信息丢失
  • 更高的保真度意味着保留更多的消息,这意味着更早地达到限制
  • 成本随上下文大小而变化——一个 200K token 的请求比一个 20K 的请求贵 10 倍

压缩让你选择:长时间段压缩历史记录,或短时间段完美回忆。对于编码智能体,长时间段几乎总是赢家——你宁愿记住第 3 回合的模糊记忆,也不希望在第 15 回合崩溃。

“为什么不直接使用更大的上下文窗口?”这是显而易见的问题。即使使用 1M-token 模型,经济性也不会改变。1M 上下文窗口在 60 回合而不是 15 回合内填满。成本急剧增加。延迟增加。最终,你仍然会撞上墙壁。压缩不是解决小上下文窗口的权宜之计——它是如何在任何规模下管理上下文作为资源的方法。

解决方案:总结,而不是累积

修复在概念上很简单:当对话变得太长时,总结旧的部分并丢弃它们。

Before compaction:
┌──────────────────────────────────────────┐
│ System prompt                            │
│ Turn 1: user + assistant + tool results  │  ← old, summarizable
│ Turn 2: user + assistant + tool results  │  ← old, summarizable
│ Turn 3: user + assistant + tool results  │  ← old, summarizable
│ ...                                      │
│ Turn 14: user + assistant + tool results │  ← recent, keep
│ Turn 15: user + assistant + tool results │  ← recent, keep
└──────────────────────────────────────────┘
  Total: 195,000 tokens (at the limit!)


After compaction:
┌──────────────────────────────────────────┐
│ System prompt                            │
│ [Summary of turns 1-13]                  │  ← 500 tokens
│ Turn 14: user + assistant + tool results │  ← kept intact
│ Turn 15: user + assistant + tool results │  ← kept intact
└──────────────────────────────────────────┘
  Total: 45,000 tokens (back to comfortable)

旧消息已经消失了。取而代之的是一个简洁的摘要,它保留了智能体继续智能工作的关键决策、文件操作和上下文。

智能体甚至没有注意到。它将摘要视为先前的上下文,并继续工作。

摘要链如何工作

压缩不是一次性操作。在一个长时间会话中,它多次触发,每次压缩都基于上一次。这形成了一个摘要链:

Compaction #1 (turn 11):
  Input:  [Turn 1] [Turn 2] ... [Turn 10]
  Output: "Summary A: User refactored auth module, added JWT tokens,
           fixed 3 test failures, created refresh token endpoint."

Compaction #2 (turn 21):
  Input:  [Summary A] [Turn 11] [Turn 12] ... [Turn 20]
  Output: "Summary B: (Builds on Summary A) Also added rate limiting,
           updated API docs, migrated database schema."

Compaction #3 (turn 31):
  Input:  [Summary B] [Turn 21] [Turn 22] ... [Turn 30]
  Output: "Summary C: (Builds on Summary B) Deployed to staging,
           fixed CORS issue, added integration tests."

每个摘要都包含上一个摘要加上新消息。大型语言模型收到一个特殊提示词:“这是上一个摘要。这是从那时起的新消息。创建一个更新的摘要,涵盖所有内容。”这是分层压缩——摘要的摘要。

想象一下 git squash 的样子。你失去了单个提交,但最终结果得到了保留。第一轮询问了认证架构。到第 3 次压缩时,该决定被捕获为“用户选择了基于 JWT 的认证”——讨论过程消失了,但结果仍然存在。

信息丢失是渐进的和前重量的。最近的回合被逐字保留。较旧的回合仅以摘要形式存在。最旧的回合在摘要中被摘要——两级压缩。这反映了人类记忆的工作方式:你详细记得今天早上的事情,上周的大致情况,以及上个月的要点。

对于编码智能体来说,这通常是可以的。智能体不需要记住第 3 回合的确切错误输出。它需要知道“我们通过从会话 cookie 切换到 JWT 修复了认证错误”——决策本身,而不是调试日志。

这为什么很难(以及 SDK 为什么很重要)

概念很简单。实现方面有锋利的边缘:

  1. 你在哪里切割?你不能随意在消息边界处切割。如果你在工具调用及其结果之间切割,API 会拒绝格式错误的对话。如果你在回合中途切割(用户问了问题,助手正在回答过程中),你会丢失关键上下文。切割点必须是回合感知的。

SDK 通过逆向遍历算法解决此问题: findCutPoint() 从最新消息开始逆向遍历,累积 token 估计值。当它拥有足够的 token 以保持( keepRecentTokens ,默认 20K)时停止。关键在于,它仅在回合边界处切割——用户消息或助手消息且没有待处理的工具结果。如果切割发生在回合中间,它会检测到分割并单独总结回合前缀。

  1. 你如何总结?你需要调用 LLM 生成总结——但你已经达到上下文限制。总结请求本身可能会溢出。你需要将消息分块,独立地总结每个块,然后合并总结。

SDK 的 generateSummary() 通过 token 预算处理此问题。它为总结提示词的开销预留 token( reserveTokens ,默认 16K),然后在每次总结调用中尽可能多地适配消息。如果消息太大无法单个调用处理,它会将它们分块,总结每个块,然后将块总结合并为最终总结。之前的压缩总结(如果有)被作为上下文传递,以便新总结在此基础上构建而不是从头开始。

  1. 如果摘要失败会怎样?摘要调用本身就是一个 LLM 调用。它可能会失败(速率限制、超时、溢出)。你需要备用策略。

SDK 实现了一个三级备用链:首先,尝试完整摘要。如果失败(例如,消息太大),尝试部分摘要——排除过大的消息并将其标注为 "[Large message (~XK tokens) omitted]" 。如果仍然失败,则返回一个通用的 "Summary unavailable due to context limits" 标记。会话无论如何都会继续——降低的上下文比死会话要好。

  1. 关于恢复循环?当智能体在 session.prompt() 时遇到上下文溢出,你需要检测它、压缩并重试——可能需要多次使用逐步升级的策略。

SDK 运行一个溢出恢复循环,最多尝试 3 次:

Attempt 1: compact() → retry the prompt
Attempt 2: truncate oversized tool results → retry
Attempt 3: compact again → retry
Give up:   return "context_overflow" error to the application

每次尝试都使用不同的策略。工具结果截断尤为重要——单个 bash("find . -type f") 可能会输出 100K 个文件列表项。SDK 将任何单个工具结果限制在上下文窗口的 30%,并截断其余部分。

  1. 什么时候触发?你不想等到溢出——那是最糟糕的压缩时机(你已经处于错误状态)。你想检测到即将接近限制,并主动压缩。

SDK 使用带安全边界的 token 估计。每轮之后,它估计总上下文使用量(使用 chars / 4 启发式,这会略微高估——在这里保守是正确的)。当 contextTokens > contextWindow - reserveTokens 时触发压缩。对于 200K 模型,保留 16K,这意味着大约 184K。这种主动触发意味着智能体几乎从不达到硬溢出——它在到达墙之前就压缩了。

PI SDK 处理所有这五项。 AgentSession 类提供:

  • shouldCompact() — 主动阈值检测
  • prepareCompaction() → compact() — 完整流程
  • 带溢出恢复循环的自动压缩
  • 用于调优的可配置设置

我们的工作是将其连接起来并展示事件。引擎已经构建完成;我们正在将其连接到仪表盘。

实现细节

整个更改在一个文件中,共 75 行: entry.ts 。

1. 两个新的状态变量

let autoCompact = true;    // auto-compaction enabled by default
let lastSession: any = null; // session reference for manual /compact

为什么是 lastSession ?之前,会话是在每个提示词中创建和销毁的。但是 /compact 需要在提示词之间访问会话。所以我们保持会话活跃,并在下一个提示词开始时销毁它(或在 /new 时销毁)。

2. /compact 命令

一个命令中包含三种模式:

if (trimmed === "/compact" || trimmed.startsWith("/compact ")) {
  const arg = trimmed.slice("/compact".length).trim().toLowerCase();
  if (arg === "on") {
    autoCompact = true;
    console.log(`Auto-compaction: on`);
  } else if (arg === "off") {
    autoCompact = false;
    console.log(`Auto-compaction: off`);
  } else {
    if (!lastSession) {
      console.log(`No active session to compact. Send a message first.`);
    } else {
      console.log(`Compacting...`);
      try {
        const result = await lastSession.compact();
        console.log(`Compacted: ${result.tokensBefore} tokens summarized.`);
        const preview = result.summary.length > 200
          ? result.summary.slice(0, 200) + "..."
          : result.summary;
        console.log(`Summary: ${preview}`);
      } catch (compactErr: any) {
        console.error(`Compaction failed: ${compactErr.message}`);
      }
    }
  }
  continue;
}

用法:

  • /compact — 现在手动触发压缩
  • /compact on — 启用自动压缩(默认)
  • /compact off — 禁用自动压缩

手动压缩调用 session.compact() ,该函数:

  1. 找到一个考虑转向的切割点(保留最近的消息,总结其余部分)
  2. 调用 LLM 生成旧消息的摘要
  3. 用会话存储中的摘要替换旧消息
  4. 返回一个 CompactionResult ,包含 summary , tokensBefore 和 firstKeptEntryId

3. 自动压缩线路

在 createAgentSession() 后一行:

session.setAutoCompactionEnabled(autoCompact);

就这样。SDK 现在会在每轮之后监控上下文使用情况。当使用量超过阈值(根据模型的上下文窗口减去安全边界计算得出)时,它会自动压缩。当 API 返回上下文溢出错误时,它会压缩并重试——最多重试 3 次。

4. 压缩事件日志记录

SDK 在压缩发生时会发出事件。我们将它们连接到现有的事件订阅器:

session.subscribe((event: any) => {
  switch (event.type) {
    // Compaction events — always shown (significant lifecycle events)
    case "auto_compaction_start":
      console.error(dim(`[compaction] auto-compacting (${event.reason})...`));
      break;
    case "auto_compaction_end":
      if (event.result) {
        console.error(dim(
          `[compaction] done — ${event.result.tokensBefore} tokens summarized`
        ));
      } else if (event.aborted) {
        console.error(dim(`[compaction] aborted`));
      } else if (event.errorMessage) {
        console.error(dim(`[compaction] failed: ${event.errorMessage}`));
      }
      if (event.willRetry) {
        console.error(dim(`[compaction] will retry...`));
      }
      break;
    // ... verbose-only events (tool calls, thinking, etc.)
  }
});

请注意设计选择:压缩事件总是可见的,不受 /verbose 的限制。工具调用细节大部分时间是噪音,但压缩是一个重要的生命周期事件——这意味着智能体正在重组其内存。用户应该始终知道何时发生这种情况。

这两个事件讲述了一个完整的故事:

  • auto_compaction_start 以 reason: "threshold" (主动)或 reason: "overflow" (被动)的方式触发
  • auto_compaction_end 发送结果或错误信息,如果 SDK 将要重试,还会加上 willRetry

5. 溢出错误检测

当压缩完全失败(3 次重试尝试完毕)时,错误会冒泡到我们的 catch 块中。我们检测到它,并显示一个有用的消息,而不是原始的堆栈跟踪:

} catch (err: any) {
  const msg = err.message ?? "";
  if (/request_too_large|context.*(window|length)|prompt.*too long|request size exceeds/i.test(msg)) {
    console.error("Context overflow: conversation too large for model.");
    console.error("Try /compact to summarize history, or /new to start fresh.");
  } else {
    console.error(`Error: ${msg}`);
  }
}

正则表达式涵盖了来自不同提供者的各种错误格式——Anthropic 说“request_too_large”,OpenAI 说“context length exceeded”,Google 说“prompt is too long”,等等。

6. 会话生命周期变更

之前:

prompt → create session → run → dispose → prompt → create session → ...

现在:

prompt → dispose previous → create session → run → keep alive → prompt → ...

变更虽小但很重要:

// Before the prompt
if (lastSession) { lastSession.dispose(); lastSession = null; }
// ... create session, run prompt ...
// After the prompt (was: session.dispose())
lastSession = session;

会话保持活动状态,以便 /compact 可以访问它。清理操作会在 /new 上、在下一次提示词时或退出 REPL 时发生:

// On /new
if (lastSession) { lastSession.dispose(); lastSession = null; }
// On exit
if (lastSession) lastSession.dispose();

完整的压缩流程

长时间编码会话期间会发生以下情况:

Turn 1-10: Normal operation
  │ Messages accumulate in the session store
  │ Context usage: 20K → 40K → 80K → 120K → 150K tokens
  │
Turn 11: Context hits threshold (~160K of 200K)
  │
  ├─ SDK fires: auto_compaction_start { reason: "threshold" }
  │   └─ You see: [compaction] auto-compacting (threshold)...
  │
  ├─ SDK internally:
  │   1. findCutPoint() — walk backwards, keep ~20K recent tokens
  │   2. prepareCompaction() — extract messages to summarize
  │   3. generateSummary() — LLM call to summarize old messages
  │   4. sessionManager.appendCompaction() — persist summary
  │   5. Reload session with summary + recent messages
  │
  ├─ SDK fires: auto_compaction_end { result, willRetry: false }
  │   └─ You see: [compaction] done — 140,000 tokens summarized
  │
  │ Context usage: 150K → 35K tokens
  │
Turn 12-20: Normal operation again
  │ Context grows from 35K → 130K
  │
Turn 21: Threshold hit again → compaction fires again
  │ This time, the summary includes the PREVIOUS summary
  │ ("Summarize summaries" — hierarchical compaction)
  │
Turn 22+: Continues indefinitely

关键点:压缩会创建一系列摘要。每次压缩都会总结旧消息以及之前的摘要。这是一种分层压缩——摘要的摘要。对话可以无限运行,因为旧上下文会逐渐被压缩。

当事情出错时:

Turn N: API returns "request_too_large"
  │
  ├─ SDK detects: isLikelyContextOverflowError() = true
  │
  ├─ Attempt 1: compact() + retry prompt
  │   └─ Still overflowing? →
  │
  ├─ Attempt 2: truncate oversized tool results + retry
  │   └─ Still overflowing? →
  │
  ├─ Attempt 3: compact again + retry
  │   └─ Still overflowing? →
  │
  └─ Give up: "Context overflow: conversation too large for model."
             "Try /compact to summarize history, or /new to start fresh."

三次重试,策略越来越激进。如果还不够,用户会收到一条清晰的带有可操作选项的消息——而不是原始的 API 错误。

变更内容:差异

src/entry.ts | 75 insertions(+), 9 deletions(-)
 1 file changed

没有新文件。没有新的依赖项。75 行用于支持任意长度的编码会话的连接代码。

以下是分解说明:

改变行目的状态变量 2 autoCompact , lastSession/compact 命令 26 手动压缩 + 开关切换压缩事件 16 始终开启的生命周期日志自动压缩连接 1 session.setAutoCompactionEnabled(autoCompact) 会话生命周期 6 在提示词之间保持活动状态 /compact 溢出错误处理 8 检测溢出,显示有帮助的消息横幅 + 状态 3 显示 /compact 和自动压缩状态

/compact 命令被添加到启动横幅中, /status 现在显示自动压缩状态。

为什么理解这一点比构建它更重要

在大多数教程中,75 行的接线代码不值得用一整篇文章来讲解。但对话压缩则不同,因为概念比代码更重要。

你应该记住以下几点:

  1. 上下文窗口是一个硬资源限制,而不是软限制。它不像 RAM 那样你可以获得交换空间。当你超出它时,API 会拒绝你的请求。就这样。每个严肃的智能体都必须像嵌入式系统对待内存字节一样对待上下文标记——将其视为一个需要预算的有限资源。

  2. 摘要是有损压缩。当你压缩时,你会丢失细节。智能体不会记住第 3 回合的确切错误消息或它在第 7 回合中编辑的具体行号。它会记住它做了什么以及为什么,但不会记住每一个细节。这是一个基本的权衡:对话长度与上下文保真度。

  3. 恢复循环使其达到生产级标准。任何智能体在被要求时都可以进行压缩。区别在于自动检测、使用升级策略重试以及在所有其他方法都失败时进行优雅降级。溢出→压缩→重试→截断→重试→放弃的链式操作是将演示与可用于 8 小时编码会话的东西区分开来的关键。

  4. SDK 级别的关注与应用程序级别的关注。压缩涉及标记估计、上下文感知的切割点、基于 LLM 的摘要、会话存储变异和错误分类。这些都是引擎层面的关注点。我们的应用程序级别的任务是:连接这些组件、展示事件、赋予用户控制权。这种区分——知道什么需要构建与什么需要委托——是智能体开发中的关键技能。

底层的压缩设置

SDK 的压缩行为由三个设置控制(可通过 SettingsManager 进行配置):

interface CompactionSettings {
  enabled: boolean;        // Master switch (default: true)
  reserveTokens: number;   // Reserved for system prompt + overhead (default: 16,384)
  keepRecentTokens: number; // Recent messages to preserve (default: 20,000)
}

算法:

  1. 每轮之后,估计总上下文令牌
  2. 如果 contextTokens > contextWindow - reserveTokens :触发压缩
  3. 从最新消息开始向后遍历,累积令牌估计
  4. 停止当累积 >= keepRecentTokens — 那是切割点
  5. 在切割点之前的内容将被总结
  6. 摘要替换会话存储中的旧消息

默认值对于大多数模型来说是合理的。对于一个 200K 的上下文窗口:

  • 为系统提示词、工具定义和开销预留 16K
  • 保留最近的 2 万条消息
  • 当总数超过~184K 时进行压缩
  • 压缩后,上下文降至约 20K + 摘要(~1-2K)

这为你提供了约 160K 的额外空间,直到下一次压缩——大约有 40-80 轮更多的主动编码空间。

下一步是什么

通过压缩,openclaw-mini 可以处理任意长度的会话。我们从 5 轮的演示转变到了一个可以投入生产的智能体——工具、技能、自我扩展、安全性,现在还有无限内存。

openclaw-mini 的完整源代码可以在 GitHub 上找到。

CSS进阶: background-clip

background-clip 是 CSS 中一个用于控制背景(背景颜色或背景图片)的显示范围的属性。简单来说,它可以决定背景是铺满整个盒子(包括边框)、只铺到边框内部,还是只铺到文字下方。

它的核心作用是限制背景的绘制区域

基本语法与三个主要属性值

1. border-box(默认值)

背景延伸到边框区域的下方(即背景会覆盖边框)。

  • 效果: 如果边框是半透明或点线样式,你能看到边框下面的背景。
  • 示例:
    .box {
        background-clip: border-box;
        /* 背景会铺满整个元素区域,包括边框部分 */
    }
    

image.png

2. padding-box

背景只延伸到内边距(padding)区域,边框下面没有背景

  • 效果: 背景在边框内部就停止了,边框保持纯色(通常是元素本身的背景色或透明)。
  • 示例:
    .box {
        background-clip: padding-box;
        /* 背景只铺到内边距边缘,边框区域无背景 */
    }
    

image.png

3. text(最炫酷、最常用)

将背景裁剪成文字的形态。

  • 效果: 背景只在文字的形状内显示,文字以外的区域背景不可见。
  • 关键配合: 通常需要配合 color: transparent 将文字颜色设为透明,才能看到被裁剪出来的背景。
  • 示例: 实现渐变文字、图片文字效果。
    .text {
        background-image: linear-gradient(45deg, #f00, #00f); /* 设置渐变背景 */
        color: transparent; /* 把文字本身的颜色变透明 */
        background-clip: text; /* 把背景裁剪成文字的形状 */
        -webkit-background-clip: text; /* 某些浏览器需要加前缀 */
    }
    

image.png

直观理解

想象一个带有内边距(padding)、边框(border)和背景色的盒子:

  • border-box:油漆刷满整个盒子,连边框(即使边框是虚线)也覆盖了背景色。
  • padding-box:油漆刷到边框内侧就停止了,边框区域没有油漆,保持原色。
  • text:油漆只涂在文字笔画上,其他地方(包括文字内部的镂空部分)都是透明的。

主要应用场景

  1. 渐变文字(最流行): 使用 background-clip: text 配合渐变色,制作醒目的标题。
  2. 特殊边框效果: 当希望边框是纯色,而背景在边框内部显示时(例如实现双层边框效果),可以使用 padding-box
  3. 精确控制背景平铺: 当不希望背景图延伸到边框下时,通过 padding-box 可以精确控制背景的边界。

浏览器兼容性

我们来具体看一下 background-clip 属性的浏览器兼容性情况。

总的来说,background-clip 的基础功能(border-boxpadding-boxcontent-box)兼容性非常好,可以放心使用。但它的“明星”功能 text 值兼容性稍复杂一些,需要特别注意写法。

我把它们的兼容性情况整理成了表格,方便你查看:

属性值 支持情况 主要细节 兼容性概览
基础值
(border-box, padding-box, content-box)
全面支持 所有现代浏览器及 IE9+ 均支持。 ✅ 很好
text 值
(background-clip: text)
广泛支持,但有细节 Chrome、Edge、Opera:从较早期版本就开始支持。
Safari:从 15.5 版本开始完全支持,早期版本(3.2-15.4)需加 -webkit- 前缀且为部分支持。
Firefox:从 49 版本开始支持,但早期版本(2-48)不支持。
Internet Explorer:全系不支持
移动端:主流浏览器(iOS Safari、Chrome for Android、Samsung Internet 等)基本都支持,但 Opera Mini 全系不支持。
🟡 良好,需注意

关键知识点与最佳实践

结合你之前问到的 background-clip 作用,这里有几个实践中的要点:

  1. text 值的标准写法 为了让 background-clip: text 在所有支持的浏览器上生效,必须同时使用带 -webkit- 前缀和不带前缀的写法。同时,记得将文字颜色设置为透明,背景图才能透出来。

    .gradient-text {
      background-image: linear-gradient(45deg, #ff6b6b, #4ecdc4);
      -webkit-background-clip: text; /* 为基于 WebKit 内核的浏览器添加 */
      background-clip: text;        /* 标准属性 */
      color: transparent;            /* 让文字颜色透明,露出背景 */
      -webkit-text-fill-color: transparent; /* 为 Safari 浏览器添加,增强兼容性 */
    }
    

    这里额外添加了 -webkit-text-fill-color: transparent,可以进一步增强在 Safari 等浏览器上的表现。

  2. Firefox 的特别注意事项 虽然 Firefox 从 49 版本开始支持 background-clip: text,但网上一些资料提到它在部分 Firefox 版本中可能存在问题,或者效果不如 Chrome/Safari 稳定。为了稳妥,可以结合 @supports 进行特性检测,为不支持(或支持不完美)的浏览器提供一个优雅的降级样式。

    .gradient-text {
      /* 默认样式(降级方案),比如一个纯色 */
      color: #ff6b6b;
    }
    
    /* 当浏览器支持 background-clip: text 时,应用渐变效果 */
    @supports (background-clip: text) or (-webkit-background-clip: text) {
      .gradient-text {
        background-image: linear-gradient(45deg, #ff6b6b, #4ecdc4);
        -webkit-background-clip: text;
        background-clip: text;
        color: transparent;
        -webkit-text-fill-color: transparent;
      }
    }
    
  3. 避开已知的坑

    • 不要只写不带前缀的属性:在现代浏览器中,仅写 background-clip: text 可能被忽略。
    • 背景必须用 background-image:使用渐变或图片,纯色背景无法体现裁切效果。
    • 留意边缘渲染:在一些非整数缩放比例或高分辨率屏幕上,文字边缘可能会出现轻微发虚或锯齿。通常使用稍粗一点的字体 (font-weight: 600 或更粗) 可以缓解。

总结一下,background-clip 的基础功能可以无忧使用。如果要用 text 值实现炫酷的文字效果,遵循上述的双前缀、透明文字和降级方案这“三板斧”,就能在绝大多数现代浏览器上获得理想且稳定的效果。

别再用 scoped 了!Vue 项目中真正安全的 CSS 封装方案,第 3 种连尤雨溪都在用

上周,设计师跑来问我:“为什么这个按钮在 A 页面是蓝色,在 B 页面变成紫色了?”

我一查代码,发现两个组件都写了:

.btn {
  background: blue;
}

<style scoped> 根本没生效——因为某个第三方 UI 库用了 :global(.btn),污染了全局。

那一刻我悟了:scoped 不是银弹,它只是“看起来安全”。

今天,我就带你盘点 Vue 项目中 4 种真正可靠的 CSS 封装方案,从“能用”到“企业级”,尤其第 3 种,连 Vue 官方文档和 Vite 团队都在悄悄推广。


先看一张对比表(建议收藏)

方案 隔离性 可维护性 支持动态主题 学习成本
<style scoped> ⚠️ 中(会被 :global 破坏) 低(命名仍可能冲突) ❌ 难
CSS Modules ✅ 强 ⚠️ 需额外处理
CSS-in-JS(如 Vanilla Extract) ✅✅ 极强 ✅ 原生支持 中高
CSS 变量 + 作用域类名(推荐!) ✅ 强 ✅✅ 极高 ✅✅ 天然支持

核心原则:隔离靠机制,不是靠“看起来不一样”


方案 1:<style scoped> —— 谨慎使用!

Vue 的 scoped 通过给元素加 data-v-xxxx 属性实现样式隔离:

<template>
  <button class="btn">Click</button>
</template>

<style scoped>
.btn { color: red; } /* 编译后 → .btn[data-v-f3f3eg9] */
</style>

致命缺陷

  • 无法防止 全局样式污染(比如 reset.css 或 UI 库)
  • 深度选择器>>>:deep())容易误伤其他组件
  • 动态插入的 HTML(如富文本)无法应用 scoped 样式

适用场景:内部工具、小型页面、快速原型

不要用在:对外组件库、多团队协作项目、需要主题切换的系统


方案 2:CSS Modules —— 经典但略重

启用后,每个 class 会被哈希化:

// Button.module.css
.primary { background: blue; }

// Button.vue
import styles from './Button.module.css';
// styles.primary → "Button_primary__aB3cD"
<template>
  <button :class="styles.primary">OK</button>
</template>

优点:

  • 100% 隔离,不怕任何全局污染
  • 支持组合(composes

缺点:

  • 模板里写 :class="styles.xxx" 略啰嗦
  • 不支持原生 CSS 嵌套(除非配合 PostCSS)
  • 动态主题需配合 JS 重新生成

在 Vite 中开启:

// vite.config.ts
export default defineConfig({
  css: { modules: { localsConvention: 'camelCase' } }
})

方案 3:CSS 变量 + 作用域类名(尤雨溪团队推荐!)

这是 Vue 官方新文档Vite 插件生态 中越来越主流的做法。

核心思想:用 CSS 变量定义设计 token,用唯一类名包裹组件

<template>
  <div class="my-button--root">
    <button class="my-button--inner">Submit</button>
  </div>
</template>

<style>
.my-button--root {
  /* 定义局部变量 */
  --btn-bg: var(--theme-primary, #3b82f6);
  --btn-color: white;
}

.my-button--inner {
  background: var(--btn-bg);
  color: var(--btn-color);
  border: none;
  padding: 8px 16px;
  border-radius: 4px;
}
</style>

神奇在哪?

  1. 天然支持主题切换
/* 全局定义亮色主题 */
:root {
  --theme-primary: #3b82f6;
}
/* 暗色主题 */
.dark {
  --theme-primary: #60a5fa;
}

只需切换 <html class="dark">,所有组件自动适配!

  1. 无构建时哈希,调试友好
  2. 类名前缀化(如 my-button--)避免冲突,比随机 hash 更语义化

这正是 ShadCN VueRadix Vue 等现代组件库的做法。


方案 4:零运行时 CSS-in-JS(Vanilla Extract)

如果你追求极致工程化,试试 编译时 CSS-in-JS

// Button.css.ts
import { style } from '@vanilla-extract/css';

export const root = style({
  vars: {
    '--btn-bg': '#3b82f6'
  }
});

export const inner = style({
  background: 'var(--btn-bg)',
  color: 'white',
  borderRadius: 4,
  selectors: {
    '&:hover': { opacity: 0.9 }
  }
});
<script setup lang="ts">
import * as styles from './Button.css';
</script>

<template>
  <div :class="styles.root">
    <button :class="styles.inner">OK</button>
  </div>
</template>

优势:

  • 100% 类型安全(TS 直接提示拼写错误)
  • 零运行时(编译成静态 CSS 文件)
  • 自动作用域(生成哈希类名)
  • 支持主题变量、条件样式

配合 Vite 插件 @vanilla-extract/vite-plugin 即可使用。


实战建议:怎么选?

项目类型 推荐方案
内部后台系统 CSS 变量 + 作用域类名(方案 3)
对外组件库 CSS 变量 + 作用域类名 or Vanilla Extract
快速原型 scoped(但警惕全局污染)
超大型应用(含多主题/国际化) Vanilla Extract(方案 4)

永远不要:

  • 在 scoped 中大量使用 :deep()
  • 把业务样式写进全局 app.css
  • 用 BEM 命名试图“人工隔离”(治标不治本)

各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

花10 分钟时间,把终端改造成“生产力武器”:Ghostty + Yazi + Lazygit 配置全流程

🍄 大家好,我是风筝

🌍 个人博客:【古时的风筝】。

本文目的为个人学习记录及知识分享。如果有什么不正确、不严谨的地方请及时指正,不胜感激。

每一个赞都是我前进的动力。

现在,我把很多时间都贡献给了终端,不管使用 Claude Code 、Codex 还是 Gemini Cli,都是以终端为主了。整个技术圈都有一种返璞归真的状态,以前最常用的 IDE,现在慢慢的退居二线,变成了辅助工具。

所工欲善其事,必先利其器。既然用终端的时间多了,那打造一个强悍的终端环境就非常的有必要了。

下面是我常用的终端布局,大部分情况下都不需要再打开一个 IDE 编辑器了。

即便是在 Obsidian 中,我也放了一个终端进去。

打造最强终端

之前发的文章有有终端截图,有些同学问我用的什么软件,如何配置的。今天我就介绍一下我目前用的终端配置,供大家参考。

本来终端应该就是一个黑窗口,然后再配一套舒服的主题就好了。

但是由于现在代码都在终端写了,就不能只是简简单单只能输入命令了,如何快速的 切窗口、找文件、看 Git 状态都是需要被解决的问题。

直到我把这套组合装起来,一切都迎刃而解了。

这套组合就是:

Ghostty 终端;

Tokyo Night 主题;

JetBrains Mono Nerd Font 字体

Yazi 文件管理;

Lazygit git 管理。

3 行指令安装

只需要复制粘贴下面的代码,然后在终端执行,一切都安装好了。


brew install ghostty yazi lazygit \
  ffmpeg sevenzip jq poppler fd ripgrep fzf zoxide imagemagick
brew tap homebrew/cask-fonts
brew install --cask font-jetbrains-mono-nerd-font

Ghostty

Ghostty 可以说是目前最受欢迎的终端了,作者也开源了核心代码,还衍生出一些二次开发的产品,比如有些衍生终端将标题栏统一放到左侧变成导航了。

Ghostty 启动快、渲染快,配置简单,适合长期盯屏开发。

官网地址: ghostty.org/

JetBrains Mono Nerd Font

只是我之前用习惯了在 IDEA 中用这个字体,所以才用这个,你有其他习惯的字体,直接用你习惯的就好。

安装命令

brew tap homebrew/cask-fonts
brew install --cask font-jetbrains-mono-nerd-font

主题配置

点击 Ghostty 即可打开配置,一个文本文件,在这里可以配置主题以及快捷键之类的功能。

terminalcolors.com/ 这个网站可以下载各种主题的配置信息,其实主要配置的就是颜色值。

下载好主题文件,放到 ~/.config/ghostty/themes目录下,然后在配置文件的 theme属性上设置这个文件名就可以应用了。我比较喜欢的是 Catppuccin 和 Tokyo Night
下面是我的配置,需要的可以直接用。

# Config generated by Ghostty Config
# ─────────────────────────────────────────────────────────────
# 主题配置
# ─────────────────────────────────────────────────────────────
theme = tokyo-night-moon
# 非聚焦窗口透明度
unfocused-split-opacity = 0.5

# window-decoration = "none"
# 设置窗口水平内边距
window-padding-x = 15

# 设置窗口垂直内边距
window-padding-y = 15


# ─────────────────────────────────────────────────────────────
# 字体配置
# ─────────────────────────────────────────────────────────────
font-family = "JetBrainsMono Nerd Font"
font-size = 15

# ─────────────────────────────────────────────────────────────
# 窗口配置
# ─────────────────────────────────────────────────────────────
window-padding-x = 10
window-padding-y = 10

# ─────────────────────────────────────────────────────────────
# 光标配置
# ─────────────────────────────────────────────────────────────
cursor-style = block
cursor-style-blink = true

# ─────────────────────────────────────────────────────────────
# 分屏快捷键
# ─────────────────────────────────────────────────────────────
keybind = cmd+d=new_split:right
keybind = cmd+shift+d=new_split:down
keybind = cmd+alt+left=goto_split:left
keybind = cmd+alt+right=goto_split:right
keybind = cmd+alt+up=goto_split:top
keybind = cmd+alt+down=goto_split:bottom
keybind = cmd+ctrl+left=resize_split:left,50
keybind = cmd+ctrl+right=resize_split:right,50
keybind = cmd+ctrl+up=resize_split:up,50
keybind = cmd+ctrl+down=resize_split:down,50
keybind = cmd+w=close_surface

# ─────────────────────────────────────────────────────────────
# 标签页快捷键
# ─────────────────────────────────────────────────────────────
keybind = cmd+t=new_tab
keybind = cmd+1=goto_tab:1
keybind = cmd+2=goto_tab:2
keybind = cmd+3=goto_tab:3
keybind = cmd+4=goto_tab:4
keybind = cmd+5=goto_tab:5

# ─────────────────────────────────────────────────────────────
# 其他配置
# ─────────────────────────────────────────────────────────────
scrollback-limit = 10000
shell-integration = detect
copy-on-select = true
link-url = true
confirm-close-surface = true

保存后重启 Ghostty或者点击设置中的 Reload Configuration 。

Yazi 终端文件管理

这是一个集成在终端中的文件管理器,凡事在 Finder 中支持的操作它都支持,比如搜索、预览图片和文件、批量处理、vim 操作等等。

开源地址:github.com/sxyazi/yazi

Yazi 常用操作

直接输入 yazi即可启动

yazi

高频按键:

  • j/k 或方向键:上下移动
  • h/l:返回上级/进入目录
  • Space:选中
  • y:复制
  • x:剪切
  • p:粘贴
  • /:过滤搜索
  • .:显示/隐藏隐藏文件
  • q:退出

Lazygit

Lazygit 可以在终端可视化目前的仓库信息,包括当前分支、提交状态、worktree、文件预览、提交记录等等,这么说吧,我用 IDE 时都没办法一直看到这么详细的信息。

我的最佳实践

如下图这个界面,我来告诉你是怎么创建出来的。

首先先打开一个终端,这个终端可以开启 Codex 或 Claude Code,也就是左上角的这个。

然后 cmd+shift+d向下开一个新 tab(在 File 菜单项中也可以操作),在这里可以当做纯粹的终端用,比如执行一个npm 命令、复制移动删除之类的纯手工命令行,也就是左下角这个。

然后聚焦到左上角,快捷键 cmd+d向右新开一个 tab,在这里可以打开 lazygit 或者 yazi ,也就是右上角这个。

接着向下开新 tab,或者聚焦到左下角这个,cmd+d向右开新 tab,打开 lazygit 或yazi,也就是右下角这个。

最后:这套配置到底值不值?

如果你每天都在终端里干活,答案是:值,而且很快回本

它不会让你一夜之间变大神,
但会把“重复动作成本”持续压低。

开发效率的本质,不是做得更快,
而是把不该浪费的注意力省下来,留给真正重要的问题。


还可以看看风筝往期文章

用这个方法,免费、无限期使用 SSL(HTTPS)证书,从此实现证书自由了

为什么我每天都记笔记,主要是因为我用的这个笔记软件太强大了,强烈建议你也用起来

「差生文具多系列」最好看的编程字体

我患上了空指针后遗症

一千个微服务之死

搭建静态网站竟然有这么多方案,而且还如此简单

被人说 Lambda 代码像屎山,那是没用下面这三个方法


从平面到空间:用 React Three Fiber 构建 3D 产品网格

原文:From Flat to Spatial: Creating a 3D Product Grid with React Three Fiber

翻译:TUARAN

欢迎关注 {{前端周刊}},每周更新国外论坛的前端热门文章,紧跟时事,掌握前端技术动态。

一篇实用的实战讲解:使用 React Three Fiber 和 GLSL 构建一个弯曲的 3D 商品网格,涵盖着色器、动画与性能。

作者:Matt Greenberg 分类:Tutorials 日期:2026 年 2 月 24 日

Demo

Code

免费课程推荐:通过 34 节免费视频课、循序渐进的项目以及可上手的演示,用 GSAP 精通 JavaScript 动画。立即报名 →

商品网格就像电商里的“白盒画廊”——默认中性,设计上尽量不冒犯任何人。奇怪的是,真正能推动产品销售的线下体验一直都知道:环境本身就是销售的一部分。光线会替你做决定。陈列传达价值。空间本身也有立场。

而网页版通常会把这些全部放弃。

我想看看要怎样才能缩小这道差距——不是为了新奇噱头,而是一次真正的尝试:让浏览商品的感觉更像“身处某个地方”。这篇文章会带你走完我构建它的过程:用 React Three Fiber 做一个弯曲的 3D 商品网格,配上地形图式的 GLSL 背景、全息风格的选中态,以及带弹簧阻尼的相机控制架构。过程中也会提到一些值得借鉴的模式——包括着色器架构、动画如何做到可打断,以及如何划分 React state 和可变 refs 的边界。

技术栈(The Stack)

这个项目使用的技术栈是 Next.jsReact Three FiberTailwindMotion。两个自定义着色器用 GLSL 编写,并通过 glslify 的 webpack 流水线作为 ES 模块导入。

这里值得单独强调一下 glslify 的配置,因为它是让着色器开发变得“现代化”的关键基础设施。在 next.config.mjs 里用两个 loader 串起来,就可以在 GLSL 内部写 #pragma glslify: snoise = require(&#039;glsl-noise/simplex/2d&#039;),并把编译后的结果作为字符串导入。

架构(Architecture)

整个系统分为四层;搞清楚每一层的起止边界,是保持项目整洁的关键:

┌─────────────────────────────────────────────────┐ │  DOM Layer (Framer Motion)                      │ │  Control bar, filters, minimap, overlays        │ ├─────────────────────────────────────────────────┤ │  Scene Layer (React Three Fiber)                │ │  Canvas, camera rig, lighting                   │ ├─────────────────────────────────────────────────┤ │  Tile Layer (per-card useFrame loops)           │ │  Position, scale, opacity, shader uniforms      │ ├─────────────────────────────────────────────────┤ │  Shader Layer (raw GLSL)                        │ │  Topography background, holographic card sheen  │ └─────────────────────────────────────────────────┘

数据流(Data flow)。 鞋子数据是一个 JSON 数组。每个集合(Nike、New Balance、Budget)都会映射到一个独立数组。筛选(filters)是在某个集合内部缩小范围;切换集合(collection switches)则是直接替换整个数组。

交互循环(Interaction loop)。 画布上的指针事件会更新一个可变的 rigState 对象。相机控制架构(camera rig)每帧读取它,并以阻尼方式向目标值收敛。每个 tile 也读取同一个 rigState 来判断自己是否被选中,然后调整自己的位置、缩放以及着色器的 uniforms。

影响一切的决策,是哪些东西放进 React state、哪些东西用可变 refs 来保存。我是吃过亏才学到的:任何以 60fps 变化的东西——相机位置、tile 的动画进度、着色器 uniforms——都不能放在 React state 里。调和(reconciliation)的开销会把你拖垮。这些值应该放在普通的可变对象里,让 useFrame 回调直接读取。React state 只留给离散的用户行为:当前激活的是哪个集合、设置了哪些筛选条件、选中了哪个 tile。

网格系统(The Grid)

第一个问题是布局。我需要把一份平铺的鞋子列表,排列成 3D 空间中居中的网格,并且要足够灵活,以支持筛选(会改变项目数量)和集合切换(会把一切都换掉)。

Configuration

所有网格参数都放在一个可变的单例里——不是 React state,也不是 context,只是一个普通对象:

const CONFIG = {
  gridCols: 8,
  itemSize: 2.5,
  gap: 0.4,
  zoomIn: 12,
  zoomOut: 31,
  curvatureStrength: 0.06,
  dampFactor: 0.2,
  tiltFactor: 0.08,
  cullDistance: 14,
};

开发期间,我把每一个值都接进了 Leva 的调试控制面板。拖动一个“curvature(曲率)”滑块,看着网格的“碗”形实时加深,对于调出理想手感非常有价值——这是用写死的常量加上不断刷新页面的方式根本做不到的。

Positioning

Tile 的位置通过简单的“按列优先(column-major)”数学计算得到,并以原点为中心:

const spacing = CONFIG.itemSize + CONFIG.gap;
const col = filteredIdx % CONFIG.gridCols;
const row = Math.floor(filteredIdx / CONFIG.gridCols);
const x = col * spacing - gridWidth / 2 + spacing / 2;
const y = -(row * spacing) + gridHeight / 2 - spacing / 2;

X 轴从左到右。Y 轴从上到下。Z 轴则完全留给深度效果——曲率、聚焦以及过渡动画。让 Z 保持“空闲”,事实证明是我早期做过的更好决策之一:这意味着我可以把多个深度效果用叠加的方式组合起来,而不会互相打架。

卡片系统(The Cards)

每只鞋都是一个 ShoeTile —— 一个 <group>,里面包含用于命中测试的平面、带有我们自定义 Shader 材质的图片网格、文字标签以及关闭按钮。

纹理(Textures)

我会在模块级别预加载所有纹理,确保在任何组件挂载之前就完成。这一点没有商量余地——否则在切换集合时,会出现明显的“跳出/突现”(pop-in):纹理会一张张上传到 GPU,导致画面逐个补齐。

shoes.forEach((shoe) => {
  useTexture.preload(shoe.image_url);
});

每个 tile 都会基于已加载的纹理计算符合宽高比的尺寸,因此图片永远不会被拉伸变形。

动画循环(The Animation Loop)

这是整个项目的核心。每个 tile 都运行自己的 useFrame 回调——一个每帧都会执行的函数,用来管理一组动画值,这些值组合起来构成最终的渲染状态。

我一开始试过 GSAP,后来放弃了。问题在于“可中断性”。如果用户在筛选过渡进行到一半时点击某只鞋,那么所有动画都需要平滑地改道。基于时间线(timeline)的系统会和这种需求对着干——你会花更多时间处理取消与清理,而不是写动画逻辑。CSS 动画从来就不是选项;它们无法深入到 WebGL 的 uniform。

最终我选择了非常棒的 maath 里的 easing.damp()——一个与帧率无关的指数阻尼函数。你设置一个目标值,当前值就会追过去;你在动画中途改目标,它就会立刻改道继续追。无需清理,无需取消。

const focusZ = useRef(0);
const curveZ = useRef(0);
const transitionZ = useRef(0);
const animatedPos = useRef({ x, y });
const filterOpacity = useRef(1);
const filterScale = useRef(1);

最终位置由这些相互独立的通道叠加而成:

ref.current.position.set(
  x,
  y + transitionY.current,
  curveZ.current + focusZ.current + transitionZ.current
);

三个 Z 向的贡献是加法叠加的:曲率把远处的 tile 推得更远,聚焦效果把选中的卡片向前“弹出”,过渡偏移负责处理进入/退出。它们各自以不同速度阻尼收敛。由于只是简单相加,因此永远不会相互冲突。

自定义 Shaders(Custom Shaders)

我使用 drei 的 shaderMaterial() 辅助方法写了两个自定义 GLSL 材质。它会给你一个声明式的 JSX 接口(<holoCardMaterial />),背后则由原生 GLSL 驱动。

我选择“按材质写 Shader”,而不是做后期处理(post-processing),原因很明确:我的效果是交互驱动、并且是按卡片(per-card)生效的。全息光泽只会出现在被选中的卡片上;如果用后期 bloom pass,就得处理屏幕上的每个像素,只为了影响一张卡。把效果放在材质里意味着对另外 59 张卡完全没有额外开销。

地形背景(Topography Background)

背景是一个带动画的等高线场——一张“活的”地形图,为场景提供技术感、类似 CAD 的空间深度,但又不会与鞋子的图像争抢注意力。

等高线如何工作(How the Isolines Work)

片元着色器会采样 2D simplex noise(通过 glslify 引入),并让它随时间缓慢漂移:

#pragma glslify: snoise = require('glsl-noise/simplex/2d')
float n = snoise(noiseUv * uScale + uTime * 0.05);

等高线来自一种经典的 isoline 提取技巧:把噪声乘以一个频率,取小数部分来生成重复的条带,然后用一对 smoothstep 在条带边界处雕刻出细线:

float lines = fract(n * 5.0);
float pattern = smoothstep(0.5 - uLineThickness, 0.5, lines)
              - smoothstep(0.5, 0.5 + uLineThickness, lines);

这两个 smoothstep 会在 0.5 处制造一个很窄的峰值——也就是每个条带“回卷”(wrap around)的边界位置。uLineThickness(默认 0.03)控制线宽;5.0 的倍数控制每个噪声 octave 中出现多少圈同心环。我花了不少时间调这些参数——太粗会像加载中的转圈 spinner,太细则在低 DPI 屏幕上几乎看不见。

遮罩与颗粒(Masking and Grain)

一个圆形 mask 让边缘柔和渐隐,胶片颗粒(film grain)则用来防止色带(banding):

float grain = (fract(sin(dot(vUv * 2.0, vec2(12.9898, 78.233))) * 43758.5453) - 0.5) * 0.15;
vec3 finalColor = uColor + grain;
gl_FragColor = vec4(finalColor, pattern * opacity * mask * uOpacity);

整体放在 Z = -15 的平面上,并设置 depthWrite={false}renderOrder={-1},确保它永远不会遮挡卡片。当用户缩放进入某只鞋时,uOpacity 会淡出到 0.25——背景后退但不会消失。

全息卡片材质(Holographic Card Material)

当卡片被选中时,这个材质会添加一道扫过的全息光泽(holographic sheen)。这是我写得最开心的 Shader,因为整个效果完全由一个 uniform 驱动:uActive

顶点“呼吸”(Vertex Breathing)

顶点着色器会对选中的卡片施加轻微的正弦缩放振荡:

float breath = sin(uTime * 2.0) * 0.015 * uActive;
float scale = 1.0 + breath;
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos * scale, 1.0);

uActive 为 0 时,呼吸量会被乘到 0——未选中的卡片不会做任何额外工作。

光泽扫过(The Sheen Sweep)

片元着色器里的光泽效果算是个“意外之喜”。我最初想要的是静态的全息渐变,但把光泽位置直接映射到 uActive 后,就免费得到了这种扫过动画——当 uniform 从 0 动到 1 时,这条光带会自然地滑过整张卡片:

float diagonal = (vUv.x * 0.8) + vUv.y;
float sheenPos = uActive * 2.5;
float sheenWidth = 0.5;
float dist = abs(diagonal - sheenPos);
float intensity = 1.0 - smoothstep(0.0, sheenWidth, dist);
intensity = pow(intensity, 3.0);

X 轴上的 0.8 倍数是“倾斜”(tilt)因子。在标准的 x+yx + y 设定里,渐变会以完美的 45° 角移动。通过让 X 轴的权重略小于 Y 轴,我们把扫光线旋转得更接近竖直方向,这更符合“卡片拿在光源下”的直觉。

pow(intensity, 3.0) 则是我们的“聚焦”(focus)控制。没有它时,光泽会变成一大片宽而浑的泛光。把强度提升到一个幂次,会把较低的值压向 0,只保留峰值,从而让衰减更锐利:从柔和的光晕变成更集中的高光(specular)条纹。

末尾的淡出可以防止光泽停住不走:

float sheenFade = 1.0 - smoothstep(0.7, 1.0, uActive);
vec3 sheenColor = vec3(0.85, 0.92, 1.0) * intensity * 0.9 * sheenFade;
vec3 finalColor = baseColor + sheenColor * texColor.a;

这种偏冷的蓝白色高光采用“叠加”的方式,并通过纹理的 alpha 进行遮罩,以确保效果始终限制在鞋子的轮廓之内。

非对称时序(Asymmetric Timing)

有一个小细节却带来了很大的差异:我在做选中与取消选中时,用不同的阻尼速度来动画 uActive

const activeDamp = isActive ? 0.6 : 0.15;
easing.damp(imageRef.current.material, "uActive", isActive ? 1 : 0, activeDamp, delta);

慢慢进入(0.6s),快速退出(0.15s)。你可以细细品味“显现”的过程,但永远不需要等待“收起”。这种不对称非常微妙,用户不会有意识地察觉到它;但一旦去掉,整个交互就会显得拖沓。

相机 Rig(The Camera Rig)

我从零开始写了一个自定义相机 rig,而不是使用 drei 的 OrbitControls。OrbitControls 提供的是围绕中心点旋转的相机轨道——而我需要的是一个 2D 平移相机:带有边界限制的拖拽、橡皮筋式边缘回弹,以及基于速度的倾斜效果。OrbitControls 里的每一条约束都会和我的需求“对着干”。

工作原理(How It Works)

这个 rig 是一个可变的单例状态,在相机组件与每一个 tile 之间共享:

const rigState = {
  target: new THREE.Vector3(0, 2, 0),
  current: new THREE.Vector3(0, 2, 0),
  velocity: new THREE.Vector3(0, 0, 0),
  zoom: CONFIG.zoomOut,
  isDragging: false,
  activeId: null,
};

指针事件会更新 target。每一帧里,current 会以阻尼方式向 target 靠拢。相机读取的是 current。这种“间接层”正是让一切感觉顺滑的原因——用户输入从来不会被直接应用到相机上。

拖拽与边界(Drag and Bounds)

我用一个距离阈值来区分点击与拖拽(桌面端 5px,触屏 15px)。拖拽灵敏度会随相机距离缩放,从而让平移在任何缩放级别下都保持一致的手感。

当拖过网格边缘时,会触发橡皮筋式阻力——你可以继续超拖 25%,之后才会被硬性夹住。松手后,相机会回弹到边界内。这和 iOS 的滚动回弹是同一种模式:它能在不“硬停”的情况下传达“你到边缘了”。

选中(Selection)

点击某个 tile 会同时触发平移与缩放。被选中的卡片会缩放到 1.5 倍,并在 Z 轴上向前弹出 2 个单位。其它所有卡片会缩小到 0.5 倍,并淡出到 15% 的不透明度——一种非常戏剧化的聚光灯效果。

筛选与集合切换(Filtering and Collection Switching)

这个应用支持两类过渡动画。有意思的是,它们需要完全不同的策略来实现。

原地筛选(In-Place Filtering)

当你在同一个集合内筛选(比如从 “All” 到 “Jordan”)时,我不会卸载再重新挂载这些 tile。那会导致纹理重新上传,而这意味着掉帧。相反,我让匹配的条目平滑地重新排布以填满更密的网格;不匹配的条目则在原地淡出并缩小:

easing.damp(animatedPos.current, "x", basePos.x, 0.2, delta);
easing.damp(animatedPos.current, "y", basePos.y, 0.2, delta);
const targetFilterOpacity = matchesFilter ? 1 : 0;
const targetFilterScale = matchesFilter ? 1 : 0.5;
easing.damp(filterOpacity, "current", targetFilterOpacity, 0.06, delta);

被隐藏的 tile 仍然保持挂载,但不可见——当不透明度低于 0.01 时,将 visible = false。这意味着筛选变化可以做到瞬时响应:没有额外的 GPU 工作,只有 uniform 的变化与位置的重新计算。

集合切换(Collection Switching)

切换集合是更重的操作——鞋子数据完全不同。我用“图层堆栈”的方式解决:旧网格与新网格会短暂共存,各自作为独立组件渲染,并拥有唯一的 React key。

const handleCollectionSwitch = (index) => {
  setGridLayers((prev) => {
    const exitingLayers = prev.map((layer) =>
      layer.mode === "enter"
        ? { ...layer, mode: "exit", startTime: now }
        : layer
    );
    const newLayer = {
      id: `grid-${index}-${now}`,
      items: collectionsData[index],
      mode: "enter",
      startTime: now,
    };
    return [...exitingLayers, newLayer];
  });
  setTimeout(() => {
    setGridLayers((prev) => prev.filter((l) => l.mode === "enter"));
  }, CONFIG.cleanupTimeout);
};

旧网格会朝相机飞来(Z +20),而新网格会从后方进入(Z -50)。每个 tile 都会获得一个随机的错峰延迟。这样读起来更像“爆散”而不是“平移”——这是刻意为之。单纯的交叉淡入淡出会显得很平。Z 轴上的运动带来真实的空间感,而随机错峰则避免了同步运动带来的机械感。

进入的新 tile 还会根据它们在网格中的位置,在 Y 轴上做“散开”:上方的条目从更高的位置开始,下方的条目从更低的位置开始——营造一种“从四面八方汇聚”的感觉。

打磨(Polish)

Dynamic Island(灵动岛)

底部控制栏借鉴了 Apple 的 Dynamic Island 模式:一个单一的玻璃拟态容器,在不同状态之间形变切换。我用的是 Framer Motion 的 layout 属性,因为它能处理 CSS 做不到的一件事——在完全不同的 DOM 结构之间进行动画过渡。

迷你地图(MiniMap)

一个 2D 的 <canvas> 覆盖层会运行自己独立的 requestAnimationFrame 循环,不依赖 R3F。每双鞋用一个点表示,被选中的鞋会发出金色光晕,而一个白色矩形表示当前可见视口。选中时,迷你地图会围绕激活的点平滑缩放到 2.5 倍。

性能(Performance)

有三种技术让我们保持在 60fps:

分片挂载(Time-sliced mounting)。 一次性挂载 60 张带纹理的卡片会造成 GPU 峰值。我改为每帧挂载 5 张,把工作分摊到约 200ms 内。快到让人无感,又慢到足以避免卡顿。我在这里没法用 InstancedMesh——因为每张卡片都有独一无二的纹理、独一无二的标签,以及独一无二的 shader 状态。实例化需要共享材质。



**三级剔除(Three-level culling)。** 每个 tile 都会做三层检查:是否已经完全退出?(直接跳过整个 `useFrame` 回调。)是否超出了视距?(把它隐藏。)它的透明度是否接近 0?(`visible = false`。)这些检查是叠加生效的——一旦某个 tile 在切换集合时已经退出,它就会跳过所有逐帧工作,而不只是跳过渲染。


**一切皆可变(Mutable everything)。** 相机位置、tile 的动画引用、着色器 uniforms ——都在 `useFrame` 里直接做可变更新,从不触碰 React state。唯一会触发重新渲染的时刻,是一些离散的用户操作:选择某个 tile、改变筛选条件、切换集合。


## 结语(Conclusion)


如果要把整个项目浓缩成一句话,那就是:难点不在 3D。难点在于让 3D 消失。没人应该看着这个就觉得“哦,一个 WebGL demo。”他们只该觉得:逛鞋子这件事,比平时稍微有趣了一点。


让我达到这个效果的那些模式——用指数阻尼替代 tween、用逐材质(per-material)着色器替代后期处理、对所有会动的东西都用可变 ref 而不是 React state——并不是什么特别稀奇的技巧。当你不再把 React Three Fiber 当作 demo 框架,而是把它当作生产级框架来对待时,这些选择会很自然地“长出来”。我在这个项目上花的大部分时间并不是在写着色器,而是在调阻尼常量、干掉不必要的重新渲染,并确保在动画进行到一半时改了筛选条件,不会把别的东西弄坏。


如果你在做类似的东西,直接抄这个架构:React 负责结构,GLSL 负责像素,一层很薄的可变状态把两者在 60fps 下桥接起来。其他一切,都是品味问题。

Vite 发展现状与回顾:从“极致开发体验”到生态基础设施

一、引言:从“慢如乌龟”的打包,到“秒起”的开发服务器

在现代前端开发中,“等待”曾经是开发者最深的痛点之一:

  • 启动开发服务器要几十秒甚至一分钟;
  • 改一行代码,热更新需要几秒钟;
  • 项目越来越大,构建越来越慢。

Vite 正是在这样的背景下诞生的。它由 Vue 作者尤雨溪发起,但并不仅仅服务于 Vue,而是试图从根本上改造前端开发体验——利用浏览器原生 ES Modules 能力,在开发阶段不再做整体打包,从而实现“秒级冷启动”“毫秒级热更新”。

经过几年的发展,Vite 已经从“新玩具”成长为主流前端工具链之一,并逐渐演化为一套框架无关、插件生态丰富、可作为底层基建的构建工具。

本文将系统回顾 Vite 的发展脉络与现状,介绍它解决了什么问题、如何实现、有哪些优缺点,并结合实践给出使用建议和未来展望。


二、背景与问题:传统打包工具的瓶颈

2.1 传统开发流程的痛点

在 Vite 之前,主流的前端构建工具是 Webpack、Rollup、Parcel 等。它们大致遵循同一个工作模式:

  1. 构建前解析整个依赖图(从入口文件开始,递归分析 import/require);
  2. 对所有模块进行打包、转换、压缩
  3. 输出一个或多个 bundle(如 app.jsvendor.js 等);
  4. 开发时借助内存文件系统和 HMR 插件来实现热更新。

在项目规模较小时,这种模式还算可接受。然而,当你面对的是一个大型单页应用(SPA)或多页应用(MPA)时,问题凸显:

  • 冷启动慢:

    • 首次启动 dev server,需要构建完整 bundle,几十秒乃至数分钟;
  • 热更新慢:

    • 修改一个组件,工具需要重新构建受影响的模块树,随着项目增大愈发明显;
  • 配置复杂:

    • 特别是 Webpack,配置复杂度陡增,各种 loader、plugin 的组合需要很强经验;
  • 现代特性支持滞后:

    • 比如原生 ES Modules、HTTP2、多核并行等,工具演进相对缓慢。

总结一下,是“重打包、重构建、重配置”导致了开发体验上的痛苦。

2.2 浏览器原生 ESM 带来的新机会

随着现代浏览器基本都支持 原生 ES Modules(ESM)

<script type="module" src="/src/main.js"></script>

浏览器可以直接解析 JavaScript 模块、处理 import 语句,而不再强制依赖打包工具把所有内容“打平”到一个文件里。这带来了一个关键启发:

既然浏览器可以帮我们处理模块加载,开发阶段是否可以完全不打包,只在需要时对单个模块做编译?

Vite 正是抓住了这个机会,提出了**“按需编译 + 原生 ESM”**的开发模式。


三、Vite 的方案与技术实现

3.1 核心理念概述

Vite 可以拆分成两个阶段的不同角色:

  1. 开发阶段(dev server)

    • 利用原生 ESM
    • 实现无需打包的极速冷启动按需转换,配合 HMR
  2. 生产阶段(build)

    • 默认使用 Rollup 进行传统打包优化(代码分割、Tree-shaking、压缩等)
    • 保证产物体积、兼容性与性能

这个“双形态”的设计兼顾了开发体验和生产性能。

3.2 开发模式:按需编译 + 原生 ESM

Vite 的开发服务器主要做几件事:

  1. 拦截浏览器请求
    浏览器访问 http://localhost:5173/src/main.ts 时,Vite 接管请求。

  2. 对源码做最小必要的编译与转换
    如:

    • TypeScript -> JavaScript
    • JSX/TSX -> JavaScript
    • .vue 单文件组件解析
    • PostCSS / CSS Modules 处理
    • 路径别名重写、依赖预构建处理
  3. 按请求返回结果
    每个模块是一个独立的 HTTP 响应,浏览器通过 ESM 按需加载依赖。

这种方式带来的直接好处:

  • 冷启动几乎只依赖“服务器启动时间”,而非“打包全量依赖时间”;
  • 第一次访问页面时,只会编译当前路由实际加载的模块;
  • 模块编译结果可以被缓存,后续请求快很多。

3.2.1 简单示例:Vite + Vue

安装与初始化:

# 使用 npm 也可以
pnpm create vite@latest my-vite-app --template vue-ts
cd my-vite-app
pnpm install
pnpm dev

main.ts 内容示例:

import { createApp } from 'vue'
import App from './App.vue'

createApp(App).mount('#app')

Vite 在开发模式下并不会预先打包 vue 和你的组件,而是在浏览器请求到 /src/main.ts 时动态编译并返回。

3.3 依赖预构建(Dependency Pre-Bundling)

尽管使用原生 ESM,第三方依赖(如 reactlodash)往往仍然采用 CommonJS 或 UMD 格式。直接用 ESM 加载这些依赖会有两个问题:

  1. 大量小文件请求:有些库(如 lodash-es)按模块拆分,ESM 导致 N 多 HTTP 请求;
  2. 兼容性问题:CJS/UMD 需要转为 ESM。

Vite 的解决方案是预构建依赖

  • 启动时扫描你的依赖;
  • 使用 esbuild 将它们打包成少量的 ESM 文件;
  • 后续开发过程中直接从预构建产物中加载依赖。

这既解决了“请求过多”问题,又兼顾了 CommonJS 的兼容性。

配置示例:

// vite.config.ts
import { defineConfig } from 'vite'

export default defineConfig({
  optimizeDeps: {
    include: ['lodash-es', 'axios'],
    exclude: ['some-big-lib'] // 可以跳过某些库
  }
})

3.4 生产构建:基于 Rollup 的打包

到了生产阶段,不能再“裸奔模块”——我们更关心:

  • 文件体积;
  • 加载策略(代码分割);
  • Tree-shaking 与缓存策略。

Vite 默认使用 Rollup 作为打包引擎,优势是:

  • 成熟稳定的打包生态;
  • 优秀的 Tree-shaking 与代码分割;
  • 插件体系与 Vite 兼容性好。

build 配置示例:

// vite.config.ts
import { defineConfig } from 'vite'

export default defineConfig({
  build: {
    target: 'es2018',
    sourcemap: true,
    rollupOptions: {
      output: {
        manualChunks: {
          vendor: ['vue', 'vue-router']
        }
      }
    }
  }
})

构建命令:

pnpm build
pnpm preview

3.5 插件体系与框架集成

Vite 的插件系统设计与 Rollup 高度兼容,同时扩展了开发服务器相关的钩子(如中间件、HMR 钩子)。

一个最简单的 Vite 插件示例:

// vite.config.ts
import { defineConfig } from 'vite'
import type { Plugin } from 'vite'

function mySimplePlugin(): Plugin {
  return {
    name: 'my-simple-plugin',
    enforce: 'pre', // pre / post
    transform(code, id) {
      if (id.endsWith('.js')) {
        // 简单示例:自动注入一行日志
        return {
          code: `console.log('[my-plugin] loaded: ${id}');\n` + code,
          map: null
        }
      }
      return null
    }
  }
}

export default defineConfig({
  plugins: [mySimplePlugin()]
})

许多现代框架都直接将 Vite 作为默认工具:

  • Vue 3create-vue 默认使用 Vite;
  • Reactcreate-vite 提供 React 模板,许多项目迁移中;
  • SvelteKit:基于 Vite;
  • SolidStart、Qwik、UnoCSS 等:都直接深度集成 Vite。

3.6 Vite 的周边生态:从工具到平台

随着 Vite 成功,出现了大量基于 Vite 的“更上层抽象”:

  • Vitest:基于 Vite 的测试框架,提供快速、近似原生 ESM 环境的单测体验;
  • VitePress:官方维护的文档站点生成器,用于构建静态站点;
  • Storybook 的 Vite Builder:将 Vite 用于组件开发环境;
  • 各种 Vite SSR/SSG 方案:如 vite-ssgvite-plugin-ssr 等。

这说明,Vite 不再只是“一个 bundler 的替代品”,而是成为前端工具生态的底层“runtime + 打包平台”


四、代码示例:构建一个简单但完整的 Vite 项目

下面用一个稍完整一点的例子,展示 Vite 的几个常见能力点(TS 支持、环境变量、别名、按需组件)。

4.1 项目初始化

pnpm create vite@latest vite-demo --template vue-ts
cd vite-demo
pnpm install
pnpm dev

4.2 配置别名与环境变量

vite.config.ts

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'node:path'

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      '@': path.resolve(__dirname, 'src'),
      '@components': path.resolve(__dirname, 'src/components')
    }
  },
  server: {
    port: 5173,
    open: true,
    proxy: {
      // 将 /api 开头的请求代理到后端
      '/api': {
        target: 'http://localhost:3000',
        changeOrigin: true,
        rewrite: path => path.replace(/^/api/, '')
      }
    }
  }
})

环境变量文件:

# .env.development
VITE_API_BASE_URL=http://localhost:3000

# .env.production
VITE_API_BASE_URL=https://api.example.com

在代码中使用:

// src/services/http.ts
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL

export async function getUser(id: number) {
  const res = await fetch(`${API_BASE_URL}/users/${id}`)
  return res.json()
}

4.3 按需加载页面组件

路由配置示例(以 vue-router 为例):

// src/router/index.ts
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'

const routes: RouteRecordRaw[] = [
  {
    path: '/',
    name: 'home',
    component: () => import('@/views/HomePage.vue')
  },
  {
    path: '/about',
    name: 'about',
    component: () => import('@/views/AboutPage.vue')
  }
]

export const router = createRouter({
  history: createWebHistory(),
  routes
})

Vite 在开发阶段不会对这些路由组件做整体打包,而是在你访问 /about 路由时,才会请求 AboutPage.vue 对应的模块;构建时,则由 Rollup 进行代码分割,生成独立的 chunk。


五、优缺点分析与实践建议

5.1 优点分析

  1. 极快的开发体验

    • 冷启动:大部分中小项目都是“秒起”,大型项目相比传统工具也快一个数量级;
    • 热更新速度快:只重新编译受影响模块,不需全局重打包;
    • 源码即运行:原生 ESM 思路更贴近浏览器行为。
  2. 开箱即用、配置简洁

    • 默认配置能满足绝大多数项目;
    • TS、JSX/TSX、CSS 预处理器等支持友好;
    • 与传统 Webpack 对比,配置量往往减少 50% 以上。
  3. 生态繁荣、框架友好

    • Vue / React / Svelte / Solid 等都有成熟的官方/社区整合方案;
    • 插件丰富,如 unplugin-auto-importunplugin-vue-componentsvite-plugin-inspect 等;
    • 学习成本相对较低,官方文档清晰。
  4. 生产构建品质可靠

    • 依托 Rollup 的成熟稳定;
    • Tree-shaking 与代码分割表现良好;
    • 配合现代浏览器的 ESM + HTTP/2,可实现良好加载性能。
  5. 适合作为底层开发平台

    • 许多上层框架已将 Vite 视为“runtime + bundler”基础;
    • 易于构建自定义脚手架和应用框架。

5.2 局限与潜在问题

  1. 对非 ESM 生态的适配成本

    • 虽然依赖预构建解决了大量问题,但一些老旧库适配依旧麻烦;
    • 超大型、极其复杂的依赖树下,依赖扫描和预构建有时会出问题,需要手动 include/exclude。
  2. 在极大型单仓、多包项目中的复杂度

    • Monorepo(例如多个 packages 共享同一 Vite 项目)场景下,如何优雅地处理依赖预构建、缓存、别名,有一定门槛;
    • 虽然 Vite 已支持 monorepo 场景,但与诸如 Turborepo、Nx 等工具配合时仍需摸索。
  3. 插件生态成熟度不完全均衡

    • 核心插件质量很高,但部分社区插件质量参差不齐;
    • 相比 Webpack 多年积累的“长尾插件”,某些特定领域(如极个别老式模块系统)支持度略弱。
  4. SSR 与复杂场景仍需实践经验

    • Vite 虽支持 SSR,但具体到“同构应用、边缘渲染、多入口 SSR”时,需要依据各自框架生态来做;
    • 一些高阶需求(如微前端、复杂多页 + SSR)需要更多经验或专门框架支持。

5.3 实战中的使用建议

  1. 中小型 Web 应用:优先选 Vite

    • Vue/React 新项目可以直接用 Vite 脚手架;
    • 配置简单、开发体验极佳、学习曲线平缓。
  2. 增量迁移旧项目

    • 对于基于 Webpack 的老项目,可以考虑:

      • 新模块/新子项目使用 Vite;
      • 逐步把老项目拆分为多包或独立子系统,引入 Vite;
    • 不必一次性“重构全部”,避免风险。

  3. Monorepo/微前端场景

    • 若已有 Turborepo/Nx,可将 Vite 用作每个包的 dev server / build 工具;
    • 合理利用 build.libbuild.rollupOptions 等配置来构建可复用库;
    • 多应用集成可用 Module Federation(需社区方案)或通过 Nginx/网关统一路由。
  4. 性能调优建议

    • 充分利用依赖预构建配置 optimizeDeps
    • 注意排查“动态 import 太多导致的 chunk 过碎”问题;
    • 结合浏览器 DevTools + rollup-plugin-visualizer 分析产物体积。
  5. 团队落地建议

    • 尽量通过模板化脚手架(如自建 create-xxx)固化团队最佳实践;
    • 统一 ESLint/Prettier/TSconfig 等基础配置,配合 Vite 模板;
    • 关注 Vite 与框架生态的版本兼容矩阵,避免“乱升版本”。

六、发展现状与未来趋势

6.1 现状:从“新秀”到“主流基础设施”

截至目前,Vite 已广泛用于:

  • Vue 官方文档站、生态站点(VitePress 自吃狗粮);
  • 各类开源项目、管理后台、文档系统、低代码平台;
  • 商业级应用:在国内外均有大量“中大型项目在生产环境中长期运行”。

更重要的是,Vite 逐渐从“单一项目开发工具”演化为:

  • 底层构建能力(构建库、组件库、SDK);
  • 各种框架(如 SvelteKit、QwikCity)的默认开发环境;
  • 配套生态(Vitest、VitePress)构成的“工具平台”。

6.2 未来可能的发展方向

  1. 更深入的 SSR / SSG 支持

    • 更好的 SSR API;
    • 在多入口 SSR、边缘渲染(Edge Runtime)、Island Architecture 上发力;
    • 与云原生平台(Vercel、Netlify 等)的集成增强。
  2. 与 RSC / 新一代框架模式的适配

    • React Server Components、Streaming SSR、Partial Hydration 等新范式;
    • 更细粒度的构建与运行时配合。
  3. 大型项目的工程化增强

    • 更好的 monorepo 支持(尤其是与 pnpm workspace、turborepo 的深度集成);
    • 更智能的缓存、增量构建和分布式构建能力。
  4. 工具链一体化

    • 测试(Vitest)、文档(VitePress)、图形组件开发(Storybook with Vite)进一步整合;
    • 打造真正“一栈式”前端工程体验。

七、结论:Vite 的价值与选择建议

综上,Vite 的关键价值可以概括为三点:

  1. 极致开发体验:原生 ESM + 按需编译,让“冷启动秒级、热更毫秒级”成为现实;
  2. 生态与平台化:不仅是替代 Webpack 的工具,更是承载现代前端框架的基础设施;
  3. 工程实践可落地:在中小型项目中“即插即用”,在中大型项目中也已积累了大量实践经验。

对于前端团队来说:

  • 如果你要开启一个新的 Vue / React / Svelte 项目,Vite 几乎是首选
  • 如果你维护的是一个老旧的 Webpack 巨石应用,可以考虑将 Vite 作为增量迁移的目标方向
  • 如果你在设计自研框架或平台,Vite 是非常合适的底层构建与开发平台候选。

未来,随着浏览器能力与运行时环境持续演进,Vite 很可能继续在“前端开发体验”这条主线上迭代,成为更多框架、平台的默认基础层。


八、参考资料与进一步学习

echarts实例:进度条加描述

记录下工作中使用echarts做出的特殊组件

image.png

import { defineComponent } from 'vue'
import { FONT_SIZE, COLOR_LIGHT_ORG, COLOR_YELLOW, COLOR_LIGHT_GREEN } from './createChart'


let props = {
  propData: {
    type: Array,
    default: () => [
      { name: '名称1', value: 353, color: COLOR_YELLOW, desc: '{x}条描述' },
      { name: '名称2', value: 85, color: COLOR_LIGHT_GREEN, desc: '{2}条描述' },
      { name: '名称3', value: 30, color: '#FF6B6B', desc: '包括{名称3} XX{2}条描述,XX{4}条描述,XX{2}条描述' },
      { name: '名称4', value: 8, color: '#00BAAE', desc: '' },
    ],
  },
  barWidth: {
    default: 25,
    type: Number,
  },
}
const colors = [COLOR_LIGHT_ORG, COLOR_YELLOW, COLOR_LIGHT_GREEN, '#7B68EE', '#FF6B6B', '#4ECDC4'] // 添加更多备用颜色


export default defineComponent({
  props,
  data() {
    return {
      fontSize: FONT_SIZE + 4,
    }
  },
  created() {},
  mounted() {
    this.init()
    this.$watch(
      () => this.$props, // 监听整个 props 对象
      () => {
        this.init()
      },
      { deep: true, immediate: false },
    )
  },
  beforeDestroy() {
    this.chart?.dispose?.()
  },
  methods: {
    init() {
      const option = this.getOption() // 间隙
      const dom = this.$refs.chart
      if (!this.chart) {
        this.chart = echarts.init(dom, null, {
          renderer: 'canvas',
        })
      }
      this.chart.setOption(option, true)
    },
    getOption() {
      if (!this.propData.length) return {}
      const seriesdata = this.getData()
      const series = this.getSeries(seriesdata)
      const maxValue = this.getMaxValue(this.propData)
      const seriesBg = this.getBackgroundBarSeries(maxValue)
      return {
        legend: {
          show: false,
        },
        tooltip: {
          show: false,
        },
        grid: {
          left: '6%',
          right: '5%',
          bottom: '0%',
          top: '1%',
          containLabel: true,
        },
        xAxis: [
          {
            type: 'value',
            axisLine: {
              show: false,
            },
            splitLine: {
              show: false,
            },
            axisLabel: {
              show: false,
            },
            axisTick: {
              show: false,
            },
          },
        ],
        yAxis: [
          {
            type: 'category',
            data: _.map(this.propData, 'name'),
            axisLine: {
              show: false,
            },
            splitLine: {
              show: false,
            },
            axisLabel: {
              show: true,
              fontSize: this.fontSize,
              textStyle: {
                color: '#fff',
              },
              align: 'left', // 左对齐
              padding: [0, 0, 0, -150], // 去掉内边距
              interval: 0,
            },
            axisTick: {
              show: false,
            },
          },
          {
            type: 'category',
            data: this.propData,
            offSet: -10,
            axisLine: {
              show: false,
            },
            splitLine: {
              show: false,
            },
            axisLabel: {
              show: false,
            },
            axisTick: {
              show: false,
            },
          },
          {
            type: 'category',
            data: this.propData,
            axisLine: {
              show: false,
            },
            splitLine: {
              show: false,
            },
            axisLabel: {
              show: false,
            },
            axisTick: {
              show: false,
            },
          },
        ],
        series: [...seriesBg, ...series],
      }
    },

    // 获取最大值
    getMaxValue(data) {
      const values = _.map(data, 'value')
      return Math.max(...values)
    },
    getData() {
      return this.propData.map((item, index) => {
        const color = item.color || colors[index] || '#ffffff'
        return {
          ...item,
          label: {
            show: true,
            position: [0, 0],
            fontSize: this.fontSize - 5,
            offset: [20, -70],
            color: '#fff',
            formatter: (params, index) => {
              const text = this.propData[params.dataIndex]?.desc || ''
              // 将文本拆分成数组,然后组合成富文本字符串
              const parts = text.split(/(\{[^}]+\})/)
              let richText = ''
              parts.forEach((part) => {
                if (part.startsWith('{') && part.endsWith('}')) {
                  // 移除花括号
                  const value = part.substring(1, part.length - 1)
                  richText += `{num|${value}}`
                } else if (part) {
                  richText += part
                }
              })
              return richText
            },
            rich: {
              num: {
                color, // 动态颜色
                fontSize: this.fontSize ,
                padding: [0, 5, 0, 5],
              },
            },
          },
          itemStyle: {
            color: {
              type: 'linear',
              x: 0,
              y: 0, // 上
              x2: 1,
              y2: 0, // 下
              colorStops: [
                {
                  offset: 0,
                  color: echarts.color.modifyAlpha(color, 0.1),
                },
                {
                  offset: 0.3,
                  color: echarts.color.modifyAlpha(color, 0.6),
                },
                {
                  offset: 1,
                  color: echarts.color.modifyAlpha(color, 1),
                },
              ],
            },
            shadowColor: 'rgba(255,255,255,0.2)',
            shadowBlur: 5,
            shadowOffsetX: 0,
            shadowOffsetY: 0,
          },
        }
      })
    },
    getSeries(data) {
      return [
        {
          name: '主系列',
          type: 'bar',
          stack: 'total',
          barWidth: this.barWidth,
          yAxisIndex: 0,
          z: 2,
          data,
        },
      ]
    },
    // 最大值为背景底部进度条
    getBackgroundBarSeries(maxValue) {
      const config = {
        data: this.propData.map(() => {
          return maxValue
        }),
        tooltip: { show: false },
        showInLegend: false,
      }
      const series = [
        {
          ...config,
          type: 'bar',
          name: '背景bar',
          barWidth: this.barWidth,
          itemStyle: {
            color: 'rgba(255, 255, 255,0.13)',
            borderWidth: 0,
          },

          yAxisIndex: 1,
        },
        {
          ...config,
          type: 'bar',
          name: '背景框',
          data: this.propData.map((i, index) => {
            return {
              value: maxValue + 5,
              label: {
                color: this.propData[index]?.color || '#fff',
              },
            }
          }),
          barWidth: this.barWidth + 20,
          itemStyle: {
            color: 'rgba(255, 255, 255,0)',
            borderColor: '#fff',
            borderWidth: 2,
            borderRadius: 0,
          },
          label: {
            show: true,
            position: 'right',
            fontSize: this.fontSize+10,
            fontFamily: 'TRENDS',
            offset: [50, 0],
            formatter: (params) => {
              return this.propData[params.dataIndex]?.value || 0
            },
          },
          yAxisIndex: 2,
        },
      ]
      return series
    },
  },
})

并发 401 下的 Token 刷新竞态:一个被低估的 Bug

当多个请求同时遇到 401 时,朴素实现会触发多次 token 刷新,导致 race condition。用一个 isRefreshing 标志 + 订阅者队列可以彻底解决——但大多数实现里存在一个隐藏的 Promise 泄漏问题。

本文假设你熟悉 async/await、HTTP 拦截器(axios/fetch)和 JWT 认证基础。


问题:并发 401 不止一个

实现过 token 刷新的人,第一版代码大概长这样:

// ❌ 朴素实现
axios.interceptors.response.use(null, async (error) => {
  if (error.response?.status === 401) {
    const newToken = await refreshToken();
    error.config.headers.Authorization = `Bearer ${newToken}`;
    return axios(error.config);
  }
  return Promise.reject(error);
});

单个请求失效时,这完全够用。但在真实应用里,你的页面同时发出 5 个请求是常态——Dashboard 加载时并行请求用户信息、通知数量、最新数据……

当 token 在这 5 个请求飞行途中过期:

Request A401refreshToken() ─┐
Request B401refreshToken()  │← 同时触发 5 次刷新
Request C → 401refreshToken()  │
Request D → 401refreshToken() ─┘
Request E → 401refreshToken()

每次刷新都会使上一次发出的 refresh_token 失效(轮换机制)。结果是:第一个刷新成功,其余四个用过期的 refresh_token 刷新——全部失败,用户被踢回登录页。


心理模型:收银台排队

把并发请求想象成超市收银台:

  • 朴素实现:每个顾客(请求)都跑去叫店长(刷新 token)。店长同时被 5 个人拉着,什么都做不了。
  • 正确实现:第一个顾客去叫店长,其他人在收银台前排队等候。店长回来后,所有人一起结账(用新 token 重试)。

实现这个逻辑只需要两个变量:

let isRefreshing = false;          // 店长是否在处理中
let subscribers: Subscriber[] = []; // 排队等待的顾客

实现:带队列的刷新机制

完整实现分四个部分:

1. 订阅者类型

// newToken 为字符串时表示刷新成功,为 null 时表示刷新失败
type Subscriber = (newToken: string | null) => void;

let isRefreshing = false;
let subscribers: Subscriber[] = [];

注意 string | null 的设计——这是避免 Promise 泄漏的关键,后面详述。

2. 队列管理

function addSubscriber(callback: Subscriber) {
  subscribers.push(callback);
}

function notifySubscribers(newToken: string | null) {
  subscribers.forEach((cb) => cb(newToken));
  subscribers = [];
}

3. 核心调度逻辑

export async function handleUnauthorized<T>(
  doRefresh: () => Promise<string | null>,
  doRetry: (newToken: string) => Promise<T>,
  onFailure: () => void,
): Promise<T> {
  // 已有刷新进行中 → 排队等待
  if (isRefreshing) {
    return new Promise<T>((resolve, reject) => {
      addSubscriber((newToken) => {
        if (newToken) {
          doRetry(newToken).then(resolve).catch(reject);
        } else {
          reject(new Error('Token refresh failed'));
        }
      });
    });
  }

  // 发起刷新
  isRefreshing = true;
  const newToken = await doRefresh();

  if (newToken) {
    notifySubscribers(newToken); // 通知队列重试
    isRefreshing = false;
    return doRetry(newToken);
  }

  // 刷新失败:通知队列(传 null),然后执行失败处理
  notifySubscribers(null);
  isRefreshing = false;
  onFailure();
  return Promise.reject(new Error('Token refresh failed'));
}

4. 接入 Axios 拦截器

axios.interceptors.response.use(null, (error) => {
  const { response, config } = error;

  // 只处理 401,跳过登录和刷新接口本身
  if (response?.status !== 401) return Promise.reject(error);
  if (config?.url?.includes('/auth/login')) return Promise.reject(error);
  if (config?.url?.includes('/auth/refresh')) {
    clearStorage();
    window.location.href = '/login';
    return Promise.reject(error);
  }

  return handleUnauthorized(
    () => fetchNewToken(),
    (newToken) => {
      config.headers.Authorization = `Bearer ${newToken}`;
      return axios(config);
    },
    () => {
      clearStorage();
      window.location.href = '/login';
    },
  );
});

现在同样的并发场景:

Request A → 401 → isRefreshing=false → 发起刷新 → isRefreshing=true
Request B → 401 → isRefreshing=true  → 加入队列
Request C → 401 → isRefreshing=true  → 加入队列
Request D → 401 → isRefreshing=true  → 加入队列

刷新成功 → notifySubscribers(newToken) → B、C、D 用新 token 重试 ✅

隐藏的 Bug:Promise 泄漏

这是大多数网上教程里存在的问题,包括一些知名库的早期版本。

当刷新失败时,朴素实现通常这样写:

// ❌ 有 Bug 的版本
isRefreshing = false;
subscribers = []; // ← 直接清空!
onFailure();

问题在于:subscribers 数组里存的是 Promise 的 resolve/reject 回调。直接清空等于把这些 Promise 永远挂起——它们既不 resolve 也不 reject,永远 pending

JavaScript 引擎不会回收仍在等待的 Promise(因为理论上它们还能被 resolve)。在 SPA 里,这意味着用户每次遇到刷新失败,都会积累一批无法被 GC 的 Promise 和闭包。

修复方式:通知订阅者失败,让它们主动 reject:

// ✅ 正确版本
notifySubscribers(null); // 传 null → 订阅者收到后调用 reject()
isRefreshing = false;
onFailure();

这就是为什么 Subscriber 的类型是 (newToken: string | null) => void 而不是 (newToken: string) => void


需要注意的边界情况

并发刷新之间的时序

isRefreshing 是模块级变量,在整个应用生命周期内共享。如果两个页面同时初始化(如 iframe 或多标签页共享 localStorage),队列不会跨页面同步——这是该模式的设计边界。多标签页场景需要用 BroadcastChannelSharedWorker

刷新接口本身的 401

必须跳过对刷新接口的重试,否则会死循环:

refreshToken() → 401handleUnauthorized() → refreshToken() → ...

代码里的这一判断不能省:

if (config?.url?.includes('/auth/refresh')) {
  clearStorage();
  window.location.href = '/login';
  return Promise.reject(error);
}

状态重置时机

isRefreshing = false 必须在 notifySubscribers() 之后设置,不能之前。否则队列通知过程中如果又进来新的 401,会再次触发刷新。


取舍与局限

优点 缺点
无额外依赖,纯逻辑 模块级状态,无法跨 iframe/标签页
O(1) 判断,O(n) 通知,性能无影响 刷新超时无内建处理(需自行包装)
与具体 HTTP 客户端解耦 队列顺序不保证(取决于 Promise 执行顺序)

如果你的应用有严格的刷新超时需求,可以在 doRefresh 里用 Promise.race 包一层 timeout:

const doRefresh = () => Promise.race([
  fetchNewToken(),
  new Promise<null>((resolve) => setTimeout(() => resolve(null), 10_000)),
]);

完整代码

token-refresh-queue.ts


延伸阅读

❌