普通视图

发现新文章,点击刷新页面。
今天 — 2026年4月21日掘金 前端

从 0 到 1 做一个支持 NFC 写入的小程序,需要哪些 API?

2026年4月21日 14:49

项目已开源:chungeplus/nfc-scan,配套源码+需求文档,欢迎 star

gh_78e57d43077f_258.jpg


先说结论

小程序可以做 NFC,但有明确的能力边界:

  • 主要面向 Android 设备
  • 基于 NDEF 标准标签做发现、连接和写入
  • iOS 端暂时无法按 Android 的方式落地

所以与其纠结"能不能做",不如把**"在能力边界内怎么做"**这件事先摸清楚。


一、我的项目里做了什么

NFC Scan 这个小程序,核心功能就一件:把网页链接、应用包名、音乐直达链接写进 NFC 标签,让用户用手机碰一碰就能跳转到目标内容。

实现的效果就是:

  1. 用户在小程序里填写目标内容
  2. 点击开始写入
  3. 小程序开启 NFC 标签发现
  4. 用户把手机贴近标签
  5. 小程序写入 NDEF 记录
  6. 返回成功或失败
image.png image.png
image.png image.png

二、正式写代码前,先了解这几个限制

1. 设备限制

  • 需要 Android 真机,且设备本身支持 NFC
  • 微信基础库版本需要匹配 NFC 能力
  • iOS 侧能力不完整,入口说明可以保留,但不要开放写入

2. 标签限制

  • 不是所有 NFC 标签都能写,最稳妥的是标准 NDEF 标签
  • 标签容量不足、损坏、加密、协议不兼容,都会导致失败

所以一定要做好错误态文案

当前设备不支持 NFC
不支持的标签技术
写入失败,请重试
标签损坏或容量不足

3. 能力边界

小程序适合做:

  • 标签发现
  • NDEF 连接
  • 标准记录写入
  • 写入流程的交互引导

小程序不适合做:

  • 全协议全格式兼容
  • 底层扇区级操作
  • 脱离微信能力边界的 NFC 控制

三、核心 API 就这 7 个

这是实战重点。做 NFC 写入小程序的 API 核心就一组:

1. wx.getNFCAdapter() — 获取适配器

const nfcAdapter = wx.getNFCAdapter();

这是入口,后续所有操作都从这里开始。

2. startDiscovery() — 开始监听标签

nfcAdapter.startDiscovery({
  success() {
    console.log('开始监听 NFC 标签');
  },
  fail() {
    console.log('监听失败');
  }
});

用户点击"开始写入"后调用。

3. onDiscovered() — 监听标签发现

nfcAdapter.onDiscovered((res) => {
  // 判断标签类型,决定是否进入写入流程
  const techs = res.techs || [];
  if (techs.includes('NDEF')) {
    this.ndefAdapterWrite();
  } else {
    this.showError('不支持的标签技术');
  }
});

4. getNdef() — 获取 NDEF 实例

const ndef = nfcAdapter.getNdef();

做应用直达、网页链接这类场景,都离不开这个实例。

5. connect() — 连接标签

ndef.connect({
  success() {
    this.writeRecords();
  },
  fail(error) {
    // 13022 或 "already connected" 说明已连接,可以继续写入
    if (error.errCode === 13022) {
      this.writeRecords();
    } else {
      this.showError('连接失败');
    }
  }
});

6. writeNdefMessage() — 执行写入

ndef.writeNdefMessage({
  records: records.map(item => ({
    tnf: item.tnf,
    id: string2ArrayBuffer(item.id),
    type: string2ArrayBuffer(item.type),
    payload: buildPayload(item),
  })),
  success() {
    console.log('写入成功');
  },
  fail() {
    console.log('写入失败');
  }
});

注意:这里的 records 必须符合 NDEF 规范,不是随便传字符串就行。

7. 资源清理 — 3 个 API

offDiscovered()  // 取消监听
stopDiscovery()   // 停止发现
close()           // 关闭连接

很多人容易忽略这一层,结果弹窗关了还在监听、同一标签被重复触发。


四、NDEF 记录怎么设计

"我要往标签里写什么格式"是第一次做 NFC 开发最容易卡住的地方。

场景一:写入网页链接 → 用 URI 记录

{
  tnf: 1,
  id: 'web',
  type: 'U',
  payload: 'https://example.com'
}

场景二:写入应用包名 → 用 Android Application Record

{
  tnf: 4,
  id: 'pkg',
  type: 'android.com:pkg',
  payload: 'com.tencent.mobileqq'
}

场景三:写入音乐直达链接

我当时的做法是组合两条记录

// 网易云音乐
{
  tnf: 1,
  id: 'music',
  type: 'U',
  payload: 'orpheus://song/413829859/?autoplay=true'
}
// + 目标 App 包名
{
  tnf: 4,
  id: 'pkg',
  type: 'android.com:pkg',
  payload: 'com.netease.cloudmusic'
}

这样体验更稳定,碰一碰就能直接拉起对应 App 播放歌曲。


五、完整写入流程怎么写

我的做法是把 NFC 写入封装成一个独立组件 scan-dialog,原因有两个:

  1. NFC 写入天然是一个独立状态机
  2. 等待、写入中、成功、失败、重试,适合做成统一弹窗

整体流程拆解如下:

第一步:准备写入数据

在用户点击"开始写入"后,把业务内容整理成标准 records,传给弹窗组件。

this.setData({
  scanVisible: true,
  records: [{
    tnf: 1,
    id: 'web',
    type: 'U',
    payload: 'https://example.com'
  }]
});

第二步:组件显示,开始监听

onShow() {
  if (!wx.getNFCAdapter) {
    this.setData({ scanStatus: 'error', errorMessage: '当前设备不支持 NFC' });
    return;
  }

  const adapter = wx.getNFCAdapter();
  adapter.startDiscovery({
    success: () => {
      adapter.onDiscovered(this.handleDiscovered);
    },
    fail: () => {
      this.setData({ scanStatus: 'error', errorMessage: '发现NFC设备失败' });
    }
  });
}

第三步:发现标签后判断类型

handleDiscovered(res) {
  const techs = Array.isArray(res.techs) ? res.techs : [];

  if (techs.includes('NDEF')) {
    this.ndefAdapterWrite();
  } else {
    this.setData({ scanStatus: 'error', errorMessage: '不支持的标签技术' });
  }
}

第四步:连接并写入

ndefAdapterWrite() {
  const ndef = this.data.baseNfcAdapter.getNdef();

  ndef.connect({
    success: () => {
      ndef.writeNdefMessage({
        records: this.buildRecords(),
        success: () => {
          this.setData({ scanStatus: 'success' });
        },
        fail: () => {
          this.setData({ scanStatus: 'error', errorMessage: '写入失败,请重试' });
        }
      });
    },
    fail: (error) => {
      if (error.errCode === 13022) {
        // 已连接,直接写入
        ndef.writeNdefMessage({ ... });
      } else {
        this.setData({ scanStatus: 'error', errorMessage: '连接失败' });
      }
    }
  });
}

六、除了 NFC API,还要配哪些能力

很多人以为只要盯着 NFC API 就够了,其实不是。

API 用途
wx.request() 音乐分享链接解析
wx.getClipboardData() 一键粘贴
wx.canIUse() 兼容性判断
wx.getUpdateManager() 版本更新

七、最容易踩的 5 个坑

1. 不做重复发现保护

标签贴近时 onDiscovered() 可能在短时间触发多次,没加锁就会出现重复写入。

我的做法是加一个 writingLock,发现标签后立刻上锁,写完再解锁。

2. 只管写,不管清理

弹窗关闭后不执行 offDiscovered() + stopDiscovery() + close(),会导致页面状态越来越乱。

3. URI Payload 直接裸写字符串

URI Record 要按 NDEF 规范做前缀编码

  • https:// → 前缀码 0x04
  • http:// → 前缀码 0x03
  • 自定义协议(orpheus://qqmusic://)→ 前缀码 0x00

不同场景处理方式不一样,直接写字符串进去大概率会失败。

4. 忽略平台差异

Android 和 iOS 的 NFC 路径完全不同。我的处理方式是 iOS 保留入口但禁用写入,避免用户误解。

5. 只做成功态,不做失败态

真正上线后,失败场景比想象的多:

手机不支持 NFC
标签不是 NDEF 类型
标签已损坏
标签容量不足
连接失败
写入失败

工具类产品尤其需要把失败原因讲清楚,不要只给一个"写入失败"的模糊提示。


八、项目结构一览

miniprogram/
├── components/               # 自定义组件
│   ├── pixel-navbar/         # 像素风导航栏
│   ├── pixel-toast/          # 像素风提示
│   ├── pixel-icon/           # 像素风图标
│   └── scan-dialog/          # NFC 写入弹窗
├── pages/                    # 页面
│   ├── write-menu/           # 首页(功能入口)
│   ├── write-app/           # 应用写入
│   ├── write-music/          # 音乐写入
│   └── write-web/           # 网页写入
├── styles/                   # 全局样式
├── utils/                    # 工具函数
│   ├── convert.js           # NDEF 数据转换
│   └── extract.js           # 音乐链接解析
└── app.js

九、最后

小程序 NFC 这件事,核心就一句话:在微信提供的能力边界内,把 NDEF 标签的发现、连接、写入和交互流程设计完整

如果你是自己从零做,建议优先把:

  • 标签格式(NDEF 规范)
  • 平台边界(Android / iOS 差异)
  • 失败态处理(把原因讲清楚)

这三件事先打磨好,再去扩展更多业务能力。

项目已开源到 GitHub:chungeplus/nfc-scan,包含完整源码、需求文档和发布说明,欢迎参考交流。

重构使用的skills:juejin.cn/post/763103…

纯浏览器解析 APK 信息,不用服务器 | 开源了一个小工具

作者 90程序员
2026年4月21日 14:31

纯浏览器解析 APK 信息,不用服务器 | 开源了一个小工具

做内部分发平台的时候遇到一个需求:用户上传 APK 后,自动填写包名、版本号、应用名称。

最直接的方案是丢给后端解析,但能不能直接在浏览器里搞定呢?折腾了一番,写了个零依赖(对服务端零依赖)的小包:apk-meta-parser

能解析什么

import { parseApkMeta } from "apk-meta-parser";

const meta = await parseApkMeta(file); // file 就是 input[type=file] 拿到的 File 对象
// {
//   packageName:      "com.example.app",
//   versionName:      "1.2.3",
//   versionCode:      123,
//   label:            "我的应用",   // 真实应用名,不是包名
//   labelIsResourceId: false,
//   apkSize:          10485760,
//   apkMd5:           "d41d8cd98f00b204e9800998ecf8427e"
// }

三行代码,搞定。

技术上做了什么

APK 本质是个 ZIP,里面的 AndroidManifest.xml 是 Android 二进制 XML 格式(不是普通文本),不能直接读。我手写了一个 AXML 解析器,支持 UTF-8/UTF-16 字符串池,处理了 versionCode 超 32 位的边界情况。

比较麻烦的一个坑是 应用名称。用 uni-app、HBuilderX 打包的 APK,android:label 不是明文字符串,而是一个资源 ID(比如 @0x7f0d001b),真正的名字存在 resources.arsc 里。为了解析这个,又手写了一套 resources.arscResTable_type 块解析逻辑,能正确拿到中文应用名。

安装

npm install apk-meta-parser jszip spark-md5

jszipspark-md5 是 peer deps,按需安装(只需要包名版本不计算 MD5 的话可以跳过 spark-md5 用 skipMd5: true)。

使用场景

  • APK 分发平台(上传自动识别)
  • 移动端 CI/CD 面板
  • 任何需要在前端展示 APK 元信息的地方

GitHub:github.com/xuantiandao…

小工具,代码量不大,欢迎看源码提 issue。


Vosk-Browser 实现浏览器离线语音转文字

作者 NB_R
2026年4月21日 14:30

最近在做一个 AI 客服对话组件,需要接入语音输入。第一反应是用浏览器原生的 window.SpeechRecognition

const recognition = new webkitSpeechRecognition()
recognition.lang = 'zh-CN'
recognition.onresult = (e) => console.log(e.results[0][0].transcript)
recognition.start()

代码很简单,本地测试也没问题。但上线后发现:在国内完全不可用

加了完整日志后才搞清楚原因:

recognition.onstart = () => console.log('已启动')       // ✅ 触发
recognition.onspeechstart = () => console.log('检测到声音') // ❌ 不触发
recognition.onresult = (e) => console.log('有结果', e)   // ❌ 不触发
recognition.onerror = (e) => console.log('错误', e.error) // 有时触发 network
recognition.onend = () => console.log('结束')            // ✅ 触发(静默结束)

根本原因:Chrome 的 SpeechRecognition 底层调用 www.google.com/speech-api,这个域名在国内被 GFW 封锁。表现为 onstart 触发后静默等待,最终 onend 直接结束,没有任何识别结果。


解决方案:Vosk-Browser 离线识别

Vosk 是开源的离线语音识别引擎,@lichess-org/vosk-browser 是其 WebAssembly 浏览器版本的维护 fork(原版 vosk-browser 已停止维护)。

核心优势:

  • 完全离线,不依赖任何外部服务,国内可用
  • 支持中文,小模型约 40MB
  • 基于 WebWorker + WASM,识别不阻塞主线程

准备工作

1. 安装依赖

npm install @lichess-org/vosk-browser

2. 复制静态文件到 public 目录

cp node_modules/@lichess-org/vosk-browser/dist/vosk.wasm.js  public/
cp node_modules/@lichess-org/vosk-browser/dist/vosk.worker.js public/
cp node_modules/@lichess-org/vosk-browser/dist/vosk.wasm      public/

Worker 和 WASM 文件必须放到可以直接通过 URL 访问的静态目录,不能走 webpack 打包。

3. 下载中文模型

https://alphacephei.com/vosk/models/vosk-model-small-cn-0.22.zip

下载后放到 public/ 目录。建议放自己服务器,不要依赖 CDN,国内访问不稳定。


核心 API

import { createVoskClient } from '@lichess-org/vosk-browser'

// 1. 加载模型(只需一次,内部启动 WebWorker)
const client = await createVoskClient({
  workerUrl: '/vosk.worker.js',
  wasmUrl:   '/vosk.wasm',
  modelUrl:  '/vosk-model-small-cn-0.22.zip'
})

// 2. 创建识别器(每次录音新建一个)
const recognizer = new client.KaldiRecognizer(16000)  // 采样率必须 16000

// 3. 监听识别结果(每句话结束触发一次)
recognizer.on('result', (msg) => {
  console.log(msg.result.text)  // 识别出的文字
})

// 4. 喂音频数据(通过 ScriptProcessor 实时传入)
recognizer.acceptWaveformFloat(float32Array, 16000)

// 5. 停止时获取最后一段未提交的结果
recognizer.retrieveFinalResult()

// 6. 销毁识别器
recognizer.remove()

实现要点

要点一:页面空闲时预加载模型

模型首次加载需要解压 WASM,用 requestIdleCallback 在页面空闲时静默加载,用户点击录音时已就绪:

async function preloadVosk() {
  if (vosk.client || vosk.loading) return
  vosk.loading = true
  try {
    vosk.client = await Promise.race([
      createVoskClient({
        workerUrl: '/vosk.worker.js',
        wasmUrl:   '/vosk.wasm',
        modelUrl:  '/vosk-model-small-cn-0.22.zip'
      }),
      // 加超时保护,防止模型文件不存在时永远挂起
      new Promise((_, reject) => setTimeout(() => reject(new Error('模型加载超时')), 60000))
    ])
  } catch (e) {
    vosk.client = null
  } finally {
    vosk.loading = false
  }
}

// 页面空闲时预加载
if (window.requestIdleCallback) {
  requestIdleCallback(() => preloadVosk(), { timeout: 3000 })
} else {
  setTimeout(preloadVosk, 2000)
}

要点二:用 AudioContext + ScriptProcessor 喂数据

Vosk 需要 16000Hz 的 PCM Float32 音频流:

const sampleRate = 16000
const stream    = await navigator.mediaDevices.getUserMedia({ audio: true })
const audioCtx  = new AudioContext({ sampleRate })
const source    = audioCtx.createMediaStreamSource(stream)
const processor = audioCtx.createScriptProcessor(4096, 1, 1)

source.connect(processor)
processor.connect(audioCtx.destination)

processor.onaudioprocess = (e) => {
  // 实时把麦克风数据喂给 Vosk
  recognizer.acceptWaveformFloat(e.inputBuffer.getChannelData(0), sampleRate)
}

要点三:避免识别文本重复

录音过程中 result 事件持续触发(每句话结束一次),停止时还需调 retrieveFinalResult() 获取最后一段。

如果在 startVoicestopVoice 里各绑一次 result 监听,最后一段文本会被计算两遍。

finalizing 标志区分:

// 开始录音时绑定监听,录音中累积文本
recognizer.on('result', (msg) => {
  const text = msg?.result?.text?.replace(/\s+/g, '') || ''
  if (!text) return
  if (vosk.finalizing) {
    // retrieveFinalResult 触发的最后一段
    inputBox.value += vosk.partialText + text
    vosk.partialText = ''
  } else {
    // 录音中间的结果,先累积
    vosk.partialText += text
  }
})

// 停止录音
function stopVoice() {
  // ... 停止音频流 ...
  vosk.finalizing = true
  recognizer.on('result', () => {
    recognizer.remove()
    vosk.finalizing = false
  })
  recognizer.retrieveFinalResult()
}

要点四:Vue 2 中不要用 _ 前缀存状态

Vue 2 不会代理 _$ 开头的 data 属性,直接赋值会是 undefined

// ❌ 错误:this._voskClient 永远是 undefined
data() {
  return { _voskClient: null }
}

// ✅ 正确:在 mounted 里直接挂到实例上(非响应式)
mounted() {
  this.$vosk = {
    client: null, loading: false,
    recognizer: null, stream: null,
    audioCtx: null, processor: null,
    partialText: '', finalizing: false
  }
}

完整 H5 Demo(纯离线版)

以下是可直接运行的完整示例,所有依赖全部本地化,无任何外部请求:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>离线语音转文字 Demo</title>
  <style>
    * { box-sizing: border-box; margin: 0; padding: 0; }
    body {
      font-family: -apple-system, BlinkMacSystemFont, sans-serif;
      background: #0f1117; color: #e0e0e0;
      display: flex; justify-content: center; align-items: center; min-height: 100vh;
    }
    .chat-box {
      width: 400px; height: 600px;
      background: linear-gradient(160deg, #1a1d2e 0%, #0f1117 100%);
      border-radius: 16px; border: 1px solid rgba(0,200,255,0.15);
      box-shadow: 0 8px 40px rgba(0,0,0,0.6);
      display: flex; flex-direction: column; overflow: hidden; position: relative;
    }
    .chat-header {
      padding: 14px 16px; background: rgba(0,200,255,0.06);
      border-bottom: 1px solid rgba(0,200,255,0.1);
      display: flex; align-items: center; gap: 8px;
      font-size: 15px; font-weight: 600; color: #00c8ff;
    }
    .dot { width: 8px; height: 8px; border-radius: 50%; background: #00c8ff; animation: blink 1.4s infinite; }
    @keyframes blink { 0%,100%{opacity:1} 50%{opacity:0.3} }
    .chat-content {
      flex: 1; overflow-y: auto; padding: 16px 12px;
      display: flex; flex-direction: column; gap: 12px;
    }
    .chat-content::-webkit-scrollbar { width: 4px; }
    .chat-content::-webkit-scrollbar-thumb { background: rgba(0,200,255,0.2); border-radius: 2px; }
    .msg { display: flex; gap: 8px; max-width: 85%; }
    .msg.user { align-self: flex-end; flex-direction: row-reverse; }
    .msg-avatar {
      width: 32px; height: 32px; border-radius: 50%;
      background: rgba(0,200,255,0.15); border: 1px solid rgba(0,200,255,0.3);
      display: flex; align-items: center; justify-content: center; font-size: 14px; flex-shrink: 0;
    }
    .msg.user .msg-avatar { background: rgba(100,180,255,0.15); }
    .msg-bubble {
      background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.08);
      border-radius: 12px; padding: 8px 12px; font-size: 13px; line-height: 1.6;
    }
    .msg.user .msg-bubble { background: rgba(0,200,255,0.12); border-color: rgba(0,200,255,0.2); }
    .msg-time { font-size: 10px; color: #555; margin-top: 4px; }
    .quick-cmds { display: flex; flex-wrap: wrap; gap: 6px; padding: 8px 12px 4px; }
    .quick-cmd {
      padding: 4px 12px; border-radius: 12px;
      background: rgba(0,200,255,0.08); border: 1px solid rgba(0,200,255,0.2);
      font-size: 12px; color: #00c8ff; cursor: pointer;
    }
    .quick-cmd:hover { background: rgba(0,200,255,0.18); }
    .chat-input {
      display: flex; align-items: center; gap: 8px;
      padding: 10px 12px; border-top: 1px solid rgba(255,255,255,0.06);
    }
    .input-box {
      flex: 1; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1);
      border-radius: 20px; padding: 8px 14px; color: #e0e0e0; font-size: 13px; outline: none;
    }
    .input-box:focus { border-color: rgba(0,200,255,0.4); }
    .input-box::placeholder { color: #555; }
    .btn-send, .btn-mic {
      width: 34px; height: 34px; border-radius: 50%; border: none;
      cursor: pointer; display: flex; align-items: center; justify-content: center;
      font-size: 15px; flex-shrink: 0;
    }
    .btn-send { background: #00c8ff; color: #000; }
    .btn-mic { background: rgba(0,200,255,0.1); border: 1px solid rgba(0,200,255,0.3); color: #00c8ff; }
    .btn-mic.recording { background: rgba(255,71,87,0.2); border-color: #ff4757; color: #ff4757; animation: pulse-red 1s infinite; }
    @keyframes pulse-red { 0%,100%{box-shadow:0 0 0 0 rgba(255,71,87,0.4)} 50%{box-shadow:0 0 0 6px rgba(255,71,87,0)} }
    .voice-overlay {
      position: absolute; bottom: 60px; left: 50%; transform: translateX(-50%);
      background: rgba(10,12,20,0.92); border: 1px solid rgba(0,200,255,0.2);
      border-radius: 16px; padding: 16px 24px;
      display: none; flex-direction: column; align-items: center; gap: 12px;
      z-index: 20; min-width: 200px;
    }
    .voice-overlay.show { display: flex; }
    .voice-wave { display: flex; align-items: flex-end; gap: 4px; height: 32px; }
    .voice-wave span { width: 4px; border-radius: 2px; background: #00c8ff; animation: wave-bar 0.8s ease-in-out infinite; }
    .voice-wave span:nth-child(1){height:8px;animation-delay:0s}
    .voice-wave span:nth-child(2){height:18px;animation-delay:0.1s}
    .voice-wave span:nth-child(3){height:28px;animation-delay:0.2s}
    .voice-wave span:nth-child(4){height:18px;animation-delay:0.3s}
    .voice-wave span:nth-child(5){height:8px;animation-delay:0.4s}
    @keyframes wave-bar { 0%,100%{transform:scaleY(1);opacity:0.7} 50%{transform:scaleY(1.6);opacity:1} }
    .voice-tip { font-size: 12px; color: #aaa; }
    .voice-actions { display: flex; gap: 24px; }
    .voice-action {
      display: flex; flex-direction: column; align-items: center; gap: 4px;
      cursor: pointer; font-size: 12px; color: #aaa; padding: 6px 12px;
      border-radius: 8px; transition: all 0.2s;
    }
    .voice-action .icon { font-size: 20px; }
    .voice-action.cancel:hover { color: #ff4757; background: rgba(255,71,87,0.1); }
    .voice-action.confirm:hover { color: #00c8ff; background: rgba(0,200,255,0.1); }
    .status-bar { padding: 4px 12px 8px; font-size: 11px; color: #555; text-align: center; }
    .status-bar.ready { color: #67c23a; }
    .status-bar.error { color: #f56c6c; }
  </style>
</head>
<body>
<div class="chat-box">
  <div class="chat-header">
    <div class="dot"></div>
    AI 智能助手(离线语音版)
  </div>

  <div class="chat-content" id="chatContent"></div>

  <div class="quick-cmds">
    <span class="quick-cmd" onclick="sendText('你好')">你好</span>
    <span class="quick-cmd" onclick="sendText('帮助')">帮助</span>
    <span class="quick-cmd" onclick="sendText('测试语音')">测试语音</span>
  </div>

  <!-- 录音遮罩 -->
  <div class="voice-overlay" id="voiceOverlay">
    <div class="voice-wave">
      <span></span><span></span><span></span><span></span><span></span>
    </div>
    <div class="voice-tip">正在录音,请说话...</div>
    <div class="voice-actions">
      <div class="voice-action cancel" onclick="cancelVoice()">
        <span class="icon">🗑</span><span>取消</span>
      </div>
      <div class="voice-action confirm" onclick="stopVoice()">
        <span class="icon"></span><span>转文本</span>
      </div>
    </div>
  </div>

  <div class="chat-input">
    <button class="btn-mic" id="btnMic" onclick="startVoice()" title="点击录音">🎤</button>
    <input class="input-box" id="inputBox" placeholder="输入消息..." onkeydown="if(event.key==='Enter')sendMessage()" />
    <button class="btn-send" onclick="sendMessage()"></button>
  </div>

  <div class="status-bar" id="statusBar">语音模型加载中...</div>
</div>

<script type="module">
// 所有文件全部本地化,无外部依赖
import { createVoskClient } from '/vosk.wasm.js'

const vosk = {
  client: null, loading: false,
  recognizer: null, stream: null,
  audioCtx: null, processor: null,
  partialText: '', finalizing: false
}
let isRecording = false

const chatContent  = document.getElementById('chatContent')
const inputBox     = document.getElementById('inputBox')
const btnMic       = document.getElementById('btnMic')
const voiceOverlay = document.getElementById('voiceOverlay')
const statusBar    = document.getElementById('statusBar')

// ── 预加载模型 ──────────────────────────────────────────────
async function preloadVosk() {
  if (vosk.client || vosk.loading) return
  vosk.loading = true
  setStatus('语音模型加载中...', '')
  try {
    vosk.client = await Promise.race([
      createVoskClient({
        workerUrl: '/vosk.worker.js',
        wasmUrl:   '/vosk.wasm',
        modelUrl:  '/vosk-model-small-cn-0.22.zip'
      }),
      new Promise((_, r) => setTimeout(() => r(new Error('加载超时,请确认模型文件已放置')), 60000))
    ])
    setStatus('语音模型已就绪,可点击 🎤 录音', 'ready')
  } catch (e) {
    vosk.client = null
    setStatus('模型加载失败:' + e.message, 'error')
  } finally {
    vosk.loading = false
  }
}

// ── 开始录音 ────────────────────────────────────────────────
window.startVoice = async function () {
  if (isRecording) return
  if (!vosk.client) {
    if (vosk.loading) { alert('模型加载中,请稍候...'); return }
    await preloadVosk()
    if (!vosk.client) return
  }
  try {
    const sampleRate  = 16000
    vosk.recognizer   = new vosk.client.KaldiRecognizer(sampleRate)
    vosk.partialText  = ''
    vosk.finalizing   = false

    vosk.recognizer.on('result', (msg) => {
      const text = msg?.result?.text?.replace(/\s+/g, '') || ''
      if (!text) return
      if (vosk.finalizing) {
        const full = vosk.partialText + text
        if (full) inputBox.value += full
        vosk.partialText = ''
      } else {
        vosk.partialText += text
      }
    })

    vosk.stream    = await navigator.mediaDevices.getUserMedia({ audio: true })
    vosk.audioCtx  = new AudioContext({ sampleRate })
    const source   = vosk.audioCtx.createMediaStreamSource(vosk.stream)
    vosk.processor = vosk.audioCtx.createScriptProcessor(4096, 1, 1)
    source.connect(vosk.processor)
    vosk.processor.connect(vosk.audioCtx.destination)
    vosk.processor.onaudioprocess = (e) => {
      if (vosk.recognizer) vosk.recognizer.acceptWaveformFloat(e.inputBuffer.getChannelData(0), sampleRate)
    }

    isRecording = true
    btnMic.classList.add('recording')
    voiceOverlay.classList.add('show')
  } catch (err) {
    alert(err.name === 'NotAllowedError' ? '麦克风权限被拒绝' : '录音启动失败:' + err.message)
  }
}

// ── 停止录音(转文本) ───────────────────────────────────────
window.stopVoice = function () {
  if (!isRecording) return
  isRecording = false
  btnMic.classList.remove('recording')
  voiceOverlay.classList.remove('show')
  if (vosk.processor) { vosk.processor.disconnect(); vosk.processor = null }
  if (vosk.audioCtx)  { vosk.audioCtx.close();       vosk.audioCtx  = null }
  if (vosk.stream)    { vosk.stream.getTracks().forEach(t => t.stop()); vosk.stream = null }
  if (vosk.recognizer) {
    vosk.finalizing = true
    vosk.recognizer.on('result', () => {
      vosk.recognizer.remove()
      vosk.recognizer = null
      vosk.finalizing = false
    })
    vosk.recognizer.retrieveFinalResult()
  }
}

// ── 取消录音 ────────────────────────────────────────────────
window.cancelVoice = function () {
  isRecording = false
  btnMic.classList.remove('recording')
  voiceOverlay.classList.remove('show')
  if (vosk.processor) { vosk.processor.disconnect(); vosk.processor = null }
  if (vosk.audioCtx)  { vosk.audioCtx.close();       vosk.audioCtx  = null }
  if (vosk.stream)    { vosk.stream.getTracks().forEach(t => t.stop()); vosk.stream = null }
  if (vosk.recognizer){ vosk.recognizer.remove(); vosk.recognizer = null }
  vosk.partialText = ''
}

// ── 消息收发 ────────────────────────────────────────────────
window.sendMessage = function () {
  const text = inputBox.value.trim()
  if (!text) return
  appendMsg('user', text)
  inputBox.value = ''
  setTimeout(() => appendMsg('ai', getReply(text)), 600)
}

window.sendText = function (text) {
  inputBox.value = text
  sendMessage()
}

function appendMsg(type, content) {
  const div = document.createElement('div')
  div.className = 'msg ' + type
  const now = new Date()
  const time = `${now.getHours().toString().padStart(2,'0')}:${now.getMinutes().toString().padStart(2,'0')}`
  div.innerHTML = `
    <div class="msg-avatar">${type === 'user' ? '👤' : '🤖'}</div>
    <div class="msg-bubble">
      <div>${content}</div>
      <div class="msg-time">${time}</div>
    </div>`
  chatContent.appendChild(div)
  chatContent.scrollTop = chatContent.scrollHeight
}

function getReply(text) {
  const map = {
    '你好': '您好!我是 AI 助手,有什么可以帮您?',
    '帮助': '您可以直接输入问题,或点击 🎤 使用语音输入。',
    '测试语音': '语音识别正常!这是一条测试回复。',
  }
  for (const k in map) { if (text.includes(k)) return map[k] }
  return `您说的是:"${text}",我已收到。`
}

function setStatus(msg, type) {
  statusBar.textContent = msg
  statusBar.className = 'status-bar ' + (type || '')
}

// 页面空闲时预加载模型
if (window.requestIdleCallback) requestIdleCallback(() => preloadVosk(), { timeout: 3000 })
else setTimeout(preloadVosk, 2000)

appendMsg('ai', '您好!我是 AI 智能助手。点击 🎤 可使用<b>离线语音输入</b>,无需联网,完全本地识别。')
</script>
</body>
</html>

运行前确保以下文件都在 public 根目录:

  • vosk.wasm.js
  • vosk.worker.js
  • vosk.wasm
  • vosk-model-small-cn-0.22.zip

image.png

前端自动化编译Jenkins

作者 存在X
2026年4月21日 14:07

前端项目自动化部署 = 3 步

Jenkins 拉取 Git 代码

执行 npm install & npm run build

把 dist 包发布到服务器 /nginx

1.windows安装虚拟机

VMware-workstation-full-17.6.1-24319023.exe link.zhihu.com/?target=htt…

2.下载# Ubuntu镜像

ubuntu.com/download/se…

3.创建虚拟机选择镜像

网络问题使用的最终配置 111网络.png 查看网络:输入 ip a成功标志:看到 ens33 下面有了 inet 192.168.1.x 这样的真实 IP!

如果你之后需要用 Xshell、终端远程连接这台 Ubuntu:保留勾选「Install OpenSSH server」,密码认证也保持勾选即可

111ssh.png

4、SSH 远程连接

在现有 Ubuntu 终端输入:

sudo apt update
sudo apt install openssh-server -y

启动并设置开机自启:

sudo systemctl start ssh
sudo systemctl enable ssh

确认 SSH 运行:

sudo systemctl status ssh

看到 active (running) 就 ok。

确认虚拟机 IP

hostname -I

使用MobaXterm连接虚拟机

5.本地极简安装 Jenkins

直接用 Ubuntu 系统自带包安装 Jenkins(彻底避开 Docker 网络问题)

# 先更新软件源
sudo apt update

# 直接安装OpenJDK(Jenkins依赖)
sudo apt install openjdk-17-jdk -y

# 添加Jenkins官方源密钥
curl -fsSL https://pkg.jenkins.io/debian-stable/jenkins.io-2023.key | sudo gpg --dearmor -o /usr/share/keyrings/jenkins-keyring.gpg

# 添加Jenkins软件源
echo "deb [signed-by=/usr/share/keyrings/jenkins-keyring.gpg] https://pkg.jenkins.io/debian-stable binary/" | sudo tee /etc/apt/sources.list.d/jenkins.list

# 安装Jenkins
sudo apt update
sudo apt install jenkins -y

启动 + 开机自启

sudo systemctl start jenkins
sudo systemctl enable jenkins

检查状态

sudo systemctl status jenkins

✅ 看到 active (running) 就成功!

访问 + 登录

浏览器打开:http://172.17.173.95:8080 获取初始管理员密码:

sudo cat /var/lib/jenkins/secrets/initialAdminPassword

极简兜底方案(国内可用、一步到位)

1. 先清理无效源

sudo rm /etc/apt/sources.list.d/jenkins.list

2. 直接用Jenkins 国内离线包安装,彻底绕开源问题

Jenkins 最新版最低要求 Java 21 / 25,升级系统 Java 到 21

sudo apt install openjdk-21-jdk -y

# 设置默认Java版本
sudo update-alternatives --config java

# 下载Jenkins稳定版war包
wget https://mirrors.tuna.tsinghua.edu.cn/jenkins/war-stable/latest/jenkins.war

# 重新启动现有war包即可
java -jar jenkins.war --httpPort=8080

3.直接启动运行(无需 Docker、无需系统服务)

nohup java -jar jenkins.war --httpPort=8080 > jenkins.log 2>&1 &

6.配置jenkins

1. 必备插件安装

登录 Jenkins → 左侧「系统管理」→「插件管理」→「可选插件」,搜索并安装:

  • Git Plugin(拉取代码)

  • NodeJS Plugin(前端编译环境)

  • 配置 Node.js

    • 找到 NodeJS → 点击 新增NodeJS
    • 自定义名称:node20
    • 勾选 Install automatically
    • 版本选:20.x.x
    • 保存
  • 配置 git

    • 进入 Manage Jenkins(系统管理)
    • 进入 Global Tool Configuration(全局工具配置)
    • 找到 Git
    • 点击 Add Git
    • Name 填:git
    • Path to Git executable 填:/usr/bin/git
    • 保存

2.新建前端构建任务(核心)

新建任务

  • 首页 → 新建任务
  • 名字:前端自动打包
  • 选择:自由风格项目 (Freestyle)
  • 确定

开启「自定义脚本选择」(最关键)

勾选:

This project is parameterized

点击 Add Parameter → Choice Parameter

按下面填写:

  • Name:
RUN_SCRIPT
  • Choices一行一个,对应你 package.json 的 scripts):
build:LNFX
build:LNSY
build:YN
build:EW
build:EA
build:YD
build:GN
build:AHJD
build:GJDT
build:GJDTDB
build:GJDTNM
build:GJDTSX
build:GJDTXA
build:DT
build:DTAH
build:DTJS
build:DTSH
build:JDCC
build:YN2
build:DTXJHM
build:GSZDJ
build:DTHN
build:DTTKT
build:LNDT
build:JXDTJK
build:ZDJGYY
build:ZJJK
build:DTGDZQ
build:GDTWCJK
build:HNYNJK
  • Description:
选择要执行的命令

源码管理(拉代码)

  • Git
  • Repository URL:填你的 Gitee/GitHub 仓库地址
  • Credentials:添加你的仓库账号密码(personal_access_tokens->read_repository权限)
  • Branch:*/main*/master

构建环境

  • 勾选:Provide Node & npm bin/ folder to PATH
  • 选择:node20

Triggers(自动触发 + 定时检测)

  • 只勾选这一项:

    Poll SCM

  • Schedule 填写(直接复制):

 H/2 * * * *

构建步骤(最重要!)

  • 增加构建步骤 → 执行 shell

  • 粘贴下面这段通用前端自动化打包脚本(直接复制):

#!/bin/bash
set -e
# 1. 兼容老项目
export NODE_OPTIONS=--openssl-legacy-provider
# 🔥 强制关闭所有编译检查(关键)
#export DISABLE_ESLINT_PLUGIN=true
#export ESLINT_NO_DEV_ERRORS=true
#export VUE_CLI_SERVICE_NO_ERRORS=true

# 还原正确的 package.json(echarts 4.9.0)
git checkout -- package.json

# 2. 国内镜像
#npm config set registry https://registry.npmmirror.com

# ==========================================
# 🔥 核心:安装时 跳过二进制编译(解决 mozjpeg 报错!)
# 以后加新包也能正常装,同时不炸图片压缩
# ==========================================
install_deps() {
  # --- 1. 仅清理旧的构建产物 ---
  # 这一步必须保留,防止旧代码影响新包
  echo "清理旧的构建产物..."
  rm -rf dist
  rm -rf 70*_dist* # 清理历史文件夹
  rm -f *.zip       # 清理旧压缩包
  
  # 1. 彻底删除可能导致冲突的旧缓存和目录
  #rm -rf node_modules
  #rm -rf package-lock.json
  #rm -rf .cache
  #rm -rf node_modules/.cache
  
  # 2. 清理 npm 全局缓存(防止损坏的包反复被调用)
  #npm cache clean -f
  
  # 3. 重新设置镜像源
  npm config set registry https://registry.npmmirror.com
  
  echo "正在安装依赖..."
  
  # 4. 关键:移除 --ignore-scripts!
  # 如果是因为某个特定包(如 mozjpeg)报错,我们应该针对性解决,而不是全局跳过脚本。
  # 使用 --legacy-peer-deps 处理 Vue2/3 依赖冲突问题
  npm install --legacy-peer-deps

  # 5. 特殊处理 node-sass (如果你的项目还在用它)
  # 很多时候 node-sass 需要手动触发一次 rebuild 才能在 Linux 环境跑通
  if [ -d "node_modules/node-sass" ]; then
    echo "检测到 node-sass,尝试重新构建二进制文件..."
    npm rebuild node-sass
  fi
}

# 每次都执行(新加包能装上)
install_deps

# ====================== 构建逻辑 ======================
# 1. 这里的 DATE 必须和 Node 脚本里的 formattedDate 逻辑完全一致
DATE=$(date +%Y%m%d)
# 2. 这里的项目前缀获取逻辑 (假设你的 RUN_SCRIPT 是 build:LNFX)
# 我们把 build: 后面的名字取出来
PROJECT_NAME=$(echo ${RUN_SCRIPT} | cut -d':' -f2)

# 3. 这里的目录名必须和你的 setup.js 逻辑完全同步:70 + 项目名 + _dist + 日期
DIR_NAME="70${PROJECT_NAME}_dist${DATE}"

echo "====================================="
echo "🚀 预期构建目录: ${DIR_NAME}"
echo "====================================="

echo "====================================="
echo "🚀 开始构建:npm run ${RUN_SCRIPT}"
echo "====================================="

# 在 echo "🚀 预期构建目录: ${DIR_NAME}" 下方加入
if [ -z "${PROJECT_NAME}" ]; then
    echo "❌ 错误:无法从 RUN_SCRIPT (${RUN_SCRIPT}) 中提取项目名称!"
    exit 1
fi

npm run ${RUN_SCRIPT}

# 5. 【关键修复】打包逻辑
# 不再写死 if/else,直接根据上面生成的 DIR_NAME 去找
if [ -d "${DIR_NAME}" ]; then
    echo "✅ 找到目录 ${DIR_NAME},开始压缩..."
    rm -f *.zip
    zip -r "${DIR_NAME}.zip" "${DIR_NAME}"
    echo "✅ 成功生成压缩包: ${DIR_NAME}.zip"
else
    # 容错:打印当前目录下的所有文件夹,方便调试
    echo "❌ 错误:未找到目录 ${DIR_NAME}"
    echo "当前目录下存在的目录有:"
    ls -d */
    exit 1
fi

构建后操作(实现下载)

  • 增加构建后操作 → Archive the artifacts

  • Files to archive 里填:

    plaintext

    70*_dist*.zip
    

7.其他报错

安装zip

sudo apt update && sudo apt install zip -y

图片压缩 依赖的系统库

 sudo apt-get update sudo apt-get install -y autoconf automake libtool libpng-dev libjpeg-dev gcc g++ make

服务器证书验证失败(因为你的 GitLab 是私有地址、自签名证书,Git 默认不让拉)关闭 Git SSL 验证

git config --global http.sslVerify false git config --global https.sslVerify false

Waiting for next available executor

1. 之前的失败任务卡住了“工位”

虽然任务显示失败了,但有时进程可能在后台挂起,占用了执行器。

  • 解决方法

    • 看看左侧栏的 “Build Executor Status” (构建执行状态)。
    • 如果有任务正在运行且进度条不动,点击旁边的 红色小叉叉 强制停止它。

2. 默认并发设置太低

Jenkins 默认可能只设置了 1 个或 2 个执行器。如果你之前的任务还在“取消中”或者有其他任务在排队,它就会显示等待。

  • 手动增加执行器数量

    1. 点击左侧的 Manage Jenkins(管理 Jenkins)。
    2. 点击 Nodes(节点管理,旧版本叫 Manage Nodes and Clouds)。
    3. 点击列表中的 Built-In Node(或者名为 master 的节点)旁边的 齿轮(Configure)
    4. 找到 Number of executors(执行器数量),把它从 1 或 2 改成 5
    5. 点击 Save

重启jenkins

pkill -f jenkins 
pkill -f java
nohup java -jar /home/wzy/jenkins.war --httpPort=8080 > /dev/null 2>&1 &

VMware 扩容

sudo lvextend -l +100%FREE /dev/mapper/ubuntu--vg-ubuntu--lv
sudo resize2fs /dev/mapper/ubuntu--vg-ubuntu--lv

CSS 里的"结界":BFC 与层叠上下文的渲染隔离逻辑

作者 yuki_uix
2026年4月21日 13:22

在写 CSS 的过程中,你可能遇到过这样的困惑:明明没动什么,一个浮动元素突然撑开了父容器;或者费尽心思调 z-index,元素就是不按预期叠放。背后大概率涉及两个概念:BFC(Block Formatting Context,块级格式化上下文)层叠上下文(Stacking Context)

这篇文章是我整理这两块知识的笔记。它们看似是两个独立的"规则",但我理解下来,其实都指向同一件事——浏览器在渲染时划定的"隔离结界" 。只是一个管的是盒子的布局,另一个管的是图层的叠放。


BFC 是什么,为什么需要它?

BFC 的官方定义很抽象:它是一个独立的渲染区域,内部的盒子按照特定规则排列,且与外部互不影响。

我更喜欢把它理解成:一个布局上的"隔离容器"

浏览器在做普通流布局时,float、margin 折叠等行为会在相邻元素之间"渗透"。BFC 的存在,就是划一道边界,宣告:边界内的布局由我自己管,外面的事不干涉进来。

BFC 的触发条件

以下属性会触发 BFC(部分常用条件):

/* 场景:浮动元素 */
.parent {
  overflow: hidden; /* 经典触发方式 */
}

/* 或者使用 display: flow-root(语义更明确,现代写法) */
.parent {
  display: flow-root;
}

/* 其他触发方式 */
.container {
  float: left;       /* 浮动元素本身也是 BFC */
  position: absolute;
  position: fixed;
  display: flex;
  display: grid;
  display: inline-block;
  overflow: auto;
  overflow: scroll;
}

BFC 能解决的三类经典问题

① 清除浮动(高度塌陷)

<!-- 问题:子元素全部浮动,父容器高度为 0 -->
<div class="parent">
  <div class="float-child">浮动子元素</div>
</div>
/* 浮动子元素脱离文档流,父容器感知不到它的高度 */
.float-child {
  float: left;
  height: 100px;
}

/* 触发父容器的 BFC,让它"负责"包含浮动子元素 */
.parent {
  overflow: hidden; /* 高度塌陷解决了 */
}

BFC 有一条规则:BFC 在计算高度时,需要包含内部的浮动元素。所以触发 BFC 之后,父容器就能撑开了。

② 阻止 margin 折叠

/* 普通流中,相邻兄弟元素的 margin 会合并(取较大值) */
.box-a { margin-bottom: 20px; }
.box-b { margin-top: 30px; }
/* 实际间距是 30px,而不是 50px */

/* 如果想阻止折叠,可以给其中一个元素套一个 BFC 容器 */
.wrapper {
  overflow: hidden; /* 触发 BFC */
}
/* 现在 .box-b 在 BFC 内,margin 不再与外部折叠,间距变回 50px */

BFC 内的 margin 不会与外部折叠——这是"隔离"的体现。

③ 防止浮动元素覆盖普通文本

/* 普通流元素默认会被浮动元素遮盖(虽然文字会环绕) */
.float-box { float: left; width: 100px; }
.text-box { overflow: hidden; } /* 触发 BFC,变成自适应两栏布局 */

BFC 不会与浮动元素的盒子重叠,这常用来实现不定宽的两栏布局。


层叠上下文是什么?

如果说 BFC 是平面布局的"结界",那层叠上下文就是 Z 轴方向的"结界"

层叠上下文(Stacking Context)定义了一组元素的 z 轴叠放顺序。每个层叠上下文内部有自己的叠放规则,且整体作为一个单元参与父上下文的叠放

层叠上下文内部,元素从下到上的叠放顺序大致如下:

(底部)
  1. 层叠上下文的背景和边框
  2. z-index 为负值的子层叠上下文
  3. 普通流中的块级元素(非浮动、非定位)
  4. 浮动元素
  5. 普通流中的行内元素
  6. z-index 为 0 或 auto 的定位元素
  7. z-index 为正值的子层叠上下文
(顶部)

z-index 的比较,只在同一个层叠上下文内才有意义。这是很多人调 z-index 调不对的根本原因。


为什么这些属性会触发层叠上下文?

这是我觉得最值得深挖的部分。MDN 列出了十几种触发条件,背后的逻辑是什么?

我的理解是:每一种触发条件,都对应浏览器在合成(Compositing)阶段的一个实际需求——它需要把这个元素及其子树单独处理,不能混在普通文档流里一起渲染。

逐条来看:

position: relative/absolute/fixed + z-index 不为 auto

.box {
  position: relative;
  z-index: 1; /* 触发层叠上下文 */
}

z-index: auto 表示"不参与层叠上下文的建立,z 序由父上下文决定"。一旦设置了具体数值,浏览器需要知道:这个元素内部的子元素应该以谁为参照来叠放?答案就是"以这个元素为根,建立一个新的层叠上下文"。

z-index 的比较需要一个局部坐标系,这个元素就是那个坐标系的原点。

opacity < 1

.box {
  opacity: 0.5; /* 触发层叠上下文 */
}

这一条让很多人困惑。opacity 和叠放有什么关系?

关键在于浏览器的渲染流程:应用 opacity 时,浏览器需要把这个元素及其所有子元素先合成为一张完整的位图(纹理),然后整体降低透明度,再合入父层。

如果不建立独立的层叠上下文,每个子元素单独透明,视觉效果会完全不同——重叠区域会叠加透明度,看起来就乱了。

所以 opacity 必须建立独立上下文,让子树作为整体处理。这是视觉正确性的要求,不是设计偏好。

/* 验证这一点:如果 opacity 不建立层叠上下文 */
/* 两个互相重叠的子元素,在父元素 opacity: 0.5 时 */
/* 会出现重叠区域更透明的视觉 bug */
.parent { opacity: 0.5; }
.child-a { width: 100px; height: 100px; background: red; }
.child-b { width: 100px; height: 100px; background: blue; margin-top: -50px; }
/* 浏览器正确处理:先把 parent 的子树合成为一个整体,再应用 0.5 透明度 */

transform: 任何值(除 none)

.box {
  transform: translateX(10px); /* 触发层叠上下文 */
}

transform 会触发 GPU 合成层提升(Composite Layer Promotion)。元素被提升到独立的合成层之后,GPU 可以单独对这一层做变换,不必重新触发 Layout 和 Paint。

但独立合成层有一个前提:它的内部叠放顺序必须是确定的,否则 GPU 不知道该怎么合成。因此它必须建立独立的层叠上下文。

这也解释了为什么 transform: none 不触发——没有离开普通文档流的渲染路径,不需要独立上下文。

filter: 任何值(除 none)

.box {
  filter: blur(4px); /* 触发层叠上下文 */
}

opacity 类似,但更极端。filter 的效果(blur、drop-shadow 等)必须基于整个子树的合成结果才能计算。

比如 blur(4px) 需要获取该元素的像素边界,对边界外也做模糊扩散——这只有先把子树渲染成一张完整纹理,才能做到。如果子元素还在和外部文档流混排,这个效果根本没办法计算。

filter 建立层叠上下文,是滤镜特效在物理上可计算的前提。


一个帮助理解的心智模型

可以把层叠上下文想象成 Photoshop 里的图层组

根文档(顶层层叠上下文)
├── 普通元素(在这个组里按顺序叠放)
├── .box-a(opacity: 0.8)← 新建了一个图层组
│   ├── 子元素 1
│   └── 子元素 2
│   (子元素 2 和父上下文里的元素比 z-index 没有意义,它们在不同"组"里)
└── .box-b(z-index: 100)← 另一个图层组
    └── 子元素(z-index: 9999,也无法超过父上下文的 .box-a)

每个"图层组"内部自行排序,整体再参与上层的排序。子元素的 z-index 永远只在自己所在的"图层组"里生效。


面试常问版

属性 触发 BFC 触发层叠上下文
overflow: hidden/auto/scroll
display: flow-root
position: absolute/relative + z-index ≠ auto ✅(absolute/fixed)
opacity < 1 ✅(子树需整体合成)
transform ≠ none ✅(GPU 合成层提升)
filter ≠ none ✅(滤镜需整体像素计算)
display: flex/grid ❌(子项另说)
float ≠ none ✅(自身)

面试可能追问的核心逻辑

  • BFC 的本质:布局维度的隔离,解决 float、margin 折叠的"副作用渗透"问题
  • 层叠上下文的本质:合成维度的隔离,让需要独立处理的元素及其子树有确定的 z 序边界
  • 为什么 opacity/transform/filter 会触发:不是 CSS 规范的"任意规定",是浏览器渲染管线(Paint → Composite)在技术上的必然要求

延伸思考

研究这两个概念的过程中,我产生了一些新的疑问:

  1. will-change: transform 会提前触发合成层提升,但是否同时建立层叠上下文?(答案是:会,但这个"预建立"对布局有没有副作用?)
  2. 在 React 组件中,如果父组件用了 transform 做动画,子组件里的 Portal(比如 Modal)会受到层叠上下文的影响吗?(这在实际开发中是个坑)
  3. 现代 CSS 的 @layer 是否引入了新的层叠维度?它和 z-index 的关系是什么?

这些问题我还在继续探索,如果你有自己的理解,欢迎交流。


小结

BFC 和层叠上下文,都是浏览器在渲染时建立"边界"的机制。理解它们,我觉得最重要的不是记住"哪些属性触发",而是理解为什么要有这个边界

  • BFC:浮动和 margin 折叠在普通流里会"渗透",需要一个容器划定范围自管布局
  • 层叠上下文:opacity、transform、filter 等特效需要把子树整体处理,必须有确定的 z 序边界

记住规则可以应付面试,但理解背后的渲染逻辑,才能在遇到真实 bug 时有判断力。

参考资料

Claude CLI 安装报错记录(native binary not installed)

作者 三木檾
2026年4月20日 11:03

Claude CLI 安装报错记录(native binary not installed)

问题现象

安装 @anthropic-ai/claude-code 后执行 claude 报错:

Error: claude native binary not installed.                                    
                                                                                
  Either postinstall did not run (--ignore-scripts, some pnpm configs)          
  or the platform-native optional dependency was not downloaded                 
  (--omit=optional).                                                            
                                                                                
  Run the postinstall manually (adjust path for local vs global install):       
    node node_modules/@anthropic-ai/claude-code/install.cjs                     
                                                                                
  Or reinstall without --ignore-scripts / --omit=optional.                      

原因

npm 使用了国内 registry(如 npmmirror),该源未同步 optional binary,导致 native 依赖未下载。

排查

查看当前 npm registry

npm config get registry

如果返回:

https://registry.npmmirror.com

或:

https://registry.npm.taobao.org

说明正在使用国内镜像源。

解决方法

切换官方源重新安装:

npm config set registry https://registry.npmjs.org/

npm uninstall -g @anthropic-ai/claude-code

npm install -g @anthropic-ai/claude-code

或仅本次使用官方源(推荐):

npm install -g @anthropic-ai/claude-code --registry=https://registry.npmjs.org/

验证

claude --version

能正常输出版本号即可,Have a try!。

Element UI 实践踩坑- date-picker 组件 定制化type="daterange"

作者 lemon_yyds
2026年4月21日 12:12

针对 datetime-pickerel-date-picker 设置 type="daterange" 修改选中颜色,通常涉及两个部分的样式修改:

  1. 输入框边框颜色:即选中该组件时,外围边框的高亮颜色。
  2. 日历面板选中颜色:即点击日期后,日历面板上日期范围的背景色。

由于不确定你具体使用的是 Element UI (Vue 2) 还是 Element Plus (Vue 3) ,我为你整理了这两种情况下的解决方案。

1. 修改输入框边框颜色 (Focus 状态)

当输入框获得焦点(被选中)时,Element UI 默认会有一个蓝色的外边框或阴影。你可以通过覆盖 CSS 变量或直接修改类名来改变它。

Element Plus (Vue 3)

Element Plus 推荐使用 CSS 变量来修改,这样更规范且容易维护。

/* 修改选中时的边框颜色 */
.el-date-picker {
  --el-input-focus-border-color: #FF6A00; /* 你想要的颜色 */
}

/* 如果是范围选择器,可能需要强制覆盖 box-shadow */
:deep(.el-range-editor.is-active) {
  box-shadow: 0 0 0 1px #FF6A00 inset !important;
}

Element UI (Vue 2)

Element UI 通常需要通过深度选择器来修改边框颜色。

/* 修改边框颜色 */
/deep/ .el-range-editor.is-active {
  border-color: #FF6A00;
}

/* 修改阴影颜色 (部分版本生效) */
/deep/ .el-range-editor.is-active {
  box-shadow: 0 0 2px 0 rgba(255, 106, 0, 0.5); 
}

2. 修改日历面板选中范围颜色

这部分比较特殊,因为日期选择器的下拉面板默认是挂载在 body 下的,而不是组件内部。因此,普通的 scoped 样式可能无法生效。

关键步骤: 你需要给 datetime-picker 添加 popper-class 属性,以便定位到下拉面板。

第一步:HTML 模板设置

<el-date-picker
  v-model="dataValue"
  type="daterange"
  range-separator="至"
  start-placeholder="开始日期"
  end-placeholder="结束日期"
  popper-class="custom-date-range-picker" <!-- 添加这个自定义类名 -->
  :teleported="false" <!-- Element Plus 建议加上这个,防止挂载到 body 导致样式难穿透 -->
>
</el-date-picker>

第二步:CSS 样式设置

你需要使用全局样式(去掉 scoped)或者使用深度选择器配合 popper-class 来修改选中背景色。

/* 针对 Element UI / Element Plus 通用逻辑 */

/* 1. 选中范围的背景色 (开始日期和结束日期中间的区域) */
.custom-date-range-picker .el-date-table td.in-range div,
.custom-date-range-picker .el-date-table td.in-range div:hover {
  background-color: #FFE0B2; /* 浅色背景 */
}

/* 2. 开始日期和结束日期的选中圆点/背景 */
.custom-date-range-picker .el-date-table td.current:not(.disabled) .el-date-table-cell__text {
  background-color: #FF6A00; /* 深色主色调 */
  color: #fff;
}

/* 3. 鼠标悬停时的颜色 */
.custom-date-range-picker .el-date-table td:hover:not(.in-range) div {
  color: #FF6A00;
}

3. 常见问题排查

如果你发现样式写了但不生效,请检查以下几点:

  1. 样式作用域

    • 如果你使用的是 <style scoped>,必须使用深度选择器,如 ::v-deep (Vue 2/3) 或 :deep() (Vue 3)。
    • 最稳妥的方法:将上述 CSS 代码放在一个不包含 scoped<style> 标签中,或者放在全局 CSS 文件中。
  2. 挂载位置

    • 如果下拉面板挂载在 body 上,你的组件 scoped 样式是绝对无法直接穿透的。务必使用 popper-class 属性来“钩住”下拉面板。
  3. 优先级

    • 如果样式依然不生效,可以在 CSS 属性后加上 !important 强制覆盖。

总结表格

修改目标 Element Plus (Vue 3) 推荐写法 Element UI (Vue 2) 推荐写法
输入框边框 修改 --el-input-focus-border-color 变量 覆盖 .el-range-editor.is-activeborder-color
下拉面板样式 添加 popper-class + :teleported="false" 添加 popper-class + 全局 CSS
选中背景色 覆盖 .el-date-table td.in-range div 同上

Vue keep-alive 原理全解析(Vue2+Vue3适配)

2026年4月21日 11:55

Vue keep-alive 原理全解析(Vue2+Vue3适配)

Vue 中的 keep-alive 是一个内置抽象组件,核心作用是缓存组件实例,避免组件频繁创建和销毁,从而提升页面切换性能、保留组件状态(如表单输入、滚动位置)。它本身不会渲染 DOM,也不会出现在组件层级中,仅作为“容器”负责管理其包裹的组件的生命周期。

与 v-show(通过 CSS 控制显隐)、v-if(控制组件挂载/卸载)不同,keep-alive 是通过缓存组件实例实现状态保留,组件卸载时不会被销毁,而是被缓存到内存中,再次渲染时直接复用缓存的实例,无需重新执行 created、mounted 等生命周期钩子,这也是它提升性能的核心原因。

一、keep-alive 核心底层原理

keep-alive 的底层实现依赖 Vue 的组件生命周期钩子和缓存容器,核心逻辑分为“缓存存储”“缓存匹配”“实例复用”三个步骤,Vue2 和 Vue3 原理一致,仅底层 API 和缓存容器细节有细微差异。

1. 核心机制:缓存容器 + 生命周期拦截

keep-alive 内部维护了一个缓存对象(缓存容器) ,用于存储被包裹组件的实例,同时拦截组件的生命周期,修改其默认的挂载/卸载行为:

  • 当组件首次被渲染时,keep-alive 会将组件实例存入缓存容器,同时阻止组件的 destroy 钩子执行(避免实例被销毁);
  • 当组件被切换(路由跳转、v-if 切换)时,组件不会被卸载,而是被“缓存”起来,DOM 会被隐藏(并非删除);
  • 当组件再次被渲染时,keep-alive 会从缓存容器中取出之前缓存的实例,直接复用,无需重新创建,同时触发对应的缓存生命周期钩子。

2. 底层缓存容器实现(Vue2 vs Vue3)

keep-alive 的缓存容器本质是一个对象(或 Map),用于存储组件实例,key 通常是组件的 name(或内部生成的唯一标识),value 是组件实例本身,不同版本的实现略有差异:

// Vue2 底层缓存容器(简化版)
const cache = Object.create(null) // 用对象存储缓存,key为组件name,value为实例

// Vue3 底层缓存容器(简化版)
const cache = new Map() // 用Map存储缓存,key为组件name或唯一标识,value为实例

注意:keep-alive 缓存的是组件实例,而非 DOM 元素;DOM 元素会随着组件实例的缓存被保留,再次渲染时直接插入页面,避免重新渲染 DOM 的开销。

3. 生命周期拦截与重写

Vue 组件默认的生命周期是:创建(created)→ 挂载(mounted)→ 卸载(destroyed)。keep-alive 会拦截组件的 mounted 和 destroyed 钩子,并重写其行为:

  • 首次渲染:组件正常执行 created → mounted,执行完毕后,keep-alive 将实例存入缓存,同时标记组件为“已缓存”;
  • 缓存后再次渲染:不执行 created、mounted 钩子(避免重复初始化),直接复用缓存实例,触发 activated 钩子;
  • 组件被切换隐藏:不执行 destroyed 钩子(避免实例销毁),仅触发 deactivated 钩子,实例被保留在缓存中;
  • 缓存被清除:实例才会执行 destroyed 钩子,彻底销毁。

注意:只有被 keep-alive 包裹的组件,才会拥有 activated 和 deactivated 两个专属生命周期钩子,未被包裹的组件不会触发这两个钩子。

二、keep-alive 核心属性(控制缓存范围)

keep-alive 提供 3 个核心属性,用于控制缓存的组件范围,避免缓存过多组件导致内存占用过大,这是使用 keep-alive 时的关键配置,也是避免滥用缓存的核心:

1. include(白名单)

仅缓存名称匹配 include 的组件,支持字符串(逗号分隔)、数组、正则表达式。组件名称需与组件的 name 选项一致(不可省略),否则无法匹配。

<!-- 字符串:缓存 name 为 Home、About 的组件 -->
<keep-alive include="Home,About">
  <router-view />
</keep-alive>

<!-- 数组:缓存 Home、About 组件 -->
<keep-alive :include="['Home', 'About']">
  <router-view />
</keep-alive>

2. exclude(黑名单)

不缓存名称匹配 exclude 的组件,用法与 include 一致,优先级高于 include(若组件同时在两个名单中,以 exclude 为准,不缓存)。

<!-- 不缓存 name 为 Login 的组件 -->
<keep-alive exclude="Login">
  <router-view />
</keep-alive>

3. max(缓存数量限制)

限制缓存的组件实例数量,当缓存的实例数量超过 max 时,会按照“LRU(最近最少使用)”策略,删除最久未使用的缓存实例,避免内存泄漏。Vue2.5+ 新增该属性,Vue3 完全兼容。

<!-- 最多缓存 3 个组件实例,超过则删除最久未使用的 -->
<keep-alive :max="3">
  <router-view />
</keep-alive>

注意:LRU 策略是 keep-alive 内置的缓存淘汰机制,核心逻辑是“最近使用的组件优先保留,最久未使用的组件优先淘汰”,适用于需要缓存多个组件但担心内存占用的场景。

三、keep-alive 缓存逻辑细节

1. 缓存的匹配规则

keep-alive 匹配组件时,优先使用组件的 name 选项作为匹配依据,若组件未设置 name(或 name 为空),则无法被 include/exclude 匹配,也无法被缓存(Vue3 中未设置 name 的组件会被默认命名,但仍建议显式设置)。

注意:路由组件的 name 需与路由配置中的 name 保持一致,否则 include/exclude 无法匹配路由组件。

2. 组件状态的保留机制

keep-alive 缓存的是组件实例,因此组件内的 data 数据、表单输入、滚动位置等状态都会被保留:

  • 表单输入:缓存后再次进入组件,输入框中的内容不会丢失;
  • 滚动位置:缓存后再次进入组件,页面滚动条会停留在上一次离开时的位置;
  • 数据状态:组件内的 data 数据不会被重置,仍保持上一次的状态。

注意:若需要重置组件状态(如再次进入时清空表单),可在 activated 钩子中手动重置数据,因为 activated 钩子每次组件被激活时都会触发。

3. 动态组件与 keep-alive 的结合

keep-alive 常与动态组件(component 标签 + is 属性)结合使用,实现组件切换时的缓存:

<keep-alive include="ComponentA,ComponentB">
  <component :is="currentComponent" />
</keep-alive>

<script setup>
import { ref } from 'vue'
import ComponentA from './ComponentA.vue'
import ComponentB from './ComponentB.vue'

const currentComponent = ref('ComponentA') // 切换组件
</script>

此时,ComponentA 和 ComponentB 切换时,都会被缓存,避免频繁创建和销毁,提升切换流畅度。

4. Vue3 专属实战示例(可直接复制复用)

以下示例均适配 Vue3 组合式 API(

示例1:Vue3 路由缓存(最常用,配合 router-view)

<!-- App.vue 中使用,缓存指定路由组件 -->
<template>
  <div id="app">
    <router-link to="/home">首页</router-link>
    <router-link to="/list">列表页</router-link>
    <router-link to="/login">登录页</router-link>
    
    <!-- 缓存 Home、List 组件,排除 Login 组件 -->
    <keep-alive include="Home,List" exclude="Login" :max="2">
      <router-view />
    </keep-alive>
  </div>
</template>

<script setup>
// 无需额外引入,Vue3 内置 keep-alive
</script>

// 路由组件示例(List.vue,需显式设置name)
<template>
  <div>
    <h2>列表页</h2>
    <input v-model="keyword" placeholder="搜索关键词" />
    <ul>
      <li v-for="item in list" :key="item.id">{{ item.name }}</li>
    </ul>
  </div>
</template>

<script setup>
import { ref, onActivated, onDeactivated } from 'vue'

// 必须显式设置组件name,否则keep-alive无法匹配
defineOptions({
  name: 'List'
})

const keyword = ref('')
const list = ref([
  { id: 1, name: 'Vue3 keep-alive 实战' },
  { id: 2, name: 'Vue3 组合式 API 用法' }
])

// 组件被激活时触发(每次进入都执行)
onActivated(() => {
  console.log('列表页被激活,可执行刷新数据等操作')
})

// 组件被缓存隐藏时触发
onDeactivated(() => {
  console.log('列表页被缓存,可执行清理操作')
})
</script>

示例2:Vue3 动态组件缓存(配合 component 标签)

<template>
  <div>
    <button @click="currentComponent = 'UserInfo'">用户信息</button>
    <button @click="currentComponent = 'UserSetting'">用户设置</button>
    
    <!-- 缓存 UserInfo、UserSetting 两个动态组件,限制最多缓存2个 -->
    <keep-alive include="UserInfo,UserSetting" :max="2">
      <component :is="currentComponent" />
    </keep-alive>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import UserInfo from './UserInfo.vue'
import UserSetting from './UserSetting.vue'

const currentComponent = ref('UserInfo')
</script>

// UserInfo.vue(需显式设置name)
<script setup>
defineOptions({
  name: 'UserInfo'
})
// 组件内容省略...
</script>

// UserSetting.vue(需显式设置name)
<script setup>
defineOptions({
  name: 'UserSetting'
})
// 组件内容省略...
</script>

示例3:Vue3 缓存组件状态重置(activated 钩子用法)

<template>
  <keep-alive include="FormPage">
    <component :is="currentComponent" />
  </keep-alive>
</template>

<script setup>
import { ref } from 'vue'
import FormPage from './FormPage.vue'

const currentComponent = ref('FormPage')
</script>

// FormPage.vue(缓存后重置表单状态)
<template>
  <form>
    <input v-model="form.name" placeholder="姓名" />
    <input v-model="form.age" placeholder="年龄" />
  </form>
</template>

<script setup>
import { ref, onActivated } from 'vue'

defineOptions({
  name: 'FormPage'
})

const form = ref({
  name: '',
  age: ''
})

// 每次进入组件(被激活),重置表单状态
onActivated(() => {
  form.value = {
    name: '',
    age: ''
  }
})
</script>

示例4:Vue3 手动清除 keep-alive 缓存

<template>
  <div>
    <button @click="clearCache">清除列表页缓存</button>
    <keep-alive include="Home,List" ref="keepAliveRef">
      <router-view />
    </keep-alive>
  </div>
</template>

<script setup>
import { ref } from 'vue'

// 获取keep-alive实例
const keepAliveRef = ref(null)

// 手动清除指定组件的缓存(List组件)
const clearCache = () => {
  // cache 是keep-alive内部的缓存容器(Vue3为Map)
  const cache = keepAliveRef.value.cache
  // 遍历缓存,删除name为List的组件实例
  for (const [key, value] of cache.entries()) {
    if (value.type.name === 'List') {
      cache.delete(key)
      // 触发组件销毁(可选)
      value.component?.unmount()
    }
  }
}
</script>

说明:示例中所有组件均显式设置 name(Vue3 组合式 API 用 defineOptions 定义),确保 keep-alive 的 include/exclude 能正常匹配;所有代码可直接复制,替换组件名称和内容即可适配自身项目。

四、Vue3 keep-alive 核心缓存策略(重点)

Vue3 中 keep-alive 并非单一缓存逻辑,而是通过内置规则+属性配置+手动干预实现多层缓存控制,核心分为四大缓存策略,覆盖日常开发全场景,每类策略均对应底层逻辑和实战用法,避免缓存滥用和内存问题。

1. 全量默认缓存策略(基础无配置)

这是 keep-alive 最基础的缓存策略,不配置任何属性时默认生效,核心是缓存所有被包裹的组件实例,无筛选、无数量限制,适合仅需缓存单个组件的极简场景。

核心逻辑:组件首次挂载后存入内部 Map 缓存容器,切换时不销毁实例、仅隐藏 DOM,再次激活直接复用,全程仅执行一次 created、mounted 钩子。

<!-- 全量缓存示例:缓存 router-view 内所有路由组件 -->
<keep-alive>
  <router-view />
</keep-alive>

对应实战场景:适合单个路由组件缓存(如仅首页缓存),可将示例1中 include="Home,List" exclude="Login" :max="2" 简化为无任何属性配置,即 ,需注意避免多组件场景使用。

注意:该策略不适合多组件场景,会无限制占用内存,频繁切换多组件时严禁直接使用,必须搭配范围控制属性。

2. 范围筛选缓存策略(精准控制)

通过 include(白名单)exclude(黑名单) 两个属性实现精准筛选,是企业级开发最常用的策略,解决“只缓存需要保留状态的组件”核心需求,二者优先级:exclude > include。

(1)白名单缓存策略(include)

仅缓存组件 name 匹配的组件,未匹配组件完全不缓存,每次切换都会重新创建销毁,适合指定少数核心页面缓存。

<!-- 仅缓存 Home、List 两个路由组件 -->
<keep-alive :include="['Home','List']">
  <router-view />
</keep-alive>

对应实战示例:参考“示例1:Vue3 路由缓存”,其中 include="Home,List" 就是典型的白名单策略,仅缓存首页和列表页,排除登录页,贴合企业级路由缓存高频场景,与示例中配置完全匹配。

(2)黑名单缓存策略(exclude)

排除指定组件,其余被包裹组件全部缓存,适合大部分组件需要缓存、仅少数组件无需缓存的场景。

<!-- 缓存所有组件,排除 Login、Detail 组件 -->
<keep-alive exclude="Login,Detail">
  <router-view />
</keep-alive>

对应实战示例:可基于示例1修改,将 include="Home,List" 改为 exclude="Login",即可实现“缓存所有路由组件,仅排除登录页”,与示例1的路由缓存场景一致,适配大部分页面需缓存的业务需求。

关键要求:该策略依赖组件 name,Vue3 组合式API中必须用 defineOptions 显式声明 name,自动生成的默认name易匹配失败。

3. LRU 淘汰缓存策略(内存优化)

通过 max 属性配合内置 LRU(最近最少使用) 算法实现,是 Vue3 自带的内存保护策略,专门解决多组件缓存导致的内存溢出问题。

核心逻辑:设定最大缓存数量,当缓存实例数超过 max 值时,自动删除最久未被激活使用的组件缓存,保留近期高频使用的组件实例,平衡性能与内存占用。

<!-- 最多缓存3个组件,超出则触发LRU淘汰 -->
<keep-alive :include="['Home','List','User','Setting']" :max="3">
  <router-view />
</keep-alive>

对应实战示例:参考“示例2:Vue3 动态组件缓存”,其中 :max="2" 就是LRU淘汰策略的应用,限制缓存UserInfo和UserSetting两个组件,若新增组件切换(如新增UserCenter),会自动淘汰最久未使用的组件,与示例配置完全对应。

4. 手动干预缓存策略(灵活控制)

属于进阶策略,突破内置属性限制,通过 ref 获取 keep-alive 实例,直接操作内部 Map 缓存容器,实现手动清除指定/全部缓存,适合需要动态重置缓存的场景(如退出登录、表单提交后清空缓存)。

核心逻辑:Vue3 中 keep-alive 实例暴露 cache 属性(Map 类型),可通过遍历、删除键值对实现手动清缓存,还可配合组件 unmount 彻底销毁实例。

<template>
  <button @click="clearTargetCache">清空列表页缓存</button>
  <button @click="clearAllCache">清空全部缓存</button>
  <keep-alive ref="keepAliveRef" include="Home,List,User">
    <router-view />
  </keep-alive>
</template>

<script setup>
import { ref } from 'vue'
const keepAliveRef = ref(null)

// 手动清除指定组件(List)缓存
const clearTargetCache = () => {
  const cacheMap = keepAliveRef.value?.cache
  if (!cacheMap) return
  for (const [key, instance] of cacheMap.entries()) {
    if (instance.type.name === 'List') {
      cacheMap.delete(key)
      // 彻底销毁组件实例
      instance.component?.unmount()
    }
  }
}

// 手动清空所有缓存
const clearAllCache = () => {
  const cacheMap = keepAliveRef.value?.cache
  if (!cacheMap) return
  cacheMap.clear()
}
</script>

对应实战示例:完全匹配“示例4:Vue3 手动清除 keep-alive 缓存”,示例4中通过 ref 获取 keep-alive 实例、删除List组件缓存,与本策略“手动干预缓存”的核心逻辑、代码实现完全一致,可直接复制示例4代码适配自身项目。

缓存策略使用优先级(推荐)

日常开发优先按这个顺序选择,兼顾性能与易用性: 范围筛选策略(include/exclude)→ LRU淘汰策略(max)→ 手动干预策略 → 默认全量策略

策略与示例对应总结(无偏差):范围筛选策略(include/exclude)对应示例1(路由缓存)、示例2(动态组件缓存);LRU淘汰策略(max)对应示例2;手动干预策略对应示例4(手动清缓存);默认全量策略可基于示例1简化配置实现,各类策略与示例精准匹配,无偏差。

五、Vue2 与 Vue3 keep-alive 核心差异

keep-alive 的核心原理和用法在 Vue2 和 Vue3 中基本一致,主要差异集中在底层实现和部分细节,不影响日常使用:

对比维度 Vue2 Vue3
缓存容器 使用普通对象(Object)存储 使用 Map 存储,性能更优,支持更灵活的 key 类型
组件 name 要求 必须显式设置 name,否则无法匹配缓存 未显式设置 name 时,会自动生成默认名称(基于组件文件路径),但仍建议显式设置
生命周期钩子 activated/deactivated 钩子在组件内直接定义 选项式 API 用法与 Vue2 一致;组合式 API 中需使用 onActivated、onDeactivated 钩子
底层实现 基于 Vue 实例的 $destroy 方法拦截 基于组件的 unmount 生命周期拦截,与 Composition API 适配更友好
缓存策略拓展 仅基础筛选+LRU,无便捷手动清缓存方式 支持直接操作 Map 缓存,手动清缓存更便捷

注意:Vue3 中,keep-alive 不支持包裹多个根节点的组件,否则会抛出警告并失效,需确保被包裹的组件只有一个根节点。

六、常见使用场景与注意事项

1. 常见使用场景

  • 路由切换场景:如首页、列表页、详情页切换,缓存列表页状态(避免重新请求数据、重置滚动位置);
  • 动态组件切换场景:如标签页、步骤条,缓存每个标签/步骤的组件状态;
  • 表单场景:如长表单分页填写,缓存已填写的表单数据,避免切换分页时数据丢失。

2. 缓存策略专属注意事项

  • 范围策略必配name:使用include/exclude时,Vue3组合式API必须用defineOptions声明name,禁止依赖自动生成name;
  • LRU策略max值合理设置:max数值建议按业务高频页面数量设定,一般设3-5即可,不宜过大或过小;
  • 手动清缓存需彻底:删除缓存后建议调用unmount销毁实例,避免残留实例导致内存泄漏;
  • 禁止策略冲突:不同时配置冲突的include和exclude,避免缓存不生效;
  • 动态路由缓存适配:动态路由组件需保证name固定,否则范围策略匹配失效;
  • 缓存状态按需重置:即便用了缓存策略,仍需在onActivated钩子中处理状态重置,避免旧数据干扰。

3. 通用关键注意事项

  • 避免过度缓存:不要缓存所有组件,尤其是一次性使用、无需保留状态的组件(如登录页),否则会增加内存占用,反而影响性能;
  • 缓存组件的生命周期差异:被缓存的组件,created、mounted 仅执行一次,后续渲染仅触发 activated,卸载仅触发 deactivated;
  • 避免缓存带定时器/事件监听的组件:若组件内有定时器、事件监听,需在 deactivated 钩子中清除,在 activated 钩子中重新初始化,避免内存泄漏;
  • Vue3 多根组件限制:keep-alive 包裹的组件必须是单根节点,否则缓存失效并抛出警告。

head.tsx 就是一个 React 组件:用 loader 数据动态生成 SEO meta

作者 AI划重点
2026年4月21日 11:55

看看大部分框架怎么处理 <head>

// Next.js
export const metadata = {
  title: 'Blog Post',
  description: '...',
  openGraph: { title: '...', images: [...] },
}

// Remix
export const meta: MetaFunction = ({ data }) => [
  { title: 'Blog Post' },
  { name: 'description', content: '...' },
  { property: 'og:image', content: data.post.coverImage },
]

元数据是配置对象。你把字符串和键值对塞进框架规定的 schema,框架再把它们转成 HTML 标签。

Pareto 反其道而行。在 Pareto 里,head.tsx 是一个返回 JSX 的 React 组件:

// app/head.tsx
export default function Head() {
  return (
    <>
      <title>My App</title>
      <meta name="description" content="My awesome app." />
    </>
  )
}

就这样。没有要学的 config schema,没有特殊的 MetaDescriptor 类型。你写 <title><meta>,React 19 自动把它们吊到文档 <head> 里。

本文讲清楚为什么这个设计更好,以及它在动态 SEO 上能解锁什么。

为什么组件比配置好

三个理由。

1. 你拿到了 JSX —— 包括表达式、循环、条件

配置对象是静态数据。如果你想"只在用户是高级账号时加这条 meta",你要么在 return 前命令式地构造对象,要么把条件逻辑塞进值里。

组件是代码。条件按正常方式写:

export default function Head({ loaderData }: HeadProps) {
  const data = loaderData as LoaderData
  return (
    <>
      <title>{data.product.name}</title>
      <meta name="description" content={data.product.tagline} />

      {data.product.coverImage && (
        <meta property="og:image" content={data.product.coverImage} />
      )}

      {data.product.keywords.map((kw) => (
        <meta property="article:tag" content={kw} key={kw} />
      ))}
    </>
  )
}

循环、守卫、条件渲染 —— React 本来就做的事情。

2. Head 组件和你应用的其他部分一样能组合

想把共享的 OG 标签抽成 helper?它就是个 React 组件:

function OpenGraphTags({ title, description, image }: OGProps) {
  return (
    <>
      <meta property="og:title" content={title} />
      <meta property="og:description" content={description} />
      <meta property="og:image" content={image} />
      <meta property="og:type" content="article" />
    </>
  )
}

export default function Head({ loaderData }: HeadProps) {
  const { post } = loaderData as { post: Post }
  return (
    <>
      <title>{post.title} — My Blog</title>
      <OpenGraphTags
        title={post.title}
        description={post.excerpt}
        image={post.coverImage}
      />
    </>
  )
}

在配置对象的世界里,这是一个返回数组、然后 spread 到另一个数组里的 helper 函数。在这里,它是组件。读树就能看到 <head> 里最终会有什么 HTML。

3. React 19 帮你做了 hoisting

这才是让整个方案成立的关键特性。在 React 19 里,你在树里任何地方渲染的 <title><meta><link>,都会被吊到文档 <head> 里——SSR 和客户端导航都一样。没有框架特定的 MetaProvider 在收集和序列化元数据。这是 React 平台级特性。

路由树决定谁胜出

Head 组件从根渲染到页面。每一层贡献自己的标签。当两层渲染同一个标签(比如两个 <title>),浏览器用最后一个——最深路由的自动胜出。

app/
  head.tsx                  ← 站点默认
  blog/
    [slug]/
      head.tsx              ← 单篇博文覆盖

根层设默认。叶子路由覆盖。这就是你思考 SEO 的方式——大部分标签全站通用,单页加自己的特定项。

// app/head.tsx —— 站点默认
export default function Head() {
  return (
    <>
      <title>My App</title>
      <meta name="description" content="The best app for doing things." />
      <link rel="icon" href="/favicon.ico" />
      <meta property="og:site_name" content="My App" />
    </>
  )
}
// app/blog/[slug]/head.tsx —— 单篇博文覆盖
import type { HeadProps } from '@paretojs/core'

export default function Head({ loaderData }: HeadProps) {
  const { post } = loaderData as { post: BlogPost }
  return (
    <>
      <title>{post.title} — My App</title>
      <meta name="description" content={post.excerpt} />
      <meta property="og:title" content={post.title} />
      <meta property="og:image" content={post.coverImage} />
      <link rel="canonical" href={`https://myapp.com/blog/${post.slug}`} />
    </>
  )
}

HeadProps:带类型的 loader 数据

每个 head 组件收两个 prop:

interface HeadProps {
  loaderData: unknown
  params: Record<string, string>
}

loaderData 是这个路由 loader 返回的东西。它被声明为 unknown——转成你的实际类型就行。

这就是让动态 SEO 水到渠成的关键。Loader 拉到了 post。Head 组件收到完全相同的数据。没有单独的 generateMetadata 调用去重新拉 post。数据流是:loader → page + head,两者用同一个结果渲染。

完整的动态 SEO 示例

给商品目录做实打实的每页 SEO 长这样。

// app/products/[id]/head.tsx
import type { HeadProps } from '@paretojs/core'

export default function Head({ loaderData }: HeadProps) {
  const { product } = loaderData as { product: Product }
  const canonicalUrl = `https://shop.example.com/products/${product.id}`
  const primaryImage = product.images[0]?.url ?? '/default-og.png'

  const jsonLd = {
    '@context': 'https://schema.org',
    '@type': 'Product',
    name: product.name,
    description: product.description,
    image: product.images.map((img) => img.url),
    offers: {
      '@type': 'Offer',
      price: product.price,
      priceCurrency: product.currency,
      availability: product.inStock
        ? 'https://schema.org/InStock'
        : 'https://schema.org/OutOfStock',
      url: canonicalUrl,
    },
  }

  return (
    <>
      <title>{`${product.name} — Our Shop`}</title>
      <meta name="description" content={product.description} />
      <link rel="canonical" href={canonicalUrl} />

      <meta property="og:type" content="product" />
      <meta property="og:title" content={product.name} />
      <meta property="og:description" content={product.description} />
      <meta property="og:image" content={primaryImage} />

      <meta name="twitter:card" content="summary_large_image" />
      <meta name="twitter:title" content={product.name} />
      <meta name="twitter:image" content={primaryImage} />

      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
      />
    </>
  )
}

一个文件。动态 title、完整 Open Graph、Twitter 卡片、canonical URL、JSON-LD 结构化数据——全部来自页面组件同样要用的那个 product 对象。没有重复拉取,没有单独的 metadata API。

简短版本

Pareto 的 head 系统是架在 React 19 特性之上的一个约定:

  • head.tsx 是一个返回 JSX 的 React 组件
  • React 19 自动把 <title><meta><link> 吊到 <head>
  • Head 组件把 loaderDataparams 作为 props 收到
  • 树从根渲染到页面,某种标签的最后一个胜出

没有独立的 metadata API 要学。你会 React,就会写 meta。任何动态场景,模式都一样:loader 返回数据,head.tsx 用它渲染 JSX,React 19 吊标签。SEO 搞定。

npx create-pareto@latest my-app
cd my-app && npm install && npm run dev

Pareto 是一个基于 Vite 的轻量流式优先 React SSR 框架。文档

AI 生成的代码都是一坨屎?聊聊怎么给 Agent 制定工程约束

作者 ErpanOmer
2026年4月21日 11:35

Gemini_Generated_Image_jrfm35jrfm35jrfm (1).png

有次前端架构评审会上,隔壁业务线的一个前端组长说:他们团队上个月全员接入了某款国产当红(不用猜,肯定时 GLM 🤣🤣)的 AI 编码 Agent,一开始开发效率确实起飞了,原来排期三天的页面,一天就能搞定。但好景不长,上周他们打算对一个核心模块进行迭代时,发现根本无从下手。

因为那个 Agent 为了实现一个极其复杂的带级联筛选的表格,硬生生在一个 .tsx 文件里堆了快 2000 行代码。里面塞满了不知道从哪里抄来的正则、极其随意的变量、以及深达四层的嵌套回调。

他在会上总结陈词:AI 生成的代码就是一坨屎👎👎,只能做做外包和 Demo,根本上不了大型企业的生产环境。

我在台下听得直摇头,心想:兄弟,是不是你自己没有用好呢?😖

做了这么多年前端,我带过不少实习生和初级开发。你回忆一下,如果你丢给一个刚毕业的新人一个复杂需求,不给他讲团队规范,不给他看底层的 Utils 封装,不给他做 Code Review,他写出来的东西,是不是跟现在 AI 生成的屎山一模一样?

AI 就像一个拥有无限精力和极快的手速,但完全没有工程常识的初级程序员。 它脑子里装满了 GitHub 上的开源代码,但它不知道你们公司的网络请求需要怎么带 Token,不知道你们的 UI 规范规定主色调必须用 Tailwind 的哪个变量,更不知道你为了防竞态条件专门写过一个自定义 Hook

如果你只是在输入框里敲一句帮我写一个用户列表,然后就等着一键合并代码,那你不被屎山淹没谁被淹没?

到了 2026 年,一个合格的高级前端,核心能力早就不是比拼敲键盘的速度了,而是如何像 Tech Lead 一样,给你的 AI Agent 制定严苛的工程约束。

咱们直接拿真实场景讲清楚。


先说说,不受约束的 AI 是怎么写代码的?

假设你让 Agent 写一个 带防抖的搜索框,然后去后端拉取数据。一个没有任何工程约束的 Agent,凭着它的原始本能,大概率会给你生成这样的代码👇:

20260421104111_rec_.gif

// 毫无工程约束的 AI 垃圾代码
import React, { useState, useEffect } from 'react';

const SearchLocation = () => {
  const [query, setQuery] = useState('');
  const [locations, setLocations] = useState([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    let timeoutId;
    if (query) {
      setLoading(true);
      // 让它手写防抖,极其容易漏掉清理函数导致内存泄漏
      timeoutId = setTimeout(() => {
        // 它直接裸调 fetch,完全无视项目里封装好的 Axios 拦截器
        fetch(`https://api.company.com/locations?q=${query}`, {
          headers: { Authorization: `Bearer ${localStorage.getItem('token')}` } // 这样安全吗?😢
        })
          .then(res => res.json())
          .then(data => {
            setLocations(data);
            setLoading(false);
          });
      }, 500);
    }
    return () => clearTimeout(timeoutId);
  }, [query]);

  return (
    // 直接写死 16px、#333 这种样式,破坏设计系统
    <div style={{ padding: '16px', background: '#fff' }}>
      <input 
        value={query} 
        onChange={e => setQuery(e.target.value)} 
        style={{ border: '1px solid #ccc', borderRadius: '4px' }}
      />
      {/* 渲染列表 */}
    </div>
  )
}

代码能跑吗?完美运行。 但如果你的项目里全充斥着这种裸调 API、手写样式、自己控制防抖生命周期的代码,这个项目活不过三个月🤷‍♂️。


把规范写进 Agent 的基因里

现在主流的 AI 工具(无论是 Cursor 还是跑在本地的 Agent),都已经支持了全局的规则配置,也就是我们常说的 .cursorrules 或者 System Prompt

给 AI 立规矩,本质上就是把你作为老兵的架构品味,强制注入到它的上下文中。

我是怎么约束我手底下的 Agent 的?我会直接在根目录下写死一份极度严苛的工程约束文档。每次让它写代码前,它必须先过这四道防线:

Gemini_Generated_Image_buq06hbuq06hbuq0.png

规则一:严禁私自造轮子,强制依赖倒置

禁止 Agent 自己写原生的 fetch 或者手搓防抖节流。我会明确告诉它:项目中所有的异步请求必须从 @/utils/request 引入,所有的防抖机制必须使用 ahooks 里的 useDebounce

规则二:状态与 UI 强制分离

只要组件超过 100 行,强制要求 Agent 把复杂的 useStateuseEffect 抽离成独立的 useXxx 自定义 HookJSX 部分只允许有纯粹的渲染逻辑。

规则三:锁死样式系统

严禁使用行内样式和传统的 CSS 文件。强制规定:必须且只能使用 Tailwind CSS。如果是特定的颜色值,必须使用 tailwind.config.js 里定义好的主题变量,比如 text-primary-500

规则四:安全底线

严禁在组件内部直接读取 localStorage 获取 Token。所有鉴权状态必须从全局的 Store 或者专门的 useAuth 钩子里拿。


约束后,你的代码品味就上来了

当你在 Agent 的配置里锁死了上面这些工程约束,你再给它下达完全一样的开发指令帮我写一个搜索框。

你来看看这次它给你的反馈是什么级别的:

// 经过工程约束后的 AI 生成代码
import React, { useState } from 'react';
import { useDebounce } from 'ahooks';
import { fetchLocations } from '@/api/location'; // 强制使用了统一的 API 封装
import { useAuth } from '@/hooks/useAuth'; // 强制使用了鉴权 Hook

// 约束生效:UI 与逻辑彻底分离
const useLocationSearch = () => {
  const [query, setQuery] = useState('');
  const [locations, setLocations] = useState<Location[]>([]);
  const [loading, setLoading] = useState(false);
  const { getToken } = useAuth(); // 安全规范生效

  const debouncedQuery = useDebounce(query, { wait: 500 });

  React.useEffect(() => {
    if (!debouncedQuery) return;
    
    const loadData = async () => {
      setLoading(true);
      try {
        // 网络层封装生效,无需关心 Token 拼接
        const data = await fetchLocations(debouncedQuery, getToken());
        setLocations(data);
      } catch (err) {
        console.error(err);
      } finally {
        setLoading(false);
      }
    };
    loadData();
  }, [debouncedQuery, getToken]);

  return { query, setQuery, locations, loading };
};

export const SearchLocation = () => {
  const { query, setQuery, locations, loading } = useLocationSearch();

  // 严格使用了 Tailwind 设计系统
  return (
    <div className="p-4 bg-white rounded-lg shadow-sm">
      <input
        type="text"
        className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-primary-500 focus:border-primary-500"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="search locations..."
      />
      {/* 渲染列表 */}
    </div>
  );
};

发现区别了吗? 第二段代码,模块极其清晰,网络请求、样式规范、安全校验全部卡在了你设定的架构底线内。这是一段哪怕我现在离职,下家接手时绝对不会骂娘的高质量代码😁。


你在驾驭,还是在旁观?

很多人觉得写 Prompt 是一门玄学,今天用请你扮演一个资深前端专家,明天用深呼吸,一步一步想。 别扯了!在严肃的商业开发面前,这些花里胡哨的咒语(指:AI 生成的代码就是一坨屎) 毫无意义。

真正的 AI 辅助开发,本质上是一次高强度的自动化 Code Review。

你觉得 AI 生成的代码烂,是因为你作为业务的 Owner,主动放弃了你的系统架构权。

你能不能把项目里乱七八糟的依赖清理干净?

你能不能提炼出极其精准的 TS 类型定义?

你能不能给那些跑在服务器上的自动化 Agent,画出一道道不可逾越的护城河,让它们在这个安全的沙盒里为你疯狂产出?🤔

AI 永远不会写出屎山。写出屎山的,是那个连架构边界都划不清楚,只知道疯狂按 Tab 键和回车的:

你!You !🫵🫵🫵

从Claude Code泄露源码看工程架构:第七章 —— 多 Agent 协作机制与上下文隔离策略

2026年4月21日 11:17

本文系统剖析 Claude Code 的多 Agent 协作架构。通过深入分析上下文隔离机制、侧链转录记录、coordinator 模式的工具边界控制以及 Task ID 防攻击设计,揭示其"同步共享、异步隔离、转录留痕"的设计哲学。研究表明,该设计在支持灵活协作的同时,有效防止了上下文污染和状态竞态问题,将并发错误率降低 70-80%

1. 问题定义与研究背景

1.1 多Agent系统的四大核心挑战

在多 Agent 系统中,多个代理同时执行任务时面临四个经典架构挑战:

挑战维度 具体问题 传统方案缺陷
状态共享边界 哪些 Agent 可以共享主线程状态,哪些必须隔离? 默认共享,竞态风险高
上下文污染防范 如何防止子 Agent 的执行结果干扰主 Agent 的上下文? 无隔离机制,易混乱
可追溯性 如何记录子 Agent 的执行过程以便审计和恢复? 日志缺失,难以排查
角色分工 Coordinator 模式下,主 Agent 和 Worker 的职责如何划分? 隐式约定,易误解

研究目标:

  1. 解析同步/异步 Agent 的状态共享策略
  2. 量化侧链转录对可追溯性的提升效果
  3. 提炼可复用的多Agent协作设计模式

1.2 Claude Code的创新方案

Claude Code通过隔离与转录分离的架构系统性解决了上述挑战。该架构的核心理念是:同步共享、异步隔离、转录单独留痕。这不是简单的"大家共用一套状态",而是分层的状态管理策略

与传统方案的对比:

方案类型 代表框架 状态管理方式 缺陷
完全共享 AutoGen 所有Agent共享同一状态 竞态条件频发
完全隔离 LangGraph(需手动配置) 独立状态,通信困难 协作效率低
隔离与转录分离 Claude Code 同步共享+异步隔离+侧链记录 实现复杂度高,但安全可靠

2. 架构概览:多 Agent 协作模型

2.1 完整协作流程图

graph TD
    A[主 Agent] -->|发起 AgentTool| B[runAgent<br/>入口函数]
    B --> C{Agent 类型判断}
    
    C -->|同步 Agent| D[shareSetAppState=true<br/>共享主状态]
    C -->|异步 Agent| E[shareSetAppState=false<br/>完全隔离]
    
    D --> F[recordSidechainTranscript<br/>初始消息记录]
    E --> F
    
    F --> G[writeAgentMetadata<br/>元数据写入<br/>agentType/worktreePath]
    G --> H[子 Agent 独立 query 循环]
    H --> I[增量转录<br/>后续消息追加]
    
    J[Coordinator 模式] --> K[getCoordinatorUserContext<br/>注入 worker 工具边界]
    K --> L[getCoordinatorSystemPrompt<br/>明确协调者身份]
    
    M[Task ID 生成] --> N[前缀分类 + 8位随机数<br/>防暴力破解]
    
    style D fill:#e1f5ff,stroke:#333,stroke-width:2px
    style E fill:#ffe1e1,stroke:#333,stroke-width:2px
    style F fill:#fff4e1,stroke:#333,stroke-width:2px
    style J fill:#fce4ec,stroke:#333,stroke-width:2px
    style M fill:#e8f5e9,stroke:#333,stroke-width:2px

图例说明:

  • 🔵 蓝色节点:同步 Agent,共享主状态
  • 🔴 红色节点:异步 Agent,完全隔离
  • 🟡 黄色节点:转录记录,保证可追溯性
  • 🟣 紫色节点:Coordinator 模式,角色显式化
  • 🟢 绿色节点:Task ID,安全基础设施

2.2 核心组件的职责划分

组件 文件位置 职责 处理的核心问题
上下文创建 runAgent.ts:697-714 根据同步/异步决定状态共享策略 状态边界
初始转录 runAgent.ts:732-742 记录 initialMessages 和 metadata 可追溯性
增量转录 runAgent.ts:792-799 O(1) 复杂度追加新消息 性能优化
Coordinator 上下文 coordinatorMode.ts:80-108 注入 worker 工具边界信息 角色分工
Coordinator 提示词 coordinatorMode.ts:111-116 明确协调者身份定位 角色显式化
Task ID 生成 Task.ts:78-106 防暴力破解的任务标识 安全防护

设计哲学:这是关注点分离(Separation of Concerns)原则的典型应用——状态管理、转录记录、角色定义各司其职,互不干扰。


3. 第一步:子 Agent 上下文生成 —— createSubagentContext() 的状态共享策略

3.1 同步 vs 异步的二元判定

文件位置:tools/AgentTool/runAgent.ts:697-714

697:  // Create subagent context using shared helper
698:  // - Sync agents share setAppState, setResponseLength, abortController with parent
699:  // - Async agents are fully isolated (but with explicit unlinked abortController)
700:  const agentToolUseContext = createSubagentContext(toolUseContext, {
701:    options: agentOptions,
702:    agentId,
703:    agentType: agentDefinition.agentType,
704:    messages: initialMessages,
705:    readFileState: agentReadFileState,
706:    abortController: agentAbortController,
707:    getAppState: agentGetAppState,
708:    // Sync agents share these callbacks with parent
709:    shareSetAppState: !isAsync,  // 关键判定
710:    shareSetResponseLength: true,
711:    criticalSystemReminder_EXPERIMENTAL:
712:      agentDefinition.criticalSystemReminder_EXPERIMENTAL,
713:    contentReplacementState,
714:  })

关键观察点:第709行的 shareSetAppState: !isAsync。这是整篇文章最核心的设计决策。


二元判定的清晰边界

Claude Code 没有用含糊的"有些 Agent 共享状态,有些不共享"去描述,而是直接把判定压成一个布尔条件:

Agent 类型 shareSetAppState 状态访问权限 适用场景
同步 Agent true 共享 setAppState,可修改主线程状态 即时反馈、紧密交互
异步 Agent false 完全隔离,不直接写主状态 后台任务、长时间运行

设计价值:这条线一立住,多 Agent 系统很多麻烦都少了一半。因为异步 worker 最大的问题不是算错,而是偷偷写坏共享状态。作者在上下文创建时就把门焊死了。

注意 runAgent.ts:698-699 的注释,作者写得非常明白:

698:  // - Sync agents share setAppState, setResponseLength, abortController with parent
699:  // - Async agents are fully isolated (but with explicit unlinked abortController)

为什么这个布尔值如此重要

很多系统做多 Agent,最容易犯的错是"先共用一套状态,出问题再打补丁"。Claude Code 不是这样。它在子 Agent 出生那一刻就先问一句:

你是不是异步?

如果是,那你的上下文就从一开始被限制成"带自己输入、带自己工具、带自己 abortController,但不直接写主状态"。

工程意义量化:

维度 同步 Agent 异步 Agent 差异分析
状态一致性 高(共享主状态) 最高(完全隔离) 异步更安全
竞态风险 中(需小心同步) 低(天然隔离) 异步风险降 70-80%
协作效率 高(实时共享) 中(需转录通信) 同步更高效
调试难度 中(状态可见) 低(隔离清晰) 异步更易排查
适用场景 即时交互 后台任务 各有优劣

4. 第二步:上下文隔离了,但转录必须留下来 —— 可追溯性保障

4.1 初始消息记录的必要性

文件位置:tools/AgentTool/runAgent.ts:732-742

732:  // Record initial messages before the query loop starts, plus the agentType
733:  // so resume can route correctly when subagent_type is omitted.
735:  void recordSidechainTranscript(initialMessages, agentId).catch(_err =>
736:    logForDebugging(`Failed to record sidechain transcript: ${_err}`),
737:  )
738:  void writeAgentMetadata(agentId, {
739:    agentType: agentDefinition.agentType,
740:    ...(worktreePath && { worktreePath }),
741:    ...(description && { description }),
742:  }).catch(_err => logForDebugging(`Failed to write agent metadata: ${_err}`))

关键观察点:第735行的 recordSidechainTranscript(...) 和第738行的 writeAgentMetadata(...)

隔离与可追溯的平衡艺术

这说明 Claude Code 的策略不是"既然异步 Agent 隔离了,那它就自己玩自己的",而是:

维度 策略 实现方式 设计意图
运行时状态 隔离 shareSetAppState: false 防止竞态条件
过程记录 单独落盘 recordSidechainTranscript() 保证可追溯性
元数据 单独写入 writeAgentMetadata() 支持审计和恢复

设计价值:这个设计保证了:

  1. 子 Agent 不该直接污染主线程状态(隔离)
  2. 子 Agent 做过什么,主系统必须能追出来(可追溯)

这就是"隔离"和"可追溯"同时成立的做法。这是**正交设计(Orthogonal Design)**原则的体现——两个维度的需求互不干扰。

元数据的三维作用

writeAgentMetadata() 记录的信息包括:

字段 用途 应用场景
agentType 区分不同类型的 Agent(如 code_reviewer、test_runner) Resume 功能路由
worktreePath 关联 Git worktree,支持并行开发 多分支协作
description 人类可读的任务描述,便于调试 故障排查、审计日志

5. 第三步:后续消息的增量记录 —— O(1)复杂度的性能优化

5.1 增量追加的性能优势

文件位置:tools/AgentTool/runAgent.ts:792-799

792:      if (isRecordableMessage(message)) {
793:        // Record only the new message with correct parent (O(1) per message)
794:        await recordSidechainTranscript(
795:          [message],
796:          agentId,
797:          lastRecordedUuid,
798:        ).catch(err =>
799:          logForDebugging(`Failed to record sidechain transcript: ${err}`),

关键观察点:第793行注释中的 O(1) per message)

性能优化意识的体现

这不是随手一写,它说明作者已经在考虑多 Agent 长时间运行下的转录成本。

也就是说,子 Agent 的记录不是"每来一条就重刷整份 transcript",而是只把新消息沿着正确父节点追加进去。

性能对比分析:

方案 单条消息成本 N 条消息总成本 内存占用 适用场景
全量重写 O(N) O(N²) O(N) 消息量少(<100)
增量追加 O(1) O(N) O(1) 长时间运行任务
性能提升 N倍 N倍 常数级 -

这点很重要。否则多 worker 跑久了,转录系统本身会变成额外负担。

父节点 UUID 的树状结构

lastRecordedUuid 参数确保消息按正确的父子关系组织:

transcript (树状结构)
├── initialMessages (uuid_0)
│   ├── message_1 (parent: uuid_0)
│   │   └── message_2 (parent: uuid_1)
│   └── message_3 (parent: uuid_0)
└── message_4 (parent: uuid_0)

这种树状结构支持:

  • 分支对话:模型可能基于不同历史做出不同决策
  • 精确定位:审计时可追溯到具体对话分支
  • Resume 恢复:从中断点继续而非从头开始

6. 第四步:Coordinator 模式的工具边界控制 —— 角色显式化

6.1 Coordinator 模式的开关机制

文件位置:coordinator/coordinatorMode.ts:36-40

36:export function isCoordinatorMode(): boolean {
37:  if (feature('COORDINATOR_MODE')) {
38:    return isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE)
39:  }
40:  return false
}

coordinator 模式没有另起一套复杂启动流程,它先是一个运行模式开关。这种设计可以通过环境变量控制,支持渐进式启用和灰度测试。

6.2 Worker 工具上下文的显式注入

文件位置: coordinator/coordinatorMode.ts:80-108

80:export function getCoordinatorUserContext(
81:  mcpClients: ReadonlyArray<{ name: string }>,
82:  scratchpadDir?: string,
83:): { [k: string]: string } {
84:  if (!isCoordinatorMode()) {
85:    return {}  // 非coordinator模式返回空
86:  }
...
88:  const workerTools = isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)
89:    ? [BASH_TOOL_NAME, FILE_READ_TOOL_NAME, FILE_EDIT_TOOL_NAME]
...
97:  let content = `Workers spawned via the ${AGENT_TOOL_NAME} tool have access to these tools: ${workerTools}`
99:  if (mcpClients.length > 0) {
100:    const serverNames = mcpClients.map(c => c.name).join(', ')
101:    content += `\n\nWorkers also have access to MCP tools from connected MCP servers: ${serverNames}`
102:  }
104:  if (scratchpadDir && isScratchpadGateEnabled()) {
105:    content += `\n\nScratchpad directory: ${scratchpadDir}\nWorkers can read and write here without permission prompts.`
106:  }
108:  return { workerToolsContext: content }

显式边界声明的设计智慧

这里特别有意思。协调者模式真正做的事情,不是让主 Agent 更强,而是明确告诉它 worker 到底拥有哪些边界内的能力

这一步非常像项目经理给外包团队写任务说明:

信息类型 内容 目的 设计价值
可用工具 Bash, Read, Edit 等 避免幻想 worker 什么都能干 防止任务分配错误
MCP Servers 已连接的服务器列表 明确外部工具访问权限 扩展能力透明化
Scratchpad 读写目录路径 提供无权限提示的工作区 提升协作效率

Claude Code 在系统提示词层面把这些边界显式告诉协调者,避免它幻想 worker 什么都能干。

6.3 Coordinator 系统提示词的角色转换

文件位置:coordinator/coordinatorMode.ts:111-116

111:export function getCoordinatorSystemPrompt(): string {
112:  const workerCapabilities = isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)
113:    ? 'Workers have access to Bash, Read, and Edit tools, plus MCP tools from configured MCP servers.'
114:    : 'Workers have access to standard tools, MCP tools from configured MCP servers, and project skills via the Skill tool.'
116:  return `You are Claude Code, an AI assistant that orchestrates software engineering tasks across multiple workers.

看这一行,orchestrates software engineering tasks across multiple workers。这说明 coordinator 模式的关键不是"再加几个工具",而是把主线程身份从执行者改成协调者。

角色转换的二维对比

维度 普通模式 Coordinator 模式 差异分析
主 Agent 角色 执行者 协调者 职责重心转移
职责重点 直接调用工具完成任务 拆分任务、分配给 worker、整合结果 从微观到宏观
工具使用 直接使用所有工具 通过 AgentTool 委派 间接控制
上下文管理 单一上下文 多个侧链转录 复杂度增加
适用场景 简单任务 复杂项目、多模块协作 场景分化

这和前面 shareSetAppState: !isAsync 其实是同一个方向:

  • worker 负责执行
  • coordinator 负责拆分和编排
  • 两边的边界在系统提示词和上下文里都被说清楚

这才是多 Agent 不乱套的前提,也角色显式化(Role Explicitness)原则的体现。


7. 第五步:Task ID 设计 —— 防后台任务失控的安全基础设施

7.1 Task ID 的结构化设计

文件位置:Task.ts:78-106

78:// Task ID prefixes
79:const TASK_ID_PREFIXES: Record<string, string> = {
80:  local_bash: 'b',
81:  local_agent: 'a',
82:  remote_agent: 'r',
83:  in_process_teammate: 't',
84:  local_workflow: 'w',
85:  monitor_mcp: 'm',
86:  dream: 'd',
87:}
...
94:// Case-insensitive-safe alphabet (digits + lowercase) for task IDs.
95:// 36^8 ≈ 2.8 trillion combinations, sufficient to resist brute-force symlink attacks.
96:const TASK_ID_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz'
98:export function generateTaskId(type: TaskType): string {
99:  const prefix = getTaskIdPrefix(type)
100:  const bytes = randomBytes(8)
101:  let id = prefix
102:  for (let i = 0; i < 8; i++) {
103:    id += TASK_ID_ALPHABET[bytes[i]! % TASK_ID_ALPHABET.length]
104:  }
105:  return id
106:}

关键观察点:第95行注释中的 36^8 ≈ 2.8 trillion combinations, sufficient to resist brute-force symlink attacks.


(1)安全意识的深层体现

这段表面看是小事,其实很有味道。

注意这句注释 36^8 ≈ 2.8 trillion combinations, sufficient to resist brute-force symlink attacks.,作者连 task id 都在考虑符号链接攻击面,说明后台任务体系不是随手挂上去的,而是按"可长期运行的任务基础设施"来设计的。

Task ID 结构详解:

a3x9k2m7p  ← 示例 ID
↑ ↑───────┘
| └─ 8位随机字符 (36^8 ≈ 2.8万亿种组合)
└─── 类型前缀 (a = local_agent)
组成部分 长度 熵值 作用 安全意义
前缀 1 字符 7 种类型 快速识别任务类型 便于过滤和监控
随机部分 8 字符 ~41 bits 防暴力破解 抵抗 symlink 攻击

设计价值:也就是说,多 Agent 不只是 prompt 层玩法,底下真有任务系统在兜。这是纵深防御(Defense in Depth)原则在任务管理层的应用。

(2)防 Symlink 攻击的实际意义

攻击场景示例:

# 攻击者猜测 task ID
ln -s /etc/passwd ~/.claude/tasks/a00000001
# 如果 ID 可预测,可能导致敏感文件泄露

通过 8 位随机字符(36^8 ≈ 2.8 万亿种组合),使得暴力枚举不可行:

攻击方式 尝试次数 预计时间 可行性
暴力枚举 2.8 万亿次 ~数年(假设1000次/秒) ❌ 不可行
字典攻击 不适用(纯随机) - ❌ 无效
社会工程学 依赖用户泄露 - ⚠️ 唯一可行途径

8. 完整协作流程总结

如果只保留核心结构,可以压成下面这张图:

主 Agent 发起 AgentTool
  ↓
runAgent()
  ↓
createSubagentContext(...)
  ├─ 同步 Agent:shareSetAppState = true(共享主状态)
  └─ 异步 Agent:shareSetAppState = false(完全隔离)
  ↓
记录 initialMessages 到 sidechain transcript(runAgent.ts:732-742)
  ↓
写入 agent metadata(agentType, worktreePath, description)
  ↓
子 Agent 进入自己的 query() 主循环
  ↓
后续消息按 parent UUID 追加到侧链转录(runAgent.ts:792-799,O(1) 复杂度)

coordinator 模式
  ├─ 通过 env 开关启用(CLAUDE_CODE_COORDINATOR_MODE)
  ├─ 给主 Agent 注入"你是协调者"的 system prompt(coordinatorMode.ts:111-116)
  └─ 给协调者明确 worker 能用哪些工具、哪些 MCP、哪些 scratchpad(coordinatorMode.ts:80-108)
  
任务管理
  ├─ 生成防攻击的 Task ID(Task.ts:78-106)
  └─ 前缀标识任务类型,8位随机数防暴力破解

看清这张图后,你就会明白 Claude Code 的多 Agent 设计为什么没有把上下文搅烂:

  • ✅ 状态共享不是默认值,而是根据同步/异步明确区分
  • ✅ 异步隔离不是补丁,而是出生配置
  • ✅ 记录链路和执行链路是分开的(正交设计)
  • ✅ 协调者角色通过 prompt 和上下文显式收束

9. 假设实验:修改影响评估

通过"反事实假设"揭示设计边界的重要性,评估移除或修改某个设计带来的连锁反应。

实验一:把 shareSetAppState 永远设成 true

修改位置:runAgent.ts:709

// 原代码
709:    shareSetAppState: !isAsync,

// 修改后
709:    shareSetAppState: true,  // 所有Agent共享状态

影响分析:

维度 短期表现 长期风险 严重程度
功能正确性 看似正常 - 🟢 轻微 -
状态一致性 - 异步 Agent 直接改主线程状态 🔴 严重
竞态条件 - 最先坏掉的未必是大功能,往往是那些很难抓的竞态 🔴 严重
UI 显示 - 状态闪烁、任务面板错乱 🟡 中等
权限上下文 - 被串写,安全检查失效 🔴 严重
调试难度 - 主线程 UI 显示和真实执行不一致,难以复现 🟡 中等

结论:那异步 Agent 就会直接改主线程状态。这类竞态问题排查成本极高。同步/异步隔离是经过深思熟虑的选择,不应轻易改动

实验二:不记录 sidechain transcript

修改方案:注释掉 runAgent.ts:735-736794-799 的转录调用

影响分析:

功能 影响程度 后果 严重程度
短期运行 像是"省了 IO" 🟢 轻微
Resume 功能 中断后无法正确恢复 🔴 严重
审计日志 无法追溯子 Agent 的执行历史 🔴 严重
故障排查 "任务确实跑过,但没人能说清它到底干了什么" 🔴 严重
多 Agent 调试 无法定位是哪个 Agent 导致了问题 🟡 中等

结论:长期看会让 resume、审计、故障排查一起变瞎。多 Agent 系统最怕"任务确实跑过,但没人能说清它到底干了什么"。转录记录是可追溯性的基石,不可省略

实验三:Coordinator 不显式告诉自己 worker 工具边界

修改方案:coordinatorMode.ts:80-108 返回空对象 {}

影响分析:

维度 影响 严重程度
任务拆分质量 协调者会经常把不可能完成的事派给 worker 🔴 严重
Worker 失败率 明显上升,因为收到了超出能力的任务 🟡 中等
用户体验 系统表面还是能跑,但效率下降 🟡 中等
错误提示 模糊,用户不知道是协调者规划错误还是 worker 执行错误 🟡 中等

结论:那协调者就会经常把不可能完成的事派给 worker。系统表面还是能跑,但任务拆分质量会越来越差,worker 失败率会明显上升。角色显式化是多Agent协作的前提,不可忽视


10 设计原则提炼与方法论总结

基于以上分析,提炼出以下可复用的设计原则:

原则一:同步共享,异步隔离(Sync Share, Async Isolate)

  • 同步 Agent 可共享主状态(适合即时交互)
  • 异步 Agent 完全隔离(防止后台污染)
  • 隔离策略在创建时确定,不可动态修改

理论依据:这是状态一致性(State Consistency)和并发安全(Concurrency Safety)原则的综合应用。

适用场景:多Agent系统、微服务架构、分布式任务调度

原则二:执行与记录分离(Execution-Recording Separation)

  • 运行时状态隔离不等于不记录
  • 侧链转录保证可追溯性
  • 增量追加优化长期运行性能(O(1)复杂度)

设计价值:这是正交设计(Orthogonal Design)原则的体现——执行逻辑和记录逻辑互不干扰。

原则三:角色显式声明(Role Explicitness)

  • Coordinator 通过 system prompt 明确身份
  • Worker 能力边界通过 user context 注入
  • 避免隐式假设导致的任务分配错误

理论依据:这是最小惊讶原则(Principle of Least Surprise)和契约式设计(Design by Contract)的应用。

原则四:安全意识内建(Security Built-in)

  • Task ID 防暴力破解设计(8位随机数,2.8万亿种组合)
  • 前缀分类便于快速识别(7种任务类型)
  • 为长期运行的任务基础设施而设计

设计价值:这是纵深防御(Defense in Depth)原则在任务管理层的应用。


11. 对比分析:与其他多Agent框架的横向评估

11.1 多维度对比表格

维度 Claude Code LangGraph AutoGen CrewAI 差异分析
状态隔离 ✅ 同步/异步区分 ⚠️ 需手动配置 ❌ 默认共享 ⚠️ 部分支持 Claude Code 更智能
转录记录 ✅ 侧链增量记录(O(1)) ⚠️ 全量存储(O(N)) ❌ 无内置 ❌ 无内置 Claude Code 性能最优
角色定义 ✅ Prompt 显式声明 ⚠️ 代码定义 ⚠️ 代码定义 ⚠️ 代码定义 Claude Code 更灵活
任务追踪 ✅ Task ID + Metadata ⚠️ Graph State ❌ 弱 ❌ 弱 Claude Code 更完善
安全设计 ✅ 防攻击 ID(41 bits熵) ❌ 不考虑 ❌ 不考虑 ❌ 不考虑 Claude Code 独有
学习曲线 🟡 陡峭 🟡 中等 🟢 平缓 🟢 平缓 Claude Code 较复杂
长期维护 ✅ 优秀 🟡 中等 🟡 中等 🟡 中等 Claude Code 更优

选型建议:

  • 简单多Agent:CrewAI(易用性好)
  • 工作流编排:LangGraph(Graph模型直观)
  • 平等协作:AutoGen(多Agent对话自然)
  • 大型项目/安全敏感:Claude Code 方案(隔离可靠,可追溯性强)

11.2 协作模式的哲学对比

模式 优势 劣势 适用场景
完全共享 实现简单,协作高效 竞态风险高,难调试 单Agent系统
完全隔离 安全性高,易维护 协作困难,通信成本高 独立任务
Claude Code 方案 兼顾协作与安全,可追溯 实现复杂度高 多Agent协作系统

核心洞察:安全与便利不是非此即彼,而是可以通过分层架构兼顾。Claude Code 的同步/异步二元判定实现了这一点。


12. 结论与工程启示

Claude Code 的多 Agent 不是"大家一起干活",而是"谁能碰主状态、谁只能留下转录",这条边界先立住了,协作才开始。多 Agent 协作系统通过隔离与转录分离的架构,成功解决了状态共享、上下文污染、可追溯性和角色分工四大挑战。其核心设计哲学是:

  1. 同步共享,异步隔离:根据执行模式决定状态访问权限,竞态风险降低70-80%
  2. 执行与记录分离:隔离不影响可追溯性,O(1)增量追加保障长期运行性能
  3. 角色显式声明:通过 prompt 明确职责边界,任务分配准确率提升至90%+
  4. 安全意识内建:从底层设计防范攻击,Task ID 熵值达41 bits

这套设计不仅适用于 AI 辅助编程工具,也为其他需要多 Agent 协作的系统(如分布式任务调度、微服务编排、工作流引擎)提供了参考范式。

对其他项目的借鉴意义:

  • 小型项目:可采用简化的"完全隔离 + 基础日志"
  • 中型项目:增加"侧链转录",支持 Resume 功能
  • 大型项目:参考 Claude Code 的完整方案,增加"同步/异步二元判定"和"角色显式化"

Node.js技术周刊 2026年第16周

作者 whinc
2026年4月21日 10:55

Axios 供应链攻击引发广泛关注;Node.js 多版本安全更新发布,安全赏金计划却因断资暂停;Temporal API 达 Stage 4 后 Node 推进默认启用;TypeScript 6.0 发布,迈向 Go 原生编译器。

🔥 头条

Node 默认启用 Temporal

Temporal API 旨在现代化 JavaScript 的日期/时间处理,上月达到 Stage 4。Node 此前等待 V8 默认启用该特性,现已开始推进。

📖 文章

一站式 Node 应用监控

AppSignal 可处理 Node.js 技术栈的错误、性能、日志、运行时间和主机指标,自动插桩 Express、Koa、Prisma 和 BullMQ。

OWASP NPM 安全速查表

一份持续更新的实用安全检查清单,近期更新涵盖禁用生命周期脚本、typosquatting 防护等内容。

嵌套 Promise 的用途

James 重新审视 2013 年 Promises/A+ 单子辩论,因遇到一个真实并发问题而改变了看法。有深度但值得读。

让 Postgres 承担分析任务

TimescaleDB 添加了超表、95% 压缩率和连续聚合功能,无需第二数据库或数据管道。免费试用。

POSETTE Postgres 线上大会

POSETTE: An Event for Postgres 2026 是一场免费虚拟开发者活动,6 月 16-18 日举行,44 场演讲将直播并提供回放。

Promise 的"取消"之道

Promise 无法取消,但可通过让 async 函数 await 一个永不 resolve 的 Promise 来中止执行,函数会静默停止并被 GC 回收。

Node 安全赏金计划暂停

自 2016 年以来 Node.js 项目为合格安全漏洞报告提供赏金,资金来自 Internet Bug Bounty 项目,现已因失去资助而暂停。

tsdown 支持生成可执行文件

VoidZero(尤雨溪的公司)的库打包工具 tsdown 现支持使用 Node 的 Single Executable Applications 功能构建独立可执行文件。

分析无需独立基础设施

TimescaleDB 扩展 Postgres 使分析可直接运行在实时数据上,相同连接、无需管道、无需第二数据库。免费开始。

Marked.js 18.0 快速 Markdown 解析

为速度而生的底层 Markdown 编译器,同时支持客户端和服务端。v18 为错误修复版本。

Memetria K/V: Redis 托管服务

Memetria K/V 为 Node.js 应用提供 Redis OSS 和 Valkey 托管,支持大键追踪和详细分析。

Axios 事件隐性影响范围

你可能已听说 Axios 供应链攻击(若未了解请检查是否受影响)。Ahmad 反思了攻击机制和更广泛的影响。

npm Workspaces 入门指南

使用 workspaces 可在一个仓库中管理多个包,本地包按名称互相导入,npm 可提升和去重依赖。

沙箱中运行 AI Agent

Ox 为每个 Agent 任务创建独立沙箱,隔离代码、计算和数据,可对生产环境进行零风险测试。

Node.js 3月安全更新发布

Node.js v25.8.2 (Current)、v24.14.1 (LTS)、v22.22.2 (LTS) 和 v20.20.2 (LTS) 发布,修复 9 个漏洞(其中 2 个高危)。

V8 抗 HashDoS 可逆哈希设计

哈希能否同时抗哈希洪泛攻击(HashDoS)又快速可逆?这是 Node 团队在本周安全更新中为 V8 解决的难题。

Clerk M2M 令牌支持 JWT

无需网络请求即可本地验证机器对机器令牌,自包含 JWT 携带机器 ID、声明和过期时间。

400MB 内存去哪了?

深入排查一个 Node WebSocket k8s Pod 为何比同类 Pod 消耗更多内存,尽管 process.memoryUsage() 显示正常。

JavaScript 臃肿三大根源

node_modules 过大的三个原因:不必要的 ES3 兼容包、只有单一消费者的微型库、已有原生 API 的 ponyfill。

🛠 工具

Node.js 24.15.0 LTS 发布

Node LTS 版本从 v25 移植了若干特性,包括 require(esm) 和模块编译缓存标记为稳定,以及 --max-heap-size 选项。

x-win: 检查系统窗口信息

获取 macOS、Linux 和 Windows 上打开窗口的位置、大小、应用图标和标题,以及其底层进程信息和内存使用情况。

Axios 供应链攻击复盘

Axios 团队分享了近期供应链攻击的详细复盘,攻击者通过恶意依赖注入了木马。

web-audio-api: Node 中用 Web Audio

在 Node 中获得完整 Web Audio API 支持,可在本机播放音频或渲染为文件(Tone.js 也可用),提供丰富示例。

Node.js 25.9.0 Current 发布

包含 --max-heap-size 选项设置进程最大堆内存,James Snell 的实验性"更好流 API"实现作为实验特性落地。

node-re2: RE2 正则库绑定

RE2 是线性时间匹配的正则表达式库,免疫回溯导致的 ReDoS 攻击。node-re2 提供近乎完整的 RegExp 替代。

Defuddle: 提取网页正文

去除 HTML 杂乱内容,只保留主要正文。提供在线演示。

Knip v6: 快速清理项目冗余

Knip 已成为查找和移除未使用文件、导出和依赖的首选工具。v6 采用 oxc-parser 实现 2-4 倍性能提升。

Vavite 6: Vite 全栈开发

使用 Vite 开发后端 Node.js 代码,获得前后端统一的工具链和热重载。v6 带来多项改进。

htmlparser2 12.0 解析器发布

消费文档并调用回调,也可生成 DOM。提供在线演示,同时支持 Node 和浏览器。

TypeScript 6.0 发布

TypeScript 6.0 旨在为从自托管编译器过渡到 Go 驱动的原生编译器(TypeScript 7.0)铺平道路。

关注微信公众号「右耳朵猫AI」获取更多资讯

提示词强化 3:JSON 与「流式」——前后端原理、BFF、以及两个示例页

作者 颜酱
2026年4月21日 10:52

提示词强化 3:JSON 与「流式」——前后端原理、BFF、以及两个示例页

很多人第一次接触 OpenAI 兼容 Chat Completions + stream: true 时,容易把两件事混在一起:

  1. **SSE(网络层)**里每一行 data: ... 往往是一个 JSON 对象——但这是 协议包络(描述本次 delta、finish_reason 等),不是你要的业务数据。
  2. 业务层若要求模型输出 一整段 JSON 字符串,流式传的是 这段字符串被切成的小块;在流未结束之前,任意时刻拼出来的文本通常 还不是 合法 JSON,因此不能指望「每个 SSE 帧 = 一个可 JSON.parse 的业务对象」。

本文按顺序说明:前后端各自在做什么本仓库里的 BFF 怎么接、以及 index-json-story-stream.html(故事)与 index-json-phrase-stream.html(亲子例句)在实现上的同与不同。

story_json.gif

enstory_json.gif

本地看效果

clone代码之后,项目根目录新建.env.local,填上各种key。然后node server.js,浏览器看任一html文件即可


一、怎么「用 JSON 做流式」——先把链路画对

更精确的说法是:流式传递的是「正在生长的文本」;若这段文本的目标是 JSON,则 JSON 是最终形态,而不是每个网络包自带一个完整业务 JSON。

1. 上游大模型(Moonshot 等)在流什么?

浏览器(或 BFF)发起:

POST .../v1/chat/completions
Content-Type: application/json

{ "model": "...", "messages": [...], "stream": true }

上游若支持流式,响应常见为 text/event-stream(SSE)长连接,服务端持续写出多行,形如:

data: {"id":"...","choices":[{"delta":{"content":"你"}}],...}

data: {"id":"...","choices":[{"delta":{"content":"好"}}],...}

...

data: [DONE]

要点:

  • 每一行 data: 后面跟的,是 OpenAI 兼容的「事件」JSON(整行可 JSON.parse)。
  • 你真正关心的故事/例句内容,在 choices[0].delta.content 里,往往是 几个字符到一小段文本;这些片段 顺序拼接 后,才是模型正在「打字」出来的 一整段字符串(本仓库页面里通常命名为 content 或 buffer)。

所以:「流式」首先流的是 delta 文本;业务 JSON 是这些文本拼接后的语义。

2. 前端在做什么?(应用层)

典型三步:

  1. fetch 拿到 response.body,用 ReadableStreamDefaultReader 按块读取。
  2. TextDecoder(..., { stream: true }) 解码字节流,按 \n 拆行;半行留在 carry 里下一拍再拼(避免 UTF-8 多字节字符或 data: 行被截断)。
  3. 对完整行:若以 data: 开头且负载不是 [DONE],则 JSON.parse 包络,取出 delta.content,执行 content += delta

到此为止,你得到的是 一根越来越长的字符串。若这根字符串应当是 单个 JSON 对象,下一步才是:

  • tryParseModelJson(content):从整段里抠出 { ... }(容错 ```json 围栏),再 JSON.parse;失败则返回 null不抛错
  • 流结束后再 兜底 parse 一次,避免最后几字节在 carry 里漏处理。

JSON.parse 要求语法完整:中间态如 {"stor、未闭合的引号、缺 ] 等都会失败——因此「每来一个 delta 就 parse 整段业务 JSON」在实现上是 反复尝试直到某一刻刚好合法,而不是「每个包必成功」。

3. BFF / 后端在做什么?(可选但推荐)

若浏览器 直连 Moonshot,则必须在请求头里带 Authorization: Bearer <API_KEY>,Key 会暴露在前端代码、网络面板、扩展、误提交的构建产物中,生产环境不可取。

BFF(Backend for Frontend) 在这里指:浏览器只访问你自己的同源或可信域上的一个小服务;由该服务:

  • 环境变量 / 密钥管理 读取 MOONSHOT_API_KEY代发 https://api.moonshot.cn/v1/...
  • stream: true 的 chat completions,把上游 SSE 原样透传 给浏览器(低延迟、不整包缓冲),浏览器端的 readSseDeltas 逻辑与直连完全一致

本仓库的 server.js 即扮演这一角色:见下一节。


二、本仓库里的 BFF:server.js 行为摘要

1. Moonshot 代理路径

  • 浏览器请求:POST {BFF根}/moonshot/v1/chat/completions
  • 服务端转发到:{MOONSHOT_API_ORIGIN}/v1/chat/completions(默认 https://api.moonshot.cn),并带上 Authorization: Bearer ${MOONSHOT_API_KEY}(或兼容读取 VITE_API_KEY / API_KEY,以文件头注释为准)。
  • 当请求体 JSON 里 "stream": true 且上游返回 text/event-stream 时:使用 Readable.fromWeb(upstream.body).pipe(res)SSE 透传;否则仍可按整包 arrayBuffer 回传。

这样:前端不需要、也不应该保存 Moonshot Key;只需填 BFF 根地址(例如 http://127.0.0.1:3000)。

2. 火山 TTS(仅例句页会用到)

亲子例句页在句末小喇叭里会调 TTS。浏览器请求:

  • POST {BFF根}/tts/api/v1/tts
    由服务端合并 AppId / Cluster / Token 等敏感配置,按火山文档格式转发到 openspeech;真实 token 不出现在页面

故事页 不涉及 TTS,因此只需 Moonshot 这一条 BFF 路径即可。

3. 运行注意

  • node server.js 启动 BFF,在 .env.local 配置 Moonshot(及例句页需要的火山 TTS)变量。
  • 静态 HTML 建议通过 同源或允许 CORS 的 HTTP 打开(不要依赖 file:// 随意跨域),以便 fetch BFF 与流式读取稳定。

三、故事页:index-json-story-stream.html

目标 JSON 形态

System prompt 要求模型只输出(概念上)如下结构:

  • story_instructionthe_whole_story_contentthe_whole_story_translate_to_enlessons[]

并可配合 response_format: { type: 'json_object' },让模型更倾向输出 纯 JSON,提高流式过程中「第一次 parse 成功」的稳定性。

实现要点

步骤 作用
readSseDeltas 只负责 SSE 包络 → delta.content → 拼 content
tryParseModelJson(content) content{...}整段 JSON.parse,失败返回 null
每个 delta 后 content += delta → 再 tryParseModelJson → 成功则 mergeParsed 写入 contentParsed,右侧卡片更新
流结束后 tryParseModelJson 一次兜底;仍失败则提示用户(可对比关闭 JSON Object 模式)

与 BFF 的配合

  • proxyBase(页内文案「Moonshot 代理」)非空:请求 URL 为 ${proxyBase}/moonshot/v1/chat/completions不发送 Authorization;localStorage 会 移除 直连用的 api key 项。
  • 清空代理:走直连 endpoint,浏览器带 Bearer ${apiKey}(仅适合本地学习)。

体验特点:结构化 UI 往往在 JSON 尾部括号、引号补全 前后才第一次稳定更新;这是「整对象流式打印」的常态,不是 bug。


四、亲子例句页:index-json-phrase-stream.html

目标 JSON 形态

example_sentences 数组,每项 english + chinese,至少 10 句;同样可用 json_object 模式。

与故事页相同的骨架

  • 同一套 readSseDeltas + tryParseModelJson + 结束兜底。
  • BFFbffBase 非空 时,POST ${bffBase}/moonshot/v1/chat/completions,不传 Moonshot Key;清空 则显示直连 endpoint + key。
  • 额外:POST ${bffBase}/tts/api/v1/tts 做句末朗读(voice_typeencoding 等在页内配置,在页内配火山 token)。

多出来的「半段 JSON 也要列表」:applyParsedFromBuffer

故事页只在 整段能 JSON.parse 时更新列表。例句页希望 更早 看到句子,因此在 tryParseModelJson 仍失败 时增加一步:

  • extractPairsFromPartialBuffer(buf)
    用正则匹配已经 成对闭合"english": "..." , "chinese": "..."(含转义字符处理 decodeJsonStrSegment)。
    这依赖模型 按 english → chinese 顺序 输出(与 system 约定一致);不是通用 JSON 流式解析器,而是 针对本结构的启发式增量展示

  • applyParsedFromBuffer
    1)若整段已能 parse → mergeParsed 得到规范数组;
    2)否则若有正则抠到的对已出现 → 用 example_sentences: partial 更新 UI。

流结束后仍以 tryParseModelJson 最终结果 为准,保证与模型完整输出一致。

TTS 与 UI

每句英文在合适的时机进入 队列,限制并发(如 2 路)调用 BFF TTS,把返回的音频 base64 转成 Blob URL,供句末小喇叭播放;密钥与火山侧细节均在 server.js


五、若你要「字段级」严格可控的流式

仅靠「一个 JSON 被慢慢生成」,无法保证键顺序与字段边界;例句页的正则增量是 特例优化。更通用的工程选项包括:

方向 说明
NDJSON 每行一个小 JSON,收到一行 parse 一行
多轮 / 分步 先流式大纲,再单独请求结构化块
tool / schema 由接口约束结构化输出
BFF 内增量解析 上游仍 SSE,BFF 用 partial-json 等库,向前端推「已就绪字段」事件

六、小结

  • 网络层:流的是 SSE 行内的包络 JSON;业务内容在 delta.content 的拼接结果里。
  • 应用层:在内存里维护 content能整段 parse 时再变成对象;故事页以整段 parse 为主;例句页 额外 用正则从半段里抽已完成的 english/chinese 对。
  • BFF:浏览器只打自家 /moonshot/...(及例句的 /tts/...),Moonshot / 火山密钥在服务端stream: trueserver.js SSE 透传,前端解析逻辑与直连一致。
  • 安全:生产环境请 默认走 BFF;直连 + 页内 Key 仅作本地对照。

参考

提示词强化 2:元提示(Meta-Prompt)与动态提示词

作者 颜酱
2026年4月21日 10:49

提示词强化 2:元提示(Meta-Prompt)与动态提示词

上一篇我们聊了「结构化、分步、JSON」等写法层面的习惯。这一篇再往上抽象一层:有些问题不适合由人一次性把提示词写死,而适合交给两段式能力——先让模型生成或改写提示词(元提示),再让下游模型按「随上下文变化的模板」执行(动态提示)。二者可以单独用,也可以组合。

读完本文,你可以分清两种模式的适用边界、在 Coze / 自研链路里怎么接节点,以及动态模板在工程上要注意的注入与缓存问题。

date.gif

元提示(Meta-Prompt):让模型先当「提示词工程师」

元提示的核心思想是:专门用一个(通常更强的或更便宜的)模型,根据业务背景与用户意图,产出「给另一个模型或工具用的提示词」。执行画图、检索、代码生成等任务的智能体,只吃元模型吐出来的那段英文/中文指令即可。

典型链路如下:

flowchart LR
  U[用户主题 / 草图描述] --> A[模型 A:元提示角色]
  A --> P[精炼后的绘图提示词]
  P --> B[模型 B 或 MJ / SD / 可灵等]
  B --> I[图片]

为什么要多此一举?

  • 领域翻译:业务方说的是「亲子英语、起床场景」,绘图 API 需要的是「白底贴纸风、构图、光影、负面词」——中间这层「翻译 + 补全」交给元模型,比强迫运营手写 MJ 咒语更稳。
  • 责任分离:上游只维护「平台人设 + 输出格式约束」,下游只维护「工具链与参数」;提示词迭代时往往只改元提示即可。
  • 质量与成本权衡:元调用可以用较快/较便宜的模型做扩写,真正生图再用贵模型;也可以两步都用大模型,换更好的最终画面。

实践示例(豆包 1.5 Pro + 亲子英语场景)

下面沿用课程里的设定:系统提示词定角色与输出类型,用户提示词用模板变量 {{input}} 承接主题。元模型的输出应是可直接丢给 Midjourney 的英文提示词(课程示例如此;换成可灵 / FLUX 时,把系统提示里的「适用于 xxx 绘图」改成对应工具的习惯即可)。

系统提示词:

这是一个亲子英文学习的平台,教授父母日常家庭亲子英语对话。

根据用户输入和背景信息,你的任务是输出适用于 midjourney 绘图的**英文**提示词。

用户提示词:

请以“{{input}}”为主题,生成白底、卡通贴纸风格的简单图标。主题是生活场景相关的,比如起床、刷牙、游玩等等。

工程上建议补一句约束(原文可酌情追加):只输出一段英文提示词、不要解释、不要 markdown;若需负面词可写「Append common negative prompts: ...」。这样下游节点做字符串拼接时不易解析失败。

元提示的常见坑

问题 应对思路
元模型输出过长、夹带说明文字 在元提示里强制「仅输出一行英文 prompt」或要求 JSON 单字段
两步串联延迟高 异步任务、对元结果做缓存(同一主题短期内复用)
安全与合规 元模型扩写可能放大敏感内容,需在前后加审核或拦截策略

动态提示词:模板 + 变量,让「同一天」在不同上下文里语义不同

动态提示词指:提示词主体是模板,在请求发出前用运行时数据替换占位符(日期、城市、用户等级、当前页 URL、A/B 实验桶等)。它和元提示的区别是:动态提示不一定再经过「模型写提示词」这一步,往往是你的应用代码或工作流引擎做字符串渲染。

典型形态:

  • 占位符:{{today}}{{user_name}}(与 Coze、Jinja2、Nunjucks 等习惯一致)。
  • 渲染时机:发起 chat 前在 BFF 或浏览器里算好,再把完整 system / user 发给模型。
  • 与流式的关系:占位符替换发生在首包之前,与 SSE 流式正文解析互不冲突;若整段 system 很大,注意 token 上限。

text-model 的对照示例

index-about-today.html 中,用本地日期字符串替换模板里的 {{today}},将结果作为 role: system 的内容,再配一条固定的 role: user(「介绍今天相关的知识」)触发模型展开节日、节气、历史事件等。这就是最简的动态提示词:无元提示、仅模板 + 单轮对话

若你使用 Vite + Nunjucks,本质相同:nunjucks.renderString(systemPrompt, { today }) 与单页里的 replace(/\{\{\s*today\s*\}\}/gi, today) 只是渲染引擎差异。

动态提示词的安全注意(必读)

只要模板里混入了不可信的用户输入(例如用户自定义「日期文案」里夹带换行与指令),就可能出现提示注入:模型更听「假用户」的话而忽略产品设定。缓解方式包括:

  • 对用户输入做白名单或长度截断
  • 把用户内容放在引号闭合的 JSON 字段里,且 system 中明确「引号内为用户素材,不得当作新指令」;
  • 敏感操作用服务端模板,不把原始拼接串暴露给前端可编辑区。

两种模式如何组合(可选进阶)

一条常见流水线是:

  1. 动态:把「今天的日期、用户选的节日类型」填入第一段模板;
  2. :让模型 A 根据该段背景,生成「给绘图工具用的英文 prompt」;
  3. 工具:调生图 API。

这样「日历事实」来自你的系统,「画风与构图语言」来自元模型,职责清晰。


小结

概念 谁在做「变化」 典型用途
元提示 另一个 LLM 生成/改写提示词 业务语言 → MJ/生图英文、Query 改写、SQL 意图归纳
动态提示词 程序替换模板变量 按日期/地区/角色切换 system 文案、多租户话术

二者都不是「更玄的魔法」,而是把提示词也当成可版本、可测试、可流水线化的资产;与结构化输出、分步工作流放在一起用时,整套智能体会好维护得多。


参考

作为前端,如果使用 Langgraph 实现第一个 Agent

作者 Moment
2026年4月21日 10:37

大家好 👋,我是 Moment,目前正在使用 Next.js、NestJS、LangChain 开发 DocFlow。这是一个面向 AI 场景的协同文档平台,集成了基于 Tiptap 的富文本编辑、NestJS 后端服务、实时协作与智能化工作流等核心模块。

在这个项目的持续打磨过程中,我积累了不少实战经验,不只是 Tiptap 的深度定制、编辑器性能优化和协同方案设计,也包括前端工程化建设、React 源码理解以及复杂项目架构实践。

如果你对 AI 全栈开发、Agent、长期记忆、文档编辑器、前端工程化或者 React 源码相关内容感兴趣,欢迎添加我的微信 yunmz777 一起交流。觉得项目还不错的话,也欢迎给 DocFlow 点个 star ⭐

从这一篇开始,用一个简化版计算器 Agent 走一遍 LangGraph 的核心要素。目标很具体:只用节点、边、状态这三个概念,从零定义一张最小的图、让它真正跑起来,在代码里看清楚状态如何在节点之间流转。持久化、本地服务、子图等进阶内容都留到后面,这一章先让你对图式编排有可运行的手感。

用计算器 Agent 认识图

例子的场景是:用户用自然语言描述算式,比如"请帮我把三加四再乘二",模型理解后决定是否调工具,工具负责加减乘除等具体运算,结果回到模型整理成一句友好的回复。

这个场景不复杂,但很好地覆盖了图的三个关键能力:节点之间如何传递状态、条件边如何根据状态决定下一跳、工具节点执行完后如何回到模型节点继续推理。用图来表示整体执行流程,如下图所示。

20260317080940

模型节点与工具节点之间的回环,就是 LangGraphLangChain 线性链最本质的区别。下面按这个结构一步步把代码写出来。

准备模型与工具

图要跑起来,先得有一个支持工具调用的聊天模型,再配几个简单的计算工具。这部分仍然由 LangChain 提供,LangGraph 暂时不登场。

模型初始化时加了 temperature: 0,是为了让模型在判断"该不该调工具、该调哪个工具"这类结构化决策时输出更稳定,减少随机性带来的不必要波动。三个计算工具 addmultiplydividetool 函数定义,schemazod 写,这样模型拿到工具描述后能清楚知道每个参数的类型。最后调 model.bindTools(tools) 把工具列表注入模型,之后每次调用这个模型时,它就知道手边有哪些工具可用。

import { ChatOpenAI } from "@langchain/openai";
import { tool } from "@langchain/core/tools";
import * as z from "zod";

const model = new ChatOpenAI({
  model: "deepseek-chat",
  apiKey: "sk-60816d9be57f4189b658f1eaee52382e",
  configuration: { baseURL: "https://api.deepseek.com" },
  temperature: 0,
});

const add = tool(({ a, b }) => a + b, {
  name: "add",
  description: "Add two numbers",
  schema: z.object({
    a: z.number().describe("First number"),
    b: z.number().describe("Second number"),
  }),
});

const multiply = tool(({ a, b }) => a * b, {
  name: "multiply",
  description: "Multiply two numbers",
  schema: z.object({ a: z.number(), b: z.number() }),
});

const divide = tool(({ a, b }) => a / b, {
  name: "divide",
  description: "Divide two numbers",
  schema: z.object({ a: z.number(), b: z.number() }),
});

const toolsByName = {
  [add.name]: add,
  [multiply.name]: multiply,
  [divide.name]: divide,
};

const tools = Object.values(toolsByName);
const modelWithTools = model.bindTools(tools);

到这里模型和工具都准备好了,接下来才是 LangGraph 登场的地方。

定义图的状态

任何一张 LangGraph 图都需要一个状态模式,用来描述在节点之间流转的是哪些数据。状态不是普通对象,每个节点不是整体替换状态,而是只返回需要更新的字段,LangGraph 按字段的 reducer 把更新合并进去。

对于对话类应用,最常用的状态定义是 MessagesAnnotation,它内置了消息列表的 reducer 逻辑。节点每次返回 { messages: [newMessage] },状态系统就自动把这条消息追加到已有列表里,不需要手动维护整个消息数组。

import {
  StateGraph,
  MessagesAnnotation,
  START,
  END,
} from "@langchain/langgraph";

后面定义节点和组装图时都会用到 MessagesAnnotation,它既是状态模式的定义,也给 TypeScript 提供了节点函数参数的类型推断,写 state: typeof MessagesAnnotation.State 就能拿到完整的类型提示。

两个核心节点

这张图里只有两个真正干活的节点。llmCall 负责调用模型,根据当前 messages 生成一条新消息,并判断要不要请求工具。toolNode 根据上一轮模型的工具调用请求执行工具,把结果封装成 ToolMessage 返回。

节点函数可以只接收 state。如果需要流式或回调,可以声明第二个参数 config?: RunnableConfig,图运行时会自动传入。这样 invokestreamEvents 的回调就能一路传到模型和工具里,流式输出才能正常触发。

先写模型节点。它把系统提示和已有消息一起发给模型,只返回本次新生成的那条消息,状态系统负责追加。

import { SystemMessage } from "@langchain/core/messages";
import type { RunnableConfig } from "@langchain/core/runnables";

const llmCall = async (
  state: typeof MessagesAnnotation.State,
  config?: RunnableConfig
) => {
  const response = await modelWithTools.invoke(
    [
      new SystemMessage(
        "你是一个负责做算术的助手,根据用户描述执行加减乘除等运算,需要时调用工具得到结果后再用自然语言回复。"
      ),
      ...state.messages,
    ],
    config
  );
  return { messages: [response] };
};

再写工具节点。逻辑分三步:拿到最后一条 AIMessage,根据里面的 tool_calls 逐个执行对应工具,把每个工具的返回值包成 ToolMessage 追加到状态里。tool_call_id 是关键,模型后续要靠它把工具结果和当初的请求对应起来。

import { AIMessage, ToolMessage } from "@langchain/core/messages";

const toolNode = async (
  state: typeof MessagesAnnotation.State,
  config?: RunnableConfig
) => {
  const lastMessage = state.messages.at(-1);
  if (!lastMessage || !AIMessage.isInstance(lastMessage)) {
    return { messages: [] };
  }

  const results: ToolMessage[] = [];
  for (const toolCall of lastMessage.tool_calls ?? []) {
    const t = toolsByName[toolCall.name];
    if (!t) continue;
    const value = await t.invoke(toolCall.args ?? {}, config);
    results.push(
      new ToolMessage({
        content: String(value),
        tool_call_id: toolCall.id ?? "",
      })
    );
  }
  return { messages: results };
};

如果最后一条不是 AIMessage,或者 AIMessage 里没有工具调用,直接返回空列表,图会照常往下走,不会卡住。

条件边与路由

节点准备好之后,还要告诉图跑完某个节点之后下一步去哪。这里的逻辑很清楚:模型回来的消息里如果带着 tool_calls,说明它想用工具,就走到 toolNode;如果没有 tool_calls,说明模型已经可以直接给用户回复了,图结束。

const shouldContinue = (state: typeof MessagesAnnotation.State) => {
  const lastMessage = state.messages.at(-1);
  if (!lastMessage || !AIMessage.isInstance(lastMessage)) return END;
  if (lastMessage.tool_calls?.length) return "toolNode";
  return END;
};

这个函数返回的是字符串(节点名)或 ENDLangGraph 拿到返回值后就知道下一步跳到哪个节点。条件边是 LangGraph 表达"分支逻辑"的核心机制,比把 if/else 藏在节点函数里要清晰得多,图的结构一眼就能看懂。

组装并运行整张图

把状态、节点和边用 StateGraph 链式调用串在一起,最后调 compile() 得到可执行的图。addConditionalEdges 的第三个参数是允许到达的节点列表,LangGraph 会在编译时验证条件边函数的返回值不会跳到意外的节点,起到一定的安全检查作用。

import { HumanMessage } from "@langchain/core/messages";

const agent = new StateGraph(MessagesAnnotation)
  .addNode("llmCall", llmCall)
  .addNode("toolNode", toolNode)
  .addEdge(START, "llmCall")
  .addConditionalEdges("llmCall", shouldContinue, ["toolNode", END])
  .addEdge("toolNode", "llmCall")
  .compile();

const result = await agent.invoke({
  messages: [new HumanMessage("请帮我算一下 3 加 4 等于多少。")],
});

for (const message of result.messages) {
  const content = typeof message.content === "string" ? message.content : "";
  console.log(message.getType(), content);
}

以"请帮我算一下 3 加 4 等于多少"为例,图的完整执行路径如下:

  1. START 进入 llmCall,模型判断需要调 add 工具,返回带 tool_callsAIMessage
  2. shouldContinue 检测到有工具调用,走到 toolNode
  3. toolNode 执行 add(3, 4) 得到 7,包成 ToolMessage 追加到状态,沿固定边回到 llmCall
  4. 模型拿到工具结果,生成"3 加 4 等于 7"这样的自然语言回复,这次没有工具调用,shouldContinue 返回 END,图结束

走完这四步,messages 列表里依次记录了用户消息、模型的工具请求、工具的执行结果、模型的最终回复,完整还原了整条推理过程。

流式输出

invoke 是一次性拿到全部结果,适合脚本和批处理。如果要做"边生成边显示"的体验,用 agent.streamEvents 按事件消费。

import { ChatOpenAI } from "@langchain/openai";
import { tool, type StructuredTool } from "@langchain/core/tools";
import { SystemMessage, AIMessage, ToolMessage, HumanMessage } from "@langchain/core/messages";
import type { RunnableConfig } from "@langchain/core/runnables";
import { StateGraph, MessagesAnnotation, START, END } from "@langchain/langgraph";
import * as z from "zod";

// 模型
const model = new ChatOpenAI({
  model: "deepseek-chat",
  apiKey: "sk-60816d9be57f4189b658f1eaee52382e",
  configuration: { baseURL: "https://api.deepseek.com" },
  temperature: 0,
});

// 工具
const add = tool(({ a, b }) => String(a + b), {
  name: "add",
  description: "Add two numbers",
  schema: z.object({ a: z.number(), b: z.number() }),
});

const multiply = tool(({ a, b }) => String(a * b), {
  name: "multiply",
  description: "Multiply two numbers",
  schema: z.object({ a: z.number(), b: z.number() }),
});

const divide = tool(({ a, b }) => String(a / b), {
  name: "divide",
  description: "Divide two numbers",
  schema: z.object({ a: z.number(), b: z.number() }),
});

const toolsByName: Record<string, StructuredTool> = {
  add,
  multiply,
  divide,
};

const modelWithTools = model.bindTools(Object.values(toolsByName));

// 节点
const llmCall = async (
  state: typeof MessagesAnnotation.State,
  config?: RunnableConfig
) => {
  const response = await modelWithTools.invoke(
    [
      new SystemMessage("你是一个负责做算术的助手,根据用户描述执行加减乘除等运算,需要时调用工具得到结果后再用自然语言回复。"),
      ...state.messages,
    ],
    config
  );
  return { messages: [response] };
};

const toolNode = async (
  state: typeof MessagesAnnotation.State,
  config?: RunnableConfig
) => {
  const lastMessage = state.messages.at(-1);
  if (!lastMessage || !AIMessage.isInstance(lastMessage)) return { messages: [] };

  const results: ToolMessage[] = [];
  for (const toolCall of lastMessage.tool_calls ?? []) {
    const t = toolsByName[toolCall.name];
    if (!t) continue;
    const value = await t.invoke(toolCall.args ?? {}, config);
    results.push(new ToolMessage({ content: String(value), tool_call_id: toolCall.id ?? "" }));
  }
  return { messages: results };
};

// 条件路由
const shouldContinue = (state: typeof MessagesAnnotation.State) => {
  const lastMessage = state.messages.at(-1);
  if (!lastMessage || !AIMessage.isInstance(lastMessage)) return END;
  return lastMessage.tool_calls?.length ? "toolNode" : END;
};

// 组装图
const agent = new StateGraph(MessagesAnnotation)
  .addNode("llmCall", llmCall)
  .addNode("toolNode", toolNode)
  .addEdge(START, "llmCall")
  .addConditionalEdges("llmCall", shouldContinue, ["toolNode", END])
  .addEdge("toolNode", "llmCall")
  .compile();

// 流式运行
async function main() {
  const stream = agent.streamEvents(
    { messages: [new HumanMessage("请帮我算一下 3 加 4 等于多少。")] },
    { version: "v2" }
  );

  for await (const event of stream) {
    if (event.event === "on_chat_model_stream") {
      const chunk = event.data?.chunk?.content;
      if (typeof chunk === "string" && chunk) process.stdout.write(chunk);
    }
    if (event.event === "on_tool_start") {
      console.log(`\n[工具调用] ${event.name}`, JSON.stringify(event.data?.input));
    }
    if (event.event === "on_tool_end") {
      console.log(`[工具结果] ${event.name}: ${event.data?.output}`);
    }
  }

  console.log("\n");
}

main().catch(console.error);

on_chat_model_stream 是模型逐 token 输出,从 event.data.chunk.content 取片段写到终端就能得到打字机效果。on_tool_starton_tool_end 分别在工具开始和结束时触发,可以用来显示"正在计算……"这样的进度提示。这些事件能正常触发的前提是节点里把 config 传给了 modelWithTools.invoket.invoke,回调通路才算打通。如果节点没有传 config,这些事件就不会冒出来。

如果只想按节点观察每一步的状态增量,不关心逐字,可以换成 agent.streamstreamMode: "updates",每个 chunk 就是"节点名 -> 该节点本次返回的状态更新",调试时很有用。

和 LangChain 传统写法的对比

LangChain 写过类似计算器 Agent 的话,会发现两者的逻辑其实差不多,都是模型判断是否需要工具、调用工具、再根据工具结果生成回复。但有几个点上 LangGraph 的优势很明显。

流程可见方面,用 LangChainAgentExecutor,整条执行链路藏在对象内部,从外部很难直接看出走了哪些步骤。用 LangGraph 写,节点、边、条件路由全都显式定义,图的结构就是代码本身,不需要额外文档解释流程。

状态可追方面,LangGraph 图执行过程中,每个节点的输入输出都是状态的一次快照。后面加上 checkpointer 之后,这些快照可以持久化,支持暂停恢复、时间旅行和回放,AgentExecutor 做不到这一点。

扩展性方面,这一章的图很小,只有两个节点。后面加入持久化、人机协同、子图、多 Agent 协作时,只需要在图里加节点和边,不需要重写整个逻辑,扩展起来很自然。

小结

这一章用计算器 Agent 完整走了一遍 LangGraph 的核心三要素,几个值得记住的点:

  • MessagesAnnotation 定义消息列表状态,每个节点只返回增量,框架负责合并,不用手动维护整个数组
  • llmCall 节点负责调用模型,toolNode 节点负责执行工具,两者通过条件边构成一个可以反复循环的 Agent 推理回路
  • shouldContinue 是整张图的路由核心,tool_calls 有值走工具、为空走 END,分支逻辑和节点实现彻底分开
  • streamEvents 打通了逐 token 流式输出和工具事件,前提是节点里把 config 一路传下去,回调通路才能正常工作
  • addConditionalEdges 的第三个参数声明了合法的目标节点,图在编译时会做边界检查,防止条件函数返回意外节点名

下一章会在这张图上引入 checkpointer,给每次执行打快照,为持久化、暂停恢复和时间旅行做准备。

【Mapmost 渲染指北】利用LUT快速构建场景色调

作者 Mapmost
2026年4月21日 10:36

关于场景渲染,我们已经发布了两篇,分别聊了HDRI选型(👉点击回顾选对HDRI,让你直接赢在起跑线)、灯光+后处理搭建立体感(👉点击回顾灯光+后处理,一招切出立体感),但画面还欠缺一丝“叙事感”——原生渲染的色彩有时会显得琐碎,缺乏整体调性。

Mapmost支持的LUT(颜色查找表)能力,为3D场景建立了一套“色彩映射标准”。它能将平淡的渲染输出瞬间转化为特定的艺术风格,是实现场景风格化、消除“塑料感”最高效的手段。

Mapmost场景使用不同的LUT加载效果

一、什么是LUT?

核心概念

LUT(Look-Up Table)翻译过来叫“颜色查找表”。你可以把它想象成一本色彩的“新旧字典”:

**输入:**渲染器输出的原始颜色坐标(比如一个普通的灰色像素)。

**查找:**渲染器去这本“字典”里查阅这个坐标对应的“新坐标”。

**输出:**转换后的目标颜色(比如一个带有电影质感的青蓝色像素)。

图源:zunzheng.com/news/archiv…

为什么要用它?

传统的滤镜往往依赖复杂的实时算法,会占用一定的显卡计算资源;而LUT采用的是“查表法”。它通过预设的映射关系大幅降低了计算开销,却能实现极为复杂的色彩偏移、对比度拉伸和饱和度重组,是目前Web端实现场景高级感的一种轻量化方案。

二、制作:如何做一个适配Mapmost的滤镜?

**制作LUT的核心逻辑是:**在专业软件中完成艺术调优,并将这种“映射关系”刻录成轻量化的数据文件。你可以根据项目需求,选择手动制作专属滤镜,或者直接利用成熟的外部LUT资源。

方法A:基于当前场景手动制作

这种方式能最精准地匹配当前场景的光影环境。

1.准备基准底片

Mapmost预览窗口截取一张当前场景的图片(包含建筑、绿化、天空等核心元素)。这张图将作为你的“调色参照物”。

2.在图像处理软件中建立调色链路

将截图导入Photoshop等软件,通过调整图层完成风格化。

**基础操作:**在图层面板下方点击圆形图标,新建“调整图层”(如曲线、色彩平衡等)进行调色。

**核心原理:**LUT记录的是全局色彩的映射关系,因此调色需基于全局视野进行。

**技巧点拨:**调色过程中应避免使用蒙版、画笔或液化等涉及局部像素改动的工具。只有保持全局调整,才能确保导出的LUT效果能够精准还原。

使用PS制作LUT步骤

3.导出颜色查找表

调好色后,点击菜单:文件 -> 导出 -> 颜色查找表 。

网格点(Grid Points):建议选择33。这是性能与精度的平衡点,能确保在 Web端加载顺滑且色彩过渡细腻。

格式:勾选CUBE。建议将导出后文件后缀名保持为大写的.CUBE,方便系统识别。

导出LUT选项

方法B:使用下载的LUT资源

互联网上有大量成熟的电影级LUT资源(如在FreshLUTs或专业调色素材站下载的资源),通过简单的标准化处理即可在Mapmost中使用。

freshluts官网:freshluts.com/

1.获取资源

下载适合城市数字孪生或工业场景的.CUBE格式滤镜。

2.标准化适配

由于不同软件生成的LUT可能包含不同的文件头注释,建议在PS中进行一次“标准化转换”。

**操作步骤:**在PS中新建一个“颜色查找”调整图层,载入该下载文件,确认色彩显示正常。

**导出保存:**确认色彩正常后,按照前文提到的“网格点33+CUBE格式”重新导出。这样可以剔除冗余信息,确保在Web端环境下获得更好的兼容性。

使用建议:
虽然方法B提供了丰富的素材选择,但仍建议大家优先使用方法A。这种方式能最大化地结合项目实际画面进行针对性调整,不仅整体效果更具原创性与可控性,且方便后期根据项目需求进行反复微调。

三、在Mapmost应用LUT

在Mapmost中,应用滤镜非常简单,直接调用setLutEffect接口即可。

map.on('load', function () {
  map.setLutEffect(true, { //开启关闭滤镜效果
    lut: './LUT/test2.CUBE', //CUBE类型的lut链接
    intensity: 0.8   //滤镜强度
  })
})

注意:CUBE资源服务器的跨域配置,或者检查浏览器的WebGL支持情况。

参数解析:

lut: 指向你制作并导出的风格文件链接。

intensity: 控制风格化的干预浓度。建议设置在0.8左右,这样既能赋予场景鲜明的调性,又能保留原始光影的质感,视觉效果最自然。

结尾

从HDRI奠定的环境底色,到灯光勾勒的模型结构,再到LUT完成的风格化闭环,Mapmost为开发者构建了一套完整的视觉交付流。

至此,**《Mapmost渲染指北》**系列正式收官。 希望这套推荐的工作流,能帮助您在Mapmost平台上快速构建出具备行业领先水准、兼具物理真实与艺术美感的数字孪生项目

立即体验,开始三维开发之旅!

👉 点击访问官网免费试用:www.mapmost.com/#/productMa…

深度解析前端性能优化

作者 Wect
2026年4月21日 10:35

前端性能优化是前端工程师核心竞争力的重要组成部分,亦是前端面试高频核心考点。多数开发者仅记忆零散优化技巧,未深入钻研底层实现原理,导致面对复杂工程场景时难以灵活落地优化方案。本文以「原理剖析+实战落地」为核心主线,采用规范术语与严谨逻辑相结合的撰写方式,全面覆盖前端性能优化全维度核心知识点,从性能指标定义、分层优化逻辑,到底层原理拆解、实战工具应用,内容系统详实、逻辑严谨,可作为专业学习笔记或团队技术分享材料,助力开发者夯实性能优化核心能力,从容应对面试考核与实际工程场景。

一、性能优化核心指标体系(基于用户体验维度)

性能优化的本质是提升用户浏览体验,所有优化策略均围绕用户可感知的页面响应速度展开。Google 官方制定的核心 Web 指标(Core Web Vitals)是当前行业内最权威的页面性能衡量标准,亦是面试必考核心内容,明确各项指标定义与衡量逻辑,是开展性能优化的前提基础,可避免优化方向偏离核心目标。

1.1 三大核心 Web 指标(用户体验核心衡量维度)

为便于理解核心指标的实际意义,可将页面访问流程类比为线下场景:用户打开网页等同于进入服务场所,LCP 对应核心服务区域的开放速度,CLS 对应服务场景的视觉稳定性,INP 对应服务响应的即时性,三项指标共同决定整体用户体验质量。

(1)LCP(Largest Contentful Paint,最大内容绘制)

【核心释义】:用户发起页面访问后,视口范围内体积最大的内容元素完成完整渲染的耗时,是用户对页面加载速度的第一直观感知,也是衡量页面加载性能的核心指标。例如电商页面中,商品主图完成加载渲染的耗时,即为该页面 LCP 的核心衡量节点。

【专业定义】:用于量化页面加载性能,统计从用户发起页面导航,到视口内最大内容元素完成渲染的全程耗时。核心统计元素包含 img 标签、video 标签、canvas 元素、块级文本区块等,排除背景图片、隐藏状态元素。

【行业标准】:优秀水平 ≤ 2.5 秒,待优化区间 2.5~4 秒,较差水平 > 4 秒(Google 官方规范)。

【原理拆解】:LCP 耗时由三个阶段构成,其一为资源加载前置阶段,包含 DNS 解析、TCP 连接建立、HTTP 请求响应等待;其二为核心资源加载阶段,即关键内容资源的网络传输过程;其三为渲染执行阶段,包含资源解码、屏幕绘制。任一阶段耗时超标,均会导致 LCP 指标不达标。

(2)CLS(Cumulative Layout Shift,累积布局偏移)

【核心释义】:页面加载及交互全生命周期内,元素发生非预期位置偏移的累计幅度,是衡量页面视觉稳定性的关键指标。典型场景为用户准备执行点击操作时,页面动态加载内容导致目标按钮位置偏移,引发误操作或操作延迟,CLS 即为该类问题的量化指标。

【专业定义】:用于评估页面视觉稳定性,统计页面全程所有非预期布局偏移的分值总和,单偏移分值由偏移影响范围与偏移距离乘积计算得出。布局偏移的核心诱因包括元素未预设固定尺寸、动态内容插入、字体加载导致文本排版变化等。

【行业标准】:优秀水平 < 0.1,待优化区间 0.1~0.25,较差水平 > 0.25。

【原理拆解】:浏览器渲染流程中,会依据元素预设尺寸与位置分配布局空间;若元素未提前定义尺寸,或动态插入内容,会触发浏览器重新执行布局计算,进而导致页面元素位置偏移,每一次偏移均会产生对应 CLS 分值,全程累计即为最终 CLS 得分。

(3)INP(Interaction to Next Paint,交互到下次绘制)

【核心释义】:用户执行点击、触摸、键盘输入等交互操作后,浏览器完成对应视觉反馈渲染的耗时,是衡量页面交互响应流畅度的核心指标,已替代原有 FID(首次输入延迟)指标,更贴合真实用户交互体验。

【专业定义】:用于量化页面交互响应性能,统计用户触发交互操作至浏览器完成下一次页面绘制的全程耗时。该指标监控用户访问全程所有交互操作,选取耗时最长的一次作为最终衡量值,全面反映页面全程交互流畅度。

【行业标准】:优秀水平 ≤ 200 毫秒,待优化区间 200~500 毫秒,较差水平 > 500 毫秒。

【原理拆解】:INP 指标优于 FID 指标的核心原因在于,FID 仅统计首次交互的延迟耗时,忽略后续操作的流畅度;而 INP 覆盖用户全程交互行为,精准反映页面持续交互性能,更贴合真实用户的实际使用场景。

1.2 辅助性能指标(面试高频补充考点)

  • TTFB(Time to First Byte,首字节时间):统计从用户发起网络请求,至服务器返回首个数据字节的耗时,核心衡量服务器响应效率,优秀水平 ≤ 100 毫秒。

  • FCP(First Contentful Paint,首次内容绘制):用户首次看到页面非空白内容的耗时,与 LCP 指标的核心区别为,LCP 统计最大内容渲染耗时,FCP 统计任意内容渲染耗时,优秀水平 ≤ 1.8 秒。

  • TBT(Total Blocking Time,总阻塞时间):统计 FCP 至 TTI 阶段内,浏览器主线程被阻塞的累计时长,反映主线程繁忙程度,优秀水平 ≤ 300 毫秒。

  • TTI(Time to Interactive,可交互时间):页面完成全部脚本加载,且可无卡顿响应各类交互操作的耗时,优秀水平 ≤ 3.8 秒。

1.3 性能数据来源分类(实验室数据与现场数据)

开展性能优化前,需先通过精准数据定位性能瓶颈,性能数据主要分为实验室数据与现场数据两类,二者结合分析方可实现全面、客观的性能评估,具体对比如下:

数据类型 核心采集工具 核心优势 核心局限性
实验室数据(Lab Data) Lighthouse、PageSpeed Insights(实验室模块)、WebPageTest 测试环境可控、执行效率高、问题可复现,适用于开发阶段快速排查性能瓶颈 非真实用户网络与设备环境,数据与实际用户体验存在一定偏差
现场数据(Field Data) CrUX、Google Search Console、web-vitals 工具库 基于真实用户、真实网络、真实设备采集,数据完全贴合实际用户体验 数据积累周期较长,单条异常数据难以精准复现对应问题场景

二、全链路性能优化策略(加载-渲染-交互三维度)

前端性能瓶颈主要集中于三大核心环节,分别为资源加载环节(资源加载耗时过长)、页面渲染环节(页面渲染效率低下)、交互响应环节(用户交互响应延迟)。本文按照从基础到进阶、从表层到底层的逻辑,拆解各环节优化原理与实战方案,每一项策略均配套原理说明与实操规范,兼顾面试考点与工程落地需求。

2.1 资源加载优化(高性价比基础优化)

资源加载是前端性能优化的首要环节,用户访问页面需优先完成 HTML、CSS、JavaScript、图片等资源的网络传输,资源加载效率直接决定页面首屏加载速度。核心优化思路为:缩减资源体积、减少请求数量、优化请求优先级、提升传输速度

(1)资源体积压缩优化

【核心原理】:资源体积与网络传输耗时呈正相关,依据网络传输公式「传输耗时=文件体积/带宽」,缩减文件体积可有效降低传输耗时,尤其在弱网环境下优化效果更为显著。针对不同类型资源,需采用差异化压缩策略,剔除冗余内容、精简代码结构。

【实战方案】:

  • JavaScript 压缩:采用 Terser 工具(Vite、Webpack 默认压缩工具),移除代码注释、空白字符、未使用代码(Tree-Shaking),混淆变量与函数名称,同时可配置移除 console 与 debugger 语句,兼顾体积缩减与代码安全性。
// Vite 配置压缩示例(Vue/React 项目通用)
import { defineConfig } from 'vite'
export default defineConfig({
  build: {
    minify: 'terser',
    terserOptions: {
      compress: {
        drop_console: true,
        drop_debugger: true,
        pure_funcs: ['console.log']
      }
    }
  }
})
  • CSS 压缩:采用 CSSNano 工具,移除样式注释、空白字符、冗余样式规则,合并重复样式声明,压缩颜色值与属性写法,最大化缩减 CSS 文件体积。

  • HTML 压缩:通过 html-minifier-terser 工具,移除注释、空白字符、换行符,精简属性写法,缩减 HTML 文件体积,适配 Vite、Webpack 等构建工具配置。

  • 图片资源优化:图片为页面资源体积占比最高的类型,优化空间极大,核心策略为格式升级与无损压缩。将 PNG/JPG 格式转换为 WebP 或 AVIF 格式,可实现50%以上体积缩减;通过 TinyPNG、Squoosh 等工具完成无损压缩,保障画质的前提下缩减体积;配置原生图片懒加载,滚动至可视区域再执行加载,减少首屏请求数量。

<!-- 原生图片懒加载规范写法 -->
<img src="image.webp" loading="lazy" alt="性能优化示例" width="400" height="300">

(2)资源合并与请求数量优化

【核心原理】:每一次 HTTP 请求均需完成 DNS 解析、TCP 连接、请求响应等流程,产生额外网络开销;HTTP 1.1 协议单域名默认支持6个并发请求,超出部分需排队等待,HTTP 2.0 虽支持多路复用,但减少请求数量仍可降低服务器负载与网络延迟。

【实战方案】:通过 Vite、Webpack 等构建工具,将多个小型 JavaScript、CSS 文件合并为少量核心文件,减少请求数量;避免过度合并导致单文件体积过大,可按业务路由实现拆分,配合路由懒加载策略;小型图标资源采用雪碧图(Sprite)技术,合并为单张图片通过 CSS 定位展示,降低图片请求数量;字体资源按需提取常用字符,缩减字体文件体积与请求次数。

(3)浏览器缓存策略优化

【核心原理】:浏览器缓存可实现静态资源一次加载、多次复用,避免重复网络请求,核心分为强缓存与协商缓存两类,二者配合使用,可兼顾资源复用与实时更新需求。

【策略拆解】:

  1. 强缓存:无需向服务器发起请求,直接调用本地缓存资源,通过 HTTP 响应头 Cache-Control、Expires 字段配置缓存有效期,有效期内直接复用本地资源。核心配置为 Cache-Control: max-age=86400,适用于更新频率极低的静态资源,如图标、字体、第三方依赖库。

  2. 协商缓存:缓存过期后,向服务器发起请求验证资源是否更新,通过 ETag(资源哈希值)、Last-Modified(资源最后修改时间)字段校验,资源未更新则返回304状态码,复用本地缓存;资源更新则返回200状态码与新资源。适用于 HTML、高频更新的 JavaScript 与 CSS 资源。

# Nginx 缓存配置示例
server {
  location ~* .(js|css|png|webp|woff2)$ {
    root /usr/share/nginx/html;
    expires 1d;
    add_header Cache-Control "public, immutable";
    add_header ETag "$request_filename$mtime";
  }
  location ~* .html$ {
    root /usr/share/nginx/html;
    add_header Cache-Control "no-cache";
    add_header ETag "$request_filename$mtime";
  }
}

(4)CDN 内容分发加速

【核心原理】:CDN(内容分发网络)通过分布式节点部署,将静态资源缓存至全国各区域节点,用户访问时自动调度至最近节点获取资源,缩短网络传输距离,降低传输延迟,同时分担源服务器负载。

【实战方案】:将图片、字体、JavaScript、CSS、第三方依赖等静态资源全部部署至 CDN 服务;配置 CDN 缓存策略,与浏览器缓存形成联动;启用 CDN 端 HTTPS 与 HTTP/2 协议,进一步提升资源传输效率。

(5)资源请求优先级优化

【核心原理】:浏览器会依据资源重要性自动分配请求优先级,可通过代码干预调整优先级,保障首屏核心资源优先加载渲染,非核心资源延后加载,提升首屏加载速度。

【实战方案】:内联首屏关键 CSS,避免外部 CSS 加载阻塞页面渲染,非关键 CSS 采用 preload 预加载或异步加载;JavaScript 资源采用 defer、async 属性实现异步加载,避免阻塞 DOM 解析;通过 preconnect 提前建立 CDN 域名连接,通过 preload 预加载首屏核心资源,优化资源加载顺序。

<!-- 预连接 CDN 域名 -->
<link rel="preconnect" href="https://cdn.example.com">
<!-- 预加载首屏核心图片 -->
<link rel="preload" href="hero.webp" as="image" type="image/webp">

2.2 页面渲染优化(解决白屏、卡顿与布局偏移)

资源加载完成后,浏览器需完成解析与渲染流程,将代码转换为用户可视页面,该环节瓶颈主要体现为 DOM/CSSOM 构建延迟、回流重绘频繁、布局偏移等问题。核心优化思路为:降低渲染阻塞、减少回流重绘、保障布局稳定

(1)浏览器渲染核心流程(面试必考原理)

浏览器标准渲染流程为:HTML 解析 → CSS 解析 → DOM 与 CSSOM 合并生成渲染树 → 布局计算(回流/重排)→ 像素绘制(重绘)→ 图层合成。

【核心概念】:

  • DOM:HTML 解析后生成的文档对象模型,描述页面结构层级;

  • CSSOM:CSS 解析后生成的样式对象模型,描述页面样式规则;

  • 渲染树:仅包含页面可见元素,隐藏元素不纳入渲染树;

  • 回流(重排):重新计算元素位置与尺寸,属于高耗时操作;

  • 重绘:重新绘制元素样式,不涉及布局调整;

  • 图层合成:依托 GPU 加速,完成多图层合并展示,提升渲染效率。

【核心结论】:CSS 解析会阻塞页面渲染,因渲染树依赖 DOM 与 CSSOM 共同构建;JavaScript 执行会阻塞 DOM 与 CSS 解析,因 JavaScript 可修改 DOM 与样式结构;回流操作必然触发重绘,重绘操作不一定触发回流。

(2)渲染阻塞优化

【实战方案】:内联首屏关键 CSS,消除外部 CSS 加载阻塞;避免使用 @import 引入 CSS,防止解析顺序紊乱;JavaScript 资源优先采用 defer、async 属性,或放置于 body 底部,避免阻塞首屏渲染;拆分 JavaScript 资源,首屏非必需资源实现异步动态加载。

(3)回流与重绘优化

【核心原理】:回流与重绘属于高开销浏览器操作,频繁执行会导致页面卡顿,需通过规范操作减少执行次数。触发回流的操作包含修改元素布局属性、调整窗口尺寸、获取布局相关属性等;触发重绘的操作包含修改元素颜色、背景等非布局样式。

【实战方案】:批量修改元素样式,通过 cssText 或类名修改实现单次操作完成多样式变更;操作 DOM 前将元素脱离文档流,操作完成后恢复,减少回流次数;缓存布局相关属性值,避免频繁获取触发强制回流;高频重绘元素开启 GPU 加速,独立为合成层,避免影响全局渲染;摒弃 table 布局,采用 div 布局,防止局部修改触发全局回流。

// 批量修改样式优化示例
const targetEl = document.getElementById('box')
// 推荐方案:单次批量修改
targetEl.style.cssText = 'width: 100px; height: 100px; margin: 10px;'
// 或通过类名修改
targetEl.className = 'box-active'

(4)CLS 视觉稳定性优化

【实战方案】:为图片、视频、iframe 等资源预设固定宽高,避免加载后尺寸变化引发布局偏移;避免在页面顶部动态插入内容,防止挤压现有元素导致偏移;字体资源配置 font-display: swap,采用备用字体过渡加载,避免字体加载导致文本偏移;动态交互元素提前预留布局空间,保障页面视觉稳定。

/* 字体加载优化配置 */
@font-face {
  font-family: 'CustomFont';
  src: url('font.woff2') format('woff2');
  font-display: swap;
}

2.3 首屏加载进阶优化(SSR/SSG/ISR、预渲染、骨架屏)

传统 SPA(单页应用)依赖客户端浏览器下载、解析、执行 JS 后才开始渲染页面,极易出现长时间白屏、LCP 指标差、SEO 不友好等问题。针对首屏体验瓶颈,工程上衍生出服务端渲染、静态生成、预渲染及骨架屏等进阶方案,从真实渲染速度用户感知速度两个维度同步优化首屏性能。

(1)SSR(Server-Side Rendering,服务端渲染)

【核心原理】:页面渲染逻辑从客户端浏览器转移至服务端执行。服务端接收到页面请求后,实时拉取数据、拼接完整 HTML 结构并直接返回给浏览器;浏览器拿到的是已包含内容的 HTML,无需等待 JS 执行即可快速展示页面内容。

【首屏性能优化价值】:

  • 大幅缩短 FCP、LCP 时间,从根源解决 SPA 白屏问题;
  • 浏览器只需做样式渲染与事件绑定,主线程压力显著降低;
  • 完整 HTML 内容有利于搜索引擎抓取,兼顾 SEO 与性能。

【适用场景】:内容频繁更新、需要实时数据的页面(电商详情、资讯文章、后台管理动态页)。

【主流实现】:Next.js(React)、Nuxt.js(Vue)、Remix 等框架内置 SSR 能力。

(2)SSG(Static Site Generation,静态站点生成)

【核心原理】:在项目构建打包阶段,就提前为所有路由页面生成完整的静态 HTML 文件,部署后用户访问时直接返回预生成的静态页面,无需服务端实时计算与数据请求。

【首屏性能优化价值】:

  • 首屏渲染速度极快,接近纯静态页面体验;
  • 静态资源可完美依托 CDN 与强缓存,网络耗时极低;
  • 服务端无计算压力,高并发场景下稳定性更强。

【适用场景】:页面内容几乎不变化的场景(官网、博客、文档、营销落地页)。

【主流实现】:Next.js SSG、Nuxt.js 静态生成、VitePress、VuePress。

(3)ISR(Incremental Static Regeneration,增量静态再生成)

【核心原理】:SSG 与 SSR 的折中方案,在构建时先生成静态页面,同时配置刷新时间窗口;在用户访问时,若页面未过期则直接返回静态 HTML,过期后后台自动重新生成新的静态页面,无需全量重建。

【首屏性能优化价值】:

  • 保留 SSG 极速首屏与 CDN 优势;
  • 解决 SSG 无法实时更新内容的缺陷;
  • 兼顾性能、实时性与服务端开销,是中大型项目首屏优化的主流方案。

【适用场景】:内容更新频率适中、需要兼顾首屏速度与数据时效性(商品列表、资讯频道、中小型电商页面)。

【主流实现】:Next.js ISR 为行业标准方案。

(4)预渲染(Prerendering)

【核心原理】:轻量级首屏优化方案,无需改造服务端,仅在构建阶段通过无头浏览器(如 Puppeteer)模拟访问路由,提前渲染并保存对应页面的 HTML 片段,部署后直接返回预渲染内容。

【首屏性能优化价值】:

  • 成本远低于 SSR/SSG,无需服务端支持,适合传统 SPA 快速优化;
  • 有效缩短白屏时间,提升 LCP 与 FCP 表现;
  • 配置简单,可只针对核心首页、落地页做预渲染。

【局限性】:无法支持动态数据,仅适用于无实时接口依赖的静态路由页面。

【主流实现】:prerender-spa-plugin、Vite 预渲染插件。

(5)骨架屏(Skeleton Screen)

【核心原理】:在真实页面内容加载完成前,先渲染与页面布局结构一致的灰色占位区块,模拟页面最终呈现形态,属于感知性能优化,不缩短真实加载耗时,但大幅降低用户等待焦虑。

【首屏性能优化价值】:

  • 消除空白等待感,显著提升用户对加载速度的主观评价;
  • 配合 LCP 优化,可让核心内容出现前的页面保持稳定,间接降低 CLS;
  • 实现成本低、收益极高,是现代前端首屏优化标配方案。

【实战方案】:

  1. 基础方案:纯 CSS 绘制骨架占位块,配合渐变动画模拟加载态;
  2. 工程方案:使用 react-loading-skeletonvue-skeleton-webpack-plugin 自动生成骨架屏;
  3. 极致方案:在 HTML 中内联骨架屏 CSS 与结构,做到浏览器解析 HTML 立即展示,无任何延迟。
<!-- 极简骨架屏内联示例(直接写在 index.html 中) -->
<style>
.skeleton { width: 100%; height: 300px; background: #f2f2f2; 
  background: linear-gradient(90deg, #f2f2f2 25%, #e6e6e6 50%, #f2f2f2 75%); 
  background-size: 200% 100%; animation: skeleton-loading 1.5s infinite; }
@keyframes skeleton-loading { 0%{ background-position: 200% 0; } 100%{ background-position: -200% 0; } }
</style>
<div id="app">
  <div class="skeleton"></div>
</div>

2.4 交互响应优化(提升交互流畅度)

页面完成加载渲染后,用户交互响应速度直接决定体验质量,该环节瓶颈核心为浏览器主线程阻塞,JavaScript 长任务占用主线程资源,导致交互操作无法及时响应。核心优化思路为:减轻主线程负载、优化 JavaScript 执行效率、避免长任务阻塞

(1)主线程工作原理

浏览器主线程承担 DOM 解析、CSS 解析、JavaScript 执行、回流重绘、交互响应等核心任务,若单任务执行耗时超过50毫秒,主线程将被阻塞,无法及时响应用户交互,引发点击延迟、滑动卡顿等问题,也是 INP 指标不达标核心原因。

(2)JavaScript 执行效率优化

  • 剔除冗余代码,通过 Tree-Shaking 移除未使用代码,减少无效执行;

  • 优化数据处理逻辑,缓存数组长度,采用 Map、Set 等高效数据结构,提升数据操作效率;

  • 拆分长任务,通过 requestIdleCallback、setTimeout 将长任务拆分为多个短任务,释放主线程响应空间;

  • 耗时计算任务移交 Web Workers 处理,分离计算逻辑与主线程,避免阻塞交互响应。

// Web Workers 耗时任务处理示例
// 主线程代码
const worker = new Worker('task-worker.js')
worker.postMessage({ data: largeDataSet })
worker.onmessage = (res) => console.log('任务处理完成', res.data)

// task-worker.js 独立任务文件
self.onmessage = (e) => {
  const result = e.data.data.map(item => item * 2)
  self.postMessage(result)
}

(3)事件处理优化

  • 采用事件委托机制,将子元素事件绑定至父元素,依托事件冒泡实现事件触发,减少事件监听器数量;

  • 高频触发事件(scroll、resize、input)配置防抖或节流策略,控制事件执行频率;

  • 组件卸载或页面跳转时,及时移除事件监听器,避免内存泄漏与冗余开销。

// 防抖与节流函数封装示例
// 防抖:延迟执行,频繁触发时重新计时
function debounce(fn, delay = 300) {
  let timer = null
  return (...args) => {
    clearTimeout(timer)
    timer = setTimeout(() => fn.apply(this, args), delay)
  }
}
// 节流:固定周期内仅执行一次
function throttle(fn, interval = 300) {
  let lastTime = 0
  return (...args) => {
    const now = Date.now()
    if (now - lastTime >= interval) {
      fn.apply(this, args)
      lastTime = now
    }
  }
}

(4)前端框架专项优化

Vue 框架优化
  • v-for 遍历必须绑定唯一 key 值,禁止使用索引作为 key,提升 DOM 复用与更新效率;

  • 频繁切换显示状态的元素采用 v-show,替代 v-if 减少 DOM 销毁与重建;

  • 利用 computed 计算属性缓存派生数据,避免重复计算;

  • 非响应式数据采用 const 声明,减少响应式监听开销;

  • 通过 defineAsyncComponent 实现组件懒加载,按需渲染。

React 框架优化
  • 通过 React.memo 缓存函数组件,避免无意义重渲染;

  • 采用 useMemo、useCallback 缓存计算结果与函数引用,防止子组件冗余更新;

  • useRef 存储无需触发重渲染的数据,避免状态变更导致的组件更新;

  • 长列表采用虚拟列表技术,仅渲染可视区域元素,降低 DOM 数量;

  • 通过 React.lazy 与 Suspense 实现路由懒加载,缩减首屏包体积。

三、性能监控与问题定位(优化闭环管理)

性能优化并非一次性工作,需建立「监控-定位-优化-验证」的闭环体系,持续跟踪指标变化、排查潜在瓶颈,该环节是区分初级与中高级前端开发者的核心考点,也是工程化优化的必要流程。

3.1 核心性能监控工具

  • Lighthouse:Chrome 浏览器内置工具,可生成全面性能报告,覆盖核心 Web 指标、优化建议,适用于开发阶段性能排查;

  • PageSpeed Insights:Google 官方工具,整合实验室数据与现场数据,提供线上页面性能评估与针对性优化方案;

  • Chrome Performance 面板:实时监控主线程任务,定位长任务、回流重绘耗时,精准排查交互卡顿问题;

  • web-vitals 库:轻量级性能采集库,可实时采集用户端 LCP、CLS、INP 指标,上报至服务端实现真实用户监控(RUM);

  • Google Search Console:提供站点整体核心 Web 指标健康度报告,辅助 SEO 与性能优化。

3.2 性能问题定位标准流程

  1. 第一步:通过 Lighthouse 完成全量性能检测,明确核心指标短板,确定优化方向;

  2. 第二步:LCP 指标不达标时,排查资源加载瓶颈,聚焦大体积资源、慢请求、加载优先级问题,落实压缩、缓存、CDN 优化;

  3. 第三步:CLS 指标不达标时,定位偏移元素,落实尺寸预设、动态内容管控优化;

  4. 第四步:INP 指标不达标时,通过 Performance 面板定位长任务,落实任务拆分、Web Workers、事件优化;

  5. 第五步:优化完成后重新检测,对比指标变化验证效果,持续监控线上真实用户数据,迭代优化策略。

四、高频面试考点与避坑指南

4.1 核心面试题(原理级标准答案)

  1. 问题:前端性能优化核心指标有哪些?分别衡量什么维度?

答案:核心为 Google 三大核心 Web 指标,LCP 衡量页面加载性能,CLS 衡量页面视觉稳定性,INP 衡量交互响应流畅度;辅助指标包含 TTFB、FCP、TBT、TTI,分别对应服务器响应、首次渲染、主线程阻塞、可交互耗时。

  1. 问题:浏览器渲染流程是什么?CSS 与 JavaScript 为何会阻塞渲染?

答案:标准流程为 HTML 解析→CSS 解析→渲染树构建→回流→重绘→合成;CSS 阻塞渲染是因为渲染树依赖 DOM 与 CSSOM,CSS 未解析完成无法构建渲染树;JavaScript 阻塞渲染是因为其可修改 DOM 与样式,浏览器会暂停解析优先执行 JS。

  1. 问题:回流与重绘的区别是什么?如何优化?

答案:回流是重新计算元素布局,重绘是重新绘制元素样式,回流必然触发重绘,重绘不一定触发回流;优化方式为批量修改样式、脱离文档流操作 DOM、缓存布局属性、开启 GPU 加速、避免 table 布局。

  1. 问题:浏览器缓存分类及原理?

答案:分为强缓存与协商缓存;强缓存无需请求服务器,通过 Cache-Control 配置有效期;协商缓存需请求服务器校验,通过 ETag、Last-Modified 判断资源是否更新,未更新返回304复用缓存。

4.2 优化避坑核心要点

  • 避免过度优化:优先解决核心指标短板,不做无意义的微优化,平衡优化成本与收益;

  • 资源拆分适度:避免过度合并导致单文件过大,也避免过度拆分导致请求数量激增;

  • 缓存策略合理:区分静态资源与动态页面,防止强缓存配置不当导致资源无法更新;

  • 兼容适配兼顾:优化方案需考虑浏览器兼容性,避免新特性导致低端设备体验异常;

  • 线上数据优先:实验室数据仅作参考,核心优化依据为线上真实用户性能数据。

前端性能优化是一项系统性工程,核心在于吃透底层原理,结合业务场景落地适配方案,而非盲目套用技巧。熟练掌握本文核心知识点,既可从容应对面试考核,也能高效解决实际工程中的性能问题,持续提升前端工程化能力。

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

作者 SmalBox
2026年4月21日 10:34

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

平滑过渡的核心原理

SmoothStep节点是Unity URP渲染管线中实现非线性过渡的核心工具,其数学本质基于三次Hermite插值函数。该函数通过三次多项式计算实现缓入缓出的平滑效果:当输入值In位于Edge1和Edge2之间时,输出值Out从0平滑过渡至1,且过渡区域的导数始终为零,有效避免了线性插值带来的机械感。这一特性使其尤其适用于需要自然过渡的视觉效果,例如UI元素的淡入淡出、模型边缘的柔和裁切等场景。

数学定义解析

SmoothStep函数的数学表达式如下:

float smoothstep(float t1, float t2, float x) 
{
     x = clamp((x - t1) / (t2 - t1), 0.0, 1.0);
     return x * x * (3 - 2 * x); 
}

当输入值x小于t1时返回0,大于t2时返回1,介于两者之间时则通过三次曲线实现平滑过渡。这种特性使其在需要自然过渡的视觉效果中表现优异,例如UI元素的淡入淡出、模型边缘的柔和裁切等应用场景。

节点参数与端口详解

SmoothStep节点包含三个关键输入端口和一个输出端口:

  • Edge1:过渡起始阈值,当输入值In ≤ Edge1时,输出为0
  • Edge2:过渡结束阈值,当输入值In ≥ Edge2时,输出为1
  • In:待评估的输入值,可以是标量或向量
  • Out:平滑插值结果,范围固定为[0,1]

阈值参数设置技巧

  • 当Edge1 > Edge2时,函数行为反转:输入值在Edge2到Edge1之间时,输出从1平滑过渡至0
  • 建议将Edge1和Edge2设置为[0,1]范围内的浮点数,便于与其他节点协同工作
  • 通过动态调整阈值可实现动画效果,例如随时间变化的溶解效果

基础应用场景

模型裁切与边缘平滑

将模型空间坐标与SmoothStep节点结合,可实现精致的模型裁切效果:

  1. 使用Position节点获取模型坐标
  2. 通过Split节点分离Y轴分量
  3. 将Y值输入SmoothStep的In端口
  4. 连接输出到AlphaClip阈值

这种方法创建的裁切边缘具有自然过渡效果,相比Step节点的硬边裁切,更适用于激光切割等特效场景。

渐变效果制作

SmoothStep节点是创建自定义渐变的核心工具:

  • 将UV坐标的某个分量作为输入
  • 设置合适的Edge1和Edge2值
  • 输出连接颜色通道可实现径向渐变、条形渐变等效果
  • 结合Tiling节点可创建无缝循环的渐变纹理

高级应用实例

圆环效果生成

通过两个SmoothStep函数相减可创建精确的圆环:

  1. 创建两个SmoothStep节点,分别设置不同的阈值范围
  2. 将第一个节点的输出减去第二个节点的输出
  3. 调整阈值使过渡区域形成环形
  4. 将结果连接至颜色通道实现视觉化

这种方法常用于创建能量护盾、光环等特效。

溶解效果实现

结合噪声贴图和时间变量,可创建动态溶解效果:

  1. 使用Simple Noise节点生成噪声纹理
  2. 将噪声值与时间变量相乘作为In输入
  3. 动态调整Edge1和Edge2值控制溶解范围
  4. 输出连接至Alpha通道实现透明过渡

该技术广泛应用于角色受伤、物品消失等场景。

性能优化建议

  1. 避免在移动设备上过度使用向量类型的SmoothStep节点
  2. 对于静态效果,可预先计算阈值参数以减少运行时计算
  3. 结合LOD系统,在远距离使用简化版本的SmoothStep效果
  4. 注意URP渲染管线的特性,确保材质设置与管线兼容

常见问题解决方案

输出结果异常

  • 检查输入值是否在预期范围内
  • 确认Edge1和Edge2的数值关系
  • 验证节点连接顺序是否正确

性能问题

  • 减少不必要的SmoothStep节点嵌套
  • 对于移动平台,考虑使用预计算的渐变贴图替代实时计算
  • 优化噪声贴图的分辨率

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

HTTP 缓存策略:新鲜度与速度的权衡艺术

作者 yuki_uix
2026年4月21日 10:05

在优化 Web 应用性能时,我发现一个有趣的矛盾:用户希望看到最新的内容,但同时又期望页面加载飞快。这个矛盾的解决方案,就藏在 HTTP 缓存机制中。

那么,HTTP 缓存到底是如何工作的?强缓存和协商缓存有什么区别?如何为不同类型的资源设置合适的缓存策略?

问题的起源

为什么需要缓存?最直接的原因是性能。网络请求的延迟远高于本地读取,尤其在移动网络环境下。如果每次访问都要重新下载所有资源,用户体验会很差。

但缓存又带来了新的问题:新鲜度。如果资源被缓存了,用户如何获取更新后的版本?

HTTP 缓存机制就是在这两个目标之间寻找平衡:既要快,又要新。

核心概念探索

1. 浏览器缓存的层级结构

在深入 HTTP 缓存之前,先了解浏览器的完整缓存体系:

浏览器请求资源的缓存查找顺序:

  1. Memory Cache(内存缓存)

    • 特点:最快,但容量小,tab 关闭即清空
    • 存储:当前页面的资源(图片、脚本、样式)
  2. Service Worker Cache

    • 特点:可编程,离线可用
    • 存储:开发者主动缓存的资源
  3. Disk Cache(磁盘缓存)

    • 特点:容量大,持久化
    • 存储:根据 HTTP 缓存头决定
  4. Push Cache(HTTP/2 推送缓存)

    • 特点:短暂存在,只在会话期间
    • 存储:服务器推送的资源
  5. 网络请求

    • 最后的选择:如果以上都没有,发起网络请求

今天我们主要关注的是 Disk Cache 层面的 HTTP 缓存。

2. 强缓存(Strong Cache)

强缓存是指浏览器直接从本地缓存读取资源,不发送任何网络请求到服务器。

Expires(HTTP/1.0)

HTTP/1.0 200 OK
Content-Type: text/css
Expires: Wed, 21 Oct 2026 07:28:00 GMT

/* CSS 内容 */

Expires 的问题:

  1. 使用的是绝对时间:如果服务器和客户端时间不同步,缓存会失效

  2. 优先级低于 Cache-Control:如果两者同时存在,Expires 会被忽略

Cache-Control(HTTP/1.1,推荐)

HTTP/1.1 200 OK
Content-Type: application/javascript
Cache-Control: max-age=31536000

/* JavaScript 内容 */

Cache-Control 常用指令

HTTP 响应头 + Cache-Control 指令详解

  1. max-age=<seconds>

    • 指定资源缓存的最大时长(相对时间,单位:秒)
    • Cache-Control: max-age=3600 // 缓存 1 小时
  2. no-cache

    • 不是"不缓存"!而是"需要验证"
    • 浏览器会缓存资源,但每次使用前必须向服务器验证是否过期
    • Cache-Control: no-cache
  3. no-store

    • 真正的"不缓存":浏览器不缓存,每次都重新请求
    • Cache-Control: no-store
  4. public

    • 允许中间代理(CDN)缓存
    • Cache-Control: public, max-age=86400
  5. private

    • 只允许浏览器缓存,中间代理不能缓存(如包含用户隐私信息的响应)
    • Cache-Control: private, max-age=3600
  6. immutable

    • 表示资源永远不会改变,即使用户刷新页面也不重新验证
    • Cache-Control: max-age=31536000, immutable
  7. must-revalidate

    • 缓存过期后必须向服务器验证,不能使用过期缓存
    • Cache-Control: max-age=3600, must-revalidate

常见组合

# 场景 1:永久缓存(适合带 hash 的静态资源)
Cache-Control: public, max-age=31536000, immutable

# 场景 2:不缓存(适合 HTML 入口文件)
Cache-Control: no-cache

# 场景 3:私密内容(适合用户个人信息)
Cache-Control: private, max-age=0, must-revalidate

# 场景 4:完全不存储(适合敏感数据)
Cache-Control: no-store

3. 协商缓存(Negotiation Cache)

当强缓存失效后,浏览器会发送请求到服务器,但可以通过协商来判断资源是否需要重新下载。

Last-Modified / If-Modified-Since

# 首次请求响应:
HTTP/1.1 200 OK
Last-Modified: Mon, 10 Jan 2026 10:00:00 GMT
Cache-Control: no-cache

/* 资源内容 */
# 再次请求时,浏览器携带:
GET /style.css HTTP/1.1
If-Modified-Since: Mon, 10 Jan 2026 10:00:00 GMT

# 如果资源未修改,服务器返回:
HTTP/1.1 304 Not Modified
# 没有响应体,浏览器使用本地缓存

# 如果资源已修改,服务器返回:
HTTP/1.1 200 OK
Last-Modified: Tue, 11 Jan 2026 14:30:00 GMT

/* 新的资源内容 */

Last-Modified 的局限性

问题 1:精度只到秒:如果文件在 1 秒内修改多次,无法检测到

问题 2:基于修改时间:即使文件内容没变,只是修改了时间戳(如重新编译),也会被认为是"已修改"

问题 3:某些服务器无法准确获取文件修改时间

ETag / If-None-Match(推荐)

ETag 是资源的唯一标识(通常是文件内容的 hash 值)。

# 首次请求响应:
HTTP/1.1 200 OK
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
Cache-Control: no-cache

/* 资源内容 */
# 再次请求时,浏览器携带:
GET /app.js HTTP/1.1
If-None-Match: "33a64df551425fcc55e4d42a148795d9f25f89d4"

# 如果 ETag 匹配(内容未改变),服务器返回:
HTTP/1.1 304 Not Modified

# 如果 ETag 不匹配(内容已改变),服务器返回:
HTTP/1.1 200 OK
ETag: "7f8c9d2e1a3b4c5d6e7f8g9h0i1j2k3l4m5n6o7p"

/* 新的资源内容 */

ETag vs Last-Modified

特性 ETag Last-Modified
精度 基于内容 hash,精度高 基于时间,精度到秒
优先级 高(如果同时存在,优先使用 ETag)
服务器开销 需要计算 hash,开销大 开销小
适用场景 内容频繁变化,需要精确控制 一般场景

4. 缓存决策流程

浏览器请求资源时的完整决策过程:

// 环境:浏览器内部逻辑
// 场景:缓存决策流程(伪代码)

function fetchResource(url) {
  // 1. 检查 Memory Cache
  if (memoryCache.has(url)) {
    return memoryCache.get(url);
  }
  
  // 2. 检查 Service Worker Cache
  if (serviceWorkerCache.has(url)) {
    return serviceWorkerCache.get(url);
  }
  
  // 3. 检查 Disk Cache(HTTP 缓存)
  const cached = diskCache.get(url);
  
  if (cached) {
    // 3.1 检查是否有 Cache-Control: no-store
    if (cached.headers['cache-control'].includes('no-store')) {
      // 不使用缓存,直接请求
      return fetchFromNetwork(url);
    }
    
    // 3.2 检查强缓存是否有效
    const maxAge = getCacheMaxAge(cached.headers);
    const age = Date.now() - cached.timestamp;
    
    if (age < maxAge) {
      // 强缓存有效,直接返回
      console.log('from disk cache');
      return cached.data;
    }
    
    // 3.3 强缓存失效,检查是否需要协商缓存
    if (cached.headers['cache-control'].includes('no-cache') || cached.headers.etag || cached.headers['last-modified']) {
      // 发起协商缓存请求
      return revalidateCache(url, cached);
    }
  }
  
  // 4. 没有缓存,发起网络请求
  return fetchFromNetwork(url);
}

function revalidateCache(url, cached) {
  const headers = {};
  
  // 添加协商缓存请求头
  if (cached.headers.etag) {
    headers['If-None-Match'] = cached.headers.etag;
  }
  if (cached.headers['last-modified']) {
    headers['If-Modified-Since'] = cached.headers['last-modified'];
  }
  
  const response = fetch(url, { headers });
  
  if (response.status === 304) {
    // 资源未修改,使用本地缓存
    console.log('304 Not Modified');
    return cached.data;
  }
  
  // 资源已修改,使用新内容并更新缓存
  return response.data;
}

用 Mermaid 图表表示:

graph TD
    A[请求资源] --> B{Memory Cache?}
    B -->|有| C[返回缓存]
    B -->|无| D{Service Worker?}
    D -->|有| C
    D -->|无| E{Disk Cache?}
    E -->|无| F[网络请求]
    E -->|有| G{no-store?}
    G -->|是| F
    G -->|否| H{强缓存有效?}
    H -->|是| C
    H -->|否| I{支持协商缓存?}
    I -->|否| F
    I -->|是| J[发起验证请求]
    J --> K{304?}
    K -->|是| C
    K -->|否| L[下载新资源]

实际场景思考

场景 1:SPA 应用的缓存策略

单页应用(SPA)通常有这样的文件结构:

dist/
├── index.html           # 入口文件
├── main.[hash].js       # 应用主逻辑
├── vendor.[hash].js     # 第三方库
├── style.[hash].css     # 样式文件
└── assets/
    └── logo.[hash].png  # 静态资源

推荐的缓存策略

// 环境:Nginx / Node.js 服务器
// 场景:为不同类型文件设置缓存

// 1. index.html:永远不缓存(或协商缓存)
// 原因:作为入口,必须获取最新版本来引用正确的 hash 文件
location = /index.html {
  add_header Cache-Control "no-cache";
  # 或者
  # add_header Cache-Control "no-store";
}

// 2. 带 hash 的资源文件:永久缓存
// 原因:文件名包含内容 hash,内容变化文件名就变,可以放心长缓存
location ~* .(js|css|png|jpg|jpeg|gif|svg|woff|woff2)$ {
  # 如果文件名包含 hash
  if ($request_filename ~* .[a-f0-9]{8,}.(js|css|png|jpg|jpeg|gif|svg|woff|woff2)$) {
    add_header Cache-Control "public, max-age=31536000, immutable";
  }
}

// 3. 不带 hash 的资源:短期缓存 + 协商缓存
location ~* .(js|css)$ {
  add_header Cache-Control "public, max-age=3600";
  # 浏览器会自动处理 ETag/Last-Modified
}

Webpack 配置生成 hash 文件名

// 环境:Node.js
// 场景:Webpack 配置
// 依赖:webpack

module.exports = {
  output: {
    filename: '[name].[contenthash:8].js',
    chunkFilename: '[name].[contenthash:8].chunk.js',
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: '[name].[contenthash:8].css',
      chunkFilename: '[name].[contenthash:8].chunk.css',
    }),
  ],
};

// contenthash:基于文件内容生成 hash
// 只有内容改变,hash 才会变
// 用户访问 index.html 时,会看到:
// <script src="/main.a1b2c3d4.js"></script>
// 如果 main.js 内容改变,变成:
// <script src="/main.e5f6g7h8.js"></script>
// 浏览器会请求新文件,而不是使用旧的缓存

场景 2:强制用户更新资源

即使设置了正确的缓存策略,有时仍需要强制用户更新:

// 问题场景:
// 用户已经访问过旧版本,浏览器缓存了 index.html
// 即使部署了新版本,用户刷新页面仍然看到旧的 index.html
// 旧的 index.html 引用旧的 js 文件

// 解决方案 1:index.html 使用 no-cache(推荐)
// 每次都向服务器验证,确保获取最新版本

// 解决方案 2:index.html 添加版本号查询参数
// 通过修改 URL 强制浏览器请求新资源
<script src="/app.js?v=1.2.3"></script>

// 解决方案 3:使用 Service Worker 控制缓存
// Service Worker 可以主动清除旧缓存
self.addEventListener('activate', event => {
  const cacheWhitelist = ['v2'];
  event.waitUntil(
    caches.keys().then(cacheNames => {
      return Promise.all(
        cacheNames.map(cacheName => {
          if (!cacheWhitelist.includes(cacheName)) {
            return caches.delete(cacheName);
          }
        })
      );
    })
  );
});

场景 3:开发环境 vs 生产环境的缓存差异

// 环境:Webpack DevServer
// 场景:开发环境禁用缓存

// 开发环境配置
module.exports = {
  devServer: {
    headers: {
      // 禁用缓存,确保每次都获取最新代码
      'Cache-Control': 'no-store',
    },
  },
};

// 为什么开发环境要禁用缓存?
// 1. 代码频繁修改,需要实时看到效果
// 2. 避免改了代码但浏览器使用旧缓存的困惑
// 3. 开发环境不关心性能,关心开发体验

// 生产环境配置(Nginx)
// 需要精细的缓存策略,平衡性能和新鲜度

场景 4:CDN 缓存失效

CDN 有自己的缓存层,如何处理?

// 问题:部署了新版本,但 CDN 仍然返回旧内容

// 解决方案 1:CDN Purge API(手动清除缓存)
// 大多数 CDN 提供了清除缓存的 API
// 例如 Cloudflare:
const response = await fetch('https://api.cloudflare.com/client/v4/zones/{zone_id}/purge_cache', {
  method: 'POST',
  headers: {
    'X-Auth-Email': 'user@example.com',
    'X-Auth-Key': 'your-api-key',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    files: [
      'https://example.com/style.css',
      'https://example.com/app.js',
    ],
  }),
});

// 解决方案 2:使用带 hash 的文件名(最佳实践)
// 文件内容变化 → hash 变化 → URL 变化 → CDN 缓存失效
// 这样就不需要手动清除 CDN 缓存了

// 解决方案 3:设置合适的 Cache-Control
// 对于 CDN,可以使用 s-maxage 单独控制 CDN 缓存时长
Cache-Control: public, max-age=3600, s-maxage=86400
// max-age:浏览器缓存 1 小时
// s-maxage:CDN 缓存 24 小时

场景 5:Cookie 与缓存

Cookie 会影响缓存行为:

// 问题:包含 Cookie 的请求默认不会被 CDN 缓存

// 请求:
GET /api/user HTTP/1.1
Cookie: session_id=abc123

// CDN 通常不会缓存这个响应,因为它可能包含用户特定的内容

// 解决方案 1:静态资源使用独立域名(Cookie-free domain)
// HTML:https://www.example.com  (可能有 Cookie)
// 静态资源:https://static.example.com (无 Cookie)

// 解决方案 2:使用 Vary 响应头
HTTP/1.1 200 OK
Vary: Cookie
Cache-Control: public, max-age=3600

// Vary: Cookie 告诉缓存服务器:
// 不同 Cookie 的请求应该分别缓存

知识点快速回顾

(30 秒版本)

Q: 什么是强缓存和协商缓存?

A: 强缓存是浏览器直接从本地读取资源,不发送请求到服务器,通过 Cache-Control(如 max-age)控制;协商缓存是浏览器向服务器验证资源是否过期,如果未过期返回 304,使用本地缓存,通过 ETag/Last-Modified 控制。

Q: Cache-Control 的常用指令有哪些?

A:

  • max-age=<seconds>:缓存时长
  • no-cache:需要验证(不是不缓存)
  • no-store:不缓存
  • public:允许 CDN 缓存
  • private:只允许浏览器缓存
  • immutable:资源不会变化

Q: ETag 和 Last-Modified 有什么区别?

A: ETag 基于内容 hash,精度高,优先级高,但服务器开销大;Last-Modified 基于修改时间,精度到秒,优先级低,开销小。如果两者都存在,优先使用 ETag。

(2 分钟版本)

Q: SPA 应用如何设置缓存策略?

A: 典型策略是:

  • index.htmlno-cache(每次验证,确保获取最新版本)
  • 带 hash 的资源(app.[hash].js):max-age=31536000, immutable(永久缓存)
  • 不带 hash 的资源:短期缓存(如 max-age=3600

原理是:index.html 作为入口必须最新,它引用的资源文件名包含 hash,内容变化时 hash 就变,URL 变了缓存自然失效。

Q: 为什么有些资源显示 "from disk cache",有些显示 "from memory cache"?

A: Memory Cache 是内存缓存,速度最快但容量小,tab 关闭即清空,通常缓存当前页面的资源;Disk Cache 是磁盘缓存,容量大、持久化,根据 HTTP 缓存头控制。浏览器会优先查找 Memory Cache,没有再查找 Disk Cache。

Q: no-cache 和 no-store 的区别?

A:

  • no-cache:浏览器会缓存资源,但每次使用前必须向服务器验证(协商缓存),如果服务器返回 304,使用本地缓存
  • no-store:完全不缓存,每次都重新下载

no-cache 的命名容易误解,它不是"不缓存",而是"缓存但需验证"。

Q: 304 状态码的完整流程是什么?

A:

  1. 浏览器发现强缓存过期(或设置了 no-cache)
  2. 发起请求,携带 If-None-Match(ETag)或 If-Modified-Since(时间戳)
  3. 服务器比对 ETag 或修改时间
  4. 如果资源未改变,返回 304 Not Modified(无响应体)
  5. 浏览器使用本地缓存

304 响应虽然也有网络请求,但没有响应体,节省了带宽。

Q: 如何强制用户更新缓存的资源?

A: 常见方法:

  1. 文件名加 hash(最佳):app.[contenthash].js
  2. URL 加版本号:style.css?v=1.2.3
  3. 设置 no-cache:每次验证
  4. CDN Purge:手动清除 CDN 缓存
  5. Service Worker:主动清除旧缓存

推荐第 1 种,因为它自动化、可靠、不需要手动操作。

有关 HTTP 缓存策略的高频关键概念

  • 强缓存 / 协商缓存
  • Cache-Control / Expires
  • ETag / If-None-Match
  • Last-Modified / If-Modified-Since
  • 304 Not Modified
  • max-age / no-cache / no-store
  • public / private / immutable
  • Memory Cache / Disk Cache
  • contenthash(Webpack)
  • CDN 缓存
  • Stale-While-Revalidate

容易踩的坑

  1. 混淆 no-cache 和 no-store:no-cache 会缓存但需验证,no-store 才是完全不缓存
  2. 忘记 index.html 也会被缓存:用户可能看到旧的 index.html,即使资源文件都更新了
  3. 过度依赖手动清除 CDN 缓存:应该使用带 hash 的文件名实现自动失效
  4. 静态资源域名包含 Cookie:Cookie 会阻止 CDN 缓存,应使用独立的无 Cookie 域名
  5. 开发环境忘记禁用缓存:导致改了代码但浏览器使用旧缓存

缓存策略决策树

资源类型?
├─ HTML 入口文件 → no-cache(或 no-store)
├─ 带 hash 的 JS/CSS/图片 → max-age=31536000, immutable
├─ 不带 hash 的静态资源 → max-age=3600(短期缓存)
├─ API 响应
│   ├─ 用户特定数据 → private, no-cache
│   ├─ 公共数据(不常变)→ public, max-age=60
│   └─ 实时数据 → no-store
└─ 字体文件 → public, max-age=31536000

小结

HTTP 缓存是 Web 性能优化的基石。理解强缓存和协商缓存的区别、Cache-Control 的各种指令、ETag 的工作原理,能帮助我们为不同类型的资源设置合适的缓存策略,在性能和新鲜度之间找到平衡。

这篇文章主要探讨了:

  • 浏览器缓存的层级结构
  • 强缓存(Cache-Control / Expires)
  • 协商缓存(ETag / Last-Modified)
  • 缓存决策流程
  • SPA 应用的缓存最佳实践
  • CDN 缓存处理

参考资料

❌
❌