普通视图

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

一天时间,用 Claude Code 蹬了一个 v0 出来(附源码)

2026年4月5日 20:49

最近,出于业务需要,参考 v0 的实现,蹬了一个类似 v0 的平台出来。

先看效果:

整体采用 Next.js 做前后端服务,E2B 提供沙箱,Claude Agent SDK 完成代码生成,沙箱提供预览和代码推送部署能力。

ps: 本文不会包含任何的代码(本身也都是 AI 生成的),只会介绍相关方案的选型、核心的架构和实现原理。同时关于部署的环节,各个公司都有自己的部署流水线,并不具备参考价值,会弱化这个环节的介绍。

方案对比和设计

AI 生成前端代码,一般有这么几种方式:一份 html,一份代码块,以及直接生成项目。

生成 html

生成一份 html,然后增删改查,最终存储 html 即可,不论是预览还是部署,都最为简单。

有很多产品都是这么做的,比如 Claude 的 Artifacts,Google 的 Stitch。

这是最简单,也最轻便的方案。

这里面的关键技术点有几个:

  1. 如何让 AI 生成高质量的 HTML?当然这也无非就是需要一些非常优秀的提示词来约束 AI 的行为。

  2. 如何增量修改?通过在浏览器侧实现一个支持局部替换的 Edit Tool 即可,这也是很多 cli 工具在本地修改代码的常见策略。

  3. 后期的可维护性是这个方案最大的隐患。生成的 HTML 往往是一个几百行甚至上千行的单文件,没有组件拆分,没有模块化,样式和逻辑全部混在一起。如果需要人工介入修改,多年程序员看到这样的代码,大概会有一种被拉回刀耕火种时代的感觉——能改,但很痛苦。这也意味着,一旦走上这条路,后续的迭代就只能继续依赖 AI,项目实际上已经不再适合人来维护。

ps: 这里可能会有人好奇,为什么不是修改某一行某几列的代码,这是因为 AI 对于行号识别不准确,反而直接执行字符搜索并替换更为准确。感兴趣的可以查看 pi-mono 项目中 edit 工具的实现,这也是绝大部分 cli 工具的实现方案。

至于 html 的预览和部署,可谓是极为简单且花费最少了。

生成代码块

另一种方式是:生成代码块,存储在数据库中,预览采用 WebContainer、Sandpack,或者通过 Babel 转 CommonJS 在浏览器端模拟打包等方式来预览前端项目。

这基本是纯前端的方案,不过 WebContainer 要授权,Sandpack 倒是开源,但是加载速度上可能存在一些问题。至于 Babel 转 CommonJS 自行实现编译系统,也是 ok 的,只是要支持 jsx, vue, 要花一点时间,开发的工作量不小。

当然,除了这些建设,如何稳定 AI 的输出,也是这个方案中的一大问题,理想情况下,希望 AI 的产物是 文件名 + 内容 组合成的 json 数组。

一般可以通过几个方案来解决:

  1. 换更好的模型
  2. 运用 XML 这样的提示词技巧,来让 AI 输出的更符合预期

但是这个方案有几个比较大的问题:

  1. 编译工程复杂度比较高
  2. 增量替换的方案,输出格式可能不如工具调用那般精准,在耗时和质量上会更低效一点。
  3. 对于外部依赖的包,需要提前做编译、告知 AI 用法等,相对不那么自由

直接生成项目

直接生成项目,最终预览和部署都和普通的项目一样。这也是 v0 的方案。

这个方案本质上是给用户准备一个沙箱,这个沙箱中,直接启动一个 claude code 或者 codex 这样的工具,可以是 cli 也可以是 sdk。

同时指定一个工作目录,最终的项目生成和运行,都发生在这个工作目录下。用户输入直接指向 claude code,从而完成项目的生成。

这个方案的灵活度最高,同时由于背后是最顶尖的 AI 生成工具,所以在质量上和效率上,其实都不太需要担心。

但是最大的问题就在于需要给每一个用户都提供一个沙箱,对于运维部署的能力要求比较高。

同时沙箱的内存分配和 cpu 分配,资源上也不能少。

不过好在已经有很多服务商提供这样的服务,比如 E2B、Cloudflare 等服务商。付费调 API 的话,准备一个沙箱也很容易。

对比表格

维度 生成 HTML 生成代码块 直接生成项目
实现复杂度
预览方案 直接渲染 iframe WebContainer / Sandpack / Babel 转 CommonJS 沙箱内启动 dev server
部署复杂度 极低,存 HTML 即可 低,纯前端方案 高,需要为每个用户分配沙箱
增量修改精准度 高(字符串 Edit Tool) 中(输出格式不如工具调用稳定) 高(Agent SDK 原生工具调用)
AI 输出稳定性 高(单文件,约束简单) 中(需要结构化 JSON 输出,依赖提示词技巧) 高(由 Agent 工具链保证)
外部依赖支持 弱(只能用 CDN 引入) 弱(需要提前编译、告知 AI 用法) 强(npm install 自由安装)
代码可维护性 低(不适合人工维护) 高(标准项目结构)
资源消耗 极低 高(沙箱需要分配内存和 CPU)
灵活度
代表产品 Claude Artifacts、Google Stitch Bolt.new(基于 StackBlitz WebContainer) v0、本文实现

架构设计

整体的架构图如上,分为三块:

  1. Next.js 前端:聊天输入框、消息流展示、代码文件树、实时预览 iframe,以及打断/重试等交互控制。

  2. Next.js 后端:接收前端消息,维护会话与沙箱的映射关系,将消息转发给对应沙箱内的 Agent,并将 Agent 的流式输出透传回前端。

  3. E2B 沙箱:基于自定义模板启动,模板内预装了 Node.js 环境和项目脚手架。沙箱内运行 Claude Agent SDK,负责代码的生成与修改;同时启动 dev server 并通过 E2B 的端口暴露能力对外提供预览。

消息流转

用户操作路径如下:

  1. 用户打开平台,发起第一条消息,后端按需创建 E2B 沙箱(冷启动约需几秒)
  2. 沙箱就绪后,后端将消息投递给沙箱内的 Claude Agent SDK
  3. Agent SDK 开始工作:调用文件读写工具生成或修改代码
  4. Agent 的输出以流式事件的形式,经后端透传回前端实时展示
  5. 代码变更同步到文件树,预览 iframe 直接加载沙箱暴露的端口

会话与沙箱管理

多用户场景下,每个会话对应一个独立的沙箱实例,隔离性天然满足。

上下文的维护完全交给 Agent SDK,后端只需持久化"会话 ID → 沙箱 ID"的映射即可。考虑到沙箱有闲置超时机制,需要在映射层做好沙箱的重建和恢复逻辑,一般沙箱的服务方基本都会内置这些能力。

部署发布

代码的部署和发布,一个比较通用的方案是在沙箱内完成 Git 提交,推送到远程仓库后触发 CI/CD 流水线,从而完成项目的上线。由于这部分强依赖各公司自身的发布体系,本文不展开。

整体来讲技术卡点并不多。最核心的 AI 代码生成能力,借助 Agent SDK 即可完成,质量和直接使用 Claude Code 打平。沙箱管理和前端页面反而是 AI 最擅长的部分,蹬起来毫无压力。

心得体会

整体蹬一个 v0,让 AI 写代码花费的时间其实并不多,大概一天左右就能蹬出来。

但是有一说一,这个方案,其实来来回回跟 AI 拉扯了几天,大到从生成 HTML,到生成片段代码,再到最后的沙箱方案,而小到增量更新的解决方案,Babel 转义的优劣,都属于考量的范畴。

包括是用 Agent SDK,还是直接用 Claude CLI,也是经过多方权衡后的结果。

一切方案落定,Plan Mode 开启,Opus 一开,反而是最轻松的时刻。

基本上第一次的产物,就能达到最小 demo 的效果。

至于交互上的细节,比如打断输入,补充说明,向用户提问明确需求,这些细节上的打磨,也是花点心思就能解决的地方。

整体来讲,在没有 AI 介入之前,其实是不太能这么快完成这样一个系统的。单单是沙箱方案的选型,可能都要花费个几天,比如沙箱的暂停和恢复,费用的对比等等,也是 AI 辅助决策的结果,有了决策,实现又是几天,确确实实在效率上提升非常大。

在这个过程中,我本身也是直接退订了 Cursor,因为完全不需要自己再上手手动修代码了,单说执行这块,AI 绝对是夯爆了。

很难说不焦虑,但又感觉不必太过焦虑。这次最大的体感不是"AI 写代码很快",而是整个过程中,花时间最多的地方依然是人在做的事——判断方案的取舍,理解各种工具的边界,决定什么值得做、什么可以砍掉。执行层 AI 确实夯爆了,但执行之前的那些决策,AI 只是参谋,拍板的还得是人。

所以与其焦虑被替代,不如想清楚自己在一件事里到底在做什么。毕竟 AI 还是得有人蹬,至于蹬到哪里去,这个问题 AI 替你答不了。

源码

本文的 POC(Proof of Concept,概念验证)代码已开源,即用最小的实现跑通"用户输入 → Agent 生成代码 → 沙箱预览"这条核心流程,感兴趣的可以查看:github.com/yuzai/code-…

昨天 — 2026年4月5日首页

现代 CSS 的新力量

2026年4月5日 20:01

如果你拆开一个稍微复杂一点的 Web 应用,很容易发现一个熟悉的现象。在还没写任何业务代码之前,就已经引入了一堆 JavaScript 库,只是为了实现一些基础的 UI 行为,比如提示框定位、滚动动画、弹窗管理,甚至一个简单的下拉选择框。而现在,这种情况正在发生改变——越来越多这样的能力,开始被 CSS 接管。

以提示框(Tooltip)和弹窗(Popover)为例,过去通常需要依赖像 Floating UI Popper 这样的库来计算位置、处理溢出、实现自动翻转。这类问题本质上是“布局问题”,却一直由 JavaScript 来解决。如今,CSS 锚点定位以及原生 Popover API 已经可以直接完成这些工作,浏览器会自动处理边界和对齐,不再需要手写复杂的逻辑。

类似的变化也发生在滚动动画上。过去如果想让动画跟随滚动进度变化,几乎离不开 GSAP ScrollTrigger。Web 开发者需要监听 scroll 事件、计算位置,再把结果映射到动画上。而现在,CSS 提供了滚动驱动动画(如 scroll-timeline),可以用声明式的方式直接把滚动和动画绑定起来,不仅代码更简单,而且性能更好,因为这些计算是在浏览器内部完成的。

在交互动效方面也是如此。像 Framer Motion 这样的库长期以来负责处理状态切换、进入和离开动画,甚至页面过渡。但随着 CSS 动画能力的增强(例如更强的 transiton@starting-style 以及视图过渡),这些原本依赖 JavaScript 控制时间轴的效果,正在逐渐转向用 CSS 描述。

再看弹窗和组件行为。过去我们常借助 Radix UI Headless UI 来处理模态框的焦点管理、键盘交互和可访问性细节。这些并不只是“显示一个弹窗”,而是涉及一整套复杂行为。而现在,浏览器提供了 <dialog>popover 等原生能力,很多行为已经内建,不再需要额外的库来兜底。

最后是表单控件这个长期的痛点。由于原生 <select> 难以样式化,开发者往往选择使用诸如 React Select 这样的库,从零实现一个组件。但随着 CSS 对原生控件样式能力的不断增强,我们越来越可以在保留原生语义和可访问性的前提下,对其进行自定义,而不是彻底重写。

把这些变化放在一起看,可以发现一个清晰的趋势,我们正在从“用 JavaScript 补足浏览器能力”,转向“直接使用浏览器提供的能力”。这不仅意味着更少的代码体积,也意味着更好的性能、更低的维护成本,以及更少需要手动处理的边界问题。本质上,这是一种回归——让 CSS 负责它本该负责的事情。

换句话说,一批被期待已久的 CSS 特性正在陆续落地,而且它们的目标很明确,替代那些过去必须依赖 JavaScript 才能实现的 UI 模式。而且,这不是“并不多能用”的替代方案。它们是浏览器提供的平台级能力——能够处理边界情况,运行在正确的渲染线程上,并且不依赖任何第三方库。

接下来,我们一起来看看,这些能力已经发展到了什么程度,它们具体替代了哪些 JavaScript 方案,你可以删掉多少代码,以及还有哪些问题,CSS 依然没有解决。

锚点定位

在很长的一段时间里,从提示框(Tooltip)、下拉菜单(DropdownMenu)、弹窗(Popover)到各种浮动菜单,这类 Web UI 一直依赖 JavaScript 来“贴住”触发元素。你要么使用第三方 JavaScript 库,要么自己写一套基于 getBoundingClientRect 的计算逻辑,在滚动、缩放和布局变化时不断更新位置。归根结底,浏览器本身一直缺少一个原生能力——让一个元素跟随另一个元素定位

CSS 锚点定位从根本上改变了这一点。现在,你可以用 anchor-name 声明一个锚点元素,然后通过position-anchor 将目标元素与锚点元素关联起来,再通过 anchor() 函数或者 position-areaposition-try ,将目标元素相对于锚点元素进行定位。所有的位置计算都交给浏览器完成,而且不仅仅是“放在那里”,连溢出处理也一并解决。比如,当 Tooltip 可能被视口裁切时,你可以定义多个备用位置,浏览器会按顺序自动尝试并选择合适的方案。

.button {
    anchor-name: --my-button;
}

.tooltip {
    position: absolute;
    position-anchor: --my-button;
    top: anchor(bottom);
    left: anchor(left);
}

Demo 地址:codepen.io/airen/full/…

不需要 JavaScript,不需要 ResizeObserver,也不需要监听 scroll。一旦建立了锚点关系,浏览器就会在滚动、尺寸变化以及布局变动时,自动保持浮层元素与锚点之间的对齐关系。这类原本需要频繁计算和同步的逻辑,现在完全交由浏览器底层处理。

这里展示的是基于 CSS 锚点定位实现的一个最基础版本的 提示框组件。但它的能力远不止于此。CSS 锚点定位不仅可以用来做提示框组件,还可以扩展到许多过去必须依赖 JavaScript 的交互场景,比如:熔岩导航菜单元素之间的联动动画、类似 macOS Dock 的导航效果滑动跟随的悬浮层带“磁性吸附”的悬浮交互,甚至是元素之间的连接线效果等。这些原本需要复杂计算和事件监听的功能,现在都可以用更原生、更简洁的方式实现。

在浏览器支持方面,Google Chrome 已在 2024 年(Chrome 125)提供了较为完整的实现,而 Mozilla Firefox 和 Safari 也在持续推进中。目前,一些基础能力(如 anchor-name anchor() 函数和 position-area)已经可以在主流浏览器中使用;而像 position-tryposition-visibility 这样的高级特性,仍在逐步落地。

温馨提示:如果你希望更系统、更深入地掌握 CSS 锚点定位相关特性,建议抽时间阅读《CSS 锚点定位: 探索下一代 Web 布局》和《CSS 布局:重聊 CSS 锚点定位》这两节课程。它们会从基础到进阶,帮助你全面理解这一能力,并应用到实际项目中。

Popover API

CSS 锚点定位和 HTML Popover API,其实是在解决同一个问题的两个不同侧面。CSS 锚点定位负责“浮层出现在哪里”,HTML Popover API 负责“浮层是否出现”。也就是说,CSS 锚点定位负责处理位置,而 HTML Popover API 处理可见性、交互行为以及可访问性。一套完整的提示框(Tooltip)或弹窗(Popover)组件,通常需要两者配合使用。

在 Popover API 出现之前,构建一个真正“可用”的 Popover 、下拉菜单或非模态弹框,往往需要使用 JavaScript 实现一整套复杂逻辑:焦点管理、维护 aria-expanded 状态、处理键盘交互、监听点击外部关闭等。这些细节不仅繁琐,而且非常容易出错,这也是为什么像 Headless UI、Radix UI 这类库长期存的原因。

而现在,事情变得简单得多。通过 HTML 的 popover 属性以及配套的 popovertarget ,你只需要少量的 HTML 结构,就可以获得一个原生、可访问的 Popover 组件。浏览器会自动帮你处理一系列关键行为,包括显示与隐藏的切换、点击外部关闭、按下 Escape 键关闭、使用 ::backdrop 创建背景遮罩、焦点管理,以及顶层渲染(popover 始终会显示在页面最上层,无需再处理复杂的 `z-index``)。

<button invoketarget="ref_1" popovertarget="ref_1">roll</button>
<div  popover id="ref_1" class="card"></div>

Demo 地址:codepen.io/airen/full/…

这些能力如今已经在 Google Chrome、Mozilla Firefox 和 Safari 中成为基础功能。对于绝大多数常见场景来说,它已经可以替代过去依赖各种第三方 JavaScript 库所实现的整套交互逻辑。

Popover API 非常适合用来构建提示框下拉菜单、上下文菜单、通知提示、引导提示等各种非模态浮层——也就是那些用户点击外部时应该自动关闭的界面。但需要注意的是,它并不是用来替代模态对话框的,对于需要阻止背景交互的场景,仍然应该使用 <dialog> 元素(稍后会详细介绍)。

温馨提示,如果你想更深入的了解 Popover API 相关的特性,请移步阅读《CSS 布局:CSS 与 Popover API 的结合》!

AIM(Anchor Interpolated Morph)

AIM (Anchor Interpolated Morph,锚点插值变形)是一种前沿的 Web 动画技术,它的核心理念是将元素视为“锚点”,通过位置、尺寸和形状的插值计算,实现元素之前的平滑过渡动画。通俗来说,AIM 能让一个元素看起来像是从另一个元素位置“生长”出来,或者在关闭时平滑地返回原来的位置。与传统的 过渡或动画 不同,AIM 不仅关注单一属性的变化(例如位置或透明度),而是综合考虑元素的空间信息和布局锚点,在不同状态之间自动生成连贯的动画路径,使界面变化更加自然流畅,同时保留用户的空间感知,让用户清楚地理解元素之间的关系。从动画效果来看,AIM 与早期的 F.L.I.P(First, Last, Invert 和 Play)技术非常相似。

只不过,在技术实现上,AIM 利用了一系列 现代 CSS 特性CSS 锚点定位anchor()anchor-size())允许一个元素将另一个元素的坐标和尺寸作为起点,从而建立空间关联。 @starting-style 规则定义元素在渲染瞬间的样式,例如弹出框初次出现的状态,使入场动画自然流畅。interpolate-size 属性则支持在内联关键词尺寸(如 automin-content)与具体长度尺寸(如 300px)之间动画化,保证元素在状态变化时平滑过渡。这些功能结合在一起,让浏览器可以在渲染时自动计算元素的起始状态和目标状态,并在它们之间生成连续动画,无需 JavaScript 干预,同时动画可以自然中断,响应用户操作。

<div class="cell">
    <button popovertarget="img-1" aria-label="Open Image of Mountain Landscape">
        <img src="https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=700&h=400&fit=crop&auto=format" alt="Mountain landscape" loading="lazy" />
    </button>
    <img popover id="img-1" src="https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=1800&auto=format" alt="Mountain landscape" loading="lazy" />
</div>
:root {
    interpolate-size: allow-keywords;
}

button {
    anchor-name: --morph;
}

[popover] {
    --speed: 0.5s;
    position-anchor: --morph;
    
    @media (prefers-reduced-motion: no-preference) {
      transition:
          display var(--speed) allow-discrete,
          overlay var(--speed) allow-discrete,
          height var(--speed) ease,
          width var(--speed) ease,
          top var(--speed) ease,
          left var(--speed) ease;
    }
    
    &:popover-open {
        height: auto; /* 或 fit-content */
        max-height: 70dvb;
        width: 70dvi;
        left: 15dvi;
        top: 15dvb;
        
        @starting-style {
            left: anchor(left);
            top: anchor(top);
            width: anchor-size(width);
            height: anchor-size(height);
        }
    }
    &:not(:popover-open) {
        left: anchor(left);
        top: anchor(top);
        width: anchor-size(width);
        height: anchor-size(height);
    }
}

AIM 技术在实际应用中非常适合图片画廊、列表展开和弹窗动画等场景。例如,在图片画廊中,缩略图可以作为锚点,大图从缩略图位置动画展开,填充屏幕;关闭时,大图又平滑缩回原位。这种动画方式不仅直观、有趣,还增强了用户对界面元素来源和去向的认知,使交互更易理解。

Demo 地址:codepen.io/airen/full/…

温馨提示,如果你对 AIM 技术感兴趣的话,建议你花点时间阅读《Web 动效:锚点定位之锚点插值变形(AIM)解析》和《Web 动效:用 AIM 做出高级感 UI 过渡》!

dialog 元素

如果说 Popover API 适用于非阻塞的浮层——用户点击外部即可关闭,那么 HTML 的 <dialog> 则用于真正的模态体验(模态框):在关闭之前,必须完全占据用户注意力的那类交互。

在传统的 JavaScript 实现中,这类模态对话框往往需要一整套繁琐的逻辑,比如创建背景遮罩(通常用 div 来创建)、模态框的定位、手动设置 aria-modal ,在模态框内捕获焦点、关闭时恢复焦点,以及禁止页面滚动等。这些细节不仅复杂,而且很容易遗漏,进而影响可访问性。

而原生 <dialog> 元素把这一切都内置了。结合 ::backdrop 伪元素和 .showModal() 方法,你只需要一个 HTML 元素和少量的 JavaScript 就可以创建一个完整、可访问的模态对话框。浏览器会自动处理焦点管理、遮罩层以及交互行为,大大简化了实现过程。

<button class="button" onclick="document.getElementById('my-dialog').showModal()">Open dialog</button>

<dialog id="my-dialog">
    <div class="dialog-header">
        <div class="dialog-title">Delete this file?</div>
    </div>
    <div class="dialog-body">
        This action cannot be undone. The file will be permanently removed from your workspace.
    </div>
    <div class="dialog-footer">
        <button class="button secondary" onclick="document.getElementById('my-dialog').close()">Cancel</button>
        <button class="button" onclick="document.getElementById('my-dialog').close()">Delete</button>
    </div>
</dialog>

其中最关键的“模态陷阱”——也就是 Tab 键只在对话框内部循环、按下 Escape 键自动关闭——同样由浏览器原生处理。从 2022 年开始,这些能力已经成为主流浏览器的基础支持,让模态对话框的实现从“复杂工程”变成了“开箱即用”。

Demo 地址:codepen.io/airen/full/…

温馨提示,有关于 dialog 更详细的介绍,请移步阅读《用于美化模态框的 :modal::backdrop》!

Popover API vs. dialog 元素:有什么区别?

在 Popover API 和 dialog 元素看起来都在处理“弹出层”,但本质上并不是同一类问题。两者在可访问性和交互模式上存在明显差异,这也是它们各自适用场景不同的根本原因。Popover API 更偏轻量、非阻塞的浮层,而 dialog 则是为严格的模态交互而设计。

或者说,两者核心区别在于页面的其他部分是否仍然可以交互:

功能 Popover API dialog 元素
阻止背景交互
点击外部关闭
焦点陷阱
滚动锁定
适用场景 Tooltip、下拉菜单、Toast 等 确认框、表单、警告
API 使用方式 纯声明式 HTML 需要 JavaScript(.showModal()

两者的控制模型也体现了这一差异。Popover API 完全不需要 JavaScript —— popovertarget 就能在 HTML 中把按钮和面板绑定起来。dialog 如果只用 .show() 打开,则是非模态框,几乎没有实际用处;真正的模态框(具有模态行为)依赖 .showModal()。两都都可以用 Escape 关闭,但只有 dialog 会捕获焦点并在关闭前阻止与页面其他部分的交互。

因此,在实际使用中可以遵循一个简单的原则:大多数普通的弹出层场景优先选择 Popover API;只有当你需要一个真正阻止用户与页面其他部分交互的模态对话框时,才使用 dialog

detailssummary

原生 <details><summary> 提供了一种极其直接的方式来实现展开与折叠的交互模式。像 FAQ、手风琴、可折叠信息区域这类 UI,在过去往往需要通过 JavaScript 控制状态切换、管理类名甚至处理动画,而现在,我们可以用一个段箭洁的 HTML 原生完成。

<details>
    <summary>查看详细内容</summary>
    <p>这里是展开后的内容,用户可以点击 summary 来切换显示。</p>
</details>

更重要的是,这不仅仅是“更简单”的实现方式,它还是一种语义化且可访问的解决方案。浏览器会自动处理展开状态(open 属性)、键盘交互(如 Enter / Space 切换)、以及与屏幕阅读器的兼容性。这意味着你不再需要手动管理 aria-expanded、焦点状态或键盘事件,这些过去容易出错的细节,现在都由浏览器原生保证。

从扩展性来看,<details> 也并不局限于基础用法。你可以通过 CSS 精细控制它的外观,比如自定义 summary 的样式、隐藏默认箭头、甚至结合动画实现更流畅的展开效果。对于手风琴场景,还可以通过少量 CSS(或配合 :has() 等现代选择器)实现“同一时间只展开一个”的交互,而无需 JavaScript。

Demo 地址:codepen.io/airen/full/…

在浏览器支持方面,<details><summary> 已经成为现代浏览器的基础能力,可以放心用于生产环境。而在开发体验上,它几乎是“零成本”的:无需引入库、无需编写逻辑,就能获得完整的交互与可访问性支持。

对于绝大多数折叠类 UI 场景,这通常是最简单、最可靠、也最推荐的实现方式

温馨提示,有关于这方面更详细的介绍,请移步阅读《CSS 布局:创建手风琴组件》和《Web UI:使用现代 CSS 创建手风琴组件》!

需要特别指出的是,发展到今天,很多“内容切换”类的交互,其实已经不再依赖 JavaScript。借助现代 CSS 与原生 HTML 能力,你完全可以用纯 CSS(或极少量声明式 HTML) 实现这些效果。常见的方式包括:利用 :has() 搭配 inputcheckbox / radio)实现状态驱动的切换,使用 <dialog> 构建模态交互,通过 Popover API 控制浮层显示,以及 <details><summary> 实现折叠内容等。

这些方案不仅实现更简单,而且更加语义化、可维护,并且在可访问性方面由浏览器原生保障。如果你对这一类“用 CSS 替代 JavaScript”的实现方式感兴趣,可以进一步阅读《CSS 布局:切换内容的现代方式》深入了解。

滚动驱动动画

在过去,所谓“滚动驱动动画”,几乎等同于一套固定模式。用 JavaScript 监听 scroll 事件,配合 requestAnimationFrame ,在每一帧中读取 window.scrollY ,再去更新 CSS 自定义属性或 transformopacity 等属性值。随着页面复杂度的提升,这种做法很容易带来性能问题——频繁的主线程计算、布局抖动,以及难以维护的同步逻辑。像 GSAP ScrollTrigger 这样的库,本质上就是为了让这种模式更可控、更易管理而存在。

而现在,CSS 正在接管这一切。随着滚动驱动动画规范的引入,你可以通过 animation-timelinescroll()view())直接将动画绑定到滚动进度上。也就是说,动画不再依赖 JavaScript 去驱动,而是由浏览器根据滚动状态自动计算和执行——整个过程完全声明式,无需额外脚本。

@keyframes spin {
    to {
        rotate: y 5turn;
    }
}

@supports (animation-timeline: scroll()) {
    @media (prefers-reduced-motion: no-preference) {
        figure {
            animation: spin linear both;
            animation-timeline: scroll();
        }
    }
}

Demo 地址:codepen.io/airen/full/…

这种变化的关键在于执行方式的不同,动画运行在浏览器的合成线程上,而不是主线程。这意味着即使在复杂页面或高负载情况下,动画依然可以保持流畅,不会因为 JavaScript 阻塞而掉帧。从“手动驱动”到“浏览器原生驱动”,这正是 CSS 在这一类交互能力上的一次质变。

更重要的是,CSS 滚动驱动动画的意义,早已不只是“做滚动动画”这么简单。它本质上提供了一种基于滚动状态驱动 UI 的能力,而这正是过去大量依赖 JavaScript 才能实现的交互核心。

借助这些能力,你可以用纯 CSS 实现一整类复杂效果。例如:根据滚动位置判断内容状态(如文本是否溢出数量尺寸变化)、构建滚动进度指示器自动高亮当前章节的目录(TOC)、实现滚动遮罩效果,甚至是更具表现力的交互——比如类似 macOS Dock 的图标放大效果按钮在滚动中切换为导航栏元素的渐入渐出动画带渐变效果的滚动条滑动删除3D 翻转滚动视差,以及轮播CoverFlow 等组件。

这些过去需要大量事件监听、状态同步和手动计算的逻辑,现在可以直接通过 CSS 声明式地完成。你不再需要“监听滚动再驱动 UI”,而是把“滚动本身”作为动画时间轴,让浏览器去完成剩下的一切。这种思维方式的转变,才是它真正强大的地方。

温馨提示,如果你想进一步玩转 CSS 滚动驱动动画?不妨看看《CSS 滚动驱动动效》和《Web 动效:滚动驱动动效之实战技巧》课程!

滚动状态查询

CSS 滚动状态查询可以看作是对容器查询的一次重要扩展(容器查询在滚动状态查询之前已有**尺寸容器查询样式容器查询**)。它将由浏览器内部管理的滚动状态直接暴露给 CSS,使得 Web 开发者可以基于滚动行为进行样式控制,而完全不需要借助 JavaScript。该特性已在 Google Chrome 133 中发布,Mozilla Firefox 和 Safari 的支持也正在推进中。

这一功能的核心在于,它提供了三类常见但过去必须依赖 JavaScript 才能获取的滚动状态:stuck (粘性状态)、 snapped (吸附状态)和 scrollable (可滚动状态)

首先是 stuck。对于 position: sticky 元素,过去如果想知道它是否已经“吸附”在顶部,通常需要借助一个额外的哨兵元素,再配合 IntersectionObserver 来判断。而现在,只需声明 container-type: scroll-state,就可以在 CSS 中直接根据是否处于粘性状态来应用样式,比如在吸顶时添加阴影效果。这种从“监听状态”到“声明状态”的转变,大大简化了实现方式。

.stuck-top {
    /* 粘性定位,是必须要的 */
    position: sticky;
    top: 0;
    
    @supports (container-type: scroll-state) {
        container-type: scroll-state; /* 定义滚动状态查询容器 */
        
        .heading {
            transition: box-shadow 0.5s ease-out;
            
            @container scroll-state(stuck: top) {
                box-shadow: 
                    rgb(0 0 0 / 0.6) 0px 12px 28px 0px,
                    rgb(0 0 0 / 0.1) 0px 2px 4px 0px,
                    rgb(255 255 255 / 0.05) 0px 0px 0px 1px inset;
            }
        }
    }
}

Demo 地址:codepen.io/airen/full/…

其次是 snapped。在滚动捕捉场景中,过去要判断哪个元素当前被吸附,需要监听 scrollsnapchange 事件,并手动切换类名。而现在,被吸附的元素可以直接通过 CSS 感知自身状态,从而改变样式或影响其子元素。例如:

.list {
    scroll-snap-type: x mandatory;
    overflow-x: auto;
    scroll-padding-inline: 2ch;

    li {
        scroll-snap-align: center;

        @supports (container-type: scroll-state) {
            container-type: scroll-state;
    
            :is(.blur, .content) {
                translate: 0 100%;
                transition: translate 0.2s ease-in-out;
                
                @container scroll-state(snapped: x) {
                    translate: 0;
                }
            }
    
            figure img {
                @container  scroll-state(snapped: x) {
                    mix-blend-mode: darken;
                    scale: 1.5;
                }
                
                @container not  scroll-state(snapped: x){
                    filter: grayscale(1);
                }
            }
        }
    }
}

Demo 地址:codepen.io/airen/full/…

最后是 scrollable。像“只有在内容可滚动时才显示滚动阴影”或“滚动到一定位置才出现返回顶部按钮”这类常见需求,以往通常依赖滚动监听或观察器 API。现在,这些都可以通过 CSS 条件直接表达——当某个方向不可滚动时隐藏对应 UI,从而实现更简洁、声明式的控制。

.scroll-container {
    overflow-y: auto;
    overflow-x: hidden;
    scroll-snap-type: y mandatory;
    inline-size: 30em;
    block-size: 18.735lh;
    border: 1px solid var(--surface-1);
    scroll-padding-block: 10px;
    overscroll-behavior: contain;
    display: grid;

    > * {
      grid-area: 1/1;
    }

    > .scroll-indicator {
        place-self: end;
        position: sticky;
        inset-block-end: 10px;
        inline-size: 100%;
        text-align: center;
        transition: translate 0.2s ease;

        > svg {
            background: var(--surface-2);
            aspect-ratio: 1;
            border-radius: 1e3px;
            inline-size: 48px;
            block-size: 48px;
        }
    }

    @supports (container-type: scroll-state) {
        container-type: scroll-state size;

        > .scroll-indicator {
            @container scroll-state((scrollable: top) or (not (scrollable: bottom))) {
                translate: 0 calc(100% + 10px);
            }
    
            @container scroll-state((scrollable: top) and (not (scrollable: bottom))) {
                translate: 0 calc(100% + 10px);
                rotate: 0.5turn;
            }
        }
    }

    .item {
        scroll-snap-align: start;
        scroll-snap-stop: always;
    }
}

Demo 地址:codepen.io/airen/full/…

这三类状态查询,本质上替代了过去大量基于事件监听(scroll)或观察器(IntersectionObserver)的实现方式。你不再需要“监听滚动再更新状态”,而是可以直接在 CSS 中描述“当处于某种滚动状态时应该如何表现”,让浏览器去完成剩余的工作。这正是现代 CSS 在交互能力上的又一次重要跃迁。

温馨提示:如果你希望更深入理解滚动状态查询的原理与实际应用,建议进一步阅读《Web UI:容器查询之滚动状态容器查询》和《Web UI:CSS 中的滚动驱动方向》这两篇内容。它们会从基础概念到进阶实践,帮助你全面掌握这一能力,并将其灵活运用到真实项目中。

更有趣的是,如今结合 CSS 的 滚动驱动动画滚动捕捉等能力,你已经可以用纯 CSS 构建复杂的交互组件。像 Web 上常见的轮播Carousel)甚至 CoverFlow 这类具有空间感和动态效果的组件,过去往往需要一整套 JavaScript 逻辑来驱动,而现在则可以通过声明式的方式直接实现。例如,通过滚动捕捉控制滚动对齐,再配合滚动驱动动画绑定滚动进度,你可以轻松实现类似 Nintendo Switch 主屏幕那种带有缩放、聚焦和过渡效果的轮播体验

Demo 地址:codepen.io/airen/full/…

需要特别提出的是,CSS 正在引入一种新的查询能力——锚定容器查询。它是在尺寸容器查询、样式容器查询以及滚动状态查询之后的又一次扩展,进一步增强了 CSS 对“上下文感知”的能力。

当你结合 CSS 锚点定位使用时,这种能力会变得尤为强大。通过锚定容器查询,你可以让组件根据相对于锚点的位置变化自动调整自身表现,例如在发生位置回退时切换样式,而这一切都可以在 CSS 中声明完成。

Demo 地址:codepen.io/airen/full/…

换句话说,过去需要通过 JavaScript 检测位置、监听变化并手动更新 UI 的逻辑,现在可以完全交由 CSS 处理。这使得复杂的浮层、提示框以及自适应交互界面,能够以更简洁、更稳定的方式实现,真正做到“由 CSS 驱动 UI 行为”。有关于这方面更详细的介绍,请移步阅读《Web 组件:一种基于锚定容器查询的动态切换提示方案》!

视图过渡

在过去,单页应用(SPA)的页面切换动画——也就是路由切换时“旧页面淡出,新页面淡入”的效果,几乎完全依赖 JavaScript。无论是 React 的 react-transition-group,还是 Vue 的 <Transition> 组件,本质上都在帮你管理一套流程:克隆元素、设置绝对定位、同步多个动画状态,并手动控制 opacitytransform 的时间轴。这不仅实现成本高,而且维护起来也相当繁琐。

而 CSS 视图过渡(CSS View Transition API)将这一切交还给浏览器来完成。你只需要用 document.startViewTransition() 包裹一次 DOM 状态更新,浏览器就会自动捕捉更新前后的界面状态,并在两者之间执行平滑过渡(例如交叉淡入淡出)。整个过程无需手动管理中间状态,同时你仍然可以通过 CSS 自定义动画效果。

对于同一文档内的状态切换,这几乎就是完整的 API 使用方式。而在跨文档导航(也就是真正的页面跳转)中,只需一行 CSS —— @view-transition {navigation: auto} ——即可启用类似的过渡效果,让多页面应用也能拥有接近 SPA 的流畅体验。

更进一步,命名视图过渡还允许你针对特定元素创建跨页面动画,比如卡片从列表页“展开”进入详情页。这类效果在过去通常需要复杂的布局克隆与同步技巧,而现在可以用更直观的方式实现。

在浏览器支持方面,Google Chrome 已率先支持该特性,而 Safari 和 Mozilla Firefox 也在持续推进中。整体来看,这标志着页面过渡动画正从“框架能力”逐步转变为“平台能力”。

Demo 地址:codepen.io/airen/full/…

时至今日,CSS 视图过渡(View Transitions)已逐渐成为一种通用的界面状态过渡机制。借助它,你可以轻松实现诸如主题切换(如浅色/深色模式的平滑过渡)、展开与折叠的动效、甚至是带有氛围感的动态灯光效果等。这些原本需要精细控制时间轴和状态同步的交互,现在都可以交由浏览器统一处理。

这种能力的关键在于,它将“状态变化”与“视觉过渡”解耦:你只需要关心界面更新本身,而动画如何发生、如何衔接,则由浏览器自动完成,同时又允许你通过 CSS 精细定制细节。这让复杂动效的实现变得更加自然,也更易于维护。

如果你想进一步探索这些技巧和实际应用场景,可以移步阅读《解锁 CSS View Transitions API 的魔力》,深入了解这一能力的更多可能性。

自定义下拉选择框

自定义下拉选择框,几乎是 Web 上被“重复造轮子”最多的 Web UI 组件之一。长期以来,由于原生 <select> 难以进行深度样式化,各大设计系统不得不从零实现一整套替代方案:隐藏原生 <select> 以保留可访问性,用自定义元素作为触发器,手动定位下拉列表,实现键盘导航、焦点管理,以及为屏幕阅读器补充语义支持。为了替代一个基础的表单控件,往往需要成百上千行代码,这本身就是平台能力缺失的体现。

而“可定制下拉选择框”正是为了解决这一长期痛点。它允许 Web 开发者通过 CSS 直接控制 <select> 以及其内部结构。不令可以自定义触发按钮的外观,还可以样式化下拉容器,甚至精细控制每一个 <option> 。更进一步,你还可以在选项中使用任意 HTML 内容,让下拉选择框具备更丰富的表达能力。

@supports (appearance: base-select) {
    select {
        anchor-name: --select;
        padding-block: 0;
        
        &::picker-icon {
            display: none;
        }
      
        .icon {
            width: calc(var(--option-size) * 0.375);
            height: calc(var(--option-size) * 0.625);
        }
    }
    
    /* 美化下拉框 */
    ::picker(select) {
        --counts: 6;
        --rotation-divide: calc(360deg / var(--counts));
        position-anchor: --select;
        top: anchor(center);
        left: anchor(center);
        translate: -50% -50%;
        overflow: visible;
        transition: overlay 0.5s, display 0.5s;
        transition-behavior: allow-discrete;
        margin: 0;
        padding: 0;
        background: transparent;
        border: none;
    }
}

这项能力的意义在于,它将一个“必须用 JavaScript 重写”的组件,重新带回到原生平台层面。你不再需要在“可访问性”和“设计一致性”之间做取舍,而是可以同时获得两者。

目前,Google Chrome 已在实验性标志下提供了初步实现。虽然规范仍在持续演进中,但整体方向已经非常清晰:未来,自定义下拉选择框将不再是复杂的工程问题,而只是一次普通的 CSS 样式设计。

Demo 地址:codepen.io/airen/full/…

温馨提示:如果你想更深入了解自定义下拉选择框的实现原理与进阶用法,可以进一步阅读相关内容,深入探索这一特性的更多可能性与实践技巧。

焦点组

在组合型控件(如工具栏、标签列表、单选组或菜单)中实现键盘的箭头导航,一直是一项重复且繁琐的工作。传统做法通常需要编写大量 JavaScript,比如监听 keydown 事件、判断 ArrowRightArrowLeftArrowUpArrowDown,手动维护 tabindex,并在用户通过 Tab 重新进入组件时,记住上一次的焦点位置。几乎每一个 UI 库都有自己的一套实现方案,比如 React 中常见的 roving-tabindex 模式,或 Fluent UI 提供的类似工具(FocusZone),本质上都在解决同一个问题。

而 Open UI 提出的 focusgroup 属性,试图将这一整套逻辑声明式地交还给浏览器。只需要在容器元素上添加一个属性,浏览器就可以自动管理其内部可聚焦子元素之间的箭头键导航,无需任何 JavaScript 参与。这不仅简化了实现,也让行为更加一致和可预测。

<div role="toolbar" focusgroup aria-label="Text Formatting">
    <button type="button" tabindex="-1">Bold</button>
    <button type="button" tabindex="-1">Italic</button>
    <button type="button" tabindex="-1">Underline</button>
</div>

同时,focusgroup 还提供了一些可选配置,用来微调导航行为。例如,可以通过 inlineblock 限制导航方向(仅水平或垂直),使用 wrap 实现循环导航,以及通过 no-memory 控制每次重新聚焦时是否回到初始项,而不是上一次的焦点位置。

<div role="tablist" focusgroup="inline wrap no-memory">
    <button role="tab" tabindex="0" aria-selected="true">Mac</button>
    <button role="tab" tabindex="-1" aria-selected="false">Windows</button>
    <button role="tab" tabindex="-1" aria-selected="false">Linux</button>
</div>

更进一步,这一机制还支持嵌套结构。例如,一个水平的菜单栏内部包含垂直的子菜单,每个层级都可以拥有独立的导航轴向,从而让不同方向的箭头键各司其职,形成更自然的交互体验。

目前,focusgroup 仍处于提案阶段,尚未被浏览器正式实现。不过,其更具体的演进版本正在积极推进中。考虑到它所替代的模式在 Web 开发中极为普遍,这项能力很可能会成为未来原生平台的一部分。

瀑布流布局

像 Pinterest 这种瀑布流布局(高度不一的卡片按列紧密排列、自动填补空隙),一直是 Web 上的经典布局需求。但长期以来,这类布局几乎只能依赖 JavaScript 实现。像 Masonry.js 、Isotope 这样的库之所以流行,正是因为传统的 CSS Grid无法原生支持这种“跨行填充”的布局方式——Grid 项目始终遵循严格的行列轨道,无法自动回填空白。

而 CSS 正在补上这一块能力。最初的 Masonry 提案通过 grid-template-row: masonry (以及对应的列方向变体),让浏览器自动计算并填补布局中的空隙,从而实现真正意义上的瀑布流布局,无需额外的脚本介入。

.masonry {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    grid-template-rows: masonry;
    gap: 16px;
}

不过,规范的发展并没有止步于此。随着讨论的推进,这一能力被重新设计并更名为 Grid Lanes,强调其作为 CSS Grid 体系的一部分,而不是一个全新的 display 类型。新的方向是通过类似 grid-lanes 机制,让布局在既有 Grid 模型中具备“跨轨道填充”的能力,从而实现更加灵活的排布方式。

.masonry {
    display: grid-lanes;
    grid-template-columns: repeat(3, 1fr);
    gap: 16px;
}

这一转变的意义在于,它不只是“让瀑布流变得可用”,而是将这一能力系统性地整合到 CSS 网格布局体系。Web 开发者不再需要依赖 JavaScript在布局完成后再进行二次计算(这也是导致布局抖动和性能问题的根源),而是可以直接交由浏览器在布局阶段完成。

目前,Mozilla Firefox 已经在实验性标志下提供相关实现,Google Chrome 也在积极推进中。虽然规范仍在演进,但方向已经非常明确:未来,瀑布流布局将成为 CSS 的原生能力,而不是一个需要额外库来“修补”的特例。

Demo 地址:codepen.io/airen/full/…

温馨提示:如果你想更深入了解瀑布流布局相关的实现原理与实践方式,可以进一步阅读《CSS Grid 之瀑布流布局:masonrymasonry-auto-flow》和《CSS 布局:从 Masonry Layout 到 Grid Lanes——CSS Grid 的新能力》获取更详细的讲解。同时,如果你希望系统性掌握现代 Web 布局(包括 Grid、Flexbox 以及最新布局能力)的设计思路与实战技巧,也可以参考我的小册《现代 Web 布局

field-sizing

field-sizing: content 为表单元素带来了一项看似简单却非常实用的能力:让 <input><select><textarea> 可以根据内容自动调整尺寸。也就是说,输入多少内容,字段就增长多少空间,无需额外干预。

在过去,如果你想实现一个“自动增高”的 textarea ,通常需要借助 JavaScript 监听输入事件、读取 scrollHeight 、再手动更新高度。这不仅代码繁琐,还容易引发性能生布局问题。而现在,这一切都可以通过一行 CSS 完成:

textarea {
  field-sizing: content;
}

Demo 地址:codepen.io/airen/full/…

这种变化的意义在于,它将一个常见但细碎的交互需求,从“脚本逻辑”转变为“样式声明”。你不再需要关心何时更新尺寸,也不需要处理边界情况,浏览器会根据内容自动完成计算与渲染。

目前,Google Chrome 已在 2024 年支持该特性。虽然它只是一个相对小的功能点,但在实际开发中却非常“解放生产力”——一旦用上,就很难再回到过去的实现方式。

温馨提示:如果你想更深入了解 field-sizing 的工作原理、兼容性以及更多实际应用场景,可以移步阅读《CSS 排版:利用 field-sizing 属性实现自动尺寸调整》,进一步探索这一特性的细节与最佳实践。

sibling-index()sibling-count()

在过去,如果你想根据元素在 DOM 中的位置来做样式变化,比如实现“第几个元素有不同的样式”、“根据数量做渐变效果”或“按顺序延迟动画”,通常需要借助 JavaScript 来计算索引或统计数量,再动态添加类名或设置变量。

sibling-index()sibling-count() 的出现,让这些需求可以直接在 CSS 中完成。它们允许你获取元素在同级中的位置索引以及同级元素总数,从而实现基于结构的动态样式控制,无需任何 JavaScript 参与。

例如,sibling-index() 可以用来为列表创建基于顺序的动画延迟,而 sibling-count() 则可以帮助你根据元素数量计算布局或分布效果。两者结合,可以构建出更加灵活的 UI 表现,比如均匀分布、渐变强度、然度计算等。

.wave-bar {
    animation: wave 1.2s ease-in-out infinite;
    animation-delay: calc(sibling-index() * 0.1s);
}

Demo 地址:codepen.io/airen/full/…

这种能力的关键在于,它把“结构信息”直接暴露给 CSS,让样式可以感知上下文,而不再只是静态描述。这意味着很多过去需要“读取 DOM → 计算 → 写回样式”的流程,现在可以一步到位地用 CSS 声明完成。

虽然这些特性目前仍处于提案或实验阶段,但它们所代表的方向非常明确:CSS 正在逐步具备处理“逻辑关系”的能力,让越来越多原本属于 JavaScript 的工作,回归到样式层来解决。

温馨提示:如果你想更深入了解 sibling-index()sibling-count() 的原理与实际应用,可以继续阅读下面这几节课程,系统掌握它们在复杂 UI 场景中的使用方式与技巧。

原生 CSS 条件判断

过去,CSS 自定义属性虽然可以通过“回退值”实现一些类似条件判断的效果,但本质上只是权宜之计,并不是真正的逻辑控制。你只能在有限的范围内“模拟条件”,而无法直接达到清晰的分支判断。

.switch {
    --i: 0; /* 开关关闭,复选框未选中 */
    
    label::after {
        translate: calc(var(--i) * 100%) 0; /* 白色滑块位于左侧*/
        transition: translate 300ms;
    }
    
    &:has(:checked) {
        --i: 1; /* 开关打开,复选框选中 */
        
        label::after {
            transition: translate 300ms linear;
        }
    }
}

Demo 地址:codepen.io/airen/full/…

而 CSS 的 if() 函数则为 CSS 带来了真正的条件表达能力。你可以在属性值中直接写出类似“如果…否则…”的逻辑,根据不同状态返回不同的样式值。

例如,假设一个设计系统支持三种主题:ocean(海洋)、forest(森林)以及默认主题。过去你可能需要为不同主题维护多套样式文件,或者通过 JavaScript 动态切换类名、管理一堆变量映射。现在,借助 if(),你可以在 CSS 中直接通过条件表达式按需定义主题样式,让逻辑更加直观且集中。例如,根据当前主题变量选择不同的颜色,而无需额外的结构或脚本参与。这种方式不仅减少了样式分散的问题,也让主题系统更易于维护和扩展。

.controls:has(#shamrock:checked) ~ .card {
    --theme: shamrock;
}
.controls:has(#saffron:checked) ~ .card{
    --theme: saffron;
}
.controls:has(#amethyst:checked) ~ .card{
    --theme: amethyst;
}

.card {
    /* 基础颜色 */
    --saffron: hsl(43 74% 64%);
    --shamrock: hsl(146 50% 40%);
    --amethyst: hsl(282 47% 56%);

    /* 文本颜色 */
    --saffron-text: light-dark(
        hsl(from var(--saffron) h s 3%),
        hsl(from var(--saffron) h s 92%)
    );

    --shamrock-text: light-dark(
        hsl(from var(--shamrock) h s 3%),
        hsl(from var(--shamrock) h s 92%)
    );

    --amethyst-text: light-dark(
        hsl(from var(--amethyst) h s 3%),
        hsl(from var(--amethyst) h s 92%)
    );

    /* 背景颜色 */
    --saffron-bg: light-dark(
        var(--saffron), 
        hsl(from var(--saffron) h s 20%)
    );

    --shamrock-bg: light-dark(
        var(--shamrock),
        hsl(from var(--shamrock) h s 18%)
    );

    --amethyst-bg: light-dark(
        var(--amethyst),
        hsl(from var(--amethyst) h s 22%)
    );

    /* 边框颜色 */
    --saffron-border: light-dark(
        hsl(from var(--saffron) calc(h - 90) s 40%),
        hsl(from var(--saffron) calc(h - 90) s 65%)
    );

    --shamrock-border: light-dark(
        hsl(from var(--shamrock) calc(h - 90) s 35%),
        hsl(from var(--shamrock) calc(h - 90) s 60%)
    );

    --amethyst-border: light-dark(
      hsl(from var(--amethyst) calc(h - 90) s 38%),
      hsl(from var(--amethyst) calc(h - 90) s 62%)
    );

    /* 主题切换*/
    /* 背景 */
    --background: if(
        style(--theme: saffron): var(--saffron-bg); 
        style(--theme: shamrock):  var(--shamrock-bg); 
        style(--theme: amethyst): var(--amethyst-bg);
    );

    /* 文本 */
    --color: if(
        style(--theme: saffron): var(--saffron-text); 
        style(--theme: shamrock): var(--shamrock-text); 
        style(--theme: amethyst): var(--amethyst-text) ;
    );

    /* 边框 */
    --border-color: if(
        style(--theme: saffron): var(--saffron-border); 
        style(--theme: shamrock): var(--shamrock-border); 
        style(--theme: amethyst): var(--amethyst-border) ;
    );

    background: var(--background);
    color: var(--color);
    border-color: var(--border-color);
}

Demo 地址:codepen.io/airen/full/…

实际上,上面的主题示例,即使不使用 if(),也可以通过 @container style() 查询来实现。你可以根据容器中定义的样式变量(例如主题标识),在不同条件下应用对应的样式规则。这种方式同样能够实现按需切换主题效果,并且完全基于 CSS 的声明式能力完成。

/* Card 的容器 */
main {
    container-type: inline-size;
    --theme: saffron;
}

.card {
    /* 基础颜色 */
    --saffron: hsl(43 74% 64%);
    --shamrock: hsl(146 50% 40%);
    --amethyst: hsl(282 47% 56%);

    /* --theme: saffron */
    @container style(--theme: saffron) {
        --color: light-dark(
            hsl(from var(--saffron) h s 3%),
            hsl(from var(--saffron) h s 92%)
        );
        --background: light-dark(
            var(--saffron),
            hsl(from var(--saffron) h s 20%)
        );
        --border-color: light-dark(
            hsl(from var(--saffron) calc(h - 90) s 40%),
            hsl(from var(--saffron) calc(h - 90) s 65%)
        );
    }

    /* --theme: shamrok */
    @container style(--theme: shamrock) {
        --color: light-dark(
            hsl(from var(--shamrock) h s 3%),
            hsl(from var(--shamrock) h s 92%)
        );
        --background: light-dark(
            var(--shamrock),
            hsl(from var(--shamrock) h s 18%)
        );
        --border-color: light-dark(
            hsl(from var(--shamrock) calc(h - 90) s 35%),
            hsl(from var(--shamrock) calc(h - 90) s 60%)
        );
    }

    /* --theme: amethyst */
    @container style(--theme: amethyst) {
        --color: light-dark(
            hsl(from var(--amethyst) h s 3%),
            hsl(from var(--amethyst) h s 92%)
        );
        --background: light-dark(
            var(--amethyst),
            hsl(from var(--amethyst) h s 22%)
        );
        --border-color: light-dark(
            hsl(from var(--amethyst) calc(h - 90) s 38%),
            hsl(from var(--amethyst) calc(h - 90) s 62%)
        );
    }

    background: var(--background);
    color: var(--color);
    border-color: var(--border-color);
}

/* 切换容器 main 的 --theme 的值 */
main:has(#shamrock:checked) {
    --theme: shamrock;
}
main:has(#saffron:checked) {
    --theme: saffron;
}
main:has(#amethyst:checked) {
    --theme: amethyst;
}

Demo 地址:codepen.io/airen/full/…

这也说明一个趋势,CSS 正在逐步提供多种路径来表达“条件逻辑”。无论是 if() 这样的内联条件函数,还是 @container style() 这样的上下文查询机制,本质上都在让组件具备更强的自适应能力,从而减少对 JavaScript 的依赖。

也就是说,样式不再只是对结果的描述,而是可以根据上下文、状态和条件主动做出响应。这种能力让很多过去需要“状态判断 + DOM 操作”的场景,可以直接在 CSS 层完成。

目前,这一特性仍处于实验阶段,但它所代表的方向非常清晰——CSS 正在从“声明样式”逐步演进为“具备一定逻辑能力的系统”,进一步压缩 JavaScript 在 UI 层的职责范围。

温馨提示:如果你想更深入了解 CSS if() 函数的语法细节、使用场景以及与其他特性的组合方式,可以移步阅读《Web UI:CSS if 语句与条件逻辑》和《CSS 技巧:如何在 CSS 中正确使用 if()》,进一步探索它在实际项目中的应用潜力。

CSS @function

一直以来,如果你希望在 CSS 中封装一段可复用的“计算逻辑”,通常只能依赖 CSS 处理器(比如 Sass 的函数),或者借助 JavaScript 在运行时动态计算。原生 CSS 虽然提供了 calc()min()max()clamp() 等函数,但它们更偏向表达式计算,而不是可复用的逻辑单元。

现如今,CSS 的 @function 为 CSS 引入了更接近“原生函数”的能力。你可以在 CSS 中定义一个带参数的函数,并在不同的地方调用它,从而根据输入动态返回计算结果。这意味着,一些重复计算逻辑(比如间距比例、尺寸换算、颜色调整等),可以被统一封装,而不是在各处重复书写。

/* 返回带透明度的颜色 */
@function --opacity(--color, --opacity) {
    result: rgb(from var(--color) r g b / var(--opacity));
}
ul {
    --brand: #f50;
  
    li {
        background: --opacity(var(--brand), calc(sibling-index() * .1));
    }
}

Demo 地址:codepen.io/airen/full/…

这种能力的关键在于,它让 CSS 开始具备逻辑抽象与复用能力。你不再只是声明结果,而是可以定义“如何得到这个结果”。对于设计系统来说,这尤为重要——许多设计规则本质上都是一套可复用的计算逻辑,而 @function 正好提供了表达这些规则的原生方式。

更进一步,当 @function 与 CSS 自定义属性、if() 条件函数以及容器查询等能力结合使用时,CSS 的表达力会进一步增强。你可以在样式层完成从“输入 → 计算 → 输出”的完整流程,让组件具备更强的自适应能力,而无需依赖 JavaScript。

这也意味着,CSS 正在从“样式描述语言”逐步演进为一种具备轻量逻辑能力的系统,让更多原本属于 JavaScript 或预处理器的工作,回归到原生平台中完成。

温馨提示:如果你想更深入了解 @function 的语法细节、使用方式以及在实际项目中的应用场景,可以移步阅读《Web UI:CSS 中的自定义函数 @function》和《CSS 技巧:5 个实用 CSS 自定义函数》,进一步探索这一特性的更多可能性与实践价值。

小结

如果把这些能力放在一起重新审视,你会发现一个非常清晰的趋势——CSS 正在从一个“被动描述样式”的语言,逐步演变为一个同时具备布局、交互,甚至一定逻辑能力的系统。它不再只是用来“画界面”,而是在参与界面的行为定义。

从锚点定位、Popover,到滚动驱动动画、视图过渡,再到 if()@function 这样的条件与计算能力,这些特性并不是零散出现的,而是在系统性地填补过去长期依赖 JavaScript 的空白。浏览器开始在更底层、更高效的层级处理 UI,而不是把所有控制权都交给运行在主线程上的脚本。

这带来的变化,也不仅仅是“少写一点 JavaScript”。更深层的影响在于:你可以用更少的代码实现更复杂的效果,同时获得更好的性能表现,因为许多计算与渲染发生在浏览器内部;组件的实现也更加稳定和可维护,不再需要手动处理大量边界情况或同步状态。

当然,这并不意味着 CSS 会完全取代 JavaScript。一些复杂交互,例如拖拽(drag & drop)、手势控制或高度动态的业务逻辑,仍然属于 JavaScript 的范畴。但可以确定的是,这条边界正在快速移动,而且比很多人想象得更快。

也正因为如此,现在是一个重新思考前端实现方式的好时机。当你准备写一段 JavaScript 来“控制 UI”的时候,不妨先停一下,问自己一个问题:CSS,现在是不是已经可以做到? 很多时候,答案已经和过去截然不同。

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

作者 SmalBox
2026年4月5日 19:10

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

节点功能概述

Normalize 节点是 Unity Shader Graph 中的基础数学工具,用于将任意向量缩放至单位长度(即长度为 1)。该节点接收动态向量(Dynamic Vector)作为输入,输出与输入向量方向一致但长度为 1 的结果向量。无论输入是二维(float2)、三维(float3)还是四维(float4)向量,该节点均能自动计算其模长并完成归一化处理。归一化后的向量在图形渲染中具有重要作用,尤其适用于法线向量处理、光照方向标准化以及方向向量分析等场景。

  • 方向保持:严格维持输入向量的原始方向,仅调整其长度
  • 数学严谨性:基于标准向量运算实现,确保结果的物理准确性
  • 多维度适配:自动适配不同维度的向量输入,无需开发者手动编写缩放逻辑
  • 实时计算优势:在 GPU 着色器层面高效执行,满足实时渲染的性能需求

端口详解

Normalize 节点包含两个主要端口:

  • In 端口:输入端口,类型为 Dynamic Vector,可接收 float2、float3 或 float4 类型的向量
  • Out 端口:输出端口,类型为 Dynamic Vector,输出归一化后的单位向量

技术原理解析

向量归一化基础

向量归一化是通过将向量除以其模长(即向量长度)来获得单位向量的过程。其数学公式为:

v_normalized = v / ||v||

其中,||v|| 表示向量的模长,计算公式为:

||v|| = sqrt(v.x² + v.y² + ... + v.n²)

节点实现逻辑

Normalize 节点通过以下步骤实现向量归一化:

  1. 计算输入向量的模长
  2. 检查模长是否为零(避免除以零错误)
  3. 将向量的每个分量除以其模长
  4. 输出归一化后的向量

数学精度控制

在归一化过程中,ShaderGraph 采用 32 位浮点数精度进行计算,确保在移动端和高端设备上均能保持足够的数值稳定性。对于需要更高精度的应用场景,开发者可通过自定义节点组合实现双精度归一化。

应用场景示例

法线向量处理

在 URP 渲染管线中,Normalize 节点常用于处理法线向量。例如,在构建自定义光照模型时,需要确保法线向量为单位长度:

  1. 使用 Normal Vector 节点获取法线
  2. 将法线向量连接至 Normalize 节点的 In 端口
  3. 将归一化后的法线用于光照计算

实际案例:在角色渲染中,通过归一化处理后的法线可以避免光照计算时的明暗不均问题,特别是在使用法线贴图时,归一化能确保法线方向的准确性。

方向向量标准化

在处理运动方向或摄像机朝向时,Normalize 节点可以确保所有方向向量具有相同的长度:

  1. 使用 Position 节点获取物体位置
  2. 计算目标位置与物体位置的差向量
  3. 使用 Normalize 节点将差向量转换为单位向量
  4. 使用单位向量进行方向判断或移动计算

扩展应用:在 AI 导航系统中,归一化后的方向向量可用于计算敌人的移动路径,确保移动速度不受距离影响。

颜色通道处理

在高级着色器效果中,Normalize 节点可用于颜色通道的标准化处理:

  1. 使用 Color 节点获取颜色值
  2. 将颜色值转换为向量形式
  3. 使用 Normalize 节点处理颜色向量
  4. 将处理后的向量转换回颜色值

进阶技巧:在 HDR 颜色处理中,归一化有助于控制颜色的亮度范围,避免过曝现象。

粒子系统方向控制

在粒子着色器中,Normalize 节点常用于控制粒子的运动方向:

  1. 获取粒子的初始速度向量
  2. 使用 Normalize 节点标准化速度方向
  3. 结合速度大小参数重新构建速度向量
  4. 实现精确的粒子运动轨迹控制

性能优化建议

  • 避免频繁调用:在复杂着色器中,尽量减少对 Normalize 节点的调用次数,可通过预计算或缓存结果优化性能
  • 预计算模长:如果可能,预先计算模长并存储为变量,在多个归一化操作中重复使用
  • 使用替代方案:在某些情况下,可以使用 Saturate 节点或 Clamp 节点替代归一化操作,特别是在只需要限制向量范围而不需要精确单位长度时
  • 移动端优化:在移动设备上,考虑使用近似归一化方法,如快速平方根倒数算法,平衡精度和性能

常见问题解答

归一化后向量长度不为 1?

检查输入向量是否包含非数值(NaN)或 Infinity 值,这些值可能导致归一化错误。同时确认着色器编译目标是否支持所需的精度级别。

如何处理零向量?

在归一化前添加条件判断,如果模长接近零,则返回特定值(如零向量或默认值)。可以使用 Branch 节点实现条件逻辑。

归一化节点支持哪些数据类型?

Normalize 节点支持所有动态向量类型,包括 float2、float3 和 float4。对于整数向量,需要先转换为浮点数类型。

归一化操作是否影响着色器性能?

在大多数现代 GPU 上,单个归一化操作的开销很小,但在片段着色器中频繁调用或处理复杂向量时可能影响性能。建议在顶点着色器中执行归一化操作,并通过插值传递结果。

进阶应用技巧

与 Normal Vector 节点结合

将 Normalize 节点与 Normal Vector 节点结合使用,可以创建更精确的法线效果:

  1. 使用 Normal Vector 节点获取法线
  2. 使用 Normalize 节点处理法线
  3. 将处理后的法线用于光照计算

优化方案:结合 URP 的 Lit Shader 框架,在自定义光照函数中集成归一化法线,提升渲染质量。

创建自定义归一化函数

通过多个数学节点组合,可以创建自定义归一化函数:

  1. 使用 Length 节点计算向量模长
  2. 使用 Divide 节点将向量分量除以其模长
  3. 使用 Saturate 节点确保结果在有效范围内

优势:自定义函数可以添加特定的边界条件处理,如处理极小向量时的特殊返回值。

处理纹理坐标

在纹理处理中,Normalize 节点可以用于标准化纹理坐标:

  1. 使用 UV 节点获取纹理坐标
  2. 使用 Normalize 节点处理纹理坐标
  3. 将处理后的坐标用于纹理采样

实际应用:在球形环境映射中,归一化的纹理坐标可以确保采样方向的一致性。

高级数学运算集成

将 Normalize 节点与其他数学节点结合,实现复杂的向量运算:

  1. 与 Dot Product 节点结合计算向量夹角
  2. 与 Cross Product 节点结合生成正交向量
  3. 与 Matrix 节点结合实现坐标系转换

与其他节点协同工作

与数学节点组合

Normalize 节点可以与 Add、Multiply、Divide 等基础数学节点协同工作,构建复杂的向量运算网络。例如,在计算反射向量时,通常需要先归一化入射光线方向。

在 URP 渲染管线中的特殊应用

在 URP 通用渲染管线中,Normalize 节点与 URP 特定的光照节点、阴影节点配合使用,能够创建符合物理准确性的渲染效果。

在 VFX Graph 中的扩展使用

在视觉效果图表中,Normalize 节点可以处理粒子系统的方向数据,确保粒子运动的方向一致性。

跨平台兼容性考虑

  • 精度差异:在不同平台上,浮点数精度可能有所差异,影响归一化结果的准确性
  • 性能表现:移动端 GPU 的归一化操作可能比桌面端慢,需要针对性优化
  • 着色器变体:针对不同平台,可能需要创建特定的归一化实现以避免兼容性问题

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

vxe-grid之筛选器

2026年4月5日 16:57

vxe-grid

vxe-table 的一部分, 能通过配置方式, 渲染出表格

普通筛选器与浮动筛选器

普通筛选器(非浮动的)

如下图所示(在列头下方, 单独占一个单元格)

image.png

浮动筛选器

如下图所示(漂浮在列头下方偏上的位置)

image.png

image.png

默认筛选器与自定义筛选器

默认筛选器

vxe-table 官方提供的筛选器(需配合 vxe-pc-ui 使用) 如: VxeInput

如果 vxe-pc-ui 已全量引入, 那直接在filterRender.name指定即可使用, filterRender.props 就是配置组件的props

{ title: '年龄', field: 'age', filterRender: { name: 'VxeInput', props: { clearable: true } } }

自定义筛选器

由用户根据业务需要, 自行定义的筛选器(可选择使用项目中实际使用的ui框架如: element-plus 或 ant-design-vue)

实现一个完整的自定义筛选器

实现一个完整的筛选器, 需要实现: 2个vue组件(普通筛选器和浮动筛选器), 以及1个渲染注入器(将你自定义的普通筛选器和浮动筛选器, 以自定义的名字注入到 vxe-table 中, 让 vxe-table 能够通过这个自定义的名字来渲染你的自定义筛选器)

普通筛选器
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { VxeGlobalRendererHandles, VxeTableDefines } from 'vxe-table';

defineOptions({ name: 'MyFilter' });

const props = defineProps<{
    // 必须有该props, 用于接收这个组件所需要的自定义props
    renderOpts: VxeGlobalRendererHandles.RenderTableFilterOptions
    // 必须有该props, vxe-table会将表格实例, 列信息等通过该props注入进来
    renderParams: VxeGlobalRendererHandles.RenderTableFilterParams<any>
}>()
// 过滤条件改变事件名称(自定义事件)
const FILTER_CHANGE_EVENT_NAME = 'filterChange'
// 获取自定义属性
const customProps = computed(()=>{
    return props.renderOpts.props || {}
})
const curOption = ref<VxeTableDefines.FilterOption>()
const field = computed(() => {
    const renderParams = props.renderParams
    if (!renderParams) return ''
    const column = renderParams.column
    if (!column) return ''
    return column.field
})
// 获取当前列的过滤条件初始值
function load() {
    if (!field.value) return
    curOption.value = props.renderParams.column.filters[0]
}
watch(field, load)
load()

function onClear() {
    if (!curOption.value || !field.value) return
    const { $table, column } = props.renderParams
    // 清除过滤条件,并触发filterChange事件
    $table.clearFilterByEvent(new Event(FILTER_CHANGE_EVENT_NAME), column)
}
function onConfirm(){
    const option = curOption.value
    if (!option || !field.value) return
    const { $table, column } = props.renderParams
    // checked 的值是用来决定列名旁边的过滤图标是否高亮显示
    const checked = !!option.data
    $table.updateFilterOptionStatus(option, checked)
    // 保存过滤条件,并触发filterChange事件
    $table.saveFilterPanelByEvent(new Event(FILTER_CHANGE_EVENT_NAME))
}
</script>

<template>
    <div v-if="curOption" class="flex flex-col">
        <div>
            <el-input v-model="curOption.data" :placeholder="customProps.placeholder"></el-input>
        </div>
        <div class="flex flex-row">
            <el-button class="w-1/2" type="primary" @click="onConfirm">确认</el-button>
            <el-button class="w-1/2" @click="onClear">清除</el-button>
        </div>
    </div>
</template>

<style lang="scss" scoped></style>

onConfirm方法中提到的checked 的值是用来决定列名旁边的过滤图标是否高亮显示, 是指这个图标

image.png

浮动筛选器
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { VxeGlobalRendererHandles, VxeTableDefines } from 'vxe-table';

defineOptions({ name: 'MyFilter' });

const props = defineProps<{
    // 必须有该props, 用于接收这个组件所需要的自定义props
    renderOpts: VxeGlobalRendererHandles.RenderTableFilterOptions
    // 必须有该props, vxe-table会将表格实例, 列信息等通过该props注入进来
    renderParams: VxeGlobalRendererHandles.RenderTableFilterParams<any>
}>()
// 过滤条件改变事件名称
const FILTER_CHANGE_EVENT_NAME = 'filterChange'
// 获取自定义属性
const customProps = computed(()=>{
    return props.renderOpts.props || {}
})
const curOption = ref<VxeTableDefines.FilterOption>()
const field = computed(() => {
    const renderParams = props.renderParams
    if (!renderParams) return ''
    const column = renderParams.column
    if (!column) return ''
    return column.field
})
// 获取当前列的过滤条件初始值
function load() {
    if (!field.value) return
    curOption.value = props.renderParams.column.filters[0]
}
watch(field, load)
load()

function onClear() {
    if (!curOption.value || !field.value) return
    const { $table, column } = props.renderParams
    // 清除过滤条件,并触发filterChange事件
    $table.clearFilterByEvent(new Event(FILTER_CHANGE_EVENT_NAME), column)
}
function onConfirm(){
    const option = curOption.value
    if (!option || !field.value) return
    const { $table, column } = props.renderParams
    // checked 的值是用来决定列名旁边的过滤图标是否高亮显示的
    const checked = !!option.data
    $table.updateFilterOptionStatus(option, checked)
    // 保存过滤条件,并触发filterChange事件
    $table.saveFilterPanelByEvent(new Event(FILTER_CHANGE_EVENT_NAME))
}
</script>

<template>
    <div v-if="curOption" class="flex flex-col">
        <div>
            <el-input v-model="curOption.data" :placeholder="customProps.placeholder"></el-input>
        </div>
        <div class="flex flex-row">
            <el-button class="w-1/2" type="primary" @click="onConfirm">确认</el-button>
            <el-button class="w-1/2" @click="onClear">清除</el-button>
        </div>
    </div>
</template>

<style lang="scss" scoped></style>
渲染注入器

MyFilterRender.tsx

import { VxeUI, type VxeGlobalRendererHandles } from "vxe-table";
import MyFilter from "./MyFilter.vue";
import MyFilterFloating from "./MyFilterFloating.vue";

/**
 * 注册自定义筛选渲染器
 */
VxeUI.renderer.add('MyFilter',{
    /**
     * 筛选容器是否显示尾部
     */
    showTableFilterFooter:false,

    /**
     * 自定义筛选渲染内容
     * @param renderOpts 渲染选项
     * @param renderParams 渲染参数
     * @returns 
     */
    renderTableFilter(renderOpts: VxeGlobalRendererHandles.RenderTableFilterOptions, renderParams: VxeGlobalRendererHandles.RenderTableFilterParams<any>){
        return <MyFilter renderOpts={renderOpts} renderParams={renderParams} />
    },
    /**
     * 自定义浮动筛选渲染内容
     * @param renderOpts 渲染选项
     * @param renderParams 渲染参数
     * @returns 
     */
    renderTableFloatingFilter(renderOpts: VxeGlobalRendererHandles.RenderTableFloatingFilterOptions, renderParams: VxeGlobalRendererHandles.RenderTableFloatingFilterParams<any>){
        return <MyFilterFloating renderOpts={renderOpts} renderParams={renderParams} />
    }
})

使用自定义的筛选器

<!--

@file: Demo01.vue
@author: pan
-->
<script setup lang="ts">
import { ElMessage } from 'element-plus';
import { ref, useTemplateRef } from 'vue';
import { VxeGridDefines, VxeGridProps, VxeGridPropTypes } from 'vxe-table';
import JsonTableComm from './JsonTableComm.vue';
import { debounce } from 'lodash-unified';
// 注入自定义筛选渲染器
import './MyFilterRender.tsx'

const data = ref([
  {
    id: 1,
    name: '张三',
    age: 18,
    sex: '男',
  },
  {
    id: 2,
    name: '李四',
    age: 20,
    sex: '女',
  },
])

const columns = ref<VxeGridPropTypes.Columns>([
  {
    title: 'ID',
    field: 'id',
  },
  {
    title: '姓名',
    field: 'name',
    // 该项必须这么配置,里面的data就是过滤条件的初始值
    filters: [
      { data: '' }
    ],
    // MyFilter为自定义筛选器, 可以在props中配置自定义属性
    filterRender: { name: 'MyFilter', props: { placeholder:'输入姓名查询...' } }
  },
  {
    title: '年龄',
    field: 'age',
    filterRender: { name: 'VxeInput', props: { clearable: true } }
  },
  {
    title: '性别',
    field: 'sex',
  },
])

/**
 * 过滤条件改变时,会触发该事件(该事件由自定义筛选器MyFilter触发)
 */
function onFilterChange(params: VxeGridDefines.FilterChangeEventParams) {
  console.log('filterChange', params.filterList, params)
  ElMessage.info(`过滤条件改变为: ${JSON.stringify(params.filterList)}`)
}
</script>

<template>
  <div class="vp-raw">
    <div class="h-[300px]">
      <vxe-grid ref="gridRef"
        :data="data" :columns="columns" @filterChange="onFilterChange">
      </vxe-grid>
    </div>
  </div>
</template>

<style lang="scss" scoped></style>

参考资料

Vxe Table v4

nginx的原理和使用

作者 shining
2026年4月5日 15:20

什么是nginx

Nginx (engine x) 是一个高性能的HTTP和反向代理web服务器,同时也提供了IMAP/POP3/SMTP服务。Nginx是由伊戈尔·赛索耶夫为俄罗斯访问量第二的Rambler.ru站点(俄文:Рамблер)开发的,第一个公开版本0.1.0发布于2004年10月4日。2011年6月1日,nginx 1.0.4发布。

其特点是占有内存少,并发能力强,事实上nginx的并发能力在同类型的网页服务器中表现较好,中国大陆使用nginx网站用户有:百度、京东、新浪、网易、腾讯、淘宝等。在全球活跃的网站中有12.18%的使用比率,大约为2220万个网站。

Nginx 是一个安装非常的简单、配置文件非常简洁(还能够支持perl语法)、Bug非常少的服务。Nginx 启动特别容易,并且几乎可以做到7*24不间断运行,即使运行数个月也不需要重新启动。你还能够不间断服务的情况下进行软件版本的升级。

Nginx代码完全用C语言从头写成。官方数据测试表明能够支持高达 50,000 个并发连接数的响应。

通俗来讲

就是你用户很多, 项目不止搭建在一台服务器上时, 需要用户可以毫无感觉的通过访问一个域名自动分配的访问到不同服务器并且返回对应结果

  • 这一个域名就是代理服务器, 就是nginx
  • 这个自动分配就是nginx干的(反向代理)

反向代理

负载均衡

至于你想用户请求这些服务器的比重就用到负载均衡, 比如说第一个服务器大, 你想用户多请求她一点就可以把他的比重搞高一点, 详情百度

配置文件

查找安装路径: whereis nginx

在路径下有配置文件 nginx.conf

文件结构

#全局块
#user  nobody;
worker_processes  1;

#event块
events {
    worker_connections  1024;
}

#http块
http {
    #http全局块
    include       mime.types;
    default_type  application/octet-stream;
    sendfile        on;
    keepalive_timeout  65;
    #server块
    server {
        #server全局块
        listen       8000;
        server_name  localhost;
        #location块
        location / {
            root   html;
            index  index.html index.htm;
        }
        error_page   500 502 503 504  /50x.html;
        location = /50x.html {
            root   html;
        }
    }
    #这边可以有多个server块
    server {
      ...
    }
}

常用命令

cd /usr/local/nginx/sbin/
./nginx  启动
./nginx -s stop  停止
./nginx -s quit  安全退出
./nginx -s reload  重新加载配置文件
ps aux|grep nginx  查看nginx进程

更改完配置文件后记得重新加载配置文件

最小应用

# 其他块省略没写

upstream test{
    server 127.0.0.1:8080 weight=1;
    server 127.0.0.1:8081 weight=1;
}

server {
    listen 80;
    server_name localhost;
    location / {
    proxy_pass http://test;
}
}

这就是一个用到反向代理和负载均衡的最小应用

这段代码的意思就是当用户请求localhost:80时, 会被反向代理到http://test, 然后再通过负载均衡按权重分配到不同端口上(相当于不同的服务器上)

  • test: 随便起的名字
  • upstream: 负载均衡
  • listen: 监听端口号
  • server_name: 访问ip或域名
  • proxy_pass: 反向代理到的地址

简单使用

作为一个小白肯定没有那么大的用户量需要将项目搭建在几个服务器上, 对于我来说的话, 主要使用场景如下:

当我搭建好一个网站需要绑定域名的时候, 因为在域名商无法直接将域名绑定至ip:端口号, 所以需要我们按以下步骤:

  1. 在域名商将域名或二级域名通过记录类型A绑定至某一ip
  2. 然后在该服务器内配置nginx, 当用户通过该域名直接访问时自动反向代理到ip:端口号的地址, 相当于我们就反向代理到一个端口, 没有负载均衡
  3. 这样我们就可以通过访问域名到指定ip:端口号

eg:
在alist的官方文档中, 反向代理用到了

server {
    listen 80;
    server_name localhost;
    location / {
      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
      proxy_set_header X-Forwarded-Proto $scheme;
      proxy_set_header Host $http_host;
      proxy_set_header X-Real-IP $remote_addr;
      proxy_set_header Range $http_range;
        proxy_set_header If-Range $http_if_range;
      proxy_redirect off;
      proxy_pass http://127.0.0.1:5244;
      # the max size of file to upload
      client_max_body_size 20000m;
}
}

这样当我们访问http://localhost时(http默认就是80端口), 会自动反向代理到http://127.0.0.1:5244, 如果你将域名绑定到localhost(服务器ip地址)了的话, 那么访问域名会直接访问到alist网站

注意事项

通常我们写的配置都会放在配置文件里, nginx默认情况下有一个主配置文件, 我们可以将写的配置放在主配置文件里, 但这样有一定的风险, 各个服务之间的配置可能会出现相互干扰, 尽管是每一个server并且用的大括号. 所以最好每个服务单独写在一个配置文件里, 并且每个单服务配置文件都要放在主配置文件包含的路径下

vue3响应式机制的理解

2026年4月5日 15:10

深入理解 Vue3 响应式机制

1. 为什么需要响应式?

在传统的 jQuery 开发中,数据变化后需要手动操作 DOM 更新视图:

let count = 0
$('#btn').click(() => {
  count++
  $('#count').text(count)   // 手动更新
})

这样做的问题:显然代码繁琐,逻辑分散,难以维护

Vue 的响应式系统解决了这个问题:数据变化 → 视图自动更新。
开发者只需要关注数据,剩下的交给 Vue。

2.从 vue2 的响应式原理开始

2.1 核心:Object.defineProperty

Vue2 通过 Object.defineProperty 劫持对象的属性读写。

function defineReactive(obj, key, val) {
  Object.defineProperty(obj, key, {
    get() {
      console.log(`读取 ${key}:`, val)
      return val
    },
    set(newVal) {
      if (newVal !== val) {
        console.log(`设置 ${key}:`, newVal)
        val = newVal
        // 触发视图更新
      }
    }
  })
}

const obj = { name: '张三' }
defineReactive(obj, 'name', obj.name)
obj.name = '李四'   // 触发 set
console.log(obj.name) // 触发 get

2.2 Vue2 的痛点

问题 原因 解决方案
无法监听新增属性 defineProperty 需要预先定义属性 Vue.set(obj, 'newProp', value)
无法监听删除属性 没有 delete 拦截 Vue.delete(obj, 'prop')
数组索引赋值不更新 arr[0] = 1 不会触发 setter 使用 $set 或重写的数组方法
修改数组 length 不更新 arr.length = 0 无拦截 使用 arr.splice(0)
初始化性能差 需要递归遍历所有属性 无解,Vue3 用 Proxy 解决

2.3 Vue2 如何处理数组?

Vue2 重写了数组的 7 个变异方法:push, pop, shift, unshift, splice, sort, reverse
当你调用这些方法时,Vue 能感知到变化并更新视图。但直接通过索引修改或修改 length 就无法检测。

// Vue2 中
this.arr[0] = 1      // 不更新
this.arr.length = 0  // 不更新
this.arr.push(1)     // 更新
this.$set(this.arr, 0, 1)  // 更新

3. Vue3 的响应式原理:Proxy 全面升级

3.1 核心流程(一句话概括)

用 Proxy 代理数据,读取时收集依赖(track),修改时派发更新(trigger)。

整个流程拆解为 4 步:

  1. reactive() 将普通对象包装成 Proxy 代理对象
  2. 当读取属性时,get 拦截器调用 track,记录“当前正在执行的副作用函数(effect)”
  3. 当修改属性时,set 拦截器调用 trigger,找出所有依赖该属性的 effect,逐个执行
  4. 执行 effect 时重新读取属性,再次触发 track,形成闭环

流程图:

3.3 track与trigger 的最小实现(理解依赖收集的核心)

javascript

let activeEffect = null                 // 当前正在执行的副作用函数
const targetMap = new WeakMap()         // 存储所有对象的依赖关系

function track(target, key) {
  if (!activeEffect) return
  let depsMap = targetMap.get(target)
  if (!depsMap) targetMap.set(target, (depsMap = new Map()))
  let dep = depsMap.get(key)
  if (!dep) depsMap.set(key, (dep = new Set()))
  dep.add(activeEffect)
}

function trigger(target, key) {
  const depsMap = targetMap.get(target)
  if (!depsMap) return
  const dep = depsMap.get(key)
  if (dep) dep.forEach(effect => effect())
}

4. Vue2 vs Vue3 响应式对比

对比维度 Vue2 (Object.defineProperty) Vue3 (Proxy)
拦截能力 只能拦截 get / set 可拦截 13 种操作(get, set, delete, has, ownKeys...)
新增属性 无法监听,需 $set 直接赋值 obj.newProp = 1 即可
删除属性 无法监听,需 $delete 直接 delete obj.prop 即可
数组索引修改 arr[0]=1 不更新 可更新
数组 length 修改 arr.length=0 不更新 可更新
初始化性能 递归遍历所有属性,对象越大越慢 惰性代理,访问到才处理,初始化快
支持数据结构 普通对象、数组(需 hack) 对象、数组、Map、Set、WeakMap 等
代码复杂度 需要递归、重写数组方法、单独处理新增/删除 逻辑统一在 Proxy handler 中

5. ref 与 reactive 详解

5.1 核心困惑:为什么不能只用 reactive

直接原因Proxy 只能代理对象,不能代理基本类型(数字、字符串、布尔、null、undefined)。这是因为 Proxy 的设计本质是拦截对象的属性访问、修改等行为,而基本类型是“值类型”,不是对象,没有任何可拦截的属性,无法完成代理逻辑。
如果你写 reactive(0),Vue 会报错。

实际开发场景:我们经常需要管理一个计数器、一个开关状态,这些是基本类型。所以必须有一个方案来处理基本类型的响应式。

5.2 ref 的本质:单值响应式包装器

ref`的核心作用:把任意类型的值(基本类型 / 对象)包装成一个带 value访问器的响应式对象。

真实简化原理(接近 Vue 源码)
class RefImpl {
  constructor(rawValue) {
    this._rawValue = rawValue // 原始值
    this._value = rawValue    // 响应式值
    this.__v_isRef = true     // 标记是 ref
  }

  get value() {
    // 收集依赖
    track(this, 'value')
    return this._value
  }

  set value(newVal) {
    // 更新 + 触发更新
    this._rawValue = newVal
    this._value = toReactive(newVal)
    trigger(this, 'value')
  }
}

  function ref(value) {
    return new RefImpl(value)
  }

5.3 对比表格

特性 reactive ref
支持数据类型 对象、数组 任意类型(基本类型 + 对象)
返回结构 Proxy 代理对象 RefImpl 实例(带 .value)
访问方式 直接访问属性 state.xxx 必须用 .value
底层实现 ES6 Proxy class + getter/setter
响应式范围 深度响应式 单层响应式,对象自动走 reactive
解构丢失响应 否(因为始终是同一个 ref 对象)

5.5 常见误区与正确理解

误区1: ref 是专门给基本类型用的,对象必须用 reactive
事实: ref 也可以接收对象,内部会调用 reactive。所以你可以全程用 ref,只是要写很多 .value

误区2: reactive 返回的对象和原对象不一样,ref 返回的对象和原值也不一样。
事实: 两者都返回代理对象。reactive 代理原对象;ref 代理包装对象。

误区3: ref.value 是多余的。
事实: 因为 ref 的本质是 { value } 对象的代理,所以必须通过 .value 访问包装对象内部的属性。这是语法代价,换来了对基本类型的支持。

6. 常用响应式 API 速查表

API 用途 示例
reactive 创建响应式对象/数组 const state = reactive({ count: 0 })
ref 创建响应式基本类型(或对象) const count = ref(0)count.value++
computed 计算属性(缓存) const double = computed(() => state.count * 2)
watch 监听指定数据源 watch(() => state.count, (val) => {...})
watchEffect 自动收集依赖,立即执行 watchEffect(() => console.log(state.count))
toRefs 解构时保持响应式 const { name } = toRefs(state)

6.1 computed和watch的区别

  • computed:懒加载,产生新值,有缓存,如果依赖不变调用缓存不重新计算,适用于过滤列表
  • watch:执行副作用,无缓存,可以获取新旧值,适用于异步请求

6.2 watch和watchEffect

  • watch:手动指定监听源,懒执行,除非immediate:true
  • watchEffect:函数内所有的响应式数据都被自动收集,立即执行,不能获取旧值

7. 经典面试题

7.1 为什么 Vue2 不能检测数组索引和 length 变化?

因为 Object.defineProperty 无法拦截这些操作。Vue2 只能通过重写数组方法(push/pop 等)来 hack,但直接 arr[0]=1arr.length=0 无法检测到。

7.2 Vue3 如何解决数组问题?

Proxyset 拦截器可以捕获所有属性设置,包括数字索引和 length。所以直接修改即可触发更新。

7.3 ref 为什么需要 .value?能去掉吗?

不能去掉。因为 ref 返回的是一个包装对象 { value } 的代理,要访问内部的值就必须通过 .value。模板中不需要是因为编译器自动添加了 .value

7.4 下面代码中,修改 state.count 会触发视图更新吗?

const state = reactive({ count: 0 })
let { count } = state
count = 1

不会。因为解构后的 count 是普通数字,不再响应式。需要使用 toRefs

const { count } = toRefs(state)
count.value = 1   // 正确触发更新

8. 总结一句话

Vue2 用 defineProperty 劫持属性,有诸多限制;Vue3 用 Proxy 全面代理,配合 track/trigger 实现响应式。reactive 直接代理对象,ref 包装基本类型后再代理,两者本质相通。记住:对象用 reactive,基本类型用 ref,解构用 toRefs

「JS全栈AI Agent学习」四、MCP:给AI工具世界造一个USB接口

作者 霪霖笙箫
2026年4月5日 14:27

📌 系列简介:「JS全栈AI Agent学习」系统学习 21 个 Agent 设计模式,篇数随学习进度持续更新。 ⏱️ 预计阅读时间:15 分钟 📖 原书地址adp.xindoo.xyz 前端转 JS 全栈,正在学 AI,理解难免有偏差,欢迎批评指正 ~


🗺️ 系列导航

主题 状态
第一篇 提示链 · 路由 · 并行化
第二篇 反思 · 工具使用 · 规划
第三篇 多智能体 · 记忆管理 · 学习适应
本篇 MCP 协议

前言

我有一个 my-resume 项目——最初只是一个静态展示页面,放了简历、项目经历、技术栈。 后来想把它改造成真正的全栈项目:NestJS 后端 + 数据库 + 前端交互 + AI 能力 + 部署上线,三端齐备,一条龙。

在这个改造过程中,我开始认真思考 AI 能力怎么集成进来。

第一步,我给项目写了一个读 PDF 的 Tool——用户上传简历 PDF,AI 解析内容,然后做各种分析。 写完挺好用的,但写完之后我意识到一个问题:

这个 Tool,只能在我自己的项目里用。

换个框架要重写,换个 AI 平台要重写 …… 这个问题,就是这篇文章的起点。


一、从一个 PDF Tool 说起

我在 LangChain.js 里注册了一个 PDF 阅读工具,大概长这样:

const pdfTool = new DynamicTool({
  name: "read_pdf",
  description: "读取PDF文件并转换为文本,保留原始布局和排版结构。当任务涉及阅读、解析PDF文件内容时触发,仅上传文件不触发。",
  func: async (filePath: string) => {
    return await extractPdfText(filePath);
  }
});

Agent 靠 description 判断要不要调用这个工具——语义匹配,不是关键词匹配。

这里有个细节值得单独说:description 写得好不好,直接决定 Agent 调用的精准度

写不好 description 的时候,不妨问自己:如果这是一份需求文档,自己刚拿到知道什么时候该用这个接口吗?

工具本身没问题。但问题来了——

假设不只是我自己用,而是 100 个开发者都需要这个 PDF Tool。 假设不只是 LangChain.js,还要支持 Claude、GPT-4、自己搭的 Agent…… 每个人都要复制代码、手动注册、适配不同框架——

这个成本,随着工具数量和使用方数量的增加,会指数级爆炸。


二、VSCode 插件的类比——以及它哪里不够用

第一反应是:这不就像 VSCode Extensions 吗?

VSCode 插件市场解决了类似的问题:

  • 开发者按规范写插件,发布到市场
  • 用户订阅安装,开箱即用
  • 统一管理,不用到处复制代码

这个思路是对的。规范 + 发布 + 订阅 + 协作,MCP 都有。

但我多想了一秒,感觉哪里不太对——

VSCode 插件是这样工作的:

插件开发者 → 发布到 Marketplace → 用户安装 → VSCode 加载插件

插件运行在哪里?运行在 VSCode 里。VSCode 是唯一的宿主。

但 AI Tool 的场景完全不同:

你的 PDF Tool,可能被 Claude 调用,可能被 GPT-4 调用,可能被你自己用 LangChain.js 写的 Agent 调用。 这三个"宿主",是三个完全不同的系统。

所以需要的不是"插件市场",而是一个跨系统的通信协议

VSCode Extensions MCP
解决什么 如何扩展 VSCode 的功能 如何让任何 Agent 调用任何工具
宿主 只有 VSCode Claude / GPT / LangChain / 任何框架
本质 插件规范 通信协议
类比 乐高积木的形状规范 USB 接口标准

USB 出现之前:鼠标有鼠标接口,键盘有键盘接口,打印机有打印机接口。 USB 出现之后:一个接口,接任何设备。

MCP 就是 AI 工具世界的 USB。


三、MCP 是什么

MCP(Model Context Protocol,模型上下文协议),是 Anthropic 在 2024 年底提出并开源的一个开放标准。

它要解决的问题,用一张图说清楚:

MCP 出现之前:
─────────────────────────────────────────
PDF Tool (Claude版)    ──→ Claude
PDF Tool (OpenAI版)    ──→ GPT-4
PDF Tool (LangChain版) ──→ 你的 Agent
同一个功能,写三遍

MCP 出现之后:
─────────────────────────────────────────
                       ──→ Claude
PDF Tool (MCP版) ────────→ GPT-4
                       ──→ 你的 Agent
写一次,到处用

一句话定义:MCP 是 AI 工具世界的 USB 接口标准——工具只写一次,任何兼容 MCP 的 Agent 都能调用。


四、MCP 协议定义了什么

既然是"通信协议",那它需要定义哪些东西,两端才能"说上话"?

类比 HTTP 协议:它定义了请求方法(GET/POST)、状态码(200/404)、Header 格式……

MCP 对应地定义了四件事:

你的直觉推导 MCP 里的概念 说明
支持哪些平台/连接方式 Transport 层 定义通信方式,解决"怎么连上"
名称和描述 Tool Definition name + description,Agent 靠这个决定要不要调用
怎么使用(说明书) Input Schema 用 JSON Schema 定义入参,Agent 知道要传什么
备注/版本/返回格式 Output Schema / Metadata 返回值格式、版本号、错误码定义

真实的 MCP Tool 定义长这样:

{
  name: "read_pdf",
  description: "读取PDF文件并转换为文本,保留原始布局和排版结构",
  inputSchema: {
    type: "object",
    properties: {
      filePath: {
        type: "string",
        description: "PDF文件的路径"
      },
      pageRange: {
        type: "string",
        description: "可选,指定页码范围,如 '1-5'"
      }
    },
    required: ["filePath"]
  }
}

看到这个结构,有没有觉得很眼熟?

这和写 TypeScript 函数签名,本质上是同一件事:

// TypeScript 函数签名
function readPdf(filePath: string, pageRange?: string): string { ... }

// MCP inputSchema = 把函数签名用 JSON 描述出来,让 Agent 能"读懂"

MCP 的 inputSchema,就是把函数类型定义翻译成 Agent 可以理解的格式。


五、MCP 架构:三个角色

MCP 基于客户端-服务器架构,有三个核心角色:

你的 Agent(MCP Client)
      │
      │  ① 发现:这里有哪些工具?
      ▼
 MCP Server(工具提供方)
      │
      │  ② 返回:工具列表 + 每个工具的 Schema
      ▼
你的 Agent 决策:
  "这个任务需要用 read_pdf"
      │
      │  ③ 调用:传入参数
      ▼
 MCP Server 执行工具
      │
      │  ④ 返回结果
      ▼
Agent 继续处理
角色 是什么 类比
MCP Client 你的 Agent,发起调用方 浏览器
MCP Server 工具提供方,暴露工具能力 Web 服务器
Transport 层 两者之间的通信方式 HTTP / WebSocket

这里有一个值得单独说的设计细节:

第 ① 步是"发现",不是"被告知"。

传统的 Tool Use,是你在代码里明确告诉 Agent:"你有这些工具"——静态注册,写死在代码里。

MCP 的方式是 Agent 主动去问 Server:"你有什么工具?"——动态发现,运行时查询。

这个差异的实际意义是:MCP Server 可以独立部署、独立更新,Agent 不需要改一行代码,就能感知到工具的变化。工具加了新功能、下线了旧接口,Agent 侧完全无感。


六、Transport 层:同步等待,还是流式返回?

三个角色清楚了,还有一个问题没解决:Agent 调用工具,该怎么等结果?

读 PDF 可能要几秒,查数据库可能要几百毫秒,调用外部 API 可能更慢。 如果 Agent 傻等,整个系统就卡住了。

最理想的设计是:既能根据情况异步请求,也能支持同步读取,最后统一输出。

这在计算机里有个专门的名字:流式响应(Streaming)

MCP 定义了两种标准传输方式:

方式一:stdio(标准输入输出)
─────────────────────────────────────────
Agent ──写入 stdin──▶ MCP Server
Agent ◀──读取 stdout── MCP Server

适合:本地工具,同一台机器上运行
类比:命令行管道  ls | grep pdf


方式二:HTTP + SSE(Server-Sent Events)
─────────────────────────────────────────
Agent ──HTTP POST──▶ MCP Server
Agent ◀──SSE 流式──── MCP Server

适合:远程工具,跨网络调用
类比:流式 AI 回复

SSE 的工作方式,画出来是这样的:

Agent 发出请求 ──────────────────────────▶ MCP Server
                                               │
                                          开始执行工具
                                               │
                ◀── 流式返回(边处理边推送)──── │
                ◀── 流式返回 ────────────────── │
                ◀── 流式返回 ────────────────── │
                ◀── [DONE] ──────────────────── │

Agent 不需要傻等,数据来一点处理一点,最后统一完成

做过前端 AI 应用的同学,这个原理一眼就熟——

你在页面上做的流式渲染 AI 回复,用的就是同一套机制: ReadableStream → 一块一块读 → 渲染到页面

MCP 的 SSE 传输,和你前端写的流式 AI 回复,本质上是同一件事。


七、动手:把 PDF Tool 升级成 MCP Server

理论讲完,直接上代码。

my-resume 项目里的 PDF Tool,改造成一个标准的 MCP Server:

import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
  ListToolsRequestSchema,
  CallToolRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";

// 创建 MCP Server 实例
const server = new Server({
  name: "resume-tools",   // 工具集名称
  version: "1.0.0",       // 版本号
});

// ① 声明工具列表
// Agent 来问"你有什么工具"时,返回这个
server.setRequestHandler(ListToolsRequestSchema, async () => ({
  tools: [
    {
      name: "read_pdf",
      description:
        "读取PDF文件并转换为文本,保留原始布局和排版结构。" +
        "当任务涉及阅读、解析PDF内容时触发,仅上传文件不触发。",
      inputSchema: {
        type: "object",
        properties: {
          filePath: {
            type: "string",
            description: "PDF文件路径",
          },
          pageRange: {
            type: "string",
            description: "可选,页码范围,如 '1-5'",
          },
        },
        required: ["filePath"],
      },
    },
  ],
}));

// ② 处理工具调用
// Agent 决定调用某个工具时,走这里
server.setRequestHandler(CallToolRequestSchema, async (request) => {
  if (request.params.name === "read_pdf") {
    const { filePath, pageRange } = request.params.arguments as {
      filePath: string;
      pageRange?: string;
    };

    const result = await extractPdfText(filePath, pageRange);

    return {
      content: [{ type: "text", text: result }],
    };
  }

  throw new Error(`未知工具: ${request.params.name}`);
});

// ③ 启动(stdio 模式,本地调用)
const transport = new StdioServerTransport();
await server.connect(transport);
console.log("MCP Server 已启动,等待 Agent 连接...");

看这个结构,有没有觉得很像你写 NestJS Controller

NestJS:  @Controller@Get/@Post         → 处理请求 → 返回响应
MCP:      Server     → ListTools/CallTool → 处理请求 → 返回结果

这个类比不是巧合——两者解决的是同一类问题:定义接口、处理请求、返回结果。只是服务的对象从"HTTP 客户端"变成了"AI Agent"。

关于 my-resume 项目的思考

my-resume 的全栈改造里,MCP 的三个角色可以这样对应:

MCP Server = 我写的工具层(NestJS 后端)
  → read_pdf:解析用户上传的简历 PDF
  → get_resume_data:从数据库读取结构化简历数据
  → search_projects:搜索项目经历

MCP Client = 调用工具的 Agent(前端发起,后端编排)
  → 接收用户指令:"帮我优化这段工作经历"
  → 决策:先调用 get_resume_data 拿到原始数据
  → 再交给 LLM 处理,返回优化建议

Transport = stdio(本地)或 HTTP+SSE(部署后远程调用)

Server 提供能力,Client 使用能力,Transport 是中间的管道。

这个分层思路,和 NestJS 的 Controller / Service / Repository 分层,逻辑上是一脉相承的。


八、MCP vs 工具函数调用:别混淆

学到这里,有一个容易混淆的地方值得单独说清楚。

MCP 和 LangChain 里的 Tool Use(工具函数调用)看起来很像,但有本质区别:

特性 工具函数调用(Tool Use) MCP
标准化 各平台专有,格式不统一 开放标准,跨平台互通
工具发现 你明确告诉 Agent 有哪些工具 Agent 主动查询,动态发现
可重用性 与特定应用/框架耦合 独立部署,任何兼容方都能用
架构 一对一(LLM ↔ 工具) 客户端-服务器(多对多)

一句话区分:

工具函数调用 = 给 AI 一套专用工具箱,工具是定制的,只能在这个项目里用。

MCP = 造一个标准插座,任何符合规格的工具都能插进来,任何兼容的 Agent 都能用。


九、核心洞察

洞察 一句话
MCP 是什么 AI 工具世界的 USB 接口标准
解决什么问题 工具只写一次,任何 Agent 都能用
三个角色 Client(Agent)· Server(工具)· Transport(通道)
两种传输 stdio(本地)· HTTP+SSE(远程+流式)
和 VSCode 插件的本质区别 插件规范 vs 跨系统通信协议
和 NestJS 的类比 Controller/Service 分层 ≈ Server/Handler 分层
你已经在用的类似概念 流式 AI 回复的 ReadableStream
动态发现 vs 静态注册 MCP 让 Agent 主动问"你有什么",而不是被动被告知

结语

MCP 这章,我觉得是目前为止最"工程感"的一章。

它不是一个新的 AI 能力,而是一个工程规范——解决的是"怎么让 AI 能力可复用、可组合、可跨平台"这个问题。

对于 my-resume 全栈改造来说,这章给了我一个很清晰的架构思路:

不要把 AI 工具写死在业务代码里。把它们抽成 MCP Server,独立部署,独立维护。 今天接 Claude,明天换 GPT-4,后天自己搭 Agent——工具层一行代码不用改。

这个思路,和后端开发里"接口与实现分离"是同一个道理。只不过现在,接口的调用方从"前端页面"变成了"AI Agent"。

学到这里,越来越觉得:AI 工程和软件工程,底层是同一套思维。 分层、解耦、标准化——这些事工程师早就在做了,只不过现在的场景换了。


💬 系列地址:持续更新中

📖 原书地址adp.xindoo.xyz

🛠️ 实战项目:my-resume(静态页面 → NestJS + 数据库 + AI + 部署上线,进行中)

如果这篇对你有帮助,欢迎点赞收藏,我们下篇见 👋

Webpack的Loader和Plugin:你以为只是配置?其实是“流水线工人”和“包工头”

作者 kyriewen
2026年4月5日 13:47

你配置过Webpack吗?是不是照着文档写了几行rulesplugins,然后它就神奇地把代码打包好了?今天我们不背配置,直接钻进Webpack的肚子里,看看Loader和Plugin到底在干什么。看完你就能自己写一个Loader和一个Plugin,再也不用怕面试官问“Webpack原理”了。

前言

把Webpack想象成一家汽车工厂。原料是各种文件(JS、CSS、图片、字体……),产品是打包后的bundle。

  • Loader:流水线上的工人。每个工人只干一件事:把某种原料加工成下一个工人能处理的形式。比如把Sass转成CSS,把ES6转成ES5。
  • Plugin包工头。包工头不管具体加工,而是监听整个生产流程——开工前、完成某个环节后、打包结束——然后在合适的时机做全局性的事,比如抽离CSS、生成HTML、压缩代码。

今天我们就来认识这两位“功臣”,顺便自己动手写一个。

一、Loader:干啥啥都行,专精第一名

Loader是一个函数,它接收源文件内容,返回处理后的内容。一个文件可以经过多个Loader串联(从右到左,从下到上)。

// 一个最简单的Loader:把内容里的“Hello”换成“Hi”
module.exports = function(source) {
  const result = source.replace(/Hello/g, 'Hi');
  return result;
};

配置里Loader的执行顺序

module.exports = {
  module: {
    rules: [
      {
        test: /\.scss$/,
        use: ['style-loader', 'css-loader', 'sass-loader']
        // 执行顺序:sass-loader → css-loader → style-loader
      }
    ]
  }
};

像流水线:sass-loader把SCSS转成CSS → css-loader处理CSS中的依赖(@import等)→ style-loader把CSS注入到DOM的<style>标签。

常用Loader举例

  • babel-loader:把ES6+转成ES5
  • css-loader:解析@importurl()
  • style-loader:把CSS插入DOM
  • file-loader:把文件输出到目录,返回路径
  • url-loader:小文件转成base64,大文件走file-loader
  • sass-loader:编译Sass/SCSS

动手写一个“清除console”的Loader

// clean-console-loader.js
module.exports = function(source) {
  // 移除console.log、console.warn等
  const cleaned = source.replace(/console\.(log|warn|error)\([^)]*\);?/g, '');
  return cleaned;
};

在webpack.config.js里使用:

module: {
  rules: [
    {
      test: /\.js$/,
      use: path.resolve(__dirname, 'clean-console-loader.js')
    }
  ]
}

二、Plugin:包工头,管全局

Plugin是一个(或者带有apply方法的对象)。它通过监听Webpack生命周期里的钩子(hooks),在特定时机干预打包过程。

class MyPlugin {
  apply(compiler) {
    // 在打包结束后执行
    compiler.hooks.done.tap('MyPlugin', (stats) => {
      console.log('🎉 打包完成了!');
    });
  }
}

Webpack的钩子有同步和异步之分。比如emit(生成资源到输出目录之前)是异步的,要用tapAsync

常用Plugin举例

  • HtmlWebpackPlugin:自动生成HTML,并注入打包后的JS/CSS
  • MiniCssExtractPlugin:把CSS抽成单独文件
  • DefinePlugin:定义全局常量(比如环境变量)
  • CleanWebpackPlugin:打包前清理输出目录

动手写一个“打包完成发通知”的Plugin

class NotifyPlugin {
  apply(compiler) {
    compiler.hooks.done.tap('NotifyPlugin', (stats) => {
      const time = stats.endTime - stats.startTime;
      console.log(`✅ 打包成功,耗时 ${time}ms`);
      // 这里可以调系统通知API(需要额外库)
    });
  }
}
module.exports = NotifyPlugin;

使用:

const NotifyPlugin = require('./notify-plugin');
plugins: [new NotifyPlugin()]

三、Loader和Plugin的核心区别

维度 Loader Plugin
职责 转换单个文件 影响整个构建流程
作用范围 匹配test正则的文件 全局
实现形式 函数 类(带apply方法)
运行时机 模块加载过程中 生命周期钩子
常见例子 babel-loader, css-loader HtmlWebpackPlugin, CleanPlugin

形象比喻:

  • Loader:工人,只会加工原料。
  • Plugin:包工头,指挥全局,监听事件。

四、编写Loader的进阶技巧

1. 获取Loader选项

const loaderUtils = require('loader-utils');
module.exports = function(source) {
  const options = loaderUtils.getOptions(this);
  // 使用options...
  return source;
};

2. 异步Loader

如果Loader里要异步操作(比如网络请求),用this.async()

module.exports = function(source) {
  const callback = this.async();
  setTimeout(() => {
    const result = source.toUpperCase();
    callback(null, result);
  }, 1000);
};

3. 生成多个文件

可以用this.emitFile

const { RawSource } = require('webpack-sources');
module.exports = function(source) {
  this.emitFile('new-file.txt', new RawSource('hello'));
  return source;
};

五、编写Plugin的进阶技巧

1. 常用钩子

  • compiler.hooks.entryOption:读取入口配置后
  • compiler.hooks.beforeRun:开始编译前
  • compiler.hooks.compile:编译前
  • compiler.hooks.emit:生成资源到输出目录前(可以修改文件内容)
  • compiler.hooks.done:打包完成

2. 修改输出内容

class ModifyFilePlugin {
  apply(compiler) {
    compiler.hooks.emit.tapAsync('ModifyFilePlugin', (compilation, callback) => {
      // compilation.assets 包含所有待输出文件
      const content = compilation.assets['bundle.js'].source();
      const modified = content.replace('old', 'new');
      compilation.assets['bundle.js'] = {
        source: () => modified,
        size: () => modified.length
      };
      callback();
    });
  }
}

六、总结:从配置使用者到原理掌握者

  • Loader:文件转换器,函数式,串联处理。
  • Plugin:构建流程干预者,事件监听式,做全局工作。
  • 掌握原理后,你就能:
    • 写自定义Loader处理特殊文件(比如把XML转成JS对象)
    • 写自定义Plugin做自动上传CDN、生成资源清单等
    • 更从容地调试Webpack配置错误

下次面试官问“Webpack的Loader和Plugin区别”,你可以自信地画出流水线和包工头的比喻,顺便掏出自己写的Loader和Plugin代码。

如果你觉得今天的“工厂之旅”够形象,点个赞让更多人看到。明天我们将深入Webpack优化——如何让打包速度飞起,让产物体积瘦成闪电。我们明天见!

计算机网络基础知识-TCP与UDP

2026年4月5日 13:29

TCP和UDP的概念及特点(重点)

TCPUDP 都是传输层协议,他们都属于 TCP/IP 协议族。在 OSI 模型中,传输层处于 IP 协议的上一层。

TCP(传输控制协议)

TCP 的全称是传输控制协议,是一种面向连接的、可靠的、基于字节流的传输层通信协议。TCP 是面向连接的、可靠的流协议。

,就是指不间断的数据结构。

TCP 特点

(1)面向连接

面向连接,是指发送数据之前必须在两端建立连接。建立连接的方法是三次握手,这样能建立可靠的连接。建立连接,是为数据的可靠传输打下了基础。

(2)仅支持单播传输

每条 TCP 传输连接只能有两个端点,只能进行点对点的数据传输,不支持多播和广播的传输方式。

多播:一对多(特定组)

广播:一对所有

(3)面向字节流

TCP 是在不保留报文边界的情况下以字节流方式进行传输,不像 UDP 一样那样一个个报文独立地传输。

不保留报文边界:是指 TCP 把数据视为无结构、无分界的连续字节序列,由应用层自行决定如何切分消息。为了效率和流控,TCP 会把多个小数据块拼成一个大的 TCP 段(减少网络开销),也可以把一个大数据块拆成多个 TCP 段(适应网络 MTU 和接收窗口),它只关心字节流的正确顺序和可靠交付,不关心应用层的消息边界。应用程序如果需要区分消息边界,就必须自己在字节流上加协议,比如固定长度、特殊分隔符(如\r\n)、长度字段+内容。

(4)可靠传输

对于可靠传输,判断丢包、误码靠的是 TCP 的段编号以及确认号

TCP 为了保证报文传输的可靠,就给每个包一个序号,序号也保证了传送到接收端实体的包能按序接收;然后接收端实体对已成功收到的字节发回一个相应的确认(ACK);如果发送端实体在合理的 往返时间(RTT,Round-Trip Time) 内未收到这个确认,那么对应的数据(假设丢失了)将会被重传。

(5)提供拥塞控制

当网络出现拥塞的时候,TCP 能够减小向网络注入数据的速率和数量,缓解拥塞。

(6)提供全双工通信

TCP 允许通信双方的应用程序在任何时候都能发送数据,因为 TCP 连接的两端都设有缓存,用来临时存放双向通信的数据。TCP 可以立即发送一个数据段,也可以缓存一段时间以便一次发送更多的数据段(最大的数据段大小取决于 MSS)。

MSS:Maximum Segment Size,最大报文段长度,可以理解为 TCP 协议在单个数据包中能发送的最大数据量。

缓存一段时间以便一次发送更多的数据段:TCP 不会应用程序一有数据就立刻发送,而是会积攒一小段时间,把多个小的数据块拼成一个大的 TCP 段再发出去。这样是为了避免网络里出现大量小数据包,每个 TCP/IP 数据包都有至少 40 字节的头部(TCP 头 20 字节+IP 头 20 字节),如果数据量很小则有效载荷低,效率极低,而且大量小包容易造成网络拥塞。

UDP(用户数据报协议)

UDP 的全称是用户数据报协议,在网络中与 TCP 协议一样用于处理数据包,是一种无连接的协议。

UDP 的缺点是不提供数据包分组、组装和不能对数据包进行排序,也就是说,当报文发送之后,是无法得知其是否安全完整到达的。

UDP 特点

(1)面向无连接

首先,UDP 是不需要和 TCP 一样在发送数据前进行三次握手建立连接的,想发数据就可以开始发送了,并且也只是数据报文的搬运工,不会对数据报文进行任何拆分和拼接操作。

具体来说就是:

  • 在发送端,应用层将数据传递给传输层的 UDP 协议,UDP 只会给数据增加一个 UDP 头部标识下是 UDP 协议,然后就传递给网络层了
  • 在接收端,网络层将数据传递给传输层,UDP 只去除IP 报文头就传递给应用层,不会任何拼接操作

(2)有单播、多播、广播的功能

UDP 不止支持一对一的传输方式,同样支持一对多、多对多、多对一的方式,也就是说 UDP 提供了单播、多播、广播的功能。

(3)面向报文

发送方的 UDP 对应用程序交下来的报文,在添加 首部(UDP 头) 后就向下交付 IP 层。

UDP 对应用层交下来的报文,既不合并,也不拆分,而是保留这些报文的边界。因此,应用程序必须选择合适大小的报文。

(4)不可靠性

首先,不可靠性体现在无连接上,通信都不需要建立连接,想发就发,这样的情况肯定不可靠。

并且,收到什么数据就传递什么数据,并且也不会备份数据,发送数据也不会关心对方是否已经正确接收到数据了。

再者,网络环境时好时坏,但是 UDP 因为没有拥塞控制,一直会以恒定的速度发送数据。即使网络条件不好,也不会对发送速率进行调整。这样实现的弊端就是在网络条件不好的情况下可能会导致丢包,但是优点也很明显,在某些实时性要求高的场景(比如电话会议)就需要使用 UDP 而不是 TCP。

(5)头部开销小,传输数据报文时很高效

UDP 头部(UDP Header)包含了以下几个数据:

  • 两个十六位的端口号,分别为源端口(可选字段,不使用时填 0)和目标端口,各占 2 字节
  • 整个数据报文的长度,包括UDP 首部 + 数据的字节数,占 2 字节
  • 整个数据报文的检验和(IPv4 可选字段),该字段用于发现头部信息和数据中的错误,占 2 字节

因此 UDP 的头部开销小,只有 8 字节,相比 TCP 的至少 20 字节要少得多,在传输数据报文时是很高效的。

TCP和UDP的区别(重点)

对比项 TCP UDP
是否连接 面向连接 无连接
是否可靠 可靠传输(保证数据顺序和正确性),使用流量控制和拥塞控制 不可靠传输,不使用流量控制和拥塞控制
连接对象个数 只能是一对一通信 支持一对一、一对多、多对一和多对多交互通信
传输方式 面向字节流 面向报文
首部开销 首部最小 20 字节,最大 60 字节 首部开销小,仅 8 字节
适用场景 适用于要求可靠传输的应用,例如文件传输 适用于实时应用,例如视频会议、直播

TCP和UDP的使用场景(重点)

TCP 应用场景

效率要求相对低,但对准确性要求相对高的场景。因为传输中需要对数据确认、重发、排序等操作,相比之下效率没有 UDP 高。

例如:

  • 文件传输(准确高要求高、但是速度可以相对慢)
  • 接受邮件(需要确认接收,不能丢失)
  • 远程登录(需要确认登录,不能丢失)

UDP 应用场景

效率要求相对高,对准确性要求相对低的场景。

例如:

  • 在线视频、网络语音电话(即时通讯,速度要求高,但是出现偶尔断续不是太大问题)
  • 广播通信(广播、多播)

TCP的重传机制(重点)

由于 TCP 的下层网络(网络层)可能出现丢失、重复或失序的情况,TCP 协议提供了可靠的数据传输服务,为保证数据传输的正确性,TCP 会重传其认为已丢失(包括报文中的比特错误)的包。

TCP 使用两套独立的机制来完成重传,一是基于时间,二是基于确认信息。

(1)基于时间(超时重传):最基础的兜底方案。

TCP 在发送一个数据之后,就开启一个定时器,若是定时器到期还没收到对方的 ACK 确认报文,TCP 假定该数据段已丢失,于是立即重传一次。每重传一次,等待时间通常会加倍(指数退避),避免网络拥塞时雪上加霜。如果重传达到一定次数(比如 Linux 默认 15 次)仍然失败,TCP 就会放弃连接,并向应用程序发送一个复位信号(RST),强制断开连接。

复位信号(RST):一个特殊的 TCP 控制位,告诉对方"这个连接出错了,立即终止"。

(2)基于确认信息(快速重传,Fast Retransmit):更高效的主动发现方案。

这是更聪明的快速重传,不用等定时器到期。当接收方收到失序的报文段(比如先收到了包 3,没收到包 2),它会立即重复发送之前已确认的 ACK(比如重复 ACK 1)。发送方如果连续收到 3 个相同的重复 ACK,就推断"那个报文段很可能丢了",于是不等超时,立刻重传。这样通常能节省一个往返时间(RTT),提高效率。

TCP的拥塞控制机制(重点)

TCP 的拥塞控制机制主要是以下四种机制:慢启动(慢开始)拥塞避免快速重传快速恢复

慢启动(慢开始)

思路:开始的时候不要发送大量数据,而是先测试一下网络的拥塞程度,由小到大增加拥塞窗口的大小。

在开始发送的时候设置 cwnd = 1(cwnd 指的是拥塞窗口)。为了防止拥塞窗口增长过大引起网络拥塞,设置一个慢开始门限(ssthresh 状态变量)

  • 当 cnwd < ssthresh,使用慢开始算法
  • 当 cnwd = ssthresh,既可使用慢开始算法,也可以使用拥塞避免算法
  • 当 cnwd > ssthresh,使用拥塞避免算法

拥塞避免

拥塞避免未必能够完全避免拥塞,是说在拥塞避免阶段将拥塞窗口控制为按线性增长,使网络不容易出现阻塞。

思路:让拥塞窗口缓慢地增大,即每经过一个 往返时间(RTT) 就把发送方的拥塞窗口加一。

无论是在慢开始阶段还是在拥塞避免阶段,只要发送方判断网络出现拥塞,就把慢开始门限设置为出现拥塞时的发送窗口大小的一半。然后把拥塞窗口 cwnd 设置为 1,执行慢开始算法。

其中,判断网络出现拥塞的根据就是没有收到确认,虽然没有收到确认可能是其他原因的分组丢失,但是因为无法判定,所以都当做拥塞来处理。

快速重传

快速重传要求接收方在收到一个失序的报文段后就立即发出重复确认(为的是使发送方及早知道有报文段没有到达对方)。发送方只要连续收到三个重复确认就立即重传对方尚未收到的报文段,而不必继续等待设置的重传计时器时间到期。

由于不需要等待设置的重传计时器到期,所以能尽早重传未被确认的报文段,提高整个网络的吞吐量。

快速恢复

当发送方连续收到三个重复确认时,就执行乘法减小算法,把 ssthresh 门限减半。但是接下去并不执行慢开始算法,因为考虑到如果网络出现拥塞的话就不会收到好几个重复的确认,所以发送方现在认为网络可能没有出现拥塞,所以此时不执行慢开始算法,而是将 cwnd 设置为 ssthresh 的大小,然后执行拥塞避免算法。

TCP的流量控制机制(重点)

一般来说,流量控制就是为了让发送方发送数据的速度不要太快,要让接收方来得及接收。

TCP 采用大小可变的滑动窗口进行流量控制,窗口大小的单位是字节。窗口大小其实就是每次传输的数据大小。

  1. 当一个连接建立时,连接的每一端分配一个缓冲区来保存输入的数据,并将缓冲区的大小发送给另一端。

  2. 当数据到达时,接收方发送确认,其中包含了自己剩余的缓冲区大小。

剩余的缓冲区空间的大小被称为窗口,指出窗口大小的通知称为窗口通告。接收方在发送的每一确认中都含有一个窗口通告。

  1. 如果接收方应用程序读数据的速度能够与数据到达的速度一样快,接收方将在每一确认中发送一个正的窗口通告

  2. 如果发送方操作的速度快于接收方,接收到的数据最终将充满接收方的缓冲区,导致接收方发送一个零窗口。发送方收到一个零窗口通告时,必须停止发送,直到收到一个正的窗口通告。

TCP的可靠传输机制(重点)

TCP 的可靠传输机制是基于连续 ARQ 协议滑动窗口协议的。

连续 ARQ 协议(Automatic Repeat reQuest,自动重传请求),就是允许发送方在收到确认前连续发多个数据段,并通过超时重复 ACK 机制触发重传,以保证可靠且高效的传输。

TCP 的滑动窗口是连续 ARQ 协议的具体实现。

  • 窗口大小决定了可以不等待 ACK 连续发送的最大字节数
  • 流量控制通过接收方通告的窗口大小动态调整发送速率
  • 拥塞控制(慢启动、拥塞避免) 也控制发送窗口的大小

TCP 协议在发送方维持了一个发送窗口

  • 发送窗口以前的报文段是已经发送并确认了的报文段
  • 发送窗口当前包含了已经发送但未确认的报文段和允许发送但还未发送的报文段
  • 发送窗口以后的报文段是缓存中还不允许发送的报文段
  • 发送窗口的大小是变化的,它是由接收窗口剩余大小和网络拥塞程度来决定的,TCP 就是通过控制发送窗口的长度来控制报文段的发送速率

当发送方向接收方发送报文时,会依次发送窗口内的所有报文段,并且设置一个定时器,这个定时器可以理解为是最早发送但未收到确认的报文段。

  • 如果发送方在定时器的时间内收到某一个报文段的确认回答,则滑动窗口,将窗口的首部向后滑动到确认报文段的后一个位置,此时如果还有已发送但未收到确认的报文段,则重新设置定时器,如果没有了则关闭定时器
  • 如果定时器超时,则重新发送所有已经发送但未收到确认的报文段,并将超时的间隔设置为以前的两倍
  • 当发送方收到接收方的三个冗余的确认应答后,说明该报文段以后的报文段很有可能发生丢失了,那么发送方会启用快速重传机制,就是当前定时器结束前,发送所有的已发送但未收到确认的报文段

接收方使用的是累计确认的机制,对于所有按序到达的报文段,接收方返回一个报文段的肯定回答。

  • 如果收到了一个乱序的报文段,那么接收方会直接丢弃,并返回一个最近的按序到达的报文段的肯定回答
  • 使用累计确认保证了返回的确认号之前的报文段都已经按序到达了,所以发送窗口可以移动到已确认报文段的后面

但是 TCP 协议并不完全和滑动窗口协议相同,因为许多的 TCP 实现会将失序的报文段给缓存起来,并且发生重传时,只会重传一个报文段,因此 TCP 协议的可靠传输机制更像是连续 ARQ 协议和滑动窗口协议的一个混合体。

TCP的三次握手和四次挥手(重点)

三次握手

三次握手(Three-way Handshake) 其实就是指建立一个 TCP 连接时,需要客户端和服务器总共发送 3 个包。

进行三次握手的主要作用就是为了确认双方的接收能力和发送能力是否正常、指定自己的初始化序列号为后面的可靠性传送做准备。实质上其实就是连接服务器指定端口,建立 TCP 连接,并同步连接双方的序列号和确认号,交换 TCP 窗口大小信息。

具体流程

刚开始客户端处于 closed 状态,服务端处于 listen 状态。

(1)第一次握手:客户端给服务端发一个 SYN 报文,并指明客户端的初始化序列号(ISN,Initial Sequence Number),此时客户端处于 SYN-SENT 状态。

首部的同步位 SYN=1,初始序号 seq=x。

SYN=1 的报文段不能携带数据,但要消耗掉一个序号。

(2)第二次握手:服务器收到客户端的 SYN 报文之后,会以自己的 SYN 报文作为应答,发送一个 ACK 报文,并且也是指定了自己的初始化序列号。同时会把客户端的 ISN + 1 作为确认号(ack)的值,表示自己已经收到了客户端的 SYN,此时服务器处于 SYN-RECEIVED(SYN-RCVD) 状态。

在确认报文段中 SYN=1,ACK=1,初始序号 seq=y,确认号 ack=x+1。

ACK 标志位:TCP 头部中的一个比特,值为 1 表示"确认号字段有效"。

确认号(ack,Acknowledgment Number):TCP 头部中的一个 32 位字段,表示"期望收到的下一个字节的序号",通常简写为 ack。

(3)第三次握手:客户端收到 SYN 报文之后,会发送一个 ACK 报文,也是一样把服务器的 ISN + 1 作为确认号(ack)的值,表示已经收到了服务端的 SYN 报文,并且也是指定了序列号为自己的 ISN + 1,此时客户端处于 ESTABLISHED 状态。服务器收到 ACK 报文之后,也处于 ESTABLISHED 状态,此时,双方已建立起了连接。

确认报文段 ACK=1,确认号 ack=y+1,序号 seq=x+1(初始为 seq=x,第二个报文段所以要+1)。ACK 报文段可以携带数据,不携带数据则不消耗序号,即客户端真正发送数据时,第一个数据字节的序号仍然是 x+1。

简单来说,就是以下三步:

  1. 第一次握手:客户端向服务端发送连接请求报文段。该报文段中包含自身的数据通讯初始序号。请求发送后,客户端便进入 SYN-SENT 状态。
  2. 第二次握手:服务端收到连接请求报文段后,如果同意连接,则会发送一个应答,该应答中也会包含自身的数据通讯初始序号,发送完成后便进入 SYN-RECEIVED 状态。
  3. 第三次握手:当客户端收到连接同意的应答后,还要向服务端发送一个确认报文。客户端发完这个报文段后便进入 ESTABLISHED 状态,服务端收到这个应答后也进入 ESTABLISHED 状态,此时连接建立成功。

为什么要三次握手?两次不行吗?

为了确认双方的接收能力和发送能力都正常。

如果是用两次握手,则会出现这种情况:客户端发出连接请求,但因连接请求报文丢失而未收到确认,于是客户端再重传一次连接请求。后来收到了确认,建立了连接。数据传输完毕后,就释放了连接,客户端共发出了两个连接请求报文段,其中第一个丢失,第二个到达了服务端,但是第一个丢失的报文段只是在某些网络结点长时间滞留了,延误到连接释放以后的某个时间才到达服务端,此时服务端误认为客户端又发出一次新的连接请求,于是就向客户端发出确认报文段,同意建立连接,不采用三次握手,只要服务端发出确认,就建立新的连接了,此时客户端忽略服务端发来的确认,也不发送数据,则服务端一致等待客户端发送数据,浪费资源。

TCP 三次握手的建立连接的过程就是相互确认初始序号的过程,告诉对方,什么样序号的报文段能够被正确接收。第三次握手的作用是客户端对服务器端的初始序号的确认。

如果只使用两次握手,那么服务器就没有办法知道自己的序号是否已被确认。同时这样也是为了防止失效的请求报文段被服务器接收,而出现错误的情况。

四次挥手

刚开始双方都处于 ESTABLISHED 状态,假如是客户端先发起关闭请求。

(1)第一次挥手:客户端会发送一个 FIN 报文,报文中会指定一个序列号。此时客户端处于 FIN-WAIT1 状态。

客户端发出连接释放报文段(FIN=1,序号 seq=u),并停止再发送数据,主动关闭 TCP 连接,进入 FIN-WAIT1(终止等待1)状态,等待服务端的确认。

(2)第二次挥手:服务端收到 FIN 之后,会发送 ACK 报文,且把客户端的序列号值 +1 作为 ACK 报文的序列号值,表明已经收到客户端的报文了,此时服务端处于 CLOSE-WAIT 状态。

服务端收到连接释放报文段后即发出确认报文段(ACK=1,确认号 ack=u+1,序号 seq=v),服务端进入 CLOSE-WAIT(关闭等待)状态,此时的 TCP 处于半关闭状态,客户端到服务端的连接释放。

客户端收到服务端的确认后,进入 FIN-WAIT2(终止等待2) 状态,等待服务端发出的连接释放报文段。

(3)第三次挥手:如果服务端也想断开连接了,和客户端的第一次挥手一样,发给 FIN 报文,且指定一个序列号,此时服务端处于 LAST-ACK 状态。

服务端没有要向客户端发出的数据了,则发出连接释放报文段(FIN=1,ACK=1,序号 seq=w,确认号 ack=u+1),服务端进入 LAST-ACK(最后确认)状态,等待客户端的确认。

(4)第四次挥手:客户端收到 FIN 之后,一样发送一个 ACK 报文作为应答,且把服务端的序列号值 +1 作为自己 ACK 报文的序列号值,此时客户端处于 TIME-WAIT(时间等待) 状态,需要过一阵子以确保服务端收到自己的 ACK 报文之后才会进入 CLOSED 状态,而服务端收到 ACK 报文之后,就处于关闭连接了,处于 CLOSED 状态。

客户端收到服务端的连接释放报文段后,对此发出确认报文段(ACK=1,seq=u+1,ack=w+1),客户端进入 TIME_WAIT状态,此时 TCP 未释放掉,需要经过时间等待计时器设置的时间 2MSL 后,客户端才进入 CLOSED 状态。

2MSL(2 × Maximum Segment Lifetime):两倍的最大报文段生存时间,指报文段在网络中生存的时间,超时会被抛弃。

简单来说,就是以下四步:

  1. 第一次挥手:若客户端认为数据发送完成,则它需要向服务端发送连接释放请求。
  2. 第二次挥手:服务端收到连接释放请求后,会告诉应用层要释放 TCP 链接,然后会发送 ACK 包,并进入 CLOSE_WAIT 状态。此时表明客户端到服务端的连接已经释放,不再接收客户端发的数据了。但是因为 TCP 连接是双向的,所以服务端仍旧可以发送数据给客户端。
  3. 第三次挥手:服务端如果此时还有没发完的数据会继续发送,完毕后会向客户端发送连接释放请求,然后服务端便进入 LAST-ACK 状态。
  4. 第四次挥手:客户端收到释放请求后,向服务端发送确认应答,此时客户端进入 TIME-WAIT 状态。该状态会持续 2MSL 时间,若该时间段内没有服务端的重发请求的话,就进入 CLOSED 状态。当服务端收到确认应答后,也便进入 CLOSED 状态。

为什么需要四次挥手?

TCP 使用四次挥手,是因为 TCP 的连接是全双工的,所以需要双方分别释放到对方的连接,单独一方的连接释放,只代表不能再向对方发送数据,连接处于的是半释放的状态

当服务端收到客户端的 SYN 连接请求报文后,可以直接发送 SYN+ACK 报文。其中 ACK 报文是用来应答的,SYN 报文是用来同步的。但是在关闭连接中,当服务端收到 FIN 报文时,很可能并不会立即关闭 Socket,所以只能先回复一个 ACK 报文,告诉客户端已经收到了它发的 FIN 报文,只有等到服务端所有的报文都发送完了,才能发送 FIN 报文,因此不能一起发送,故需要四次挥手。

最后一次挥手中,客户端会等待一段时间再关闭的原因,是为了防止发送给服务器的确认报文段丢失或者出错,从而导致服务器端不能正常关闭。

UDP协议为什么不可靠?

UDP 在传输数据之前不需要先建立连接,远地主机的运输层在接收到 UDP 报文后,不需要确认,提供不可靠交付。

原因

  1. 不保证消息交付:不确认,不重传,无超时。
  2. 不保证交付顺序:不设置包序号,不重排,不会发生队首阻塞。
  3. 不跟踪连接状态:不必建立连接或重启状态机。
  4. 不进行拥塞控制:不内置客户端或网络反馈机制。

TCP粘包是怎么回事,如何处理?

默认情况下,TCP 连接会启用延迟传送算法(Nagle 算法),在数据发送之前缓存他们。如果短时间有多个数据发送,会缓冲到一起做一次发送(缓冲大小见 socket.bufferSize),这样可以减少 IO 消耗,提高性能。

如果是传输文件的话,那么根本不用处理粘包的问题,来一个包拼一个包就好了。但是如果是多条消息,或者是别的用途的数据那么就需要处理粘包。

下面看一个例子,连续调用两次 send 分别发送两段数据 data1 和 data2,在接收端有以下几种常见的情况:

  • A、先接收到 data1,然后接收到 data2
  • B、先接收到 data1 的部分数据,然后接收到 data1 余下的部分以及 data2 的全部
  • C、先接收到了 data1 的全部数据和 data2 的部分数据,然后接收到了 data2 的余下的数据
  • D、一次性接收到了 data1 和 data2 的全部数据

其中的 BCD 就是我们常见的粘包的情况。

处理粘包问题常见的解决方案

  1. 多次发送之前间隔一个等待时间。

只需要等上一段时间再进行下一次 send 就好,适用于交互频率特别低的场景。

缺点也很明显,对于比较频繁的场景传而言,传输效率实在太低,不过几乎不用做什么处理。

  1. 关闭 Nagle 算法。

关闭 Nagle 算法,在 NodeJS 中你可以通过 socket.setNoDelay() 方法来关闭 Nagle 算法,让每一次 send 都不缓冲直接发送。

该方法比较适用于每次发送的数据都比较大(但不是文件那么大),并且频率不是特别高的场景。

该方法不适用于网络较差的情况,因为关闭 Nagle 算法后,每次发送操作的数据都会立刻发出,产生多个小包。如果网络质量差,或者接收端应用程序不能及时从 TCP 接收缓冲区中取走数据,这些数据包就会在接收端缓冲区里堆积,首尾相连,导致应用程序一次读取操作拿到多个包拼在一起的数据,也就是粘包。

在稳定的机房内部通信中,这种情况概率较小,可以忽略。

  1. 进行封包/拆包。

封包/拆包是目前业内常见的解决方案了。

即给每个数据包在发送之前,于其前/后放一些有特征的数据,然后收到数据的时候根据特征数据分割出来各个数据包。

为什么UDP不会粘包?

TCP 协议是面向流的协议,UDP 是面向消息的协议。

UDP 段都是一条消息,应用程序必须以消息为单位提取数据,不能一次提取任意字节的数据。

UDP 保留应用层消息边界,每个数据报独立传输且带有头部(含源端口、目的端口等),便于接收端区分。接收端每次只能读取一个完整的数据报;若接收缓冲区小于数据报长度,多余数据将被丢弃,不会分多次接收。

JS手撕:性能优化、渲染技巧与定时器实现

作者 Wect
2026年4月5日 13:25

在前端开发中,我们经常会遇到「大量数据渲染卡顿」「频繁事件触发导致性能损耗」「自定义定时逻辑」等问题,今天就来拆解7个高频实用的JS代码片段,用「通俗话术+专业解析」的方式,讲懂每一行代码的作用、核心原理和实际应用场景,帮你吃透这些前端必备技能。

一、万条数据渲染优化:避免卡顿的核心技巧

前端渲染大量数据(比如10万条)时,直接一次性插入DOM会导致主线程阻塞,页面出现卡顿、掉帧,甚至浏览器崩溃。这段代码的核心思路是「分批渲染+文档片段+requestAnimationFrame」,从根源上减少DOM操作带来的性能损耗。

核心代码(带详细注释)

// 延迟0ms执行,让DOM先渲染完毕,避免阻塞主线程
// 这里的setTimeout(fn, 0)不是真的延迟,而是把回调放到宏任务队列,等待当前同步代码和DOM渲染完成后执行
setTimeout(() => {
  // 总共需要渲染10万条数据(实际开发中可根据需求调整)
  const total = 100000;
  // 每一批次渲染20条,防止一次性渲染过多导致卡顿
  // 批次大小可优化:一般建议20-50条,过多仍会卡顿,过少则渲染次数过多
  const once = 20;
  // 计算总共需要分多少批次(向上取整,避免最后一批数据不足20条被遗漏)
  const loopCount = Math.ceil(total / once);
  // 记录当前已经渲染了多少批次,用于判断是否渲染完成
  let countOfRender = 0;
  // 获取页面中的ul容器,所有li都会插入到这个容器中
  const ul = document.querySelector('ul');

  // 每一批次添加DOM的函数:核心是「减少DOM操作次数」
  function add() {
    // 创建文档片段(DocumentFragment),临时存放当前批次的li
    // 重点:fragment不属于页面DOM树,向它添加子元素不会触发页面重排重绘,相当于“临时仓库”
    const fragment = document.createDocumentFragment();

    // 循环生成当前批次的20个li
    for (let i = 0; i < once; i++) {
      const li = document.createElement('li');
      // 给li填充随机数字(实际开发中可替换为真实业务数据,比如列表项内容)
      li.innerText = Math.floor(Math.random() * total);
      // 先放入片段中,此时不触发任何页面渲染
      fragment.appendChild(li);
    }

    // 一次性将20条li插入ul,只触发一次DOM重排(关键优化点)
    // 对比:如果每次循环都appendChild到ul,会触发20次重排,性能极差
    ul.appendChild(fragment);
    // 已渲染批次+1,更新渲染进度
    countOfRender += 1;

    // 继续执行下一批渲染
    loop();
  }

  // 控制渲染节奏:使用requestAnimationFrame,跟随浏览器刷新频率执行
  // 浏览器每秒刷新约60次(16.67ms/帧),requestAnimationFrame会在每帧开始时执行回调
  // 好处:避免渲染操作与浏览器刷新冲突,保证页面流畅不卡顿
  function loop() {
    // 如果还没渲染完所有批次,就继续下一批
    if (countOfRender < loopCount) {
      window.requestAnimationFrame(add);
    }
  }

  // 启动第一轮渲染
  loop();
}, 0);

关键知识点解析

  • setTimeout(fn, 0):不是延迟执行,而是将回调推入宏任务队列,确保当前同步代码和DOM初始化完成后再执行,避免DOM未挂载时查询不到ul容器。

  • 分批渲染:将10万条数据拆分为5000批次(100000/20),每次只渲染20条,降低单次DOM操作的压力。

  • DocumentFragment:前端性能优化神器,作为临时DOM容器,所有操作都在内存中完成,最后一次性插入页面,只触发1次重排重绘,比直接操作真实DOM性能提升10倍以上。

  • requestAnimationFrame:与setTimeout相比,它能跟随浏览器刷新频率执行,避免“掉帧”,尤其适合大量DOM渲染、动画等场景,保证页面流畅度。

实际应用场景

大数据列表渲染(如后台管理系统的订单列表、日志列表)、长列表滚动加载(结合滚动事件,滚动到底部时加载下一批),避免一次性渲染大量数据导致页面卡死。

二、手撕防抖:解决频繁触发的“性能杀手”

防抖(Debounce)的核心逻辑:频繁触发同一事件时,只在最后一次触发后,延迟指定时间执行回调函数。比如搜索框输入、窗口resize、滚动事件,频繁触发会导致性能损耗,防抖能有效“合并”触发次数。

1. 基础版防抖(延迟执行)

// 防抖:频繁触发时,只在**最后一次触发后延迟执行**
function debounce(callback, wait) {
  // 定时器标识:用闭包保存,避免污染全局变量,且能在多次调用中共享状态
  let timer = null;

  // 返回一个可调用的包装函数,接收原函数的参数
  return function (...args) {
    // 保存原this(解决事件回调中this指向window的问题,比如btn点击事件中this应指向btn)
    const context = this;

    // 再次触发时,清除之前的定时器 → 重新计时(核心:取消上一次的延迟执行)
    if (timer) clearTimeout(timer);

    // 新建定时器:延迟 wait 毫秒后执行回调
    timer = setTimeout(() => {
      // 恢复原函数的this指向和参数,保证回调函数执行时上下文正确
      callback.apply(context, args);
      // 执行完清空timer,方便垃圾回收,避免内存泄漏
      timer = null;
    }, wait);
  };
}

2. 完整版防抖(支持立即执行/延迟执行)

基础版防抖是“延迟执行”(触发后等待wait时间再执行),但实际开发中有时需要“立即执行”(第一次触发就执行,之后频繁触发不执行,直到wait时间后才可再次执行),比如按钮点击防重复提交。

// 完整版防抖:支持 立即执行(immediate=true) / 延迟执行(immediate=false)
function debounce(callback, wait, immediate) {
  let timer = null; // 闭包缓存定时器,共享状态

  return function () {
    // 每次进入先清除上一次定时器 → 重新计时(无论立即还是延迟,都要取消上一次)
    if (timer) clearTimeout(timer);

    // ========== 立即执行模式 ==========
    if (immediate) {
      // timer为null时表示可以立即执行(首次触发或wait时间已过)
      const callNow = !timer;

      // 设置定时器:wait时间后把timer置空,解锁“立即执行”权限
      // 作用:这段时间内再次触发,callNow会为false,不会执行回调
      timer = setTimeout(() => {
        timer = null;
      }, wait);

      // 满足立即执行条件时,调用原函数,恢复this和参数
      if (callNow) {
        callback.apply(this, arguments);
      }

    // ========== 常规延迟执行模式 ==========
    } else {
      // 延迟wait时间执行,每次触发都重置定时器
      timer = setTimeout(() => {
        callback.apply(this, arguments);
        timer = null; // 执行后清空,垃圾回收
      }, wait);
    }
  };
}

关键知识点解析

  • 闭包的作用:保存timer变量,让多次触发的事件能共享同一个定时器标识,实现“清除上一次定时器”的逻辑,这是防抖的核心。

  • this指向修复:事件回调中this默认指向window(如addEventListener中的回调),通过context = this + apply(context, args),让原函数this指向正确的元素(如按钮、输入框)。

  • 立即执行vs延迟执行

    • 立即执行(immediate=true):适合防重复提交(按钮点击后立即执行,wait时间内不可再次点击);

    • 延迟执行(immediate=false):适合搜索框联想(输入停止后wait时间,再发送请求,避免频繁请求接口)。

实际应用场景

搜索框输入联想、窗口resize事件(调整窗口大小时,避免频繁计算布局)、滚动事件(滚动到底部加载更多,避免频繁触发)、按钮防重复提交。

三、手撕节流:固定频率执行,避免过度触发

节流(Throttle)的核心逻辑:频繁触发同一事件时,按照固定的时间间隔执行回调函数,无论触发多少次,都不会超过这个频率。和防抖的区别:防抖是“最后一次触发后执行”,节流是“固定频率持续执行”。

1. 立即触发版节流(停止触发后不执行最后一次)

// 节流:固定频率执行,立即触发,停止触发后不执行最后一次
function throttle(callback, wait) {
  // 上一次执行回调的时间戳(初始为0,确保第一次触发能立即执行)
  let previous = 0;

  return function(...args) {
    // 获取当前时间戳
    const now = Date.now();
    // 核心逻辑:当前时间 - 上一次执行时间 >= 等待时间,才执行回调
    if (now - previous >= wait) {
      // 执行回调,恢复this和参数
      callback.apply(this, args);
      // 更新上一次执行时间戳为当前时间,开始下一个周期
      previous = now;
    }
  };
}

2. 延迟触发版节流(停止触发后仍执行最后一次)

立即触发版节流的问题:如果停止触发时,距离上一次执行已经超过wait时间,不会执行最后一次触发的回调。延迟触发版可以解决这个问题,适合需要“收尾”的场景(如滚动加载,即使停止滚动,也要执行最后一次加载逻辑)。

// 节流:固定频率执行,延迟触发,停止触发后仍执行最后一次
function throttle(callback, wait) {
  let timer = null; // 用定时器控制延迟执行

  return function(...args) {
    const context = this;
    // 核心逻辑:没有定时器才创建,不重置计时(保证固定频率)
    if (!timer) {
      timer = setTimeout(() => {
        // 延迟wait时间执行回调
        callback.apply(context, args);
        // 执行后清空定时器,允许下一次创建
        timer = null;
      }, wait);
    }
  };
}

关键知识点解析

  • 时间戳版(立即触发):通过对比当前时间和上一次执行时间,控制执行频率,优点是简单高效,缺点是停止触发后不会执行最后一次。

  • 定时器版(延迟触发):通过定时器控制执行时机,优点是停止触发后仍会执行最后一次,缺点是首次触发会延迟wait时间才执行。

  • 防抖vs节流

    • 防抖:合并多次触发,只执行最后一次(比如搜索输入);

    • 节流:控制触发频率,固定间隔执行(比如滚动加载、鼠标移动绘制)。

实际应用场景

滚动事件(监听滚动位置,固定频率更新导航栏状态)、鼠标移动事件(绘制canvas,避免频繁重绘)、resize事件(固定频率调整页面布局)、高频点击事件(如游戏中的攻击按钮,控制点击频率)。

四、自定义定时器:可递增延迟的MySetInterval

原生setInterval的缺点:间隔时间固定,无法实现“每次执行延迟递增”的需求(比如第一次延迟100ms,第二次延迟200ms,第三次延迟300ms...)。这段代码通过类封装,实现了“基础延迟+递增步长”的自定义定时器,灵活满足复杂定时需求。

核心代码(带详细注释)

class MySetInterval {
  /**
   * @param {Function} fn 要执行的函数(回调函数)
   * @param {number} base 基础延迟 a(第一次执行的延迟时间)
   * @param {number} step 每次递增 b(每次执行的延迟比上一次多b ms)
   * @param  {...any} args 传递给 fn 的参数(可选)
   */
  constructor(fn, base, step, ...args) {
    this.fn = fn;         // 要执行的回调函数
    this.base = base;     // 基础延迟时间(ms)
    this.step = step;     // 延迟递增步长(ms)
    this.args = args;     // 传递给回调函数的参数
    this.count = 0;       // 记录执行次数(用于计算当前延迟)
    this.timer = null;    // 定时器ID,用于停止定时器
  }

  // 启动定时器
  start() {
    // 计算当前批次的延迟:a + count * b(第一次count=0,延迟a;第二次count=1,延迟a+b,以此类推)
    const delay = this.base + this.count * this.step;

    // 用setTimeout模拟递归执行,实现“递增延迟”
    this.timer = setTimeout(() => {
      // 执行用户传入的回调函数,并传递参数
      this.fn(...this.args);
      // 执行次数+1,为下一次延迟计算做准备
      this.count++;
      // 递归调用start,启动下一次定时执行
      this.start();
    }, delay);
  }

  // 停止定时器(必须有,避免内存泄漏)
  stop() {
    // 清除当前定时器
    clearTimeout(this.timer);
    // 清空timer,方便垃圾回收,也避免重复停止
    this.timer = null;
  }
}

// 使用示例
const timer = new MySetInterval(() => {
  console.log('自定义定时器执行');
}, 100, 50); // 第一次延迟100ms,第二次150ms,第三次200ms...
timer.start(); // 启动
// timer.stop(); // 停止(需要时调用)

关键知识点解析

  • 类封装优势:通过class封装,将定时器的状态(count、timer、base等)挂载到实例上,避免全局变量污染,同时方便调用start和stop方法,逻辑更清晰。

  • 递增延迟实现:通过count记录执行次数,每次执行后count+1,下一次延迟 = 基础延迟 + count * 递增步长,实现“每次延迟递增”的效果。

  • 递归setTimeout:没有使用原生setInterval,而是用setTimeout递归调用start方法,避免setInterval可能出现的“时间漂移”(比如回调执行时间过长,导致下一次执行延迟偏差)。

实际应用场景

倒计时递增(比如活动倒计时,后期每秒增加延迟,营造紧迫感)、轮播图渐变(每次切换的延迟递增,实现慢放效果)、接口重试(失败后重试,每次重试延迟递增,避免频繁请求接口)。

五、重写setTimeout:用requestAnimationFrame模拟

原生setTimeout的缺点:执行时间不精确,受主线程阻塞影响(比如主线程有耗时操作,setTimeout的回调会延迟执行)。而requestAnimationFrame(rAF)会跟随浏览器刷新频率执行(16.67ms/帧),用它模拟setTimeout,能让延迟执行更精确,同时避免主线程阻塞导致的偏差。

核心代码(带详细注释)

// 用 requestAnimationFrame 模拟 setTimeout,提升执行精度
let setTimeout = (fn, timeout, ...args) => {
  const start = Date.now(); // 记录定时器启动的时间戳
  let timer;               // 保存rAF的标识,用于取消定时器

  // 循环执行函数,每帧检查是否达到设定的延迟时间
  const loop = () => {
    // 注册下一次rAF回调,保证循环执行,跟随浏览器刷新频率
    timer = window.requestAnimationFrame(loop);
    // 获取当前时间戳
    const now = Date.now();

    // 核心逻辑:当前时间 - 启动时间 >= 设定的延迟时间,执行回调
    if (now - start >= timeout) {
      // 执行用户传入的回调函数,恢复this和参数
      fn.apply(this, args);
      // 执行完成后,取消rAF循环,避免无限执行
      window.cancelAnimationFrame(timer);
    }
  };

  // 启动rAF循环,开始计时
  window.requestAnimationFrame(loop);
};

// 使用示例(和原生setTimeout用法一致)
setTimeout(() => {
  console.log('用rAF模拟的setTimeout执行');
}, 1000);

关键知识点解析

  • 精度提升原理:原生setTimeout的延迟是“最小延迟”,如果主线程忙碌,回调会被推迟;而rAF每帧(16.67ms)执行一次loop,每次都检查时间差,一旦达到设定延迟就执行回调,精度更高。

  • 循环终止:通过cancelAnimationFrame(timer)取消rAF循环,避免回调执行后仍继续循环,造成性能损耗。

  • 用法兼容:模拟后的setTimeout用法和原生一致,无需修改现有代码,直接替换即可提升执行精度。

实际应用场景

需要精确延迟执行的场景(如动画同步、定时更新DOM)、避免主线程阻塞导致延迟偏差的场景(如复杂页面中的定时任务)。

六、模拟sleep函数:让代码“暂停”指定时间

JS中没有原生的sleep函数(即“暂停代码执行指定时间,再继续执行后面的代码”),但可以通过Promise+setTimeout模拟。核心思路:返回一个Promise,在setTimeout延迟后resolve,通过await等待Promise完成,实现代码“暂停”效果。

核心代码(带详细注释)

// 休眠函数:等待 time 毫秒后,再继续执行后面的代码
// 核心:通过Promise包裹setTimeout,用resolve触发后续代码执行
function sleep(time) {
  // 返回一个Promise对象,pending状态表示“正在休眠”
  return new Promise(function (resolve) {
    // 定时 time 毫秒后,执行resolve(),让Promise变为fulfilled状态
    // resolve()无参数,仅用于通知“休眠结束”
    setTimeout(resolve, time);
  });
}

// 使用示例(必须配合async/await,因为await只能在async函数中使用)
async function test() {
  console.log('开始执行');
  await sleep(2000); // 暂停2000ms(2秒)
  console.log('2秒后执行'); // 2秒后才会打印这句话
  await sleep(1000); // 再暂停1秒
  console.log('再1秒后执行');
}

关键知识点解析

  • Promise的作用:用Promise包裹setTimeout,将“延迟执行”转化为“异步等待”,配合await使用,实现代码的“线性暂停”,避免回调地狱。

  • async/await依赖:sleep函数返回Promise,必须在async函数中用await调用,才能实现“暂停”效果;如果不用await,代码会继续执行,不会暂停。

  • 非阻塞特性:sleep是异步暂停,不会阻塞主线程,其他异步任务(如接口请求、DOM渲染)可以在sleep期间正常执行,避免页面卡顿。

实际应用场景

代码分步执行(如引导页步骤切换,每步间隔1秒)、接口请求重试(失败后sleep1秒再重试)、模拟加载动画(sleep指定时间后隐藏加载框)。

七、版本号对比:实现语义化版本排序

在前端开发中,经常需要对版本号进行排序(如npm包版本、项目版本),版本号格式通常为“x.y.z”(如1.0.0、2.3.4、1.10.2),直接字符串排序会出现错误(如1.10.2会排在1.2.0前面),这段代码能正确对比版本号大小并排序。

核心代码(带详细注释)

// 版本号对比排序:接收版本号数组,返回按升序排列的数组
var compareVersion = function (versions) {
  // 用数组的sort方法排序,核心是自定义排序规则
  return versions.sort((version1, version2) => {
    // 1. 将版本号按“.”分割,转为数字数组(如"1.10.2" → [1,10,2])
    // map(Number):将分割后的字符串转为数字,避免字符串比较的误差
    let s1 = version1.split('.').map(Number);
    let s2 = version2.split('.').map(Number);

    // 2. 逐位对比版本号(从左到右,依次对比主版本、次版本、修订版本)
    // 循环次数取两个版本号数组的最大长度,不足的位补0(如1.0 → [1,0,0])
    for (let i = 0; i < s1.length || i < s2.length; i++) {
      const v1 = s1[i] || 0; // 版本1当前位的值,无则补0
      const v2 = s2[i] || 0; // 版本2当前位的值,无则补0
      // 若当前位不相等,直接返回差值(正数表示v1>v2,负数表示v1<v2)
      if (v1 !== v2) {
        return v1 - v2;
      }
    }

    // 3. 若所有位都相等,按原字符串排序(处理版本号格式完全一致的情况)
    return version1.localeCompare(version2);
  })
};

// 使用示例
const versions = ['1.10.2', '1.2.0', '2.3.4', '1.0.0', '1.5'];
console.log(compareVersion(versions)); 
// 输出:["1.0.0", "1.2.0", "1.5", "1.10.2", "2.3.4"]

关键知识点解析

  • 版本号分割与转数字:split('.')将版本号分割为数组,map(Number)转为数字数组,避免“10”作为字符串比“2”小的问题(字符串比较是按字符编码,'10' < '2')。

  • 逐位对比逻辑:从左到右对比每一位版本号,先对比主版本(第一位),主版本大的版本号更大;主版本相等则对比次版本(第二位),以此类推;不足的位补0(如1.5 → 1.5.0)。

  • 兜底逻辑:若所有位都相等(如1.0.0和1.0.0),用localeCompare按字符串排序,保证排序的稳定性。

实际应用场景

npm包版本排序、后台管理系统的版本日志排序、APP版本更新提示(对比当前版本和最新版本,判断是否需要更新)。

总结

以上7个代码片段,覆盖了前端开发中「性能优化」「事件处理」「定时任务」「版本对比」四大核心场景,每段代码都包含“核心逻辑+详细注释+知识点解析+实际应用”,既能直接复制使用,也能帮你理解背后的原理。

重点记住:前端性能优化的核心是「减少DOM操作」「避免频繁触发事件」「合理利用异步」,而防抖、节流、分批渲染、rAF都是实现这些目标的关键手段;自定义定时器、sleep、版本对比则是解决实际业务场景的实用工具,掌握这些,能让你的代码更高效、更健壮。

JS炼化:闭包——未识其名已善用, 深知反被概念拘

作者 忆往wu前
2026年4月5日 12:31

扯下“闭包”大衣,发现函数你好装

ZZZ复盘学习JS基础,重新认识知识,总结掌握知识,炼化知识,输出知识。这是一篇学习反思,用自己的逻辑把知识体系搭建,用自己的话总结输出。

首先看闭包定义 :闭包是指一个函数以及其捆绑的周边环境状态(词法环境)的引用的组合。

(定义中既有捆绑也有引用,感觉很复杂拗口。但是,如果我们从函数特性出发,再结合 JavaScript 发展过程中有意思的历史背景与设计演变,看清闭包的意义是如何一步步变化的,从而真正理解:我们今天学习闭包,到底在学什么、它又为何重要。)

一句话戳破闭包:没有那么玄乎,就是函数的特性延展

一、从函数来看闭包

1.1 了解函数概念及特点

在JS里,函数是一段可以被重复执行的代码块,也是实现闭包最基础的单元。想要看懂闭包,必须先搞懂函数这几个核心特点:

<>1. 函数可以独立定义,也可以嵌套定义 我们不仅可以在全局写函数,还可以在一个函数内部再定义另一个函数,形成内外层函数结构。

<>2. 函数有自己的作用域 函数内部声明的变量,外部无法直接访问,形成了一层独立的“变量空间”,保证了数据的独立性。

<>3. 内部函数可以访问外部函数的变量 内层函数能够“向外”读取外层函数的变量和参数,而外层函数不能访问内层函数的变量,这是一条单向可见的规则。

<>4. 函数可以作为值被返回和传递 函数可以被  return  出去、赋值给变量、当作参数传递,让它可以在定义它的作用域之外执行。

有了上面这四个函数特性,我们就初步理解了函数的基本能力。接下来我们试着实现一个简单功能,不用刻意去写闭包,却会在无形中把闭包搭建出来。

1.2 应用函数特长形成闭包

给你一个任务,写一段代码实现最简单的计数功能

// 定义一个用来生成计数功能的函数
function makeCounter() {
  // 在这里定义一个变量,用来记录次数
  let count = 0

  // 返回一个新的函数
  return function () {
    // 每次执行这个函数,count 就加 1
    count++
    // 把最新的 count 返回出去
    return count
  }
}

// 调用 makeCounter,得到里面返回的那个函数
const counter = makeCounter()

// 每调用一次 counter,就会操作之前的 count
console.log(counter()) // 1
console.log(counter()) // 2
console.log(counter()) // 3

通过上面的代码,我们实现了计数器功能。

回头看闭包的定义: 闭包是函数与其周边词法环境(状态)的引用所捆绑形成的组合。

对照这段代码就会发现:

  • 我们有一个内部函数
  • 这个函数引用并绑定了外部函数的  count  变量(也就是它的词法环境/状态)
  • 即使外部  makeCounter  执行完毕,内部函数依然保留着对  count  的引用

所以这段代码虽然我们没有刻意强调闭包,但它已经完全符合闭包的定义,天然形成了标准的闭包结构。通过这个例子,只是看到了我写了一个稍微复杂一点的逻辑,就有了一个闭包的形成,但是对于它的产生和定义还是有疑惑?继续看完后面闭包的优缺点以及历史演变,我想这些问题就会迎刃而解了。

二、为什么要学闭包:三大优点

2.1 优点一

持久保存状态,变量不会丢失

:内部函数会一直记住外部函数的变量,函数执行完后变量依然保留,不会被重置。

还看刚刚写的计数器

function makeCounter() {
  let count = 0
  return function () {
    count++
    return count
  }
}

const counter = makeCounter()
console.log(counter()) // 1
console.log(counter()) // 2

这个功能用到了函数的这几个特性:

  • 用到了 函数可以嵌套,在外面函数里又写了一个函数。
  • 用到了 内部函数可以访问外部函数变量,所以内部函数能拿到并修改  count 。
  • 用到了 函数可以被返回,把内部函数传出去,在外面还能调用。

正是这几个特性一起作用, count  才会被一直记住,实现了状态持久保存。

2.2 优点二

实现数据私有,外部无法随意修改

:外部函数里的变量,外部作用域无法直接访问和修改,只能通过闭包函数操作,保证了数据安全。

看一个简单私有数据

function createPerson() {
  let name = "小明" // 外部无法直接改

  return {
    getName: function () {
      return name
    }
  }
}

const person = createPerson()
console.log(person.getName()) // 小明
// 无法直接修改 person.name,保证了数据私有

这个功能同样是依靠函数特性:

  • 函数有 自己独立的作用域,外部本来就不能直接访问里面的  name 。
  • 再加上 函数嵌套 和 内部函数能访问外部变量,外部只能通过返回的方法去读  name 。
  • 最后把方法 返回出去,外部才能使用。

最终效果就是: 外面可以调用,但不能直接改,只能通过内部函数操作,实现了数据私有。

2.3 优点三

这里我先卖个关子,第三个优点我先不直接写出来。相信看完后面的例子和历史演变,自然就明白了,同时也是希望大家能看下去,虽然是个人的学习分享,但是想让大家一起思考一下技术学习的意义。

三、闭包的历史与作用域演变

3.1 闭包是不是JS 特有?还是早就被定义

虽然可能大家都知道,但是对于我来说确实一开始以为闭包是 JavaScript 才有的东西,了解后才发现其实不然。 闭包早在 1964 年就被计算机科学家 Peter Landin(彼得·兰丁) 正式定义,最初源于 λ 演算 和函数式编程,是一个非常古老的通用概念。

最早的官方定义(原文直译):

闭包是由 环境部分 + 控制部分 组成的整体,用于记录表达式的执行上下文。

简单大白话翻译(你文章直接用):

闭包就是 一个函数 + 它所绑定的外部变量环境 的组合,把“开放”的函数变成“闭合”的完整整体。

它从一开始就不是 JS 特有,Python、Java、Kotlin、Rust 等语言全都支持。 我们今天在 JS 里学闭包,本质是在学一门通用的编程基础能力,而不只是某一门语言的小技巧。

3.2 var 的问题与闭包“被迫”使用形成的第三个优点

前面我卖了个关子,说闭包还有第三个关键作用。 其实它和早期 JavaScript 里  var  的设计问题紧密相关——正是因为 var 没有块级作用域,才让我们不得不靠闭包来解决问题,这也成了闭包一个很重要的历史价值。

首先看例子1:循环绑定事件(var 的经典坑)

// 用 var 会出问题
for (var i = 0; i < 3; i++) {
  setTimeout(function () {
    console.log(i) // 输出:3 3 3
  }, 1000)
}

因为  var  只有函数作用域,没有块级作用域, 循环里的  i  是同一个变量,等到定时器执行时, i  已经变成 3 了。

“被迫”用闭包来解决

当年没有  let ,大家只能被迫用闭包来锁住每一次的  i :

  
for (var i = 0; i < 3; i++) {
  // 用立即执行函数制造一个函数作用域,形成闭包
  (function (currentI) {
    setTimeout(function () {
      console.log(currentI) // 输出:0 1 2
    }, 1000)
  })(i)
}

这里就是闭包的第三个优点:

  • 在没有块级作用域的年代,闭包帮我们模拟出独立的作用域
  • 让每个循环都能保存自己的变量,互不干扰

被迫这个词用的不是很恰当,只能说闭包很厉害能够成功解决这个痛点。但也正是因为这些痛点,后来 ES6 才推出了  let  /  const ,让我们不用再被迫写复杂的闭包。但也正因为这段历史,我们才更明白:闭包的意义,不只是技巧,更是语言发展过程中解决问题的关键方案。

3.3 let / const 带来的技术解放

后来 ES6 带来了  let  和  const ,可以说给前端开发者带来了一次技术解放。

它们拥有真正的块级作用域,在  for  循环、 if 、 {}  里都会生成独立的变量, 不再像  var  那样全局共用一个变量。

我们再看刚才的循环问题:直接用  let ,代码变简单了,再也不需要靠闭包来强行造作用域。

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

这就意味着:

  • 以前闭包第三条优点是被迫使用,用来填  var  的坑
  • 现在  let  /  const  把坑填上了,闭包终于回归它本来的样子: 用来做状态保存、数据私有这些真正有价值的场景

所以我们回过头再看闭包,它的意义也变了: 不再是“不得不写的 workaround”, 而是主动选择、用来写出更优雅、更安全代码的强大特性。

3.4补充闭包使用带来的缺点

闭包虽然好用,但也有一个问题:它会让变量一直留在内存里,不容易被释放,用多了可能让程序变卡。

原因也很简单: 内部函数一直引用着外部函数的变量,所以就算外部函数执行完了,这些变量也不会被垃圾回收,会一直占着内存。如果不注意,就可能造成内存占用过高,甚至内存泄漏。

比如

function outer() {
  let data = new Array(10000).fill('我占内存')

  return function inner() {
    console.log(data.length)
  }
}

const fn = outer()
// 只要 fn 还在,data 就一直占内存

怎么解决?

不用闭包的时候,把引用设为 null,浏览器就会回收内存:

fn = null

四、总结:闭包是什么?我理解的闭包

4.1再看闭包是什么?

依我来看,闭包其实并不是 JavaScript 里刻意创造的复杂语法,而是我们用函数嵌套、实现功能时自然而然就会产生的一种形式。本质上,就是通过函数嵌套,让内部函数可以访问并使用外部函数的变量和参数,从而延长了这些变量的生命周期,同时让不同调用之间的数据相互独立、互不干扰,也实现了变量的私有化。

只要我们用到了「函数能访问外部作用域变量」这个特性,并且用它来实现对应的功能,闭包就已经在默默工作了。理解到这一层就会发现,闭包并没有那么神秘难懂,它只是 JavaScript 函数特性的自然延伸,是写好代码、做复杂逻辑时绕不开、也必然会用到的东西。

以上就是我对闭包的学习心路历程,逻辑梳理和炼化后的输出

如有理解不当,欢迎大家指正,一起学习进步

双源验证与强制推演:生产级 AI 投研 Agent 的幻觉抑制方案

作者 木西
2026年4月5日 11:55

前言

本文系前篇技术方案的进阶优化版本,核心聚焦于解决生成式 AI 在实际应用中的三大典型问题:幻觉生成(Hallucination)、语义模糊(Ambiguity)及输出精度不足(Low Precision)

前篇内容主要围绕基础环境配置与端到端流程验证展开,侧重于确保系统可正常运行,尚未对业务逻辑层进行深度优化与精细化处理。

整体架构概览

这是一个 LangChain/LangGraph 驱动的 AI 投研 Agent,专门用于分析地缘政治事件对预测市场(Polymarket)和能源市场的影响,寻找套利机会。

🔧 第一部分:tools.ts —— 工具层实现

1. 环境配置与依赖

import * as dotenv from "dotenv";      // 环境变量管理
import { tool } from "@langchain/core/tools";  // LangChain 工具装饰器
import { z } from "zod";                // 参数校验
import { TavilySearch } from "@langchain/tavily";  // Tavily AI 搜索引擎
import axios from "axios";              // HTTP 请求
import { HttpsProxyAgent } from "https-proxy-agent";  // 代理支持

2. 网络代理配置

const agent = new HttpsProxyAgent("http://127.0.0.1:3067");  // 本地代理
const axiosConfig = { timeout: 15000, httpsAgent: agent, proxy: false };
  • 通过本地 3067 端口代理访问外部 API
  • 15秒超时设置

3. 工具一:financialSearchTool —— Tavily 新闻搜索

属性 说明
功能 搜索最新新闻背景、套利机会或市场异动
底层引擎 TavilySearch (maxResults: 5)
输入格式 {"query": "搜索关键词"}
输出 JSON 格式化的搜索结果

核心用途:获取基本面信息(政策、突发事件、市场情绪)


4. 工具二:marketDataTool —— Polymarket 套利扫描器 ⭐核心

这是一个高度优化的智能过滤系统

4.1 语义映射表(中英关键词转换)
"原油" → ["oil", "crude", "energy", "brent", "wti"]
"中东" → ["israel", "gaza", "iran", "lebanon", "middle east"]
"战争" → ["war", "military", "strike", "attack"]
"选举" → ["election", "trump", "harris"]
"套利" → ["arbitrage", "mispricing", "spread", "basis"]
4.2 智能组合搜索策略
const hotSuffixes = ["ceasefire", "rate cut", "ath", "trump", "deadline"];
// 生成组合词:baseTerm + hotSuffix
// 例如:输入"以色列" → 生成 "israel ceasefire", "israel rate cut" 等
4.3 数据获取与过滤流程
1. 调用 Polymarket API → 获取最活跃的 50 个市场
2. 语义匹配 → 检查标题是否包含关注词或其组合
3. 噪音过滤 → 剔除体育类市场 (NBA, NHL, soccer等)
4. 结果排序 → 按相关度返回
4.4 输出格式
{
  "status": "success",
  "count": 3,
  "data": [
    {"question": "市场问题", "price": 0.65, "endDate": "2024-12-31"},
    ...
  ],
  "hint": "请分析以上市场之间是否存在隐含概率冲突或定价偏差"
}

🤖 第二部分:主程序 —— ReAct Agent 实现

1. 核心架构组件

┌─────────────────────────────────────────┐
│           ReAct Agent 架构             │
├─────────────────────────────────────────┤
│  LLM: DeepSeek Chat (via OpenAI SDK)   │
│  Memory: MemorySaver (对话状态持久化)   │
│  Tools: [搜索工具, 行情工具]             │
│  System Prompt: 投研专家角色定义         │
└─────────────────────────────────────────┘

2. LLM 配置详解

const llm = new ChatOpenAI({
    apiKey: process.env.DEEPSEEK_API_KEY,
    modelName: "deepseek-chat",
    configuration: { baseURL: process.env.DEEPSEEK_API_BASE_URL },
    temperature: 0,  // 确定性输出,适合分析任务
});

3. 系统提示词(System Message)核心逻辑

【双源验证策略】
┌─────────────┐      ┌─────────────┐
│  盘面赔率    │  ←→  │  新闻基本面  │
│ (Polymarket)│      │ (Tavily搜索) │
└─────────────┘      └─────────────┘
      ↓                    ↓
   寻找定价偏差 ←──── 验证/补充信息
【 fallback 机制】

如果没搜到预测市场数据 → 禁止只说"没搜到" → 必须基于新闻进行逻辑推演

【输出模板强制结构】
章节 内容要求
一、实时盘面观测 赔率数据或"未定价原因分析"
二、核心驱动因子 3条最关键新闻摘要
三、投研推演 深度见解 + 概率判断 + 套利建议

4. Agent 执行流程

const result = await agent.invoke({
    messages: [{ role: "user", content: "分析 Israel(以色列)局势..." }]
}, config);

ReAct 循环

  1. Thought: LLM 分析需要调用什么工具
  2. Action: 调用 get_realtime_market_datafinancial_market_search
  3. Observation: 接收工具返回数据
  4. Repeat: 如有需要,继续调用工具
  5. Final Answer: 生成结构化投研报告

🎯 完整工作流程图解

用户输入主题
    ↓
[Agent 启动 ReAct 循环]
    ↓
┌─────────────────┐
│ 并行调用双工具  │
├─────────────────┤
│ 1. 搜索新闻基本面 │ → Tavily API2. 扫描Polymarket│ → 智能语义匹配 → 过滤体育噪音
└─────────────────┘
    ↓
[数据融合分析]
    ↓
┌─────────────────────────┐
│  定价偏差检测算法        │
│  - 赔率 vs 新闻热度对比  │
│  - 相似市场概率冲突检测   │
│  - 时间差套利机会识别     │
└─────────────────────────┘
    ↓
[生成结构化报告]
    ↓
📊 深度投研报告(三章节固定格式)

💡 核心创新点

特性 说明
语义智能映射 中文输入自动扩展为英文关键词组合
组合搜索策略 基础词 + 热点后缀,捕获最新市场
噪音过滤 自动剔除体育等无关市场
强制推演机制 无数据时必须逻辑推演,禁止空返回
套利导向设计 输出明确提示寻找定价偏差

这是一个生产级的地缘政治-能源套利分析 Agent,结合了实时预测市场数据与 AI 搜索能力,输出具备实战价值的投研报告。

核心代码

工具(Tools.ts)

import * as dotenv from "dotenv";
dotenv.config();
import { tool } from "@langchain/core/tools";
import { z } from "zod";
import { TavilySearch } from "@langchain/tavily";
import axios from "axios";
import { HttpsProxyAgent } from "https-proxy-agent";

const agent = new HttpsProxyAgent("http://127.0.0.1:3067");
const axiosConfig = { timeout: 15000, httpsAgent: agent, proxy: false };
const searchInstance = new TavilySearch({ maxResults: 5 }); // 增加搜索结果以捕捉更多套利新闻

// 1. 搜索工具:强化了对套利/异动新闻的搜索描述
export const financialSearchTool = tool(
    async (input) => {
        const query = typeof input === 'string' ? input : (input.query || JSON.stringify(input));
        console.log(`\n[🔍 正在执行深度搜索]: ${query}`);
        try {
            const res = await searchInstance.invoke(query);
            return JSON.stringify(res);
        } catch (e: any) { return `搜索暂时不可用`; }
    },
    {
        name: "financial_market_search",
        description: "搜索最新新闻背景、套利机会或市场异动。请输入关键词对象,例如:{\"query\": \"Polymarket arbitrage opportunities\"}",
        schema: z.object({ query: z.string() }), 
    }
);

// 2. 深度优化的行情工具 (Arbitrage & Trend Ready)
export const marketDataTool = tool(
    async (input) => {
        const userInput = typeof input === 'string' ? input : (input.marketName || JSON.stringify(input));
        
        // 1. 行业语义映射表 (包含最新套利关键词)
        const mapping: Record<string, string[]> = {
            "原油": ["oil", "crude", "energy", "brent", "wti", "gasoline"],
            "油价": ["oil", "crude", "energy"],
            "中东": ["israel", "gaza", "iran", "lebanon", "middle east", "hezbollah", "conflict"],
            "战争": ["war", "military", "strike", "attack", "invasion"],
            "选举": ["election", "trump", "vance", "harris", "walz"],
            "停火": ["ceasefire", "truce", "peace"],
            "核": ["nuclear", "facility", "isfahan"],
            "海峡": ["hormuz", "strait", "shipping"],
            "宏观": ["fed", "rate cut", "inflation", "recession", "gdp"],
            "套利": ["arbitrage", "mispricing", "spread", "basis", "hedging"],
            "时间差": ["deadline", "expiry", "until", "before", "sooner"],
            "美联储": ["powell", "fomc", "interest rate", "basis points"],
            "加密货币": ["bitcoin", "etf", "ethereum", "solana", "ath"]
        };

        // 获取基础关注词
        let baseTerms = [userInput.toLowerCase()];
        for (const [zh, ens] of Object.entries(mapping)) {
            if (userInput.includes(zh)) {
                baseTerms = ens;
                break;
            }
        }

        // --- 优化点:自动生成组合搜索词(捕获最新最热) ---
        const hotSuffixes = ["ceasefire", "rate cut", "ath", "trump", "deadline"];
        const focusTerms = [
            ...baseTerms,
            ...baseTerms.flatMap(term => hotSuffixes.map(suffix => `${term} ${suffix}`))
        ];

        console.log(`\n[📊 正在扫描套利机会]: 领域 -> ${userInput} | 衍生词数 -> ${focusTerms.length}`);

        try {
            // 获取全平台最火的 50 个市场
            const url = `https://gamma-api.polymarket.com/markets?active=true&closed=false&limit=50`;
            const res = await axios.get(url, axiosConfig);
            
            if (!res.data || res.data.length === 0) return "Polymarket 暂无活跃市场。";

            // 2. 精准过滤逻辑
            const relevantMarkets = res.data.filter((m: any) => {
                const title = m.question.toLowerCase();
                // 必须包含正向词,剔除体育等干扰噪音
                const hasFocus = focusTerms.some(term => title.includes(term));
                const isNoise = ["nhl", "nba", "cup", "game", "soccer", "football"].some(noise => title.includes(noise));
                return hasFocus && !isNoise;
            });

            if (relevantMarkets.length > 0) {
                // 按相关度或题目排序,方便 Agent 对比相似市场寻找套利空间
                const marketList = relevantMarkets.map((m: any) => ({
                    question: m.question,
                    price: m.lastTradePrice,
                    endDate: m.endDate
                }));

                return JSON.stringify({
                    status: "success",
                    count: marketList.length,
                    data: marketList,
                    hint: "请分析以上市场之间是否存在隐含概率冲突或定价偏差。"
                });
            }

            return `[提示]:热门榜单中暂无直接对标 "${userInput}" 的套利交易对。`;
        } catch (e: any) { return `行情接口异常: ${e.message}`; }
    },
    {
        name: "get_realtime_market_data",
        description: "获取 Polymarket 实时赔率与套利机会。支持组合搜索。",
        schema: z.object({ marketName: z.string() }),
    }
);

export const tools = [financialSearchTool, marketDataTool];

主程(index.ts)

import * as dotenv from "dotenv";
dotenv.config();
import { ChatOpenAI } from "@langchain/openai";
import { createReactAgent } from "@langchain/langgraph/prebuilt"; 
import { MemorySaver } from "@langchain/langgraph";
import { tools } from "./tools1";

// ... 其他 import 保持不变

const systemMessage = `你是一位 Web3 顶级投研专家。你的目标是产出具备实战价值的地缘政治与能源市场报告。

【分析逻辑链条】:
1. 优先调用 get_realtime_market_data 寻找“盘面赔率”(最真实的真金白银预测)。
2. 同时调用 financial_market_search 搜索“基本面动态”(新闻、政策、突发事件)。

【核心指令】:
- 如果搜到了预测市场数据:请对比“盘面价格”与“新闻热度”,寻找定价偏差。
- 如果【没搜到】预测市场数据:你绝对不能只说“没搜到”。你必须利用搜索到的新闻事实,基于你的专业知识进行“逻辑推演”,分析当前局势对未来油价的潜在影响路径。

【报告输出模板】:
## 📊 [主题] 深度投研报告
---
### 一、 实时盘面观测 (On-chain Data)
[列出获取到的赔率,若无则分析为何市场尚未定价]

### 二、 核心驱动因子 (News Context)
[列出 Tavily 搜索到的 3 条最关键新闻摘要]

### 三、 投研推演与套利建议 (Expert Thesis)
[基于以上两点给出深度见解,必须包含对未来走势的概率判断]`;

// 创建 Agent 的配置
const llm = new ChatOpenAI({
        apiKey: process.env.DEEPSEEK_API_KEY,
        modelName: "deepseek-chat",
        configuration: { baseURL: process.env.DEEPSEEK_API_BASE_URL },
        temperature: 0,
    });
const memory = new MemorySaver();
const agent = createReactAgent({
  llm,
  tools,
  checkpointSaver: memory,
  messageModifier: systemMessage,
});

// 在运行前,我们可以通过一段逻辑拦截并修正 (这步是关键)
async function run() {
  console.log("🚀 正在启动一劳永逸版 Agent...");
  const config = { configurable: { thread_id: "final_test" }, recursionLimit: 50 };
  
  const result = await agent.invoke({
    messages: [{ role: "user", content: "分析 Israel(以色列)局势对预测市场的影响。" }]
  }, config);

  // 打印最后结果
  const lastMsg = result.messages[result.messages.length - 1];
  console.log("\n--- 最终报告 ---");
  console.log(lastMsg.content);
}

run();

结语

至此,关于 LangChain/LangGraph 驱动的 AI 投研 Agent 从理论架构到工程实现的完整链路已全部落地。

该方案通过双源验证策略(盘面赔率 + 新闻基本面)与强制推演机制,有效抑制了生成式 AI 的幻觉与模糊输出问题,为地缘政治与能源市场的量化分析提供了可复用的技术框架。后续可围绕多 Agent 协作、动态策略回测等方向持续迭代。

JS手撕:DOM操作 & 浏览器API高频场景详解

作者 Wect
2026年4月5日 11:22

在前端开发中,我们经常会遇到一些重复且基础的需求——比如解析URL参数、给大量元素绑定点击事件、实现图片懒加载等。这些功能看似简单,但写得不够严谨就容易出现bug(比如中文参数乱码、事件绑定冗余、滚动加载卡顿)。

今天就整理了7个前端高频实用JS功能,用“通俗话+专业解析”的方式,把每个功能的原理、代码细节和使用场景讲透,还附上了可直接复制使用的优化版代码,新手也能快速套用。

一、URL参数解析:把URL里的参数“拆”成可直接用的对象

通俗解读

我们经常会看到这样的URL:https://xxx.com/list?page=1&size=10&keyword=前端,里面的page、size、keyword就是参数。这个功能就是把这些参数“拆出来”,变成一个对象(比如{page:1, size:10, keyword:"前端"}),不用我们手动去切割字符串,省心又不易错。

专业解析

核心利用浏览器原生的URL对象,它能自动解析URL的协议(http/https)、主机(xxx.com)、路径(/list)和查询参数(?后面的内容);再配合URLSearchParams处理查询参数,同时解决中文乱码(用decodeURIComponent解码)、空值、重复key、数字类型转换等常见问题。

完整代码(可直接复制)

function parseParam(url) {
  // 创建URL对象,自动解析协议、域名、路径、参数(原生API,无需手动切割)
  const urlObj = new URL(url);
  // 获取查询参数部分(即?后面的内容,不含?)
  const queryParams = new URLSearchParams(urlObj.search);
  const paramsObj = {};

  // 遍历所有查询参数,处理各种边界情况
  for (let [key, value] of queryParams.entries()) {
    // 空值处理:如果参数值是空字符串或null,统一赋值为true(比如?flag&name=xxx,flag对应true)
    if (value === '' || value == null) {
      paramsObj[key] = true;
    } else {
      // 解码参数:处理中文、特殊字符(比如%E5%89%8D%E7%AB%AF解码为“前端”)
      let val = decodeURIComponent(value);
      // 纯数字字符串转为数字类型(比如"123"转为123,避免后续使用时还要手动转换)
      val = /^\d+$/.test(val) ? parseFloat(val) : val;

      // 重复key处理:如果参数有多个相同key(比如?tag=js&tag=html),转为数组形式
      if (paramsObj.hasOwnProperty(key)) {
        paramsObj[key] = [].concat(paramsObj[key], val);
      } else {
        paramsObj[key] = val;
      }
    }
  }

  return paramsObj;
}

// 示例使用
const url = "https://api.example.com/data?id=123&name=张三&page=2&tag=js&tag=html&flag";
const params = parseParam(url);
console.log(params);
// 输出:{id: 123, name: "张三", page: 2, tag: ["js", "html"], flag: true}

关键注意点

  • URL对象是浏览器原生API,无需引入第三方库,兼容现代浏览器(IE不支持,需兼容IE可额外处理);

  • 中文参数必须用decodeURIComponent解码,因为URL中中文会被自动编码为%开头的字符;

  • 重复key(如?tag=js&tag=html)处理为数组,避免后面的参数覆盖前面的。

二、事件委托:一次绑定,搞定所有子元素的事件

通俗解读

如果页面有100个按钮,给每个按钮都绑定点击事件,会占用很多内存,而且新增按钮还得重新绑定。事件委托就是“找一个父容器”,只给父容器绑定一次事件,不管里面有多少个子元素(哪怕是后来新增的),点击子元素时都会触发父容器的事件,再通过判断点击的是哪个子元素,执行对应逻辑。

专业解析

利用DOM事件的“冒泡机制”(子元素的事件会向上传递给父元素),将事件绑定在父容器上,通过event.target获取真正被点击的子元素,再通过matches()方法匹配目标元素选择器,实现“一次绑定,多元素复用”,优化性能并简化代码。

完整代码(可直接复制)

/**
 * 事件委托(代理)
 * @param {string} eventType 事件类型 click/input 等(比如"click"、"input")
 * @param {string|Element} elDelegate 委托父元素(选择器字符串或DOM对象)
 * @param {string} selector 真正要触发的目标元素选择器(比如"#btn"、".item")
 * @param {Function} fn 触发的回调函数(this指向目标元素)
 */
function on(eventType, elDelegate, selector, fn) {
  // 1. 处理委托父元素:如果传入的是选择器字符串,自动转为DOM对象
  if (!(elDelegate instanceof Element) && typeof elDelegate === 'string') {
    elDelegate = document.querySelector(elDelegate);
  }

  // 安全判断:如果没找到父元素,直接退出,避免报错
  if (!elDelegate) return null;

  // 2. 给父元素绑定事件,利用事件冒泡机制
  elDelegate.addEventListener(eventType, (e) => {
    let el = e.target; // 真正被点击/触发的元素(子元素)

    // 3. 向上查找匹配selector的元素(防止点击的是子元素的子节点)
    while (el && !el.matches(selector)) {
      if (el === elDelegate) { // 查到委托父元素还没匹配到,说明不是目标元素,停止查找
        el = null;
        break;
      }
      el = el.parentNode; // 向上查找父级节点
    }

    // 4. 如果找到目标元素,执行回调,this指向目标元素
    el && fn.call(el, e, el);
  });

  return elDelegate;
}

// HTML示例
/*

*/

// 示例使用1:单个目标元素
on('click', '#box', '#btn', function(e, el){
  console.log('点击成功!');
  console.log(this); // this 指向 #btn(目标元素)
  console.log(el); // el 也是目标元素,和this一致
});

// 示例使用2:多个目标元素(.item)
on('click', '#box', '.item', function(e, el){
  console.log('点击了item按钮:', el.innerText);
});

关键注意点

  • 委托父元素必须是目标元素的祖先节点(比如按钮的父div、body、document);

  • 不要阻止事件冒泡(e.stopPropagation()),否则事件无法传递到父元素,委托失效;

  • 新增的子元素(比如通过JS动态添加的按钮),无需重新绑定事件,会自动触发委托的事件。

三、滚动加载:滚动到底部自动加载更多内容

通俗解读

我们刷朋友圈、逛电商列表时,往下滚动页面,到底部后会自动加载更多内容,这就是滚动加载。核心就是“判断页面是否滚动到底部”,如果到了,就执行加载数据的逻辑。

专业解析

通过监听window的scroll事件,获取三个关键高度:可视区域高度(屏幕能看到的页面高度)、滚动条已滚动距离、页面总高度(包括看不见的部分)。核心判断公式:可视区域高度 + 已滚动距离 ≥ 页面总高度,满足该条件即表示滚动到底部。

完整代码(可直接复制)

// 监听滚动事件
window.addEventListener('scroll', function() {
    // 1. 可视区域高度(屏幕能看到的高度,不同设备可能不同)
    const clientHeight = document.documentElement.clientHeight;
    // 2. 滚动条卷上去的高度(已滚动的距离,兼容不同浏览器)
    const scrollTop = document.documentElement.scrollTop || document.body.scrollTop;
    // 3. 整个页面总高度(包括看不见的部分,即页面完整高度)
    const scrollHeight = document.documentElement.scrollHeight;

    // 核心判断:可视高度 + 已滚动距离 ≥ 总高度 → 滚动到底部(加10是为了提前加载,优化体验)
    if (clientHeight + scrollTop >= scrollHeight - 10) {
        console.log("滚动到底部啦!");
        // 这里写加载更多逻辑(比如请求接口、渲染列表)
        // loadMoreData(); // 假设这是加载更多数据的函数
    }
}, false);

// 优化建议:滚动事件会频繁触发,可结合节流函数(参考后面的图片懒加载),避免性能消耗
// 比如:window.addEventListener('scroll', throttle(handleScroll, 300), false);

关键注意点

  • scroll事件会频繁触发(滚动过程中每秒触发几十次),建议结合节流函数(后面会讲),减少函数执行次数,优化性能;

  • 滚动到底部的判断可加一个小偏移量(比如-10),让加载提前触发,避免用户看到底部空白再加载;

  • 加载数据时,建议添加“加载中”状态,防止用户多次触发加载。

四、图片懒加载:减少页面加载时间,提升体验

通俗解读

页面有很多图片时,如果一打开就加载所有图片,会导致页面加载变慢、卡顿。图片懒加载就是“只加载屏幕能看到的图片”,用户往下滚动页面,图片进入视野后再加载,既节省带宽,又提升页面加载速度。

专业解析

核心思路:先给图片设置自定义属性(比如data-src)存储真实图片地址,src属性设为占位图(或空);监听scroll事件,判断图片是否进入可视区域,若进入,则将data-src的值赋给src,实现图片加载。同时用节流函数限制scroll事件触发频率,避免性能消耗。

完整代码(可直接复制)

// 节流函数:限制函数在指定时间内只能执行一次(优化scroll事件频繁触发)
function throttle(fn, delay) {
  let timer = null;
  return function (...args) {
    if (!timer) {
      fn.apply(this, args); // 执行函数
      timer = setTimeout(() => {
        timer = null; // 延迟后重置timer,允许下次执行
      }, delay);
    }
  };
}

// 图片懒加载核心函数
function lazyload() {
  const imgs = document.getElementsByTagName('img'); // 获取所有图片元素(注意加s,避免报错)
  const viewHeight = document.documentElement.clientHeight; // 可视区域高度
  // 滚动距离(兼容不同浏览器)
  const scrollTop = document.documentElement.scrollTop || document.body.scrollTop;

  // 遍历所有图片,判断是否进入可视区域
  for (let i = 0; i < imgs.length; i++) {
    const img = imgs[i];
    const offsetTop = img.offsetTop; // 图片到页面顶部的距离

    // 判断:图片顶部距离 ≤ 可视区域高度 + 滚动距离 → 图片进入可视区域
    if (offsetTop < viewHeight + scrollTop) {
      // 优化:已经加载过的图片跳过,避免重复赋值(防止scroll事件重复触发导致的冗余操作)
      if (img.src === img.dataset.src) continue;

      // 开始加载图片:将data-src(真实地址)赋给src
      img.src = img.dataset.src;
    }
  }
}

// 监听scroll事件,用节流函数限制触发频率(300ms执行一次)
window.addEventListener('scroll', throttle(lazyload, 300));

// 页面刚打开时,执行一次懒加载(加载可视区域内的图片)
window.addEventListener('load', lazyload);

// HTML示例
/*
<!-- 占位图用1x1透明图,或loading图片,data-src存储真实图片地址 -->

*

关键注意点

  • 图片必须设置src属性(可设为占位图),否则会出现图片占位空白;

  • 节流函数的延迟时间(300ms)可根据需求调整,延迟太短达不到优化效果,太长会影响体验;

  • 页面加载完成后(window.load)必须执行一次lazyload,避免可视区域内的图片无法加载。

五、统计HTML页面标签:快速了解页面结构

通俗解读

有时候我们需要知道一个页面用了多少种标签、每种标签用了多少次(比如做页面优化、排查冗余标签),这个功能就能自动统计,不用手动一个个数,还能按使用次数排序。

专业解析

利用document.getElementsByTagName('*')获取页面所有元素,转为数组后提取每个元素的标签名;用Set统计标签种类(Set自动去重),用reduce统计每种标签的数量,最后用sort排序,返回标签种类数和各标签数量(从多到少)。

完整代码(可直接复制)

function countTagsOnPage() {
  // 1. 获取页面所有元素(*表示匹配所有标签)
  const allTags = document.getElementsByTagName('*');
  // 2. 转为数组,提取每个元素的标签名,并转为大写(统一格式,避免div和DIV重复统计)
  const tagNames = [...allTags].map(el => el.tagName.toUpperCase());
  
  // 3. 统计标签种类(Set自动去重,size就是种类数)
  const totalTagTypes = new Set(tagNames).size;

  // 4. 统计每种标签的数量(用reduce累加)
  const tagCount = tagNames.reduce((acc, tag) => {
    acc[tag] = (acc[tag] || 0) + 1; // 有则加1,无则初始化为1
    return acc;
  }, {});

  // 5. 按标签数量从多到少排序(将对象转为数组,再排序)
  const sorted = Object.entries(tagCount).sort((a, b) => b[1] - a[1]);

  // 返回统计结果:标签种类数、排序后的标签数量
  return {
    totalTagTypes,
    tagCounts: sorted
  };
}

// 使用并打印结果
const res = countTagsOnPage();
console.log('页面标签种类:', res.totalTagTypes);
console.log('各标签数量(从多到少):', res.tagCounts);
// 示例输出:
// 页面标签种类:8
// 各标签数量(从多到少):[["DIV", 12], ["SPAN", 8], ["IMG", 5], ["BUTTON", 3], ...]

关键注意点

  • tagName返回的是大写标签名(比如div返回DIV),统一转为大写可避免大小写重复统计;

  • document.getElementsByTagName('*')会获取所有元素,包括head、body、script等隐藏元素,若需统计可见元素,可添加筛选条件;

  • 排序后的结果是二维数组,每个元素的第一个值是标签名,第二个值是数量。

六、点击打印HTML标签名:快速定位元素标签

通俗解读

开发时,我们经常需要知道点击的元素是什么标签(比如排查样式问题、调试事件绑定),这个功能就是“点击页面任意元素,自动打印该元素的标签名”,不用手动去开发者工具里查看。

专业解析

利用事件委托(前面讲过的知识点),在document上绑定一次click事件,通过event.target获取被点击的具体元素,再用tagName获取该元素的标签名,最后用console.log打印(也可改为弹窗显示)。

完整代码(可直接复制)

// 利用事件委托,在document上绑定一次click事件,处理所有元素的点击
document.addEventListener('click', function(event) {
    // event.target 指向被点击的具体元素(最底层子元素)
    const clickedElement = event.target;
    
    // 获取标签名(tagName返回大写形式,如'DIV'、'SPAN'、'IMG')
    const tagName = clickedElement.tagName;
    
    // 打印标签名(默认控制台打印,可改为弹窗)
    console.log(`点击的元素标签名:${tagName}`);
    // 如需弹窗显示,可取消下面这行的注释
    // alert(`点击的元素标签名:${tagName}`);
});

// 示例:点击页面上的div、span、按钮,控制台会分别打印 DIV、SPAN、BUTTON

关键注意点

  • 点击的是元素的子节点(比如span里的文本),event.target会指向文本节点的父元素(span),不影响标签名获取;

  • 可根据需求修改打印方式(控制台打印/弹窗),弹窗适合非开发环境快速查看;

  • 若只想打印特定元素的标签名,可添加筛选条件(比如只打印按钮标签:if(tagName === 'BUTTON') { ... })。

七、模拟JSONP:解决跨域请求问题

通俗解读

前端请求接口时,经常会遇到“跨域”报错(比如前端域名是a.com,接口域名是b.com),JSONP是一种简单的跨域解决方案。核心就是“通过创建script标签,加载接口地址,利用script标签不受跨域限制的特性,获取后端返回的数据”。

注意:结合你提供的报错信息 link hit security strategy(链接触发安全策略),若使用JSONP时出现该报错,大概率是后端接口的安全策略限制了该请求(比如不允许JSONP请求、域名白名单限制),需联系后端调整安全策略。

专业解析

JSONP的核心原理:script标签的src属性不受同源策略限制,可加载任意域名的资源。前端生成唯一回调函数名,拼接在接口URL中;后端接收请求后,返回“回调函数名(数据)”的格式;前端通过全局回调函数,接收并处理后端返回的数据,最后删除临时创建的script标签和全局函数,避免冗余。

完整代码(可直接复制)

function JSONP(url, _params = {}) {
  // 1. 生成唯一回调函数名(防止多个JSONP请求冲突,默认用jsonp_+时间戳)
  const callbackName = _params.callback || "jsonp_" + Date.now();
  
  // 2. 处理请求参数(排除callback,因为要单独拼接)
  const params = [];
  for (let key in _params) {
    if (key !== "callback") {
      // 编码参数值,处理中文/特殊字符
      params.push(`${key}=${encodeURIComponent(_params[key])}`);
    }
  }
  // 3. 拼接callback参数(JSONP核心:后端会根据该参数返回对应的回调函数调用)
  params.push(`callback=${callbackName}`);

  // 4. 创建script标签,用于加载接口(script不受跨域限制)
  const script = document.createElement("script");
  // 拼接接口URL和参数(url?key1=value1&key2=value2&callback=xxx)
  script.src = `${url}?${params.join("&")}`;

  // 5. 返回Promise,方便用then/catch处理结果
  return new Promise((resolve, reject) => {
    
    // 6. 挂载全局回调函数(必须在script加载前定义,否则后端返回时函数还不存在)
    window[callbackName] = (result) => {
      try {
        resolve(result); // 成功:将后端返回的数据传入resolve
      } catch (err) {
        reject(err); // 失败:捕获异常并传入reject
      } finally {
        // 7. 清理工作:删除script标签和全局回调函数,避免内存泄漏
        document.body.removeChild(script);
        delete window[callbackName];
      }
    };

    // 8. 处理脚本加载失败(比如网络错误、接口不存在)
    script.onerror = () => {
      reject(new Error("JSONP 请求失败"));
      // 失败也需要清理
      document.body.removeChild(script);
      delete window[callbackName];
    };

    // 9. 把script插入页面(最后执行,确保回调函数已定义)
    document.body.appendChild(script);
  });
}

// 示例使用(结合你提供的URL)
JSONP("https://api.example.com/data", {
  id: 123,
  callback: "getData" // 可选:指定后端约定的回调名,不指定则自动生成
}).then(res => {
  console.log("拿到数据:", res);
}).catch(err => {
  console.log("出错:", err);
  // 若出现 "link hit security strategy" 报错,需检查后端安全策略
});

// 后端返回格式(必须是回调函数调用的形式)
// getData({ "name": "张三", "id": 123 })
// 前端会通过window.getData接收该数据,并传入then的回调函数

关键注意点

  • JSONP只支持GET请求,不支持POST请求(因为script标签的src只能发起GET请求);

  • 回调函数名必须唯一,避免多个JSONP请求冲突(代码中用时间戳保证唯一性);

  • 若出现 link hit security strategy 报错,不是前端代码问题,而是后端接口的安全策略限制了该JSONP请求,需联系后端调整(比如添加前端域名到白名单、允许JSONP请求);

  • 请求完成后必须清理script标签和全局函数,避免内存泄漏。

总结

以上7个JS功能,覆盖了前端开发中URL处理、事件绑定、性能优化、跨域请求等高频场景,代码均经过优化,可直接复制到项目中使用。

重点提醒:使用JSONP时若遇到 link hit security strategy 报错,需排查后端安全策略,而非前端代码;另外,事件绑定和滚动相关功能,建议结合节流函数优化性能,避免频繁触发函数导致页面卡顿。

清明假期第二天 湖南客流1301.83万人次

2026年4月5日 18:40
4月5日,据湖南日报消息,清明假期第二天,根据手机信令大数据建模统计,湖南全省当日接待游客1301.83万人次,其中省外游客186.57万人次,占比为14.33%;本省游客1115.26万人次,占比为85.67%,同比增长3.10%。根据银联商务大数据建模统计,当日交易量2495.21万笔,当日交易额38亿元,同比增长3.05%。(每日经济新闻)

民政部:清明假期第二天各地祭扫活动平稳有序

2026年4月5日 18:19
据民政部消息,今天是清明节假期第二天,也是清明节当天,全国祭扫人数较昨日明显上升。据民政部统计,今天全国有6.62万家殡葬服务机构提供现场祭扫服务,44.60万名工作人员参与服务保障工作。全天共接待现场祭扫群众3073.40万人次,较昨日增加59.33%;疏导车辆685.65万台次,较昨日增加49.96%。其中,选择鲜花祭扫等绿色低碳祭扫方式的群众1957.86万人次,占现场祭扫总人数的63.70%;859个网络祭扫平台服务祭扫群众35.38万人次。全天各殡葬服务机构共安葬骨灰19258份,其中采取海葬、树葬等生态安葬方式的有1281份,占当日安葬总数的6.65%。各地民政部门和殡葬服务机构严格落实各项服务保障措施,积极开展便民利民服务,引导群众选择文明绿色低碳祭扫方式。截至目前,全国殡葬服务机构未发生突发安全事件,群众祭扫活动安全平稳有序。明天是清明节假期最后一天,预计各地仍有不少群众出行祭扫。民政部提醒广大群众,及时关注当地天气变化和交通路况信息,自觉遵守当地祭扫要求,文明绿色低碳祭扫。(界面新闻)

4月5日全社会跨区域人员流动量预计超2.6亿人次

2026年4月5日 18:14
4月5日,从交通运输部获悉,今天预计全社会跨区域人员流动量26727.6万人次,环比下降11.0%,同比增长7.2%。预计铁路客运量1470万人次,环比下降33.6%,同比增长7.4%。预计公路人员流动量(包括高速公路及普通国省道非营业性小客车人员出行量、公路营业性客运量)24952万人次,环比下降9.3%,同比增长7.2%。其中,公路营业性客运量3464万人次,环比下降8.5%,同比增长3.8%;高速公路及普通国省道非营业性小客车人员出行量21488万人次,环比下降9.4%,同比增长7.8%。预计水路客运量136.6万人次,环比增长11.5%,同比增长7.2%。预计民航客运量169.0万人次,环比下降11.8%,同比下降0.4%。(财联社)

机构最新调研路线图出炉,迈瑞医疗最获关注

2026年4月5日 17:53
Wind数据显示,机构本周共调研了355家上市公司,其中迈瑞医疗最获关注,参与调研的机构达356家。此外,东鹏饮料获325家机构调研,金盘科技、澜起科技均获超200家机构调研。从被调研总次数来看,东方钽业获机构调研10次,中国重汽获调研5次,军信股份、金风科技获调研4次。从调研行业来看,机构持续聚焦医药医疗、电子元件、集成电路等板块。(第一财经)
❌
❌