阅读视图

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

别再手写代码了!2026 前端 5 个 AI 杀招,直接解放 80% 重复劳动(附工具+步骤)

你还在手动搭项目、手写组件、熬夜调 Bug 吗?2026 年的前端开发,AI 已经接管 80% 重复工作——从项目初始化、UI 生成、Bug 修复到代码重构,全流程智能化。

今天这篇,不讲虚的,直接带工具、带步骤、带实战指令,照着做,今天就能少加班 50%。


一、AI 一键搭项目:1 分钟搞定 Vue/React 工程(VS Code + Copilot)

以前搭项目:装依赖、配路由、装状态库、调 ESLint……半天没了。 现在用 GitHub Copilot(VS Code 必装),一句话生成完整工程。

工具安装(5 分钟)

  1. 安装 VS Code(最新版)

image.png

  1. 扩展商店搜:GitHub Copilot + GitHub Copilot Chat(安装)

image.png

  1. 点击左下角 Copilot 图标 → 登录 GitHub → 授权成功(图标变绿)

image.png

实战步骤(1 分钟出项目)

  1. 新建空文件夹 → 用 VS Code 打开
  2. 快捷键 Ctrl+Shift+I(Win)/ Cmd+Shift+I(Mac)打开 Copilot Chat

image.png

  1. 直接发指令(复制可用):

    生成一个 Vue3 + Vite + Pinia + VueRouter + Tailwind CSS 项目,包含:

    • 完整目录结构
    • ESLint + Prettier 规范配置
    • 请求封装(axios)
    • 路由守卫
    • 自适应布局基础
    • 自动安装依赖
  2. 等待 30 秒 → AI 自动生成所有文件、安装依赖、写好 README

image.png

效果对比

  • 以前:1 天工作量
  • 现在:1 分钟,零配置、零报错

二、AI 组件工厂:一句话生成生产级 UI(Cursor 编辑器)

前端最耗时:写页面、调样式、做响应式、加交互。 Cursor(AI 原生编辑器) 是前端 UI 生成神器,比 VS Code 更智能,支持跨文件、自动处理样式依赖。

工具安装

  1. 官网下载:www.cursor.so/

image.png

  1. 安装 → 首次启动用 GitHub 登录 → 导入 VS Code 配置

安装

  1. 设置中文:Ctrl+Shift+P → 搜索 Configure Display Language → 选中文

设置中文

实战步骤(生成电商商品卡片)

  1. 新建 ProductCard.vue
  2. 快捷键 Ctrl+K(Win)/ Cmd+K(Mac)打开 AI 指令

打开 AI

  1. 输入(复制可用):

    用 Vue3 + TS + Tailwind CSS 生成电商商品卡片组件,要求:

    • 包含:商品图、标题、原价、现价、折扣标签、加入购物车按钮
    • hover 上浮动效、过渡动画
    • 移动端响应式(375px 适配)
    • 带 TS 类型定义
    • 支持自定义主题色
    • 加注释、符合 ESLint 规范
  2. 回车 → 直接生成完整代码(复制即用)

完整代码

进阶:Figma 转代码

  1. 打开 Figma 设计稿 → 复制链接
  2. Cursor 指令:

    把这个 Figma 设计稿转成 Vue3 代码:[粘贴链接],带响应式、TS 类型、可直接运行


三、AI 自动改 Bug:秒定位+修复,告别熬夜(Copilot Chat)

前端最痛:白屏、样式错乱、报错、兼容问题。 Copilot Chat 能直接读代码+报错,自动定位根因+给修复方案

实战步骤(修复白屏 Bug)

  1. 遇到报错:Uncaught TypeError: Cannot read properties of undefined (reading 'xxx')
  2. 选中报错代码 → 右键 → Copilot → Explain This Error

修改Bug

  1. 或直接在聊天框发:

    分析这段代码和报错,找出根因,给修复代码+解释: 【粘贴报错】 【粘贴代码】

  2. AI 秒回:

  • 错误原因(如:变量未初始化、异步时序问题)
  • 完整修复代码
  • 优化建议(如:加可选链、错误捕获)

image

常见前端 Bug 指令(直接复制)

  • 样式兼容:修复 iOS 微信浏览器样式错乱问题
  • 性能卡顿:分析页面滚动卡顿,优化 FPS,给代码方案
  • 接口报错:修复 axios 跨域+超时+错误重试

四、AI 代码重构:老项目一键升级(文心快码)

维护 jQuery/老 Vue2 项目?手动重构太痛苦。 文心快码(国产 AI,前端重构最强) 能批量升级、补 TS、优化性能。

工具安装(VS Code 插件)

  1. 扩展商店搜:文心快码(Baidu Comate) → 安装

Baidu Comate

  1. 用百度账号登录 → 免费额度够用

实战步骤(jQuery 转 Vue3)

  1. 打开老代码文件

  2. 打开文心快码聊天 → 发指令:

    把这段 jQuery 代码重构成 Vue3 组合式 API + TS,要求:

    • 保留原功能
    • 加类型定义
    • 用 Pinia 管理状态
    • 优化性能、移除冗余
    • 符合团队规范
  3. AI 自动生成新代码 → 对比确认 → 直接替换

进阶:批量重构

分析整个项目,把所有 Vue2 组件升级到 Vue3,统一 TS 规范


五、AI 全链路工程化:从接口到部署一条龙(v0 + Copilot)

不止写代码,接口、类型、测试、部署 AI 全包。 v0(Vercel 出品)+ Copilot 前端全链路最强组合。

1. 接口 + TS 类型自动生成

Copilot 指令:

根据这份接口文档,生成:

  • axios 请求封装
  • TS 接口类型定义
  • Mock 数据
  • API 调用示例

2. UI 生成(v0 最强)

  1. 打开:v0.dev/

image

  1. 输入:生成一个后台管理系统列表页,带筛选、分页、操作按钮,用 React + Tailwind
  2. 10 秒出页面 → 复制代码到项目

3. 自动写测试 + 部署

Copilot 指令:

为这个组件写 Vitest 单元测试,覆盖:渲染、交互、边界情况 再生成 Dockerfile + CI/CD 部署脚本


2026 前端 AI 工具选型表(直接抄)

场景 最佳工具 价格 上手难度
日常编码、补全 GitHub Copilot $19/月
UI 组件、页面生成 Cursor、v0 $20/月(Cursor) ⭐⭐
老项目重构、升级 文心快码 免费额度+付费
Bug 修复、调试 Copilot Chat 含在 Copilot 内
全栈项目、原型 Bolt.new 免费试用 ⭐⭐

最后:AI 不淘汰前端,淘汰不用 AI 的人

2026 年的前端竞争:

  • ❶ 不会 AI:天天手写、加班、被淘汰
  • ❷ 会用 AI:少写 80% 重复代码、早下班、涨薪更快

今天就行动

  1. 装 VS Code + Copilot + Cursor
  2. 把本文指令复制试用
  3. 把重复工作丢给 AI,专注架构、业务、价值

别再手写代码了,AI 时代,拼的是会不会用工具,不是手速!


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

前端工程师必备的 10 个 AI 万能提示词(Prompt),复制直接用,效率再翻倍!

你是不是也有这种困扰? 用 Copilot、Cursor 写代码,明明想让 AI 帮你省时间,结果指令发出去,AI 瞎编代码、答非所问,反而更费劲儿?

不是 AI 不好用,是你没找对“说话方式”——前端 AI 高效开发的核心,从来不是“让 AI 写代码”,而是“让 AI 精准懂你的需求”。

很多前端每天用 AI,却不知道:一句好的 Prompt(提示词),能让效率直接翻 3 倍,少写 80% 重复代码、少踩 90% 的坑。

今天这篇,不搞虚的,直接给大家整理了 10 个前端专属 AI 万能提示词,覆盖前端开发全场景——组件开发、Bug 修复、代码重构、样式优化、工程化配置,全部复制就能用,不用自己琢磨,新手也能轻松上手。

不管你用的是 Copilot、Cursor、文心快码,还是 Claude Code,这些 Prompt 都通用,今天用,今天就能省时间、少加班!

先划重点:前端 AI Prompt 万能公式(记牢更省心)

所有好用的前端 Prompt,都离不开这 4 个核心要素,记下来,以后自己也能自定义:

明确场景 + 技术栈 + 具体需求 + 输出要求

举个反例:“帮我写个按钮组件”(模糊,AI 易瞎编) 举个正例:“用 Vue3 + TS + Tailwind CSS 写一个按钮组件,包含默认/禁用/高亮三种状态,hover 有过渡动画,带类型定义和注释,符合 ESLint 规范”(精准,AI 直接出可用代码)

下面这 10 个 Prompt,全部按照这个公式编写,复制粘贴,替换括号里的内容,就能直接用!

一、组件开发类(最常用,每天都能用到)

前端每天都要写组件,这 2 个 Prompt,覆盖 80% 的组件开发场景,不用再手动写样式、写逻辑。

Prompt 1:基础组件生成(复制即用)

用【Vue3/React】+【TS】+【Tailwind CSS/Element Plus/Ant Design】生成【组件名称,如:登录表单/商品卡片/分页组件】,要求:
1. 包含【具体功能,如:表单校验/分页切换/hover 动效】;
2. 支持【自定义属性,如:自定义颜色/尺寸/回调函数】;
3. 带完整 TS 类型定义、详细注释,符合 ESLint 规范;
4. 适配移动端响应式,兼容主流浏览器;
5. 输出完整可运行代码,复制就能直接导入项目。

示例替换:用 Vue3 + TS + Element Plus 生成登录表单,要求:1. 包含账号密码校验、记住密码、忘记密码功能;2. 支持自定义提交按钮文本;3. 带完整 TS 类型定义、详细注释,符合 ESLint 规范;4. 适配移动端响应式,兼容主流浏览器;5. 输出完整可运行代码,复制就能直接导入项目。

Prompt 2:复杂组件封装(复制即用)

帮我封装一个【复杂组件名称,如:树形表格/弹窗表单/下拉搜索选择器】,技术栈【Vue3/React + TS】,要求:
1. 核心功能:【详细描述功能,如:树形表格支持勾选、展开/折叠、搜索筛选;弹窗表单支持表单联动、提交校验】;
2. 性能优化:【如:懒加载、防抖节流、避免重复渲染】;
3. 可扩展性:支持插槽、自定义事件、Props 传参,方便后续二次开发;
4. 附带使用示例、TS 类型说明、常见问题备注;
5. 代码结构清晰,分模块编写,便于维护。

二、Bug 修复类(前端救星,告别熬夜改 Bug)

遇到 Bug 不用慌,不用再翻 Stack Overflow、不用瞎试代码,这 2 个 Prompt,让 AI 秒定位、秒修复,还能告诉你问题根源。

Prompt 3:报错快速修复(复制即用)

帮我分析以下前端报错和对应代码,要求:
1. 报错信息:【粘贴完整报错信息,如:Uncaught TypeError: Cannot read properties of undefined (reading 'value')】;
2. 对应代码:【粘贴报错相关的完整代码片段】;
3. 请找出报错根因,给出详细解释,然后提供完整的修复代码;
4. 补充优化建议,避免以后再出现类似问题;
5. 修复后的代码要符合项目技术栈【Vue3/React + TS】规范,可直接替换使用。

Prompt 4:兼容性/Bug 排查(复制即用)

我遇到一个前端问题:【详细描述问题,如:iOS 微信浏览器样式错乱、页面滚动卡顿、接口请求跨域失败、组件渲染异常】;
项目技术栈:【Vue3/React + TS + 具体框架/工具】;
请帮我:
1. 分析可能的问题原因,列出所有可能性;
2. 给出每一种原因的解决方案和完整代码;
3. 提供预防措施,避免后续出现类似兼容性/性能问题;
4. 方案要简单易操作,不用复杂配置,直接能落地。

三、代码重构类(老项目救星,提升代码质量)

维护老项目、接手烂代码,手动重构太费时间?这 2 个 Prompt,让 AI 帮你优化代码、升级版本,不用自己逐行修改。

Prompt 5:代码优化/重构(复制即用)

帮我重构以下前端代码,项目技术栈【Vue3/React + TS】,要求:
1. 原始代码:【粘贴需要重构的代码片段】;
2. 重构目标:优化代码结构、移除冗余代码、修复潜在 Bug、提升代码可读性和可维护性;
3. 保留原有的所有功能,不改变业务逻辑;
4. 加入 TS 类型定义(如果没有),补充必要注释,符合 ESLint 规范;
5. 给出重构前后的对比说明,解释优化的原因和好处。

Prompt 6:版本升级迁移(复制即用)

帮我将【旧版本技术,如:Vue2 组件/Vue3 旧语法/jQuery 代码】迁移到【新版本技术,如:Vue3 组合式 API/TS/React 函数组件】,要求:
1. 原始代码:【粘贴需要迁移的代码片段/文件】;
2. 迁移要求:完全保留原业务功能,兼容原有项目配置,不引入新的依赖;
3. 遵循新版本的最佳实践,如:Vue3 组合式 API 规范、React Hooks 规范;
4. 补充迁移说明,列出需要注意的细节和可能出现的问题及解决方案;
5. 输出完整的迁移后代码,可直接替换使用。

四、样式/交互类(告别调样式的痛苦)

调样式、做交互,最费时间还容易出错?这 2 个 Prompt,让 AI 帮你写样式、做动效,不用再反复调试。

Prompt 7:样式快速生成/优化(复制即用)

帮我写/优化【元素/组件】的样式,技术栈【Tailwind CSS/CSS3/SCSS】,要求:
1. 样式需求:【详细描述,如:居中显示、圆角、阴影、hover 动效、响应式适配(375px/768px/1200px)、深色模式兼容】;
2. 样式规范:符合项目设计规范,避免样式冲突,代码简洁可复用;
3. 优化要求:减少冗余样式,提升样式加载速度,兼容主流浏览器;
4. 输出完整的样式代码,可直接复制到项目中使用,并给出使用说明。

Prompt 8:交互效果实现(复制即用)

帮我实现【交互效果,如:下拉菜单动画、弹窗淡入淡出、滚动加载、拖拽排序、表单联动】,技术栈【Vue3/React + JS/TS】,要求:
1. 交互细节:【详细描述,如:弹窗点击遮罩关闭、下拉菜单hover展开、拖拽时显示提示、滚动加载到底部自动请求数据】;
2. 性能要求:避免卡顿、防抖节流处理,不影响页面其他功能;
3. 兼容性:适配移动端和PC端,兼容主流浏览器;
4. 输出完整的代码(HTML/CSS/JS/TS),复制就能用,附带使用说明和注意事项。

五、工程化/工具类(提升全链路效率)

除了写代码,工程化配置、接口请求、测试用例也能让 AI 帮你做,这 2 个 Prompt,覆盖前端全链路开发。

Prompt 9:接口请求/类型生成(复制即用)

根据以下接口文档,生成【Vue3/React】项目的接口请求代码,要求:
1. 接口信息:【粘贴接口文档,包含请求地址、请求方式、参数、返回值】;
2. 技术栈:【Axios + TS】;
3. 输出内容:
   - 完整的接口请求函数封装(包含请求拦截、响应拦截、错误处理);
   - 所有接口参数和返回值的 TS 类型定义;
   - Mock 数据生成(用于本地调试);
   - 接口调用示例;
4. 代码符合项目规范,可直接导入项目使用。

Prompt 10:测试用例/工程化配置(复制即用)

帮我生成【组件/函数】的测试用例,或【工程化配置文件】,要求:
1. 目标:【如:为登录组件写单元测试、生成 ESLint 配置、生成 Vitest 配置、生成 Dockerfile】;
2. 技术栈:【Vitest/Jest/ESLint/Docker】;
3. 具体要求:【如:测试用例覆盖渲染、交互、边界情况;配置文件适配 Vue3/React + TS 项目,包含常用配置】;
4. 输出完整的代码/配置文件,可直接复制到项目中使用,并给出配置说明和使用方法。

关键提醒:这 3 个小技巧,让 Prompt 效果再翻倍

  1. 越具体,AI 越精准:不要说“帮我写个表单”,要明确技术栈、功能、样式,甚至是兼容要求,避免 AI 瞎编;
  2. 分场景使用:不同的 AI 工具(Copilot/Cursor)适配性略有差异,但以上 Prompt 全部通用,复制后可根据工具微调;
  3. 善用追问:如果 AI 输出不符合预期,直接追问“修改一下,让组件支持自定义颜色”“修复这个代码里的语法错误”,不用重新发指令。

写在最后:AI 提效,Prompt 是关键

2026 年的前端开发,拼的不是手速,是“用 AI 的能力”——同样是用 AI,会写 Prompt 的人,每天能多省 1-2 小时,少加很多班;不会写的人,反而被 AI 拖累。

以上 10 个 Prompt,覆盖了前端开发的全场景,不管你是新手还是资深前端,复制就能用,不用自己琢磨、不用记复杂语法。

建议你 收藏本文,转发给身边还在瞎用 AI、天天加班的前端同事,一起省时间、提效率、早下班。


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

前端进阶之路:从性能优化到响应式布局的实战指南(Tailwindcss)

前端进阶之路:从性能优化到响应式布局的实战指南(Tailwindcss)

在现代前端开发的宏大叙事中,我们往往容易迷失在纷繁复杂的框架和库中。然而,剥开React、Vue或Tailwind CSS的外衣,其核心往往回归到对DOM操作的深刻理解、对性能的极致追求以及对用户体验的细腻把控。今天,我们将串联起三个看似独立却内在逻辑紧密相连的知识点:原生DOM操作的性能基石——DocumentFragment,React组件化开发的“隐形斗篷”——Fragment,以及现代CSS布局的利器——Tailwind CSS的响应式哲学。

一、性能的基石:理解DocumentFragment

在JavaScript直接操作DOM的时代,性能优化是一个绕不开的话题。浏览器渲染页面的过程是昂贵的,每一次DOM的增删改查都可能触发重排和重绘。让我们通过两段代码的对比,来窥探原生性能优化的奥秘。

假设我们需要向页面中的一个容器内插入两个段落元素。

直接追加的“笨重”方式:

const container = document.querySelector('.container');
const p1 = document.createElement('p');
p1.textContent = '111';
const p2 = document.createElement('p');
p2.textContent = '222';

container.appendChild(p1); // 触发一次重排/重绘
container.appendChild(p2); // 再次触发重排/重绘

这种方式虽然直观,但效率极低。每执行一次appendChild,浏览器就需要重新计算样式、布局,并更新画面。如果你需要插入成百上千个节点,页面就会出现明显的卡顿甚至闪烁。

使用DocumentFragment的“聪明”方式:

const container = document.querySelector('.container');
const p1 = document.createElement('p');
p1.textContent = '111';
const p2 = document.createElement('p');
p2.textContent = '222';

const fragment = document.createDocumentFragment(); // 创建内存中的碎片
fragment.appendChild(p1); // 内存操作,无渲染消耗
fragment.appendChild(p2); // 内存操作,无渲染消耗
container.appendChild(fragment); // 一次性插入,仅触发一次重排/重绘

DocumentFragment就像一个存在于内存中的“隐形容器”。它不属于DOM树,因此对它的操作不会触发页面的渲染更新。我们可以把所有的子节点先组装到这个碎片中,最后一次性将其内容“倾倒”进真实的DOM树。这就像搬家时,与其每拿一个箱子就跑一趟新车,不如先把所有箱子装上一辆大卡车(Fragment),然后一次性运达目的地。这种批量操作的思维,是现代前端性能优化的原点。

二、React的“隐形斗篷”:Fragment组件

随着React等声明式框架的普及,我们不再直接操作DOM,但DocumentFragment的思想在React中以另一种形式得到了升华——那就是Fragment组件。

在React中,组件的render函数或函数组件本身必须返回一个单一的根元素。这是由React内部虚拟DOM树的协调机制决定的。

痛点场景:

//  错误写法:返回了多个根节点,React会报错
function MyComponent() {
  return (
    <h1>标题</h1>
    <p>内容</p>
  );
}

为了解决这个问题,新手往往会用一个无意义的div包裹起来:

// ️ 不理想的写法:引入了多余的DOM节点
function MyComponent() {
  return (
    <div>
      <h1>标题</h1>
      <p>内容</p>
    </div>
  );
}

这种做法虽然能跑通,但会带来“DOM污染”。多余的div会破坏CSS布局(比如Flexbox的父子关系),增加DOM树的深度,甚至导致HTML结构语义错误(例如在table的tr中插入div)。

Fragment的解决方案:

React提供了Fragment(简写为<>...</>),它就像一个“隐形斗篷”。它满足了React对单一根节点的要求,但在最终生成的HTML中,它自身会消失,只留下它的子元素。

//  完美写法:使用Fragment,DOM结构纯净
function MyComponent() {
  return (
    <>
      <h1>标题</h1>
      <p>内容</p>
    </>
  );
}

在列表渲染中,Fragment更是不可或缺。它允许我们将一组相关的元素(如术语dt和描述dd)组合在一起,而不破坏父级列表的结构。

// 在列表中组合多个元素,保持语义化
{items.map(item => (
  <Fragment key={item.id}>
    <dt>{item.term}</dt>
    <dd>{item.description}</dd>
  </Fragment>
))}

虽然React的Fragment和原生的DocumentFragment在实现机制上有所不同(前者是虚拟DOM层面的概念,后者是真实DOM API),但它们的精神内核是一致的:高效组织节点,避免冗余,拒绝不必要的渲染开销。

三、布局的艺术:Tailwind CSS与移动端优先

当我们构建好纯净的DOM结构后,如何高效地为其赋予样式?Tailwind CSS提供了一种原子化的解决方案,而“移动端优先”则是其响应式布局的核心哲学。

让我们看一段典型的Tailwind代码:

export default function App() { 
    return (
        <div className="flex flex-col md:flex-row gap-4">
            <main className="bg-blue-100 p-4 md:w-2/3">
                主内容
            </main>
            <aside className="bg-green-100 p-4 md:w-1/3">侧边栏</aside>
        </div>
    )
}

这段代码精妙地展示了如何适配不同设备。

第一阶段:移动端(默认样式) 当我们在手机上(屏幕宽度小于768px)浏览时,Tailwind会忽略所有带前缀(如md:)的类名。

  • flex flex-col:容器启用Flex布局,且方向为垂直堆叠。
  • gap-4:元素间保持间距。 此时,主内容和侧边栏是上下排列的单列布局,非常适合手机阅读。

第二阶段:PC端(md:断点生效) 当屏幕变宽(≥768px),md:前缀的样式被激活。

  • md:flex-row:布局方向瞬间切换为水平排列。
  • md:w-2/3md:w-1/3:主内容占据2/3宽度,侧边栏占据1/3。 页面平滑地过渡为经典的两栏布局。

这种“先写死移动端,再修饰大屏”的策略,避免了复杂的媒体查询嵌套,让响应式逻辑变得清晰可见。它要求开发者首先关注核心内容的呈现(移动端),然后再考虑在大屏幕上如何利用富余空间(PC端),这是一种非常健康的设计思维。

结语

从原生JavaScript中利用DocumentFragment减少重排,到React中使用Fragment保持DOM树的语义化和纯净,再到利用Tailwind CSS的原子类快速构建响应式布局,这三者共同构成了一个现代前端开发者的核心素养。

它们分别解决了结构、逻辑和表现层面的关键问题:

  • DocumentFragment教会我们敬畏浏览器的渲染性能
  • React Fragment教会我们追求代码结构的逻辑纯净
  • Tailwind CSS教会我们以高效的方式驾驭复杂的UI设计

掌握这些工具背后的原理,而不仅仅是语法,才是通往高级前端工程师的必经之路。

前端面试必问 Git 通关指南:常用命令速查 + merge/rebase 深度辨析,看完再也不慌

本文面向前端面试场景,覆盖从日常开发高频命令,到面试官必问的核心原理辨析,所有内容均来自面试实战考点,无冗余废话,面试前刷一遍直接通关。

开篇前言

Git 是前端开发的必备工具,也是几乎所有公司一面必问的基础考点。但很多同学日常开发只会用add/commit/push/pull,一被问到mergerebase的区别、代码回滚方案、分支管理规范就卡壳。

本文基于我备战前端实习的面试笔记整理,先补全优化前端开发必背的全量高频命令,再深度拆解面试最高频的merge vs rebase考点,最后补充面试常问的附加题,帮大家彻底搞定 Git 面试。


一、前端开发 & 面试必背 Git 常用命令大全

我将所有命令按开发场景做了模块拆分,保留了基础核心用法,同时补全了面试常考的进阶参数和注意事项,可直接当作面试速查手册。

1. 仓库初始化与远程关联

表格

命令 核心作用 面试注意事项
git init 在当前目录初始化一个全新的 Git 本地仓库 初始化后会生成.git隐藏目录,存储 Git 所有的版本和配置信息
git clone <远程仓库地址> 克隆远程仓库到本地,自动完成远程关联 面试常考:支持 HTTPS 和 SSH 两种地址,SSH 需提前配置密钥
git remote add origin <远程仓库地址> 给本地仓库关联远程仓库,origin 是远程仓库的默认别名 必用场景:本地 init 的仓库首次推送到远程前,必须先执行此命令
git remote -v 查看当前仓库关联的远程仓库地址详情 排查远程仓库配置问题的核心命令

2. 分支管理核心命令(面试超高频)

表格

命令 核心作用 面试注意事项
git branch 查看所有本地分支,带*的是当前所在分支 基础必背,面试官常以此为起点延伸分支相关问题
git branch -r 查看所有远程分支
git branch -a 查看所有分支(本地 + 远程)
git checkout <分支名> 切换到已存在的本地分支 高频快捷操作:git checkout - 一键切换到上一个分支
git checkout -b <新分支名> 创建并立即切换到新分支 等价于git branch <新分支名> + git checkout <新分支名>,开发最高频用法
git checkout -b <新分支名> <起点> 基于指定起点(远程分支 / 历史提交 / 标签)创建新分支 示例:git checkout -b hotfix/v1.0 origin/main,面试常考场景化用法
git branch -d <分支名> 安全删除本地分支 仅能删除已经合并到当前分支的分支,未合并的分支会报错,防止误删代码
git branch -D <分支名> 强制删除本地分支 无论分支是否合并,都会直接删除,仅用于废弃的功能分支,面试常问-d-D的区别
git push origin --delete <远程分支名> 删除远程分支

补充:Git 2.23+ 官方推出语义更清晰的git switch替代checkout的分支操作功能,面试可提:

  • 切换分支:git switch <分支名>
  • 创建并切换新分支:git switch -c <新分支名>解决了checkout功能过载、容易误操作的问题。

3. 代码提交与暂存核心命令

表格

命令 核心作用 面试注意事项
git status 查看当前工作目录和暂存区的状态,显示未跟踪、已修改、已暂存的文件 开发必用,切换分支、提交代码前建议必执行,避免误操作
git add <文件路径/文件名> 将指定文件的修改 / 新增添加到暂存区
git add . 将当前目录所有修改、新增的文件添加到暂存区 面试常考:不会处理已删除的文件,仅新增和修改
git add -u 仅将已跟踪文件的修改、删除添加到暂存区,不包含新增文件 高频场景:只想提交已有文件的改动,不想提交新增的临时文件
git commit -m "提交描述信息" 将暂存区的内容提交到本地仓库,生成一条提交记录 核心要求:提交信息必须语义化,面试常问提交规范
git commit -am "提交描述信息" 等价于git add -u + git commit -m,一步完成已跟踪文件的提交 注意:对新增的未跟踪文件无效
git commit --amend 修改上一次的提交信息,或补充漏提交的文件,不会生成新的提交 面试高频考点:仅适用于未推送到远程的本地提交,已推送的提交修改后会重写历史,需要强制推送,有极高风险

4. 代码拉取与推送核心命令

表格

命令 核心作用 面试注意事项
git push 将本地当前分支的提交推送到已关联的远程分支 首次推送必须加-u参数设置上游分支:git push -u origin <分支名>,后续可直接用git push
git push -f 强制推送本地分支覆盖远程分支 高危操作!仅能在自己的私有分支使用,绝对禁止在公共分支执行,会直接覆盖远程历史,导致团队代码丢失
git pull 拉取远程当前分支的最新代码,并自动合并到本地分支 面试核心考点:git pull = git fetch + git merge,默认用 merge 方式合并,会生成合并提交
git pull --rebase 拉取远程最新代码,并用 rebase 方式合并到本地分支 多人协作高频用法,避免生成多余的合并提交,保持本地历史线性
git fetch 仅拉取远程仓库的所有最新提交到本地,不会自动合并 面试常问和git pull的区别:更安全,可先查看远程改动,再手动决定是否合并,不会直接修改本地工作区

5. 临时存储 stash 全命令(面试高频)

表格

命令 核心作用 面试注意事项
git stash 将当前分支未提交的改动(工作区 + 暂存区)临时保存到堆栈中,清空工作区 高频场景:正在开发功能,突然需要切换分支改 bug,又不想提交半成品代码
git stash save "存储备注信息" 给 stash 记录添加备注,方便后续识别 多个 stash 记录时必用,避免分不清存储的内容
git stash list 查看堆栈中所有的 stash 存储记录
git stash pop 恢复堆栈中最新的一条 stash 记录,并删除该条记录 恢复后会自动从堆栈中移除,对应git stash的反向操作
git stash apply 恢复最新的 stash 记录,但不会从堆栈中删除 场景:需要把同一份改动恢复到多个分支
git stash drop 删除堆栈中最新的一条 stash 记录
git stash clear 清空堆栈中所有的 stash 记录

6. 提交历史查看命令

表格

命令 核心作用 面试注意事项
git log 查看当前分支的完整提交日志记录,包含提交哈希、作者、时间、提交信息
git log --oneline 一行显示一条提交记录,仅展示简短提交哈希和提交信息 高频用法,快速查看提交历史,面试必提
git log --graph 图形化展示分支的合并历史和分叉情况 配合--oneline使用效果极佳,直观看到分支合并轨迹
git log -p 查看提交日志的同时,显示每次提交的具体代码改动内容 排查问题、代码溯源高频用法

7. 工作区修改撤销与文件恢复

表格

命令 核心作用 面试注意事项
git checkout -- <文件路径/文件名> 用暂存区的版本覆盖工作区的文件,撤销未暂存的修改 ⚠️ 高危操作:修改不可逆,本地未暂存的改动会永久丢失
git checkout -- . 撤销当前目录所有未暂存的修改
git checkout <提交哈希/分支名> -- <文件路径> 用指定提交 / 分支的文件版本,覆盖当前工作区和暂存区的对应文件 场景:恢复某个文件到历史版本,不影响其他文件

补充:Git 2.23+ 官方推出git restore替代checkout的文件恢复功能,语义更清晰:

  • 撤销工作区未暂存修改:git restore <文件名>
  • 撤销暂存区的修改:git restore --staged <文件名>

二、前端面试最高频考点:git merge vs git rebase 深度辨析

这是 Git 面试的必问题,90% 的面试官都会问到,很多同学只能答出 “一个会生成合并提交,一个不会”,但想要拿到高分,必须从原理、区别、优缺点、场景、禁忌全维度讲透。

1. 核心相同点

git mergegit rebase核心目标完全一致:将一个分支的代码变更,整合到另一个分支中,是 Git 中最核心的两种分支合并方案。

2. 核心原理(面试答题先讲原理,直接拉开差距)

我们用一个最常见的开发场景举例:

主分支main有提交记录 A→B→C,我们从C切出功能分支feature开发,提交了D→E;此时main分支有了新的提交F→G,现在需要把main的最新代码合并到feature,或者把feature合并到main

git merge 原理

git merge采用三方合并策略,执行git merge feature时会做 3 件事:

  1. 找到两个分支的最近共同祖先 C
  2. 基于共同祖先,将两个分支的变更做三方合并对比;
  3. 最终生成一个全新的合并提交 H,这个提交有两个父提交,分别指向两个分支的最新提交GE,同时完整保留两个分支的所有原始提交历史。

最终合并后的main分支历史:A→B→C→F→G→H(合并提交)feature分支的D、E提交完整保留,时间线是分叉的。

git rebase 原理

rebase直译是变基,核心是改变分支的基准,执行git rebase main时会做 4 件事:

  1. 找到两个分支的最近共同祖先 C
  2. 提取feature分支上从C之后的所有提交D、E,临时保存起来;
  3. feature分支的基准指针,直接指向main分支的最新提交G
  4. 按顺序将临时保存的D、E,逐个重放应用到新的基准G上,生成新的提交D'、E'

最终变基后的feature分支历史:A→B→C→F→G→D'→E',形成了完全线性的提交记录,没有任何合并提交,原始的D、E提交会被废弃,提交历史被重写。

3. 全维度对比表(面试分点答,逻辑拉满)

表格

对比维度 git merge git rebase
核心逻辑 三方合并,生成全新的合并提交 变基重放,逐个应用提交,重写提交历史
提交历史 完整保留所有分支的原始提交,时间线分叉,上下文完整 重写提交历史,形成线性记录,无多余合并提交,原始上下文丢失
冲突处理 合并时仅需解决 1 次冲突,解决后生成合并提交即可,成本极低 变基过程中,每个提交重放时都可能产生冲突,需要逐个解决,提交越多成本越高
操作安全性 极高,不会修改现有提交历史,所有操作都有记录可追溯,不会丢失代码 高危,会重写提交历史,操作失误极易丢失提交,可通过git reflog恢复,但有门槛
代码溯源 完整的合并轨迹,可精准定位 bug 是哪个分支、哪次提交引入的,排查问题效率高 提交历史被重写,原始提交的上下文丢失,问题溯源难度大幅提升
公共分支兼容性 完全兼容,是公共分支合并的标准方案 绝对禁止在公共分支使用,会导致团队成员分支历史不一致,引发灾难性冲突

4. 优缺点详解

git merge 优缺点

✅ 优点:

  1. 操作简单、上手门槛低,符合 Git 分布式设计的初衷,全程安全无风险;
  2. 完整保留所有分支的开发上下文和提交历史,方便后续代码审计、问题回溯、版本回滚;
  3. 冲突处理简单,仅需解决一次冲突,不会出现重复处理冲突的情况;
  4. 支持快进合并(Fast-Forward),当目标分支无新提交时,可直接移动分支指针,无需生成合并提交。

❌ 缺点:

  1. 多人协作频繁合并时,会产生大量的合并提交,导致提交历史分叉严重,可读性变差;
  2. 对于追求简洁线性提交历史的团队,多余的合并提交会显得冗余,不利于版本管理。

git rebase 优缺点

✅ 优点:

  1. 最终会形成干净、无分叉的线性提交历史,提交日志可读性极强,方便版本迭代回溯;
  2. 支持交互式变基git rebase -i,可在合并前整理本地提交(合并零散提交、修改提交信息、删除无用提交),让提交记录更规范;
  3. git pull --rebase拉取远程代码,可避免生成多余的合并提交,保持本地分支的线性历史。

❌ 缺点:

  1. 操作风险高,重写提交历史的操作不可逆,一旦失误极易丢失代码;
  2. 冲突处理成本高,多个提交重放时需要逐个解决冲突,重复操作多;
  3. 重写历史后,原始提交的上下文丢失,出现问题时很难精准定位 bug 引入的节点;
  4. 在公共分支使用会给团队带来灾难性后果,所有成员都需要强制同步重写后的历史,极易出现代码丢失、冲突爆炸。

5. 最佳实践 & 使用场景(面试必答,体现你的实战经验)

git merge 推荐使用场景

  1. 将功能分支合并到公共主分支(main/master、develop)时,必须使用 git merge,建议搭配--no-ff参数(禁用快进合并),强制生成合并提交,完整保留分支合并的上下文,方便后续追溯和回滚;
  2. 多人协作的公共分支之间的合并,保证所有团队成员的提交历史一致,不会出现历史混乱;
  3. 合并到上线分支、生产分支时,必须使用 merge,保证所有操作可追溯,出问题可快速回滚;
  4. 需要完整保留分支开发上下文,用于代码审计、合规检查的场景。

git rebase 推荐使用场景

  1. 本地私有功能分支的提交历史整理,比如自己开发的 feature 分支,在合并到公共分支之前,用git rebase -i HEAD~n整理零散的提交,让提交记录语义化、规范化;
  2. 本地分支拉取远程公共分支的最新代码时,用git pull --rebase代替默认的git pull,避免生成多余的合并提交,保持本地分支的线性历史;
  3. 给开源项目提交 PR/MR 时,绝大多数开源项目要求提交历史是线性的,需要用 rebase 基于上游最新分支整理提交,避免合并冲突和冗余的合并提交;
  4. 个人独立开发的项目,想要保持干净的线性提交历史,可自由使用 rebase。

6. 黄金法则(面试答出来直接加分)

永远不要在已经推送到远程的公共分支上,执行 git rebase 操作!

公共分支是所有团队成员的开发基准,你 rebase 之后会重写公共分支的提交历史,其他成员的本地分支还是基于原来的历史,当他们拉取代码时,会出现两个版本的历史,合并后会产生大量重复的提交和无法解决的冲突,最终导致代码仓库的历史彻底混乱,甚至丢失核心代码。


三、前端面试 Git 高频附加题

除了核心的 merge/rebase,这些考点也是面试官常问的,补充在这里帮大家全面通关:

1. git reset 和 git revert 的区别?

核心区别:是否重写提交历史,是否可逆

  • git revert:生成一个新的提交,反向撤销目标提交的所有改动,不会修改现有提交历史,安全,适合公共分支的代码回滚,所有操作可追溯;
  • git reset:直接移动分支指针,删除目标提交之后的所有提交,会重写提交历史,分为--soft(保留改动到暂存区)、--mixed(默认,保留改动到工作区)、--hard(彻底丢弃所有改动,高危),仅适合本地私有分支的回滚,绝对禁止在已推送的公共分支使用。

2. 什么是分离头指针(detached HEAD)?有什么风险?

  • 定义:执行git checkout <提交哈希/标签名>时,HEAD 指针不再指向任何一个命名分支,而是直接指向一个具体的提交记录,此时就进入了分离头指针状态;
  • 风险:此状态下的提交,属于匿名分支上的提交,一旦切换到其他分支,这些提交会被 Git 的垃圾回收机制清理,极易丢失;
  • 解决方案:如果需要在此状态下保留修改,立即执行git checkout -b <新分支名>,创建新分支保存这些提交。

3. .gitignore 文件不生效怎么办?

  • 根本原因:.gitignore只能忽略未被跟踪的文件,如果文件已经被提交到 Git 仓库,就不会被 ignore 规则匹配;

  • 解决方案:

    1. 先把本地需要忽略的文件备份,避免丢失;
    2. 执行git rm -r --cached .,清除所有文件的本地跟踪缓存;
    3. 重新执行git add .,此时.gitignore规则会生效,忽略指定文件;
    4. 提交修改到仓库即可。

4. 不小心把账号密码、密钥等敏感信息提交到 Git 仓库了,怎么办?

  • 第一步:立即修改敏感信息的密码 / 密钥,杜绝泄露风险;

  • 第二步:清理 Git 仓库中的敏感信息:

    • 如果是仅本地提交、未推送到远程:用git reset --soft HEAD~1回滚提交,修改后重新提交即可;
    • 如果已经推送到远程公共仓库:用git filter-repo(官方推荐)或 BFG 工具彻底清理历史记录,清理后需要强制推送重写历史;
  • 第三步:如果是开源仓库,建议联系平台仓库管理员,彻底清理缓存记录。


结尾总结

Git 作为前端开发的必备工具,面试考察的核心从来不是你背了多少命令,而是你是否理解每个操作背后的原理,是否知道不同操作的风险和最佳实践。

核心记住两点:

  1. 公共分支永远用merge,保证安全和可追溯;私有分支可以用rebase整理提交历史,保持简洁;
  2. 所有会重写提交历史的操作(rebasereset --hardcommit --amend),绝对不要用在已经推送到远程的公共分支上。

这篇文章整理了我备战前端实习面试的 Git 核心笔记,希望能帮到同样在找工作的同学。如果觉得有用,欢迎点赞、收藏、评论,我会持续更新前端面试的干货内容~

面试爱问底层时,我是怎么读大型前端源码的❓❓❓

大家好 👋,我是 Moment,目前正在使用 Next.js、NestJS、LangChain 开发 DocFlow。这是一个面向 AI 场景的协同文档平台,集成了基于 Tiptap 的富文本编辑、NestJS 后端服务、实时协作与智能化工作流等核心模块。

在这个项目的持续打磨过程中,我积累了不少实战经验,不只是 Tiptap 的深度定制、编辑器性能优化和协同方案设计,也包括前端工程化建设、React 源码理解以及复杂项目架构实践。

如果你对 AI 全栈开发、文档编辑器、前端工程化或者 React 源码相关内容感兴趣,欢迎添加我的微信 yunmz777 一起交流。觉得项目还不错的话,也欢迎给 DocFlow 点个 star ⭐

image.png

网上类似的源码长文不少,我最近也在写 React 源码。作者往往写得尽兴、覆盖面也广,读者却不一定对得上自己的节奏:你想抠的那一点未必落在文章的主线上,而仓库一直在演进,成稿稍一搁置,对照现版就容易对不上号。

也正因如此,很多同学更倾向于亲自读源码。带着问题找答案,节奏和技术栈都更贴自己。

这篇想分享的是读大型前端开源项目(例如 ReactVueWebpackBabel)源码时怎么切入、怎么少迷路。目标很简单:授人以渔,让你在遇到新机制、底层实现或 Bug 时,能自己钻进去看清楚。

为什么读源码要先有问题

读之前先想清楚:为什么要打开仓库?

我的看法是,首要目的是解决实际问题。没有目标地"逛"仓库,像大海捞针,效率低也容易泄气。反过来,从一个具体问题出发,更容易把设计和实现串起来。

例如你可能会问:为什么在 React 里调用 setState 后,状态不会立刻改掉,而是走一批调度?这个问题会把你带到更新队列和调度相关代码上,比空读文件快得多。

如下图所示。

20260401084026

一图说清这条路径:先有问题,再定入口,沿调用栈往下跟,最后把理解收成自己的模型。

读新版别从第一个 commit 啃起

有人说从第一个 commit 顺着读能看懂演进。对极少数人可行,对大多数人来说性价比很低。以 React 为例,提交量极大,早期设计不少已废弃,啃旧代码对理解当下版本帮助有限。

更稳妥的做法是盯住当前主流版本:社区文章、视频、讨论多,卡住了好搜;API 和你项目里用的是同一世代;可以先读二手资料抓思路,再回仓库对细节。"资料 + 源码"比一行行硬读省时间。等你对现版熟了,再针对某个功能去翻 commit 和 PR,会更有数。

如下图所示。

20260401084233

一图把两种读法摆开:一种以手头在用的主版本为主线,资料帮着搭骨架,演进历史等站稳了再补;另一种是从第一个 commit 起顺序硬啃。取舍在哪,看图就明白。

读源码不是上来就梭哈

React 这种体量的仓库是进阶活。基础不够会越看越懵。建议先具备下面这些块,再往里钻(哪块弱就补哪块,本身也是正经学习)。

  • 语言:ES6+ 常用语法要熟,闭包、原型、异步和事件循环要真的用过,不然 Hooks 和调度相关代码很难读顺。
  • 框架:组件、propsstate、常用 HooksReact 18 里和并发、批更新、Suspense 相关的东西,至少用过再对照源码。
  • 调度直觉:时间片、优先级队列这类概念有个印象即可,读 FiberScheduler 时会轻松些。
  • 基础数据结构:树、链表、堆、 Diff 在干什么,知道个大概即可定位章节。
  • 浏览器与帧:FPSrequestAnimationFrame、为何怕长任务占主线程,有助于理解为什么要切片和中断渲染。

如下图所示。

20260401084431

该备的五块底子和边读边补哪弱补哪,下图一笔带过,正文就不摊开长清单了。

先把源码跑起来再说

第一步不是乱翻文件,而是按 READMECONTRIBUTING.md 把仓库构建起来、能断点。前端框架尽量用本地编出来的 development 包,别拿压缩过的生产包硬读。可以写最小 demo,或用 link 把本地包挂进项目里,贴近真实用法。CONTRIBUTING.md 里往往写了怎么跑测试、怎么编包,这部分本身就是读源码的序章。

如下图所示。

20260401084716

从克隆到能下断点的一圈步骤,对应正文不再逐句展开。

我那边的协同文档 Docflow 里也写了贡献说明和架构笔记,道理一样:先能构建、能跑,再谈读。

理清目录结构再看实现

大仓多是 Monorepo,packages/ 里一块一块职责分明。先当看地图,再进文件,不容易盲人摸象。

以 React 为例,常见分包包括:react(对外 API)、react-dom(对接 DOM)、react-reconciler(调和与更新)、scheduler(优先级与调度)、shared(公共工具)。心里有了这张表,搜到符号时才知道该进哪个包。

如下图所示。

20260401084924

React 各包各管一摊,读源码时先认准该进哪个包再翻文件,下面的版式把分工和这个习惯叠在一眼能扫开的地方。

如何调试 React 源码

调试前要会编开发包。示例流程:git clone React 仓库,yarn install,再 yarn build react/index,react-dom/index --type=NODE(或需要浏览器时用 --type=UMD_DEV)。更贴近日常的做法是建一个小项目,用 yarn link 把本地构建产物链进去。

搭建调试环境

具体命令随仓库文档变动,以官方 CONTRIBUTING.md 为准。下面只记思路:依赖装好、开发包产出、demo 或 link 接上。

如下图所示。

20260401085429

构建与 link、再在浏览器里下的那一套,和正文里的命令说明互补。

调试 useState 的执行流程

在业务组件里写一个最小 useState 示例。源码里对外声明多在 packages/react/src/ReactHooks.js,实现落在 packages/react-reconciler/src/ReactFiberHooks.jsmountStateupdateState。在几处入口加 debugger,重建后刷新页面,看调用栈:useStatedispatcher.useStatemountStateupdateState,再单步看链表与更新对象如何挂到 fiber 上。

调试 useEffect 的执行时机

useEffect 跨阶段更多:在 ReactFiberHooks.js 里看 mountEffectupdateEffect 如何在 render 阶段挂 effect,再到 ReactFiberCommitWork.js 里跟 commitLayoutEffectsflushPassiveEffects,能看清 passive 为何在布局后异步跑、为何不挡绘制。

调试技巧与注意事项

频繁触发的路径用条件断点(例如只在某个 fiber.type.name 上停)。if (__DEV__) 和纯告警逻辑可先跳过。Call StackScopeWatch 里盯住 fiber.memoizedStatefiber.updateQueue 等字段。双缓存时要分清当前在 current 还是 workInProgress 上,可配合 fiber.alternate 对照。

debugger 与全局搜索一起用

问题驱动的一个完整例子:搞清楚类组件里调用 setState 之后内部大致怎么走。先用全局搜索在 packages/react/src/ReactBaseClasses.js 找到入口,在本地加断点(下例仅示意,与仓库真实实现一致处请以你检出版本为准)。

// 示意:类组件 setState 入口会委托 updater(真实代码以仓库为准)
Component.prototype.setState = function (
  partialState: object | ((prev: any, props: any) => object) | null,
  callback?: () => void,
): void {
  this.updater.enqueueSetState(this, partialState, callback, "setState");
};

触发断点后跟栈,会进入 react-reconciler 里的 enqueueSetState、更新入队,再到 scheduleUpdateOnFiber 一类调度入口。下面用 Mermaid 收束主链路,细节仍靠你在关键函数上停。

20260401085625

Performance 面板适合观察并发下任务切片、长任务是否让出主线程;和源码里的时间片策略对照着看,比纯文字描述直观。

断点不要从入口无脑单步。beginWorkcompleteWorkcommitRoot、各生命周期与 Hooks 关键函数,按问题选挂。Node 工具链则多看插件注册与钩子调用处。主流程外的 __DEV__ 分支、冗长报错拼装,知道存在即可,不必逐行啃。

宏观上,React 一次更新可以粗分为 render(生成或调和 Fiber 树)与 commit(提交到 DOM)。render 里又可记 beginWork 向下、completeWork 向上;commit 里再分 beforeMutationmutationlayout 等子阶段。先记住这张骨架,再按需钻 reconcileChildrenflushPassiveEffects 之类细节。

如下图所示。

20260401085840

render 与 commit 的分段记忆图,和上面的 Mermaid 互补。

官方一手资料别浪费

维护者在博客、GitHub、演讲里解释"为什么这样设计"的句子,往往比第三手摘要靠谱。Issue 里长讨论、RFC 仓库里的提案与反对意见,都是源码的"旁白",代码告诉你是什么,这些文字告诉你为什么。按关键词搜 schedulerFiber、你关心的特性名,常能挖到设计取舍。

如下图所示。

20260401090038

博客、演讲、Issue、PR、RFC、发布说明,六类一手材料与"代码加为什么"的对照。

借助大模型但要自己验证

把难读的片段贴给模型,请它讲控制流和字段含义,能省大量初读时间。长 Issue、RFC 可先让模型摘要,再挑段落精读。仓库级助手(例如 Copilot 一类)适合问"谁调用了这个符号"。输出要当草稿,和本地断点、官方文档交叉验证,思维模型还是要自己搭。

如下图所示。

20260401090157

下面六道顺手用法之外,单独压一条硬底线:模型说得再顺,也要用本地断点和官方文档对一遍,不必在正文里一条条摊开。

总结

读大型源码,不必把全文再背一遍,抓住几条习惯就够把前面的方法串起来。

带着问题进门,版本对准你日常在用的主线,基础薄就先补。仓库能构建、能下断点,再谈细读。packages 当地图,先认包再走文件。debugger 配合全局搜索沿调用栈往下跟,官方讨论、Issue、RFC、发布说明补上代码里看不见的为什么。大模型可以加速梳理,最后一步仍要落回本机跑一遍,和官方文档对上。

如下图所示。

20260401090432

习惯之间的层次和先后,用上面这张比把前文再拉长复述更省事。

源码不玄,只是别人把取舍写进了可运行的形式里。节奏对了,会越读越轻。别指望一次吃透,那是慢功夫。路径熟了,换一套框架也能沿用同一套钻法。

🔥前端跨域封神解法:Vite Proxy + Express CORS,一篇搞定所有跨域坑!

🔥前端跨域封神解法:Vite Proxy + Express CORS,一篇搞定所有跨域坑!

全网最通俗跨域教程|前端 Vue/React 通用|后端仅 Express|开发 / 生产全覆盖

前言

做前端开发,跨域绝对是新手最崩溃的拦路虎!浏览器同源策略一拦,接口请求直接报错 No 'Access-Control-Allow-Origin',调试半天毫无头绪。

今天直接给你两套绝杀方案,全程只用到 Vite 代理 + Express 后端:✅ 本地开发用 Vite Proxy 代理(零后端改动,秒解决)✅ 线上生产用 Express CORS 配置(标准规范,永久生效)一文吃透,从此跨域再也不是问题!


一、先搞懂:到底什么是跨域?

浏览器同源策略:协议、域名、端口任意一个不同,就是跨域

举个例子:

  • 前端:http://localhost:5173(Vite 默认端口)
  • 后端:http://localhost:3000(Express 服务)端口不同 → 直接跨域,接口被浏览器拦截!

典型跨域报错:No 'Access-Control-Allow-Origin' header is present on the requested resource.


二、方案 1:本地开发神器 ✨ Vite Proxy 代理

核心原理

前端不直接请求后端,交给Vite 开发服务器做中间人转发,绕过浏览器同源限制,纯前端配置,后端零改动

完整配置(Vue / React 二选一)

1. Vue 版本
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  // Vue 编译插件
  plugins: [vue()],
  // 开发服务器配置
  server: {
    // 跨域代理核心配置
    proxy: {
      // 匹配所有 /api 开头的接口
      '/api': {
        target: 'http://localhost:3000', // Express 后端真实地址
        changeOrigin: true, // 🔥 关键:伪装来源,解决跨域
        pathRewrite: {
          '^/api': '' // 路径重写,前端 /api/login → 后端 /login
        }
      }
    }
  }
})
2. React 版本
// vite.config.js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  // React 编译插件
  plugins: [react()],
  // 代理配置和 Vue 完全一致!
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:3000',
        changeOrigin: true,
        pathRewrite: {
          '^/api': ''
        }
      }
    }
  }
})

关键配置解读

  • target:Express 后端接口真实地址
  • changeOrigin: true:伪装请求来源,让后端认为是同源请求
  • pathRewrite:路径重写,简化前端接口书写

适用场景

仅限本地开发环境上线打包后代理失效,生产环境必须用 CORS!


三、方案 2:生产环境标配 🚀 Express CORS 配置

核心原理

后端在响应头中添加跨域允许规则,明确告诉浏览器:允许这个前端域名访问我的接口。

需要配置的三个核心响应头:

Access-Control-Allow-Origin: 允许的前端域名
Access-Control-Allow-Methods: 允许的请求方法
Access-Control-Allow-Headers: 允许的请求头

完整 Express 配置(直接复制可用)

// 1. 初始化项目:npm init -y
// 2. 安装依赖:npm install express cors
const express = require('express')
const cors = require('cors')
const app = express()

// 解析 JSON 请求体
app.use(express.json())

// 🔥 CORS 核心配置(生产环境必写)
app.use(cors({
  // 允许访问的前端域名(本地开发/线上替换即可)
  origin: 'http://localhost:5173',
  // 允许的请求方式
  methods: ['GET', 'POST'],
  // 允许的请求头
  allowedHeaders: ['Content-Type'],
  // 允许携带Cookie(登录场景必开)
  credentials: true
}))

// 测试接口
app.get('/user', (req, res) => {
  res.send({ 
    code: 200, 
    msg: '请求成功',
    data: { name: '前端开发者' } 
  })
})

// 启动 Express 服务
app.listen(3000, () => {
  console.log('Express 服务启动:http://localhost:3000')
})

极简原生写法(不依赖 cors 包)

如果不想安装第三方包,直接手动设置响应头:

const express = require('express')
const app = express()
app.use(express.json())

// 手动配置 CORS 响应头
app.use((req, res, next) => {
  res.header('Access-Control-Allow-Origin', 'http://localhost:5173')
  res.header('Access-Control-Allow-Methods', 'GET,POST')
  res.header('Access-Control-Allow-Headers', 'Content-Type')
  res.header('Access-Control-Allow-Credentials', 'true')
  next()
})

// 接口
app.get('/user', (req, res) => {
  res.send({ code: 200, msg: '请求成功' })
})

app.listen(3000)

适用场景

生产环境正式上线Express 专属标准解决方案,全网通用。


四、Proxy vs CORS 到底怎么选?

表格

方案 适用环境 优点 缺点
Vite Proxy 本地开发 零后端改动,配置简单 上线失效
Express CORS 生产环境 标准规范,永久生效 需要后端配置

最佳实践开发用 Proxy,上线用 CORS,两套方案无缝衔接!


五、高频踩坑总结

  1. changeOrigin: true 忘记写 → 跨域依然报错
  2. 路径重写错误 → 接口 404
  3. CORS 域名配置错误 → 线上依然跨域
  4. 开发 / 生产配置混用 → 线上接口异常
  5. 请求方式超出允许范围 → 预检请求失败

结语

跨域根本不是难题,只是没找对方法!Proxy 搞定开发,CORS 搞定生产,照着这篇配置,从此和跨域报错说拜拜~

需要完整 Demo 源码的小伙伴,评论区扣「跨域」直接发你!

💡 关注我,持续输出前端硬核干货,Vue/React/Express 一站式学习!

Map / Set / WeakMap / WeakSet,一次给你讲透

面试中经常被问:你了解 WeakMap / WeakSet 吗?
实际开发中也常有人困惑:我什么时候该用 Map,而不是 Object?Weak 到底弱在哪?

这篇文章,我会从最熟悉的 Object 讲起,一步步到 Map、Set,最后深入 WeakMap 和 WeakSet。

一、从 Object 说起:我们最熟悉,也最容易踩坑

在 JavaScript 里,对象几乎无处不在:

const person = { name: "张三" };
console.log(person.name); // 张三

for (const key in person) {
  console.log(key, person[key]); // name 张三
}

delete person.name;
console.log(person.name); // undefined

我们对 Object 已经非常熟悉了:

  • 可以通过 .[] 访问属性
  • 可以用 for...in 遍历
  • 可以用 delete 删除属性

但 Object 天生就不是为了「做集合」设计的。

一个真实的小坑

假设你想做一个“字典”,key 可以是任意值:

const obj = {};
const a = {};
const b = {};

obj[a] = 'A';
obj[b] = 'B';

console.log(obj); // { "[object Object]": "B" }

你以为是两个 key,实际上:

  • 对象的 key 只能是字符串或 Symbol
  • 非字符串会被隐式转换成字符串

这也是 Map 诞生的原因之一

二、Map:为“键值对集合”而生

可以把 Map 理解成:一个“升级版 Object”,但专门用来存键值对

1. 创建和添加数据

const map = new Map();
map.set('name', '张三');
map.set('phone', 'iPhone');

特点很明确:

  • set(key, value) 添加数据
  • key 可以是任意类型(对象、函数、基本类型)
  • 同一个 key 只会存在一份
map.set('phone', 'Galaxy'); // 覆盖

2. 读取、判断、长度

map.get('phone'); // Galaxy
map.has('phone'); // true
map.size; // 2

3. Map 是可迭代的

这是它和 Object 的一个重要区别

for (const [key, value] of map) {
  console.log(key, value);
}
// name 张三
// phone Galaxy

要仅获取键或值,还有一些方法可供使用

map.keys() // MapIterator {'name', 'phone'}
map.values() // MapIterator {'张三', 'Galaxy'}
map.entries() // MapIterator {'name' => '张三', 'phone' => 'Galaxy'}
map.forEach(item => {})

甚至可以直接展开:

[...map]; // [['name', '张三'],['phone', 'Galaxy']]

4. 删除与清空

map.delete('phone'); // true
// 清空所有
map.clear(); // Map(0) {}

三、WeakMap:真正让人迷惑的地方来了

WeakMap起源于Map,因此它们彼此非常相似。但是,WeakMap 具有很大的不同

弱?弱在哪里?

核心一句话

WeakMap 的 key 是“弱引用”,不会阻止垃圾回收

1. key 只能是对象

const wm = new WeakMap();
wm.set({}, 'data'); // ✅
wm.set('a', 1);    // ❌ TypeError

原因很简单:

  • WeakMap 的设计目标:绑定对象的“附加信息”
  • 如果 key 是基本类型,就谈不上 GC

2. 为什么不能遍历?

想象这样一个场景:

let user = { name: 'John' };
const wm = new WeakMap();
wm.set(user, 'meta');

user = null; // 断开引用

这时候:

  • 垃圾回收 随时可能发生
  • WeakMap 中的数据 可能突然消失

如果还能遍历,那结果就是不稳定的

所以 ES 规范直接规定:

  • ❌ 不可遍历
  • ❌ 没有 size
  • ✅ 只有 get / set / has / delete

3. WeakMap 的真实使用场景

一个非常经典的例子:

const wm = new WeakMap();

function process(obj) {
  if (!wm.has(obj)) {
    wm.set(obj, { count: 0 });
  }
  wm.get(obj).count++;
}
  • 不污染原对象
  • 对象销毁后,数据自动释放
  • 不会内存泄漏

这也是 WeakMap 最大的价值。

四、Set:只关心“值是否存在”

如果说 Map 是 Object 的替代品,

Set 更像是“升级版数组”

1. 成员唯一

const set = new Set();
set.add(1);
set.add(1);
set.add(NaN);
set.add(NaN);

结果:

Set { 1, NaN }

规则总结:

  • 基本类型:值相同 → 只存一个
  • 引用类型:地址相同 → 只存一个
  • NaN 在 Set 中被认为是“相等的”

2. 可遍历

for (const val of set) {}
set.forEach(val => {})

3. 实战:数组去重、交并差集

[...new Set([1,1,2,3])]; // [1,2,3]

Set 在这类场景下,简洁又高效

五、WeakSet:存在,但很低调

WeakSet 和 WeakMap 的理念是一样的:

  • 成员是对象
  • 成员是弱引用
  • 不可遍历
let obj = { a: 1 };
const ws = new WeakSet();
ws.add(obj);

obj = null; // 被 GC

你永远不知道它什么时候“少了一个成员”,

所以:

WeakSet 适合做“对象存在性标记”,而不是数据容器


六、一张表彻底记住它们

类型 key/value 限制 是否可遍历 GC 影响
Object key 只能是字符串 可遍历 强引用
Map key 任意 强引用
WeakMap key 只能是对象 弱引用
Set value 任意 强引用
WeakSet value 只能是对象 弱引用

如何一句话答 WeakMap / WeakSet区别

WeakMap / WeakSet 的核心在于“弱引用 + 不可遍历”,
它们不会阻止垃圾回收,适合存放与对象生命周期绑定的附加数据,用来避免内存泄漏。

如果这篇文章对你有帮助,欢迎点赞、收藏,

彻底吃透 Promise:从状态、链式到手写实现,再到 async/await 底层原理

彻底吃透 Promise:从状态、链式到手写实现,再到 async/await 底层原理

面试必考,源码必问,日常必用 —— Promise 是 JavaScript 异步编程的基石。本文带你完整梳理 Promise 的核心知识,并深入 async/await 的底层实现。

一、为什么需要 Promise?

在 Promise 出现之前,我们靠回调函数处理异步。回调模式有三个致命问题:

  1. 回调地狱:异步任务层层嵌套,代码横向发展(金字塔结构),难以阅读和维护。
  2. 错误处理混乱:每个回调必须单独处理错误,容易遗漏;try/catch 无法捕获异步回调中的异常。
  3. 并发组合困难:并行执行多个任务并在全部完成后执行逻辑,需要手动计数器,极易出错。
  4. 信任问题(控制反转):将回调交给第三方库后,无法保证它会被正确调用(次数、时机、参数等)。

Promise 应运而生,它通过状态机 + 链式调用 + 统一错误处理 + 组合工具,彻底改变了异步编程的体验。


二、Promise 核心概念速览

2.1 三种状态

  • pending(进行中):初始状态。
  • fulfilled(已成功):调用 resolve 后到达此状态,并拥有一个最终 value
  • rejected(已失败):调用 reject 后到达此状态,并拥有一个最终 reason

重要规则:状态一旦定型(settled)就不可再变,且只能从 pending 转换为 fulfilledrejected

2.2 链式调用

thencatchfinally返回一个新 Promise,从而实现链式。

  • then(onFulfilled, onRejected):接收成功/失败回调。返回值决定新 Promise 的状态:

    • 返回普通值 → 新 Promise 用该值 resolve
    • 返回 Promise → 新 Promise 的状态与该 Promise 一致。
    • 抛出异常 → 新 Promise 用该错误 reject
    • 如果 onFulfilledonRejected 不是函数,会发生值穿透(原值直接传递)。
  • catch(onRejected):语法糖 then(undefined, onRejected)

  • finally(onFinally):无论成功失败都会执行,不接收参数,返回值被忽略(除非回调内抛出异常或返回 rejected Promise,则会中断链并传递新错误)。适合做清理工作。

2.3 静态方法一览

方法 行为 典型场景
Promise.all 全部成功才成功,任一失败则立即失败 多个接口数据都成功后才渲染页面
Promise.allSettled 等待所有定型,永不失败;返回结果状态数组 记录所有任务结果,即使部分失败
Promise.race 最快定型的 Promise 胜出(成功或失败) 设置超时计时
Promise.any 最快成功的 Promise 胜出;全部失败才失败 多个备用接口,取最快成功的响应
Promise.resolve 包装值为 resolved Promise 将 thenable 转换为真正 Promise
Promise.reject 包装值为 rejected Promise 快速返回失败

三、面试高频考点:事件循环与微任务

理解微任务(microtask)是写出正确 Promise 代码的前提。

  • 宏任务setTimeoutsetInterval、I/O、UI 渲染。
  • 微任务Promise.then/catch/finallyqueueMicrotaskMutationObserver

执行顺序:当前宏任务 → 所有微任务 → 下一个宏任务

经典例题

console.log(1);
setTimeout(() => console.log(2), 0);
Promise.resolve().then(() => console.log(3));
console.log(4);
// 输出:1,4,3,2

解释:先执行同步代码(1,4),然后清空微任务队列(3),最后执行下一个宏任务(2)。


四、手写一个符合 Promise/A+ 规范的简化版 Promise

面试中常要求手写简易 Promise,核心包含:构造函数、thencatchresolvereject,支持异步与链式调用。

下面是一个符合规范的实现(重点注释):

class MyPromise {
  constructor(executor) {
    this.state = 'pending';   // 'fulfilled' | 'rejected'
    this.value = undefined;
    this.reason = undefined;
    this.onFulfilledCallbacks = [];
    this.onRejectedCallbacks = [];

    const resolve = (value) => {
      if (this.state !== 'pending') return;
      this.state = 'fulfilled';
      this.value = value;
      this.onFulfilledCallbacks.forEach(fn => fn());
    };

    const reject = (reason) => {
      if (this.state !== 'pending') return;
      this.state = 'rejected';
      this.reason = reason;
      this.onRejectedCallbacks.forEach(fn => fn());
    };

    try {
      executor(resolve, reject);
    } catch (err) {
      reject(err);
    }
  }

  then(onFulfilled, onRejected) {
    // 值穿透处理
    onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : v => v;
    onRejected = typeof onRejected === 'function' ? onRejected : err => { throw err; };

    const promise2 = new MyPromise((resolve, reject) => {
      const fulfilledMicrotask = () => {
        queueMicrotask(() => {
          try {
            const x = onFulfilled(this.value);
            resolvePromise(promise2, x, resolve, reject);
          } catch (err) {
            reject(err);
          }
        });
      };

      const rejectedMicrotask = () => {
        queueMicrotask(() => {
          try {
            const x = onRejected(this.reason);
            resolvePromise(promise2, x, resolve, reject);
          } catch (err) {
            reject(err);
          }
        });
      };

      if (this.state === 'fulfilled') {
        fulfilledMicrotask();
      } else if (this.state === 'rejected') {
        rejectedMicrotask();
      } else if (this.state === 'pending') {
        this.onFulfilledCallbacks.push(fulfilledMicrotask);
        this.onRejectedCallbacks.push(rejectedMicrotask);
      }
    });

    return promise2;
  }

  catch(onRejected) {
    return this.then(null, onRejected);
  }

  static resolve(value) {
    if (value instanceof MyPromise) return value;
    return new MyPromise(resolve => resolve(value));
  }

  static reject(reason) {
    return new MyPromise((_, reject) => reject(reason));
  }
}

// 辅助函数:处理 then 返回的 x(可能是普通值、Promise 或 thenable)
function resolvePromise(promise2, x, resolve, reject) {
  if (promise2 === x) {
    return reject(new TypeError('Chaining cycle detected'));
  }
  if (x && (typeof x === 'object' || typeof x === 'function')) {
    let called = false;   // 防止多次调用 resolve/reject
    try {
      const then = x.then;
      if (typeof then === 'function') {
        then.call(
          x,
          y => {
            if (called) return;
            called = true;
            resolvePromise(promise2, y, resolve, reject);
          },
          r => {
            if (called) return;
            called = true;
            reject(r);
          }
        );
      } else {
        resolve(x);
      }
    } catch (err) {
      if (called) return;
      called = true;
      reject(err);
    }
  } else {
    resolve(x);
  }
}

关键点说明

  • 使用 queueMicrotask 模拟原生 Promise 的微任务行为。
  • 支持异步:当状态为 pending 时将回调存入队列,等待 resolve/reject 后执行。
  • 支持链式:then 返回新 Promise,并通过 resolvePromise 解包返回值。
  • 实现值穿透、错误冒泡、循环引用检测。

五、深入理解 async/await 的底层原理

async/await 是 ES2017 引入的语法糖,其底层基于 Promise + 生成器(Generator)

5.1 生成器 + Promise 模拟 async/await

生成器函数可以暂停(yield)和恢复(next),并且可以向外部传递值。利用这一点,我们可以编写一个执行器来自动驱动生成器,每次遇到 yield 就等待 Promise 完成,然后将结果传回生成器继续执行。

以下是一个简化版的执行器 run

function run(generatorFn) {
  const gen = generatorFn();

  function handle(result) {
    if (result.done) return Promise.resolve(result.value);
    return Promise.resolve(result.value).then(
      value => handle(gen.next(value)),
      error => handle(gen.throw(error))
    );
  }

  try {
    return handle(gen.next());
  } catch (err) {
    return Promise.reject(err);
  }
}

// 使用示例
function fetchData(url) {
  return new Promise(resolve => setTimeout(() => resolve(`数据来自 ${url}`), 1000));
}

const genAsync = function* () {
  const data1 = yield fetchData('https://api.example.com/user');
  console.log(data1);
  const data2 = yield fetchData('https://api.example.com/orders');
  console.log(data2);
  return '完成';
};

run(genAsync).then(console.log);

这段代码的行为与 async/await 完全一致:

async function asyncFunc() {
  const data1 = await fetchData('https://api.example.com/user');
  console.log(data1);
  const data2 = await fetchData('https://api.example.com/orders');
  console.log(data2);
  return '完成';
}
asyncFunc().then(console.log);

5.2 编译转换(Babel 视角)

当使用 Babel 将 async/await 编译到 ES5 时,会将其转换为生成器 + 执行器(或 Promise 链)。例如:

// 源代码
async function foo() {
  const a = await bar();
  return a;
}

// Babel 简化输出(类似)
function foo() {
  return _asyncToGenerator(function* () {
    const a = yield bar();
    return a;
  })();
}

其中 _asyncToGenerator 就是一个类似于上面 run 的执行器。

5.3 总结:async/await 的本质

层级 实现机制
最上层 async/await 语法(开发者编写)
转译/编译层 转换为生成器 + 执行器 或 Promise 链
执行层 生成器的 yield 暂停能力 + Promise 的异步通知
底层运行时 微任务(Microtask) + 事件循环

因此,理解 async/await 的关键在于掌握:

  1. Promise 提供了异步结果的标准表示和组合能力。
  2. 生成器 提供了函数执行的可暂停、可恢复能力。
  3. 执行器 将两者粘合,自动处理 Promise 的完成和拒绝,驱动生成器继续执行。

这也解释了为什么 async 函数总是返回 Promise,以及 await 只能出现在 async 函数中——因为生成器模式需要外部执行器驱动,而 async 函数正是这个执行器的容器。


六、高频面试题精选(附解答要点)

1. Promise 有哪几种状态?状态之间如何转换?

  • 三种:pendingfulfilledrejected
  • 转换:pending → fulfilled(调用 resolve),pending → rejected(调用 reject)。状态一旦定型不可逆。

2. then 方法返回的是什么?如何实现链式调用?

  • 返回一个新 Promise。新 Promise 的状态由回调的返回值决定。通过返回新 Promise 实现链式。

3. 什么是 Promise 的“值穿透”?举例。

  • 如果 then 传入非函数,则忽略该参数,原值直接传递下去。
Promise.resolve(42).then(null).then(v => console.log(v)); // 42

4. finally 能改变返回值吗?

  • 不能。返回值被忽略,原 Promise 的值或原因会继续传递。除非 finally 回调抛出异常或返回 rejected Promise,则会传递新错误。

5. Promise.allPromise.allSettled 的区别?

  • all:全部成功才成功,任一失败则立即失败(短路)。
  • allSettled:等待所有定型,总是成功,返回每个结果的状态对象数组。

6. 如何捕获 Promise 链中的错误?

  • 使用链尾的 .catch(),它会捕获链中任何地方抛出的错误(包括 then 回调中抛出的错误)。

7. 简述 Promise 的实现原理(手写简化版)。

  • 状态机 + 回调队列 + then 返回新 Promise + 微任务调度。详见上文实现。

8. 什么是微任务?为什么 Promise 的回调是微任务?

  • 微任务在当前宏任务执行完毕后、下一个宏任务之前执行。Promise 回调设为微任务是为了让异步结果尽快被处理,同时保持顺序可预测。

9. async/await 的底层实现是什么?

  • 基于 Promise 和生成器(Generator)的语法糖。通过执行器自动驱动生成器,每次 yield 一个 Promise,等待完成后恢复执行。

10. 如何将 Node.js 回调风格 API 转换为 Promise?

  • 使用 util.promisify 或手动 new Promise 包装。

七、实战:使用 Promise.race 实现请求超时

function fetchWithTimeout(url, timeoutMs = 5000) {
  const fetchPromise = fetch(url).then(res => res.json());
  const timeoutPromise = new Promise((_, reject) =>
    setTimeout(() => reject(new Error('请求超时')), timeoutMs)
  );
  return Promise.race([fetchPromise, timeoutPromise]);
}

// 使用
fetchWithTimeout('https://api.example.com/data', 3000)
  .then(data => console.log(data))
  .catch(err => console.error(err.message));

注意Promise.race 不会取消未完成的请求,但可以控制超时后的行为。如果需要真正取消请求,可结合 AbortController


八、总结

Promise 的出现统一了 JavaScript 的异步模式,解决了回调地狱、错误处理和组合难的问题。掌握 Promise 是理解现代前端异步编程的基石,而 async/await 则是在 Promise 之上的优雅语法糖,其底层依赖生成器与执行器。希望本文能帮助你彻底吃透 Promise,并在面试和实战中游刃有余。

如果觉得有帮助,欢迎点赞、收藏、评论交流!

从一道前端面试题,聊到朋友做实时通信时的心跳检测

大家好~这篇算是上一篇「前端倒计时不准怎么优化」的延伸。本来只是吃透一道面试题,结果发现同一个思路,居然能用到实时通信里,而且还是朋友做项目时真实踩过的坑,今天用大白话跟大家分享一下。

一、先快速回顾:那道面试题的核心

上一篇我们聊到:用 setInterval 做倒计时为什么不准?因为它是靠 “执行了多少次” 来计时,页面一卡、一切后台,定时器就会偷懒少跑,时间就偏了。

真正靠谱的方案是: 别靠次数,靠时间戳差值 不管定时器怎么延迟,用「目标时间 - 当前时间」算出来的结果永远是准的。

后来我发现,这个思路在WebSocket 心跳检测里简直是一模一样的用法。

二、WebSocket 到底是个啥?(人话版)

平时我们上网,都是浏览器问一句、服务器答一句,叫 HTTP。但像聊天、弹幕、实时数据这种场景,需要服务器主动推消息,HTTP 就不太合适了。

所以会用到 WebSocket:浏览器和服务器建立一条 “长连接”,一直保持通话,服务器有消息就直接推过来。 传感器那边一有新数据,服务器直接推给前端,前端不用傻傻地一遍遍问:“有新数据吗?有新数据吗?”

这就是实时通信

聊天、弹幕、股票、传感器数据,基本都靠它。

而且它不是插件、不是库,是浏览器原生自带的 API,直接写就能用。

三、用上 WebSocket 就万事大吉了?并没有

以为连上就完事,结果踩了一堆坑:

1. 连接会莫名其妙断掉

  • 网络假死

    • 连接表面还在,实际已经断了(WiFi 切换、路由器重启、弱网),WebSocket 不会自动感知。
  • 服务器踢人

    • 网关会自动断开 “长时间不说话” 的空闲连接,心跳就是用来 “刷存在感”。
  • 及时发现异常

    • 有时候断了前端都不知道,导致消息发不出去、用户体验极差。

2. 不知道连接到底还活不活着

网络有时候会 “假死”:看着连着,其实早就断了,前端还在傻傻等数据。

3. 断了之后不能自动重连

总不能让用户手动刷新页面吧?


四、解决办法:心跳检测 + 断线重连

这时候,最开始那道倒计时面试题的思路就用上了:

什么是心跳检测?

就像两个人打电话,每隔一会儿说一句:“我还在哦。”对方回:“我也在。”

  • 前端每隔几十秒发一个小包(心跳包)
  • 服务器收到后回复一下
  • 一段时间没回复,就认为连接挂了

先明确:WebSocket 自带心跳吗?

结论:不带!必须开发者自己写!

WebSocket 只负责建立连接、收发数据,心跳、保活、断线重连、超时判断,全都要自己写

这里刚好用到面试题的技巧:

不用 “定时器跑了多少次” 来判断超时,而是用:当前时间 - 最后一次收到回复的时间只要差值超过某个时间,就判定断开,直接重连。

完美复用了倒计时那套 “用时间差值,不靠次数” 的思想。

额外一个小细节:

浏览器切到后台、锁屏或休眠时,WebSocket 可能被系统冻结,表面不断开实则已失效。

可以在页面切回前台时,主动检查一次连接状态:

js

document.addEventListener('visibilitychange', () => {
  if (!document.hidden && ws) {
    // 回到前台,检查是否还在线
    if (!ws.isConnected) {
      ws.reconnect();
    }
  }
});

五、WebSocket 上线后还会遇到哪些难点?(深度拓展)

WebSocket 只解决了 “实时推送” 的基础问题,真正到生产环境落地,还会遇到一大堆工程化和稳定性的难点,我按开发→上线→运维的顺序,用大白话给大家拆解开,小白也能看懂:

1️⃣ 数据可靠性痛点

痛点 1:消息会丢失

场景:网络闪断瞬间,正在传输的传感器数据直接消失,用户看不到完整数据。详细解决方法

  1. 消息确认机制(ACK)

    • 前端发消息时,给每条消息加唯一 msgId,并启动一个超时定时器(比如 5 秒)。
    • 服务端收到后,必须回复 { type: 'ack', msgId: 'xxx' } 确认。
    • 前端如果在超时时间内没收到 ACK,就重新发送这条消息(最多重发 3 次,避免无限循环)。

js

// 封装一个完整的 WebSocket 客户端(带心跳 + 重连)
class WebSocketClient {
  // 构造函数:初始化所有配置
  constructor(url) {
    this.url = url; // WebSocket 服务端地址
    this.ws = null; // 存放 WebSocket 实例
    this.isConnected = false; // 标记是否连接成功

    // ==================== 心跳配置 ====================
    // 心跳发送间隔:3秒发一次
    this.heartBeatInterval = 3000;
    // 记录最后一次收到心跳回复的时间(核心:用时间戳判断)
    this.lastHeartBeatAckTime = Date.now();
    // 心跳定时器
    this.heartBeatTimer = null;

    // ==================== 重连配置 ====================
    this.reconnectTimer = null; // 重连定时器
    this.reconnectDelay = 3000; // 断开后 3 秒重连
  }

  // 初始化 WebSocket 连接
  connect() {
    this.ws = new WebSocket(this.url);

    // ==================== 连接成功触发 ====================
    this.ws.onopen = () => {
      console.log("✅ WebSocket 连接成功");
      this.isConnected = true;
      this.startHeartBeat(); // 连接成功 → 立刻启动心跳
    };

    // ==================== 收到服务端消息 ====================
    this.ws.onmessage = (evt) => {
      const data = JSON.parse(evt.data);

      // 如果是心跳响应 → 更新最后收到心跳的时间
      if (data.type === "heartbeat_ack") {
        this.lastHeartBeatAckTime = Date.now();
        return;
      }

      // 普通业务数据(比如传感器/实时消息)
      console.log("📡 收到实时数据:", data);
    };

    // ==================== 连接断开触发 ====================
    this.ws.onclose = () => {
      console.log("🔌 连接断开,准备重连...");
      this.isConnected = false;
      this.stopHeartBeat(); // 断开 → 停止心跳
      this.reconnect(); // 自动重连
    };

    // ==================== 连接报错触发 ====================
    this.ws.onerror = (err) => {
      console.error("❌ 连接异常", err);
    };
  }

  // ==================== 心跳检测核心方法 ====================
  startHeartBeat() {
    this.heartBeatTimer = setInterval(() => {
      // 向服务端发送心跳包
      this.ws.send(JSON.stringify({ type: "heartbeat" }));

      // ==================== 重点:用时间差判断是否超时 ====================
      // 和倒计时面试题同一个思路:不用计数,用时间戳差值
      const now = Date.now();
      // 超过 2 个心跳周期没回复 → 判断断开
      if (now - this.lastHeartBeatAckTime > this.heartBeatInterval * 2) {
        console.log("💀 心跳超时,开始重连");
        this.close(); // 关闭旧连接
        this.reconnect(); // 触发重连
      }
    }, this.heartBeatInterval);
  }

  // 停止心跳
  stopHeartBeat() {
    clearInterval(this.heartBeatTimer);
  }

  // ==================== 断线自动重连 ====================
  reconnect() {
    // 防止重复重连
    if (this.reconnectTimer) return;
    this.reconnectTimer = setTimeout(() => {
      this.connect(); // 重新创建连接
      this.reconnectTimer = null;
    }, this.reconnectDelay);
  }

  // 关闭连接 + 清理心跳
  close() {
    this.ws?.close();
    this.stopHeartBeat();
  }
}

// ==================== 使用方式 ====================
// 创建客户端实例
const ws = new WebSocketClient("ws://localhost:8080/sensor");
// 启动连接
ws.connect();

我们把整个流程拆成「正常运行」和「异常断连」两个场景,用大白话描述:

场景 1:正常连接 & 心跳保活

  1. 建立连接:前端调用 connect(),和服务端建立 WebSocket 连接,连接成功后触发 onopen

  2. 启动心跳:连接成功后立刻调用 startHeartBeat(),开启一个每 3 秒执行一次的定时器。

  3. 发送心跳:定时器每 3 秒向服务端发送 {type: "heartbeat"} 心跳包。

  4. 服务端响应:服务端收到心跳后,回复 {type: "heartbeat_ack"} 心跳响应包。

  5. 更新时间戳:前端收到 heartbeat_ack 后,立刻更新 lastHeartBeatAckTime = 当前时间

  6. 超时判断:每次发心跳时,都会计算「当前时间 - 最后心跳响应时间」:

    • 如果差值 ≤ 6 秒(2 个心跳周期):说明连接正常,继续循环。
    • 如果差值 > 6 秒:说明服务端没回应,判定连接假死

场景 2:连接异常 & 自动重连

  1. 触发超时:连续 2 个心跳周期(6 秒)没收到 heartbeat_ack,判定连接断开。

  2. 关闭旧连接:调用 close() 主动关闭当前无效连接,同时停止心跳定时器。

  3. 触发重连:调用 reconnect(),等待 3 秒后(避免重连风暴)重新执行 connect()

  4. 重新连接:新的 connect() 尝试和服务端建立连接:

    • 连接成功:回到「正常连接 & 心跳保活」流程,继续发心跳。
    • 连接失败:触发 onclose,再次进入重连逻辑,直到连接恢复。

后续场景:

离线消息缓存

-   服务端给每个连接维护一个「待推送消息队列」,当客户端断开时,消息暂存队列。
-   客户端重连成功后,服务端先把队列里的未读消息全部推送过去,再推送新消息。

痛点 2:消息乱序 / 重复

场景:重连后消息顺序打乱,或者同一条消息被重复推送,导致页面展示错误。详细解决方法

  1. 消息序号 + 时间戳

    • 服务端推送消息时,必须带上自增 seq(序号)和 timestamp(时间戳)。
    • 前端维护一个 lastSeq 变量,只处理 seq > lastSeq 的消息,保证顺序。
  • lastSeq 是前端维护的一个变量,用来记录最后一次成功处理的消息序号

    • 初始值一般设为 0(表示还没处理过任何消息)
    • 每次处理完一条新消息,就把 lastSeq 更新为这条消息的序号 data.seq
    • 作用:记住 “我已经处理到哪条消息了”
  • if (data.seq > lastSeq)消息去重 + 保证顺序的核心判断逻辑:

    • data.seq:服务端推送过来的当前消息的序号(自增,比如 1、2、3、4...)

    • 条件 data.seq > lastSeq

      • ✅ 如果当前消息序号 大于 上次处理的序号 → 说明是新消息、顺序正确,可以渲染 / 处理
      • ❌ 如果当前消息序号 小于等于 上次处理的序号 → 说明是旧消息 / 重复消息 / 乱序消息,直接丢弃,不处理

    js

    let lastSeq = 0;
    ws.onmessage = (e) => {
      const data = JSON.parse(e.data);
      if (data.seq > lastSeq) {
        renderData(data); // 只渲染顺序正确的消息
        lastSeq = data.seq;
      }
    };
    
  1. 去重机制

    • 前端维护一个 Set 存储已处理的 msgId,收到消息先判断是否存在,存在则直接丢弃。

    js

    const processedMsgIds = new Set();
    ws.onmessage = (e) => {
      const data = JSON.parse(e.data);
      if (processedMsgIds.has(data.msgId)) return;
      renderData(data);
      processedMsgIds.add(data.msgId);
    };
    

痛点 3:大数据传不了

场景:WebSocket 单条消息有大小限制(通常 64KB 左右),传大文件 / 海量传感器数据会直接失败。详细解决方法

  1. 分片传输 + 前端重组

    • 把大数据拆成固定大小的分片(比如 16KB / 片),每个分片带上 chunkId(分片序号)、totalChunks(总分片数)、msgId(所属消息 ID)。
    • 前端收到所有分片后,按 chunkId 顺序拼接成完整数据。

    js

    // 前端分片重组示例
    const chunkMap = new Map(); // key: msgId, value: { chunks: [], total: number }
    ws.onmessage = (e) => {
      const chunk = JSON.parse(e.data);
      if (!chunkMap.has(chunk.msgId)) {
        chunkMap.set(chunk.msgId, {
            chunks: new Array(chunk.totalChunks), total: chunk.totalChunks 
        });
      }
      const entry = chunkMap.get(chunk.msgId);
      entry.chunks[chunk.chunkId] = chunk.data;
      
      // 所有分片都收到了,开始重组
      if (entry.chunks.every(c => c != null)) {
        const fullData = entry.chunks.join('');
        renderData(fullData);
        chunkMap.delete(chunk.msgId);
      }
    };
    

为什么 every(c => c!= null) 能代表接收完毕?

  • 这是 “前端分片重组” 的约定:
    • 背后有一个硬性前提(这是大文件上传 / 大消息传输的通用标准):

    • 服务端(后端)在发送分片时,必须按顺序编号!

为什么不会有空分片的情况?

1. 后端不会发空包
  • 在 “分片传输” 场景下,空的分片(null)是没有业务意义的。

    • 一个完整的大文件,被切分成了 10 块,每一块都有内容。
    • 后端不可能只发了 9 块,第 10 块发一个 null
    • 规则:每一个 chunkId 对应的,必须是一段真实的数据。
2. null 代表的是 “未收到”,不是 “空数据”

在这段代码里:

  • entry.chunks = new Array(chunk.totalChunks)

    • 这行先创建了一个空数组,长度是总片数。
    • 此时数组里全是 empty(空槽),但这还不是 null
  • 当收到第 0 片时,entry.chunks[0] = chunk.data

    • 这一格被填满了。
  • 如果网络丢包了:比如第 2 片没收到。

    • entry.chunks[2] 就永远是 empty(或者被你初始化为 null)。
    • 此时 every(c => c!= null) 就会返回 false
    • 代码就不会拼接,会继续等待,直到补全了第 2 片。

如果网络丢包了,前端必须要做的处理

你不能让它无限等下去,通常要加这些机制:

  • 超时机制:给每个分片集合设置一个等待超时时间(比如 30s),超时后主动抛出错误或重试。
  • 重传机制:检测到丢包后,向服务端请求重传丢失的分片。
  • 兜底策略:如果多次重传仍失败,给用户提示 “网络不稳定,部分内容加载失败”,而不是一直转圈。
  • 进度反馈:告诉用户当前已收到多少分片、还在等待哪几片,避免用户以为页面卡死。

js

// 超时后处理
if (isTimeout(entry)) { 
    if (retryCount < MAX_RETRY) { 
        retryCount++; requestMissingChunks(entry); // 重传丢失的分片 
    } else {
        showError("加载失败,请检查网络"); } return; 
    }
}

2️⃣ 业务与性能痛点

痛点 1:百万级连接扛不住

场景:上千个传感器同时连接,服务器内存暴涨、连接数过载,甚至崩溃。详细解决方法

  1. 服务端高性能框架

    • 用 Netty(Java)、Node.js Cluster、Go 等高性能框架,利用多核心 CPU 处理连接,避免单线程瓶颈。
    • 开启连接复用、内存池优化,减少每个连接的内存占用。
  2. 负载均衡 + 水平扩展

    • 用 Nginx 或云服务商负载均衡器,把连接分发到多台服务器。
    • 服务器之间通过共享存储(如 Redis)同步用户连接状态,实现水平扩容。

痛点 2:不知道消息推送给谁

场景:多个传感器分组、不同用户看不同设备数据,推送混乱、浪费资源。详细解决方法

  1. Pub/Sub(发布 - 订阅)模式

    • 把每个传感器 / 用户组抽象成一个频道(Channel)
    • 客户端连接后,订阅自己需要的频道(比如 sensor:temp:room1)。
    • 服务端只往有订阅者的频道推送消息,避免无效推送。
    • 可以用 Redis Pub/Sub、MQTT、Kafka 等现成组件实现。

    js

    // 前端订阅示例
    ws.send(JSON.stringify({ type: 'subscribe', channel: 'sensor:temp:room1' }));
    

痛点 3:前端页面卡顿

场景:传感器每秒推 100 条数据,前端频繁渲染 DOM 导致页面卡死、崩溃。详细解决方法

  1. Web Worker 处理数据

    • 把数据解析、计算逻辑放到 Web Worker 里,不和主线程抢资源,避免阻塞 UI 渲染。

    js

    // main.js-页面主线程
    // 主线程(页面)只负责渲染和收消息,所有耗时计算都扔给 Web Worker 去做,不让页面卡顿
    // 1. 创建一个后台工作线程
    const worker = new Worker('data-worker.js');
    
    // 2. 监听 Worker 算完后发回来的结果
    worker.onmessage = (e) => {
      renderData(e.data); // 只做一件事:渲染页面
    };
    
    // 3.  websocket 收到数据 → 直接扔给 Worker,不自己算
    ws.onmessage = (e) => {
      worker.postMessage(e.data); 
    };
    
    // data-worker.js -后台独立线程,专门算东西,不影响页面
    // 监听主线程发来的数据
    self.onmessage = (e) => {
      // 这里做耗时计算!!!
      const processedData = parseAndCalculate(e.data); 
    
      // 算完 → 发回给主线程
      self.postMessage(processedData);
    };
    
  2. 节流渲染

    • setTimeoutrequestAnimationFrame 做节流,比如 100ms 内只渲染一次最新数据。

    js

    let lastRenderTime = 0;
    let pendingData = null;
    ws.onmessage = (e) => {
      pendingData = JSON.parse(e.data);
      requestAnimationFrame(() => {
        const now = performance.now();
        if (now - lastRenderTime > 100) {
          renderData(pendingData);
          lastRenderTime = now;
        }
      });
    };
    

3️⃣ 安全与合规痛点

痛点 1:谁都能连,数据不安全

场景:未做身份验证,任何人都能连接窃取传感器数据。

详细解决方法

  1. Token 身份验证

    • WebSocket 握手时,在 URL 或 Header 里带上 Token(比如 wss://xxx.com?token=xxx)。
    • 服务端先校验 Token 有效性,无效则直接拒绝连接。

    js

    // 前端连接示例
    const ws = new WebSocket(
    `wss://xxx.com/sensor?token=${localStorage.getItem('token')}`
    );
    
  2. 细粒度权限控制

    • 服务端根据 Token 对应用户的权限,只允许订阅 / 发送自己有权限的设备数据,比如普通用户只能看自己的传感器,管理员才能看所有。

痛点 2:数据会被窃听、篡改

场景:明文传输时,数据在网络中可能被截获、修改。

详细解决方法

  1. 必须用 wss:// 协议

    • wss:// 是基于 TLS 加密的 WebSocket,和 https:// 一样,数据在传输过程中会被加密,防止窃听和篡改。
    • 绝对不要在生产环境用 ws://(明文)。
  2. 敏感数据额外加密

    • 对特别敏感的数据(比如用户隐私、设备核心参数),在发送前用 AES 等对称加密算法加密,接收后再解密,进一步提升安全性。

痛点 3:恶意攻击耗尽服务器资源

场景:攻击者建立大量虚假连接,或疯狂发送消息,导致正常设备无法接入。

详细解决方法

  1. 连接 / 频率限制

    • 限制单个 IP 最多只能建立 10 个连接,超过则拒绝。
    • 限制单个连接每秒最多发送 10 条消息,超过则断开连接。
  2. 消息大小限制

    • 服务端设置单条消息最大长度(比如 64KB),超过则直接丢弃,防止超大消息占用带宽。

4️⃣ 调试与监控痛点

痛点 1:出问题找不到原因

场景:断连、丢消息等问题很难复现,日志分散,排查效率极低。

详细解决方法

  1. 全链路追踪

    • 接入 OpenTelemetry 等工具,给每个连接、每条消息生成唯一 Trace ID,记录从客户端→服务端→数据库的完整调用链路。
    • 出问题时,通过 Trace ID 就能快速定位是哪一步出了问题。
  2. 消息日志留存

    • 服务端记录所有消息的收发日志(包含 msgIdseq、时间戳、发送 / 接收方),方便回溯问题发生时的上下文。

痛点 2:不知道服务运行状态

场景:服务器连接数、消息延迟、断连率等指标无监控,异常时无法及时发现。

详细解决方法

  1. 核心指标监控

    • 用 Prometheus + Grafana 监控以下指标:

      • 在线连接数
      • 消息吞吐量(条 / 秒)
      • 平均消息延迟(毫秒)
      • 断连率(断开连接数 / 总连接数)
      • 消息丢失率
  2. 告警规则配置

    • 当连接数突增 50%、延迟超过 200ms、断连率超过 10% 时,自动通过钉钉 / 企业微信 / 邮件通知运维人员。

痛点 3:环境不兼容,功能用不了

场景:旧浏览器(如 IE11)、特殊网络(如企业防火墙)不支持 WebSocket,用户无法使用功能。

详细解决方法

  1. 自动降级方案

    • 前端先检测浏览器是否支持 WebSocket,不支持则自动切换为 长轮询(Long Polling)

      js

      if (window.WebSocket) {
        // 用WebSocket
      } else {
        // 用长轮询:前端发请求,服务端hold住请求,有新数据时再返回,然后前端立刻发起下一次请求
        function longPoll() {
          fetch('/api/long-poll')
            .then(res => res.json())
            .then(data => {
              renderData(data);
              longPoll(); // 立刻发起下一次请求
            });
        }
        longPoll();
      }
      
  2. 友好 Fallback UI

    • 降级时给用户提示:「当前环境不支持实时通信,已切换为普通模式,数据每 30 秒自动刷新」,避免用户困惑。

六、最后聊聊

从一道倒计时面试题,意外挖到 WebSocket 心跳的通用思路,还挺有意思的。

很多时候我们觉得实时通信复杂,其实拆开看,无非就是:保证连接活着、保证消息不丢、保证页面不卡。

真正上线后你会发现,WebSocket 本身不难,难的是各种网络异常、弱网、断连、重复消息、卡顿……能把这些 “边角情况” 都兜住,才算一个能用在生产里的稳定方案。

如果你也在做聊天、大屏、传感器数据这类实时需求,欢迎在评论区说说你遇到过什么奇奇怪怪的坑,我们一起交流~

React 滚动效果:告别第三方库

滚动是 Web 上最基础的用户交互。随阅读进度填充的进度条、滑动后缩小并吸顶的导航栏、打开弹窗时锁定背后页面的滚动、点击按钮平滑跳转到指定区域——这些效果几乎出现在每个现代网站上。然而在 React 中正确实现它们,意味着你要同时处理 addEventListenerIntersectionObserveroverflow 样式以及一大堆意想不到的边界情况。大多数开发者要么引入一个沉重的动画库,要么花几个小时写出脆弱的命令式代码。

本文选择另一条路。我们将逐一攻克六个常见的滚动场景,每个场景先展示手动实现,让你理解底层原理,然后用 ReactUse@reactuses/core)中对应的 Hook 替换。ReactUse 是一个开源的 React Hook 集合,提供 100 多个封装了常见浏览器和元素交互的 Hook。读完之后,你将拥有一组可组合、SSR 安全的 Hook 工具箱,涵盖滚动追踪、滚动锁定、平滑滚动、吸顶检测、可见性检测和交叉观察——全程不需要任何外部动画或滚动库。

1. 追踪滚动位置

手动实现

追踪用户的滚动距离看起来很简单,但一旦要考虑节流、方向检测以及判断用户是否滚到了边缘,复杂度就上来了。

import { useEffect, useRef, useState } from "react";

function ManualScrollTracker() {
  const containerRef = useRef<HTMLDivElement>(null);
  const [scrollY, setScrollY] = useState(0);
  const [direction, setDirection] = useState<"up" | "down">("down");
  const lastY = useRef(0);

  useEffect(() => {
    const el = containerRef.current;
    if (!el) return;

    const onScroll = () => {
      const y = el.scrollTop;
      setDirection(y > lastY.current ? "down" : "up");
      lastY.current = y;
      setScrollY(y);
    };

    el.addEventListener("scroll", onScroll, { passive: true });
    return () => el.removeEventListener("scroll", onScroll);
  }, []);

  const progress = containerRef.current
    ? scrollY /
      (containerRef.current.scrollHeight - containerRef.current.clientHeight)
    : 0;

  return (
    <div>
      <div
        style={{
          position: "fixed",
          top: 0,
          left: 0,
          height: 4,
          width: `${progress * 100}%`,
          background: "#4f46e5",
          transition: "width 0.1s",
        }}
      />
      <div
        ref={containerRef}
        style={{ height: "100vh", overflow: "auto" }}
      >
        {/* 长内容 */}
      </div>
    </div>
  );
}

对于一个简单的进度条来说够用了,但它无法告诉你用户是否已经滚到底部,不支持横向滚动追踪,方向检测也很粗糙——惯性滚动中一个像素的反弹就会翻转方向。如果还要加上"到达边缘"的阈值判断,状态管理和计算量会更多。

用 useScroll

useScroll 返回当前的 xy 偏移量、双轴滚动方向,以及 isScrollingarrivedState 布尔值,后者会告诉你用户是否到达了上、下、左、右边缘。

import { useScroll } from "@reactuses/core";
import { useRef } from "react";

function ScrollTracker() {
  const containerRef = useRef<HTMLDivElement>(null);

  const [position, direction, arrivedState, isScrolling] = useScroll(
    containerRef,
    { throttle: 50 }
  );

  const el = containerRef.current;
  const progress = el
    ? position.y / (el.scrollHeight - el.clientHeight)
    : 0;

  return (
    <div>
      {/* 进度条 */}
      <div
        style={{
          position: "fixed",
          top: 0,
          left: 0,
          height: 4,
          width: `${Math.min(progress * 100, 100)}%`,
          background: "#4f46e5",
          zIndex: 50,
        }}
      />

      {/* 滚动信息浮层 */}
      <div
        style={{
          position: "fixed",
          bottom: 16,
          right: 16,
          padding: "8px 16px",
          background: "#1e293b",
          color: "#fff",
          borderRadius: 8,
          fontSize: 14,
          zIndex: 50,
        }}
      >
        <div>Y: {Math.round(position.y)}px</div>
        <div>方向: {direction.y ?? "无"}</div>
        <div>
          {arrivedState.bottom
            ? "已到达底部!"
            : isScrolling
              ? "滚动中..."
              : "空闲"}
        </div>
      </div>

      <div
        ref={containerRef}
        style={{ height: "100vh", overflow: "auto" }}
      >
        {Array.from({ length: 100 }, (_, i) => (
          <p key={i} style={{ padding: "8px 16px" }}>
            第 {i + 1} 段
          </p>
        ))}
      </div>
    </div>
  );
}

一次 Hook 调用就替代了所有手动事件绑定、方向追踪和边缘检测。内置的 throttle 选项保证即使在高频 scroll 事件下也能保持流畅。

2. 弹窗滚动锁定

手动实现

打开弹窗时,你需要阻止弹窗背后的页面继续滚动。经典做法是给 body 加上 overflow: hidden

import { useEffect, useState } from "react";

function ManualModal() {
  const [isOpen, setIsOpen] = useState(false);

  useEffect(() => {
    if (isOpen) {
      const scrollY = window.scrollY;
      document.body.style.position = "fixed";
      document.body.style.top = `-${scrollY}px`;
      document.body.style.width = "100%";
      document.body.style.overflow = "hidden";

      return () => {
        document.body.style.position = "";
        document.body.style.top = "";
        document.body.style.width = "";
        document.body.style.overflow = "";
        window.scrollTo(0, scrollY);
      };
    }
  }, [isOpen]);

  return (
    <>
      <button onClick={() => setIsOpen(true)}>打开弹窗</button>
      {isOpen && (
        <div
          style={{
            position: "fixed",
            inset: 0,
            background: "rgba(0,0,0,0.5)",
            display: "flex",
            alignItems: "center",
            justifyContent: "center",
            zIndex: 100,
          }}
        >
          <div
            style={{
              background: "#fff",
              padding: 24,
              borderRadius: 12,
              maxWidth: 400,
            }}
          >
            <h2>弹窗标题</h2>
            <p>背后的页面无法滚动。</p>
            <button onClick={() => setIsOpen(false)}>关闭</button>
          </div>
        </div>
      )}
    </>
  );
}

桌面浏览器上没问题,但 position: fixed 这个技巧在 iOS Safari 上会导致页面跳动——除非你小心保存和恢复滚动位置。它也没有处理多层弹窗叠加的情况。

用 useScrollLock

useScrollLock 帮你处理了所有这些边界情况。传入要锁定的元素引用(通常是 document.body)和一个控制锁定状态的布尔值。

import { useScrollLock } from "@reactuses/core";
import { useState } from "react";

function Modal() {
  const [isOpen, setIsOpen] = useState(false);

  useScrollLock(
    typeof document !== "undefined" ? document.body : null,
    isOpen
  );

  return (
    <>
      <button onClick={() => setIsOpen(true)}>打开弹窗</button>
      {isOpen && (
        <div
          style={{
            position: "fixed",
            inset: 0,
            background: "rgba(0,0,0,0.5)",
            display: "flex",
            alignItems: "center",
            justifyContent: "center",
            zIndex: 100,
          }}
        >
          <div
            style={{
              background: "#fff",
              padding: 24,
              borderRadius: 12,
              maxWidth: 400,
            }}
          >
            <h2>弹窗标题</h2>
            <p>滚动已锁定,试试滑动背后的页面。</p>
            <button onClick={() => setIsOpen(false)}>关闭</button>
          </div>
        </div>
      )}
    </>
  );
}

一行代码锁定滚动,组件卸载时自动解锁,SSR 环境下也安全无虞。滚动位置在所有浏览器上都能正确保留。

3. 平滑滚动到指定区域

手动实现

落地页上常见的"滚动到某区域"按钮,命令式的写法如下:

import { useRef } from "react";

function ManualScrollTo() {
  const sectionRef = useRef<HTMLDivElement>(null);

  const scrollToSection = () => {
    sectionRef.current?.scrollIntoView({
      behavior: "smooth",
      block: "start",
    });
  };

  return (
    <div>
      <nav style={{ position: "fixed", top: 0, padding: 16, zIndex: 10 }}>
        <button onClick={scrollToSection}>跳转到功能介绍</button>
      </nav>

      <div style={{ height: "100vh", background: "#f1f5f9" }}>
        <h1 style={{ paddingTop: 80 }}>首屏区域</h1>
      </div>

      <div ref={sectionRef} style={{ padding: 40 }}>
        <h2>功能介绍</h2>
        <p>功能详情…</p>
      </div>
    </div>
  );
}

scrollIntoView 对基本场景够用,但它无法控制缓动曲线、滚动轴和偏移量(当你有一个固定头部时,偏移量就很重要了)。同时也没有办法知道滚动动画何时完成。

用 useScrollIntoView

useScrollIntoView 提供了对滚动动画的精细控制,包括自定义时长、缓动函数、滚动轴、偏移量和完成回调。

import { useScrollIntoView } from "@reactuses/core";
import { useRef } from "react";

function SmoothScrollPage() {
  const targetRef = useRef<HTMLDivElement>(null);

  const { scrollIntoView } = useScrollIntoView(targetRef, {
    duration: 800,
    offset: 80, // 为固定头部留出空间
  });

  return (
    <div>
      <nav
        style={{
          position: "fixed",
          top: 0,
          left: 0,
          right: 0,
          height: 64,
          background: "#1e293b",
          display: "flex",
          alignItems: "center",
          padding: "0 24px",
          zIndex: 50,
        }}
      >
        <button
          onClick={() => scrollIntoView({ alignment: "start" })}
          style={{
            background: "#4f46e5",
            color: "#fff",
            border: "none",
            padding: "8px 16px",
            borderRadius: 6,
            cursor: "pointer",
          }}
        >
          跳转到定价
        </button>
      </nav>

      <div style={{ height: "150vh", paddingTop: 80 }}>
        <h1>首屏</h1>
        <p>向下滚动或点击上方按钮。</p>
      </div>

      <div ref={targetRef} style={{ padding: 40, background: "#eef2ff" }}>
        <h2>定价方案</h2>
        <p>详细的套餐和价格信息…</p>
      </div>

      <div style={{ height: "100vh" }} />
    </div>
  );
}

offset 选项确保目标区域出现在固定头部下方,而不是被遮挡。平滑滚动动画使用可配置的缓动函数,如果组件在滚动过程中卸载,Hook 也会正确清理。

4. 吸顶检测

手动实现

一个常见的交互模式是:当 header 吸顶后改变外观,比如加上阴影、缩小高度。手动检测需要借助 IntersectionObserver 和一个哨兵元素:

import { useEffect, useRef, useState } from "react";

function ManualStickyHeader() {
  const sentinelRef = useRef<HTMLDivElement>(null);
  const [isStuck, setIsStuck] = useState(false);

  useEffect(() => {
    const sentinel = sentinelRef.current;
    if (!sentinel) return;

    const observer = new IntersectionObserver(
      ([entry]) => {
        setIsStuck(!entry.isIntersecting);
      },
      { threshold: 0 }
    );

    observer.observe(sentinel);
    return () => observer.disconnect();
  }, []);

  return (
    <div>
      <div ref={sentinelRef} style={{ height: 1 }} />
      <header
        style={{
          position: "sticky",
          top: 0,
          padding: isStuck ? "8px 24px" : "16px 24px",
          background: isStuck ? "rgba(255,255,255,0.95)" : "#fff",
          boxShadow: isStuck ? "0 2px 8px rgba(0,0,0,0.1)" : "none",
          transition: "all 0.2s",
          zIndex: 40,
        }}
      >
        <h1 style={{ margin: 0, fontSize: isStuck ? 18 : 24 }}>
          我的应用
        </h1>
      </header>
      <main style={{ padding: 24 }}>
        {Array.from({ length: 80 }, (_, i) => (
          <p key={i}>内容段落 {i + 1}</p>
        ))}
      </main>
    </div>
  );
}

哨兵方案能用但很脆弱:你需要精确地放置哨兵元素,管理观察者的生命周期,并在 DOM 结构变化时保持同步。

用 useSticky

useSticky 干净利落地解决了吸顶检测问题,返回一个布尔值,当元素进入吸顶状态时翻转为 true

import { useSticky } from "@reactuses/core";
import { useRef } from "react";

function StickyHeader() {
  const headerRef = useRef<HTMLElement>(null);
  const [isStuck] = useSticky(headerRef);

  return (
    <div>
      <header
        ref={headerRef}
        style={{
          position: "sticky",
          top: 0,
          padding: isStuck ? "8px 24px" : "16px 24px",
          background: isStuck
            ? "rgba(255,255,255,0.95)"
            : "#fff",
          boxShadow: isStuck
            ? "0 2px 8px rgba(0,0,0,0.1)"
            : "none",
          transition: "all 0.2s",
          zIndex: 40,
        }}
      >
        <h1 style={{ margin: 0, fontSize: isStuck ? 18 : 24 }}>
          我的应用
        </h1>
      </header>
      <main style={{ padding: 24 }}>
        {Array.from({ length: 80 }, (_, i) => (
          <p key={i}>内容段落 {i + 1}</p>
        ))}
      </main>
    </div>
  );
}

不需要哨兵元素,不需要手动设置观察者。Hook 在内部完成检测,给你一个简单的响应式布尔值来驱动样式。

5. 滚动进入视口时的渐显效果

用 useElementVisibility

useElementVisibilityIntersectionObserver 封装成一个布尔值返回。搭配 useState 标记位即可实现单次渐显效果:

import { useElementVisibility } from "@reactuses/core";
import { useRef, useState, useEffect } from "react";

function RevealOnScroll({ children }: { children: React.ReactNode }) {
  const ref = useRef<HTMLDivElement>(null);
  const [visible] = useElementVisibility(ref);
  const [hasRevealed, setHasRevealed] = useState(false);

  useEffect(() => {
    if (visible && !hasRevealed) {
      setHasRevealed(true);
    }
  }, [visible, hasRevealed]);

  return (
    <div
      ref={ref}
      style={{
        opacity: hasRevealed ? 1 : 0,
        transform: hasRevealed ? "translateY(0)" : "translateY(30px)",
        transition: "opacity 0.6s ease, transform 0.6s ease",
      }}
    >
      {children}
    </div>
  );
}

function FeaturePage() {
  return (
    <div style={{ padding: "100vh 24px 24px" }}>
      <RevealOnScroll>
        <h2>功能一</h2>
        <p>滚动到视口内时淡入显示。</p>
      </RevealOnScroll>
      <div style={{ height: 200 }} />
      <RevealOnScroll>
        <h2>功能二</h2>
        <p>每个区域独立动画。</p>
      </RevealOnScroll>
      <div style={{ height: 200 }} />
      <RevealOnScroll>
        <h2>功能三</h2>
        <p>只动画一次——回滚时不会闪烁。</p>
      </RevealOnScroll>
    </div>
  );
}

6. 高级交叉观察:滚动进度指示

useIntersectionObserver 以声明式的方式暴露完整的 IntersectionObserver API,让你直接获取 IntersectionObserverEntry,包括 intersectionRatioisIntersectingboundingClientRect

import { useIntersectionObserver } from "@reactuses/core";
import { useRef, useState } from "react";

function SectionProgress() {
  const sectionRef = useRef<HTMLDivElement>(null);
  const [ratio, setRatio] = useState(0);

  useIntersectionObserver(
    sectionRef,
    ([entry]) => {
      setRatio(entry.intersectionRatio);
    },
    {
      threshold: Array.from({ length: 101 }, (_, i) => i / 100),
    }
  );

  return (
    <div>
      <div style={{ height: "100vh" }} />
      <div ref={sectionRef} style={{ minHeight: "100vh", padding: 40 }}>
        <div
          style={{
            position: "sticky",
            top: 20,
            width: 200,
            height: 8,
            background: "#e2e8f0",
            borderRadius: 4,
          }}
        >
          <div
            style={{
              height: "100%",
              width: `${ratio * 100}%`,
              background: "#4f46e5",
              borderRadius: 4,
              transition: "width 0.1s",
            }}
          />
        </div>
        <h2>长篇区域</h2>
        {Array.from({ length: 20 }, (_, i) => (
          <p key={i}>区域中的第 {i + 1} 段。</p>
        ))}
      </div>
      <div style={{ height: "100vh" }} />
    </div>
  );
}

Hook 负责管理观察者的生命周期,在选项变化时重新连接,在卸载时自动清理。

安装

npm i @reactuses/core

相关 Hook


ReactUse 提供了 100 多个 React Hook。浏览全部 →

如果想转 AI 全栈?推荐你学一下 Langchain!

大家好 👋,我是 Moment,目前正在使用 Next.js、NestJS、LangChain 开发 DocFlow。这是一个面向 AI 场景的协同文档平台,集成了基于 Tiptap 的富文本编辑、NestJS 后端服务、实时协作与智能化工作流等核心模块。

在这个项目的持续打磨过程中,我积累了不少实战经验,不只是 Tiptap 的深度定制、编辑器性能优化和协同方案设计,也包括前端工程化建设、React 源码理解以及复杂项目架构实践。

如果你对 AI 全栈开发、文档编辑器、前端工程化或者 React 源码相关内容感兴趣,欢迎添加我的微信 yunmz777 一起交流。觉得项目还不错的话,也欢迎给 DocFlow 点个 star ⭐

image.png

上个月月底,我去参加了一场在深圳举办的线下聚会,现场人很多,几乎称得上爆满,分享具体讲了什么我其实没有认真听完,但有一个现象让我印象特别深。

我发现,现场已经有很多并非技术出身的人在真实地使用 AI 做开发,有人是产品经理,有人甚至没有完整的软件工程背景,但他们一样能借助 Claude CodeCursor 这类 AI 编辑器,把一个产品从想法推进到可运行的形态。

只要你真正用过这类工具,你就会知道它们强在哪里,很多时候你不必先把所有代码写完,只要把问题、目标和约束说清楚,模型就能替你完成相当大一部分工作,它不光是在替你补几行代码,更是在把你的想法翻译成可执行的过程。

这件事带来的冲击其实很直接,不是只有程序员才能做产品了,而是谁更会拆问题、谁更会组织上下文、谁更会调度 AI,谁就更有机会把事情做成。

所以,真正需要警惕的从来不是 AI 会不会写代码,而是你是否还停留在只会发一个 chat.completions 请求、然后等它吐一段文本的阶段,因为当 AI 开始参与真实任务时,竞争点已经不再只是会不会调模型,而是你能不能把模型接进系统、接进流程、接进业务,最后让它稳定地把事做完。

也正因为如此,这套文档不会停留在教你调用一下 LLM API 这一层,它想解决的是更往前一步的问题,当 AI 不再只是聊天,而是真正进入你的产品、流程和工程系统里时,你到底该怎么设计它、约束它、组织它、编排它。

从会调模型到能改整条 Agent 链路

理想状态大概是,你不再满足于发完请求就收一段文本,而是能把一条真正可执行的 Agent 链路说清楚,别人问起来,你也知道该动哪一层、从哪下手改。

这里不会拿概念填空来凑篇幅,那些词你多半已经见过。更值得花时间的是落地之后一定会撞上的事,比如上下文该留什么、该砍什么,模型才既记得住关键信息,又不会被历史拖垮。工具怎么写、Function Call 怎么接,才能少空转、少胡编,多把事办完。结构化输出怎么定,业务里才能当真数据用,而不是靠正则和运气硬接。

再往后,中间件、护栏、运行时、上下文工程各自兜的是哪一类坑,MCP 这类协议又该摆在协作架构的哪一层。人机协同、多 AgentSubagentsHandoffsSkillsRouter、自定义工作流,听起来多,其实都是在不同复杂度下选一条路。至于 CoTToTGoTReActPlan-and-ExecuteReflexionSelf-CriticLATS 这些名字,背下来没多大用,有用的是它们背后控制流怎么画、推理预算该多给还是该省。

章节一路跟下来,术语和框架名自然会熟,但更值得带走的是一种手感。某类任务该用简单的 Agent 循环还是上图式编排,某段流程要不要上人审、要不要拆角色,某一步老是失败时,该补护栏、补记忆、补工具描述,还是干脆换一套推理策略。能分清这些,比多记十个 API 名字实在得多。

真正花时间的是把系统搭稳

网上讲 AI 开发的内容已经很多,常见的却两头偏,一头概念讲得热闹,回到工程里不知道该动哪只手,另一头 demo 复制粘贴能跑,一进真实业务就开始散。

第一次把结果跑出来的时候,你往往还觉得挺顺。你很快会发现,真正难的从来不是让它第一次跑起来,而是:

  • 为什么这个 Agent 一到复杂任务就开始乱
  • 为什么多轮之后上下文越来越脏
  • 为什么工具明明接了,模型还是不会正确调用
  • 为什么结构化输出看起来像 JSON,实际上却根本不稳定
  • 为什么接了很多能力,系统却越来越难控、越来越难测、越来越难上线

这套文档想把这一串问号拆开来看。重点不单是让模型答得更聪明,而是让你看清一个能进生产环境的系统底下有几层、每层在扛什么,出事该往哪一层摸,而不是遇事就把锅甩给模型不够聪明。

如何学习

按章节顺序读就行,不是要你迷信目录,而是后面的例子会默认你已经看过前面的概念,跳太狠容易半路卡住。

开头一大段都在打基础,裸调模型哪里别扭、LangChain 在补什么、Function Call、消息结构、工具怎么接、先跑一个最简单的 Agent、再加上会话记忆和结构化输出。拆开看是很多篇,合起来就是在说一件事,模型是怎么被接进一条可执行的链路里的。

再往后会硬一些,主要对付"能跑"和"敢上线"之间的差距,中间件、护栏、运行时、上下文工程、MCP、人机协同、多 Agent,以及 SubagentsHandoffsSkillsRouter、自定义工作流之类。名字多,你不用全记住,先有个印象,知道这些多半是在管权限、管边界、管出事以后谁来兜底。

后面才轮到规划、反思、试探、回退这类话题。CoTToTGoTReActPlan-and-ExecuteReflexionSelf-CriticLATS 当几种不同的走法看就好,定义背了也没多大用。有用的是下面这些判断,心里过一遍比抄名词强:

  • 什么场景下值得多给一点推理预算
  • 什么场景下应该尽快落工具、少走内耗
  • 什么任务适合先规划后执行
  • 什么任务反而应该边做边修正
  • 什么情况下多想一步是收益,什么情况下只是成本

快收尾的时候会把长期记忆和 harness 拉出来,把执行、状态、持久化、审计、可观测性这些零散提过的东西并到一块,方便你对照真实环境里一般长什么样。

20260329233412

整体就是这样,先把基础概念和常见拼法摸熟,再啃工程和协作里那些让人心里发虚的部分,最后在控制流和收尾方式上收个口。

适合谁、怎么读

你若是写 React、做业务、跟需求,模型 API 也碰过,却越来越觉得卡不在页面上,而在模型怎么接、工具怎么配、多步任务怎么串,这一路的写法就是按这个感觉排的。

做过聊天框、demo,想再往"能办事"那边挪一步,也会对上号。别人做出来的像助手,自己的还在一问一答里打转,这类落差在这里会当成工程问题拆,而不是甩一句模型不够聪明。

还有一种情况,文章东一篇西一篇看过,记忆、工具调用、Agent 都见过词,就是拼不出一张图。按章节往下翻,多半能把那些散点接回一条线。

读法上可以松一点,不必一次啃完。过完一章,想想自己项目里有没有同款糟心事,有的话最小改动可以先动哪一步。理论不用第一遍就全吃透,能慢慢把问题和章节里的招对上,就已经在读对路了。

🚨别再滥用 useEffect 了!90% React Bug 的根源就在这

你有没有发现一个现象:

  • 只要写 React,就离不开 useEffect
  • 数据变了 → 加 useEffect
  • 不知道逻辑放哪 → 塞 useEffect
  • 页面不更新 → 再加一层 useEffect

写到最后:

  • 组件里一半代码都是 useEffect
  • 无限循环、重复请求、莫名其妙重渲染、闭包陷阱满天飞
  • 改 Bug 比写功能还累

这篇文章只讲一件事:

useEffect 到底是什么?以及它为什么被 90% 的人用错?

先讲背景:useEffect 到底是干嘛的?

早期 React 组件,有一堆生命周期: componentDidMountcomponentDidUpdatecomponentWillUnmount… 逻辑散得到处都是,维护巨痛苦。

Hook 出来后,React 想解决一个问题:

把“跟渲染无关、跟外部交互”的逻辑,统一收拢。

于是有了 useEffect

它的定位非常清晰:处理副作用(Side Effect)

什么是副作用?就是跳出 React 渲染逻辑、去跟外部打交道的操作:

  • 请求 API 接口
  • 操作真实 DOM(比如聚焦第三方库)
  • 定时器、延时
  • 局事件监听(resize、keydown)
  • 本地存储、document.title
  • 同步外部系统(日志、埋点)

一句话总结:只有需要和“外部世界”同步时,才需要 useEffect。

致命误解:你把它当成了 “监听器”?

这是 React 新手最大的误区。

你以为它是:监听某个变量变化,然后执行逻辑。 但 React 的核心模型是:UI = f (state)(纯函数)

请死死记住这句话:useEffect 不是 “监听变量变化”,而是 “处理副作用”。

一旦滥用,React 内部发生了什么?

你写了一个逻辑,React 执行了一条死循环:

render (渲染) → effect (执行副作用) → setState (更新状态) → render (再次渲染) → effect ...

你以为只写了几行代码,其实你在 React 里开了一条高速公路,车多了自然堵车。

滥用 useEffect 的三大灾难

  1. 多余渲染暴增(性能杀手):一次逻辑触发多次渲染,页面卡顿、掉帧。
  2. 依赖链混乱(Bug 温床):依赖数组稍微不严谨,就陷入无限循环,或者闭包陷阱数据对不上。
  3. 逻辑碎片化(维护灾难):一个功能拆碎在多个不同的 useEffect 里,逻辑碎片化,谁敢动?

典型灾难链:

A 改 B → B 改 C → C 再改 A

你以为你在写逻辑,其实你在堆 Bug

这 4 种场景,绝对别用 useEffect

1. 计算状态 → 直接算,别存状态

// ❌ 错误:多此一举,引发重复渲染 
const [a, setA] = useState(1) 
const [b, setB] = useState(0) 

useEffect(() => { 
  setB(a * 2) 
}, [a])
// ✅ 正确:直接计算
const a = 1 
const b = a * 2 // 直接计算

能通过现有状态直接算出来的,就不要单独存状态,避免多余的渲染和逻辑。

2. 交互逻辑 → 写在事件处理函数里,不是 useEffect

// ❌ 错误:为了弹个提示,监听整个count
useEffect(() => {
  if (count === 10) alert('够了')
}, [count])
// ✅ 正确:点击时直接判断
const handleClick = () => {
  const newCount = count + 1
  setCount(newCount)
  if (newCount === 10) alert('够了')
}

用户主动触发的行为,不属于副作用同步,理应写在对应的事件处理函数中。

3. 初始化数据 → useState 初始值就能搞定

// ❌ 错误:多一次render
const [user, setUser] = useState(null)
useEffect(() => {
  setUser(currentUser)
}, [])
// ✅ 正确:一步到位,直接初始化
const [user] = useState(currentUser)

4. Props 同步 → 直接用 props,不要本地状态+effect

// ❌ 错误:典型反模式,数据来源不单一
useEffect(() => {
  setValue(props.value)
}, [props.value])
// ✅ 直接用 props
const { value } = props

🎯 useEffect 的唯一合法使用场景

只记 5 种合法场景,多一个都不用:

  1. 接口请求(记得必须带 AbortController 清理)
  2. 定时器 / 延时(必须 clear)
  3. 手动操作 DOM
  4. 全局事件监听(addEventListener 必须 remove)
  5. 同步外部系统(localStorage、title、埋点)

除此之外,能不用就不用。

结尾

很多人以为问题在 useEffect,其实问题在这里:

你有没有把组件当成“纯函数”?

通俗来讲,React 组件本该是纯函数:固定的 Props 和 State,就输出固定的 UI,不掺杂多余的副作用。

滥用 useEffect 就是强行打破这个规则,在渲染中乱加状态修改、异步逻辑,才引发各种 Bug 和性能问题。

你认为呢?Vue 的 Watch 是不是也是这个道理?欢迎在评论区一起讨论 ~~

【LeetCode 刷题系列|第 3 篇】详解大数相加:从模拟竖式到简洁写法的优化之路🔢

🔢 前言

Hello~大家好,我是秋天的一阵风

今天要攻克的是 LeetCode 上的经典 大数计算 题 ——「字符串相加」(题号 415)。

这道题的核心场景是「大数相加」:输入的两个非负整数以字符串形式存储(长度最长可达 5100 位),根本无法直接转成 Number 或 BigInt 类型计算,本质是考察手动模拟大数竖式加法的能力。

它和前两篇的「盛最多水」「接雨水」不同,重点不是算法复杂度优化,而是处理「进位、长度对齐、末尾残留进位」这些大数计算的关键细节,非常适合夯实字符串操作和边界处理思维。

话不多说,咱们一步步拆解,让你彻底掌握大数相加的核心逻辑~

一、LeetCode 大数相加(字符串版)题目详情

1. 题目描述

给定两个非负整数 num1num2,它们以字符串形式表示(即大数),返回它们的和也以字符串形式表示。说明

  • 你不能使用任何内置的 BigInteger 库或直接将输入转换为整数形式(核心限制,凸显大数场景);
  • num1num2 的长度都小于 5100(明确大数规模);
  • num1num2 都只包含数字 0-9
  • num1num2 都不包含前导零(除了数字 0 本身)。

题目链接415. 字符串相加 - 力扣(LeetCode)

2. 示例演示

  • 输入:num1 = "11", num2 = "123"
  • 输出:"134"
  • 解释:11 + 123 = 134,模拟竖式相加:个位 1+3=4,十位 1+2=3,百位 0+1=1,拼接结果为 "134"(小型大数场景,理解基础逻辑)。
  • 输入:num1 = "456", num2 = "77"
  • 输出:"533"
  • 解释:个位 6+7=13(留 3 进 1),十位 5+7+1=13(留 3 进 1),百位 4+0+1=5,结果为 "533"(含进位的典型场景)。
  • 输入:num1 = "999999999999999999", num2 = "1"
  • 输出:"1000000000000000000"
  • 解释:超长大数相加,末尾进位贯穿所有位,最终需在最前方补 1(大数计算核心边界场景)。
  • 输入:num1 = "0", num2 = "0"
  • 输出:"0"
  • 解释:两个零相加,结果仍为零,注意不能返回 "00" 这类前导零(特殊边界场景)。

3. 难度级别

🟢 简单 → 🔵 中等(实际考察):题目逻辑本身不复杂,但大数场景下的「进位传递」「长度对齐补零」「末尾残留进位」这三个点极易出错,核心是复刻竖式加法的完整流程,确保覆盖所有大数计算的边界情况。

二、解题思路大剖析

1. 基础解法:模拟大数竖式相加

基础解法的核心思路就是复刻大数竖式加法的手工流程:因为是大数,无法直接转数字计算,所以从两个字符串的「末尾(个位)」开始,逐位提取数字相加,同步记录当前位结果和进位,最后将结果反转(因计算顺序是从低位到高位)。

核心步骤:

  1. 指针初始化:i 指向 num1 末尾(个位),j 指向 num2 末尾(个位),适配大数的低位到高位计算逻辑;

  2. 进位初始化:carry = 0(初始无进位,大数相加的进位可能贯穿多位);

  3. 结果容器:用数组 res 存储每一位结果(大数拼接频繁,数组比字符串高效);

  4. 循环计算(覆盖大数所有位 + 残留进位):只要 i >= 0(num1 未处理完)、j >= 0(num2 未处理完)或 carry > 0(仍有进位),就继续:

    • 提取当前位数字:num1 当前位为 i >= 0 ? num1[i] - '0' : 0(大数长度不一致时,短数高位补 0),num2 同理;
    • 计算当前位总和:sum = 位1 + 位2 + carry(必须包含前一位进位,大数进位不可遗漏);
    • 提取当前位结果:sum % 10(取个位,如 sum=13 则当前位为 3);
    • 更新进位:carry = Math.floor(sum / 10)(取十位,如 sum=13 则进位为 1,可能传递到下一位);
    • 存入结果:将当前位结果推入 res 数组;
    • 指针左移:i--j--,处理大数的更高位;
  5. 结果整理:res 中是「个位→高位」的顺序,反转后拼接成字符串(大数的高位在前、低位在后)。

分步拆解演示(以大数输入 num1="9999", num2="123" 为例):

  • 初始状态:i=3(num1[3]='9'),j=2(num2[2]='3'),carry=0res=[]

  • 第 1 轮(个位):

    • 位 1=9,位 2=3 → sum=9+3+0=12;
    • 当前位:12%10=2 → res=[2];
    • 进位:12/10=1 → carry=1;
    • 指针:i=2,j=1;
  • 第 2 轮(十位):

    • 位 1=9(num1 [2]='9'),位 2=2(num2 [1]='2') → sum=9+2+1=12;
    • 当前位:12%10=2 → res=[2,2];
    • 进位:12/10=1 → carry=1;
    • 指针:i=1,j=0;
  • 第 3 轮(百位):

    • 位 1=9(num1 [1]='9'),位 2=1(num2 [0]='1') → sum=9+1+1=11;
    • 当前位:11%10=1 → res=[2,2,1];
    • 进位:11/10=1 → carry=1;
    • 指针:i=0,j=-1;
  • 第 4 轮(千位):

    • 位 1=9(num1 [0]='9'),位 2=0(j<0 补 0) → sum=9+0+1=10;
    • 当前位:10%10=0 → res=[2,2,1,0];
    • 进位:10/10=1 → carry=1;
    • 指针:i=-1,j=-1;
  • 第 5 轮(残留进位):

    • 位 1=0,位 2=0 → sum=0+0+1=1;
    • 当前位:1%10=1 → res=[2,2,1,0,1];
    • 进位:1/10=0 → carry=0;
    • 指针:i=-1,j=-1;
  • 循环终止:i<0、j<0 且 carry=0;

  • 反转 res:[2,2,1,0,1] → [1,0,1,2,2] → 拼接成字符串 "10122"(9999+123=10122,符合大数计算预期)。

JavaScript 代码实现(基础解法):

/**
 * @param {string} num1
 * @param {string} num2
 * @return {string}
 */
var addStrings = function(num1, num2) {
    let i = num1.length - 1; // 指向num1末尾(大数个位)
    let j = num2.length - 1; // 指向num2末尾(大数个位)
    let carry = 0; // 进位,大数相加可能跨多位传递
    const res = []; // 存储每一位结果,避免大数字符串频繁拼接
    
    // 循环条件:覆盖大数所有位 + 残留进位
    while (i >= 0 || j >= 0 || carry > 0) {
        // 提取当前位数字(大数长度不一致时补0),字符转数字(减'0')
        const digit1 = i >= 0 ? num1[i] - '0' : 0;
        const digit2 = j >= 0 ? num2[j] - '0' : 0;
        
        // 计算当前位总和(含前一位进位)
        const sum = digit1 + digit2 + carry;
        // 当前位结果:sum的个位数
        const currentDigit = sum % 10;
        // 更新进位:sum的十位数(向下取整,可能为0或1)
        carry = Math.floor(sum / 10);
        
        // 推入结果数组(大数低位→高位顺序)
        res.push(currentDigit);
        
        // 指针左移,处理大数更高位
        i--;
        j--;
    }
    
    // 反转数组→拼接字符串(大数高位→低位顺序)
    return res.reverse().join('');
};

// 测试用例验证(覆盖大数、进位、边界场景)
console.log(addStrings("11", "123")); // 输出"134",符合预期
console.log(addStrings("9999", "123")); // 输出"10122",符合预期
console.log(addStrings("999999999999999999", "1")); // 输出"1000000000000000000",符合预期
console.log(addStrings("0", "0")); // 输出"0",符合预期

基础解法的优缺点:

  • 优点:完全贴合大数竖式加法逻辑,步骤清晰,覆盖所有大数场景的边界(超长长度、跨位进位、残留进位),面试中写出来稳定性高,不易出错;
  • 缺点:代码有少量冗余变量(如 digit1 digit2),但不影响可读性,大数计算场景下时间和空间已接近最优,无明显可优化点。

2. 优化解法:代码简洁化

优化解法的核心逻辑和基础解法完全一致(仍是模拟大数竖式),仅在代码写法上精简,减少冗余变量,让代码更紧凑(面试中能体现对大数计算逻辑的熟练掌握)。

优化点:

  • 合并变量声明:将 i j carry 合并声明,减少代码行数;
  • 嵌入位计算:将 digit1 digit2 的提取直接嵌入 sum 计算中,避免冗余变量;
  • 简化循环条件:carry 为 0 时会自动终止,无需写 carry > 0(因 0 为 falsy 值)。

JavaScript 代码实现(优化解法):

/**
 * @param {string} num1
 * @param {string} num2
 * @return {string}
 */
var addStrings = function(num1, num2) {
    let i = num1.length - 1, j = num2.length - 1, carry = 0;
    const res = [];
    
    while (i >= 0 || j >= 0 || carry) {
        // 直接计算当前位总和(嵌入大数位提取+补0逻辑)
        const sum = (i >= 0 ? num1[i] - '0' : 0) + (j >= 0 ? num2[j] - '0' : 0) + carry;
        res.push(sum % 10); // 当前位结果
        carry = Math.floor(sum / 10); // 更新进位
        i--;
        j--;
    }
    
    return res.reverse().join('');
};

// 测试用例验证
console.log(addStrings("456", "77")); // 输出"533",符合预期
console.log(addStrings("999999999999999999", "1")); // 输出"1000000000000000000",符合预期

优化解法的特点:

  • 逻辑不变:完全遵循大数竖式加法规则,覆盖所有边界场景;
  • 代码精炼:行数减少,无冗余变量,面试时书写速度更快;
  • 可读性强:变量名自解释,面试官能快速理解大数计算逻辑;
  • 复杂度不变:时间和空间复杂度与基础解法一致,属于「写法优化」而非「算法优化」。

三、总结

1. 核心逻辑

大数相加(字符串版)的本质是「模拟手工竖式加法」,核心要点有三个,缺一不可:

  1. 「从后往前算」:大数的低位在字符串末尾,需从末尾开始逐位处理;
  2. 「补零对齐」:大数长度不一致时,短数的高位补 0,避免索引越界,确保每一位都能对应相加;
  3. 「进位不遗漏」:每一步相加必须带上前一位的进位,且循环结束前需检查是否有残留进位(如 999+1 的最后进位 1)。

2. 最后

今天的「大数相加(字符串版)」就讲解到这里啦!相信大家已经吃透了「模拟竖式 + 进位传递」的核心逻辑,不管是基础解法还是优化解法,都能轻松应对面试中的大数场景。如果在测试超长大数、全 9 数字相加等特殊情况时遇到问题,或者有更巧妙的实现思路,欢迎在评论区留言讨论~

下一篇,咱们会继续攻克 LeetCode 高频题(「三数之和」),关注我,刷题路上不迷路!咱们下期再见~ 👋

写这需求快崩溃了,幸好我会装饰器模式

目的

装饰器模式(Decorator Pattern) 的目的非常简单,那就是:在不修改原有代码的情况下增加逻辑。 这句话听起来可能有些矛盾,既然都要增加逻辑了,怎么可能不去修改原有的代码?但 SOLID (向对象设计5大重要原则)的开放封闭原则就是在试图解决这个问题,其内容是不去改动已经写好的核心逻辑,但又能够扩充新逻辑,也就是对扩展开放,对修改关闭。

举个例子,假如产品的需求是实现一个专门在浏览器的控制台中输出文本的功能,你可能会这样做:

class Printer {  
  print(text) {  
    console.log(text);  
  }  
}  
  
const printer = new Printer();  
printer.print('something'); // something

在你满意的看着自己的成果时,产品过来说了一句:“我觉得颜色不够突出,还是把它改成黄色的吧!”

小菜一碟!你自信的打开百度一通操作之后,把代码改成了下面这样子:

class Printer {  
  print(text) {  
    console.log(`%c${text}`,'color: yellow;');  
  }  
}

image.png

但产品看了看又说:“这个字体有点太小了,再大一点,最好是高端大气上档次那种。

”好吧。。。“你强行控制着自己拿刀的冲动,一边琢磨多大的字体才是高端大气上档次,一边修改 print 的代码:

image.png

class Printer {  
  print(text) {  
    console.log(`%c${text}`,'color: yellow;font-size: 36px;');  
  }  
}

image.png

这次改完你之后你心中已经满是 mmp 了,而且偷偷给产品贴了个标签:

image.png

你无法保证这次是最后的修改,而且也可能会不只一个产品来对你指手划脚。你呆呆的看着显示器,直到电脑进入休眠模式,屏幕中映出你那张苦大仇深的脸,想着不断变得乱七八糟的 print 方法,不知道该怎么去应付那些永无休止的需求。。。

image.png

在上面的例子中,最开始的 Printer 按照需求写出它应该要有的逻辑,那就是在控制台中输出一些文本。换句话说,当写完“在控制台中输出一些文本”这段逻辑后,就能将 Printer 结束了,因为它就是 Printer 的全部逻辑了。那在这个情况下该如何改变字体或是颜色的逻辑呢?

这时你该需要装饰器模式了。

Decorator Pattern(装饰器模式)

首先修改原来的 Printer,使它可以支持扩充样式:

class Printer {  
  print(text = '', style = '') {  
    console.log(`%c${text}`, style);  
  }  
}

之后分别创建改变字体和颜色的装饰器:

const yellowStyle = (printer) => ({  
  ...printer,  
  print(text = '', style = '') => {  
    printer.print(text, `${style}color: yellow;`);  
  }  
});  
  
const boldStyle = (printer) => ({  
  ...printer,  
  print(text = '', style = '') => {  
    printer.print(text, `${style}font-weight: bold;`);  
  }  
});  
  
const bigSizeStyle = (printer) => ({  
  ...printer,  
  print(text = '', style = '') => {  
    printer.print(text, `${style}font-size: 36px;`);  
  }  
});

代码中的 yellowStyleboldStyle 和 bigSizeStyle 分别是给 print 方法的装饰器,它们都会接收 printer,并以 printer 为基础复制出一个一样的对象出来并返回,而返回的 printer 与原来的区别是,各自 Decorator 都会为 printer 的 print 方法加上各自装饰的逻辑(例如改变字体、颜色或字号)后再调用 printer 的 print

使用方式如下:

image.png

只要把所有装饰的逻辑抽出来,就能够自由的搭配什么时候要输出什么样式,加入要再增加一个斜体样式,也只需要再新增一个装饰器就行了,不需要改动原来的 print 逻辑。

image.png

不过要注意的是上面的代码只是简单的把 Object 用解构复制,如果在 prototype 上存在方法就有可能会出错,所以要深拷贝一个新对象的话,还需要另外编写逻辑:

const copyObj = (originObj) => {  
  const originPrototype = Object.getPrototypeOf(originObj);  
  let newObj = Object.create(originPrototype);  
     
  const originObjOwnProperties = Object.getOwnPropertyNames(originObj);  
  originObjOwnProperties.forEach((property) => {  
    const prototypeDesc = Object.getOwnPropertyDescriptor(originObj, property);  
     Object.defineProperty(newObj, property, prototypeDesc);  
  });  
    
  return newObj;  
}

然后装饰器内改使上面代码中的 copyObj,就能正确复制相同的对象了:

const yellowStyle = (printer) => {  
  const decorator = copyObj(printer);  
  
  decorator.print = (text = '', style = '') => {  
    printer.print(text, `${style}color: yellow;`);  
  };  
  
  return decorator;  
};

其他案例

因为我们用的语言是 JavaScript,所以没有用到类,只是简单的装饰某个方法,比如下面这个用来发布文章的 publishArticle

const publishArticle = () => {  
  console.log('发布文章');  
};

如果你想要再发布文章之后在 微博或QQ空间之类的平台上发个动态,那又该怎么处理呢?是像下面的代码这样吗?

const publishArticle = () => {  
  console.log('发布文章');  
  
  console.log('发 微博 动态');  
  console.log('发 QQ空间 动态');  
};

这样显然不好!publishArticle 应该只需要发布文章的逻辑就够了!而且如果之后第三方服务平台越来越多,那 publishArticle 就会陷入一直加逻辑一直爽的情况,在明白了装饰器模式后就不能再这样做了!

所以把这个需求套上装饰器:

const publishArticle = () => {  
  console.log('发布文章');  
};  
  
const publishWeibo = (publish) => (...args) => {  
  publish(args);  
  console.log('发 微博 动态');  
};  
  
const publishQzone = (publish) => (...args) => {  
  publish(args);  
  console.log('发 QQ空间 动态');  
};  
  
  
const publishArticleAndWeiboAndQzone = publishWeibo(publishQzone(publishArticle));

前面 Printer 的例子是复制一个对象并返回,但如果是方法就不用复制了,只要确保每个装饰器都会返回一个新方法,然后会去执行被装饰的方法就行了。

image.png

总结

装饰器模式是一种非常有用的设计模式,在项目中也会经常用到,当需求变动时,觉得某个逻辑很多余,那么直接不装饰它就行了,也不需要去修改实现逻辑的代码。每一个装饰器都做他自己的事情,与其他装饰器互不影响。

前端预检请求是什么?

前言

本文谈到的前端预检请求其实就是解决跨域方案其中之一的安全机制,可以理解为你想进一个小区,门口有一个保安“拦截器”,通过了保安“拦截器”检查,就可以进入了。

关于跨域(端口、协议、域名),想必大家都不陌生吧,回想下跨域的相关知识以及解决方案,就知道前端预检请求的来源了。

知其然知其所以然

一、浏览器的‌同源策略

跨域问题主要源于浏览器的‌同源策略(Same-Origin Policy) ‌。该策略是浏览器最核心的安全机制之一,用于防止不同源之间的恶意行为。

同源的定义‌:
当一个请求的 URL 的协议、域名、端口三者中任意一个与当前页面的 URL 不同时,就称为跨域请求。

例如:

  • 协议不同:http://example.com 和 https://example.com
  • 域名不同:http://www.example.com 和 http://www.other.com
  • 端口不同:http://example.com:8080 和 http://example.com:8081

即使两个域名指向同一个 IP 地址,也属于跨域。

由于浏览器出于安全考虑,同源策略限制了以下行为:

  • 无法读取非同源网页的 Cookie、LocalStorage 和 IndexedDB;
  • 无法访问非同源网页的 DOM;
  • 无法向非同源地址发送 AJAX 请求。

二、解决跨域的方法

1. CORS(跨域资源共享)

CORS 是目前最常用且推荐的跨域解决方案。它通过在服务器端设置响应头来允许特定源访问资源。

  • 服务器通过设置 Access-Control-Allow-Origin 响应头来指定允许访问的源。
  • 可以设置为具体域名或 *(表示允许所有源)。
  • 支持所有类型的 HTTP 请求,功能完善。 ‌

2. JSONP(JSON with Padding)

JSONP 是一种利用 <script> 标签不受同源策略限制的特性实现跨域请求的方式。

  • 仅支持 GET 请求;
  • 存在安全风险,容易受到 XSS 攻击;
  • 目前已被 CORS 取代。

3. 代理服务器(正向/反向代理)

通过在本地搭建一个代理服务器,前端请求先发送到代理服务器,再由代理服务器转发到目标服务器。

  • 在开发环境中常用前端脚手架配置代理;
  • 生产环境则可以使用 Nginx 等反向代理工具。

4. WebSocket

WebSocket 协议不遵循同源策略,适用于需要实时通信的场景。

5. postMessage API

用于不同窗口或 iframe 之间传递消息,常用于跨域通信。

6. document.domain + iframe

适用于主域名相同但子域名不同的情况。

7. window.name + iframe

通过 iframe 的 window.name 属性实现跨域数据传递。

前端预检请求是什么?

前端预检请求(Preflight Request)是浏览器在发起某些跨域请求前,自动发送的一个 ‌OPTIONS‌ 请求,用于确认服务器是否允许实际的跨域请求。这种机制是为了保证安全性,防止未经允许的跨域请求对服务器数据造成影响。

CORS(跨域资源共享)机制在特定条件下会触发预检请求(Preflight Request) ‌。

注意‌:如果请求满足“简单请求”的所有条件(如使用 GETPOSTHEAD 方法,且 Content-Type 为 application/x-www-form-urlencodedmultipart/form-data 或 text/plain),则不会触发预检请求。

何时会触发预检请求?

当请求不满足“简单请求”的条件时,浏览器就会自动触发一个预检请求。简单请求包括以下几种情况:

  • 使用 ‌GET、HEAD 或 POST‌ 方法;
  • 请求头仅包含以下字段:AcceptAccept-LanguageContent-LanguageContent-Type(且值为 text/plainmultipart/form-data 或 application/x-www-form-urlencoded)。

如果请求中包含以下任意一种情况,则会被视为“非简单请求”,从而触发预检请求:

  1. 使用了非简单 HTTP 方法,如 ‌PUT、DELETE、PATCH‌ 等;
  2. 请求头中包含了自定义字段,例如 AuthorizationX-Custom-Header 等;
  3. Content-Type 设置为 application/jsonapplication/xml 等非简单类型;
  4. 请求中携带了凭证(如 Cookie),需设置 withCredentials = true

预检请求的内容

预检请求是一个 ‌OPTIONS‌ 方法的请求,它会携带以下关键请求头:

  • Access-Control-Request-Method:表示实际请求将使用的 HTTP 方法;
  • Access-Control-Request-Headers:列出实际请求中使用的自定义头部。

服务器如何响应预检请求?

服务器需要返回一系列 CORS 响应头来表明其是否允许该跨域请求:

  • Access-Control-Allow-Origin:指定允许访问的源;
  • Access-Control-Allow-Methods:列出允许的 HTTP 方法;
  • Access-Control-Allow-Headers:声明允许的请求头部;
  • Access-Control-Allow-Credentials:是否允许携带凭证(如 Cookie);
  • Access-Control-Max-Age:指定预检请求结果的缓存时间,减少重复预检。

为什么需要预检请求?

预检请求本质上是一种安全机制,确保服务器明确知道并同意来自某个源的请求。这可以避免一些潜在的安全风险,比如在未授权的情况下向服务器发送敏感操作。

如何优化预检请求?

预检请求是现代浏览器为保障跨域请求安全而设计的一种机制,虽然会带来额外的网络开销,但在必要时是不可或缺的。

站在开发者角度,性能优化还是少不了的。全面认识了预检请求,优化方案减少必要自己也就出来了。为什么平时开发项目大部分是简单请求GET、HEAD 或 POST‌?这个问题答案也自己出来了吧。

总结下为了减少不必要的预检请求,可以采取以下策略:

  1. 尽量使用简单请求‌:避免使用非标准方法或自定义头部;
  2. ‌**合理设置 Access-Control-Max-Age**‌:通过设置较长的缓存时间,减少重复的 OPTIONS 请求;
  3. 使用代理服务器‌:将跨域请求转发到同源接口,绕过浏览器的 CORS 检查。

本文思维导图

前言 (1).png

写在最后

我是凉城a,一个前端,热爱技术也热爱生活。

与你相逢,我很开心。

如果你想了解更多,请点这里,期待你的小⭐⭐

  • 文中如有错误,欢迎在评论区指正,如果这篇文章帮到了你,欢迎点赞和关注😊
  • 本文首发于掘金,未经许可禁止转载💌

DOM树与节点操作:用JS给网页“动手术”

你写的HTML页面,在浏览器眼里其实是一棵树。今天我们就来当一回“外科医生”,用JS给这棵树做手术——增、删、改、查,想怎么动就怎么动。看完这篇,你就能理解为什么说“JS能控制网页的一切”。

前言

你有没有想过,当你用document.getElementById拿到一个元素,然后改它的文字、换它的颜色时,背后发生了什么?

其实,浏览器把HTML解析成了一棵“树”,每个标签、属性、文本都是树上的一个“节点”。JS能做的,就是在这棵树上爬上爬下,找到某个节点,然后对它做各种操作——换个果子、摘掉枯枝、甚至嫁接新枝。

今天我们就来解剖这棵DOM树,学会用JS给网页“做手术”。

一、DOM树:网页的“族谱”

DOM(Document Object Model)把HTML文档表示成一棵树。比如这段HTML:

<!DOCTYPE html>
<html>
  <head>
    <title>我的网页</title>
  </head>
  <body>
    <div class="container">
      <h1>标题</h1>
      <p>一段文字</p>
    </div>
  </body>
</html>

在浏览器眼里,它长这样:

html
├── head
│   └── title
│       └── "我的网页"
└── body
    └── div.container
        ├── h1
        │   └── "标题"
        └── p
            └── "一段文字"

每个方框都是一个节点。节点之间是父子、兄弟关系。这棵树的根节点是document

节点有不同的类型,最常见的是:

  • 元素节点:比如<div><p>,类型是1
  • 文本节点:比如“标题”这两个字,类型是3
  • 属性节点:比如class="container",类型是2(但很少单独操作)

二、获取节点:找到你要动刀的位置

做手术第一步,得找到病灶。JS提供了好几种“找节点”的方法:

1. 单个元素

// 根据ID(最常用)
const header = document.getElementById('header');

// 根据CSS选择器(推荐,灵活)
const container = document.querySelector('.container');
const title = document.querySelector('#title');

// 根据类名(返回集合)
const items = document.getElementsByClassName('item'); // HTMLCollection,实时更新

2. 多个元素

// 获取所有匹配的元素
const allDivs = document.querySelectorAll('div'); // NodeList,静态快照

// 根据标签名
const paras = document.getElementsByTagName('p'); // HTMLCollection

3. 在节点之间“爬树”

拿到一个节点后,你可以在它周围爬来爬去:

const container = document.querySelector('.container');

// 往上爬
const parent = container.parentNode;

// 往下爬
const firstChild = container.firstChild; // 可能是文本节点(换行)
const firstElementChild = container.firstElementChild; // 只算元素

// 找兄弟
const prev = container.previousSibling; // 可能是文本节点
const prevElement = container.previousElementSibling;
const next = container.nextElementSibling;

坑点firstChildnextSibling这些会返回文本节点(包括换行和空格),所以大部分时候用firstElementChildnextElementSibling更安全。

三、修改节点:动手术的核心操作

找到目标后,就可以下手了。

1. 修改内容和属性

// 改文本内容
element.textContent = '新文本'; // 纯文本,安全
element.innerHTML = '<strong>新文本</strong>'; // 解析HTML,有XSS风险

// 改属性
element.id = 'newId';
element.className = 'newClass'; // 覆盖所有类
element.classList.add('active'); // 推荐,增删类
element.classList.remove('hidden');
element.classList.toggle('open');

// 改样式(内联样式)
element.style.color = 'red';
element.style.backgroundColor = '#f0f0f0'; // 驼峰命名

2. 创建新节点

// 创建元素
const newDiv = document.createElement('div');
newDiv.textContent = '我是新来的';

// 创建文本节点(很少单独用)
const textNode = document.createTextNode('一段文字');

3. 插入节点

// 追加到最后
parent.appendChild(newDiv);

// 插入到某个子节点之前
parent.insertBefore(newDiv, referenceNode);

// 现代插入方法(更灵活)
referenceNode.before(newDiv); // 插到前面
referenceNode.after(newDiv);  // 插到后面
parent.prepend(newDiv);       // 插到父元素开头
parent.append(newDiv);        // 插到父元素末尾(类似appendChild)

4. 删除节点

// 删除自己
element.remove();

// 通过父节点删除
parent.removeChild(child);

四、实战:动态添加待办事项

来做个简单待办列表,把上面的操作串起来:

<div id="todo-app">
  <input type="text" id="todo-input" placeholder="输入待办事项">
  <button id="add-btn">添加</button>
  <ul id="todo-list"></ul>
</div>
const input = document.getElementById('todo-input');
const addBtn = document.getElementById('add-btn');
const list = document.getElementById('todo-list');

function addTodo() {
  const text = input.value.trim();
  if (text === '') return;
  
  // 创建li元素
  const li = document.createElement('li');
  li.textContent = text;
  
  // 创建删除按钮
  const delBtn = document.createElement('button');
  delBtn.textContent = '删除';
  delBtn.onclick = function() {
    li.remove(); // 删除这一项
  };
  
  li.appendChild(delBtn);
  list.appendChild(li);
  
  input.value = ''; // 清空输入框
}

addBtn.addEventListener('click', addTodo);
// 按回车也添加
input.addEventListener('keypress', function(e) {
  if (e.key === 'Enter') addTodo();
});

就这几行代码,一个动态待办列表就有了。你看,增删改查全用上了。

五、节点集合:HTMLCollection vs NodeList

当你用getElementsByClassName时,拿到的是HTMLCollection;用querySelectorAll拿到的是NodeList。它们有啥区别?

  • HTMLCollection:实时的。DOM变了,它也跟着变。而且它只有元素节点,没有文本节点。
  • NodeList:大部分是静态快照(querySelectorAll返回的就是静态的)。但childNodes返回的NodeList是实时的。
const live = document.getElementsByClassName('item'); // 实时
const static = document.querySelectorAll('.item'); // 静态

// 如果你删除了一个.item元素,live会立刻变少,static还是原来的

遍历时,HTMLCollection没有forEach方法(但可以Array.from()转成数组),NodeList有forEach

六、性能小贴士:别频繁动DOM

DOM操作是“重活”,频繁操作会影响性能。记住几个原则:

  1. 批量操作:用document.createDocumentFragment()创建虚拟片段,一次性插入。
const fragment = document.createDocumentFragment();
for (let i = 0; i < 1000; i++) {
  const li = document.createElement('li');
  li.textContent = i;
  fragment.appendChild(li);
}
list.appendChild(fragment); // 只触发一次重排
  1. 减少重排:修改样式时,尽量用classList批量改,而不是一个个改style属性。

  2. 离屏操作:先把元素从DOM树上摘下来,改完再放回去。

七、总结:DOM就是你的“手术台”

  • DOM是HTML解析成的树,每个标签、文本都是节点。
  • document.querySelector等方法找到节点。
  • textContentinnerHTML改内容,用classList改样式。
  • createElement造新节点,用appendinsertBefore插入,用remove删除。
  • 注意HTMLCollection和NodeList的区别,实时和静态要分清。
  • 批量操作、减少重排,让页面更流畅。

掌握了这些,你就能用JS随心所欲地操控页面。明天我们将继续深入,聊聊事件流与事件委托——当用户点击按钮时,浏览器里到底发生了什么。

如果你觉得今天的“手术”课够实用,点个赞让更多人看到。我们明天见!

for...of 的秘密:迭代器与可迭代对象,你也能创造“可循环”的东西

为什么数组可以用for...of循环?为什么对象不行?今天我们来揭开JS里“可循环”的秘密——迭代器(Iterator)和可迭代对象(Iterable)。弄懂它们,你就能让自己的对象也支持for...of,甚至还能写出像Python生成器那样优雅的代码。

前言

你有没有好奇过,为什么数组可以用for...of遍历,而对象不行?为什么...扩展运算符可以展开数组,却不能直接展开对象?这背后其实是迭代器协议在起作用。

今天我们就来彻底搞懂这套机制,然后亲手造一个可以for...of遍历的对象。看完你会感叹:原来JS的循环还有这么多骚操作!

一、什么是可迭代对象?

如果一个对象实现了可迭代协议,它就是可迭代对象。可迭代协议要求对象有一个[Symbol.iterator]方法,这个方法返回一个迭代器

简单来说:可迭代对象 = 有一个能返回迭代器的方法

数组、字符串、Map、Set、arguments、NodeList等都是原生可迭代对象。所以你可以:

for (let item of [1,2,3]) { console.log(item); } // 数组
for (let char of 'hello') { console.log(char); } // 字符串
for (let [key,val] of new Map([[1,2]])) { } // Map

对象不是可迭代对象,所以for...of直接遍历对象会报错。

二、迭代器长什么样?

迭代器是一个对象,它有一个next()方法。每次调用next(),会返回一个对象:{ value: 任意值, done: boolean }done表示是否遍历结束。

比如手动创建一个数组的迭代器:

const arr = ['a', 'b', 'c'];
const iterator = arr[Symbol.iterator]();

console.log(iterator.next()); // { value: 'a', done: false }
console.log(iterator.next()); // { value: 'b', done: false }
console.log(iterator.next()); // { value: 'c', done: false }
console.log(iterator.next()); // { value: undefined, done: true }

你看,这个迭代器就像个“读取器”,每次取一个值,直到取完。

三、自己实现一个可迭代对象

现在我们来造一个可以for...of遍历的对象。比如一个范围对象,能遍历从start到end的所有整数。

const range = {
  start: 1,
  end: 5,
  [Symbol.iterator]() {
    let current = this.start;
    const end = this.end;
    return {
      next() {
        if (current <= end) {
          return { value: current++, done: false };
        } else {
          return { value: undefined, done: true };
        }
      }
    };
  }
};

for (let num of range) {
  console.log(num); // 1,2,3,4,5
}

就这么简单!只要对象有[Symbol.iterator]方法,并且返回一个带有next的对象,它就能被for...of遍历。

四、扩展运算符、解构赋值背后的迭代器

很多JS语法都依赖迭代器:

  • ...扩展运算符:把可迭代对象展开成元素列表
  • 数组解构:[a, b, ...rest] = iterable
  • Array.from():把可迭代对象转成数组
  • for...of循环
  • Promise.all()Promise.race()的参数也是可迭代对象

所以,只要你的对象是可迭代的,它就能享受这些语法糖。

const numbers = [...range]; // [1,2,3,4,5]
const [first, second, ...rest] = range; // first=1, second=2, rest=[3,4,5]

五、生成器函数:迭代器的快捷方式

还记得昨天的Generator吗?生成器函数返回的就是迭代器!所以我们可以用Generator来简化上面的代码:

const range = {
  start: 1,
  end: 5,
  *[Symbol.iterator]() {
    for (let i = this.start; i <= this.end; i++) {
      yield i;
    }
  }
};

是不是简洁多了?*[Symbol.iterator]()就是Generator方法,每次yield一个值,for...of会自动调用next

六、无限迭代器:永不停止的循环

迭代器可以无限进行下去,比如生成斐波那契数列:

const fibonacci = {
  *[Symbol.iterator]() {
    let a = 0, b = 1;
    while (true) {
      yield a;
      [a, b] = [b, a + b];
    }
  }
};

const fib = fibonacci[Symbol.iterator]();
console.log(fib.next().value); // 0
console.log(fib.next().value); // 1
console.log(fib.next().value); // 1
// 想取多少取多少

但注意:用for...of遍历无限迭代器会死循环,所以要手动控制。

七、提前终止迭代器:return方法

如果迭代器被提前终止(比如for...of中遇到break,或者解构只取前几个值),JS会调用迭代器的return方法(如果有的话)。这可以用来做清理工作。

const specialIterable = {
  [Symbol.iterator]() {
    let i = 0;
    return {
      next() {
        if (i < 3) return { value: i++, done: false };
        return { done: true };
      },
      return() {
        console.log('提前终止了');
        return { done: true };
      }
    };
  }
};

for (let x of specialIterable) {
  console.log(x);
  if (x === 1) break; // 触发return
}
// 输出:0,1, 然后打印“提前终止了”

八、实际应用:让对象可迭代

假设你有一个用户列表对象,你想让它支持for...of直接遍历用户:

const userList = {
  users: [
    { name: '张三', age: 18 },
    { name: '李四', age: 20 },
    { name: '王五', age: 22 }
  ],
  *[Symbol.iterator]() {
    for (let user of this.users) {
      yield user;
    }
  }
};

for (let user of userList) {
  console.log(user.name); // 张三 李四 王五
}

这样,你的自定义对象就能像数组一样优雅地遍历了。

九、总结:迭代器无处不在

  • 可迭代对象:实现了[Symbol.iterator]方法,返回一个迭代器。
  • 迭代器:实现了next()方法,返回{ value, done }
  • 生成器函数:是迭代器最便捷的实现方式。
  • 很多JS语法(for...of、扩展运算符、解构)都依赖迭代器协议。

理解了这套机制,你就能:

  • 让自定义对象支持for...of
  • 创建无限序列
  • 深入理解JS语法糖背后的原理

下次你写for...of时,脑子里可以浮现出迭代器一步步next的画面——这才是真正掌握了JS的底层。

明天我们将进入DOM操作与事件流,从JS的核心走向与页面的交互。如果你觉得今天的文章够“可迭代”,点个赞让更多人看到。我们明天见!

产品:这个文字颜色能不能根据背景图自动换?

产品:这个文字颜色能不能根据背景图自动换?我:安排

当产品经理拿着两张背景图——一张深邃的午夜蓝、一张清新的樱花粉——问出这句话时,我知道,又要动脑子了。

事情是这样的

那天产品小哥跑过来,手里拿着两张设计稿:一张是深邃的午夜蓝纯色背景,另一张是清新的樱花粉渐变背景。

“你看啊,”他指着图上的文字区域,“我们的商品详情页,深色背景上用黑色字根本看不清,浅色背景上白字又太刺眼。能不能——让文字颜色自己适应背景?”

我看着他期待的小眼神,深吸一口气:“安排。”

需求拆解

其实这个需求很清晰:文字颜色需要根据背景图的颜色自动调整

更具体地说:

  • 深色背景 → 文字变浅色(白或浅灰)
  • 浅色背景 → 文字变深色(黑或深灰)

但如果只是简单判断黑白,遇到五颜六色的背景图(比如渐变、花纹)就不够用了。我们需要真正读懂背景图的主色调。

技术选型

要在前端实现这个功能,核心是读取图片的颜色信息。方案如下:

  1. 用 Canvas 绘制背景图
  2. 获取图片的像素数据
  3. 计算平均色或亮度
  4. 根据亮度决定文字颜色

没错,就这四步。下面开干。

编程的本质就是以数据为中心。  图片,说到底就是一个数组。数组的长宽对应图片的尺寸,而每个元素里存储着该像素的 RGBA 值——红、绿、蓝和透明度。我们要做的,就是读取这个数组,分析它的颜色分布,然后做出决策。这听起来很酷,对吧?

第一步:获取图片像素数据

function getImagePixels(image) {
  const canvas = document.createElement('canvas');
  const { naturalWidth: width, naturalHeight: height } = image;
  canvas.width = width;
  canvas.height = height;
  const ctx = canvas.getContext('2d');
  ctx.drawImage(image, 0, 0, width, height);
  const imageData = ctx.getImageData(0, 0, width, height).data;
  
  // 为了方便计算,返回二维数组 [x][y] = [r, g, b, a]
  const pixels = [];
  for (let x = 0; x < width; x++) {
    pixels[x] = [];
    for (let y = 0; y < height; y++) {
      const idx = (y * width + x) * 4;
      pixels[x][y] = [
        imageData[idx],     // R
        imageData[idx + 1], // G
        imageData[idx + 2], // B
        imageData[idx + 3]  // A
      ];
    }
  }
  return pixels;
}

这里有个坑需要注意:像素索引是 (y * width + x) * 4,别写错了,不然颜色就全乱了。

第二步:计算区域平均亮度

我们不需要全图平均,只计算文字所在区域的背景色即可,这样更精准。

function getAverageBrightness(pixels, xRange, yRange) {
  const [xMin, xMax] = xRange;
  const [yMin, yMax] = yRange;
  let rSum = 0, gSum = 0, bSum = 0;
  let count = 0;
  
  for (let x = xMin; x < xMax; x++) {
    if (!pixels[x]) continue;
    for (let y = yMin; y < yMax; y++) {
      if (!pixels[x][y]) continue;
      const [r, g, b] = pixels[x][y];
      rSum += r;
      gSum += g;
      bSum += b;
      count++;
    }
  }
  
  if (count === 0) return 128; // 默认中灰
  
  const avgR = rSum / count;
  const avgG = gSum / count;
  const avgB = bSum / count;
  
  // 人眼对绿色最敏感,亮度公式
  return 0.299 * avgR + 0.587 * avgG + 0.114 * avgB;
}

第三步:决定文字颜色

亮度范围 0~255,以 128 为分界:

function getTextColor(brightness) {
  return brightness > 128 ? '#000000' : '#FFFFFF';
}

第四步:整合到页面

const img = document.getElementById('bgImage');
const textElement = document.querySelector('.dynamic-text');

img.onload = () => {
  // 获取像素数据
  const pixels = getImagePixels(img);
  const width = pixels.length;
  const height = pixels[0]?.length || 0;
  
  // 文字通常在图片底部中央,取这个区域
  const textAreaX = [width * 0.3, width * 0.7];
  const textAreaY = [height * 0.7, height * 0.9];
  
  const brightness = getAverageBrightness(pixels, textAreaX, textAreaY);
  const textColor = getTextColor(brightness);
  
  textElement.style.color = textColor;
  
  // 可选:加个半透明底,更稳妥
  textElement.style.textShadow = brightness > 128 
    ? '0 0 2px rgba(0,0,0,0.3)' 
    : '0 0 2px rgba(255,255,255,0.3)';
};

// 跨域处理
img.crossOrigin = 'Anonymous';
if (img.complete) img.onload();

优化与坑点

1. 性能问题

图片很大时遍历所有像素会卡。采样降频:每隔 10 个像素取一次,速度提升 100 倍。

// 采样版
for (let x = 0; x < width; x += 10) {
  for (let y = 0; y < height; y += 10) {
    // 采样处理
  }
}

2. 跨域问题

如果图片是 CDN 上的,记得设置 crossOrigin,并且服务端要支持 CORS。

3. 图片加载

一定要在 onload 里处理,否则 Canvas 是空的。

4. 复杂背景怎么办

如果背景是渐变或复杂图案,纯黑白文字可能还不够。可以加一层半透明蒙层:

textElement.style.backgroundColor = brightness > 128 
  ? 'rgba(0,0,0,0.5)' 
  : 'rgba(255,255,255,0.5)';

最终效果

搞定之后,我拿给产品小哥演示:

  • 深色背景图 → 白色文字,带淡淡阴影
  • 浅色背景图 → 黑色文字,清晰可见
  • 花纹复杂的 → 自动取平均亮度,稳稳适配

产品小哥满意地点点头:“不错,安排上了。”

我也满意地点点头:又一个小需求,用技术优雅地解决了。

写在最后

这个方案的核心就三件事:画 Canvas、取像素、算亮度。代码量不大,但非常实用。

如果你也遇到类似的需求——无论是商品详情页、活动 banner,还是用户自定义背景——都可以用这套思路搞定。

最后送大家一句话:与其让产品经理追着你改颜色,不如让代码自己学会挑颜色。 你还遇到过什么奇葩需求 欢迎在评论区大声吐槽。

纯干货,前端字体极致优化!谷歌、阿里、字节、腾讯都在用的终极解决方案,Vue3 + Vite 直接抄,页面提速不妥协!

最近在做一个公网的小项目,本身是一个在线的海报编辑器,因为之前做的比较糙,最近有时间了,领导让优化一下。

问题主要集中在页面的加载速度上。

image.png

设计稿要求多字体质感、标题正文差异化排版,结果引入的字体包动辄几MB,中文字体甚至直奔10MB+。

页面首屏加载非常慢,但是删字体包设计那边过不去,不删吧用户等待时间过长,体验直线下滑,简直两难。

核心问题

不过别慌,稳住了!

先明确我们到底在解决什么问题,避免盲目优化。

其实主要几个问题:

  • 字体体积冗余:完整字体包包含上万字符,项目实际用到的不过几百个,甚至有可能就几个字,全量加载纯纯浪费。
  • 阻塞页面:字体包过大,加载过程中可能触发FOIT(文字隐形)、FOUT(文字闪烁),页面出现留白卡顿。
  • 多字体加载混乱:一个页面同时存在多种字体包同时引入的时候,基本上就是谁在前面加载谁,无规划加载拖慢整体渲染。
  • 格式不兼容:沿用TTF/OTF老式字体格式,体积大、压缩率低,完全适配不了现代前端性能要求。

但是格式问题需要注意,新式的字体包,比如说WOFF2,是不支持IE这种较老版本的浏览器的。

如果你有兼容需求,记得不要上新包。

解决方案

建议字体包转WOFF2

前提是你只要没有兼容性需求,几乎WOFF2必转的。

相比于传统TTF、OTF、WOFF格式,WOFF2是现代浏览器专属的字体压缩格式,体积能直接缩减50%-70%

一个TTF大约20MB的包,在WOFF2上大概也就8MB左右,这个优化是显而易见的。

而且Chrome、Edge、Safari、Firefox全版本兼容,完全不用担心兼容性问题。

可以用 Font Squirrel在线工具进行一键转换,或者直接让UI那边导出来WOFF2的包。

按场景拆分字体包

多字体包的情况下千万别全量引入,一定要按照使用频率、使用场景拆分:

高频使用的优先处理,低频使用的单独拆分,延后加载。

可以在引入的部分,按照字重、字体样式拆分@font-face

这样的好处在于浏览器只会加载当前页面用到的字体规则,不会全量请求所有字体包。

/* 按字重拆分,按需加载 */
@font-face {
  font-family: 'SourceHanSans';
  src: url('./fonts/regular.woff2') format('woff2');
  font-weight: 400;
}
@font-face {
  font-family: 'SourceHanSans';
  src: url('./fonts/bold.woff2') format('woff2');
  font-weight: 700;
}

延迟加载

另外文字留白、闪烁问题,核心就是靠font-display属性。

使用font-display: swap浏览器会先使用系统默认字体展示页面文字,等到自定义字体加载完成后,再无缝替换。

全程不会出现文字隐形、页面卡顿的情况,让用户感知上有一种等一小会儿的感觉。

还有就是可视区域以外的字体,完全没必要和首屏一起加载,等到页面加载完成、或者用户触发对应模块时,再加载字体即可,减少首屏的请求数量。

// 页面加载完成后,懒加载非首屏字体
window.addEventListener('load', () => {
  const link = document.createElement('link');
  link.rel = 'stylesheet';
  link.href = '/css/other-font.css';
  document.head.appendChild(link);
})

字体子集化&按需加载(终极方案)

前面主要是给字体"减负",字体子集化和按需加载就是直接给字体"瘦身"。

据我所知,这也是目前谷歌、阿里、腾讯等大厂通用的天花板方案,能够彻底解决字体冗余问题。

核心原理

完整字体包中包含海量未使用字符,这样我们其实可以通过工具提取项目实际用到的字符

将大字体拆分成多个极小的字体分片;再通过CSS的unicode-range告诉浏览器,哪些字符对应哪个字体分片。

浏览器只会加载当前页面用到的分片,没用到的完全不请求。

相当于是对通过拆字的方式实现了对字体包的懒加载。

简单画一个流程图:

image.png

这套方案下来,原本几MB的字体,能直接压缩到几十KB,首屏字体加载速度提升数十倍,还完全不影响多字体使用!

具体实现

这里我推荐几个我用过的:glyphhangerfontminvite-plugin-fontmin

glyphhanger

基于Node.js,无需手动提取字符,直接爬取页面文字,自动生成子集字体+unicode-range CSS,适合快速优化现有页面,新手也能一键上手。

# 全局安装
npm install -g glyphhanger
# 一键生成子集字体与CSS
glyphhanger http://localhost:3000 --formats=woff2 --subset=./src/fonts/xxx.ttf

fontmin,纯JS定制化工具

纯JavaScript实现,无额外环境依赖,支持自定义提取字符、批量处理,可嵌入Webpack、Gulp等构建流程,适合需要定制化优化的项目。

const Fontmin = require('fontmin')
new Fontmin()
  .src('./src/fonts/xxx.ttf')
  .use(Fontmin.glyph({ text: '项目实际用到的文字', hinting: false }))
  .use(Fontmin.ttf2woff2())
  .dest('./dist/fonts')
  .run()

vite-plugin-fontmin,Vue3+Vite项目专属

# 安装插件
npm install vite-plugin-fontmin -D
# 或者yarn/pnpm
pnpm add vite-plugin-fontmin -D

配置文件,这里写的比较全,包含字体优化、资源打包、开发环境优化等等,可根据项目字体自行修改。

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
// 引入字体子集化插件
import fontminPlugin from 'vite-plugin-fontmin'

export default defineConfig({
  // 路径别名
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src')
    }
  },
  // 插件配置
  plugins: [
    vue(),
    // 字体子集化核心配置
    fontminPlugin({
      // 配置多个字体(单字体直接写单个对象即可)
      fonts: [
        {
          // 源字体文件路径(放入项目src/fonts目录下)
          fontSrc: './src/fonts/SourceHanSansCN-Regular.ttf',
          // 子集化后字体的输出目录
          fontDest: './src/assets/fonts/subset/',
          // 自动扫描项目文件,提取所有用到的字符(无需手动书写)
          inputPath: ['./src/**/*.{vue,ts,tsx,js,jsx,css,scss}'],
          // 额外预留字符(动态内容、用户输入、接口返回文字,提前预留)
          input: '0123456789qwertyuiopasdfghjklzxcvbnm,。!?;:“”‘’',
          // 仅输出WOFF2格式
          formats: ['woff2'],
          // 开启unicode-range按需加载(核心)
          unicodeRange: true,
          // 字体渲染规则,避免阻塞
          fontDisplay: 'swap',
          // 关闭字体提示,进一步压缩体积
          hinting: false
        },
        // 多字体配置示例(标题字体,按需添加)
        {
          fontSrc: './src/fonts/TitleFont-Bold.ttf',
          fontDest: './src/assets/fonts/subset/title/',
          inputPath: ['./src/components/Title/**/*.vue', './src/views/**/*.vue'],
          formats: ['woff2'],
          unicodeRange: true,
          fontDisplay: 'swap'
        }
      ],
      // 开发环境仅执行一次子集化,避免热更新卡顿
      runOnceInDev: true,
      // 生产环境压缩字体
      compress: true
    })
  ],
  // 生产构建配置
  build: {
    assetsDir: 'static/assets',
    // 字体资源单独打包,方便缓存
    rollupOptions: {
      output: {
        assetFileNames: (assetInfo) => {
          if (assetInfo.name && assetInfo.name.endsWith('.woff2')) {
            return 'static/assets/fonts/[name]-[hash][extname]'
          }
          return 'static/assets/[name]-[hash][extname]'
        }
      }
    },
    // 关闭生产环境sourcemap,提升打包速度
    sourcemap: false,
    // 代码压缩
    minify: 'terser'
  },
  // 开发服务器配置
  server: {
    port: 3000,
    open: true
  }
})

完成上述配置以后,插件会自动生成对应的@font-face规则,无需手动引入字体CSS,直接在项目样式里使用即可。

/* src/assets/css/global.css */
body {
  font-family: 'SourceHanSansCN', sans-serif;
}
.title {
  font-family: 'TitleFont', sans-serif;
  font-weight: 700;
}

总结

其实字体优化一直是老大难问题,不上字体包效果出不来,上了字体包加载速度上不来。

当然,我们仍然建议,非必要不要上字体包。

还有一个"邪修"方案,可以手动创建字体包子集,也就是说这个字体包只包含要用字,其他的删掉。(参考iconFont的字体库"下载子集")。

如果非要上,那就是所有字体转WOFF2,拆分字体包,添加font-display: swap

另外再增加字体子集化和按需加载的部分,让首屏加载快起来。

❌