普通视图

发现新文章,点击刷新页面。
今天 — 2025年12月18日掘金 前端

面试官问"try-catch影响性能吗",我用数据打脸

2025年12月17日 22:42

面试官问"try-catch影响性能吗",我用数据打脸

面试的时候被问到这个问题:try-catch 会影响性能吗?

当时我有点懵,回答了一个模糊的"会有一点影响吧"。面试官追问:影响多大?什么情况下影响大?我就说不上来了。

回来之后认真研究了一下,发现这个问题的答案比想象中有意思。

先说结论

在现代 JavaScript 引擎中,try-catch 本身几乎不影响性能,但异常抛出是昂贵的操作。

听起来有点绕?用人话说就是:

  • 代码外面套一层 try-catch → 基本没影响
  • 代码里频繁 throw Error → 性能会很差

下面用数据说话。

实测数据

我写了个简单的测试:

const iterations = 1000000;

// 测试1:不用 try-catch
console.time('无 try-catch');
for (let i = 0; i < iterations; i++) {
    Math.sqrt(i);
}
console.timeEnd('无 try-catch');

// 测试2:用 try-catch,但不抛异常
console.time('有 try-catch,不抛异常');
for (let i = 0; i < iterations; i++) {
    try {
        Math.sqrt(i);
    } catch (e) {
        // 不会执行
    }
}
console.timeEnd('有 try-catch,不抛异常');

// 测试3:用 try-catch,每次都抛异常(注意迭代次数少很多)
console.time('有 try-catch,每次抛异常');
for (let i = 0; i < 10000; i++) {
    try {
        throw new Error('test');
    } catch (e) {
        // 处理异常
    }
}
console.timeEnd('有 try-catch,每次抛异常');

结果(Node.js v20,M1 Mac):

场景 迭代次数 耗时
无 try-catch 1,000,000 1.8ms
有 try-catch,不抛异常 1,000,000 1.2ms
有 try-catch,每次抛异常 10,000 13.9ms

有意思的是,加了 try-catch 反而更快了?

是的,这可能是 V8 引擎的优化效果。但重点是:只要不抛异常,try-catch 的开销可以忽略不计。

而抛异常的场景呢?迭代次数少了100倍,耗时却多了10倍。换算一下,异常抛出比正常执行慢约1000倍

为什么异常抛出这么慢?

因为 JavaScript 引擎需要做三件事:

  1. 创建 Error 对象 - 这个对象包含了错误信息
  2. 捕获堆栈跟踪 - 遍历调用栈,记录每一层的函数名、文件名、行号
  3. 展开调用栈 - 从抛出点一直往上找,直到找到匹配的 catch 块

其中第 2 步最耗时。调用栈越深,捕获堆栈跟踪的开销越大。

什么时候该用 try-catch?

记住一个原则:异常是用来处理异常情况的,不是用来控制正常流程的

正确用法:处理真正的异常

// JSON 解析可能失败
try {
    const data = JSON.parse(userInput);
    processData(data);
} catch (e) {
    showError('输入的格式不对');
}

// 网络请求可能失败
try {
    const response = await fetch('/api/data');
    const data = await response.json();
} catch (e) {
    showError('网络连接失败');
}

这些场景下,异常是"意外情况",不是每次都会发生。用 try-catch 完全没问题。

错误用法:用异常控制流程

// 错误示范:用异常来判断用户是否存在
function findUser(id) {
    try {
        return database.query(`SELECT * FROM users WHERE id = ${id}`);
    } catch (e) {
        return null;  // 用异常来返回"找不到"
    }
}

如果大部分查询都找不到用户,那每次都会抛异常,性能会很差。

正确做法是先检查,再操作:

// 正确做法
function findUser(id) {
    const result = database.query(`SELECT * FROM users WHERE id = ${id}`);
    return result || null;
}

循环里怎么用 try-catch?

这是另一个常见问题。看两种写法:

// 写法1:try-catch 在循环内
for (const item of items) {
    try {
        processItem(item);
    } catch (e) {
        console.error('处理失败:', item);
    }
}

// 写法2:try-catch 在循环外
try {
    for (const item of items) {
        processItem(item);
    }
} catch (e) {
    console.error('处理失败:', e);
}

性能上,两者差不多。因为只要不抛异常,try-catch 本身几乎没开销。

区别在于错误处理策略

  • 写法1:某一项失败了,继续处理其他项
  • 写法2:某一项失败了,整个循环终止

根据业务需求选择,别纠结性能。

关于早期 V8 的问题

网上有些老文章说"try-catch 会阻止 V8 优化",这在早期版本确实存在。但在 V8 6.0+(Node.js 8.3+,Chrome 60+)之后,这个问题已经解决了。

所以如果你看到有人说"try-catch 会让函数无法被优化",看看文章发布时间。2018 年之前的文章可以参考,但别太当真。

最佳实践总结

  1. 放心用 try-catch - 现代引擎下,性能影响可以忽略
  2. 异常是异常 - 用于处理真正的错误情况,不是控制流程
  3. 先检查,再操作 - 能用 if 判断的,别用异常处理
  4. catch 里要做事 - 空的 catch 块是代码坏味道
  5. 错误要有上下文 - catch 里记录足够的信息方便排查
// 最佳实践示例
async function fetchUserData(userId) {
    if (!userId) {
        return null;  // 先检查,不用异常
    }

    try {
        const response = await fetch(`/api/users/${userId}`);
        if (!response.ok) {
            // HTTP 错误,但不一定是异常
            console.warn(`获取用户失败: ${response.status}`);
            return null;
        }
        return await response.json();
    } catch (e) {
        // 真正的异常:网络断开、JSON 解析失败等
        console.error('获取用户数据异常:', {
            userId,
            error: e.message,
            stack: e.stack
        });
        throw e;  // 根据需要决定是否重新抛出
    }
}

面试怎么答?

下次再被问到这个问题,可以这样回答:

try-catch 本身在现代 JavaScript 引擎中几乎没有性能开销。真正影响性能的是异常的抛出和捕获,因为需要创建 Error 对象和捕获堆栈跟踪。所以建议把 try-catch 用于处理真正的异常情况,而不是用来控制正常的程序流程。比如用户输入校验,应该用 if 判断而不是 try-catch。


如果你觉得这篇文章有帮助,欢迎关注我的 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

使用命令行工具 ogr2ogr 将 CSV 转换为 Shp 数据(二)

作者 GIS之路
2025年12月17日 22:08

前言

ogr2ogr 是一个数据转换工具,可用于在文件格式之间转换简单要素数据。它还可以在转换过程中执行各种操作,如空间或属性选择、减少属性集、设置输出坐标系,甚至在转换过程中重新投影特征。

如有兴趣,可参阅文章:

GDAL 实现将 CSV 转换为 Shp 数据(一)[1]

ogr2ogr 作为 GDAL(Geospatial Data Abstraction Library)库中的一个命令行工具,用于转换和处理地理空间数据。它可以读取多种矢量数据格式(如Shapefile、GeoJSON、KML、GML、PostGIS等),并进行格式转换、坐标转换、属性过滤、几何操作等。

1. 用户环境

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

时间:2025年

系统:Windows 11

Python:3.11.7

GDAL:3.11.1

ogr2ogr:3.9.2

2. 验证 ogr2ogr 工具信息

打开命令行终端,输入命令ogr2ogr --version查看版本信息。如果ogr2ogr正确安装并添加到环境变量,便会输出其版本号。

3. 将CSV转换为Shp数据

原始CSV数据内容显示如下,具有四个字段,分别为“名称”、“地址”、“LATQ”代表纬度,“LNGQ”代表经度。

在命令行终端输入以下命令进行数据转换。

ogr2ogr -f "ESRI Shapefile" E:datatest_dataogr_transform.shp E:datatest_data景点.csv -oo X_POSSIBLE_NAMES=LNGQ,longitude,x,X -oo Y_POSSIBLE_NAMES=LATQ,latitude,y,Y  -a_srs EPSG:4326

以下是ogr2ogr工具常用参数解释:

选项 说明
-f "ESRI Shapefile" 指定输出格式,如“ESRI Shapefile”
-a_srs EPSG:4326 设置空间参考
-select "field1,field2" 选择特定字段
-where "value > 100" 过滤条件
-lco ENCODING=UTF-8 设置字符编码
-oo AUTODETECT_TYPE=YES 自动检测字段类型
-oo SEPARATOR=TAB 设置分隔符(TAB/COMMA/SEMICOLON)
-oo X_POSSIBLE_NAMES 指定经度字段,可书写多个可能值
-oo Y_POSSIBLE_NAMES 指定纬度字段,可书写多个可能值

4. 中文转换错误解决办法

由于CSV文件中具有中文字段,直接进行转换工具会报错,无法创建中文字段。此时需要指定正确的字符编码。

在命令中添加-lco ENCODING=UTF-8选项以正确转换中文字符。

ogr2ogr -f "ESRI Shapefile" E:datatest_dataogr_transform.shp E:datatest_data景点.csv -oo X_POSSIBLE_NAMES=LNGQ,longitude,x,X -oo Y_POSSIBLE_NAMES=LATQ,latitude,y,Y -lco ENCODING=UTF-8 -a_srs EPSG:4326

将CSV转换为Shp数据在ArcGIS中的显示结果。

5. 常见字符编码

以下是一些常见编码格式。

# UTF-8(推荐)
-lco ENCODING=UTF-8

# 中文编码
-lco ENCODING=GBK      # 简体中文
-lco ENCODING=BIG5     # 繁体中文
-lco ENCODING=GB2312   # 简体中文旧版

# 其他编码
-lco ENCODING=LATIN1
-lco ENCODING=ISO-8859-1
-lco ENCODING=CP1252

6. 数据转换事项

使用ogr2ogr工具进行数据转换时如果所有的命令都写在一行,那么显示效果不仅不美观,也不具有可读性,所以可按照转换内容对其进行分行展示。在windowslinux系统中换行符略有差别。

windows系统进行分行展示时使用符号"^"进行分隔:

ogr2ogr -f "ESRI Shapefile" "E:\datatest_data\ogr_transform5.shp" "E:\data\test_data\景点.csv" ^
-oo X_POSSIBLE_NAMES=LNGQ,longitude,x,X ^
-oo Y_POSSIBLE_NAMES=LATQ,latitude,y,Y ^
-lco ENCODING=UTF-8 ^
-a_srs EPSG:4326

linux系统进行分行展示时使用符号"``"进行分隔

ogr2ogr -f "ESRI Shapefile" "E:\datatest_data\ogr_transform5.shp" "E:\data\test_data\景点.csv" ^
-oo X_POSSIBLE_NAMES=LNGQ,longitude,x,X ^
-oo Y_POSSIBLE_NAMES=LATQ,latitude,y,Y ^
-lco ENCODING=UTF-8 ^
-a_srs EPSG:4326

为什么 JavaScript 可以单线程却能处理异步?

作者 天涯学馆
2025年12月17日 21:07

今天咱们聊一个前端最核心的机制:JavaScript 单线程却能处理异步操作的秘密——事件循环(Event Loop)

你可能遇到过这种情况:用 setTimeout 延迟执行代码,或者用 fetch 发网络请求时,主线程并没有卡住,反而能继续执行后面的代码。明明 JS 是单线程的(同一时间只能做一件事),这是怎么做到的?

别急,我会用 “底层机制+代码演示+流程拆解” 的方式,带你彻底搞懂事件循环的运行原理。

前置知识:JS 单线程的本质

什么是单线程?

单线程指的是 JavaScript 引擎(如 V8)的主线程同一时间只能执行一个任务。这意味着:

  • 所有同步代码必须按顺序执行,前一个任务没完成,后一个任务必须等待;
  • 如果有一个任务耗时很长(比如死循环、大量计算),后面的任务会被“阻塞”(卡住)。

举个栗子

console.log('开始');
while (true) {  // 死循环(耗时任务)
  // 主线程被卡死,后面的代码永远执行不到
}
console.log('结束');  // 永远不会输出

这就是单线程的“缺陷”——容易阻塞。但 JS 又必须处理异步操作(比如网络请求、文件读取),否则早就被淘汰了。

为什么 JS 必须支持异步?

因为浏览器需要处理大量“耗时但不阻塞主线程”的操作:

  • 网络请求(fetchXMLHttpRequest);
  • 定时器(setTimeoutsetInterval);
  • 事件监听(点击、滚动);
  • 文件操作(Node.js 中的 fs 模块)。

如果这些操作都用同步方式执行,浏览器会直接“假死”(比如点一个按钮要等 3 秒才能响应)。所以 JS 必须设计一套机制,让这些耗时操作“在后台执行”,不阻塞主线程。

事件循环的核心角色:谁在“调度”任务?

要理解事件循环,必须先明确 JS 运行时的几个核心“角色”:

角色 职责 所属环境
调用栈(Call Stack) 存储同步任务的执行上下文(函数调用链),按“后进先出”顺序执行 JS 引擎(V8)
Web APIs 浏览器提供的“后台线程能力”(如定时器、网络请求、DOM 事件监听) 浏览器(非 JS 引擎)
任务队列(Task Queue) 存储异步任务完成后的“回调函数”,等待事件循环调度执行 浏览器
事件循环(Event Loop) 不断检查调用栈是否为空,若为空则从任务队列中取出回调函数执行 浏览器

一句话总结
JS 主线程(调用栈)只负责执行同步代码;遇到异步操作(如 setTimeout),就交给 Web APIs 后台处理;Web APIs 完成任务后,把回调函数“丢”到任务队列;事件循环负责“盯着”调用栈,一旦调用栈空了,就从任务队列里“捞”回调函数来执行。

事件循环的执行流程(附代码演示)

基础流程:同步任务 → 微任务 → 宏任务

事件循环的执行顺序有一个核心规则:同步任务优先,微任务(Microtask)次之,宏任务(Macrotask)最后

关键概念区分:

  • 同步任务:直接在调用栈中执行的任务(如 console.log、普通函数调用);
  • 微任务:由 Promise.thenMutationObserver 等 API 产生的回调,优先级高于宏任务;
  • 宏任务:由 setTimeoutsetIntervalI/O、DOM 事件等 API 产生的回调,优先级低于微任务。

代码演示 1:基础执行顺序

console.log('1: 同步任务');

setTimeout(() => {
  console.log('3: 宏任务(setTimeout)');
}, 0);

Promise.resolve()
  .then(() => {
    console.log('2: 微任务(Promise.then)');
  })
  .then(() => {
    console.log('4: 微任务(另一个 Promise.then)');
  });

输出顺序1 → 2 → 4 → 3

执行流程拆解

  1. 执行同步任务:调用栈先执行 console.log('1'),输出 1

  2. 处理异步任务

    • 遇到 setTimeout,将其交给 Web APIs 的定时器线程处理(定时 0ms);
    • 遇到 Promise.resolve().then(),将其回调(第一个 then)加入微任务队列;
  3. 同步任务执行完毕:调用栈清空;

  4. 执行微任务:事件循环检查微任务队列,按顺序执行两个 then 回调,输出 24

  5. 执行宏任务:微任务队列清空后,事件循环检查宏任务队列,执行 setTimeout 的回调,输出 3

进阶流程:嵌套异步任务

实际代码中,异步任务可能嵌套(比如 setTimeout 里又有 Promise),事件循环的处理逻辑依然遵循“同步→微任务→宏任务”的顺序,但需要注意每一轮事件循环只处理一个宏任务(除非宏任务队列为空)。

代码演示 2:嵌套异步任务

console.log('A: 同步任务');

setTimeout(() => {
  console.log('B: 宏任务(外层 setTimeout)');
  
  // 嵌套的微任务
  Promise.resolve().then(() => {
    console.log('C: 微任务(外层 setTimeout 内的 then)');
  });
  
  // 嵌套的宏任务
  setTimeout(() => {
    console.log('D: 宏任务(内层 setTimeout)');
  }, 0);
}, 0);

// 顶层的微任务
Promise.resolve().then(() => {
  console.log('E: 微任务(顶层 then)');
});

输出顺序A → E → B → C → D

执行流程拆解

  1. 执行同步任务:输出 A

  2. 处理异步任务

    • 外层 setTimeout 交给定时器线程(0ms);
    • 顶层 Promise.then 回调加入微任务队列;
  3. 同步任务结束:调用栈清空;

  4. 执行微任务:执行顶层 then 回调,输出 E

  5. 第一轮事件循环结束,开始处理宏任务队列:

    • 取出外层 setTimeout 的回调执行,输出 B
    • 外层 setTimeout 回调中遇到新的 Promise.then,将其加入微任务队列;
    • 外层 setTimeout 回调中遇到内层 setTimeout,交给定时器线程(0ms);
  6. 第一轮宏任务处理完毕,立即执行新产生的微任务:

    • 执行外层 setTimeout 内的 then 回调,输出 C
  7. 第二轮事件循环开始,处理宏任务队列:

    • 取出内层 setTimeout 的回调执行,输出 D

特殊场景:DOM 事件与异步

DOM 事件(如点击、滚动)的回调属于宏任务,但它们的触发时机与事件循环密切相关。

代码演示 3:DOM 事件回调

<button id="btn">点击我</button>
<script>
  console.log('1: 同步任务');

  const btn = document.getElementById('btn');
  btn.addEventListener('click', () => {
    console.log('3: DOM 事件回调(宏任务)');
    
    // 事件回调中的微任务
    Promise.resolve().then(() => {
      console.log('4: 事件回调内的微任务');
    });
  });

  setTimeout(() => {
    console.log('2: 宏任务(setTimeout)');
  }, 0);
</script>

可能的输出顺序(取决于用户何时点击按钮):

  • 如果用户在 setTimeout 回调执行前点击按钮:1 → 3 → 4 → 2
  • 如果用户在 setTimeout 回调执行后点击按钮:1 → 2 → 3 → 4

执行流程拆解

  1. 执行同步任务,输出 1

  2. 注册 setTimeout(宏任务)和 click 事件监听(宏任务);

  3. 若用户先点击按钮:

    • 点击事件触发,回调被加入宏任务队列;
    • setTimeout 回调也被加入宏任务队列;
    • 事件循环先处理 click 宏任务(因为先进入队列),输出 3
    • 执行 click 回调中的微任务,输出 4
    • 最后处理 setTimeout 宏任务,输出 2
  4. 若用户后点击按钮:

    • setTimeout 回调先被处理,输出 2
    • 然后处理 click 宏任务,输出 3
    • 最后执行 click 回调中的微任务,输出 4

为什么 JS 单线程还能“异步”?核心答案

现在回到最初的问题:JS 单线程却能处理异步,本质原因是什么?

异步操作不由 JS 引擎主线程执行

JS 引擎(如 V8)只负责执行同步代码和调度任务。像网络请求、定时器这些耗时操作,实际是由浏览器的其他线程(如网络线程、定时器线程)完成的。JS 主线程只需要“注册”异步任务(比如告诉定时器线程“3 秒后执行这个函数”),然后继续执行后面的同步代码。

事件循环负责“调度”异步回调

当异步任务完成后(比如定时器到时间、网络请求返回数据),浏览器会将对应的回调函数放入任务队列。事件循环会不断检查调用栈是否为空:

  • 如果调用栈空了,就从任务队列中取出回调函数,交给调用栈执行;
  • 如果调用栈不空(还在执行同步代码),事件循环就继续等待。

微任务和宏任务的优先级设计

通过区分微任务和宏任务,JS 可以更精细地控制异步回调的执行顺序。微任务(如 Promise.then)会在当前调用栈清空后立即执行(属于“本轮事件循环”),而宏任务(如 setTimeout)会在下一轮事件循环执行。这种设计保证了异步操作的高效调度。

事件循环的“隐藏阶段”:浏览器和 Node.js 的差异

你可能不知道:浏览器和 Node.js 的事件循环实现完全不同!虽然核心目标一致(调度异步任务),但具体阶段划分和执行顺序差异很大。

浏览器的事件循环:聚焦“渲染”与“用户交互”

浏览器的事件循环围绕 “页面渲染” 设计,核心目标是让用户界面流畅响应。它的执行流程可简化为以下步骤(简化版):

1. 执行调用栈中的同步任务 → 
2. 执行所有微任务(Microtask Queue) → 
3. 检查是否需要渲染(如距离上次渲染超过 16ms) → 
   a. 执行 UI 渲染(重排/重绘) → 
4. 从宏任务队列(Macrotask Queue)中取出一个任务执行 → 
5. 重复步骤 2~4(直到所有队列为空)

关键细节

  • 渲染时机:浏览器默认以 60Hz 刷新率(约 16ms/帧)渲染页面。如果同步任务执行时间超过 16ms,会导致“掉帧”(页面卡顿)。因此,事件循环会在每轮宏任务后检查是否需要渲染,避免长时间阻塞渲染。
  • 宏任务的“公平性”:浏览器宏任务队列是“队列优先”(FIFO),但为了保证交互响应,用户触发的事件(如点击、滚动)的回调会被“插队”到队列头部,优先执行。

代码演示:渲染与事件循环的关系

console.log('开始');

// 同步任务:耗时 20ms(超过 16ms 渲染间隔)
const start = performance.now();
while (performance.now() - start < 20) {}

// 宏任务:setTimeout
setTimeout(() => {
  console.log('setTimeout 执行');
}, 0);

// 微任务:Promise.then
Promise.resolve().then(() => {
  console.log('Promise.then 执行');
});

// 输出顺序:开始 → Promise.then → setTimeout 执行

执行流程

  • 同步任务耗时 20ms,导致页面无法在第 16ms 渲染(掉帧);
  • 同步任务结束后,先执行微任务(Promise.then);
  • 最后执行宏任务(setTimeout)。

Node.js 的事件循环:聚焦“ I/O 与异步操作”

Node.js 的事件循环是为 服务器端高并发 I/O 设计的,核心目标是高效处理大量异步 I/O 请求(如文件读写、数据库查询)。它的阶段划分更复杂,具体分为 6 个阶段(按执行顺序):

阶段 职责 对应的宏任务类型
timers 执行 setTimeoutsetInterval 的回调 定时器相关宏任务
I/O callbacks 处理上一轮未完成的 I/O 错误回调(如 TCP 连接错误) 系统级 I/O 错误回调
idle/prepare 内部使用,仅在 Node.js 启动时执行
poll 核心阶段:等待新的 I/O 事件(如文件读取完成、网络请求响应) 大部分 I/O 相关宏任务(除 setImmediate
check 执行 setImmediate 的回调 setImmediate 宏任务
close callbacks 执行关闭事件的回调(如 socket.on('close') 连接关闭相关回调

关键细节

  • setImmediate vs setTimeout(0)
    • setImmediate 的回调在 check 阶段执行(本轮事件循环的最后阶段);
    • setTimeout(0) 的回调在 timers 阶段执行(下一轮事件循环的开始阶段)。
      因此,setImmediate 永远比 setTimeout(0) 先执行(在 Node.js 中)。

代码演示:Node.js 事件循环阶段

const fs = require('fs');

// 阶段 1:timers(setTimeout)
setTimeout(() => {
  console.log('1: setTimeout(timers 阶段)');
}, 0);

// 阶段 4:poll(fs.readFile 是 I/O 操作)
fs.readFile(__filename, () => {
  console.log('2: fs.readFile 回调(poll 阶段)');
  
  // 阶段 5:check(setImmediate)
  setImmediate(() => {
    console.log('3: setImmediate(check 阶段)');
  });
  
  // 阶段 1:timers(嵌套的 setTimeout)
  setTimeout(() => {
    console.log('4: 嵌套 setTimeout(timers 阶段)');
  }, 0);
});

// 阶段 5:check(直接调用 setImmediate)
setImmediate(() => {
  console.log('5: 顶层 setImmediate(check 阶段)');
});

输出顺序(每次运行可能略有不同,但 setImmediate 总在 setTimeout(0) 前):

1: setTimeout(timers 阶段)
2: fs.readFile 回调(poll 阶段)
3: setImmediate(check 阶段)
5: 顶层 setImmediate(check 阶段)
4: 嵌套 setTimeout(timers 阶段)

宏任务与微任务的“细分家族”:你以为的“宏任务”可能不是真宏任务

之前我们简单区分了宏任务和微任务,但实际上它们的“家族成员”各有不同。明确每个任务的来源,能帮你更精准地控制执行顺序。

宏任务(Macrotask)的常见类型

宏任务是“需要等待当前调用栈和微任务队列清空后才能执行”的任务,常见来源包括:

来源 示例 说明
定时器 setTimeoutsetInterval 基于时间的延迟执行
I/O 操作 fs.readFile(Node.js)、fetch 网络请求、文件读写等异步 I/O
UI 渲染(浏览器) requestAnimationFrame(部分场景) 浏览器渲染帧的回调
事件监听 clickscroll 等 DOM 事件回调 用户交互触发的回调
setImmediate(Node.js) setImmediate(() => {}) Node.js 特有的“本轮事件循环结束后”执行

微任务(Microtask)的常见类型

微任务是“在当前调用栈清空后立即执行”的任务,优先级高于宏任务,常见来源包括:

来源 示例 说明
Promise 回调 Promise.then()Promise.catch() Promise 状态变为 fulfilledrejected 后的回调
MutationObserver(浏览器) new MutationObserver(callback) 监听 DOM 变化的回调
process.nextTick(Node.js) process.nextTick(() => {}) Node.js 特有的“微任务队列优先级最高”的任务(比普通微任务更早执行)
async/await(本质是 Promise) async function() { await ... } await 后的代码会被包装成微任务

关键细节

  • async/await 的底层是 Promise:await 会暂停函数执行,将后续代码包装成微任务,等待 Promise 解决后执行。
  • process.nextTick 在 Node.js 中是“伪微任务”:它的优先级比普通微任务(如 Promise.then)更高,会在当前调用栈清空后立即执行(甚至在宏任务之前)。

代码演示:Node.js 中 process.nextTick 的优先级

console.log('开始');

setTimeout(() => {
  console.log('1: setTimeout(宏任务)');
}, 0);

Promise.resolve().then(() => {
  console.log('2: Promise.then(微任务)');
});

process.nextTick(() => {
  console.log('3: process.nextTick(伪微任务)');
});

console.log('结束');

输出顺序

开始  结束  3: process.nextTick  2: Promise.then  1: setTimeout

调试工具:用 Chrome DevTools“看见”事件循环

理论看懂了,但如何验证?推荐用 Chrome DevTools 的 Performance 面板 直接观察事件循环的执行过程。

步骤 1:打开 Performance 面板

  1. F12 打开 DevTools;
  2. 点击 Performance 标签;
  3. 点击左上角的 录制 按钮(或按 Ctrl+E)开始记录。

步骤 2:执行测试代码

在控制台执行以下代码:

console.log('同步任务');

setTimeout(() => {
  console.log('宏任务(setTimeout)');
}, 0);

Promise.resolve().then(() => {
  console.log('微任务(Promise.then)');
});

步骤 3:分析记录结果

停止录制后,Performance 面板会显示时间线。重点关注 Main 线程 的调用栈和任务队列:

  • 同步任务:显示为连续的调用栈(如 console.log('同步任务'));
  • 微任务:在调用栈清空后,显示为 Promise.then 的执行;
  • 宏任务:在微任务执行后,显示为 setTimeout 的回调执行。

截图示例(文字描述):

  • 时间轴 0ms:开始执行同步任务;
  • 时间轴 1ms:同步任务结束,调用栈清空;
  • 时间轴 1ms:执行微任务(Promise.then);
  • 时间轴 2ms:微任务结束,开始执行宏任务(setTimeout)。

实战避坑:事件循环的常见陷阱

陷阱 1:定时器“延迟不准”

你可能遇到过:setTimeout(fn, 100) 实际执行时间远大于 100ms。原因是 同步任务耗时过长,阻塞了事件循环,导致定时器回调无法按时执行。

示例

setTimeout(() => {
  console.log('定时器执行'); // 实际延迟可能远大于 100ms
}, 100);

// 同步任务耗时 1000ms
const start = Date.now();
while (Date.now() - start < 1000) {}

解决方案

  • 避免在主线程执行耗时操作(如大数据计算);
  • 将耗时任务拆分为小任务(用 setTimeoutrequestIdleCallback 分段执行);
  • 使用 Web Workers(浏览器)或 Worker Threads(Node.js)将任务放到后台线程。

陷阱 2:微任务“堆积导致页面卡死”

微任务会在当前调用栈清空后立即执行,如果微任务队列无限增长(如递归调用 Promise.then),会导致页面永远无法渲染(因为事件循环被微任务阻塞)。

示例

function recursiveMicrotask() {
  Promise.resolve().then(() => {
    console.log('微任务执行');
    recursiveMicrotask(); // 递归添加微任务
  });
}

recursiveMicrotask();

现象

  • 控制台会无限输出 微任务执行
  • 页面无法响应点击、滚动等交互(因为事件循环被微任务队列占满,无法执行渲染或事件回调)。

解决方案

  • 避免在微任务中递归调用自身;
  • 限制微任务的数量(如设置最大递归次数);
  • 将部分任务转为宏任务(如用 setTimeout 包裹)。

陷阱 3:Node.js 中 setImmediatesetTimeout 的“竞争”

在 Node.js 中,setImmediatesetTimeout(0) 的执行顺序可能不稳定(取决于事件循环的启动时间)。

示例

setTimeout(() => {
  console.log('setTimeout(timers 阶段)');
}, 0);

setImmediate(() => {
  console.log('setImmediate(check 阶段)');
});

可能的输出

  • 第一次运行:setTimeout 先执行(如果 timers 阶段的延迟接近 0);
  • 第二次运行:setImmediate 先执行(如果 timers 阶段没有其他任务)。

解决方案

  • 如果需要严格的执行顺序,避免依赖 setImmediatesetTimeout(0) 的顺序;
  • 明确使用 setImmediate 表示“本轮事件循环结束后执行”,setTimeout 表示“延迟指定时间后执行”。

常见误区与总结

误区 1:JS 是多线程的?

错误。JS 主线程是单线程的,但浏览器环境(如 Web APIs)是多线程的。JS 通过事件循环与这些线程协作,实现了“异步”的效果。

误区 2:setTimeout(0) 会立即执行?

错误setTimeout(0) 表示“将回调函数放入宏任务队列,等待当前调用栈和微任务队列清空后立即执行”,而不是立即执行。

误区 3:微任务比宏任务优先级高,所以会插队?

正确。微任务队列的优先级高于宏任务队列,所以每轮事件循环会先清空微任务队列,再处理宏任务队列。

事件循环的“口诀”

事件循环的核心是 “协作式调度”:JS 主线程负责同步任务,异步任务交给其他线程处理,完成后通过回调函数“排队”,事件循环按优先级(微任务→宏任务)调度执行。

同步任务先执行,调用栈里排好队;
遇到异步交后台,Web APIs 帮你忙;
定时网络事件等,完成回调放队列;
微任务队列优先级,当前循环接着跑;
宏任务队列下一轮,事件循环来回跑;
单线程不阻塞住,异步全靠事件环。

用 Turborepo 打造 Strapi 插件开发的极速全栈体验

作者 知航驿站
2025年12月17日 21:05

什么是Turborepo

我在两三年前听到这个名词一直没搞懂,今天我借助AI+实践搞懂了这个架构,以及我们为什么要用它

在现代前端工程化中,"Monorepo" 已经成为了一个绕不开的热词。但很多开发者在初次接触时,容易把它和 "Multirepo" 甚至 "Turborepo" 混淆

1. Multirepo:各自为政的“独栋别墅”

Multirepo (Multiple Repositories) 是最传统的管理方式。

🏠 比喻:你的前端团队住在 A 栋别墅,后端团队住在 B 栋别墅,UI 组件库团队住在 C 栋别墅。大家互不干扰,物理隔离。

这种模式是怎样的?

  • 每个项目都有独立的 Git 仓库(例如 git/frontendgit/backend)。
  • 每个仓库有自己独立的配置(ESLint, TSConfig)。

✅ 优点

  • 权限清晰:你可以只给新人开前端仓库的权限,他看不到后端的代码。
  • 体积小git clone 很快,因为你只拉取了这一个项目的代码。

❌ 痛点

  • 协作噩梦:当你修了一个 Bug,需要同时改动前端和后端代码时,你无法在一个 Commit 里完成。
  • 依赖地狱:如果你改了 UI组件库 的代码,你得先发个 npm 包,然后去前端项目里升级版本号,再 npm install……这一套流程下来,一下午就过去了。
这种模式应该是目前大家所最常用的一种模式,这个主要是思维惯性与工具默认,早期的 CI/CD 工具(如 Jenkins)也更习惯于一个仓库对应一条流水线, 当你用 create-react-app 或 git init 时,默认就是创建一个仓库管一个项目

2. Monorepo:欢聚一堂的“大平层”

为了解决 Multirepo 的协作痛点,Google、Facebook 等大厂率先采用了 Monorepo (Monolithic Repository)

🏢 比喻:大家搬进了一栋巨大的大平层公寓。虽然每人还有自己的房间(目录),但都在同一个大门(Git 仓库)里进出。

这种模式是怎样的?

  • 所有项目都在同一个 Git 仓库里(例如 apps/webapps/docspackages/ui)。
  • 使用 Workspace(工作区)技术将它们在本地软链接起来。

✅ 优点

  • 原子提交 (Atomic Commits):你可以一次 Commit 同时修复前后端的 Bug,保证版本的一致性。
  • 代码共享零成本:你在 packages/ui 里改了按钮颜色,保存一下,apps/web 里立马生效,根本不需要发包!
  • 统一规范:大家共用一套配置,从此不再有“你用 2 空格缩进,我用 4 空格”的争吵。

❌ 新的问题

  • 构建太慢了! 以前你只构建一个项目,现在仓库里有 10 个项目。如果每次改一行代码,CI(持续集成)都要把 10 个项目全跑一遍,那就要等到天荒地老

在微服务架构流行之前(大概 2015 年以前),绝大多数项目(包括 JSP、ASP.NET、PHP)其实都是单体。那时候大家默认就是“一个仓库管所有代码”,根本不需要发明 Monorepo 这个词。后来,微服务和前后端分离流行起来了,大家把仓库拆散了(Multirepo)。再后来,大家发现拆太散了不好管,又把它们合并回来了,这时候为了区分“强耦合的单体”和“松耦合的多项目集合”,才强调了 Monorepo 这个概念


3. Turborepo:大平层的“智能管家”

这时候,Turborepo 登场了。

很多人的误区在于:“我要用 Turborepo 还是用 Monorepo?” 这是一个错误的问题。 Turborepo 不是 Monorepo 的替代品,而是它的加速器

🤖 比喻:Turborepo 是这栋大平层公寓配备的AI 智能管家

Turborepo 解决了什么?

它专门解决 Monorepo “构建慢、任务乱” 的问题。

⚡️ 核心黑科技 1:任务编排 (Pipeline)

它知道 web 应用依赖 ui 包。当你运行构建命令时,它会自动先构建 ui,而且能把互不相关的任务(比如 linttest)利用多核 CPU 并行执行

⚡️ 核心黑科技 2:智能缓存 (Caching)

这是 Turborepo 的杀手锏。当你运行构建时,它会先检查:“哎,这个文件之前构建过吗?输入变了吗?”

  • 如果没变:直接从缓存复制粘贴结果。时间消耗:几毫秒
  • 甚至支持云端缓存:你同事构建过的结果,你拉下来也能直接用!

4. 项目实践

Turborepo官网

安装

pnpm dlx create-turbo@latest

以下是我工程目录

我们的目标目录结构如下:

my-strapi-monorepo/
├── apps/
│   ├── backend/          # Strapi v5 主程序
│   └── docs/             # VitePress 文档站
├── packages/
│   ├── strapi-plugin-bag/ # 我们的插件核心代码
│   └── ui/               # (可选) 共享 UI 组件库
├── package.json
├── pnpm-workspace.yaml
└── turbo.json

这种结构清晰地分离了“宿主应用”和“插件包”,模拟了真实的 npm 包引用场景。

统一依赖管理 (Workspace)

在根目录的 pnpm-workspace.yaml 中,我们将所有项目串联起来:

packages:
  - "apps/*"
  - "packages/*"

然后,在 apps/backend/package.json 中,我们可以直接引用本地插件,无需发包,无需 npm link

"dependencies": {
  "strapi-plugin-bag": "workspace:*"
}

这意味着,strapi-plugin-bag 的任何修改,对于 backend 来说都是实时可见的。

配置 turbo.json

这是 Turborepo 的大脑。我们需要告诉它,Strapi 的构建产物在哪里,以及哪些任务需要并行。

{
  "$schema": "https://turborepo.com/schema.json",
  "tasks": {
    "build": {
      // 依赖上游的 build (例如 backend 依赖插件先 build 完)
      "dependsOn": ["^build"],
      // 告诉 Turbo 哪些文件是产物,下次命中缓存直接恢复,不用真跑
      "outputs": ["dist/**", "build/**", ".next/**"]
    },
    "dev": {
      // 开发服务器不需要缓存,且需要持久化运行
      "cache": false,
      "persistent": true
    }
  }
}

小技巧:Strapi 的产物在 build/,插件在 dist/,VitePress 在 .vitepress/dist。确保把这些目录都加到 outputs 里,Turbo 的缓存才能生效。

一键启动全栈环境

在根目录运行:

pnpm dev

Turborepo 会自动寻找所有子项目中的 dev 脚本并并行执行:

  • Backend: 启动 Strapi CMS (http://localhost:1337)
  • Plugin: 开启 TypeScript 实时编译监听
  • Docs: 启动 VitePress 文档 (http://localhost:5173)

所有日志汇聚在一个终端,互不干扰,配合极好。

精准构建

只想重新构建插件?不需要跑整个仓库:

turbo run build --filter=strapi-plugin-bag

Turbo 的 --filter 参数非常强大,支持依赖拓扑筛选。比如 turbo run build --filter=backend... 会自动先构建 backend 依赖的所有包(插件),然后再构建 backend,--filter=等于项目名字(在package.json)

📝 总结

通过引入 Turborepo,我们将分散的 Strapi 插件开发流程整合成了一个有机的整体。

  • 开发pnpm dev 一键起飞。
  • 调试:Workspace 软链接让源码修改即时生效。
  • 文档:代码和文档同仓管理,版本同步更容易。

如果你也在做 Strapi 插件开发,强烈推荐尝试这种架构,它会让你从繁琐的 cd 命令中解放出来,专注于代码本身。

image.png

总结:该怎么选?

模式 适用场景 关键特点
Multirepo 团队完全隔离,技术栈无关,项目间几乎没有代码复用。 简单,但协作成本高。
Monorepo 全栈团队,前后端紧密协作,有公共组件库或工具库。 协作爽,代码复用率高。
Turborepo 只要你用了 Monorepo,就强烈建议用它! 让 Monorepo 快得飞起。

一句话总结: Monorepo 是一种架构风格(把大家聚在一起),而 Turborepo 是工具(让大家聚在一起还能高效干活)

【AI 编程实战】第 4 篇:一次完美 vs 五轮对话 - UnoCSS 配置的正确姿势

作者 HashTang
2025年12月17日 20:07

很多教程告诉你"写好提示词就能让 AI 一次给出正确答案",但现实是:你不可能一开始就知道所有细节。真正高效的 AI 协作,是在对话中逐步明确需求。这篇文章通过一个完整的 UnoCSS 配置案例,展示我和 TRAE SOLO 的真实交互过程——从模糊想法到完整方案,中间经历了哪些沟通和调整。

这是《AI 编程实战:TRAE SOLO 全栈开发指南》专栏的第四篇文章,带你学会如何与 AI 正确的沟通协作。

一、开篇:别被"完美提示词"骗了

1.1 一个常见的误区

网上很多 AI 教程会给你看这样的"完美提示词":

帮我配置 UnoCSS,项目环境和需求如下:
【项目环境】UniApp + Vue3 + Vite,目标平台微信小程序
【功能需求】支持 attributify、@apply、图标、安全区适配
【已知问题】小程序 transform 类需要预设 CSS 变量
【输出要求】完整的 uno.config.ts 配置文件

然后告诉你:"看,一次就能得到完美答案!"

但问题是

  • 你一开始怎么知道需要"安全区适配"?
  • 你怎么知道"transform 类需要预设 CSS 变量"?
  • 这些都是踩坑之后才知道的

1.2 真实的 AI 协作是什么样的

真实的 AI 协作是一个渐进式对话的过程:

第 1 轮:我有个模糊的需求
    ↓
AI:给出初步方案 + 执行计划
    ↓
第 2 轮:我发现问题 / 补充细节
    ↓
AI:调整方案
    ↓
第 3 轮:遇到报错 / 样式不生效
    ↓
AI:排查问题 + 修复
    ↓
最终:得到完整可用的方案

接下来,我会完整展示和 TRAE SOLO 配置 UnoCSS 的真实对话过程

二、第一轮对话:从模糊需求开始

2.1 我的第一句话

刚开始,我对 UnoCSS 的了解很有限,只知道它是"原子化 CSS"。我的第一句话很简单:

我:项目需要用 UnoCSS,帮我配置一下

2.2 TRAE SOLO 的响应:先给执行计划

TRAE SOLO 没有直接开始写代码,而是先给出了执行计划

TRAE SOLO:好的,我来帮你配置 UnoCSS。在开始之前,我需要先了解一些信息:

📋 执行计划:
1. 分析项目结构,确定技术栈
2. 选择合适的 UnoCSS 预设
3. 生成配置文件
4. 配置 Vite 插件

🤔 我有几个问题:
1. 这是什么类型的项目?(Vue/React/UniApp)
2. 目标运行平台是什么?(Web/小程序/App)
3. 有没有特别想要的功能?(图标/attributify 模式等)

请告诉我这些信息,或者我可以先分析你的项目结构来判断。

关键点:TRAE SOLO 主动询问上下文,而不是瞎猜。

2.3 我补充信息

我:UniApp + Vue3 项目,主要跑微信小程序,也要支持 H5。
    功能的话,我也不太清楚,你看着配吧,常用的都加上。

2.4 TRAE SOLO 分析项目后给出方案

TRAE SOLO:我分析了你的项目结构,发现:
- 框架:UniApp + Vue3 + Vite
- UI 库:已安装 wot-design-uni
- 目标:微信小程序 + H5

📋 更新执行计划:

1. 安装依赖
   - unocss
   - @uni-helper/unocss-preset-uni (UniApp 专用预设,必须)
   - @iconify-json/carbon (图标库,可选)

2. 创建 uno.config.ts,包含:
   - presetUni:小程序适配
   - presetAttributify:支持属性化写法
   - presetIcons:图标支持
   - transformerDirectives:支持 @apply 指令

3. 修改 vite.config.ts,添加 UnoCSS 插件

4. 在 main.ts 中引入 uno.css

是否按这个计划执行?或者你想调整什么?

2.5 我确认执行

我:可以,就按这个来
TRAE SOLO:好的,开始执行...

✅ 正在安装依赖...
✅ 创建 uno.config.ts...
✅ 修改 vite.config.ts...
✅ 修改 main.ts...

配置完成!你可以这样使用:

<view class="flex items-center gap-4 p-4 bg-white rounded-xl">
  <text class="text-lg font-bold">Hello UnoCSS</text>
</view>

运行 pnpm dev:mp-weixin 试试看效果。

三、第二轮对话:遇到问题,继续沟通

3.1 我发现样式不生效

运行项目后,我发现部分样式不生效:

我:样式大部分生效了,但是 translate-x-2 这个类没效果,
    元素没有移动

3.2 TRAE SOLO 排查问题

TRAE SOLO:让我检查一下...

我查看了生成的 CSS,发现 translate-x-2 生成的代码是:

transform: translateX(var(--un-translate-x));

问题在于:小程序环境没有预设 --un-translate-x 这个 CSS 变量。

在浏览器中,UnoCSS 会通过 :root 选择器预设这些变量,
但小程序不支持 :root 选择器。

🔧 修复方案:
在 uno.config.ts 中添加 preflights,手动为小程序元素预设变量。

是否需要我帮你修复?

3.3 我让它修复

我:帮我修复吧
TRAE SOLO:好的,我来修改 uno.config.ts...

添加了以下配置:

preflights: [
  {
    layer: 'unocss-transform-fix',
    getCSS: () => `
page, view, text, image {
  --un-translate-x: 0;
  --un-translate-y: 0;
  --un-rotate: 0;
  --un-scale-x: 1;
  --un-scale-y: 1;
  /* ... 其他变量 */
}
`,
  },
],

✅ 已修复,重新运行项目试试。

3.4 我验证修复成功

我:好了,translate 生效了

四、第三轮对话:追加新需求

4.1 我想添加安全区适配

开发过程中,我发现 iPhone 底部有安全区域遮挡内容:

我:iPhone 底部有个横条会挡住内容,UnoCSS 能处理这个吗?

4.2 TRAE SOLO 给出方案

TRAE SOLO:这是 iPhone 的安全区域问题。可以通过自定义 UnoCSS 规则来解决。

📋 方案:
添加以下自定义规则:
- pb-safe:底部安全区内边距
- pt-safe:顶部安全区内边距(刘海屏)
- p-safe:四边安全区内边距

使用方式:
<view class="pb-safe">底部内容会避开安全区</view>

是否需要我添加这些规则?
我:加上吧
TRAE SOLO:好的,在 uno.config.ts 的 rules 中添加:

rules: [
  ['pb-safe', { 'padding-bottom': 'env(safe-area-inset-bottom)' }],
  ['pt-safe', { 'padding-top': 'env(safe-area-inset-top)' }],
  ['p-safe', {
    padding: 'env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left)',
  }],
],

✅ 已添加,现在可以使用 pb-safe 类了。

五、第四轮对话:优化开发体验

5.1 我觉得类名太长了

我:flex items-center justify-between 每次都要写这么长,
    有没有办法简化?

5.2 TRAE SOLO 介绍 shortcuts

TRAE SOLO:可以用 UnoCSS 的 shortcuts 功能,把常用组合定义成短类名。

比如:
- center = flex justify-center items-center
- between = flex justify-between items-center

你想定义哪些快捷方式?或者我根据你的代码分析常用组合?
我:你分析一下吧,看哪些用得多

5.3 TRAE SOLO 分析代码后给出建议

TRAE SOLO:我扫描了你的代码,发现以下组合出现频率较高:

📊 使用频率分析:
1. flex items-center (出现 23 次)
2. flex items-center justify-between (出现 15 次)
3. flex items-center justify-center (出现 12 次)
4. flex flex-col (出现 8 次)
5. bg-white rounded-xl p-4 (出现 7 次)

🔧 建议添加的 shortcuts:

shortcuts: [
  { 'center': 'flex justify-center items-center' },
  { 'between': 'flex justify-between items-center' },
  { 'col': 'flex flex-col' },
  { 'card': 'bg-white rounded-xl p-4' },
],

是否按这个配置?或者你想调整?
我:可以,就这样

六、第五轮对话:处理动态类名问题

6.1 我遇到动态类名不生效

我:我有个地方根据状态切换颜色:

    <text :class="`text-${status}-500`">状态</text>

    status 可能是 red、green、blue,但样式不生效

6.2 TRAE SOLO 解释原因并给方案

TRAE SOLO:这是 UnoCSS 的一个限制。

🔍 原因:
UnoCSS 在构建时静态扫描代码,`text-${status}-500` 这种
动态拼接的类名无法被识别,所以不会生成对应的 CSS。

🔧 解决方案有三种:

【方案一】对象语法(推荐)
<text :class="{
  'text-red-500': status === 'red',
  'text-green-500': status === 'green',
  'text-blue-500': status === 'blue',
}">状态</text>

【方案二】safelist 预声明
在 uno.config.ts 中添加:
safelist: ['text-red-500', 'text-green-500', 'text-blue-500']

【方案三】CSS 变量
<text class="text-[var(--status-color)]"
      :style="{ '--status-color': statusColor }">
  状态
</text>

你的场景用哪种方案比较合适?
我:颜色就这三种,用方案二吧,简单
TRAE SOLO:好的,已添加到 safelist:

safelist: [
  'text-red-500',
  'text-green-500',
  'text-blue-500',
  'i-carbon-code', // 之前已有的图标
],

✅ 现在动态类名可以正常工作了。

七、最终配置:多轮对话的成果

经过 5 轮对话,最终的 uno.config.ts 是这样的:

// uno.config.ts
// 这个配置是通过多轮对话逐步完善的,不是一次性写出来的

import { presetUni } from '@uni-helper/unocss-preset-uni'
import {
  defineConfig,
  presetAttributify,
  presetIcons,
  transformerDirectives,
  transformerVariantGroup,
} from 'unocss'

export default defineConfig({
  // 【第 1 轮】基础预设
  presets: [
    presetUni({
      attributify: {
        prefixedOnly: true,
      },
    }),
    presetIcons({
      scale: 1.2,
      warn: true,
      extraProperties: {
        'display': 'inline-block',
        'vertical-align': 'middle',
      },
    }),
    presetAttributify(),
  ],

  // 【第 1 轮】转换器
  transformers: [
    transformerDirectives(),
    transformerVariantGroup(),
  ],

  // 【第 2 轮】修复小程序 transform 不生效
  preflights: [
    {
      layer: 'unocss-transform-fix',
      getCSS: () => `
page, view, text, image {
  --un-translate-x: 0;
  --un-translate-y: 0;
  --un-translate-z: 0;
  --un-rotate: 0;
  --un-rotate-x: 0;
  --un-rotate-y: 0;
  --un-rotate-z: 0;
  --un-skew-x: 0;
  --un-skew-y: 0;
  --un-scale-x: 1;
  --un-scale-y: 1;
  --un-scale-z: 1;
}
`,
    },
  ],

  // 【第 3 轮】安全区适配
  rules: [
    ['p-safe', {
      padding: 'env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left)',
    }],
    ['pt-safe', { 'padding-top': 'env(safe-area-inset-top)' }],
    ['pb-safe', { 'padding-bottom': 'env(safe-area-inset-bottom)' }],
  ],

  // 【第 4 轮】常用快捷方式
  shortcuts: [
    { 'center': 'flex justify-center items-center' },
    { 'between': 'flex justify-between items-center' },
    { 'col': 'flex flex-col' },
    { 'card': 'bg-white rounded-xl p-4' },
  ],

  // 【第 5 轮】动态类名预声明
  safelist: [
    'text-red-500',
    'text-green-500',
    'text-blue-500',
    'i-carbon-code',
  ],

  // 【第 1 轮】主题配置
  theme: {
    colors: {
      primary: 'var(--wot-color-theme, #0957DE)',
    },
    fontSize: {
      '2xs': ['20rpx', '28rpx'],
      '3xs': ['18rpx', '26rpx'],
    },
  },
})

八、组件开发:同样是对话式协作

8.1 开发回复卡片组件

配置完成后,我开始开发业务组件。同样是对话式协作:

我:帮我写一个回复卡片组件,用来展示 AI 生成的回复。
    左边是语气标签(比如"高情商"),中间是回复文字,右边是复制按钮。
TRAE SOLO:好的,我来帮你开发这个组件。

📋 执行计划:
1. 创建 ReplyCard.vue 组件
2. 定义 Props:tone(语气)、text(文本)
3. 定义 Events:copy(复制)
4. 使用 UnoCSS 原子类实现样式

🤔 几个问题:
1. 语气标签的颜色是固定的还是根据内容变化?
2. 复制按钮是图标还是文字?
3. 有没有设计稿可以参考?
我:标签颜色根据语气类型自动变,用哈希算法分配吧。
    复制按钮用图标。设计稿没有,你自己设计一个好看的。
TRAE SOLO:明白了,我来实现:

【设计方案】
- 卡片:白色背景、圆角、内边距
- 标签:小圆角、根据文字哈希分配 5 种颜色
- 文本:深灰色、支持多行
- 按钮:圆形点击区域、图标居中

开始生成代码...

8.2 TRAE SOLO 生成的组件

<!-- components/ReplyCard.vue -->
<template>
  <view class="flex items-center gap-2.5 bg-white rounded-xl p-4 pr-2">
    <!-- 语气标签 -->
    <view
      v-show="!hideTag"
      class="text-xs font-bold rounded-sm px-2 py-1 flex-shrink-0"
      :style="{ backgroundColor: tagStyle.bg, color: tagStyle.text }"
    >
      {{ tone }}
    </view>

    <!-- 回复文本 -->
    <view class="text-sm text-#1F2937 flex-1">
      {{ text }}
    </view>

    <!-- 复制按钮 -->
    <view
      class="w-9 h-9 center cursor-pointer"
      @click="$emit('copy', text)"
    >
      <image src="/static/images/home/copy.svg" mode="aspectFit" class="w-5 h-5" />
    </view>
  </view>
</template>

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

interface Props {
  tone: string;
  text: string;
  hideTag?: boolean;
}

const props = withDefaults(defineProps<Props>(), {
  hideTag: false,
});

defineEmits<{
  copy: [text: string];
}>();

// 5 种预设颜色
const COLORS = [
  { bg: '#F3E8FF', text: '#8B5CF6' },  // 紫色
  { bg: '#E6FFFA', text: '#319795' },  // 青色
  { bg: '#FFF5E6', text: '#F56500' },  // 橙色
  { bg: '#FFE4E6', text: '#E11D48' },  // 粉色
  { bg: '#E0F2FE', text: '#0284C7' },  // 蓝色
];

// 根据语气文字哈希分配颜色
const tagStyle = computed(() => {
  let hash = 0;
  for (let i = 0; i < props.tone.length; i++) {
    hash = (hash + props.tone.charCodeAt(i)) % COLORS.length;
  }
  return COLORS[hash];
});
</script>

8.3 我提出修改意见

我:不错,但我想加一个功能:有时候不需要显示标签,能不能加个 prop 控制?
TRAE SOLO:已经有了,就是 hideTag 这个 prop。

使用方式:
<ReplyCard tone="高情商" text="回复内容" :hide-tag="true" />

设置 hide-tag 为 true 就会隐藏标签。
我:哦对,我没注意看。那再加个需求:复制成功后给个反馈
TRAE SOLO:复制反馈建议在父组件处理,因为:
1. 卡片组件应该保持简单,只负责 UI
2. 复制逻辑和 toast 提示是业务逻辑,放父组件更合适

父组件这样用:

<ReplyCard
  tone="高情商"
  text="回复内容"
  @copy="handleCopy"
/>

const handleCopy = async (text: string) => {
  await copyText(text);
  toast.success('已复制');
};

你觉得这样合理吗?还是希望把 toast 放在组件内部?
我:你说得对,放父组件更好

九、核心经验:与 AI 协作的正确姿势

9.1 不要追求"一次完美"

错误做法 正确做法
花 30 分钟写一个"完美提示词" 花 1 分钟说出需求,让 AI 提问
一次性想清楚所有细节 边做边发现问题,逐步完善
把 AI 当执行工具 把 AI 当协作伙伴,可以对话

9.2 让 AI 先给执行计划

每次提需求后,让 AI 先给出执行计划

我:[需求描述]

AI:📋 执行计划:
    1. xxx
    2. xxx
    3. xxx

    🤔 有几个问题:
    1. xxx?
    2. xxx?

    确认后我开始执行。

好处

  • 确保 AI 理解正确
  • 发现遗漏的需求
  • 可以在执行前调整方向

9.3 遇到问题时提供上下文

❌ 错误方式

我:样式不生效

✅ 正确方式

我:translate-x-2 这个类在小程序里不生效,但在 H5 正常。
    我看了生成的 CSS,是 transform: translateX(var(--un-translate-x))。
    是不是变量没定义?

9.4 让 AI 解释它的方案

当 AI 给出方案时,追问原理:

我:为什么要用 preflights?直接写 CSS 不行吗?
TRAE SOLO:preflights 是 UnoCSS 的预设样式注入机制,和直接写 CSS 的区别:

1. preflights 会被注入到所有生成的 CSS 之前,确保优先级正确
2. 可以通过 layer 控制加载顺序
3. 统一管理,修改时只需要改配置文件

直接写 CSS 也可以,但需要确保加载顺序正确,否则可能被覆盖。

好处:理解原理后,下次遇到类似问题可以自己判断。

十、总结:对话式协作 > 完美提示词

10.1 本文的核心观点

  1. 不要追求"一次完美的提示词",真实的 AI 协作是多轮对话
  2. 让 AI 先给执行计划,确认后再执行
  3. 遇到问题时提供上下文,帮助 AI 定位问题
  4. 追问原理,理解 AI 的方案,下次才能举一反三

10.2 5 轮对话完成的工作

轮次 需求 成果
第 1 轮 基础配置 presets、transformers、theme
第 2 轮 修复 transform 问题 preflights
第 3 轮 安全区适配 自定义 rules
第 4 轮 简化常用类名 shortcuts
第 5 轮 动态类名支持 safelist

10.3 下一篇预告

《【AI 编程实战】第 5 篇:Pinia 状态管理不会写?让 AI 手把手教你最佳实践》

下一篇继续展示对话式协作,教你:

  • 如何让 AI 设计 Store 结构
  • 状态持久化的对话过程
  • 用户登录态管理的多轮沟通

与 AI 协作的关键不是"写出完美提示词",而是学会和 AI 对话

如果这篇文章对你有帮助,请点赞、收藏!

昨天 — 2025年12月17日掘金 前端

改了CSS刷新没反应-你可能不懂HTTP缓存

2025年12月17日 18:24

改了 CSS 刷新没反应?你可能不懂 HTTP 缓存

做前端的应该都遇到过这种情况:明明改了 CSS,浏览器里看到的还是旧样式。网上搜一下,告诉你"清缓存",Ctrl+F5 一刷新就好了。

但问题是:

  • 缓存到底是怎么工作的?
  • 为什么有时候会用缓存,有时候不会?
  • Network 面板里的 (disk cache)304 Not Modified 有什么区别?

今天把这个问题彻底搞清楚。

HTTP 缓存的两层机制

浏览器缓存分两层:强缓存协商缓存

简单说:

  • 强缓存:直接用本地缓存,不问服务器
  • 协商缓存:问一下服务器"我这个还能用吗?"服务器说"能",就用缓存
浏览器想请求资源
    │
    ▼
检查本地缓存
    │
    ├── 有缓存且没过期 → 强缓存命中 → 返回 200 (from cache)
    │
    └── 没缓存 / 过期了
            │
            ▼
        发请求给服务器,带上验证信息
            │
            ├── 服务器说没变 → 协商缓存命中 → 返回 304
            │
            └── 服务器说变了 → 返回 200 + 新资源

强缓存:完全不发请求

强缓存命中时,浏览器直接从本地拿资源,Network 面板会显示:

  • Size 列:(disk cache)(memory cache)
  • Time 列:0ms 或很小的数字

控制强缓存的响应头:

# 方式1:Cache-Control(推荐)
Cache-Control: max-age=31536000

# 方式2:Expires(老方式,优先级低于 Cache-Control)
Expires: Wed, 21 Oct 2025 07:28:00 GMT

max-age=31536000 意思是"这个资源 31536000 秒(一年)内有效"。

memory cache vs disk cache

两者区别:

类型 存储位置 速度 什么时候用
memory cache 内存 最快 当前会话、小文件、频繁访问的资源
disk cache 硬盘 稍慢 跨会话、大文件

关了浏览器 Tab,memory cache 就没了,disk cache 还在。

协商缓存:问一下服务器

当强缓存过期(或被设置为 no-cache),浏览器会发请求给服务器验证资源是否变化。

有两种验证方式:

方式1:ETag / If-None-Match(推荐)

基于内容哈希:

# 第一次请求,服务器返回
HTTP/1.1 200 OK
ETag: "abc123"
Cache-Control: no-cache

# 后续请求,浏览器带上 If-None-Match
GET /style.css HTTP/1.1
If-None-Match: "abc123"

# 如果内容没变,服务器返回
HTTP/1.1 304 Not Modified
ETag: "abc123"

# 如果内容变了,服务器返回
HTTP/1.1 200 OK
ETag: "xyz789"
[新的文件内容]

方式2:Last-Modified / If-Modified-Since

基于修改时间:

# 第一次请求,服务器返回
HTTP/1.1 200 OK
Last-Modified: Wed, 21 Oct 2024 07:28:00 GMT

# 后续请求,浏览器带上 If-Modified-Since
GET /style.css HTTP/1.1
If-Modified-Since: Wed, 21 Oct 2024 07:28:00 GMT

# 如果文件没改过,服务器返回 304

ETag vs Last-Modified

优先用 ETag,因为 Last-Modified 有问题:

  1. 精度只到秒 - 1 秒内改多次,检测不到
  2. 时间可能不准 - 服务器时间、部署时间等问题
  3. 只看时间不看内容 - 文件内容没变但时间变了,也会重新下载

不同资源该用什么策略?

静态资源(JS/CSS/图片)

Cache-Control: max-age=31536000, immutable

配合文件名哈希:app.abc123.js

内容变了 → 文件名变了 → 请求新文件,老文件继续用缓存。

这是现代前端打包工具(Webpack、Vite)的标准做法。

HTML 文件

Cache-Control: no-cache
ETag: "page-v1"

no-cache 不是"不缓存",而是"每次都要验证"。HTML 文件要保证用户能拿到最新版本,否则会引用旧的 JS/CSS 文件名。

API 接口

根据数据特性选择:

# 几乎不变的数据(如配置)
Cache-Control: max-age=3600

# 实时性要求高的数据
Cache-Control: no-cache

# 敏感数据(不要缓存)
Cache-Control: no-store

no-store 才是真正的"不缓存",连验证都不做。

常见问题排查

改了文件,浏览器没反应

原因:强缓存还没过期。

解决方案:

  1. 开发环境 - 打开 DevTools,勾选 "Disable cache"
  2. 生产环境 - 用文件名哈希,别用 ?v=1.0 这种

用了 ?v=1.0 还是不行

<link rel="stylesheet" href="/css/style.css?v=1.0">

问题:有些 CDN 会忽略查询参数,或者浏览器缓存策略不一致。

正确做法:

<link rel="stylesheet" href="/css/style.abc123.css">

让打包工具自动生成哈希,内容变了文件名才变。

CDN 缓存了旧文件

部署了新版本,但用户还是拿到旧文件。

原因:CDN 节点还在缓存期内。

解决方案:

  1. 部署时清除 CDN 缓存
  2. 用文件名哈希(CDN 会认为是新文件)

Nginx 配置参考

# 静态资源长期缓存
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
    expires 1y;
    add_header Cache-Control "public, immutable";
}

# HTML 文件协商缓存
location ~* \.html$ {
    add_header Cache-Control "no-cache";
    etag on;
}

# API 不缓存
location /api/ {
    add_header Cache-Control "no-store";
}

快速判断缓存类型

打开 DevTools Network 面板:

看到什么 缓存类型 含义
200 + (disk cache) 强缓存 从硬盘读取
200 + (memory cache) 强缓存 从内存读取
304 Not Modified 协商缓存 服务器说没变
200 + 正常大小 无缓存 重新下载

一句话总结

  • 强缓存:不问服务器,直接用。适合长期不变的静态资源。
  • 协商缓存:问服务器,没变就用缓存。适合需要保持最新的内容。
  • 文件名哈希:解决缓存更新问题的银弹。

如果你觉得这篇文章有帮助,欢迎关注我的 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

老项目改造 vue-cli 2.6 升级 rsbuild 提升开发效率300% upupup!!!

2025年12月17日 18:01

先上图打包 100m大小和时间37秒

image.png

image.png

image.png

升级后打包37秒

image.png

启动项目2秒左右 打开第一次页面7秒左右后续正常访问 总的9秒左右 和vite差不多项目很大

先上vue-cli 升级示例项目 github.com/1438343098/…

贴配置

import { defineConfig } from '@rsbuild/core'
import { pluginVue2 } from '@rsbuild/plugin-vue2'
import { pluginBabel } from '@rsbuild/plugin-babel'
import { pluginVue2Jsx } from '@rsbuild/plugin-vue2-jsx'
import { pluginNodePolyfill } from '@rsbuild/plugin-node-polyfill'
import { pluginBasicSsl } from '@rsbuild/plugin-basic-ssl'
import path from 'path'
const resolve = dir => path.resolve(__dirname, dir)

export default defineConfig({
  plugins: [
    pluginNodePolyfill(),
    pluginBabel({
      // 处理子包
      include: [/src/, /@kuaizi\/saas-components/],
      babelLoaderOptions: {
        presets: [['@babel/preset-env', { targets: 'defaults' }]]
      }
    }),
    pluginVue2(),
    // jsx
    pluginVue2Jsx(),
    // ssl
    pluginBasicSsl()
  ],
  source: {
    entry: {
      index: './src/main.js'
    },
    // 对齐process.env
    define: {
      ...(function () {
        return Object.fromEntries(
          Object.entries(process.env).map(([key, value]) => [
            `process.env.${key}`,
            JSON.stringify(value)
          ])
        )
      })()
    }
  },
  // @符号
  resolve: {
    alias: {
      '@': path.resolve(__dirname, 'src'),
      // 兼容vue cdn注入和
      vue$: require.resolve('vue/dist/vue.runtime.esm.js')
      // vuex$: require.resolve('vuex')
    }
  },
  html: {
    template: './public/index.html',
    // 注入环境变量
    templateParameters: (compilation, assets, assetTags) => {
      return {
        // 兼容 Vue CLI 的 htmlWebpackPlugin 语法 之前webpack 的html直接写这里就不需要修改html了
        htmlWebpackPlugin: {
          options: {
            title: 'test',
            assetPrefix: assets.publicPath
          }
        },
        autoprefixer: {
          browsers: ['> 1%', 'last 2 versions', 'not dead']
        }
      }
    }
  },
  output: {
    cssModules: {
      auto: /\.module\.(css|less|scss|sass)$/,
      localIdentName: '[local]--[hash:base64:5]'
    }
  },
  // Vue 2 项目需要关闭 experiments.css
  experiments: {
    css: false
  },
  // 关闭性能提示 一些日志可以点开查看
  performance: {
    hints: false
  },
  server: {
    // https: true 如果需要https
  },
  tools: {
    rspack(config, { addRules }) {
      // externals 兼容cdn注入
      // config.externals = {
      //   vue: 'Vue',
      //   vuex: 'Vuex',
      //   'vue-router': 'VueRouter',
      //   'vue-i18n': 'VueI18n',
      //   'element-ui': 'ELEMENT',
      //   axios: 'axios'
      // }

      config.module.rules = (config.module.rules || []).filter(rule => {
        return !(rule.test && rule.test.toString().includes('less'))
      })

      // 全局css变量注入
      const lessVars = {
        colorPrimary: '#0066ff',
        colorPrimaryLight8: '#d4e7ff',
        colorSuccess: '#3ec07d',
        colorWarning: '#f97c56',
        colorDanger: '#f56c6c',
        colorBg: 'white',
        colorBorder: '#999',
        colorText: '#777b7e',
        colorTitle: '#1d2328',
        colorDark: 'rgba(0, 0, 0, 0.75)',
        headerHeight: '64px',
        minWidth: '1200px',
        sideBarWidth: '210px',
        font: '14px',
        fontSmall: '12px',
        bg: 'white'
      }
      // svg + rules 和less处理
      addRules([
        {
          test: /\.less$/,
          oneOf: [
            {
              // 处理 <style module lang="less">
              resourceQuery: /module/,
              use: [
                'vue-style-loader',
                {
                  loader: 'css-loader',
                  options: {
                    modules: {
                      localIdentName: '[local]--[hash:base64:5]'
                    }
                  }
                },
                'postcss-loader',
                {
                  loader: 'less-loader',
                  options: {
                    lessOptions: {
                      javascriptEnabled: true,
                      globalVars: lessVars
                    }
                    // additionalData: sharedLessImports
                  }
                }
              ]
            },
            {
              // 普通 less
              use: [
                'vue-style-loader',
                'css-loader',
                // 需要有postcss.config.js
                'postcss-loader',
                {
                  loader: 'less-loader',
                  options: {
                    lessOptions: {
                      javascriptEnabled: true,
                      globalVars: lessVars
                    }
                    // 注入所有style 顶部的 css代码例如 @import "${resolve('src/style/theme.less')}";
                    // additionalData: sharedLessImports
                  }
                }
              ]
            }
          ]
        },
        {
          test: /\.svg$/,
          include: [resolve('src/asset/icons/svg')],
          use: [
            {
              loader: require.resolve('svg-sprite-loader'),
              options: {
                symbolId: 'icon-[name]'
              }
            }
          ]
        }
      ])
    }
  }
})

升级遇到的坑详解

  1. module不通过问题
    去除官方的import { pluginLess } from '@rsbuild/plugin-less'
     //pluginLess()
    改成'vue-style-loader','vue-style-loader', 'css-loader', 'postcss-loader',
    这几个都集合
    这个也需要 关闭
     // Vue 2 项目需要关闭 experiments.css
  experiments: {
    css: false
  },

2. jsx 编译问题 安装 import { pluginVue2Jsx } from '@rsbuild/plugin-vue2-jsx'

3.不想改原来的webpack页面就这样子修改

    // 兼容 Vue CLI 的 htmlWebpackPlugin 语法
        htmlWebpackPlugin: {
          options: {
            title: 'Kuaizi™ - 内容商业一站式AI应用平台',
            cdn,
            kzCDN: process.env.VUE_APP_CDN_URL,
            npsId: process.env.VUE_APP_NPS_ID || '',
            personalNpsId: process.env.VUE_APP_PERSONAL_NPS_ID || '',
            kzENVScript,
            assetPrefix: process.env.VUE_APP_CDN_URL
              ? `${process.env.VUE_APP_CDN_URL}/plus`
              : '/',
            content: ''
          }
        }

4.处理项目里面大量的process 问题

 source: {
    entry: {
      index: './src/main.js'
    },
    // 对齐process.env
    define: {
      ...(function () {
        return Object.fromEntries(
          Object.entries(process.env).map(([key, value]) => [
            `process.env.${key}`,
            JSON.stringify(value)
          ])
        )
      })()
    }
  },

如果大家遇到问题可以下面留言问我

2025OpenTiny星光ShowTime!年度贡献者征集启动!

2025年12月17日 18:00

前言

携手共创,致敬不凡!

2025年,OpenTiny持续在前端开源领域扎根,每一位开发者都是推动项目共同前行的宝贵力量。从bug修复,到技术探讨;从参与开源活动,到输出技术文章;从使用项目,到参与共建,每一步跨越,都凝聚了开发者的智慧与汗水。致敬所有在OpenTiny社区里默默付出、积极贡献、引领创新的杰出个人,我们正式启动“OpenTiny年度贡献者评选”活动!欢迎各位开发者踊跃报名~

活动详情

活动简介:

本次活动主要是通过开发者申报+社区评选+开发者投票形式开展,入选开发者后续可获得相应活动礼品。本次活动一共设置 4 类奖项。

  1.  “技术炼金师”(参与共建)、“布道魔法师”(参与分享)、“社区宝藏玩家”(参与社区讨论) 三个类目奖项通过投票评选获奖选手,本次投票共选出5名获奖选手,按照名次顺利依次给予相应奖励。
  2. “技术硬核奖”则由社区自主根据实际共建情况评选 2 位,获得机械键盘/蓝牙音响(2选1)及荣誉证书

活动奖品:

荣誉 奖项 礼品
第一名
  • 技术炼金师
  • 布道魔法师
  • 社区宝藏玩家
机械键盘 / 蓝牙音响(2选1) +荣誉证书
第二名 华为 66W 快充充电宝+荣誉证书
第三名 BKT 护腰坐垫椅+荣誉证书
第四/五名 屏幕挂灯+荣誉证书
社区优秀共建者 技术硬核奖 机械键盘 / 蓝牙音响(2选1) +荣誉证书

活动时间:

  • 年度贡献者征集时间:2025年12月17日-2025年12月24日
  • 年度贡献者投票评选时间:2025年12月25日-2025年12月31日

报名入口:

v.wjx.cn/vm/tdGJdjR.…

默认标题__2025-12-17+16_34_23.jpg

关于OpenTiny

欢迎加入 OpenTiny 开源社区。添加微信小助手:opentiny-official 一起参与交流前端技术~

OpenTiny 官网:opentiny.design
OpenTiny 代码仓库:github.com/opentiny
TinyVue 源码:github.com/opentiny/ti…
TinyEngine 源码: github.com/opentiny/ti…
欢迎进入代码仓库 Star🌟TinyEngine、TinyVue、TinyNG、TinyCLI、TinyEditor~ 如果你也想要共建,可以进入代码仓库,找到 good first issue 标签,一起参与开源贡献~

微信小程序真机预览-数字不等宽如何解决

2025年12月17日 17:55

数字显示不等宽

image.png

你按照设计稿, 把右侧时间设置了同一个字号, 以为就是100%还原了UI设计,还心里小小得意🤔

真机预览你发现

image.png

哎呦我去,我猜你肯定区检查了字体大小,肯定也预览了好几遍,才确认这个情况确实不是字体大小不一样导致的

😂😂😂 其实是因为 01 与其它数字宽度不一致导致的

那解决方法肯定是 设置一个等宽字体 但是那么多等宽字体 到底应该选择那个字体?

选择的不对,就会造成下面的效果

image.png

或者这样

image.png

如果你也出现上述问题,你不妨设置

font-family: Helvetica Neue;

这个试试看,你会得到以下结果

image.png

好了,最后希望大家都不要踩坑,愿所有bug的解决方法 都可以百度到 谢谢

从零到一:打造企业级 Vue 3 高性能表格组件的设计哲学与实践

2025年12月17日 17:52

本文将深入剖析一个企业级 Vue 3 表格组件的架构设计、性能优化策略与工程化实践,涵盖 Composition API 深度应用、响应式系统优化、TypeScript 类型体操等核心技术点。

前言

在企业级 B 端应用开发中,表格组件堪称"兵家必争之地"——它承载着数据展示、交互操作、状态管理等核心职责,其设计质量直接影响着整个系统的用户体验与可维护性。

本文将以笔者主导设计的 CmcCardTable 组件为例,系统性地阐述:

  • 🎯 架构设计:如何运用 Composition API 实现关注点分离
  • 性能优化:响应式系统的精细化调优策略
  • 🔒 类型安全:TypeScript 在复杂组件中的深度实践
  • 🧪 工程质量:可测试性设计与单元测试最佳实践

一、架构设计:Composables 模式的深度实践

1.1 从"上帝组件"到"关注点分离"

传统的表格组件往往会演变成一个庞大的"上帝组件"(God Component),动辄数千行代码,维护成本极高。我们采用 Composables 模式 将表格的核心功能解耦为独立的组合式函数:

src/components/CmcCardTable/
├── CmcCardTable.vue          # 主组件(视图层)
├── composables/
│   ├── useTableSelection.ts  # 选择状态管理
│   ├── useTableExpand.ts     # 展开/折叠逻辑
│   ├── useTableSort.ts       # 排序功能
│   └── useTableLayout.ts     # 布局计算
├── types.ts                  # 类型定义
└── SubRowGrid.vue            # 子行网格组件

这种架构带来的收益:

维度 传统方案 Composables 方案
单文件代码量 3000+ 行 主组件 < 800 行
可测试性 需要挂载整个组件 可独立单元测试
复用性 难以复用 可跨组件复用
认知负载 低(单一职责)

1.2 Composable 的设计原则

useTableSelection 为例,一个优秀的 Composable 应遵循以下原则:

// useTableSelection.ts
export interface UseTableSelectionOptions {
  data: Ref<TableRow[]>
  rowKey: string
  selectionMode: Ref<SelectionMode>
  selectedRowKeys: Ref<(string | number)[]>
  reserveSelection?: boolean
  selectable?: (row: TableRow, index: number) => boolean  // 🆕 行级选择控制
}

export interface UseTableSelectionReturn {
  internalSelectedKeys: Ref<(string | number)[]>
  selectionState: ComputedRef<{
    isAllSelected: boolean
    isIndeterminate: boolean
  }>
  isRowSelected: (row: TableRow) => boolean
  isRowSelectable: (row: TableRow, index: number) => boolean
  handleRowSelect: (row: TableRow, selected: boolean) => void
  handleSingleSelect: (row: TableRow) => void
  handleSelectAll: (selected: boolean) => void
  clearSelection: () => void
  getAllSelectedRows: () => TableRow[]
}

设计要点

  1. 显式的输入/输出接口:通过 TypeScript 接口明确定义 Options 和 Return,消除隐式依赖
  2. 响应式数据作为参数:传入 Ref 而非原始值,保持响应式链路
  3. 纯函数式设计:无副作用,所有状态变更都是显式的

二、响应式系统的精细化优化

2.1 shallowRef vs ref:内存与性能的权衡

在处理大数据量表格时,响应式系统的开销不容忽视。我们采用分层的响应式策略:

// ❌ 反模式:深层响应式导致不必要的依赖追踪
const selectedRowsMap = ref(new Map<string | number, TableRow>())

// ✅ 优化方案:使用 shallowRef 减少响应式开销
const selectedRowsMap = shallowRef(new Map<string | number, TableRow>())

// 更新时手动触发响应
function updateSelection(key: string, row: TableRow) {
  const newMap = new Map(selectedRowsMap.value)
  newMap.set(key, row)
  selectedRowsMap.value = newMap  // 触发响应式更新
}

性能对比(1000 行数据场景):

方案 内存占用 批量选择耗时
ref + Map ~2.4MB ~45ms
shallowRef + Map ~1.8MB ~12ms

2.2 Computed 依赖的精准控制

computed 的依赖追踪是自动的,但这也可能导致"过度追踪"问题:

// ❌ 反模式:整个 data 数组变化都会触发重计算
const selectionState = computed(() => {
  const allKeys = data.value.map(row => row[rowKey])
  // ...
})

// ✅ 优化方案:只追踪必要的依赖
const selectableRows = computed(() => 
  data.value.filter((row, index) => isRowSelectable(row, index))
)

const selectionState = computed(() => {
  const selectableKeys = selectableRows.value.map(row => row[rowKey])
  const selectedSet = new Set(internalSelectedKeys.value)
  
  const selectedCount = selectableKeys.filter(key => selectedSet.has(key)).length
  const totalSelectable = selectableKeys.length
  
  return {
    isAllSelected: totalSelectable > 0 && selectedCount === totalSelectable,
    isIndeterminate: selectedCount > 0 && selectedCount < totalSelectable,
  }
})

2.3 Watch 的防抖与节流策略

对于高频变化的数据源,直接 watch 可能导致性能问题:

// useTableExpand.ts
watch(
  () => data.value.length,  // 👈 只监听长度变化,而非整个数组
  () => {
    if (defaultExpandAll.value) {
      initializeExpandedKeys()
    }
  },
  { flush: 'post' }  // 👈 在 DOM 更新后执行,避免重复计算
)

三、TypeScript 类型体操:从"能用"到"好用"

3.1 泛型约束与条件类型

为了让 API 更加智能,我们使用了条件类型来约束参数:

// types.ts
export interface TableColumn<T = TableRow> {
  key: keyof T | string
  label: string
  width?: string
  flex?: string
  align?: 'left' | 'center' | 'right'
  sortable?: boolean
  
  // 条件渲染函数
  render?: (value: unknown, row: T, column: TableColumn<T>) => string
  
  // 行操作项(支持静态配置和动态函数)
  actionItems?: ActionItem[] | ((row: T) => ActionItem[])
}

// 使用泛型约束行数据类型
interface OrderRow {
  id: number
  orderNo: string
  amount: number
  status: 'pending' | 'confirmed' | 'shipped'
}

const columns: TableColumn<OrderRow>[] = [
  {
    key: 'status',  // ✅ IDE 自动补全,类型安全
    label: '状态',
    render: (value) => statusMap[value as OrderRow['status']]
  }
]

3.2 事件类型的完整定义

为组件事件提供完整的类型定义,让使用方获得最佳的开发体验:

// 定义强类型的 emit 接口
export interface TableEmits {
  'update:selectedRowKeys': [keys: (string | number)[]]
  'update:expandedRowKeys': [keys: (string | number)[]]
  'selection-change': [data: {
    selectedRows: TableRow[]
    selectedRowKeys: (string | number)[]
  }]
  'select': [data: {
    row: TableRow
    selected: boolean
    selectedRows: TableRow[]
  }]
  'select-all': [data: {
    selected: boolean
    selectedRows: TableRow[]
    selectionChanges: TableRow[]
  }]
  'sort-change': [data: {
    column: string | null
    order: 'asc' | 'desc' | null
    sortState: SortState
  }]
  'expand-change': [data: {
    row: TableRow
    expanded: boolean
    expandedRows: TableRow[]
  }]
}

// 组件中使用
const emit = defineEmits<TableEmits>()

3.3 Props 的智能默认值

利用 TypeScript 的类型推导,实现带默认值的 Props 定义:

const props = withDefaults(
  defineProps<{
    data?: TableRow[]
    columns: TableColumn[]
    rowKey?: string
    selectionMode?: SelectionMode
    selectable?: (row: TableRow, index: number) => boolean
    reserveSelection?: boolean
    defaultExpandAll?: boolean
  }>(),
  {
    data: () => [],
    rowKey: 'id',
    selectionMode: 'none',
    selectable: () => true,  // 默认所有行可选
    reserveSelection: false,
    defaultExpandAll: false,
  }
)

四、高级特性实现剖析

4.1 跨分页选择保持(Reserve Selection)

在分页场景下,如何保持用户的选择状态是一个经典问题。我们的解决方案:

// 使用 Map 存储选中行的完整数据,而非仅存储 key
const selectedRowsMap = shallowRef(new Map<string | number, TableRow>())

// 数据变化时的处理策略
watch(data, (newData) => {
  if (!reserveSelection) {
    // 非保留模式:清除不在新数据中的选中项
    const newDataKeys = new Set(newData.map(row => row[rowKey]))
    const newMap = new Map<string | number, TableRow>()
    
    for (const [key, row] of selectedRowsMap.value) {
      if (newDataKeys.has(key)) {
        newMap.set(key, row)
      }
    }
    selectedRowsMap.value = newMap
  }
  // 保留模式:Map 中的数据不会被清除,即使该行不在当前页
}, { deep: false })

// 获取所有选中行数据(跨分页)
function getAllSelectedRows(): TableRow[] {
  return Array.from(selectedRowsMap.value.values())
}

核心思想:将选中行的完整数据存储在 Map 中,而非仅存储 key。这样即使数据源(当前页)不包含某些选中行,我们依然可以获取其完整信息。

4.2 部分行禁用选择(Selectable)

业务场景中常需要根据行数据动态禁用选择,我们通过 selectable 函数实现:

/**
 * 判断行是否可选择
 * @description 支持业务自定义禁用逻辑
 */
function isRowSelectable(row: TableRow, index: number): boolean {
  if (!selectable) return true
  return selectable(row, index)
}

// 全选逻辑需要排除不可选行
function handleSelectAll(selected: boolean): void {
  const selectableRows = data.value.filter((row, index) => 
    isRowSelectable(row, index)
  )
  
  if (selected) {
    // 只选中可选的行
    selectableRows.forEach(row => {
      const key = row[rowKey]
      if (!internalSelectedKeys.value.includes(key)) {
        internalSelectedKeys.value.push(key)
        selectedRowsMap.value.set(key, row)
      }
    })
  } else {
    // 只取消可选行的选中状态
    const selectableKeys = new Set(selectableRows.map(row => row[rowKey]))
    internalSelectedKeys.value = internalSelectedKeys.value.filter(
      key => !selectableKeys.has(key)
    )
  }
}

// 计算全选状态时只考虑可选行
const selectionState = computed(() => {
  const selectableRows = data.value.filter((row, index) => 
    isRowSelectable(row, index)
  )
  const selectableKeys = selectableRows.map(row => row[rowKey])
  const selectedSet = new Set(internalSelectedKeys.value)
  
  const selectedCount = selectableKeys.filter(key => selectedSet.has(key)).length
  
  return {
    isAllSelected: selectableKeys.length > 0 && selectedCount === selectableKeys.length,
    isIndeterminate: selectedCount > 0 && selectedCount < selectableKeys.length,
  }
})

使用示例

<template>
  <CmcCardTable
    v-model:selected-row-keys="selectedKeys"
    :data="tableData"
    :columns="columns"
    selection-mode="multiple"
    :selectable="(row) => row.status !== 'locked'"
  />
</template>

4.3 远程排序与本地排序的统一抽象

支持本地排序和远程排序两种模式,通过配置切换:

// useTableSort.ts
export function useTableSort(options: UseTableSortOptions) {
  const { data, defaultSort, remoteSort, onSortChange } = options
  
  // 当前排序状态
  const sortState = shallowRef<SortState>({
    column: defaultSort?.column ?? null,
    order: defaultSort?.order ?? null,
  })
  
  // 排序后的数据
  const sortedData = computed(() => {
    // 远程排序模式:直接返回原数据,排序由后端处理
    if (remoteSort?.value) {
      return data.value
    }
    
    // 本地排序模式
    if (!sortState.value.column || !sortState.value.order) {
      return data.value
    }
    
    return [...data.value].sort((a, b) => {
      const aVal = a[sortState.value.column!]
      const bVal = b[sortState.value.column!]
      const result = compareValues(aVal, bVal)
      return sortState.value.order === 'desc' ? -result : result
    })
  })
  
  // 切换排序
  function toggleSort(column: string) {
    const newOrder = getNextSortOrder(sortState.value, column)
    sortState.value = { column: newOrder ? column : null, order: newOrder }
    
    // 触发事件,交由父组件处理(远程排序时调用接口)
    onSortChange?.({
      column: sortState.value.column,
      order: sortState.value.order,
      sortState: sortState.value,
    })
  }
  
  return { sortState, sortedData, toggleSort }
}

五、工程化实践:可测试性设计

5.1 Composable 的单元测试

得益于 Composables 的独立性,我们可以脱离组件进行单元测试:

// useTableSelection.test.ts
import { describe, it, expect, vi } from 'vitest'
import { ref } from 'vue'
import { useTableSelection } from '../useTableSelection'

describe('useTableSelection', () => {
  const createTestData = () => [
    { id: 1, name: '张三', status: 'active' },
    { id: 2, name: '李四', status: 'locked' },
    { id: 3, name: '王五', status: 'active' },
  ]

  describe('selectable 部分禁用', () => {
    it('全选时应只选中可选行', () => {
      const data = ref(createTestData())
      const selectedRowKeys = ref<(string | number)[]>([])
      
      const { handleSelectAll, internalSelectedKeys } = useTableSelection({
        data,
        rowKey: 'id',
        selectionMode: ref('multiple'),
        selectedRowKeys,
        selectable: (row) => row.status === 'active',  // 只有 active 可选
      })
      
      handleSelectAll(true)
      
      // 应只选中 id=1 和 id=3(status='active')
      expect(internalSelectedKeys.value).toEqual([1, 3])
      expect(internalSelectedKeys.value).not.toContain(2)
    })
    
    it('isIndeterminate 应只基于可选行计算', () => {
      const data = ref(createTestData())
      const selectedRowKeys = ref([1])  // 选中一个可选行
      
      const { selectionState } = useTableSelection({
        data,
        rowKey: 'id',
        selectionMode: ref('multiple'),
        selectedRowKeys,
        selectable: (row) => row.status === 'active',
      })
      
      // 可选行有 2 个(id=1,3),选中了 1 个,应为半选状态
      expect(selectionState.value.isIndeterminate).toBe(true)
      expect(selectionState.value.isAllSelected).toBe(false)
    })
  })
  
  describe('reserveSelection 跨分页保留', () => {
    it('应保留不在当前页的选中数据', () => {
      const data = ref(createTestData())
      const selectedRowKeys = ref([1, 99])  // 99 不在当前数据中
      
      const { internalSelectedKeys, getAllSelectedRows } = useTableSelection({
        data,
        rowKey: 'id',
        selectionMode: ref('multiple'),
        selectedRowKeys,
        reserveSelection: true,
      })
      
      // key 99 应被保留
      expect(internalSelectedKeys.value).toContain(99)
    })
  })
})

5.2 测试覆盖率与质量保障

我们为核心 Composables 编写了全面的单元测试:

✓ useTableSelection.test.ts (25 tests)
✓ useTableExpand.test.ts    (27 tests)
✓ useTableSort.test.ts      (22 tests)
✓ useTableLayout.test.ts    (31 tests)

Total: 105 tests passed

测试用例覆盖:

  • ✅ 正常流程
  • ✅ 边界条件(空数据、重复操作等)
  • ✅ 模式切换(单选/多选/禁用)
  • ✅ 响应式同步
  • ✅ 事件触发

六、API 设计的艺术:向 Element Plus 学习

6.1 Props 命名的一致性

我们在 API 设计上尽量与 Element Plus 保持一致,降低用户的学习成本:

<!-- Element Plus Table -->
<el-table
  :data="tableData"
  :row-key="rowKey"
  :default-sort="{ prop: 'date', order: 'descending' }"
  @selection-change="handleSelectionChange"
  @sort-change="handleSortChange"
>
  <el-table-column type="selection" />
  <el-table-column type="index" />
  <el-table-column type="expand" />
</el-table>

<!-- CmcCardTable(风格一致) -->
<CmcCardTable
  :data="tableData"
  :row-key="rowKey"
  :default-sort="{ column: 'date', order: 'desc' }"
  @selection-change="handleSelectionChange"
  @sort-change="handleSortChange"
  :columns="[
    { key: 'selection', type: 'selection' },
    { key: 'index', type: 'index' },
    { key: 'expand', type: 'expand' },
  ]"
/>

6.2 渐进式的功能启用

通过 Props 开关式地启用功能,保持 API 的简洁性:

<!-- 最简用法 -->
<CmcCardTable :data="data" :columns="columns" />

<!-- 启用选择 -->
<CmcCardTable 
  :data="data" 
  :columns="columns"
  selection-mode="multiple"
/>

<!-- 启用选择 + 部分禁用 -->
<CmcCardTable 
  :data="data" 
  :columns="columns"
  selection-mode="multiple"
  :selectable="row => row.status === 'active'"
/>

<!-- 启用选择 + 跨分页保留 + 部分禁用 -->
<CmcCardTable 
  :data="data" 
  :columns="columns"
  selection-mode="multiple"
  reserve-selection
  :selectable="row => row.status === 'active'"
/>

七、性能优化清单

优化点 技术手段 效果
减少响应式开销 shallowRef 替代 ref 内存降低 25%
避免重复计算 computed 缓存 渲染性能提升 40%
精准依赖追踪 拆分细粒度 computed 减少无效更新
大列表渲染 CSS Grid 布局 重排性能提升
事件处理 事件委托 减少监听器数量

八、进阶优化:突破性能瓶颈

8.1 虚拟滚动支持:突破万级数据渲染

当表格需要渲染数千甚至上万行数据时,传统的 v-for 渲染会导致严重的性能问题。我们实现了 useVirtualScroll composable 来解决这个问题:

// useVirtualScroll.ts
export function useVirtualScroll(options: UseVirtualScrollOptions) {
  const { data, itemHeight, containerHeight, overscan = 5 } = options
  
  // 当前滚动位置
  const scrollTop = ref(0)
  
  // 计算可见范围(起始和结束索引)
  const visibleRange = computed(() => {
    const start = Math.floor(scrollTop.value / itemHeight)
    const end = start + Math.ceil(containerHeight / itemHeight)
    
    // 应用 overscan(预渲染额外行数)
    return {
      start: Math.max(0, start - overscan),
      end: Math.min(data.value.length - 1, end + overscan)
    }
  })
  
  // 只渲染可见范围内的数据
  const virtualItems = computed(() => {
    const { start, end } = visibleRange.value
    return data.value.slice(start, end + 1).map((item, i) => ({
      data: item,
      index: start + i,
      offsetTop: (start + i) * itemHeight
    }))
  })
  
  return { virtualItems, visibleRange, onScroll, ... }
}

性能对比(渲染 10,000 行数据):

指标 普通渲染 虚拟滚动
首屏时间 ~3200ms ~45ms
DOM 节点数 10,000+ ~30
内存占用 ~50MB ~2MB

8.2 WeakMap vs Map:内存管理的深层思考

selectedRowsMap 的实现中,我们选择了 Map 而非 WeakMap,这是经过深思熟虑的决定:

// ✅ 当前方案:使用 shallowRef + Map
const selectedRowsMap = shallowRef(new Map<string | number, TableRow>())

// ❓ 为什么不用 WeakMap?
// const selectedRowsMap = shallowRef(new WeakMap<TableRow, boolean>())

不选择 WeakMap 的原因

特性 Map WeakMap
键类型 任意类型 仅对象
可枚举 ✅ 支持 ✖️ 不支持
size 属性 ✅ 有 ✖️ 无
GC 回收 手动管理 自动回收

关键问题

  1. 我们需要用 rowKey(字符串/数字)作为键,WeakMap 只接受对象作为键
  2. 我们需要遍历所有选中行(getAllSelectedRows),WeakMap 不可枚举
  3. 我们需要知道选中数量,WeakMap 没有 size 属性

内存泄漏防护

// 通过 shallowRef 包装,确保随组件生命周期管理
const selectedRowsMap = shallowRef(new Map())

// 组件卸载时,shallowRef 的引用释放,Map 自然被 GC 回收
// 无需手动清理

8.3 Vue 3.4 defineModel:简化双向绑定

Vue 3.4 稳定版引入的 defineModel 宏可以大幅简化双向绑定的代码:

// ❌ Vue 3.4 之前的写法
const props = defineProps<{ selectedRowKeys: (string | number)[] }>()
const emit = defineEmits<{ 'update:selectedRowKeys': [keys: (string | number)[]] }>()

// 需要手动同步
watch(() => props.selectedRowKeys, (newKeys) => {
  internalSelectedKeys.value = [...newKeys]
})

function updateSelection(keys: (string | number)[]) {
  emit('update:selectedRowKeys', keys)
}

// ✅ Vue 3.4+ 使用 defineModel
const selectedRowKeys = defineModel<(string | number)[]>('selectedRowKeys', {
  default: () => []
})

const expandedRowKeys = defineModel<(string | number)[]>('expandedRowKeys', {
  default: () => []
})

// 直接修改即可触发更新,无需手动 emit
selectedRowKeys.value = [...newKeys]

优势

  • 减少约 60% 的模板代码
  • 自动处理 props 和 emit 的同步
  • 更直观的双向绑定语义

8.4 Vue Vapor Mode:无虚拟 DOM 的未来

Vue 3.6 alpha 引入了实验性的 Vapor Mode,它完全跳过虚拟 DOM,直接生成 DOM 操作代码:

<!-- 启用 Vapor Mode:只需添加 vapor 关键字 -->
<script setup vapor>
import { ref } from 'vue'
const count = ref(0)
</script>

<template>
  <button @click="count++">{{ count }}</button>
</template>

Vapor Mode 的优势

  • 🚀 更小的包体积(无 VDOM 运行时)
  • ⚡ 更快的渲染(无 diff/patch 开销)
  • 📊 更低的内存占用

兼容性设计

// CmcCardTable 的 Vapor Mode 兼容性设计
import { vaporInteropPlugin } from 'vue'

// 在 VDOM 应用中使用 Vapor 组件
const app = createApp(App)
app.use(vaporInteropPlugin)  // 启用互操作插件
app.mount('#app')

// CmcCardTable 可以渐进式迁移到 Vapor Mode
// 1. 将性能关键的子组件转为 Vapor
// 2. 与现有 VDOM 组件无缝共存

迁移指南

特性 支持状态 说明
Composition API ✅ 完全支持 唯一支持的 API 风格
Options API ✖️ 不支持 需迁移到 Composition API
<Transition> ✖️ 暂不支持 后续版本支持
Element Plus ✅ 支持 需配合 vaporInteropPlugin

九、总结与展望

通过 CmcCardTable 组件的设计实践,我们验证了以下技术方案的可行性:

  1. Composables 模式 是组织复杂组件逻辑的最佳实践
  2. 分层响应式策略 能显著提升大数据量场景下的性能
  3. 完备的类型定义 是提升开发体验的关键
  4. 可测试性设计 应贯穿组件开发的始终
  5. 虚拟滚动 是突破大数据量渲染瓶颈的关键技术

已完成的优化

  • ✅ 虚拟滚动 useVirtualScroll composable
  • shallowRef + Map 内存管理优化
  • ✅ 部分行禁用选择 selectable 支持

未来计划

  • 🔮 迁移到 Vue 3.4 defineModel 简化双向绑定
  • 🔮 探索 Vapor Mode 零虚拟 DOM 方案
  • 🔮 实现列的拖拽排序与宽度调整
  • 🔮 支持动态行高的虚拟滚动

参考资料


如果这篇文章对你有帮助,欢迎点赞收藏 ⭐️,你的支持是我持续输出的动力!

有任何问题欢迎在评论区交流讨论 💬

关于作者:专注于企业级 Vue 3 应用开发,热衷于组件设计与性能优化。


本文首发于掘金,转载请注明出处。

前端趣味交互:如何精准判断鼠标从哪个方向进入元素?

作者 Drift_Dream
2025年12月17日 17:41

当我们希望一个元素在鼠标移入时,能像一扇门一样从正确的一侧滑开,或者让提示信息从鼠标来的方向自然浮现,这个看似“智能”的效果,其核心就是一个经典的几何计算:判断鼠标的进入方向

核心原理

想象一下,你正站在一个房间的正中央。有人从门口进来,你怎么判断他是从左边门还是右边门进来的?你会对比他出现的位置和你(中心点)的左右关系。我的思路是:

image.png

  1. 算出元素中心点坐标
  2. 根据中心坐标建立坐标象限
  3. 每个象限分为两个部分
  4. 依据鼠标进入的位置,算出鼠标进入时的坐标点
  5. 根据坐标点判断进入方向

第一版代码

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>判断鼠标进入方向</title>
  </head>
  <style>
    * {
      margin: 0;
      padding: 0;
    }
    body {
      width: 100vw;
      height: 100vh;
      display: flex;
      align-items: center;
      justify-content: center;
    }
    .box {
      width: 600px;
      height: 600px;
      background-color: aquamarine;
    }
  </style>
  <body>
    <div class="box"></div>
  </body>
  <script>
    const box = document.querySelector(".box");

    box.addEventListener("mouseenter", (event) => {
      // 计算元素中心点坐标
      const rect = box.getBoundingClientRect();
      const centerX = rect.x + rect.width / 2;
      const centerY = rect.y + rect.height / 2;
      
      // 鼠标位置
      const clientX = event.clientX;
      const clientY = event.clientY;
      
      // 坐标
      const x = clientX - centerX;
      const y = centerY - clientY;

      // 第一象限
      if (x > 0 && y > 0) {
        if (y > x) {
          console.log("top");
        } else {
          console.log("right");
        }
      }
      // 第四象限
      if (x > 0 && y < 0) {
        if (x > -y) {
          console.log("right");
        } else {
          console.log("bottom");
        }
      }
      // 第三象限
      if (x < 0 && y < 0) {
        if (-x > -y) {
          console.log("left");
        } else {
          console.log("bottom");
        }
      }
      // 第二象限
      if (x < 0 && y > 0) {
        if (y > -x) {
          console.log("top");
        } else {
          console.log("left");
        }
      }
    });
  </script>
</html>

但是第一版代码分支判断过多,我们仔细观察后能发现,实际上核心判断就是“比较水平偏差和垂直偏差哪个更大

第二版代码

const box = document.querySelector(".box");

box.addEventListener("mouseenter", (event) => {
  // 获取中心点坐标
  const rect = box.getBoundingClientRect();
  const centerX = rect.x + rect.width / 2;
  const centerY = rect.y + rect.height / 2;

  const clientX = event.clientX;
  const clientY = event.clientY;

  // 优化后的核心逻辑
  const deltaX = event.clientX - centerX; // 水平偏差
  const deltaY = event.clientY - centerY; // 垂直偏差

  if (Math.abs(deltaX) > Math.abs(deltaY)) {
    // 水平方向主导
    console.log(deltaX > 0 ? "right" : "left");
  } else {
    // 垂直方向主导
    console.log(deltaY > 0 ? "bottom" : "top");
  }
});

在vue3项目使用示例

我们可以做一个鼠标移入时图片从不同方向进入的效果

<template>
  <div class="mouse-direction-container">
    <p>鼠标方向:{{ direction }}</p>

    <div :class="['box', direction]">
      <img class="top-img" src="@/assets/top.jpg" alt="" />
      <img class="right-img" src="@/assets/right.jpg" alt="" />
      <img class="bottom-img" src="@/assets/bottom.jpg" alt="" />
      <img class="left-img" src="@/assets/left.jpg" alt="" />
      <img class="center-img" src="@/assets/center.jpg" alt="" />
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted } from "vue";

const direction = ref<"top" | "bottom" | "left" | "right" | "">("");

onMounted(() => {
  const box = document.querySelector(".box")!;

  box.addEventListener("mouseenter", (e) => {
    const { clientX, clientY } = e as MouseEvent;
    const { left, top, width, height } = box.getBoundingClientRect();

    const centerX = left + width / 2;
    const centerY = top + height / 2;

    const deltaX = clientX - centerX; // 水平偏差
    const deltaY = clientY - centerY; // 垂直偏差
    if (Math.abs(deltaX) > Math.abs(deltaY)) {
      direction.value = deltaX > 0 ? "right" : "left";
    } else {
      direction.value = deltaY > 0 ? "bottom" : "top";
    }
  });
});
</script>

<style scoped lang="scss">
.mouse-direction-container {
  width: 100%;
  height: 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 100px;
}

.box {
  width: 400px;
  height: 300px;
  background-color: red;
  position: relative;
  overflow: hidden;
  &.top {
    .top-img {
      transform: translateY(0);
    }
  }
  &.right {
    .right-img {
      transform: translateX(0);
    }
  }
  &.bottom {
    .bottom-img {
      transform: translateY(0);
    }
  }
  &.left {
    .left-img {
      transform: translateX(0);
    }
  }
  img {
    position: absolute;
    transition: all 0.3s linear;
    &.center-img {
      top: 0;
      left: 0;
      z-index: 1;
    }
    &.top-img {
      transform: translateY(-100%);
      z-index: 2;
    }
    &.right-img {
      transform: translateX(100%);
      z-index: 2;
    }
    &.bottom-img {
      transform: translateY(100%);
      z-index: 2;
    }
    &.left-img {
      transform: translateX(-100%);
      z-index: 2;
    }
  }
}
</style>

image.png

NuxtImage 配置上传目录配置

2025年12月17日 17:32

NuxtImage 配置上传目录配置

// https://nuxt.com/docs/api/configuration/nuxt-config
import { join } from 'path'

export default defineNuxtConfig({
  compatibilityDate: "2025-07-15",
  devtools: { enabled: true },
  app: {
    head: {
      title: "XJ",
      link: [
        {
          rel: "icon",
          href: "/images/favicon.ico",
        },
      ],
    },
  },
  modules: [
    "@nuxt/eslint",
    "@nuxt/image",
    "@nuxt/scripts",
    "@nuxt/ui",
    "nuxt-icons",
    "@nuxtjs/i18n"
  ],
  // 某些v-if里的图片无法加载
  nitro: {
    // [新配置] 将根目录下的 uploads 文件夹挂载为公共资源
    // 这样 Nitro 会直接服务这个目录下的文件,无需 Nginx 或自定义 API
    publicAssets: [
      {
        baseURL: '/uploads',
        dir: join(process.cwd(), 'uploads'),
        maxAge: 60 * 60 * 24 * 30, // 缓存 30 天
      }
    ],
    prerender: {
      routes: [
        "/_ipx/f_webp,avif,png&q_80/images/card_bg_1_xl.png",
        "/_ipx/f_webp,avif,png&q_80/images/card_bg_1_2xl.png",
        "/_ipx/f_webp,avif,png&q_80/images/card_bg_2_xl.png",
        "/_ipx/f_webp,avif,png&q_80/images/card_bg_3_xl.png",
        "/_ipx/f_webp,avif,png&q_80/images/card_bg_3_2xl.png",
        "/_ipx/f_webp,avif,png&q_80&s_457x534/images/contact_us_img.png",
        "/_ipx/f_webp,avif,png&q_80/images/contact_us_img_2.png",
      ],
    },
  },

  css: ["~/assets/css/main.css"],

  image: {
    domains: ['localhost', 'gwweb.gametest6.com'],
    alias: {
    // upload 目录需要挂载,并且能够访问
      '/uploads': process.env.NODE_ENV === 'production' ? 'https://mydomain/uploads' : 'http://localhost:3000/uploads'
    }
  },

  i18n: {
    defaultLocale: "en",
    locales: [
      {
        code: "en",
        name: "English",
        file: "en.json",
        icon: "/images/flag_en.png",
      },
      {
        code: "cn",
        name: "China",
        file: "cn.json",
        icon: "/images/flag_ch.png",
      },
    ],
  },
});

面试官问 React Fiber,这一篇文章就够了

2025年12月16日 14:10

看 React 源码或者技术文章的时候,经常会遇到 Fiber 这个词:

  • Fiber 到底是什么?为什么 React 要搞出这么个东西?
  • 都说 Fiber 让渲染可以"中断",具体是怎么实现的?
  • Fiber 和 Hooks、Suspense、Concurrent Mode 这些特性有什么关系?

这篇文章会把 Fiber 架构从头到尾讲清楚,包括它的设计动机、数据结构、工作流程,以及它是如何支撑 React 18/19 那些"并发特性"的。

为什么需要 Fiber?

Stack Reconciler 的困境

React 16 之前用的是 Stack Reconciler(栈调和器)。名字来源于它的工作方式——依赖 JavaScript 的调用栈来递归处理组件树。

当你调用 setState() 的时候,React 会从根节点开始,递归遍历整棵组件树,计算出哪些节点需要更新,然后一次性把变更应用到 DOM 上。

这个过程有个致命问题:同步且不可中断

假设你有一个包含 1000 个节点的列表,用户在输入框里打了个字,触发了状态更新。React 必须一口气把这 1000 个节点都 diff 完、更新完,才能把控制权还给浏览器。在这期间:

  • 用户输入没有响应(输入框卡住)
  • 动画掉帧(因为主线程被占用)
  • 整个页面感觉"卡顿"

问题的根源在于:所有更新都被当成同等优先级处理。用户的输入响应、动画渲染、后台数据更新,在 Stack Reconciler 眼里都一样——必须按顺序执行,谁也不能插队。

Fiber 的解题思路

2015 年,Facebook 开始开发 Fiber,2017 年随 React 16 正式发布。

Fiber 的核心思想是:把不可中断的递归调用,改成可中断的循环遍历

用 Andrew Clark(React 核心开发者)的话说:

"Fiber 是对调用栈的重新实现,专门为 React 组件设计。你可以把一个 fiber 想象成一个虚拟的栈帧,好处是你可以把这些栈帧保存在内存里,然后随时随地执行它们。"

这段话有点抽象,展开来说就是:

  1. 把大任务拆成小任务:每个组件的处理变成一个独立的"工作单元"(fiber)
  2. 每个小任务执行完都可以暂停:检查是否有更紧急的事情要做
  3. 高优先级任务可以插队:用户输入比后台数据更新重要
  4. 被中断的任务可以恢复:从上次暂停的地方继续

Fiber 数据结构

Fiber 不只是一个概念,它是一个具体的数据结构。每个 React 组件在内部都对应一个 fiber 节点,这些节点组成一棵树,但不是普通的树——是用链表串联的树。

FiberNode 的关键字段

直接看 React 源码中的 FiberNode 构造函数(简化版):

function FiberNode(tag, pendingProps, key, mode) {
  // ========== 身份标识 ==========
  this.tag = tag;           // fiber 类型:FunctionComponent、ClassComponent、HostComponent 等
  this.key = key;           // 用于 diff 的唯一标识
  this.type = null;         // 组件函数/类,或者 DOM 标签名(如 'div')
  this.stateNode = null;    // 对应的真实 DOM 节点,或者类组件的实例

  // ========== 树结构指针 ==========
  this.return = null;       // 父节点
  this.child = null;        // 第一个子节点
  this.sibling = null;      // 下一个兄弟节点
  this.index = 0;           // 在兄弟节点中的位置

  // ========== 状态相关 ==========
  this.pendingProps = pendingProps;   // 新的 props(待处理)
  this.memoizedProps = null;          // 上次渲染用的 props
  this.memoizedState = null;          // 上次渲染的 state(Hooks 链表也存这里)
  this.updateQueue = null;            // 状态更新队列

  // ========== 副作用 ==========
  this.flags = NoFlags;               // 副作用标记:Placement、Update、Deletion 等
  this.subtreeFlags = NoFlags;        // 子树中的副作用标记
  this.deletions = null;              // 需要删除的子节点

  // ========== 调度相关 ==========
  this.lanes = NoLanes;               // 当前节点的优先级
  this.childLanes = NoLanes;          // 子树中的优先级

  // ========== 双缓冲 ==========
  this.alternate = null;              // 指向另一棵树中对应的 fiber
}

tag 字段:fiber 的类型标识

tag 决定了 React 如何处理这个 fiber。常见的类型包括:

tag 值 类型名称 说明
0 FunctionComponent 函数组件
1 ClassComponent 类组件
3 HostRoot 根节点(ReactDOM.createRoot() 创建的)
5 HostComponent 原生 DOM 元素,如 <div>
6 HostText 文本节点
7 Fragment <React.Fragment>
11 ForwardRef React.forwardRef() 创建的组件
14 MemoComponent React.memo() 包装的组件
15 SimpleMemoComponent 简单的 memo 组件

React 根据 tag 来决定调用什么方法。比如遇到 FunctionComponent 就执行函数拿返回值,遇到 ClassComponent 就调用 render() 方法。

链表树结构

Fiber 树用三个指针串联:

         ┌─────────────────────────────────────────────┐
         │                   App                        │
         │          (return: null)                      │
         └─────────────────────────────────────────────┘
                           │
                         child
                           ↓
         ┌─────────────────────────────────────────────┐
         │                 Header                       │
         │            (return: App)                     │
         └─────────────────────────────────────────────┘
                           │
                         child                  sibling
                           ↓                       ↓
         ┌─────────────────┐              ┌─────────────────┐
         │      Logo       │   sibling    │      Nav        │
         │  (return: Header)│ ─────────→  │  (return: Header)│
         └─────────────────┘              └─────────────────┘
  • child:指向第一个子节点
  • sibling:指向下一个兄弟节点
  • return:指向父节点

为什么用链表而不是数组存子节点?因为链表可以方便地暂停和恢复遍历——只需要记住当前处理到哪个节点就行。

双缓冲机制

Fiber 使用"双缓冲"技术,同时维护两棵树:

  • current 树:当前屏幕上显示的内容
  • workInProgress 树:正在构建的新树

两棵树的节点通过 alternate 指针互相引用:

   current 树                    workInProgress 树

   ┌─────────┐      alternate     ┌─────────┐
   │  App    │ ←─────────────────→│  App    │
   │(current)│                    │ (WIP)   │
   └─────────┘                    └─────────┘
       │                              │
   ┌─────────┐      alternate     ┌─────────┐
   │ Header  │ ←─────────────────→│ Header  │
   │(current)│                    │ (WIP)   │
   └─────────┘                    └─────────┘

这个设计的好处:

  1. 渲染过程中不影响当前显示:所有变更都在 workInProgress 树上进行
  2. 原子化提交:构建完成后,直接把 workInProgress 变成 current(交换指针)
  3. 复用 fiber 节点:下次更新时,current 树变成 workInProgress 树的基础,减少内存分配

工作循环:Fiber 如何执行

Fiber 的核心执行逻辑在 workLoop 函数中。整个过程分为两个阶段:Render 阶段Commit 阶段

整体流程

flowchart TB
    subgraph render["Render 阶段(可中断)"]
        direction TB
        A[开始更新] --> B[选取下一个工作单元]
        B --> C{有工作单元?}
        C -->|是| D[beginWork: 处理当前节点]
        D --> E{有子节点?}
        E -->|是| B
        E -->|否| F[completeWork: 完成当前节点]
        F --> G{有兄弟节点?}
        G -->|是| B
        G -->|否| H[返回父节点继续 complete]
        H --> I{回到根节点?}
        I -->|否| G
        I -->|是| J[Render 阶段结束]
        C -->|否| J
    end

    subgraph commit["Commit 阶段(不可中断)"]
        direction TB
        K[开始提交] --> L[Before Mutation]
        L --> M[Mutation: 操作 DOM]
        M --> N[Layout: 执行副作用]
        N --> O[完成]
    end

    J --> K

    style render fill:#cce5ff,stroke:#0d6efd
    style commit fill:#d4edda,stroke:#28a745

Render 阶段:构建 workInProgress 树

Render 阶段的目标是:遍历 fiber 树,找出哪些节点需要更新,打上标记(flags),构建完整的 workInProgress 树。

这个阶段是可中断的——React 可以在处理完任意一个 fiber 后暂停,把控制权交还给浏览器。

workLoop:工作循环

function workLoop() {
  // 循环处理每个工作单元
  while (workInProgress !== null) {
    performUnitOfWork(workInProgress);
  }
}

function performUnitOfWork(unitOfWork) {
  const current = unitOfWork.alternate;

  // 1. beginWork:处理当前节点,返回子节点
  const next = beginWork(current, unitOfWork, renderLanes);

  // 2. 更新 memoizedProps
  unitOfWork.memoizedProps = unitOfWork.pendingProps;

  if (next === null) {
    // 没有子节点了,完成当前节点的工作
    completeUnitOfWork(unitOfWork);
  } else {
    // 有子节点,继续处理子节点
    workInProgress = next;
  }
}

beginWork:向下遍历,标记更新

beginWork 负责处理当前 fiber 节点,主要做这些事:

  1. 判断是否可以跳过:如果 props 和 state 都没变,直接跳过这个子树
  2. 根据 fiber 类型执行不同逻辑:函数组件就执行函数,类组件就调用 render 方法
  3. 创建/更新子 fiber 节点:对比新旧 children,进行 diff
  4. 返回第一个子 fiber:作为下一个工作单元
function beginWork(current, workInProgress, renderLanes) {
  // 优化:如果没有更新,可以跳过
  if (current !== null) {
    const oldProps = current.memoizedProps;
    const newProps = workInProgress.pendingProps;

    if (oldProps === newProps && !hasContextChanged()) {
      // 没有变化,尝试跳过
      return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
    }
  }

  // 根据 fiber 类型分发处理
  switch (workInProgress.tag) {
    case FunctionComponent:
      return updateFunctionComponent(current, workInProgress, renderLanes);
    case ClassComponent:
      return updateClassComponent(current, workInProgress, renderLanes);
    case HostComponent:
      return updateHostComponent(current, workInProgress, renderLanes);
    // ... 其他类型
  }
}

completeWork:向上回溯,准备 DOM

当一个 fiber 节点没有子节点(或所有子节点都处理完了),就会调用 completeWork

  1. 创建/更新真实 DOM 节点:但还不挂载到页面上
  2. 收集副作用:把有 flags 的节点串成链表,方便 Commit 阶段处理
  3. 冒泡 subtreeFlags:让父节点知道子树中有没有需要处理的副作用
function completeWork(current, workInProgress, renderLanes) {
  const newProps = workInProgress.pendingProps;

  switch (workInProgress.tag) {
    case HostComponent: {
      const type = workInProgress.type; // 如 'div'

      if (current !== null && workInProgress.stateNode !== null) {
        // 更新:对比新旧 props,计算需要更新的属性
        updateHostComponent(current, workInProgress, type, newProps);
      } else {
        // 新建:创建 DOM 元素
        const instance = createInstance(type, newProps);
        // 把所有子 DOM 节点挂到这个元素上
        appendAllChildren(instance, workInProgress);
        workInProgress.stateNode = instance;
      }

      // 收集副作用标记到父节点
      bubbleProperties(workInProgress);
      return null;
    }
    // ... 其他类型
  }
}

遍历顺序示意

假设有这样一棵组件树:

       App
      /   \
   Header  Main
    /  \      \
  Logo  Nav   Content

Fiber 的遍历顺序是深度优先,但会在每个节点上执行 beginWork(向下)和 completeWork(向上):

1. beginWork(App)
2. beginWork(Header)
3. beginWork(Logo)
4. completeWork(Logo)     ← Logo 没有子节点,开始 complete
5. beginWork(Nav)         ← 回到 Header,处理下一个子节点
6. completeWork(Nav)
7. completeWork(Header)   ← Header 所有子节点处理完,complete 自己
8. beginWork(Main)
9. beginWork(Content)
10. completeWork(Content)
11. completeWork(Main)
12. completeWork(App)     ← 回到根节点,Render 阶段结束

Commit 阶段:应用变更

Render 阶段完成后,workInProgress 树已经构建好了,所有需要的变更也都标记好了。接下来就是 Commit 阶段——把这些变更实际应用到 DOM 上。

Commit 阶段是同步的、不可中断的。因为这个阶段要操作真实 DOM,必须一次性完成,否则用户会看到不一致的 UI。

Commit 阶段分为三个子阶段:

1. Before Mutation(DOM 操作前)

  • 调用 getSnapshotBeforeUpdate 生命周期方法
  • 调度 useEffect 的清理函数(异步)

2. Mutation(执行 DOM 操作)

这是真正修改 DOM 的阶段:

function commitMutationEffects(root, finishedWork) {
  while (nextEffect !== null) {
    const flags = nextEffect.flags;

    // 处理 DOM 插入
    if (flags & Placement) {
      commitPlacement(nextEffect);
    }

    // 处理 DOM 更新
    if (flags & Update) {
      commitWork(current, nextEffect);
    }

    // 处理 DOM 删除
    if (flags & Deletion) {
      commitDeletion(root, nextEffect);
    }

    nextEffect = nextEffect.nextEffect;
  }
}

3. Layout(DOM 操作后)

  • 调用 componentDidMount / componentDidUpdate
  • 调用 useLayoutEffect 的回调
  • 更新 ref

最后,React 把 current 指针指向 workInProgress 树,完成树的切换。

优先级调度:让重要的事先做

Fiber 架构的一大优势是支持优先级调度。不是所有更新都同等重要——用户输入应该比后台数据刷新更快响应。

Lane 优先级模型

React 18 使用 Lane 模型来表示优先级。每个优先级是一个 32 位整数中的一个位:

const NoLanes = 0b0000000000000000000000000000000;
const SyncLane = 0b0000000000000000000000000000001;         // 最高优先级:同步
const InputContinuousLane = 0b0000000000000000000000000100; // 连续输入,如拖拽
const DefaultLane = 0b0000000000000000000000000010000;      // 默认优先级
const TransitionLane1 = 0b0000000000000000000001000000;     // Transition
const IdleLane = 0b0100000000000000000000000000000;         // 空闲时执行

位运算的好处是可以高效地合并、比较多个优先级:

// 合并优先级
const mergedLanes = lane1 | lane2;

// 判断是否包含某优先级
const includesLane = (lanes & lane) !== 0;

// 获取最高优先级
const highestLane = lanes & -lanes;

Scheduler:任务调度器

React 有一个独立的 Scheduler 包,负责按优先级调度任务。它的核心思想是时间切片(Time Slicing):

  1. 把渲染工作拆成多个小任务
  2. 每个小任务执行完后,检查是否有更高优先级的任务
  3. 如果有,暂停当前工作,先处理高优先级任务
  4. 如果时间片用完了(通常是 5ms),让出主线程给浏览器
function workLoopConcurrent() {
  // 循环执行工作单元,但会在时间片用完时暂停
  while (workInProgress !== null && !shouldYield()) {
    performUnitOfWork(workInProgress);
  }
}

function shouldYield() {
  // 检查是否还有剩余时间,或者是否有更高优先级的任务
  return getCurrentTime() >= deadline || hasHigherPriorityWork();
}

时间切片的效果

没有时间切片的情况下,一个耗时 200ms 的渲染任务会完全阻塞主线程:

主线程:[==== 200ms 渲染任务 ====]
         ^                    ^
         |                    |
    用户点击                200ms 后才响应

有时间切片后,渲染任务被拆成多个 5ms 的小块,中间可以响应用户交互:

主线程:[5ms][响应点击][5ms][5ms][5ms]...[5ms]
         ^      ^
         |      |
    用户点击   立即响应

startTransition:标记低优先级更新

React 18 提供了 startTransition API,让开发者可以手动标记哪些更新是"可以延迟的":

import { startTransition } from 'react';

function SearchPage() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);

  function handleChange(e) {
    // 高优先级:立即更新输入框
    setQuery(e.target.value);

    // 低优先级:搜索结果可以稍后更新
    startTransition(() => {
      setResults(search(e.target.value));
    });
  }

  return (
    <>
      <input value={query} onChange={handleChange} />
      <SearchResults results={results} />
    </>
  );
}

在这个例子中,用户每次输入都会触发两个更新:

  1. setQuery:高优先级,输入框立即响应
  2. setResults:低优先级,包裹在 startTransition 里,可以被打断

如果用户快速输入,搜索结果的渲染可能会被跳过几次,直到用户停止输入。这保证了输入框始终流畅响应,而不会被搜索结果的渲染阻塞。

Hooks 与 Fiber 的关系

Hooks 是怎么实现的?答案就在 Fiber 节点的 memoizedState 字段里。

Hooks 链表

每个函数组件的 fiber 节点都有一个 memoizedState 字段,存储着该组件所有 hooks 的状态。这些状态以链表的形式串联:

FiberNode
├── memoizedState ─→ Hook1 ─→ Hook2 ─→ Hook3 ─→ null
│                    (useState) (useEffect) (useMemo)
└── ...

每个 hook 节点的结构大致如下:

const hook = {
  memoizedState: any,     // 这个 hook 存储的值
  baseState: any,         // 更新前的基础状态
  baseQueue: Update | null,
  queue: UpdateQueue,     // 待处理的更新队列
  next: Hook | null       // 指向下一个 hook
};

useState 的实现原理

当你在组件里调用 useState(0) 时,React 做的事情:

首次渲染(mount):

function mountState(initialState) {
  // 1. 创建一个新的 hook 节点,挂到链表上
  const hook = mountWorkInProgressHook();

  // 2. 计算初始值
  if (typeof initialState === 'function') {
    initialState = initialState();
  }

  // 3. 保存状态
  hook.memoizedState = hook.baseState = initialState;

  // 4. 创建更新队列
  hook.queue = {
    pending: null,
    dispatch: null,
    // ...
  };

  // 5. 返回 [state, setState]
  const dispatch = dispatchSetState.bind(null, currentlyRenderingFiber, hook.queue);
  hook.queue.dispatch = dispatch;

  return [hook.memoizedState, dispatch];
}

后续更新(update):

function updateState() {
  // 1. 找到当前 hook(按顺序从链表取)
  const hook = updateWorkInProgressHook();

  // 2. 处理所有待处理的更新
  const queue = hook.queue;
  let newState = hook.baseState;

  let update = queue.pending;
  while (update !== null) {
    // 应用每个更新
    newState = typeof update.action === 'function'
      ? update.action(newState)
      : update.action;
    update = update.next;
  }

  // 3. 保存新状态
  hook.memoizedState = newState;

  return [newState, queue.dispatch];
}

为什么 Hooks 不能在条件语句里调用?

因为 hooks 是按调用顺序存储在链表里的,React 靠顺序来匹配每次渲染时的 hook。

假设你这样写:

function Bad() {
  const [count, setCount] = useState(0);

  if (count > 0) {
    useEffect(() => { /* ... */ }); // 危险!
  }

  const [name, setName] = useState('');
  // ...
}

第一次渲染时 count 是 0,hooks 链表是:

Hook1(useState: count) → Hook2(useState: name) → null

第二次渲染时 count 变成 1,useEffect 被执行了,链表变成:

Hook1(useState: count) → Hook2(useEffect) → Hook3(useState: name) → null

React 按顺序取 hook,第二个位置取到了 useEffect,但代码里第二个 hook 是 useState,类型对不上,直接报错。

所以 React 的规则是:Hooks 只能在函数组件的最顶层调用,不能在条件、循环或嵌套函数里。

useEffect 的实现原理

useEffect 的状态存储方式略有不同,它的副作用会被收集到 fiber 的 updateQueue 中:

function mountEffect(create, deps) {
  const hook = mountWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;

  // 标记这个 fiber 有 Passive 副作用
  currentlyRenderingFiber.flags |= PassiveEffect;

  // 创建 effect 对象,挂到 fiber 的 updateQueue
  hook.memoizedState = pushEffect(
    HasEffect | Passive,
    create,
    undefined,
    nextDeps
  );
}

Effect 链表结构:

FiberNode.updateQueue.effectsEffect1Effect2Effect3Effect1 (循环链表)
  ├── create: () => { ... }     // 执行的函数
  ├── destroy: () => { ... }    // 清理函数(上次 create 的返回值)
  ├── deps: [a, b]              // 依赖数组
  └── next: Effect2

在 Commit 阶段完成后,React 会异步执行所有 Passive 类型的 effects:

  1. 先执行所有 effect 的 destroy(清理函数)
  2. 再执行所有 effect 的 create(新的副作用)

这也是为什么 useEffect 总是在 DOM 更新后异步执行。

Fiber 支撑的高级特性

Fiber 架构不只是让渲染可以中断,它还是很多 React 高级特性的基础。

Suspense:优雅处理异步

Suspense 让你可以在等待异步数据时显示 fallback UI:

function ProfilePage() {
  return (
    <Suspense fallback={<Spinner />}>
      <ProfileDetails />
    </Suspense>
  );
}

function ProfileDetails() {
  const user = use(fetchUser()); // 这个 promise 还没 resolve 时会"挂起"
  return <h1>{user.name}</h1>;
}

Suspense 的实现依赖 Fiber 的以下能力:

  1. 抛出 Promise:当组件需要等待异步数据时,可以 throw 一个 Promise
  2. 捕获并暂停:Fiber 在遍历时捕获这个 Promise,标记当前子树为"挂起"状态
  3. 显示 fallback:渲染最近的 Suspense 边界的 fallback
  4. 恢复渲染:Promise resolve 后,从挂起的地方重新开始渲染
sequenceDiagram
    participant App
    participant Suspense
    participant ProfileDetails
    participant Fiber

    App->>Suspense: 渲染
    Suspense->>ProfileDetails: 渲染
    ProfileDetails->>Fiber: throw Promise
    Fiber->>Suspense: 捕获 Promise,显示 fallback
    Note over Suspense: 显示 <Spinner />
    ProfileDetails-->>Fiber: Promise resolved
    Fiber->>ProfileDetails: 重新渲染
    ProfileDetails->>Suspense: 返回真实内容
    Note over Suspense: 显示 <h1>用户名</h1>

Concurrent Rendering:并发渲染

React 18 的并发模式完全建立在 Fiber 之上。它的核心能力是:React 可以同时准备多个版本的 UI

比如使用 useTransition

function TabContainer() {
  const [isPending, startTransition] = useTransition();
  const [tab, setTab] = useState('home');

  function selectTab(nextTab) {
    startTransition(() => {
      setTab(nextTab);
    });
  }

  return (
    <>
      <TabButton onClick={() => selectTab('home')}>Home</TabButton>
      <TabButton onClick={() => selectTab('posts')}>Posts</TabButton>
      <TabButton onClick={() => selectTab('contact')}>Contact</TabButton>

      {isPending && <Spinner />}

      <TabPanel tab={tab} />
    </>
  );
}

当用户点击 "Posts" tab 时:

  1. React 开始在"后台"渲染 Posts 页面(构建 workInProgress 树)
  2. 同时保持显示当前的 Home 页面(current 树不变)
  3. 如果 Posts 渲染很慢,用户可以点击其他 tab,之前的渲染会被放弃
  4. 渲染完成后,React 才会切换到新的 UI

这就是"并发"的含义:多个渲染任务可以同时存在,React 可以在它们之间切换。

Error Boundaries:错误边界

Error Boundaries 也依赖 Fiber 的树结构:

class ErrorBoundary extends React.Component {
  state = { hasError: false };

  static getDerivedStateFromError(error) {
    return { hasError: true };
  }

  componentDidCatch(error, info) {
    logError(error, info.componentStack);
  }

  render() {
    if (this.state.hasError) {
      return <h1>Something went wrong.</h1>;
    }
    return this.props.children;
  }
}

当子组件抛出错误时,Fiber 会:

  1. 沿着 return 指针向上查找最近的 Error Boundary
  2. 标记该 Error Boundary 的 fiber 有 ShouldCapture flag
  3. 在 Commit 阶段调用 componentDidCatch
  4. 重新渲染 Error Boundary,显示降级 UI

Server Components:服务端组件

React Server Components 也依赖 Fiber 架构。服务端渲染时,React 会:

  1. 在服务端构建 Fiber 树
  2. 将 Fiber 树序列化成特殊的"Flight"格式
  3. 客户端接收后,重建 Fiber 树,进行 Hydration

由于 Fiber 是一个可序列化的数据结构(本质上是一棵树),它可以在服务端和客户端之间传输。

实际影响:性能优化建议

理解了 Fiber 架构,可以更好地进行性能优化。

1. 避免频繁创建新对象

每次渲染时创建新对象会导致 props 变化,触发不必要的子组件更新:

// 差:每次渲染都创建新的 style 对象
function Bad() {
  return <div style={{ color: 'red' }}>...</div>;
}

// 好:把常量提到组件外面
const style = { color: 'red' };
function Good() {
  return <div style={style}>...</div>;
}

在 Fiber 的 beginWork 阶段,React 会比较新旧 props。如果是同一个引用,可以快速跳过子树的处理。

2. 合理使用 key

key 帮助 React 在 diff 时识别哪些元素改变了位置:

// 差:用 index 作为 key,列表重排序时会有问题
items.map((item, index) => <Item key={index} {...item} />)

// 好:用稳定的唯一 ID
items.map(item => <Item key={item.id} {...item} />)

Fiber 在 reconcile children 时,会用 key 来复用已有的 fiber 节点,而不是删除再创建。

3. 使用 startTransition 处理大量更新

如果某个操作会触发大量组件更新(比如筛选长列表),用 startTransition 标记为低优先级:

function FilteredList({ items }) {
  const [filter, setFilter] = useState('');
  const [filteredItems, setFilteredItems] = useState(items);

  function handleFilterChange(e) {
    const value = e.target.value;
    setFilter(value); // 高优先级:输入框立即响应

    startTransition(() => {
      // 低优先级:过滤操作可以稍后执行
      setFilteredItems(items.filter(item =>
        item.name.includes(value)
      ));
    });
  }

  return (
    <>
      <input value={filter} onChange={handleFilterChange} />
      <ItemList items={filteredItems} />
    </>
  );
}

4. 使用 useDeferredValue 延迟非关键更新

useDeferredValue 可以让某个值的更新延后:

function SearchResults({ query }) {
  // 这个值会延迟更新,不阻塞输入
  const deferredQuery = useDeferredValue(query);

  // 基于延迟的值进行渲染
  const results = useMemo(
    () => slowSearch(deferredQuery),
    [deferredQuery]
  );

  return <ResultList results={results} />;
}

5. Suspense 配合 lazy 做代码分割

const HeavyComponent = lazy(() => import('./HeavyComponent'));

function App() {
  return (
    <Suspense fallback={<Loading />}>
      <HeavyComponent />
    </Suspense>
  );
}

Fiber 会在 HeavyComponent 加载时挂起,显示 Loading,加载完成后自动恢复渲染。

调试 Fiber

React DevTools 可以直接查看 Fiber 树。打开 DevTools 的 Components 面板,你看到的组件树本质上就是 Fiber 树的可视化。

你还可以在浏览器控制台里访问 fiber:

// 获取某个 DOM 元素对应的 fiber
const fiber = domElement._reactFiber$...; // 键名是动态的

// 查看 fiber 的结构
console.log(fiber.type);         // 组件类型
console.log(fiber.memoizedState); // 状态(hooks 链表)
console.log(fiber.memoizedProps); // props
console.log(fiber.child);        // 第一个子节点
console.log(fiber.sibling);      // 兄弟节点
console.log(fiber.return);       // 父节点

总结

React Fiber 是 React 从 16 版本开始的核心架构,它解决了旧版 Stack Reconciler 的几个关键问题:

  1. 可中断渲染:把递归调用改成循环遍历,每处理完一个 fiber 就可以暂停
  2. 优先级调度:不同类型的更新有不同优先级,重要的先做
  3. 并发渲染:可以同时准备多个版本的 UI,按需切换
  4. 增量渲染:把渲染工作拆成小块,分散到多个帧中

Fiber 的核心是一个链表树结构的数据模型,加上双缓冲技术和两阶段渲染(Render + Commit)。它是 Hooks、Suspense、Concurrent Mode、Server Components 等现代 React 特性的基础设施。

理解 Fiber 不是为了在日常开发中直接操作它,而是为了:

  • 理解 React 的工作原理,写出更高效的代码
  • 更好地使用 startTransitionuseDeferredValue 等 API
  • 排查性能问题时知道从哪里入手
  • 读懂 React 源码和技术文章

最后放几个深入学习的资源:


如果你觉得这篇文章有帮助,欢迎关注我的 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

Gemini 3做粒子交互特效很出圈?拿 TRAE SOLO 来实现一波!

2025年12月16日 14:07

大家好,我是不如摸鱼去,欢迎来到我的 AI 编程分享专栏。

前几天在家养病时刷抖音,刷到了很多用 Gemini 3做粒子交互特效的视频,感觉很不错,TRAE SOLO 正好也集成了 Gemini 3 pro ,今天就分享一下如何用 TRAE SOLO 快速制作一个告白粒子交互特效。

关于 TRAE SOLO 正式版

11 月 12 日 TRAE SOLO 正式版上线了,新增三栏布局、DiffView 工具、SOLO Coder + Plan,支持多任务并行等功能,在前几天集成了 Gemini 3 pro 也算是填补了 Claude Sonnet 4 的空白。

我可是 TRAE SOLO 老用户了,SOLO 模式(Beta 版)上线的时候我就拿到了 SOLO CODE,并且使用它完成了「复刻童年坦克大战」、「像老乡鸡那样做饭小程序」等实践,并且在实际工作中使用 TRAE 参与了很多任务的开发。如今正式版发布,而且集成了 Gemini 3 pro,自然要尝试一番。

想想做点什么

当刷到类似的视频的时候,我想可以做个《伍六七》里面的「以气御剪」或者「魔刀千刃」,后来想了想放弃了,那个模型应该好复杂😂。后面又想到《楚门的世界》,不知怎么突然想到的,里面有句台词:假如再也见不到你,祝你早安午安晚安。就拿这句台词来做个交互效果吧,假如你再也刷不到我的文章,再也不会给我点赞,祝你早安午安晚安(手动🐶)。

开始SOLO

准备

我们可以把自己当作一个小白,完全不懂应该如何实现这个粒子交互特效,但是要哪些功能和手势是我们需要提供的,我总结了需求如下:

基于原生h5、浏览器、PC摄像头实现手势控制粒子特效交互的逻辑,粒子默认离散,类似银河系分布缓慢移动,同时有5种手势:

手势1: 握拳,握拳后粒子聚拢显示爱心的形状

手势2: 展开手掌并挥手,展开手掌挥手后粒子从当前状态恢复到离散状态

手势3: 👆 比 1 :只有食指伸直,其他 3 根弯曲,此时粒子聚拢显示第一句话:以防再也见不到你

手势4: ✌ 比 2 :只有食指和中指伸直,其他 2 根弯曲手此时粒子聚拢显示第二句话: 祝你

手势5: 👌 比 3 :只有中指、无名指、小指伸直,食指弯曲,此时粒子聚拢显示第三句话:早安,午安,晚安

然后打开 TRAE,切换到 SOLO Builder 模式,选择 Gemini-3-Pro 模型,当然也可以选择 GPT5 ,不过我感觉它不如 Gemini-3-Pro 好用。

SOLO

发送我们的需求给 TRAE SOLO

我们的需求很简单,它一轮就做出来了,并且会输出总结

我们可以看到它完整实现了需求中提到的功能,并且将技术栈和代码结构介绍得很清楚。

部署

在 TRAE 的中栏选择浏览器,并部署,它会帮我们把制作好的内容部署到 vercel 上

部署地址:traeproject1ckaf.vercel.app/

体验效果

效果还不错,符合需求了,哈哈。

ezgif-1f6b77c49a889e7d.gif

总结

本次我们用TRAE SOLO 轻松实现了《早安午安晚安粒子交互特效》,可以看出 TRAE SOLO 正式版发布之后,功能上的增强还是不错的,还有很多我们本次没用到的功能例如 Plan 模式等也很有用,接入了 Gemini-3-Pro 后大模型的短板基本补上了。还有一点我们也可以从最近的更新动作上看出 TRAE 是想走大众编程的路线,它想让更多少基础和无基础的人也能使用到 AI 编程。我在这也希望国产的 AI 编程工具和大模型能更越来越好吧。

最后,祝你早安午安晚安。

欢迎评论区沟通、讨论、分享👇👇

WebP/AVIF 有多香?比 JPEG 小 30%,<picture>标签完美解决兼容性

2025年12月16日 11:37

🖼️ 告别“图多杀猫”:前端图片加载策略全攻略,让你的页面又快又美!

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

在前端性能优化的战场上,图片无疑是最大的“资源杀手”。它们通常占据了网页下载体积的一半以上。一个图片加载策略不当的网站,就像一辆装满了超重行李的跑车,再好的引擎也跑不快。

本篇,我们就来深入探讨如何系统性地优化图片加载,让你的页面既高清又秒开!


⚠️ 图片优化的意义:不仅仅是快

图片优化是一种系统性工程,它的意义远超乎“加载快一点”:

  • 体积最大: 图片是网页中体积最大的静态资源之一,优化可显著降低页面首屏加载时间。
  • 影响 SEO: 页面加载速度是搜索引擎排名的重要指标。
  • 影响转化率: 页面每延迟一秒,用户流失率和转化率都会受到负面影响。
  • 用户留存: 快速、流畅的体验是用户留下的关键。

✨ 策略一:格式选择 — 新旧格式的博弈

选择合适的图片格式是优化的第一步,也是最重要的一步。我们必须根据场景和兼容性,在新旧格式之间做出权衡。

1. 新一代格式:WebP 与 AVIF

格式 优势 适用场景 兼容性
WebP 更高压缩率,体积比 JPEG 小约 30%,支持无损和有损压缩,支持透明度。 网页中的大部分图片,尤其是需要透明度的图标。 广泛支持(Chrome, Firefox, Edge, Safari)。
AVIF 基于 AV1 视频编码,压缩率比 WebP 更高,体积更小。 对性能要求极高,且目标用户群浏览器支持度较高的场景。 较新,支持度稍低于 WebP。

图片格式体积与质量对比图

2. 传统格式:JPEG 与 PNG

  • JPEG: 适合色彩丰富的照片大图,采用有损压缩,不支持透明度。
  • PNG: 适合需要透明背景的图标、Logo 或截图,采用无损压缩,体积通常较大。

🔄 策略二:响应式图片 — 按需加载的智慧

在移动设备、平板和高清屏并存的今天,给所有设备都加载同一张大图是极大的浪费。响应式图片就是解决这个问题的“智慧大脑”。

1. srcset 实现分辨率适配

srcset 允许浏览器根据设备的分辨率视口大小来选择最合适的图片源。

<img srcset="image-480w.jpg 480w,
             image-800w.jpg 800w"(图片源的集合)
     sizes="(max-width: 600px) 480px, 800px"(提供上下文,<=600像素使用480,大于使用800)
     src="image-800w.jpg"(降级方案,为旧浏览器提供)
     alt="响应式图片">
  • srcset 告诉浏览器有哪些可用的图片资源及其对应的宽度(w)。
  • sizes 告诉浏览器在不同视口大小下,图片将占据的宽度。
  • src 作为旧浏览器的降级方案,保证兼容性。

2. <picture> 元素实现格式切换

<picture> 元素可以实现更高级的控制,让浏览器根据格式兼容性来选择图片源,完美解决新格式的兼容性问题。

<picture>(从上往下依次询问是否符合这个source)
  <source srcset="image.avif" type="image/avif">
  <source srcset="image.webp" type="image/webp">
  <img src="image.jpg" alt="多格式图片">
</picture>

工作原理: 浏览器会从上往下依次检查 <source> 标签,一旦找到一个它支持的 type 或符合条件的 srcset,就会加载该资源,并忽略后续的 <source>。如果所有 <source> 都不支持,则会加载最后的 <img> 标签中的 src

picture 和 srcset 响应式加载示意图

核心优势:

  • 浏览器自动选择最佳格式与尺寸
  • 避免浪费带宽与不必要的下载

🔧 策略三:加载时机 — 懒加载与预加载

除了图片本身,何时加载图片也至关重要。

1. 懒加载(Lazy Loading)

对于首屏以外的图片,应采用懒加载。即只有当图片进入或即将进入用户视口时才开始加载。

  • 原生支持: 现代浏览器已支持 loading="lazy" 属性,无需 JavaScript 库。

    <img src="image.jpg" loading="lazy" alt="懒加载图片">
    

2. 预加载(Preloading)

对于首屏关键的图片(如 Logo、背景图),应使用预加载,确保它们尽快被加载。

  • HTML 标签:

    <link rel="preload" href="critical-image.jpg" as="image">
    

✅ 总结与实践:图片优化 Check List

图片优化是一个多维度的工程,以下是实践中的 Check List:

优化层面 实践方法 目标
格式层面 优先使用 WebP / AVIF,PNG/JPEG 作为降级。 最小化文件体积。
响应式层面 利用 <picture>srcset 实现格式和尺寸的按需加载。 避免加载不必要的资源。
加载层面 首屏关键图使用预加载,非首屏图使用懒加载。 优化加载时机,提升首屏速度。
压缩层面 在构建阶段使用工具(如 imagemin)进行自动化无损/有损压缩 保证图片质量的同时进一步减小体积。
架构层面 接入 CDN 与专业的图片服务(如阿里云 OSS、七牛云等)。 自动处理格式转换、缩放和压缩。

下一篇预告: 面对大量的图标和小图片,一个个请求不仅慢,还会造成大量的 HTTP 开销。下一篇我们将探讨如何将这些零散的资源“打包”起来,学习 雪碧图(CSS Sprites) 的原理和实践,以及如何利用它来优化 SVG 的加载。敬请期待!

告别 AI 输出的重复解析:正常 markdown 解析渲染也能提速 2-10 倍以上

作者 king王一帅
2025年12月16日 10:54

incremark 的惊人实力

昨天发布了周日开发的 incremark,没想到实际体验性能远超预期,本想作为自己产品内部的一个小工具,但想了想,开源未尝不是一个好的方向。

先看结果:普通比较短的 markdown 在线测试结果

image.png

稍长的 markdown 在线测试结果

image.png

说明:网站每次 benchmark 测试每次提升倍数可能不一样,这是由于分块策略导致的,demo 页面每次分块的 markdown 字符串长度是随机的 const chunks = content.match(/[\s\S]{1,20}/g) || [],分块会影响稳定块的生成,这样也更符合真实场景,某个块可能会有上一个块的内容跟下一个块的内容。但无论怎样分,性能提升是必然的,demo 网站未通过迎合自身利益的手段进行 chunk 拆分。

vue demo 在线链接:incremark-vue.vercel.app/

react demo 在线链接:incremark-react.vercel.app/

文档链接:incremark-docs.vercel.app/

超长的 markdown 输出会来更更加离谱的速度,20k 的 markdown benchmark 可以达到恐怖的 46 倍速度提升。

为什么它会有如此提升?

传统解析方案弊端

做过 AI 聊天应用的人可能都知道,AI 的流式输出在前端每次获取到的都是一个个简短的 chunk,每次 chunk 过来后,都需要将完整的 markdown 字符串喂给 markdown 解析器,可能是 remark、 markedjs,也可能是 markdown-it。它们每次都需要将所有的 markdown 进行解析,最后在进行渲染,在此过程中会有一部分性能浪费,即已经稳定渲染的块也要被重复解析。

vue-stream-markdown 这类型工具在渲染层也做了努力,将稳定的 token 渲染为稳定的组件,每次只更新不稳定的组件,以此达到渲染层的平滑流式输出。

但、这仍无法解决性能浪费的本质问题,也就是 markdown 文本的重复解析,这一步才是吃掉 cpu 性能的怪兽,输出文档越长,则性能浪费越高。

incremark 核心性能优化原理

incremark 除了做到了 UI 渲染层的组件复用平滑更新,最主要的是在 markdown 解析上做了文档,只解析不稳定的 markdown,不再重复解析稳定的块。因此将解析性能从 O(n²) 拉到了 O(n),理论上输出越长,提升越多。

1. 增量解析:从 O(n²) 到 O(n)

传统解析器每次都重新解析整个文档,导致解析量呈平方增长。Incremark 的 IncremarkParser 类采用增量解析策略,见代码 IncremarkParser.ts

// 设计思路:
// 1. 维护一个文本缓冲区,接收流式输入 
// 2. 识别"稳定边界",将已完成的块标记为 completed 
// 3. 对于正在接收的块,每次重新解析,但只解析该块的内容 
// 4. 复杂嵌套节点作为整体处理,直到确认完成

2. 智能边界检测机制

append 方法中的 findStableBoundary() 是关键优化点

append(chunk: string): IncrementalUpdate {  
  this.buffer += chunk  
  this.updateLines()  
    
  const { line: stableBoundary, contextAtLine } = this.findStableBoundary()  
    
  if (stableBoundary >= this.pendingStartLine && stableBoundary >= 0) {  
    // 只解析新完成的块,不重复解析已完成内容  
    const stableText = this.lines.slice(this.pendingStartLine, stableBoundary + 1).join('\n')  
    const ast = this.parse(stableText)  
    // ...  
  }  
}

3. 状态管理避免重复计算

解析器维护多个关键状态来避免重复工作

  • buffer: 累积的未解析内容
  • completedBlocks: 已完成且永不重新解析的块数组
  • lineOffsets: 行偏移量前缀和,支持 O(1) 行位置计算
  • context: 跟踪代码块、列表等嵌套状态

4. 增量行更新优化

updateLines() 方法只处理新增内容,避免全量 split 操作

private updateLines(): void {  
  // 找到最后一个不完整的行(可能被新 chunk 续上)  
  const lastLineStart = this.lineOffsets[prevLineCount - 1]  
  const textFromLastLine = this.buffer.slice(lastLineStart)  
    
  // 重新 split 最后一行及之后的内容  
  const newLines = textFromLastLine.split('\n')  
  // 只更新变化的部分  
}

性能对比数据

这种设计在实际测试中表现优异 :

文档大小 传统解析字符量 Incremark 解析字符量 减少比例
1KB 1,010,000 20,000 98%
5KB 25,050,000 100,000 99.6%
20KB 400,200,000 400,000 99.9%

关键不变量

Incremark 的性能优势源于一个关键不变量:一旦块被标记为 completed,就永远不会被重新解析。这确保了每个字符最多只被解析一次,实现了 O(n) 的时间复杂度。

小结

昨天在掘金发布了 # AI 时代真正流式解析+渲染双重优化的 Incremark 并没有获得推流,在 V 站随意发帖下,github 意外收获了 35 颗 star,npm 收获 92 下载量,突然感觉它似乎应该是能对一些小伙伴产生作用的,所以尝试当个事来做,如果你也感兴趣,欢迎试用以及代码贡献。

flutter使用package_info_plus库获取应用信息的教程

作者 鹏多多
2025年12月16日 08:40

1. 简介

在 Flutter 应用开发中,获取应用自身的信息(如应用名称、版本号、包名等)是一项常见需求,这些信息可用于展示在关于页面、用于统计分析或实现特定业务逻辑。package_info_plus 插件是 Flutter 生态中获取应用元数据的最佳选择,支持多平台且使用简单。本文将详细介绍如何使用该插件获取各类应用信息。

package_info_plus 是一个跨平台插件,用于获取应用的包信息和元数据,它是 package_info 插件的升级版,由 Flutter Community 维护。

核心特点

  • 支持 iOS、Android、Web、Windows、macOS 和 Linux 多平台
  • 能够获取应用名称、包名、版本号、构建号等关键信息
  • 用法简单,API 直观
  • 与最新的 Flutter 版本保持兼容

可获取的主要信息

  • 应用名称(应用商店中显示的名称)
  • 包名/应用 ID(如 com.example.myapp)
  • 版本名称(如 1.0.0)
  • 版本号/构建号(如 1)
  • 应用签名(部分平台)

2. 安装与配置

在项目的 pubspec.yaml 文件中添加 package_info_plus 依赖:

dependencies:
  flutter:
    sdk: flutter
  package_info_plus: ^4.0.0  # 使用最新版本

运行以下命令安装依赖:

flutter pub get

2.1. 平台特定配置

package_info_plus 在大多数平台上无需额外配置即可使用,但某些平台可能需要注意以下事项:

Web 平台: 需要在 web/index.html 中添加一些元数据,插件会从这里读取信息:

<head>
  <!-- 其他头部内容 -->
  <meta name="application-name" content="你的应用名称">
  <meta name="version" content="1.0.0">
  <meta name="build-number" content="1">
  <meta name="package" content="com.example.myapp">
</head>

其他平台: Android、iOS、Windows、macOS 和 Linux 平台不需要额外配置,插件会自动从各自的配置文件中读取信息。

3. 基本使用方法

在需要使用的 Dart 文件中导入 package_info_plus

import 'package:package_info_plus/package_info_plus.dart';

3.1. 获取应用信息

package_info_plus 提供了一个 PackageInfo 类,通过它可以获取所有应用信息。获取过程是异步的,通常使用 await 关键字:

Future<void> getAppInfo() async {
  PackageInfo packageInfo = await PackageInfo.fromPackageInfo();
  
  String appName = packageInfo.appName;
  String packageName = packageInfo.packageName;
  String version = packageInfo.version;
  String buildNumber = packageInfo.buildNumber;
  
  print('应用名称: $appName');
  print('包名: $packageName');
  print('版本号: $version');
  print('构建号: $buildNumber');
}

3.2. 详细信息说明

PackageInfo 类提供的主要属性:

属性 说明 平台支持
appName 应用的用户可见名称 所有平台
packageName 应用的唯一标识符(Android 的包名,iOS 的 Bundle ID 等) 所有平台
version 应用的版本名称(如 1.0.0) 所有平台
buildNumber 应用的构建号或版本代码(通常是整数) 所有平台
buildSignature 应用的签名信息 Android 特有
installerStore 应用的安装来源商店 Android 特有

4. 实战示例

下面是一个完整的示例,创建一个展示应用信息的"关于"页面:

import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart';

class AboutPage extends StatefulWidget {
  const AboutPage({super.key});

  @override
  State<AboutPage> createState() => _AboutPageState();
}

class _AboutPageState extends State<AboutPage> {
  late Future<PackageInfo> _packageInfoFuture;

  @override
  void initState() {
    super.initState();
    // 初始化时获取应用信息
    _packageInfoFuture = PackageInfo.fromPackageInfo();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('关于我们'),
      ),
      body: FutureBuilder<PackageInfo>(
        future: _packageInfoFuture,
        builder: (context, snapshot) {
          if (snapshot.connectionState == ConnectionState.waiting) {
            return const Center(child: CircularProgressIndicator());
          } else if (snapshot.hasError) {
            return Center(child: Text('获取应用信息失败: ${snapshot.error}'));
          } else if (!snapshot.hasData) {
            return const Center(child: Text('无法获取应用信息'));
          }

          PackageInfo packageInfo = snapshot.data!;

          return SingleChildScrollView(
            padding: const EdgeInsets.all(16.0),
            child: Column(
              children: [
                // 应用图标
                const CircleAvatar(
                  radius: 60,
                  child: Icon(Icons.android, size: 60), // 实际项目中替换为应用图标
                ),
                const SizedBox(height: 24),
                
                // 应用名称
                Text(
                  packageInfo.appName,
                  style: Theme.of(context).textTheme.headlineMedium,
                ),
                const SizedBox(height: 8),
                
                // 版本信息
                Text(
                  '版本: ${packageInfo.version} (${packageInfo.buildNumber})',
                  style: Theme.of(context).textTheme.titleMedium,
                ),
                const SizedBox(height: 24),
                
                // 详细信息列表
                _buildInfoItem('包名', packageInfo.packageName),
                if (packageInfo.buildSignature.isNotEmpty)
                  _buildInfoItem('应用签名', packageInfo.buildSignature),
                if (packageInfo.installerStore != null && packageInfo.installerStore!.isNotEmpty)
                  _buildInfoItem('安装来源', packageInfo.installerStore!),
                
                const SizedBox(height: 32),
                
                // 版权信息
                const Text(
                  '© 2023 你的公司名称. 保留所有权利.',
                  style: TextStyle(color: Colors.grey),
                ),
              ],
            ),
          );
        },
      ),
    );
  }

  // 构建信息项
  Widget _buildInfoItem(String label, String value) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 8.0),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(
            '$label: ',
            style: const TextStyle(
              fontWeight: FontWeight.bold,
              width: 100,
            ),
          ),
          Expanded(
            child: Text(value),
          ),
        ],
      ),
    );
  }
}

这个示例创建了一个完整的"关于"页面,包含以下功能:

  • 使用 FutureBuilder 处理异步获取应用信息的过程
  • 展示加载状态、错误状态和成功状态
  • 显示应用名称、图标、版本号等基本信息
  • 根据平台特性选择性展示签名和安装来源信息

5. 高级应用场景

下面是一些高级的应用场景,例如:

5.1. 版本更新检查

可以使用获取到的版本号实现版本更新检查功能:

class VersionChecker {
  // 假设这是从服务器获取的最新版本号
  final String _latestVersion = "1.1.0";
  
  Future<bool> isUpdateAvailable() async {
    PackageInfo packageInfo = await PackageInfo.fromPackageInfo();
    return _compareVersions(packageInfo.version, _latestVersion) < 0;
  }
  
  // 版本号比较工具方法
  int _compareVersions(String current, String latest) {
    List<int> currentParts = current.split('.').map(int.parse).toList();
    List<int> latestParts = latest.split('.').map(int.parse).toList();
    
    int maxLength = max(currentParts.length, latestParts.length);
    
    for (int i = 0; i < maxLength; i++) {
      int currentPart = i < currentParts.length ? currentParts[i] : 0;
      int latestPart = i < latestParts.length ? latestParts[i] : 0;
      
      if (currentPart < latestPart) return -1;
      if (currentPart > latestPart) return 1;
    }
    
    return 0;
  }
}

// 使用示例
void checkForUpdates() async {
  VersionChecker checker = VersionChecker();
  bool hasUpdate = await checker.isUpdateAvailable();
  if (hasUpdate) {
    // 显示更新提示
    print("有新版本可用!");
  }
}

5.2. 应用使用统计

结合设备信息和应用信息,可以实现应用使用统计:

import 'package:device_info_plus/device_info_plus.dart';

Future<void> trackAppUsage() async {
  PackageInfo packageInfo = await PackageInfo.fromPackageInfo();
  DeviceInfoPlugin deviceInfo = DeviceInfoPlugin();
  
  String trackingInfo = '''
  应用信息:
  - 名称: ${packageInfo.appName}
  - 版本: ${packageInfo.version}
  - 包名: ${packageInfo.packageName}
  
  设备信息:
  ''';
  
  if (Platform.isAndroid) {
    AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo;
    trackingInfo += '- 设备: ${androidInfo.brand} ${androidInfo.model}\n';
    trackingInfo += '- 系统版本: Android ${androidInfo.version.release}';
  } else if (Platform.isIOS) {
    IosDeviceInfo iosInfo = await deviceInfo.iosInfo;
    trackingInfo += '- 设备: ${iosInfo.model}\n';
    trackingInfo += '- 系统版本: iOS ${iosInfo.systemVersion}';
  }
  
  // 实际项目中,这里会将统计信息发送到服务器
  print(trackingInfo);
}

5.3. 动态配置应用行为

根据应用版本动态配置应用行为:

Future<void> configureAppBehavior() async {
  PackageInfo packageInfo = await PackageInfo.fromPackageInfo();
  
  // 根据不同版本启用不同功能
  if (packageInfo.version.startsWith('1.0.')) {
    // v1.0.x 版本的配置
    print('配置基础功能');
  } else if (packageInfo.version.startsWith('1.1.')) {
    // v1.1.x 版本的配置
    print('配置高级功能');
  }
  
  // 根据构建号启用测试功能
  int buildNumber = int.tryParse(packageInfo.buildNumber) ?? 0;
  if (buildNumber >= 100) {
    print('启用beta功能');
  }
}

6. 注意事项

  • 异步处理
    • PackageInfo.fromPackageInfo() 是异步方法,必须正确处理异步操作
    • 建议在应用启动时获取一次应用信息并缓存,避免重复获取
    • 使用 FutureBuilder 或状态管理工具(如 Provider、Bloc)处理 UI 渲染
  • 平台差异
    • 不同平台上的信息可能有所不同,尤其是 buildSignatureinstallerStore 等平台特定属性
    • Web 平台需要手动配置元数据,其他平台则自动读取
    • 处理平台特定属性时,应先检查其是否存在或不为空
  • 版本号管理
    • 保持 pubspec.yaml、AndroidManifest.xml 和 Info.plist 中的版本信息同步
    • 建立清晰的版本号命名规则(如语义化版本 主版本.次版本.修订号)
    • 构建号应随每次构建递增,用于区分不同构建
  • 性能考虑
    • 应用信息不会频繁变化,无需多次获取
    • 建议在应用初始化阶段获取并存储在全局状态中
    • 避免在 UI 渲染关键路径中获取应用信息

7. 常见问题解决

  • 信息不准确或未更新
    • 确保所有平台的配置文件都已更新版本信息
    • 运行 flutter clean 清理缓存后重新构建
    • 对于 Web 平台,检查 index.html 中的元数据是否正确
  • 某些属性为空
    • 某些属性(如 installerStore)仅在特定条件下才有值
    • 检查是否在正确的平台上访问平台特定属性
    • Web 平台需要手动配置所有属性
  • 构建错误
    • 确保使用的 package_info_plus 版本与 Flutter 版本兼容
    • 检查是否正确导入了包
    • 尝试更新插件到最新版本

8. 总结

package_info_plus 是 Flutter 开发中获取应用元数据的实用工具,它提供了简单直观的 API,支持多平台,能够满足大多数应用对获取自身信息的需求。

合理利用应用信息可以提升用户体验,简化开发流程,并为应用的统计分析提供支持。更多详细信息可以参考 官方文档


本次分享就到这儿啦,我是鹏多多,深耕前端的技术创作者,如果您看了觉得有帮助,欢迎评论,关注,点赞,转发,我们下次见~

PS:在本页按F12,在console中输入document.getElementsByClassName('panel-btn')[0].click();有惊喜哦~

往期文章

Cocos游戏开发中的箭头游戏效果

2025年12月16日 08:32

游戏录屏

引言

Cocos游戏开发中的箭头游戏效果

近日,笔者看到小伙伴正在推广他的小游戏,深入游玩了一波,非常精致和益智(就是有点花眼)。看了下排行榜,这类游戏目前依旧非常的火爆。

言归正传,游戏体验过之后,非常感兴趣这个游戏中的箭头游戏效果是如何实现的呢?

今天给大家介绍一下如何在Cocos游戏开发中实现箭头游戏效果,非常感谢小伙伴的投稿。

本文源工程在文末获取,小伙伴们自行前往。

1. 分析一下游戏

我们先来分析一下实现这个游戏有哪些关键点:

  1. 箭头:既然是箭头游戏,游戏的核心元素就是横七竖八的箭头。

  2. 箭头的移动:箭头点击之后,可以移动,若前方无阻挡,则可以移出消除,反之则不可移动。

  3. 关卡编辑:关卡类游戏离不开关卡的编辑,除非关卡的难度可以通过某些参数控制并随机,但是纯随机的游戏也不一定好玩。

2. 怎么实现?

1.箭头

箭头其实就是各种路径的线,然后在顶部加上一个三角形,即可形成箭头。

在Cocos游戏开发中,画线常用的组件就是Graphics组件。

来源于Cocos官方文档

我们画箭头要用到的核心接口如下。

来源于Cocos官方文档

画线过程如下

画箭头过程如下

2.箭头移动

箭头移动的逻辑其实和贪吃蛇比较相似,如下图,箭头由5段,6个点组成,箭头移动时,实际上只需要根据方向移动头和尾两个点即可,其他点不用动。

尾巴的点移动到下一个点的位置时,我们只需要把尾巴的点去掉,那么下一个点就成了新的尾巴。

移动的方向只需要根据相邻的两个点即可判断。

3. 关卡编辑

游戏的功能实现比较简单,较为复杂的在于编辑器,编辑器既要做到方便编辑,最理想的状态下是能够轻松地根据难度生成不同的关卡,又要能够检测关卡是否能正常通关。下面列举几种:

  • 手动编辑:生成格子盘,然后通过鼠标点击标注不产生箭头的区域,然后生成。
  • 自动生成:根据规则自动生成,自动检测生成的关卡是否能顺利通关。
  • 上传图片生成:上传不同形状的图片,根据图片像素去生成关卡,使得关卡更加有趣。
  • Excel编辑生成: 最近看到有小伙伴在Excel中进行关卡编辑,并且通过宏生成关卡数据,让笔者眼前一亮。

最后看看小伙伴们编辑生成的精美关卡(文末可获取编辑器)。

3. 箭头游戏效果实战

1.创建工程

引擎版本Cocoscreator 3.8.7

编程语言TypeScript

首先创建一个Arrow工程:

2.创建脚本

新建一个Main.ts脚本,并且拖拽到Canvas,用于实现我们的实战逻辑。

3.画箭头

首先根据原理,我们通过Graphics组件实现,打开Main.ts脚本,在start方法中添加Graphics组件,并且画一根线宽为10,坐标从(0,0)(0,200)的线。

  • moveTo: 移动到画线起点。
  • lineTo: 画线到该点。
  • stroke: 开始画线。

运行后就能看到一根长200的线。

接下来我们要画一个三角形,让线头变成箭头,先找到三个点,然后通过fill进行填充形成三角形。

  • close: 从当前点画线到起点。
  • fillColor: 填充颜色。
  • fill: 填充。

运行后就能看到线变成了箭头。

箭头一般不止2个点,于是我们把方法整理一下。

箭头的路径点加进去,然后进行绘制。

运行后就能得到一个这样的箭头。

4.箭头移动

箭头移动的逻辑相对好写一点,就是让头和尾巴的点沿着方向移动即可,当尾巴点和前一节接触后移除。

点击开始移动。

5.效果演示

结语

还有没玩过这类游戏的吗?实在太火爆了,变种的才刚出来,还能分口热汤吗?

本文实战源码可通过阅读原文获取。


我是"亿元程序员",一位有着8年游戏行业经验的主程。在游戏开发中,希望能给到您帮助, 也希望通过您能帮助到大家。

AD:笔者线上的小游戏《打螺丝闯关》《贪吃蛇掌机经典》《重力迷宫球》《填色之旅》《方块掌机经典》大家可以自行点击搜索体验。

实不相瞒,想要个爱心!请把该文章分享给你觉得有需要的其他小伙伴。谢谢!

推荐文章:

Cocos游戏如何接入安卓穿山甲广告变现?

你知道和不知道的微信小游戏常用API整理,赶紧收藏用起来~

Cocos游戏如何快速接入抖音小游戏广告变现?

如何在CocosCreator3.8中实现割绳子游戏效果

如何在CocosCreator3.8中实现动态切割模型?

小米不仅造车,还造模型?309B参数全开源,深度思考完胜DeepSeek 🐒🐒🐒

作者 Moment
2025年12月17日 13:55

我正在开发 DocFlow,它是一个完整的 AI 全栈协同文档平台。该项目融合了多个技术栈,包括基于 Tiptap 的富文本编辑器、NestJs 后端服务、AI 集成功能和实时协作。在开发过程中,我积累了丰富的实战经验,涵盖了 Tiptap 的深度定制、性能优化和协作功能的实现等核心难点。

如果你对 AI 全栈开发、Tiptap 富文本编辑器定制或 DocFlow 项目的完整技术方案感兴趣,欢迎加我微信 yunmz777 进行私聊咨询,获取详细的技术分享和最佳实践。

小米不仅造车,还造模型?

2024 年 12 月,当所有人还在关注小米汽车的时候,小米却悄然开源了一款震撼整个 AI 界的大语言模型——MiMo-V2-Flash。这款拥有 309B总参数15B激活参数 的超大规模模型,不仅在性能上达到了世界顶尖水平,更在深度思考能力上完胜 DeepSeek,重新定义了 AI 模型的效率天花板。

本文将详细介绍这款模型的技术特点、性能表现以及使用方式。

MiMo-V2-Flash

MiMo-V2-Flash 是一个混合专家(MoE)语言模型,拥有 309B总参数15B激活参数。专为高速推理和智能体工作流设计,它采用了新颖的混合注意力架构和多 Token 预测(MTP)技术,在显著降低推理成本的同时实现了最先进的性能。

image.png

1. 介绍

MiMo-V2-Flash 在长上下文建模能力和推理效率之间创造了新的平衡。主要特性包括:

  • 混合注意力架构:以 5:1 的比例交织滑动窗口注意力(SWA)和全局注意力(GA),采用激进的 128-token 窗口。通过可学习的 attention sink bias(注意力沉降偏置),在保持长上下文性能的同时,将 KV缓存 存储减少了近 6 倍。
  • 多 Token 预测(MTP):配备了轻量级 MTP 模块(每块 0.33B 参数),使用密集 FFN。这将推理期间的输出速度提升了 3 倍,并有助于加速 RL 训练中的 rollout
  • 高效预训练:使用 FP8 混合精度在 27T token 上训练,原生 32k 序列长度。上下文窗口支持最长 256k。
  • 智能体能力:后训练利用多教师在线策略蒸馏(MOPD)和大规模智能体 RL,在 SWE-Bench 和复杂推理任务上实现了卓越性能。

2. 模型下载

模型 总参数 激活参数 上下文长度 下载地址
MiMo-V2-Flash-Base 309B 15B 256k 🤗 HuggingFace
MiMo-V2-Flash 309B 15B 256k 🤗 HuggingFace

重要提示:我们还开源了 3 层 MTP 权重,以促进社区研究。

3. 评估结果

基础模型评估

MiMo-V2-Flash-Base 在标准基准测试中展现出强大的性能,超越了参数量显著更大的模型。

类别 基准测试 设置/长度 MiMo-V2-Flash Base Kimi-K2 Base DeepSeek-V3.1 Base DeepSeek-V3.2 Exp Base
参数 激活参数 / 总参数 - 15B / 309B 32B / 1043B 37B / 671B 37B / 671B
通用 BBH 3-shot 88.5 88.7 88.2 88.7
MMLU 5-shot 86.7 87.8 87.4 87.8
MMLU-Redux 5-shot 90.6 90.2 90.0 90.4
MMLU-Pro 5-shot 73.2 69.2 58.8 62.1
DROP 3-shot 84.7 83.6 86.3 86.6
ARC-Challenge 25-shot 95.9 96.2 95.6 95.5
HellaSwag 10-shot 88.5 94.6 89.2 89.4
WinoGrande 5-shot 83.8 85.3 85.9 85.6
TriviaQA 5-shot 80.3 85.1 83.5 83.9
GPQA-Diamond 5-shot 55.1 48.1 51.0 52.0
SuperGPQA 5-shot 41.1 44.7 42.3 43.6
SimpleQA 5-shot 20.6 35.3 26.3 27.0
数学 GSM8K 8-shot 92.3 92.1 91.4 91.1
MATH 4-shot 71.0 70.2 62.6 62.5
AIME 24&25 2-shot 35.3 31.6 21.6 24.8
代码 HumanEval+ 1-shot 70.7 84.8 64.6 67.7
MBPP+ 3-shot 71.4 73.8 72.2 69.8
CRUXEval-I 1-shot 67.5 74.0 62.1 63.9
CRUXEval-O 1-shot 79.1 83.5 76.4 74.9
MultiPL-E HumanEval 0-shot 59.5 60.5 45.9 45.7
MultiPL-E MBPP 0-shot 56.7 58.8 52.5 50.6
BigCodeBench 0-shot 70.1 61.7 63.0 62.9
LiveCodeBench v6 1-shot 30.8 26.3 24.8 24.9
SWE-Bench (AgentLess) 3-shot 30.8 28.2 24.8 9.4*
中文 C-Eval 5-shot 87.9 92.5 90.0 91.0
CMMLU 5-shot 87.4 90.9 88.8 88.9
C-SimpleQA 5-shot 61.5 77.6 70.9 68.0
多语言 GlobalMMLU 5-shot 76.6 80.7 81.9 82.0
INCLUDE 5-shot 71.4 75.3 77.2 77.2
长上下文 NIAH-Multi 32K 99.3 99.8 99.7 85.6
64K 99.9 100.0 98.6 85.9
128K 98.6 99.5 97.2 94.3
256K 96.7 - - -
GSM-Infinite Hard 16K 37.7 34.6 41.5 50.4
32K 33.7 26.1 38.8 45.2
64K 31.5 16.0 34.7 32.6
128K 29.0 8.8 28.7 25.7
  • 表示模型可能无法遵循提示或格式。

后训练模型评估

通过采用 MOPD 和智能体 RL 的后训练范式,模型在推理和智能体性能上达到了最先进水平。

基准测试 MiMo-V2 Flash Kimi-K2 Thinking DeepSeek-V3.2 Thinking Gemini-3.0 Pro Claude Sonnet 4.5 GPT-5 High
推理
MMLU-Pro 84.9 84.6 85.0 90.1 88.2 87.5
GPQA-Diamond 83.7 84.5 82.4 91.9 83.4 85.7
HLE (无工具) 22.1 23.9 25.1 37.5 13.7 26.3
AIME 2025 94.1 94.5 93.1 95.0 87.0 94.6
HMMT Feb. 2025 84.4 89.4 92.5 97.5 79.2 88.3
LiveCodeBench-v6 80.6 83.1 83.3 90.7 64.0 84.5
通用写作
Arena-Hard (困难提示) 54.1 71.9 53.4 72.6 63.3 71.9
Arena-Hard (创意写作) 86.2 80.1 88.8 93.6 76.7 92.2
长上下文
LongBench V2 60.6 45.1 58.4 65.6 61.8 -
MRCR 45.7 44.2 55.5 89.7 55.4 -
代码智能体
SWE-Bench Verified 73.4 71.3 73.1 76.2 77.2 74.9
SWE-Bench Multilingual 71.7 61.1 70.2 - 68.0 55.3
Terminal-Bench Hard 30.5 30.6 35.4 39.0 33.3 30.5
Terminal-Bench 2.0 38.5 35.7 46.4 54.2 42.8 35.2
通用智能体
BrowseComp 45.4 - 51.4 - 24.1 54.9
BrowseComp (带上下文管理) 58.3 60.2 67.6 59.2 - -
τ²-Bench 80.3 74.3 80.3 85.4 84.7 80.2

4. 模型架构

image.png

混合滑动窗口注意力

MiMo-V2-Flash 通过交织局部滑动窗口注意力(SWA)和全局注意力(GA)来解决长上下文的平方复杂度问题。

  • 配置:M=8 个混合块的堆叠。每个块包含 N=5 个 SWA 层,之后是 1 个 GA 层。
  • 效率:SWA 层使用 128 个 token 的窗口大小,显著减少了 KV缓存
  • 沉降偏置:应用可学习的注意力沉降偏置,即使在激进的窗口大小下也能保持性能。

轻量级多 Token 预测(MTP)

与传统的推测解码不同,我们的 MTP 模块原生集成用于训练和推理。

  • 结构:使用密集 FFN(而非 MoE)和 SWA(而非 GA)来保持较低的参数量(每块 0.33B)。
  • 性能:促进自推测解码,将生成速度提升 3 倍,并缓解小批量 RL 训练期间的 GPU 空闲问题。

5. 后训练技术亮点

MiMo-V2-Flash 利用专门设计的后训练流程,通过创新的蒸馏和强化学习策略最大化推理和智能体能力。

5.1 多教师在线策略蒸馏(MOPD)

我们引入了 多教师在线策略蒸馏(MOPD),这是一种将知识蒸馏重新定义为强化学习过程的新范式。

  • 密集 Token 级指导:与依赖稀疏序列级反馈的方法不同,MOPD 利用领域特定的专家模型(教师)在每个 token 位置提供监督。
  • 在线策略优化:学生模型从自己生成的响应中学习,而不是从固定数据集学习。这消除了暴露偏差,并确保更小、更稳定的梯度更新。
  • 固有的奖励鲁棒性:奖励源于学生和教师之间的分布差异,使该过程天然抵抗奖励黑客攻击。

5.2 扩展智能体强化学习

我们大幅扩展了智能体训练环境,以提高智能和泛化能力。

  • 大规模代码智能体环境:我们利用真实世界的 GitHub 问题创建了超过 100,000 个可验证任务。我们的自动化流程维护着一个能够运行超过 10,000 个并发 pod 的 Kubernetes 集群,环境设置成功率达 70%。
  • Web 开发的多模态验证器:对于 Web 开发任务,我们采用基于视觉的验证器,通过录制的视频而非静态截图来评估代码执行。这减少了视觉幻觉并确保功能正确性。
  • 跨域泛化:我们的实验表明,在代码智能体上的大规模 RL 训练能有效泛化到其他领域,提升数学和通用智能体任务的性能。

5.3 先进的强化学习基础设施

为了支持大规模 MoE 模型的高吞吐量 RL 训练,我们在 SGLangMegatron-LM 基础上实现了多项基础设施优化。

  • Rollout 路由重放(R3):解决 MoE 路由在推理和训练之间的数值精度不一致问题。R3 在训练阶段重用 rollout 中的确切路由专家,以可忽略的开销确保一致性。
  • 请求级前缀缓存:在多轮智能体训练中,此缓存存储先前轮次的 KV 状态和路由专家。它避免了重新计算,并确保跨轮次的采样一致性。
  • 细粒度数据调度器:我们扩展了 rollout 引擎以调度细粒度序列而非微批次。结合部分 rollout,这显著减少了长尾任务导致的 GPU 空闲。
  • 工具箱和工具管理器:使用 Ray actor 池的两层设计来处理资源争用。它消除了工具执行的冷启动延迟,并将任务逻辑与系统策略隔离。

6. 推理与部署

MiMo-V2-Flash 支持 FP8 混合精度推理。我们推荐使用 SGLang 以获得最佳性能。

使用建议:我们推荐将采样参数设置为 temperature=0.8, top_p=0.95

使用 SGLang 快速开始

pip install sglang

# 启动服务器
python3 -m sglang.launch_server \
        --model-path XiaomiMiMo/MiMo-V2-Flash \
        --served-model-name mimo-v2-flash \
        --pp-size 1 \
        --dp-size 2 \
        --enable-dp-attention \
        --tp-size 8 \
        --moe-a2a-backend deepep \
        --page-size 1 \
        --host 0.0.0.0 \
        --port 9001 \
        --trust-remote-code \
        --mem-fraction-static 0.75 \
        --max-running-requests 128 \
        --chunked-prefill-size 16384 \
        --reasoning-parser qwen3 \
        --tool-call-parser mimo \
        --context-length 262144 \
        --attention-backend fa3 \
        --speculative-algorithm EAGLE \
        --speculative-num-steps 3 \
        --speculative-eagle-topk 1 \
        --speculative-num-draft-tokens 4 \
        --enable-mtp

# 发送请求
curl -i http://localhost:9001/v1/chat/completions \
    -H 'Content-Type:application/json' \
    -d  '{
            "messages" : [{
                "role": "user",
                "content": "Nice to meet you MiMo"
            }],
            "model": "mimo-v2-flash",
            "max_tokens": 4096,
            "temperature": 0.8,
            "top_p": 0.95,
            "stream": true,
            "chat_template_kwargs": {
                "enable_thinking": true
            }
        }'

注意事项

重要提示:在带有多轮工具调用的思考模式中,模型会在 tool_calls 旁边返回一个 reasoning_content 字段。要继续对话,用户必须在每个后续请求的 messages 数组中保留所有历史 reasoning_content

重要提示:强烈推荐使用以下系统提示,请从英文和中文版本中选择。

英文版本

You are MiMo, an AI assistant developed by Xiaomi.

Today's date: {date} {week}. Your knowledge cutoff date is December 2024.

中文版本

你是MiMo(中文名称也是MiMo),是小米公司研发的AI智能助手。

今天的日期:{date} {week},你的知识截止日期是2024年12月。

7. 引用

如果您觉得我们的工作有帮助,请引用我们的技术报告:

@misc{mimo2025flash,
  title={MiMo-V2-Flash Technical Report},
  author={LLM-Core Xiaomi},
  year={2025},
  url={https://github.com/XiaomiMiMo/MiMo-V2-Flash/paper.pdf}
}

8. 相关链接

9. 结论

MiMo-V2-Flash 不仅在基准测试中展现出卓越的性能,更在实际应用场景中展现出独特的优势。特别是在深度思考能力方面,通过对比测试可以明显看出,在基本相同的输出结果质量下,小米 MiMo-V2-Flash 的深度思考功能相比 DeepSeek 具有显著优势。

这一优势体现在多个方面:

  • 思考深度MiMo-V2-Flash 能够进行更深入、更系统的思考,展现出更强的逻辑推理能力
  • 思考效率:在保证输出质量的前提下,能够更快速地完成深度思考过程
  • 思考质量:思考过程更加结构化、条理清晰,能够更好地展现推理路径

这种深度思考能力的优势,使得 MiMo-V2-Flash 在复杂推理任务、学术研究、代码分析等需要深度思考的场景中,能够为用户提供更高质量、更可靠的智能服务。

深度思考对比测试 1

深度思考对比测试 2

❌
❌