阅读视图

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

面试官 : “ 说一下 localhost 和127.0.0.1 的区别 ? ”

localhost 是主机名(域名) ,属于应用层概念;

127.0.0.1 是IPv4 回环地址,属于网络层概念。

两者都用于访问本机服务,但 localhost 必须通过解析才能映射到具体 IP(默认是 127.0.0.1 或 IPv6 的 ::1),而 127.0.0.1 是直接的网络层标识,无需解析。


一、本质定义与协议层次

概念 localhost 127.0.0.1
本质 互联网标准规定的特殊主机名(RFC 6761 定义) IPv4 协议规定的回环地址(RFC 5735 定义)
协议层次 应用层(DNS 协议解析范畴) 网络层(IP 协议寻址范畴)
归属 属于域名系统(DNS) 属于 IP 地址体系
默认映射 IPv4: 127.0.0.1;IPv6: ::1 仅 IPv4 回环网段(127.0.0.0/8)的第一个地址

关键补充

  1. 127.0.0.0/8 网段:不只是 127.0.0.1,整个 127.x.x.x 网段(共 16777216 个地址)都属于回环地址,访问任何一个都会指向本机。
  2. localhost 的特殊性:它是一个保留主机名,不能被注册为公共域名,且操作系统会优先通过 hosts 文件解析,而非公共 DNS 服务器。

二、解析流程的根本差异

这是两者最核心的区别 ——是否需要解析,以及解析的顺序

1. localhost 的解析流程(应用层 → 网络层)

当你在浏览器输入 http://localhost:3000 时,操作系统会执行以下步骤:

  1. 检查本地 hosts 文件

    • Windows 路径:C:\Windows\System32\drivers\etc\hosts
    • Linux/macOS 路径:/etc/hosts
    • 如果 hosts 文件中有如下映射:127.0.0.1 localhost 或 ::1 localhost,则直接使用对应的 IP。
  2. 若 hosts 文件无映射,查询本地 DNS 缓存

    • 操作系统会检查之前是否解析过 localhost,若有缓存则直接使用。
  3. 若缓存无结果,查询本地 DNS 服务器

    • 但由于 localhost 是保留主机名,公共 DNS 服务器通常也会返回 127.0.0.1 或 ::1
  4. 解析完成后,转换为 IP 地址进行网络请求

    • 此时才进入网络层,使用解析后的 IP 连接本机服务。

2. 127.0.0.1 的访问流程(直接进入网络层)

当你输入 http://127.0.0.1:3000 时,跳过所有解析步骤

  1. 操作系统直接识别这是一个 IPv4 回环地址。
  2. 直接将网络请求发送到本机的网络接口(回环接口,lo 接口)。
  3. 目标服务监听 127.0.0.1 或 0.0.0.0 时,即可响应请求。

三、功能与使用上的具体差异

1. 协议支持差异

  • localhost:支持 IPv4 和 IPv6 双协议

    • 若你的系统开启了 IPv6,localhost 可能优先解析为 ::1(IPv6 回环地址)。
    • 例如:在 Node.js 中,server.listen(3000, 'localhost') 会同时监听 IPv4 的 127.0.0.1:3000 和 IPv6 的 ::1:3000
  • 127.0.0.1仅支持 IPv4

    • 无论系统是否开启 IPv6,使用 127.0.0.1 都只会走 IPv4 协议。
    • 例如:server.listen(3000, '127.0.0.1') 仅监听 IPv4 地址。

2. 性能差异

  • 127.0.0.1 略快:因为跳过了 DNS 解析流程(即使是本地 hosts 文件解析,也需要一次文件读取和匹配)。
  • 差异极小:在开发环境中,这种性能差异几乎可以忽略不计,除非是高频次的请求(如每秒上万次)。

3. 服务监听的差异

服务端程序的监听地址,会影响是否能被 localhost 或 127.0.0.1 访问:

监听地址 能否被 localhost 访问 能否被 127.0.0.1 访问 能否被局域网其他设备访问
localhost ✅(IPv4 解析时)
127.0.0.1 ✅(解析为 127.0.0.1 时)
0.0.0.0 ✅(通过本机局域网 IP)
::1(IPv6) ✅(解析为 ::1 时)

4. 自定义映射的差异

  • localhost 可以被自定义映射

    • 你可以修改 hosts 文件,将 localhost 映射到任意 IP,例如:

      192.168.1.100   localhost
      
    • 此时访问 localhost 会指向局域网的 192.168.1.100,而不是本机。

  • 127.0.0.1 无法被自定义

    • 它是 IPv4 协议规定的回环地址,无论如何修改配置,访问 127.0.0.1 都只会指向本机。

5. 兼容性差异

  • 老旧系统 / 服务:某些非常古老的程序(如早期的 DOS 程序、嵌入式设备程序)可能不识别 localhost 主机名,但一定能识别 127.0.0.1
  • IPv6 专属服务:某些服务仅监听 IPv6 的 ::1,此时只能通过 localhost 访问(解析为 ::1),而 127.0.0.1 无法访问。

四、实际开发中的选择建议

  1. 优先使用 localhost

    • 理由:兼容性更好,支持双协议,符合开发习惯,且无需关心 IPv4/IPv6 配置。
    • 场景:本地开发、测试环境、前端代理配置(如 Vite、Webpack 的 devServer.host: 'localhost')。
  2. 使用 127.0.0.1 的场景

    • 强制使用 IPv4:当服务仅监听 IPv4 地址,或系统 IPv6 配置有问题时。
    • 避免自定义映射:当你怀疑 hosts 文件被修改,localhost 被映射到非本机地址时。
    • 某些工具的特殊要求:部分 CLI 工具或服务(如某些数据库客户端)默认只识别 127.0.0.1
  3. 特殊场景:0.0.0.0

    • 这不是回环地址,而是通配地址,表示监听本机所有网络接口(包括回环接口、局域网接口、公网接口)。
    • 场景:需要让局域网其他设备访问本机服务时(如手机测试前端页面)。

五、验证两者差异的小实验

实验 1:修改 hosts 文件,观察 localhost 映射

  1. 打开 /etc/hosts(Linux/macOS)或 C:\Windows\System32\drivers\etc\hosts(Windows)。
  2. 添加一行:192.168.1.1 localhost
  3. 执行 ping localhost,会发现 ping 的是 192.168.1.1,而非 127.0.0.1
  4. 执行 ping 127.0.0.1,仍然 ping 本机。
  5. 恢复 hosts 文件默认配置:127.0.0.1 localhost 和 ::1 localhost

实验 2:查看服务监听的地址

  1. 在 Node.js 中运行以下代码:

    const http = require('http');
    const server = http.createServer((req, res) => {
      res.end('Hello World!');
    });
    // 监听 localhost
    server.listen(3000, 'localhost', () => {
      console.log('Server running on localhost:3000');
    });
    
  2. 执行 netstat -tulpn | grep 3000(Linux/macOS)或 netstat -ano | findstr 3000(Windows)。

  3. 会发现服务同时监听 127.0.0.1:3000 和 ::1:3000(IPv4 + IPv6)。

  4. 若将监听地址改为 127.0.0.1,则仅监听 127.0.0.1:3000


六、总结:核心区别一览表

对比维度 localhost 127.0.0.1
本质 主机名(域名) IPv4 回环地址
协议层次 应用层(DNS) 网络层(IP)
解析需求 必须解析(hosts → DNS) 无需解析
协议支持 IPv4 + IPv6 仅 IPv4
自定义映射 可通过 hosts 文件修改 不可修改,固定指向本机
服务监听 可同时监听 IPv4/IPv6 仅监听 IPv4
兼容性 现代系统支持,老旧系统可能不支持 所有支持 IPv4 的系统都支持
性能 略慢(解析开销) 略快(无解析开销)

我是千寻, 这期内容到这里就结束了,我们有缘再会😂😂😂 !!!

月哥创业3年,还活着!

说什么呢 18年9月入行,到现在7年多了。。真特码快!粉丝们一步一步看着月哥的成长,感谢大家一直以来的陪伴,和支持!谢谢大家! 写了很多东西,删掉了很多,怕发不出来,思来想去分享一些踩坑经验,和一些浅

2026最新React技术栈梳理,全栈必备

前言 2025年的React生态持续迭代,从核心框架的编译器革新到生态工具的性能优化,都带来了诸多实用特性。对于前端开发者而言,精准把握最新技术栈选型,是提升开发效率、构建高性能应用的关键。

面试官: “ 说一下怎么做到前端图片尺寸的响应式适配 ”

前端开发中,图片的尺寸适配是响应式设计的核心部分之一,需要结合图片类型、容器场景、设备特性来选择方案。以下是常见的图片尺寸策略和多窗口适配方法:

一、先明确:前端常用的图片尺寸场景

不同场景下,图片的 “合适尺寸” 差异很大:

场景 建议尺寸范围 示例
图标 / 小图标 24×24 ~ 128×128(2 倍图) 按钮图标、头像缩略图
列表缩略图 300×200 ~ 600×400(2 倍图) 商品列表、文章封面缩略图
详情页主图 800×600 ~ 1920×1080(2 倍图) 商品详情图、Banner 图
背景图 1920×1080 ~ 3840×2160 全屏背景、页面 Banner
移动端适配图 750×1334(2 倍图)、1242×2208(3 倍图) 移动端页面元素图

二、多窗口适配的核心方法

1. 基础适配:max-width: 100%(通用)

最常用的适配方式,让图片不超过容器宽度,自动缩放高度:

img {
  max-width: 100%; /* 图片宽度不超过父容器 */
  height: auto;    /* 高度自动按比例缩放,避免变形 */
}

✅ 适用场景:大部分内联图片、列表图、详情图。

2. 背景图适配:background-size

针对背景图,通过 CSS 属性控制缩放逻辑:

.bg-img {
  width: 100%;
  height: 300px;
  background: url("bg.jpg") center/cover no-repeat; 
  /* 或单独设置: */
  background-size: cover; /* 覆盖容器,可能裁剪 */
  /* background-size: contain; 完整显示,可能留白 */
}
  • cover:优先覆盖容器,保持比例(常用全屏背景);
  • contain:优先完整显示,保持比例(常用图标背景)。

3. 响应式图片:srcset + sizes(精准加载)

让浏览器根据设备尺寸 / 像素比,自动选择合适的图片(减少加载体积):

<img 
  src="img-800.jpg"  <!-- 默认图 -->
  srcset="
    img-400.jpg 400w,  <!-- 400px宽的图 -->
    img-800.jpg 800w,  <!-- 800px宽的图 -->
    img-1200.jpg 1200w <!-- 1200px宽的图 -->
  "
  sizes="(max-width: 600px) 400px, 800px" <!-- 告诉浏览器容器宽度 -->
  alt="响应式图片"
>

✅ 适用场景:对加载性能要求高的大图(如 Banner、详情主图)。

4. 移动端高清图:2 倍图 / 3 倍图

针对 Retina 屏,提供高分辨率图,避免模糊:

<!-- 方法1:srcset 按像素比适配 -->
<img 
  src="img@2x.png" 
  srcset="
    img@1x.png 1x,  <!-- 普通屏 -->
    img@2x.png 2x,  <!-- Retina屏 -->
    img@3x.png 3x   <!-- 超高清屏 -->
  "
  alt="高清图"
>

<!-- 方法2:CSS 背景图(针对图标) -->
.icon {
  background: url("icon@2x.png") no-repeat;
  background-size: 24px 24px; /* 实际显示尺寸是24×24,图片是48×48 */
  width: 24px;
  height: 24px;
}

5. 容器限制:object-fit(控制图片在容器内的显示方式)

当图片宽高比与容器不一致时,避免变形:

.img-container {
  width: 300px;
  height: 300px;
  overflow: hidden;
}
.img-container img {
  width: 100%;
  height: 100%;
  object-fit: cover; /* 覆盖容器,裁剪多余部分(常用头像、卡片图) */
  /* object-fit: contain; 完整显示,留白 */
  /* object-fit: fill; 拉伸变形(不推荐) */
}

6. 媒体查询:针对特定窗口尺寸切换图片

强制在不同屏幕下使用不同图片(适合差异较大的场景):

/* 移动端用小图 */
@media (max-width: 768px) {
  .banner {
    background-image: url("banner-mobile.jpg");
  }
}
/* 桌面端用大图 */
@media (min-width: 769px) {
  .banner {
    background-image: url("banner-desktop.jpg");
  }
}

三、总结适配思路

  1. 优先用 max-width: 100% + height: auto:覆盖 80% 的基础场景;
  2. 背景图用 background-size: cover/contain
  3. 大图用 srcset + sizes:兼顾性能和清晰度;
  4. 固定容器用 object-fit:避免图片变形;
  5. 移动端用 2 倍 / 3 倍图:保证高清显示。

面试官: “ 请你讲一下 package.json 文件 ? ”

1. package.json 的作用

package.json 是 Node.js/npm 项目的核心配置文件,位于项目根目录,它的作用包括:

  • 描述项目信息:名称、版本、作者、许可证等。
  • 声明依赖:项目运行所需的包(dependencies)和开发所需的包(devDependencies)。
  • 定义脚本命令:通过 scripts 字段,让你可以用 npm run 执行自定义任务(如启动、测试、构建)。
  • 指定元数据:比如入口文件、浏览器兼容性等。

2. 基本结构示例

一个典型的 package.json 可能如下:

{
  "name": "my-project",
  "version": "1.0.0",
  "description": "A sample Node.js project",
  "main": "index.js",
  "scripts": {
    "start": "node index.js",
    "test": "jest",
    "build": "webpack"
  },
  "dependencies": {
    "express": "^4.18.2"
  },
  "devDependencies": {
    "jest": "^29.7.0",
    "webpack": "^5.89.0"
  },
  "author": "Your Name",
  "license": "MIT",
  "keywords": ["node", "express", "example"]
}

3. 核心字段说明

3.1 项目信息字段

  • name:项目名称(必须小写,无空格)。
  • version:项目版本,遵循 SemVer(语义化版本),格式为 x.y.z(主版本。次版本。补丁版本)。
  • description:项目的简短描述。
  • author:作者信息,可以是字符串或对象(如 {"name": "xxx", "email": "xxx"})。
  • license:开源许可证类型(如 MITISCGPL)。
  • keywords:项目关键字数组,方便在 npm 上搜索。

3.2 入口与配置字段

  • main:指定项目的入口文件(默认是 index.js)。

  • type:指定模块系统类型:

    • "commonjs"(默认):使用 require() 导入。
    • "module":使用 import/export 语法。
  • files:发布到 npm 时需要包含的文件或目录。

  • repository:项目代码仓库地址。


3.3 依赖字段

  • dependencies:生产环境依赖(项目运行时必需的包),例如:

    "dependencies": {
      "react": "^18.2.0"
    }
    

    版本号前的 ^ 表示兼容当前版本的次版本更新。

  • devDependencies:开发环境依赖(仅开发时使用,比如测试、构建工具),例如:

    "devDependencies": {
      "eslint": "^8.55.0"
    }
    
  • peerDependencies:声明项目运行时需要的外部依赖版本(常用于插件或库)。

  • optionalDependencies:可选依赖,即使安装失败也不会影响项目。


3.4 脚本字段

  • scripts:定义可执行的命令,例如:

    "scripts": {
      "start": "node index.js",
      "dev": "nodemon index.js"
    }
    

    执行方法:

    npm run start
    npm run dev
    

4. package.json 的生成方式

  • 手动创建:直接新建 package.json 文件并写入内容。

  • 使用命令:

    npm init
    

    会通过交互方式生成。

  • 使用默认配置:

    npm init -y
    

    直接生成一个默认的 package.json


5. 与 package-lock.json 的关系

  • package.json:声明依赖的版本范围
  • package-lock.json:锁定安装时的具体版本,确保每次安装的依赖版本一致。

✅ 总结package.json 是项目的 “身份证” 和 “说明书”,它定义了项目的基本信息、依赖关系、可执行脚本等。掌握它的结构和字段,是使用 npm 和 Node.js 开发的基础。

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

前言

在前端开发中,防抖(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;
  });
}

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

❌