阅读视图

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

项目大扫除神器:Knip —— 将你的代码库“瘦身”到底

image.png

在现代前端开发中,随着项目迭代周期的拉长,代码库中往往会堆积越来越多的“僵尸代码”:不再使用的组件、废弃的工具函数、遗留在 package.json 中的依赖项……这些冗余不仅增加了项目的维护成本,拖慢了构建速度,还可能因为未及时更新的废弃依赖带来安全隐患。

通常我们可能会用 ESLint 来检查未使用的变量,或者手动定期清理。但对于跨文件的未使用导出、或者整个未被引用的文件,普通的 Linter 往往无能为力。

今天我要介绍的这款神器 —— Knip,就是为了解决这个问题而生的。它像一把锋利的瑞士军刀,能精准地切割掉你项目中的那些“累赘”。

什么是 Knip?

Knip 是一个基于 TypeScript 编写的命令行工具,专门用于查找 JavaScript 和 TypeScript 项目中的未使用文件、依赖项和导出

与传统的 Linter 不同,Knip 不仅仅盯着单个文件看,它会分析整个项目的依赖关系图。这意味着它能发现那些“虽然被定义了且没有语法错误,但实际上从未被其他文件引用过”的孤立代码。

Knip 的核心绝技

Knip 的功能非常强大,主要涵盖以下几个方面:

1. ✂ 查找未使用的文件 (Unused Files)

它能找出项目中那些静静躺着却从未被导入过的文件。

2. 查找未使用的依赖 (Unused Dependencies)

它会扫描你的 package.json,告诉你哪些 dependenciesdevDependencies 实际上在代码中一次都没用到。这对于清理陈旧依赖非常有帮助。

3. 查找未使用的导出 (Unused Exports)

这是 Knip 最杀手级的功能。通过分析导入导出关系,它能找出那些被 export 出来,但不仅当前文件没用,整个项目其他地方也没用的变量、函数或类。

4. 查找未列出的依赖 (Unlisted Dependencies)

反过来,它也能帮你发现那些你在代码里 import 了,但忘记写在 package.json 里的依赖,避免上线后因为缺包而 crash。

5. 强大的生态支持

Knip 内置了对各种主流框架和工具的插件支持,包括但不限于:

  • Next.js
  • Vite, Webpack, Rollup
  • Jest, Vitest
  • Tailwind CSS
  • Storybook
  • GitHub Actions 等等

这意味着它能理解这些工具的配置文件和特定用法,不会误报(比如把 Next.js 的 pages 目录下的文件当成未使用文件)。

快速上手

安装

你可以将 Knip 作为开发依赖安装到你的项目中:

npm install -D knip typescript @types/node
# 或者
yarn add -D knip typescript @types/node
# 或者
pnpm add -D knip typescript @types/node

注意:Knip 依赖 TypeScript 进行分析,即使你是 JS 项目也建议安装。

配置 script

package.json 中添加一个脚本:

{
  "scripts": {
    "knip": "knip"
  }
}

运行

在终端执行:

npm run knip

Knip 会开始扫描你的项目,并输出一份详细的报告,列出所有疑似未使用的项目。

进阶配置

虽然 Knip 很有“眼力见”,能够自动识别很多配置,但对于复杂的项目,你可能需要一个 knip.json (或 knip.ts, knip.js) 配置文件来微调它的行为。

例如,指定入口文件(Entry files)和忽略特定的文件或依赖:

{
  "$schema": "https://unpkg.com/knip@5/schema.json",
  "entry": ["src/index.ts", "src/cli.ts"],
  "project": ["src/**/*.ts!"],
  "ignore": ["src/legacy/**"],
  "ignoreDependencies": ["eslint-config-prettier"],
  "ignoreExportsUsedInFile": true
}
  • entry: 告诉 Knip 从哪里开始分析依赖图。通常是你的 main 文件或页面入口。
  • ignore: 忽略某些文件不进行检查。
  • ignoreDependencies: 有些依赖可能是全局使用的或者通过 Babel 插件注入的,Knip 也就是静态分析扫不到,可以在这里忽略以消除误报。

最佳实践工作流

  1. 初次扫描:运行 knip,你会大概率被吓一跳(甚至有成百上千个报错)。不要慌,这很正常。
  2. 排除误报:仔细检查报告。如果是工具配置(如 Jest 配置、Webpack 配置)被误报,检查是否需要启用对应的 Knip 插件或在 knip.json 中配置入口。
  3. 逐步清理
    • 先删文件:未使用的文件是最容易确认和删除的。
    • 再删依赖:确认 package.json 中未使用的依赖,卸载它们。
    • 最后处理导出:对于未使用的 export,你可以选择删除导出关键字变成局部变量,或者直接删除该函数。
  4. CI 集成:将 knip 加入到你的 CI 流程中(如 GitHub Actions)。这样每次提交 MR 时,Knip 都会自动检查,防止新的“垃圾代码”混入主分支。

总结

代码库的维护就像打扫房间,不仅要勤拂拭,更要定期“断舍离”。Knip 为我们提供了一个上帝视角,让我们能清晰地看到项目中那些被遗忘的角落。

建议大家都在自己的项目中试运行一下 Knip,相信我,它一定会给你带来“惊喜”(或者是惊吓,哈哈)。让我们的项目保持轻量、整洁,从使用 Knip 开始!


参考链接:

【节点】[Adjustment-InvertColors节点]原理解析与实际应用

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

Unity URP Shader Graph中的Invert Colors节点是一个功能强大的颜色处理工具,能够基于每个通道单独反转输入颜色的数值。其设计理念是通过数学运算将颜色值在其取值范围内翻转,以创造多样的视觉效果和艺术表现。在计算机图形学中,颜色反转是一项基础且重要的图像处理技术,可为游戏开发者提供丰富的视觉表现手段。

该节点假设所有输入值均处于0到1的标准范围内,这是计算机图形学中颜色值的标准表示方式。在Unity的Shader Graph环境中,颜色通道通常以浮点数表示,其中0表示该颜色的最低强度(即完全无该颜色),1表示该颜色的最高强度(即完全饱和)。这种标准化处理使颜色计算更加统一和可预测。

颜色反转的核心原理是从基准值中减去当前颜色值,从而得到相反的颜色效果。在RGB颜色模型中,这相当于计算每个颜色通道的“补色”。例如,纯红色(1,0,0)反转后会变为青色(0,1,1),纯绿色(0,1,0)会变为品红色(1,0,1),而纯蓝色(0,0,1)则会变为黄色(1,1,0)。这种颜色转换关系基于色彩理论中的互补色概念,为游戏视觉效果设计提供了理论基础。

端口详解

img 输入端口是节点接收数据的接口,Invert Colors节点的输入端口设计体现了其灵活性和适应性:

输入端口(In)

  • 方向:输入
  • 类型:动态矢量
  • 绑定:无
  • 功能描述:该端口接收待处理的颜色或矢量数据。作为动态矢量类型,它可以接受不同维度的输入数据,包括:
    • 浮点数(单通道)
    • 二维矢量(两个通道)
    • 三维矢量(RGB颜色)
    • 四维矢量(RGBA颜色)

这种动态特性使节点能够适应各种使用场景,从简单的单值反转到复杂的多通道颜色处理均可胜任。

输出端口(Out)

  • 方向:输出
  • 类型:动态矢量
  • 绑定:无
  • 功能描述:输出经过颜色反转处理后的结果。输出数据的维度与输入数据保持一致,确保数据处理的一致性。输出值的计算基于每个启用反转的通道单独进行,未启用的通道则保持原值不变。

控件系统

Invert Colors节点的控件系统提供了精细的通道控制能力,使开发者能够根据具体需求定制反转效果:

红色通道控制(Red)

  • 类型:布尔开关
  • 选项:True(启用)、False(禁用)
  • 功能说明:当设置为true时,对输入值的红色通道进行反转处理。反转计算采用标准的颜色反转算法:Out.r = 1 - In.r。这一简单的数学运算能够产生显著的视觉效果,红色越强的区域在反转后青色越明显。

绿色通道控制(Green)

  • 类型:布尔开关
  • 选项:True(启用)、False(禁用)
  • 功能特点:该控件具有智能的维度感知功能。当输入矢量维度小于2时(如单通道标量),绿色通道控件会自动禁用,因为此时不存在绿色通道可供操作。这种设计避免了无效操作,提升了节点的用户友好性。

蓝色通道控制(Blue)

  • 类型:布尔开关
  • 选项:True(启用)、False(禁用)
  • 智能检测:与绿色通道类似,蓝色通道控件也会根据输入数据的维度自动调整可用性。只有当输入矢量包含至少三个通道时,蓝色通道控件才处于可操作状态。这种维度感知机制确保了操作的合理性和系统的稳定性。

Alpha通道控制(Alpha)

  • 类型:布尔开关
  • 选项:True(启用)、False(禁用)
  • 特殊功能:Alpha通道控制专门处理透明度信息的反转。当输入数据为四维矢量时,该控件可用。Alpha通道反转能够创造出独特的透明度效果,例如将完全不透明的区域变为完全透明,或创建特殊的遮罩效果。

数学原理与算法实现

Invert Colors节点的核心算法基于向量运算和通道分离处理。其数学表达式可分解为:

对于每个颜色通道i(i ∈ {r, g, b, a}),反转计算遵循以下规则: Out.i = Control.i ? (1 - In.i) : In.i

其中Control.i表示对应通道的布尔控制值。这种按通道独立处理的方式提供了极大的灵活性,允许开发者创建复杂的分通道反转效果。

在Shader Graph的底层实现中,该节点生成相应的HLSL代码。节点内部维护一个控制向量_InvertColors_InvertColors,该向量存储了各个通道的反转状态。实际的颜色反转操作在Unity_InvertColors_float4函数中完成,该函数接收输入颜色、反转控制向量,并输出处理后的颜色值。

实际应用场景

游戏视觉效果

  • 伤害效果表现:当角色受到伤害时,通过短暂的颜色反转创造视觉冲击
  • 特殊状态指示:用于表现角色的中毒、眩晕、魔法效果等异常状态
  • 场景转换过渡:在场景切换时使用颜色反转作为转场效果
  • 超自然现象模拟:表现幽灵、幻影、异世界等超自然视觉效果

用户界面设计

  • 按钮交互反馈:在按钮按下时使用部分通道反转创造视觉反馈
  • 高亮提示效果:通过选择性反转突出显示重要UI元素
  • 主题切换过渡:在不同界面主题间切换时使用颜色反转平滑过渡
  • 状态指示器:用颜色反转表示系统状态变化,如电量不足、网络断开等

艺术风格化处理

  • 负片效果创作:完全反转所有颜色通道创建照片负片效果
  • 色调分离技术:结合其他着色器节点创建复杂的色彩分离效果
  • 动态色彩循环:通过动画控制反转参数创建流动的色彩效果
  • 材质特性表现:用于强调特定材质的反射、折射等光学特性

性能分析与优化建议

Invert Colors节点在性能方面的表现主要取决于以下几个因素:

计算复杂度分析

  • 单个通道反转操作需要一次减法运算
  • 四通道完全反转共需要四次减法运算
  • 条件判断基于静态控件,在编译时即可优化

优化策略

  • 避免在片段着色器中频繁切换反转状态
  • 合理使用通道选择性反转,减少不必要的计算
  • 结合LOD系统,在远距离降低反转效果精度
  • 使用实例化减少重复计算

平台兼容性考虑

  • 在所有支持Shader Graph的平台上都能稳定运行
  • 移动设备上性能开销可控,适合适度使用
  • WebGL平台需要注意精度和性能平衡

高级使用技巧

与其他节点的组合应用Invert Colors节点可以与其他Shader Graph节点组合使用,创造出更加丰富多样的视觉效果:

  • 与Blend节点结合:创建复杂的颜色混合效果
  • 与Time节点联动:制作动态的颜色反转动画
  • 与Gradient节点配合:实现自定义的颜色过渡效果
  • 与Noise节点组合:添加有机的纹理变化

参数动画化技术通过将反转控制参数与时间、玩家输入或其他游戏变量绑定,可以创建动态的颜色反转效果:

  • 周期性反转:使用正弦函数控制反转强度,创建呼吸灯效果
  • 交互驱动反转:基于玩家操作实时调整反转参数
  • 环境响应反转:根据游戏环境变化自动调整反转效果
  • 渐进式反转:使用缓动函数实现平滑的反转过渡

多层反转效果通过串联多个Invert Colors节点,可以实现复杂的分层反转效果:

  • 部分通道多次反转:创建独特的色彩循环
  • 条件性反转链:基于游戏状态选择不同的反转路径
  • 空间变化反转:结合UV坐标创建区域性的反转效果
  • 时间延迟反转:在不同时间点启用不同通道的反转

故障排除与常见问题

视觉效果异常排查当Invert Colors节点产生不符合预期的效果时,可以按照以下步骤进行排查:

  • 检查输入数据范围:确保所有颜色值在0-1范围内
  • 验证控件状态:确认各个通道的反转开关设置正确
  • 检查节点连接:确保数据流连接正确无误
  • 测试隔离效果:单独测试反转节点排除其他节点影响

性能问题诊断如果发现使用Invert Colors节点后性能下降,可以考虑:

  • 分析绘制调用次数:检查是否因反转效果导致批次合并失败
  • 监控GPU负载:使用性能分析工具检测具体瓶颈
  • 优化着色器变体:减少不必要的功能变体生成
  • 简化节点网络:重构着色器图提高执行效率

跨平台兼容性问题在不同平台上可能会遇到不同的表现问题:

  • 精度差异:移动设备上可能出现的精度问题
  • 色彩空间:线性空间与伽马空间的转换问题
  • 特性支持:不同图形API对特定功能的支持差异
  • 内存限制:移动设备上的内存使用限制

最佳实践指南

项目组织规范为了确保着色器项目的可维护性和团队协作效率,建议遵循以下规范:

  • 统一的命名约定:为反转控制参数制定明确的命名规则
  • 模块化设计:将常用的反转效果封装为子图重用
  • 文档化配置:为复杂的反转设置提供说明文档
  • 版本控制策略:合理管理着色器资源的版本历史

性能与质量平衡在追求视觉效果的同时,需要兼顾性能考量:

  • 质量级别划分:为不同设备等级设置不同的反转效果质量
  • 动态细节调整:根据运行帧率自动调整反转效果复杂度
  • 资源预算管理:为反转效果分配合理的性能预算
  • 测试覆盖全面:在各种硬件配置上测试反转效果表现

创意应用拓展鼓励开发者发挥创意,探索Invert Colors节点的更多可能性:

  • 实验性艺术效果:尝试非传统的反转参数组合
  • 技术创新应用:将反转技术应用于新的渲染领域
  • 跨媒体适应:调整反转效果适应不同的输出介质
  • 用户可定制化:提供反转参数让玩家自定义视觉效果

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

每日一题-优惠券校验器🟢

给你三个长度为 n 的数组,分别描述 n 个优惠券的属性:codebusinessLineisActive。其中,第 i 个优惠券具有以下属性:

  • code[i]:一个 字符串,表示优惠券的标识符。
  • businessLine[i]:一个 字符串,表示优惠券所属的业务类别。
  • isActive[i]:一个 布尔值,表示优惠券是否当前有效。

当以下所有条件都满足时,优惠券被认为是 有效的 

  1. code[i] 不能为空,并且仅由字母数字字符(a-z、A-Z、0-9)和下划线(_)组成。
  2. businessLine[i] 必须是以下四个类别之一:"electronics""grocery""pharmacy""restaurant"
  3. isActive[i]true 

返回所有 有效优惠券的标识符 组成的数组,按照以下规则排序:

  • 先按照其 businessLine 的顺序排序:"electronics""grocery""pharmacy""restaurant"
  • 在每个类别内,再按照 标识符的字典序(升序)排序。

 

示例 1:

输入: code = ["SAVE20","","PHARMA5","SAVE@20"], businessLine = ["restaurant","grocery","pharmacy","restaurant"], isActive = [true,true,true,true]

输出: ["PHARMA5","SAVE20"]

解释:

  • 第一个优惠券有效。
  • 第二个优惠券的标识符为空(无效)。
  • 第三个优惠券有效。
  • 第四个优惠券的标识符包含特殊字符 @(无效)。

示例 2:

输入: code = ["GROCERY15","ELECTRONICS_50","DISCOUNT10"], businessLine = ["grocery","electronics","invalid"], isActive = [false,true,true]

输出: ["ELECTRONICS_50"]

解释:

  • 第一个优惠券无效,因为它未激活。
  • 第二个优惠券有效。
  • 第三个优惠券无效,因为其业务类别无效。

 

提示:

  • n == code.length == businessLine.length == isActive.length
  • 1 <= n <= 100
  • 0 <= code[i].length, businessLine[i].length <= 100
  • code[i]businessLine[i] 由可打印的 ASCII 字符组成。
  • isActive[i] 的值为 truefalse

理解 Proxy 原理及如何拦截 Map、Set 等集合方法调用实现自定义拦截和日志——含示例代码解析

先理解 Proxy 的核心思想

Proxy 就像一个“拦截器”,它可以“监听”一个对象的操作,比如:

  • 访问对象的属性(读取) → 触发 get 拦截器
  • 给对象的属性赋值(写入) → 触发 set 拦截器
  • 调用对象的方法 → 其实是先访问方法(触发 get),再执行它

但集合类型(Map、Set 等)不直接用属性赋值来写入数据

  • Map 写入数据是调用它的 set(key, value) 方法
  • Set 写入数据是调用它的 add(value) 方法
  • 读取数据是调用 Map 的 get(key) 或 Set 的 has(value) 方法

所以,我们想拦截“写入”操作,就要拦截这些方法的调用。


Proxy 怎么拦截方法调用?

  • 当你访问 proxyMap.set,会触发 Proxy 的 get 拦截器,告诉你访问了 set 方法。
  • 这时我们返回一个“包装函数”,这个函数内部可以插入自定义逻辑(比如打印日志),然后再调用原始的 set 方法。
  • 这样就实现了“拦截写入操作”。

具体示例:拦截 Map 的读取和写入

const map = new Map();

const handler = {
  get(target, prop, receiver) {
    // 访问属性或方法时触发
    const origMethod = target[prop];
    if (typeof origMethod === 'function') {
      // 如果访问的是方法,返回一个包装函数
      return function (...args) {
        if (prop === 'set') {
          console.log(`写入操作:set(${args[0]}, ${args[1]})`);
        } else if (prop === 'get') {
          console.log(`读取操作:get(${args[0]})`);
        }
        // 调用原始方法
        return origMethod.apply(target, args);
      };
    }
    // 访问普通属性,直接返回
    return Reflect.get(target, prop, receiver);
  }
};

const proxyMap = new Proxy(map, handler);

proxyMap.set('name', 'CodeMoss');  // 控制台输出:写入操作:set(name, CodeMoss)
console.log(proxyMap.get('name')); // 控制台输出:读取操作:get(name)
                                   // 输出:CodeMoss

可以把它理解成:

  • 访问 proxyMap.set → Proxy 拦截,返回一个“带日志”的函数
  • 调用这个函数时,先打印日志,再调用真正的 map.set

Set 也是类似的,只是写入方法叫 add,读取方法叫 has

const set = new Set();

const handler = {
  get(target, prop, receiver) {
    const origMethod = target[prop];
    if (typeof origMethod === 'function') {
      return function (...args) {
        if (prop === 'add') {
          console.log(`写入操作:add(${args[0]})`);
        } else if (prop === 'has') {
          console.log(`读取操作:has(${args[0]})`);
        }
        return origMethod.apply(target, args);
      };
    }
    return Reflect.get(target, prop, receiver);
  }
};

const proxySet = new Proxy(set, handler);

proxySet.add(123);  // 控制台输出:写入操作:add(123)
console.log(proxySet.has(123)); // 控制台输出:读取操作:has(123)
                               // 输出:true

WeakMap 和 WeakSet 也一样,只是它们的键或值必须是对象,且不能遍历

const weakMap = new WeakMap();

const handler = {
  get(target, prop, receiver) {
    const origMethod = target[prop];
    if (typeof origMethod === 'function') {
      return function (...args) {
        if (prop === 'set') {
          console.log('WeakMap 写入操作,键:', args[0], '值:', args[1]);
        } else if (prop === 'get') {
          console.log('WeakMap 读取操作,键:', args[0]);
        }
        return origMethod.apply(target, args);
      };
    }
    return Reflect.get(target, prop, receiver);
  }
};

const proxyWeakMap = new Proxy(weakMap, handler);

const objKey = {};
proxyWeakMap.set(objKey, 'secret');  // 控制台输出:WeakMap 写入操作,键: {} 值: secret
console.log(proxyWeakMap.get(objKey)); // 控制台输出:WeakMap 读取操作,键: {}
                                       // 输出:secret

总结

  • Proxy 的 get 拦截器拦截的是“属性访问”,方法调用是先访问方法再执行。
  • 集合的写入和读取都是通过调用方法实现的,所以我们拦截方法访问,返回包装函数。
  • 包装函数里可以插入自定义逻辑(日志、权限等),然后调用原始方法完成操作。

把 const objKey = {}; 换成 map”,把 WeakMap 的键从一个普通对象 {} 换成一个 Map 对象。


先说明一点:

WeakMap 的键必须是对象,而 Map 本身是一个对象(它是一个构造函数实例),所以理论上是可以作为 WeakMap 的键的。


可以这样写:

const weakMap = new WeakMap();

const handler = {
  get(target, prop, receiver) {
    const origMethod = target[prop];
    if (typeof origMethod === 'function') {
      return function (...args) {
        if (prop === 'set') {
          console.log('WeakMap 写入操作,键:', args[0], '值:', args[1]);
        } else if (prop === 'get') {
          console.log('WeakMap 读取操作,键:', args[0]);
        }
        return origMethod.apply(target, args);
      };
    }
    return Reflect.get(target, prop, receiver);
  }
};

const proxyWeakMap = new Proxy(weakMap, handler);

// 这里用 Map 作为键
const mapKey = new Map();

proxyWeakMap.set(mapKey, 'secret');  // 控制台输出:WeakMap 写入操作,键: Map {} 值: secret
console.log(proxyWeakMap.get(mapKey)); // 控制台输出:WeakMap 读取操作,键: Map {}
                                       // 输出:secret

解释:

  • mapKey 是一个 Map 实例,属于对象类型,可以作为 WeakMap 的键。
  • WeakMap 允许任何对象作为键,包括普通对象、数组、函数、甚至 Map、Set 等实例。
  • 用 Map 作为键,完全没问题。

使用Trae SOLO模式开发一个视频提取文字并总结归纳的工具——附线上预览地址

前言

本文记录使用Trae SOLO模式开发一个视频提取文字并总结归纳的工具

线上地址:ashuai.site:24680/

需求场景表述

  • 笔者是前端开发,但是对产品经理的知识了解不多,所以想学习产品经理的知识,问之前的产品同事要了一份视频课程,无奈课程时长起步一个多小时,如果一点点开,或者快进看,也是效率略低。

  • 因此,笔者想开发一个工具,能够一键提取视频中的内容文字,并把内容文字交给大模型,由大模型总结摘要

  • 这样我就可以快速学习产品经理的知识,而不是浪费时间在看视频上

首先,我需要做技术框架选型,限定为react+vite+ts+antd+tailwindcss

篇幅原因,把内容文字交给大模型,由大模型总结摘要这一步,笔者没有再solo

同质化调用大模型api的文章,可以参考笔者先前的文章:《效能工具(十)之接入deepseek实现AI学习PDF文档读后感文件批量生成功能》

Trae的SOLO模式开发

1. 基于用户需求,生成对应文档

笔者把上述需求,告知Trae 以后,Trae自动帮我生成一个文档,规划好,它需要做的事情,并且允许我调整这个规划文档,如下:

1.png
  • 如果我觉得规划文档冗余,或者缺少东西,可以修修改改
  • 这一步,很像项目经理提出需求后,产品提供的需求拆解文档(包含技术开发要点)

2. 让其按照文档,进行开工

让其按照文档,进行开工,Trae SOLO会自动在命令行执行相关命令,然后在右侧生成对应代码

2.png

然后,安装各种依赖

4.png

3. 产物变更

当Trae SOLO完毕以后,会提供一个产物汇总,我们可以查看变更,这样能够具体看出来,Trae帮我们写了那些代码

5.png

然后,我们查看一下终端

4. 启动项目跑起来,浏览器看效果

默认运行在5173端口上

6.png

看看浏览器的效果,发现了一个小bug

7.png

5. 告知修复antd的属性弃用的bug

这里可以截图,或者文字输入,把浏览器的bug粘贴,告知Trae,如下

8.png

然后,Trae会进行思考,并定位到问题代码,自动修复

9.png

这样的话,基本的样子就出来了,接下来,需要我进行人工介入

6. 视频提取文字,技术拆解

视频提取文字,分为这几个步骤

  1. 把视频中的音频剥离出来——使用fluent-ffmpeg这个包
  2. 把音频转成文字——使用whisper-node这个包

fluent-ffmpeg需要下载ffmepg这个工具的本地

whisper-node下载tiny微小版模型就行了

接下来,我需要 Windows 平台,下载ffmepg

参考这篇文章:blog.csdn.net/Natsuago/ar…

最终,笔者把ffmpeg安装好了,如下

10.png

7. 发现还得写后端

fluent-ffmpeg和whisper-node需要后端服务,才方便运行,所以,我和Trae沟通后,它又帮我继续创建后端代码

11.png

8. 针对于高风险的命令会暂停并提示用户

比如删除文件操作,Trae会停下来solo,然后询问用户是否这样操作,这样还是不错的,防止AI编程误删一些重要的文件

12.png

9. 若是方向错误,告知可纠正

  • 实际上,涉及到视频转文本的功能,还是python生态更加合适
  • 笔者一开始,让其使用nodejs生态写后端,而后,solo也发现了并推荐改成python生态
  • 笔者点击同意,选择让其把后端代码改成python生态
  • 然后trae也很清晰地理解了需求
  • 进行了重构
  • 重构过程中,可能也会出现一些报错,也需要人工介入,但是这并不Trae的问题,而是所有AI编程的问题

和人沟通,有什么问题,和AI沟通也会有

有时候,锅不在AI,而在我们,因为我们没有清晰地表达明白需求

10. 来回solo最终得到结果成品

在来回的solo交流中,最终,实现了笔者想要的效果

工具成品

技术栈介绍

注意,以下这总结文档,也是solo出来,我再修改的

介绍图片.png

效果图

效果图.gif

线上地址(不包含后端)

地址:ashuai.site:24680/

服务器内存容量吃紧,就不部署后端了,大家可以自己拉取代码,自己本机跑起来

github仓库代码

地址:github.com/shuirongshu…

注意,若是生产环境,高可用,笔者还是建议,使用云服务商的付费接口

原因主要有两点:

1.开源模型的识别准确率、2.服务器维护成本

总结Trae SOLO模式

  • Trae SOLO模式就是我们开发者化身项目经理角色
  • Trea SOLO化身产品经理写文档、加程序员写代码角色
  • 我们开发者,主要是进行把控、管控、调整
  • 从而让开发出来的项目,符合预期

整体用下来,还是能够提升很大的开发效率的

Vue组件缓存终极指南:keep-alive原理与动态更新实战

一、为什么需要组件缓存?

在Vue单页应用开发中,我们经常会遇到这样的场景:用户在数据筛选页面设置了复杂的查询条件,然后进入详情页查看,当返回时希望之前的筛选条件还能保留。如果每次切换路由都重新渲染组件,会导致用户体验下降、数据丢失、性能损耗等问题。

组件缓存的核心价值:

    1. 保持组件状态,避免重复渲染
    1. 提升应用性能,减少不必要的DOM操作
    1. 改善用户体验,维持用户操作上下文

二、Vue的缓存神器:keep-alive

2.1 keep-alive基础用法

<template>
  <div id="app">
    <!-- 基本用法 -->
    <keep-alive>
      <component :is="currentComponent"></component>
    </keep-alive>
    
    <!-- 结合router-view -->
    <keep-alive>
      <router-view v-if="$route.meta.keepAlive"></router-view>
    </keep-alive>
    <router-view v-if="!$route.meta.keepAlive"></router-view>
  </div>
</template>

<script>
export default {
  data() {
    return {
      currentComponent'UserList'
    }
  }
}
</script>

2.2 keep-alive的生命周期变化

当组件被缓存时,正常的生命周期会发生变化:

<script>
export default {
  name'UserList',
  
  // 正常生命周期(未缓存时)
  created() {
    console.log('组件创建')
    this.loadData()
  },
  
  mounted() {
    console.log('组件挂载')
  },
  
  destroyed() {
    console.log('组件销毁')
  },
  
  // 缓存特有生命周期
  activated() {
    console.log('组件被激活(进入缓存组件)')
    this.refreshData() // 重新获取数据
  },
  
  deactivated() {
    console.log('组件被停用(离开缓存组件)')
    this.saveState() // 保存当前状态
  }
}
</script>

生命周期流程图:

首次进入组件:
created → mounted → activated

离开缓存组件:
deactivated

再次进入缓存组件:
activated(跳过created和mounted)

组件被销毁:
deactivated → destroyed(如果完全销毁)

三、高级缓存策略

3.1 条件缓存与排除缓存

<template>
  <div>
    <!-- 缓存特定组件 -->
    <keep-alive :include="cachedComponents" :exclude="excludedComponents" :max="5">
      <router-view></router-view>
    </keep-alive>
  </div>
</template>

<script>
export default {
  data() {
    return {
      // 只缓存这些组件(基于组件name)
      cachedComponents: ['UserList''ProductList''OrderList'],
      
      // 不缓存这些组件
      excludedComponents: ['Login''Register']
    }
  }
}
</script>

3.2 动态路由缓存方案

// router/index.js
const routes = [
  {
    path'/user/list',
    name'UserList',
    component() => import('@/views/UserList.vue'),
    meta: {
      title'用户列表',
      keepAlivetrue// 需要缓存
      isRefreshtrue  // 是否需要刷新
    }
  },
  {
    path'/user/detail/:id',
    name'UserDetail',
    component() => import('@/views/UserDetail.vue'),
    meta: {
      title'用户详情',
      keepAlivefalse // 不需要缓存
    }
  }
]

// App.vue
<template>
  <div id="app">
    <keep-alive>
      <router-view v-if="$route.meta.keepAlive"></router-view>
    </keep-alive>
    <router-view v-if="!$route.meta.keepAlive"></router-view>
  </div>
</template>

四、缓存后的数据更新策略

4.1 方案一:使用activated钩子

<script>
export default {
  name'ProductList',
  data() {
    return {
      products: [],
      filterParams: {
        category'',
        priceRange: [01000],
        sortBy'createdAt'
      },
      lastUpdateTimenull
    }
  },
  
  activated() {
    // 检查是否需要刷新数据(比如超过5分钟)
    const now = new Date().getTime()
    if (!this.lastUpdateTime || (now - this.lastUpdateTime) > 5 * 60 * 1000) {
      this.refreshData()
    } else {
      // 使用缓存数据,但更新一些实时性要求高的内容
      this.updateRealTimeData()
    }
  },
  
  methods: {
    async refreshData() {
      try {
        const response = await this.$api.getProducts(this.filterParams)
        this.products = response.data
        this.lastUpdateTime = new Date().getTime()
      } catch (error) {
        console.error('数据刷新失败:', error)
      }
    },
    
    updateRealTimeData() {
      // 只更新库存、价格等实时数据
      this.products.forEach(async (product) => {
        const stockInfo = await this.$api.getProductStock(product.id)
        product.stock = stockInfo.quantity
        product.price = stockInfo.price
      })
    }
  }
}
</script>

4.2 方案二:事件总线更新

// utils/eventBus.js
import Vue from 'vue'
export default new Vue()

// ProductList.vue(缓存组件)
<script>
import eventBus from '@/utils/eventBus'

export default {
  created() {
    // 监听数据更新事件
    eventBus.$on('refresh-product-list'(params) => {
      if (this.filterParams.category !== params.category) {
        this.filterParams = { ...params }
        this.refreshData()
      }
    })
    
    // 监听强制刷新事件
    eventBus.$on('force-refresh'() => {
      this.refreshData()
    })
  },
  
  deactivated() {
    // 离开时移除事件监听,避免内存泄漏
    eventBus.$off('refresh-product-list')
    eventBus.$off('force-refresh')
  },
  
  methods: {
    handleSearch(params) {
      // 触发搜索时,通知其他组件
      eventBus.$emit('search-params-changed', params)
    }
  }
}
</script>

4.3 方案三:Vuex状态管理 + 监听

// store/modules/product.js
export default {
  state: {
    list: [],
    filterParams: {},
    lastFetchTimenull
  },
  
  mutations: {
    SET_PRODUCT_LIST(state, products) {
      state.list = products
      state.lastFetchTime = new Date().getTime()
    },
    
    UPDATE_FILTER_PARAMS(state, params) {
      state.filterParams = { ...state.filterParams, ...params }
    }
  },
  
  actions: {
    async fetchProducts({ commit, state }, forceRefresh = false) {
      // 如果不是强制刷新且数据在有效期内,则使用缓存
      const now = new Date().getTime()
      if (!forceRefresh && state.lastFetchTime && 
          (now - state.lastFetchTime) < 10 * 60 * 1000) {
        return
      }
      
      const response = await api.getProducts(state.filterParams)
      commit('SET_PRODUCT_LIST', response.data)
    }
  }
}

// ProductList.vue
<script>
import { mapState, mapActions } from 'vuex'

export default {
  computed: {
    ...mapState('product', ['list''filterParams'])
  },
  
  activated() {
    // 监听Vuex状态变化
    this.unwatch = this.$store.watch(
      (state) => state.product.filterParams,
      (newParams, oldParams) => {
        if (JSON.stringify(newParams) !== JSON.stringify(oldParams)) {
          this.fetchProducts()
        }
      }
    )
    
    // 检查是否需要更新
    this.checkAndUpdate()
  },
  
  deactivated() {
    // 取消监听
    if (this.unwatch) {
      this.unwatch()
    }
  },
  
  methods: {
    ...mapActions('product', ['fetchProducts']),
    
    checkAndUpdate() {
      const lastFetchTime = this.$store.state.product.lastFetchTime
      const now = new Date().getTime()
      
      if (!lastFetchTime || (now - lastFetchTime) > 10 * 60 * 1000) {
        this.fetchProducts()
      }
    },
    
    handleFilterChange(params) {
      this.$store.commit('product/UPDATE_FILTER_PARAMS', params)
    }
  }
}
</script>

五、实战:动态缓存管理

5.1 缓存管理器实现

<!-- components/CacheManager.vue -->
<template>
  <div class="cache-manager">
    <keep-alive :include="dynamicInclude">
      <router-view></router-view>
    </keep-alive>
  </div>
</template>

<script>
export default {
  name'CacheManager',
  
  data() {
    return {
      cachedViews: [], // 缓存的组件名列表
      maxCacheCount10 // 最大缓存数量
    }
  },
  
  computed: {
    dynamicInclude() {
      return this.cachedViews
    }
  },
  
  created() {
    this.initCache()
    
    // 监听路由变化
    this.$watch(
      () => this.$route,
      (to, from) => {
        this.addCache(to)
        this.manageCacheSize()
      },
      { immediatetrue }
    )
  },
  
  methods: {
    initCache() {
      // 从localStorage恢复缓存设置
      const savedCache = localStorage.getItem('vue-cache-views')
      if (savedCache) {
        this.cachedViews = JSON.parse(savedCache)
      }
    },
    
    addCache(route) {
      if (route.meta && route.meta.keepAlive && route.name) {
        const cacheName = this.getCacheName(route)
        
        if (!this.cachedViews.includes(cacheName)) {
          this.cachedViews.push(cacheName)
          this.saveCacheToStorage()
        }
      }
    },
    
    removeCache(routeName) {
      const index = this.cachedViews.indexOf(routeName)
      if (index > -1) {
        this.cachedViews.splice(index, 1)
        this.saveCacheToStorage()
      }
    },
    
    clearCache() {
      this.cachedViews = []
      this.saveCacheToStorage()
    },
    
    refreshCache(routeName) {
      // 刷新特定缓存
      this.removeCache(routeName)
      setTimeout(() => {
        this.addCache({ name: routeName, meta: { keepAlivetrue } })
      }, 0)
    },
    
    manageCacheSize() {
      // LRU(最近最少使用)缓存策略
      if (this.cachedViews.length > this.maxCacheCount) {
        this.cachedViews.shift() // 移除最旧的缓存
        this.saveCacheToStorage()
      }
    },
    
    getCacheName(route) {
      // 为动态路由生成唯一的缓存key
      if (route.params && route.params.id) {
        return `${route.name}-${route.params.id}`
      }
      return route.name
    },
    
    saveCacheToStorage() {
      localStorage.setItem('vue-cache-views'JSON.stringify(this.cachedViews))
    }
  }
}
</script>

5.2 缓存状态指示器

<!-- components/CacheIndicator.vue -->
<template>
  <div class="cache-indicator" v-if="showIndicator">
    <div class="cache-status">
      <span class="cache-icon">💾</span>
      <span class="cache-text">数据已缓存 {{ cacheTime }}</span>
      <button @click="refreshData" class="refresh-btn">刷新</button>
    </div>
  </div>
</template>

<script>
export default {
  props: {
    componentName: {
      typeString,
      requiredtrue
    }
  },
  
  data() {
    return {
      lastUpdatenull,
      showIndicatorfalse,
      updateIntervalnull
    }
  },
  
  computed: {
    cacheTime() {
      if (!this.lastUpdatereturn ''
      
      const now = new Date()
      const diff = Math.floor((now - this.lastUpdate) / 1000)
      
      if (diff < 60) {
        return `${diff}秒前`
      } else if (diff < 3600) {
        return `${Math.floor(diff / 60)}分钟前`
      } else {
        return `${Math.floor(diff / 3600)}小时前`
      }
    }
  },
  
  activated() {
    this.loadCacheTime()
    this.showIndicator = true
    this.startTimer()
  },
  
  deactivated() {
    this.showIndicator = false
    this.stopTimer()
  },
  
  methods: {
    loadCacheTime() {
      const cacheData = localStorage.getItem(`cache-${this.componentName}`)
      if (cacheData) {
        this.lastUpdate = new Date(JSON.parse(cacheData).timestamp)
      } else {
        this.lastUpdate = new Date()
        this.saveCacheTime()
      }
    },
    
    saveCacheTime() {
      const cacheData = {
        timestampnew Date().toISOString(),
        componentthis.componentName
      }
      localStorage.setItem(`cache-${this.componentName}`JSON.stringify(cacheData))
      this.lastUpdate = new Date()
    },
    
    refreshData() {
      this.$emit('refresh')
      this.saveCacheTime()
    },
    
    startTimer() {
      this.updateInterval = setInterval(() => {
        // 更新显示时间
      }, 60000// 每分钟更新一次显示
    },
    
    stopTimer() {
      if (this.updateInterval) {
        clearInterval(this.updateInterval)
      }
    }
  }
}
</script>

<style scoped>
.cache-indicator {
  position: fixed;
  bottom20px;
  right20px;
  backgroundrgba(0000.8);
  color: white;
  padding10px 15px;
  border-radius20px;
  font-size14px;
  z-index9999;
}

.cache-status {
  display: flex;
  align-items: center;
  gap8px;
}

.refresh-btn {
  background#4CAF50;
  color: white;
  border: none;
  padding4px 12px;
  border-radius4px;
  cursor: pointer;
  font-size12px;
}

.refresh-btn:hover {
  background#45a049;
}
</style>

六、性能优化与注意事项

6.1 内存管理建议

// 监控缓存组件数量
Vue.mixin({
  activated() {
    if (window.keepAliveInstances) {
      window.keepAliveInstances.add(this)
      console.log(`当前缓存组件数量: ${window.keepAliveInstances.size}`)
    }
  },
  
  deactivated() {
    if (window.keepAliveInstances) {
      window.keepAliveInstances.delete(this)
    }
  }
})

// 应用初始化时
window.keepAliveInstances = new Set()

6.2 缓存策略选择指南

场景 推荐方案 说明
列表页 → 详情页 → 返回列表 keep-alive + activated刷新 保持列表状态,返回时可选刷新
多标签页管理 动态include + LRU策略 避免内存泄漏,自动清理
实时数据展示 Vuex + 短时间缓存 保证数据实时性
复杂表单填写 keep-alive + 本地存储备份 防止数据丢失

6.3 常见问题与解决方案

问题1:缓存组件数据不更新

// 解决方案:强制刷新特定组件
this.$nextTick(() => {
  const cache = this.$vnode.parent.componentInstance.cache
  const keys = this.$vnode.parent.componentInstance.keys
  
  if (cache && keys) {
    const key = this.$vnode.key
    if (key != null) {
      delete cache[key]
      const index = keys.indexOf(key)
      if (index > -1) {
        keys.splice(index, 1)
      }
    }
  }
})

问题2:滚动位置保持

// 在路由配置中
{
  path'/list',
  componentListPage,
  meta: {
    keepAlivetrue,
    scrollToTopfalse // 不滚动到顶部
  }
}

// 在组件中
deactivated() {
  // 保存滚动位置
  this.scrollTop = document.documentElement.scrollTop || document.body.scrollTop
},

activated() {
  // 恢复滚动位置
  if (this.scrollTop) {
    window.scrollTo(0this.scrollTop)
  }
}

七、总结

Vue组件缓存是提升应用性能和用户体验的重要手段,但需要合理使用。关键点总结:

  1. 1. 合理选择缓存策略:根据业务场景选择适当的缓存方案
  2. 2. 注意内存管理:使用max属性限制缓存数量,实现LRU策略
  3. 3. 数据更新要灵活:结合activated钩子、事件总线、Vuex等多种方式
  4. 4. 监控缓存状态:实现缓存指示器,让用户了解数据状态
  5. 5. 提供刷新机制:始终给用户手动刷新的选择权

正确使用keep-alive和相关缓存技术,可以让你的Vue应用既保持流畅的用户体验,又能保证数据的准确性和实时性。记住,缓存不是目的,而是提升用户体验的手段,要根据实际业务需求灵活运用。

希望这篇详细的指南能帮助你在实际项目中更好地应用Vue组件缓存技术!

Java Map遍历的“优雅”合集

提起Java中Map的遍历,很多人第一反应还是: for (Map.Entry<K,V> entry : map.entrySet()) 。但其实Map遍历藏着多种玩法,有的优雅简洁,有的性能拉满,今天咱们盘一盘这些进阶偏基础的遍历方式,告别重复又臃肿的代码~

一、先搞懂:Map遍历的核心目标

遍历Map本质是获取「键(Key)」、「值(Value)」或「键值对(Entry)」,不同场景对应不同遍历方式,先上基础准备代码:

import java.util.HashMap;
import java.util.Map;

public class MapTraversalDemo {
    public static void main(String[] args) {
        Map<String, Integer> fruitPrice = new HashMap<>();
        fruitPrice.put("苹果", 10);
        fruitPrice.put("香蕉", 5);
        fruitPrice.put("橙子", 8);
        
        // 各种遍历方式写在这里~
    }
}

 

二、几种遍历方式的对比

1. 传统EntrySet遍历(最通用)

这是最基础也最常用的方式,支持同时获取键和值,兼容所有Java版本:

// 方式1:普通for循环+EntrySet
for (Map.Entry<String, Integer> entry : fruitPrice.entrySet()) {
    String key = entry.getKey();
    Integer value = entry.getValue();
    System.out.println(key + ":" + value + "元");
}
 

2. Lambda表达式遍历(Java 8+,极简)

Java 8引入的forEach+Lambda,一行代码搞定,告别冗余:

// 方式2:Lambda+forEach
fruitPrice.forEach((key, value) -> System.out.println(key + ":" + value + "元"));

 

3. 只遍历Key/Value(按需选择)

如果只需要键或值,不用遍历EntrySet,直接针对性获取:

// 只遍历Key
for (String key : fruitPrice.keySet()) {
    System.out.println("水果:" + key);
}

// 只遍历Value
for (Integer value : fruitPrice.values()) {
    System.out.println("价格:" + value + "元");
}

 

4. 迭代器遍历(支持删除元素)

如果遍历过程中需要删除元素,迭代器是安全选择(foreach遍历删除会抛异常):

// 方式4:迭代器遍历(支持删除)
Iterator<Map.Entry<String, Integer>> iterator = fruitPrice.entrySet().iterator();
while (iterator.hasNext()) {
    Map.Entry<String, Integer> entry = iterator.next();
    if (entry.getValue() < 8) {
        iterator.remove(); // 安全删除价格低于8的水果
    }
}
System.out.println("删除后的Map:" + fruitPrice);

 

三、避坑提醒

1. 遍历过程中修改Map(如put/remove):除了迭代器的remove方法,其他方式可能触发 ConcurrentModificationException ;

2. 性能优先级:EntrySet遍历 > 分别遍历Key+getValue(后者会重复查询Map);

3. Lambda遍历虽然简洁,但无法在内部使用break/continue终止遍历。

React Server Components 再曝高危漏洞:拒绝服务与源码泄露接踵而至

如果您的团队刚刚在几天前为了修复那个满分核弹级漏洞 React2Shell (CVE-2025-55182) 而熬夜加班,那么现在可能需要请大家再喝一杯咖啡了。

就在安全社区深入研究上周的 RCE(远程代码执行)漏洞补丁时,剧情出现了反转——研究人员在防御工事中发现了新的裂缝。React 官方和 Next.js 团队刚刚确认了三个新的 CVE 编号,虽然这次不是直接的 RCE,但它们依然能让您的服务器“罢工”或者泄露敏感代码。

别慌,将用最简单的大白话带您看懂发生了什么,以及为什么您之前打的补丁可能已经失效了

image.png


一、新威胁登场:不仅是崩溃,还有泄密

这次的主角是两个新发现的漏洞,它们都潜伏在 React Server Components (RSC) 处理网络请求的机制中:

🔥 漏洞一:让服务器“陷入沉思”的 DoS 攻击 (CVE-2025-55184)

  • 严重等级: 高危 (CVSS 7.5)
  • 这是什么? 这是一个“拒绝服务”(Denial of Service)漏洞。
  • 通俗解释: 想象一下,攻击者给您的服务器发了一个特制的“订单”(HTTP 请求)。这个订单里包含了一个死循环逻辑(比如“A依懒于B,B又依懒于A”的循环 Promise 引用)。
  • 后果: 服务器在试图解析这个订单时,会陷入无限循环的深渊。这会导致服务器进程挂起,CPU 狂转,但就是不干活。即使您的应用没有显式使用 Server Functions,只要开启了 RSC 支持,就可能中招。结果就是:真实用户的请求进不来,您的服务相当于“宕机”了

🕵️ 漏洞二:把代码“吐出来”的源码泄露 (CVE-2025-55183)

  • 严重等级: 中危 (CVSS 5.3)
  • 这是什么? 这是一个信息泄露漏洞。
  • 通俗解释: 攻击者通过特定的请求,诱骗服务器将某个 Server Function(服务器端函数)误认为是一个字符串。
  • 后果: React 会“好心”地把这个函数的完整源代码作为字符串返回给攻击者。如果您的代码里硬编码了 API Key、数据库密码或者复杂的业务逻辑,这些秘密就会被攻击者一览无余。

image.png


二、攻击原理

这些 React Server Components (RSC) 漏洞(CVE-2025-55184, CVE-2025-55183, CVE-2025-67779)的触发机制主要集中在 React Flight 协议 的反序列化处理逻辑上。攻击者不需要身份验证,只需向服务器发送特制的 HTTP 请求即可触发。

以下是针对不同漏洞的具体触发原理:

1. 触发拒绝服务 (DoS)

涉及漏洞: CVE-2025-55184, CVE-2025-67779 核心机制: 循环 Promise 引用 (Cyclical Promise References)

  • 触发方式: 攻击者构造一个恶意的 RSC Flight Payload(通常包含在 HTTP POST 请求中),发送给任意 Server Function 端点(或者支持 RSC 的应用入口)。
  • Payload 特征: 这个 Payload 内部包含精心设计的 循环 Promise 引用(Cyclical Promise references)。
  • 服务器反应: 当 React 服务器尝试反序列化这个 Payload 时,运行时会陷入解析嵌套 Promise 的 无限递归死循环
  • 后果: 由于 Node.js 通常是单线程事件循环,这个死循环会直接导致服务器进程挂起(Hang),CPU 占用率飙升,无法处理后续的任何请求,从而造成拒绝服务。
  • 补丁绕过 (CVE-2025-67779): 之前的补丁未能完全覆盖所有类型的循环引用 Payload,因此攻击者可以调整 Payload 结构绕过第一轮修复,再次触发死循环。
graph TD
    A[攻击者发送恶意请求] --> B[Payload包含循环Promise引用]
    B --> C{React服务器反序列化}
    C --> D[进入无限递归/死循环]
    D --> E[CPU占用率飙升至100%]
    E --> F[服务器进程挂起]
    F --> G[服务完全不可用]

2. 触发源代码泄露 (Source Code Exposure)

涉及漏洞: CVE-2025-55183 核心机制: 字符串强制转换 (String Coercion)

  • 触发方式: 攻击者发送一个恶意的 HTTP 请求,将某个参数伪造成指向 另一个 Server Function 的引用

  • 触发条件: 目标 Server Function 必须包含将传入参数 转换为字符串 的操作(显式或隐式)。例如,代码中使用了模板字符串 `Hello ${name}`,或者将参数传递给需要字符串的 API(如数据库查询)。

  • 执行流程:

    1. 攻击者发送请求,将参数(如 name)的值设为一个指向服务器端函数(Server Function)的引用对象。
    2. 服务器代码执行时,试图将该参数“字符串化”(调用 .toString())。
    3. 由于漏洞存在,React 不安全地返回了该函数的 完整源代码 字符串,而不是一个普通的对象标识。
  • 泄露内容: 攻击者可以在 HTTP 响应中直接看到函数的源代码。如果代码中包含硬编码的密钥(如 db.connect('SECRET_KEY')),这些敏感信息就会被窃取。

graph LR
    A[攻击者构造恶意参数] --> B[参数伪装成Server Function引用]
    B --> C[服务器执行字符串转换]
    C --> D{React不安全处理}
    D --> E[返回函数完整源代码]
    E --> F[泄露API密钥/数据库密码]
    E --> G[泄露业务逻辑代码]

3. 攻击入口与通用前提

  • 无需登录: 这些攻击是预认证的(Pre-authentication),攻击者不需要登录即可发起。
  • HTTP 请求: 攻击通过向 Server Function 端点发送 HTTP 请求实现。即便应用没有显式使用 Server Function,只要开启了 RSC 支持,框架(如 Next.js App Router)默认暴露的端点也可能成为攻击入口。
  • 协议层漏洞: 问题出在底层的 react-server 包处理 Flight 协议数据流的方式上,这意味着使用该协议的多种框架(Next.js, Waku, Hydrogen 等)都可能受到波及。

三、关键反转:为什么说您的补丁可能“白打了”?

这才是本次事件中最令人头疼的地方。

在修复 DoS 漏洞 (CVE-2025-55184) 的过程中,官方发布了第一版补丁(例如 React 19.0.2)。但安全研究人员很快发现,这个修复不完整!攻击者依然可以绕过这个补丁让服务器挂起。

这就是第三个漏洞 CVE-2025-67779 的由来。

⚠️ 划重点: 如果您在本周早些时候更新到了 React 19.0.2、19.1.3 或 19.2.2您依然是脆弱的! 您需要再次升级。

graph TB
    A[第一轮修复 CVE-2025-55184] --> B[修复特定类型循环引用]
    B --> C[攻击者调整Payload结构]
    C --> D[发现新的循环引用模式]
    D --> E[绕过第一轮补丁]
    E --> F[需要第二轮修复 CVE-2025-67779]

四、我受影响了吗?(自查清单)

如果您的项目使用了以下技术栈,请立即自查:

  • React: 版本 19.0.0 到 19.2.2 之间的所有版本。
  • Next.js (App Router): 版本 13.x 到 16.x。尤其是使用了 App Router 的项目。
  • 其他框架: 任何使用了 react-server-dom-webpack/parcel/turbopack 的框架,如 Waku, RedwoodJS, React Router 等。

注意: 如果您的 React 代码只在客户端运行(不涉及 Server Components),或者使用的是老版本的 Next.js (Pages Router),恭喜您,这波风暴由于您“由于技术栈不够新”而完美避开。


五、立即行动:终极修复方案

不要犹豫,立即升级到以下“最终安全版本”。请跳过中间的过渡版本,直接升到最新!

React 核心包升级指南:

您当前的版本系列 ❌ 依然脆弱的版本 ✅ 必须升级到的安全版本
19.0.x 19.0.0 - 19.0.2 19.0.3 (及以上)
19.1.x 19.1.0 - 19.1.3 19.1.4 (及以上)
19.2.x 19.2.0 - 19.2.2 19.2.3 (及以上)

Next.js 用户升级指南:

请运行以下命令将 Next.js 升级到最新的补丁版本:

  • Next.js 15.x: 升级至 15.0.7+, 15.1.11+, 15.2.8+, 15.3.8+, 15.4.10+
  • Next.js 14.x: 升级至 14.2.35+
  • Next.js 16.x (Canary): 升级至 16.0.10+
# 一键升级到最新安全版本
npm install next@latest react@latest react-dom@latest

# 或指定确切版本
npm install next@15.0.7 react@19.0.3 react-dom@19.0.3

六、写在最后:给开发者的建议

由于利用门槛极低且已有自动化扫描工具出现,依靠 WAF(Web应用防火墙)只能作为临时缓解措施,唯一彻底的修复方法是立即升级 React 和 Next.js 到最新的安全版本(如 React 19.0.3+, Next.js 15.0.7+ 等)。

这次的一连串漏洞(从 RCE 到 DoS 再到 Info Leak)提醒我们,React Server Components (RSC) 极大地简化了前后端的数据交互,但同时也模糊了信任边界

  • 不要硬编码密钥: 即使补丁打好了,也永远不要在代码里直接写 const API_KEY = "sk-..."。请务必使用环境变量(process.env),因为运行时注入的变量通常不会因源码泄露而被直接读取。
  • 保持警惕: 新技术栈往往伴随着新的攻击面。关注官方公告,及时响应。

现在,去检查您的 package.json 吧,祝大家部署顺利,服务常青!


参考资料:

  1. React Blog: Denial of Service and Source Code Exposure in React Server Components
  2. Vercel Security Bulletin: CVE-2025-55184 and CVE-2025-55183
  3. NVD - CVE-2025-55184 Detail
  4. The Hacker News: New React RSC Vulnerabilities Enable DoS and Source Code Exposure

漫谈 JS 解析与作用域锁定

这部分内容,学了当然最好,没学,也不影响前端开发。当然,能了解肯定是比不了解的强。

依旧是无图无码,网文风格。我觉得,能用文字把逻辑或者概念表述清楚,一是对作者本身的能力提升有好处,二是对读者来说 思考文字表达的内容 有助于多使用抽象思维和逻辑思维能力,构建自己的思考模式,用现在流行的说法 就是心智模型。你自己什么都可以脑补,那不是厉害大了嘛。

上面的话不要相信,其实我就是为自己懒找的借口。

因为标题就说了 是漫谈,所以有些细节做了省略 有些边界情况做了简化表述。但是总体来说 准确性还是可以的。如果有错漏的地方,还请多多指正。 这是第一部分 词法和语法分析。

一.词法分析和语法分析

当浏览器从网络下载了js文件,比如app.js,浏览器引擎拿到的最初形态是一串**字节流 **。

  1. 识别: V8 首先要处理编码,V8 接收的是 UTF-8 编码的字节流,内部会转换为 UTF-16 处理字符串。

  2. 流式快速处理: 引擎并不是等整个文件下载完才开始干活的。只要网络传过来一段数据,V8 的扫描器就开始工作了。 这样可以加快启动速度。此时的状态就是毫无意义的字符 c, o, n, s, t, , a, , =, , 1, ; ...

  3. 然后的这一步叫 Tokenization 词语切分。 负责这一步的组件就是上面提到的叫 Scanner(扫描器)。它的工作就像是一个切菜工,把滔滔不绝连绵不断的字符串切成一个个有语法意义的最小单位,叫做 Token(记号)。看到这个词 ,大家是不是惊觉心一缩,没错,就是它,它们就是以它为单位来收咱钱的。

    scanner 内部是一个状态机。它逐个读取字符:

    • 读到 c 可能是 const,也可能是变量名,继续。
    • 读到 o, n, s, t 凑齐了5个娃,且下一个字符不是字母(比如是空格),确认这是一个关键字 const。”(防止误判 constant 这种变量名)
    • 读到 空格 忽略,跳过去。
    • 读到 1 这是一个数字。

    这样就由原来的字节流变成了 Token 流。这是一种扁平的列表结构。

    • 源码: const a = 1;
    • Token 流:
      • CONST (关键字)
      • IDENTIFIER (值为 "a")
      • ASSIGN (符号 "=")
      • SMI (小整数 "1")
      • SEMICOLON (符号 ";")

    这一步,注释和多余的空格和换行符会被抛弃。

  4. 现在就是解析阶段了

    其实解析是一个总称,它分为 全量解析 和 预解析 两种形式。

    这就是v8的懒解析机制。看到这个懒字,也差不多能明白了吧。

    对于那些不是立即执行的函数(比如点击按钮才触发的回调),V8 会先用预解析快速扫一遍。

    检查基本的语法错误(比如有没有少写括号),确认这是一个函数。并不会生成复杂的 AST 结构,也不建立具体的变量绑定,只进行最基础的闭包引用检查。御姐喜的结果是这个函数在内存里只是一个很小的占位符,跳过内部细节。

    而只有那些立即执行函数或者顶层代码,才会进入真正的全量解析,进行完整的 AST 构建。

    那么,问题就来了,v8怎么判断到底是使用预解析还是使用全量解析呢?

    它的原则就是 懒惰为主 全量为辅

    就是v8默认你写的函数暂时不会执行,除非是已经显式的通过语法告诉它,这段这行代码 马上就要跑 你赶快全量解析。

    下面 我们稍微详细的说一下

    • 默认绝大多数函数都是预解析

      v8认为js在初始运行时,仅仅只有很少很少一部分代码 是需要马上使用的 其他觉得大部分 都是要么是回调 要么是其他的暂时用不到的,所以,凡是具名函数声明、嵌套函数,默认都是预解析。

      function clickHandler() {
        console.log("要不要解析我");
      }
      // 引擎认为 这是一个函数声明  看起来还没人调勇它
      // 先不浪费时间了,只检查一下括号匹配吧,
      // 把它标记为 'uncompiled',然后跳过。"
      
    • 那么 如何才能符合它进行全量解析的条件呢

      1. 顶层代码

        写在最外层 不在任何函数内 的代码,加载完必须立即执行。

        判断依据: 只要不在 function 块里的代码,全是顶层代码,必须全量解析。

      2. 立即执行函数

        那么这里有个问题,就是V8 如何在还没运行代码时,就知道这个函数是立即调用执行函数呢?

        答案就是 看括号()

        当解析器扫描到一个函数关键字 function 时,它会看一眼这个 function 之前有没有左括号 (

        • 没括号

          function foo() { ... }
          // 没看到左括号,那你先靠边吧, 对它预解析。
          
        • 有括号

          (function() { ... })();
          // 扫描器扫到了这个左括号
          // 欸,这有个左括号包着 function
          // 根据万年经验,这是个立即执行函数,马上就要执行。
          // 直接上大菜,全量解析,生成 AST
          
        • 其他的立即执行的迹象:除了括号,!+- 等一元运算符放在 function 前面,也会触发全量解析

          !function() { ... }(); // 全量解析
          
    • 如果有嵌套函数咋办呢

      嵌套函数默认是预解析,即使外部函数进行的是全量解析,它内部定义的子函数,默认依然是预解析。只有当子函数真的被调用时,V8 才会暂停执行,去把子函数的全量解析做完 把 AST 补齐

      //顶层代码全量解析
      (function outer() {
        var a = 1;
      
        // 内部函数 inner:
        // 虽然 outer 正在执行,但 inner 还没被调用
        // 引擎也不确定 inner 会不会被调用。
        // 所以inner 默认预解析。
        function inner() {
          var b = 2;
        }
      
        inner(); // 直到执行到这一行,引擎才会回头去对 inner 进行全量解析
      })();
      
    • 那么 引擎根据自己的判断 进行全量解析或者预解析,会出错吗

      当然会,

      如果是本该预解析的 结果判断错了 进行了全量解析 浪费了时间和内存生成了 AST 和字节码,结果这代码根本没跑。

      如果是本该全量解析的又巨又大又重的函数 结果判断错了 进行了预解析,然后马上下一行代码就调用了,结果就是 白白预解析了一遍,浪费了时间,发现马上被调用,又马上回头全量解析一边 又花了时间,两次的花费。

  5. 在上面只是讲了解析阶段的预解析和全量解析的不同,现在我们讲解析阶段的过程

    V8 使用的是递归下降分析法。它根据js 的语法规则来匹配 Token。

    它的规则类似于:当我们遇到 const,根据语法规则,后面必须跟一个变量名,然后是一个赋值号,然后是一个表达式。

    过程示例:

    看到 const 创建一个变量声明节点。

    看到 a 把它作为声明的标识符

    看到 = 知道后面是初始值

    看到 1 创建一个字面量节点,挂在 = 的右边。

    而在这个阶段的同时,作用域分析也在同步进行,因为在构建 AST 的过程中,解析器必须要搞清楚变量在哪里

    它会盘算 这个 a 是全局变量,还是函数内的局部变量?

    如果当前函数内部引用了外层的变量,解析器会在这个阶段打上标记:“要小心,这个变量被逮住了,将来可能需要上下文来分配”。

    这个作用域分析比较重要,我们用稍微大点的篇幅来讲讲。

    首先 强烈建议 不要再去用以前的 活动对象AO vo 等等的说法来思考问题。应该使用现在的词法作用域 环境记录 等等思考模型。

    词法作用域 (Lexical Scoping)” 的定义:作用域是由代码书写的位置决定的,而不是由调用位置决定的。

    这说明,引擎在还没开始执行代码,仅仅通过“扫描”源代码生成 AST 的阶段,就已经把“谁能访问谁”、“谁被谁逮住”这笔账算得清清楚楚了。

    一旦AST被生成,那么至少意味着下面的情况

    作用域层级被确定

    AST 本身的树状结构,就是作用域层级的物理体现。

    • AST 节点: 当解析器遇到一个 function 关键字,它会在 AST 上生成一个 FunctionLiteral 节点。

    • Scope 对象: 在 V8 内部,随着 AST 的生成,解析器会同时维护一棵 “作用域树”

      • 每进入一个函数,V8 就会创建一个新的 Scope 对象。
      • 这个 Scope 对象会有一个指针指向它的 Outer Scope父作用域。
    • 结果: 这种“父子关系”是静态锁定的。无论你将来在哪里调用这个函数,它的“父级”永远是定义时的那个作用域。

    变量引用关系被识别

    这是解析器最忙碌的工作之一,叫做 变量解析

    • 声明: 当解析器遇到 let a = 1,它会在当前 Scope 记录:“我有了一个叫 a 的变量”。
    • 引用: 当解析器遇到 console.log(a) 时,它会生成一个 变量代理
    • 链接过程: 解析器会尝试“连接”这个代理和声明:
      1. 先在当前 Scope 找 a
      2. 找不到?沿着 Scope Tree 往上找父作用域。
      3. 找到了?建立绑定。
      4. 一直到了全局还没找到?标记为全局变量(或者报错)。

    这里要注意: 这个“找”的过程是在编译阶段完成的逻辑推导。

    闭包的蓝图被预判

    这一步是 V8 性能优化的关键,也就是作用域分析。

    • 发现闭包: 解析器发现内部函数 inner 引用了外部函数 outer 的变量 x
    • 打个大标签:
      • 解析器会给 x 打上一个标签:“强制上下文分配”
      • 意思是:“虽然 x 是局部变量,但因为有人跨作用域引用它,所以它不能住在普通的栈(Stack)上了... 必须搬家,住到堆(Heap)里专门开辟的 Context(上下文对象) 中去。”
    • 还没有实例化:
      • 此时内存里没有上下文对象,也没有变量 x 的值(那是运行时的事)。
      • AST 只是生成了一张**“蓝图”**,图纸上写着:“注意,将来运行的时候,这个 x 要放在特别的地方 - Context里,别放在栈上。”

下面就是解释器Ignition该登场了。我们第二部分再见。

企业级 Vue 3 项目图标系统重构实践:从多源混乱到单一数据源

日期: 2025-12-12
技术栈: Vue 3 + TypeScript + iconfont

前言

在大型前端项目中,图标管理是一个看似简单却容易失控的问题。随着业务迭代,往往会出现多套图标方案并存的情况:有人用 SVG 文件,有人用 iconfont,有人直接用图片……这不仅增加了维护成本,还容易导致图标风格不统一、打包体积膨胀等问题。

本文将分享我们在 CMC Link IBS Web 项目中进行的一次图标系统重构实践,核心目标是:统一图标来源,降低维护成本,提升开发体验

一、问题诊断:混乱的图标现状

1.1 现状分析

重构前,项目中存在两套并行的图标方案:

图标来源
├── iconfont(阿里图标库)
│   ├── iconfont.cssFont Class 模式
│   └── iconfont.js      → Symbol 模式(支持彩色)
│
└── 本地 SVG 图标
    └── src/assets/icons/ → 150+ 个 SVG 文件
        └── vite-plugin-svg-spritemap 处理

1.2 痛点总结

问题 影响
双重维护 新增图标需要决定放哪里,老员工用 SVG,新员工用 iconfont
处理逻辑复杂 SVG 需要 SVGO 插件处理 fill/stroke/width/height 属性
彩色图标识别困难 需要通过文件名约定(c- 前缀)或内容分析来判断
构建依赖 额外引入 @spiriit/vite-plugin-svg-spritemap 依赖
心智负担 开发者需要了解两套方案的差异和适用场景

1.3 核心矛盾

开发效率 vs 技术债务
    ↓
每次新增图标都在累积技术债
    ↓
维护成本随项目规模线性增长

二、方案设计:单一数据源架构

2.1 设计原则

  1. 单一数据源:所有图标统一从 iconfont 获取
  2. 向后兼容:现有代码无需修改即可工作
  3. 渐进迁移:支持新旧写法并存,逐步过渡
  4. 开发体验优先:新增图标流程简化

2.2 架构设计

┌─────────────────────────────────────────────────┐
│                  使用层                          │
│  <SvgIcon name="search" />  (旧代码,无需修改)    │
│  <CmcIcon name="icon-search" /> (新代码)         │
└──────────────────┬──────────────────────────────┘
                   │
┌──────────────────▼──────────────────────────────┐
│              SvgIcon (兼容层)                    │
│  - 接收旧的 name/icon 属性                       │
│  - 通过映射表转换为 iconfont 名称                 │
│  - 自动判断彩色/单色                             │
│  - 内部渲染 CmcIcon                             │
└──────────────────┬──────────────────────────────┘
                   │
┌──────────────────▼──────────────────────────────┐
│              CmcIcon (核心组件)                  │
│  - Font Class 模式(单色,可改颜色)              │
│  - Symbol 模式(彩色,保留原色)                  │
└──────────────────┬──────────────────────────────┘
                   │
┌──────────────────▼──────────────────────────────┐
│           iconfont 资源 (单一数据源)             │
│  - iconfont.css (Font Class)                    │
│  - iconfont.js (Symbol)                         │
└─────────────────────────────────────────────────┘

2.3 关键设计决策

决策1:为什么选择 iconfont 作为单一数据源?

方案 优势 劣势
本地 SVG 完全可控、离线可用 需要构建处理、维护成本高
iconfont 在线管理、团队协作、支持彩色 依赖外部服务

选择 iconfont 的原因:

  • 已有成熟的图标库(400+ 图标)
  • 支持 Symbol 模式(彩色图标)
  • 团队协作友好(设计师可直接上传)
  • 无需额外构建插件

决策2:兼容层设计

不破坏现有代码是重构的底线。通过代理模式,让旧的 SvgIcon 组件内部调用新的 CmcIcon

<!-- SvgIcon.vue - 兼容层 -->
<script setup lang="ts">
import CmcIcon from '../CmcIcon/CmcIcon.vue'
import { getIconfontName, isColorfulIcon } from '../CmcIcon/icon-mapping'

// ... props 定义

const iconfontName = computed(() => getIconfontName(rawIconName.value))
const colorful = computed(() => isColorfulIcon(rawIconName.value))
</script>

<template>
  <CmcIcon
    :name="iconfontName"
    :size="size"
    :color="color"
    :colorful="colorful"
  />
</template>

决策3:映射表策略

对于名称不一致的情况,通过映射表解决:

// icon-mapping.ts
export const SVG_TO_ICONFONT_MAP: Record<string, string> = {
  'dingcangicon': 'icon-menu-dingcang',
  'billoflading': 'icon-menu-tidan',
  // ...
}

export const COLORFUL_ICONS = new Set([
  'menu-chukou',
  'menu-jinkou',
  'USD', 'CNY', 'EUR',
  // ...
])

三、实现细节

3.1 CmcIcon 核心组件

<script lang="ts" setup>
interface Props {
  name: string           // 图标名称(需带 icon- 前缀)
  size?: number | string // 尺寸,默认 16px
  color?: string         // 颜色(仅单色有效)
  colorful?: boolean     // 是否为彩色图标
}

const props = withDefaults(defineProps<Props>(), {
  size: 16,
  color: 'currentColor',
  colorful: false,
})
</script>

<template>
  <!-- 彩色图标:Symbol 模式 -->
  <svg v-if="colorful" class="cmc-icon" :style="{ width: sizeValue, height: sizeValue }">
    <use :xlink:href="`#${iconName}`" />
  </svg>

  <!-- 单色图标:Font Class 模式 -->
  <i v-else class="cmc-icon iconfont-cmc" :class="iconName" :style="{ fontSize: sizeValue, color }" />
</template>

3.2 iconfont 的两种模式

Font Class 模式(单色图标):

  • 通过 CSS 类名引用图标
  • 支持 color 属性动态改变颜色
  • 文件:iconfont.css

Symbol 模式(彩色图标):

  • 通过 SVG <use> 引用
  • 保留图标原始颜色
  • 文件:iconfont.js
<!-- 单色:可通过 color 控制颜色 -->
<CmcIcon name="icon-search" color="red" />

<!-- 彩色:保留原始多色 -->
<CmcIcon name="icon-menu-chukou" colorful />

3.3 清理冗余代码

移除了不再需要的构建配置:

// build/plugins.ts
- import VitePluginSVGSpritemap from '@spiriit/vite-plugin-svg-spritemap'

export function createVitePlugins() {
  return [
    // ...其他插件
-   createSvgIconsPlugin(),  // 移除 SVG 处理插件
  ]
}

- // 移除 138 行 SVG 处理代码
- function createSvgIconsPlugin() { ... }
- function processMonoIcon() { ... }
- function processRootIcon() { ... }
- function traverseSvgNodes() { ... }

四、收益分析

4.1 量化收益

指标 重构前 重构后 变化
图标来源 2 套 1 套 -50%
构建依赖 +1 0 -100%
plugins.ts 代码行数 241 103 -57%
新增图标步骤 5 步 3 步 -40%

4.2 定性收益

  1. 降低心智负担:开发者只需了解一套方案
  2. 简化新增流程:上传 iconfont → 更新资源 → 使用
  3. 减少构建时间:移除 SVGO 处理环节
  4. 代码更简洁:核心组件 < 100 行

4.3 新增图标流程对比

重构前(SVG 方案):

  1. 获取 SVG 文件
  2. 判断是单色还是彩色
  3. 如果单色,手动处理 fill/stroke 属性
  4. 放入对应目录(mono/ 或 colorful/)
  5. 使用 <SvgIcon name="xxx" />

重构后(iconfont 方案):

  1. 上传到 iconfont 项目
  2. 下载更新资源文件
  3. 使用 <CmcIcon name="icon-xxx" />

五、经验总结

5.1 重构原则

  1. 向后兼容是底线:通过兼容层保证现有代码正常工作
  2. 渐进式迁移:新代码用新方案,旧代码按需迁移
  3. 单一数据源:避免多源并存的混乱
  4. 简化优于完美:够用就好,不过度设计

5.2 技术选型思考

选择 iconfont 而非自建 SVG 方案的核心原因:

  • 团队协作:设计师可直接在 iconfont 管理图标
  • 成本效益:利用现有成熟方案,避免重复造轮子
  • 彩色支持:Symbol 模式原生支持多色图标

5.3 适用场景

本方案适合:

  • 已在使用 iconfont 的项目
  • 团队规模中等以上,需要设计师协作
  • 图标更新频繁的业务系统

不太适合:

  • 对离线可用性要求极高的场景
  • 图标需要复杂动画的场景
  • 完全私有化部署、无法访问外网的环境

六、后续优化方向

  1. 自动化更新:编写脚本自动从 iconfont 拉取最新资源
  2. 类型安全:生成图标名称的 TypeScript 类型定义
  3. 按需加载:对于大型图标库,考虑按需加载策略
  4. 文档自动化:从 iconfont.json 自动生成图标文档

结语

图标系统看似是个小问题,但在大型项目中却能显著影响开发效率和代码质量。这次重构的核心思路是:识别技术债务 → 设计兼容方案 → 统一数据源 → 渐进式迁移

希望本文的实践经验能为你的项目提供一些参考。记住,最好的架构不是最复杂的,而是最适合团队的。


本文基于公司项目的真实重构实践整理,如有问题欢迎讨论。

CDN 技术深度解析

序言

从 Q3 开始公司如火如荼开展了号称战略级的项目《车商城》,该项目横跨了 5 个 BU 相互配合,由于我所在的组属于前端中台架构组,理所当然的一些中台部分工作就落在了我们这边,比如支付,结算,通用订单详情等,刚好我之前就是负责的支付侧,在这个项目里我也首当其冲的冲在了最前线。

好了,说了一段废话,讲一下为什么要写这篇文章吧,在项目初期的设计,为了提升用户体验,完成秒开率等指标,把静态资源 CDN 加速引入到了项目架构中来,项目落地过程中在公司搭建的 CDN 服务的使用上也遇到了一些问题,随着项目逐步上线,就把该部分给总结一下给团队分享一下技术心得。(PS: 已隐去一些内部敏感部分)

一、CDN 核心概念与价值

1.1 什么是 CDN?

CDN(Content Delivery Network),即内容分发网络,是通过在现有互联网基础上构建的一层智能虚拟网络,将源站内容分发至全球各地的边缘节点,使用户能够就近获取所需内容。

1.2 为什么需要 CDN?

传统访问模式痛点:
用户 → 长途网络传输 → 源站服务器
      ↓
问题:延迟高、拥塞、单点故障
      
CDN访问模式:
用户 → 最近 CDN 节点(通常<50ms)
      ↓
优势:快速、稳定、可扩展

1.3 CDN 的核心价值

维度 收益 量化指标
性能 访问速度提升 50%-70% 首屏加载时间↓
成本 节省源站带宽 40%-60% 带宽费用↓
可用性 可达 99.99% SLA 提升
安全 DDoS 防护能力增强 攻击拦截率↑

 

二、CDN 核心工作原理

2.1 完整请求流程图解

请求流程.png  

2.2 DNS智能调度机制

用户请求流程:
1. 用户访问 www.example.com
2. 本地 DNS → 权威 DNS(CNAME 指向 CDN 厂商)
3. CDN 的 DNS 基于以下因素决策:
   - 用户 IP 地理位置
   - 运营商线路质量
   - 节点健康状态
   - 实时负载情况
4. 返回最优边缘节点 IP

2.3 缓存层级架构

三层缓存体系:
边缘层(Edge) - 最接近用户,数量最多
区域层(Regional) - 省级/大区级节点
中心层(Core) - 全国/全球级核心节点


回源路径:边缘 → 区域 → 中心 → 源站

三、CDN 关键技术特性

3.1 加速性能优化

静态资源加速:

  • 图片、CSS、JS 等静态文件
  • 缓存命中率通常 > 95%
  • 支持 HTTP/2、QUIC 等新协议

动态内容加速:

  • 智能路由选择
  • TCP 优化(拥塞控制、窗口调整)
  • 链路优化(BGP Anycast)

3.2 安全防护体系

防护层次:
┌─────────────────┐
│   应用层防护    │ ← DDoS/CC 攻击防护
├─────────────────┤
│   协议层防护    │ ← TCP/UDP Flood 防护
├─────────────────┤
│   网络层防护    │ ← 带宽耗尽攻击防护
└─────────────────┘

3.3 跨地域/跨运营商优化

问题根源:
电信 → 联通 → 移动 → 教育网
  ↓      ↓      ↓       ↓
互通带宽有限,跨网延迟高


CDN 解决方案:
- 多线 BGP 接入
- 运营商深度合作
- 边缘节点覆盖所有运营商

四、CDN 缓存解析

4.1 缓存工作机制

缓存机制.png

4.2 缓存更新策略对比

策略 原理 适用场景 优缺点
被动更新 缓存失效时回源 更新不频繁的内容 简单,但有延迟
主动预热 提前推送至CDN 重要活动、新品发布 体验好,成本略高
版本化 URL URL 带版本号或哈希 静态资源更新 缓存控制精准
时间戳参数 URL 带时间戳参数 开发调试阶段 简单但缓存命中率低

 

4.3 CDN 预热详解

  1. 什么是预热?

预热是指主动将内容推送到 CDN 边缘节点,而不是等待用户首次访问时才回源拉取。

 

  1. 为什么需要预热?
首次访问问题:
用户A请求新资源 → CDN未缓存 → 回源拉取(慢)
预热解决:
提前推送资源到CDN → 所有用户都快速访问

3. 预热实施方式:

API 主动推送

# 使用 CDN 厂商 API 接口
curl -X POST "https://cdn-api.example.com/prefetch" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -d '{
    "urls": [
      "https://cdn.example.com/product/new.jpg",
      "https://cdn.example.com/static/v2.0/app.js"
    ]
  }'

 

  1. 预热 vs 刷新:
操作 目的 时机 影响
预热 提前填充缓存 内容发布前 改善首次访问体验
刷新 强制更新缓存 内容更新后 确保用户获取最新内容

 

五、CDN应用场景

5.1 场景化配置方案

电商网站方案:

静态资源:长期缓存(30天)
商品图片:版本化管理
活动页面:预热+短缓存(5分钟)
API接口:动态加速+适当缓存

5.2 监控与告警

关键监控指标:
1. 命中率(>95%为健康)
2. 平均响应时间(<100ms为优)
3. 带宽使用(异常突增需预警)
4. 错误率(5xx错误<0.1%)
5. 回源比例(<10%为佳)


告警策略:
- 实时告警:错误率突增
- 定时报告:每日命中率汇总
- 容量预警:带宽使用达80%

六、最佳实践

6.1 Nginx 优化

现代 Nginx 优化实践:架构、配置与性能调优: mp.weixin.qq.com/s/JvSx9rkCq…

6.2 之家使用的 CDN 供应商

百度 Age头标识边缘是否有缓存及缓存时长;存在表示命中边缘缓存,不存在表示边缘没有缓存,请求回了上级或者源站;Ohc-Global-Saved-Time头标识全局首次缓存时间,可以通过该响应头判断第一次缓存时间,如果该值和请求时间接近或相等则说明请求回源,格林威治标准时间;   < Age: 4< Ohc-Global-Saved-Time: Thu, 14 Sep 2023 07:55:28 GMT pic-b.autoimg.cn
华为 有“x-hcs-proxy-type”头部,值为“1”即命中缓存,值为“0”即未命中缓存,不再查看其它头部;   示例缓存命中头部如下x-hcs-proxy-type: 1  

6.4 百度 CDN 响应头解析

1. ohc-cache-hit: lf6ct205 [2], xaix205 [1]

含义:CDN缓存命中状态和路径追踪

详细解读:

  • lf6ct205 [2]:
    • lf6ct205:表示第一个响应节点(可能是边缘节点)的标识
    • [2]:缓存命中状态码,常见含义:
      • 0:MISS(未命中,回源)
      • 1:HIT(完全命中)
      • 2:PARTIAL_HIT/HIT_REVALIDATED(部分命中/验证后命中)
      • 这里[2]表示该节点可能进行了条件验证(If-Modified-Since/If-None-Match)后确认资源有效
  • xaix205 [1]:
    • xaix205:第二个响应节点(可能是父层或中间层节点)的标识
    • [1]:表示在该节点完全命中缓存

 

综合理解:请求经过了xaix205→lf6ct205两级CDN节点,两个节点都命中缓存,其中父节点完全命中,边缘节点经过验证后命中。

 

2. ohc-file-size: 2108

含义:CDN 缓存的文件大小

详细解读:

  • 单位:字节(Bytes)
  • 值:2108字节 ≈ 2.06KB
  • 表示CDN缓存的这个资源文件的实际大小
  • 用于监控和诊断,确认缓存的文件与源站文件大小是否一致

 

3. ohc-global-saved-time: Thu, 11 Dec 2025 09:17:19 GMT

含义:资源在 CDN 上首次缓存的时间戳

详细解读:

  • 格式:RFC 1123格式的GMT时间
  • 时间值:2025年12月11日 09:17:19(GMT)
  • 注意:这是一个未来时间,可能有以下情况:
    • 时间同步问题:源站或CDN服务器时间设置错误
    • 缓存策略配置:可能设置了很长的缓存时间(到2025年)
    • 调试/测试环境:可能是测试配置
  • 正常情况下应该是过去的时间点,表示资源何时被缓存

mysql

Problem: 3606. 优惠券校验器

[TOC]

思路

这题很明显是数据库题目,写个mysql好了:

先按照其 businessLine 的顺序排序:"electronics"、"grocery"、"pharmacy"、"restaurant"
上面的顺序刚好符合字典序,那就不需要case when 来编号再排序了

with t as (
    select "SAVE20" code,"restaurant" businessLine, true isActive union
    select "" code,"grocery" businessLine, true isActive union
    select "PHARMA5" code,"pharmacy" businessLine, true isActive union
    select "SAVE@20" code,"restaurant" businessLine, true isActive
)
select code from t
 where code REGEXP '^[a-zA-Z0-9_]+$' 
   and businessLine in ("electronics","grocery","pharmacy","restaurant")
   and isActive
 order by businessLine,1

分组 + 排序(Python/Java/C++/Go)

技巧:

  1. 用一个哈希表保存类别到类别编号($0,1,2,3$)的映射,方便把答案分组,顺带可以判断类别是否合法(是否在哈希表中)。
  2. 创建四个列表,把相同类别的优惠码加到同一个列表中,这样我们只需对列表中的优惠码排序。

###py

BUSINESS_LINE_TO_CATEGORY = {
    "electronics": 0,
    "grocery": 1,
    "pharmacy": 2,
    "restaurant": 3,
}

class Solution:
    def validateCoupons(self, code: List[str], businessLine: List[str], isActive: List[bool]) -> List[str]:
        groups = [[] for _ in range(len(BUSINESS_LINE_TO_CATEGORY))]
        for s, bus, active in zip(code, businessLine, isActive):
            category = BUSINESS_LINE_TO_CATEGORY.get(bus, -1)
            if s and category >= 0 and active and \
               all(c == '_' or c.isalnum() for c in s):
                groups[category].append(s)  # 相同类别的优惠码分到同一组

        ans = []
        for g in groups:
            g.sort()  # 每一组内部排序
            ans += g
        return ans

###java

class Solution {
    private static final Map<String, Integer> BUSINESS_LINE_TO_CATEGORY = Map.of(
        "electronics", 0,
        "grocery", 1,
        "pharmacy", 2,
        "restaurant", 3
    );

    public List<String> validateCoupons(String[] code, String[] businessLine, boolean[] isActive) {
        List<String>[] groups = new ArrayList[BUSINESS_LINE_TO_CATEGORY.size()];
        Arrays.setAll(groups, _ -> new ArrayList<>());
        for (int i = 0; i < code.length; i++) {
            String s = code[i];
            Integer category = BUSINESS_LINE_TO_CATEGORY.get(businessLine[i]);
            if (category != null && isActive[i] && isValid(s)) {
                groups[category].add(s); // 相同类别的优惠码分到同一组
            }
        }

        List<String> ans = new ArrayList<>();
        for (List<String> g : groups) {
            Collections.sort(g); // 每一组内部排序
            ans.addAll(g);
        }
        return ans;
    }

    // 检查字符串是否非空,只包含字母、数字和下划线
    private boolean isValid(String s) {
        for (char c : s.toCharArray()) {
            if (c != '_' && !Character.isLetterOrDigit(c)) {
                return false;
            }
        }
        return !s.isEmpty();
    }
}

###cpp

unordered_map<string, int> BUSINESS_LINE_TO_CATEGORY = {
    {"electronics", 0},
    {"grocery", 1},
    {"pharmacy", 2},
    {"restaurant", 3},
};

class Solution {
    // 检查字符串是否非空,只包含字母、数字和下划线
    bool is_valid(const string& s) {
        for (char c : s) {
            if (c != '_' && !isalnum(c)) {
                return false;
            }
        }
        return !s.empty();
    }

public:
    vector<string> validateCoupons(vector<string>& code, vector<string>& businessLine, vector<bool>& isActive) {
        vector<string> groups[4];
        for (int i = 0; i < code.size(); i++) {
            string& s = code[i];
            auto it = BUSINESS_LINE_TO_CATEGORY.find(businessLine[i]);
            if (it != BUSINESS_LINE_TO_CATEGORY.end() && isActive[i] && is_valid(s)) {
                groups[it->second].push_back(s); // 相同类别的优惠码分到同一组
            }
        }

        vector<string> ans;
        for (auto& g : groups) {
            ranges::sort(g); // 每一组内部排序
            ans.insert(ans.end(), g.begin(), g.end());
        }
        return ans;
    }
};

###go

var businessLineToCategory = map[string]int{
"electronics": 0,
"grocery":     1,
"pharmacy":    2,
"restaurant":  3,
}

// 检查字符串是否非空,只包含字母、数字和下划线
func isValid(s string) bool {
for _, c := range s {
if c != '_' && !unicode.IsLetter(c) && !unicode.IsDigit(c) {
return false
}
}
return s != ""
}

func validateCoupons(code []string, businessLine []string, isActive []bool) (ans []string) {
groups := [4][]string{}
for i, s := range code {
category, ok := businessLineToCategory[businessLine[i]]
if ok && isActive[i] && isValid(s) {
groups[category] = append(groups[category], s) // 相同类别的优惠码分到同一组
}
}

for _, g := range groups {
slices.Sort(g) // 每一组内部排序
ans = append(ans, g...)
}
return
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(L\log n)$,其中 $n$ 是 $\textit{code}$ 的长度,$L$ 是 $\textit{code}[i]$ 的长度之和。瓶颈在排序上。
  • 空间复杂度:$\mathcal{O}(n)$ 或 $\mathcal{O}(L)$,取决于编程语言保存的是字符串的引用还是拷贝。

分类题单

如何科学刷题?

  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站@灵茶山艾府

告别字体闪烁 / 首屏卡顿!preload 让关键资源 “高优先级” 提前到

⚡️ 浏览器“未卜先知”的秘密:资源提示符,让你的页面加载速度快人一步!

前端性能优化专栏 - 第四篇

在前端性能优化的战场上,时间就是金钱,尤其是在页面加载的关键时刻。我们上一篇讲到 PerformanceObserver 可以精准地测量性能,但测量只是第一步,更重要的是主动出击,让浏览器在用户需要资源之前,就提前做好准备。

今天,我们就来揭秘浏览器“未卜先知”的秘密武器——资源提示符(Resource Hints)


💡 什么是资源提示符?

资源提示符(Resource Hints)是 <link> 标签 rel 属性的一组特殊值,用于告诉浏览器未来即将发生的资源处理策略,让它提前做准备

简单来说,它们是开发者给浏览器下达的“预处理指令”,让浏览器在空闲或关键时刻,提前完成一些耗时的网络操作,从而:

  • 提高网页的首屏加载性能
  • 减少 DNS、TCP、TLS 等连接延迟
  • 预加载关键或预测性资源
<!-- 资源提示符示例 -->
<link rel="preconnect" href="//cdn.example.com">

🔧 四大金刚:资源提示符的家族成员

资源提示符家族主要有四个核心成员,它们各有神通,针对不同的优化场景:

1. dns-prefetch:最小开销的“打听”

<link rel="dns-prefetch" href="//api.example.com">
  • 作用: 仅提前解析 DNS,将域名解析为 IP 地址,不建立连接

  • 开销: 最小,兼容性最好。

  • 使用场景:

    • 非关键的第三方资源(如分析脚本、广告、插件)。
    • 可作为 preconnect降级方案

专业名词解释:DNS 解析 DNS(Domain Name System)解析是将人类可读的域名(如 www.google.com)转换为机器可读的 IP 地址(如 142.250.190.14)的过程。这是一个网络请求的起点,通常需要几十到几百毫秒。

2. preconnect:提前握手的“老朋友”

<link rel="preconnect" href="//cdn.example.com" crossorigin>
  • 作用: 完成 DNS 解析 + TCP 握手 + TLS 加密握手,全流程建立连接。

  • 效果: 极大地消除了后续资源请求的网络延迟。

  • 使用时机:

    • 字体库核心 APICDN 静态资源关键第三方域名
    • 注意: 建立连接会消耗资源,建议控制数量(一般建议 ≤6 个)。

Preconnect 提前握手过程示意图

3. preload:高优先级的“快递”

<link rel="preload" href="font.woff2" as="font" type="font/woff2" crossorigin>
  • 作用: 直接以高优先级下载关键资源,但下载后暂不执行

  • 特点: 提前触发关键资源的加载,确保资源在需要时立即可用。

  • 常见场景:

    • CSS 定义的字体文件(避免文本闪烁 FOUT/FOIT)。
    • 背景图或 LCP 元素图片(加速最大内容绘制)。
    • 首屏必需的动态脚本

注意: preload 必须配合 as 属性指定资源类型,否则浏览器会重复下载。

4. prefetch:空闲时的“下一站”

<link rel="prefetch" href="next-page.js">
  • 作用:当前页加载完成后,利用浏览器空闲时间请求资源。

  • 特点: 优先级最低,不会与当前页面的关键资源竞争带宽。

  • 使用场景:

    • 优化“下一个页面”的加载体验
    • SPA 路由中,预取用户可能访问的下一个 chunk
    • 基于用户行为预测的预加载。

💡 总结:让资源“早一步”准备好

资源提示符家族的目标一致:让资源“早一步”准备好

它们的核心区别在于时机与深度

提示符 深度(提前到哪一步) 时机(何时触发) 优先级 适用场景
dns-prefetch 仅 DNS 解析 尽早 非关键第三方资源
preconnect DNS + TCP + TLS 尽早 关键第三方域名
preload 下载资源 尽早(高优先级) 当前页面的关键资源
prefetch 下载资源 页面空闲时 最低 下一个页面的资源

资源提示符概览图

重要提醒: 资源提示符虽好,但过度使用可能导致浪费带宽或建立过多连接,反而拖慢性能。请务必根据实际的性能数据(比如 RUM 采集的数据)来合理规划和使用。


下一篇预告: 既然资源都提前加载了,如何让它们在下次访问时更快出现呢?下一篇我们将深入探讨前端性能优化的“节流大师”——HTTP 缓存机制。敬请期待!

性能数据别再瞎轮询了!PerformanceObserver 异步捕获 LCP/CLS,不卡主线程

🚀 性能监控的“最强大脑”:PerformanceObserver API,如何让你告别轮询的噩梦?

前端性能优化专栏 - 第三篇

在上一篇中,我们聊到了 RUM(真实用户监控)是如何帮助我们打破“薛定谔的 Bug”魔咒的。既然 RUM 是性能监控的“雷达”,那么谁来负责实时、精准地采集数据呢?

答案就是今天的主角——PerformanceObserver API。它就像是浏览器内置的“高性能数据采集器”,彻底改变了我们获取性能数据的方式。


⚠️ 为什么需要 PerformanceObserver?告别“老黄历”

在 PerformanceObserver 出现之前,我们获取性能数据的方式,简直就是一场“噩梦”:

传统方式:性能监控的“老黄历”

  1. performance.timingperformance.getEntries()

    • 问题: 这些 API 只能获取页面加载完成那一刻的静态数据。对于像 First Input Delay (FID) 这种发生在用户交互过程中的动态指标,它们就无能为力了。
    • 痛点: 想要获取实时数据?你只能轮询(不断地去问:“数据好了吗?好了吗?”)。这种方式不仅时机难以掌握,还会带来额外的性能开销,甚至可能阻塞主线程,让页面更卡!

专业名词解释:轮询 (Polling) 轮询是一种计算机通信技术,指客户端程序或设备不断地向服务器程序或设备发送请求,以查询是否有新的数据或状态更新。在前端性能监控中,轮询意味着需要定时检查性能数据是否生成,效率低下且消耗资源。

✨ 优化方案:事件驱动的“高性能引擎”

PerformanceObserver 的出现,彻底解决了轮询的痛点。它提供了一种事件驱动、异步回调的机制:

  • 高效、非阻塞: 它在浏览器记录到性能事件时,会异步通知你,不会阻塞主线程。
  • 实时性: 能够实时捕获动态指标,如用户首次输入延迟(FID)和布局偏移(CLS)。
  • 可订阅: 你可以像订阅报纸一样,选择你感兴趣的性能事件类型。

🔄 PerformanceObserver 的工作原理:三步走战略

PerformanceObserver 的使用流程非常简洁,可以概括为“创建、指定、接收”三步走战略:

步骤 1:创建观测器(Observer)

首先,我们需要创建一个 PerformanceObserver 实例,并传入一个回调函数 (callback)

const observer = new PerformanceObserver((list) => {
  // 浏览器在记录到性能条目时,会自动异步触发这个回调函数
  // list.getEntries() 包含了所有被观测到的性能数据
})

工作原理揭秘: 浏览器在内部记录性能数据时,会检查是否有 PerformanceObserver 在监听。如果有,它就会将最新的性能条目(Performance Entry)打包,并在下一个空闲时机(异步)调用你提供的回调函数。

步骤 2:指定观测目标(Observe)

创建好观测器后,你需要明确告诉它:“我想看哪些数据? ” 这通过 observer.observe() 方法实现,你需要指定一个或多个 entryTypes

observer.observe({
  entryTypes: ['largest-contentful-paint', 'first-input', 'layout-shift']
})

常见的核心观测指标:

entryType 对应指标 含义
largest-contentful-paint LCP 最大内容绘制时间,衡量加载速度。
first-input FID 首次输入延迟,衡量交互响应速度。
layout-shift CLS 累积布局偏移,衡量视觉稳定性。
resource Resource Timing 资源加载(图片、CSS、JS)的详细耗时。

PerformanceObserver 与传统方式对比图

步骤 3:接收和处理数据(Callback)

在回调函数中,你可以通过 list.getEntries() 获取到所有新产生的性能条目。每个条目(Entry)都是一个包含详细信息的对象。

示例:基础用法

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log('指标名称:', entry.name)
    console.log('开始时间:', entry.startTime)
    console.log('持续时间:', entry.duration)

    // 针对不同指标进行特殊处理,例如获取 CLS 的具体值
    if (entry.entryType === 'layout-shift') {
      console.log('CLS 值:', entry.value)
    }
    // 在这里将数据上报到 RUM 服务器
  }
})

observer.observe({
  entryTypes: ['largest-contentful-paint', 'first-input', 'layout-shift']
})

总结:PerformanceObserver 的核心优势

PerformanceObserver 是前端性能监控领域的一次重大飞跃,它的核心优势在于:

  • 实时性: 事件驱动,性能数据一产生就能被捕获,无需低效的轮询。
  • 低开销: 异步执行,不占用主线程资源,对用户体验影响极小。
  • 可扩展: 通过 entryTypes,可以轻松订阅未来浏览器新增的各种性能事件。
  • 易集成: 它是现代 RUM 监控体系中,最核心、最可靠的数据采集组件。

结论: PerformanceObserver 是构建前端性能可观测性的核心组件,它让我们从“猜测性能”迈向了 “数据驱动的性能优化” ,让性能数据采集变得高效、优雅。


下一篇预告: 既然我们能精准地测量性能了,下一步就是如何主动出击,让浏览器提前加载资源。下一篇我们将深入讲解前端性能优化的“预加载神器”——浏览器资源提示符。敬请期待!

GDAL 读取KML数据

前言

KML是一种基于XML的地理数据格式,最初有Keyhole公司开发,后来被Google采用并成为OGC标准。在GIS开发中,属于一种重要的数据格式,使用GDAL读取KML数据,有助于认识、了解KML数据结构与特点,从而提高开发效率。

本篇教程在之前一系列文章的基础上讲解

  • GDAL 简介[1]
  • GDAL 下载安装[2]
  • GDAL 开发起步[3]

如果你还没有看过,建议从以上内容开始。

1. 开发环境

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

时间:2025年

系统:Windows 11

Python:3.11.7

GDAL:3.11.1

2. 导入依赖

KML作为一种矢量数据格式,可以使用GDAL直接读取或者使用其矢量库OGR进行处理,以实现KML图层和属性数据读取。

from osgeo import ogr,gdal
import os

3. 读取KML数据

(一)使用GDAL读取

定义一个方法ReadKMLOfGDAL(kmlPath)用于读取KML数据,其中kmlPath为数据路径。在读取KML数据之前,需要检查数据路径是否存在。

# 检查文件是否存在
if os.path.exists(kmlPath):
    print("文件存在")
else:
    print("文件不存在,请重新选择文件!")
    return

KML数据路径正确,则可以使用OpenEx方法打开KML文件。需要判断KML数据集是否正常,若无法打开,则退出数据读取程序。

# 打开KML文件
dataset = gdal.OpenEx(kmlPath)
if dataset is None:
    print("KML 文件打开异常,请检查文件路径!")
    return

通过数据集方法GetLayerCount可以获取图层数量。

# 获取图层数量
layerCount = dataset.GetLayerCount()
print(f"图层数量:{layerCount}")

图层数量信息显示如下:

之后通过遍历图层获取图层字段数量、字段名称以及字段类型等信息,在输出结果中读取要素属性信息和几何对象并限制要素输出数量。

# 遍历图层
for i in range(layerCount):
    print(f"################开始打印第【{i+1}】个图层################n")
    # 根据索引获取目标图层
    layer = dataset.GetLayerByIndex(i)
    # 获取图层名称
    layerName = layer.GetName()
    # 获取图层要素数量
    layerFeatureCount = layer.GetFeatureCount()

    print(f"图层名称:{layerName}")
    print(f"要素数量:{layerFeatureCount}")

    # 获取图层属性
    layerProperty = layer.GetLayerDefn()
    # 获取图层字段数量
    fieldCount = layerProperty.GetFieldCount()
    print(f"字段数量:{fieldCount}")

    # 获取字段信息
    for j in range(fieldCount):
        # 获取字段属性对象
        fieldProperty = layerProperty.GetFieldDefn(j)
        # 获取字段属性名称
        fieldName = fieldProperty.GetName()
        # 获取字段属性类型
        fieldType = fieldProperty.GetTypeName()

        print(f"第 【{j}】 个字段名称:{fieldName},字段类型:{fieldType}")

    # 获取要素
    feature = layer.GetNextFeature()
    limitCount = 0

    # 限制打印前十个要素
    while feature and limitCount < 10:
        print(f"打印第【{limitCount+1}】个要素")
        # print(f"打印要素类型:{type(feature)},{feature}")

        # 读取要素属性
        for k in range(fieldCount):
            # 属性字段名
            fieldName = layerProperty.GetFieldDefn(j).GetName()
            # 属性字段值
            fieldValue = feature.GetField(k)
            # fieldValue = feature.GetField(fieldName)

            print(f"第 【{k}】 个字段名:{fieldName},字段值:{fieldValue}")

        # 读取几何属性
        geom = feature.GetGeometryRef()
        if geom:
            # 获取几何类型
            geomType = geom.GetGeometryName()
            # 获取WKT格式几何对象,打印前100个字符
            geomWKT = geom.ExportToWkt()[:100]

            print(f"第 【{limitCount}】 个几何对象类型:{geomType},几何对象:{geomWKT}")

        feature = layer.GetNextFeature()
        limitCount += 1

    # 重置读取位置
    layer.ResetReading()

    print(f"n################结束打印第【{i+1}】个图层################n")

图层要素属性信息显示如下:

(二)使用OGR读取

定义一个方法ReadKMLOfOGR(kmlPath)用于读取KML数据,其中kmlPath为数据路径。在读取KML数据之前,需要检查数据路径是否存在。

# 检查文件是否存在
if os.path.exists(kmlPath):
    print("文件存在")
else:
    print("文件不存在,请重新选择文件!")
    return

KML数据路径正确,则可以注册KML数据驱动用于读取KML数据,如使用RegisterAll方法注册所有矢量驱动。然后调用ogr对象Open方法打开KML数据源,若其不存在,则退出数据读取程序。

# 注册所有驱动
ogr.RegisterAll()

# 打开KML数据源
dataSource = ogr.Open(kmlPath)

# 检查数据源是否正常
if dataSource is None:
    print("文件打开出错,请重新选择文件!")
    return

之后通过遍历图层获取图层空间参考、字段名称以及字段类型等信息,在输出结果中读取要素属性信息。

# 遍历图层
for i in range(dataSource.GetLayerCount()):
    # 根据索引获取目标图层
    layer = dataSource.GetLayer(i)
    # 获取图层名称
    layerName = layer.GetName()
    print(f"第【{i}】个图层名称:{layerName}")

    # 获取空间参考
    spatialReference = layer.GetSpatialRef()
    if spatialReference:
        print(f"空间参考:{spatialReference.GetName()}")
    else:
        print(f"图层【{layerName}】空间参考不存在")

    # 读取几何属性
    for feature in layer:
        # 读取几何属性
        geom = feature.GetGeometryRef()
        if geom:
            # 获取四至范围
            envelope = geom.GetEnvelope()
            print(f"几何范围:{envelope}")

        # 读取要素属性
        for field in feature.keys():
            # 获取属性字段值
            fieldValue = feature.GetField(field)
            print(f"属性字段名称:{field},属性字段值:{fieldValue}")

# 关闭数据源
dataSource = None        

图层要素属性信息显示如下:

4. 注意事项

注1:数据路径读取异常

在windows系统中建议使用"\"定义数据路径。

注2:中文数据读取异常(中文乱码)

GIS开发中,涉及属性数据读取时经常会遇到中文乱码问题,需要根据图层编码设置正确的字符集。

# 设置Shapefile的编码为GBK
os.environ['SHAPE_ENCODING'] = "GBK"

注3:代码运行异常

需要开启代码异常处理

# 启用异常处理(推荐)
ogr.UseExceptions()

注4:坐标读取异常

在读取坐标参考时报错已安装PostgreSQL数据库中的投影文件版本与GDAL中的投影文件不兼容,此时需要为GDAL单独指定投影文件,在代码开头添加以下代码指定目标投影文件路径。

# 找到proj文件路径
os.environ['PROJ_LIB'] = r'D:\Programs\Python\Python311\Lib\site-packages\osgeo\data\proj'

5. 完整代码

from osgeo import ogr,gdal
import os

# 如果是通过 pip 安装的,可能需要找到对应位置
os.environ['PROJ_LIB'] = r'D:ProgramsPythonPython311Libsite-packagesosgeodataproj'

# 设置Shapefile的编码为GBK
os.environ['SHAPE_ENCODING'] = "GBK"

# 启用异常处理(推荐)
ogr.UseExceptions()

# 注册所有驱动
ogr.RegisterAll()

"""
使用GDAL读取KML数据
"""
def ReadKMLOfGDAL(kmlPath):

    # 检查文件是否存在
    if os.path.exists(kmlPath):
        print("文件存在")
    else:
        print("文件不存在,请重新选择文件!")
        return

    # 打开KML文件
    dataset = gdal.OpenEx(kmlPath)
    if dataset is None:
        print("KML 文件打开异常,请检查文件路径!")
        return

    # 获取图层数量
    layerCount = dataset.GetLayerCount()
    print(f"图层数量:{layerCount}")

    # 遍历图层
    for i in range(layerCount):
        print(f"################开始打印第【{i+1}】个图层################n")
        # 根据索引获取目标图层
        layer = dataset.GetLayerByIndex(i)
        # 获取图层名称
        layerName = layer.GetName()
        # 获取图层要素数量
        layerFeatureCount = layer.GetFeatureCount()

        print(f"图层名称:{layerName}")
        print(f"要素数量:{layerFeatureCount}")

        # 获取图层属性
        layerProperty = layer.GetLayerDefn()
        # 获取图层字段数量
        fieldCount = layerProperty.GetFieldCount()
        print(f"字段数量:{fieldCount}")

        # 获取字段信息
        for j in range(fieldCount):
            # 获取字段属性对象
            fieldProperty = layerProperty.GetFieldDefn(j)
            # 获取字段属性名称
            fieldName = fieldProperty.GetName()
            # 获取字段属性类型
            fieldType = fieldProperty.GetTypeName()

            print(f"第 【{j}】 个字段名称:{fieldName},字段类型:{fieldType}")

        # 获取要素
        feature = layer.GetNextFeature()
        limitCount = 0

        # 限制打印前十个要素
        while feature and limitCount < 10:
            print(f"打印第【{limitCount+1}】个要素")
            # print(f"打印要素类型:{type(feature)},{feature}")

            # 读取要素属性
            for k in range(fieldCount):
                # 属性字段名
                fieldName = layerProperty.GetFieldDefn(j).GetName()
                # 属性字段值
                fieldValue = feature.GetField(k)
                # fieldValue = feature.GetField(fieldName)

                print(f"第 【{k}】 个字段名:{fieldName},字段值:{fieldValue}")

            # 读取几何属性
            geom = feature.GetGeometryRef()
            if geom:
                # 获取几何类型
                geomType = geom.GetGeometryName()
                # 获取WKT格式几何对象,打印前100个字符
                geomWKT = geom.ExportToWkt()[:100]

                print(f"第 【{limitCount}】 个几何对象类型:{geomType},几何对象:{geomWKT}")

            feature = layer.GetNextFeature()
            limitCount += 1

        # 重置读取位置
        layer.ResetReading()

        print(f"n################结束打印第【{i+1}】个图层################n")

"""
使用OGR读取KML数据
"""
def ReadKMLOfOGR(kmlPath):

    # 检查文件是否存在
    if os.path.exists(kmlPath):
        print("文件存在")
    else:
        print("文件不存在,请重新选择文件!")
        return
    # 注册所有驱动
    ogr.RegisterAll()

    # 打开KML数据源
    dataSource = ogr.Open(kmlPath)

    # 检查数据源是否正常
    if dataSource is None:
        print("文件打开出错,请重新选择文件!")
        return

    # 遍历图层
    for i in range(dataSource.GetLayerCount()):
        # 根据索引获取目标图层
        layer = dataSource.GetLayer(i)
        # 获取图层名称
        layerName = layer.GetName()
        print(f"第【{i}】个图层名称:{layerName}")

        # 获取空间参考
        spatialReference = layer.GetSpatialRef()
        if spatialReference:
            print(f"空间参考:{spatialReference.GetName()}")
        else:
            print(f"图层【{layerName}】空间参考不存在")

        # 读取几何属性
        for feature in layer:
            # 读取几何属性
            geom = feature.GetGeometryRef()
            if geom:
                # 获取四至范围
                envelope = geom.GetEnvelope()
                print(f"几何范围:{envelope}")

            # 读取要素属性
            for field in feature.keys():
                # 获取属性字段值
                fieldValue = feature.GetField(field)
                print(f"属性字段名称:{field},属性字段值:{fieldValue}")

    # 关闭数据源
    dataSource = None        

if __name__ == "__main__":

    # 数据路径
    kmlPath = "E:\data\test_data\四姑娘山三峰.kml"

    # GDAL读取KML数据
    ReadKMLOfGDAL(kmlPath)

    # OGR读取KML数据
    ReadKMLOfOGR(kmlPath)

6. KML示例数据

<?xml version="1.0" encoding="utf-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2">
  <Document>
    <name>Track 登顶四姑娘山三峰 :wikiloc.com</name>
    <visibility>1</visibility>
    <LookAt>
      <longitude>102.8793075</longitude>
      <latitude>31.0426283</latitude>
      <altitude>0</altitude>
      <heading>3</heading>
      <tilt>66</tilt>
      <range>15000</range>
    </LookAt>
    <StyleMap id="m1367020">
      <Pair>
        <key>normal</key>
        <styleUrl>#n1367020</styleUrl>
      </Pair>
      <Pair>
        <key>highlight</key>
        <styleUrl>#h1367020</styleUrl>
      </Pair>
    </StyleMap>
    <Style id="h1367020">
      <IconStyle>
        <Icon>
          <href>http://s1.wklcdn.com/wikiloc/images/pictograms/ge/1.png</href>
        </Icon>
      </IconStyle>
      <BalloonStyle>
        <text>$[description]</text>
      </BalloonStyle>
    </Style>
    <Style id="lineStyle">
      <LineStyle>
        <color>f03399ff</color>
        <width>4</width>
      </LineStyle>
    </Style>
    <Style id="n1367020">
      <LabelStyle>
        <scale>0</scale>
      </LabelStyle>
      <BalloonStyle>
        <text>$[description]</text>
      </BalloonStyle>
      <Icon>
        <href>http://s1.wklcdn.com/wikiloc/images/pictograms/ge/1.png</href>
      </Icon>
    </Style>
    <Style id="waypointStyle">
      <IconStyle>
        <Icon>
          <href>http://sc.wklcdn.com/wikiloc/images/pictograms/ge/wpt.png</href>
        </Icon>
      </IconStyle>
      <BalloonStyle>
        <text>$[description]</text>
      </BalloonStyle>
    </Style>
    <Folder>
      <name>Trails</name>
      <visibility>1</visibility>
      <Folder>
        <name>登顶四姑娘山三峰</name>
        <visibility>1</visibility>
        <Placemark>
          <name>Path</name>
          <visibility>1</visibility>
          <LookAt>
            <longitude>102.8862617</longitude>
            <latitude>31.052715</latitude>
            <altitude>0</altitude>
            <heading>0</heading>
            <tilt>0.00779126500014642</tilt>
            <range>5250.96911517065</range>
          </LookAt>
          <Style>
            <IconStyle>
              <color>ffffffff</color>
              <scale>1</scale>
              <Icon>
                <href/>
              </Icon>
            </IconStyle>
            <LabelStyle>
              <color>ffffffff</color>
              <scale>1</scale>
            </LabelStyle>
            <LineStyle>
              <color>f00000ff</color>
              <width>4</width>
            </LineStyle>
            <PolyStyle>
              <color>ffffffff</color>
              <fill>1</fill>
              <outline>1</outline>
            </PolyStyle>
          </Style>
          <LineString>
            <altitudeMode>clampToGround</altitudeMode>
            <coordinates>
              102.8527267,31.0061667,3255.400146
              102.8530967,31.00604,3254.899902
              102.8537967,31.0060883,3256.899902
              102.8547817,31.0064133,3270.100098
              102.8558183,31.0071067,3271.100098
              102.8575333,31.00785,3271.699951
              102.8588867,31.0093867,3278.899902
              102.8599,31.0099067,3281.5
              102.8605217,31.01093,3289.899902
              102.8613217,31.0128967,3298.899902
              102.863045,31.014905,3307.199951
              102.8638983,31.016515,3313.100098
              102.8639067,31.01642,3306.699951
              102.86423,31.0168667,3317.199951
              102.8645867,31.017765,3330.000244
              102.8655283,31.0190083,3314.100342
              102.86643,31.0211683,3324.100098
              102.8665367,31.0217183,3321.300049
              102.86754,31.0228467,3328.399902
              102.8682333,31.023345,3331.699951
              102.868495,31.02422,3338.399902
              102.86873,31.0245367,3336.199951
              102.8697533,31.0251667,3343.100098
              102.870035,31.0256033,3345.800049
              102.86997,31.02594,3350.099854
              102.870195,31.0265117,3357.800049
              102.8706917,31.0273617,3360.300049
              102.8717183,31.0284717,3374
              102.8735067,31.0298317,3377.699951
              102.8744233,31.0310767,3382.300049
              102.8748283,31.0321567,3378.699951
              102.8747833,31.0328433,3391.800049
              102.8756183,31.0336933,3406.399902
              102.875455,31.034915,3408
              102.8754967,31.0361467,3406.399902
              102.8759333,31.037405,3412
              102.8763117,31.0379283,3415.999756
              102.87597,31.0385567,3416.199951
              102.8757067,31.0415767,3399.100098
              102.87552,31.0419067,3415.999756
              102.8758433,31.0423217,3424.100098
              102.8762517,31.0425117,3439.200195
              102.8762617,31.04284,3444
              102.8764567,31.0430117,3450.199951
              102.8766917,31.0436783,3461.399902
              102.8771717,31.0439417,3481.399902
              102.876935,31.04407,3486.899902
              102.8771133,31.04414,3494.399902
              102.8772133,31.0444317,3502.300049
              102.8782383,31.0450583,3541.100098
              102.878835,31.045955,3559.100098
              102.8790667,31.0470883,3574.699951
              102.8792533,31.0472867,3574.5
              102.8790733,31.04746,3574.199951
              102.8791133,31.0475933,3575.300049
              102.879595,31.0479917,3586
              102.8803283,31.0490267,3626.399902
              102.8804683,31.0489483,3627.600098
              102.880595,31.049135,3626.800049
              102.8807983,31.0491317,3629.199951
              102.8807333,31.0493933,3629.800049
              102.88088,31.04944,3629.100098
              102.880855,31.049585,3628.699951
              102.8811167,31.0496783,3629
              102.8812417,31.049575,3629.600098
              102.8814083,31.049755,3632.600098
              102.881335,31.0500367,3634.5
              102.8811333,31.0499417,3638.800049
              102.88138,31.05021,3638.699951
              102.8812683,31.0501417,3639
              102.8813417,31.0499933,3637.499756
              102.8813383,31.0501217,3642.600098
              102.8822067,31.050155,3652.599854
              102.8823317,31.050305,3655.699951
              102.8827433,31.0501883,3663.399902
              102.882945,31.0503983,3691
              102.8835383,31.0504067,3708.600098
              102.883635,31.0504717,3713.199707
              102.88357,31.0509167,3720.699951
              102.8834217,31.0509483,3723.000244
              102.8837983,31.0511317,3728.600342
              102.8841217,31.0509617,3733
              102.8840783,31.0516483,3760.400146
              102.8844567,31.0517517,3780.399902
              102.8844183,31.0518767,3795.699951
              102.884775,31.0518117,3818.499756
              102.8848583,31.0522,3863
              102.885575,31.051965,3896.800049
              102.88583,31.05217,3908.600098
              102.885545,31.0519417,3948.100098
              102.88575,31.0519467,3951.500244
              102.8857867,31.0521417,3960.899902
              102.8861367,31.0522567,3973.300293
              102.8862617,31.052715,3985.5
              102.8865033,31.0528033,3996.699707
              102.8865233,31.0531233,4007.399902
              102.886855,31.053565,4025.600098
              102.8878733,31.0542133,4081.300049
              102.888465,31.0543383,4096.399902
              102.8887633,31.05476,4105.5
              102.8889883,31.0546883,4115.200195
              102.8891233,31.0549117,4131
              102.8893483,31.0548067,4143.200195
              102.8900367,31.055275,4164.200195
              102.8902983,31.0563283,4190.399902
              102.8902633,31.0578033,4191.899902
              102.890535,31.05789,4203.200195
              102.89051,31.058235,4225.799805
              102.8909267,31.0584983,4262.799805
              102.8911817,31.05891,4273.899902
              102.8913883,31.05877,4285
              102.8913233,31.0584617,4289.399902
              102.89199,31.0583817,4299
              102.8919,31.058545,4308.200195
              102.8920433,31.05873,4319.299805
              102.8924917,31.05891,4352
              102.8927133,31.0588033,4365.200195
              102.8930267,31.059215,4373.200195
              102.89327,31.0590433,4388.899902
              102.8934967,31.0592717,4391.299805
              102.8934583,31.0594417,4395.899902
              102.8937567,31.0595283,4406.299805
              102.8940683,31.0601267,4421
              102.8943233,31.06027,4429.5
              102.8943667,31.0605067,4435.600098
              102.8941,31.0606483,4444
              102.89444,31.0607917,4452.799805
              102.89331,31.0618433,4485.899902
              102.893345,31.061985,4489.799805
              102.8938833,31.0621483,4498.399902
              102.8937483,31.0619783,4499
              102.89363,31.0620033,4499.399902
              102.8937967,31.062175,4499.799805
              102.8943467,31.0621867,4503.899902
              102.8943433,31.062095,4504.700195
              102.8943767,31.0622417,4504.5
              102.8948533,31.062295,4503.600098
              102.8957933,31.0629667,4506.299805
              102.8959517,31.0628633,4506.399902
              102.89649,31.0635683,4509.799805
              102.8966483,31.063565,4509.399902
              102.8967717,31.0639033,4511.600098
              102.8974033,31.0641033,4518.100098
              102.8982783,31.0652517,4530.399902
              102.8985533,31.0661067,4556.299805
              102.899115,31.0666583,4589.600098
              102.8990783,31.0670983,4620.700195
              102.8994317,31.0674483,4636
              102.8997217,31.068335,4650.799805
              102.9004533,31.0686783,4657.799805
              102.90056,31.0690317,4672.100098
              102.9008217,31.069215,4664.5
              102.9005883,31.0696883,4677.399902
              102.9007033,31.0700017,4692.100098
              102.9013133,31.070325,4701.100098
              102.9020567,31.0710117,4716.899902
              102.902175,31.0713983,4738.899902
              102.9026167,31.0719533,4748
              102.903125,31.0721467,4758.299805
              102.9036383,31.0726467,4757.299805
              102.9035233,31.072715,4757.399902
              102.9036517,31.0728533,4759.5
              102.9047917,31.0735717,4823.5
              102.905155,31.07431,4862.299805
              102.9062583,31.0745867,4891.799805
              102.9065483,31.07534,4962.100098
              102.906415,31.075375,4966
              102.906495,31.0755583,4993.700195
              102.9062583,31.0755983,4994.899902
              102.9066633,31.0755817,4990.700195
              102.9064633,31.0757367,5003.000488
              102.9069417,31.0759117,5031.500488
              102.9069833,31.0760817,5034.899902
              102.9068167,31.076175,5040.100098
              102.9069583,31.0762483,5041.700195
              102.9070367,31.0766883,5058.5
              102.906675,31.0769033,5078.899902
              102.906895,31.0768783,5081.200195
              102.90672,31.0772267,5096.200195
              102.9071467,31.0774933,5137.5
              102.9072017,31.07771,5142.200195
              102.90558,31.0791683,5322.200195
              102.905505,31.0793567,5341.899902
              102.905815,31.0797233,5358.100098
              102.9054383,31.07938,5345.500488
              102.9055167,31.07932,5349.5
              102.90543,31.0794,5349.100098
            </coordinates>
          </LineString>
        </Placemark>
      </Folder>
    </Folder>
  </Document>
</kml>

OpenLayers示例数据下载,请回复关键字:ol数据

全国信息化工程师-GIS 应用水平考试资料,请回复关键字:GIS考试

【GIS之路】 已经接入了智能助手,欢迎关注,欢迎提问。

欢迎访问我的博客网站-长谈GIShttp://shanhaitalk.com

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

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

记住这张时间线图,你再也不会乱用 useEffect / useLayoutEffect

useEffect 和 useLayoutEffect 的区别:别背定义,按“什么时候上屏”来选

以前一直写vue,现在写react了,写react代码的时候有时候会碰到一个选择题:

  • 这个副作用用 useEffect 还是 useLayoutEffect
  • 为什么我用 useEffect 量 DOM 会闪一下?
  • Next.js 里 useLayoutEffect 为什么会给我一个 warning?

这俩 Hook 的差别,说穿了就一句:它们跑在“上屏(paint)”的前后


一句话结论(先拿走)

  • 默认用 useEffect:不会挡住浏览器绘制。
  • 只有在“必须读布局/写布局且不能闪”的时候用 useLayoutEffect:它会在浏览器 paint 之前同步执行。

如果你脑子里只留两句话,就留这两句。


它们到底差在哪:在浏览器 paint 的前后

把 React DOM 的一次更新粗暴拆成四步,你就不会混了:

flowchart LR
  A[render 计算 JSX] --> B[commit 写入 DOM]
  B --> C[useLayoutEffect 同步执行]
  C --> D[浏览器 paint 上屏]
  D --> E[useEffect 执行]

  classDef info fill:#cce5ff,stroke:#0d6efd,color:#004085
  classDef warning fill:#fff3cd,stroke:#ffc107,color:#856404

  class C warning
  class E info
  • useLayoutEffectDOM 已经变了,但还没 paint。它会阻塞本次 paint。
  • useEffect页面已经 paint 了。它不会阻塞上屏(但也意味着你在里面改布局可能会“先错后改”,肉眼看到就是闪)。

注意我在说的是“commit 后”的那个时间点,不是 render 阶段。


一个很真实的例子:测量 DOM 决定位置(useEffect 会闪)

比如你做一个 Tooltip:初始不知道自己宽高,得先 render 出来,然后用 getBoundingClientRect() 量一下,再把位置修正。

如果你用 useEffect

  • 第一次 paint:Tooltip 先用默认位置上屏
  • effect 里量完 -> setState
  • 第二次 paint:位置修正

用户看到的就是“闪一下”。如果你用 useLayoutEffect,修正发生在 paint 之前,第一帧就是对的。

下面这段代码可以直接在 React DOM 里跑(为了不违反 Hooks 规则,我写成两个组件,用 checkbox 切换时会 remount):

import React, { useEffect, useLayoutEffect, useRef, useState } from "react";

type TooltipPosition = {
  anchorRef: React.RefObject<HTMLButtonElement | null>;
  tipRef: React.RefObject<HTMLDivElement | null>;
  left: number;
};

function calcLeft(anchor: HTMLButtonElement, tip: HTMLDivElement) {
  const a = anchor.getBoundingClientRect();
  const t = tip.getBoundingClientRect();
  return Math.round(a.left + a.width / 2 - t.width / 2);
}

function useTooltipPositionWithEffect(): TooltipPosition {
  const anchorRef = useRef<HTMLButtonElement | null>(null);
  const tipRef = useRef<HTMLDivElement | null>(null);
  const [left, setLeft] = useState(0);

  useEffect(() => {
    const anchor = anchorRef.current;
    const tip = tipRef.current;
    if (!anchor || !tip) return;
    setLeft(calcLeft(anchor, tip));
  }, []);

  return { anchorRef, tipRef, left };
}

function useTooltipPositionWithLayoutEffect(): TooltipPosition {
  const anchorRef = useRef<HTMLButtonElement | null>(null);
  const tipRef = useRef<HTMLDivElement | null>(null);
  const [left, setLeft] = useState(0);

  useLayoutEffect(() => {
    const anchor = anchorRef.current;
    const tip = tipRef.current;
    if (!anchor || !tip) return;
    setLeft(calcLeft(anchor, tip));
  }, []);

  return { anchorRef, tipRef, left };
}

function TooltipFrame({ pos }: { pos: TooltipPosition }) {
  return (
    <>
      <button ref={pos.anchorRef} style={{ marginLeft: 120 }}>
        Hover me
      </button>

      <div
        ref={pos.tipRef}
        style={{
          position: "fixed",
          top: 80,
          left: pos.left,
          padding: "8px 10px",
          borderRadius: 8,
          background: "#111827",
          color: "#fff",
          fontSize: 12,
          whiteSpace: "nowrap",
        }}
      >
        I am a tooltip
      </div>
    </>
  );
}

function DemoUseEffect() {
  return <TooltipFrame pos={useTooltipPositionWithEffect()} />;
}

function DemoUseLayoutEffect() {
  return <TooltipFrame pos={useTooltipPositionWithLayoutEffect()} />;
}

export function Demo() {
  const [layout, setLayout] = useState(false);

  return (
    <div style={{ padding: 40 }}>
      <label style={{ display: "block", marginBottom: 12 }}>
        <input
          type="checkbox"
          checked={layout}
          onChange={(e) => setLayout(e.target.checked)}
        />{" "}
        用 useLayoutEffect(勾上后更不容易闪)
      </label>

      {layout ? <DemoUseLayoutEffect /> : <DemoUseEffect />}
    </div>
  );
}

真实项目里你可能还会处理 resize、内容变化(ResizeObserver)、字体加载导致的宽度变化等;但对理解这两个 Hook 的差别,上面这个例子够用了。


怎么选:我自己用的“决策口诀”

1)只要不读/写布局,就用 useEffect

典型场景:

  • 请求数据、上报埋点
  • 订阅/取消订阅(WebSocket、EventEmitter)
  • document.titlelocalStorage 同步
  • 给 window/document 绑事件

这些东西不需要卡在 paint 之前完成,useEffect 更合适。

2)你要读布局(layout read)并且会影响第一帧渲染,就用 useLayoutEffect

典型场景:

  • getBoundingClientRect() / offsetWidth / scrollHeight 这种
  • 计算初始滚动位置、同步滚动
  • 需要避免视觉抖动的“测量 -> setState”
  • focus / selection(输入框聚焦、光标定位)对首帧体验敏感

一句话:“不想让用户看到中间态”

3)别在 useLayoutEffect 里干重活

因为它会阻塞 paint:

  • 你在里面做重计算,页面就掉帧
  • 你在里面频繁 setState,可能放大卡顿

如果你只是“想早点跑一下”,但并不依赖布局,别用它。


Next.js / SSR 里那个 warning 怎么回事

在服务端渲染(SSR)时:

  • useEffect 本来就不会执行(它只在浏览器跑)
  • useLayoutEffect 也不会执行,但 React 会提示你:它在服务端没意义,可能导致你写出“依赖布局但 SSR 不存在布局”的代码

如果你写的是“浏览器才有意义的 layout effect”,又不想看到 warning,常见做法是包一层:

import { useEffect, useLayoutEffect } from "react";

export const useIsomorphicLayoutEffect =
  typeof window !== "undefined" ? useLayoutEffect : useEffect;

然后把需要 layout 的地方用 useIsomorphicLayoutEffect


容易踩的坑(顺手说两句)

  • Strict Mode 下 effect 会在开发环境额外执行一次useEffectuseLayoutEffect 都一样,别拿这个现象判断线上行为。
  • “我在 useEffect 里 setState 为什么会闪?”:因为你改的是布局相关内容,第一帧已经 paint 了。
  • 不要把数据请求塞进 useLayoutEffect:它既不需要 paint 前完成,还可能拖慢上屏。

简单总结一下

  • useEffect:大多数副作用的默认选择。
  • useLayoutEffect:只在“必须卡在 paint 前解决”的那一小撮场景里用。

真要说区别,其实就是一句:你愿不愿意为了“第一帧正确”去挡住 paint


如果你觉得这篇文章有帮助,欢迎关注我的 GitHub,下面是我的一些开源项目:

Claude Code Skills(按需加载,意图自动识别,不浪费 token,介绍文章):

全栈项目(适合学习现代技术栈):

  • prompt-vault - Prompt 管理器,Next.js 15 + React 19 + tRPC 11 + Supabase 全栈示例,clone 下来配个免费 Supabase 就能跑
  • chat_edit - 双模式 AI 应用(聊天+富文本编辑),Vue 3.5 + TypeScript + Vite 5 + Quill 2.0 + IndexedDB

我只是给Typescript提个 typo PR,为什么还要签协议?

第一次给大公司的开源项目(Typescript)提 PR,提交完 GitHub 就弹出一条评论,让你签什么 CLA:

@microsoft-github-policy-service agree

image.png 什么玩意儿?我就改了个拼写错误,还要签协议?

CLA 是什么

CLA,全称 Contributor License Agreement,翻译过来叫"贡献者许可协议"。

简单说,就是一份法律文件,你签了之后,就授权项目方可以合法使用你贡献的代码。

为什么需要这东西

想象一个场景:

张三给某开源项目提了个 PR,合并了。过了两年,张三突然跳出来说:"这段代码是我写的,你们用了我的代码,侵犯我版权,赔钱!"

项目方一脸懵:代码是你自己提交的啊?

张三:提交归提交,我没说授权你们用啊。

听起来像碰瓷,但法律上还真不好说。毕竟代码确实是张三写的,版权默认归作者。

CLA 就是为了堵这个漏洞。你签了 CLA,就相当于白纸黑字写清楚了:

  • 这代码是我自己写的(不是抄的)
  • 我授权你们用、改、分发
  • 以后不会反悔找你们麻烦

CLA 里具体写了啥

以微软的 CLA 为例,核心条款就这几条:

1. 原创声明

你保证提交的代码是你自己写的。如果包含别人的代码,要标注清楚来源和许可证。

2. 版权授权

你授予项目方永久的、全球范围的、免版税的版权许可。说白了就是:他们可以随便用,不用给你钱,也不用每次都问你。

3. 专利授权

如果你的代码涉及专利(虽然大多数情况下不会),你也授权他们使用。

4. 雇主确认

如果你是在工作中写的代码,公司可能对代码有知识产权。这种情况下,你得先拿到公司的许可才能签 CLA。

签了会怎样

签 CLA 不会让你:

  • 失去代码的版权(版权还是你的)
  • 不能在别处使用这段代码
  • 承担什么法律责任

签 CLA 只是说:

  • 项目方可以合法使用你的贡献
  • 你不会秋后算账

不同项目的 CLA

不是所有开源项目都要签 CLA,主要是大公司的项目:

公司 需要 CLA
微软
Google
Meta
Apache 基金会
个人项目 通常不需要

个人维护的开源项目一般不搞这套,太麻烦。但大公司不行,法务部不允许有法律风险敞口。

怎么签

以 GitHub 上的微软项目为例:

  1. 提交 PR
  2. 机器人会自动评论,让你签 CLA
  3. 回复:@microsoft-github-policy-service agree
  4. 搞定

就这么简单。签一次就行,以后再给同一个组织提 PR 就不用重复签了。

如果你是代表公司贡献代码,需要加上公司名:

@microsoft-github-policy-service agree company="你的公司名"

一些细节

Q:我就改了个 typo,也要签?

是的。哪怕只改了一个字符,也是贡献,也要签。

Q:签了 CLA,代码版权归谁?

版权还是你的。CLA 只是授权,不是转让。

Q:能撤回吗?

理论上你不能撤回已经合并的代码的授权。但你可以随时停止贡献。

Q:CLA 和开源许可证什么关系?

开源许可证(MIT、Apache 等)是项目对外的授权,告诉使用者可以怎么用这个项目。

CLA 是贡献者对项目的授权,告诉项目方可以怎么用贡献者的代码。

两个方向不一样。

小结

CLA 这东西,说白了就是大公司的法务需求。对贡献者来说,签一下也没什么损失,就是授权项目方合法使用你的代码。

第一次遇到可能有点懵,但理解了它的目的,就知道这是正常流程,不是什么坑。

签就完了。


如果你觉得这篇文章有帮助,欢迎关注我的 GitHub,下面是我的一些开源项目:

Claude Code Skills(按需加载,意图自动识别,不浪费 token,介绍文章):

全栈项目(适合学习现代技术栈):

  • prompt-vault - Prompt 管理器,用的都是最新的技术栈,适合用来学习了解最新的前端全栈开发范式:Next.js 15 + React 19 + tRPC 11 + Supabase 全栈示例,clone 下来配个免费 Supabase 就能跑
  • chat_edit - 双模式 AI 应用(聊天+富文本编辑),Vue 3.5 + TypeScript + Vite 5 + Quill 2.0 + IndexedDB
❌