普通视图
Swift Actor 为什么选择可重入设计?——一道让人深思的并发题
Swift Actor 为什么选择可重入设计?——一道让人深思的并发题
iOS 进阶必修 · Swift 并发编程系列 第 2 期
面试官问你:"Swift 的 actor 是可重入的,你觉得这个设计合理吗?"
很多人第一反应是:可重入?那不是有 bug 风险吗?为什么不做成传统锁那样不可重入?
这篇文章就来彻底说清楚这件事。
先把概念说明白
可重入(Reentrant):actor 在 await 挂起时会释放自身的"访问权",其他任务可以趁机进入 actor 执行别的方法。
不可重入(Non-reentrant):actor 一旦被某任务占用,其他任务必须排队等待,直到当前任务彻底执行完(包括所有 await)。
用一句话概括差异:
可重入:
await是"暂时离开",锁被放开
不可重入:await是"原地等待",锁被一直握着
如果 Actor 是不可重入的,会发生什么?
死锁:跨 actor 调用的必然结局
actor ServiceA {
let b: ServiceB
func doWork() async {
await b.help() // A 持锁,等待 B
}
}
actor ServiceB {
let a: ServiceA
func help() async {
await a.check() // B 持锁,等待 A ← 死锁!
}
}
两个 actor 互相持锁等待对方,经典死锁。
在真实业务里这种结构比比皆是——网络层调用缓存层,缓存层调用配置层,配置层又依赖某个共享状态…只要存在环形调用,就必然死锁。
而且这种死锁极难排查:没有崩溃日志,没有报错,App 就静静地卡在那里。
Actor 内部 async 调用,自己等自己
actor Logger {
func log(_ msg: String) async {
await writeToFile(msg) // 不可重入 → 自己等自己 → 死锁
}
func writeToFile(_ msg: String) async {
// 磁盘写入…
}
}
这意味着什么?actor 内部完全不能出现 await。但现实中 actor 管理的资源(网络、磁盘、数据库)几乎无一例外需要异步操作。
不可重入 + async/await 生态,在逻辑上根本无法自洽。
那可重入会带来哪些坑?
可重入把死锁风险消除了,但代价是:await 挂起期间,actor 的状态可能被其他任务修改。
坑 1:状态假设在 await 前后失效
这是最经典的重入陷阱,银行转账场景:
actor BankAccount {
var balance: Double = 1000
func withdraw(_ amount: Double) async throws {
// ① 检查余额:1000 >= 800,通过
guard balance >= amount else { throw InsufficientFundsError() }
// ② await 挂起,actor 释放访问权
// 另一个 withdraw(800) 趁机进来,也通过了 guard
// 它先执行,balance 变成 200
await logTransaction(amount)
// ③ 回来继续执行:800 > 200,但已经没有再次检查!
balance -= amount // balance = 200 - 800 = -600,超支!
}
}
// 并发:两个任务同时取 800
Task { try await account.withdraw(800) }
Task { try await account.withdraw(800) }
// 最终 balance = -600,资损!
问题的根源:guard 检查到 balance -= amount 之间夹着一个 await,整个操作不是原子的。
坑 2:不变量(Invariant)在 await 期间被破坏
actor DataPipeline {
var isProcessing = false
var buffer: [Data] = []
func process() async {
guard !isProcessing else { return }
isProcessing = true // 设置标志
// await 挂起,另一个 process() 调用进来
// 它看到 isProcessing = true,直接 return
// 看起来没问题…但如果两个调用"同时"通过 guard 呢?
// → 取决于调度时序,存在 TOCTOU(检查-使用时差)窗口
await doHeavyWork()
isProcessing = false
}
}
正确应对可重入的三个模式
模式一:await 之前完成所有关键状态变更
actor BankAccount {
var balance: Double = 1000
// ✅ 正确写法
func withdraw(_ amount: Double) async throws {
guard balance >= amount else { throw InsufficientFundsError() }
balance -= amount // ← 先改状态(无 await,绝对原子)
await logTransaction(amount) // 再异步处理(状态已一致)
}
}
规则:guard 检查通过后,立刻完成状态变更,然后才 await。await 之后不再依赖之前检查过的条件。
模式二:原子卫兵——同步方法作为临界区
actor SafeQueue {
private var items: [WorkItem] = []
private var isRunning = false
// 同步方法:无 await,绝对原子
private func takeNext() -> WorkItem? {
guard let item = items.first else { return nil }
items.removeFirst() // 取出即删除,不会被重入影响
return item
}
func drainAll() async {
guard !isRunning else { return }
isRunning = true
while let item = takeNext() {
await item.execute() // await 时 item 已从队列移除,安全
}
isRunning = false
}
}
思路:把"检查 + 修改"合并进一个不含 await 的同步方法,让它成为原子操作。
模式三:状态机保护并发入口
actor TaskScheduler {
private enum Phase { case idle, running, draining }
private var phase: Phase = .idle
func schedule(_ task: Task<Void, Never>) async {
guard phase == .idle else { return }
phase = .running // ← await 之前切状态,拿到"令牌"
await task.value // 其他调用看到 .running,直接 return
phase = .idle
}
}
用状态机枚举而非 Bool 标志,让每种状态的含义更清晰,也更难被误用。
设计对比:可重入 vs 不可重入
| 维度 | 不可重入(传统锁语义) | 可重入(Swift actor) |
|---|---|---|
| 跨 actor 调用 | ❌ 极易死锁 | ✅ 安全 |
| actor 内部 await | ❌ 自己等自己,死锁 | ✅ 正常工作 |
| 状态一致性 | await 前后一致 | ⚠️ 开发者自行保证 |
| 死锁风险 | ❌ 高,且难排查 | ✅ 无 |
| 正确性复杂度 | 低(锁语义直觉) | 中(需理解挂起语义) |
| 与 async/await 生态兼容性 | ❌ 根本无法自洽 | ✅ 天然融合 |
Apple 为什么必须选可重入
这是一道"两害取其轻"的工程决策题:
- 死锁:不可预测,运行时无日志,难以复现,线上问题几乎无法定位
- 重入陷阱:有规律可循(await 前完成状态变更),编码期可发现,有明确的防御模式
Apple 把不确定性更高、危害更大的风险消除了,把相对可控的复杂性留给开发者。
从语言设计角度看,这也与 Swift 的一贯哲学吻合:编译器负责能静态验证的安全,开发者负责剩下的语义正确性。
Swift 6 的严格并发检查(
-strict-concurrency=complete)正在把越来越多的重入问题提升为编译器警告,方向是对的。
实际项目中的选择建议
优先用可重入,配合以下纪律:
-
黄金法则:
await之前必须完成所有关键状态变更,await之后不再信任之前读取的条件 -
原子临界区:把"检查 + 修改"封装进无
await的同步方法 - 状态机优先:用枚举状态机而非 Bool 标志管理并发入口
-
最小化 await 范围:需要保护的临界操作不要夹带
await
// 完整示例:安全的资源管理 actor
actor ResourceManager {
private enum State { case idle, acquired, releasing }
private var state: State = .idle
private var resource: Resource?
// ✅ 获取资源:先拿到"凭证"再 await
func acquire() async throws -> Resource {
guard state == .idle else { throw ResourceError.busy }
state = .acquired // 改状态在 await 之前
let res = try await fetchResource()
resource = res
return res
}
// ✅ 释放资源:先清理状态再 await
func release() async {
guard state == .acquired else { return }
let res = resource
resource = nil // 先清空
state = .releasing
await cleanupResource(res)
state = .idle
}
}
总结
| 问题 | 答案 |
|---|---|
| 可重入设计合理吗? | 合理,是工程必要性决定的,不是妥协 |
| 不可重入的最大问题? | 跨 actor 死锁 + 内部 async 调用死锁,且难排查 |
| 可重入最大的坑? | await 前后状态假设失效,经典场景是 guard 通过后 await,回来状态已变 |
| 实际项目怎么用? | 拥抱可重入,用"await 前完成状态变更"作为硬性编码纪律 |
可重入的坑有规律可循,死锁没有。选可重入,然后学会驾驭它。
延伸思考
Kotlin 协程的 Mutex 提供了不可重入的互斥锁,但它是手动使用的工具,而不是语言默认行为——与 Swift actor 的定位完全不同。Java 的 synchronized 则是可重入的(同一线程可以重复进入),与 Swift actor 的可重入语义有些类似,但实现机制不同。
Swift actor 的可重入设计,本质上是结构化并发思想的延伸:任务在 await 时让出资源,让其他任务有机会推进,整个系统的吞吐量更高,而不是让一个任务独占 actor 直到它的所有 await 全部完成。
如果你在项目里遇到过 actor 重入导致的 bug,欢迎评论区分享——是什么场景、如何发现、怎么修复的?优质案例会收录进下一期。
📅 本系列持续更新 ✅ 第 1 期:Swift Concurrency 基础精讲 · ➡️ 第 2 期:Actor 可重入设计深析(本期)· ○ 第 3 期:Swift 6 严格并发检查实战 · ○ 第 4 期:待定
经济学家:美国3月CPI月率或大涨1% 美联储今年或难降息
兆瓦级氢燃料航空涡桨发动机首飞成功
3月中国大宗商品价格指数环比上涨4%
高端智能投影品牌AWOL Vision获近亿元B轮融资,新品拿下近2000万美金众筹|硬氪首发
作者|黄楠
编辑|袁斯来
硬氪获悉,高端智能投影品牌AWOL Vision近日完成近亿元B轮融资,投资方包括天堂硅谷、会畅科技及金鹏佳等机构。资金将主要用于核心技术研发,持续迭代超短焦与长焦激光显示能力,并推进“家庭AI智能娱乐平台”生态构建;同时加快全球渠道与品牌拓展,强化供应链协同,并引入顶尖人才以夯实全球化运营基础。
AWOL Vision成立于2020年,长期专注RGB激光投影与家庭视听体验创新,并以深圳海高特科技有限公司作为其研发与设计中枢,为全球视听领域用户提供更具沉浸感的解决方案,重塑家庭娱乐体验。
3月31日,AWOL Vision新款超短焦4K三色激光投影产品Aetherion在Kickstarter上结束众筹,募得近2000万美元;加上2024年其子品牌Valerion的千万美元级项目,AWOL累计众筹金额已超2亿元,是首个立足中国、完成“双千万美元级验证”的消费电子品牌。
![]()
超短焦4K三色激光投影产品Aetherion(图源/企业)
从CRT到液晶,从单色激光到三色激光,家庭显示行业经历了持续的技术跃迁。市场竞争日趋成熟,但大多数品牌仍沿着一条从电视时代延续至今的硬件参数竞赛路径,不断追求更大尺寸、更高分辨率和更低价格。
在欧美等成熟市场,家庭影院正走向刚需。但一套传统高端系统的部署成本高达5万至6万美元,且一旦安装便几乎固定,迁移成本极高。同时,传统投影品牌智能化迭代滞后,缺乏无线投屏、自动对焦等基础体验。当硬件参数逼近极限,真正的差异化开始向交互体验迁移。
这正是AWOL Vision所切入的断层。海高特将原本用于专业影院的三色激光技术下放至消费级产品,并在两大品牌中AWOL Vision与Valerion形成了清晰的场景分工。AWOL主打超短焦技术,适用于明亮环境的客厅场景,支持开灯观看;Valerion则专注于长焦投影,服务于影音室、后院户外等暗光环境,强调影院级画质与氛围感。
![]()
适用于明亮环境的客厅场景(图源/企业)
公司在产品定义阶段便将“开箱即用”作为核心设计原则,其产品外形采用金属骨架与轻量化设计,便于用户在客厅、卧室、庭院等不同场景间移动;所有接口隐藏在盖板或凹槽内,避免线材外露。
使用过程中,设备支持自动对焦、自动梯形校正、自动画面避障和幕布对齐,即使用户挪动设备,画面也能实时保持最佳状态。
软件层面,预装Netflix、Disney+、HBO Max等主流流媒体平台,支持语音控制和一键投屏——这些在国内智能投影市场已成标配的功能,在海外高端投影赛道中仍是稀缺能力。
可以看到,凭借独特的“美国品牌 +中国设计中心”模式,海高特依托中国成熟的智能硬件研发与供应链优势,瞄准欧美传统投影市场,提供更完整的使用体验与场景化解决方案,显著降低了高端家庭影院的实际使用门槛。
而随着设备进入越来越多家庭,一个更核心的问题浮现:如何让投影从“被动显示工具”进化为“主动理解用户的娱乐伙伴”?
家庭娱乐的竞争正在从“屏的竞争”转向“入口的竞争”。电视、投影、AR/VR设备都在争夺用户家庭中的核心交互位置。然而,当前市场上大部分投影设备仍停留在被动显示阶段,即用户选择内容、设备播放,交互止步于遥控器或语音指令。
![]()
构建家庭AI智能娱乐平台(图源/企业)
家庭娱乐的需求正在深化:用户不再满足于单次观影,而是希望设备能够理解使用习惯、预测内容偏好、自动适配不同场景。周末晚上自动切换为电影模式并调暗环境光,游戏模式下自动优化延迟与音效联动等,这些能力的背后,需要一个持续学习用户行为的AI引擎。
这正是AWOL Vision的差异化所在。它并非从零搭建一个操作系统,而是基于用户最高频的观影和游戏场景,逐步叠加环境感知、多设备协同、个性化推荐等AI能力。这种“硬件+场景+AI”的渐进式路径,更易被用户感知和接受。
到这时,智能投影设备将不再只是一块负责播放的终端,更成为连接内容、环境、设备与用户习惯的AI智能娱乐入口,在下一代智能生态竞争中成为建立长期壁垒的关键。
投资方观点:
天堂硅谷表示,AWOL Vision在全球最挑剔的消费市场持续验证了产品力,两次众筹破纪录是真实市场需求最有力的证明。我们看好其从高端硬件向平台生态延伸的战略路径,这是一家有潜力在全球家庭娱乐赛道诞生世界级品牌的企业。
会畅科技表示,作为产业投资方,我们与AWOL Vision在视听技术与内容生态层面有高度互补的战略协同空间。作为合作伙伴,我们期待与AWOL共同定义下一代家庭沉浸式娱乐的标准。
使用 AI SDK 创建 「知识库」
![]()
今天分享一个用纯 Node.js 实现知识库(RAG)的最简方案。
RAG 的核心思路其实并不复杂:
- 对文档进行内容分割,将文档拆解成一个个小的语义分块(chunk);
- 将这些分块通过大模型解析成向量(embedding)并储存在向量数据库中;
- 当用户输入一个查询时,同样将查询进行向量化处理,通过向量数据库检索高度相关的知识片段;
- 最后将这些片段整合到发送给大模型的上下文中,提升回答的精准度和相关性。
开始
技术栈:
安装依赖
npm install @libsql/client @ai-sdk/openai-compatible ai dotenv
添加环境变量
先在项目根目录创建 .env:
AIPROXY_API_KEY=your_api_key_here
初始化
先把最基础的依赖准备好,包括模型客户端、本地数据库客户端,以及一组演示知识。
import 'dotenv/config'
import { createClient } from '@libsql/client/sqlite3'
import { createOpenAICompatible } from '@ai-sdk/openai-compatible'
import { embed, generateText } from 'ai'
const aiproxy = createOpenAICompatible({
baseURL: 'https://api.aiproxy.shop/v1',
apiKey: process.env.AIPROXY_API_KEY!,
name: 'aiproxy',
})
const db = createClient({
url: 'file:local.db',
})
const knowledgeDocuments = [
{
title: 'AI SDK 是什么',
content:
'AI SDK 是一个帮助 TS 和 JS 开发者快速接入大模型的工具包,支持流式响应、工具调用和多模型适配。',
},
{
title: 'RAG 的核心流程',
content:
'RAG 的核心流程是切分文档、生成向量、保存向量、查询时把问题也转成向量、最后检索最相近的内容作为上下文。',
},
{
title: '为什么要做分块',
content:
'因为整篇文档太长会影响检索精度,所以通常要先按语义切成多个 chunk,再分别生成 embedding。',
},
]
type StoredChunk = {
id: number
title: string
content: string
distance: number
}
文档分块
RAG 不会直接拿整篇文档做检索,而是将文档拆分成很多小的文本块。
const splitIntoChunks = (text: string, size = 200) => {
const normalized = text.replace(/\s+/g, ' ').trim()
if (!normalized) {
return []
}
const chunks: string[] = []
for (let index = 0; index < normalized.length; index += size) {
chunks.push(normalized.slice(index, index + size))
}
return chunks
}
如何生成 chunk 的内容?
最简单的办法就是直接按字符切分。比如上面 size = 200,就表示每 200 个字符切成一个块。或者可以通过标点符号进行切分,也可以用一些第三方库来进行智能分割。
生成 Embedding
这里会在两个阶段使用 embedding:
- 入库时把每个 chunk 转成向量并存储。
- 查询时把用户问题也转成向量,用于检索最相关的上下文。
const createEmbedding = async (value: string) => {
const result = await embed({
model: aiproxy.embeddingModel('openai/text-embedding-3-small'),
value,
})
return result.embedding
}
初始化知识库表
现在要把知识片段真正保存到本地数据库中。
const initializeDatabase = async () => {
await db.execute(`
CREATE TABLE IF NOT EXISTS documents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT NOT NULL,
embedding BLOB NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`)
}
这里的设计也比较直接:
-
documents表存标题、文本内容和 embedding。 - embedding 用
BLOB存储,方便直接使用 libSQL 的向量函数检索。
这不是最终架构里性能最高的方案,但它非常利于你先理解“存储 + 检索”的闭环。
知识库入库
有了表结构之后,我们还需要把知识内容写入数据库。
const seedKnowledgeBase = async () => {
await initializeDatabase()
const countResult = await db.execute('SELECT COUNT(*) AS count FROM documents')
const count = Number(countResult.rows[0]?.count ?? 0)
if (count > 0) {
return
}
for (const document of knowledgeDocuments) {
const chunks = splitIntoChunks(document.content)
for (const chunk of chunks) {
const embedding = await createEmbedding(chunk)
const embeddingBuffer = Buffer.from(new Float32Array(embedding).buffer)
await db.execute({
sql: 'INSERT INTO documents (title, content, embedding) VALUES (?, ?, ?)',
args: [document.title, chunk, embeddingBuffer],
})
}
}
}
入库流程如下:
- 如果表里已经有数据,就不重复写入;
- 把每篇文档切成多个 chunk;
- 给每个 chunk 生成 embedding;
- 把 chunk 和向量一起存进数据库。
检索相关片段
当用户提问时,我们先把问题转成向量,然后直接在数据库里做 Top-K 检索。
const searchKnowledge = async (query: string, limit = 3) => {
const userQueryEmbedded = await createEmbedding(query)
const queryBuffer = Buffer.from(new Float32Array(userQueryEmbedded).buffer)
const rs = await db.execute({
sql: `
SELECT
id,
title,
content,
vector_distance_cos(embedding, vector32(?)) AS distance
FROM documents
ORDER BY distance ASC
LIMIT ?
`,
args: [queryBuffer, limit],
})
return rs.rows.map((row) => ({
id: Number(row.id),
title: String(row.title),
content: String(row.content),
distance: Number(row.distance),
}))
}
查询流程如下:
- 先把用户问题转成 embedding。
- 用
vector32(?)把查询向量传给数据库。 - 数据库内部用
vector_distance_cos计算距离并排序,拿到 Top-K 结果。
封装主函数
最后,把上面所有逻辑串起来。
export const chatWithKnowledge = async (question: string) => {
await seedKnowledgeBase()
const results = await searchKnowledge(question)
const contextText = results.map((item) => `- ${item.title}: ${item.content}`).join('\n')
const result = await generateText({
model: aiproxy('deepseek/deepseek-chat'),
system: `你是一个有用的助手。请优先根据知识库内容回答问题;如果知识库里没有相关信息,就明确告诉用户你不知道。\n\n知识库上下文:\n${contextText}`,
prompt: question,
})
return {
answer: result.text,
references: results,
}
}
整个请求链路到这里就闭环了:
- 函数执行时,先确保知识库已经初始化;
- 把用户问题转成向量;
- 在数据库层执行向量检索,拿到最相关片段;
- 把这些片段加入
system prompt,作为上下文或者参考信息; - 调用主模型生成最终回答;
- 返回最终答案和命中的参考片段。
把上面所有代码拼到一起,就是一个完整的单文件 RAG Demo。
在文件末尾加上入口调用,直接运行即可:
const result = await chatWithKnowledge('RAG 为什么要分块?')
console.log('回答:', result.answer)
console.log('参考片段:', result.references)
知识库文档处理
在上面的示例里,我直接在代码里写了一个 knowledgeDocuments 数组来模拟知识库内容:
const knowledgeDocuments = [
{
title: '你的第一篇文档',
content: '这里放你自己的知识内容',
},
]
如果你的内容来自 Markdown、数据库或者 CMS,也可以先把内容读出来,再复用同样的 splitIntoChunks -> createEmbedding -> insert 流程。如果你的文档是 PDF 或图片,也可以先用 OCR 或者 pdf.js 把它们转成文本,再进行后续处理。
运行
npx tsx rag.ts
结语
复杂的应用是一个个小的应用组合起来的,学会每个小的知识点,就能够构建非常牛x的大型项目。
大家可以看一下我实现的 知识库的 NPM CLI 工具 - Meow ,欢迎 star 🌟。
Agent 工程化 的核心
当前 Agent 工程化 的核心。我通过一个完整的代码示例,把它们串起来讲清楚。
一、整体架构图(先有个印象)
text
用户输入
│
▼
┌─────────────────────────────────────────────┐
│ Agent │
│ ┌─────────────────────────────────────────┐ │
│ │ 历史消息 (Messages) │ │
│ │ [{role:user, content}, {role:assistant}]│ │
│ └─────────────────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────────────────────┐ │
│ │ 工作流 (Workflow) │ │
│ │ Plan → Execute → Observe → Loop │ │
│ └─────────────────────────────────────────┘ │
│ │ │
│ ┌──────────────┬──────────────┐ │
│ │ 工具调用 │ 子Agent │ Skills │
│ │ (Tools) │ (Sub-Agent) │ (能力集) │
│ └──────────────┴──────────────┘ │
└─────────────────────────────────────────────┘
二、完整代码示例(可直接运行)
用 TypeScript + Bun 实现一个能做数学计算和天气查询的简单 Agent:
typescript
// agent.ts - 一个完整的 Agent 实现
// ==================== 1. 历史消息管理 ====================
interface Message {
role: 'user' | 'assistant' | 'tool';
content: string;
toolCallId?: string;
timestamp: number;
}
class MessageHistory {
private messages: Message[] = [];
private maxTokens: number = 4000;
add(message: Message) {
this.messages.push(message);
this.trimIfNeeded();
}
get() {
return this.messages;
}
getForLLM() {
// 返回 LLM 需要的格式,只保留最近的消息
return this.messages.slice(-20).map(m => ({
role: m.role,
content: m.content
}));
}
private trimIfNeeded() {
// 简化版:超过 50 条就删除一半
if (this.messages.length > 50) {
this.messages = this.messages.slice(-25);
}
}
}
// ==================== 2. 工具定义与调用 ====================
interface Tool {
name: string;
description: string;
parameters: Record<string, any>;
execute: (args: any) => Promise<string>;
}
// 工具1:计算器
const calculatorTool: Tool = {
name: 'calculator',
description: '执行数学计算,支持 + - * / 和 sqrt',
parameters: {
type: 'object',
properties: {
expression: { type: 'string', description: '数学表达式,如 "2+3*4"' }
},
required: ['expression']
},
execute: async (args) => {
try {
// 安全计算(生产环境请用 math.js 等库)
const result = eval(args.expression);
return `计算结果: ${result}`;
} catch (e) {
return `计算错误: ${e.message}`;
}
}
};
// 工具2:模拟天气查询
const weatherTool: Tool = {
name: 'get_weather',
description: '查询指定城市的天气',
parameters: {
type: 'object',
properties: {
city: { type: 'string', description: '城市名称' }
},
required: ['city']
},
execute: async (args) => {
// 模拟 API 调用
const weathers = {
'北京': '晴天 25°C',
'上海': '多云 22°C',
'深圳': '阵雨 28°C'
};
return weathers[args.city] || `${args.city} 天气: 晴 20°C`;
}
};
// ==================== 3. 子 Agent(专门处理特定任务)====================
class SubAgent {
name: string;
description: string;
private handler: (input: string) => Promise<string>;
constructor(name: string, description: string, handler: (input: string) => Promise<string>) {
this.name = name;
this.description = description;
this.handler = handler;
}
async run(input: string): Promise<string> {
console.log(` [子Agent:${this.name}] 处理: ${input}`);
return this.handler(input);
}
}
// 创建两个子 Agent
const mathSubAgent = new SubAgent(
'math-expert',
'专门处理复杂数学问题',
async (input) => {
// 模拟复杂计算
await Bun.sleep(500); // 假装在计算
return `【数学专家】计算结果: ${input.replace('计算', '').trim()} = 42`;
}
);
const weatherSubAgent = new SubAgent(
'weather-expert',
'专门处理天气相关问题',
async (input) => {
await Bun.sleep(300);
const city = input.match(/[北京上海深圳广州]+/)?.[0] || '未知';
return `【天气专家】${city},温度适中,建议出门带伞`;
}
);
// ==================== 4. Skill(可复用的能力模块)====================
interface Skill {
name: string;
description: string;
execute: (context: any) => Promise<any>;
}
const loggingSkill: Skill = {
name: 'logging',
description: '记录 Agent 的执行日志',
execute: async (context) => {
console.log(`[LOG] ${new Date().toISOString()} - ${context.action}`);
return { logged: true };
}
};
const memorySkill: Skill = {
name: 'memory',
description: '记住用户的重要偏好',
execute: async (context) => {
// 简化版:存到全局 Map
if (context.preference) {
userPreferences.set(context.userId, context.preference);
}
return { remembered: true };
}
};
const userPreferences = new Map<string, any>();
// ==================== 5. 主 Agent(核心工作流)====================
class SimpleAgent {
private tools: Map<string, Tool> = new Map();
private subAgents: Map<string, SubAgent> = new Map();
private skills: Skill[] = [];
private messageHistory: MessageHistory;
constructor() {
this.messageHistory = new MessageHistory();
this.registerDefaultTools();
}
// 注册工具
registerTool(tool: Tool) {
this.tools.set(tool.name, tool);
console.log(`📦 注册工具: ${tool.name}`);
}
// 注册子 Agent
registerSubAgent(agent: SubAgent) {
this.subAgents.set(agent.name, agent);
console.log(`🤖 注册子Agent: ${agent.name}`);
}
// 注册 Skill
registerSkill(skill: Skill) {
this.skills.push(skill);
console.log(`⚡ 注册Skill: ${skill.name}`);
}
private registerDefaultTools() {
this.registerTool(calculatorTool);
this.registerTool(weatherTool);
this.registerSubAgent(mathSubAgent);
this.registerSubAgent(weatherSubAgent);
this.registerSkill(loggingSkill);
this.registerSkill(memorySkill);
}
// ========== 核心工作流 ==========
async run(userInput: string): Promise<string> {
console.log('\n' + '='.repeat(50));
console.log(`📝 用户: ${userInput}`);
console.log('='.repeat(50));
// Step 1: 添加用户消息到历史
this.messageHistory.add({
role: 'user',
content: userInput,
timestamp: Date.now()
});
// Step 2: 意图识别(简化版,实际应该用 LLM)
const intent = this.analyzeIntent(userInput);
console.log(`🎯 识别意图: ${intent.type}`);
// Step 3: 执行 Skills(前置)
for (const skill of this.skills) {
await skill.execute({ action: intent.type, userId: 'default' });
}
// Step 4: 根据意图分发处理
let result: string;
if (intent.type === 'calculation' && intent.tool) {
// 直接调用工具
result = await this.callTool(intent.tool, intent.args);
}
else if (intent.type === 'weather') {
// 可以调用工具或子 Agent,这里演示委托给子 Agent
result = await this.delegateToSubAgent('weather-expert', userInput);
}
else if (intent.type === 'complex_math') {
result = await this.delegateToSubAgent('math-expert', userInput);
}
else {
// 普通对话
result = await this.generateResponse(userInput);
}
// Step 5: 保存助手回复到历史
this.messageHistory.add({
role: 'assistant',
content: result,
timestamp: Date.now()
});
console.log(`🤖 助手: ${result}`);
return result;
}
// 意图分析(简化版,实际应该调用 LLM)
private analyzeIntent(input: string): {
type: 'calculation' | 'weather' | 'complex_math' | 'chat';
tool?: string;
args?: any;
} {
// 计算器意图
if (input.includes('+') || input.includes('-') || input.includes('*') || input.includes('/') || input.includes('计算')) {
const match = input.match(/[\d\s+-*/()]+/);
if (match && match[0].trim()) {
return { type: 'calculation', tool: 'calculator', args: { expression: match[0] } };
}
}
// 天气意图
if (input.includes('天气')) {
return { type: 'weather' };
}
// 复杂数学
if (input.includes('方程') || input.includes('积分') || input.includes('导数')) {
return { type: 'complex_math' };
}
return { type: 'chat' };
}
// 调用工具
private async callTool(toolName: string, args: any): Promise<string> {
const tool = this.tools.get(toolName);
if (!tool) return `工具 ${toolName} 不存在`;
console.log(`🔧 调用工具: ${toolName}`, args);
return await tool.execute(args);
}
// 委托给子 Agent
private async delegateToSubAgent(agentName: string, input: string): Promise<string> {
const agent = this.subAgents.get(agentName);
if (!agent) return `子Agent ${agentName} 不存在`;
console.log(`🔄 委托给子Agent: ${agentName}`);
return await agent.run(input);
}
// 生成回复(简化版,实际应该调用 LLM)
private async generateResponse(input: string): Promise<string> {
if (input.includes('你好') || input.includes('嗨')) {
return '你好!我是智能助手,可以帮你计算、查天气等。试试说"计算 2+3"或"北京天气"';
}
return `收到: "${input}"。我是一个简单Agent,能处理计算和天气查询。`;
}
// 查看历史消息
showHistory() {
console.log('\n📜 历史消息:');
for (const msg of this.messageHistory.get()) {
console.log(` [${msg.role}] ${msg.content.slice(0, 50)}`);
}
}
}
// ==================== 6. 运行演示 ====================
async function main() {
console.log('🚀 启动 Simple Agent...\n');
const agent = new SimpleAgent();
console.log('\n' + '🌟 Agent 已就绪,开始对话...\n');
// 测试各种场景
await agent.run('你好,你是谁?');
await agent.run('计算 15 + 27');
await agent.run('北京天气怎么样?');
await agent.run('帮我解方程 x^2 = 4');
// 查看历史消息
agent.showHistory();
console.log('\n✅ 演示完成');
}
// 运行
main().catch(console.error);
三、用 Bun 运行
bash
# 安装 bun(如果还没装)
curl -fsSL https://bun.sh/install | bash
# 运行 Agent
bun run agent.ts
输出示例:
text
🚀 启动 Simple Agent...
📦 注册工具: calculator
📦 注册工具: get_weather
🤖 注册子Agent: math-expert
🤖 注册子Agent: weather-expert
⚡ 注册Skill: logging
⚡ 注册Skill: memory
==================================================
📝 用户: 计算 15 + 27
==================================================
🎯 识别意图: calculation
[LOG] 2026-04-04T10:30:00.000Z - calculation
🔧 调用工具: calculator { expression: "15+27" }
🤖 助手: 计算结果: 42
==================================================
📝 用户: 北京天气怎么样?
==================================================
🎯 识别意图: weather
[LOG] 2026-04-04T10:30:01.000Z - weather
🔄 委托给子Agent: weather-expert
[子Agent:weather-expert] 处理: 北京天气怎么样?
🤖 助手: 【天气专家】北京,温度适中,建议出门带伞
四、核心概念对照表
| 概念 | 在这个例子中的体现 | 作用 |
|---|---|---|
| 历史消息 |
MessageHistory 类 |
保留对话上下文,支持多轮交互 |
| 工作流 |
run() 方法中的 5 个步骤 |
意图识别→技能执行→工具/子Agent→返回结果 |
| 工具调用 |
calculatorTool、weatherTool
|
Agent 通过工具执行具体操作 |
| 子 Agent |
mathSubAgent、weatherSubAgent
|
专门化处理,可以嵌套调用 |
| Skill |
loggingSkill、memorySkill
|
可复用的横切能力,可在工作流中自动执行 |
| Bun 一体化 | 一个文件搞定 TypeScript 编译+运行 | 不需要 tsc + node,直接 bun run
|
五、关于 Bun 的亮点
你说得对,Bun 的运行时+构建一体化确实很棒:
bash
# 传统 Node + TypeScript 需要:
npm install -g typescript ts-node
tsc agent.ts && node agent.js
# Bun 只需要:
bun run agent.ts # 直接运行,自动编译
Bun 还内置了:
- 包管理器(比 npm/yarn/pnpm 快很多)
- 测试运行器
- 打包器(bun build)
- 原生支持 JSX、TS
运行这个例子后,你会直观感受到一个 Agent 是如何组织起来的。想深入了解哪一块?比如:
- 如何接入真实的 LLM(OpenAI/Claude API)?
- 如何处理更复杂的工作流(循环、重试、并行)?
- 工具调用的 function calling 具体怎么对接?
Vue3.5设计理念和响应式原理(下)
computed 实现原理
// 实例
const state = reactive({ name: "zoyi" });
const aliasName = computed(() => {
console.log("getter 执行");
return "**" + state.name;
});
effect(() => {
console.log("外层 effect 执行");
console.log(aliasName.value);
});
state.name = "star zoyi";
初始化
- 执行到 computed(getter) 时,返回ComputedRefImpl(getter)实例 aliasName:创建内部的 ReactiveEffect(getter, scheduler);实例(aliasName).value 是可 get/set 的响应式。
export class ComputedRefImpl {
constructor(getter: () => any) {
this.effect = new ReactiveEffect(getter, () => {
//...
});
}
}
- 执行 effect(fn),创建外层 effect 实例,将 fn 添加至 schedule 中并执行。
- 打印 外层 effect 执行。 执行 aliasName.value ===> 触发内部 effect 的 get value。
- 在 getter 若有 activeEffect(外部 effect.run() 时保存的 activeEffect),把外层 effect 记进 aliasName.dep。
get value() {
// 外层 effect 读取计算属性时,把外层 effect 记到本 ref 的 dep 上
this.trackComputed();
if (this._dirty === DirtyLevels.Dirty) {
this._dirty = DirtyLevels.NoDirty;
this._value = this.effect.run();
}
return this._value;
}
/** 收集「谁依赖了这个计算属性」 */
private trackComputed() {
if (!activeEffect) {
return;
}
this.dep ??= createDep(() => {
this.dep = undefined;
}, "computed");
trackEffect(activeEffect, this.dep);
}
- 第一次
_dirty默认是脏,改为不脏,并执行内部 effect.run()(即包含 computed 的 getter方法的运行器)。 - 更新 activeEffect 为内部 effect,执行 getter,打印 getter 执行,
return中执行state.name触发 name 属性的 get,将此时 activeEffect = 内层effect,收集为依赖。返回name = zoyi。 - getter 中
return计算后属性@zoyi,将值缓存到aliasName._value上,aliasName.value 的 get value 执行完毕,并返回_value。 - 打印 @zoyi,外层 effect.run() 执行完毕。
此时关系是:
state.name 的 dep → 内层 ReactiveEffect(计算属性的 scheduler)。 aliasName.dep → 外层 effect(读了 .value)。
更新阶段(Vue 3.4)
-
执行
state.name = "star zoyi"state.name 发生改变,触发 name 的 setter。set(target, key, value, recevier) { let oldValue = target[key]; let result = Reflect.set(target, key, value, recevier); // 只有新旧值不一样才会触发更新 if (oldValue !== value) { trigger(target, key, value, oldValue); } return result; } -
新旧值不一样,触发 trigger,执行收集到的内层 effect 的 scheduler。
- 但默认不会执行 run,只把
_dirty设置为脏。 - triggerEffects(aliasName.dep) → 外层 effect 的 scheduler 执行 → 外层 effect 再次 run()。
- 但默认不会执行 run,只把
constructor(getter: () => any) {
// 不在此构造函数里立即 run:首次访问 .value 时再求值,实现惰性。
// scheduler:依赖变更时不立刻重算,只标脏并通知「读过我的人」去更新。
this.effect = new ReactiveEffect(getter, () => {
if (this._dirty === DirtyLevels.NoDirty) {
this._dirty = DirtyLevels.Dirty;
}
if (this.dep) {
triggerEffects(this.dep); // aliasName.dep
}
});
}
- 打印 外层 effect 执行,执行到 aliasName.value,再次进入其 get value 中。
- trackComputed() 再次把外层 effect 记到 aliasName.dep(去重逻辑在 trackEffect 里)。
- 发现
_dirty为脏 → 执行this.effect.run()→ 打印 getter 执行,读到新 state.name,得到@star zoyi,缓存进_value,再标不脏。
- 打印 @star zoyi,结束更新
![]()
注意:在 Vue 3.5 中 computed 的更新阶段稍微有些变化
更新阶段(Vue 3.5)
- 执行
state.name = "star zoyi"state.name 发生改变,触发 name 的 setter。 - 新旧值不一样,触发 trigger,执行收集到的内层 effect 的 scheduler。
-
此时发生了变化: 执行
refreshComputed-> 发现_dirty为脏,先清脏 → 执行this.effect.run()→ 打印 getter 执行,读到新 state.name,得到@star zoyi,缓存进_value。
constructor(getter: () => any) {
this.effect = new ReactiveEffect(getter, () => {
// 3.5 风格:先置脏并同步重算,再通知下游(顺序与官方包一致)
this._dirty = DirtyLevels.Dirty;
this.refreshComputed();
if (this.dep) {
triggerEffects(this.dep); // 再执行外层 effect.run
}
});
}
/**
* 若当前为脏,则执行内层 effect(getter),更新 _value 并清脏。
*/
private refreshComputed() {
if (this._dirty !== DirtyLevels.Dirty) {
return;
}
this._dirty = DirtyLevels.NoDirty;
this._value = this.effect.run(); // 先执行 getter
}
- 再执行外层 effect.run,打印 外层 effect 执行,执行到 aliasName.value,再次进入其 get value 中。
- trackComputed() 再次把外层 effect 记到 aliasName.dep(去重逻辑在 trackEffect 里)。
- 已经计算过新的属性了,直接从
_value中获取并返回。
- 打印 @star zoyi,结束更新。
get value() {
// 收集计算属性(aliasName)的依赖,再保证缓存最新
this.trackComputed();
this.refreshComputed(); // _dirty 为不脏直接返回
return this._value; // 已经计算过新的属性了,直接从_value中获取
}
![]()
watch 实现原理
watch(
{ state.name }, // source
(prev, next, onCleanup) => { //cb
console.log("触发回调函数")
onCleanup(() => {
console.log("清理副作用函数");
});
},
{
immediate: false, // 立即执行一次
deep: false // 是否深度监听
});
source 发生变化,触发 cb 的执行
即 watch 需要实现:完成 source (必须是响应式)对某个 effect 进行收集,在触发 scheduler 时,将 cb 加入到其中,将新旧值传入 cb 中。
function watch(source, cb, options?) {
const { immediate = false, deep = false } = options;
const getter = createWatchGetter(source, deep);
let oldValue;
let cleanup;
// 初始化 effect,值变化时进行更新操作
const _effect = new ReactiveEffect(getter, () => {
const newValue = _effect.run(); // 获得最新的值
if (cleanup) {
cleanup();
cleanup = undefined;
}
cb(newValue, oldValue, (fn) => {
cleanup = fn;
});
oldValue = newValue;
});
oldValue = _effect.run();
// 立马执行一次 cb
if (immediate) {
cb(oldValue, undefined, (fn) => {
cleanup = fn;
});
}
return () => {
if (cleanup) {
cleanup();
cleanup = undefined;
}
stopEffect(_effect);
};
}
createWatchGetter:将 source 变为可执行的 getter,支持对 source 中的响应式属性进行依赖收集
source 支持的类型:ref,reactive、数组(进行遍历)、函数
function createWatchGetter(source: unknown, deep: boolean): () => unknown {
if (isRef(source)) {
return () => (source as { value: unknown }).value;
}
if (typeof source === "function") {
return source as () => unknown;
}
if (isArray(source)) {
return () =>
(source as unknown[]).map((s) => {
if (isRef(s)) {
return (s as { value: unknown }).value;
}
if (typeof s === "function") {
return (s as () => unknown)();
}
return s;
});
}
if (isReactive(source)) {
// deep 为 true 则深度监听,否则只监听一层
const maxDepth = deep ? undefined : 1;
return () => traverse(source, maxDepth);
}
return () => source;
}
清理函数:onCleanup 是回调的第三个参数,用来注册「下一次将要执行回调之前」或「停止监听时」会先执行的清理函数。
// 示例
watch(
() => state.id,
(id, oldId, onCleanup) => {
let cancelled = false;
onCleanup(() => {
cancelled = true;
});
fetch(`/api/user/${id}`).then((res) => {
if (!cancelled) {
state.user = res;
}
});
},
);
停止监听:watch 的返回值可以返回 stopEffect
/**
* 停止副作用:从各 dep 中移除并清空依赖列表,之后不再被 trigger。
*/
export function stopEffect(effect: ReactiveEffect) {
if (!effect.active) {
return;
}
effect.active = false; // 激活状态改为 false
const deps = effect.deps;
for (let i = 0; i < deps.length; i++) { // 并清理 effect 上的 deps
cleanDepEffect(deps[i], effect);
}
effect.deps.length = 0;
}
选项api:flush
- pre(默认):在同一轮事件里稍后跑(通常仍在微任务里),多在组件重新渲染之前调度,方便你在 DOM 还没更新时读旧 DOM、或先改别的状态。
- post:DOM 更新之后再跑,适合依赖已更新后的 DOM(例如 ref 量尺寸)。
- sync:一触发依赖更新,就同步、立刻执行回调,不排到微任务、也不等组件更新阶段。
用 Node.js 往复杂 Excel 模板里灌数据?现有库都差点意思,我手搓了一个
用 Node.js 往复杂 Excel 模板里灌数据?现有库都差点意思,我手搓了一个
一个 Excel 模板里塞了透视表、图片、合并单元格、跨表公式——我只需要往数据页写几行数,为什么这么难?
先说场景
做企业报表的同学大概都遇到过这种模板:
- 展示页:透视表、图表、嵌套合并单元格、图片、跨表公式,花里胡哨
- 数据页:干干净净一个表格,被展示页的公式引用
需求很简单:Node.js 后端往数据页里写数据,展示页自动算出结果。
就这么个事。
试了一圈,都不行
exceljs
生态里最流行的 Excel 库,用的人最多。
问题在于它的工作方式是解析 → 内存对象 → 重建。也就是说,读进来的是它能理解的部分,读不进去的就丢了。
如果你的模板里有透视表、复杂图表、某些特定格式的图片——写出来再打开,大概率面目全非。
这不是 exceljs 的锅,它的设计目标本来就不是"保真"。
xlsx-populate
这个库比 exceljs 好一点,设计上就考虑了模板场景。但问题是:
- 透视表?不支持
- 复杂图表?不支持
- 某些条件格式写完就丢
而且这个库更新频率不太稳定,有些 issue 挂很久。
SheetJS (xlsx)
性能好,能解析的东西多。但它本质上是个数据读取库,写入能力偏弱,尤其是样式和复杂对象的处理。
共同的问题
这些库都在做同一件事:把 xlsx 解析成内存对象,修改,再重新打包。
问题就在"重新打包"这一步。xlsx 内部有几十个 XML 文件,互相之间有引用关系。解析的时候丢信息,打包的时候自然就出问题。
换个思路:别重建,做手术
先搞清楚 xlsx 到底是什么。把 .xlsx 后缀改成 .zip,解压:
xl/
├── workbook.xml # 工作簿配置
├── _rels/
│ └── workbook.xml.rels # 工作表映射关系
├── worksheets/
│ ├── sheet1.xml # 工作表数据(不一定叫 sheet1)
│ └── sheet7.xml # 实际的工作表可能叫任何名字
├── styles.xml # 所有样式定义
├── drawings/ # 图片资源
├── pivotTables/ # 透视表定义
├── calcChain.xml # 公式计算链
└── sharedStrings.xml # 共享字符串表
关键发现:数据页的内容只存在 worksheets/sheetN.xml 的 <sheetData> 标签里。
也就是说,理论上我只需要:
- 打开 zip
- 找到目标 worksheet
- 只改
<sheetData>里的内容 - 其他文件一概不动
- 封包
样式、图片、透视表都不受影响——压根没碰它们。
设计原则
三条,很简单:
-
黑盒原则:
styles.xml、drawings/、pivotTables/一律不碰 -
片段手术:只改目标 worksheet 的
<sheetData>区域,其他 XML 片段原样保留 - 可诊断失败:遇到不支持的场景直接报错,不静默降级。报错带上错误码,好排查
核心实现
整个组件大概 600 行 TypeScript,只依赖 adm-zip(操作 zip)和 fast-xml-parser(局部辅助解析)。
1. worksheet 定位:不能假设 sheet1.xml
第一坑:worksheet 文件名不一定是 sheet1.xml。
实际项目中,Excel 内部的文件可能是 sheet7.xml、sheet3.xml,跟你在 Excel 里看到的标签顺序不一定对应。直接猜文件名会出 bug。
正确做法是通过 workbook.xml + workbook.xml.rels 做映射:
// workbook.xml 里有每个 sheet 的 name 和 r:id
// <sheet name="Data" sheetId="1" r:id="rId1"/>
// workbook.xml.rels 里有 r:id 到实际文件的映射
// <Relationship Id="rId1" Target="worksheets/sheet7.xml"/>
export function resolveWorksheetPath(
workbookXml: string,
relsXml: string,
sheetRef: SheetRef
): string {
// 1. 从 workbook.xml 找到目标 sheet 的 r:id
// 2. 从 rels 找到 r:id 对应的 Target
// 3. 拿到真实路径,比如 "xl/worksheets/sheet7.xml"
}
这样不管 Excel 内部怎么编号,都能精准定位。
2. 数据注入:直接拼 XML
数据注入的本质是生成 <row> 和 <c>(cell)节点,替换掉原来的 <sheetData> 内容。
不同类型的数据,生成的 XML 不一样:
function buildCellXml(cellRef: string, value: unknown, ...): string {
// 数字
if (typeof value === 'number') {
return '<c r="' + cellRef + '" t="n"><v>' + value + '</v></c>';
}
// 字符串:用 inlineStr,不走共享字符串表
// 为什么不用 sharedStrings?因为改那个索引太容易出错了
return '<c r="' + cellRef + '" t="inlineStr"><is><t>' + escapeXmlText(String(value)) + '</t></is></c>';
// 日期:转成序列号,当作数字写入
// 布尔:t="b",值写 0/1
}
注意字符串用的是 inlineStr 而不是共享字符串表(sharedStrings.xml)。原因是改共享字符串表的索引很容易搞乱其他单元格,inlineStr 虽然文件稍大一点,但安全。
3. 行扩展策略
写入数据时,数据行数可能比模板里的行多,也可能少。两种策略:
- 模式 A(覆盖):只往已有行里写数据,多出来的行不要。适合固定行数的模板。
- 模式 B(扩展):允许新增行,新行会继承附近行的样式索引。
样式继承的逻辑:
// 新增行时,向上扫描同列,找到最近的带样式的单元格
private resolveInheritedStyle(
existingRowsMap: Map<number, string>,
rowIndex: number,
col: number
): string | undefined {
let cursor = rowIndex - 1;
while (cursor > 0) {
const xml = existingRowsMap.get(cursor);
if (!xml) { cursor -= 1; continue; }
// 先找同列的样式
// 找不到就找这一行任意一个有样式的单元格
// 还找不到就继续往上一行找
}
return undefined;
}
这样新增的行不会变成"裸奔"状态,至少能继承模板的基本样式。
4. 冲突检测:行扩展前先扫雷
模式 B 扩展行的时候,新行可能覆盖到一些不能碰的东西:
- 合并单元格(mergeCells)
- 数据校验规则(dataValidations)
- 条件格式(conditionalFormatting)
- 表格对象(tableParts)
- 命名区域(definedNames)
所以扩展之前先做一次矩形碰撞检测:
export function detectRangeConflicts(
worksheetXml: string,
targetRange: RangeRect,
strictMode: boolean
): string[] {
// 从 XML 中提取 mergeCells、dataValidations、conditionalFormatting 的范围
// 跟目标写入范围做矩形相交判断
// 严格模式下直接抛 E_UNSUPPORTED_RANGE 错误
// 宽松模式下收集告警,继续执行
}
严格模式下,有冲突直接报错终止。
5. 日期体系的坑
Excel 有两套日期体系:1900 和 1904。macOS 版 Excel 默认用 1904,Windows 版用 1900。
同一个日期,两套体系算出来的序列号差 1462 天。如果不管这个,写入的日期就会偏移四年多。
更离谱的是,1900 体系里有个著名 bug:Excel 认为 1900 年是闰年,2 月 29 日是"存在的"(实际上 1900 不是闰年)。所以序列号 60 对应的是这个不存在的日期,60 以后的序列号都要 +1。
export function toExcelDate(date: Date, date1904: boolean): number {
if (date1904) {
// 1904 体系:从 1904-01-01 开始算
return Math.floor((utc.getTime() - base1904.getTime()) / DAY_MS);
}
// 1900 体系:从 1899-12-31 开始算
let serial = Math.floor((utc.getTime() - base1900.getTime()) / DAY_MS);
// 兼容 Excel 的 1900 闰年 bug
if (serial >= 60) {
serial += 1;
}
return serial;
}
组件会自动检测模板用的是哪套体系,按模板的体系转换。
6. 公式重算
数据写进去了,展示页的公式要重新算。但 Node.js 里没有 Excel 计算引擎,怎么办?
答案是:让 Excel 自己算。
private applyRecalcPolicy(mode: RecalcMode): void {
// 删掉 calcChain.xml(旧的计算缓存)
this.zip.deleteFile('xl/calcChain.xml');
// 在 workbook.xml 里设置全量重算标记
// Excel/WPS 打开文件时会自动重算所有公式
workbookObj.workbook.calcPr['@_fullCalcOnLoad'] = '1';
workbookObj.workbook.calcPr['@_forceFullCalc'] = '1';
}
这样用户打开文件的时候,Excel 会自动把所有公式重算一遍。代价是第一次打开会慢几秒(取决于公式数量),但结果一定是正确的。
完整用法
import { ExcelSurgicalLink } from './src';
// 从本地模板创建
const link = new ExcelSurgicalLink('template.xlsx');
// 注入数据
link.inject(
[
['商品A', 100, new Date('2026-04-01')],
['商品B', 120, new Date('2026-04-02')]
],
{
sheetRef: { name: 'Data' }, // 按名称定位工作表
rowExpansion: 'B', // 允许行扩展
dateHandling: 'serial', // 日期写序列号
recalcMode: 'full', // 全量重算
strictMode: 'strict', // 严格模式
onUnsupportedFeature: 'error', // 不支持的特性直接报错
startCell: 'A2' // 从 A2 开始写
}
);
// 保存
link.save('output.xlsx');
也支持远程模板——从 URL 拉模板,写完直接上传:
const link = await ExcelSurgicalLink.fromSource(
'https://your-server.com/template.xlsx',
{ headers: { Authorization: 'Bearer token' }, timeoutMs: 10000 }
);
// ... 注入数据 ...
await link.saveToRemote(
'https://your-server.com/output.xlsx',
{ method: 'PUT', headers: { Authorization: 'Bearer token' } }
);
效果
核心指标:
-
样式保全:
styles.xml、drawings/、pivotTables/字节级不变 - 公式正确:展示页公式打开后自动重算,结果与输入数据一致
- 可打开性:Excel(Windows/Mac)和 WPS 打开无修复提示
-
依赖极简:只依赖
adm-zip+fast-xml-parser
已知边界
实事求是,没做完的就是没做完:
| 功能 | 状态 | 说明 |
|---|---|---|
| 固定区域写入 | ✅ | 完全支持 |
| 样式/图片/透视表保全 | ✅ | 字节级不变 |
| 日期体系兼容 | ✅ | 1900/1904 自动识别 |
| 公式重算触发 | ✅ | fullCalcOnLoad |
| 行扩展 + 样式继承 | ✅ | 模式 B |
| 冲突检测 | ✅ | 五类对象 |
| 远程模板读写 | ✅ | HTTP(S) |
| 结构化表(ListObject)自动扩展 | ❌ | 还没做 |
| definedNames 动态重写 | ❌ | 当前为保护性拦截 |
| 大规模性能压测 | ❌ | SLO 报告待补 |
最后
这个组件的思路其实不复杂:别重建,只做手术。
xlsx 是个 zip 包,数据就在几个 XML 标签里。与其让库帮你解析→重建(顺便丢信息),不如直接上手改那几行 XML。
当然,这个方案也有适用范围——它适合"模板复杂、数据写入点固定"的场景。如果你需要动态创建图表、动态生成透视表,那还是得用更重的方案。
代码在本地跑着,等什么时候有空了整理一下放 GitHub。
【Swift Concurrency】彻底告别回调地狱——async/await、Task、Actor 系统精讲
【Swift Concurrency】彻底告别回调地狱——async/await、Task、Actor 系统精讲
iOS 进阶必修 · Swift 并发编程系列 第 1 期
一、一句话介绍
Swift Concurrency 是 Apple 在 Swift 5.5(iOS 15+)正式引入的原生并发框架,它让异步代码的编写、错误处理、线程安全变得声明式、结构化、且编译器可静态验证。
| 属性 | 信息 |
|---|---|
| 引入版本 | Swift 5.5 / Xcode 13 |
| 运行时最低要求 | iOS 13+(back-deploy)/ iOS 15+ 全功能 |
| 核心特性 | async/await · Task · Actor · AsyncStream |
| 与 Combine 关系 | 互补共存,AsyncSequence 可与 Combine 互转 |
| 官方文档 | Swift Concurrency |
二、为什么选择它
原生异步方案的痛点
在 Swift Concurrency 出现之前,iOS 异步编程长期面临这些问题:
| 旧方案 | Swift Concurrency |
|---|---|
| 回调嵌套(Callback Hell),可读性极差 |
async/await 线性写法,与同步代码几乎一致 |
DispatchQueue + 锁保护共享状态,极易出错 |
actor 编译器静态保证线程安全 |
DispatchGroup 聚合多个并行任务,样板代码多 |
async let / withTaskGroup 声明式并行 |
| 任务取消需要自行维护 flag,容易遗漏 | 结构化取消,父取消子自动跟随 |
线程切换 DispatchQueue.main.async {} 到处散落 |
@MainActor 注解,编译器强制保证主线程 |
Combine 学习曲线陡,操作符多 |
AsyncStream 原生支持,与 for await 天然融合 |
核心优势:
- 可读性:async/await 让异步代码读起来像同步,减少 80% 认知负担
- 安全性:actor 让数据竞争成为编译错误而非运行时崩溃
- 结构化:父子任务形成树形结构,取消/错误自动传播
- 可组合:AsyncSequence 统一了事件流、定时器、网络流的消费模型
- 零依赖:语言内置,无需引入任何第三方库
三、核心功能速览
基础层(新手必读)
无需配置,开箱即用
Swift Concurrency 是语言特性,直接在 Xcode 13+ 的任意 Swift 文件中使用:
// Swift 5.5+ · iOS 13+ (back-deploy) / iOS 15+ (全功能)
import Foundation // 仅需标准库
async/await:异步函数的声明与调用
// ✅ 声明异步函数:加 async 关键字
func fetchUser(id: Int) async throws -> User {
let url = URL(string: "https://api.example.com/users/\(id)")!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode(User.self, from: data)
}
// ✅ 调用:必须在 async 上下文中,用 await 挂起
Task {
do {
let user = try await fetchUser(id: 1)
print(user.name)
} catch {
print("加载失败:\(error)")
}
}
await是挂起点而非阻塞点:挂起时线程被释放,恢复后可能在不同线程继续执行。这是 Swift Concurrency 高效的根本原因。
SwiftUI 中使用 .task 修饰符(推荐)
struct UserView: View {
@State private var user: User?
var body: some View {
Text(user?.name ?? "加载中...")
.task {
// 视图消失时任务自动取消,无需手动管理
user = try? await fetchUser(id: 1)
}
}
}
进阶层(最佳实践)
async let:并行执行多个任务
// ❌ 顺序执行:总耗时 = 500ms + 300ms + 200ms = 1000ms
let user = try await fetchUser(id: 1)
let orders = try await fetchOrders(uid: 1)
let profile = try await fetchProfile(uid: 1)
// ✅ async let 并行:总耗时 = max(500ms, 300ms, 200ms) = 500ms
async let user = fetchUser(id: 1)
async let orders = fetchOrders(uid: 1)
async let profile = fetchProfile(uid: 1)
let (u, o, p) = try await (user, orders, profile)
// 三行代码实现并行,耗时减半
withTaskGroup:动态数量的并行任务
// 并行下载数量不固定的图片列表
func downloadImages(urls: [URL]) async throws -> [UIImage] {
try await withThrowingTaskGroup(of: UIImage.self) { group in
for url in urls {
group.addTask { try await fetchImage(from: url) }
}
var images: [UIImage] = []
for try await image in group {
images.append(image)
}
return images
}
}
Task:非结构化任务与取消
// 创建任务(继承当前 actor 上下文)
let task = Task(priority: .userInitiated) {
for i in 1...100 {
try Task.checkCancellation() // 取消时自动 throw CancellationError
await processItem(i)
}
}
// 取消(协作式,不会强制停止)
task.cancel()
// Task.detached:不继承 actor 上下文,完全独立
Task.detached(priority: .background) {
let result = await heavyComputation()
await MainActor.run { updateUI(result) }
}
Continuation:桥接旧式回调 API
// 将旧式 completion block API 包装为 async 函数
func requestLocation() async throws -> CLLocation {
try await withCheckedThrowingContinuation { continuation in
locationManager.requestLocation { location, error in
if let error {
continuation.resume(throwing: error)
} else if let location {
continuation.resume(returning: location)
}
}
}
}
// ⚠️ resume 只能调用一次,多次调用会 crash
深入层(源码视角)
核心模块职责划分
| 特性 | 职责 | 适用场景 |
|---|---|---|
async/await |
异步函数声明与挂起 | 任何异步 IO 操作 |
async let |
静态数量并行任务 | 首页多接口聚合 |
Task |
非结构化任务单元 | 按钮触发的独立操作 |
withTaskGroup |
动态数量结构化并发 | 批量下载/处理 |
actor |
数据竞争保护 | 共享状态管理 |
@MainActor |
主线程强制约束 | UI 更新 |
Sendable |
跨边界类型安全 | actor 参数/返回值 |
AsyncStream |
自定义异步序列 | 事件流/实时数据 |
四、实战演示
场景:AI 流式问答 + 打字机渲染
这是目前最热门的应用场景之一,完整演示了 AsyncStream + Task + @MainActor 的协同工作。
// Swift 5.5+
// MARK: - 1. 流式 AI 服务层(可替换为真实 SSE 接口)
enum AIStreamService {
/// Mock:逐字符推送,实际项目替换为 URLSession.bytes 读取 SSE
static func stream(prompt: String) -> AsyncStream<String> {
let response = "Swift Concurrency 让并发编程如行云流水," +
"async/await 消除回调地狱,Actor 守护数据安全," +
"AsyncStream 带来流式体验。🚀"
return AsyncStream { continuation in
Task {
for char in response {
guard !Task.isCancelled else {
continuation.finish()
return
}
continuation.yield(String(char))
try? await Task.sleep(nanoseconds: 60_000_000) // 60ms/字
}
continuation.finish()
}
}
}
/// 接入真实 SSE 接口(生产参考)
static func streamFromSSE(url: URL) -> AsyncStream<String> {
AsyncStream { continuation in
Task {
let (bytes, _) = try await URLSession.shared.bytes(from: url)
for try await line in bytes.lines {
guard line.hasPrefix("data: "),
let data = line.dropFirst(6).data(using: .utf8),
let json = try? JSONDecoder().decode(TokenResponse.self, from: data)
else { continue }
continuation.yield(json.token)
}
continuation.finish()
}
}
}
}
// MARK: - 2. SwiftUI 打字机视图
struct TypewriterView: View {
@State private var prompt = "Swift 并发编程"
@State private var output = ""
@State private var isStreaming = false
@State private var streamTask: Task<Void, Never>?
var body: some View {
VStack(alignment: .leading, spacing: 16) {
TextField("输入问题…", text: $prompt)
.textFieldStyle(.roundedBorder)
// 打字机光标效果
Text(output + (isStreaming ? "▌" : ""))
.font(.body)
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(Color(.secondarySystemBackground))
.cornerRadius(10)
.animation(.none, value: output)
HStack(spacing: 12) {
Button(isStreaming ? "生成中…" : "开始生成") {
startStream()
}
.buttonStyle(.borderedProminent)
.disabled(isStreaming)
Button("停止") {
streamTask?.cancel()
isStreaming = false
}
.buttonStyle(.bordered)
.tint(.red)
.disabled(!isStreaming)
}
}
.padding()
.onDisappear { streamTask?.cancel() } // ✅ 离开页面时取消
}
private func startStream() {
streamTask?.cancel()
output = ""
isStreaming = true
streamTask = Task {
for await token in AIStreamService.stream(prompt: prompt) {
output += token // SwiftUI 自动感知变化实时渲染
}
isStreaming = false
}
}
}
// MARK: - 3. UIKit 打字机控制器(@MainActor 保证 UI 安全)
@MainActor
class TypewriterViewController: UIViewController {
private let textView = UITextView()
private var streamTask: Task<Void, Never>?
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
streamTask?.cancel() // ✅ 离开页面时取消,防止内存泄漏
}
@objc func startStream() {
streamTask?.cancel()
textView.text = ""
streamTask = Task {
for await token in AIStreamService.stream(prompt: "UIKit") {
guard !Task.isCancelled else { break }
textView.text += token
// 自动滚到底部
let range = NSRange(location: textView.text.count - 1, length: 1)
textView.scrollRangeToVisible(range)
}
}
}
}
这个示例完整演示了:AsyncStream 的创建与消费、Task 的取消管理、@MainActor 的 UI 安全保证、SwiftUI 和 UIKit 的两套接入方式。
五、源码亮点
进阶层:值得借鉴的设计
Actor 并发计数器(告别 DispatchQueue + 锁)
// ❌ 传统写法:容易因忘记加锁而出现数据竞争
class Counter {
var value = 0
let queue = DispatchQueue(label: "counter.queue")
func increment() { queue.sync { value += 1 } }
}
// ✅ actor:编译器静态保证,忘加 await 直接报错
actor SafeCounter {
private(set) var value = 0
func increment() { value += 1 }
}
// 并发使用:1000 个任务同时递增,结果一定是 1000
let counter = SafeCounter()
await withTaskGroup(of: Void.self) { group in
for _ in 0..<1000 {
group.addTask { await counter.increment() }
}
}
print(await counter.value) // 1000,绝无数据竞争
AsyncStream 资源安全回收
// 定时器流:onTermination 防止 timer 泄漏
func timerStream(interval: Double) -> AsyncStream<Int> {
AsyncStream { continuation in
var tick = 0
let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { _ in
tick += 1
continuation.yield(tick)
}
// ✅ 流取消/结束时自动调用,清理外部资源
continuation.onTermination = { _ in
timer.invalidate()
}
}
}
深入层:设计思想解析
结构化并发:任务树模型
Swift Concurrency 引入了"结构化并发"概念——任务形成父子树形结构:
父任务(Task)
├── 子任务 A(async let)
├── 子任务 B(async let)
└── TaskGroup
├── 子任务 C(addTask)
└── 子任务 D(addTask)
关键特性:
- 父取消 → 子自动取消:无需手动遍历
- 子抛出错误 → 父捕获:错误自动冒泡
- 父作用域结束 → 等待所有子完成:无任务泄漏
这与 Kotlin 协程的 StructuredConcurrency 思想一脉相承,但 Swift 通过编译器强制实施,更难写错。
Actor 的可重入设计
Actor 内部通过隐式串行队列保证数据安全,但它是可重入的:
actor BankAccount {
var balance: Double = 1000
// ⚠️ 重入陷阱:await 挂起期间,其他任务可进入 actor 修改 balance
func withdrawUnsafe(amount: Double) async throws {
guard balance >= amount else { throw BankError.insufficient }
await logTransaction(amount) // 挂起!balance 可能被别的 withdraw 修改
balance -= amount // 此时 balance 可能已不足!
}
// ✅ 正确:先修改状态再 await
func withdrawSafe(amount: Double) async throws {
guard balance >= amount else { throw BankError.insufficient }
balance -= amount // 先扣,在 await 之前完成关键状态变更
await logTransaction(amount)
}
}
规则:actor 中,await 之前必须完成所有关键状态变更。
六、踩坑记录
问题 1:Continuation.resume 调用了多次导致 crash
- 原因:某些旧 SDK 的 completion block 可能被调用多次(如进度回调)
- 解决:用 bool flag 保护,确保 resume 只执行一次
func safeContinuation<T>(_ block: (@escaping (T) -> Void) -> Void) async -> T {
await withCheckedContinuation { continuation in
var resumed = false
block { value in
guard !resumed else { return }
resumed = true
continuation.resume(returning: value)
}
}
}
问题 2:Task.detached 中直接更新 UI 导致崩溃
-
原因:
Task.detached不继承当前 actor 上下文,不在主线程 - 解决:显式切回主线程
// ❌ 危险
Task.detached { self.label.text = "done" }
// ✅ 正确
Task.detached {
let result = await process()
await MainActor.run { self.label.text = result }
}
问题 3:视图消失后 Task 仍在运行,导致内存泄漏
- 原因:Task 生命周期独立于视图,视图销毁后任务仍持有 self
-
解决:SwiftUI 用
.task {}修饰符(自动管理),UIKit 在viewWillDisappear中 cancel
// UIKit
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
loadTask?.cancel()
}
问题 4:Actor 重入性导致余额多扣
- 原因:await 挂起期间其他任务进入 actor 修改共享状态
- 解决:遵守"先修改状态,再 await"原则(见第五章深入层)
问题 5:AsyncStream 中 timer / 监听器未释放,持续运行
-
原因:忘记实现
continuation.onTermination -
解决:每个 AsyncStream 必须实现
onTermination,清理外部资源
continuation.onTermination = { reason in
timer.invalidate()
notificationCenter.removeObserver(observer)
}
问题 6:withTaskGroup 中子任务抛出错误没有被感知
-
原因:使用了
withTaskGroup(不抛出版),错误被吞掉 -
解决:需要错误传播时,使用
withThrowingTaskGroup
// ✅ 任意子任务失败,整个 group 取消并抛出错误
try await withThrowingTaskGroup(of: Data.self) { group in
for url in urls { group.addTask { try await fetch(url) } }
for try await data in group { process(data) }
}
问题 7:在 iOS 13 / 14 上使用 actor 报链接错误
- 原因:actor 运行时需要 iOS 15+ 的系统库支持;Xcode back-deploy 支持 async/await 但不完全支持 actor
-
解决:确认最低 Deployment Target,或对 actor 用
@available(iOS 15, *)包裹
七、延伸思考
与同类方案横向对比
| 方案 | 简介 | 学习曲线 | 线程安全 | 取消支持 | 适用场景 |
|---|---|---|---|---|---|
| Swift Concurrency | Swift 原生,语言级别支持 | 中 | 编译器保证(actor) | 结构化取消 | 新项目首选 |
| GCD + DispatchQueue | 苹果传统并发方案 | 低 | 手动加锁,容易出错 | 无原生支持 | 老项目维护 |
| Combine | 响应式框架,操作符丰富 | 高 | 需手动 receive(on:) | AnyCancellable | 复杂数据流转换 |
| PromiseKit | 基于 Promise 的链式回调 | 中 | 无特殊支持 | 有限支持 | OC/早期 Swift 项目 |
| RxSwift | 响应式编程全家桶 | 很高 | 需配置 scheduler | Disposable | 重度响应式架构 |
推荐使用场景
- ✅ iOS 13+ 新项目,全面拥抱 Swift Concurrency
- ✅ 需要并行聚合多个接口的页面(async let / TaskGroup)
- ✅ 共享状态管理,替代 DispatchQueue + 锁(actor)
- ✅ 实时数据流、WebSocket、AI 流式响应(AsyncStream)
- ✅ 需要优雅取消的长时任务(下载、文件处理)
不推荐场景
- ❌ 项目最低支持 iOS 12 及以下,部分特性无法使用
- ❌ 已有大量 Combine 代码,短期内迁移成本过高
- ❌ 需要复杂响应式操作符链(merge、combineLatest 等),Combine 更合适
迁移策略建议
- 新功能优先用 async/await,不强制改旧代码
-
旧接口用
Continuation包装,对调用方透明 -
Combine Pipeline 可通过
.values属性转为AsyncSequence互通 -
Swift 6 开启严格并发检查(
-strict-concurrency=complete),提前消灭隐患
八、参考资源
- Swift 官方文档:Concurrency
- Apple Developer:Swift Concurrency
- WWDC 2021 Meet async/await in Swift
- WWDC 2021 Explore structured concurrency in Swift
- WWDC 2021 Protect mutable state with Swift actors
- WWDC 2022 Eliminate data races using Swift Concurrency
- WWDC 2023 Beyond the basics of structured concurrency
- SE-0296 async/await Proposal
- 系列 Demo 仓库(持续更新):
github.com/yourname/ios-swift-concurrency-demos
九、本期互动
小作业
基于本文的 AsyncStream 示例,实现一个实时心跳检测器:
- 用
AsyncStream每隔 1 秒 yield 一次当前时间戳 - 连续 5 次 yield 后,主动调用
continuation.finish()结束流 - 在 SwiftUI 中用
.task {}消费流,将每次时间戳展示在列表中 - 点击「停止」按钮时,通过
task.cancel()终止流,并验证onTermination被调用
完成后在评论区贴出你的 AsyncStream 创建代码和 onTermination 实现,说明你是如何验证资源正确释放的。
思考题
Swift Concurrency 的 actor 选择了可重入设计——即 await 挂起时允许其他任务进入 actor 执行。你认为这个设计决策是合理的吗?
如果设计成不可重入(像传统锁一样),会带来哪些问题?可重入又会引发哪些坑?在你的实际项目中,你更希望哪种行为?
读者征集
下一期预计深入讲解 Swift Concurrency 进阶:自定义 AsyncSequence、结构化并发原理、Swift 6 严格并发检查实战。
如果你在项目中迁移到 Swift Concurrency 时踩过坑(特别是 actor 重入、Sendable 编译报错、Task 泄漏等),欢迎评论区留言,优质踩坑经历将收录进下一期《踩坑记录》章节!
📅 本系列持续更新 ➡️ 第 1 期:Swift Concurrency 基础精讲(本期)· ○ 第 2 期:Swift Concurrency 进阶 · ○ 第 3 期:待定 · ○ 第 4 期:待定
前端架构实操:地铁出行系统高并发与性能优化全解析(二)
一、引言:从行业通用场景出发,理清高并发与性能优化的核心逻辑
作为前端备考软考架构师的伙伴,我们都清楚,中大型项目的核心挑战,从来不是 “实现功能”,而是 “扛住流量、保证体验”。
本篇继续围绕我了解到的地铁出行系统(中大型微服务项目),聚焦高并发与性能优化核心场景,完整拆解「项目场景→实际问题→思考过程→技术选型→解决方案→实施结果」,既梳理性能优化类技术体系,又还原项目实操逻辑,帮大家吃透技术落地思路,同时规避保密风险。
二、项目场景:地铁出行系统高并发与性能现状
结合行业通用地铁出行系统项目特点,该类项目的高并发与性能相关场景如下,为后续问题排查和技术选型奠定基础:
-
用户规模与流量特点:服务全市 500 万 + 用户,早晚高峰(7:00-9:00、17:00-19:00)为流量峰值,瞬时并发量可达平日的 5-8 倍,核心页面(实时到站、客流监控)需承载高并发请求;
-
架构现状:后端 6 个微服务(线路管理、实时到站、客流监控、用户管理、票务支付、站点设施管理)独立部署,前端采用 Vue3+Pinia 技术栈,已通过 BFF 层 + Nacos 解决接口与环境问题,但随着用户量增长,性能瓶颈逐步凸显;
-
部署环境:4 套环境(开发、测试、仿真、生产),后端服务需根据早晚高峰客流动态扩容 / 缩容,对系统弹性扩缩容能力要求极高。
三、项目痛点:高并发与性能优化中遇到的实际问题
在项目迭代过程中,随着用户量持续增长,早晚高峰时段系统出现了多个核心性能问题,严重影响用户体验和系统稳定性,具体如下:
1. 静态资源加载慢,页面首屏渲染超时
地铁出行系统包含大量静态资源(线路地图、站点图片、样式文件、JS 代码包),初期采用 “前端直连服务器” 的方式加载资源,遇到 3 个核心问题:
-
资源分发效率低:静态资源存储在后端服务器,用户跨区域访问时,网络延迟高,首屏加载时间长达 8-10 秒,远超用户可接受的 3 秒阈值;
-
服务器带宽压力大:早晚高峰时,大量用户同时请求静态资源,后端服务器带宽被占满,导致接口请求延迟、页面加载失败;
-
缓存策略不合理:未做合理的资源缓存配置,用户每次访问都重新加载全量资源,进一步加剧服务器压力。
2. 高并发场景下,服务扩容不及时,系统崩溃风险高
早晚高峰瞬时并发量激增,后端微服务(尤其是实时到站、票务支付服务)负载过高,出现以下问题:
-
扩容响应慢:传统手动扩容方式,需运维人员手动部署服务器、配置服务,耗时长达 1-2 小时,无法应对突发流量高峰;
-
服务稳定性差:服务过载时,出现接口超时、请求失败,甚至服务宕机,导致用户无法查询实时到站、无法购票,严重影响出行体验;
-
资源浪费严重:平峰时段服务器负载低,手动缩容不及时,造成大量服务器资源闲置,运维成本陡增。
3. 大数据量页面渲染卡顿,用户交互体验差
客流监控、线路查询等页面,需展示大量实时数据(如全线路客流数据、历史到站记录),前端直接渲染全量数据,出现:
-
页面渲染卡顿:大数据量渲染导致主线程阻塞,页面滚动、点击等交互操作延迟,甚至出现页面卡死;
-
内存占用过高:全量数据加载导致浏览器内存占用飙升,部分低端设备出现闪退;
-
数据更新不及时:实时数据频繁更新,未做合理的渲染优化,导致页面频繁重绘,进一步加剧卡顿。
四、思考过程:从问题出发,拆解破局思路
面对上述 3 个核心问题,相关开发团队没有盲目选型技术,而是从「提升用户体验、降低运维成本、增强系统稳定性」三个核心目标出发,逐步拆解思考,形成了清晰的破局思路:
针对 “静态资源加载慢” 问题的思考
核心需求:提升静态资源加载速度,降低服务器带宽压力,实现用户就近访问,优化首屏渲染体验。思考拆解:
-
痛点本质:静态资源集中存储在后端服务器,用户跨区域访问延迟高,且未做缓存优化,导致服务器带宽压力大、首屏加载慢;
-
核心思路:引入内容分发网络(CDN) ,将静态资源缓存到全国各区域节点,用户就近访问节点资源,大幅降低网络延迟;同时优化资源缓存策略,减少重复请求;
-
技术选型考量:对比自建 CDN 与第三方商用 CDN—— 自建 CDN 部署成本高、维护难度大,不适合中大型项目;第三方商用 CDN(如阿里云 CDN、腾讯云 CDN)部署简单、节点覆盖广,能快速解决资源加载问题,因此确定选用 CDN 作为静态资源优化方案。
针对 “高并发服务扩容难” 问题的思考
核心需求:实现服务自动扩缩容,应对突发流量高峰,提升系统稳定性,同时降低运维成本,避免资源浪费。思考拆解:
-
痛点本质:传统手动扩容 / 缩容方式,响应速度慢、效率低,无法适配地铁项目 “早晚高峰流量波动大” 的特点,且运维成本高;
-
核心思路:引入容器化编排工具,将后端微服务、BFF 层打包成容器,通过编排工具实现服务的自动部署、弹性扩缩容、故障自愈;
-
技术选型考量:对比 Docker+K8s(Kubernetes)与其他容器化方案 ——Docker 实现容器化打包,保证环境一致性;K8s 实现容器编排,支持自动扩缩容、服务治理,是行业内微服务容器化的标准方案,因此确定选用「Docker+K8s」作为容器化编排方案。
针对 “大数据量渲染卡顿” 问题的思考
核心需求:优化大数据量页面渲染性能,避免主线程阻塞,提升用户交互体验,降低浏览器内存占用。思考拆解:
-
痛点本质:前端一次性加载并渲染全量数据,导致主线程阻塞、内存占用过高,页面交互卡顿;
-
核心思路:采用虚拟列表 + 懒加载技术,仅渲染可视区域内的数据,按需加载剩余数据,减少 DOM 节点数量,降低主线程压力;同时优化数据更新逻辑,避免频繁重绘;
-
技术选型考量:虚拟列表(如 vue-virtual-scroller)是前端大数据量渲染的通用优化方案,适配 Vue3 技术栈,无需额外引入复杂框架,开发成本低、优化效果显著,因此确定选用虚拟列表 + 懒加载作为渲染优化方案。
整体思考总结
最终形成「CDN+Docker+K8s + 虚拟列表」的技术栈组合,各技术针对性解决对应痛点:CDN 解决静态资源加载慢,Docker+K8s 解决高并发扩容难,虚拟列表解决大数据量渲染卡顿,形成完整的高并发与性能优化解决方案,贴合地铁出行系统的业务特点,同时符合软考架构师 “技术选型贴合项目需求” 的核心要求。
五、解决方案:CDN+Docker+K8s + 虚拟列表技术栈落地细节
结合上述行业通用思考思路,相关开发团队落地了完整的高并发与性能优化方案,每一项技术都严格贴合项目需求,具体落地细节如下:
1. CDN 落地:静态资源加速与缓存优化
-
核心功能落地:
-
资源分发:将地铁出行系统的所有静态资源(线路地图、站点图片、JS/CSS 代码包、字体文件)上传至 CDN,缓存到全国各区域节点,用户访问时,自动路由到最近的节点获取资源;
-
缓存策略优化:针对不同类型资源设置差异化缓存时间 —— 静态资源(图片、样式)设置 7 天缓存,JS 代码包设置 1 天缓存,同时配置版本号,避免缓存过期导致的资源更新不及时;
-
回源策略优化:设置 CDN 回源规则,仅在缓存过期时回源到后端服务器获取最新资源,减少服务器带宽压力;
-
-
大白话理解:CDN 就像是 “全国连锁的资源便利店”,把静态资源提前放到用户家门口的便利店,用户不用再跑到后端服务器(总店)取资源,就近就能拿到,速度大幅提升,还能减轻总店的压力。
2. Docker+K8s 落地:容器化编排与自动扩缩容
-
核心功能落地:
-
容器化打包:将后端 6 个微服务、BFF 层分别打包成 Docker 镜像,保证开发、测试、仿真、生产环境的一致性,避免 “本地运行正常,线上报错” 的问题;
-
K8s 集群部署:搭建 K8s 集群,部署所有容器化服务,配置服务发现、负载均衡,实现服务的自动部署与故障自愈;
-
自动扩缩容配置:基于 CPU 使用率、请求量设置 HPA(Horizontal Pod Autoscaler),早晚高峰流量激增时,自动扩容服务实例;平峰时段自动缩容,节省服务器资源;
-
运维自动化:通过 K8s 实现服务的一键部署、滚动更新,无需手动操作服务器,大幅降低运维成本;
-
-
大白话理解:Docker 就像是 “集装箱”,把服务和运行环境打包成统一的集装箱,不管在哪都能正常运行;K8s 就像是 “智能调度中心”,自动管理这些集装箱,根据流量多少,自动增减集装箱数量,应对高峰、节省资源。
3. 虚拟列表 + 懒加载落地:大数据量渲染优化
-
核心功能落地:
-
虚拟列表实现:针对客流监控、线路查询等大数据量页面,引入 vue-virtual-scroller 组件,仅渲染可视区域内的 10-20 条数据,滚动时动态加载剩余数据,大幅减少 DOM 节点数量;
-
懒加载优化:图片、非首屏数据采用懒加载,仅当用户滚动到可视区域时,再加载对应资源,减少首屏加载时间;
-
渲染优化:优化数据更新逻辑,采用虚拟滚动 + 防抖处理,避免频繁重绘,保证页面交互流畅;
-
-
大白话理解:虚拟列表就像是 “无限长的名单,但只给你看当前屏幕上的几行”,滚动时再替换内容,不用一次性渲染全部名单,页面自然不卡顿。
4. 技术协同落地:全链路优化逻辑
各技术并非独立使用,而是形成协同闭环,确保优化效果最大化:
-
CDN 加速静态资源,减少首屏加载时间,降低服务器带宽压力,为 K8s 服务预留更多资源处理业务请求;
-
Docker+K8s 实现服务自动扩缩容,应对 CDN 加速后带来的更高并发请求,保证系统稳定性;
-
虚拟列表优化前端渲染,配合 CDN 资源加速,共同提升用户体验,形成 “前端渲染 + 资源加速 + 后端扩容” 的全链路优化。
六、实施结果:问题解决成效与技术体系总结
方案落地后,相关开发团队对系统性能、用户体验、运维成本进行了统计,核心成效显著,同时梳理性能优化类技术体系,夯实备考基础:
1. 实施成效(量化呈现,贴合项目实际)
-
静态资源加载优化:首屏加载时间从 8-10 秒缩短至 2-3 秒,用户访问成功率从 85% 提升至 99.5%,服务器带宽压力降低 70%;
-
高并发扩容优化:服务扩容响应时间从 1-2 小时缩短至 5 分钟内,早晚高峰服务宕机率从 15% 降至 0,服务器资源利用率提升 60%,运维成本降低 50%;
-
大数据量渲染优化:页面渲染卡顿率从 20% 降至 1% 以下,浏览器内存占用降低 40%,用户交互体验大幅提升;
-
系统稳定性提升:全链路优化后,系统整体可用性从 99.2% 提升至 99.99%,完全满足地铁出行系统的高并发、高可用需求。
2. 性能优化技术体系梳理(融入技术体系化思路)
通过本次对行业通用地铁出行系统项目的梳理,总结出高并发与性能优化相关的技术体系,方便后续备考记忆和项目复用:
-
技术分类:本次落地的 CDN、Docker+K8s、虚拟列表,分属不同优化维度,覆盖全链路性能提升:
-
CDN:属于「静态资源加速技术」,核心解决资源加载慢、带宽压力大的问题,适配中大型项目的静态资源分发;
-
Docker+K8s:属于「容器化编排技术」,核心解决服务扩缩容、系统稳定性问题,适配微服务架构的高并发场景;
-
虚拟列表 + 懒加载:属于「前端渲染优化技术」,核心解决大数据量渲染卡顿问题,适配前端大数据量页面场景;
-
-
技术选型逻辑:技术选型的核心是 “针对性解决痛点”,CDN 解决资源问题,Docker+K8s 解决扩容问题,虚拟列表解决渲染问题,三者协同形成全链路优化,这也是架构设计的核心思路;
-
备考记忆技巧:可总结为 “资源慢用 CDN,扩容难用 K8s,渲染卡用虚拟列表”,后续学习其他性能优化技术时,也按「场景→问题→思考→选型→落地」的思路梳理,贴合项目实操与软考备考。
七、自我复盘 + 下一篇预告
自我复盘:本次围绕地铁出行系统的高并发与性能优化,完整拆解了从问题到解决方案的全流程,让我深刻体会到,性能优化不是 “堆砌技术”,而是 “针对痛点精准选型”。同时,通过梳理技术体系,夯实了性能优化类技术的基础,也为软考论文中 “高并发与性能优化” 模块的写作,积累了完整的项目场景与落地逻辑。
下一篇,我们继续围绕地铁出行系统,聚焦工程化与监控场景 —— 随着项目迭代,代码质量、构建效率、系统监控成为新的痛点,我们将拆解「工程化场景→核心问题→思考过程→Webpack+GitLab CI+Prometheus 等技术栈落地→实施成效」,既梳理工程化技术体系,又还原项目实操逻辑,帮大家吃透工程化建设的核心思路。
最后,非常非常欢迎大家在评论区分享自己的想法、补充相关实操经验,也欢迎大家指出文中不对的地方,一起交流、一起进步,共同吃透前端架构实操与软考备考要点。
每日一题-机器人能否返回原点🟢
在二维平面上,有一个机器人从原点 (0, 0) 开始。给出它的移动顺序,判断这个机器人在完成移动后是否在 (0, 0) 处结束。
移动顺序由字符串 moves 表示。字符 move[i] 表示其第 i 次移动。机器人的有效动作有 R(右),L(左),U(上)和 D(下)。
如果机器人在完成所有动作后返回原点,则返回 true。否则,返回 false。
注意:机器人“面朝”的方向无关紧要。 “R” 将始终使机器人向右移动一次,“L” 将始终向左移动等。此外,假设每次移动机器人的移动幅度相同。
示例 1:
输入: moves = "UD" 输出: true 解释:机器人向上移动一次,然后向下移动一次。所有动作都具有相同的幅度,因此它最终回到它开始的原点。因此,我们返回 true。
示例 2:
输入: moves = "LL" 输出: false 解释:机器人向左移动两次。它最终位于原点的左侧,距原点有两次 “移动” 的距离。我们返回 false,因为它在移动结束时没有返回原点。
提示:
1 <= moves.length <= 2 * 104-
moves只包含字符'U','D','L'和'R'
简单题,简单做(Python/Java/C++/C/Go/JS/Rust)
机器人的横坐标,等于向右移动的次数,减去向左移动的次数。如果 $\texttt{R}$ 的个数等于 $\texttt{L}$ 的个数,那么最终横坐标为 $0$。
机器人的纵坐标,等于向上移动的次数,减去向下移动的次数。如果 $\texttt{U}$ 的个数等于 $\texttt{D}$ 的个数,那么最终纵坐标为 $0$。
这两个条件同时成立,才能回到原点。
###py
class Solution:
def judgeCircle(self, moves: str) -> bool:
return moves.count('R') == moves.count('L') and \
moves.count('U') == moves.count('D')
###py
class Solution:
def judgeCircle(self, moves: str) -> bool:
cnt = Counter(moves)
return cnt['R'] == cnt['L'] and cnt['U'] == cnt['D']
###java
class Solution {
public boolean judgeCircle(String moves) {
int x = 0;
int y = 0;
for (char move : moves.toCharArray()) {
if (move == 'R') {
x++;
} else if (move == 'L') {
x--;
} else if (move == 'U') {
y++;
} else {
y--;
}
}
return x == 0 && y == 0;
}
}
###cpp
class Solution {
public:
bool judgeCircle(string moves) {
return ranges::count(moves, 'R') == ranges::count(moves, 'L') &&
ranges::count(moves, 'U') == ranges::count(moves, 'D');
}
};
###c
bool judgeCircle(char* moves) {
int x = 0, y = 0;
for (int i = 0; moves[i]; i++) {
char move = moves[i];
if (move == 'R') {
x++;
} else if (move == 'L') {
x--;
} else if (move == 'U') {
y++;
} else {
y--;
}
}
return x == 0 && y == 0;
}
###go
func judgeCircle(moves string) bool {
return strings.Count(moves, "R") == strings.Count(moves, "L") &&
strings.Count(moves, "U") == strings.Count(moves, "D")
}
###js
var judgeCircle = function(moves) {
const cnt = _.countBy(moves);
return cnt['R'] === cnt['L'] && cnt['U'] === cnt['D'];
};
###rust
impl Solution {
pub fn judge_circle(moves: String) -> bool {
moves.matches('R').count() == moves.matches('L').count() &&
moves.matches('U').count() == moves.matches('D').count()
}
}
复杂度分析
- 时间复杂度:$\mathcal{O}(n)$,其中 $n$ 是 $\textit{moves}$ 的长度。
- 空间复杂度:$\mathcal{O}(1)$。
分类题单
- 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
- 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
- 单调栈(基础/矩形面积/贡献法/最小字典序)
- 网格图(DFS/BFS/综合应用)
- 位运算(基础/性质/拆位/试填/恒等式/思维)
- 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
- 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
- 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
- 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
- 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
- 链表、树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA)
- 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)
欢迎关注 B站@灵茶山艾府
三行代码搞定!还能有人比我短?
模拟
最直白的思路非模拟莫属。
设机器人的坐标为 (x, y)。
显然初始时,机器人在原点,即 x = 0, y = 0。
然后遍历整个字符串 moves,根据具体的方向更新 x 或 y。
最后判断 x, y 是否均为 0,即是否又回到了原点。
###cpp
class Solution {
public:
bool judgeCircle(string moves) {
int x = 0, y = 0;
for (auto move: moves) {
switch (move) {
case 'U': y--; break;
case 'D': y++; break;
case 'L': x--; break;
case 'R': x++; break;
}
}
return x == 0 && y == 0;
}
};
统计字符数量
让我们先把问题简化为一维问题,即机器人只在 X 轴上移动。
假设机器人朝着正方向移动了 R 次,朝着负方向移动了 L 次。
无论这 L+R 次如何排列,最后的机器人在 X 轴上的坐标必为 R - L。即当 R == L 时,机器人才能回到 0 处。
同理,在二维平面上,分别向上下左右四个方向移动了,U,D,L,R 次,当且仅当 L == R 且 U == D 时,机器人才能回到原点。
那么问题,就变成了统计 moves 里各个字符出现的次数。三行搞定 ~
###cpp
class Solution {
public:
bool judgeCircle(const string &moves) {
std::unordered_map<char, int> cnt;
std::for_each(moves.begin(), moves.end(), [&cnt](char c) { cnt[c]++; });
return cnt['U'] == cnt['D'] && cnt['L'] == cnt['R'];
}
};
如果感觉有点意思,那就关注一下【我的公众号】吧~
![]()
看了评论区老铁们的花式短代码,感觉熟练掌握 STL 属实能提高生产力。
推荐一本 《C++ 标准库》,关注公众号,回复 "CPP标准库(第二版)" 即可获取下载方式。
JS解法,只需要判断LR的个数是否相等,UD的个数是否相等即可。
/**
* @param {string} moves
* @return {boolean}
*/
var judgeCircle = function(moves) {
// 判断左右移动的次数和上下移动的次数是否相等(即 L.count === R.count && U.count === D.count)
return moves.split('L').length === moves.split('R').length && moves.split('U').length === moves.split('D').length
};
Next.js 14 + wagmi v2 构建 NFT 市场:从列表渲染到实时更新的完整链路
背景
上个月,我接手了一个新的 Web3 项目:为一个基于 Base 链的 NFT 系列开发一个轻量级的交易市场前端。核心需求很简单:展示该系列的所有 NFT,显示每个 NFT 的当前挂单价格,并且当用户购买或取消挂单时,页面上的信息要能实时更新,无需手动刷新。
技术栈选型很明确:Next.js 14(App Router)、TypeScript、Tailwind CSS,以及 Web3 交互的核心——wagmi v2 和 viem。我心想,这不过是把链上数据读出来,监听几个事件,再渲染成列表,用 useAccount 和 useReadContract 不就搞定了吗?结果,从第一行代码开始,坑就一个接一个地来了。
问题分析
我的初始思路非常直接:在页面组件里,用 wagmi 的 useReadContract 读取 NFT 合约的 totalSupply,然后循环获取每个 Token 的元数据和挂单信息,最后用 useEffect 监听 ListingUpdated 和 Transfer 事件来触发数据重拉。
但一上手就发现了几个致命问题:
-
水合(Hydration)错误:在服务端组件(Server Component)中直接使用
useAccount或useReadContract会导致错误,因为这些钩子依赖于浏览器环境。 - 性能灾难:如果我有 1000 个 NFT,难道要发起 1000+ 次 RPC 调用吗?页面加载会慢到无法接受。
-
实时更新失效:简单地用
useEffect监听事件,在用户切换钱包或断开连接时,监听器会混乱,导致更新不及时或重复更新。 - 状态同步难题:交易(购买、挂单)提交后,如何优雅地等待链上确认,并立即更新 UI,而不是等用户手动刷新?
最初的方案完全走不通。我意识到,必须把服务端初始渲染、客户端状态管理、批量数据获取和事件驱动更新这几个环节拆解开,设计一个更清晰的架构。
核心实现
1. 架构分层:服务端获取初始数据
首先,我放弃了在页面组件里直接调用 Web3 钩子获取所有数据的想法。对于 NFT 列表这种相对静态的初始数据,应该在服务端获取。我创建了一个服务端函数,使用 Viem 的公共客户端(Public Client)来读取链上数据。
关键点:在 App Router 中,我们可以在 Server Component 或 Server Action 里直接与区块链交互,无需钱包连接。这完美解决了初始渲染的问题。
// app/api/nfts/route.ts
import { createPublicClient, http } from 'viem';
import { base } from 'viem/chains';
// 初始化一个不需要钱包的公共客户端
const publicClient = createPublicClient({
chain: base,
transport: http(process.env.NEXT_PUBLIC_RPC_URL),
});
// NFT 合约 ABI 片段
const NFT_ABI = [
{
name: 'totalSupply',
type: 'function',
stateMutability: 'view',
inputs: [],
outputs: [{ type: 'uint256' }],
},
{
name: 'tokenURI',
type: 'function',
stateMutability: 'view',
inputs: [{ name: 'tokenId', type: 'uint256' }],
outputs: [{ type: 'string' }],
},
] as const;
// 市场合约 ABI 片段
const MARKET_ABI = [
{
name: 'listings',
type: 'function',
stateMutability: 'view',
inputs: [{ name: 'tokenId', type: 'uint256' }],
outputs: [
{ name: 'seller', type: 'address' },
{ name: 'price', type: 'uint256' },
{ name: 'isActive', type: 'bool' },
],
},
] as const;
export async function GET() {
try {
const totalSupply = await publicClient.readContract({
address: process.env.NEXT_PUBLIC_NFT_CONTRACT as `0x${string}`,
abi: NFT_ABI,
functionName: 'totalSupply',
});
const nftDataPromises = [];
// 注意:这里用 Number 转换只适用于总量不大的情况,真实项目需考虑 BigInt
for (let i = 0; i < Number(totalSupply); i++) {
const promise = Promise.all([
// 获取元数据 URI
publicClient.readContract({
address: process.env.NEXT_PUBLIC_NFT_CONTRACT as `0x${string}`,
abi: NFT_ABI,
functionName: 'tokenURI',
args: [BigInt(i)],
}),
// 获取挂单信息
publicClient.readContract({
address: process.env.NEXT_PUBLIC_MARKET_CONTRACT as `0x${string}`,
abi: MARKET_ABI,
functionName: 'listings',
args: [BigInt(i)],
}),
]).then(([tokenURI, listing]) => ({
tokenId: i,
tokenURI,
listing,
}));
nftDataPromises.push(promise);
}
const nfts = await Promise.all(nftDataPromises);
return Response.json({ nfts });
} catch (error) {
console.error('Failed to fetch NFTs:', error);
return Response.json({ error: 'Fetch failed' }, { status: 500 });
}
}
这里有个坑:直接循环调用 RPC 在 NFT 数量多时确实慢。在生产环境中,你应该考虑让合约本身返回批量数据,或者使用 The Graph 这类索引服务。我这里为了演示核心流程,先采用简单循环。
2. 客户端状态与实时更新
服务端提供了初始数据,但购买、挂单等交互后的实时更新必须在客户端处理。我创建了一个客户端组件 NftList,它接收服务端的初始数据,并负责管理动态状态。
实时更新的核心是 监听链上事件。wagmi v2 提供了 useWatchContractEvent 钩子,但直接用在列表组件里会导致每个 NFT 卡片都创建一个监听器,性能极差。我的方案是:在父级组件只监听市场合约的全局事件。
// components/nft-list.tsx
'use client';
import { useEffect, useState } from 'react';
import { useWatchContractEvent } from 'wagmi';
import { NftCard } from './nft-card';
// 市场合约 ABI 事件片段
const MARKET_EVENT_ABI = [
{
type: 'event',
name: 'ListingUpdated',
inputs: [
{ indexed: true, name: 'tokenId', type: 'uint256' },
{ indexed: false, name: 'seller', type: 'address' },
{ indexed: false, name: 'price', type: 'uint256' },
{ indexed: false, name: 'isActive', type: 'bool' },
],
},
] as const;
interface NftListProps {
initialNfts: Array<{
tokenId: number;
tokenURI: string;
listing: [string, bigint, boolean];
}>;
}
export function NftList({ initialNfts }: NftListProps) {
// 使用服务端数据初始化状态
const [nfts, setNfts] = useState(initialNfts);
// 关键:监听全局的 ListingUpdated 事件
useWatchContractEvent({
address: process.env.NEXT_PUBLIC_MARKET_CONTRACT as `0x${string}`,
abi: MARKET_EVENT_ABI,
eventName: 'ListingUpdated',
onLogs(logs) {
console.log('ListingUpdated logs:', logs);
// 当事件触发时,更新对应 NFT 的挂单信息
logs.forEach((log) => {
const { tokenId, price, isActive } = log.args;
if (tokenId !== undefined) {
setNfts((prev) =>
prev.map((nft) =>
nft.tokenId === Number(tokenId)
? {
...nft,
listing: [log.args.seller || '0x', price || 0n, isActive || false],
}
: nft
)
);
}
});
},
});
// 一个手动刷新函数,用于在交易确认后主动触发(作为兜底)
const refreshData = async () => {
const res = await fetch('/api/nfts');
const data = await res.json();
if (data.nfts) setNfts(data.nfts);
};
return (
<div>
<button onClick={refreshData} className="mb-4 p-2 bg-gray-200 rounded">
手动刷新数据
</button>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{nfts.map((nft) => (
<NftCard key={nft.tokenId} nft={nft} onActionSuccess={refreshData} />
))}
</div>
</div>
);
}
注意这个细节:useWatchContractEvent 的回调函数中,log.args 的类型可能是 undefined,必须做防御性判断,否则 TypeScript 会报错,运行时也可能崩溃。
3. 交易交互与乐观更新
用户点击“购买”时,如果等到交易上链确认(可能十几秒)才更新 UI,体验会很差。我采用了 乐观更新(Optimistic Update) 的策略:先立即更新本地状态,假设交易会成功;如果交易失败,再回滚状态。
// components/nft-card.tsx
'use client';
import { useState } from 'react';
import { useAccount, useWriteContract, useWaitForTransactionReceipt } from 'wagmi';
import { parseEther } from 'viem';
interface NftCardProps {
nft: {
tokenId: number;
tokenURI: string;
listing: [string, bigint, boolean];
};
onActionSuccess?: () => void;
}
export function NftCard({ nft, onActionSuccess }: NftCardProps) {
const { address } = useAccount();
const [isUpdating, setIsUpdating] = useState(false);
const { data: hash, writeContract, error } = useWriteContract();
const { isLoading: isConfirming } = useWaitForTransactionReceipt({ hash });
const [seller, price, isActive] = nft.listing;
const handleBuy = async () => {
if (!address || !isActive) return;
setIsUpdating(true); // 开始乐观更新
// 这里可以立即调用父组件传递的回调,或者用状态管理更新本地列表
// 为了简化,我们假设父组件会通过事件监听更新,这里只处理自身加载状态
try {
writeContract({
address: process.env.NEXT_PUBLIC_MARKET_CONTRACT as `0x${string}`,
abi: [
{
name: 'buyToken',
type: 'function',
stateMutability: 'payable',
inputs: [{ name: 'tokenId', type: 'uint256' }],
outputs: [],
},
] as const,
functionName: 'buyToken',
args: [BigInt(nft.tokenId)],
value: price,
});
} catch (err) {
console.error('Buy failed:', err);
setIsUpdating(false); // 回滚乐观更新
}
};
// 交易确认后的处理
useEffect(() => {
if (hash && !isConfirming) {
console.log('Transaction confirmed!');
setIsUpdating(false);
onActionSuccess?.(); // 通知父组件刷新数据
}
}, [hash, isConfirming, onActionSuccess]);
return (
<div className="border p-4 rounded-lg shadow">
<img src={`https://ipfs.io/ipfs/${nft.tokenURI.split('://')[1]}`} alt={`NFT ${nft.tokenId}`} className="w-full h-48 object-cover rounded" />
<div className="mt-2">
<p className="font-bold">Token ID: {nft.tokenId}</p>
<p>Price: {price ? parseFloat(parseEther(price.toString()).toString()).toFixed(4)} ETH</p>
<p>Status: {isActive ? 'For Sale' : 'Not Listed'}</p>
</div>
{isActive && address !== seller && (
<button
onClick={handleBuy}
disabled={isUpdating || isConfirming}
className={`mt-2 w-full py-2 rounded ${isUpdating || isConfirming ? 'bg-gray-400' : 'bg-blue-500 hover:bg-blue-600 text-white'}`}
>
{isUpdating || isConfirming ? 'Processing...' : 'Buy Now'}
</button>
)}
{error && <p className="text-red-500 text-sm mt-1">Error: {error.message}</p>}
</div>
);
}
这里有个大坑:乐观更新时,你更新的状态必须与链上最终状态一致。比如购买后,NFT 的卖家会变,挂单状态会变为 false。如果只是简单地把 isActive 设为 false,但卖家地址没变,就会与链上数据不一致。最稳妥的方式是,在交易发送后,立即用事件监听来更新,或者等确认后触发一次数据重拉。
4. 页面集成与配置
最后,将服务端数据获取和客户端组件在页面中组装起来。页面是服务端组件,它获取数据并传递给客户端组件。
// app/page.tsx
import { NftList } from '@/components/nft-list';
async function getInitialNfts() {
// 在构建时或请求时从 API 路由获取数据
const res = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/nfts`, {
// 根据需求配置缓存
// next: { revalidate: 60 }, // ISR: 每60秒重新验证
cache: 'no-store', // 每次请求都获取最新数据
});
if (!res.ok) {
throw new Error('Failed to fetch NFTs');
}
return res.json();
}
export default async function HomePage() {
const data = await getInitialNfts();
return (
<main className="container mx-auto p-4">
<h1 className="text-3xl font-bold mb-6">NFT Marketplace</h1>
{/* 将服务端数据作为 prop 传递给客户端组件 */}
<NftList initialNfts={data.nfts || []} />
</main>
);
}
同时,需要在项目根目录配置 wagmi 的 Provider。注意 Next.js 14 App Router 中,Provider 必须是客户端组件。
// app/providers.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { WagmiProvider, createConfig, http } from 'wagmi';
import { base } from 'wagmi/chains';
import { injected } from 'wagmi/connectors';
const queryClient = new QueryClient();
const config = createConfig({
chains: [base],
connectors: [injected()],
transports: {
[base.id]: http(process.env.NEXT_PUBLIC_RPC_URL),
},
});
export function Providers({ children }: { children: React.ReactNode }) {
return (
<WagmiProvider config={config}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</WagmiProvider>
);
}
// app/layout.tsx
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import { Providers } from './providers';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: 'NFT Marketplace',
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={inter.className}>
<Providers>{children}</Providers>
</body>
</html>
);
}
完整代码结构
项目的主要文件结构如下:
my-nft-marketplace/
├── app/
│ ├── api/
│ │ └── nfts/
│ │ └── route.ts # 服务端 API,获取初始 NFT 数据
│ ├── layout.tsx # 根布局,包含 Providers
│ ├── page.tsx # 主页(服务端组件)
│ └── providers.tsx # Wagmi & React Query Provider
├── components/
│ ├── nft-list.tsx # NFT 列表客户端组件(核心状态与事件监听)
│ └── nft-card.tsx # 单个 NFT 卡片组件(交易交互)
├── .env.local # 环境变量(合约地址、RPC URL)
└── package.json
踩坑记录
-
NEXT_PUBLIC_变量在服务端为undefined:我一开始把合约地址放在.env.local但没加NEXT_PUBLIC_前缀,导致在服务端 API 路由中读取不到。解决:确保所有需要在浏览器和服务端共享的变量都以NEXT_PUBLIC_开头。 -
useWatchContractEvent监听不到事件:我一开始把监听器放在NftCard组件里,结果组件卸载时监听器也被移除了,而且重复创建。解决:将全局事件监听提升到父组件(NftList),并确保合约地址和 ABI 正确。 -
BigInt 序列化错误:从服务端 API 返回的数据中包含
bigint类型的价格,直接JSON.stringify会报错。解决:在服务端将bigint转换为字符串,或者在客户端使用 Viem 的parseEther等工具处理。我在 API 路由中返回了原始数据,在组件内处理转换。 -
交易确认后状态不同步:用户购买成功后,列表里该 NFT 的
isActive状态变了,但卖家地址还是旧的。解决:这是因为我的乐观更新逻辑不完整。最终我选择依赖useWatchContractEvent的事件监听作为主要更新源,手动刷新作为兜底,保证了数据与链上严格同步。
小结
这套方案的核心收获是 “服务端初始化,客户端维护动态状态,事件驱动更新”。它既保证了首屏加载速度,又实现了流畅的实时交互。对于更复杂的场景,比如分页、筛选、多链支持,还可以在此基础上扩展,例如引入状态管理库来集中管理 NFT 数据,或者用 The Graph 替代批量 RPC 调用。Web3 前端开发就是这样,每一个需求都在逼你更深入地理解数据流和链上链下的同步逻辑。