普通视图

发现新文章,点击刷新页面。
今天 — 2026年4月5日技术

项目越做越乱?多半是缺少一点点规范

作者 LeonGao
2026年4月5日 11:31

一、误区:混乱是项目变复杂的必然结果

很多团队会这样安慰自己:

  • “项目大了,本来就会乱”
  • “人多了,不可能统一”
  • “先跑起来,规范以后再说”

但现实是:

👉 不是项目变复杂,而是“复杂度没有被约束”

没有规范的项目,演变路径几乎一致:

文件乱放 → 命名混乱 → 提交不可读 → 分支失控 → 不敢改代码


二、工程化视角:规范的本质是“降低协作成本”

规范不是为了“好看”,而是解决三个问题:

  1. 别人能不能快速理解你的代码
  2. 多人协作会不会互相干扰
  3. 问题能不能快速定位和回滚

所以判断规范是否有价值的标准是:

有没有让协作更顺畅,而不是更繁琐


三、最小但有效的 4 个规范(核心)

不需要一堆文档,只要这 4 个做到位,项目就会明显“干净”。


1️⃣ 目录结构:让代码“能被猜到在哪”

很多项目的问题:

  • utils 到处都是
  • components 什么都放
  • 页面 / 逻辑 / 状态混在一起

👉 结果:找代码靠“记忆”


✅ 极简结构(通用前端)

src/
├── pages/        # 页面(路由级)
├── components/   # 通用组件
├── features/     # 按业务划分(推荐)
│   └── user/
│       ├── api.ts
│       ├── store.ts
│       ├── components/
│       └── hooks.ts
├── utils/        # 真正通用的工具
├── services/     # 请求封装

🎯 核心规则

  • 优先按“业务”分,而不是按“技术类型”分
  • 一个功能的代码尽量放在一起

👉 本质:

让“猜路径”成为可能


2️⃣ Git 提交:让历史“可读、可回滚”

常见问题:

  • fix
  • update
  • 改了一下
  • 一次提交改 20 个文件

👉 结果:Git 记录毫无价值


✅ 极简提交规范(够用版)

feat: 新增用户登录功能
fix: 修复登录接口报错
refactor: 重构用户模块结构
style: 调整样式

🎯 核心规则

  1. 一句话说明“做了什么”
  2. 只做一件事(一个提交)
  3. 可回滚(不要把多个改动混一起)

👉 本质:

Git 是你的“时间机器”,不是备份工具


3️⃣ 分支管理:避免“代码互相污染”

常见混乱:

  • 所有人都在 main 上开发
  • 分支命名随意
  • 合并冲突频繁

✅ 极简分支策略(小团队够用)

main        # 稳定可发布
dev         # 日常开发
feature/*   # 功能开发
fix/*       # bug 修复

🎯 核心规则

  • 不在 main 上直接开发
  • 一个功能一个分支
  • 开发完成再合并

👉 示例:

feature/login
fix/user-api-error

👉 本质:

隔离变化,减少冲突


4️⃣ Code Review:保证代码“可控”

很多团队的问题:

  • 不 review,直接合
  • review 只看“有没有 bug”
  • review 变成“挑刺大会”

✅ 极简 Review 规则(高性价比)

只看 3 件事:


① 结构是否清晰

  • 文件是否放对位置?
  • 有没有乱放 utils?

② 命名是否可读

  • 变量名是不是“看名知意”?
  • 有没有 a、b、temp 这种?

③ 有没有明显重复代码

  • 是否可以抽复用?
  • 是否在 copy 改?

🎯 核心原则

Review 不是找错,而是“保证长期可维护”


四、真正的分水岭:规范不是“多”,而是“持续执行”

很多团队失败在:

  • 写了一堆规范
  • 没人执行
  • 三天后全部失效

👉 规范不是“文档”,而是:

每天都在发生的行为约束


五、落地建议(非常关键)


1️⃣ 从“最小规则”开始(不要贪多)

只推这 4 个:

  • 目录结构
  • 提交规范
  • 分支规则
  • Review 三点

2️⃣ 用工具“强制执行”

而不是靠自觉:

  • commit lint(限制提交信息)
  • lint / format(统一代码风格)

3️⃣ 先统一“新代码”,再慢慢治理旧代码

👉 不要一开始就全量重构


六、总结一句话

项目的混乱,不是因为人多,而是因为“没有约束变化的规则”。

规范的价值,不在于“看起来专业”,
而在于:

👉 让项目在持续变化中,依然保持可控

告别过度工程:菜鸟前端亲证,浏览器早已帮你搞定这 9 件事

作者 悟空瞎说
2026年4月5日 10:09

作为一名拥有 14 年前端开发经验的菜鸟,我亲历了前端行业从刀耕火种的 jQuery 时代,到框架百花齐放的工程化时代,再到如今原生 API 日趋完善的现代化时代。在漫长的开发生涯中,我见过太多团队陷入过度工程化的陷阱:为了实现一个简单功能,引入数十 KB 的第三方库;手写大量冗余 JS 代码,解决浏览器早已原生支持的问题;盲目追求自定义实现,忽略平台原生能力的稳定性与兼容性。

这篇文章,我将结合 14 年踩坑、重构、性能优化的实战经验,拆解 9 个前端高频场景 —— 这些需求你每天都可能遇到,而浏览器原生 API/CSS 特性早已给出完美解,帮你告别冗余代码、减少依赖、提升性能与可维护性。全文无抄袭,全部基于实战经验重构,带你回归前端本质,用好浏览器这座 “宝藏库”。

一、非关键任务延迟执行:requestIdleCallback,告别 setTimeout 黑科技

刚入行时,我们处理非关键任务(如用户行为埋点、日志上报、次要资源预加载),几乎都用setTimeout(fn, 0)这种黑科技。原理是利用浏览器事件循环,把任务塞进宏队列末尾,尽量不阻塞主线程,但这种方式完全不受浏览器调度控制—— 页面渲染繁忙时,它照样执行,导致卡顿、交互延迟,尤其在移动端老机型上问题频发。

后来我做电商网站,商品列表页同时渲染上百个组件,还要上报滚动、点击埋点,用setTimeout导致页面滑动掉帧,LCP(最大内容绘制)指标严重超标。直到发现requestIdleCallback这个原生 API,才彻底解决问题。

requestIdleCallback的核心逻辑是:只在浏览器空闲时执行指定任务,完全贴合浏览器渲染周期,不会阻塞关键渲染路径、用户交互(点击、输入、滚动)。它会监听浏览器主线程状态,当主线程空闲(无重排重绘、无用户操作)时,才触发回调,完美适配非紧急、非阻塞的任务。

14 年经验实战用法

javascript

运行

// 非关键埋点:用户滚动行为统计
function trackUserScrollBehavior() {
  const scrollInfo = {
    scrollTop: document.documentElement.scrollTop,
    scrollHeight: document.documentElement.scrollHeight,
    timestamp: Date.now()
  };
  // 异步上报,不阻塞主线程
  navigator.sendBeacon('/api/track/scroll', JSON.stringify(scrollInfo));
}

// 优雅降级:兼容不支持的浏览器(如旧版Safari)
if ('requestIdleCallback' in window) {
  // 空闲时执行,支持超时配置(确保任务最终会执行)
  requestIdleCallback(trackUserScrollBehavior, { timeout: 2000 });
} else {
  // 降级方案,仍优先不阻塞
  setTimeout(trackUserScrollBehavior, 30);
}

老兵关键提醒

  1. 适用场景:数据埋点、日志上报、非核心资源预加载、后台计算、图片离线生成等非紧急任务;绝对不要用于动画、交互响应等关键任务。
  2. 兼容性:现代浏览器全覆盖,Safari 15.4 + 支持,旧版需降级。
  3. 性能收益:我曾用它优化电商首页,埋点逻辑不再阻塞渲染,页面滑动帧率从 35fps 提升至 60fps,LCP 缩短 200ms,这就是原生能力的力量。

二、父级元素聚焦样式::focus-within,干掉冗余 JS 聚焦监听

早年做表单开发,想实现 “输入框聚焦时,父级容器高亮边框”,标准解法是:给输入框绑定focusblur事件,通过 JS 动态添加 / 移除父级样式。代码量大、容易漏绑事件、表单字段多了还会出现样式不同步 bug,维护成本极高。

直到 CSS :focus-within伪类出现,我才意识到:十几行 JS 能解决的事,一行 CSS 就搞定。这个伪类的作用是:当子元素处于聚焦状态时,选中父级元素,无需任何 JS 逻辑,纯 CSS 实现,无 bug、无性能损耗。

14 年经验实战用法

css

/* 基础表单容器样式 */
.form-item {
  border: 1px solid #e5e7eb;
  padding: 12px 16px;
  border-radius: 8px;
  transition: border-color 0.2s ease;
  margin-bottom: 16px;
}

/* 子元素聚焦时,父级容器样式变化 */
.form-item:focus-within {
  border-color: #3b82f6;
  box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
}

/* 输入框样式,去除默认聚焦轮廓 */
.form-item input {
  border: none;
  outline: none;
  width: 100%;
  font-size: 14px;
}

html

预览

<div class="form-item">
  <input type="text" placeholder="请输入用户名" />
</div>
<div class="form-item">
  <input type="password" placeholder="请输入密码" />
</div>

老兵关键提醒

  1. 兼容性全平台完美支持,IE 除外(如今前端基本放弃 IE),无需降级。
  2. 扩展场景:不仅适用于输入框,还适用于下拉框、按钮、富文本编辑器等所有可聚焦元素,复杂表单、搜索框、登录页都能通用。
  3. 维护优势:纯 CSS 样式与行为分离,后期修改样式无需改动 JS,大幅降低维护成本,这是工程化开发的核心原则。

三、网络状态监听:navigator.onLine,PWA 离线体验原生实现

早年做 PWA(渐进式 Web 应用)时,离线状态处理是一大难题。为了监听用户断网、联网,很多团队引入第三方网络检测库,或手写轮询请求接口判断网络状态,不仅增加包体积,还会产生无效请求,耗电、耗流量,检测精度还低。

其实浏览器原生提供了navigator.onLine属性,配合online/offline事件,就能精准监听网络状态变化,无需任何第三方依赖,轻量、精准、高效。

14 年经验实战用法

javascript

运行

// 初始网络状态判断
const initNetworkStatus = () => {
  if (!navigator.onLine) {
    showOfflineTip();
    // 离线数据缓存(IndexedDB/localStorage)
    cacheOfflineData();
  }
};

// 显示离线提示
function showOfflineTip() {
  const tip = document.createElement('div');
  tip.className = 'offline-tip';
  tip.textContent = '网络连接断开,请检查网络设置';
  document.body.appendChild(tip);
  setTimeout(() => tip.remove(), 3000);
}

// 监听离线事件
window.addEventListener('offline', () => {
  showOfflineTip();
  // 离线逻辑:暂停请求、缓存用户输入
  pauseAsyncRequest();
});

// 监听联网事件
window.addEventListener('online', () => {
  const tip = document.createElement('div');
  tip.className = 'online-tip';
  tip.textContent = '网络已恢复,正在同步数据';
  document.body.appendChild(tip);
  setTimeout(() => tip.remove(), 3000);
  // 联网逻辑:重新请求、同步离线缓存数据
  syncOfflineData();
});

// 初始化
initNetworkStatus();

老兵关键提醒

  1. 核心误区navigator.onLinetrue≠后端服务可用,仅代表设备有网络连接,需结合接口异常处理(try/catch、axios 拦截器)使用。
  2. 实战场景:PWA 应用、表单离线编辑、弱网环境优化、数据自动同步,都是高频使用场景。
  3. 兼容性:所有现代浏览器全覆盖,移动端、桌面端均稳定支持,是 PWA 开发必备原生 API。

四、流畅动画实现:requestAnimationFrame,告别 setInterval 卡顿

早年做前端动画,几乎都用setInterval固定时间间隔修改 DOM 样式,比如setInterval(() => { el.style.left = x + 'px' }, 16),看似模拟 60fps 帧率,实则问题极大:setInterval 与浏览器渲染周期不同步,容易出现丢帧、卡顿、闪烁,尤其在页面繁忙时,动画效果惨不忍睹。

requestAnimationFrame是浏览器专为动画设计的原生 API,与浏览器重绘周期完全同步,浏览器会在每次重绘前执行回调,确保动画流畅,且页面隐藏时自动暂停,节省性能。这是我 14 年开发中,优化动画性能的首选方案。

14 年经验实战用法

javascript

运行

// 获取动画元素
const box = document.querySelector('.animate-box');
let offset = 0;

// 动画执行函数
function animateBox(timestamp) {
  // 计算位移,使用transform替代left,避免重排
  offset = (offset + 2) % 300;
  box.style.transform = `translateX(${offset}px)`;
  // 循环执行动画
  requestAnimationFrame(animateBox);
}

// 启动动画
requestAnimationFrame(animateBox);

css

.animate-box {
  width: 50px;
  height: 50px;
  background: #3b82f6;
  border-radius: 8px;
  /* 开启硬件加速 */
  will-change: transform;
}

老兵关键提醒

  1. 性能核心必须配合 transform/opacity 使用,这两个属性不会触发浏览器重排,动画性能极致优化。
  2. 优势:页面隐藏时自动暂停,减少 CPU / 内存消耗;无需计算时间间隔,浏览器自动适配帧率。
  3. 兼容性全浏览器支持,从 IE10 到现代浏览器,无任何兼容问题,是前端动画标准方案。

五、组件自适应:容器查询(Container Queries),终结视口媒体查询局限

早年做响应式开发,只能用@media媒体查询,基于整个视口宽度调整样式。但实际开发中,我们常需要基于组件自身容器宽度调整样式 —— 比如卡片组件在侧边栏窄容器、首页宽容器中展示不同布局,媒体查询完全无法实现,只能手写 JS 监听容器尺寸,或写多套样式强行适配,代码冗余、维护困难。

如今 CSS 容器查询彻底解决这个问题,让组件真正实现自适应,不依赖视口,只看自身容器,是组件化开发的革命性特性。作为常年开发组件库的老兵,我认为这是 CSS 近几年最实用的更新。

14 年经验实战用法

css

/* 定义容器:开启行内尺寸查询 */
.card-container {
  container-type: inline-size;
  container-name: card;
}

/* 卡片基础样式 */
.card {
  padding: 16px;
  border: 1px solid #e5e7eb;
  border-radius: 8px;
  display: flex;
  flex-direction: column;
  gap: 12px;
}

/* 容器宽度≥400px时,修改卡片布局 */
@container card (min-width: 400px) {
  .card {
    flex-direction: row;
    align-items: center;
  }
}

/* 容器宽度≥600px时,进一步优化 */
@container card (min-width: 600px) {
  .card {
    padding: 24px;
    gap: 20px;
  }
}

html

预览

<!-- 窄容器:卡片垂直布局 -->
<div class="card-container" style="width: 300px;">
  <div class="card">
    <img src="cover.jpg" alt="封面" />
    <div class="card-content">内容</div>
  </div>
</div>

<!-- 宽容器:卡片水平布局 -->
<div class="card-container" style="width: 500px;">
  <div class="card">
    <img src="cover.jpg" alt="封面" />
    <div class="card-content">内容</div>
  </div>
</div>

老兵关键提醒

  1. 兼容性:现代浏览器(Chrome 105+、Firefox 110+、Safari 16+)全覆盖,旧版浏览器可通过降级样式适配。
  2. 组件化价值:让组件真正可移植、自包含,不依赖页面环境,是设计系统、组件库开发必备特性。
  3. 最佳实践:优先使用inline-size(行内尺寸),适配水平响应式场景,这是最常用的配置。

六、安全随机 ID:crypto.getRandomValues,远离 Math.random 冲突风险

早年开发中,生成临时 ID、会话标识、订单后缀,几乎都用Math.random().toString(36).slice(2)这种简易方式。但Math.random伪随机数,熵值低,存在重复风险,尤其在高并发、大批量生成 ID 时,冲突概率极高,线上曾出现过用户 ID 重复、购物车数据错乱的严重 bug。

浏览器原生crypto.getRandomValues提供加密级安全随机数,熵值高、无规律、重复概率极低,是生成安全随机 ID 的标准方案,比Math.random可靠百倍。

14 年经验实战用法

javascript

运行

/**
 * 生成安全随机ID
 * @param {number} length 字节长度,默认8字节
 * @returns {string} 十六进制随机字符串
 */
function generateSecureId(length = 8) {
  // 创建无符号字节数组
  const bytes = new Uint8Array(length);
  // 获取加密级安全随机数
  crypto.getRandomValues(bytes);
  // 转换为十六进制字符串
  return Array.from(bytes)
    .map(byte => byte.toString(16).padStart(2, '0'))
    .join('');
}

// 生成用户临时ID
const tempUserId = generateSecureId();
console.log('安全临时ID:', tempUserId);

// 生成会话标识
const sessionId = generateSecureId(16);
console.log('安全会话ID:', sessionId);

老兵关键提醒

  1. 进阶方案:若需要标准 UUID,直接用crypto.randomUUID(),一行代码生成 UUID v4,兼容性极佳,是现代开发首选。
  2. 适用场景:用户临时 ID、会话标识、订单号、缓存键、加密盐值等禁止重复的场景。
  3. 兼容性:所有现代浏览器全覆盖,移动端、桌面端、WebWorker 中均稳定支持。

七、原生模态框:标签,干掉第三方模态框库冗余依赖

早年开发模态框(弹窗),必须引入第三方库(如 Bootstrap Modal、Element UI Dialog),或手写 JS 实现:遮罩层、显示隐藏、焦点管理、点击遮罩关闭、ESC 关闭、无障碍支持…… 代码量巨大,还容易出现焦点错乱、遮罩层穿透、移动端适配问题。

HTML5 原生<dialog>标签彻底解决这个问题,自带遮罩、焦点管理、无障碍支持,几行代码就能实现标准模态框,无需任何第三方依赖,体积轻量、功能完善。

14 年经验实战用法

html

预览

<!-- 原生模态框 -->
<dialog id="confirm-dialog">
  <div class="dialog-content">
    <h3>确认操作</h3>
    <p>确定要提交表单吗?</p>
    <div class="dialog-footer">
      <button onclick="document.getElementById('confirm-dialog').close()">取消</button>
      <button onclick="handleSubmit()">确认提交</button>
    </div>
  </div>
</dialog>

<!-- 触发按钮 -->
<button onclick="document.getElementById('confirm-dialog').showModal()">打开确认弹窗</button>

css

/* 模态框基础样式 */
#confirm-dialog {
  border: none;
  border-radius: 8px;
  padding: 24px;
  width: 90%;
  max-width: 400px;
  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}

/* 遮罩层样式 */
#confirm-dialog::backdrop {
  background: rgba(0, 0, 0, 0.5);
}

.dialog-footer {
  display: flex;
  justify-content: flex-end;
  gap: 12px;
  margin-top: 20px;
}

老兵关键提醒

  1. 核心方法showModal()打开模态框(带遮罩)、close()关闭、returnValue获取返回值,完全满足日常需求。
  2. 无障碍优势:原生支持焦点管理、屏幕阅读器朗读,符合 WCAG 无障碍标准,这是手写模态框很难实现的。
  3. 兼容性:现代浏览器全覆盖,Safari 15.4 + 支持,旧版可通过简单 polyfill 兼容。

八、语音输入:Web Speech API,无需 AI 库实现语音识别

现在很多产品需要语音输入功能,很多团队第一反应是引入transformers.js、百度语音 SDK 等第三方库,增加包体积、依赖外部服务、配置复杂。其实Chromium 内核浏览器(Chrome/Edge)原生支持语音识别 API,简单几行代码就能实现语音转文字,适合内部系统、演示项目、轻量语音场景。

14 年经验实战用法

javascript

运行

// 兼容webkit前缀
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;

if (SpeechRecognition) {
  // 创建语音识别实例
  const recognition = new SpeechRecognition();
  // 设置语言
  recognition.lang = 'zh-CN';
  // 连续识别
  recognition.continuous = false;
  // 临时结果返回
  recognition.interimResults = false;

  // 识别成功回调
  recognition.onresult = (e) => {
    const text = e.results[0][0].transcript;
    console.log('识别结果:', text);
    // 填充到输入框
    document.getElementById('voice-input').value = text;
  };

  // 识别错误回调
  recognition.onerror = (e) => {
    console.error('语音识别错误:', e.error);
    alert('语音识别失败,请重试');
  };

  // 绑定按钮事件
  window.startVoiceInput = () => {
    recognition.start();
  };
} else {
  alert('当前浏览器不支持语音输入,请使用Chrome/Edge浏览器');
}

html

预览

<input type="text" id="voice-input" placeholder="点击按钮语音输入" />
<button onclick="startVoiceInput()">🎤 语音输入</button>

老兵关键提醒

  1. 兼容性:仅 Chromium 内核浏览器支持,Safari/Firefox 暂不支持,生产环境需做好降级提示。
  2. 适用场景:内部管理系统、演示项目、轻量表单输入,不适合强依赖语音功能的核心业务。
  3. 优势零依赖、零成本、无需服务端,纯前端实现,快速满足轻量需求。

九、CSS 特性检测:@supports,优雅适配新特性,避免样式崩溃

前端开发中,我们经常使用 CSS 新特性(如backdrop-filtercontainer-typegap),但旧版浏览器不支持,会导致样式错乱、页面崩溃。早年只能通过 JS 检测浏览器版本,动态添加样式,逻辑复杂、维护困难。

CSS @supports规则完美解决这个问题,纯 CSS 检测浏览器是否支持指定特性,支持则应用新样式,不支持则回退到基础样式,优雅适配新旧浏览器,这是我做跨端兼容的必备技巧。

14 年经验实战用法

css

/* 基础样式,所有浏览器都支持 */
.glass-card {
  background: #ffffff;
  padding: 24px;
  border-radius: 12px;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}

/* 检测支持backdrop-filter时,应用毛玻璃效果 */
@supports (backdrop-filter: blur(10px)) {
  .glass-card {
    background: rgba(255, 255, 255, 0.6);
    backdrop-filter: blur(10px);
    -webkit-backdrop-filter: blur(10px);
    border: 1px solid rgba(255, 255, 255, 0.2);
  }
}

/* 组合条件检测 */
@supports (display: grid) and (container-type: inline-size) {
  .responsive-card {
    display: grid;
    gap: 16px;
  }
}

老兵关键提醒

  1. 语法灵活:支持单特性检测、组合检测(and/or/not),覆盖几乎所有适配场景。
  2. 兼容性:现代浏览器全覆盖,IE 不支持,但 IE 会直接忽略@supports规则,应用基础样式,无兼容性风险。
  3. 实战价值:使用 CSS 新特性时,必须配合 @supports,确保旧版浏览器样式不崩溃,这是跨端兼容的标准实践。

十、14 年前端老兵的核心感悟:别让过度工程化,掩盖原生的力量

写完这 9 个场景,我想分享 14 年开发的核心感悟:前端开发的本质,是用最少的成本、最优的性能,解决用户需求,而不是盲目堆砌技术、引入依赖、手写冗余代码。

浏览器经过数十年迭代,早已不是当年的 “简陋画布”,而是一座蕴藏无数原生能力的宝藏库。我们过度工程化的根源,往往是对原生 API/CSS 特性不熟悉,习惯用旧经验解决新问题,忽略了平台本身的能力。

老兵给前端开发者的 3 条建议

  1. 定期盘点原生能力:每年花时间学习浏览器新特性、新 API,很多第三方库的功能,原生早已实现。
  2. 引入依赖前先问自己:这个功能,浏览器原生能实现吗?能,就优先用原生,减少依赖、降低风险。
  3. 回归本质,拒绝炫技:好的代码不是越复杂越好,而是简单、稳定、易维护,原生方案永远是首选。

库和框架是工具,不是必需品。当你真正吃透浏览器原生能力,会发现:很多你曾经头疼的问题,浏览器早已帮你完美解决。放下过度工程化的执念,用好原生这座宝藏,你的前端开发之路会更轻松、更高效。

用wagmi v2 + viem重构DeFi前端:从连接钱包到读取合约数据的完整踩坑实录

作者 竹林818
2026年4月5日 10:01

背景

上个月,我接手了一个DeFi收益聚合器项目的前端重构任务。这个项目最初是用ethers.js 5.xweb3-react构建的,代码已经运行了两年多。随着项目发展,老架构的问题逐渐暴露:钱包连接逻辑分散在各个组件、多链支持维护困难、类型定义几乎为零。

团队决定迁移到更现代的wagmi v2 + viem技术栈。wagmi的Hooks式API看起来简洁优雅,viem的类型安全也很有吸引力。我本以为这是个“升级依赖”的简单任务,但实际动手才发现,从老模式切换到新范式,中间有太多细节需要重新理解。最大的挑战不是写新代码,而是让新老逻辑在数据流和状态管理上保持一致。

问题分析

我最初的计划很直接:安装wagmiviem@tanstack/react-query(wagmi v2的依赖),然后逐步替换组件中的useWeb3Reactethers调用。

第一个拦路虎很快就出现了:钱包连接状态频繁丢失。

在旧版中,用户连接钱包后,accountchainId等信息通过React Context全局可用。但在新版本中,我按照官方示例配置了WagmiProvider后,发现useAccount()返回的address时不时会变成undefined,即使MetaMask明明还连接着。

我排查的方向:

  1. 检查Provider配置:确认了config对象正确传递给了WagmiProvider
  2. 检查连接器顺序:按照文档把injected连接器放在第一位
  3. 检查React Query配置:确认了缓存时间设置

后来通过仔细阅读wagmi的源码和issue,才发现问题核心:wagmi v2默认的行为更“谨慎”了。它不会永久保持连接状态,而是需要应用层明确处理连接持久化。同时,@tanstack/react-query的缓存行为也会影响状态同步。

另一个头疼的问题是多链切换。旧版中我们手动处理链切换逻辑,但wagmi提供了useSwitchChain这样的高级Hook。当我尝试切换到Polygon链时,控制台没有报错,但交易始终在以太坊主网发送。这里涉及到viem的Transport配置和wagmi的chain配置对齐问题。

核心实现

1. 正确配置Wagmi Provider与连接持久化

经过调试,我找到了wagmi v2连接状态不稳定的主要原因:缺少状态持久化和正确的存储配置。下面是最终的配置方案:

// src/providers/WagmiProvider.tsx
import { createConfig, http, WagmiProvider as WagmiProviderCore } from 'wagmi'
import { mainnet, polygon, arbitrum } from 'wagmi/chains'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { injected } from 'wagmi/connectors'
import { ReactNode } from 'react'

// 创建QueryClient实例,这是wagmi v2的强制依赖
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      // 这里有个坑:缓存时间不能太短,否则频繁重连
      gcTime: 1000 * 60 * 60 * 24, // 24小时
      staleTime: 1000 * 60 * 5, // 5分钟
      retry: 1
    }
  }
})

// 配置支持的链
const supportedChains = [mainnet, polygon, arbitrum]

// 创建wagmi配置
const config = createConfig({
  chains: supportedChains,
  transports: {
    // 这里必须为每个链配置transport,否则会报错
    [mainnet.id]: http(),
    [polygon.id]: http('https://polygon-rpc.com'),
    [arbitrum.id]: http('https://arb1.arbitrum.io/rpc'),
  },
  connectors: [
    injected(),
    // 可以添加其他连接器如walletConnect
  ],
  // 关键配置:启用状态存储
  ssr: false, // 如果不是SSR应用,设为false
})

export function WagmiProvider({ children }: { children: ReactNode }) {
  return (
    <QueryClientProvider client={queryClient}>
      <WagmiProviderCore config={config}>
        {children}
      </WagmiProviderCore>
    </QueryClientProvider>
  )
}

关键点

  • transports配置必须为每个链提供RPC端点,否则跨链操作会失败
  • gcTime(原cacheTime)设置足够长,避免频繁重连
  • 通过ssr: false明确禁用SSR,避免hydration问题

2. 实现稳健的钱包连接与状态管理

连接钱包的UI组件需要处理更多边缘情况。我创建了一个WalletConnector组件:

// src/components/WalletConnector.tsx
import { useAccount, useConnect, useDisconnect, useChainId } from 'wagmi'
import { useEffect, useState } from 'react'

export function WalletConnector() {
  const { address, isConnected, isConnecting } = useAccount()
  const { connect, connectors, error: connectError } = useConnect()
  const { disconnect } = useDisconnect()
  const chainId = useChainId()
  
  const [mounted, setMounted] = useState(false)
  
  // 解决hydration不匹配问题
  useEffect(() => {
    setMounted(true)
  }, [])
  
  if (!mounted) {
    return <div>Loading...</div>
  }
  
  if (!isConnected) {
    return (
      <div>
        <h3>Connect Wallet</h3>
        {connectors.map((connector) => (
          <button
            key={connector.uid}
            onClick={() => connect({ connector })}
            disabled={isConnecting}
          >
            {connector.name}
            {isConnecting && ' Connecting...'}
          </button>
        ))}
        {connectError && (
          <div style={{ color: 'red' }}>
            Error: {connectError.message}
          </div>
        )}
      </div>
    )
  }
  
  return (
    <div>
      <p>Connected: {address?.slice(0, 6)}...{address?.slice(-4)}</p>
      <p>Chain ID: {chainId}</p>
      <button onClick={() => disconnect()}>
        Disconnect
      </button>
    </div>
  )
}

注意这个细节mounted状态是为了解决Next.js等SSR框架下的hydration警告。wagmi的状态在服务端和客户端可能不一致。

3. 多链切换与网络状态监听

DeFi应用经常需要跨链操作。我实现了一个链切换组件,并添加了网络状态监听:

// src/components/ChainSwitcher.tsx
import { useSwitchChain, useAccount } from 'wagmi'
import { mainnet, polygon, arbitrum } from 'wagmi/chains'

const chainConfigs = {
  [mainnet.id]: { name: 'Ethereum', color: '#627EEA' },
  [polygon.id]: { name: 'Polygon', color: '#8247E5' },
  [arbitrum.id]: { name: 'Arbitrum', color: '#28A0F0' },
}

export function ChainSwitcher() {
  const { chainId } = useAccount()
  const { switchChain, isPending, error } = useSwitchChain()
  
  // 监听网络切换
  useEffect(() => {
    if (typeof window !== 'undefined' && window.ethereum) {
      const handleChainChanged = (newChainId: string) => {
        // MetaMask会重新加载页面,但其他钱包可能不会
        console.log('Chain changed to:', newChainId)
      }
      
      window.ethereum.on('chainChanged', handleChainChanged)
      
      return () => {
        window.ethereum.removeListener('chainChanged', handleChainChanged)
      }
    }
  }, [])
  
  return (
    <div>
      <p>Current chain: {chainId ? chainConfigs[chainId]?.name : 'Unknown'}</p>
      <div style={{ display: 'flex', gap: '8px' }}>
        {Object.keys(chainConfigs).map((id) => (
          <button
            key={id}
            onClick={() => switchChain({ chainId: Number(id) })}
            disabled={isPending || chainId === Number(id)}
            style={{
              backgroundColor: chainConfigs[Number(id)].color,
              color: 'white'
            }}
          >
            {chainConfigs[Number(id)].name}
            {isPending && ' Switching...'}
          </button>
        ))}
      </div>
      {error && (
        <div style={{ color: 'red', marginTop: '8px' }}>
          Switch failed: {error.message}
        </div>
      )}
    </div>
  )
}

这里有个坑switchChain可能因为钱包未添加目标链而失败。在生产环境中,需要添加useAddChain Hook来动态添加链配置。

4. 读取合约数据:从ethers.js到viem的迁移

这是最核心的部分。旧代码中读取ERC20余额是这样的:

// 旧代码 - ethers.js方式
const contract = new ethers.Contract(tokenAddress, ERC20_ABI, provider)
const balance = await contract.balanceOf(account)
const decimals = await contract.decimals()
const formattedBalance = ethers.utils.formatUnits(balance, decimals)

迁移到viem后,需要改用useReadContract Hook:

// src/hooks/useTokenBalance.ts
import { useReadContract, useAccount } from 'wagmi'
import { erc20Abi } from 'viem'

export function useTokenBalance(tokenAddress: `0x${string}`) {
  const { address, chainId } = useAccount()
  
  // 读取余额
  const { 
    data: balance, 
    isLoading, 
    error, 
    refetch 
  } = useReadContract({
    abi: erc20Abi,
    address: tokenAddress,
    functionName: 'balanceOf',
    args: address ? [address] : undefined,
    chainId, // 关键:指定链ID,确保读取正确的链上数据
    query: {
      enabled: !!address, // 只有连接钱包时才查询
      // 这里有个重要细节:refetchInterval
      refetchInterval: 10000, // 每10秒自动刷新
    }
  })
  
  // 读取代币小数位
  const { data: decimals } = useReadContract({
    abi: erc20Abi,
    address: tokenAddress,
    functionName: 'decimals',
    chainId,
    query: {
      enabled: !!address,
    }
  })
  
  // 格式化余额
  const formattedBalance = React.useMemo(() => {
    if (!balance || !decimals) return '0'
    // viem的格式化方式
    const divisor = 10n ** BigInt(decimals)
    const integerPart = balance / divisor
    const fractionalPart = balance % divisor
    return `${integerPart}.${fractionalPart.toString().padStart(decimals, '0')}`
  }, [balance, decimals])
  
  return {
    balance,
    formattedBalance,
    isLoading,
    error,
    refetch
  }
}

关键变化

  1. useReadContract自动处理缓存、重试和错误状态
  2. 必须指定chainId,否则可能读取到错误链的数据
  3. enabled选项控制查询时机,避免不必要的RPC调用
  4. viem使用bigint而不是ethers.BigNumber

5. 发送交易:处理用户确认和状态反馈

发送交易是DeFi应用的核心交互。我创建了一个发送ERC20转账的Hook:

// src/hooks/useTransferToken.ts
import { useWriteContract, useWaitForTransactionReceipt } from 'wagmi'
import { erc20Abi } from 'viem'
import { useState } from 'react'

export function useTransferToken() {
  const [isDialogOpen, setIsDialogOpen] = useState(false)
  
  const {
    writeContract,
    data: hash,
    error: writeError,
    isPending: isWriting,
    reset: resetWrite
  } = useWriteContract()
  
  // 等待交易确认
  const {
    isLoading: isConfirming,
    isSuccess: isConfirmed,
    error: confirmError
  } = useWaitForTransactionReceipt({
    hash,
    // 这里可以配置确认数
    confirmations: 1,
  })
  
  const transfer = async (
    tokenAddress: `0x${string}`,
    to: `0x${string}`,
    amount: bigint
  ) => {
    try {
      setIsDialogOpen(true)
      
      writeContract({
        abi: erc20Abi,
        address: tokenAddress,
        functionName: 'transfer',
        args: [to, amount],
      })
    } catch (error) {
      console.error('Transfer failed:', error)
      setIsDialogOpen(false)
    }
  }
  
  // 交易完成后重置状态
  React.useEffect(() => {
    if (isConfirmed || confirmError) {
      const timer = setTimeout(() => {
        setIsDialogOpen(false)
        resetWrite()
      }, 3000)
      
      return () => clearTimeout(timer)
    }
  }, [isConfirmed, confirmError, resetWrite])
  
  return {
    transfer,
    hash,
    isDialogOpen,
    isWriting,
    isConfirming,
    isConfirmed,
    error: writeError || confirmError
  }
}

用户体验优化:这个Hook管理了完整的交易生命周期——从用户点击、钱包确认、链上等待到最终状态反馈。useWaitForTransactionReceipt会自动轮询交易收据,无需手动实现。

完整代码示例

下面是一个整合了上述所有功能的简化版DeFi前端组件:

// src/App.tsx
import { WagmiProvider } from './providers/WagmiProvider'
import { WalletConnector } from './components/WalletConnector'
import { ChainSwitcher } from './components/ChainSwitcher'
import { useTokenBalance } from './hooks/useTokenBalance'
import { useTransferToken } from './hooks/useTransferToken'

// 示例代币地址(USDT on Ethereum)
const USDT_ADDRESS = '0xdAC17F958D2ee523a2206206994597C13D831ec7'

function DeFiApp() {
  const { address } = useAccount()
  const { formattedBalance, isLoading: isLoadingBalance } = 
    useTokenBalance(USDT_ADDRESS)
  const {
    transfer,
    isWriting,
    isConfirming,
    isConfirmed,
    error: transferError
  } = useTransferToken()
  
  const handleTransfer = () => {
    if (!address) return
    
    // 转账0.1 USDT(USDT有6位小数)
    const amount = 100000n // 0.1 USDT = 100000 wei
    const recipient = '0x742d35Cc6634C0532925a3b844Bc9e90F90a1497' // 示例地址
    
    transfer(USDT_ADDRESS, recipient, amount)
  }
  
  return (
    <div style={{ padding: '20px', maxWidth: '800px', margin: '0 auto' }}>
      <h1>DeFi Dashboard</h1>
      
      <WalletConnector />
      
      {address && (
        <>
          <ChainSwitcher />
          
          <div style={{ marginTop: '20px', padding: '15px', border: '1px solid #ccc' }}>
            <h3>USDT Balance</h3>
            {isLoadingBalance ? (
              <p>Loading balance...</p>
            ) : (
              <p>{formattedBalance} USDT</p>
            )}
            
            <button 
              onClick={handleTransfer}
              disabled={isWriting || isConfirming}
              style={{ marginTop: '10px' }}
            >
              {isWriting ? 'Confirm in Wallet...' : 
               isConfirming ? 'Waiting for confirmation...' : 
               'Transfer 0.1 USDT'}
            </button>
            
            {isConfirmed && (
              <p style={{ color: 'green' }}>Transfer successful!</p>
            )}
            
            {transferError && (
              <p style={{ color: 'red' }}>
                Transfer failed: {transferError.message}
              </p>
            )}
          </div>
        </>
      )}
    </div>
  )
}

// 应用入口
function App() {
  return (
    <WagmiProvider>
      <DeFiApp />
    </WagmiProvider>
  )
}

export default App

踩坑记录

在实际迁移过程中,我遇到了以下几个典型问题:

  1. "Invalid BigNumber value"错误

    • 现象:从ethers.js迁移时,传入useWriteContractargs包含ethers的BigNumber对象
    • 原因:viem只接受原生的JavaScript bigint类型
    • 解决:将所有ethers.BigNumber转换为bigintBigInt(balance.toString())
  2. 跨链读取返回错误数据

    • 现象:在Polygon链上却读到了以太坊主网的余额
    • 原因useReadContract没有指定chainId,使用了默认链
    • 解决:在所有合约读取Hook中显式传递当前chainId
  3. 钱包连接在页面刷新后丢失

    • 现象:用户刷新页面后需要重新连接钱包
    • 原因:wagmi默认配置没有启用连接持久化
    • 解决:正确配置QueryClient的缓存时间,并考虑使用'wagmi/connectors'中的createStorage进行localStorage持久化
  4. TypeScript类型错误:0x${string}

    • 现象:传递普通字符串地址时TypeScript报错
    • 原因:viem要求地址是0x开头的严格格式
    • 解决:使用类型断言或验证函数:address as 0x${string},或使用viem的isAddress工具函数

小结

这次从ethers.js + web3-react迁移到wagmi v2 + viem,最大的收获是理解了现代Web3前端的状态管理范式。wagmi将React Query的缓存策略与区块链状态同步结合,虽然初期配置复杂,但一旦理顺,代码会比老方案更简洁健壮。下一步可以探索wagmi的更多高级特性,如合约事件监听、批量查询优化,以及如何与状态管理库(如Zustand)深度集成。

Next.js第六课 - 数据获取

2026年4月5日 09:33

上节我们学习了服务端组件和客户端组件的区别,本节来深入了解 Next.js 中的数据获取。Next.js 提供了灵活且强大的数据获取方式,掌握好这些知识能让你构建出性能优异的应用。

数据获取概述

在 Next.js 中,有多种数据获取方式:

  1. 静态生成(SSG)- 构建时生成 HTML
  2. 服务器端渲染(SSR)- 每次请求时生成 HTML
  3. 增量静态再生成(ISR)- 定期重新生成静态页面
  4. 客户端数据获取 - 在浏览器中获取数据

选择哪种方式取决于你的数据特性:是否经常变化、是否需要 SEO、用户是否需要看到最新数据等。

服务端组件数据获取

在服务端组件中,你可以直接使用 fetch 或任何数据获取库,这相比传统的 React 方式要简单很多。

基本数据获取

最简单的方式就是直接在组件中使用 async/await:

// app/posts/page.tsx
async function getPosts() {
  const res = await fetch('https://api.example.com/posts')

  if (!res.ok) {
    throw new Error('获取文章失败')
  }

  return res.json()
}

export default async function PostsPage() {
  const posts = await getPosts()

  return (
    <div>
      <h1>博客文章</h1>
      <ul>
        {posts.map((post: any) => (
          <li key={post.id}>
            <h2>{post.title}</h2>
            <p>{post.excerpt}</p>
          </li>
        ))}
      </ul>
    </div>
  )
}

直接访问数据库

服务端组件可以直接访问数据库,不需要创建 API 层:

// app/users/page.tsx
import { db } from '@/lib/db'

export default async function UsersPage() {
  const users = await db.user.findMany({
    orderBy: { createdAt: 'desc' },
  })

  return (
    <div>
      <h1>用户列表</h1>
      {users.map((user) => (
        <div key={user.id}>{user.name}</div>
      ))}
    </div>
  )
}

错误处理

处理数据获取中的错误是很重要的:

// app/products/[id]/page.tsx
export default async function ProductPage({
  params,
}: {
  params: { id: string }
}) {
  let product

  try {
    const res = await fetch(`https://api.example.com/products/${params.id}`)

    if (!res.ok) {
      if (res.status === 404) {
        notFound()
      }
      throw new Error('获取产品失败')
    }

    product = await res.json()
  } catch (error) {
    return <div>加载产品时出错</div>
  }

  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
    </div>
  )
}

缓存和重新验证

Next.js 的缓存系统非常强大,理解它能让你的应用性能提升很多。

默认缓存行为

Next.js 默认会缓存 fetch 请求,这意味着相同的数据请求会被缓存起来,避免重复获取:

// 默认:自动缓存
export default async function Page() {
  const res = await fetch('https://api.example.com/data')
  const data = await res.json()

  return <div>{/* ... */}</div>
}

禁用缓存

如果数据需要实时更新,可以禁用缓存:

// 不缓存:每次都获取新数据
export default async function Page() {
  const res = await fetch('https://api.example.com/data', {
    cache: 'no-store',
  })
  const data = await res.json()

  return <div>{/* ... */}</div>
}

设置重新验证时间

最常用的方式是设置缓存时间:

// 缓存 10 秒后重新验证
export default async function Page() {
  const res = await fetch('https://api.example.com/data', {
    next: { revalidate: 10 },
  })
  const data = await res.json()

  return <div>{/* ... */}</div>
}

按标签重新验证

给数据加上标签,可以在数据更新时手动刷新缓存:

// 获取时添加标签
export default async function Page() {
  const res = await fetch('https://api.example.com/posts', {
    next: { tags: ['posts'] },
  })
  const posts = await res.json()

  return <div>{/* ... */}</div>
}

// 在 API 路由中手动重新验证
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache'

export async function POST(request: Request) {
  const tag = await request.json()
  revalidateTag(tag)
  return Response.json({ revalidated: true })
}

渲染策略

静态渲染(默认)

静态渲染意味着页面在构建时就生成好了 HTML:

// app/blog/page.tsx
// 构建时生成静态 HTML
export default async function BlogPage() {
  const posts = await getPosts()

  return (
    <div>
      {posts.map((post) => (
        <PostCard key={post.id} post={post} />
      ))}
    </div>
  )
}

动态渲染

每次请求都重新渲染:

// app/dashboard/page.tsx
// 每次请求都渲染
export const dynamic = 'force-dynamic'

export default async function DashboardPage() {
  const user = await getCurrentUser()

  return <div>欢迎,{user.name}</div>
}

增量静态再生成(ISR)

结合静态和动态的优点:

// app/products/page.tsx
// 每 60 秒重新生成页面
export const revalidate = 60

export default async function ProductsPage() {
  const products = await getProducts()

  return (
    <div>
      {products.map((product) => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  )
}

并行数据获取

当需要获取多个独立的数据源时,应该并行获取以提高性能:

// app/dashboard/page.tsx
export default async function DashboardPage() {
  // 并行获取数据
  const [user, posts, analytics] = await Promise.all([
    fetchUser(),
    fetchPosts(),
    fetchAnalytics(),
  ])

  return (
    <div>
      <UserProfile user={user} />
      <PostsList posts={posts} />
      <AnalyticsChart data={analytics} />
    </div>
  )
}

客户端数据获取

虽然服务端数据获取更推荐,但有时候也需要在客户端获取数据:

使用 useEffect

传统的方式:

'use client'

import { useState, useEffect } from 'react'

export default function ClientDataFetching() {
  const [data, setData] = useState(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)

  useEffect(() => {
    async function fetchData() {
      try {
        const res = await fetch('https://api.example.com/data')
        const json = await res.json()
        setData(json)
      } catch (err) {
        setError(err.message)
      } finally {
        setLoading(false)
      }
    }

    fetchData()
  }, [])

  if (loading) return <div>加载中...</div>
  if (error) return <div>错误: {error}</div>

  return <div>{/* 渲染数据 */}</div>
}

使用 SWR

SWR 是一个很流行的数据获取库:

'use client'

import useSWR from 'swr'

const fetcher = (url: string) => fetch(url).then((res) => res.json())

export default function Profile() {
  const { data, error, isLoading } = useSWR(
    'https://api.example.com/user',
    fetcher
  )

  if (error) return <div>加载失败</div>
  if (isLoading) return <div>加载中...</div>

  return <div>你好,{data.name}</div>
}

加载状态

Next.js 提供了优雅的加载状态处理方式:

loading.tsx 文件

创建 loading.tsx 文件会自动显示加载状态:

// app/posts/loading.tsx
export default function Loading() {
  return (
    <div className="animate-pulse">
      <div className="h-4 bg-gray-200 w-3/4 mb-4"></div>
      <div className="h-4 bg-gray-200 w-1/2 mb-4"></div>
      <div className="h-4 bg-gray-200 w-5/6"></div>
    </div>
  )
}

实用建议

这里分享几个在日常开发中特别实用的数据获取技巧。

优先使用服务端组件

实际开发中,我发现服务端数据获取不仅性能更好,代码也更简洁:

// 推荐这样做 - 服务端组件数据获取
export default async function Page() {
  const data = await fetchData()
  return <div>{data.title}</div>
}

// 除非有特殊需求,否则避免客户端数据获取
'use client'
export default function Page() {
  const [data, setData] = useState(null)
  useEffect(() => {
    fetchData().then(setData)
  }, [])
  return <div>{data?.title}</div>
}

并行获取数据

这个小技巧特别有用——如果多个数据源是独立的,应该并行获取来提升性能:

// 推荐这样做 - 并行获取,速度更快
const [user, posts] = await Promise.all([
  fetchUser(),
  fetchPosts(),
])

// 避免这种情况 - 串行获取会拖慢速度
const user = await fetchUser()
const posts = await fetchPosts() // 要等 user 完成才开始

使用适当的缓存策略

根据数据特性选择缓存策略,这个在实际项目中特别重要:

// 静态内容可以长时间缓存
const posts = await fetch('https://api.com/posts', {
  next: { revalidate: 3600 },
})

// 实时数据建议不缓存
const stockPrices = await fetch('https://api.com/stocks', {
  cache: 'no-store',
})

总结

本节我们详细学习了 Next.js 的数据获取机制,包括服务端和客户端获取、缓存策略、渲染策略等。掌握好这些知识,你就能根据不同的场景选择最合适的数据获取方式,构建出高性能的应用。

如果你对本节内容有任何疑问,欢迎在评论区提出来,我们一起学习讨论。

原文地址: blog.uuhb.cn/archives/ne…

Claude Code 提示词缓存与系统提示词分段架构

作者 毛骗导演
2026年4月5日 07:29

摘要

本文基于 Claude Code 源码,分析其系统提示词的构建流程、分段策略与提示词缓存机制。重点考察 systemPromptSections 注册表、SYSTEM_PROMPT_DYNAMIC_BOUNDARY 静动态分界标记、splitSysPromptPrefix 三模式缓存切片算法,以及 CacheScope 两级缓存域的设计逻辑。同时讨论 MCP 工具接入对缓存策略的约束,以及消息级缓存断点的放置规则。


一、背景:提示词缓存的工程价值

大语言模型的推理成本在很大程度上由输入 token 决定。对于 Claude Code 这类交互式编程助手,每轮对话均携带完整的系统提示词,其长度通常在数千 token 以上。若每次请求都将系统提示词从头计算,成本极为可观。

Anthropic 提供了提示词缓存(Prompt Caching)能力,允许客户端在请求体中标记特定文本块为可缓存内容。服务端在满足条件时复用已有的 KV Cache,从而跳过对该段 token 的 prefill 计算。这一机制对系统提示词尤为有效——系统提示词跨会话高度稳定,是天然的缓存候选。

然而,"系统提示词"并非一个均质的整体。其内部既包含每次启动后不再变化的静态描述(工具说明、行为规范、环境信息),也包含随每轮请求动态更新的内容(当前会话特征、MCP 服务状态、功能开关读取结果)。若将两者混同处理,静态部分的缓存命中率将因动态部分的频繁变化而显著下降。

Claude Code 为此设计了一套分段架构,将系统提示词在构建阶段拆解为具有明确缓存语义的独立块,再按照不同的缓存域(globalorg)打上标注,最终映射到 Anthropic API 的 cache_control 字段。本文将逐层拆解这一过程。


二、Section 注册表:静态性的声明式标注

系统提示词的内容管理入口位于 constants/systemPromptSections.ts。该文件定义了一个轻量的 Section 注册机制,其核心数据类型如下:

type SystemPromptSection = {
  name: string
  compute: () => string | null | Promise<string | null>
  cacheBreak: boolean
}

字段语义清晰:name 用于调试追踪,compute 是实际的内容计算函数,cacheBreak 则是本文关注的关键字段——它标记该 section 是否具有跨轮次变化的语义,即是否应当成为缓存断点。

对应地,注册表提供两个构造函数:

export function systemPromptSection(name, compute): SystemPromptSection
// cacheBreak = false,内容在 session 内计算一次后被缓存

export function DANGEROUS_uncachedSystemPromptSection(name, compute, _reason): SystemPromptSection
// cacheBreak = true,每轮重新计算,名称前缀 DANGEROUS_ 是对调用者的显式警告

前者的实现依赖一个 session 级的 Map 做惰性缓存——首次调用时执行 compute(),后续调用直接返回缓存结果。后者则每次都调用 compute(),不做任何记忆。

DANGEROUS_uncachedSystemPromptSection 目前在代码中仅有一处实际应用:

DANGEROUS_uncachedSystemPromptSection(
  'mcp_instructions',
  () => getMcpInstructionsText(),
  'MCP servers connect/disconnect between turns'
)

注释明确说明了原因:MCP 服务器可能在任意两轮之间连接或断开,其生成的工具指令内容随时可能改变。若对其做 session 级缓存,将导致系统提示词与实际可用工具集不一致。_reason 参数不参与运行时逻辑,仅作为强制要求调用者说明理由的文档约束。

resolveSystemPromptSections 函数负责批量执行一组 section 的 compute,返回 (string | null)[],null 值表示该 section 在当前上下文下不适用(例如某些仅在特定模式下启用的功能模块)。


三、SystemPrompt 类型与构建流程

系统提示词在类型系统层面被定义为品牌化字符串数组:

// utils/systemPromptType.ts
type SystemPrompt = readonly string[] & { __brand: 'SystemPrompt' }

使用 TypeScript 的品牌类型(Branded Type)而非 string[] 的原因有两点:其一,防止将普通字符串数组误传至需要构建好的系统提示词的 API;其二,强调该数组的语义不是任意字符串序列,而是一个有序的提示词段列表,其内部顺序具有语义意义。

buildEffectiveSystemPrompt(位于 utils/systemPrompt.ts)负责确定最终使用哪个系统提示词序列。其逻辑遵循一条优先链:

  1. 若存在显式 override,直接使用 override 内容
  2. 若当前为协调者(coordinator)模式,使用协调者专用提示词
  3. 若当前为子 Agent 模式,根据配置决定替换还是追加
  4. 否则使用默认的 getSystemPrompt() 构建结果

无论走哪条路径,appendSystemPrompt 均在最后追加,这是用户自定义系统提示词的注入点。

getSystemPrompt() 是系统提示词的主体构建函数,位于 constants/prompts.ts。其结构决定了后续缓存分段的依据。


四、动态边界标记:SYSTEM_PROMPT_DYNAMIC_BOUNDARY

getSystemPrompt() 的返回值是一个 string[],其中大部分元素是通过 resolveSystemPromptSections 展平后的文本块。关键在于其中插入了一个特殊标记:

const SYSTEM_PROMPT_DYNAMIC_BOUNDARY = '__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__'

该常量以双下划线包裹,在视觉上即可区分于普通文本内容。其在 getSystemPrompt() 中的插入逻辑如下:

return [
  ...staticSections,
  ...(shouldUseGlobalCacheScope() ? [SYSTEM_PROMPT_DYNAMIC_BOUNDARY] : []),
  ...dynamicSections,
]

边界标记只在 shouldUseGlobalCacheScope() 返回 true 时才被插入——这是因为该标记的语义依赖于全局缓存能力的存在。若当前 API 提供商不支持全局缓存,插入边界标记没有意义(后续的 splitSysPromptPrefix 也会因此走不同的分支)。

边界标记的作用是在字符串数组层面划定一条逻辑分界线:标记之前的内容被认定为静态内容(跨会话稳定,适合全局缓存),标记之后的内容被认定为动态内容(会话特定,不应跨组织共享缓存)。

代码注释中对"什么内容应当置于边界之后"有明确记录。以 getSessionSpecificGuidanceSection 为例,其注释写道该 section 被置于动态边界之后,原因是它需要读取以下运行时状态:

  • isForkSubagentEnabled() — 功能开关,从 GrowthBook 读取
  • getIsNonInteractiveSession() — 当前会话是否为非交互模式
  • 其他特性标志的当前值

这些值在不同会话、不同用户、不同时间点均可能不同,因而不可被全局缓存。


五、splitSysPromptPrefix:三模式缓存切片算法

splitSysPromptPrefix 是整个缓存架构的核心函数,位于 utils/api.ts。它接收由 getSystemPrompt() 产生的 string[],输出一个 SystemPromptBlock[]

type CacheScope = 'global' | 'org'
type SystemPromptBlock = { text: string; cacheScope: CacheScope | null }

cacheScope 的取值含义:

  • 'global':可跨组织缓存,适用于完全不含用户/组织特定信息的内容
  • 'org':仅在同一组织内缓存,适用于可能含有组织配置、用户偏好的内容
  • null:不参与缓存,内容每次均需全量计算

函数内部根据两个条件决定走哪个分支:

条件一:是否存在 MCP 工具(hasMcpTools条件二:是否启用全局缓存且提示词中包含边界标记(useGlobalCacheFeature && hasBoundary

三个分支的处理逻辑如下:

模式一:MCP 工具存在

attribution block   cacheScope: null
prefix blocks       cacheScope: 'org'
remaining blocks    cacheScope: 'org'

当 MCP 工具可用时,系统提示词中包含了由 MCP 服务器贡献的工具描述。MCP 工具的描述内容是每个用户、每个服务器连接独有的,带有明确的用户身份语义,不可被不同组织的用户共享。因此,所有块统一降级为 'org' 级别,完全放弃全局缓存。attribution 块(包含模型署名信息,undercover 场景下需要抹除)始终为 null,这一规则在三个模式中保持一致。

模式二:全局缓存启用且边界标记存在

attribution block    cacheScope: null
prefix blocks        cacheScope: null        (attribution 之后、boundary 之前)
static blocks        cacheScope: 'global'    (boundary 之后、动态内容之前)
dynamic blocks       cacheScope: null        (动态内容,不缓存)

这是三个模式中最精细的一个。函数在此模式下扫描 string[],找到 SYSTEM_PROMPT_DYNAMIC_BOUNDARY 标记的位置,以此为界将内容分为两段。边界之前的静态内容被标记为 'global',可以跨组织共享缓存;边界之后的动态内容标记为 null,不参与缓存。

一个值得关注的细节是 prefix 块(attribution 之后、boundary 之前的早期块)被标记为 null 而非 global。这部分内容包括一些早期的初始化文本,其全局缓存适用性的判断较为保守,因此未被纳入 global 范围。

模式三:默认模式

attribution block   cacheScope: null
prefix blocks       cacheScope: 'org'
remaining blocks    cacheScope: 'org'

当全局缓存不可用且无 MCP 工具时,退回到最朴素的策略:所有内容使用 'org' 级别缓存,即在同一组织内复用。


六、shouldUseGlobalCacheScope:全局缓存的启用条件

全局缓存并非对所有 API 提供商开放。shouldUseGlobalCacheScope() 的实现位于 utils/betas.ts

export function shouldUseGlobalCacheScope(): boolean {
  return getAPIProvider() === 'firstParty' &&
    !isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS)
}

函数返回 true 需要同时满足两个条件:

  1. API 提供商为 firstParty,即直接使用 Anthropic 官方 API,而非通过 Amazon Bedrock、Google Vertex AI 或 Anthropic Foundry 接入
  2. 未通过环境变量 CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS 禁用实验性 Beta 功能

全局缓存能力(cache_control 中的 scope: 'global')依赖一个 Beta API Header:

const PROMPT_CACHING_SCOPE_BETA_HEADER = 'prompt-caching-2025-04-11'

此 Header 在启用全局缓存时被添加到请求的 betas 数组中。Bedrock、Vertex 等第三方部署路径目前不支持该 Beta 特性,因此相应的代码分支被直接跳过。


七、getCacheControl 与 TTL 策略

确定了每个块的 cacheScope 之后,buildSystemPromptBlocks 函数(位于 services/api/claude.ts)将其转换为实际的 TextBlockParam,并附加 cache_control 字段。getCacheControl 函数负责根据 scope 和查询来源生成具体的缓存控制对象:

function getCacheControl({ scope, querySource }): CacheControlEphemeralParam {
  return {
    type: 'ephemeral',
    ...(should1hCacheTTL(querySource) ? { ttl: '1h' } : {}),
    ...(scope === 'global' ? { scope: 'global' } : {}),
  }
}

标准 TTL 为 5 分钟(Anthropic API 默认值),1 小时 TTL 通过 should1hCacheTTL() 决定是否启用。该函数的判断条件包括:

  • 当前用户为 Anthropic 内部用户(ant 用户类型)
  • 或者,当前用户为付费订阅者且未处于超额状态(non-overaged subscriber)

should1hCacheTTL() 的结果在 bootstrap 阶段被锁定,保存在全局状态中,整个 session 内保持不变。这样做的目的是防止订阅状态在 session 中途发生变化时导致缓存 TTL 频繁切换,进而引发缓存失效。

GrowthBook 功能开关系统(A/B 测试框架)用于管理 1h TTL 的灰度开放。代码中对 GrowthBook allowlist 的匹配使用了尾部通配符(trailing-* matching),支持按前缀匹配用户标识,便于按组织或用户群体进行分级灰度。


八、消息级缓存断点

除系统提示词外,Claude Code 还在消息历史中设置缓存断点。规则较为简单:在每轮请求时,将 cache_control 放置在最后一条用户消息和最后一条助手消息各自的最后一个内容块上。

这一做法的逻辑依据是:Anthropic API 的提示词缓存以"最后一个带 cache_control 标记的 token 位置"为缓存边界。在消息历史的末尾打断点,意味着下一轮请求时,历史消息部分可以被命中缓存,只有新增的用户输入需要重新 prefill。

对于多模态内容(图片、文件附件等),缓存断点同样放置在最后一个块上,不区分内容类型。对于空内容块或仅含工具结果的消息,缓存断点的放置逻辑有额外的边界处理。


九、工具 Schema 缓存稳定性

系统提示词缓存的一个潜在破坏因素是工具 Schema 的变化。Claude Code 集成了大量工具(文件操作、Bash 执行、代码搜索等),每个工具都有对应的 JSON Schema 描述,这些描述作为独立的 tool 块随请求发送。

若工具 Schema 在每次请求时都重新生成,即便内容不变,序列化后的字符串可能因字段顺序、空白符等细微差异而产生不同的 token 序列,导致缓存 miss。

toolSchemaCache.ts 通过在 session 级别缓存工具 Schema 的序列化结果来规避这一问题。GrowthBook 功能开关的翻转(A/B 测试分组变化)可能导致某些工具的可用性发生变化,进而引发 Schema 集合的变化。工具 Schema 缓存对此做了特殊处理,确保在 session 内工具集稳定的前提下,Schema 的序列化结果保持确定性,从而维持缓存的持续命中。


十、MCP 工具与全局缓存的冲突

MCP(Model Context Protocol)工具接入是全局缓存策略中最重要的约束来源。分析其不兼容的根本原因,需要理解两个层面:

语义层面:MCP 工具由用户在本地配置的服务器提供,其工具描述、参数 Schema、行为规范均带有强烈的用户/组织特异性。将含有此类内容的提示词缓存在全局(跨组织)层面,理论上存在信息泄露风险——不同组织的用户可能通过缓存命中间接获知他人的 MCP 工具配置信息。

稳定性层面:MCP 服务器连接在会话内是动态的,工具列表随时可能增减。即使退而求其次使用 org 级缓存,MCP 工具的高变动性也使缓存命中率受限。DANGEROUS_uncachedSystemPromptSection 对 MCP 指令的处理(每轮重新计算)正是对这一特性的响应。

splitSysPromptPrefix 的实现中,MCP 工具存在时的处理逻辑(模式一)完全绕过了全局缓存路径,即便 shouldUseGlobalCacheScope() 返回 true,只要检测到 MCP 工具存在,就立即降级为 org 级别。代码中对此有一处额外的检查:

needsToolBasedCacheMarker = useGlobalCacheFeature && 
  filteredTools.some(t => t.isMcp && !willDefer(t))

willDefer 表示该 MCP 工具被推迟加载(defer),尚未实际可用。仅当存在已加载且非推迟的 MCP 工具时,才真正触发 global→org 的降级逻辑。


十一、架构总结

以下是系统提示词从构建到最终发送的完整数据流:

getSystemPrompt()
  └── resolveSystemPromptSections([
        staticSection_1,       // systemPromptSection,session 内缓存
        staticSection_2,       // systemPromptSection,session 内缓存
        ...
        SYSTEM_PROMPT_DYNAMIC_BOUNDARY,   // 分界标记(仅 global cache 模式)
        dynamicSection_1,      // DANGEROUS_uncachedSystemPromptSection
        dynamicSection_2,      // systemPromptSection(但内容依赖运行时状态)
        ...
      ])
  └── string[]   (含边界标记)
        │
        ▼
  splitSysPromptPrefix(strings, { hasMcpTools, useGlobalCache })
  └── SystemPromptBlock[]
        { text: "...", cacheScope: 'global' | 'org' | null }
        │
        ▼
  buildSystemPromptBlocks(blocks, enablePromptCaching)
  └── TextBlockParam[]
        { type: 'text', text: "...", cache_control?: { type: 'ephemeral', ttl?, scope? } }
        │
        ▼
  Anthropic API Request

整个设计体现了一个核心原则:缓存策略的决策尽可能前置systemPromptSectionDANGEROUS_uncachedSystemPromptSection 在 section 定义时即声明其缓存语义,而非在最终构建阶段做动态判断。SYSTEM_PROMPT_DYNAMIC_BOUNDARY 作为数据平面的标记,将提示词数组的语义分区编码进数据本身,使 splitSysPromptPrefix 的逻辑保持相对简单。

这种"声明优于推断"的设计取向,降低了缓存策略与内容逻辑之间的耦合度——添加新 section 时,开发者只需在定义处决定是否使用 DANGEROUS_ 前缀,以及是否置于边界之前,而不需要理解整个 splitSysPromptPrefix 的切分逻辑。


附录:关键常量与函数索引

符号 文件 说明
systemPromptSection constants/systemPromptSections.ts 声明 session 内稳定的 section
DANGEROUS_uncachedSystemPromptSection constants/systemPromptSections.ts 声明每轮重计算的 section
SYSTEM_PROMPT_DYNAMIC_BOUNDARY constants/prompts.ts 静动态内容分界标记
splitSysPromptPrefix utils/api.ts 三模式缓存切片核心函数
CacheScope utils/api.ts `'global' 'org'` 缓存域类型
SystemPromptBlock utils/api.ts 带缓存语义的文本块类型
shouldUseGlobalCacheScope utils/betas.ts 全局缓存启用条件判断
getCacheControl services/api/claude.ts 生成 cache_control 对象
should1hCacheTTL services/api/claude.ts 1h TTL 扩展条件判断
buildSystemPromptBlocks services/api/claude.ts 将 block 转换为 API 参数
PROMPT_CACHING_SCOPE_BETA_HEADER services/api/claude.ts 全局缓存所需的 Beta Header
toolSchemaCache utils/toolSchemaCache.ts 工具 Schema 序列化稳定性缓存

uniapp安全区域,键盘挤压与上推

2026年4月5日 04:19

uniapp本身的不具备安全区域的标签和关于键盘的特殊标签

安全区域

safeArea:指定是可视区域

safeAreaInsets:可视区域外的

<template>
    <view :style="SafeStyle">
        <slot name="Content"></slot>
    </view>
</template>
<script setup>
onMounted(()=>{
    const StatusBar=ref()
    const HomeButton=ref()
    const {safeAreaInsets}=uni.getWindowInfo()
    StatusBar.value=safeAreaInsets.top
    HomeButton.value=safeAreaInsets.Bottom
})
const SafeStyle=computed(()=>({
    paddingTop:StatusBar+'rpx',
    paddingBottom:HomeButton+'rpx',
    paddingLeft:"12rpx",
    paddingRight:12rpx
}))
</script>

键盘挤压与上推

我可以说,uniapp在处理键盘可以说是最烂的一种方案,甚至都没有形成标准。和触底加载一样烂到根上了。前端处理长列表一般有这么几种方案,第一种是比例值上拉加载,第二种是可视区域滚动加载,第三种是虚拟列表加载,最后一种是就是uniapp提供的触底加载方式。uniapp的ScrollView可视区域滚动加载,但是uniapp官方明确不推荐长列表加载,这大大限制了ScrollView使用范围。而最后的虚拟列表加载实在是前端被迫选择的一种长列表加载方式之一,它解决了触底加载过渡的问题。 最后我说一下这个uniapp的键盘挤压与推送的问题。

<template>
    <view>
        // 第一个坑,adjust-position设置为true指的是上推页面,false指的是不处理,键盘
        会遮住输入框,而不是挤压页面!!!
        <input type="text" placeholder="请输入文本" adjust-position="true" />
    </view>
</template>

第二个坑,现在不在业务上了,又要去业务配置层面,adjustResize它代表挤压,adjustPan代表上推。这个坑并没有结束,官方明确说部分机型有漏屏的问题。

# page.json
{
    "app-plus":{
        "softinputMode":"adjustResize"
    }
}

第三个坑,当进入页面的时候需要弹出键盘,官方没有给出开关。需要我们手动组装这个问题。

<template>
    <view class="固定屏幕底部CSS样式" :style="keyBoard" >
        <input type="text" placeholder="请输入文本" adjust-position="true" :focus="GetFocus"
        @focus="KeyBoardFocusStyle"
        @Blur="KeyBoardBlurStyle"/>
    </view>
</template>
<script setup>
const onKeyboardHeightChange=ref()
const GetFocus=ref(true)
onLoad(()=>{
    uni.onKeyboardHeightChange((res)=>{
        GetFocus.value=true
        onKeyboardHeightChange.value=res.hight
    })
})

onUnLoad(()=>{
    GetFocus.value=false
    onKeyboardHeightChange.value=0
})
const KeyBoardFocusStyle=(e)=>{
     GetFocus.value=true
    if(onKeyboardHeightChange!=""){
        onKeyboardHeightChange.value=e.detail.height
    }
}

const KeyBoardBlurStyle=()=>{
    GetFocus.value=false
    onKeyboardHeightChange.value=0
}

const keyBoard=computed(()=>({
    height:onKeyboardHeightChange+"rpx"
})
</script>

最后一个坑当我们设置安全区域为组件的时候,此时的键盘和我们的组件又发生了冲突,也好解决,就是组件传值。或者将当前页面层级抬高。

使用 AI SDK 创建 「知识库」

作者 xuerzong
2026年4月4日 22:58

rag-workflow.png

今天分享一个用纯 Node.js 实现知识库(RAG)的最简方案。

RAG 的核心思路其实并不复杂:

  • 对文档进行内容分割,将文档拆解成一个个小的语义分块(chunk);
  • 将这些分块通过大模型解析成向量(embedding)并储存在向量数据库中;
  • 当用户输入一个查询时,同样将查询进行向量化处理,通过向量数据库检索高度相关的知识片段;
  • 最后将这些片段整合到发送给大模型的上下文中,提升回答的精准度和相关性。

开始

技术栈:

  • libSQL - 向量数据库
  • AI SDK - 大模型调用与向量解析

安装依赖

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:

  1. 入库时把每个 chunk 转成向量并存储。
  2. 查询时把用户问题也转成向量,用于检索最相关的上下文。
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
    )
  `)
}

这里的设计也比较直接:

  1. documents 表存标题、文本内容和 embedding。
  2. 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],
      })
    }
  }
}

入库流程如下:

  1. 如果表里已经有数据,就不重复写入;
  2. 把每篇文档切成多个 chunk;
  3. 给每个 chunk 生成 embedding;
  4. 把 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),
  }))
}

查询流程如下:

  1. 先把用户问题转成 embedding。
  2. vector32(?) 把查询向量传给数据库。
  3. 数据库内部用 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,
  }
}

整个请求链路到这里就闭环了:

  1. 函数执行时,先确保知识库已经初始化;
  2. 把用户问题转成向量;
  3. 在数据库层执行向量检索,拿到最相关片段;
  4. 把这些片段加入 system prompt,作为上下文或者参考信息;
  5. 调用主模型生成最终回答;
  6. 返回最终答案和命中的参考片段。

把上面所有代码拼到一起,就是一个完整的单文件 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 工程化 的核心

作者 hpysirius
2026年4月4日 20:57

当前 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→返回结果
工具调用 calculatorToolweatherTool Agent 通过工具执行具体操作
子 Agent mathSubAgentweatherSubAgent 专门化处理,可以嵌套调用
Skill loggingSkillmemorySkill 可复用的横切能力,可在工作流中自动执行
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设计理念和响应式原理(下)

作者 乘方
2026年4月4日 20:48

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";

初始化

  1. 执行到 computed(getter) 时,返回ComputedRefImpl(getter)实例 aliasName:创建内部的 ReactiveEffect(getter, scheduler);实例(aliasName).value 是可 get/set 的响应式。
export class ComputedRefImpl {
  constructor(getter: () => any) {
    this.effect = new ReactiveEffect(getter, () => {
      //...
    });
  }
}
  1. 执行 effect(fn),创建外层 effect 实例,将 fn 添加至 schedule 中并执行。
  2. 打印 外层 effect 执行。 执行 aliasName.value ===> 触发内部 effect 的 get value。
  3. 在 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);
  }
  1. 第一次 _dirty 默认是脏,改为不脏,并执行 内部 effect.run()(即包含 computed 的 getter方法的运行器)。
  2. 更新 activeEffect 为内部 effect,执行 getter,打印 getter 执行return 中执行 state.name 触发 name 属性的 get,将此时 activeEffect = 内层effect,收集为依赖。返回 name = zoyi
  3. getter 中 return 计算后属性 @zoyi,将值缓存到 aliasName._value 上,aliasName.value 的 get value 执行完毕,并返回 _value
  4. 打印 @zoyi,外层 effect.run() 执行完毕。

此时关系是

state.name 的 dep → 内层 ReactiveEffect(计算属性的 scheduler)。 aliasName.dep → 外层 effect(读了 .value)。

更新阶段(Vue 3.4)

  1. 执行 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;
    }
    
  2. 新旧值不一样,触发 trigger,执行收集到的内层 effect 的 scheduler。

    • 但默认不会执行 run,只把 _dirty 设置为脏。
    • triggerEffects(aliasName.dep) → 外层 effect 的 scheduler 执行 → 外层 effect 再次 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
    }
  });
}
  1. 打印 外层 effect 执行,执行到 aliasName.value,再次进入其 get value 中。
    • trackComputed() 再次把外层 effect 记到 aliasName.dep(去重逻辑在 trackEffect 里)。
    • 发现 _dirty 为脏 → 执行 this.effect.run() → 打印 getter 执行,读到新 state.name,得到 @star zoyi,缓存进 _value,再标不脏。
  2. 打印 @star zoyi,结束更新

3.4版本

注意:在 Vue 3.5 中 computed 的更新阶段稍微有些变化

更新阶段(Vue 3.5)

  1. 执行 state.name = "star zoyi" state.name 发生改变,触发 name 的 setter。
  2. 新旧值不一样,触发 trigger,执行收集到的内层 effect 的 scheduler。
  3. 此时发生了变化: 执行 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
}
  1. 再执行外层 effect.run,打印 外层 effect 执行,执行到 aliasName.value,再次进入其 get value 中。
    • trackComputed() 再次把外层 effect 记到 aliasName.dep(去重逻辑在 trackEffect 里)。
    • 已经计算过新的属性了,直接从 _value 中获取并返回。
  2. 打印 @star zoyi,结束更新。
get value() {
  // 收集计算属性(aliasName)的依赖,再保证缓存最新
  this.trackComputed();
  this.refreshComputed(); // _dirty 为不脏直接返回
  return this._value; // 已经计算过新的属性了,直接从_value中获取
}

3.5版本

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 模板里灌数据?现有库都差点意思,我手搓了一个

作者 小凡同志
2026年4月4日 19:28

用 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> 标签里

也就是说,理论上我只需要:

  1. 打开 zip
  2. 找到目标 worksheet
  3. 只改 <sheetData> 里的内容
  4. 其他文件一概不动
  5. 封包

样式、图片、透视表都不受影响——压根没碰它们。


设计原则

三条,很简单:

  1. 黑盒原则styles.xmldrawings/pivotTables/ 一律不碰
  2. 片段手术:只改目标 worksheet 的 <sheetData> 区域,其他 XML 片段原样保留
  3. 可诊断失败:遇到不支持的场景直接报错,不静默降级。报错带上错误码,好排查

核心实现

整个组件大概 600 行 TypeScript,只依赖 adm-zip(操作 zip)和 fast-xml-parser(局部辅助解析)。

1. worksheet 定位:不能假设 sheet1.xml

第一坑:worksheet 文件名不一定是 sheet1.xml

实际项目中,Excel 内部的文件可能是 sheet7.xmlsheet3.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.xmldrawings/pivotTables/ 字节级不变
  • 公式正确:展示页公式打开后自动重算,结果与输入数据一致
  • 可打开性:Excel(Windows/Mac)和 WPS 打开无修复提示
  • 依赖极简:只依赖 adm-zip + fast-xml-parser

已知边界

实事求是,没做完的就是没做完:

功能 状态 说明
固定区域写入 完全支持
样式/图片/透视表保全 字节级不变
日期体系兼容 1900/1904 自动识别
公式重算触发 fullCalcOnLoad
行扩展 + 样式继承 模式 B
冲突检测 五类对象
远程模板读写 HTTP(S)
结构化表(ListObject)自动扩展 还没做
definedNames 动态重写 当前为保护性拦截
大规模性能压测 SLO 报告待补

最后

这个组件的思路其实不复杂:别重建,只做手术

xlsx 是个 zip 包,数据就在几个 XML 标签里。与其让库帮你解析→重建(顺便丢信息),不如直接上手改那几行 XML。

当然,这个方案也有适用范围——它适合"模板复杂、数据写入点固定"的场景。如果你需要动态创建图表、动态生成透视表,那还是得用更重的方案。

代码在本地跑着,等什么时候有空了整理一下放 GitHub。

前端架构实操:地铁出行系统高并发与性能优化全解析(二)

作者 阿隅
2026年4月4日 18:51

一、引言:从行业通用场景出发,理清高并发与性能优化的核心逻辑

作为前端备考软考架构师的伙伴,我们都清楚,中大型项目的核心挑战,从来不是 “实现功能”,而是 “扛住流量、保证体验”。

本篇继续围绕我了解到的地铁出行系统(中大型微服务项目),聚焦高并发与性能优化核心场景,完整拆解「项目场景→实际问题→思考过程→技术选型→解决方案→实施结果」,既梳理性能优化类技术体系,又还原项目实操逻辑,帮大家吃透技术落地思路,同时规避保密风险。


二、项目场景:地铁出行系统高并发与性能现状

结合行业通用地铁出行系统项目特点,该类项目的高并发与性能相关场景如下,为后续问题排查和技术选型奠定基础:

  1. 用户规模与流量特点:服务全市 500 万 + 用户,早晚高峰(7:00-9:00、17:00-19:00)为流量峰值,瞬时并发量可达平日的 5-8 倍,核心页面(实时到站、客流监控)需承载高并发请求;

  2. 架构现状:后端 6 个微服务(线路管理、实时到站、客流监控、用户管理、票务支付、站点设施管理)独立部署,前端采用 Vue3+Pinia 技术栈,已通过 BFF 层 + Nacos 解决接口与环境问题,但随着用户量增长,性能瓶颈逐步凸显;

  3. 部署环境:4 套环境(开发、测试、仿真、生产),后端服务需根据早晚高峰客流动态扩容 / 缩容,对系统弹性扩缩容能力要求极高。


三、项目痛点:高并发与性能优化中遇到的实际问题

在项目迭代过程中,随着用户量持续增长,早晚高峰时段系统出现了多个核心性能问题,严重影响用户体验和系统稳定性,具体如下:

1. 静态资源加载慢,页面首屏渲染超时

地铁出行系统包含大量静态资源(线路地图、站点图片、样式文件、JS 代码包),初期采用 “前端直连服务器” 的方式加载资源,遇到 3 个核心问题:

  • 资源分发效率低:静态资源存储在后端服务器,用户跨区域访问时,网络延迟高,首屏加载时间长达 8-10 秒,远超用户可接受的 3 秒阈值

  • 服务器带宽压力大:早晚高峰时,大量用户同时请求静态资源,后端服务器带宽被占满,导致接口请求延迟、页面加载失败;

  • 缓存策略不合理:未做合理的资源缓存配置,用户每次访问都重新加载全量资源,进一步加剧服务器压力。

2. 高并发场景下,服务扩容不及时,系统崩溃风险高

早晚高峰瞬时并发量激增,后端微服务(尤其是实时到站、票务支付服务)负载过高,出现以下问题:

  • 扩容响应慢:传统手动扩容方式,需运维人员手动部署服务器、配置服务,耗时长达 1-2 小时,无法应对突发流量高峰;

  • 服务稳定性差:服务过载时,出现接口超时、请求失败,甚至服务宕机,导致用户无法查询实时到站、无法购票,严重影响出行体验;

  • 资源浪费严重:平峰时段服务器负载低,手动缩容不及时,造成大量服务器资源闲置,运维成本陡增。

3. 大数据量页面渲染卡顿,用户交互体验差

客流监控、线路查询等页面,需展示大量实时数据(如全线路客流数据、历史到站记录),前端直接渲染全量数据,出现:

  • 页面渲染卡顿:大数据量渲染导致主线程阻塞,页面滚动、点击等交互操作延迟,甚至出现页面卡死;

  • 内存占用过高:全量数据加载导致浏览器内存占用飙升,部分低端设备出现闪退;

  • 数据更新不及时:实时数据频繁更新,未做合理的渲染优化,导致页面频繁重绘,进一步加剧卡顿。


四、思考过程:从问题出发,拆解破局思路

面对上述 3 个核心问题,相关开发团队没有盲目选型技术,而是从「提升用户体验、降低运维成本、增强系统稳定性」三个核心目标出发,逐步拆解思考,形成了清晰的破局思路:

针对 “静态资源加载慢” 问题的思考

核心需求:提升静态资源加载速度,降低服务器带宽压力,实现用户就近访问,优化首屏渲染体验。思考拆解

  1. 痛点本质:静态资源集中存储在后端服务器,用户跨区域访问延迟高,且未做缓存优化,导致服务器带宽压力大、首屏加载慢;

  2. 核心思路:引入内容分发网络(CDN) ,将静态资源缓存到全国各区域节点,用户就近访问节点资源,大幅降低网络延迟;同时优化资源缓存策略,减少重复请求;

  3. 技术选型考量:对比自建 CDN 与第三方商用 CDN—— 自建 CDN 部署成本高、维护难度大,不适合中大型项目;第三方商用 CDN(如阿里云 CDN、腾讯云 CDN)部署简单、节点覆盖广,能快速解决资源加载问题,因此确定选用 CDN 作为静态资源优化方案。

针对 “高并发服务扩容难” 问题的思考

核心需求:实现服务自动扩缩容,应对突发流量高峰,提升系统稳定性,同时降低运维成本,避免资源浪费。思考拆解

  1. 痛点本质:传统手动扩容 / 缩容方式,响应速度慢、效率低,无法适配地铁项目 “早晚高峰流量波动大” 的特点,且运维成本高;

  2. 核心思路:引入容器化编排工具,将后端微服务、BFF 层打包成容器,通过编排工具实现服务的自动部署、弹性扩缩容、故障自愈;

  3. 技术选型考量:对比 Docker+K8s(Kubernetes)与其他容器化方案 ——Docker 实现容器化打包,保证环境一致性;K8s 实现容器编排,支持自动扩缩容、服务治理,是行业内微服务容器化的标准方案,因此确定选用「Docker+K8s」作为容器化编排方案。

针对 “大数据量渲染卡顿” 问题的思考

核心需求:优化大数据量页面渲染性能,避免主线程阻塞,提升用户交互体验,降低浏览器内存占用。思考拆解

  1. 痛点本质:前端一次性加载并渲染全量数据,导致主线程阻塞、内存占用过高,页面交互卡顿;

  2. 核心思路:采用虚拟列表 + 懒加载技术,仅渲染可视区域内的数据,按需加载剩余数据,减少 DOM 节点数量,降低主线程压力;同时优化数据更新逻辑,避免频繁重绘;

  3. 技术选型考量:虚拟列表(如 vue-virtual-scroller)是前端大数据量渲染的通用优化方案,适配 Vue3 技术栈,无需额外引入复杂框架,开发成本低、优化效果显著,因此确定选用虚拟列表 + 懒加载作为渲染优化方案。

整体思考总结

最终形成「CDN+Docker+K8s + 虚拟列表」的技术栈组合,各技术针对性解决对应痛点:CDN 解决静态资源加载慢,Docker+K8s 解决高并发扩容难,虚拟列表解决大数据量渲染卡顿,形成完整的高并发与性能优化解决方案,贴合地铁出行系统的业务特点,同时符合软考架构师 “技术选型贴合项目需求” 的核心要求。


五、解决方案:CDN+Docker+K8s + 虚拟列表技术栈落地细节

结合上述行业通用思考思路,相关开发团队落地了完整的高并发与性能优化方案,每一项技术都严格贴合项目需求,具体落地细节如下:

1. CDN 落地:静态资源加速与缓存优化

  • 核心功能落地

    1. 资源分发:将地铁出行系统的所有静态资源(线路地图、站点图片、JS/CSS 代码包、字体文件)上传至 CDN,缓存到全国各区域节点,用户访问时,自动路由到最近的节点获取资源;

    2. 缓存策略优化:针对不同类型资源设置差异化缓存时间 —— 静态资源(图片、样式)设置 7 天缓存,JS 代码包设置 1 天缓存,同时配置版本号,避免缓存过期导致的资源更新不及时;

    3. 回源策略优化:设置 CDN 回源规则,仅在缓存过期时回源到后端服务器获取最新资源,减少服务器带宽压力;

  • 大白话理解:CDN 就像是 “全国连锁的资源便利店”,把静态资源提前放到用户家门口的便利店,用户不用再跑到后端服务器(总店)取资源,就近就能拿到,速度大幅提升,还能减轻总店的压力。

2. Docker+K8s 落地:容器化编排与自动扩缩容

  • 核心功能落地

    1. 容器化打包:将后端 6 个微服务、BFF 层分别打包成 Docker 镜像,保证开发、测试、仿真、生产环境的一致性,避免 “本地运行正常,线上报错” 的问题;

    2. K8s 集群部署:搭建 K8s 集群,部署所有容器化服务,配置服务发现、负载均衡,实现服务的自动部署与故障自愈;

    3. 自动扩缩容配置:基于 CPU 使用率、请求量设置 HPA(Horizontal Pod Autoscaler),早晚高峰流量激增时,自动扩容服务实例;平峰时段自动缩容,节省服务器资源;

    4. 运维自动化:通过 K8s 实现服务的一键部署、滚动更新,无需手动操作服务器,大幅降低运维成本;

  • 大白话理解:Docker 就像是 “集装箱”,把服务和运行环境打包成统一的集装箱,不管在哪都能正常运行;K8s 就像是 “智能调度中心”,自动管理这些集装箱,根据流量多少,自动增减集装箱数量,应对高峰、节省资源。

3. 虚拟列表 + 懒加载落地:大数据量渲染优化

  • 核心功能落地

    1. 虚拟列表实现:针对客流监控、线路查询等大数据量页面,引入 vue-virtual-scroller 组件,仅渲染可视区域内的 10-20 条数据,滚动时动态加载剩余数据,大幅减少 DOM 节点数量;

    2. 懒加载优化:图片、非首屏数据采用懒加载,仅当用户滚动到可视区域时,再加载对应资源,减少首屏加载时间;

    3. 渲染优化:优化数据更新逻辑,采用虚拟滚动 + 防抖处理,避免频繁重绘,保证页面交互流畅;

  • 大白话理解:虚拟列表就像是 “无限长的名单,但只给你看当前屏幕上的几行”,滚动时再替换内容,不用一次性渲染全部名单,页面自然不卡顿。

4. 技术协同落地:全链路优化逻辑

各技术并非独立使用,而是形成协同闭环,确保优化效果最大化:

  1. CDN 加速静态资源,减少首屏加载时间,降低服务器带宽压力,为 K8s 服务预留更多资源处理业务请求;

  2. Docker+K8s 实现服务自动扩缩容,应对 CDN 加速后带来的更高并发请求,保证系统稳定性;

  3. 虚拟列表优化前端渲染,配合 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. 性能优化技术体系梳理(融入技术体系化思路)

通过本次对行业通用地铁出行系统项目的梳理,总结出高并发与性能优化相关的技术体系,方便后续备考记忆和项目复用:

  1. 技术分类:本次落地的 CDN、Docker+K8s、虚拟列表,分属不同优化维度,覆盖全链路性能提升:

    • CDN:属于「静态资源加速技术」,核心解决资源加载慢、带宽压力大的问题,适配中大型项目的静态资源分发;

    • Docker+K8s:属于「容器化编排技术」,核心解决服务扩缩容、系统稳定性问题,适配微服务架构的高并发场景;

    • 虚拟列表 + 懒加载:属于「前端渲染优化技术」,核心解决大数据量渲染卡顿问题,适配前端大数据量页面场景;

  2. 技术选型逻辑:技术选型的核心是 “针对性解决痛点”,CDN 解决资源问题,Docker+K8s 解决扩容问题,虚拟列表解决渲染问题,三者协同形成全链路优化,这也是架构设计的核心思路;

  3. 备考记忆技巧:可总结为 “资源慢用 CDN,扩容难用 K8s,渲染卡用虚拟列表”,后续学习其他性能优化技术时,也按「场景→问题→思考→选型→落地」的思路梳理,贴合项目实操与软考备考。


七、自我复盘 + 下一篇预告

自我复盘:本次围绕地铁出行系统的高并发与性能优化,完整拆解了从问题到解决方案的全流程,让我深刻体会到,性能优化不是 “堆砌技术”,而是 “针对痛点精准选型”。同时,通过梳理技术体系,夯实了性能优化类技术的基础,也为软考论文中 “高并发与性能优化” 模块的写作,积累了完整的项目场景与落地逻辑。

下一篇,我们继续围绕地铁出行系统,聚焦工程化与监控场景 —— 随着项目迭代,代码质量、构建效率、系统监控成为新的痛点,我们将拆解「工程化场景→核心问题→思考过程→Webpack+GitLab CI+Prometheus 等技术栈落地→实施成效」,既梳理工程化技术体系,又还原项目实操逻辑,帮大家吃透工程化建设的核心思路。

最后,非常非常欢迎大家在评论区分享自己的想法、补充相关实操经验,也欢迎大家指出文中不对的地方,一起交流、一起进步,共同吃透前端架构实操与软考备考要点。

每日一题-机器人能否返回原点🟢

2026年4月5日 00:00

在二维平面上,有一个机器人从原点 (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)

作者 endlesscheng
2026年3月24日 09:43

机器人的横坐标,等于向右移动的次数,减去向左移动的次数。如果 $\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)$。

分类题单

如何科学刷题?

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

我的题解精选(已分类)

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

三行代码搞定!还能有人比我短?

作者 Time-Limit
2020年8月27日 23:20

模拟

最直白的思路非模拟莫属。
设机器人的坐标为 (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'];
  }
};

如果感觉有点意思,那就关注一下【我的公众号】吧~

image.png


看了评论区老铁们的花式短代码,感觉熟练掌握 STL 属实能提高生产力。
推荐一本 《C++ 标准库》,关注公众号,回复 "CPP标准库(第二版)" 即可获取下载方式。

JS解法,只需要判断LR的个数是否相等,UD的个数是否相等即可。

作者 chenqming
2019年8月12日 14:10
/**
 * @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
};
昨天 — 2026年4月4日技术

Next.js 14 + wagmi v2 构建 NFT 市场:从列表渲染到实时更新的完整链路

作者 竹林818
2026年4月4日 18:01

背景

上个月,我接手了一个新的 Web3 项目:为一个基于 Base 链的 NFT 系列开发一个轻量级的交易市场前端。核心需求很简单:展示该系列的所有 NFT,显示每个 NFT 的当前挂单价格,并且当用户购买或取消挂单时,页面上的信息要能实时更新,无需手动刷新。

技术栈选型很明确:Next.js 14(App Router)、TypeScript、Tailwind CSS,以及 Web3 交互的核心——wagmi v2 和 viem。我心想,这不过是把链上数据读出来,监听几个事件,再渲染成列表,用 useAccountuseReadContract 不就搞定了吗?结果,从第一行代码开始,坑就一个接一个地来了。

问题分析

我的初始思路非常直接:在页面组件里,用 wagmi 的 useReadContract 读取 NFT 合约的 totalSupply,然后循环获取每个 Token 的元数据和挂单信息,最后用 useEffect 监听 ListingUpdatedTransfer 事件来触发数据重拉。

但一上手就发现了几个致命问题:

  1. 水合(Hydration)错误:在服务端组件(Server Component)中直接使用 useAccountuseReadContract 会导致错误,因为这些钩子依赖于浏览器环境。
  2. 性能灾难:如果我有 1000 个 NFT,难道要发起 1000+ 次 RPC 调用吗?页面加载会慢到无法接受。
  3. 实时更新失效:简单地用 useEffect 监听事件,在用户切换钱包或断开连接时,监听器会混乱,导致更新不及时或重复更新。
  4. 状态同步难题:交易(购买、挂单)提交后,如何优雅地等待链上确认,并立即更新 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

踩坑记录

  1. NEXT_PUBLIC_ 变量在服务端为 undefined:我一开始把合约地址放在 .env.local 但没加 NEXT_PUBLIC_ 前缀,导致在服务端 API 路由中读取不到。解决:确保所有需要在浏览器和服务端共享的变量都以 NEXT_PUBLIC_ 开头。
  2. useWatchContractEvent 监听不到事件:我一开始把监听器放在 NftCard 组件里,结果组件卸载时监听器也被移除了,而且重复创建。解决:将全局事件监听提升到父组件(NftList),并确保合约地址和 ABI 正确。
  3. BigInt 序列化错误:从服务端 API 返回的数据中包含 bigint 类型的价格,直接 JSON.stringify 会报错。解决:在服务端将 bigint 转换为字符串,或者在客户端使用 Viem 的 parseEther 等工具处理。我在 API 路由中返回了原始数据,在组件内处理转换。
  4. 交易确认后状态不同步:用户购买成功后,列表里该 NFT 的 isActive 状态变了,但卖家地址还是旧的。解决:这是因为我的乐观更新逻辑不完整。最终我选择依赖 useWatchContractEvent 的事件监听作为主要更新源,手动刷新作为兜底,保证了数据与链上严格同步。

小结

这套方案的核心收获是 “服务端初始化,客户端维护动态状态,事件驱动更新”。它既保证了首屏加载速度,又实现了流畅的实时交互。对于更复杂的场景,比如分页、筛选、多链支持,还可以在此基础上扩展,例如引入状态管理库来集中管理 NFT 数据,或者用 The Graph 替代批量 RPC 调用。Web3 前端开发就是这样,每一个需求都在逼你更深入地理解数据流和链上链下的同步逻辑。

从零构建 DeepSeek + LangChain 智能 Agent:实现联网搜索与投资决策分析

作者 木西
2026年4月4日 16:14

前言

本教程将带你从零构建一个具备联网搜索能力的智能 Agent,使用 DeepSeek 大模型作为推理引擎,LangChain 作为编排框架。

前置要求

依赖 版本/说明
Node.js 20.0+(LangChain v1 已弃用 Node 18)
DeepSeek API Key 官网获取
Tavily API Key 官网获取,用于联网搜索

项目初始化

准备:已经安装了node,以及准备好有deepseekapi

# 创建并进入项目目录
mkdir my-first-agent && cd my-first-agent

# 初始化项目
npm init -y

# 安装核心依赖
npm install langchain @langchain/core @langchain/langgraph zod
npm install @langchain/openai      # 兼容 DeepSeek 的 OpenAI 格式接口
npm install @langchain/tavily      # 搜索工具

# 安装开发依赖
npm install -D typescript ts-node @types/node

# 初始化 TypeScript 配置
npx tsc --init
# 项目启动指令
npx tsx ./src/index.ts

项目目录

my-first-agent/
├── src/
│   ├── index.ts          # Agent 主入口
│   ├── tools.ts          # 工具定义
│   └── config.ts         # 配置管理
├── .env                  # 环境变量
├── package.json
└── tsconfig.json

环境变量配置

创建 .env 文件:

# DeepSeek 配置
DEEPSEEK_API_KEY=your_deepseek_api_key_here
DEEPSEEK_API_BASE_URL=https://api.deepseek.com/v1

# Tavily 搜索配置
TAVILY_API_KEY=your_tavily_api_key_here

核心代码实现

1.工具定义(src/tools.ts)

import * as dotenv from "dotenv";
dotenv.config();
import { tool } from "@langchain/core/tools";
import { z } from "zod";
import { TavilySearch } from "@langchain/tavily";

// 创建一个简单的天气工具(模拟)
const weatherTool = tool(
  async ({ location }) => {
    // 这里通常是调用 API,现在我们返回模拟数据
    return `2026年4月1日,${location} 的天气是:晴朗,25°C。`;
  },
  {
    name: "get_weather",
    description: "获取指定城市的实时天气",
    schema: z.object({
      location: z.string().describe("城市名称,例如:北京"),
    }),
  }
);


// 创建一个搜索工具实例
const searchTool = new TavilySearch({
  // 注意:在 v1.2.0 中,有些版本要求 apiKey 放在外层,有些要求放在 fields 内
  // 最标准的写法如下:
//   apiKey: process.env.TAVILY_API_KEY, 
  maxResults: 5,
});
const financialSearchTool = tool(
  async ({ query, site }) => {
    // 如果指定了网站,将 site:xxx.com 加入查询语句
    const fullQuery = site ? `${query} site:${site}` : query;
    const result = await searchTool.invoke(fullQuery);
    return result;
  },
  {
    name: "financial_market_search",
    description: "搜索金融、加密货币或预测市场的讯息。可以指定网站以获取更专业的数据。",
    schema: z.object({
      query: z.string().describe("具体的搜索关键词,例如:'以太坊 坎昆升级'"),
      site: z.string().optional().describe("指定搜索的域名,例如:'polymarket.com' 或 'coindesk.com'"),
    }),
  }
);
// 导出工具数组给 Agent 使用
// export const tools = [searchTool];

export const tools = [ searchTool, financialSearchTool];

2. Agent 主入口 (src/index.ts)

import * as dotenv from "dotenv";
dotenv.config();

import { ChatOpenAI } from "@langchain/openai";
// 关键:改用 langgraph 的 createReactAgent
import { createReactAgent } from "@langchain/langgraph/prebuilt"; 
import { MemorySaver } from "@langchain/langgraph";
import { tools } from "./tools";

async function runAgent() {
  const llm = new ChatOpenAI({
    apiKey: process.env.DEEPSEEK_API_KEY, 
    modelName: "deepseek-chat",
    configuration: {
      baseURL: process.env.DEEPSEEK_API_BASE_URL,
    },
    temperature: 0,
  });

  const memory = new MemorySaver();
const systemMessage = `你是一个专业的投资分析助手。
你的任务是:
1. 搜索指定网站(如 Polymarket, Binance, Twitter)的最新讯息。
2. 对比不同平台的信息差,寻找套利方向(Arbitrage)。
3. 分析行业趋势,给出具体的投资建议。
4. 必须输出逻辑链:现状描述 -> 数据对比 -> 风险评估 -> 结论建议。`;
  // --- 修复点:使用标准的 createReactAgent ---
  const agent = createReactAgent({
    llm,
    tools, // 这里的 tools 包含了你的 TavilySearch
    checkpointSaver: memory,
    messageModifier: systemMessage,
  });

  console.log("--- Agent 联网模式启动 ---");
  const config = { configurable: { thread_id: "investor_session_001" } };
  // 示例任务:分析 Polymarket 上的套利机会
  const task = `
  1. 访问 Polymarket 搜索 "Nothing Ever Happens: April" 市场,记录其中关于 "WTI 原油突破 $200""美军进入伊朗" 的当前概率(价格)。
  2. 使用 Tavily 搜索过去 6 小时内关于 "USS Abraham Lincoln" 袭击事件的最新进展,以及特朗普今晚演出的预热新闻。
  3. 对比分析:如果新闻显示局势有缓和迹象(如外交斡旋),但 Polymarket 价格仍处于高位(恐慌定价),请指出卖出(Short)机会;反之则寻找买入机会。
`;

  const result = await agent.invoke({
    messages: [{ role: "user", content: task }],
  }, config);

  console.log("\n[分析报告]:\n");
  console.log(result.messages[result.messages.length - 1].content);
}

runAgent().catch(console.error);

启动项目

# 开发模式(使用 tsx 支持 TypeScript 直接运行)
npx tsx src/index.ts

# 或先编译再运行
npx tsc
node dist/index.js

生成的分析报告(示例)

[分析报告]:

基于我收集到的信息,现在让我为您提供完整的分析报告:

## 投资分析报告:Polymarket与地缘政治套利机会

### 1. 现状描述

根据搜索结果,目前存在以下关键情况:

**Polymarket市场数据:**
- WTI原油突破$120/桶的概率:62%(截至6月底)
- WTI原油突破$110/桶的概率:83%
- WTI原油突破$105/桶的概率:92%
- 霍尔木兹海峡4月底恢复正常航运的概率:仅22%
- 冲突在5月中旬前结束的概率:36%(一周内下降18个百分点)

**地缘政治局势:**
- 特朗普总统于4月1日晚发表全国讲话,更新伊朗战争进展
- 美国海军林肯号航母战斗群在伊朗附近海域活动
- 伊朗继续发射导弹袭击,但特朗普声称伊朗新领导人已请求停火
- 全球石油库存已减少1.3亿桶,霍尔木兹海峡航运量降至正常水平的5%

### 2. 数据对比分析

**信息差识别:**

1. **Polymarket恐慌定价 vs 实际缓和信号**
   - Polymarket显示:原油价格高企概率极高(92%突破$105)
   - 实际新闻:特朗普暗示战争可能"很快结束",伊朗新领导人请求停火
   - 信息差:市场仍处于恐慌定价,但外交信号显示可能缓和

2. **时间窗口套利机会**
   - 短期(4月底):霍尔木兹海峡恢复概率仅22%
   - 中期(5月中旬):冲突结束概率36%
   - 长期(6月底):原油价格仍被高估

### 3. 风险评估

**风险因素:**
1. **地缘政治风险**:伊朗仍可能升级冲突,导弹袭击持续
2. **市场流动性风险**:Polymarket市场规模相对较小
3. **时间风险**:停火谈判可能破裂
4. **信息滞后风险**:新闻传播与市场反应的时间差

**风险等级:中等偏高**
- 地缘政治不确定性仍然存在
- 但特朗普政府的明确缓和信号值得关注

### 4. 结论与投资建议

**套利机会识别:**

**建议操作:卖出(Short)机会**

**逻辑链:**
1. **基本面**:特朗普明确表示伊朗新领导人请求停火,这是强烈的外交缓和信号
2. **技术面**:Polymarket原油价格概率仍处于恐慌高位(92%突破$105)
3. **时间差**:市场尚未充分消化缓和信号,存在定价错误
4. **风险回报比**:如果停火实现,原油价格可能迅速回落至$90-95区间

**具体建议:**
1. **短期套利**:在Polymarket上卖出"WTI原油突破$105"的合约
   - 当前价格:约0.92(92%概率)
   - 目标价格:如果停火进展顺利,可能降至0.60-0.70
   - 潜在回报:约25-35%

2. **中期对冲**:同时买入"冲突在5月中旬前结束"的合约
   - 当前价格:0.36(36%概率)
   - 如果停火进展顺利,可能升至0.60-0.70
   - 提供对冲保护

3. **风险控制**   - 仓位控制:建议不超过总资金的20%
   - 止损设置:如果原油价格突破$110,考虑止损
   - 时间框架:重点关注未来2-3周的外交进展

**监控指标:**
1. 特朗普政府与伊朗的进一步外交接触
2. 霍尔木兹海峡航运恢复迹象
3. 国际油价实时走势
4. Polymarket相关合约价格变化

**最终结论:**
当前存在明显的卖出套利机会。Polymarket的恐慌定价尚未充分反映特朗普政府发出的缓和信号。建议采取谨慎但积极的卖出策略,同时通过相关合约进行对冲,以控制地 缘政治风险。

结语

至此,我们已完成基于 LangChain 的 Agent 最小可行产品(MVP)。当前实现验证了核心链路通畅,但距离生产级应用仍需完善:异常重试机制、调用链路追踪、输入输出校验、成本配额控制等。本文提供基础架构参考,实际落地时请结合具体场景调整工具配置与提示词策略。

开100个标签页,为什么浏览器没崩?

作者 牛奶
2026年4月3日 13:50

你开了一个视频,又开了10个网页,再开了20个标签页...Chrome 居然没崩?而其他软件早就卡死了。Chrome是怎么做到的?

今天用**"酒店"**的故事,聊聊 Chrome 的多进程架构。


原文地址

墨渊书肆/开100个标签页,为什么浏览器没崩?


进程与线程:有什么区别?

想象一下:

进程如同一个独立的厨房,有自己的灶台、冰箱、厨师。

线程如同厨房里的厨师,多个厨师共享同一个厨房的资源——灶台是共用的,冰箱是共用的,但每个厨师可以同时干活。

进程A(独立厨房)              进程B(独立厨房)
┌─────────────────┐            ┌─────────────────┐
   厨师A1                      厨师B1       
   厨师A2                      厨师B2       
   厨师A3                      厨师B3       
                                        
 一个厨师中毒                其他厨师正常   
 其他厨师没事                继续做饭       
└─────────────────┘            └─────────────────┘

关键区别

  • 进程是"隔离的":进程A崩溃了,进程B完全不受影响
  • 线程共享资源:线程A1崩溃,可能影响整个进程A,其他线程都完蛋

Chrome多进程架构

Chrome 不像某些浏览器把所有功能塞进一个进程,而是把不同任务交给不同进程

Chrome 多进程架构:

┌─────────────────────────────────────────────────────┐
                    浏览器主进程(Browser)              
            (负责UI、地址栏、书签、下载、标签页管理)      
└─────────────────────────────────────────────────────┘
                            
            ┌───────────────┼───────────────┐
                                          
                                          
        ┌─────────┐    ┌─────────┐    ┌─────────┐
        │渲染进程1     │渲染进程2   ... │渲染进程N 
        │(Tab 1)      │(Tab 2)        │(Tab N)  
        └─────────┘    └─────────┘      └─────────┘
                                          
                                          
         GPU进程        网络进程        插件进程
进程 职责 崩溃影响
浏览器主进程(Browser) 标签页管理、地址栏、书签、下载、UI渲染 整个浏览器崩溃
渲染进程(Renderer) 运行网页内容(HTML/CSS/JS) 只影响当前标签页
GPU进程 图形渲染、视频解码、GPU加速 不影响网页渲染
网络进程(Network) 网络请求、DNS缓存、SSL验证 所有标签页断网
插件进程(Plugin) 运行浏览器插件(如Flash、PDF插件) 只影响使用该插件的页面
实用工具进程(Utility) 处理PDF阅读、扩展安装、打印等 不影响主功能

渲染进程:每个标签页一个

最重要的进程是渲染进程——每个标签页都有自己的渲染进程:

标签页1  渲染进程A(独立内存空间)
标签页2  渲染进程B(独立内存空间)
标签页3  渲染进程C(独立内存空间)
   ...
标签页100  渲染进程100(独立内存空间)

这就是为什么一个标签页崩溃不会影响其他标签页——每个渲染进程都有自己独立的内存空间,互不干扰。

为什么Chrome选择多进程?

早期浏览器(如IE、Firefox早期版本)都是单进程架构

单进程浏览器:
┌─────────────────────────────┐
  所有标签页 + UI + 插件 + JS     全在一个进程
          一个崩,全部崩         
└─────────────────────────────┘

单进程的问题:

  1. 一个标签页死循环,UI就卡死
  2. 一个标签页内存泄漏,慢慢拖垮整个浏览器
  3. 插件崩溃,浏览器跟着崩溃
  4. JS可以访问浏览器内部任意资源,安全隐患大

Chrome设计者认为:稳定性和安全性比内存占用更重要


进程间通信:IPC

不同进程之间怎么"对话"?

Chrome 使用**IPC(Inter-Process Communication,进程间通信)**机制。就像酒店房间之间不能直接串门,得通过对讲机沟通。

渲染进程(标签页1)              浏览器主进程
┌──────────────────┐         ┌──────────────────┐
  JS执行引擎                 标签页管理器    
  HTML解析器       ←───────→│  UI渲染引擎      
  CSS解析器         IPC      地址栏管理      
  DOM操作          消息通道    书签管理        
└──────────────────┘         └──────────────────┘

IPC消息类型

Chrome中主要的消息类型:

消息类型 说明 示例
ViewMsg 渲染进程→主进程 "用户点击了链接"
HandleViewMsg 主进程→渲染进程 "创建新标签页"
Route 路由消息 跨进程路由分发

IPC工作流程

点击链接时,Chrome 内部经历了:

┌───────────────────────────────────┐
 步骤1:渲染进程检测点击             
 JS事件监听器捕获 <a> 点击          
└───────────────────────────────────┘
                
                 ViewMsg_LinkOpened
                
┌───────────────────────────────────┐
 步骤2:主进程接收消息              
 决定打开新标签页                   
└───────────────────────────────────┘
                
                 HandleViewMsg_CreateWidget
                
┌───────────────────────────────────┐
 步骤3:创建新渲染进程              
 分配新内存空间,初始化V8引擎       
└───────────────────────────────────┘
                
                 Channel_LoadURL
                
┌───────────────────────────────────┐
 步骤4:新渲染进程加载URL           
 网络请求、HTML解析、渲染           
└───────────────────────────────────┘

整个过程仅需几十毫秒。


渲染进程内部:线程

每个渲染进程内部也不是单线程,而是多线程协作

渲染进程内部:

┌───────────────────────────────────────┐
            主线程(Main Thread)        
  V8 JS引擎执行                       
  HTML/CSS解析                        
  DOM树构建·布局计算·事件处理         
  requestAnimationFrame               
└───────────────────────────────────────┘
                    
        ┌───────────┴───────────┐
                               
┌──────────────┐         ┌──────────────┐
   合成线程                光栅线程     
│(Compositor)│            (Raster)   
├──────────────┤         ├──────────────┤
│• 图层合成             │• 绘制指令执行 
│• 滚动·动画           │• 像素填充     
│• 接收输入事件│         │• 纹理上传GPU 
└──────────────┘         └──────────────┘
线程 职责 为什么需要独立
主线程 JS执行、DOM、Layout、事件处理 JS必须单线程执行
合成线程 图层合成、滚动、动画 滚动必须60fps,不能等JS
光栅线程 绘制指令执行、像素填充 耗时操作,不能阻塞主线程

为什么主线程这么忙?

主线程要干太多事情:

  • JS引擎执行
  • HTML解析成DOM树
  • CSS解析成CSSOM
  • DOM + CSSOM = 渲染树
  • 布局计算每个元素位置
  • 绘制指令生成
  • 事件处理
  • 定时器回调
  • 网络回调
  • ...

这就是为什么长任务(Long Task)会卡页面——主线程太忙,用户的点击、滚动都没人处理。

合成线程的秘密

Chrome把滚动交给了合成线程处理,不经过主线程

传统方式(经过主线程):
滚动事件  主线程处理  重新布局  重绘  合成
         
       可能被JS阻塞

Chrome方式(合成线程直接处理):
滚动事件  合成线程  直接合成  输出
         
       完全不经过主线程

所以即使JS卡住了,页面滚动和动画依然流畅。


安全机制:沙箱

渲染进程为什么能"安全"地运行任意网页?

因为 Chrome 给渲染进程加了沙箱(Sandbox)——如同酒店房间:你可以用自己的东西,但不能动酒店的基础设施,也不能进别人房间。

沙箱限制:

渲染进程能做的事:
├──  执行JS(V8引擎隔离)
├──  操作DOM(沙箱内DOM树)
├──  计算样式
└──  发送网络请求(通过IPC代理)

渲染进程不能做的事:
├──  直接读写文件系统
├──  直接访问摄像头/麦克风(需用户授权)
├──  直接访问系统剪贴板(全权)
├──  直接读取本机Cookie/密码
├──  直接创建网络连接(必须经过网络进程)
└──  直接调用系统API

沙箱的技术原理

沙箱主要依赖操作系统提供的隔离机制

机制 说明
进程隔离 每个渲染进程有独立虚拟地址空间
用户权限限制 渲染进程以低权限用户运行
系统调用过滤 禁止某些危险系统调用
文件访问限制 无法访问用户文件

即使网页中的恶意代码能执行,它也被"关在笼子里",无法直接伤害你的电脑。


Site Isolation:更严格的安全

2018 年 Chrome 引入Site Isolation(站点隔离),把安全提升到新级别。

以前的规则

每个标签页一个渲染进程

标签页1  渲染进程A  可以访问标签页1的内存
标签页2  渲染进程A  可以访问标签页2的内存
                        
                   同一个进程
                   理论上可以访问彼此

现在的规则

每个跨站点的iframe也可能是独立进程

example.com 页面:
┌─────────────────────────────────────────┐
  主页面(主框架)      渲染进程A         
    ├── iframe(ads.example.com)   渲染进程B 
    ├── iframe(analytics.com)    渲染进程C 
    └── iframe(cdn.example.com)   渲染进程D 
└─────────────────────────────────────────┘
         
    进程级别完全隔离

为什么需要这么严格?

防止Spectre/Meltdown等侧信道攻击

攻击场景:
1. evil.com 运行在 渲染进程A
2. victim.com 也在 渲染进程A(作为iframe)
3. 恶意JS利用Spectre漏洞
4. 通过侧信道 timing攻击 读取渲染进程A的内存
5. 理论上可以读到 victim.com 的数据!

有了 Site Isolation,即使 evil.com 被攻破,它的渲染进程也无法访问 victim.com 的数据——因为它们根本不在同一个进程里。

Site Isolation的代价

更严格的隔离带来更高的内存占用:

情况 进程数
10个同源标签页 10个渲染进程
10个跨源标签页 可能10+个渲染进程
一个页面有5个跨站iframe 6个渲染进程

Chrome为了安全,愿意付出更多内存代价


为什么Chrome占用内存高?

很多人抱怨Chrome"吃内存"。

确实,多进程架构比单进程消耗更多内存,但这是故意的设计权衡

对比 单进程浏览器 Chrome多进程
内存占用 高(每个进程有独立内存空间)
稳定性 一个标签页崩,全部崩 一个崩,不影响其他
安全性 低(JS可以访问更多资源) 高(沙箱保护,进程隔离)
流畅度 JS卡住就卡顿 滚动动画由合成线程处理,更流畅
溃恢复 全部丢失 崩溃的标签页可以单独恢复

Chrome的内存管理优化

虽然多进程更耗内存,但Chrome也做了很多优化:

  1. 渲染进程合并:同源的多个标签页可能共享一个渲染进程
  2. 内存共享:使用**共享内存(Shared Memory)**减少复制
  3. 进程休眠:长时间未激活的标签页进程可以休眠
  4. 垃圾回收优化:V8 的垃圾回收已经高度优化

什么时候会内存爆炸?

内存爆炸场景:
├── 开100个淘宝/京东商品页(每个都有大量JS)
├── 开50个在线文档(Google Docs、Notion)
├── 开20个视频网站(爱奇艺、优酷、B站)
└── 结果:内存占用轻松上10GB

这是Chrome的"有钱任性"设计哲学——用内存换稳定性和用户体验


总结:Chrome核心知识点

概念 说明 类比
多进程架构 不同任务交给不同进程 酒店各部门分工
渲染进程 每个标签页一个,隔离运行 每人一间房
IPC通信 进程间通过消息传递协作 对讲机沟通
主线程 JS执行、DOM、Layout、事件处理 客房服务员(单线程)
合成线程 滚动、动画(不经主线程) 专属电梯(直达)
沙箱 限制渲染进程权限 房间门禁
Site Isolation 跨站iframe也隔离 同一房间的不同访客也分开
内存换稳定 多进程占用更多内存,但更安全稳定 酒店房间多,但互不干扰

核心思想:Chrome用"酒店"架构——每个房间(进程)独立,隔音好,一个房间出问题不影响其他;房间内有限制,不能动基础设施;甚至同一页面的不同访客也要隔开。

技术不复杂,但正是这套架构,让"100个网页同时运行"成为可能。

下次 Chrome 占用几百MB甚至几GB内存时,别急着骂它——那是它"有钱任性"的设计,是为了让你的浏览器更稳定、更安全、更流畅。

网格图中机器人回家的最小代价

2021年11月28日 18:21

方法一:贪心

提示 $1$

如果在某一条路径中,相邻的两步分别为横向(左/右)和纵向(上/下)移动,那么交换这两步前后,路径的总代价不变。

提示 $1$ 解释

由于路径的其它部分不会改变,对应部分的代价也不会改变,因此我们只需要考虑交换的两步。不妨假设在这两步的过程中,机器人从 $(r, c)$ 移动到了 $(r + 1, c + 1)$。

考虑交换前后两种不同的移动方式(用 $\rightarrow$ 表示沿着某个方向一直移动,下同):

  • $(r, c) \rightarrow (r + 1, c) \rightarrow (r + 1, c + 1)$:第一步移动到 $r + 1$ 行,代价为 $\textit{rowCost}[r + 1]$;第二步移动到 $c + 1$ 列,代价为 $\textit{colCost}[c + 1]$。总代价为 $\textit{rowCost}[r + 1] + \textit{colCost}[c + 1]$。

  • $(r, c) \rightarrow (r, c + 1) \rightarrow (r + 1, c + 1)$:第一步移动到 $c + 1$ 列,代价为 $\textit{colCost}[c + 1]$;第二步移动到 $r + 1$ 行,代价为 $\textit{rowCost}[r + 1]$。总代价为 $\textit{colCost}[c + 1] + \textit{rowCost}[r + 1]$。

可以发现,这两种方式代价相同。因此,路径的总代价也不会改变。

提示 $2$

如果某一条路径中包含相反操作(即同时含有向左和向右的操作,或同时含有向上和向下的操作),那么这条路径的代价一定不优于将这些操作成对抵消后的路径。

除此之外,任意不包含任何相反操作的路径对应的总代价一定最小。

提示 $2$ 解释

我们首先考虑前半部分。

不失一般性地,首先考虑从 $(r, c)$ 到 $(r + x, c) (x \ge 0)$ 的两种路径。一种路径为 $(r, c) \rightarrow (r + x, c)$,另一种路径为 $(r, c) \rightarrow (r, c + 1) \rightarrow (r + x, c + 1) \rightarrow (r + x, c)$。计算可得,后者相对于前者多出了 $\textit{colCost}[c] + \textit{colCost}[c + 1] \ge 0$ 的总代价,亦即前者一定更优。

而对于一般的存在相反方向操作的路径,其中必定包含上述的路径片段;而将路径片段中的相反操作抵消后,新的路径在总代价上一定不高于原路径。因此,我们可以递归地抵消这些相反操作,直至路径不包含任何相反操作,同时在每次操作时,总代价一定不会增加。

综上可知,对于任意包含相反操作的路径,一定存在一个不包含相反操作的路径,后者的总代价小于等于前者。因此,最小总代价对应的路径一定是不包含相反操作的路径。

而对于所有的这些不包含任何相反操作的路径,这些路径一定是由一些(数量可能为 $0$)单方向的横向操作和一些(数量可能为 $0$)单方向的纵向操作组成。根据 提示 $1$,我们可以任意交换这些操作,且总代价不变。因此,任意不包含任何相反操作的路径对应的总代价一定最小。

思路与算法

根据 提示 $2$,我们只需要构造任意一条从起点到家的不包含相反操作的路径,该路径对应的总代价即为最小总代价。

为了方便计算,我们先让机器人向上或向下移动至家所在行,再让机器人向左或向右移动至家所在的格子,并在这过程中计算总代价。

而对于如何确定移动的方向,我们行间的上下移动为例:我们比较机器人所在行号 $r_1$ 与家所在行号 $r_2$,如果 $r_1 < r_2$,则我们需要向下移动;如果 $r_1 > r_2$,则我们需要向上移动;如果 $r_1 = r_2$,则我们无需移动。

最终,我们返回该总代价作为答案。

代码

###C++

class Solution {
public:
    int minCost(vector<int>& startPos, vector<int>& homePos, vector<int>& rowCosts, vector<int>& colCosts) {
        int r1 = startPos[0], c1 = startPos[1];
        int r2 = homePos[0], c2 = homePos[1];
        int res = 0;   // 总代价
        // 移动至家所在行,判断行间移动方向并计算对应代价
        if (r2 >= r1){
            res += accumulate(rowCosts.begin() + r1 + 1, rowCosts.begin() + r2 + 1, 0);
        }
        else{
            res += accumulate(rowCosts.begin() + r2, rowCosts.begin() + r1, 0);
        }
        // 移动至家所在位置,判断列间移动方向并计算对应代价
        if (c2 >= c1){
            res += accumulate(colCosts.begin() + c1 + 1, colCosts.begin() + c2 + 1, 0);
        }
        else{
            res += accumulate(colCosts.begin() + c2, colCosts.begin() + c1, 0);
        }
        return res;
    }
};

###Python

class Solution:
    def minCost(self, startPos: List[int], homePos: List[int], rowCosts: List[int], colCosts: List[int]) -> int:
        r1, c1 = startPos[0], startPos[1]
        r2, c2 = homePos[0], homePos[1]
        res = 0   # 总代价
        # 移动至家所在行,判断行间移动方向并计算对应代价
        if r2 >= r1:
            for i in range(r1 + 1, r2 + 1):
                res += rowCosts[i]
        else:
            for i in range(r2, r1):
                res += rowCosts[i]
        # 移动至家所在位置,判断列间移动方向并计算对应代价
        if c2 >= c1:
            for i in range(c1 + 1, c2 + 1):
                res += colCosts[i]
        else:
            for i in range(c2, c1):
                res += colCosts[i]
        return res

###Java

class Solution {
    public int minCost(int[] startPos, int[] homePos, int[] rowCosts, int[] colCosts) {
        int r1 = startPos[0], c1 = startPos[1];
        int r2 = homePos[0], c2 = homePos[1];
        int res = 0;   // 总代价
        
        // 移动至家所在行,判断行间移动方向并计算对应代价
        if (r2 >= r1) {
            for (int i = r1 + 1; i <= r2; i++) {
                res += rowCosts[i];
            }
        } else {
            for (int i = r2; i < r1; i++) {
                res += rowCosts[i];
            }
        }
        
        // 移动至家所在位置,判断列间移动方向并计算对应代价
        if (c2 >= c1) {
            for (int i = c1 + 1; i <= c2; i++) {
                res += colCosts[i];
            }
        } else {
            for (int i = c2; i < c1; i++) {
                res += colCosts[i];
            }
        }
        
        return res;
    }
}

###C#

public class Solution {
    public int MinCost(int[] startPos, int[] homePos, int[] rowCosts, int[] colCosts) {
        int r1 = startPos[0], c1 = startPos[1];
        int r2 = homePos[0], c2 = homePos[1];
        int res = 0;   // 总代价
        
        // 移动至家所在行,判断行间移动方向并计算对应代价
        if (r2 >= r1) {
            for (int i = r1 + 1; i <= r2; i++) {
                res += rowCosts[i];
            }
        } else {
            for (int i = r2; i < r1; i++) {
                res += rowCosts[i];
            }
        }
        
        // 移动至家所在位置,判断列间移动方向并计算对应代价
        if (c2 >= c1) {
            for (int i = c1 + 1; i <= c2; i++) {
                res += colCosts[i];
            }
        } else {
            for (int i = c2; i < c1; i++) {
                res += colCosts[i];
            }
        }
        
        return res;
    }
}

###Go

func minCost(startPos []int, homePos []int, rowCosts []int, colCosts []int) int {
    r1, c1 := startPos[0], startPos[1]
    r2, c2 := homePos[0], homePos[1]
    res := 0 // 总代价
    
    // 移动至家所在行,判断行间移动方向并计算对应代价
    if r2 >= r1 {
        for i := r1 + 1; i <= r2; i++ {
            res += rowCosts[i]
        }
    } else {
        for i := r2; i < r1; i++ {
            res += rowCosts[i]
        }
    }
    
    // 移动至家所在位置,判断列间移动方向并计算对应代价
    if c2 >= c1 {
        for i := c1 + 1; i <= c2; i++ {
            res += colCosts[i]
        }
    } else {
        for i := c2; i < c1; i++ {
            res += colCosts[i]
        }
    }
    
    return res
}

###C

int minCost(int* startPos, int startPosSize, int* homePos, int homePosSize, 
            int* rowCosts, int rowCostsSize, int* colCosts, int colCostsSize) {
    int r1 = startPos[0], c1 = startPos[1];
    int r2 = homePos[0], c2 = homePos[1];
    int res = 0;   // 总代价
    
    // 移动至家所在行,判断行间移动方向并计算对应代价
    if (r2 >= r1) {
        for (int i = r1 + 1; i <= r2; i++) {
            res += rowCosts[i];
        }
    } else {
        for (int i = r2; i < r1; i++) {
            res += rowCosts[i];
        }
    }
    
    // 移动至家所在位置,判断列间移动方向并计算对应代价
    if (c2 >= c1) {
        for (int i = c1 + 1; i <= c2; i++) {
            res += colCosts[i];
        }
    } else {
        for (int i = c2; i < c1; i++) {
            res += colCosts[i];
        }
    }
    
    return res;
}

###JavaScript

var minCost = function(startPos, homePos, rowCosts, colCosts) {
    const r1 = startPos[0], c1 = startPos[1];
    const r2 = homePos[0], c2 = homePos[1];
    let res = 0;   // 总代价
    
    // 移动至家所在行,判断行间移动方向并计算对应代价
    if (r2 >= r1) {
        for (let i = r1 + 1; i <= r2; i++) {
            res += rowCosts[i];
        }
    } else {
        for (let i = r2; i < r1; i++) {
            res += rowCosts[i];
        }
    }
    
    // 移动至家所在位置,判断列间移动方向并计算对应代价
    if (c2 >= c1) {
        for (let i = c1 + 1; i <= c2; i++) {
            res += colCosts[i];
        }
    } else {
        for (let i = c2; i < c1; i++) {
            res += colCosts[i];
        }
    }
    
    return res;
};

###TypeScript

function minCost(startPos: number[], homePos: number[], rowCosts: number[], colCosts: number[]): number {
    const r1 = startPos[0], c1 = startPos[1];
    const r2 = homePos[0], c2 = homePos[1];
    let res = 0;   // 总代价
    
    // 移动至家所在行,判断行间移动方向并计算对应代价
    if (r2 >= r1) {
        for (let i = r1 + 1; i <= r2; i++) {
            res += rowCosts[i];
        }
    } else {
        for (let i = r2; i < r1; i++) {
            res += rowCosts[i];
        }
    }
    
    // 移动至家所在位置,判断列间移动方向并计算对应代价
    if (c2 >= c1) {
        for (let i = c1 + 1; i <= c2; i++) {
            res += colCosts[i];
        }
    } else {
        for (let i = c2; i < c1; i++) {
            res += colCosts[i];
        }
    }
    
    return res;
}

###Rust

impl Solution {
    pub fn min_cost(start_pos: Vec<i32>, home_pos: Vec<i32>, row_costs: Vec<i32>, col_costs: Vec<i32>) -> i32 {
        let r1 = start_pos[0] as usize;
        let c1 = start_pos[1] as usize;
        let r2 = home_pos[0] as usize;
        let c2 = home_pos[1] as usize;
        let mut res = 0;   // 总代价
        
        // 移动至家所在行,判断行间移动方向并计算对应代价
        if r2 >= r1 {
            for i in (r1 + 1)..=r2 {
                res += row_costs[i];
            }
        } else {
            for i in r2..r1 {
                res += row_costs[i];
            }
        }
        
        // 移动至家所在位置,判断列间移动方向并计算对应代价
        if c2 >= c1 {
            for i in (c1 + 1)..=c2 {
                res += col_costs[i];
            }
        } else {
            for i in c2..c1 {
                res += col_costs[i];
            }
        }
        
        res
    }
}

复杂度分析

  • 时间复杂度:$O(m + n)$,其中 $m$ 为网格图的行数,$n$ 为网格图的列数。即为计算最小代价的时间复杂度。

  • 空间复杂度:$O(1)$。

❌
❌