阅读视图

发现新文章,点击刷新页面。

Vue3 defineProps使用指南

defineProps是Vue3组合式API( < script setup > )中专用来声明组件接受父组件传值的宏函数,无须导入,直接使用。他的核心:声明子组件要接收的props、定义类型校验、设置默认值、必填项。

一、基础用法(最简单)

直接声明props名称数组,适合简单场景:

<!-- 子组件 Child.vue --> 
<script setup> 
// 基础用法:只声明名称 
defineProps(['title', 'count']) 
</script> 
<template>
    <div>{{ title }}</div>
    <div>{{ count }}</div>
</template>

父组件使用

<!-- 父组件 parent.vue -->
<Child title="我是标题" :cout="10" />

二、带校验的用法(推荐)

可以指定类型、必填、默认值,开发中很常见

<script setup> 
defineProps({ 
    // 基础类型校验 String/Number/Boolean/Array/Object/Function 
    title: { 
        type: String, 
        required: true, // 必填项 
        default: '默认标题' // 默认值 
    }, 
    count: { 
        type: Number, 
        default: 0 
    }, 
    // 多个可能的类型 
    id: [String, Number], 
    // 自定义校验函数 
    status: { 
        validator(value) { 
            // 必须是这几个值之一 
            return ['success', 'error', 'warning'].includes(value) 
        }
    } 
}) </script>

三、TS类型写法(Vue3+TypeScript)

如果用TS,推荐泛型写法,类型更安全

<script setup>
import {widthDefault} from "vue"
// 定义接口(推荐)
interface IProps {
    title: string
    count?: number  //可选
    list?: string[]
}

// 泛型+默认值
const props = withDefault(defineProps<IProps>(), {
    count: 0,
    list: ()=>[]
});
</script>

四、获取和使用props变量

widthDefault或者defineProps都会返回一个响应式对象,可以接收并使用

<script setup>
import { toRefs } from "vue"
// 接收 props 对象 
const props = defineProps(['title', 'count']);
// 1.直接使用props.xxx方式使用
console.log(props.title) 
console.log(props.count)

// 2.通过使用toRefs的方式解构props
// 注意不能直接结构props, 会丢失响应式
const {title, count} = toRefs(props);
console.log(title.value)
console.log(count.value)
</script>

Flutter开箱即用一站式解决方案5.0-ComDraggable悬浮拖拽

Flutter Chen Common

Pub Version License

🌟 简介

Flutter Chen Common 是一个功能丰富的 Flutter 通用库,为应用开发提供一站式解决方案。

  • 可定制的主题系统
  • 完整的国际化支持
  • 企业级网络请求封装
  • 企业级日志体系封装
  • N+高质量常用组件
  • 常用开发工具及扩展集合
  • 智能刷新列表解决方案
  • 全局统一各状态布局
  • 全局无需Context的Toast

特性

  • 🎨 主题系统:通过 ThemeExtension 全局配置颜色/圆角/间距等样式
  • 🌍 国际化支持:内置中英文,支持自定义文本和动态语言切换
  • 优先级覆盖:支持全局配置 + 组件级参数覆盖
  • 📱 自适应设计:完美适配 iOS/Material 设计规范
  • 🔥 企业级方案:内置日志/网络/安全等通用模块,提供开箱即用的复杂场景解决方案

🚀 快速接入

安装依赖

pubspec.yaml 中添加依赖:

dependencies:
  flutter_chen_common: 最新版本

ComDraggable 悬浮拖拽组件

ComDraggable 是一个通用的悬浮拖拽容器,适合承载悬浮按钮、调试入口、客服入口、快捷工具球等场景。

它基于以下能力实现:

  • Stack 作为悬浮层容器
  • Positioned 控制当前位置
  • GestureDetector 处理拖动手势
  • 默认开启横向吸边
  • 吸边带缓动动画,并结合释放方向让手感更自然

功能特性

  • 通用 child 容器,可承载任意 Widget
  • 默认右下角定位,使用 initialRight / initialBottom
  • 支持安全区约束
  • 支持边界留白 boundaryMargin
  • 支持拖动中视觉态 builder
  • 支持关闭拖动
  • 支持关闭吸边

ComDraggable 效果预览

快速开始

基础用法

ComDraggable(
  initialRight: 24,
  initialBottom: 120,
  childSize: const Size(62, 62),
  child: DecoratedBox(
    decoration: BoxDecoration(
      color: Colors.blue,
      shape: BoxShape.circle,
    ),
    child: Icon(Icons.chat, color: Colors.white),
  ),
)

自定义拖动中视觉反馈

ComDraggable(
  initialRight: 24,
  initialBottom: 120,
  childSize: const Size(62, 62),
  builder: (context, child, isDragging) {
    return AnimatedScale(
      scale: isDragging ? 1.06 : 1,
      duration: const Duration(milliseconds: 120),
      child: child,
    );
  },
  child: DecoratedBox(
    decoration: BoxDecoration(
      color: Colors.deepPurple,
      shape: BoxShape.circle,
    ),
    child: Icon(Icons.bug_report, color: Colors.white),
  ),
)

关闭吸边

ComDraggable(
  initialRight: 24,
  initialBottom: 120,
  snapToEdge: false,
  childSize: const Size(62, 62),
  child: YourFloatingWidget(),
)

吸边说明

默认开启 snapToEdge

组件在拖拽结束后会按以下规则吸边:

  1. 优先参考释放时的横向速度
  2. 如果释放速度不明显,则按当前位置距离最近的左右边吸附
  3. 吸边过程使用短时缓动动画,而不是直接跳边

当前只做横向吸边,纵向位置保持释放时的位置。

API

参数 类型 默认值 说明
child Widget - 悬浮内容
childSize Size - 悬浮内容占用尺寸,用于边界计算
initialRight double 0 初始右侧偏移
initialBottom double 0 初始底部偏移
draggable bool true 是否允许拖动
snapToEdge bool true 是否开启横向吸边
useSafeArea bool true 是否把安全区纳入可拖拽边界
boundaryMargin EdgeInsets EdgeInsets.zero 额外边界留白
clipBehavior Clip Clip.none 外层 Stack 的裁剪行为
snapAnimationDuration Duration 180ms 吸边动画时长
snapAnimationCurve Curve Curves.easeOutCubic 吸边动画曲线
onPositionChanged ValueChanged<Offset>? null 位置变化回调,回传 (right, bottom)
builder Widget Function(BuildContext, Widget, bool)? null 自定义包装器,第三个参数表示是否正在拖动

使用建议

  • childSize 要和实际可点击区域尺寸一致,否则边界计算会偏。
  • 如果悬浮内容有阴影,建议通过 boundaryMargin 预留一点空间。
  • 如果业务需要记忆位置,可以在 onPositionChanged 里持久化最终的 (right, bottom)
  • 如果用于全局悬浮入口,建议放在页面最外层的 StackOverlay 中。

前端工程化七连问:从紧急修复到版本控制,一文打通工程化任督二脉

本文系统梳理前端工程化中的7个高频问题:npm 紧急修复、代码分包、分支部署、browserslist、CJS 转 ESM、Git Hooks、Semver。每个问题附带原理分析和实战方案。


一、如何修复某个 npm 包的紧急 bug

场景

生产环境发现某个依赖包有严重 bug,但官方尚未发布修复版本。

方案对比

方案 适用场景 操作复杂度
patch-package 临时修复,等待官方更新 ⭐ 低
fork + 私有仓库 长期维护,团队内部使用 ⭐⭐⭐ 高
resolutions/overrides 强制锁定特定版本 ⭐⭐ 中
npm link/yalc 本地开发调试 ⭐ 低

推荐方案:patch-package

# 1. 直接修改 node_modules 中的问题代码
vim node_modules/some-lib/index.js

# 2. 生成补丁文件
npx patch-package some-lib

# 3. 补丁自动保存到 patches/ 目录
# patches/some-lib+1.2.3.patch

# 4. 配置 package.json,安装时自动应用
{
  "scripts": {
    "postinstall": "patch-package"
  }
}

原理patch-packagepostinstall 钩子中对比 node_modulespatches/ 目录的 diff,自动还原修改。

注意事项

  • 补丁只针对特定版本,升级依赖后需重新生成
  • 适合小改动,大改建议 fork

二、前端如何进行高效的分包

核心目标

减少首屏加载时间,按需加载代码。

Webpack 分包策略

// webpack.config.js
module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        // 1. 第三方库单独打包
        vendor: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendors',
          chunks: 'all',
          priority: 10
        },
        // 2. 公共模块提取
        common: {
          minChunks: 2,
          chunks: 'all',
          enforce: true
        }
      }
    },
    // 3. 运行时代码单独提取
    runtimeChunk: 'single'
  }
};

动态导入(按需加载)

// 路由级别懒加载
const Dashboard = () => import(/* webpackChunkName: "dashboard" */ './Dashboard.vue');

// 组件级别懒加载
const HeavyChart = defineAsyncComponent(() => 
  import(/* webpackChunkName: "charts" */ './HeavyChart.vue')
);

分包效果

优化前:app.js (2.5MB)
优化后:
  ├── runtime.js (5KB)      ← 模块加载器
  ├── vendors.js (800KB)    ← React/Vue/Lodash 等(长期缓存)
  ├── common.js (200KB)     ← 公共业务代码
  ├── dashboard.js (300KB)  ← 路由按需加载
  └── settings.js (150KB)   ← 路由按需加载

缓存策略:第三方库变化频率低,可设置长期缓存;业务代码每次构建 Hash 变化,短期缓存。


三、前端如何对分支环境进行部署

需求

每个功能分支都有独立的可访问环境,供测试/产品验收。

方案:GitLab CI + Docker + Traefik 动态路由

# .gitlab-ci.yml
stages:
  - build
  - deploy

variables:
  DOCKER_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG

build:
  stage: build
  script:
    - docker build -t $DOCKER_IMAGE .
    - docker push $DOCKER_IMAGE

deploy:
  stage: deploy
  script:
    # 根据分支名生成唯一服务名和域名
    - export SERVICE_NAME=preview-${CI_COMMIT_REF_SLUG}
    - export DOMAIN=${CI_COMMIT_REF_SLUG}.preview.example.com
    - envsubst < docker-compose.template.yml > docker-compose.yml
    - docker stack deploy -c docker-compose.yml preview
# docker-compose.template.yml
version: "3"
services:
  ${SERVICE_NAME}:
    image: ${DOCKER_IMAGE}
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.${SERVICE_NAME}.rule=Host(`${DOMAIN}`)"
      - "traefik.http.routers.${SERVICE_NAME}.tls=true"

效果

  • feat/login 分支 → https://feat-login.preview.example.com
  • feat/pay 分支 → https://feat-pay.preview.example.com

现代简化方案:Vercel/Netlify

推送分支即自动部署,零配置:

git push origin feat/login
# Vercel 自动生成:https://myapp-git-feat-login.vercel.app

四、简述 browserslist 的意义

核心作用

定义目标浏览器范围,让编译工具(Babel、PostCSS、Autoprefixer)知道需要兼容哪些环境。

配置方式

// package.json
{
  "browserslist": [
    "> 1%",           // 全球使用率 > 1% 的浏览器
    "last 2 versions", // 每个浏览器的最近 2 个版本
    "not dead",        // 排除官方不再维护的浏览器(如 IE 10)
    "not ie 11"        // 明确排除 IE 11
  ]
}

工具链联动

browserslist 配置
    ↓
【Babel】@babel/preset-env
    根据目标浏览器决定需要哪些语法转换
    如:目标不支持 ?? 运算符 → 转换为 a !== null && a !== void 0 ? a : bPostCSSautoprefixer
    根据目标浏览器添加 CSS 前缀
    如:display: flex → 自动添加 -webkit-/-ms- 前缀
    
【ESLinteslint-plugin-compat
    检查代码中是否使用了目标浏览器不支持的 API

查询实际覆盖范围

npx browserslist "> 1%, last 2 versions"
# 输出:
# and_chr 121
# and_ff 122
# chrome 121
# chrome 120
# edge 121
# ...

意义:避免过度兼容(增加代码体积)或兼容不足(用户报错),实现精准的"按需降级"。


五、如何将 CommonJS 转化为 ESM

背景

Vite 等 Bundless 工具原生只支持 ESM,但 npm 大量包仍是 CJS。

核心差异

特性 CJS ESM
导出 module.exports = {...} export default / export const
导入 const x = require('x') import x from 'x'
加载时机 运行时同步 解析时静态分析

转换难点

// CJS:动态导出,难以静态分析
module.exports = { a: 1 };
exports.b = 2;
if (condition) {
  exports.c = 3;  // 条件导出
}

// 转换后(需处理所有情况)
const __module = { a: 1 };
__module.b = 2;
if (condition) {
  __module.c = 3;
}
export default __module;
export const a = __module.a;
export const b = __module.b;

工具方案

工具 用法 场景
@rollup/plugin-commonjs Rollup 插件 项目打包时转换
vite-plugin-commonjs-externals Vite 插件 Vite 项目处理 CJS 依赖
Skypack / jspm / esm.sh CDN 服务 浏览器直接 import CJS 包
// Vite 中使用
import { defineConfig } from 'vite';
import { viteCommonjs } from '@originjs/vite-plugin-commonjs';

export default defineConfig({
  plugins: [viteCommonjs()]
});

六、Git Hooks 原理是什么

核心机制

Git 在执行特定操作前/后,自动触发本地脚本,用于代码检查、格式化等。

钩子触发时机

git commit
    ↓
【pre-commit】提交前触发 → 运行 lint/format
    ↓
【prepare-commit-msg】编辑提交信息前 → 自动生成信息
    ↓
【commit-msg】提交信息编辑后 → 检查信息格式
    ↓
【post-commit】提交完成后 → 发送通知

实战:husky + lint-staged

# 1. 安装
npm install -D husky lint-staged

# 2. 初始化 husky
npx husky init

# 3. 配置 pre-commit 钩子
# .husky/pre-commit
npx lint-staged
// package.json
{
  "lint-staged": {
    "*.{js,ts,vue}": [
      "eslint --fix",
      "prettier --write",
      "git add"
    ]
  }
}

原理

  1. husky.git/hooks/ 目录安装钩子脚本
  2. git commit 时触发 pre-commit
  3. lint-staged 只检查暂存区的文件,而非全量检查,提升速度

自定义钩子示例

# .husky/commit-msg
# 检查提交信息格式:必须是 feat:/fix:/docs: 开头
#!/bin/sh
commit_msg=$(cat $1)
if ! echo "$commit_msg" | grep -qE "^(feat|fix|docs|style|refactor|test|chore)(\(.+\))?: .+"; then
  echo "提交信息格式错误!示例:feat: 新增登录功能"
  exit 1
fi

七、什么是 Semver,~1.2.3 与 ^1.2.3 的版本号范围

Semver 规范

Semver(Semantic Versioning)= 语义化版本控制,格式:MAJOR.MINOR.PATCH

含义 何时递增
MAJOR 主版本号 不兼容的 API 修改
MINOR 次版本号 向下兼容的功能新增
PATCH 修订号 向下兼容的问题修复

示例:2.3.12.4.0(新增功能)→ 3.0.0(破坏性更新)

版本号范围

符号 含义 1.2.3 的范围
~1.2.3 锁定 MINOR,允许 PATCH 更新 >=1.2.3 <1.3.0
^1.2.3 锁定 MAJOR,允许 MINOR/PATCH 更新 >=1.2.3 <2.0.0
1.2.3 精确版本 只有 1.2.3
* 任意版本 最新版
>1.2.3 大于指定版本 >1.2.3

对比图示

版本时间线:1.2.31.2.41.2.51.3.01.4.02.0.0

~1.2.3 允许:  [1.2.3]────[1.2.4]────[1.2.5]  ✓
              拒绝:[1.3.0] [1.4.0] [2.0.0]  ✗

^1.2.3 允许:  [1.2.3]────[1.2.4]────[1.2.5]────[1.3.0]────[1.4.0]  ✓
              拒绝:[2.0.0]

package.json 中的实际应用

{
  "dependencies": {
    "react": "^18.2.0",      // 允许 18.x.x,不允许 19.0.0
    "lodash": "~4.17.21",    // 允许 4.17.x,不允许 4.18.0
    "webpack": "5.75.0"      // 精确锁定,不自动更新
  }
}

lock 文件的意义

package-lock.json / yarn.lock / pnpm-lock.yaml 的作用:

package.json: 声明依赖范围(^1.2.3)
      ↓
lock 文件: 锁定实际安装的精确版本(1.2.5)
      ↓
下次安装: 直接读取 lock 文件,保证版本一致

为什么需要 lock 文件^1.2.3 允许安装 1.2.51.4.0,不同时间安装可能得到不同版本,导致"在我电脑上能跑"的问题。


总结速查表

问题 核心方案 关键工具
npm 紧急修复 patch-package patch-package, postinstall
高效分包 splitChunks + 动态导入 Webpack, import()
分支环境部署 Docker + 动态路由 GitLab CI, Traefik, Vercel
browserslist 定义目标浏览器范围 browserslist, Babel, PostCSS
CJS 转 ESM 静态分析 + 代码包裹 @rollup/plugin-commonjs
Git Hooks 本地脚本自动触发 husky, lint-staged
Semver 语义化版本控制 ~ 锁定 MINOR, ^ 锁定 MAJOR

💡 工程化的本质:用工具和流程将"人工决策"转化为"自动化规则",降低出错概率,提升协作效率。

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

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

Minimum 节点是 Unity Shader Graph 中一个基础但功能强大的数学运算节点,用于比较两个输入值并返回其中的较小值。在着色器编程中,这种最小值操作广泛应用于各种视觉效果制作,从简单的颜色混合到复杂的材质表现,都能看到它的身影。该节点的核心价值在于它能够以简洁的方式实现复杂的逻辑判断,让着色器开发者能够更高效地创建各种视觉特效。

在图形渲染中,经常需要根据特定条件选择使用哪个数值,Minimum 节点正是为此而生。它不仅仅是一个简单的比较工具,更是构建复杂着色器逻辑的基础构建块。无论是控制光照强度、管理纹理混合,还是实现特殊的视觉效果,Minimum 节点都能提供精确的数值控制能力。

描述

Minimum 节点的功能非常直观且专一:它接收两个输入值 A 和 B,经过内部比较后,输出这两个值中较小的一个。这种操作在数学上称为"最小值函数",在编程中通常表示为 min(A, B)。

从数学角度来看,Minimum 节点执行的操作可以表示为:Out = A < B ? A : B。这意味着如果 A 小于 B,则输出 A;否则输出 B。这种简单的比较逻辑在着色器编程中有着极其广泛的应用场景。

Minimum 节点支持动态矢量类型,这意味着它可以处理各种维度的数据,包括:

  • 浮点数 (Float)
  • 二维矢量 (Vector2)
  • 三维矢量 (Vector3)
  • 四维矢量 (Vector4)

这种灵活性使得 Minimum 节点能够同时处理单个数值和复杂的多维数据,大大扩展了其应用范围。例如,可以一次性比较两个颜色值(Vector3)的所有通道,找出每个通道上的最小值。

在性能方面,Minimum 节点通常会被编译为 GPU 原生支持的高效指令,因此在着色器中使用它不会带来明显的性能开销。这使得它成为实现各种效果时的首选工具之一。

端口

Minimum 节点的端口设计简洁明了,包括两个输入端口和一个输出端口,每个端口都有其特定的功能和用途。

输入端口

  • A 端口
    • 方向:输入
    • 类型:动态矢量
    • 描述:作为比较的第一个输入值。可以接受任意维度的矢量数据,从简单的浮点数到复杂的四维矢量。在实际使用中,A 端口通常连接需要参与比较的第一个数值源,例如纹理采样结果、时间参数或其他数学运算的输出。
  • B 端口
    • 方向:输入
    • 类型:动态矢量
    • 描述:作为比较的第二个输入值。与 A 端口一样,支持各种维度的矢量数据。B 端口通常连接比较的基准值或第二个数值源。当 A 和 B 端口连接的数值类型维度不同时,Shader Graph 会自动进行类型转换和匹配。

输出端口

  • Out 端口
    • 方向:输出
    • 类型:动态矢量
    • 描述:输出 A 和 B 中的较小值。输出的维度与输入值的维度保持一致。例如,如果输入两个 Vector3 类型的数据,输出也会是 Vector3 类型,其中每个分量都是对应输入分量的最小值。

端口使用注意事项

  • 当连接不同维度的数据时,Shader Graph 会自动进行适当的类型转换。例如,将一个 Float 值与 Vector3 连接时,Float 值会被扩展为各个分量相同的 Vector3。
  • 输入端口支持直接连接常量值,也可以通过其他节点提供动态计算的数值。
  • 输出端口可以连接到任何接受相应数据类型输入的端口,包括颜色输入、数值参数或其他数学运算节点。

生成的代码示例

理解 Minimum 节点在底层如何实现对于深入学习 Shader Graph 至关重要。通过查看生成的代码,我们可以更好地理解节点的运作原理,并在需要时进行手动优化或自定义实现。

基本代码结构

Minimum 节点在 HLSL 代码中的典型实现如下:

HLSL

void Unity_Minimum_float4(float4 A, float4 B, out float4 Out)
{
    Out = min(A, B);
}

这段代码展示了一个处理 float4 类型数据的 Minimum 节点实现。函数接收两个 float4 参数 A 和 B,通过 HLSL 内置的 min 函数计算最小值,并将结果存储在输出参数 Out 中。

不同数据类型的实现

根据输入数据类型的不同,Shader Graph 会生成相应版本的函数:

Float 类型:

HLSL

void Unity_Minimum_float(float A, float B, out float Out)
{
    Out = min(A, B);
}

Vector2 类型:

HLSL

void Unity_Minimum_float2(float2 A, float2 B, out float2 Out)
{
    Out = min(A, B);
}

Vector3 类型:

HLSL

void Unity_Minimum_float3(float3 A, float3 B, out float3 Out)
{
    Out = min(A, B);
}

自定义实现变体

在某些情况下,开发者可能需要自定义的最小值函数,例如为了兼容不同的渲染管线或添加特殊功能:

支持半精度浮点数:

HLSL

void Unity_Minimum_half(half A, half B, out half Out)
{
    Out = min(A, B);
}

添加阈值的最小值函数:

HLSL

void Unity_MinimumWithThreshold_float(float A, float B, float Threshold, out float Out)
{
    Out = min(A, B);
    // 可以添加额外的逻辑,如确保结果不低于某个阈值
    Out = max(Out, Threshold);
}

代码优化技巧

理解生成的代码后,我们可以应用一些优化技巧:

  • 当连续使用多个 Minimum 节点时,可以考虑合并它们以减少函数调用次数。
  • 对于常量比较,可以在 CPU 端预先计算结果,避免在着色器中执行不必要的计算。
  • 使用适当的精度修饰符(如 half 代替 float)可以在移动设备上提高性能。

在自定义函数中使用

Minimum 操作也可以集成到更大的自定义函数中:

HLSL

void Unity_CustomLighting_float(float3 Albedo, float3 LightColor, float LightIntensity, out float3 Out)
{
    // 计算基础光照
    float3 baseLighting = Albedo * LightColor * LightIntensity;

    // 使用最小值限制最大亮度
    float3 maxAllowed = float3(1.0, 1.0, 1.0);
    float3 finalLighting = min(baseLighting, maxAllowed);

    Out = finalLighting;
}

这个例子展示了如何在自定义光照函数中使用 Minimum 操作来限制最大亮度,防止颜色值超过有效范围。


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

从 AST 视角看透前端工程化:一条编译管线如何串联起所有工具

当你写下一行 import React from 'react',到它在浏览器中运行,中间经历了多少次 AST 变换?本文从抽象语法树(AST)的视角,串联 Babel、Webpack、ESLint、TypeScript、Terser 等工具,揭示前端工程化的底层统一模型。


一、AST:所有工具的"通用语言"

前端工程化工具看似各自独立,实则共享同一套底层机制:

源代码 (Source Code)
    ↓
【解析 Parse】→ Token 流 → AST(抽象语法树)
    ↓
【转换 Transform】→ 遍历/修改 AST
    ↓
【生成 Generate】→ 目标代码 (Target Code)

这是编译原理的经典三段式,也是 Babel、Webpack、ESLint、TypeScript 的共同骨架。


二、工具链全景:谁在操作 AST?

工具 输入 AST 输出 AST/代码 核心操作
ESLint 源码 诊断报告 遍历 AST,检查模式匹配
Prettier 源码 格式化代码 遍历 AST,按规则重新打印
TypeScript 源码 类型擦除后的 JS 遍历 AST,类型检查 + 转换
Babel 源码 降级/转换后的 JS 遍历 AST,语法转换
SWC 源码 转换后的 JS Rust 实现的 AST 操作
Webpack 多个源码 AST 打包后的 JS Bundle 分析依赖图,模块拼接
Terser/Uglify JS AST 压缩后的 JS 遍历 AST,删除无用代码、缩短变量名
Vue Compiler .vue SFC 渲染函数 + JS 解析模板为 AST,生成代码
PostCSS CSS AST 转换后的 CSS 遍历 CSS AST,插件处理

所有工具的本质:解析 → 变换 AST → 生成。


三、逐层深入:每个工具的 AST 操作细节

1. ESLint:AST 模式检查器

// 源码
if (a == b) { console.log('equal') }

// ESLint 解析后的 AST(ESTree 规范)
{
  "type": "IfStatement",
  "test": {
    "type": "BinaryExpression",
    "operator": "==",        // ← ESLint 规则检查这里
    "left": { "type": "Identifier", "name": "a" },
    "right": { "type": "Identifier", "name": "b" }
  }
}

ESLint 规则 eqeqeq 的实现

module.exports = {
  create(context) {
    return {
      BinaryExpression(node) {
        if (node.operator === '==') {
          context.report({
            node,
            message: 'Expected === instead of =='
          });
        }
      }
    };
  }
};

本质:注册 AST 节点访问者,匹配特定模式即报错。


2. Babel:AST 转换器

// 源码(ES6+)
const add = (a, b) => a + b;

// Babel 解析后的 AST(Babel AST 规范)
{
  "type": "VariableDeclaration",
  "declarations": [{
    "type": "VariableDeclarator",
    "id": { "type": "Identifier", "name": "add" },
    "init": {
      "type": "ArrowFunctionExpression",  // ← 需要转换的节点
      "params": [...],
      "body": { ... }
    }
  }]
}

Babel 插件转换过程

// 箭头函数转普通函数
export default function() {
  return {
    visitor: {
      ArrowFunctionExpression(path) {
        // 1. 创建新节点:普通函数表达式
        const func = t.functionExpression(
          null,
          path.node.params,
          t.blockStatement([
            t.returnStatement(path.node.body)
          ])
        );
        
        // 2. 替换原节点
        path.replaceWith(func);
      }
    }
  };
}

转换结果

var add = function(a, b) { return a + b; };

3. TypeScript:带类型的 AST 分析与擦除

// 源码
function greet(name: string): void {
  console.log(`Hello, ${name}`);
}

// TypeScript AST(带类型节点)
{
  "type": "FunctionDeclaration",
  "name": { "type": "Identifier", "name": "greet" },
  "parameters": [{
    "type": "Parameter",
    "name": { "type": "Identifier", "name": "name" },
    "typeAnnotation": {          // ← TS 特有节点
      "type": "StringKeyword"
    }
  }],
  "typeAnnotation": {            // ← TS 特有节点
    "type": "VoidKeyword"
  }
}

TypeScript 的两阶段处理

阶段一:类型检查(遍历 AST,检查类型约束)
  - name: string → 检查调用时传入的是否为 string
  - 发现类型错误 → 报错,不生成代码

阶段二:类型擦除(删除所有类型节点,生成纯 JS)
  - 删除 :string
  - 删除 :void
  - 删除 interfacetype 等类型声明

输出

function greet(name) {
  console.log("Hello, ".concat(name));
}

4. Webpack:AST 依赖分析器

Webpack 不直接暴露 AST 操作,但内部高度依赖 AST:

// 源码
import { add } from './math.js';
console.log(add(1, 2));

Webpack 的 AST 分析流程

1. 解析源码为 AST
        ↓
2. 遍历 AST,找到 ImportDeclaration 节点
   {
     "type": "ImportDeclaration",
     "source": { "value": "./math.js" }  ← 提取依赖路径
   }
        ↓
3. 解析 ./math.js,递归分析其依赖
        ↓
4. 构建完整依赖图(Dependency Graph)
        ↓
5. 将多个模块的 AST 拼接为一个 Bundle AST
        ↓
6. 生成最终代码,注入模块加载器(__webpack_require__)

生成的 Bundle 代码

// 简化示意
(function(modules) {
  function __webpack_require__(moduleId) {
    // 模块加载器
    var module = { exports: {} };
    modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
    return module.exports;
  }
  
  // 入口执行
  return __webpack_require__(0);
})([
  // 模块 0:入口
  function(module, exports, __webpack_require__) {
    var _math = __webpack_require__(1);
    console.log(_math.add(1, 2));
  },
  // 模块 1:math.js
  function(module, exports) {
    exports.add = function(a, b) { return a + b; };
  }
]);

5. Terser:AST 压缩器

// 源码
function calculate(x, y) {
  const result = x + y;
  console.log(result);
  return result;
}

// Terser 的 AST 优化策略
{
  "type": "FunctionDeclaration",
  "body": {
    "type": "BlockStatement",
    "body": [
      { "type": "VariableDeclaration", ... },  // const result = ...
      { "type": "ExpressionStatement", ... },  // console.log(...)
      { "type": "ReturnStatement", ... }        // return result
    ]
  }
}

Terser 的 AST 变换

优化策略 AST 操作 效果
变量名缩短 resultr 减少代码体积
死代码删除 删除未使用的变量声明 删除无用节点
常量折叠 1 + 23 替换计算结果为字面量
函数内联 短函数直接展开 减少函数调用开销

输出

function calculate(n,o){const c=n+o;return console.log(c),c}

6. Vue Compiler:模板 AST 生成器

<template>
  <div class="hello" @click="handleClick">
    {{ msg }}
  </div>
</template>

Vue 的编译流程

模板字符串
    ↓
【解析】→ HTML Parser → 模板 AST(类似虚拟 DOM 结构)
{
  "type": "Element",
  "tag": "div",
  "props": [
    { "name": "class", "value": "hello" },
    { "name": "@click", "value": "handleClick" }
  ],
  "children": [
    { "type": "Interpolation", "content": "msg" }
  ]
}
    ↓
【转换】→ 遍历 AST,生成渲染函数代码
    ↓
【生成】→ JS 代码

生成的渲染函数

function render(_ctx, _cache) {
  return _openBlock(), _createElementBlock("div", {
    class: "hello",
    onClick: _ctx.handleClick
  }, _toDisplayString(_ctx.msg), 1 /* TEXT */);
}

四、AST 规范之争:工具间如何协作?

不同工具使用不同的 AST 规范,转换成本成为性能瓶颈:

工具 AST 规范 特点
Babel Babel AST 基于 ESTree 扩展,支持 JSX、TS、Flow
ESLint ESTree 标准 JavaScript AST 规范
TypeScript TS AST 自带类型节点,与 ESTree 不兼容
Acorn ESTree 轻量级解析器,Webpack 早期使用
SWC 自研 AST Rust 实现,性能极致
PostCSS CSS AST 专门针对 CSS 的节点类型

性能痛点:Babel 解析 → TS 类型检查 → 再转回 Babel AST,多次转换损耗性能。

解决方案

  • SWC:统一用 Rust 实现解析 + 转换 + 生成,避免跨语言边界
  • Oxc:新一代 Rust 工具链,统一 AST 格式
  • Babel 的 @babel/parser 支持 TS:减少一次解析

五、工程化管线串联:一次完整的构建流程

【源代码】
  App.vue
  utils.ts
  main.js

    ↓ ① ESLint(ESTree AST)
  代码规范检查
    ↓ ② TypeScript(TS AST)
  类型检查 + 类型擦除
    ↓ ③ Vue Compiler(模板 AST → JS AST)
  .vue 文件编译为 JS
    ↓ ④ Babel/SWC(Babel AST)
  ES6+ → ES5 语法转换
    ↓ ⑤ Webpack(Acorn/Babel AST)
  依赖分析 + 模块打包
    ↓ ⑥ Terser(Uglify AST)
  代码压缩 + 优化
    ↓
【输出代码】
  dist/main.[hash].js

每个阶段都在操作 AST,只是目的不同:检查、转换、分析、压缩。


六、未来趋势:AST 操作的统一与提速

趋势 说明
Rust 化 SWC、Oxc 用 Rust 重写,解析速度提升 10~20 倍
统一 AST 减少工具间 AST 转换,降低性能损耗
并行化 多线程解析多个文件,充分利用多核 CPU
持久化缓存 文件未变更时直接复用上次 AST,跳过解析

七、总结

前端工程化的所有工具——ESLint 检查、Babel 转译、TypeScript 类型擦除、Webpack 打包、Terser 压缩、Vue 模板编译——本质都是"解析源码为 AST → 遍历变换 AST → 生成目标代码"的三段式。理解 AST,就理解了前端工程化的底层统一模型。

TinyRobot Bubble:为 AI 对话而生的 Vue 3 消息气泡组件

本文由云软件体验技术团队胡靖原创。

在 AI 应用里,消息气泡看似只是 UI 的一小块,真正落地时却会快速变复杂:流式输出、Markdown、图片、多模态内容、推理过程、工具调用、消息分组、状态折叠、角色样式、自动滚动……这些能力如果都从零实现,往往会让业务代码被展示逻辑淹没。

TinyRobot 的 Bubble 组件正是为这个场景设计的。它不是一个简单的“文本气泡”,而是一套面向 AI 对话界面的消息展示系统,内置 BubbleBubbleListBubbleProvider 三个核心能力,让开发者可以从单条消息展示平滑扩展到完整对话流。

1.png

一行代码,展示一条 AI 消息

最基础的用法非常直接:

<template>
  <tr-bubble role="assistant" content="你好,我是 TinyRobot,可以帮助你快速构建 AI 对话界面。" placement="start" />
</template>

<script setup lang="ts">
import { TrBubble } from "@opentiny/tiny-robot";
</script>

Bubble 支持 placement 控制左右位置,支持 avatar 注入头像组件,也支持通过 CSS 变量调整背景、字号、圆角、宽度等视觉细节。对业务开发来说,这意味着你可以先快速搭出可用界面,再按产品设计逐步定制样式。

为流式输出准备的响应式内容

AI 回复通常不是一次性返回,而是逐 token、逐片段输出。Bubble 的 content 是响应式的,只要持续更新内容,组件就能自然呈现流式效果:

<template>
  <tr-bubble :content="streamContent" :avatar="aiAvatar" />
</template>

<script setup lang="ts">
import { TrBubble } from "@opentiny/tiny-robot";
import { IconAi } from "@opentiny/tiny-robot-svgs";
import { h, ref } from "vue";

const aiAvatar = h(IconAi, { style: { fontSize: "32px" } });
const streamContent = ref("");

async function startStream() {
  const text = "这是一段正在生成中的 AI 回复。";
  streamContent.value = "";

  for (const char of text) {
    streamContent.value += char;
    await new Promise((resolve) => setTimeout(resolve, 80));
  }
}
</script>

这类设计非常适合接入 SSE、Fetch Stream 或 TinyRobot Kit 的消息管理能力。展示层只关心消息对象如何变化,不需要把流式渲染逻辑塞进组件内部。

2.gif

不止文本:图片、Markdown、推理和工具调用

Bubble 的内容模型兼容常见的大模型消息结构。content 可以是字符串,也可以是数组内容项,例如图片:

<tr-bubble
  :content="[
    { type: 'text', text: '这是一张生成结果:' },
    { type: 'image_url', image_url: { url: imageUrl } },
  ]"
  content-render-mode="split"
/>

当内容项中出现 type: 'image_url' 时,Bubble 会自动命中内置图片渲染器。通过 contentRenderMode,可以选择把图文渲染在同一个气泡框内,或拆成多个独立 box。

对更复杂的 AI 模型输出,Bubble 也提供了内置渲染器:

  • Text:默认文本渲染
  • Image:图片内容渲染
  • Markdown:Markdown 内容渲染
  • Loading:加载状态渲染
  • Reasoning:推理过程渲染
  • Tools / Tool:工具调用渲染
  • ToolRole:tool 角色消息渲染

例如模型返回推理内容时,可以直接使用 reasoning_content

<tr-bubble :content="answer" :reasoning_content="reasoningContent" :state="{ thinking: false, open: true }" />

工具调用也可以用 OpenAI 风格的 tool_calls 结构表达:

const message = {
  role: "assistant",
  content: "我来查询天气。",
  tool_calls: [
    {
      id: "call_0",
      type: "function",
      function: {
        name: "get_weather",
        arguments: '{"city":"深圳"}',
      },
    },
  ],
  state: {
    toolCall: {
      call_0: { status: "running", open: true },
    },
  },
};

这让 Bubble 很适合构建 Agent、Copilot、企业知识库助手等需要展示“模型正在做什么”的产品。

3.gif

BubbleList:从单条气泡到完整对话流

实际业务不会只展示一条消息。BubbleList 接收 messages 数组,并通过 roleConfigs 统一配置不同角色的头像、位置、形状和隐藏策略:

<template>
  <tr-bubble-list :messages="messages" :role-configs="roleConfigs" auto-scroll />
</template>

<script setup lang="ts">
import type { BubbleListProps, BubbleRoleConfig } from "@opentiny/tiny-robot";
import { TrBubbleList } from "@opentiny/tiny-robot";

const messages: BubbleListProps["messages"] = [
  { role: "user", content: "帮我总结这份文档" },
  { role: "assistant", content: "可以,请上传文档。" },
];

const roleConfigs: Record<string, BubbleRoleConfig> = {
  user: { placement: "end" },
  assistant: { placement: "start" },
};
</script>

BubbleList 默认使用 divider 分组策略,以 user 作为分割点:用户消息单独成组,后续非用户消息合并为同一次回答。它也支持 consecutive 连续角色分组,或传入自定义分组函数。

这种默认策略很适合 AI 聊天结构:一次完整回答通常以 assistant 开始、以 assistant 结束,中间可能穿插一条或多条 tool 结果。

User
└─ 帮我查一下这个工单的 SLA 风险

Assistant 回答块
├─ assistant:我先查询工单详情,并发起 tool call
├─ tool:返回工单详情
├─ tool:返回 SLA 规则
└─ assistant:根据工具结果给出风险判断

User
└─ 那应该怎么处理?

Assistant 回答块
├─ assistant:我继续检查处理人和审批状态
├─ tool:返回处理人信息
└─ assistant:给出处理建议和注意事项

autoScroll 也针对聊天场景做了处理:当用户发送新消息时,列表会平滑滚动到底部;当 AI 内容持续更新时,只有在用户接近底部时才自动跟随,避免打断用户查看历史内容。

渲染器架构:扩展复杂内容,而不是重写组件

Bubble 最值得开发者关注的是它的渲染器机制。组件将渲染拆成两层:

  • Box 渲染器:控制气泡外层容器
  • Content 渲染器:控制具体内容,如文本、图片、Markdown、工具调用

通过 BubbleProvider,可以在组件树内统一配置匹配规则:

<tr-bubble-provider :content-renderer-matches="contentRendererMatches">
  <tr-bubble-list :messages="messages" />
</tr-bubble-provider>
import { BubbleRendererMatchPriority, type BubbleContentRendererMatch } from "@opentiny/tiny-robot";
import { markRaw } from "vue";
import SchemaCardRenderer from "./SchemaCardRenderer.vue";

const contentRendererMatches: BubbleContentRendererMatch[] = [
  {
    find: (_, content) => content.type === "schema_card",
    renderer: markRaw(SchemaCardRenderer),
    priority: BubbleRendererMatchPriority.CONTENT,
  },
];

这套机制让业务可以把订单卡片、审批卡片、知识库引用、图表结果等结构化内容接入 Bubble,而不用 fork 组件或在消息列表里写大量条件判断。

适合企业 AI 应用的状态边界

Bubble 的消息类型中包含 state 字段,专门用于存放 UI 状态,例如推理过程是否展开、工具调用详情是否展开、点赞状态等。组件通过 state-change 事件把状态变更抛给外层。

这种设计的好处是:消息内容仍然保持接近模型返回结构,UI 状态不会污染真正要发给模型或持久化的业务字段。

总结

TinyRobot Bubble 的价值不只是“好看的气泡”,而是把 AI 对话界面里高频、复杂、容易重复造轮子的展示能力沉淀成了一套可组合系统:

  • 单条消息用 Bubble
  • 完整对话流用 BubbleList
  • 全局渲染扩展用 BubbleProvider
  • 文本、图片、Markdown、推理、工具调用都有内置支持
  • 角色、分组、自动滚动、插槽、CSS 变量和 TypeScript 类型一并覆盖

如果你正在用 Vue 3 构建 AI Chat、Agent 控制台、企业知识库助手或 Copilot 类产品,TinyRobot Bubble 可以帮你把注意力从“消息怎么画”转移到“AI 能为用户完成什么”。

关于 OpenTiny NEXT

OpenTiny NEXT 是一套企业智能前端开发解决方案,以生成式 UI 和 WebMCP 两大核心技术为基础,对现有传统的 TinyVue 组件库、TinyEngine 低代码引擎等产品进行智能化升级,构建出面向 Agent 应用的前端 NEXT-SDKs、AI Extension、TinyRobot智能助手、GenUI等新产品,实现AI理解用户意图自主完成任务,加速企业应用的智能化改造。

欢迎加入 OpenTiny 开源社区。添加微信小助手:opentiny-official 一起参与交流前端技术~
OpenTiny 官网:opentiny.design
TinyRobot 代码仓库:github.com/opentiny/ti… (欢迎star ⭐)

如果你也想要共建,可以进入代码仓库,找到 good first issue标签,一起参与开源贡献~如果你有任何问题,欢迎在评论区留言交流!

vLLM 容器化部署实战:如何在云服务器上跑起高并发大模型推理服务

云服务器 GPU 资源那么贵,怎么部署大模型才能兼顾性能与成本?本文用 vLLM + Docker 实战部署 Qwen2.5/DeepSeek 等主流模型,附完整配置文件与压测数据,手把手教你把模型跑出生产级性能。


为什么选 vLLM?

目前主流的大模型推理框架有三个:

框架 特点 适用场景
vLLM PagedAttention、Tensor Parallelization、连续批处理 高并发、生产级部署
HF Transformers 简单易用、生态丰富 实验、小规模推理
TGI (Text Generation Inference) 量化优化、OpenAI 兼容 HuggingFace 模型快速部署

vLLM 的核心优势:

vLLM = PagedAttention(显存管理革命)
     + Continuous Batching(吞吐量提升 10-23 倍)
     + Tensor Parallelism(多卡并行)
     + 量化支持(AWQ/SGPTQ)

实测数据(单卡 A100 80G,Qwen2.5-7B):

指标 HF Transformers vLLM 提升
Throughput (tokens/s) 28 312 11x
Latency P50 (ms) 850 95 9x
Latency P99 (ms) 2100 280 7.5x
GPU Memory 14.8 GB 8.2 GB 节省 45%

环境准备

硬件要求

最低配置:
├── GPU:NVIDIA A100 40G 或 L20 48G(推荐)
├── CPU:8 核 +(推理调度用)
├── 内存:32 GB+
└── 存储:50 GB+(模型文件)

生产推荐:
├── 双卡 A100 80G(TP=2)
└── 或单卡 H100 80G

安装 NVIDIA 驱动和 Docker

# 1. 安装 NVIDIA 驱动(Ubuntu 22.04)
sudo apt update
sudo apt install -y nvidia-driver-535

# 重启后验证
nvidia-smi
# 预期输出:
# +------------------------------------------------------------------+
# | NVIDIA-SMI 535.161.06   Driver Version: 535.161.06   CUDA: 12.2 |
# +------------------------------------------------------------------+
# | GPU  Name        Persistence-M| Bus-Id        Disp.A | Volatile Unc. |
# |   0  NVIDIA A100 80G...  Off  | 00000000:00:07.0 Off |            0 |
# +------------------------------------------------------------------+

# 2. 安装 NVIDIA Container Toolkit
distribution=$(. /etc/os-release;echo $ID$VERSION_ID)
curl -fsSL https://nvidia.github.io/nvidia-docker/gpgkey | sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-keyring.gpg
curl -s -L https://nvidia.github.io/nvidia-docker/$distribution/nvidia-docker.list | \
    sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-keyring.gpg] https://#g' | \
    sudo tee /etc/apt/sources.list.d/nvidia-docker.list

sudo apt update
sudo apt install -y nvidia-container-toolkit
sudo systemctl restart docker

# 3. 验证 Docker + NVIDIA 集成
docker run --rm --gpus all nvidia/cuda:12.1.0-base-ubuntu22.04 nvidia-smi

vLLM 快速启动(Docker 方式)

方法一:直接用 PyTorch 镜像

# 拉取镜像(推荐 vLLM 最新版)
docker pull nvidia/cuda:12.1.0-base-ubuntu22.04

# 方式一:快速体验(以 Qwen2.5-7B 为例)
docker run --gpus all \
    -p 8000:8000 \
    -v ~/.cache/huggingface:/root/.cache/huggingface \
    --env HF_TOKEN="your_huggingface_token" \
    vllm/vllm-openai:latest \
    --model Qwen/Qwen2.5-7B-Instruct \
    --tensor-parallel-size 1 \
    --port 8000

方法二:Dockerfile 定制(生产推荐)

# Dockerfile.vllm
FROM nvidia/cuda:12.1.0-base-ubuntu22.04

ENV DEBIAN_FRONTEND=noninteractive
ENV PYTHON_VERSION=3.10

# 安装依赖
RUN apt-get update && apt-get install -y \
    python3.10 \
    python3-pip \
    python3.10-venv \
    curl \
    git \
    && rm -rf /var/lib/apt/lists/*

# 安装 vLLM
RUN pip3 install vllm==0.6.3.post1

# 预下载模型(可选,减少首次启动时间)
ARG MODEL_NAME="Qwen/Qwen2.5-7B-Instruct"
ENV MODEL_NAME=${MODEL_NAME}

WORKDIR /app

# 健康检查
HEALTHCHECK --interval=30s --timeout=10s --start-period=120s --retries=3 \
    CMD curl -f http://localhost:8000/health || exit 1

EXPOSE 8000

CMD ["python3", "-m", "vllm.entrypoints.openai.api_server", \
     "--model", "${MODEL_NAME}", \
     "--tensor-parallel-size", "1", \
     "--port", "8000", \
     "--gpu-memory-utilization", "0.9"]
# 构建镜像
docker build -t my-vllm:latest \
    --build-arg MODEL_NAME="Qwen/Qwen2.5-7B-Instruct" \
    -f Dockerfile.vllm .

# 运行
docker run -d \
    --gpus all \
    --name vllm-qwen \
    -p 8000:8000 \
    -v /data/models:/root/.cache/huggingface \
    --restart unless-stopped \
    --shm-size=16g \
    my-vllm:latest

OpenAI 兼容 API 使用

vLLM 提供与 OpenAI API 100% 兼容的接口:

# 安装客户端
pip install openai

# Python 调用示例
from openai import OpenAI

client = OpenAI(
    api_key="EMPTY",  # vLLM 不需要 API Key
    base_url="http://localhost:8000/v1"
)

# 同步调用
response = client.chat.completions.create(
    model="Qwen/Qwen2.5-7B-Instruct",
    messages=[
        {"role": "system", "content": "你是一个有帮助的AI助手。"},
        {"role": "user", "content": "用 Python 写一个快速排序"}
    ],
    temperature=0.7,
    max_tokens=512
)

print(response.choices[0].message.content)

流式输出

# 流式调用(适合实时展示)
stream = client.chat.completions.create(
    model="Qwen/Qwen2.5-7B-Instruct",
    messages=[{"role": "user", "content": "解释一下什么是 RESTful API"}],
    stream=True,
    max_tokens=1024
)

for chunk in stream:
    if chunk.choices[0].delta.content:
        print(chunk.choices[0].delta.content, end="", flush=True)

批量推理

# 批量请求(提升吞吐量)
import asyncio

async def batch_inference(prompts: list, batch_size: int = 10):
    """批量推理:比逐条调用快 5-10 倍"""
    results = []
    
    for i in range(0, len(prompts), batch_size):
        batch = prompts[i:i + batch_size]
        tasks = [
            client.chat.completions.create(
                model="Qwen/Qwen2.5-7B-Instruct",
                messages=[{"role": "user", "content": p}],
                max_tokens=256
            )
            for p in batch
        ]
        batch_results = await asyncio.gather(*tasks)
        results.extend([r.choices[0].message.content for r in batch_results])
    
    return results

# 使用示例
prompts = [
    "什么是 Kubernetes?",
    "Docker 和 Podman 有什么区别?",
    "如何优化 PostgreSQL 查询性能?",
]

results = asyncio.run(batch_inference(prompts))
for q, a in zip(prompts, results):
    print(f"Q: {q}\nA: {a[:100]}...\n")

多卡并行部署(Tensor Parallelism)

当模型太大,单卡放不下时,使用 Tensor Parallelism:

# 8B 模型,单卡 A100 80G 够用
# 14B+ 模型,需要多卡

# 2 卡并行部署 DeepSeek-14B
docker run -d \
    --gpus '"device=0,1"' \
    --name vllm-deepseek \
    -p 8000:8000 \
    -v /data/models:/root/.cache/huggingface \
    --shm-size=32g \
    vllm/vllm-openai:latest \
    --model deepseek-ai/DeepSeek-V2.5 \
    --tensor-parallel-size 2 \
    --port 8000 \
    --gpu-memory-utilization 0.9
# 多卡部署时,代码无需修改,vLLM 自动处理
client = OpenAI(base_url="http://localhost:8000/v1")

# 14B 模型(2卡),吞吐量是单卡的 1.8x
response = client.chat.completions.create(
    model="deepseek-ai/DeepSeek-V2.5",
    messages=[{"role": "user", "content": "写一个 Web 服务器"}],
    max_tokens=1024
)

性能压测与调优

压测脚本

#!/usr/bin/env python3
"""vLLM 性能压测工具"""
import time
import statistics
from openai import OpenAI

client = OpenAI(
    api_key="EMPTY",
    base_url="http://localhost:8000/v1"
)

def benchmark_concurrent(num_requests: int, concurrent: int):
    """并发压测"""
    import concurrent.futures
    
    def single_request():
        start = time.time()
        client.chat.completions.create(
            model="Qwen/Qwen2.5-7B-Instruct",
            messages=[{"role": "user", "content": "写一个快速排序"}],
            max_tokens=512
        )
        return time.time() - start
    
    with concurrent.futures.ThreadPoolExecutor(max_workers=concurrent) as executor:
        start_time = time.time()
        futures = [executor.submit(single_request) for _ in range(num_requests)]
        latencies = [f.result() for f in futures]
        total_time = time.time() - start_time
    
    print(f"=== 压测结果 ===")
    print(f"总请求数:{num_requests}")
    print(f"并发数:{concurrent}")
    print(f"总耗时:{total_time:.2f}s")
    print(f"QPS:{num_requests/total_time:.2f}")
    print(f"P50 延迟:{statistics.median(latencies)*1000:.0f}ms")
    print(f"P99 延迟:{sorted(latencies)[int(len(latencies)*0.99)]*1000:.0f}ms")
    print(f"平均延迟:{statistics.mean(latencies)*1000:.0f}ms")

# 运行压测
benchmark_concurrent(num_requests=100, concurrent=10)

常见调优参数

# vLLM 关键参数说明

--gpu-memory-utilization 0.9    # GPU 显存利用率,越高吞吐量越大
--max-num-batched-tokens 8192   # 单批最大 token 数
--max-num-seqs 256              # 最大并发序列数
--block-size 16                 # KV Cache 块大小(影响显存碎片)
--enable-chunked-prefill        # 启用分块预填充,降低首 token 延迟
--enforce-eager                 # 禁用 CUDA Graph(调试用)
--trust-remote-code             # 允许执行远程代码(某些模型需要)

与腾讯云 GPU 云服务器集成

在腾讯云 CVM 上部署

# 1. 创建 GPU 实例(GN7vwL 或 GN10Xp)
# 推荐:GN10Xp(双卡 V100 32G)或 GN7vwL(单卡 A100 40G)

# 2. 连接服务器
ssh root@your-server-ip

# 3. 安装 Docker(Ubuntu 22.04)
curl -fsSL https://get.docker.com | sh

# 4. 验证 GPU
nvidia-smi

# 5. 部署 vLLM
docker run -d \
    --gpus all \
    --name vllm-production \
    -p 8000:8000 \
    -v /root/.cache/huggingface:/root/.cache/huggingface \
    --restart unless-stopped \
    --shm-size=16g \
    -e HF_TOKEN="your_token" \
    vllm/vllm-openai:latest \
    --model Qwen/Qwen2.5-14B-Instruct \
    --tensor-parallel-size 2 \
    --port 8000 \
    --gpu-memory-utilization 0.85 \
    --max-num-batched-tokens 16384

# 6. 配置 nginx 反向代理(可选)
# apt install nginx
# 配置负载均衡和多实例

配置 systemd 服务(生产环境)

# /etc/systemd/system/vllm.service
[Unit]
Description=vLLM OpenAI API Server
After=network.target docker.service
Requires=docker.service

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/bin/docker start vllm-production
ExecStop=/usr/bin/docker stop vllm-production
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable vllm.service
sudo systemctl start vllm.service
sudo systemctl status vllm.service

量化部署(省钱方案)

INT8 / AWQ 量化

# vLLM 支持 AWQ 量化,显存占用减少 60%,速度反而更快
docker run -d \
    --gpus all \
    --name vllm-quantized \
    -p 8001:8000 \
    vllm/vllm-openai:latest \
    --model Qwen/Qwen2.5-14B-Instruct-AWQ \
    --quantization awq \
    --tensor-parallel-size 1 \
    --port 8000
模型版本 显存占用 吞吐量 精度损失
Qwen2.5-14B (FP16) 28 GB 180 tok/s
Qwen2.5-14B (INT8) 16 GB 220 tok/s < 1%
Qwen2.5-14B (AWQ) 11 GB 260 tok/s < 2%
Qwen2.5-7B (FP16) 14 GB 310 tok/s
Qwen2.5-7B (AWQ) 5 GB 380 tok/s < 2%

结论:AWQ 量化后,一张 A100 可以跑 14B 模型,显存节省 60%,吞吐量反而更高!


总结

vLLM 部署最佳实践:
├── 环境:Docker + NVIDIA Container Toolkit + A100/H100
├── 推荐镜像:vllm/vllm-openai:latest
├── 并发优化:--max-num-batched-tokens 调大
├── 显存优化:--gpu-memory-utilization 0.85-0.9
├── 量化:AWQ 量化是性价比最高的选择
├── 监控:Prometheus + Grafana 监控 QPS 和延迟
└── 多卡:--tensor-parallel-size N(N卡并行)

推荐配置(按场景):
├── 7B 模型 → 单卡 A100 40G / L20,Docker 直接跑
├── 14B 模型 → 双卡 A100 40G 或单卡 A100 80G
├── 72B 模型 → 四卡 A100 80G,TP=4
└── 成本敏感 → AWQ 量化,单卡跑 14B!

关于作者

长期关注大模型应用落地与云服务器实战,专注技术在企业场景中的落地实践。

个人博客:yunduancloud.icu —— 持续更新云计算、AI大模型实战教程,欢迎访问交流。

如何实现一个网页版的剪映(五)如何跳转到视频某一帧

如何实现一个网页版的剪映(一)简介

如何实现一个网页版的剪映(二)深入webcodecs

如何实现一个网页版的剪映(三)使用fabric.js绘制时间轴

如何实现一个网页版的剪映(四)使用插件化思维创建pixi绘制画布(转场/滤镜)

如何实现一个网页版的剪映(五)如何跳转到视频某一帧


# 如何实现一个网页版的剪映(一)简介讲过webav是如何seek某一帧的,我们来回顾一下

源码入口是一个tick函数

/**
 * 获取素材指定时刻的图像帧、音频数据
 * @param time 微秒
 */
async tick(time: number): Promise<{
  video?: VideoFrame;
  audio: Float32Array[];
  state: 'success' | 'done';
}> {
  if (time >= this.#meta.duration) {
    return await this.tickInterceptor(time, {
      audio: (await this.#audioFrameFinder?.find(time)) ?? [],
      state: 'done',
    });
  }

  const [audio, video] = await Promise.all([
    this.#audioFrameFinder?.find(time) ?? [],
    this.#videoFrameFinder?.find(time).then(this.#vfRotater),
  ]);

  if (video == null) {
    return await this.tickInterceptor(time, {
      audio,
      state: 'success',
    });
  }

  return await this.tickInterceptor(time, {
    video,
    audio,
    state: 'success',
  });
}

WebAV 是如何seek的

核心类在 VideoFrameFinder

什么时候会 Reset(等价于 Seek 重建解码状态)

在 find 里,满足任一条件就会 #reset(time) :

find = async (time: number): Promise<VideoFrame | null> => {
    if (
      this.#dec == null ||
      this.#dec.state === 'closed' ||
      time <= this.#ts ||
      time - this.#ts > 3e6
    ) {
      this.#reset(time);
    }

    this.#curAborter.abort = true;
    this.#ts = time;

    this.#curAborter = { abort: false, st: performance.now() };
    const vf = await this.#parseFrame(time, this.#dec, this.#curAborter);
    this.#sleepCnt = 0;
    return vf;
  };
  • 解码器不存在/已关闭
  • time <= 上一次的 time (倒退 seek)
  • time - 上一次的 time > 3e6 (跨度超过 3 秒,认为是 seek)

Reset 做了几件关键事 #reset :

  • 清空缓存的 VideoFrame 队列(并 close)
  • 关闭并重建 VideoDecoder (必要时会降级软件解码)
  • 最重要: 把解码游标 #videoDecCusorIdx 移到“目标时间点之前最近的 IDR 帧”
    • 逻辑是扫描 samples:不断更新 keyIdx (遇到 s.is_idr ),当第一次看到 s.cts >= time 时,把 cursor 设为 keyIdx

这一步决定了: 视频定位不是直接“找 time 对应的那一帧 sample”就解,而是必须从关键帧开始解码 GOP,才能得到后续帧。

parseFrame:从“解码输出队列”里挑出覆盖 time 的那一帧

#parseFrame = async (
  time: number,
  dec: VideoDecoder | null,
  aborter: { abort: boolean; st: number },
): Promise<VideoFrame | null> => {
  if (dec == null || dec.state === 'closed' || aborter.abort) return null;

  if (this.#videoFrames.length > 0) {
    const vf = this.#videoFrames[0];
    if (time < vf.timestamp) return null;
    // 弹出第一帧
    this.#videoFrames.shift();
    // 第一帧过期,找下一帧
    if (time > vf.timestamp + (vf.duration ?? 0)) {
      vf.close();
      return await this.#parseFrame(time, dec, aborter);
    }

    if (!this.#predecodeErr && this.#videoFrames.length < 10) {
      // 预解码 避免等待
      this.#startDecode(dec).catch((err) => {
        this.#predecodeErr = true;
        this.#reset(time);
        throw err;
      });
    }
    // 符合期望
    return vf;
  }

  // 缺少帧数据
  if (
    this.#decoding ||
    (this.#outputFrameCnt < this.#inputChunkCnt && dec.decodeQueueSize > 0)
  ) {
    if (performance.now() - aborter.st > 6e3) {
      throw Error(
        `MP4Clip.tick video timeout, ${JSON.stringify(this.#getState())}`,
      );
    }
    // 解码中,等待,然后重试
    this.#sleepCnt += 1;
    await sleep(15);
  } else if (this.#videoDecCusorIdx >= this.samples.length) {
    // decode completed
    return null;
  } else {
    try {
      await this.#startDecode(dec);
    } catch (err) {
      this.#reset(time);
      throw err;
    }
  }
  return await this.#parseFrame(time, dec, aborter);
};

#parseFrame(time, dec, aborter)的第一优先级是消费 #videoFrames 缓存队列:

  • 若队列非空,取队头 vf = videoFrames[0]
  • time < vf.timestamp :说明 当前缓存最早帧都比目标 time 晚 ,直接返回 null
  • 否则 shift 弹出这一帧,然后判断是否“过期”:
    • time > vf.timestamp + (vf.duration ?? 0) :目标 time 已经超过这帧覆盖区间,close 掉继续递归找下一帧
    • 否则:这帧覆盖了 time ,直接返回它

因此,“寻找帧”的判定标准就是:vf.timestamp <= time <= vf.timestamp + vf.duration

如果缓存里没有帧,就推进解码(按 GOP 批量 decode)

当 #videoFrames 为空时, #parseFrame 会进入“要么等解码完成,要么启动新解码”的状态机:

  • 如果正在解码 / 或者 decodeQueue 里还有待输出:睡眠 15ms 再重试,并带 6s 超时保护
  • 如果 cursor 已经到 sample 末尾:返回 null
  • 否则调用 #startDecode(dec) 推进一段 GOP 解码

startDecode:如何切出一个 GOP、读数据、喂给 VideoDecoder(最关键的代码)

#startDecode = async (dec: VideoDecoder) => {
  if (this.#decoding || dec.decodeQueueSize > 600) return;

  // 启动解码任务,然后重试
  let endIdx = this.#videoDecCusorIdx + 1;
  if (endIdx > this.samples.length) return;

  this.#decoding = true;
  // 该 GoP 时间区间有时间匹配,且未被删除的帧
  let hasValidFrame = false;
  for (; endIdx < this.samples.length; endIdx++) {
    const s = this.samples[endIdx];
    if (!hasValidFrame && !s.deleted) {
      hasValidFrame = true;
    }
    // 找一个 GoP,所以是下一个 IDR 帧结束
    if (s.is_idr) break;
  }

  if (hasValidFrame) {
    const samples = this.samples.slice(this.#videoDecCusorIdx, endIdx);
    if (samples[0]?.is_idr !== true) {
      Log.warn('First sample not idr frame');
    } else {
      const readStarTime = performance.now();
      const chunks = await videosamples2Chunks(samples, this.localFileReader);

      const readCost = performance.now() - readStarTime;
      if (readCost > 1000) {
        const first = samples[0];
        const last = samples.at(-1)!;
        const rangSize = last.offset + last.size - first.offset;
        Log.warn(
          `Read video samples time cost: ${Math.round(readCost)}ms, file chunk size: ${rangSize}`,
        );
      }
      // Wait for the previous asynchronous operation to complete, at which point the task may have already been terminated
      if (dec.state === 'closed') return;

      this.#lastVfDur = chunks[0]?.duration ?? 0;
      decodeGoP(dec, chunks, {
        onDecodingError: (err) => {
          if (this.#downgradeSoftDecode) {
            throw err;
          } else if (this.#outputFrameCnt === 0) {
            this.#downgradeSoftDecode = true;
            Log.warn('Downgrade to software decode');
            this.#reset();
          }
        },
      });

      this.#inputChunkCnt += chunks.length;
    }
  }
  this.#videoDecCusorIdx = endIdx;
  this.#decoding = false;
};

首先需要先知道的几个个概念

  • 关键帧(sync frame) :能独立解码的帧,H.264/265 里通常是 IDR。代码里用 s.is_idr 作为 GOP 的分界。(更详细的解释看# 如何实现一个网页版的剪映(一)简介
  • GOP :从一个 IDR 到下一个 IDR 之前的那一段帧序列。解码时通常要从 GOP 开头开始喂数据,才能正确得到中间的 delta 帧。
  • cursor(游标) : #videoDecCusorIdx 表示“下一次准备从 samples 的哪个下标开始喂给解码器”。

先判断“要不要启动一次解码”

if (this.#decoding || dec.decodeQueueSize > 600return;
  • #decoding :防止重复并发启动(一次还没结束又启动一次)。
  • decodeQueueSize > 600 :解码器内部积压太多了就先别喂,避免爆内存/卡死。

确定这次要处理的范围:从 cursor 往后找到一个 GOP 的“结束位置”

let endIdx = this.#videoDecCusorIdx + 1;
...
for (; endIdx < this.samples.length; endIdx++) {
  const s = this.samples[endIdx];
  ...
  if (s.is_idrbreak;
}

endIdx 往后走,直到遇到下一个关键帧,这就相当于找到了 [start, endIdx) 这一段 GOP (从当前关键帧开始,到下一个关键帧之前结束)

喂给 VideoDecoder 解码(异步产出 VideoFrame)

this.#lastVfDur = chunks[0]?.duration ?? 0;
decodeGoP(dec, chunks, { onDecodingError ... });
this.#inputChunkCnt += chunks.length;

function decodeGoP(
  dec: VideoDecoder,
  chunks: EncodedVideoChunk[],
  opts: {
    onDecodingError?: (err: Error) => void;
  },
) {
  if (dec.state !== 'configured') return;
  for (let i = 0; i < chunks.length; i++) dec.decode(chunks[i]);

  // todo:flush 之后下一帧必须是 IDR 帧,是否可以根据情况再决定调用 flush?
  // windows 某些设备 flush 可能不会被 resolved,所以不能 await flush
  dec.flush().catch((err) => {
    if (!(err instanceof Error)) throw err;
    if (
      err.message.includes('Decoding error') &&
      opts.onDecodingError != null
    ) {
      opts.onDecodingError(err);
      return;
    }
    // reset 中断解码器,预期会抛出 AbortedError
    if (!err.message.includes('Aborted due to close')) {
      throw err;
    }
  });
}

decodeGoP 做的事很直接:

  • 循环 dec.decode(chunk) 把 chunk 都送进去
  • 调 dec.flush() (但不 await)让解码器尽快把队列处理完

重要: startDecode 本身 不会在这里等到 VideoFrame 真正出来 。帧是通过 new VideoDecoder({ output(vf) { ... } }) 的 output 回调异步推入 #videoFrames 缓存的。

最后推进 cursor,并解除 “正在解码” 状态

this.#videoDecCusorIdx = endIdx;
this.#decoding = false;

这一步非常关键:它决定下一次 startDecode 会从哪里继续喂下一段 GOP。

MediaBunny 是如何seek的

// 一次取单个时间点
const sample = await videoSink.getSample(1.25);

// 一次取多个时间点
for await (const sample of videoSink.samplesAtTimestamps([0.5, 1.0, 1.5])) {
console.log(sample); // MediaSample 或 null
}

MediaBunny 的 seek,和webav一样,本质是三步:

  1. 定位目标帧(target packet)
  2. 找到对应关键帧(key packet)
  3. 从关键帧开始解码到目标帧(按批次)

getSample底层会调mediaSamplesAtTimestamps这个函数,其中getKeyPacket就是获取关键帧的函数

mediaSamplesAtTimestamps部分代码如下

for await (const timestamp of timestampIterator) {
  // getPacket(timestamp) 取“表示这个时间点内容”的目标包。
  const targetPacket = await packetSink.getPacket(timestamp);
  // getKeyPacket(timestamp, { verifyKeyPackets: true })会定位该时间点可用的关键包,
  // 并校验关键包标记,避免容器元数据不准导致错误解码。
  const keyPacket =
    targetPacket &&
    (await packetSink.getKeyPacket(timestamp, {
      verifyKeyPackets: true,
    }));

  if (!keyPacket) {
    if (maxSequenceNumber !== -1) {
      await decodePackets();
      await flushDecoder();
    }

    pushToQueue(null);
    lastPacket = null;
    continue;
  }

  // 如果关键帧变了,或者请求时间戳发生“倒退”,说明不能继续复用当前这批解码状态,
  // 需要先把上一批收尾并清空解码器状态,再开启新批次。
  if (
    lastPacket &&
    (keyPacket.sequenceNumber !== lastKeyPacket!.sequenceNumber ||
      targetPacket.timestamp < lastPacket.timestamp)
  ) {
    await decodePackets();
    await flushDecoder(); // 这里始终 flush,一些解码器在这种切换场景下兼容性更好。
  }

  // 记录这个时间戳最终实际要匹配的样本起始时间。
  timestampsOfInterest.push(targetPacket.timestamp);
  // 批次终点取多个目标包中的最大序号,这样相邻请求可以复用同一轮解码。
  maxSequenceNumber = Math.max(
    targetPacket.sequenceNumber,
    maxSequenceNumber,
  );

  lastPacket = targetPacket;
  lastKeyPacket = keyPacket;
}

从关键帧解码到目标帧(核心)

// 下一批需要解码到的结束序号(包含)。
// 每一批都从 `lastKeyPacket` 开始,一直解码到这个序号为止。
let maxSequenceNumber = -1;

const decodePackets = async () => {
  // 从当前关键帧开始解码,确保解码器拥有正确的参考状态。
  let currentPacket = lastKeyPacket;
  decoder.decode(currentPacket);

  while (currentPacket.sequenceNumber < maxSequenceNumber) {
    // `computeMaxQueueSize()` 根据当前已解码样本数动态决定允许的总排队量,避免占用过多内存。
    const maxQueueSize = computeMaxQueueSize(sampleQueue.length);
    while (
      sampleQueue.length + decoder.getDecodeQueueSize() > maxQueueSize &&
      !terminated
    ) {
      // 队列太满时暂停继续喂包,等消费者取走一些样本后再继续。
      ({ promise: queueDequeue, resolve: onQueueDequeue } =
        promiseWithResolvers());
      await queueDequeue;
    }

    if (terminated) {
      break;
    }

    // `getNextPacket()` 按编码顺序拿到当前包之后的下一个包。
    const nextPacket = await packetSink.getNextPacket(currentPacket);

    decoder.decode(nextPacket);
    currentPacket = nextPacket;
  }

  maxSequenceNumber = -1;
};

getKeyPacket是怎么获取关键帧的

入口函数如下

async getKeyPacket(
  timestamp: number,
  options: PacketRetrievalOptions = {},
): Promise<EncodedPacket | null> {
  validateTimestamp(timestamp);
  validatePacketRetrievalOptions(options);

  if (this._track.input._disposed) {
    throw new InputDisposedError();
  }

  if (!options.verifyKeyPackets) {
    return this._track._backing.getKeyPacket(timestamp, options);
  }

  const packet = await this._track._backing.getKeyPacket(timestamp, options);
  if (!packet || packet.type === "delta") {
    return packet;
  }

  const determinedType = await this._track.determinePacketType(packet);
  if (determinedType === "delta") {
    // Try returning the previous key packet (in hopes that it's actually a key packet)
    return this.getKeyPacket(
      packet.timestamp - 1 / this._track.timeResolution,
      options,
    );
  }

  return packet;
}

this._track._backing.getKeyPacket是内部的函数,如果不做验证这帧是不是关键帧,就直接返回

但是会有这种情况:有些文件写错了 keyframe 标记,会返回“看起来是 key、实际是 delta”的包,导致解码器报错

接着,就会进行校验

  1. 先向下拿候选 key packet;
  2. 如果没拿到,或底层直接说它是 delta,就返回
  3. 如果底层说它是 key,那就“扒开码流看一眼”它到底是不是 key,会解析码流(比如 H.264 看有没有 IDR NAL)来判断

this._track._backing.getKeyPacket如下

/**
 * 按时间戳取“关键帧”数据包。
 *
 * 取包有两条路:
 * 1) 文件自带“索引表”(普通 MP4/MOV 常见):先用索引表找到这个时间附近的 sample,再往前退到最近的关键帧。
 * 2) 没有索引表、而是“分段存储”(fMP4 常见):就去各个分段里找“时间 <= 目标时间”的最后一个关键帧。
 */
async getKeyPacket(timestamp: number, options: PacketRetrievalOptions) {
  // 外部传进来的 timestamp 是“秒”,内部查找用的是 track 自己的时间单位(timescale)
  const timestampInTimescale = this.mapTimestampIntoTimescale(timestamp);

  // 先尝试走“索引表”这条更快的路:从索引表定位到 sample,再找到对应关键帧
  const sampleTable = this.internalTrack.demuxer.getSampleTableForTrack(
    this.internalTrack,
  );
  const sampleIndex = getSampleIndexForTimestamp(
    sampleTable,
    timestampInTimescale,
  );
  const keyFrameSampleIndex =
    sampleIndex === -1
      ? -1
      : getRelevantKeyframeIndexForSample(sampleTable, sampleIndex);
  const regularPacket = await this.fetchPacketForSampleIndex(
    keyFrameSampleIndex,
    options,
  );

  // 只要索引表里有内容,或者这个文件不是分段格式,就用上面这条“索引表”结果
  if (
    !sampleTableIsEmpty(sampleTable) ||
    !this.internalTrack.demuxer.isFragmented
  ) {
    return regularPacket;
  }

  // 索引表为空 + 分段格式:改为到分段里去找关键帧
  return this.performFragmentedLookup(
    null,
    (fragment) => {
      const trackData = fragment.trackData.get(this.internalTrack.id);
      if (!trackData) {
        return { sampleIndex: -1, correctSampleFound: false };
      }

      // 在这个分段里,从后往前找:
      // 最后一个 “是关键帧 && 时间戳 <= 目标时间” 的 sample
      const index = findLastIndex(trackData.presentationTimestamps, (x) => {
        const sample = trackData.samples[x.sampleIndex]!;
        return (
          sample.isKeyFrame && x.presentationTimestamp <= timestampInTimescale
        );
      });

      const sampleIndex =
        index !== -1
          ? trackData.presentationTimestamps[index]!.sampleIndex
          : -1;
      // 如果目标时间戳就在这个分段的时间范围内,说明已经找到“正确分段”了,不用继续翻别的分段
      const correctSampleFound =
        index !== -1 && timestampInTimescale < trackData.endTimestamp;

      return { sampleIndex, correctSampleFound };
    },
    timestampInTimescale,
    timestampInTimescale,
    options,
  );
}

讲讲关键的函数:

  • fetchPacketForSampleIndex按 sample 索引读取文件字节 + 组装 packet 元信息(也就是生成一个EncodedPacket对象,这是mediabunny封装的EncodedVideoChunk)
const packet = new EncodedPacket(
  data,
  sampleInfo.isKeyFrame ? 'key' : 'delta',
  timestamp,
  duration,
  sampleIndex,
  sampleInfo.sampleSize,
);
  • getSampleTableForTrack作用是: 为某个 MP4 track 构建并缓存 sample table(把 MP4 的 stbl(stts/ctts/stsz/stco/co64/stsc/stss 等)解析成内部可用的索引结构),把后续随机访问(按时间取包/找关键帧/取下一帧)需要的索引数据准备好
  • return this.performFragmentedLookup当常规 moov 里的 sample table 为空(或拿不到样本),但文件是 fragmented 的时候,就去扫描后续的 moof fragment,在其中找到时间戳 ≤ 目标时间的最近关键帧并返回对应的 packet

fragmented 指的是“分片/分段的 MP4”(常见叫 fMP4,fragmented MP4),也就是:媒体数据不是一次性在 moov 里用完整的 sample table 描述完,而是被切成很多段,每段用一个 moof (Movie Fragment box)+ 对应的 mdat 来描述/承载。

蓝牙GAP通用访问协议详解:从原理到多平台实战代码

在蓝牙开发中,很多开发者会困惑:“为什么设备能被搜索到?”“配对和连接的底层逻辑是什么?”“不同设备之间如何实现身份识别?”——这些问题的答案,都藏在GAP(Generic Access Profile,通用访问协议) 中。

GAP是蓝牙协议栈的基础协议之一,也是所有蓝牙设备(经典蓝牙、低功耗蓝牙BLE)必须遵循的“通用规则”。它不负责数据传输本身,却掌管着蓝牙设备的“对外交互”:从设备广播、被搜索,到配对认证、连接管理,每一步都离不开GAP的规范。可以说,GAP是蓝牙设备的“社交礼仪”,没有它,不同厂商的蓝牙设备就无法互联互通。

本文将从GAP的核心定义、核心功能入手,用通俗的语言拆解其工作原理,再结合iOS(OC)、Flutter、Android(Java)三种主流开发语言的实战代码,帮你快速掌握GAP协议的开发应用,解决蓝牙开发中“设备交互”的核心痛点。

注意:本文聚焦GAP协议的核心实战场景,代码示例均为基础可复用版本,适配经典蓝牙和BLE通用场景,可直接复制到项目中扩展使用。

一、先搞懂:GAP协议到底是什么?

1. 核心定义

GAP通用访问协议,本质是蓝牙设备之间“建立交互”的通用规范,它定义了蓝牙设备的角色、状态、交互流程,以及设备如何对外展示自己、与其他设备建立关联。

简单来说,GAP的作用就是“让两个蓝牙设备认识彼此、建立信任、搭建沟通的基础”。它位于蓝牙协议栈的最上层,直接面向应用层,所有蓝牙设备的“对外操作”(广播、扫描、配对、连接),都需要通过GAP协议来实现。

2. GAP的核心角色(必懂)

GAP定义了两种核心角色,所有蓝牙设备在交互时,必然处于其中一种(可动态切换),这是理解GAP的关键:

  • 广播者(Advertiser) :主动发送广播包,对外“自我介绍”的设备(如耳机、智能手表、BLE传感器),核心作用是让其他设备发现自己。对应之前提到的“从设备(Slave)”。
  • 扫描者(Scanner) :主动扫描周围的广播包,寻找其他设备的设备(如手机、平板),核心作用是发现广播者,进而发起连接。对应之前提到的“主设备(Master)”。

补充:同一台设备可以同时扮演两种角色(如手机既能扫描耳机,也能开启广播让其他设备发现),角色切换由应用层根据需求控制。

3. GAP的核心功能(开发重点)

GAP的所有功能,都围绕“设备交互”展开,核心可分为4类,也是开发中最常用的场景:

  1. 广播管理:广播者发送广播包(包含设备名称、MAC地址、服务UUID等信息),控制广播间隔、广播功率;
  2. 扫描管理:扫描者扫描周围的广播包,过滤目标设备,获取广播者的基础信息;
  3. 配对管理:实现设备间的身份认证,协商加密密钥,保存配对信息(避免重复配对);
  4. 连接管理:建立、维持、断开设备间的连接,管理连接状态(如连接成功、连接失败、断开重连)。

这里需要注意:GAP只负责“建立连接”,不负责“数据传输”;数据传输由后续的GATT协议负责,但GAP是GATT协议的前置基础——没有GAP建立的连接,GATT就无法传输数据。

二、GAP核心功能实战:多平台代码示例

下面针对GAP的4个核心功能,分别提供iOS(OC)、Flutter、Android(Java)的实战代码,覆盖“广播、扫描、配对、连接”全场景,代码可直接复用,重点标注GAP相关的核心API。

1. 功能1:广播管理(GAP广播者角色)

场景:让设备开启广播,对外发送“自我介绍”,供其他设备扫描发现(如BLE传感器主动广播自己的存在)。

(1)iOS(OC)—— BLE广播开启(GAP广播者)

// 导入GAP相关头文件(CoreBluetooth已封装GAP协议)
#import <CoreBluetooth/CoreBluetooth.h>

@interface GAPAdvertiserManager () <CBPeripheralManagerDelegate>
@property (nonatomic, strong) CBPeripheralManager *peripheralManager; // GAP广播核心管理器
@end

@implementation GAPAdvertiserManager

- (instancetype)init {
    self = [super init];
    if (self) {
        // 初始化GAP广播管理器(底层已实现GAP协议)
        self.peripheralManager = [[CBPeripheralManager alloc] initWithDelegate:self queue:nil options:nil];
    }
    return self;
}

// 监听广播管理器状态,状态就绪后开启广播(GAP核心操作)
- (void)peripheralManagerDidUpdateState:(CBPeripheralManager *)peripheral {
    if (peripheral.state == CBManagerStatePoweredOn) {
        NSLog(@"GAP广播者就绪,开始发送广播(GAP协议)");
        
        // 配置GAP广播包信息(符合GAP规范,包含设备名称、服务UUID)
        NSDictionary *advertisementData = @{
            // 设备名称(GAP广播包必填字段,供扫描者识别)
            CBAdvertisementDataLocalNameKey: @"GAP-Device",
            // 服务UUID(GAP广播包可选,用于过滤目标设备)
            CBAdvertisementDataServiceUUIDsKey: @[[CBUUID UUIDWithString:@"0000FFE0-0000-1000-8000-00805F9B34FB"]]
        };
        
        // 开启GAP广播(底层GAP协议自动处理广播信道、广播间隔)
        // 广播间隔默认由系统控制,可通过options参数自定义(如缩短间隔提升被发现速度)
        [self.peripheralManager startAdvertising:advertisementData];
    }
}

// 监听GAP广播开启结果
- (void)peripheralManagerDidStartAdvertising:(CBPeripheralManager *)peripheral error:(NSError *)error {
    if (error) {
        NSLog(@"GAP广播开启失败:%@", error.localizedDescription);
    } else {
        NSLog(@"GAP广播开启成功,在3个广播信道(37、38、39)发送广播(GAP规范)");
    }
}

// 停止GAP广播(GAP协议操作)
- (void)stopGAPAdvertising {
    if (self.peripheralManager.isAdvertising) {
        [self.peripheralManager stopAdvertising];
        NSLog(@"GAP广播已停止");
    }
}

@end

(2)Flutter—— BLE广播开启(GAP广播者,依赖flutter_blue_plus)

// 导入依赖(pubspec.yaml中添加:flutter_blue_plus: ^1.13.3)
import 'package:flutter_blue_plus/flutter_blue_plus.dart';

// 开启GAP广播(GAP广播者角色)
Future<void> startGAPAdvertising() async {
  // 检查蓝牙状态,开启蓝牙(GAP广播前提)
  if (await FlutterBluePlus.isOn == false) {
    await FlutterBluePlus.turnOn();
  }

  // 配置GAP广播包信息(符合GAP协议规范)
  Map<String, dynamic> gapAdvertisementData = {
    'localName': 'GAP-Device', // 设备名称(GAP必填)
    'serviceUuids': ['0000FFE0-0000-1000-8000-00805F9B34FB'], // 服务UUID(GAP可选)
    'manufacturerData': [0x00, 0x01] // 厂商数据(GAP扩展字段)
  };

  try {
    // 开启GAP广播(插件底层已封装GAP协议,自动处理广播逻辑)
    await FlutterBluePlus.startAdvertising(gapAdvertisementData);
    print("GAP广播开启成功,遵循GAP协议发送广播");
  } catch (e) {
    print("GAP广播开启失败:$e");
  }
}

// 停止GAP广播
Future<void> stopGAPAdvertising() async {
  if (await FlutterBluePlus.isAdvertising) {
    await FlutterBluePlus.stopAdvertising();
    print("GAP广播已停止");
  }
}

(3)Android(Java)—— BLE广播开启(GAP广播者)

// 导入GAP相关包(Android蓝牙API已封装GAP协议)
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.le.AdvertiseCallback;
import android.bluetooth.le.AdvertiseData;
import android.bluetooth.le.AdvertiseSettings;
import android.bluetooth.le.BluetoothLeAdvertiser;
import android.os.ParcelUuid;
import java.util.UUID;

// GAP广播者实现类
public class GAPAdvertiser {
    private BluetoothLeAdvertiser advertiser; // GAP广播核心对象

    // 开启GAP广播
    public void startGAPAdvertising() {
        BluetoothAdapter bluetoothAdapter = BluetoothAdapter.getDefaultAdapter();
        if (bluetoothAdapter == null || !bluetoothAdapter.isEnabled()) {
            System.out.println("蓝牙未开启,无法启动GAP广播");
            return;
        }

        // 获取GAP广播对象(仅BLE设备支持,经典蓝牙广播逻辑略有不同)
        advertiser = bluetoothAdapter.getBluetoothLeAdvertiser();
        if (advertiser == null) {
            System.out.println("设备不支持GAP广播");
            return;
        }

        // 配置GAP广播设置(符合GAP协议,控制广播功率、间隔)
        AdvertiseSettings gapAdvertiseSettings = new AdvertiseSettings.Builder()
                .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY) // 低延迟(优先被发现)
                .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH) // 高功率广播
                .setConnectable(true) // 可连接(GAP广播核心标识,说明设备可被连接)
                .build();

        // 配置GAP广播包数据(符合GAP规范)
        AdvertiseData gapAdvertiseData = new AdvertiseData.Builder()
                .setIncludeDeviceName(true) // 包含设备名称(GAP必填)
                .addServiceUuid(new ParcelUuid(UUID.fromString("0000FFE0-0000-1000-8000-00805F9B34FB"))) // 服务UUID
                .build();

        // 开启GAP广播(底层GAP协议自动处理广播信道、广播逻辑)
        advertiser.startAdvertising(gapAdvertiseSettings, gapAdvertiseData, new AdvertiseCallback() {
            @Override
            public void onStartSuccess(AdvertiseSettings settingsInEffect) {
                super.onStartSuccess(settingsInEffect);
                System.out.println("GAP广播开启成功,遵循GAP协议发送广播");
            }

            @Override
            public void onStartFailure(int errorCode) {
                super.onStartFailure(errorCode);
                System.out.println("GAP广播开启失败,错误码:" + errorCode);
            }
        });
    }

    // 停止GAP广播
    public void stopGAPAdvertising() {
        if (advertiser != null) {
            advertiser.stopAdvertising(new AdvertiseCallback() {});
            System.out.println("GAP广播已停止");
        }
    }
}

2. 功能2:扫描管理(GAP扫描者角色)

场景:设备主动扫描周围的GAP广播,发现目标设备,获取广播包中的设备信息(如设备名称、MAC地址),为后续配对、连接做准备(如手机扫描耳机)。

(1)iOS(OC)—— BLE扫描(GAP扫描者)

// 导入GAP相关头文件
#import <CoreBluetooth/CoreBluetooth.h>

@interface GAPScannerManager () <CBCentralManagerDelegate>
@property (nonatomic, strong) CBCentralManager *centralManager; // GAP扫描核心管理器
@end

@implementation GAPScannerManager

- (instancetype)init {
    self = [super init];
    if (self) {
        // 初始化GAP扫描管理器(底层已实现GAP扫描协议)
        self.centralManager = [[CBCentralManager alloc] initWithDelegate:self queue:nil options:@{CBCentralManagerOptionShowPowerAlertKey: @YES}];
    }
    return self;
}

// 监听扫描管理器状态,就绪后开始扫描(GAP核心操作)
- (void)centralManagerDidUpdateState:(CBCentralManager *)central {
    if (central.state == CBManagerStatePoweredOn) {
        NSLog(@"GAP扫描者就绪,开始扫描周围GAP广播(GAP协议)");
        
        // 开始GAP扫描(底层自动扫描3个广播信道,符合GAP规范)
        // options参数:设置是否允许重复扫描(NO表示只扫描一次,提升效率)
        [central scanForPeripheralsWithServices:nil options:@{CBCentralManagerScanOptionAllowDuplicatesKey: @NO}];
    }
}

// 发现GAP广播设备(GAP扫描核心回调,获取广播包信息)
- (void)centralManager:(CBCentralManager *)central didDiscoverPeripheral:(CBPeripheral *)peripheral advertisementData:(NSDictionary<NSString *,id> *)advertisementData RSSI:(NSNumber *)RSSI {
    // 从GAP广播包中获取设备信息(符合GAP协议规范的字段)
    NSString *deviceName = advertisementData[CBAdvertisementDataLocalNameKey] ?: @"未知设备";
    NSString *deviceUUID = peripheral.identifier.UUIDString; // 设备唯一标识(GAP协议定义)
    NSNumber *signalStrength = RSSI; // 信号强度(GAP广播包扩展字段)

    NSLog(@"发现GAP广播设备:名称=%@,UUID=%@,信号强度=%@ dBm", deviceName, deviceUUID, signalStrength);

    // 过滤目标设备(根据设备名称,符合GAP扫描逻辑)
    if ([deviceName isEqualToString:@"GAP-Device"]) {
        NSLog(@"发现目标GAP设备,停止扫描");
        [central stopScan]; // 停止扫描,准备发起连接
        // 后续可调用GAP连接、配对逻辑
    }
}

// 停止GAP扫描
- (void)stopGAPScanning {
    if (self.centralManager.isScanning) {
        [self.centralManager stopScan];
        NSLog(@"GAP扫描已停止");
    }
}

@end

(2)Flutter—— BLE扫描(GAP扫描者,依赖flutter_blue_plus)

// 导入依赖
import 'package:flutter_blue_plus/flutter_blue_plus.dart';

// 开始GAP扫描(扫描周围的GAP广播设备)
Future<void> startGAPScanning() async {
  // 检查蓝牙状态,开启蓝牙(GAP扫描前提)
  if (await FlutterBluePlus.isOn == false) {
    await FlutterBluePlus.turnOn();
  }

  // 开始GAP扫描(插件底层封装GAP协议,自动扫描3个广播信道)
  // timeout:扫描超时时间(10秒),符合GAP扫描效率规范
  FlutterBluePlus.startScan(timeout: const Duration(seconds: 10));
  print("GAP扫描已开始,正在扫描周围GAP广播设备");

  // 监听GAP扫描结果(获取广播包信息,符合GAP协议)
  FlutterBluePlus.scanResults.listen((List<ScanResult> results) {
    for (ScanResult result in results) {
      // 从GAP广播包中提取设备信息
      String deviceName = result.device.name ?? "未知设备";
      String deviceAddress = result.device.address; // 设备MAC地址(GAP协议定义)
      int signalStrength = result.rssi; // 信号强度

      print("发现GAP设备:名称=$deviceName,地址=$deviceAddress,信号强度=$signalStrength dBm");

      // 过滤目标GAP设备
      if (deviceName == "GAP-Device") {
        print("发现目标GAP设备,停止扫描");
        FlutterBluePlus.stopScan();
        // 后续可发起GAP连接、配对
      }
    }
  });
}

// 停止GAP扫描
Future<void> stopGAPScanning() async {
  if (FlutterBluePlus.isScanningNow) {
    FlutterBluePlus.stopScan();
    print("GAP扫描已停止");
  }
}

(3)Android(Java)—— BLE扫描(GAP扫描者)

// 导入GAP相关包
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.le.BluetoothLeScanner;
import android.bluetooth.le.ScanCallback;
import android.bluetooth.le.ScanResult;

// GAP扫描者实现类
public class GAPScanner {
    private BluetoothLeScanner scanner; // GAP扫描核心对象

    // 开始GAP扫描
    public void startGAPScanning() {
        BluetoothAdapter bluetoothAdapter = BluetoothAdapter.getDefaultAdapter();
        if (bluetoothAdapter == null || !bluetoothAdapter.isEnabled()) {
            System.out.println("蓝牙未开启,无法启动GAP扫描");
            return;
        }

        // 获取GAP扫描对象(底层已实现GAP扫描协议)
        scanner = bluetoothAdapter.getBluetoothLeScanner();
        if (scanner == null) {
            System.out.println("设备不支持GAP扫描");
            return;
        }

        // 开始GAP扫描(符合GAP规范,自动扫描3个广播信道)
        scanner.startScan(new ScanCallback() {
            @Override
            public void onScanResult(int callbackType, ScanResult result) {
                super.onScanResult(callbackType, result);
                // 从GAP广播包中提取设备信息(符合GAP协议)
                String deviceName = result.getDevice().getName() == null ? "未知设备" : result.getDevice().getName();
                String deviceAddress = result.getDevice().getAddress(); // 设备MAC地址(GAP定义)
                int signalStrength = result.getRssi(); // 信号强度

                System.out.println("发现GAP设备:名称=" + deviceName + ",地址=" + deviceAddress + ",信号强度=" + signalStrength + " dBm");

                // 过滤目标GAP设备
                if ("GAP-Device".equals(deviceName)) {
                    System.out.println("发现目标GAP设备,停止扫描");
                    stopGAPScanning();
                    // 后续可发起GAP连接、配对
                }
            }
        });

        System.out.println("GAP扫描已开始,遵循GAP协议扫描广播设备");
    }

    // 停止GAP扫描
    public void stopGAPScanning() {
        if (scanner != null) {
            scanner.stopScan(new ScanCallback() {});
            System.out.println("GAP扫描已停止");
        }
    }
}

3. 功能3:配对管理(GAP核心交互)

场景:扫描到目标设备后,通过GAP协议完成身份认证(配对),协商加密密钥,确保设备间的通信安全,这是GAP协议的核心安全功能。

(1)iOS(OC)—— GAP配对监听(系统自动处理配对流程)

// 继续使用上面的GAP扫描者管理器,连接后监听GAP配对状态
#import <CoreBluetooth/CoreBluetooth.h>

@interface GAPPairManager () <CBCentralManagerDelegate, CBPeripheralDelegate>
@property (nonatomic, strong) CBCentralManager *centralManager;
@property (nonatomic, strong) CBPeripheral *targetPeripheral; // 目标GAP设备
@end

@implementation GAPPairManager

// 连接目标GAP设备,触发GAP配对
- (void)connectToGAPDevice:(CBPeripheral *)peripheral {
    self.targetPeripheral = peripheral;
    self.targetPeripheral.delegate = self;
    // 发起GAP连接(连接成功后,系统自动触发GAP配对流程,符合GAP协议)
    [self.centralManager connectPeripheral:peripheral options:nil];
}

// GAP连接成功,开始监听配对状态(GAP配对核心回调)
- (void)centralManager:(CBCentralManager *)central didConnectPeripheral:(CBPeripheral *)peripheral {
    NSLog(@"GAP设备连接成功,触发GAP配对流程");
    // 发现设备服务(间接判断配对状态,符合GAP协议逻辑)
    [peripheral discoverServices:nil];
}

// 监听GAP配对状态(通过服务发现结果判断配对是否成功)
- (void)peripheral:(CBPeripheral *)peripheral didDiscoverServices:(NSError *)error {
    if (error) {
        NSLog(@"GAP配对失败:%@(可能未完成身份认证)", error.localizedDescription);
        return;
    }
    // 服务发现成功,说明GAP配对已完成(iOS系统自动处理配对弹窗,无需手动干预)
    NSLog(@"GAP配对成功,已完成身份认证,可进行后续数据传输");
}

// 辅助方法:判断设备是否已完成GAP配对
- (BOOL)isGAPPaired:(CBPeripheral *)peripheral {
    // GAP配对信息由系统保存,通过获取已连接设备列表判断
    NSArray *pairedPeripherals = [self.centralManager retrieveConnectedPeripheralsWithServices:nil];
    for (CBPeripheral *p in pairedPeripherals) {
        if ([p.identifier isEqualToString:peripheral.identifier]) {
            return YES;
        }
    }
    return NO;
}

@end

(2)Flutter—— GAP配对监听(依赖flutter_blue_plus)

// 导入依赖
import 'package:flutter_blue_plus/flutter_blue_plus.dart';

// 连接GAP设备并监听配对状态(GAP配对流程)
Future<void> connectAndMonitorGAPPairing(BluetoothDevice device) async {
  try {
    // 发起GAP连接(连接成功后,触发GAP配对流程)
    await device.connect();
    print("GAP设备连接成功,开始GAP配对");

    // 监听GAP配对状态(通过服务发现结果判断,符合GAP协议)
    device.discoverServices().then((List<BluetoothService> services) {
      if (services.isNotEmpty) {
        print("GAP配对成功,已完成身份认证,获取到设备服务");
      }
    }).catchError((error) {
      print("GAP配对失败:$error(可能未完成身份认证)");
    });

    // 监听GAP配对后的连接状态
    device.connectionState.listen((BluetoothConnectionState state) {
      if (state == BluetoothConnectionState.connected) {
        print("GAP配对后,设备保持连接状态");
      } else if (state == BluetoothConnectionState.disconnected) {
        print("GAP配对后连接断开,可尝试重新配对连接");
      }
    });
  } catch (e) {
    print("GAP设备连接失败,无法触发配对:$e");
  }
}

// 断开GAP配对连接
Future<void> disconnectGAPPairedDevice(BluetoothDevice device) async {
  if (device.connectionState == BluetoothConnectionState.connected) {
    await device.disconnect();
    print("GAP配对连接已断开");
  }
}

(3)Android(Java)—— GAP配对发起与监听

// 导入GAP配对相关包
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothGatt;
import android.bluetooth.BluetoothGattCallback;
import android.bluetooth.BluetoothProfile;
import android.content.Context;
import android.content.IntentFilter;
import android.content.BroadcastReceiver;
import android.content.Intent;

// GAP配对管理器(发起配对、监听配对状态)
public class GAPPairManager {
    private Context context;

    public GAPPairManager(Context context) {
        this.context = context;
    }

    // 发起GAP配对(经典蓝牙,符合GAP协议规范)
    public void startGAPPairing(BluetoothDevice device) {
        // 注册广播接收器,监听GAP配对状态(Android系统GAP配对回调)
        IntentFilter filter = new IntentFilter(BluetoothDevice.ACTION_PAIRING_REQUEST);
        context.registerReceiver(new BroadcastReceiver() {
            @Override
            public void onReceive(Context context, Intent intent) {
                String action = intent.getAction();
                if (BluetoothDevice.ACTION_PAIRING_REQUEST.equals(action)) {
                    // 取消系统默认配对弹窗,手动处理GAP配对(可选)
                    abortBroadcast();
                    BluetoothDevice pairedDevice = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE);
                    // 发起GAP配对(经典蓝牙需输入PIN码,符合GAP协议,此处以0000为例)
                    pairedDevice.setPin(new byte[]{0x30, 0x30, 0x30, 0x30});
                    pairedDevice.setPairingConfirmation(true);
                    System.out.println("GAP配对成功:" + pairedDevice.getName());
                }
            }
        }, filter);

        // 发起GAP配对请求(通过反射调用,符合GAP协议)
        try {
            java.lang.reflect.Method method = BluetoothDevice.class.getMethod("createBond");
            method.invoke(device);
            System.out.println("发起GAP配对请求:" + device.getName());
        } catch (Exception e) {
            e.printStackTrace();
            System.out.println("GAP配对请求失败:" + e.getMessage());
        }
    }

    // BLE设备GAP配对(连接后自动配对,符合GAP协议)
    public void connectAndPairGAPBLEDevice(BluetoothDevice device) {
        // 发起GAP连接,连接成功后自动触发配对
        device.connectGatt(context, false, new BluetoothGattCallback() {
            @Override
            public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) {
                super.onConnectionStateChange(gatt, status, newState);
                if (newState == BluetoothProfile.STATE_CONNECTED) {
                    System.out.println("GAP BLE设备连接成功,自动触发GAP配对");
                    gatt.discoverServices(); // 发现服务,确认配对成功
                } else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
                    System.out.println("GAP配对连接断开");
                }
            }

            @Override
            public void onServicesDiscovered(BluetoothGatt gatt, int status) {
                super.onServicesDiscovered(gatt, status);
                if (status == BluetoothGatt.GATT_SUCCESS) {
                    System.out.println("GAP BLE配对成功,已完成身份认证");
                } else {
                    System.out.println("GAP BLE配对失败,服务发现失败");
                }
            }
        });
    }
}

4. 功能4:连接管理(GAP协议收尾)

场景:GAP配对成功后,建立稳定的连接链路,管理连接状态(连接成功、断开、重连),为后续GATT数据传输提供基础。

(1)iOS(OC)—— GAP连接管理

// 继续使用GAPPairManager,完善GAP连接管理逻辑
#import <CoreBluetooth/CoreBluetooth.h>

@interface GAPConnectionManager () <CBCentralManagerDelegate, CBPeripheralDelegate>
@property (nonatomic, strong) CBCentralManager *centralManager;
@property (nonatomic, strong) CBPeripheral *connectedPeripheral; // 已连接的GAP设备
@end

@implementation GAPConnectionManager

// 发起GAP连接(配对后建立连接,符合GAP协议)
- (void)connectGAPDevice:(CBPeripheral *)peripheral {
    self.centralManager = [[CBCentralManager alloc] initWithDelegate:self queue:nil options:nil];
    if (self.centralManager.state == CBManagerStatePoweredOn) {
        NSLog(@"发起GAP连接:%@", peripheral.name);
        [self.centralManager connectPeripheral:peripheral options:nil];
    }
}

// GAP连接成功(GAP协议核心回调)
- (void)centralManager:(CBCentralManager *)central didConnectPeripheral:(CBPeripheral *)peripheral {
    self.connectedPeripheral = peripheral;
    peripheral.delegate = self;
    NSLog(@"GAP连接成功:%@,已建立稳定链路(GAP协议)", peripheral.name);
    // 发现设备服务,准备后续数据传输
    [peripheral discoverServices:nil];
}

// GAP连接失败(GAP协议异常处理)
- (void)centralManager:(CBCentralManager *)central didFailToConnectPeripheral:(CBPeripheral *)peripheral error:(NSError *)error {
    NSLog(@"GAP连接失败:%@,错误信息:%@", peripheral.name, error.localizedDescription);
    // 重试连接(符合GAP连接重试规范)
    [central connectPeripheral:peripheral options:nil];
}

// GAP连接断开(GAP协议异常处理)
- (void)centralManager:(CBCentralManager *)central didDisconnectPeripheral:(CBPeripheral *)peripheral error:(NSError *)error {
    NSLog(@"GAP连接断开:%@,错误信息:%@", peripheral.name, error.localizedDescription);
    self.connectedPeripheral = nil;
    // 重新扫描并连接(符合GAP连接管理逻辑)
    [central scanForPeripheralsWithServices:nil options:nil];
}

// 断开GAP连接
- (void)disconnectGAPDevice {
    if (self.connectedPeripheral && self.centralManager.isConnected(self.connectedPeripheral)) {
        [self.centralManager cancelPeripheralConnection:self.connectedPeripheral];
        NSLog(@"GAP连接已主动断开");
    }
}

@end

(2)Flutter—— GAP连接管理(依赖flutter_blue_plus)

// 导入依赖
import 'package:flutter_blue_plus/flutter_blue_plus.dart';

// GAP连接管理类
class GAPConnectionManager {
  BluetoothDevice? _connectedDevice; // 已连接的GAP设备

  // 发起GAP连接
  Future<void> connectGAPDevice(BluetoothDevice device) async {
    try {
      if (await FlutterBluePlus.isOn == false) {
        await FlutterBluePlus.turnOn();
      }
      // 发起GAP连接(符合GAP协议,配对后建立连接)
      await device.connect(autoConnect: false);
      _connectedDevice = device;
      print("GAP连接成功:${device.name},建立稳定链路");

      // 监听GAP连接状态变化
      device.connectionState.listen((BluetoothConnectionState state) {
        switch (state) {
          case BluetoothConnectionState.connected:
            print("GAP连接保持稳定");
            break;
          case BluetoothConnectionState.disconnected:
            print("GAP连接断开,尝试重连");
            reconnectGAPDevice(device); // 自动重连
            break;
          default:
            break;
        }
      });
    } catch (e) {
      print("GAP连接失败:$e");
    }
  }

  // GAP连接重连(符合GAP协议异常处理)
  Future<void> reconnectGAPDevice(BluetoothDevice device) async {
    try {
      await device.connect(autoConnect: true);
      _connectedDevice = device;
      print("GAP设备重连成功:${device.name}");
    } catch (e) {
      print("GAP设备重连失败:$e");
    }
  }

  // 断开GAP连接
  Future<void> disconnectGAPDevice() async {
    if (_connectedDevice != null &&
        _connectedDevice!.connectionState == BluetoothConnectionState.connected) {
      await _connectedDevice!.disconnect();
      _connectedDevice = null;
      print("GAP连接已主动断开");
    }
  }
}

(3)Android(Java)—— GAP连接管理

// 导入GAP连接相关包
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothGatt;
import android.bluetooth.BluetoothGattCallback;
import android.bluetooth.BluetoothProfile;
import android.content.Context;

// GAP连接管理器
public class GAPConnectionManager {
    private Context context;
    private BluetoothGatt gatt; // GAP连接核心对象

    public GAPConnectionManager(Context context) {
        this.context = context;
    }

    // 发起GAP连接(BLE设备,符合GAP协议)
    public void connectGAPDevice(BluetoothDevice device) {
        // 发起GAP连接,获取GATT对象(GAP连接的核心载体)
        gatt = device.connectGatt(context, false, new BluetoothGattCallback() {
            @Override
            public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) {
                super.onConnectionStateChange(gatt, status, newState);
                if (newState == BluetoothProfile.STATE_CONNECTED) {
                    System.out.println("GAP连接成功:" + gatt.getDevice().getName());
                    // 发现服务,准备数据传输
                    gatt.discoverServices();
                } else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
                    System.out.println("GAP连接断开,尝试重连");
                    reconnectGAPDevice(device); // 自动重连
                }
            }
        });
    }

    // GAP连接重连(符合GAP协议异常处理)
    public void reconnectGAPDevice(BluetoothDevice device) {
        if (gatt != null) {
            gatt.connect();
            System.out.println("GAP设备重连中:" + device.getName());
        } else {
            connectGAPDevice(device);
        }
    }

    // 断开GAP连接
    public void disconnectGAPDevice() {
        if (gatt != null) {
            gatt.disconnect();
            gatt.close();
            gatt = null;
            System.out.println("GAP连接已主动断开");
        }
    }
}

三、GAP开发注意事项(避坑重点)

  • GAP协议是“通用规范”,无论经典蓝牙还是BLE,都必须遵循,开发时无需区分,重点关注角色(广播者/扫描者)即可;
  • 广播包大小限制:GAP广播包最大31字节(BLE),经典蓝牙略大,开发时避免在广播包中携带过多数据,仅传递设备基础信息;
  • 配对流程:iOS/Flutter的GAP配对由系统自动处理,开发者仅能监听状态;Android可手动处理配对(如自定义PIN码),但需遵循GAP协议规范;
  • 连接稳定性:GAP连接后,需监听连接状态,实现重连逻辑,避免因设备远离、信号干扰导致连接断开;
  • 权限问题:多平台开发时,需申请蓝牙权限(如iOS的NSBluetoothAlwaysUsageDescription,Android的BLUETOOTH、BLUETOOTH_ADMIN等),否则无法正常使用GAP功能。

四、总结:GAP协议的核心价值

GAP通用访问协议,是蓝牙设备“互联互通”的基础——它定义了设备如何“自我介绍”(广播)、如何“寻找朋友”(扫描)、如何“建立信任”(配对)、如何“保持联系”(连接)。没有GAP,不同厂商、不同类型的蓝牙设备就无法相互识别、建立连接。

对于开发者而言,掌握GAP协议,就是掌握了蓝牙开发的“入门钥匙”:无论是智能硬件、物联网设备,还是手机端蓝牙应用,所有涉及“设备交互”的场景,都离不开GAP的核心操作。

手写 React 对比 VuReact 编译:真正省下来的是维护成本

📢 前言

很多人讨论 Vue 转 React,第一反应总是“能不能转”“转得快不快”“性能差多少”。

但如果你真的做过迁移,或者真的在 React 里维护过一批复杂组件,你很快会发现,最贵的往往不是第一次把组件写出来,而是之后每一次修改、交接、重构、补功能时,你还要不要重新审一遍 useCallbackuseMemo、依赖数组、事件回调和样式隔离。

所以这篇文章不讨论跑分,也不讨论玄学优化。我只想回答一个更实际的问题:

同一个组件,如果你手写 React,需要亲自维护的东西,是不是明显比“用 Vue 写输入,再交给 VuReact 编译”更多?

我的结论是:是,而且差距不小。VuReact 真正省下来的,不只是迁移动作本身,而是组件进入长期维护期之后,那些原本要由开发者脑补、手填、反复确认的成本。

比较口径说明

为了避免这篇文章变成情绪化宣传,我先把比较口径说清楚。

本文不比较运行时 benchmark,不比较“谁更现代”,也不假装手写 React 只有一种写法。这里比较的是典型工程实现下的维护成本,维度固定为:接口、回调、依赖、样板代码、样式隔离、运行时纯度。

维度 手写 React VuReact 编译路线
props 类型声明 需要手动设计和维护 defineProps / defineEmits 可映射为 TS 类型
事件回调 wiring 需要手动把事件改成 onXxx 编译阶段自动映射
Hook 依赖维护 需要开发者自己判断和补齐 编译阶段自动分析、自动注入
对象/数组 memo 判断 需要自己决定要不要包 useMemo 只对可分析的响应式表达式做优化
样式隔离处理 需要自己选方案并维护一致性 scoped 可直接落成带作用域标识的 CSS
最终产物纯度 取决于你的实现方式 输出就是纯 React,不带 Vue 运行时

也就是说,这篇文章不是在说“手写 React 不好”,而是在说:如果同样的业务目标可以用 Vue 输入 + VuReact 编译完成,那么你本来需要自己承担的维护义务,会少很多。

主证据样本:同一个组件,三种维护方式

我先拿一个综合样本来说话。这个样本不是极端 demo,而是很像真实业务组件:有 props、有 emits、有 ref、有 computed、有顶层箭头函数、有对象方法,还有 scoped 样式。

先看 Vue 输入。你会发现它本质上就是一个很正常的 Vue 3 组件,没有为了“迁移”刻意写成奇怪样子。

<template>
  <section class="counter-card">
    <h1>{{ props.title }}</h1>
    <h2>VuReact + Vue = React ({{ count }})</h2>
    <p>{{ title }}</p>
    <button @click="increment">+1</button>
    <button @click="methods.decrease">-1</button>
  </section>
</template>

<script setup lang="ts">
// @vr-name: HelloWorld
import { computed, ref, watch } from 'vue';

const props = defineProps<{ title?: string }>();
const emits = defineEmits<{ (e: 'update', value: number): void }>();

const step = ref(1);
const count = ref(0);
const title = computed(() => `阶数:x${step.value}`);

const increment = () => {
  count.value += step.value;
  emits('update', count.value);
};

const methods = {
  decrease() {
    count.value -= step.value;
    emits('update', count.value);
  },
};

watch(count, (newVal) => {
  step.value = Math.floor(newVal / 10) || 1;
});
</script>

<style scoped>
.counter-card { border: 1px solid #ddd; padding: 12px; }
</style>

如果这段逻辑让你手写成 React,一个很典型的等价实现,大概会长这样。注意,这不是“唯一正确写法”,而是一个工程上完全合理、也是多数团队都会接受的版本。

import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import './HelloWorld.css';

type IHelloWorldProps = {
  title?: string;
  onUpdate?: (value: number) => void;
};

const HelloWorld = memo((props: IHelloWorldProps) => {
  const [step, setStep] = useState(1);
  const [count, setCount] = useState(0);

  const title = useMemo(() => `阶数:x${step}`, [step]);

  const increment = useCallback(() => {
    setCount((prev) => {
      const next = prev + step;
      props.onUpdate?.(next);
      return next;
    });
  }, [step, props.onUpdate]);

  const methods = useMemo(
    () => ({
      decrease() {
        setCount((prev) => {
          const next = prev - step;
          props.onUpdate?.(next);
          return next;
        });
      },
    }),
    [step, props.onUpdate],
  );

  useEffect(() => {
    setStep(Math.floor(count / 10) || 1);
  }, [count]);

  return (
    <section className="counter-card">
      <h1>{props.title}</h1>
      <h2>VuReact + Vue = React ({count})</h2>
      <p>{title}</p>
      <button onClick={increment}>+1</button>
      <button onClick={methods.decrease}>-1</button>
    </section>
  );
});

再看 VuReact 的编译产物。这里最关键的不是“它也能跑”,而是它并没有牺牲 React 工程质量。你在 React 里想要的 memouseComputed/useVRefuseCallbackuseMemo、类型接口、样式作用域,它都完整落下来了。

import { useComputed, useVRef, useWatch } from '@vureact/runtime-core';
import { memo, useCallback, useMemo } from 'react';
import './HelloWorld-ebf8d8dc.css';

export type IHelloWorldProps = {
  title?: string;
} & {
  onUpdate?: (value: number) => void;
};

const HelloWorld = memo((props: IHelloWorldProps) => {
  const step = useVRef(1);
  const count = useVRef(0);
  const title = useComputed(() => `阶数:x${step.value}`);

  const increment = useCallback(() => {
    count.value += step.value;
    props.onUpdate?.(count.value);
  }, [count.value, step.value, props.onUpdate]);

  const methods = useMemo(
    () => ({
      decrease() {
        count.value -= step.value;
        props.onUpdate?.(count.value);
      },
    }),
    [count.value, step.value, props.onUpdate],
  );

  useWatch(count, (newVal) => {
    step.value = Math.floor(newVal / 10) || 1;
  });
});

这时候真正值得看的,不是“哪段代码更短”,而是“哪些维护动作必须由人来做”。按上面这个样本的可见代码统计:

指标 手写 React Vue 输入 + VuReact
显式优化 API 数量 5 处:memo、2 处 useMemouseCallbackuseEffect 0 处由开发者手写
需要手填的依赖数组项数量 6 项 0 项
与稳定性相关的样板代码行数 约 18 行 0 行由开发者额外维护
需要开发者主动判断的优化点数量 至少 5 个 0 个优化判断点

这个表的意义很直接:VuReact 不是帮你“少写一点 React 语法”,而是帮你少承担一整套组件级维护义务。你不用亲自决定标题该不该 useMemo,不用亲自判断回调依赖要不要补 onUpdate,也不用在每次改业务时重新审一遍数组是不是还正确。

次证据样本:连 slot 到 children 的接口翻译,也会更顺

如果只聊 Hook,你可能会以为这件事只是“少写几个依赖数组”。其实不是。组件接口设计本身,也会因为 VuReact 变得更顺。

以插槽为例,Vue 里的默认插槽会自然映射成 React 的 children,作用域插槽会映射成带参数的函数 children。也就是说,VuReact 帮你省掉的,不只是底层优化,还有内容分发接口的手工翻译成本。

例如:

<slot></slot> 会直接落成 props.children

<slot :item="item" :index="i"></slot> 会落成 props.children?.({ item, index })

这件事看起来小,实际在大型组件库里特别重要。因为你少做的不是一行改写,而是少做一次“我要把 Vue 的内容分发机制手工翻成 React 接口”的设计工作。对于需要交给别人继续维护的组件,这种接口自然度非常值钱。

工程上更关键的一点:产物是纯 React,不是套壳

很多“转换工具”最让人不放心的地方,不在于能不能跑,而在于它最后到底给你留下了什么。

VuReact 在这一点上的边界其实很清楚:官方文档明确强调,编译产物最终为纯 React 应用,不依赖 Vue 运行时,也不是在 React 中嵌入 Vue 容器的套壳方案。

这句话为什么重要?因为它直接决定了后续维护体验。

如果最终产物是双运行时桥接,短期也许能演示,但长期一定会出现调试复杂、性能归因困难、团队协作断层的问题。可如果最终产物就是标准 React 代码,那它就能直接进入你现有的 React 工具链、code review 流程和长期演进路径。

这也是为什么我更愿意用官网那四个词来概括 VuReact:语义感知、渐进迁移、约定驱动、完整特性适配。 它不是在做“表面可运行”,而是在做“可进入工程维护周期的 React 产物”。

为什么这对团队比对个人更重要

个人开发者感受到的是轻松,团队感受到的则是确定性。

对 code review 来说,少一些手工 memo 和依赖数组,意味着 review 的注意力可以更多放回业务本身,而不是反复检查“这里是不是漏依赖了”。对交接来说,新同事看到的是更稳定的输入约定和更标准的输出产物,而不是一堆高度依赖原作者经验的 React 小技巧。

对重构来说,成本差异更明显。手写 React 组件经常让人不敢轻动,因为你一改业务结构,就可能牵动 useMemouseCallbackuseEffect 的依赖关系。VuReact 让这类稳定性工作前移到编译阶段,本质上是在降低重构的心理门槛。

对迁移路线也是一样。你当然可以手写一个组件、十个组件,但当项目规模上来之后,真正难的不是有没有人会写 React,而是有没有办法把大量“手工判断”变成稳定流程。VuReact 的价值,恰恰就在这里。

下一步怎么验证

如果你想判断这是不是适合你的路线,最好的方法不是继续看宣传语,而是直接去看真实产物。

先看官网的 语义编译对照 和 “为什么选 VuReact”,确认它是不是你认同的工程思路;再看 GitHub 和在线演示,判断编译后的 React 项目是不是你愿意接手维护的样子;如果还想继续深挖,可以再读我前面写过的那篇 “证据链” 文章,专门看 Hook 和依赖数组那一层的负担差异。

官网GitHub在线演示(CRM)在线演示(Customer Support Hub)

💬 写在最后

VuReact 的初心一直没有变——让你用熟悉的 Vue 编写 React,同时让项目平滑迁移到 React 生态,降低迁移成本,保留开发体验

它是一款面向 Vue 转 React 编译工具,它能将 Vue 3 代码编译为标准、可维护的纯 React 。

🌐 Github:github.com/vureact-js/… 📃 官方文档:vureact.top

✨ 如果你觉得本文对你理解 VuReact 有帮助,欢迎点赞、收藏、关注!Github 仓库点亮 Star ⭐!

3 个命令 7 个步骤,学会 git worktree 并行开发

大家好,我是双越。wangEditor 作者,前百度 滴滴 资深前端工程师,慕课网金牌讲师,PMP,前端面试派 作者。

我正致力于两个项目的开发和升级,感兴趣的可以私信我,加入项目小组。

  • 【划水AI】 Node 全栈 AIGC 知识库,包括 AI 写作、多人协同编辑。复杂业务,真实上线。
  • 【智语】 AI Agent 智能体项目。一个智能面试官,可以优化简历、模拟面试、解答题目等。

开始

你可以一边修复 bug ,同时一边开发新功能,反正两个目录互不影响。

在过去的传统开发模式中,大多数程序员使用 git branch 就已经足够:

  • 开一个功能分支(feature)
  • 开发完成后提交 PR
  • 合并回主分支(main)

这种方式简单直接,在单任务开发场景下非常高效。

但现在情况变了。

随着 AI 编程工具(如 ChatGPT、Claude Code、Copilot)的普及,开发模式正在发生变化:

  • 同时开发多个功能
  • 多个 AI 对话并行执行任务
  • 长时间等待模型响应
  • 需要频繁切换上下文

👉 这时候,传统的 git branch 就开始显得不够用了。

于是,一个很多人没用过的工具开始进入视野:git worktree

很多程序员甚至从没听说过它,但它其实早就内置在 Git 中。

本文将从实战角度,带你彻底理解并掌握它。

Git branch 的不足

我们先看一个真实开发场景:你正在开发功能 A,突然被要求紧急修复 bug1。

传统做法

当前在 feature-a 分支,开发到一半儿了。得先缓存当前代码,再切换到 bugfix-1 分支。

git stash
git checkout -b bugfix-1

或者直接提交当前修改,然后切换到 bugfix-1 分支。

git commit -m "WIP"
git checkout -b bugfix-1

存在的问题

1. 上下文被打断

  • stash 容易忘内容
  • commit 会污染历史

2. 切换成本高

频繁的 stashpop ,容易冲突、出错

git checkout feature-A
git stash pop

3. 无法并行开发

你同一时间只能处理一个分支:

  • 不能一边开发 A,一边修 bug
  • 不能同时跑多个 AI 任务

本质原因

branch 是“逻辑切换”,不是“物理隔离”。

worktree 的价值

👉 它解决的是:让你可以“同时在多个分支开发”,而不是来回切换

worktree 的基本使用

核心概念

  • branch:代码版本线(逻辑)
  • worktree:工作目录(物理)

👉 一个仓库可以有多个 worktree

add 命令

创建一个新的 worktree 并同时创建一个新分支(惯例)

git worktree add ../project-A -b feature-A

PS. 一个分支不能同时在多个 worktree 打开,git 会提示 fatal: 'feature-A' is already checked out at '/Users/xxx/xxx/project-A'

list 命令

列出当前所有的 worktree 目录

git worktree list

remove 命令

删除一个 worktree:

git worktree remove ../project-A

表面现象

你会看到三个同级别的文件夹,其中 /project 就是原始项目的文件夹。看起来像多个独立项目。

/project
/project-A
/project-bugfix

本质

多个目录,共享同一个 Git 仓库 —— 这很重要。

不是执行了三次 git clone 创建的三个独立仓库,不是!

多目录如何实现共享同一个 git 内容

主仓库的 .git 目录,管理着所有 git 数据。这个程序员都知道。

/project/.git/

新建的 worktree 仓库中,有一个 .git 文件。注意,这是一个文件,不是目录。

/project-A/.git

内容类似如下,连接到主仓库的 git 目录中

gitdir: /project/.git/worktrees/project-A

主仓库里多了如下内容,用于绑定 worktree 的状态信息

/project/.git/worktrees/project-A/

这是 git 专门设计的,并不是操作系统的 symlink 软链接。

举例说明(完整流程)

场景

这是一个实际的开发场景:

  • 从 main 开始
  • 开发 A 功能
  • 中途修 bug1
  • bug 合并回 main
  • 继续开发 A
  • A 合并回 main

Step 1:初始化

从 main 分支开始

git checkout main
git pull

Step 2:开始开发 A

新建一个 worktree project-A 同时新建一个 feature-A 分支

git worktree add ../project-A -b feature-A

Step 3:出现 bug

此时你不需要 git stashgit commit

你直接新建一个 worktree ,同时基于 main 分支新建一个 bugfix-1 分支即可。不影响当前分支。

git worktree add ../project-bugfix -b bugfix-1 main

Step 4:修复 bug

在新的 worktree 修复 bug ,提交代码。正常开发流程。

cd ../project-bugfix
git add .
git commit -m "fix: bug1"

Step 5:合并 bug 代码

在任何一个 worktree ,将 bugfix-1 分支的代码合并到 main 分支。或者提交 PR 合并都可以。

cd ../project
git checkout main
git pull
git merge bugfix-1
git push

合并以后,就可以删掉这个 worktree 和分支

git worktree remove ../project-bugfix
git branch -D bugfix-1

Step 6:回到 A 功能

回到 A 功能的 worktree 目录,同步最新的 main 分支代码

cd ../project-A
git fetch
git rebase origin/main

然后继续开发 A 功能的代码。中间如果再有 bug 修复,还是参照上文的步骤。

甚至,你可以一边修复 bug ,同时一边开发 A 功能,反正两个目录互不影响。

Step 7:完成 A 并合并

等待 A 功能开发完了,提交代码,合并到 main 分支。

git add .
git commit -m "feat: A done"

cd ../project
git checkout main
git pull

git merge feature-A
git push

最后删掉 worktree 和分支

git worktree remove ../project-A
git branch -D feature-A

以上就是 git worktree 的基础使用,亲自操作一边,肯定已经学会了。

非 git 管理的文件

以上讨论的都是 git 管理的文件,还有写文件和目录不是 git 管理的,例如 .envnode_modules

注意,这些文件 git 不管理,worktree 当然也就不会管理它们,需要你自己手动处理。

.env

.env 这种单个文件,推荐使用软链接,直接链接到主仓库的原始文件

ln -s ../project/.env ../project-A/.env

node_modules

node_modules 目录的内容太多,而且可能还有版本一致性问题,推荐重复安装。
即新建一个 worktree 之后,在新目录下重新安装。

这里推荐使用 pnpm 安装 pnpm install ,它的优势:

  • 全局缓存
  • 节省空间
  • 安装速度快

最后

学会 worktree,你将获得:

  • 真正的并行开发能力
  • 更好的 AI 编程体验
  • 更清晰的开发上下文隔离

最后,worktree 不是替代 branch,而是让 branch 发挥最大价值。

前端监控体系

引言

在现代前端开发中,监控体系是保障用户体验和系统稳定性的关键基础设施。一个完善的前端监控体系能够帮助我们及时发现性能问题、定位错误根源、理解用户行为,从而持续优化产品体验。本文将深入探讨前端监控的三大核心维度:性能监控、错误监控和行为监控。

一、性能监控

1.1 核心性能指标

首屏加载时间 ( FCP - First Contentful Paint )

使用 Performance API 获取 FCP:

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.name === 'first-contentful-paint') {
      console.log(`FCP: ${entry.startTime}ms`);
      reportMetric('fcp', entry.startTime);
    }
  }
});
observer.observe({ entryTypes: ['paint'] });

最大内容绘制 ( LCP - Largest Contentful Paint )

const lcpObserver = new PerformanceObserver((list) => {
  const entries = list.getEntries();
  const lastEntry = entries[entries.length - 1];
  console.log(`LCP: ${lastEntry.startTime}ms`);
  reportMetric('lcp', lastEntry.startTime);
});
lcpObserver.observe({ entryTypes: ['largest-contentful-paint'] });

累积布局偏移 ( CLS - Cumulative Layout Shift )

let clsValue = 0;
const clsObserver = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (!entry.hadRecentInput) {
      clsValue += entry.value;
      console.log(`CLS: ${clsValue}`);
      reportMetric('cls', clsValue);
    }
  }
});
clsObserver.observe({ entryTypes: ['layout-shift'] });

1.2 自定义性能埋点

class PerformanceMonitor {
  constructor() {
    this.metrics = {};
  }

  recordPageLoad() {
    const timing = performance.timing;
    const metrics = {
      dnsLookup: timing.domainLookupEnd - timing.domainLookupStart,
      tcpConnect: timing.connectEnd - timing.connectStart,
      domParse: timing.domComplete - timing.domLoading,
      fullLoad: timing.loadEventEnd - timing.navigationStart
    };
    
    Object.entries(metrics).forEach(([key, value]) => {
      this.reportMetric(`page_${key}`, value);
    });
  }

  recordResourceLoad() {
    performance.getEntriesByType('resource').forEach(resource => {
      if (resource.duration > 1000) {
        this.reportMetric('slow_resource', {
          name: resource.name,
          duration: resource.duration,
          type: resource.initiatorType
        });
      }
    });
  }

  reportMetric(name, value) {
    fetch('/api/metrics', {
      method: 'POST',
      body: JSON.stringify({ name, value, timestamp: Date.now() }),
      headers: { 'Content-Type': 'application/json' }
    });
  }
}

const perfMonitor = new PerformanceMonitor();
perfMonitor.recordPageLoad();
perfMonitor.recordResourceLoad();

二、错误监控

2.1 全局错误捕获

JavaScript 运行时错误

window.addEventListener('error', (event) => {
  reportError({
    type: 'runtime',
    message: event.message,
    filename: event.filename,
    lineno: event.lineno,
    colno: event.colno,
    stack: event.error?.stack,
    timestamp: Date.now()
  });
}, true);

window.addEventListener('unhandledrejection', (event) => {
  reportError({
    type: 'promise',
    message: event.reason?.message || 'Unhandled Promise Rejection',
    stack: event.reason?.stack,
    timestamp: Date.now()
  });
});

Vue 错误捕获

import { createApp } from 'vue';

const app = createApp(App);

app.config.errorHandler = (err, instance, info) => {
  reportError({
    type: 'vue',
    message: err.message,
    stack: err.stack,
    component: instance?.name || 'Anonymous',
    lifecycleHook: info,
    timestamp: Date.now()
  });
};

React 错误边界

import React from 'react';

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false, error: null };
  }

  static getDerivedStateFromError(error) {
    return { hasError: true, error };
  }

  componentDidCatch(error, errorInfo) {
    reportError({
      type: 'react',
      message: error.message,
      stack: error.stack,
      componentStack: errorInfo.componentStack,
      timestamp: Date.now()
    });
  }

  render() {
    if (this.state.hasError) {
      return <div>页面出错了,请稍后刷新</div>;
    }
    return this.props.children;
  }
}

2.2 资源加载错误

window.addEventListener('error', (event) => {
  if (event.target instanceof HTMLImageElement) {
    reportError({
      type: 'resource',
      resourceType: 'image',
      url: event.target.src,
      timestamp: Date.now()
    });
  }
}, true);

const originalFetch = window.fetch;
window.fetch = async function(...args) {
  try {
    const response = await originalFetch(...args);
    if (!response.ok) {
      reportError({
        type: 'http',
        url: args[0],
        status: response.status,
        method: args[1]?.method || 'GET'
      });
    }
    return response;
  } catch (error) {
    reportError({
      type: 'http',
      url: args[0],
      message: error.message,
      method: args[1]?.method || 'GET'
    });
    throw error;
  }
};

三、行为监控

3.1 用户交互追踪

class BehaviorTracker {
  constructor() {
    this.sessionId = this.generateSessionId();
    this.pageViewTime = 0;
  }

  trackClick(element) {
    const eventData = {
      type: 'click',
      element: element.tagName,
      className: element.className,
      text: element.textContent?.slice(0, 50),
      x: element.getBoundingClientRect().left,
      y: element.getBoundingClientRect().top,
      pageUrl: window.location.href,
      timestamp: Date.now()
    };
    this.reportBehavior(eventData);
  }

  trackPageView() {
    const startTime = Date.now();
    
    window.addEventListener('beforeunload', () => {
      const duration = Date.now() - startTime;
      this.reportBehavior({
        type: 'pageview',
        url: window.location.href,
        duration,
        sessionId: this.sessionId,
        timestamp: Date.now()
      });
    });
  }

  trackScroll() {
    let scrollTimeout;
    window.addEventListener('scroll', () => {
      clearTimeout(scrollTimeout);
      scrollTimeout = setTimeout(() => {
        const scrollDepth = Math.round(
          (window.scrollY / (document.documentElement.scrollHeight - window.innerHeight)) * 100
        );
        this.reportBehavior({
          type: 'scroll',
          scrollDepth,
          timestamp: Date.now()
        });
      }, 500);
    });
  }

  reportBehavior(data) {
    navigator.sendBeacon('/api/behavior', JSON.stringify(data));
  }

  generateSessionId() {
    return `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
  }
}

const tracker = new BehaviorTracker();
tracker.trackPageView();
tracker.trackScroll();

document.addEventListener('click', (event) => {
  tracker.trackClick(event.target);
});

3.2 性能与行为关联分析

class Analytics {
  constructor() {
    this.userActions = [];
  }

  recordAction(action) {
    this.userActions.push({
      ...action,
      sessionId: this.getSessionId(),
      userId: this.getUserId()
    });
  }

  getSessionId() {
    return localStorage.getItem('session_id') || 
           (localStorage.setItem('session_id', `sess_${Date.now()}`), 
            localStorage.getItem('session_id'));
  }

  getUserId() {
    return localStorage.getItem('user_id') || 'anonymous';
  }

  analyzePerformanceAfterAction(actionType) {
    const actions = this.userActions.filter(a => a.type === actionType);
    const recentActions = actions.slice(-10);
    
    return {
      avgResponseTime: recentActions.reduce((sum, a) => sum + (a.responseTime || 0), 0) / recentActions.length,
      errorRate: recentActions.filter(a => a.error).length / recentActions.length
    };
  }
}

四、监控数据上报与可视化

4.1 统一上报服务

class MonitorService {
  constructor(config) {
    this.endpoint = config.endpoint;
    this.appId = config.appId;
    this.queue = [];
    this.flushInterval = 5000;
    
    this.init();
  }

  init() {
    setInterval(() => this.flush(), this.flushInterval);
    window.addEventListener('beforeunload', () => this.flush());
  }

  report(type, data) {
    const payload = {
      appId: this.appId,
      type,
      data,
      timestamp: Date.now(),
      userAgent: navigator.userAgent,
      url: window.location.href,
      referrer: document.referrer
    };
    
    this.queue.push(payload);
    
    if (type === 'error') {
      this.flush();
    }
  }

  flush() {
    if (this.queue.length === 0) return;
    
    const data = [...this.queue];
    this.queue = [];
    
    navigator.sendBeacon(`${this.endpoint}/batch`, JSON.stringify(data));
  }
}

const monitor = new MonitorService({
  endpoint: 'https://monitor.example.com',
  appId: 'frontend-app-001'
});

4.2 告警规则

const alertRules = {
  errorRate: { threshold: 0.05, window: 300 },
  fcp: { threshold: 2500 },
  lcp: { threshold: 4000 },
  cls: { threshold: 0.25 },
  apiErrorRate: { threshold: 0.1, window: 60 }
};

function checkAlerts(metric, value) {
  const rule = alertRules[metric];
  if (!rule) return;
  
  if (value > rule.threshold) {
    sendAlert({
      metric,
      value,
      threshold: rule.threshold,
      timestamp: Date.now()
    });
  }
}

总结

一个完善的前端监控体系应该包含:

  1. 性能监控:关注 FCP、LCP、CLS 等核心指标,持续优化加载体验
  2. 错误监控:全面捕获运行时错误、资源错误、HTTP 错误,快速定位问题
  3. 行为监控:追踪用户交互,理解用户行为模式

通过统一的数据上报和告警机制,我们可以:

  • 及时发现并修复问题
  • 持续优化性能表现
  • 提升用户体验
  • 降低运维成本

记住:监控不是为了发现问题,而是为了预防问题。建立完善的监控体系,让前端开发更加从容!

GLB 模型压缩 — 完整流程与代码映射

本文档记录 3D 预览模块中 GLB 模型从"原始高精度"到"浏览器可用"的完整压缩流程,每个环节都标注对应的源文件和行号


1. 为什么要压缩

问题现象

移动视角和缩放时明显卡顿,帧率极低。

诊断方式

src/three-preview/composables/useThreeOverview.ts L105-116buildOverview 完成后打印场景统计:

let totalTris = 0, drawCalls = 0
overviewGroup.traverse((child) => {
  const mesh = child as THREE.InstancedMesh
  if (!mesh.isMesh && !mesh.isInstancedMesh) return
  const geo = (mesh as THREE.Mesh).geometry
  if (!geo?.index) return
  const tris = geo.index.count / 3
  const count = mesh.isInstancedMesh ? mesh.count : 1
  totalTris += tris * count
  drawCalls++
})
console.log(`[3D] draw calls: ${drawCalls}, total triangles: ${(totalTris/1000).toFixed(1)}k, nodes: ${nodeMapped.length}`)

诊断结果

draw calls: 6, total triangles: 201496.3k, nodes: 153

2 亿个三角面。153 个节点,每个模型约 130 万面。GPU 每帧需要处理 2 亿个三角形,任何显卡都无法流畅渲染。

原始模型数据

模型 GLB 文件路径 原始大小 估算三角面数
三通 public/models/三通.glb 78.4 MB ~130 万
冷却塔 public/models/冷却塔.glb 74.9 MB ~130 万
冷水机组 public/models/冷水机组.glb 78.7 MB ~130 万
水泵 public/models/水泵.glb 24.1 MB ~40 万

这些精度对于 CAD 工程是必要的,但对于浏览器 3D 渲染完全不必要。

代码中的模型路径映射

src/three-preview/config/modelMapping.ts L7-28

export const MODEL_MAPPINGS: ModelMapping[] = [
  { nodeType: '冷却塔',   modelPath: '/models/冷却塔.glb',   scale: 1.0 },
  { nodeType: '冷水机组', modelPath: '/models/冷水机组.glb', scale: 1.0 },
  { nodeType: '水泵',     modelPath: '/models/水泵.glb',     scale: 1.0 },
  { nodeType: '三通',     modelPath: '/models/三通.glb',     scale: 1.0 },
]

压缩后的文件直接替换 public/models/ 下的同名文件,代码无需修改。


2. 压缩原理

两个瓶颈

瓶颈 原因 解决方案
几何体面数(顶点数量) GPU 顶点着色器对每个顶点运行一次,顶点越多每帧计算量越大 减面(Simplification)
文件传输体积(贴图+几何体数据) 原始 GLB 中贴图未压缩、几何体是原始浮点数组,体积大且占显存 贴图压缩(WebP) + 几何体压缩(Meshopt)

两阶段处理流程

原始 GLB (78MB, 130万面)
    │
    ↓ 第一阶段:减面 (simplify)
    │   算法:QEM(二次误差度量)— 迭代折叠代价最小的边
    │   参数:--ratio 0.03(保留 3%)、--error 0.01
    │
中间 GLB (33MB, ~3.9万面)
    │
    ↓ 第二阶段:全量压缩 (optimize)
    │   包含 9 个子步骤(见下表)
    │
最终 GLB (0.9MB, ~3.9万面, Meshopt编码 + WebP贴图)

optimize 命令的 9 个子步骤

序号 步骤 作用
1 dedup 去除重复的网格、材质、贴图
2 instance 对场景内重复的网格自动转为 GPU instancing
3 flatten 展平场景层级,减少节点数量
4 join 合并使用相同材质的相邻 mesh
5 weld 焊接重叠顶点(去除 T 形接缝冗余顶点)
6 simplify 再次轻量减面(对前面未做减面的 mesh)
7 prune 删除场景中未被引用的节点、材质、贴图
8 textureCompress 贴图转为 WebP(比 PNG 小 70-80%)
9 meshopt 几何体数据用 Meshopt 压缩编码

Meshopt 压缩原理

原始几何体存储为浮点数组(每个顶点 3×4 字节)。Meshopt 先做 quantization(将浮点精度从 32bit 降至 16bit 或更低),再用 LZ4 变体算法压缩字节流。GPU 加载时由解码器实时还原,性能影响可忽略。


3. 操作步骤

工具

使用 @gltf-transform/cli,无需安装,直接用 npx 运行:

npx @gltf-transform/cli --version
# 4.3.0

第一阶段:减面

npx @gltf-transform/cli simplify \
  --ratio 0.03 \
  --error 0.01 \
  input.glb output_simplified.glb

参数说明:

参数 含义
--ratio 0.03 保留原始面数的 3%。俯视全览场景模型较小,3% 即可保持可辨识轮廓
--error 0.01 允许的最大几何误差(相对于模型包围盒大小),值越小越保守

底层算法:meshoptimizer 的 QEM(Quadric Error Metrics,二次误差度量)。对每条边计算"折叠代价",优先折叠代价最小的边,迭代直到达到目标面数。

第二阶段:全量压缩

npx @gltf-transform/cli optimize \
  --texture-compress webp \
  input_simplified.glb output_final.glb

批量处理脚本

for model in 三通 冷却塔 冷水机组 水泵; do
  # 第一阶段:减面
  npx @gltf-transform/cli simplify \
    --ratio 0.03 --error 0.01 \
    "public/models/${model}.glb" \
    "public/models/${model}_opt.glb"

  # 第二阶段:全量压缩
  npx @gltf-transform/cli optimize \
    --texture-compress webp \
    "public/models/${model}_opt.glb" \
    "public/models/${model}_final.glb"

  # 备份原始文件,替换为优化版本
  mv "public/models/${model}.glb" "public/models/${model}_backup.glb"
  mv "public/models/${model}_final.glb" "public/models/${model}.glb"
  rm "public/models/${model}_opt.glb"
done

只压缩不减面(面数本身不多的模型)

npx @gltf-transform/cli optimize --texture-compress webp input.glb output.glb

4. 压缩结果

模型 原始大小 优化后 压缩率
三通 78.4 MB 903 KB 99%
冷却塔 74.9 MB 817 KB 99%
冷水机组 78.7 MB 966 KB 99%
水泵 24.1 MB 623 KB 97%

总三角面数:2 亿 → 约 200 万,减少 99%。


5. 代码端解码配置 — 完整映射

压缩后的 GLB 文件使用 Meshopt 编码,浏览器端加载时需要配置解码器,否则会加载失败。

解码器配置

src/three-preview/composables/useModelLoader.ts L2-5(import)+ L57-65(配置)

// L2-5: 导入
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js'
import { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.js'

// L57-65: 配置加载器
const gltfLoader = new GLTFLoader()

// Draco 解码器(处理 draco 压缩的几何体)
const dracoLoader = new DRACOLoader()
dracoLoader.setDecoderPath('https://www.gstatic.com/draco/versioned/decoders/1.5.7/')
gltfLoader.setDRACOLoader(dracoLoader)

// Meshopt 解码器(处理 gltf-transform optimize 产生的压缩)
gltfLoader.setMeshoptDecoder(MeshoptDecoder)

MeshoptDecoder 来自 Three.js 自带的 examples/jsm/libs/,无需额外安装 npm 依赖。

加载流程

src/three-preview/composables/useModelLoader.ts L70-115

loadModel(path)
  ├─ L72-78: 查缓存 → 命中且未过期(10min) → return model.clone()
  ├─ L84-96: gltfLoader.load(path)
  │    └─ GLTFLoader 内部自动检测 GLB 是否使用 Meshopt/Draco 编码
  │       ├─ 若 Meshopt → 调用 MeshoptDecoder.decode() 还原几何体
  │       └─ 若 Draco → 调用 DRACOLoader.decode() 还原几何体
  ├─ L98-103: clone 存入缓存
  ├─ L106: disposeTextures() 释放原始 blob URL
  └─ L108: return cachedClone.clone()

模型文件加载链路

ToolBar 点击「3D预览」
  └─ src/views/flow/g6Graph/ToolBar/index.vue:90
     emitter.emit('toggle3DPanel')

→ flow/index.vue:186
  emitter.emit('request3DGraphData')

→ g6CanvasGraph.vue:4501-4505
  emitter.emit('response3DGraphData', graph.getData())

→ ThreeScene.vue:44-60
  overview.buildOverview(data, scene)

→ useThreeOverview.ts:92
  placeModelsMerged(nodeMapped)

→ useThreeOverview.ts:188-190
  for (const node of nodes) {
    const path = getModelPathByCompType(node.comptype)
    //                ↓
    // modelMapping.ts:60-69 — 模糊匹配 comptype → GLB 路径
    // 例如 "无变频开式冷却塔" → 包含 "冷却塔" → '/models/冷却塔.glb'
  }

→ useThreeOverview.ts:198
  const { model } = await modelLoader.loadModel(modelPath)
  //                        ↓
  // useModelLoader.ts:70-115 — GLTF 加载 + Meshopt/Draco 自动解码

→ useThreeOverview.ts:199
  autoScaleModel(model, MODEL_SIZE=3)
  // 统一缩放到 3 单位大小

Blob URL 纹理释放

src/three-preview/composables/useModelLoader.ts L27-49

GLTFLoader 加载时会将贴图转为 blob URL,不手动释放会造成内存泄漏:

function disposeTextures(object: Object3D) {
  object.traverse((child) => {
    // 遍历 11 种纹理类型
    const textures = [
      mat.map, mat.normalMap, mat.roughnessMap, mat.metalnessMap,
      mat.aoMap, mat.emissiveMap, mat.alphaMap, mat.envMap,
      mat.lightMap, mat.bumpMap, mat.displacementMap,
    ]
    for (const tex of textures) {
      if (tex?.image?.src?.startsWith('blob:')) {
        URL.revokeObjectURL(tex.image.src)    // 释放浏览器 blob 引用
      }
      tex?.dispose()                          // 释放 GPU 纹理
    }
  })
}

调用位置:

  • useModelLoader.ts L106 — 每次加载新模型后释放原始 scene 的纹理
  • useThreeOverview.ts L728-754clearOverview() 清理场景时释放所有纹理

6. ratio 参数选择建议

使用场景 推荐 ratio 说明
大场景全览(本项目) 0.02 ~ 0.05 模型占屏幕面积小,轮廓可识别即可
中等距离展示 0.1 ~ 0.2 需要保留主要结构细节
近距离单模型展示 0.3 ~ 0.5 保留较多细节,仍比原始小很多
高精度展示 0.8+ 接近原始,仅做压缩不做减面

7. 验证优化效果

命令行检查

# 查看模型概要
npx @gltf-transform/cli inspect output.glb

# 查看每个 mesh 的详细信息
npx @gltf-transform/cli inspect --verbose output.glb

关注字段:

  • renderVertexCount:渲染顶点数,越小越好
  • uploadVertexCount:上传到 GPU 的顶点数

浏览器控制台检查

打开 3D 面板后查看控制台输出(由 useThreeOverview.ts L116 打印):

[3D] draw calls: 5, total triangles: 12.3k, nodes: 28
  • 优化前:201496.3k 三角面
  • 优化后:12.3k 三角面(以 28 个节点为例)

8. 新增模型的完整流程

当需要新增一种设备类型的 3D 模型时:

1. 获取原始 GLB 文件(从 3ds Max / Maya / Rhino 导出)

2. 压缩处理
   npx @gltf-transform/cli simplify --ratio 0.03 --error 0.01 原始.glb 中间.glb
   npx @gltf-transform/cli optimize --texture-compress webp 中间.glb 最终.glb

3. 放置文件
   将 最终.glb 命名为 设备名.glb,放入 public/models/

4. 注册映射 — modelMapping.ts L7-28
   在 MODEL_MAPPINGS 数组添加一项:
   { nodeType: '设备名', modelPath: '/models/设备名.glb', scale: 1.0 }

   在 AVAILABLE_MODELS 数组添加一项(如有模型选择面板):
   { label: '设备名', path: '/models/设备名.glb' }

5. 验证
   打开 3D 面板,检查控制台 [3D] draw calls 和 triangles 输出
   目视检查模型外观是否可接受

9. 注意事项

  1. 备份原始文件:减面是有损操作,务必保留 _backup.glb
  2. 视觉验收:优化后需在浏览器中目测效果,ratio 过低可能导致模型严重变形
  3. ratio 是目标比例,不是保证值:meshoptimizer 会在不超过 --error 限制的前提下尽量接近 ratio,实际结果可能略高
  4. WebP 兼容性:现代浏览器均支持 WebP,如需兼容旧浏览器可改用 --texture-compress jpeg
  5. 重新处理:如需调整 ratio,从 _backup.glb 重新处理,不要对已优化的文件再次减面
  6. Draco vs Meshoptoptimize 命令默认用 Meshopt,代码中两个解码器都已配置(useModelLoader.ts L60-65),两种格式都能正常加载

Cladue Code 源码解析-键盘事件与 Vim 模式:parse-keypress 解析状态机

系列索引:《从零吃透 Claude Code 源码》系列 前置知识(React Ink 终端 UI 引擎) 源码路径src/src/vim/src/src/utils/Cursor.tssrc/src/utils/suggestions/


1. 概述

Claude Code 的输入系统分为三层:

┌──────────────────────────────────────────────┐
│            物理层:终端原始字节                  │
│         parse-keypress.ts(ANSI 序列解析)      │
├──────────────────────────────────────────────┤
│            语义层:标准化按键对象                │
│         转换 ParsedKey { name, shift, ctrl } │
├──────────────────────────────────────────────┤
│            应用层:Vim 模式 / 命令补全          │
│         vim/ + suggestions/                   │
└──────────────────────────────────────────────┘

2. 物理层:ANSI 转义序列解析

2.1 为什么终端按键不是简单的 ASCII?

浏览器键盘事件很简单——keydown 事件直接告诉你按了哪个键。但终端里:

  • 方向键 = ESC[A(三个字节的转义序列)
  • Shift+Enter = ESC[13;2u(CSI u 协议,Kitty 键盘格式)
  • 鼠标点击 = ESC[<0;15;40M(SGR 鼠标协议)

这些统称为 ANSI Escape Sequences(ANSI 转义序列),格式为 ESC + [ + 参数 + 命令

2.2 parse-keypress.ts 的解析器

parse-keypress.ts(801 行)是整个输入系统的第一关。它接收终端原始字节流,输出标准化按键对象。

// parse-keypress.ts
// 核心正则:识别不同类型的转义序列

// CSI u (Kitty 键盘协议)
// 格式: ESC [ codepoint [; modifier] u
// ESC[13;2u = Shift+Enter, ESC[27u = Escape
const CSI_U_RE = /^\x1b\[(\d+)(?:;(\d+))?u/

// xterm modifyOtherKeys(备用协议)
// 格式: ESC [ 27 ; modifier ; keycode ~
const MODIFY_OTHER_KEYS_RE = /^\x1b\[27;(\d+);(\d+)~/

// SGR 鼠标事件
// CSI < button ; col ; row M (press) or m (release)
// 按钮码: 64/65 = 滚轮上/下, 32 = 左键拖拽, 0/1/2 = 左/中/右键
const SGR_MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/

// 功能键转义序列(F1-F12, Home, End 等)
const FN_KEY_RE = /^\x1b+(O|N|\[|\[\[)(?:(\d+)(?:;(\d+))?([~^$])|(?:1;)?(\d+)?([a-zA-Z]))/

// 终端响应(不是按键,是终端对查询的回复)
const DECRPM_RE = /^\x1b\[\?(\d+);(\d+)\$y$/  // DECRQM 响应
const DA1_RE = /^\x1b\[\?([\d;]*)c$/        // 设备属性响应
const XTVERSION_RE = /^\x1bP>\|(.*?)(?:\x07|\x1b\\)$/ // xterm.js 版本探测

2.3 解析流程

function parseKeypress(buffer: Buffer): ParsedKey | ParsedMouse | null {
  // 1. 检测粘贴事件(粘贴内容通常较长)
  if (isPaste(buffer)) {
    return createPasteKey(buffer.toString())
  }

  // 2. 检测终端响应(DAC1、DECRPM 等)
  if (matchTerminalResponse(buffer)) {
    handleTerminalResponse(buffer)  // 更新终端状态
    return null  // 不是用户按键,不分发
  }

  // 3. 检测鼠标事件
  const mouseMatch = buffer.match(SGR_MOUSE_RE)
  if (mouseMatch) {
    return parseMouseEvent(mouseMatch)
  }

  // 4. 解析修饰键和按键码
  const sequence = buffer.toString()

  // 检测修饰键前缀
  let shift = false, ctrl = false, meta = false, option = false
  if (sequence.startsWith('\x1b')) { meta = true; ... }

  // 根据转义序列匹配到具体按键
  const name = resolveKeyName(sequence, ctrl, shift)

  return {
    kind: 'key',
    name,           // 'arrowLeft', 'enter', 'escape'
    shift, ctrl, meta, option,
    sequence,       // 原始序列 '\x1b[D'
    raw: buffer.toString(),
    isPasted: false,
  }
}

3. 语义层:ParsedKey 对象

解析后的标准化对象:

interface ParsedKey {
  kind: 'key' | 'mouse' | 'paste'
  name: string         // 语义化: 'arrowLeft', 'enter', 'escape', 'tab'
  fn: boolean         // 功能键
  ctrl: boolean       // Ctrl 修饰键
  meta: boolean       // Alt/Option 修饰键
  shift: boolean      // Shift 修饰键
  option: boolean
  super: boolean      // Command/Win 修饰键
  sequence: string     // 原始转义序列
  raw: string         // 原始字节
  isPasted: boolean    // 粘贴事件(特殊处理,绕过命令解析)
}

interface ParsedMouse {
  kind: 'mouse'
  button: number      // 0=左键, 1=中键, 2=右键, 32=拖拽, 64/65=滚轮
  col: number         // 列位置(1-indexed)
  row: number         // 行位置
  action: 'press' | 'release' | 'drag' | 'scroll'
}

4. 应用层:Vim 模式状态机

4.1 两种 Vim 状态

// types.ts
export type VimState =
  | { mode: 'INSERT'; insertedText: string }   // 插入模式
  | { mode: 'NORMAL'; command: CommandState }  // 普通模式

4.2 普通模式状态机

                              ┌──────────────────────────────────────┐
                              │             NORMAL 模式               │
                              │        (CommandState 状态机)           │
                              │                                       │
  idle ────[d/c/y]──────────►│ operator ────[motion]────► execute   │
    │      [d/c/y]              ▲      ├─[数字]────► operatorCount  │
    │                            │      ├─[ia]────► operatorTextObj │
    ├──────[1-9]────────────► count        └─[fFtT]──► operatorFind │
    ├──────[fFtT]────────────► find                                │
    ├──────[g]────────────────► g                                   │
    ├──────[r]────────────────► replace                             │
    ├──────[><]───────────────► indent                               │
    └─────────────────────────►►──────[i/a/o/A/I]──► INSERT模式     │
                              └──────────────────────────────────────┘

4.3 状态定义

// types.ts
export type CommandState =
  | { type: 'idle' }                                    // 空闲,等待按键
  | { type: 'count'; digits: string }                   // 数字前缀(如 5j 中的 5)
  | { type: 'operator'; op: Operator; count: number }   // 操作符待续(d 后等待 motion)
  | { type: 'operatorCount'; op: Operator; count: number; digits: string }
  | { type: 'operatorFind'; op: Operator; count: number; find: FindType }
  | { type: 'operatorTextObj'; op: Operator; count: number; scope: TextObjScope }
  | { type: 'find'; find: FindType; count: number }    // f/F/t/T 寻找
  | { type: 'g'; count: number }                       // g 前缀
  | { type: 'operatorG'; op: Operator; count: number }
  | { type: 'replace'; count: number }                  // r 单字符替换
  | { type: 'indent'; dir: '>' | '<'; count: number }

设计亮点:TypeScript 的穷举类型(discriminated union)确保每个 case 都处理了所有状态。如果未来加新状态,编译器会强制更新所有 switch。


5. Motions:光标移动

motions.ts 将按键解析为光标移动目标——纯函数,无副作用

// motions.ts
export function resolveMotion(
  key: string,
  cursor: Cursor,
  count: number,
): Cursor {
  let result = cursor
  // 支持数字前缀:5j = 执行 5 次 j
  for (let i = 0; i < count; i++) {
    const next = applySingleMotion(key, result)
    if (next.equals(result)) break  // 边界保护
    result = next
  }
  return result
}

function applySingleMotion(key: string, cursor: Cursor): Cursor {
  switch (key) {
    case 'h': return cursor.left()
    case 'l': return cursor.right()
    case 'j': return cursor.downLogicalLine()   // 逻辑行(软换行)
    case 'k': return cursor.upLogicalLine()
    case 'gj': return cursor.down()              // 物理行(显示行)
    case 'gk': return cursor.up()
    case 'w': return cursor.nextVimWord()       // word 词首
    case 'b': return cursor.prevVimWord()        // word 词首(反向)
    case 'e': return cursor.endOfVimWord()       // word 词尾
    case 'W': return cursor.nextWORD()           // WORD(大写,以空白分隔)
    case 'B': return cursor.prevWORD()
    case 'E': return cursor.endOfWORD()
    case '0': return cursor.startOfLogicalLine()
    case '^': return cursor.firstNonBlankInLogicalLine()
    case '$': return cursor.endOfLogicalLine()
    case 'g0': return cursor.startOfDisplayLine() // 屏幕行首
    case 'g^': return cursor.firstNonBlankInDisplayLine()
    case 'g$': return cursor.endOfDisplayLine()
    case '|': return cursor.column(n)            // 到第 n 列
    // ...
  }
}

逻辑行 vs 显示行

这是 Vim 中容易混淆的概念,Claude Code 实现了两种:

  • 逻辑行:文件中的实际行(包含软换行的长行可能被显示为多行)
  • 显示行:终端上看到的物理行

6. Operators:操作符

操作符结合 motion 产生动作(d3w = delete 3 words):

// operators.ts
export type Operator = 'delete' | 'change' | 'yank'

export function executeOperatorMotion(
  op: Operator,
  motion: string,
  count: number,
  ctx: OperatorContext,
): void {
  // 1. 解析 motion 得到目标位置
  const target = resolveMotion(motion, ctx.cursor, count)
  if (target.equals(ctx.cursor)) return

  // 2. 计算操作范围
  const range = getOperatorRange(ctx.cursor, target, motion, op, count)

  // 3. 执行操作
  applyOperator(op, range.from, range.to, ctx, range.linewise)
}

// 操作上下文
export type OperatorContext = {
  cursor: Cursor
  text: string
  setText: (text: string) => void
  setOffset: (offset: number) => void
  enterInsert: (offset: number) => void
  getRegister: () => string
  setRegister: (content: string, linewise: boolean) => void
  getLastFind: () => { type: FindType; char: string } | null
  setLastFind: (type: FindType, char: string) => void
  recordChange: (change: RecordedChange) => void   // 用于 . 重复
}

三大操作符

操作符 快捷键 效果
delete d 删除并放入寄存器
change c 删除并进入插入模式
yank y 复制到寄存器(不删除)

典型组合:

  • dw — 删除一个 word
  • d$ / D — 删除到行尾
  • dd — 删除整行
  • c3w — 改变 3 个 word
  • yyp — 复制当前行并粘贴到下方

7. Text Objects:文本对象

文本对象让你一次性选中一个"块"(括号对、引号、单词等):

// textObjects.ts

// 配对括号定义
const PAIRS: Record<string, [string, string]> = {
  '(': ['(', ')'],   ')': ['(', ')'],   b: ['(', ')'],
  '[': ['[', ']'],   ']': ['[', ']'],
  '{': ['{', '}'],   '}': ['{', '}'],   B: ['{', '}'],
  '<': ['<', '>'],   '>': ['<', '>'],
  '"': ['"', '"'],
  "'": ["'", "'"],
  '`': ['`', '`'],
}

export function findTextObject(
  text: string,
  offset: number,
  objectType: string,
  isInner: boolean,    // i = inner(不含分隔符), a = around(含分隔符)
): TextObjectRange {
  if (objectType === 'w')
    return findWordObject(text, offset, isInner, isVimWordChar)
  if (objectType === 'W')
    return findWordObject(text, offset, isInner, ch => !isVimWhitespace(ch))

  const pair = PAIRS[objectType]
  if (pair) {
    const [open, close] = pair
    return open === close
      ? findQuoteObject(text, offset, open, isInner)
      : findBracketObject(text, offset, open, close, isInner)
  }
  return null
}

常用文本对象

命令 含义 说明
ci" change inner quote 修改引号内的内容
di( delete inner paren 删除括号内的内容
ya{ yank around brace 复制大括号及内容
ciw change inner word 改变当前单词
ci( change inner paren 修改括号内容
yiB yank inner Brace 复制大括号内容

8. 状态转换:transitions.ts

transitions.ts(490 行)是 Vim 状态机的核心——每个状态有一个转换函数:

// transitions.ts
export type TransitionResult = {
  next?: CommandState
  execute?: () => void
}

export function transition(
  state: CommandState,
  input: string,
  ctx: TransitionContext,
): TransitionResult {
  switch (state.type) {
    case 'idle':
      return fromIdle(input, ctx)
    case 'count':
      return fromCount(state, input, ctx)
    case 'operator':
      return fromOperator(state, input, ctx)
    // ...
  }
}

// 从 idle 状态的处理
function fromIdle(input: string, ctx: TransitionContext): TransitionResult {
  // 操作符 → 进入 operator 状态
  if (isOperatorKey(input)) {
    return { next: { type: 'operator', op: OPERATORS[input], count: 1 } }
  }

  // 数字 → 进入 count 状态
  if (/[1-9]/.test(input)) {
    return { next: { type: 'count', digits: input } }
  }

  // f/F/t/T → 进入 find 状态
  if (isFindKey(input)) {
    return { next: { type: 'find', find: input, count: 1 } }
  }

  // g → 进入 g 状态
  if (input === 'g') {
    return { next: { type: 'g', count: 1 } }
  }

  // i/a → 进入 INSERT 模式
  if (input === 'i' || input === 'a') {
    return { next: { mode: 'INSERT', insertedText: '' } }
  }

  // ...
}

9. 持久状态:寄存器与 . 重复

Vim 的"记忆"通过 PersistentState 体现:

// types.ts
export type PersistentState = {
  lastChange: RecordedChange | null    // 最近一次修改(用于 . 重复)
  lastFind: { type: FindType; char: string } | null  // ; 和 , 的搜索目标
  register: string                     // 寄存器内容(d/y/p 使用)
  registerIsLinewise: boolean          // 是否为行级操作
}

Dot Repeat(. 命令)

. 命令是 Vim 最强大的功能之一——重复上次修改。实现方式:

// operators.ts
export type RecordedChange =
  | { type: 'insert'; text: string }
  | { type: 'operator'; op: Operator; motion: string; count: number }
  | { type: 'operatorTextObj'; op: Operator; objType: string; scope: TextObjScope; count: number }
  | { type: 'operatorFind'; op: Operator; find: FindType; char: string; count: number }
  | { type: 'replace'; char: string; count: number }
  // ...

每次修改都记录一个 RecordedChange。执行 . 时,回放这个记录:

function repeatLastChange(ctx: OperatorContext): void {
  const change = ctx.getLastChange()
  if (!change) return

  switch (change.type) {
    case 'insert':
      ctx.setOffset(ctx.cursor.offset + change.text.length)
      ctx.setText(insertText(ctx.text, ctx.cursor.offset, change.text))
      break
    case 'operator':
      executeOperatorMotion(change.op, change.motion, change.count, ctx)
      break
    // ...
  }
}

10. 输入历史与命令补全

10.1 Shell 历史补全

Cursor.ts 实现了类似 Emacs 的 kill-ring(剪切环):

// Cursor.ts
const KILL_RING_MAX_SIZE = 10
let killRing: string[] = []

// 连续删除累积到 kill ring
export function pushToKillRing(
  text: string,
  direction: 'prepend' | 'append' = 'append',
): void {
  if (text.length > 0) {
    if (lastActionWasKill && killRing.length > 0) {
      // 与最近一次 kill 合并
      killRing[0] = direction === 'prepend'
        ? text + killRing[0]
        : killRing[0] + text
    } else {
      killRing.unshift(text)  // 新条目入栈
      if (killRing.length > KILL_RING_MAX_SIZE) killRing.pop()
    }
    lastActionWasKill = true
  }
}

// Alt+Y 在 kill ring 中循环(yank-pop)
export function getKillRingItem(index: number): string {
  const normalizedIndex =
    ((index % killRing.length) + killRing.length) % killRing.length
  return killRing[normalizedIndex] ?? ''
}

10.2 命令模糊搜索(Fuse.js)

命令建议使用 Fuse.js 实现模糊匹配:

// suggestions/commandSuggestions.ts
const fuse = new Fuse(commandData, {
  includeScore: true,
  threshold: 0.3,         // 相对严格的匹配
  location: 0,            // 优先匹配字符串开头
  distance: 100,           // 允许在描述中匹配
  keys: [
    { name: 'commandName', weight: 3 },    // 命令名权重最高
    { name: 'partKey', weight: 2 },        // 驼峰分词
    { name: 'aliasKey', weight: 2 },        // 别名
    { name: 'descriptionKey', weight: 0.5 }, // 描述权重最低
  ],
})

// 输入 "inc" → 匹配 ["/incremental", "invalidate-cache"]
// 输入 "sug" → 匹配 ["suggestions:..."]

10.3 目录自动补全

// suggestions/directoryCompletion.ts
// 根据当前光标前的路径,实时列出匹配的目录/文件

11. 总结:Vim 模式的架构亮点

设计 价值
状态机类型化 TypeScript discriminated union 确保穷举处理
纯函数 Motions motions.ts 无副作用,测试简单,可组合
操作上下文注入 OperatorContext 包含所有副作用,逻辑清晰
RecordedChange 统一的变更记录格式支持 . 重复
Kill Ring 全局剪切环支持 Alt+Y 循环
Fuse.js 模糊搜索 命令补全支持任意子串匹配
ANSI 多协议支持 CSI u + modifyOtherKeys 双协议兼容各种终端

源码速查表

文件 行数 职责
ink/parse-keypress.ts 801 ANSI 转义序列解析、鼠标事件
vim/types.ts 199 状态机类型定义(核心文档)
vim/transitions.ts 490 状态转换函数
vim/motions.ts 82 光标移动(纯函数)
vim/operators.ts 556 操作符执行逻辑
vim/textObjects.ts 186 文本对象边界查找
utils/Cursor.ts 1530 光标操作、kill-ring、Emacs 风格编辑
utils/suggestions/commandSuggestions.ts 567 Fuse.js 命令模糊搜索
utils/suggestions/directoryCompletion.ts 目录/文件路径补全
utils/suggestions/shellHistoryCompletion.ts Shell 历史补全

下一篇预告:将深入 工具系统:40+ 工具的注册与调用机制,解析 tools/ 目录的核心架构,包括工具基类设计、schema 生成、工具发现与生命周期管理。

第 9 篇:让 AI 助手记住会话:示例问题点击发送与 localStorage 持久化

项目地址

说明:当前在线版本部署在 Vercel,国内网络访问可能不稳定。如果打不开,可以直接查看 GitHub 源码并在本地启动。


前言

上一篇我们做了一次 UI 和组件结构升级。

项目现在已经有了更像 AI 产品的界面:

顶部 Header
中间消息区
底部输入框
用户 / AI 消息气泡
空状态示例问题
Markdown 渲染
引用来源展示

但当前项目还有一个很明显的问题:

刷新页面后,所有聊天记录都会丢失。

因为现在消息保存在 React state 中:

const [messages, setMessages] = useState<Message[]>([])
const [conversationId, setConversationId] = useState<string>()

React state 只存在于当前页面生命周期里。只要刷新页面,状态就会重新初始化。

这篇文章我们先不引入数据库,而是用最简单的方式解决这个问题:

使用 localStorage 保存会话历史和 conversationId。

同时,我们也把空状态里的示例问题改成可点击发送。


本篇目标

完成后项目会支持:

1. 空状态示例问题可以点击发送
2. 消息列表保存到 localStorage
3. Dify conversationId 保存到 localStorage
4. 刷新页面后自动恢复历史会话
5. 清空会话时同步清除本地缓存

这一篇仍然只做单会话持久化。

多会话列表会在下一篇实现。


为什么先用 localStorage?

真正的产品里,会话历史应该保存在服务端数据库里。

比如:

SQLite
PostgreSQL
MySQL
MongoDB

但这个阶段我们还没有用户系统,也没有数据库。

如果一上来就引入数据库,会多出很多额外复杂度:

表结构设计
接口设计
数据同步
用户身份
服务端存储
迁移脚本

而我们当前最想解决的问题很简单:

刷新页面后不要丢失聊天记录

所以 localStorage 是一个很合适的过渡方案。

它的优点是:

1. 使用简单
2. 不需要后端接口
3. 适合本地 Demo 和个人项目
4. 可以快速验证会话持久化体验

缺点也很明显:

1. 只保存在当前浏览器
2. 清缓存会丢失
3. 不能多设备同步
4. 不适合多用户系统
5. 存储容量有限

但作为项目演进的中间阶段,足够用了。


第一步:让示例问题可以点击发送

上一篇的 EmptyState 只是展示示例问题:

<div className="example-card" key={example}>
  {example}
</div>

现在我们希望点击示例问题后,直接发送这个问题。

修改:

src/components/EmptyState.tsx

改成:

type EmptyStateProps = {
  onExampleClick: (question: string) => void
}

export function EmptyState({ onExampleClick }: EmptyStateProps) {
  const examples = [
    '前端架构主要包括哪些内容?',
    '什么是 RAG?',
    '大型前端项目可以怎么分层?',
  ]

  return (
    <div className="empty-state">
      <h2>Frontend AI Assistant</h2>
      <p>基于你的知识库回答前端学习和架构问题。</p>

      <div className="example-list">
        {examples.map(example => (
          <button
            type="button"
            className="example-card"
            key={example}
            onClick={() => onExampleClick(example)}
          >
            {example}
          </button>
        ))}
      </div>
    </div>
  )
}

注意这里把 div 改成了 button

因为它现在是可交互元素,用 button 更符合语义,也更利于可访问性。


第二步:把 onExampleClick 传给 ChatWindow

修改:

src/components/ChatWindow.tsx

给 props 增加 onExampleClick

import { useEffect, useRef } from 'react'
import type { Message } from '../types/chat'
import { ChatMessage } from './ChatMessage'
import { EmptyState } from './EmptyState'

type ChatWindowProps = {
  messages: Message[]
  loading: boolean
  onExampleClick: (question: string) => void
}

export function ChatWindow({
  messages,
  loading,
  onExampleClick,
}: ChatWindowProps) {
  const bottomRef = useRef<HTMLDivElement | null>(null)

  useEffect(() => {
    bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
  }, [messages, loading])

  if (messages.length === 0) {
    return <EmptyState onExampleClick={onExampleClick} />
  }

  return (
    <div className="chat-window">
      {messages.map((message, index) => (
        <ChatMessage
          key={index}
          role={message.role}
          content={message.content}
          sources={message.sources}
        />
      ))}

      {loading && <div className="typing">AI 正在思考...</div>}

      <div ref={bottomRef} />
    </div>
  )
}

这样空状态的点击事件就能传回 App。


第三步:把发送函数改成支持传入指定问题

原来的发送函数大概是:

async function handleSend() {
  const text = input.trim()
  if (!text || loading) return

  // 发送逻辑
}

现在我们希望两种方式都能发送:

1. 用户在输入框输入后点击发送
2. 用户点击空状态示例问题

所以把 handleSend 改成:

async function handleSend(question?: string) {
  const text = (question ?? input).trim()

  if (!text || loading) return

  setInput('')
  setLoading(true)

  // 后续发送逻辑保持不变
}

然后在 ChatWindow 中传入:

<ChatWindow
  messages={messages}
  loading={loading}
  onExampleClick={question => handleSend(question)}
/>

ChatInput 中仍然这样调用:

<ChatInput
  value={input}
  loading={loading}
  onChange={setInput}
  onSend={() => handleSend()}
  onClear={handleClear}
/>

这样示例问题和输入框共用同一套发送逻辑。


第四步:设计本地存储结构

我们要保存两类信息:

messages:聊天记录
conversationId:Dify 多轮对话 ID

所以本地存储结构可以设计成:

type StoredState = {
  messages: Message[]
  conversationId?: string
}

为什么要保存 conversationId?

因为 Dify 的多轮对话依赖它。

如果只保存 messages,不保存 conversationId,刷新后页面虽然能看到历史消息,但下一次追问时,Dify 会认为这是一个新会话。

保存 conversationId 后,刷新页面继续追问,仍然可以延续同一个 Dify 会话。


第五步:创建 storage 工具函数

新建:

src/utils/storage.ts

写入:

import type { Message } from '../types/chat'

const STORAGE_KEY = 'frontend-ai-assistant-state'

type StoredState = {
  messages: Message[]
  conversationId?: string
}

export function loadChatState(): StoredState {
  try {
    const raw = localStorage.getItem(STORAGE_KEY)

    if (!raw) {
      return {
        messages: [],
        conversationId: undefined,
      }
    }

    const parsed = JSON.parse(raw) as StoredState

    return {
      messages: Array.isArray(parsed.messages) ? parsed.messages : [],
      conversationId: parsed.conversationId,
    }
  } catch {
    return {
      messages: [],
      conversationId: undefined,
    }
  }
}

export function saveChatState(state: StoredState) {
  localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
}

export function clearChatState() {
  localStorage.removeItem(STORAGE_KEY)
}

这里做了几个保护:

1. localStorage 没有数据时返回默认值
2. JSON.parse 失败时返回默认值
3. messages 不是数组时返回空数组

不要假设 localStorage 里的内容永远是可靠的。

用户可能手动改,旧版本数据结构也可能和新版本不一致。


第六步:初始化状态时读取 localStorage

打开:

src/App.tsx

先引入:

import { useEffect, useState } from 'react'
import {
  clearChatState,
  loadChatState,
  saveChatState,
} from './utils/storage'

然后把原来的状态初始化:

const [messages, setMessages] = useState<Message[]>([])
const [conversationId, setConversationId] = useState<string>()

改成:

const initialState = loadChatState()

const [messages, setMessages] = useState<Message[]>(initialState.messages)
const [conversationId, setConversationId] = useState<string | undefined>(
  initialState.conversationId
)

这样页面第一次加载时,会优先从 localStorage 恢复历史会话。


第七步:状态变化时自动保存

App 组件中增加:

useEffect(() => {
  saveChatState({
    messages,
    conversationId,
  })
}, [messages, conversationId])

这段逻辑表示:

只要 messages 或 conversationId 变化,就保存到 localStorage

所以:

用户发送消息 → 保存
AI 流式输出 → 保存
conversationId 返回 → 保存
引用来源更新 → 保存

都会自动持久化。


第八步:清空会话时同步清除本地缓存

原来的清空函数可能是:

function handleClear() {
  setMessages([])
  setConversationId(undefined)
}

现在要加上:

clearChatState()

完整如下:

function handleClear() {
  setMessages([])
  setConversationId(undefined)
  clearChatState()
}

这样点击清空后,刷新页面也不会恢复旧记录。


第九步:补充示例卡片样式

上一篇的 .example-card 是普通展示卡片。

现在它变成了按钮,可以补充 hover 效果:

.example-card {
  width: 100%;
  background: #ffffff;
  border: 1px solid #e5e7eb;
  border-radius: 12px;
  padding: 14px 16px;
  text-align: left;
  color: #374151;
  transition:
    border-color 0.15s ease,
    box-shadow 0.15s ease,
    transform 0.15s ease;
}

.example-card:hover {
  border-color: #2563eb;
  box-shadow: 0 4px 12px rgba(37, 99, 235, 0.12);
  transform: translateY(-1px);
}

这样用户能明显感知它是可点击的。


第十步:测试功能

启动项目:

npm run dev:all

依次测试:

1. 点击示例问题

初始页面点击:

前端架构主要包括哪些内容?

应该能直接发送,并开始流式回答。

2. 刷新页面

等待回答完成后刷新页面。

历史消息应该仍然存在。

3. 刷新后继续追问

刷新后继续问:

那大型项目怎么分层?

因为 conversationId 被保存了,所以 Dify 仍然可以延续同一个会话。

4. 清空会话

点击清空会话后,再刷新页面。

旧消息不应该再恢复。


localStorage 持久化有什么坑?

1. 服务端渲染环境不能直接访问 localStorage

当前项目是 Vite SPA,所以没问题。

但如果以后迁移到 Next.js,要注意:

localStorage 只存在浏览器环境
服务端渲染时不能直接访问

需要放到 useEffect 或判断 typeof window !== 'undefined'

2. 数据结构升级要兼容旧数据

比如现在存的是:

{
  messages: [],
  conversationId: 'xxx'
}

下一篇做多会话后,结构会变成:

{
  activeSessionId: 'xxx',
  sessions: []
}

所以读取 localStorage 时要做好兜底,不能盲目信任旧数据。

3. 不要存敏感信息

localStorage 不是安全存储。

不要把这些内容放进去:

Dify API Key
DeepSeek API Key
用户密码
敏感 Token

我们这里只存聊天内容和 conversationId。

4. 流式输出会频繁写入

因为 AI 每输出一段,messages 都会更新一次,所以 useEffect 也会频繁写 localStorage。

当前项目规模小,问题不大。

如果后面内容变多,可以考虑:

防抖保存
只在 message_end 后保存
迁移到数据库

当前版本的局限

现在已经解决了单会话刷新丢失问题,但还有一个不足:

只有一个会话。

真实 AI 产品一般都有左侧会话列表,比如:

新建会话
历史会话
切换会话
删除会话
重命名会话
搜索会话

目前我们的 localStorage 结构只支持一个会话。

下一篇会把它升级成多会话结构:

type ChatSession = {
  id: string
  title: string
  messages: Message[]
  conversationId?: string
  createdAt: number
  updatedAt: number
}

然后实现类似 ChatGPT 的左侧会话列表。


本篇总结

这一篇我们完成了两个可用性增强:

1. 示例问题点击发送
2. localStorage 单会话持久化

具体做了:

1. EmptyState 支持 onExampleClick
2. ChatWindow 透传示例点击事件
3. handleSend 支持传入指定问题
4. 创建 storage 工具函数
5. 保存 messages 和 conversationId
6. 页面刷新后恢复会话
7. 清空会话时同步清除缓存

现在项目已经可以持续使用,不会一刷新就丢失记录。

下一篇我们继续升级:

实现多会话管理:新建、切换、删除、重命名和搜索。

第 7 篇:让 RAG 答案可追溯:展示知识库引用来源

项目地址

说明:当前在线版本部署在 Vercel,国内网络访问可能不稳定。如果打不开,可以直接查看 GitHub 源码并在本地启动。


前言

上一篇我们解决了 AI 回答的展示体验问题:

Markdown 渲染
代码高亮
表格展示
列表展示

现在 AI 的回答已经更像一个正式产品,而不是普通纯文本输出。

但对于一个 RAG 知识库问答系统来说,还有一个非常关键的问题:

AI 的答案到底来自哪里?

如果用户只能看到 AI 回答,却看不到引用来源,那他很难判断:

这段内容是知识库里的?
还是模型自己补充的?
有没有可能编造?
我能不能回到原文确认?

所以这一篇我们要做一个非常重要的能力:

展示知识库引用来源。

最终效果类似:

前端架构主要包括项目分层、组件设计、状态管理、权限控制、性能优化、工程化和可观测性。

引用来源:
- frontend-notes.md

这一步做完后,我们的 AI 知识库应用会更像真正的 RAG 产品。


为什么引用来源很重要?

RAG 的核心不是“让 AI 回答”,而是“让 AI 基于可检索的知识回答”。

普通聊天机器人可以随便回答,但知识库问答系统更强调:

可追溯
可验证
可解释
减少幻觉

引用来源的价值在于:

1. 告诉用户答案来自哪份文档
2. 增强回答可信度
3. 方便用户回到原文确认
4. 帮助开发者调试知识库召回效果
5. 判断模型有没有脱离上下文发挥

尤其是企业知识库场景,如果 AI 回答后能显示:

引用自:员工手册.pdf
引用自:研发规范.md
引用自:项目介绍.docx

用户会更容易信任这个系统。


Dify 的引用来源在哪里?

在 Dify 的流式响应中,答案片段通常通过 message 事件返回。

类似:

{
  "event": "message",
  "answer": "前端架构"
}

而引用来源通常会在回答结束时的 message_end 事件中返回。

结构大概是:

{
  "event": "message_end",
  "conversation_id": "xxx",
  "metadata": {
    "retriever_resources": [
      {
        "dataset_name": "frontend-learning-kb",
        "document_name": "frontend-notes.md",
        "content": "前端架构主要包括项目分层、组件设计、状态管理、权限控制、性能优化、工程化和可观测性。"
      }
    ]
  }
}

我们要做的就是解析:

metadata.retriever_resources

然后把它展示到 AI 回答下面。


本篇目标

这一篇完成后,项目会支持:

1. 从 Dify streaming 的 message_end 事件中解析 retriever_resources
2. 保存每条 AI 消息对应的引用来源
3. 在 AI 消息下方展示引用文档名称
4. 为后续展示引用片段、跳转原文打基础

第一步:扩展消息类型

之前我们的消息类型可能是:

export type Role = 'user' | 'assistant'

export type Message = {
  role: Role
  content: string
}

现在要给 AI 消息增加引用来源。

打开:

src/types/chat.ts

改成:

export type Role = 'user' | 'assistant'

export type Source = {
  datasetName?: string
  documentName?: string
  content?: string
}

export type Message = {
  role: Role
  content: string
  sources?: Source[]
}

这里的 sources 是可选的。

原因是:

用户消息没有引用来源
AI 消息也不一定每次都有引用来源

比如用户问了一个知识库没有命中的问题,或者 Dify 没有返回 retriever_resources,那 sources 就可以为空。


第二步:扩展流式 API 类型

打开:

src/api/difyStream.ts

定义 Dify 返回的引用来源类型:

export type RetrieverResource = {
  dataset_name?: string
  document_name?: string
  content?: string
}

然后在回调类型里增加 onSources

export type StreamCallbacks = {
  onMessage: (text: string) => void
  onConversationId?: (conversationId: string) => void
  onSources?: (sources: RetrieverResource[]) => void
  onError?: (error: Error) => void
  onDone?: () => void
}

onSources 的作用是:

当流式回答结束,并且 Dify 返回引用来源时,把 sources 通知给外层组件。

第三步:解析 message_end 事件

sendMessageToDifyStream 的 SSE 解析逻辑中,之前我们已经处理了:

if (data.event === 'message' && data.answer) {
  callbacks.onMessage(data.answer)
}

现在加上 message_end

if (data.event === 'message_end') {
  const sources = data.metadata?.retriever_resources || []

  if (sources.length > 0) {
    callbacks.onSources?.(sources)
  }
}

完整片段类似:

try {
  const data = JSON.parse(jsonStr)

  if (data.event === 'message' && data.answer) {
    callbacks.onMessage(data.answer)
  }

  if (data.conversation_id) {
    callbacks.onConversationId?.(data.conversation_id)
  }

  if (data.event === 'message_end') {
    const sources = data.metadata?.retriever_resources || []

    if (sources.length > 0) {
      callbacks.onSources?.(sources)
    }
  }

  if (data.event === 'error') {
    callbacks.onError?.(new Error(data.message || 'Dify stream error'))
  }
} catch {
  // 忽略无法解析的 SSE 行
}

这样,前端就能拿到 Dify 返回的引用来源了。


第四步:把引用来源保存到 AI 消息上

我们之前处理流式回答时,是先插入一条空 AI 消息:

setMessages(prev => [
  ...prev,
  { role: 'user', content: text },
  { role: 'assistant', content: '' },
])

然后每收到一段 answer,就更新这条 AI 消息的 content

现在拿到 sources 后,也要更新同一条 AI 消息。

在调用 sendMessageToDifyStream 时增加:

onSources: sources => {
  setMessages(prev => {
    const next = [...prev]
    const current = next[assistantMessageIndex]

    if (current) {
      next[assistantMessageIndex] = {
        ...current,
        sources: sources.map(source => ({
          datasetName: source.dataset_name,
          documentName: source.document_name,
          content: source.content,
        })),
      }
    }

    return next
  })
}

这里把 Dify 的字段名转换成了前端更习惯的驼峰命名:

dataset_name   → datasetName
document_name  → documentName
contentcontent

这样组件里使用会更自然。


第五步:创建 SourceList 组件

接下来写一个组件专门展示引用来源。

新建:

src/components/SourceList.tsx

写入:

import type { Source } from '../types/chat'

type SourceListProps = {
  sources: Source[]
}

export function SourceList({ sources }: SourceListProps) {
  if (sources.length === 0) return null

  return (
    <div className="source-list">
      <div className="source-title">引用来源</div>
      <ul>
        {sources.map((source, index) => (
          <li key={index}>
            <span>{source.documentName || '未知文档'}</span>
          </li>
        ))}
      </ul>
    </div>
  )
}

第一版我们只展示文档名。

后续可以扩展成:

展示知识库名称
展示引用片段
点击展开原文
显示相似度分数
跳转到原文位置

但第一版先简单一点。


第六步:在 ChatMessage 中展示来源

打开:

src/components/ChatMessage.tsx

引入类型和组件:

import type { Source } from '../types/chat'
import { SourceList } from './SourceList'

把 props 改成:

type ChatMessageProps = {
  role: 'user' | 'assistant'
  content: string
  sources?: Source[]
}

然后在 AI 消息下面加:

{!isUser && sources && sources.length > 0 && (
  <SourceList sources={sources} />
)}

完整组件类似:

import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeHighlight from 'rehype-highlight'
import type { Source } from '../types/chat'
import { SourceList } from './SourceList'

type ChatMessageProps = {
  role: 'user' | 'assistant'
  content: string
  sources?: Source[]
}

export function ChatMessage({ role, content, sources }: ChatMessageProps) {
  const isUser = role === 'user'

  return (
    <div className={`message ${isUser ? 'user' : 'ai'}`}>
      <strong>{isUser ? '你' : 'AI'}:</strong>

      {isUser ? (
        <div className="message-text">{content}</div>
      ) : (
        <div className="markdown-body">
          <ReactMarkdown
            remarkPlugins={[remarkGfm]}
            rehypePlugins={[rehypeHighlight]}
          >
            {content}
          </ReactMarkdown>
        </div>
      )}

      {!isUser && sources && sources.length > 0 && (
        <SourceList sources={sources} />
      )}
    </div>
  )
}

第七步:App 渲染时传入 sources

原来渲染消息时可能是:

<ChatMessage
  key={index}
  role={message.role}
  content={message.content}
/>

现在改成:

<ChatMessage
  key={index}
  role={message.role}
  content={message.content}
  sources={message.sources}
/>

这样每条 AI 消息就可以显示自己的引用来源。


第八步:补充样式

在 CSS 里加入:

.source-list {
  margin-top: 12px;
  padding-top: 10px;
  border-top: 1px solid #e5e7eb;
  font-size: 13px;
  color: #4b5563;
}

.source-title {
  font-weight: 600;
  margin-bottom: 4px;
}

.source-list ul {
  margin: 0;
  padding-left: 18px;
}

.source-list li {
  margin: 2px 0;
}

这样引用来源会作为 AI 消息的一部分显示在回答底部。


第九步:测试引用来源

启动项目:

npm run dev:all

提问:

前端架构主要包括哪些内容?

理想效果是:

前端架构主要包括项目分层、组件设计、状态管理、权限控制、性能优化、工程化和可观测性。

引用来源:
- frontend-notes.md

如果你能看到 frontend-notes.md,说明引用来源已经展示成功。


第十步:如何判断来源是否真的来自知识库?

可以打开浏览器 DevTools → Network。

找到:

/api/chat/stream

查看流式响应里是否有 message_end 事件。

你应该能看到类似:

{
  "event": "message_end",
  "metadata": {
    "retriever_resources": [
      {
        "dataset_name": "frontend-learning-kb",
        "document_name": "frontend-notes.md",
        "content": "前端架构主要包括项目分层、组件设计、状态管理、权限控制、性能优化、工程化和可观测性。"
      }
    ]
  }
}

如果这里有数据,但页面没展示,说明是前端状态或组件传参问题。

如果这里没有数据,说明 Dify 没有返回引用来源,可能是:

1. 知识检索没有命中
2. Dify 应用配置没有启用相关返回
3. 当前问题没有走知识库
4. Prompt 或流程配置有问题

一个细节:为什么 sources 要放在 Message 上?

因为每一条 AI 回答都应该有自己的引用来源。

如果把 sources 单独放成全局状态,比如:

const [sources, setSources] = useState([])

会有几个问题:

1. 多轮对话时,来源会被后一次回答覆盖
2. 历史消息无法保留各自来源
3. 多会话管理时更容易混乱
4. 后续持久化不好设计

所以更合理的结构是:

type Message = {
  role: 'user' | 'assistant'
  content: string
  sources?: Source[]
}

也就是:

回答内容和引用来源属于同一条 AI 消息

这对后续 localStorage 持久化、多会话管理、数据库存储都更友好。


现在引用来源只展示文档名够吗?

第一版够用。

但从产品体验上看,未来还可以继续优化。

比如可以展示:

1. 文档名称
2. 知识库名称
3. 引用片段
4. 相似度分数
5. 点击展开详情

比如 SourceList 可以扩展成卡片:

引用来源

frontend-notes.md
前端架构主要包括项目分层、组件设计、状态管理...

不过这会带来 UI 和交互复杂度。

当前阶段我们的目标是先把来源链路跑通。


当前版本还有哪些不足?

现在项目已经有:

Dify RAG
Express BFF
流式输出
Markdown 渲染
引用来源

但是 UI 仍然比较像 Demo。

比如:

页面布局不够正式
输入框没有固定底部
消息区不够像聊天产品
空状态比较简陋
组件拆分还不够完整

所以下一篇我们会开始做产品化 UI:

ChatLayout
ChatWindow
ChatInput
ChatMessage
SourceList
EmptyState

让项目从“功能 Demo”变成“像样的 AI 产品界面”。


本篇总结

这一篇我们完成了 RAG 产品很关键的一步:展示引用来源。

具体做了:

1. 扩展 Message 类型,增加 sources 字段
2. 扩展 difyStream 回调,增加 onSources
3. 解析 Dify message_end 事件中的 metadata.retriever_resources
4. 把引用来源保存到对应的 AI 消息上
5. 创建 SourceList 组件
6. 在 ChatMessage 下方展示文档来源

这一步让 AI 回答变得更加可信。

因为用户不再只能看到“AI 说了什么”,还能看到“AI 参考了哪里”。

下一篇我们继续做前端体验升级:

从 Demo 到产品:拆分组件,优化聊天 UI。

别再乱装图片插件了!我手写了一个,能扒光整个网页(含背景/iframe/Shadow DOM)

开场白

我真的受够了,每次想从网页批量保存图片,要么右键被禁用,要么装了五六个插件还漏掉一半的 CSS 背景图,要么好不容易抓到图了,却发现插件在后台偷偷上报我的浏览记录。

于是我自己写了一个 —— Image Harvest。它能把网页里所有图片(包括 <img>、CSS 背景、iframe 内嵌、甚至 Shadow DOM 里的)全部扒出来,一键打包 ZIP,而且本地处理,零追踪。

点击立即体验

hero.gif

这篇文章不讲产品吹水,只说技术实现:MV3 踩坑、深度图片提取、客户端感知哈希去重、Side Panel + Popup 双形态共存。干货 + 代码 + 真实踩坑记录,希望对写 Chrome 插件的朋友有帮助。


一、为什么我要自己写一个图片下载插件?

现有的同类插件,我试过 10+ 个,普遍三个问题:

  1. 抓不全:只能抓 <img>,CSS background-imageiframe、Shadow DOM 里的图基本放弃。
  2. 有隐私风险:manifest 里申请 <all_urls> + webRequest,还往未知服务器发数据。
  3. 体验拉胯:批量下载要么一张张点,要么 ZIP 包里一半是占位图。

所以我决定自己造轮子。核心目标:

  • 不漏图(递归提取所有图片源)
  • 不监守自盗(纯本地,零数据收集)
  • 好用(侧边栏/弹窗双模式、暗色主题、3 档密度)

二、Manifest V3 的几个坑(附解法)

2.1 Service Worker 冷启动

V3 用 service worker 替代 V2 的常驻 background page。它会在几秒无活动后休眠,导致下次调用时变量全丢。

解决方案:用 chrome.storage.session 缓存关键状态。

// 抓取完成后存入 session
await chrome.storage.session.set({ 
  lastExtract: { images, timestamp: Date.now() } 
});

// 下次打开面板时恢复
const cached = await chrome.storage.session.get('lastExtract');
if (cached && Date.now() - cached.timestamp < 60000) {
  return cached.images;
}

collection.jpg

2.2 远程代码被禁止

V3 完全禁止执行从外部下载的脚本。对我没影响:Image Harvest 所有代码本地打包,不依赖任何远程配置。

2.3 webRequestdeclarativeNetRequest 替代

如果你需要修改网络请求(如给图片请求加 header),现在只能用声明式规则,灵活性降低。不过图片下载器不需要这玩意儿。


三、深度图片提取:从 <img> 到 Shadow DOM

3.1 基础提取:<img><picture>

function extractSimpleImages() {
  const urls = [];
  document.querySelectorAll('img').forEach(img => {
    if (img.src) urls.push(img.src);
  });
  document.querySelectorAll('picture source').forEach(source => {
    if (source.srcset) {
      const highest = source.srcset.split(',').pop().trim().split(' ')[0];
      urls.push(highest);
    }
  });
  return urls;
}

3.2 提取 CSS background-image

很多网站的 Banner、图标都用背景图实现,必须挖出来。

function extractBgImages(root = document) {
  const elements = root.querySelectorAll('*');
  const bgUrls = [];
  for (let i = 0; i < Math.min(elements.length, 2000); i++) {
    const bg = getComputedStyle(elements[i]).backgroundImage;
    if (bg && bg !== 'none') {
      const match = bg.match(/url\(["']?([^"')]+)["']?\)/);
      if (match) bgUrls.push(match[1]);
    }
  }
  return bgUrls;
}

batch-download.jpg

3.3 递归 Shadow DOM

现代前端框架(React/Vue)常把图片封装在 Shadow DOM 里,必须递归遍历。

function extractFromShadowDOM(root = document) {
  let results = [];
  // 普通图片
  results.push(...extractSimpleImages(root));
  results.push(...extractBgImages(root));
  // 递归 Shadow DOM
  const hosts = root.querySelectorAll('*');
  hosts.forEach(host => {
    if (host.shadowRoot) {
      results.push(...extractFromShadowDOM(host.shadowRoot));
    }
  });
  return results;
}

multi-tab-extract.jpg

3.4 iframe 处理

同源 iframe 可以用 chrome.scripting.executeScript 注入提取函数。需要 webNavigation 权限获取所有 frame。

const frames = await chrome.webNavigation.getAllFrames({ tabId });
for (const frame of frames) {
  if (frame.parentFrameId !== -1) continue; // 只处理顶层 iframe
  const injection = await chrome.scripting.executeScript({
    target: { tabId, frameIds: [frame.frameId] },
    func: extractFromShadowDOM,
  });
  // 合并结果...
}

四、客户端感知哈希(pHash)实现相似图去重

很多用户反馈:“下载 100 张图,里面有 30 张是重复的缩略图”。所以我在 Pro 版中加了相似图检测。

4.1 算法选择:dHash

  • 速度快(纯前端)
  • 对缩放、轻微裁剪不敏感
  • 汉明距离 ≤ 5 判定为相似

4.2 核心代码

async function computeDHash(blob) {
  const img = await createImageBitmap(blob);
  const canvas = new OffscreenCanvas(9, 8);
  const ctx = canvas.getContext('2d');
  ctx.drawImage(img, 0, 0, 9, 8);
  const data = ctx.getImageData(0, 0, 9, 8).data;
  
  // 转灰度
  const gray = [];
  for (let i = 0; i < data.length; i += 4) {
    gray.push(0.299 * data[i] + 0.587 * data[i+1] + 0.114 * data[i+2]);
  }
  
  // 差分哈希
  let hash = 0n;
  for (let row = 0; row < 8; row++) {
    for (let col = 0; col < 7; col++) {
      const left = gray[row * 9 + col];
      const right = gray[row * 9 + col + 1];
      if (right > left) hash |= (1n << BigInt(row * 7 + col));
    }
  }
  return hash;
}

4.3 Worker 中运行,不阻塞 UI

const worker = new Worker('phash-worker.js');
worker.postMessage({ blob });
worker.onmessage = (e) => {
  console.log(`哈希: ${e.data.hash}`);
};

reverse-search.jpg


五、Side Panel + Popup 双模式共存

Chrome 115+ 支持 Side Panel,但老用户习惯 Popup。我两个都要。

实现要点

  • manifest 中配置 side_panel.default_path = "sidepanel.html"
  • action.default_popup 留空,动态控制
chrome.action.onClicked.addListener(async (tab) => {
  const settings = await getAppSettings();
  if (settings.useSidePanel) {
    await chrome.sidePanel.open({ tabId: tab.id });
  } else {
    await chrome.action.setPopup({ tabId: tab.id, popup: 'popup.html' });
    chrome.action.openPopup();
  }
});

同一套 UI 代码,通过 window.location.pathname 判断当前模式,微调布局(弹窗固定 620×600,侧边栏自适应)。

similar-images.jpg


六、上架 Chrome Web Store 的 4 个雷区

  1. 图标尺寸不全:必须 16/32/48/128 px,缺一个直接驳回。
  2. 描述太短:简短描述 ≤132 字符,要包含核心关键词。
  3. 隐私政策缺失:即使不收集数据,也要写一份说明“不收集什么”。
  4. 权限过度:不需要 <all_urls> 就别写,否则审核会问。

我第一次提交被拒就是因为隐私政策链接 404。补上后 2 天过审。

你的数据该在哪儿拿?Next.js三种姿势一次讲清

前言

Next.js给了你三把“钥匙”去开门拿数据。选错了,要么页面慢得像蜗牛,要么用户数据混乱,要么服务器天天崩。别怕,其实规则很简单:数据变不变?要不要实时?要不要SEO? 回答了这三个问题,答案就出来了。

我们用“开餐厅”来打个比方:

  • 静态生成(SSG):菜单印在纸上,顾客看的是纸质菜单。印刷一次管好几天,永不变化。
  • 服务端渲染(SSR):电子黑板,每次客人来,服务员现写菜单,保证最新。
  • 客户端渲染(CSR):客人扫码点餐,手机上的菜单是动态加载的。

三种方式对应三种数据需求。

一、getStaticProps:预制菜,又快又省

适用场景:数据基本不变,或者你可以接受一定延迟更新。比如博客文章、产品介绍页、帮助文档。

怎么做:在构建时(next build)获取数据,生成静态HTML。之后每次请求直接返回这个HTML,速度极快,还能放CDN。

// pages/posts/[id].js
export async function getStaticPaths() {
  const posts = await fetch('https://api.example.com/posts').then(r => r.json());
  const paths = posts.map(post => ({ params: { id: post.id } }));
  return { paths, fallback: false };
}

export async function getStaticProps({ params }) {
  const post = await fetch(`https://api.example.com/posts/${params.id}`).then(r => r.json());
  return {
    props: { post },
    revalidate: 60, // 增量静态再生(ISR):60秒后重新生成,不是必须
  };
}

优点:快(CDN缓存)、省服务器资源、SEO完美。 缺点:构建时数据必须是可得的;如果有大量页面,构建时间会变长(可用fallback: true或ISR缓解)。

生活比喻:预制菜包。你提前做好,客人来了热一下就能吃。适合麦当劳(每家的巨无霸都一样)。

二、getServerSideProps:现点现做,永远新鲜

适用场景:数据随用户不同而变化,或者数据实时性要求极高。比如用户个人主页、购物车、实时搜索页。

怎么做:每次请求都跑到服务器上执行getServerSideProps,获取数据后渲染成HTML返回。

// pages/profile.js
export async function getServerSideProps(context) {
  const { req } = context;
  const token = req.cookies.token;
  const user = await fetch('https://api.example.com/user', {
    headers: { Authorization: `Bearer ${token}` }
  }).then(r => r.json());
  return { props: { user } };
}

优点:数据永远最新,可以读取请求上下文(cookies、headers),适合个性化内容。 缺点:每个请求都调用服务器,性能比静态生成差,不能放CDN。

生活比喻:餐厅里的大厨现炒。客人点一份炒一份,味道新鲜,但慢一点,厨师也累。

三、客户端获取(CSR):扫码点餐,不占服务资源

适用场景:数据实时性要求高,但SEO不重要,或者你不希望服务器压力大。比如用户仪表盘、图表数据、实时消息列表。

怎么做:在组件里用useEffect + fetch,或者用SWR、React Query等库。

import { useState, useEffect } from 'react';

export default function Dashboard() {
  const [data, setData] = useState(null);
  useEffect(() => {
    fetch('/api/dashboard')
      .then(r => r.json())
      .then(setData);
  }, []);
  if (!data) return <div>加载中...</div>;
  return <div>{/* 渲染数据 */}</div>;
}

优点:服务器负担小(只提供API),可以实时更新,适合交互密集的后台。 缺点:首次加载有白屏,SEO不友好(因为内容靠JS填充)。

生活比喻:扫码点餐。客人自己看手机菜单,下单后厨房再做。菜单可以随时改,但客人得先扫个码(等待JS加载)。

四、混合模式:ISR(增量静态再生)

Next.js还提供了一个“中间态”:revalidate 参数。你依然用getStaticProps,但指定一个秒数,超过这个时间后,下次请求会尝试重新生成页面,同时返回旧版本。这样既有静态的速度,又能定期更新。

return { props: { data }, revalidate: 3600 }; // 每小时更新一次

适合数据偶尔变化,但又不希望每次请求都重新生成的场景,比如电商的商品库存(每小时更新就行)。

五、怎么选?决策树

  1. 数据是否需要实时(每次请求都要最新)?

    • 是 → 是否需要SEO?是 → getServerSideProps;否 → 客户端获取。
    • 否 → 进入2
  2. 数据是否依赖用户身份(cookies/headers)?

    • 是 → getServerSideProps
    • 否 → getStaticProps(甚至可以ISR)
  3. 数据量巨大且变化频繁,但不需要SEO? → 客户端获取。

简单口诀

  • 博客文章、产品介绍:getStaticProps
  • 用户个人中心、购物车:getServerSideProps
  • 后台图表、实时看板:客户端获取。

六、实战:一个混合使用的首页

假设首页包含:顶部最新公告(实时)、中间产品列表(每天更新一次)、底部用户推荐(个性化)。

  • 公告:getServerSideProps(实时,且SEO重要?其实公告可以客户端获取,但为了体验,可以SSR)。
  • 产品列表:getStaticProps + revalidate: 86400(一天更新一次)。
  • 用户推荐:客户端获取(依赖登录状态,且SEO不重要)。
export default function Home({ announcement, products }) {
  const [recommendations, setRecommendations] = useState([]);
  useEffect(() => {
    fetch('/api/recommendations').then(r => r.json()).then(setRecommendations);
  }, []);
  return (
    <>
      <Announcement data={announcement} />
      <ProductList data={products} />
      <Recommendations data={recommendations} />
    </>
  );
}

export async function getServerSideProps() {
  const announcement = await fetchAnnouncement(); // 实时
  const products = await fetchProducts(); // 缓存一小时
  return {
    props: { announcement, products },
    revalidate: 3600, // 对products有效,对announcement无效(因为SSR总是实时)
  };
}

但注意:getServerSideProps里不能同时用revalidate(它只对静态生成有效)。如果你要混合,可以把产品数据用getStaticProps单独抽出来,或者全部在客户端获取。上面代码只是示意:实际中getServerSideProps的返回值里加revalidate是无意义的。

更佳实践:产品列表单独做一个静态页面,首页通过客户端请求该接口。

七、总结:没有银弹,只有合适

  • getStaticProps:适合“快、不变、要SEO”。
  • getServerSideProps:适合“实时、要SEO、可接受稍慢”。
  • 客户端获取:适合“实时、不要SEO、降低服务器压力”。

掌握这三种,你就能游刃有余地驾驭Next.js的数据层。别再每页都用客户端fetch了,下次老板说页面慢,你就能有理有据地告诉他:“这个页面应该用ISR!”

前端性能优化实战指南

概述

性能优化是前端开发中至关重要的一环。优秀的性能不仅提升用户体验,还能提高转化率、降低跳出率,并改善 SEO 排名。本文将深入探讨前端性能优化的核心策略和实战技巧。

一、性能指标与测量

1.1 核心 Web 指标 (Core Web Vitals)

// 使用 Web Vitals 库测量核心指标
import { getCLS, getFID, getLCP } from 'web-vitals';

getCLS(console.log);   // 累积布局偏移
getFID(console.log);   // 首次输入延迟
getLCP(console.log);   // 最大内容绘制

关键指标说明:

  • LCP (Largest Contentful Paint) : 最大内容绘制,衡量加载性能

    • 优秀:≤ 2.5 秒
    • 需要改进:2.5-4.0 秒
    • 差:> 4.0 秒
  • FID (First Input Delay) : 首次输入延迟,衡量交互性

    • 优秀:≤ 100 毫秒
    • 需要改进:100-300 毫秒
    • 差:> 300 毫秒
  • CLS (Cumulative Layout Shift) : 累积布局偏移,衡量视觉稳定性

    • 优秀:≤ 0.1
    • 需要改进:0.1-0.25
    • 差:> 0.25

1.2 性能测量工具

# 使用 Lighthouse 进行性能审计
npx lighthouse https://example.com --view

# 使用 Chrome DevTools Performance 面板
# 使用 WebPageTest 进行多地点测试

# 使用 PageSpeed Insights
curl "https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=https://example.com"

二、加载性能优化

2.1 资源压缩与优化

2.1.1 图片优化

// 使用 modern 图片格式
<img src="image.webp" alt="描述" 
     srcset="image-400w.webp 400w, image-800w.webp 800w"
     sizes="(max-width: 600px) 400px, 800px"
     loading="lazy">

// 使用 picture 元素提供多种格式
<picture>
  <source srcset="image.avif" type="image/avif">
  <source srcset="image.webp" type="image/webp">
  <img src="image.jpg" alt="描述" loading="lazy">
</picture>

图片优化策略:

  • 使用 WebP/AVIF 等现代格式
  • 实现响应式图片(srcset + sizes)
  • 懒加载非首屏图片
  • 使用 CDN 进行图片优化

2.1.2 代码压缩

// Vite 配置优化
export default defineConfig({
  build: {
    minify: 'terser', // 使用 terser 进行压缩
    terserOptions: {
      compress: {
        drop_console: true, // 生产环境移除 console
        drop_debugger: true,
      },
    },
    rollupOptions: {
      output: {
        manualChunks: {
          vendor: ['vue', 'vue-router', 'pinia'],
          utils: ['lodash-es', 'dayjs'],
        },
      },
    },
  },
});

2.2 资源预加载与预获取

<!-- 关键资源预加载 -->
<link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/js/hero.js" as="script">

<!-- 未来导航预获取 -->
<link rel="prefetch" href="/js/about.js">
<link rel="preconnect" href="https://api.example.com">

<!-- 智能预加载 -->
<script>
  // 检测用户意图,预加载可能访问的页面
  document.addEventListener('mouseover', (e) => {
    if (e.target.tagName === 'A') {
      const url = e.target.href;
      if (isSameOrigin(url)) {
        const link = document.createElement('link');
        link.rel = 'prefetch';
        link.href = url;
        document.head.appendChild(link);
      }
    }
  });
</script>

2.3 代码分割与懒加载

// 路由级代码分割
const routes = [
  {
    path: '/',
    component: () => import('@/views/Home.vue')
  },
  {
    path: '/about',
    component: () => import('@/views/About.vue')
  },
  {
    path: '/admin',
    component: () => import('@/views/Admin.vue'),
    meta: { requiresAuth: true }
  }
];

// 组件级懒加载
const HeavyChart = defineAsyncComponent({
  loader: () => import('@/components/HeavyChart.vue'),
  loadingComponent: LoadingSpinner,
  delay: 200,
  timeout: 3000
});

// 按需加载第三方库
const loadLodash = async () => {
  const _ = await import('lodash-es');
  return _.default;
};

三、运行时性能优化

3.1 渲染优化

3.1.1 虚拟列表

<!-- 实现虚拟列表处理大量数据 -->
<template>
  <div class="virtual-list" ref="listContainer">
    <div :style="{ height: totalHeight + 'px' }">
      <div 
        v-for="item in visibleItems" 
        :key="item.id"
        :style="{ 
          transform: `translateY(${item.offset}px)`,
          position: 'absolute',
          top: 0,
          left: 0,
          right: 0
        }"
      >
        <ItemComponent :item="item" />
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue';

const props = defineProps({
  items: { type: Array, required: true },
  itemHeight: { type: Number, default: 50 }
});

const listContainer = ref(null);
const scrollTop = ref(0);

const visibleCount = 20;
const totalHeight = computed(() => props.items.length * props.itemHeight);

const visibleItems = computed(() => {
  const start = Math.floor(scrollTop.value / props.itemHeight);
  const end = Math.min(start + visibleCount, props.items.length);
  return props.items
    .slice(start, end)
    .map((item, index) => ({
      ...item,
      offset: (start + index) * props.itemHeight
    }));
});

const handleScroll = () => {
  scrollTop.value = listContainer.value.scrollTop;
};

onMounted(() => {
  listContainer.value.addEventListener('scroll', handleScroll);
});

onUnmounted(() => {
  listContainer.value.removeEventListener('scroll', handleScroll);
});
</script>

3.1.2 防抖与节流

// 防抖函数
function debounce(func, wait, immediate = false) {
  let timeout;
  return function(...args) {
    const later = () => {
      timeout = null;
      if (!immediate) func.apply(this, args);
    };
    const callNow = immediate && !timeout;
    clearTimeout(timeout);
    timeout = setTimeout(later, wait);
    if (callNow) func.apply(this, args);
  };
}

// 节流函数
function throttle(func, limit) {
  let inThrottle;
  return function(...args) {
    if (!inThrottle) {
      func.apply(this, args);
      inThrottle = true;
      setTimeout(() => inThrottle = false, limit);
    }
  };
}

// 使用示例
const handleScroll = throttle(() => {
  console.log('Scroll position:', window.scrollY);
}, 100);

const handleResize = debounce(() => {
  console.log('Window resized');
  updateLayout();
}, 300);

3.2 内存优化

// 避免内存泄漏
class DataFetcher {
  constructor() {
    this.abortController = new AbortController();
    this.cache = new Map();
  }

  async fetchData(url) {
    try {
      const response = await fetch(url, {
        signal: this.abortController.signal
      });
      const data = await response.json();
      this.cache.set(url, data);
      return data;
    } catch (error) {
      if (error.name === 'AbortError') {
        console.log('Request aborted');
      } else {
        throw error;
      }
    }
  }

  destroy() {
    this.abortController.abort();
    this.cache.clear();
  }
}

// 使用 WeakMap 避免内存泄漏
const componentData = new WeakMap();

function registerComponent(component, data) {
  componentData.set(component, data);
  // 当 component 被垃圾回收时,data 也会自动释放
}

3.3 Web Worker 优化

// 主线程
const worker = new Worker('./worker.js');

worker.postMessage({
  type: 'PROCESS_DATA',
  data: largeDataSet
});

worker.onmessage = (e) => {
  const result = e.data;
  updateUI(result);
};

// worker.js
self.onmessage = (e) => {
  const { type, data } = e.data;
  
  if (type === 'PROCESS_DATA') {
    const result = heavyComputation(data);
    self.postMessage(result);
  }
};

function heavyComputation(data) {
  // 繁重的计算逻辑
  return data.map(item => item * 2).filter(x => x > 10);
}

四、网络优化

4.1 HTTP/2与HTTP/3

# Nginx HTTP/2 配置
server {
    listen 443 ssl http2;
    server_name example.com;
    
    # HTTP/2 推送
    http2_push /js/app.js;
    http2_push /css/style.css;
    
    # 多路复用优化
    tcp_nodelay on;
    tcp_nopush on;
}

4.2 缓存策略

// Service Worker 缓存策略
const CACHE_NAME = 'v1';
const CACHE_STRATEGIES = {
  // 缓存优先
  static: ['/', '/index.html', '/css/*', '/js/*'],
  
  // 网络优先
  api: '/api/*',
  
  // 过期时间
  images: {
    pattern: '/images/*',
    maxAge: 7 * 24 * 60 * 60 // 7 天
  }
};

self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url);
  
  if (CACHE_STRATEGIES.static.some(pattern => url.pathname.includes(pattern))) {
    event.respondWith(cachedFirst(event.request));
  } else if (url.pathname.startsWith('/api/')) {
    event.respondWith(networkFirst(event.request));
  } else {
    event.respondWith(staleWhileRevalidate(event.request));
  }
});

async function cachedFirst(request) {
  const cached = await caches.match(request);
  if (cached) return cached;
  
  const response = await fetch(request);
  if (response.ok) {
    const cache = await caches.open(CACHE_NAME);
    cache.put(request, response.clone());
  }
  return response;
}

4.3 请求优化

// 请求合并
class RequestBatcher {
  constructor(batchSize = 10, batchDelay = 100) {
    this.batchSize = batchSize;
    this.batchDelay = batchDelay;
    this.queue = [];
    this.timer = null;
  }

  add(request) {
    return new Promise((resolve, reject) => {
      this.queue.push({ request, resolve, reject });
      this.flush();
    });
  }

  flush() {
    if (this.timer) clearTimeout(this.timer);
    
    if (this.queue.length >= this.batchSize) {
      this.executeBatch();
    } else {
      this.timer = setTimeout(() => this.executeBatch(), this.batchDelay);
    }
  }

  async executeBatch() {
    if (this.queue.length === 0) return;
    
    const batch = [...this.queue];
    this.queue = [];
    
    try {
      const responses = await Promise.all(batch.map(item => item.request));
      batch.forEach((item, index) => item.resolve(responses[index]));
    } catch (error) {
      batch.forEach(item => item.reject(error));
    }
  }
}

// 使用示例
const batcher = new RequestBatcher();

// 批量请求
const results = await Promise.all([
  batcher.add(fetch('/api/user/1')),
  batcher.add(fetch('/api/user/2')),
  batcher.add(fetch('/api/user/3'))
]);

五、构建优化

5.1 依赖分析

# 分析打包体积
npx webpack-bundle-analyzer dist/stats.json

# 使用 source-map-explorer
npx source-map-explorer dist/js/*.js

# Vite 内置分析
vite build --analyze
// webpack 配置
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;

module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        vendors: {
          test: /[\/]node_modules[\/]/,
          name: 'vendors',
          chunks: 'all',
        },
        default: {
          minChunks: 2,
          priority: -10,
          reuseExistingChunk: true,
        },
      },
    },
  },
  plugins: [
    new BundleAnalyzerPlugin({
      analyzerMode: 'static',
      openAnalyzer: false,
    }),
  ],
};

5.2 Tree Shaking

// 确保使用 ES 模块语法
import { debounce, throttle } from 'lodash-es'; // ✅ 支持 tree shaking
// import _ from 'lodash'; // ❌ 会引入整个库

// 使用 sideEffects 配置
// package.json
{
  "sideEffects": [
    "*.css",
    "*.scss"
  ]
}

// 标记纯函数
/*#__PURE__*/
function pureFunction() {
  return 42;
}

六、监控与分析

6.1 性能监控

// 自定义性能监控
class PerformanceMonitor {
  constructor() {
    this.metrics = {};
    this.init();
  }

  init() {
    // 监听核心 Web 指标
    if ('PerformanceObserver' in window) {
      this.observeLCP();
      this.observeCLS();
      this.observeFID();
    }

    // 监听页面加载性能
    window.addEventListener('load', () => {
      this.recordLoadPerformance();
    });
  }

  observeLCP() {
    new PerformanceObserver((list) => {
      const entries = list.getEntries();
      const lastEntry = entries[entries.length - 1];
      this.recordMetric('LCP', lastEntry.startTime);
    }).observe({ type: 'largest-contentful-paint', buffered: true });
  }

  observeCLS() {
    let clsValue = 0;
    new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        if (!entry.hadRecentInput) {
          clsValue += entry.value;
        }
      }
      this.recordMetric('CLS', clsValue);
    }).observe({ type: 'layout-shift', buffered: true });
  }

  recordMetric(name, value) {
    this.metrics[name] = value;
    
    // 发送到分析服务
    this.sendToAnalytics(name, value);
  }

  recordLoadPerformance() {
    const timing = performance.timing;
    const loadTime = timing.loadEventEnd - timing.navigationStart;
    this.recordMetric('LoadTime', loadTime);
  }

  sendToAnalytics(name, value) {
    // 发送到监控服务
    navigator.sendBeacon('/api/performance', 
      JSON.stringify({ metric: name, value, timestamp: Date.now() })
    );
  }

  getReport() {
    return {
      ...this.metrics,
      timestamp: new Date().toISOString(),
      userAgent: navigator.userAgent,
      url: window.location.href
    };
  }
}

// 使用
const monitor = new PerformanceMonitor();

6.2 错误监控

// 全局错误处理
window.addEventListener('error', (event) => {
  reportError({
    type: 'javascript',
    message: event.message,
    filename: event.filename,
    lineno: event.lineno,
    colno: event.colno,
    error: event.error
  });
});

window.addEventListener('unhandledrejection', (event) => {
  reportError({
    type: 'promise',
    message: event.reason?.message || 'Unhandled promise rejection',
    error: event.reason
  });
});

function reportError(error) {
  // 发送到错误监控服务
  navigator.sendBeacon('/api/error', JSON.stringify({
    ...error,
    timestamp: Date.now(),
    url: window.location.href,
    userAgent: navigator.userAgent
  }));
  
  // 可选:记录到控制台
  console.error('Performance Error:', error);
}

七、实战案例

7.1 电商网站性能优化

优化前:

  • LCP: 4.2s
  • FID: 350ms
  • CLS: 0.35
  • 首屏加载时间:5.1s

优化措施:

  1. 图片优化(WebP + 懒加载)
  2. 代码分割(路由级 + 组件级)
  3. 预加载关键资源
  4. Service Worker 缓存
  5. HTTP/2 启用

优化后:

  • LCP: 1.8s ✅
  • FID: 85ms ✅
  • CLS: 0.08 ✅
  • 首屏加载时间:2.1s ✅

总结

前端性能优化是一个持续的过程,需要:

核心策略:

  1. 测量先行 - 使用工具了解当前性能状况
  2. 渐进优化 - 从影响最大的地方开始
  3. 持续监控 - 建立性能监控体系
  4. 团队协作 - 将性能纳入开发流程

关键要点:

  • 图片优化通常带来最大收益
  • 代码分割能显著改善首屏加载
  • 缓存策略对重复访问至关重要
  • 运行时优化提升用户体验
  • 监控确保优化效果持续

记住:性能优化不是一次性的任务,而是持续改进的过程。定期测量、分析、优化,确保你的应用始终保持最佳性能。

❌