阅读视图

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

通过英伟达平台免费调用 GLM4.7 教程

前言

最近在折腾 AI Agent 和模型接入相关的事情时,意外发现英伟达居然提供了一个面向开发者、可以免费调用模型 API 的平台。更关键的是,这个平台上不仅能用到一些主流开源模型,还能直接使用最近热度很高,号称开源 Coding 能力最强的 GLM4.7,以及综合表现相当稳的 minimax-m2.1

说实话,在如今 API 几乎全面 token 计费、随便一个复杂任务就轻松几十万甚至上百万 token 的背景下,这种可以正经做开发实验、不用一上来就烧钱的平台,对个人开发者和学习阶段的人来说非常友好。所以这篇文章主要做三件事:

  • 介绍 NVIDIA Build 平台本身能做什么
  • 记录从注册到实际调用 API 的完整流程
  • 分享一段真实使用下来的模型体验与限制

整体偏实践,结论也会尽量基于实际使用情况展开。

NVIDIA Build 模型平台

NVIDIA Build可以理解为英伟达官方提供的一个模型集成与调试平台。平台上已经部署了大量模型,涵盖文生文(Chat / Reasoning / Coding)、文生图 / 图生文、语音相关等模型。目前平台上可见的模型数量在 200+,基本覆盖了市面上主流的开源模型生态,例如:deepseek-R1 / deepseek-v3.xqwen3 / qwen-coderkimi-k2minimax-m2.1z-ai/glm4.7,平台本身还提供了在线 Playground(支持参数调节、tools 调用)、OpenAI 风格的 API 接口、模型示例代码一键生成等能力。

注册账号与 API Key 申请

账号注册说明

注意:疑似因为近期注册用户激增,新账号存在一定概率无法正常申请 API Key 的问题。在不影响账号合规性的前提下,比较稳妥的做法是使用非国内常见的邮箱注册,例如相对少见的邮箱(yeah.net),或国外邮箱(gmail.com)等,以及注册时使用浏览器无痕窗口,避免历史状态干扰。

创建账号

访问:build.nvidia.com/ ,点击左上角 Login

在弹窗中输入邮箱并点击 Next,随后填写注册信息并完成人机验证。

这里需要注意:在“更多注册选项”中可以看到 QQ、微信等方式,但不建议使用第三方快捷登录。在当前阶段,使用这些方式注册后,账号更容易出现 API 权限受限的情况。

完成注册后,会进入一些偏个性化设置的步骤(例如名称、偏好选项),按需填写即可。

如果账号状态正常,稍等片刻后,页面顶部会出现提示:

Please verify your account to get API access

点击右上角的 Verify 进入验证流程。

手机号验证

在验证弹窗中,将默认的 +1 修改为 +86,输入国内手机号即可。这里不需要刻意规避,国内手机号是可以正常通过验证的

点击 Send Code via SMS,完成验证码验证。

创建 API Key

验证完成后,点击右上角头像,进入 API Keys 管理页面。

如果账号状态正常,这里可以看到 Generate API Key 按钮。

点击后,输入一个 Key 名称(仅用于区分),过期时间选择 Never Expire

生成完成后,复制并妥善保存该 API Key,后续调用只会展示一次。

如果在 API Keys 页面完全看不到生成按钮,而是类似下图所示的提示界面,基本可以确认该账号当前无法使用 API 功能,建议更换账号重新注册。

使用 API Key 调用

本地客户端配置

只要是支持 OpenAI 风格接口的客户端基本都可以直接使用,我这里以 Jan 为例。

进入设置页,添加一个新的模型提供商。

  • Provider Name:自定义(例如 Nvidia
  • API Key:填写刚刚生成的 Key
  • Base URL:https://integrate.api.nvidia.com/v1

完成后添加模型。

例如添加 GLM4.7:

z-ai/glm4.7

新建会话并选择该模型后,即可正常对话。从体感上看,在普通对话场景下 token 输出速度非常快

获取模型列表

Jan 也支持直接调用 /models 接口获取模型列表,点击刷新即可自动拉取并添加。

需要注意的是:

  • /models 返回的是平台全量模型列表
  • 其中包含文生图、语音、多模态等模型
  • 并非所有模型都支持 chat / text-to-text

因此,如果在客户端中直接选择不支持 chat 的模型发送消息,会直接报错,这是模型能力不匹配,不是接口问题。

Playground 与模型调试

在 NVIDIA Build 平台的 Models 页面中,可以通过搜索 Chat / Reasoning 筛选支持的模型,或者在 Playground 页面的左上角看到所有支持文生文的模型列表。

kimi-k2 为例,点击模型后可以进入在线调试界面。

  • 左侧 Tools:可启用模型支持的工具
  • 右侧 Parameters:控制温度、最大 token 等参数

点击右上角 View Code,可以直接看到对应的 API 调用示例,包括 Base URL、Model ID、Messages 结构等。

Tools 调用示例

在部分模型中可以直接启用 tools,这里以 minimax-m2 为例演示。

启用 get_current_weather 工具后,询问某地天气,模型会自动进行 tools 规划与调用,并返回结果。

再次点击 View Code,可以看到完整的 tools 调用示例代码。

模型与接口

NVIDIA Build 提供的是 OpenAI 风格 API,接口层面兼容 chat.completions / responses,是否支持 chattools、多模态,取决于模型本身。所以,最稳妥的方式仍然是在平台 Models 页面中筛选 chat / reasoning,再决定是否接入到本地客户端或代码中。

使用体验与限制

说一下 GLM4.7 这个模型。它并不是我第一次用,在刚发布不久时我就已经通过一些第三方 API 供应商接触过,这次算是第二次较完整地使用。综合两次实际开发体验,说实话体感并不算好。

首先一个比较明显的问题是,在我目前常用的模型里(比如 qwen-code、gpt-5.1、gpt-5.2、Claude 等),只有 GLM4.7 会在生成的文件头部插入 Markdown 的代码块标记。这个问题在代码编辑和文件生成场景下尤其影响体验,需要额外清理,看起来就很蠢。

其次是执行效率问题,这一点让我感觉很奇怪。纯对话场景下它的响应速度是很快的,但一旦进入干活模式,比如稍微复杂一点的任务编排、代码修改或多步执行,单个任务可能会跑十几甚至二十分钟。问题不在于我不能接受模型执行复杂任务耗时,而是过程中偶尔会出现明显的停顿或卡住一段时间再继续,节奏非常不稳定。

一开始我也怀疑是 API 调用频率或限流导致的,但后来在同样的客户端、同样的任务复杂度下切换到 minimax-m2,发现并不是这个原因。minimax 的整体执行节奏要顺畅得多,调用也更激进,甚至可以轻松跑到 40 次 / 分钟 的平台上限,当然代价就是一旦规划稍微激进,就很容易直接撞上限流,接口报错,任务中断。

从平台层面来看,这个平台整体体验其实是非常不错的:模型选择多、接入成本低、示例清晰,对学习和实验阶段的开发者非常友好。平台的限制也比较直观明确,比如 API 调用频率限制在 40 次 / 分钟,超出后直接返回错误,这一点在 minimax-m2 上体现得尤为明显。

回到 GLM4.7 本身,客观来说它的功能是完全正常的:工具调用没问题,代码编辑能用,对话速度也快,只是在复杂任务执行阶段明显偏慢,且稳定性不够好。相比之下,minimax-m2 在相同条件下执行节奏更线性、更听话,只是更容易触发平台限流 (当然了,因为频繁触发限流所以我也没深度使用 minimax)

总结来说,GLM4.7 并不是不能干活,但实际开发体验一般,尤其是在需要长时间、连续执行任务的场景下,效率和节奏上的问题会被放大。

结语

实话说,这个平台在当前这个时间点,真的算是相当良心的存在。对想学习 AI Agent、工具调用、多模型编排的开发者来说,能够在不额外付费的情况下反复试错,本身就很有价值。

当然了,平台策略和风控状态可能随时变化,如果只是想白嫖一点体验,建议还是尽早注册,至少在账号状态正常的前提下,把 API Key 拿到手。

至于模型怎么选,建议多试、多对比,别迷信单一模型。能稳定把活干完的模型,才是好模型。

相关链接

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

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

在Unity URP Shader Graph中,Integer节点是一个基础但功能强大的工具节点,它允许开发者在着色器程序中定义和使用整型常量。虽然着色器编程通常以浮点数运算为主,但整数在特定场景下具有不可替代的作用,特别是在控制流程、数组索引、循环计数和条件判断等方面。

Integer节点的基本概念

Integer节点在Shader Graph中代表一个整型常量值。与其他节点不同,Integer节点不接收输入,而是直接输出一个用户定义的整数值。这个特性使得它成为着色器中的固定参数或控制变量的理想选择。

Integer节点的核心特点

  • 输出值为整型,但在着色器运算中会自动转换为浮点数
  • 可用于控制着色器中的离散状态和条件分支
  • 适合用于数组索引、循环计数和枚举类型的表示
  • 在性能优化方面,整数运算通常比浮点数运算更高效

在Shader Graph中的定位

Integer节点属于Shader Graph的Input类别,与其他的常量节点如Float、Vector2、Vector3等并列。它提供了在可视化着色器编程中处理整数数据的能力,弥补了传统节点图主要以浮点数为中心的设计局限。

节点属性和配置

端口配置

Integer节点只有一个输出端口,其配置如下:

  • 名称:Out
  • 方向:输出
  • 类型:Float
  • 绑定:无
  • 描述:输出整数值,但在类型系统中作为Float处理

端口特性的深入理解

虽然端口类型标记为Float,但实际上输出的是整数值。这种设计是因为HLSL和GLSL着色语言中,整数和浮点数在很多时候可以隐式转换,而且Shader Graph的内部数据类型系统主要以浮点数为基础。在实际着色器代码生成时,这个整数值会被正确地处理为整数类型。

控件参数

Integer节点提供了一个简单的控件用于配置其输出值:

  • 名称:无(在节点上直接显示数值)
  • 类型:Integer
  • 选项:无
  • 描述:定义节点输出的整数值

控件使用要点

  • 可以直接在节点上的输入框中输入整数值
  • 支持正负整数,范围通常受着色语言限制但足够大多数应用场景
  • 数值改变会实时更新节点预览和生成的着色器代码

生成的代码分析

根据官方文档,Integer节点生成的代码示例如下:

HLSL

float _Integer = 1;

代码生成机制深入解析

虽然示例代码显示变量被声明为float类型,但在实际的HLSL编译中,当这个值用于整数上下文时(如数组索引、循环计数器),编译器会进行适当的优化和处理。在更复杂的使用场景中,生成的代码可能会有不同的表现形式:

HLSL

// 当Integer节点用于数组索引时
int index = 2;
float value = _MyArray[index];

// 当用于循环控制时
for (int i = 0; i < _IterationCount; i++)
{
    // 循环体
}

变量命名规则

在生成的代码中,Integer节点对应的变量名称会根据节点在Graph中的名称自动生成。如果节点被命名为"TileCount",则生成的变量可能是_TileCount_Integer_TileCount,具体命名规则取决于Shader Graph的版本和配置。

Integer节点的实际应用

基础数值应用

Integer节点最直接的用途是提供整型常量值,用于控制着色器的各种参数:

  • 平铺和偏移控制:指定纹理平铺次数
  • 循环次数设置:控制for循环的迭代次数
  • 数组大小定义:确定固定大小数组的维度
  • 枚举状态表示:用整数代表不同的渲染状态或材质类型

纹理平铺示例

在纹理采样节点中,使用Integer节点控制平铺参数:

Integer节点(值:4) → TilingAndOffset节点 → SampleTexture2D节点

这种配置可以实现纹理的精确平铺控制,比如确保纹理在模型表面重复恰好4次,而不是4.5次或其他非整数值。

条件逻辑控制

Integer节点在着色器条件逻辑中发挥重要作用,特别是在需要离散状态判断的场景:

  • 多重材质切换:使用整数值选择不同的材质属性集
  • LOD级别控制:根据整数距离值切换细节级别
  • 特效强度分级:将连续的特效参数离散化为几个固定级别

状态机实现示例

通过结合Branch节点和Integer节点,可以实现简单的着色器状态机:

Integer节点(状态值) → Branch节点 → 不同的颜色/纹理输出

数组和循环操作

在高级着色器编程中,数组和循环是常见的编程结构,Integer节点在其中扮演关键角色:

  • 数组索引:安全地访问数组元素
  • 循环计数器:控制固定次数的循环迭代
  • 多维数组处理:计算行主序或列主序数组的索引

数组访问模式

For循环节点(使用Integer节点作为最大值) → 数组索引计算 → 数组元素访问

这种模式常见于图像处理效果,如卷积核操作、多光源累积计算等。

与其他节点的协同工作

与数学节点的配合

Integer节点可以与各种数学节点结合,实现更复杂的数值计算:

  • 算术运算:与Add、Subtract、Multiply、Divide节点配合进行整数运算
  • 比较运算:与Equal、NotEqual、Greater Than、Less Than节点结合实现条件判断
  • 插值运算:虽然整数本身不插值,但可以控制插值参数

运算精度注意事项

当Integer节点参与浮点数运算时,会自动提升为浮点类型。在需要保持整数精度的场景,应尽量避免与浮点数进行混合运算,或确保在关键步骤中使用适当的舍入函数。

与控制流节点的集成

Integer节点与Shader Graph的控制流节点紧密结合,实现动态的着色器行为:

  • Branch节点:使用整数值作为条件输入
  • For循环节点:提供循环次数和索引值
  • Switch节点:作为选择器输入,决定执行哪个分支

性能优化提示

在Shader Graph中使用整数控制流通常比使用浮点数更高效,因为整数比较和分支操作在GPU上的开销较小。特别是在移动平台上,这种优化更为明显。

高级应用技巧

动态整数参数

虽然Integer节点本身表示常量,但可以通过多种方式实现动态的整数参数:

  • 脚本驱动:通过C#脚本在运行时修改材质属性
  • 动画控制:使用Unity动画系统或时间节点驱动整数值变化
  • 顶点数据:从顶点颜色或UV通道中提取整数值

脚本集成示例

CSHARP

// C#脚本中设置整数值
material.SetInt("_IntegerParameter", 5);

数组和数据结构模拟

在着色器中模拟复杂数据结构时,Integer节点用于索引和管理:

  • 查找表索引:访问预计算的查找表
  • 状态矩阵:管理多维状态数组
  • 有限状态机:实现复杂的着色器行为切换

多维度索引计算

通过组合多个Integer节点和数学运算,可以计算多维数组的线性索引:

行索引 × 列数 + 列索引 = 线性索引

性能优化策略

合理使用Integer节点可以显著提升着色器性能:

  • 循环展开优化:使用小的整数值作为循环次数,促进编译器自动展开循环
  • 常量传播:整型常量的优化效果通常比浮点数更好
  • 分支预测:整数条件语句的预测效率通常更高

平台特定考虑

不同GPU架构对整数运算的支持程度不同。在编写跨平台着色器时,应了解目标平台的整数运算特性,特别是在移动设备上的性能表现。

实际案例研究

案例一:离散化颜色调色板

创建一个使用Integer节点选择预定义颜色的着色器:

  • 设置Integer节点作为颜色索引
  • 使用Branch节点或数组索引选择对应颜色
  • 应用选中的颜色到材质表面

这种技术常用于低多边形风格游戏或需要特定颜色方案的应用程序。

案例二:多纹理混合系统

实现一个根据整数值混合多个纹理的系统:

  • 使用Integer节点选择基础纹理、细节纹理和遮罩纹理
  • 根据整数值决定混合模式和强度
  • 创建可配置的多材质系统

案例三:程序化几何生成

在曲面细分或几何着色器中使用Integer节点控制细节级别:

  • 根据距离或重要性设置细分因子
  • 使用整数值确保对称和一致的几何分布
  • 优化性能的同时保持视觉质量

故障排除和最佳实践

常见问题解决

整数精度问题

  • 问题:大整数导致精度丢失或意外行为
  • 解决:确保使用的整数值在合理范围内,通常0-255对于大多数应用足够

类型转换错误

  • 问题:整数到浮点的隐式转换导致意外结果
  • 解决:在关键计算中显式处理类型转换,使用Round、Floor或Ceiling节点

性能问题

  • 问题:使用整数节点后着色器变慢
  • 解决:检查是否创建了复杂的依赖关系,简化节点网络

最佳实践建议

  • 命名规范:为Integer节点使用描述性名称,提高可读性
  • 数值范围:限制整数值在必要的最小范围内,避免不必要的内存占用
  • 文档注释:在Shader Graph中使用注释节点说明Integer节点的用途和预期值范围
  • 测试验证:在不同平台和设备上测试整数相关功能,确保一致的行为

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

uniapp开发问题记录(2)--“YYYY-MM-DD”时间字符串格式获取

问题

请求接口需要“YYYY-MM-DD”时间字符串,使用了new Date().toLocaleDateString('en-CA')来获取结果示例(东八区 2026-01-17 08:30) "2026-01-17" ,但在线上却行不通,接口获取不到数据。

原因:

在微信小程序(尤其是安卓真机)里写:

JavaScript

复制

new Date().toLocaleDateString('en-CA')

不会保证返回 "YYYY-MM-DD" ,而是可能得到:

  • "2024/06/03"(安卓最常见)
  • "2019年11月20日"(部分 iOS)
  • "11/20/2019"(地区语言设置不同)

总之格式完全取决于系统语言和宿主环境对 Intl 的支持程度,没有任何兼容性保障


最稳妥的写法(推荐)

直接手拼,彻底摆脱环境差异:

JavaScript

复制

const d = new Date()
const dateStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
// 结果一定是 "2024-06-03"

这段代码在开发者工具、iOS、安卓、线上版本表现完全一致,也是小程序社区最常用方案。

结论:
想要稳定拿到 "YYYY-MM-DD",就别用 toLocaleDateString,手拼最保险。

还有一个:

使用new Date().toISOString().slice(0, 10)也可以获取时间字符串

但要先确认一点:它按 UTC 零时区算日期

  • 在中国(东八区)
    今天 08:00 以前调用,得到的是昨天的日期
    08:00 及以后调用,得到的才是今天的日期

如果业务允许“UTC 自然日”或者只在服务器端/云函数里用,这条语句最简洁:

new Date().toISOString().slice(0, 10)   // 一定 "YYYY-MM-DD"

小程序端想拿“用户本地今天”就别用它,否则凌晨 0–8 点会出现“日期少一天”的坑;
继续用手拼:

const d = new Date()
`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`

一句话:
要 UTC 日期 → toISOString().slice(0,10) 最方便;
要本地日历 → 手拼最安全。

vercel新框架 json-render 介绍

2天前,vercel团队发布了一个新的框架 json-render ,是为了针对性解决 AI 生成 UI 的问题,官方有例子 可以尝试一下 json-render.dev/ ,跟我上篇文章介绍的 formily 集成 AI有点类似,下面来拆解一下

image.png

架构设计

json-render一共做了3件事:约定 AI 输出 → 解析 JSON → 渲染 UI

约定AI输出

开发者先在 json-render 的 registry(组件注册中心)里,把所有可用的组件(比如 Input、Button)、组件允许的 props、可触发的交互事件 actions(比如表单提交、数据导出)都声明清楚。框架会根据这份 registry 配置,自动生成一份约束性的 prompt,直接传给大模型。

解析大模型生成的JSON

然后是解析JSON,这里没什么太多新意,下面代码可以看到 只是做了简单的trim, image.png 实际用过大模型生成 JSON 的开发者都知道,这玩意不是百分百靠谱的 —— 哪怕给了再严格的约束 Prompt,大模型偶尔也会抽风,要么是 JSON 格式错乱、括号不闭合、字段缺失,要么干脆生成一半突然断了,甚至输出完全不相关的内容。值得一提的是支持了JSONL流式解析,生成一行解析一行,而且大模型生成单行的jsonl也比较稳定。

解析后的 JSON 会被转换成扁平的 UITree 结构,父子节点通过 key 列表关联。这种设计让 AI 生成扁平结构时,不用维护复杂的嵌套层级,出错率更低。前端渲染时,能通过 key 直接定位节点,不用递归遍历,渲染和更新的效率都更高。

image.png

渲染UI

框架会根据 UITree 里的节点类型(type)、属性(props),到 registry 里匹配对应的原生组件,把 props、actions 都注入进去,最终生成可交互的 UI 代码。

比如 UITree 里的{type: "Button", props: {text: "提交"}, action: "submit_form"},会直接映射到 registry 里注册的 Button 组件,自动绑定 submit_form 动作,不用开发者手写组件调用和事件绑定的代码。

总结

json-render的亮点在于 逻辑UI分离,可以适配不同的框架,扁平 UITree 结构和 JSONL 让AI犯错的概率更低

核心短板也很突出:对 AI 生成 JSON 的格式错误没有处理,需要结合 json-repair 这类工具,先对 AI 生成的 JSON 做格式修复,再交给框架解析

uniapp开发问题记录(1)

问题

页面结构:

<swiper :current="idx" @change="onSwipe" class="page-swiper" :duration="800" :circular="true"
:skip-hidden-item-layout="true">
<swiper-item v-for="(tab, i) in 7" :key="i">
<!-- 每页一个透明层 -->
<view class="page-fade" :class="i === idx ? 'fade-in' : 'fade-out'" v-show="visiblePages[i]"
@animationend="onFadeEnd(i)">
<scroll-view scroll-y="true" class="tab-scroll" refresher-enabled="true" @refresherrefresh="refresh"
:refresher-triggered="triggered">

下拉刷新时所绑定的事件被多次触发

原因:

7 个 scroll-view 是同时存在的(swiper-item 只是被 v-show 显隐,并没有销毁),
你把同一个变量 triggered 和同一个方法 refresh 同时绑给了 7 个实例,
于是任意一个下拉,7 个都会一起触发


解决思路:让“刷新状态”和“刷新函数”各自独立

  1. 给每个 scroll-view 单独的下标级状态
    triggered 做成数组 / 对象,而不是全局一个布尔值
data() {
  return {
    triggered: [false, false, false, false, false, false, false] // 7 份
  }
}
  1. 刷新事件也带下标
    模板里把索引传进去:
<scroll-view
  scroll-y
  refresher-enabled
  :refresher-triggered="triggered[i]"
  @refresherrefresh="refresh(i)">
</scroll-view>
  1. 方法里只操作对应下标的那一份
methods: {
  async refresh(i) {
    this.$set(this.triggered, i, true)          // 1. 开启第 i 个刷新动画
    await this.loadTabData(i)                   // 2. 只拉第 i 页的数据
    this.$set(this.triggered, i, false)         // 3. 关闭第 i 个刷新动画
  }
}

总结

swiper-item 里的 7 个 scroll-view并行存活的,
一个全局变量 + 一个全局函数就会被它们共享触发
triggeredrefresh 都改成**“按页独立”**,就能保证每页只触发自己的那一次。

前端AI应用开发深入理解 FunctionCall:让 LLM 拥有"超能力"的完整指南

想象一下,如果你的 AI 助手不仅能聊天,还能帮你查询实时天气、调用 API、执行数据库查询,那会是什么体验?FunctionCall 技术正是实现这一目标的关键!

🌟 什么是 FunctionCall?

FunctionCall(函数调用)是一种让大语言模型(LLM)能够调用外部工具函数的技术。它打破了传统 LLM 只能基于训练数据回答问题的限制,让 AI 能够:

  • 🔍 查询实时数据(如天气、股票价格)
  • 📊 访问数据库获取最新信息
  • 🔗 调用第三方 API 服务
  • ⚙️ 执行特定的业务逻辑

简单来说,FunctionCall 就像是给 LLM 装上了"手脚",让它不仅能"思考",还能"行动"!

🚀 为什么需要 FunctionCall?

传统的 LLM 有一个明显的局限:它只能基于训练时的数据回答问题。这意味着:

❌ 无法获取实时信息(比如今天的天气) ❌ 无法访问你的私有数据 ❌ 无法执行具体的操作(如发送邮件、创建订单)

而 FunctionCall 完美解决了这些问题!它让 LLM 成为了一个智能的"调度器",能够:

  • 理解用户的自然语言需求
  • 判断需要调用哪些工具
  • 自动提取函数参数
  • 将执行结果转化为自然语言回答

FunctionCall 完整工作流程

让我们通过一个实际例子来理解整个流程。假设用户问:"北京今天天气怎么样?"

🔧 步骤 1:定义工具描述

首先,我们需要告诉 LLM 有哪些工具可以使用。这就像给 LLM 提供一份"工具说明书":

const tools = [
  {
    type: "function",
    function: {
      name: "get_current_weather",
      description: "获取指定城市当前的实时天气",
      parameters: {
        type: "object",
        properties: {
          city: {
            type: "string",
            description: "城市名称",
          },
          unit: {
            type: "string",
            enum: ["摄氏度", "华氏度"],
            description: "温度单位",
          },
        },
        required: ["city"],
      },
    },
  },
];

这里的描述非常重要!LLM 会根据 description 来判断何时需要调用这个函数,根据 parameters 来提取用户输入中的参数值。

⚙️ 步骤 2:实现实际函数

定义好工具描述后,我们需要实现真正的函数逻辑:

function get_current_weather(city, unit = "摄氏度") {
  // 这里可以调用外部 API 来获取真实天气数据
  console.log(`正在获取 ${city} 的天气...`);
  const weather = {
    city: city,
    temperature: "25",
    unit: unit,
    forecast: "晴朗",
  };
  return JSON.stringify(weather);
}

这个函数可以调用任何你需要的 API 或执行任何业务逻辑。

🔗 步骤 3:建立函数映射

为了方便调用,我们创建一个映射对象:

const availableFunctions = {
  get_current_weather: get_current_weather,
};

这样 LLM 返回函数名后,我们就能快速找到对应的函数并执行。

🚀 步骤 4:发起对话

现在,我们将用户的问题和可用工具一起发送给 LLM:

const response = await zhipuClient.createCompletions({
  model: llmModel,
  messages: [
    {
      role: "user",
      content: "北京今天天气怎么样?",
    },
  ],
  tools: tools, // 告诉 LLM 有哪些工具可用
});

🤔 步骤 5:LLM 智能决策

LLM 会分析用户的问题,并做出智能判断:

情况 A:需要调用工具

{
  "tool_calls": [
    {
      "id": "call_123",
      "function": {
        "name": "get_current_weather",
        "arguments": "{\"city\": \"北京\", \"unit\": \"摄氏度\"}"
      }
    }
  ]
}

情况 B:不需要调用工具

{
  "content": "我是 AI 助手,无法直接回答天气问题。"
}

这就是 FunctionCall 的神奇之处:LLM 自动判断是否需要调用工具,并从自然语言中提取参数!

💻 步骤 6:执行本地函数

当 LLM 决定调用工具时,我们解析参数并执行函数:

const toolCall = message.tool_calls[0];
const functionName = toolCall.function.name;
const functionArgs = JSON.parse(toolCall.function.arguments);

const functionResponse = availableFunctions[functionName](
  functionArgs.city,
  functionArgs.unit
);

执行结果可能是:

{
  "city": "北京",
  "temperature": "25",
  "unit": "摄氏度",
  "forecast": "晴朗"
}

🔄 步骤 7:回传结果给 LLM

将函数执行结果作为上下文再次发送给 LLM:

const secondResponse = await zhipuClient.createCompletions({
  model: llmModel,
  messages: [
    { role: "user", content: "北京今天天气怎么样?" }, // 原始问题
    message, // LLM 的工具调用请求
    {
      role: "tool", // 工具执行结果
      tool_call_id: toolCall.id,
      content: functionResponse,
    },
  ],
});

这一步非常关键!LLM 需要看到完整的对话历史,包括:

  • 用户的问题
  • 自己的工具调用决策
  • 工具的执行结果

✅ 步骤 8:生成最终回答

最后,LLM 基于函数执行结果,生成自然语言的回答:

根据查询结果,北京今天的天气是晴朗,温度为 25 摄氏度。今天是个好天气,适合外出活动!

🎯 FunctionCall 的核心优势

1. 智能决策 🧠

LLM 自动判断是否需要调用工具,无需复杂的规则判断。比如:

  • 用户问"北京天气怎么样?" → 调用天气查询函数
  • 用户问"什么是人工智能?" → 直接回答,无需调用工具

2. 参数解析 📝

LLM 能够从自然语言中精准提取参数:

  • "北京今天天气怎么样?" → city="北京"
  • "查一下上海的天气,用华氏度" → city="上海", unit="华氏度"
  • "纽约的天气如何" → city="纽约"

3. 结果整合 💬

LLM 将结构化的函数结果转化为自然的语言回答,让用户体验更加流畅。

4. 多轮对话 🔄

支持连续的工具调用和上下文延续,可以进行复杂的任务链。

� 实际应用场景

场景 1:智能客服助手

// 工具:查询订单状态
function get_order_status(orderId) { ... }

// 工具:处理退款
function process_refund(orderId, reason) { ... }

// 用户:"我的订单 #12345 怎么还没到?"
// LLM 自动调用 get_order_status("12345")
// 回答:"您的订单 #12345 已发货,预计明天送达"

场景 2:数据分析助手

// 工具:查询数据库
function query_database(sql) { ... }

// 工具:生成图表
function generate_chart(data, type) { ... }

// 用户:"帮我看看上个月的销售数据"
// LLM 自动调用 query_database 和 generate_chart

场景 3:办公自动化

// 工具:发送邮件
function send_email(to, subject, body) { ... }

// 工具:创建日程
function create_calendar_event(title, time) { ... }

// 用户:"帮我给张三发封邮件约明天开会"
// LLM 自动调用 send_email 和 create_calendar_event

🛠️ 快速开始

1. 安装依赖

npm install zhipu-sdk-js dotenv

2. 配置环境变量

创建 .env 文件:

ZHIPUAI_API_KEY=your_api_key_here

3. 编写代码

import ZhipuAI from "zhipu-sdk-js";
import "dotenv/config";

const zhipuClient = new ZhipuAI({
  apiKey: process.env.ZHIPUAI_API_KEY,
});

// 定义工具、实现函数、运行对话...

4. 运行示例

node index.js

📚 技术栈

  • Node.js: 运行环境
  • zhipu-sdk-js: 智谱 AI 官方 SDK
  • GLM-4.5-Flash: 高性能大语言模型
  • dotenv: 环境变量管理

🔮 未来展望

FunctionCall 技术正在快速发展,未来可能支持:

  • 🤖 多工具并行调用
  • 📊 复杂的工具调用链
  • 🎯 自适应工具选择
  • 🔒 更安全的权限控制

代码示例

// index.js
import ZhipuAI from "zhipu-sdk-js";

import "dotenv/config";

// 步骤 1: 定义你的工具函数
const tools = [
  {
    type: "function",
    function: {
      name: "get_current_weather",
      description: "获取指定城市当前的实时天气",
      parameters: {
        type: "object",
        properties: {
          city: {
            type: "string",
            description: "城市名称",
          },
          unit: {
            type: "string",
            enum: ["摄氏度", "华氏度"],
            description: "温度单位",
          },
        },
        required: ["city"],
      },
    },
  },
];

// 步骤 2: 创建一个假想的函数来执行工具
function get_current_weather(city, unit = "摄氏度") {
  // 这里可以调用外部 API 来获取真实天气数据
  // 为了简化,我们直接返回一个模拟值
  console.log(`正在获取 ${city} 的天气...`);
  const weather = {
    city: city,
    temperature: "25",
    unit: unit,
    forecast: "晴朗",
  };
  return JSON.stringify(weather);
}

// 定义一个映射,将函数名和实际函数绑定
const availableFunctions = {
  get_current_weather: get_current_weather,
};

const zhipuClient = new ZhipuAI({
  apiKey: process.env.ZHIPUAI_API_KEY,
});

const llmModel = "glm-4.5-flash";
async function runConversation() {
  const userMessage = "北京今天天气怎么样?";

  // 向 LLM 发送用户问题,并提供可用的工具
  const response = await zhipuClient.createCompletions({
    model: llmModel,
    messages: [
      {
        role: "user",
        content: userMessage,
      },
    ],
    tools: tools,
  });
  debugger;
  const message = response.choices[0].message;

  // 步骤 3: 检查 LLM 是否决定调用函数
  if (message.tool_calls && message.tool_calls.length > 0) {
    const toolCall = message.tool_calls[0];
    const functionName = toolCall.function.name;
    const functionArgs = JSON.parse(toolCall.function.arguments);

    // 步骤 4: 在本地执行函数
    const functionResponse = availableFunctions[functionName](
      functionArgs.city,
      functionArgs.unit,
    );

    // 再次调用 LLM,将函数执行结果作为上下文
    const secondResponse = await zhipuClient.createCompletions({
      model: llmModel,
      messages: [
        { role: "user", content: userMessage },
        message, // 将 LLM 第一次的响应也作为上下文
        {
          role: "tool",
          tool_call_id: toolCall.id,
          content: functionResponse, // 将函数执行结果作为上下文
        },
      ],
    });

    // 最终返回 LLM 基于函数结果生成的回答
    console.log(secondResponse.choices[0].message.content);
  } else {
    // 如果 LLM 没决定调用函数,直接返回 LLM 的回答
    console.log(message.content);
  }
}

runConversation();
{
  "name": "function_call_llm",
  "version": "1.0.0",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "dotenv": "^17.2.3",
    "zhipu-sdk-js": "^1.0.0"
  }
}

📝 总结

FunctionCall 是连接 LLM 与现实世界的桥梁,它让 AI 从"聊天机器人"进化为"智能助手"。通过掌握这项技术,你可以:

  1. 构建更强大的 AI 应用
  2. 提供更智能的用户体验
  3. 实现更复杂的业务场景

现在就开始尝试吧! 让你的 AI 助手拥有真正的"超能力"!🚀


💡 小贴士:本文档配套了完整的示例代码,你可以直接运行体验 FunctionCall 的强大功能。如有问题,欢迎交流讨论!

Vue3响应式API全指南:ref/reactive及衍生API的区别与最佳实践

Vue3基于Proxy重构了响应式系统,提供了一套灵活的API矩阵——核心的ref与reactive、浅响应式的shallowRef/shallowReactive、只读封装的readonly/shallowReadonly。这些API看似功能重叠,实则各有适配场景,误用易导致响应式失效或性能冗余。本文将从特性本质、核心区别、代码示例、适用场景四个维度,系统拆解六大API,帮你精准选型、规避踩坑。

一、核心基础:ref 与 reactive

ref和reactive是Vue3响应式开发的基石,均用于创建响应式数据,但针对的数据类型、访问方式有明确边界,是后续衍生API的设计基础。

1. 核心特性与区别

维度 ref reactive
支持类型 基本类型(string/number/boolean等)+ 引用类型 仅支持引用类型(对象/数组),基本类型传入无响应式效果
实现原理 封装为Ref对象(含.value属性),基本类型靠Object.defineProperty拦截.value,引用类型内部调用reactive 直接通过Proxy拦截对象的属性读取/修改,天然支持嵌套属性响应式
操作方式 脚本中需通过.value访问/修改,模板中自动解包(无需.value) 脚本、模板中均直接操作属性(无.value冗余)
解构特性 解构后丢失响应式,需用toRefs/toRef转换保留 直接解构失效,通过toRefs可将属性转为Ref对象维持响应式
响应式深度 默认深响应式(嵌套对象属性变化触发更新) 默认深响应式(嵌套对象属性变化触发更新)

2. 代码示例

import { ref, reactive, toRefs } from 'vue';

// ref使用:基本类型+引用类型
const count = ref(0);
count.value++; // 脚本中必须用.value
console.log(count.value); // 1

const user = ref({ name: '张三', age: 20 });
user.value.age = 21; // 嵌套属性修改,触发响应式

// reactive使用:仅引用类型
const person = reactive({ name: '李四', info: { height: 180 } });
person.name = '王五'; // 直接操作属性
person.info.height = 185; // 嵌套属性深响应式

// 解构处理
const { name, age } = toRefs(user.value); // 保留响应式
name.value = '赵六'; // 触发更新

3. 适用场景

ref:优先用于基本类型响应式(如计数器、开关状态、输入框值);单独维护单个引用类型数据(无需复杂嵌套解构);组合式API中作为默认选择,灵活性更高。

reactive:适用于复杂引用类型(如用户信息、列表数据、表单聚合状态);希望避免.value冗余,追求更直观的属性操作;组件内部状态聚合管理(相关属性封装为一个对象,可读性更强)。

二、性能优化:shallowRef 与 shallowReactive

ref和reactive的深响应式会递归处理所有嵌套属性,对大型对象/第三方实例而言,可能产生不必要的性能开销。浅响应式API仅拦截顶层数据变化,专为性能优化场景设计。

1. 核心特性与区别

维度 shallowRef shallowReactive
支持类型 基本类型 + 引用类型(同ref) 仅引用类型(同reactive)
响应式深度 仅拦截.value的引用替换,嵌套属性变化不触发更新 仅拦截顶层属性变化,嵌套属性变化无响应式效果
更新触发 需替换.value引用(如shallowRef.value = 新对象);嵌套修改需用triggerRef手动触发更新 仅修改顶层属性触发更新,嵌套属性修改完全不拦截
使用成本 嵌套修改需手动触发更新,有额外编码成本 无需手动触发,但需牢记仅顶层响应式,易踩坑

2. 代码示例

import { shallowRef, shallowReactive, triggerRef } from 'vue';

// shallowRef示例
const shallowUser = shallowRef({ name: '张三', info: { age: 20 } });
shallowUser.value.info.age = 21; // 嵌套修改,无响应式
shallowUser.value = { name: '李四', info: { age: 22 } }; // 替换引用,触发更新
triggerRef(shallowUser); // 手动触发更新(嵌套修改后强制同步)

// shallowReactive示例
const shallowPerson = shallowReactive({
  name: '王五',
  info: { height: 180 }
});
shallowPerson.name = '赵六'; // 顶层修改,触发更新
shallowPerson.info.height = 185; // 嵌套修改,无响应式

3. 适用场景

shallowRef:引用类型数据仅需整体替换(如大型图表配置、第三方库实例、不可变数据);明确不需要嵌套属性响应式,追求极致性能(避免递归Proxy开销)。

shallowReactive:复杂对象仅需顶层属性响应式(如表单顶层状态、静态嵌套数据的配置对象);大型对象场景下,规避深响应式的性能损耗,且无需频繁修改嵌套属性。

注意:浅响应式API并非“银弹”,仅在明确不需要深层响应式时使用,否则易导致响应式失效问题,增加调试成本。

三、只读防护:readonly 与 shallowReadonly

在父子组件通信、全局常量管理等场景,需禁止数据被修改,此时可使用只读API。它们会拦截修改操作(开发环境抛警告),同时保留原数据的响应式特性(原数据变化时,只读数据同步更新)。

1. 核心特性与区别

维度 readonly shallowReadonly
支持类型 引用类型为主(基本类型只读无实际意义) 引用类型为主(基本类型只读无实际意义)
只读深度 深只读:顶层+所有嵌套属性均不可修改 浅只读:仅顶层属性不可修改,嵌套属性可正常修改
修改拦截 任何层级修改均被拦截,开发环境抛警告 仅顶层修改被拦截,嵌套修改无拦截、无警告
响应式保留 保留深响应式:原数据任意层级变化,只读数据同步更新 保留浅响应式:原数据变化(无论层级),只读数据同步更新

2. 代码示例

import { readonly, shallowReadonly, reactive } from 'vue';

// 原始响应式数据
const original = reactive({
  name: '张三',
  info: { age: 20 }
});

// readonly示例
const readOnlyData = readonly(original);
readOnlyData.name = '李四'; // 顶层修改,被拦截(抛警告)
readOnlyData.info.age = 21; // 嵌套修改,被拦截(抛警告)
original.name = '李四'; // 原数据变化,只读数据同步更新
console.log(readOnlyData.name); // 李四

// shallowReadonly示例
const shallowReadOnlyData = shallowReadonly(original);
shallowReadOnlyData.name = '王五'; // 顶层修改,被拦截(抛警告)
shallowReadOnlyData.info.age = 22; // 嵌套修改,正常执行(无警告)
console.log(shallowReadOnlyData.info.age); // 22

3. 适用场景

readonly:完全禁止修改的响应式数据(如全局常量配置、接口返回的不可变数据);父子组件通信的Props(Vue内部默认对Props做readonly处理,防止子组件修改父组件状态);需要严格防护数据完整性的场景。

shallowReadonly:仅需禁止顶层属性修改,嵌套属性允许微调(如父组件传递给子组件的复杂对象,子组件可修改嵌套细节但不能替换整体);追求性能优化,避免深只读的递归拦截开销(大型对象场景更明显)。

四、API选型总指南与避坑要点

1. 快速选型流程图

  1. 明确需求:是否需要响应式?→ 不需要则直接用普通变量;需要则进入下一步。
  2. 数据类型:基本类型→只能用ref;引用类型→进入下一步。
  3. 修改权限:需要禁止修改→readonly(深防护)/shallowReadonly(浅防护);允许修改→进入下一步。
  4. 响应式深度:仅需顶层响应式→shallowRef/shallowReactive;需要深层响应式→ref/reactive。
  5. 操作习惯:避免.value→reactive;接受.value或基本类型→ref。

2. 常见坑点规避

  • ref解构丢失响应式:务必用toRefs/toRef转换,而非直接解构。
  • reactive传入基本类型:无响应式效果,需改用ref。
  • 浅响应式嵌套修改失效:shallowRef需用triggerRef手动触发,shallowReactive避免依赖嵌套属性更新。
  • readonly修改原数据:只读API仅拦截对自身的修改,原数据仍可修改,需注意数据溯源。
  • ref嵌套对象修改:无需额外处理,内部已转为reactive,直接修改.value.属性即可。

五、总结

Vue3的响应式API设计围绕“灵活性”与“性能”两大核心:ref/reactive构建基础响应式能力,适配绝大多数日常场景;shallow系列API针对性优化性能,降低大型数据的响应式开销;readonly系列API保障数据安全性,适配只读场景。

核心原则是“按需选型”——无需为简单场景引入复杂API,也无需为性能牺牲开发效率。掌握各API的响应式深度、修改权限、操作方式,就能在项目中精准运用,打造高效、健壮的响应式系统。

高级异步:并发控制与性能优化

上一期我们掌握了 Fetch + async/await 的基本网络请求能力。
但真实项目中经常遇到下面这些场景:

  • 需要同时请求 50~200 个接口
  • 不能让全部请求同时发出(服务器会限流或直接封 IP)
  • 请求顺序有依赖,但又想尽可能并行
  • 防止瀑布式请求把页面加载时间拉长到几秒甚至十几秒

这一期我们就来系统解决这些“高级异步”问题。

1. 并发控制的核心思路

并发方式 最大同时请求数 适用场景 实现难度
全部同时发 无限制 接口少、服务器不限流 ★☆☆☆☆
Promise.all 全部同时 数量少(<30个) ★☆☆☆☆
固定并发数分批 3~10 大批量请求 + 服务器有限流 ★★☆☆☆
令牌桶/滑动窗口 动态控制 严格限流、需要平滑流量 ★★★★☆
带优先级 + 超时 动态 + 优先级 核心接口优先、边缘接口可丢弃 ★★★★☆

2. 最常用的几种实现方式(代码示例)

方式1:简单粗暴版 - Promise.all + 分组

async function fetchAllInBatches(urls, batchSize = 6) {
  const results = [];
  
  for (let i = 0; i < urls.length; i += batchSize) {
    const batch = urls.slice(i, i + batchSize);
    const batchPromises = batch.map(url => fetch(url).then(r => r.json()));
    
    const batchResults = await Promise.all(batchPromises);
    results.push(...batchResults);
  }
  
  return results;
}

最常用、最好理解,适合绝大多数场景。

方式2:并发限制工具函数(推荐生产使用)

class ConcurrencyLimiter {
  constructor(maxConcurrent) {
    this.max = maxConcurrent;
    this.running = 0;
    this.queue = [];
  }

  async run(task) {
    if (this.running >= this.max) {
      await new Promise(resolve => this.queue.push(resolve));
    }
    
    this.running++;
    try {
      return await task();
    } finally {
      this.running--;
      if (this.queue.length > 0) {
        const next = this.queue.shift();
        next();
      }
    }
  }
}

// 使用示例
const limiter = new ConcurrencyLimiter(5); // 最多同时5个

const allResults = await Promise.all(
  urls.map(url => 
    limiter.run(() => fetch(url).then(r => r.json()))
  )
);

方式3:p-limit / p-queue(社区最流行库)

// 使用 p-limit(非常推荐,体积小、API 优雅)
import pLimit from 'p-limit';

const limit = pLimit(4); // 最多4个并发

const results = await Promise.all(
  items.map(item => 
    limit(() => processItem(item))
  )
);

目前(2026年)前端项目中最受欢迎的并发控制方案,几乎成为“标配”。

3. 性能优化实战技巧

优化手段 效果 实现成本 推荐指数
请求合并(BFF/GraphQL) 1次请求代替 N 次 ★★★☆☆ ★★★★★
预请求 / prefetch 用户还没点已经开始请求 ★★☆☆☆ ★★★★☆
瀑布 → 并行转换 串行 8s → 并行 2.5s ★★☆☆☆ ★★★★★
缓存(memory + IndexedDB) 重复请求直接秒返回 ★★★☆☆ ★★★★☆
弱网优化(骨架屏+延迟加载) 感知速度提升明显 ★★☆☆☆ ★★★★☆
请求优先级 + 可取消 核心数据优先,边缘可丢 ★★★★☆ ★★★☆☆

经典案例:商品详情页的“并行优化”

优化前(典型瀑布)

  1. 获取商品基本信息 → 2. 获取 SKU → 3. 获取推荐商品 → 4. 获取评论 → 5. 获取店铺信息

优化后

async function loadProductPage(productId) {
  const [basic, skus, comments, shop] = await Promise.all([
    fetchBasic(productId),
    fetchSkus(productId),           // 可与 basic 并行
    fetchComments(productId),       // 可提前请求
    fetchShopInfo(basic.shopId)     // 依赖 basic,但可延迟
  ]);

  const recommends = await fetchRecommends(basic.category); // 最后请求

  return { basic, skus, comments, shop, recommends };
}

时间从 8~10秒 → 2.5~3.5秒(弱网环境下感知差距更大)

4. 小结与进阶路线

当前阶段你应该已经掌握:

  • Promise.all / allSettled 的合理使用
  • 固定并发数的实现方式(手写 + p-limit)
  • 如何把瀑布式请求改造成并行
  • 基本的请求超时与取消(AbortController)

下一阶段值得深入的方向:

  • 更复杂的流量控制(令牌桶、漏桶)
  • 请求优先级队列
  • 自动重试 + 指数退避
  • Service Worker + Cache API 的离线优化
  • Web Workers 做真正的后台并发计算

我们下一期见~
(最后一期:异步编程最佳实践与调试技巧 + 系列总结)

留言区互动:
你项目里最大的并发请求量是多少?
用过哪些并发控制方案?效果如何?

Fetch API 与异步网络请求

上一期我们掌握了 async/await 的优雅写法,今天就把它真正用起来:
学习现代浏览器中最推荐的网络请求方式 —— Fetch API

Fetch 是 Promise 风格的网络请求 API,取代了古老的 XMLHttpRequest,成为目前前端获取数据的标准方式。

1. 最基础的 GET 请求

async function getUser() {
  try {
    const response = await fetch('https://api.example.com/users/123');
    
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    
    const user = await response.json();
    console.log('用户信息:', user);
    return user;
  } catch (error) {
    console.error('获取用户失败:', error);
  }
}

关键点:

  • fetch() 返回一个 Promise,resolve 后得到 Response 对象
  • response.ok 判断请求是否成功(状态码 200~299)
  • response.json() 也是返回 Promise,需要 await

2. 常用请求方式完整示例

// GET - 带查询参数
async function searchUsers(keyword) {
  const url = `https://api.example.com/users?q=${encodeURIComponent(keyword)}`;
  const response = await fetch(url);
  return response.json();
}

// POST - 创建资源
async function createPost(title, content) {
  const response = await fetch('https://api.example.com/posts', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${token}`
    },
    body: JSON.stringify({ title, content })
  });
  
  if (!response.ok) throw new Error('创建失败');
  return response.json();
}

// PUT - 更新资源
async function updateUser(id, data) {
  const response = await fetch(`https://api.example.com/users/${id}`, {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(data)
  });
  return response.json();
}

// DELETE
async function deletePost(id) {
  await fetch(`https://api.example.com/posts/${id}`, {
    method: 'DELETE'
  });
  // 通常 204 No Content,不需要 .json()
}

3. 实用技巧与最佳实践

场景 推荐写法 说明
超时控制 使用 AbortController 防止请求挂起太久
统一错误处理 封装 fetch 函数 统一处理 4xx/5xx 和网络错误
带凭证(cookie) credentials: 'include' 跨域携带 cookie
防止缓存 cache: 'no-store' 或添加时间戳 开发调试或实时数据时常用
并发请求 Promise.all + fetch 同时请求多个接口
流式响应 response.body.getReader() 处理大文件或 SSE

超时示例(非常推荐)

async function fetchWithTimeout(url, timeout = 8000) {
  const controller = new AbortController();
  const id = setTimeout(() => controller.abort(), timeout);

  try {
    const response = await fetch(url, { signal: controller.signal });
    clearTimeout(id);
    return response;
  } catch (error) {
    clearTimeout(id);
    if (error.name === 'AbortError') {
      throw new Error('请求超时');
    }
    throw error;
  }
}

4. Fetch vs axios vs jQuery.ajax 对比(2025-2026 视角)

特性 Fetch axios jQuery.ajax
原生 否(需引入)
Promise 支持 原生 原生 + 更多封装 较老版本需转换
自动 JSON 否(需手动 .json()) 部分支持
请求/响应拦截 无(需自己实现) 原生支持
超时处理 需 AbortController 内置 timeout 配置 内置
取消请求 AbortController CancelToken / AbortSignal 较麻烦
浏览器兼容 几乎全部现代浏览器 所有(含旧版) 所有
包体积 0kb ~13kb(gz) 很大

结论
2026 年,大多数中大型项目仍然首选 axios(因为生态完善、拦截器好用),
所有新项目都应该优先掌握原生 Fetch,它是标准、轻量、无依赖的未来方向。

5. 小结

Fetch + async/await 是目前最“现代感”的网络请求组合方式:

  • 语法简洁
  • 原生无依赖
  • 与 Promise 生态无缝衔接
  • 支持所有现代特性(Abort、流、form-data 等)

下一期我们进入更进阶的内容:
高级异步:并发控制与性能优化
—— 如何优雅地处理 100 个并发请求?如何避免瀑布式请求?如何做请求节流?

我们下期见~

留言区互动:
你在实际项目中是用原生 Fetch 还是 axios 更多?
有没有遇到过“明明接口成功了却进 catch”的奇怪情况?😂

Nuxt 写后端

写接口

// server/api/test.ts

export default defineEventHandler(async (event) => {
  // 写这个接口的逻辑
})

可以直接返回textjsonhtmlstream(文件流等流)

放心好了,nuxt支持热模块替换和自动导入。改代码直接看到效果。无需手动写import语句。

// vue
<script setup>
// 要写import
import { ref } from 'vue'
import MyButton from '@/components/MyButton.vue'
import { useFetch } from '@/composables/useFetch'

const count = ref(0)
const { data } = useFetch('/api/data')
</script>

<template>
  <MyButton>点击</MyButton>
</template>

Nuxt的自动导入

<script setup>
const count = ref(0) // 自动从`vue`导入ref
const { data } = useFetch('/api/data') // 自动从 composables/ 导入
</script>

<template>
  <MyButton>点击</MyButton>
</template>

Nuxt自动导入了哪些

  • Vue APIrefcomputedonMounted

  • Nuxt ComposablesuseFetchuseAsyncData

  • Vue RouteruseRouteruseRoute

  • 组件components/目录下的所有组件

  • 工具函数utils/composables/目录下的函数

  • VueUse:如果安装了@vueuse/nuxt

<template>
  <div>
    <h1>{{ title }}</h1>
    <MyComponent />
  </div>
</template>

<script setup>
// 1. 不需要导入 MyComponent - 自动导入
// 2. 修改后页面局部更新 - HMR
const title = ref('欢迎') // ref 也是自动导入的
</script>

部署 - 通用

云服务器构建Nuxt应用

  • Cloudflare
  • Netlify
  • Edge

混合渲染

自定义路由

// nuxt.config.ts

export default defineNuxtConfig({
  routeRules: {
    // 为 SEO 目的在构建时生成
    '/': { prerender: true },
    '/api/*': { cache: { maxAge: 60 * 60 } },
    '/old-page': {
      redirect: { to: '/new-page', statusCode: 302 }
    }
  }
})

目录结构

  • 通过nuxt.configapp.config在项目之间共享可重用的配置预设。
  • components/目录做组件库。
  • composables/utils/目录创建工具和组合式函数库。
  • layers/目录做项目的层

每个层的srcDir都会自动创建命名的层别名。可以用#layers/test访问~~/layers/test层。

也可以自定义nuxt.config文件去设置添加extends去加一个层:

// nuxt.config.ts

export default defineNuxtConfig({
  extends: [
    '../base', // 从本地层去加
    '@my-themes/awesome', // 从安装的包去加
    'github:my-themes/awesome#v1' // 从git库中加
  ]
})

github私有库的要加token

// nuxt.config.ts

export default defineNuxtConfig({
  extends: [
    '../base', // 从本地层去加
    '@my-themes/awesome', // 从安装的包去加
    ['github:my-themes/awesome#v1': { auth: process.env.GITHUB_TOKEN }] // 从git库中加
  ]
})

起别名

// nuxt.config.ts
export default defineNuxtConfig({
  extends: [
    [
      'github:my-themes/awesome',
      { 
        meta: {
          name: 'my-awesome-theme',
        },
      },
    ],
  ]
})

预渲染

Nuxt允许页面在构建时进行静态渲染,提高SEO。

为啥选Nuxt。它SEO优秀啊。在应用中,我们可以选几个页面在构建时进行渲染。有请求时,Nuxt会提供预构建的页面,而不是动态生它们。

基于爬取的预渲染

nuxt generate命令。通过Nitro爬虫去建和预渲染应用。

建站点,启动一个nuxt实例。

选择性预渲染

// nuxt.config.ts
export default defineNuxtConfig({
  nitro: {
    prerender: {
      routes: ["/user/1", "/user/2"],
      ignore: ["/dynamic"],
    },
  },
});

NASA项目一些关键代码展示

client 文件夹下

结构

image.png

app.js

import {
  BrowserRouter as Router,
} from "react-router-dom";
import {
  Arwes,
  SoundsProvider,
  ThemeProvider,
  createSounds,
  createTheme,
} from "arwes";

import AppLayout from "./pages/AppLayout";

import { theme, resources, sounds } from "./settings";

const App = () => {
  return <ThemeProvider theme={createTheme(theme)}>
    <SoundsProvider sounds={createSounds(sounds)}>
      <Arwes animate background={resources.background.large} pattern={resources.pattern}>
        {anim => (
          <Router>
            <AppLayout show={anim.entered} />
          </Router>
        )}
      </Arwes>
    </SoundsProvider>
  </ThemeProvider>;
};

export default App;

setting.js

const resources = {
  background: {
    small: "/img/background-small.jpg",
    medium: "/img/background-medium.jpg",
    large: "/img/background-large.jpg",
  },
  pattern: "/img/glow.png",
};

const sounds = {
  shared: {
    volume: 0.5,
  },
  players: {
    click: {
      sound: { src: ["/sound/click.mp3"] },
      settings: { oneAtATime: true }
    },
    typing: {
      sound: { src: ["/sound/typing.mp3"] },
      settings: { oneAtATime: true }
    },
    deploy: {
      sound: { src: ["/sound/deploy.mp3"] },
      settings: { oneAtATime: true }
    },
    success: {
      sound: {
        src: ["/sound/success.mp3"],
        volume: 0.2,
      },
      settings: { oneAtATime: true }
    },
    abort: {
      sound: { src: ["/sound/abort.mp3"] },
      settings: { oneAtATime: true }
    },
    warning: {
      sound: { src: ["/sound/warning.mp3"] },
      settings: { oneAtATime: true }
    },
  }
};

const theme = {
  color: {
    content: "#a1ecfb",
  },
  padding: 20,
  responsive: {
    small: 600,
    medium: 800,
    large: 1200
  },
  typography: {
    headerFontFamily: '"Titillium Web", "sans-serif"',
  },
};

export {
  resources,
  sounds,
  theme,
};

pages/Launch.js

import { useMemo } from "react";
import { Appear, Button, Loading, Paragraph } from "arwes";
import Clickable from "../components/Clickable";

const Launch = props => {
  const selectorBody = useMemo(() => {
    return props.planets?.map(planet => 
      <option value={planet.keplerName} key={planet.keplerName}>{planet.keplerName}</option>
    );
  }, [props.planets]);

  const today = new Date().toISOString().split("T")[0];

  return <Appear id="launch" animate show={props.entered}>
    <Paragraph>Schedule a mission launch for interstellar travel to one of the Kepler Exoplanets.</Paragraph>
    <Paragraph>Only confirmed planets matching the following criteria are available for the earliest scheduled missions:</Paragraph>
    <ul>
      <li>Planetary radius &lt; 1.6 times Earth's radius</li>
      <li>Effective stellar flux &gt; 0.36 times Earth's value and &lt; 1.11 times Earth's value</li>
    </ul>

    <form onSubmit={props.submitLaunch} style={{display: "inline-grid", gridTemplateColumns: "auto auto", gridGap: "10px 20px"}}>
      <label htmlFor="launch-day">Launch Date</label>
      <input type="date" id="launch-day" name="launch-day" min={today} max="2040-12-31" defaultValue={today} />
      <label htmlFor="mission-name">Mission Name</label>
      <input type="text" id="mission-name" name="mission-name" />
      <label htmlFor="rocket-name">Rocket Type</label>
      <input type="text" id="rocket-name" name="rocket-name" defaultValue="Explorer IS1" />
      <label htmlFor="planets-selector">Destination Exoplanet</label>
      <select id="planets-selector" name="planets-selector">
        {selectorBody}
      </select>
      <Clickable>
        <Button animate 
          show={props.entered} 
          type="submit" 
          layer="success" 
          disabled={props.isPendingLaunch}>
          Launch Mission ✔
        </Button>
      </Clickable>
      {props.isPendingLaunch &&
        <Loading animate small />
      }
    </form>
  </Appear>
};

export default Launch;

pages/AppLayout.js


import {
  useState,
} from "react";
import {
  Switch,
  Route,
} from "react-router-dom";
import {
  Frame,
  withSounds,
  withStyles,
} from "arwes";

import usePlanets from "../hooks/usePlanets";
import useLaunches from "../hooks/useLaunches";

import Centered from "../components/Centered";
import Header from "../components/Header";
import Footer from "../components/Footer";

import Launch from "./Launch";
import History from "./History";
import Upcoming from "./Upcoming";

const styles = () => ({
  content: {
    display: "flex",
    flexDirection: "column",
    height: "100vh",
    margin: "auto",
  },
  centered: {
    flex: 1,
    paddingTop: "20px",
    paddingBottom: "10px",
  },
});

const AppLayout = props => {
  const { sounds, classes } = props;

  const [frameVisible, setFrameVisible] = useState(true);
  const animateFrame = () => {
    setFrameVisible(false);
    setTimeout(() => {
      setFrameVisible(true);
    }, 600);
  };

  const onSuccessSound = () => sounds.success && sounds.success.play();
  const onAbortSound = () => sounds.abort && sounds.abort.play();
  const onFailureSound = () => sounds.warning && sounds.warning.play();

  const {
    launches,
    isPendingLaunch,
    submitLaunch,
    abortLaunch,
  } = useLaunches(onSuccessSound, onAbortSound, onFailureSound);

  const planets = usePlanets();
  
  return <div className={classes.content}>
    <Header onNav={animateFrame} />
    <Centered className={classes.centered}>
      <Frame animate 
        show={frameVisible} 
        corners={4} 
        style={{visibility: frameVisible ? "visible" : "hidden"}}>
        {anim => (
          <div style={{padding: "20px"}}>
          <Switch>
            <Route exact path="/">
              <Launch 
                entered={anim.entered}
                planets={planets}
                submitLaunch={submitLaunch}
                isPendingLaunch={isPendingLaunch} />
            </Route>
            <Route exact path="/launch">
              <Launch
                entered={anim.entered}
                planets={planets}
                submitLaunch={submitLaunch}
                isPendingLaunch={isPendingLaunch} />
            </Route>
            <Route exact path="/upcoming">
              <Upcoming
                entered={anim.entered}
                launches={launches}
                abortLaunch={abortLaunch} />
            </Route>
            <Route exact path="/history">
              <History entered={anim.entered} launches={launches} />
            </Route>
          </Switch>
          </div>
        )}
      </Frame>
    </Centered>
    <Footer />
  </div>;
};

export default withSounds()(withStyles(styles)(AppLayout));

pages/History.js

import { useMemo } from "react";
import { Appear, Table, Paragraph } from "arwes";

const History = props => {
  const tableBody = useMemo(() => {
    return props.launches?.filter((launch) => !launch.upcoming)
      .map((launch) => {
        return <tr key={String(launch.flightNumber)}>
          <td>
            <span style={
              {color: launch.success ? "greenyellow" : "red"}
            }></span>
          </td>
          <td>{launch.flightNumber}</td>
          <td>{new Date(launch.launchDate).toDateString()}</td>
          <td>{launch.mission}</td>
          <td>{launch.rocket}</td>
          <td>{launch.customers?.join(", ")}</td>
        </tr>;
      });
  }, [props.launches]);

  return <article id="history">
    <Appear animate show={props.entered}>
      <Paragraph>History of mission launches including SpaceX launches starting from the year 2006.</Paragraph>
      <Table animate>
        <table style={{tableLayout: "fixed"}}>
          <thead>
            <tr>
              <th style={{width: "2rem"}}></th>
              <th style={{width: "3rem"}}>No.</th>
              <th style={{width: "9rem"}}>Date</th>
              <th>Mission</th>
              <th style={{width: "7rem"}}>Rocket</th>
              <th>Customers</th>
            </tr>
          </thead>
          <tbody>
            {tableBody}
          </tbody>
        </table>
      </Table>
    </Appear>
  </article>;
}
  
export default History;

pages/Upcoming.js

import { useMemo } from "react";
import { 
  withStyles,
  Appear,
  Link,
  Paragraph,
  Table,
  Words,
} from "arwes";

import Clickable from "../components/Clickable";

const styles = () => ({
  link: {
    color: "red",
    textDecoration: "none",
  },
});

const Upcoming = props => {
  const { 
    entered,
    launches,
    classes,
    abortLaunch,
  } = props;

  const tableBody = useMemo(() => {
    return launches?.filter((launch) => launch.upcoming)
      .map((launch) => {
        return <tr key={String(launch.flightNumber)}>
          <td>
            <Clickable style={{color:"red"}}>
              <Link className={classes.link} onClick={() => abortLaunch(launch.flightNumber)}>
                ✖
              </Link>
            </Clickable>
          </td>
          <td>{launch.flightNumber}</td>
          <td>{new Date(launch.launchDate).toDateString()}</td>
          <td>{launch.mission}</td>
          <td>{launch.rocket}</td>
          <td>{launch.target}</td>
        </tr>;
      });
  }, [launches, abortLaunch, classes.link]);

  return <Appear id="upcoming" animate show={entered}>
    <Paragraph>Upcoming missions including both SpaceX launches and newly scheduled Zero to Mastery rockets.</Paragraph>
    <Words animate>Warning! Clicking on the ✖ aborts the mission.</Words>
    <Table animate show={entered}>
      <table style={{tableLayout: "fixed"}}>
        <thead>
          <tr>
            <th style={{width: "3rem"}}></th>
            <th style={{width: "3rem"}}>No.</th>
            <th style={{width: "10rem"}}>Date</th>
            <th style={{width: "11rem"}}>Mission</th>
            <th style={{width: "11rem"}}>Rocket</th>
            <th>Destination</th>
          </tr>
        </thead>
        <tbody>
          {tableBody}
        </tbody>
      </table>
    </Table>
  </Appear>;
}

export default withStyles(styles)(Upcoming);

hooks/usePlanets.js

import { useCallback, useEffect, useState } from "react";

import { httpGetPlanets } from "./requests";

function usePlanets() {
  const [planets, savePlanets] = useState([]);

  const getPlanets = useCallback(async () => {
    const fetchedPlanets = await httpGetPlanets();
    savePlanets(fetchedPlanets);
  }, []);

  useEffect(() => {
    getPlanets();
  }, [getPlanets]);

  return planets;
}

export default usePlanets;

其他可以看仓库

server 文件夹下

image.png

app.js

const express = require('express')
const cors = require('cors');
const path = require('path');
const morgan = require('morgan');

const api = require('./routes/api')

const app = express()

// 日志记录位置尽量 早 
app.use(morgan('combined'))

app.use(cors({
    origin: 'http://localhost:3000',
  }));

app.use(express.json())
app.use(express.static(path.join(__dirname, '..', 'public')))

app.use('/v1', api)

// 确保第一页打开就是 index.html 内容
app.get('/*', (req, res) => {
    res.sendFile(path.join(__dirname, '..','public','index.html'));
} )

module.exports = app

server.js

const http = require('http');
require('dotenv').config()

const {mongoConnect} = require('./services/mongo')

const app = require('./app')

const {loadPlanetsData} = require('./models/planets.model')
const {loadLaunchData} = require('./models/launches.model')

const PORT = process.env.PORT || 8000

const server = http.createServer(app)

async function startServer() {
    await mongoConnect()
    await loadPlanetsData()
    await loadLaunchData()

    server.listen(PORT,() => {
        console.log(`Listening on ${PORT}`);
    });
}

startServer()

models/launches.model.js

const axios = require('axios');
const launchesDatabase = require('./launches.mongo')
const planets = require('./planets.mongo')

const DEFAULT_FLIGHT_NUMBER = 100

const launch = {
    flightNumber: 100, // flight_number
    mission: 'Kepler Exploration X', // name
    rocket: 'Explorer IS1', // rocket.name
    launchDate: new Date('December 27, 2030'), // date_local
    target: 'Kepler-442 b', // not applicable
    customers:['ZTM','NASA'],  // payloads.customers for each payload
    upcoming:true, // upcoming
    success: true // success
}

saveLaunch(launch)

async function findLaunch(filter){
    return await launchesDatabase.findOne(filter)
}

async function existsLaunchWithId(launchId) {
    return await launchesDatabase.findOne({
        flightNumber: launchId
    })
}

async function getLatestFlightNumber(){
    // findOne()用于查找匹配查询条件的第一条记录
    // sort('-flightNumber')用于按照flightNumber字段降序排列结果
    const latestLaunch = await launchesDatabase.findOne().sort('-flightNumber')

    if(!latestLaunch) return DEFAULT_FLIGHT_NUMBER

    return latestLaunch.flightNumber
}

async function getAllLaunches(skip, limit) { 
    return await launchesDatabase
    .find(
        {},{
            "_id":0,
            "__v":0
    })
    .skip(skip)
    .limit(limit)
}

async function saveLaunch(launch) {
    await launchesDatabase.findOneAndUpdate({
        flightNumber: launch.flightNumber,
    }, launch, {
        upsert: true,
    })
}

async function scheduleNewLaunch(launch) {
    const planet =  await planets.findOne({
        keplerName: launch.target
    })

    if(!planet){
        throw new Error('Not matching planet found')
    }


    const newFlightNumber = await getLatestFlightNumber() + 1

    const newLaunch = Object.assign(launch, {
        success: true,
        upcoming: true,
        customers:['ZTM','NASA'],
        flightNumber: newFlightNumber
    })

    await saveLaunch(newLaunch)
}

async function abortLaunchById(launchId) {
    const aborted =  await launchesDatabase.updateOne({
        flightNumber: launchId
    },{
        upcoming: false,
        success: false,
    })

    return aborted.modifiedCount === 1
    // const aborted = launches.get(launchId)
    // aborted.success = false
    // aborted.upcoming = false
    // return aborted
}

async function populateLaunches(){
    const response = await axios.post(SPACEX_API_URL,{
        query: {},
        options:{
            // 不分页 拿到所有数据
            pagination:false,
            populate:[
                {
                    path: 'rocket',
                    select:{
                        name:1
                    }
                },
                {
                    path: 'payloads',
                    select:{
                        customers:1
                    }
                }
            ]
        }
    })

    const launchDocs = response.data.docs 
    for(const launchDoc of launchDocs){
        const payloads = launchDoc.payloads 
        // 使用 flatMap 将嵌套数组扁平化
        const customers = payloads.flatMap(payload => payload.customers)

        const launch = {
            flightNumber: launchDoc.flight_number,
            mission:launchDoc.name,
            rocket: launchDoc.rocket.name,
            launchDate: launchDoc.date_local,
            customers,
            upcoming: launchDoc.upcoming,
            success: launchDoc.success
        }

        // console.log('launch',`${launch.flightNumber} ${launch.mission}`);
        await saveLaunch(launch);
    }

    if(response.status !== 200) {
        console.log('Problem downloading launch data');
    }
}

const SPACEX_API_URL = 'https://api.spacexdata.com/v4/launches/query'

async function loadLaunchData(){
    const firstLaunch = await findLaunch({
        flightNumber:1,
        rocket:'Falcon 1',
        mission:'FalconSat'
    })

    if(firstLaunch){
        console.log('Launch data already loaded');
    }else{
        await populateLaunches()
    }


}

module.exports = {
    getAllLaunches,
    scheduleNewLaunch,
    existsLaunchWithId,
    abortLaunchById,
    loadLaunchData,
}

routes/launches/launches.router.js

const express = require('express');

const {httpGetAllLaunch, httpAddLaunch, httpAbortLaunch} = require('./launches.controller')

const  launchesRouter = express.Router();

launchesRouter.get('/', httpGetAllLaunch);
launchesRouter.post('/', httpAddLaunch);
launchesRouter.delete('/:id', httpAbortLaunch);

module.exports = launchesRouter;

仓库

github.com/huanhunmao/…

【翻译】理解 React 的 useEffectEvent:解决过期闭包的完整指南

原文链接: peterkellner.net/2026/01/09/…

作者:Peter Kellner

TL;DR

useEffectEvent 允许你在 Effect 中读取最新的 props/state,而无需将其添加到依赖数组中。当你需要一个始终能看到当前值的稳定回调函数时,它消除了使用 useRef 变通方案的必要性。跳转至对比说明


# 引言 若你曾长期使用 [React](https://react.dev/) 钩子,想必遭遇过这种令人沮丧的场景:在 `useEffect` 中设置订阅或定时器时,回调函数需要读取最新状态。但若将该状态加入依赖数组,每次状态变更时效果器都会重新运行(并重新订阅)。这种做法轻则造成资源浪费,重则导致功能失效。

传统解决方法?将状态镜像到 useRef 中,使回调无需添加依赖即可读取。虽然可行,但冗余代码多且易出错。

React 19.2 推出的 useEffectEvent 提供了优雅解决方案。该钩子创建稳定函数,调用时始终读取最新值——无需在 Effect 依赖中显式添加这些值。

本文将带您了解:

  1. useEffectEvent解决的核心问题
  2. 旧版useRef变通方案及其缺陷
  3. useEffectEvent的底层工作原理
  4. 两种方案的实战示例
  5. 关键规则与注意事项

问题:效果中的陈旧闭包

让我们从一个具体问题开始。你正在构建一个连接聊天室的聊天应用。当收到消息时,你希望显示通知——但仅当通知功能已启用时才显示。

以下是看似"显而易见"却行不通的方法:

import { useEffect, useState } from "react";

function ChatRoom({ roomId }: { roomId: string }) {
  const [notificationsEnabled, setNotificationsEnabled] = useState(true);

  useEffect(() => {
    const connection = connectToRoom(roomId);

    connection.on("message", (message: string) => {
      // BUG: This captures the initial value of notificationsEnabled
      // It will NEVER see updates when the user toggles the checkbox!
      if (notificationsEnabled) {
        showNotification(message);
      }
    });

    return () => connection.disconnect();
  }, [roomId]); // notificationsEnabled is NOT in deps

  return (
    <label>
      <input
        type="checkbox"
        checked={notificationsEnabled}
        onChange={(e) => setNotificationsEnabled(e.target.checked)}
      />
      Enable notifications
    </label>
  );
}

connection.on("message", ...) 中的回调函数创建了一个闭包,该闭包捕获了效果运行时的 notificationsEnabled 值。由于 notificationsEnabled 未包含在依赖数组中,因此当 roomId 发生变化时,该效果仅会触发一次。回调函数将永远看到原始值。

制造新问题的“修复方案”

你可能会想:“简单,只要在依赖项中添加notificationsEnabled就行了!”

useEffect(() => {
  const connection = connectToRoom(roomId);

  connection.on("message", (message: string) => {
    if (notificationsEnabled) {
      showNotification(message);
    }
  });

  return () => connection.disconnect();
}, [roomId, notificationsEnabled]); // Now notificationsEnabled is a dep

现在回调函数能看到最新值了……但有个问题。每次 notificationsEnabled 改变时,效果都会重新运行。这意味着:

  1. 断开房间连接
  2. 重新连接房间
  3. 重新注册消息处理器

切换通知复选框不该导致聊天重新连接!这将导致糟糕的用户体验——重新连接期间可能遗漏消息,服务器连接数激增,纯属资源浪费。

这正是 useEffectEvent 解决的核心矛盾:某些值应触发 Effect 重跑(如roomId,而另一些值仅需在需要时读取但不触发重跑(如notificationsEnabled

旧式解决方法:useRef

useEffectEvent出现之前,标准做法是通过将值镜像到useRef中来"逃逸"闭包:

import { useEffect, useRef, useState } from "react";

function ChatRoom({ roomId }: { roomId: string }) {
  const [notificationsEnabled, setNotificationsEnabled] = useState(true);

  // Mirror the value into a ref
  const notificationsEnabledRef = useRef(notificationsEnabled);

  // Keep the ref in sync with state
  useEffect(() => {
    notificationsEnabledRef.current = notificationsEnabled;
  }, [notificationsEnabled]);

  useEffect(() => {
    const connection = connectToRoom(roomId);

    connection.on("message", (message: string) => {
      // Read from the ref instead of the closure
      if (notificationsEnabledRef.current) {
        showNotification(message);
      }
    });

    return () => connection.disconnect();
  }, [roomId]); // Only roomId triggers reconnection

  return (
    <label>
      <input
        type="checkbox"
        checked={notificationsEnabled}
        onChange={(e) => setNotificationsEnabled(e.target.checked)}
      />
      Enable notifications
    </label>
  );
}

这确实有效!连接效果仅在roomId变更时重新运行。消息处理器从notificationsEnabledRef.current读取数据,该值始终保持最新状态。

useRef模式存在的问题

  1. 冗余代码:每次需要"逃逸"的值都需创建ref、同步效果,并记得读取.current
  2. 易遗漏:新增回调所需值时,必须额外添加 ref 和同步 Effect
  3. 组件冗余:核心逻辑被 ref 管理层掩盖
  4. 无法通过代码检查:ESLint 钩子规则无法验证 ref 使用正确性

使用useEffectEvent

useEffectEvent提供了一种一流的解决方案。它返回一个稳定函数,调用时始终使用最新的propsstate执行。

import { useEffect, useState, useEffectEvent } from "react";

function ChatRoom({ roomId }: { roomId: string }) {
  const [notificationsEnabled, setNotificationsEnabled] = useState(true);

  // Create an Effect Event that reads latest values
  const onMessage = useEffectEvent((message: string) => {
    if (notificationsEnabled) {
      showNotification(message);
    }
  });

  useEffect(() => {
    const connection = connectToRoom(roomId);
    connection.on("message", onMessage);
    return () => connection.disconnect();
  }, [roomId]); // Only roomId triggers reconnection

  return (
    <label>
      <input
        type="checkbox"
        checked={notificationsEnabled}
        onChange={(e) => setNotificationsEnabled(e.target.checked)}
      />
      Enable notifications
    </label>
  );
}

请注意以下差异:

  • 无引用
  • 无同步效果
  • .current 读取
  • onMessage 函数保持稳定(在不同渲染间具有相同标识)
  • 但调用时会看到当前 notificationsEnabled 的值

并列对比:顿悟时刻

让我们通过一个实际案例来对比两种方法:使用分析工具追踪页面访问量。

问题描述

您希望在URL变更时记录页面访问,日志应包含:

  • 访问的URL(响应式——应触发日志)
  • 当前购物车商品数量(非响应式——不应触发新日志)

未使用useEffectEvent(useRef替代方案)

import { useContext, useEffect, useRef } from "react";
import { ShoppingCartContext } from "./cart";

function Page({ url }: { url: string }) {
  const { items } = useContext(ShoppingCartContext);
  const numberOfItems = items.length;

  // Step 1: Create a ref to hold the latest value
  const numberOfItemsRef = useRef(numberOfItems);

  // Step 2: Keep the ref synchronized
  useEffect(() => {
    numberOfItemsRef.current = numberOfItems;
  }, [numberOfItems]);

  // Step 3: Use the ref in your Effect
  useEffect(() => {
    logVisit(url, numberOfItemsRef.current);
  }, [url]); // Only url triggers re-run
}

解决方法所需代码行数:8(引用声明、同步效果、读取.current

使用useEffectEvent

import { useContext, useEffect, useEffectEvent } from "react";
import { ShoppingCartContext } from "./cart";

function Page({ url }: { url: string }) {
  const { items } = useContext(ShoppingCartContext);
  const numberOfItems = items.length;

  // Create an Effect Event for the non-reactive logic
  const onVisit = useEffectEvent((visitedUrl: string) => {
    logVisit(visitedUrl, numberOfItems);
  });

  useEffect(() => {
    onVisit(url);
  }, [url]); // Only url triggers re-run
}

代码行数:4(仅包含Effect事件和Effect)

关键洞察:参数与捕获值的区别

注意我将url作为参数传递给onVisit(),而非直接在Effect事件内部读取。这是有意为之,且符合React文档的建议。

当将 url 作为参数传递时:

  • 不同 URL 明确代表不同"事件"
  • 响应式值通过函数调用显式传递

当在效果事件内部读取 numberOfItems 时:

  • 它捕获调用时的最新值
  • 不影响效果事件的触发时机

这种模式清晰区分了响应式与非响应式逻辑。

这是魔法吗?(剧透:不,只是JavaScript)

初次见到useEffectEvent时,我的反应是:"等等,这怎么实现的?回调函数居然...能自动获取最新状态?React在搞什么魔法?"

答案是否定的。这里没有魔法,没有特殊编译技巧,也没有隐藏的React内部机制在做不可思议的事。useEffectEvent 实现的正是你手动使用 useRef 时所做的操作——React 只是将这种模式自动化了。

让我们通过图表逐步揭开谜底。

直观理解闭包失效问题

首先可视化闭包失效的原因:当你在组件内部创建回调函数时,它会捕获该次渲染的值: 在渲染1中创建的回调函数捕获了count=0的值。即使组件使用新值重新渲染,该回调函数仍保留其原始闭包。当事件最终触发时,它读取的是过时的值。

useRef如何解决此问题

useRef模式之所以有效,是因为ref提供了一个具有稳定标识的可变容器 关键洞见:ref对象本身永远不会改变身份。回调函数的闭包捕获了ref对象(保持不变),当它运行时,会读取.current(已被更新)。间接引用解决了问题!

useEffectEvent的工作原理(揭秘其"魔力")

现在揭晓答案:useEffectEvent的实现原理完全相同。React创建了一个稳定的封装函数,该函数委托给内部持有最新回调的ref对象: 当你调用useEffectEvent返回的包装函数时:

  • 它不会直接执行你的回调函数
  • 而是从内部引用中查找最新版本
  • 然后调用该最新版本

这就是为什么返回的函数具有稳定的特性(跨渲染的相同函数引用),但读取最新值(因为它委托给刚更新的回调函数)。

等效表达

以下代码实现了相同效果: 使用 useRef 的实现:

// Manual approach: 8 lines
const countRef = useRef(count);
useEffect(() => {
  countRef.current = count;
}, [count]);

const handler = useCallback(() => {
  console.log(countRef.current);
}, []);

useEffectEvent的实现:

// useEffectEvent: 3 lines
const handler = useEffectEvent(() => {
  console.log(count);
});

相同的行为。更少的代码。所谓的“魔法”不过是React自动实现了众所周知的模式。

概念性实现

以下是useEffectEvent在底层的核心工作原理:

// This is NOT the actual React implementation, just a mental model
function useEffectEvent<T extends (...args: any[]) => any>(callback: T): T {
  // This ref holds the latest callback
  const latestCallbackRef = useRef(callback);

  // Update the ref after each render (synchronously, before Effects run)
  latestCallbackRef.current = callback;

  // Return a stable wrapper that calls the latest callback
  const stableWrapper = useCallback((...args: Parameters<T>) => {
    return latestCallbackRef.current(...args);
  }, []);

  return stableWrapper as T;
}

实际实现更为复杂(它与React内部的Fiber架构深度集成),但这抓住了核心本质:一个稳定的标识符包裹着一个持续更新的回调函数

为何这很重要

理解 useEffectEvent 并非魔法具有实际益处:

  1. **调试:**当出错时,你能理性分析——它本质只是引用和回调
  2. **思维模型:**你理解规则存在的缘由(如"仅限从效果器调用")
  3. **回退知识:**在旧版 React 中,你清楚如何复现行为
  4. **信心:**你不再依赖"它就是管用"——而是理解其机制

最优秀的抽象并非神秘的黑盒,而是对已知模式的便捷封装。

更复杂的示例:静音聊天连接

以下示例充分展现了其价值。设想一款聊天应用:

  • 切换聊天室时应重新连接
  • 切换静音状态时不应重新连接

显而易见的笨拙方法

function Chat({ roomId }: { roomId: string }) {
  const [isMuted, setIsMuted] = useState(false);

  useEffect(() => {
    const connection = connectToRoom(roomId);

    connection.on("message", (message: string) => {
      // BUG: isMuted is stale!
      if (!isMuted) {
        playMessageSound();
      }
      addMessageToChat(message);
    });

    return () => connection.disconnect();
  }, [roomId]); // isMuted not in deps = stale

  return (
    <button onClick={() => setIsMuted(!isMuted)}>
      {isMuted ? "Unmute" : "Mute"}
    </button>
  );
}

随心切换静音状态——声音播放却基于初始的isMuted值。

重新连接的“修复”

useEffect(() => {
  const connection = connectToRoom(roomId);

  connection.on("message", (message: string) => {
    if (!isMuted) {
      playMessageSound();
    }
    addMessageToChat(message);
  });

  return () => connection.disconnect();
}, [roomId, isMuted]); // Now it works... but reconnects on mute toggle

此方法可行但用户体验极差。每次静音切换都会:

  1. 断开聊天连接
  2. 重新连接
  3. 可能在重新连接期间遗漏消息

useRef 替代方案

function Chat({ roomId }: { roomId: string }) {
  const [isMuted, setIsMuted] = useState(false);

  const isMutedRef = useRef(isMuted);
  useEffect(() => {
    isMutedRef.current = isMuted;
  }, [isMuted]);

  useEffect(() => {
    const connection = connectToRoom(roomId);

    connection.on("message", (message: string) => {
      if (!isMutedRef.current) {
        playMessageSound();
      }
      addMessageToChat(message);
    });

    return () => connection.disconnect();
  }, [roomId]);

  return (
    <button onClick={() => setIsMuted(!isMuted)}>
      {isMuted ? "Unmute" : "Mute"}
    </button>
  );
}

运行正常,但会添加引用模板代码。

Clean useEffectEvent 解决方案

import { useEffect, useState, useEffectEvent } from "react";

function Chat({ roomId }: { roomId: string }) {
  const [isMuted, setIsMuted] = useState(false);

  const onMessage = useEffectEvent((message: string) => {
    if (!isMuted) {
      playMessageSound();
    }
    addMessageToChat(message);
  });

  useEffect(() => {
    const connection = connectToRoom(roomId);
    connection.on("message", onMessage);
    return () => connection.disconnect();
  }, [roomId]); // Clean: only roomId triggers reconnection

  return (
    <button onClick={() => setIsMuted(!isMuted)}>
      {isMuted ? "Unmute" : "Mute"}
    </button>
  );
}

无引用、无额外效果、无冗余代码。关注点分离明确:

  • 响应式(roomId): 变更触发重新连接
  • 非响应式(isMuted): 按需读取最新值,不触发重新连接

规则与注意事项

useEffectEvent 功能强大,但需遵循重要规则。eslint-plugin-react-hooks(6.1.1+版本)会强制执行这些规则。

规则一:仅在效果器内部调用效果事件

效果事件的设计目的仅限于在效果器内部调用。它们并非通用型稳定回调函数。

// ✅ Correct: Called from inside an Effect
const onMessage = useEffectEvent((msg: string) => {
  console.log(msg, latestState);
});

useEffect(() => {
  socket.on("message", onMessage);
  return () => socket.off("message", onMessage);
}, []);

// ❌ Wrong: Called from an event handler
<button onClick={() => onMessage("hello")}>
  Click me
</button>

// ❌ Wrong: Called during render
return <div>{onMessage("rendered")}</div>;

对于常规事件处理器(如onClickonChange等),无需使用useEffectEvent。由于处理器在每次渲染时都会创建,因此每次运行时都会获得最新值。

规则二:不要将效果事件传递给其他组件

效果事件应局限于其所属组件内部。请勿将其作为 props 传递:

// ✅ Correct: Keep Effect Events local
function Parent() {
  const [count, setCount] = useState(0);

  const onTick = useEffectEvent(() => {
    console.log(count);
  });

  useEffect(() => {
    const id = setInterval(() => onTick(), 1000);
    return () => clearInterval(id);
  }, []);

  return <div>Count: {count}</div>;
}

// ❌ Wrong: Passing Effect Event as a prop
function Parent() {
  const onTick = useEffectEvent(() => {
    console.log(latestCount);
  });

  return <Timer onTick={onTick} />; // Don't do this!
}

若需构建需要回调参数的自定义钩子,请在钩子内部而非外部定义效果事件。

规则三:在使用效果事件之前声明

将效果事件声明置于其使用位置附近:

// ✅ Good: Effect Event declared right before its Effect
function Component() {
  const [value, setValue] = useState(0);

  const onInterval = useEffectEvent(() => {
    console.log("Current value:", value);
  });

  useEffect(() => {
    const id = setInterval(() => onInterval(), 1000);
    return () => clearInterval(id);
  }, []);
}

// ❌ Avoid: Effect Event far from its Effect (confusing)
function Component() {
  const [value, setValue] = useState(0);
  const onInterval = useEffectEvent(() => { /* ... */ });

  // ... 50 lines of other code ...

  useEffect(() => {
    const id = setInterval(() => onInterval(), 1000);
    return () => clearInterval(id);
  }, []);
}

规则4:不要使用useEffectEvent来抑制代码检查器警告

这关乎意图。useEffectEvent用于分离响应式与非响应式逻辑——而非用于屏蔽exhaustive-deps代码检查规则。

// ✅ Correct: page SHOULD be a dependency because you WANT to refetch when it changes
useEffect(() => {
  async function fetchData() {
    const data = await fetch(`/api/items?page=${page}`);
    setItems(data);
  }
  fetchData();
}, [page]); // Correctly triggers refetch on page change

// ❌ Wrong mental model: "I'll use useEffectEvent so I don't have to list dependencies"
const fetchData = useEffectEvent(async () => {
  const data = await fetch(`/api/items?page=${page}`);
  setItems(data);
});

useEffect(() => {
  fetchData();
}, []); // "Now I don't need page in deps!" <- Wrong!

需要思考的问题是:“当该值发生变化时,是否需要重新运行效果?”若答案为是,则属于依赖关系;若答案为否(仅需在其他触发器启动效果时读取最新值),则应使用效果事件。

何时应使用 useEffectEvent

当效果器内部的回调函数满足以下条件时,请使用 useEffectEvent

  • 被传递给订阅器、定时器或外部库,且不希望重新注册
  • 调用时需要读取最新的 props/state
  • 这些值不应触发效果器重新运行

常见场景:

场景 响应式(触发效果) 非响应式(效果事件)
聊天室连接 roomId isMutedtheme
分析日志记录 pageUrl cartItemCountuserId
间隔计数器 - (仅运行一次) countstep
WebSocket消息 socketUrl isOnlinepreferences
动画帧 - (仅运行一次) currentPosition

关于 React 版本说明

useEffectEvent 作为稳定功能在 React 19.2 中引入。若您使用的是早期版本:

  • React 18.x 及更早版本: 请使用上述描述的 useRef 模式
  • React 19.0-19.1:useEffectEvent 可用但处于实验阶段
  • React 19.2+: 可放心使用 useEffectEvent

您可通过以下方式检查 React 版本:

npm list react

总结

useEffectEvent 解决了 React 钩子中长期存在的痛点:在 Effect 内部访问最新状态/属性时,避免触发不必要的重新运行。

之前: 将值镜像到 ref 中,添加同步 Effect,读取 .current——所有这些都是手动操作且易出错的冗余代码。

之后: 将回调函数包裹在 useEffectEvent 中,让 React 自动保持其更新。

核心思维模型:

  • 依赖关系回答:"何时应重新运行此 Effect?"
  • Effect Events回答:"Effect 运行时应读取哪些值?"

通过明确分离这些关注点,代码更清晰,更不易引入错误。

延伸阅读

Vue3 多主题/明暗模式切换:CSS 变量 + class 覆盖的完整工程方案(附开源代码)

文章简介

之前逛 V 站的时候刷到一个讲 JSON 格式化工具信息泄漏的帖子,有条评论说:“V 站不是人手一个工具站吗?”受此感召,我给自己做了一个工具站。

在搭建工具站的时候有做多主题、亮/暗主题切换,于是有了这篇文章。

备注:工具站当前支持的工具还不多,但已开源,也有部署在 Github page 中,文中介绍的主题切换源码也在其中,感兴趣的朋友可随意取用,后续我也会将自己要用的、感兴趣的工具集成进去。

再备注:此处介绍的多主题、模式切换是在 vue3 中实现,其他环境请感兴趣的朋友自行实现。

工具站源码地址

仓库地址:github.com/the-wind-is…

工具站地址:the-wind-is-rising-dev.github.io/endless-que…

实现原理

主题切换使用了 CSS 变量和 class 覆盖两种特性。

  • class 覆盖特性,后加载的 class 样式会覆盖之前加载的 class 样式,变量也会被覆盖。
  • CSS 变量定义时以 -- 开头,如下:
:root {
  /* ========== 品牌主色调 ========== */
  --brand-primary: #4f46e5; /* 主色:靛蓝 */
  --brand-secondary: #0ea5e9; /* 次要色:天蓝 */
  --brand-accent: #8b5cf6; /* 强调色:紫色 */
}

实现思路

  1. 首先在 :root 伪 class 下定义所有需要用到的变量,然后定义拥有相同变量的不同主题 class
  2. 切换主题时通过 document 直接设置对应主题的 class
  3. 跟随系统主题可以通过监听 (prefers-color-scheme: dark) 来切换

:root 伪 class 定义

源码在 src/themes/index.css 文件内,此处只贴出部分变量

:root {
  /* 背景与表面色 */
  --bg-primary: #f8fafc; /* 主背景 */
  --bg-secondary: #ffffff; /* 次级背景/卡片 */
  --bg-tertiary: #f1f5f9; /* 工具栏/三级背景 */
  --bg-sidebar: #e2e8f0; /* 侧边栏背景 */
}

默认主题明亮模式 class 定义

源码在 src/themes/default/light.css 文件内,此处只贴出部分变量

html.theme-default {
  /* 背景与表面色 */
  --bg-primary: #f8fafc; /* 主背景 */
  --bg-secondary: #ffffff; /* 次级背景/卡片 */
  --bg-tertiary: #f1f5f9; /* 工具栏/三级背景 */
  --bg-sidebar: #e2e8f0; /* 侧边栏背景 */
}

默认主题暗夜模式 class 定义

源码在 src/themes/default/dark.css 文件内,此处只贴出部分变量

html.theme-default.dark {
  /* 背景与表面色 */
  --bg-primary: #0f172a; /* 主背景 */
  --bg-secondary: #1e293b; /* 次级背景/卡片 */
  --bg-tertiary: #334155; /* 工具栏/三级背景 */
  --bg-sidebar: #1e293b; /* 侧边栏背景 */
}

主题切换源码

源码位置:src/themes/theme.ts

切换主题后会将当前主题保存至本地,下次打开站点时会自动加载上次设置的主题

  • 对象定义
    • Theme:用来定义主题信息
    • ThemeModel:用来定义当前模式(明亮/暗夜),以及是否跟随系统
    • ThemeConfig:用来定义当前主题与模式
  • 函数定义
    • isDarkMode:用来判断当前系统是否为暗夜模式
    • applyTheme:用来应用主题与模式
    • initializeTheme:初始化主题,用来加载之前设置的主题与模式
    • getCurrentThemeConfig:获取当前主题配置(主题与模式)
    • addDarkListener:添加暗夜模式监听
    • removeDarkListener:移除暗夜模式监听
    • changeThemeMode:切换主题模式(亮/暗模式)
    • changeTheme:切换主题,默认主题、星空主题、海洋主题等
    • getThemeList:获取支持的主题列表 备注:主题初始化、暗夜模式监听/移除监听函数需要在主页面加载时调用、设置
// 存储主题配置的键
const THEME_STORAGE_KEY = "custom-theme";

// 主题
export interface Theme {
  name: string; // 主题名称
  className: string; // 对应的 CSS 类名
}

// 模式
export interface ThemeModel {
  name: string; // 模式名称
  followSystem: boolean; // 是否跟随系统
  value: "light" | "dark"; // 模式值
}

// 主题配置
export interface ThemeConfig {
  theme: Theme; // 主题
  model: ThemeModel; // 默认主题模式
}

/**
 * 检测当前系统是否启用暗黑模式
 */
function isDarkMode() {
  return (
    window.matchMedia &&
    window.matchMedia("(prefers-color-scheme: dark)").matches
  );
}

/**
 * 应用主题
 * @param themeConfig 主题配置
 */
function applyTheme(themeConfig: ThemeConfig) {
  const className = themeConfig.theme.className;
  const mode = themeConfig.model;

  // 移除旧的主题类
  const classes = document.documentElement.className.split(" ");
  const themeClasses = classes.filter(
    (c) => !c.includes("theme-") && c !== "dark"
  );
  document.documentElement.className = themeClasses.join(" ");

  // 添加新的主题类
  document.documentElement.classList.add(className);
  // 判断是否启用暗黑模式
  if (mode.value === "dark") {
    document.documentElement.classList.add("dark");
  }

  // 存储当前主题配置
  localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify(themeConfig));
}

/**
 * 初始化主题
 */
export function initializeTheme() {
  // 获取当前主题配置并应用
  const themeConfig = getCurrentThemeConfig();
  // 初始化当前主题类型
  if (themeConfig.model.followSystem) {
    themeConfig.model.value = isDarkMode() ? "dark" : "light";
  }
  applyTheme(themeConfig);
}

/**
 * 获取当前主题配置
 * @returns 主题配置
 */
export function getCurrentThemeConfig(): ThemeConfig {
  let theme: any = localStorage.getItem(THEME_STORAGE_KEY);
  return theme
    ? JSON.parse(theme)
    : {
        theme: getThemeList()[0], // 默认主题
        model: {
          name: "跟随系统",
          followSystem: true,
          value: isDarkMode() ? "dark" : "light",
        },
      };
}

/**
 * 添加暗黑模式监听
 */
export function addDarkListener() {
  // 监听暗黑模式变化, auto 模式动态切换主题
  window
    .matchMedia("(prefers-color-scheme: dark)")
    .addEventListener("change", (e) => {
      const themeConfig = getCurrentThemeConfig();
      if (!themeConfig.model.followSystem) return;
      changeThemeMode(themeConfig.model);
    });
}

/**
 * 移除暗黑模式监听
 */
export function removeDarkListener() {
  window
    .matchMedia("(prefers-color-scheme: dark)")
    .removeEventListener("change", () => {});
}

/**
 * 切换主题模式
 * @param mode 模式
 */
export function changeThemeMode(themeModel: ThemeModel) {
  const themeConfig = getCurrentThemeConfig();
  themeConfig.model = themeModel;
  if (themeModel.followSystem) {
    themeConfig.model.value = isDarkMode() ? "dark" : "light";
  }
  applyTheme(themeConfig);
}

/**
 * 切换主题
 * @param theme 主题
 */
export function changeTheme(theme: Theme) {
  const themeConfig = getCurrentThemeConfig();
  themeConfig.theme = theme;
  applyTheme(themeConfig);
}

/**
 * 获取主题列表
 * @returns 主题列表
 */
export function getThemeList(): Theme[] {
  return [
    {
      name: "默认",
      className: "theme-default",
    },
    {
      name: "星空",
      className: "theme-starry",
    },
    {
      name: "海洋",
      className: "theme-ocean",
    },
  ];
}

主题、模式手动切换组件

源码位置:src/themes/Theme.vue

组件内会自动加载站点支持的主题与模式,也会根据系统模式变化自动切换状态信息,源码内有注释,此处不赘述

<script setup lang="ts">
import { SettingOutlined, BulbFilled } from "@ant-design/icons-vue";
import { onMounted, onUnmounted, ref } from "vue";
import {
  Theme,
  getThemeList,
  getCurrentThemeConfig,
  changeTheme,
  changeThemeMode,
} from "./theme";

const themeList = ref<Theme[]>(getThemeList());
const currentTheme = ref<Theme>(getCurrentThemeConfig().theme);
const followSystem = ref<boolean>(getCurrentThemeConfig().model.followSystem);
const isLightModel = ref<boolean>(
  getCurrentThemeConfig().model.value == "light"
);

// 切换主题
function onChangeTheme(theme: Theme) {
  currentTheme.value = theme;
  changeTheme(theme);
}

// 切换跟随系统
function onFollowSystemChange() {
  followSystem.value = !followSystem.value;
  let themeConfig = getCurrentThemeConfig();
  themeConfig.model.followSystem = followSystem.value;
  changeThemeMode(themeConfig.model);
}

// 切换主题模式
function onChangeThemeModel(value: boolean) {
  isLightModel.value = value;
  let themeConfig = getCurrentThemeConfig();
  themeConfig.model.value = value ? "light" : "dark";
  changeThemeMode(themeConfig.model);
}

// 添加主题模式监听
let interval: NodeJS.Timeout | null = null;
onMounted(() => {
  // 定时更新主题信息
  interval = setInterval(() => {
    const themeConfig = getCurrentThemeConfig();
    currentTheme.value = themeConfig.theme;
    followSystem.value = themeConfig.model.followSystem;
    isLightModel.value = themeConfig.model.value == "light";
  }, 200);
});

onUnmounted(() => {
  // 移除定时更新主题信息
  interval && clearInterval(interval);
});
</script>

<template>
  <div class="theme-root center">
    <a-dropdown placement="bottom">
      <div class="theme-btn center">
        <SettingOutlined />
      </div>
      <template #overlay>
        <a-menu>
          <div
            class="theme-item"
            v-for="theme in themeList"
            :key="theme.className"
            @click="onChangeTheme(theme)"
          >
            <div class="row">
              <div
                style="width: var(--space-xl); font-size: var(--font-size-sm)"
              >
                <BulbFilled
                  class="sign"
                  v-if="theme.className == currentTheme.className"
                />
              </div>
              <div>{{ theme.name }}-主题</div>
            </div>
          </div>
          <div class="theme-model-item row">
            <a-radio
              v-model:checked="followSystem"
              @click="onFollowSystemChange()"
              >🖥️</a-radio
            >
            <a-switch
              checked-children="☀️"
              un-checked-children="🌑"
              v-model:checked="isLightModel"
              :disabled="followSystem"
              @change="onChangeThemeModel"
            />
          </div>
        </a-menu>
      </template>
    </a-dropdown>
  </div>
</template>

<style scoped>
.theme-root {
  padding: var(--space-lg);
}
.theme-btn {
  padding: var(--space-xs) var(--space-lg);
  font-size: var(--font-size-2xl);
  color: var(--brand-primary);
}
.theme-item {
  padding: var(--space-sm) var(--space-md);
  border-radius: var(--radius-sm);
  color: var(--text-primary);
  user-select: none;
  cursor: pointer;

  .sign {
    color: var(--brand-accent);
  }

  &:hover {
    background: var(--brand-secondary);
    color: var(--text-inverse);
  }

  &:active {
    background: var(--brand-primary);
    color: var(--text-inverse);
    .sign {
      color: var(--text-inverse);
    }
  }
}
.theme-model-item {
  padding: var(--space-sm) var(--space-md);
  color: var(--text-primary);
  user-select: none;
}
</style>

vue main.js 文件内容

源码位置:src/main.js

该文件内需引入 "src/themes/index.css" 文件,如下

import { createApp } from "vue";
import Antd from "ant-design-vue";
import "./themes/index.css";
import App from "./App.vue";

createApp(App).use(Antd).mount("#app");

主题初始化、模式监听

源码位置:src/App.vue

src/App.vue 文件是 vue 所有的页面基础,在此处初始化主题信息、监听模式变化比较合适。

  • 初始化主题样式只需要调用 src/themes/theme.ts 内的 initializeTheme() 函数即可
  • 监听模式变化需要在组件挂载之后,在 onMounted 函数内调用 addDarkListener() 函数即可
  • 移除监听需要在组件卸载之后,在 onUnmounted 函数内调用 removeDarkListener() 函数即可

src/App.vue 文件内 script 块部分源码如下

function initialize() {
  // 初始化主题样式
  initializeTheme();
}
initialize();
// 组件生命周期钩子
onMounted(() => {
  initialize();
  // 添加暗黑模式监听器
  addDarkListener();
});
onUnmounted(() => {
  // 移除暗黑模式监听器
  removeDarkListener();
});

仓库地址:

仓库地址:github.com/the-wind-is…

工具站地址:the-wind-is-rising-dev.github.io/endless-que…

map和weakMap有哪些区别

Map 和 WeakMap 都是 ES6 引入的键值对集合,但它们在内存管理和使用上有重要区别:

1. 核心区别对比表

特性 Map WeakMap
键的类型 任意类型(原始值、对象等) 必须是对象(非原始值)
引用类型 强引用(阻止垃圾回收) 弱引用(不阻止垃圾回收)
可枚举性 可迭代(可获取所有键值) 不可迭代(无法获取所有键值)
大小/长度 有 .size 属性 没有 .size 属性
清除方法 有 .clear() 方法 没有 .clear() 方法
遍历方法 keys()values()entries()forEach() 没有任何遍历方法
垃圾回收 键值对会阻止垃圾回收 键对象被回收时,对应值也会被回收
使用场景 通用数据存储、缓存 私有数据存储、DOM元素元数据

2. 详细对比

键的类型限制

// Map:键可以是任意类型
const map = new Map();
map.set('string', 'value'); // ✅ 字符串
map.set(123, 'number'); // ✅ 数字
map.set(true, 'boolean'); // ✅ 布尔值
map.set({}, 'object'); // ✅ 对象
map.set(null, 'null'); // ✅ null
map.set(undefined, 'undefined'); // ✅ undefined

// WeakMap:键必须是对象(非原始值)
const weakMap = new WeakMap();
const obj = {};
const arr = [];
const func = function() {};

weakMap.set(obj, 'value1'); // ✅ 对象
weakMap.set(arr, 'value2'); // ✅ 数组
weakMap.set(func, 'value3'); // ✅ 函数

// weakMap.set('string', 'value'); // ❌ 原始值会报错
// weakMap.set(123, 'value'); // ❌ 原始值会报错

垃圾回收行为

// Map:强引用 - 阻止垃圾回收
let obj = { id: 1 };
const map = new Map();
map.set(obj, 'some data');

obj = null; // 清除对对象的引用

// 但 Map 仍然持有引用,对象不会被垃圾回收
console.log(map.size); // 1
// 可以通过 map.keys() 获取到 { id: 1 }

// WeakMap:弱引用 - 不阻止垃圾回收
let obj2 = { id: 2 };
const weakMap = new WeakMap();
weakMap.set(obj2, 'some private data');

obj2 = null; // 清除对对象的引用

// 下一次垃圾回收时,{ id: 2 } 会被回收
// weakMap 中的对应条目也会自动删除
// 无法验证这一点,因为 WeakMap 不可枚举

方法和属性的差异

const map = new Map();
const weakMap = new WeakMap();
const key = {};

map.set(key, 'map value');
weakMap.set(key, 'weakmap value');

// Map 有丰富的方法和属性
console.log(map.size); // 1 ✅
console.log([...map.keys()]); // [{}] ✅
console.log([...map.values()]); // ['map value'] ✅
map.forEach((v, k) => console.log(k, v)); // {} 'map value' ✅

// WeakMap 只有基本操作
console.log(weakMap.has(key)); // true ✅
console.log(weakMap.get(key)); // 'weakmap value' ✅
weakMap.delete(key); // true ✅

// console.log(weakMap.size); // undefined ❌
// console.log([...weakMap.keys()]); // 报错 ❌
// weakMap.forEach(...); // 没有此方法 ❌
// weakMap.clear(); // 没有此方法 ❌

3. 内存管理示例

内存泄漏问题

// Map 可能导致内存泄漏
class User {
  constructor(id) {
    this.id = id;
  }
}

// 使用 Map 存储用户元数据
const userMetadata = new Map();
let users = [];

// 创建 1000 个用户
for (let i = 0; i < 1000; i++) {
  const user = new User(i);
  users.push(user);
  userMetadata.set(user, { createdAt: Date.now() });
}

// 删除所有用户引用
users = [];

// 但是 Map 仍然持有所有用户对象的引用!
// 这 1000 个 User 对象不会被垃圾回收
console.log(userMetadata.size); // 1000 - 内存泄漏!

// WeakMap 自动清理
const userPrivateData = new WeakMap();
let users2 = [];

for (let i = 0; i < 1000; i++) {
  const user = new User(i);
  users2.push(user);
  userPrivateData.set(user, { token: 'secret' + i });
}

users2 = []; // 清除引用

// WeakMap 中的条目会自动被垃圾回收
// 无法查看大小,但内存会被释放

DOM 元素关联数据

// 场景:为 DOM 元素存储额外数据

// 使用 Map 的问题
const elementDataMap = new Map();
const elements = document.querySelectorAll('.item');

elements.forEach((element, index) => {
  elementDataMap.set(element, { index, clicked: 0 });
  
  element.addEventListener('click', () => {
    const data = elementDataMap.get(element);
    data.clicked++;
    console.log(`Clicked ${data.clicked} times`);
  });
});

// 当 DOM 元素被移除时
elements[0].remove(); // 从 DOM 中移除

// 但 Map 仍然持有对该元素的引用
// 元素不会被垃圾回收,导致内存泄漏!

// 使用 WeakMap 的解决方案
const elementDataWeakMap = new WeakMap();
const elements2 = document.querySelectorAll('.item');

elements2.forEach((element, index) => {
  elementDataWeakMap.set(element, { index, clicked: 0 });
  
  element.addEventListener('click', () => {
    const data = elementDataWeakMap.get(element);
    if (data) { // 元素可能已被垃圾回收
      data.clicked++;
      console.log(`Clicked ${data.clicked} times`);
    }
  });
});

// 当 DOM 元素被移除时
elements2[0].remove(); // 从 DOM 中移除

// 如果没有其他引用指向该元素
// 元素会被垃圾回收,WeakMap 中的对应数据也会被清理
// 自动防止内存泄漏!

4. 使用场景对比

适合使用 Map 的场景:

// 1. 需要遍历所有键值对
const config = new Map([
  ['host', 'localhost'],
  ['port', 8080],
  ['debug', true]
]);

// 遍历所有配置
for (let [key, value] of config) {
  console.log(`${key}: ${value}`);
}

// 2. 需要知道存储了多少项
const cache = new Map();
cache.set('user:1', { name: 'Alice' });
cache.set('user:2', { name: 'Bob' });
console.log(`缓存了 ${cache.size} 个用户`);

// 3. 键是原始值
const errorMessages = new Map();
errorMessages.set(404, 'Not Found');
errorMessages.set(500, 'Internal Server Error');

// 4. 需要一次性清除所有数据
cache.clear(); // 清空缓存

适合使用 WeakMap 的场景:

// 1. 私有数据存储(模拟私有属性)
const _private = new WeakMap();

class Person {
  constructor(name, age) {
    // 将私有数据存储在 WeakMap 中
    _private.set(this, {
      name,
      age,
      secret: Math.random()
    });
  }
  
  getName() {
    return _private.get(this).name;
  }
  
  getAge() {
    return _private.get(this).age;
  }
  
  // 无法从外部访问 _private 中的数据
}

const alice = new Person('Alice', 25);
console.log(alice.getName()); // 'Alice'
console.log(alice.secret); // undefined - 无法访问

// 当 Person 实例被销毁时,私有数据自动清理

// 2. 缓存计算结果,但允许自动清理
const expensiveCalculationCache = new WeakMap();

function expensiveCalculation(obj) {
  if (expensiveCalculationCache.has(obj)) {
    return expensiveCalculationCache.get(obj);
  }
  
  const result = /* 昂贵的计算 */ obj.value * 1000;
  expensiveCalculationCache.set(obj, result);
  return result;
}

// 当 obj 不再需要时,缓存会自动清理

// 3. 监听器或订阅者列表
const listeners = new WeakMap();

function addListener(element, event, handler) {
  if (!listeners.has(element)) {
    listeners.set(element, new Map());
  }
  listeners.get(element).set(event, handler);
  element.addEventListener(event, handler);
}

function removeElement(element) {
  // 只需要移除元素,监听器会自动清理
  element.remove();
  // WeakMap 会自动清理相关数据
}

5. 性能注意事项

// WeakMap 在特定场景下性能更好
// 因为它不需要维护键的强引用

// 示例:大量临时对象作为键
const temporaryObjects = [];

// 使用 Map - 内存会持续增长
const map = new Map();
console.time('Map with temporary keys');
for (let i = 0; i < 10000; i++) {
  const tempObj = { id: i };
  temporaryObjects.push(tempObj); // 保持引用,模拟临时使用
  map.set(tempObj, `data${i}`);
  // 假设 tempObj 在这里使用后就不再需要了
}
console.timeEnd('Map with temporary keys');
console.log('Map size:', map.size); // 10000 - 内存占用

// 使用 WeakMap - 内存更友好
const weakMap = new WeakMap();
console.time('WeakMap with temporary keys');
for (let i = 0; i < 10000; i++) {
  const tempObj = { id: i };
  temporaryObjects.push(tempObj); // 保持引用
  weakMap.set(tempObj, `data${i}`);
  // 如果 tempObj 在其他地方没有引用,垃圾回收会清理
}
console.timeEnd('WeakMap with temporary keys');
// 无法获取 weakMap.size,但内存会自动管理

6. 实践建议

选择指南:

// 问自己这些问题:

// 1. 键是对象吗?
//    是 → 考虑 WeakMap
//    否 → 必须用 Map

// 2. 需要遍历所有键值对吗?
//    是 → 必须用 Map
//    否 → 考虑 WeakMap

// 3. 需要知道有多少个键值对吗?
//    是 → 必须用 Map
//    否 → 考虑 WeakMap

// 4. 担心内存泄漏吗?
//    是 → 优先 WeakMap
//    否 → Map 或 WeakMap 都可以

// 5. 数据需要长期存在吗?
//    是 → Map
//    否 → WeakMap(临时数据)

组合使用模式:

// 有时需要组合使用 Map 和 WeakMap
class Registry {
  constructor() {
    this._objects = new Map(); // 需要遍历所有对象
    this._privateData = new WeakMap(); // 每个对象的私有数据
  }
  
  register(id, obj) {
    this._objects.set(id, obj);
    this._privateData.set(obj, {
      registeredAt: Date.now(),
      accessCount: 0
    });
    return obj;
  }
  
  getPrivateData(obj) {
    return this._privateData.get(obj);
  }
  
  getAllIds() {
    return [...this._objects.keys()];
  }
  
  unregister(id) {
    const obj = this._objects.get(id);
    if (obj) {
      this._objects.delete(id);
      // _privateData 中的对应条目会自动清理
    }
  }
}

总结

关键区别:

  • 引用类型:Map 是强引用,WeakMap 是弱引用
  • 键类型:Map 接受任意类型,WeakMap 只接受对象
  • 可枚举性:Map 可枚举,WeakMap 不可枚举
  • 内存管理:WeakMap 自动清理无引用的键,Map 不会

使用 WeakMap 当:

  • 键是对象且生命周期不确定
  • 需要存储私有数据
  • 关联 DOM 元素或临时对象的数据
  • 不希望阻止垃圾回收

使用 Map 当:

  • 键是原始值或需要长期存在
  • 需要遍历所有条目
  • 需要知道存储了多少数据
  • 需要一次性清除所有数据

在现代 JavaScript 开发中,优先考虑 WeakMap 来处理对象作为键的场景,除非你需要 Map 的额外功能(遍历、大小等),这样可以避免内存泄漏问题。

电商都在用的 Sticky Sidebar,原来是这样实现的!

在电商、内容类网站中,“粘性侧边栏” 是非常常见的交互设计 —— 滚动页面时,侧边栏(如商品规格、筛选条件)始终保持可视,能显著提升用户体验。但实现过程中,我们常会遇到布局冲突、动态内容导致 sticky 失效等问题。本文将从基础原理到进阶适配,拆解一个 “智能粘性侧边栏” 的实现思路。

最近在浏览海外电商平台时,注意到一个高频出现的交互细节:产品详情页的侧边栏会“粘性固定”。无论左侧是图片轮播区,还是右侧是商品信息/购买按钮区,只要其中一侧内容较短,它就会在用户滚动页面时自动“吸顶”,始终保持在可视区域内。

9-sticky_effect1.gif

还有一些官网介绍页也有这种效果

9-sticky_effect2.gif

这种 Sticky Sidebar(粘性侧边栏) 效果极大提升了用户体验——用户无需反复滚动回顶部就能看到关键信息或操作按钮。

作为前端,必须学习借鉴一下。今天就一起深入理解下 position: sticky 的工作原理,并手写一个响应式 Sticky Sidebar 的 HTML Demo。

一、position: sticky 基础:粘住,但不 “越界”

position: sticky 是 CSS 中非常实用的定位属性,它兼具relativefixed的特性:

  • 当用户滚动页面、该元素尚未到达指定的粘附阈值(如 top: 20px)时,它表现为 relative 定位,随文档流正常布局;
  • 一旦滚动使其达到阈值(元素顶部距离视口顶部为 20px),它就会“粘住”在视口的指定位置(顶部20px处),表现得像 fixed 定位;
  • 但这种“固定”仅在其父容器的边界内有效——当父容器完全滚出视口后,该元素也会随之离开,不再固定。

总结一句话sticky 元素在滚动到阈值前表现如 relative,之后表现如 fixed,但始终被限制在父容器内

核心粘性样式定义如下:

.sticky-sidebar_sticky {
    position: sticky;
    top: 20px; /* 滚动到距离视口顶部20px时触发粘性 */
    z-index: 10;
}

⚠️ 注意:sticky 定位必须配合至少一个 toprightbottomleft 值才能生效。

二、Sticky 拟人化比喻:方形的女孩与视口顶端的男孩

光看定义太抽象。我自己强行想了个类比来加深记忆:

9-css-sticky-explainer-diagram.png

想象有一个 被拍扁成方形的女孩,她只能在家(父容器)里,从小被父母“金屋藏娇”——她永远不能离开这个房间(即不能脱离父元素的边界)。

在女孩家上空,视口顶部(top: 0)挂着一个 被拧成一条线的男孩,处在浏览器视口的上边缘。女孩头朝向男孩。

  1. 当页面刚开始向下滚动时(视口向下移动),男孩逐渐靠近女孩。

  2. 一旦女孩的头碰到男孩所在的位置(top: 0男孩立马“粘住”了她,带着她在房间内继续“移动”——此时女孩表现为 fixed 定位,粘在视口顶部。

  3. 男孩带着女孩继续在家里“移动”,但注意!她依然不能走出房间。如果男孩飘出女孩家(父容器滚动出视口),她也停留在房间内,男孩女孩暂时分离了。

  4. 当页面向上回滚时,男孩接触到女孩头部时,男孩又会“粘住”她,直到把她带回她最初的那个位置——也就是她在房间里的原始坐标。这时她又变回 relative 定位。

三、实战:手写一个 Sticky Sidebar Demo

我参考主流实现方式,写了一个简洁的 HTML 示例。(可以复制保存到本地看效果)以下是完整代码:

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>Sticky Sidebar with Bottom Alignment</title>
        <style>
            * {
                margin: 0;
                padding: 0;
                box-sizing: border-box;
            }

            body {
                font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
                background: #f9f9f9;
                padding: 40px 20px;
                line-height: 1.6;
                color: #333;
            }

            .sticky-sidebar {
                display: block;
                width: 100%;
            }

            .sticky-sidebar__container {
                display: grid;
                grid-template-columns: 1fr 1fr;
                gap: 40px;
                align-items: flex-start; /* 👈 关键!避免子项被 stretch */
                max-width: 1200px;
                margin: 0 auto;
                padding: 40px 20px;
                background: white;
                border-radius: 16px;
                box-shadow: 0 6px 24px rgba(0, 0, 0, 0.08);
            }

            /* 加深 .sticky-sidebar__content 的阴影 */
            .sticky-sidebar__content {
                background-color: #ffffff;
                padding: 24px;
                border-radius: 12px;
                /* 增加阴影的垂直偏移、模糊半径、扩散半径和颜色,使边缘更明显 */
                box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
            }

            /* 👇 核心:sticky 行为 */
            .sticky-sidebar__sticky {
                position: sticky;
                top: 20px;
                z-index: 10;
            }

            .image-placeholder {
                width: 150px;
                height: 150px;
                background-color: #e0e0e0;
                color: #000000;
                border-radius: 6px;
                display: flex;
                justify-content: center;
                align-items: center;
                font-family: Arial, sans-serif;
                font-size: 14px;
                text-align: center;
                padding: 10px;
            }

            @media screen and (max-width: 989px) {
                .sticky-sidebar__container {
                    grid-template-columns: 1fr;
                    gap: 24px;
                }

                .sticky-sidebar__sticky {
                    position: static !important;
                }
            }

            /* 推荐商品区域 */
            .recommended-products {
                max-width: 1200px;
                margin: 80px auto 0;
                padding: 0 20px;
            }

            .recommended-products h2 {
                text-align: center;
                margin-bottom: 32px;
                font-size: 28px;
                color: #111;
            }

            .product-grid {
                display: grid;
                grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
                gap: 24px;
            }

            .product-card {
                background: white;
                border-radius: 12px;
                overflow: hidden;
                box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
                transition: transform 0.2s;
            }

            .product-card:hover {
                transform: translateY(-4px);
            }

            .product-card img {
                width: 100%;
                height: 200px;
                object-fit: cover;
                background: #eee;
            }

            .product-card .info {
                padding: 16px;
            }

            .product-card .info h3 {
                font-size: 18px;
                margin-bottom: 8px;
            }

            .product-card .info .price {
                color: #e53935;
                font-weight: bold;
            }

            /* 滚动提示 */
            .scroll-hint {
                text-align: center;
                margin-top: 40px;
                color: #888;
                font-style: italic;
            }
        </style>
    </head>

    <body>
        <!-- 主 Sticky 区域 -->
        <sticky-sidebar class="sticky-sidebar" data-sticky-offset="20">
            <div class="sticky-sidebar__container">
                <!-- 左侧:短内容 -->
                <div class="sticky-sidebar__left" data-sidebar-side="left">
                    <div class="sticky-sidebar__content">
                        <h2>🏃‍♂️ Product Media</h2>
                        <p>This is the product image/video gallery area.</p>
                        <div
                            style="
                background: #eee;
                height: 300px;
                margin-top: 16px;
                border-radius: 8px;
                display: flex;
                align-items: center;
                justify-content: center;
              "
                        >
                            [Product Image]
                        </div>
                        <p style="margin-top: 16px; font-size: 14px; color: #666">
                            (Short content — will stick while scrolling)
                        </p>

                        <!-- 动态内容:可展开的图片库 -->
                        <details
                            style="
                margin-top: 20px;
                padding: 12px;
                background: #f0f0f0;
                border-radius: 8px;
                cursor: pointer;
              "
                        >
                            <summary style="font-weight: bold; user-select: none">
                                🖼️ More Images (Click to expand)
                            </summary>
                            <div
                                style="
                  margin-top: 12px;
                  display: grid;
                  grid-template-columns: 1fr 1fr;
                  gap: 8px;
                "
                            >
                                <div class="image-placeholder">[image 1]</div>
                                <div class="image-placeholder">[image 2]</div>
                                <div class="image-placeholder">[image 3]</div>
                                <div class="image-placeholder">[image 4]</div>
                            </div>
                        </details>
                    </div>
                </div>

                <!-- 右侧:超长内容 -->
                <div class="sticky-sidebar__right" data-sidebar-side="right">
                    <div class="sticky-sidebar__content">
                        <h2>🛒 Variants & Add to Cart</h2>
                        <p>Select your size, color, and add to cart below.</p>

                        <div style="margin: 20px 0">
                            <label><strong>Size:</strong></label>
                            <select
                                style="
                  width: 100%;
                  padding: 10px;
                  margin-top: 6px;
                  border: 1px solid #ddd;
                  border-radius: 6px;
                "
                            >
                                <option>US 7</option>
                                <option>US 8</option>
                                <option>US 9</option>
                                <option>US 10</option>
                                <option>US 11</option>
                                <option>US 12</option>
                            </select>
                        </div>

                        <button
                            style="
                background: #1a73e8;
                color: white;
                border: none;
                padding: 14px 24px;
                font-size: 18px;
                border-radius: 8px;
                width: 100%;
                margin: 20px 0;
              "
                        >
                            Add to Cart
                        </button>

                        <hr style="margin: 30px 0; border: 0; border-top: 1px solid #eee" />

                        <h3>📝 Product Description</h3>
                        <p>
                            This premium running shoe features lightweight mesh, responsive foam,
                            and durable outsole.
                        </p>

                        <!-- 动态内容:可展开的详细介绍 -->
                        <details
                            style="
                margin-top: 30px;
                padding: 16px;
                background: #f9f9f9;
                border-radius: 8px;
                cursor: pointer;
              "
                        >
                            <summary style="font-weight: bold; font-size: 16px; user-select: none">
                                📖 Detailed Features & Benefits
                            </summary>
                            <div style="margin-top: 16px; line-height: 1.8; color: #555">
                                <h4 style="margin-top: 16px; margin-bottom: 8px; color: #333">
                                    Advanced Cushioning Technology
                                </h4>
                                <p>
                                    Our premium running shoes feature cutting-edge cushioning
                                    technology that provides exceptional comfort and support. The
                                    multi-layer foam construction absorbs impact while maintaining
                                    responsiveness, allowing you to run longer with less fatigue.
                                </p>

                                <h4 style="margin-top: 16px; margin-bottom: 8px; color: #333">
                                    Breathable Mesh Upper
                                </h4>
                                <p>
                                    The engineered mesh upper ensures maximum breathability, keeping
                                    your feet cool and dry during intense workouts. The strategic
                                    ventilation zones allow air to flow freely, preventing moisture
                                    buildup and odor formation even during extended running
                                    sessions.
                                </p>

                                <h4 style="margin-top: 16px; margin-bottom: 8px; color: #333">
                                    Durable Outsole
                                </h4>
                                <p>
                                    The reinforced rubber outsole is designed to withstand rigorous
                                    use on various terrains. With our proprietary grip pattern,
                                    you'll experience superior traction on both wet and dry
                                    surfaces, ensuring safety and confidence with every stride.
                                </p>

                                <h4 style="margin-top: 16px; margin-bottom: 8px; color: #333">
                                    Lightweight Design
                                </h4>
                                <p>
                                    Weighing just 7.2 ounces per shoe, our design minimizes energy
                                    expenditure while maintaining structural integrity. The
                                    lightweight construction allows for faster acceleration and
                                    smoother transitions, making it ideal for both casual joggers
                                    and competitive runners.
                                </p>

                                <h4 style="margin-top: 16px; margin-bottom: 8px; color: #333">
                                    Sustainability
                                </h4>
                                <p>
                                    We're committed to environmental responsibility. Our shoes are
                                    crafted using 30% recycled materials, reducing waste without
                                    compromising performance. The eco-friendly manufacturing process
                                    minimizes water usage and carbon emissions, making this a
                                    responsible choice for conscious consumers.
                                </p>

                                <h4 style="margin-top: 16px; margin-bottom: 8px; color: #333">
                                    Fit & Comfort
                                </h4>
                                <p>
                                    Designed with an ergonomic fit, these shoes conform to your
                                    foot's natural shape. The padded collar and tongue provide
                                    additional comfort, while the secure lacing system ensures a
                                    snug fit that reduces slippage and blisters during extended
                                    wear.
                                </p>

                                <h4 style="margin-top: 16px; margin-bottom: 8px; color: #333">
                                    Performance Metrics
                                </h4>
                                <p>Testing by professional athletes has shown:</p>
                                <ul style="margin-left: 20px; margin-top: 8px">
                                    <li>15% improvement in running efficiency</li>
                                    <li>25% reduction in impact-related fatigue</li>
                                    <li>40% increase in comfort rating vs. competitors</li>
                                    <li>99% durability over 300+ miles of running</li>
                                </ul>

                                <h4 style="margin-top: 16px; margin-bottom: 8px; color: #333">
                                    Care Instructions
                                </h4>
                                <p>
                                    To maintain optimal performance, hand wash with mild soap and
                                    cool water. Air dry naturally away from direct heat sources.
                                    Regular cleaning helps preserve the breathable mesh and extends
                                    the lifespan of your shoes.
                                </p>
                            </div>
                        </details>

                        <!-- 另一个可展开的动态内容 -->
                        <details
                            style="
                margin-top: 20px;
                padding: 16px;
                background: #f9f9f9;
                border-radius: 8px;
                cursor: pointer;
              "
                        >
                            <summary style="font-weight: bold; font-size: 16px; user-select: none">
                                ⭐ Customer Reviews
                            </summary>
                            <div style="margin-top: 16px">
                                <div
                                    style="
                    margin-bottom: 16px;
                    padding: 12px;
                    background: white;
                    border-left: 4px solid #ffc107;
                    border-radius: 4px;
                  "
                                >
                                    <p style="font-weight: bold; margin-bottom: 4px">
                                        John D. ⭐⭐⭐⭐⭐
                                    </p>
                                    <p>
                                        "Best shoes I've ever owned! The comfort is incredible, and
                                        they last forever. Highly recommend for anyone serious about
                                        running."
                                    </p>
                                </div>
                                <div
                                    style="
                    margin-bottom: 16px;
                    padding: 12px;
                    background: white;
                    border-left: 4px solid #ffc107;
                    border-radius: 4px;
                  "
                                >
                                    <p style="font-weight: bold; margin-bottom: 4px">
                                        Sarah M. ⭐⭐⭐⭐⭐
                                    </p>
                                    <p>
                                        "I've tried many brands, but these are my favorite. The
                                        support and cushioning are perfect. My feet feel amazing
                                        after long runs."
                                    </p>
                                </div>
                                <div
                                    style="
                    margin-bottom: 16px;
                    padding: 12px;
                    background: white;
                    border-left: 4px solid #ffc107;
                    border-radius: 4px;
                  "
                                >
                                    <p style="font-weight: bold; margin-bottom: 4px">
                                        Mike T. ⭐⭐⭐⭐
                                    </p>
                                    <p>
                                        "Great shoes! True to size, very comfortable. Only minor
                                        issue with sizing guide, but overall fantastic product."
                                    </p>
                                </div>
                                <div
                                    style="
                    margin-bottom: 16px;
                    padding: 12px;
                    background: white;
                    border-left: 4px solid #ffc107;
                    border-radius: 4px;
                  "
                                >
                                    <p style="font-weight: bold; margin-bottom: 4px">
                                        Emma L. ⭐⭐⭐⭐⭐
                                    </p>
                                    <p>
                                        "Perfect fit, amazing comfort level. These shoes transformed
                                        my running experience. Will definitely buy again!"
                                    </p>
                                </div>
                            </div>
                        </details>
                    </div>
                </div>
            </div>
        </sticky-sidebar>

        <!-- 👇 新增:推荐商品区域(让页面更长,并展示 sticky 自然结束) -->
        <div class="recommended-products">
            <h2>You May Also Like</h2>
            <div class="product-grid">
                <div class="product-card">
                    <img
                        src="https://via.placeholder.com/300x200/e0e0e0/000000?text=Trail+Shoe"
                        alt="Trail Shoe"
                    />
                    <div class="info">
                        <h3>Trail Running Shoe</h3>
                        <div class="price">$119.99</div>
                    </div>
                </div>
                <div class="product-card">
                    <img
                        src="https://via.placeholder.com/300x200/e0e0e0/000000?text=Gym+Shoe"
                        alt="Gym Shoe"
                    />
                    <div class="info">
                        <h3>Gym Training Shoe</h3>
                        <div class="price">$99.99</div>
                    </div>
                </div>
                <div class="product-card">
                    <img
                        src="https://via.placeholder.com/300x200/e0e0e0/000000?text=Running+Socks"
                        alt="Socks"
                    />
                    <div class="info">
                        <h3>Performance Socks (3-Pack)</h3>
                        <div class="price">$19.99</div>
                    </div>
                </div>
                <div class="product-card">
                    <img
                        src="https://via.placeholder.com/300x200/e0e0e0/000000?text=Water+Bottle"
                        alt="Bottle"
                    />
                    <div class="info">
                        <h3>Insulated Water Bottle</h3>
                        <div class="price">$29.99</div>
                    </div>
                </div>
                <div class="product-card">
                    <img
                        src="https://via.placeholder.com/300x200/e0e0e0/000000?text=Trail+Shoe"
                        alt="Trail Shoe"
                    />
                    <div class="info">
                        <h3>Trail Running Shoe</h3>
                        <div class="price">$119.99</div>
                    </div>
                </div>
                <div class="product-card">
                    <img
                        src="https://via.placeholder.com/300x200/e0e0e0/000000?text=Gym+Shoe"
                        alt="Gym Shoe"
                    />
                    <div class="info">
                        <h3>Gym Training Shoe</h3>
                        <div class="price">$99.99</div>
                    </div>
                </div>
                <div class="product-card">
                    <img
                        src="https://via.placeholder.com/300x200/e0e0e0/000000?text=Running+Socks"
                        alt="Socks"
                    />
                    <div class="info">
                        <h3>Performance Socks (3-Pack)</h3>
                        <div class="price">$19.99</div>
                    </div>
                </div>
                <div class="product-card">
                    <img
                        src="https://via.placeholder.com/300x200/e0e0e0/000000?text=Water+Bottle"
                        alt="Bottle"
                    />
                    <div class="info">
                        <h3>Insulated Water Bottle</h3>
                        <div class="price">$29.99</div>
                    </div>
                </div>
            </div>
        </div>

        <div class="scroll-hint">
            ✅ 尝试以下操作来观察 ResizeObserver 的实时效果:<br />
            1️⃣ 展开左侧 "More Images" → 左侧变高<br />
            2️⃣ 展开右侧 "Detailed Features & Benefits" → 右侧变高<br />
            3️⃣ 观察 sticky 策略是否动态调整(哪一侧保持固定)<br />
            4️⃣ 滚动到底部,观察 sticky 在容器结束时自然结束(不会穿透父容器)
        </div>

        <script>
            (function () {
                class StickySidebar extends HTMLElement {
                    constructor() {
                        super();
                        this.leftSide = null;
                        this.rightSide = null;
                        this.stickyOffset = 20;
                        this.resizeObserver = null;
                        this.isDesktop = window.innerWidth > 989;
                    }

                    connectedCallback() {
                        this.leftSide = this.querySelector('[data-sidebar-side="left"]');
                        this.rightSide = this.querySelector('[data-sidebar-side="right"]');
                        this.stickyOffset = parseInt(this.dataset.stickyOffset) || 20;

                        this.setupStickyBehavior();
                        this.setupResizeObserver();

                        window.addEventListener('resize', () => {
                            const wasDesktop = this.isDesktop;
                            this.isDesktop = window.innerWidth > 989;
                            if (wasDesktop !== this.isDesktop) {
                                this.setupStickyBehavior();
                            }
                        });
                    }

                    setupResizeObserver() {
                        if (!window.ResizeObserver) return;

                        this.resizeObserver = new ResizeObserver(() => {
                            if (this.isDesktop) {
                                setTimeout(() => this.setupStickyBehavior(), 50);
                            }
                        });

                        this.resizeObserver.observe(this.leftSide);
                        this.resizeObserver.observe(this.rightSide);
                    }

                    setupStickyBehavior() {
                        if (!this.isDesktop) {
                            this.leftSide.classList.remove('sticky-sidebar__sticky');
                            this.rightSide.classList.remove('sticky-sidebar__sticky');
                            return;
                        }

                        const leftHeight = this.leftSide.offsetHeight;
                        const rightHeight = this.rightSide.offsetHeight;

                        this.leftSide.classList.remove('sticky-sidebar__sticky');
                        this.rightSide.classList.remove('sticky-sidebar__sticky');

                        if (leftHeight < rightHeight) {
                            this.leftSide.classList.add('sticky-sidebar__sticky');
                            this.leftSide.style.top = this.stickyOffset + 'px';
                        } else if (rightHeight < leftHeight) {
                            this.rightSide.classList.add('sticky-sidebar__sticky');
                            this.rightSide.style.top = this.stickyOffset + 'px';
                        }
                    }
                }

                customElements.define('sticky-sidebar', StickySidebar);
            })();
        </script>
    </body>
</html>

1. 两列布局:Grid 实现 + flex/grid 布局的关键坑点

示例中采用 CSS Grid 实现两列布局,核心容器样式:

.sticky-sidebar_container {
    display: grid;
    grid-template-columns: 1fr 1fr; /*两列等分 */
    gap: 40px;
    align-items: flex-start; /* 重中之重 */
    max-width: 1200px;
    margin: 0 auto;
}

确保主内容区和侧边栏水平并排,且有合理间距。

为什么必须加 align-items: flex-start?

如果省略align-items: flex-start,会发生两个问题:

  1. 子元素被强制拉伸,即使内容本身很短,也会和另一列(长内容列)等高;
  2. sticky元素的 “父容器高度” 被撑满,粘性效果失去意义(元素本身已经占满父容器,已经没有在父容器的滚动空间了,滚动时不会触发 fixed)。

补充:如果用 Flex 实现两列布局,同样需要注意

/*Flex布局示例 */
.flex-container {
    display: flex;
    gap: 40px;
    align-items: flex-start; /* 必须加,否则sticky失效 */
}
.flex-container .col {
    flex: 1;
}

2. 进阶:ResizeObserver 监测动态高度,让sticky“智能切换”

示例中侧边栏包含可展开的details组件(如“更多图片”“详细特性”),展开/收起时列的高度会动态变化。如果仅靠初始高度判断哪一列加sticky,交互体验会割裂——因此需要ResizeObserver监测高度变化,动态调整粘性元素。

ResizeObserver 是什么?

ResizeObserver是浏览器原生API,用于监测元素的尺寸(宽/高)变化,触发回调函数。相比传统的window.resize(仅监测窗口变化),它能精准感知元素自身的尺寸变化,是处理动态内容的利器。

示例中的实现逻辑

  1. 初始化监测:连接DOM后,监听左右两列的尺寸变化
setupResizeObserver() {
  if (!window.ResizeObserver) return;

  this.resizeObserver = new ResizeObserver(() => {
    if (this.isDesktop) {
      // 延迟执行,确保DOM尺寸已更新
      setTimeout(() => this.setupStickyBehavior(), 50);
    }
  });

  // 监听左右两列的尺寸变化
  this.resizeObserver.observe(this.leftSide);
  this.resizeObserver.observe(this.rightSide);
}
  1. 动态调整粘性规则:对比两列高度,仅给“较短的列”添加sticky类
setupStickyBehavior() {
  if (!this.isDesktop) {
    // 移动端取消sticky,回归静态布局
    this.leftSide.classList.remove("sticky-sidebar__sticky");
    this.rightSide.classList.remove("sticky-sidebar__sticky");
    return;
  }

  // 获取当前两列的实际高度
  const leftHeight = this.leftSide.offsetHeight;
  const rightHeight = this.rightSide.offsetHeight;

  // 先清空所有sticky类
  this.leftSide.classList.remove("sticky-sidebar__sticky");
  this.rightSide.classList.remove("sticky-sidebar__sticky");

  // 智能判断:短的一列添加sticky
  if (leftHeight < rightHeight) {
    this.leftSide.classList.add("sticky-sidebar__sticky");
    this.leftSide.style.top = this.stickyOffset + "px";
  } else if (rightHeight < leftHeight) {
    this.rightSide.classList.add("sticky-sidebar__sticky");
    this.rightSide.style.top = this.stickyOffset + "px";
  }
}
  1. 响应式兼容:窗口尺寸变化时,同步更新布局逻辑
window.addEventListener('resize', () => {
    const wasDesktop = this.isDesktop;
    this.isDesktop = window.innerWidth > 989;
    // 仅当从桌面端/移动端切换时,重新设置sticky
    if (wasDesktop !== this.isDesktop) {
        this.setupStickyBehavior();
    }
});

3. 细节优化,完善交互体验

  1. 移动端降级:屏幕宽度<989px时,强制取消sticky(position: static !important),避免小屏上的布局错乱;
  2. z-index 层级:给sticky元素加z-index: 10,防止被其他内容遮挡;

4. <sticky-sidebar> Web Component 元素

<sticky-sidebar>是一个基于 Web Component 技术实现的自定义元素。Web Component 是浏览器原生支持的标准组件技术,相比 React、Vue 等框架组件具有跨框架兼容的优势,可以在不同技术栈之间直接复用。

其中,connectedCallback 方法相当于 React 中的 useEffect 钩子(组件挂载时执行)。

你可以根据具体的业务需求进一步扩展功能(例如自定义 sticky 触发阈值、适配多列布局等)。

总结

实现一个“健壮的粘性侧边栏”,需要兼顾三层:

  1. 基础层:掌握position: sticky的特性和边界;
  2. 布局层:Grid/Flex布局中,务必设置align-items: flex-start,避免子元素拉伸导致sticky失效;
  3. 动态层:用ResizeObserver监测元素高度变化,让sticky策略随内容动态调整。

这套思路不仅适用于电商商品页,也可迁移到博客侧边栏、后台管理系统等场景。核心是理解“sticky的生效条件”和“布局对定位的影响”,再结合原生API解决动态内容的适配问题。

position: sticky 是一个优雅而强大的 CSS 特性,它用极简的代码解决了复杂的滚动交互问题。理解其“相对+固定+受限”的三重特性,是用好它的关键。

注意事项 & 性能建议

  1. 父容器需有滚动上下文
    position: sticky 是否可见,取决于父容器是否有足够内容使其在滚动中“经过”视口。如果整个页面不可滚动,或父容器内无其他内容,sticky 行为将无法被触发——并非失效,而是缺乏滚动场景。

  2. 警惕 overflow 限制 sticky 范围
    避免在 sticky 元素与 body 之间意外插入 overflow: hidden/scroll/auto 的祖先元素,否则 sticky 的粘附范围会被限制在该容器内,可能不符合预期。

  3. 移动端兼容性良好
    现代浏览器(包括 iOS Safari 和 Android Chrome)均完整支持 position: sticky,可安全用于生产环境。

  4. 避免过度使用
    虽然 sticky 性能开销较小,但大量或嵌套使用可能引发布局抖动,尤其在低端设备上。保持简洁,只在必要处使用。


📚 参考资料

学习优秀作品,是提升技术的最佳路径。本文作为自己的学习笔记,也希望这篇解析对你有所帮助

这 10 个 Vue3 性能优化技巧很实用,但很多项目都没用上

今天来分享 10 个 Vue3 的性能优化技巧。

核心原则
减少不必要的响应式追踪
避免无谓的 DOM 操作
按需加载资源

咱也不要为了优化而优化!小项目用默认写法完全没问题,优化应在性能瓶颈出现后进行。

这些技巧不难,但都非常关键。 看完你会发现:原来 Vue3 还能这么写。


1. 使用 shallowReactive 替代 reactive

问题
reactive 会让对象里每一层都变得“敏感”——哪怕你只改了最里面的某个小字段,Vue 也会花力气去追踪它。数据一大,性能就变慢。

解决方案
对不需要深层响应的数据,使用 shallowReactive,只让最外层变成响应式的。

示例

import { shallowReactive } from 'vue';

const data = shallowReactive({
  list: [],
  meta: { total: 0 }
});

适用场景
当你从后端拿到一大坨只读数据(比如表格列表、API 响应),且不会修改嵌套属性时。


2. 用 toRefs 解构响应式对象

问题
如果你直接从 reactive 对象里解构变量(如 const { name } = state),这个 name 就变成普通变量了,修改它不会触发页面更新。

解决方案
使用 toRefs 解构,保持每个属性的响应性。

示例

const state = reactive({ name: 'Vue', age: 3 });
const { name, age } = toRefs(state); // name 和 age 依然是响应式的!

好处
在模板中可以直接写 {{ name }},不用写 {{ state.name }},代码更清爽。


3. 优先使用 watchEffect 而非 watch

区别

  • watch:你要手动指定监听谁(比如 watch(count, ...))。
  • watchEffect:你只写逻辑,Vue 自动分析里面用了哪些响应式变量,并监听它们。

示例

watchEffect(() => {
  // Vue 自动发现 count.value 被用了 → 只要 count 变,这段就执行
  localStorage.setItem('count', count.value);
});

适合场景
保存用户输入到本地缓存、根据筛选条件自动请求数据、同步状态到 URL 等。


4. 利用 <Suspense> 优雅处理异步组件

问题
动态加载组件(如通过 import())时,页面可能白屏几秒,用户体验差。

解决方案
<Suspense> 包裹异步组件,显示 loading 提示。

示例

<Suspense>
  <template #default>
    <UserProfile /> <!-- 必须是异步组件 -->
  </template>
  <template #fallback>
    <div>加载中,请稍候…</div>
  </template>
</Suspense>

注意
仅适用于异步组件(即用 defineAsyncComponent() => import(...) 定义的组件)。


5. 使用 <Teleport> 解决模态框层级问题

问题
弹窗写在组件内部,可能被父级的 overflow: hiddenz-index 限制,导致显示不全或盖不住其他内容。

解决方案
<Teleport> 把组件“传送”到 <body> 底部,脱离当前 DOM 树。

示例

<Teleport to="body">
  <Modal v-if="show" />
</Teleport>

类比
就像你在客厅写了个气球,但它实际飘到了天空——不受房间天花板限制。

常用目标to="body" 是最常见用法。


6. 自定义指令封装高频操作(如复制)

问题
复制文本、防抖点击、自动聚焦……这些功能到处都要用,每次都写一堆代码很麻烦。

解决方案
写一个自定义指令,一次定义,处处使用。

示例

app.directive('copy', {
  mounted(el, binding) {
    el.addEventListener('click', () => {
      navigator.clipboard.writeText(binding.value);
    });
  }
});

使用

<button v-copy="'要复制的内容'">点我复制</button>

好处:逻辑集中、复用性强、模板干净。


7. 用 Pinia 插件扩展 store 能力

问题
每个 store 都想加个“重置”功能?手动一个个写太重复。

解决方案
通过 Pinia 插件,一次性给所有 store 添加 $reset() 方法。

正确实现

pinia.use(({ store }) => {
  // 保存初始状态快照(深拷贝)
  const initialState = JSON.parse(JSON.stringify(store.$state));
  store.$reset = () => {
    store.$state = initialState;
  };
});

使用

const userStore = useUserStore();
userStore.$reset(); // 恢复初始状态

适用场景:表单重置、清除缓存、统一日志等。

注意:不能直接用 store.$patch(store.$state),因为 $state 是当前状态,不是初始状态!


8. v-memo 优化大型列表渲染

问题
列表有上千项,哪怕只改了一行的状态,Vue 默认会重新比对整张表,浪费性能。

解决方案
v-memo 告诉 Vue:“只有这些值变了,才需要重新渲染这一行”。

示例

<li v-for="item in list" :key="item.id" v-memo="[item.id, item.status]">
  {{ item.name }} —— 状态:{{ item.status }}
</li>

注意事项

  • 适合内容稳定、更新频率低的大列表。
  • 不要和 <transition-group> 一起用(会失效)。
  • 高频变动的列表慎用,可能适得其反。

v-memo 是 Vue 3.2+ 的功能。


9. 虚拟滚动(Virtual Scrolling)

问题
渲染 10,000 条消息?浏览器直接卡死!

解决方案
只渲染“当前可见区域”的内容,滑动时动态替换,内存和性能都省下来。

推荐库(Vue 3 兼容)

安装 & 示例(以 vueuc 为例)

npm install vueuc
<script setup>
import { VirtualList } from 'vueuc';
</script>

<template>
  <VirtualList :items="messages" :item-height="60" :bench="10">
    <template #default="{ item }">
      <MessageItem :msg="item" />
    </template>
  </VirtualList>
</template>

类比
就像微信聊天记录——你往上滑,旧消息才加载;不滑的时候,几千条其实没真画出来。


10. 路由与组件懒加载 + 图片优化

组件懒加载

原理:不是一打开网页就加载所有页面,而是“用到哪个才加载哪个”。

写法

{ path: '/about', component: () => import('./views/About.vue') }

好处:首屏加载更快,节省流量和内存。

图片优化

  • 用 WebP 格式:比 JPG/PNG 小 30%~50%,清晰度不变(现代浏览器都支持)。
  • 图片懒加载:屏幕外的图先不加载,滑到附近再加载。
  • 关键图预加载:首页 Banner 图提前加载,避免白块。

简单懒加载(原生支持)

<img src="image.jpg" loading="lazy" alt="示例图" />

兼容性提示loading="lazy" 在 Chrome/Firefox/Edge 支持良好,但 Safari 15.4 以下和 IE 不支持。若需兼容旧环境,建议搭配 IntersectionObserver 或第三方库(如 lazysizes)。


总结

技巧 解决什么问题 关键词
shallowReactive 大对象响应式开销大 浅响应
toRefs 解构丢失响应性 保持链接
watchEffect 手动监听麻烦 自动追踪
<Suspense> 异步组件白屏 加载提示
<Teleport> 弹窗被遮挡 脱离 DOM
自定义指令 重复逻辑多 一键复用
Pinia 插件 store 功能重复 全局增强
v-memo 大列表重渲染 按需更新
虚拟滚动 上万条卡顿 只渲染可见
懒加载 + 图片优化 首屏慢、流量大 按需加载

先写出清晰可维护的代码,再根据实际性能问题选择合适的优化手段!

本文首发于公众号:程序员大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!

太猛了,我用“千问AI”帮我点了一杯混果汁外卖

引言:AI 从“纸上谈兵”到“真能办事”的跨越

当我们还在吐槽 AI 只会“纸上谈兵”(chat对话)、停留在对话层面时,千问 App 的这次更新直接改写了人机交互的规则。1 月 15 日,阿里正式宣布千问全面接入淘宝闪购、支付宝、飞猪、高德等核心生态,一次性上线多项新功能,从点外卖、订机票到办政务、做报告,真正实现“一句话搞定所有事”。

这绝非单纯的功能升级,我猜测或许也是受豆包手机或 GEO 兴起等竞品刺激后,阿里加速生态与 AI 融合的关键动作,本质上是阿里沉淀多年的生态资源与 AI 能力的深度绑定,既是 AI 从“能说”到“能干”的跨越,更是极大方便人的效率、真正做到 AI 改变生活的生动实践。

一、功能落地:一个 App 搞定全场景真实任务

跟 DeepSeek、豆包、Kimi、Gemini、ChatGPT,等等相比,千问的核心优势,是能落地在实际的使用场景里。从日常琐事到专业需求,无需切换多个 App一句话就能触发全流程服务,彻底打破虚拟对话与现实服务的壁垒,成为真正的超级个人生活助理。

1.日常生活极大改变

订餐厅:用户直接在千问里面说“周六晚 6 点订 6 人川菜馆包厢,不爱喝酒,希望有宝宝餐”,千问会快速匹配附近高评分商家,核验营业时间与包厢可用性还有分析适不适合小孩,自动拨打电话完成预订(我看到官方视频里面有通话的过程,我试了但没试出来),最后生成带导航、订按钮的卡片,全程不到 2 分钟,告别传统操作的反复切换麻烦。虽然我没定成功,但至少他能通过我的位置,自动帮我找最近的川菜店

交水电费:一句“交本月电费”,千问直接调用支付宝生活缴费接口,生成含金额、缴费历史的卡片,点击确认即可支付,不跳转 App,精准直达核心需求。

定外卖:直接输入,我想点一杯混果汁,就帮我自动下单,支付,然后外卖真的送到了我家门口

地图导航:输入我想去宝安机场,帮我订个位置,就会返回地图

2. 专业领域:“行业助手”

除了上面日常化的提效,千问还支持专业的领域知识,具体不展开,可以自己体验

财务分析:通过官方视频能看到可以梳理各种发票

医疗咨询:我猜测是依托蚂蚁阿福 

商业报告:梳理业务数据汇报,用网页呈现

知识问答:各种问答和数学知识题库

二、时代变革:AI 超级入口的颠覆性价值

千问的突破,不仅重构了 AI 助手的体验,更推动数字生活入口迎来变革——从“多 App 跳转”到“一个入口搞定”,背后是技术与生态的深度融合,重塑了交互、商业与生态格局。

1. 交互革命:语言成为新的 "操作系统"

千问让“说句话就能办事”成为可能——用户无需点击图标、输入关键词,只需一句话,就能被精准理解并调用对应服务。比如“帮我订明天北京的机票 + 推荐酒店 + 规划行程”,会同时触发飞猪、高德、支付宝的多项操作,后台自动协调,用户只需接收连贯结果。

这种变革就像从“功能机物理按键”到“智能机触屏交互”的跨越,未来我们或许无需安装几十个App,千问一个入口就能连接全场景服务,从日常琐事到工作需求都能覆盖,核心是阿里生态的全面支撑与高效的需求拆解能力

前端的角度来看,超级入口这种大话其实还早,但从我们操作的图片来看,其实也能看出,前端交互从原来的后端返回什么,前端就渲染什么的时代,变成了后端返回什么,前端就动态渲染什么的时代,这也会大大减少前端工程师的岗位数量。

再从前端另一个角度来说,未来前端可能不再是开发 APP,网页H5,而是基于AI Chat页面开发各种富文本内容,插件,工具。

2. 商业重构:从 SEO 到 GEO,技术定义新流量逻辑

千问其实也在改写商业流量规则。过去商家靠 "关键词竞价" 获取曝光(SEO 逻辑),而现在千问通过精准的需求匹配,实现 "人货最优解匹配"(GEO 逻辑)

想象一下,你去到一个陌生的城市旅游,这时候你逛口渴,拿出手机,对着千问说了一句,我口渴了,哪里有好喝,理想情况下这时候千问就会根据你的口味,还有当前的地理位置,给你推荐奶茶店或者果汁店(但这里就可能会被 AI 劫持,就是 AI 生成的推荐,是商家花钱之后,付费到了推荐榜第一),但也可能AI会识别到底是广告还是真的好喝,然后 AI屏蔽掉不真实的欺骗商家。

3. 生态壁垒:技术 + 数据的 "双重护城河"

全球科技巨头都在争夺 AI 落地赛道,但千问的优势在于 "技术架构 + 生态数据" 的不可复制性:

为什么说千问说不可复制的,一句话来说就是,阿里的 AI + 阿里的生态,都是国内顶级的。我列举几个我常用的阿里生态APP、高德、飞猪、蚂蚁阿福、支付宝、饿了么、淘宝,共享单车。

所以 AI + 本地化就实现了闭环,所以说字节家只有豆包和抖音,在生态方面你拿什么跟千问比?毕竟阿里做本地化做了十几年了。

当其他 AI 还在为 "跨平台授权"" 数据隐私 "发愁时,千问已经通过" 原生技术 + 原生数据 "的协同,实现了" 体验闭环 "—— 这正是千问能成为" 全球首个多品类 AI 购物入口 " 的根本原因,也是未来 AI 连接真实世界的核心竞争力。

三、核心支撑:千问连接“虚实”的技术原理

千问能无缝打通虚拟指令与现实服务,核心不在于复杂的底层模型,而在于“工具内嵌+可视化渲染+生态授权”的实用技术逻辑,本质上还是阿里生态的资源积淀,叠加 AI 技术的赋能,既保证AI可落地、可交互,又能解决多平台协同的痛点,最终实现效率跃升。

下面仅为个人分析,如果有误,请见谅

1. 工具内置:实现“对话即操作”的无缝体验

告别繁琐的App跳转,将阿里生态内的订票、办政务、缴费等服务模块化为标准化工具(我个人的理解,都是 tools),直接内嵌于对话中。用户只需对话,即可完成操作,形成从指令到服务的自然闭环,从根本上解决多应用切换的痛点。

2. 可视化渲染:让服务结果清晰可感

通过强大的内容渲染能力,将指令结果转化为直观的可视化界面。可自动生成带交互按钮的信息卡片、地图导航、参数对比表格乃至简易内嵌网页,使服务结果一目了然。例如,规划行程时直接展示标注好路线的地图,推荐商品时生成含详情与价格的对比卡片,大幅提升交互效率和体验。

3. 生态授权:避免多账号登陆

依托阿里统一账号体系,淘宝账号就能同时登陆阿里体系内的所有 APP。例如规划旅游调用飞猪出行数据,呈现机票价格,能渲染高德地图,规划路线,办理政务对接相关接口。想象一下,未来下面的功能,都能在千问实现,甚至千问能直接购买支付宝基金,买房,彻底替代 APP。

四、总结:AI从“能说”到“能干”的里程碑

一句话总结:千问成功把AI 从虚拟世界跟真实世界的人连接了起来,这种感觉就像当年乔布斯发布iPhone开启触屏时代。千问成功打通虚拟与真实世界服务,我认为人机交互正经历新的一次革命。它以阿里生态为根基、以AI为引擎,既成为老人或者普通人也能轻松使用的超级生活助理,又开辟了GEO营销的新赛道,更用实打实的效率提升证明:AI不是空谈,而是真正改变生活、赋能日常的核心力量。随着阿里抢占先机,很期待其他家如何跟上,也许GEO会带动新一轮的广告革命,也许随着日志的收集,AI进化的更加先进,也许...也许....也许....

❌