普通视图

发现新文章,点击刷新页面。
今天 — 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月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内存时,别急着骂它——那是它"有钱任性"的设计,是为了让你的浏览器更稳定、更安全、更流畅。

前端基础项目:Drumkit,敲击乐实现路径

作者 YAwu11
2026年4月4日 17:08

Drumkit 是一个面向前端初学者的交互式项目,核心功能是通过键盘按键触发对应鼓垫单元的视觉反馈。该项目不涉及音频播放,专注于 DOM 操作与 CSS 交互的完整链路。以下从 HTML 结构、CSS 样式、JavaScript 逻辑三个层面解析其实现路径。

一、HTML 速写与 Emmet 简写

HTML 代码构建了页面的内容骨架。八个鼓垫单元分别对应键盘上的 A、D、C、B、T、R、G、M 键。

代码片段 1:鼓垫单元的结构

<div class="word" data-key="65">
    <h1>A</h1>
    <span class="sound">tele</span>
</div>

每个鼓垫单元使用 div 元素承载,类名为 word。自定义属性 data-key 存储对应按键的键码值,键码 65 代表字母 A,68 代表 D,67 代表 C,以此类推。该属性建立了键盘按键与 DOM 元素之间的映射关系,为 JavaScript 提供查找依据。

在编写多个结构相似的鼓垫单元时,Emmet 简写技术可以显著提升效率。输入 div.word[data-key]>h1+span.sound 并按下展开键,编辑器自动生成完整的 HTML 结构。开发者仅需修改 data-key 属性值与内部文本内容即可完成所有单元。

代码片段 2:脚本与样式的引入位置

<link rel="stylesheet" href="./drumkit.css" />
<!-- 页面内容 -->
<script src="./script.js"></script>

CSS 文件通过 <link> 标签放置在 head 中,确保样式在页面渲染前加载。JavaScript 文件通过 <script> 标签放置在 body 结束标签之前,这一位置选择与脚本执行机制直接相关。

二、CSS 基础内容:布局与交互样式

CSS 代码完成页面视觉表现与交互反馈样式的定义。以下从重置样式、布局系统、视觉美化、交互反馈四个维度分析。

代码片段 3:全局重置与视口高度

*{
    margin:0;
    padding:0;
}
body,html{
    height:100%;
}

通配符选择器 * 将所有元素的 marginpadding 归零,消除浏览器默认边距差异。htmlbody 高度设置为 100%,为后续 Flex 布局提供高度参照。

代码片段 4:Flex 布局实现居中

.all{
    display:flex;
    min-height: 100vh; 
    align-items:center;
    justify-content:center;
}

.all 容器作为父元素,通过 display: flex 开启弹性盒模型。min-height: 100vh 使容器最小高度占满整个视口。align-items: center 控制子元素在交叉轴(垂直方向)居中,justify-content: center 控制子元素在主轴(水平方向)居中。四个属性共同实现了所有鼓垫单元的视口中央排列。

代码片段 5:鼓垫单元的基础样式

.word{
    border:.4rem solid black;
    margin:1rem;
    width:10rem;
    background:rgba(0,0,0,0.4);
    text-shadow:0 0 .5rem black;
}

.word 类定义了鼓垫单元的固定宽度 10rem、黑色边框以及半透明黑色背景。rem 单位相对于根元素 html 的字体大小,根元素已设置为 10px,因此 1rem 等于 10pxrgba(0,0,0,0.4) 表示黑色通道值为 0,透明度为 0.4,实现半透明效果。

代码片段 6:交互反馈样式

.playing{
    transform: scale(1.5);
    box-shadow:0 0 1rem #ffc009;
    border-color:#ffc009;
}

.playing 类定义了按键触发时的视觉反馈。transform: scale(1.5) 将元素放大至原尺寸的 1.5 倍。box-shadow 生成向外扩散的亮黄色阴影,border-color 将边框颜色改为 #ffc009。当该类被添加到 .word 元素时,放大与高亮效果同时生效。

CSS 代码中的错误提示.key sound 选择器不会匹配任何元素,因为 HTML 中不存在类名为 key 的元素。正确选择器应为 .word .sound

三、JavaScript 脚本存放与 DOM 编程

JavaScript 代码实现键盘事件监听与 DOM 元素操作。以下从脚本存放位置、事件绑定、动态查询、类名操作四个层面解析。

代码片段 7:脚本存放位置与 DOMContentLoaded 事件

document.addEventListener('DOMContentLoaded',function(){
    // 核心逻辑
});

脚本文件放置在 body 结束标签之前,但代码中仍使用了 DOMContentLoaded 事件。该事件在初始 HTML 文档完全加载和解析后触发,无需等待样式表与图片。双重保障确保 DOM 元素可被安全查询。

代码片段 8:事件处理函数的结构

function playSound(event){
    console.log(event.keyCode,'////////////');
    let dataCode = event.keyCode;
    let element = document.querySelector('.word[data-key="'+dataCode+'"]');
    console.log(element); 
    element.classList.add('playing');
}

playSound 函数接收键盘事件对象 event 作为参数。event.keyCode 属性返回被按下按键的键码值,控制台输出用于调试验证。

动态 DOM 编程的核心体现在 document.querySelector 方法。该方法接受 CSS 选择器字符串,返回匹配的第一个元素。此处使用属性选择器 '.word[data-key="' + dataCode + '"]',通过字符串拼接将键码值嵌入选择器。当按下 A 键时,dataCode65,生成的选择器为 '.word[data-key="65"]',精确匹配对应的鼓垫单元。

代码片段 9:键盘事件绑定

window.addEventListener('keydown',playSound);

window.addEventListenerkeydown 事件与 playSound 处理函数绑定到全局窗口对象。任何键盘按键被按下时,playSound 函数均被调用,通过键码匹配决定是否触发视觉反馈。

查找到目标元素后,classList.add('playing') 为元素添加 playing 类。classList 是 DOM 元素提供的类名操作接口,add 方法向类列表中添加新类。添加完成后,CSS 中预定义的放大与阴影样式立即生效。

JavaScript 代码的潜在问题:当按下未在 data-key 中定义的按键时,document.querySelector 返回 null,后续调用 classList.add 会抛出错误。实际开发中应增加条件判断:if (element) { element.classList.add('playing'); }

四、核心学习内容总结

Drumkit 项目集中训练了五项前端基础能力,其核心链路与知识点占整体内容的 70% 以上。

Emmet 简写:通过 div.word[data-key]>h1+span.sound 类表达式快速生成重复 HTML 结构,减少手动编码时间。

HTML 速写:自定义属性 data-key 的命名与赋值,属性选择器 [data-key="value"] 的语法规则,以及脚本与样式文件的引入位置规范。

CSS 基础内容:通配符选择器重置样式、Flex 布局的四个核心属性(displaymin-heightalign-itemsjustify-content)、remvh 视口单位、rgba() 半透明背景、transform: scale() 变换、box-shadow 阴影效果、类名切换驱动样式变化的交互模式。

JavaScript 脚本存放<script> 标签放置在 body 末尾的原因(避免阻塞 DOM 构建)、DOMContentLoaded 事件的作用与用法。

JavaScript DOM 编程document.querySelector 动态查询元素、属性选择器的字符串拼接、classList.add 操作类名、window.addEventListener 绑定键盘事件、事件对象 event.keyCode 属性的读取。

该项目的完整交互链路为:用户按键 → 触发 keydown 事件 → 读取 event.keyCode → 拼接属性选择器 → 调用 querySelector 查找元素 → 调用 classList.add 添加类名 → CSS 渲染放大与高亮样式。这一链路覆盖了前端事件处理与 DOM 操作的核心流程,是学习组件化开发与框架交互的基础原型。

跨越边界的艺术:现代 Web 开发跨域解决方案终极指南

作者 有意义
2026年4月4日 16:46

一、跨域的本质:同源策略是什么?

想要解决跨域问题,首先要明白“跨域”从何而来。

1. 同源策略的定义

浏览器的同源策略(Same-Origin Policy) 是跨域的核心根源,它是浏览器最核心也最基本的安全功能。
所谓“同源”,要求两个页面的以下三点必须完全相同:

  • 协议(http/https)
  • 域名(包括主域名、子域名)
  • 端口号(80/443/3000等)

image.png 只要三者有其一不同,就会被判定为“跨域”。此时,浏览器会限制非同源页面的以下行为:

  • 读取非同源网页的 Cookie、LocalStorage、IndexedDB 等存储数据。
  • 获取非同源网页的 DOM 元素。
  • 向非同源地址发送 AJAX 请求(XMLHttpRequest/fetch)。

2. 为什么需要同源策略?

同源策略就像一道“防火墙”,从根本上限制了恶意网站的非法操作,保障了用户的信息安全。试想一下,如果没有同源策略:

  • 恶意网站可以轻易读取你网银页面的 Cookie,盗取账户信息。
  • 钓鱼网站可以嵌入真实的电商页面,篡改支付金额。
  • 任意网站都能向你的服务器发送伪造请求,发起 CSRF 攻击。

3. 跨域的常见场景

日常开发中,跨域几乎无处不在:

  • 前后端分离项目:前端运行在 localhost:5173,后端接口在 localhost:3000(端口不同)。
  • 调用第三方接口:如支付、地图、天气等第三方服务(域名不同)。
  • 多端协作:公司内部不同部门的系统对接(子域名不同)。

二、方案 1:JSONP——兼容性拉满的“老古董”

JSONP(JSON with Padding)是跨域方案中的“老前辈”,也是早期前端解决跨域最常用的方式,最大的优势是浏览器兼容性极好(甚至能兼容 IE6/7)。

1. JSONP 的核心原理

浏览器的同源策略限制了 AJAX 请求,但并没有限制 <script> 标签的 src 属性。<script> 可以加载任意域名的资源(比如 CDN 上的 jQuery)。
JSONP 正是利用这一“漏洞”实现跨域:

  • 前端动态创建 <script> 标签,通过 src 向跨域接口发送请求,同时传递一个回调函数名。
  • 后端接收到请求后,将数据包裹在回调函数中返回(即“JSON with Padding”)。
  • 前端的回调函数被执行,从而拿到跨域数据。

2. JSONP 实战实现

前端代码(封装 JSONP 函数)
这段代码封装了一个返回 Promise 的 JSONP 函数,便于处理异步逻辑:

// 封装JSONP请求函数,返回Promise方便异步处理
function jsonp({ url, params, callback }) {
  return new Promise((resolve, reject) => {
    // 1. 创建script标签
    let script = document.createElement('script')
    // 2. 定义全局回调函数,接收后端返回的数据
    window[callback] = function(data) {
      resolve(data) // 成功拿到数据,resolve Promise
      document.body.removeChild(script) // 移除script标签,避免污染
    }
    // 3. 拼接请求参数(包含回调函数名)
    params = { ...params, callback } // 比如:{wd: 'test', callback: 'show'}
    let arrs = []
    for (let key in params) {
      arrs.push(`${key}=${params[key]}`)
    }
    // 4. 设置script的src属性,发送请求
    script.src = `${url}?${arrs.join('&')}`
    document.body.appendChild(script)
    // 5. 处理请求失败场景
    script.onerror = function() {
      reject(new Error('JSONP请求失败'))
      document.body.removeChild(script)
    }
  })
}

// 调用JSONP请求
jsonp({
  url: 'http://localhost:3000/say',
  params: { wd: 'Iloveyou' },
  callback: 'show'
}).then(data => {
  console.log('JSONP请求结果:', data)
}).catch(err => {
  console.error(err)
})

后端代码(Node.js 原生实现)
后端需要接收回调函数名,并将数据包裹在函数调用中返回:

const http = require('http');
const server = http.createServer((req, res) => {
  // 匹配/say接口
  if (req.url.startsWith('/say')) {
    // 解析URL参数
    const url = new URL(req.url, `http://${req.headers.host}`);
    const callback = url.searchParams.get('callback'); // 获取回调函数名
    
    // 设置响应头:返回JS脚本
    res.writeHead(200, { 'Content-type': 'text/javascript' });
    // 构造返回数据,包裹在回调函数中
    const data = {
      id: 1,
      username: 'admin',
      msg: 'JSONP请求成功'
    }
    // 核心:返回 "回调函数(数据)" 格式的JS代码
    res.end(`${callback}(${JSON.stringify(data)})`);
  } else {
    res.writeHead(404);
    res.end('Not Found')
  }
})

server.listen(3000, () => {
  console.log('JSONP服务器运行在 http://localhost:3000');
})

3. JSONP 的优缺点

表格

维度 详细说明
✅ 优点 兼容性极强:支持所有主流浏览器,包括低版本 IE。 实现简单:无需复杂的配置,前端后端少量代码即可完成。
❌ 缺点 仅支持 GET 请求:因为 <script> 标签的 src 只能发起 GET 请求。 安全风险:容易遭受 XSS 攻击(加载的脚本可能包含恶意代码),需确保请求的服务器是可信的。 性能问题:额外加载的 <script> 标签会阻塞页面渲染,影响首屏加载速度。

4. 适用场景

仅推荐在兼容老旧浏览器(如需要支持 IE6/7)的场景下使用。在现代项目中,优先选择其他方案。

三、方案 2:CORS——现代跨域的主流之选

CORS(跨域资源共享,Cross-Origin Resource Sharing)是W3C制定的标准,也是目前解决跨域问题最主流、最推荐的方式。它本质上是在HTTP协议之上,通过增加特定的请求头和响应头,让浏览器和服务器协同工作,来判断一个跨域请求是否被允许。

1. CORS 的核心原理

CORS 的核心思想是:将跨域的控制权从浏览器完全转移到服务器。

1. 浏览器发起请求:当浏览器发起一个跨域请求时,它会自动在请求头中添加 Origin 字段,标明请求的来源(协议、域名、端口)。

2. 服务器决策:服务器接收到请求后,根据 Origin 字段的值来判断是否允许这个来源的请求。

3. 服务器响应:如果服务器允许该请求,它会在响应头中添加 Access-Control-Allow-Origin 字段,其值就是被允许的源。

4. 浏览器检查:浏览器收到响应后,会检查响应头中的 Access-Control-Allow-Origin 是否与请求的 Origin 匹配。如果匹配,则将响应数据返回给前端JS代码;如果不匹配,则浏览器会拦截响应,并在控制台抛出跨域错误。

2. 简单请求 vs 预检请求

CORS 将跨域请求分为两类:简单请求预检请求

简单请求 一个请求要成为简单请求,必须同时满足以下条件:

请求方法是以下之一:GET, HEAD, POST

自定义请求头:除了浏览器自动设置的 Accept, Accept-Language, Content-Language, Content-Type 等,没有添加其他自定义请求头。

Content-Type 的值仅限于:application/x-www-form-urlencoded, multipart/form-data, text/plain

对于简单请求,浏览器会直接发送,并在请求头中带上 Origin

预检请求 不满足简单请求条件的,就是预检请求。例如,使用 PUTDELETE 方法,或者 Content-Typeapplication/json,又或者添加了自定义请求头(如 token)。

对于预检请求,浏览器会先自动发起一个 OPTIONS 方法的请求(这就是“预检”),询问服务器是否允许当前的跨域请求。只有在服务器明确回复“允许”后,浏览器才会真正发起后续的请求。

3. CORS 实战实现

后端代码(Node.js + Express) 使用 Express 框架时,可以借助 cors 中间件轻松实现 CORS。

const express = require('express');
const cors = require('cors'); // 引入cors中间件
const app = express();

// 1. 允许所有来源的跨域请求 (最宽松的配置)
app.use(cors());

// 2. 或者,进行更精细的配置
const corsOptions = {
  origin: 'http://localhost:5173', // 只允许这个源访问
  methods: ['GET', 'POST', 'PUT'], // 允许的请求方法
  allowedHeaders: ['Content-Type', 'Authorization'], // 允许的自定义请求头
  credentials: true // 允许携带Cookie等凭证
};
app.use(cors(corsOptions));

// 定义一个需要跨域访问的接口
app.get('/api/data', (req, res) => {
  res.json({ message: 'CORS请求成功!', data: [1, 2, 3] });
});

app.listen(3000, () => {
  console.log('CORS服务器运行在 http://localhost:3000');
});

后端代码(Node.js 原生实现) 如果不使用框架,也可以手动设置响应头。

const http = require('http');
const server = http.createServer((req, res) => {
  // 设置允许跨域的源,* 表示允许所有源
  res.setHeader('Access-Control-Allow-Origin', '*');
  // 允许的请求方法
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
  // 允许的自定义请求头
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
  // 允许携带凭证(如Cookie)
  res.setHeader('Access-Control-Allow-Credentials', 'true');

  // 处理预检请求
  if (req.method === 'OPTIONS') {
    res.writeHead(204); // 204 No Content
    res.end();
    return;
  }

  // 处理其他业务请求
  if (req.url === '/api/data') {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ message: 'CORS请求成功!', data: [1, 2, 3] }));
  }
});

server.listen(3000, () => {
  console.log('CORS服务器运行在 http://localhost:3000');
});

4. 关键的 CORS 响应头

响应头 说明
Access-Control-Allow-Origin 必需。指定允许访问的源,可以是 *(通配符)或具体的源(如 http://example.com)。
Access-Control-Allow-Methods 预检请求必需。指定允许的请求方法,如 GET, POST, PUT
Access-Control-Allow-Headers 预检请求必需。指定允许的自定义请求头,如 Content-Type, Authorization
Access-Control-Allow-Credentials 可选。一个布尔值,表示是否允许浏览器发送 Cookie。如果为 true,则 Access-Control-Allow-Origin 不能为 *,必须是具体的源。
Access-Control-Max-Age 可选。指定预检请求的缓存时间(秒),避免频繁发送 OPTIONS 请求。

5. CORS 的优缺点

维度 详细说明
优点 功能强大:支持所有类型的 HTTP 请求(GET, POST, PUT, DELETE 等)。安全性高:通过服务器精确控制允许的源、方法和请求头。现代标准:被所有现代浏览器支持,是前后端分离项目的最佳实践。
缺点 需要后端配合:必须在服务器端进行配置,前端无法单方面解决。配置复杂:对于需要携带凭证或复杂请求头的场景,配置相对繁琐。兼容性问题:不支持 IE9 及以下版本。

6. 适用场景

CORS 是现代 Web 开发中解决跨域问题的首选方案,尤其适用于前后端分离的架构。只要后端能够配合修改响应头,就应该优先使用 CORS。

四、方案 3:反向代理——“曲线救国”的万能钥匙

反向代理是开发环境中解决跨域问题最常用、也最省心的方法之一。它的核心思想是 “曲线救国” :既然浏览器禁止前端直接访问后端接口,那我们就让前端请求一个和自己“同源”的代理服务器,再由这个代理服务器去请求真正的后端接口。

1. 反向代理的核心原理

1.  前端请求代理:前端应用(如运行在 localhost:5173)不再直接请求后端接口(如 localhost:3000/api),而是请求一个与自己同源的代理地址(如 localhost:5173/api)。因为源相同,所以不会触发浏览器的跨域限制。

2.  代理服务器转发:这个代理服务器(通常是开发服务器或 Nginx)收到请求后,会以自己的身份向真正的后端接口(localhost:3000/api)发起请求。这个请求是服务器与服务器之间的通信,不受浏览器同源策略的限制。

3. 代理服务器返回:代理服务器拿到后端接口的响应数据后,再原封不动地返回给前端。 通过这种方式,前端巧妙地绕过了浏览器的跨域限制,实现了数据的获取。

2. 反向代理实战实现

反向代理的实现方式多种多样,从开发环境的配置到生产环境的部署,都有其身影。

开发环境:Vite/Webpack 配置 在现代前端框架(如 Vue, React)的开发环境中,我们通常使用 Vite 或 Webpack 作为开发服务器。它们都内置了强大的代理功能,只需几行配置即可解决跨域问题。

Vite 配置示例 ( vite.config.js )

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  server: {
    // 配置代理规则
    proxy: {
      // 当前端请求 /api 路径时,触发代理
      '/api': {
        target: 'http://localhost:3000', // 代理的目标服务器地址
        changeOrigin: true, // 修改请求头中的 Origin 为目标服务器的 Origin
        // rewrite: (path) => path.replace(/^/api/, '') // 可选:重写路径,去掉 /api 前缀
      }
    }
  }
})

配置完成后,前端请求 http://localhost:5173/api/data,开发服务器会自动将其转发到 http://localhost:3000/api/data

生产环境:Nginx 配置 在项目部署到生产环境时,Nginx 是最常用的反向代理服务器。它不仅能处理跨域,还能提供负载均衡、静态资源服务、缓存等多种功能。

Nginx 配置示例 ( nginx.conf )

server {
    listen       80;
    server_name  localhost; # 或者你的域名

    # 1. 配置前端静态文件
    location / {
        root   /usr/share/nginx/html; # 前端打包文件的路径
        index  index.html index.htm;
        try_files $uri $uri/ /index.html; # 解决前端路由 history 模式刷新404的问题
    }

    # 2. 配置后端接口代理
    location /api/ {
        proxy_pass http://backend_server:3000/; # 后端服务器的地址
        proxy_set_header Host $host;             # 传递原始主机头
        proxy_set_header X-Real-IP $remote_addr; # 传递用户真实IP
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

这样配置后,无论是前端页面还是 /api/ 开头的接口请求,都由同一个 Nginx 服务器处理,完美规避了跨域问题。

3. 反向代理的优缺点

维度 详细说明
优点 前端无感:前端代码无需任何修改,完全不用关心跨域问题。功能强大:除了跨域,还能实现负载均衡、请求/响应拦截、日志记录等。通用性强:适用于任何类型的请求,不受请求方法和请求头的限制。
缺点 增加服务器成本:需要额外部署和维护一台代理服务器(如 Nginx)。配置相对复杂:相比 CORS,反向代理的配置(尤其是 Nginx)需要一定的运维知识。可能增加延迟:请求多了一次转发,理论上会增加一点点网络延迟。

4. 适用场景

● 开发环境:强烈推荐使用 Vite/Webpack 的代理功能,是开发阶段解决跨域问题的首选。

● 生产环境:当你无法控制后端服务器(例如调用第三方API),或者后端团队不方便配合配置 CORS 时,使用 Nginx 反向代理是最佳选择。它也是微服务架构中 API 网关的雏形。

五、方案 4:WebSocket——实时通信的“特权通道”

WebSocket 是一种在单个 TCP 连接上进行全双工通信的协议。它最大的特点是不受同源策略的限制,这使得它在需要实时双向通信的场景下,成为了一个天然的跨域解决方案。

1. WebSocket 的核心原理

WebSocket 的工作流程可以分为三个阶段:

1. 握手阶段:前端通过 JavaScript 创建一个 WebSocket 对象,浏览器会向服务器发起一个特殊的 HTTP 请求。这个请求头中包含 Upgrade: websocket 字段,表示希望将协议从 HTTP 升级到 WebSocket。

2. 协议升级:服务器收到请求后,如果支持 WebSocket,会返回一个状态码为 101 (Switching Protocols) 的响应,同意协议升级。

3. 数据传输:一旦握手成功,客户端和服务器之间就建立了一条持久的 TCP 连接。此后,双方可以随时主动向对方推送数据,而无需像 HTTP 那样由客户端反复发起请求。

正是因为 WebSocket 在握手成功后就脱离了 HTTP 协议的范畴,建立了一条独立的“管道”,所以浏览器不会对其应用同源策略的限制。

2. WebSocket 实战实现

前端代码 前端使用非常简单,只需几行代码即可建立连接并监听事件。

// 1. 创建 WebSocket 连接,传入服务器地址
// 注意:协议是 ws:// (或 wss:// 用于加密连接)
const socket = new WebSocket('ws://localhost:3000');

// 2. 监听连接成功事件
socket.onopen = function(event) {
  console.log('WebSocket 连接已建立');
  // 连接成功后,可以立即向服务器发送数据
  socket.send(JSON.stringify({ type: 'init', data: 'Hello Server!' }));
};

// 3. 监听服务器发来的消息
socket.onmessage = function(event) {
  console.log('收到服务器消息:', event.data);
  const data = JSON.parse(event.data);
  // 根据消息类型处理数据
  if (data.type === 'notification') {
    alert(data.message);
  }
};

// 4. 监听连接关闭事件
socket.onclose = function(event) {
  console.log('WebSocket 连接已关闭');
};

// 5. 监听连接错误事件
socket.onerror = function(event) {
  console.error('WebSocket 发生错误:', event);
};

// 随时可以向服务器发送数据
function sendMsg() {
  socket.send('这是一条新消息');
}

后端代码(Node.js + ws 库) 后端可以使用 ws 这个轻量级的 WebSocket 库来快速搭建服务器。

const WebSocket = require('ws');

// 创建一个 WebSocket 服务器,监听 3000 端口
const wss = new WebSocket.Server({ port: 3000 });

// 监听客户端连接事件
wss.on('connection', (ws) => {
  console.log('有客户端连接进来了');

  // 向当前连接的客户端发送欢迎消息
  ws.send(JSON.stringify({ type: 'welcome', message: '欢迎连接到 WebSocket 服务器' }));

  // 监听当前客户端发来的消息
  ws.on('message', (data) => {
    console.log('收到客户端消息:', data.toString());
    // 可以将消息广播给所有连接的客户端
    wss.clients.forEach((client) => {
      if (client.readyState === WebSocket.OPEN) {
        client.send(`广播: ${data}`);
      }
    });
  });

  // 监听客户端断开连接
  ws.on('close', () => {
    console.log('客户端连接已关闭');
  });
});

console.log('WebSocket 服务器运行在 ws://localhost:3000');

3. WebSocket 的优缺点

维度 详细说明
优点 天然跨域:不受同源策略限制,无需额外配置。实时双向通信:服务器可以主动向客户端推送数据,延迟极低。持久连接:只需一次握手,即可保持长时间通信,减少了 HTTP 反复建立连接的开销。
缺点 协议不同:需要服务器和客户端都支持 WebSocket 协议,不适用于传统的 HTTP 请求场景。兼容性问题:不支持 IE9 及以下版本。连接管理复杂:需要处理连接的建立、维持、断开和重连,比简单的 HTTP 请求更复杂。

4. 适用场景

WebSocket 专为实时性要求高的场景而生,例如:

● 在线聊天/即时通讯:如微信网页版、在线客服。

● 实时数据推送:如股票行情、体育比赛比分、新闻快讯。

● 协同编辑:如在线文档、代码编辑器。

● 多人在线游戏:需要实时同步玩家状态。


六、方案 5:Vite 反向代理 —— 本地开发的 “最优解”

在前端本地开发阶段,我们经常会遇到这样的场景:前端项目运行在 http://localhost:5173,而后端接口运行在 http://localhost:3000。虽然这只是开发环境下的端口不同,但在浏览器看来这就是“跨域”。

虽然可以通过后端配置 CORS 来解决,但在开发阶段,更优雅、更安全的方式是利用开发服务器(如 Vite、Webpack)进行反向代理。这种方式不需要后端做任何改动,完全由前端工具链来处理跨域问题。

1. Vite 代理的核心原理

Vite 代理的本质是利用了开发服务器(Dev Server)作为“中间人”。

  • 前端视角:前端代码发起请求时,目标地址是 Vite 服务器(例如 /api/user)。
  • 同源策略豁免:因为 Vite 服务器就是提供前端页面的服务端,所以前端请求 /api/user 属于“同源请求”,浏览器不会拦截。
  • 服务器转发:Vite 服务器接收到请求后,发现这是一个代理请求,于是它会以“服务器身份”向真正的后端接口(例如 http://localhost:3000/api/user)发起请求。
  • 响应返回:后端接口将数据返回给 Vite 服务器,Vite 再将数据返回给前端浏览器。

关键点:浏览器与 Vite 服务器之间是同源的(无跨域);Vite 服务器与后端服务器之间是服务器间的通信(不受浏览器同源策略限制)。通过这种“曲线救国”的方式,完美绕过了浏览器的跨域限制。

2. Vite 代理实战配置

Vite 内置了强大的代理功能,基于 http-proxy 中间件实现。我们只需要在 vite.config.js 中进行简单的配置即可。

配置步骤:

  1. 打开项目根目录下的 vite.config.js 文件。
  2. 在 server 选项中添加 proxy 配置。
  3. 定义需要代理的路径前缀(如 /api)。
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  server: {
    // 配置代理规则
    proxy: {
      // 1. 定义代理前缀
      // 当请求路径以 '/api' 开头时,触发代理
      '/api': {
        // 2. 目标服务器地址
        // 这里填写后端接口的真实地址
        target: 'http://localhost:3000',
        
        // 3. 是否改变请求头中的 Origin
        // 设置为 true 时,Vite 会将请求头的 Host 改为目标服务器的 Host
        // 避免后端因为 Origin 校验不通过而拒绝请求
        changeOrigin: true,
        
        // 4. 路径重写 (可选)
        // 如果后端不需要 '/api' 这个前缀,可以将其重写为空
        // 例如:前端请求 '/api/user' -> 后端接收 '/user'
        rewrite: (path) => path.replace(/^/api/, '')
      },
      
      // 5. 多个代理配置 (可选)
      // 如果有多个不同的后端服务,可以继续添加
      '/upload': {
        target: 'http://upload-server.com',
        changeOrigin: true
      }
    }
  }
})

3. 配置项详解

表格

配置项 类型 说明
target String 必需。你要代理到的目标地址,即后端接口的真实域名或 IP。
changeOrigin Boolean 推荐开启。设为 true 时,会自动修改请求头中的 host 为 target 的值。很多后端框架(如 Nginx、Java Spring)会校验 host,不开启可能导致 403/404 错误。
rewrite Function 可选。用于重写请求路径。例如,如果后端接口不需要前端定义的前缀(如 /api),可以用此函数将其替换或删除。
secure Boolean 如果目标是 https 接口,设为 false 可以忽略 HTTPS 证书校验(开发环境常用)。

4. 优缺点分析

优点:

  • 开发环境专用神器:无需后端配合,前端开发者自己就能搞定,不影响生产环境配置。
  • 无跨域风险:完全在开发服务器层面处理,浏览器根本感知不到跨域的存在。
  • 配置极其简单:Vite 内置功能,几行代码即可完成,且支持 TypeScript 配置。
  • 支持 WebSocket:Vite 代理也支持代理 WebSocket 连接(配置 ws: true)。

缺点:

  • 仅限开发环境:Vite 代理只在 vite dev 启动的开发服务器中生效。项目打包上线后,Vite 服务器不再运行,代理配置也随之失效。
  • 无法解决生产环境问题:它只是一个开发时的“模拟器”,不能用于解决线上环境的跨域问题。

5. 适用场景

  • 前端本地开发:这是该方案的唯一且最佳场景。
  • 接口联调阶段:在后端尚未部署或无法修改响应头时,前端通过代理快速进行联调。
  • Mock 数据切换:配合环境变量,可以在代理真实接口和本地 Mock 服务之间灵活切换。

八、方案 7:postMessage —— 跨域通信的“万能信使”

前文提到的 JSONP、CORS、反向代理等方案,主要解决的是“浏览器向服务器请求数据”的跨域问题。但在现代 Web 开发中,我们经常会遇到“页面与页面”、“窗口与窗口”之间需要通信的场景,例如:

  • 父页面与嵌入的跨域 iframe 进行数据交互。
  • 主窗口与通过 window.open() 打开的跨域子窗口同步状态。
  • 主线程与 Web Worker 之间传递消息。

对于这些场景,postMessage 是 HTML5 提供的标准解决方案,它就像一个“万能信使”,允许不同源的窗口之间安全地进行双向通信。

1. postMessage 的核心原理

postMessage 的核心思想是 “消息传递” 而非“直接访问”。它打破了浏览器的同源策略,但并没有完全移除安全限制。

  • 发送方:通过 targetWindow.postMessage(message, targetOrigin) 方法,向目标窗口发送一条结构化的数据消息。
  • 接收方:通过监听 window 对象的 message 事件,来捕获并处理来自其他窗口的消息。
  • 安全校验:整个过程通过 targetOrigin(发送时指定目标源)和 event.origin(接收时检查消息来源)进行双重安全校验,确保消息只在你信任的窗口之间传递。

2. postMessage 实战实现

我们以最常见的 父页面与跨域 iframe 通信 为例,展示如何实现双向通信。

父页面 (parent.html)

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>父页面</title>
</head>
<body>
    <h1>我是父页面</h1>
    <!-- 嵌入一个跨域的 iframe -->
    <iframe id="childFrame" src="https://iframe-example.com/child.html"></iframe>

    <script>
        const iframe = document.getElementById('childFrame');

        // 1. 向 iframe 发送消息
        // 注意:必须等待 iframe 加载完成后再发送
        iframe.onload = () => {
            const data = { type: 'GREETING', text: 'Hello from parent!' };
            // 精确指定目标源,这是安全的关键!
            iframe.contentWindow.postMessage(data, 'https://iframe-example.com');
        };

        // 2. 监听来自 iframe 的消息
        window.addEventListener('message', (event) => {
            // 【安全红线】必须校验消息来源!
            if (event.origin !== 'https://iframe-example.com') {
                console.warn('收到来自非法源的消息,已忽略');
                return;
            }
            console.log('父页面收到消息:', event.data);
        });
    </script>
</body>
</html>

子页面 (child.html)

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>子页面 (iframe)</title>
</head>
<body>
    <h1>我是子页面 (iframe)</h1>
    <script>
        // 1. 监听来自父页面的消息
        window.addEventListener('message', (event) => {
            // 【安全红线】同样必须校验消息来源!
            if (event.origin !== 'https://parent-example.com') {
                return;
            }
            console.log('子页面收到消息:', event.data);

            // 2. 向父页面回复消息
            const replyData = { type: 'REPLY', text: 'Hello back!' };
            // event.source 是发送消息的窗口对象的引用
            event.source.postMessage(replyData, event.origin);
        });
    </script>
</body>
</html>

3. API 详解

发送消息:targetWindow.postMessage(message, targetOrigin)

表格

参数 类型 说明
message 任意类型 要发送的数据。可以是字符串、对象、数组等,数据会被浏览器使用“结构化克隆算法”进行序列化。
targetOrigin String 安全关键!  指定接收消息的窗口的源(协议+域名+端口)。必须精确指定,严禁在生产环境使用通配符 '*' ,否则可能导致敏感数据泄露给恶意网站。

接收消息:window.addEventListener('message', callback)

回调函数接收一个 MessageEvent 对象,其中包含三个关键属性:

表格

属性 类型 说明
event.data 任意类型 发送方传递的实际消息数据。
event.origin String 安全关键!  发送消息的窗口的源。接收方必须校验此属性,确保消息来自可信源。
event.source Window 对象 发送消息的窗口对象的引用。可用于向发送方回传消息,实现双向通信。

4. 优缺点分析

优点:

  • 功能强大:解决了页面间通信的跨域问题,这是 CORS 和代理无法做到的。
  • 双向通信:支持父子窗口、主副窗口之间的双向消息传递。
  • 安全性高:通过 targetOrigin 和 event.origin 的双重校验,可以有效防止恶意攻击。
  • 数据灵活:支持传递复杂的结构化数据。

缺点:

  • 使用场景特定:仅适用于窗口间通信,不适用于常规的 AJAX 请求。
  • 安全要求高:开发者必须手动进行源校验,任何疏忽都可能导致严重的安全漏洞(如 XSS)。
  • 异步通信:基于事件模型,处理复杂交互时逻辑可能变得分散。

5. 适用场景

  • 第三方组件集成:如嵌入支付宝/微信支付的 iframe,支付完成后通知父页面。
  • 跨域单点登录(SSO) :通过一个中央登录页,使用 postMessage 将登录令牌传递给其他域名的应用。
  • 微前端架构:主应用与子应用之间进行状态同步和事件通知。
  • 多窗口协作:如在线协作文档,主窗口打开多个编辑窗口,并同步光标位置和编辑内容。
  • Web Worker:主线程与后台线程进行数据交换。

深入浅出PureMVC框架:从理论到Unity实战

作者 UnityMozh
2026年4月4日 16:22

一、PureMVC是什么?

一句话定义:PureMVC是一个轻量级的、基于经典MVC模式的应用程序框架,核心目标是将代码按职责分离,实现高内聚低耦合

PureMVC框架链接puremvc.org/ 找到对应的语言下载即可

二、为什么需要PureMVC?

没有框架的Unity项目长什么样?

csharp

// 典型“上帝类”写法 —— 所有逻辑堆在一个MonoBehaviour里
public class BattleManager : MonoBehaviour
{
    public UIManager ui;
    public NetworkManager network;
    public DataManager data;
    
    void OnEnemyDie()
    {
        ui.ShowVictoryPanel();
        data.AddExp(100);
        network.SendBattleResult();
        // 越往后越难维护...
    }
}

问题

  1. 代码耦合度高,改一个地方炸一片
  2. 新人不敢改老代码
  3. 单元测试几乎不可能写
  4. 多人协作时频繁冲突

三、PureMVC核心组件详解

1. Facade —— 统一入口(门面)

角色定位:整个框架的“前台总机”,所有对外操作都通过它。

(简单来说就是Mediator、Proxy、Command之间都不会互相调用,因为这样会非常的复杂,不便于维护,而是统一通过调用Facade里的类似GetProxy()、GetMediator()函数来间接获取目标引用,这样通过在Facade实现统一的接口来让外部调用的方法很好的解决了模块间直接引用导致的逻辑混乱)

csharp

// 标准用法:项目里写一个自己的Facade继承基类
public class GameFacade : Facade
{
    // 单例访问
    public static GameFacade Instance => instance as GameFacade;
    
    // 启动框架
    public void Startup()
    {
        // 注册Proxy
        RegisterProxy(new UserProxy());
        RegisterProxy(new BagProxy());
        
        // 注册Command(绑定事件与处理逻辑)
        RegisterCommand(NotificationName.LOGIN, () => new LoginCommand());
        RegisterCommand(NotificationName.BAG_ADD_ITEM, () => new AddItemCommand());
        
        // 注册Mediator(通常由UIManager在打开界面时动态注册)
    }
}

// 业务代码中调用
GameFacade.Instance.SendNotification(NotificationName.LOGIN, userData);

核心职责

  • 初始化Model、View、Controller三大核心模块
  • 注册/获取Proxy、Mediator、Command
  • 发送Notification

2. Proxy —— 数据与业务代理

角色定位:管理某一类数据,以及操作这些数据的方法。

csharp

// 数据代理:管理玩家背包
public class BagProxy : Proxy
{
    // Proxy名称(用于跨模块获取)
    public new const string NAME = "BagProxy";
    
    // 实际数据
    public List<Item> Items { get; private set; } = new List<Item>();
    
    // 业务方法
    public void AddItem(Item item)
    {
        Items.Add(item);
        // 数据变了,发通知告诉UI更新
        SendNotification(NotificationName.BAG_UPDATE, Items.Count);
    }
    
    public bool HasItem(int itemId)
    {
        return Items.Any(item => item.Id == itemId);
    }
    
    public void RemoveItem(int itemId)
    {
        Items.RemoveAll(item => item.Id == itemId);
        SendNotification(NotificationName.BAG_UPDATE);
    }
}

// 其他地方获取并使用
var bagProxy = GameFacade.Instance.RetrieveProxy(BagProxy.NAME) as BagProxy;
bagProxy.AddItem(new Item(10001, "红药水"));

关键理解

  • Proxy 只发Notification,不收Notification —— 这是PureMVC刻意设计,保证Model层独立,不依赖其他层
  • Proxy可以持有网络请求逻辑(但盛趣项目通常网络层单独封装,Proxy只负责调用)

3. Mediator —— 视图的中介

角色定位:UI界面和PureMVC系统之间的“翻译官”。

csharp

// 中介者:管理一个背包面板
public class BagMediator : Mediator
{
    public new const string NAME = "BagMediator";
    
    // 持有的UI组件引用
    private BagPanel bagPanel;
    
    // 构造函数:传入View组件
    public BagMediator(BagPanel panel) : base(NAME)
    {
        bagPanel = panel;
        bagPanel.OnItemClick += HandleItemClick; // 监听UI事件
    }
    
    // 声明感兴趣的通知(订阅)
    public override IList<string> ListNotificationInterests()
    {
        return new List<string>
        {
            NotificationName.BAG_UPDATE,
            NotificationName.ITEM_USE_RESULT
        };
    }
    
    // 处理通知
    public override void HandleNotification(INotification notification)
    {
        switch (notification.Name)
        {
            case NotificationName.BAG_UPDATE:
                UpdateBagView();
                break;
            case NotificationName.ITEM_USE_RESULT:
                ShowUseResult(notification.Body as string);
                break;
        }
    }
    
    // 视图更新逻辑
    private void UpdateBagView()
    {
        var bagProxy = Facade.RetrieveProxy(BagProxy.NAME) as BagProxy;
        bagPanel.RefreshItems(bagProxy.Items);
    }
    
    // UI事件响应
    private void HandleItemClick(Item item)
    {
        // Mediator不处理业务逻辑,发个通知交给Command
        SendNotification(NotificationName.USE_ITEM, item.Id);
    }
    
    // Mediator销毁时的清理
    public override void OnRemove()
    {
        bagPanel.OnItemClick -= HandleItemClick;
        base.OnRemove();
    }
}

关键理解

  • Mediator 既不持有数据(数据在Proxy),也不处理业务逻辑(逻辑在Command)
  • Mediator只做两件事:监听UI事件→发Notification出去 + 收到Notification→更新UI

4. Command —— 业务逻辑命令

角色定位:执行具体的业务操作,可以调用多个Proxy协同工作。

csharp

// 简单命令:使用物品
public class UseItemCommand : SimpleCommand
{
    public override void Execute(INotification notification)
    {
        int itemId = (int)notification.Body;
        
        var bagProxy = Facade.RetrieveProxy(BagProxy.NAME) as BagProxy;
        var roleProxy = Facade.RetrieveProxy(RoleProxy.NAME) as RoleProxy;
        
        if (bagProxy.HasItem(itemId))
        {
            bagProxy.RemoveItem(itemId);
            roleProxy.AddHp(100);
            
            // 发通知让UI刷新
            SendNotification(NotificationName.ROLE_HP_UPDATE);
            SendNotification(NotificationName.USE_ITEM_SUCCESS, itemId);
        }
        else
        {
            SendNotification(NotificationName.USE_ITEM_FAIL, "物品不存在");
        }
    }
}

// 宏命令:执行一系列命令(比如登录流程)
public class LoginMacroCommand : MacroCommand
{
    public override void InitializeMacroCommand()
    {
        AddSubCommand(() => new CheckVersionCommand());    // 1. 检查版本
        AddSubCommand(() => new ConnectServerCommand());   // 2. 连接服务器
        AddSubCommand(() => new AuthCommand());            // 3. 身份验证
        AddSubCommand(() => new LoadRoleDataCommand());    // 4. 加载角色数据
        AddSubCommand(() => new EnterGameCommand());       // 5. 进入游戏
    }
}

Command的特点

  • 无状态:每次执行都创建新实例(由框架管理)
  • 单一职责:一个Command只做一件事
  • 可组合:MacroCommand可以把多个SimpleCommand串起来

5. Notification —— 通信的载体

角色定位:模块间传递消息的信封。

csharp

// 定义通知名称常量(避免字符串硬编码)
public static class NotificationName
{
    public const string LOGIN = "login";
    public const string LOGOUT = "logout";
    public const string BAG_UPDATE = "bag_update";
    public const string USE_ITEM = "use_item";
    public const string USE_ITEM_SUCCESS = "use_item_success";
}

// 发送通知的三种重载
SendNotification(NotificationName.LOGIN);                        // 只有名称
SendNotification(NotificationName.LOGIN, loginData);             // 带body(数据)
SendNotification(NotificationName.LOGIN, loginData, "extra");    // 带body和type

Notification与C#事件的对比

特性 C#事件 PureMVC Notification
解耦程度 中等(需要持有发布者引用) 高(完全不知道谁在收)
调试难度 容易跟踪 难(字符串匹配)
性能 快(直接委托调用) 稍慢(反射+装箱拆箱)
跨模块通信 需要统一的事件总线 天然支持

四、完整流程示例:登录功能

把上面所有组件串起来,看一个完整的登录流程:

csharp

// 1. 启动框架(GameManager中)
GameFacade.Instance.Startup();

// 2. UI按钮点击 -> 打开登录面板时注册Mediator
LoginPanel panel = UIManager.Open<LoginPanel>();
GameFacade.Instance.RegisterMediator(new LoginMediator(panel));

// 3. 用户点击登录按钮 -> Mediator监听到UI事件
public class LoginMediator : Mediator
{
    private LoginPanel panel;
    
    public LoginMediator(LoginPanel panel) : base("LoginMediator")
    {
        this.panel = panel;
        panel.OnLoginClick += (user, pwd) => 
            SendNotification(NotificationName.LOGIN, new LoginData(user, pwd));
    }
}

// 4. Command处理登录业务
public class LoginCommand : SimpleCommand
{
    public override void Execute(INotification notification)
    {
        var loginData = notification.Body as LoginData;
        var userProxy = Facade.RetrieveProxy(UserProxy.NAME) as UserProxy;
        
        // 调用网络层发送登录请求
        NetworkManager.Instance.Login(loginData.User, loginData.Pwd, (success, msg) =>
        {
            if (success)
            {
                userProxy.SetUserInfo(msg);
                SendNotification(NotificationName.LOGIN_SUCCESS);
                SendNotification(NotificationName.OPEN_MAIN_PANEL);
            }
            else
            {
                SendNotification(NotificationName.LOGIN_FAIL, msg);
            }
        });
    }
}

// 5. 登录成功 -> 关闭登录界面,打开主界面
public class LoginSuccessCommand : SimpleCommand
{
    public override void Execute(INotification notification)
    {
        // 移除登录Mediator
        Facade.RemoveMediator("LoginMediator");
        // 打开主界面并注册其Mediator
        MainPanel mainPanel = UIManager.Open<MainPanel>();
        Facade.RegisterMediator(new MainMediator(mainPanel));
    }
}

五、PureMVC在Unity中的注意事项

1. MonoBehaviour与PureMVC的关系

原则:Mediator持有MonoBehaviour的引用,但Mediator本身不继承MonoBehaviour。

csharp

// 错误:让Mediator继承MonoBehaviour
public class BadMediator : MonoBehaviour, IMediator { } // ❌

// 正确:Mediator是纯C#类
public class GoodMediator : Mediator  // 不继承MonoBehaviour ✓
{
    private GoodPanel panel; // 持有MonoBehaviour引用
}

2. 生命周期管理

组件 创建时机 销毁时机
Facade 游戏启动时 游戏结束时
Proxy 游戏启动时注册 游戏结束时
Command 每次执行时new 执行完后销毁
Mediator 打开UI时注册 关闭UI时移除

3. 性能优化建议

  • 避免频繁发Notification:一帧内多次发同一个通知可以合并
  • Mediator的ListNotificationInterests返回缓存列表,不要每次new
  • Command尽量轻量:耗时操作要用协程或异步

六、PureMVC的优缺点总结

优点

  1. 约定大于配置:规定好了代码该放哪,团队协作不吵架
  2. 完全解耦:改一个模块基本不影响其他模块
  3. 可测试性强:Proxy和Command可以脱离Unity写单元测试
  4. 学习成本低:核心概念就5个,一两天就能上手

缺点

  1. 代码冗余:一个小功能也要建Mediator+Command+Proxy三个类
  2. Notification难追踪:字符串事件名,谁发谁收不直观
  3. 反射开销:框架内部大量使用反射,但影响不大
  4. 不够Unity原生:不支持MonoBehaviour的生命周期方法

HTTP 演进史:每次升级都在解决什么痛点?

作者 有意义
2026年4月4日 15:24

模块一:引言

HTTP(HyperText Transfer Protocol) 是基于 TCP/IP 的应用层协议,是互联网数据通信和网页传输的基石。

它采用请求-响应模式,是无状态协议,特别适合海量用户的高 并发访问场景。

HTTP 是一种请求-响应模式的协议:客户端发起请求,服务器返回响应。过程非常 简单,却极其强大——正是这种简洁性,让 HTTP 得以快速普及,成为互联网的"通用语言"。

同时,它也是无状态协议——服务器不会记得你上一次请求说了什么。每次请求都是 独立的。这种设计让它能够轻松应对海量并发访问,但也催生了 Session、Cookie、Token 等会话管理机制。

从 1991 年 HTTP 0.9 诞生至今,经历了多次重大升级。本文 将从历史演进的角度,带你系统回顾 HTTP 协议的发展脉络。

模块二:HTTP 0.9 和 HTTP 1.0 — 协议的诞生与初探

HTTP 0.9(1991年)— 一切的开始

1991年,万维网(World Wide Web)的发明者 Tim Berners-Lee 发布了 HTTP
的第一个版本。

这是一个极其简单的协议,只有一行命令:

GET /index.html

服务器直接返回 HTML 文档内容,传输完成就断开连接。

它的特点:

  • 仅支持 GET 方法
  • 无请求头、无响应头
  • 只能传输纯 HTML 文本
  • 每次请求都是一个新的 TCP 连接

现在的眼光看,HTTP 0.9 简陋得难以置信。但正是这个简单的开始,开启了互联网时代的大门。

HTTP 1.0(1996年)— 走向标准化

随着互联网蓬勃发展,HTTP 0.9 已经不够用了。1996年,HTTP 1.0 正式发布,协议开始走向标准化。

1. 多种请求方法

HTTP 1.0 扩展了请求方法:

  • GET — 从服务器读取资源
  • POST — 向服务器提交数据
  • HEAD — 仅获取响应头,不返回正文

POST 的出现意义重大——它让表单提交成为可能,Web 开始从"只读"走向"可写"。

2. 请求头(Headers)

HTTP 1.0 创新性地引入了请求头和响应头的概念,让客户端和服务器能够传递元数据。

常用请求头:

  • User-Agent — 客户端信息,比如浏览器版本、操作系统
  • Cookie — 会话标识,服务端下发的会话 ID
  • Content-Type — 请求体格式,比如 application/json
  • Accept — 可接受的响应类型,比如 text/html, */
  • Authorization — 认证信息,比如 Bearer token 或 Basic auth

User-Agent 是其中最有意思的一个。它的格式大致如下:

Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36

这一串标识的意思是:

image.png

  • Mozilla/5.0 最初是 Netscape 浏览器的标识,后来所有浏览器都兼容这个标识
  • (Macintosh; Intel Mac OS X 10_15_7) 告诉你操作系统和硬件信息
  • AppleWebKit/537.36 是渲染引擎
  • Chrome/146.0.0.0 是浏览器版本号
  • Safari/537.36 是为了兼容 Safari

这就是为什么国内早期要区分 PC 站和移动站——不同的 User-Agent 告诉服务器你用的是什么设备,服务器就返回不同的页面。

3. 短连接

HTTP 1.0 依然是短连接模式:每次请求都要先 TCP三次握手建立连接,请求完成后四次挥手断开连接,下一次请求再重新建连。

一个网页可能有几十个资源——HTML、CSS、JS、图片、字体……每个都要单独建立和断开 TCP 连接。这种方式在当时互联网规模还不大的时候勉强够用,但随着网页资源越来越多,性能问题开始凸显。

HTTP 1.0 为协议奠定了基本框架:确立了请求-响应模式,引入了 Headers
的概念,支持多种请求方法。 但它也有很多局限:短连接效率低、无状态导致会话管理困难、明文传输不安全…… 这些问题的解决方案,都留给了下一代的 HTTP 1.1。

模块四:HTTP 2.0 — 性能飞跃

HTTP 1.1 虽然大大提升了 Web的能力,但它的核心问题——对头阻塞——始终没有解决。

2015年,HTTP 2.0发布,专门针对这个痛点进行了底层重构。

1. 二进制分帧

HTTP 1.1 的数据是明文传输的,所有数据混在一起,没有边界,没有编号,想插队 是不可能的。

举个例子:

假设浏览器同时请求:

  - 流 1:index.html
  - 流 3:style.css
  - 流 5:app.js

  HTTP 2.0 的传输可能是这样的——帧交错在一起:

  流1帧头 → 流3帧头 → 流1数据 → 流5帧头 → 流3数据 → 流1数据 → ...

  到达接收端之后,按流 ID 分开重组:

  流1:帧1-1 + 帧1-2 + 帧1-3 → 拼成 index.html
  流3:帧3-1 + 帧3-2          → 拼成 style.css
  流5:帧5-1 + 帧5-2          → 拼成 app.js

为什么能解决对头阻塞?

因为每个流都有自己的 ID,独立重组。假设流 1 的某个帧丢了,只影响流1,流3和流5 照样传输、照样重组,完全不受影响。

2. 多路复用

基于二进制分帧,HTTP 2.0 实现了真正的多路复用(Multiplexing):

  • 一个 TCP 连接中可以并发多个请求
  • 每个请求都有一个独立的流 ID
  • 帧可以交错发送,按流 ID 归类重组

举个例子:

 浏览器要请求 index.html、style.css、app.js 三个资源。

  HTTP 1.1 时代:

  同一个域名只能开 1-6 个 TCP
  连接(浏览器限制),每个请求必须等上一个响应回来才能发下一个:

  TCP连接1:GET /index.html → 等 → 收到响应 → GET /style.css → 等 →        
  收到响应 → GET /app.js ...
  TCP连接2:GET /app.js     → 等 → 收到响应
  ...

  如果 index.html 卡住了,后面 style.css 和 app.js 只能在后面排队等。      

  HTTP 2.0 时代:

  一个 TCP 连接就够了。三个请求分属三个流,同时发送:

  一个 TCP 连接里:
  → 流1: GET /index.html
  → 流3: GET /style.css
  → 流5: GET /app.js
  ← 流1: 返回 index.html 数据帧
  ← 流3: 返回 style.css 数据帧
  ← 流5: 返回 app.js 数据帧

  三个流并行跑,互不等待,互不阻塞。

这从根本上解决了 HTTP 1.1 的对头阻塞问题

3. 服务器推送

传统的请求模式是:浏览器请求 HTML → 服务器返回 HTML → 浏览器解析 HTML
发现需要 CSS/JS → 再去请求 CSS/JS。

HTTP 2.0 支持服务器推送(Server Push):服务器知道浏览器需要什么资源,主动把 CSS/JS 推送给浏览器,浏览器还没请求就已经收到了。

这样就省去了浏览器反复请求的延迟。

4. 头部压缩

HTTP 2.0 还对 Header 进行了压缩。因为一个请求的 Header 往往有几百字节,而实际数据可能只有几字节,Header 的开销非常大。

HTTP 2.0 使用 HPACK 算法压缩 Header,进一步减少了传输量。


HTTP 2.0 的意义

HTTP 2.0 通过二进制分帧和多路复用,从根本上解决了 HTTP 1.1 的对头阻塞问题,让 Web 性能有了质的飞跃。

但它仍然有一个隐患——底层还是 TCP。TCP 在传输层也有对头阻塞问题,一旦丢包,整个 TCP 连接上的所有流都会受影响。

这个问题的最终解决方案,就是 HTTP 3.0。

模块五:HTTP 3.0 — QUIC 革命

HTTP 2.0 虽然解决了应用层的对头阻塞,但它的底层还是 TCP。TCP在传输层同样存在对头阻塞——一旦丢包,整个 TCP连接上的所有数据都要等待重传,所有流都被卡住。

HTTP 3.0 就是为了彻底解决这个问题。

HTTP 3.0 的核心是 QUIC(Quick UDP Internet Connections),一种基于 UDP的传输协议。

UDP 的特点是什么?无连接、不重传、不管顺序——简单粗暴,速度快,但不可靠。

QUIC

在 UDP 之上实现了自己的可靠传输逻辑,把 TCP 的优点移植过来,同时避免了
TCP 的缺点。

HTTP 3.0 的核心改进

1. 彻底抛弃 TCP,全程 UDP

HTTP 3.0 不再使用 TCP,而是直接基于 QUIC。没有 TCP 三次握手,改用 QUIC
自己的连接建立逻辑。

2. 每个流独立,不再互相影响

QUIC 把连接分成多个流(Stream)。丢包了?只影响当前流,其他流照常跑。这就 是真正的无对头阻塞。

3. 0-RTT 快速建立连接

第一次连接需要 1-RTT,之后可以做到 0-RTT——客户端直接发送数据,连接已经建立了。

4. 内置 TLS

HTTP 3.0 把加密直接做进了传输层,而不是像 HTTPS 那样单独跑一个 TLS
层。QUIC 本身就支持加密,而且比 TLS 更快。

一句话总结

HTTP 3.0 = HTTP 2.0 的所有特性 + QUIC(基于 UDP)= 彻底解决对头阻塞 +
更快建立连接 + 内置加密


模块六:总结

一部解决痛点的历史回顾 HTTP 的演进,有一条清晰的脉络:

 HTTP 0.9HTTP 1.0HTTP 1.1HTTP 2.0HTTP 3.0

每一次升级,都是为了解决上一代暴露出来的核心问题。


  • HTTP 0.9 太简陋,只能发 GET 请求,于是 HTTP 1.0 加入了 POST、HEAD、请求头和响应头。

  • HTTP 1.0 每次请求都要重新建连,效率太低,于是 HTTP 1.1 引入了长连接,多个请求可以复用同一个 TCP 连接。

  • HTTP 1.1 的管道化听起来很美,但响应没有编号,一个请求卡住后面全部排队,实际被浏览器弃用。

  • HTTP 2.0 用二进制分帧和流 ID 彻底解决了这个问题,一个 TCP 连接里可以并发多个请求,互不阻塞。

HTTP 2.0 看起来很完美了,但底层还是 TCP。TCP本身的传输层对头阻塞没有解决,一旦丢包,所有流都受影响。

  • HTTP 3.0 直接抛弃TCP,改用基于 UDP 的 QUIC,每个流独立可靠传输,彻底告别了对头阻塞。

各版本一句话定位

  • HTTP 0.9:一行 GET,一切的开端
  • HTTP 1.0:引入 Header,走向标准化
  • HTTP 1.1:长连接为主,但应用层对头阻塞无法根治
  • HTTP 2.0:二进制分帧 + 多路复用,从根本上解决对头阻塞
  • HTTP 3.0:基于 QUIC(UDP),彻底解决对头阻塞,更快、更安全

面试该怎么答

当面试官问起 HTTP 的演进时,不要只背区别,要讲清为什么需要这些升级。

比如被问到"HTTP 2.0 相比 1.1 有什么改进",可以这样答:

HTTP 1.1 虽然有长连接,但响应没有编号,存在对头阻塞问题。HTTP 2.0
通过二进制分帧和流 ID,把每个请求拆成带编号的帧,在同一个 TCP 连接里并发传输,按流 ID 重组,彻底解决了对头阻塞。

顺着"问题→解决方案→新问题→再解决"的逻辑讲下去

【节点】[Negate节点]原理解析与实际应用

作者 SmalBox
2026年4月4日 14:43

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

Negate 节点

在 Unity URP Shader Graph 中,Negate 节点是一个功能简单但用途广泛的数学运算节点。它执行最基本的数学操作之一——符号翻转,即将输入值的符号取反。这个节点虽然概念简单,但在着色器编程中有着丰富的应用场景和实用价值。

Negate 节点的核心功能可以用一句话概括:它将任何输入数值的符号进行反转。这意味着正数会变成负数,负数会变成正数,而零值保持不变。这种操作在数学上等同于将数值乘以-1。

在 Shader Graph 的可视化编程环境中,Negate 节点属于数学运算类别,通常可以在 Math 菜单下找到。它的图标设计直观,通常包含一个负号符号,清晰地表明其功能。与其他复杂的着色器节点相比,Negate 节点的界面非常简洁,只有一个输入端口和一个输出端口,这使得即使是着色器编程的初学者也能快速理解和应用。

理解 Negate 节点的工作原理对于掌握着色器数学至关重要。在计算机图形学中,符号翻转不仅仅是简单的数学运算,它还涉及到向量方向的反转、法线方向的调整、纹理坐标的镜像等多种图形效果。通过巧妙地应用 Negate 节点,开发者可以创造出各种视觉上引人注目的效果,而无需编写复杂的代码。

描述

Negate 节点是 Shader Graph 中最基础的数学运算节点之一,它的功能纯粹而直接:接收一个输入值,然后返回该值的符号翻转版本。从数学角度来看,这个操作等同于将输入值乘以-1。虽然概念简单,但这个操作在着色器编程中却有着深远的意义和广泛的应用。

在着色器编程的上下文中,符号翻转不仅仅是改变数值的符号那么简单。当处理向量时,Negate 节点实际上会反转向量的方向。例如,一个表示向右的向量(1, 0, 0)经过 Negate 节点处理后,会变成表示向左的向量(-1, 0, 0)。这种方向反转的能力使得 Negate 节点在控制运动方向、光照计算和法线处理等方面变得极为有用。

Negate 节点支持多种数据类型,包括:

  • 浮点数(float)
  • 二维向量(float2)
  • 三维向量(float3)
  • 四维向量(float4)

这种灵活性意味着无论您是在处理单个数值、UV 坐标、颜色值还是位置数据,Negate 节点都能胜任。当输入是向量时,Negate 节点会对向量的每个分量分别执行符号翻转操作,确保整个向量的方向被完全反转。

在实际应用中,Negate 节点经常与其他数学节点结合使用,以创建更复杂的效果。例如,将 Negate 节点与加法节点结合可以实现减法运算;与乘法节点结合可以改变缩放方向;与条件判断节点结合可以创建基于数值符号的切换效果。

理解 Negate 节点的另一个重要方面是认识其在性能上的优势。由于符号翻转是一个非常简单的操作,现代 GPU 能够以极高的效率执行它,几乎不会对渲染性能产生任何 noticeable 影响。这使得 Negate 节点成为优化着色器时的理想选择,特别是在需要频繁改变数值符号的场景中。

端口

Negate 节点的端口设计体现了其功能的简洁性。节点只有两个端口:一个输入端口和一个输出端口。这种极简的设计使得节点易于理解和使用,同时也保证了其在复杂节点网络中的高效性。

输入端口

输入端口名为"In",是节点接收数据的入口。这个端口的设计有几个值得注意的特点:

  • 方向特性:输入端口是单向的,意味着数据只能从外部流向节点,而不能从节点通过输入端口向外流出。这种设计符合数据流的基本原理,确保了节点网络的可预测性和稳定性。
  • 类型灵活性:输入端口支持动态矢量类型,这意味着它可以接受多种数据类型的输入,包括:
    • 单个浮点数值(float)
    • 二维向量(float2),常用于表示 UV 坐标
    • 三维向量(float3),常用于表示位置、法线或颜色
    • 四维向量(float4),常用于表示包含透明度的颜色或变换矩阵
  • 数据类型传播:输入端口的一个重要特性是它的数据类型会决定输出端口的数据类型。如果输入是一个 float3 向量,那么输出也会是一个 float3 向量。这种类型传播机制简化了节点网络的设计,减少了类型转换的需要。
  • 连接兼容性:输入端口可以与任何输出相同数据类型的端口连接。在 Shader Graph 中,您可以通过拖拽连接线的方式将其他节点的输出端口与 Negate 节点的输入端口连接起来,创建数据流。

输出端口

输出端口名为"Out",是节点处理结果的出口。输出端口的设计同样具有几个关键特性:

  • 数据一致性:输出端口的数据类型始终与输入端口保持一致。如果输入是 float2 类型,输出也会是 float2 类型;如果输入是 float4 类型,输出也会是 float4 类型。这种一致性确保了节点在网络中的无缝集成。
  • 实时计算:输出端口的值不是静态的,而是根据输入值实时计算的。每当输入值发生变化时,输出值会立即更新,反映了符号翻转后的结果。
  • 下游连接:输出端口可以连接到任何接受相同数据类型的输入端口。这使得 Negate 节点可以轻松集成到复杂的节点网络中,作为数据处理管道中的一个环节。
  • 可视化反馈:在 Shader Graph 编辑器中,当节点被选中时,输出端口通常会显示当前的计算结果,提供即时的视觉反馈,帮助开发者调试和优化着色器。

理解这两个端口的工作原理对于有效使用 Negate 节点至关重要。输入端口决定了节点接收什么样的数据,而输出端口提供了处理后的结果。通过正确连接这些端口,开发者可以构建出复杂而高效的着色器效果。

生成的代码示例

当在 Shader Graph 中使用 Negate 节点时,Unity 会在背后生成相应的 HLSL 代码。理解这些生成的代码不仅有助于深入理解节点的功能,还能帮助开发者在需要时直接编写或修改着色器代码。以下是 Negate 节点生成的典型代码示例及其详细解析。

基本代码结构

HLSL

void Unity_Negate_float4(float4 In, out float4 Out)
{
    Out = -1 * In;
}

这个函数定义展示了 Negate 节点的核心实现。让我们逐部分分析这段代码:

  • 函数签名void Unity_Negate_float4(float4 In, out float4 Out) 这个函数签名定义了节点的接口。它是一个返回类型为 void 的函数,意味着它不直接返回值,而是通过输出参数传递结果。函数名 Unity_Negate_float4 表明这是处理 float4 类型数据的 Negate 函数。Unity 为不同的数据类型生成不同的函数变体。
  • 输入参数float4 In 这是函数的输入参数,对应节点的输入端口。参数类型为 float4,表示一个包含四个浮点数的向量。在实际使用中,根据输入数据类型的不同,Unity 会生成相应的函数变体,如 Unity_Negate_floatUnity_Negate_float2Unity_Negate_float3 等。
  • 输出参数out float4 Out 这是函数的输出参数,对应节点的输出端口。out 关键字表明这是一个输出参数,函数内部对其的修改会反映到传入的变量中。参数类型同样为 float4,与输入类型保持一致。
  • 函数体Out = -1 * In; 这是函数的实际运算部分,也是 Negate 功能的核心实现。这行代码将输入向量 In 的每个分量都乘以-1,然后将结果赋值给输出向量 Out。从数学角度看,这就是对向量的每个分量执行符号翻转操作。

不同数据类型的实现

虽然上面的示例展示了 float4 类型的实现,但 Unity 会为不同的输入数据类型生成相应的函数变体:

Float 类型实现:

HLSL

void Unity_Negate_float(float In, out float Out)
{
    Out = -1 * In;
}

Float2 类型实现:

HLSL

void Unity_Negate_float2(float2 In, out float2 Out)
{
    Out = -1 * In;
}

Float3 类型实现:

HLSL

void Unity_Negate_float3(float3 In, out float3 Out)
{
    Out = -1 * In;
}

从这些实现可以看出,无论输入数据的维度如何,核心操作都是相同的:将输入向量的每个分量乘以-1。这种一致性使得节点的行为在不同数据类型间保持一致,简化了开发者的学习曲线。

实际使用场景

在完整的着色器中,Negate 函数通常会被这样调用:

HLSL

// 在片元着色器或顶点着色器中调用Negate函数
float4 originalValue = float4(1.0, -2.0, 3.0, -4.0);
float4 negatedValue;

// 调用生成的Negate函数
Unity_Negate_float4(originalValue, negatedValue);

// 此时negatedValue的值为(-1.0, 2.0, -3.0, 4.0)

这个示例展示了如何在着色器代码中直接使用 Negate 函数。首先定义了一个原始值 originalValue,然后声明了一个变量 negatedValue 来存储结果。调用 Unity_Negate_float4 函数后,negatedValue 包含了符号翻转后的结果。

性能考虑

从生成的代码可以看出,Negate 操作在计算上是非常轻量级的。它只涉及简单的乘法运算,现代 GPU 能够以极高的效率执行这种操作。即使在每帧处理数百万个顶点或片元的情况下,Negate 操作对性能的影响也微乎其微。

然而,在性能关键的场景中,有几点值得注意:

  • 向量化操作:由于 Negate 操作是分量独立的,GPU 可以充分利用 SIMD(单指令多数据)架构,并行处理向量的所有分量。
  • 常量传播优化:如果输入值是编译时常量,着色器编译器通常会在编译时执行 Negate 操作,而不是在运行时,从而完全消除运行时的计算开销。
  • 指令计数:在复杂的着色器中,减少指令计数是优化性能的重要手段。由于 Negate 操作通常只对应一条 GPU 指令,它是优化着色器时的理想选择,特别是当需要替代更复杂的符号处理逻辑时。

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

❌
❌