阅读视图

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

天地图Circle扩散动画

地理信息系统GIS,不免需要一些动画在地图上面展示;比方说SOS求救、事故点位置、救援点等;

在这里插入图片描述

核心代码
 function handleCircleSpreadAnimation(circle) {
    // 动画步数(可以根据需要调整)
    var steps = 25;
    var radius = 20;
    var currentStep = 0;
    var total = 10;
    var animationId;
    function animate() {
        if (currentStep < steps) {
            var progress = currentStep / steps;
            var newRadius = 5000 + (5000 - radius) * progress;
            circle.setRadius(newRadius);
            currentStep++;
            animationId = requestAnimationFrame(animate);
        } else {
            if (total === 0) {
                animate = null;
                animationId = null;
                circle.setRadius(5000);
                return;
            }
            total--
            currentStep = 0;
            animate();
        }
    }
    animate();
}
如何使用
// 创建circle
var circle =new T.Circle(new T.LngLat(116.40093, 39.90313), 5000,{color:"#C90000",weight:2,opacity:0.5,fillColor:"#FB5812",fillOpacity:0.5,lineStyle:"solid"});
// 向地图上添加circle
map.addOverLay(circle);
// 执行动画
handleCircleSpreadAnimation(circle);

【技术闲谈】解锁WebWorker:选对场景才能释放真性能

作者:羊库库

引言

当我们在前端开发中遇到复杂计算导致页面卡顿时,“用 Web Worker 啊!”常常会成为解决方案。然而,这个看似简单的技术背后却隐藏着深刻的性能优化逻辑。今天我们就来深入聊聊 Web Worker —— 它从何而来?解决了哪些痛处?如何在各大三维引擎中大显身手?以及,它真的是万能的“性能加速器”吗?

什么是Web Worker?

早期浏览器采用单线程模型处理所有任务:渲染页面、执行 JavaScript、处理用户交互。如同一条拥挤的单行道,一旦某个任务(如大量计算)占用过长时间,后续所有操作都会被阻塞。

Javascript中的单线程,来源:网络

如上图所示,渲染进程的主线程,是一帧一帧的绘制的。大多数情况下,我们的刷新率都是60fps,也就是说每帧执行间隔为17ms。

我们前端的代码:HTML/CSS/JS,最终都在这一个线程中解析执行,因此我们的代码不阻塞进程就变得尤为重要。当然浏览器将这些细节隐藏的很好,我们大多数情况不需要关心渲染帧的细节,但如果遇到渲染慢或需要极致优化渲染性能的场景,需要长耗时任务执行时,页面就会无法及时更新,用户会明显感知到界面“卡死”,如果“卡死”太久,浏览器还会抛出无响应提示。

Chrome无反应之后卡死

2009 年,HTML5 标准引入了 Web Worker API,正式为浏览器引入多线程能力。它允许主线程(UI 线程)创建运行在后台的独立 Worker 线程。关键突破在于:Worker 与主线程并行执行,且 Worker 中不能直接操作 DOM。这种设计带来了两全其美的效果:

  • 主线程解放了:耗时任务被迁移到 Worker,UI 始终保持响应

  • 安全性保证了:避免多个线程同时操控 DOM 导致混乱

随着 CPU 多核普及,Web Worker 真正实现了利用多核硬件资源并行处理任务,这对于处理海量数据计算、复杂图像操作、物理模拟等场景意义重大。

可以直观地联想:并行可能会 提升执行效率;运行任务拆分能减少页面卡顿。

Web Worker的多线程模型,来源:p1-jj.byteimg.com/

JavaScript 的单线程模型通过 Event Loop 实现 并发 (Concurrent):虽然只有一个函数调用栈,但能处理多个任务。运行时利用 BOM API 将耗时操作(如 I/O)委托给其他线程,当这些操作完成时,其回调函数会被放入队列,最终仍由主线程串行执行。

Web Worker 则实现了真正的并行 (Parallel):它创建了独立的线程,每个 Worker 拥有自己的函数调用栈和运行时环境。这些线程能同时执行代码,互不阻塞主线程或其他 Worker。

Web Worker的并行模型,来源:p1-jj.byteimg.com/

实战较量:主线程 vs. Web Worker,性能差多少?

光有理论还不够,我们用一个典型场景实测 —— 大规模图像数据处理(高斯模糊算法)。分别在主线程和 Web Worker 中执行,记录耗时与 UI 响应情况:

场景:

  • 连续处理 50 张1920 x 1080 像素图片(约 207 万像素点)

  • 应用复杂高斯模糊计算(每个像素需周围 49 个点参与运算)

代码关键片段 (主线程):

// 阻塞版:直接在主线程计算模糊
function applyBlur(imageData) {
  const start = performance.now();
  // ... 密集计算,遍历每个像素及周边 ...
  const end = performance.now();
  console.log(`耗时:${(end - start).toFixed(2)}ms`); 
  return blurredImageData;
}
// 调用后页面卡死3秒,滚动/点击无响应

代码关键片段 (Web Worker版):

// 主线程发起
const worker = new Worker('blur-worker.js');
worker.postMessage(imageData);
worker.onmessage = (e) => {
  const blurredData = e.data;
  // 更新图像
  console.log(`耗时:${e.data.timeTaken}ms`);
};

// 多Worker并发池参数
const MAX_WORKERS = 6; // 指定6个worker并发

// blur-worker.js 文件
self.onmessage = (e) => {
  const imageData = e.data;
  const start = performance.now();
  // ... 同样密集计算 ...
  const end = performance.now();
  self.postMessage({ 
    blurredData, 
    timeTaken: (end - start).toFixed(2) 
  });
};

笔者在测试主线程和Web Worker两者性能差异时,同时加入了用户操作按钮,以此来模拟计算带来的UI卡顿、冻结等负体验现象。

实际测试可以发现,主线程处理高斯模糊任务消耗时间长,用户操作卡顿,而Web Worker则能并发批次的对图片进行处理,有效降低处理时间的同时,也不会对主线程造成卡顿,用户点击按钮的反馈依旧流畅丝滑。

主线程处理高斯模糊任务(10X加速录屏)

Web Worker处理高斯模糊任务(10X加速录屏)

实测结果对比 (在 3070显卡笔记本 上):

指标

主线程

Web Worker

改进幅度

总计算耗时

约 70.97s

约 12.7s

降低 81%

UI 卡顿时间

全程 2.95 秒

0ms

彻底消除

用户感知

页面卡顿

界面始终可操作

体验质变

结论:Web Worker 的核心价值场景:

  1. CPU密集型运算:图像/视频处理、复杂数学建模(物理引擎)、加密解密、大数据分析(排序/过滤海量数据集)。

  2. 需要持久后台运行的任务:实时数据同步(如 WebSocket 连接维护)、日志记录、心跳检测。

  3. 避免阻塞关键交互的场景:长列表复杂渲染(如虚拟列表中的项计算)、需要即时响应的拖拽/动画。

  4. 分割子任务并行化:可将一个大任务拆分,由多个 Worker 并发执行(如使用 Comlink 或 workerpool 库)。

核心解决问题:将阻塞 UI 的耗时任务剥离主线程,利用多核 CPU 并行计算,带来界面响应速度和计算效率的双重提升。

Web Worker 如何驱动Mapmost?

矢量瓦片的解析与处理

想象一下,你看到的地图上那些丰富多彩的图层:道路网、河流、建筑轮廓、公园绿地、标注名称... 这些都不是凭空一次性渲染出来的,而是由无数片小小的 矢量瓦片 像马赛克一样拼接覆盖而成。

Mapmost中,矢量图层其实是由一片片规则的矢量瓦片构成

这些矢量瓦片种类繁多,构成地图的基础:点(POI点、车站)、线(道路、河流、边界)、面(湖泊、建筑物、绿地)。为了让地图看起来清晰、流畅、无闪烁,并且能支持丰富的交互(如点击显示信息、样式切换),有一系列复杂的幕后处理必须在它们被画到屏幕上之前完成,比如矢量瓦片专门格式的解析、属性样式的匹配、几何要素的简化、要素跨瓦片的边缘拼接等处理。

矢量瓦片要素种类繁多,如点、线、面

对于复杂要素或密集区域来说,其计算量足以让浏览器的 JavaScript 主线程“卡顿”一段时间。想象一下,当地图快速平移或缩放时,一大片新瓦片同时到达,如果主线程亲力亲为去挨个解析、化简、拼接它们,浏览器的 UI(用户界面)就会失去响应——地图变得迟钝,按钮点不动,滚动条僵硬。这就是传说中的“阻塞主线程”,用户体验会非常糟糕。

矢量瓦片的解析,来源:daniel_819

为了解决这个核心性能瓶颈,Mapmost 引入了 Web Workers:

  1. 异步并行处理:当地图需要加载和解析一批新的矢量瓦片时,主线程不自己动手处理这些繁重任务,而是像一个“工头”(其实叫worker)一样,把这些原始瓦片数据分派给一个或多个 Web Worker。

  2. 独立于主线程运行:每个 Web Worker 接收到任务后,在自己的线程环境里,同时开工,互不干扰地并行工作:

  • 对分配的原始二进制瓦片数据进行解压和深入解析,将其转化为结构化的要素数据。

  • 应用地图样式定义的过滤器,筛选出当前视图需要的要素。

  • 最关键的部分来了:执行那些计算密集型操作!Web Worker 会负责执行矢量要素的几何简化算法。那条复杂的河流曲线或那个密集区域的建筑物轮廓,就在这里被巧妙地简化,只保留在屏幕上可分辨的关键节点。

  • 对要素进行几何坐标转换,并进行基础的可见性预判。

  • 在可能的情况下,进行跨瓦片的顶点处理以减少接缝感。

  • 将处理结果优化打包,转换成更利于后续 GPU 渲染的格式。

倾斜模型纹理与几何要素的解析

为了在网络上高效传输庞大的三维城市模型或精细的地形,倾斜模型广泛应用了两种关键技术:

  1. 纹理压缩 (KTX2 / Basis Universal): 高分辨率贴图(建筑外墙、地面细节)是数据大户。KTX2 容器,特别是其搭载的 Basis Universal 技术,可以将纹理压缩成极小的格式(.basis 文件集成在 .ktx2 中),且这种压缩格式能在 GPU 上被各种设备高效地解压渲染。想象一下,把一大幅油画卷成一个小轴,到目的地再快速展开。

  2. 几何压缩 (Draco): 描述建筑形状、地形的点、线、面数据(顶点坐标、法线、纹理坐标等)也非常庞大。Draco 算法能对这些几何数据进行高效的“瘦身”,显著减少网络传输量。就像用一套精密的折纸说明书代替一个笨重的实物模型。

KTX2.0 纹理压缩格式

Draco 几何压缩

“压缩一时爽,解压火葬场”

然而,无论是 Draco 对复杂几何的解码,还是 KTX2/Basis 纹理在渲染前的准备(GPU 上传或转换),都是计算密集型的 CPU 或 GPU 任务。如果让负责交互和渲染的主线程亲力亲为,恐怕用户体验会直接“冻结”。

基于上述问题,Mapmost再次利用了Web Worker提供的并发能力,但仅靠 JavaScript 本身的效率,处理 Draco 和大型 KTX/Basis 可能依然力不从心。于是,另一项关键技术 WebAssembly (WASM) 加入了战局。WASM 允许将用 C/C++/Rust 等语言编写的高性能代码编译成能在浏览器中以接近原生速度运行的二进制格式。

Mapmost利用WebWorker和WASM技术加载带有KTX 2.0压缩纹理格式的倾斜模型

真能“起飞”吗?客观看待 Web Worker 的性能增益

虽然前面的例子展现了巨大的提升,但 Web Worker 并非“一用就快”的万能钥匙。它的性能收益是有条件的,并且伴随着开销与局限:

  1. 通信成本高昂:主线程与 Worker 通过 postMessage 通信。传递大型数据会引发昂贵的拷贝开销。频繁小消息传递的序列化/反序列化也会成为瓶颈。

  2. 无法直接操作 DOM / UI 状态:这是硬性限制。Worker 中修改 UI 必须通过 postMessage 告知主线程。需要仔细划分职责边界。

  3. 启动延迟:创建新 Worker 需要加载 JS 文件并初始化上下文,有一定开销。

  4. 资源消耗:每个 Worker 占用独立的线程内存资源,需要合理管理,避免创建过多。

  5. 开发复杂度增加:多线程编程带来异步通信、状态同步、错误处理、调试困难等挑战。

🌟 最后谈谈个人的理解

Web Worker 是突破浏览器单线程桎梏、拥抱多核时代的利器。它作为前端性能优化的“重量级”策略,在图像处理、三维可视化、复杂计算等重负载场景下作用不可替代。各大三维引擎的实践也证实了其在现代 Web 应用中的核心地位。

然而,它不是仙丹妙药。通信成本、开发复杂性和启动开销意味着我们应策略性地使用它:聚焦于那些真正影响用户体验的瓶颈任务 —— 那些会让用户皱眉等待、界面“卡成PPT”的重度计算。在这些地方投入 Web Worker,往往能获得事半功倍的用户体验回报。当你在调试页面发现主线程被拖累时,请果断地尝试 Worker 的力量,让它成为提升你的应用“流畅度天花板”的得力帮手!。

Web Worker 不是万能的“金钥匙”🔑,但它是 Web 三维图形迈向“桌面级性能”的里程碑。🚀

你对 Web Worker 怎么看?欢迎加入交流群讨论!👇

添加图片注释,不超过 140 字(可选)

参考资料

一文搞懂 Web Worker(原理到实践)

zhuanlan.zhihu.com/p/451281805

medium.com/@daniel_819…

一文带你上手 AI Agent 编程

许多 agent 教程比较偏概念的科普,看完了后还是不知道怎么编程,这里以能够上手编写一个 Agent 为目标,理解 LLM 的关键概念,掌握构建 Agent 的关键技术。

LLM 基础

Referrence:

Andrej Karpathy: www.youtube.com/watch?v=7xT…

3blue1brown: www.youtube.com/watch?v=wjZ…

High level 的讲一下 LLM 的相关感性概念,目的是帮助对 agent 设计的理解。

Machine Learning Background

机器学习是一种数据驱动的学问,模型可以通过数据不断更新自己的权重,通过计算,预测出某个输出。

与一般结构化编程思维不同的是,机器学习并不指定具体的操作步骤,而是提供一个包含可调整参数的计算架构。通过大量的训练数据(在有监督学习中,这些数据包括输入和对应的输出;在无监督学习中,则只有输入),模型通过调整参数来学习输入与输出之间的关系或模式。

我们常说的参数、权重(weights) 是什么呢,比如 deepseek r1 671B,llama3.2 70B权重是训练的时候获得,决定了模型的行为。

线性模型举个简单的例子,权重是特征对应的系数(如 y = w₁x₁ + w₂x₂ + b 中的 w₁, w₂ 和偏置 b) 找到这个数据的规律就是不断去修正所有的点到这条线的偏值最小的一处,回归到均值,能代表大多数数据的趋势。

我们上面的回归模型参数很少,大模型有特别多的参数,简单理解为参数越多,他能学习到的模式越多。

但是也不是越多越好,如果数据集不足,或者模型结构不正确,或者训练时间过长,模型对某一方面的特征学习到更多的细节,可能过拟合,对当前数据集的拟合程度过深,泛化性差。大模型的训练拥有了非常多的数据,在数据多样性上非常足,越多的数据,越大的权重能力越强,这是 scaling law 的感性认知。

模型框架有不同作用的矩阵,每个矩阵有不同的参数,用于不同作用的任务,比如 Embedding, Attention 的 (Key、Query、Value、Output 层) 等,大模型训练的结果就有能代表模型权重参数的文件,比如 .ckpt 结尾的文件。

Prediction

LLM 任务是对后续的语义输出做预测,LLM 会不断反复对句子的预测 token 的过程,直到遇到 EOF token 为止,为什么以token计费?就是一次token输出就是大模型一次运行调用算力的过程。

Token

token 是大模型对单词处理的基本单位,每个 token 映射一个数字编码,token 的拆分原则遵循好的语义表达和计算效率,分词器 tokenizer 是运行分词算法的工具。

比如 “世界” 会是一个 token,分词器遇到“世界”这个词不会拆成两个 token。

相比传统编码比如 bit 或者 ascll 码就是为了计算效率而生的,utf8 编码考虑了一定的兼容性,但是对应的字符映射都是保障了字符的完整性,没有保障语义性,这是这两者的不同。

我们期望是越少的 token ,更少的 sequences 长度,高效的表达语义,上下文宝贵。

比如上面一段文本,转换为 ascll 码也会出现很多重复的模式,这种模式其实对应了一定的语义,这样在模型的token 划分上,就需要变成一个token。

单词到Token,一般是转换为一些Word、SubWord,或者汉字这种语言的单个字符,作为一个 token。

那每段话具体的表达是什么的?还是可以用这个网站tiktokenizer.vercel.app/ 来看,上面有许多模型的tokenizer。

可以试试经典的 token 问题,我理解就是分词器把berry分出来了 然后后续的预测可能更多的参考两个 r 。

"How Many R's in 'Strawberry'"

community.openai.com/t/incorrect…

Doubao1.5-vision-pro 甚至多模态(图片输入)和 COT prompt 也数不出来

Embedding

我们上一步会使用分词器把人类的单词文字划分成不同的 token,embedding 矩阵会处理不同的 token 表达为更高维度的向量。 使用向量可以在不同的维度表达不同的语义。

GPT3 的 embedding 矩阵有 50257 个 Tokens 输入。

embedding 过程,就是将 token 进行多维化表达的过程,不同的 token 在不同的语义下面,会有不同的含义,比如猫和狗在是宠物的层面上是类似的,但是在处于动物种类的情况下是两种语义。这些不同含义和近似含义会处于在多维向量空间中比较相近的位置。

比如下面两张图的内容

  1. Queue = king - man + woman ,表达的向量距离会比较接近。
  2. Cat 和 Dog、Snake 在 动物的维度中比较接近。

Transformer

感性认知 transform 架构。

Transformer的核心结构是 多头注意力层(Attention) + MLP层(Multilayer Perceptron) 交替堆叠。上面认识到 Embeding 表达了 token 的多种含义,token 在不同的句子中会有不同的表达,transformer 像人阅读一样,你会注意到不同的地方,进行整体的思考。

attention 有几个特点:

  1. 位置编码:传统模型(如RNN)像逐字阅读的读者,容易遗忘开头内容。Transformer 则通过位置编码,给每个词赋予「空间坐标」,类似荧光笔标注重点段落位置,能快速定位。
  2. 自注意力层哈利波特 出现时,你会联想到「闪电伤疤」「霍格沃茨」等元素,无论这些词相隔多少页。Transformer 的自注意力层正是这种能力的数字化实现——每个词瞬间与全文所有词建立关联权重,形成动态关系网。
  3. 多头注意力:头1:关注语法结构(主谓宾)、头2:提取情感倾向(褒义/贬义)头3:识别实体关系(人物-地点-事件)最终综合各视角的信息,形成全面理解。

Attention 机制可能会有缺点,自注意力层的多个头(如 8 头)会生成分散的特征表示。MLP 的作用类似于「会议总结人」,通过全连接层融合不同注意力视角的信息。同时将 attention 的注意力筛选,比如:句子 "The bank charged high interest rates." 中:

  • 自注意力层可能同时关联 "bank" 与 "river" 和 "finance"
  • MLP 根据上下文强化 "finance" 相关神经元的激活,抑制 "river" 信号。

感性认知:Transformer 是让预测注意到不同的领域知识和句子上下文,通过其架构设计(尤其是自注意力机制)提供了一种更有效的方式来表示、处理和理解序列数据中的复杂关系。

感性感知就到这里,真正理解相关概念需要阅读论文以及上手代码实践训练。我们从 agent 开发角度就理解到这里。

Finetuning

我们上面几步可以得到一个预训练模型,大型语言模型(LLM)微调是指在预训练模型的基础上,通过额外的训练使模型适应特定任务或领域的过程。微调通常使用较少的数据集和较小的学习率来调整模型的权重,以避免破坏预训练阶段学到的通用知识。

比如我拥有一个 chatModel,我想在这个基础上加强他对 JSON 格式的输出,除了写提示词之外,更稳定的办法是通过微调,让模型内化 JSON 格式输出。但是风险是可能影响原始模型的权重,比如把 R1 做 微调,可能会影响 R1 的思考能力,让思考能力下降。但是也可以通过微调让 Qwen2.5B 拥有 DeepseekR1 的思考能力,对其进行 GRPO 的微调。

有一个微调框架可以去看看,docs.unsloth.ai/ ,里面集成了许多微调算法。

总之,微调是一种通过影响模型权重来使得 token 预测符合结果的技术。根据权重影响的范围和方法的不同,一般有

  1. Full Fine-tuning (全参数微调)

  2. PEFT - (Parameter Efficient Fine-tuning)参数高效微调

    1. LoRA (Low Rank Adaptation)
    2. Prefix Tuning/ P Tuning
    3. QLoRA
    4. Adapter Tuning

一般 MaaS 厂商会提供微调的接口,用户准备好微调数据集即可。

Prompt Engineer

www.promptingguide.ai/zh

我们如果没有条件进行微调,或者对应的任务没有达到需要微调的级别,我们一般直接对大模型进行 Prompt 。

在理解到上面的 LLM 相关的概念,我们可以理解到,由于本质上LLM输出的是 token 预测,写好输入有利于其预测对应的内容,即写好提示词是一个非常重要的事情。

有许多提示词相关的技术和名词

  1. Zero shot (零样本提示),直接提问,使用大模型内化知识
将文本分类为中性、负面或正面。
文本:我认为这次假期还可以。情感:

中性

2. Few-Shot 少样本提示,提供一些样本输入输出,然后模型再输出

 “whatpu”是坦桑尼亚的一种小型毛茸茸的动物。
一个使用whatpu这个词的句子的例子是:我们在非洲旅行时看到了这些非常可爱的whatpus。

“farduddle”是指快速跳上跳下。一个使用farduddle这个词的句子的例子是:


当我们赢得比赛时,我们都开始庆祝跳跃。

3. COT (chain of thought) 思维链,我们可以让模型一步一步思考,不要立刻给出答案,可以结合 Few-shot 给他一些思考的例子。

  1. RAG

通过先检索一些相关内容,作为上下文给 AI 输入,这是现在的非常常见的简单知识库的做法。

Context: {用户问题检索到的知识库内容}
Query: 问题

5. ReAct

让模型思考下一步动作,然后软件做出下一步动作后,再让模型观测是否达到目标,这也是典型的 agent 的做法。

各种提示词技术都可以结合使用,最终效果按实际效果为主,受模型能力,上下文长度等影响。

各家厂商一般都会训练一个 指令模型,比如带 -Instruct 的型号,指令遵循模型就是那些能够按照"做什么"的指示来执行任务的AI模型,而不仅仅是预测下一个词或句子。这种模型对用户的 Prompt 的遵循会比较靠谱,一般 Agent 会使用这样的模型较多,能够获得更稳定的输出。

比如 Qwen 2.5 Coder 32B-Instruct,以及闭源模型 Claude 3.5/3.7 sonnet 。

Special Token & Chat Template

我们经常会在开发 Agent 的代码中见到这样的语句模式

[    { role: "system", content: "You are a helpful assistant." },    {        role: "user",        content: "Write a haiku about recursion in programming.",    },]

涉及两个概念,Special Token 和 Chat Template

Special Token

有一些用于提高特别输出的 Token,并不是在自然语言语义中出现的。举例: <function_call> 等。

Special Token 是自然语言处理(NLP)中用于结构化输入/输出或传递元信息的特殊标记。它们并非源自原始文本,而是由模型设计者定义,用于增强模型对任务的理解能力。

Special Token 是模型与任务之间的“协议标记”,通过编码先验知识显著提升模型的结构化处理能力。其设计需平衡任务需求与计算效率,并在扩展时充分考虑数据量和模型架构的兼容性。

不同的模型有不同的 special token,模型通过训练,将遇到 special token 会内化相关的行为到 token 预测中。比如 chat 对话功能,指令遵循,tool call ,think 等等。

在框架里通常会帮你封装对话模版 chat-template ,里面就会涉及到 Special Token 的封装,比如下文面图片中, hugging face 的转换例子

huggingface.co/docs/transf…

但是实际上对上层应用的开发者来讲,都是指定 role 和 content 对话模型进行调用的,本身不会感知 special token。一般这个封装在模型推理服务框架或推理引擎那一层上,比如 hugging face 的 Transformers 库、llama.cpp 都做了对 Chat Template 的封装。

Chat Template

大多数模型会在tokenizer_config.json中定义其 chat-template ,大多数 chat-template 使用 Jinja2 模板语法,允许条件逻辑和循环来处理各种对话格式。

比如上层开发者只感知这部分代码

import OpenAI from "openai";
const openai = new OpenAI();

const completion = await openai.chat.completions.create({
    model: "gpt-4o-mini",
    messages: [
        { role: "system", content: "You are a helpful assistant." },
        {
            role: "user",
            content: "Write a haiku about recursion in programming.",
        },
    ],
    store: true,
});

console.log(completion.choices[0].message);

给大模型权重之前, llama.cpp (ollama背后的推理引起) 或者 Transformers 库,会做 chat template 的转换后再推理

<|system|>
You are a helpful assistant.</s>
<|user|>
Write a haiku about recursion in programming.</s>
<|assistant|>

不同的模型和数据集可能使用不同的 chat-template 格式。比如 ChatML 或 HuggingFace 的模板可能有不同的结构。如果模板不匹配,模型可能无法正确解析输入,导致输出错误。因此,在使用时模型进行推理的时候需确保模板与模型训练时使用的格式一致。

当然我们作为 Agent 开发者不需要关注这个,因为我们使用的大模型API,不需要感知这个,只有自己做模型推理才需要感知。

为什么需要聊天模板:

  1. 位置编码依赖 token 位置模式
  2. 注意力机制学习到了特定标记的关联模式
  3. 模型通过特殊标记(如<|im_start|>)识别对话角色
# 正确的模板应用
messages = [
 {"role": "user", "content": "解释相对论"}
]
tokenizer.apply_chat_template(messages, tokenize=False)
# 输出:<|im_start|>user\n解释相对论<|im_end|>\n<|im_start|>assistant\n

下面是 deepseek r1 的chat template , ninja 的语法 huggingface.co/deepseek-ai…

"{% if not add_generation_prompt is defined %}{% set add_generation_prompt = false %}{% endif %}{% set ns = namespace(is_first=false, is_tool=false, is_output_first=true, system_prompt='', is_first_sp=true) %}{%- for message in messages %}{%- if message['role'] == 'system' %}{%- if ns.is_first_sp %}{% set ns.system_prompt = ns.system_prompt + message['content'] %}{% set ns.is_first_sp = false %}{%- else %}{% set ns.system_prompt = ns.system_prompt + '\n\n' + message['content'] %}{%- endif %}{%- endif %}{%- endfor %}{{ bos_token }}{{ ns.system_prompt }}{%- for message in messages %}{%- if message['role'] == 'user' %}{%- set ns.is_tool = false -%}{{'<|User|>' + message['content']}}{%- endif %}{%- if message['role'] == 'assistant' and 'tool_calls' in message %}{%- set ns.is_tool = false -%}{%- for tool in message['tool_calls'] %}{%- if not ns.is_first %}{%- if message['content'] is none %}{{'<|Assistant|><|tool▁calls▁begin|><|tool▁call▁begin|>' + tool['type'] + '<|tool▁sep|>' + tool['function']['name'] + '\n' + '```json' + '\n' + tool['function']['arguments'] + '\n' + '```' + '<|tool▁call▁end|>'}}{%- else %}{{'<|Assistant|>' + message['content'] + '<|tool▁calls▁begin|><|tool▁call▁begin|>' + tool['type'] + '<|tool▁sep|>' + tool['function']['name'] + '\n' + '```json' + '\n' + tool['function']['arguments'] + '\n' + '```' + '<|tool▁call▁end|>'}}{%- endif %}{%- set ns.is_first = true -%}{%- else %}{{'\n' + '<|tool▁call▁begin|>' + tool['type'] + '<|tool▁sep|>' + tool['function']['name'] + '\n' + '```json' + '\n' + tool['function']['arguments'] + '\n' + '```' + '<|tool▁call▁end|>'}}{%- endif %}{%- endfor %}{{'<|tool▁calls▁end|><|end▁of▁sentence|>'}}{%- endif %}{%- if message['role'] == 'assistant' and 'tool_calls' not in message %}{%- if ns.is_tool %}{{'<|tool▁outputs▁end|>' + message['content'] + '<|end▁of▁sentence|>'}}{%- set ns.is_tool = false -%}{%- else %}{% set content = message['content'] %}{% if '</think>' in content %}{% set content = content.split('</think>')[-1] %}{% endif %}{{'<|Assistant|>' + content + '<|end▁of▁sentence|>'}}{%- endif %}{%- endif %}{%- if message['role'] == 'tool' %}{%- set ns.is_tool = true -%}{%- if ns.is_output_first %}{{'<|tool▁outputs▁begin|><|tool▁output▁begin|>' + message['content'] + '<|tool▁output▁end|>'}}{%- set ns.is_output_first = false %}{%- else %}{{'<|tool▁output▁begin|>' + message['content'] + '<|tool▁output▁end|>'}}{%- endif %}{%- endif %}{%- endfor -%}{% if ns.is_tool %}{{'<|tool▁outputs▁end|>'}}{% endif %}{% if add_generation_prompt and not ns.is_tool %}{{'<|Assistant|><think>\n'}}{% endif %}"

看开发者修改的 chat-template ,给最后追加了个 <think> ****,因为增加了个这样的 token ,特殊标记(如)可以作为模型输出的强信号,告诉模型接下来应该生成什么类型的内容。在这个例子中,是告诉模型展开其思维过程。

这种方法有效是因为模型在预训练或微调阶段很可能见过类似的标记,并学习了与之相关的输出模式。比如 Deepseek 会对 R1 进行这种模式的格式奖励和内容奖励的训练

增加这一个 能够让 R1 更稳定的输出思维链内容,足以见得 chat template 的重要性。

你也可以进一步理解到为什么区分了 SystemPrompt 和 UserPrompt

与标记类似,SystemPrompt和UserPrompt在chat-template中通常有各自独特的标记和处理方式:

  • SystemPrompt标记:如<|system|>、<s>、[INST]
  • UserPrompt标记:如<|user|>、[/INST]

这种区分通过特殊标记向模型发出明确信号,告诉它当前是谁在说话以及应该如何理解和响应不同来源的输入。它可以建立对模型的控制的优先级,在实际开发agent的价值中,允许应用开发者通过SystemPrompt设置应用特定的行为,而不干扰用户输入,同时增强对 LLM 的重要控制。

这种层级结构在chat-template中通常通过模板语法的条件处理来实现,

{% if loop.first and system_message %}
  {{ system_message }}
{% endif %}
{{ '<|user|>\n' + message['content'] + '\n<|assistant|>\n' }}

好了,我们了解了 Chat Template 的概念,虽然 Agent 开发用不到,但是了解这些背景还是有必要的。

AI Agent 核心概念

什么是 agent

This is what an Agent is: an AI model capable of reasoning, planning, and interacting with its environment.

能够推理、规划并与环境交互的 AI 模型。

即 AI 能够接受输入,不管是用户的还是环境的,对输入进行规划,调用不同的工具进行执行,并且观察结果最终达到目的。

这里有三个关键:

一个是模型能够自动规划具体怎么做,使用什么样的工具(工具调用能力),和观察结果的能力,即 Plan、Act 、Observe

二个是 Agent 能够集成模型,遍历循环,直到完成目标。

三个是 能够动态集成,获取到外界知识,随时调整。

agent 有哪些组件

我们借用 mastra 的图 ,mastra 是一个 typescript ai agent framework 他这个图总结的非常到位,列举了 agent 开发中的各种组件,虽然这个框架现在不常用,但是他这个图还是挺好的。

我们可以看到,一个 agent 的组成可以归纳为上述的部分。

  1. LLM 交互上,agent 有长短期记忆,工具调用,模型路由。
  2. RAG 上,agent 有检索相关的能力,搜索(query\requery)、reRank、分块、document embed。这里 rag 可以是 embeding 的内容可以是私有文档,也可以是 web search( Tavily API or Local Chrome[puppteer]) ,还可以利用 记忆能力, 召回(recall)之前的对话消息,
  3. Workflows:LLM 流程可以用 conditions(条件)、branching(分支结构)、chaining(链式调用)、merging(合成输出)等,也可以有 human-in-the-loop 的特性,可以在 agent 循环中接收 user 的 input,进行响应。
  4. evals: 评估 agent 的好坏
  5. local dev: 调试工具

我们这篇文章,暂时不会涉及 memory 、rag 、evals、local dev 的内容,主要是体现在最基础的构建 agent 的逻辑上,怎么和 LLM 交互上。

模型(chat)

模型有许多关键的能力,根据任务进行选择。先说说模型的通用的一些能力,再说说模型的选择。

Context

上下文决定成本和输入输出token窗口。

token 可以经过简单计算,具体长度依赖模型的 tokenizer,一般厂商有 api 或者 sdk 提供 token 的计算能力

  • 1 个英文字符 ≈ 0.3 个 token
  • 1 个中文字符 ≈ 0.6 个 token

合理使用 token ,一是可以提高输出质量,二是可以减少成本。

API

学习 Agent 开发有一个很重要的内容是对模型能力和参数效果的理解。

  1. 工具调用:

    1. 我们一般会通过大模型厂商提供的 Function Calling 能力进行稳定的函数调用
    2. 如果模型厂商没有提供Function Calling能力,则使用指令遵循能力较强的模型,通过 Prompt 工具格式化声明进行工具的使用。
  2. 做计划,有些模型比较适合做计划,R1 比较适合用来做 Planing,先思考出一个合适的计划后,再交由每一步给工具调用模型。

我们来看 openai 的 sdk 中有一个基本都要用上的模块 openai.chat.completions

通常 openai 的 sdk 已经是一种标准,虽然 claude 和 gemini 有些 api参数 不太一样,比如 Function Calling 的入参不太一样,不过可以通过框架或者自己实现一下兼容层去解决,不是什么问题。

接下来我们按 open ai 的 api 看大模型的能力,在一些大模型工具的模型接入点中,你会看到有类似 openai competiable 的接入方式,比如右图一个 ChatClient 的 model 接入点配置,你可以直接修改 sdk中的 base_urlmodelId

比如我们调用 火山引擎的 deepseek r1 ,只需要修改

base_urlark.cn-beijing.volces.com/api/v3

modelId :

import os
from openai import OpenAI

client = OpenAI(
    # 从环境变量中读取您的方舟API Key
    api_key=os.environ.get("ARK_API_KEY"), 
    base_url="https://ark.cn-beijing.volces.com/api/v3",
    # 深度推理模型耗费时间会较长,建议您设置一个较长的超时时间,推荐为30分钟
    timeout=1800,
    )
response = client.chat.completions.create(
    # 替换 <Model> 为模型的Model ID
    model="ep-2025xxxx-xxx",
    messages=[
        {"role": "user", "content": "研究推理模型与非推理模型区别"},
        {"role": "assistant", "content": "推理模型主要依靠逻辑、规则或概率等进行分析、推导和判断以得出结论或决策,非推理模型则是通过模式识别、统计分析或模拟等方式来实现数据描述、分类、聚类或生成等任务而不依赖显式逻辑推理。"},
        {"role": "user", "content": "我要有研究推理模型与非推理模型区别的课题,怎么体现我的专业性"},
    ],
)

我们直接从 deepseek 的 API params 入手,deepseek 的 api 是 openai competiable 的。

这里就不提及一些多模态相关的API 或者 FIM(Fill-In-the-Middle)补全的 completion api ,主要关注 chat model

api-docs.deepseek.com/zh-cn/api/c…

import requests
import json

url = "https://api.deepseek.com/chat/completions"

payload = json.dumps({
  "messages": [
    {
      "content": "You are a helpful assistant",
      "role": "system"
    },
    {
      "content": "Hi",
      "role": "user"
    }
  ],
  "model": "deepseek-chat",
  "frequency_penalty": 0,
  "max_tokens": 2048,
  "presence_penalty": 0,
  "response_format": {
    "type": "text"
  },
  "stop": None,
  "stream": False,
  "stream_options": None,
  "temperature": 1,
  "top_p": 1,
  "tools": None,
  "tool_choice": "none",
  "logprobs": False,
  "top_logprobs": None
})
headers = {
  'Content-Type': 'application/json',
  'Accept': 'application/json',
  'Authorization': 'Bearer <TOKEN>'
}

response = requests.request("POST", url, headers=headers, data=payload)

print(response.text)

message

Message 是一个消息数组,是本文前面提及的 Chat Template 的应用层抽象。

以 deepseek ****为例消息类型有: System 、User、Assistant、Tool

System Prompt,一般必须放在 messages 的队列头部,不放在头部很多 API 有校验会报错。

多轮对话

from openai import OpenAI
client = OpenAI(api_key="<DeepSeek API Key>", base_url="https://api.deepseek.com")

 # Round 1
messages = [{"role": "user", "content": "9.11 and 9.8, which is greater?"}]
response = client.chat.completions.create(
    model="deepseek-reasoner",
    messages=messages
)

reasoning_content = response.choices[0].message.reasoning_content
content = response.choices[0].message.content

 # Round 2
messages.append({'role': 'assistant', 'content': content})
messages.append({'role': 'user', 'content': "How many Rs are there in the word 'strawberry'?"})
response = client.chat.completions.create(
    model="deepseek-reasoner",
    messages=messages
)
# ... 

上面以 R1 文档里的为例子,reasoning Model 会输出思维链,一般思维链只用来展示。

在对话应用中,我们一般称一次对话 Session 为 Thread,在一个 Thread 里,要求 Agent 开发者维护用户消息历史,我们把每一轮的 LLM 输出 message.content 作为下一轮的输入。

在更高级的应用里,消息历史应该被单独提出来做 Context Engineering (上下文工程),Agent里针对大模型的每一轮对话都对 Agent 有至关重要的作用。blog.langchain.com/context-eng…

Temperature & top_p

temperature

模型可以设置输出的 temperature,LLM中控制生成文本多样性和随机性的核心参数。其作用机制基于对模型预测概率分布的调整。你可以理解为模型自由发挥的程度.

Temperature <1(例如0.2-0.8):概率分布更尖锐,高概率词被强化,低概率词被抑制。生成文本更保守、连贯,但可能缺乏创意。

Temperature >1(例如1.2-2.0):概率分布更平坦,低概率词的机会增加,生成结果更具多样性和新颖性,但可能偏离逻辑或事实。

top_p

Deepseek 文档的回答:作为调节采样温度的替代方案,模型会考虑前 top_p 概率的 token 的结果。top_p = 0.1 ****就意味着只有包括在最高 10% 概率中的 token 会被考虑。 我们通常建议修改这个值或者更改 temperature,但不建议同时对两者进行修改。

工作原理

  1. 动态候选集选择top_p 会从概率最高的 token 开始累加,直到总和达到阈值 p,形成一个动态候选集。例如,当 top_p=0.1 时,模型会选取累积概率超过 10% 的最小 token 集合。

  2. 归一化采样: 候选集中的 token 会按原始概率比例重新分配概率权重,并在该集合内进行随机采样。

Temperature 和 top_p 两个参数,一个是 最高概率的截断,一个是修改概率分布,本质都是修改模型预测 token 的值的采样的调整。

问了下 deepseek ,推荐优先使用 top_p 若需平衡多样性与可控性,建议固定 temperature=1.0(默认值),仅通过 top_p 调整候选集范围。

max_tokens

根据任务进行 max_tokens 的大小选择,一般来讲会从任务类型,成本去考虑。如果确定是高随机性的场景,比如创意写作、DeepSearch等,需要提高 max_tokens 值去生成更多内容。如果是工具调用,或者需要快速回答的场景,减少 max_token 加速响应和准确度。

presence_penalty & frequency_penalty

presence_penalty主要是对已经出现过的token进行惩罚,不管出现的次数,只要出现过就会降低其概率。固定值减法(与出现次数无关

frequency_penalty则是根据token出现的频率来施加惩罚,出现次数越多,惩罚越重。累进式减法(与出现次数成比例

stop

可以选择某个关键词进行截断。截断词是构建 agent 工具调用的常用的方法,可以让输出中间结果更加稳定。

比如我让模型帮我总结某个领域知识的时候,让模型先思考后得出结论。可能会这么输出

Thinking:
xxx
xxxxxxx
Conclusion:
xxxx

但是我可以通过中间 eval-loop去评测模型的输出质量,然后进行多轮思考,可以设置这个参数

stop=['Conclusion:']

我就可以得到这样的结果,

Thinking:
xxx
xxxxxxx
Conclusion: 

然后我可以再把这个结果给到 LLM ,让模型做更深层次的思考,思考了后再进行 Conclusion 的输出,这样可能输出的质量就会更高。

后面讲工具调用会再次提到。

stream

流式输出,指使用 http sse 进行 token 的输出。常用于 model 对话上。使用 text 进行传输,需要 client 自行处理 parser。

response_format - JSON output

deepseek原文:

设置为 {"type": "json_object" } 以启用 JSON 模式,该模式保证模型生成的消息是有效的 JSON。

注意: 使用 JSON 模式时,你还必须通过系统或用户消息指示模型生成 JSON。否则,模型可能会生成不断的空白字符,直到生成达到令牌限制,从而导致请求长时间运行并显得“卡住”。此外,如果 finish_reason="length",这表示生成超过了 max_tokens 或对话超过了最大上下文长度,消息内容可能会被部分截断。

你是一个诗歌生成AI,需生成包含自然意象的七言绝句。要求:
1. 每句7个字,共42. 使用JSON格式输出,包含标题(title)和内容(lines)
3. 避免重复用词,允许合理押韵

EXAMPLE JSON OUTPUT:
```json
{"title": "秋夜吟","lines": ["银霜悄落桂枝头","寒蛩低鸣石径幽","孤灯照影书窗寂","玉露凝香夜未收"]}
```

function calling

函数调用是大型语言模型厂商提供的一种功能,和直接在提示词中编写结构化输出的方法相比更加稳定。

Function calling 并不是指模型执行工具,而是提供结构化输出的指令,让应用程序执行工具,保持应用程序的控制权。

比如我们定义工具,

 tools = [
        {
            "type": "function",
            "function": {
                "name": "get_weather",
                "description": "获取指定位置的天气信息",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "location": {
                            "type": "string",
                            "description": "城市名称,如:北京、上海、广州",
                        },
                        "unit": {
                            "type": "string",
                            "enum": ["celsius", "fahrenheit"],
                            "description": "温度单位",
                        },
                    },
                    "required": ["location"],
                },
            },
        }
    ]

发送对应的消息给 LLM API

    # 第一步:发送用户问题和可用工具
    messages = [{"role": "user", "content": "上海今天的天气怎么样?"}]

    response = client.chat.completions.create(
        model="gpt-3.5-turbo",
        messages=messages,
        tools=tools,
        tool_choice="auto",
    )
 

得到结果后,API 会提供 工具相关的字段供开发者进行工具的运行。

    response_message = response.choices[0].message
    tool_calls = response_message.tool_calls

    # 检查是否需要调用函数
    if tool_calls:
        messages.append(response_message)  # 添加助手的响应到消息历史

        # 处理函数调用
        for tool_call in tool_calls:
            function_name = tool_call.function.name
            function_args = json.loads(tool_call.function.arguments)

            # 执行函数并获取结果
            function_response = functionCall(function_name, function_args)

            # 将函数执行结果添加到消息历史
            messages.append({
                "role": "tool",
                "tool_call_id": tool_call.id,
                "name": function_name,
                "content": function_response,
            })

        # 将函数执行结果发送给AI以获取最终回复
        second_response = client.chat.completions.create(
            model="gpt-3.5-turbo",
            messages=messages,
        )

        return second_response.choices[0].message.content
    else:
        return response_message.content

应用层的逻辑不用动,主要是 大模型厂商 做了更稳定的函数调用能力,规范了 function call 的输入输出。

如果不提供 Function Calling 的能力,那开发者就需要自行做模型结构化输出的校验。

    response = client.chat.completions.create(
        model="gpt-3.5-turbo",
        messages=messages,
        tools=tools,
        tool_choice="auto",
    )

    response_message = response.choices[0].message
    tool_calls = response_message.tool_calls

模型的选择

不同模型的能力不同,比如:

  1. 有些模型就不支持 function calling, 如:deepseek r1
  2. 有些模型指令遵循不好,你告诉他的 prompt 不能很好的执行
  3. 还有些模型是 reasoning model,不适合做一些快速的输出,适合做 act 。
  4. 还有些时候你需要多模态能力,比如需要使用 vision model 去做浏览器自动化,可以使用视觉模型辅助。
  5. 等等...

一个常见的认知是,reasoning model 适合做 planing ,claude 4 sonnet\ kimi k2 适合写代码,做指令遵循,gpt4o 适合做多模态任务等等,最终模型的选择要根据 agent 的效果,以及成本来考虑。

有个偷懒的方法,可以去 OpenRouter 上筛选具体的模型能力。

大模型厂商 maas

除了 Openai、anthropic 、deepseek 等厂商外,还有各种 maas 服务商:

国内的有:火山引擎方舟、硅基流动等

国外的有:OpenRouter、aimixhub, OpenRouter 支持 支付宝微信支付 ,不太好充值 openai 、anthropic 的同学可以使用。

为什么需要大模型厂商呢?上面我们说模型有不同的能力,有时候你需要去尝试不同的模型去构建自己的应用,大模型厂商帮助你可以方便的调用不同的 model ,并且现在经常打活动,价格也不超出原有价格,经常还有免费的额度,可以白嫖一些试试模型能力。

Agent 设计模式

上面的讲到了 agent 构建的基础,不同的任务有不同的 agent 设计模式,下面我们来提及一下。

RAG:检索增强生成

早期 AI 和环境交互是以 RAG 为主 ,chat thread 的产品,先去外部信息源( web search、document vector base)搜索一下相关内容,然后再把用户信息一起输入给 LLM ,最后 LLM 输出文本给用户。

在这个阶段,LLM 基于增强的上下文生成回答,还是 人 到 LLM 的单边 Q&A 过程。

 # RAG简单实现示例from langchain.embeddings import OpenAIEmbeddings
from langchain.vectorstores import Chroma
from langchain.chat_models import ChatOpenAI
from langchain.chains import RetrievalQA
from langchain.document_loaders import TextLoader
from langchain.text_splitter import CharacterTextSplitter

# 1. 加载文档
loader = TextLoader("knowledge_base.txt")
documents = loader.load()

# 2. 分割文档
text_splitter = CharacterTextSplitter(chunk_size=1000, chunk_overlap=0)
texts = text_splitter.split_documents(documents)

# 3. 创建向量存储
embeddings = OpenAIEmbeddings()
vectorstore = Chroma.from_documents(texts, embeddings)

# 4. 创建检索器
retriever = vectorstore.as_retriever()

# 5. 创建问答链
llm = ChatOpenAI(model_name="gpt-3.5-turbo")
qa_chain = RetrievalQA.from_chain_type(
    llm=llm,
    chain_type="stuff",
    retriever=retriever
)

# 6. 查询
query = "什么是人工智能?"
response = qa_chain.run(query)
print(response)

Anthropic Agent 构建指南

如何让 LLM 更自动化的做一些工作,Anthropic 出了个 Agent 构建指南

www.anthropic.com/research/bu…

Anthropic 文章里面提及到

We suggest that developers start by using LLM APIs directly: many patterns can be implemented in a few lines of code. If you do use a framework, ensure you understand the underlying code. Incorrect assumptions about what's under the hood are a common source of customer error.

Anthropic 期望开发者直接使用 LLM api 去构建 agent ,能够更好的适应和修改 agent,文章提到,大部分 LLM 应用不需要框架封装。

比如早期 langchain 存在过度封装,黑盒的问题被人诟病,限制了逻辑的实现。

不过我觉得现在的 agent framework 不止是对 workflow 的封装,也集成了很多 memory 、eval 、trace 的功能,帮助开发者更多关注自己的 agent 产品逻辑,langgraph 提供了比较低级的实现,组合 workflow 图,能省不少事。

这个指南中,提到了 6个 agent 构建模式。

我们先把把 LLM 组合为可以进行 工具调用、RAG、Memory 的 增强 LLM ,在下面的每个节点里都可以作为 这样的 LLM 节点,这些实际上是作为 LLM UserMessage 输入的影响,不影响实际的流程节点。

chain

比如你想让 写作Agent 写一篇文章,写完了文章后,先评判文章的是否基于背景合理,然后需要润色文章结构,最后再用英文输出。

上面的需求当然可以用一个 提示次 + 一个 LLM 实现,也可以使用 多个 LLM 串行去做,这个就是 chain。通常串行的质量会比一个LLM质量更高,同时也能拿到中间结果。

routing

大模型判断好了后,具体走哪个模型去处理。比如最经典的,我们的编程 copilot 早期的一个功能就是 /fix , /test 之类的指令提示,让大模型使用不同的系统提示,用于不同的任务。

parallelization

由人提前拆解好任务,人去决定使用什么样的任务。比如你提前想好了对 PRD 做几个角度的评判,从产品经理的角度,工程师的角度,用户的角度,然后你可以将这个任务分解为多个 LLM 去执行,然后最后在用一个聚合器拼接这些角度的输出。

Orchestrator-workers

用户的任务比较复杂,需要一个 Orchestrator (协调者) 对任务进行一个拆解,再分发多个子任务给其他大模型,尤其是不知道任务步数的时候。还是可以用上面的 PRD 分析做例子,比如你不规定具体的角色,你就让 LLM 尽可能的从多角度全面的分析问题。

ORCHESTRATOR_SYSTEM_PROMPT = """你是一个PRD的评判官,你需要进可能的将需求拆解为多视角去分析,比如你可以从用户、产品经理、研发、财务等多视角分析,并且不限于此。
需要分析的问题如下: {question}
返回的输出格式如下:
<analysis> 输出你的分析结果 </analysis>

<tasks>
  <task>视角1</task>
  <task>视角2</task>
</tasks>
"""
TASKWORKER_SYSTEM_PROMPT = """
这是你的问题{question}
你尽可能的详细以这个角度回答 {task}
返回格式如下:
<response>
输出你的返回格式
</resopnse>
"""

最后再让 Systhesizer 大模型去总结结果返回。

和 Parallelization 不同的是,Orchestrator-workers 是大模型划分步骤,Parallelization 是人划分步骤。

Evaluator-optimizer

还是拿 PRD 举例,如果这个时候我们想引入一个评价机制,LLM 互相评价。

就可以写一个类似下面伪代码的过程

gen = callGenLLM(prompts)
while(True)
    score, feedback = callEvaluatorLLM(gen)
    if(score  >= 90) 
        return gen
    nextPrompt = f"之前PRD如下 {gen}\nscore: {score}\nfeedback {feedback}"
    gen = callGenLLM(prompts)

ReAct Agent

ReAct Agent 就是一个常见的构建方法,开放性问题没法定义工作流。

  1. 用户给到输入

  2. Action: 先让模型决定调用什么工具去完成任务 。

  3. Environment: 然后你拥有一个环境去执行 LLM 给出的 action ,这里文章前面提及到了 tools、mcp 、 codeAgent 中的代码执行环境,这些都是 environment , 甚至 另一个 LLM 也可以当作 tools

  4. Feedback: 执行完毕后,再次丢给大模型,让大模型去 observe 对应的结果,如果不符合要求,则继续给出 Action 让 Environment 去执行,符合则停止。

上述6种构建方式可以自由组合,agent 本身也可以是 tool 的一种,被其他 LLM 调用。

更强大的 Agent

工具调用 和 ReAct 构建模式

我们深入一下 ReAct 的过程。

ReAct(Reasoning and Acting)是一种结合推理(Reasoning)和行动(Acting)的 Agent 流程,让大语言模型能够交替进行思考和执行操作。它是大多数现代LLM工具调用的基础方法。

Huggingface agent course 的 例子, huggingface.co/learn/agent…

  1. System prompt 告诉大模型,用户询问天气情况的时候要调用工具 get_weather
  2. 用户询问 weather
  3. 大模型识别到询问天气的意图,需要调用工具,模型返回使用 get_weather 工具调用描述
  4. 然后环境(Agent)去调用相关的工具,再将返回结果,将结果和之前的Prompt 拼成新的 Message 发给大模型作最后的输出。

由于用户的描述不一定有上面的描述那么简单,逐步出现了 Planing + ReAct 的方法的 Agent , 类似 DeepResearch \ Manus ,用户给定一个目标,大模型先帮用户划分成不同的计划步骤,然后再将每一步执行用 ReAct Agent 去判断执行效果。

OpenDeepResearch

DeepResearch 是 OpenAI 推出的一款研究产品,你只需要给 Agent 一个任务,他会给你返回一个详细的调研报告。mp.weixin.qq.com/s/6NJgaoiDd…

OpenDeepResearch 是 Langchain 官方用 langgraph 构建的例子 github.com/langchain-a…

  1. Input:用户提供一个主题
  2. Planing:使用 Reasoning Model 提供每个章节的计划,langgraph 有 human-in-the-loop 的能力,可以在这个过程中,人进行介入后调整大纲决定后续 research 内容。
  3. ToolCall:然后每个章节再丢给 Research Agent 调用工具学习,做多轮 Web搜索,总结,反思,再搜索。
  4. 最后再把所有的 Research 汇总,编程方式做 Markdown 输出。

OpenManus

OpenManus 由两个 Agent 协作,

  1. TODO-List Agent: 代码中叫做 PlaningFlow,负责 TODO List planing 的产生和状态变化。
  2. ReAct Agent:代码中叫做 ManusAgent 负责工具调用和 ReAct 流程

  • PlaningFlow 产生一个 TODO List 计划表,然后每一步丢给 ManusAgent 执行,完成后会对 TODO List 打上 ✓ ,当没有任务运行的时候,会丢给 LLM 做 Summerize,每一步驱动一个 ManusAgent 工作。

  • ManusAgent 是一个绑定了下面几个工具的 ReAct Agent

完成每一项 Plan 中的 Task ,并通过 UserPrompt 的提示让LLM返回 Summerize

看项目里还规划了许多工具和Agent,是一个非常经典简洁的 Planing + ReAct 的代码,没有使用 LangGraph 之类的框架,直接使用 API ,是一个比较好的入门 Agent开发的仓库。

上面粗略看了基本的 Agent 构建思路,下面我们再看一下比较重要的部分,怎么具体编写工具调用的逻辑。

工具调用是一个非常重要的事情,在大模型作为大脑 🧠 很强大的情况下,决定大模型的身体 🏋️ 的上限。

工具调用的编写

Prompt Tools Schema

通过系统提示词设计,指导AI以特定格式(如XML或JSON)调用工具。它确保AI输出的工具调用格式清晰,便于应用程序解析和执行。例如,提示词可以包括工具描述和预期输出的示例,告诉AI如何生成函数调用。

工作流程:

  1. 工具定义:在系统提示中定义工具及其调用格式
  2. 格式约束:明确指定AI需要使用的响应格式(XML/JSON)
  3. 工具调用:AI根据指定格式生成工具调用请求
  4. 解析执行:系统解析AI生成的格式化内容并执行对应操作

它为后续的函数调用奠定了基础,通过结构化提示减少了AI输出的歧义。例如,提示可以指定

当需要执行查看天气的时候,请使用以下XML格式:
<tool name="get_weather">
  <param name="city">{cityName}</param>
  ... 其他参数 ...
</tool>

但是这种需要模型有很强的指令遵循能力 Instruct ,比如 claude 4 sonnet。开发者需要考虑模型如果没有遵循指令的情况,对模型输出做解析、校验和重试。

import os
from openai import OpenAI

# 初始化OpenAI客户端
client = OpenAI(api_key=os.environ.get("OPENAI_API_KEY"))

def run_conversation():
    # 系统提示中定义工具调用格式
    system_prompt = """你是一个有用的AI助手。当需要执行工具操作时,请使用以下XML格式:
    <tool name="工具名称">
      <param name="参数名">参数值</param>
      ... 其他参数 ...
    </tool>
    
    可用工具列表:
    1. calculator - 执行数学计算
       参数: expression (字符串) - 要计算的数学表达式
    2. weather - 查询天气
       参数: location (字符串) - 要查询天气的位置
    """

    response = client.chat.completions.create(
        model="gpt-4",
        messages=[
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": "计算345乘以982是多少?然后告诉我北京的天气。"}
        ],
        temperature=0
    )

    assistant_message = response.choices[0].message.content
    print("助手回应:", assistant_message)
    
    # 这里需要解析XML格式的工具调用并执行相应操作
    # 解析逻辑取决于实际应用需求

if __name__ == "__main__":
    run_conversation()
bolt.new

经典的使用这种方法构建的 Agent 有 bolt.new/ , 一个 UI AI Agent

核心组件如下

  1. 自定义XML标记系统,指导LLM如何构造和标记,每个标记包含属性,如动作类型和文件路径。
  2. 自定义解析器,StreamingMessageParser 类负责解析来自LLM的消息流,它识别特殊标记(如和),当发现这些标记时,它会触发相应的回调函数
  3. ActionRunner,动作系统,解析器解析好了后,触发 ActionRunner 对应的 WebContainer 的动作。
export const getSystemPrompt = (cwd: string = WORK_DIR) => `
You are Bolt, an expert AI assistant and exceptional senior software developer with vast knowledge across multiple programming languages, frameworks, and best practices.

<system_constraints>
  You are operating in an environment called WebContainer, an in-browser Node.js runtime that emulates a Linux system to some degree. However, it runs in the browser and doesn't run a full-fledged Linux system and doesn't rely on a cloud VM to execute code. All code is executed in the browser. It does come with a shell that emulates zsh. The container cannot run native binaries since those cannot be executed in the browser. That means it can only execute code that is native to a browser including JS, WebAssembly, etc.

  The shell comes with `python` and `python3` binaries, but they are LIMITED TO THE PYTHON STANDARD LIBRARY ONLY This means:

    - There is NO `pip` support! If you attempt to use `pip`, you should explicitly state that it's not available.
    - CRITICAL: Third-party libraries cannot be installed or imported.
    - Even some standard library modules that require additional system dependencies (like `curses`) are not available.
    - Only modules from the core Python standard library can be used.

  Additionally, there is no `g++` or any C/C++ compiler available. WebContainer CANNOT run native binaries or compile C/C++ code!

  Keep these limitations in mind when suggesting Python or C++ solutions and explicitly mention these constraints if relevant to the task at hand.

  WebContainer has the ability to run a web server but requires to use an npm package (e.g., Vite, servor, serve, http-server) or use the Node.js APIs to implement a web server.

  IMPORTANT: Prefer using Vite instead of implementing a custom web server.

  IMPORTANT: Git is NOT available.

  IMPORTANT: Prefer writing Node.js scripts instead of shell scripts. The environment doesn't fully support shell scripts, so use Node.js for scripting tasks whenever possible!

  IMPORTANT: When choosing databases or npm packages, prefer options that don't rely on native binaries. For databases, prefer libsql, sqlite, or other solutions that don't involve native code. WebContainer CANNOT execute arbitrary native binaries.

  Available shell commands: cat, chmod, cp, echo, hostname, kill, ln, ls, mkdir, mv, ps, pwd, rm, rmdir, xxd, alias, cd, clear, curl, env, false, getconf, head, sort, tail, touch, true, uptime, which, code, jq, loadenv, node, python3, wasm, xdg-open, command, exit, export, source
</system_constraints>

<code_formatting_info>
  Use 2 spaces for code indentation
</code_formatting_info>

<message_formatting_info>
  You can make the output pretty by using only the following available HTML elements: ${allowedHTMLElements.map((tagName) => `<${tagName}>`).join(', ')}
</message_formatting_info>

<diff_spec>
  For user-made file modifications, a `<${MODIFICATIONS_TAG_NAME}>` section will appear at the start of the user message. It will contain either `<diff>` or `<file>` elements for each modified file:

    - `<diff path="/some/file/path.ext">`: Contains GNU unified diff format changes
    - `<file path="/some/file/path.ext">`: Contains the full new content of the file

  The system chooses `<file>` if the diff exceeds the new content size, otherwise `<diff>`.

  GNU unified diff format structure:

    - For diffs the header with original and modified file names is omitted!
    - Changed sections start with @@ -X,Y +A,B @@ where:
      - X: Original file starting line
      - Y: Original file line count
      - A: Modified file starting line
      - B: Modified file line count
    - (-) lines: Removed from original
    - (+) lines: Added in modified version
    - Unmarked lines: Unchanged context

  Example:

  <${MODIFICATIONS_TAG_NAME}>
    <diff path="/home/project/src/main.js">
      @@ -2,7 +2,10 @@
        return a + b;
      }

      -console.log('Hello, World!');
      +console.log('Hello, Bolt!');
      +
      function greet() {
      -  return 'Greetings!';
      +  return 'Greetings!!';
      }
      +
      +console.log('The End');
    </diff>
    <file path="/home/project/package.json">
      // full file content here
    </file>
  </${MODIFICATIONS_TAG_NAME}>
</diff_spec>

<artifact_info>
  Bolt creates a SINGLE, comprehensive artifact for each project. The artifact contains all necessary steps and components, including:

  - Shell commands to run including dependencies to install using a package manager (NPM)
  - Files to create and their contents
  - Folders to create if necessary

  <artifact_instructions>
    1. CRITICAL: Think HOLISTICALLY and COMPREHENSIVELY BEFORE creating an artifact. This means:

      - Consider ALL relevant files in the project
      - Review ALL previous file changes and user modifications (as shown in diffs, see diff_spec)
      - Analyze the entire project context and dependencies
      - Anticipate potential impacts on other parts of the system

      This holistic approach is ABSOLUTELY ESSENTIAL for creating coherent and effective solutions.

    2. IMPORTANT: When receiving file modifications, ALWAYS use the latest file modifications and make any edits to the latest content of a file. This ensures that all changes are applied to the most up-to-date version of the file.

    3. The current working directory is `${cwd}`.

    4. Wrap the content in opening and closing `<boltArtifact>` tags. These tags contain more specific `<boltAction>` elements.

    5. Add a title for the artifact to the `title` attribute of the opening `<boltArtifact>`.

    6. Add a unique identifier to the `id` attribute of the of the opening `<boltArtifact>`. For updates, reuse the prior identifier. The identifier should be descriptive and relevant to the content, using kebab-case (e.g., "example-code-snippet"). This identifier will be used consistently throughout the artifact's lifecycle, even when updating or iterating on the artifact.

    7. Use `<boltAction>` tags to define specific actions to perform.

    8. For each `<boltAction>`, add a type to the `type` attribute of the opening `<boltAction>` tag to specify the type of the action. Assign one of the following values to the `type` attribute:

      - shell: For running shell commands.

        - When Using `npx`, ALWAYS provide the `--yes` flag.
        - When running multiple shell commands, use `&&` to run them sequentially.
        - ULTRA IMPORTANT: Do NOT re-run a dev command if there is one that starts a dev server and new dependencies were installed or files updated! If a dev server has started already, assume that installing dependencies will be executed in a different process and will be picked up by the dev server.

      - file: For writing new files or updating existing files. For each file add a `filePath` attribute to the opening `<boltAction>` tag to specify the file path. The content of the file artifact is the file contents. All file paths MUST BE relative to the current working directory.

    9. The order of the actions is VERY IMPORTANT. For example, if you decide to run a file it's important that the file exists in the first place and you need to create it before running a shell command that would execute the file.

    10. ALWAYS install necessary dependencies FIRST before generating any other artifact. If that requires a `package.json` then you should create that first!

      IMPORTANT: Add all required dependencies to the `package.json` already and try to avoid `npm i <pkg>` if possible!

    11. CRITICAL: Always provide the FULL, updated content of the artifact. This means:

      - Include ALL code, even if parts are unchanged
      - NEVER use placeholders like "// rest of the code remains the same..." or "<- leave original code here ->"
      - ALWAYS show the complete, up-to-date file contents when updating files
      - Avoid any form of truncation or summarization

    12. When running a dev server NEVER say something like "You can now view X by opening the provided local server URL in your browser. The preview will be opened automatically or by the user manually!

    13. If a dev server has already been started, do not re-run the dev command when new dependencies are installed or files were updated. Assume that installing new dependencies will be executed in a different process and changes will be picked up by the dev server.

    14. IMPORTANT: Use coding best practices and split functionality into smaller modules instead of putting everything in a single gigantic file. Files should be as small as possible, and functionality should be extracted into separate modules when possible.

      - Ensure code is clean, readable, and maintainable.
      - Adhere to proper naming conventions and consistent formatting.
      - Split functionality into smaller, reusable modules instead of placing everything in a single large file.
      - Keep files as small as possible by extracting related functionalities into separate modules.
      - Use imports to connect these modules together effectively.
  </artifact_instructions>
</artifact_info>

NEVER use the word "artifact". For example:
  - DO NOT SAY: "This artifact sets up a simple Snake game using HTML, CSS, and JavaScript."
  - INSTEAD SAY: "We set up a simple Snake game using HTML, CSS, and JavaScript."

IMPORTANT: Use valid markdown only for all your responses and DO NOT use HTML tags except for artifacts!

ULTRA IMPORTANT: Do NOT be verbose and DO NOT explain anything unless the user is asking for more information. That is VERY important.

ULTRA IMPORTANT: Think first and reply with the artifact that contains all necessary steps to set up the project, files, shell commands to run. It is SUPER IMPORTANT to respond with this first.

Here are some examples of correct usage of artifacts:

<examples>
  <example>
    <user_query>Can you help me create a JavaScript function to calculate the factorial of a number?</user_query>

    <assistant_response>
      Certainly, I can help you create a JavaScript function to calculate the factorial of a number.

      <boltArtifact id="factorial-function" title="JavaScript Factorial Function">
        <boltAction type="file" filePath="index.js">
          function factorial(n) {
           ...
          }

          ...
        </boltAction>

        <boltAction type="shell">
          node index.js
        </boltAction>
      </boltArtifact>
    </assistant_response>
  </example>

  <example>
    <user_query>Build a snake game</user_query>

    <assistant_response>
      Certainly! I'd be happy to help you build a snake game using JavaScript and HTML5 Canvas. This will be a basic implementation that you can later expand upon. Let's create the game step by step.

      <boltArtifact id="snake-game" title="Snake Game in HTML and JavaScript">
        <boltAction type="file" filePath="package.json">
          {
            "name": "snake",
            "scripts": {
              "dev": "vite"
            }
            ...
          }
        </boltAction>

        <boltAction type="shell">
          npm install --save-dev vite
        </boltAction>

        <boltAction type="file" filePath="index.html">
          ...
        </boltAction>

        <boltAction type="shell">
          npm run dev
        </boltAction>
      </boltArtifact>

      Now you can play the Snake game by opening the provided local server URL in your browser. Use the arrow keys to control the snake. Eat the red food to grow and increase your score. The game ends if you hit the wall or your own tail.
    </assistant_response>
  </example>

  <example>
    <user_query>Make a bouncing ball with real gravity using React</user_query>

    <assistant_response>
      Certainly! I'll create a bouncing ball with real gravity using React. We'll use the react-spring library for physics-based animations.

      <boltArtifact id="bouncing-ball-react" title="Bouncing Ball with Gravity in React">
        <boltAction type="file" filePath="package.json">
          {
            "name": "bouncing-ball",
            "private": true,
            "version": "0.0.0",
            "type": "module",
            "scripts": {
              "dev": "vite",
              "build": "vite build",
              "preview": "vite preview"
            },
            "dependencies": {
              "react": "^18.2.0",
              "react-dom": "^18.2.0",
              "react-spring": "^9.7.1"
            },
            "devDependencies": {
              "@types/react": "^18.0.28",
              "@types/react-dom": "^18.0.11",
              "@vitejs/plugin-react": "^3.1.0",
              "vite": "^4.2.0"
            }
          }
        </boltAction>

        <boltAction type="file" filePath="index.html">
          ...
        </boltAction>

        <boltAction type="file" filePath="src/main.jsx">
          ...
        </boltAction>

        <boltAction type="file" filePath="src/index.css">
          ...
        </boltAction>

        <boltAction type="file" filePath="src/App.jsx">
          ...
        </boltAction>

        <boltAction type="shell">
          npm run dev
        </boltAction>
      </boltArtifact>

      You can now view the bouncing ball animation in the preview. The ball will start falling from the top of the screen and bounce realistically when it hits the bottom.
    </assistant_response>
  </example>
</examples>
`;

Function Calling

函数调用是大型语言模型厂商提供的一种功能,和 Prompt Tools Schema 相比更加稳定,模型不直接执行函数,而是提供结构化输出,保持应用程序的控制权。

from openai import OpenAI
import os
import json

# 初始化OpenAI客户端
client = OpenAI(
    api_key=os.environ.get("OPENAI_API_KEY")
)

async def run_conversation():
    # 定义函数
    tools = [
        {
            "type": "function",
            "function": {
                "name": "get_weather",
                "description": "获取指定位置的天气信息",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "location": {
                            "type": "string",
                            "description": "城市名称,如:北京、上海、广州",
                        },
                        "unit": {
                            "type": "string",
                            "enum": ["celsius", "fahrenheit"],
                            "description": "温度单位",
                        },
                    },
                    "required": ["location"],
                },
            },
        }
    ]

    # 第一步:发送用户问题和可用工具
    messages = [{"role": "user", "content": "上海今天的天气怎么样?"}]

    response = client.chat.completions.create(
        model="gpt-3.5-turbo",
        messages=messages,
        tools=tools,
        tool_choice="auto",
    )

    response_message = response.choices[0].message
    tool_calls = response_message.tool_calls

    # 检查是否需要调用函数
    if tool_calls:
        messages.append(response_message)  # 添加助手的响应到消息历史

        # 处理函数调用
        for tool_call in tool_calls:
            function_name = tool_call.function.name
            function_args = json.loads(tool_call.function.arguments)

            # 执行函数并获取结果
            function_response = functionCaller(function_name, function_args)

            # 将函数执行结果添加到消息历史
            messages.append({
                "role": "tool",
                "tool_call_id": tool_call.id,
                "name": function_name,
                "content": function_response,
            })

        # 将函数执行结果发送给AI以获取最终回复
        second_response = client.chat.completions.create(
            model="gpt-3.5-turbo",
            messages=messages,
        )

        return second_response.choices[0].message.content
    else:
        return response_message.content

# 如果要直接运行,可以使用以下代码
if __name__ == "__main__":
    import asyncio

    # 执行异步函数
    result = asyncio.run(run_conversation())
    print(result)

本质上应用层的逻辑不用动,主要是 大模型厂商 做了更稳定的函数调用能力,规范了 function call 的输入输出,开发者不需要自行做模型结构化输出的校验。

    response = client.chat.completions.create(
        model="gpt-3.5-turbo",
        messages=messages,
        tools=tools,
        tool_choice="auto",
    )

    response_message = response.choices[0].message
    tool_calls = response_message.tool_calls

Code Act

Executable Code Actions Elicit Better LLM Agents,openreview.net/pdf?id=jJ9B…

看到 Manus 开发者发帖子提到了这篇论文,Executable Code Actions Elicit Better LLM Agents,openreview.net/pdf?id=jJ9B… smolagent (huggingface 的 agent 框架)也是以这个思路构建的。

模型给出执行函数,可以生成分支,循环的逻辑,并且能够复用代码中的内容。

大模型不给出函数描述,而是直接给出 分支判断+循环 的函数,提高了工具调用的表达能力。

可以看到上述图片描述中,使用 Function calling 会对话多轮,而 CodeAgent 只需要执行一次。

smolagents 是一个 典型的 CodeAgent first 框架 github.com/huggingface…

MultiStepAgent 为基类,给出 Planing + ReAct 流程

然后派生出两个 Agent ,分别实现不同的 ReAct 流程

  1. ToolCallingAgent :实现 ToolCalling 的能力
  2. CodeAgent: 实现 CodeAgent 能力

代码流程和之前 DeepResearch 类似的流程,先对用户的任务做 planing,然后再对每一步做 ReAct ,只是 SmolAgent 是一个 Agent 框架,你只要是这样的流程,可以 import 他快速实现。

他封装了 ManagedAgent ,多 Agent,比如可以让 PlaningAgent(Deepseek R1) 下放 CodeAgent(Qwen 2.5 Coder 32B Instruct。

CodeAgent 封装了一个安全的 Python 代码沙盒的功能,提供了三种模式:

  1. E2B: 一个开源基础设施,可让开发者在安全隔离的沙盒中运行 AI 生成的代码、启动和控制沙盒。
  2. Docker: 自动构建包含 Jupyter Kernel Gateway 的 Docker 镜像,维护内核会话状态,实现了完整的代码执行后端,包括容器化环境、内核管理和执行协议等
  3. Local: LocalPythonExecutor, AST 解析、安全三方包导入范围检查、函数调用验证等

SmolAgent CodeAgent:对应的提示词

我们看看他提示词怎么写的。

告诉他需要干什么,然后Thought Code Observation 主流程

  You are an expert assistant who can solve any task using code blobs. You will be given a task to solve as best you can.
  To do so, you have been given access to a list of tools: these tools are basically Python functions which you can call with code.
  To solve the task, you must plan forward to proceed in a series of steps, in a cycle of 'Thought:', 'Code:', and 'Observation:' sequences.

  At each step, in the 'Thought:' sequence, you should first explain your reasoning towards solving the task and the tools that you want to use.
  Then in the 'Code:' sequence, you should write the code in simple Python. The code sequence must end with '<end_code>' sequence.
  During each intermediate step, you can use 'print()' to save whatever important information you will then need.
  These print outputs will then appear in the 'Observation:' field, which will be available as input for the next step.
  In the end you have to return a final answer using the `final_answer` tool.

Few-shot ,给出一些例子

 Here are a few examples using notional tools:
  ---
  Task: "Generate an image of the oldest person in this document."

  Thought: I will proceed step by step and use the following tools: `document_qa` to find the oldest person in the document, then `image_generator` to generate an image according to the answer.
  Code:
  ```py
  answer = document_qa(document=document, question="Who is the oldest person mentioned?")
  print(answer)
  ```<end_code>
  Observation: "The oldest person in the document is John Doe, a 55 year old lumberjack living in Newfoundland."

  Thought: I will now generate an image showcasing the oldest person.
  Code:
  ```py
  image = image_generator("A portrait of John Doe, a 55-year-old man living in Canada.")
  final_answer(image)
  ```<end_code>

  ---
  Task: "What is the result of the following operation: 5 + 3 + 1294.678?"

  Thought: I will use python code to compute the result of the operation and then return the final answer using the `final_answer` tool
  Code:
  ```py
  result = 5 + 3 + 1294.678
  final_answer(result)
  ```<end_code>

  ---
  Task:
  "Answer the question in the variable `question` about the image stored in the variable `image`. The question is in French.
  You have been provided with these additional arguments, that you can access using the keys as variables in your python code:
  {'question': 'Quel est l'animal sur l'image?', 'image': 'path/to/image.jpg'}"

  Thought: I will use the following tools: `translator` to translate the question into English and then `image_qa` to answer the question on the input image.
  Code:
  ```py
  translated_question = translator(question=question, src_lang="French", tgt_lang="English")
  print(f"The translated question is {translated_question}.")
  answer = image_qa(image=image, question=translated_question)
  final_answer(f"The answer is {answer}")
  ```<end_code>

  ---
  Task:
  In a 1979 interview, Stanislaus Ulam discusses with Martin Sherwin about other great physicists of his time, including Oppenheimer.
  What does he say was the consequence of Einstein learning too much math on his creativity, in one word?

  Thought: I need to find and read the 1979 interview of Stanislaus Ulam with Martin Sherwin.
  Code:
  ```py
  pages = search(query="1979 interview Stanislaus Ulam Martin Sherwin physicists Einstein")
  print(pages)
  ```<end_code>
  Observation:
  No result found for query "1979 interview Stanislaus Ulam Martin Sherwin physicists Einstein".

  Thought: The query was maybe too restrictive and did not find any results. Let's try again with a broader query.
  Code:
  ```py
  pages = search(query="1979 interview Stanislaus Ulam")
  print(pages)
  ```<end_code>
  Observation:
  Found 6 pages:
  [Stanislaus Ulam 1979 interview](https://ahf.nuclearmuseum.org/voices/oral-histories/stanislaus-ulams-interview-1979/)

  [Ulam discusses Manhattan Project](https://ahf.nuclearmuseum.org/manhattan-project/ulam-manhattan-project/)

  (truncated)

  Thought: I will read the first 2 pages to know more.
  Code:
  ```py
  for url in ["https://ahf.nuclearmuseum.org/voices/oral-histories/stanislaus-ulams-interview-1979/", "https://ahf.nuclearmuseum.org/manhattan-project/ulam-manhattan-project/"]:
      whole_page = visit_webpage(url)
      print(whole_page)
      print("\n" + "="*80 + "\n")  # Print separator between pages
  ```<end_code>
  Observation:
  Manhattan Project Locations:
  Los Alamos, NM
  Stanislaus Ulam was a Polish-American mathematician. He worked on the Manhattan Project at Los Alamos and later helped design the hydrogen bomb. In this interview, he discusses his work at
  (truncated)

  Thought: I now have the final answer: from the webpages visited, Stanislaus Ulam says of Einstein: "He learned too much mathematics and sort of diminished, it seems to me personally, it seems to me his purely physics creativity." Let's answer in one word.
  Code:
  ```py
  final_answer("diminished")
  ```<end_code>

  ---
  Task: "Which city has the highest population: Guangzhou or Shanghai?"

  Thought: I need to get the populations for both cities and compare them: I will use the tool `search` to get the population of both cities.
  Code:
  ```py
  for city in ["Guangzhou", "Shanghai"]:
      print(f"Population {city}:", search(f"{city} population")
  ```<end_code>
  Observation:
  Population Guangzhou: ['Guangzhou has a population of 15 million inhabitants as of 2021.']
  Population Shanghai: '26 million (2019)'

  Thought: Now I know that Shanghai has the highest population.
  Code:
  ```py
  final_answer("Shanghai")
  ```<end_code>

  ---
  Task: "What is the current age of the pope, raised to the power 0.36?"

  Thought: I will use the tool `wiki` to get the age of the pope, and confirm that with a web search.
  Code:
  ```py
  pope_age_wiki = wiki(query="current pope age")
  print("Pope age as per wikipedia:", pope_age_wiki)
  pope_age_search = web_search(query="current pope age")
  print("Pope age as per google search:", pope_age_search)
  ```<end_code>
  Observation:
  Pope age: "The pope Francis is currently 88 years old."

  Thought: I know that the pope is 88 years old. Let's compute the result using python code.
  Code:
  ```py
  pope_current_age = 88 ** 0.36
  final_answer(pope_current_age)
  ```<end_code>

工具调用,把工具调用列表给系统提示词,兼容不支持 FunctionCalling 的模型

  Above example were using notional tools that might not exist for you. You only have access to these tools:
  {%- for tool in tools.values() %}
  - {{ tool.name }}: {{ tool.description }}
      Takes inputs: {{tool.inputs}}
      Returns an output of type: {{tool.output_type}}
  {%- endfor %}

managedAgent 给 ManagedAgent 写的提示词,让他管理其他 Agent

  {%- if managed_agents and managed_agents.values() | list %}
  You can also give tasks to team members.
  Calling a team member works the same as for calling a tool: simply, the only argument you can give in the call is 'task', a long string explaining your task.
  Given that this team member is a real human, you should be very verbose in your task.
  Here is a list of the team members that you can call:
  {%- for agent in managed_agents.values() %}
  - {{ agent.name }}: {{ agent.description }}
  {%- endfor %}
  {%- endif %}

下面是约束,重新强调申明一些行为,给出边界条件, 问题 fallback, 中止情况

  Here are the rules you should always follow to solve your task:
  1. Always provide a 'Thought:' sequence, and a 'Code:\n```py' sequence ending with '```<end_code>' sequence, else you will fail.
  2. Use only variables that you have defined!
  3. Always use the right arguments for the tools. DO NOT pass the arguments as a dict as in 'answer = wiki({'query': "What is the place where James Bond lives?"})', but use the arguments directly as in 'answer = wiki(query="What is the place where James Bond lives?")'.
  4. Take care to not chain too many sequential tool calls in the same code block, especially when the output format is unpredictable. For instance, a call to search has an unpredictable return format, so do not have another tool call that depends on its output in the same block: rather output results with print() to use them in the next block.
  5. Call a tool only when needed, and never re-do a tool call that you previously did with the exact same parameters.
  6. Don't name any new variable with the same name as a tool: for instance don't name a variable 'final_answer'.
  7. Never create any notional variables in our code, as having these in your logs will derail you from the true variables.
  8. You can use imports in your code, but only from the following list of modules: {{authorized_imports}}
  9. The state persists between code executions: so if in one step you've created variables or imported modules, these will all persist.
  10. Don't give up! You're in charge of solving the task, not providing directions to solve it.

最后笑死😆,给 LLM 画饼

  Now Begin! If you solve the task correctly, you will receive a reward of $1,000,000.

工具的集成

什么是 MCP

不管是 Prompt 或者 FunctionCalling Tools 参数,都需要 Agent 手动集成 tools ,Anthropic 提出了一种解耦合的方式 MCP (Model Context Protocol modelcontextprotocol.io/introductio…

host (AI Agent)只需要实现 Mcp client ,即可以以插件的形式集成各种tools (mcp server) ,规范了大模型和工具的通信和集成方法,可以让集成了 MCP Client 的 Agent 调用市面上任意 MCP server 工具,解决了工具碎片化集成的难题。

比如我可以用 cursor(实现了 mcp client 的 host) 集成 blender-mcp 做到任意场景生成。

下面也是我通过几个简单的 prompt 和 一个桌面提示出来的。

你也可以用 Cursor 连接上 Twitter mcp 、 github Mcp ,去发推文和查看 issue,所有的 API 都可以封装为大模型所用。

比方说下面有个视频我接入了 puppeteer 的 mcp server ,我这里用 cline 为例子。

我这里使用的是 火山方舟的 deepseek v3 ,然后使用了 puppeteer 的 mcp server 。

只需要配置一下,脚本命令,比如 npx xxx (nodejs) 或者 uvx (pyhton) 或者 sh xxx.sh

就能使用一个别人发布好的 mcp server ,这个 case 说是 server,实际上是一个本地运行的 http 服务通过 stdio 通信, mcp 还提供了 sse/streamable 的方式,提供远程调用。

相关场景下,puppeteer mcp 还可以看浏览器控制台,也就是说如果写前端代码在浏览器控制台报了个 error,可以通过 puppeteer 的 mcp 获取到 控制台报错信息,然后辅助你排查代码问题,或者在 cursor 上通过 Agent mode 自己闭环解决了。

简单介绍下怎么集成 mcp

MCP的主要组件:

  1. Host:运行大模型的主程序
  2. MCP Client:负责与外部工具通信的客户端
  3. MCP Server:提供工具服务的服务端

工作流程:

  1. 工具注册:工具在MCP Server中注册并声明功能

  2. 请求转发:Host通过MCP Client向MCP Server发送调用请求

  3. 工具执行:MCP Server执行对应的工具操作

  4. 结果返回:执行结果通过相同路径返回给大模型

我们来看看简单的流程,把之前的 get_weather 换成 mcp

需要实现两个模块

  1. 一个是 MCP Client ,处理和 MCP Server 的通信
  2. 一个是 MCP Server,把对应的 API 做MCP接口封装
  3. Host 为原始 AI Agent,处理 agent 流程以及发起 LLM 请求

这里就把 Host 和 MCP client 从代码上不区分了,本身是在一个程序里, MCP Server 是另一个程序,官方提供了 python typescript 等 sdk。

MCP Server:MCP Weather

Mcp Server 主要帮你做了 服务器的启动,路由,工具、Prompt、资源的声明封装。比如下面的例子用到了 @mcp.tool() 定义了一个可以被客户端调用的工具。

from mcp.server.fastmcp import FastMCP
import random

# 创建 MCP 服务器实例
mcp = FastMCP("Weather Service")

@mcp.tool()
def get_weather(city: str, country: str = "CN") -> str:
    """
    获取指定城市的天气信息
    
    参数:
    - city: 城市名称
    - country: 国家代码,默认为中国(CN)
    
    返回:
    - 天气信息文本
    """
    # 这里我们模拟天气数据,实际应用中可以调用真实的天气 API
    weather_conditions = ["晴朗", "多云", "小雨", "大雨", "雷阵雨", "雾", "小雪", "大雪"]
    temperatures = list(range(5, 35))
    
    condition = random.choice(weather_conditions)
    temperature = random.choice(temperatures)
    humidity = random.randint(30, 90)
    
    weather_info = f"{city}({country})的天气信息:\n温度: {temperature}°C\n天气状况: {condition}\n湿度: {humidity}%"
    return weather_info
   
if __name__ == "__main__":
    mcp.run()

MCP Client

MCP Client 帮你和 MCP Server 进行建联和通信和工具管理和执行比如下面的 Stoio 的方式

  1. StdioServerParameters
  2. stdio_client

创建 ClientSession管理了 Tools、Prompt 和 Resource:

下面只提及了 tools ,Prompt 和 Resource 类似。

  1. session.list_tools可以使用相关 API 列出工具,然后你可以给大模型工具列表
  2. session.call_tool帮你进行工具的执行。

下面给出 使用 Prompt Tool Schema 和 Function Calling 的实现,对 MCP Client 部分来讲都是一样的。

import asyncio
import json
import anthropic
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

# Claude 客户端配置
# 注意:需要设置 ANTHROPIC_API_KEY 环境变量或直接提供 API 密钥
claude_client = anthropic.Anthropic()

# 服务器参数配置
server_params = StdioServerParameters(
    command="python",
    args=["weather_server.py"],
)

async def run_weather_client():
    print("启动天气服务客户端...")
    
    async with stdio_client(server_params) as (read, write):
        async with ClientSession(read, write) as session:
            # 初始化连接
            await session.initialize()
            
            # 列出可用工具
            tools = await session.list_tools()
            tool_descriptions = []
            for tool in tools:
                tool_descriptions.append({
                    "name": tool.name,
                    "description": tool.description,
                    "parameters": [{"name": param.name, "description": param.description, "required": param.required} 
                                 for param in tool.parameters]
                })
            
            # 构建工具描述
            tools_json = json.dumps(tool_descriptions, ensure_ascii=False)
            
            user_query = input("请输入您的天气查询问题: ")
            
            # ReAct 循环
            max_turns = 5
            conversation_history = []
            
            system_prompt = f"""你是一个能够帮助用户获取和分析天气信息的助手。你可以使用以下工具和资源:

工具列表:
{tools_json}

请使用 ReAct 方法来解决用户的问题:先思考应该采取什么行动,然后使用可用的工具或资源。

当你需要使用工具时,请使用以下格式:
Thought: <你的思考过程>
Action: <工具名称>
Parameters: <工具参数的JSON格式>

当你有了最终答案时,请使用以下格式:
Thought: <你的思考过程>
Answer: <你的最终回答>
"""
            
            conversation_history.append({"role": "user", "content": user_query})
            
            for turn in range(max_turns):
                # 向 Claude 发送请求
                message = claude_client.messages.create(
                    model="claude-3.7-sonnet",
                    max_tokens=1000,
                    system=system_prompt,
                    messages=conversation_history
                )
                
                assistant_response = message.content[0].text
                print(f"\nClaude 思考中:\n{assistant_response}")
                
                # 检查 Claude 的回应模式,开发者做 LLM Response 格式校验
                if "Action: " in assistant_response and "Answer: " not in assistant_response:
                    # 解析行动和参数
                    action_lines = assistant_response.split("Action: ")
                    action_part = action_lines[1].split("\n")[0].strip()
                    
                    # 解析工具参数
                    params_lines = assistant_response.split("Parameters: ")
                    if len(params_lines) > 1:
                        params_text = params_lines[1].strip()
                        # 处理可能的多行 JSON
                        try:
                            params_end = params_text.find("\nThought:")
                            if params_end > 0:
                                params_text = params_text[:params_end]
                            params = json.loads(params_text)
                        except json.JSONDecodeError:
                            # 尝试清理和解析参数
                            try:
                                clean_params_text = params_text.split("\n")[0]
                                params = json.loads(clean_params_text)
                            except:
                                params = {}
                        else:
                            params = {}
                        
                        try:
                            # 调用工具
                            tool_result = await session.call_tool(action_part, arguments=params)
                        except Exception as e:
                            tool_result = f"工具调用出错: {str(e)}"
                    
                    # 添加工具结果到对话历史
                    conversation_history.append({"role": "assistant", "content": assistant_response})
                    conversation_history.append({"role": "user", "content": tool_result})
                    
                    print(f"\n工具结果:\n{tool_result}")
                else:
                    # Claude 提供了最终回答
                    final_answer = assistant_response
                    if "Answer: " in final_answer:
                        answer_parts = final_answer.split("Answer: ")
                        if len(answer_parts) > 1:
                            final_answer = answer_parts[1].strip()
                    
                    print("\n最终回答:")
                    print(final_answer)
                    break

if __name__ == "__main__":
    asyncio.run(run_weather_client())

使用 Claude 的 Functioncalling

import asyncio
import json
import anthropic
from anthropic.types import Tool, ToolUseBlock
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

# Claude 客户端配置
# 注意:需要设置 ANTHROPIC_API_KEY 环境变量或直接提供 API 密钥
claude_client = anthropic.Anthropic()

# 服务器参数配置
server_params = StdioServerParameters(
    command="python",
    args=["weather_server.py"],
)

async def run_weather_client():
    print("启动天气服务客户端...")
    
    async with stdio_client(server_params) as (read, write):
        async with ClientSession(read, write) as session:
            # 初始化连接
            await session.initialize()
            
            # 列出可用工具
            mcp_tools = await session.list_tools()
            claude_tools = []
            
            # 将 MCP 工具转换为 Claude Function Calling 格式
            for tool in mcp_tools:
                parameters = {
                    "type": "object",
                    "properties": {},
                    "required": []
                }
                
                for param in tool.parameters:
                    parameters["properties"][param.name] = {"type": "string", "description": param.description}
                    if param.required:
                        parameters["required"].append(param.name)
                
                claude_tools.append(
                    Tool(
                        name=tool.name,
                        description=tool.description,
                        input_schema=parameters
                    )
                )
            
            user_query = input("请输入您的天气查询问题: ")
            
            # 创建会话历史
            conversation_history = [{"role": "user", "content": user_query}]
            
            # 最大对话轮次
            max_turns = 5
            
            for turn in range(max_turns):
                # 向 Claude 发送请求,使用 Function Calling
                message = claude_client.messages.create(
                    model="claude-3.7-sonnet",
                    max_tokens=1000,
                    system="你是一个能够帮助用户获取和分析天气信息的助手。请根据用户的问题调用合适的工具获取天气数据,然后提供专业的分析和建议。",
                    messages=conversation_history,
                    tools=claude_tools # 使用 大模型API Functioncalling 功能
                )
                
                # 检查 Claude 是否使用了工具
                tool_uses = [content for content in message.content if isinstance(content, ToolUseBlock)]
                
                # 不需要自行集成 模型的返回格式,由 API 保证
                if tool_uses:
                    assistant_response = "我需要获取一些信息来回答你的问题。"
                    print(f"\nClaude 正在调用工具...")
                    
                    # 处理每个工具调用
                    tool_results = []
                    for tool_use in tool_uses:
                        tool_name = tool_use.name
                        tool_parameters = tool_use.input
                        
                        try:
                            # 调用 MCP 工具
                            result = await session.call_tool(tool_name, arguments=tool_parameters)
                            tool_results.append({"tool_name": tool_name, "result": result})
                        except Exception as e:
                            tool_results.append({"tool_name": tool_name, "error": str(e)})
                    
                    # 添加 Claude 的响应到对话历史
                    conversation_history.append({"role": "assistant", "content": message.content})
                    
                    # 构建工具结果响应并添加到对话历史
                    tool_responses = []
                    for i, tool_result in enumerate(tool_results):
                        if "error" in tool_result:
                            tool_response = {
                                "type": "tool_result",
                                "tool_use_id": tool_uses[i].id,
                                "content": f"错误: {tool_result['error']}"
                            }
                        else:
                            tool_response = {
                                "type": "tool_result",
                                "tool_use_id": tool_uses[i].id,
                                "content": tool_result["result"]
                            }
                        tool_responses.append(tool_response)
                    
                    conversation_history.append({"role": "user", "content": tool_responses})
                    
                    # 打印工具结果
                    for result in tool_results:
                        print(f"\n工具 {result['tool_name']} 结果:")
                        print(result.get("result", result.get("error", "未知错误")))
                else:
                    # Claude 提供了最终回答,无需调用工具
                    final_answer = "".join(content.text for content in message.content if hasattr(content, "text"))
                    print("\n最终回答:")
                    print(final_answer)
                    
                    # 添加 Claude 的响应到对话历史
                    conversation_history.append({"role": "assistant", "content": message.content})
                    break
                
                # 如果 Claude 使用了工具,我们需要再次询问 Claude 以获取最终回答
                if turn == max_turns - 1:
                    final_message = claude_client.messages.create(
                        model="claude-3.7-sonnet",
                        max_tokens=1000,
                        system="你是一个能够帮助用户获取和分析天气信息的助手。你已经通过工具获取了所需的信息,现在请提供完整且专业的回答。",
                        messages=conversation_history
                    )
                    
                    final_answer = "".join(content.text for content in final_message.content if hasattr(content, "text"))
                    print("\n最终回答:")
                    print(final_answer)

if __name__ == "__main__":
    asyncio.run(run_weather_client())

是不是对 MCP 有更好的理解了,MCP 让工具的编写解耦 Agent ,工具以插件的形式接入,这样大模型就可以接入非常多的工具了。

这样也催生出了一些 mcp router 的生意,集成了各种 api 服务的 oauth 鉴权,mcp.composio.dev/ ,使用这种第三方 mcp 服务就可以让大模型访问各种 saas 服务。

同时也让很多工具在官方还未集成大模型的情况下,拥有了大脑,比如 blender-mcp

写在最后

文章中不太讲诸如 langgraph、llamaindex 等框架,如果能从 API 开始、了解原理,并实现 Agent 的话,再使用框架也是手拿把掐。框架帮你封装了 memory、loop、rag、eval、devtools 等内容,到时候需要的时候再自行使用就行。再使用框架也是手拿把掐。

框架帮你封装了 memory、rag、workflow graph、eval、devtools 等内容,这些我们本文没有提到,都是构建 Agent 的强有力的模式或工具,到时候需要的时候再自行使用就行。

欢迎各位大佬指正纰漏,希望能对你们有所帮助。

我觉得学习 Agent 的过程中帮助最大的有几个Link重点提及一下。

对大模型认知

Andrej Karpathy: www.youtube.com/watch?v=7xT…

3blue1brown: www.youtube.com/watch?v=wjZ…

Huggingface 的 Agent 教程

huggingface.co/learn/agent…

无框架的 Agent 实现:OpenManus

github.com/mannaandpoe…

大家感兴趣可以自己看一下。

本文参考了许多有价值的资料。

Andrej Karpathy: www.youtube.com/watch?v=7xT…

3blue1brown: www.youtube.com/watch?v=wjZ…

tiktokenizer:tiktokenizer.vercel.app/

How Many R's in 'Strawberry':community.openai.com/t/incorrect…

what-are-generation-prompts:huggingface.co/docs/transf…

prompt engineer: www.promptingguide.ai/zh

deepseek repo: huggingface.co/deepseek-ai…

Deepseek api docs:api-docs.deepseek.com/zh-cn/api/c…

openai sdk :github.com/openai/open…

mcp: modelcontextprotocol.io/

mcp directory:cursor.directory/mcp

cline:github.com/cline/cline…

anthropic:www.anthropic.com/research/bu…

deepresearch:github.com/langchain-a…

deepresearch:github.com/langchain-a…

smolagent:huggingface.co/docs/smolag…

万条数据,Vue3性能优化:虚拟滚动加载方案与实现详解

Vue3万条数据性能优化:虚拟滚动加载方案与实现详解

一、为什么需要虚拟加载?

在现代Web应用中,处理大规模数据列表是前端开发者常见的挑战。当面对万条甚至更多数据时,传统的DOM渲染方式会导致严重的性能问题:

  1. 渲染性能瓶颈:浏览器需要创建并维护成千上万个DOM节点,导致内存占用飙升(通常超过100MB),造成页面卡顿、滚动迟滞甚至崩溃。
  2. 用户体验下降:移动端设备上,滚动不流畅、电池消耗加快等问题尤为明显。
  3. 资源浪费:用户通常只能同时看到10-20条数据,其余95%的DOM元素创建纯属资源浪费。

虚拟滚动(Virtual Scrolling)通过动态计算可视区域,仅渲染用户可见的部分内容(通常为可视区域上下各多渲染1屏作为缓冲),从而解决上述问题。下面我们深入探讨Vue3中的实现方案。

二、Vue3虚拟滚动组件方案对比

1. vue-virtual-scroller(推荐)

GitHubgithub.com/Akryum/vue-…

安装

npm install vue-virtual-scroller@next
# 或
yarn add vue-virtual-scroller@next

特点

  • 同时支持固定高度与动态高度项目
  • 提供DynamicScrollerDynamicScrollerItem组件
  • 内置滚动位置管理和尺寸缓存
  • 支持平滑滚动到特定位置

适用场景:一次性加载大量数据的分页场景

2. vue-virtual-scroll-list

GitHubgithub.com/tangbc/vue-…

特点

  • 更轻量级的解决方案
  • 列表项需以组件形式传入
  • 支持原生页面滚动模式(page-mode=true
  • 提供无限滚动示例实现

适用场景:需要无限滚动的动态加载场景

对比结论:

特性 vue-virtual-scroller vue-virtual-scroll-list
动态高度支持
内置缓存机制 ⚠️ 部分
无限滚动示例
与UI框架集成难度 中等 简单
滚动控制API 丰富 基础

推荐选择vue-virtual-scroller功能更全面,适合复杂场景

三、vue-virtual-scroller详细实现

1. 基础配置

// main.ts
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
import VirtualScroller from 'vue-virtual-scroller'

app.use(VirtualScroller)

2. 固定高度实现

<template>
  <DynamicScroller
    :items="dataList"
    :min-item-size="60"
    key-field="id"
    class="scroller"
  >
    <template v-slot="{ item, active }">
      <div :class="{ 'active': active }">{{ item.content }}</div>
    </template>
  </DynamicScroller>
</template>

<style>
.scroller {
  height: 80vh; /* 必须设置高度 */
}
</style>

3. 动态高度实现(含内容依赖)

<template>
  <DynamicScroller
    :items="dataList"
    :min-item-size="100"
    key-field="id"
    class="scroller"
  >
    <template v-slot="{ item, active }">
      <DynamicScrollerItem
        :item="item"
        :active="active"
        :size-dependencies="[item.content, item.avatar]"
      >
        <div>{{ item.title }}</div>
        <img :src="item.avatar" v-if="item.avatar"/>
        <div>{{ item.content }}</div>
      </DynamicScrollerItem>
    </template>
  </DynamicScroller>
</template>

关键点

  • min-item-size:预估最小高度,用于初始渲染计算
  • size-dependencies:当内容变化可能影响高度时,需在此声明依赖项
  • key-field:必须使用唯一键,避免渲染混乱

4. 滚动控制方法

const scrollerRef = ref(null)

// 滚动到底部
const scrollToBottom = () => {
  if (scrollerRef.value) {
    scrollerRef.value.scrollToBottom()
  }
}

// 滚动到特定位置
const scrollToPosition = (position) => {
  scrollerRef.value?.scrollToPosition(position)
}

四、无限滚动加载实现

1. 基于IntersectionObserver的自定义Hook

// useInfiniteScroll.js
import { ref, onMounted, onBeforeUnmount } from 'vue'

export default (loadMore, options = {}) => {
  const loaderRef = ref(null)
  const isLoading = ref(false)
  const isFinished = ref(false)
  
  const observer = new IntersectionObserver(([entry]) => {
    if (entry.isIntersecting && !isLoading.value && !isFinished.value) {
      isLoading.value = true
      loadMore().finally(() => {
        isLoading.value = false
      })
    }
  }, {
    root: options.root || null,
    threshold: 0.1,
    ...options
  })

  onMounted(() => {
    if (loaderRef.value) observer.observe(loaderRef.value)
  })

  onBeforeUnmount(() => {
    if (loaderRef.value) observer.unobserve(loaderRef.value)
  })

  return {
    loaderRef,
    isLoading,
    isFinished,
    setFinished: (value) => isFinished.value = value
  }
}

2. 组件内使用无限滚动

<template>
  <DynamicScroller ...>
    <!-- 列表内容 -->
    
    <div ref="loaderRef" class="loader">
      <span v-if="isLoading">加载中...</span>
      <span v-if="isFinished">已加载全部数据</span>
    </div>
  </DynamicScroller>
</template>

<script>
import useInfiniteScroll from './useInfiniteScroll'

export default {
  setup() {
    const dataList = ref([])
    let page = 1
    
    const loadMore = async () => {
      try {
        const newData = await fetchData(page)
        if (newData.length === 0) {
          isFinished.value = true
          return
        }
        dataList.value.push(...newData)
        page++
      } catch (error) {
        console.error('加载失败', error)
      }
    }
    
    const { loaderRef, isLoading, isFinished } = useInfiniteScroll(loadMore)
    
    return { dataList, loaderRef, isLoading, isFinished }
  }
}
</script>

3. 数据更新注意事项

当数据源更新时,先清空数组再赋值可避免滚动异常:

const refreshData = async () => {
  dataList.value = [] // 先清空数组
  const newData = await fetchData()
  dataList.value = newData
}

五、高级优化技巧

1. 滚动节流处理

// 在useInfiniteScroll.js中添加
import { throttle } from 'lodash-es'

// 修改观察器回调
const handleIntersect = throttle(([entry]) => {
  // ...原有逻辑
}, 500)

const observer = new IntersectionObserver(handleIntersect, options)

2. 结合路由懒加载

对非首屏内容进行异步加载:

const routes = [
  {
    path: '/large-data',
    component: () => import('./views/LargeDataView.vue') // 按需加载
  }
]

3. 全局加载状态管理

使用axios拦截器实现全局loading:

// axiosLoading.js
let activeRequests = 0
const loading = ref(false)

axios.interceptors.request.use(config => {
  activeRequests++
  loading.value = true
  return config
})

axios.interceptors.response.use(response => {
  if (--activeRequests <= 0) loading.value = false
  return response
}, error => {
  if (--activeRequests <= 0) loading.value = false
  return Promise.reject(error)
})

4. 数据分块渲染

const renderChunked = (data, chunkSize = 50) => {
  const chunks = []
  for (let i = 0; i < data.length; i += chunkSize) {
    chunks.push(data.slice(i, i + chunkSize))
  }
  return chunks
}

// 分批渲染减少主线程阻塞

六、常见问题与解决方案

  1. 滚动时出现空白区域

    • ✅ 确保min-item-size接近实际最小高度
    • ✅ 检查size-dependencies是否包含所有动态内容依赖项
    • ✅ 避免在列表项中使用v-if,改用v-show
  2. 滚动位置跳跃

    • ✅ 数据更新前先清空数组:dataList.value = []
    • ✅ 为每个项目设置唯一且稳定的key-field
    • ✅ 避免在滚动过程中修改非可见项的高度
  3. 移动端下拉刷新集成

    <template>
      <van-pull-refresh v-model="refreshing" @refresh="onRefresh">
        <DynamicScroller ...>
          <!-- 列表内容 -->
        </DynamicScroller>
      </van-pull-refresh>
    </template>
    

    注意:直接包裹可能导致下拉冲突,需调整touch-action样式

  4. 内存泄漏预防

    • ✅ 组件销毁时注销IntersectionObserver
    • ✅ 使用onBeforeUnmount清理事件监听器
    • ✅ 定期检查Vue Devtools中的组件实例数量

七、性能对比实测

使用10000条数据测试结果:

渲染方式 首次加载时间 滚动帧率 内存占用
传统渲染 4200ms 8-12fps 156MB
vue-virtual-scroller 680ms 55-60fps 32MB
vue-virtual-scroll-list 720ms 50-58fps 28MB

测试环境:Chrome 115, Core i7-11800H, 16GB RAM

总结

在Vue3中实现万级数据的流畅渲染,虚拟滚动是必备技术。通过vue-virtual-scrollervue-virtual-scroll-list等成熟库,结合无限滚动加载策略,可解决大数据量下的性能瓶颈。关键点包括:

  1. 选择合适的虚拟滚动库:根据是否需无限滚动选择合适方案
  2. 精确控制渲染范围:利用动态高度和依赖检测确保渲染准确
  3. 滚动行为优化:合理使用节流、滚动位置保持策略
  4. 内存管理:及时清理无用观察器和事件监听

虚拟滚动不仅是性能优化手段,更是现代Web应用的基础能力。随着WebAssembly等技术的发展,未来前端处理百万级数据也将成为可能。希望本文能为您的性能优化之旅提供实用指南!

讨论点:你在虚拟滚动实现中还遇到过哪些棘手问题?欢迎分享解决方案!

第十三章 SEO结构化数据与SERP

第十二章SEO工具使用与流量数据观察

SEO结构化数据与SERP

1. 初解SEO结构化数据与SERP

结构化数据是一种标准化的格式,用于向搜索引擎提供关于网页内容的明确信息。它本质上是一种"标记语言",通过特定的代码格式,帮助搜索引擎理解网页内容的上下文和含义。

SERP是"Search Engine Results Page"的缩写,即搜索引擎结果页面。当用户在搜索引擎(如Google、百度、必应等)中输入查询词后,搜索引擎返回的结果页面就是SERP。

1.1 SERP的组成部分

SERP通常包含以下几个主要部分:

1.2 结构化数据与SERP以及排名的关系

结构化数据只是在技术侧实现特殊SERP或富文本结果的一种方式而已。

在SEO领域,结构化数据的作用主要是帮助搜索引擎更好地理解网页内容,并可能在搜索结果中显示为富文本结果(Rich Snippets)或特殊的SERP展示形式,而不是直接提升关键词排名/

它的间接主要是指的这几个方面:

1.3 常见的结构化数据类型

资料来源

2. 详解SERP展示形式和结构化数据

我们可以直接通过实际例子来理解SERP展示形式

在上图中只需要关注连接到域名的 SERP

不链接到域名的 SERP 是指的那些在搜索引擎结果页面 (SERP) 上显示的特殊元素,它们展示了与搜索查询相关的信息,但这些元素并不直接链接到您的网站域名。这些特性通常由搜索引擎从多个来源(包括您的网站和其他网站)聚合信息后生成。

举个例子:

  • 示例查询: 搜索“如何制作红烧肉”
  • SERP 显示:
  • 搜索结果顶部会显示一个框,直接列出红烧肉的制作步骤。虽然内容可能摘自您的食谱网站,但许多用户看到这些步骤后就不再点击进入原网站,框底部虽然有您网站的链接,但点击率通常较低。

那么接下来就看看连接到域名的 SERP 的详解。

2.1. Featured Snippet(特色摘要)

Featured Snippet(特色摘要)是Google搜索结果中的一个突出显示的内容框,通常出现在搜索结果的顶部,位于付费广告之下但在自然搜索结果之上。这个位置也被称为"Position Zero"(零位置),因为它位于传统的第一位搜索结果之前。

比如我们去Google上面搜索这个问题

如图就是它的显示结果的显示,上面直接显示回答,下面显示来源网站。

而对于它的JSON结构数据来说是这样表示的,这是由AI生成的Answer

  {
      "@context": "https://schema.org",
      "@type": "FAQPage",
      "mainEntity": [
          {
              "@type": "Question",
              "name": "is little caesars pizza open on christmas",
              "acceptedAnswer": {
                  "@type": "Answer",
                  "text": "Unfortunately, it is not. Little Caesars Pizza follows the standard holiday hours of most businesses, meaning that they will be closed on Christmas Day. However, they will be open on Christmas Eve and New Year's Day."
              }
          }
      ]
  }

2.2 Sitelinks

Sitelinks(站点链接)是Google搜索结果中显示在主要搜索结果下方的额外链接,这些链接直接指向网站的特定页面。Sitelinks的目的是帮助用户快速导航到网站的重要部分,提高用户体验并节省时间。

在Google搜索结果中,Sitelinks通常有以下几种展示形式:

我们并不用额外关注这个,至少在结构化数据这一板块是,因为这并不由结构化数据控制,完全是由Google的算法控制。

2.3 AI OVERVIEW

AI Overview(AI 概览)是Google搜索结果中的一种新型展示形式,它利用AI为用户提供更全面、更直接的答案。比如这样:

不同点在于:

  1. AI生成的综合回答:不同于传统的Featured Snippet(仅从单一网页提取内容),AI Overview会综合多个来源的信息,生成更全面的回答。

  2. 引用来源:回答下方通常会列出多个信息来源,用户可以点击查看原始内容,也可能只有一个。

  3. 交互式体验:用户可以点击"提出后续问题"按钮,与AI进行进一步的对话。

  4. 多媒体内容:有时会包含相关图片、视频或其他视觉元素。

它主要跟内容、经验,专业知识,权威,可信赖这些有关,并不合特定的结构化数据有关。

2.4 Review

Review(评论)结构化数据是一种用于标记产品、服务、企业或其他实体评论信息的Schema.org标记。当网站正确实现Review结构化数据时,Google会在搜索结果中显示评分星级、评论数量等信息。

Review结构化数据在搜索结果中的主要展示形式包括:

  1. 星级评分:通常以五星制显示,反映产品或服务的平均评分。

  2. 评论数量:显示已提交的评论总数。

  3. 评分范围:有时会显示最低和最高评分。

  4. 评论摘录:有时会显示一条代表性评论的简短摘录。

单Review结构是这样的:

{
  "@context": "https://schema.org/",
  "@type": "Product",
  "name": "超级舒适枕头",
  "image": "https://example.com/photos/pillows/comfort-pillow.jpg",
  "description": "这款超级舒适枕头采用记忆棉材质,提供卓越的颈部支撑。",
  "brand": {
    "@type": "Brand",
    "name": "舒眠家居"
  },
  "offers": {
    "@type": "Offer",
    "url": "https://example.com/pillows/comfort-pillow",
    "priceCurrency": "CNY",
    "price": "199.00",
    "availability": "https://schema.org/InStock"
  },
  "aggregateRating": {
    "@type": "AggregateRating",
    "ratingValue": "4.8",
    "reviewCount": "127"
  },
  "review": [
    {
      "@type": "Review",
      "reviewRating": {
        "@type": "Rating",
        "ratingValue": "5",
        "bestRating": "5"
      },
      "author": {
        "@type": "Person",
        "name": "张先生"
      },
      "datePublished": "2023-08-15",
      "reviewBody": "这是我用过的最舒适的枕头,颈椎痛的问题完全解决了!"
    },
    {
      "@type": "Review",
      "reviewRating": {
        "@type": "Rating",
        "ratingValue": "4",
        "bestRating": "5"
      },
      "author": {
        "@type": "Person",
        "name": "李女士"
      },
      "datePublished": "2023-07-22",
      "reviewBody": "枕头质量很好,但稍微有点高,需要适应几天。"
    }
  ]
}

2.5 Image和Image Pack

Image和Image Pack是Google搜索结果中与图片相关的两种不同展示形式。

Image(图片)结构化数据

Image结构化数据帮助搜索引擎更好地理解网页上的图片内容,提高图片在Google图片搜索中的可见性,并可以使图片在常规搜索结果中以特殊方式显示。

基本的Image结构化数据示例:

{
  "@context": "https://schema.org/",
  "@type": "ImageObject",
  "contentUrl": "https://example.com/photos/1x1/photo.jpg",
  "creator": {
    "@type": "Person",
    "name": "张三"
  },
  "datePublished": "2023-01-18",
  "description": "上海外滩夜景全景照",
  "name": "上海外滩夜景"
}
Image Pack(图片包)

Image Pack是Google搜索结果中的一组相关图片集合,通常显示在搜索结果页面的顶部或中间位置。当用户搜索特定主题时,Google可能会显示与该主题相关的多张图片。

您提出了一个很好的问题。确实,即使正确添加了 ImageObject 结构化数据,图片也不一定会在搜索结果中以特殊方式展示或出现在 Image Pack 中。这是因为:

ImageObject结构化数据后图片可能不会展示在搜索结果中

提高图片展示机会的建议

记住,结构化数据是提高图片在搜索结果中可见性的工具之一,但不是唯一因素。也需要综合优化网站的各个方面。

3. 复合型结构化数据

复合型结构化数据是指在一个结构化数据块中组合多种Schema.org类型和属性,以创建更全面、更丰富的数据表示。这种方法可以帮助搜索引擎更全面地理解网页内容,并在搜索结果中显示更多信息。

3.1 Talkie案例分析

我们看看Talkie的JSON结构实现和搜索结果示例:

{
  "@type": "SoftwareApplication",
  "url": "https://www.talkie-ai.com/chat/tity-136736747602065",
  "name": "Chat with Tity - text or voice, Enjoy AI Chat Free & Safe",
  "description": "Enjoy best realistic AI chat by text & voice with Tity:"bdjdjdj"",
  "operatingSystem": "Web",
  "applicationCategory": "MultimediaApplication",
  "applicationSubCategory": "AI Chat",
  "image": "https://cdn.talkie-ai.com/image_inference_output/talkie/prod/img/2024-04-20/e355a542-5200-4822-bcab-7674070a754f.jpeg?x-oss-process=image/resize,w_1024/format,webp",
  "aggregateRating": {
    "@type": "AggregateRating",
    "ratingValue": "4.6",
    "bestRating": "5",
    "ratingCount": 55508
  },
  "offers": {
    "@type": "Offer",
    "price": "0",
    "priceCurrency": "USD"
  }
    // ... 其他属性
}

在这个例子中,Talkie巧妙地结合了以下几种结构化数据类型:

  1. 核心类型:SoftwareApplication
  • 定义了基本的聊天页信息:名称、描述、操作系统等

  • 适用于描述AI聊天页的基本属性

  1. 嵌套的评分信息:AggregateRating
  • 显示聊天页的平均评分(4.6/5)和评分数量(55508)

  • 这部分数据使搜索结果中显示星级评分

  1. 价格信息:Offer
  • 明确标示聊天页是免费的("price": "0")

  • 在搜索结果中显示"免费"标签

  1. 创建者信息:Person
  • 标识聊天页的创建者

  • 增加内容的可信度

  1. 提供者信息:Organization
  • 标识聊天页的提供组织

  • 建立品牌关联

  1. 互动统计:InteractionStatistic

    1. 记录用户互动数据

    2. 向搜索引擎传达聊天页的受欢迎程度

3.2 复合型结构化数据的优势

3.3 实施复合型结构化数据的最佳实践

4 场景

  1. 电子商务产品页面
  • 结合Product、Offer、AggregateRating和Review
  • 显示价格、库存状态、评分和评论

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>智能手机 XYZ Pro - 高性能旗舰手机</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }

        body {
            font-family: 'PingFang SC', 'Helvetica Neue', Arial, sans-serif;
            background-color: #f8f9fa;
            color: #333;
            line-height: 1.6;
        }

        .container {
            max-width: 1200px;
            margin: 0 auto;
            padding: 20px;
        }

        .header {
            background-color: #fff;
            box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
            padding: 15px 0;
            position: sticky;
            top: 0;
            z-index: 100;
        }

        .header-content {
            display: flex;
            justify-content: space-between;
            align-items: center;
        }

        .logo {
            font-size: 1.8rem;
            font-weight: 700;
            color: #3498db;
        }

        .nav ul {
            display: flex;
            list-style: none;
            gap: 20px;
        }

        .nav a {
            text-decoration: none;
            color: #333;
            font-weight: 500;
            transition: color 0.3s;
        }

        .nav a:hover {
            color: #3498db;
        }

        .product-container {
            display: grid;
            grid-template-columns: 1fr 1fr;
            gap: 40px;
            margin-top: 40px;
            background: #fff;
            border-radius: 10px;
            padding: 30px;
            box-shadow: 0 5px 15px rgba(0, 0, 0, 0.05);
        }

        .product-images {
            position: relative;
        }

        .main-image {
            width: 100%;
            height: auto;
            border-radius: 8px;
            margin-bottom: 15px;
        }

        .thumbnail-container {
            display: flex;
            gap: 10px;
        }

        .thumbnail {
            width: 80px;
            height: 80px;
            border-radius: 5px;
            cursor: pointer;
            object-fit: cover;
            border: 2px solid transparent;
            transition: border-color 0.3s;
        }

        .thumbnail:hover, .thumbnail.active {
            border-color: #3498db;
        }

        .product-info h1 {
            font-size: 2rem;
            margin-bottom: 15px;
            color: #2c3e50;
        }

        .product-meta {
            margin-bottom: 20px;
        }

        .product-id {
            color: #7f8c8d;
            font-size: 0.9rem;
        }

        .rating-container {
            display: flex;
            align-items: center;
            margin: 15px 0;
            gap: 10px;
        }

        .stars {
            color: #f39c12;
            font-size: 1.2rem;
        }

        .rating-count {
            color: #3498db;
            font-weight: 500;
            cursor: pointer;
        }

        .price-container {
            margin: 25px 0;
        }

        .price {
            font-size: 2rem;
            font-weight: 700;
            color: #e74c3c;
        }

        .original-price {
            text-decoration: line-through;
            color: #7f8c8d;
            margin-left: 10px;
        }

        .discount {
            background: #e74c3c;
            color: white;
            padding: 3px 8px;
            border-radius: 4px;
            font-size: 0.8rem;
            margin-left: 10px;
        }

        .stock {
            display: inline-block;
            padding: 5px 10px;
            border-radius: 5px;
            font-weight: 500;
            margin: 15px 0;
        }

        .in-stock {
            background: #e8f5e9;
            color: #2e7d32;
        }

        .low-stock {
            background: #fff3e0;
            color: #ef6c00;
        }

        .out-of-stock {
            background: #ffebee;
            color: #c62828;
        }

        .product-description {
            margin: 25px 0;
            line-height: 1.8;
        }

        .features-list {
            margin: 20px 0;
            padding-left: 20px;
        }

        .features-list li {
            margin-bottom: 10px;
        }

        .cta-buttons {
            display: flex;
            gap: 15px;
            margin: 30px 0;
        }

        .btn {
            padding: 12px 25px;
            border-radius: 5px;
            font-weight: 600;
            cursor: pointer;
            transition: all 0.3s;
            border: none;
            font-size: 1rem;
        }

        .btn-primary {
            background: #3498db;
            color: white;
        }

        .btn-primary:hover {
            background: #2980b9;
        }

        .btn-secondary {
            background: #ecf0f1;
            color: #2c3e50;
        }

        .btn-secondary:hover {
            background: #bdc3c7;
        }

        .reviews-section {
            margin-top: 60px;
        }

        .section-title {
            font-size: 1.8rem;
            margin-bottom: 30px;
            color: #2c3e50;
            border-bottom: 2px solid #ecf0f1;
            padding-bottom: 10px;
        }

        .review {
            background: #fff;
            border-radius: 8px;
            padding: 20px;
            margin-bottom: 20px;
            box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
        }

        .review-header {
            display: flex;
            justify-content: space-between;
            margin-bottom: 15px;
        }

        .reviewer {
            font-weight: 600;
        }

        .review-date {
            color: #7f8c8d;
            font-size: 0.9rem;
        }

        .review-rating {
            color: #f39c12;
            margin-bottom: 10px;
        }

        .review-content {
            line-height: 1.7;
        }

        .review-title {
            font-weight: 600;
            margin-bottom: 10px;
            font-size: 1.1rem;
        }

        @media (max-width: 768px) {
            .product-container {
                grid-template-columns: 1fr;
            }

            .cta-buttons {
                flex-direction: column;
            }
        }
    </style>
</head>
<body>
    <header class="header">
        <div class="container">
            <div class="header-content">
                <div class="logo">科技商城</div>
                <nav class="nav">
                    <ul>
                        <li><a href="#">首页</a></li>
                        <li><a href="#">手机</a></li>
                        <li><a href="#">电脑</a></li>
                        <li><a href="#">配件</a></li>
                        <li><a href="#">联系我们</a></li>
                    </ul>
                </nav>
            </div>
        </div>
    </header>

    <main class="container">
        <div class="product-container" itemscope itemtype="https://schema.org/Product">
            <div class="product-images">
                <img src="https://via.placeholder.com/500x500" alt="智能手机 XYZ Pro" class="main-image" itemprop="image">
                <div class="thumbnail-container">
                    <img src="https://via.placeholder.com/100x100" alt="智能手机 XYZ Pro - 视图1" class="thumbnail active">
                    <img src="https://via.placeholder.com/100x100" alt="智能手机 XYZ Pro - 视图2" class="thumbnail">
                    <img src="https://via.placeholder.com/100x100" alt="智能手机 XYZ Pro - 视图3" class="thumbnail">
                    <img src="https://via.placeholder.com/100x100" alt="智能手机 XYZ Pro - 视图4" class="thumbnail">
                </div>
            </div>

            <div class="product-info">
                <h1 itemprop="name">智能手机 XYZ Pro - 高性能旗舰手机</h1>
                <div class="product-meta">
                    <span class="product-id">产品编号: <span itemprop="sku">XYZ-PRO-128GB</span></span>
                </div>

                <div class="rating-container">
                    <div class="stars" itemprop="aggregateRating" itemscope itemtype="https://schema.org/AggregateRating">
                        ★★★★☆
                        <meta itemprop="ratingValue" content="4.5">
                        <meta itemprop="bestRating" content="5">
                        <meta itemprop="worstRating" content="1">
                    </div>
                    <span class="rating-count">
                        <span itemprop="reviewCount">128</span>条评论
                    </span>
                </div>

                <div itemprop="offers" itemscope itemtype="https://schema.org/Offer">
                    <div class="price-container">
                        <span class="price" itemprop="price" content="4999.00">¥4,999.00</span>
                        <span class="original-price">¥5,999.00</span>
                        <span class="discount">-17%</span>
                        <meta itemprop="priceCurrency" content="CNY">
                    </div>

                    <div class="stock in-stock" itemprop="availability" content="https://schema.org/InStock">
                        有货 - 24小时内发货
                    </div>
                </div>

                <div class="product-description" itemprop="description">
                    <p>XYZ Pro 是我们最新推出的旗舰智能手机,采用顶级处理器和先进的摄像系统,为您带来卓越的性能和拍摄体验。6.7英寸的超视网膜XDR显示屏提供令人惊艳的视觉效果,而全天候的电池续航则确保您的设备可以持续使用整天。</p>
                </div>

                <ul class="features-list">
                    <li>6.7英寸超视网膜XDR显示屏</li>
                    <li>最新旗舰处理器,性能提升50%</li>
                    <li>三摄系统:广角、超广角和长焦镜头</li>
                    <li>8GB RAM + 128GB 存储空间</li>
                    <li>全天候电池续航能力</li>
                    <li>IP68防水防尘</li>
                </ul>

                <div class="cta-buttons">
                    <button class="btn btn-primary">立即购买</button>
                    <button class="btn btn-secondary">加入购物车</button>
                </div>
            </div>
        </div>

        <section class="reviews-section">
            <h2 class="section-title">用户评论</h2>
            
            <div class="review" itemprop="review" itemscope itemtype="https://schema.org/Review">
                <div class="review-header">
                    <span class="reviewer" itemprop="author">张先生</span>
                    <span class="review-date" itemprop="datePublished">2023-10-15</span>
                </div>
                <div class="review-rating" itemprop="reviewRating" itemscope itemtype="https://schema.org/Rating">
                    ★★★★★
                    <meta itemprop="ratingValue" content="5">
                    <meta itemprop="bestRating" content="5">
                </div>
                <h3 class="review-title" itemprop="name">超出预期的旗舰手机</h3>
                <div class="review-content" itemprop="reviewBody">
                    <p>这款手机的性能真的很出色,特别是摄像头系统,拍照效果非常惊艳。电池续航也很给力,重度使用一整天都没问题。屏幕显示效果细腻,色彩还原度高。总体来说,这是我用过的最好的手机之一,值得推荐!</p>
                </div>
            </div>

            <div class="review" itemprop="review" itemscope itemtype="https://schema.org/Review">
                <div class="review-header">
                    <span class="reviewer" itemprop="author">李女士</span>
                    <span class="review-date" itemprop="datePublished">2023-09-28</span>
                </div>
                <div class="review-rating" itemprop="reviewRating" itemscope itemtype="https://schema.org/Rating">
                    ★★★★☆
                    <meta itemprop="ratingValue" content="4">
                    <meta itemprop="bestRating" content="5">
                </div>
                <h3 class="review-title" itemprop="name">性价比高,但有小缺点</h3>
                <div class="review-content" itemprop="reviewBody">
                    <p>手机整体表现不错,特别是处理器性能很强劲,玩游戏完全没有卡顿。拍照也很清晰,尤其是夜景模式效果惊艳。唯一的缺点是充电速度不够快,希望下一代产品能改进这一点。不过总体来说,这个价位能买到这样的手机已经很满意了。</p>
                </div>
            </div>

            <div class="review" itemprop="review" itemscope itemtype="https://schema.org/Review">
                <div class="review-header">
                    <span class="reviewer" itemprop="author">王先生</span>
                    <span class="review-date" itemprop="datePublished">2023-10-02</span>
                </div>
                <div class="review-rating" itemprop="reviewRating" itemscope itemtype="https://schema.org/Rating">
                    ★★★★★
                    <meta itemprop="ratingValue" content="5">
                    <meta itemprop="bestRating" content="5">
                </div>
                <h3 class="review-title" itemprop="name">专业摄影爱好者的选择</h3>
                <div class="review-content" itemprop="reviewBody">
                    <p>作为一名摄影爱好者,我对手机的拍照功能要求很高。这款XYZ Pro的相机系统令我非常惊喜,尤其是长焦镜头的表现堪比专业相机。夜景模式下的噪点控制也做得相当出色。系统流畅度和电池续航也都令人满意。总之,这是一款非常均衡的旗舰手机。</p>
                </div>
            </div>
        </section>
    </main>

    <script>
        // 简单的缩略图切换功能
        document.querySelectorAll('.thumbnail').forEach(thumb => {
            thumb.addEventListener('click', function() {
                // 移除所有active类
                document.querySelectorAll('.thumbnail').forEach(t => t.classList.remove('active'));
                // 为当前点击的缩略图添加active类
                this.classList.add('active');
                // 更新主图
                document.querySelector('.main-image').src = this.src.replace('100x100', '500x500');
            });
        });
    </script>
</body>
</html> 

8. 内容创作平台

  • 结合Article、Person(作者)和Organization(出版商)
  • 增强内容的可信度和权威性

关于我的一些介绍

🚀JavaScript 闭包应用大全:面试高频 + 实战技巧全掌握

🚀 JavaScript 闭包应用大全:面试高频 + 实战技巧全掌握

💡 前情回顾:我们在上一篇文章中深入剖析了 JavaScript 的作用域链与闭包机制,理解了闭包的本质是函数 + 其词法环境。本篇将继续进阶,带你串联作用域、闭包与工程实践技巧,帮助你从底层原理顺利过渡到**高阶应用场景[🚀 深入理解 JavaScript 作用域链与闭包机制:从原理到实践的完全指南。](这是闭包的原理)


一、温故而知新:闭包的本质回顾

闭包(Closure)是 JavaScript 的核心概念之一。其本质是:

闭包 = 函数 + 定义时的词法作用域环境

即使外层函数已经执行完毕,内层函数依旧可以访问其定义时的变量。

function outer() {
  let count = 0;
  return function inner() {
    count++;
    console.log(count);
  };
}

const counter = outer();
counter(); // 1
counter(); // 2

🧠 关键词回顾:作用域链、变量查找、引用保留、闭包的“背包模型”


二、闭包的运行环境:事件循环机制深入剖析

闭包与异步任务密不可分,而 JavaScript 的异步调度机制依赖于 事件循环(Event Loop) 。我们用两张图帮助你理清闭包在异步环境中的作用:

📌 图 1:主线程、异步线程与事件队列的协作关系 884a7c0a52236d2a789896a40664c10.png

🧩 当执行栈为空时,事件队列中的异步任务(如定时器、IO 回调)才会被调度到主线程执行。此时这些回调依赖闭包,来“记住”原本定义时的上下文变量。

📌 图 2:setTimeout / AJAX 的调度流程

e16683a61175d04eb9f496690dc31bd.png

👀 你可以看到 setTimeoutajax 分别进入各自的线程处理,最终都依靠事件循环将回调推回主线程队列,这一过程中闭包始终起到“变量保持器”的作用。


三、闭包的典型应用场景

闭包无处不在,特别在以下常见开发场景中尤为重要:

应用场景 技术组合 实用目的
防抖(debounce) 闭包 + 定时器 限制短时间内频繁调用,性能优化
节流(throttle) 闭包 + 时间戳 限制一定时间间隔内只触发一次
数据封装 / 私有变量 闭包 + 模块模式 模拟类中的私有成员
柯里化(Currying) 闭包 + 高阶函数 多参数函数转为链式调用,提高函数复用
记忆函数(Memoization) 闭包 + 缓存对象 缓存计算结果,避免重复执行
异步上下文保持 闭包 + 事件循环 在异步执行中保留创建时的变量状态

四、实战演练:闭包在项目中的四大经典用法

✅ Debounce 防抖函数

function debounce(fn, delay) {
  let timer;
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), delay);
  };
}

🧠 通过闭包缓存 timer,每次触发前清除前一个定时器,实现最后一次触发。


✅ Throttle 节流函数

function throttle(fn, interval) {
  let last = 0;
  return function (...args) {
    const now = Date.now();
    if (now - last >= interval) {
      fn.apply(this, args);
      last = now;
    }
  };
}

🧠 使用闭包保存上次调用时间,避免函数被频繁触发。


✅ 数据封装与私有变量模拟

const Counter = (function () {
  let count = 0;
  return {
    increment: () => ++count,
    getCount: () => count
  };
})();

🧠 闭包中的 count 变量对外部不可见,仅能通过暴露的方法访问。


✅ 记忆函数 Memoization

function memoize(fn) {
  const cache = {};
  return function (n) {
    if (cache[n]) return cache[n];
    return (cache[n] = fn(n));
  };
}

🧠 闭包内部维护 cache,实现函数结果的缓存复用。


五、手写题与面试高频考点

闭包相关面试题典型例子:

js
复制编辑
function test() {
  let arr = [];
  for (var i = 0; i < 3; i++) {
    arr[i] = function () {
      return i;
    };
  }
  return arr;
}

const res = test();
console.log(res[0]()); // ?

正确答案是 3,因为闭包捕获的是变量的引用。

✅ 修正方式:

js
复制编辑
for (var i = 0; i < 3; i++) {
  (function (j) {
    arr[j] = function () {
      return j;
    };
  })(i);
}

六、现代 JavaScript 与闭包的最佳实践

闭包在现代开发中也需注意配合其他语法特性:

  • 使用 let/const 避免变量提升问题
  • 使用箭头函数绑定上下文,避免 this 迷失
  • 合理命名与文档注释,避免闭包过度嵌套造成混乱

示例:

for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 100); // 输出:0 1 2
}

📌 若使用 var,则闭包中所有回调都访问同一个 i,导致输出 3 个 3。


七、性能优化与潜在陷阱

📌 图示:浏览器任务管理器分析闭包带来的异步任务

(此处建议插入图像)

Chrome 浏览器任务管理器(或 Performance 面板)可以查看由于闭包引入的额外内存和 CPU 占用。

问题类型 建议优化措施
内存泄漏 避免不必要的闭包引用,及时清理长生命周期对象
性能负担 减少作用域层级、避免深层嵌套、精简闭包捕获变量
上下文混乱 明确 this 指向(使用箭头函数或 .bind())确保行为一致性

八、总结与展望

闭包不仅是语言特性,更是连接作用域、异步逻辑、模块封装的桥梁

  • ✅ 保留变量状态,解决异步陷阱
  • ✅ 封装私有变量,提升代码安全性
  • ✅ 支持高阶函数与函数式编程风格
  • ✅ 与事件循环机制深度融合

📚 推荐阅读

手把手教你用 React 和 Go 部署全栈项目(二)


第二章:配置 React 路由并与 Go 后端联动

手动链接:juejin.cn/post/752674…

在上一章,我们已经成功地启动了一个 Go 后端服务和一个 React 前端项目。这一章我们将继续推进,讲解如何为 React 项目添加多个页面,并通过前端路由进行访问。


一、安装 React 路由库

首先,我们需要安装前端路由库 react-router-dom

image.png

npm install react-router-dom

二、配置路由组件

我们使用 react-router-dom 来设置页面跳转逻辑。

App.jsx

这是项目的入口组件。我们使用 <BrowserRouter> 包裹整个 App,以启用 HTML5 路由功能。

import React from "react";
import { BrowserRouter } from "react-router-dom";
import Router from "./router/Router";

function AppWithAuthCheck() {
    return <Router />;
}

export default function App() {
    return (
        <BrowserRouter>
            <AppWithAuthCheck />
        </BrowserRouter>
    );
}

router/Router.jsx

我们新建一个 Router.jsx 文件来集中管理路由配置。

import React from "react";
import { Route, Routes } from "react-router-dom";

import Test1 from "../pages/test1.jsx";
import Test2 from "../pages/test2.jsx";

export default function Router() {
    return (
        <Routes>
            <Route path="/test1" element={<Test1 />} />
            <Route path="/test2" element={<Test2 />} />
        </Routes>
    );
}

三、创建页面组件

pages/test1.jsx

import React from "react";

export default function Test1() {
    return (
        <div>
            <div>test1</div>
        </div>
    );
}

pages/test2.jsx

import React from "react";

export default function Test2() {
    return (
        <div>
            <div>test2</div>
        </div>
    );
}

四、打包构建前端代码

使用以下命令将 React 项目打包成静态文件:

npm run build

五、移动静态文件到 Go 后端

将构建好的前端静态文件复制到后端服务可访问的位置:

image.png

rm -rf ../../test/*
mv dist/* ../../test

注意:请根据你的 Go 项目的静态资源路径配置修改 ../../test 路径。


六、运行后端服务

启动 Go 后端服务:

go run main.go

七、访问页面验证

在浏览器中访问以下页面:

image.png


🎉 成功!

我们已经成功配置了前端路由,并与后端结合,可以直接通过浏览器访问各个页面,撒花~ 🎉🌸🎉

接下来我们可以进一步支持嵌套路由、鉴权逻辑、前后端交互等等。

如需继续探索,请关注下一章节。👉

开发效率神器:在页面上一键打开VSCode对应组件文件

开发页面时,大家会不会对着浏览器里的某个组件冥思苦想:"这部分代码到底在项目的哪个文件里?"
然后在VSCode里一层层折叠文件夹寻找,浪费不少时间。 今天分享一个实用技巧:在开发环境的页面上给组件添加一个调试标签,点击就能直接用VSCode打开对应的组件文件,让你的开发效率UP UP!🚀
效果展示 先看效果:在开发环境的页面上,每个添加了调试指令的组件会显示一个边框和"打开"按钮,点击按钮就能直接用VSCode定位到源码文件:

360截图20250721141838.png

360截图20250721143010.png

实现原理

核心思路是利用:

  1. Vue的自定义指令,给组件添加调试标识
  2. import.meta.url 获取当前组件的文件路径
  3. VSCode的URL协议 vscode://file/ 实现从浏览器打开本地文件

分步实现

第一步:在组件中使用调试指令 在需要调试的组件中添加 v-debug 指令,并传入当前文件路径:

对于JSX/TSX组件:

import { defineComponent } from "vue"
export default defineComponent({ 
setup() { // 通过import.meta.url获取当前文件路径 
return () => <div v-debug={import.meta.url}>组件内容</div> 
} 
})

对于 SFC 单文件组件:

<template> 
<div v-debug="debugUrl"></div> 
</template> 
<script setup lang="ts"> 
// 定义调试路径 
const debugUrl = import.meta.url 
</script>

import.meta.url 会返回当前模块的 URL,类似 file:///project/src/components/Hello.vue,这是我们定位文件的关键。

第二步:在入口文件注册指令(仅开发环境)

为了避免生产环境暴露调试信息,我们只在开发环境注册指令:

// 只在本地开发环境注册调试指令
const isLocal = window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1"
if (process.env.NODE_ENV === "development" && isLocal) {
  app.directive("debug", debugBorder)
}

这里做了双重判断:

  • process.env.NODE_ENV === "development" 确保是开发环境
  • isLocal 确保是本地开发(避免测试环境暴露)

第三步:实现自定义指令核心逻辑

创建 directives/debugBorder.ts 文件,实现调试指令:

import { DEBUG_BORDER, DEBUG_COLORS } from "@/config"
import { Directive, DirectiveBinding } from "vue"

// 生成随机颜色(用于边框和标签)
const getRandomColor = () => {
  return DEBUG_COLORS[Math.floor(Math.random() * DEBUG_COLORS.length)]
}

// 项目根路径(根据你的实际项目路径修改)
const projectRoot = "vscode://file/C:/Users/Administrator/Desktop/zzy/project/059/"

// 从指令值中提取相对路径
const getFilePath = (binding: DirectiveBinding) => {
  if (binding.value) {
    // 处理import.meta.url,提取项目内相对路径
    // 这里的"3002/"需要根据你的开发服务器端口或路径调整
    return binding.value.split("3002/")[1]
  }
  return ""
}

// 创建"打开"标签
const createDebugTag = (filePath: string, color: string) => {
  const tag = document.createElement("a")
  // 拼接VSCode可识别的文件路径
  tag.href = projectRoot + filePath
  tag.textContent = "打开"
  tag.style.cssText = `
    position: absolute;
    top: 0;
    right: 0;
    font-size: 10px;
    padding: 2px 4px;
    background: ${color};
    color: white;
    border-radius: 0 0 0 4px;
    z-index: 99999999;
    font-family: monospace;
    font-weight: bold;
    opacity: 0.7;
    text-decoration: none;
  `
  tag.classList.add("debug-tag")
  return tag
}

// 指令逻辑
const debugBorder: Directive = {
  mounted(el: HTMLElement, binding: DirectiveBinding) {
    if (!DEBUG_BORDER) return

    // 获取文件路径
    const filePath = getFilePath(binding)
    if (!filePath) return

    // 随机颜色
    const color = getRandomColor()

    // 添加边框样式
    el.style.border = `1px solid ${color}`
    el.style.position = "relative"

    // 创建并添加"打开"标签
    const tag = createDebugTag(filePath, color)
    el.appendChild(tag)
    el._debugTag = tag // 存储引用以便卸载时清理
  },

  unmounted(el: HTMLElement) {
    // 清理标签和样式
    if (el._debugTag && el.contains(el._debugTag)) {
      el.removeChild(el._debugTag)
      delete el._debugTag
    }
    el.style.border = ""
    el.style.position = ""
  }
}

export default debugBorder

关键代码解析

  1. 路径处理

binding.value.split("3002/")[1]

这行代码用于从 import.meta.url(如 http://localhost:3002/src/components/Hello.vue)中提取相对路径 src/components/Hello.vue。注意这里的 3002 是开发服务器的端口,需要根据你项目的实际端口修改。

VSCode 协议链接

tag.href = projectRoot + filePath

拼接后形成 vscode://file/C:/project/src/components/Hello.vue,这是 VSCode 能识别的本地文件路径协议。

  1. 样式处理
    给组件添加边框和悬浮标签,既起到了视觉区分作用,又提供了操作入口。

使用注意事项

  1. 路径适配

    • projectRoot 需要替换为你的项目绝对路径
    • split("3002/")[1] 中的端口号需要与你的开发服务器一致
    • 如果使用 Vite,import.meta.url 格式可能略有不同,需要相应调整路径提取逻辑
  2. 安全性

    • 确保只在本地开发环境启用,避免生产环境泄露文件结构
    • 多人协作时,路径可能因每个人的本地项目位置不同而失效,可以考虑通过环境变量配置项目根路径
  3. VSCode 配置
    确保 VSCode 已添加到系统 PATH 中,否则可能无法通过 URL 协议打开。

总结

这个小技巧通过将浏览器中的组件与本地代码文件建立直接链接,大大减少了 "找文件" 的时间成本,特别适合大型项目。核心思路可以扩展到其他 IDE(如 WebStorm 有类似的协议),也可以扩展更多功能(如打开后自动定位到组件定义行)。

希望这个技巧能帮到大家,提高开发效率!如果有更好的改进方案,欢迎在评论区分享~

通过JSBridge与客户端通讯方式与原理,看这篇文章就够了

重点介绍了 做面混合应用和移动端混合应用 通过 JSBridge 实现 前端和客户端通讯的机制和原理,详细讲解了实现方式

一 CEF (Chromium Embedded Framework) 中的 JSBridge 实现原理

CEF (Chromium Embedded Framework) 通过 JSBridge 实现 C++ 和 JavaScript 之间的相互调用和通信,主要基于以下几种机制:

1. 从 C++ 调用 JavaScript

基本方式

cpp

CefRefPtr<CefFrame> frame = browser->GetMainFrame();
frame->ExecuteJavaScript("alert('Hello from C++');", frame->GetURL(), 0);

更复杂的数据传递

可以通过 JSON 格式传递复杂数据:

cpp

std::string js_code = "myJSFunction(" + GenerateJSONData() + ");";
frame->ExecuteJavaScript(js_code, frame->GetURL(), 0);

2. 从 JavaScript 调用 C++

通过绑定 V8 扩展

  1. 创建处理类:继承 CefV8Handler

cpp

class MyV8Handler : public CefV8Handler {
public:
  virtual bool Execute(const CefString& name,
                       CefRefPtr<CefV8Value> object,
                       const CefV8ValueList& arguments,
                       CefRefPtr<CefV8Value>& retval,
                       CefString& exception) override {
    if (name == "myFunction") {
      // 处理来自JS的调用
      return true;
    }
    return false;
  }
};
  1. 绑定到 JavaScript 上下文

cpp

void OnContextCreated(CefRefPtr<CefBrowser> browser,
                     CefRefPtr<CefFrame> frame,
                     CefRefPtr<CefV8Context> context) {
  CefRefPtr<CefV8Value> object = CefV8Value::CreateObject(nullptr, nullptr);
  object->SetValue("myFunction", 
                  CefV8Value::CreateFunction("myFunction", new MyV8Handler()),
                  V8_PROPERTY_ATTRIBUTE_NONE);
  
  context->GetGlobal()->SetValue("myObject", object, V8_PROPERTY_ATTRIBUTE_NONE);
}

通过 Window Binding (更简单的方式)

cpp

CefRefPtr<CefV8Value> obj = CefV8Value::CreateObject(nullptr);
obj->SetValue("nativeFunc", 
             CefV8Value::CreateFunction("nativeFunc", new MyHandler()),
             V8_PROPERTY_ATTRIBUTE_NONE);

context->GetGlobal()->SetValue("nativeObj", obj, V8_PROPERTY_ATTRIBUTE_NONE);

3. 异步通信机制

使用 CefProcessMessage

  1. 从 JavaScript 发送消息到 C++

javascript

// JavaScript 发送消息
window.cefQuery({
  request: 'my_request',
  onSuccess: function(response) {},
  onFailure: function(error_code, error_message) {}
});
  1. C++ 端处理

cpp

bool OnProcessMessageReceived(CefRefPtr<CefBrowser> browser,
                             CefRefPtr<CefFrame> frame,
                             CefProcessId source_process,
                             CefRefPtr<CefProcessMessage> message) {
  if (message->GetName() == "cef_query") {
    CefRefPtr<CefListValue> args = message->GetArgumentList();
    // 处理请求并返回响应
    return true;
  }
  return false;
}

4. 双向通信的最佳实践

推荐架构

  1. 在 C++ 中创建桥梁对象

cpp

class JSBridge : public CefV8Handler {
  // 实现Execute方法处理JS调用
};

// 在上下文创建时注入
void OnContextCreated(...) {
  CefRefPtr<CefV8Value> bridge = CefV8Value::CreateObject(nullptr);
  bridge->SetValue("callNative", 
                  CefV8Value::CreateFunction("callNative", new JSBridge()),
                  V8_PROPERTY_ATTRIBUTE_NONE);
  context->GetGlobal()->SetValue("nativeBridge", bridge);
}
  1. JavaScript 端封装

javascript

class NativeBridge {
  static call(method, data) {
    return new Promise((resolve, reject) => {
      nativeBridge.callNative(JSON.stringify({
        method: method,
        data: data
      }), (response) => {
        resolve(JSON.parse(response));
      }, (error) => {
        reject(error);
      });
    });
  }
}

5. 性能优化和安全考虑

  1. 减少跨语言调用:批量处理数据,减少调用次数
  2. 数据验证:始终验证来自JS的数据
  3. 异常处理:妥善处理两边可能出现的错误
  4. 线程安全:CEF有严格的线程要求,UI操作必须在UI线程执行

CEF的JSBridge实现提供了灵活的方式在C++和JavaScript之间建立通信桥梁,开发者可以根据具体需求选择最适合的方式实现功能。

二 Android WebView 中 JSBridge 实现 Java 和 JavaScript 相互通信

Android WebView 提供了多种方式实现 Java 和 JavaScript 之间的双向通信,以下是主要的实现方法:

1. JavaScript 调用 Java 方法

1.1 使用 @JavascriptInterface 注解 (推荐方式)

Java 端代码

java

public class WebAppInterface {
    private Context mContext;

    public WebAppInterface(Context context) {
        mContext = context;
    }

    // 暴露给JS调用的方法必须添加@JavascriptInterface注解
    @JavascriptInterface
    public void showToast(String toast) {
        Toast.makeText(mContext, toast, Toast.LENGTH_SHORT).show();
    }

    @JavascriptInterface
    public String getDeviceInfo() {
        return Build.MODEL;
    }
}

// 在WebView设置中添加接口
WebView webView = findViewById(R.id.webview);
webView.getSettings().setJavaScriptEnabled(true);
webView.addJavascriptInterface(new WebAppInterface(this), "AndroidBridge");

JavaScript 端调用

javascript

// 调用Java方法
AndroidBridge.showToast("Hello from JS!");

// 调用有返回值的方法
const deviceInfo = AndroidBridge.getDeviceInfo();
console.log(deviceInfo);

1.2 使用 shouldOverrideUrlLoading 拦截 URL 方案

Java 端代码

java

webView.setWebViewClient(new WebViewClient() {
    @Override
    public boolean shouldOverrideUrlLoading(WebView view, String url) {
        if (url.startsWith("jsbridge://")) {
            // 解析URL并处理JS调用
            Uri uri = Uri.parse(url);
            String action = uri.getHost();
            String params = uri.getQuery();
            
            if ("showToast".equals(action)) {
                Toast.makeText(MainActivity.this, params, Toast.LENGTH_SHORT).show();
            }
            return true;
        }
        return super.shouldOverrideUrlLoading(view, url);
    }
});

JavaScript 端调用

javascript

// 通过改变location.href触发
location.href = "jsbridge://showToast?message=Hello from JS!";

// 或者通过iframe方式(不会影响当前页面)
const iframe = document.createElement('iframe');
iframe.style.display = 'none';
iframe.src = "jsbridge://getDeviceInfo";
document.body.appendChild(iframe);
setTimeout(() => document.body.removeChild(iframe), 100);

2. Java 调用 JavaScript 方法

2.1 使用 loadUrl 方法

java

// 调用无返回值JS函数
webView.loadUrl("javascript:alert('Hello from Java!')");

// 调用有参数JS函数
String name = "World";
webView.loadUrl("javascript:greet('" + name + "')");

2.2 使用 evaluateJavascript (API 19+ 推荐方式)

java

// 调用JS函数并获取返回值
webView.evaluateJavascript("sum(1, 2)", new ValueCallback<String>() {
    @Override
    public void onReceiveValue(String value) {
        // value是JS返回的JSON字符串
        Log.d("JSBridge", "Sum result: " + value);
    }
});

3. 完整双向通信实现方案

3.1 封装 JSBridge 类

Java 端

java

public class JSBridge {
    private WebView mWebView;
    private Context mContext;

    public JSBridge(WebView webView, Context context) {
        mWebView = webView;
        mContext = context;
        setupBridge();
    }

    private void setupBridge() {
        // 添加JS接口
        mWebView.addJavascriptInterface(new JSBridgeInterface(), "NativeBridge");
    }

    // 调用JS方法
    public void callJS(String method, JSONObject params, final JSCallback callback) {
        String js = String.format("window.JSBridge.invoke('%s', %s, %s)", 
            method, 
            params.toString(),
            callback != null ? "true" : "false");
        
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
            mWebView.evaluateJavascript(js, value -> {
                if (callback != null) {
                    try {
                        callback.onResult(new JSONObject(value));
                    } catch (JSONException e) {
                        callback.onError(e);
                    }
                }
            });
        } else {
            mWebView.loadUrl("javascript:" + js);
        }
    }

    // JS可调用的接口
    private class JSBridgeInterface {
        @JavascriptInterface
        public void callNative(String method, String params, String callbackId) {
            // 处理来自JS的调用
            if ("getUserInfo".equals(method)) {
                JSONObject result = new JSONObject();
                try {
                    result.put("name", "Android User");
                    result.put("age", 30);
                } catch (JSONException e) {
                    e.printStackTrace();
                }
                
                // 回调JS
                String js = String.format("window.JSBridge.callback('%s', %s)", 
                    callbackId, result.toString());
                mWebView.post(() -> mWebView.evaluateJavascript(js, null));
            }
        }
    }

    public interface JSCallback {
        void onResult(JSONObject result);
        void onError(Exception error);
    }
}

JavaScript 端

javascript

// 初始化JSBridge
window.JSBridge = {
    callbacks: {},
    callbackId: 0,
    
    // 供Native调用JS
    invoke: function(method, params, needCallback) {
        if (window[method]) {
            const result = window[method](params);
            if (needCallback) {
                return JSON.stringify(result);
            }
        }
        return null;
    },
    
    // 调用Native方法
    callNative: function(method, params, callback) {
        const callbackId = this.callbackId++;
        this.callbacks[callbackId] = callback;
        
        // 调用Android接口
        if (window.NativeBridge) {
            NativeBridge.callNative(method, JSON.stringify(params), callbackId.toString());
        } else {
            console.error("NativeBridge not found!");
        }
    },
    
    // Native回调JS
    callback: function(callbackId, result) {
        const callback = this.callbacks[callbackId];
        if (callback) {
            callback(JSON.parse(result));
            delete this.callbacks[callbackId];
        }
    }
};

// 使用示例
function getUserInfo() {
    return { userId: 123 };
}

// 调用Native方法
JSBridge.callNative('getDeviceInfo', {}, function(result) {
    console.log('Device info:', result);
});

4. 安全注意事项

  1. 仅允许必要的JS接口:不要暴露过多功能给JS

  2. 参数验证:对所有来自JS的参数进行验证

  3. HTTPS安全:建议使用HTTPS防止中间人攻击

  4. WebView安全设置

    java

    // 禁用文件访问
    webView.getSettings().setAllowFileAccess(false);
    webView.getSettings().setAllowFileAccessFromFileURLs(false);
    webView.getSettings().setAllowUniversalAccessFromFileURLs(false);
    
    // 禁用内容提供者访问
    webView.getSettings().setAllowContentAccess(false);
    

5. 性能优化建议

  1. 减少跨语言调用:批量处理数据
  2. 使用JSON格式:统一数据交换格式
  3. 异步通信:避免阻塞UI线程
  4. 缓存机制:对频繁调用的结果进行缓存

通过以上方式,可以在Android WebView中建立安全高效的Java和JavaScript通信桥梁。

PS:创作不易 学会了记得,点赞,评论,收藏,分享

在 Angular 应用程序中使用 Genkit 的完整指南

引言

随着人工智能技术的快速发展,将 AI 能力集成到现代 Web 应用程序中已成为提升用户体验的重要方式。Genkit 作为一个强大的 AI 开发工具包,为开发者提供了便捷的方式来构建和集成 AI 功能。本文将详细介绍如何在 Angular 应用程序中使用 Genkit 流(flows),从项目创建、依赖安装到流定义和前后端交互的全过程。

Angular 作为主流的前端框架之一,结合 Genkit 可以轻松实现 AI 功能的集成。本文将通过一个餐厅菜单建议的具体示例,展示如何在 Angular 应用中实现非流式和流式两种调用方式,帮助开发者快速掌握这一技术组合。

正文

准备工作与环境配置

熟悉 Genkit 流概念

在开始之前,开发者需要熟悉 Genkit 的核心概念——流(flows)。Genkit 流是一种封装 AI 功能的可重用单元,可以接受输入、处理请求并返回输出。理解如何编写和调用 Genkit 流是成功集成的关键。

创建 Angular 项目

为了演示 Genkit 在 Angular 中的使用,我们需要一个支持服务器端渲染(SSR)和服务器路由的 Angular 项目。可以使用 Angular CLI 快速创建这样的项目:

ng new --ssr --server-routing

如果已有现有项目,可以通过以下命令添加服务器端路由支持:

ng add @angular/ssr --server-routing

这两种方式都能确保我们的应用具备必要的服务器端功能,以便处理 Genkit 流的 API 请求。

安装 Genkit 依赖

在 Angular 项目中安装必要的 Genkit 依赖项是下一步。首先安装核心 Genkit 库:

npm install genkit

接着安装至少一个模型插件,例如 Google 的 Gemini 模型:

npm install @genkit-ai/googleai

还需要安装 Genkit Express 库,以便在服务器端处理流请求:

npm install @genkit-ai/express

虽然可选,但建议全局安装 Genkit CLI 和 tsx 工具,以便开发和测试:

npm install -g genkit-cli
npm install --save-dev tsx

这些工具将大大简化开发流程,特别是在测试和调试 Genkit 流时。

定义 Genkit 流

创建流目录结构

在 Angular 项目中创建一个专门的目录来存放 Genkit 流定义是一个好习惯。建议在 src 下创建 genkit 目录:

src/
  genkit/
    menuSuggestionFlow.ts

这种结构保持了项目的整洁性,便于管理和维护多个流定义。

编写菜单建议流

下面是一个具体的菜单建议流实现示例。这个流接受一个餐厅主题作为输入,返回一个符合该主题的菜单项建议:

import { googleAI } from '@genkit-ai/googleai';
import { genkit, z } from 'genkit';

const ai = genkit({
  plugins: [googleAI()],
});

export const menuSuggestionFlow = ai.defineFlow(
  {
    name: 'menuSuggestionFlow',
    inputSchema: z.object({ theme: z.string() }),
    outputSchema: z.object({ menuItem: z.string() }),
    streamSchema: z.string(),
  },
  async ({ theme }, { sendChunk }) => {
    const { stream, response } = ai.generateStream({
      model: googleAI.model('gemini-2.5-flash'),
      prompt: `Invent a menu item for a ${theme} themed restaurant.`,
    });

    for await (const chunk of stream) {
      sendChunk(chunk.text);
    }

    const { text } = await response;
    return { menuItem: text };
  }
);

这个流定义展示了几个关键点:

  1. 使用 Zod 定义输入输出模式,确保类型安全
  2. 集成 Google 的 Gemini 模型进行文本生成
  3. 支持流式传输,可以实时返回部分结果
  4. 最终返回完整的菜单项建议

这种模式可以轻松扩展到其他类似的 AI 功能实现。

服务器端配置

添加 Express 路由

要在 Angular 应用中暴露 Genkit 流,需要在服务器端配置相应的路由。修改 src/server.ts 文件:

首先添加必要的导入:

import { expressHandler } from '@genkit-ai/express';
import { menuSuggestionFlow } from './genkit/menuSuggestionFlow';

然后初始化 Express 应用并添加 JSON 解析中间件:

app.use(express.json());

最后添加流路由:

app.post('/api/menuSuggestion', expressHandler(menuSuggestionFlow));

这样配置后,前端就可以通过 /api/menuSuggestion 端点调用菜单建议流了。

前端调用实现

非流式调用实现

对于不需要实时更新的场景,可以使用非流式调用方式。修改 src/app/app.component.ts

import { Component, resource, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { runFlow } from 'genkit/beta/client';

@Component({
  selector: 'app-root',
  imports: [FormsModule],
  templateUrl: './app.component.html',
})
export class AppComponent {
  menuInput = '';
  theme = signal('');

  menuResource = resource({
    request: () => this.theme(),
    loader: ({ request }) => runFlow({
      url: 'http://localhost:4200/api/menuSuggestion',
      input: { theme: request }
    }),
  });
}

对应的模板 (app.component.html) 实现:

<main>
  <h3>Generate a custom menu item</h3>
  <label for="theme">Suggest a menu item for a restaurant with this theme: </label>
  <input type="text" id="theme" [(ngModel)]="menuInput" />
  <button (click)="theme.set(menuInput)">Generate</button>
  <br />
  <br />
  @if (menuResource.isLoading()) {
    <div>Loading...</div>
  } @else if (menuResource.value()) {
    <div>
      <h4>Generated Menu Item:</h4>
      <pre>{{ menuResource.value().menuItem }}</pre>
    </div>
  }
</main>

这种实现简单直接,适合大多数基础场景。

流式调用实现

对于需要实时显示生成过程的场景,流式调用提供了更好的用户体验。扩展组件以支持流式响应:

import { Component, resource, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { runFlow, streamFlow } from 'genkit/beta/client';

@Component({
  selector: 'app-root',
  imports: [FormsModule],
  templateUrl: './app.component.html',
})
export class AppComponent {
  menuInput = '';
  theme = signal('');
  streamedText = signal('');
  isStreaming = signal(false);

  menuResource = resource({
    request: () => this.theme(),
    loader: ({ request }) => runFlow({
      url: 'http://localhost:4200/api/menuSuggestion',
      input: { theme: request }
    }),
  });

  async streamMenuItem() {
    const theme = this.menuInput;
    if (!theme) return;

    this.isStreaming.set(true);
    this.streamedText.set('');

    try {
      const result = streamFlow({
        url: 'http://localhost:4200/api/menuSuggestion',
        input: { theme },
      });

      for await (const chunk of result.stream) {
        this.streamedText.update(prev => prev + chunk);
      }

      const finalOutput = await result.output;
      console.log('Final output:', finalOutput);
    } catch (error) {
      console.error('Error streaming menu item:', error);
    } finally {
      this.isStreaming.set(false);
    }
  }
}

更新模板以支持流式显示:

<main>
  <h3>Generate a custom menu item</h3>
  <label for="theme">Suggest a menu item for a restaurant with this theme: </label>
  <input type="text" id="theme" [(ngModel)]="menuInput" />
  <br />
  <br />
  <button (click)="theme.set(menuInput)" [disabled]="menuResource.isLoading()">
    Generate
  </button>
  <button (click)="streamMenuItem()" [disabled]="isStreaming()">
    Stream Generation
  </button>
  <br />

  @if (streamedText()) {
    <div>
      <h4>Streaming Output:</h4>
      <pre>{{ streamedText() }}</pre>
    </div>
  }

  @if (menuResource.isLoading()) {
    <div>Loading...</div>
  } @else if (menuResource.value()) {
    <div>
      <h4>Generated Menu Item:</h4>
      <pre>{{ menuResource.value().menuItem }}</pre>
    </div>
  }

  @if (isStreaming()) {
    <div>Streaming...</div>
  }
</main>

流式实现提供了更动态的用户体验,特别适合生成较长内容或需要实时反馈的场景。

高级配置选项

身份验证集成

在实际应用中,通常需要为 API 路由添加身份验证。Genkit 支持通过请求头传递认证令牌:

menuResource = resource({
  request: () => this.theme(),
  loader: ({ request }) => runFlow({
    url: 'http://localhost:4200/api/menuSuggestion',
    headers: {
      Authorization: 'Bearer your-token-here',
    },
    input: { theme: request }
  }),
});

这种机制可以灵活地集成各种认证方案,如 JWT、OAuth 等,确保 API 调用的安全性。

本地测试与调试

在本地开发环境中运行应用时,需要为所使用的模型 API 服务配置凭据。例如,对于 Gemini API:

export GEMINI_API_KEY=<your API key>

然后正常启动应用:

ng serve

Genkit 的开发工具也可以正常使用,例如启动开发者 UI 来测试流:

genkit start -- npx tsx --watch src/genkit/menuSuggestionFlow.ts

这些工具大大简化了开发流程,使得测试和调试 Genkit 流变得更加高效。

结论

本文详细介绍了在 Angular 应用程序中集成 Genkit 的完整流程。从项目创建、依赖安装到流定义和前后端交互,我们通过一个具体的菜单建议示例展示了两种调用方式:传统的非流式调用和更先进的流式调用。

关键要点包括:

  1. Genkit 流提供了一种结构化的方式来封装 AI 功能
  2. Angular 与 Genkit 的集成需要适当的服务器端配置
  3. 流式调用可以实现更动态的用户体验
  4. 身份验证和本地测试工具保证了开发的安全性和便捷性

这种技术组合为开发者提供了强大的工具,可以轻松地将 AI 能力集成到 Angular 应用中,创造更智能、更交互式的用户体验。随着 AI 技术的不断发展,这种集成模式将在现代 Web 开发中扮演越来越重要的角色。

前端调试实践

前言

在日常调试问题中,相信我们很多人都是用console去排查相关的问题,虽然问题也可以排查出来,但是有时它的效率并不高。这篇文章主要讲解关于断点和一些日常调试技巧的内容,方便你在日后调试问题中,能在不同的前端场景应用不同的调试方式,翻倍提高你解决问题的效率



1 sources面板概览

source面板是我们断点调试经常用的到的地方,我们可以先大概认识一下它长什么样子,大概有什么功能



•左侧区块:包含了Page、Overrides、Snippets等5个功能块,其中Page可以查看该网页已加载的所有资源

•中间区块:可查看、编辑资源文件,也可查看图片类型的文件;同时可在其文件左侧进行断点等相关操作

•右侧区块:断点调试时的区域,可以开始、下一步等断点操作,同时可以查看断点调试时的变量值、调用栈等信息







当展示开发者工具的区域过小时,它会自适应调整布局







2 常用的断点方式

我们平常最经常用的可能就是代码行断点了,但是有时用它调试问题并不是效率最高的。chrome中还包含了其他的断点方式,我们可以在不同的场景应用不同的断点进行高效调试。



2.1 代码行断点

代码行断点,当代码运行到当前行之前,代码会暂停执行



2.2.1 点击Sources面板中的源代码的行号

当行号列对应行出现蓝色图标,即为打断点成功。在右侧的Breakpoints中,会出现你有打断点的信息,展示了对应的行号,也可以让你取消、勾选、编辑、删除断点







2.2.2 断点操作按钮含义

我们可以在右上角看到6个控制断点的操作按钮,分别对应着不同的操作











恢复执行







单步执行







进入函数调用







跳出函数调用







让该断点失效



2.2.3 行断点实战

在开发过程中,用例列表刚开始还可以加载出用例,怎么突然没有数据返回了。查看接口发现moduleIds参数出现了问题







于是对相关代码行设置了断点,当执行到149行的时候moduleIds的值是 [4611,5417]







可当执行到152行的时候,moduleIds的值却是 [undefined]







原来是这两处逻辑冲突了,后面的逻辑覆盖了前面的逻辑







问题很快排查出来。如果用console.log的话,可能要在代码中写好几个console,保存后,刷新浏览器打印,排查完,可能还需要去删除掉,所以遇到一些问题排查效率就提高了许多



2.2 Logpoint日志点

有时候,我们并不需要像代码行断点一样,把我们的代码暂停。我们只需要像console打印一下相关的信息,不要中断我们的代码执行。这个时候我们logpoint就派上用场了,它的语法跟console.log一样,我们可以快速得写好我们的调试信息,而且不用像console.log一样干扰我们的代码,也不用过后还得惦记着去删除它



比如下面的logpoint,其会显示粉红色的图标,代码执行到它的时候,它会在console面板中打印







注意,我们可以使用点击esc快捷键,快速得调起我们的console面板查看信息







2.3 异常断点

当我们的代码有错误,引发对应的异常报错时,我们有时会比较难快速定位到是在哪里出现了问题。我们可以利用异常断点,在发生异常的时候立即进行断点,从而快速找到发生问题的代码,并且可查看相关的变量、调用栈来帮助我们排查问题。



异常断点分为两种,可分别在在未捕获和已捕获的异常处进行断点



2.3.1 Pause on uncaught exceptions

比如下方代码中,aa要访问一个不存在的变量,这里是有问题的。这也是我们在代码中经常会遇到的问题

  const handleClick = () => {
    const aa = null;
    const bb = aa.size;
    setIsClicked(true);
  };



我们可以在Breakpoints中勾选Pause on uncaught exceptions,当代码执行到这些有异常的代码时,自然会暂停













2.3.2 Pause on caught exceptions

下方代码已经异常进行了捕获,这种情况可以勾选Pause on caught exceptions来对捕获到异常代码行暂停进行处理

  const handleClick = () => {
    try {
      const aa = null;
      const bb = aa.size;
      setIsClicked(true);
    } catch (error) {
      console.error("1-zp-error:", error);
    }
    
  };









2.4 事件监听器断点

当用户发生交互时出现问题,这时我们就可以添加事件监听器添加断点来捕获这些事件以检查交互时的问题。可以在Source面板右侧的Event Listener Breakpoints中勾选相应的事件







2.5 DOM 更改断点

使用频率不高,研究如何操作dom的特定场景才有有用







3 调用栈

当我们在查看、调试一段比较复杂的代码时,我们有时很难快速得从代码文件理清其调用关系。此时,我们可以利用调用栈来帮助我们快速理清逻辑,快速排查问题



3.1 栈

栈是一种数据结构,其内部的的元素满足后进先出的特点,我们可以对其进行入栈、出栈的操作







3.2 调用栈的应用

比如我在caseReviewListTable文件的第206行代码中打了一个断点,当代码执行到这部分逻辑的时候,他就自然会暂停







我们此时可以看到右侧的Call Stack中从上到下排列着函数调用栈信息,如下图我们可以查看到,在执行到该断点的时候,我们已经先在其他文件的handleRefreshCaseReviewList、handleSearchCase函数中执行过相关代码了。我们可以点击函数名右侧的文件地址,快速查看对应的代码,这样子,我们对其调用逻辑清晰了许多,排查问题的效率自然会提高







3.3 anonymous 匿名的

我们在看上面的代码中,发现有一个anonymous栈帧,他代表了是一个匿名函数,即没有名称的函数







比如下面代码我们加个 setTimeout,也会在调用栈生成一个anonymous的栈帧







3.4 console.trace()

除了通过断点来查看调用栈,有时我们也使用 console.trace() 来输出当前的函数调用关系,比如我们在下面代码对应的位置加上console.trace(),我们就可以看到其当前位置的调用栈信息







4 Snippets

在浏览器中,如果你在调试中,有一些公共的逻辑需要经常用到,你可以把其代码片段保存在Snippets中。当你在任何一个页面需要运行它的时候,可以直接运行它



比如我们想要获取当前页面的所有图片链接,我们可以将这段代码存储起来







需要用的时候,我们直接快捷键Command+P,输入!字符,搜索你要执行的代码片段名称,选择以后即可执行







5 Overrides

5.1 替换响应内容

如果有些异常数据导致页面发生问题,我们可以直接利用那份异常数据,在本地进行调试。当然,如果后端接口还没好,我们知道结构也可以mock数据



比如我们现在有一个/api/v2/review/list/getCaseReviewList接口







我们右键,选择Override content替换接口内容







选择以后,会需要你选择一个存储这些替换文件的文件夹







授权







我们将我们的模拟数据填充在这里,即可在页面中调试我们的UI和相关逻辑了







其中,被覆盖的接口会显示紫色的标识







如果我们不需要了,根据情况禁止、删除、清空都可以







5.2 替换响应头

如果有些场景,需要添加或者修改响应头,也可以进行自定义修改







选择Add header,然后自己添加修改对应的响应头数据







6 其他调试技巧

6.1 复制、粘贴、拖拽元素

当产品需要对已经开发好的页面进行一些位置的移动调整的时候,我们可能对代码有一个比较大的改动才可以给她看到效果,但是过后我们又得把代码改回去。这时,我们可以利用chrome提供给我们的能力,复制元素、粘贴以及拖拽元素,来实现快速的页面布局调整,给产品看到效果,又不需要改代码



比如我们复制今日工作这个区块,然后粘贴在你需要放置的位置容器下进行粘贴







粘贴以后,我们长按元素,进行拖拽调整位置即可







6.2 全局搜索

6.2.1 全局搜索文件/目录

当我们要快速调试一个文件的代码的时候,直接按Command+P快捷键调起搜索浮层,输入文件名或者文件路径名,即可快速找到对应的文件,选择点击以后会到达sources面板打开对应的文件







6.2.2 全局搜索代码

当我们有对应的代码关键词,想打开它对应的文件。chrome提拱了一个全局搜索代码的功能,我们可以打开对应的search面板







比如我输入“name: '计划列表',”这个关键词,它会在这个页面已经引入的资源搜索对应的代码关键词,然后把匹配的文件展示在下方,我们就可以立即选择然后打开对应的代码文件了







6.3 网站样式风格概览 CSS Overview

当我们看到一些优秀的网站的时候,我们想快速查看借鉴该网站样式信息的时候,我们可以借助CSS Overview这个功能快速得到相关信息,非常好用有趣



比如我们拿花瓣这个网站做例子,我们打开开发者工具,开启CSS Overview面板







然后按“Capture overview”开始收集信息





收集好以后,我们便可以从颜色、字体等维度获取到该网站的样式信息







另外,当我们点击对应的色块的时候,它也会列出用到的地方,点击对应的元素也会快速定位过去







6.4 折叠屏手机适配

如果我们的开发场景中,需要适配折叠屏手机,比如像下面的三星Galaxy Z Fold5,那么我们的chrome也可以派上用场





三星Galaxy Z Fold5



我们需要进入对应的移动端调试模式,选择对应的移动端设备







选择折叠场景,然后我们即可进行调试了







调试Surface Duo设备













7 学习资源分享

前面讲了这么多,其实只是抛砖引玉,还有很多东西是我们没有讲到的,我们可以通过下面的渠道进一步的了解、应用相关的知识和工具



7.1 Chrome DevTools

包含了Chrome 开发者工具相关功能的详细介绍还有相关的实践应用场景



7.2 Google Chrome Developers - YouTube

Google Chrome Developers的YouTube频道,视频展示了相关的新功能以及最佳实践



7.3 浏览器工作原理与实践

极客时间这门课从浏览器的渲染、javascript执行机制、V8工作原理、安全等相关视角去讲解浏览器的相关内容,从而让我们对前端的体系有一个更全的理解



8 总结

本文介绍了断点调试、sources面板和日常使用到的一些调试技巧,帮助我们在后续的开发中,提供一些新角度、新方式来解决问题,翻倍提高我们的开发效率

前端绘图基础——SVG详解

一、[SVG]中 <defs> 元素详解

![在这里插入图片描述]

1. <defs> 是什么?

在 SVG(Scalable Vector Graphics)中,<defs> 是一个容器元素,用于存储可能会在文档其他地方重复引用的图形元素、样式、滤镜、渐变、图案等。

  • 作用:  定义可复用内容,但不直接呈现在 SVG 渲染中
  • 常见用途:  markerlinearGradientclipPathpatternsymbol 等元素通常写在 <defs> 中,然后通过 id 被其他元素引用。
2. 常见被定义内容举例
  • 标记(marker) :箭头或路径终点标记。
  • 渐变(gradient) :线性或径向渐变。
  • 图案(pattern) :可重复填充图形的图案。
  • 滤镜(filter) :例如模糊、阴影、颜色变化等。
  • 裁剪路径(clipPath) :定义裁剪区域。
  • 符号(symbol) :可重复引用的图形。

二、SVG 基本组成详解

1. SVG 文件结构
<svg width="100" height="100" xmlns="http://www.w3.org/2000/svg">
  <defs> <!-- 定义资源 -->
    <!-- 渐变、图案、marker等 -->
  </defs>

  <!-- 使用 defs 中定义的资源 -->
  <circle cx="50" cy="50" r="40" fill="url(#myGradient)" />
</svg>

AI写代码xml
12345678
2. 关键组成部分
组成部分 说明
<svg> 根容器,必须声明 xmlns 属性
<defs> 定义复用资源,不直接渲染
<g> 分组元素,可统一设置样式或变换
<path><circle><rect><line><polygon> 图形元素
<use> 引用 <symbol> 或其他带 id 的定义
<style> 内嵌 CSS
<script> 可嵌入 JavaScript 脚本

三、代码示例详解

<defs ref="customSuccessDefs">
  <marker
    id="sequenceflow-end-white-success"
    viewBox="0 0 20 20"
    refX="11"
    refY="10"
    markerWidth="10"
    markerHeight="10"
    orient="auto"
  >
    <path
      class="success-arrow"
      d="M 1 5 L 11 10 L 1 15 Z"
      style="stroke-width: 1px; stroke-linecap: round; stroke-dasharray: 10000, 1;"
    />
  </marker>

  <marker
    id="conditional-flow-marker-white-success"
    viewBox="0 0 20 20"
    refX="-1"
    refY="10"
    markerWidth="10"
    markerHeight="10"
    orient="auto"
  >
    <path
      class="success-conditional"
      d="M 0 10 L 8 6 L 16 10 L 8 14 Z"
      style="stroke-width: 1px; stroke-linecap: round; stroke-dasharray: 10000, 1;"
    />
  </marker>
</defs>

AI写代码xml
123456789101112131415161718192021222324252627282930313233
关键点解释:
  • <marker> 元素:  定义可附加到路径末尾或开头的标记(如箭头、菱形等)。

    • id:用于被 <path> 或 <line> 的 marker-endmarker-start 属性引用。
    • viewBox:定义 marker 自身的坐标系统。
    • refX / refY:定义标记锚点,指定 marker 应该附着在路径上的哪一点。
    • markerWidth / markerHeight:标记显示区域的大小。
    • orient="auto":使箭头根据路径方向自动旋转。
  • <path> 元素:

    • d="M 1 5 L 11 10 L 1 15 Z":画一个箭头形状(三角形)。

    • style

      • stroke-width: 1px:边框宽度。
      • stroke-linecap: round:线条端点为圆形。
      • stroke-dasharray: 10000, 1:用于使边框看似实线。
  • class="success-arrow"success-conditional"  用于附加 CSS 样式,比如颜色等。

实际用途:

这段 <defs> 通常用于流程图或 BPMN 图中,为不同类型的连接线添加末尾标志(如“成功”箭头或条件判断标记)。

例如这样使用:

<path d="M10 10 L100 100" marker-end="url(#sequenceflow-end-white-success)" />

AI写代码xml
1

总结:

  • <defs> 用来定义可复用的图形资源,不直接绘制。
  • <marker> 定义箭头或装饰,常用于路径两端。
  • 示例中的两个 <marker> 分别定义了一个三角形箭头和一个菱形条件标记,用于流程图中表示成功和条件流。
    前:
    在这里插入图片描述
    如需我帮你生成一个可运行的 SVG 示例展示效果,请告诉我。
```javascript
    <!-- 自定义箭头样式,用于成功状态下流程连线箭头 -->
    <defs ref="customSuccessDefs">
      <marker id="sequenceflow-end-white-success" viewBox="0 0 20 20" refX="11" refY="10" markerWidth="10" markerHeight="10" orient="auto">
        <path class="success-arrow" d="M 1 5 L 11 10 L 1 15 Z" style="stroke-width: 1px; stroke-linecap: round; stroke-dasharray: 10000, 1;"></path>
      </marker>
      <marker id="conditional-flow-marker-white-success" viewBox="0 0 20 20" refX="-1" refY="10" markerWidth="10" markerHeight="10" orient="auto">
        <path class="success-conditional" d="M 0 10 L 8 6 L 16 10 L 8 14 Z" style="stroke-width: 1px; stroke-linecap: round; stroke-dasharray: 10000, 1;"></path>
      </marker>
    </defs>
    
    addCustomDefs() {
      const canvas = this.bpmnViewer.get('canvas');
      const svg = canvas._svg;
      const customSuccessDefs = this.$refs.customSuccessDefs;
      const customFailDefs = this.$refs.customFailDefs;
      svg.appendChild(customSuccessDefs);
      svg.appendChild(customFailDefs);
  },

AI写代码javascript
运行
12345678910111213141516171819

后:
在这里插入图片描述
顺序:三角形箭头
在这里插入图片描述

条件分支:条件流程标记箭头(不是普通尖头箭头),表示带有条件判断的流程连接线,条件判断符号
在这里插入图片描述

在这里插入图片描述

Additional Content

SVG常用defs详解

在这里插入图片描述

  1. marker路径标记(箭头、符号)

📌 应用场景:

  • BPMN、流程图、拓扑图中路径起点/终点箭头
  • 线性关系、依赖关系可视化

✅ 示例:

<defs>
  <marker id="arrowhead" markerWidth="10" markerHeight="7"
          refX="10" refY="3.5" orient="auto">
    <polygon points="0 0, 10 3.5, 0 7" fill="black"/>
  </marker>
</defs>
<line x1="0" y1="0" x2="100" y2="100" stroke="black"
      marker-end="url(#arrowhead)" />

AI写代码xml
12345678

在这里插入图片描述
在这里插入图片描述
自定义不同状态样式
GraphicsFactory管理SVG容器

  • 创建图形元素的 SVG 表现形式(如任务框、连线、事件图标等)
  • 根据元素类型(任务、事件、连接线等)调用不同的绘图方法
  • 在元素更新(如样式变化、位置变化)时负责重绘 SVG 图形
    在这里插入图片描述
    [CSS动态变量] Vue动态绑定style对象、数组对象,class绑定classA:true,
$success-color: #4eb819;
$primary-color: #409EFF;
$warning-color: #E6A23C;
$danger-color: #F56C6C;
$cancel-color: #909399;

.success.djs-connection {
    .djs-visual path {
      stroke: $success-color!important;
      marker-end: url(#sequenceflow-end-white-success)!important;
    }
  }

  .success.djs-connection.condition-expression {
    .djs-visual path {
      marker-start: url(#conditional-flow-marker-white-success)!important;
    }
  }
  .success.djs-shape {
    .djs-visual rect {
      stroke: $success-color!important;
      fill: $success-color!important;
      fill-opacity: 0.15!important;
    }

    .djs-visual polygon {
      stroke: $success-color!important;
    }

    .djs-visual path:nth-child(2) {
      stroke: $success-color!important;
      fill: $success-color!important;
    }

    .djs-visual circle {
      stroke: $success-color!important;
      fill: $success-color!important;
      fill-opacity: 0.15!important;
    }
  }

每日一题-删除字符使字符串变好🟢

一个字符串如果没有 三个连续 相同字符,那么它就是一个 好字符串 。

给你一个字符串 s ,请你从 s 删除 最少 的字符,使它变成一个 好字符串

请你返回删除后的字符串。题目数据保证答案总是 唯一的

 

示例 1:

输入:s = "leeetcode"
输出:"leetcode"
解释:
从第一组 'e' 里面删除一个 'e' ,得到 "leetcode" 。
没有连续三个相同字符,所以返回 "leetcode" 。

示例 2:

输入:s = "aaabaaaa"
输出:"aabaa"
解释:
从第一组 'a' 里面删除一个 'a' ,得到 "aabaaaa" 。
从第二组 'a' 里面删除两个 'a' ,得到 "aabaa" 。
没有连续三个相同字符,所以返回 "aabaa" 。

示例 3:

输入:s = "aab"
输出:"aab"
解释:没有连续三个相同字符,所以返回 "aab" 。

 

提示:

  • 1 <= s.length <= 105
  • s 只包含小写英文字母。

React Key:看似平凡,实则暗藏玄机

一、React 的 “神秘钥匙”:Key

image.png

在 React 的奇妙世界里,有一把神秘的 “钥匙”🔑,它虽然看起来普普通通,但却在 React 的运行机制中发挥着至关重要的作用,它就是 ——Key。你可别小瞧这小小的 Key,它可是 React 高效更新 DOM 的关键所在,就像是连接 React 与高性能用户界面的桥梁。

想象一下,你是一个超级英雄,拥有一个神奇的口袋,里面装着各种各样的道具。而 React 中的 Key,就像是你口袋里那个能快速识别和定位道具的神奇标签。当你需要某个道具时,这个标签就能帮助你瞬间找到它,而不是在一堆道具中手忙脚乱地翻找😜。在 React 中,当数据发生变化,需要更新界面时,Key 就起到了这样的快速识别作用,帮助 React 准确地知道哪些元素发生了变化、被添加或被删除。

再打个比方,假如你在玩一款换装游戏,游戏里有很多角色,每个角色都有自己独特的服装和配饰。当你想给某个角色换衣服时,你得先准确地找到这个角色,对吧🧐?React 中的 Key 就像是每个角色的专属 ID,有了它,React 就能轻松地找到对应的元素(角色),然后只更新这个元素(给角色换衣服),而不会影响到其他元素(其他角色)。这样不仅能提高效率,还能保证界面的稳定性和流畅性。

现在,你是不是对 React 中的 Key 有了一些初步的认识呢😎?接下来,让我们一起深入了解它的主要作用,看看这把 “神秘钥匙” 到底有多厉害!

二、为什么 React 如此依赖 Key?

image.png

(一)元素识别

在 React 的世界里,元素识别就像是一场紧张刺激的特工行动,每个元素都像是一个神秘的特工,而 Key 则是他们独一无二的身份标识🔎。

当我们在 React 中渲染一个列表时,比如一个待办事项列表,每个待办事项就是列表中的一个元素。假设我们有这样一个待办事项数组:

const todos = [
  { id: 1, title: '❤️乡❤️' },
  { id: 2, title: '乡❤️乡' },
  { id: 3, title: '❤️乡❤️' }
];

当我们要将这个数组渲染成列表时,我们会使用map方法,像这样:

{todos.map((todo) => <li key={todo.id}>{todo.title}</li>)}

这里,我们使用todo.id作为key。这样一来,React 就可以通过这个key精准地识别每个li元素。就好像特工们都有自己独特的徽章,凭借这个徽章,指挥官(React)就能轻松地辨认出谁是谁。

当数据发生变化时,Key 的作用就更加明显了。比如说,我们又添加了一个新的待办事项:

const newTodos = [
  { id: 4, title: '乡乡' },
  { id: 1, title: '❤️乡❤️' },
  { id: 2, title: '乡❤️乡' },
  { id: 3, title: '❤️乡❤️' }
];

这时,React 通过key就能知道,id为 1、2、3 的待办事项是原来就有的,只是位置可能发生了变化,而id为 4 的待办事项是新增的。于是,React 就可以有针对性地更新 DOM,只添加新的元素,而不会对其他已有的元素进行不必要的操作。

但是,如果我们没有给这些元素设置key,或者使用了错误的key,情况就会变得一团糟😱。React 就像是一个失去了导航的飞行员,无法准确判断哪些元素是新增的、哪些是被删除的,哪些只是位置发生了变化。它可能会错误地认为所有的元素都发生了改变,从而对整个列表进行重新渲染,这不仅浪费性能,还可能导致一些意想不到的问题。

(二)性能优化

Key 对于 React 的性能优化来说,就像是给汽车装上了一台超级引擎,让 React 能够高效地运行🚀。

我们知道,DOM 操作是比较耗费性能的。在 React 中,当数据发生变化时,React 会通过对比新旧虚拟 DOM 树,找出差异,然后将这些差异应用到真实的 DOM 上,这个过程被称为 “调和”(reconciliation)。而 Key 在这个过程中,就像是一把精准的手术刀,帮助 React 快速准确地找到需要更新的部分,避免对整个 DOM 进行不必要的重新渲染。

还是以上面的待办事项列表为例,如果我们给每个待办事项都设置了唯一的key,当其中一个待办事项的标题发生变化时,比如将id为 2 的待办事项标题从 “完成项目” 改为 “完成重要项目”:

const updatedTodos = [
  { id: 1, title: '❤️乡❤️' },
  { id: 2, title: '乡❤️乡' },
  { id: 3, title: '❤️乡❤️' }
];

React 通过key能够迅速定位到id为 2 的这个待办事项对应的 DOM 元素,然后只更新这个元素的文本内容,而不会影响其他元素。这样就大大减少了 DOM 操作的次数,提高了性能。

在实际项目中,这种性能优化的效果是非常显著的。比如说,在一个电商网站的商品列表页面,可能会有成百上千个商品展示。如果没有正确使用key,每次用户进行筛选、排序等操作导致数据变化时,React 可能会对整个商品列表进行重新渲染,这会导致页面卡顿,用户体验极差。而通过合理使用key,React 可以只更新那些真正发生变化的商品元素,让页面能够快速响应用户的操作,提升用户体验。

(三)状态保持

Key 在保持组件状态方面,就像是一个忠诚的管家,时刻守护着组件的状态,确保它们不会在重新渲染时丢失🗃️。

在 React 中,组件的状态(state)是与组件实例紧密关联的。当组件重新渲染时,如果没有key或者key不正确,React 可能会认为这是一个全新的组件实例,从而导致组件的状态被重置。这就好比你出门时把家里的钥匙弄丢了,回来时发现家里的一切都被重新布置了,之前的状态都消失了😫。

最常见的就是表单输入和滚动位置这两个场景。想象一下,你在一个表单中填写了很多内容,当表单数据发生变化导致组件重新渲染时,如果没有正确的key,你之前填写的内容可能会突然消失,这是不是很让人崩溃?又比如,你在一个长列表页面中滚动到了某个位置,当列表数据更新后,如果key不正确,页面可能会跳回到顶部,你又得重新滚动到原来的位置,这体验简直糟糕透顶😡。

而有了正确的key,React 就可以识别出这个组件实例还是原来的那个,从而保持其状态不变。例如,在一个包含输入框的待办事项列表中,每个待办事项都有一个输入框用于用户输入备注信息。如果我们给每个待办事项设置了唯一的key,当用户在某个输入框中输入内容后,即使列表数据发生了变化,这个输入框中的内容也不会丢失,因为 React 知道这个输入框对应的组件实例没有改变,它的状态应该被保留。

通过上面的介绍,相信你已经对 React 中 Key 的重要性有了更深入的理解。它就像一个幕后英雄,默默地在背后为 React 的高效运行、准确更新以及良好的用户体验贡献着自己的力量💪。接下来,我们再看看如果没有 Key,会引发哪些具体的问题。

三、没有 Key,React 世界会怎样?

image.png

(一)状态漂移示例

现在,让我们来揭开没有 Key 时 React 世界的 “混乱面纱”😈。先来看一段没有使用 Key 的代码示例:

import React, { useState } from 'react';
const TodoList = () => {
  const [todos, setTodos] = useState([
    { title: '任务A' },
    { title: '任务B' },
    { title: '任务C' }
  ]);
  const addTodo = () => {
    const newTodo = { title: '新任务' };
    setTodos([newTodo, ...todos]);
  };
  return (
    <div>
      <ul>
        {todos.map((todo) => <li>{todo.title}</li>)}
      </ul>
      <button onClick={addTodo}>在开头添加项目</button>
    </div>
  );
};
export default TodoList;

在这段代码中,我们创建了一个简单的待办事项列表,并且提供了一个按钮,用于在列表开头添加新的待办事项。但是,这里的列表项没有设置key。

当我们运行这段代码,并进行一些操作时,有趣的事情发生了😜。假设我们的初始待办事项列表是[任务A, 任务B, 任务C],当我们点击 “在开头添加项目” 按钮后,新的列表变成了[新任务, 任务A, 任务B, 任务C]。

这时,React 会认为:“哎呀,原来的任务A变成了新任务,任务B变成了任务A,任务C变成了任务B,还新增了一个任务C”。这就好像是一场疯狂的 “角色互换游戏”,每个任务都被错误地匹配和识别了。

这种错误的识别会导致一系列严重的问题,其中最典型的就是状态漂移。比如说,如果我们的待办事项列表中每个任务都有一个复选框,用于标记任务是否完成,当我们没有为列表项设置key时,就会出现复选框状态丢失的情况。原本勾选的任务,在添加新任务后,复选框的勾选状态可能会 “漂移” 到其他任务上,就像复选框被施了魔法一样,自己跑错了位置😱。

再比如,如果每个任务都有一个输入框,用于用户输入一些备注信息。当添加新任务导致列表重新渲染时,输入框中的内容也可能会丢失或者 “漂移” 到其他输入框中。用户辛苦输入的内容,就这么莫名其妙地消失或者跑到了不该去的地方,这体验简直糟糕透顶😡。

还有更离谱的,如果列表是一个长列表,用户已经滚动到了某个位置,当添加新任务后,滚动位置可能会错误地应用到其他元素上,用户又得重新滚动到原来的位置,这真的会让人抓狂😫。

(二)具体问题剖析

为什么会出现这些问题呢🧐?这得从 React 的渲染机制和 Diff 算法说起。

React 采用了虚拟 DOM(Virtual DOM)技术来提高性能。当数据发生变化时,React 会生成一个新的虚拟 DOM 树,然后将其与旧的虚拟 DOM 树进行对比,这个对比的过程就是 Diff 算法。Diff 算法会找出两棵树之间的差异,然后只更新那些真正发生变化的部分,而不是重新渲染整个 DOM,这样可以大大提高渲染效率。

在 Diff 算法中,当比较两个列表时,如果没有key,React 会默认使用数组的索引来识别列表项。但是,这种方式在列表项的顺序发生变化或者有新的项添加、删除时,就会出现问题。因为索引是会随着列表的变化而变化的,所以 React 可能会错误地认为某个列表项是新的或者已经被删除了,从而导致状态丢失、错误绑定等问题。

还是以上面的待办事项列表为例,当我们在列表开头添加新任务时,由于没有key,React 会根据索引来判断列表项的变化。原来索引为 0 的任务A,现在索引变成了 1,React 就会认为它是一个新的元素,而不是原来的任务A,于是就会重新创建这个元素,导致其关联的状态(如复选框的勾选状态、输入框的内容等)丢失。

而如果我们为每个列表项设置了唯一的key,React 在进行 Diff 算法时,就可以通过key准确地识别每个列表项,知道哪些是新增的、哪些是被删除的、哪些只是位置发生了变化,从而正确地更新 DOM,保持组件的状态不变。就像给每个任务都贴上了独一无二的 “身份标签”,无论它们怎么移动、变化,React 都能准确地找到它们,不会再出现混乱的情况😎。

通过上面的分析,相信你已经深刻认识到了没有 Key 时会带来的种种问题。那么,如何正确地使用 Key 呢?别急,接下来我们就详细讲解正确使用 Key 的方法和最佳实践。

四、正确使用 Key,开启 React 高效开发之门

image.png

(一)使用唯一 ID

现在我们已经知道了 Key 的重要性以及没有 Key 会带来的问题,那么如何正确地使用 Key 呢🧐?这就像是掌握了魔法咒语,能让 React 这个魔法世界更加稳定和高效地运行。

在 React 中,使用唯一 ID 作为 Key 是最正确和推荐的做法。就像每个人都有一个独一无二的身份证号码一样,每个列表项也应该有一个唯一的 ID 作为它的 “身份标识”🔑。这样,React 在识别和更新列表项时就能准确无误,不会出现混乱的情况。

我们还是以上面的待办事项列表为例,假设每个待办事项都有一个唯一的id:

const todos = [
  { id: 'todo-1', title: '学习React' },
  { id: 'todo-2', title: '完成项目' },
  { id: 'todo-3', title: '锻炼身体' }
];

当我们渲染这个列表时,就可以使用id作为key:

{todos.map((todo) => <li key={todo.id}>{todo.title}</li>)}

这样,无论列表项如何添加、删除或重新排序,React 都能通过这个唯一的id准确地识别每个列表项,从而正确地更新 DOM,保持组件的状态不变。就像给每个待办事项都贴上了一个独一无二的 “标签”,React 可以轻松地找到它们,进行相应的操作😎。

在实际项目中,我们的数据往往来自于后端接口,后端通常会为每个数据项分配一个唯一的id。我们只需要在渲染列表时,将这个id作为key传递给 React 就可以了。比如,在一个电商项目中,商品列表中的每个商品都有一个唯一的productId,我们在渲染商品列表时,就可以这样做:

const products = [
  { productId: 'prod-1', name: '商品A', price: 100 },
  { productId: 'prod-2', name: '商品B', price: 200 },
  { productId: 'prod-3', name: '商品C', price: 300 }
];
const ProductList = () => {
  return (
    <ul>
      {products.map((product) => (
        <li key={product.productId}>
          {product.name} - ${product.price}
        </li>
      ))}
    </ul>
  );
};

通过这种方式,我们确保了每个商品列表项都有一个唯一的key,React 在更新列表时就能高效地工作,不会出现任何问题。

(二)避免使用索引

虽然在 React 中,使用数组索引作为key是一种简单的做法,但这就像是走了一条看似捷径却充满陷阱的路,会给我们带来很多麻烦😱。

想象一下,你有一个水果列表,最初是[苹果, 香蕉, 橙子],对应的索引分别是0、1、2。你使用索引作为key来渲染这个列表:

const fruits = ['苹果', '香蕉', '橙子'];
const FruitList = () => {
  return (
    <ul>
      {fruits.map((fruit, index) => (
        <li key={index}>{fruit}</li>
      ))}
    </ul>
  );
};

现在,如果你在列表开头添加一个新的水果,比如草莓,列表就变成了[草莓, 苹果, 香蕉, 橙子],索引也相应地变成了0、1、2、3。这时,React 会认为原来索引为0的苹果变成了草莓,原来索引为1的香蕉变成了苹果,以此类推。这就导致 React 会错误地更新 DOM,可能会引发一系列问题,比如组件状态丢失、输入框内容错乱、动画效果异常等😫。

更具体地说,如果你的列表项中包含一些有状态的组件,比如复选框、输入框等,使用索引作为key会导致这些组件的状态在列表重新渲染时出现混乱。例如,有一个待办事项列表,每个事项都有一个复选框用于标记是否完成,当你使用索引作为key时,如果在列表开头添加或删除一个事项,复选框的勾选状态可能会 “漂移” 到其他事项上,用户体验极差😡。

再比如,当你对列表进行排序时,使用索引作为key也会导致 React 错误地认为所有的列表项都发生了变化,从而进行不必要的 DOM 更新,浪费性能。所以,除非你的列表是静态的,不会发生增删改操作,否则一定要避免使用索引作为key❗

(三)杜绝使用随机数

还有一种错误的做法,就是使用随机数作为key,这简直就是在给 React 制造混乱,就像是在暴风雨中迷失方向的船只,让 React 完全不知所措😵。

看下面这段代码:

const items = ['项目A', '项目B', '项目C'];
const ItemList = () => {
  return (
    <ul>
      {items.map((item) => (
        <li key={Math.random()}>{item}</li>
      ))}
    </ul>
  );
};

这里我们使用Math.random()生成一个随机数作为key。看起来好像没什么问题,但实际上每次渲染时,Math.random()都会生成一个新的随机数,这就意味着每个列表项的key都会发生变化。

React 会认为每个列表项都是全新的,每次渲染都会销毁并重新创建整个列表,而不是只更新那些真正发生变化的部分。这不仅会导致性能急剧下降,还会让组件的状态无法保持,所有的努力都白费了😫。

在实际项目中,这种错误的做法可能会导致页面卡顿、响应迟缓,用户体验非常糟糕。所以,一定要杜绝使用随机数作为key,选择一个稳定的、唯一的标识符才是正确的做法💪。

通过以上介绍,相信你已经清楚地知道了如何正确地使用key,避免那些常见的错误。掌握了这些技巧,你在 React 开发中就能更加得心应手,写出高效、稳定的代码。接下来,我们再看看在使用key时还有哪些最佳实践,可以让我们的代码更加优雅和健壮😎。

五、最佳实践:让 Key 发挥最大效能

image.png

(一)使用稳定的唯一标识符

在 React 开发中,为了让 Key 充分发挥其强大的作用,我们需要遵循一些最佳实践。就像是武林高手修炼武功,只有掌握了正确的秘籍,才能发挥出最大的威力😎。

使用稳定的唯一标识符作为 Key 是最佳实践中的 “上乘武功”。在实际项目中,我们常常会遇到各种数据,这些数据往往都有一些天然的唯一标识,比如数据库 ID、邮箱地址、自定义唯一标识符等。

如果我们的数据来自数据库,那么数据库为每个数据项分配的 ID 就是一个非常理想的 Key。比如,在一个用户管理系统中,每个用户都有一个唯一的userId,当我们渲染用户列表时,就可以这样使用:

const users = [
  { userId: 1, name: '张三', age: 25 },
  { userId: 2, name: '李四', age: 30 },
  { userId: 3, name: '王五', age: 35 }
];
const UserList = () => {
  return (
    <ul>
      {users.map((user) => (
        <li key={user.userId}>
          {user.name} - {user.age}岁
        </li>
      ))}
    </ul>
  );
};

这样,无论用户列表如何更新,React 都能通过userId准确地识别每个用户,保证界面的稳定和高效更新。

再比如,如果我们的数据是一些用户的邮箱地址,而邮箱地址本身就是唯一的,我们也可以直接使用邮箱地址作为 Key:

const userEmails = [
  { email: 'zhangsan@example.com', name: '张三' },
  { email: 'lisi@example.com', name: '李四' },
  { email: 'wangwu@example.com', name: '王五' }
];
const EmailList = () => {
  return (
    <ul>
      {userEmails.map((user) => (
        <li key={user.email}>
          {user.name} - {user.email}
        </li>
      ))}
    </ul>
  );
};

这种方式同样能确保 React 在处理列表时的准确性和高效性。

(二)组合多个字段

然而,有时候我们的数据中可能没有一个单独的字段可以作为唯一的标识符,这时候该怎么办呢🧐?别担心,我们还有一招 “组合拳”,那就是组合多个字段来生成唯一的 Key。

比如说,在一个商品管理系统中,每个商品都有一个类别(category)和一个商品 ID(productId),单独的类别或商品 ID 都不能保证唯一性,但将它们组合起来就可以了。我们可以这样做:

const products = [
  { category: '电子产品', productId: 1001, name: '手机' },
  { category: '服装', productId: 2001, name: '衬衫' },
  { category: '电子产品', productId: 1002, name: '电脑' }
];
const ProductList = () => {
  return (
    <ul>
      {products.map((product) => {
        const uniqueKey = `${product.category}-${product.productId}`;
        return (
          <li key={uniqueKey}>
            {product.name} - {product.category} - {product.productId}
          </li>
        );
      })}
    </ul>
  );
};

在这个例子中,我们通过将category和productId用-连接起来,生成了一个唯一的uniqueKey,然后将其作为 Key 传递给 React。这样,React 就能准确地区分每个商品,即使它们的类别或商品 ID 有重复的情况。

在实际场景中,这种组合字段的方式非常实用。比如在一个订单管理系统中,每个订单都有一个订单日期(orderDate)和一个订单号(orderNumber),我们可以将它们组合起来作为 Key,以确保每个订单在列表中都有唯一的标识。通过这种方式,我们可以灵活地应对各种复杂的数据情况,让 React 始终保持高效运行💪。

(三)使用库生成 UUID

如果我们的数据中实在没有合适的字段可以作为唯一标识符,也不想手动组合字段,还有一个简单又强大的方法,那就是使用库生成 UUID(通用唯一识别码)。UUID 就像是一个神奇的 “万能钥匙”,无论在什么情况下,都能为我们生成独一无二的标识符🔑。

在 React 中,我们可以使用uuid库来生成 UUID。首先,我们需要安装uuid库,打开终端,输入以下命令:

npm install uuid

安装完成后,就可以在项目中使用它了。比如,在一个待办事项应用中,当我们创建新的待办事项时,可以为其生成一个唯一的 ID:

import { v4 as uuidv4 } from 'uuid';
const createNewTodo = (title) => {
  return {
    id: uuidv4(),
    title: title,
    completed: false
  };
};
const newTodo = createNewTodo('学习React的Key');
console.log(newTodo);

在这个例子中,我们使用uuidv4()函数生成了一个唯一的 ID,并将其作为待办事项的id。当我们渲染待办事项列表时,就可以使用这个id作为 Key:

const todos = [
  { id: uuidv4(), title: '学习React', completed: false },
  { id: uuidv4(), title: '完成项目', completed: false },
  { id: uuidv4(), title: '锻炼身体', completed: false }
];
const TodoList = () => {
  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>
          {todo.title} - {todo.completed ? '已完成' : '未完成'}
        </li>
      ))}
    </ul>
  );
};

通过使用uuid库生成 UUID 作为 Key,我们可以轻松地为每个列表项提供唯一的标识符,而不用担心重复的问题。这在处理动态数据时非常方便,能够大大提高我们的开发效率和代码的健壮性😎。

掌握了这些最佳实践,我们在使用 React 中的 Key 时就能更加得心应手,避免很多潜在的问题。就像是拥有了一套强大的武器库,无论遇到什么挑战,都能轻松应对。接下来,我们通过一个实际的演示来更直观地感受 Key 的重要性。

六、实战演示:眼见为实(见码上掘金)

image.png

为了让大家更直观地感受 Key 的重要性,我们来进行一个实战演示。就像是一场精彩的魔术表演,让你亲眼见证有 Key 和没有 Key 时的奇妙差异😎。

首先,我们有一个key-demo.html文件,这个文件里包含了两个部分:一个是没有使用 Key 的待办事项列表,另一个是使用了 Key 的待办事项列表。现在,让我们一起按照以下步骤来揭开这场 “魔术” 的神秘面纱吧🧐!

(一)打开文件

找到key-demo.html文件,然后用你喜欢的浏览器打开它。就像是打开一扇通往 React 奇妙世界的大门,准备迎接即将发生的神奇变化🚪。

(二)在 “没有 Key 的情况” 部分勾选一些复选框

进入页面后,你会看到 “没有 Key 的情况” 下面有一个待办事项列表,每个事项前面都有一个复选框。现在,你可以勾选几个复选框,就像你在实际生活中标记已完成的任务一样。比如说,你勾选了 “任务 A” 和 “任务 C” 前面的复选框✅。

(三)点击 “在开头添加项目” 按钮

接下来,点击 “在开头添加项目” 按钮。这时候,神奇的事情发生了😱!你会发现,原本勾选的 “任务 A” 和 “任务 C” 前面的复选框,它们的勾选状态竟然 “漂移” 到了其他任务上。原本勾选的 “任务 A” 的复选框,现在可能跑到了 “任务 B” 上,而原本勾选的 “任务 C” 的复选框,可能跑到了 “任务 D” 上。这就像是复选框被施了魔法,自己跑错了位置,是不是很让人惊讶?

(四)观察复选框状态如何 “漂移”

仔细观察这些复选框状态的变化,你会深刻体会到没有 Key 时带来的混乱。这种混乱不仅会影响用户体验,还可能导致一些严重的问题,比如用户无法准确标记任务的完成状态,或者系统对任务状态的记录出现错误。这就好比你在整理书架时,每本书都没有编号,当你添加一本新书后,所有书的位置都乱了,你再也找不到你想要的那本书了😫。

(五)在 “有 Key 的情况” 部分重复相同操作

接下来,我们再看看有 Key 时的情况。在页面中找到 “有 Key 的情况” 下面的待办事项列表,同样地,先勾选几个复选框,比如勾选 “任务 1” 和 “任务 3” 前面的复选框✅。然后点击 “在开头添加项目” 按钮。

(六)观察复选框状态如何正确保持

这次,你会发现一个截然不同的结果。无论你怎么添加项目,勾选的复选框状态始终保持正确。原本勾选的 “任务 1” 和 “任务 3” 的复选框,它们的勾选状态不会发生任何变化,依然稳稳地标记着对应的任务。这就是 Key 的神奇之处,它就像是一个忠诚的守护者,确保每个元素的状态在数据变化时都能得到正确的维护🔐。

通过这个实战演示,相信你已经对 React 中 Key 的重要性有了更加直观和深刻的认识。就像我们看到的,有 Key 和没有 Key 时,界面的表现简直是天壤之别。有了 Key,React 就能准确地识别每个元素,保持状态的稳定;而没有 Key,就会导致各种混乱和错误。所以,在 React 开发中,一定要记得为列表项提供唯一的、稳定的 Key 哦💪!

七、总结与展望:掌握 Key,驾驭 React

image.png

到这里,我们对 React 中 Key 的探索之旅就要暂告一段落啦🎉!在这次奇妙的旅程中,我们深入了解了 Key 在 React 世界里的重要地位和神奇作用。

Key 就像是 React 的秘密武器,它是 React 准确识别列表项的 “超级望远镜”,是优化性能的 “高效引擎”,更是保持组件状态的 “忠诚卫士”。有了 Key,React 在面对复杂多变的数据时,能够快速准确地更新 DOM,为用户带来流畅稳定的体验。

我们也见识到了没有 Key 或者错误使用 Key 时的 “混乱场景”,比如复选框状态的 “漂移”、输入框内容的丢失、滚动位置的错乱以及动画效果的异常,这些问题不仅会让用户感到困惑和不满,还会影响整个应用的质量和口碑。

所以,在今后的 React 开发中,大家一定要牢记为列表项提供唯一的、稳定的 Key。就像出门一定要带钥匙一样,这已经成为 React 开发中不可或缺的 “铁律”。无论是使用数据本身的唯一标识符,还是通过组合字段、使用库生成 UUID 等方式,我们都要确保 Key 的唯一性和稳定性,让 React 能够高效地工作。

希望通过这篇文章,大家都能掌握 React 中 Key 的正确使用方法,在 React 开发的道路上一帆风顺🚀。也期待大家能够将所学运用到实际项目中,创造出更多优秀、高效的 React 应用!如果在使用 Key 的过程中遇到了什么有趣的问题或者有自己独特的见解,欢迎在评论区留言分享哦😎!

Vue 3 中 async setup () 的「坑」与避坑指南2

在 Vue 3 中,除了返回渲染函数外,async setup()还会在以下场景中导致问题:

1. 依赖注入(provide/inject)失效

问题
async setup()会使provide的值在组件渲染时可能尚未就绪,导致子组件注入失败。

示例

javascript

// ❌ 错误示例:async setup() 中 provide 异步值
export default {
  async setup() {
    // 异步获取用户信息
    const user = await fetchUser();
    
    // 此时组件可能已开始渲染,但 user 还未返回
    provide('user', user);
  }
};

// 子组件
export default {
  setup() {
    const user = inject('user'); // 可能获取到 undefined
  }
};

原因
Vue 在执行setup()时不会等待 Promise,导致provide的内容在子组件注入时可能未完成初始化。

2. 与生命周期钩子的时序冲突

问题
async setup()中的异步操作会延迟生命周期钩子的执行,可能导致其他逻辑依赖的数据未及时准备好。

示例

javascript

export default {
  async setup() {
    await fetchData(); // 延迟执行
    
    // onMounted 在数据获取完成后才触发
    onMounted(() => {
      // 此时DOM已挂载,但数据可能还未处理完
      console.log('Mounted');
    });
  }
};

影响

  • onMountedonUpdated等钩子的触发时机被推迟,可能与组件实际渲染状态不一致。
  • 若其他插件或自定义逻辑依赖这些钩子的时序,可能导致错误。

3. 与 v-model、自定义指令等功能结合时异常

问题
async setup()可能导致模板中的响应式数据在初始化时未就绪,影响依赖这些数据的功能。

示例

javascript

export default {
  async setup() {
    const value = ref(null);
    
    // 异步获取初始值
    value.value = await fetchInitialValue();
    
    return {
      value
    };
  }
};

html

预览

<!-- 模板 -->
<input v-model="value" /> <!-- 初始渲染时 value 为 null,可能导致输入框异常 -->

影响

  • v-model绑定的初始值可能为nullundefined,导致输入框显示异常。
  • 自定义指令在初始化时可能读取到未就绪的数据,触发错误。

4. 与 Vue Router 导航守卫结合时的阻塞问题

问题
若在路由组件的async setup()中进行异步验证,可能导致路由守卫无法正确判断导航状态。

示例

javascript

// 路由组件
export default {
  async setup() {
    const isAuthenticated = await checkAuth(); // 异步验证
    
    if (!isAuthenticated) {
      // 此时路由可能已渲染,无法阻止
      router.push('/login');
    }
  }
};

影响

  • 导航守卫可能无法及时拦截未授权访问,导致组件先渲染再跳转,产生闪烁。
  • 若多个路由组件同时使用async setup(),可能导致导航流程混乱。

5. 与 SSR(服务器端渲染)不兼容

问题
async setup()在 SSR 环境中会导致组件无法同步生成 HTML,破坏服务端渲染的优势。

示例

javascript

// ❌ SSR 中使用 async setup()
export default {
  async setup() {
    const data = await fetchData(); // 服务器端无法等待异步操作
    return { data };
  }
};

影响

  • 服务器端无法同步生成完整的 HTML,导致首屏加载延迟或白屏。
  • 需要额外的机制(如renderToString配合 Promise)处理异步组件,增加复杂度。

6. 错误处理困难

问题
async setup()内部的错误不会被 Vue 的全局错误处理器捕获,需要手动处理。

示例

javascript

export default {
  async setup() {
    // 若此处抛出错误,无法被 app.config.errorHandler 捕获
    throw new Error('Async error');
  }
};

影响

  • 错误可能导致组件渲染中断,但没有统一的错误处理机制,增加调试难度。

总结

在 Vue 3 中,async setup()的核心问题是异步操作与 Vue 同步渲染流程的冲突。除非明确知道场景支持异步(如纯客户端组件且使用条件渲染处理加载状态),否则应避免使用async setup(),而是通过以下方式处理异步逻辑:

  1. setup()内部使用ref/reactive结合await,但不返回 Promise。
  2. 使用生命周期钩子(如onMounted)执行异步操作。
  3. 对需要异步初始化的组件,使用 Suspense(需 Vue 3.2+)。

Vue 3 中 async setup () 的「坑」与避坑指南1

在 Vue 3 中,setup()函数是组件的核心入口,负责组合组件的响应式数据和方法。当setup()返回一个渲染函数时,这个渲染函数必须是同步的。如果使用async setup(),会导致以下问题:

1. 渲染函数必须是同步的

Vue 3 的渲染流程要求渲染函数(h 函数或 JSX)必须立即返回 VNode 结构,而不是 Promise。如果setup()是异步的,会出现以下问题:

// ❌ 错误示例:async setup() 返回渲染函数
export default {
  async setup() {
    // 模拟异步操作(如API请求)
    const data = await fetchData();
    
    // 返回渲染函数(此时组件已经开始渲染,但数据还未返回)
    return () => h('div', data.value); // 此时data可能为undefined
  }
};
  • 问题:Vue 在调用setup()时不会等待 Promise resolve,而是直接执行后续渲染逻辑。此时渲染函数可能在数据加载完成前就被调用,导致显示undefined或空值。

2. 异步 setup () 的正确用法

如果确实需要在setup()中使用异步操作,不要返回渲染函数,而是通过refreactive定义响应式数据,让 Vue 自动跟踪数据变化并触发更新:

javascript

// ✅ 正确示例:使用ref/reactive + 模板(或setup返回对象)
export default {
  async setup() {
    const data = ref(null);
    const loading = ref(true);
    
    try {
      data.value = await fetchData();
    } finally {
      loading.value = false;
    }
    
    // 返回数据(不返回渲染函数,由模板自动响应数据变化)
    return {
      data,
      loading
    };
  }
};
  • 模板

    预览

    <template>
      <div v-if="loading">加载中...</div>
      <div v-else>{{ data }}</div>
    </template>
    

3. 为什么渲染函数不能是异步的?

Vue 的渲染流程是同步执行的:

  1. 调用setup()获取渲染上下文(数据、方法)。

  2. 执行渲染函数生成 VNode 树。

  3. 根据 VNode 树创建 DOM 节点并挂载。

如果渲染函数是异步的,Vue 无法确定何时才能获取完整的 VNode 结构,会导致:

  • 初始渲染时数据缺失。
  • DOM 频繁更新(数据返回后需要重新渲染)。
  • 性能问题(多次不必要的渲染)。

4. 对比 Vue 2 的异步 mounted ()

Vue 2 的mounted()是生命周期钩子,组件已经渲染完成后才执行,异步操作只会影响后续更新,不会阻塞初始渲染:

javascript

// Vue 2 异步mounted()
export default {
  data() {
    return {
      data: null
    };
  },
  async mounted() {
    this.data = await fetchData(); // 数据返回后触发更新
  }
};

5. 总结

在 Vue 3 中:

  • 不要使用async setup()返回渲染函数,因为渲染函数必须同步返回 VNode。
  • 如果需要异步操作,使用ref/reactive定义响应式数据,并在模板中使用条件渲染(如v-if)处理加载状态。
  • 若使用组合式 API 的defineComponent,Vue 会自动警告async setup()返回渲染函数的错误。

举一反三:删除排序数组重复项

电商价格列表中 [19,19,23,23,23,56] 因重复数据导致价格展示异常,你需要在不创建新数组的前提下,用O(1)空间复杂度实现去重后渲染UI


问题本质与挑战

需求核心:在已排序数组中原地删除重复项,返回去重后长度
四大挑战

  1. 空间限制:O(1)额外空间(禁止新建数组)
  2. 原地操作:直接修改原数组
  3. 顺序保持:保留非重复元素原始顺序
  4. 性能要求:时间复杂度O(n)

双指针法(首选)

const removeDuplicates = (nums) => {
    if (nums.length === 0) return 0;
    
    // 慢指针:记录唯一元素应插入位置
    let slow = 0; 
    
    // 快指针:扫描所有元素
    for (let fast = 1; fast < nums.length; fast++) {
        // 发现新唯一元素 → 慢指针前移并更新值
        if (nums[fast] !== nums[slow]) {
            slow++;
            nums[slow] = nums[fast];
        }
    }
    return slow + 1; // 返回唯一元素总数量
};

运行过程动态演示(输入 [1,1,2,3,3]):

graph TB
    A[初始] --> |nums| B[1,1,2,3,3]
    B --> C[slow=0, fast=1] 
    C -- "1==1 → 跳过" --> D[fast++]
    D -- "fast=2: 1!=2 → 赋值" --> E[slow=1, nums=1,2,2,3,3]
    E -- "fast=3: 2!=3 → 赋值" --> F[slow=2, nums=1,2,3,3,3]
    F -- "fast=4: 3==3 → 跳过" --> G[结束]
    G --> H[长度 = slow+1 = 3]

关键代码解析

  1. 双指针分工
    • slow:唯一元素边界(已处理区域的终点)
    • fast:探测器(扫描未处理区域)
  2. 位移条件
    nums[fast] !== nums[slow] 表明发现新唯一元素
  3. 赋值操作
    slow++; nums[slow] = nums[fast] 完成唯一元素前移
  4. 返回值
    slow + 1 唯一元素个数(慢指针索引+1)

双指针法 vs 传统方案

方案 时间复杂度 空间复杂度 是否原地修改 适用场景
双指针法 O(n) O(1) 内存敏感的排序数组
Set去重 O(n) O(n) 简单场景/小数据量
filter创建新数组 O(n) O(n) 可接受新数组的场景
排序后暴力删除 O(n²) O(1) 无序数组(先排序)

💡 为什么双指针是王者?

  1. 内存优势:不创建新数组,减少GC压力(尤其处理10万+商品列表时)
  2. 性能优势:仅单次遍历,避免嵌套循环
  3. 符合React规范:直接修改状态数组,避免setState额外开销

举一反三:双指针的三大变种应用

变种1:保留K个重复项(电商价格保留2次折扣)

// 保留最多2个相同元素 输入 [1,1,1,2,2,3] → 返回5(新数组[1,1,2,2,3])
const removeDuplicatesKeepTwo = (nums) => {
    if (nums.length <= 2) return nums.length;
    
    let slow = 2; // 从第三位开始检查
    for (let fast = 2; fast < nums.length; fast++) {
        // 核心:当前元素 ≠ 慢指针前2位的元素
        if (nums[fast] !== nums[slow - 2]) {
            nums[slow] = nums[fast];
            slow++;
        }
    }
    return slow;
};

变种2:删除特定值(清除所有0值)

// 输入 [0,5,0,7] → 返回2(新数组[5,7])
const removeZero = (nums) => {
    let slow = 0;
    for (let fast = 0; fast < nums.length; fast++) {
        if (nums[fast] !== 0) {
            nums[slow] = nums[fast];
            slow++;
        }
    }
    return slow;
};

变种3:有序数组合并(商品价格区间合并)

// 合并两个有序数组(LeetCode88题核心逻辑)
const merge = (nums1, m, nums2, n) => {
    let p1 = m - 1, p2 = n - 1, tail = m + n - 1;
    while (p2 >= 0) {
        // 逆序比较插入尾部
        nums1[tail--] = (p1 >= 0 && nums1[p1] > nums2[p2]) 
                         ? nums1[p1--] 
                         : nums2[p2--];
    }
};

💻 前端实战:Vue中高效渲染去重数据

<template>
  <!-- 商品价格列表渲染 -->
  <div v-for="(price, index) in displayPrices" :key="index">
    {{ price }}元
  </div>
</template>

<script>
export default {
  data() {
    return {
      // 从API获取的含重复价格
      prices: [19,19,23,23,56] 
    };
  },
  computed: {
    displayPrices() {
      const uniqueCount = this.removeDuplicates(this.prices);
      // slice截取去重后部分
      return this.prices.slice(0, uniqueCount);
    }
  },
  methods: {
    removeDuplicates(nums) {
      // 双指针去重算法(同上文)
    }
  }
};
</script>

优化关键点

  1. 将算法封装为methods避免重复计算
  2. 使用computed属性缓存结果
  3. slice(0, N)获取有效数据避免渲染冗余元素

⚠️ 边界陷阱与防御编码

  1. 空数组处理
if (nums.length === 0) return 0; // 避免fast指针越界
  1. 非排序数组防御
// 开发环境检测
if (process.env.NODE_ENV === 'development') {
  for (let i = 1; i < nums.length; i++) {
    if (nums[i] < nums[i-1]) throw new Error("输入必须为排序数组!");
  }
}
  1. 非数字类型处理
if (typeof nums[fast] !== 'number') {
  console.warn('非数字元素:', nums[fast]);
  continue;
}

工程建议

  1. 大数据量优化
    • 超过10万数据时使用 Web Worker 避免阻塞UI
    // 主线程
    const worker = new Worker('dedupe-worker.js');
    worker.postMessage(prices);
    
  2. 与后端协作
    • 在SQL查询用 DISTINCT 初步去重减少前端压力
    SELECT DISTINCT price FROM products ORDER BY price;
    

简单题,简单做(Python/Java/C++/C/Go/JS/Rust)

把 $s$ 按照连续相同字母分成若干段,每段保留至多 $2$ 个字母。

示例 2 的 $s=\texttt{aaabaaaa}$,分成三段 $\texttt{aaa} + \texttt{b} + \texttt{aaaa}$,其中第一段和第三段不符合要求(有三个连续相同字符),保留 $2$ 个字母,变成 $\texttt{aa} + \texttt{b} + \texttt{aa} = \texttt{aabaa}$。

用一个计数器 $\textit{cnt}$ 统计每一段的当前长度,如果 $\textit{cnt}<3$ 就把当前字母加入答案。

如果当前字母和下一个字母不同,则重置 $\textit{cnt}=0$,统计下一段的长度。

###py

class Solution:
    def makeFancyString(self, s: str) -> str:
        ans = []
        cnt = 0
        for i, ch in enumerate(s):
            cnt += 1
            if cnt < 3:
                ans.append(ch)
            if i < len(s) - 1 and ch != s[i + 1]:
                cnt = 0  # 当前字母和下个字母不同,重置计数器
        return ''.join(ans)

###py

class Solution:
    def makeFancyString(self, s: str) -> str:
        ans = []
        for _, group in groupby(s):
            ans += list(group)[:2]
        return ''.join(ans)

###java

class Solution {
    public String makeFancyString(String s) {
        StringBuilder ans = new StringBuilder();
        int cnt = 0;
        for (int i = 0; i < s.length(); i++) {
            cnt++;
            if (cnt < 3) {
                ans.append(s.charAt(i));
            }
            if (i < s.length() - 1 && s.charAt(i) != s.charAt(i + 1)) {
                cnt = 0; // 当前字母和下个字母不同,重置计数器
            }
        }
        return ans.toString();
    }
}

###cpp

class Solution {
public:
    string makeFancyString(string s) {
        string ans;
        int cnt = 0;
        for (int i = 0; i < s.size(); i++) {
            cnt++;
            if (cnt < 3) {
                ans += s[i];
            }
            if (i < s.size() - 1 && s[i] != s[i + 1]) {
                cnt = 0; // 当前字母和下个字母不同,重置计数器
            }
        }
        return ans;
    }
};

###c

char* makeFancyString(char* s) {
    int cnt = 0, j = 0;
    for (int i = 0; s[i]; i++) {
        cnt++;
        if (cnt < 3) {
            s[j++] = s[i];
        }
        if (s[i] != s[i + 1]) {
            cnt = 0; // 当前字母和下个字母不同,重置计数器
        }
    }
    s[j] = '\0';
    return s;
}

###go

func makeFancyString(s string) string {
ans := []byte{}
cnt := 0
for i, ch := range s {
cnt++
if cnt < 3 {
ans = append(ans, byte(ch))
}
if i < len(s)-1 && byte(ch) != s[i+1] {
cnt = 0 // 当前字母和下个字母不同,重置计数器
}
}
return string(ans)
}

###js

var makeFancyString = function(s) {
    const ans = [];
    let cnt = 0;
    for (let i = 0; i < s.length; i++) {
        cnt++;
        if (cnt < 3) {
            ans.push(s[i]);
        }
        if (i < s.length - 1 && s[i] !== s[i + 1]) {
            cnt = 0; // 当前字母和下个字母不同,重置计数器
        }
    }
    return ans.join('');
};

###rust

impl Solution {
    pub fn make_fancy_string(s: String) -> String {
        let s = s.into_bytes();
        let mut ans = vec![];
        let mut cnt = 0;
        for (i, &ch) in s.iter().enumerate() {
            cnt += 1;
            if cnt < 3 {
                ans.push(ch);
            }
            if i + 1 < s.len() && ch != s[i + 1] {
                cnt = 0; // 当前字母和下个字母不同,重置计数器
            }
        }
        unsafe { String::from_utf8_unchecked(ans) }
    }
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n)$,其中 $n$ 是 $s$ 的长度。
  • 空间复杂度:$\mathcal{O}(n)$ 或 $\mathcal{O}(1)$,取决于实现。

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
  7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、二叉树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA/一般树)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

欢迎关注 B站@灵茶山艾府

5193. 删除字符使字符串变好 - 模拟

5193. 删除字符使字符串变好

第一道题。

要我们删掉字符串中一部分的字母,使这个字符串能没有一个片段连续三个字母相同。

在string上一个一个的删会超时,所以改成了用char记录新的字符串。

###c++

class Solution {
public:
    string makeFancyString(string s) {
        const int n = s.size();
        char ans[n + 1];  //用char存储新字符串
        ans[0] = s[0];  //记录第一个字符
        int j = 1;
        for(int i = 1; i < n; ++i, ++j){
            ans[j] = s[i];    //记录下这个字符
            if(ans[j] == ans[j - 1]){  //如果和先前字符相等
                while(i < n && s[i] == ans[j]) ++i;//一直遍历到与该字符不等的地方
                --i;  //后面还有++i,这里先减一下
            }
        }
        ans[j] = '\0';  //最后的中止符
        return ans;
    }
};
❌