阅读视图

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

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

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

在Unity的Shader Graph中,View Direction节点是一个功能强大且常用的工具,它允许开发者访问网格顶点或片元的视图方向矢量。这个矢量表示从顶点或片元指向摄像机的方向,在光照计算、反射效果、边缘光等众多视觉效果中扮演着关键角色。

View Direction节点的基本概念

View Direction节点输出的矢量本质上是从当前处理的顶点或片元位置指向摄像机位置的矢量。这个矢量在不同的渲染计算中有着广泛的应用,特别是在需要基于观察角度变化效果的场景中。

视图方向在计算机图形学中是一个基础概念,它描述了表面点相对于观察者的方向关系。在Shader Graph中,View Direction节点封装了这一计算,让开发者能够轻松获取和使用这一重要数据。

从Unity 11.0版本开始,View Direction节点在URP和HDRP中的行为已经统一,都会对所有坐标空间下的视图方向进行标准化处理。这一变化简化了跨渲染管线的着色器开发,确保了行为的一致性。

节点参数详解

坐标空间选择

View Direction节点提供了一个重要的控件参数——Space下拉选单,允许开发者选择输出视图方向矢量的坐标空间。理解不同坐标空间的特性对于正确使用该节点至关重要。

  • Object空间:在此空间下,视图方向是相对于物体自身坐标系表达的。这意味着无论物体如何旋转、移动或缩放,视图方向都会相对于物体的本地坐标系进行计算。在需要基于物体自身方向的效果时特别有用,如某些类型的卡通渲染或物体特定的光照效果。
  • View空间:也称为摄像机空间,在此空间中,摄像机位于原点,视图方向是相对于摄像机坐标系的。这个空间下的计算通常更高效,因为许多与视图相关的变换已经完成。适用于屏幕空间效果、与摄像机直接相关的特效。
  • World空间:在此空间下,视图方向是基于世界坐标系表达的。这是最直观的空间之一,因为所有场景中的物体都共享同一世界坐标系。适用于需要与世界坐标交互的效果,如全局光照、环境遮挡等。
  • Tangent空间:也称为切线空间,这是一个相对于表面法线的局部坐标系。在此空间下,视图方向是相对于每个顶点或片元的法线方向表达的。特别适用于法线贴图、视差映射等需要基于表面方向的效果。

输出端口

View Direction节点只有一个输出端口,标记为"Out",输出类型为Vector 3。这个三维矢量包含了在当前选择的坐标空间下的视图方向。

输出的矢量始终是标准化的,即其长度为1。这一特性使得开发者可以直接使用该矢量进行点积计算等需要单位矢量的操作,而无需额外的标准化步骤。

在不同渲染管线中的行为差异

理解View Direction节点在不同渲染管线中的历史行为差异对于维护和迁移现有项目非常重要。

在Unity 11.0版本之前,View Direction节点在URP和HDRP中的工作方式存在显著差异:

  • 在URP中,该节点仅在Object空间下输出标准化的视图方向,在其他坐标空间下则保持原始长度
  • 在HDRP中,该节点在所有坐标空间下都会标准化视图方向

这种不一致性可能导致相同的着色器在不同渲染管线中产生不同的视觉效果。从11.0版本开始,Unity统一了这一行为,View Direction节点在所有渲染管线和所有坐标空间下都会输出标准化的视图方向。

对于需要在URP中使用旧行为(在Object空间外使用未标准化视图方向)的开发者,Unity提供了View Vector节点作为替代方案。这个节点保持了旧版本View Direction节点的行为,确保了向后兼容性。

实际应用场景

View Direction节点在着色器开发中有着广泛的应用,以下是一些常见的应用场景:

光照计算

在光照模型中,视图方向是计算高光反射的关键要素。结合表面法线和光照方向,视图方向用于确定观察者看到的高光强度。

  • 在Blinn-Phong光照模型中,使用法线、光照方向和视图方向的半角矢量来计算高光
  • 在基于物理的渲染中,视图方向是双向反射分布函数的重要输入

边缘光效果

视图方向可用于创建边缘光效果,当表面几乎与视图方向平行时增强其亮度。

  • 通过计算表面法线与视图方向的点积,可以确定表面的边缘程度
  • 结合菲涅耳效应,可以创建逼真的边缘发光效果

反射效果

视图方向在反射计算中至关重要,无论是平面反射、环境映射还是屏幕空间反射。

  • 在立方体环境映射中,使用视图方向计算反射矢量
  • 在屏幕空间反射中,视图方向用于确定反射射线的方向

视差映射

在视差映射技术中,视图方向用于模拟表面的深度和凹凸感。

  • 在切线空间中使用视图方向偏移纹理坐标
  • 创建更真实的表面凹凸效果,增强场景的立体感

使用示例与步骤

基础视图方向可视化

创建一个简单的着色器,直接显示视图方向:

  • 在Shader Graph中创建新图
  • 添加View Direction节点,选择World空间
  • 将View Direction节点连接到主节点的Base Color端口
  • 由于视图方向可能包含负值,需要将其映射到0-1范围
  • 可以使用Remap节点或简单的数学运算完成这一映射

这个简单的示例可以帮助开发者直观理解视图方向在不同表面区域的变化。

创建菲涅耳效果

菲涅耳效果模拟了物体表面在掠射角(表面几乎与视图平行)反射率增加的现象:

  • 添加View Direction节点和Normal节点,确保使用相同的坐标空间
  • 使用Dot Product节点计算法线和视图方向的点积
  • 使用One Minus节点反转结果,使掠射角的值接近1
  • 使用Power节点控制效果的衰减程度
  • 将结果与颜色或纹理相乘,连接到发射或基础颜色

实现简单的边缘光

创建一个基础的边缘光效果:

  • 按照菲涅耳效果的步骤计算边缘因子
  • 使用Smoothstep或Color节点控制边缘光的范围和颜色
  • 将结果添加到现有的光照计算中
  • 可以结合深度或屏幕空间信息增强效果的真实性

高级反射效果

创建一个基于视图方向的反射效果:

  • 使用View Direction节点和Normal节点计算反射方向
  • 将反射方向用于采样环境贴图或反射探针
  • 结合粗糙度贴图控制反射的模糊程度
  • 使用菲涅耳效应混合反射颜色和表面颜色

性能考虑与最佳实践

虽然View Direction节点本身计算开销不大,但在大规模使用时应考虑性能影响:

  • 在片元着色器中计算视图方向比在顶点着色器中计算更精确但更昂贵
  • 对于不需要高精度的效果,考虑在顶点着色器中计算视图方向并插值
  • 避免在着色器中重复计算视图方向,尽可能重用计算结果
  • 根据具体需求选择合适的坐标空间,减少不必要的空间转换

在移动平台或性能受限的环境中,应特别关注视图方向计算的开销:

  • 尽可能使用计算量较小的坐标空间
  • 考虑使用近似计算替代精确的视图方向
  • 对于远处或小物体,可以使用简化的视图方向计算

常见问题与解决方案

视图方向显示异常

当视图方向显示不正确时,通常是由于坐标空间不匹配造成的:

  • 确保View Direction节点和与之交互的其他节点使用相同的坐标空间
  • 检查物体的变换矩阵是否包含非常规的缩放或旋转
  • 验证摄像机的设置,特别是正交投影与透视投影的区别

性能问题

如果着色器因视图方向计算导致性能下降:

  • 分析是否真的需要在片元级别计算视图方向
  • 考虑使用更简化的计算模型
  • 检查是否有重复的视图方向计算可以合并

跨平台兼容性

确保视图方向相关效果在不同平台上表现一致:

  • 测试在不同图形API下的表现
  • 验证在移动设备上的精度和性能
  • 考虑为不同平台提供不同的精度或实现

进阶技巧与创意应用

结合时间变化的动态效果

通过将视图方向与时间参数结合,可以创建动态变化的视觉效果:

  • 使用视图方向驱动动画或纹理偏移
  • 创建随着观察角度变化而动态调整的效果
  • 实现类似全息图或科幻界面元素的视觉效果

非真实感渲染

在卡通渲染或其他非真实感渲染风格中,视图方向可以用于:

  • 控制轮廓线的粗细和强度
  • 实现基于角度的色彩简化
  • 创建手绘风格的笔触效果

特殊材质模拟

视图方向在模拟特殊材质时非常有用:

  • 模拟丝绸、缎子等具有角度相关反射的织物
  • 创建各向异性材料如拉丝金属的效果
  • 实现液晶显示屏的角度相关颜色变化

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

Flutter 中的 FittedBox 详解:比如让金额显示自适应


Flutter 中的 FittedBox 详解:如何让金额显示自适应

在开发 Flutter 应用时,我们常常遇到需要根据屏幕尺寸或父容器大小自动调整子组件大小的情况。特别是在处理金额显示时,金额可能从一个较小的数字(例如:100)逐渐增大(例如:100,000,000,00000)。这种情况下,如何保证金额无论大小都能自适应父容器而不被截断或者变形呢?这时,FittedBox 就能派上用场,它能够帮助我们根据父容器的大小自动缩放子组件,确保内容能够完美展示。

例子:金额的自适应显示

假设我们要在界面中显示一个金额。刚开始时,金额是 100,随着时间的推移,它逐渐变大,比如 100,000,000,00000。如果我们不对文本进行缩放,随着金额的增加,数字可能会超出屏幕,导致 UI 出现溢出。这里,FittedBox 就能解决这个问题。它会根据父容器的尺寸自动缩放文本,确保金额内容始终能适应屏幕空间。

示例代码:

Flexible(
  child: FittedBox(
    fit: BoxFit.scaleDown,
    alignment: AlignmentDirectional.centerStart,
    // 金额显示文本 - 线性渐变(垂直方向)
    child: ShaderMask(
      shaderCallback: (bounds) => const LinearGradient(
        begin: Alignment.topCenter,
        end: Alignment.bottomCenter,
        colors: [
          Color(0xFFFFC30B),
          Color(0xFFFFF4CD),
          Color(0xFFFFBB10),
        ],
      ).createShader(bounds),
      child: const Text(
        '100,000,000,00000',
        style: TextStyle(
          fontSize: 40,
          fontWeight: FontWeight.w900,
          color: Colors.white,
        ),
      ),
    ),
  ),
)

关键点:

  • FittedBox: 它是 Text 组件的父容器,负责调整文本的大小,使其适应可用的空间。随着金额变大,FittedBox 会根据父容器的变化自动缩放文本。
  • BoxFit.scaleDown: 这个 fit 属性确保文字不会超出容器的边界。如果金额变大,文字会被缩小以适应容器。
  • ShaderMask: 为金额文本添加了线性渐变的效果,使数字显示得更加醒目。

在这个例子中,Text 组件会根据金额的大小自动调整字体大小。假设金额从 100 增长到 100,000,000,00000FittedBox 会确保文本始终适应父容器的大小,并随着数字增大而逐渐缩小字体大小,从而避免文本溢出或失真。

FittedBox 的作用和原理

FittedBox 是一个用于缩放其子组件的布局小部件。它会根据父容器的尺寸来自动调整子组件的大小,确保子组件不会超出父容器的边界。使用 FittedBox,我们可以避免文字、图片等内容溢出或者失真,确保 UI 布局在各种屏幕尺寸上都能适应良好。

fit 属性的不同值

FittedBox 通过 fit 属性来决定如何缩放其子组件。fit 属性可以设置为不同的 BoxFit 枚举值,其中一些常见的值包括:

  • BoxFit.contain: 保持子组件的宽高比,同时让子组件完全显示在父组件的空间中。
  • BoxFit.cover: 让子组件覆盖整个父容器,可能会裁剪部分内容,但不会出现空白。
  • BoxFit.fill: 拉伸子组件以完全填充父组件,可能会导致内容变形。
  • BoxFit.scaleDown: 如果子组件的尺寸大于父组件,它会缩小子组件;如果子组件本身小于父组件,它将保持原尺寸。

在前面的金额示例中,我们使用了 BoxFit.scaleDown,确保金额文本不会被拉伸或者裁剪,而是适应父容器的大小。

alignment 属性

FittedBox 还允许通过 alignment 属性来控制子组件在容器中的对齐方式。alignment 属性接受一个 AlignmentGeometry 类型的值,可以让我们选择子组件在容器中的不同对齐方式。

例如:

  • Alignment.topLeft: 左上角对齐
  • Alignment.center: 居中对齐
  • Alignment.bottomRight: 右下角对齐

结合其他小部件使用

FittedBox 可以与其他布局小部件(如 FlexibleExpandedAlign 等)结合使用,以达到更加灵活的布局效果。例如,我们可以在 RowColumnFlex 中使用 Flexible 来让 FittedBox 更好地适应父容器的大小。

示例:配合 Flexible 使用
Row(
  children: [
    Flexible(
      child: FittedBox(
        fit: BoxFit.scaleDown,
        alignment: Alignment.center,
        child: Text(
          'Hello, Flutter!',
          style: TextStyle(fontSize: 30),
        ),
      ),
    ),
  ],
)

在这个例子中,Text 组件会根据 Flexible 给定的空间自动缩放,而不会超出父容器的边界。

FittedBox 的实际应用场景

1. 自动缩放文本

在显示动态变化的数字时,尤其是金额等大数字,FittedBox 可以自动缩放文本,确保它们适应容器的宽度和高度。例如,当显示一个不断增长的金额时,FittedBox 会根据金额的增加,自动调整字体大小,以适应屏幕空间。

2. 图片适配

在显示图片时,FittedBox 可以确保图片根据父容器的大小进行自动缩放或裁剪,避免图片超出边界或者变形。尤其在响应式设计中,FittedBox 提供了非常灵活的解决方案。

3. 保持比例缩放

FittedBox 也可以用于保持子组件的宽高比不变。比如,当我们有一个矩形图形或者图片时,我们可以使用 FittedBox 来确保图形始终保持比例缩放,而不会失真。

总结

FittedBox 是 Flutter 中一个非常强大的小部件,它使得我们能够轻松地让子组件自适应父容器的大小。通过合理使用 fitalignment 等属性,FittedBox 可以帮助我们处理复杂的布局需求,特别是在响应式设计中。无论是缩放文本、图片,还是保持宽高比,FittedBox 都能提供简单而灵活的解决方案。

在实际开发中,FittedBox 常常用于解决布局溢出、图片裁剪、文字缩放等问题,是 Flutter 中不可或缺的布局工具之一。通过理解和掌握 FittedBox 的用法,你可以更加高效地构建响应式和自适应的用户界面。

【ThreeJS】InstancedMesh 实战:从20000个Mesh到1个Draw Call

前言:不知道有没有小伙伴跟我一样,刚接触web3D,接触ThreeJS,然后在做类似智慧站房这种项目的时候,兴致冲冲的把设备放至到场景,每一个设备摆放的位置都恰到好处,包括管道,流向动画等等,觉得这样应该就万无一失了,然而实际运行起来却发现,卧槽,好卡!!!

正题: 关于如何优化ThreeJS里设备一多就卡的问题,今天我们就来讲讲“InstancedMesh”,这是ThreeJS官方的一个API,官方是这么描述的:

这是一个支持实例化渲染的特殊网格模型。如果您需要渲染大量具有相同几何体和材质但世界变换不同的对象,请使用此类。使用“InstancedMesh”有助于减少绘制调用次数,从而提高应用程序的整体渲染性能。

不知道大伙懵了没,反正我第一次看到这描述的时候,也是云里雾里的,不过现在我自认为算是比较了解了,就给大家大白话翻译一下,意思就是:这个API可以让你场景里的模型无损影分身,就是不耗费多的性能,但能让场景里的模型分身多个出来,怎么样,是不是像魔法一样,那它是怎么做到的呢?

这时候就不得不提3d场景的绘制原理了,我相信大部分小伙伴都知道显卡这个东西,也知道它是用来为电脑渲染图形,web3d当然也是靠它渲染,但是,我要说但是了,虽然渲染是它来做,但别忘了咱们电脑老大哥CPU啊,事实就是,像这种场景多个模型卡顿的原因,可能瓶颈并不在GPU身上,而是CPU身上,怎么样,是不是又蒙了,我当初了解到也疑惑了一下,我给大家说一下,其实原因很简单

打个比方,把CPU比作一家餐馆的接单播报员,而GPU就是后厨接单的厨子,但是接单员只有一个(需要处理各种复杂的订单、优惠券叠加、会员判定、退单逻辑、库存检查),而厨子有很多(只需要固定几个步骤炒菜,其它都不用管)

现在店里来了一百个客人,这些客人要的都是蛋炒饭,但接单员每一单都跑去后厨说有一桌要蛋炒饭,有一桌要蛋炒饭...直到派完这一百桌蛋炒饭,在喊的过程中,其它厨子是闲着的状态,因为还没给它派单,现在大伙发现有哪里不太对劲吗?没错!那就是为什么接单员明明一百桌都是同样的菜,但为什么要分一百次派单呢,一次派完不行吗,嘿嘿嘿,这时候如果你用的是普通Mesh,那么答案就是不行,这是由Mesh这个API决定的

这时候就讲到了我们今天的主题,InstancedMesh,它就是那个对这种重复需求更专业的接单员,有十桌蛋炒饭它就会直接跟后厨说现在需要炒十桌蛋炒饭,不会来回去通知,这样后厨就可以火力全开,一次性把十桌蛋炒饭都做好,现在明白为什么我说瓶颈不在GPU上了吧,Three.js的卡顿,往往不是"显卡不够好",而是"CPU在疯狂传话,没让显卡满负荷干活"。

开始我们今天的实验,我现在创建了20000个BoxMesh,帧数只有可怜的10fps,要知道我这可是rtx3080,要是显卡差一点的,我都不敢想象。。。

图片.png

大伙也可以复制以下代码自己去试试自己的电脑能跑多少FPS,可以把FPS打在评论区

  // 200 个 box:200 列 x 100 行,整齐排列
  const cols = 200;
  const rows = 100;
  const spacing = 1.3;
  const geometry = new THREE.BoxGeometry(0.8, 0.8, 0.8);
  const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 });

  for (let i = 0; i < rows; i++) {
    for (let j = 0; j < cols; j++) {
      const box = new THREE.Mesh(geometry, material);
      box.position.x = (j - cols / 2 + 0.5) * spacing;
      box.position.z = (i - rows / 2 + 0.5) * spacing;
      scene.add(box);
    }
  }

我相信看到这的大伙对于性能优化都有一定的追求,对于这种情况是很难容忍的,所以,我们可以用InstancedMesh开始优化它了!

我先卖个关子,给大家看看优化后的截图:

image.png

指标 20000个Mesh InstancedMesh 优化倍数
Draw Calls 20000 1 20000x
帧率(FPS) 10 144 14x
内存占用 ~500MB+ ~80MB 6x
CPU占用 90% 15% 6x

我就说一句,牛不牛!刚刚只有10fps,没眼看,现在一下把我显示器的刷新率跑满了,144FPS,并且从原来20000的Draw Call,降到了1Draw Call,也就是说接单员一次性跟后厨说要做这么多份蛋炒饭,然后后厨火力全开,一次性搞定!

好了,魔术总有揭秘的那天,下面就是优化后的代码:

  // ---------- InstancedMesh:200 列 x 100 行 = 20000 个 box,单次 draw call ----------
  const cols = 200;
  const rows = 100;
  const count = cols * rows;
  const spacing = 1.3; // 实例之间的间距
  const geometry = new THREE.BoxGeometry(0.8, 0.8, 0.8);
  const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 });
  const instancedMesh = new THREE.InstancedMesh(geometry, material, count);
  // 设置为静态绘制,避免每帧更新实例矩阵
  instancedMesh.instanceMatrix.setUsage(THREE.StaticDrawUsage);
  const matrix = new THREE.Matrix4();
  const position = new THREE.Vector3();
  let idx = 0;
  for (let i = 0; i < rows; i++) {
    for (let j = 0; j < cols; j++) {
      // 以场景中心为原点,按行列均匀排布
      position.set(
        (j - cols / 2 + 0.5) * spacing,
        0,
        (i - rows / 2 + 0.5) * spacing
      );
      matrix.setPosition(position);
      instancedMesh.setMatrixAt(idx, matrix);
      idx++;
    }
  }
  // 更新包围球,便于视锥剔除等优化
  instancedMesh.computeBoundingSphere();
  scene.add(instancedMesh);

有几个明显的不同,第一是没有new mesh这个环节了,关于新增mesh都放在了 new THREE.InstancedMesh(geometry, material, count); 这个count就是告诉InstanceMesh需要新增多少个Mesh,然后在遍历之后把这个InstanceMesh添加到Scene里,中间的环节只是决定InstanceMesh里的Mesh应该在什么位置。

我再给大家讲几个InstanceMesh的坑,新手非常容易忽略

1.动态更新位置的正确姿势

// 错误:每帧都设置 needsUpdate,卡成PPT
function animate() {
  for(let i=0; i<20000; i++) {
    instancedMesh.setMatrixAt(i, newMatrix); // 别在循环里干这个!
  }
  instancedMesh.instanceMatrix.needsUpdate = true;
  requestAnimationFrame(animate);
}

// 正确:只更新变化的,且设置 DynamicDrawUsage
instancedMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
// 只在数据真的变了才 setMatrixAt + needsUpdate = true

2.Raycaster(射线) 咋交互InstanceMesh实例

const intersection = raycaster.intersectObject(instancedMesh);
if (intersection.length > 0) {
  const instanceId = intersection[0].instanceId;
  // 高亮该实例:需要配合 setColorAt
  instancedMesh.setColorAt(instanceId, new THREE.Color(0xff0000));
  instancedMesh.instanceColor.needsUpdate = true;
}

3.dispose 内存泄漏 -----(这个一定要重视,我有过很惨痛的经历....)

// 组件卸载时一定要:
geometry.dispose();
material.dispose();
instancedMesh.dispose(); // 释放GPU缓冲区
scene.remove(instancedMesh);
}

总结:InstancedMesh 不是魔法,它只是让 CPU 从 "发了20000个快递包裹" (每次都要打包、贴单、叫车)变成了 "发了一车货" (整车直达)。在智慧站房、智慧工厂这种 "设备多但型号少" 的场景,这是性价比最高的优化方案。

但别急着走——如果你的设备需要各自不同的贴图(比如每台机器显示不同的温度数字),InstancedMesh就力不从心了。这时候该用 Texture Atlas(纹理图集) 还是 合并几何体(MergeGeometry)

关注下篇: 【ThreeJS】多材质设备优化:一张贴图管理1000个设备的独立显示 ,咱们继续折腾性能优化。

互动:你的项目最多渲染过多少个Mesh?在评论区晒晒帧率,看看谁的优化更狠😏

【React-5/Lesson81(2025-12-23)】构建一个完整的 React 待办事项应用(Todo App):从零到本地持久化📝

📝在现代前端开发中,React 以其组件化、声明式和高效更新的特性成为构建用户界面的首选框架之一。本文将带你深入剖析一个功能完整、结构清晰、具备本地存储能力的 React 待办事项应用(Todo App) 的实现细节。我们将逐层拆解其核心组件、状态管理逻辑、父子通信机制、样式处理以及数据持久化策略,并补充大量相关知识,帮助你真正掌握 React 应用的开发范式。


🧩 应用整体架构概览

该 Todo 应用采用典型的 单页应用(SPA) 结构,以 App.jsx 作为根组件,协调三个主要子组件:

  • TodoInput.jsx:负责接收用户输入并添加新任务。
  • TodoList.jsx:渲染任务列表,支持标记完成与删除操作。
  • TodoStats.jsx:展示任务统计信息,并提供“清除已完成”功能。

整个应用的数据流遵循 单向数据流(Unidirectional Data Flow) 原则:所有状态(todos 数组)由父组件 App 集中管理,子组件通过 props 接收数据和回调函数,实现“只读数据 + 上报事件”的通信模式。


🏗️ 核心组件详解

🔹 App.jsx:状态管理中心与生命周期协调者

import { useState, useEffect } from 'react'
import './styles/App.styl'
import TodoInput from './components/TodoInput'
import TodoList from './components/TodoList'
import TodoStats from './components/TodoStats'

function App() {
  // 初始化 todos 状态,优先从 localStorage 读取
  const [todos, setTodos] = useState(() => {
    const saved = localStorage.getItem('todos')
    return saved ? JSON.parse(saved) : []
  })

  // 添加任务
  function addTodo(text) {
    setTodos([...todos, { id: Date.now(), text, completed: false }])
  }

  // 删除任务
  const deleteTodo = (id) => {
    setTodos(todos.filter(todo => todo.id !== id))
  }

  // 切换任务完成状态
  const toggleTodo = (id) => {
    setTodos(todos.map(todo =>
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    ))
  }

  // 清除所有已完成任务
  const clearCompleted = () => {
    setTodos(todos.filter(todo => !todo.completed))
  }

  // 计算活跃与已完成任务数量
  const activeCount = todos.filter(todo => !todo.completed).length;
  const completedCount = todos.filter(todo => todo.completed).length;

  // 监听 todos 变化,同步到 localStorage
  useEffect(() => {
    localStorage.setItem('todos', JSON.stringify(todos))
  }, [todos])

  return (
    <div className="todo-app">
      <h1>My Todo List</h1>
      <TodoInput onAdd={addTodo} />
      <TodoList todos={todos} onDelete={deleteTodo} onToggle={toggleTodo} />
      <TodoStats 
        total={todos.length} 
        active={activeCount} 
        completed={completedCount} 
        onClearCompleted={clearCompleted} 
      />
    </div>
  )
}

export default App

📌 关键知识点补充:

  • useState 的惰性初始化(Lazy Initialization)

    useState(() => { ... })
    

    这种写法确保初始化函数只在组件首次渲染时执行一次,避免不必要的计算。这对于从 localStorage 读取数据非常有用。

  • 不可变性(Immutability)原则
    所有状态更新都使用展开运算符(...)或数组方法(filter, map)创建新数组,而非直接修改原数组。这是 React 状态更新的核心要求,确保组件能正确响应变化。

  • useEffect 与副作用管理
    useEffect 在每次 todos 变化后自动将数据序列化并存入 localStorage,实现本地持久化。依赖数组 [todos] 确保仅在 todos 改变时触发。

  • 计算属性
    activeCountcompletedCount 是派生状态(Derived State),每次渲染时重新计算,无需单独用 useState 管理,符合 React 最佳实践。


🔹 TodoInput.jsx:受控组件与表单处理

import { useState } from 'react'

const TodoInput = (props) => {
  const { onAdd } = props;
  const [inputValue, setInputValue] = useState('')

  const handleSubmit = e => {
    e.preventDefault()
    if (inputValue.trim() !== '') {
      onAdd(inputValue)
      setInputValue('')
    }
  }

  return (
    <form className="todo-input" onSubmit={handleSubmit}>
      <input 
        type="text" 
        value={inputValue}
        onChange={e => setInputValue(e.target.value.replace(/\s+/g, '').trim())}
      />
      <button type="submit">Add</button>
    </form>
  )
}

export default TodoInput

📌 关键知识点补充:

  • 受控组件(Controlled Component)
    <input> 的值由 React 状态 inputValue 控制,任何用户输入都通过 onChange 事件处理器更新状态,实现 单向数据绑定。这与 Vue 的 v-model 双向绑定不同,但更符合 React 的声明式理念。
  • 表单提交处理
    使用 <form>onSubmit 事件而非按钮的 onClick,可支持回车键提交,并通过 e.preventDefault() 阻止页面刷新。
  • 输入清理逻辑
    e.target.value.replace(/\s+/g, '').trim() 会移除所有空白字符(包括中间空格),这可能是为了强制输入为连续字符串。但在实际 Todo 应用中,通常只需 .trim() 去除首尾空格即可保留中间空格(如 “Buy milk and eggs”)。此处逻辑略显激进,可根据需求调整。

🔹 TodoList.jsx:列表渲染与交互

const TodoList = (props) => {
  const { todos, onDelete, onToggle } = props;
  return (
    <ul className="todo-list">
      { todos.length === 0 ? (
        <li className="empty">No todos yet!</li>
      ) : (
        todos.map(todo => (
          <li key={todo.id} className={todo.completed ? 'completed' : ''}>
            <label>
              <input 
                type="checkbox" 
                checked={todo.completed}
                onChange={() => onToggle(todo.id)} 
              />
              <span>{todo.text}</span>
            </label>
            <button onClick={() => onDelete(todo.id)}>💩</button>
          </li>
        ))
      )}
    </ul>
  )
}

export default TodoList

📌 关键知识点补充:

  • 列表渲染与 Key 属性
    key={todo.id} 是 React 识别列表项身份的关键。使用 Date.now() 作为 ID 虽简单,但在高频添加时可能冲突(毫秒级精度不足)。生产环境应使用更可靠的唯一 ID 生成方案(如 uuid 库)。
  • 条件渲染
    使用三元运算符根据 todos.length 决定显示空状态还是任务列表。
  • 样式动态绑定
    className={todo.completed ? 'completed' : ''} 根据任务状态动态添加 CSS 类,配合样式表实现视觉反馈(如划线、透明度降低等)。
  • 事件处理器内联 vs 提前定义
    此处使用内联箭头函数 () => onToggle(todo.id),简洁但可能影响性能(每次渲染创建新函数)。对于大型列表,可考虑使用 useCallback 优化,但本应用规模小,影响可忽略。

🔹 TodoStats.jsx:状态展示与条件渲染

const TodoStats = (props) => {
  const { total, active, completed, onClearCompleted } = props
  return (
    <div>
      <p>Total: {total} | Active: {active} | Completed: {completed}</p>
      { completed > 0 && (
        <button onClick={onClearCompleted} className="clear-btn">
          Clear Completed
        </button>
      )}
    </div>
  )
}

export default TodoStats

📌 关键知识点补充:

  • 短路求值(Short-circuit Evaluation)
    { completed > 0 && <button>... 是 React 中常见的条件渲染技巧。当 completed > 0true 时渲染按钮,否则渲染 false(React 会忽略)。
  • Props 解构
    使用解构赋值 const { total, active, ... } = props 提高代码可读性。

🎨 样式与主题:CSS 与 Stylus 的结合

项目同时使用了:

  • index.css:全局样式,设置页面布局、字体、颜色方案及背景图。

    body {
      margin: 0;
      display: flex;
      place-items: center;
      min-width: 320px;
      min-height: 100vh;
      background-image: url('./assets/react-beautiful.svg');
      /* ... */
    }
    
    • 利用 prefers-color-scheme 媒体查询实现深色/浅色主题自动切换
    • place-items: centeralign-itemsjustify-items 的简写,用于 Flex 容器居中。
  • App.styl(未提供内容,但被引入):
    使用 Stylus 预处理器编写组件局部样式。Stylus 语法简洁,支持嵌套、变量、混入等特性,提升 CSS 可维护性。


⚙️ 项目初始化与构建工具链

根据 README.md 描述,项目使用 Vite 初始化:

npm init vite
# 选择 react + javascript 模板
npm i stylus  # 安装 Stylus 预处理器支持

Vite 提供极速的冷启动和热更新(HMR),极大提升开发体验。配合 React 的模块化开发,形成高效的工作流。


🔄 数据持久化:localStorage 的巧妙运用

应用通过以下方式实现数据持久化:

  1. 初始化时读取

    useState(() => {
      const saved = localStorage.getItem('todos')
      return saved ? JSON.parse(saved) : []
    })
    
  2. 状态变更时写入

    useEffect(() => {
      localStorage.setItem('todos', JSON.stringify(todos))
    }, [todos])
    

⚠️ 注意:localStorage 只能存储字符串,因此需用 JSON.stringifyJSON.parse 进行序列化/反序列化。

这种方案简单有效,适用于小型应用。对于复杂应用,可考虑 IndexedDB 或状态管理库(如 Redux + redux-persist)。


🧠 设计模式与最佳实践总结

  • 单一数据源(Single Source of Truth) :所有状态集中在 App 组件。
  • 自上而下的数据流:父组件通过 props 向下传递数据和函数。
  • 子组件无状态(Dumb Components)TodoInputTodoListTodoStats 仅负责 UI 渲染和事件上报。
  • 不可变更新:始终返回新对象/数组,而非修改原数据。
  • 副作用隔离:使用 useEffect 处理副作用(如 localStorage 同步)。
  • 语义化 HTML:使用 <form>, <label>, <input type="checkbox"> 提升可访问性。

✅ 结语

这个看似简单的 Todo 应用,实则涵盖了 React 开发的核心概念:组件化、状态管理、事件处理、条件渲染、列表渲染、表单控制、副作用管理、本地存储以及样式处理。通过深入理解每一行代码背后的原理,你不仅能复现此应用,更能举一反三,构建更复杂的 React 项目。🚀

现在,打开你的编辑器,动手实践吧!

Node.js 进程退出时,为什么你的日志总会“断尾”?

🚀 省流助手(速通结论)

  1. 内存不用清:进程退出后 OS 会自动回收内存,在 exit 事件里手动清理变量是浪费时间。
  2. 异步已死exit 触发时事件循环已停止,所有 Promisestream.end() 均无效。
  3. 日志丢失真相WriteStream 有内部缓冲区(默认 64KB)。不执行物理落盘(fsyncSync),进程消失时内存里的残留数据会被丢弃。
  4. 非侵入式清理:严禁在信号监听器里硬编码 process.exit() 导致“霸权退出”,应采用  “同步刷盘 + 信号归还”  的稳健方案。

一、 进程弥留之际,我们在清理什么?

很多开发者习惯在进程退出时忙着将对象置空、清空 Map。请停止这种无效劳动。

进程退出后,操作系统会强制回收所有物理内存。我们真正关心的是那些  “操作系统无法自动收尾”  的数据:应用层缓冲区。

Node.js 的文件写入流(fs.WriteStream)为了性能,默认会有 64KB 的缓冲区。当你调用 write() 时,数据可能还停留在 V8 堆内存或内核缓冲区中。如果进程此时突然消失,这 64KB 的数据(通常是最关键的报错日志)就会直接随内存一起蒸发。


二、 核心事件拆解:谁才是真正的“临终告别”?

处理退出逻辑主要涉及以下两类事件,但它们的性质截然不同:

1. exit 事件 (最后的归口)

  • 特性:Node.js 进程的“终点站”,此时事件循环(Event Loop)已经停止。
  • 局限:绝对不要写异步代码。比如调用 stream.end(),它依赖事件循环去刷新缓冲区,在 exit 事件里它根本没机会执行完。
  • 唯一解:必须使用基于文件描述符(FD)的同步系统调用 fs.fsyncSync(fd)

2. SIGINT & SIGTERM (外部的关闭请求)

  • 信号特性SIGINT(Ctrl+C)或 SIGTERM(Docker/K8s 停机)默认会使进程闪退,且不会主动触发 exit 事件。
  • 劫持效应:一旦你监听了这些信号,Node.js 的默认退出行为会被拦截。如果处理不当,进程会“苟活”在后台变成僵尸进程。

三、 工业级方案:实现 100% 日志完整性

为了确保最后一行日志不丢失,我们需要一套既能“落盘”又不“侵入”的清理逻辑。

  1. 非侵入式监听:只负责保命,不抢夺控制权

不要在信号监听器里写 process.exit()。这会截断其他模块(如数据库连接池、框架销毁钩子)的清理逻辑。推荐使用 once 监听并执行同步刷盘。

const flushLogsSync = () => {
  // 核心:遍历所有日志流,执行同步物理落盘
  for (const stream of logStreamMap.values()) {
    try {
      // 检查流是否持有物理文件句柄 (fd)
      if (typeof stream.fd === "number") {
        // 强制内核将文件缓冲区数据物理写入磁盘
        fs.fsyncSync(stream.fd);
      }
    } catch (e) {}
  }
};

// 监听一次性退出信号
['SIGTERM', 'SIGINT'].forEach(signal => {
  process.once(signal, () => {
    flushLogsSync();
    // 逻辑执行完后,若无其他异步任务,进程将按 Node.js 机制自然退出
    // 若需确保绝对退出,可在此处根据业务优先级决定是否补充 process.exit()
  });
});

// 监听 Node.js 自然退出事件(最后的同步兜底)
process.once("exit", flushLogsSync);
  1. 针对“手动存档”的特殊处理

在不退出进程的情况下,如果你希望手动强制落盘(如应对定时快照或热重载),可以利用 SIGHUP 信号:

// 针对“手动存档”的特殊处理 
// 使用 .on 而非 .once,因为热重载或手动存档可能在一个进程生命周期内多次触发
process.on('SIGHUP', flushLogsSync);

四、 总结:造轮子是为了看清路

在 Node.js 进程关闭的瞬间,异步是不可靠的。

  • 错误做法:在 exit 里写 await 或异步的 stream.end()
  • 工业做法同步 fsyncSync + 尊重进程自然生命周期。

通过这套逻辑,你才能在进程挂掉的瞬间,把内存中的“遗言”安全存入磁盘。


💡 进阶思考:原生实现 vs 工业级日志库

本文方案是从底层原理出发,带你搞清丢失根源。在复杂的生产环境中,建议在理解原理的基础上选择成熟的库:

  1. Pino (性能王者) :Node.js 性能天花板。注意:Pino v8+ 引入了 Worker Threads 异步写入架构。在 2026 年的实践中,若需确保退出时不丢日志,应在信号处理器中通过 destination.flushSync() 手动刷盘,而无需牺牲平时的性能。
  2. Winston:功能最全的“老哥”。支持多端传输(Transports),适合需要将日志同时分发到控制台、本地文件和远程监控系统的场景。
  3. Bunyan:主打严格的 JSON 结构化。

总结一句话:理解了文件描述符(FD)重定向和物理落盘,你就能更从容地配置这些专业库,确保每一行日志都能在进程“临终”前安全到家。


Vue3虚拟滚动列表组件进阶:不定高度及原理分析!!!

你是否遇到过这样的场景:

后端一次性返回了 10,000 条聊天记录,每条记录的内容长度都不一样。当你试图把它们全部渲染到页面上时,浏览器瞬间卡死,用户体验极差。

这就是我们需要 虚拟列表 (Virtual List) 的时刻。

今天,我们将深入浅出地讲解如何实现一个支持不定高度的虚拟列表组件。哪怕你是刚入门的前端小白,读完这篇文章也能亲手写出来!


1. 核心原理:只渲染你能看到的

想象你在看一本 1000 页的书。你虽然拿着整本书,但你的眼睛同一时间只能看到展开的那两页。

虚拟列表也是这个道理:无论数据有多少条,我们只渲染当前可视区域内的那几条。

两个关键容器

要实现这个效果,我们需要在 HTML 里放两个“盒子”:

  1. 幽灵容器 (Phantom Container)
    • 它不装任何内容,但它的高度等于所有数据加载完后的总高度
    • 它的作用是撑开浏览器的滚动条,让用户感觉自己在滚一个很长的列表。
  2. 渲染区域 (Content Container)
    • 它真正用来放列表项。
    • 随着你的滚动,我们会动态计算它的 transform: translate3d(...) 偏移量,让它永远出现在你的眼前。
  +------------------+  <-- 浏览器视口 (Viewport)
  |                  |
  |   +----------+   |  <-- 渲染区域 (Content Container)
  |   | Item 10  |   |      (通过 transform 移动到这里)
  |   +----------+   |
  |   | Item 11  |   |
  |   +----------+   |
  |                  |
  +------------------+
          |
          | (滚动条)
          |
  +------------------+  <-- 幽灵容器 (Phantom Container)
  |                  |      (高度 = 所有 Item 高度之和)
  |                  |      (虽然是空的,但负责把滚动条撑长)
  |                  |
  +------------------+

2. 形象比喻:秒懂虚拟列表

单纯看原理可能有点枯燥,我们用电影放映的例子来帮你彻底记住它。

🎞️ 场景一:老式电影放映机 (对应核心机制)

想象一下:你正在放映一部长达 3 小时的电影胶片。

  1. 长长的胶片卷 (Phantom / 幽灵容器): 整卷胶片可能有几公里长,这决定了放映机旁边的卷盘有多大。这就是滚动条,它让你知道电影(数据)的总长度。
  2. 小小的放映窗口 (Viewport / 可视区域): 虽然胶片很长,但放映机一次只能让一张底片经过镜头。我们不需要把整卷胶片都摊在银幕上,只需要确保当前那一小段在镜头前。
  3. 快速切换底片 (Data Binding & Offset / 数据绑定与偏移): 随着电机转动,旧的画面移出,新的画面移入。对观众来说,画面是连续的;但对放映机来说,它永远只在处理镜头前的那一丁点空间。

🎬 场景二:长短不一的“电影片段” (对应不定高度与修正)

如果每张底片的高度都一样,放映机转速固定即可。但如果这是一部“实验电影”,有些片段是正常的,有些片段特别长(比如长卷轴):

  1. 盲目快进 (Estimate / 预估): 你以为每段都是 10 厘米。你想看第 10 段,于是快进了 100 厘米。
  2. 发现画面偏了 (Render / 渲染): 停下一看,发现前面的片段里有好几个是“超长版”,结果你现在停在了第 8 段和第 9 段的中间。
  3. 校准位置 (Correction / 修正): 你不得不量一下刚才那几段到底有多长,然后把放映机的位置往后挪一挪,确保第 10 段能精准对齐镜头。

这就是 updatePositions 的意义:当实际内容渲染出来后,发现它比预想的要高,就要立刻把后面的内容往后“顶”开,并修正滚动条位置。


3. 实现步骤:从 0 到 1

我们将按照以下流程来实现这个组件:

  1. 初始化:定义 positions 数组,预估每个列表项的高度,生成初始位置信息。
  2. 可视区计算:监听滚动事件,根据 scrollTop 计算出当前应该显示哪几项(startIndexendIndex)。
  3. 渲染与偏移:从数据源中取出这几项进行渲染,并设置 transform 偏移量,让它们显示在屏幕正确的位置。
  4. 动态修正:DOM 渲染完成后,获取每一项的真实高度。如果真实高度与预估不符,更新 positions 数组,并调整后续所有项的位置。
        [ 🚀 开始 ]
             │
             ▼
  ┌───────────────────────────┐
  │   1. 初始化数据 (Init)    │
  ├───────────────────────────┤
  │ 🔑 生成 positions 数组    │
  │ 🔑 设定预估高度           │
  └──────────┬────────────────┘
             │
             ▼
  ┌───────────────────────────┐ <─────┐
  │   2. 监听事件 (Listen)    │       │
  ├───────────────────────────┤       │
  │ 🔑 scroll 事件 (滚动)     │       │
  │ 🔑 ResizeObserver (视口)  │       │
  └──────────┬────────────────┘       │
             │                        │
             ▼                        │
  ┌───────────────────────────┐       │
  │   3. 渲染视图 (Render)    │       │
  ├───────────────────────────┤       │
  │ 🔑 截取 visibleData       │       │
  │ 🔑 设置 translate3d 偏移  │       │
  └──────────┬────────────────┘       │
             │                        │
             ▼                        │
  ┌───────────────────────────┐       │
  │   4. 动态修正 (Correct)   │       │
  ├───────────────────────────┤       │
  │ 🔑 测量真实 DOM 高度      │       │
  │ 🔑 更新 positions 数组    │ ──────┘
  └───────────────────────────┘

请添加图片描述

🔁 核心逻辑拆解

为了让这个闭环高效运转,我们需要处理好三个关键行为与事件的关联:

  1. 初始化与预估 (Init & Estimate)
    • 触发:组件加载时。
    • 逻辑:由于无法提前预知真实高度,我们先给每一项“画大饼(预估高度)”,生成初始的 positions 坐标系。
  2. 滚动定位 (Scroll & Locate)
    • 触发:用户拖动滚动条(scroll 事件)。
    • 逻辑:利用二分查找positions 中快速锁定当前 scrollTop 对应的 startIndex。随后通过 translate3d 将容器偏移到可视区。
  3. 动态测量与修正 (Measure & Correct)
    • 触发:数据渲染到 DOM 后(onUpdated)或视口大小改变(ResizeObserver)。
    • 逻辑:获取真实 DOM 高度。如果发现第 N 项高了 20px,就修正该项并让 N 之后的所有项坐标“集体下移” 20px,确保存储的位置信息与实际完美对齐。 请添加图片描述

⚠️ 关于 ResizeObserver 的误区

很多同学可能会疑惑:“列表滚动或数据更新时,ResizeObserver 会触发吗?”

答案是:不会

ResizeObserver 专门用于监听元素容器本身(外层盒子)的物理尺寸变化

场景 ResizeObserver 触发? 应该由谁处理?
用户滚动列表 ❌ 不触发 @scroll 事件监听
后端返回新数据 ❌ 不触发 watch(() => props.data)
浏览器窗口缩放 触发 ResizeObserver
侧边栏折叠/展开 触发 ResizeObserver

它的核心使命:当视口(盒子)变大变小时,告诉组件“现在一屏能多塞几条数据了”,从而避免底部出现留白。 简单总结:就像电影放映机刚摆好的时候,先量一下银幕有多大,然后调整好镜头焦距,并安排一个人盯着,万一银幕变大了就赶紧调整画面。

4. 代码实战:一步步实现

<script setup lang="ts">
import type { PropType } from 'vue'
import { useNamespace } from '@my-antd-ui/utils'
import {
  computed,
  nextTick,
  onMounted,
  onUnmounted,
  onUpdated,
  reactive,
  ref,
  watch,
} from 'vue'

defineOptions({
  name: 'MyVirtualList',
})

const props = defineProps({
  data: {
    type: Array as PropType<any[]>,
    default: () => [],
  },
  itemHeight: {
    type: Number,
    default: 50,
  },
  estimatedItemHeight: {
    type: Number,
    default: 50,
  },
  height: {
    type: [Number, String],
    default: '100%',
  },
})

const ns = useNamespace('virtual-list')
const rootRef = ref<HTMLElement | null>(null)
const itemsRef = ref<HTMLElement[] | null>([])

interface Position {
  index: number
  top: number
  bottom: number
  height: number
  dHeight: number // 更新后的高度差
}

const positions = ref<Position[]>([])

const state = reactive({
  start: 0,
  end: 10,
  scrollTop: 0,
  containerHeight: 0,
})

// 基于预估高度初始化位置信息
function initPositions() {
  positions.value = props.data.map((item, index) => ({
    index,
    height: props.estimatedItemHeight,
    top: index * props.estimatedItemHeight,
    bottom: (index + 1) * props.estimatedItemHeight,
    dHeight: 0,
  }))
}

watch(() => props.data, initPositions, { immediate: true })

// 1. 列表总高度等于最后一个元素的底部位置
const listHeight = computed(() => {
  return positions.value.length > 0
    ? positions.value[positions.value.length - 1].bottom
    : 0
})

// 2. 可视区域内的列表项数量(逻辑上非必需,但有助于初始估算)
const visibleCount = computed(() => {
  return Math.ceil(state.containerHeight / props.estimatedItemHeight)
})

// 3. 当前可视区域的数据
const visibleData = computed(() => {
  return props.data
    .slice(state.start, Math.min(state.end, props.data.length))
    .map((item, index) => ({
      ...item,
      index: state.start + index,
    }))
})

// 4. 偏移量
const offset = computed(() => {
  if (state.start >= 1) {
    return positions.value[state.start].top
  }
  return 0
})

// 使用二分查找找到第一个 bottom > scrollTop 的列表项
function getStartIndex(scrollTop: number = 0) {
  let low = 0
  let high = positions.value.length - 1
  let res = -1

  while (low <= high) {
    const mid = Math.floor((low + high) / 2)
    const pos = positions.value[mid]

    if (pos.bottom > scrollTop) {
      if (pos.top < scrollTop) {
        // 该列表项跨越了顶部边界
        res = mid
        break
      }
      // 该列表项完全在 scrollTop 之下,尝试查找更早的项
      res = mid // 候选项,但可能还有更早的?
      high = mid - 1
    }
    else {
      // 该列表项完全在 scrollTop 之上
      low = mid + 1
    }
  }
  return res === -1 ? 0 : res
}

function updateVisibleRange() {
  state.start = getStartIndex(state.scrollTop)
  state.end = state.start + visibleCount.value + 2 // 渲染额外的缓冲区以增加平滑度
}

function onScroll(e: Event) {
  state.scrollTop = (e.target as HTMLElement).scrollTop
  updateVisibleRange()
}

// 渲染后测量并更新位置信息
function updatePositions() {
  const nodes = itemsRef.value
  if (!nodes || nodes.length === 0)
    return

  nodes.forEach((node) => {
    // 从 dataset 获取索引(需要在模板中绑定)
    // 或者假设顺序与 visibleData 一致
    // 为了安全起见,我们使用 getAttribute 获取绑定的属性
    const indexStr = node.getAttribute('data-index')
    if (!indexStr)
      return
    const index = Number.parseInt(indexStr)

    const rect = node.getBoundingClientRect()
    const height = rect.height
    const oldHeight = positions.value[index].height
    const dHeight = height - oldHeight

    if (dHeight) {
      positions.value[index].height = height
      positions.value[index].bottom = positions.value[index].bottom + dHeight
      positions.value[index].dHeight = dHeight
    }
  })

  // 累积更新后续项的位置信息
  // 找到第一个发生变化的索引
  // 这是一个简化的 O(N) 更新。对于海量列表可能需要优化,但通常情况下性能可以接受。
  // 实际上,我们应该从发生变化的起始索引开始更新。
  // 但这里我们是在遍历可视节点。
  const startUpdateIndex = Number.parseInt(nodes[0].getAttribute('data-index') || '0')
  const len = positions.value.length

  for (let i = startUpdateIndex; i < len; i++) {
    const item = positions.value[i]
    // 如果它是我们刚才测量过的项,它可能已经有了自己的 dHeight
    // 但我们也需要向前传递之前累积的差异。
    // 等等,上面的逻辑只更新了该项的 `bottom`。
    // 我们需要基于前一项的 bottom 重新计算 top/bottom。
    if (i > 0) {
      item.top = positions.value[i - 1].bottom
      item.bottom = item.top + item.height
    }
    else {
      item.top = 0
      item.bottom = item.height
    }
  }
}

onUpdated(() => {
  nextTick(() => {
    updatePositions()
  })
})

// 设置 ResizeObserver
let observer: ResizeObserver | null = null

onMounted(() => {
  if (rootRef.value) {
    state.containerHeight = rootRef.value.clientHeight
    updateVisibleRange()

    observer = new ResizeObserver((entries) => {
      for (const entry of entries) {
        state.containerHeight = entry.contentRect.height
        updateVisibleRange()
      }
    })
    observer.observe(rootRef.value)
  }
})

onUnmounted(() => {
  if (observer) {
    observer.disconnect()
    observer = null
  }
})
</script>

<template>
  <div ref="rootRef" :class="ns.b()" @scroll="onScroll">
    <!-- 1. 幽灵容器 -->
    <div :class="ns.e('phantom')" :style="{ height: `${listHeight}px` }" />

    <!-- 2. 真实渲染区域 -->
    <div
      :class="ns.e('content')"
      :style="{ transform: `translate3d(0, ${offset}px, 0)` }"
    >
      <div
        v-for="item in visibleData"
        :key="item.key ?? item.id"
        ref="itemsRef"
        :class="ns.e('item')"
        :data-index="item.index"
      >
        <slot :item="item" :index="item.index" />
      </div>
    </div>
  </div>
</template>

第一步:定义数据结构

我们需要一个数组 positions 来记录每一项的位置信息。

interface Position {
  index: number  // 第几条数据
  top: number    // 顶部距离总列表顶端的距离
  bottom: number // 底部距离总列表顶端的距离
  height: number // 这一项的高度
  dHeight: number // 高度修正值(真实高度 - 预估高度)
}

const positions = ref<Position[]>([])

初始化的时候,我们先按预估高度(比如 50px)把这个数组填满。

第二步:二分查找 (Binary Search)

当你滚动到 scrollTop = 10000 的位置时,我们怎么知道该从第几条数据开始渲染?

如果从头遍历 positions 数组,如果数据有一百万条,那每次滚动都要算很久。所以我们用二分查找,效率瞬间起飞。

// 找到第一个底部位置大于 scrollTop 的元素索引
function getStartIndex(scrollTop: number = 0) {
  let low = 0
  let high = positions.value.length - 1
  
  while (low <= high) {
    const mid = Math.floor((low + high) / 2)
    const pos = positions.value[mid]

    if (pos.bottom > scrollTop) {
      if (pos.top < scrollTop) {
        // 就是它!跨越了可视边界
        return mid
      }
      high = mid - 1 // 在前半截
    } else {
      low = mid + 1  // 在后半截
    }
  }
  return 0
}

第三步:渲染后的自动修正

这是最精彩的一步。当数据渲染到页面上后,Vue 的 onUpdated 钩子会被触发。我们在这里进行测量和修正。

function updatePositions() {
  const nodes = itemsRef.value
  if (!nodes || nodes.length === 0) return

  // 1. 测量真实高度,更新当前可视区域的数据
  nodes.forEach(node => {
    const rect = node.getBoundingClientRect()
    const height = rect.height
    const index = parseInt(node.getAttribute('data-index'))
    const oldHeight = positions.value[index].height
    const dHeight = height - oldHeight

    if (dHeight) {
      positions.value[index].height = height
      positions.value[index].bottom = positions.value[index].bottom + dHeight
      positions.value[index].dHeight = dHeight // 记录差值
    }
  })

  // 2. 累积效应:从第一个变化的位置开始,后续所有项都要调整
  // 比如第 5 项变高了 10px,那么第 6 项的 top 就要 +10px,bottom 也要 +10px...
  let startAdjustIndex = /* 找到这次更新中最早出现的那个索引 */
  let accumulatedDiff = 0
  
  for (let i = startAdjustIndex; i < positions.value.length; i++) {
     const item = positions.value[i]
     // 更新 top
     item.top = positions.value[i - 1].bottom
     // 更新 bottom
     item.bottom = item.top + item.height
  }
}

第四步:模板与样式 (Template & Style)

最后,看看模板里是怎么把这两个“盒子”组装起来的:

<template>
  <div ref="rootRef" class="my-virtual-list" @scroll="onScroll">
    <!-- 1. 幽灵容器:高度由 listHeight 撑开 -->
    <div class="my-virtual-list__phantom" :style="{ height: `${listHeight}px` }" />

    <!-- 2. 真实渲染区域:通过 transform 移动到可视区 -->
    <div
      class="my-virtual-list__content"
      :style="{ transform: `translate3d(0, ${offset}px, 0)` }"
    >
      <div
        v-for="item in visibleData"
        :key="item.id"
        class="my-virtual-list__item"
        :data-index="item.index" 
      >
        <!-- 插槽:把具体怎么渲染这一行的权力交给使用者 -->
        <slot :item="item" :index="item.index" />
      </div>
    </div>
  </div>
</template>

别忘了加上关键的 CSS,否则“幽灵”和“内容”会打架:

.my-virtual-list {
  position: relative;
  overflow-y: auto; /* 必须开启滚动 */
  height: 100%;
}

.my-virtual-list__phantom {
  position: absolute;
  left: 0;
  top: 0;
  right: 0;
  z-index: -1; /* 藏在后面,只为了撑开高度 */
}

.my-virtual-list__content {
  position: absolute;
  left: 0;
  right: 0;
  top: 0;
  /* transform 会在这里动态设置 */
}

第五步:响应式适配 (ResizeObserver)

如果用户缩放了浏览器窗口,或者侧边栏折叠导致列表容器高度变化,我们需要重新计算“一屏能显示多少条数据”。

// 监听容器尺寸变化
let observer: ResizeObserver | null = null

onMounted(() => {
  if (rootRef.value) {
    observer = new ResizeObserver((entries) => {
      // 更新容器高度 -> 触发 visibleCount 重新计算
      state.containerHeight = entries[0].contentRect.height
      updateVisibleRange()
    })
    observer.observe(rootRef.value)
  }
})

onUnmounted(() => {
  if (observer) {
    observer.disconnect()
    observer = null
  }
})

5. 总结

实现不定高度虚拟列表,核心就是:

  1. 位置缓存:用 positions 数组记录每一项的几何信息。
  2. 滚动计算:监听 scroll 事件,用二分查找快速定位 startIndex
  3. 动态修正:DOM 渲染完后,测量真实高度,反过来修正 positions 数组,确保持续滚动的准确性。

里程碑五:Elpis框架npm包抽象封装并发布

本文是《大前端全栈实践》学习实践的总结,参考自抖音"哲玄前端"老师的课程内容,在此基础上进行了深度思考与实践。


一、背景与目标

在完成了前四个里程碑的开发后,我们的项目已经具备了完整的后端服务(Koa)、以及基于Vue3的动态组件库前端。然而,随着功能模块的增加,整个项目的代码结构开始变得臃肿。业务逻辑与底层架构代码混杂在一起,导致核心架构难以复用,新项目的启动成本也越来越高。

里程碑五的核心目标是 "架构与业务分离"

如果不进行抽象,每次新建项目都需要拷贝通过一大堆基础代码。通过将底层的通用能力(路由加载、中间件管理、控制器装载等)封装为一个独立的 npm 包,我们可以实现:

  • 框架复用:一次编写,多处使用。
  • 业务聚焦:开发者只需关注 app 目录下的业务逻辑,无需关心底层启动流程。
  • 版本管理:框架的更新与维护独立于具体业务项目。

二、架构设计:从单体到分层

我们将项目拆解为两个部分:

  1. 框架层 (@aikun4588/elpis):负责底层驱动,包括 Koa 实例创建、环境识别、自动加载器(Loader)等。
  2. 业务层 (User Project):负责具体业务,遵循框架约定的目录结构编写 Controller、Service 和 Config。

目录结构约定(Convention over Configuration)

框架采用了约定优于配置的设计理念。只要业务层按照以下结构组织代码,框架就能自动识别并加载:

Project/
├── app/
│   ├── controller/      # 业务逻辑控制器
│   ├── service/         # 业务逻辑服务层
│   ├── middleware/      # 业务中间件
│   ├── router/          # 路由定义
│   └── router-schema/   # 参数校验Schema
├── config/              # 配置文件
│   ├── config.default.js
│   └── config.local.js
├── index.js             # 入口文件
└── package.json

三、核心实现原理:Loader机制

框架的核心在于Loader(加载器)。它利用 Node.js 的模块系统和文件系统能力(如 glob),自动扫描指定目录并挂载到 app 实例上。

3.1 统一入口与启动

elpis-core/index.js 中,我们导出一个 start 方法,该方法按顺序执行各个 Loader:

// elpis-core/index.js
start(options = {}) {
    const app = new Koa();
    // 确定业务代码根路径
    app.businessPath = path.resolve(process.cwd(), './app');
    
    // 按顺序加载核心模块
    app.env = env();                    // 环境识别
    middlewareLoader(app);              // 中间件加载
    routerSchemaLoader(app);            // 参数校验加载
    controllerLoader(app);              // 控制器加载
    serviceLoader(app);                 // 服务层加载
    configLoader(app);                  // 配置加载
    
    // ...最后启动监听
    app.listen(port);
}

3.2 智能控制器加载(Controller Loader)

controllerLoader 负责扫描 app/controller 下的所有脚本,并根据文件路径自动转换命名(如 user-info.js -> userInfo),最终挂载到 app.controller 上。

这种设计使得我们在路由定义时,可以直接通过 app.controller.home.index 来引用,而不需要手动 require。

// loader/controller.js 核心逻辑
module.exports = (app) => {
    // 扫描 app/controller 下所有 js 文件
    const fileList = glob.sync(path.resolve(app.businessPath, './controller/**/*.js'));
    
    fileList.forEach(file => {
        // 解析文件名,转换为驼峰命名
        // 动态 require 并实例化
        // 挂载到 app.controller 对象树上
    });
}

3.3 路由与Schema的结合

配合里程碑四的理念,我们还实现了 routerSchemaLoader。它自动读取 parameter 校验规则,与 API 接口进行绑定。这体现了框架层规范的强制性支持 —— 在框架层面就集成了参数校验能力,业务开发只需填写 JSON Schema。


四、用户自定义与框架结合

抽象后的框架极大地简化了用户端的使用。开发者只需安装 npm 包,并在入口文件中简单调用即可启动服务。

发布包名@aikun4588/elpis NPM地址www.npmjs.com/package/@ai…

使用示例

用户项目的 index.js 变得非常简洁:

// 引入核心框架
const ElpisCore = require('@aikun4588/elpis');

// 自定义配置并启动
ElpisCore.start({
    name: 'MyProject',
    homePage: '/view/dashboard'
});

框架运行时,会主动读取当前工作目录 (process.cwd()) 下的 app 文件夹,将用户的自定义代码注入到框架的生命周期中。这就实现了**"框架现有内容(底层能力)""用户自定义(业务逻辑)"**的完美结合。


五、流程图解

flowchart TD
    User[用户入口 index.js] --> Framework[Framework Start]
    Framework --> Init[初始化 Koa & Env]
    
    subgraph Loaders [自动加载器序列]
        L1[加载 Middleware]
        L2[加载 RouterSchema]
        L3[加载 Controller]
        L4[加载 Service]
        L5[加载 Config]
    end
    
    Init --> Loaders
    
    Loaders -- 扫描 --> UserCode[用户目录 ./app]
    UserCode -- 注入 --> Context[App上下文对象]
    
    Context --> Server[启动 HTTP 服务]

六、总结

里程碑五的完成,标志着 Elpis 从一个单一的练手项目,进化为一个可复用的企业级 Node.js 开发框架

通过将通用逻辑抽离为 npm 包 (@aikun4588/elpis),我们不仅解耦了代码,更重要的是确立了一套开发规范。未来的微服务或新功能模块,都可以基于此框架快速构建,真正实现了"提质增效"。

这一步抽象,是全栈工程师向架构师思维转变的关键一步。

2026 效率分水岭:从"会提问"到"会写技能",Rules、Skills、MCP 三剑客重塑 AI 协作

核心概念

什么是 Agent Skills?

Agent Skills 是由 Anthropic 开发并作为开放标准发布的智能体能力扩展工具,其本质是将专业知识和工作流封装为可复用的能力单元。

与传统提示词工程不同,Agent Skills 采用"文件夹+SKILL.md"的结构化设计,使模型能够按需加载不同层级的技能信息。

Agent Skills 的三大核心价值

  1. 知识外置化:将领域知识从模型权重中解耦,变成可编辑、可版本控制的文件
  2. 上下文高效化:通过渐进式披露,在有限窗口中最大化有效信息密度
  3. 能力生态化:通过开放标准,实现跨平台的知识共享与复用

渐进式披露机制

Agent Skills 最核心的创新是**渐进式披露(Progressive Disclosure)**机制。该机制将技能信息分为三个层次,智能体按需逐步加载:

(1)元数据层(Metadata)

在 Skills 的设计中,每个技能都存放在一个独立的文件夹中,核心是一个名为 SKILL.md 的 Markdown 文件。这个文件必须以 YAML 格式的 Frontmatter 开头,定义技能的基本信息:

---
name: pdf-processing
description: Extract text and tables from PDF files... Use when working with PDF files...
---

当智能体启动时,它会扫描所有已安装的技能文件夹,仅读取每个 SKILL.md 的 Frontmatter 部分,将这些元数据加载到系统提示词中。根据实测数据,每个技能的元数据仅消耗约 100 个 token

(2)指令层(Instructions)

当智能体通过分析用户请求,判断某个技能与当前任务高度相关时,它会进入第二层加载。此时,智能体会读取该技能的完整 SKILL.md 文件内容,将详细的指令、注意事项、示例等加载到上下文中。这部分内容的 token 消耗取决于指令的复杂度,通常在 1,000 到 5,000 个 token 之间。

(3)资源层(Scripts & References)

对于更复杂的技能,SKILL.md 可以引用同一文件夹下的其他文件:脚本、配置文件、参考文档等。智能体仅在需要时才加载这些资源。

层级 内容 加载策略 Token消耗 技术价值
元数据层 YAML Frontmatter的name和description字段 Always-On常驻加载 极低(<1%) 模型路由决策与意图识别
指令层 SKILL.md正文的规则和步骤 On-Demand按需加载 中等(5-10%) 定义业务处理逻辑与SOP
资源层 外部文档、手册、脚本等 Context-Triggered条件触发 高但可变 按需加载专业知识,用完即弃

Rules、Skills、MCP 三者的关系

image.png

1. Rules(规则)的定位

Rules 是项目级别的静态规范,定义了代码风格、架构模式、开发流程等通用规则。

特点:

  • Always-On(常驻):始终加载在系统提示词中
  • 项目特定:针对特定项目的编码规范和最佳实践
  • 静态知识:不随任务变化,是项目的基础约束

典型用途:

  • 代码风格规范(ESLint、TypeScript 规范)
  • 项目架构模式(组件结构、文件组织)
  • 技术栈使用规范(UnoCSS 优先、组件库使用)
  • 命名规范和注释规范

示例(Cursor Rules):

# 项目开发规范

## 编码规范
- 使用 TypeScript,严格类型检查
- 优先使用 UnoCSS 原子类
- 组件逻辑抽离到 useXxx.ts hooks 文件

2. Skills(技能)的定位

Skills 是任务级别的动态能力,提供特定领域的专业知识和工作流程。

特点:

  • On-Demand(按需):仅在相关任务时加载
  • 领域特定:针对特定任务或领域的专业知识
  • 可复用:跨项目、跨平台复用

典型用途:

  • 设计稿转代码(Figma → uni-app)
  • API 集成指南
  • 数据分析工作流
  • 文档处理流程

示例(Design Conversion Skill):

---
name: design-conversion
description: Figma 设计稿转换为 uni-app 代码的技能,包含图片资源下载、UnoCSS 原子类使用、rpx 单位转换等完整规范
---

3. MCP(Model Context Protocol)的定位

MCP 是工具级别的连接协议,提供标准化的外部工具和数据访问接口。

特点:

  • 工具接口:定义如何访问外部工具和数据
  • 标准化:统一的协议规范
  • 运行时:动态调用外部服务

典型用途:

  • 文件系统操作
  • 数据库查询
  • API 调用
  • 外部服务集成(Figma、GitHub 等)

三者关系详解

关系类比

用一个完整的软件开发流程来理解:

  1. Rules = 公司编码规范手册

    • 定义"怎么写代码"的通用规则
    • 所有项目都要遵循
    • 例如:使用 TypeScript、优先使用 UnoCSS
  2. Skills = 特定任务的操作手册

    • 定义"如何完成特定任务"的专业知识
    • 按需加载,针对特定场景
    • 例如:如何将 Figma 设计稿转换为代码
  3. MCP = 工具和服务的驱动程序

    • 定义"如何连接和使用工具"
    • 提供标准化的接口
    • 例如:如何访问 Figma API、如何操作文件系统

协作流程示例

假设用户请求:"帮我把这个 Figma 设计稿转换成代码"

1. Rules 提供基础约束
   └─> "使用 UnoCSS 原子类,不要自定义 CSS"
   └─> "使用 TypeScript,严格类型检查"
   └─> "组件逻辑抽离到 hooks 文件"

2. Skills 提供专业知识
   └─> 触发 design-conversion skill
   └─> 加载 "如何从 Figma 下载图片"
   └─> 加载 "rpx 单位转换规则"
   └─> 加载 "UnoCSS 使用规范"

3. MCP 提供工具接口
   └─> 使用 Figma MCP 下载图片资源
   └─> 使用文件系统 MCP 保存文件
   └─> 使用代码编辑器 MCP 创建文件

职责分离原则

设计理念:连接性(Connectivity)与能力(Capability)应该分离。

  • MCP 专注于连接性:提供标准化的访问接口,让智能体能够"够得着"外部世界的数据和工具
  • Skills 专注于能力:提供领域专业知识,告诉智能体在特定场景下"如何组合使用这些工具"
  • Rules 专注于约束:提供项目级别的规范和最佳实践,确保输出符合项目标准

如果说 MCP 为智能体提供了"手"来操作工具,那么 Skills 就提供了"操作手册"或"SOP(标准作业程序)",Rules 则提供了"公司规范手册"。

对比总结

维度 Rules Skills MCP
定位 项目规范 领域知识 工具接口
加载时机 Always-On On-Demand Runtime
作用范围 项目级别 任务级别 工具级别
内容性质 静态规范 动态能力 连接协议
Token消耗 中等(常驻) 可变(按需) 低(接口定义)
复用性 项目内 跨项目 跨平台
更新频率

如何写好一个 Skill

image.png

核心原则

1. 简洁是关键

上下文窗口是公共资源。 技能与 Claude 需要的所有其他内容共享上下文窗口:系统提示、对话历史、其他技能的元数据以及实际的用户请求。

默认假设:Claude 已经非常智能。 只添加 Claude 尚未具备的上下文。质疑每条信息:

  • "Claude 真的需要这个解释吗?"
  • "这段文字值得它的 token 成本吗?"

优先使用简洁的示例而不是冗长的解释。

2. 设置适当的自由度

根据任务的脆弱性和可变性匹配具体程度:

  • 高自由度(基于文本的指令):当多种方法都有效、决策取决于上下文或启发式方法指导方法时使用
  • 中等自由度(伪代码或带参数的脚本):当存在首选模式、可接受一些变化或配置影响行为时使用
  • 低自由度(特定脚本,少量参数):当操作脆弱且容易出错、一致性至关重要或必须遵循特定顺序时使用

将 Claude 想象成在探索一条路径:有悬崖的狭窄桥梁需要特定的护栏(低自由度),而开阔的田野允许多条路线(高自由度)。

3. 渐进式披露设计

保持 SKILL.md 正文精简,不超过 500 行,以最小化上下文膨胀。当接近此限制时,将内容拆分到单独的文件中。

关键原则: 当技能支持多种变体、框架或选项时,仅在 SKILL.md 中保留核心工作流程和选择指导。将特定于变体的详细信息(模式、示例、配置)移至单独的参考文件。

Skill 创建流程

步骤 1:通过具体示例理解技能

要创建有效的技能,需要清楚理解技能将如何使用的具体示例。这种理解可以来自直接的用户示例或经过用户反馈验证的生成示例。

关键问题:

  • "此技能应该支持哪些功能?"
  • "您能给一些如何使用此技能的示例吗?"
  • "用户说什么应该触发此技能?"

步骤 2:规划可重用的技能内容

要将具体示例转化为有效的技能,通过以下方式分析每个示例:

  1. 考虑如何从头开始执行示例
  2. 确定在重复执行这些工作流程时哪些脚本、参考资料和资产会有帮助

分析维度:

  • 脚本(scripts/):需要确定性可靠性或重复编写的代码
  • 参考资料(references/):需要参考的文档、API 规范、数据库模式等
  • 资产(assets/):模板、图标、字体等输出资源

步骤 3:编写 YAML 前置元数据

这是技能最重要的部分,因为这是 Claude 用来确定何时使用该技能的唯一字段。

---
name: skill-name
description: 清晰全面地描述技能是什么以及何时应该使用它。包括技能做什么以及使用它的特定触发器/上下文。
---

关键要点:

  • name:技能名称,使用 kebab-case
  • description必须包含所有"何时使用"信息——而不是在正文中。正文仅在触发后加载,因此正文中的"何时使用此技能"部分对 Claude 没有帮助
  • 不要在 YAML 前置元数据中包含任何其他字段(除非有特殊需求,如 license

好的描述示例:

description: 全面的文档创建、编辑和分析,支持修订追踪、批注、格式保留和文本提取。当 Claude 需要处理专业文档(.docx 文件)时使用,用于:(1) 创建新文档,(2) 修改或编辑内容,(3) 处理修订追踪,(4) 添加批注,或任何其他文档任务

步骤 4:编写 SKILL.md 正文

写作指南: 始终使用祈使句/不定式形式。

结构建议:

  1. 快速入门:核心工作流程的简洁示例
  2. 详细规则:分步骤的详细说明
  3. 注意事项:常见错误和陷阱
  4. 参考资源:链接到 references/ 中的详细文档

避免:

  • 冗长的解释(优先使用示例)
  • 重复 Rules 中已有的内容
  • 过于详细的实现细节(移到 references/ 中)

步骤 5:组织资源文件

根据步骤 2 的分析,创建必要的资源文件:

  • scripts/:可执行代码(Python/Bash 等)
  • references/:参考文档(API 文档、数据库模式、详细指南等)
  • assets/:输出资源(模板、图标、字体等)

重要原则:

  • 避免重复:信息应仅存在于 SKILL.md 或参考文件中,而不是两者都有
  • 保持 SKILL.md 精简:仅在 SKILL.md 中保留基本的程序性指令和工作流程指导
  • 将详细的参考材料、模式和示例移至参考文件

步骤 6:测试和迭代

在实际任务中使用技能,注意困难或低效之处,确定应如何更新 SKILL.md 或捆绑资源,实施更改并再次测试。

Skill 的通用目录结构

标准目录结构

skill-name/
├── SKILL.md                    # 必需:技能主文件
│   ├── YAML 前置元数据(必需)
│   │   ├── name:(必需)
│   │   └── description:(必需)
│   └── Markdown 说明(必需)
│
└── 捆绑资源(可选)
    ├── scripts/                # 可执行代码
    │   ├── process_data.py
    │   └── generate_report.sh
    │
    ├── references/             # 参考文档
    │   ├── api-docs.md
    │   ├── database-schema.md
    │   └── workflows.md
    │
    └── assets/                 # 输出资源
        ├── templates/
        │   └── report-template.html
        ├── icons/
        │   └── logo.png
        └── fonts/
            └── custom-font.ttf

目录说明

SKILL.md(必需)

每个技能的核心文件,包含:

  1. YAML 前置元数据(必需)

    ---
    name: skill-name
    description: 技能描述,包含何时使用此技能的信息
    ---
    
  2. Markdown 正文(必需)

    • 使用技能的说明和指导
    • 仅在技能触发后加载
    • 保持精简,不超过 500 行

scripts/(可选)

用于需要确定性可靠性或重复编写的任务的可执行代码。

何时包含:

  • 当相同的代码被重复编写时
  • 当需要确定性可靠性时
  • 当操作复杂且容易出错时

示例:

  • scripts/rotate_pdf.py - PDF 旋转任务
  • scripts/process_data.py - 数据处理脚本
  • scripts/generate_report.sh - 报告生成脚本

优点:

  • Token 效率高(可以在不加载到上下文的情况下执行)
  • 确定性强(避免 LLM 生成过程中的不确定性)
  • 可复用(跨任务复用)

references/(可选)

用于根据需要加载到上下文中以指导 Claude 过程和思考的文档和参考材料。

何时包含:

  • 用于 Claude 在工作时应参考的文档
  • 当信息量较大(>10k 字)时
  • 当信息是领域特定的详细知识时

示例:

  • references/api-docs.md - API 规范文档
  • references/database-schema.md - 数据库模式
  • references/workflows.md - 详细工作流程指南
  • references/company-policies.md - 公司政策文档

最佳实践:

  • 如果文件较大(>10k 字),在 SKILL.md 中包含 grep 搜索模式
  • 避免重复:信息应仅存在于 SKILL.md 或参考文件中,而不是两者都有
  • 结构化较长的参考文件:在顶部包含目录,以便 Claude 在预览时可以看到完整范围

assets/(可选)

不打算加载到上下文中,而是在 Claude 产生的输出中使用的文件。

何时包含:

  • 当技能需要将在最终输出中使用的文件时
  • 模板、图像、图标、样板代码等

示例:

  • assets/logo.png - 品牌资产
  • assets/slides.pptx - PowerPoint 模板
  • assets/frontend-template/ - HTML/React 样板
  • assets/font.ttf - 字体文件

优点:

  • 将输出资源与文档分开
  • 使 Claude 能够使用文件而不将其加载到上下文中

不应包含的内容

技能应仅包含直接支持其功能的必要文件。不要创建多余的文档或辅助文件,包括:

  • ❌ README.md
  • ❌ INSTALLATION_GUIDE.md
  • ❌ QUICK_REFERENCE.md
  • ❌ CHANGELOG.md
  • ❌ 等等

技能应仅包含 AI 代理完成手头工作所需的信息。它不应包含关于创建过程的辅助上下文、设置和测试程序、面向用户的文档等。

渐进式披露模式示例

模式 1:带参考资料的高级指南

# PDF 处理

## 快速入门

使用 pdfplumber 提取文本:
[代码示例]

## 高级功能

- **表单填写**:完整指南请参见 [FORMS.md](references/FORMS.md)
- **API 参考**:所有方法请参见 [REFERENCE.md](references/REFERENCE.md)
- **示例**:常见模式请参见 [EXAMPLES.md](references/EXAMPLES.md)

Claude 仅在需要时加载 FORMS.mdREFERENCE.mdEXAMPLES.md

模式 2:领域特定组织

bigquery-skill/
├── SKILL.md(概述和导航)
└── references/
    ├── finance.md(收入、账单指标)
    ├── sales.md(机会、管道)
    ├── product.md(API 使用、功能)
    └── marketing.md(活动、归因)

当用户询问销售指标时,Claude 只读取 sales.md

模式 3:条件详情

# DOCX 处理

## 创建文档

使用 docx-js 创建新文档。请参见 [DOCX-JS.md](references/DOCX-JS.md)。

## 编辑文档

对于简单编辑,直接修改 XML。

**对于修订追踪**:请参见 [REDLINING.md](references/REDLINING.md)
**对于 OOXML 详情**:请参见 [OOXML.md](references/OOXML.md)

Claude 仅在用户需要这些功能时读取 REDLINING.mdOOXML.md

重要指南:

  • 避免深层嵌套引用 - 保持引用从 SKILL.md 起一层深度。所有参考文件应直接从 SKILL.md 链接
  • 结构化较长的参考文件 - 对于超过 100 行的文件,在顶部包含目录

最佳实践与设计原则

1. 内容组织原则

保持 SKILL.md 精简

  • 不超过 500 行
  • 优先使用示例而不是冗长的解释
  • 将详细信息移到 references/

避免重复

  • 信息应仅存在于 SKILL.md 或参考文件中,而不是两者都有
  • 仅在 SKILL.md 中保留基本的程序性指令和工作流程指导
  • 将详细的参考材料、模式和示例移至参考文件

使用渐进式披露

  • 元数据层:Always-On(约 100 token)
  • 指令层:On-Demand(1k-5k token)
  • 资源层:Context-Triggered(可变)

2. 写作风格

使用祈使句/不定式形式

# ✅ 好的写法
使用 pdfplumber 提取文本。
优先使用 UnoCSS 原子类。

# ❌ 避免的写法
你应该使用 pdfplumber 提取文本。
我们建议优先使用 UnoCSS 原子类。

3. 资源文件管理

Scripts 最佳实践

  • 必须通过实际运行来测试
  • 确保没有错误并且输出符合预期
  • 如果有许多类似的脚本,只需测试具有代表性的样本

References 最佳实践

  • 如果文件较大(>10k 字),在 SKILL.md 中包含 grep 搜索模式
  • 在顶部包含目录(对于超过 100 行的文件)
  • 避免深层嵌套引用(保持从 SKILL.md 起一层深度)

Assets 最佳实践

  • 仅包含在最终输出中使用的文件
  • 不要将资产加载到上下文中
  • 使用有意义的命名

4. 描述(Description)编写技巧

这是技能最重要的部分,因为这是 Claude 用来确定何时使用该技能的唯一字段。

好的描述应包含:

  1. 技能做什么:清晰描述技能的核心功能
  2. 何时使用:具体的触发场景和上下文
  3. 使用场景:列举主要的使用场景

示例对比

# ❌ 不好的描述(太简短,缺少触发信息)
description: PDF 处理技能

# ✅ 好的描述(包含何时使用信息)
description: 全面的 PDF 文档处理,支持文本提取、表格提取、页面旋转和合并。当 Claude 需要处理 PDF 文件时使用,用于:(1) 提取文本内容,(2) 提取表格数据,(3) 旋转页面,(4) 合并多个 PDF,或任何其他 PDF 操作任务

实战案例

案例 1:设计稿转换 Skill

这是一个实际项目中的 Skill,展示了完整的目录结构和内容组织。

目录结构

design-conversion/
├── SKILL.md
└── references/
    ├── rpx转换速查表.md
    └── 响应式单位使用指南.md

SKILL.md 结构

---
name: design-conversion
description: Figma 设计稿转换为 uni-app 代码的技能,包含图片资源下载、UnoCSS 原子类使用、rpx 单位转换等完整规范
tags:
  - figma
  - design-to-code
  - uniapp
  - unocss
  - rpx
---

正文结构:

  1. UnoCSS 优先使用规则(第一优先级)
  2. 图片资源下载规则
  3. 布局尺寸精确还原
  4. 颜色值精确匹配
  5. 文字样式完整复制
  6. ...(其他规则)
  7. 转换流程检查清单
  8. 相关文档链接

设计亮点

  1. 渐进式披露

    • 元数据:技能名称和描述(Always-On)
    • 指令层:核心转换规则(On-Demand)
    • 资源层:详细的转换速查表和指南(Context-Triggered)
  2. 避免重复

    • SKILL.md 中包含核心规则和检查清单
    • 详细的转换表和指南放在 references/
  3. 清晰的触发条件

    • Description 中明确说明:"Figma 设计稿转换为 uni-app 代码"
    • 包含具体的使用场景

案例 2:技能创建器 Skill

这是一个元技能(meta-skill),用于创建其他技能。

目录结构

skill-creator/
├── SKILL.md
├── LICENSE.txt
├── scripts/
│   ├── init_skill.py
│   ├── package_skill.py
│   └── quick_validate.py
└── references/
    ├── output-patterns.md
    └── workflows.md

设计亮点

  1. 包含脚本

    • init_skill.py:初始化新技能
    • package_skill.py:打包技能
    • quick_validate.py:快速验证
  2. 参考文档

    • output-patterns.md:输出格式模式
    • workflows.md:工作流程指南
  3. 渐进式披露

    • 核心创建流程在 SKILL.md
    • 详细的工作流程和输出模式在 references/

总结

关键要点

  1. Rules、Skills、MCP 三者职责分离

    • Rules:项目级别的静态规范
    • Skills:任务级别的动态能力
    • MCP:工具级别的连接协议
  2. 写好 Skill 的核心原则

    • 简洁是关键(质疑每条信息的必要性)
    • 设置适当的自由度(根据任务脆弱性)
    • 渐进式披露(三级加载系统)
  3. 标准目录结构

    • SKILL.md(必需):元数据 + 核心指令
    • scripts/(可选):可执行代码
    • references/(可选):参考文档
    • assets/(可选):输出资源
  4. 最佳实践

    • 保持 SKILL.md 精简(不超过 500 行)
    • 避免重复(信息只存在一处)
    • 使用渐进式披露(按需加载)
    • 优先使用示例而不是冗长解释

参考资料

前端远程组件方案设计

基于 Webpack 5 Module Federation 的微模块架构,实现组件级的运行时加载与独立部署。

1. 目标 (Goals)

实现一个高效、可扩展的远程组件架构,支持:

  • 运行时加载: 宿主应用在运行时从 CDN 加载组件,无需构建时静态导入。
  • 独立部署: 远程组件项目更新后,宿主应用无需重新打包即可生效,解耦开发周期。
  • 动态依赖共享: 自动处理 React、Mobx 等公共依赖,避免重复加载,保证单例运行。

2. 方案对比 (Solution Comparison)

为说明本方案(Webpack 5 Module Federation)选择的合理性,这里对常见几类“远程组件/远程模块”方案进行对比。

2.1 典型方案概览

方案 加载方式 依赖共享 隔离性 发布/回滚成本 典型适用场景
NPM 组件库 构建时安装、打包 无(各自打包进 Host) 高(按模块边界) 高(每次都要重新发版 + 重建 Host) 设计系统、稳定基础组件
CDN UMD 组件 + window.xxx 运行时 <script> 注入 手动处理(通常重复加载) 低(易污染全局) 中(可替换 CDN 版本,但接口变更难管控) 老项目、简单挂业务插件
iframe 微前端 运行时 <iframe> 实际无共享(强隔离) 很高(JS/CSS 全隔离) 中(独立发布,但集成体验差) 完整子系统嵌入、不同技术栈共存
应用级微前端 (qiankun) 运行时加载整个应用 依赖共享有限/需自研 中(约定式隔离) 中(子应用独立发布) 多路由/多页面级别拆分
自研 Runtime Loader 运行时按 URL 加载模块 可手动实现,但复杂 取决于实现 中高(协议稳定性要自控) 需支持多构建工具、多语言模块
Module Federation (本方案) 运行时按模块名加载 内置 shared 机制 中高(模块边界清晰) 低(Remote 独立发布) “组件级”远程加载,React 业务拆分

2.2 核心差异分析

  1. 与 NPM 组件库的对比:NPM 方案是构建时绑定,无法做到真正的运行时热更新;而 MF 支持 Remote 独立部署,Host 无需重建即可生效。
  2. 与 iframe / 应用级微前端的对比:iframe 适合整应用隔离,但对“单个组件”级别的复用非常笨重;MF 就像 import 本地组件一样自然。
  3. 与 CDN UMD / 自研 Loader 的对比:MF 将“容器管理、版本协商、依赖单例化”框架化为 Webpack 特性,相比自研方案大幅降低了工程落地成本。

2.3 本方案定位

综合来看,本方案主要针对 组件级 / 业务模块级 的远程加载,旨在平衡“业务复杂度、迭代效率、工程成本”。


3. 技术选型 (Tech Stack)

  • 构建工具: Webpack 5
  • 核心插件: ModuleFederationPlugin
  • 状态管理: Mobx 6+ (支持跨容器状态共享)
  • UI 框架: React 18
  • 样式方案: CSS Modules + Tailwind CSS

4. 核心架构 (Core Architecture)

4.1 架构模型

采用 Provider-Consumer (提供者-消费者) 模型:

graph LR
    subgraph "Remote (Provider)"
        A[RemoteTable] --> B[remoteEntry.js]
        C[RemoteForm] --> B
    end
    
    subgraph "Host (Consumer)"
        D[Dashboard] -.->|动态拉取| B
        E[Settings] -.->|动态拉取| B
    end
    
    subgraph "Shared Scope"
        F[React/ReactDOM]
        G[Mobx/Lite]
    end
    
    B --> F
    B --> G
    D --> F
    D --> G
  • Remote (Provider): apps/RemoteComponent
    • 负责定义、打包并导出原子化或业务级组件。
    • 生成 remoteEntry.js 作为动态加载的清单文件。
  • Host (Consumer): 其他业务项目 (如 apps/admin)
    • 配置远程节点,通过异步方式消费远程组件。

4.2 样式隔离策略 (Styling)

  • CSS Modules (推荐): 默认方案。通过类名 Hash 确保样式仅在组件内生效。
  • Tailwind Prefix: 若使用 Tailwind,必须配置全局前缀 (如 rc-) 以防覆盖宿主样式。
  • Shadow DOM (可选): 针对极其复杂的样式隔离需求,可考虑将远程组件挂载在 Shadow Root 下。

5. 目录结构 (Directory Structure)

apps/RemoteComponent/
├── src/
│   ├── components/         # 远程暴露的组件集合
│   │   ├── RemoteTable/    # 业务组件示例
│   │   └── index.ts        # 统一导出入口
│   ├── bootstrap.tsx       # 异步启动逻辑
│   ├── App.tsx             # 本地开发预览环境
│   └── index.tsx           # Webpack 入口
├── webpack.config.js       # MF 提供方核心配置
└── tailwind.config.js      # 样式前缀配置

6. 关键实现 (Implementation)

6.1 Remote 端配置 (webpack.config.js)

const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
  // ... 其他配置
  plugins: [
    new ModuleFederationPlugin({
      name: 'remote_app',
      filename: 'remoteEntry.js',
      exposes: {
        './RemoteTable': './src/components/RemoteTable/index.tsx',
      },
      shared: {
        react: { singleton: true, requiredVersion: '^18.0.0' },
        'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
        mobx: { singleton: true }
      },
    }),
  ],
};

6.2 Host 端配置 (webpack.config.js)

const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
  // ... 其他配置
  plugins: [
    new ModuleFederationPlugin({
      name: 'host_app',
      remotes: {
        // 声明远程容器的名称及入口地址
        remote_app: 'remote_app@http://localhost:3001/remoteEntry.js',
      },
      shared: {
        react: { singleton: true, requiredVersion: '^18.0.0' },
        'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
        mobx: { singleton: true }
      },
    }),
  ],
};

6.3 Host 端消费

import React, { Suspense } from 'react';
import ErrorBoundary from './components/ErrorBoundary';

// 静态导入:依赖 webpack 配置中的 remotes
const RemoteTable = React.lazy(() => import('remote_app/RemoteTable'));

const App = () => (
  <ErrorBoundary fallback={<p>组件加载失败</p>}>
    <Suspense fallback={<div>Loading Remote Component...</div>}>
      <RemoteTable title="远程数据表" />
    </Suspense>
  </ErrorBoundary>
);

6.4 运行时动态加载远程容器 (Runtime Dynamic Loading)

在某些场景下(如环境 URL 动态化、A/B 测试或基于插件系统的应用),我们无法在构建时硬编码远程地址。此时可以利用 Webpack 的运行时 API 动态加载:

6.4.1 实现工具函数

/**
 * 动态加载远程组件脚本并初始化容器
 * @param url remoteEntry.js 的完整地址
 * @param scope 远程应用的 name (对应 MF 配置中的 name)
 * @param module 导出的路径 (如 './RemoteTable')
 */
async function loadRemoteModule(url: string, scope: string, module: string) {
  // 1. 动态注入 script 标签
  await new Promise((resolve, reject) => {
    const script = document.createElement('script');
    script.src = url;
    script.type = 'text/javascript';
    script.async = true;
    script.onload = resolve;
    script.onerror = reject;
    document.head.appendChild(script);
  });

  // 2. 初始化共享作用域 (Share Scope)
  await __webpack_init_sharing__('default');

  // 3. 获取远程容器
  const container = window[scope];
  
  // 4. 初始化容器
  await container.init(__webpack_share_scopes__.default);

  // 5. 获取模块工厂并执行
  const factory = await container.get(module);
  const Module = factory();
  return Module;
}

6.4.2 业务中使用

const DynamicRemoteTable = React.lazy(() => 
  loadRemoteModule(
    'http://cdn.example.com/remoteEntry.js', 
    'remote_app', 
    './RemoteTable'
  )
);

7. 核心机制深入 (Deep Dive)

7.1 ModuleFederationPlugin 核心配置详解

ModuleFederationPlugin 是实现模块联邦的核心配置工具。以下是其关键参数的详细介绍:

配置项 类型 作用描述 关键注意点
name string 定义当前容器的全局唯一名称。 必填。会作为全局变量挂载到 window 上,Host 端通过此名称引用。
filename string 生成的远程入口清单文件名。 通常设为 remoteEntry.js。Host 应用在运行时首先加载此文件以获取模块元数据。
remotes object 声明当前应用需要消费的远程应用。 键为本地引用别名,值为远程容器名和地址(如 app: 'app@http://.../remoteEntry.js')。
exposes object 导出当前应用的内部模块供他人使用。 键为对外暴露的路径,值为内部模块的实际相对路径。
shared object 定义多个应用间共享的公共依赖库。 包含 singleton(单例)、requiredVersion(版本要求)等高级配置,防止 React 等库出现多实例。

7.2 核心概念

模块联邦(Module Federation)是 Webpack 5 提供的一种 跨构建共享模块 的机制,核心目标是:

  • 不同项目(构建产物)之间可以像本地模块一样互相 import
  • 每个项目仍能独立构建、独立部署
  • 自动处理依赖共享与版本协商,避免重复打包 React 等公共库

几个关键角色:

  • Host(消费方):使用远程模块的应用,如 apps/admin
  • Remote(提供方):暴露模块给其他应用使用,如 apps/RemoteComponent
  • Container Runtime(容器运行时):Webpack 在浏览器中注入的运行时代码,用于:
    • 注册和查找远程模块
    • 拉取 remoteEntry.js
    • 处理 shared 依赖的版本与实例复用

7.3 加载流程(以 RemoteComponent 为例)

以 Host 端 import('remote_app/RemoteTable') 为例,其内部大致流程:

  1. 构建阶段

    • Remote 端通过 ModuleFederationPlugin 配置:
      • name: 'remote_app'
      • exposes: { './RemoteTable': './src/components/RemoteTable/index.tsx' }
    • Webpack 根据该配置生成:
      • remoteEntry.js:一份模块暴露清单 + 加载入口
      • 对应的业务 chunk:真正的组件代码
  2. 运行时注册

    • Remote 应用加载时,remoteEntry.js 被浏览器执行:
      • 调用 Webpack runtime,将 remote_app 注册为一个 远程容器
      • 同时注册它暴露出来的模块映射关系(如 './RemoteTable' -> 具体 chunk
  3. Host 端发起远程加载

    • 当 Host 代码执行到:import('remote_app/RemoteTable') 时:
      1. Webpack runtime 检查是否已加载 remote_app 容器
      2. 如未加载,则动态插入 <script src="remoteEntry.js"> 拉取容器
      3. 等待 remoteEntry.js 执行完成,拿到远程容器对象
      4. 请求容器中的 './RemoteTable' 模块工厂函数
      5. 若该模块还依赖其它 chunk(按需加载),继续动态加载对应 JS 文件
  4. 模块执行与使用

    • 得到模块工厂函数后,Webpack runtime 会:
      • 先完成 shared 依赖初始化(见 6.3)
      • 执行模块工厂函数,得到真正的 React 组件
    • 在代码层面,Host 侧就像拿到了一个本地的 RemoteTable 组件一样使用。

7.3.1 加载流程伪代码

> 伪代码仅描述关键流程,与实际 Webpack 运行时代码略有差异,用于帮助理解 Host0 到拿到远程组件的完整加载阶段。

// ======================
// Host 侧:业务触发入口
// ======================
function renderHostPage() {
  // React.lazy 触发异步加载远程组件
  const RemoteTable = React.lazy(() =>
    // 实际上会走到下面的 loadRemoteModule 流程
    loadRemoteModule({
      remoteName: 'remote_app',
      exposedModule: './RemoteTable',
    })
  );

  return (
    <ErrorBoundary>
      <Suspense fallback={<Spinner />}>
        <RemoteTable title="远程数据表" />
      </Suspense>
    </ErrorBoundary>
  );
}

// ===========================================
// 抽象的“加载远程模块”伪代码(Host 运行时角度)
// ===========================================
async function loadRemoteModule(options: {
  remoteName: string;     // 容器名,如 'remote_app'
  exposedModule: string;  // 模块路径,如 './RemoteTable'
}) {
  const { remoteName, exposedModule } = options;

  // 1. 确保 remoteEntry 已经加载(容器已注册)
  // ----------------------------------------------------------------
  //  1.1 从 Host 的 MF 配置中拿到 remote 的 URL
  const remoteUrl = getRemoteEntryUrlFromConfig(remoteName);
  //  1.2 如果容器还不存在,则动态插入 <script> 加载 remoteEntry.js
  if (!window[remoteName]) {
    await loadRemoteEntryScript(remoteUrl);  // 等价于动态创建 <script> 标签并等待 onload
  }

  // 2. 初始化 shared 作用域(全局只需做一次,但伪代码里简化为每次确保完成) 
  // ----------------------------------------------------------------
  //  调用 Webpack runtime 的共享初始化逻辑
  await __webpack_init_sharing__('default'); // 初始化默认 shared scope

  // 3. 获取远程容器并完成协商
  // ----------------------------------------------------------------
  const container = window[remoteName];      // e.g. window.remote_app

  //  3.1 将 Host 的 shared scope 传给 Remote,完成版本协商
  //      - Remote 会把自己声明的 shared 依赖注入进来
  //      - Host / Remote 共同决定实际使用哪个版本(如 React 单例)
  await container.init(__webpack_share_scopes__.default);

  // 4. 从容器里获取远程模块
  // ----------------------------------------------------------------
  //  4.1 container.get 返回一个 Promise,resolve 为模块工厂函数
  const moduleFactory = await container.get(exposedModule); // e.g. './RemoteTable'

  //  4.2 执行工厂函数,得到真正的模块导出对象
  const moduleExports = moduleFactory();

  //  4.3 按需返回默认导出或具名导出
  return moduleExports.default || moduleExports;
}

// ======================
// 工具:加载 remoteEntry.js
// ======================
function loadRemoteEntryScript(url: string): Promise<void> {
  return new Promise((resolve, reject) => {
    const existingScript = document.querySelector<HTMLScriptElement>(
      `script[data-remote-entry="${url}"]`
    );
    if (existingScript) {
      // 已经在加载或加载完成,直接复用
      existingScript.addEventListener('load', () => resolve());
      existingScript.addEventListener('error', () =>
        reject(new Error('remoteEntry load error'))
      );
      return;
    }

    const script = document.createElement('script');
    script.src = url;
    script.type = 'text/javascript';
    script.async = true;
    script.dataset.remoteEntry = url;

    script.onload = () => resolve();
    script.onerror = () =>
      reject(new Error(`Failed to load remoteEntry: ${url}`));

    document.head.appendChild(script);
  });
}

7.4 依赖共享机制(Shared Scope)深入

ModuleFederationPlugin 通过一套复杂的 共享作用域 (Share Scope) 机制来管理跨应用的依赖:

7.4.1 构建阶段:元数据生成

在 Webpack 构建时,插件会根据 shared 配置生成特殊的模块:

  • ProvideSharedModule: 如果应用是共享库的提供者,Webpack 会将该库标记,并准备好供他人使用的元数据。
  • ConsumeSharedModule: 如果应用是消费者,Webpack 会生成一段异步加载逻辑,优先从全局共享作用域查找。
  • 版本清单: 插件会记录每个共享库的 versioneager 属性,并将这些元数据写入 remoteEntry.js

7.4.2 运行阶段:初始化与协商 (init)

这是共享依赖生效的关键步骤:

  1. Scope 注册: 当 Host 加载 Remote 时,首先调用 Remote 容器的 init(shareScope) 方法。
  2. 合并作用域: Remote 会将自己的共享依赖清单合并到全局 shareScope 对象中(通常是 default 作用域)。
  3. 版本协商 (Resolution):
    • Webpack 运行时会检查 shareScope 中所有可用的版本。
    • 最高版本优先: 如果没有特殊限制,选择版本号最高的实例。
    • 语义版本匹配: 根据 requiredVersion (如 ^18.0.0) 过滤。
  4. 加载模块: 一旦确定了版本,Host 和 Remote 都会通过 get() 方法从共享作用域获取对应的模块工厂函数。

7.4.3 单例模式 (Singleton) 逻辑

当配置了 singleton: true 时:

  • Webpack 保证在整个应用生命周期中,某个库(如 React)只会被初始化一次
  • 如果 Host 已经加载了 React,Remote 即使版本略高也会被迫使用 Host 的实例(除非版本冲突严重导致 strictVersion 报错)。
  • 这解决了 React Hook 必须依赖同一个单例 dispatcher 的核心问题。

7.4.4 最终效果

  • Host 与多个 Remote 应用共享同一份 React/Mobx 实例:
    • 避免包体积膨胀。
    • 保证状态管理库(如 Mobx)在不同应用间行为一致。
    • 避免 React 多实例导致的 Context/Mobx Provider 失效等问题。

7.5 适用场景与限制

适用场景

  • 多团队、多仓库协作的前端架构(微前端)
  • 远程组件库/业务模块的独立发布与灰度
  • 希望在不重建 Host 的前提下,快速更新某些业务模块

注意点 / 限制

  • Host 和 Remote 在运行时必须能访问彼此的构建产物(一般通过 CDN)
  • 构建工具需统一使用 Webpack 5,或保证 Module Federation 兼容
  • shared 依赖版本要尽量收敛,避免多版本共存导致行为不一致
  • 运行时加载意味着首屏可能增加一次 HTTP 请求,需要结合缓存与预加载优化

8. 构建与部署流程 (Build & Deploy)

下面以本方案中的 Remote (apps/RemoteComponent) 与 Host(如 apps/admin)为例,说明从配置到产物输出的整体打包流程。

8.1 配置阶段

  1. Remote 侧配置 ModuleFederationPlugin

    • 关键字段:
      • name: 远程容器名称(如 remote_app
      • filename: 容器入口文件名(如 remoteEntry.js
      • exposes: 暴露的模块映射(如 './RemoteTable' -> './src/components/RemoteTable/index.tsx'
      • shared: 共享依赖(react / react-dom / mobx 等)
  2. Host 侧配置 ModuleFederationPlugin

    • 关键字段:
      • remotes: 声明远程容器及其地址
        • 例如:remote_app: 'remote_app@https://cdn.xxx.com/remoteEntry.js'
      • shared: 与 Remote 尽量保持一致的共享依赖配置
  3. Webpack 解析与依赖图构建

    • Webpack 在构建时会:
      • 扫描入口和所有依赖模块,构建依赖图(包括 import('remote_app/RemoteTable') 等动态导入语句)。
      • 根据 ModuleFederationPlugin 的配置,标记哪些模块属于:
        • 本地 chunk
        • 暴露给外部的 remote 模块
        • 需要加入 shared 作用域的依赖

8.2 Remote 端打包流程

apps/RemoteComponent 为例,一个典型的构建过程大致如下:

  1. 入口解析

    • 常规 Webpack 入口(如 src/App.tsx)用于本地预览开发。
    • 同时,ModuleFederationPlugin 会在内部注入一个“容器入口”,用于生成 remoteEntry.js
  2. 业务代码分块(chunk)

    • Webpack 根据 import 关系进行代码分割:
      • 主业务 chunk(如远程组件相关代码)
      • 公共依赖 chunk(如 lodash 等未被 shared 的库)
      • 运行时代码(Webpack runtime)
  3. 生成 Remote 容器 (remoteEntry.js)

    • ModuleFederationPlugin 生成一个特殊的入口文件 remoteEntry.js,其主要职责是:
      • 在浏览器中注册一个名为 remote_app 的容器对象。
      • 声明容器中可被远程访问的模块清单:
        • 例如 './RemoteTable' -> 对应的业务 chunk id/文件名
      • 初始化 shared 作用域的提供方/消费方逻辑(如如何提供 react、如何从 Host 复用 react)。

8.3 容器对象实例 (Window Container Object)

  • remoteEntry.js 加载完成后,会在全局 window 上注册对应的容器对象(如 window.remote_app)。该对象符合模块联邦协议,其结构如下:

// 浏览器控制台打印 window.remote_app 的示意 { /**

  • get 方法:用于异步获取容器中暴露的模块工厂函数
  • @param {string} module - 模块路径,如 "./RemoteTable"
  • @returns {Promise} 返回一个 Promise,resolve 后得到模块工厂 */ get: async (module) => { // 内部逻辑:根据 moduleMap 查找并加载对应的 chunk // 返回一个 factory 函数,执行 factory() 即可得到导出的组件 },

/**

  • init 方法:用于初始化容器,并与其共享作用域进行协商
  • @param {Object} shareScope - 宿主应用传来的共享作用域对象 */ init: (shareScope) => { // 内部逻辑: // 1. 将当前容器的 shared 依赖版本信息存入 shareScope // 2. 根据 singleton 等规则确定使用本地版本还是宿主提供的版本 } }

8.4 Shared 依赖处理

  • shared 声明的依赖(如 react):
    • Remote 不会把其完整代码再次打包进自身业务 chunk 中。
    • 而是生成一些“共享初始化”代码,用来:
      • 在需要时向 shared scope 注册本地版本(当 Remote 首先被加载时)。
      • 或者从已有 shared scope 中获取单例(当 Host 先于 Remote 被加载时)。

8.5 产物输出

  • Remote 构建产物通常包括:
    • remoteEntry.js:远程容器入口 + 模块清单。
    • 若干业务 chunk:如 RemoteTable 组件相关 bundle。
    • 样式文件:如 remote.[contenthash].css(含 CSS Modules/Tailwind)。
    • 静态资源:带 hash 的图片、字体等。

8.6 打包产物示例 (Artifacts Demo)

为了更直观地理解构建结果,以下是执行 npm run builddist 目录中关键文件的结构及内容示意:

8.6.1 目录结构示意

dist/ ├── remoteEntry.js # 远程入口清单(核心元数据) ├── src_components_RemoteTable_index_tsx.js # 业务组件代码块 (Chunk) ├── 789.js # 共享依赖或公共逻辑块 ├── remoteTable.css # 组件样式(含 Hash 类名) └── index.html # 本地预览入口

8.6.2 remoteEntry.js 内容片段 (逻辑简化)

这是 Host 应用首先加载的文件,它定义了如何查找模块:

var remote_app;
remote_app = (() => {
  "use strict";
  var __webpack_modules__ = ({
    // 包含模块定义的映射表
  });
  
  // 核心:暴露的模块映射
  var moduleMap = {
    "./RemoteTable": () => {
      return __webpack_require__.e("src_components_RemoteTable_index_tsx").then(() => () => __webpack_require__("./src/components/RemoteTable/index.tsx"));
    }
  };

  // 核心:初始化共享作用域的方法
  var init = (shareScope) => {
    // 将 Remote 的共享依赖版本与 Host 提供的 shareScope 进行协商
    // 例如:检查 React 版本,决定使用本地的还是 Host 的
  };

  // 核心:获取模块的方法
  var get = (module) => {
    return moduleMap[module]();
  };

  return { get, init };
})();

8.6.3 业务 Chunk 内容片段 (src_components_RemoteTable_index_tsx.js)

包含真正的业务逻辑,它是按需加载的: (self["webpackChunkremote_component"] = self["webpackChunkremote_component"] || []).push([["src_components_RemoteTable_index_tsx"], { "./src/components/RemoteTable/index.tsx": ((__unused_webpack_module, webpack_exports, webpack_require) => { // 真正的 React 组件代码 const RemoteTable = ({ title }) => { const styles = webpack_require("./src/components/RemoteTable/index.module.less"); return React.createElement("div", { className: styles.container }, title); }; webpack_exports["default"] = RemoteTable; }) }]);


8.7 Host 端打包流程

apps/admin 为例,Host 侧构建过程在普通 Webpack 构建基础上增加了对远程模块的处理。

  1. 解析 remotes 配置

    • ModuleFederationPlugin 读取 Host 的 remotes 字段,例如:
      • remote_app: 'remote_app@https://cdn.xxx.com/remoteEntry.js'
    • 在打包产物中,Webpack runtime 会内置一段逻辑,用于:
      • 在运行时按需加载 https://cdn.xxx.com/remoteEntry.js
      • 把远程容器对象挂载到内部的容器管理系统上。
  2. 处理远程 import 语句

    • 对类似 import('remote_app/RemoteTable') 的语句,Webpack 不会在构建时去解析实际模块内容,而是:
      • 生成一段调用“联邦运行时”的代码:
        • 运行时根据 remote_app 容器名与 './RemoteTable' 来查找模块工厂。
        • 并在需要的时候触发对 remoteEntry.js 和后续 chunk 的网络请求。
    • 从 Host 打包结果来看,这些远程模块不会出现在本地 bundle 中,从而减小 Host 的体积。
  3. shared 依赖收敛

    • Host 通常是 shared 作用域的“优先提供方”:
      • Host 的 reactreact-dommobx 等被注册到 shared scope。
      • Remote 加载时优先使用 Host 提供的版本(在配置 singleton: true 时尤为重要)。
    • Webpack 在 Host 侧会对这些依赖进行一次正常打包(因为 Host 需要自己用到),但不会重复打入 Remote 的 bundle。
  4. Host 产物输出

    • 与普通应用类似:
      • 应用主入口 bundle / 异步 chunk
      • shared 依赖打包结果
      • Webpack runtime(内置对 Module Federation 的扩展)

8.8 开发环境 vs 生产环境

  1. 开发环境

    • 通常通过 webpack-dev-server 或类似服务启动 Host 和 Remote:
      • 如 Host 在 http://localhost:3000
      • Remote 在 http://localhost:3001
    • remotes 中配置为本地地址:
      • remote_app: 'remote_app@http://localhost:3001/remoteEntry.js'
    • 特性:
      • 支持 HMR / 热更新(变更 Remote 组件后,刷新 Host 页面即可看到最新效果)。
      • 构建模式为 development,无压缩,便于调试。
  2. 生产环境

    • Remote 产物(含 remoteEntry.js 和静态资源)通常发布到 CDN:
      • https://cdn.xxx.com/remote/remoteEntry.js
    • Host 在构建时把 remotes 配置为线上地址或使用环境变量/运行时注入:
      • 保持 remoteEntry.js路径稳定(或有明确的版本策略)。
    • 使用 contenthash 等避免缓存问题,同时配合:
      • HTTP 缓存头(Cache-Control
      • 预加载策略(<link rel="preload"> / <link rel="prefetch">)优化远程组件加载性能。

8.9 发布与更新策略

  1. Remote 独立发布

    • 当只更新远程组件时:
      • 重新构建 apps/RemoteComponent,生成新的 remoteEntry.js 和业务 chunk。
      • 发布到 CDN(版本号/路径需与 Host 配置兼容)。
    • Host 无需重新打包,只要在运行时能访问最新的 remoteEntry.js 即可使用新版本组件。
  2. 兼容性与版本管理

    • 保持 exposes 的模块名和导出接口尽量稳定:
      • 新增组件:可以新增 expose 项(例如 ./RemoteForm)。
      • 修改组件:尽量保持现有 props 结构向后兼容。
    • shared 依赖的版本需严格控制:
      • Host 与 Remote 的 react / react-dom / mobx 版本尽量一致。
      • 如需大版本升级,建议:
        • 先协调 Host 升级 shared 依赖。
        • 或使用多容器、多版本 remoteEntry 的方式做平滑迁移。

通过上述流程,模块联邦在构建阶段完成 容器生成、依赖共享、远程入口注册,在运行时实现 按需加载远程组件 + 共享依赖单例化,从而支撑本方案的「运行时加载、独立部署、依赖共享」目标。


9. 隔离与安全 (Isolation)

在当前方案中,JS 隔离主要依赖 Webpack 模块系统 + Module Federation 的容器隔离

9.1 JS 隔离

  1. 模块级作用域隔离

    • 远程组件在 Remote 端被打包为普通 ES 模块 / Webpack 模块:
      • 变量、函数都封装在模块作用域内,不会挂载到 window
      • Host 端仅通过 import('remote_app/RemoteTable') 拿到模块导出(React 组件本身),不会直接访问 Remote 内部实现细节。
    • 只要远程组件避免主动写入 window.xxx 等全局变量,就不会污染宿主应用。
  2. 容器级隔离(Module Federation Container)

    • 每个 Remote(如 remote_app)在浏览器中是一个独立的 容器
      • 容器只暴露在 exposes 中声明的模块(如 ./RemoteTable)。
      • 其他业务代码、工具函数等都被封装在 Remote 自身的 bundle 内,对 Host 不可见。
    • Host 和 Remote 之间唯一打通的是:
      • exposes 暴露的组件/模块接口。
      • shared 中声明的共享依赖(如 reactmobx)。
  3. 依赖共享白名单

    • 通过 shared 显式声明共享依赖:
      • reactreact-dommobx 等公共库会在 Host/Remote 间复用。
      • 业务层的工具库、状态等不会自动共享,避免不同应用之间产生隐式耦合。
    • 这样既保证了公共依赖单例化,又保证业务代码边界清晰。
  4. 通信方式约束

    • 推荐所有交互通过:
      • 组件 props
      • 上层注入的 Context / Mobx store
    • 不建议远程组件直接访问宿主的全局变量或单例对象,从组织方式上进一步保证“逻辑隔离”。

如需更强的运行时隔离(如避免任何全局污染、CSS/JS 互不干扰),可以在更高层使用 iframe / Shadow DOM 等方案,但不在本设计的基础范畴内。


9.2 CSS 隔离

CSS 隔离主要通过 CSS Modules / Tailwind 前缀 实现,结合打包产物的文件 hash,避免样式冲突。

9.2.1 使用 CSS Modules(推荐)

  • Remote 端组件样式写成 *.module.css / *.module.less 等:
    • Webpack 配置 modules 后,会将类名编译为带 hash 的唯一名字,例如:

      • 源码:.container { ... }
      • 编译后:.RemoteTable_container__3kS4a { ... }
    • 组件内部使用: import styles from './index.module.less';

      export const RemoteTable = () => (

      ...
      );
  • 隔离效果:
    • 不会生成全局 .container 之类的类名,宿主应用的 .container 不会与之冲突。
    • Remote 之间、Remote 与 Host 之间,即使类名语义相同(如 .button),最终编译出来的真实类名也不同。

9.2.2 使用 Tailwind 时的隔离

若远程组件内部使用 Tailwind,可通过以下方式隔离:

  1. 前缀(prefix)

    • 在 Tailwind 配置中为 Remote 单独设置: // tailwind.config.js module.exports = { prefix: 'rc-', // 远程组件用 rc- 开头的类名 // ... };
    • 生成的类名会变成:
      • rc-flex, rc-m-2, rc-text-sm ...
    • 避免与 Host 侧的 Tailwind 类(无前缀或其他前缀)发生冲突。
  2. important / 注入范围控制

    • 如有需要可以开启: module.exports = { important: true, // ... };
    • 或者在样式注入时限制作用范围,确保 Tailwind 的 preflight / reset 等不会覆盖宿主的全局样式。
  3. 打包边界

    • Remote 的 Tailwind CSS 作为其自身 bundle 的一部分输出:
      • 仅在远程组件被加载时动态注入对应样式。
      • Host 不会因为自身使用 Tailwind 就自动“接管” Remote 的样式,反之亦然。

9.2.3 静态资源与命名空间

  • 通过 Webpack 对 CSS 内使用的图片、字体等资源进行 hash 处理:
    • background: url('./icon.png')url('.../icon.abc123.png')
    • Host 与 Remote 的资源路径互不影响,不会出现同名文件被覆盖的情况。
  • 样式文件本身也通常带有内容 hash:
    • remote.[contenthash].css,避免缓存污染和冲突。

综上:

  • JS 隔离:依托 Module Federation 的容器 + 模块作用域 + 显式 shared 配置,将 Remote 的业务逻辑与 Host 隔离,仅暴露必要组件接口与公共依赖。
  • CSS 隔离:通过 CSS Modules / Tailwind 前缀与独立打包,使远程组件的样式局部化、不侵入宿主全局样式,从而在运行时做到“加载即用、互不干扰”。

10. 最佳实践与优化 (Best Practices)

10.1 异常处理与降级 (Fallback)

远程组件加载受网络影响,必须提供健壮的异常处理:

  1. 加载状态: 使用 Suspense 提供 Loading 占位。
  2. 容错处理: 使用 ErrorBoundary 包裹组件,当远程资源 404 或运行时崩溃时显示降级 UI。
  3. 版本锁定: 生产环境建议通过版本号目录存放 remoteEntry.js,避免发布时的缓存闪动。

10.2 性能优化

  1. 预加载 (Preload): 在 Host 端关键路径通过 <link rel="preload"> 预拉取 remoteEntry.js
  2. 公共依赖外置: 确保共享依赖真正命中单例,减少网络传输。
  3. 按需加载: 组件内部的非核心逻辑使用动态 import() 进一步拆分。

10.3 调试技巧

  • 版本对齐: 确保 Host 和 Remote 的 shared 配置完全一致,尤其是 singletoneager 属性。
  • Source Map: 开启生产环境的 source-map 关联,方便在 Host 端调试 Remote 源码。

11. 潜在挑战与局限性 (Challenges & Limitations)

虽然基于 Webpack 5 Module Federation 的方案在灵活性和解耦方面表现出色,但在实际大规模应用中仍需注意以下潜在问题:

11.1 运行时风险

  1. 网络稳定性依赖: 远程组件在运行时加载,如果 CDN 节点故障或用户网络环境差,会导致 remoteEntry.js 加载失败,直接影响页面关键功能。必须强制要求配套 ErrorBoundary 和重试机制。
  2. 版本冲突与依赖地狱:
    • 虽然有 shared 机制,但如果不同 Remote 要求的 shared 依赖版本跨度过大(例如一个要 React 16,一个要 React 18),可能会导致加载多份 React 实例或直接运行时报错。
    • singleton: true 虽然能保证单例,但在版本不匹配时可能导致某些组件运行在不兼容的库版本上,引发难以调试的 Bug。

11.2 类型安全与开发体验

  1. TypeScript 类型丢失: 默认情况下,Host 应用无法直接获取 Remote 应用导出的组件类型定义。通常需要通过 @module-federation/typescript 插件、手动同步 .d.ts 文件或建立私有 NPM 类型包来解决。
  2. 本地开发复杂度: 调试 Host 引用 Remote 的逻辑时,往往需要同时启动两个 Dev Server。如果涉及多个远程容器,本地内存占用和配置管理成本会上升。

11.3 性能挑战

  1. 瀑布式加载 (Waterfall): Host 加载完后才去拉取 remoteEntry.js,解析完清单后再去拉取组件 chunk。如果不做预加载 (Preload),会导致明显的首屏白屏或组件闪烁。
  2. 打包碎片化: 随着 exposes 模块增多,会产生大量微小的 JS 文件,增加 HTTP 请求数量。

11.4 维护与规范

  1. 调试困难: 当线上出现 Bug 时,很难一眼判断问题出在 Host 还是某个 Remote 的特定版本。需要完善的 Source Map 映射和日志追踪系统。
  2. 样式污染风险: 尽管有 CSS Modules 和 Tailwind 前缀,但对于某些未经过处理的第三方库样式(如全局引入的 antd.css),仍可能发生 Host 与 Remote 之间的样式覆盖问题。
  3. 构建工具绑定: 强依赖 Webpack 5。如果未来项目想迁移到 Vite 或其他构建工具,需要引入额外的适配层(如 vite-plugin-federation),并处理底层运行时实现的细微差异。

每日一题-将数组分成最小总代价的子数组 I🟢

给你一个长度为 n 的整数数组 nums 。

一个数组的 代价 是它的 第一个 元素。比方说,[1,2,3] 的代价是 1 ,[3,4,1] 的代价是 3 。

你需要将 nums 分成 3 个 连续且没有交集 的子数组。

请你返回这些子数组最小 代价 总和 。

 

示例 1:

输入:nums = [1,2,3,12]
输出:6
解释:最佳分割成 3 个子数组的方案是:[1] ,[2] 和 [3,12] ,总代价为 1 + 2 + 3 = 6 。
其他得到 3 个子数组的方案是:
- [1] ,[2,3] 和 [12] ,总代价是 1 + 2 + 12 = 15 。
- [1,2] ,[3] 和 [12] ,总代价是 1 + 3 + 12 = 16 。

示例 2:

输入:nums = [5,4,3]
输出:12
解释:最佳分割成 3 个子数组的方案是:[5] ,[4] 和 [3] ,总代价为 5 + 4 + 3 = 12 。
12 是所有分割方案里的最小总代价。

示例 3:

输入:nums = [10,3,1,1]
输出:12
解释:最佳分割成 3 个子数组的方案是:[10,3] ,[1] 和 [1] ,总代价为 10 + 1 + 1 = 12 。
12 是所有分割方案里的最小总代价。

 

提示:

  • 3 <= n <= 50
  • 1 <= nums[i] <= 50

从 O(nlogn) 到 O(n)(Python/Java/C++/Go)

题意:把数组分成三段,每一段取第一个数再求和,问和的最小值是多少。

第一段的第一个数是确定的,即 $\textit{nums}[0]$。

如果知道了第二段的第一个数的位置,和第三段的第一个数的位置,那么这个划分方案也就确定了。

这两个下标可以在 $[1,n-1]$ 中随意取。

所以问题变成求下标在 $[1,n-1]$ 中的两个最小的数。

视频讲解

方法一:直接排序

###py

class Solution:
    def minimumCost(self, nums: List[int]) -> int:
        return nums[0] + sum(sorted(nums[1:])[:2])

###java

class Solution {
    public int minimumCost(int[] nums) {
        Arrays.sort(nums, 1, nums.length);
        return nums[0] + nums[1] + nums[2];
    }
}

###cpp

class Solution {
public:
    int minimumCost(vector<int>& nums) {
        sort(nums.begin() + 1, nums.end());
        return reduce(nums.begin(), nums.begin() + 3, 0);
    }
};

###go

func minimumCost(nums []int) int {
slices.Sort(nums[1:])
return nums[0] + nums[1] + nums[2]
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n\log n)$,其中 $n$ 为 $\textit{nums}$ 的长度。
  • 空间复杂度:$\mathcal{O}(1)$。忽略排序的栈开销。

方法二:维护最小值和次小值

###py

class Solution:
    def minimumCost(self, nums: List[int]) -> int:
        return nums[0] + sum(nsmallest(2, nums[1:]))

###java

class Solution {
    public int minimumCost(int[] nums) {
        int fi = Integer.MAX_VALUE, se = Integer.MAX_VALUE;
        for (int i = 1; i < nums.length; i++) {
            int x = nums[i];
            if (x < fi) {
                se = fi;
                fi = x;
            } else if (x < se) {
                se = x;
            }
        }
        return nums[0] + fi + se;
    }
}

###cpp

class Solution {
public:
    int minimumCost(vector<int> &nums) {
        int fi = INT_MAX, se = INT_MAX;
        for (int i = 1; i < nums.size(); i++) {
            int x = nums[i];
            if (x < fi) {
                se = fi;
                fi = x;
            } else if (x < se) {
                se = x;
            }
        }
        return nums[0] + fi + se;
    }
};

###go

func minimumCost(nums []int) int {
fi, se := math.MaxInt, math.MaxInt
for _, x := range nums[1:] {
if x < fi {
se = fi
fi = x
} else if x < se {
se = x
}
}
return nums[0] + fi + se
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n)$,其中 $n$ 为 $\textit{nums}$ 的长度。
  • 空间复杂度:$\mathcal{O}(1)$。

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
  7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、二叉树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA/一般树)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

欢迎关注 B站@灵茶山艾府

首元素+后续两个最小元素,C0ms/python一行,双百

Problem: 100181. 将数组分成最小总代价的子数组 I

[TOC]

思路

就是计算首个元素、后续两个最小元素,三个元素之和。

Code

执行用时分布0ms击败100.00%;消耗内存分布5.47MB击败100.00%

###C

int minimumCost(int* nums, int numsSize) {
    int a1 = nums[1], a2 = 50;
    for (int i = 2; i < numsSize; ++ i) 
        if      (nums[i] <= a1) a2 = a1, a1 = nums[i];
        else if (nums[i] <  a2) a2 = nums[i];
    return nums[0] + a1 + a2;
}

###Python3

class Solution:
    def minimumCost(self, nums: List[int]) -> int:
        return nums[0] + sum(sorted(nums[1:])[:2])

您若还有不同方法,欢迎贴在评论区,一起交流探讨! ^_^

↓ 点个赞,点收藏,留个言,再划走,感谢您支持作者! ^_^

JSyncQueue——一个开箱即用的鸿蒙异步任务同步队列

零、JSyncQueue

JSyncQueue 是一个开箱即用的鸿蒙异步任务同步队列。

项目地址:github.com/zincPower/J…

一、JSyncQueue 有什么作用

在鸿蒙应用开发中,有时需要让多个异步任务按顺序执行,例如状态的转换处理,如果不加控制,会因为执行顺序混乱而产生一些莫名其妙的问题。 所以 JSyncQueue 提供了一个简洁的解决方案:

  • 保证顺序执行:所有任务严格按照入队顺序执行,即使任务内部有异步操作也能保证顺序
  • 两种执行模式:支持 "立即执行" 和 "延时执行" 两种模式,可以满足不同场景需求
  • 两种任务类型:支持向同步队列添加 "Message 类型任务" 和 "Runnable 类型任务"
  • 任务取消和管理:可随时取消指定任务或清空整个队列
  • 获取任务结果:通过任务的 getResult() 获取执行结果

项目架构如下图所示:

二、如何安装 JSyncQueue

第一种方式: 在需要使用 JSyncQueue 的模块中运行以下命令

ohpm install jsyncqueue

第二种方式: 在需要使用 JSyncQueue 的模块 oh-package.json5 中添加以下依赖

{
  "name": "sample",
  "version": "1.0.0",
  "description": "Please describe the basic information.",
  "main": "",
  "author": "",
  "license": "",
  "dependencies": {
    "jsyncqueue": "1.0.0" // 添加这一行,请根据需要修改版本号
  }
}

三、JSyncQueue API 介绍

3-1、JSyncQueue 类

构造函数

constructor(queueName: string)

创建一个同步队列实例。

  • queueName: 队列名称,用于标识和调试

方法

方法 参数 返回值 说明
post(runnable) runnable: (taskId: number) => Promise<Any> Task 立即执行闭包
postDelay(runnable, delay) runnable: (taskId: number) => Promise<Any>, delay: number Task 延时 delay 毫秒执行闭包
sendMessage(message) message: Message Task 立即发送消息
sendMessageDelay(message, delay) message: Message, delay: number Task 延时 delay 毫秒发送消息
cancel(taskId) taskId: number void 取消指定任务
clear() - void 清空队列中所有等待的任务
dumpInfo() - string 获取队列调试信息
onHandleMessage(message, taskId) message: Message, taskId: number Promise<Any> 消息处理方法,子类可重写

属性

属性 类型 说明
queueName string 队列名称(只读)
length number 当前队列中的任务数量(只读)

3-2、Message 接口

interface Message {
  what: string   // 消息类型
  data: Any      // 消息数据
}

3-3、Task 接口

所有添加的任务,包括“Message 类型任务”和“Runnable 类型任务”,均会返回该类型实例,通过该实例可以“取消任务”、“获取任务结果”、“任务 Id”。

interface Task {
  cancel(): void                  // 取消任务
  getResult(): Promise<Any>       // 获取任务结果
  getTaskId(): number             // 获取任务 ID
}

3-4、异常类型

JSyncQueueCancelException

当任务被取消时,会抛出该类型的异常。

interface JSyncQueueCancelException {
  message: string
}

JSyncQueueException

当 JSyncQueue 内部发生异常时,会抛出该类型的异常。

值得注意:使用者编写的逻辑中抛出的异常会原封不动的抛到 Task.getResult().catch 中,而不是以 JSyncQueueException 类型抛出

interface JSyncQueueException {
  message: string
}

四、如何使用 JSyncQueue

4-1、使用 JSyncQueue 创建同步队列

如果你处理的场景均是简单的一次性任务,那么直接使用 JSyncQueue 创建一个同步队列,并压入 Runnable 闭包即可。

以下代码展示的逻辑细节:

  • 代码中使用了 delay 函数模拟了两次耗时操作,并且返回结果
  • 外部通过 Task 类型实例接收返回结果,并且打印
  • 在第四次循环(即 i 为 3)的时候,会模拟抛出异常,异常内容会原封不动的抛到 catch

值得注意:

  • 立即执行任务会严格按入队顺序执行
  • 任务结果的接收处理(即对 Task.getResult() 的处理)和 JSyncQueue 对任务的处理是不保证顺序的,因为 Task.getResult() 的处理已不在队列范围内
immediatelyJSyncQueue: JSyncQueue = new JSyncQueue("ImmediatelyJSyncQueue")
for (let i = 0; i < 5; ++i) {
  const task = this.immediatelyJSyncQueue.post(async () => {
    const delayTime1 = Math.round(Math.random() * 500)
    Log.i(TAG, `【添加5个Runnable】执行逻辑 i=${i} 第一段 将会模拟耗时=${delayTime1}`)
    await this.delay(delayTime1)

    if (i == 3) {
      throw { message: "模拟异常" } as Error
    }

    const delayTime2 = Math.round(Math.random() * 500)
    Log.i(TAG, `【添加5个Runnable】执行逻辑 i=${i} 第二段 将会模拟耗时=${delayTime2}`)
    await this.delay(delayTime2)

    return `jiangpengyong-添加5个Runnable ${i}`
  })
  task.getResult()
    .then((result) => {
      Log.i(TAG, `【添加5个Runnable-执行成功】i=${i} result=${result}`)
    })
    .catch((e: Error) => {
      Log.e(TAG, `【添加5个Runnable-执行异常】i=${i} e=${JSON.stringify(e)}`)
    })
    .finally(() => {
      Log.i(TAG, `【添加5个Runnable-执行结束】i=${i}`)
    })
}

// ========================================= 输出日志 =========================================
// 【添加5个Runnable】执行逻辑 i=0 第一段 将会模拟耗时=239
// 【添加5个Runnable】执行逻辑 i=0 第二段 将会模拟耗时=315
// 【添加5个Runnable】执行逻辑 i=1 第一段 将会模拟耗时=379
// 【添加5个Runnable-执行成功】i=0 result=jiangpengyong-添加5个Runnable 0
// 【添加5个Runnable-执行结束】i=0
// 【添加5个Runnable】执行逻辑 i=1 第二段 将会模拟耗时=391
// 【添加5个Runnable】执行逻辑 i=2 第一段 将会模拟耗时=499
// 【添加5个Runnable-执行成功】i=1 result=jiangpengyong-添加5个Runnable 1
// 【添加5个Runnable-执行结束】i=1
// 【添加5个Runnable】执行逻辑 i=2 第二段 将会模拟耗时=395
// 【添加5个Runnable】执行逻辑 i=3 第一段 将会模拟耗时=478
// 【添加5个Runnable-执行成功】i=2 result=jiangpengyong-添加5个Runnable 2
// 【添加5个Runnable-执行结束】i=2
// 【添加5个Runnable】执行逻辑 i=4 第一段 将会模拟耗时=166
// 【添加5个Runnable-执行异常】i=3 e={"message":"模拟异常"}
// 【添加5个Runnable-执行结束】i=3
// 【添加5个Runnable】执行逻辑 i=4 第二段 将会模拟耗时=33
// 【添加5个Runnable-执行成功】i=4 result=jiangpengyong-添加5个Runnable 4
// 【添加5个Runnable-执行结束】i=4

取消同步任务

通过返回的 Task 类型实例调用 cancel 方法可以进行取消任务。

下面的代码会取消第四次任务,所以在日志中会看到对应的取消异常,并且不会执行该任务。

let task: Task | undefined
for (let i = 0; i < 5; ++i) {
  const tempTask = this.immediatelyJSyncQueue.post(async () => {
    const delayTime1 = Math.round(Math.random() * 500)
    Log.i(TAG, `【移除Runnable】执行逻辑 i=${i} 第一段 将会模拟耗时=${delayTime1}`)
    await this.delay(delayTime1)

    const delayTime2 = Math.round(Math.random() * 500)
    Log.i(TAG, `【移除Runnable】执行逻辑 i=${i} 第二段 将会模拟耗时=${delayTime2}`)
    await this.delay(delayTime2)

    if (i == 2) {
      throw { message: "模拟异常" } as Error
    }
    return `jiangpengyong-移除Runnable ${i}`
  })
  tempTask.getResult().then((result) => {
    Log.i(TAG, `【移除Runnable】执行成功 i=${i} result=${result}`)
  }).catch((e: Any) => {
    Log.e(TAG, `【移除Runnable】执行异常 i=${i} e=${JSON.stringify(e)}`)
  }).finally(() => {
    Log.i(TAG, `【移除Runnable】执行完成 i=${i}`)
  })
  if (i == 3) {
    task = tempTask
  }
}
Log.i(TAG, `【移除Runnable】取消任务 task=${JSON.stringify(task)}`)
task?.cancel()

// ========================================= 输出日志 =========================================
// 【移除Runnable】执行逻辑 i=0 第一段 将会模拟耗时=263
// 【移除Runnable】取消任务 task={"taskId":13,"queue":{},"promise":{}}
// 【移除Runnable】执行异常 i=3 e={"message":"Cancel task by cancel function."}
// 【移除Runnable】执行完成 i=3
// 【移除Runnable】执行逻辑 i=0 第二段 将会模拟耗时=474
// 【移除Runnable】执行逻辑 i=1 第一段 将会模拟耗时=318
// 【移除Runnable】执行成功 i=0 result=jiangpengyong-移除Runnable 0
// 【移除Runnable】执行完成 i=0
// 【移除Runnable】执行逻辑 i=1 第二段 将会模拟耗时=6
// 【移除Runnable】执行逻辑 i=2 第一段 将会模拟耗时=406
// 【移除Runnable】执行成功 i=1 result=jiangpengyong-移除Runnable 1
// 【移除Runnable】执行完成 i=1
// 【移除Runnable】执行逻辑 i=2 第二段 将会模拟耗时=212
// 【移除Runnable】执行逻辑 i=4 第一段 将会模拟耗时=226
// 【移除Runnable】执行异常 i=2 e={"message":"模拟异常"}
// 【移除Runnable】执行完成 i=2
// 【移除Runnable】执行逻辑 i=4 第二段 将会模拟耗时=439
// 【移除Runnable】执行成功 i=4 result=jiangpengyong-移除Runnable 4
// 【移除Runnable】执行完成 i=4

延时执行 Runnable 类型任务

添加延时任务只需改用 postDelay 方法并传入延时参数

  • 下面代码记录了添加任务到真正执行的延时,通过 realDelay 参数可以查看
  • 使用了 delay 函数模拟了两次耗时操作,并模拟返回了处理结果
  • 第四次任务抛出了异常,异常消息会原封不动的在 catch 的日志展示
  • 因为延时任务的添加是按索引进行累加的,所以添加顺序其实并没变化,从最后的日志输出可以看到保证了执行顺序
for (let i = 0; i < 5; ++i) {
  const startTime = systemDateTime.getTime(false)
  const delayTime = i * 100
  const task = this.delayJSyncQueue.postDelay(async () => {
    const endTime = systemDateTime.getTime(false)
    const realDelay = endTime - startTime
    const delayTime1 = Math.round(Math.random() * 500)
    Log.i(TAG, `【添加5个Runnable】执行逻辑 delay=${delayTime} realDelay=${realDelay} i=${i} 第一段 将会模拟耗时=${delayTime1}`)
    await this.delay(delayTime1)

    const delayTime2 = Math.round(Math.random() * 500)
    Log.i(TAG, `【添加5个Runnable】执行逻辑 i=${i} 第二段 将会模拟耗时=${delayTime2}`)
    await this.delay(delayTime2)

    if (i == 3) {
      throw { message: "模拟异常" } as Error
    }
    return `jiangpengyong-添加5个Runnable ${i}`
  }, delayTime)
  task.getResult()
    .then((result) => {
      Log.i(TAG, `【添加5个Runnable】执行成功 i=${i} result=${result}`)
    })
    .catch((e: Error) => {
      Log.e(TAG, `【添加5个Runnable】执行异常 i=${i} e=${JSON.stringify(e)}`)
    })
    .finally(() => {
      Log.i(TAG, `【添加5个Runnable】执行结束 i=${i}`)
    })
}

// ========================================= 输出日志 =========================================
// 【添加5个Runnable】执行逻辑 delay=0 realDelay=1 i=0 第一段 将会模拟耗时=473
// 【添加5个Runnable】执行逻辑 i=0 第二段 将会模拟耗时=410
// 【添加5个Runnable】执行逻辑 delay=100 realDelay=888 i=1 第一段 将会模拟耗时=178
// 【添加5个Runnable】执行成功 i=0 result=jiangpengyong-添加5个Runnable 0
// 【添加5个Runnable】执行结束 i=0
// 【添加5个Runnable】执行逻辑 i=1 第二段 将会模拟耗时=204
// 【添加5个Runnable】执行逻辑 delay=200 realDelay=1272 i=2 第一段 将会模拟耗时=410
// 【添加5个Runnable】执行成功 i=1 result=jiangpengyong-添加5个Runnable 1
// 【添加5个Runnable】执行结束 i=1
// 【添加5个Runnable】执行逻辑 i=2 第二段 将会模拟耗时=36
// 【添加5个Runnable】执行逻辑 delay=300 realDelay=1721 i=3 第一段 将会模拟耗时=475
// 【添加5个Runnable】执行成功 i=2 result=jiangpengyong-添加5个Runnable 2
// 【添加5个Runnable】执行结束 i=2
// 【添加5个Runnable】执行逻辑 i=3 第二段 将会模拟耗时=483
// 【添加5个Runnable】执行逻辑 delay=400 realDelay=2686 i=4 第一段 将会模拟耗时=9
// 【添加5个Runnable】执行异常 i=3 e={"message":"模拟异常"}
// 【添加5个Runnable】执行结束 i=3
// 【添加5个Runnable】执行逻辑 i=4 第二段 将会模拟耗时=395
// 【添加5个Runnable】执行成功 i=4 result=jiangpengyong-添加5个Runnable 4
// 【添加5个Runnable】执行结束 i=4

取消延时任务

延时任务的取消操作和立即执行的取消操作是完全一样的,都是通过返回的 Task 实例调用 cancel 方法,这里就不再赘述。

4-2、继承 JSyncQueue 创建同步队列

如果你的同步逻辑需要集中管理或进行复用,可以考虑 Message 类型任务。

处理 Message 类型任务,需要继承 JSyncQueue 实现 onHandleMessage 方法,在该方法中会按入队顺序接收到 Message

  • 通过 Message.what 属性区分不同类别消息实现不同处理逻辑
  • 通过 Message.data 属性可以获取外部传入的数据,数据类型是 Any 可以是任意类型数据,使用者自行转换为真实类型进行逻辑处理

具体操作如下:

  • 定义一个 ImmediatelyQueue 类继承 JSyncQueue ,实现 onHandleMessage 方法
  • 创建一个 ImmediatelyQueue 实例,并通过这个实例进行发送 Message 消息,同步队列会按入队顺序一个个进行分发给该实例的 onHandleMessage 方法进行处理
// 自定义 JSyncQueue
export class ImmediatelyQueue extends JSyncQueue {
  private count = 0

  async onHandleMessage(message: Message): Promise<Any> {
    switch (message.what) {
      case "say_hello": {
        const name = message.data["name"]
        this.count += 1

        const delayTime1 = Math.round(Math.random() * 500)
        Log.i("ImmediatelyQueue", `【say_hello】执行逻辑 第一段 将会模拟耗时=${delayTime1}`)
        await this.delay(delayTime1)

        const delayTime2 = Math.round(Math.random() * 500)
        Log.i("ImmediatelyQueue", `【say_hello】执行逻辑 第二段 将会模拟耗时=${delayTime2}`)
        await this.delay(delayTime2)

        if (this.count % 10 == 5) {
          throw { message: "模拟异常" }
        }
        return `你好,${name}。这是第${this.count}次打招呼。`
      }
      // ... 其他 what 处理逻辑
    }
    return undefined
  }

  private async delay(ms: number) {
    return new Promise<Any>(resolve => setTimeout(resolve, ms))
  }
}

// 使用逻辑
immediatelyQueue: JSyncQueue = new ImmediatelyQueue("ImmediatelyQueue")
for (let i = 0; i < 5; ++i) {
  const tempTask = this.immediatelyQueue.sendMessage({
    what: `say_hello`,
    data: { name: '江澎涌', age: 20 + i },
  })
  tempTask.getResult()
    .then((result) => {
      Log.i(TAG, `【添加5个Message】执行成功 i=${i} result=${result}`)
    })
    .catch((e: Error) => {
      Log.e(TAG, `【添加5个Message】执行异常 i=${i} e=${JSON.stringify(e)}`)
    })
    .finally(() => {
      Log.i(TAG, `【添加5个Message】执行结束i=${i}`)
    })
}
// ========================================= 输出日志 =========================================
// onHandleMessage message={"what":"say_hello","data":{"name":"江澎涌","age":20}}
// 【say_hello】执行逻辑 第一段 将会模拟耗时=92
// 【say_hello】执行逻辑 第二段 将会模拟耗时=143
// onHandleMessage message={"what":"say_hello","data":{"name":"江澎涌","age":21}}
// 【say_hello】执行逻辑 第一段 将会模拟耗时=276
// 【say_hello】执行逻辑 第二段 将会模拟耗时=377
// onHandleMessage message={"what":"say_hello","data":{"name":"江澎涌","age":22}}
// 【say_hello】执行逻辑 第一段 将会模拟耗时=120
// 【say_hello】执行逻辑 第二段 将会模拟耗时=223
// onHandleMessage message={"what":"say_hello","data":{"name":"江澎涌","age":23}}
// 【say_hello】执行逻辑 第一段 将会模拟耗时=424
// 【say_hello】执行逻辑 第二段 将会模拟耗时=444
// onHandleMessage message={"what":"say_hello","data":{"name":"江澎涌","age":24}}
// 【say_hello】执行逻辑 第一段 将会模拟耗时=181
// 【say_hello】执行逻辑 第二段 将会模拟耗时=402

移除 Message 消息

使用 sendMessage 方法压入 “Message 类型任务” 同样会返回 Task 类型实例,调用该实例的 cancel 方法就可以取消该任务。

下列代码会取消第二个任务,所以不会看到 "age":11 的消息。

let task: Task | undefined
for (let i = 0; i < 5; ++i) {
  const tempTask = this.immediatelyQueue.sendMessage({
    what: `remove_message`,
    data: { name: 'jiang peng yong', age: 10 + i },
  })
  tempTask.getResult().then((result) => {
    Log.i(TAG, `【移除Message】执行成功 i=${i} result=${result}`)
  }).catch((e: Any) => {
    Log.e(TAG, `【移除Message】执行异常 i=${i} e=${JSON.stringify(e)}`)
  }).finally(() => {
    Log.i(TAG, `【移除Message】执行完成 i=${i}`)
  })
  if (i == 1) {
    task = tempTask
  }
}
Log.i(TAG, `【移除Message】取消任务 task=${JSON.stringify(task)}`)
task?.cancel()
// ========================================= 输出日志 =========================================
// onHandleMessage message={"what":"remove_message","data":{"name":"jiang peng yong","age":10}}
// 【remove_message】执行逻辑 第一段 将会模拟耗时=497
// 【remove_message】执行逻辑 第二段 将会模拟耗时=397
// onHandleMessage message={"what":"remove_message","data":{"name":"jiang peng yong","age":12}}
// 【remove_message】执行逻辑 第一段 将会模拟耗时=162
// 【remove_message】执行逻辑 第二段 将会模拟耗时=283
// onHandleMessage message={"what":"remove_message","data":{"name":"jiang peng yong","age":13}}
// 【remove_message】执行逻辑 第一段 将会模拟耗时=193
// 【remove_message】执行逻辑 第二段 将会模拟耗时=93
// onHandleMessage message={"what":"remove_message","data":{"name":"jiang peng yong","age":14}}
// 【remove_message】执行逻辑 第一段 将会模拟耗时=359
// 【remove_message】执行逻辑 第二段 将会模拟耗时=145

延时执行 Message 类型任务

  • 定义一个 DelayQueue 类继承 JSyncQueue ,主要重写 onHandleMessage 方法,用于接收处理 Message
  • 创建 DelayQueue 实例,通过这个实例调用 sendMessageDelay 方法即可达到相应的延时效果
export class DelayQueue extends JSyncQueue {
  private count = 0

  async onHandleMessage(message: Message): Promise<Any> {
    Log.i("DelayQueue", `onHandleMessage message=${JSON.stringify(message)}`)
    switch (message.what) {
      case "say_hello": {
        const name = message.data["name"]
        this.count += 1

        const delayTime1 = Math.round(Math.random() * 500)
        Log.i("DelayQueue", `【say_hello】执行逻辑 第一段 将会模拟耗时=${delayTime1}`)
        await this.delay(delayTime1)

        const delayTime2 = Math.round(Math.random() * 500)
        Log.i("DelayQueue", `【say_hello】执行逻辑 第二段 将会模拟耗时=${delayTime2}`)
        await this.delay(delayTime2)

        if (this.count % 10 == 5) {
          throw { message: "模拟异常" }
        }
        return `Hello,${name}. This is the ${this.count} th greeting.`
      }
    }
    return undefined
  }

  private async delay(ms: number) {
    return new Promise<Any>(resolve => setTimeout(resolve, ms))
  }
}

delayQueue: JSyncQueue = new DelayQueue("DelayQueue")
for (let i = 0; i < 5; ++i) {
  const delayTime = i * 100
  const task = this.delayQueue.sendMessageDelay({
    what: `say_hello`,
    data: { name: '江澎涌', age: 20 + i },
  }, delayTime)
  task.getResult()
    .then((result) => {
      Log.i(TAG, `【添加5个Message】执行成功 i=${i} result=${result}`)
    })
    .catch((e: Error) => {
      Log.e(TAG, `【添加5个Message】执行异常 i=${i} e=${JSON.stringify(e)}`)
    })
    .finally(() => {
      Log.i(TAG, `【添加5个Message】执行结束i=${i}`)
    })
}
// ========================================= 输出日志 ========================================= 
// onHandleMessage message={"what":"say_hello","data":{"name":"江澎涌","age":20}}
// 【say_hello】执行逻辑 第一段 将会模拟耗时=356
// 【say_hello】执行逻辑 第二段 将会模拟耗时=302
// onHandleMessage message={"what":"say_hello","data":{"name":"江澎涌","age":21}}
// 【say_hello】执行逻辑 第一段 将会模拟耗时=67
// 【say_hello】执行逻辑 第二段 将会模拟耗时=344
// onHandleMessage message={"what":"say_hello","data":{"name":"江澎涌","age":22}}
// 【say_hello】执行逻辑 第一段 将会模拟耗时=339
// 【say_hello】执行逻辑 第二段 将会模拟耗时=384
// onHandleMessage message={"what":"say_hello","data":{"name":"江澎涌","age":23}}
// 【say_hello】执行逻辑 第一段 将会模拟耗时=442
// 【say_hello】执行逻辑 第二段 将会模拟耗时=392
// onHandleMessage message={"what":"say_hello","data":{"name":"江澎涌","age":24}}
// 【say_hello】执行逻辑 第一段 将会模拟耗时=443
// 【say_hello】执行逻辑 第二段 将会模拟耗时=199

取消延时的 Message 类型任务

延时任务的取消操作和立即执行的取消操作是完全一样的,都是通过返回的 Task 实例调用 cancel 方法,这里就不再赘述。

同一队列压入 Message 类型任务和 Runnable 类型任务

JSyncQueue 同一实例压入 MessageRunnable 两种类型任务是支持的,会按照压入顺序进行执行和分发。

// ImmediatelyQueue 源码就不再展示,需要可以移步 Github 上查阅
immediatelyQueue: JSyncQueue = new ImmediatelyQueue("ImmediatelyQueue")
for (let i = 0; i < 10; ++i) {
  if (i % 2 == 0) {
    this.immediatelyQueue.post(async () => {
      const delayTime1 = Math.round(Math.random() * 500)
      Log.i(TAG, `【添加10个Message和Runnable】执行逻辑 i=${i} 第一段 将会模拟耗时=${delayTime1}`)
      await this.delay(delayTime1)

      const delayTime2 = Math.round(Math.random() * 500)
      Log.i(TAG, `【添加10个Message和Runnable】执行逻辑 i=${i} 第二段 将会模拟耗时=${delayTime2}`)
      await this.delay(delayTime2)

      if (i / 2 == 3) {
        throw { message: "模拟异常" } as Error
      }
      return `小朋友-添加10个Message和Runnable ${i}`
    })
  } else {
    this.immediatelyQueue.sendMessage({
      what: `say_hello`,
      data: { name: '小朋友', age: i },
    })
  }
}
// ========================================= 输出日志 ========================================= 
// 【添加10个Message和Runnable】执行逻辑 i=0 第一段 将会模拟耗时=416
// 【添加10个Message和Runnable】执行逻辑 i=0 第二段 将会模拟耗时=41
// onHandleMessage message={"what":"say_hello","data":{"name":"小朋友","age":1}}
// 【say_hello】执行逻辑 第一段 将会模拟耗时=184
// 【say_hello】执行逻辑 第二段 将会模拟耗时=63
// 【添加10个Message和Runnable】执行逻辑 i=2 第一段 将会模拟耗时=451
// 【添加10个Message和Runnable】执行逻辑 i=2 第二段 将会模拟耗时=223
// onHandleMessage message={"what":"say_hello","data":{"name":"小朋友","age":3}}
// 【say_hello】执行逻辑 第一段 将会模拟耗时=99
// 【say_hello】执行逻辑 第二段 将会模拟耗时=27
// 【添加10个Message和Runnable】执行逻辑 i=4 第一段 将会模拟耗时=273
// 【添加10个Message和Runnable】执行逻辑 i=4 第二段 将会模拟耗时=193
// onHandleMessage message={"what":"say_hello","data":{"name":"小朋友","age":5}}
// 【say_hello】执行逻辑 第一段 将会模拟耗时=20
// 【say_hello】执行逻辑 第二段 将会模拟耗时=231
// 【添加10个Message和Runnable】执行逻辑 i=6 第一段 将会模拟耗时=46
// 【添加10个Message和Runnable】执行逻辑 i=6 第二段 将会模拟耗时=198
// onHandleMessage message={"what":"say_hello","data":{"name":"小朋友","age":7}}
// 【say_hello】执行逻辑 第一段 将会模拟耗时=179
// 【say_hello】执行逻辑 第二段 将会模拟耗时=0
// 【添加10个Message和Runnable】执行逻辑 i=8 第一段 将会模拟耗时=131
// 【添加10个Message和Runnable】执行逻辑 i=8 第二段 将会模拟耗时=401
// onHandleMessage message={"what":"say_hello","data":{"name":"小朋友","age":9}}
// 【say_hello】执行逻辑 第一段 将会模拟耗时=452
// 【say_hello】执行逻辑 第二段 将会模拟耗时=40

4-3、取消队列中所有任务

JSyncQueue 实例调用 clear 方法,就会把队列中等待执行的任务,包括延时执行和立即执行的任务,全都取消。同时会抛出 JSyncQueueCancelException 类型异常。

for (let i = 0; i < 5; ++i) {
  const task = this.immediatelyQueue.post(async () => {
    const delayTime1 = Math.round(Math.random() * 500)
    Log.i(TAG, `【清空队列】执行逻辑 i=${i} 第一段 将会模拟耗时=${delayTime1}`)
    await this.delay(delayTime1)

    const delayTime2 = Math.round(Math.random() * 500)
    Log.i(TAG, `【清空队列】执行逻辑 i=${i} 第二段 将会模拟耗时=${delayTime2}`)
    await this.delay(delayTime2)

    return `小朋友-清空队列 ${i}`
  })
  task.getResult()
    .then((result) => {
      Log.i(TAG, `【清空队列】执行成功 i=${i} result=${result}`)
    })
    .catch((e: Error) => {
      Log.e(TAG, `【清空队列】执行异常 i=${i} e=${JSON.stringify(e)}`)
    })
    .finally(() => {
      Log.i(TAG, `【清空队列】执行结束 i=${i}`)
    })
}
this.immediatelyQueue.clear()
// ========================================= 输出日志 ========================================= 
// 【清空队列】执行逻辑 i=0 第一段 将会模拟耗时=14
// 【清空队列】执行异常 i=1 e={"message":"Cancel task by clear function."}
// 【清空队列】执行异常 i=2 e={"message":"Cancel task by clear function."}
// 【清空队列】执行异常 i=3 e={"message":"Cancel task by clear function."}
// 【清空队列】执行异常 i=4 e={"message":"Cancel task by clear function."}
// 【清空队列】执行结束 i=1
// 【清空队列】执行结束 i=2
// 【清空队列】执行结束 i=3
// 【清空队列】执行结束 i=4
// 【清空队列】执行逻辑 i=0 第二段 将会模拟耗时=125
// 【清空队列】执行成功 i=0 result=小朋友-清空队列 0
// 【清空队列】执行结束 i=0

五、作者博客

掘金:juejin.im/user/5c3033…

csdn:blog.csdn.net/weixin_3762…

公众号:微信搜索 "江澎涌"

Vue-Data 属性避坑指南

前言

在 Vue 开发中,我们经常会遇到“明明修改了数据,视图却不更新”的尴尬场景。这通常与 Vue 的初始化顺序及响应式实现原理有关。本文将从 Data 属性的本质出发,解析响应式“丢失”的根本原因及解决方案。

一、 组件中的 Data 为什么必须是函数?

在 Vue 2 中,根实例的 data 可以是对象,但组件中的 data 必须是函数

核心原因:数据隔离

  • 对象形式:JavaScript 中的对象是引用类型。如果 data 是对象,所有组件实例将共享同一个内存地址。修改实例 A 的数据,实例 B 也会跟着变。
  • 函数形式:当 data 是一个函数时,每次创建新实例,Vue 都会调用该函数,返回一个全新的数据对象拷贝。这保证了每个组件实例数据的独立性。

二、 Props 与 Data 的优先级之争

在组件初始化时,Vue 会按照特定的顺序处理选项。

初始化顺序

PropsMethodsDataComputedWatch

因为 Props 最先被初始化,所以我们可以在 data 中直接引用 props 传来的值

// Vue 3 + TS 示例
const props = defineProps<{ initialCount: number }>();
const count = ref(props.initialCount); // 合法,因为 props 优先初始化

三、 Vue2动态添加新属性的“失效”困局

1. 故障场景

vue2中当我们直接给对象添加一个原本不存在的属性时,视图不会产生任何变化。

<p v-for="(value,key)in item" :key="key">
    {{ value }}
</p>
<button@click="addProperty">动态添加新属性</button>

const app = new Vue({
  el: '#app',
  data: {
    item: {
      oldProperty: 'l日属性'
    }
  },
  methods: {
    addProperty() {
      this.items.newProperty = '新属性'; // 为items添加新属性
      console.log(this.items); // 输出带有newProperty的items
    }
  }
})

2. 原因剖析

  • Vue 2 局限性:使用 Object.defineProperty 实现响应式。它只能劫持对象已有的属性。对于后来新增的属性,Vue 无法感知其 getter/setter,因此无法触发视图更新。
  • Vue 3 的进化:改用 Proxy 代理整个对象。Proxy 可以拦截到属性的新增与删除,因此 Vue 3 不再有这个问题。

四、 解决方案(Vue 2 必备技巧)

如果你仍在使用 Vue 2,可以通过以下三种方式解决:

1. 推荐方案:Vue.set / this.$set

这是最正统的方法,它会手动将新属性转为响应式,并触发依赖更新。

语法: this.$set(target, propertyName/index, value)

  • target:data中要修改的对象或者数组
  • propertyName/index:要添加或修改的属性名称(对于对象)或索引(对于数组)
  • value:要设置的值
addProperty() {
   this.$set(this.item, 'newProperty', '新属性'); 
}

2. 对象整体替换:Object.assign

通过创建一个包含新属性的新对象,并将这个新对象赋值给原有对象,触发 Vue 对原对象引用的变更感知。

addProperty() {
   this.item = Object.assign({}, this.item, { newProperty: '新属性' });
   // 或者使用展开运算符
   this.item = { ...this.item, newProperty: '新属性' };
}

3. 暴力方案:$forceUpdate

迫使 Vue 重新渲染组件。

  • 注意:这只是“治标”。虽然视图刷新了,但该属性依然不是响应式的。后续再次修改 newProperty 时,视图依然不会动。

五、 Vue 3 + TS 最佳实践

在 Vue 3 中,借助 TypeScript 的类型定义,我们可以规避大部分因“动态添加”导致的逻辑混乱。

<script setup lang="ts">
import { reactive } from 'vue';

// 定义接口,提前声明可选属性
interface Item {
  oldProperty: string;
  newProperty?: string; // 声明可选属性
}

const item = reactive<Item>({
  oldProperty: '旧属性'
});

const addProperty = () => {
  // Vue 3 Proxy 自动处理响应式,无需 $set
  item.newProperty = '新属性'; 
};
</script>

FossFLOW:开源等距图表工具,为技术文档注入立体活力!

文章简介:FossFLOW是一款创新的开源等距图表工具,专为技术文档设计。它通过立体视角将复杂的系统架构转化为直观的3D图表,支持拖放式操作和离线使用,让技术图表变得生动易懂。无需注册,数据安全存储在本地,并提供JSON导入导出功能。无论是Docker快速部署还是在线体验,FossFLOW都能为架构图、流程图注入立体活力,是提升技术文档表现力的得力助手。

你是否曾经为了绘制清晰的技术架构图或系统流程图而烦恼?是否觉得传统的平面图表难以表达复杂的层次关系?今天,我要向大家介绍一款令人惊艳的开源工具——FossFLOW,它能让你的技术图表瞬间变得立体、生动!

🌟 什么是FossFLOW?

FossFLOW 是一款功能强大的、开源的渐进式 Web 应用(PWA),专为创建精美的等距图表而设计。它基于 React 和 Isoflow(现已 fork 并以 fossflow 名称发布到 NPM)库构建,完全在浏览器中运行,并支持离线使用,让你随时随地都能创作出专业级的技术图表!

github地址:github.com/stan-smith/…

在线地址:stan-smith.github.io/FossFLOW/

该项目目前在github上已有17k ⭐️star

✨ 主要特性

🎨 立体图表,视觉升级

  • • 创建令人惊叹的3D风格技术图表
  • • 等距视角让复杂的系统架构一目了然
  • • 拖放式操作,简单直观

🔒 隐私优先,安全可靠

  • • 所有数据都存储在您的浏览器中
  • • 无需注册,无需上传
  • • 完全控制你的数据

🔄 导入导出,轻松分享

  • • JSON格式导入导出
  • • 快速分享你的设计
  • • 完整备份功能

🚀 快速上手

🐳Docker部署

创建docker-compose.yml文件,内容如下:

services:
  fossflow:
    image: stnsmith/fossflow:latest
    container_name: fossflow
    ports:
      - "5010:80"
    volumes:
      # 如果要禁用服务端存储,可以注释掉这行
      - ./diagrams:/data/diagrams
    environment:
      - TZ=Asia/Shanghai
      # 如果要启用服务端存储,注释掉下面这行
      # - ENABLE_SERVER_STORAGE="false"
    restart: unless-stopped

在docker-compose.yml 同级命令下使用以下命令启动

docker-compose up -d

到此,我们就部署完了,在浏览器中输入地址就可以访问了

🌐在线体验

直接访问:stan-smith.github.io/FossFLOW/

📱本地启动

# 克隆仓库
git clone https://github.com/stan-smith/FossFLOW
cd FossFLOW

# 安装依赖
npm install

# 启动开发服务器
npm start

🛠️ 使用指南

📈1. 创建图表

  • • 点击右上角"+"按钮打开组件库
  • • 从左侧拖放组件到画布
  • • 或右键网格选择"Add node"

🧩2. 连接组件

  • • 使用连接器显示组件关系
  • • 智能对齐,保持图表整洁
  • • 多层连接,表达复杂关系

✏️3. 自定义样式

  • • 更改颜色、标签和属性
  • • 调整位置和大小
  • • 添加说明文字

🎨4. 导航操作

  • • 鼠标滚轮放大缩小
  • • 点击拖动平移画布
  • • Ctrl+Z撤销,Ctrl+Y重做

🏗️ 技术栈

  • • React - 现代化的UI框架
  • • TypeScript - 类型安全的开发体验
  • • Isoflow - 强大的等距图表引擎
  • • PWA - 离线优先的Web应用架构

🚨缺点与不足

虽然该工具在基础功能方面表现良好,但在实际使用过程中仍存在一些明显的局限性与不足之处:

  • • 3D节点资源严重匮乏

    官方提供的3D节点类型极为有限,仅包含基础的几何形状和少数预设模型,无法满足复杂三维场景的构建需求。

  • • 第三方节点生态发展不完善

    第三方插件多为2D节点,在构建复杂三维场景时可能面临节点素材不足的问题。

  • • 快捷操作方式还有待改进

📝 最后的话

在技术文档越来越重要的今天,一个清晰、直观的图表往往胜过千言万语。FossFLOW以其独特的等距视角,为技术图表带来了全新的可能性。无论你是架构师、开发者、技术作家还是项目经理,这款工具都值得一试。

最重要的是,它是完全免费和开源的!你可以在GitHub上找到所有源代码,自由使用、学习和改进。

一天一个开源项目(第8篇):UI/UX Pro Max Skill - AI设计智能助手,让AI帮你构建专业UI/UX

引言

"当AI能够理解设计系统、色彩理论、用户体验原则时,它就不再是简单的代码生成器,而是真正的设计伙伴。"

这是"一天一个开源项目"系列的第8篇文章。今天带你了解的项目是 UI/UX Pro Max SkillGitHub)。

如果你正在使用Claude Code、Cursor、Windsurf等AI编程助手,并且希望它们能够生成专业、美观、符合设计规范的UI/UX代码,那么UI/UX Pro Max Skill绝对值得你深入了解。它通过24.7k+ Stars的社区认可,证明了AI设计智能的巨大价值。

你将学到什么

  • UI/UX Pro Max Skill的核心架构和工作原理
  • 如何为不同AI助手安装和配置这个技能
  • 设计系统自动生成的机制和原理
  • 智能推荐系统如何工作
  • 如何构建分层设计系统(Master + Overrides)
  • 与手动设计相比的优势和局限性
  • 如何在实际项目中应用这个技能

前置知识

  • 对AI编程助手有基本了解(Claude Code、Cursor等)
  • 熟悉UI/UX设计的基本概念
  • 了解设计系统的基本组成
  • 对命令行工具有基本使用经验

项目背景

项目简介

08-01-ui-pro-website.png

UI/UX Pro Max Skill 是一个为AI编程助手提供设计智能的开源技能,通过内置的设计系统生成引擎和智能推荐系统,让AI助手能够生成专业、美观、符合设计规范的UI/UX代码。它不仅仅是一个代码生成工具,而是一个完整的设计智能系统。

项目解决的核心问题

  • AI生成的UI代码缺乏设计规范,样式混乱
  • 缺乏对色彩理论、排版、间距等设计原则的理解
  • 无法根据产品类型推荐合适的设计风格
  • 缺少设计系统的概念,代码难以维护
  • 不同AI助手平台需要重复配置设计知识

面向的用户群体

  • 使用AI编程助手的开发者
  • 需要快速生成专业UI的前端开发者
  • 缺乏设计背景但需要构建美观界面的开发者
  • 希望提升AI生成代码质量的团队
  • 需要统一设计系统的项目

作者/团队介绍

团队:nextlevelbuilder

  • 背景:专注于AI工具和设计智能的开源团队
  • 理念:让AI真正理解设计,而不仅仅是生成代码
  • 项目定位:AI设计智能的标准解决方案
  • 官网:ui-ux-pro-max-skill.nextlevelbuilder.io

项目创建时间:2024年(从GitHub活动来看是持续活跃的项目)

项目数据

  • GitHub Stars: 24.7k+(持续快速增长)
  • 🍴 Forks: 2.5k+
  • 📦 版本: v2.2.1(最新版本,2026年1月26日发布)
  • 📄 License: MIT(完全开源,自由使用)
  • 🌐 官网: ui-ux-pro-max-skill.nextlevelbuilder.io
  • 📚 文档: 包含完整的使用指南和API文档
  • 💬 社区: GitHub Issues和Discussions活跃
  • 👥 贡献者: 27位贡献者,活跃的社区参与

项目发展历程

  • 2024年:项目创建,开始构建核心设计智能引擎
  • 2024年中:添加多平台支持,扩展到20+AI助手
  • 2024年底:引入分层设计系统(Master + Overrides)
  • 2025年:完善CLI工具,优化安装体验
  • 2026年:持续优化,社区活跃度持续提升

主要功能

核心作用

UI/UX Pro Max Skill的核心作用是为AI编程助手注入设计智能,主要功能包括:

  1. 自动生成设计系统:根据产品类型自动生成完整设计系统(色彩、字体、间距、组件规范)
  2. 智能样式推荐:基于产品领域推荐最佳样式方案
  3. 代码质量保障:生成符合UI/UX最佳实践的代码,自动检测设计反模式
  4. 多平台支持:支持Web(HTML+Tailwind、React、Vue)、移动端(SwiftUI、Jetpack Compose)、跨平台(React Native、Flutter)
  5. 分层设计系统:支持Master设计系统和页面级覆盖

快速开始

安装方式

UI/UX Pro Max Skill支持多种安装方式:

方式1:通过Claude Marketplace(Claude Code)

# 在Claude Code中直接安装
/plugin marketplace add nextlevelbuilder/ui-ux-pro-max-skill
/plugin install ui-ux-pro-max@ui-ux-pro-max-skill

方式2:使用CLI工具(推荐)

# 安装CLI工具
npm install -g uipro-cli

# 为你的AI助手安装技能
cd /path/to/your/project

# 支持多种AI助手
uipro init --ai claude      # Claude Code
uipro init --ai cursor      # Cursor
uipro init --ai windsurf    # Windsurf
uipro init --ai antigravity # Antigravity
uipro init --ai copilot     # GitHub Copilot
uipro init --ai codex       # Codex CLI
uipro init --ai gemini       # Gemini CLI
uipro init --ai opencode    # OpenCode
uipro init --ai all         # 所有支持的助手

其他CLI命令

uipro versions      # 查看可用版本
uipro update        # 更新到最新版本
uipro init --offline  # 离线安装(使用本地资源)

前置要求

Python 3.x是必需的(用于搜索脚本):

# 检查Python版本
python3 --version

# macOS安装
brew install python3

# Ubuntu/Debian安装
sudo apt update && sudo apt install python3

# Windows安装
winget install Python.Python.3.12

最简单的使用示例

Skill模式(自动激活)

支持的平台:Claude Code, Windsurf, Antigravity, Codex CLI, Continue, Gemini CLI, OpenCode, Qoder, CodeBuddy

# 直接对话,技能会自动激活
Build a landing page for my SaaS product

Workflow模式(斜杠命令)

支持的平台:Cursor, Kiro, GitHub Copilot, Roo Code

# 使用斜杠命令
/ui-ux-pro-max Build a landing page for my SaaS product

示例提示词

Build a landing page for my SaaS product
Create a dashboard for healthcare analytics
Design a portfolio website with dark mode
Make a mobile app UI for e-commerce
Build a fintech banking app with dark theme

我通过一行提示词生成的项目管理网站首页UI界面

08-02-ui-pro-demo.png

核心特性

  • 自动设计系统生成:根据产品类型生成完整设计系统,支持10+个专业领域知识库
  • 智能推荐引擎:基于产品类型推荐最佳样式,考虑用户体验和可访问性
  • 多技术栈支持:Web(HTML+Tailwind、React、Vue等)、iOS(SwiftUI)、Android(Jetpack Compose)、跨平台(React Native、Flutter)
  • 分层设计系统:Master设计系统 + 页面级覆盖,智能检索和优先级管理
  • 设计规范检查:自动检测UI/UX反模式,提供改进建议
  • CLI工具:统一安装管理,支持离线安装和版本管理
  • 模板系统:基于模板的代码生成,平台特定模板支持

项目优势

对比项 UI/UX Pro Max Skill 手动设计 其他AI设计工具
设计智能 ✅ 内置完整设计系统 ❌ 需要手动设计 ⚠️ 有限设计知识
多平台支持 ✅ 20+AI助手 ❌ 无 ⚠️ 单一平台
自动推荐 ✅ 智能样式推荐 ❌ 需要经验 ⚠️ 基础推荐
设计系统 ✅ 自动生成 ❌ 手动构建 ⚠️ 部分支持
代码质量 ✅ 符合最佳实践 ⚠️ 依赖经验 ⚠️ 不一致
学习曲线 ✅ 开箱即用 ❌ 需要学习 ⚠️ 需要配置
社区支持 ✅ 24.7k+ Stars ❌ 无 ⚠️ 有限

为什么选择UI/UX Pro Max Skill?

相比手动设计和其他AI设计工具,UI/UX Pro Max Skill提供完整的设计系统生成引擎、智能推荐、20+AI助手支持、10+领域知识库,开箱即用,社区活跃(24.7k+ Stars)。


项目详细剖析

架构设计

UI/UX Pro Max Skill采用模块化、可扩展的架构,主要包含以下几个核心模块:

核心架构

UI/UX Pro Max Skill
├── CLI工具层
│   ├── 安装器(uipro-cli)
│   ├── 模板生成系统
│   └── 版本管理
├── 技能层(.claude/skills/ui-ux-pro-max)
│   ├── 技能定义文件
│   ├── 提示词模板
│   └── 平台适配器
├── 数据层(data/)
│   ├── 设计知识库(CSV格式)
│   ├── 样式数据库
│   ├── 色彩方案库
│   └── 字体库
├── 脚本层(scripts/)
│   ├── search.py(搜索和推理引擎)
│   ├── 设计系统生成器
│   └── 推荐算法
└── 模板层(templates/)
    ├── 平台特定模板
    ├── 组件模板
    └── 页面模板

设计系统生成引擎

核心是Python脚本search.py,实现设计系统的自动生成。流程包括:1) 分析产品类型和需求;2) 从知识库检索设计知识(色彩、字体、样式、组件、领域特定知识);3) 应用推理引擎生成设计系统;4) 验证和优化设计系统。

智能推荐系统

推荐系统基于产品类型和领域知识进行智能匹配,推荐维度包括:色彩方案(基于产品类型、目标用户、品牌调性、可访问性)、字体方案(基于可读性和品牌)、间距系统(基于平台和内容)、组件风格(基于交互需求)。通过相似度计算返回Top 3推荐方案。

分层设计系统(Master + Overrides)

支持分层设计系统,Master系统包含全局色彩、字体、间距、组件规范,页面覆盖只包含与Master不同的规则。检索逻辑:如果指定页面,先检查页面覆盖文件;如果存在,合并Master和页面规则;否则只使用Master规则。

知识库系统

UI/UX Pro Max Skill的核心是设计知识库,包含10+个领域的专业设计知识:

知识库结构

data/
├── colors.csv          # 色彩方案库
├── typography.csv      # 字体方案库
├── styles.csv          # 样式方案库
├── components.csv      # 组件规范库
└── domain/             # 领域特定知识
    ├── saas.csv        # SaaS产品设计
    ├── ecommerce.csv   # 电商设计
    ├── fintech.csv     # 金融科技设计
    ├── healthcare.csv  # 医疗健康设计
    ├── education.csv   # 教育设计
    └── ...

知识库内容示例

色彩方案库(colors.csv)

product_type,industry,tone,primary_color,secondary_color,accent_color,background_color,text_color
SaaS,B2B,professional,#2563EB,#1E40AF,#3B82F6,#FFFFFF,#1F2937
SaaS,B2C,friendly,#10B981,#059669,#34D399,#F9FAFB,#111827
Ecommerce,Retail,vibrant,#EC4899,#DB2777,#F472B6,#FFFFFF,#1F2937
Fintech,Finance,trustworthy,#1E40AF,#1E3A8A,#3B82F6,#F8FAFC,#0F172A
Healthcare,Medical,calm,#059669,#047857,#10B981,#FFFFFF,#064E3B

字体方案库(typography.csv)

product_type,heading_font,body_font,font_size_scale,line_height_scale
SaaS,Inter,Inter,1.25,1.5
Ecommerce,Poppins,Inter,1.2,1.6
Fintech,Roboto,Roboto,1.15,1.5
Healthcare,Open Sans,Open Sans,1.3,1.7

领域特定知识

每个领域都有专门的设计知识库,包含:

  • 行业最佳实践:该领域的UI/UX最佳实践
  • 用户行为模式:目标用户的使用习惯
  • 设计趋势:当前流行的设计风格
  • 可访问性要求:行业特定的可访问性标准
  • 合规要求:法律法规要求(如金融、医疗)

多平台适配系统

UI/UX Pro Max Skill通过模板系统支持多种AI助手平台:

平台适配架构

通过PlatformAdapter接口实现平台适配,每个AI助手(Claude Code、Cursor等)都有独立的适配器。Claude Code支持自动激活,Cursor使用斜杠命令。CLI工具使用模板系统动态生成平台特定文件,从templates/目录加载模板并生成技能文件、配置文件和脚本。

设计规范检查系统

UI/UX Pro Max Skill内置设计规范检查,自动检测常见反模式:

反模式检测

设计规范检查系统自动检测常见反模式:色彩对比度不足(WCAG AA标准)、字体大小过小、间距不一致、组件使用不当、可访问性问题。检测器解析代码中的设计元素,对照设计系统规范,返回问题列表和改进建议。

工作流程

UI/UX Pro Max Skill的完整工作流程:

用户请求UI/UX任务
    ↓
技能自动激活(Skill模式)或通过命令激活(Workflow模式)
    ↓
分析产品类型和需求
    ↓
从知识库检索相关设计知识
    ↓
应用推理引擎生成设计系统
    ↓
智能推荐最佳样式方案
    ↓
生成符合设计系统的代码
    ↓
执行设计规范检查
    ↓
返回生成的代码和改进建议

项目地址与资源

官方资源

适用人群

UI/UX Pro Max Skill特别适合:需要快速生成专业UI代码的前端开发者、缺乏设计背景的全栈开发者、需要建立设计系统的项目、使用Claude Code/Cursor等AI助手的开发者、需要统一设计规范的团队。

不适合:不需要AI辅助的资深设计师、只需要简单代码生成的用户、不使用AI编程助手的开发者。


欢迎来我中的个人主页找到更多有用的知识和有趣的产品

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

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

在Unity的可编程渲染管线中,Shader Graph为开发者提供了可视化编写着色器的能力,而Vertex ID节点则是其中一个功能强大但常被忽视的重要工具。Vertex ID节点允许着色器访问当前处理的顶点或片元的唯一标识符,为各种高级渲染技术提供了基础支持。

Vertex ID节点概述

Vertex ID节点的核心功能是输出当前正在处理的顶点或片元在网格中的索引值。这个索引值从0开始,按照网格顶点缓冲区的顺序递增。在顶点着色器阶段,它代表顶点的索引;在片元着色器阶段,它代表生成该片元的顶点的索引。

工作原理与底层机制

Vertex ID的实现依赖于GPU的顶点着色器输入语义。在HLSL中,这通常对应着SV_VertexID系统值语义。当Unity提交绘制调用时,GPU会为每个处理的顶点分配一个唯一的ID,这个ID基于顶点在顶点缓冲区中的位置。

在传统的编写着色器代码方式中,开发者会这样声明和使用Vertex ID:

HLSL

truct appdata
{
    uint vertexID : SV_VertexID;
};

而在Shader Graph中,这个过程被简化为简单地添加和连接Vertex ID节点,大大降低了使用门槛。

节点特性与限制

Vertex ID节点有几个重要特性需要注意:

  • 输出值为浮点数类型,范围从0到网格顶点数减1
  • 在顶点着色器和片元着色器中均可使用
  • 值在单个绘制调用中保持唯一性和连续性
  • 不受网格变形或动画影响,始终反映原始网格的顶点顺序

同时也有一些使用限制:

  • 不能用于计算着色器
  • 在某些移动设备上可能有限制或性能考虑
  • 对于动态批处理的物体,Vertex ID可能不会按预期工作

Vertex ID节点的应用场景

Vertex ID节点在Shader Graph中有着广泛的应用场景,从简单的效果到复杂的渲染技术都能发挥作用。

顶点级动画与变形

利用Vertex ID可以实现基于顶点索引的动画效果,比如波浪效果、随机偏移等。由于每个顶点都有唯一的ID,可以基于ID计算不同的变换参数。

HLSL

// 伪代码示例:基于Vertex ID的波浪动画
float wave = sin(_Time.y * _WaveSpeed + vertexID * _WaveDensity);
float3 offset = float3(0, wave * _WaveHeight, 0);
position.xyz += offset;

程序化纹理坐标生成

当网格缺乏合适的UV坐标时,可以使用Vertex ID来生成程序化的纹理映射。这在处理程序化生成的几何体时特别有用。

HLSL

// 伪代码示例:基于Vertex ID生成UV
float2 uv = float2(frac(vertexID * _UVScale), floor(vertexID * _UVScale) / _GridSize);

实例化与批量渲染优化

在GPU实例化场景中,Vertex ID可以与其他系统值(如Instance ID)结合使用,实现高效的批量渲染和数据索引。

调试与可视化工具

Vertex ID是强大的调试工具,可以用于:

  • 可视化顶点分布和顺序
  • 检测顶点缓冲区问题
  • 理解网格拓扑结构

实际应用示例

下面通过几个具体的Shader Graph设置示例,展示Vertex ID节点的实际应用。

波浪地形效果

创建一个基于Vertex ID的波浪地形效果:

  • 首先在Shader Graph中创建Vertex ID节点
  • 将输出连接到Custom Function节点进行波浪计算
  • 使用Time节点提供动画参数
  • 将计算结果连接到Position节点的偏移量

关键节点设置:

  • Vertex ID → Custom Function (波浪计算) → Add to Position
  • Time → Multiply (控制速度) → Custom Function
  • 参数输入:波浪幅度、频率、传播速度

这种设置可以实现流畅的波浪动画,每个顶点基于其ID产生相位偏移,形成自然的波浪传播效果。

顶点颜色渐变

使用Vertex ID创建沿着顶点顺序的颜色渐变:

  • Vertex ID节点输出除以网格顶点总数,归一化到[0,1]范围
  • 将归一化值输入到Gradient节点
  • 将Gradient输出连接到Base Color

这种方法特别适合线框渲染或几何可视化,可以清晰展示顶点的顺序和分布。

程序化网格变形

结合Vertex ID和数学节点创建复杂的网格变形:

  • 使用Vertex ID作为噪声函数的输入种子
  • 通过不同的数学运算(sin、cos、fract等)创建各种变形模式
  • 将变形结果应用到顶点位置

这种技术可以创建有机的、程序化的形状变化,无需额外的纹理或顶点数据。

性能优化与最佳实践

正确使用Vertex ID节点需要考虑性能因素和最佳实践。

性能考虑

  • 在移动平台上,尽量减少基于Vertex ID的复杂计算
  • 避免在片元着色器中使用Vertex ID进行每帧重计算
  • 考虑使用顶点着色器计算并将结果传递给片元着色器

兼容性处理

  • 使用Shader Graph的节点功能检查目标平台的兼容性
  • 为不支持Vertex ID的平台提供fallback方案
  • 测试在不同图形API下的行为一致性

调试技巧

  • 使用Vertex ID可视化来理解网格结构
  • 结合RenderDoc等工具分析实际的Vertex ID分布
  • 创建调试着色器来验证Vertex ID的预期行为

高级应用技巧

与其他系统值的结合

Vertex ID可以与其他系统值结合使用,创造更复杂的效果:

  • 结合Instance ID实现每实例的顶点变形
  • 与Primitive ID配合实现基于图元的特效
  • 和Screen Position结合创建屏幕相关的顶点动画

自定义函数封装

对于复杂的Vertex ID应用,可以创建自定义HLSL函数节点:

HLSL

void VertexIDAnimation_float(float VertexID, float Time, float Amplitude, float Frequency, out float3 Offset)
{
    float phase = VertexID * Frequency + Time;
    Offset = float3(0, sin(phase) * Amplitude, 0);
}

这样可以在多个Shader Graph中重用复杂的Vertex ID逻辑。

数据驱动的方法

将Vertex ID与外部数据结合:

  • 使用Compute Buffer存储每顶点的动画参数
  • 通过MaterialPropertyBlock传递顶点级别的数据
  • 结合Scriptable Renderer Features实现更高级的渲染管线集成

故障排除与常见问题

Vertex ID输出异常

当Vertex ID不按预期工作时,可能的原因包括:

  • 网格被动态批处理,改变了顶点顺序
  • 使用了不支持的渲染路径
  • 图形API限制

解决方案:

  • 禁用动态批处理
  • 检查目标平台的图形API支持
  • 使用Shader Variant收集器确保所有需要的变体都被编译

性能问题

基于Vertex ID的效果导致性能下降时的优化策略:

  • 将计算从片元着色器移到顶点着色器
  • 使用LOD系统在远距离简化效果
  • 预计算静态效果到顶点颜色或纹理中

平台兼容性

处理不同平台的兼容性问题:

  • 为OpenGL ES 2.0等老旧平台提供简化版本
  • 使用Shader Graph的Keyword系统管理平台特定代码
  • 进行充分的跨平台测试

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

Nest 的中间件 Middleware ?

新建项目

nest new middleware-demo

创建一个中间件

nest g middleware aaa --no-spec --flat

image.png

加下打印

import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response } from 'express';

@Injectable()
export class AaaMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: () => void) {
    console.log('brefore');
    next();
    console.log('after');
  }
}

在 Module 里这样使用

import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { AaaMiddleware } from './aaa.middleware';

@Module({
  imports: [],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer.apply(AaaMiddleware).forRoutes('*');
  }
}

跑起来看看

image.png

可以指定更精确的路由,添加几个 handler

import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get('hello')
  getHello(): string {
    console.log('hello');
    return this.appService.getHello();
  }

  @Get('hello2')
  getHello2(): string {
    console.log('hello2');
    return this.appService.getHello();
  }

  @Get('hi')
  getHi(): string {
    console.log('hi');
    return this.appService.getHello();
  }

  @Get('hi1')
  getHi1(): string {
    console.log('hi1');
    return this.appService.getHello();
  }
}

module 匹配更新下

import { AaaMiddleware } from './aaa.middleware';
import {
  MiddlewareConsumer,
  Module,
  NestModule,
  RequestMethod,
} from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';

@Module({
  imports: [],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(AaaMiddleware)
      .forRoutes({ path: 'hello/*path', method: RequestMethod.GET });
    consumer
      .apply(AaaMiddleware)
      .forRoutes({ path: 'hi1', method: RequestMethod.GET });
  }
}

image.png

Nest 为什么要把 Middleware 做成 class 呢?

为了依赖注入!

通过 @Inject 注入 AppService 到 middleware 里

import { AppService } from './app.service';
import { Inject, Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response } from 'express';

@Injectable()
export class AaaMiddleware implements NestMiddleware {
  @Inject(AppService)
  private readonly appService: AppService;

  use(req: Request, res: Response, next: () => void) {
    console.log('brefore');
    console.log('-------' + this.appService.getHello());
    next();
    console.log('after');
  }
}

image.png

这就是 Nest 注入的依赖

❌