普通视图

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

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

作者 SmalBox
2026年2月13日 10:01

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

Baked GI 节点是 Unity URP Shader Graph 中一个重要的光照计算节点,它允许着色器访问预计算的光照信息,为场景中的静态物体提供高质量的间接光照效果。在实时渲染中,全局光照(Global Illumination)的计算通常非常耗费性能,因此 Unity 提供了烘焙光照的解决方案,将复杂的光照计算预先处理并存储在光照贴图或光照探针中,运行时直接采样这些预计算数据,既能保证视觉效果又能保持高性能。

该节点的核心功能是根据物体的位置和朝向,从预先烘焙的光照数据中获取相应的光照颜色值。这些数据可以来自两种主要来源:光照贴图用于静态几何体,以及光照探针用于动态物体或需要动态光照的静态物体。通过合理使用 Baked GI 节点,开发者可以创建出具有丰富间接光照和真实感光照交互的着色器,而无需承担实时全局光照计算的性能开销。

在 URP 管线中,Baked GI 节点的实现经过了优化,专门针对移动平台和性能敏感的场景。与内置渲染管线或 HDRP 相比,URP 中的 Baked GI 节点可能有一些特定的限制和行为差异,但这些差异主要是为了确保在目标平台上的最佳性能表现。理解这些差异对于创建跨管线兼容的着色器至关重要。

描述

Baked GI 节点为着色器提供了访问烘焙全局光照值的能力,这些值可以在顶点着色器或片元着色器阶段使用。节点需要几个关键的输入参数来确定如何采样光照数据,包括世界空间中的位置和法线向量,以及用于光照贴图采样的 UV 坐标。

烘焙全局光照基础

烘焙全局光照是 Unity 光照系统的重要组成部分,它通过预计算场景中光线如何在不同表面之间反射和传播,生成静态的光照信息。这个过程包括直接光照和间接光照的计算,但只针对标记为静态的物体进行。烘焙完成后,光照信息会被存储到光照贴图或光照探针中:

  • 光照贴图是应用于静态几何体的纹理,包含预先计算的光照信息
  • 光照探针是在场景空间中放置的采样点,存储了该位置的光照信息,可用于动态物体或需要动态光照的静态物体

Baked GI 节点的作用就是在着色器执行时,根据提供的输入参数,从这些预计算的光照数据中获取相应的颜色值。

位置和法线输入的重要性

位置和法线输入对于正确采样光照探针数据至关重要。光照探针数据是基于球谐函数编码的,这种编码方式能够高效地存储全方向的光照信息。当着色器需要获取某点的光照信息时,系统会根据该点的位置找到最近的光照探针组,然后使用法线方向来评估球谐函数,得到该方向上的光照颜色。

如果提供的位置或法线不正确,可能会导致光照采样错误,表现为不自然的光照过渡或错误的光照方向。因此,确保这些输入参数的准确性是使用 Baked GI 节点的关键。

光照贴图坐标的作用

Static UV 和 Dynamic UV 输入用于采样不同类型的光照贴图:

  • Static UV 通常对应网格的 UV1 通道,用于采样静态光照贴图
  • Dynamic UV 通常对应网格的 UV2 通道,用于采样动态全局光照的光照贴图

在 Unity 的光照设置中,开发者可以选择使用不同的光照模式,如 Baked、Mixed 或 Realtime。对于 Mixed 光照模式的静态物体,Unity 会生成两套光照贴图:一套用于完全烘焙的光照,另一套用于与实时光照结合的效果。Baked GI 节点通过不同的 UV 输入来访问这些不同的光照贴图。

节点行为的管线依赖性

一个重要的注意事项是,Baked GI 节点的具体行为并未在全局范围内统一定义。Shader Graph 本身并不定义这个节点的功能实现,而是由每个渲染管线决定为此节点生成什么样的 HLSL 代码。这意味着:

  • 在高清渲染管线中,Baked GI 节点可能有特定的优化和功能
  • 在通用渲染管线中,节点的实现可能更注重性能和跨平台兼容性
  • 在内置渲染管线中,节点的行为可能又有所不同

这种设计使得每个渲染管线可以根据自身的架构和需求,优化 Baked GI 节点的实现方式。对于着色器开发者来说,这意味着如果计划创建在多种渲染管线中使用的着色器,需要在每个目标管线中测试 Baked GI 节点的行为,确保它按预期工作。

无光照着色器中的限制

在 URP 和 HDRP 中,Baked GI 节点不能在无光照着色器中使用。无光照着色器通常用于不需要复杂光照计算的物体,如UI元素、粒子效果或特殊效果。这些着色器通常会绕过管线的标准光照流程,因此无法访问烘焙全局光照数据。

如果尝试在无光照着色器中使用 Baked GI 节点,可能会遇到编译错误或运行时错误。对于需要简单光照的无光照物体,考虑使用其他光照技术,如顶点光照或简单的漫反射计算。

端口

Baked GI 节点包含多个输入端口和一个输出端口,每个端口都有特定的功能和数据要求。理解这些端口的作用对于正确使用节点至关重要。

Position 输入端口

Position 输入端口接收世界空间中的位置坐标,用于确定光照采样的空间位置。这个位置信息主要用于:

  • 光照探针采样:确定使用哪些光照探针的数据
  • 光照贴图索引:在某些情况下,帮助确定使用哪张光照贴图

在大多数情况下,应该将物体的世界空间位置连接到这个端口。在顶点着色器阶段使用 Baked GI 节点时,可以使用 Position 节点获取顶点在世界空间中的位置;在片元着色器阶段使用时,可以使用屏幕位置或通过其他方式计算得到的世界位置。

当使用光照探针时,位置输入的准确性尤为重要。如果位置偏差过大,可能会导致物体采样到错误位置的光照探针数据,造成光照不匹配的现象。

Normal 输入端口

Normal 输入端口接收世界空间中的法线向量,用于确定表面朝向,从而影响光照采样的方向。法线输入的主要作用包括:

  • 光照探针评估:球谐光照基于法线方向评估光照颜色
  • 光照贴图采样:在某些高级用法中,法线可能影响光照贴图的采样方式

法线向量应当是世界空间中的单位向量。如果提供的法线没有归一化,可能会导致光照计算错误。通常情况下,可以使用 Transform 节点将物体空间法线转换到世界空间,并确保使用正确的变换矩阵(通常是转置逆矩阵)。

对于动态法线效果(如法线贴图),需要将修改后的法线向量连接到 Normal 端口,这样 Baked GI 节点就会基于修改后的表面朝向计算光照,创造出更加丰富的视觉效果。

Static UV 输入端口

Static UV 输入端口用于指定静态光照贴图的纹理坐标。这些坐标通常对应于网格的 UV1 通道,也就是在建模软件中为光照贴图准备的 UV 集。Static UV 的作用包括:

  • 采样完全烘焙的光照贴图
  • 访问静态物体的间接光照信息
  • 在 Mixed 光照模式下,采样烘焙的间接光照部分

当场景中使用 Baked 或 Mixed 光照模式时,Unity 会为静态物体生成光照贴图。这些光照贴图包含了预计算的直接和间接光照信息。Static UV 输入确保着色器能够正确访问这些光照数据。

如果网格没有正确设置光照贴图 UV,或者 Static UV 输入不正确,可能会导致光照贴图采样错误,表现为拉伸、扭曲或重复的光照图案。

Dynamic UV 输入端口

Dynamic UV 输入端口用于指定动态光照贴图的纹理坐标,通常对应于网格的 UV2 通道。Dynamic UV 的主要应用场景包括:

  • 在 Mixed 光照模式下,采样用于实时光照交互的光照贴图
  • 访问动态全局光照系统生成的光照信息
  • 处理需要与实时光源交互的静态物体的光照

在 Mixed 光照模式下,Unity 会为静态物体生成两套光照贴图:一套用于完全烘焙的光照(通过 Static UV 访问),另一套用于与实时光源结合的效果(通过 Dynamic UV 访问)。这种设计允许静态物体既受益于高质量的烘焙光照,又能与场景中的实时光源正确交互。

Out 输出端口

Out 输出端口提供从烘焙全局光照系统采样的颜色值。这个输出是三维向量,表示 RGB 颜色空间中的光照颜色。输出的光照值已经考虑了:

  • 直接光照和间接光照的贡献
  • 颜色反射和光能传递效果
  • 场景的环境光遮蔽

输出的颜色值通常需要与材质的反照率颜色相乘,以实现正确的光照着色。在基于物理的着色模型中,Baked GI 的输出代表入射光强度,应当与表面反照率相乘来计算出射光强度。

在某些高级用法中,Baked GI 的输出可以用于更复杂的光照计算,如与实时光照结合,或作为其他着色效果的输入。

控件

Baked GI 节点提供了一个重要的控件选项,用于调整光照贴图的处理方式。

Apply Lightmap Scaling 切换

Apply Lightmap Scaling 是一个布尔切换控件,决定是否对光照贴图坐标自动应用缩放和偏移。这个选项默认为启用状态,在大多数情况下应该保持启用。

当启用 Apply Lightmap Scaling 时,节点会自动应用 Unity 光照系统中定义的光照贴图缩放和偏移变换。这些变换确保光照贴图正确映射到网格表面,考虑到了光照贴图的分包、排列和压缩设置。

禁用 Apply Lightmap Scaling 的情况较为少见,通常只在以下特定场景中考虑:

  • 当手动处理光照贴图坐标时
  • 当使用自定义的光照贴图布局时
  • 在某些特殊效果着色器中,需要直接访问原始光照贴图坐标

在大多数标准用法中,建议保持此选项启用,以确保光照贴图正确映射。如果禁用此选项,需要手动确保光照贴图坐标的正确性,否则可能导致光照贴图采样错误。

生成代码示例

Baked GI 节点在生成着色器代码时,会根据所在的渲染管线产生相应的 HLSL 代码。以下示例展示了 URP 中 Baked GI 节点可能生成的代码结构。

基本函数定义

HLSL

void Unity_BakedGI_float(float3 Position, float3 Normal, float2 StaticUV, float2 DynamicUV, out float3 Out)
{
    Out = SHADERGRAPH_BAKED_GI(Position, Normal, StaticUV, DynamicUV, false);
}

这个函数定义展示了 Baked GI 节点的基本代码结构。函数接收位置、法线和光照贴图坐标作为输入,通过 SHADERGRAPH_BAKED_GI 宏计算烘焙全局光照值,并将结果输出到 Out 参数。

SHADERGRAPH_BAKED_GI 是一个由 Shader Graph 系统定义的宏,它的具体实现取决于目标渲染管线。在 URP 中,这个宏会展开为访问 URP 烘焙光照系统的代码。

实际应用示例

在实际的着色器中,Baked GI 节点通常与其他光照计算结合使用。以下是一个简单的表面着色器示例,展示如何将 Baked GI 与实时直接光照结合:

HLSL

void surf(Input IN, inout SurfaceOutputStandard o)
{
    // 采样反照率贴图
    fixed4 albedo = tex2D(_MainTex, IN.uv_MainTex) * _Color;

    // 获取烘焙全局光照
    float3 bakedGI;
    Unity_BakedGI_float(IN.worldPos, IN.worldNormal, IN.uv1, IN.uv2, bakedGI);

    // 计算实时直接光照(简化示例)
    float3 directLight = _LightColor0 * max(0, dot(IN.worldNormal, _WorldSpaceLightPos0.xyz));

    // 结合光照
    o.Albedo = albedo.rgb;
    o.Emission = bakedGI * albedo.rgb;
    // 直接光照已经在光照模型中处理
}

这个示例展示了烘焙间接光照与实时直接光照的基本结合方式。在实际的 URP 着色器中,光照计算可能更加复杂,涉及更多光照模型和渲染特性。

顶点与片元着色器中的使用

Baked GI 节点既可以在顶点着色器中使用,也可以在片元着色器中使用,取决于性能和质量的需求:

顶点着色器中使用:

HLSL

v2f vert (appdata v)
{
    v2f o;
    // ... 其他顶点变换

    // 在顶点着色器中计算烘焙GI
    Unity_BakedGI_float(mul(unity_ObjectToWorld, v.vertex).xyz,
                        normalize(mul(v.normal, (float3x3)unity_WorldToObject)),
                        v.uv1, v.uv2, o.bakedGI);

    return o;
}

片元着色器中使用:

HLSL

fixed4 frag (v2f i) : SV_Target
{
    // 在片元着色器中计算烘焙GI(更高质量)
    float3 bakedGI;
    Unity_BakedGI_float(i.worldPos, normalize(i.worldNormal), i.uv1, i.uv2, bakedGI);

    // ... 其他着色计算
}

在顶点着色器中使用 Baked GI 性能更好,但光照细节较少;在片元着色器中使用质量更高,但性能开销更大。根据目标平台和性能要求选择合适的阶段。

最佳实践和性能考虑

使用 Baked GI 节点时,遵循一些最佳实践可以确保最佳的性能和视觉效果。

光照贴图设置优化

确保场景的光照贴图设置正确优化:

  • 使用适当的光照贴图分辨率,平衡质量和内存使用
  • 合理设置光照贴图压缩,在移动平台上使用压缩格式
  • 对不需要高质量光照的物体使用较低的光照贴图分辨率

光照探针布局优化

光照探针的布局影响动态物体的光照质量:

  • 在光照变化明显的区域放置更多光照探针
  • 确保动态物体的移动路径上有足够的光照探针覆盖
  • 使用光照探针代理卷提高大范围区域的光照探针效率

着色器性能优化

在着色器中使用 Baked GI 节点时考虑性能:

  • 在移动平台上,考虑在顶点着色器中使用 Baked GI
  • 对于远处物体,使用简化的光照计算
  • 避免在透明物体的着色器中过度使用复杂的光照计算

跨管线兼容性

如果计划创建跨渲染管线使用的着色器:

  • 在目标管线中测试 Baked GI 节点的行为
  • 使用着色器变体或自定义函数处理管线特定的差异
  • 提供回退方案,当 Baked GI 节点不可用时使用替代光照计算

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

TypeScript 核心基础知识

2026年2月13日 09:11

TypeScript(简称 TS)作为 JavaScript 的超集,已成为前端工程化的标配。它通过静态类型检查,提前规避大量运行时错误,让代码更易维护、更具可读性。本文抛开复杂概念,从新手视角梳理 TS 核心基础知识,看完就能上手写 TS 代码。

一、为什么要学 TypeScript?

先明确学习的意义,避免盲目跟风:

  1. 静态类型检查:编码阶段发现错误(如类型不匹配、属性不存在),而非运行时崩溃;
  2. 更好的代码提示:VS Code 等编辑器能精准提示变量 / 函数的属性和方法,提升开发效率;
  3. 代码可读性提升:类型注解就是 “自文档”,一眼看懂变量 / 函数的用途;
  4. 工程化必备:Vue3、React、Node.js 主流框架 / 环境均推荐 / 支持 TS,大厂项目标配。

二、TS 环境搭建(快速上手)

1. 安装 TypeScript

# 全局安装 TS 编译器
npm install -g typescript
# 验证安装(查看版本)
tsc -v

2. 第一个 TS 程序

  • 创建 hello.ts 文件:

    // 类型注解:指定变量类型为字符串
    const message: string = "Hello TypeScript!";
    console.log(message);
    
  • 编译 TS 为 JS:

    # 将 hello.ts 编译为 hello.js
    tsc hello.ts
    
  • 运行 JS 文件:

    node hello.js
    

3. 简化开发:自动编译 + 热更新(可选)

# 安装 ts-node(直接运行 TS,无需手动编译)
npm install -g ts-node
# 直接运行 TS 文件
ts-node hello.ts

三、核心基础:类型注解与类型推断

1. 类型注解(手动指定类型)

语法:变量名: 类型 = 值,告诉 TS 变量的具体类型。

// 基本类型注解
let name: string = "张三"; // 字符串
let age: number = 25; // 数字(整数/浮点数/NaN/Infinity)
let isAdult: boolean = true; // 布尔值
let empty: null = null; // null
let undef: undefined = undefined; // undefined

// 数组注解(两种写法)
let arr1: string[] = ["苹果", "香蕉"]; // 推荐
let arr2: Array<number> = [1, 2, 3]; // 泛型写法

// 对象注解
let user: { name: string; age: number } = {
  name: "李四",
  age: 30,
};

// 函数注解(参数 + 返回值)
function add(a: number, b: number): number {
  return a + b;
}

2. 类型推断(TS 自动推导类型)

TS 会根据变量的初始值自动推断类型,无需手动注解(日常开发中优先用推断,减少冗余)。

typescript

运行

let str = "hello"; // TS 自动推断 str 为 string 类型
str = 123; // 报错:不能将类型“number”分配给类型“string”

let num = 100; // 推断为 number 类型
let bool = false; // 推断为 boolean 类型

核心原则:能靠推断的就不手动注解,需要明确约束时才加注解。

四、常用基础类型

1. 原始类型

表格

类型 说明 示例
string 字符串 let str: string = "TS"
number 数字 let num: number = 666
boolean 布尔值 let flag: boolean = false
null 空值 let n: null = null
undefined 未定义 let u: undefined = undefined
symbol 唯一值 let s: symbol = Symbol("id")
bigint 大整数 let b: bigint = 100n

2. 数组

两种写法,推荐第一种:

// 写法1:类型[]
let numbers: number[] = [1, 2, 3];
// 写法2:Array<类型>
let strings: Array<string> = ["a", "b"];
// 禁止混合类型(除非指定联合类型)
let mix: (string | number)[] = [1, "a"]; // 联合类型:字符串或数字

3. 元组(Tuple)

固定长度、固定类型的数组(强约束):

// 元组注解:第一个元素是string,第二个是number
let tuple: [string, number] = ["张三", 25];
tuple[0] = "李四"; // 合法
tuple[1] = 30; // 合法
tuple.push(3); // 注意:push 不会报错(TS 设计缺陷),但访问 tuple[2] 会报错

4. 任意类型(any)

关闭 TS 类型检查,慎用(失去 TS 核心价值):

let anyValue: any = "hello";
anyValue = 123; // 不报错
anyValue = true; // 不报错
anyValue.foo(); // 不报错(运行时可能崩溃)

5. 未知类型(unknown)

安全版 any,必须先类型校验才能使用:

let unknownValue: unknown = "hello";
// unknownValue.toUpperCase(); // 报错:不能直接调用方法

// 先校验类型,再使用
if (typeof unknownValue === "string") {
  unknownValue.toUpperCase(); // 合法
}

6. 空类型(void)

表示函数没有返回值(或返回 undefined):

function logMsg(): void {
  console.log("这是一个无返回值的函数");
  // 省略 return 或 return undefined 均合法
}

7. 永不类型(never)

表示永远不会发生的值(如抛出错误、无限循环):

// 抛出错误的函数,返回值为 never
function throwError(): never {
  throw new Error("出错了!");
}

// 无限循环的函数,返回值为 never
function infiniteLoop(): never {
  while (true) {}
}

五、进阶基础:接口与类型别名

1. 接口(interface)

用于约束对象的结构,可扩展、可实现,是 TS 中定义对象类型的核心方式:

// 定义接口
interface User {
  name: string; // 必选属性
  age: number; // 必选属性
  gender?: string; // 可选属性(加 ?)
  readonly id: number; // 只读属性(不可修改)
}

// 使用接口约束对象
let user: User = {
  name: "张三",
  age: 25,
  id: 1001,
  // gender 可选,可省略
};

user.id = 1002; // 报错:只读属性不能修改

2. 类型别名(type)

给类型起别名,适用范围更广(可约束任意类型,不止对象):

// 基本类型别名
type Str = string;
let str: Str = "hello";

// 对象类型别名
type User = {
  name: string;
  age: number;
};

// 联合类型别名
type NumberOrString = number | string;
let value: NumberOrString = 100;
value = "abc";

3. interface vs type 核心区别

表格

特性 interface type
扩展 可通过 extends 扩展 可通过 & 交叉扩展
重复定义 支持(自动合并) 不支持(会报错)
适用范围 主要约束对象 / 类 可约束任意类型(基本类型、联合类型等)

使用建议:定义对象 / 类的结构用 interface,其他场景用 type

六、函数相关类型

1. 函数参数与返回值注解

// 普通函数
function sum(a: number, b: number): number {
  return a + b;
}

// 箭头函数
const multiply = (a: number, b: number): number => {
  return a * b;
};

// 无返回值
const log = (msg: string): void => {
  console.log(msg);
};

2. 可选参数与默认参数

// 可选参数(加 ?,必须放在必选参数后面)
function greet(name: string, age?: number): void {
  console.log(`姓名:${name},年龄:${age || "未知"}`);
}
greet("张三"); // 合法
greet("李四", 30); // 合法

// 默认参数(自动推断类型,无需加 ?)
function sayHi(name: string = "游客"): void {
  console.log(`你好,${name}`);
}
sayHi(); // 输出:你好,游客

3. 函数类型别名

定义函数的 “形状”(参数类型 + 返回值类型):

// 定义函数类型
type AddFn = (a: number, b: number) => number;

// 实现函数
const add: AddFn = (x, y) => {
  return x + y;
};

七、类型守卫

通过代码逻辑缩小类型范围,让 TS 更精准推断类型:

// typeof 类型守卫(适用于原始类型)
function printValue(value: string | number) {
  if (typeof value === "string") {
    console.log(value.toUpperCase()); // TS 知道这里 value 是 string
  } else {
    console.log(value.toFixed(2)); // TS 知道这里 value 是 number
  }
}

// instanceof 类型守卫(适用于类实例)
class Animal {}
class Dog extends Animal {
  bark() {
    console.log("汪汪汪");
  }
}

function judgeAnimal(animal: Animal) {
  if (animal instanceof Dog) {
    animal.bark(); // TS 知道这里 animal 是 Dog 实例
  }
}

八、TS 配置文件(tsconfig.json)

项目中通过 tsconfig.json 配置 TS 编译规则,执行 tsc --init 生成默认配置,核心配置说明:

{
  "compilerOptions": {
    "target": "ES6", // 编译目标 JS 版本(ES5/ES6/ESNext)
    "module": "ESNext", // 模块系统(CommonJS/ESModule)
    "outDir": "./dist", // 编译后的 JS 文件输出目录
    "rootDir": "./src", // 源文件目录
    "strict": true, // 开启严格模式(推荐,强制类型检查)
    "noImplicitAny": true, // 禁止隐式 any 类型
    "esModuleInterop": true // 兼容 CommonJS 和 ESModule
  },
  "include": ["./src/**/*"], // 要编译的文件
  "exclude": ["node_modules"] // 排除的文件
}

九、新手避坑指南

  1. 不要滥用 any:用 unknown 替代 any,保留类型检查;
  2. 可选参数放最后:TS 要求可选参数必须在必选参数之后;
  3. 元组 push 不报错:元组虽固定长度,但 push 不会触发 TS 报错,需手动规避;
  4. 严格模式必开strict: true 能暴露更多潜在问题,是 TS 核心价值所在;
  5. 类型断言要谨慎as 语法是 “告诉 TS 我比你更清楚类型”,滥用会导致类型不安全。

总结

  1. TS 核心是静态类型系统,通过类型注解 / 推断提前规避错误;
  2. 常用基础类型:原始类型、数组、元组、any/unknown、void/never,需掌握各自使用场景;
  3. 定义对象结构优先用 interface,其他类型约束用 type
  4. 函数注解要关注参数、返回值、可选参数,类型守卫能提升类型推断精度;
  5. 项目中务必开启严格模式(strict: true),发挥 TS 最大价值。

从 JS 过渡到 TS 无需一步到位,可先在项目中局部使用,逐步覆盖,重点是理解 “类型” 的核心思想,而非死记语法。掌握本文的基础知识,足以应对日常开发中 80% 的 TS 场景,后续可再深入泛型、装饰器、高级类型等内容。

JavaScript 手写 new 操作符:深入理解对象创建

作者 wuhen_n
2026年2月13日 09:10

当我们使用 new 关键字时,背后到底发生了什么?这个看似简单的操作,实际上完成了一系列复杂的步骤。理解 new 的工作原理,是掌握 JavaScript 面向对象编程的关键。

前言:从 new 的神秘面纱说起

function Person(name, age) {
    this.name = name;
    this.age = age;
}

Person.prototype.greet = function() {
    return `你好,我是${this.name},今年${this.age}岁`;
};

const person = new Person('张三', 25);

上述代码中 new 到底创建了什么?为什么 this 指向了新对象?原型链是怎么建立的?如果构造函数有返回值会怎样?我们将通过本篇文章揭开 new 的神秘面纱,从零实现一个自己的 new 操作符。

理解 new

new 的四个核心步骤:

  1. 创建一个空对象
  2. 将对象的原型设置为构造函数的 prototype 属性
  3. 将构造函数的 this 绑定到新对象,并执行构造函数
  4. 判断返回值类型:如果构造函数返回一个对象(包括函数),则返回该对象;否则返回新创建的对象

手写实现 new 操作符

基础版本实现

function myNew(constructor, ...args) {

  // 1. 创建一个空对象
  const obj = {};

  // 2. 将对象的原型设置为构造函数的 prototype 属性
  Object.setPrototypeOf(obj, constructor.prototype);

  // 3. 将构造函数的 this 绑定到新对象,并执行构造函数
  const result = constructor.apply(obj, args);

  // 4. 判断返回值类型
  // 如果构造函数返回一个对象(包括函数),则返回该对象
  // 否则返回新创建的对象
  const isObject = result !== null && (typeof result === 'object' || typeof result === 'function');

  return isObject ? result : obj;
}

处理边界情况

function myNewEnhanced(constructor, ...args) {

  // 边界情况1:constructor 不是函数
  if (typeof constructor !== 'function') {
    throw new TypeError(`${constructor} is not a constructor`);
  }

  // 边界情况2:箭头函数(没有 prototype)
  if (!constructor.prototype) {
    throw new TypeError(`${constructor.name || constructor} is not a constructor`);
  }

  // 1. 创建新对象(改进方法):使用 Object.create 更优雅地设置原型
  const obj = Object.create(constructor.prototype);

  // 2. 调用构造函数
  let result;
  try {
    result = constructor.apply(obj, args);
  } catch (error) {
    // 如果构造函数抛出异常,直接传播
    throw error;
  }

  // 3. 处理返回值
  // 注意:null 也是 object 类型,但需要特殊处理
  const resultType = typeof result;
  const isObject = result !== null && (resultType === 'object' || resultType === 'function');

  return isObject ? result : obj;
}

完整实现与原型链优化

function myNewComplete(constructor, ...args) {
  // 1. 参数验证
  if (typeof constructor !== 'function') {
    throw new TypeError(`Constructor ${constructor} is not a function`);
  }

  // 2. 检查是否为可构造的函数:箭头函数和部分内置方法没有 prototype
  if (!constructor.prototype && !isNativeConstructor(constructor)) {
    throw new TypeError(`${getFunctionName(constructor)} is not a constructor`);
  }

  // 3. 创建新对象并设置原型链
  const proto = constructor.prototype || Object.prototype;
  const obj = Object.create(proto);

  // 4. 绑定 constructor 属性
  obj.constructor = constructor; 

  // 5. 执行构造函数
  const result = Reflect.construct(constructor, args, constructor);

  // 6. 处理返回值
  // Reflect.construct 已经处理了返回值逻辑
  // 但我们还是实现自己的逻辑以保持一致
  return processConstructorResult(result, obj, constructor);
}

// 辅助函数:检查是否为原生构造函数
function isNativeConstructor(fn) {
  // 一些内置构造函数如 Symbol、BigInt 没有 prototype
  const nativeConstructors = [
    'Number', 'String', 'Boolean', 'Symbol', 'BigInt',
    'Date', 'RegExp', 'Error', 'Array', 'Object', 'Function'
  ];

  return nativeConstructors.some(name =>
    fn.name === name || fn === globalThis[name]
  );
}

// 辅助函数:获取函数名
function getFunctionName(fn) {
  if (fn.name) return fn.name;
  const match = fn.toString().match(/^function\s*([^\s(]+)/);
  return match ? match[1] : 'anonymous';
}

// 辅助函数:处理构造函数返回值
function processConstructorResult(result, defaultObj, constructor) {
  // 如果 result 是 undefined 或 null,返回 defaultObj
  if (result == null) {
    return defaultObj;
  }

  // 检查 result 的类型
  const type = typeof result;

  // 如果是对象或函数,返回 result
  if (type === 'object' || type === 'function') {
    // 额外检查:如果 result 是构造函数本身的实例,确保原型链正确
    if (result instanceof constructor) {
      return result;
    }
    return result;
  }

  // 原始值,返回 defaultObj
  return defaultObj;
}

深入原型链与继承

原型链的建立过程

// 父构造函数
function Animal(name) {
  this.name = name;
}
// 父类方法
Animal.prototype.speak = function () {
  return `${this.name} 叫了`;
};
// 子构造函数
function Dog(name, breed) {
  Animal.call(this, name);
  this.breed = breed;
}
// 建立原型链
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;
// 子类方法
Dog.prototype.bark = function () {
  return `${this.name} 汪汪叫`;
};
// 创建实例
const myDog = new Dog('旺财', '金毛');
console.log(myDog.speak()); // 旺财 叫了
console.log(myDog.bark());  // 旺财 汪汪叫

ES6 类与 new 的关系

ES6 类的本质还是基于原型的语法糖:

ES6 基本写法

class Person {
  constructor(name, age) {
    this.name = name;
    this.age = age;
  }

  greet() {
    return `你好,我是${this.name}`;
  }
}
const person = new Person('张三', 30);

对应 ES5 的写法

function PersonES5(name, age) {
  // 类构造器中的代码
  if (!(this instanceof PersonES5)) {
    throw new TypeError("Class constructor Person cannot be invoked without 'new'");
  }

  this.name = name;
  this.age = age;
}

// 实例方法(添加到原型)
PersonES5.prototype.greet = function () {
  return `你好,我是${this.name}`;
};
const personES5 = new PersonES5('李四', 25);

类的重要特性

  1. 类必须用 new 调用
  2. 类方法不可枚举
  3. 类没有变量提升

ES6 实现继承的完整示例

class Animal {
  constructor(name) {
    this.name = name;
  }

  speak() {
    console.log(this.name + ' 叫了');
  }
}
class Dog extends Animal {
  constructor(name) {
    super(name);
  }

  speak() {
    console.log(this.name + ' 汪汪叫');
  }
}

ES6 继承的本质

ES6 通过 extends 关键字实现继承,就等价于 ES5 的寄生组合继承:

function AnimalES5(name) {
  this.name = name;
}

AnimalES5.prototype.speak = function () {
  console.log(this.name + ' 叫了');
};

function DogES5(name) {
  AnimalES5.call(this, name);
}

// 设置原型链
DogES5.prototype = Object.create(AnimalES5.prototype);
DogES5.prototype.constructor = DogES5;

DogES5.prototype.speak = function () {
  console.log(this.name + ' 汪汪叫');
};

特殊场景与高级应用

单例模式与 new

方法1:使用静态属性

class SingletonV1 {
  static instance = null;

  constructor(name) {
    if (SingletonV1.instance) {
      return SingletonV1.instance;
    }

    this.name = name;
    SingletonV1.instance = this;
  }

  static getInstance(name) {
    if (!this.instance) {
      this.instance = new SingletonV1(name);
    }
    return this.instance;
  }
}

方法2:使用闭包

const SingletonV2 = (function () {
  let instance = null;

  return class Singleton {
    constructor(name) {
      if (instance) {
        return instance;
      }

      this.name = name;
      instance = this;
    }
  };
})();

方法3:代理模式

function createSingletonProxy(Class) {
  let instance = null;

  return new Proxy(Class, {
    construct(target, args) {
      if (!instance) {
        instance = Reflect.construct(target, args);
      }
      return instance;
    }
  });
}

实现 Object.create 的 polyfill

if (typeof Object.create !== 'function') {
  Object.create = function (proto, propertiesObject) {
    // 参数验证
    if (typeof proto !== 'object' && typeof proto !== 'function') {
      throw new TypeError('Object prototype may only be an Object or null');
    }

    // 核心实现:使用空函数作为中间构造函数
    function F() { }
    F.prototype = proto;

    // 创建新对象,原型指向proto
    const obj = new F();

    // 处理第二个参数(属性描述符)
    if (propertiesObject !== undefined) {
      Object.defineProperties(obj, propertiesObject);
    }

    // 处理 null 原型
    if (proto === null) {
      obj.__proto__ = null;
    }
    // 返回新对象
    return obj;
  };
}

常见面试问题与解答

问题1:new 操作符做了什么?

  1. 创建一个新的空对象',
  2. 将这个空对象的原型设置为构造函数的 prototype 属性',
  3. 将构造函数的 this 绑定到这个新对象,并执行构造函数',
  4. 如果构造函数返回一个对象(包括函数),则返回该对象;否则返回新创建的对象

问题2:如果构造函数有返回值会怎样?

  • 返回对象(包括函数):忽略 this 绑定的对象,返回该对象
  • 返回原始值(number, string, boolean等):忽略返回值,返回 this 绑定的对象
  • 没有 return 语句:隐式返回 undefined,返回 this 绑定的对象

问题3:如何判断函数是否被 new 调用?

  • ES5:检查 this instanceof Constructor'
  • ES6+:使用 new.target(更准确)
  • 箭头函数:不能作为构造函数,没有 new.target

结语

通过深入理解 new 操作符的工作原理,我们不仅能在面试中脱颖而出,还能在实际开发中做出更明智的设计决策。对于文章中错误的地方或者有任何问题,欢迎在评论区留言讨论!

企业级 Prompt 工程实战指南(下):构建可复用 Prompt 架构平台

作者 乘风gg
2026年2月13日 08:47

一、前言:从“懂原理”到“能落地”

在上篇内容中企业级 Prompt 工程实战指南(上):别让模糊指令浪费你的AI算力,我们拆解了 Prompt 的底层逻辑、四大核心要素,以及四大典型避坑技巧,解决了“怎么写才不踩坑”的基础问题。

但对一线开发者和架构师而言,Prompt 工程的最终价值,不在于“懂原理”,而在于“能落地”——如何将 Prompt 设计融入实际业务,降低开发成本、提升效率,构建可复用、可迭代的 Prompt 体系?

本篇将聚焦实战,通过完整业务案例拆解落地流程,对比不同技术路径的优劣,分享工程化落地技巧,并展望未来发展趋势,真正把 Prompt 技术转化为业务竞争力。

二、实战案例:企业客服工单自动分类与摘要生成

为了更直观地展示 Prompt 工程在实际业务中的应用效果,我们以一家电商企业的售后客服场景为例,详细拆解如何通过精心设计的 Prompt 实现工单的自动分类与摘要生成,大幅提升客服工作效率。

2.1 场景角色

  • AI 应用产品经理(Prompt 设计者) :负责设计和优化 Prompt,确保大语言模型能够准确理解业务需求并生成高质量的输出。
  • 客服团队(需求方) :每天需要处理大量的售后工单,希望借助 AI 技术实现工单的自动分类和摘要生成,以减轻工作负担,提高服务效率。
  • 大模型(执行主体) :选用市面上成熟的大语言模型,如 ChatGPT、Gemini、通义千问等,作为执行任务的核心引擎,根据输入的 Prompt 和工单文本进行分析和处理。
  • 服务对象:日均产生 500 + 售后工单的电商售后部门,涵盖各类复杂的客户问题和诉求。

2.2 核心目标

通过优化 Prompt 设计,让大语言模型自动将杂乱无章的售后工单准确分类为 “物流问题”“产品故障”“退换货申请” 三类,并为每个工单生成 50 字以内的结构化处理摘要,清晰概括核心诉求与关键信息。目标是替代人工分类,将整体工作效率提升 30% 以上,同时保证分类准确率达到 95% 以上,摘要关键信息覆盖率达到 90% 以上。

2.3 输入

  • 原始输入:无结构化的售后工单文本,例如 “我买的衣服尺码不对,昨天收到的,想换大一码,请问需要寄回吗?” 这类文本通常表述随意,包含大量冗余信息,需要模型进行信息提取和分类。

  • 辅助输入(少样本学习) :为了引导模型更好地理解任务,提供 3 条分类示例,如:

    • 示例 1:“我买的手机三天了还没收到,单号查不到,啥情况?” - 分类:物流问题;摘要:用户反映手机未收到且单号查询无果。
    • 示例 2:“刚用的吹风机,突然冒烟了,不敢再用了。” - 分类:产品故障;摘要:用户反馈吹风机使用中冒烟。
    • 示例 3:“买的电脑配置和宣传不符,申请退货。” - 分类:退换货申请;摘要:用户因电脑配置不符申请退货。

2.4 处理流程(工具调用逻辑)

  • 第一步:编写系统 Prompt:“你是电商售后工单分类专家,需完成 2 个任务:1. 将工单分为物流问题 / 产品故障 / 退换货申请三类;2. 生成 50 字内处理摘要,包含核心诉求与关键信息。” 此系统 Prompt 明确了模型的角色和任务范围,为后续处理奠定基础。
  • 第二步:加入少样本示例:将上述 3 条分类示例加入 Prompt 中,让模型通过少样本学习掌握分类和摘要生成的模式与规则,增强模型对任务的理解和适应性。
  • 第三步:输入用户工单文本:将实际的售后工单文本输入给模型,与系统 Prompt 和少样本示例共同构成完整的输入信息,触发模型的处理流程。
  • 第四步:输出结构化结果:模型根据输入信息进行分析处理,输出结构化的结果,格式为 “分类:[具体类别];摘要:[处理摘要]”。整个过程无需对模型进行微调,仅通过精心设计的 Prompt 即可实现高效的任务处理。

2.5 输出与校验

  • 输出格式:“分类:退换货申请;摘要:用户购买衣服尺码不符,昨日收货,需求换货大一码,咨询寄回流程”。这种结构化的输出便于客服人员快速理解工单内容,提高处理效率。

  • 校验标准

    • 分类准确率:通过人工抽样复核 100 条工单,对比模型分类结果与人工标注结果,要求分类准确率达到 95% 以上。
    • 摘要关键信息覆盖率:同样抽样 100 条工单,检查摘要是否涵盖用户核心诉求和关键信息,如问题类型、涉及产品、关键时间等,覆盖率需达到 90% 以上。

三、技术路径对比:不同 Prompt 策略的适用场景与成本分析

3.1 三类主流 Prompt 技术路径对比表

在实际应用中,零样本、少样本和思维链(CoT)这三类 Prompt 技术路径各有优劣,适用于不同的业务场景。下面通过表格对比,我们可以更清晰地了解它们在设计思路、优势、劣势、适用场景以及技术成本等方面的差异。

技术路径 设计思路 优势 劣势 适用场景 技术成本 实现复杂度 落地可行性
零样本 Prompt 仅输入任务描述,无示例 成本最低、无需准备样本、迭代快 准确率低、复杂任务易失控 简单文本生成、基础问答 极低(仅需指令设计) 极高(即写即用)
少样本 Prompt 加入 3-5 个示例引导模型 准确率高于零样本、适配多数场景 需准备标注示例、指令长度受限 文本分类、摘要生成、格式标准化 低(样本标注成本低) 高(中小规模业务首选)
思维链(CoT)Prompt 引导模型分步推理,展示思考过程 适配复杂逻辑任务、推理准确率高 指令设计复杂、token 消耗大、速度慢 数学计算、故障排查、多步骤决策 中(需设计推理框架) 中(适合专业场景)

3.2 技术选型核心原则:成本与效果的平衡

从高层往下看视角看,技术选型需遵循 “低成本优先” 原则:优先用零样本 Prompt 解决简单任务;中等复杂度任务采用少样本 Prompt,以最低标注成本提升准确率;仅复杂推理任务考虑思维链 Prompt,同时需评估 token 消耗带来的算力成本,避免过度设计。在实际应用中,我们要根据任务的复杂度、数据资源、算力成本等多方面因素,综合评估选择最合适的 Prompt 技术路径,以实现最佳的性价比。例如,在一个简单的文本分类任务中,如果使用思维链 Prompt,虽然可能会提高准确率,但由于其指令设计复杂、token 消耗大,会增加不必要的成本,此时选择少样本 Prompt 可能更为合适。

四、Prompt 工程化落地:从 “一次性指令” 到 “可复用架构”

当我们在实际业务中大规模应用 Prompt 技术时,就不能仅仅满足于 “一次性” 的指令设计,而需要从工程化的角度构建一套可复用、可迭代、低成本的 Prompt 架构体系。这不仅关系到开发效率与成本控制,更是决定 AI 应用能否在复杂业务环境中持续稳定运行的关键。

4.1 模块化设计:Prompt 模板化与组件化

从工程实践看,将 Prompt 拆分为多个可复用组件是提高开发效率与灵活性的关键。一个典型的 Prompt 可以拆解为 “角色定义 + 任务指令 + 格式约束 + 示例” 四大组件。以电商客服场景为例,我们可以将 “你是专业电商客服” 这一角色定义固化为通用组件;任务指令部分则根据不同工单类型(如物流咨询、产品售后等)动态替换;格式约束(如 “输出为 JSON 格式”)和示例(如常见问题及解答示例)也可按需调整。通过这种组件化设计,我们可以快速搭建针对不同业务场景的 Prompt,实现跨工单类型的快速适配,大幅降低重复开发成本。这种方式就像是搭积木,每个组件都是一个独立的模块 ,我们可以根据不同的业务需求,灵活地组合这些模块,快速构建出满足需求的 Prompt。在这之后还会专门搭建 Prompt 平台,专门存储和编写 Prompt,一键更新到 AI 应用里面,方便 Prompt 各种环境使用和进行版本管理

4.2 迭代优化:基于输出反馈的指令调优

Prompt 并非一成不变,而是需要根据模型输出结果持续优化。建立 “指令 - 输出 - 反馈 - 优化” 的闭环迭代流程是实现这一目标的核心。例如,在工单分类任务中,如果模型将某个 “产品故障” 工单误分类为 “物流问题”,我们需要深入分析指令设计的漏洞,比如是否存在未覆盖的边缘场景、示例是否足够典型等。

针对这些问题,我们可以补充更多边缘场景的示例,细化分类规则,逐步提高模型的准确率。这种迭代优化的过程就像是对产品进行持续改进,通过不断收集用户反馈,优化产品功能,提升用户体验。

在这里,我想额外问一个问题 在进行 prompt 更新的时候,如何去评判 Prompt 前后两次修改的质量好坏呢? 我列出三个纬度供大家参考

  • 质量维度,能说到重点上吗?
  • 稳定性纬度,每次问都回答一样吗?
  • 正确性纬度,回答的数据正确吗?

4.3 成本控制:减少无效 token 消耗

在实际应用中,token 消耗不单单会影响大模型幻觉,还会直接关系到算力成本,因此从工程化角度优化 token 使用至关重要。首先,要精简指令内容,避免冗长复杂的表述,确保每一个 token 都传递有效信息;

其次,合理利用模型上下文窗口特性,优先保留系统 Prompt 中的核心规则与约束,对用户输入中的冗余信息进行预处理;对于超长文本任务,结合检索增强生成(RAG)技术,将长文本拆分为多个短文本分批次输入,避免一次性输入导致的 token 溢出。这就好比在装修房子时,合理规划空间,避免浪费,让每一寸空间都得到充分利用。通过这些策略,可以在保证模型性能的前提下,有效降低 token 成本,提高应用的性价比。

五、总结与展望:Prompt 工程的现在与趋势

5.1 核心观点总结

Prompt 工程的本质是 “用工程化思维替代感性经验”,核心在于明确角色、拆解任务、约束格式、补充示例,而非依赖模型参数提升。对于多数企业级应用,优质 Prompt 设计带来的效果提升,远高于盲目追求大模型升级的收益。在实际应用中,我们不应过分关注模型的参数规模和性能指标,而应将更多的精力放在如何设计有效的 Prompt 上。通过合理的 Prompt 设计,我们可以引导模型更好地理解任务需求,提高输出的质量和准确性,从而实现更高的性价比。

5.2 当前局限性

现有 Prompt 技术仍存在边界:无法突破模型预训练知识范围,易产生 “幻觉” ;复杂任务的指令设计依赖专业经验;多模态场景下的 Prompt 设计尚未形成标准化方案。例如,当我们询问模型关于未来的科技发展趋势时,由于模型的知识截止于训练时间,它无法提供最新的信息,可能会产生不准确或过时的回答。在多模态场景下,如结合图像和文本的应用中,如何设计有效的 Prompt 以实现多模态信息的融合和交互,仍然是一个待解决的问题。

5.3 目前趋势展望

目前 Prompt 工程将向 “自动化” 与 “融合化” 发展:自动化方面,AI 将自主生成并优化 Prompt,降低人工设计门槛;融合化方面,Prompt 将与 RAG 深度结合,形成 “Prompt+RAG 解决知识时效性的 SOP。随着技术的不断发展,我们可以期待 AI 能够根据用户的需求自动生成和优化 Prompt,进一步提高效率和准确性。Prompt 与其他技术的融合也将为 AI 应用带来更多的可能性,推动 AI 技术在各个领域的深入应用和发展。

感谢观看,欢迎大家点赞关注,下期更精彩!

JavaScript 手写 call、apply、bind:深入理解函数上下文绑定

作者 wuhen_n
2026年2月13日 06:57

当面试官让我们手写 call、apply、bind 时,他们真正考察的是什么?这三个方法看似简单,却隐藏着 JavaScript 函数执行上下文、原型链、参数处理等核心概念。本文将从零实现,并深入理解它们的差异和应用场景。

前言:为什么需要 call、apply、bind?

const obj = {
  name: '张三',
  sayHello() {
    console.log(`你好,我是${this.name}`);
  }
};

const sayHelloFunc = obj.sayHello;
obj.sayHello();     // "你好,我是张三" - 正确
sayHelloFunc();     // "你好,我是undefined" - this丢失了!

上述代码,出现问题根源是:函数的 this 在调用时才确定,取决于调用方式。那如何解决呢?使用call、apply、bind 显式绑定 this 。

call 方法的实现

call 的基本使用

call 方法用于调用一个函数,并显式指定函数的 this 值和参数列表。

function greet(message) {
  console.log(`${message}, ${this.name}!`);
}

const person = { name: 'zhangsan' };

// 原生 call 的使用
greet.call(person, '你好'); // "你好, zhangsan!"

call 的工作原理

  1. 将函数设为对象的属性
  2. 使用该对象调用函数
  3. 删除该属性

基础版本实现

Function.prototype.myCall = function (context, ...args) {
  // 如果context是null或undefined,则绑定到全局对象
  if (context == null) {
    context = globalThis;
  }
  // 给context对象添加一个临时属性,值为当前函数
  const fnKey = Symbol('fn'); // 使用Symbol避免属性名冲突
  context[fnKey] = this; // this指向调用myCall的函数
  // 使用context对象调用函数
  const result = context[fnKey](...args);
  // 删除临时属性
  delete context[fnKey];
  return result;
};

处理边界情况

Function.prototype.myCallEnhanced = function (context, ...args) {
  // 处理undefined和null
  if (context == null) {
    context = globalThis;
  }

  // 原始值需要转换为对象,否则不能添加属性
  const contextType = typeof context;
  if (contextType === 'string' ||
    contextType === 'number' ||
    contextType === 'boolean' ||
    contextType === 'symbol' ||
    contextType === 'bigint') {
    context = Object(context); // 转换为包装对象
  }

  // 使用更安全的Symbol作为key
  const fnKey = Symbol('fn');
  context[fnKey] = this;

  try {
    const result = context[fnKey](...args);
    return result;
  } finally {
    // 确保总是删除临时属性
    delete context[fnKey];
  }
};

完整实现与性能优化

Function.prototype.myCallFinal = function (context = globalThis, ...args) {
  // 1. 类型检查:确保调用者是函数
  if (typeof this !== 'function') {
    throw new TypeError('Function.prototype.myCallFinal called on non-function');
  }

  // 2. 处理Symbol和BigInt(ES6+)
  const contextType = typeof context;
  let finalContext = context;

  // 3. 处理原始值(非严格模式下的自动装箱)
  if (contextType === 'string') {
    finalContext = new String(context);
  } else if (contextType === 'number') {
    finalContext = new Number(context);
  } else if (contextType === 'boolean') {
    finalContext = new Boolean(context);
  } else if (contextType === 'symbol') {
    // Symbol不能通过new创建,使用Object
    finalContext = Object(context);
  } else if (contextType === 'bigint') {
    // BigInt不能通过new创建,使用Object
    finalContext = Object(context);
  }
  // null和undefined已经通过默认参数处理

  // 4. 使用Symbol创建唯一key,避免属性冲突
  const fnSymbol = Symbol('callFn');

  // 5. 将函数绑定到上下文对象
  // 使用Object.defineProperty确保属性可配置
  Object.defineProperty(finalContext, fnSymbol, {
    value: this,
    configurable: true,
    writable: true,
    enumerable: false
  });

  // 6. 执行函数并获取结果
  let result;
  try {
    result = finalContext[fnSymbol](...args);
  } finally {
    // 7. 清理临时属性
    try {
      delete finalContext[fnSymbol];
    } catch (error) {
      // 如果上下文不可配置,忽略错误
      console.warn('无法删除临时属性:', error.message);
    }
  }

  return result;
};

apply 方法的实现

apply 的基本使用

apply 和 call 的功能基本相同,唯一的区别在于参数的传递方式:

  • call 接受参数列表
  • apply 接受参数数组
function sum(a, b, c) {
  return a + b + c;
}
// apply:参数以数组形式传递
sum.apply(null, [1, 2, 3]);

基础版本实现

Function.prototype.myCall = function (context, args) {
  // 如果context是null或undefined,则绑定到全局对象
  if (context == null) {
    context = globalThis;
  }
  // 给context对象添加一个临时属性,值为当前函数
  const fnKey = Symbol('fn'); // 使用Symbol避免属性名冲突
  context[fnKey] = this; // this指向调用myCall的函数
  // 使用context对象调用函数
  const result = context[fnKey](...args);
  // 删除临时属性
  delete context[fnKey];
  return result;
};

完整实现与性能优化

Function.prototype.myApply = function (context = globalThis, argsArray) {
  // 1. 类型检查
  if (typeof this !== 'function') {
    throw new TypeError('Function.prototype.myApply called on non-function');
  }

  // 2. 参数处理:确保argsArray是数组或类数组对象
  let args = [];
  if (argsArray != null) {
    // 检查是否为数组或类数组
    if (typeof argsArray !== 'object' ||
      (typeof argsArray.length !== 'number' && argsArray.length !== undefined)) {
      throw new TypeError('第二个参数必须是数组或类数组对象');
    }

    // 将类数组转换为真实数组
    if (!Array.isArray(argsArray)) {
      args = Array.from(argsArray);
    } else {
      args = argsArray;
    }
  }

  // 3. 使用Symbol作为唯一key
  const fnSymbol = Symbol('applyFn');

  // 4. 处理原始值(与call相同)
  const contextType = typeof context;
  let finalContext = context;

  if (contextType === 'string') {
    finalContext = new String(context);
  } else if (contextType === 'number') {
    finalContext = new Number(context);
  } else if (contextType === 'boolean') {
    finalContext = new Boolean(context);
  } else if (contextType === 'symbol') {
    finalContext = Object(context);
  } else if (contextType === 'bigint') {
    finalContext = Object(context);
  }

  // 5. 绑定函数到上下文
  Object.defineProperty(finalContext, fnSymbol, {
    value: this,
    configurable: true,
    writable: true,
    enumerable: false
  });

  // 6. 执行函数
  let result;
  try {
    result = finalContext[fnSymbol](...args);
  } finally {
    // 7. 清理
    try {
      delete finalContext[fnSymbol];
    } catch (error) {
      // 忽略删除错误
    }
  }

  return result;
};

bind 方法的实现

bind 的基本使用

bind 方法创建一个新的函数,当这个新函数被调用时,它的 this 值会被绑定到指定的对象,并且可以预先传入部分参数。

function greet(greeting, name) {
  console.log(`${greeting}, ${name}! 我是${this.role}`);
}

const context = { role: '管理员' };

// bind:创建新函数,稍后执行
const boundGreet = greet.bind(context, '你好');
boundGreet('李四'); 

bind 的核心特性:

  1. 返回一个新函数
  2. 可以预设参数(柯里化)
  3. 绑定this值
  4. 支持new操作符(特殊情况)

基础版本实现

Function.prototype.myBind = function (context, ...bindArgs) {
  const fn = this;
    return function(...newArgs) {
        return fn.apply(context, [...args, ...newArgs]);
    };
};

处理 new 操作符的特殊情况

Function.prototype.myBindEnhanced = function (context = globalThis, ...bindArgs) {
  const originalFunc = this;

  if (typeof originalFunc !== 'function') {
    throw new TypeError('Function.prototype.myBindEnhanced called on non-function');
  }

  // 内部函数,用于判断是否被new调用
  const boundFunc = function (...callArgs) {
    // 关键判断:this instanceof boundFunc
    // 如果使用new调用,this会是boundFunc的实例
    const isConstructorCall = this instanceof boundFunc;

    // 确定最终的上下文
    // 如果是构造函数调用,使用新创建的对象作为this
    // 否则使用绑定的context
    const finalContext = isConstructorCall ? this : Object(context);

    // 合并参数
    const finalArgs = bindArgs.concat(callArgs);

    // 执行原函数
    // 如果原函数有返回值,需要特殊处理
    const result = originalFunc.apply(finalContext, finalArgs);

    // 构造函数调用的特殊处理
    // 如果原函数返回一个对象,则使用该对象
    // 否则返回新创建的对象(this)
    if (isConstructorCall) {
      if (result && (typeof result === 'object' || typeof result === 'function')) {
        return result;
      }
      return this;
    }

    return result;
  };

  // 维护原型链
  // 方法1:直接设置prototype(有缺陷)
  // boundFunc.prototype = originalFunc.prototype;

  // 方法2:使用空函数中转(推荐)
  const F = function () { };
  F.prototype = originalFunc.prototype;
  boundFunc.prototype = new F();
  boundFunc.prototype.constructor = boundFunc;

  // 添加一些元信息(可选)
  boundFunc.originalFunc = originalFunc;
  boundFunc.bindContext = context;
  boundFunc.bindArgs = bindArgs;

  return boundFunc;
};

完整实现与性能优化

Function.prototype.myBindFinal = (function () {
  // 使用闭包保存Slice方法,提高性能
  const ArraySlice = Array.prototype.slice;

  // 空函数,用于原型链维护
  function EmptyFunction() { }

  return function myBindFinal(context = globalThis, ...bindArgs) {
    const originalFunc = this;

    // 严格的类型检查
    if (typeof originalFunc !== 'function') {
      throw new TypeError('Function.prototype.bind called on non-function');
    }

    // 处理原始值的上下文(非严格模式)
    let boundContext = context;
    const contextType = typeof boundContext;

    // 原始值包装(与call/apply保持一致)
    if (contextType === 'string') {
      boundContext = new String(boundContext);
    } else if (contextType === 'number') {
      boundContext = new Number(boundContext);
    } else if (contextType === 'boolean') {
      boundContext = new Boolean(boundContext);
    } else if (contextType === 'symbol') {
      boundContext = Object(boundContext);
    } else if (contextType === 'bigint') {
      boundContext = Object(boundContext);
    }

    // 创建绑定函数
    const boundFunction = function (...callArgs) {
      // 判断是否被new调用
      const isConstructorCall = this instanceof boundFunction;

      // 确定最终上下文
      let finalContext;
      if (isConstructorCall) {
        // new调用:忽略绑定的context,使用新实例
        finalContext = this;
      } else if (boundContext == null) {
        // 非严格模式:使用全局对象
        finalContext = globalThis;
      } else {
        // 普通调用:使用绑定的context
        finalContext = boundContext;
      }

      // 合并参数
      const allArgs = bindArgs.concat(callArgs);

      // 调用原函数
      const result = originalFunc.apply(finalContext, allArgs);

      // 处理构造函数调用的返回值
      if (isConstructorCall) {
        // 如果原函数返回对象,则使用该对象
        if (result && (typeof result === 'object' || typeof result === 'function')) {
          return result;
        }
        // 否则返回新创建的实例
        return this;
      }

      return result;
    };

    // 维护原型链 - 高性能版本
    // 避免直接修改boundFunction.prototype,使用中间函数
    if (originalFunc.prototype) {
      EmptyFunction.prototype = originalFunc.prototype;
      boundFunction.prototype = new EmptyFunction();
      // 恢复constructor属性
      boundFunction.prototype.constructor = boundFunction;
    } else {
      // 处理没有prototype的情况(如箭头函数)
      boundFunction.prototype = undefined;
    }

    // 添加不可枚举的原始函数引用(用于调试)
    Object.defineProperty(boundFunction, '__originalFunction__', {
      value: originalFunc,
      enumerable: false,
      configurable: true,
      writable: true
    });

    // 添加不可枚举的绑定信息
    Object.defineProperty(boundFunction, '__bindContext__', {
      value: boundContext,
      enumerable: false,
      configurable: true,
      writable: true
    });

    Object.defineProperty(boundFunction, '__bindArgs__', {
      value: bindArgs,
      enumerable: false,
      configurable: true,
      writable: true
    });

    // 设置适当的函数属性
    Object.defineProperty(boundFunction, 'length', {
      value: Math.max(0, originalFunc.length - bindArgs.length),
      enumerable: false,
      configurable: true,
      writable: false
    });

    Object.defineProperty(boundFunction, 'name', {
      value: `bound ${originalFunc.name || ''}`.trim(),
      enumerable: false,
      configurable: true,
      writable: false
    });

    return boundFunction;
  };
})();

面试常见问题与解答

问题1:手写call的核心步骤是什么?

  1. 步骤1: 将函数设为上下文对象的属性
  2. 步骤2: 执行该函数
  3. 步骤3: 删除该属性
  4. 步骤4: 返回函数执行结果
  5. 关键点:
    • 使用Symbol避免属性名冲突
    • 处理null/undefined上下文
    • 处理原始值上下文
    • 使用展开运算符处理参数

问题2:bind如何处理new操作符?

  1. 通过 this instanceof boundFunction 判断是否被new调用
  2. 如果是new调用,忽略绑定的上下文,使用新创建的对象作为this
  3. 需要正确设置boundFunction的原型链,以支持instanceof
  4. 如果原构造函数返回对象,则使用该对象,否则返回新实例

问题3:call、apply、bind的性能差异?

  1. call通常比apply快,因为apply需要处理数组参数
  2. bind创建新函数有开销,但多次调用时比重复call/apply高效

结语

通过深入理解call、apply、bind的实现原理,我们不仅能更好地回答面试问题,还能在实际开发中编写出更优雅、更高效的JavaScript代码。对于文章中错误的地方或者有任何问题,欢迎在评论区留言讨论!

Next.js 16 + Supabase + Vercel:SmartChat 全栈 AI 应用架构实战

作者 梦里寻码
2026年2月13日 01:19

前言

全栈 AI 应用怎么选技术栈?这个问题没有标准答案,但 SmartChat 的选择——Next.js 16 + Supabase + Vercel——是一套经过验证的高效组合。本文从架构角度拆解这套技术栈的设计思路。

🔗 项目地址:smartchat.nofx.asia/

微信图片_20260212194724_46_236.png

一、为什么是 Next.js 16?

SmartChat 使用 Next.js 16 的 App Router,充分利用了以下特性:

Server Components: 仪表盘页面使用 Server Components 直接在服务端查询数据库,减少客户端 JS 体积。

API Routes(Serverless): 所有后端逻辑通过 API Routes 实现,无需维护独立的后端服务。

src/app/api/
├── chat/          # 聊天接口(SSE 流式)
├── bots/          # 机器人 CRUD
├── upload/        # 文档上传与向量化
└── conversations/ # 对话管理

Turbopack: 开发环境使用 Turbopack,热更新速度显著提升。

关键优势:前后端同仓库、同语言(TypeScript)、同部署,极大降低了开发和运维复杂度。

二、Supabase:不只是数据库

SmartChat 用 Supabase 承担了多个角色:

2.1 PostgreSQL 数据库 + pgvector

-- 业务数据和向量数据在同一个库
CREATE TABLE document_chunks (
  id uuid PRIMARY KEY,
  bot_id uuid REFERENCES bots(id) ON DELETE CASCADE,
  content text,
  embedding vector(512),  -- pgvector 向量列
  metadata jsonb
);

-- IVFFlat 索引加速向量检索
CREATE INDEX ON document_chunks
  USING ivfflat (embedding vector_cosine_ops)
  WITH (lists = 100);

2.2 用户认证(Supabase Auth)

内置邮箱/密码、OAuth 登录,无需自建认证系统。

2.3 行级安全(RLS)

-- 用户只能访问自己的机器人
CREATE POLICY "Users can only access own bots" ON bots
  FOR ALL USING (auth.uid() = user_id);

-- 匿名访客可以查看公开的机器人配置
CREATE POLICY "Public bot access" ON bots
  FOR SELECT USING (is_public = true);

RLS 确保了多租户数据隔离,同时通过 Service Role 客户端允许匿名访客与机器人对话。

2.4 文件存储

文档上传使用 Supabase Storage,统一管理。

SmartChat Dashboard转存失败,建议直接上传图片文件

三、可嵌入组件设计

SmartChat 的一大亮点是一行代码嵌入任何网站

<script src="https://your-domain.com/embed.js" data-bot-id="xxx"></script>

实现原理:

// embed.js 核心逻辑
(function() {
  const botId = document.currentScript.getAttribute('data-bot-id');

  // 创建 iframe 容器
  const iframe = document.createElement('iframe');
  iframe.src = `https://your-domain.com/chat/${botId}?embed=true`;
  iframe.style.cssText = 'position:fixed;bottom:20px;right:20px;...';

  // 创建触发按钮
  const button = document.createElement('div');
  button.onclick = () => iframe.classList.toggle('visible');

  document.body.appendChild(button);
  document.body.appendChild(iframe);
})();

通过 iframe 隔离样式和脚本,避免与宿主网站冲突。聊天界面的颜色、头像、欢迎语都可以在后台自定义。

微信图片_20260212194756_49_236.png

微信图片_20260212194810_51_236.png

四、SSE 流式响应架构

SmartChat 使用 Server-Sent Events 实现实时流式输出:

// API Route: /api/chat
export async function POST(req: Request) {
  const { message, botId, conversationId } = await req.json();

  // 1. 向量检索相关文档
  const relevantDocs = await searchDocuments(message, botId);

  // 2. 构建带上下文的 Prompt
  const messages = await buildMessages(conversationId, relevantDocs);

  // 3. 流式调用 LLM
  const stream = await streamChat({ provider, model, messages });

  // 4. 返回 SSE 流
  return new Response(
    new ReadableStream({
      async start(controller) {
        let fullResponse = '';
        for await (const chunk of stream) {
          const text = extractText(chunk);
          fullResponse += text;
          controller.enqueue(`data: ${JSON.stringify({ text })}\n\n`);
        }
        // 5. 流结束后,附加来源信息
        controller.enqueue(`data: ${JSON.stringify({
          sources: relevantDocs
        })}\n\n`);
        controller.close();

        // 6. 异步保存完整回复到数据库
        await saveMessage(conversationId, fullResponse, relevantDocs);
      }
    }),
    { headers: { 'Content-Type': 'text/event-stream' } }
  );
}

流程:向量检索 → 构建 Prompt → 流式生成 → 实时推送 → 保存记录。

五、Vercel 一键部署

SmartChat 的 Serverless 架构天然适合 Vercel 部署:

  • 零服务器管理:API Routes 自动变成 Serverless Functions
  • 全球 CDN:静态资源自动分发
  • 自动扩缩容:流量高峰自动扩容,空闲时零成本
  • 环境变量管理:在 Vercel Dashboard 配置 API Keys 等敏感信息

部署流程:Fork 仓库 → 连接 Vercel → 配置环境变量 → 部署完成。

六、性能优化要点

  • React 19 + Turbopack:开发体验和构建速度大幅提升
  • Server Components:减少客户端 JS 体积
  • 流式渲染:用户无需等待完整回复
  • IVFFlat 索引:向量检索毫秒级响应
  • 批量写入:文档分块后每 10 条一批插入,避免超时

总结

Next.js + Supabase + Vercel 这套组合的核心优势是简单:一个仓库、一种语言、一键部署。对于中小团队做 AI 应用,这可能是目前投入产出比最高的技术栈选择。SmartChat 是这套架构的一个完整实践案例。

🔗 项目地址:smartchat.nofx.asia/,MIT 开源协议,支持一键部署到 Vercel。

文件16进制查看器核心JS实现

作者 滕青山
2026年2月13日 00:45

文件16进制查看器核心JS实现

本文将介绍基于 Vue 3 和 Nuxt 3 实现的“文件16进制查看器”的核心技术方案。该工具主要用于在浏览器端直接查看任意文件(包括二进制文件)的十六进制编码,所有文件处理均在前端完成,不涉及后端上传。

在线工具网址:see-tool.com/file-hex-vi…
工具截图:
在这里插入图片描述

1. 核心工具函数 (utils/file-hex-viewer.js)

我们将核心的文件处理和格式化逻辑封装在 utils/file-hex-viewer.js 中,主要包括文件大小格式化、二进制转换十六进制字符串以及文件名生成。

1.1 文件大小格式化 (formatFileSize)

用于将字节数转换为人类可读的格式(如 KB, MB)。

export function formatFileSize(bytes, units = ['Bytes', 'KB', 'MB', 'GB', 'TB']) {
  if (!Number.isFinite(bytes) || bytes < 0) return `0 ${units[0] || 'Bytes'}`
  if (bytes === 0) return `0 ${units[0] || 'Bytes'}`

  const k = 1024
  const index = Math.floor(Math.log(bytes) / Math.log(k))
  const value = Math.round((bytes / Math.pow(k, index)) * 100) / 100
  const unit = units[index] || units[units.length - 1] || 'Bytes'
  return `${value} ${unit}`
}

1.2 二进制转十六进制 (bytesToHex)

这是本工具的核心转换函数。它接收一个 Uint8Array,并根据传入的 format 参数(支持 spacenospaceuppercase)生成对应的十六进制字符串。对于 space 格式,每16个字节会自动换行,方便阅读。

export function bytesToHex(uint8Array, format = 'space') {
  if (!uint8Array || !uint8Array.length) return ''
  const useUppercase = format === 'uppercase'
  const useSpace = format === 'space'
  let hexString = ''

  for (let i = 0; i < uint8Array.length; i++) {
    // 将每个字节转换为2位十六进制字符串
    let hex = uint8Array[i].toString(16).padStart(2, '0')
    
    if (useUppercase) {
      hex = hex.toUpperCase()
    }
    
    if (useSpace) {
      hexString += `${hex} `
      // 每16个字节插入一个换行符
      if ((i + 1) % 16 === 0) {
        hexString += '\n'
      }
    } else {
      hexString += hex
    }
  }

  return hexString.trim()
}

1.3 导出文件名生成 (buildHexFileName)

根据原文件名和当前的格式设置,生成导出文件的名称(后缀为 .hex.HEX)。

export function buildHexFileName(originalName, format = 'space') {
  if (!originalName) return `file${format === 'uppercase' ? '.HEX' : '.hex'}`
  const lastDot = originalName.lastIndexOf('.')
  const baseName = lastDot > 0 ? originalName.slice(0, lastDot) : originalName
  const extension = format === 'uppercase' ? '.HEX' : '.hex'
  return `${baseName}${extension}`
}

2. 文件读取与处理逻辑

在前端实现十六进制查看器的核心是利用 HTML5 的 FileReader API 读取文件内容为 ArrayBuffer,然后转换为 Uint8Array 进行处理。

const processFile = (file) => {
  const reader = new FileReader()
  
  reader.onload = (event) => {
    try {
      const buffer = event.target.result
      const bytes = new Uint8Array(buffer)
      // 调用工具函数生成 Hex 字符串
      const hex = bytesToHex(bytes, 'space') 
      // 更新视图...
    } catch (error) {
      console.error('Process failed:', error)
    }
  }
  
  reader.onerror = () => {
    console.error('Read error')
  }
  
  // 读取文件为 ArrayBuffer
  reader.readAsArrayBuffer(file)
}

3. 导出与下载功能

为了让用户可以将十六进制编码保存到本地,我们利用 Blob 对象和 URL.createObjectURL 创建临时的下载链接,实现纯前端下载。

const downloadHexFile = (hexContent, originalName, format) => {
  if (!hexContent) return

  const fileName = buildHexFileName(originalName, format)
  // 创建包含 Hex 内容的 Blob
  const blob = new Blob([hexContent], { type: 'text/plain' })
  const url = URL.createObjectURL(blob)
  
  // 创建临时链接并触发下载
  const link = document.createElement('a')
  link.href = url
  link.download = fileName
  document.body.appendChild(link)
  link.click()
  
  // 清理
  document.body.removeChild(link)
  URL.revokeObjectURL(url)
}

总结

该方案的核心在于通过 utils/file-hex-viewer.js 封装纯粹的格式化和转换逻辑,并结合浏览器原生的 FileReaderBlob API 完成文件的读取与导出,实现了一个轻量级且高效的纯前端文件十六进制查看工具。

Vben Admin管理系统集成微前端wujie-(三)终

作者 go_caipu
2026年2月12日 22:56
  1. # Vben Admin管理系统集成qiankun微服务(一)
  2. # Vben Admin管理系统集成qiankun微服务(二)

一、前言

本篇是vben前端框架集成微服务的第3篇,前段时间写了vue-vben-admin集成qiankun的两篇文章,收到了大家不少建议,文章还遗留了一个问题就是多tab标签不支持状态保持,借助AI虽然也实现的相应方案,但是对vben的package包修改内容较多(后续同步主框架较为繁琐),并且修改代码健状性不好评估。抱歉暂停了进一步完善实现方案,目前先保持基本功能是ok。

近期也尝试wujie微前端框架发现能满足我当前的所有诉求,所以有了本篇的文章内容,前两篇文章的功能和问题在本文中都已支持,选择wujie原因是支持以下两个功能:

  • 天然支持保活模式alive=true,与vben中route中Keeplive参数绑定,能支持状态保持的配置。
  • wujie实现逻辑是iframe框架模式,对子应改造较小,如果不要支持主应用传参子应用可以不用改造或少量改造。

下面分步实施集成功能:

二、主应用调整

1.安装wujie和wujie-vue3

# 安装wujie
pnpm i wujie
# 安装wujie-vue3
pnpm i wujie-vue3

2. 清除沙箱数据实现

主应用src下添加wujie文件夹并添加index.ts文件,两个函数实现功能是清理沙箱缓存数据,保证在”退出登录重新打开“样式不会异常,refreshApp函数为后续单个页签关闭提供备用支持。 index.ts,文件内容如下:

interface HTMLIframeElementWithContentWindow extends HTMLIFrameElement {
  contentWindow: Window;
}

// refreshApp 主应用可以通过下述方法,主动清除指定子应用的沙箱缓存
const refreshApp = (name = '') => {
  if (!name) {
    console.error('refreshApp方法必须传入子应用的name属性');
    return;
  }

  // 这里的window应该是顶级窗口,也就是主应用的window
  const SUB_FRAME = window.document.querySelector(
    `iframe[name=${name}]`,
  ) as HTMLIframeElementWithContentWindow;

  if (!SUB_FRAME) {
    console.warn(`未找到${name}子应用,跳过刷新`);
    return;
  }

  const SUB_WINDOW = SUB_FRAME.contentWindow;
  const SUB_IDMAP = SUB_WINDOW.__WUJIE?.inject?.idToSandboxMap; // 沙箱Map对象
  SUB_IDMAP.clear();
};

// 主应用中清除所有已激活的子应用沙箱缓存
const refreshAllApp = () => {
  // 找到所有无界子应用的iframe
  const ALL_SUB_IFRAME = window.document.querySelectorAll(
    'iframe[data-wujie-flag]',
  );

  if (ALL_SUB_IFRAME.length === 0) {
    console.warn('未找到任何子应用,跳过刷新');
    return;
  }

  // 拿到这些iframe里面的contentWindow
  const ALL_SUB_WINDOW = [...ALL_SUB_IFRAME].map(
    (v) => (v as HTMLIframeElementWithContentWindow).contentWindow,
  );

  // 依次执行清除
  ALL_SUB_WINDOW.forEach((v) => v.__WUJIE?.inject?.idToSandboxMap?.clear());
};

export { refreshAllApp, refreshApp };

主应用/src/layouts/basic.vue 程序主界面,在头部引入上述文件并在相应位置调用清除沙箱方法

# 引用
import { refreshAllApp } from '#/wujie/index';

# 退出时清理
// logout
async function handleLogout() {
  await authStore.logout(false);
  refreshAllApp();
}

3. 添加微服务通用页面wujie.vue

在主应用 /apps/web-caipu/src/views/_core下添加wujie.vue页面,页面内容如:

<script setup lang="ts">
import { ref } from 'vue';
import { useRoute } from 'vue-router';

import { preferences } from '@vben/preferences';
import { useAccessStore, useUserStore } from '@vben/stores';

import WujieVue from 'wujie-vue3';

const useStore = useUserStore();
const accessStore = useAccessStore();
const route = useRoute();

// props通信
const props = ref({
  userinfo: useStore.userInfo,
  token: accessStore.accessToken,
  preferences,
});
// 加时缀是强制刷新
const appUrl = ref(`http://localhost:5667/app${route.path}?t=${Date.now()}`);
const keepLive = route.meta?.keepAlive;
</script>
<template>
  <div class="sub-app-container">
    <WujieVue
      width="100%"
      height="100%"
      :name="appUrl"
      :url="appUrl"
      :alive="keepLive"
      :props="props"
    />
  </div>
</template>
<style scoped>
.sub-app-container {
  width: 100%;
  height: 100%;
  background: white;
}
</style>

<style scoped>
.sub-app-container {
  width: 100%;
  height: 100%;
  overflow: hidden;
  background: white;
  border-radius: 8px;
}
</style>

聪明的你,一定知道实现的逻辑。其中子应用的地址测试写localhost:5667,后面会集成配置文件中,至此主应用改造完成。

三、子应用改造

子应用基本不用改,只要改/Users/wgh/code/caipu-vben-admin/apps-micro/web-antd/src/bootstrap.ts文件即可

image.png 在49行添加如下代码,代码不用解释,之前一样的实现逻辑。


 // 初使化存储之后赋值,避免路由判断跳转到登录页
  if (window.__POWERED_BY_WUJIE__) {
    // props 接收
    const props = window.$wujie?.props; // {data: xxx, methods: xxx}
    const useStore = useUserStore();
    const accessStore = useAccessStore();
    useStore.setUserInfo(props.userInfo);
    accessStore.setAccessToken(props.token);
    updatePreferences(props.preferences);
    // window.$wujie?.bus.$on('wujie-theme-update', (theme: any) => {
    //   alert('wujie-theme-update');
    //   updatePreferences(theme);
    // });
    window.addEventListener('wujie-theme-update', (theme: any) => {
      updatePreferences(theme.detail);
    });
  }

四。新增路由配置

在主应用路由中配置子应用一个测试路由 /app/basic/test,

image.png

为测试在子应用状态保持,我在页面中添加一个测试文本框 ,测试内容不会随着切tab页签而重新加载,浏览器的前进后退也不会出错。

image.png

上述功能已集成在前端程序里,如果我的文章对你有帮助,感谢给我点个🌟

Anthony Fu 的 Vue3 开发规范完整解读

作者 扉川川
2026年2月12日 21:59

Anthony Fu 的 Vue3 开发规范完整解读

本文基于 antfu/skills 仓库整理翻译,全面解析 Anthony Fu 在 Vue 3 生态中的编码规范、最佳实践和工具链推荐。作为 Vue 核心团队成员、Vite 团队成员以及众多开源项目的作者(VueUse、UnoCSS、Vitest、Slidev 等),Anthony 的开发理念深刻影响了现代 Vue 开发生态。

第一部分:编码实践与工具链

代码组织原则

单一职责原则

保持文件和函数专注于单一职责。当文件超过 200-300 行时,考虑拆分:

// ❌ 避免:一个文件包含所有逻辑
// UserManager.ts (800 lines)
export class UserManager {
  validateUser() { /* 50 lines */ }
  fetchUserData() { /* 100 lines */ }
  updateUserProfile() { /* 150 lines */ }
  // ...
}

// ✅ 推荐:按职责拆分
// validation.ts
export function validateUser(user: User) { /* ... */ }

// api.ts
export function fetchUserData(id: string) { /* ... */ }

// profile.ts
export function updateUserProfile(data: ProfileData) { /* ... */ }

类型与常量分离

// types.ts
export interface User {
  id: string
  name: string
  role: UserRole
}

export type UserRole = 'admin' | 'user' | 'guest'

// constants.ts
export const DEFAULT_PAGE_SIZE = 20
export const MAX_RETRIES = 3
export const API_ENDPOINTS = {
  users: '/api/users',
  posts: '/api/posts',
} as const

// user-service.ts
import type { User, UserRole } from './types'
import { API_ENDPOINTS } from './constants'

export async function fetchUsers(): Promise<User[]> {
  const response = await fetch(API_ENDPOINTS.users)
  return response.json()
}

运行时环境标注

编写同构代码时,为环境特定的代码添加明确的注释:

// ✅ 明确标注环境依赖
// @env browser
export function getWindowSize() {
  return {
    width: window.innerWidth,
    height: window.innerHeight,
  }
}

// @env node
export function readConfigFile() {
  return fs.readFileSync('./config.json', 'utf-8')
}

// ✅ 同构代码无需标注
export function formatDate(date: Date): string {
  return date.toISOString()
}

TypeScript 最佳实践

显式返回类型

// ❌ 避免:隐式返回类型
export function calculateTotal(items) {
  return items.reduce((sum, item) => sum + item.price, 0)
}

// ✅ 推荐:显式返回类型
export function calculateTotal(items: Item[]): number {
  return items.reduce((sum, item) => sum + item.price, 0)
}

// ✅ 复杂类型提取为类型别名
export type AsyncResult<T> = Promise<{ data: T; error: null } | { data: null; error: Error }>

export function fetchData<T>(url: string): AsyncResult<T> {
  // ...
}

避免复杂内联类型

// ❌ 避免:复杂内联类型
function processUsers(
  users: Array<{
    id: string
    profile: {
      name: string
      email: string
      settings: {
        theme: 'light' | 'dark'
        notifications: boolean
      }
    }
  }>
) {
  // ...
}

// ✅ 推荐:提取类型定义
interface UserSettings {
  theme: 'light' | 'dark'
  notifications: boolean
}

interface UserProfile {
  name: string
  email: string
  settings: UserSettings
}

interface User {
  id: string
  profile: UserProfile
}

function processUsers(users: User[]) {
  // ...
}

注释哲学

解释"为什么"而非"怎么做"

// ❌ 避免:无意义的注释
// 循环遍历用户数组
users.forEach(user => {
  // 打印用户名
  console.log(user.name)
})

// ✅ 推荐:解释为什么这样做
// 使用 setTimeout 0 延迟执行,确保 DOM 更新完成后再计算高度
setTimeout(() => {
  const height = element.offsetHeight
}, 0)

// ✅ 解释非直观的业务逻辑
// 价格计算需要先扣除折扣,再加税费,顺序不能颠倒
// 因为税费基于折后价计算(符合当地税法要求)
const finalPrice = (price - discount) * (1 + taxRate)

测试规范(Vitest)

文件组织

src/
  utils/
    format.ts          # 源代码
    format.test.ts     # 测试文件
  components/
    Button.vue
    Button.test.ts

测试结构

// format.test.ts
import { describe, it, expect } from 'vitest'
import { formatCurrency, formatDate } from './format'

describe('formatCurrency', () => {
  it('should format USD correctly', () => {
    expect(formatCurrency(1234.56, 'USD')).toBe('$1,234.56')
  })

  it('should handle zero', () => {
    expect(formatCurrency(0, 'USD')).toBe('$0.00')
  })

  it('should round to 2 decimal places', () => {
    expect(formatCurrency(1.234, 'USD')).toBe('$1.23')
  })
})

describe('formatDate', () => {
  it('should match snapshot', () => {
    const date = new Date('2024-01-15T10:30:00Z')
    expect(formatDate(date)).toMatchSnapshot()
  })
})

工具链速查

@antfu/ni - 通用包管理器命令
命令 npm yarn pnpm bun
ni npm install yarn install pnpm install bun install
nr dev npm run dev yarn run dev pnpm run dev bun run dev
nu npm update yarn upgrade pnpm update bun update
nun lodash npm uninstall lodash yarn remove lodash pnpm remove lodash bun remove lodash
nci npm ci yarn install --frozen-lockfile pnpm install --frozen-lockfile bun install --frozen-lockfile
nlx vitest npx vitest yarn dlx vitest pnpm dlx vitest bunx vitest
TypeScript 配置标准
{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "lib": ["ESNext", "DOM", "DOM.Iterable"],
    "jsx": "preserve",
    "strict": true,
    "skipLibCheck": true,
    "resolveJsonModule": true,
    "esModuleInterop": true,
    "isolatedModules": true,
    "noUncheckedIndexedAccess": true,
    "paths": {
      "~/*": ["./src/*"]
    }
  }
}

关键配置说明:

  • moduleResolution: "bundler" - 适配 Vite/Rollup 等现代打包工具
  • noUncheckedIndexedAccess: true - 索引访问返回 T | undefined,更安全
  • strict: true - 启用所有严格类型检查
ESLint 配置
# 安装
pnpm add -D @antfu/eslint-config eslint

# 运行
pnpm run lint --fix
// eslint.config.js
import antfu from '@antfu/eslint-config'

export default antfu({
  vue: true,
  typescript: true,
  formatters: {
    css: true,
    html: true,
    markdown: true,
  },
})
Git Hooks 配置
# 安装
pnpm add -D simple-git-hooks lint-staged
// package.json
{
  "simple-git-hooks": {
    "pre-commit": "pnpm lint-staged"
  },
  "lint-staged": {
    "*.{js,ts,vue}": "eslint --fix"
  }
}
pnpm Catalogs 最佳实践
# pnpm-workspace.yaml
catalogs:
  # 生产依赖
  prod:
    vue: ^3.5.0
    pinia: ^2.2.0
  
  # 内联依赖(会被打包)
  inlined:
    lodash-es: ^4.17.21
  
  # 开发依赖
  dev:
    vitest: ^2.0.0
    typescript: ^5.6.0
  
  # 前端特定依赖
  frontend:
    unocss: ^0.63.0
// package.json
{
  "dependencies": {
    "vue": "catalog:prod",
    "lodash-es": "catalog:inlined"
  },
  "devDependencies": {
    "vitest": "catalog:dev",
    "unocss": "catalog:frontend"
  }
}

第二部分:Vue 3 核心规范

基于 Vue 3.5,优先使用 TypeScript 和 <script setup>

偏好设定

场景 推荐方案 原因
语言选择 TypeScript 类型安全、更好的 IDE 支持
脚本格式 <script setup lang="ts"> 更简洁的语法、更好的性能
响应式选择 shallowRef > ref 大多数场景足够用,性能更好
API 风格 Composition API 更好的逻辑复用和类型推导
Props 解构 ❌ 不推荐 会丢失响应式

标准组件模板

<script setup lang="ts">
import { computed, ref, watch, onMounted } from 'vue'
import type { ComponentPublicInstance } from 'vue'

// Props 定义
interface Props {
  title: string
  count?: number
  disabled?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  count: 0,
  disabled: false,
})

// Emits 定义
interface Emits {
  update: [value: number]
  submit: [data: { name: string }]
}

const emit = defineEmits<Emits>()

// Model 双向绑定
const modelValue = defineModel<string>({ required: true })

// 响应式状态
const isLoading = ref(false)
const items = ref<Item[]>([])

// 计算属性
const displayTitle = computed(() => {
  return props.disabled ? `${props.title} (已禁用)` : props.title
})

// 侦听器
watch(() => props.count, (newVal, oldVal) => {
  console.log(`Count changed from ${oldVal} to ${newVal}`)
})

// 生命周期
onMounted(() => {
  console.log('Component mounted')
})

// 方法
function handleClick() {
  emit('update', props.count + 1)
}

// 暴露给父组件(defineExpose)
defineExpose({
  focus: () => {
    // 暴露的方法
  },
})
</script>

<template>
  <div>
    <h1>{{ displayTitle }}</h1>
    <button @click="handleClick" :disabled="disabled">
      Count: {{ count }}
    </button>
  </div>
</template>

<style scoped>
/* scoped 样式 */
</style>

关键导入速查

// 核心响应式 API
import {
  ref,           // 深层响应式
  shallowRef,    // 浅层响应式(推荐)
  reactive,      // 深层响应式对象
  shallowReactive, // 浅层响应式对象
  readonly,      // 只读代理
  computed,      // 计算属性
  watch,         // 侦听器
  watchEffect,   // 副作用侦听器
} from 'vue'

// 生命周期钩子
import {
  onMounted,
  onUpdated,
  onUnmounted,
  onBeforeMount,
  onBeforeUpdate,
  onBeforeUnmount,
} from 'vue'

// 组件通信
import {
  defineProps,
  defineEmits,
  defineModel,
  defineExpose,
  defineSlots,
  provide,
  inject,
} from 'vue'

// 工具函数
import {
  nextTick,      // 等待 DOM 更新
  toRef,         // 转换为 ref
  toRefs,        // 解构保持响应式
  unref,         // 解包 ref
  isRef,         // 判断是否 ref
  markRaw,       // 标记为非响应式
} from 'vue'

// 类型工具
import type {
  Ref,
  ComputedRef,
  ComponentPublicInstance,
  PropType,
} from 'vue'

ref vs shallowRef 性能对比

// ref - 深层响应式(递归代理所有层级)
const deepState = ref({
  user: {
    profile: {
      name: 'John',
      settings: {
        theme: 'dark',
      },
    },
  },
})

// 任何层级的修改都会触发响应
deepState.value.user.profile.settings.theme = 'light' // ✅ 响应式

// shallowRef - 浅层响应式(只代理第一层)
const shallowState = shallowRef({
  user: {
    profile: {
      name: 'John',
      settings: {
        theme: 'dark',
      },
    },
  },
})

// 只有整体替换才会触发响应
shallowState.value.user.profile.settings.theme = 'light' // ❌ 不会触发
shallowState.value = { ...shallowState.value } // ✅ 触发响应

// 性能建议:大部分场景使用 shallowRef 足够

第三部分:Vue 3 最佳实践与常见陷阱

响应式系统

问题 建议
ref vs reactive 如何选择? 优先使用 refref 可以存储任何类型,而 reactive 只能用于对象。ref 在重新赋值时保持响应性,reactive 不行。
什么时候用 shallowRef? 存储大型数据结构(如长列表、复杂嵌套对象)时,用 shallowRef 避免深层代理的性能开销。更新时需要整体替换对象。
如何阻止对象变成响应式? 使用 markRaw()。例如存储第三方库实例(Chart.js、Monaco Editor)时,避免不必要的代理。
多次修改 ref 会触发多次渲染吗? 不会。Vue 会将同一 tick 内的更新批量处理。如需立即看到 DOM 变化,使用 await nextTick()
ref 解包规则是什么? 在模板中自动解包({{ count }})。在 reactive 对象中自动解包(state.count)。在数组和 Map/Set 中不解包(需要 .value)。
解构 props 会丢失响应性吗? 是的。使用 toRefs(props)toRef(props, 'key') 保持响应性,或在 computed/watch 中访问 props.xxx

计算属性

问题 建议
计算属性可以有副作用吗? 不应该。计算属性应该是纯函数,只做计算和返回值。副作用应该放在 watchwatchEffect 中。
为什么计算属性是只读的? 默认只读,但可以提供 setter。推荐只读设计,修改应通过源数据。
计算属性什么时候重新计算? 只有当依赖的响应式数据变化时才重新计算(懒执行 + 缓存)。这是相比 method 的主要优势。
计算属性的条件依赖如何工作? 只追踪当前执行分支的依赖。if (flag) return a 时只追踪 flaga,不追踪 else 分支。
计算属性内使用 array.map 有性能问题吗? 有。每次重新计算都会创建新数组。考虑使用 shallowRef 存储映射结果,或在 watch 中手动更新。

侦听器

问题 建议
watch 的 getter 函数是什么? watch(() => obj.count, ...) 中的箭头函数。推荐用 getter 而非直接传对象,可以精确控制依赖。
deep: true 有性能问题吗? 有。深度侦听需要遍历对象的所有属性。只在必要时使用,或用 getter 函数精确指定依赖。
immediate: true 的执行时机是什么? 立即执行一次,此时 DOM 可能未挂载。需要访问 DOM 时注意判断。
flush 选项有什么区别? pre(默认):DOM 更新前执行。post:DOM 更新后执行。sync:同步执行(避免使用)。
如何在侦听器中访问旧值? watch(source, (newVal, oldVal) => {})。注意对象类型的 oldValnewVal 可能指向同一个引用。
watch vs watchEffect 如何选择? watchEffect:自动追踪依赖,简洁。watch:显式指定侦听源,可访问旧值,更精确。

组件通信

问题 建议
可以修改 props 吗? 不可以。Props 是单向数据流,只读。需要修改时,emit 事件或使用 defineModel
自定义事件会冒泡吗? 不会。Vue 的自定义事件不像原生 DOM 事件那样冒泡,只触发直接父组件的监听器。
组件名应该用什么格式? PascalCase(MyComponent.vue)。在模板中可以用 <MyComponent><my-component>,推荐前者。
defineExpose 何时使用? 当需要父组件通过 ref 调用子组件方法时。默认 <script setup> 不暴露任何内容。
如何获取组件实例的类型? InstanceType<typeof MyComponent>,配合 ref<InstanceType<typeof MyComponent>>()

Props 与 Emits

问题 建议
Boolean props 的转换规则? <MyComp disabled> 等价于 :disabled="true"<MyComp> 则是 undefined(除非有默认值)。
解构 props 会丢失响应性吗? 是的。const { title } = defineProps() 会丢失响应性。使用 toRefs 或直接访问 props.title
props 命名约定是什么? JS 中用 camelCase,HTML 中用 kebab-case。defineProps<{ userName: string }>()<Comp user-name="John">
emit 事件命名约定? JS 中用 camelCase,HTML 中用 kebab-case。emit('updateValue')@update-value="handler"
defineModel 的优势是什么? 简化 v-model 实现,自动生成 prop 和 emit。支持修饰符(.trim.number 等)。

模板语法

问题 建议
v-html 安全吗? 不安全。可能导致 XSS 攻击。只用于可信内容,或使用 DOMPurify 等库清理。
v-if 和 v-for 能同时用吗? Vue 3 中 v-if 优先级高于 v-for,但不推荐同时使用。应该用 computed 过滤或嵌套 template。
v-if vs v-show 如何选择? v-if:条件渲染,切换开销高。v-show:CSS 切换,初始渲染开销高。频繁切换用 v-show
key 的作用是什么? 帮助 Vue 识别节点,优化 diff 算法。列表渲染必须提供唯一 key,避免用 index。
如何绑定多个属性? v-bind="attrs" 可以一次性绑定对象的所有属性。例如 v-bind="{ id: 'foo', class: 'bar' }"

表单与 v-model

问题 建议
defineModel 的修饰符如何使用? 内置 .trim.number.lazy。自定义修饰符通过 defineModel 的第二个参数处理。
v-model 在组件上的原理? 语法糖::modelValue="value" @update:modelValue="value = $event"。多个 v-model:v-model:title
如何在 v-model 更新后访问 DOM? 使用 await nextTick(),因为 Vue 异步更新 DOM。
textarea 的 v-model 和插值的区别? <textarea v-model="text"> 正确。<textarea>{{ text }}</textarea> 不生效,textarea 不支持插值。

事件处理与修饰符

问题 建议
.once 修饰符如何工作? 事件只触发一次后自动移除监听器。@click.once="handler"
.exact 修饰符的作用? 精确匹配修饰键。@click.ctrl.exact 只在按下 Ctrl(无其他键)时触发。
.passive 和 .prevent 冲突吗? 冲突。.passive 告诉浏览器不调用 preventDefault(),两者不能同时使用。
自定义事件可以用修饰符吗? 可以,但需要在子组件中通过 defineEmits 的第二个参数手动实现验证逻辑。

生命周期

问题 建议
生命周期钩子必须同步注册吗? 是的。必须在 setup<script setup> 的同步代码中调用,不能在 setTimeoutasync 函数中。
onUpdated 钩子性能如何? 会在任何响应式数据变化导致的重新渲染后调用,可能频繁触发。谨慎使用,考虑用 watch 替代。
如何在组件外部注册生命周期? 使用 effectScope 创建作用域,在其中注册钩子。

插槽

问题 建议
插槽的作用域是什么? 默认插槽只能访问父组件的数据。作用域插槽通过 v-slot="slotProps" 接收子组件传递的数据。
defineSlots 的作用? 仅用于类型定义,帮助 TypeScript 推导插槽的 props 类型。不影响运行时。
插槽的 fallback content 是什么? <slot>默认内容</slot> 中的默认内容,当父组件不提供插槽内容时显示。
动态插槽名如何使用? v-slot:[dynamicSlotName]#[dynamicSlotName]

Provide / Inject

问题 建议
应该用什么作为 injection key? 使用 Symbol 而非字符串,避免命名冲突。export const userKey = Symbol('user')
注入的数据可以修改吗? 可以,但建议 mutations 集中在 provider 组件,通过提供修改方法而非直接暴露响应式状态。
如何为 inject 提供类型? const user = inject<User>(userKey) 或在定义 key 时指定 InjectionKey<User>

组合式函数

问题 建议
命名约定是什么? use 开头,camelCase。例如 useMouseuseFetch
返回值应该是什么? 返回包含响应式状态和方法的对象。使用 readonly() 保护内部状态。
何时使用 options 对象模式? 参数超过 2 个时推荐。useFetch(url, { method, headers, onSuccess })
组合式函数可以嵌套调用吗? 可以。一个组合式函数可以调用其他组合式函数。
// 示例:标准组合式函数
export function useMouse() {
  const x = ref(0)
  const y = ref(0)

  function update(event: MouseEvent) {
    x.value = event.clientX
    y.value = event.clientY
  }

  onMounted(() => window.addEventListener('mousemove', update))
  onUnmounted(() => window.removeEventListener('mousemove', update))

  return {
    x: readonly(x),
    y: readonly(y),
  }
}

Composition API

问题 建议
为什么用 Composition API 替代 mixin? Mixin 有命名冲突、来源不清晰、难以重用等问题。Composition API 通过函数组合解决这些问题。
Composition API 和 React Hooks 有什么区别? Vue 的 setup 只执行一次,不受闭包陷阱影响。React Hooks 每次渲染都执行,需要依赖数组。
何时仍然使用 Options API? 简单组件、团队不熟悉 Composition API、维护老代码时可以使用 Options API。

自定义指令

问题 建议
必须清理副作用吗? 是的。在 unmounted 钩子中清理事件监听器、定时器等,避免内存泄漏。
指令命名约定? v 开头。注册时用 camelCase(vFocus),使用时用 kebab-case(v-focus)。
可以在组件上使用指令吗? 可以,但不推荐。指令会应用到组件的根元素,多根元素组件会报警告。

过渡与动画

问题 建议
Transition 只能包含单个子元素吗? 是的。多个元素需要用 v-if / v-elseTransitionGroup
为什么列表项需要 key? TransitionGroup 使用 key 追踪元素移动,实现平滑的移动动画。
mode 属性的作用? out-in:旧元素先离开,新元素再进入。in-out:新元素先进入,旧元素再离开。
如何自定义动画时长? 通过 duration prop:<Transition :duration="500">{ enter: 500, leave: 800 }

KeepAlive

问题 建议
max 属性的作用? 限制缓存组件数量,超出时移除最久未访问的。<KeepAlive :max="10">
组件必须有 name 属性吗? 使用 include / exclude 时需要。<script setup> 组件名默认是文件名。
特殊生命周期钩子? onActivated(激活时)、onDeactivated(停用时)。用于处理缓存组件的状态恢复。

异步组件

问题 建议
delay 选项的作用? 延迟显示加载状态,避免加载很快时出现闪烁。默认 200ms。
hydration 策略是什么? Vue 3.5+ 支持延迟 hydration:defineAsyncComponent({ loader, hydrate: 'visible' })

TypeScript 集成

问题 建议
如何为 defineProps 提供类型? 基于类型:defineProps<{ title: string }>()。基于运行时:defineProps({ title: String })。推荐前者。
withDefaults 如何使用? withDefaults(defineProps<Props>(), { count: 0 }),为类型定义的 props 提供默认值。
如何获取组件实例类型? InstanceType<typeof MyComponent>,用于 ref 的类型标注。
// 完整示例
import MyComponent from './MyComponent.vue'

const compRef = ref<InstanceType<typeof MyComponent>>()

onMounted(() => {
  compRef.value?.focus() // 类型安全的方法调用
})

SSR 注意事项

问题 建议
如何避免跨请求状态污染? 每个请求创建新的应用实例。避免在模块顶层创建响应式状态。
服务端可以使用哪些 API? 不能用 windowdocument 等浏览器 API。生命周期只有 setuponServerPrefetch
getSSRProps 的作用? 在 SSR 时修改组件 props,常用于注入服务端数据。

性能优化

问题 建议
props 稳定性为什么重要? 子组件使用 shallowRef 时,props 引用变化会触发重新渲染。尽量保持 props 引用稳定。
何时使用虚拟滚动? 渲染超过 1000 项的列表时。使用 vue-virtual-scroller 等库。
v-once 和 v-memo 的区别? v-once:只渲染一次,永不更新。v-memo:条件性跳过更新,依赖数组未变时复用。

SFC 特性

问题 建议
如何在 scoped 样式中修改子组件? 使用 :deep() 伪类:.parent :deep(.child) { }
scoped CSS 的限制? 不影响子组件的根元素(会自动添加 scoped 属性)。深层元素需要 :deep()

插件开发

问题 建议
插件应该用 provide/inject 吗? 是的。插件通过 provide 提供功能,组件通过 inject 使用,比全局属性更灵活。
注入 key 命名约定? 使用 Symbol 避免冲突:export const myPluginKey = Symbol()
如何为插件添加类型支持? 通过模块扩展:declare module 'vue' { interface ComponentCustomProperties { $myPlugin: MyPlugin } }

第四部分:为什么选择 UnoCSS 而不是 Tailwind CSS?

核心论点:UnoCSS 是 Tailwind 的超集

UnoCSS 不是 Tailwind 的竞争者,而是增强版。通过预设系统,UnoCSS 可以 100% 兼容 Tailwind 语法

// uno.config.ts
import { defineConfig, presetWind } from 'unocss'

export default defineConfig({
  presets: [
    presetWind(), // Tailwind CSS v3 兼容
    // 或 presetWind({ version: 4 }) // Tailwind CSS v4 兼容
  ],
})

使用 presetWind 后,所有 Tailwind 类名都能正常工作:

<!-- Tailwind 语法完全兼容 -->
<div class="flex items-center justify-between p-4 bg-blue-500 text-white rounded-lg shadow-md hover:bg-blue-600 transition">
  <span class="text-xl font-bold">完全兼容</span>
</div>

UnoCSS 的独家能力

1. 纯 CSS 图标(零 JS 运行时)

UnoCSS 通过 presetIcons 支持 10 万+ Iconify 图标,编译为纯 CSS,零 JavaScript 运行时开销:

pnpm add -D @iconify-json/carbon @iconify-json/mdi
<!-- 直接用 class 引用图标,无需导入 -->
<div class="i-carbon-logo-github text-2xl" />
<div class="i-mdi-home text-red-500" />
<button class="i-carbon-arrow-right hover:i-carbon-arrow-right-filled" />

编译结果(纯 CSS):

.i-carbon-logo-github {
  display: inline-block;
  width: 1em;
  height: 1em;
  background: url("data:image/svg+xml;utf8,...") no-repeat;
  background-size: 100% 100%;
}

对比 Tailwind + React Icons:

  • Tailwind:需要导入 React/Vue 组件,增加 bundle 体积
  • UnoCSS:纯 CSS,零 JS,图标按需编译
2. 属性化模式(Attributify)

避免 class 字符串爆炸:

<!-- Tailwind:class 字符串过长 -->
<button class="bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded-lg shadow-md transition duration-300 flex items-center gap-2">
  提交
</button>

<!-- UnoCSS:属性化模式 -->
<button 
  bg="blue-500 hover:blue-600"
  text="white"
  font="bold"
  p="y-2 x-4"
  rounded="lg"
  shadow="md"
  transition
  duration="300"
  flex
  items="center"
  gap="2"
>
  提交
</button>
3. Variant Group(变体组简写)
<!-- Tailwind:重复写 hover -->
<div class="hover:bg-red-500 hover:text-white hover:scale-105">

<!-- UnoCSS:Variant Group -->
<div class="hover:(bg-red-500 text-white scale-105)">
4. 自定义规则引擎

Tailwind 需要配置复杂的插件系统,UnoCSS 支持正则和函数定义原子类:

// uno.config.ts
import { defineConfig } from 'unocss'

export default defineConfig({
  rules: [
    // 正则匹配:自定义间距
    [/^m-(\d+)$/, ([, d]) => ({ margin: `${d}px` })],
    
    // 函数定义:自定义颜色
    ['text-brand', { color: '#3b82f6' }],
    
    // 动态值:任意单位
    [/^gap-(\d+)(px|rem|em)$/, ([, num, unit]) => ({ gap: `${num}${unit}` })],
  ],
  shortcuts: {
    // 快捷组合类
    'btn-primary': 'bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600',
  },
})
5. 编译模式(Compile Class)

将多个原子类编译为一个哈希类,减少 HTML 体积:

<!-- 开发模式:原子类 -->
<div class="flex items-center gap-4 bg-blue-500 p-4">

<!-- 生产模式:编译为单个类 -->
<div class="uno-abc123">

<style>
.uno-abc123 {
  display: flex;
  align-items: center;
  gap: 1rem;
  background: #3b82f6;
  padding: 1rem;
}
</style>

对比总结

特性 Tailwind CSS UnoCSS
基础原子类 ✅ 完整支持 ✅ 完全兼容(presetWind)
图标方案 需要额外库(React Icons 等) ✅ 内置 10 万+ 图标(纯 CSS)
属性化模式 ❌ 不支持 ✅ presetAttributify
Variant Group ❌ 不支持 hover:(bg-red text-white)
自定义规则 复杂插件系统 ✅ 正则/函数直接定义
编译模式 ❌ 不支持 ✅ 编译为哈希类
性能 JIT 编译快 ✅ 更快(Vite 原生)
生态整合 Standalone ✅ Vite/Nuxt 深度集成

与 Anthony Fu 技术栈的协同

  1. Vite 原生设计:UnoCSS 为 Vite 设计,HMR 极快
  2. Nuxt 一等公民@nuxt/unocss 开箱即用
  3. 作者生态:Anthony Fu 同时是 UnoCSS 和 Iconify 的作者,工具链深度整合

完整配置示例

// uno.config.ts
import {
  defineConfig,
  presetAttributify,
  presetIcons,
  presetTypography,
  presetUno,
  presetWebFonts,
  transformerDirectives,
  transformerVariantGroup,
} from 'unocss'

export default defineConfig({
  // 预设
  presets: [
    presetUno(), // 默认预设(类似 Tailwind)
    presetAttributify(), // 属性化模式
    presetIcons({
      scale: 1.2,
      cdn: 'https://esm.sh/',
    }),
    presetTypography(), // 排版预设
    presetWebFonts({
      fonts: {
        sans: 'Inter',
        mono: 'Fira Code',
      },
    }),
  ],

  // 转换器
  transformers: [
    transformerDirectives(), // @apply 指令
    transformerVariantGroup(), // Variant Group
  ],

  // 自定义规则
  rules: [
    ['text-brand', { color: '#3b82f6' }],
  ],

  // 快捷方式
  shortcuts: {
    'btn': 'px-4 py-2 rounded inline-block cursor-pointer',
    'btn-primary': 'btn bg-blue-500 text-white hover:bg-blue-600',
  },

  // 主题扩展
  theme: {
    colors: {
      brand: {
        primary: '#3b82f6',
        secondary: '#8b5cf6',
      },
    },
  },
})

安装命令:

pnpm add -D unocss
pnpm add -D @iconify-json/carbon @iconify-json/mdi

第五部分:配套工具链一览

工具 用途 推荐理由
Vue 3.5+ 渐进式 JavaScript 框架 Composition API、性能优化、TypeScript 支持
Nuxt 3 Vue 元框架 SSR/SSG、文件路由、服务端 API、SEO 优化
Pinia 状态管理 直观的 API、完整的 TypeScript 支持、Vue DevTools 集成
Vite 构建工具 极速 HMR、原生 ESM、Rollup 生产构建
VitePress 静态站点生成器 Vue 驱动、Markdown 扩展、主题定制
Vitest 单元测试 Vite 原生、与 Jest 兼容的 API、快速执行
UnoCSS 原子化 CSS 引擎 Tailwind 超集、纯 CSS 图标、Vite 深度集成
pnpm 包管理器 磁盘高效、严格依赖管理、monorepo 支持
VueUse 组合式函数集合 200+ 实用工具、SSR 友好、Tree-shakable
Slidev 开发者幻灯片 Markdown 编写、Vue 组件、录制功能
tsdown TypeScript 打包工具 零配置、类型声明生成、ESM/CJS 双输出
Vue Router 官方路由 嵌套路由、导航守卫、动态路由匹配

快速开始命令

# 创建 Vue 3 项目(Vite)
pnpm create vite my-vue-app --template vue-ts

# 创建 Nuxt 3 项目
pnpm dlx nuxi@latest init my-nuxt-app

# 添加 UnoCSS
pnpm add -D unocss

# 添加 VueUse
pnpm add @vueuse/core

# 添加 Pinia
pnpm add pinia

# 添加 Vitest
pnpm add -D vitest

总结

Anthony Fu 的开发规范强调:

  1. 类型安全优先:TypeScript + 显式类型定义
  2. 性能意识shallowRef > ref、避免深度侦听、虚拟滚动
  3. 工具链协同:Vite + UnoCSS + Vitest 深度整合
  4. 代码质量:单一职责、ESLint 自动化、Git Hooks
  5. 现代化实践:Composition API、<script setup>、组合式函数

通过遵循这些规范,可以构建更快、更可维护、更具扩展性的 Vue 3 应用。


参考资料:

译者注: 本文基于 antfu/skills 仓库于 2026 年 2 月的内容整理翻译,随着生态演进,部分实践可能更新,请以官方文档为准。

从原理到实践:JavaScript中的this指向,一篇就够了

2026年2月12日 21:54

从原理到实践:JavaScript中的this指向,一篇就够了

前言

最近在复习JavaScript的this指向问题,写了几个小例子来加深理解。很多同学觉得this难,其实是因为this是在函数执行时确定的,而不是定义时。这个特性导致了this指向的“善变”。

今天,就让我通过这几个代码例子,带你由浅入深掌握JavaScript中的this指向。


第一章:基础概念 - this的默认绑定

1.1 全局环境下的this

在浏览器全局环境中,this指向window对象:

var name = "windowName"; // 全局变量
var func1 = function() {
  console.log('func1');
}

console.log(this.name); // "windowName"
console.log(window.name); // "windowName"

核心知识点:

  • 全局作用域下,this === window
  • var声明的全局变量会自动挂载到window对象上

第二章:谁调用我,我就指向谁

2.1 对象方法调用

var a = {
  name: "Cherry",
  func1: function() {
    console.log(this.name);
  }
}

a.func1(); // "Cherry"

关键理解: 这里的this指向了对象a。因为func1是由a调用的。

2.2 经典面试题 - 定时器中的this

// 2.html 中的例子
var a = {
  name: "Cherry",
  func1: function() {
    console.log(this.name);
  },
  func2: function() {
    setTimeout(function() {
      this.func1(); // 这里会报错!
    }, 3000)
  }
}

a.func2(); // TypeError: this.func1 is not a function

为什么报错?

定时器的回调函数是由定时器内部调用的,此时this指向了window对象。而window对象上并没有func1方法(虽然有全局变量func1,但这里调用的是对象方法)。

验证一下:

var a = {
  // ... 同上
  func2: function() {
    setTimeout(function() {
      console.log(this); // window
    }, 3000)
  }
}

第三章:解决this丢失的三种方案

3.1 方案一:保存this(that = this)

// 3.html 中的例子
var a = {
  name: "Cherry",
  func1: function() {
    console.log(this.name);
  },
  func2: function() {
    var that = this; // 保存外层的this
    setTimeout(function() {
      that.func1(); // "Cherry" ✅
    }, 3000)
  }
}

a.func2(); // 3秒后输出 "Cherry"

原理: 利用闭包的特性,内部函数可以访问外部函数的变量。that保存了正确的this引用。

3.2 方案二:bind绑定

// 2.html 中的例子
var a = {
  name: "Cherry",
  func1: function() {
    console.log(this.name);
  },
  func2: function() {
    setTimeout(function() {
      this.func1(); 
    }.bind(a), 3000) // bind永久绑定this为a
  }
}

a.func2(); // 3秒后输出 "Cherry" ✅

重要区别:

  • bind()不会立即执行,返回一个新函数,永久绑定this
  • call()/apply()立即执行函数,临时绑定this
// 对比演示
a.func1.call(a); // 立即执行
const boundFunc = a.func1.bind(a); // 返回绑定后的函数,不执行
boundFunc(); // 执行时this已经绑定为a

3.3 方案三:箭头函数

// 4.html 中的例子
var a = {
  name: "Cherry",
  func1: function() {
    console.log(this.name);
  },
  func2: function() {
    setTimeout(() => {
      console.log(this); // a对象 ✅
      this.func1(); // "Cherry" ✅
    }, 3000)
  }
}

a.func2(); // 3秒后输出 "Cherry"

箭头函数的特点:

  • 没有自己的this
  • 继承定义时所在作用域的this
  • this是静态的,不会改变

第四章:深入理解箭头函数

4.1 箭头函数没有自己的this

// 5.html 中的例子
const func = () => {
  console.log(this); // window(在浏览器环境)
}

func(); // window

4.2 箭头函数不能作为构造函数

const func = () => {
  console.log(this);
}

new func(); // TypeError: func is not a constructor ❌

为什么? 箭头函数没有自己的this,也没有prototype属性,无法进行实例化。

4.3 关于arguments对象

const func = () => {
  console.log(arguments); // ReferenceError ❌
}

// 箭头函数也没有自己的arguments对象
// 但可以这样获取参数
const func2 = (...args) => {
  console.log(args); // [1, 2, 3] ✅
}

func2(1, 2, 3);

第五章:综合实践 - 分析一段复杂代码

让我们来分析1.html中的代码,它包含了多个知识点的综合运用:

var name = "windowName";
var func1 = function() {
  console.log('func1');
}
var a = {
  name: "Cherry",
  func1: function() {
    console.log(this.name);
  },
  func2: function() {
    setTimeout(function() {
      this.func1(); // 这里原本有问题
      return function() {
        console.log('hahaha');
      }
    }.call(a), 3000) // ⚠️ 注意:这里用了call
  }
}

这里有个坑:

setTimeout的第一个参数应该是函数,但.call(a)立即执行这个函数,并把返回值作为第一个参数传给setTimeout。这里返回的是undefined,相当于:

// 实际执行效果
setTimeout(undefined, 3000)

正确写法:

// 使用bind(不会立即执行)
setTimeout(function() {
  this.func1();
}.bind(a), 3000)

// 或者使用箭头函数
setTimeout(() => {
  this.func1(); // 这里的this继承自func2
}, 3000)

第六章:面试题精选

6.1 经典组合题

var name = 'window';

var obj = {
  name: 'obj',
  fn1: function() {
    console.log(this.name);
  },
  fn2: () => {
    console.log(this.name);
  },
  fn3: function() {
    return function() {
      console.log(this.name);
    }
  },
  fn4: function() {
    return () => {
      console.log(this.name);
    }
  }
}

obj.fn1();      // 'obj' - 对象方法调用
obj.fn2();      // 'window' - 箭头函数,this指向外层window
obj.fn3()();    // 'window' - 独立函数调用
obj.fn4()();    // 'obj' - 箭头函数,this继承自fn4的this

6.2 优先级问题

function foo() {
  console.log(this.name);
}

const obj1 = { name: 'obj1', foo };
const obj2 = { name: 'obj2' };

obj1.foo();                 // 'obj1'
obj1.foo.call(obj2);       // 'obj2' - call优先于隐式绑定
const bound = foo.bind(obj1);
bound.call(obj2);          // 'obj1' - bind绑定后,call无法改变

this绑定优先级:

  1. new绑定(最高)
  2. call/apply/bind显式绑定
  3. 对象方法调用(隐式绑定)
  4. 默认绑定(独立函数调用,最低)

总结

this的指向规律其实很简单,记住这几点:

  1. 函数被调用时才能确定this指向
  2. 普通函数:谁调用我,我指向谁
  3. 箭头函数:我在哪里定义,this就跟谁一样
  4. 可以通过bind永久绑定this,call/apply临时绑定this

理解了这些,JavaScript的this问题就迎刃而解了。


练习题

// 尝试分析下面的输出
const obj = {
  name: 'obj',
  say: function() {
    setTimeout(() => {
      console.log(this.name);
    }, 100);
  }
}

obj.say(); // 输出什么?

const say = obj.say;
say(); // 输出什么?

答案和解析欢迎在评论区讨论!


如果你觉得这篇文章对你有帮助,请点赞👍收藏⭐,让更多的小伙伴看到!我们下期再见!

谷歌浏览器取色器插件源码学习分享

作者 Json_
2026年2月12日 21:24

在使用谷歌浏览器的时候,我们经常会使用到浏览器插件,作为技术肯定也要学习一下浏览器插件的开发咯,这篇文章给大家分享一个 我最近开发的一个浏览器插件-取色器,因为是学习阶段,所以功能做的还是比较简单的。

插件样子

image.png

使用技术:html css和js

image.png

插件部分代码:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>取色器</title>
  <style>
    body {
      width: 200px;
      padding: 15px;
      font-family: Arial, sans-serif;
      margin: 0;
    }
    h3 {
      margin: 0 0 15px 0;
      text-align: center;
      color: #333;
    }
    #startBtn {
      width: 100%;
      padding: 12px;
      background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
      color: white;
      border: none;
      border-radius: 8px;
      cursor: pointer;
      font-size: 16px;
      font-weight: bold;
      transition: transform 0.2s, box-shadow 0.2s;
    }
    #startBtn:hover {
      transform: translateY(-2px);
      box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
    }
    #startBtn:active {
      transform: translateY(0);
    }
    .tip {
      margin-top: 15px;
      font-size: 12px;
      color: #666;
      text-align: center;
      line-height: 1.5;
    }
    .ad {
      margin-top: 20px;
      padding-top: 15px;
      border-top: 1px solid #e0e0e0;
      font-size: 11px;
      color: #999;
      text-align: center;
      line-height: 1.6;
    }
    .ad a {
      color: #667eea;
      text-decoration: none;
    }
    .ad a:hover {
      text-decoration: underline;
    }
  </style>
</head>
<body>
  <h3>🎨 取色器</h3>
  <button id="startBtn">开始取色</button>
  <div class="tip">
    点击按钮后,在网页上任意位置<br>
    左键点击即可获取颜色值
  </div>
  <div class="ad">
    万物OOP出品<br>
    <a href="https://www.wwwoop.com" target="_blank">www.wwwoop.com</a>
  </div>
  <script src="popup.js"></script>
</body>
</html>
// 获取开始取色按钮
const startBtn = document.getElementById('startBtn');

// 点击开始取色按钮
startBtn.addEventListener('click', async () => {
  // 向当前活动标签页发送消息,开始取色模式
  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
  
  if (tab && tab.id) {
    try {
      // 先尝试发送消息
      await chrome.tabs.sendMessage(tab.id, { action: 'startPickColor' });
      window.close();
    } catch (error) {
      // 如果消息发送失败,说明content script未加载,先注入
      console.log('Content script未加载,正在注入...');
      try {
        await chrome.scripting.executeScript({
          target: { tabId: tab.id },
          files: ['content.js']
        });
        // 注入CSS
        await chrome.scripting.insertCSS({
          target: { tabId: tab.id },
          files: ['content.css']
        });
        // 重新发送消息
        await chrome.tabs.sendMessage(tab.id, { action: 'startPickColor' });
        window.close();
      } catch (injectError) {
        console.error('注入脚本失败:', injectError);
        alert('无法在此页面使用取色器,请刷新页面后重试');
      }
    }
  }
});

从零搭建多 Agent 智能编排平台:NestJS + LangGraph 全栈实战

作者 Amber丶King
2026年2月12日 20:29

从零搭建多 Agent 智能编排平台:NestJS + LangGraph 全栈实战

本文分享一个开源项目 Nest-Agent——基于 NestJS + LangGraph 构建的多 Agent 编排平台,支持 Supervisor 自动路由、DAG 自定义工作流、RAG 知识库检索,遵循 AG-UI 标准协议实现 token 级流式输出。如果对你有帮助,欢迎 GitHub Star 支持 ⭐


为什么做这个项目?

当前 AI Agent 框架层出不穷,Python 生态的 LangChain、CrewAI 已经很成熟。但在 Node.js / TypeScript 生态,企业级的 Agent 编排后端方案相对匮乏。作为一个 NestJS 爱好者,我希望用最熟悉的技术栈打造一个:

  • 生产级的多 Agent 协同平台,不是 demo
  • 支持 Supervisor 自动路由DAG 自定义工作流 两种编排模式
  • 遵循 AG-UI 标准协议,前后端解耦,可对接 CopilotKit 等主流前端 SDK
  • 内置 RAG 知识库多 LLM 供应商多租户隔离,开箱即用

于是有了 Nest-Agent


技术栈一览

层次 技术
后端框架 NestJS 11
Agent 编排 LangChain + LangGraph
数据库 MySQL 8.0 (TypeORM)
缓存 Redis 7 (ioredis)
向量库 Milvus 2.3
认证 Passport JWT
前端 React 18 + shadcn/ui + Vite
包管理 pnpm workspace (monorepo)
部署 Docker Compose 一键启动

核心功能展示

1. AI 对话 — 流式输出 + 工具调用可视化

对话是整个平台的核心。输入一个问题,Supervisor 自动判断是否需要搜索、检索知识库还是直接回答,整个过程实时流式展示。

对话首页

流式输出

亮点:

  • Token 级别的流式输出,逐字显示 AI 回复
  • 实时展示 Agent 执行步骤(researcher → 搜索 → 生成回答)
  • 工具调用全程可视化——工具名、参数、返回结果一目了然
  • Markdown 富文本渲染(代码块、表格、列表、链接等)
  • 对话标题自动生成,不再是千篇一律的 "New Conversation"

对话详情

2. 工作流编排 — 可视化 DAG 工作流

除了 Supervisor 自动模式,你还可以自定义 DAG 工作流,精确控制 Agent 的执行流程。

工作流列表

创建工作流

支持的节点类型:

  • agent — 可调用工具的 AI Agent,支持自定义 system prompt
  • tool — 直接调用工具节点
  • condition — 条件分支,根据关键词路由到不同分支
  • start / end — 起止标记

3. RAG 知识库 — 语义检索

上传文档到知识库,Agent 对话时自动检索相关知识片段,实现基于私有数据的问答。

知识库列表

语义检索

RAG Pipeline:

文档 → 分块(1000/200) → BAAI/bge-m3 Embedding → Milvus 向量存储
查询 → Embedding → Milvus ANN 检索 → Top-K 结果

4. 认证系统 — 多租户隔离

登录注册

JWT 认证 + TenantGuard,所有数据按 tenantId 隔离,天然支持多团队使用。


架构设计

整体分层

┌──────────────────────────────────────────────────────────────┐
│                     React + shadcn/ui                         │
│                (SSE 客户端 + AG-UI 事件解析)                   │
└────────────────────────┬─────────────────────────────────────┘
                         │ HTTP POST + SSE 流
┌────────────────────────▼─────────────────────────────────────┐
│                     NestJS 应用层                              │
│  ┌──────┐ ┌──────┐ ┌───────┐ ┌──────┐                       │
│  │ Auth │ │ Chat │ │ Agent │ │ RAG  │  Controller 层          │
│  └──┬───┘ └──┬───┘ └───┬───┘ └──┬───┘                       │
│     │        │         │        │                             │
│  ┌──▼───┐ ┌──▼───┐ ┌───▼───┐ ┌──▼───┐                       │
│  │ Auth │ │ Chat │ │Agent  │ │ RAG  │  Service 层            │
│  └──────┘ └──────┘ └───┬───┘ └──────┘                       │
│                        │                                      │
│           ┌────────────┼────────────┐                        │
│     ┌─────▼─────┐ ┌────▼────┐ ┌────▼────┐                   │
│     │ Supervisor │ │   DAG   │ │  Tool   │  核心编排层        │
│     │  Factory   │ │ Engine  │ │Registry │                   │
│     └───────────┘ └─────────┘ └─────────┘                   │
│                                                               │
│     ┌─────────┐ ┌────────┐ ┌────────┐                       │
│     │   LLM   │ │ Milvus │ │ Redis  │  基础设施层            │
│     │ Service  │ │Service │ │Service │                       │
│     └─────────┘ └────────┘ └────────┘                       │
└──────────────────────────────────────────────────────────────┘
         │             │           │
    ┌────▼────┐  ┌─────▼────┐ ┌───▼──┐
    │OpenAI/  │  │ Milvus   │ │Redis │  存储层
    │Anthropic│  │ 2.3      │ │  7   │
    │/Qwen    │  └──────────┘ └──────┘
    └─────────┘

两种编排模式对比

Supervisor 模式(默认)

适合通用对话场景。LLM 充当"主管",根据用户意图动态路由到合适的 Agent:

用户: "搜索一下 NestJS 11 新功能"
  → Supervisor 判断: 需要搜索,路由到 researcher
  → researcher 调用 web_search 工具
  → researcher 根据搜索结果生成回答
  → Supervisor 判断: 任务完成,路由到 __end__

核心实现:Supervisor 使用 LLM + withStructuredOutput(zod schema) 做结构化输出,返回 { next: "researcher" | "responder" | "__end__" }

DAG 模式(自定义工作流)

适合复杂业务流程。用户预定义有向无环图,精确控制执行链路:

Start → 研究员Agent(调用搜索) → 条件判断 → 写手Agent(生成报告) → End
                                    ↘ 工具节点(直接检索) ↗

核心实现:将 JSON 格式的 nodes[] + edges[] 编译为 LangGraph 的 StateGraph,通过 Command({ goto, update }) 控制状态转移。


关键技术实现细节

1. AG-UI 流式事件协议

这是项目中最复杂也最有价值的部分。系统遵循 AG-UI 标准协议,通过 SSE 推送细粒度事件:

event: RUN_STARTED
data: {"type":"RUN_STARTED","threadId":"xxx","runId":"xxx"}

event: STEP_STARTED
data: {"type":"STEP_STARTED","stepName":"researcher"}

event: TOOL_CALL_START
data: {"type":"TOOL_CALL_START","toolCallId":"xxx","toolCallName":"web_search"}

event: TEXT_MESSAGE_CONTENT
data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"xxx","delta":"逐字输出..."}

event: RUN_FINISHED
data: {"type":"RUN_FINISHED","threadId":"xxx","runId":"xxx"}

三段式文本消息TEXT_MESSAGE_START → TEXT_MESSAGE_CONTENT(增量) → TEXT_MESSAGE_END

四段式工具调用TOOL_CALL_START → TOOL_CALL_ARGS → TOOL_CALL_END → TOOL_CALL_RESULT

遵循标准协议意味着可以直接对接 CopilotKit 等前端 SDK,无需自定义解析。

2. LangGraph streamEvents → AG-UI 的精细转换

AgentService.processStreamEvents() 方法实现了 LangGraph 底层事件到 AG-UI 协议的映射,需要处理几个棘手的问题:

问题 1:Supervisor 路由节点的输出不应暴露给用户

Supervisor 的结构化输出({ next: "researcher" })是内部路由决策,不应该推送给前端。通过节点名过滤解决。

问题 2:嵌套子 Agent 的"思考"文本

createReactAgent 内部的 LLM 在调用工具前会输出一些"思考"文本(通常是复述 prompt),这些不应显示给用户。解决方案是通过 langgraph_checkpoint_ns 区分嵌套层级:

const isNestedAgent = parentNode && langgraphNode !== parentNode;
if (isNestedAgent && !nodeToolsDone.get(nodeKey)) {
  // 暂存到 pendingTextPerNode,工具完成后才释放
}

问题 3:XML 工具调用格式兼容

某些模型(如 qwen)会以 <tool_call> XML 标签输出工具调用。代码使用正则检测并过滤,避免 XML 标签泄露到前端。

3. 智能记忆管理

对话记忆采用 滑动窗口 + LLM 自动摘要 的混合策略:

消息数 ≤ 10        → 全量返回,无压缩
10 < 消息数 ≤ 20   → 滑动窗口,取最近 10 条
消息数 > 20        → LLM 对早期消息生成摘要,摘要 + 最近 10 条

摘要存储在 Conversation.summary 字段,支持增量摘要(新对话内容追加到现有摘要上)。会话消息缓存在 Redis,TTL 3600 秒,减少数据库查询。

4. 多 LLM 供应商抽象

统一的模型工厂,切换供应商只需一个参数:

// 用户请求时指定
{ llmOptions: { provider: "openai", model: "gpt-4o" } }
{ llmOptions: { provider: "dashscope", model: "qwen-max" } }
{ llmOptions: { provider: "anthropic", model: "claude-sonnet-4-20250514" } }

DashScope(通义千问)通过 OpenAI 兼容协议接入,自定义 baseURL 即可。同样的方式可以接入 SiliconFlow、Deepseek、vLLM 等任何兼容 OpenAI API 的服务。

5. 动态工具 + 多租户隔离

工具系统采用注册表模式。web_search 是全局静态工具,而 rag_retrieval 因为需要 tenantId 上下文,采用工厂函数动态创建

// 每次请求动态创建,确保 tenantId 隔离
const ragTool = createRagRetrievalTool(ragService, tenantId);

这样不同租户只能检索到自己的知识库数据。


项目结构

nest-agent/
├── src/                       # 后端(NestJS)
│   ├── auth/                  # JWT 认证 + 多租户守卫
│   ├── chat/                  # 对话管理 + SSE 流式接口 + 记忆策略
│   ├── agent/                 # 核心:Supervisor 路由 + DAG 引擎
│   │   ├── agent.service.ts   # 编排入口 + AG-UI 事件转换
│   │   ├── supervisor.factory.ts  # Supervisor 有向图构建
│   │   └── dag-engine.ts      # DAG 编译执行引擎
│   ├── rag/                   # RAG 知识库(Milvus 向量检索)
│   ├── llm/                   # 多 LLM 供应商抽象
│   ├── tools/                 # 工具注册中心
│   ├── redis/                 # Redis 缓存
│   ├── entities/              # TypeORM 实体
│   └── common/                # AG-UI 协议定义、配置、异常过滤器
├── web/                       # 前端(React + shadcn/ui + Vite)
│   └── src/
│       ├── pages/             # 对话、工作流、知识库、登录
│       ├── components/        # 布局 + shadcn/ui 组件
│       └── lib/               # API 封装、认证上下文
├── Dockerfile                 # 多阶段构建
├── docker-compose.yml         # MySQL + Redis + Milvus 一键启动
└── pnpm-workspace.yaml        # monorepo 配置

快速启动

Docker 一键部署

git clone https://github.com/peng-yin/nest-agent.git
cd nest-agent
cp .env.example .env
# 编辑 .env,填入 OPENAI_API_KEY 等配置
docker-compose up -d
# 访问 http://localhost:3000

本地开发

# 1. 启动基础设施
docker-compose up -d mysql redis etcd minio milvus-standalone

# 2. 安装依赖
pnpm install

# 3. 启动后端(热重载)
pnpm dev            # http://localhost:3000

# 4. 启动前端(另开终端)
pnpm dev:web        # http://localhost:5173

环境变量

变量 必填 说明
OPENAI_API_KEY OpenAI API Key
OPENAI_BASE_URL 自定义 API 地址(兼容 SiliconFlow/Deepseek)
TAVILY_API_KEY 网页搜索功能需要
JWT_SECRET 生产必填 JWT 签名密钥

踩过的坑

分享几个开发过程中遇到的典型问题:

1. createReactAgent 子图的"思考"文本泄露

使用 LangGraph 的 createReactAgent 时,内部 LLM 在决定调用工具前会输出一段"思考"文本。这些文本通过 streamEvents 被捕获并发送到前端,用户看到的是一堆 prompt 模板文字而非实际回答。

解决方案:通过 langgraph_checkpoint_ns 中的 langgraphNodeparentNode 区分顶层节点和子图内部调用。子图内部 LLM 的 langgraphNode"agent",而 parentNode"researcher",两者不同;普通顶层节点两者相同。利用 langgraphNode !== parentNode 识别嵌套调用,将工具调用前的文本暂存丢弃。

2. StateGraph 节点的 checkpointNs 理解偏差

最初以为只有 createReactAgent 构建的子图才有 checkpointNs,但实际上 StateGraph 中所有节点都有。导致 responder(直接回答)的正常文本也被错误暂存。debug 日志大法好。

3. 阿里云 DashScope 的 XML 工具调用

通过 OpenAI 兼容 API 调用 qwen 模型时,部分场景下工具调用不走标准的 tool_calls 字段,而是在文本中输出 <tool_call> XML 标签。需要正则检测并过滤,否则前端会显示一堆 XML。


后续计划

  • 支持更多工具(代码执行、文件上传、图片生成等)
  • 工作流可视化编辑器(拖拽式 DAG 编辑)
  • Agent 执行过程的可视化 Trace
  • 支持更多向量数据库(Pinecone、Qdrant)
  • 支持文件上传(PDF、Word 等文档直接入库)

写在最后

这个项目从架构设计到编码实现,全部由一个人完成。涵盖了 Agent 编排、流式通信、RAG 检索、多租户隔离等多个技术领域,希望能为 Node.js/TypeScript 生态的 AI Agent 开发提供一个可参考的实践案例。

代码完全开源,如果这个项目对你有帮助,或者你对 NestJS + AI Agent 感兴趣,欢迎:

  • Star 这个项目:GitHub - nest-agent
  • 🐛 提 Issue 或 PR,一起完善
  • 💬 留言交流,分享你的想法

感谢阅读!

学成在线 案例练习

作者 糖糖TANG
2026年2月12日 20:07

大家好,我是糖糖~最近我跟着 b 站 pink 老师做了学成在线的案例练习,收获满满,今天就来和大家分享一下我的学习过程与心得,一起开启技术学习新旅程!

一.案例准备工作

graph TD
A[创建study目录文件夹] --> B[在vscode打开目录文件夹]
B --> C[study目录内新建images文件夹]
C --> D[新建首页文件index.html]
D --> E[新建style.css样式文件采用外链样式]
E --> F[样式表写入清除内外边距的样式来检验样式表是否引入成功]

index.html:!符号+Tab键生成模板,link+Taba键添加外链样式;

body里随机写入内容用于测试引入是否成功

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>学成在线首页</title>
    <link rel="stylesheet" href="style.css">
</head>

<body>
    123
</body>
</html>

image.png 样式文件去除内外边距,代码如下

* {
    margin: 0;
    padding: 0;
}

image.png 通过浏览器预览网页再F12按键,若看到以下清除内外边距的内容,说明引入成功

image.png

二.确定版心(页面宽度)

每个版心都要水平居中,可以定义版心公共类;

样式文件添加:

.w{
    width: 1200px;
    margin: auto;
}

image.png

三.分块实现

接下来一行一行的实现,先写第一行的每一列,做完以后以此类推,在做第二行、第三行;

3.1头部制作

3.1.1第一行盒子

1.量头部内容高度和上下外边框高度;
2.在index.html文件的body里加入头部盒子代码,并引入之前的公共类.w;

  <div class="header w">

  </div>

3.在style.css文件加入高度和外边距属性;

.header{
    height: 42px;
    background-color: pink;
    /* 注意此地方会层叠.w 里面的margin,故要写左右的auto */
    margin: 30px auto;
}

3.1.2第一行第一列(logo部分)

index.html的logo部分:

         <!-- logo部分 -->
        <div class="logo">
            <img src="images/logo.png" alt="">
        </div>

image.png style.css的logo样式部分:

.logo {
    width: 198px;
    height: 42px;
    background-color: purple;
}

image.png

3.1.3第一行第二列(导航栏部分)

实际开发中,我们不会直接用链接a而是用li包含链接(li+a)的做法。
原因:
1.li+a语义更清晰,一看这就是有条理的列表型内容。
2.如果直接用a,搜索引擎容易辨别为有堆砌关键词嫌疑从而影响网站排名

导航栏代码:
<ul>
    <li><a href="#">首页</a></li>
    <li><a href="#">课程</a></li>
    <li><a href="#">职业规划</a></li>
</ul>
样式代码:

1.去掉li的小圆点

li{
   list-style: none;
}

2.注意当导航栏加浮动时,logo也要加上浮动

.nav{
    float: left;
    margin-left: 60px;
}

3.链接去掉下划线

a {
    text-decoration: none;
}

4.让导航栏里的内容横向排列,因为li是块状元素需要一行显示,加浮动

.nav ul li {
    float: left;
}

5.链接样式:转为行内块元素才能调整高度,设置内边距、行高、字符大小、颜色

.nav ul li a {
    display: block;
    height: 42px;
    padding: 0 10px;
    line-height: 42px;
    font-size: 18px;
    color: #050505;

}

注意:

这个nav导航栏可以不加宽度,将来可以加其余文字;
因为导航栏里面文字不够多,所以最好给链接a左右内边距padding撑开盒子,而不是指定宽度。

6.鼠标经过链接的样式代码

.nav ul li a {
    display: block;
    height: 42px;
    padding: 0 10px;
    line-height: 42px;
    font-size: 18px;
    color: #050505;

}
效果图

image.png

3.1.4第一行第三列(search搜索模块)

1.结构分析

一个search大盒子里面包括两个表单=input文本框+button按钮

   <!-- 搜索模块 -->
        <div class="search">
            <input type="text" value="输入关键词">
            <button></button>
        </div>

2.样式代码实现

搜索框部分

style.css

/* search搜索模块 */
.search {
    float: left;
    width: 412px;
    height: 42px;
    background-color: skyblue;
    margin-left: 70px;
}

.search input {
    width: 345px;
    height: 40px;
    border: 1px solid #00a4ff;
    border-right: 0;
    color: #bfbfbf;
    font-size: 14px;
    padding-left: 15px;
}

注意: 加上左内边距后,盒子会被撑大,在宽度减去左内边距360-15=345

按钮部分
.search button {
    float: left;
    width: 50px;
    height: 42px;
    background: url(images/btn.png);
    /* 按钮button默认有个边框需要我们手动去掉 */
    border: 0;
}

注意: 去掉按钮默认边框; 使按钮在搜索框右边则加上浮动,此时搜索框也要加上浮动。

效果图

image.png

3.1.5第一行第四列(用户user模块制作)

1.用一个盒子装用户头像和用户名

<!-- 用户模块 -->
        <div class="user">
            <img src="images/user.png" alt="">
            qq-lilei
        </div>

2.样式:右浮动、行高、右外边距、字体大小、颜色

/* 用户模块 */
.user {
    float: right;
    line-height: 42px;
    margin-right: 30px;
    font-style: 14px;
    color: #666;
}

##背景色 1.设置整个页面背景色

body {
    background-color: #f3f5f7;
}

2.去掉头部辅助的背景色

头部模块效果图

image.png

3.2 banner制作

思路

大盒子banner里包含版心.w,版心里有左右两个子盒子。
左边盒子用ul>li>a span,大于符号用>
右边盒子用一个标题h4+ul>li*3+一个a链接
其中li里面有h4和p

代码实现

  <!-- banner部分start -->
    <div class="banner">
        <!-- 版心 左子盒子-->
        <div class="w">
            <div class="subnav">
                <ul>
                    <li><a href="#">前端开发<span>&gt;</span></a></li>
                    <li><a href="#">后端开发<span>&gt;</span></a></li>
                    <li><a href="#">移动开发<span>&gt;</span></a></li>
                    <li><a href="#">人工智能<span>&gt;</span></a></li>
                    <li><a href="#">商业预测<span>&gt;</span></a></li>
                    <li><a href="#">云计算&大数据<span>&gt;</span></a></li>
                    <li><a href="#">运维&从测试<span>&gt;</span></a></li>
                    <li><a href="#">UI设计<span>&gt;</span></a></li>
                    <li><a href="#">产品<span>&gt;</span></a></li>
                </ul>
            </div>
            
            <!--我的课程表  右子盒子 -->
            <div class="course">
                <h2>我的课程表</h2>
                <div class="bd">
                    <ul>
                        <li>
                            <h4>继续学习 程序语言设计</h4>
                            <p>正在学习-使用对象</p>
                        </li>
                        <li>
                            <h4>继续学习 程序语言设计</h4>
                            <p>正在学习-使用对象</p>
                        </li>
                        <li>
                            <h4>继续学习 程序语言设计</h4>
                            <p>正在学习-使用对象</p>
                        </li>
                    </ul>
                    <a href="#" class="more">全部课程</a>
                </div>
            </div>
        </div>
    </div>
    <!-- banner部分end -->

样式代码

/* banner区域 */
.banner {
    height: 421px;
    background-color: #1c036c;
}

.banner .w {
    height: 421px;
    background: url(images/banner2.png) no-repeat top center;
}

.subnav {
    float: left;
    width: 190px;
    height: 421px;
    background-color: rgba(0, 0, 0, 0.3);
}

.subnav ul li {
    height: 45px;
    line-height: 45px;
    padding: 0px 20px;


}

.subnav ul li a {
    font-style: 14px;
    color: #fff;

}

.subnav ul li a span {
    float: right;
    padding-right: 20px;
}

.subnav ul li a:hover {
    color: #00a4ff;
}

.course {
    float: right;
    width: 230px;
    height: 300px;
    background-color: #fff;
    /* 浮动的盒子不会有外边距合并的问题 */
    margin-top: 50px;
}

.course h2 {
    height: 48px;
    background-color: #9bceea;
    text-align: center;
    line-height: 48px;
    font-size: 18px;
    color: #fff;
}

.bd{
    padding: 0 20px;
}

.bd ul li{
    padding: 15px 0;
    border-bottom: 1px solid #ccc;
}
.bd ul li h4{
    font-size: 16px;
    color: #4e4e4e;
}

.bd ul li p {
    font-size: 12px;
    color: #a5a5a5;
}
.bd .more{
    display:block;
    height: 38px;
    border: 1px solid #00a4ff;
    text-align: center;
    line-height: 38px;
    color:#00a4ff;
    font: size 16px;
    font-weight: 700;
}

banner模块效果图

image.png

3.3精品推荐小模块

思路

大盒子水平居中goods精品,注意此处有个盒子阴影。
大盒子里面有三个小盒子,一号盒子是标题h3左侧浮动,二号盒子里面放链接左侧浮动,goods-item距离可以控制连接的左右边距(注意行内元素只给左右内外边距),三号盒子右浮动mod修改

代码实现

  <!--3. 精品推荐模块开始 -->
    <div class="goods w">
        <h3>精品推荐</h3>
         
            <ul>
            <li><a href="#">jQuery</a></li>
            <li><a href="#">Spark</a></li>
            <li><a href="#">MySQL</a></li>
            <li><a href="#">JavaWeb</a></li>
            <li><a href="#">MyAQL</a></li>
            <li><a href="#">JavaWeb</a></li>
        </ul>

        <a href="#"class="mod">修改兴趣</a>

    </div>
    <!--3. 精品推荐模块结束-->

样式代码

/* 精品推荐模块 */
.goods{
height: 60px;
background-color: #fff;
margin-top: 10px;
box-shadow: 0 2px 3px 3px rgba(0, 0, 0, 0.1);
/* 行高会继承 */
line-height: 60px;
}

.goods h3{
    float: left;
    margin-left: 30px;
    font-size: 16px;
    color: #00a4ff;
}

.goods ul{
    float: left;
    margin-left: 30px;
}

.goods ul li{
float: left;
   
}

.goods ul li a{

        padding: 0 30px;
        font-size: 16px;
        color: #050505;
        border-left: 1px solid #ccc;
}

.mod{
    float:right;
    margin-right:30px ;
    font-size: 14px;
    color: #00a4ff;
}

精品推荐小模块效果图

image.png

3.4精品推荐大模块

思路

1号盒子为最大盒子,box版心水平居中;
2号盒子为上面部分,box-hd--里面左侧标题h3左浮动,右侧链接a右浮动;
3号盒子为底下部分,box-bd--里面是无序列表,有10小li组成;
小li外边距的问题,这里有个小技巧:给box-hd宽度为1215就可以一行装开5个li
另外,Ctrl+g 输入1,到第一行,给body加一个高度,便于滑动,后面再删掉

具体注意事项:

把li的父亲ul修改的足够宽,一行能装开5个盒子就不会换行了 image.png

设置好一个盒子以后,再删掉其他li,复制粘贴第一个li,再修改内容和图片

image.png

代码实现

结构代码
<!-- 4.box核心内容其余开始 -->
     <div class="box w">

        <div class="box-hd">
            <h3>精品推荐</h3>
            <a href="#">查看全部</a>
        </div>

        <div class="box-bd">
            <ul>
                <li>
                    <img src="images/pic.png" alt="">
                    <h4>Think PHP 5.0 博客系统实战项目演练</h4>
                 <div class="info"><span>高级</span>  ·  1125人在学</div>
                </li>
                <li>
                    <img src="images/pic.png" alt="">
                    <h4>Think PHP 5.0 博客系统实战项目演练</h4>
                    <div class="info"><span>高级</span> · 1125人在学</div>
                </li>
                <li>
                    <img src="images/pic.png" alt="">
                    <h4>Think PHP 5.0 博客系统实战项目演练</h4>
                    <div class="info"><span>高级</span> · 1125人在学</div>
                </li>
                <li>
                    <img src="images/pic.png" alt="">
                    <h4>Think PHP 5.0 博客系统实战项目演练</h4>
                    <div class="info"><span>高级</span> · 1125人在学</div>
                </li>
                <li>
                    <img src="images/pic.png" alt="">
                    <h4>Think PHP 5.0 博客系统实战项目演练</h4>
                    <div class="info"><span>高级</span> · 1125人在学</div>
                </li>
                <li>
                    <img src="images/pic.png" alt="">
                    <h4>Think PHP 5.0 博客系统实战项目演练</h4>
                    <div class="info"><span>高级</span> · 1125人在学</div>
                </li>
                <li>
                    <img src="images/pic.png" alt="">
                    <h4>Think PHP 5.0 博客系统实战项目演练</h4>
                    <div class="info"><span>高级</span> · 1125人在学</div>
                </li>
                <li>
                    <img src="images/pic.png" alt="">
                    <h4>Think PHP 5.0 博客系统实战项目演练</h4>
                    <div class="info"><span>高级</span> · 1125人在学</div>
                </li>
                <li>
                    <img src="images/pic.png" alt="">
                    <h4>Think PHP 5.0 博客系统实战项目演练</h4>
                    <div class="info"><span>高级</span> · 1125人在学</div>
                </li>
                <li>
                    <img src="images/pic.png" alt="">
                    <h4>Think PHP 5.0 博客系统实战项目演练</h4>
                    <div class="info"><span>高级</span> · 1125人在学</div>
                </li>
            </ul>
        </div>

     </div>
    <!-- 4.box核心内容其余结束-->
   
样式代码
/* 精品推荐大模块 */
/* box-hd部分 */
.box{
    margin-top: 30px;
}

.box-hd{
    height: 45px;
}

.box-hd h3{
float: left;
font-size: 20px;
color:#494949
}

.box-hd a{
    float:right;
    font-size: 12px;
    color:#a5a5a5;
    margin-top: 10px;
    margin-right: 30px;
}

/* box-bd */
.box-bd a{
    float: right;
    font-size:12px;
    color: #a5a5a5;
    margin-top: 10px;
    margin-right: 30px;
}
/* 把li的父亲ul修改的足够宽,一行能装开5个盒子就不会换行了 */
.box-bd{
    width: 1215px;
}

.box-bd ul li{
    float:left;
    width: 228px;
    height: 270px;
    background-color: #fff;
    margin-right: 15px;
    margin-bottom: 15px;
}

.box-bd ul li img{
    width: 100%;
}

.box-bd ul li h4{
margin: 20px 20px 20px 25px;
font-size: 14px;
color: #050505;
font-weight: 400;
}

.box-bd .info{
    margin: 0 20px 0 25px;
    font-size: 12px;
    color:#999
}

.box-bd .info span{
    color:#ff7c2d;
}

效果图(未修改成不同内容版)

image.png

3.5底部模块

思路

1号盒子是通栏大盒子,底部footer给高度,底色是白色
2号盒子版心水平居中
3号盒子版权copyright左对齐
4号盒子链接links右对齐

所遇问题

为便于观察,先用粉色背景,由于浮动不占空间,粉色大盒子不在预想位置,如下图,此时需在li的父亲ul去浮动 image.png 去浮动代码(清除浮动之双伪元素清除)

样式代码添加

.clearfix:before,
 .clearfix:after {
     content: "";
     display: table;
 }

 .clearfix:after {
     clear: both;
 }

 .clearfix {
     zoom: 1;
 } 

在结构代码的ul加上clearfix的类名

  <ul class="clearfix ">

去浮动以后,效果图如下 image.png

用外上边距会出现塌陷,如图所示

image.png 要用内边距

.footer .w{
    /* 不要用外边距 */
    /* margin-top: 35px; */
    padding-top: 35px;
}

image.png

代码实现

结构代码
 <!-- 5.footer底部模块开始 -->
     <div class="footer">
        <div class="w">
            <div class="copyright">
                <img src="images/logo.png" alt="">
                <p>学成在线致力于普及中国最好的教育它与中国一流大学和机构合作提供在线课程。<br>
               © 2017年XTCG Inc.保留所有权力。-沪ICP备15025210号</p>

               <a href="#" class="app">下载APP</a>

            </div>
            <div class="links">
                <dl>
                    <dt>关于学成网</dt>
                    <dd><a href="#">关于</a></dd>
                    <dd><a href="#">管理团队</a></dd>
                    <dd><a href="#">工作机会</a></dd>
                    <dd><a href="#">客户服务</a></dd>
                    <dd><a href="#">帮助</a></dd>
               
                </dl>
                <dl>
                    <dt>新手指南</dt>
                    <dd><a href="#">如何注册</a></dd>
                    <dd><a href="#">如何选课</a></dd>
                    <dd><a href="#">如何拿到毕业证</a></dd>
                    <dd><a href="#">学分是什么</a></dd>
                    <dd><a href="#">考试未通过怎么办</a></dd>
                
                </dl>
                <dl>
                    <dt>合作伙伴</dt>
                    <dd><a href="#">合作机构</a></dd>
                    <dd><a href="#">合作导师</a></dd>
                  
                
                </dl>
            </div>

        </div>
     </div>
    <!-- 5.footer底部模块结束 -->
样式代码
/* .footer模块 */
.footer{
height: 415px;
background-color: #fff;
}

.footer .w{
    /* 不要用外边距 */
    /* margin-top: 35px; */
    padding-top: 35px;
}

.copyright{
    float: left;
}

.copyright p{
    font-size: 12px;
    color:#666;
    margin: 20px 0 ;
}
.copyright .app{
    display:block;
    width: 118px;
    height: 33px;
    border: 1px solid #00a4ff;
    text-align: center;
    line-height: 33px;
    color:#00a4ff;
    font-size: 16px;
}

.links{
    float:right;
}

.links dl{
    float: left;
    margin-left: 100px;
}

.links dl dt{
    font-size: 16px;
    color:#333;
}

.links dl dd a {
    font-size: 12px;
    color: #333;
}

底部效果图

image.png

4.总结

为方便大家查看相关代码,我已将本次学成在线案例练习的代码上传至 GitHub 仓库,链接:[github.com/TANG1110/fr…] 。
从摸索代码到看到模块效果,每一步都充满挑战与惊喜。小伙伴们,学习之路虽有坎坷,但坚持就有收获 !希望大家多多关注我,并点赞收藏这篇文章,给予我更多支持与鼓励,我们一起在技术海洋里并肩前行,共同成长~

前端/iOS开发者必备工具软件合集

2026年2月12日 20:00

近期新购入了一款 Mac,趁此机会重装了一遍所有软件,记录一下程序员必备软件合集,方便下次换机或新同事参考。


一、环境与包管理(安装顺序建议)

1. Homebrew

Mac 的包管理器,建议最先安装,后续很多软件可通过它一键安装。

# 安装(官方一键脚本)
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
# 安装后按提示把 brew 加入 PATH(Apple Silicon 常见路径)
echo 'eval "$(/opt/homebrew/bin/brew shellenv)"' >> ~/.zprofile && eval "$(/opt/homebrew/bin/brew shellenv)"

常用技巧:

  • brew install <包名>:安装软件
  • brew upgrade:升级所有已安装包
  • brew search <关键词>:搜索可用包
  • brew list:查看已安装列表
  • brew cleanup:清理旧版本缓存
  • brew cask install <应用>:安装图形界面应用(如 Chrome、Cursor)

2. nvm(Node 版本管理)

多项目可能依赖不同 Node 版本,用 nvm 切换很方便。

# 使用 Homebrew 安装
brew install nvm
# 在 ~/.zshrc 中加入(如用 Oh My Zsh 则编辑该文件)
export NVM_DIR="$HOME/.nvm"
[ -s "/opt/homebrew/opt/nvm/nvm.sh" ] && \. "/opt/homebrew/opt/nvm/nvm.sh"
[ -s "/opt/homebrew/opt/nvm/etc/bash_completion.d/nvm" ] && \. "/opt/homebrew/opt/nvm/etc/bash_completion.d/nvm"
# 然后执行 source ~/.zshrc

常用技巧:

  • nvm install 20:安装 Node 20 最新版

  • nvm use 18:当前终端切换到 Node 18

  • nvm alias default 20:默认使用 Node 20

  • nvm ls:查看已安装版本

  • 项目根目录放 .nvmrc 写版本号(如 20),在目录下执行 nvm use 即可自动切换

  • 技巧2
    在.nvmrc 中写入当前支持的node版本号node -v > .nvmrc

autoload -U add-zsh-hook
load-nvmrc() {
  local nvmrc_path
  nvmrc_path="$(nvm_find_nvmrc)"

  if [ -n "$nvmrc_path" ]; then
    local nvmrc_node_version
    nvmrc_node_version=$(nvm version "$(cat "${nvmrc_path}")")

    if [ "$nvmrc_node_version" = "N/A" ]; then
      nvm install
    elif [ "$nvmrc_node_version" != "$(nvm version)" ]; then
      nvm use
    fi
  elif [ "$(nvm version)" != "$(nvm version default)" ]; then
    # 离开有 .nvmrc 的目录,切回默认版本
    echo "Reverting to nvm default version"
    nvm use default
  fi
}
add-zsh-hook chpwd load-nvmrc
load-nvmrc

即可实现切换到当前文件自动切换到对应的node版本


3. pnpm

比 npm 更快、省磁盘,适合 Monorepo 和前端项目。

# 安装(建议在配置好 nvm 后执行,全局安装到当前 Node)
npm install -g pnpm
# 或使用 Homebrew
brew install pnpm

常用技巧:

  • pnpm install:安装依赖(会严格按 workspace 和 lockfile)
  • pnpm add <包名> / pnpm add -D <包名>:添加依赖 / 开发依赖
  • pnpm run <script>pnpm <script>:运行 package.json 里脚本
  • pnpm store path:查看全局 store 目录;pnpm store prune:清理未引用包
  • 在项目里用 pnpm 前可先 corepack enablecorepack prepare pnpm@latest --activate,便于团队统一 pnpm 版本

4. rvm + CocoaPods

iOS/macOS 开发常用 Ruby 环境管理 + 依赖管理。

# 安装 rvm
curl -sSL https://get.rvm.io | bash -s stable
source ~/.rvm/scripts/rvm

# 安装 Ruby(建议 3.x,与 CocoaPods 兼容好)
rvm install 3.2.0
rvm use 3.2.0 --default

# 安装 CocoaPods
sudo gem install cocoapods -n /usr/local/bin

常用技巧:

  • rvm
    • rvm list:已安装 Ruby 列表
    • rvm use 3.2.0:切换版本
    • 项目目录放 .ruby-version 可自动切换
  • CocoaPods
    • pod init:在 Xcode 工程目录下初始化 Podfile
    • pod install:安装/更新依赖(之后用 .xcworkspace 打开工程)
    • pod update <库名>:更新指定库
    • pod repo update:更新 CocoaPods 官方 spec 仓库(卡住时常用)
    • 国内镜像若慢,可在 Podfile 顶部指定 source,或使用 CDN 源

二、必备软件列表

类型 软件 说明
浏览器 Chrome 开发、调试、日常
终端 iTerm2 + Oh My Zsh 更好用的终端与 shell 配置
抓包/调试 Proxyman macOS 抓包、证书、代理
开发 Xcode iOS/macOS 开发必备
编辑器 Cursor AI 辅助编码
系统监控 iStat Menus 菜单栏 CPU/内存/网速等
数据库 TablePlus 多数据库客户端
截图 iShot 截图、标注、录屏
远程 Termius SSH 等远程连接
JSON OK JSON JSON 查看工具

三、Cursor 已安装插件

当前 Cursor 中已安装的扩展(便于换机或重装时恢复):

扩展 ID 说明
bradlc.vscode-tailwindcss Tailwind CSS IntelliSense(类名补全、预览)
christian-kohler.npm-intellisense npm 包名/版本补全
christian-kohler.path-intellisense 路径补全(import、src 等)
dbaeumer.vscode-eslint ESLint 代码检查
dsznajder.es7-react-js-snippets ES7+ React/Redux 代码片段
eamodio.gitlens Git 增强(行历史、blame、对比等)
esbenp.prettier-vscode Prettier 格式化
formulahendry.auto-rename-tag 自动重命名配对的 HTML/JSX 标签
ms-ceintl.vscode-language-pack-zh-hans 中文(简体)语言包
pkief.material-icon-theme Material 风格文件/文件夹图标
ritwickdey.liveserver 本地 Live Server 前端预览
steoates.autoimport 自动补全并插入 import
stylelint.vscode-stylelint CSS/SCSS/Less 的 Stylelint 检查
tomi.xasnippets Xcode 风格代码片段(iOS 开发)
usernamehw.errorlens 行内显示错误/警告(Error Lens)
wallabyjs.console-ninja Console Ninja(控制台日志增强)
yzhang.markdown-all-in-one Markdown 编辑与预览增强

一键安装(在 Cursor 终端执行):

cursor --install-extension bradlc.vscode-tailwindcss
cursor --install-extension christian-kohler.npm-intellisense
cursor --install-extension christian-kohler.path-intellisense
cursor --install-extension dbaeumer.vscode-eslint
cursor --install-extension dsznajder.es7-react-js-snippets
cursor --install-extension eamodio.gitlens
cursor --install-extension esbenp.prettier-vscode
cursor --install-extension formulahendry.auto-rename-tag
cursor --install-extension ms-ceintl.vscode-language-pack-zh-hans
cursor --install-extension pkief.material-icon-theme
cursor --install-extension ritwickdey.liveserver
cursor --install-extension steoates.autoimport
cursor --install-extension stylelint.vscode-stylelint
cursor --install-extension tomi.xasnippets
cursor --install-extension usernamehw.errorlens
cursor --install-extension wallabyjs.console-ninja
cursor --install-extension yzhang.markdown-all-in-one

四、后续可补充

  • 各软件的偏好设置或快捷键
  • 常用 Xcode 配置(证书、模拟器、Build Settings 等)
  • 团队统一的 .nvmrc.ruby-version、编辑器推荐插件配置(如放入仓库 .vscode/extensions.json

【React-9/Lesson93(2025-12-30)】React Hooks 深度解析:从基础到实战🎯

作者 Jing_Rainbow
2026年2月12日 19:28

📚 一、Hooks 概述

🌟 什么是 Hooks

Hooks 是 React 16.8 引入的新特性,它是一种函数编程思想的体现。Hooks 以 use 开头,用于封装 Vue/React 组件的状态和生命周期,让开发者可以"呼之即来",使用起来非常方便。

Hooks 的核心理念是将组件的状态逻辑抽离出来,使组件更加简洁、可维护。通过 Hooks,我们可以在不编写 class 组件的情况下使用 state 以及其他的 React 特性。

🔧 Hooks 的分类

Hooks 可以分为两大类:

  1. React 内置 Hooks:React 官方提供的一系列常用 Hooks
  2. 自定义 Hooks:开发者根据业务需求自己封装的 Hooks

🎨 二、React 内置 Hooks 详解

1️⃣ useState Hook

useState 是最基础的 Hook,用于在函数组件中添加状态管理能力。

基本语法

const [state, setState] = useState(initialValue)

参数说明

  • initialValue:状态的初始值,可以是任意类型(数字、字符串、对象、数组等)
  • 也可以是一个函数,用于惰性初始化状态

返回值

返回一个数组,包含两个元素:

  • 第一个元素:当前状态的值
  • 第二个元素:更新状态的函数

使用示例

import { useState } from 'react'

function Counter() {
  const [count, setCount] = useState(0)
  
  return (
    <div>
      <p>当前计数:{count}</p>
      <button onClick={() => setCount(count + 1)}>增加</button>
      <button onClick={() => setCount(count - 1)}>减少</button>
    </div>
  )
}

惰性初始化

当初始状态需要通过复杂计算得出时,可以使用函数作为初始值:

const [todos, setTodos] = useState(() => {
  const storedTodos = localStorage.getItem('todos')
  return storedTodos ? JSON.parse(storedTodos) : []
})

这种方式可以避免每次渲染都执行复杂的初始化逻辑。

状态更新注意事项

  1. 直接替换:状态更新是直接替换,而不是合并
const [user, setUser] = useState({ name: '张三', age: 18 })

// 错误方式
setUser({ age: 19 }) // 会丢失 name 属性

// 正确方式
setUser({ ...user, age: 19 })
  1. 异步更新:状态更新是异步的,不能立即获取到最新值

  2. 函数式更新:当新状态依赖于旧状态时,使用函数式更新

setCount(prevCount => prevCount + 1)

2️⃣ useEffect Hook

useEffect 用于处理副作用操作,如数据获取、订阅、手动修改 DOM 等。

基本语法

useEffect(() => {
  // 副作用代码
  return () => {
    // 清理函数(可选)
  }
}, [dependencies])

参数说明

  1. 第一个参数:副作用函数,在组件渲染后执行
  2. 第二个参数:依赖数组,控制副作用何时执行

执行时机

依赖数组 执行时机
不提供 每次渲染后都执行
[] 只在组件挂载时执行一次
[a, b] 当 a 或 b 变化时执行

清理函数

清理函数在组件卸载或下一次副作用执行前执行,主要用于:

  • 清除事件监听器
  • 清除定时器
  • 取消网络请求
  • 清除订阅

使用示例:事件监听

import { useState, useEffect } from 'react'

export default function useMouse() {
  const [x, setX] = useState(0)
  const [y, setY] = useState(0)

  useEffect(() => {
    const update = (event) => {
      console.log('鼠标移动')
      setX(event.pageX)
      setY(event.pageY)
    }
    
    // 组件挂载时,监听 mousemove 事件
    window.addEventListener('mousemove', update)
    console.log('事件监听已添加')
    
    return () => {
      // 组件卸载时,移除 mousemove 事件
      // 防止内存泄漏
      console.log('清除事件监听')
      window.removeEventListener('mousemove', update)
    }
  }, []) // 空依赖数组,只在挂载时执行一次

  return (
    <div>
      鼠标位置:{x} {y}
    </div>
  )
}

使用示例:数据持久化

const STORAGE_KEY = 'todos'

function loadFromStorage() {
  const storedTodos = localStorage.getItem(STORAGE_KEY)
  return storedTodos ? JSON.parse(storedTodos) : []
}

function saveToStorage(todos) {
  localStorage.setItem(STORAGE_KEY, JSON.stringify(todos))
}

export default function useTodos() {
  const [todos, setTodos] = useState(() => loadFromStorage())

  // 监听 todos 变化,保存到 localStorage
  useEffect(() => {
    saveToStorage(todos)
  }, [todos])

  return { todos, setTodos }
}

3️⃣ useContext Hook

useContext 用于在组件树中跨层级传递数据,避免通过 props 一层层传递。

基本语法

const value = useContext(MyContext)

使用步骤

  1. 创建 Context
const MyContext = React.createContext(defaultValue)
  1. 提供 Context
<MyContext.Provider value={/* 某个值 */}>
  <子组件 />
</MyContext.Provider>
  1. 消费 Context
import { useContext } from 'react'

function ChildComponent() {
  const value = useContext(MyContext)
  return <div>{value}</div>
}

使用示例

import { createContext, useContext, useState } from 'react'

// 创建 Context
const ThemeContext = createContext()

// 父组件提供 Context
function App() {
  const [theme, setTheme] = useState('light')
  
  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      <Header />
      <Content />
    </ThemeContext.Provider>
  )
}

// 子组件消费 Context
function Header() {
  const { theme, setTheme } = useContext(ThemeContext)
  
  return (
    <header className={theme}>
      <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
        切换主题
      </button>
    </header>
  )
}

4️⃣ useRef Hook

useRef 用于创建一个可变的 ref 对象,其 .current 属性可以被赋值和读取。

基本语法

const refContainer = useRef(initialValue)

主要用途

  1. 访问 DOM 元素
import { useRef, useEffect } from 'react'

function TextInput() {
  const inputRef = useRef(null)
  
  useEffect(() => {
    inputRef.current.focus()
  }, [])
  
  return <input ref={inputRef} type="text" />
}
  1. 保存可变值(不触发重新渲染)
function Timer() {
  const timerRef = useRef(null)
  
  const start = () => {
    timerRef.current = setInterval(() => {
      console.log('定时器运行中')
    }, 1000)
  }
  
  const stop = () => {
    clearInterval(timerRef.current)
  }
  
  return (
    <div>
      <button onClick={start}>开始</button>
      <button onClick={stop}>停止</button>
    </div>
  )
}
  1. 保存上一次的值
function usePrevious(value) {
  const ref = useRef()
  
  useEffect(() => {
    ref.current = value
  }, [value])
  
  return ref.current
}

📝 三、JavaScript 函数回顾

1️⃣ 普通函数

使用 function 关键字声明的函数。

function greet(name) {
  return `你好,${name}!`
}

console.log(greet('张三')) // 你好,张三!

特点:

  • 有函数提升
  • 可以作为构造函数使用
  • this 指向调用时的对象

2️⃣ 箭头函数

ES6 引入的函数语法,更简洁。

const greet = (name) => {
  return `你好,${name}!`
}

// 简写形式
const greet = name => `你好,${name}!`

console.log(greet('李四')) // 你好,李四!

特点:

  • 没有 this 绑定,this 继承自外层作用域
  • 没有 arguments 对象
  • 不能作为构造函数使用
  • 没有 prototype 属性
  • 更简洁的语法

3️⃣ 匿名函数

没有函数名的函数,通常作为回调函数使用。

setTimeout(function() {
  console.log('1秒后执行')
}, 1000)

// 箭头函数形式的匿名函数
setTimeout(() => {
  console.log('1秒后执行')
}, 1000)

4️⃣ 立即执行函数

定义后立即执行的函数。

(function() {
  console.log('立即执行')
})()

// 箭头函数形式
(() => {
  console.log('立即执行')
})()

用途:

  • 创建独立作用域,避免变量污染
  • 模块化代码
  • 初始化配置

5️⃣ 递归函数

函数调用自身。

function factorial(n) {
  if (n <= 1) return 1
  return n * factorial(n - 1)
}

console.log(factorial(5)) // 120

使用场景:

  • 遍历树形结构
  • 计算阶乘、斐波那契数列
  • 深度优先搜索

6️⃣ 回调函数

作为参数传递给另一个函数的函数。

function processData(data, callback) {
  // 处理数据
  const result = data.map(item => item * 2)
  // 调用回调函数
  callback(result)
}

processData([1, 2, 3], (result) => {
  console.log(result) // [2, 4, 6]
})

在 React 中广泛应用:

<button onClick={() => handleClick(id)}>
  点击我
</button>

7️⃣ 构造函数

用于创建对象的函数。

function Person(name, age) {
  this.name = name
  this.age = age
}

Person.prototype.greet = function() {
  console.log(`我是${this.name},今年${this.age}岁`)
}

const person = new Person('王五', 25)
person.greet() // 我是王五,今年25岁

ES6 类语法:

class Person {
  constructor(name, age) {
    this.name = name
    this.age = age
  }
  
  greet() {
    console.log(`我是${this.name},今年${this.age}岁`)
  }
}

8️⃣ 闭包

函数能够记住并访问其词法作用域,即使函数在其词法作用域之外执行。

function createCounter() {
  let count = 0
  
  return {
    increment: () => {
      count++
      console.log(count)
    },
    decrement: () => {
      count--
      console.log(count)
    }
  }
}

const counter = createCounter()
counter.increment() // 1
counter.increment() // 2
counter.decrement() // 1

闭包的应用场景:

  • 数据私有化
  • 柯里化
  • 模块模式
  • 事件处理程序

在 React Hooks 中的闭包问题:

function Counter() {
  const [count, setCount] = useState(0)
  
  useEffect(() => {
    const timer = setInterval(() => {
      console.log(count) // 永远打印 0,因为闭包捕获了初始值
    }, 1000)
    
    return () => clearInterval(timer)
  }, []) // 空依赖数组
  
  return <button onClick={() => setCount(count + 1)}>{count}</button>
}

解决方案:使用函数式更新或添加正确的依赖。

🚀 四、自定义 Hooks

自定义 Hooks 是一个函数,其名称以 use 开头,函数内部可以调用其他 Hooks。

🎯 自定义 Hooks 的优势

  1. 逻辑复用:将组件间的共享逻辑抽离到自定义 Hook 中
  2. 关注点分离:UI 组件更简单,只负责 HTML + CSS,好维护
  3. 复用性好:和组件一样,是前端团队的核心资产
  4. 业务逻辑更简单:好测试

📦 案例 1:useMouse Hook

监听鼠标位置的自定义 Hook。

import { useState, useEffect } from 'react'

export default function useMouse() {
  const [x, setX] = useState(0)
  const [y, setY] = useState(0)

  useEffect(() => {
    const update = (event) => {
      console.log('鼠标移动')
      setX(event.pageX)
      setY(event.pageY)
    }
    
    // 组件挂载时,监听 mousemove 事件
    window.addEventListener('mousemove', update)
    console.log('事件监听已添加')
    
    return () => {
      // 组件卸载时,移除 mousemove 事件
      // 防止内存泄漏
      console.log('清除事件监听')
      window.removeEventListener('mousemove', update)
    }
  }, [])

  return { x, y }
}

使用方式:

import useMouse from './hooks/useMouse'

function App() {
  const { x, y } = useMouse()
  
  return (
    <div>
      鼠标位置:{x} {y}
    </div>
  )
}

📦 案例 2:useTodos Hook

完整的待办事项管理 Hook。

import { useState, useEffect } from 'react'

const STORAGE_KEY = 'todos'

// 从 localStorage 加载 todos
function loadFromStorage() {
  const storedTodos = localStorage.getItem(STORAGE_KEY)
  return storedTodos ? JSON.parse(storedTodos) : []
}

// 保存 todos 到 localStorage
function saveToStorage(todos) {
  localStorage.setItem(STORAGE_KEY, JSON.stringify(todos))
}

export default function useTodos() {
  // useState 接收函数计算同步
  const [todos, setTodos] = useState(() => loadFromStorage())

  // 监听 todos 变化,保存到 localStorage
  useEffect(() => {
    saveToStorage(todos)
  }, [todos])
  
  // 添加 todo
  const addTodo = (text) => {
    text = text.trim()
    if (text === '') {
      return
    }
    setTodos([
      ...todos,
      {
        id: Date.now(),
        text: text,
        completed: false
      }
    ])
  }
  
  // 删除 todo
  const deleteTodo = (id) => {
    setTodos(todos.filter((todo) => todo.id !== id))
  }
  
  // 切换 todo 完成状态
  const toggleTodo = (id) => {
    setTodos(
      todos.map((todo) => {
        if (todo.id === id) {
          return {
            ...todo,
            completed: !todo.completed
          }
        }
        return todo
      })
    )
  }

  return {
    todos,
    addTodo,
    deleteTodo,
    toggleTodo
  }
}

🎨 组件实现

TodoInput 组件

import { useState } from 'react'

export default function TodoInput({ onAddTodo }) {
  const [text, setText] = useState('')
  
  const handleSubmit = (e) => {
    e.preventDefault()
    if (!text.trim()) return
    onAddTodo(text.trim())
    setText('')
  }
  
  return (
    <form className="todo-input" onSubmit={handleSubmit}>
      <input
        type="text"
        value={text}
        onChange={e => setText(e.target.value)}
      />
      <button type="submit">添加</button>
    </form>
  )
}

TodoItem 组件

export default function TodoItem({ todo, onDeleteTodo, onToggleTodo }) {
  return (
    <li className='todo-item'>
      <input
        type="checkbox"
        checked={todo.completed}
        onChange={() => onToggleTodo(todo.id)}
      />
      <span className={todo.completed ? 'completed' : ''}>
        {todo.text}
      </span>
      <button onClick={() => onDeleteTodo(todo.id)}>删除</button>
    </li>
  )
}

TodoList 组件

import TodoItem from './TodoItem.jsx'

export default function TodoList({ todos, onDeleteTodo, onToggleTodo }) {
  return (
    <ul className='todo-list'>
      {todos.map((todo) => (
        <TodoItem 
          key={todo.id}
          todo={todo}
          onDeleteTodo={onDeleteTodo}
          onToggleTodo={onToggleTodo}
        />
      ))}
    </ul>
  )
}

App 主组件

import useTodos from './hooks/useTodos.js'
import TodoList from './components/TodoList.jsx'
import TodoInput from './components/TodoInput.jsx'

export default function App() {
  const {
    todos,
    addTodo,
    deleteTodo,
    toggleTodo,
  } = useTodos()

  return (
    <>
      <TodoInput onAddTodo={addTodo} />
      {todos.length > 0 ? (
        <TodoList
          todos={todos}
          onDeleteTodo={deleteTodo}
          onToggleTodo={toggleTodo}
        />
      ) : (
        <div>暂无待办事项</div>
      )}
    </>
  )
}

⚠️ 五、内存泄漏与清理

🔍 什么是内存泄漏

内存泄漏是指程序中已动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。

🐌 在 React 中的常见场景

  1. 未清除的事件监听器
// 错误示例
useEffect(() => {
  window.addEventListener('mousemove', update)
  // 缺少清理函数
}, [])
  1. 未清除的定时器
// 错误示例
useEffect(() => {
  const timer = setInterval(() => {
    console.log('定时器')
  }, 1000)
  // 缺少清理函数
}, [])
  1. 未取消的网络请求
// 错误示例
useEffect(() => {
  fetch('/api/data')
    .then(res => res.json())
    .then(data => setData(data))
  // 组件卸载后,请求可能仍在进行
}, [])

✅ 正确的清理方式

事件监听器清理

useEffect(() => {
  const update = (event) => {
    setX(event.pageX)
    setY(event.pageY)
  }
  
  window.addEventListener('mousemove', update)
  
  return () => {
    window.removeEventListener('mousemove', update)
  }
}, [])

定时器清理

useEffect(() => {
  const timer = setInterval(() => {
    console.log('定时器运行')
  }, 1000)
  
  return () => {
    clearInterval(timer)
  }
}, [])

网络请求清理

useEffect(() => {
  const controller = new AbortController()
  
  fetch('/api/data', { signal: controller.signal })
    .then(res => res.json())
    .then(data => setData(data))
    .catch(err => {
      if (err.name !== 'AbortError') {
        console.error(err)
      }
    })
  
  return () => {
    controller.abort()
  }
}, [])

🎯 useEffect 清理函数执行时机

清理函数在以下时机执行:

  1. 组件卸载时:组件从 DOM 中移除时
  2. 下一次副作用执行前:当依赖数组变化,新的副作用执行前
useEffect(() => {
  console.log('副作用执行')
  
  return () => {
    console.log('清理函数执行')
  }
}, [count])

执行顺序:

  • 组件挂载:副作用执行
  • count 变化:清理函数执行 → 副作用执行
  • 组件卸载:清理函数执行

🎓 六、Hooks 使用规则

📏 两条黄金规则

  1. 只在函数最顶层调用 Hooks
    • 不要在循环、条件判断或嵌套函数中调用 Hooks
    • 确保 Hooks 在每次渲染时都以相同的顺序被调用
// ❌ 错误示例
if (count > 0) {
  useEffect(() => {
    // ...
  }, [])
}

// ✅ 正确示例
useEffect(() => {
  if (count > 0) {
    // ...
  }
}, [count])
  1. 只在 React 函数中调用 Hooks
    • 在 React 函数组件中调用 Hooks
    • 在自定义 Hooks 中调用 Hooks
// ❌ 错误示例
function regularFunction() {
  const [count, setCount] = useState(0)
}

// ✅ 正确示例
function Component() {
  const [count, setCount] = useState(0)
}

🔧 ESLint 插件

使用 eslint-plugin-react-hooks 来强制执行这些规则:

{
  "plugins": [
    "react-hooks"
  ],
  "rules": {
    "react-hooks/rules-of-hooks": "error",
    "react-hooks/exhaustive-deps": "warn"
  }
}

📊 七、常用内置 Hooks 补充

useMemo Hook

用于缓存计算结果,避免不必要的重复计算。

import { useMemo } from 'react'

function ExpensiveComponent({ items }) {
  const sortedItems = useMemo(() => {
    console.log('计算排序')
    return items.sort((a, b) => a.value - b.value)
  }, [items])
  
  return <div>{sortedItems.map(...)}</div>
}

useCallback Hook

用于缓存函数,避免子组件不必要的重新渲染。

import { useCallback } from 'react'

function ParentComponent() {
  const handleClick = useCallback(() => {
    console.log('点击')
  }, [])
  
  return <ChildComponent onClick={handleClick} />
}

useReducer Hook

用于复杂的状态管理,类似 Redux。

import { useReducer } from 'react'

function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 }
    case 'decrement':
      return { count: state.count - 1 }
    default:
      return state
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, { count: 0 })
  
  return (
    <div>
      <p>{state.count}</p>
      <button onClick={() => dispatch({ type: 'increment' })}>增加</button>
      <button onClick={() => dispatch({ type: 'decrement' })}>减少</button>
    </div>
  )
}

🎯 八、项目实战总结

📁 项目结构

hooks-demo/
├── src/
│   ├── components/
│   │   ├── TodoInput.jsx
│   │   ├── TodoItem.jsx
│   │   └── TodoList.jsx
│   ├── hooks/
│   │   ├── useMouse.js
│   │   └── useTodos.js
│   ├── App.jsx
│   ├── main.jsx
│   └── index.css
└── package.json

🔄 数据流

App (使用 useTodos)
  ├── TodoInput (接收 onAddTodo)
  └── TodoList (接收 todos, onDeleteTodo, onToggleTodo)
      └── TodoItem (接收 todo, onDeleteTodo, onToggleTodo)

💡 核心要点

  1. 自定义 Hooks 封装业务逻辑,使组件更简洁
  2. useEffect 的清理函数防止内存泄漏
  3. useState 的惰性初始化提高性能
  4. localStorage 持久化数据
  5. 组件化开发,关注点分离

🚀 九、最佳实践

✨ 命名规范

  • 自定义 Hooks 必须以 use 开头
  • 组件名使用 PascalCase
  • 函数名使用 camelCase

🎨 组件设计

  1. 单一职责:每个组件只做一件事
  2. Props 最小化:只传递必要的 props
  3. 组合优于继承:使用组合构建复杂组件

🔧 Hooks 设计

  1. 关注点分离:将相关逻辑放在一个 Hook 中
  2. 返回对象:便于解构使用
  3. 提供清理函数:避免副作用导致的内存泄漏

📊 性能优化

  1. 使用 useMemo 缓存计算结果
  2. 使用 useCallback 缓存函数
  3. 合理使用依赖数组
  4. 避免不必要的渲染

🎊 十、总结

React Hooks 是 React 开发的核心特性,它通过函数式编程思想,让我们能够更优雅地管理组件状态和副作用。

核心要点回顾:

  • useState:管理组件状态
  • useEffect:处理副作用,包括清理函数防止内存泄漏
  • useContext:跨层级传递数据
  • useRef:访问 DOM 和保存可变值
  • 自定义 Hooks:逻辑复用,提高代码可维护性

通过合理使用 Hooks,我们可以编写出更简洁、更易维护、更易测试的 React 代码。Hooks 让函数组件拥有了类组件的所有能力,同时避免了类组件的复杂性和 this 绑定问题。

在实际开发中,遵循 Hooks 的使用规则,合理设计自定义 Hooks,充分利用 React 生态中的各种 Hooks,将大大提升开发效率和代码质量。

从一个想法到可发布:我把博客接进 MCP 的完整实践

作者 mCell
2026年2月13日 01:10

同步至个人站点:从一个想法到可发布:我把博客接进 MCP 的完整实践

最近看 MUI 文档时,我注意到它已经有 MCP 了。然后我就顺手把本地的 Codex、Claude 这类 code agent 都接进了它的 MCP。

体验非常直接:开发里遇到 MUI 用法问题,Agent 不用我手动贴链接,自己调 mui mcp 就能拿到官方答案。用过一次之后,很容易上瘾。

我这边正好也在做 memo code ,最近在补 MCP client / pool / CLI(比如 memo mcp add/remove/list)。越做越觉得,MCP 不只是“大厂工具的生态接口”,它对小工具开发者、库作者、内容站作者也很有价值。

我这次的起点其实很朴素:

我想让别人本地 npx 一下,就能把 CellStack 接进 Agent 的 MCP 生态。

最理想的画面是,用户问一句“mcell 如何解决xxx问题的”,Agent 直接调工具返回结果,而不是我手动甩链接。

所以这篇想表达的重点,不只是“我实现了一个 MCP server”,而是这套做法本身:

如果你手里有工具、库、内容站,想让 Agent 像调工具一样调用你的内容,这是一条几乎零服务器成本的落地路线。

1. 先拆三层:把站点细节从 Agent 侧剥离

我一开始就刻意把方案拆开,避免 Agent 直接依赖站点内部结构:

  1. 构建期生成静态数据(先把站点内容变成标准 JSON)
  2. 用户本地起 MCP Server(@mcell/stack-mcpnpx 即用)
  3. Agent 侧 MCP Client 只管调工具,不关心站点怎么组织、页面怎么写

这三层拆完之后,server 的职责就很纯粹:把内容暴露成一组可查询工具,而且是用户本地跑,我不用额外养服务

第一版很快做出来,但当时模型太“文章中心”,主要只覆盖 blog / topic article。很快我就发现不够:CellStack 不只有文章,还有专题页、站点介绍、关于我这些信息。

如果 Agent 只能读文章,它就更像“文章检索器”,而不是“能回答整个站点内容”的助手。

2. 模型升级成多资源:把“站点”变成可查询资源集合

我把数据模型升级成了多资源结构:

  • blog
  • topic
  • topic_article
  • site_page
  • profile

构建产物也从单一索引,扩成下面这套:

  • index.json
  • latest.json
  • catalog.json
  • articles/*

同时把站点信息和“我”的信息抽成统一数据源,让页面渲染和 MCP 共用同一份内容,避免维护两套数据。

工具层也同步升级:

  • list_resources
  • read_resource
  • list_topics
  • read_site_info

并且我保留了旧工具名做兼容,避免早期接入方被强制迁移。

3. 真正的坑不在协议,在交付链路

中间踩了两个很真实的坑:

  1. MCP 握手失败。我一度怀疑是协议问题,最后发现根因是 npx 当时还拉不到包(404),stderr 里是 npm 报错。
  2. 手工发 npm 不稳定。token / 2FA 在“我电脑上能发”不等于“可重复的交付链路”。

这俩问题让我意识到,如果目标是“一句命令接入”,那真正要工程化的不只是 MCP,而是发布与分发。

4. 把发包也工程化:从“能用”到“可持续演进”

后面我把发布流程写进了 CI:

  • 构建流程生成 MCP 原数据
  • 单独加 publish-stack-mcp workflow,检测 packages/stack-mcp 改动后自动发包(版本已存在则跳过)

然后又做了一个很关键的小优化:默认静默启动日志,减少客户端把 stderr 噪音误判成异常;需要排查时再开 STACK_MCP_DEBUG

到这一步,这件事才算从“能不能做”变成了“可发布、可维护、可演进”。

5. 接入体验:一句命令 + 站点内直接给配置

现在接入基本一句命令:

codex mcp add cellstack -- npx -y @mcell/stack-mcp@0.2.1

此外我在站点右上角加了 MCP 图标和配置弹窗,直接给 Codex / Claude / Memo / 标准配置的可复制示例,尽量把接入门槛压到最低。

相关记录:

6. 为什么我现在更偏向 MCP(而不只是 skills)

很多人会说“skills 不就行了”。skills 当然有用,但它有个很现实的维护问题:你经常要为不同 Agent 分别写接入说明和配置。并且这类文档库等产品,更新比较及时,使用 SKILLS 就需要频繁的修改本地 SKILSS 文档,远不如使用 MCP。

Claude Code CLI、Cursor、Codex CLI、OpenCode……各家入口、目录约定、文档风格都不同。久而久之,一个仓库里会长出一堆 .xxx/ 配置目录,后续改接口、改 schema、加字段时,就得挨个同步、挨个测。

这套静态 JSON + npx 本地 MCP Server 的组合,本质上是把问题收敛成三件事:

  • 站点侧:维护一份可演进的标准化数据产物(JSON / catalog / latest / resources)
  • 工具侧:维护一个 MCP Server(npx 即用)
  • Agent 侧:谁支持 MCP 谁就能接,差异只剩“添加 server 的命令怎么写”

事实上,定义好mcp server tools、json docs 生成脚本逻辑之后不需要再管了,后面就快快乐乐的用吧。

这对长期维护“自己的专属知识库”非常友好。后续如果我打算做个人Agent知识库的话,觉得这种做法还是蛮好的。甚至扩展一下,MCP + SKILL的组合也不错,或许长期积累之后,AGENT 即我呢?

(完)

如何零成本搭建个人站点

作者 mCell
2026年2月13日 00:52

同步至个人站点:如何零成本搭建个人站点

站点地址:stack.mcell.top,包含完整的:写作、评论、部署、MCP支持...

我经常写作,最开始是在一些平台上,比如稀土掘金。后面慢慢写多了,就想有个自己的博客平台。

最初搭建的博客很简单:一个纯静态的 HTML 文件,内容也不复杂,写点自我介绍,当作个人站点。直接托管到 GitHub Pages,域名用的也是它默认那串。

但很快就发现:功能太少了。 比如发布文章?评论?甚至想加点扩展能力都很难——纯 HTML 又没框架,后面越改越痛苦。

接着就走上了“大家都走过的弯路”: 买了轻量服务器,又买了域名……然后写服务端、接数据库、写前端,把整套都搭起来。

直到后面参与了一个开源项目才意识到: 这种内容站点/文档站点,压根没必要搞这么重。成熟框架太多了,比如 VitePress 这种(比如Vue官网就是VitePress),基本开箱即用。

然后我就重构了一次:直接上 VitePress。部署?还是 GitHub Pages。那时候至少配上了自定义域名,看起来舒服多了:stack.mcell.top

又过了一段时间,我开始觉得个人站点还是有点单调。VitePress 能改,但做深度定制的时候会有点别扭(有些地方甚至会翻车)。 索性就 vibe coding 一把:把原先 VitePress 那套,重构到了 Next.js

路线也很清晰:

  • Next.js
  • SSG / 静态导出(要部署到 GitHub Pages,关键是要把站点导出成纯静态)
  • GitHub Actions 自动构建
  • 部署到 GitHub Pages
  • 配上自定义域名

后面我又陆续补了评论和文档搜索功能。用到服务器了吗?没有。

  • 评论用的是 giscus:本质是把评论托管在 GitHub Discussions 里,前端加载组件就行,也不用数据库。

  • 搜索用的是 pagefind:还是静态站那套玩法,构建阶段生成索引,运行时纯前端查询。

再后面,我还给博客加了 MCP 功能。同样,还是没有服务器: SSG 阶段生成一份 JSON docs,只要把路径映射到 MCP server 就行;然后我做了个本地的 MCP server,用户安装大概这样:

memo mcp add stack-mcepp npx -y @mcell/stack-mcell

memo code 是我最近自己写的一个轻量级编程Agent,类似Claude code那种,感兴趣可以参与进来。

本质上就是:agent 请求本地 MCP server,MCP server 再去拉取我提前生成好的 JSON 内容。

文档站上 MCP 的整体方案的记录我放在这里: stack.mcell.top/blog/2026/m…

这一套折腾下来,依然是 0 成本。分享给大家,或许是个不错的“0 成本建站思路”。

提示词

如果你对这套方案比较感兴趣,想要多了解了解,你可以clone我的博客仓库:mcell satck,或者是直接把这段提示词发给AI,他会给你方案:

我想搭建一个个人博客,大致如下:

- 框架:nextjs ssg
- 部署:Github Action 自动化部署 + Github Page(自定义域名)
- 图片存储:对象存储(七牛云或者火山引擎)
- 搜索服务:pagefind
- 文章评论服务:giscus
- 开发方式:Vibe coding
- mcp 集成:参考 https://stack.mcell.top/blog/2026/mcp-from-idea-to-delivery-for-content-site

请你给我一个具体可落地的方案(分阶段)

(完)

为什么 Memo Code 先做 CLI:以及终端输入框到底有多难搞

作者 mCell
2026年2月12日 23:58

如果你对我的 Code Agent项目感兴趣,可以看这里:

Github Repo: Memo Code - Github

站点:Memo Web Site

大概四年前,我刚接触编程。学的是 C 语言,第一个程序当然是 hello world。

很简单,几行就写完。run 一下,弹出来一个 terminal(我已经忘了当时用的是什么:cmd?PowerShell?反正不重要),然后打印了一行:

“hello, world!”

从那以后,在我还没接触前端之前,我写的所有程序几乎都靠终端完成输入输出:猜数字、九九乘法表,再到后来刷算法,基本就是——写、跑、看终端、改、再跑。

那时候倒也没觉得有什么问题,只是偶尔会突然有点空虚: 难道以后我工作的成果,就是一直对着这个黑框框吗?

第一种认知:终端就是编程的全部

一开始我对“程序结果形态”的理解非常单一:

输入 → 运行 → 终端输出

终端就是一切:日志、交互、结果展示,全在那儿。 它很直接,很原始,也很“学生气”。

第二种认知:页面才是“程序结果”的另一种世界

后来我接触了前端。直到靠前端拿到第一份实习、第一份工作,我才对“程序结果形态”形成了第二种认知:

不仅仅是终端里几行日志,也可以是页面效果、动画、交互。 之后陆续做过小程序、App、桌面程序……那段时间里,终端更像“开发过程的工具”,而不是“产品本体”。

也就是从那时候开始,我对 terminal 的误解更深了: 它好像就应该是黑框框 + 命令 + 日志,仅此而已。

第三种认知:原来 terminal 也可以玩得这么花

25 年下半年,自从 Gemini CLI 这类东西开始出现之后,我对“程序结果形态”有了第三种认知:

原来 terminal 也可以玩得这么花。

彩色输出、输入框、选择框、进度条……该有的都有。 当时我没怎么深入研究,只是隐约意识到:以前我对 terminal 的理解偏了,它并不等于“只能打印”。

现实把我拉回来了:写 Agent,界面到底做什么?

后面开始做值班,又不得不把 Linux 命令捡起来:从 pwd / cat / tail / find,到 vi / vim……慢慢也熟练了。

直到最近两个月,我开始认真做我的 code agent:memo github.com/minorcell/m…

等我写好了 MVP,写好了 runtime,写好了 toolrouter、tools 等;下一步突然被一个看起来很“产品”、但本质上很“工程”的问题卡住了:

界面到底做什么? 传统 Web 页面?还是终端交互?

如果做传统 Web UI,我其实很拿手:加一个 HTTP server 包,再来个 Web UI 包就够了。 但现实是,市面上大多数 code agent(比如 claude code cli、codex cli)都是从终端交互做起的。后续再补 VSCode 插件、桌面版,甚至浏览器插件。

题外话:这里也不得不感慨一下——原来我大前端确实挺“六”的:只要界面能画出来,基本都能做。也更坚定一个想法:AI 时代,大前端技术只会更普及。

最终,可能是“理所当然”,也可能是对陌生技术栈的兴趣使然,我决定: memo code 的第一种产品形态,先做终端 CLI。

选 Ink:看起来都正常,直到输入框

调研开源的 Gemini CLI 时,我发现他们用的是 Ink(React for CLI)。我也就直接跟了:选 Ink。

一开始真的很顺:

  • 会话记录渲染没问题
  • slash 指令也能做
  • 封装组件库也舒服

似乎都挺好……直到我碰到最难的一块:输入框。

以前做 Web app:

  • 单行用 input
  • 多行用 textarea

天然、顺滑、毫无心理负担。

但在终端里,多行输入并不是默认就“应该支持”的体验。甚至 Ink 的 input 组件,也只有单行。

这时候你才会意识到:在终端里,“输入框”不是 UI 控件,它更像是一个小型编辑器。

我以为我解决了,结果只是解决了“最简单的部分”

我一开始尝试的方案很朴素,比如:

  • Shift + Enter 插入换行符

表面看起来能用了。 但很快更真实的问题出现了:粘贴文本。

粘贴一段文本时,你会遇到:

  • 显示残缺
  • 粘贴后光标位置不对
  • 输入状态偶尔乱跳

这时候我才明白:我不是在做“多行 input”,我是在终端里硬写一个“半个 textarea”。

如果对照二八法则:掌握 20% 的技术,就能做出 80% 的功能。

但要把剩下 20% 做好,往往需要补齐另外 80% 的细节。

终端交互就是这样:你很快能做出一个“能用”的 CLI;但要做得像样,细节多到离谱。

于是我最后认真设计了一套方案(写在这个 issue 里): github.com/minorcell/m…

解决方案:在 Ink 里做一个“可控的多行编辑器内核”

我最后没有继续纠结“有没有更好的 input 组件”,而是换了一个思路:

把多行输入当成一个小型编辑器来做。 在 Ink 的限制里,把“编辑状态”和“渲染”解耦,然后在输入事件层做适配。

整个方案我拆成三个核心模块。

编辑器状态管理层

我不再把输入框当成“一个字符串”,而是当成一个状态机。

核心结构就是:

  • value: string(当前文本)
  • cursor: number(光标在文本中的位置)

听起来很简单,但一旦涉及多行、上下移动、终端折行,坑就开始密集出现:

  • 光标移动要能跨行
  • 上下键移动不能乱跳(要记住“我想待在哪一列”)
  • Unicode 也得小心:emoji / 代理对如果按字符串下标移动,光标很容易卡在“半个字符”上
  • 所以要做 clamp,保证 cursor 永远落在合法边界

这一层的目标只有一个: 不管 Ink 怎么渲染,我内部都能稳定得到“当前文本是什么 + 光标在哪里”。

粘贴检测

真正让我没绷住的,其实是粘贴。

终端里粘贴一坨文本时,底层输入事件会被拆成很多个 keypress,然后 Ink / 渲染层每次都会触发更新。你会遇到一种非常诡异的现象:

你粘贴的是 A,但 UI 看起来像是 A 的碎片; 光标也像在“追不上输入”,最后漂到一个你完全无法理解的位置。

所以我做了一个“粘贴 burst 检测”:用启发式规则把粘贴从普通输入里识别出来,然后改成 缓冲 + 批量插入

  • 时间间隔规则(主机制):字符到达间隔 < 8ms 基本视为粘贴(人不可能这么快)
  • 字符数量规则(备用机制):连续字符 ≥ 16 时也按粘贴处理(对中文/emoji 路径更稳)
  • 识别到粘贴后进入状态机:pending → active → flush 先塞 buffer,等“粘贴结束”再一次性写入 value,避免每个字符都触发一轮复杂计算

这一步做完之后,“粘贴残缺 / 光标乱跳”基本从玄学变成可控问题了。

输入处理适配器:快捷键 + 换行策略 + 视觉换行

终端输入要像编辑器,光靠“插入字符”是不够的,你还得补齐肌肉记忆:

  • 支持常见快捷键(Ctrl+A / Ctrl+E / Ctrl+U / Ctrl+K / Ctrl+W 这类)
  • 换行与提交要分开:
    • Shift + Enter 永远插入新行
    • Enter 默认提交
    • 但如果处在粘贴期间(或粘贴后的短窗口期),Enter 当作插入新行
      • 防止用户“粘贴完顺手一回车”直接把消息提交出去了(这个真的很常见)

还有一个关键点:逻辑行 vs 视觉行分离

  • 逻辑行:真正的 \n
  • 视觉行:终端宽度导致的自动折行

编辑用逻辑行,展示按视觉行计算,这样长段落在不同宽度终端也能保持一致体验。

同时,视觉换行还要能响应终端 resize(不然窗口一变宽/变窄,光标又漂移)。

这一层本质上就是: 把终端输入从“能打字”推到“像个 textarea”。

念头通达,交给 codex 快速帮我实现了一个版本。

结果:我解决了剩下 20% 里最烦的 15%

这套方案不可能一把梭把所有边界问题抹平。 不同终端模拟器、不同输入法路径、极端大文本性能……仍然需要持续打磨。

但至少到这里,我觉得我把剩下 20% 里最难受、最影响体验的那 15% 解决掉了:

  • 多行输入稳定
  • 粘贴不再玄学
  • 光标不再乱飞
  • Enter / Shift+Enter 行为可控

收尾:终端不只是输入输出,它可以是简易版 Web App

memo 的这段实践,让我对终端交互有了更清晰的认知:

它不再只是我最开始学编程时那种“输入输出 + 打日志”。 它完全可以是简易版本的 Web App:有组件、有状态、有布局,甚至能长出一点“编辑器”的味道。

这感觉有点像当年最早的 HTML 刚出来时:朴素、克制,但足够表达。 而我现在做的,就是在这个黑框框里,把“能表达的东西”再往前推一点点。

如果你对我的 Code Agent项目感兴趣,可以看这里:

Github Repo: Memo Code - Github

站点:Memo Web Site

构建无障碍组件之Dialog Pattern

作者 anOnion
2026年2月12日 22:59

Dialog (Modal) Pattern 详解:构建无障碍模态对话框

模态对话框是 Web 应用中常见的交互组件,用于在不离开当前页面的情况下展示重要信息或获取用户输入。本文基于 W3C WAI-ARIA Dialog Pattern 规范,详解如何构建无障碍的模态对话框。

一、Dialog 的定义与核心功能

Dialog(对话框)是覆盖在主窗口或其他对话框之上的窗口。模态对话框会阻断用户与底层内容的交互,直到对话框关闭。底层内容通常会被视觉遮挡或变暗,以明确当前焦点在对话框内。

与 Alert Dialog 不同,普通 Dialog 适用于各种需要用户交互的场景,如表单填写、信息展示、设置配置等。它不强调紧急性,用户可以自主决定是否与之交互。

二、Dialog 的关键特性

模态对话框具有以下核心特性:

焦点限制:对话框包含独立的 Tab 序列,Tab 和 Shift+Tab 仅在对话框内循环,不会移出对话框外部。

背景禁用:对话框背后的内容处于 inert 状态,用户无法与之交互。尝试与背景交互通常会导致对话框关闭。

层级管理:对话框可以嵌套,新的对话框覆盖在旧对话框之上,形成层级结构。

三、WAI-ARIA 角色与属性

3.1 基本角色

role="dialog" 是对话框的基础角色,用于标识模态或非模态对话框元素。

aria-modal="true" 明确告知辅助技术这是一个模态对话框,背景内容当前不可用。

3.2 标签与描述

aria-labelledby 引用对话框标题元素,为对话框提供可访问名称。

aria-describedby 引用包含对话框主要内容的元素,帮助屏幕阅读器用户理解对话框目的。

<dialog
  role="dialog"
  aria-modal="true"
  aria-labelledby="dialog-title"
  aria-describedby="dialog-desc">
  <h2 id="dialog-title">用户设置</h2>
  <p id="dialog-desc">请配置您的个人偏好设置。</p>
  <!-- 对话框内容 -->
</dialog>

四、键盘交互规范

4.1 焦点管理

打开对话框时:焦点应移动到对话框内的某个元素。通常移动到第一个可聚焦元素,但根据内容不同可能有不同策略:

  • 内容包含复杂结构(列表、表格)时,可将焦点设置在内容的静态元素上,便于用户理解
  • 内容较长时,将焦点设置在标题或顶部段落,避免内容滚动出视野
  • 简单确认对话框,焦点可设置在主要操作按钮

关闭对话框时:焦点应返回到触发对话框的元素,除非该元素已不存在。

4.2 键盘操作

按键 功能
Tab 移动到对话框内下一个可聚焦元素,到达末尾时循环到第一个
Shift + Tab 移动到对话框内上一个可聚焦元素,到达开头时循环到最后一个
Escape 关闭对话框
// 焦点管理示例
function openDialog(dialog, triggerElement) {
  dialog.triggerElement = triggerElement;
  dialog.showModal();

  // 将焦点设置到第一个可聚焦元素或标题
  const focusable = dialog.querySelector(
    'button, [href], input, select, textarea',
  );
  if (focusable) {
    focusable.focus();
  }
}

function closeDialog(dialog) {
  dialog.close();
  // 恢复焦点到触发元素
  if (dialog.triggerElement) {
    dialog.triggerElement.focus();
  }
}

五、实现方式

5.1 原生 dialog 元素

HTML5 <dialog> 元素是推荐实现方式,内置模态行为和无障碍支持:

  • 自动焦点管理showModal() 自动将焦点移动到对话框内第一个可聚焦元素
  • 内置 ESC 关闭:用户按 ESC 键自动关闭对话框
  • 自动模态背景:自动创建背景遮罩,阻止与底层内容交互
  • 焦点循环:Tab 键在对话框内自动循环,不会移出对话框
  • 内置 ARIA 属性:浏览器自动处理 aria-modal 等属性
  • Top Layer 支持:模态对话框显示在浏览器顶层,不受 z-index 限制
<dialog
  id="settings-dialog"
  aria-labelledby="dialog-title">
  <div class="dialog-header">
    <h2 id="dialog-title">设置</h2>
    <button
      onclick="this.closest('dialog').close()"
      aria-label="关闭"></button>
  </div>
  <div class="dialog-content">
    <label>
      用户名
      <input type="text" />
    </label>
  </div>
  <div class="dialog-footer">
    <button onclick="this.closest('dialog').close()">取消</button>
    <button onclick="saveSettings()">保存</button>
  </div>
</dialog>

<button onclick="document.getElementById('settings-dialog').showModal()">
  打开设置
</button>

5.2 div + ARIA 实现

需要手动处理焦点管理和背景交互。这种方式适用于需要自定义动画、复杂布局或旧浏览器兼容的场景:

<div
  role="dialog"
  aria-modal="true"
  aria-labelledby="dialog-title"
  class="modal-overlay">
  <div class="modal-content">
    <h2 id="dialog-title">确认操作</h2>
    <p>确定要执行此操作吗?</p>
    <button>取消</button>
    <button>确认</button>
  </div>
</div>

六、最佳实践

初始焦点策略和键盘交互的详细规范请参考 4.1 焦点管理。在实际应用中,建议遵循以下策略:

  • 信息展示:焦点设置在标题或内容开头,便于屏幕阅读器顺序阅读
  • 表单输入:焦点设置在第一个输入框
  • 确认操作:焦点设置在主操作按钮或取消按钮(视风险而定)

6.1 关闭方式

提供多种关闭方式提升用户体验:

  • ESC 键关闭(原生 dialog 自动支持)
  • 关闭按钮
  • 点击背景遮罩关闭(可选)
  • 明确的取消/确认按钮

6.2 嵌套对话框

支持多层对话框嵌套,每层新对话框覆盖在上层:

<dialog id="layer1">
  <button onclick="document.getElementById('layer2').showModal()">
    打开第二层
  </button>
</dialog>

<dialog id="layer2">
  <p>第二层对话框</p>
</dialog>

6.3 避免滥用

对话框会中断用户流程,应谨慎使用:

  • 优先使用非模态方式展示非关键信息
  • 避免对话框内再嵌套复杂导航
  • 保持对话框内容简洁,避免过多滚动

七、Dialog 与 Alert Dialog 的区别

特性 Dialog Alert Dialog
用途 一般交互、表单、配置 紧急确认、警告、错误
紧急性 非紧急 紧急,需立即响应
关闭方式 多种方式 通常只有确认/取消
角色 role="dialog" role="alertdialog"
系统提示音 可能有

八、总结

构建无障碍的模态对话框需要关注三个核心:正确的 ARIA 属性声明、合理的焦点管理、完整的键盘交互支持。原生 <dialog> 元素简化了实现,但开发者仍需理解无障碍原理,确保所有用户都能顺利使用。

遵循 W3C Dialog Pattern 规范,我们能够创建既美观又包容的对话框组件,为不同能力的用户提供一致的体验。

文章同步于 an-Onion 的 Github。码字不易,欢迎点赞。

❌
❌