普通视图

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

🚀你以为你在写 React?其实你在“搭一套前端操作系统”

2025年12月18日 00:37

——从 Vite + React 架构出发,对照 Vue,彻底看懂现代前端工程化

👉 “现代前端不是写页面,而是在设计一套「运行在浏览器里的应用架构」。”


一、先说结论:React / Vue 早就不只是“框架”了

很多人学 React / Vue 的路径是这样的:

JSX / template → 组件 → 状态 → 路由 → API 请求
✋ 到此为止

但面试官想听的不是这个。

他们更关心的是:

  • 你知不知道项目是怎么被“跑起来”的
  • dev / test / production 在架构层面有什么区别
  • React 和 Vue 在“设计哲学”上的根本差异

而你给的这套 Vite + React 架构材料,刚好是理解这一切的钥匙


二、npm init vite:不是创建项目,是“选择一套世界观”

npm init vite

表面上看:
👉 创建一个 React / Vue 项目

实际上是:

你在选择一套“前端运行时 + 构建体系 + 模块规范”的组合

为什么是 Vite?

一句话总结:

Vite = 为 ESM 而生的开发期引擎


🔥 Vite 做了什么?

传统工具(Webpack) Vite
打包后再启动 dev server 先启动 server
一切都是 bundle 开发期不打包
冷启动慢 ESM 原生加载,冷启动极快

📌 关键点

Vite 在 dev 阶段几乎不做打包,而是把模块加载权交给浏览器。


三、dev → test → production:这是“阶段”,不是环境变量

dev → test → production

很多人理解成:

换几个 .env 文件

❌ 错

正确理解是:

阶段 核心目标
dev 开发效率最大化
test 稳定性、可测试
production 性能、体积、安全

为什么 devDependencies 和 dependencies 要分开?

npm i -D vite stylus
npm i react react-dom

这是工程级别的架构意识

  • devDependencies
    👉 只在开发和构建阶段存在
  • dependencies
    👉 最终会进到用户浏览器

📌 一句面试金句

“依赖划分的本质,是在控制「哪些代码会进入最终产物」。”


四、React 项目结构,本质是“三权分立”

你现在的 React 架构,其实非常清晰:

src/
 ├─ main.jsx        // 应用入口
 ├─ App.jsx         // 应用壳
 ├─ router/         // 路由系统
 ├─ pages/          // 页面级组件
 ├─ components/     // 通用组件
 └─ styles/         // 全局样式

这背后是一个非常经典的思想:

入口、调度、业务 —— 各司其职


1️⃣ main.jsx:应用启动器(Bootloader)

createRoot(document.getElementById('root'))
  .render(<App />)

📌 它只干一件事:

把 React 应用“挂”到真实 DOM 上

对比 Vue:

createApp(App).mount('#app')

👉 本质完全一致
都是“把虚拟世界接管真实 DOM”


2️⃣ App.jsx:应用壳(Shell)

<Router>
  <nav>...</nav>
  <AppRoutes />
</Router>

📌 App 不是页面
👉 App 是“应用框架”

它负责:

  • 路由接管
  • 布局结构
  • 全局样式
  • 上下文(Context)

3️⃣ pages:页面级组件 ≠ 普通组件

const Home = () => {
  const [repos, setRepos] = useState([])
}

📌 页面组件的特点

  • 和路由一一对应
  • 负责数据请求
  • 负责业务编排

👉 它们是“业务容器”,不是 UI 零件


五、React Router:不是路由库,是“前端导航系统”

<Routes>
  <Route path="/" element={<Home />} />
  <Route path="/about" element={<About />} />
</Routes>

BrowserRouter vs HashRouter

你写得非常关键:

BrowserRouter // HTML5 history
HashRouter    // # 路由

📌 面试这样说直接加分:

“BrowserRouter 更接近后端路由,但依赖服务器支持;HashRouter 是前端自包含方案。”


六、useState + useEffect:不是 API,是“状态驱动模型”

const [repos, setRepos] = useState([])

很多人背:

useState 是响应式
useEffect 是副作用

架构层的理解是


React 的核心模型

UI = f(state)

  • state 改变
  • UI 自动重新计算

useEffect 的存在是因为:

有些逻辑不属于“渲染”本身

比如:

  • 请求数据
  • 订阅事件
  • 操作 DOM

对比 Vue

Vue React
ref / reactive useState
watch / onMounted useEffect
模板自动追踪依赖 手动声明依赖

📌 哲学差异

Vue:我帮你想
React:你来决定


七、Vue = react + react-dom?这句话为什么对

vue = react(core) + react-dom(component render dom)

这句话的本质是:

  • React:只关心状态 → UI 的映射
  • react-dom:把虚拟 UI 映射到浏览器
  • Vue:两者合一,外加编译期优化

📌 所以才有:

  • React Native
  • React Three Fiber
  • Vue 的模板编译优化

🔚 写在最后

当你真正理解架构时,你就不再纠结“用 React 还是 Vue”。

因为你知道:
👉 框架只是语法
👉 架构才是能力

面试官问"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中实现动态切割模型?

❌
❌