普通视图

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

LARYBench 发布:定义具身动作表征 ImageNet,首次度量从人类视频学习的泛化表征

LARYBench (Latent Action Representation Yielding Benchmark),一个指引从大规模的视觉数据学习到通用的隐式动作表征的系统化评测基准。实验结果表明:在动作泛化和控制精度上,通用视觉模型的表现均显著优于专门为具身智能设计的动作专家模型,具身动作表征可以从大规模人类视频数据中涌现。

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

作者 SmalBox
2026年4月27日 10:22

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

Fraction节点核心功能解析

Fraction节点是Shader Graph数学运算模块中的基础组件,其核心功能为提取输入值的纯小数部分。该节点通过公式 Frac(In) = In - Floor(In) 实现运算,其中 Floor 函数返回小于等于输入值的最大整数。这一运算特性赋予其在图形处理中的独特应用价值:

  • 小数分离机制:对正数直接截取小数部分,例如输入3.8输出0.8;
  • 负数处理逻辑:对负数同样执行小数分离,如输入-2.3输出-0.3(与Floor节点结果形成互补);
  • 向量分量支持:可处理float2/float3/float4向量,并对每个分量独立运算;
  • 图形学应用:在纹理映射、动画过渡及视觉特效中发挥关键作用。

数学原理与实现细节

运算公式分解

Fraction节点的数学本质为取模运算的特殊形式:

Frac(x) = x - floor(x) = x mod 1

该运算在图形学中常用于构建周期性纹理,其核心优势包括:

  • 保持数值连续性,避免因四舍五入造成的精度损失;
  • 支持负数范围的正确处理,确保跨平台一致性;
  • 在GPU上实现高效计算,适用于实时渲染需求。

与相关节点的对比

节点类型 输入3.2 输入-0.7 应用场景 性能影响
Fraction 0.2 -0.7 周期性纹理
Floor 3 -1 网格化处理
Truncate 3 -0 整数提取
Round 3 -1 四舍五入
Fmod 0.2 -0.7 通用模运算

基础应用场景与实现

创建重复纹理

通过UV坐标与Fraction节点组合,可轻松实现无缝重复纹理:

  1. 获取物体UV坐标的X分量;
  2. 乘以缩放因子(如5.0);
  3. 连接Fraction节点;
  4. 输出至颜色通道。

// 伪代码实现 float2 uv = i.uv; float scaled = uv.x * 5.0; float pattern = frac(scaled); o.color = pattern;

动态渐变效果

结合时间节点创建动态小数变化:

  1. 创建Time节点并连接至Fraction;
  2. 调整时间乘数以控制变化速度;
  3. 输出至材质透明度通道。

基础动画控制

通过Fraction节点创建循环动画:

  1. 连接Time节点至Fraction;
  2. 乘以动画周期参数;
  3. 输出至材质属性通道。

进阶应用技巧

多通道混合控制

利用Fraction节点实现多通道的独立控制:

  • 红色通道:由UVY坐标驱动;
  • 绿色通道:由时间驱动;
  • 蓝色通道:由噪声驱动。

边缘检测优化

在边缘检测算法中,Fraction节点可替代传统模运算:

// 传统边缘检测 float edge = step(0.5, frac(uv.x * 10.0));

// 优化版本 float edge = smoothstep(0.45, 0.55, frac(uv.x * 10.0));

性能优化方案

  • 避免在顶点着色器中使用Fraction节点;
  • 对静态纹理预计算小数部分;
  • 使用LOD技术降低高频调用开销;
  • 在移动平台优化使用频率。

常见问题解决方案

负数处理异常

当输入为负数时,需确保理解:

  • Fraction(-2.3) = -0.3;
  • Floor(-2.3) = -3;
  • 两者相加应等于原始输入。

向量分量处理

对float4向量进行运算时:

  • 每个分量独立计算;
  • 可通过分量选择节点提取特定通道;
  • 支持混合运算模式。

精度误差处理

在高精度需求场景中:

  • 使用double类型输入(需自定义节点);
  • 添加微小扰动以避免阶梯效应;
  • 结合Smoothstep节点平滑过渡;
  • 考虑采用更高精度的渲染管线。

工程实践案例

案例1:动态水波纹效果

  1. 创建Time节点驱动UV坐标;
  2. 连接Fraction节点生成周期性变化;
  3. 通过噪声节点添加随机扰动;
  4. 输出至法线贴图通道;
  5. 添加边缘光效以增强视觉效果。

案例2:赛博朋克霓虹灯

  1. 使用Fraction节点控制灯带闪烁频率;
  2. 结合颜色渐变节点实现RGB循环;
  3. 添加辉光后处理以增强视觉效果;
  4. 使用深度混合实现半透明效果。

案例3:地形高度图优化

  1. 对地形UV坐标进行小数分离;
  2. 创建不同频率的Fraction图层;
  3. 混合多个图层以生成复杂地形细节;
  4. 输出至高度图通道以控制凹凸;
  5. 添加细节纹理以增强真实感。

性能优化

  • 在移动平台避免高频调用Fraction节点;
  • 对静态纹理预计算小数部分;
  • 使用LOD技术降低Shader复杂度;
  • 结合GPU Instancing减少绘制调用;
  • 考虑使用Shader变体优化特定平台。

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

前端JavaScript:NaN、undefined、null详解

作者 淸湫
2026年4月27日 10:18

在 JavaScript 的世界里,有三个特殊的值常常让初学者甚至有经验的开发者感到困惑:undefinednullNaN。它们都在某种程度上表示 “空” 或 “无值”,但在语义、类型系统以及运行时行为上却有着天壤之别。

很多线上 bug 的根源,往往就在于混淆了这三者的区别。比如,你是否曾疑惑过为什么 typeof null 会返回 'object'?为什么 NaN === NaN 会返回 false?为什么 null == undefinedtruenull === undefined 却是 false

本文将带你深入这三个特殊值的底层逻辑,拆解它们的产生场景、类型特征以及相等性判断的陷阱,帮助你彻底理清这三者的关系,写出更健壮的代码。

一、undefined:系统的 “未定义”

1.1 语义:自然的缺失

undefined 的核心语义是 “未定义” 。它代表的是一种 “自然的缺失”—— 也就是说,当 JavaScript 引擎在找不到值的时候,它会默认用 undefined 来填充。

这不是开发者主动设置的,而是语言本身的默认行为。当一个变量声明了但还没赋值,或者访问了一个对象上不存在的属性,JavaScript 并不会抛出错误,而是返回 undefined 告诉你:“这里应该有个值,但我还没找到。”

1.2 常见的产生场景

undefined 通常出现在以下几种情况:

  1. 变量声明但未赋值:这是最常见的场景。

    1.  let name;
       console.log(name); // undefined
      
  2. 访问不存在的对象属性

    1.  const user = { name: 'John' };
       console.log(user.age); // undefined
      
  3. 函数没有返回值:如果一个函数没有 return 语句,它默认返回 undefined

    1.  function doSomething() {
           // 没有 return
       }
       console.log(doSomething()); // undefined
      
  4. 函数参数未传参

    1.  function greet(name) {
           console.log(name); // undefined
       }
       greet(); // 没传参数
      

1.3 类型与注意事项

undefined 本身是一个独立的原始数据类型(Undefined Type),它只有一个值,就是 undefined

使用 typeof 运算符检测 undefined 时,会准确地返回 'undefined'

let a;
console.log(typeof a); // 'undefined'

最佳实践:不要主动给变量赋值为 undefined。因为 undefined 是语言用来表示 “缺失” 的默认值,如果你手动赋值 let a = undefined,会混淆 “本来就没有值” 和 “我故意把它清空了” 这两种语义。如果你想表示清空,应该使用 null

二、null:开发者的 “空指针”

2.1 语义:主动的清空

undefined 相反,null 的核心语义是 “空值” 。它代表的是一种 “主动的清空”。

null 意味着:“我,开发者,明确地告诉引擎,这个变量现在是空的,它不指向任何对象。”

这是一个有意为之的状态。通常我们用它来表示一个变量本来应该是一个对象,但现在暂时没有值。比如,在等待异步请求返回数据之前,我们可以把变量初始化为 null,表示 “数据还没加载好”。

2.2 常见的使用场景

  1. 初始化对象变量

    1.  // 表示用户对象目前为空,等待后续赋值
       let currentUser = null;
      
       // 登录成功后
       currentUser = { id: 1, name: 'John' };
      
  2. 主动释放引用:在一些手动内存管理的场景下,将对象引用置为 null 可以帮助垃圾回收。

    1.  let bigData = getBigData();
       // 处理完数据
       bigData = null; // 释放引用
      
  3. 函数返回 “无结果” :当查询数据库没有找到结果时,返回 null 而不是抛出错误,表示 “找到了,但结果是空的”。

2.3 那个著名的 Bug:typeof null === 'object'

这是 JavaScript 中最广为人知的历史遗留问题。当你使用 typeof 检测 null 时,你会得到:

console.log(typeof null); // 'object'

这其实是 JavaScript 最初实现时的一个错误。在最初的 JavaScript 引擎中,值是由一个标签和实际数据表示的。对象的标签是 0,而 null 表示空指针,在大多数平台下是空指针的引用也是 0x00,所以它的标签也被误写成了 0,导致 typeof 把它当成了对象。

虽然这个错误已经被所有人知道了,但由于兼容性的原因,ECMAScript 标准一直没有修复它。

因此,永远不要用 typeof 来检测 null 正确的检测方式是直接使用严格相等:

if (value === null) {
    // 这才是正确的判断方式
}

三、NaN:数字里的 “坏孩子”

3.1 语义:无效的数字

NaN 全称是 Not-a-Number,即 “非数字”。但这并不意味着它的类型不是数字。恰恰相反,NaN 是一个 数值类型 的特殊值。

它的语义是:“这本来应该是一个数字,但是运算失败了,所以我用 NaN 来表示这个无效的结果。”

比如,你试图把一个字符串 "abc" 转换成数字,或者对负数开平方,JavaScript 不会抛出异常,而是返回 NaN 来告诉你:“这次数字运算搞砸了。”

console.log(typeof NaN); // 'number'
// 没错,它的类型是 number!

这是因为 JavaScript 遵循了 IEEE 754 浮点数标准,而 NaN 正是该标准中定义的一个特殊数值,用来表示非法的计算结果。

3.2 最反直觉的特性:传染性与自不等

NaN 有两个极其特殊的性质,也是无数 bug 的来源:

  1. 传染性:只要你的数学运算中混入了 NaN,那么最终的结果一定是 NaN。它就像病毒一样会传染。

    1.  console.log(1 + NaN); // NaN
       console.log(2 * NaN); // NaN
       console.log(Math.max(1, 2, NaN, 3)); // NaN
      
    2.   这意味着,一旦你的计算链中某个环节出错产生了 NaN,它会一路污染到最终结果,而且很难定位到底是哪里出的错。
  2. 它不等于任何值,包括它自己:这是最反直觉的一点。

    1.  console.log(NaN === NaN); // false
       console.log(NaN == NaN); // false
      
    2.   为什么会这样?因为 IEEE 754 标准规定,NaN 不与任何值相等,包括它自己。这是为了让你能通过 x !== x 来检测 NaN

3.3 如何正确检测 NaN?

既然 === 不好使,那我们该怎么检测一个值是不是 NaN 呢?

  • 全局的 isNaN() :这是最早的方法,但它有坑。它会先把参数转换成数字,如果转换失败就返回 true。这意味着它会把很多非数字的值也误判为 NaN

    •   console.log(isNaN('hello')); // true!因为 'hello' 转数字失败了
        console.log(isNaN(undefined)); // true
      
    •   这显然不对,因为 'hello' 本身并不是 NaN,它只是个字符串。
  • Number.isNaN() :ES6 引入的正确方法。它不会做类型转换,只有当值真的是 NaN 时才返回 true

    •   console.log(Number.isNaN(NaN)); // true
        console.log(Number.isNaN('hello')); // false
        console.log(Number.isNaN(undefined)); // false
      
  • 利用自不等特性:这是一个古老的 trick,因为只有 NaN 才会不等于自己。

    •   function myIsNaN(value) {
            return value !== value;
        }
      

四、一张表看懂三者的区别

为了让你更直观地对比这三者的区别,我们整理了一张核心特性对比表:

从表中可以清晰地看到,虽然它们在布尔转换中都为 false,但在类型、语义和转换行为上完全不同。

五、相等性判断的迷局

搞清楚了它们各自的定义,接下来最容易踩坑的就是相等性判断了。JavaScript 提供了三种比较方式:=====Object.is(),它们对这三个值的处理各不相同。

5.1 == vs ===:null 和 undefined 的暧昧关系

我们都知道 == 会进行类型转换,而 === 不会。对于 nullundefined,ECMAScript 标准做了一个特殊的规定:

null == undefined 必须返回 true

这是因为语言设计者认为,这两者都表示 “无值”,在宽松比较下应该被视为相等。但在严格比较下,它们是不同的。

console.log(null == undefined);  // true
console.log(null === undefined); // false

这就导致了一个非常有用的简写技巧:如果你想同时检查一个变量是不是 null 或者 undefined,你可以直接写:

if (value == null) {
    // 这会同时匹配 null 和 undefined
    // 等价于 if (value === null || value === undefined)
}

这在实际开发中非常常用,因为很多时候我们并不关心到底是 null 还是 undefined,我们只关心 “这个值是不是空的”。

5.2 Object.is:终极的相等判断

ES6 引入了 Object.is() 方法,它解决了 === 无法处理 NaN 的问题。

我们来看一下三种比较方式的区别:

比较 == === Object.is()
null == undefined true false false
NaN == NaN false false true
+0 == -0 true true false

Object.is() 是最严格的相等判断,它不会做任何类型转换,也不会对 NaN-0 做特殊处理。

console.log(Object.is(NaN, NaN)); // true!终于可以正常判断 NaN 了
console.log(Object.is(null, null)); // true
console.log(Object.is(undefined, undefined)); // true

下面的矩阵图展示了在严格相等(===)下,各个特殊值之间的比较结果:

六、实战避坑:那些年我们踩过的雷

6.1 坑 1:滥用 if (!value)

很多人喜欢用 if (!value) 来判断变量是否为空。但这会把所有的假值(Falsy Value)都过滤掉,包括 0''false

// 错误示范
function processAge(age) {
    if (!age) {
        console.log('年龄为空');
    } else {
        console.log('处理年龄', age);
    }
}

processAge(0); // 错误!0 是合法年龄,但被当成空了

正确做法:明确检查 nullundefined

if (age == null) {
    console.log('年龄为空');
}

6.2 坑 2:JSON 序列化的丢失

当你使用 JSON.stringify 序列化数据时,undefinedNaNInfinity 会被特殊处理:

  • undefined、函数、Symbol 会被忽略(在对象中)或者变成 null(在数组中)
  • NaNInfinity 会变成 null
JSON.stringify({ a: NaN, b: undefined, c: null });
// 结果: "{"a":null,"c":null}"

注意,这里 NaNundefined 都变成了 null!这意味着你序列化之后,就再也分不清原来的是 NaN 还是 undefined 还是 null 了,这在处理后端数据时要格外小心。

6.3 坑 3:默认参数只对 undefined 生效

ES6 的默认参数只有在参数是 undefined 的时候才会触发,null 不会!

function greet(name = 'Guest') {
    console.log(name);
}

greet(undefined); // Guest (触发默认值)
greet(null);      // null (不触发!因为 null 是一个明确的传值)

这也符合语义:undefined 表示 “我没传这个参数”,而 null 表示 “我传了,就是空”。

七、最佳实践总结

经过上面的分析,我们可以总结出一套最佳实践,帮助你在日常开发中正确使用这三个值:

  1. 语义优先

    1. undefined 处理 “缺失” 的情况,不要手动赋值它。
    2. null 表示 “主动清空”,当你想表示一个对象变量为空时使用它。
  2. 判断准则

    1. 检测 null:使用 value === null
    2. 检测 undefined:使用 value === undefined 或者 typeof value === 'undefined'(处理未声明变量)
    3. 检测 NaN:使用 Number.isNaN(value),永远不要用全局的 isNaN()
    4. 同时检测两者:使用 value == null 来同时匹配 nullundefined,这是一个安全的简写。
  3. 利用现代语法

    1. 使用空值合并运算符 ?? 来处理默认值,它只会在 null/undefined 时生效,不会误伤 0''

      •   const count = response.count ?? 0;
        
    2. 使用可选链运算符 ?. 来安全访问属性,避免 Cannot read property of undefined 错误。

结语

undefinednullNaN,这三个看似简单的值,背后却隐藏着 JavaScript 类型系统的设计哲学和历史包袱。

  • undefined 是系统告诉你 “这里没东西”。
  • null 是你告诉系统 “这里我故意清空了”。
  • NaN 是系统告诉你 “数字运算炸了”。

理解了它们的区别,你就能在日常开发中避开绝大多数与空值相关的 bug,写出更清晰、更健壮的前端代码。

参考资料

  1. MDN Web Docs. NaN
  2. MDN Web Docs. undefined
  3. MDN Web Docs. Object.is()
  4. MDN Web Docs. 相等比较和相同
  5. OpenReplay. The Strange Life of NaN in JavaScript

contenteditable 深度剖析:让网页元素「活」起来

作者 Momo__
2026年4月27日 10:17

contenteditable 深度剖析:让网页元素「活」起来

前端开发者必备技能 | 深入理解 HTML 可编辑属性

📖 基本概念

contenteditable 是什么?

contenteditable 是 HTML5 的一个全局属性(Global Attribute) ,可以让任意 HTML 元素变成可编辑区域。用户可以直接点击元素并修改其内容,无需使用传统的 <input><textarea> 表单元素。

<!-- 最简单用法 -->
<div contenteditable="true">点击这里编辑我</div>

<!-- 等价于 -->
<div contenteditable>我也是可编辑的</div>

属性值说明

说明 示例
true 启用编辑 <div contenteditable="true">
false 禁用编辑 <div contenteditable="false">
plaintext-only 仅纯文本(禁止富文本) <input> 行为类似
空字符串/inherit 继承父元素或默认可编辑 <div contenteditable>
<!-- plaintext-only 场景:评论框只需要纯文本 -->
<article contenteditable="plaintext-only">
  这里只能输入纯文本,富文本格式会被过滤
</article>

浏览器支持情况

现代浏览器全覆盖,包括:

浏览器 支持版本
Chrome 4.0+
Firefox 3.5+
Safari 3.1+
Edge 12+
IE 6.0+(功能有限)

⚠️ 注意:虽然所有现代浏览器都支持,但行为存在差异,需要针对性处理。

⚡ 核心特性

可编辑区域的行为特性

  1. 原生光标(Carets) :自动显示插入符

  2. 文本选择:支持鼠标选中文本

  3. 富文本支持:用户可以输入带格式的文本

  4. 键盘交互:支持快捷键(Ctrl+B 加粗等)

  5. 拖拽操作:支持在元素内拖拽文本

contenteditable vs 表单元素

特性 contenteditable input/textarea
内容格式 HTML 片段(富文本) 纯文本
样式控制 灵活(继承父样式) 受限
语义化
表单提交 需手动处理 自动
XSS 风险
<!-- textarea 的 value 是纯文本 -->
<textarea id="ta">你好</textarea>
<script>
  console.log(document.getElementById('ta').value); // "你好"
</script>

<!-- contenteditable 的 innerHTML 是 HTML -->
<div contenteditable="true">你好</div>
<script>
  // 用户可能输入 <strong>粗体</strong>
  console.log(editor.innerHTML); // "你好" 或 "<strong>粗体</strong>"
</script>

默认的富文本能力

contenteditable="true" 时,浏览器天然支持:

  • 富文本粘贴:从网页复制的带格式内容会保留样式

  • 撤销/重做:Ctrl+Z / Ctrl+Shift+Z

  • 拖拽重新排列:选中文本可拖拽移动位置

  • 浏览器内置格式化:Ctrl+B/I/U 等

🎯 使用场景分析

1. 在线富文本编辑器

最简单的富文本编辑器实现:

<div contenteditable="true" 
     id="editor"
     style="border: 1px solid #ccc; min-height: 200px; padding: 16px;">
</div>

<button onclick="format('bold')">加粗</button>
<button onclick="format('italic')">斜体</button>

<script>
function format(cmd) {
  document.execCommand(cmd, false, null);
}
</script>

2. 可编辑表格

CMS 系统中常见的需求:

<table border="1" style="border-collapse: collapse; width: 100%;">
  <tr>
    <th>商品名称</th>
    <th>价格</th>
    <th>库存</th>
  </tr>
  <tr>
    <td contenteditable="true">iPhone 15</td>
    <td contenteditable="true">5999</td>
    <td contenteditable="true">100</td>
  </tr>
  <tr>
    <td contenteditable="true">MacBook Pro</td>
    <td contenteditable="true">12999</td>
    <td contenteditable="true">50</td>
  </tr>
</table>

<script>
// 保存表格数据
document.querySelectorAll('table').forEach(table => {
  table.addEventListener('blur', (e) => {
    if (e.target.isContentEditable) {
      console.log('Cell updated:', e.target.textContent);
      // 发送到服务器
    }
  }, true);
});
</script>

3. 即时编辑(Click-to-Edit)

用户点击标题直接编辑,类似于 Notion/Figma 的体验:

<h1 class="editable-title" contenteditable="true" 
    data-placeholder="输入标题...">
  点击这里编辑标题
</h1>

<style>
.editable-title {
  outline: none;
  border-bottom: 2px dashed transparent;
  transition: border-color 0.2s;
}
.editable-title:focus {
  border-bottom-color: #4285f4;
}
.editable-title:empty::before {
  content: attr(data-placeholder);
  color: #999;
}
</style>

<script>
document.querySelector('.editable-title').addEventListener('blur', function() {
  saveToServer(this.textContent);
});
</script>

4. 评论/笔记区域

轻量级笔记应用:

<div id="notes" contenteditable="true" 
     style="white-space: pre-wrap;"
     data-placeholder="在这里记录笔记...">
</div>

<script>
// 自动保存到 localStorage
const notes = document.getElementById('notes');
const saved = localStorage.getItem('user-notes');

if (saved) {
  notes.innerHTML = saved;
}

notes.addEventListener('input', debounce(() => {
  localStorage.setItem('user-notes', notes.innerHTML);
}, 500));

function debounce(fn, delay) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), delay);
  };
}
</script>

5. 协作编辑场景

配合 WebSocket 或 WebRTC 实现实时协作:

// 基础协作框架示例
class CollaborativeEditor {
  constructor(element) {
    this.element = element;
    this.socket = new WebSocket('ws://your-server');
    
    // 监听本地变化
    this.element.addEventListener('input', () => {
      this.broadcast(this.getContent());
    });
    
    // 接收远程变化
    this.socket.onmessage = (event) => {
      const { content, userId } = JSON.parse(event.data);
      if (userId !== this.userId) {
        this.setContent(content);
      }
    };
  }
  
  getContent() {
    return this.element.innerHTML;
  }
  
  setContent(html) {
    // 保存光标位置
    const selection = window.getSelection();
    const range = selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
    
    this.element.innerHTML = html;
    
    // 恢复光标
    // ... 光标恢复逻辑
  }
  
  broadcast(content) {
    this.socket.send(JSON.stringify({
      content,
      userId: this.userId
    }));
  }
}

💻 代码示例

基础用法

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>ContentEditable 基础示例</title>
  <style>
    .editor {
      border: 1px solid #ddd;
      border-radius: 8px;
      padding: 20px;
      min-height: 150px;
      font-size: 16px;
      line-height: 1.6;
      outline: none;
      transition: border-color 0.2s, box-shadow 0.2s;
    }
    .editor:focus {
      border-color: #4285f4;
      box-shadow: 0 0 0 3px rgba(66, 133, 244, 0.1);
    }
    .editor:empty::before {
      content: attr(data-placeholder);
      color: #aaa;
      pointer-events: none;
    }
  </style>
</head>
<body>
  <div class="editor" 
       contenteditable="true" 
       data-placeholder="输入内容...">初始内容</div>
  
  <p>HTML 内容:<span id="html-output"></span></p>
  <p>纯文本内容:<span id="text-output"></span></p>
  
  <script>
    const editor = document.querySelector('.editor');
    const htmlOutput = document.getElementById('html-output');
    const textOutput = document.getElementById('text-output');
    
    // 获取内容
    function updateOutput() {
      htmlOutput.textContent = editor.innerHTML;
      textOutput.textContent = editor.textContent;
    }
    
    editor.addEventListener('input', updateOutput);
    updateOutput();
    
    // 监听粘贴,保留纯文本
    editor.addEventListener('paste', (e) => {
      e.preventDefault();
      const text = e.clipboardData.getData('text/plain');
      document.execCommand('insertText', false, text);
    });
  </script>
</body>
</html>

获取/设置内容

const editor = document.getElementById('editor');

// 获取内容
const htmlContent = editor.innerHTML;      // 包含 HTML 标签
const textContent = editor.textContent;    // 仅纯文本
const innerText = editor.innerText;        // 仅纯文本(尊重CSS)

// 设置内容
editor.innerHTML = '<p>新内容</p>';

// 追加内容
editor.innerHTML += '<span>追加内容</span>';

// 安全的追加方式
function safeAppend(element, html) {
  const fragment = document.createDocumentFragment();
  const temp = document.createElement('div');
  temp.innerHTML = html;
  while (temp.firstChild) {
    fragment.appendChild(temp.firstChild);
  }
  element.appendChild(fragment);
}

实现简单的富文本功能

class SimpleEditor {
  constructor(element) {
    this.editor = element;
    this.setupToolbar();
    this.setupKeyboardShortcuts();
  }
  
  setupToolbar() {
    document.querySelectorAll('[data-command]').forEach(btn => {
      btn.addEventListener('click', () => {
        const cmd = btn.dataset.command;
        const value = btn.dataset.value || null;
        
        if (cmd === 'createlink') {
          const url = prompt('输入链接地址:');
          if (url) this.exec(cmd, false, url);
        } else if (cmd === 'insertImage') {
          const url = prompt('输入图片地址:');
          if (url) this.exec(cmd, false, url);
        } else {
          this.exec(cmd, false, value);
        }
      });
    });
  }
  
  setupKeyboardShortcuts() {
    this.editor.addEventListener('keydown', (e) => {
      if (e.ctrlKey || e.metaKey) {
        switch (e.key.toLowerCase()) {
          case 'b': e.preventDefault(); this.exec('bold'); break;
          case 'i': e.preventDefault(); this.exec('italic'); break;
          case 'u': e.preventDefault(); this.exec('underline'); break;
          case 's': e.preventDefault(); this.exec('save'); break;
        }
      }
    });
  }
  
  exec(command, showUI, value) {
    if (command === 'save') {
      this.save();
    } else {
      document.execCommand(command, showUI, value);
    }
  }
  
  exec(command, showUI, value) {
    document.execCommand(command, showUI, value);
  }
  
  save() {
    console.log('HTML:', this.editor.innerHTML);
    console.log('Text:', this.editor.textContent);
    // 发送到服务器
    fetch('/api/save', {
      method: 'POST',
      body: JSON.stringify({ content: this.editor.innerHTML }),
      headers: { 'Content-Type': 'application/json' }
    });
  }
  
  getContent() {
    return this.editor.innerHTML;
  }
  
  setContent(html) {
    this.editor.innerHTML = html;
  }
  
  clear() {
    this.editor.innerHTML = '';
  }
}

// 使用
const editor = new SimpleEditor(document.getElementById('editor'));

与 Selection/Range API 配合

现代替代 execCommand 的方式:

// 获取选区
function getSelectionInfo() {
  const selection = window.getSelection();
  if (selection.rangeCount === 0) return null;
  
  const range = selection.getRangeAt(0);
  return {
    text: range.toString(),
    startContainer: range.startContainer,
    startOffset: range.startOffset,
    endContainer: range.endContainer,
    endOffset: range.endOffset,
    collapsed: range.collapsed
  };
}

// 包裹选中内容
function wrapSelection(tagName, attributes = {}) {
  const selection = window.getSelection();
  if (selection.rangeCount === 0) return;
  
  const range = selection.getRangeAt(0);
  if (range.collapsed) {
    console.warn('没有选中文本');
    return;
  }
  
  const element = document.createElement(tagName);
  Object.entries(attributes).forEach(([key, value]) => {
    element.setAttribute(key, value);
  });
  
  try {
    range.surroundContents(element);
  } catch (e) {
    // 选区跨越多个节点时,需要使用 extractContents
    console.warn('选区跨越多个节点,使用备用方案');
  }
}

// 示例:加粗选中文本
function boldSelection() {
  wrapSelection('strong');
}

// 示例:创建链接
function linkSelection(url) {
  wrapSelection('a', { href: url, target: '_blank' });
}

// 在光标位置插入内容
function insertAtCursor(html) {
  const selection = window.getSelection();
  if (selection.rangeCount === 0) return;
  
  const range = selection.getRangeAt(0);
  range.deleteContents();
  
  const fragment = document.createRange().createContextualFragment(html);
  const lastNode = fragment.lastChild;
  
  range.insertNode(fragment);
  
  // 将光标移动到插入内容之后
  if (lastNode) {
    range.setStartAfter(lastNode);
    range.collapse(true);
    selection.removeAllRanges();
    selection.addRange(range);
  }
}

// 示例:在光标位置插入表情
function insertEmoji(emoji) {
  insertAtCursor(`<span class="emoji">${emoji}</span>`);
}

⚠️ 注意事项与坑点

1. XSS 安全问题

这是 contenteditable 最大的坑! 用户输入的内容会被浏览器解析为 HTML。

<!-- 恶意输入示例 -->
<div contenteditable="true">
  <img src onerror="alert('XSS!')">
  <script>document.cookie</script>
  <div onclick="stealData()">点我</div>
</div>

防御方案

// ❌ 危险:直接输出用户输入
div.innerHTML = userInput;

// ✅ 安全方案 1:转义 HTML
function escapeHtml(text) {
  const div = document.createElement('div');
  div.textContent = text;
  return div.innerHTML;
}

// ✅ 安全方案 2:使用 DOMPurify 白名单过滤
import DOMPurify from 'dompurify';

function sanitize(dirty) {
  return DOMPurify.sanitize(dirty, {
    ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'br', 'p', 'ul', 'ol', 'li'],
    ALLOWED_ATTR: ['class']
  });
}

// ✅ 安全方案 3:使用 beforeinput 拦截
editor.addEventListener('beforeinput', (e) => {
  if (e.inputType === 'insertFromPaste') {
    e.preventDefault();
    // 自定义粘贴处理
    const text = e.getTargetRanges()[0].text;
    document.execCommand('insertText', false, text);
  }
});

2. 样式继承问题

contenteditable 会继承父元素的许多样式:

/* ❌ 问题:输入的文字可能继承奇怪的颜色 */
.parent {
  color: red;
  font-family: cursive;
}

/* ✅ 解决方案:明确设置 */
[contenteditable] {
  color: inherit;
  font-family: inherit;
  font-size: inherit;
  /* 关键:允许继承但可被覆盖 */
}

/* ✅ 更好的方案:使用 plaintext-only */
[contenteditable="plaintext-only"] {
  all: unset;  /* 重置所有继承 */
  display: block;
  /* 然后显式设置需要的样式 */
}

3. 焦点管理

// ❌ 问题:程序设置内容会丢失光标
editor.innerHTML = 'new content';  // 光标位置丢失

// ✅ 正确做法:保存和恢复光标
function setContentPreservingCursor(element, html) {
  const selection = window.getSelection();
  const range = selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
  
  // 保存相对位置
  let startOffset = 0, endOffset = 0;
  let startNode, endNode;
  
  if (range) {
    const preRange = document.createRange();
    preRange.selectNodeContents(element);
    preRange.setEnd(range.startContainer, range.startOffset);
    startOffset = preRange.toString().length;
    
    preRange.setEnd(range.endContainer, range.endOffset);
    endOffset = preRange.toString().length;
    
    startNode = range.startContainer;
    endNode = range.endContainer;
  }
  
  element.innerHTML = html;
  
  // 恢复位置(简化版,实际需要更复杂)
  if (range) {
    const newRange = document.createRange();
    // ... 恢复逻辑
  }
}

// ✅ 更简洁的方案:使用 beforeinput 事件

4. 换行行为差异

不同浏览器按 Enter 键产生的 HTML 元素不同:

浏览器 产生的元素
Chrome <div>
Firefox <br>
Safari <p>
// ✅ 解决方案:统一换行行为
editor.addEventListener('keydown', (e) => {
  if (e.key === 'Enter' && !e.shiftKey) {
    // 检测浏览器并插入统一的元素
    const browser = detectBrowser();
    
    if (browser === 'chrome') {
      e.preventDefault();
      document.execCommand('insertLineBreak');
    }
  }
});

// ✅ 更好的方案:在初始化时统一配置
document.execCommand('defaultParagraphSeparator', false, 'p');

5. 粘贴内容过滤

editor.addEventListener('paste', (e) => {
  e.preventDefault();
  
  // 获取剪贴板内容
  const clipboardData = e.clipboardData || window.clipboardData;
  
  // 方式 1:只粘贴纯文本(最安全)
  const text = clipboardData.getData('text/plain');
  document.execCommand('insertText', false, text);
  
  // 方式 2:粘贴但过滤危险标签
  const html = clipboardData.getData('text/html');
  if (html) {
    const sanitized = DOMPurify.sanitize(html, {
      ALLOWED_TAGS: ['p', 'br', 'b', 'i', 'em', 'strong', 'a', 'ul', 'ol', 'li'],
      ALLOWED_ATTR: ['href', 'target']
    });
    document.execCommand('insertHTML', false, sanitized);
  }
});

6. MutationObserver 监听变化

const observer = new MutationObserver((mutations) => {
  mutations.forEach((mutation) => {
    switch (mutation.type) {
      case 'characterData':
        console.log('文本变化:', mutation.target.textContent);
        break;
      case 'childList':
        console.log('子节点变化');
        mutation.addedNodes.forEach(node => {
          if (node.nodeType === Node.ELEMENT_NODE) {
            console.log('新增元素:', node.tagName);
          }
        });
        break;
    }
  });
});

observer.observe(editor, {
  characterData: true,
  childList: true,
  subtree: true
});

// 清理
// observer.disconnect();

🔧 相关 API

document.execCommand(已废弃但仍在用)

// 常用命令
document.execCommand('bold', false, null);           // 加粗
document.execCommand('italic', false, null);          // 斜体
document.execCommand('underline', false, null);      // 下划线
document.execCommand('strikeThrough', false, null);  // 删除线
document.execCommand('createLink', false, url);      // 创建链接
document.execCommand('insertImage', false, url);      // 插入图片
document.execCommand('formatBlock', false, 'p');     // 段落格式
document.execCommand('insertUnorderedList', false, null); // 无序列表
document.execCommand('insertOrderedList', false, null);   // 有序列表
document.execCommand('undo', false, null);           // 撤销
document.execCommand('redo', false, null);           // 重做
document.execCommand('selectAll', false, null);       // 全选

// 检查命令支持
if (document.queryCommandSupported('bold')) {
  document.execCommand('bold', false, null);
}

⚠️ 警告execCommand 已被 MDN 标记为废弃,但目前在所有浏览器中仍可使用。对于简单场景可以直接使用,对于复杂编辑器建议使用 Selection/Range API。

Selection API

const selection = window.getSelection();

// 获取选中的文本
console.log(selection.toString());

// 获取 Range 对象
if (selection.rangeCount > 0) {
  const range = selection.getRangeAt(0);
  
  // 常用属性
  console.log(range.startContainer);   // 起始容器节点
  console.log(range.startOffset);      // 起始偏移量
  console.log(range.endContainer);      // 结束容器节点
  console.log(range.endOffset);        // 结束偏移量
  console.log(range.collapsed);        // 是否折叠(无选中)
  console.log(range.commonAncestorContainer); // 共同祖先
  
  // 方法
  range.deleteContents();              // 删除选中内容
  range.extractContents();             // 提取选中内容(从 DOM 移除)
  range.cloneContents();               // 克隆选中内容
  range.insertNode(node);              // 插入节点
  range.surroundContents(node);        // 用节点包裹选中内容
}

// 设置选区
const newRange = document.createRange();
newRange.setStart(node, offset);
newRange.setEnd(node, offset);
selection.removeAllRanges();
selection.addRange(newRange);

// 折叠选区
selection.collapseToStart();  // 折叠到起始位置
selection.collapseToEnd();     // 折叠到结束位置

// 全选
selection.selectAllChildren(element);

Range API

const range = document.createRange();

// 设置边界
range.setStart(node, offset);
range.setEnd(node, offset);

// 便捷方法
range.selectNode(node);           // 选中整个节点
range.selectNodeContents(node);    // 选中节点内容
range.setStartBefore(node);       // 开始于节点前
range.setStartAfter(node);        // 开始于节点后
range.setEndBefore(node);         // 结束于节点前
range.setEndAfter(node);          // 结束于节点后

// 比较位置
range.compareBoundaryPoints('START_TO_START', otherRange);
range.compareBoundaryPoints('START_TO_END', otherRange);
range.compareBoundaryPoints('END_TO_END', otherRange);
range.compareBoundaryPoints('END_TO_START', otherRange);

// 操作内容
range.cloneContents();      // 克隆选中内容
range.deleteContents();      // 删除选中内容
range.extractContents();     // 提取内容
range.insertNode(node);     // 插入节点
range.surroundContents(node); // 包裹内容

// 复制粘贴
range.cloneRange();         // 克隆范围
range.detach();            // 释放范围(优化性能)

// 折叠
range.collapse(true);       // 折叠到起点
range.collapse(false);      // 折叠到终点

Input 事件

const editor = document.getElementById('editor');

// input 事件:内容变化后触发
editor.addEventListener('input', () => {
  console.log('内容变化:', editor.innerHTML);
  saveContent();
});

// beforeinput 事件:内容变化前触发,可取消
editor.addEventListener('beforeinput', (e) => {
  // 拦截粘贴为纯文本
  if (e.inputType === 'insertFromPaste') {
    e.preventDefault();
    const text = e.clipboardData.getData('text/plain');
    insertText(text);
  }
  
  // 限制字数
  if (editor.textContent.length >= MAX_LENGTH && 
      e.inputType === 'insertText') {
    e.preventDefault();
  }
});

// compositionstart/end:处理输入法
editor.addEventListener('compositionstart', () => {
  console.log('开始输入中文...');
});
editor.addEventListener('compositionend', () => {
  console.log('中文输入完成');
  handleInput();
});

InputEvent 的 inputType 枚举

// 常用 inputType 值
'insertText'           // 插入文本
'insertLineBreak'      // 插入换行
'insertParagraph'      // 插入段落
'insertOrderedList'    // 插入有序列表
'insertUnorderedList'  // 插入无序列表
'insertFromPaste'      // 从粘贴板粘贴
'formatBold'           // 格式-加粗
'formatItalic'         // 格式-斜体
'formatUnderline'      // 格式-下划线
'deleteContentBackward' // 删除前一个字符
'deleteContentForward'  // 删除后一个字符
'deleteWordBackward'    // 删除前一个单词
'deleteWordForward'     // 删除后一个单词

✅ 最佳实践

安全的内容处理

// 1. 永远不要相信用户输入
function sanitizeUserInput(dirty) {
  return DOMPurify.sanitize(dirty, {
    ALLOWED_TAGS: [
      'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
      'p', 'br', 'hr',
      'ul', 'ol', 'li',
      'blockquote',
      'a', 'img',
      'strong', 'em', 'b', 'i', 'u', 's', 'sub', 'sup',
      'code', 'pre'
    ],
    ALLOWED_ATTR: ['href', 'src', 'alt', 'class', 'target'],
    ALLOW_DATA_ATTR: false
  });
}

// 2. 显示时二次转义
function escapeHtml(text) {
  return text
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#039;');
}

// 3. CSP 配置
// Content-Security-Policy: script-src 'self'; style-src 'self' 'unsafe-inline'

数据绑定方案

class ContentEditor {
  constructor(element, options = {}) {
    this.element = element;
    this.options = {
      placeholder: options.placeholder || '',
      onChange: options.onChange || (() => {}),
      debounceMs: options.debounceMs || 300,
      ...options
    };
    
    this.init();
  }
  
  init() {
    this.element.contentEditable = 'true';
    this.element.dataset.placeholder = this.options.placeholder;
    
    // 初始化内容
    if (this.options.initialValue) {
      this.element.innerHTML = this.options.initialValue;
    }
    
    this.bindEvents();
  }
  
  bindEvents() {
    // 防抖保存
    let timer;
    this.element.addEventListener('input', () => {
      clearTimeout(timer);
      timer = setTimeout(() => {
        this.options.onChange(this.getContent());
      }, this.options.debounceMs);
    });
    
    // 失去焦点时立即保存
    this.element.addEventListener('blur', () => {
      clearTimeout(timer);
      this.options.onChange(this.getContent());
    });
    
    // 粘贴过滤
    this.element.addEventListener('paste', (e) => {
      e.preventDefault();
      const text = e.clipboardData.getData('text/plain');
      document.execCommand('insertText', false, text);
    });
  }
  
  getContent() {
    return this.element.innerHTML;
  }
  
  getText() {
    return this.element.textContent;
  }
  
  setContent(html) {
    this.element.innerHTML = html;
  }
  
  clear() {
    this.element.innerHTML = '';
  }
  
  focus() {
    this.element.focus();
  }
}

// 使用
const editor = new ContentEditor(document.getElementById('editor'), {
  initialValue: '<p>Hello World</p>',
  placeholder: '输入内容...',
  onChange: (content) => {
    console.log('保存:', content);
    localStorage.setItem('draft', content);
  },
  debounceMs: 500
});

富文本编辑器推荐

对于生产环境,建议使用成熟的富文本编辑器库:

特点 适用场景
TinyMCE 功能全面、插件丰富、企业级 企业应用、CMS
Quill 轻量、API 简洁、文档友好 轻量级应用
Tiptap Vue/React 友好、扩展性强 现代 SPA
Slate.js 完全可定制、插件化 高度定制需求
ProseMirror Schema 驱动、协作支持 复杂文档、协作
Editor.js 块编辑、JSON 输出 博客、笔记

🚀 现代替代方案

Quill 2.0

import Quill from 'quill';

// 初始化
const quill = new Quill('#editor', {
  theme: 'snow',
  modules: {
    toolbar: [
      [{ header: [1, 2, 3, false] }],
      ['bold', 'italic', 'underline', 'strike'],
      [{ list: 'ordered' }, { list: 'bullet' }],
      ['link', 'image', 'blockquote', 'code-block'],
      ['clean']
    ]
  }
});

// 获取/设置内容
quill.on('text-change', () => {
  console.log('HTML:', quill.root.innerHTML);
  console.log('Delta:', quill.getContents());
});

quill.setContents({
  ops: [
    { insert: 'Hello ' },
    { insert: 'World', attributes: { bold: true } },
    { insert: '!\n' }
  ]
});

Tiptap

import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';

const editor = new Editor({
  element: document.querySelector('#editor'),
  extensions: [StarterKit],
  content: '<p>Hello World!</p>',
  onUpdate: ({ editor }) => {
    console.log(editor.getHTML());
    console.log(editor.getJSON());
  }
});

// 命令
editor.chain().focus().toggleBold().run();
editor.chain().focus().setParagraph().run();

小型项目:使用 contenteditable + Selection API

// 极简富文本框(无依赖)
class MinimalEditor {
  constructor(container) {
    this.container = container;
    this.editor = document.createElement('div');
    this.editor.contentEditable = true;
    this.editor.className = 'minimal-editor';
    this.container.appendChild(this.editor);
    
    this.setupStyles();
    this.setupToolbar();
    this.setupPasteHandler();
  }
  
  setupStyles() {
    const style = document.createElement('style');
    style.textContent = `
      .minimal-editor {
        border: 1px solid #ddd;
        padding: 16px;
        min-height: 100px;
        outline: none;
      }
      .minimal-editor:focus { border-color: #4285f4; }
      .minimal-editor-toolbar { margin-bottom: 8px; }
      .minimal-editor button {
        padding: 4px 8px;
        margin-right: 4px;
        cursor: pointer;
      }
    `;
    document.head.appendChild(style);
  }
  
  setupToolbar() {
    const toolbar = document.createElement('div');
    toolbar.className = 'minimal-editor-toolbar';
    toolbar.innerHTML = `
      <button type="button" data-cmd="bold"><b>B</b></button>
      <button type="button" data-cmd="italic"><i>I</i></button>
      <button type="button" data-cmd="underline"><u>U</u></button>
      <button type="button" data-cmd="createLink">🔗</button>
    `;
    
    toolbar.addEventListener('click', (e) => {
      const btn = e.target.closest('button');
      if (!btn) return;
      
      const cmd = btn.dataset.cmd;
      if (cmd === 'createLink') {
        const url = prompt('URL:');
        if (url) this.exec('createLink', url);
      } else {
        this.exec(cmd);
      }
    });
    
    this.container.insertBefore(toolbar, this.editor);
  }
  
  setupPasteHandler() {
    this.editor.addEventListener('paste', (e) => {
      e.preventDefault();
      const text = e.clipboardData.getData('text/plain');
      this.exec('insertText', text);
    });
  }
  
  exec(cmd, value = null) {
    document.execCommand(cmd, false, value);
  }
  
  getContent() {
    return this.editor.innerHTML;
  }
  
  getText() {
    return this.editor.textContent;
  }
}

// 使用
const editor = new MinimalEditor(document.getElementById('container'));

📋 总结

什么时候用 contenteditable?

适合的场景

  • 轻量级富文本编辑(笔记、评论)
  • 即时编辑(click-to-edit)
  • 可编辑表格/列表
  • 需要灵活布局的编辑区域
  • 原型/内部工具

不适合的场景

  • 企业级文档编辑(用 TinyMCE)
  • 需要复杂协作(用 Tiptap/ProseMirror + Yjs)
  • 严格的格式控制(用成熟的编辑器库)
  • 对 XSS 零容忍(除非做好完整防护)

关键要点

  1. 安全第一:永远不要信任用户输入,使用 DOMPurify 等库进行过滤
  2. 关注差异:不同浏览器的行为差异需要针对性处理
  3. 光标管理:修改内容后记得恢复光标位置
  4. 渐进增强:从简单开始,必要时引入编辑器库
  5. 替代方案:生产环境优先考虑成熟的编辑器库

📝 写在最后

contenteditable 是一个「入门简单、深坑不少」的属性。它能快速实现富文本编辑,但要在生产环境稳定使用,需要处理大量的浏览器兼容性和安全问题。

建议:如果是个人项目或内部工具,直接使用 contenteditable 足够;如果是面向用户的产品,强烈建议使用 TinyMCE、Quill 或 Tiptap 等成熟方案。

文档由AI辅助整理

Vue 的 :deep/:global/:slotted 怎么转成 React ?一份对照指南?

作者 Ruihong
2026年4月27日 10:04

VuReact 是一个能将 Vue 3 代码编译为标准、可维护 React 代码的工具。今天就带大家直击核心:Vue 作用域样式中的穿透选择器(:deep/:global/:slotted)经过 VuReact 编译后会变成什么样的 React 代码?

前置约定

为避免示例代码冗余导致理解偏差,先明确两个小约定:

  1. 文中 Vue / React 代码均为核心逻辑简写,省略完整组件包裹、无关配置等内容;
  2. 默认读者已熟悉样式 :deep/:global/:slotted 的用法。

编译对照

:global():声明全局样式

:global() 用于在 scoped 样式中声明一段不受作用域限制的全局样式。VuReact 的处理方式:移除 :global() 包装,保留内部选择器原样输出

  • Vue 代码:
<!-- Component.vue -->
<template>
  <div class="component">
    <div class="global-class">全局类</div>
  </div>
</template>

<style scoped>
.component {
  :global(.global-class) {
    color: green;
  }
}
</style>
  • VuReact 编译后 CSS:
/* component-abc123.css */
.component[data-css-abc123] {
  .global-class {
    color: green;
  }
}

从示例可以看到::global(...) 被完全移除,内部的选择器照常展开,且不添加 scope 属性。这样 .global-class 就是一个全局可用的样式类。


:deep():样式穿透

:deep() 是 scoped 样式中最常用的穿透选择器,用于让父组件的样式能够影响子组件内部的元素。VuReact 的处理策略是::deep(...) 左侧的选择器加上 scope,右侧(:deep 内部)的选择器保持原样

在嵌套规则中使用 :deep()

  • Vue 代码:
<!-- Component.vue -->
<template>
  <div class="component">
    <div class="nested-component">深层嵌套组件</div>
  </div>
</template>

<style scoped>
.component {
  :deep(.nested-component) {
    background: yellow;
  }
}
</style>
  • VuReact 编译后 CSS:
/* component-abc123.css */
.component[data-css-abc123] {
  & .nested-component {
    background: yellow;
  }
}

从示例可以看到:在嵌套规则中,:deep() 左侧是 .component(加 scope),右侧 .nested-component(不加 scope)。

在单行规则中使用 :deep()

:deep() 也可以在非嵌套的单行规则中使用,左侧部分仍然被 scoped。

  • Vue 代码:
<style scoped>
.parent :deep(.btn) { color: red; }
</style>
  • VuReact 编译后 CSS:
.parent[data-css-abc123] .btn { color: red; }

:deep() 紧贴选择器

  • Vue 代码:
<style scoped>
.parent:deep(.btn) { color: red; }
</style>
  • VuReact 编译后 CSS:
.parent[data-css-abc123] .btn { color: red; }

带组合器的 :deep()

  • Vue 代码:
<style scoped>
.parent > :deep(.btn) { color: red; }
</style>
  • VuReact 编译后 CSS:
.parent[data-css-abc123] > .btn { color: red; }

:deep() 作为选择器起始

:deep() 位于选择器最左侧时(无左侧部分),VuReact 会直接用 [scopeId] 作为左侧。

  • Vue 代码:
<style scoped>
:deep(.btn) { color: red; }
</style>
  • VuReact 编译后 CSS:
[data-css-abc123] .btn { color: red; }

处理逻辑:左侧为空时,用 [data-css-abc123] 自身作为 scoped 占位。

:deep() 展开逗号选择器

:deep() 内部可以包含多个逗号分隔的选择器,VuReact 会逐一展开。

  • Vue 代码:
<style scoped>
.a :deep(.x, .y) { color: red; }
</style>
  • VuReact 编译后 CSS:
.a[data-css-abc123] .x, .a[data-css-abc123] .y { color: red; }

从示例可以看到::deep(.x, .y) 被展开为两个独立的选择器 .x.y,各自与左侧 .a[data-css-abc123] 拼接。


4. :slotted():插槽样式

:slotted() 用于为插槽传入的内容设置样式,VuReact 当前的处理方式是简单解包

  • Vue 代码:
<style scoped>
.component {
  :slotted(.slotted-content) {
    display: flex;
  }
}
</style>
  • VuReact 编译后 CSS:
.component[data-css-abc123] {
  .slotted-content {
    display: flex;
  }
}

从示例可以看到::slotted(...) 被移除,内部选择器 .slotted-content 保留,但不加 scope。完整的 :slotted() 语义支持仍在解决中。


复杂选择器共存

在一个组件中,:global:deep:slotted 可以与标准 scoped 选择器以及伪类(:hover::before 等)混合使用。

  • Vue 代码:
<style scoped>
.component {
  &:hover { opacity: 0.8; }
  &.active { font-weight: bold; }
  :global(.global-class) { color: green; }
  :deep(.nested-component) { background: yellow; }
  :slotted(.slotted-content) { display: flex; }
  &:not(:first-child) { margin-top: 20px; }
  &:nth-child(2n) { background: #f0f0f0; }
  &::before { content: '→'; }
  &::placeholder { color: gray; }
}
</style>
  • VuReact 编译后 CSS:
.component[data-css-abc123] {
  &:hover { opacity: 0.8; }
  &.active { font-weight: bold; }
  .global-class { color: green; }
  & .nested-component { background: yellow; }
  .slotted-content { display: flex; }
  &:not(:first-child) { margin-top: 20px; }
  &:nth-child(2n) { background: #f0f0f0; }
  &::before { content: '→'; }
  &::placeholder { color: gray; }
}

共处规则

选择器类型 行为 scope 注入
标准选择器 尾部追加 [data-css-xxx]
伪类/属性选择器 保持原样,插入 scope 在其之前
:global(...) 移除包装,内部不加 scope
:deep(...) 左侧加 scope,内部不加
:slotted(...) 移除包装,内部不加 scope ⚠️(待完善)

编译策略总结

VuReact 的作用域样式穿透选择器编译策略展示了完整的 scoped 选择器转换能力

  1. :global() 转换:移除 :global(...) 包装,内部选择器按全局样式输出,不加 scope
  2. :deep() 转换:将选择器按 :deep(...) 位置切割,左侧加 scope,内部保持穿透能力,支持嵌套、组合器、逗号展开等复杂场景
  3. :slotted() 转换:移除 :slotted(...) 包装,内部选择器保持原样(完整语义实现 WIP)
  4. 伪类兼容:hover::before:not():nth-child() 等伪类保持原样,scope 只插入在伪类之前
  5. 嵌套兼容:与 SCSS/Less 的 & 嵌套语法协作良好

支持的穿透选择器

选择器 状态 说明
:deep() ✅ 完整支持 左侧 scoped + 右侧穿透
:global() ✅ 完整支持 移除包装,全局样式
:slotted() ⚠️ 部分支持 解包处理,完整语义待完善

VuReact 的编译策略确保了从 Vue 到 React 的平滑迁移。编译后的 CSS 选择器既保持了 Vue scoped 样式的作用域隔离语义,又能通过 :deep():global() 灵活控制样式穿透范围,让迁移后的应用保持完整的 scoped 样式能力。

🔗 相关资源


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

9.响应式系统演进:effectScope 的作用与实现原理(Vue3.2)

作者 Cobyte
2026年4月27日 09:33

前言

effectScope 是 Vue3.2 引入的一个强大响应式副作用管理工具,用于自动收集在同一个作用域内的响应式副作用(effect),以便在需要的时候可以一起销毁这些响应式副作用(effect),防止内存泄漏和意外行为。effectScope 简化了复杂代码中的响应式副作用的管理,提高了代码的可维护性,同时,effectScope 还支持嵌套作用域和独立的子作用域,即隔离副作用,总的来说它主要作用为开发者提供了灵活的响应式副作用管理方式。

effectScope 是一个底层的高级进阶 API,对于普通应用开发者一般使用不到它,但如果我们想进阶,那么就必须了解它的实现原理。如果我们想实现一些基于 Vue3 响应式库 @vue/reactivity 的公共 Hooks 库,我们就有可能需要使用到 effectScope API, 比如 vueuse 就使用到了 effectScope API。同时如果我们想要了解 Vue3.2 以后的源码也必须要了解 effectScope 的实现原理,另外还有 Vue3 状态管理库 Pinia 的源码也使用到了 effectScope API。所以说我们还是非常有必要了解它的。

在 Vue RFC 也有对其详细的解释,也可以了解一下。

注意:本篇文章实现的代码例子是在第五篇的基础上的,所以你还没看第五篇,可以先学习第五篇的内容。

在 Vue3 中什么时候需要清除响应式副作用

现在我们要实现以下这样的一个计数功能:

image.png

我们具体要实现的功能就是按 + 按钮就累计加 1,点击 清除计算结果 按钮则清除计算结果,且我们希望再次点击 + 按钮的时候也不再进行计算。

HTML 部分的代码如下:

<div>计算结果:<span id="counter"></span></div>
<button id="add">+</button>
<button id="delete">清除计算结果</button>

功能实现部分代码如下:

// 获取真实 DOM
const counterEl = document.getElementById('counter')
const addEl = document.getElementById('add')
const delEl = document.getElementById('delete')

// 利用响应式创建数据
const count = ref(0)
// 利用响应式动态变更 DOM 内容
effect(() => {
    counterEl.textContent = count.value
})
// 添加
addEl.addEventListener('click', () => {
    count.value++
})
// 清除计算结果
delEl.addEventListener('click', () => {
    const parent = counterEl.parentNode
    parent.removeChild(counterEl)
})

值得注意的是我们清除计算结果是直接删除相关 DOM 内容的。

实现结果如下:

tutieshi_494x218_7s.gif

我们从上面的实现效果来看,似乎没什么问题。

我们在动态更新 DOM 内容的 effect 执行的副作用函数中添加一个打印日志来观察一下实现效果:

effect(() => {
    counterEl.textContent = count.value
+    console.log('动态变更 DOM 内容', count.value)
})

观察结果如下:

tutieshi_504x222_5s.gif

这时我们发现,即便我们已经删除了显示计算结果的 DOM,但重新点击 + 按钮的时候,effect 的副作用函数还是继续执行。如果我们有大量这样的功能的话,那么会对我们的内存性能带来影响,所以我们需要及时释放不需要的内存,在上述例子中就是当显示计算结果的 DOM 被删除后,那么对应的响应式副作用也需要被删除,在上述例子中就是 effect 中副作用函数需要被删除。如果从发布订阅模式的角度来看,就是对应的订阅者要被删除。

删除 effect 中的副作用函数这个功能我们已经在第五篇中已经实现了,现在我们实现起来就很简单了,代码如下:

- effect(() => {
-    counterEl.textContent = count.value
-    console.log('动态变更 DOM 内容', count.value)
- })
+ const runner = effect(() => {
+    counterEl.textContent = count.value
+    console.log('动态变更 DOM 内容', count.value)
+ })
delEl.addEventListener('click', () => {
+    runner.effect.stop()
    const parent = counterEl.parentNode
    parent.removeChild(counterEl)
})

我们再来看看修改后的执行效果:

tutieshi_510x166_4s.gif

这时我们发现在删除相关 DOM 的时候同时清除相关的副作用函数,即便对应的响应式数据发生变化,那些已经被删除的副作用函数就不再执行了,这样就达到优化内存,提高响应式框架程序性能的作用了。

如果上述功能是一个 Vue3 的应用的话,计算结果可以使用一个组件来实现,那么当清除计算结果的时候,可以看作卸载计算结果的组件,那么也就是说在卸载组件的时候需要清除对应组件的响应式副作用函数

Vue3 组件的响应式副作用的收集与清除

在 Vue3.15 的版本的源码中,也就是 effectScope 相关代码提交的前一个版本,我们可以看到 Vue3 组件的响应式副作用收集过程是如下的:

image.png

首先在组件初始化的时候,会通过实例化 ReactiveEffect 类创建一个副作用对象,并且赋值给组件实例 instance.effect 上。

组件卸载的时候:

image.png

我们可以看到组件卸载的时候,又会从组件实例对象上取 ReactiveEffect 类的实例对象,然后执行 stop 方法清除组件的响应式副作用。

上述通过 ReactiveEffect 类创建的副作用对象主要应用于组件的 render 函数的包装函数,是 Vue3 系统底层自动创建的。而一个组件的响应式副作用并不止组件的 render 函数的包装函数,还有用户通过 watch、watchEffect、computed API 手动创建的响应式副作用。

例如 watch API:

image.png

在 watch API 的实现中也是通过实例化 ReactiveEffect 类创建一个副作用对象,然后再通过 recordInstanceBoundEffect 函数保存起来。recordInstanceBoundEffect 函数实现如下:

image.png

recordInstanceBoundEffect 函数实现的实现很简单,就是将用户通过 watch、watchEffect、computed API 手动创建的 ReactiveEffect 类的实例对象存储到组件实例对象的 effects 属性上。这样在组件卸载的时候,就可以通过获取组件实例上 effects 属性的值进行执行达到取消相关响应式副作用的目的。相关实现如下:

image.png

这个就是 Vue3 组件的响应式副作用是如何收集与清除的实现原理。在 Vue3 源码底层已经自动帮我们实现了在 Vue 组件的 setup 中,初始化的时候响应式副作用将被收集并绑定到当前实例,在实例被卸载的时候,响应式副作用则会自动的被取消追踪了。注意上述的实现是 Vue3.15 中的实现。在 Vue3.2 以后就通过 effectScope 进行实现了,那么为什么要通过 effectScope 进行实现呢?

手动处理响应式副作用的弊端

经过上文我们知道响应式副作用失效之后需要及时把它们销毁掉,否则会存在内存泄漏和意外行为的风险。而在 Vue3 的底层已经自动帮我们实现了响应式副作用的处理,我们在平时写应用的时候无需担心。但我们如果想实现一些基于 Vue3 响应式库 @vue/reactivity 的公共库的时候,我们可能就需要手动处理响应式副作用了。

例如下面的代码例子:

const count1 = ref(0)
const count2 = ref(0)
// 用于存储副作用对象,以便后续可以停止它们
const effectStacks = []
// 观察响应式变量 count1 的变化情况
const effect1 = effect(() => {
    console.log(`effect1:${count1.value}`)
})
// 手动收集 effect1 的副作用
effectStacks.push(effect1)
// 观察响应式变量 count2 的变化情况
const effect2 = effect(() => {
    console.log(`effect2:${count2.value}`)
})
// 手动收集 effect2 的副作用
effectStacks.push(effect2)

// 通过设置定时器每一秒让 count1 和 count2 自动增加 1
const t = setInterval(() => {
    if (count1.value === 2) {
        // 如果等于 2,则遍历 effectStacks 数组,调用每个副作用对象的 stop 方法来停止它们。
        effectStacks.forEach(effect => effect.effect.stop())
        clearInterval(t)
    } else {
        count1.value++
        count2.value++
    }
}, 1000)

我们上述代码使用 ref 创建了两个响应式变量 count1 和 count2,初始值都为 0,然后通过 effect 函数定义了两个响应式副作用 effect1 和 effect2 用来分别观察响应式变量 count1 和 count2 的变化情况,并且将这两个响应式副作用对象手动收集到 effectStacks 数组中。然后使用 setInterval 设置了一个定时器,每隔 1 秒执行一次,在定时器的回调函数中检查 count1 的值是否等于 2,如果等于 2,则遍历 effectStacks 数组,调用每个副作用对象的 stop 方法来停止它们,否则递增 count1 和 count2 的值。

总的来说就是通过手动收集副作用对象,可以在特定条件下(如 count1 达到 2)停止这些副作用,从而控制程序的执行流程。

现在我们再增加两个响应式变量 count3 和 count4,再分别观察它们的变化情况。

// 省略...
+ const count3 = ref(0)
+ const count4 = ref(0)
// 省略...

+ // 观察响应式变量 count3 的变化情况
+ const effect3 = effect(() => {
+    console.log(`effect1:${count3.value}`)
+ })
+ // 手动收集 effect3 的副作用
+ effectStacks.push(effect3)
+ // 观察响应式变量 count4 的变化情况
+ const effect4 = effect(() => {
+     console.log(`effect2:${count4.value}`)
+ })
+ // 手动收集 effect4 的副作用
+ effectStacks.push(effect4)

// 通过设置定时器每一秒让 count1 和 count2 自动增加 1
const t = setInterval(() => {
    if (count1.value === 2) {
        // 如果等于 2,则遍历 effectStacks 数组,调用每个副作用对象的 stop 方法来停止它们。
        effectStacks.forEach(effect => effect.effect.stop())
        clearInterval(t)
    } else {
        count1.value++
        count2.value++
+        count3.value++
+        count4.value++
    }
}, 1000)

现在我们想实现当 count1 的值等于 2 的时候停止对 count3count4 的观察,也就是要停止 effect3effect4 的副作用。这时我们发现要实现这个比较麻烦,需要我们重新定义一个全局存储 effect3effect4 的副作用的变量。

+ const effectStacks2 = []

// 观察响应式变量 count3 的变化情况
const effect3 = effect(() => {
    console.log(`effect1:${count3.value}`)
})
// 手动收集 effect3 的副作用
- effectStacks.push(effect3)
+ effectStacks2.push(effect3)
// 观察响应式变量 count4 的变化情况
const effect4 = effect(() => {
    console.log(`effect2:${count4.value}`)
})
// 手动收集 effect4 的副作用
- effectStacks.push(effect4)
+ effectStacks2.push(effect4)

// 通过设置定时器每一秒让 count1 和 count2 自动增加 1
const t = setInterval(() => {
    if (count1.value === 2) {
        // 如果等于 2,则遍历 effectStacks2 数组,调用每个副作用对象的 stop 方法来停止对 `count3` 和 `count4` 的观察。
-        effectStacks.forEach(effect => effect.effect.stop())
+        effectStacks2.forEach(effect => effect.effect.stop())
        clearInterval(t)
    } else {
        count1.value++
        count2.value++
        count3.value++
        count4.value++
    }
}, 1000)

我们发现目前我们对响应式副作用的管理是非常麻烦的,怎么可以实现非常方便地管理响应式副作用呢?这时我们的 effectScope 就要登场了。

effectScope 的实现原理

我们在上一小节遇到的问题就是目前我们对响应式副作用的管理是非常的麻烦,我们希望可以很方便地把响应式副作用 effect1effect2 归一组,把 effect3effect4 归一组。其实在 Vue3 组件的响应式副作用的收集与清除 那小节中可以知道,每个组件的响应式副作用都自动收集到组件实例对象上了,所以在组件卸载的时候,也就很方便把相关的副作用也卸载了。那么有什么方案呢?

其实对发布订阅模式理解透彻的同学,可以很清楚地知道,我们在上一小节中实现的手动进行处理响应式副作用的方法,本质就是一个发布订阅模式的应用。

首先是创建一个订阅者存储中心的变量:

const effectStacks = []

然后所谓手动收集每个响应式副作用对象,其实是订阅的动作。

effectStacks.push(effect1)

最后在需要的时候,去通知每一个订阅者。

effectStacks.forEach(effect => effect.effect.stop())

这其实就是发布订阅模式的最核心的要义。

通过我们前面章节对发布订阅模式的学习,我们知道订阅者存储中心可以由一个叫消息代理中心类来实现,例如我们前面实现的 EventBus,通过 new EventBus() 我们就可以创建不同分组的事件总线,很明显这个模式同样适合我们上面的需求。那么如果你熟悉发布订阅模式的话,你可以很快写出我们现在需要实现的消息代理中心类 EffectScope 的基本框架代码。

那么根据我们前面实现 EventBus 类或者消息代理类的实现,我们可以得出以下代码:

class EffectScope {
    // 响应式副作用对象存储中心
    effects = []
    constructor() {

    }
    // 订阅,也就是收集响应式副作用对象
    sub() {

    }
    // 通知,也就是停止收集到的响应式副作用对象
    notify() {
        this.effects.forEach(e => e.stop())
    }
}

现在我们就可以通过以下方式创建不同的响应式副作用分组了。代码如下:

const scope = new EffectScope()

那么接下来就需要思考怎么去实现把响应式副作用对象收集到 EffectScope 类内部的 effects 属性上。在代码实现上我们可以参考 effect 函数的实现,代码如下:

const count1 = ref(0)
const count2 = ref(0)
scope.sub(() => {
    effect(() => {
        console.log(`effect1:${count1.value}`)
    })
    effect(() => {
        console.log(`effect2:${count2.value}`)
    })
})

就是给 sub 方法传递一个包装函数,那么在 EffectScope 类中的 sub 方法最终需要执行一下这个包装函数。

class EffectScope {
    // 省略...
    sub(fn) {
       fn()
    }
   // 省略...
}

通过前面对 Vue3 响应式原理的学习,我们知道所谓响应式副作用对象其实就是 ReactiveEffect 类的实例对象。那么也就是说在实例化 ReactiveEffect 类的时候就需要去把 ReactiveEffect 类的实例对象添加到 EffectScope 类的 effects 属性上。

首先我们需要创建一个记录当前激活的作用域对象的全局变量。代码如下:

+ // 记录当前激活的作用域对象
+ let activeEffectScope
class EffectScope {
    // 省略...
    sub(fn) {
+        activeEffectScope = this
        fn()
+        activeEffectScope = null
    }
   // 省略...
}

如果还记得 Vue 响应式原理的实现的同学,应该对上述代码的套路很熟悉,所以我们真的彻底理解底层的知识,那么学习其他相关的知识就能达到触类旁通的效果,这也是为什么有些人学习新知识学得那么快的原因。

接下来我们就可以在实例化 ReactiveEffect 类的时候就需要去把 ReactiveEffect 类的实例对象添加到全局变量 activeEffectScopeeffects 属性上即可。代码实现如下:

class ReactiveEffect {
    deps = []
    constructor(fn) {
        this._fn = fn
+        // 在定义副作用时,自动将它们关联到当前的作用域。
+        if (activeEffectScope) {
+            activeEffectScope.effects.push(this)
+        }
    }
    // 省略...
} 

这样我们就可以进行重新测试了,测试代码如下:

setInterval(() => {
    console.log('=====')
    if (count1.value === 2) {
        scope1.notify()
    }
    count1.value++
    count2.value++
}, 1000)

测试结果如下:

tutieshi_454x284_6s.gif

从测试结果可以看到,我们实现了通过作用域对响应式副作用对象的收集和卸载是成功的。

为了我们的代码更有语义,我们对上述代码进行迭代优化:

class ReactiveEffect {
    deps = []
    constructor(fn) {
        this._fn = fn
-        if (activeEffectScope) {
-            activeEffectScope.effects.push(this)
-        }
+        recordEffectScope(this)
    }
    // 省略...
} 

// 省略...

+ function recordEffectScope(effect) {
+     if (activeEffectScope) {
+         activeEffectScope.effects.push(effect)
+     }
+ }

封装一个在定义副作用时,自动将它们关联到当前的作用域的函数:recordEffectScope

同时修改 EffectScope 类中的相关方法的名称让它们更具有语义性。具体修改如下:

class EffectScope {
    // 省略...
-    sub() {
+    run(fn) {
    // 省略...
    }
    
-    notify() {
+    stop() {
        // 省略...
    }
}

+ // 创建作用域的工厂函数
+ function effectScope() {
+     return new EffectScope()
+ }

同时封装了一个创建作用域的工厂函数 effectScope

这时我们再实现我们之前的需求就很方便了。代码实现如下:

const count1 = ref(0)
const count2 = ref(0)
const count3 = ref(0)
const count4 = ref(0)
// 作用域1
const scope1 = effectScope()
scope1.run(() => {
    effect(() => {
        console.log(`effect1:${count1.value}`)
    })

    effect(() => {
        console.log(`effect2:${count2.value}`)
    })
})
// 作用域2
const scope2 = effectScope()
scope2.run(() => {
    effect(() => {
        console.log(`effect3:${count4.value}`)
    })

    effect(() => {
        console.log(`effect4:${count4.value}`)
    })
})

setInterval(() => {
    console.log('=====')
    if (count1.value === 1) {
        // 当 count1 等于 1 时停止作用域2的依赖追踪
        scope2.stop()
    }
    count1.value++
    count2.value++
    count3.value++
    count4.value++
}, 1000)

测试结果如下:

tutieshi_460x444_4s.gif

自此我们就实现了 effectScope 的最核心的功能,本质上就是一个发布订阅模式的应用,effectScope 函数是一个工厂函数,通过实例化 EffectScope 类,创建不同的作用域对象,而 EffectScope 类本质上是发布订阅模式中的消息代理类或者我们经常说的事件总线类,然后通过 run 方法运行一个包装函数,本质上是在订阅响应式副作用对象,最后可以通过 stop 方法通知每个订阅的响应式副作用对象进行停止追踪响应式依赖。所以如果你对发布订阅模式非常熟悉,那么你对 effectScope 的实现原理也非常容易理解了。

嵌套作用域

我们目前想实现这样的功能,在一个作用域里面嵌套一个作用域,代码如下:

const count1 = ref(0)
const count2 = ref(0)
const count3 = ref(0)
const count4 = ref(0)
const scope1 = effectScope()
// 作用域1
scope1.run(() => {
    effect(() => {
        console.log(`effect1:${count1.value}`)
    })

    effect(() => {
        console.log(`effect2:${count2.value}`)
    })
    // 嵌套作用域
    const scope2 = effectScope()
    scope2.run(() => {
        effect(() => {
            console.log(`effect3:${count4.value}`)
        })

        effect(() => {
            console.log(`effect4:${count4.value}`)
        })
    })
})

setInterval(() => {
    console.log('=====')
    if (count1.value === 1) {
        // 停止外层作用域的依赖追踪
        scope1.stop()
    }
    count1.value++
    count2.value++
    count3.value++
    count4.value++
}, 1000)

我们想当停止外层作用域的依赖追踪后,嵌套的作用域中的依赖也停止追踪。目前测试结果如下:

tutieshi_444x392_4s.gif

我们发现当我们停止了外层作用域的依赖追踪后,嵌套的作用域中的依赖还是能够进行追踪的,这是因为我们目前是已经实现了作用域隔离,也就是不同作用域中的依赖是互不干扰的,但有些场景可能我们又需要嵌套作用域是能够关联的,也就是停止了外层作用域,嵌套的作用域也应该停止。

要实现这个功能,其实也很简单,还是通过发布订阅模式的应用去实现,从上文可以知道,effectScope 的实现原理本质就是发布订阅模式的应用,EffectScope 类就是消息代理中心,所谓订阅者就是 ReactiveEffect 类的实例对象。从在们前面所学的知识可以知道,订阅者也可以是发布者,发布者也可以是订阅者,或者说观察者也可以是被观察者,被观察者也可以是观察者。

所以根据这个规则,我们可以让父级的 EffectScope 订阅嵌套的 EffectScope。代码实现如下:

class EffectScope {
    effects = []
    constructor() {
        // 订阅嵌套的 EffectScope
+        recordEffectScope(this)
    }
    // 省略...
}

而 EffectScope 类上有个 stop 方法,而 ReactiveEffect 类上也有一个 stop 方法,所以在执行父级作用域的 stop 方法循环 effects 属性上的订阅者的时候,有可能是嵌套的作用域,而因为都共同拥有一个 stop 方法,所以在执行嵌套作用域的实例对象的 stop 方法的时候又会去循环嵌套作用域中 effets 属性中订阅者,这样就实现了父作用域与嵌套作用域的依赖的共同管理了。

这时我们再来测试一下上述的嵌套作用域的测试代码。测试结果如下:

tutieshi_444x324_4s.gif

这时我们发现清除父级作用域的时候,嵌套作用域的响应式副作用也被清除了。

我们还需要继续迭代一下我们的功能,现在是默认就关联收集了嵌套作用域了,这样就失去了隔离作用域的作用了。那么我们希望做一个开关,开关开启的时候就进行作用域隔离,默认就收集嵌套作用域的响应式副作用。

实现代码如下:

class EffectScope {
    // 省略...
-    constructor() {
+    constructor(detached = false) {
+        if (!detached) {
            recordEffectScope(this)
+        }
    }
    // 省略...
}

// 创建作用域的工厂函数
- function effectScope() {
+ function effectScope(detached) {
-    return new EffectScope()
+    return new EffectScope(detached)
}

这样我们就初步实现了 effectScope 功能了。

在 Vue3 底层应用 effectScope

在 Vue3.2 以后 Vue3 组件的响应式副作用的收集与清除的实现就通过 effectScope 进行了。通过上文我们知道一个组件的响应式副作用是有两种类型的,分别是由组件的 render 函数的包装函数和用户通过 watch、watchEffect、computed API 手动创建的响应式副作用。在 Vue3.2 以前,它们分别收集在组件实例的 effect 和 effects 两个属性上。在 Vue3.2 以后实现就通过 effectScope 进行实现了,就只需需要一个 scope 属性来存储 EffectScope 实例对象即可。

image.png

从上图我们可以看到在 Vue3.2 以后组件实例化后,也会在组件实例对象的 scope 属性实例化一个 EffectScope 实例对象。

然后我们知道一个组件的响应式变量是在 setup 方法中创建的,然后在 render 方法中使用,当响应式变量发生变化的时候,render 函数重新执行,而要实现这个功能是通过 ReactiveEffect 来实现的。

image.png

然后通过上文对 effectScope 的实现原理的讲解我们知道,在实例化 ReactiveEffect 的时候,会把 ReactiveEffect 实例对象收集到 EffectScope 的实例对象的 effects 属性上。然后在组件卸载的时候,就可以通过组件实例对象上的 scope 属性的 stop 方法进行卸载相关的副作用了。

image.png

隔离副作用的实际应用

我们使用 Vue3 Composition API 编写一个自定义钩子(hook)函数,名为 useCounter。它的功能是实现一个简单的计数器,并附带了一个额外的特性:当计数器的值是偶数时,计算并存储这个值的两倍。

以下是 useCounter 的代码实现:

import { ref, watch } from "vue"

export function useCounter() {
    // 定义计数器
    const counter = ref(0)
    // 增加
    const increment = () => counter.value++
    // 减少
    const decrement = () => counter.value--
    // 计数器的偶数双倍值
    const doubleCount = ref(0)
    // 监听计数器值的变化
    watch(() => counter.value, (newVal) => {
        // 当计数器的值是偶数时,计算并存储这个值的两倍
        if (newVal % 2 === 0) {
            doubleCount.value = newVal * 2 
        }
    })

    return {
      counter,
      doubleCount,
      increment,
      decrement
    }
}

接着我们在两个组件中使用它。

Counter1.vue

<template>
    <div>
        <p>当前值: {{state.counter}}</p>
        <p>偶数双倍值:{{state.doubleCount}}</p>
        <button @click="state.increment">+</button>
        <button @click="state.decrement">-</button>
    </div>
</template>
<script setup>
import { useCounter } from '../hooks/useCounter';
const state = useCounter()
</script>

Counter2.vue

<template>
    <div>
        <p>当前值: {{state.counter}}</p>
        <p>偶数双倍值:{{state.doubleCount}}</p>
        <button @click="state.increment">+</button>
        <button @click="state.decrement">-</button>
    </div>
</template>
<script setup>
import { useCounter } from '../hooks/useCounter';
const state = useCounter()
</script>

接着在 App.vue 中引用它们。

App.vue

<script setup>
import Counter1 from './components/Counter1.vue'
import Counter2 from './components/Counter2.vue'
</script>

<template>
  <Counter1 />
  <Counter2 />
</template>

实现效果如下:

tutieshi_442x432_12s.gif

我们当前的实现是两个组件的状态是不共享的,分别各自计算各自的值,现在我们希望它们是互相共享状态的,也就是点击 组件1 中的按钮进行计算的时候,组件2 中的状态也是同时改变的,同样地点击组件2中的按钮进行计算的时候,组件1中的状态也是同时改变的。

通常要在多个组件之间共享数据状态,我们一般在最上层的父组件创建响应式变量,然后通过层层传递进行使用,这种很明显层级过多时候很不方便;或者使用 Vuex 或者 Pinia,但一般在小型项目中,比如我们上述的计数器功能,如果我们也引用这种第三方库,代码就显得很臃肿了。所以我们可以自己实现一个小型的状态管理工具函数。

那么我们要实现在多个组件共享数据状态,本质是要创建一个单例的数据状态变量,也就是单例模式的应用。

单例模式是一种设计模式,目的是确保一个类或者对象在整个应用生命周期中只被实例化一次,并提供全局访问点。

在 JavaScript 中,单例模式通常通过闭包来实现,利用闭包保存一个私有的实例变量,同时通过一个函数来控制创建和访问这个实例。

具体代码实现如下:

function createGlobalState(stateFactory) {
    let initialized = false;
    let state;
    return ((...args) => {
      if (!initialized) {
        state = stateFactory(...args);
        initialized = true;
      }
      return state;
    });
}

上面的 JavaScript 代码通过闭包和函数表达式实现了一个简单的单例模式,确保某个状态(state)对象只会被创建一次,并始终返回同一个实例。

createGlobalState 是一个工厂函数,它接受一个参数 stateFactory,这个参数也是一个工厂函数,负责生成状态对象。也就是说,我们把状态对象的创建逻辑封装在 stateFactory 中。对于我们上面的计算器的实现例子,那么这个参数就是 useCounter 函数。使用例子如下:

export const useCounterState = createGlobalState(useCounter)

createGlobalState 返回的是一个匿名函数(箭头函数),从上述例子可以知道变量 useCounterState 就是一个函数,这个函数会被用来获取状态对象。

在 createGlobalState 函数内部,声明了两个私有变量:initialized 标记状态对象是否已经被初始化(默认值是 false), state 变量存储状态对象的引用。只有当 initialized 是 false 时,才会调用 stateFactory 创建状态对象,并将其赋值给 state。同时将 initialized 设置为 true,表示状态对象已经被创建。这样每次调用匿名函数时,都会返回同一个 state 对象,从而实现单例模式的效果。

接下来我们在两个组件 Counter1.vue 和 Counter2.vue 中进行以下引用:

import { useCounterState } from '../hooks/useCounter';
const state = useCounterState();

然后测试结果如下:

tutieshi_420x408_8s.gif

这时,我们可以看到两个组件的状态实现了互相共享,也就是点击 组件1 中的按钮进行计算的时候,组件2 中的状态也是同时改变的,同样地点击组件2中的按钮进行计算的时候,组件1中的状态也是同时改变的。

至此我们好像还没讲到实现副作用隔离的作用是什么。接下来我们再实现一个小功能,代码如下:

<script setup>
import { ref } from 'vue'
import Counter1 from './components/Counter1.vue'
import Counter2 from './components/Counter2.vue'

+ const isShow = ref(true)
+ const handleHide = () => {
+   isShow.value = false
+ }
</script>

<template>
-  <Counter1 />
+  <Counter1 v-if="isShow" />
  <Counter2 />
+  <button @click="handleHide">隐藏第一个组件</button>
</template>

实现效果如下:

tutieshi_392x384_10s.gif

我们可以看到当我们隐藏第一个组件之后,第二个组件的偶数双倍值失效了。这是为什么呢?首先是因为偶数双倍值的实现是通过 watch 来实现的,从而产生了一个副作用,并且因为第一个组件是最新执行的,所以这个副作用就被收集到了第一个组件的实例对象上,而又因为我们是通过单例模式实现了状态共享,所以第二个组件使用的状态变量实际上跟第一个组件使用的状态变量是同一个,所以第一个组件使用 watch 产生的副作用被隐藏从而删除之后,第二个组件的相关功能也就失效了。

所以这个时候,我们就要想办法,让这些第三方的库产生的副作用不要和组件进行绑定,而是要和组件进行隔离,这个时候很明显就需要用到 effectScope 功能了,也是 effectScope 功能的最大作用之一。所以我们对 createGlobalState 函数进行修改,具体修改如下:

function createGlobalState(stateFactory) {
    let initialized = false;
    let state;
+    const scope = effectScope(true)
    return ((...args) => {
      if (!initialized) {
-        state = stateFactory(...args);
+        state = scope.run(() => stateFactory(...args));
        initialized = true;
      }
      return state;
    });
}

通过上文我们知道 effectScope 函数传参为 true 时就会进行作用域隔离。

这时我们再进行测试:

tutieshi_274x374_9s.gif

这时我们发现当我们隐藏第一个组件的时候,第二个组件的偶数双倍值功能不再受影响了。

至此 Vue3 中新增的 effectScope API 功能的实现原理和相关作用我们都介绍得差不多了。

总结

effectScope 是 Vue 3.2 提供的高阶响应式副作用管理工具,其核心本质是发布订阅模式的应用。通过 EffectScope 类作为消息代理中心,run 方法负责收集当前作用域内的所有 ReactiveEffect 实例(即副作用),stop 方法则批量停止它们。它还支持嵌套作用域,通过 detached 参数控制父子作用域是否关联,实现了灵活的副作用隔离。

在 Vue 3.2 之后,组件内部使用 effectScope 统一管理渲染副作用和用户定义的 watch/computed 副作用,替代了之前分散在 instance.effect 和 effects 数组的手动管理方式,简化了代码并提升了内存安全。此外,在开发可复用的组合式函数(如 createGlobalState 实现全局状态共享)时,利用隔离的 effectScope 可以避免副作用被错误绑定到特定组件上,从而保证状态跨组件共享时的正确性。掌握 effectScope 有助于深入理解 Vue 3 响应式系统及构建更健壮的公共库。

我是程序员Cobyte,现在已转向研究 AI Agent,欢迎添加 v: icobyte,学习交流 AI Agent 应用开发。

Flutter 自制轻量级状态管理方案

作者 MonkeyKing
2026年4月27日 09:28

在Flutter开发中,状态管理是绕不开的话题。市面上成熟的方案层出不穷——GetX简洁高效、Bloc规范可测、Riverpod灵活易用,但很多时候我们会陷入“过度依赖”的困境:明明只是一个简单的页面状态,却要引入庞大的第三方库,增加项目体积和学习成本;复杂项目中,第三方库的“黑盒逻辑”又会导致排查问题时无从下手。

其实,对于大多数中小型项目、独立模块,我们完全可以自制一套轻量级状态管理方案。它不需要复杂的架构设计,无需引入任何第三方依赖,仅用Flutter原生API就能实现“状态监听、响应式更新、逻辑与UI解耦”,既精简了项目体积,又能让我们完全掌控状态流转的每一步。

本文就从实战出发,带你从零搭建一套可复用的轻量级状态管理方案,搭配3个递进式案例,从基础计数器到异步请求,让你轻松掌握核心逻辑,按需定制适合自己项目的状态管理方式。

一、为什么要自制轻量级状态管理?

在开始实现之前,我们先明确一个核心问题:既然有这么多成熟的第三方库,为什么还要自制?答案很简单——按需定制,拒绝冗余

  • 减少依赖冗余:很多第三方库集成了路由、依赖注入、国际化等功能,若仅需状态管理,引入后会增加项目体积(如GetX约1.5MB+),自制方案仅需几十行核心代码,无任何冗余;
  • 掌控核心逻辑:第三方库的“封装黑盒”的问题,遇到状态异常时难以排查,自制方案的每一行代码都可自定义,调试、修改更灵活;
  • 降低学习成本:无需学习第三方库的API规范(如GetX的.obs、Obx,Bloc的Event/State),仅依赖Flutter原生API(如ChangeNotifier、InheritedWidget),上手门槛极低;
  • 灵活适配需求:可根据项目复杂度按需扩展,简单场景用基础版,复杂场景可逐步增加监听、防抖、状态持久化等功能,不被第三方库的设计限制。

当然,自制方案也有局限性——不适合超大型、多人协作的复杂项目(这类项目更需要Bloc/Riverpod的规范约束),但对于中小型项目、独立模块,它绝对是“性价比之王”。

二、核心实现思路(基于Flutter原生API)

自制轻量级状态管理的核心,是利用Flutter原生的 ChangeNotifier(状态通知)和Consumer/AnimatedBuilder(状态监听),搭配简单的封装,实现“状态集中管理、UI响应式更新”,核心思路分为3步:

  1. 状态封装:创建状态管理类,继承ChangeNotifier,集中管理所有状态和业务逻辑,状态修改后调用notifyListeners()通知UI更新;
  2. 状态共享:通过InheritedWidgetProvider(Flutter原生,非第三方)将状态管理类共享给子组件,避免状态层层传递;
  3. UI监听:子组件通过ConsumerAnimatedBuilder监听状态变化,仅在状态更新时重建相关UI,避免不必要的重建。

注意:这里用到的Provider是Flutter SDK自带的(package:flutter/material.dart中内置),并非第三方库provider,无需额外引入依赖,真正做到“零依赖”。

三、实战案例:从基础到进阶,逐步实现

下面我们通过3个案例,从简单到复杂,逐步实现自制轻量级状态管理方案,每个案例都可直接复制到项目中使用。

案例1:基础版——计数器(最简洁的状态管理)

需求:实现一个简单的计数器,点击按钮增减计数,UI实时更新,无需任何第三方依赖。

1. 封装状态管理类(CounterViewModel)

// counter_view_model.dart
import 'package:flutter/foundation.dart';

// 状态管理类:继承ChangeNotifier,管理状态和业务逻辑
class CounterViewModel extends ChangeNotifier {
  // 私有状态(仅内部可修改)
  int _count = 0;

  // 对外提供只读状态(禁止外部直接修改)
  int get count => _count;

  // 状态修改方法(所有状态修改都通过方法,便于追溯和调试)
  void increment() {
    _count++;
    // 通知UI状态已更新,触发重建
    notifyListeners();
  }

  void decrement() {
    if (_count > 0) {
      _count--;
      notifyListeners();
    }
  }
}

核心要点:状态私有化(_count),对外提供只读getter,所有状态修改都通过方法实现,避免外部直接修改状态导致的混乱,这也是状态管理的核心规范。

2. 状态共享(通过InheritedWidget封装)

// counter_provider.dart
import 'package:flutter/material.dart';
import 'counter_view_model.dart';

// 自定义InheritedWidget,实现状态共享
class CounterProvider extends InheritedWidget {
  // 持有状态管理类实例
  final CounterViewModel viewModel;

  // 构造函数:接收子组件和状态管理实例
  const CounterProvider({
    super.key,
    required this.viewModel,
    required super.child,
  });

  // 静态方法:方便子组件获取状态管理实例(无需层层传递)
  static CounterProvider of(BuildContext context) {
    final CounterProvider? result =
        context.dependOnInheritedWidgetOfExactType<CounterProvider>();
    assert(result != null, 'CounterProvider not found in context');
    return result!;
  }

  // 判断是否需要通知子组件重建:状态变化时返回true
  @override
  bool updateShouldNotify(CounterProvider oldWidget) {
    return oldWidget.viewModel.count != viewModel.count;
  }
}

3. UI组件使用(CounterPage)

// counter_page.dart
import 'package:flutter/material.dart';
import 'counter_provider.dart';
import 'counter_view_model.dart';

class CounterPage extends StatelessWidget {
  const CounterPage({super.key});

  @override
  Widget build(BuildContext context) {
    // 1. 获取状态管理实例
    final counterViewModel = CounterProvider.of(context).viewModel;

    return Scaffold(
      appBar: AppBar(title: const Text("自制轻量状态管理:计数器")),
      body: Center(
        // 2. 监听状态变化,仅当count变化时重建Text组件
        child: AnimatedBuilder(
          animation: counterViewModel, // 监听ChangeNotifier实例
          builder: (context, child) {
            return Text(
              "当前计数:${counterViewModel.count}",
              style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
            );
          },
        ),
      ),
      floatingActionButton: Row(
        mainAxisAlignment: MainAxisAlignment.end,
        children: [
          FloatingActionButton(
            onPressed: counterViewModel.decrement,
            child: const Icon(Icons.remove),
          ),
          const SizedBox(width: 16),
          FloatingActionButton(
            onPressed: counterViewModel.increment,
            child: const Icon(Icons.add),
          ),
        ],
      ),
    );
  }
}

4. 入口使用(main.dart)

// main.dart
import 'package:flutter/material.dart';
import 'counter_page.dart';
import 'counter_provider.dart';
import 'counter_view_model.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    // 提供状态管理实例,让子组件可访问
    return CounterProvider(
      viewModel: CounterViewModel(),
      child: MaterialApp(
        title: '自制轻量状态管理',
        theme: ThemeData(primarySwatch: Colors.blue),
        home: const CounterPage(),
      ),
    );
  }
}

效果说明:点击增减按钮,count状态变化后,notifyListeners()触发AnimatedBuilder重建,UI实时更新,整个方案仅用3个文件,几十行核心代码,无任何第三方依赖。

案例2:进阶版——异步请求(处理加载/成功/失败状态)

需求:实现一个商品列表页面,发起异步请求获取商品数据,处理“加载中、加载成功、加载失败”三种状态,UI根据状态展示对应内容,这是实际开发中最常见的场景。

1. 封装状态管理类(ProductViewModel)

// product_view_model.dart
import 'package:flutter/foundation.dart';

// 商品模型
class Product {
  final int id;
  final String name;
  final double price;

  const Product({required this.id, required this.name, required this.price});
}

// 加载状态枚举(规范状态类型,避免魔法值)
enum LoadStatus { loading, success, error }

// 状态管理类
class ProductViewModel extends ChangeNotifier {
  // 状态:商品列表、加载状态、错误信息
  List<Product> _products = [];
  LoadStatus _loadStatus = LoadStatus.loading;
  String _errorMsg = "";

  // 对外提供只读状态
  List<Product> get products => _products;
  LoadStatus get loadStatus => _loadStatus;
  String get errorMsg => _errorMsg;

  // 异步请求:获取商品列表
  Future<void> fetchProducts() async {
    try {
      // 1. 切换为加载中状态
      _loadStatus = LoadStatus.loading;
      notifyListeners();

      // 2. 模拟网络请求(实际项目替换为真实接口)
      await Future.delayed(const Duration(seconds: 2));
      // 模拟请求成功数据
      final mockData = List.generate(10, (index) {
        return Product(
          id: index + 1,
          name: "商品${index + 1}",
          price: 39.9 + index * 10,
        );
      });

      // 3. 请求成功,更新状态
      _products = mockData;
      _loadStatus = LoadStatus.success;
      _errorMsg = "";
    } catch (e) {
      // 4. 请求失败,更新错误状态
      _loadStatus = LoadStatus.error;
      _errorMsg = "加载失败:${e.toString()}";
    } finally {
      // 5. 无论成功失败,都通知UI更新
      notifyListeners();
    }
  }

  // 重新加载
  Future<void> reloadProducts() async {
    await fetchProducts();
  }
}

2. 状态共享(复用InheritedWidget封装)

// product_provider.dart
import 'package:flutter/material.dart';
import 'product_view_model.dart';

class ProductProvider extends InheritedWidget {
  final ProductViewModel viewModel;

  const ProductProvider({
    super.key,
    required this.viewModel,
    required super.child,
  });

  static ProductProvider of(BuildContext context) {
    final ProductProvider? result =
        context.dependOnInheritedWidgetOfExactType<ProductProvider>();
    assert(result != null, 'ProductProvider not found in context');
    return result!;
  }

  @override
  bool updateShouldNotify(ProductProvider oldWidget) {
    // 状态变化时通知重建(只要任意状态变化,就触发更新)
    return oldWidget.viewModel.loadStatus != viewModel.loadStatus ||
        oldWidget.viewModel.products != viewModel.products ||
        oldWidget.viewModel.errorMsg != viewModel.errorMsg;
  }
}

3. UI组件使用(ProductListPage)

// product_list_page.dart
import 'package:flutter/material.dart';
import 'product_provider.dart';
import 'product_view_model.dart';

class ProductListPage extends StatelessWidget {
  const ProductListPage({super.key});

  @override
  Widget build(BuildContext context) {
    final productViewModel = ProductProvider.of(context).viewModel;

    // 页面初始化时发起请求
    WidgetsBinding.instance.addPostFrameCallback((_) {
      productViewModel.fetchProducts();
    });

    return Scaffold(
      appBar: AppBar(title: const Text("商品列表(异步请求)")),
      body: AnimatedBuilder(
        animation: productViewModel,
        builder: (context, child) {
          // 根据加载状态展示不同UI
          switch (productViewModel.loadStatus) {
            case LoadStatus.loading:
              // 加载中
              return const Center(child: CircularProgressIndicator());
            case LoadStatus.error:
              // 加载失败
              return Center(
                child: Column(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: [
                    Text(
                      productViewModel.errorMsg,
                      style: const TextStyle(color: Colors.red, fontSize: 16),
                    ),
                    const SizedBox(height: 16),
                    ElevatedButton(
                      onPressed: productViewModel.reloadProducts,
                      child: const Text("重新加载"),
                    ),
                  ],
                ),
              );
            case LoadStatus.success:
              // 加载成功,展示商品列表
              return ListView.builder(
                itemCount: productViewModel.products.length,
                itemBuilder: (context, index) {
                  final product = productViewModel.products[index];
                  return ListTile(
                    leading: CircleAvatar(child: Text("${product.id}")),
                    title: Text(product.name),
                    subtitle: Text("¥${product.price.toStringAsFixed(1)}"),
                  );
                },
              );
          }
        },
      ),
    );
  }
}

核心要点:通过枚举规范加载状态,异步请求中严格控制状态流转(加载中→成功/失败),所有状态修改都通过方法实现,UI根据状态动态展示,逻辑清晰,可维护性强。

案例3:优化版——全局状态+状态防抖(适配多页面共享)

需求:实现全局用户状态(登录/未登录),多页面可共享该状态,同时实现状态防抖(避免频繁修改状态导致UI频繁重建),模拟登录、退出登录功能。

1. 封装全局状态管理类(GlobalUserViewModel)

// global_user_view_model.dart
import 'package:flutter/foundation.dart';

// 用户模型
class User {
  final String id;
  final String name;
  final String avatar;

  const User({required this.id, required this.name, required this.avatar});
}

// 全局用户状态管理类(单例模式,确保全局唯一)
class GlobalUserViewModel extends ChangeNotifier {
  // 单例实例
  static final GlobalUserViewModel _instance = GlobalUserViewModel._internal();

  // 私有构造函数,禁止外部实例化
  GlobalUserViewModel._internal();

  // 对外提供单例
  static GlobalUserViewModel get instance => _instance;

  // 状态:当前用户(null表示未登录)
  User? _currentUser;

  // 对外提供只读状态
  User? get currentUser => _currentUser;

  // 判断是否登录
  bool get isLogin => _currentUser != null;

  // 防抖计时器(避免频繁调用notifyListeners)
  Duration _debounceDuration = const Duration(milliseconds: 300);
  late Timer _debounceTimer;

  // 登录方法(带防抖)
  void login(User user) {
    // 取消之前的计时器,避免频繁更新
    if (_debounceTimer.isActive) {
      _debounceTimer.cancel();
    }
    // 延迟通知UI,实现防抖
    _debounceTimer = Timer(_debounceDuration, () {
      _currentUser = user;
      notifyListeners();
    });
  }

  // 退出登录方法(带防抖)
  void logout() {
    if (_debounceTimer.isActive) {
      _debounceTimer.cancel();
    }
    _debounceTimer = Timer(_debounceDuration, () {
      _currentUser = null;
      notifyListeners();
    });
  }

  // 初始化防抖计时器
  @override
  void initState() {
    super.initState();
    _debounceTimer = Timer(_debounceDuration, () {});
  }

  // 销毁时取消计时器,避免内存泄漏
  @override
  void dispose() {
    _debounceTimer.cancel();
    super.dispose();
  }
}

2. 全局状态共享(封装全局Provider)

// global_provider.dart
import 'package:flutter/material.dart';
import 'global_user_view_model.dart';

// 全局状态共享,可包含多个全局状态管理实例
class GlobalProvider extends InheritedWidget {
  // 全局用户状态实例(单例)
  final GlobalUserViewModel userViewModel = GlobalUserViewModel.instance;

  const GlobalProvider({super.key, required super.child});

  // 静态方法,方便子组件获取全局状态
  static GlobalProvider of(BuildContext context) {
    final GlobalProvider? result =
        context.dependOnInheritedWidgetOfExactType<GlobalProvider>();
    assert(result != null, 'GlobalProvider not found in context');
    return result!;
  }

  @override
  bool updateShouldNotify(GlobalProvider oldWidget) {
    // 仅当用户状态变化时,通知子组件重建
    return oldWidget.userViewModel.currentUser != userViewModel.currentUser;
  }
}

3. 多页面使用(首页+个人中心)

// home_page.dart(首页)
import 'package:flutter/material.dart';
import 'global_provider.dart';
import 'global_user_view_model.dart';
import 'profile_page.dart';

class HomePage extends StatelessWidget {
  const HomePage({super.key});

  @override
  Widget build(BuildContext context) {
    final globalProvider = GlobalProvider.of(context);
    final userViewModel = globalProvider.userViewModel;

    return Scaffold(
      appBar: AppBar(
        title: const Text("首页"),
        actions: [
          IconButton(
            icon: const Icon(Icons.person),
            onPressed: () {
              Navigator.push(
                context,
                MaterialPageRoute(builder: (context) => const ProfilePage()),
              );
            },
          ),
        ],
      ),
      body: AnimatedBuilder(
        animation: userViewModel,
        builder: (context, child) {
          return Center(
            child: userViewModel.isLogin
                ? Text(
                    "欢迎回来,${userViewModel.currentUser!.name}!",
                    style: const TextStyle(fontSize: 20),
                  )
                : const Text(
                    "请先登录",
                    style: TextStyle(fontSize: 20, color: Colors.grey),
                  ),
          );
        },
      ),
      floatingActionButton: AnimatedBuilder(
        animation: userViewModel,
        builder: (context, child) {
          return FloatingActionButton(
            onPressed: () {
              if (userViewModel.isLogin) {
                // 退出登录
                userViewModel.logout();
              } else {
                // 模拟登录(实际项目替换为真实登录逻辑)
                final user = User(
                  id: "1",
                  name: "Flutter开发者",
                  avatar: "https://api.example.com/avatar.jpg",
                );
                userViewModel.login(user);
              }
            },
            child: Icon(userViewModel.isLogin ? Icons.logout : Icons.login),
          );
        },
      ),
    );
  }
}

// profile_page.dart(个人中心)
import 'package:flutter/material.dart';
import 'global_provider.dart';
import 'global_user_view_model.dart';

class ProfilePage extends StatelessWidget {
  const ProfilePage({super.key});

  @override
  Widget build(BuildContext context) {
    final userViewModel = GlobalProvider.of(context).userViewModel;

    return Scaffold(
      appBar: AppBar(title: const Text("个人中心")),
      body: AnimatedBuilder(
        animation: userViewModel,
        builder: (context, child) {
          if (!userViewModel.isLogin) {
            // 未登录,提示登录
            return const Center(child: Text("请先登录"));
          }

          // 已登录,展示用户信息
          final user = userViewModel.currentUser!;
          return Center(
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                CircleAvatar(
                  radius: 50,
                  backgroundImage: NetworkImage(user.avatar),
                ),
                const SizedBox(height: 16),
                Text("用户名:${user.name}"),
                Text("用户ID:${user.id}"),
              ],
            ),
          );
        },
      ),
    );
  }
}

4. 全局注册(main.dart)

// main.dart
import 'package:flutter/material.dart';
import 'global_provider.dart';
import 'home_page.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    // 全局注册状态,所有页面可共享
    return GlobalProvider(
      child: MaterialApp(
        title: '全局轻量状态管理',
        theme: ThemeData(primarySwatch: Colors.blue),
        home: const HomePage(),
      ),
    );
  }
}

核心优化点:采用单例模式确保全局状态唯一,添加防抖机制避免频繁状态更新导致的UI卡顿,通过GlobalProvider封装多个全局状态,实现多页面状态共享,同时保持代码简洁、可扩展。

四、自制方案的优化与扩展方向

上面的案例的是基础版轻量级状态管理,我们可以根据项目需求,逐步扩展以下功能,让方案更贴合实际开发:

  1. 状态持久化:结合shared_preferences(仅引入必要依赖),实现状态本地缓存(如用户登录状态),避免App重启后状态丢失;
  2. 状态监听优化:通过Selector(可自定义)实现“局部状态监听”,仅监听需要的状态字段,进一步减少UI重建;
  3. 异常统一处理:在状态管理类中封装统一的异常捕获逻辑,避免每个异步方法重复写try-catch;
  4. 多状态组合:通过MultiProvider(Flutter原生)组合多个状态管理类,实现复杂页面的多状态管理。

五、自制方案 vs 第三方库(怎么选?)

很多开发者会纠结:自制方案和第三方库到底该怎么选?这里给出明确的选型建议,结合项目规模和需求来决定:

场景 自制轻量方案 第三方库(GetX/Bloc/Riverpod)
小型项目、独立模块 推荐(精简、灵活、无冗余) 不推荐(引入冗余,学习成本高)
中型项目、状态逻辑简单 推荐(可按需扩展,掌控核心逻辑) 可选(GetX/Riverpod,提升开发效率)
大型项目、多人协作 不推荐(缺乏规范约束,维护成本高) 推荐(Bloc/Riverpod,规范统一,可测试性强)
需要路由、依赖注入等附加功能 不推荐(需额外开发,成本高) 推荐(GetX/Riverpod,一站式解决方案)

六、总结

自制轻量级状态管理方案,核心是“用原生API做极简封装,按需定制”。它不需要复杂的架构设计,也无需依赖任何第三方库,就能实现状态管理的核心需求——状态集中、响应式更新、逻辑与UI解耦。

通过本文的3个案例,我们从基础计数器到全局状态管理,逐步掌握了自制方案的实现思路和技巧。对于中小型项目、独立模块来说,这种方案既能精简项目体积,又能让我们完全掌控状态流转,避免被第三方库的“黑盒逻辑”束缚。

当然,技术选型没有绝对的“最好”,只有“最适合”。如果你的项目是大型多人协作项目,Bloc/Riverpod等规范的第三方库依然是更好的选择;但如果是小型项目、个人项目,不妨试试自制轻量方案,既能提升开发效率,也能加深对Flutter状态管理核心逻辑的理解。

最后,附上本文所有案例的完整代码,大家可以直接复制到项目中,根据自己的需求修改扩展,真正做到“拿来就用”。

iOS 音频会话 AVAudioSession 完整机制:分类、模式、激活策略

作者 MonkeyKing
2026年4月27日 09:27

在iOS开发中,只要涉及音频播放、录制(如音乐播放器、语音通话、录音APP),就绕不开 AVAudioSession。它是iOS系统管理音频资源的“总管家”,负责协调APP与系统、其他APP之间的音频抢占、路由切换(扬声器/耳机/蓝牙)、音量控制等核心逻辑。

很多开发者在开发音频相关功能时,常会遇到“播放没声音”“插入耳机不切换路由”“后台播放被中断”“与其他音频APP冲突”等问题,本质上都是对 AVAudioSession 的机制理解不透彻,尤其是分类、模式的选择和激活策略的运用出现了偏差。

本文将从基础概念入手,逐步拆解 AVAudioSession 的完整机制,重点讲解分类、模式的核心作用及选型逻辑,结合激活策略和实战避坑,搭配可直接复用的代码示例,帮你彻底掌握这个iOS音频开发的核心知识点。

一、先搞懂:AVAudioSession 到底是什么?

AVAudioSession 是 Apple 提供的音频会话管理类(隶属于 AVFoundation 框架),它的核心作用是统一管理APP的音频行为,并与系统音频服务进行通信,解决“多个音频APP共存时的资源竞争”“音频硬件(扬声器、耳机等)的路由分配”“音频场景适配”三大核心问题。

简单来说,你的APP想播放或录制音频,必须先通过 AVAudioSession 向系统“报备”自己的音频需求(比如“我要播放音乐,希望能后台播放”“我要录音,需要关闭其他音频”),系统再根据所有APP的“报备”情况,分配音频资源、决定音频路由。

核心特性总结:

  • 单例模式:整个APP只有一个 AVAudioSession 实例,通过 [AVAudioSession sharedInstance] 获取,全局共享。
  • 行为契约:通过“分类+模式”定义APP的音频行为,系统根据这个契约分配资源。
  • 路由管理:自动或手动控制音频输出/输入路由(扬声器、耳机、蓝牙音箱、麦克风等)。
  • 状态监听:监听音频会话的中断(如来电、闹钟)、路由变化(插入/拔出耳机)等事件,适配场景变化。

基础使用代码(OC/Swift)

无论后续配置分类、模式,第一步都是获取单例并导入头文件,以下是基础模板代码,可直接复用:

// OC 基础模板(需导入 AVFoundation 头文件)
#import <AVFoundation/AVFoundation.h>

// 获取 AVAudioSession 单例
AVAudioSession *audioSession = [AVAudioSession sharedInstance];

// 快速判断当前会话激活状态
BOOL isActive = audioSession.isActive;
NSLog(@"当前音频会话激活状态:%@", isActive ? @"已激活" : @"未激活");
// Swift 基础模板(需导入 AVFoundation 框架)
import AVFoundation

// 获取 AVAudioSession 单例
let audioSession = AVAudioSession.sharedInstance()

// 快速判断当前会话激活状态
let isActive = audioSession.isActive
print("当前音频会话激活状态:isActive ? "已激活" : "未激活")")

二、核心机制1:音频会话分类(Category)—— 定义音频行为的“基础规则”

分类(Category)是 AVAudioSession 最核心的配置,它直接决定了APP的音频行为边界,比如“是否允许后台播放”“是否与其他音频APP共存”“是否需要使用麦克风”。

Apple 提供了7种官方分类(iOS 10+ 稳定支持),每种分类对应特定的音频场景,开发者需根据APP的核心功能选择,不可随意搭配。下面重点讲解常用分类,结合场景说明选型逻辑,并附上对应配置代码。

1. 常用核心分类(必掌握)

(1)AVAudioSessionCategoryPlayback —— 纯播放场景(推荐音乐/视频APP)

核心作用:用于仅播放音频的场景(如音乐播放器、播客APP),是最常用的分类之一。

关键特性:

  • 默认不允许与其他音频APP共存(会抢占其他APP的音频资源,比如打开你的音乐APP,其他正在播放的音乐APP会暂停)。
  • 支持后台播放(需在 Info.plist 中配置UIBackgroundModesaudio)。
  • 支持静音开关控制(静音模式下,若未连接耳机,音频会静音;连接耳机则正常播放)。
  • 不使用麦克风(若需同时播放+录音,不可用此分类)。

配置代码(音乐播放器场景)

// OC 配置:纯音乐播放(支持后台播放)
#import <AVFoundation/AVFoundation.h>

- (void)configurePlaybackCategory {
    AVAudioSession *audioSession = [AVAudioSession sharedInstance];
    NSError *error = nil;
    
    // 配置分类为 Playback,模式为默认,允许蓝牙输出
    [audioSession setCategory:AVAudioSessionCategoryPlayback
                         mode:AVAudioSessionModeDefault
                       options:AVAudioSessionCategoryOptionAllowBluetooth
                         error:&error];
    
    if (error) {
        NSLog(@"Playback 分类配置失败:%@", error.localizedDescription);
        return;
    }
    
    // 激活会话(后续会详细讲解激活策略)
    [audioSession setActive:YES withOptions:AVAudioSessionSetActiveOptionNotifyOthersOnDeactivation error:&error];
    if (error) {
        NSLog(@"会话激活失败:%@", error.localizedDescription);
    }
}
// Swift 配置:纯音乐播放(支持后台播放)
import AVFoundation

func configurePlaybackCategory() {
    let audioSession = AVAudioSession.sharedInstance()
    do {
        // 配置分类为 Playback,模式为默认,允许蓝牙输出
        try audioSession.setCategory(.playback, mode: .default, options: .allowBluetooth)
        // 激活会话
        try audioSession.setActive(true, options: .notifyOthersOnDeactivation)
    } catch {
        print("Playback 分类配置/激活失败:(error.localizedDescription)")
    }
}

备注:配置后台播放时,需在 Info.plist 中添加 UIBackgroundModes 数组,添加 audio 字段,否则退到后台后音频会立即停止。

适用场景:音乐播放器、视频播放器、有声书APP。

(2)AVAudioSessionCategoryRecord —— 纯录音场景(推荐录音/语音APP)

核心作用:用于仅录制音频的场景(如录音APP、语音备忘录)。

关键特性:

  • 会强制抢占所有音频资源,其他正在播放的音频APP会立即暂停。
  • 不支持后台录音(除非配置后台模式,但需注意隐私权限,且iOS对后台录音有严格限制)。
  • 必须请求麦克风权限(Info.plist 配置NSMicrophoneUsageDescription)。
  • 静音开关不影响录音(即使手机静音,麦克风依然可以正常录音)。

配置代码(录音APP场景)

// OC 配置:纯录音(需先请求麦克风权限)
#import <AVFoundation/AVFoundation.h>

- (void)configureRecordCategory {
    // 1. 请求麦克风权限
    [AVCaptureDevice requestAccessForMediaType:AVMediaTypeAudio completionHandler:^(BOOL granted) {
        if (!granted) {
            NSLog(@"麦克风权限未授权,无法录音");
            return;
        }
        
        // 2. 配置录音分类
        AVAudioSession *audioSession = [AVAudioSession sharedInstance];
        NSError *error = nil;
        [audioSession setCategory:AVAudioSessionCategoryRecord
                             mode:AVAudioSessionModeDefault
                           options:0
                             error:&error];
        
        if (error) {
            NSLog(@"Record 分类配置失败:%@", error.localizedDescription);
            return;
        }
        
        // 3. 激活会话
        [audioSession setActive:YES error:&error];
        if (error) {
            NSLog(@"会话激活失败:%@", error.localizedDescription);
        }
    }];
}
// Swift 配置:纯录音(需先请求麦克风权限)
import AVFoundation

func configureRecordCategory() {
    // 1. 请求麦克风权限
    AVCaptureDevice.requestAccess(for: .audio) { granted in
        guard granted else {
            print("麦克风权限未授权,无法录音")
            return
        }
        
        // 2. 配置录音分类
        let audioSession = AVAudioSession.sharedInstance()
        do {
            try audioSession.setCategory(.record, mode: .default)
            // 3. 激活会话
            try audioSession.setActive(true)
        } catch {
            print("Record 分类配置/激活失败:(error.localizedDescription)")
        }
    }
}

备注:Info.plist 需添加 NSMicrophoneUsageDescription(描述麦克风使用场景,如“用于录制语音”),否则会崩溃。

适用场景:录音APP、语音备忘录、语音输入功能。

(3)AVAudioSessionCategoryPlayAndRecord —— 播放+录音场景(推荐语音通话/直播APP)

核心作用:用于同时需要播放和录制音频的场景,是语音通话、直播、K歌APP的核心分类。

关键特性:

  • 支持同时使用扬声器/耳机(播放)和麦克风(录音)。
  • 默认不与其他音频APP共存(会抢占资源),但可通过配置选项允许共存。
  • 支持后台播放/录音(需配置后台模式)。
  • 必须请求麦克风权限,静音开关不影响录音,但会影响播放(静音模式下扬声器无声音)。

配置代码(语音通话场景,最常用)

// OC 配置:语音通话(支持蓝牙、默认扬声器输出)
#import <AVFoundation/AVFoundation.h>

- (void)configurePlayAndRecordCategory {
    // 1. 请求麦克风权限
    [AVCaptureDevice requestAccessForMediaType:AVMediaTypeAudio completionHandler:^(BOOL granted) {
        if (!granted) {
            NSLog(@"麦克风权限未授权,无法进行语音通话");
            return;
        }
        
        AVAudioSession *audioSession = [AVAudioSession sharedInstance];
        NSError *error = nil;
        
        // 配置分类:PlayAndRecord,模式:VoiceChat(语音通话优化)
        // 选项:允许蓝牙、默认扬声器输出、允许与其他音频混音
        AVAudioSessionCategoryOptions options = AVAudioSessionCategoryOptionAllowBluetooth |
                                                AVAudioSessionCategoryOptionDefaultToSpeaker |
                                                AVAudioSessionCategoryOptionMixWithOthers;
        
        [audioSession setCategory:AVAudioSessionCategoryPlayAndRecord
                             mode:AVAudioSessionModeVoiceChat
                           options:options
                             error:&error];
        
        if (error) {
            NSLog(@"PlayAndRecord 分类配置失败:%@", error.localizedDescription);
            return;
        }
        
        // 激活会话,退出时通知其他APP恢复音频
        [audioSession setActive:YES withOptions:AVAudioSessionSetActiveOptionNotifyOthersOnDeactivation error:&error];
        if (error) {
            NSLog(@"会话激活失败:%@", error.localizedDescription);
        }
    }];
}
// Swift 配置:语音通话(支持蓝牙、默认扬声器输出)
import AVFoundation

func configurePlayAndRecordCategory() {
    // 1. 请求麦克风权限
    AVCaptureDevice.requestAccess(for: .audio) { granted in
        guard granted else {
            print("麦克风权限未授权,无法进行语音通话")
            return
        }
        
        let audioSession = AVAudioSession.sharedInstance()
        do {
            // 配置分类:PlayAndRecord,模式:VoiceChat(语音通话优化)
            // 选项:允许蓝牙、默认扬声器输出、允许与其他音频混音
            let options: AVAudioSession.CategoryOptions = [.allowBluetooth, .defaultToSpeaker, .mixWithOthers]
            try audioSession.setCategory(.playAndRecord, mode: .voiceChat, options: options)
            // 激活会话,退出时通知其他APP恢复音频
            try audioSession.setActive(true, options: .notifyOthersOnDeactivation)
        } catch {
            print("PlayAndRecord 分类配置/激活失败:(error.localizedDescription)")
        }
    }
}

补充:该分类可通过 AVAudioSessionCategoryOptionMixWithOthers 选项实现与其他音频APP共存(如语音通话时允许背景音乐播放),适合直播、K歌场景。同时,语音通话场景下搭配 AVAudioSessionModeVoiceChat 模式,可自动开启回声消除、降噪功能,提升通话清晰度。

适用场景:语音通话(微信/QQ电话)、直播APP、K歌APP、语音助手。

(4)AVAudioSessionCategoryAmbient —— 背景音场景(推荐游戏/工具APP)

核心作用:用于非核心的背景音频(如游戏背景音乐、工具APP的提示音),优先级最低。

关键特性:

  • 允许与其他音频APP共存(比如用户打开音乐APP播放音乐,你的APP的背景音会混合播放,或被压低音量)。
  • 不支持后台播放(APP退到后台后,音频会立即停止)。
  • 受静音开关控制(静音模式下,音频会静音)。

配置代码(游戏背景音场景)

// OC 配置:游戏背景音(允许与其他音频共存)
#import <AVFoundation/AVFoundation.h>

- (void)configureAmbientCategory {
    AVAudioSession *audioSession = [AVAudioSession sharedInstance];
    NSError *error = nil;
    
    // 配置分类为 Ambient,无需额外选项(默认允许共存)
    [audioSession setCategory:AVAudioSessionCategoryAmbient
                         mode:AVAudioSessionModeDefault
                       options:0
                         error:&error];
    
    if (error) {
        NSLog(@"Ambient 分类配置失败:%@", error.localizedDescription);
        return;
    }
    
    // 激活会话(背景音场景可延迟激活,避免过早抢占资源)
    [audioSession setActive:YES error:&error];
    if (error) {
        NSLog(@"会话激活失败:%@", error.localizedDescription);
    }
}
// Swift 配置:游戏背景音(允许与其他音频共存)
import AVFoundation

func configureAmbientCategory() {
    let audioSession = AVAudioSession.sharedInstance()
    do {
        try audioSession.setCategory(.ambient, mode: .default)
        try audioSession.setActive(true)
    } catch {
        print("Ambient 分类配置/激活失败:(error.localizedDescription)")
    }
}

备注:该分类优先级最低,不会抢占其他APP的音频,适合作为“辅助音频”(如游戏音效、APP提示音),用户打开音乐播放器时,背景音会自动混合播放或被压低音量。

适用场景:游戏背景音乐、APP操作提示音、闹钟APP的背景音。

2. 其他补充分类(了解即可)

  • AVAudioSessionCategorySoloAmbient(默认分类):与 Ambient 类似,但会抢占其他音频资源(其他APP音频暂停),不支持后台播放,适合简单的提示音场景。
  • AVAudioSessionCategoryMultiRoute:多路由输出,允许音频同时输出到多个设备(如同时连接耳机和蓝牙音箱,两者都能播放),适合专业音频场景。
  • AVAudioSessionCategoryAudioProcessing:用于音频处理(无播放/录音,仅处理音频数据),适合音频编辑APP。

多路由分类配置代码(专业场景)

// OC 配置:多路由输出(专业音频场景)
- (void)configureMultiRouteCategory {
    AVAudioSession *audioSession = [AVAudioSession sharedInstance];
    NSError *error = nil;
    [audioSession setCategory:AVAudioSessionCategoryMultiRoute
                         mode:AVAudioSessionModeDefault
                       options:0
                         error:&error];
    if (error) {
        NSLog(@"MultiRoute 分类配置失败:%@", error.localizedDescription);
    }
}

3. 分类选型核心原则

记住一个核心逻辑:根据APP的“核心音频行为”选择分类,不要过度配置。比如:

  • 只播放音乐 → 选 Playback,不要选 PlayAndRecord(浪费资源,还需额外请求麦克风权限)。
  • 语音通话 → 选 PlayAndRecord,不要选 Playback+Record 组合(分类本身已支持双功能)。
  • 游戏背景音 → 选 Ambient,不要选 Playback(避免抢占用户的音乐播放)。

补充:实际开发中,可先通过 audioSession.availableCategories 读取当前设备支持的分类,避免配置不兼容的分类导致失败。

三、核心机制2:音频会话模式(Mode)—— 优化特定场景的“补充规则”

模式(Mode)是对分类的“补充优化”,它不能单独使用,必须搭配分类一起配置,用于适配特定的音频场景(如语音通话、视频通话、录音),让音频行为更贴合场景需求。

简单来说,分类定义了“能做什么”(播放/录音/共存),模式定义了“怎么做更好”(适配特定场景的音频优化)。下面讲解常用模式及搭配逻辑,附上对应搭配代码。

1. 常用模式及搭配场景

(1)AVAudioSessionModeDefault —— 默认模式(通用)

所有分类都可以搭配此模式,无额外优化,适用于大多数通用场景(如普通音乐播放、普通录音)。

搭配示例:Playback + Default(音乐播放器)、Record + Default(普通录音)。

搭配代码(普通音乐播放)

// OC:Playback + Default 搭配(普通音乐播放)
- (void)configurePlaybackWithDefaultMode {
    AVAudioSession *audioSession = [AVAudioSession sharedInstance];
    NSError *error = nil;
    [audioSession setCategory:AVAudioSessionCategoryPlayback
                         mode:AVAudioSessionModeDefault
                       options:AVAudioSessionCategoryOptionAllowBluetooth
                         error:&error];
    if (error) {
        NSLog(@"配置失败:%@", error.localizedDescription);
    }
}

(2)AVAudioSessionModeVoiceChat —— 语音通话模式(重点)

核心优化:针对实时语音通话(如微信电话、手机通话),优化音频质量(降低延迟、降噪),并自动适配路由(插入耳机时切换到耳机,拔出时切换到扬声器)。

搭配要求:仅支持 PlayAndRecord 分类(因为语音通话需要同时播放和录音)。

关键特性:自动启用“回声消除”“降噪”功能,提升语音清晰度;支持蓝牙耳机的通话模式。

搭配代码(实时语音通话)

// Swift:PlayAndRecord + VoiceChat 搭配(语音通话)
func configureVoiceChatMode() {
    let audioSession = AVAudioSession.sharedInstance()
    do {
        // 仅能搭配 PlayAndRecord 分类
        try audioSession.setCategory(.playAndRecord, mode: .voiceChat, options: [.allowBluetooth, .defaultToSpeaker])
        try audioSession.setActive(true)
    } catch {
        print("语音通话模式配置失败:(error.localizedDescription)")
    }
}

补充:该模式下,系统会自动优化语音传输延迟,开启回声消除和降噪,适合微信语音、手机通话等实时场景,搭配 AVAudioSessionCategoryOptionAllowBluetooth 可支持蓝牙耳机通话。

(3)AVAudioSessionModeVideoChat —— 视频通话模式

核心优化:针对视频通话(如微信视频、FaceTime),在语音通话优化的基础上,适配视频场景的音频同步(降低音视频延迟)。

搭配要求:仅支持 PlayAndRecord 分类,与 VoiceChat 类似,但更侧重音视频同步。

搭配代码(视频通话)

// OC:PlayAndRecord + VideoChat 搭配(视频通话)
- (void)configureVideoChatMode {
    AVAudioSession *audioSession = [AVAudioSession sharedInstance];
    NSError *error = nil;
    AVAudioSessionCategoryOptions options = AVAudioSessionCategoryOptionAllowBluetooth | AVAudioSessionCategoryOptionDefaultToSpeaker;
    [audioSession setCategory:AVAudioSessionCategoryPlayAndRecord
                         mode:AVAudioSessionModeVideoChat
                       options:options
                         error:&error];
    if (error) {
        NSLog(@"视频通话模式配置失败:%@", error.localizedDescription);
    }
}

(4)AVAudioSessionModeMeasurement —— 精准录音模式

核心优化:针对精准录音(如音频分析、专业录音),关闭所有音频处理(降噪、回声消除),保留原始音频数据,确保录音的准确性。

搭配要求:支持 PlayAndRecord、Record 分类。

适用场景:音频分析APP、专业录音APP。

搭配代码(专业录音)

// Swift:Record + Measurement 搭配(精准录音)
func configureMeasurementMode() {
    let audioSession = AVAudioSession.sharedInstance()
    do {
        // 搭配 Record 分类,关闭所有音频处理,保留原始数据
        try audioSession.setCategory(.record, mode: .measurement)
        try audioSession.setActive(true)
    } catch {
        print("精准录音模式配置失败:(error.localizedDescription)")
    }
}

(5)AVAudioSessionModeMoviePlayback —— 视频播放模式

核心优化:针对视频播放,优化音频与视频的同步,提升播放流畅度,支持多声道音频。

搭配要求:仅支持 Playback 分类。

搭配代码(视频播放)

// OC:Playback + MoviePlayback 搭配(视频播放)
- (void)configureMoviePlaybackMode {
    AVAudioSession *audioSession = [AVAudioSession sharedInstance];
    NSError *error = nil;
    [audioSession setCategory:AVAudioSessionCategoryPlayback
                         mode:AVAudioSessionModeMoviePlayback
                       options:AVAudioSessionCategoryOptionAllowAirPlay
                         error:&error];
    if (error) {
        NSLog(@"视频播放模式配置失败:%@", error.localizedDescription);
    }
}

补充:该模式优化了音视频同步逻辑,支持多声道音频和AirPlay输出,适合视频播放器、影视APP场景。

2. 模式搭配核心原则

  • 模式必须与分类匹配,不可随意搭配(如 VoiceChat 不能搭配 Playback 分类)。
  • 无需优化的场景,用 Default 模式即可,不要画蛇添足(如普通音乐播放,无需搭配 MoviePlayback)。
  • 特定场景优先用对应模式(如语音通话用 VoiceChat,精准录音用 Measurement),能大幅提升用户体验。

四、核心机制3:激活策略 —— 让音频会话“生效”的关键操作

配置好分类和模式后,必须通过“激活”操作,让音频会话生效。激活(activate)是 AVAudioSession 与系统建立连接的过程,也是音频资源分配的触发点。

很多开发者配置完分类和模式后,发现音频没声音,大概率是没有激活会话,或激活时机、方式错误。下面讲解激活的核心要点、时机和注意事项,附上完整激活代码。

1. 激活的核心API(iOS 10+ 推荐)

// 获取单例
AVAudioSession *session = [AVAudioSession sharedInstance];

// 配置分类和模式(示例:语音通话场景)
NSError *error = nil;
[session setCategory:AVAudioSessionCategoryPlayAndRecord 
               mode:AVAudioSessionModeVoiceChat 
             options:AVAudioSessionCategoryOptionAllowBluetooth 
               error:&error];

if (error) {
    NSLog(@"分类模式配置失败:%@", error.localizedDescription);
    return;
}

// 核心激活API(iOS 10+),带选项控制
// 选项说明:
// AVAudioSessionSetActiveOptionNotifyOthersOnDeactivation:退出激活时,通知其他APP恢复音频
// AVAudioSessionSetActiveOptionNotifyOthersOnDeactivation:激活时,不中断其他APP音频(需配合分类options)
[session setActive:YES withOptions:AVAudioSessionSetActiveOptionNotifyOthersOnDeactivation error:&error];

if (error) {
    NSLog(@"会话激活失败:%@", error.localizedDescription);
} else {
    NSLog(@"会话激活成功,可正常播放/录音");
}

// 取消激活(退出音频场景时调用)
[session setActive:NO withOptions:AVAudioSessionSetActiveOptionNotifyOthersOnDeactivation error:&error];
// Swift 核心激活API(iOS 10+)
let session = AVAudioSession.sharedInstance()
do {
    // 配置分类和模式
    try session.setCategory(.playAndRecord, mode: .voiceChat, options: .allowBluetooth)
    // 激活会话,退出时通知其他APP恢复音频
    try session.setActive(true, options: .notifyOthersOnDeactivation)
    print("会话激活成功,可正常播放/录音")
    
    // 取消激活(退出音频场景时调用)
    // try session.setActive(false, options: .notifyOthersOnDeactivation)
} catch {
    print("会话配置/激活失败:(error.localizedDescription)")
}

2. 激活的核心时机(避坑关键)

激活时机直接影响用户体验和功能稳定性,推荐以下3种核心时机,附上对应代码逻辑:

(1)延迟激活(推荐)

不要在APP启动时就激活会话,避免过早抢占其他APP的音频资源(如用户正在听音乐,打开你的APP就中断音乐,体验极差)。建议在“即将播放/录音”时激活。

// OC:延迟激活(点击播放按钮时激活)
- (IBAction)playButtonClick:(UIButton *)sender {
    // 1. 配置分类和模式(提前配置,或首次点击时配置)
    [self configurePlaybackCategory];
    
    // 2. 激活会话(即将播放时激活)
    AVAudioSession *session = [AVAudioSession sharedInstance];
    NSError *error = nil;
    [session setActive:YES withOptions:AVAudioSessionSetActiveOptionNotifyOthersOnDeactivation error:&error];
    if (error) {
        NSLog(@"激活失败:%@", error.localizedDescription);
        return;
    }
    
    // 3. 开始播放音频
    [self.audioPlayer play];
}

(2)退出场景时取消激活

当APP退出音频场景(如关闭播放页面、退出录音),必须取消激活会话,避免占用音频资源,同时通知其他APP恢复音频。

// Swift:退出页面时取消激活
override func viewWillDisappear(_ animated: Bool) {
    super.viewWillDisappear(animated)
    let session = AVAudioSession.sharedInstance()
    do {
        // 取消激活,通知其他APP恢复音频
        try session.setActive(false, options: .notifyOthersOnDeactivation)
        print("会话已取消激活")
    } catch {
        print("取消激活失败:(error.localizedDescription)")
    }
}

(3)中断后重新激活

当音频会话被系统中断(如来电、闹钟),中断结束后需重新激活会话,恢复音频播放/录音。需先监听中断事件,再执行重新激活。

// OC:监听中断事件,重新激活会话
#import <AVFoundation/AVFoundation.h>

@interface ViewController () <AVAudioSessionDelegate>
@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    // 设置代理,监听中断事件
    AVAudioSession *session = [AVAudioSession sharedInstance];
    session.delegate = self;
}

// 监听音频会话中断(来电、闹钟等)
- (void)audioSessionInterruptionNotification:(NSNotification *)notification {
    NSInteger type = [[notification.userInfo objectForKey:AVAudioSessionInterruptionTypeKey] integerValue];
    // 中断结束,重新激活会话
    if (type == AVAudioSessionInterruptionTypeEnded) {
        AVAudioSession *session = [AVAudioSession sharedInstance];
        NSError *error = nil;
        [session setActive:YES error:&error];
        if (!error) {
            NSLog(@"中断结束,重新激活会话,恢复播放");
            // 恢复播放/录音
            [self.audioPlayer play];
        }
    }
}

3. 激活的注意事项(避坑重点)

  • 同一时间只能有一个会话处于激活状态,若多个地方调用激活,会导致冲突(报错:AVAudioSessionErrorCodeResourceBusy)。
  • 激活前必须先配置分类和模式,否则会激活失败(报错:AVAudioSessionErrorCodeNotConfigured)。
  • 录音场景激活前,必须先获取麦克风权限,否则会崩溃或激活失败。
  • 取消激活时,建议使用 AVAudioSessionSetActiveOptionNotifyOthersOnDeactivation 选项,提升用户体验(如退出APP后,恢复之前的音乐播放)。
  • iOS 14+ 需注意:多次频繁激活/取消激活,可能触发系统Bug,建议添加状态判断,避免重复操作。

五、实战避坑:常见问题及解决方案(附代码)

结合实际开发中高频遇到的问题,整理4个核心避坑点,附上解决方案和代码,帮你快速排查问题。

1. 问题1:播放没声音(最常见)

核心原因:未激活会话、分类配置错误、静音开关影响、路由错误。

// Swift:排查播放没声音的核心代码
func checkNoSoundIssue() {
    let session = AVAudioSession.sharedInstance()
    // 1. 检查会话是否激活
    guard session.isActive else {
        print("会话未激活,尝试重新激活")
        do { try session.setActive(true) } catch { print(error) }
        return
    }
    
    // 2. 检查分类是否正确(纯播放需用 Playback)
    guard session.category == .playback else {
        print("分类配置错误,重新配置 Playback 分类")
        do { try session.setCategory(.playback, mode: .default) } catch { print(error) }
        return
    }
    
    // 3. 检查静音开关状态(Playback 分类,静音模式下耳机可正常播放)
    let isSilent = session.category == .playback && !session.isOtherAudioPlaying && session.outputVolume == 0
    if isSilent {
        print("当前处于静音模式,连接耳机可正常播放")
    }
    
    // 4. 检查音频路由(是否输出到扬声器/耳机)
    print("当前音频输出路由:(session.currentRoute.outputs.first?.portType.rawValue ?? "未知")")
}

2. 问题2:录音失败/无声音

核心原因:未获取麦克风权限、分类错误(未用 Record/PlayAndRecord)、会话未激活。

// OC:录音失败排查代码
- (void)checkRecordIssue {
    // 1. 检查麦克风权限
    AVAuthorizationStatus status = [AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeAudio];
    if (status != AVAuthorizationStatusAuthorized) {
        NSLog(@"麦克风权限未授权,请求权限");
        [AVCaptureDevice requestAccessForMediaType:AVMediaTypeAudio completionHandler:^(BOOL granted) {}];
        return;
    }
    
    // 2. 检查分类(录音需用 Record 或 PlayAndRecord)
    AVAudioSession *session = [AVAudioSession sharedInstance];
    if (![session.category isEqualToString:AVAudioSessionCategoryRecord] && 
        ![session.category isEqualToString:AVAudioSessionCategoryPlayAndRecord]) {
        NSLog(@"分类错误,重新配置录音分类");
        [self configureRecordCategory];
        return;
    }
    
    // 3. 检查会话是否激活
    if (!session.isActive) {
        NSLog(@"会话未激活,重新激活");
        [session setActive:YES error:nil];
    }
}

3. 问题3:后台播放中断

核心原因:未配置后台模式、退出时未取消激活、分类不支持后台播放。

解决方案:1. Info.plist 配置 UIBackgroundModesaudio;2. 用 Playback/PlayAndRecord 分类;3. 后台播放时保持会话激活。

4. 问题4:与其他音频APP冲突(打开APP,其他APP音频暂停)

核心原因:分类默认不允许混音,未配置 AVAudioSessionCategoryOptionMixWithOthers 选项。

// Swift:允许与其他音频APP共存(混音)
func configureMixWithOthers() {
    let session = AVAudioSession.sharedInstance()
    do {
        // 配置分类时,添加 mixWithOthers 选项
        try session.setCategory(.playAndRecord, mode: .default, options: [.mixWithOthers, .allowBluetooth])
        try session.setActive(true)
        print("已配置混音,可与其他音频APP共存")
    } catch {
        print("配置混音失败:(error.localizedDescription)")
    }
}

补充:该配置适合直播、K歌等需要同时播放背景音乐和录音的场景,需注意部分分类(如 Record)不支持混音选项。

六、总结

AVAudioSession 的核心机制,本质是“分类定义基础行为,模式优化特定场景,激活触发资源分配”。掌握这三者的搭配逻辑,就能解决绝大多数iOS音频开发中的问题。

核心总结:

  • 分类:选对场景(纯播放→Playback,录音→Record,通话→PlayAndRecord),不盲目配置。
  • 模式:特定场景用对应模式(语音通话→VoiceChat,视频播放→MoviePlayback),通用场景用Default。
  • 激活:延迟激活、及时取消、中断后重新激活,避免资源冲突和用户体验问题。

本文所有代码均可直接复制到项目中复用,建议根据自己的APP场景(播放/录音/通话),选择对应的分类、模式和激活策略,同时注意权限配置和避坑点。

最后提醒:音频开发的核心是“贴合用户场景”,不同场景的配置差异较大,建议开发时多测试不同场景(静音模式、后台、耳机切换、来电中断),确保功能稳定。

栗子前端技术周刊第 126 期 - Rspack 2.0、TypeScript 7.0 Beta、Git 2.54...

2026年4月27日 08:42

🌰栗子前端技术周刊第 126 期 (2026.04.20 - 2026.04.26):浏览前端一周最新消息,学习国内外优秀文章,让我们保持对前端的好奇心。

📰 技术资讯

  1. Rspack 2.0:Rspack 2.0 发布,亮点包括性能提升(构建提速、精简依赖)、产物优化(静态分析增强、编译器注解支持等)、改进 ESM 支持、新增特性(如 React Server Components 支持、支持 #/ 子路径别名导入等)。同时 Rsbuild 2.0 也发布了。

  2. TypeScript 7.0 Beta:基于 Go 语言原生重构移植的 TypeScript 首个测试版正式推出,性能提升约 10 倍。TypeScript 6.0 仍是版本升级过程中的关键过渡版本,因为 TS 7.0 沿用了 6.0 的默认配置调整,且 6.0 中标注的废弃语法,在新版中已转为强制报错。

  3. Git 2.54:Git 2.54 正式发布,带来多项重磅新特性,git history 提供了全新简易操作方式,可编辑提交说明,或以交互模式将单次提交拆分为两次;现在可在配置文件中定义钩子(仓库级、用户级、系统级均可),不再局限于传统的 .git/hooks 目录,同时支持为同一个事件配置并运行多个钩子。

  4. Bun v1.3.13:Bun v1.3.13 发布,bun test 迎来多项功能增强,新增 --isolate--parallel--shard--changed 等参数,支持测试环境隔离、并行测试,以及仅运行近期代码变更影响的测试文件。本次更新后,运行时内存占用降低 5%bun install 安装速度进一步提升,同时包含多项优化。

📒 技术文章

  1. You really, really, really don’t need an effect! I swear!:你真的、真的、真的完全不需要用副作用(effect)!我发誓!- 文中给出了各类常见滥用场景的最优替代方案,并提供了可落地的心智模型与 ESLint 规则。

  2. Skill 入门指南:从零开始打造你的智能编程助手:本文将从 Skill 概念解析到实战落地,一步步带你认识 Skill、开发 Skill、配置并使用 Skill,全程贴合实际开发场景,所有操作步骤均经过实测验证,确保你跟着做就能快速上手,真正将 Skill 融入日常开发工作。

  3. 前端视角下的 Python:AI 时代来临,人人都在喊转“全栈“,作者也开始真正深入 Python 的生态系统,才发现这不仅是 JS 和 Python 两门语言的对话,更是两种编程哲学、两种技术文化的碰撞与融合,本文作者将从前端视角重新审视 Python。

🔧 开发工具

  1. uuid 14.0:可生成符合 RFC9562 标准的 UUID(v1 至 v7 版本)。
image-20260426145121117
  1. np 11.2:功能齐全的 npm publish 替代工具。
image-20260426145431744
  1. React Tooltip 6.0:易用的悬浮提示组件。
image-20260426145633240

🚀🚀🚀 以上资讯文章选自常见周刊,如 JavaScript Weekly 等,周刊内容也会不断优化改进,希望你们能够喜欢。

💖 欢迎关注微信公众号:栗子前端

用 Python 接入大模型 API:从 0 到 1 实现文本分类/抽取/匹配

作者 屿正
2026年4月27日 08:26

写在前面

随着ChatGPT、通义千问等大语言模型的兴起,越来越多的开发者希望在自己的应用中集成AI能力,我决定通过实践来学习大模型API的调用方法。

我使用Python作为开发语言,采用阿里云百炼的通义千问模型(qwen3-max)作为基础模型,同时也体验了本地Ollama部署的qwen3:4b模型。

主题:围绕"科技资讯"这个方向,一步步实现文本分类信息抽取语义匹配三个常见场景。

1.环境准备

1.1 安装python

www.python.org/downloads/

验证 Python 环境是否安装成功

# 通用命令(推荐,所有系统兼容)
python3 --version
# 补充:Windows系统若已配置PATH,也可执行
python --version

验证 pip3(Python 包管理工具)pip3 是 Python3 默认的包管理工具类似于安装Nodejs中npm包管理工具,用于安装第三方库,验证命令:

# 通用命令
pip3 --version
# Windows补充命令
pip --version

安装成功 image.png

2. OpenAI 库的基础使用

Python 库已经成为了调用大模型的"事实标准"。不管是 OpenAI 官方、阿里云 DashScope、还是本地 Ollama,都提供了 OpenAI 兼容的接口。这意味着写一套代码,换个 base_url 就能切换模型。

2.1 安装openai

pip install openai

模型可以使用阿里云百炼,也可以使用 Ollama本地部署模型,先启动 Ollama 并拉取模型:

image.png

2.2 第一次调用OpenAI

最简单的调用只需要三步:创建 client → 构造 messages → 发起请求。

from openai import OpenAI

# 1. 创建 client 对象
client = OpenAI(
    base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
    # api_key="YOUR_API_KEY"  # 如果环境变量没配,这里需要手动传
)

# 2. 调用模型
response = client.chat.completions.create(
    model="qwen3-max",
    messages=[
        {"role": "system", "content": "你是一个科技资讯助手"},
        {"role": "user", "content": "2026年AI领域有哪些重要趋势?"},
    ]
)

# 3. 打印结果
print(response.choices[0].message.content)

image.png

api_key可以直接配置到全局环境变量中~./zshrc,就无需在创建client时明文传入了

export OPENAI_API_KEY="your key"
export DASHSCOPE_API_KEY="your key"

关键点拆解

参数 作用
base_url API 地址,换这个就能切换不同服务商
model 模型名称,不同平台名称不一样
messages 对话历史,system 设定人设,user 是用户输入,assistant 是模型回复
role 三条消息角色:system(系统设定)、user(用户)、assistant(助手)

system 消息就像是给演员的"角色剧本",告诉模型"你是谁、该怎么做"。assistant 消息可以用作 few-shot 示例,让模型"照着学"。

2.3 流式输出:让用户体验"打字机"效果

非流式调用会等模型全部生成完才返回,用户可能要等十几秒。开启 stream=True 后,模型每生成一段就返回一段,实现类似 ChatGPT 的打字机效果。

from openai import OpenAI

client = OpenAI(
    base_url="https://dashscope.aliyuncs.com/compatible-mode/v1"
)

response = client.chat.completions.create(
    model="qwen3-max",
    messages=[
        {"role": "system", "content": "你是一个科技资讯助手,回答问题时请简洁"},
        {"role": "user", "content": "什么是大语言模型?用一句话解释"},
    ],
    stream=True     # 开启流式输出
)

# 流式返回的结果需要遍历 chunk
for chunk in response:
    print(chunk.choices[0].delta.content, end="", flush=True)

流式处理要点

  • chunk.choices[0].delta.content 是每一小段文本,可能是几个字或一个词
  • end="" 避免 print 自动换行,flush=True 立即刷新到终端
  • 第一个 chunk 的 delta.content 可能为 None,生产环境需要做空值判断

2.4 附带历史消息:让模型"记住"上下文

大模型本身是无状态的,每次调用都是独立的。要实现多轮对话,需要把之前的消息一起发过去。

from openai import OpenAI

client = OpenAI(
    base_url="https://dashscope.aliyuncs.com/compatible-mode/v1"
)

# 把之前的对话历史全部带上
response = client.chat.completions.create(
    model="qwen3-max",
    messages=[
        {"role": "system", "content": "你是科技资讯助手,回答简洁"},
        {"role": "user", "content": "英伟达最新一代GPU是什么?"},
        {"role": "assistant", "content": "英伟达最新一代数据中心GPU是Blackwell架构的B200。"},
        {"role": "user", "content": "它比上一代性能提升多少?"},   # 这个问题依赖上文
    ],
    stream=True
)

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

为什么需要历史消息? 第二个问题"它比上一代性能提升多少?"中的"它"指代的是前面提到的 B200。如果不带上历史消息,模型就不知道"它"是谁。

3. 提示词优化实战

提示词(Prompt)决定了模型的输出质量。下面通过三个实战案例,展示几种最常用且效果立竿见影的提示词优化技巧。

统一主题:所有案例都围绕"科技资讯处理"场景,包括科技文章分类、关键信息抽取、语义相似度判断。

3.1 案例一:科技文本分类(Few-Shot 提示)

场景:给定一段科技相关的文本,自动判断它属于哪个类别。

核心思路:Few-Shot Learning

直接让模型分类,它可能按自己的理解来。但如果给它几个"输入→输出"的示例,它就能照葫芦画瓢,准确率大幅提升。这就是 Few-Shot Prompting

from openai import OpenAI

client = OpenAI(
    base_url="http://localhost:11434/v1"  # 本地 Ollama
)

# 示例数据:类别 → 文本
examples_data = {
    'AI前沿': 'OpenAI今日发布了GPT-5,该模型在多项基准测试中超越前代,特别是在数学推理和代码生成方面表现突出。',
    '产品发布': '苹果公司在WWDC上正式推出了Vision Pro二代,售价降低至2000美元,新增手势追踪和眼动交互功能。',
    '行业分析': '据IDC最新报告,2025年Q2全球AI芯片市场规模达到180亿美元,同比增长65%,其中GPU占比超过70%。',
    '投融资': 'AI编程助手Cursor完成1亿美元C轮融资,估值达到100亿美元,红杉资本领投。'
}

# 待分类的文本
questions = [
    "今日,华为在开发者大会上发布了HarmonyOS NEXT正式版,全面支持纯血鸿蒙应用生态,不再兼容Android应用。",
    "据市场分析机构Gartner预测,到2026年全球企业级AI软件支出将突破5000亿美元,SaaS和AI Agent是主要增长点。",
    "字节跳动旗下豆包大模型团队宣布开源新一代多模态模型,支持文本、图像和视频的联合理解与生成。",
    "今天天气真不错,适合出去散步。"  # 干扰项,不属于任何科技类别
]

# 构造 messages:system 说明任务 + 示例对话
messages = [
    {
        "role": "system",
        "content": "你是科技资讯专家,请将文本分类为['AI前沿', '产品发布', '行业分析', '投融资']之一,不属于任何类别的回答'不清楚类别'。以下是示例:"
    },
]

# 把示例数据转为 user/assistant 对话对
for category, text in examples_data.items():
    messages.append({"role": "user", "content": text})
    messages.append({"role": "assistant", "content": category})

# 批量分类
for q in questions:
    response = client.chat.completions.create(
        model="qwen3:4b",
        messages=messages + [{"role": "user", "content": f"请分类:{q}"}]
    )
    print(f"文本: {q[:30]}... → 类别: {response.choices[0].message.content}")

消息构造示意图

messages 最终结构:
┌─────────────────────────────────────────────────┐
│ system: 你是科技资讯专家,请分类为[...]            │
│ user:   OpenAI今日发布了GPT-5...                  │
│ assistant: AI前沿                                │
│ user:   苹果公司在WWDC上正式推出...                 │
│ assistant: 产品发布                               │
│ user:   据IDC最新报告...                          │
│ assistant: 行业分析                               │
│ user:   AI编程助手Cursor完成...                    │
│ assistant: 投融资                                 │
│ user:   请分类:今日,华为在开发者大会上...         │  ← 实际要分类的
└─────────────────────────────────────────────────┘

image.png

3.2 案例二:科技信息抽取(结构化输出)

场景:从一篇科技新闻中提取关键字段(日期、公司名、产品名、金额等),并以 JSON 格式返回。

核心思路:JSON 结构化输出 + Few-Shot 示例

直接让模型"提取信息",它可能返回自然语言描述。但如果要求它输出 JSON,并在示例中展示格式,它就能稳定返回结构化数据,方便后续程序处理。

from openai import OpenAI
import json

client = OpenAI(base_url="http://localhost:11434/v1")

# 定义要抽取的字段
schema = ['日期', '公司名称', '产品名称', '融资金额', '投资方']

# 示例数据
examples_data = [
    {
        "content": "2025年3月15日,AI编程公司Cursor宣布完成1亿美元C轮融资,由红杉资本领投,Andreessen Horowitz跟投。该公司主打产品Cursor AI Editor已成为开发者热门工具。",
        "answers": {
            "日期": "2025年3月15日",
            "公司名称": "Cursor",
            "产品名称": "Cursor AI Editor",
            "融资金额": "1亿美元",
            "投资方": "红杉资本、Andreessen Horowitz"
        }
    },
    {
        "content": "昨日,华为在深圳总部正式发布Mate 70系列手机,搭载全新麒麟9100芯片,起售价5499元。",
        "answers": {
            "日期": "昨日",
            "公司名称": "华为",
            "产品名称": "Mate 70系列",
            "融资金额": "原文未提及",
            "投资方": "原文未提及"
        }
    }
]

# 待抽取的文本
questions = [
    "2025年6月1日,月之暗面科技宣布完成10亿元人民币B轮融资,高榕资本独家领投,公司将用于Kimi智能助手的产品迭代。",
    '特斯拉于上海超级工厂投产了搭载HW5.0芯片的新一代自动驾驶硬件,马斯克称这是"史上最大升级"。'
]

# 构造 messages
messages = [
    {
        "role": "system",
        "content": f"你是信息抽取专家。请从文本中提取 {schema} 这些字段,以JSON格式输出。如果某字段信息不存在,填'原文未提及'。参考以下示例:"
    },
]

# 追加示例
for example in examples_data:
    messages.append({"role": "user", "content": example["content"]})
    messages.append({
        "role": "assistant",
        "content": json.dumps(example["answers"], ensure_ascii=False)
    })

# 批量抽取
for q in questions:
    response = client.chat.completions.create(
        model="qwen3:4b",
        messages=messages + [{"role": "user", "content": f"请抽取以下文本的信息:{q}"}]
    )
    result = response.choices[0].message.content
    print(f"原文: {q[:40]}...")
    print(f"抽取结果: {result}\n")

image.png关键技巧

  1. 明确字段列表:在 system 消息中列出要抽取的所有字段
  2. JSON 格式约束:要求模型输出 JSON,方便后续 json.loads() 直接解析
  3. 缺失值处理:约定"原文未提及"作为默认值,避免模型编造信息
  4. 示例中展示完整格式:模型会严格模仿示例的 JSON 结构

实际使用中,拿到结果后建议用 json.loads() 验证一下,确保返回的是合法 JSON:

parsed = json.loads(result)
print(parsed["公司名称"])  # 直接按 key 取值

3.3 案例三:科技文本匹配判断(对比推理)

场景:给定两句话,判断它们描述的是否是同一件事/同一主题。这在资讯去重、推荐系统等场景很常见。

核心思路:正负示例对比 + 格式化输入

让模型判断两句话是否"匹配",关键是要给它正面示例(匹配)负面示例(不匹配),让它学会区分。

from openai import OpenAI

client = OpenAI(base_url="http://localhost:11434/v1")

# 正负示例
examples_data = {
    "是": [   # 这两句说的是同一件事
        ("英伟达发布Blackwell架构B200芯片,性能比H100提升30倍。", "英伟达推出新一代GPU B200,采用Blackwell架构,性能远超H100。"),
        ("字节跳动旗下豆包大模型团队宣布开源新一代多模态模型。", "豆包团队开源了多模态大模型。"),
    ],
    "不是": [  # 两句话各说各的
        ("英伟达股价今日下跌5%。", "苹果宣布Vision Pro二代正式发布。"),
        ("特斯拉上海工厂开始生产HW5.0芯片。", "SpaceX星舰完成第六次试飞。"),
    ]
}

# 待判断的文本对
questions = [
    ("华为发布HarmonyOS NEXT正式版,不再兼容Android。", "纯血鸿蒙系统正式发布,安卓应用将无法运行。"),
    ("AI芯片市场Q2增长65%。", "AI编程助手Cursor完成1亿美元融资。"),
    ("小米SU7交付量突破10万台。", "小米汽车市场表现强劲,累计交付超10万辆SU7。"),
]

# 构造 messages
messages = [
    {
        "role": "system",
        "content": "你帮我完成文本匹配判断。我会给你两个句子,用[]包围,请判断它们是否描述同一件事,只回答'是'或'不是'。参考以下示例:"
    },
]

# 追加正负示例
for label, pairs in examples_data.items():
    for s1, s2 in pairs:
        messages.append({"role": "user", "content": f"句子1:[{s1}],句子2:[{s2}]"})
        messages.append({"role": "assistant", "content": label})

# 批量判断
for s1, s2 in questions:
    response = client.chat.completions.create(
        model="qwen3:4b",
        messages=messages + [{"role": "user", "content": f"句子1:[{s1}],句子2:[{s2}]"}]
    )
    print(f"句子1: {s1[:35]}...")
    print(f"句子2: {s2[:35]}...")
    print(f"匹配结果: {response.choices[0].message.content}\n")

image.png关键技巧

  1. [] 包裹句子:让模型清楚知道哪里是句子边界,避免混淆
  2. 正负示例均衡:匹配和不匹配的例子各给几个,防止模型偏向某一方
  3. 约束输出:要求"只回答'是'或'不是'",避免模型输出多余解释

4. 核心知识点总结

4.1 OpenAI SDK 调用流程

不管什么场景,调用流程都是固定的三步:

# 第一步:创建 client
client = OpenAI(base_url="xxx", api_key="xxx")

# 第二步:构造 messages 并调用模型
response = client.chat.completions.create(
    model="模型名",
    messages=[...],
    stream=True/False
)

# 第三步:处理结果
# 非流式:response.choices[0].message.content
# 流式:遍历 response,每次取 chunk.choices[0].delta.content

4.2 提示词优化的三个实用技巧

技巧 适用场景 核心做法
Few-Shot 示例 分类、抽取、判断 在 messages 中塞入几个"输入→输出"的示例对
JSON 结构化 信息抽取、API 返回 要求模型输出 JSON,并在示例中展示格式
格式化输入 文本对比、多段输入 用特殊符号(如 [])标注边界,避免混淆

4.3 本地 vs 云端模型选择

本地 Ollama 云端 DashScope
base_url http://localhost:11434/v1 https://dashscope.aliyuncs.com/compatible-mode/v1
模型示例 qwen3:4b qwen3-max
优点 免费、隐私安全、离线可用 模型更强、无需本地算力
缺点 小模型能力有限 需要 API Key、按量计费

4.4 JSON 基础回顾

提示词优化案例中频繁用到 JSON 序列化/反序列化,这里简单回顾:

import json

# dict → JSON 字符串
data = {"name": "张三", "age": 25}
json_str = json.dumps(data, ensure_ascii=False)
# '{"name": "张三", "age": 25}'

# JSON 字符串 → dict
parsed = json.loads(json_str)
# {'name': '张三', 'age': 25}

# list → JSON 字符串
items = [{"name": "A"}, {"name": "B"}]
json.dumps(items, ensure_ascii=False)
# '[{"name": "A"}, {"name": "B"}]'

ensure_ascii=False 很重要,否则中文会被转成 \uXXXX 编码,不方便阅读和调试。

写在最后

我们做的,并不只是“调用一个大模型 API”,而是在尝试一种新的开发方式。

过去,我们习惯用代码去精确描述规则,而现在,我们开始用自然语言去“定义能力”。

这两者的区别在于:

  • 代码解决的是确定性问题
  • 大模型更擅长处理模糊、复杂、难以穷举规则的问题

Next.js从入门到实战保姆级教程(第十七章):综合实战项目(下)——前端页面、性能优化与部署

2026年4月27日 08:11

本系列文章将围绕Next.js技术栈,旨在为AI Agent开发者提供一套完整的客户端侧工程实践指南。

这是全栈博客系统实战的下篇。在上篇《全栈博客系统架构与核心功能》中,我们完成了数据库设计、认证系统、Server Actions 等后端核心功能。本篇将聚焦前端页面开发、用户体验优化和生产部署,带你完成从代码到上线的完整流程。

一、📖 前置准备

在开始之前,请确保你已经:

  • ✅ 完成了上篇的所有内容
  • ✅ 数据库已初始化并运行
  • ✅ Auth.js 配置完成
  • ✅ Server Actions 可以正常调用

如果还没有,建议先回顾上篇内容《博客系统架构与核心功能》


二、🎨 Markdown 渲染与代码高亮

1. 为什么选择 MDX?

传统 Markdown 的局限性:

  • ❌ 无法使用 React 组件
  • ❌ 交互功能受限
  • ❌ 动态内容难以集成

MDX (Markdown + JSX) 的优势:

  • ✅ 在 Markdown 中嵌入 React 组件
  • ✅ 支持自定义渲染逻辑
  • ✅ 完美的 TypeScript 类型支持

例如,你可以在文章中这样写:

这是一段普通文本。

<Callout type="info">
  这是一个提示框组件!
</Callout>

```javascript
console.log('代码块自动高亮');
```

2. 安装依赖

npm install next-mdx-remote shiki rehype-autolink-headings rehype-slug

依赖说明:

  • next-mdx-remote: 在服务端安全地渲染 MDX
  • shiki: VS Code 同款语法高亮引擎(比 Prism.js 更准确)
  • rehype-autolink-headings: 自动为标题添加锚点链接
  • rehype-slug: 为标题生成 ID

3. 创建 MDX 渲染器组件

创建 components/MDXRenderer.tsx:

// components/MDXRenderer.tsx
import { MDXRemote } from 'next-mdx-remote/rsc';
import { serialize } from 'next-mdx-remote/serialize';
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
import rehypeSlug from 'rehype-slug';
import { CodeBlock } from './CodeBlock';
import { Callout } from './Callout';

interface MDXRendererProps {
  content: string;
}

/**
 * MDX 内容渲染器
 * 
 * 工作流程:
 * 1. serialize: 将 Markdown 字符串编译为 MDX AST
 * 2. MDXRemote: 在服务端渲染为 HTML
 * 3. components: 自定义组件映射表
 * 
 * @param content - Markdown 内容
 */
export async function MDXRenderer({ content }: MDXRendererProps) {
  // 序列化 MDX 内容
  const mdxSource = await serialize(content, {
    mdxOptions: {
      rehypePlugins: [
        rehypeSlug,  // 先生成 slug
        [rehypeAutolinkHeadings, { 
          behavior: 'wrap',  // 将整个标题包装为链接
          properties: {
            className: ['anchor-link'],
          },
        }],
      ],
    },
  });

  return (
    <article className="prose prose-lg max-w-none dark:prose-invert prose-headings:relative">
      <MDXRemote
        {...mdxSource}
        components={{
          // 自定义代码块渲染
          pre: CodeBlock,
          // 自定义提示框
          Callout,
          // 可以添加更多自定义组件
          img: CustomImage,
          a: CustomLink,
        }}
      />
    </article>
  );
}

/**
 * 自定义图片组件(懒加载)
 */
function CustomImage(props: React.ImgHTMLAttributes<HTMLImageElement>) {
  return (
    <img 
      {...props} 
      loading="lazy"  // 懒加载
      className="rounded-lg shadow-md"
    />
  );
}

/**
 * 自定义链接组件(外部链接新窗口打开)
 */
function CustomLink(props: React.AnchorHTMLAttributes<HTMLAnchorElement>) {
  const href = props.href;
  const isExternal = href?.startsWith('http');

  return (
    <a
      {...props}
      {...(isExternal && {
        target: '_blank',
        rel: 'noopener noreferrer',
      })}
      className="text-blue-600 hover:underline"
    />
  );
}

🔍 代码解析:

(1) 为什么使用 next-mdx-remote/rsc?

import { MDXRemote } from 'next-mdx-remote/rsc';  // RSC 版本
  • RSC 版本: 在服务端渲染,性能更好
  • 客户端版本: next-mdx-remote/client,用于交互式 MDX

(2) Rehype 插件的作用

rehypePlugins: [
  rehypeSlug,  // 为 h1-h6 添加 id 属性
  [rehypeAutolinkHeadings, { behavior: 'wrap' }],  // 将标题变为可点击链接
]

执行顺序很重要:

  1. rehypeSlug 先执行,生成 id="introduction"
  2. rehypeAutolinkHeadings 后执行,包裹为 <a href="#introduction"><h2>...</h2></a>

(3) Components 映射表

components={{
  pre: CodeBlock,  // 替换所有 <pre> 标签
  Callout,         // 支持自定义 <Callout> 组件
}}

当 MDX 中出现 <pre> 时,会自动使用 CodeBlock 组件渲染。

4. 代码高亮组件

创建 components/CodeBlock.tsx:

// components/CodeBlock.tsx
import { codeToHtml } from 'shiki';

interface CodeBlockProps {
  children: React.ReactNode;
  className?: string;
}

/**
 * 代码块组件(带语法高亮)
 * 
 * Shiki 优势:
 * - 使用 TextMate grammar,与 VS Code 一致
 * - 支持主题切换
 * - 输出静态 HTML,无运行时 JS
 */
export async function CodeBlock({ children, className }: CodeBlockProps) {
  // 提取语言信息(如 language-jsx)
  const match = /language-(\w+)/.exec(className || '');
  const lang = match ? match[1] : 'text';
  
  // 获取代码内容
  const code = String(children).replace(/\n$/, '');

  // 使用 Shiki 生成高亮 HTML
  const html = await codeToHtml(code, {
    lang,
    theme: 'github-dark',  // 可切换主题
  });

  return (
    <div className="relative my-6 rounded-lg overflow-hidden">
      {/* 语言标签 */}
      <div className="absolute top-2 right-2 px-2 py-1 text-xs bg-gray-700 text-gray-300 rounded">
        {lang}
      </div>
      
      {/* 高亮代码 */}
      <div 
        dangerouslySetInnerHTML={{ __html: html }}
        className="overflow-x-auto"
      />
    </div>
  );
}

⚡ 性能优化:

Shiki 是异步的,所以组件必须是 async:

export async function CodeBlock({ ... }) {
  const html = await codeToHtml(code, { ... });
  // ...
}

Next.js 会在服务端等待异步操作完成,然后缓存结果。

5. 提示框组件

创建 components/Callout.tsx:

// components/Callout.tsx
interface CalloutProps {
  type?: 'info' | 'warning' | 'error' | 'success';
  children: React.ReactNode;
}

const icons = {
  info: '💡',
  warning: '⚠️',
  error: '❌',
  success: '✅',
};

const styles = {
  info: 'bg-blue-50 border-blue-200 text-blue-800',
  warning: 'bg-yellow-50 border-yellow-200 text-yellow-800',
  error: 'bg-red-50 border-red-200 text-red-800',
  success: 'bg-green-50 border-green-200 text-green-800',
};

/**
 * 提示框组件
 * 
 * 使用示例:
 * <Callout type="warning">
 *   这是一个警告提示
 * </Callout>
 */
export function Callout({ type = 'info', children }: CalloutProps) {
  return (
    <div className={`p-4 my-4 border-l-4 rounded ${styles[type]}`}>
      <div className="flex items-start gap-3">
        <span className="text-xl">{icons[type]}</span>
        <div className="flex-1">{children}</div>
      </div>
    </div>
  );
}

三、🏠 首页文章列表

1. 页面结构

创建 app/page.tsx:

// app/page.tsx
import { getPosts } from '@/lib/posts';
import Link from 'next/link';
import Image from 'next/image';
import { formatDate } from '@/lib/utils';

// ==================== 元数据 ====================

export const metadata = {
  title: '全栈博客 - 分享技术与思考',
  description: '专注于 Next.js、React、TypeScript 等现代 Web 开发技术',
};

// ==================== 缓存策略 ====================

/**
 * 每小时重新验证一次
 * 
 * 为什么不是静态生成?
 * - 文章可能频繁更新
 * - 需要显示最新评论数、点赞数
 * - revalidate 平衡了性能和时效性
 */
export const revalidate = 3600;

// ==================== 页面组件 ====================

export default async function HomePage() {
  // 获取第一页的 10 篇文章
  const { posts, pagination } = await getPosts({ 
    page: 1, 
    pageSize: 10 
  });

  return (
    <div className="container mx-auto px-4 py-8">
      {/* Hero 区域 */}
      <section className="mb-12 text-center">
        <h1 className="text-5xl font-bold mb-4 bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
          全栈博客
        </h1>
        <p className="text-xl text-gray-600">
          分享 Next.js、React、TypeScript 等现代 Web 开发技术
        </p>
      </section>

      {/* 文章列表 */}
      <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
        {posts.map(post => (
          <PostCard key={post.id} post={post} />
        ))}
      </div>

      {/* 分页 */}
      {pagination.totalPages > 1 && (
        <Pagination 
          currentPage={pagination.page}
          totalPages={pagination.totalPages}
        />
      )}

      {/* 空状态 */}
      {posts.length === 0 && (
        <EmptyState />
      )}
    </div>
  );
}

/**
 * 文章卡片组件
 */
function PostCard({ post }: { post: any }) {
  return (
    <article className="group border rounded-lg overflow-hidden hover:shadow-lg transition-all duration-300">
      {/* 封面图 */}
      {post.coverImage && (
        <Link href={`/blog/${post.slug}`}>
          <Image
            src={post.coverImage}
            alt={post.title}
            width={400}
            height={200}
            className="w-full h-48 object-cover group-hover:scale-105 transition-transform"
          />
        </Link>
      )}
      
      <div className="p-4">
        {/* 标题 */}
        <h2 className="text-xl font-semibold mb-2 line-clamp-2">
          <Link 
            href={`/blog/${post.slug}`}
            className="hover:text-blue-600 transition-colors"
          >
            {post.title}
          </Link>
        </h2>
        
        {/* 摘要 */}
        <p className="text-gray-600 text-sm mb-4 line-clamp-2">
          {post.excerpt}
        </p>
        
        {/* 元信息 */}
        <div className="flex items-center justify-between text-sm text-gray-500">
          <div className="flex items-center gap-2">
            {post.author.image && (
              <Image
                src={post.author.image}
                alt={post.author.name || ''}
                width={24}
                height={24}
                className="rounded-full"
              />
            )}
            <span>{post.author.name}</span>
          </div>
          
          <div className="flex gap-3">
            <span title="浏览量">👁 {post.viewCount}</span>
            <span title="评论数">💬 {post._count.comments}</span>
            <span title="点赞数">❤️ {post._count.likes}</span>
          </div>
        </div>
        
        {/* 日期和阅读时间 */}
        <div className="mt-3 flex items-center gap-3 text-xs text-gray-400">
          <time dateTime={post.publishedAt?.toISOString()}>
            {formatDate(post.publishedAt || post.createdAt)}
          </time>
          {post.readingTime && (
            <>
              <span></span>
              <span>{post.readingTime} 分钟阅读</span>
            </>
          )}
        </div>
        
        {/* 标签 */}
        {post.tags.length > 0 && (
          <div className="flex flex-wrap gap-2 mt-3">
            {post.tags.slice(0, 3).map(({ tag }) => (
              <Link
                key={tag.id}
                href={`/tags/${tag.slug}`}
                className="px-2 py-1 text-xs rounded-full hover:opacity-80 transition-opacity"
                style={{ 
                  backgroundColor: `${tag.color}20`, 
                  color: tag.color 
                }}
              >
                {tag.name}
              </Link>
            ))}
          </div>
        )}
      </div>
    </article>
  );
}

/**
 * 分页组件
 */
function Pagination({ 
  currentPage, 
  totalPages 
}: { 
  currentPage: number;
  totalPages: number;
}) {
  return (
    <nav className="flex justify-center gap-2 mt-8">
      {Array.from({ length: totalPages }, (_, i) => i + 1).map(page => (
        <Link
          key={page}
          href={`/?page=${page}`}
          className={`px-4 py-2 rounded ${
            page === currentPage
              ? 'bg-blue-600 text-white'
              : 'bg-gray-100 hover:bg-gray-200'
          }`}
        >
          {page}
        </Link>
      ))}
    </nav>
  );
}

/**
 * 空状态组件
 */
function EmptyState() {
  return (
    <div className="text-center py-12">
      <div className="text-6xl mb-4">📝</div>
      <h3 className="text-xl font-semibold mb-2">暂无文章</h3>
      <p className="text-gray-600">
        博主正在努力创作中,敬请期待...
      </p>
    </div>
  );
}

📖 设计要点解析:

(1) 渐进增强原则

<Link href={`/blog/${post.slug}`}>
  <Image src={post.coverImage} alt={post.title} />
</Link>

即使 JavaScript 未加载,用户仍可点击链接跳转,保证基本可用性。

(2) 图片优化

<Image
  src={post.coverImage}
  width={400}
  height={200}
  className="group-hover:scale-105 transition-transform"
/>

next/image 自动:

  • ✅ 生成多种尺寸的图片
  • ✅ 转换为现代格式(WebP/AVIF)
  • ✅ 懒加载(非首屏图片)
  • ✅ 防止布局偏移(CLS)

(3) 文本截断

className="line-clamp-2"  // 最多显示 2 行

Tailwind CSS 的实用类,优雅地处理长文本。


四、📄 文章详情页

1. 动态路由页面

创建 app/blog/[slug]/page.tsx:

// app/blog/[slug]/page.tsx
import { getPostBySlug } from '@/lib/posts';
import { notFound } from 'next/navigation';
import { MDXRenderer } from '@/components/MDXRenderer';
import { CommentSection } from '@/components/CommentSection';
import { LikeButton } from '@/components/LikeButton';
import { BookmarkButton } from '@/components/BookmarkButton';
import { auth } from '@/auth';
import Image from 'next/image';

interface BlogPostPageProps {
  params: Promise<{ slug: string }>;
}

// ==================== 元数据生成 ====================

export async function generateMetadata({ params }: BlogPostPageProps) {
  const { slug } = await params;
  const post = await getPostBySlug(slug);
  
  if (!post) {
    return {};
  }

  return {
    title: post.title,
    description: post.excerpt || post.content.substring(0, 160),
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: post.coverImage ? [{ url: post.coverImage }] : [],
      type: 'article',
      publishedTime: post.publishedAt?.toISOString(),
      authors: [post.author.name].filter(Boolean),
    },
    twitter: {
      card: 'summary_large_image',
      title: post.title,
      description: post.excerpt,
      images: post.coverImage ? [post.coverImage] : [],
    },
  };
}

// ==================== 静态参数生成(可选优化) ====================

/**
 * 预生成热门文章的静态页面
 * 
 * 适用场景:
 * - 访问量高的文章
 * - 不经常更新的内容
 * 
 * 注意:如果文章很多,不要全部预生成,会导致构建缓慢
 */
export async function generateStaticParams() {
  // 只预生成最近 10 篇文章
  const { posts } = await getPosts({ 
    page: 1, 
    pageSize: 10,
    published: true 
  });

  return posts.map(post => ({
    slug: post.slug,
  }));
}

// ==================== 页面组件 ====================

export default async function BlogPostPage({ params }: BlogPostPageProps) {
  const { slug } = await params;
  
  // 并行获取文章和当前用户
  const [post, session] = await Promise.all([
    getPostBySlug(slug),
    auth(),
  ]);

  if (!post) {
    notFound();
  }

  return (
    <article className="container mx-auto px-4 py-8 max-w-4xl">
      {/* 文章头部 */}
      <PostHeader post={post} />

      {/* 互动按钮 */}
      <InteractionBar 
        postId={post.id}
        initialLiked={false}
        initialBookmarked={false}
        user={session?.user || null}
      />

      {/* 文章内容 */}
      <MDXRenderer content={post.content} />

      {/* 标签 */}
      <PostTags tags={post.tags} />

      {/* 作者信息 */}
      <AuthorCard author={post.author} />

      {/* 评论区 */}
      <CommentSection 
        postId={post.id} 
        comments={post.comments}
        currentUser={session?.user || null}
      />
    </article>
  );
}

/**
 * 文章头部组件
 */
function PostHeader({ post }: { post: any }) {
  return (
    <header className="mb-8 pb-8 border-b">
      {/* 标题 */}
      <h1 className="text-4xl md:text-5xl font-bold mb-6">
        {post.title}
      </h1>
      
      {/* 作者和日期 */}
      <div className="flex flex-wrap items-center gap-4 text-gray-600">
        {post.author.image && (
          <Image
            src={post.author.image}
            alt={post.author.name || ''}
            width={40}
            height={40}
            className="rounded-full"
          />
        )}
        <span className="font-medium">{post.author.name}</span>
        <span></span>
        <time dateTime={post.publishedAt?.toISOString()}>
          {new Date(post.publishedAt || post.createdAt).toLocaleDateString('zh-CN', {
            year: 'numeric',
            month: 'long',
            day: 'numeric',
          })}
        </time>
        <span></span>
        <span>{post.readingTime} 分钟阅读</span>
        <span></span>
        <span>👁 {post.viewCount} 次阅读</span>
      </div>

      {/* 封面图 */}
      {post.coverImage && (
        <div className="mt-6 rounded-lg overflow-hidden">
          <Image
            src={post.coverImage}
            alt={post.title}
            width={1200}
            height={600}
            priority  // 首屏图片,优先加载
            className="w-full h-auto"
          />
        </div>
      )}
    </header>
  );
}

/**
 * 互动按钮栏
 */
function InteractionBar({ 
  postId, 
  initialLiked, 
  initialBookmarked,
  user 
}: { 
  postId: string;
  initialLiked: boolean;
  initialBookmarked: boolean;
  user: any;
}) {
  return (
    <div className="flex gap-4 mb-8 pb-8 border-b">
      <LikeButton 
        postId={postId} 
        initialLiked={initialLiked}
        isAuthenticated={!!user}
      />
      <BookmarkButton 
        postId={postId} 
        initialBookmarked={initialBookmarked}
        isAuthenticated={!!user}
      />
    </div>
  );
}

/**
 * 标签组件
 */
function PostTags({ tags }: { tags: any[] }) {
  if (tags.length === 0) return null;

  return (
    <div className="flex flex-wrap gap-2 my-8">
      {tags.map(({ tag }) => (
        <Link
          key={tag.id}
          href={`/tags/${tag.slug}`}
          className="px-3 py-1 text-sm rounded-full transition-opacity hover:opacity-80"
          style={{ 
            backgroundColor: `${tag.color}20`, 
            color: tag.color 
          }}
        >
          #{tag.name}
        </Link>
      ))}
    </div>
  );
}

/**
 * 作者卡片
 */
function AuthorCard({ author }: { author: any }) {
  return (
    <div className="my-12 p-6 bg-gray-50 rounded-lg">
      <div className="flex items-center gap-4">
        {author.image && (
          <Image
            src={author.image}
            alt={author.name || ''}
            width={60}
            height={60}
            className="rounded-full"
          />
        )}
        <div>
          <h3 className="font-semibold text-lg">{author.name}</h3>
          {author.bio && (
            <p className="text-gray-600 text-sm mt-1">{author.bio}</p>
          )}
        </div>
      </div>
    </div>
  );
}

🎯 关键知识点:

(1)Metadata API

export async function generateMetadata({ params }) {
  return {
    title: post.title,
    openGraph: { /* Facebook/Twitter 预览 */ },
    twitter: { /* Twitter Card */ },
  };
}

SEO 最佳实践:

  • title: 控制在 60 字符以内
  • description: 150-160 字符,包含关键词
  • openGraph.images: 至少 1200x630 像素
  • twitter.card: 使用 summary_large_image 获得大卡片

(2) generateStaticParams

export async function generateStaticParams() {
  const { posts } = await getPosts({ page: 1, pageSize: 10 });
  return posts.map(post => ({ slug: post.slug }));
}

何时使用?

  • ✅ 访问量高的页面(首页、热门文章)
  • ✅ 内容不频繁变化
  • ❌ 文章数量巨大(会导致构建缓慢)

效果:

  • 这些页面在构建时生成静态 HTML
  • 访问时无需服务端渲染,速度极快

(3)并行数据获取

const [post, session] = await Promise.all([
  getPostBySlug(slug),
  auth(),
]);

而不是串行:

// ❌ 慢
const post = await getPostBySlug(slug);
const session = await auth();

五、💬 评论组件实现

1. 评论列表

创建 components/CommentSection.tsx:

// components/CommentSection.tsx
'use client';

import { useState } from 'react';
import { createComment } from '@/app/actions/comment';
import Image from 'next/image';
import { formatDate } from '@/lib/utils';

interface CommentSectionProps {
  postId: string;
  comments: any[];
  currentUser: any;
}

/**
 * 评论区组件
 * 
 * 功能:
 * - 显示评论列表(支持嵌套)
 * - 发表评论
 * - 回复评论
 * - Optimistic UI(乐观更新)
 */
export function CommentSection({ 
  postId, 
  comments,
  currentUser 
}: CommentSectionProps) {
  const [commentList, setCommentList] = useState(comments);
  const [replyingTo, setReplyingTo] = useState<string | null>(null);
  const [content, setContent] = useState('');
  const [loading, setLoading] = useState(false);

  /**
   * 提交评论
   */
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    
    if (!content.trim()) return;
    if (!currentUser) {
      alert('请先登录');
      return;
    }

    setLoading(true);

    try {
      const result = await createComment({
        postId,
        content,
        parentId: replyingTo || undefined,
      });

      if (result.success && result.comment) {
        // Optimistic Update: 立即更新 UI
        if (replyingTo) {
          // 添加到回复列表
          setCommentList(prev =>
            prev.map(comment =>
              comment.id === replyingTo
                ? {
                    ...comment,
                    replies: [...(comment.replies || []), result.comment],
                  }
                : comment
            )
          );
        } else {
          // 添加到顶级评论
          setCommentList(prev => [...prev, result.comment]);
        }

        // 清空表单
        setContent('');
        setReplyingTo(null);
      } else {
        alert(result.error || '评论失败');
      }
    } catch (error) {
      console.error(error);
      alert('评论失败,请稍后重试');
    } finally {
      setLoading(false);
    }
  };

  return (
    <section className="mt-12 pt-8 border-t">
      <h2 className="text-2xl font-bold mb-6">
        评论 ({commentList.length})
      </h2>

      {/* 评论表单 */}
      <CommentForm
        content={content}
        onChange={setContent}
        onSubmit={handleSubmit}
        loading={loading}
        placeholder={
          replyingTo ? '撰写回复...' : '写下你的评论...'
        }
        onCancel={() => setReplyingTo(null)}
        isReply={!!replyingTo}
      />

      {/* 评论列表 */}
      <div className="space-y-6 mt-8">
        {commentList.map(comment => (
          <CommentItem
            key={comment.id}
            comment={comment}
            currentUser={currentUser}
            onReply={(commentId) => setReplyingTo(commentId)}
            replyingTo={replyingTo}
          />
        ))}

        {commentList.length === 0 && (
          <p className="text-center text-gray-500 py-8">
            暂无评论,来发表第一条评论吧!
          </p>
        )}
      </div>
    </section>
  );
}

/**
 * 评论表单组件
 */
function CommentForm({
  content,
  onChange,
  onSubmit,
  loading,
  placeholder,
  onCancel,
  isReply,
}: any) {
  return (
    <form onSubmit={onSubmit} className="mb-8">
      <textarea
        value={content}
        onChange={(e) => onChange(e.target.value)}
        placeholder={placeholder}
        rows={4}
        className="w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
        required
      />
      
      <div className="flex justify-end gap-2 mt-3">
        {isReply && (
          <button
            type="button"
            onClick={onCancel}
            className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded"
          >
            取消
          </button>
        )}
        <button
          type="submit"
          disabled={loading || !content.trim()}
          className="px-6 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
        >
          {loading ? '提交中...' : '发表评论'}
        </button>
      </div>
    </form>
  );
}

/**
 * 单条评论组件
 */
function CommentItem({ 
  comment, 
  currentUser, 
  onReply,
  replyingTo 
}: any) {
  const isReplying = replyingTo === comment.id;

  return (
    <div className="flex gap-4">
      {/* 头像 */}
      {comment.author.image && (
        <Image
          src={comment.author.image}
          alt={comment.author.name || ''}
          width={40}
          height={40}
          className="rounded-full flex-shrink-0"
        />
      )}

      <div className="flex-1">
        {/* 评论头部 */}
        <div className="flex items-center gap-2 mb-2">
          <span className="font-medium">{comment.author.name}</span>
          <time 
            className="text-sm text-gray-500"
            dateTime={comment.createdAt}
          >
            {formatDate(comment.createdAt)}
          </time>
        </div>

        {/* 评论内容 */}
        <p className="text-gray-700 mb-3 whitespace-pre-wrap">
          {comment.content}
        </p>

        {/* 回复按钮 */}
        {currentUser && !isReplying && (
          <button
            onClick={() => onReply(comment.id)}
            className="text-sm text-blue-600 hover:underline"
          >
            回复
          </button>
        )}

        {/* 回复表单 */}
        {isReplying && (
          <div className="mt-4 ml-8">
            <CommentForm
              content=""
              onChange={() => {}}
              onSubmit={async (e: any) => {
                e.preventDefault();
                // 实际应由父组件处理
              }}
              loading={false}
              placeholder="撰写回复..."
              onCancel={() => onReply(null)}
              isReply={true}
            />
          </div>
        )}

        {/* 回复列表 */}
        {comment.replies?.length > 0 && (
          <div className="mt-4 space-y-4 ml-8">
            {comment.replies.map((reply: any) => (
              <CommentItem
                key={reply.id}
                comment={reply}
                currentUser={currentUser}
                onReply={onReply}
                replyingTo={replyingTo}
              />
            ))}
          </div>
        )}
      </div>
    </div>
  );
}

💡 Optimistic UI 原理:

提交评论的时候采用了乐观更新的方式:

// 1. 立即更新 UI(假设成功)
setCommentList(prev => [...prev, newComment]);

// 2. 发送请求
const result = await createComment(data);

// 3. 如果失败,回滚
if (!result.success) {
  setCommentList(prev => prev.filter(c => c.id !== newComment.id));
}

优势:

  • ✅ 用户体验极佳,无需等待服务器响应
  • ✅ 减少感知延迟

风险:

  • ⚠️ 需要处理失败情况
  • ⚠️ 不适合关键操作(如支付)

后续的点赞收藏功能也采用乐观更新。


六、❤️ 点赞与收藏按钮

1. 点赞按钮

创建 components/LikeButton.tsx:

// components/LikeButton.tsx
'use client';

import { useState } from 'react';
import { toggleLike } from '@/app/actions/interaction';

interface LikeButtonProps {
  postId: string;
  initialLiked: boolean;
  isAuthenticated: boolean;
}

/**
 * 点赞按钮(Optimistic UI)
 * 
 * 交互流程:
 * 1. 用户点击
 * 2. 立即切换 UI 状态
 * 3. 后台发送请求
 * 4. 如果失败,回滚状态
 */
export function LikeButton({ 
  postId, 
  initialLiked,
  isAuthenticated 
}: LikeButtonProps) {
  const [liked, setLiked] = useState(initialLiked);
  const [loading, setLoading] = useState(false);

  const handleClick = async () => {
    if (!isAuthenticated) {
      alert('请先登录');
      return;
    }

    // Optimistic Update
    const previousState = liked;
    setLiked(!previousState);
    setLoading(true);

    try {
      const result = await toggleLike(postId);

      if (!result.success) {
        // 回滚
        setLiked(previousState);
        alert(result.error);
      }
    } catch (error) {
      // 回滚
      setLiked(previousState);
      console.error(error);
    } finally {
      setLoading(false);
    }
  };

  return (
    <button
      onClick={handleClick}
      disabled={loading}
      className={`flex items-center gap-2 px-4 py-2 rounded-full transition-all ${
        liked
          ? 'bg-red-50 text-red-600 hover:bg-red-100'
          : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
      }`}
    >
      <span className={`text-xl ${liked ? 'animate-pulse' : ''}`}>
        {liked ? '❤️' : '🤍'}
      </span>
      <span>{liked ? '已点赞' : '点赞'}</span>
    </button>
  );
}

2. 收藏按钮

创建 components/BookmarkButton.tsx:

// components/BookmarkButton.tsx
'use client';

import { useState } from 'react';
import { toggleBookmark } from '@/app/actions/interaction';

interface BookmarkButtonProps {
  postId: string;
  initialBookmarked: boolean;
  isAuthenticated: boolean;
}

export function BookmarkButton({ 
  postId, 
  initialBookmarked,
  isAuthenticated 
}: BookmarkButtonProps) {
  const [bookmarked, setBookmarked] = useState(initialBookmarked);
  const [loading, setLoading] = useState(false);

  const handleClick = async () => {
    if (!isAuthenticated) {
      alert('请先登录');
      return;
    }

    const previousState = bookmarked;
    setBookmarked(!previousState);
    setLoading(true);

    try {
      const result = await toggleBookmark(postId);

      if (!result.success) {
        setBookmarked(previousState);
        alert(result.error);
      }
    } catch (error) {
      setBookmarked(previousState);
      console.error(error);
    } finally {
      setLoading(false);
    }
  };

  return (
    <button
      onClick={handleClick}
      disabled={loading}
      className={`flex items-center gap-2 px-4 py-2 rounded-full transition-all ${
        bookmarked
          ? 'bg-yellow-50 text-yellow-600 hover:bg-yellow-100'
          : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
      }`}
    >
      <span className="text-xl">
        {bookmarked ? '⭐' : '☆'}
      </span>
      <span>{bookmarked ? '已收藏' : '收藏'}</span>
    </button>
  );
}

七、🔐 登录页面

1. 自定义登录页

创建 app/auth/signin/page.tsx:

// app/auth/signin/page.tsx
import { signIn } from '@/auth';
import { Github } from 'lucide-react';

export const metadata = {
  title: '登录 - 全栈博客',
  description: '使用 GitHub 账号登录',
};

export default function SignInPage() {
  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50">
      <div className="max-w-md w-full bg-white rounded-lg shadow-lg p-8">
        <h1 className="text-3xl font-bold text-center mb-8">欢迎回来</h1>
        
        <form
          action={async () => {
            'use server';
            await signIn('github', { 
              redirectTo: '/' 
            });
          }}
          className="space-y-4"
        >
          <button
            type="submit"
            className="w-full flex items-center justify-center gap-3 px-6 py-3 bg-gray-900 text-white rounded-lg hover:bg-gray-800 transition-colors"
          >
            <Github className="w-5 h-5" />
            使用 GitHub 登录
          </button>
        </form>

        <p className="mt-6 text-center text-sm text-gray-600">
          登录后即可评论、点赞、收藏文章
        </p>
      </div>
    </div>
  );
}

🔑 Server Actions 表单:

<form action={async () => {
  'use server';
  await signIn('github', { redirectTo: '/' });
}}>
  <button type="submit">登录</button>
</form>

这种写法:

  • ✅ 无需 JavaScript 也可工作
  • ✅ 自动处理 CSRF Token
  • ✅ 简洁优雅

八、⚡ 性能优化深度实践

1. 图片懒加载与优先级

// 首屏图片:优先加载
<Image
  src={heroImage}
  priority  // 关键!
  alt="Hero"
/>

// 非首屏图片:懒加载(默认行为)
<Image
  src={thumbnail}
  alt="Thumbnail"
  loading="lazy"  // 可省略,默认就是 lazy
/>

2. 字体优化

创建 app/layout.tsx:

// app/layout.tsx
import { Inter } from 'next/font/google';

// Next.js 自动优化字体
const inter = Inter({ 
  subsets: ['latin'],
  display: 'swap',  // 避免 FOIT(Flash of Invisible Text)
});

export default function RootLayout({ children }) {
  return (
    <html lang="zh-CN">
      <body className={inter.className}>
        {children}
      </body>
    </html>
  );
}

优势:

  • ✅ 自动托管字体文件(CDN)
  • ✅ 消除布局偏移
  • ✅ 预加载关键字体

3. 代码分割

Next.js App Router 自动进行代码分割:

  • 每个路由独立 bundle
  • 客户端组件按需加载
  • 第三方库 Tree Shaking

无需手动配置!

4. 流式渲染(Streaming)

对于慢查询,可以使用 Suspense:

// app/blog/[slug]/page.tsx
import { Suspense } from 'react';

export default function BlogPostPage({ params }) {
  return (
    <article>
      {/* 快速加载的部分 */}
      <PostHeader />
      
      {/* 慢查询部分:流式加载 */}
      <Suspense fallback={<CommentsSkeleton />}>
        <Comments />
      </Suspense>
    </article>
  );
}

async function Comments() {
  // 模拟慢查询
  await new Promise(resolve => setTimeout(resolve, 2000));
  return <div>评论内容...</div>;
}

function CommentsSkeleton() {
  return <div className="animate-pulse">加载中...</div>;
}

效果:

  • 用户先看到文章头部
  • 评论逐步加载,无需等待

九、🚀 部署上线

1. Vercel 部署(推荐)

步骤 1:推送代码到 GitHub

git init
git add .
git commit -m "feat: 完成博客系统"
git remote add origin https://github.com/yourusername/fullstack-blog.git
git push -u origin main

步骤 2:连接 Vercel

  1. 访问 vercel.com
  2. 点击 "New Project"
  3. 导入 GitHub 仓库
  4. 配置环境变量

步骤 3:配置环境变量

在 Vercel Dashboard → Settings → Environment Variables 中添加:

DATABASE_URL=postgresql://...
AUTH_SECRET=your-secret-key
GITHUB_ID=your-github-id
GITHUB_SECRET=your-github-secret
OPENAI_API_KEY=sk-your-key
NEXT_PUBLIC_APP_URL=https://your-domain.vercel.app

步骤 4:自动部署

每次推送到 main 分支,Vercel 会自动:

  1. 安装依赖
  2. 执行 next build
  3. 部署到全球 CDN
  4. 提供预览 URL

2. 数据库托管(Neon)

Neon 提供免费 Serverless PostgreSQL:

  1. 注册 neon.tech
  2. 创建新项目
  3. 获取连接字符串
  4. 更新 DATABASE_URL

优势:

  • ✅ 免费 tier: 0.5 GB 存储
  • ✅ 自动扩缩容
  • ✅ 分支功能(类似 Git)

3. 自定义域名

在 Vercel Dashboard → Settings → Domains 中:

  1. 添加你的域名
  2. 按提示配置 DNS(CNAME/A Record)
  3. 等待 SSL 证书签发(自动)

十一、📊 监控与分析

1. Vercel Analytics

app/layout.tsx 中添加:

import { Analytics } from '@vercel/analytics/react';

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        {children}
        <Analytics />
      </body>
    </html>
  );
}

安装依赖:

npm install @vercel/analytics

功能:

  • 页面浏览量
  • 用户地理位置
  • 设备类型
  • 性能指标

2. Core Web Vitals 监控

Vercel 自动收集:

  • LCP(Largest Contentful Paint): 最大内容绘制时间
  • FID(First Input Delay): 首次输入延迟
  • CLS(Cumulative Layout Shift): 累积布局偏移

目标值:

  • LCP < 2.5s
  • FID < 100ms
  • CLS < 0.1

十二、📝 本章小结

通过上下两篇的学习,你已完成了一个生产级全栈博客系统:

✅ 已完成功能

模块 功能 技术栈
用户系统 GitHub OAuth 登录 Auth.js
文章管理 CRUD、Markdown 渲染 Prisma、MDX
AI 增强 自动摘要、标签推荐 OpenAI API
社交互动 评论、点赞、收藏 Server Actions
性能优化 缓存、懒加载、流式渲染 Next.js 内置
部署运维 Vercel 自动化部署 CI/CD

🎯 核心知识点回顾

  1. App Router 架构: 文件系统路由、嵌套布局、并行路由
  2. React Server Components: 服务端渲染、减少客户端 JS
  3. Server Actions: 类型安全的表单处理
  4. 数据缓存策略: revalidateTaggenerateStaticParams
  5. 性能优化: next/image、字体优化、代码分割
  6. SEO 最佳实践: Metadata API、Open Graph、Sitemap

🚀 下一步扩展方向

  1. 全文搜索: 集成 Meilisearch 或 Algolia
  2. RSS 订阅: 生成 RSS/Atom Feed
  3. 邮件通知: 新评论提醒(Resend/SendGrid)
  4. 管理后台: 文章审核、数据统计、用户管理
  5. 暗黑模式: next-themes 实现主题切换
  6. 国际化: next-intl 多语言支持
  7. PWA: 离线访问、推送通知

💪 练习作业

  1. 实现"相关文章推荐"功能(基于标签相似度)
  2. 添加"阅读进度条"(客户端组件)
  3. 实现"代码复制"按钮(CodeBlock 组件)
  4. 添加 Google Analytics 集成
  5. 实现简单的站内搜索(使用 Prisma 全文搜索)

🎉 结语

恭喜你完成了这个完整的 Next.js 全栈项目!

从环境配置到生产部署,你已掌握了:

  • ✅ 现代 Web 开发的最佳实践
  • ✅ 全栈应用的架构设计思路
  • ✅ 性能优化与 SEO 技巧
  • ✅ 自动化部署与监控

记住: 学习编程最好的方式就是不断实践。在此基础上,尝试添加新功能、优化现有代码、重构架构。

祝你成为一名优秀的 Next.js 全栈开发者! 🚀


资源链接:

Next.js从入门到实战保姆级教程(第十六章):实战项目(上)——全栈博客系统架构与核心功能

2026年4月27日 08:10

本系列文章将围绕Next.js技术栈,旨在为AI Agent开发者提供一套完整的客户端侧工程实践指南。

理论学一百遍,不如动手做一遍。 本章将带你从零开始构建一个有实际价值的全栈博客系统,将前面所有章节的知识融会贯通。我们将分上下两篇完成这个项目,上篇聚焦架构设计与核心功能实现,下篇将完善前端页面、性能优化及部署上线。

一、📋 项目规划与设计思路

1. 为什么选择博客系统作为实战项目?

在开始编码之前,我们先思考一个问题:为什么博客系统是学习 Next.js 的最佳实战项目?

mindmap
  root((为什么选博客系统))
    (技术覆盖面广)
      路由系统
      数据获取
      表单处理
      认证鉴权
    (业务逻辑完整)
      CRUD 操作
      权限控制
      缓存策略
      SEO 优化
    (可扩展性强)
      评论系统
      AI 集成
      搜索功能
      管理后台
    (真实应用场景)
      个人品牌
      技术分享
      作品集展示

博客系统看似简单,实则涵盖了现代 Web 开发的几乎所有核心技术点:

  1. 内容管理系统(CMS):文章的创建、编辑、删除
  2. 用户系统:注册、登录、权限管理
  3. 交互功能:评论、点赞、收藏
  4. 性能优化:缓存策略、图片优化、SEO
  5. AI 增强:智能摘要、标签推荐

通过这个项目,你将真正理解如何将理论知识转化为生产力

2. 功能特性全景图

让我们先明确这个博客系统要实现哪些功能:

(1)核心功能模块

模块 功能点 技术要点
用户系统 邮箱/GitHub 登录、个人资料管理 Auth.js、Session 管理
文章系统 Markdown 编写、代码高亮、标签分类 MDX、Shiki、Prisma
AI 功能 自动生成摘要、智能标签推荐 OpenAI API、Vercel AI SDK
社交互动 评论、点赞、收藏、RSS 订阅 Server Actions、Optimistic UI
管理后台 文章审核、数据统计、用户管理 RBAC 权限控制

3. 技术选型决策过程

在实际项目中,技术选型不是越新越好,而是要权衡多个维度:

(1)框架选择:Next.js 15 App Router

  • React Server Components 提升性能
  • 文件系统路由简化开发
  • 内置优化(Image/Font/Metadata)
  • Vercel 生态无缝集成

(2) 数据库方案:PostgreSQL + Prisma ORM

  • 关系型数据库适合博客数据结构
  • Prisma 提供类型安全的查询
  • Neon 提供免费 Serverless PostgreSQL
  • 迁移工具简化数据库版本管理

(3) 认证方案:Auth.js (NextAuth v5)

  • 官方推荐的 Next.js 认证方案
  • 支持 OAuth 和凭证登录
  • 与 Prisma 适配器完美集成
  • Session 管理开箱即用

(4)样式方案:Tailwind CSS

  • 实用优先,开发效率高
  • 与 Next.js 深度集成
  • 响应式设计简单易用
  • 社区组件库丰富

关键决策原则:

  • 稳定性优先: 选择成熟稳定的技术栈,而非最新但未经验证的
  • 生态完整: 优先考虑有良好文档和社区支持的技术
  • 开发体验: 减少样板代码,提高开发效率
  • 可维护性: 类型安全、清晰的代码结构

二、🚀 项目初始化与环境搭建

第一步:创建 Next.js 项目

打开终端,执行以下命令:

npx create-next-app@latest fullstack-blog

在交互式提示中,按以下方式选择:

✔ Would you like to use TypeScript? … Yes
✔ Would you like to use ESLint? … Yes
✔ Would you like to use Tailwind CSS? … Yes
✔ Would you like to use `src/` directory? … Yes
✔ Would you like to use App Router? (recommended) … Yes
✔ Would you like to customize the default import alias (@/*)? … No

为什么要这样配置?

  • TypeScript: 提供类型安全,减少运行时错误,是生产项目的标配
  • ESLint: 自动检测代码问题,保持代码质量
  • Tailwind CSS: 快速构建 UI,避免手写大量 CSS
  • src/ 目录: 更好的项目结构组织,分离源代码和配置文件
  • App Router: Next.js 13+ 的推荐路由方案,支持 RSC

第二步:安装核心依赖

进入项目目录后,我们需要安装几类依赖:

cd fullstack-blog

# 1️⃣ 数据库相关
npm install prisma @prisma/client

# 2️⃣ 认证相关
npm install next-auth@beta @auth/prisma-adapter bcryptjs

# 3️⃣ Markdown 渲染
npm install next-mdx-remote shiki rehype-autolink-headings rehype-slug

# 4️⃣ 表单验证
npm install zod react-hook-form @hookform/resolvers

# 5️⃣ AI 集成
npm install ai openai

# 6️⃣ 工具库
npm install date-fns slugify clsx tailwind-merge

# 7️⃣ 开发依赖(类型定义)
npm install -D @types/bcryptjs

依赖分类解析:

类别 包名 作用
ORM prisma, @prisma/client 类型安全的数据库访问层
认证 next-auth@beta Next.js 官方认证库 v5 版本
密码加密 bcryptjs 用户密码哈希加密
MDX next-mdx-remote 在服务端渲染 Markdown
代码高亮 shiki VS Code 同款语法高亮引擎
表单 zod, react-hook-form Schema 验证 + 高性能表单管理
AI ai, openai Vercel AI SDK + OpenAI 客户端
工具 date-fns, slugify 日期格式化、URL 友好字符串生成

第三步:环境变量配置

在项目根目录创建 .env.local 文件:

# .env.local

# ==================== 数据库配置 ====================
# 本地开发使用 PostgreSQL
DATABASE_URL="postgresql://user:password@localhost:5432/blog"
# Prisma 直连 URL(用于迁移等操作)
DIRECT_URL="postgresql://user:password@localhost:5432/blog"

# ==================== 认证配置 ====================
# Auth.js 会话加密密钥(至少 32 字符)
AUTH_SECRET="your-secret-key-min-32-characters-long!!!"
# GitHub OAuth 凭据(需在 GitHub Developer Settings 中创建)
GITHUB_ID="your-github-client-id"
GITHUB_SECRET="your-github-client-secret"

# ==================== AI 配置 ====================
# OpenAI API Key(从 https://platform.openai.com 获取)
OPENAI_API_KEY="sk-your-openai-api-key"

# ==================== 应用配置 ====================
# 应用基础 URL(开发环境)
NEXT_PUBLIC_APP_URL="http://localhost:3000"

⚠️ 安全提醒:

  • .env.local 已默认添加到 .gitignore,不会提交到 Git
  • AUTH_SECRET 可使用命令生成: openssl rand -base64 32
  • 生产环境需在部署平台(Vercel/Docker)配置这些变量

第四步:启动开发服务器

npm run dev

访问 http://localhost:3000,如果看到 Next.js 欢迎页面,说明项目初始化成功! 🎉


三、🗄️ 数据库设计与 Prisma 建模

1. 为什么需要精心设计数据库?

数据库设计直接影响应用的性能、可扩展性和维护成本。对于博客系统,我们需要考虑:

  1. 实体关系: 用户、文章、标签、评论之间的关系
  2. 索引优化: 加速常用查询(如按 slug 查找文章)
  3. 数据完整性: 外键约束、级联删除
  4. 扩展预留: 未来可能添加的功能(如点赞、收藏)

2. ER 图(Entity-Relationship Diagram)

erDiagram
    USER ||--o{ POST : writes
    USER ||--o{ COMMENT : comments
    USER ||--o{ LIKE : likes
    USER ||--o{ BOOKMARK : bookmarks
    
    POST ||--o{ POST_TAG : has
    TAG ||--o{ POST_TAG : tagged_in
    POST ||--o{ COMMENT : receives
    POST ||--o{ LIKE : gets
    POST ||--o{ BOOKMARK : saved
    
    COMMENT ||--o{ COMMENT : replies_to
    
    USER {
        String id PK
        String email UK
        String name
        Role role
    }
    
    POST {
        String id PK
        String slug UK
        String title
        Boolean published
    }
    
    TAG {
        String id PK
        String name UK
        String slug UK
    }
    
    COMMENT {
        String id PK
        String postId FK
        String parentId FK
    }

3. Prisma Schema 详解

创建 prisma/schema.prisma 文件:

// prisma/schema.prisma

// 1. 生成器配置:告诉 Prisma 生成什么语言的客户端
generator client {
  provider = "prisma-client-js"
}

// 2. 数据源配置:指定数据库类型和连接字符串
datasource db {
  provider  = "postgresql"
  url       = env("DATABASE_URL")
  directUrl = env("DIRECT_URL")
}

// 3. 枚举类型:用户角色
enum Role {
  USER   // 普通用户
  ADMIN  // 管理员
}

// ==================== 核心模型 ====================

// 用户模型
model User {
  id            String    @id @default(cuid())
  name          String?
  email         String    @unique
  emailVerified DateTime?
  image         String?
  bio           String?   @db.Text
  website       String?
  github        String?
  role          Role      @default(USER)
  createdAt     DateTime  @default(now())
  updatedAt     DateTime  @updatedAt

  // 关联关系
  accounts  Account[]
  sessions  Session[]
  posts     Post[]
  comments  Comment[]
  likes     Like[]
  bookmarks Bookmark[]

  @@map("users")
}

// 文章模型
model Post {
  id          String     @id @default(cuid())
  title       String
  slug        String     @unique
  content     String     @db.Text
  excerpt     String?    // AI 生成的摘要
  coverImage  String?
  published   Boolean    @default(false)
  featured    Boolean    @default(false)
  viewCount   Int        @default(0)
  readingTime Int?       // 预计阅读时间(分钟)
  authorId    String
  createdAt   DateTime   @default(now())
  updatedAt   DateTime   @updatedAt
  publishedAt DateTime?

  // 关联关系
  author    User       @relation(fields: [authorId], references: [id])
  tags      PostTag[]
  comments  Comment[]
  likes     Like[]
  bookmarks Bookmark[]

  // 索引优化查询性能
  @@index([slug])
  @@index([published])
  @@index([createdAt])
  @@map("posts")
}

// 标签模型
model Tag {
  id          String    @id @default(cuid())
  name        String    @unique
  slug        String    @unique
  description String?
  color       String    @default("#6366f1")

  posts PostTag[]

  @@map("tags")
}

// 文章-标签多对多关系表
model PostTag {
  postId String
  tagId  String

  post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
  tag  Tag  @relation(fields: [tagId], references: [id], onDelete: Cascade)

  @@id([postId, tagId])
  @@map("post_tags")
}

// 评论模型(支持嵌套回复)
model Comment {
  id        String   @id @default(cuid())
  content   String   @db.Text
  authorId  String
  postId    String
  parentId  String?  // 父评论 ID,用于嵌套评论
  approved  Boolean  @default(true)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  author   User      @relation(fields: [authorId], references: [id])
  post     Post      @relation(fields: [postId], references: [id], onDelete: Cascade)
  parent   Comment?  @relation("CommentReplies", fields: [parentId], references: [id])
  replies  Comment[] @relation("CommentReplies")

  @@index([postId])
  @@index([approved])
  @@map("comments")
}

// 点赞模型
model Like {
  userId String
  postId String

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)
  post Post @relation(fields: [postId], references: [id], onDelete: Cascade)

  @@id([userId, postId])
  @@map("likes")
}

// 收藏模型
model Bookmark {
  userId    String
  postId    String
  createdAt DateTime @default(now())

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)
  post Post @relation(fields: [postId], references: [id], onDelete: Cascade)

  @@id([userId, postId])
  @@map("bookmarks")
}

// ==================== Auth.js 所需模型 ====================

model Account {
  id                String  @id @default(cuid())
  userId            String
  type              String
  provider          String
  providerAccountId String
  refresh_token     String? @db.Text
  access_token      String? @db.Text
  expires_at        Int?
  token_type        String?
  scope             String?
  id_token          String? @db.Text
  session_state     String?

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([provider, providerAccountId])
  @@map("accounts")
}

model Session {
  id           String   @id @default(cuid())
  sessionToken String   @unique
  userId       String
  expires      DateTime
  user         User     @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@map("sessions")
}

model VerificationToken {
  identifier String
  token      String   @unique
  expires    DateTime

  @@unique([identifier, token])
  @@map("verification_tokens")
}

📝 Schema 设计要点解析:

(1) 主键策略:cuid() vs uuid()

id String @id @default(cuid())
  • cuid: 更短、更易读、按时间排序,适合大多数场景
  • uuid: 标准 UUID v4,更长但全球唯一
  • 自增 ID: 不适合分布式系统,不推荐

(2) 索引优化

@@index([slug])        // 加速按 slug 查询文章
@@index([published])   // 加速筛选已发布文章
@@index([postId])      // 加速查询文章的评论

何时添加索引?

  • ✅ 经常用于 WHERE 条件的字段
  • ✅ 外键字段
  • ❌ 低基数字段(如布尔值)
  • ❌ 频繁更新的字段

(3) 级联删除

post Post @relation(fields: [postId], references: [id], onDelete: Cascade)

当文章被删除时,自动删除相关的评论、点赞、收藏记录,保持数据一致性

(4) 自引用关系(嵌套评论)

parent   Comment?  @relation("CommentReplies", fields: [parentId], references: [id])
replies  Comment[] @relation("CommentReplies")

通过 parentId 实现评论的树形结构,支持无限层级回复。

4. 初始化数据库

执行以下命令创建数据库表:

# 1. 生成 Prisma Client(TypeScript 类型定义)
npx prisma generate

# 2. 创建数据库迁移
npx prisma migrate dev --name init

# 3. (可选)可视化查看数据库
npx prisma studio

迁移文件说明:

执行 migrate dev 后,会在 prisma/migrations/ 目录生成 SQL 文件:

-- prisma/migrations/20260412000000_init/migration.sql

CREATE TABLE "users" (
    "id" TEXT NOT NULL,
    "email" TEXT NOT NULL,
    "name" TEXT,
    "role" "Role" NOT NULL DEFAULT 'USER',
    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
    "updatedAt" TIMESTAMP(3) NOT NULL,
    
    CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);

CREATE UNIQUE INDEX "users_email_key" ON "users"("email");

-- ... 其他表的创建语句

💡 最佳实践:

  • 每次修改 Schema 都创建新的迁移
  • 迁移文件应提交到 Git,便于团队协作
  • 生产环境使用 prisma migrate deploy 而非 dev

四、🔐 认证系统集成(Auth.js)

1. 认证流程概览

sequenceDiagram
    participant User as 用户
    participant App as Next.js App
    participant Auth as Auth.js
    participant DB as Database
    participant OAuth as GitHub OAuth
    
    User->>App: 点击"使用 GitHub 登录"
    App->>Auth: 重定向到 /api/auth/signin/github
    Auth->>OAuth: 请求授权
    OAuth->>User: 显示授权页面
    User->>OAuth: 确认授权
    OAuth->>Auth: 返回授权码
    Auth->>OAuth: 交换访问令牌
    Auth->>DB: 创建/更新用户记录
    Auth->>App: 设置 Session Cookie
    App->>User: 重定向到首页(已登录状态)

2. 配置 Auth.js

创建 auth.ts 文件(项目根目录):

// auth.ts
import NextAuth from 'next-auth';
import GitHub from 'next-auth/providers/github';
import { PrismaAdapter } from '@auth/prisma-adapter';
import { db } from '@/lib/db';

export const { handlers, signIn, signOut, auth } = NextAuth({
  adapter: PrismaAdapter(db),
  
  providers: [
    GitHub({
      clientId: process.env.GITHUB_ID!,
      clientSecret: process.env.GITHUB_SECRET!,
    }),
  ],
  
  callbacks: {
    // Session 回调:自定义 Session 数据
    async session({ session, user }) {
      if (session.user) {
        session.user.id = user.id;
        session.user.role = user.role;
      }
      return session;
    },
    
    // JWT 回调:将用户信息编码到 Token
    async jwt({ token, user }) {
      if (user) {
        token.id = user.id;
        token.role = user.role;
      }
      return token;
    },
  },
  
  pages: {
    signIn: '/auth/signin',  // 自定义登录页面
  },
});

🔑 关键配置解析:

(1) Adapter(适配器模式)

adapter: PrismaAdapter(db)

Auth.js 通过适配器与不同数据库交互。PrismaAdapter 会自动:

  • 创建/更新用户记录
  • 管理 OAuth 账户绑定
  • 处理 Session 生命周期

(2) Providers(认证提供者)

providers: [
  GitHub({ /* 配置 */ }),
  // 可以添加更多: Google、Email、Credentials...
]

每个 Provider 对应一种登录方式。GitHub OAuth 需要在 GitHub Developer Settings 中创建应用,获取 Client IDClient Secret

(3) Callbacks(回调函数)

callbacks: {
  async session({ session, user }) {
    // 在这里可以向 session 添加额外数据
    session.user.id = user.id;
    return session;
  }
}

常见用途:

  • 向 Session 添加用户 ID、角色等信息
  • 根据用户角色限制访问
  • 记录登录日志

(3)创建 API 路由

Next.js App Router 中,Auth.js 的路由位于 app/api/auth/[...nextauth]/route.ts:

// app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/auth';

export const { GET, POST } = handlers;

路由命名规则:

  • [...nextauth] 是动态路由段,匹配所有 /api/auth/* 路径
  • Auth.js 内部会根据子路径分发请求(如 /api/auth/signin)

4. 封装认证辅助函数

创建 lib/auth.ts:

// lib/auth.ts
import { auth } from '@/auth';
import { redirect } from 'next/navigation';

/**
 * 获取当前会话(服务端组件中使用)
 */
export async function getCurrentUser() {
  const session = await auth();
  return session?.user || null;
}

/**
 * 要求用户登录(未登录则重定向)
 */
export async function requireAuth() {
  const session = await auth();
  
  if (!session?.user) {
    redirect('/auth/signin?callbackUrl=' + encodeURIComponent(
      typeof window !== 'undefined' ? window.location.pathname : '/'
    ));
  }
  
  return session;
}

/**
 * 检查是否为管理员
 */
export async function requireAdmin() {
  const session = await requireAuth();
  
  if (session.user.role !== 'ADMIN') {
    throw new Error('权限不足');
  }
  
  return session;
}

使用示例:

// app/dashboard/page.tsx
import { requireAuth } from '@/lib/auth';

export default async function DashboardPage() {
  // 未登录会自动重定向到登录页
  const session = await requireAuth();
  
  return <div>欢迎, {session.user.name}</div>;
}

五、✍️ 文章 CRUD 核心功能

Server Actions 架构设计

在 Next.js 13+ 中,Server Actions 是处理表单提交和数据突变的首选方案,相比传统 API Routes 有以下优势:

对比项 Server Actions API Routes
类型安全 ✅ 端到端类型推断 ❌ 需手动定义接口
渐进增强 ✅ 无 JS 也可工作 ❌ 依赖客户端 JS
代码复用 ✅ 直接导入函数 ❌ 需 HTTP 请求
安全性 ✅ 自动 CSRF 保护 ⚠️ 需手动实现

1. 创建文章 Action

创建 app/actions/post.ts:

// app/actions/post.ts
'use server';

import { auth } from '@/auth';
import { db } from '@/lib/db';
import { z } from 'zod';
import slugify from 'slugify';
import { revalidateTag } from 'next/cache';
import { redirect } from 'next/navigation';

// ==================== Schema 定义 ====================

/**
 * 创建文章的验证 Schema
 * 
 * Zod 的优势:
 * 1. 运行时验证 + TypeScript 类型推断
 * 2. 详细的错误信息
 * 3. 可组合、可扩展
 */
const createPostSchema = z.object({
  title: z.string()
    .min(1, '标题不能为空')
    .max(200, '标题不能超过 200 字符'),
  
  content: z.string()
    .min(100, '文章内容至少 100 字符'),
  
  excerpt: z.string()
    .max(500, '摘要不能超过 500 字符')
    .optional(),
  
  coverImage: z.string()
    .url('请输入有效的图片 URL')
    .optional(),
  
  tagIds: z.array(z.string())
    .min(1, '至少选择一个标签'),
  
  published: z.boolean()
    .default(false),
});

// 从 Schema 推断 TypeScript 类型
type CreatePostInput = z.infer<typeof createPostSchema>;

/**
 * 创建新文章
 * 
 * @param data - 文章数据
 * @returns 创建结果
 * 
 * 使用场景:
 * - 管理后台创建文章
 * - 用户投稿功能
 */
export async function createPost(data: CreatePostInput) {
  // 1. 身份验证
  const session = await auth();
  
  if (!session?.user) {
    return { 
      success: false, 
      error: '未授权,请先登录' 
    };
  }

  // 2. 数据验证
  const validated = createPostSchema.safeParse(data);
  
  if (!validated.success) {
    return { 
      success: false, 
      error: '数据验证失败',
      details: validated.error.flatten().fieldErrors
    };
  }

  // 3. 生成 URL 友好的 slug
  const slug = slugify(validated.data.title, { 
    lower: true,      // 转小写
    strict: true,     // 严格模式,移除特殊字符
  });

  // 4. 检查 slug 是否已存在
  const existingPost = await db.post.findUnique({
    where: { slug },
  });

  if (existingPost) {
    // 如果 slug 冲突,添加时间戳后缀
    const uniqueSlug = `${slug}-${Date.now()}`;
    return await savePost({ ...validated.data, slug: uniqueSlug }, session.user.id!);
  }

  return await savePost({ ...validated.data, slug }, session.user.id!);
}

/**
 * 保存文章到数据库(内部函数)
 */
async function savePost(
  data: CreatePostInput & { slug: string }, 
  authorId: string
) {
  try {
    const post = await db.post.create({
      data: {
        title: data.title,
        slug: data.slug,
        content: data.content,
        excerpt: data.excerpt,
        coverImage: data.coverImage,
        published: data.published,
        publishedAt: data.published ? new Date() : null,
        authorId,
        // 关联标签(多对多关系)
        tags: {
          create: data.tagIds.map(tagId => ({
            tag: { connect: { id: tagId } },
          })),
        },
      },
    });

    // 5. 失效相关缓存
    revalidateTag('posts');           // 文章列表缓存
    revalidateTag(`user-${authorId}`); // 用户文章列表缓存

    return { 
      success: true, 
      postId: post.id,
      message: '文章创建成功'
    };
  } catch (error) {
    console.error('Failed to create post:', error);
    return { 
      success: false, 
      error: '创建文章失败,请稍后重试'
    };
  }
}

📖 代码解析:

(1) 为什么使用 'use server' 指令?

'use server';

这个指令告诉 Next.js:

  • 该文件中的所有导出函数都在服务端执行
  • 可以在函数中访问数据库、环境变量等敏感资源
  • 客户端调用时会自动序列化参数和返回值

(2) Zod Schema 验证的重要性

const validated = createPostSchema.safeParse(data);

if (!validated.success) {
  return { error: '数据验证失败', details: validated.error.flatten() };
}

防御性编程原则:

  • 永远不要信任客户端传来的数据
  • ✅ 在服务端进行二次验证
  • ✅ 提供清晰的错误提示

(3)缓存失效策略

revalidateTag('posts');

当我们创建/更新/删除文章后,需要通知 Next.js 清除相关缓存:

  • revalidateTag('posts'): 清除所有文章列表的缓存
  • revalidatePath('/blog'): 清除特定路径的缓存

缓存失效时机:

  • 创建文章 → 清除列表缓存
  • 更新文章 → 清除详情 + 列表缓存
  • 删除文章 → 清除详情 + 列表 + 用户缓存

2. 获取文章列表(带缓存)

创建 lib/posts.ts:

// lib/posts.ts
import { db } from '@/lib/db';
import { cache } from 'react';

interface GetPostsOptions {
  page?: number;
  pageSize?: number;
  tagSlug?: string;
  search?: string;
  published?: boolean;
}

/**
 * 获取文章列表(带 React Cache)
 * 
 * cache() 的作用:
 * - 在同一请求中多次调用时,只执行一次数据库查询
 * - 配合 Next.js 数据缓存,实现多层缓存
 */
export const getPosts = cache(async ({
  page = 1,
  pageSize = 10,
  tagSlug,
  search,
  published = true,
}: GetPostsOptions = {}) => {
  const skip = (page - 1) * pageSize;

  // 构建动态查询条件
  const where = {
    published,
    ...(tagSlug && {
      tags: {
        some: {
          tag: { slug: tagSlug },
        },
      },
    }),
    ...(search && {
      OR: [
        { title: { contains: search, mode: 'insensitive' as const } },
        { content: { contains: search, mode: 'insensitive' as const } },
      ],
    }),
  };

  // 并行查询:文章列表 + 总数
  const [posts, total] = await Promise.all([
    db.post.findMany({
      where,
      skip,
      take: pageSize,
      orderBy: { publishedAt: 'desc' },
      include: {
        author: {
          select: { id: true, name: true, image: true },
        },
        tags: {
          include: {
            tag: { select: { id: true, name: true, slug: true, color: true } },
          },
        },
        _count: {
          select: { comments: true, likes: true },
        },
      },
    }),
    db.post.count({ where }),
  ]);

  return {
    posts,
    pagination: {
      page,
      pageSize,
      total,
      totalPages: Math.ceil(total / pageSize),
    },
  };
});

🎯 性能优化技巧:

(1) 使用 Promise.all 并行查询

const [posts, total] = await Promise.all([
  db.post.findMany({ /* ... */ }),
  db.post.count({ where }),
]);

而不是串行:

// ❌ 慢:两个查询依次执行
const posts = await db.post.findMany({ /* ... */ });
const total = await db.post.count({ where });

(2) 精确选择字段

include: {
  author: {
    select: { id: true, name: true, image: true }, // 只取需要的字段
  },
}

避免 select: true 取出所有字段,减少网络传输和内存占用。

(3) 使用 _count 聚合查询

_count: {
  select: { comments: true, likes: true },
}

直接在数据库层面统计数量,避免在应用层遍历数组。

4. 获取单篇文章详情

继续在 lib/posts.ts 中添加:

/**
 * 根据 slug 获取文章详情
 * 
 * @param slug - 文章 URL 标识
 * @returns 文章详情或 null
 */
export const getPostBySlug = cache(async (slug: string) => {
  const post = await db.post.findUnique({
    where: { slug },
    include: {
      author: {
        select: { id: true, name: true, image: true, bio: true },
      },
      tags: {
        include: {
          tag: { select: { id: true, name: true, slug: true, color: true } },
        },
      },
      // 获取顶级评论(不包括回复)
      comments: {
        where: { approved: true, parentId: null },
        include: {
          author: { select: { id: true, name: true, image: true } },
          // 嵌套获取回复评论
          replies: {
            include: {
              author: { select: { id: true, name: true, image: true } },
            },
          },
        },
        orderBy: { createdAt: 'asc' },
      },
      _count: {
        select: { likes: true, bookmarks: true },
      },
    },
  });

  if (!post) {
    return null;
  }

  // 异步增加浏览量(不阻塞响应)
  incrementViewCount(post.id);

  return post;
});

/**
 * 增加文章浏览量
 */
async function incrementViewCount(postId: string) {
  await db.post.update({
    where: { id: postId },
    data: { viewCount: { increment: 1 } },
  });
}

💡 设计思考:

为什么浏览量更新不等待?

// 不阻塞主流程
incrementViewCount(post.id);
return post;
  • 用户体验优先: 用户无需等待计数器更新
  • ✅ 即使更新失败,也不影响文章展示
  • ⚠️ 注意:在高并发场景可能需要队列或批量更新优化

六、🤖 AI 功能集成

1. 为什么要在博客中集成 AI?

传统博客系统的痛点:

  • ❌ 作者需要手动编写摘要,耗时耗力
  • ❌ 标签选择主观,不利于 SEO
  • ❌ 相关文章推荐算法复杂

AI 可以解决这些问题:

  • 自动生成摘要: 节省作者时间
  • 智能标签推荐: 基于内容语义分析
  • 个性化推荐: 提升用户停留时长

2. 配置 OpenAI

创建 lib/ai.ts:

// lib/ai.ts
import { openai } from '@ai-sdk/openai';
import { generateText } from 'ai';

/**
 * 使用 AI 生成文章摘要
 * 
 * @param content - 文章正文
 * @returns 生成的摘要文本
 * 
 * 应用场景:
 * - 创建文章时自动生成 excerpt
 * - 批量处理历史文章
 */
export async function generateExcerpt(content: string): Promise<string> {
  // 限制输入长度,避免超出 Token 限制
  const truncatedContent = content.substring(0, 2000);

  const { text } = await generateText({
    model: openai('gpt-4-turbo'),
    prompt: `请为以下文章内容生成一段简洁的摘要(不超过 200 字)。
要求:
1. 突出核心观点
2. 语言精炼流畅
3. 吸引读者继续阅读

文章内容:
${truncatedContent}`,
    temperature: 0.7, // 创造性:0-1,越高越随机
  });

  return text.trim();
}

/**
 * 智能推荐标签
 * 
 * @param title - 文章标题
 * @param content - 文章正文
 * @returns 标签名称数组
 */
export async function suggestTags(
  title: string,
  content: string
): Promise<string[]> {
  const { text } = await generateText({
    model: openai('gpt-4-turbo'),
    prompt: `基于以下文章标题和内容,推荐 3-5 个相关的技术标签。
要求:
1. 标签应为常见的技术术语
2. 用逗号分隔,不要编号
3. 每个标签不超过 10 个字符

标题: ${title}
内容: ${content.substring(0, 1500)}`,
    temperature: 0.5, // 更低温度,更稳定
  });

  // 解析返回结果
  return text
    .split(',')
    .map(tag => tag.trim())
    .filter(Boolean)
    .slice(0, 5); // 最多 5 个标签
}

/**
 * 生成文章预计阅读时间
 * 
 * @param content - 文章正文
 * @returns 阅读时间(分钟)
 */
export function calculateReadingTime(content: string): number {
  const wordsPerMinute = 300; // 中文阅读速度
  const wordCount = content.length / 2; // 粗略估算中文字数
  return Math.ceil(wordCount / wordsPerMinute);
}

⚙️ AI 配置最佳实践:

(1)Temperature 参数调优

temperature: 0.7  // 摘要生成:需要一定创造性
temperature: 0.5  // 标签推荐:需要稳定性
  • 0.0-0.3: 确定性输出,适合事实性问题
  • 0.4-0.7: 平衡创造性和准确性
  • 0.8-1.0: 高创造性,适合创意写作

(2)Prompt Engineering 技巧

prompt: `请为以下文章内容生成一段简洁的摘要(不超过 200 字)。
要求:
1. 突出核心观点
2. 语言精炼流畅
3. 吸引读者继续阅读`

有效 Prompt 的要素:

  • ✅ 明确任务目标
  • ✅ 列出具体要求
  • ✅ 提供示例(Few-shot Learning)
  • ✅ 限制输出格式

(3) 成本控制

const truncatedContent = content.substring(0, 2000);
  • 限制输入长度,减少 Token 消耗
  • 对于长文章,可以分段处理后合并
  • 考虑使用更便宜的模型(如 gpt-3.5-turbo)进行测试

3. 在创建文章时调用 AI

修改 createPost 函数:

// app/actions/post.ts
import { generateExcerpt, calculateReadingTime } from '@/lib/ai';

export async function createPost(data: CreatePostInput) {
  // ... 前面的验证逻辑 ...

  // 如果没有提供摘要,使用 AI 生成
  let excerpt = validated.data.excerpt;
  if (!excerpt) {
    excerpt = await generateExcerpt(validated.data.content);
  }

  // 计算阅读时间
  const readingTime = calculateReadingTime(validated.data.content);

  // 保存到数据库
  const post = await db.post.create({
    data: {
      // ... 其他字段 ...
      excerpt,
      readingTime,
    },
  });

  return { success: true, postId: post.id };
}

🎯 用户体验优化:

可以在前端显示"AI 生成中..."的加载状态:

// components/AIExcerptGenerator.tsx
'use client';

import { useState } from 'react';
import { generateExcerpt } from '@/app/actions/ai';

export function AIExcerptGenerator({ content }: { content: string }) {
  const [loading, setLoading] = useState(false);
  const [excerpt, setExcerpt] = useState('');

  const handleGenerate = async () => {
    setLoading(true);
    try {
      const result = await generateExcerpt(content);
      setExcerpt(result);
    } finally {
      setLoading(false);
    }
  };

  return (
    <div>
      <button onClick={handleGenerate} disabled={loading}>
        {loading ? 'AI 生成中...' : '✨ 自动生成摘要'}
      </button>
      {excerpt && <textarea value={excerpt} />}
    </div>
  );
}

七、💬 评论系统实现

1. 评论系统设计要点

评论系统是博客的社交核心,需要考虑:

  1. 嵌套回复: 支持楼中楼式讨论
  2. 审核机制: 防止垃圾评论
  3. 实时更新: 新评论即时显示
  4. 权限控制: 仅登录用户可评论

2. 创建评论 Action

创建 app/actions/comment.ts:

// app/actions/comment.ts
'use server';

import { auth } from '@/auth';
import { db } from '@/lib/db';
import { z } from 'zod';
import { revalidateTag } from 'next/cache';

const commentSchema = z.object({
  postId: z.string().min(1, '文章 ID 不能为空'),
  content: z.string()
    .min(1, '评论内容不能为空')
    .max(5000, '评论不能超过 5000 字符'),
  parentId: z.string().optional(), // 回复评论时填写
});

type CreateCommentInput = z.infer<typeof commentSchema>;

/**
 * 发表评论
 * 
 * @param data - 评论数据
 * @returns 创建结果
 */
export async function createComment(data: CreateCommentInput) {
  // 1. 身份验证
  const session = await auth();
  
  if (!session?.user) {
    return { 
      success: false, 
      error: '请先登录后再评论' 
    };
  }

  // 2. 数据验证
  const validated = commentSchema.safeParse(data);
  
  if (!validated.success) {
    return { 
      success: false, 
      error: '数据验证失败',
      details: validated.error.flatten().fieldErrors
    };
  }

  // 3. 检查文章是否存在
  const post = await db.post.findUnique({
    where: { id: validated.data.postId },
    select: { id: true, published: true },
  });

  if (!post || !post.published) {
    return { 
      success: false, 
      error: '文章不存在或未发布' 
    };
  }

  // 4. 如果是回复,检查父评论是否存在
  if (validated.data.parentId) {
    const parentComment = await db.comment.findUnique({
      where: { id: validated.data.parentId },
    });

    if (!parentComment) {
      return { 
        success: false, 
        error: '父评论不存在' 
      };
    }
  }

  try {
    // 5. 创建评论
    const comment = await db.comment.create({
      data: {
        content: validated.data.content,
        authorId: session.user.id!,
        postId: validated.data.postId,
        parentId: validated.data.parentId,
        approved: true, // 默认通过审核(可改为 false 启用审核)
      },
      include: {
        author: { 
          select: { id: true, name: true, image: true } 
        },
      },
    });

    // 6. 失效缓存
    revalidateTag(`post-${validated.data.postId}`);

    return { 
      success: true, 
      comment,
      message: '评论成功'
    };
  } catch (error) {
    console.error('Failed to create comment:', error);
    return { 
      success: false, 
      error: '评论失败,请稍后重试'
    };
  }
}

🔒 安全防护措施:

(1) 防 XSS 攻击

虽然我们在数据库中存储原始内容,但在渲染时需要转义:

// 使用 dangerouslySetInnerHTML 时要谨慎
<div dangerouslySetInnerHTML={{ __html: sanitize(comment.content) }} />

可以使用 dompurify 库清理 HTML:

npm install dompurify
npm install -D @types/dompurify

(2) 频率限制

防止用户刷评论:

// 检查用户最近 1 分钟内的评论次数
const recentComments = await db.comment.count({
  where: {
    authorId: session.user.id!,
    createdAt: {
      gte: new Date(Date.now() - 60 * 1000), // 1 分钟内
    },
  },
});

if (recentComments >= 5) {
  return { 
    success: false, 
    error: '评论过于频繁,请稍后再试' 
  };
}

(3) 敏感词过滤

const bannedWords = ['广告', '赌博', '色情'];

if (bannedWords.some(word => validated.data.content.includes(word))) {
  return { 
    success: false, 
    error: '评论包含不当内容' 
  };
}

八、👍 点赞与收藏功能

1. 为什么需要点赞和收藏?

  • 点赞: 量化文章受欢迎程度,激励作者
  • 收藏: 用户个人知识库,方便后续查阅
  • 数据分析: 了解用户偏好,优化内容策略

2. 切换点赞状态

创建 app/actions/interaction.ts:

// app/actions/interaction.ts
'use server';

import { auth } from '@/auth';
import { db } from '@/lib/db';
import { revalidateTag } from 'next/cache';

/**
 * 切换点赞状态(点赞/取消点赞)
 * 
 * @param postId - 文章 ID
 * @returns 操作结果
 */
export async function toggleLike(postId: string) {
  const session = await auth();
  
  if (!session?.user) {
    return { 
      success: false, 
      error: '请先登录' 
    };
  }

  // 检查是否已点赞
  const existing = await db.like.findUnique({
    where: {
      userId_postId: {
        userId: session.user.id!,
        postId,
      },
    },
  });

  try {
    if (existing) {
      // 取消点赞
      await db.like.delete({
        where: {
          userId_postId: {
            userId: session.user.id!,
            postId,
          },
        },
      });
      
      return { success: true, liked: false };
    } else {
      // 添加点赞
      await db.like.create({
        data: {
          userId: session.user.id!,
          postId,
        },
      });
      
      return { success: true, liked: true };
    }
  } catch (error) {
    console.error('Failed to toggle like:', error);
    return { 
      success: false, 
      error: '操作失败' 
    };
  } finally {
    // 无论成功与否,都失效缓存
    revalidateTag(`post-${postId}`);
  }
}

/**
 * 切换收藏状态
 * 
 * @param postId - 文章 ID
 * @returns 操作结果
 */
export async function toggleBookmark(postId: string) {
  const session = await auth();
  
  if (!session?.user) {
    return { 
      success: false, 
      error: '请先登录' 
    };
  }

  const existing = await db.bookmark.findUnique({
    where: {
      userId_postId: {
        userId: session.user.id!,
        postId,
      },
    },
  });

  try {
    if (existing) {
      await db.bookmark.delete({
        where: {
          userId_postId: {
            userId: session.user.id!,
            postId,
          },
        },
      });
      
      return { success: true, bookmarked: false };
    } else {
      await db.bookmark.create({
        data: {
          userId: session.user.id!,
          postId,
        },
      });
      
      return { success: true, bookmarked: true };
    }
  } catch (error) {
    console.error('Failed to toggle bookmark:', error);
    return { 
      success: false, 
      error: '操作失败' 
    };
  } finally {
    revalidateTag(`user-${session.user.id}`);
  }
}

💡 设计模式:Toggle Pattern

点赞/收藏这类功能是典型的 Toggle 模式:

  1. 检查当前状态
  2. 如果存在则删除,不存在则创建
  3. 返回新状态

这种模式的优点:

  • ✅ 幂等性:多次调用结果一致
  • ✅ 简化前端逻辑:无需分别实现"点赞"和"取消点赞"
  • ✅ 原子操作:避免竞态条件

九、📝 本章小结

通过实战项目上篇的学习,我们已经完成了博客系统的后端核心功能:

项目初始化: Next.js 15 + TypeScript + Tailwind CSS
数据库设计: Prisma Schema 建模,理解关系型数据结构
认证系统: Auth.js 集成 GitHub OAuth
文章 CRUD: Server Actions 实现数据突变
AI 集成: OpenAI 自动生成摘要和标签
评论系统: 嵌套评论 + 安全防护
互动功能: 点赞、收藏的 Toggle 模式

核心知识点回顾:

知识点 应用场景 关键代码
Server Actions 表单提交、数据突变 'use server'
Zod 验证 输入数据校验 z.object().parse()
React Cache 同请求内去重查询 cache(fn)
Revalidate Tag 缓存失效策略 revalidateTag()
Prisma Relations 多对多、自引用关系 @relation
AI Integration 智能摘要生成 generateText()

十、🚀 下篇预告

下篇中,我们将实现:

  1. 前端页面开发:

    • 首页文章列表
    • 文章详情页(MDX 渲染)
    • 登录/注册页面
    • 管理后台
  2. UI 组件实现:

    • Markdown 代码高亮
    • 评论组件(嵌套显示)
    • 点赞/收藏按钮(Optimistic UI)
  3. 性能优化:

    • 图片懒加载
    • 并行数据获取
    • 流式渲染
  4. 部署上线:

    • Vercel 部署
    • 环境变量配置
    • 域名绑定

敬请期待! 🎉


练习作业:

  1. 尝试添加"文章编辑"功能(提示:参考 createPost,使用 db.post.update)
  2. 实现"删除文章"功能,并处理级联删除
  3. 添加"草稿箱"功能(区分 published: true/false)
  4. 实现简单的全文搜索(使用 Prisma 的 contains 查询)

完成这些练习,你将真正掌握 Next.js 全栈开发的核心技能! 💪

HeyGen 开源了一个"用 HTML 写视频"的框架,我研究了一下,发现事情没那么简单

作者 AIReadingHub
2026年4月27日 01:30

HeyGen 最近开源了一个叫 Hyperframes 的项目,GitHub 上已经拿到 11.2k Star。

一句话描述:用 HTML 写视频,专门为 AI Agent 设计。

听起来好像没什么新鲜的——Remotion 不是早就能用代码生成视频了吗?但仔细研究之后,我发现 Hyperframes 做的事情比"换个语法"要深得多。它的真正野心是:让 AI 自己拍视频。

今天这篇文章,我会从三个层面拆解:

  1. Hyperframes 到底是什么,怎么用
  2. 和 Remotion 等竞品的核心差异
  3. 为什么说它可能代表了"程序化视频"的下一个范式

一、Hyperframes 是什么

Hyperframes 是 HeyGen(全球最大的 AI 数字人视频公司之一)开源的视频渲染框架。

核心逻辑非常直白:

你写一个 HTML 文件 → 框架用无头浏览器逐帧截图 → FFmpeg 编码成 MP4。

但它聪明的地方在于,它不只是一个"HTML 转视频"的工具,而是围绕 AI Agent 的工作流做了完整的设计。

技术栈一览

组件 技术
渲染引擎 Puppeteer(无头 Chrome)+ FFmpeg
动画系统 GSAP 为主,支持 Lottie、CSS、Three.js 等
CLI 工具 Node.js(需要 22+)
视频捕获 image2pipe 流式传输到 FFmpeg
特效系统 WebGL 着色器转场(@hyperframes/shader-transitions)
播放器 可嵌入的 <hyperframes-player> Web Component

架构设计

Hyperframes 拆成了 7 个包,各司其职:

  • hyperframes(CLI):项目脚手架、预览服务器、渲染编排
  • @hyperframes/core:类型定义、解析器、Linter、运行时
  • @hyperframes/engine:Puppeteer-FFmpeg 可寻址的捕获引擎
  • @hyperframes/producer:完整流水线:捕获 + 编码 + 音频混合
  • @hyperframes/studio:浏览器端的可视化编辑器
  • @hyperframes/player:可嵌入的播放组件
  • @hyperframes/shader-transitions:WebGL 转场特效

这个模块拆分有讲究——每一层都可以独立使用,你可以只用 engine 做帧捕获,也可以用完整的 producer 走全流程。

二、5 分钟上手教程

前置条件

  • Node.js 22+
  • FFmpeg 7.x+(brew install ffmpegapt install ffmpeg

方式一:AI Agent 驱动(推荐)

如果你用 Claude Code、Cursor 或 Gemini CLI:

npx skills add heygen-com/hyperframes

安装后你会获得几个 slash 命令:/hyperframes/hyperframes-cli/gsap

然后你直接用自然语言描述你要什么视频:

"用 /hyperframes 做一个 10 秒的产品介绍,暗色背景,标题渐入,配上背景音乐。"

AI 会自动帮你写 HTML 组合,生成视频。这才是 Hyperframes 真正想推的工作流。

方式二:手动创建

第 1 步:初始化项目

npx hyperframes init my-video
cd my-video

生成的目录结构:

my-video/
├── meta.json           # 项目元数据
├── index.html          # 根组合文件(入口)
├── compositions/       # 子组合
│   ├── intro.html
│   └── captions.html
└── assets/             # 媒体文件

第 2 步:预览

npx hyperframes preview

在浏览器中打开 Studio,支持热重载——改 HTML 就能实时看到效果。

第 3 步:编写视频组合

一个最简示例:

<div id="root" data-composition-id="my-video"
     data-start="0" data-width="1920" data-height="1080">

  <h1 id="title" class="clip"
      data-start="0" data-duration="5" data-track-index="0"
      style="font-size: 72px; color: white; text-align: center;
             position: absolute; top: 50%; left: 50%;
             transform: translate(-50%, -50%);">
    Hello, Hyperframes!
  </h1>

  <script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>

  <script>
    const tl = gsap.timeline({ paused: true });
    tl.from("#title", { opacity: 0, y: -50, duration: 1 }, 0);
    window.__timelines = window.__timelines || {};
    window.__timelines["my-video"] = tl;
  </script>
</div>

三条核心规则:

  1. 根元素必须有 data-composition-iddata-widthdata-height
  2. 有时间轴的元素需要 data-startdata-durationdata-track-indexclass="clip"
  3. GSAP 动画必须用 { paused: true } 创建,并注册到 window.__timelines

第 4 步:渲染输出

npx hyperframes render --output output.mp4
✔ Capturing frames... 150/150
✔ Encoding MP4...
✔ output.mp4 (1920×1080, 5.0s, 30fps)

一个更实际的示例

带视频素材、音频和叠加文字的组合:

<div id="stage" data-composition-id="product-demo"
     data-start="0" data-width="1920" data-height="1080">

  <video id="clip-1" data-start="0" data-duration="5"
         data-track-index="0" src="intro.mp4" muted playsinline>
  </video>

  <img id="overlay" class="clip" data-start="2"
       data-duration="3" data-track-index="1" src="logo.png" />

  <audio id="bg-music" data-start="0" data-duration="9"
         data-track-index="2" data-volume="0.5" src="music.wav">
  </audio>
</div>

视频、图片、音频,全部用 data 属性控制时间轴,就像在写网页一样。

组件市场:50+ 现成模块

Hyperframes 还提供了一个组件注册表,可以直接安装预制模块:

npx hyperframes add flash-through-white    # 闪白转场
npx hyperframes add instagram-follow       # 社交媒体关注弹窗
npx hyperframes add data-chart             # 动画数据图表

这些组件就像视频版的 npm 包,可以直接插入你的 composition 中使用。

三、竞品对比:Hyperframes 的真正优势在哪

核心竞品一览

工具 方式 开源 价格 定位
Hyperframes HTML/CSS/GSAP → 视频 Apache 2.0 完全免费 AI Agent 优先
Remotion React → 视频 源码可用(非开源) 个人免费,企业 $100-500/月 React 团队
Revideo TypeScript → 视频 MIT 免费 服务端批量渲染
Shotstack JSON API → 视频 $49-309/月 零运维云端
Creatomate 模板 + API → 视频 $41-249/月 营销自动化
FFCreator Node.js → 视频 MIT 免费 轻量级幻灯片

Hyperframes vs Remotion:核心差异

这是最常被拿来比较的一组,也是理解 Hyperframes 定位的关键。

维度 Hyperframes Remotion
编写语言 HTML + CSS + GSAP React/TSX
构建步骤 无——HTML 文件直接跑 需要打包工具
动画精度 逐帧可寻址,帧级精确 GSAP 按真实时间播放,会"跑偏"
HTML 复用 直接粘贴复用 必须改写成 JSX
分布式渲染 暂不支持 Lambda 已成熟
HDR 支持 不支持
协议 Apache 2.0(真开源) 商用需付费

重点说一下动画精度的问题。

这是 Hyperframes 技术层面最硬的差异化。

GSAP 是业界最流行的 Web 动画库,但它的时间系统基于 performance.now()——也就是说动画按"墙钟时间"运行。在 Remotion 的渲染模式下,GSAP 会把 4 秒的动画在 1 秒内全部播完,后面的帧全是空的。

Hyperframes 的解决方案是:暂停动画,然后精确跳到每一帧对应的时间点截图。这意味着 GSAP、Anime.js、Motion One 等动画库的效果在 Hyperframes 里是帧级精确的。

这个技术点不容易被感知,但它直接决定了最终视频的动画质量。

许可证:真正的开源 vs "源码可见"

Remotion 的代码虽然在 GitHub 上公开,但它用的是自定义商业许可(BSL),不是开源协议。超过 3 人的团队商用必须付费。

Hyperframes 用的是 Apache 2.0——真正的 OSI 认证开源协议。没有渲染次数限制,没有座位数上限,没有公司规模门槛。可以自由商用、修改、再分发。

对于创业团队和高频视频生产场景来说,这不是细节差异,而是成本模型的根本不同。

Hyperframes 的短板

说优势也要说不足,目前 Hyperframes 的几个明显短板:

  1. 没有分布式渲染:Remotion 有 Lambda 级别的云端渲染方案,适合大规模视频工厂。Hyperframes 目前只能单机渲染,这是生产环境中最大的瓶颈。

  2. 项目还年轻:v0.4.x 阶段,迭代很快但也意味着 API 可能还在变化。Remotion 已经经过多年生产验证。

  3. 平台要求较窄:需要 Node.js 22+,确定性渲染需要 Linux + Chrome Headless Shell,macOS/Windows 走降级方案。

  4. 没有类似 Remotion Player 的交互式播放器:不能在 React 应用中嵌入交互式视频预览。

四、为什么说它代表了下一个范式

Hyperframes 的核心洞察不在技术层面,而在用户层面

传统的程序化视频工具,用户是开发者。Remotion 假设你会 React,Shotstack 假设你能写 JSON Schema。

Hyperframes 假设的用户是 AI Agent

这个区别很关键。LLM 最擅长生成什么代码?HTML + CSS。全球互联网上最多的训练数据就是网页代码。让 AI 用 React 写视频,等于加了一层不必要的认知负担。让 AI 用 HTML 写视频,则是在它最擅长的领域发挥。

所以 Hyperframes 的 Skill 系统不是锦上添花,而是核心设计:

npx skills add heygen-com/hyperframes

安装后,Claude Code / Cursor / Gemini CLI 就能直接"拍视频"。你说"做一个 TikTok 风格的产品演示",AI 就生成 HTML composition + GSAP 动画 + 音频混合,一键渲染成 MP4。

这不再是"开发者写代码生成视频",而是"AI Agent 自主生产视频内容"。

HeyGen 自己就是这套框架的最大用户——他们内部之前用 Remotion,但发现 React 的框架开销和 GSAP 的时间精度问题在 Agent 驱动的工作流中太痛了,所以重新造了轮子。

五、实操建议:什么场景该用 Hyperframes

根据我的分析,给大家一个选型参考:

适合用 Hyperframes 的场景:

  • 你已经在用 Claude Code / Cursor 等 AI 编码工具,想让 AI 直接帮你做视频
  • 你需要大量用 HTML/CSS 做动画的经验(比如 Web 开发者转做视频)
  • 你的视频生产不需要大规模分布式渲染(几十条/天以内)
  • 你不想被商业许可证绑定,需要完全自由的开源方案
  • 你想把 GSAP 动画精确还原到视频中

更适合用 Remotion 的场景:

  • 团队是 React 技术栈,有成熟的 React 组件库
  • 需要 Lambda 级别的分布式云端渲染(日产千条以上)
  • 需要在 Web 应用中嵌入交互式视频预览
  • 看重成熟度和稳定性,不想踩新项目的坑

更适合用 SaaS 平台(Shotstack / Creatomate)的场景:

  • 不想维护任何基础设施
  • 视频内容是模板化的(广告、社交媒体批量生产)
  • 团队没有前端开发能力

六、快速上手 Cheat Sheet

# 安装(AI Agent 方式)
npx skills add heygen-com/hyperframes

# 初始化项目
npx hyperframes init my-video && cd my-video

# 预览
npx hyperframes preview

# 检查组合文件是否有问题
npx hyperframes lint

# 渲染为 MP4
npx hyperframes render --output video.mp4

# 从视频中提取字幕
npx hyperframes transcribe input.mp4

# 生成 TTS 语音
npx hyperframes tts "要说的文字" --output speech.wav

# 添加预制组件
npx hyperframes add data-chart
npx hyperframes add flash-through-white

# 系统环境检查
npx hyperframes doctor

七、我的判断

Hyperframes 不是"又一个视频生成工具"。它的出现代表了一个信号:视频制作的门槛正在从"会剪辑"降到"会说话"。

当 AI Agent 能用 HTML 直接"写"出视频时,视频内容的供给端会发生根本性的变化。以前一条精编视频需要编导+拍摄+剪辑+后期,未来可能一句 prompt 就搞定了。

当然,目前 Hyperframes 还有明显的不成熟之处——没有分布式渲染、项目还在快速迭代、生态还没建立起来。但方向是对的。

如果你是做视频自动化、内容工厂或 AI 应用的,我建议现在就开始关注甚至上手试试。Apache 2.0 的许可证意味着你可以没有任何顾虑地用在商业项目中。

这可能是目前"让 AI 自己做视频"最优雅的方案。


项目地址:github.com/heygen-com/hyperframes

文档地址:hyperframes.heygen.com

React Diff算法:3个“神级假设”让虚拟DOM快得像闪电

作者 kyriewen
2026年4月26日 23:56

前言

假设你有两棵各有1000个节点的树,传统树对比算法需要十亿级别的操作(O(n³))。那根本不可能用在浏览器里——一更新就死机。React团队发现,在实际Web应用中,树的变化符合一些规律,于是他们大胆做了3个假设,把复杂度降到了线性(O(n))。虽然有些场景会误判,但在99%的情况下,它准得吓人还快得离谱。

今天我们就来揭开这3个“神级假设”,以及React是怎么基于它们对比DOM的。

一、3个假设:React的“赌注”

  1. 同层对比:两个不同类型的元素会产生不同的树。
    比如 <div> 变成 <span>,React会直接销毁旧子树,重建新子树,不会浪费时间去比较子节点。
  2. 唯一标识:开发者可以通过 key 属性告诉React哪些子元素是稳定的。
    比如列表顺序变化时,有key就能识别“这个li还是那个li”,只是挪了个位置。
  3. 同级子节点只在该层比较:不会跨层级移动节点。
    如果某个节点从子节点变成了父节点的兄弟,React会销毁重建,而不是复用。

基于这些假设,React设计出了基于广度优先遍历的Diff算法。

二、节点类型不同:直接“拆房重建”

如果旧树是 <div>,新树是 <span>,React压根不看子节点,直接删掉旧节点及其所有子节点,重新创建 <span> 及子节点。

// 旧
<div><Counter /></div>
// 新
<span><Counter /></span>

即使 <Counter /> 是一样的,整个组件也会被卸载再重新挂载,Counter 的state会丢失,生命周期重新走一遍。

所以尽量保持DOM类型稳定,比如别把 <div> 随意改成 <section>

三、同一类型节点:保留DOM,只更新属性和子节点

如果新旧节点类型相同(比如都是 <div>),React会保留该节点的DOM元素,然后对比属性,更新改变的属性。接着递归对比子节点。

// 旧:<div className="old" title="tip">hello</div>
// 新:<div className="new" title="tip">world</div>

React保留 <div>,把 className"old" 改为 "new",然后对比文本子节点,把 "hello" 改成 "world"

这时子节点的对比就进入“列表对比”阶段。

四、列表对比:没有key VS 有key

这是Diff最精彩的部分。

没有key时:React的“暴力”

假设子节点都是同一类型,但顺序变化。没有key,React只能逐个比较位置。

// 旧:A - B - C
// 新:C - A - B

React的做法:

  1. 旧第一个A,新第一个C:不同,更新A为C。
  2. 旧第二个B,新第二个A:不同,更新B为A。
  3. 旧第三个C,新第三个B:不同,更新C为B。 最终结果正确,但进行了3次更新操作。实际上只需要把C移到最前面就能复用A、B。这就是没有key的低效。

有key时:移动、插入、删除三步走

给每个子节点加唯一key,React就能追踪节点的身份。

// 旧:key=A - key=B - key=C
// 新:key=C - key=A - key=B

React会构建一个“旧节点键值映射”,然后遍历新列表:

  • 新第一个C,在旧里有,且位置变了,标记为“移动”。
  • 新第二个A,旧里有,标记为“移动”。
  • 新第三个B,旧里有,标记为“移动”。 最后React只做一次移动操作(将C移到最前),其余复用。性能大大提升。

注意:千万不要用 index 作为key!因为列表顺序变化时,index也会变,React会误判,导致性能退化和组件状态错乱。

五、跨层级移动:React无能为力

由于第3个假设“不同层级不比较移动”,如果你把一个子节点从父节点内移动到另一个父节点下,React会直接卸载重建,而不是复用。

// 旧
<div>
  <span>hello</span>
</div>
// 新
<span>hello</span>

React会把 <span><div> 下删掉,再重新创建到新位置。虽然有点浪费,但这样可以保持算法简单快速。

六、递归Diff与性能优化

整个Diff过程是递归的:从根开始,深度优先遍历,同级对比子节点。由于假设了同层对比,整个递归树的大小就是原树的大小,复杂度O(n)。

配合 shouldComponentUpdateReact.memo 可以跳过整棵子树的Diff,进一步提升性能。

七、总结:Diff算法的“三板斧”

  • 类型不同:删了重建。
  • 类型相同:保留DOM,更新属性和子节点。
  • 子节点列表:靠key识别身份,移动/增删。

这三条简单规则,让React在大多数场景下既快又准。理解Diff,你就能写出更高效的组件:给列表加稳定key,避免不必要的DOM类型改变,用 memo 跳过无意义的更新。

现在你知道为什么map时要加key,为什么不能随意把div改成span,为什么index做key会出问题了吧?

零基础教你claude code 接入 deepseek V4

作者 偶像佳沛
2026年4月26日 23:53

省钱利器:Claude Code + DeepSeek V4,月花费不到100元的AI编程神器

本文手把手教你用 DeepSeek V4 API 接入 Claude Code CLI,实现一个低成本、高质量的 AI 编程助手。全程可复刻,踩坑经验全收录。


前言:为什么我要折腾这个?

作为一名追求性价比的开发者,日常工作离不开 AI 编程助手。

但市面上主流的 AI 编程工具要么订阅费不便宜,要么需要海外信用卡充值,对国内开发者来说多少有点门槛。

于是我开始寻找替代方案,目标很明确:

  • 便宜,最好月花费控制在 100 元以内
  • 好用,代码补全、Bug 分析、架构设计都能搞定
  • 门槛低,国内手机号注册,支付宝充值,不需要折腾虚拟信用卡

研究了一圈之后,我发现了一个黄金组合:Claude Code CLI + DeepSeek V4 API

Claude Code 是 Anthropic 出品的命令行 AI 编程工具,本身需要 Anthropic 的 API Key(海外信用卡才能充值)。但 DeepSeek 提供了一个与 Anthropic API 完全兼容的接口,只需要改一下配置文件,Claude Code 就能无缝使用 DeepSeek 的模型——而 DeepSeek 支持国内手机号注册 + 支付宝充值,价格还便宜到离谱。

折腾了一晚上,踩了不少坑,最终跑通了。这篇文章就是我的完整记录,希望能帮到和我一样追求性价比的开发者。


费用对比:到底能省多少钱?

先上大家最关心的——

主流方案 vs Claude Code + DeepSeek V4

对比项 主流AI编程工具(订阅制) Claude Code + DeepSeek V4
月费用 $100~200 / 月(订阅制) ¥50 ~ 200 / 月(按量付费)
注册门槛 通常需要海外账号 国内手机号即可
充值方式 虚拟信用卡 支付宝直接充
模型能力 较强(DeepSeek V4 还不错)
使用场景 IDE 内集成 命令行交互,灵活度更高

DeepSeek V4 最新定价(2026年4月)

模型 输入价格 输出价格 适用场景
V4-Flash ¥1/百万token(缓存命中¥0.2) ¥2/百万token 日常编码、简单问答
V4-Pro ¥4/百万token(缓存命中¥1) ¥24/百万token 复杂推理、架构设计

说真的,这个价格对追求性价比的开发者太友好了。日常写代码一天大概消耗几万 token,算下来一天也就几毛钱。即使重度使用,一个月也很难超过 50 块。


前置环境准备

在开始之前,确保你的电脑满足以下条件:

项目 版本要求 说明
操作系统 Windows 10/11 其他系统理论兼容
Shell PowerShell 注意: 不支持 &&,用分号 ; 分隔命令
Node.js ≥ 18.18(推荐 v24.x) 这个版本要求很重要,后面会讲坑
npm 随 Node.js 安装即可 不需要单独装

验证环境

node --version
# 期望输出: v24.14.0 或更高

npm --version
# 期望输出: 11.9.0 或更高

如果没装 Node.js,去官网下载 LTS 版本:nodejs.org


踩坑记录一:Node.js 版本问题(重要!)

这是我踩的第一个大坑,必须提前说。

Claude Code 要求 Node.js 版本 ≥ 18.18,低于这个版本会直接启动失败。

但问题来了——你手头的项目很可能用的是 Node.js 14.x 或 16.x,直接升级 Node 版本可能会导致现有项目跑不起来。

解决方案:用 nvm 管理多版本 Node.js

# 安装高版本 Node.js
nvm install 24.14.0

# 需要用 Claude Code 时切换到高版本
nvm use 24.14.0

# 回去写其他项目时切换回低版本
nvm use 14.21.3

小技巧:可以开两个终端,一个用高版本跑 Claude Code,另一个用低版本跑其他项目,互不影响。

注意: nvm 切换 Node 版本后,之前版本安装的全局 npm 包不会自动继承。所以切换到新版本后需要重新 npm install -g @anthropic-ai/claude-code


第一步:安装 Claude Code CLI

环境没问题之后,一行命令搞定安装:

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

验证安装是否成功:

claude --version
# 输出类似: 2.1.119 (Claude Code) 即为成功

第二步:获取 DeepSeek API Key

2.1 注册账号

  1. 打开 DeepSeek 开放平台:platform.deepseek.com
  2. 使用国内手机号注册(无需任何海外账号)

2.2 充值余额

  1. 登录后进入控制台
  2. 选择支付宝充值(先充个 10 块钱足够用很久了)

2.3 创建 API Key

  1. 进入 API Keys 页面
  2. 点击 创建 API Key
  3. 复制生成的 Key,格式为:sk-xxxxxxxxxxxx

API Key 只在创建时显示一次! 请立即复制保存到安全的地方,丢失了只能重新创建。


第三步:配置 Claude Code 接入 DeepSeek

这是最核心的一步。

3.1 原理简述

DeepSeek 提供了一个与 Anthropic API 完全兼容的接口地址:

https://api.deepseek.com/anthropic

我们只需要修改 Claude Code 的配置文件,把 API 请求地址指向 DeepSeek,Claude Code 就会"以为"自己在调用 Anthropic 的 API,实际上请求都发到了 DeepSeek。

  • 不需要任何中间代理或适配层
  • 完整支持流式输出、函数调用等核心特性

3.2 创建配置目录

if (-not (Test-Path "$env:USERPROFILE\.claude")) { New-Item -ItemType Directory -Path "$env:USERPROFILE\.claude" -Force }

3.3 写入配置文件

请将 sk-你的DeepSeek API Key 替换为你在上一步获取的真实 API Key!

$content = @'
{
  "env": {
    "ANTHROPIC_AUTH_TOKEN": "sk-你的DeepSeek API Key",
    "ANTHROPIC_BASE_URL": "https://api.deepseek.com/anthropic",
    "ANTHROPIC_MODEL": "deepseek-v4-pro",
    "ANTHROPIC_DEFAULT_OPUS_MODEL": "deepseek-v4-pro",
    "ANTHROPIC_DEFAULT_SONNET_MODEL": "deepseek-v4-pro",
    "ANTHROPIC_DEFAULT_HAIKU_MODEL": "deepseek-v4-flash",
    "CLAUDE_CODE_SUBAGENT_MODEL": "deepseek-v4-flash",
    "CLAUDE_CODE_MAX_OUTPUT_TOKENS": "32000"
  },
  "permissions": {
    "allow": [],
    "deny": []
  }
}
'@
Set-Content -Path "$env:USERPROFILE\.claude\settings.json" -Value $content -Encoding UTF8

3.4 验证配置写入成功

Get-Content "$env:USERPROFILE\.claude\settings.json"

确认输出内容包含你的 API Key 和 DeepSeek 的 Base URL 即可。


配置参数详解

这里解释一下每个参数的作用,方便你根据自己的需求调整:

参数 说明
ANTHROPIC_AUTH_TOKEN sk-你的Key DeepSeek API Key,身份认证用
ANTHROPIC_BASE_URL https://api.deepseek.com/anthropic 关键! DeepSeek 的 Anthropic 兼容接口
ANTHROPIC_MODEL deepseek-v4-pro 默认使用的模型
ANTHROPIC_DEFAULT_OPUS_MODEL deepseek-v4-pro Opus 级别任务的模型
ANTHROPIC_DEFAULT_SONNET_MODEL deepseek-v4-pro Sonnet 级别任务的模型(主力模型
ANTHROPIC_DEFAULT_HAIKU_MODEL deepseek-v4-flash Haiku 级别任务的模型(轻量快速)
CLAUDE_CODE_SUBAGENT_MODEL deepseek-v4-flash 子代理使用的模型
CLAUDE_CODE_MAX_OUTPUT_TOKENS 32000 单次回复最大 Token 数

踩坑记录二:模型路由机制(必看!)

这个坑我研究了很久才搞明白。

Claude Code 内部有一个模型路由机制:它会根据任务复杂度自动选择使用 Opus、Sonnet 还是 Haiku 级别的模型。

关键发现:大约 90% 的任务都走 Sonnet 级别,Opus 几乎不会自动触发。

这意味着什么?你配在 ANTHROPIC_DEFAULT_SONNET_MODEL 的模型才是真正的主力模型!

所以我的策略是:

  • Sonnet 位 → deepseek-v4-pro:主力模型,承担绝大部分编码任务
  • Opus 位 → deepseek-v4-pro:少数复杂任务也用 Pro,保证质量
  • Haiku 位 → deepseek-v4-flash:简单任务用 Flash,省钱

踩坑记录三:模型名称变更(别踩旧坑!)

如果你在网上搜到的教程还在用 deepseek-chatdeepseek-reasoner注意了,这些是旧模型名!

旧名称(即将下线) 新名称(推荐使用) 下线时间
deepseek-chat deepseek-v4-pro / deepseek-v4-flash 2026年7月24日
deepseek-reasoner deepseek-v4-pro 2026年7月24日

注意: 旧模型名将在 2026年7月24日 正式下线,之后使用旧名称会直接报错。建议现在就用新名称,避免到时候突然不能用。


踩坑记录四:Windows PowerShell 的 && 问题

这个坑比较小但很烦人。

如果你习惯在 Linux/Mac 上用 && 连接命令,在 Windows PowerShell 里会直接报错:

# 这样会报错
cd C:\my-project && claude

# 正确写法:用分号分隔
cd C:\my-project; claude

第四步:启动 Claude Code

配置完成,激动人心的时刻到了!

claude

首次启动引导

首次启动会出现几个引导步骤:

  1. 选择主题 → 推荐选择 Dark mode(程序员标配)
  2. 信任工作目录 → 选择 Yes, I trust this folder
  3. 完成后进入交互式命令行界面

验证接入成功

在交互界面中随便输入一个问题:

你好,请介绍一下你自己

如果能正常回复,恭喜你,DeepSeek API 已成功接入!

如果报错,排查清单:

  1. API Key 是否正确粘贴(注意有没有多余空格)
  2. DeepSeek 账户余额是否充足
  3. 网络是否正常(试试 ping api.deepseek.com
  4. 配置文件路径是否正确:$env:USERPROFILE\.claude\settings.json

安全提醒

配置文件里包含了你的 API Key,千万不要上传到公开的 Git 仓库!

在项目的 .gitignore 中加上:

.claude/

完整命令速查表

如果你不想看前面的解释,直接按下面的命令一步步执行就行:

# 1. 安装 Claude Code CLI
npm install -g @anthropic-ai/claude-code

# 2. 验证安装
claude --version

# 3. 创建配置目录
if (-not (Test-Path "$env:USERPROFILE\.claude")) { New-Item -ItemType Directory -Path "$env:USERPROFILE\.claude" -Force }

# 4. 写入配置文件(替换 sk-你的DeepSeek API Key 为真实 Key)
$content = @'
{
  "env": {
    "ANTHROPIC_AUTH_TOKEN": "sk-你的DeepSeek API Key",
    "ANTHROPIC_BASE_URL": "https://api.deepseek.com/anthropic",
    "ANTHROPIC_MODEL": "deepseek-v4-pro",
    "ANTHROPIC_DEFAULT_OPUS_MODEL": "deepseek-v4-pro",
    "ANTHROPIC_DEFAULT_SONNET_MODEL": "deepseek-v4-pro",
    "ANTHROPIC_DEFAULT_HAIKU_MODEL": "deepseek-v4-flash",
    "CLAUDE_CODE_SUBAGENT_MODEL": "deepseek-v4-flash",
    "CLAUDE_CODE_MAX_OUTPUT_TOKENS": "32000"
  },
  "permissions": {
    "allow": [],
    "deny": []
  }
}
'@
Set-Content -Path "$env:USERPROFILE\.claude\settings.json" -Value $content -Encoding UTF8

# 5. 验证配置
Get-Content "$env:USERPROFILE\.claude\settings.json"

# 6. 启动
claude

总结

优点 缺点
月花费极低 命令行交互,没有 GUI(习惯就好)
国内手机号注册,支付宝充值 需要一点配置门槛
DeepSeek V4 模型能力够强 偶尔响应速度不如原生 API
按量付费,不用不花钱 需要 Node.js ≥ 18.18
配置一次,永久使用

对于追求性价比的开发者来说,这个方案真的是目前最优解。DeepSeek V4 的代码能力不弱,日常写业务代码、Debug、甚至做架构分析都完全够用。

React 核心技术深度笔记

2026年4月26日 23:04

React 核心技术深度笔记

一、非交互性更新的优先级

1.1 理解更新优先级

React 18 引入了优先级调度系统,不同类型的更新有不同的优先级:

优先级 类型 说明
Immediate 同步更新 需要立即执行,不能中断
UserBlocking 用户交互 点击、输入等,需要快速响应
Normal 普通更新 数据获取后的更新
Low 低优先级 非关键更新,如日志上报
Idle 空闲时执行 可延迟到浏览器空闲时

1.2 非交互性更新的处理

场景示例

import { startTransition, useEffect } from 'react';

function SearchResults({ query }) {
  const [results, setResults] = useState([]);
  
  useEffect(() => {
    // 低优先级更新:不会阻塞用户交互
    startTransition(() => {
      fetchResults(query).then(data => {
        setResults(data);
      });
    });
  }, [query]);
  
  return <ResultsList items={results} />;
}

使用 useTransition 钩子

import { useTransition } from 'react';

function App() {
  const [isPending, startTransition] = useTransition();
  
  const handleSearch = (query) => {
    // 标记为过渡更新
    startTransition(() => {
      setSearchQuery(query);
    });
  };
  
  return (
    <>
      <SearchInput onChange={handleSearch} />
      {isPending && <LoadingIndicator />}
      <Results query={searchQuery} />
    </>
  );
}

二、Server Components(RSC)深度解析

2.1 什么是 Server Components

定义:在服务端运行的 React 组件,无需发送到客户端

核心优势

  • 📦 减少 JS 体积:不包含在客户端 bundle 中
  • 🚀 数据获取更高效:直接在服务端访问数据库
  • 🎯 SEO 友好:服务端渲染完整 HTML

2.2 RSC vs SSR vs Client Components

特性 Server Components SSR Client Components
运行位置 服务端 服务端渲染,客户端交互 客户端
可访问 数据库、文件系统 仅通过 API 浏览器 API
JS 体积 0KB 需要 hydration 包含在 bundle
交互能力 需要 hydration 完全支持

2.3 RSC 实践

创建 Server Component

// app/PostList.server.jsx
async function PostList() {
  // 直接在服务端获取数据
  const posts = await db.posts.findMany({ 
    orderBy: { createdAt: 'desc' },
    take: 10 
  });
  
  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>
          <h3>{post.title}</h3>
          <p>{post.excerpt}</p>
        </li>
      ))}
    </ul>
  );
}

混合使用 Server/Client Components

// app/Blog.jsx
import PostList from './PostList.server';
import CommentSection from './CommentSection.client';

function Blog({ postId }) {
  return (
    <div>
      {/* Server Component:渲染内容 */}
      <PostList />
      
      {/* Client Component:处理交互 */}
      <CommentSection postId={postId} />
    </div>
  );
}

2.4 RSC 数据流模式

┌─────────────────────────────────────────────────────────────┐
│                    Server Side                              │
├─────────────────────────────────────────────────────────────┤
│  Server Components                                          │
│  ├─ 获取数据(DB/API)                                      │
│  ├─ 渲染为特殊格式(React Server Component Payload)         │
│  └─ 发送到客户端                                             │
└─────────────────────────────────────────────────────────────┘
                            │
                            ▼
┌─────────────────────────────────────────────────────────────┐
│                    Client Side                              │
├─────────────────────────────────────────────────────────────┤
│  Client Components                                          │
│  ├─ 接收 Server Component 的渲染结果                        │
│  ├─ 进行 hydration(如果需要)                              │
│  └─ 处理用户交互                                            │
└─────────────────────────────────────────────────────────────┘

2.5 RSC 适用场景

内容展示组件:博客文章、产品列表、静态页面
数据密集型组件:仪表盘、报表
SEO 关键页面:首页、落地页

高交互组件:表单、实时编辑器
需要浏览器 API:DOM 操作、事件监听


三、React.memo 完全指南

3.1 什么是 React.memo

定义:高阶组件(HOC),用于对组件输出进行浅比较,避免不必要的重渲染

工作原理

// 伪代码展示 React.memo 的工作方式
function memo(Component) {
  return function MemoizedComponent(props) {
    // 比较前后 props
    if (propsChanged(prevProps, currentProps)) {
      return <Component {...props} />;
    }
    // 返回缓存的结果
    return cachedResult;
  };
}

3.2 使用场景

场景一:纯展示组件

const UserCard = React.memo(function UserCard({ user }) {
  return (
    <div className="card">
      <img src={user.avatar} alt={user.name} />
      <h3>{user.name}</h3>
      <p>{user.bio}</p>
    </div>
  );
});

场景二:列表项组件

const TodoItem = React.memo(function TodoItem({ todo, onToggle }) {
  return (
    <div onClick={() => onToggle(todo.id)}>
      <input 
        type="checkbox" 
        checked={todo.completed} 
        readOnly 
      />
      <span className={todo.completed ? 'completed' : ''}>
        {todo.text}
      </span>
    </div>
  );
});

3.3 自定义比较函数

深度比较场景

const DeepMemoComponent = React.memo(
  MyComponent,
  (prevProps, nextProps) => {
    // 自定义比较逻辑
    return (
      prevProps.id === nextProps.id &&
      prevProps.data.value === nextProps.data.value
    );
  }
);

3.4 React.memo 的性能考量

什么时候使用

  • 组件渲染成本高(复杂计算、大量子组件)
  • 组件频繁接收相同 props
  • 在大型列表中使用

什么时候不使用

  • 简单组件(比较成本 > 渲染成本)
  • props 频繁变化
  • 组件内部有 useState/useContext

3.5 React.memo vs useMemo

特性 React.memo useMemo
作用范围 组件级 值/计算结果
比较方式 浅比较 props 比较依赖数组
返回值 新组件 缓存的值
使用方式 包装组件 包装计算表达式
// React.memo - 缓存组件渲染
const MemoizedList = React.memo(List);

// useMemo - 缓存计算结果
const expensiveValue = useMemo(() => {
  return computeExpensiveData(data);
}, [data]);

四、组件设计原则

4.1 单一职责原则

定义:一个组件应该只负责一件事情

反例

// ❌ 违反单一职责:同时处理数据获取和UI渲染
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  
  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => setUser(data));
  }, [userId]);
  
  if (!user) return <Loading />;
  
  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
      {/* ... 更多UI */}
    </div>
  );
}

正例

// ✅ 数据获取逻辑
function useUser(userId) {
  const [user, setUser] = useState(null);
  
  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => setUser(data));
  }, [userId]);
  
  return user;
}

// ✅ 纯UI组件
function UserProfile({ user }) {
  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  );
}

// ✅ 使用
function ProfilePage({ userId }) {
  const user = useUser(userId);
  if (!user) return <Loading />;
  return <UserProfile user={user} />;
}

4.2 可复用性原则

设计通用、可配置的组件

示例:通用 Button 组件

const Button = ({ 
  children, 
  variant = 'primary', 
  size = 'md',
  disabled = false,
  onClick,
  className = '',
  ...props 
}) => {
  const baseStyles = 'px-4 py-2 rounded font-medium transition-all';
  
  const variants = {
    primary: 'bg-blue-500 text-white hover:bg-blue-600',
    secondary: 'bg-gray-200 text-gray-800 hover:bg-gray-300',
    danger: 'bg-red-500 text-white hover:bg-red-600',
  };
  
  const sizes = {
    sm: 'text-sm px-3 py-1',
    md: 'text-base',
    lg: 'text-lg px-6 py-3',
  };
  
  return (
    <button
      disabled={disabled}
      onClick={onClick}
      className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`}
      {...props}
    >
      {children}
    </button>
  );
};

// 使用
<Button>默认按钮</Button>
<Button variant="secondary" size="sm">次要按钮</Button>
<Button variant="danger" disabled>危险按钮</Button>

4.3 纯组件原则

定义:相同输入产生相同输出,无副作用

纯组件特征

  • 不修改传入的 props
  • 不依赖外部状态
  • 不产生副作用(如 API 调用、DOM 操作)
  • 相同输入始终返回相同输出

示例

// ✅ 纯组件
function Greeting({ name }) {
  return <h1>Hello, {name}!</h1>;
}

// ❌ 不纯组件
function BadGreeting({ name }) {
  // 副作用:直接修改 props
  name = name.toUpperCase();
  return <h1>Hello, {name}!</h1>;
}

五、React.lazy() 与代码分割

5.1 什么是代码分割

定义:将应用代码分割成多个小块,按需加载

核心价值

  • 📦 减少初始加载体积
  • ⚡ 提升首屏加载速度
  • 🚀 优化用户体验

5.2 React.lazy() 基础用法

import { lazy, Suspense } from 'react';

// 动态导入组件
const HeavyChart = lazy(() => import('./HeavyChart'));
const DataTable = lazy(() => import('./DataTable'));

function Dashboard() {
  return (
    <div>
      <h1>Dashboard</h1>
      <Suspense fallback={<Loading />}>
        <HeavyChart />
        <DataTable />
      </Suspense>
    </div>
  );
}

5.3 路由级代码分割

import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';

const Home = lazy(() => import('./Home'));
const About = lazy(() => import('./About'));
const Dashboard = lazy(() => import('./Dashboard'));

function App() {
  return (
    <BrowserRouter>
      <Suspense fallback={<LoadingScreen />}>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/about" element={<About />} />
          <Route path="/dashboard" element={<Dashboard />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

5.4 高级用法:命名导出

// 导出多个组件
export const Chart = () => <div>Chart</div>;
export const Table = () => <div>Table</div>;

// 导入指定组件
const { Chart } = await import('./components');
// 使用命名导出的 lazy 加载
const Chart = lazy(() => 
  import('./components').then(module => ({ default: module.Chart }))
);

5.5 React.lazy vs loadable-components

特性 React.lazy loadable-components
SSR 支持
加载状态 需要 Suspense 内置 loading 状态
错误处理 需要 Error Boundary 内置 error 状态
预加载

六、学习阶段指南

6.1 进阶阶段:核心技能

Hooks 精通

  • useState/useEffect/useContext/useReducer
  • useCallback/useMemo/useRef
  • 自定义 Hooks 设计模式

状态管理

  • Context API 深度应用
  • Redux Toolkit 或 Zustand
  • 状态规范化与选择器

路由

  • React Router 6.x 完整掌握
  • 嵌套路由、路由守卫
  • 动态路由与参数处理

项目结构

src/
├── components/          # 通用组件
│   ├── layout/          # 布局组件
│   └── ui/              # UI 原子组件
├── features/            # 功能模块
│   └── auth/            # 认证模块
│       ├── hooks/       # 自定义 hooks
│       ├── components/  # 模块组件
│       └── index.js     # 模块入口
├── hooks/               # 全局 hooks
├── utils/               # 工具函数
└── App.js

6.2 高级阶段:性能优化

渲染优化

  • React.memo、useMemo、useCallback
  • 虚拟列表(react-window/react-virtualized)
  • 状态提升与拆分

资源优化

  • 图片优化(WebP/AVIF)
  • 代码分割与懒加载
  • 资源预加载(preload/prefetch)

服务端渲染

  • Next.js App Router
  • Server Components
  • 数据获取策略(getServerSideProps、fetch)

性能监控

  • Web Vitals
  • React DevTools Profiler
  • 自定义性能指标

6.3 工程化阶段:企业级实践

TypeScript

  • 类型安全、泛型、类型体操
  • 类型定义文件(.d.ts)
  • ESLint + TypeScript 集成

测试体系

  • 单元测试(Jest + RTL)
  • 集成测试(Playwright)
  • 端到端测试

构建工具

  • Vite 配置与插件
  • Webpack 高级配置
  • 构建优化策略

CI/CD

  • GitHub Actions/GitLab CI
  • 自动化测试与部署
  • 环境配置与变量管理

七、最佳实践总结

7.1 性能优化优先级

  1. 架构层面:使用 Server Components 减少客户端 JS
  2. 组件层面:React.memo + useMemo/useCallback 优化渲染
  3. 打包层面:React.lazy + Suspense 实现按需加载
  4. 运行时层面:虚拟滚动、代码分割、资源优化

7.2 组件设计检查清单

  • 是否符合单一职责?
  • 是否可复用、可配置?
  • 是否为纯组件(无副作用)?
  • props 是否最小化、清晰?
  • 是否有合适的错误处理?

7.3 RSC 使用决策树

需要渲染内容吗?
    │
    ├─ 是 → 需要交互吗?
    │       │
    │       ├─ 是 → Client Component
    │       └─ 否 → Server Component
    │
    └─ 否 → 考虑是否需要该组件

这些笔记涵盖了你提到的所有主题,从基础概念到高级实践都有详细说明。建议根据自己的学习阶段,从进阶阶段开始逐步深入,重点关注 Server Components 和性能优化,这是当前 React 生态的核心竞争力所在。

Vue3 + Three.js 仓储数字孪生:按需渲染架构与五大核心功能复盘

2026年4月26日 22:22

🛠️ Vue3 + Three.js 仓储数字孪生:按需渲染架构与五大核心功能复盘

在重构企业级 3D 仓储数字孪生项目的过程中,我摒弃了原项目过度封装的插件机制,转而采用 Vue 3 Composition API 结合 Three.js 原生 API 进行开发。

为了追求极致的性能,我在这次重构中彻底去掉了传统的 requestAnimationFrame 死循环,采用了针对静态场景性能最佳的**“按需渲染(On-Demand Rendering)”**架构。本文将详细复盘我实现的五大核心功能,并给出对应的核心脱水代码。

💡 功能一:搭建纯粹的 3D 舞台与光影配置

需求目标:在浏览器中初始化 3D 画布,并引入相机、环境光与平行光,为后续的模型加载提供基础环境。

实现思路: 在 Vue 的 onMounted 钩子中,构建 Three.js 的核心对象。由于我们放弃了动画循环,在场景初始化完毕后,必须手动调用一次 renderer.render() 来“按下快门”拍下第一张照片。

核心代码

import * as THREE from 'three';

// 1. 场景与光影
const scene = new THREE.Scene();
scene.background = new THREE.Color('#2b2b2b');

// 添加环境光(提亮全局)与平行光(制造立体感)
scene.add(new THREE.AmbientLight(0xffffff, 0.8));
const dirLight = new THREE.DirectionalLight(0xffffff, 1.5);
dirLight.position.set(10, 20, 10);
scene.add(dirLight);

// 2. 相机配置
const camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000);
camera.position.set(0, 15, 25);

// 3. WebGL 渲染器
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(width, height);
renderer.setPixelRatio(window.devicePixelRatio);
containerRef.value.appendChild(renderer.domElement);

// 初始化完成后,手动渲染第一帧(极其重要,否则黑屏)
renderer.render(scene, camera);

🏭 功能二:工业级 GLB 模型的解析与加载

需求目标:将大厂工业级的 .glb 仓储模型加载到场景中展示,并解决 Meshopt 压缩网格的解析报错。

实现思路: 数字孪生的高精度模型往往使用 Meshoptimizer 进行压缩,以极大地减小网络传输体积。在实例化 GLTFLoader 后,必须强行注入 MeshoptDecoder。模型异步加载完成后,因为没有动画循环自动重绘,必须在回调函数里手动触发一次渲染

核心代码

import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.js';

const gltfLoader = new GLTFLoader();
// 核心:解决工业级模型的网格压缩报错
gltfLoader.setMeshoptDecoder(MeshoptDecoder); 

gltfLoader.load('/warehouse.glb', (gltf) => {
  scene.add(gltf.scene);
  
  // 模型加载并添加到场景后,必须手动刷新画面才能看到
  renderer.render(scene, camera);
  console.log('模型加载成功并已渲染!');
});

🎯 功能三:射线拾取、克隆高亮与信息标签

需求目标:鼠标点击 3D 屏幕,选中特定货架使其变红高亮,并在货架正上方弹出 HTML 信息标签。

实现思路: 利用 Raycaster 将鼠标二维坐标转换为 3D 射线检测碰撞。

  • 高亮去重:使用 material.clone() 剥离共享材质,防止“牵一发而动全身”。
  • 标签定位:使用 CSS2DRenderer 配合 THREE.Box3 计算货架的世界绝对最高点,规避模型复杂的内部层级带来的局部坐标偏移。
  • 按需更新:高亮和弹窗发生后,手动调用两者的 render 方法刷新画面。

核心代码

import { CSS2DRenderer, CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer.js';

// ... 提前初始化 labelRenderer 并挂载到 DOM ...
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();

const onMouseClick = (event) => {
  // 坐标转换与射线检测
  mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
  mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
  raycaster.setFromCamera(mouse, camera);
  const intersects = raycaster.intersectObjects(scene.children, true);

  if (intersects.length > 0) {
    const obj = intersects[0].object;
    
    // 1. 材质克隆与独立高亮
    obj.material = obj.material.clone(); 
    obj.material.color.set('#ff0000');   

    // 2. CSS2D 标签绝对坐标计算
    const div = document.createElement('div');
    div.textContent = `📍 ${obj.name}`;
    div.className = 'three-label'; 
    const label = new CSS2DObject(div);

    const box = new THREE.Box3().setFromObject(obj);
    const center = new THREE.Vector3();
    box.getCenter(center);
    label.position.set(center.x, box.max.y + 0.5, center.z);
    scene.add(label);

    // 3. 核心:状态改变后,手动更新 WebGL 和 CSS2D 画面
    renderer.render(scene, camera);
    labelRenderer.render(scene, camera);
  }
};

🕹️ 功能四:基于 Change 事件的视角操作(缩放与旋转)

需求目标:使用鼠标拖拽旋转、滚轮缩放查看模型细节,同时摒弃耗费 GPU 的全局动画循环

实现思路: 引入 OrbitControls 接管相机的操作。重要避坑:为了实现“按需渲染”,必须关闭控制器的阻尼效果(enableDamping = false),否则没有动画循环为其计算数学递减,拖拽会严重卡顿。随后,我们将画面刷新逻辑直接绑定在控制器的 change 事件上。

核心代码

import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';

const controls = new OrbitControls(camera, renderer.domElement);

// ⚠️ 大厂优化铁律:采用按需渲染时,必须关闭阻尼惯性
controls.enableDamping = false; 

// 监听用户的鼠标/触摸操作
controls.addEventListener('change', () => {
  // 只有当相机视角发生真实变化时,才“按需”冲洗照片
  renderer.render(scene, camera);
  
  if (labelRenderer) {
    labelRenderer.render(scene, camera);
  }
});

💥 功能五:包围盒控制(相机空气墙)防穿模

需求目标:防止用户在缩放或平移视角时,不小心把相机钻到地板下面,或者穿出仓库大楼外部导致画面穿帮。

实现思路: 不使用庞大的物理引擎,利用原生 THREE.Box3 定义一个安全的可视空间(空气墙)。借助上一步实现的 change 按需渲染机制,在每次视角变化准备渲染之前,使用 Vector3.clamp() 方法强制将相机的坐标钳制在安全盒子内部。

核心代码

// 1. 划定一个仓库的安全边界(空气墙 Box3)
// 假设仓库范围是 X(-50~50), Y(1~30), Z(-50~50)
const safeBounds = new THREE.Box3(
  new THREE.Vector3(-50, 1, -50), // Y轴最小为1,防止钻入地板
  new THREE.Vector3(50, 30, 50)   // 限制最大高度和边界
);

controls.addEventListener('change', () => {
  // 2. 碰撞钳制逻辑:一旦相机试图越出边界,强制将其拉回安全区域边缘
  camera.position.clamp(safeBounds.min, safeBounds.max);
  
  // 3. 渲染钳制后的合法画面
  renderer.render(scene, camera);
  if (labelRenderer) labelRenderer.render(scene, camera);
});

🚀 总结与架构沉淀

通过这次重构,我不仅掌握了从模型加载、射线拾取到碰撞检测的完整 3D 链路,更深刻体会到了**“按需渲染(On-Demand Rendering)”**在前端工程中的威力。

去除了全局的 requestAnimationFrame 后,当用户不操作页面时,GPU 占用率直接降为 0%。结合 Vue 3 优秀的响应式系统,这种极致轻量、彻底解耦的代码架构,才是现代化 Web 3D 项目应该追求的形态。

告别“死视角”——手把手给你的 3D 世界装上灵活相机

2026年4月26日 21:42

开篇提要

摘要:  Day 1 我们搭好了金三角,立方体自己转了起来。但有个硬伤——你只能从一个角度看它,像被钉在墙上的监控画面。

今天给相机“解绑”,让立方体跟着你的鼠标转:

  1. 鼠标光标交互——鼠标指哪,相机看哪,最直观的“眼神操控”
  2. 轨道控制器——拖拽、旋转、缩放,像玩手办一样自由
  3. 阻尼效果——带惯性的拖拽,手感丝滑不卡顿
  4. 坐标轴辅助器——红X绿Y蓝Z,转晕了也不怕迷路

1.鼠标光标交互

1.const cursor = { x: 0, y: 0 } 这里我们创建一个全局对象cursor,用于保存处理后的鼠标位置,初始值为(0,0),代表鼠标位于屏幕正中心。

2.window.addEventListener('mousemove', (e) => { console.log(e.clientX); }) 给整个浏览器窗口添加mousemove鼠标时间监听器,这个监听器可以监听当鼠标发生移动的时候,回调函数会被出发,事件对象e中包含了鼠标当前的位置信息。 在函数中打印console.log(e.clientX);,表示打印x轴的实时坐标。

鼠标 00_00_00-00_00_30.gif

我们可以看到目前我们设置的画布宽度为800 // sizes const sizes = { width: 800, height: 600 }

鼠标越往左越趋近于0,越往右越趋近800,鼠标移出画布比800还大。但是我们通常不这样做。

我们只需要用公式:当前x坐标的像素位置 / 画布总宽度。

结果范围:

当鼠标在最左侧时:0 / 800 = 0
当鼠标在最右侧时:800 / 800 = 1
当鼠标在中间时:400 / 800 = 0.5

为什么这么做?

因为在 Three.js 或 WebGL 开发中,直接使用像素坐标(如 0~800)通常不方便进行数学计算或映射到 3D 空间。因为我们需要进行数学计算让鼠标的坐标规范在0到1之间。这样我们就不用考虑分辨率,无论画布大小如何变化,(比如改成1920*1080),cursor.x始终保持在0到1之间,逻辑通用。

注意: 这里不再赘述y轴的坐标获取,只需要注意在y轴坐标的时候我们需要给公式取反

cursor.y = -(e.clientY / sizes.height - 0.5);

因为在屏幕坐标系中y轴向下为正,而three.js世界中是向上为正,如果没有没有给结果取反,这意味着当你鼠标向上移动(clientY 变小),cursor.y 会变小(负数方向);而在 Three.js 中,通常希望鼠标向上时物体或相机向上移动(正数方向),如果 cursor.y 为负,相机就会向下移动。这与直觉可能相反(通常鼠标上移,视角上移)。

这样我们就获得了坐标范围,接下来我们可以在tick函数中做点什么: 我们在tick函数中更新相机的位置,大家可以来猜想一下,我们应该移动哪个轴可以观察立方体?这很容易知道,是x轴和y轴,我们可以通过移动移动相机在x轴和y轴上的位置观察立方体,给相应的值乘3,来增加移动的幅度。

` // 更新相机位置

camera.position.x = cursor.x * 3;
camera.position.y = cursor.y * 3;

`

更新相机 00_00_00-00_00_30~1.gif

当我们更新相机后,需要让我们的相机注视着立方体,确保我们的相机以立方体为中心。只需要调用 // 注视立方体 camera.lookAt(mesh.position)

mesh.position 获取的是立方体在 3D 空间中的坐标向量,默认是(0, 0, 0)。

注视立方体 00_00_00-00_00_30.gif

可以看到效果很好。

虽然效果很好,但是我们看不到立方体的后面和上面以及下面,因此我们需要引入今天的第二个知识:

2.轨道控制器

2.1我们如何实例化轨道控制器呢?

实际上没那么简单,因为轨道控制器OrbitControls 不能像核心库(如 THREE.SceneTHREE.Mesh)那样直接从 'three'包中引入,主要原因是,核心库和扩展库分离:

  • 核心库 (three) : 只包含渲染 3D 场景最基础、最核心的功能(如场景、相机、几何体、材质、渲染器等)。保持核心库轻便是为了性能和通用性。
  • 扩展库 (three/examples/jsm/...)OrbitControls 属于“控件”或“辅助工具”,它不是渲染 3D 图形所必需的。因此,Three.js 团队将其放在 examples 目录下,作为附加模块提供。

我们项目中使用的是Three.js r150+ 版本推荐的新方式:

import { OrbitControls } from 'three/examples/jsm/Addons.js';

旧版本:

import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'

2.2创建轨道控制器

// 创建控制器 const controls = new THREE.OrbitControls(camera, canvas);

你需要给这个类2个值,一个是你的相机,因为轨道控制器要更新相机,另一个是DOM元素,是指页面中某个要作为参考的元素,以便我们把鼠标事件放在上面,就像你刷手机——手指必须在屏幕上滑动,屏幕才会跟着动。滑到手机边框上?没反应。轨道控制器的第二个参数 canvas 就是这个作用:告诉控制器“只有用户在这个画布范围内拖拽,相机才响应”,然而我们canvas,就是我们当前的画布。

此时我们可以进行移动鼠标让立方体旋转,按鼠标右键移动立方体位置,用滚轮来放大缩小。

控制器 00_00_00-00_00_30~1.gif

3.阻尼效果

虽然我们已经可以随意玩转我们的立方体了,但是在上图可以看到,我鼠标移动很快,它转的也很快,停止也很快,完全是鼠标移动方式,我们可以添加阻尼,让他看起来更加平滑自然。

小贴士: 阻尼就是给相机加上“惯性”。

就像你在超市推购物车——猛推一把然后松手:

  • 没有阻尼:购物车瞬间停下,像撞上了一堵墙(立方体转起来会很“愣”)
  • 有阻尼:购物车会继续滑一小段,慢慢停下(手感丝滑,有过度感)

加入阻尼很简单:

// 加入阻尼并开启 controls.enableDamping = true

并且在tick函数中更新控制器:

controls.update()

我们会得到这样的效果:

⚠️ 大坑提醒:开启阻尼后,必须在动画循环里每帧调用 controls.update() ,否则阻尼不生效。

阻尼 00_00_00-00_00_30.gif

4.坐标轴辅助器

3d世界的指南针,一句话解释:

坐标轴辅助器就是帮你分清“东西南北”的工具——红X、绿Y、蓝Z,一眼看懂三维方向。

  • 红色 = X轴(左/右)
  • 绿色 = Y轴(上/下)
  • 蓝色 = Z轴(前/后)

我们添加它看看效果:

// 创建坐标轴辅助器 //实例化坐标辅助器,单位为2,代表坐标轴长度为2个单位,2倍的立方体宽度 const axesHelper = new THREE.AxesHelper(2) //将创建好的坐标轴辅助器添加到当前场景 scene 中。 scene.add(axesHelper)

坐标轴辅助器 00_00_00-00_00_30.gif

这就是坐标轴辅助器,简直是为 3D 新手量身定制的「指南针」——红 X、绿 Y、蓝 Z,一眼就能分清上下左右前后,再也不会转着转着就迷路啦。

Day 1 的立方体像挂在墙上的监控画面——你只能从一个角度看它。

今天的四步,给你的相机解了绑:

  • 鼠标滑动,视角跟随
  • 拖拽旋转,任意角度
  • 惯性阻尼,手感丝滑
  • 坐标辅助,永不迷路

现在,你的立方体从“监控录像”变成了“可触摸的展品”。

React 技术笔记梳理

2026年4月26日 21:02

一、React 基础概念

1.1 什么是 React

  • 定义 :React 是由 Facebook 开发和维护的用于构建用户界面的 JavaScript 库
  • 核心理念 :组件化、声明式编程、虚拟 DOM
  • 特点 :单向数据流、可复用组件、跨平台支持(React Native)

1.2 JSX 语法

// 基础JSX
const element = <h1>Hello, React!</
h1>;

// 表达式嵌入
const name = 'World';
const element = <h1>Hello, {name}</
h1>;

// 条件渲染
const isLoggedIn = true;
const element = (
  <div>
    {isLoggedIn ? <UserGreeting /> 
    : <GuestGreeting />}
  </div>
);

// 列表渲染
const numbers = [123];
const listItems = numbers.map((num) 
=> 
  <li key={num}>{num}</li>
);

二、React 组件

2.1 函数组件 vs 类组件

函数组件(推荐) :

function Welcome(props) {
  return <h1>Hello, {props.name}</
  h1>;
}

// 箭头函数形式
const Welcome = ({ name }) => 
<h1>Hello, {name}</h1>;

类组件(传统方式) :

class Welcome extends React.
Component {
  render() {
    return <h1>Hello, {this.props.
    name}</h1>;
  }
}

2.2 Props(属性)

  • 只读性 :props 是从父组件传递的数据,子组件不应修改
  • 默认值 :
function Welcome({ name = 
'Guest' }) {
  return <h1>Hello, {name}</h1>;
}

2.3 State(状态)

  • 组件内部状态 ,用于管理可变数据
  • 状态更新 必须使用 setState (类组件)或 useState (函数组件)

三、React Hooks

3.1 useState

import { useState } from 'react';

function Counter() {
  const [count, setCount] = useState
  (0);
  
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => 
      setCount(count + 1)}>
        Increment
      </button>
    </div>
  );
}

3.2 useEffect

import { useEffect } from 'react';

function Example() {
  const [count, setCount] = useState
  (0);
  
  // 相当于 componentDidMount + 
  componentDidUpdate
  useEffect(() => {
    document.title = `Count: $
    {count}`;
    
    // 清理函数(相当于 
    componentWillUnmount)
    return () => {
      document.title = 'React App';
    };
  }, [count]); // 依赖数组
  
  return <button onClick={() => 
  setCount(c => c + 1)}>Click</
  button>;
}

3.3 useContext

// 创建Context
const ThemeContext = React.
createContext('light');

// 提供值
function App() {
  return (
    <ThemeContext.Provider 
    value="dark">
      <Toolbar />
    </ThemeContext.Provider>
  );
}

// 消费值
function Toolbar() {
  const theme = useContext
  (ThemeContext);
  return <div>Theme: {theme}</div>;
}

3.4 useReducer

const initialState = { count0 };

function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 
      1 };
    case 'decrement':
      return { count: state.count - 
      1 };
    default:
      throw new Error();
  }
}

function Counter() {
  const [state, dispatch] = 
  useReducer(reducer, initialState);
  return (
    <>
      Count: {state.count}
      <button onClick={() => 
      dispatch({ type: 
      'increment' })}>+</button>
      <button onClick={() => 
      dispatch({ type: 
      'decrement' })}>-</button>
    </>
  );
}

3.5 useCallback & useMemo

// useCallback - 缓存函数引用
const memoizedFn = useCallback(
  () => doSomething(a, b),
  [a, b]
);

// useMemo - 缓存计算结果
const memoizedValue = useMemo(
  () => computeExpensiveValue(a, b),
  [a, b]
);

四、React 18 新特性

4.1 Concurrent Mode(并发模式)

  • 支持可中断渲染,提升大型应用性能
  • 通过 ReactDOM.createRoot() 启用

4.2 Suspense for Data Fetching

const resource = fetchProfileData();

function ProfilePage() {
  return (
    <Suspense fallback={<Loading />}
    >
      <ProfileDetails />
    </Suspense>
  );
}

function ProfileDetails() {
  const user = resource.user.read();
  return <h1>{user.name}</h1>;
}

4.3 Transitions

import { startTransition } from 
'react';

function SearchInput() {
  const [query, setQuery] = useState
  ('');
  
  function handleChange(e) {
    // 紧急更新:立即更新输入框
    setQuery(e.target.value);
    
    // 过渡更新:可中断的非紧急更新
    startTransition(() => {
      // 更新搜索结果
    });
  }
  
  return <input onChange=
  {handleChange} value={query} />;
}

4.4 Server Components(RSC)

// Server Component(服务端渲染)
async function BlogPost({ id }) {
  const post = await db.posts.
  findOne({ id });
  return <article>{post.content}</
  article>;
}

五、状态管理

5.1 Redux

// store.js
import { createStore } from 'redux';

const initialState = { count0 };

function counterReducer(state = 
initialState, action) {
  switch (action.type) {
    case 'INCREMENT':
      return { count: state.count + 
      1 };
    default:
      return state;
  }
}

const store = createStore
(counterReducer);

// 组件中使用
import { useSelector, useDispatch } 
from 'react-redux';

function Counter() {
  const count = useSelector(state 
  => state.count);
  const dispatch = useDispatch();
  
  return (
    <button onClick={() => dispatch
    ({ type: 'INCREMENT' })}>
      {count}
    </button>
  );
}

5.2 Context API

const AuthContext = createContext
(null);

function AuthProvider({ children }) 
{
  const [user, setUser] = useState
  (null);
  
  const login = (userData) => 
  setUser(userData);
  const logout = () => setUser
  (null);
  
  return (
    <AuthContext.Provider value={{ 
    userloginlogout }}>
      {children}
    </AuthContext.Provider>
  );
}

六、React Router

6.x 版本

import { 
  BrowserRouter, 
  Routes, 
  Route, 
  Link,
  useParams,
  useNavigate
} from 'react-router-dom';

function App() {
  return (
    <BrowserRouter>
      <nav>
        <Link to="/">Home</Link>
        <Link to="/about">About</
        Link>
        <Link to="/users/123">User</
        Link>
      </nav>
      
      <Routes>
        <Route path="/" element=
        {<Home />} />
        <Route path="/about" 
        element={<About />} />
        <Route path="/users/:id" 
        element={<User />} />
      </Routes>
    </BrowserRouter>
  );
}

function User() {
  const params = useParams();
  const navigate = useNavigate();
  
  return (
    <>
      <p>User ID: {params.id}</p>
      <button onClick={() => 
      navigate('/')}>Go Home</
      button>
    </>
  );
}

七、性能优化

7.1 React.memo

const MemoizedComponent = React.memo
(function MyComponent(props) {
  // 仅在props变化时重新渲染
});

7.2 useMemo & useCallback(见3.5节)

7.3 虚拟列表

import { FixedSizeList } from 
'react-window';

function VirtualList({ items }) {
  const renderItem = ({ index, 
  style }) => (
    <div style={style}>
      {items[index].name}
    </div>
  );
  
  return (
    <FixedSizeList
      height={400}
      width="100%"
      itemCount={items.length}
      itemSize={50}
    >
      {renderItem}
    </FixedSizeList>
  );
}

7.4 Code Splitting

import { lazy, Suspense } from 
'react';

const LazyComponent = lazy(() => 
import('./HeavyComponent'));

function App() {
  return (
    <Suspense fallback={<Loading />}
    >
      <LazyComponent />
    </Suspense>
  );
}

八、最佳实践

8.1 组件设计原则

  • 单一职责 :一个组件只做一件事
  • 可复用性 :设计通用、可配置的组件
  • 纯组件 :相同输入产生相同输出

8.2 目录结构

src/
├── components/       # 通用组件
│   ├── Button/
│   └── Card/
├── features/         # 功能模块
│   └── user/
│       ├── components/
│       ├── hooks/
│       └── index.js
├── hooks/            # 自定义hooks
├── utils/            # 工具函数
└── App.js

8.3 自定义 Hooks

function useLocalStorage(key, 
initialValue) {
  const [storedValue, 
  setStoredValue] = useState(() => {
    try {
      const item = window.
      localStorage.getItem(key);
      return item ? JSON.parse
      (item) : initialValue;
    } catch {
      return initialValue;
    }
  });
  
  const setValue = (value) => {
    try {
      setStoredValue(value);
      window.localStorage.setItem
      (key, JSON.stringify(value));
    } catch (error) {
      console.error(error);
    }
  };
  
  return [storedValue, setValue];
}

8.4 错误边界

class ErrorBoundary extends React.
Component {
  constructor(props) {
    super(props);
    this.state = { hasError: 
    false };
  }
  
  static getDerivedStateFromError
  (error) {
    return { hasErrortrue };
  }
  
  componentDidCatch(error, 
  errorInfo) {
    console.error(error, errorInfo);
  }
  
  render() {
    if (this.state.hasError) {
      return <h1>Something went 
      wrong.</h1>;
    }
    return this.props.children;
  }
}

九、测试

9.1 单元测试(Jest + Testing Library)

import { render, screen } from 
'@testing-library/react';
import App from './App';

test('renders welcome message'() 
=> {
  render(<App />);
  const linkElement = screen.
  getByText(/welcome/i);
  expect(linkElement).
  toBeInTheDocument();
});

9.2 快照测试

test('renders correctly', () => {
  const { asFragment } = render
  (<MyComponent />);
  expect(asFragment()).
  toMatchSnapshot();
});

十、React 生态

类别 推荐库 路由 React Router 状态管理 Redux Toolkit, Zustand, Jotai 样式 Tailwind CSS, Styled Components 表单 React Hook Form, Formik 数据请求 React Query, SWR 图表 Recharts, Chart.js 动画 Framer Motion, GSAP 测试 Jest, React Testing Library

十一、学习路径总结

  1. 基础阶段 :JSX、组件、Props、State、事件处理
  2. 进阶阶段 :Hooks、Context、React Router、状态管理
  3. 高级阶段 :性能优化、服务端渲染、React 18新特性
  4. 工程化阶段 :TypeScript、测试、构建工具(Vite/Webpack) React 的核心思想是 组件化 和 声明式编程 ,掌握 Hooks 和状态管理是进阶的关键。随着 React 18 的发布,并发特性和 Server Components 正在改变前端开发的方式。
❌
❌