阅读视图

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

以浏览器多进程的角度解构页面渲染的整个流程

一、前言

页面在浏览器上的渲染并不是一个一条线的过程,而是多进程架构下协同的结果,本文从浏览器多进程的角度解构从url输入到页面渲染的整个流程的解析。

二、浏览器的多进程架构

1.进程与线程之间的关系

在操作系统中,进程是资源分配的最小单位,而线程是 CPU 调度的最小单位。

为了更好地理解浏览器架构,我们可以从以下三个维度来拆解它们的关系:

A. 包含与归属:工厂与工人

进程是容器:一个进程好比一个工厂车间,它拥有独立的内存空间、数据集和系统资源(如网络句柄、文件描述符)。

线程是执行者:线程是车间里的工人。一个进程可以包含多个线程,它们协同完成复杂的任务

B. 共享与隔离:内存的边界

进程间相互隔离:为了保证系统的稳定性,进程与进程之间的内存是完全隔离的如果一个渲染进程(Tab页)因为代码崩溃了,它不会影响到浏览器主进程或其他 Tab 页

线程间资源共享:同一个进程内的所有线程都可以访问该进程的内存空间。这意味着 JS 引擎线程可以轻松地读取到由网络线程下载并存放在内存中的数据

C. 协同与竞争:互斥锁的由来 ★★★

这是理解“为什么 JS 会阻塞渲染”的关键点:

在同一个渲染进程内,GUI 渲染线程和 JS 引擎线程是互斥的。

原因:因为 JavaScript 脚本具有修改 DOM 的能力。如果两者同时运行,GUI 线程正在绘制一个 DOM 节点,而 JS 线程同时把它删除了,就会导致渲染结果不可预期。因此,浏览器规定:当 JS 引擎工作时,GUI 渲染线程会被挂起。

2. 为什么浏览器要采用“多进程”而非“单进程多线程”?

在早期的浏览器中,所有功能都运行在一个进程里。现代浏览器演进为多进程架构,主要是为了解决三个核心痛点:

稳定性: 在单进程下,任何一个线程的崩溃(如 Flash 插件卡死或复杂的脚本死循环)都会导致整个浏览器瘫痪。多进程架构下,“一个 Tab 一个进程” 实现了故障隔离。

安全性: 浏览器通过沙箱 机制将渲染进程锁起来。由于渲染进程运行的是不受信任的第三方脚本,沙箱让它无法直接读写本地文件或调用系统 API。所有的敏感操作必须通过 IPC(进程间通信) 告知浏览器主进程,由主进程进行权限审查后代为执行。

流畅性: 多进程可以更充分地利用多核 CPU 的并行计算能力。同时,某些耗时的操作(如网络下载、插件运行)被抽离到独立进程中,不会占用渲染进程的主线程

3.浏览器的主要进程及其进程下的线程(4 个核心进程 和 2 个辅助/动态进程)

① 浏览器主进程

它是浏览器的核心,也是所有进程的父进程。

职责:

界面显示:负责地址栏、前进后退按钮、书签栏等浏览器“外壳”的 UI。

用户交互:监听鼠标点击、键盘输入

进程管理:负责创建、销毁和协调其他子进程

存储功能:负责管理 Cookie、本地存储(LocalStorage)等磁盘读写

核心线程:

  • UI 线程:负责绘制浏览器外壳(地址栏、书签栏、窗口控制按钮)。
  • I/O 线程:负责与其他进程进行 IPC 通信。(进程管理)

它负责管理子进程。当你点击关闭按钮,是 UI 线程捕捉到信号,通知浏览器进程去销毁对应的渲染进程

② 网络进程

原本是浏览器主进程中的一个线程,为了提升稳定性和安全性,现代 Chrome 将其独立为进程,专门负责处理与外部世界的资源交换。

职责:

资源加载:负责发起所有的网络请求(HTTP/HTTPS),并接收服务器返回的数据

协议解析:解析 HTTP 响应头、状态码,处理 301/302 重定向

DNS 解析:将域名映射为 IP 地址

缓存管理:根据 HTTP 头部信息(如 Cache-Control)判断并管理磁盘/内存中的网络资源缓存

安全校验:拦截恶意 URL 访问(如 Safe Browsing 安全检查)。

Cookie 处理:负责 HTTP 响应中 Set-Cookie 的解析,以及请求时 Cookie 字段的自动注入

核心线程:

网络协议栈线程:这是最忙碌的“工人”,负责处理 TCP 握手、TLS 加密解密、以及 HTTP/1.1、H2、H3 协议的处理。

DNS 解析线程:专门负责寻找域名背后的 IP 地址,并维护 DNS 缓存。

Socket 管理线程:管理与不同服务器之间的长连接(Connection Pool)。

③ 渲染进程

这是前端代码真正运行的“车间”,也是浏览器安全机制的核心。每个 Tab 标签页通常拥有一个独立的渲染进程,它运行在“沙箱”中,无法直接访问系统资源。

核心职责:

解析与构建:将 HTML、CSS 字节流转化为浏览器能理解的 DOM 树和 CSSOM 树

脚本执行:运行 JavaScript 代码,处理用户交互逻辑

布局与绘制:计算元素的大小位置,并生成最终的像素图像传给 GPU 进程

五大核心线程:

A.GUI 渲染线程

职责:负责解析 HTML、CSS,构建 DOM 树、CSSOM 树、布局树和绘制。

特点:当界面需要重绘(Repaint)或由于某些操作引发回流(Reflow)时,该线程就会执行

B.JS 引擎线程

职责:负责解析 JavaScript 脚本,运行代码

核心痛点(互斥机制):JS 引擎线程与 GUI 渲染线程是互斥的。如果 JS 执行时间过长,就会导致页面渲染加载阻塞,出现掉帧或卡顿。

面试亮点:为什么互斥?因为 JS 拥有修改 DOM 的权限。如果两者并行,可能会出现“GUI 正在画背景,而 JS 删除了该节点”的竞态问题。

C.事件触发线程

职责:归属于浏览器而不是 JS 引擎,用来控制事件循环(Event Loop)。

工作机制:当事件被触发(如点击、AJAX 完成)时,该线程会将对应的回调任务加入到任务队列的末尾,等待 JS 引擎空闲时处理。

D.定时器触发线程

职责:负责 setTimeout 与 setInterval 的计时

存在的意义:因为 JS 引擎是单线程的,如果处于阻塞状态就无法计时。因此需要独立线程计时,计时完毕后再通知事件触发线程将回调推入队列。

注意:W3C 标准规定,setTimeout 的间隔时间低于 4ms 会被自动设为 4ms。

E.异步 HTTP 请求线程

职责:在请求发起后,通过浏览器分配一个线程专门负责监控网络状态

工作机制:当请求状态变更(如成功返回)时,如果设有回调函数,该线程就会通知“事件触发线程”将回调放入任务队列。

F. 合成线程 专门负责处理页面的分层和图像合成,不占用主线程(GUI渲染线程和JS引擎线程)

职责:

接收指令:主线程完成布局和绘制列表后,将这些信息提交给合成线程

图层切片:将页面图层划分为大小固定的图块(Tiles),优先栅格化视口内的内容

调度栅格化:配合 GPU 进程将图块转换为位图(像素点)

响应交互:直接处理页面的滚动 (Scroll) 和 缩放 (Zoom),而无需经过主线程

核心优势(独立性)

非阻塞交互:由于合成线程与 JS 引擎线程、GUI 渲染线程不互斥。这意味着即使 JS 引擎正在运行一个死循环导致页面卡死,你依然可以流畅地滚动页面。

硬件加速:它是利用 GPU 资源的核心入口,通过处理 transform、opacity 等属性,实现无需重排重绘的高性能动画。

④ GPU 进程 (GPU Process)

最初仅用于处理 3D 图形,但随着现代网页对流畅度要求的提高,它已成为网页“排版合成”与“像素上色”的物理支柱。

职责:

硬件加速:将合成线程提交的图块由逻辑指令转换为显卡可识别的位图

复合渲染:负责将来自不同进程(如浏览器进程的 UI、渲染进程的网页内容)的位图进行混合,最终绘制到显示器屏幕上。

核心线程:

GPU 渲染线程:与显卡驱动直接通信,执行真正的绘制操作

为什么独立?

浏览器将 GPU 独立为进程,主要是因为图形处理涉及到复杂的操作系统底层调用和硬件驱动。驱动程序通常不如系统核心稳定,一旦 GPU 任务崩溃,浏览器只需重启该进程即可,而不会导致整个浏览器或所有标签页“黑屏”或死机。

⑤ 插件进程 (Plugin Process)

专门用于运行如 Flash、Silverlight 等第三方插件的进程。

职责:

隔离风险:插件往往由第三方开发,稳定性差且极易存在安全漏洞

物理隔离:确保即便插件崩溃或被劫持,其破坏力也仅限在该进程内部,不会波及渲染进程(你的网页)或主进程。

随着 Chrome 彻底停止对 Flash 的支持,现代网页中插件进程已较少出现。注意不要将“插件”与“扩展”混淆。

⑥ 扩展进程 (Extension Process)

地位:为你安装的浏览器扩展程序(如 Vue Devtools, AdBlock, 翻译插件)提供独立的运行空间。

职责:

独立运行:确保扩展程序的 JS 逻辑不会占用网页渲染进程的 CPU 资源

权限管控:浏览器进程会根据扩展申明的权限严格控制扩展进程对网页内容(DOM)或系统 API 的访问

思考题

渲染进程有GUI线程负责对html css js的解析和渲染,主进程的UI线程和gpu进程的gup加速线程也有类似功能,为什么要这样设计?

A. 渲染进程:逻辑计算的核心

虽然它叫“渲染进程”,但它大部分时间在做逻辑转换

GUI 线程的工作:它把代码字节流变成 DOM/CSSOM。最重要的是,它计算出 Layout(布局)。 它告诉浏览器:“这里有一个 100x100 的红色方块”。

它不直接画图:GUI 线程并不直接控制显示器像素,它生成的只是“绘制指令(Paint Records)”。

B. GPU 进程:硬件加速的真相

以前浏览器确实靠 CPU 画图(软件渲染),但 CPU 处理图形太慢了。

GPU 加速线程:它接收来自合成线程的指令。因为 GPU 擅长并行处理大量像素,它把渲染进程算好的“图块”直接转为屏幕上的像素。

独立性:把 GPU 独立出来是为了防崩溃。图形驱动非常脆弱,如果 GPU 线程在渲染进程里,一个复杂的 3D 效果挂了,你的网页就崩了。

C. 主进程(UI 线程):窗口的守护者

为什么主进程也要参与“显示”?

非网页区域的渲染:网页之外的区域(地址栏、书签栏、前进后退按钮)不受渲染进程控制。

最终合成:这是一个关键点。屏幕上显示的内容 = 浏览器外壳位图 + 网页内容位图

协作流程:GPU 进程会把画好的网页内容位图交给主进程,主进程把自己的 UI 位图叠上去,最后由主进程指挥显示器把这整张图显示出来。

一个具体的场景:改变 background-color

渲染进程 (JS/GUI 线程):JS 修改了 CSS,主线程重新计算样式,发现颜色变了,生成一份新的“绘制列表”。

渲染进程 (合成线程):拿到列表,把任务分块,发给 GPU 进程。

GPU 进程 (GPU 线程):调用显卡硬件,把受影响的像素点重新喷色,生成位图。

浏览器主进程 (UI 线程):把这张新的位图放在浏览器窗口的“白板”区域显示。

三、从url输入到页面渲染的全流程(结合浏览器多进程架构)

整个流程实质上是多个独立进程在浏览器主进程的调度下,通过 Mojo IPC(进程间通信) 进行的一场数据与控制权的接力。

1. 导航触发:浏览器主进程的调度与拦截

输入预处理:UI 线程拦截地址栏输入。若为非 URL 字符串,调用搜索引擎封装 URL;若为合法 URL,则直接进入导航逻辑

BeforeUnload 拦截:如果当前已存在页面,主进程通过 IPC 向当前渲染进程发出信号。渲染进程执行 JS 逻辑并返回结果。为了防止渲染进程无响应导致导航卡死,主进程会对这一过程设置 Timeout 阈值。

启动网络指令:UI 线程发起一个指向 网络进程 的 IPC 请求

这里详细解释一下BeforeUnload和启动网络指令的过程及优化——

① BeforeUnload 拦截:给旧页面“交代遗言”的机会

当你点击一个链接或在地址栏回车时,当前的网页(旧页面)还没销毁。浏览器必须先询问它:“你还有没处理完的事吗?”

IPC 信号是什么? 浏览器主进程(管理窗口的)发现你要跳走了,它会发一个 IPC(进程间通信)消息 给当前网页所在的渲染进程

渲染进程在做什么? 渲染进程接收信号后,会检查 JS 代码里有没有监听 beforeunload 事件。比如你在写博客,还没保存,JS 就会弹出一个对话框:“系统可能不会保存您所做的更改。确定要离开吗?”

为什么需要 Timeout(超时)阈值? 这是为了防死锁。如果旧页面的渲染进程崩了,或者 JS 写了个死循环(例如 while(true){}),它就无法回复主进程。如果没有超时机制,你的浏览器地址栏就会永远卡在那里。

底层逻辑: 主进程会启动一个定时器(比如几秒钟)。如果渲染进程在规定时间内没回话,主进程会认为这个渲染进程“挂了”,直接强行掐断它的生命周期,强制开始加载新页面。

②启动网络指令:外交部正式出航

一旦旧页面被处理完(或者超时了),浏览器主进程就要去互联网上拿新页面的数据了。

UI 线程发起请求: 此时,主进程里的 UI 线程(负责处理地址栏、按钮点击的那个工人)会整理好目标 URL、Cookie、请求头等信息。

指向网络进程的 IPC 请求: 在现代 Chrome 中,主进程自己不负责下载。它会把刚才整理好的“请求包”通过 IPC 扔给 网络进程

形象点说: 主进程(CEO)给网络进程(外交部)打了个电话:“喂,去帮我把 github.com 的 HTML 字节流取回来。”

③为什么这两步是“并行的优化点”?

这里有一个非常硬核的亮点:现代浏览器并不会等 beforeunload 彻底结束才去发起网络请求。

为了快,浏览器通常会采取 并行策略:

一边让主进程询问旧页面是否要离开。

一边同步通知网络进程去进行 DNS 解析 和 建立连接。

如果用户最后点击了“取消离开”,浏览器就把刚发起的网络请求掐断。如果用户确定离开,此时网络连接可能已经建好了,网页秒开。这就是所谓的 “导航预加载”思想。

2. 网络资源获取:网络进程的“外交”与“初筛”

当网络进程接到主进程的指令后,它开始在互联网上为网页寻找材料。

A. 物理链路的打通(建立连接)

浏览器缓存:首先检查网络进程内存中存储的 DNS 记录(通常缓存 1 分钟)

DNS 与握手如果浏览器缓存未命中网络进程会去查 IP 地址(DNS),然后进行 TCP 三次握手。如果是 HTTPS,还要进行 TLS 加密握手。

亮点:为了快,浏览器会维护一个 连接池。如果最近刚访问过这个域名,它会直接复用之前的“管道”,省去握手时间。

B. 响应头的解析与“重定向”黑箱

内部消化重定向如果服务器返回 301/302(重定向),网络进程不会跑回去告诉主进程,而是自己在内部重新发起新的请求。

逻辑意义:对主进程和渲染进程来说,它们只关心最终拿到的结果,中间转了几次弯(重定向),网络进程在底层偷偷帮你处理好了。

C. 核心:响应体的“首包”嗅探

这是全流程中最精妙的地方。当网络进程收到服务器返回的第一份数据包(通常是前几个字节)时

确定身份:网络进程会查看 Content-Type。

如果是 text/html,它就知道:“正主来了,准备通知渲染进程干活”。

如果是 application/octet-stream,它会意识到:“这是一个下载任务”,于是把请求转交给下载管理器,导航流程在此终止。

建立数据管道:

核心机制:一旦确认是 HTML,网络进程不会等整个网页下载完。

它会建立一条“数据长管”。管子的这头在网络进程(继续下载后续字节),管子的那头直接插进未来的渲染进程。

它的意义:实现“边下载边解析”,极大地缩短了白屏时间。

3. 提交导航:控制权从主进程移交给渲染进程

这是导航阶段最核心的“状态切换”点,标志着页面正式从旧地址切换到新地址。

进程分配:主进程根据 Site Isolation 策略分配渲染进程。如果是同站跳转,可能复用原有进程;否则启动新进程。

Commit 指令:主进程发送 CommitNavigation 消息给目标 渲染进程。

数据交接:主进程会将网络进程中那个 Data Pipe 的句柄随指令发送给渲染进程。

确认反馈:渲染进程收到句柄后,直接从管道读取数据流。一旦开始解析,渲染进程向主进程发送 DidCommitProvisionalLoad。

状态切换:主进程收到反馈后,执行 UI 状态更新(更新地址栏、重置历史记录、刷新前进按钮)。此时旧页面正式被销毁。

4. 渲染流水线:渲染进程与 GPU 的像素产出

在渲染进程接收到“数据管道”的句柄后,内部的主线程、合成线程 与 GPU 进程 开始高度协同。

A. 解析与构建:将字节转化为结构

流式解析:主线程无需等待 HTML 下载完成。通过 Data Pipe,每接收到一个数据包,GUI 渲染线程就会立即启动解析,边下载边构建 DOM 树。

样式计算(CSSOM):主线程解析 CSS 样式,计算出每个 DOM 节点的最终样式

亮点(互斥机制):此阶段若遇到 <script>,主线程会挂起 GUI 渲染线程,切换到 JS 引擎线程。这种互斥确保了 JS 在修改 DOM 时不会产生渲染竞态。

B. 几何计算:确定空间坐标

布局树(Layout Tree)构建:主线程将 DOM 与 CSSOM 合并。它会过滤掉 display: none 的节点,仅保留可见元素。

几何量算:主线程精确计算每个元素在三维空间中的 (x, y) 坐标、宽高及层级

产物:一棵包含所有几何信息的布局树。

C. 记录与图层化:生成施工图纸

分层:为了处理 3D 转换(transform)或滚动,主线程会根据属性将页面拆分为多个图层。

绘制记录(Paint):主线程并不直接画图,而是将每个图层的绘制逻辑拆解为一个个指令(如:“在此处画正方形”,“在彼处填充红色”)。

产物:一份名为 绘制记录(Paint Records) 的逻辑清单。

D. 栅格化与合成:像素的工业产出

任务此时从主线程移交给合成线程,进入真正的硬件加速阶段。

切片(Tiling):合成线程将巨大图层划分为固定大小的 图块(Tiles),优先处理视口(用户肉眼可见区域)内的内容。

栅格化:

合成线程通过 IPC 向 GPU 进程 发出指令。

GPU 进程 利用显卡的并行计算能力,将图块指令转化为显存中的位图。

复合与上屏:

合成线程收集所有图块位图,生成一份“指引(Compositor Frame)”。 浏览器主进程 接收该指引,将网页位图与浏览器外壳(地址栏等)进行叠加,最终由 GPU 刷新到屏幕上。

为什么要把“合成”独立出来?

非阻塞滚动:当主线程因为运行复杂的 JavaScript 而卡死时,合成线程 依然可以独立工作。它能直接利用 GPU 显存里已有的位图进行位移偏移,这就是为什么即便网页脚本卡顿,你依然能流畅滑动(Scroll)页面的原因。

硬件加速:通过 transform 或 opacity 做的动画,直接在合成阶段完成,不触发主线程的“重排”或“重绘”,实现了真正的性能最优。

四、流水线视角的重排、重绘与合成

理解了多进程协作的渲染流水线后,我们就能从底层逻辑解释:为什么有的代码会让页面卡顿,而有的代码却能实现 60fps 的丝滑动画? 关键在于你的操作强迫流水线“回溯”到了哪一步。

1. 重排

触发原因:修改了影响几何空间的属性(如 width, height, margin, padding, border, display 等),或调整浏览器窗口大小。

流水线回溯:

主线程:必须重新经历 样式计算 -> 布局 -> 图层分层 -> 生成绘制列表 。

合成线程:重新进行图块划分。

GPU 进程:重新进行栅格化和位图上传。

这是开销最大的操作,因为它触发了全量流水线,且深度依赖主线程的计算压力。

2. 重绘

触发原因:修改了不影响布局、仅影响视觉外观的属性(如 color, background-color, visibility, box-shadow 等)。

流水线回溯:

主线程:跳过布局和分层,直接重新生成 绘制记录。

合成/GPU 进程:重新进行栅格化。

开销中等。虽然避开了几何几何计算,但依然需要主线程生成指令并触发 GPU 重新喷色。

3. 合成 (Composite):硬件加速的“超车道”

触发原因:使用 CSS 的 transform(位移、缩放、旋转)或 opacity。

流水线表现:

主线程完全不参与。

合成线程:直接接收指令,在 GPU 中利用已有的图块位图进行矩阵变换。

开销极低。这是多进程架构带来的最大红利——动画直接在合成线程与 GPU 进程间通讯,即使此时 JS 引擎在主线程里跑死循环,合成动画依然能流畅运行。

五、总结

通过对浏览器多进程架构及渲染流水线的深度解构,我们可以发现,从输入 URL 到页面呈现,本质上是一场多进程间的“接力赛”与流水线上的“精密加工”。

1. 核心链路回顾

我们可以将整个漫长的流程浓缩为四个关键的瞬间:

主进程:拦截输入,启动导航,指挥网络部出航。

网络进程:打通链路,嗅探内容,并建立指向未来的数据管道。

渲染进程-主线程:将字节流转化为 DOM/CSSOM,并在几何计算中确定每一个像素的坐标。

合成线程 & GPU:利用硬件加速,将逻辑指令转化为位图,实现最终的像素上屏。

2. 给前端开发的性能启示

理解了这套底层机制,我们对“性能优化”的理解便不再流于表面,而是进化为一种流水线思维:

保护主线程:GUI 渲染与 JS 执行的互斥性告诉我们,长任务是掉帧的元凶。我们应当利用 Web Workers 或时间切片来释放主线程。

善用合成器:优先使用 transform 和 opacity 实现动画,本质上是在利用多进程架构的红利,绕过拥挤的主线程,走 GPU 加速的“超车道”。

尊重重排规律:减少对 offsetWidth 等属性的频繁读取,实质上是在保护流水线不被“强制同步布局”打断,避免昂贵的重复计算。

3. 写在最后

浏览器作为现代最复杂的软件之一,其多进程架构是稳定性、安全性和高性能巴巴博弈后的终极方案。

作为开发者,理解底层是为了更自由地构建上层。 当你再次打开浏览器的 Performance 面板,看到那些交织的进程与线程曲线时,你看到的不再是枯燥的数据,而是一场由数万行 C++ 代码支撑、毫秒必争的协作交响乐。

深入防抖与节流:从闭包原理到性能优化实战

前言

在前端开发中,防抖(Debounce)节流(Throttle) 是两种经典的性能优化技术,广泛应用于搜索建议、滚动加载、窗口缩放等高频事件场景。它们能有效减少不必要的函数调用,避免页面卡顿或请求爆炸。

要深入理解其实现原理,你需要掌握以下核心知识点:

闭包(Closure) :用于在函数返回后仍能“记住”并访问内部变量(如定时器 ID 或时间戳)

对于闭包,我写了这两篇文章

柯里化:用闭包编织参数的函数流水线

JavaScript 词法作用域与闭包:从底层原理到实战理解

this 与参数的正确传递:确保被包装的函数在正确上下文中运行。

对于this,有不懂的可以参考这篇文章:

this 不是你想的 this:从作用域迷失到调用栈掌控

本文将结合生活类比、代码实现与真实场景,带你一步步拆解防抖与节流的机制、差异与应用之道。即使你曾觉得它们“有点绕”,读完也会豁然开朗。

一、问题背景:输入框频繁触发事件

全部代码在后面的附录

在 Web 开发中,用户在输入框中打字时,常会绑定 keyup 事件来实时响应输入内容。例如:

// 1.html Lines 17-19
function ajax(content) {
  console.log('ajax request', content);
}
// 1.html Lines 64-66
inputa.addEventListener('keyup', function(e) {
  ajax(e.target.value); // 复杂操作
});

问题:每当用户输入一个字符,就会触发一次 ajax() 调用。若用户输入 “hello”,将产生 5 次请求,造成不必要的网络开销和性能浪费。

image.png


二、防抖(Debounce)机制

想象你站在电梯里,正等着门关上。

可就在这时,一个路人匆匆跑进来,门立刻重新打开;还没等它合拢,又一个人冲了进来……只要不断有人进入,电梯就会一直“耐心”地等下去。

我站在里面心想:“这门到底什么时候才关啊?”

直到最后,整整几秒钟没人再进来——终于,“叮”一声,门缓缓合上,电梯开始运行。

这就像防抖:只要事件还在频繁触发,函数就一直“等”;只有当触发停歇了一段时间,它才真正执行。

这种“按节奏执行”的思想,不仅存在于游戏中,也广泛应用于 Web 交互。

一些AI编辑器 ( 比如Trae Cursor )就是这样

当你在代码框里飞快敲字时,它并不会每按一个键就立刻分析整段逻辑或发起智能补全请求。

那样做不仅浪费资源,还会拖慢输入体验。

相反,它会默默“观察”你的输入节奏:

只要你还在连续打字,它就耐心等待;一旦你停顿半秒,它才迅速介入,给出精准建议

代码实现

// 1.html Lines 21-30
function debounce(fn, delay) {
  let id; // 闭包中的自由变量,用于保存定时器 ID
  return function(...args) {
    if (id) clearTimeout(id); // 清除上一次的定时器
    const that = this;
    id = setTimeout(() => {
      fn.apply(that, args);
    }, delay);
  };
}

关键点解析

防抖函数通过闭包维护一个共享的定时器标识 id,使得多次事件触发都能访问并操作同一个状态。

每当用户触发事件(如键盘输入),函数会先清除之前尚未执行的定时器(如果存在),然后重新启动一个延迟为 delay 毫秒的新定时器

这意味着只要用户持续操作,计时就会不断重置,真实逻辑始终被推迟;只有当用户停止操作并经过指定的等待时间后,目标函数才会真正执行。

delay = 500ms 为例,若用户在 200ms 内快速输入 “hello”,每次按键都会打断之前的倒计时,最终仅在最后一次输入结束 500ms 后调用一次 ajax("hello")。整个过程将原本可能触发 5 次的请求压缩为 1 次,在保证响应合理性的同时,显著降低了系统开销。

image.png

使用示例

// 1.html Lines 58-69
const debounceAjax = debounce(ajax, 500);

inputb.addEventListener('keyup', function(e) {
  debounceAjax(e.target.value);
});

三、节流(Throttle)机制

核心思想

在固定时间间隔内,最多执行一次函数。

我正在玩一款FPS游戏,手指死死按住鼠标左键疯狂扫射——

可游戏里的枪根本没跟着我的节奏“突突突”到底。明明我一秒点了十下,它却稳稳地“哒、哒、哒”,每隔固定时间才射出一发子弹。

后来我才明白:这不是卡顿,而是射速限制在起作用。无论我多着急、按得多快,系统都会冷静地按自己的节奏来,既不让火力过猛破坏平衡,也不让我白白浪费弹药。

这就像节流:不管事件触发得多密集,函数都坚持“定时打卡”,不多不少,稳稳执行。

这种设计哲学,同样被现代开发工具所采纳

比如京东等电商平台:鼠标滚动时,页面需要不断判断是否已滑动到商品列表底部,从而决定是否自动加载下一页商品。

如果对每一次滚动事件都立即响应,浏览器会因频繁计算和发起网络请求而卡顿,尤其在低端设备上体验更差。

于是,开发者会使用节流机制——将滚动处理函数限制为每 200~300 毫秒最多执行一次。这样,即使用户快速拖动滚动条,系统也只会在固定间隔“抽样”检查位置,既保证了加载的及时性,又避免了性能过载。

换句话说:我不在乎你滚得多快,我只按自己的节奏干活——这正是节流在真实场景中的价值。

代码实现

// 1.html Lines 32-52
function throttle(fn, delay) {
  let last = 0;       // 上次执行的时间戳
  let deferTimer = null;

  return function(...args) {
    const now = Date.now();
    const that = this;

    if (last && now < last + delay) {
      // 还未到下次执行时间:延迟执行,并确保最后一次能触发
      clearTimeout(deferTimer);
      deferTimer = setTimeout(() => {
        last = now;
        fn.apply(that, args);
      }, delay - (now - last));
    } else {
      // 可立即执行
      last = now;
      fn.apply(that, args);
    }
  };
}

关键点解析

节流函数通过闭包维护两个关键状态:

last 记录上一次实际执行的时间戳,deferTimer 则用于管理可能的延迟执行任务。

每当事件被触发,函数会先获取当前时间,并判断距离上次执行是否已超过设定的间隔 delay

如果尚未到冷却期(即 now < last + delay),它不会立即执行,而是清除之前安排的延迟任务,并根据剩余时间重新设置一个定时器,确保在当前周期结束时至少执行一次;

如果已经过了冷却期,则直接执行函数并更新 last。这种机制既实现了“固定频率执行”的节奏控制,又巧妙地保证了在连续高频触发的末尾仍能响应最后一次操作。

例如,在 delay = 500ms 的配置下,无论用户在短时间内触发多少次事件,函数都会在 0ms、500ms、1000ms 等时间点稳定执行,既避免了过度调用,又不丢失关键的最终状态。

使用示例

// 1.html Lines 59-62
const throttleAjax = throttle(ajax, 500);

inputc.addEventListener('keyup', function(e) {
  throttleAjax(e.target.value);
});

四、典型应用场景

防抖适用场景

防抖最适合那些“只关心最终结果”的交互场景。

例如,在百度或淘宝的搜索框中,用户一边输入一边期待建议词,但如果每敲一个字母就立刻发起请求,不仅会制造大量无意义的网络调用,还可能因中间态(如拼音未完成)返回错误结果。

通过防抖,系统会耐心等到用户停顿片刻(比如 300 毫秒),再以最终输入内容发起一次精准查询。

类似的逻辑也适用于表单字段的验证——只有当用户真正输完并稍作停顿,才触发校验,避免在输入过程中不断弹出错误提示干扰操作。

简言之,防抖在“太快导致资源浪费”和“太慢影响体验”之间找到了最佳平衡点。

节流适用场景

相比之下,节流则适用于需要“持续响应但必须限频”的场景。

比如在京东、掘金等电商或内容平台,用户快速滚动页面时,系统需判断是否已滑到底部以加载更多商品或帖子。若对每一次滚动都立即响应,浏览器将不堪重负。

而通过节流(如每 300 毫秒最多执行一次检查),既能及时感知滚动行为,又避免过度计算。

同样,鼠标移动或元素拖拽过程中,实时更新坐标若不加限制,极易造成界面卡顿;节流能确保 UI 以稳定帧率更新,保持流畅感。甚至在某些对 resize 事件要求实时反馈的场景(如动态调整画布或视频比例),也会采用节流而非防抖,以兼顾响应性与性能。


防抖与节流,看似简单,却是前端性能优化的基石。掌握它们,就掌握了在“响应速度”与“系统负担”之间优雅平衡的艺术。


五、完整示例代码

上面的代码

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>防抖</title>
</head>
<body>
  <div>
    <input type="text" id="undebounce" />
    <br>
    <input type="text" id="debounce" />
    <br>
    <input type="text" id="throttle" />
  </div>
  <script>
  function ajax(content) {
    console.log('ajax request', content);
  }
  // 高阶函数 参数或返回值(闭包)是函数(函数就是对象) 
  function debounce(fn, delay) {
    var id; // 自由变量 
    return function(args) {
      if(id) clearTimeout(id);
      var that = this;
      id = setTimeout(function(){
        fn.call(that, args)
      }, delay);
    }
  }
  // 节流 fn 执行的任务 
  function throttle(fn, delay) {
    let 
      last, 
      deferTimer;
    return function() {
      let that = this; // this 丢失
      let _args = arguments // 类数组对象
      let now = + new Date(); // 类型转换, 毫秒数
      // 上次执行过 还没到执行时间
      if(last && now < last + delay) {
        clearTimeout(deferTimer);
        deferTimer = setTimeout(function(){
          last = now;
          fn.apply(that, _args);
        }, delay - (now - last));
      } else {
        last = now;
        fn.apply(that, _args);
      }
    }
  }
  
  const inputa = document.getElementById('undebounce');
  const inputb = document.getElementById('debounce');
  const inputc = document.getElementById('throttle');

  let debounceAjax = debounce(ajax, 500);
  let throttleAjax = throttle(ajax, 500);
  inputc.addEventListener('keyup', function(e) {
    throttleAjax(e.target.value)
  })
  // 频繁触发
  inputa.addEventListener('keyup', function(e) {
    ajax(e.target.value) // 蛮复杂
  })
  inputb.addEventListener('keyup', function(e) {
    debounceAjax(e.target.value)
  })
  </script>
</body>
</html>

防抖与节流:前端性能优化的“双子星”,让你的网页丝滑如德芙!

防抖与节流:前端性能优化的“双子星”,让你的网页丝滑如德芙!

在现代 Web 开发中,用户交互越来越丰富,事件触发也越来越频繁。无论是搜索框的实时建议、页面滚动加载,还是窗口尺寸调整,这些看似简单的操作背后,都可能隐藏着性能陷阱。如果不加以控制,高频事件会像洪水一样冲垮你的应用——导致卡顿、内存泄漏,甚至服务器崩溃。

幸运的是,前端工程师早已找到了两大利器:防抖(Debounce)节流(Throttle) 。它们如同性能优化领域的“双子星”,一个专注“等你停手”,一个坚持“按节奏来”。今天,我们就深入剖析这两位高手的原理、区别与实战用法,助你写出更高效、更流畅的代码!


一、问题根源:为什么我们需要防抖和节流?

想象一下你在百度搜索框输入“React教程”:

  • 每按下一个键(R → e → a → c → t …),浏览器都会触发一次 keyup 事件;
  • 如果每次事件都立即发送 AJAX 请求,那么短短 6 个字就会发出 6 次网络请求
  • 而实际上,你只关心最终的关键词 “React教程”。

这就是典型的 “高频事件 + 复杂任务” 组合:

  • 事件太密集keyupscrollresize 等事件每秒可触发数十次;
  • 任务太复杂:AJAX 请求、DOM 操作、复杂计算等消耗大量资源。

若不加限制,后果严重:

  • 浪费带宽和服务器资源;
  • 页面卡顿,用户体验差;
  • 可能因请求顺序错乱导致 UI 显示错误(竞态条件)。

于是,防抖节流 应运而生。


二、防抖(Debounce):只执行最后一次

✅ 核心思想

“别急,等用户彻底停手再说!”

防抖的逻辑非常简单:在连续触发事件的过程中,不执行任务;只有当事件停止触发超过指定时间后,才执行一次。

🏠 生活类比:电梯关门

  • 电梯门打开后,等待 5 秒再关闭;
  • 如果第 3 秒有人进来,就重新计时 5 秒
  • 只有连续 5 秒没人进入,门才真正关闭。

💻 代码实现(闭包 + 定时器)

function debounce(fn, delay) {
  let timer; // 闭包变量,保存定时器 ID
  return function (...args) {
    clearTimeout(timer); // 清除上一个定时器
    timer = setTimeout(() => {
      fn.apply(this, args); // 执行原函数
    }, delay);
  };
}
关键点解析:
  • timer 是自由变量,被内部函数通过闭包“记住”;
  • 每次调用返回的函数,都会先 clearTimeout,再 setTimeout
  • 结果:只有最后一次触发后的 delay 毫秒内无新触发,才会执行

🌟 典型应用场景

场景 说明
搜索建议 用户打字时,等他停手再发请求,避免无效搜索
表单校验 输入邮箱/密码后,延迟验证,减少干扰
窗口 resize 保存布局 用户调整完窗口大小再保存,而非过程中反复保存

✅ 一句话总结:防抖适用于“有明确结束点”的操作,关注最终状态。


三、节流(Throttle):固定间隔执行

✅ 核心思想

“别慌,按我的节奏来!”

节流的逻辑是:无论事件触发多频繁,我保证每隔 X 毫秒最多执行一次任务。

🏠 生活类比:FPS 游戏射速

  • 即使你一直按住鼠标左键,枪也只会按照设定的射速(如每秒 10 发)射击;
  • 多余的点击会被忽略。

💻 代码实现(时间戳版)

function throttle(fn, delay) {
  let last = 0; // 上次执行时间
  return function (...args) {
    const now = Date.now();
    if (now - last >= delay) {
      fn.apply(this, args);
      last = now;
    }
  };
}

但你提供的代码更智能——它结合了尾部补偿

function throttle(fn, delay) {
  let last, deferTimer;
  return function () {
    let that = this;
    let _args = arguments;
    let now = +new Date();

    if (last && now < last + delay) {
      // 还在冷却期:清除旧定时器,安排新尾部任务
      clearTimeout(deferTimer);
      deferTimer = setTimeout(() => {
        last = now;
        fn.apply(that, _args);
      }, delay);
    } else {
      // 冷却期结束:立即执行
      last = now;
      fn.apply(that, _args);
    }
  };
}
工作流程:
  1. 第一次调用 → 立即执行;
  2. 高频调用期间 → 忽略中间操作,但记录最后一次
  3. 停止触发后 → 在 delay 毫秒后执行最后一次。

⚠️ 注意:这种实现确保了尾部操作不丢失,适合需要“收尾”的场景。

🌟 典型应用场景

场景 说明
页面滚动(scroll) 每 200ms 记录一次滚动位置,避免卡顿
鼠标移动(mousemove) 控制动画或绘图频率
按钮防连点 提交订单后 1 秒内禁止再次点击
无限滚动加载 用户滚动到底部时,定期检查是否需加载新数据

✅ 一句话总结:节流适用于“持续高频”的操作,关注过程节奏。


四、防抖 vs 节流:关键区别一目了然

对比项 防抖(Debounce) 节流(Throttle)
执行时机 停止触发后延迟执行 固定间隔执行
执行次数 N 次触发 → 1 次执行 N 次触发 → ≈ N/delay 次执行
是否保留尾部 是(天然保留) 基础版否,增强版可保留
核心机制 clearTimeout + setTimeout 时间戳判断 或 setTimeout 控制
适用事件 inputkeyup scrollresizemousemove
用户感知 “打完字才响应” “滚动时定期响应”

🔥 记住这个口诀:
“防抖等停手,节流控节奏。”


五、闭包:防抖与节流的“幕后英雄”

你可能注意到,无论是 debounce 还是 throttle,都用到了 闭包

function debounce(fn, delay) {
  let timer; // ← 这个变量被内部函数“记住”
  return function() {
    clearTimeout(timer); // ← 能访问外部的 timer
    // ...
  };
}

为什么必须用闭包?

  • timerlast 等状态需要在多次函数调用之间保持
  • 普通局部变量在函数执行完就销毁;
  • 而闭包让内部函数持续持有对外部变量的引用,形成“私有记忆”。

💡 闭包 = 函数 + 其词法环境。它是实现状态管理的基石。


六、实战建议:如何选择?

你的需求 推荐方案
用户输入搜索词 ✅ 防抖(500ms)
监听窗口 resize ✅ 节流(200ms)
滚动加载更多 ✅ 节流(300ms)
表单自动保存草稿 ✅ 防抖(1000ms)
鼠标拖拽元素 ✅ 节流(16ms ≈ 60fps)

📌 小技巧:

  • 防抖延迟通常 300~500ms(平衡响应与性能);
  • 节流间隔通常 100~300ms(根据场景调整)。

七、结语:优雅地控制频率,是专业前端的标志

防抖与节流,看似只是几行代码,却体现了对用户体验和系统性能的深刻理解。它们不是炫技,而是工程实践中不可或缺的“安全阀”。

下次当你面对高频事件时,不妨问问自己:

  • 我需要的是最终结果,还是过程采样
  • 用户是否希望立刻响应,还是可以稍等片刻

答案将指引你选择防抖或节流。掌握这“双子星”,你的代码将不再“颤抖”,而是如丝般顺滑——这才是真正的前端艺术!

vue3 KeepAlive 核心原理和渲染更新流程

vue3 KeepAlive 核心原理和渲染更新流程

KeepAlive 是 Vue 3 的内置组件,用于缓存动态组件,避免重复创建和销毁组件实例。 当组件被切换时,KeepAlive 会将组件实例存储在内存中,而不是完全销毁它,从而保留组件状态并提升性能。

1. 挂载

将子组件vnode进行缓存,并且设置vnode.shapeFlag |= ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE,供运行时在卸载时特殊处理

2. 停用 deactivate

当组件需要隐藏时, 根据COMPONENT_SHOULD_KEEP_ALIVE 和 renderer的逻辑

  1. 将组件移动到 storageContainer(一个不可见的 DOM 容器)
  2. 触发组件的 deactivated 生命周期钩子
  3. 组件实例和状态得以保留

3. 激活 activate

当组件再次激活时, 根据COMPONENT_KEPT_ALIVE 和 renderer的逻辑

  1. 新的 vnode.el 使用 cachedVNode.el
  2. 新的 vnode.component 使用 cachedVNode.component,这个是已经挂载的 组件了,里面的subTree都是有el的
  3. 将 vnode 移回目标容器
  4. 执行 patch 更新(处理 props 变化)
  5. 触发组件的 activated 生命周期钩子

4. 相关源码(只保留关于KeepAlive相关的核心逻辑)

const KeepAliveImpl: ComponentOptions = {
  name: `KeepAlive`,
  __isKeepAlive: true,
  setup(_, { slots }: SetupContext) {
    const instance = getCurrentInstance()!
    const sharedContext = instance.ctx as KeepAliveContext
    const cache: Cache = new Map()
    const keys: Keys = new Set()

    const {
      renderer: {
        p: patch,
        m: move,
        um: _unmount,
        o: { createElement },
      },
    } = sharedContext
    const storageContainer = createElement('div')

    // vnode 缓存的子组件, 结合runtime patch
    sharedContext.activate = (
      vnode,
      container,
      anchor,
      namespace,
      optimized
    ) => {
      // instance 是子组件实例
      const instance = vnode.component!
      // 移回来
      move(vnode, container, anchor, MoveType.ENTER, parentSuspense)
      // in case props have changed
      patch(instance.vnode, vnode, container, anchor, instance,...)
      queuePostRenderEffect(() => {
        instance.isDeactivated = false
        if (instance.a) {
          invokeArrayFns(instance.a)
        }
      }, parentSuspense)
    }

    // vnode 缓存的子组件,里面的缓存的组件除了这两个钩子,其他都是常规流程
    sharedContext.deactivate = (vnode: VNode) => {
      const instance = vnode.component!
      // 移到缓存容器
      move(vnode, storageContainer, null, MoveType.LEAVE, parentSuspense)
      queuePostRenderEffect(() => {
        if (instance.da) {
          invokeArrayFns(instance.da)
        }
      }, parentSuspense)
    }

    // 当缓存失效,就需要真正的卸载
    function unmount(vnode: VNode) {
      // reset the shapeFlag so it can be properly unmounted
      resetShapeFlag(vnode)
      _unmount(vnode, instance, parentSuspense, true)
    }

    let pendingCacheKey: CacheKey | null = null
    const cacheSubtree = () => {
      // fix #1621, the pendingCacheKey could be 0
      if (pendingCacheKey != null) {
        cache.set(pendingCacheKey, getInnerChild(instance.subTree))
      }
    }
    onMounted(cacheSubtree)
    onUpdated(cacheSubtree)

    onBeforeUnmount(() => {
      cache.forEach(unmount)
    })

    // 渲染函数
    return () => {
      pendingCacheKey = null

      const children = slots.default()
      const rawVNode = children[0]
      const vnode = children[0]
      // 这里的vnode 就是指 缓存的组件
      // warn(`KeepAlive should contain exactly one component child.`)

      const comp = vnode.type as ConcreteComponent

      const name = getComponentName(comp)

      const { include, exclude, max } = props

      if (
        (include && (!name || !matches(include, name))) ||
        (exclude && name && matches(exclude, name))
      ) {
        // #11717 // 我写的pr!!!!
        vnode.shapeFlag &= ~ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE
        return rawVNode
      }

      const key = vnode.key == null ? comp : vnode.key
      const cachedVNode = cache.get(key)

      pendingCacheKey = key

      if (cachedVNode) {
        // 使用缓存的el,缓存的component tree,所以就不用走mount
        // copy over mounted state
        vnode.el = cachedVNode.el
        vnode.component = cachedVNode.component
        // 结合runtime patch 流程 当激活时就不走mount
        vnode.shapeFlag |= ShapeFlags.COMPONENT_KEPT_ALIVE
      } else {
        keys.add(key)
      }
      // avoid vnode being unmounted
      // 结合runtime patch 流程 当卸载时就不走unmount
      vnode.shapeFlag |= ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE

      return vnode
    }
  },
}
// renderer 中关于 KeepAlive的逻辑
function baseCreateRenderer() {
  const processComponent = (
    n1: VNode | null,
    n2: VNode,
    container: RendererElement,
    anchor: RendererNode | null,
    parentComponent: ComponentInternalInstance | null
  ) => {
    // parentComponent 就是 keepalive
    if (n1 == null) {
      if (n2.shapeFlag & ShapeFlags.COMPONENT_KEPT_ALIVE) {
        ;(parentComponent!.ctx as KeepAliveContext).activate(
          n2,
          container,
          anchor,
          namespace,
          optimized
        )
      } else {
        // 正常mount mountComponent
      }
    } else {
      // 正常更新 updateComponent
    }
  }

  const mountComponent: MountComponentFn = (initialVNode) => {
    // initialVNode 是keepalive的vnode时,把对应的render传入进去,这逻辑其实不重要,只是为了封装复用
    // inject renderer internals for keepAlive
    if (isKeepAlive(initialVNode)) {
      ;(instance.ctx as KeepAliveContext).renderer = internals
    }
  }

  const unmount: UnmountFn = (vnode, parentComponent) => {
    // parentComponent 就是 keepalive
    const { shapeFlag } = vnode
    if (shapeFlag & ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE) {
      ;(parentComponent!.ctx as KeepAliveContext).deactivate(vnode)
      return
    }
  }
}

JavaScript 闭包实战:手写防抖与节流函数,优化高频事件性能

在前端开发中,我们经常会遇到高频触发的事件,比如:

  • 输入框 keyup 时的搜索建议(类似百度、VS Code 的智能补全)
  • 窗口 resize 时的布局重新计算
  • 页面滚动时的懒加载或返回顶部按钮显示
  • 鼠标 mousemove 时的拖拽预览

这些事件往往在短时间内被触发数百甚至上千次,如果每次都直接执行复杂的逻辑(如发 AJAX 请求、操作 DOM、计算布局),会严重消耗浏览器资源,导致页面卡顿、掉帧,甚至崩溃。

解决这类问题的核心方案就是函数防抖(debounce)函数节流(throttle) ,而它们的实现都离不开 JavaScript 的灵魂特性——闭包

本文将从实际场景出发,详细讲解防抖和节流的原理、区别、手动实现,并提供完整可运行的 HTML 示例,帮助你彻底掌握这一前端性能优化的必备技能。

什么是闭包?为什么能用于防抖节流?

闭包是指函数能够“记住”并访问其词法作用域中的变量,即使函数在外部作用域之外执行。

在防抖和节流中,我们需要:

  • 保存定时器 ID(用于清除或判断时间)
  • 记住上一次执行的时间戳
  • 保留正确的 this 指向和参数

这些变量必须在多次事件触发间“存活”下来,而不能每次都重新创建——这正是闭包的用武之地。

场景一:搜索输入框的 AJAX 请求优化

用户在搜索框输入关键词时,我们希望实时显示搜索建议(如百度输入“react”时下方出现的建议列表)。

如果不做任何处理,每次 keyup 都立即发送请求:

  • 用户输入“react”五个字符 → 触发 5 次请求
  • 网络开销大、服务器压力大
  • 用户体验差(快速输入时建议闪烁)

理想效果是:用户停止输入 500ms 后,才发送一次请求。

这正是防抖的典型应用场景。

函数防抖(debounce)原理与实现

防抖的核心思想:不管事件触发多少次,我只关心最后一次。在最后一次触发后的 delay 时间内如果没有新触发,才真正执行函数。

JavaScript

function debounce(fn, delay) {
  let timer = null; // 闭包中保存定时器 ID

  return function(...args) {
    const context = this;

    // 每次触发时,先清除上一次的定时器
    if (timer) {
      clearTimeout(timer);
    }

    // 重新设置定时器
    timer = setTimeout(() => {
      fn.apply(context, args);
      timer = null; // 执行完可可选清理
    }, delay);
  };
}

关键点解析:

  • timer 变量定义在 debounce 函数作用域中,被返回的函数“记住”(闭包)。
  • 每次事件触发都清除旧定时器,重新开始倒计时。
  • 只有在 delay 时间内没有新触发时,定时器才会执行 fn。
  • 使用 apply 保留正确的 this 和参数。

函数节流(throttle)原理与实现

节流的核心思想:在规定时间内,无论触发多少次,只执行一次。常用于限制执行频率。

典型场景:页面滚动时加载更多内容(scroll 事件),我们希望每 500ms 最多检查一次是否到达底部。

JavaScript

function throttle(fn, delay) {
  let last = 0; // 闭包中记录上次执行时间

  return function(...args) {
    const context = this;
    const now = Date.now();

    // 如果距离上次执行不足 delay,则不执行
    if (now - last < delay) {
      return;
    }

    // 执行并更新 last
    last = now;
    fn.apply(context, args);
  };
}

更常见的时间戳 + 定时器混合版(支持尾部执行):

JavaScript

function throttle(fn, delay) {
  let last = 0;
  let timer = null;

  return function(...args) {
    const context = this;
    const now = Date.now();

    // 如果还在冷却期,且没有定时器(避免重复设置)
    if (now - last < delay) {
      clearTimeout(timer);
      timer = setTimeout(() => {
        last = now;
        fn.apply(context, args);
      }, delay);
    } else {
      // 立即执行(领先执行)
      last = now;
      fn.apply(context, args);
    }
  };
}

这种实现兼顾了“固定频率执行”和“停止触发后仍能执行最后一次”。

防抖 vs 节流:如何选择?

特性 防抖 (debounce) 节流 (throttle)
执行时机 事件停止触发后 delay 时间执行一次 每隔 delay 时间执行一次
典型场景 搜索输入、表单提交验证 滚动加载、鼠标跟随、射击游戏射速
用户体验 等待用户“想好了”再响应 持续操作时保持流畅响应
实现复杂度 相对简单(setTimeout) 稍复杂(时间戳或定时器混合)

记忆口诀:

  • 需要“最后一次”执行 → 用防抖(如搜索)
  • 需要“持续但限频”执行 → 用节流(如滚动)

完整可运行示例

下面是一个完整的 HTML 文件,包含三个输入框:

  • 第一个:无优化,每次 keyup 都发请求
  • 第二个:防抖优化,停止输入 500ms 后发一次请求
  • 第三个:节流优化,每 500ms 最多发一次请求

HTML

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>防抖与节流演示</title>
  <style>
    body { font-family: Arial, sans-serif; padding: 20px; }
    input { display: block; margin: 20px 0; padding: 10px; width: 300px; font-size: 16px; }
    label { font-weight: bold; }
  </style>
</head>
<body>
  <div>
    <label>无优化(每次输入都请求)</label>
    <input type="text" id="undebounce" placeholder="快速输入观察控制台" />

    <label>防抖(停止输入500ms后请求)</label>
    <input type="text" id="debounce" placeholder="输入完成后才会请求" />

    <label>节流(每500ms最多请求一次)</label>
    <input type="text" id="throttle" placeholder="持续输入时会定期请求" />
  </div>

  <script>
    function ajax(content) {
      console.log('ajax request:', content);
    }

    // 防抖实现
    function debounce(fn, delay) {
      let timer = null;
      return function(...args) {
        const context = this;
        if (timer) clearTimeout(timer);
        timer = setTimeout(() => {
          fn.apply(context, args);
        }, delay);
      };
    }

    // 节流实现(时间戳 + 定时器混合版)
    function throttle(fn, delay) {
      let last = 0;
      let timer = null;
      return function(...args) {
        const context = this;
        const now = Date.now();
        if (now - last < delay) {
          clearTimeout(timer);
          timer = setTimeout(() => {
            last = now;
            fn.apply(context, args);
          }, delay);
        } else {
          last = now;
          fn.apply(context, args);
        }
      };
    }

    const inputA = document.getElementById('undebounce');
    const inputB = document.getElementById('debounce');
    const inputC = document.getElementById('throttle');

    const debouncedAjax = debounce(ajax, 500);
    const throttledAjax = throttle(ajax, 500);

    inputA.addEventListener('keyup', function(e) {
      ajax(e.target.value);
    });

    inputB.addEventListener('keyup', function(e) {
      debouncedAjax(e.target.value);
    });

    inputC.addEventListener('keyup', function(e) {
      throttledAjax(e.target.value);
    });
  </script>
</body>
</html>

打开浏览器控制台,分别在三个输入框快速输入,你会清晰看到三者的巨大差异。

现代框架中的应用

虽然原生 JS 需要手写,但现代框架/库已内置:

  • Lodash:_.debounce(fn, wait) 和 _.throttle(fn, wait)
  • Vue:@input.debounce="500ms"
  • React:可配合 useCallback + useRef 实现,或使用第三方如 use-debounce

但理解底层原理,能让你在复杂场景下自定义行为(如立即执行选项、取消功能等)。

最佳实践建议

  1. 搜索输入 → 防抖(节约资源,用户输入完成后响应)
  2. 滚动事件 → 节流(保持流畅)
  3. 按钮防止重复点击 → 防抖(delay 设为 1000ms)
  4. resize/scroll 计算复杂布局 → 节流
  5. 拖拽过程中实时预览 → 节流

结语

闭包是 JavaScript 最强大的特性之一,而防抖和节流则是它在性能优化领域最经典的应用体现。

通过合理使用防抖和节流,我们可以:

  • 大幅减少不必要的网络请求和计算
  • 提升页面响应速度和流畅度
  • 改善用户体验
  • 降低服务器压力

无论你是面试被问“手写防抖节流”,还是实际项目中遇到卡顿问题,这两个函数都是你工具箱中不可或缺的利器。

建议立即复制上面的完整示例到本地运行,亲身体验三者的差异——理论结合实践,你才能真正掌握。

前端性能优化,从理解闭包开始,从手写防抖节流起步。愿你的页面永远丝滑流畅!

《网页布局速通:8 大主流方案 + 实战案例》-pink老师现代网页布局总结

一、概述与目标

CSS 布局是网页设计的核心技术,主要用于控制页面元素的排列与呈现方式。目前主流的布局方案包括常规文档流布局、模式转换布局、弹性布局(Flexbox)、定位布局、网格布局(Grid)和多列布局。

接下来我们会逐一拆解它们的优缺点与适用场景,帮你快速看懂主流官网的布局实现思路。

二、常规文档流布局

这是浏览器的默认排版,是 CSS 布局的基础,页面大结构依靠块元素上下堆叠实现。包含块元素和行内元素,文档流方向默认从上到下、从左到右排列。

块元素(block) 独占一行,宽度默认撑满容器;可设置宽高,呈垂直排列;举例:div、p、h1~h6
行内元素(inline) 水平依次排列,容器宽度不足则换行;宽高由内容决定,无法直接设置;举例:span、img、strong

image.pngimage.png

三、模式转换布局

image.pngimage.png

如上图所示,需求要求我们把块级盒子展示为一行,或者要求行内元素有更大的点击范围,我们改怎么办呢?

那么就需要用到display转换, 我们可以将上面两种元素的display属性设置为inline-block, 可实现上述效果

image.pngimage.png

display转换为 inline-block后,可以设置宽高,又不用独占一行,这种特点让它可以广泛应用于让块级盒子一行显示或让行内盒子具备宽高的场景

属性值 是否独占一行 能否设置宽高 默认宽度
display: block ✔️ 撑满容器宽度
display: inline 由内容决定
display: inline-block ✔️ 由内容决定(可覆盖)

但是使用行内块元素需要注意: 元素间会有空隙,需要给父元素设font-size: 0,因此适合对间距要求不高的场景,如果精细排版建议用 Flex或Grid。

image.png

四、被逐渐替代的float

float最早是做”文字环绕”效果的,如下图所示

image.png

float可以让元素脱离文档流向左或向右浮动, 但这会导致父容器高度塌陷,从而影响周围元素的布局,例如下图1所示。而很多时候我们是不能给父容器规定高度的,它的高度取决于后台服务返回的数据量,例如京东的这个商品列表展示,随着鼠标的滚动,商品不断增多,高度不断增加,这个时候我们怎么办呢?

image.pngimage.png

这个时候我们就要进行清除浮动了,主要有以下四种方法

1、双伪元素清除浮动

image.png

2、单伪元素清除浮动

image.png

3、额外标签法:在浮动元素最后新增块级标签,但增加冗余标签

image.png

4、overflow 清除浮动:触发 BFC 包裹浮动元素
image.png

因为float问题太多, 要手动解决 “高度塌陷”,还得写额外代码清除浮动, 排版稍微复杂点就容易错位,对新手很不友好, 现在有更简单的 Flex/Grid 布局,又灵活又不存在上述问题,所以浮动就成 “时代的眼泪”了

五、弹性布局

Flexbox是Flexible Box Layout Module(弹性盒子布局模块)的缩写,可以快速实现元素的对齐、分布和空间分配。例如京东、淘宝、小米等主流网站都使用了flex布局,而且我们的低代码平台也可以设置元素为flex布局

image.pngimage.pngimage.png

我们为啥要使用flex布局呢?

以B站头部为例,想要实现下图的效果,三个块级元素并排在一行,实现两端对齐的效果,用之前的办法,可能要变成行内块、给margin或者padding来实现,或者干脆采用浮动的办法,那么实现垂直居中该怎么办呢?

垂直居中是传统布局的 “老大难”,有的同学可能说使用line-height,但是line-height是无法让块级的盒子垂直居中,这个时候我们可以使用flex,只需要三行代码(display: flex;align-items: center;justify-content: space-between;)就可以实现B站头部的布局效果,我们公司的官网头部也是类似的实现方案

image.pngimage.png

1、flex布局的核心

父控子:父盒子控制子盒子如何排列布局(父盒子称为容器,子盒子称为项目),控制属性要写在父元素身上;

轴方向:主轴默认水平、交叉轴默认垂直,可自定义。

2、flex的属性

父盒子属性

属性 作用说明 所有可选值
display 定义元素为 Flex 容器 flex
flex-direction 定义主轴方向(项目排列方向) row(默认,水平从左到右)、row-reverse(水平从右到左)、column(垂直从上到下)、column-reverse(垂直从下到上)
flex-wrap 控制项目是否换行 nowrap(默认,不换行)、wrap(换行,第一行在上)、wrap-reverse(换行,第一行在下)
justify-content 定义主轴上的对齐方式(项目整体分布) flex-start(默认,靠主轴起点)、flex-end(靠主轴终点)、center(居中)、space-between(两端对齐,项目间间距相等)、space-around(项目两侧间距相等)、space-evenly(项目间间距完全相等)
align-items 定义交叉轴上的对齐方式(单行时项目整体对齐) stretch(默认,拉伸填满容器)、flex-start(靠交叉轴起点)、flex-end(靠交叉轴终点)、center(垂直居中)、
align-content 定义多行时交叉轴上的对齐方式(仅当 flex-wrap: wrap 且内容换行时生效) stretch(默认,拉伸填满容器)、flex-start(靠交叉轴起点)、flex-end(靠交叉轴终点)、center(居中)、space-between(两端对齐)、space-around(项目行两侧间距相等)

项目属性:

属性 作用说明 所有可选值 / 取值规则
order 定义项目的排列顺序(默认 0,数值越小越靠前) 任意整数(正整数 / 负整数 / 0),无单位
flex-grow 定义项目的放大比例(默认 0,即不放大) 非负数字(0 / 正小数 / 正整数),无单位;数值越大,占剩余空间比例越高
flex-shrink 定义项目的缩小比例(默认 1,空间不足时等比缩小) 非负数字(0 / 正小数 / 正整数),无单位;设为 0 则空间不足时不缩小
flex-basis 定义项目在主轴方向上的初始大小(优先级高于 width/height) 1. 长度值(px/em/rem/% 等);2. auto(默认,取项目自身宽高);3. content(按内容自适应)
flex flex-grow、flex-shrink、flex-basis 的简写 1. 常用简写:- flex: 1 → 等价于 flex: 1 1 auto- flex: auto → 等价于 flex: 1 1 auto- flex: none → 等价于 flex: 0 0 auto2. 完整写法:flex:
align-self 覆盖容器的 align-items,单独定义某个项目的交叉轴对齐方式 auto(默认,继承容器 align-items)、stretch、flex-start、flex-end、center、baseline

3、使用场景

3.1实现基础横向并排 + 垂直居中(导航栏核心效果)

3 个子元素水平并排,且在父盒子中垂直居中(对应 B 站头部核心布局)

image.png

    /* 父容器(控制子元素) */
    .container {
     ...
      display: flex; /* 开启Flex */
      align-items: center; /* 交叉轴(垂直)居中 */
      ...
    }
  
3.2实现横向两端对齐(导航栏左右分布效果)

logo 居左、登录按钮居右,且两者都垂直居中(网页头部通用布局)。

image.png

  .container {
      ...
      display: flex;
      align-items: center;
      justify-content: space-between; /* 主轴(水平)两端对齐 */
     ...
    }
3.3实现横向平均分布(卡片列表效果)

3 个卡片水平平均分布,间距一致(商品列表 / 功能入口常用)。

image.png

  .container {
      ...
    display: flex;
      align-items: center;
      justify-content: space-around; /* 主轴平均分布(项目两侧间距相等) */
     ...
    }
3.4实现垂直排列(侧边栏)

子元素垂直排列(更改主轴方向),且垂直居中(侧边栏核心布局)。

image.png

  .container {
      ...
     display: flex;
      flex-direction: column; /* 更改主轴为垂直方向 */
      justify-content: center; /* 主轴(垂直)居中 */
      gap: 10px; /* 项目间距(替代margin) */
     ...
    }
3.5实现自动换行(响应式卡片)

元素超出父容器宽度自动换行(响应式布局核心)。

image.png

  .container {
      ...
     width: 800px;
     display: flex;
      flex-wrap: wrap; /* 超出容器宽度自动换行 */
      gap: 15px;
     ...
    }

 .item {
      width: 220px;
      height: 120px;
      ...
    }
3.6实现子元素占满剩余空间(搜索框布局)

搜索框自动占满左右元素的剩余空间(网页搜索栏通用布局)。

image.png

 .container {
      width: 800px;
      height: 80px;
      border: 1px solid #ccc;
      display: flex;
      align-items: center;
        ...
    }
    .left {
      width: 80px;
      height: 40px;
       ...
    }
    .search {
      flex: 1; /* 占满主轴剩余空间 */
      height: 40px;
      ...
    }
    .right {
      width: 80px;
      height: 40px;
      line-height: 40px;
     ...
    }
3.7实现整体居中(登录框 / 弹窗)

在页面中水平 + 垂直居中

image.png

body {
      margin: 0;
      height: 100vh; /* 占满视口高度 */
      display: flex;
      justify-content: center; /* 水平居中 */
      align-items: center; /* 垂直居中 */
       ...
    }
    .login-box {
      width: 400px;
      height: 300px;
      line-height: 300px;
        ...
    }
3.8实现自定义子元素顺序

元素显示顺序为 菜单 2 → 菜单 3 → 菜单 1(无需修改 HTML 结构,仅通过 CSS 调整)。

image.png

  .container {
      ...
    display: flex;
      align-items: center
     ...
    }
.item {
      width: 100px;
      height: 60px;
      ...
    }
    /* 自定义顺序(默认0,数值越小越靠前) */
    .item1 { order: 3; }
    .item2 { order: 1; }
    .item3 { order: 2; }

4、真实应用场景

4.1 百度图片-模仿瀑布流效果

image.pngimage.png

五个块级列容器通过 Flex 水平均分排列(各占父容器 1/5 宽度),每个列容器内垂直排布图片、按钮等内容。

4.2 京东-无限滚动展示商品列表 image.pngimage.png

父容器设 Flex 并允许换行,子元素通过媒体查询 + 宽高限制,实现不同屏幕下自动调整每行展示数量,超出则换行。

淘宝也跟京东一样,使用flex布局来实现的无限滚动展示商品,但是如果你需要更复杂的响应式布局,需精准控制行列、页面多模块分区时就要使用grid了

六、定位布局

定位布局是控制页面元素位置的核心技术,能实现元素脱离文档流、层叠、固定位置等效果。 例如下图中B站首页,很多效果都是使用定位布局实现的。

image.png

常见场景:

固定导航栏:页面滚动时,导航栏始终固定在视口顶部

吸顶效果:元素滚动到特定位置后固定

弹出 / 下拉菜单:鼠标悬浮时显示

悬浮效果:元素浮在其他元素上方

定位分类

  • 相对定位:元素相对自身原位置偏移,不脱离文档流,保留原占位
  • 绝对定位:元素相对最近的已定位父元素偏移,完全脱离文档流,不保留占位
  • 固定定位:元素相对浏览器视口固定,脱离文档流,滚动页面时位置不变
  • 粘性定位:元素在滚动到指定阈值前是相对定位,之后变为固定定位,结合两者特性

1、 场景一:子绝父相实现购物车效果

为什么用 “子绝父相”?

子元素用绝对定位:能浮在上方,且不占位置、不影响其他元素布局,而父元素用相对定位,让子元素能跟着父元素移动(作为定位参考),同时父元素保留原占位、不影响其他布局,例如下图。

image.png

<style>
    /* 父元素:购物车按钮(相对定位) */
    .cart-btn {
      position: relative; /* 父相 */
    ...
    }

    /* 子元素:数量标记(绝对定位) */
    .cart-count {
      position: absolute; /* 子绝 */
      top: -5px; /* 向上偏移 */
      right: -5px; /* 向右偏移 */
      width: 18px;
      height: 18px;
      ...
    }
  </style>
 <button class="cart-btn">
    我的购物车
    <span class="cart-count">3</span>
  </button>

小米官网swiper组件左右翻页的箭头也是采用子绝父相的做法,将左右箭头先使用top调整到50%的高度,然后再使用margin-top往上调整为自身高度的一半,从而实现在swiper中垂直居中效果,如下图所示

image.png

2、 场景二:固定定位实顶部导航栏和侧边悬浮导航

例如下图中官网导航栏和右侧悬浮按钮,就是使用固定定位实现的

image.pngimage.png

3、 场景三:粘性定位实现低代码卡片 tab 标签页吸顶效果

image.pngimage.png

七、网格布局

网格布局是二维布局模型,通过定义行(rows)和列(columns),精准控制网页元素的位置、尺寸,还能实现响应式设计。

网格布局具有上述优势,我们是不是可以抛弃弹性布局,全部使用网格布局呢?

事实上,实际开发中 flex 和 grid 常混用:

Flex:适合快速做一维布局、动态对齐内容(比如单行布局) 等线性排列场景

Grid:适合搭建复杂页面框架,可同时控制行和列的排列,实现真正的二维布局。

例如下图中B站首页布局就是 flex 和 grid 混用实现的

image.png

场景1:实现B站11列2行竖向排列导航栏效果,同时控行列

  /* 1列2行,竖向排列 */
    .bilibili-nav {
 ...
      display: grid;
      /* 核心:列优先排列(竖向填充) */
      grid-auto-flow: column;
      /* 定义2行(每行高度均分) */
      grid-template-rows: repeat(2, 1fr);
      /* 定义11列(每列宽度均分) */
      grid-template-columns: repeat(11, 1fr);
  ...
    }

image.png

场景2:实现阿里巴巴矢量图标库响应式卡片布局(适配手机 / 平板 / PC)

如下图效果,可以直接使用grid布局实现,无须借助媒体查询

...
    /* 卡片网格容器 */
    .card-grid {
      display: grid;
      gap: 20px; /* 卡片之间的水平+垂直间距(无需margin,避免重叠) */
      /* 核心:自动适配列数,列宽最小250px,最大自适应 */
      grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
    }
...
 

image.pngimage.pngimage.png

image.pngimage.png

场景3:实现蔚来汽车官网“2 行 3 列 + 汽车图跨 2 列”效果

...
    /* 红框网格容器 */
    .nio-grid-container {
      display: grid;
      /* 行列比例:匹配2行3列+大元素跨列 */
      grid-template-columns: 2fr 1fr 1fr; 
      grid-template-rows: 1fr 1fr;
...
    }

    /* 1. 汽车图(跨1行2列) */
    .item-car {
      grid-area: 1 / 1 / 2 / 3; /* 行1-2,列1-3 → 跨2列 */
    }
    /* 2. 右上角“生长” */
    .item-grow {
      grid-area: 1 / 3 / 2 / 4;
    }

    /* 3. 中间右侧“11” */
    .item-11 {
      grid-area: 2 / 3 / 3 / 4;
    }

    /* 4. 左下角元素 */
    .item-left-bottom {
      grid-area: 2 / 1 / 3 / 2;
    }

    /* 5. 中间下元素 */
    .item-middle-bottom {
      grid-area: 2 / 2 / 3 / 3;
    }
  </style>

image.pngimage.pngimage.png

简单来说,Grid 是 “为复杂二维布局而生”,能以更少的代码实现更灵活、可控的布局,尤其适合页面框架、响应式卡片、复杂图文组合等场景。

八、多列布局

用于将元素内容自动分割为指定数量的垂直列,如下图效果。有些同学可能会说,下面的布局我们用flex或者grid也能做出来,那么为什么要再学习多列布局呢

因为如果使用flex或者grid布局,我们需要先准备三个盒子,然后再把内容装进去,而使用多列布局则不需要事先准备盒子,直接准备内容就可以了,如下代码所示

image.png

 /* 容器:设置多列 */
    .column-container {
      ...
      /* 多列核心属性 */
      column-count: 3; /* 分为3列 */
      column-gap: 10px; /* 列之间的间隙 */
      column-rule: 2px solid #4da6ff; /* 列分隔线 */
       ...
    }
    /* 子元素:不同高度模拟不规则布局 */
    .item {
      ...
      break-inside: avoid; /* 避免子元素被列分割 */
      ...
    }

适用场景

  1. 长文章分栏:文章自动分列,支持间隙、响应式效果,如语雀官网效果
  2. 图片瀑布流,如阿里巴巴矢量图标库

image.pngimage.png

九、总结

不同技术各有适用场景、优缺点,需配合使用:

  • 简单布局:优先用 Flexbox(一维)或 Grid(二维)
  • 复杂响应式布局:Grid + 媒体查询
  • 文本内容分栏:多列布局(column-count)
  • 兼容旧浏览器:浮动布局,或 Flexbox 降级方案
  • 趋势:CSS Grid 逐渐成为主流,适配更复杂布局场景

Set/Map+Weak三剑客的骚操作:JS 界的 “去重王者” ,“万能钥匙”和“隐形清洁工”

前言

家人们,咱写 JS 的时候是不是总被 “数组里的重复元素” 烦到挠头?是不是吐槽过 “对象的 key 只能是字符串,太死板了”?今天这俩 JS 界的 “宝藏工具人”——SetMap,直接给你把这些痛点按在地上摩擦!还有它们的 “低调兄弟” WeakSet/WeakMap,偷偷帮你解决内存泄漏,这波骚操作直接把 JS 玩明白了~

篇幅有点长,但是干货拉满!没搞懂你找我😄

一、Set:数组的 “洁癖管家”,重复元素一键劝退

先给 Set 下个性感定义长得像数组,却容不下任何重复成员,主打一个 “宁缺毋滥”。不管你往里面塞多少个一样的,它都只留一个!就是这么洁癖,你不服也得服!

1. 基础操作:add/delete/has/clear/size,一套组合拳

先来看add(往里面添加元素):

// 初始化一个空的Set实例
let s = new Set();
s.add(1); // 向 Set中添加数字1
s.add(2); // 向 Set中添加数字2
console.log(s);  

image.png

你会发现,咦?为啥打印出的结果是这个奇怪样子?前面还有个Set(2)是个什么玩意,其实这是控制台对 Set 实例“友好提示”——Set(2)表示这是一个包含 2 个成员的 Set 集合,后面跟着的{1, 2}才是 Set 里的具体成员,并不是打印结果 “奇怪”,而是控制台为了让你直观看到 Set 的类型和长度,特意做的格式展示~,这也印证了Set不属于数组。

所以这里我们如果用解构的方法就不会有前面的东西:

console.log(...s);   // 直接输出成员:1 2(解构为独立参数)
console.log([...s]); // 输出数组:[1, 2](转换为普通数组)

image.png

简单说,Set(2)只是控制台的 “类型标签”,不是 Set 本身的内容,真正的成员就是12,这也是 Set 和数组在控制台展示的核心区别~

在一起看看deletehasclear

let s = new Set([1, 2, 3, 4, 5]);
s.delete(2);  // 删除 Set中的元素2
console.log(s);  // 输出 Set(4) { 1, 3, 4, 5 },没有 2
console.log(s.has(3));  // 判断 Set中是否存在元素 3,ture
s.clear();  // 清空 Set中的所有元素,Set(0) {}
console.log(s);

image.png

❗️⭐当然这里有一个要注意的点:如果用has判断[]

let s = new Set([1, 2, 3, 4, 5]);
s.add([]);  // 增加一个[]
console.log(s.has([]));  // false,引用地址不一样

image.png

任何涉及到引用地址的,都会判断为false核心原因就是引用类型的 “地址唯一性”,数组是引用类型,每一次 [] 都会创建一个全新的、内存地址不同的数组对象。

Set 的 has 方法判断元素是否存在时,对于引用类型(数组、对象等),是通过 “内存地址是否一致” 来判断,而非值是否相同。因此 has([]) 找不到之前添加的那个数组,最终返回 false

最后就是用size获得set的长度(不要把数组的length搞混哦⚠️):

let s = new Set([1, 2, 3, 4, 5]);
console.log(s.size);

image.png

2. 最实用技能:数组去重!

这绝对是 Set“成名作”,一行代码解决数组重复问题,我们大部分时候用Set目的就是为了去重,好用的飞起,不需要再用for一个一个遍历啦!

const arr = [1, 2, 3, 2, 1];
let arr2 = [...new Set(arr)];  // Set 里面是允许存放数组的!
console.log(arr2);  // 解构的结果为 [ 1, 2, 3 ]

image.png

不只是数组,字符串也能去重:

const str = 'abcba';
console.log(new Set(str));

image.png

3. 遍历 Set:keys/values/entries/forEach,其实都差不多😝

Set 里的 “键” 和 “值” 是同一个东西(毕竟它是单值集合),所以keys()values()遍历出来的结果一毛一样。看似花里胡哨,实则逻辑超简单:

let set = new Set(['a','b','c']);

// 1. keys():获取Set的“键”(Set的键和值是同一个)
for(let key of set.keys()){
    console.log(key); // 依次输出a、b、c
}

// 2. values():获取Set的值,和keys()结果完全一致
for(let val of set.values()){
    console.log(val); // 依次输出a、b、c
}

// 3. entries():返回[key, value]形式的迭代器,键值相同
for(let item of set.entries()){
    console.log(item); // 依次输出['a','a']、['b','b']、['c','c']
}

// 4. forEach遍历:和数组forEach用法一致
set.forEach((val, key) => {
    console.log(key + ':' + val); // 依次输出a:a、b:b、c:c
});

image.png

4. ⚠ 遍历不改变原数组!return返回也没用!⚠️

当我们把 SetforEach 的特性结合起来时,还能发现更多有趣的细节 —— 比如用 Set 对数组去重后,再通过 forEach 修改数组元素,依然要遵循 “直接改 item 无效、需通过索引修改原数组” 的规则:

const arr = [1, 2, 3];
arr.forEach((item, i, array) => {
    item *= 10;  // 直接修改是没用滴!
})
console.log(arr); // 还是输出 [1, 2, 3]

image.png

const arr = [1, 2, 3];
arr.forEach((item, i, array) => {
    arr[i] = item * 10;  // 必须通过索引!
})
console.log(arr); // 输出 [10, 20, 30]

image.png

forEach 里的 return 也依旧无法终止遍历

const arr = [1, 2, 3];
arr.forEach((item, i, array) => {
    if(i < 2) {
        console.log(item);
        return;  // 正常打印完 1就退出,但是结果为 1,2
    }
})

image.png

这段代码既体现 Set “成员唯一” 的核心特性,又完整复现了 forEach 修改数组的关键规则 —— 直接操作 item 无法改变原数组、return 仅终止当前循环而非整个遍历,把 Set 和 forEach 的核心逻辑紧密串联了起来。

5.🌈判断能否遍历的小技巧

假设你不知道Set可以遍历,那怎么判断呢?一招搞定,那就是直接去浏览器上打印出一个Set对象,看看里面有没有iterator这个方法,如果有,那就👌(^o^)/~,大胆放心遍历!

image.png

二、Map:传统对象的 “超级进化版”,key 想放啥就放啥

传统 JS 对象的痛点:key 只能是字符串或 Symbol,想拿对象当 key?门都没有!但 Map 直接打破这个限制 —— 数字、数组、对象、甚至 null 和 undefined! 啥都能当 key,堪称 “万能键值对容器”

1. 基础操作:set/get/has/size/delete/clear(跟Map差不多!)

const m = new Map();
// 各种奇奇怪怪的 key 都能放
m.set('hello', 'world'); // key是字符串
m.set([], 1); // key是数组
m.set(null, 2); // key是null
console.log(m.size); // Map 的长度,输出 3

console.log(m.get(null)); // 输出 2,精准取到 null对应的值
console.log(m.has([])); // 输出 false!注意:数组是引用类型,这里的[]和set的[]不是同一个对象!
m.delete(null); // 删除 key为 null的项
m.clear(); // 清空 Map

image.png

2. 遍历 Map:比对象遍历爽多了

Map 天生支持遍历,不用像对象那样 “转数组再遍历”。好我现在假设不知道可以遍历,大声告诉我怎么办?😮看来你会了:

image.png

const m = new Map([['name', 'henry'], ['age', 18]]);
// 直接用for...of遍历,拿到[key, value]
for (let [key, val] of m) {
    console.log(key, val); // 输出name henry、age 18
}

image.png

这里依旧跟Map一样的问题 (引用地址不同)

const arrKey = [];
const m = new Map();
m.set(arrKey, '我是数组键的值');
console.log(m.get(arrKey)); // 输出"我是数组键的值"(引用地址一致)
console.log(m.get([])); // 输出undefined(新数组,地址不同)

image.png

三、WeakSet/WeakMap:JS 内存的 “隐形清洁工”,弱引用太香了

聊完 SetMap,必须提它们的 “低调兄弟”——WeakSetWeakMap,这俩主打一个 “弱引用”,堪称内存泄漏的 “克星”,一个守护 Set 体系,一个守护 Map 体系,分工明确又超实用!甚至有些前端开发者都不知道有这俩玩意!必须补充上⬆️!

1. WeakSet:Set 的 “内存友好版”,只存对象 + 自动回收

WeakSetSet“轻量版”,核心规则先划重点:

  • 只能存储对象类型(数字、字符串等原始类型一概不收,存了也白存);
  • 对存储的对象是弱引用:如果外部没有其他引用指向这个对象,垃圾回收机制会自动把 WeakSet 里的这个对象清理掉,绝不占内存;
  • 不可遍历(没有 keys ()/values ()/forEach 等遍历方法),也没有 size 属性,主打一个 “默默干活不露面”

错误示例:向WeakSet添加原始类型(会直接报错)

const wsError = new WeakSet();
try {
    // 尝试添加数字(原始类型),会抛出TypeError
    wsError.add(123); 
} catch (err) {
    console.log('报错信息:', err.message); // 输出:Invalid value used in weak set
}

image.png

正确示例:WeakSet仅存储对象+弱引用特性

// 1. 初始化WeakSet
const ws = new WeakSet();

// 2. 定义对象(只有对象能存入WeakSet)
let obj1 = { name: 'JS玩家1' };
let obj2 = { name: 'JS玩家2' };

// 3. 向WeakSet添加对象(正常生效,无报错)
ws.add(obj1);
ws.add(obj2);

// 4. 判断对象是否存在(返回布尔值)
console.log('obj1是否在WeakSet中:', ws.has(obj1)); // 输出:true
console.log('obj2是否在WeakSet中:', ws.has(obj2)); // 输出:true

// 5. 删除指定对象(返回布尔值,存在则删除并返回true)
ws.delete(obj2);
console.log('删除obj2后,obj2是否存在:', ws.has(obj2)); // 输出:false

// 6. 弱引用核心演示:外部销毁obj1的引用
console.log('销毁obj1前,obj1是否存在:', ws.has(obj1)); // 输出:true
obj1 = null; // 外部不再引用obj1
// 此时JS垃圾回收器(GC)会在合适时机自动清理WeakSet中obj1的引用
// 注意:无法通过代码直接验证回收结果(WeakSet不可遍历、无size属性),但原理是确定的

image.png

补充:WeakSet不支持的操作(避免踩坑)

try {
    // WeakSet无size属性,访问会报错
    console.log(ws.size); 
} catch (err) {
    console.log('访问size报错:', err.message); // 输出:ws.size is undefined
}

try {
    // WeakSet不可遍历,forEach会报错
    ws.forEach(item => console.log(item)); 
} catch (err) {
    console.log('forEach遍历报错:', err.message); // 输出:ws.forEach is not a function
}

image.png

2. WeakMap:Map 的 “内存友好版”,键仅对象 + 自动回收

WeakMapMap“专属内存管家”,核心规则和 WeakSet 呼应,更贴合键值对场景:

  • 键只能是对象类型(原始类型当键直接无效);
  • 对键的引用是弱引用:如果外部没有其他引用指向这个键对象,垃圾回收机制会自动回收这个键值对,彻底杜绝内存泄漏;
  • 不可遍历(没有 keys ()/values ()/entries () 等方法),也没有 clear () 方法,主打 “用完即走不拖沓”。
// 1. 初始化WeakMap
let wm = new WeakMap();

// 2. 定义对象作为键(符合 WeakMap的键要求)
let obj = {name: 'JS玩家'};

// 3. 添加键值对(键是对象,正常生效)
wm.set(obj, '这是WeakMap的值');

// 4. 查看值:成功获取
console.log(wm.get(obj)); // 输出:这是WeakMap的值

// 5. 外部销毁obj的引用
obj = null;
// 此时WeakMap中obj对应的键值对会被垃圾回收器自动清理(无法通过代码直接验证,是内存层面的行为)

// 6. 尝试用原始类型(字符串)当键:直接报错!
try {
    wm.set('hello', 'world'); // WeakMap不允许原始类型作为键,执行到这行就会抛错
    console.log(wm.get('hello')); // 这行代码永远不会执行
} catch (err) {
    console.log('错误原因:', err.message); // 输出:错误原因: Invalid value used as weak map key
}

image.png

3. 为啥需要 WeakSet/WeakMap?为啥有些开发者甚至不知道它俩?

比如做 DOM 元素的状态管理,用 WeakMap 存 DOM 元素对应的状态:

// 假设页面有个按钮元素
const btn = document.querySelector('#myBtn');

// 用 WeakMap存按钮的点击次数
const btnClickCount = new WeakMap();
btnClickCount.set(btn, 0);

// 按钮点击时更新次数
btn.addEventListener('click', () => {
    let count = btnClickCount.get(btn);
    btnClickCount.set(btn, count + 1);
    console.log('点击次数:', count + 1);
});

// 如果后续按钮被移除(比如btn = null),WeakMap里的键值对会自动回收,不会内存泄漏!
// 要是用普通Map,即使btn被移除,Map依然持有强引用,内存会一直被占用,这就是差距~

四、总结

1. 最后唠两句(核心点):

  • Set 核心是唯一值集合,主打数组去重,支持 add/delete/has/clear 等操作,无法通过索引取值;

  • Map 核心是万能键值对,键可以是任意类型,弥补传统对象短板,支持 set/get/delete/has/clear;

  • WeakSet/WeakMap 主打弱引用 + 自动回收,仅存 / 仅以对象为键,不可遍历,是解决内存泄漏的绝佳方案。

2. 一张表理清 Set/Map/WeakSet/WeakMap 核心区别:

特性 Set Map WeakSet WeakMap
存储形式 单值集合(无键值) 键值对集合 单值集合(仅对象) 键值对集合(键仅对象)
成员 / 键类型 任意类型 键:任意类型; 值:任意 仅对象 键:仅对象;值:任意
引用类型 强引用 强引用 弱引用 弱引用(仅键)
遍历性 可遍历 可遍历 不可遍历 不可遍历
内存回收 手动清空 手动清空 自动回收无引用对象 自动回收无引用键对象
特殊属性 有 size 有 size 无 size 无 size

结语

Set 就像 “去重神器”,解决数组重复问题手到擒来;Map“万能键值对”,弥补了传统对象的短板;WeakSet/WeakMap 则是 “内存管家”,默默帮你清理无用内存,杜绝泄漏。

记住核心用法

  • 去重、存唯一值 → 用 Set;
  • 非字符串键的键值对存储 → 用 Map;
  • 存对象且怕内存泄漏 → 存单值用 WeakSet,存键值对用 WeakMap。

把这四个玩明白,JS 数据存储的坑能少踩一大半,效率直接拉满!赶快用起来!

需要了解其他数据类型的读者可以看我的文章:栈与堆的精妙舞剧:JavaScript 数据类型深度解析

附上ES6的原文资料:es6.ruanyifeng.com/#docs/set-m…

用 React Context 实现全局主题切换:从零搭建暗黑/亮色模式系统

用 React Context 实现全局主题切换:从零搭建暗黑/亮色模式系统

在现代 Web 应用中,主题切换(如白天/夜间模式)已成为提升用户体验的标配功能。用户希望界面能随环境光线自动适应,或按个人偏好自由切换。然而,如何在 React 应用中高效、优雅地实现这一功能?答案就是:React Context + 自定义 Provider 封装

本文将带你从零开始,手把手构建一个完整的主题管理系统,涵盖状态共享、UI 响应、持久化存储等核心环节,并深入解析其背后的设计思想与最佳实践。


一、为什么需要 Context?告别“Props Drilling”之痛

假设我们想在应用顶部放一个“切换主题”按钮,而底部某个卡片组件需要根据主题改变背景色。若使用传统 props 传递:

<App theme={theme} toggleTheme={toggleTheme}><Header theme={theme} toggleTheme={toggleTheme}><Content><Card theme={theme} />

每一层组件都必须接收并透传 themetoggleTheme,即使它们自身并不使用。这种 “属性层层透传” (Props Drilling)不仅代码冗余,还导致组件耦合度高、难以维护。

React Context 正是为解决此类跨层级状态共享问题而生。它提供了一种机制:

父组件创建一个“数据广播站”,所有后代组件都能直接“收听”,无需中间人传话。


二、核心架构:三大组件协同工作

我们的主题系统由三个关键部分组成:

1. ThemeContext:数据通道

// contexts/ThemeContext.js
import { createContext } from 'react';
export const ThemeContext = createContext(null);
  • 使用 createContext(null) 创建一个全局可访问的上下文对象;
  • null 是默认值,当组件未被 Provider 包裹时返回。

2. ThemeProvider:状态管理 + 数据广播

// contexts/ThemeContext.js (续)
import { useState, useEffect } from 'react';

export default function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');
  
  const toggleTheme = () => {
    setTheme(t => t === 'light' ? 'dark' : 'light');
  };

  // 关键:同步主题到 HTML 根元素
  useEffect(() => {
    document.documentElement.setAttribute('data-theme', theme);
  }, [theme]);

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}
  • 状态管理:用 useState 维护当前主题('light' 或 'dark');
  • 操作封装toggleTheme 函数封装切换逻辑;
  • DOM 同步:通过 useEffect 将主题写入 <html data-theme="dark">,便于 CSS 选择器响应。

3. Header:消费主题状态

// components/Header.js
import { useContext } from 'react';
import { ThemeContext } from '../contexts/ThemeContext';

export default function Header() {
  const { theme, toggleTheme } = useContext(ThemeContext);
  
  return (
    <div style={{ marginBottom: 24 }}>
      <h2>当前主题: {theme}</h2>
      <button onClick={toggleTheme}>切换主题</button>
    </div>
  );
}
  • 使用 useContext(ThemeContext) 直接获取主题状态和切换函数;
  • 完全解耦:无需父组件传递 props,无论嵌套多深都能访问。

三、应用组装:自上而下的数据流

根组件 App:启动主题服务

// App.js
import ThemeProvider from './contexts/ThemeContext';
import Page from './Pages/Page';

export default function App() {
  return (
    <ThemeProvider>
      <Page />
    </ThemeProvider>
  );
}
  • 用 <ThemeProvider> 包裹整个应用,确保所有子组件处于主题上下文中。

页面组件 Page:透明中转

// Pages/Page.js
import Header from '../components/Header';

export default function Page() {
  return (
    <div style={{ padding: 24 }}>
      Page
      <Header />
    </div>
  );
}
  • Page 无需知道主题存在,直接渲染 Header,实现零耦合

四、CSS 如何响应主题变化?

虽然你的示例未使用 Tailwind,但原理相通。关键在于 利用 data-theme 属性编写条件样式

/* 全局样式 */
body {
  background-color: white;
  color: black;
}

/* 暗色模式覆盖 */
html[data-theme='dark'] body {
  background-color: #1a1a1a;
  color: #e0e0e0;
}

/* 组件级样式 */
.card {
  background: #f5f5f5;
}

html[data-theme='dark'] .card {
  background: #2d2d2d;
}

✅ 优势:

  • 不依赖 JavaScript 动态设置 class;
  • 样式集中管理,易于维护;
  • 支持服务端渲染(SSR)。

若使用 Tailwind CSS,只需配置 darkMode: 'class',然后写:

<div class="bg-white dark:bg-gray-900 text-black dark:text-white">

并通过 JS 切换 <html class="dark"> 即可。


五、进阶优化:持久化用户偏好

当前实现刷新后会重置为 'light'。要记住用户选择,只需两步:

1. 初始化时读取 localStorage

const [theme, setTheme] = useState(() => {
  if (typeof window !== 'undefined') {
    return localStorage.getItem('theme') || 'light';
  }
  return 'light';
});

2. 切换时保存到 localStorage

const toggleTheme = () => {
  const newTheme = theme === 'light' ? 'dark' : 'light';
  setTheme(newTheme);
  localStorage.setItem('theme', newTheme); // 👈 保存
};

💡 注意:需判断 window 是否存在,避免 SSR 报错。


六、设计思想:为什么这样封装?

1. 单一职责原则

  • ThemeContext:只负责创建通道;
  • ThemeProvider:只负责状态管理与广播;
  • Header:只负责 UI 展示与交互。

2. 高内聚低耦合

  • 中间组件(如 Page)完全 unaware 主题存在;
  • 新增组件只需调用 useContext,无需修改父组件。

3. 可复用性

  • ThemeProvider 可直接复制到新项目;
  • 配合自定义 Hook(如 useTheme())进一步简化调用。

七、常见陷阱与解决方案

问题 原因 解决方案
useContext 返回 null 组件未被 Provider 包裹 确保根组件正确包裹
切换无效 CSS 未响应 data-theme 检查选择器优先级
SSR 不一致 客户端/服务端初始状态不同 在 useEffect 中初始化状态
性能问题 高频更新导致重渲染 拆分 Context,避免大对象

八、总结:Context 是 React 的“神经系统”

通过这个主题切换案例,我们看到:

  • Context 不是“传数据”,而是“建通道”
  • Provider 是数据源,useContext 是接收器
  • 中间组件完全透明,实现极致解耦

这种模式不仅适用于主题,还可用于:

  • 用户登录状态
  • 国际化语言
  • 购物车数据
  • 应用配置

掌握 Context,你就掌握了 React 全局状态管理的第一把钥匙

未来,你可以在此基础上集成 useReducer 管理复杂状态,或结合 Zustand/Jotai 等轻量库进一步简化。但无论如何,理解 Context 的底层机制,永远是进阶之路的基石

现在,打开你的编辑器,亲手实现一个主题切换吧——让用户在白天与黑夜之间,自由穿梭! 🌓☀️

历史性突破!LCP 和 INP 终于覆盖所有主流浏览器,iOS 性能盲点彻底消失

我正在开发 DocFlow,它是一个完整的 AI 全栈协同文档平台。该项目融合了多个技术栈,包括基于 Tiptap 的富文本编辑器、NestJs 后端服务、AI 集成功能和实时协作。在开发过程中,我积累了丰富的实战经验,涵盖了 Tiptap 的深度定制、性能优化和协作功能的实现等核心难点。

如果你对 AI 全栈开发、Tiptap 富文本编辑器定制或 DocFlow 项目的完整技术方案感兴趣,欢迎加我微信 yunmz777 进行私聊咨询,获取详细的技术分享和最佳实践。

随着 Safari 26.2 在 12 月 12 日的发布,Web 性能领域迎来了一个令人振奋的年终礼物:最大内容绘制(LCP)和交互到下次绘制(INP)现已正式成为 Baseline 新可用功能。所有主流浏览器的最新版本现在都包含了测量这些指标所需的最大内容绘制 API 和事件计时 API。这是 Interop 2025 项目的一部分,很高兴看到这些功能在今年成功交付!

这意味着什么

核心 Web 指标(Core Web Vitals)已成为衡量网页体验的广泛采用标准,无论是对于 Web 开发者还是业务利益相关者而言都是如此。它们试图将复杂的 Web 性能故事总结为几个关键指标:页面加载速度(LCP)、交互响应速度(INP)以及内容稳定性(CLS)。

长期以来,这些指标只能在基于 Chromium 的浏览器(如 Chrome 和 Edge)中测量。在 iOS 设备上,由于所有浏览器都使用驱动 Safari 的 WebKit 浏览器引擎,这些指标完全不可用。这造成了一个盲点:网站可能不知道大量访问者正在经历完全不同的体验。虽然许多 Web 性能改进确实使所有浏览器受益,但某些技术和 API 仅在部分浏览器中可用。此外,浏览器内部的工作方式、页面加载方式以及处理交互的方式可能彼此不同。仅拥有网站性能的部分视图远非理想状态。

随着所有主流浏览器现在都支持这两个指标,我们现在可以更好地了解网站的关键加载和交互性能。这将使网站所有者能够更好地理解性能问题并识别可以进行的改进,最终使用户和业务指标受益。

其他浏览器的数据会进入 CrUX 吗?

不会。Chrome 用户体验报告(CrUX)仅基于符合条件的 Chrome 用户,这一点不会改变。这也适用于使用此数据的下游系统,如 PageSpeed Insights、Google Search Console 和 CrUX Vis。

这也将继续排除 Chrome iOS 用户,因为他们使用 WebKit 浏览器引擎。

如何从其他浏览器测量

CrUX 数据仍然作为网站性能的摘要很有用,并且可以与网络上的其他网站进行基准测试。然而,由于它是一个高级摘要,我们长期以来一直建议测量更详细的真实用户数据(field data)以帮助识别和改进性能。

真实用户监控(RUM)工具现在能够收集额外的真实用户数据,包括通过 Chrome 团队的 web-vitals 库测量的数据。在大多数情况下,这应该自动开始包含在您现有的解决方案中,但如果您有任何问题,请与您的 RUM 提供商确认。

请注意,RUM 和 CrUX 之间可能存在差异,现在这些指标在更多不包括在 CrUX 中的浏览器中可用,这种差异可能更加明显。

实现方式有什么不同吗?

虽然所有浏览器引擎在加载和显示网页方面大致执行相同的任务,但这些浏览器的构建方式存在许多差异,特别是在它们的渲染管道中,这些管道将网站的代码(主要是 HTML、CSS 和 JavaScript)转换为屏幕上的像素。

渲染循环的结束大致是可互操作的,被定义为 paintTime。然而,在这之后,有一个稍后的 presentationTime,这是特定于实现的,旨在指示像素实际绘制到屏幕上的时间。Chrome 测量 LCP 直到 presentationTime 结束,而 Firefox 和 Safari 不包括 presentationTime,因此测量到更早的 paintTime。这导致测量结果之间存在几毫秒的差异。从 Chrome 145 开始,paintTime 测量也将为 LCP 公开,以便那些希望能够在浏览器之间进行同类比较的人使用。

同样的差异也适用于 INP。

其他浏览器实现这些指标的事实,有助于识别一些需要澄清和更好定义的未解决问题。这再次可能导致轻微差异——尽管这些主要出现在边缘情况中。这就是拥有多个实现和关注 API 的好处!我们将继续致力于这些以及指标的任何其他改进。

然而,尽管存在这些小的差异,我们确信 LCP 和 INP 大致是可互操作的,因此我们很高兴它们被标记为 Baseline 新可用功能。那些实现 RUM 解决方案或深入研究数据的人可能会注意到其中一些差异,但 Web 开发者应该对跨浏览器测量这些指标充满信心,尽管存在这些微小差异。

不支持这些 API 的浏览器怎么办?

Baseline 新可用功能仅在所有主流浏览器的最新版本中可用。您的用户群可能不会立即升级,或者可能无法升级,这取决于他们的操作系统和提供商。30 个月后,它们将被视为 Baseline 广泛可用,因为大多数用户可能会使用支持这些功能的浏览器。

然而,作为测量 API 而不是网站的核心功能,您可以安全地为支持这些功能的浏览器测量这些指标——就像您到目前为止可能一直在做的那样。只需注意,您可能正在看到过滤后的用户视图——那些已升级的用户——特别是在最初的几个月里。

累积布局偏移(CLS)呢?

第三个核心 Web 指标是累积布局偏移(CLS),它不是 Interop 2025 项目的一部分——尽管它已被提议用于 Interop 2026。目前,除了基于 Chromium 的浏览器之外,它不受支持。

结论

Web Vitals 计划的目标是通过为 Web 平台创建一套标准 API 来改善 Web 性能,使关键指标能够被测量并被网站所有者广泛理解。很高兴看到这些指标中的两个现在得到了所有主流浏览器的支持。我们期待看到这些指标为网站所有者提供什么见解,以及这如何带来更好的用户体验!

参考来源: web.dev - LCP and INP are now Baseline Newly available

告别重复传参!用柯里化提升代码优雅度

柯里化:让函数“慢慢来”,一次只吃一口

在编程世界里,我们常常会遇到这样一种场景:一个函数需要多个参数才能完成任务。但有时候,这些参数并不会一下子全部准备好——可能今天知道第一个,明天拿到第二个,后天才凑齐全部。

这时候,柯里化(Currying) 就派上用场了。它就像一位耐心的厨师,不急着把整道菜做完,而是先记住你已经给的食材,等你把剩下的材料陆续送来,再一锅炒好。


从最简单的加法说起

假设我们有一个普通的加法函数:

function add(a, b) {
  return a + b;
}
console.log(add(1, 2)); // 输出 3

这很直接:两个数一起传进去,立刻出结果。但如果我只能先给你 a,过一会儿再告诉你 b 呢?普通函数就无能为力了。

于是我们可以手动“柯里化”一下:

function add(a) {
  return function(b) {
    return a + b;
  };
}
console.log(add(1)(2)); // 输出 3

你看,现在 add(1) 返回的是一个新函数,这个函数“记得”了 a = 1,等你再调用它传入 b,就能算出结果。这背后靠的是 闭包——内部函数可以“记住”外部函数的变量,即使外部函数已经执行完了。

这种写法虽然可行,但只适用于固定两个参数的情况。如果函数有三个、四个甚至更多参数,手动嵌套写起来会非常繁琐,而且难以复用


自动柯里化:通用解决方案

手动为每个函数写柯里化版本太麻烦了。有没有办法写一个“万能工具”,自动把任意多参函数变成可逐步传参的形式?

当然有!来看这个通用的 curry 函数:

function add(a, b, c, d) {
  return a + b + c + d;
}

function curry(fn) {
  return function curried(...args) {
    if (args.length >= fn.length) {
      return fn(...args); // 参数够了,直接执行
    }
    return (...rest) => curried(...args, ...rest); // 不够?继续收
  };
}

const curriedAdd = curry(add);
console.log(curriedAdd(1)(2)(3)(4)); // 10
console.log(curriedAdd(1, 2)(3, 4)); // 10,也可以一次传多个

它是怎么工作的?

  • fn.length 是 JavaScript 中函数的一个属性,表示该函数声明时定义的参数个数(不包括剩余参数)。
  • 每次调用 curried,都会收集当前传入的参数(通过 ...args)。
  • 如果当前参数数量 ≥ 原函数所需参数数量,就立即执行 fn(...args)
  • 否则,返回一个新函数,这个新函数会把已有的 args 和后续传入的 rest 合并,再次调用 curried —— 这就是递归的思想。

只要收集到足够数量的参数,就立刻执行;否则,返回一个新函数继续等待。

🔍 注意:这个实现利用了 闭包 + 递归 的思想。每次调用都把已有的参数“存起来”,直到攒够为止。而闭包保证了这些中间参数不会丢失。


实战:日志函数的柯里化妙用

柯里化不只是炫技,它在实际开发中非常有用。比如处理日志:

const log = type => message => {
  console.log(`${type}: ${message}`);
};

const errorLog = log('ERROR');
const infoLog = log('info');

errorLog('接口异常');        // 输出: ERROR: 接口异常
infoLog('页面加载完成');     // 输出: info: 页面加载完成

这里,log 是一个柯里化函数。我们先固定日志类型(如 'ERROR'),得到一个专门打错误日志的函数 errorLog。以后只要传消息内容就行,不用每次都写类型。

优势在哪里?

  1. 减少重复代码:不需要每次写 log('error', 'xxx')
  2. 提高可读性errorLog('xxx')log('error', 'xxx') 更直观。
  3. 便于组合与复用:可以轻松创建不同级别的日志器,并在多个模块中共享。

总结:柯里化的三大核心

  1. 闭包:保存已传入的参数,不会被垃圾回收。
  2. 递归/链式调用:每次返回新函数,继续接收剩余参数。
  3. 退出条件:当参数数量达到原函数要求(fn.length),就执行并返回结果。

柯里化不是必须用的技术,但它能让你的函数更灵活、更具组合性。就像乐高积木——你可以先拼好一部分,等需要时再接上其他模块,最终搭出完整作品。

下次当你发现某个函数总是在不同地方传相同的前几个参数时,不妨试试柯里化——让函数学会“等一等”,说不定代码会变得更优雅!

那些让你 debug 到凌晨的陷阱,我帮你踩平了:React Hooks 避坑指南

前言

React 函数组件的世界里,Hooks 无疑是 “效率神器”—— 它用极简的 API 封装了状态管理与生命周期逻辑,让我们告别了类组件的繁琐。但就像童话里藏在糖果屋后的陷阱,这些看似友好的 API 背后,藏着不少因 “闭包特性”“依赖逻辑”“异步处理” 引发的。很多开发者刚上手时,总在 “写得通” 和 “写得对” 之间反复踩坑,debug 到怀疑人生。

我在刚入手时踩了不少 “坑”,所以我将用我的亲身经历帮你们填平。本文先快速梳理 Hooks 核心基础,再聚焦那些最容易中招的 “陷阱”,用代码案例拆解坑因、给出避坑方案,帮你避开 Hooks 路上的 “连环雷”!

一、先搭个 Hooks 小舞台

React Hooks 就像给函数组件开了挂 —— 不用写类就能拥有状态和生命周期,先来快速回顾下 “基础三件套”

1. useState:状态管理的 “入门钥匙”

它能接收同步函数(注意:异步代码会翻车!),返回 “状态 + 修改状态的方法”。修改状态时,setXxx 既可以直接传值,也能传一个 “接收旧状态、返回新状态” 的函数(这个细节是避坑关键!)。

比如State.jsx里的例子:

import { useState } from "react";
function getDate() {
    return new Promise((resolve) => {
        setTimeout(() => { resolve(100) }, 1000)
    })
}
export default function State() {
    // ❌ 错误示范:useState不支持异步函数
    // const [num, setNum] = useState(async () => {
    //   const res = await getDate();
    //   return res;
    // });

    // ✅ 正确:同步函数初始化状态
    const [num, setNum] = useState(() => {
        return 1;
    });

    function add() {
        // ✅ 用函数形式获取“修改前的旧状态”
        setNum((prev) => {
            console.log(prev); // 点击时打印当前num(修改前)
            return prev + 1;
        })
    }
    return (
        <div onClick={add}>{num}</div>
    )
}

2. useEffect:生命周期的 “伪装者”

它像个 “多面手”,能模拟组件的 “挂载、更新、卸载”,但用法不对就会踩坑:

  • useEffect(() => {}):组件初次加载 + 每次重渲染都触发
  • useEffect(() => {}, []):只在初次加载时触发
  • useEffect(() => {}, [x]):初次加载 + x 变化时触发
  • 返回的函数:组件卸载前执行(用来做清理,比如清定时器)

Effect.jsx里的定时器例子:

import { useState, useEffect } from "react";
async function getData() {
    const data = new Promise((resolve) => {
        setTimeout(() => { resolve(100) }, 1000)
    })
    return data;
}
export default function Effect() {
    const [num, setNum] = useState(() => { return 1; });
    const [age, setAge] = useState(18);

    // 🌰 依赖项的坑(后面细说)
    // useEffect(() => {
    //   getData().then((data) => {
    //     console.log(data);
    //     setNum(data);
    //   })
    // }, [age]) // 依赖age,但实际修改的是num…

    useEffect(() => {
        // 启动定时器
        const timer = setInterval(() => {
            // setNum(num + 1); // ❌ 这里有坑!后面讲
        }, 1000)
        // 组件卸载前清理定时器(避免内存泄漏)
        return () => {
            clearInterval(timer);
        }
    })

    function add() {
        setNum((prev) => {
            return prev + 1;
        })
    }
    return (
        <div onClick={add}>{num}---{age}</div>
    )
}

3. useReducer:复杂状态的 “调度员”

当状态逻辑比较复杂时,useReduceruseState更清晰 —— 它把 “状态修改逻辑” 抽成reducer函数,通过dispatch触发:

import { useReducer } from "react"
// 状态修改的“规则函数”
function reducer(state, action) {
    switch(action.type) {
        case 'add':
            return state + action.num;
        case 'minus':
            return state - action.num;
        default:
            return state;
    }
}
export default function Trap() {
    // 初始化状态为0,dispatch用来触发reducer
    const [count, dispatch] = useReducer(reducer, 0);
    
    // 触发“add”操作,传参num=1
    dispatch({type: 'add', num: 1});
    
    return (
        <div>{count}</div>
    )
}

以上我们就大致回顾了hooks的一些基础知识,如果想看更具体的请看我的文章:

一场组件的进化脱口秀——React从 “类” 到 “hooks” 的 “改头换面”

二、重点来了:Hooks 的 “陷阱”

前面都是铺垫,这些看似简单的 Hooks,藏着能让你 debug 到天亮的坑—— 接下来逐个拆解:

陷阱 1: useState 的 “异步更新”+“闭包陷阱”

Effect.jsx里的定时器:

useEffect(() => {
    const timer = setInterval(() => {
        setNum(num + 1); // ❌ 这里有问题!
    }, 1000)
    return () => clearInterval(timer);
})

坑在哪?

useEffect默认没有依赖项,会在组件每次重渲染时重新执行 —— 但定时器里的num是 “闭包捕获的旧值”,导致num + 1永远只在初始值1的基础上加,页面不会更新。

怎么填坑?

setNum的 “函数形式”,它能拿到最新的旧状态:

setNum((prev) => prev + 1); // ✅ 不管闭包,直接拿最新prev

陷阱 2:useEffect 的 “依赖项迷路”

再看Effect.jsx里被注释的代码:

useEffect(() => {
    getData().then((data) => {
        setNum(data); // 修改num
    })
}, [age]) // ❌ 依赖项写了age,但实际没用到age

坑在哪?

useEffect的依赖项数组必须包含 “回调里用到的所有外部变量”—— 这里回调里没用到age,却把age当依赖,会导致age变化时重复请求;反过来,如果用到了某个变量却没写进依赖,就会拿到旧值 (闭包陷阱)

怎么填坑?

依赖项要 “诚实”:用到啥就写啥,没用到就别写。比如上面的代码,要么把age从依赖里删掉,要么在回调里真正用到age

陷阱 3:useReducer 的 “dispatch 不是万能药”

Trap.jsx里的定时器注释:

useEffect(() => {
    setInterval(() => {
        // console.log(count); // ❌ 永远打印初始值0
        // setCount(count + 1); // 同样的闭包坑
        // setCount((prev) => prev + 1) // ✅ 用函数形式才对
    }, 1000)
}, [])

坑在哪?

哪怕用了useReducer,如果在useEffect(依赖为空)里用count,还是会因为闭包捕获旧值,导致拿到的count永远是初始值。

怎么填坑?

useState一样,修改状态时用 “函数形式”;或者把count加入useEffect的依赖项(但要注意重复触发的问题)。

陷阱 4:useState 的 “异步初始化”

State.jsx里的注释:

// ❌ useState不支持异步函数初始化
// const [num, setNum] = useState(async () => {
//     const res = await getDate();
//     return res;
// });

坑在哪?

useState的初始化函数必须是同步的 —— 异步代码会直接返回一个Promise,而不是你想要的结果。

怎么填坑?

把异步初始化逻辑放到useEffect里:

const [num, setNum] = useState(0);
useEffect(() => {
    getDate().then(res => setNum(res));
}, [])

三、避坑总结: Hooks 的 “生存法则”

  1. useState 修改状态,优先用函数形式setXxx(prev => prev + 1),避免闭包旧值。
  2. useEffect 依赖项要 “完整且诚实” :回调里用到的变量,必须写进依赖数组;没用到的,别瞎写。
  3. 异步逻辑别往 useState 初始化里塞:交给useEffect(依赖为空)来做。
  4. 定时器 / 订阅要记得清理:在useEffect的返回函数里做卸载前的清理(比如清定时器)。

结语

Hooks 的陷阱,本质上大多是对 “闭包” “React 渲染机制” 和 “Hooks 设计规则” 理解不透彻的结果。没有绝对 “万能” 的 API,只有 “用对场景” 的用法 —— 比如 useState 的函数式更新、useEffect 的依赖项规范,这些看似细节的点,恰恰是避开陷阱的关键。希望本文的案例能帮你跳出 “踩坑 - debug - 再踩坑” 的循环,在使用 Hooks 时更从容、更精准。

记住:

好的代码不是 “写出来的”,而是 “避坑避出来的”,多理解底层逻辑,少依赖 “经验主义”,才能真正掌握 Hooks 的精髓

JavaScript 函数柯里化:从入门到实战,一文搞定(面试可用)

前言

最近在复习 JavaScript 高阶函数的时候,又把函数柯里化(Currying)翻出来好好捋了一遍。很多人一听到“柯里化”就觉得高大上,其实它没那么神秘,用通俗的话说,就是把一个接受多个参数的函数,变成一个个只接受一个参数的函数链条。

这篇文章就把我自己的学习笔记整理了一下,从最基础的对比开始,慢慢讲到原理、实现、实际用法,希望能帮你把这个知识点彻底吃透。

1. 先看一个最直观的对比

普通写法:

function add(a, b) {
  return a + b;
}

console.log(add(1, 2)); // 3

柯里化写法:

function add(a) {
  return function (b) {
    return a + b;
  };
}

console.log(add(1)(2)); // 3

看到区别了吗?

  • 普通版:一次把所有参数传完。
  • 柯里化版:参数一个一个传,每次调用返回一个新函数,直到所有参数收集齐了才真正计算。

这种“一个一个传”的方式,就是函数柯里化的核心。

2. 柯里化的本质:闭包 + 参数收集

为什么能一个一个传?靠的是闭包。

在外层函数里,参数 a 被保存了下来(成了闭包里的自由变量),内层函数可以随时访问它。当我们再传进来 b 的时候,就可以用之前保存的 a 去计算。

所以说,柯里化本质上就是利用闭包把参数“攒”起来,等参数够了再执行真正的逻辑。

3. 怎么判断参数“够了”?

JavaScript 函数有一个隐藏属性 length,它表示函数定义时参数的个数(不包括剩余参数和默认参数)。

function add(a, b) {
  return a + b;
}

console.log(add.length); // 2

我们可以利用这个属性来做一个相对严谨的柯里化判断:只有当收集到的参数数量 ≥ 原函数的 length 时,才真正执行。

4. 手写一个通用柯里化函数

下面这个是我自己最常用的一版:

function curry(fn) {
  return function curried(...args) {
    if (args.length >= fn.length) {
      return fn(...args); // 直接展开,更清晰
    }
    return (...more) => curried(...args, ...more); // 这里也用展开合并
  };
}

// 测试
function add(a, b, c) {
  return a + b + c;
}

const curriedAdd = curry(add);

console.log(curriedAdd(1)(2)(3));     // 6
console.log(curriedAdd(1, 2)(3));     // 6
console.log(curriedAdd(1)(2, 3));     // 6
console.log(curriedAdd(1, 2, 3));     // 6

这个版本的好处:

  • 支持任意数量的参数逐步传递
  • 也支持一次传多个(只要总数够了就执行)
  • 实现只有十来行,容易理解和记忆

注意:这里用了递归 + 闭包,外层每次调用都会产生新的 curried 函数,args 会被不断累加,直到满足条件。

5. 柯里化的经典实战场景

说了那么多,这么麻烦的编写柯里化函数,它到底能做什么呢?

场景一:固定部分参数,制造专用的工具函数

// 通用日志函数
const log = (type) => (message) => {
  console.log(`[${type.toUpperCase()}]: ${message}`);
};

// 通过柯里化“固定”日志类型,得到专用函数
const errorLog = log('error'); // 第一个参数
const infoLog = log('info');
const warnLog = log('warn');
// 第二个参数
errorLog('接口 404 了!');         // [ERROR]: 接口 404 了!
infoLog('页面加载完成');           // [INFO]: 页面加载完成
warnLog('即将弃用旧 API');        // [WARN]: 即将弃用旧 API

这种写法在实际项目里特别常见,尤其是做日志、埋点、事件绑定的时候,能让代码语义更清晰。

场景二:延迟执行 / 参数复用

比如我们有一个通用的 Ajax 请求函数:

function ajax(method, url, data) {
  // ...真正的请求逻辑
}

// 柯里化后
const get = curry(ajax)('GET');
const post = curry(ajax)('POST');

const fetchUserList = get('/api/users');
const fetchUserDetail = get('/api/users/');

const submitForm = post('/api/submit');

这样每次调用时就不用反复写 method,代码更简洁,也更不容易写错。

场景三:配合函数式编程库(如 lodash、ramda)

lodash 的 _.curry 功能更强大,支持占位符 __ 来跳过某些参数:

const _ = require('lodash');

const join = (sep, ...arr) => arr.join(sep);
const curryJoin = _.curry(join);

const dotJoin = curryJoin('.');
dotJoin('a', 'b', 'c'); // "a.b.c"

不过日常项目里,自己手写一个简单版往往就够用了。

6. 柯里化的优缺点总结

优点:

  1. 参数复用:固定前几个参数,快速生成新函数
  2. 延迟执行:参数没收集齐之前不会真正运行
  3. 让代码更函数式、更声明式,阅读性更好(尤其配合管道操作)

缺点:

  1. 产生大量闭包和中间函数,性能略有损耗(现代引擎优化后影响很小)
  2. 调试时调用栈会变深一点
  3. 如果滥用,会让代码看起来“太巧妙”,反而降低可读性

所以我的建议是:合适的地方用,别为了柯里化而柯里化。

最后

函数柯里化其实就是一个很小的技巧,但用好了能让你的代码更优雅、更灵活。尤其是当你开始接触函数式编程、React 高阶组件、Redux 中间件这些场景时,会发现柯里化的影子到处都是。

希望这篇从零开始的整理,能帮你把柯里化彻底搞明白。欢迎在评论区分享你用柯里化写过的有趣代码,或者你踩过的坑~

JS复杂去重一定要先排序吗?深度解析与性能对比

引言

在日常开发中,数组去重是JavaScript中常见的操作。对于简单数据类型,我们通常会毫不犹豫地使用Set。但当面对复杂对象数组时,很多开发者会产生疑问:复杂去重一定要先排序吗?

这个问题背后其实隐藏着几个更深层次的考量:

  • 排序是否会影响原始数据顺序?
  • 排序的性能开销是否值得?
  • 是否有更优雅的解决方案?

1. 常见的排序去重方案

1.1 传统的排序去重思路
// 先排序后去重的经典写法
function sortThenUnique(arr, key) {
  return arr
    .slice()
    .sort((a, b) => {
      // 避免修改原始数组
      const valueA = key ? a[key] : a;
      const valueB = key ? b[key] : b;
      if (valueA < valueB) return -1;
      if (valueA > valueB) return 1;
      return 0;
    })
    .filter((item, index, array) => {
      if (index === 0) return true; // 保留第一个元素
      const value = key ? item[key] : item;
      const prevValue = key ? array[index - 1][key] : array[index - 1];
      return value !== prevValue; // 仅保留与前一个元素不同的元素
    });
}
1.2 排序去重的优缺点

优点:

  • 代码逻辑相对直观
  • 对于已排序或需要排序的数据,可以一步完成
  • 在某些算法题中可能是必要步骤

缺点:

  • 时间复杂度至少为 O(n log n)
  • 改变了原始数据的顺序
  • 对于不需要排序的场景是额外开销

2. 不排序的去重方案

2.1 基于Map的保持顺序方案
function uniqueByKey(arr, key) {
  const seen = new Map();
  const result = [];

  for (const item of arr) {
    const keyValue = item[key];
    if (!seen.has(keyValue)) {
      seen.set(keyValue, true);
      result.push(item);
    }
  }
  return result;
}

// 支持多个字段的复合键
function uniqueByMultipleKeys(arr, keys) {
  const seen = new Set();
  return arr.filter((item) => {
    const compositeKey = keys.map((key) => item[key]).join("|");
    if (seen.has(compositeKey)) {
      return false;
    }
    seen.add(compositeKey);
    return true;
  });
}
2.2 基于对象的缓存方案
function uniqueByKeyWithObject(arr, key) {
  const cache = {};
  return arr.filter((item) => {
    const keyValue = item[key];
    if (cache[keyValue]) {
      return false;
    }
    cache[keyValue] = true;
    return true;
  });
}
2.3 基于自定义比较函数的方案
function uniqueWithCustomComparator(arr, comparator) {
  return arr.filter((current, index, self) => {
    // 查找第一个相同元素的位置
    return self.findIndex((item) => comparator(item, current)) === index;
  });
}

// 使用示例
const users = [
  { id: 1, name: "Alice", age: 25 },
  { id: 2, name: "Bob", age: 30 },
  { id: 1, name: "Alice", age: 25 }, // 重复
  { id: 1, name: "Alice", age: 26 }, // ID相同但年龄不同
];

const uniqueUsers = uniqueWithCustomComparator(
  users,
  (a, b) => a.id === b.id && a.name === b.name
);

console.log(uniqueUsers);
// [ { id: 1, name: 'Alice', age: 25 }, { id: 2, name: 'Bob', age: 30 } ]

3. 性能对比分析

3.1 时间复杂度对比
方法 时间复杂度 空间复杂度 是否保持顺序
排序后去重 O(n log n) O(1) 或 O(n)
Map去重 O(n) O(n)
对象缓存去重 O(n) O(n)
filter + findIndex O(n²) O(1)
3.2 实际性能测试
// 性能测试代码示例
function generateTestData(count) {
  return Array.from({length: count}, (_, i) => ({
    id: Math.floor(Math.random() * count / 10), // 产生大量重复
    value: `item-${i}`,
    data: Math.random()
  }));
}

function runPerformanceTest() {
  const data = generateTestData(10000);
  
  console.time('Map去重');
  uniqueByKey(data, 'id');
  console.timeEnd('Map去重');
  
  console.time('排序去重');
  sortThenUnique(data, 'id');
  console.timeEnd('排序去重');
  
  console.time('filter+findIndex');
  uniqueWithCustomComparator(data, (a, b) => a.id === b.id);
  console.timeEnd('filter+findIndex');
}

测试结果趋势:

  • 数据量<1000:各种方法差异不大
  • 数据量1000-10000:Map方案明显占优
  • 数据量>10000:排序方案开始显现劣势

4. 应用场景与选择建议

4.1 什么时候应该考虑排序?
1.需要有序输出时
// 既要去重又要按特定字段排序
const getSortedUniqueUsers = (users) => {
  const uniqueUsers = uniqueByKey(users, 'id');
  return uniqueUsers.sort((a, b) => a.name.localeCompare(b.name));
};
2. 数据本身就需要排序时
// 如果业务本来就需要排序,可以合并操作
const processData = (data) => {
  // 先排序便于后续处理
  data.sort((a, b) => a.timestamp - b.timestamp);
  // 去重
  return uniqueByKey(data, 'id');
};
3.处理流式数据时
// 实时数据流,需要维持有序状态
class SortedUniqueCollection {
  constructor(key) {
    this.key = key;
    this.data = [];
    this.seen = new Set();
  }
  
  add(item) {
    const keyValue = item[this.key];
    if (!this.seen.has(keyValue)) {
      this.seen.add(keyValue);
      // 插入到正确位置维持有序
      let index = 0;
      while (index < this.data.length && 
             this.data[index][this.key] < keyValue) {
        index++;
      }
      this.data.splice(index, 0, item);
    }
  }
}
4.2 什么时候应该避免排序?
1.需要保持原始顺序时
// 日志记录、时间线数据等
const logEntries = [
  {id: 3, time: '10:00', message: '启动'},
  {id: 1, time: '10:01', message: '初始化'},
  {id: 3, time: '10:02', message: '启动'}, // 重复
  {id: 2, time: '10:03', message: '运行'}
];

// 保持时间顺序很重要!
const uniqueLogs = uniqueByKey(logEntries, 'id');
2.性能敏感的应用
// 实时渲染大量数据
function renderItems(items) {
  // 使用Map去重避免不必要的排序开销
  const uniqueItems = uniqueByKey(items, 'id');
  // 快速渲染
  return uniqueItems.map(renderItem);
}
3. 数据不可变要求
// React/Vue等框架中,避免改变原数组
const DeduplicatedList = ({ items }) => {
  // 不改变原始数据
  const uniqueItems = useMemo(
    () => uniqueByKey(items, 'id'),
    [items]
  );
  return <List items={uniqueItems} />;
};

5. 高级技巧和优化

5.1 惰性去重迭代器
function* uniqueIterator(arr, getKey) {
  const seen = new Set();
  for (const item of arr) {
    const key = getKey(item);
    if (!seen.has(key)) {
      seen.add(key);
      yield item;
    }
  }
}

// 使用示例
const data = [...]; // 大数据集
for (const item of uniqueIterator(data, x => x.id)) {
  // 逐个处理,节省内存
  processItem(item);
}
5.2 增量去重
class IncrementalDeduplicator {
  constructor(key) {
    this.key = key;
    this.seen = new Map();
    this.count = 0;
  }
  
  add(items) {
    return items.filter(item => {
      const keyValue = item[this.key];
      if (this.seen.has(keyValue)) {
        return false;
      }
      this.seen.set(keyValue, ++this.count); // 记录添加顺序
      return true;
    });
  }
  
  getAddedOrder(keyValue) {
    return this.seen.get(keyValue);
  }
}
5.3 内存优化版本
function memoryEfficientUnique(arr, key) {
  const seen = new Map();
  const result = [];
  
  // 使用WeakMap处理对象键
  const weakMap = new WeakMap();
  
  for (let i = 0; i < arr.length; i++) {
    const item = arr[i];
    const keyValue = item[key];
    
    // 对于对象类型的键值,使用WeakMap
    if (typeof keyValue === 'object' && keyValue !== null) {
      if (!weakMap.has(keyValue)) {
        weakMap.set(keyValue, true);
        result.push(item);
      }
    } else {
      if (!seen.has(keyValue)) {
        seen.set(keyValue, true);
        result.push(item);
      }
    }
  }
  
  return result;
}

6. 实战案例分析

6.1 电商商品去重
// 场景:合并多个来源的商品数据
const productsFromAPI = [...];
const productsFromCache = [...];
const userUploadedProducts = [...];

// 需求:按商品SKU去重,保持最新数据
function mergeProducts(productLists) {
  const merged = [];
  const skuMap = new Map();
  
  // 按优先级处理(后处理的优先级高)
  productLists.forEach(list => {
    list.forEach(product => {
      const existing = skuMap.get(product.sku);
      if (!existing || product.updatedAt > existing.updatedAt) {
        if (existing) {
          // 移除旧的
          const index = merged.findIndex(p => p.sku === product.sku);
          merged.splice(index, 1);
        }
        merged.push(product);
        skuMap.set(product.sku, product);
      }
    });
  });
  
  return merged;
}
6.2 实时消息去重
// 场景:聊天应用消息去重
class MessageDeduplicator {
  constructor(timeWindow = 5000) {
    this.timeWindow = timeWindow;
    this.messageIds = new Set();
    this.timestamps = new Map();
  }
  
  addMessage(message) {
    const now = Date.now();
    const { id } = message;
    
    // 清理过期记录
    this.cleanup(now);
    
    // 检查是否重复
    if (this.messageIds.has(id)) {
      return false;
    }
    
    // 添加新记录
    this.messageIds.add(id);
    this.timestamps.set(id, now);
    return true;
  }
  
  cleanup(now) {
    for (const [id, timestamp] of this.timestamps) {
      if (now - timestamp > this.timeWindow) {
        this.messageIds.delete(id);
        this.timestamps.delete(id);
      }
    }
  }
}

结论

回到最初的问题:JS复杂去重一定要先排序吗?

答案是否定的。 排序只是众多去重策略中的一种,而非必需步骤。

我的建议:

  1. 默认使用Map方案: 对于大多数场景,基于Map或Set的去重方法在性能和功能上都是最佳选择。
  2. 根据需求选择:
  • 需要保持顺序 → 使用Map
  • 需要排序结果 → 先排序或后排序
  • 数据量很大 → 考虑迭代器或流式处理
  • 内存敏感 → 使用WeakMap或定期清理
  1. 考虑可读性和维护性: 有时清晰的代码比微小的性能优化更重要。
  2. 进行实际测试: 在性能关键路径上,用真实数据测试不同方案。

实践总结:

// 通用推荐方案
function deduplicate(arr, identifier = v => v) {
  const seen = new Set();
  return arr.filter(item => {
    const key = typeof identifier === 'function' 
      ? identifier(item)
      : item[identifier];
    
    if (seen.has(key)) return false;
    seen.add(key);
    return true;
  });
}

// 需要排序时的方案
function deduplicateAndSort(arr, key, sortBy) {
  const unique = deduplicate(arr, key);
  return unique.sort((a, b) => {
    const aVal = a[sortBy];
    const bVal = b[sortBy];
    return aVal < bVal ? -1 : aVal > bVal ? 1 : 0;
  });
}

记住,没有银弹。最合适的去重方案取决于你的具体需求:数据规模、顺序要求、性能需求和代码上下文。希望这篇文章能帮助你在面对复杂去重问题时做出明智的选择!

JavaScript 中的深拷贝与浅拷贝详解

深拷贝和浅拷贝是 JavaScript 中处理引用类型数据(对象、数组等)的核心概念,二者的本质区别在于是否复制引用类型的深层嵌套数据,直接影响数据操作的独立性,是开发中避免数据污染的关键。

一、先明确:为什么需要拷贝?(引用类型的特性)

JavaScript 数据类型分为两类,拷贝行为仅对引用类型有区分(原始类型为值传递,不存在深浅拷贝):

数据类型类别 包含类型 拷贝特性
原始类型 String、Number、Boolean、Null、Undefined、Symbol、BigInt 赋值 / 拷贝时传递「值本身」,修改新值不会影响原值
引用类型 Object(普通对象、数组、函数、正则等) 赋值 / 浅拷贝时传递「内存地址(引用)」,修改新数据会影响原数据;深拷贝才会复制数据本身,实现完全独立

示例:引用类型的默认赋值(引用传递,非拷贝)

// 引用类型:数组
const arr1 = [1, 2, { name: "张三" }];
const arr2 = arr1; // 仅传递引用,不是拷贝
arr2[0] = 100;
arr2[2].name = "李四";
console.log(arr1); // [100, 2, { name: "李四" }](原值被修改)
console.log(arr2); // [100, 2, { name: "李四" }]

二、浅拷贝(Shallow Copy):仅复制表层数据

1. 核心定义

浅拷贝是指只复制引用类型的表层属性(第一层数据) ,对于深层嵌套的引用类型(如对象中的对象、数组中的数组),仅复制其内存地址(引用),新旧数据的深层嵌套部分会共享同一块内存,修改其中一个的深层数据会影响另一个。

2. 常见实现方式

(1)数组浅拷贝

  • Array.prototype.slice()

    const arr1 = [1, 2, { age: 25 }];
    const arr2 = arr1.slice(); // 浅拷贝数组
    // 修改表层数据:不影响原值
    arr2[0] = 100;
    console.log(arr1[0]); // 1
    console.log(arr2[0]); // 100
    // 修改深层引用类型:影响原值
    arr2[2].age = 30;
    console.log(arr1[2].age); // 30(原值被修改)
    console.log(arr2[2].age); // 30
    
  • Array.prototype.concat()

    const arr1 = [1, 2, { age: 25 }];
    const arr2 = arr1.concat(); // 浅拷贝
    
  • 扩展运算符 [...arr]

    const arr1 = [1, 2, { age: 25 }];
    const arr2 = [...arr1]; // 浅拷贝
    

(2)对象浅拷贝

  • Object.assign(target, ...sources)

    const obj1 = { name: "张三", info: { age: 25 } };
    const obj2 = Object.assign({}, obj1); // 浅拷贝到空对象
    // 修改表层数据:不影响原值
    obj2.name = "李四";
    console.log(obj1.name); // 张三
    console.log(obj2.name); // 李四
    // 修改深层引用类型:影响原值
    obj2.info.age = 30;
    console.log(obj1.info.age); // 30(原值被修改)
    console.log(obj2.info.age); // 30
    
  • 扩展运算符 {...obj}

    const obj1 = { name: "张三", info: { age: 25 } };
    const obj2 = { ...obj1 }; // 浅拷贝
    

3. 浅拷贝的特点

  • 优点:实现简单、性能开销小,适合仅包含表层数据的引用类型;
  • 缺点:无法独立深层嵌套数据,修改深层数据会造成原数据污染;
  • 适用场景:只需复制表层数据,无需修改深层嵌套内容的场景(如展示数据副本、临时修改表层属性)。

三、深拷贝(Deep Copy):复制所有层级数据

1. 核心定义

深拷贝是指递归复制引用类型的所有层级数据,不仅复制表层属性,还会对深层嵌套的每个引用类型都创建独立的副本,新旧数据完全隔离,修改其中一个不会影响另一个,实现真正意义上的 “复制”。

2. 常见实现方式

(1)JSON 序列化 / 反序列化(简单场景首选)

通过 JSON.stringify() 将对象转为 JSON 字符串,再通过 JSON.parse() 解析为新对象,实现深拷贝。

const obj1 = { name: "张三", info: { age: 25 }, hobbies: ["篮球", "游戏"] };
const obj2 = JSON.parse(JSON.stringify(obj1)); // 深拷贝

// 修改表层数据:不影响原值
obj2.name = "李四";
// 修改深层数据:不影响原值
obj2.info.age = 30;
obj2.hobbies[0] = "足球";

console.log(obj1.name); // 张三
console.log(obj1.info.age); // 25
console.log(obj1.hobbies[0]); // 篮球
console.log(obj2.name); // 李四
console.log(obj2.info.age); // 30
console.log(obj2.hobbies[0]); // 足球

注意:JSON 方式的局限性(无法处理特殊类型)

  • 无法拷贝函数、正则表达式、Date 对象(会转为字符串 / 对象字面量,丢失原有特性);
  • 无法拷贝 Symbol 类型属性、undefined 类型属性(会被忽略);
  • 无法处理循环引用(如 obj.a = obj,会报错)。

(2)手动递归实现(灵活可控,支持特殊类型)

通过递归遍历对象 / 数组的每一层,对原始类型直接赋值,对引用类型创建新副本,可自定义处理特殊类型。

// 深拷贝工具函数
function deepClone(target) {
  // 1. 处理原始类型和 null
  if (typeof target !== "object" || target === null) {
    return target;
  }

  // 2. 处理 Date 对象
  if (target instanceof Date) {
    return new Date(target);
  }

  // 3. 处理 RegExp 对象
  if (target instanceof RegExp) {
    return new RegExp(target.source, target.flags);
  }

  // 4. 处理数组和普通对象(创建新副本)
  const result = Array.isArray(target) ? [] : {};

  // 5. 递归遍历,拷贝所有层级属性
  for (let key in target) {
    // 仅拷贝自身属性,不拷贝原型链属性
    if (target.hasOwnProperty(key)) {
      result[key] = deepClone(target[key]);
    }
  }

  return result;
}

// 测试
const obj1 = {
  name: "张三",
  info: { age: 25 },
  hobbies: ["篮球", "游戏"],
  birth: new Date("1999-01-01"),
  reg: /abc/gi,
  fn: () => console.log("hello")
};
const obj2 = deepClone(obj1);

obj2.info.age = 30;
obj2.birth.setFullYear(2000);
obj2.fn = () => console.log("world");

console.log(obj1.info.age); // 25(不影响原值)
console.log(obj1.birth.getFullYear()); // 1999(不影响原值)
console.log(obj1.fn()); // hello(函数独立)
console.log(obj2.fn()); // world

(3)第三方库(成熟稳定,推荐生产环境)

  • Lodash 库的 _.cloneDeep()(支持所有类型,处理循环引用)

    // 安装:npm i lodash
    const _ = require("lodash");
    
    const obj1 = { name: "张三", info: { age: 25 }, a: obj1 }; // 循环引用
    const obj2 = _.cloneDeep(obj1); // 深拷贝,正常处理循环引用
    
    obj2.info.age = 30;
    console.log(obj1.info.age); // 25
    
  • jQuery 库的 $.extend(true, {}, obj)(true 表示深拷贝)

    const obj1 = { name: "张三", info: { age: 25 } };
    const obj2 = $.extend(true, {}, obj1); // 深拷贝
    

3. 深拷贝的特点

  • 优点:新旧数据完全独立,修改任意一方不会影响另一方,避免数据污染;
  • 缺点:实现复杂(手动递归需处理多种特殊类型)、性能开销大(递归遍历所有层级);
  • 适用场景:需要修改拷贝后的数据,且数据包含深层嵌套引用类型的场景(如表单提交、状态管理、复杂数据处理)。

四、深拷贝 vs 浅拷贝 核心对比

对比维度 浅拷贝(Shallow Copy) 深拷贝(Deep Copy)
拷贝层级 仅拷贝表层(第一层)数据 递归拷贝所有层级数据
引用类型处理 深层嵌套引用类型仅复制内存地址(共享) 深层嵌套引用类型创建独立副本(不共享)
数据独立性 深层数据共享,修改会相互影响 完全独立,修改互不影响
实现难度 简单(原生 API 即可实现) 复杂(需处理特殊类型、循环引用)
性能开销 小(仅遍历表层) 大(递归遍历所有层级)
适用场景 表层数据拷贝、无需修改深层数据 复杂嵌套数据拷贝、需要独立修改数据
常见实现 数组:slice、concat、[...arr];对象:Object.assign、{...obj} JSON.parse (JSON.stringify ())、手动递归、_.cloneDeep ()

五、常见误区

  1. 认为 Object.assign 是深拷贝Object.assign 仅对第一层数据实现值拷贝,深层引用类型仍为引用传递,属于浅拷贝;
  2. JSON 方式能处理所有数据:JSON 序列化无法处理函数、正则、循环引用、Symbol 等类型,仅适用于简单 JSON 数据;
  3. 原始类型需要深浅拷贝:原始类型赋值时直接传递值,不存在引用,无需区分深浅拷贝;
  4. 深拷贝一定优于浅拷贝:深拷贝性能开销大,若数据无深层嵌套,浅拷贝更高效,无需过度使用深拷贝。

总结

  1. 核心区别:是否拷贝深层嵌套的引用类型,决定数据是否独立;
  2. 原始类型无深浅拷贝之分,引用类型才需要区分;
  3. 浅拷贝:简单高效,适合表层数据,推荐 [...arr]/{...obj}/Object.assign
  4. 深拷贝:完全独立,适合复杂嵌套数据,简单场景用 JSON.parse(JSON.stringify()),生产环境推荐 _.cloneDeep()
  5. 选型原则:根据数据结构选择,无需深层独立时优先浅拷贝,避免性能浪费。

面试和算法:常见面试题实现与深度解析

本文将深入探讨前端面试中常见的算法和编程题,提供多种实现方案和性能优化策略,帮助大家全面掌握核心面试技能。

1. 函数柯里化(Currying)

1.1 基础柯里化实现
/**
 * 基础柯里化函数
 * 将多参数函数转换为一系列单参数函数
 */
function curry(fn) {
  return function curried(...args) {
    // 如果参数数量足够, 直接执行原函数
    if (args.length >= fn.length) {
      return fn.apply(this, args);
    }
    // 否则返回一个新函数, 继续收集参数
    return function (...nextArgs) {
      return curried.apply(this, args.concat(nextArgs));
    };
  };
}
// 示例:加法函数柯里化
function add(a, b, c) {
  return a + b + c;
}

const curriedAdd = curry(add);

// 测试
console.log(curriedAdd(1)(2)(3)); // 6
console.log(curriedAdd(1, 2)(3)); // 6
console.log(curriedAdd(1)(2, 3)); // 6
1.2 高级柯里化实现(支持占位符)
/**
 * 高级柯里化函数, 支持占位符
 */
function advancedCurry(fn) {
  return function curried(...args) {
    // 检查参数是否足够且没有占位符
    const complete =
      args.length >= fn.length &&
      !args.slice(0, fn.length).includes(advancedCurry.placeholder);
    if (complete) {
      return fn.apply(this, args);
    }

    return function (...nextArgs) {
      // 替换占位符
      const combinedArgs = args
        .map((arg) =>
          arg === advancedCurry.placeholder && nextArgs.length
            ? nextArgs.shift()
            : arg
        )
        .concat(nextArgs);

      return curried.apply(this, combinedArgs);
    };
  };
}

// 定义占位符
advancedCurry.placeholder = Symbol("_");
// 示例使用
const curriedMultiply = advancedCurry((a, b, c) => a * b * c);

const _ = advancedCurry.placeholder;

// 测试
console.log(curriedMultiply(2)(3)(4)); // 24
console.log(curriedMultiply(_, 3)(2)(4)); // 24
console.log(curriedMultiply(2, _, 4)(3)); // 24

2. 函数组合(Compose)

2.1 基础函数组合
function compose(...fns) {
  return function(x) {
    return fns.reduceRight((acc, fn) => fn(acc), x);
  };
}

function pipe(...fns) {
  return function(x) {
    return fns.reduce((acc, fn) => fn(acc), x);
  };
}

// 测试
const add1 = x => x + 1;
const multiply2 = x => x * 2;
const square = x => x * x;

const composed = compose(square, multiply2, add1);
console.log(composed(2)); // 36

3. 斐波那契数列优化

3.1 多种实现对比
// 1. 递归(性能差)
function fibonacciRecursive(n) {
  if (n <= 1) return n;
  return fibonacciRecursive(n - 1) + fibonacciRecursive(n - 2);
}

// 2. 记忆化递归
function fibonacciMemo(n, memo = {}) {
  if (n <= 1) return n;
  if (memo[n]) return memo[n];
  memo[n] = fibonacciMemo(n - 1, memo) + fibonacciMemo(n - 2, memo);
  return memo[n];
}

// 3. 动态规划
function fibonacciDP(n) {
  if (n <= 1) return n;
  const dp = [0, 1];
  for (let i = 2; i <= n; i++) {
    dp[i] = dp[i - 1] + dp[i - 2];
  }
  return dp[n];
}

// 4. 空间优化
function fibonacciOptimized(n) {
  if (n <= 1) return n;
  let prev = 0, curr = 1;
  for (let i = 2; i <= n; i++) {
    const next = prev + curr;
    prev = curr;
    curr = next;
  }
  return curr;
}

4. 数组去重多种方法

4.1 基础方法
// 1. Set
function uniqueSet(arr) {
  return [...new Set(arr)];
}

// 2. filter + indexOf
function uniqueFilter(arr) {
  return arr.filter((item, index) => arr.indexOf(item) === index);
}

// 3. reduce
function uniqueReduce(arr) {
  return arr.reduce((acc, curr) => {
    if (!acc.includes(curr)) acc.push(curr);
    return acc;
  }, []);
}
4.2 复杂对象去重
function uniqueComplex(arr, keyFn) {
  const seen = new Map();
  const result = [];
  
  for (let item of arr) {
    const key = keyFn ? keyFn(item) : JSON.stringify(item);
    if (!seen.has(key)) {
      seen.set(key, true);
      result.push(item);
    }
  }
  
  return result;
}

// 测试
const users = [
  { id: 1, name: 'Alice' },
  { id: 1, name: 'Alice' },
  { id: 2, name: 'Bob' }
];
console.log(uniqueComplex(users, user => user.id));

5. 深比较(DeepEqual)

function deepEqual(a, b) {
  if (a === b) return true;
  
  if (a == null || b == null || typeof a !== 'object' || typeof b !== 'object') {
    return false;
  }
  
  if (Array.isArray(a) && Array.isArray(b)) {
    if (a.length !== b.length) return false;
    for (let i = 0; i < a.length; i++) {
      if (!deepEqual(a[i], b[i])) return false;
    }
    return true;
  }
  
  const keysA = Object.keys(a);
  const keysB = Object.keys(b);
  
  if (keysA.length !== keysB.length) return false;
  
  for (let key of keysA) {
    if (!keysB.includes(key) || !deepEqual(a[key], b[key])) {
      return false;
    }
  }
  
  return true;
}

6. 防抖与节流

防抖(Debounce) 节流(Throttle)

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

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

7. Promise实现

手写 Promise:深入理解 JavaScript 异步编程的核心

class MyPromise {
  constructor(executor) {
    this.state = 'pending';
    this.value = undefined;
    this.reason = undefined;
    this.onFulfilledCallbacks = [];
    this.onRejectedCallbacks = [];
    
    const resolve = (value) => {
      if (this.state === 'pending') {
        this.state = 'fulfilled';
        this.value = value;
        this.onFulfilledCallbacks.forEach(cb => cb());
      }
    };
    
    const reject = (reason) => {
      if (this.state === 'pending') {
        this.state = 'rejected';
        this.reason = reason;
        this.onRejectedCallbacks.forEach(cb => cb());
      }
    };
    
    try {
      executor(resolve, reject);
    } catch (error) {
      reject(error);
    }
  }
  
  then(onFulfilled, onRejected) {
    onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : v => v;
    onRejected = typeof onRejected === 'function' ? onRejected : e => { throw e };
    
    const promise2 = new MyPromise((resolve, reject) => {
      const handleFulfilled = () => {
        setTimeout(() => {
          try {
            const x = onFulfilled(this.value);
            this.resolvePromise(promise2, x, resolve, reject);
          } catch (error) {
            reject(error);
          }
        });
      };
      
      const handleRejected = () => {
        setTimeout(() => {
          try {
            const x = onRejected(this.reason);
            this.resolvePromise(promise2, x, resolve, reject);
          } catch (error) {
            reject(error);
          }
        });
      };
      
      if (this.state === 'fulfilled') {
        handleFulfilled();
      } else if (this.state === 'rejected') {
        handleRejected();
      } else {
        this.onFulfilledCallbacks.push(handleFulfilled);
        this.onRejectedCallbacks.push(handleRejected);
      }
    });
    
    return promise2;
  }
  
  resolvePromise(promise2, x, resolve, reject) {
    if (promise2 === x) {
      return reject(new TypeError('循环引用'));
    }
    
    let called = false;
    
    if (x && (typeof x === 'object' || typeof x === 'function')) {
      try {
        const then = x.then;
        if (typeof then === 'function') {
          then.call(
            x,
            y => {
              if (called) return;
              called = true;
              this.resolvePromise(promise2, y, resolve, reject);
            },
            r => {
              if (called) return;
              called = true;
              reject(r);
            }
          );
        } else {
          resolve(x);
        }
      } catch (error) {
        if (called) return;
        called = true;
        reject(error);
      }
    } else {
      resolve(x);
    }
  }
  
  catch(onRejected) {
    return this.then(null, onRejected);
  }
}

8. call、apply、bind实现

JavaScript 核心方法深度解析:手写 call、apply、bind 和 Object.create

Function.prototype.myCall = function(context = window, ...args) {
  const fnKey = Symbol('fn');
  context[fnKey] = this;
  const result = context[fnKey](...args);
  delete context[fnKey];
  return result;
};

Function.prototype.myApply = function(context = window, args = []) {
  const fnKey = Symbol('fn');
  context[fnKey] = this;
  const result = context[fnKey](...args);
  delete context[fnKey];
  return result;
};

Function.prototype.myBind = function(context = window, ...bindArgs) {
  const self = this;
  return function(...callArgs) {
    return self.apply(context, [...bindArgs, ...callArgs]);
  };
};

9. 事件总线(EventEmitter)

手写 EventEmitter:深入理解发布订阅模式

class EventEmitter {
  constructor() {
    this.events = new Map();
  }
  
  on(event, listener) {
    if (!this.events.has(event)) {
      this.events.set(event, []);
    }
    this.events.get(event).push(listener);
  }
  
  off(event, listener) {
    if (!this.events.has(event)) return;
    const listeners = this.events.get(event);
    const index = listeners.indexOf(listener);
    if (index > -1) listeners.splice(index, 1);
  }
  
  emit(event, ...args) {
    if (!this.events.has(event)) return false;
    this.events.get(event).forEach(listener => listener.apply(this, args));
    return true;
  }
  
  once(event, listener) {
    const onceWrapper = (...args) => {
      listener.apply(this, args);
      this.off(event, onceWrapper);
    };
    this.on(event, onceWrapper);
  }
}

10. LRU缓存

JavaScript性能与优化:手写实现关键优化技术 JavaScript 性能与优化:数据结构和算法

class LRUCache {
  constructor(capacity) {
    this.capacity = capacity;
    this.cache = new Map();
  }
  
  get(key) {
    if (!this.cache.has(key)) return -1;
    const value = this.cache.get(key);
    this.cache.delete(key);
    this.cache.set(key, value);
    return value;
  }
  
  put(key, value) {
    if (this.cache.has(key)) {
      this.cache.delete(key);
    } else if (this.cache.size >= this.capacity) {
      const firstKey = this.cache.keys().next().value;
      this.cache.delete(firstKey);
    }
    this.cache.set(key, value);
  }
}

11. 快速排序

JavaScript 数组原生方法手写实现 JavaScript 性能与优化:数据结构和算法

function quickSort(arr) {
  if (arr.length <= 1) return arr;
  
  const pivotIndex = Math.floor(arr.length / 2);
  const pivot = arr[pivotIndex];
  const left = [];
  const right = [];
  
  for (let i = 0; i < arr.length; i++) {
    if (i === pivotIndex) continue;
    if (arr[i] < pivot) {
      left.push(arr[i]);
    } else {
      right.push(arr[i]);
    }
  }
  
  return [...quickSort(left), pivot, ...quickSort(right)];
}

12. 二分查找

JavaScript 性能与优化:数据结构和算法

function binarySearch(arr, target) {
  let left = 0, right = arr.length - 1;
  
  while (left <= right) {
    const mid = Math.floor((left + right) / 2);
    if (arr[mid] === target) return mid;
    if (arr[mid] < target) {
      left = mid + 1;
    } else {
      right = mid - 1;
    }
  }
  
  return -1;
}

总结

本文涵盖了前端面试中常见的算法和编程题,包括函数柯里化、函数组合、斐波那契数列、数组去重、深比较、防抖节流、Promise实现等核心知识点。掌握这些内容有助于提升编程能力和面试表现。

关键要点:

  1. 函数柯里化: 理解函数式编程思想,掌握基础实现和高级功能
  2. 函数组合: 学会构建可复用的函数管道,支持同步和异步操作
  3. 算法优化: 掌握递归优化、动态规划、空间优化等技巧
  4. 数据处理: 了解不同去重方法的适用场景和性能差异
  5. 深度比较: 处理复杂对象的比较,包括循环引用和特殊类型
  6. 异步控制: 实现防抖和节流,优化高频事件处理
  7. Promise实现: 深入理解异步编程模型
  8. 原生方法实现: 掌握call、apply、bind的内部原理
  9. 设计模式: 实现事件总线,理解发布-订阅模式

这些知识点不仅是面试的常见考点,也是实际开发中的重要技能。建议大家不仅要理解代码实现,更要掌握背后的设计思想和适用场景。

一文搞懂 Tailwind CSS v4 主题变量映射背后的原理

我正在开发 DocFlow,它是一个完整的 AI 全栈协同文档平台。该项目融合了多个技术栈,包括基于 Tiptap 的富文本编辑器、NestJs 后端服务、AI 集成功能和实时协作。在开发过程中,我积累了丰富的实战经验,涵盖了 Tiptap 的深度定制、性能优化和协作功能的实现等核心难点。

如果你对 AI 全栈开发、Tiptap 富文本编辑器定制或 DocFlow 项目的完整技术方案感兴趣,欢迎加我微信 yunmz777 进行私聊咨询,获取详细的技术分享和最佳实践。

在 Tailwind CSS v4 中,theme variables(设计变量或主题变量)与页面样式之间的映射关系是一个值得深入理解的核心机制。本文将从底层原理、生成阶段、作用域和命名空间等维度,详细解析变量是如何一步步与页面和 class 建立起映射关系的。

总体架构概览

Tailwind CSS v4 引入了一个核心变化:design tokens(设计变量)默认以 CSS 自定义属性(CSS variables)的方式暴露,并结合新的 CSS-first 配置 DSL(@theme 指令在 CSS 中)来定义变量。这些 design tokens 不仅仅是值的存储,还直接决定哪些 utility classesresponsive 变体或 variant 等会被生成。

整个映射过程可以概括为以下四个步骤:

  1. 在 CSS 文件中,使用 @theme 定义主题变量(命名空间变量)
  2. Tailwind 构建阶段读取这些变量,生成对应的 CSS 自定义属性(在 :root 或主题作用域中)以及与这些变量关联的 utility classesvariants
  3. 在页面(HTML 或模板)中使用这些类名,或者通过 var(--theme-variable) 方式直接访问变量
  4. 如果需要主题切换、暗黑模式、亮色模式或其他主题,则在不同作用域中重写变量值,使同样的类名在 UI 上呈现不同的视觉效果

详细步骤拆解

下面我们将深入解析从代码编写到页面效果呈现的完整流程,揭示变量是如何被映射的。

步骤 1:定义 theme 变量(design tokens)

在 CSS 文件中(通常是一个入口文件,如 app.css),你可以这样定义主题变量:

@import "tailwindcss";

@theme {
  --color-primary: #3490dc;
  --color-secondary: #ff8800;
  --breakpoint-lg: 1024px;
  --spacing-base: 1rem;
  /* 更多 theme 变量 ... */
}

@theme { ... } 是 Tailwind v4 中用来定义那些会影响实用类的 design token 的指令。命名规则中通常包含命名空间,例如 --color-*--font-*--spacing-*--breakpoint-* 等。每个 namespace 通常对应一种或一组 utilityvariant 的生成逻辑。

需要注意的是,这些变量的定义必须在顶层(非嵌套在选择器内、非嵌套在 media query 内)才能被 Tailwind 正确识别为 theme variables

步骤 2:识别并生成 CSS 自定义属性

在编译阶段,Tailwind 会把你在 @theme 中定义的变量转换成 CSS 自定义属性(custom properties),输出通常在 :root 或主题作用域中。也就是说,这些变量会变成浏览器可识别的 --color-primary--spacing-base 等。

简化后的生成示例可能是:

:root {
  --color-primary: #3490dc;
  --color-secondary: #ff8800;
  --breakpoint-lg: 1024px;
  --spacing-base: 1rem;
  /* 其他变量 ... */
}

步骤 3:根据命名空间生成对应的 utility classes 和 variants

这一步是整个映射机制的关键:Tailwind 会根据那些 namespace 变量来决定哪些实用类需要生成。换句话说,变量不仅只是值,它们也决定了哪些 class 是存在的。

举几个具体的例子:

  • 如果你定义了 --color-primary(在 --color-* 命名空间中),那么 Tailwind 会生成 .bg-primary.text-primary.border-primary.fill-primary 等与颜色相关的 utility
  • 如果你定义了 --breakpoint-lg,那么 .lg: 这个 responsive variant 会相对于这个断点存在。比如在 HTML 中你可以写 lg:text-xl,只有在视口宽度大于等于 --breakpoint-lg 时才会应用该样式
  • 如果你定义了 --spacing-basespacing 相关的类(如 p-<n>m-<n>gap-<n> 等)就会基于这个变量(spacing scale)来生成。Tailwind 默认会生成基于 spacing scalemarginpaddinggapwidth/height 等类,这些生成会参考 theme 变量

所以 namespaceutilities / variants 是映射的规则。具体映射关系如下表所示:

命名空间(namespace) 实用类 / variant 类型可能的映射
--color-* 背景色 background、文本颜色 text、边框色 border、填充 fill/stroke 等颜色相关的类
--font-* font-family utilities,例如 font-sansfont-serif
--text-*--text-size-* font-size utilities,如 text-xl
--spacing-* margin / padding / width / height / gap / inset 等与大小、间距相关的实用类
--breakpoint-* 响应式变体(breakpoints),如 sm:...md:...lg:...

步骤 4:CSS 输出和类的形式

在编译输出的 CSS 文件中,会有两部分内容:

  • :root 或主题基础作用域下定义所有被识别的 theme variablesCSS 自定义属性
  • utilities(以及 base / components 层)中,Tailwind 为每个被 theme-variable 驱动的实用类生成对应的 CSS 规则,这些规则使用变量值或者直接映射变量

例如,如果定义了 --color-primary,会生成 .bg-primary { background-color: var(--color-primary); } 或等效的方式。也可能生成 opacity 可变的版本(如 .bg-primary/50)等。

另外,类似 breakpoints 会在 media query 中生成对应 variantclass。比如定义 --breakpoint-lg,那么在 @media (min-width: var(--breakpoint-lg)) { ... } 中会输出 .lg:bg-primary.lg:text-xl 等类。

步骤 5:页面中的使用方式

在页面或模板中,开发者使用 Tailwind utility class 名称。例如:

<div class="bg-primary text-secondary p-4 lg:text-xl">Hello</div>

解析这行代码:

  • bg-primary 会应用 background-color: var(--color-primary)
  • text-secondary 会应用 color: var(--color-secondary)
  • p-4 会应用 padding: calc(var(--spacing-base) * 4) 或类似计算(取决于 spacing 命名空间的定义方式)
  • lg:text-xl 会在大于等于 --breakpoint-lg 的视口上应用 text-xl

步骤 6:主题切换和作用域变量重写

因为主题变量是 CSS 自定义属性,你可以在不同作用域或基于某些属性、数据属性、暗黑模式、亮色模式等重写这些变量的值,从而用同样的 utility 类名产生不同的视觉样式。

示例:

/* 默认 / light 模式 */
@theme {
  --color-primary: #3490dc;
  --color-secondary: #ff8800;
}

/* 暗黑模式或其他 theme 作用域 */
[data-theme="dark"] {
  --color-primary: #0a2239;
  --color-secondary: #ff5500;
}

页面中使用 .bg-primary 的地方会根据 data-theme 的值决定实际背景色。这样类名不变,但变量值会动态变化。

其他细节和边缘情况

inline 选项:如果你定义 @theme inline { ... },则某些 utility 类会直接写入变量值而不是引用变量,例如 .font-sans { font-family: Inter, sans-serif; } 而不是 font-family: var(--font-sans)。这个主要影响变量引用的方式和层次。

静态生成 vs 动态按需生成:Tailwind 会扫描你项目中用到的 class,然后只生成这些所需的 utilities 和对应的媒体查询 / variants,从而减小最终 CSS 大小。变量虽然都在 :root(或主题作用域)定义,但 utility 类如果没有被使用,不会生成对应规则。

arbitrary values:有时候你可能要用一个不在 theme 中的值,这种情况下可以使用 [...] 的语法,例如 bg-[#abcdef] 或者 w-[calc(var(--spacing-base) * 3 + 1rem)] 等,这样会跳过 theme 类的生成逻辑,直接生成或内联这些值。

流程图

为了帮助理解,下面是一个流程图,展示从定义变量到页面生效的完整流程:

20251222142708

设计动机和优势

理解这个映射流程之后,你会明白 Tailwind v4 这样设计的动机与优势:

统一定义和 CSS-first:将设计变量(design tokens)定义在 CSS 中,使整个样式系统更接近 CSS 原生工作流程,无需 JS 配置累赘。

变量暴露和运行时可用性:变量是原生 CSS custom properties,可以在运行时被引用、覆盖、修改(例如主题切换、样式插值、JS 动态样式等),不仅仅在编译阶段。

按需生成:只生成你实际用到的 class,避免生成一大堆冗余 CSS。媒体查询和变体也只有在需要时生成,这样最终 bundle 文件更小。

灵活性与可扩展性:你可以扩展命名空间,新增变量,重写默认主题,实现多个主题,实现暗黑模式等。并且 arbitrary values 给了例外情况下的自由度。

总结

变量与页面建立映射的过程可以总结为:

  1. 定义 theme 变量(design tokens
  2. Tailwind 根据这些变量创建 CSS 自定义属性 + utility classes / variants
  3. 页面通过 class 使用这些 utilities 或直接用 var(...) 引用变量
  4. 若重写变量或在不同作用域里变量的值不同,可实现主题切换等行为

通过这种机制,Tailwind CSS v4 实现了设计系统与样式输出的无缝衔接,既保持了灵活性,又提供了强大的主题定制能力。

从 0 搭建 React 待办应用:状态管理、副作用与双向绑定模拟

React 作为前端主流框架,其单向数据流 组件化 状态驱动视图的设计理念,看似抽象却能通过一个简单的 TodoList 案例彻底吃透。本文不只是 “解释代码”,而是从设计初衷、底层逻辑、实际价值三个维度,拆解 useState useEffect、受控组件模拟双向绑定、父子通信等核心知识点,让你不仅 “会用”,更 “懂为什么这么用”。

一、案例整体架构:先懂 “拆分逻辑”,再看 “代码细节”

在动手写代码前,React 开发的第一步是组件拆分—— 遵循单一职责原则,把复杂页面拆成独立、可复用的小组件,这是 React 组件化思想的核心。

本次 TodoList 的组件拆分如下:

组件名 核心职责 核心交互
App(根组件) 全局状态管理 + 核心逻辑封装 定义新增 / 删除 / 切换待办、数据持久化等方法
TodoInput 待办输入 + 提交 收集用户输入,触发 “新增待办” 逻辑
TodoList 待办列表渲染 展示待办项,转发 “删除 / 切换完成状态” 事件
TodoStats 待办数据统计 展示总数 / 已完成 / 未完成数,触发 “清除已完成” 逻辑

这种拆分的核心价值:每个组件只做一件事,便于维护、复用和调试(比如后续想改输入框样式,只动 TodoInput 即可,不影响列表和统计逻辑)。

二、核心 API 深度拆解:不止 “会用”,更懂 “为什么这么设计”

1. useState:React 状态管理的 “灵魂”

React 中所有可变数据都必须通过**状态(State)**管理,而 useState 是最基础、最核心的状态钩子 —— 它解决了 “函数组件无法拥有自身状态” 的问题,也是 “状态驱动视图” 的核心载体。

(1)基础原理:为什么需要 useState?

纯函数组件本身是 “无状态” 的(执行完就销毁,无法保存数据),而用户交互(比如输入待办、切换完成状态)必然需要 “保存可变数据”。useState 本质是给函数组件提供了持久化的状态存储空间,且这个存储空间和组件渲染周期绑定:

  • 状态更新 → 组件重新渲染 → 视图同步更新;
  • 状态不更新 → 组件不会重复渲染,保证性能。

(2)两种初始化方式:普通初始化 vs 惰性初始化

// 方式1:普通初始化(适合简单、无计算的初始值)
const [count, setCount] = useState(0);

// 方式2:惰性初始化(重点! TodoList 中用的就是这种)
const [todos, setTodos] = useState(() => {
  const saved = localStorage.getItem('todos');
  return saved ? JSON.parse(saved) : [];
});

关键区别与设计初衷

  • 普通初始化:useState(初始值) 中,初始值表达式会在组件每次渲染时都执行(哪怕状态没变化);
  • 惰性初始化:useState(() => { ... }) 中,传入的函数仅在**组件首次渲染*时执行一次,后续渲染不会再跑。

TodoList 中用惰性初始化的核心原因:localStorage.getItem('todos') 是浏览器本地读取操作,虽然开销小,但如果放在普通初始化里,每次组件渲染(比如新增 / 删除待办)都会重复读取本地存储,完全没必要;而惰性初始化只执行一次,既拿到了初始数据,又避免了性能浪费 —— 这是 React 性能优化的 “小细节”,也是理解 useState 设计的关键。

(3)状态更新的 “不可变原则”:为什么必须返回新值?

React 规定:状态是只读的,修改状态必须返回新值,不能直接修改原状态。比如这里的 “新增待办” 逻辑:

const addTodo = (text) => {
  // 错误写法:直接修改原数组(React 无法检测到状态变化,视图不更新)
  // todos.push({ id: Date.now(), text, completed: false });
  // setTodos(todos);

  // 正确写法:解构原数组 + 新增项,返回新数组
  setTodos([...todos, {
    id: Date.now(),
    text,
    completed: false
  }]);
};

底层逻辑:React 判断状态是否变化的依据是引用是否改变。数组 / 对象是引用类型,直接修改原数组(todos.push),数组的引用没变化,React 会认为 “状态没改”,因此不会触发组件重新渲染;而通过 [...todos] 解构生成新数组,引用变了,React 才能检测到状态变化,进而更新视图。

这也是 React “单向数据流” 的核心体现:状态更新是 “不可变” 的,每一次状态变化都会生成新值,便于追踪数据流转(比如调试时能清晰看到每次状态更新的前后值)。

2. useEffect:副作用处理的 “专属管家”

React 组件的核心职责是根据状态渲染视图,而像 “读取本地存储、发送网络请求、绑定事件监听、修改 DOM” 这类不直接参与渲染,但又必须执行的操作,统称为 “副作用(Side Effect)”。useEffect 是 React 专门为处理副作用设计的钩子,替代了类组件中 componentDidMount componentDidUpdate componentWillUnmount 等生命周期方法,且逻辑更集中。

(1)核心语法与执行机制

useEffect(() => {
  // 副作用逻辑:比如保存数据到本地存储
  localStorage.setItem('todos', JSON.stringify(todos));

  // 可选的清理函数(比如取消事件监听、清除定时器)
  return () => {
    // 组件卸载/依赖变化前执行
  };
}, [todos]); // 依赖数组:决定副作用的执行时机

执行时机的深度解析

  • 依赖数组为空 []:仅在组件首次渲染完成后执行一次(对应类组件 componentDidMount);
  • 依赖数组有值 [todos]:组件首次渲染执行 + 每次依赖项(todos)变化后执行(对应 componentDidMount + componentDidUpdate);
  • 无依赖数组:组件每次渲染完成后都执行(极少用,易导致性能问题);
  • 清理函数:组件卸载前 / 下一次副作用执行前触发(比如监听窗口大小变化后,卸载组件时要取消监听,避免内存泄漏)。

(2)在 TodoList 中的核心应用:数据持久化

代码中,useEffect 用来将 todos 同步到 localStorage,这是前端 “数据持久化” 的经典场景,我们拆解其价值:

useEffect(() => {
  localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);
  • 为什么 localStorage 只能存字符串? localStorage 是浏览器提供的本地存储 API,其底层设计只支持字符串键值对存储,因此存储数组 / 对象时,必须用 JSON.stringify 转为字符串;读取时用 JSON.parse 转回原数据类型,这是前端本地存储的通用规则。

(3)useEffect 在这里的核心价值(为什么非它不可)

1. 精准触发:只在需要时执行,保证性能

useEffect 的第二个参数(依赖数组 [todos])是关键:

  • 组件首次渲染时,执行一次(把初始的 todos 保存到本地);
  • 只有 todos 发生实际变化时,才会再次执行(新增 / 删除 / 切换状态 / 清除已完成,只要 todos 变了,就同步保存);
  • todos 没变化时(比如组件因其他状态重新渲染),完全不执行,避免无效操作。

对比 “写在组件顶层” 的无差别执行,useEffect 实现了 “按需执行”,既保证数据同步,又不浪费性能。

2. 时机正确:拿到最新的状态,避免数据不一致

useEffect 的执行时机是「组件渲染完成后」—— 也就是说,当 useEffect 里的代码执行时,setTodos 已经完成了状态更新,todos 一定是最新的。

比如新增待办时:

  1. 调用 addTodo → 执行 setTodos → 组件重新渲染(todos 变为新值);
  2. 渲染完成后,useEffect 检测到 todos 变化 → 执行保存逻辑 → 拿到的是最新的 todos

这就避免了 “异步更新导致保存旧值” 的问题,保证本地存储的数据和组件状态完全一致。

3. 逻辑聚合:一处监听,全场景生效

不管是新增、删除、切换状态、清除已完成,只要最终导致 todos 变化,useEffect 都会自动触发保存 —— 无需在每个修改 todos 的函数里重复写保存逻辑,代码简洁、易维护,后续新增修改 todos 的逻辑(比如批量修改),完全不用动保存代码,天然符合 “开闭原则”。

(4)useEffect 的设计价值:分离 “渲染逻辑” 与 “副作用逻辑”

React 追求 “组件核心逻辑纯净”—— 组件顶层只关注 “根据状态渲染什么”,副作用全部交给 useEffect 处理,这样:

  • 代码结构更清晰:渲染和副作用分离,一眼能区分 “视图相关” 和 “非视图相关” 逻辑;
  • 便于调试:副作用的执行时机由依赖数组明确控制,能精准定位 “什么时候执行、为什么执行”;
  • 避免内存泄漏:通过清理函数可优雅处理 “组件卸载后仍执行副作用” 的问题(比如请求数据时组件卸载了,清理函数可取消请求)。

3. 受控组件:模拟双向绑定的底层逻辑

Vue 中用 v-model 就能实现 “表单值 ↔ 数据” 的双向绑定,但 React 没有内置的双向绑定语法 —— 不是 “做不到”,而是 React 坚持单向数据流,通过 “受控组件” 手动模拟双向绑定,虽然代码多了几行,但能完全掌控数据流转。

(1)双向绑定的本质:视图 ↔ 数据同步

不管是 Vue 的 v-model 还是 React 的受控组件,双向绑定的核心是两件事:

  1. 数据 → 视图:数据(状态)变化,视图(输入框)自动更新;
  2. 视图 → 数据:视图(用户输入)变化,数据(状态)自动更新。

(2)React 受控组件的实现:拆解每一步

以 TodoInput 组件为例,逐行解析双向绑定的实现逻辑:

const TodoInput = ({ onAdd }) => {
  // 步骤1:定义状态存储输入框值(数据层)
  const [inputValue, setInputValue] = useState('');

  // 步骤2:处理表单提交
  const handleSubmit = (e) => {
    // 关键:阻止表单默认提交行为
    e.preventDefault();
    // 输入内容校验:去除首尾空格,避免空提交
    const text = inputValue.trim();
    if (!text) return;
    // 步骤3:将输入内容传给父组件(父子通信)
    onAdd(text);
    // 步骤4:清空输入框(修改状态 → 视图清空)
    setInputValue('');
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        // 核心1数据视图状态控制输入框显示value={inputValue}
        // 核心2视图数据输入变化同步更新状态onChange={e => setInputValue(e.target.value)}
        placeholder="请输入待办事项..."
      />
      <button type="submit">Add</button>
    </form>
  );
};

逐点深度解析

  • 数据 → 视图value={inputValue} 是 “单向绑定” 的核心 —— 输入框显示的内容完全由 inputValue 状态决定,而非 DOM 自身的 value。比如执行 setInputValue('')inputValue 变为空,输入框就会立刻清空,这是 “状态驱动视图” 的体现。
  • 视图 → 数据onChange 事件监听输入框的每一次字符变化,e.target.value 是输入框当前的 DOM 取值,通过 setInputValue 将其同步到 inputValue 状态 —— 这一步是 “手动补全” 双向绑定的反向流程,也是 React 与 Vue 的核心区别(Vue 把这一步封装成了 v-model,React 让开发者手动控制,更灵活)。
  • e.preventDefault() :表单的默认行为是 “提交并刷新页面”,而 React 是单页应用,刷新页面会导致所有状态丢失,因此必须阻止这个默认行为 —— 这是前端开发的通用知识点,也是 React 处理表单的 “必做步骤”。
  • 为什么用 form + onSubmit 而非 button + onClick除了点击按钮提交,用户在输入框按回车键也能触发 onSubmit,而单纯的 onClick 无法响应回车提交,这是语义化 + 用户体验的双重考量。

(3)受控组件的核心优势:完全可控

相比 Vue 的 v-model 黑盒封装,React 受控组件的 “手动操作” 带来了两个核心价值:

  • 可校验性:在 onChange 或 handleSubmit 中可随时对输入内容做校验(比如禁止输入特殊字符、限制长度、去除空格),比如在代码中 inputValue.trim() 就是简单的校验,若需要更复杂的校验(比如手机号格式),可直接在这一步处理;
  • 可追溯性:输入框的每一次值变化都必须通过 setInputValue 触发,在调试工具中能清晰看到 inputValue 的每一次更新记录,便于定位 “输入异常” 问题(比如输入框值不变,可直接查 setInputValue 是否执行)。

4. 父子组件通信:单向数据流的极致体现

React 的 “单向数据流” 不是 “限制”,而是 “保障”—— 数据只能从父组件通过 props 流向子组件,子组件不能直接修改父组件的状态,只能通过父组件传递的回调函数 “通知” 父组件修改状态。这种设计让数据流转路径清晰,避免了 “多个组件随意修改数据导致的混乱”。

(1)通信流程:以 “清除已完成任务” 为例

  1. 父组件(App) :定义状态修改逻辑 + 传递回调函数
// 步骤1:父组件定义修改状态的核心逻辑
const clearCompleted = () => {
  setTodos(todos.filter(todo => !todo.completed));
};

// 步骤2:通过 props 将回调函数传递给子组件
<TodoStats 
  total={todos.length}
  completed={completedCount}
  active={activeCount}
  onClearCompleted={clearCompleted} // 传递回调
/>
  1. 子组件(TodoStats) :接收回调函数 + 触发回调
const TodoStats = ({ total, completed, active, onClearCompleted }) => {
  return (
    <div>
      <p>Total: {total}</p>
      <p>Completed: {completed}</p>
      <p>Active: {active}</p>
      {/* 条件渲染:有已完成任务才显示按钮 */}
      {completed > 0 && (
        <button onClick={onClearCompleted} className="clear-btn">
          清除已完成任务
        </button>
      )}
    </div>
  );
};

深度解析

  • 子组件 TodoStats 只负责 “展示数据 + 触发交互”,不关心 “清除已完成任务” 的具体逻辑 —— 哪怕后续修改清除逻辑(比如加确认弹窗),只需改父组件的 clearCompleted,子组件完全不用动,符合 “开闭原则”。
  • 回调函数是 “子组件通知父组件” 的唯一方式:子组件无法直接访问父组件的 todos 状态,也不能直接调用 setTodos,只能通过父组件传递的 onClearCompleted 回调,触发父组件的状态修改逻辑 —— 这就是 “单向数据流”:数据向下传(父→子),事件向上传(子→父),所有状态修改都集中在父组件,便于追踪和调试。

(2)props 的本质:只读的 “数据桥梁” (后面会单独来讲)

props 是父子组件通信的唯一桥梁,但有一个核心规则:子组件不能修改 props。比如 TodoStats 接收的 completed total 等 props,子组件只能读取,不能修改 —— 因为 props 是父组件状态的 “快照”,修改 props 会导致数据源头混乱(比如子组件改了 completed,父组件的 completedCount 却没变化,数据不一致)。

image.png

三、核心设计思想:从 TodoList 看 React 的底层逻辑

通过这个 TodoList 案例,我们能提炼出 React 最核心的 4 个设计思想,这也是理解 React 的关键:

1. 状态驱动视图

React 中 “视图是什么样” 完全由 “状态是什么样” 决定,没有 “手动操作 DOM” 的场景(比如不用 document.getElementById 改输入框值,不用 appendChild 加待办项)。所有视图变化,都是先修改状态,再由 React 自动更新 DOM—— 这避免了手动操作 DOM 的繁琐和易出错,也让代码更易维护(只需关注状态变化,不用关注 DOM 变化)。

2. 单向数据流

数据只有一个流向:父组件 → 子组件,状态只有一个修改入口:定义状态的组件(比如 todos 定义在 App,只有 App 能改,子组件只能通过回调通知 App 改)。这种设计让数据流转 “可预测”—— 不管项目多复杂,都能顺着 props 找到数据的源头,顺着回调找到状态修改的地方。

3. 组件化与单一职责

每个组件只做一件事:TodoInput 只处理输入,TodoList 只渲染列表,TodoStats 只展示统计。这种拆分让组件 “高内聚、低耦合”:

  • 高内聚:组件内部逻辑围绕核心职责展开,不掺杂其他功能;
  • 低耦合:组件之间通过 props 通信,修改一个组件不会影响其他组件。

4. 副作用与渲染分离

useEffect 将 “副作用逻辑”(比如本地存储)与 “渲染逻辑”(比如展示待办列表)分离,让组件的核心逻辑(根据状态渲染视图)保持 “纯净”—— 纯净的组件逻辑更易测试、更易复用,这也是 React 推崇的 “函数式编程” 思想的体现。

四、总结:从 TodoList 到 React 核心能力

这个看似简单的 TodoList,实则涵盖了 React 日常开发的核心知识点:

  • useState 实现状态管理,理解 “不可变更新” 和 “惰性初始化”;
  • useEffect 处理副作用,理解 “依赖数组” 和 “数据持久化”;
  • 受控组件模拟双向绑定,理解 “状态驱动视图” 和 “单向数据流”;
  • 父子组件通信,理解 props 的 “只读特性” 和回调函数的作用。
❌