阅读视图

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

现代 CSS 的新力量

如果你拆开一个稍微复杂一点的 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 节点]原理解析与实际应用

【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之筛选器

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的原理和使用

什么是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响应式机制的理解

深入理解 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接口

📌 系列简介:「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:你以为只是配置?其实是“流水线工人”和“包工头”

你配置过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

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手撕:性能优化、渲染技巧与定时器实现

在前端开发中,我们经常会遇到「大量数据渲染卡顿」「频繁事件触发导致性能损耗」「自定义定时逻辑」等问题,今天就来拆解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炼化:闭包——未识其名已善用, 深知反被概念拘

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

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 的幻觉抑制方案

前言

本文系前篇技术方案的进阶优化版本,核心聚焦于解决生成式 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高频场景详解

在前端开发中,我们经常会遇到一些重复且基础的需求——比如解析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 报错,需排查后端安全策略,而非前端代码;另外,事件绑定和滚动相关功能,建议结合节流函数优化性能,避免频繁触发函数导致页面卡顿。

项目越做越乱?多半是缺少一点点规范

一、误区:混乱是项目变复杂的必然结果

很多团队会这样安慰自己:

  • “项目大了,本来就会乱”
  • “人多了,不可能统一”
  • “先跑起来,规范以后再说”

但现实是:

👉 不是项目变复杂,而是“复杂度没有被约束”

没有规范的项目,演变路径几乎一致:

文件乱放 → 命名混乱 → 提交不可读 → 分支失控 → 不敢改代码


二、工程化视角:规范的本质是“降低协作成本”

规范不是为了“好看”,而是解决三个问题:

  1. 别人能不能快速理解你的代码
  2. 多人协作会不会互相干扰
  3. 问题能不能快速定位和回滚

所以判断规范是否有价值的标准是:

有没有让协作更顺畅,而不是更繁琐


三、最小但有效的 4 个规范(核心)

不需要一堆文档,只要这 4 个做到位,项目就会明显“干净”。


1️⃣ 目录结构:让代码“能被猜到在哪”

很多项目的问题:

  • utils 到处都是
  • components 什么都放
  • 页面 / 逻辑 / 状态混在一起

👉 结果:找代码靠“记忆”


✅ 极简结构(通用前端)

src/
├── pages/        # 页面(路由级)
├── components/   # 通用组件
├── features/     # 按业务划分(推荐)
│   └── user/
│       ├── api.ts
│       ├── store.ts
│       ├── components/
│       └── hooks.ts
├── utils/        # 真正通用的工具
├── services/     # 请求封装

🎯 核心规则

  • 优先按“业务”分,而不是按“技术类型”分
  • 一个功能的代码尽量放在一起

👉 本质:

让“猜路径”成为可能


2️⃣ Git 提交:让历史“可读、可回滚”

常见问题:

  • fix
  • update
  • 改了一下
  • 一次提交改 20 个文件

👉 结果:Git 记录毫无价值


✅ 极简提交规范(够用版)

feat: 新增用户登录功能
fix: 修复登录接口报错
refactor: 重构用户模块结构
style: 调整样式

🎯 核心规则

  1. 一句话说明“做了什么”
  2. 只做一件事(一个提交)
  3. 可回滚(不要把多个改动混一起)

👉 本质:

Git 是你的“时间机器”,不是备份工具


3️⃣ 分支管理:避免“代码互相污染”

常见混乱:

  • 所有人都在 main 上开发
  • 分支命名随意
  • 合并冲突频繁

✅ 极简分支策略(小团队够用)

main        # 稳定可发布
dev         # 日常开发
feature/*   # 功能开发
fix/*       # bug 修复

🎯 核心规则

  • 不在 main 上直接开发
  • 一个功能一个分支
  • 开发完成再合并

👉 示例:

feature/login
fix/user-api-error

👉 本质:

隔离变化,减少冲突


4️⃣ Code Review:保证代码“可控”

很多团队的问题:

  • 不 review,直接合
  • review 只看“有没有 bug”
  • review 变成“挑刺大会”

✅ 极简 Review 规则(高性价比)

只看 3 件事:


① 结构是否清晰

  • 文件是否放对位置?
  • 有没有乱放 utils?

② 命名是否可读

  • 变量名是不是“看名知意”?
  • 有没有 a、b、temp 这种?

③ 有没有明显重复代码

  • 是否可以抽复用?
  • 是否在 copy 改?

🎯 核心原则

Review 不是找错,而是“保证长期可维护”


四、真正的分水岭:规范不是“多”,而是“持续执行”

很多团队失败在:

  • 写了一堆规范
  • 没人执行
  • 三天后全部失效

👉 规范不是“文档”,而是:

每天都在发生的行为约束


五、落地建议(非常关键)


1️⃣ 从“最小规则”开始(不要贪多)

只推这 4 个:

  • 目录结构
  • 提交规范
  • 分支规则
  • Review 三点

2️⃣ 用工具“强制执行”

而不是靠自觉:

  • commit lint(限制提交信息)
  • lint / format(统一代码风格)

3️⃣ 先统一“新代码”,再慢慢治理旧代码

👉 不要一开始就全量重构


六、总结一句话

项目的混乱,不是因为人多,而是因为“没有约束变化的规则”。

规范的价值,不在于“看起来专业”,
而在于:

👉 让项目在持续变化中,依然保持可控

告别过度工程:菜鸟前端亲证,浏览器早已帮你搞定这 9 件事

作为一名拥有 14 年前端开发经验的菜鸟,我亲历了前端行业从刀耕火种的 jQuery 时代,到框架百花齐放的工程化时代,再到如今原生 API 日趋完善的现代化时代。在漫长的开发生涯中,我见过太多团队陷入过度工程化的陷阱:为了实现一个简单功能,引入数十 KB 的第三方库;手写大量冗余 JS 代码,解决浏览器早已原生支持的问题;盲目追求自定义实现,忽略平台原生能力的稳定性与兼容性。

这篇文章,我将结合 14 年踩坑、重构、性能优化的实战经验,拆解 9 个前端高频场景 —— 这些需求你每天都可能遇到,而浏览器原生 API/CSS 特性早已给出完美解,帮你告别冗余代码、减少依赖、提升性能与可维护性。全文无抄袭,全部基于实战经验重构,带你回归前端本质,用好浏览器这座 “宝藏库”。

一、非关键任务延迟执行:requestIdleCallback,告别 setTimeout 黑科技

刚入行时,我们处理非关键任务(如用户行为埋点、日志上报、次要资源预加载),几乎都用setTimeout(fn, 0)这种黑科技。原理是利用浏览器事件循环,把任务塞进宏队列末尾,尽量不阻塞主线程,但这种方式完全不受浏览器调度控制—— 页面渲染繁忙时,它照样执行,导致卡顿、交互延迟,尤其在移动端老机型上问题频发。

后来我做电商网站,商品列表页同时渲染上百个组件,还要上报滚动、点击埋点,用setTimeout导致页面滑动掉帧,LCP(最大内容绘制)指标严重超标。直到发现requestIdleCallback这个原生 API,才彻底解决问题。

requestIdleCallback的核心逻辑是:只在浏览器空闲时执行指定任务,完全贴合浏览器渲染周期,不会阻塞关键渲染路径、用户交互(点击、输入、滚动)。它会监听浏览器主线程状态,当主线程空闲(无重排重绘、无用户操作)时,才触发回调,完美适配非紧急、非阻塞的任务。

14 年经验实战用法

javascript

运行

// 非关键埋点:用户滚动行为统计
function trackUserScrollBehavior() {
  const scrollInfo = {
    scrollTop: document.documentElement.scrollTop,
    scrollHeight: document.documentElement.scrollHeight,
    timestamp: Date.now()
  };
  // 异步上报,不阻塞主线程
  navigator.sendBeacon('/api/track/scroll', JSON.stringify(scrollInfo));
}

// 优雅降级:兼容不支持的浏览器(如旧版Safari)
if ('requestIdleCallback' in window) {
  // 空闲时执行,支持超时配置(确保任务最终会执行)
  requestIdleCallback(trackUserScrollBehavior, { timeout: 2000 });
} else {
  // 降级方案,仍优先不阻塞
  setTimeout(trackUserScrollBehavior, 30);
}

老兵关键提醒

  1. 适用场景:数据埋点、日志上报、非核心资源预加载、后台计算、图片离线生成等非紧急任务;绝对不要用于动画、交互响应等关键任务。
  2. 兼容性:现代浏览器全覆盖,Safari 15.4 + 支持,旧版需降级。
  3. 性能收益:我曾用它优化电商首页,埋点逻辑不再阻塞渲染,页面滑动帧率从 35fps 提升至 60fps,LCP 缩短 200ms,这就是原生能力的力量。

二、父级元素聚焦样式::focus-within,干掉冗余 JS 聚焦监听

早年做表单开发,想实现 “输入框聚焦时,父级容器高亮边框”,标准解法是:给输入框绑定focusblur事件,通过 JS 动态添加 / 移除父级样式。代码量大、容易漏绑事件、表单字段多了还会出现样式不同步 bug,维护成本极高。

直到 CSS :focus-within伪类出现,我才意识到:十几行 JS 能解决的事,一行 CSS 就搞定。这个伪类的作用是:当子元素处于聚焦状态时,选中父级元素,无需任何 JS 逻辑,纯 CSS 实现,无 bug、无性能损耗。

14 年经验实战用法

css

/* 基础表单容器样式 */
.form-item {
  border: 1px solid #e5e7eb;
  padding: 12px 16px;
  border-radius: 8px;
  transition: border-color 0.2s ease;
  margin-bottom: 16px;
}

/* 子元素聚焦时,父级容器样式变化 */
.form-item:focus-within {
  border-color: #3b82f6;
  box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
}

/* 输入框样式,去除默认聚焦轮廓 */
.form-item input {
  border: none;
  outline: none;
  width: 100%;
  font-size: 14px;
}

html

预览

<div class="form-item">
  <input type="text" placeholder="请输入用户名" />
</div>
<div class="form-item">
  <input type="password" placeholder="请输入密码" />
</div>

老兵关键提醒

  1. 兼容性全平台完美支持,IE 除外(如今前端基本放弃 IE),无需降级。
  2. 扩展场景:不仅适用于输入框,还适用于下拉框、按钮、富文本编辑器等所有可聚焦元素,复杂表单、搜索框、登录页都能通用。
  3. 维护优势:纯 CSS 样式与行为分离,后期修改样式无需改动 JS,大幅降低维护成本,这是工程化开发的核心原则。

三、网络状态监听:navigator.onLine,PWA 离线体验原生实现

早年做 PWA(渐进式 Web 应用)时,离线状态处理是一大难题。为了监听用户断网、联网,很多团队引入第三方网络检测库,或手写轮询请求接口判断网络状态,不仅增加包体积,还会产生无效请求,耗电、耗流量,检测精度还低。

其实浏览器原生提供了navigator.onLine属性,配合online/offline事件,就能精准监听网络状态变化,无需任何第三方依赖,轻量、精准、高效。

14 年经验实战用法

javascript

运行

// 初始网络状态判断
const initNetworkStatus = () => {
  if (!navigator.onLine) {
    showOfflineTip();
    // 离线数据缓存(IndexedDB/localStorage)
    cacheOfflineData();
  }
};

// 显示离线提示
function showOfflineTip() {
  const tip = document.createElement('div');
  tip.className = 'offline-tip';
  tip.textContent = '网络连接断开,请检查网络设置';
  document.body.appendChild(tip);
  setTimeout(() => tip.remove(), 3000);
}

// 监听离线事件
window.addEventListener('offline', () => {
  showOfflineTip();
  // 离线逻辑:暂停请求、缓存用户输入
  pauseAsyncRequest();
});

// 监听联网事件
window.addEventListener('online', () => {
  const tip = document.createElement('div');
  tip.className = 'online-tip';
  tip.textContent = '网络已恢复,正在同步数据';
  document.body.appendChild(tip);
  setTimeout(() => tip.remove(), 3000);
  // 联网逻辑:重新请求、同步离线缓存数据
  syncOfflineData();
});

// 初始化
initNetworkStatus();

老兵关键提醒

  1. 核心误区navigator.onLinetrue≠后端服务可用,仅代表设备有网络连接,需结合接口异常处理(try/catch、axios 拦截器)使用。
  2. 实战场景:PWA 应用、表单离线编辑、弱网环境优化、数据自动同步,都是高频使用场景。
  3. 兼容性:所有现代浏览器全覆盖,移动端、桌面端均稳定支持,是 PWA 开发必备原生 API。

四、流畅动画实现:requestAnimationFrame,告别 setInterval 卡顿

早年做前端动画,几乎都用setInterval固定时间间隔修改 DOM 样式,比如setInterval(() => { el.style.left = x + 'px' }, 16),看似模拟 60fps 帧率,实则问题极大:setInterval 与浏览器渲染周期不同步,容易出现丢帧、卡顿、闪烁,尤其在页面繁忙时,动画效果惨不忍睹。

requestAnimationFrame是浏览器专为动画设计的原生 API,与浏览器重绘周期完全同步,浏览器会在每次重绘前执行回调,确保动画流畅,且页面隐藏时自动暂停,节省性能。这是我 14 年开发中,优化动画性能的首选方案。

14 年经验实战用法

javascript

运行

// 获取动画元素
const box = document.querySelector('.animate-box');
let offset = 0;

// 动画执行函数
function animateBox(timestamp) {
  // 计算位移,使用transform替代left,避免重排
  offset = (offset + 2) % 300;
  box.style.transform = `translateX(${offset}px)`;
  // 循环执行动画
  requestAnimationFrame(animateBox);
}

// 启动动画
requestAnimationFrame(animateBox);

css

.animate-box {
  width: 50px;
  height: 50px;
  background: #3b82f6;
  border-radius: 8px;
  /* 开启硬件加速 */
  will-change: transform;
}

老兵关键提醒

  1. 性能核心必须配合 transform/opacity 使用,这两个属性不会触发浏览器重排,动画性能极致优化。
  2. 优势:页面隐藏时自动暂停,减少 CPU / 内存消耗;无需计算时间间隔,浏览器自动适配帧率。
  3. 兼容性全浏览器支持,从 IE10 到现代浏览器,无任何兼容问题,是前端动画标准方案。

五、组件自适应:容器查询(Container Queries),终结视口媒体查询局限

早年做响应式开发,只能用@media媒体查询,基于整个视口宽度调整样式。但实际开发中,我们常需要基于组件自身容器宽度调整样式 —— 比如卡片组件在侧边栏窄容器、首页宽容器中展示不同布局,媒体查询完全无法实现,只能手写 JS 监听容器尺寸,或写多套样式强行适配,代码冗余、维护困难。

如今 CSS 容器查询彻底解决这个问题,让组件真正实现自适应,不依赖视口,只看自身容器,是组件化开发的革命性特性。作为常年开发组件库的老兵,我认为这是 CSS 近几年最实用的更新。

14 年经验实战用法

css

/* 定义容器:开启行内尺寸查询 */
.card-container {
  container-type: inline-size;
  container-name: card;
}

/* 卡片基础样式 */
.card {
  padding: 16px;
  border: 1px solid #e5e7eb;
  border-radius: 8px;
  display: flex;
  flex-direction: column;
  gap: 12px;
}

/* 容器宽度≥400px时,修改卡片布局 */
@container card (min-width: 400px) {
  .card {
    flex-direction: row;
    align-items: center;
  }
}

/* 容器宽度≥600px时,进一步优化 */
@container card (min-width: 600px) {
  .card {
    padding: 24px;
    gap: 20px;
  }
}

html

预览

<!-- 窄容器:卡片垂直布局 -->
<div class="card-container" style="width: 300px;">
  <div class="card">
    <img src="cover.jpg" alt="封面" />
    <div class="card-content">内容</div>
  </div>
</div>

<!-- 宽容器:卡片水平布局 -->
<div class="card-container" style="width: 500px;">
  <div class="card">
    <img src="cover.jpg" alt="封面" />
    <div class="card-content">内容</div>
  </div>
</div>

老兵关键提醒

  1. 兼容性:现代浏览器(Chrome 105+、Firefox 110+、Safari 16+)全覆盖,旧版浏览器可通过降级样式适配。
  2. 组件化价值:让组件真正可移植、自包含,不依赖页面环境,是设计系统、组件库开发必备特性。
  3. 最佳实践:优先使用inline-size(行内尺寸),适配水平响应式场景,这是最常用的配置。

六、安全随机 ID:crypto.getRandomValues,远离 Math.random 冲突风险

早年开发中,生成临时 ID、会话标识、订单后缀,几乎都用Math.random().toString(36).slice(2)这种简易方式。但Math.random伪随机数,熵值低,存在重复风险,尤其在高并发、大批量生成 ID 时,冲突概率极高,线上曾出现过用户 ID 重复、购物车数据错乱的严重 bug。

浏览器原生crypto.getRandomValues提供加密级安全随机数,熵值高、无规律、重复概率极低,是生成安全随机 ID 的标准方案,比Math.random可靠百倍。

14 年经验实战用法

javascript

运行

/**
 * 生成安全随机ID
 * @param {number} length 字节长度,默认8字节
 * @returns {string} 十六进制随机字符串
 */
function generateSecureId(length = 8) {
  // 创建无符号字节数组
  const bytes = new Uint8Array(length);
  // 获取加密级安全随机数
  crypto.getRandomValues(bytes);
  // 转换为十六进制字符串
  return Array.from(bytes)
    .map(byte => byte.toString(16).padStart(2, '0'))
    .join('');
}

// 生成用户临时ID
const tempUserId = generateSecureId();
console.log('安全临时ID:', tempUserId);

// 生成会话标识
const sessionId = generateSecureId(16);
console.log('安全会话ID:', sessionId);

老兵关键提醒

  1. 进阶方案:若需要标准 UUID,直接用crypto.randomUUID(),一行代码生成 UUID v4,兼容性极佳,是现代开发首选。
  2. 适用场景:用户临时 ID、会话标识、订单号、缓存键、加密盐值等禁止重复的场景。
  3. 兼容性:所有现代浏览器全覆盖,移动端、桌面端、WebWorker 中均稳定支持。

七、原生模态框:标签,干掉第三方模态框库冗余依赖

早年开发模态框(弹窗),必须引入第三方库(如 Bootstrap Modal、Element UI Dialog),或手写 JS 实现:遮罩层、显示隐藏、焦点管理、点击遮罩关闭、ESC 关闭、无障碍支持…… 代码量巨大,还容易出现焦点错乱、遮罩层穿透、移动端适配问题。

HTML5 原生<dialog>标签彻底解决这个问题,自带遮罩、焦点管理、无障碍支持,几行代码就能实现标准模态框,无需任何第三方依赖,体积轻量、功能完善。

14 年经验实战用法

html

预览

<!-- 原生模态框 -->
<dialog id="confirm-dialog">
  <div class="dialog-content">
    <h3>确认操作</h3>
    <p>确定要提交表单吗?</p>
    <div class="dialog-footer">
      <button onclick="document.getElementById('confirm-dialog').close()">取消</button>
      <button onclick="handleSubmit()">确认提交</button>
    </div>
  </div>
</dialog>

<!-- 触发按钮 -->
<button onclick="document.getElementById('confirm-dialog').showModal()">打开确认弹窗</button>

css

/* 模态框基础样式 */
#confirm-dialog {
  border: none;
  border-radius: 8px;
  padding: 24px;
  width: 90%;
  max-width: 400px;
  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}

/* 遮罩层样式 */
#confirm-dialog::backdrop {
  background: rgba(0, 0, 0, 0.5);
}

.dialog-footer {
  display: flex;
  justify-content: flex-end;
  gap: 12px;
  margin-top: 20px;
}

老兵关键提醒

  1. 核心方法showModal()打开模态框(带遮罩)、close()关闭、returnValue获取返回值,完全满足日常需求。
  2. 无障碍优势:原生支持焦点管理、屏幕阅读器朗读,符合 WCAG 无障碍标准,这是手写模态框很难实现的。
  3. 兼容性:现代浏览器全覆盖,Safari 15.4 + 支持,旧版可通过简单 polyfill 兼容。

八、语音输入:Web Speech API,无需 AI 库实现语音识别

现在很多产品需要语音输入功能,很多团队第一反应是引入transformers.js、百度语音 SDK 等第三方库,增加包体积、依赖外部服务、配置复杂。其实Chromium 内核浏览器(Chrome/Edge)原生支持语音识别 API,简单几行代码就能实现语音转文字,适合内部系统、演示项目、轻量语音场景。

14 年经验实战用法

javascript

运行

// 兼容webkit前缀
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;

if (SpeechRecognition) {
  // 创建语音识别实例
  const recognition = new SpeechRecognition();
  // 设置语言
  recognition.lang = 'zh-CN';
  // 连续识别
  recognition.continuous = false;
  // 临时结果返回
  recognition.interimResults = false;

  // 识别成功回调
  recognition.onresult = (e) => {
    const text = e.results[0][0].transcript;
    console.log('识别结果:', text);
    // 填充到输入框
    document.getElementById('voice-input').value = text;
  };

  // 识别错误回调
  recognition.onerror = (e) => {
    console.error('语音识别错误:', e.error);
    alert('语音识别失败,请重试');
  };

  // 绑定按钮事件
  window.startVoiceInput = () => {
    recognition.start();
  };
} else {
  alert('当前浏览器不支持语音输入,请使用Chrome/Edge浏览器');
}

html

预览

<input type="text" id="voice-input" placeholder="点击按钮语音输入" />
<button onclick="startVoiceInput()">🎤 语音输入</button>

老兵关键提醒

  1. 兼容性:仅 Chromium 内核浏览器支持,Safari/Firefox 暂不支持,生产环境需做好降级提示。
  2. 适用场景:内部管理系统、演示项目、轻量表单输入,不适合强依赖语音功能的核心业务。
  3. 优势零依赖、零成本、无需服务端,纯前端实现,快速满足轻量需求。

九、CSS 特性检测:@supports,优雅适配新特性,避免样式崩溃

前端开发中,我们经常使用 CSS 新特性(如backdrop-filtercontainer-typegap),但旧版浏览器不支持,会导致样式错乱、页面崩溃。早年只能通过 JS 检测浏览器版本,动态添加样式,逻辑复杂、维护困难。

CSS @supports规则完美解决这个问题,纯 CSS 检测浏览器是否支持指定特性,支持则应用新样式,不支持则回退到基础样式,优雅适配新旧浏览器,这是我做跨端兼容的必备技巧。

14 年经验实战用法

css

/* 基础样式,所有浏览器都支持 */
.glass-card {
  background: #ffffff;
  padding: 24px;
  border-radius: 12px;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}

/* 检测支持backdrop-filter时,应用毛玻璃效果 */
@supports (backdrop-filter: blur(10px)) {
  .glass-card {
    background: rgba(255, 255, 255, 0.6);
    backdrop-filter: blur(10px);
    -webkit-backdrop-filter: blur(10px);
    border: 1px solid rgba(255, 255, 255, 0.2);
  }
}

/* 组合条件检测 */
@supports (display: grid) and (container-type: inline-size) {
  .responsive-card {
    display: grid;
    gap: 16px;
  }
}

老兵关键提醒

  1. 语法灵活:支持单特性检测、组合检测(and/or/not),覆盖几乎所有适配场景。
  2. 兼容性:现代浏览器全覆盖,IE 不支持,但 IE 会直接忽略@supports规则,应用基础样式,无兼容性风险。
  3. 实战价值:使用 CSS 新特性时,必须配合 @supports,确保旧版浏览器样式不崩溃,这是跨端兼容的标准实践。

十、14 年前端老兵的核心感悟:别让过度工程化,掩盖原生的力量

写完这 9 个场景,我想分享 14 年开发的核心感悟:前端开发的本质,是用最少的成本、最优的性能,解决用户需求,而不是盲目堆砌技术、引入依赖、手写冗余代码。

浏览器经过数十年迭代,早已不是当年的 “简陋画布”,而是一座蕴藏无数原生能力的宝藏库。我们过度工程化的根源,往往是对原生 API/CSS 特性不熟悉,习惯用旧经验解决新问题,忽略了平台本身的能力。

老兵给前端开发者的 3 条建议

  1. 定期盘点原生能力:每年花时间学习浏览器新特性、新 API,很多第三方库的功能,原生早已实现。
  2. 引入依赖前先问自己:这个功能,浏览器原生能实现吗?能,就优先用原生,减少依赖、降低风险。
  3. 回归本质,拒绝炫技:好的代码不是越复杂越好,而是简单、稳定、易维护,原生方案永远是首选。

库和框架是工具,不是必需品。当你真正吃透浏览器原生能力,会发现:很多你曾经头疼的问题,浏览器早已帮你完美解决。放下过度工程化的执念,用好原生这座宝藏,你的前端开发之路会更轻松、更高效。

用wagmi v2 + viem重构DeFi前端:从连接钱包到读取合约数据的完整踩坑实录

背景

上个月,我接手了一个DeFi收益聚合器项目的前端重构任务。这个项目最初是用ethers.js 5.xweb3-react构建的,代码已经运行了两年多。随着项目发展,老架构的问题逐渐暴露:钱包连接逻辑分散在各个组件、多链支持维护困难、类型定义几乎为零。

团队决定迁移到更现代的wagmi v2 + viem技术栈。wagmi的Hooks式API看起来简洁优雅,viem的类型安全也很有吸引力。我本以为这是个“升级依赖”的简单任务,但实际动手才发现,从老模式切换到新范式,中间有太多细节需要重新理解。最大的挑战不是写新代码,而是让新老逻辑在数据流和状态管理上保持一致。

问题分析

我最初的计划很直接:安装wagmiviem@tanstack/react-query(wagmi v2的依赖),然后逐步替换组件中的useWeb3Reactethers调用。

第一个拦路虎很快就出现了:钱包连接状态频繁丢失。

在旧版中,用户连接钱包后,accountchainId等信息通过React Context全局可用。但在新版本中,我按照官方示例配置了WagmiProvider后,发现useAccount()返回的address时不时会变成undefined,即使MetaMask明明还连接着。

我排查的方向:

  1. 检查Provider配置:确认了config对象正确传递给了WagmiProvider
  2. 检查连接器顺序:按照文档把injected连接器放在第一位
  3. 检查React Query配置:确认了缓存时间设置

后来通过仔细阅读wagmi的源码和issue,才发现问题核心:wagmi v2默认的行为更“谨慎”了。它不会永久保持连接状态,而是需要应用层明确处理连接持久化。同时,@tanstack/react-query的缓存行为也会影响状态同步。

另一个头疼的问题是多链切换。旧版中我们手动处理链切换逻辑,但wagmi提供了useSwitchChain这样的高级Hook。当我尝试切换到Polygon链时,控制台没有报错,但交易始终在以太坊主网发送。这里涉及到viem的Transport配置和wagmi的chain配置对齐问题。

核心实现

1. 正确配置Wagmi Provider与连接持久化

经过调试,我找到了wagmi v2连接状态不稳定的主要原因:缺少状态持久化和正确的存储配置。下面是最终的配置方案:

// src/providers/WagmiProvider.tsx
import { createConfig, http, WagmiProvider as WagmiProviderCore } from 'wagmi'
import { mainnet, polygon, arbitrum } from 'wagmi/chains'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { injected } from 'wagmi/connectors'
import { ReactNode } from 'react'

// 创建QueryClient实例,这是wagmi v2的强制依赖
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      // 这里有个坑:缓存时间不能太短,否则频繁重连
      gcTime: 1000 * 60 * 60 * 24, // 24小时
      staleTime: 1000 * 60 * 5, // 5分钟
      retry: 1
    }
  }
})

// 配置支持的链
const supportedChains = [mainnet, polygon, arbitrum]

// 创建wagmi配置
const config = createConfig({
  chains: supportedChains,
  transports: {
    // 这里必须为每个链配置transport,否则会报错
    [mainnet.id]: http(),
    [polygon.id]: http('https://polygon-rpc.com'),
    [arbitrum.id]: http('https://arb1.arbitrum.io/rpc'),
  },
  connectors: [
    injected(),
    // 可以添加其他连接器如walletConnect
  ],
  // 关键配置:启用状态存储
  ssr: false, // 如果不是SSR应用,设为false
})

export function WagmiProvider({ children }: { children: ReactNode }) {
  return (
    <QueryClientProvider client={queryClient}>
      <WagmiProviderCore config={config}>
        {children}
      </WagmiProviderCore>
    </QueryClientProvider>
  )
}

关键点

  • transports配置必须为每个链提供RPC端点,否则跨链操作会失败
  • gcTime(原cacheTime)设置足够长,避免频繁重连
  • 通过ssr: false明确禁用SSR,避免hydration问题

2. 实现稳健的钱包连接与状态管理

连接钱包的UI组件需要处理更多边缘情况。我创建了一个WalletConnector组件:

// src/components/WalletConnector.tsx
import { useAccount, useConnect, useDisconnect, useChainId } from 'wagmi'
import { useEffect, useState } from 'react'

export function WalletConnector() {
  const { address, isConnected, isConnecting } = useAccount()
  const { connect, connectors, error: connectError } = useConnect()
  const { disconnect } = useDisconnect()
  const chainId = useChainId()
  
  const [mounted, setMounted] = useState(false)
  
  // 解决hydration不匹配问题
  useEffect(() => {
    setMounted(true)
  }, [])
  
  if (!mounted) {
    return <div>Loading...</div>
  }
  
  if (!isConnected) {
    return (
      <div>
        <h3>Connect Wallet</h3>
        {connectors.map((connector) => (
          <button
            key={connector.uid}
            onClick={() => connect({ connector })}
            disabled={isConnecting}
          >
            {connector.name}
            {isConnecting && ' Connecting...'}
          </button>
        ))}
        {connectError && (
          <div style={{ color: 'red' }}>
            Error: {connectError.message}
          </div>
        )}
      </div>
    )
  }
  
  return (
    <div>
      <p>Connected: {address?.slice(0, 6)}...{address?.slice(-4)}</p>
      <p>Chain ID: {chainId}</p>
      <button onClick={() => disconnect()}>
        Disconnect
      </button>
    </div>
  )
}

注意这个细节mounted状态是为了解决Next.js等SSR框架下的hydration警告。wagmi的状态在服务端和客户端可能不一致。

3. 多链切换与网络状态监听

DeFi应用经常需要跨链操作。我实现了一个链切换组件,并添加了网络状态监听:

// src/components/ChainSwitcher.tsx
import { useSwitchChain, useAccount } from 'wagmi'
import { mainnet, polygon, arbitrum } from 'wagmi/chains'

const chainConfigs = {
  [mainnet.id]: { name: 'Ethereum', color: '#627EEA' },
  [polygon.id]: { name: 'Polygon', color: '#8247E5' },
  [arbitrum.id]: { name: 'Arbitrum', color: '#28A0F0' },
}

export function ChainSwitcher() {
  const { chainId } = useAccount()
  const { switchChain, isPending, error } = useSwitchChain()
  
  // 监听网络切换
  useEffect(() => {
    if (typeof window !== 'undefined' && window.ethereum) {
      const handleChainChanged = (newChainId: string) => {
        // MetaMask会重新加载页面,但其他钱包可能不会
        console.log('Chain changed to:', newChainId)
      }
      
      window.ethereum.on('chainChanged', handleChainChanged)
      
      return () => {
        window.ethereum.removeListener('chainChanged', handleChainChanged)
      }
    }
  }, [])
  
  return (
    <div>
      <p>Current chain: {chainId ? chainConfigs[chainId]?.name : 'Unknown'}</p>
      <div style={{ display: 'flex', gap: '8px' }}>
        {Object.keys(chainConfigs).map((id) => (
          <button
            key={id}
            onClick={() => switchChain({ chainId: Number(id) })}
            disabled={isPending || chainId === Number(id)}
            style={{
              backgroundColor: chainConfigs[Number(id)].color,
              color: 'white'
            }}
          >
            {chainConfigs[Number(id)].name}
            {isPending && ' Switching...'}
          </button>
        ))}
      </div>
      {error && (
        <div style={{ color: 'red', marginTop: '8px' }}>
          Switch failed: {error.message}
        </div>
      )}
    </div>
  )
}

这里有个坑switchChain可能因为钱包未添加目标链而失败。在生产环境中,需要添加useAddChain Hook来动态添加链配置。

4. 读取合约数据:从ethers.js到viem的迁移

这是最核心的部分。旧代码中读取ERC20余额是这样的:

// 旧代码 - ethers.js方式
const contract = new ethers.Contract(tokenAddress, ERC20_ABI, provider)
const balance = await contract.balanceOf(account)
const decimals = await contract.decimals()
const formattedBalance = ethers.utils.formatUnits(balance, decimals)

迁移到viem后,需要改用useReadContract Hook:

// src/hooks/useTokenBalance.ts
import { useReadContract, useAccount } from 'wagmi'
import { erc20Abi } from 'viem'

export function useTokenBalance(tokenAddress: `0x${string}`) {
  const { address, chainId } = useAccount()
  
  // 读取余额
  const { 
    data: balance, 
    isLoading, 
    error, 
    refetch 
  } = useReadContract({
    abi: erc20Abi,
    address: tokenAddress,
    functionName: 'balanceOf',
    args: address ? [address] : undefined,
    chainId, // 关键:指定链ID,确保读取正确的链上数据
    query: {
      enabled: !!address, // 只有连接钱包时才查询
      // 这里有个重要细节:refetchInterval
      refetchInterval: 10000, // 每10秒自动刷新
    }
  })
  
  // 读取代币小数位
  const { data: decimals } = useReadContract({
    abi: erc20Abi,
    address: tokenAddress,
    functionName: 'decimals',
    chainId,
    query: {
      enabled: !!address,
    }
  })
  
  // 格式化余额
  const formattedBalance = React.useMemo(() => {
    if (!balance || !decimals) return '0'
    // viem的格式化方式
    const divisor = 10n ** BigInt(decimals)
    const integerPart = balance / divisor
    const fractionalPart = balance % divisor
    return `${integerPart}.${fractionalPart.toString().padStart(decimals, '0')}`
  }, [balance, decimals])
  
  return {
    balance,
    formattedBalance,
    isLoading,
    error,
    refetch
  }
}

关键变化

  1. useReadContract自动处理缓存、重试和错误状态
  2. 必须指定chainId,否则可能读取到错误链的数据
  3. enabled选项控制查询时机,避免不必要的RPC调用
  4. viem使用bigint而不是ethers.BigNumber

5. 发送交易:处理用户确认和状态反馈

发送交易是DeFi应用的核心交互。我创建了一个发送ERC20转账的Hook:

// src/hooks/useTransferToken.ts
import { useWriteContract, useWaitForTransactionReceipt } from 'wagmi'
import { erc20Abi } from 'viem'
import { useState } from 'react'

export function useTransferToken() {
  const [isDialogOpen, setIsDialogOpen] = useState(false)
  
  const {
    writeContract,
    data: hash,
    error: writeError,
    isPending: isWriting,
    reset: resetWrite
  } = useWriteContract()
  
  // 等待交易确认
  const {
    isLoading: isConfirming,
    isSuccess: isConfirmed,
    error: confirmError
  } = useWaitForTransactionReceipt({
    hash,
    // 这里可以配置确认数
    confirmations: 1,
  })
  
  const transfer = async (
    tokenAddress: `0x${string}`,
    to: `0x${string}`,
    amount: bigint
  ) => {
    try {
      setIsDialogOpen(true)
      
      writeContract({
        abi: erc20Abi,
        address: tokenAddress,
        functionName: 'transfer',
        args: [to, amount],
      })
    } catch (error) {
      console.error('Transfer failed:', error)
      setIsDialogOpen(false)
    }
  }
  
  // 交易完成后重置状态
  React.useEffect(() => {
    if (isConfirmed || confirmError) {
      const timer = setTimeout(() => {
        setIsDialogOpen(false)
        resetWrite()
      }, 3000)
      
      return () => clearTimeout(timer)
    }
  }, [isConfirmed, confirmError, resetWrite])
  
  return {
    transfer,
    hash,
    isDialogOpen,
    isWriting,
    isConfirming,
    isConfirmed,
    error: writeError || confirmError
  }
}

用户体验优化:这个Hook管理了完整的交易生命周期——从用户点击、钱包确认、链上等待到最终状态反馈。useWaitForTransactionReceipt会自动轮询交易收据,无需手动实现。

完整代码示例

下面是一个整合了上述所有功能的简化版DeFi前端组件:

// src/App.tsx
import { WagmiProvider } from './providers/WagmiProvider'
import { WalletConnector } from './components/WalletConnector'
import { ChainSwitcher } from './components/ChainSwitcher'
import { useTokenBalance } from './hooks/useTokenBalance'
import { useTransferToken } from './hooks/useTransferToken'

// 示例代币地址(USDT on Ethereum)
const USDT_ADDRESS = '0xdAC17F958D2ee523a2206206994597C13D831ec7'

function DeFiApp() {
  const { address } = useAccount()
  const { formattedBalance, isLoading: isLoadingBalance } = 
    useTokenBalance(USDT_ADDRESS)
  const {
    transfer,
    isWriting,
    isConfirming,
    isConfirmed,
    error: transferError
  } = useTransferToken()
  
  const handleTransfer = () => {
    if (!address) return
    
    // 转账0.1 USDT(USDT有6位小数)
    const amount = 100000n // 0.1 USDT = 100000 wei
    const recipient = '0x742d35Cc6634C0532925a3b844Bc9e90F90a1497' // 示例地址
    
    transfer(USDT_ADDRESS, recipient, amount)
  }
  
  return (
    <div style={{ padding: '20px', maxWidth: '800px', margin: '0 auto' }}>
      <h1>DeFi Dashboard</h1>
      
      <WalletConnector />
      
      {address && (
        <>
          <ChainSwitcher />
          
          <div style={{ marginTop: '20px', padding: '15px', border: '1px solid #ccc' }}>
            <h3>USDT Balance</h3>
            {isLoadingBalance ? (
              <p>Loading balance...</p>
            ) : (
              <p>{formattedBalance} USDT</p>
            )}
            
            <button 
              onClick={handleTransfer}
              disabled={isWriting || isConfirming}
              style={{ marginTop: '10px' }}
            >
              {isWriting ? 'Confirm in Wallet...' : 
               isConfirming ? 'Waiting for confirmation...' : 
               'Transfer 0.1 USDT'}
            </button>
            
            {isConfirmed && (
              <p style={{ color: 'green' }}>Transfer successful!</p>
            )}
            
            {transferError && (
              <p style={{ color: 'red' }}>
                Transfer failed: {transferError.message}
              </p>
            )}
          </div>
        </>
      )}
    </div>
  )
}

// 应用入口
function App() {
  return (
    <WagmiProvider>
      <DeFiApp />
    </WagmiProvider>
  )
}

export default App

踩坑记录

在实际迁移过程中,我遇到了以下几个典型问题:

  1. "Invalid BigNumber value"错误

    • 现象:从ethers.js迁移时,传入useWriteContractargs包含ethers的BigNumber对象
    • 原因:viem只接受原生的JavaScript bigint类型
    • 解决:将所有ethers.BigNumber转换为bigintBigInt(balance.toString())
  2. 跨链读取返回错误数据

    • 现象:在Polygon链上却读到了以太坊主网的余额
    • 原因useReadContract没有指定chainId,使用了默认链
    • 解决:在所有合约读取Hook中显式传递当前chainId
  3. 钱包连接在页面刷新后丢失

    • 现象:用户刷新页面后需要重新连接钱包
    • 原因:wagmi默认配置没有启用连接持久化
    • 解决:正确配置QueryClient的缓存时间,并考虑使用'wagmi/connectors'中的createStorage进行localStorage持久化
  4. TypeScript类型错误:0x${string}

    • 现象:传递普通字符串地址时TypeScript报错
    • 原因:viem要求地址是0x开头的严格格式
    • 解决:使用类型断言或验证函数:address as 0x${string},或使用viem的isAddress工具函数

小结

这次从ethers.js + web3-react迁移到wagmi v2 + viem,最大的收获是理解了现代Web3前端的状态管理范式。wagmi将React Query的缓存策略与区块链状态同步结合,虽然初期配置复杂,但一旦理顺,代码会比老方案更简洁健壮。下一步可以探索wagmi的更多高级特性,如合约事件监听、批量查询优化,以及如何与状态管理库(如Zustand)深度集成。

Next.js第六课 - 数据获取

上节我们学习了服务端组件和客户端组件的区别,本节来深入了解 Next.js 中的数据获取。Next.js 提供了灵活且强大的数据获取方式,掌握好这些知识能让你构建出性能优异的应用。

数据获取概述

在 Next.js 中,有多种数据获取方式:

  1. 静态生成(SSG)- 构建时生成 HTML
  2. 服务器端渲染(SSR)- 每次请求时生成 HTML
  3. 增量静态再生成(ISR)- 定期重新生成静态页面
  4. 客户端数据获取 - 在浏览器中获取数据

选择哪种方式取决于你的数据特性:是否经常变化、是否需要 SEO、用户是否需要看到最新数据等。

服务端组件数据获取

在服务端组件中,你可以直接使用 fetch 或任何数据获取库,这相比传统的 React 方式要简单很多。

基本数据获取

最简单的方式就是直接在组件中使用 async/await:

// app/posts/page.tsx
async function getPosts() {
  const res = await fetch('https://api.example.com/posts')

  if (!res.ok) {
    throw new Error('获取文章失败')
  }

  return res.json()
}

export default async function PostsPage() {
  const posts = await getPosts()

  return (
    <div>
      <h1>博客文章</h1>
      <ul>
        {posts.map((post: any) => (
          <li key={post.id}>
            <h2>{post.title}</h2>
            <p>{post.excerpt}</p>
          </li>
        ))}
      </ul>
    </div>
  )
}

直接访问数据库

服务端组件可以直接访问数据库,不需要创建 API 层:

// app/users/page.tsx
import { db } from '@/lib/db'

export default async function UsersPage() {
  const users = await db.user.findMany({
    orderBy: { createdAt: 'desc' },
  })

  return (
    <div>
      <h1>用户列表</h1>
      {users.map((user) => (
        <div key={user.id}>{user.name}</div>
      ))}
    </div>
  )
}

错误处理

处理数据获取中的错误是很重要的:

// app/products/[id]/page.tsx
export default async function ProductPage({
  params,
}: {
  params: { id: string }
}) {
  let product

  try {
    const res = await fetch(`https://api.example.com/products/${params.id}`)

    if (!res.ok) {
      if (res.status === 404) {
        notFound()
      }
      throw new Error('获取产品失败')
    }

    product = await res.json()
  } catch (error) {
    return <div>加载产品时出错</div>
  }

  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
    </div>
  )
}

缓存和重新验证

Next.js 的缓存系统非常强大,理解它能让你的应用性能提升很多。

默认缓存行为

Next.js 默认会缓存 fetch 请求,这意味着相同的数据请求会被缓存起来,避免重复获取:

// 默认:自动缓存
export default async function Page() {
  const res = await fetch('https://api.example.com/data')
  const data = await res.json()

  return <div>{/* ... */}</div>
}

禁用缓存

如果数据需要实时更新,可以禁用缓存:

// 不缓存:每次都获取新数据
export default async function Page() {
  const res = await fetch('https://api.example.com/data', {
    cache: 'no-store',
  })
  const data = await res.json()

  return <div>{/* ... */}</div>
}

设置重新验证时间

最常用的方式是设置缓存时间:

// 缓存 10 秒后重新验证
export default async function Page() {
  const res = await fetch('https://api.example.com/data', {
    next: { revalidate: 10 },
  })
  const data = await res.json()

  return <div>{/* ... */}</div>
}

按标签重新验证

给数据加上标签,可以在数据更新时手动刷新缓存:

// 获取时添加标签
export default async function Page() {
  const res = await fetch('https://api.example.com/posts', {
    next: { tags: ['posts'] },
  })
  const posts = await res.json()

  return <div>{/* ... */}</div>
}

// 在 API 路由中手动重新验证
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache'

export async function POST(request: Request) {
  const tag = await request.json()
  revalidateTag(tag)
  return Response.json({ revalidated: true })
}

渲染策略

静态渲染(默认)

静态渲染意味着页面在构建时就生成好了 HTML:

// app/blog/page.tsx
// 构建时生成静态 HTML
export default async function BlogPage() {
  const posts = await getPosts()

  return (
    <div>
      {posts.map((post) => (
        <PostCard key={post.id} post={post} />
      ))}
    </div>
  )
}

动态渲染

每次请求都重新渲染:

// app/dashboard/page.tsx
// 每次请求都渲染
export const dynamic = 'force-dynamic'

export default async function DashboardPage() {
  const user = await getCurrentUser()

  return <div>欢迎,{user.name}</div>
}

增量静态再生成(ISR)

结合静态和动态的优点:

// app/products/page.tsx
// 每 60 秒重新生成页面
export const revalidate = 60

export default async function ProductsPage() {
  const products = await getProducts()

  return (
    <div>
      {products.map((product) => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  )
}

并行数据获取

当需要获取多个独立的数据源时,应该并行获取以提高性能:

// app/dashboard/page.tsx
export default async function DashboardPage() {
  // 并行获取数据
  const [user, posts, analytics] = await Promise.all([
    fetchUser(),
    fetchPosts(),
    fetchAnalytics(),
  ])

  return (
    <div>
      <UserProfile user={user} />
      <PostsList posts={posts} />
      <AnalyticsChart data={analytics} />
    </div>
  )
}

客户端数据获取

虽然服务端数据获取更推荐,但有时候也需要在客户端获取数据:

使用 useEffect

传统的方式:

'use client'

import { useState, useEffect } from 'react'

export default function ClientDataFetching() {
  const [data, setData] = useState(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)

  useEffect(() => {
    async function fetchData() {
      try {
        const res = await fetch('https://api.example.com/data')
        const json = await res.json()
        setData(json)
      } catch (err) {
        setError(err.message)
      } finally {
        setLoading(false)
      }
    }

    fetchData()
  }, [])

  if (loading) return <div>加载中...</div>
  if (error) return <div>错误: {error}</div>

  return <div>{/* 渲染数据 */}</div>
}

使用 SWR

SWR 是一个很流行的数据获取库:

'use client'

import useSWR from 'swr'

const fetcher = (url: string) => fetch(url).then((res) => res.json())

export default function Profile() {
  const { data, error, isLoading } = useSWR(
    'https://api.example.com/user',
    fetcher
  )

  if (error) return <div>加载失败</div>
  if (isLoading) return <div>加载中...</div>

  return <div>你好,{data.name}</div>
}

加载状态

Next.js 提供了优雅的加载状态处理方式:

loading.tsx 文件

创建 loading.tsx 文件会自动显示加载状态:

// app/posts/loading.tsx
export default function Loading() {
  return (
    <div className="animate-pulse">
      <div className="h-4 bg-gray-200 w-3/4 mb-4"></div>
      <div className="h-4 bg-gray-200 w-1/2 mb-4"></div>
      <div className="h-4 bg-gray-200 w-5/6"></div>
    </div>
  )
}

实用建议

这里分享几个在日常开发中特别实用的数据获取技巧。

优先使用服务端组件

实际开发中,我发现服务端数据获取不仅性能更好,代码也更简洁:

// 推荐这样做 - 服务端组件数据获取
export default async function Page() {
  const data = await fetchData()
  return <div>{data.title}</div>
}

// 除非有特殊需求,否则避免客户端数据获取
'use client'
export default function Page() {
  const [data, setData] = useState(null)
  useEffect(() => {
    fetchData().then(setData)
  }, [])
  return <div>{data?.title}</div>
}

并行获取数据

这个小技巧特别有用——如果多个数据源是独立的,应该并行获取来提升性能:

// 推荐这样做 - 并行获取,速度更快
const [user, posts] = await Promise.all([
  fetchUser(),
  fetchPosts(),
])

// 避免这种情况 - 串行获取会拖慢速度
const user = await fetchUser()
const posts = await fetchPosts() // 要等 user 完成才开始

使用适当的缓存策略

根据数据特性选择缓存策略,这个在实际项目中特别重要:

// 静态内容可以长时间缓存
const posts = await fetch('https://api.com/posts', {
  next: { revalidate: 3600 },
})

// 实时数据建议不缓存
const stockPrices = await fetch('https://api.com/stocks', {
  cache: 'no-store',
})

总结

本节我们详细学习了 Next.js 的数据获取机制,包括服务端和客户端获取、缓存策略、渲染策略等。掌握好这些知识,你就能根据不同的场景选择最合适的数据获取方式,构建出高性能的应用。

如果你对本节内容有任何疑问,欢迎在评论区提出来,我们一起学习讨论。

原文地址: blog.uuhb.cn/archives/ne…

Claude Code 提示词缓存与系统提示词分段架构

摘要

本文基于 Claude Code 源码,分析其系统提示词的构建流程、分段策略与提示词缓存机制。重点考察 systemPromptSections 注册表、SYSTEM_PROMPT_DYNAMIC_BOUNDARY 静动态分界标记、splitSysPromptPrefix 三模式缓存切片算法,以及 CacheScope 两级缓存域的设计逻辑。同时讨论 MCP 工具接入对缓存策略的约束,以及消息级缓存断点的放置规则。


一、背景:提示词缓存的工程价值

大语言模型的推理成本在很大程度上由输入 token 决定。对于 Claude Code 这类交互式编程助手,每轮对话均携带完整的系统提示词,其长度通常在数千 token 以上。若每次请求都将系统提示词从头计算,成本极为可观。

Anthropic 提供了提示词缓存(Prompt Caching)能力,允许客户端在请求体中标记特定文本块为可缓存内容。服务端在满足条件时复用已有的 KV Cache,从而跳过对该段 token 的 prefill 计算。这一机制对系统提示词尤为有效——系统提示词跨会话高度稳定,是天然的缓存候选。

然而,"系统提示词"并非一个均质的整体。其内部既包含每次启动后不再变化的静态描述(工具说明、行为规范、环境信息),也包含随每轮请求动态更新的内容(当前会话特征、MCP 服务状态、功能开关读取结果)。若将两者混同处理,静态部分的缓存命中率将因动态部分的频繁变化而显著下降。

Claude Code 为此设计了一套分段架构,将系统提示词在构建阶段拆解为具有明确缓存语义的独立块,再按照不同的缓存域(globalorg)打上标注,最终映射到 Anthropic API 的 cache_control 字段。本文将逐层拆解这一过程。


二、Section 注册表:静态性的声明式标注

系统提示词的内容管理入口位于 constants/systemPromptSections.ts。该文件定义了一个轻量的 Section 注册机制,其核心数据类型如下:

type SystemPromptSection = {
  name: string
  compute: () => string | null | Promise<string | null>
  cacheBreak: boolean
}

字段语义清晰:name 用于调试追踪,compute 是实际的内容计算函数,cacheBreak 则是本文关注的关键字段——它标记该 section 是否具有跨轮次变化的语义,即是否应当成为缓存断点。

对应地,注册表提供两个构造函数:

export function systemPromptSection(name, compute): SystemPromptSection
// cacheBreak = false,内容在 session 内计算一次后被缓存

export function DANGEROUS_uncachedSystemPromptSection(name, compute, _reason): SystemPromptSection
// cacheBreak = true,每轮重新计算,名称前缀 DANGEROUS_ 是对调用者的显式警告

前者的实现依赖一个 session 级的 Map 做惰性缓存——首次调用时执行 compute(),后续调用直接返回缓存结果。后者则每次都调用 compute(),不做任何记忆。

DANGEROUS_uncachedSystemPromptSection 目前在代码中仅有一处实际应用:

DANGEROUS_uncachedSystemPromptSection(
  'mcp_instructions',
  () => getMcpInstructionsText(),
  'MCP servers connect/disconnect between turns'
)

注释明确说明了原因:MCP 服务器可能在任意两轮之间连接或断开,其生成的工具指令内容随时可能改变。若对其做 session 级缓存,将导致系统提示词与实际可用工具集不一致。_reason 参数不参与运行时逻辑,仅作为强制要求调用者说明理由的文档约束。

resolveSystemPromptSections 函数负责批量执行一组 section 的 compute,返回 (string | null)[],null 值表示该 section 在当前上下文下不适用(例如某些仅在特定模式下启用的功能模块)。


三、SystemPrompt 类型与构建流程

系统提示词在类型系统层面被定义为品牌化字符串数组:

// utils/systemPromptType.ts
type SystemPrompt = readonly string[] & { __brand: 'SystemPrompt' }

使用 TypeScript 的品牌类型(Branded Type)而非 string[] 的原因有两点:其一,防止将普通字符串数组误传至需要构建好的系统提示词的 API;其二,强调该数组的语义不是任意字符串序列,而是一个有序的提示词段列表,其内部顺序具有语义意义。

buildEffectiveSystemPrompt(位于 utils/systemPrompt.ts)负责确定最终使用哪个系统提示词序列。其逻辑遵循一条优先链:

  1. 若存在显式 override,直接使用 override 内容
  2. 若当前为协调者(coordinator)模式,使用协调者专用提示词
  3. 若当前为子 Agent 模式,根据配置决定替换还是追加
  4. 否则使用默认的 getSystemPrompt() 构建结果

无论走哪条路径,appendSystemPrompt 均在最后追加,这是用户自定义系统提示词的注入点。

getSystemPrompt() 是系统提示词的主体构建函数,位于 constants/prompts.ts。其结构决定了后续缓存分段的依据。


四、动态边界标记:SYSTEM_PROMPT_DYNAMIC_BOUNDARY

getSystemPrompt() 的返回值是一个 string[],其中大部分元素是通过 resolveSystemPromptSections 展平后的文本块。关键在于其中插入了一个特殊标记:

const SYSTEM_PROMPT_DYNAMIC_BOUNDARY = '__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__'

该常量以双下划线包裹,在视觉上即可区分于普通文本内容。其在 getSystemPrompt() 中的插入逻辑如下:

return [
  ...staticSections,
  ...(shouldUseGlobalCacheScope() ? [SYSTEM_PROMPT_DYNAMIC_BOUNDARY] : []),
  ...dynamicSections,
]

边界标记只在 shouldUseGlobalCacheScope() 返回 true 时才被插入——这是因为该标记的语义依赖于全局缓存能力的存在。若当前 API 提供商不支持全局缓存,插入边界标记没有意义(后续的 splitSysPromptPrefix 也会因此走不同的分支)。

边界标记的作用是在字符串数组层面划定一条逻辑分界线:标记之前的内容被认定为静态内容(跨会话稳定,适合全局缓存),标记之后的内容被认定为动态内容(会话特定,不应跨组织共享缓存)。

代码注释中对"什么内容应当置于边界之后"有明确记录。以 getSessionSpecificGuidanceSection 为例,其注释写道该 section 被置于动态边界之后,原因是它需要读取以下运行时状态:

  • isForkSubagentEnabled() — 功能开关,从 GrowthBook 读取
  • getIsNonInteractiveSession() — 当前会话是否为非交互模式
  • 其他特性标志的当前值

这些值在不同会话、不同用户、不同时间点均可能不同,因而不可被全局缓存。


五、splitSysPromptPrefix:三模式缓存切片算法

splitSysPromptPrefix 是整个缓存架构的核心函数,位于 utils/api.ts。它接收由 getSystemPrompt() 产生的 string[],输出一个 SystemPromptBlock[]

type CacheScope = 'global' | 'org'
type SystemPromptBlock = { text: string; cacheScope: CacheScope | null }

cacheScope 的取值含义:

  • 'global':可跨组织缓存,适用于完全不含用户/组织特定信息的内容
  • 'org':仅在同一组织内缓存,适用于可能含有组织配置、用户偏好的内容
  • null:不参与缓存,内容每次均需全量计算

函数内部根据两个条件决定走哪个分支:

条件一:是否存在 MCP 工具(hasMcpTools条件二:是否启用全局缓存且提示词中包含边界标记(useGlobalCacheFeature && hasBoundary

三个分支的处理逻辑如下:

模式一:MCP 工具存在

attribution block   cacheScope: null
prefix blocks       cacheScope: 'org'
remaining blocks    cacheScope: 'org'

当 MCP 工具可用时,系统提示词中包含了由 MCP 服务器贡献的工具描述。MCP 工具的描述内容是每个用户、每个服务器连接独有的,带有明确的用户身份语义,不可被不同组织的用户共享。因此,所有块统一降级为 'org' 级别,完全放弃全局缓存。attribution 块(包含模型署名信息,undercover 场景下需要抹除)始终为 null,这一规则在三个模式中保持一致。

模式二:全局缓存启用且边界标记存在

attribution block    cacheScope: null
prefix blocks        cacheScope: null        (attribution 之后、boundary 之前)
static blocks        cacheScope: 'global'    (boundary 之后、动态内容之前)
dynamic blocks       cacheScope: null        (动态内容,不缓存)

这是三个模式中最精细的一个。函数在此模式下扫描 string[],找到 SYSTEM_PROMPT_DYNAMIC_BOUNDARY 标记的位置,以此为界将内容分为两段。边界之前的静态内容被标记为 'global',可以跨组织共享缓存;边界之后的动态内容标记为 null,不参与缓存。

一个值得关注的细节是 prefix 块(attribution 之后、boundary 之前的早期块)被标记为 null 而非 global。这部分内容包括一些早期的初始化文本,其全局缓存适用性的判断较为保守,因此未被纳入 global 范围。

模式三:默认模式

attribution block   cacheScope: null
prefix blocks       cacheScope: 'org'
remaining blocks    cacheScope: 'org'

当全局缓存不可用且无 MCP 工具时,退回到最朴素的策略:所有内容使用 'org' 级别缓存,即在同一组织内复用。


六、shouldUseGlobalCacheScope:全局缓存的启用条件

全局缓存并非对所有 API 提供商开放。shouldUseGlobalCacheScope() 的实现位于 utils/betas.ts

export function shouldUseGlobalCacheScope(): boolean {
  return getAPIProvider() === 'firstParty' &&
    !isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS)
}

函数返回 true 需要同时满足两个条件:

  1. API 提供商为 firstParty,即直接使用 Anthropic 官方 API,而非通过 Amazon Bedrock、Google Vertex AI 或 Anthropic Foundry 接入
  2. 未通过环境变量 CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS 禁用实验性 Beta 功能

全局缓存能力(cache_control 中的 scope: 'global')依赖一个 Beta API Header:

const PROMPT_CACHING_SCOPE_BETA_HEADER = 'prompt-caching-2025-04-11'

此 Header 在启用全局缓存时被添加到请求的 betas 数组中。Bedrock、Vertex 等第三方部署路径目前不支持该 Beta 特性,因此相应的代码分支被直接跳过。


七、getCacheControl 与 TTL 策略

确定了每个块的 cacheScope 之后,buildSystemPromptBlocks 函数(位于 services/api/claude.ts)将其转换为实际的 TextBlockParam,并附加 cache_control 字段。getCacheControl 函数负责根据 scope 和查询来源生成具体的缓存控制对象:

function getCacheControl({ scope, querySource }): CacheControlEphemeralParam {
  return {
    type: 'ephemeral',
    ...(should1hCacheTTL(querySource) ? { ttl: '1h' } : {}),
    ...(scope === 'global' ? { scope: 'global' } : {}),
  }
}

标准 TTL 为 5 分钟(Anthropic API 默认值),1 小时 TTL 通过 should1hCacheTTL() 决定是否启用。该函数的判断条件包括:

  • 当前用户为 Anthropic 内部用户(ant 用户类型)
  • 或者,当前用户为付费订阅者且未处于超额状态(non-overaged subscriber)

should1hCacheTTL() 的结果在 bootstrap 阶段被锁定,保存在全局状态中,整个 session 内保持不变。这样做的目的是防止订阅状态在 session 中途发生变化时导致缓存 TTL 频繁切换,进而引发缓存失效。

GrowthBook 功能开关系统(A/B 测试框架)用于管理 1h TTL 的灰度开放。代码中对 GrowthBook allowlist 的匹配使用了尾部通配符(trailing-* matching),支持按前缀匹配用户标识,便于按组织或用户群体进行分级灰度。


八、消息级缓存断点

除系统提示词外,Claude Code 还在消息历史中设置缓存断点。规则较为简单:在每轮请求时,将 cache_control 放置在最后一条用户消息和最后一条助手消息各自的最后一个内容块上。

这一做法的逻辑依据是:Anthropic API 的提示词缓存以"最后一个带 cache_control 标记的 token 位置"为缓存边界。在消息历史的末尾打断点,意味着下一轮请求时,历史消息部分可以被命中缓存,只有新增的用户输入需要重新 prefill。

对于多模态内容(图片、文件附件等),缓存断点同样放置在最后一个块上,不区分内容类型。对于空内容块或仅含工具结果的消息,缓存断点的放置逻辑有额外的边界处理。


九、工具 Schema 缓存稳定性

系统提示词缓存的一个潜在破坏因素是工具 Schema 的变化。Claude Code 集成了大量工具(文件操作、Bash 执行、代码搜索等),每个工具都有对应的 JSON Schema 描述,这些描述作为独立的 tool 块随请求发送。

若工具 Schema 在每次请求时都重新生成,即便内容不变,序列化后的字符串可能因字段顺序、空白符等细微差异而产生不同的 token 序列,导致缓存 miss。

toolSchemaCache.ts 通过在 session 级别缓存工具 Schema 的序列化结果来规避这一问题。GrowthBook 功能开关的翻转(A/B 测试分组变化)可能导致某些工具的可用性发生变化,进而引发 Schema 集合的变化。工具 Schema 缓存对此做了特殊处理,确保在 session 内工具集稳定的前提下,Schema 的序列化结果保持确定性,从而维持缓存的持续命中。


十、MCP 工具与全局缓存的冲突

MCP(Model Context Protocol)工具接入是全局缓存策略中最重要的约束来源。分析其不兼容的根本原因,需要理解两个层面:

语义层面:MCP 工具由用户在本地配置的服务器提供,其工具描述、参数 Schema、行为规范均带有强烈的用户/组织特异性。将含有此类内容的提示词缓存在全局(跨组织)层面,理论上存在信息泄露风险——不同组织的用户可能通过缓存命中间接获知他人的 MCP 工具配置信息。

稳定性层面:MCP 服务器连接在会话内是动态的,工具列表随时可能增减。即使退而求其次使用 org 级缓存,MCP 工具的高变动性也使缓存命中率受限。DANGEROUS_uncachedSystemPromptSection 对 MCP 指令的处理(每轮重新计算)正是对这一特性的响应。

splitSysPromptPrefix 的实现中,MCP 工具存在时的处理逻辑(模式一)完全绕过了全局缓存路径,即便 shouldUseGlobalCacheScope() 返回 true,只要检测到 MCP 工具存在,就立即降级为 org 级别。代码中对此有一处额外的检查:

needsToolBasedCacheMarker = useGlobalCacheFeature && 
  filteredTools.some(t => t.isMcp && !willDefer(t))

willDefer 表示该 MCP 工具被推迟加载(defer),尚未实际可用。仅当存在已加载且非推迟的 MCP 工具时,才真正触发 global→org 的降级逻辑。


十一、架构总结

以下是系统提示词从构建到最终发送的完整数据流:

getSystemPrompt()
  └── resolveSystemPromptSections([
        staticSection_1,       // systemPromptSection,session 内缓存
        staticSection_2,       // systemPromptSection,session 内缓存
        ...
        SYSTEM_PROMPT_DYNAMIC_BOUNDARY,   // 分界标记(仅 global cache 模式)
        dynamicSection_1,      // DANGEROUS_uncachedSystemPromptSection
        dynamicSection_2,      // systemPromptSection(但内容依赖运行时状态)
        ...
      ])
  └── string[]   (含边界标记)
        │
        ▼
  splitSysPromptPrefix(strings, { hasMcpTools, useGlobalCache })
  └── SystemPromptBlock[]
        { text: "...", cacheScope: 'global' | 'org' | null }
        │
        ▼
  buildSystemPromptBlocks(blocks, enablePromptCaching)
  └── TextBlockParam[]
        { type: 'text', text: "...", cache_control?: { type: 'ephemeral', ttl?, scope? } }
        │
        ▼
  Anthropic API Request

整个设计体现了一个核心原则:缓存策略的决策尽可能前置systemPromptSectionDANGEROUS_uncachedSystemPromptSection 在 section 定义时即声明其缓存语义,而非在最终构建阶段做动态判断。SYSTEM_PROMPT_DYNAMIC_BOUNDARY 作为数据平面的标记,将提示词数组的语义分区编码进数据本身,使 splitSysPromptPrefix 的逻辑保持相对简单。

这种"声明优于推断"的设计取向,降低了缓存策略与内容逻辑之间的耦合度——添加新 section 时,开发者只需在定义处决定是否使用 DANGEROUS_ 前缀,以及是否置于边界之前,而不需要理解整个 splitSysPromptPrefix 的切分逻辑。


附录:关键常量与函数索引

符号 文件 说明
systemPromptSection constants/systemPromptSections.ts 声明 session 内稳定的 section
DANGEROUS_uncachedSystemPromptSection constants/systemPromptSections.ts 声明每轮重计算的 section
SYSTEM_PROMPT_DYNAMIC_BOUNDARY constants/prompts.ts 静动态内容分界标记
splitSysPromptPrefix utils/api.ts 三模式缓存切片核心函数
CacheScope utils/api.ts `'global' 'org'` 缓存域类型
SystemPromptBlock utils/api.ts 带缓存语义的文本块类型
shouldUseGlobalCacheScope utils/betas.ts 全局缓存启用条件判断
getCacheControl services/api/claude.ts 生成 cache_control 对象
should1hCacheTTL services/api/claude.ts 1h TTL 扩展条件判断
buildSystemPromptBlocks services/api/claude.ts 将 block 转换为 API 参数
PROMPT_CACHING_SCOPE_BETA_HEADER services/api/claude.ts 全局缓存所需的 Beta Header
toolSchemaCache utils/toolSchemaCache.ts 工具 Schema 序列化稳定性缓存

uniapp安全区域,键盘挤压与上推

uniapp本身的不具备安全区域的标签和关于键盘的特殊标签

安全区域

safeArea:指定是可视区域

safeAreaInsets:可视区域外的

<template>
    <view :style="SafeStyle">
        <slot name="Content"></slot>
    </view>
</template>
<script setup>
onMounted(()=>{
    const StatusBar=ref()
    const HomeButton=ref()
    const {safeAreaInsets}=uni.getWindowInfo()
    StatusBar.value=safeAreaInsets.top
    HomeButton.value=safeAreaInsets.Bottom
})
const SafeStyle=computed(()=>({
    paddingTop:StatusBar+'rpx',
    paddingBottom:HomeButton+'rpx',
    paddingLeft:"12rpx",
    paddingRight:12rpx
}))
</script>

键盘挤压与上推

我可以说,uniapp在处理键盘可以说是最烂的一种方案,甚至都没有形成标准。和触底加载一样烂到根上了。前端处理长列表一般有这么几种方案,第一种是比例值上拉加载,第二种是可视区域滚动加载,第三种是虚拟列表加载,最后一种是就是uniapp提供的触底加载方式。uniapp的ScrollView可视区域滚动加载,但是uniapp官方明确不推荐长列表加载,这大大限制了ScrollView使用范围。而最后的虚拟列表加载实在是前端被迫选择的一种长列表加载方式之一,它解决了触底加载过渡的问题。 最后我说一下这个uniapp的键盘挤压与推送的问题。

<template>
    <view>
        // 第一个坑,adjust-position设置为true指的是上推页面,false指的是不处理,键盘
        会遮住输入框,而不是挤压页面!!!
        <input type="text" placeholder="请输入文本" adjust-position="true" />
    </view>
</template>

第二个坑,现在不在业务上了,又要去业务配置层面,adjustResize它代表挤压,adjustPan代表上推。这个坑并没有结束,官方明确说部分机型有漏屏的问题。

# page.json
{
    "app-plus":{
        "softinputMode":"adjustResize"
    }
}

第三个坑,当进入页面的时候需要弹出键盘,官方没有给出开关。需要我们手动组装这个问题。

<template>
    <view class="固定屏幕底部CSS样式" :style="keyBoard" >
        <input type="text" placeholder="请输入文本" adjust-position="true" :focus="GetFocus"
        @focus="KeyBoardFocusStyle"
        @Blur="KeyBoardBlurStyle"/>
    </view>
</template>
<script setup>
const onKeyboardHeightChange=ref()
const GetFocus=ref(true)
onLoad(()=>{
    uni.onKeyboardHeightChange((res)=>{
        GetFocus.value=true
        onKeyboardHeightChange.value=res.hight
    })
})

onUnLoad(()=>{
    GetFocus.value=false
    onKeyboardHeightChange.value=0
})
const KeyBoardFocusStyle=(e)=>{
     GetFocus.value=true
    if(onKeyboardHeightChange!=""){
        onKeyboardHeightChange.value=e.detail.height
    }
}

const KeyBoardBlurStyle=()=>{
    GetFocus.value=false
    onKeyboardHeightChange.value=0
}

const keyBoard=computed(()=>({
    height:onKeyboardHeightChange+"rpx"
})
</script>

最后一个坑当我们设置安全区域为组件的时候,此时的键盘和我们的组件又发生了冲突,也好解决,就是组件传值。或者将当前页面层级抬高。

使用 AI SDK 创建 「知识库」

rag-workflow.png

今天分享一个用纯 Node.js 实现知识库(RAG)的最简方案。

RAG 的核心思路其实并不复杂:

  • 对文档进行内容分割,将文档拆解成一个个小的语义分块(chunk);
  • 将这些分块通过大模型解析成向量(embedding)并储存在向量数据库中;
  • 当用户输入一个查询时,同样将查询进行向量化处理,通过向量数据库检索高度相关的知识片段;
  • 最后将这些片段整合到发送给大模型的上下文中,提升回答的精准度和相关性。

开始

技术栈:

  • libSQL - 向量数据库
  • AI SDK - 大模型调用与向量解析

安装依赖

npm install @libsql/client @ai-sdk/openai-compatible ai dotenv

添加环境变量

先在项目根目录创建 .env

AIPROXY_API_KEY=your_api_key_here

初始化

先把最基础的依赖准备好,包括模型客户端、本地数据库客户端,以及一组演示知识。

import 'dotenv/config'
import { createClient } from '@libsql/client/sqlite3'
import { createOpenAICompatible } from '@ai-sdk/openai-compatible'
import { embed, generateText } from 'ai'

const aiproxy = createOpenAICompatible({
  baseURL: 'https://api.aiproxy.shop/v1',
  apiKey: process.env.AIPROXY_API_KEY!,
  name: 'aiproxy',
})

const db = createClient({
  url: 'file:local.db',
})

const knowledgeDocuments = [
  {
    title: 'AI SDK 是什么',
    content:
      'AI SDK 是一个帮助 TS 和 JS 开发者快速接入大模型的工具包,支持流式响应、工具调用和多模型适配。',
  },
  {
    title: 'RAG 的核心流程',
    content:
      'RAG 的核心流程是切分文档、生成向量、保存向量、查询时把问题也转成向量、最后检索最相近的内容作为上下文。',
  },
  {
    title: '为什么要做分块',
    content:
      '因为整篇文档太长会影响检索精度,所以通常要先按语义切成多个 chunk,再分别生成 embedding。',
  },
]

type StoredChunk = {
  id: number
  title: string
  content: string
  distance: number
}

文档分块

RAG 不会直接拿整篇文档做检索,而是将文档拆分成很多小的文本块。

const splitIntoChunks = (text: string, size = 200) => {
  const normalized = text.replace(/\s+/g, ' ').trim()
  if (!normalized) {
    return []
  }

  const chunks: string[] = []
  for (let index = 0; index < normalized.length; index += size) {
    chunks.push(normalized.slice(index, index + size))
  }

  return chunks
}

如何生成 chunk 的内容?

最简单的办法就是直接按字符切分。比如上面 size = 200,就表示每 200 个字符切成一个块。或者可以通过标点符号进行切分,也可以用一些第三方库来进行智能分割。

生成 Embedding

这里会在两个阶段使用 embedding:

  1. 入库时把每个 chunk 转成向量并存储。
  2. 查询时把用户问题也转成向量,用于检索最相关的上下文。
const createEmbedding = async (value: string) => {
  const result = await embed({
    model: aiproxy.embeddingModel('openai/text-embedding-3-small'),
    value,
  })

  return result.embedding
}

初始化知识库表

现在要把知识片段真正保存到本地数据库中。

const initializeDatabase = async () => {
  await db.execute(`
    CREATE TABLE IF NOT EXISTS documents (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      title TEXT NOT NULL,
      content TEXT NOT NULL,
      embedding BLOB NOT NULL,
      created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
    )
  `)
}

这里的设计也比较直接:

  1. documents 表存标题、文本内容和 embedding。
  2. embedding 用 BLOB 存储,方便直接使用 libSQL 的向量函数检索。

这不是最终架构里性能最高的方案,但它非常利于你先理解“存储 + 检索”的闭环。

知识库入库

有了表结构之后,我们还需要把知识内容写入数据库。

const seedKnowledgeBase = async () => {
  await initializeDatabase()

  const countResult = await db.execute('SELECT COUNT(*) AS count FROM documents')
  const count = Number(countResult.rows[0]?.count ?? 0)

  if (count > 0) {
    return
  }

  for (const document of knowledgeDocuments) {
    const chunks = splitIntoChunks(document.content)

    for (const chunk of chunks) {
      const embedding = await createEmbedding(chunk)
      const embeddingBuffer = Buffer.from(new Float32Array(embedding).buffer)

      await db.execute({
        sql: 'INSERT INTO documents (title, content, embedding) VALUES (?, ?, ?)',
        args: [document.title, chunk, embeddingBuffer],
      })
    }
  }
}

入库流程如下:

  1. 如果表里已经有数据,就不重复写入;
  2. 把每篇文档切成多个 chunk;
  3. 给每个 chunk 生成 embedding;
  4. 把 chunk 和向量一起存进数据库。

检索相关片段

当用户提问时,我们先把问题转成向量,然后直接在数据库里做 Top-K 检索。

const searchKnowledge = async (query: string, limit = 3) => {
  const userQueryEmbedded = await createEmbedding(query)
  const queryBuffer = Buffer.from(new Float32Array(userQueryEmbedded).buffer)

  const rs = await db.execute({
    sql: `
      SELECT
        id,
        title,
        content,
        vector_distance_cos(embedding, vector32(?)) AS distance
      FROM documents
      ORDER BY distance ASC
      LIMIT ?
    `,
    args: [queryBuffer, limit],
  })

  return rs.rows.map((row) => ({
    id: Number(row.id),
    title: String(row.title),
    content: String(row.content),
    distance: Number(row.distance),
  }))
}

查询流程如下:

  1. 先把用户问题转成 embedding。
  2. vector32(?) 把查询向量传给数据库。
  3. 数据库内部用 vector_distance_cos 计算距离并排序,拿到 Top-K 结果。

封装主函数

最后,把上面所有逻辑串起来。

export const chatWithKnowledge = async (question: string) => {
  await seedKnowledgeBase()

  const results = await searchKnowledge(question)
  const contextText = results.map((item) => `- ${item.title}: ${item.content}`).join('\n')

  const result = await generateText({
    model: aiproxy('deepseek/deepseek-chat'), 
    system: `你是一个有用的助手。请优先根据知识库内容回答问题;如果知识库里没有相关信息,就明确告诉用户你不知道。\n\n知识库上下文:\n${contextText}`,
    prompt: question,
  })

  return {
    answer: result.text,
    references: results,
  }
}

整个请求链路到这里就闭环了:

  1. 函数执行时,先确保知识库已经初始化;
  2. 把用户问题转成向量;
  3. 在数据库层执行向量检索,拿到最相关片段;
  4. 把这些片段加入 system prompt,作为上下文或者参考信息;
  5. 调用主模型生成最终回答;
  6. 返回最终答案和命中的参考片段。

把上面所有代码拼到一起,就是一个完整的单文件 RAG Demo。

在文件末尾加上入口调用,直接运行即可:

const result = await chatWithKnowledge('RAG 为什么要分块?')

console.log('回答:', result.answer)
console.log('参考片段:', result.references)

知识库文档处理

在上面的示例里,我直接在代码里写了一个 knowledgeDocuments 数组来模拟知识库内容:

const knowledgeDocuments = [
  {
    title: '你的第一篇文档',
    content: '这里放你自己的知识内容',
  },
]

如果你的内容来自 Markdown、数据库或者 CMS,也可以先把内容读出来,再复用同样的 splitIntoChunks -> createEmbedding -> insert 流程。如果你的文档是 PDF 或图片,也可以先用 OCR 或者 pdf.js 把它们转成文本,再进行后续处理。

运行

npx tsx rag.ts

结语

复杂的应用是一个个小的应用组合起来的,学会每个小的知识点,就能够构建非常牛x的大型项目。

大家可以看一下我实现的 知识库的 NPM CLI 工具 - Meow ,欢迎 star 🌟。

❌