阅读视图

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

CSS-HTML Form 表单交互深度指南

前言

虽然现代前端框架(Vue/React)已经极大简化了表单处理,但理解原生 Form 表单 的事件流、控件属性和 API,依然是处理复杂业务逻辑(如埋点、自定义验证、无刷新提交)的基础。

一、 Form 表单的核心机制

<form> 是所有输入控件的容器。它不仅负责数据的收集,还管理着数据的提交 (Submit)重置 (Reset) 周期。

1. 关键属性

  • action:数据提交的目标 URL。
  • method:HTTP 请求方式(GET 拼接到 URL,POST 放入请求体)。

2. 提交与拦截

当表单内存在 type="submit" 的按钮时,点击会触发 submit 事件。

const form = document.querySelector("#myForm");

form.addEventListener("submit", (event) => {
  // 1. 阻止浏览器默认的跳转刷新行为
  event.preventDefault(); 
  
  // 2. 自定义验证逻辑
  if (inputValue === "") {
    alert("内容不能为空");
    return;
  }
  
  // 3. 执行异步提交(如使用 Fetch/Axios)
  console.log("表单已提交");
});

3. 重置行为

form.reset() 不仅仅是清空。它会将所有字段恢复为页面加载时的初始值(例如 <input value="default"> 会恢复为 "default" 而非空)。


二、 输入控件的“通用武器库”

无论 inputselect 还是 textarea,它们都共享以下核心属性和方法:

1. 公共属性与方法

  • disabled:禁用控件,数据不会被提交。
  • readOnly:只读,数据随表单提交。
  • form:只读属性,返回当前控件所属的表单对象引用。
  • focus() / blur() :手动控制焦点的获取与失去。

2. 三大核心事件

事件 触发时机
focus 控件获得焦点时。
blur 控件失去焦点时(常用于输入后的实时验证)。
change 内容改变且失去焦点时触发(注意:与 input 事件实时触发不同)。

三、 文本输入:Input vs Textarea

1. 单行文本框 <input type="text">

  • placeholder:提示文本。
  • maxlength:硬性限制用户输入的字符长度。

2. 多行文本框 <textarea>

  • rows/cols:控制显示的行数和宽度。

  • wrap 换行控制

    • soft(默认):提交时不带换行符。
    • hard:提交的数据中包含换行符(必须配合 cols 使用)。

四、 选择框:Select 与 Option

<select> 元素在 JavaScript 中拥有更丰富的集合操作。

1. Select 关键操作

  • multiple:是否允许多选(按住 Ctrl/Command 键)。
  • options:返回包含所有 <option> 元素的 HTMLCollection。
  • remove(index) :移除指定索引的选项。

2. Option 选项详情

每一个 Option 对象都有:

  • index:在下拉列表中的位置。
  • selected:布尔值,通过设置为 true 可实现代码控制选中。
  • text:用户看到的文字。
  • value:提交给后端的值。

五、 面试模拟题

Q1:submit 事件和按钮的 click 事件有什么区别?

参考回答:

submit 事件是绑定在 form 元素上的。如果用户在输入框中按“回车键”,或者点击了 type="submit" 的按钮,都会触发 form 的 submit 事件。相比点击事件,监听 submit 能更全面地捕获提交动作。

Q2:如何通过原生 JS 获取表单内的所有数据?

参考回答:

现代浏览器推荐使用 FormData 对象:

const formData = new FormData(formElement);
// 获取特定字段
const username = formData.get('username');
// 转化为对象
const data = Object.fromEntries(formData.entries());

Q3:disabledreadOnly 在表单提交时有什么区别?

参考回答:

  • 设置了 disabled 的控件,其值在表单提交时会被忽略,且用户无法交互。
  • 设置了 readOnly 的控件,用户无法修改值,但其值在提交时会被包含在表单数据中。

掌握 CSS 布局基石:行内、块级、行内块元素深度解析

前言

在 CSS 世界中,每个元素都有一个默认的 display 属性。理解这些元素的显示模式,是解决“为什么我的宽高设置无效?”、“为什么两个 div 不在一行?”等问题的关键。

一、 三大元素显示模式对比

1. 块级元素 (Block Elements)

块级元素就像是积木,默认从上往下堆叠。

  • 特点

    • 独占一行:默认占满父容器 100% 宽度。
    • 属性全开:支持设置 widthheightmarginpadding
    • 嵌套规则:可以包含行内元素和其他块级元素(注意:ph1~h6 比较特殊,建议不要包裹块级元素)。
  • 代表标签div, p, h1~h6, ul, ol, li, header, footer, section 等。

2. 行内元素 (Inline Elements)

行内元素就像是文本,随内容流动。

  • 特点

    • 并排显示:相邻元素在同一行内排列,直到排不下才换行。
    • 宽高无效:设置 widthheight 不起作用,宽度由内容撑开。
    • 间距局限:水平方向的 marginpadding 有效;垂直方向无效(不占据空间,但可能背景会溢出)。
  • 代表标签span, a, strong, em, i, label

3. 行内块元素 (Inline-Block)

结合了前两者的优点,既能并排显示,又能设置宽高。

  • 特点

    • 并排排列:不独占一行。
    • 属性支持:支持设置 widthheightmarginpadding
  • 代表标签img, input, button, textarea, select

    :这些元素在 CSS 规范中被称为“可替换元素”,它们天生具有行内块的特性。


二、 inline-block 的“间隙之谜”

1. 产生原因

当你给子元素设置 display: inline-block 时,HTML 代码中标签之间的空格或换行符会被浏览器解析为一个约 4px 的空白字符。

2. 解决方案

  • 方法 A:父元素设置 font-size: 0(最常用)

    .parent { font-size: 0; }
    .child { display: inline-block; font-size: 14px; } /* 子元素需手动恢复字号 */
    
  • 方法 B:标签首尾相接(代码极丑,不推荐)

    <div class="child">A</div><div class="child">B</div>
    
  • 方法 C:改用 Flex 布局(现代开发首选)

    .parent { display: flex; } /* 彻底告别间隙问题 */
    

三、 空元素 (Void Elements)

空元素是指没有子节点且没有结束标签的元素,它们通常通过属性来承载内容。

  • 常见标签<br>, <hr>, <img>, <input>, <link>, <meta>

四、 面试模拟题

Q1:如何让行内元素(如 span)支持宽高?

参考回答:

  1. 修改 display 属性为 blockinline-block
  2. 设置 float(浮动后的元素会自动变为块级表现)。
  3. 设置 position: absolutefixed

Q2:img 标签是行内元素还是块级元素?为什么它可以设置宽高?

参考回答: img 在表现上属于行内元素(不换行),但它是一个可替换元素(Replaced element) 。可替换元素的内容不受 CSS 控制,其外观由标签属性决定。浏览器在渲染这类元素时,会赋予它们类似 inline-block 的特性,因此可以设置宽高。

Q3:display: nonevisibility: hidden 有什么区别?

参考回答:

  • display: none:脱离文档流,不占据空间,会引起回流(Reflow)。
  • visibility: hidden:隐藏内容,但保留占据的物理空间,不会引起回流,仅引起重绘(Repaint)。

CSS 核心基石-彻底搞懂“盒子模型”与“外边距合并”

前言

在网页布局中,万物皆“盒子”。理解盒子模型的构造及其不同模式的差异,是实现精准布局的前提。本文将从基础构成到进阶属性,带你全方位梳理 CSS 盒子模型。

一、 盒子的基本构成

一个完整的 CSS 盒子由内到外由以下四个部分组成:

  1. Content(内容) :存放文本或图片的区域。
  2. Padding(内边距) :内容与边框之间的透明区域。
  3. Border(边框) :包裹在内边距和内容外的线。
  4. Margin(外边距) :盒子与其他元素之间的距离。

1. Margin 的简写规则(顺时针原则)

  • 1 个值all (四周)
  • 2 个值top/bottom , left/right
  • 3 个值top , left/right , bottom
  • 4 个值top , right , bottom , left (上右下左,顺时针)

2. Border 的复合属性

语法:border: width style color; 例如:border: 2px solid #333;


二、 两大盒模型:标准 vs IE

通过 box-sizing 属性,我们可以切换盒子的计算方式。这是开发中处理“明明设置了宽度,盒子却被撑大”问题的关键。

1. 标准盒模型 (content-box)

  • 默认值
  • 计算公式实际宽度 = width

2. IE 盒模型 / 怪异盒模型 (border-box)

  • 推荐使用
  • 计算公式实际宽度 = width + padding + border
  • 优势:设定好的宽度不会被 padding 撑开,更符合人的直觉。
/* 全局推荐方案 */
* {
  box-sizing: border-box;
}

三、 外边距合并与合并高度

普通文档流中,两个垂直相邻的块级元素,其 margin-topmargin-bottom 会发生折叠(Collapse)。

1. 合并规则

  • 同号:取两者中的较大值
  • 异号:取两者相加之和

2. 经典面试:如何防止外边距合并?

这通常涉及触发 BFC(块级格式化上下文)

  • 为元素设置 display: inline-block
  • 设置 overflow 不为 visible(如 hidden)。
  • 使用 flexgrid 布局(它们内部的子元素不会发生 margin 合并)。

四、 现代布局新特性

1. aspect-ratio(宽高比)

现在只需要一个属性即可设置元素宽高比:

.video-card {
  width: 100%;
  aspect-ratio: 16 / 9; /* 自动根据宽度计算高度 */
  background: #000;
}

五、 面试模拟题

Q1:为什么设置 width: 100% 后再加 padding 页面会出现滚动条?如何解决?

参考回答: 因为默认是标准盒模型 (content-box),100% 宽度加上 padding 后的总宽度超过了父容器。 解决方案:将该元素的 box-sizing 设置为 border-box

Q2:什么是 BFC?它与盒模型有什么关系?

参考回答: BFC 是页面上的一个独立渲染区域。在 BFC 内部,盒子的布局不会影响到外部。利用 BFC 可以:

  1. 防止垂直外边距合并。
  2. 清除内部浮动(父元素高度塌陷问题)。
  3. 防止元素被浮动元素遮盖。

Q3:margin: auto 为什么能实现水平居中?

参考回答: 在块级元素设定了固定 width 的情况下,将左右 margin 设置为 auto,浏览器会自动平分剩余的可用空间,从而使元素居中。注意:垂直方向的 margin: auto 只有在 Flex 布局或绝对定位下才有效。

JS-前端埋点神器 navigator.sendBeacon 全指南

前言

在前端开发中,埋点系统是必不可少的一环。我们经常需要在用户关闭页面刷新跳转路由时,向服务器发送最后一条统计数据(比如用户停留时长、页面跳出率)。

但这看似简单的需求,在实现时却危机四伏:请求发不出去?页面跳转卡顿?今天我们就来聊聊这个问题的终极解决方案 —— navigator.sendBeacon

一、 痛点与传统方案的挣扎

场景还原

当用户点击关闭按钮时,浏览器会触发生命周期事件(unloadvisibilitychange)。如果我们直接使用普通的异步 AJAX (xhrfetch) 发送请求,浏览器通常会忽略它,因为页面都要销毁了,浏览器不想处理未完成的请求。

传统方案:同步 XHR

为了保证数据能发出去,以前的做法是将请求改为同步(Synchronous)

const syncReport = (url, { data = {}, headers = {} } = {}) => {
  const xhr = new XMLHttpRequest();
  // 第三个参数 false 表示同步请求
  xhr.open('POST', url, false);
  xhr.withCredentials = true;
  Object.keys(headers).forEach((key) => {
    xhr.setRequestHeader(key, headers[key]);
  });
  xhr.send(JSON.stringify(data));
};

致命缺陷

  1. 用户体验极差:同步请求会阻塞主线程。这意味着只有请求发送完成,页面才能关闭或跳转。在弱网环境下,用户会感觉页面“卡死”了。
  2. 浏览器废弃:现代浏览器(如 Chrome)已经明确表示将在页面卸载期间禁用同步 XHR,这种方法迟早失效。

二、 救世主:navigator.sendBeacon

1. 概念

navigator.sendBeacon() 是专门为“页面卸载时发送数据”而设计的 Web API。 它的核心能力是:将数据放入浏览器的发送队列,即使页面已经关闭,浏览器也会在后台默默完成发送。

2. 核心优势

  • 可靠性高:不受页面生命周期影响,确保数据不丢失。
  • 非阻塞:完全异步执行,不会阻塞页面关闭或跳转,用户体验丝滑。
  • 低优先级:浏览器会择机发送(通常是网络空闲时),不争抢关键资源。

3. API 语法

const result = navigator.sendBeacon(url, data);
  • url:请求地址。

  • data:要发送的数据,支持 ArrayBufferArrayBufferViewBlobDOMStringFormDataURLSearchParams

  • result(返回值):布尔值 (true / false)。

    • true:表示数据成功加入传输队列(注意:不代表服务器接收成功)。
    • false:表示队列已满,无法加入。

三、 实战:三种常见发送姿势

1. 发送普通字符串

默认 Content-Typetext/plain

const reportData = (url, data) => {
  // data 可能会被转为字符串 "[object Object]",建议先 stringify
  navigator.sendBeacon(url, JSON.stringify(data));
};

2. 发送 JSON 数据(推荐)

如果你希望后端接收到的 Content-Typeapplication/json 或者 application/x-www-form-urlencoded,需要使用 Blob 来手动指定。

const reportData = (url, data) => {
  // ✅ 正确写法:Blob 的第二个参数才是 options
  const blob = new Blob([JSON.stringify(data)], {
    type: 'application/json; charset=UTF-8' // 或者 application/x-www-form-urlencoded
  });
  navigator.sendBeacon(url, blob);
};

3. 发送 FormData

适用于需要上传文件或模拟表单提交的场景。浏览器会自动设置 Content-Typemultipart/form-data

const reportData = (url, data) => {
  const formData = new FormData();
  Object.keys(data).forEach((key) => {
    let value = data[key];
    // FormData 的 value 只能是字符串或 Blob
    if (typeof value !== 'string' && !(value instanceof Blob)) {
      value = JSON.stringify(value);
    }
    formData.append(key, value);
  });
  navigator.sendBeacon(url, formData);
};

四、 避坑指南(面试考点)

  1. 请求类型固定sendBeacon 只能发送 POST 请求。
  2. 无法读取响应:这是一个“射后不理”的 API,你无法获取服务器返回的数据(状态码、Response Body 等)。
  3. 数据大小限制:虽然标准没有明确规定,但浏览器对队列总大小有限制(通常在 64KB 左右),不适合发送大数据。
  4. Cookie 携带sendBeacon 默认会携带同域的 Cookie。

五、 面试模拟题

Q1:sendBeaconajax (XHR/Fetch) 有什么根本区别?

参考回答:

  • 生命周期:Ajax 请求属于页面上下文,页面关闭时请求会被取消(除非同步);sendBeacon 属于浏览器上下文,页面关闭后依然存活。
  • 交互体验:页面卸载时,同步 Ajax 会阻塞跳转;sendBeacon 是异步非阻塞的。
  • 功能限制sendBeacon 只能 POST,无法自定义 headers(除了 Content-Type),且无法读取响应。

Q2:如果浏览器不支持 sendBeacon 怎么办?

参考回答: 需要做降级处理。

  1. 检测 navigator.sendBeacon 是否存在。
  2. 如果不存在,降级为 同步 XHR 请求(虽然体验差,但得保数据)。
  3. 或者使用 <img> 标签发送 GET 请求(仅限数据量极小且不需要响应的场景)。

Q3:sendBeacon 返回 true 代表数据一定发送成功了吗?

参考回答: 不一定。返回 true 仅代表浏览器成功将数据加入了发送队列。如果网络断开、或者浏览器崩溃,数据依然可能发送失败。但相比于普通 Ajax,它的成功率已经高出了几个数量级。

JS-Navigator 对象全方位实战指南

前言

在前端开发中,我们需要了解用户到底在用什么设备、网络状况如何、甚至物理位置在哪里。这时,navigator 对象就是我们派出的“侦探”。它存储了浏览器的版本、操作系统、设备能力等关键信息。今天我们就来盘点 navigator 中那些高频使用的核心技能。

一、 核心身份识别:UserAgent

navigator.userAgent 是实战中用来判断设备类型(iOS/Android/PC)的基石。

// 简单的设备判断函数
const isMobile = /Mobi|Android|iPhone/i.test(navigator.userAgent);
console.log(isMobile ? "当前是移动端" : "当前是 PC 端");

注意:随着隐私保护加强,现代浏览器正在推广 User-Agent Client Hints (navigator.userAgentData) 来逐步替代 userAgent


二、 现代 API 实战(高频场景)

navigator 不仅仅是用来读属性的,它还挂载了许多强大的 API。

1. 剪切板操作:Clipboard API

早期的 document.execCommand 已被废弃,现代复制粘贴使用 navigator.clipboard,它是异步的。

// 复制文本到剪切板
async function copyText(text) {
  try {
    await navigator.clipboard.writeText(text);
    console.log('复制成功!');
  } catch (err) {
    console.error('复制失败: ', err);
  }
}

// 读取剪切板内容
async function readText() {
  const text = await navigator.clipboard.readText(); 
  console.log('剪切板内容:', text); // text为复制到剪切板上内容
}

2. 页面卸载时的“遗言”:sendBeacon

面试必考点:如何在页面关闭(unload)前可靠地发送埋点数据?

使用 XHR 或 Fetch 可能会因为页面关闭而被浏览器取消,使用同步 AJAX 会阻塞页面关闭影响体验。sendBeacon 是最佳解。

  • 特点:异步发送、不阻塞页面关闭、但是不一定保证发送成功!!!
// 页面卸载时发送数据
window.addEventListener('unload', function() {
  const data = JSON.stringify({ event: 'page_close', time: Date.now() });
  
  // 注意:如果后端需要 JSON 格式,建议使用 Blob 设置 Header
  const blob = new Blob([data], { type: 'application/json' });
  
  const result = navigator.sendBeacon('/api/log', blob);
  console.log(result ? "埋点进入发送队列" : "发送队列已满");
});

3. 地理位置:Geolocation

获取用户经纬度,常用于地图或本地服务。

  • 注意:必须在 HTTPS 环境下才能调用,且需要用户授权。
if (navigator.geolocation) {
  navigator.geolocation.getCurrentPosition(
    (pos) => {
      console.log(`维度: ${pos.coords.latitude}`); 
      console.log(`经度: ${pos.coords.longitude}`);
    },
    (err) => {
      console.error("定位失败(可能是用户拒绝或超时):", err.message);
    },
    { timeout: 5000 } // 设置超时时间
  );
}

三、 环境嗅探属性

属性 描述 示例值
language 浏览器首选语言 "zh-CN"
cookieEnabled 是否启用 Cookie true
platform 操作系统平台(已废弃但常用) "Win32", "MacIntel"
hardwareConcurrency CPU 逻辑核心数 8 (常用于决定开启多少 Web Worker)

四、 网络状态的“假象”:onLine

navigator.onLine 返回 true 表示设备连接到了局域网或路由器,并不代表一定能访问互联网(比如连了 wifi 但宽带欠费了)。

因此,更严谨的网络检测通常结合 window 的事件监听:

function updateStatus() {
  const status = document.getElementById('status');
  if (navigator.onLine) {
    console.log("网络已连接(但不一定能上网)");
    // 实际场景中,这里通常会发一个请求 ping 一下服务器来确认真连网
  } else {
    console.log("网络已断开");
  }
}

// 监听网络变化事件
window.addEventListener('online', updateStatus);
window.addEventListener('offline', updateStatus);

五、 面试模拟题

Q1:如何判断当前用户是否处于断网状态?

参考回答:

初步判断可以使用 navigator.onLine 属性,配合 window 的 online 和 offline 事件监听。但 navigator.onLine 存在误报(只检测网卡连接状态),最稳妥的方式是配合一个轻量级的 Ajax 请求(Ping)或者加载一张 1x1 像素的图片来检测实际连通性。

Q2:navigator.sendBeacon 和普通 AJAX 请求有什么区别?

参考回答:

  1. 优先级sendBeacon 是为了解决页面卸载时发送数据的问题设计的,浏览器会将其放入专门的队列,即使页面已关闭,浏览器也会在后台完成发送。
  2. 不阻塞:它完全异步,不会像同步 XHR 那样阻塞页面跳转。
  3. 请求类型:只能发送 POST 请求,且无法读取服务器的响应内容(它是“射后不理”的)。

Q3:如何获取用户的剪切板内容?有什么限制?

参考回答:

使用 navigator.clipboard.readText()。

限制:

  1. 必须在 HTTPS 环境下使用。
  2. 必须由 用户交互(如点击事件)触发,不能自动读取。
  3. 浏览器通常会弹窗询问用户是否允许读取。

JS-深度解密 History API:单页应用(SPA)实现无刷新跳转的底层逻辑

前言

在现代前端开发中,我们习惯了页面不刷新但 URL 却在变化的体验。这背后除了 Hash 模式,最核心的功臣就是 History 对象。它不仅能控制页面的前进后退,还能在不触发请求的情况下修改地址栏。

一、 History 对象:用户的“航迹云”

window.history 存储了用户在当前窗口中访问的所有记录。为了隐私保护,你无法看到具体的 URL 列表,但可以通过它自由穿梭。

1. 基础属性

  • length:历史堆栈中的条目总数(包括当前页)。
  • state:返回当前历史条目的状态对象副本。

2. 基础导航方法

  • back() :后退一页。

  • forward() :前进一页。

  • go(n)

    • n > 0:前进 n 步。
    • n < 0:后退 n 步。
    • n = 0 或不传:刷新当前页面

二、 HTML5 状态管理:无刷新跳转的核心

HTML5 为 history 引入了两个重量级方法:pushStatereplaceState。它们允许我们在不请求服务器的情况下,手动修改浏览器的地址栏。

1. pushState(state, title, url) —— 新增记录

  • 作用:在历史记录栈中添加一个新条目。

  • 参数

    • state:一个 JSON 对象,用于存储自定义信息。
    • title:目前大多数浏览器忽略,传 "" 即可。
    • url:新的 URL 地址,必须与当前页面同源

2. replaceState(state, title, url) —— 替换记录

  • 作用:修改当前的历史记录,而不是创建新的。
  • 影响:调用后 history.length 不会增加。

⚠️ 核心特点(避坑指南):

  • 不刷新:调用这两个方法后,地址栏变了,但浏览器不会检查 URL 是否存在,也不会重新加载页面。
  • 不触发 popstate:手动调用 pushStatereplaceState 不会触发 popstate 事件。

三、 popstate 事件:监听历史变动

虽然 pushState 不会触发 popstate,但浏览器的原生行为会触发它:

  • 点击“后退”或“前进”按钮。
  • 在 JS 中调用 back()forward()go()
window.addEventListener('popstate', (event) => {
    console.log("检测到路径变化,当前关联的状态数据:", event.state);
    // 这里通常是单页路由的核心:根据新的状态更新 UI 组件
});

四、 实战:为什么 Vue/React Router 需要它?

在 History 模式下,路由系统会拦截链接的默认点击行为,改为调用 history.pushState

  1. 用户点击链接 ➜ 拦截默认跳转 ➜ 执行 pushState('/new-path')
  2. 地址栏更新 ➜ 页面不刷新。
  3. 代码主动更新组件 ➜ 渲染新内容。
  4. 用户点击后退 ➜ 触发 popstate ➜ 路由监听到变化 ➜ 切换回旧组件。

五、 面试模拟题

Q1:history.pushStatelocation.href 有什么区别?

参考回答:

  • location.href 会导致浏览器立即向服务器发送请求,触发整页刷新。
  • history.pushState 仅修改浏览器地址栏并增加历史记录,不会触发网络请求,也不会刷新页面,是单页应用无刷新跳转的基础。

Q2:为什么 History 模式在刷新页面时会出现 404?如何解决?

参考回答: 因为 pushState 设置的 URL 是前端虚拟的。用户直接刷新时,浏览器会向服务器请求这个不存在的物理路径。 解决方法:需要后端(Nginx/Node)配合。将所有未命中的静态资源请求统一重定向(Fallback)到 index.html,让前端路由接管后续的解析。

Q3:replaceState 的典型应用场景是什么?

参考回答: 常用于重定向纠正 URL。例如:

  1. 用户登录后,从登录页跳转到个人中心,可以使用 replaceState 替换掉登录页。这样用户在个人中心点击“后退”时,不会再回到登录页,而是回到登录之前的页面。
  2. 在搜索页修改筛选条件时,实时更新 URL 却不希望产生过多的历史记录导致用户后退困难。

更新完就跳槽系列之html篇吊打面试官

HTML面试题汇总

  1. 结构与语义:语义化标签、文档流、空元素。
  2. 通信与存储:本地存储、Web Worker、跨文档通信、表单enctype。
  3. 渲染与性能:渲染引擎原理、defer/async、性能优化。
  4. 安全:同源策略、XSS/CSRF、data-*属性。

一、广义的 HTML5 新增了哪些东西?

HTML5 不仅指新的 HTML 标记语言标准,更是一个技术集合,为 Web 开发带来了革命性变化。

1. 语义化标签 (Semantic Tags)

HTML5 引入了大量具有明确含义的标签,取代了过去到处都是 <div> 的局面,提高了代码的可读性、SEO 和无障碍访问性。

  • 结构标签<header><footer><nav><section><article><aside><main>
  • 其他语义标签<figure>(插图)、<figcaption>(插图标题)、<time><mark>(高亮)

2. 多媒体支持 (Multimedia)

在 HTML5 之前,播放视频或音频通常需要第三方插件(如 Flash)

  • <video><audio> :原生支持流媒体播放,支持 controlsautoplayloop 等属性
  • <track> :为媒体文件添加字幕(WebVTT 格式)

3. 表单增强 (Forms 2.0)

大大简化了前端表单验证和交互逻辑

  • 新的 Input 类型emailurlnumberrange(滑块)、datetimecolorsearchtel
  • 新属性placeholder(占位符)、required(必填)、autofocus(自动聚焦)、multiple(多选)、pattern(正则匹配)
  • 新元素<datalist>(输入建议列表)、<output>(计算结果输出)

4. 强大的绘图与图形 (Graphics)

Web 不再只是静态的图文,而是可以进行高性能渲染

  • Canvas API:使用 JavaScript 在网页上绘制 2D 图形(适合游戏、动态图表)
  • SVG 内联:支持在 HTML 中直接嵌入和操作可伸缩矢量图形
  • WebGL:基于 Canvas 的 3D 渲染接口(常配合 Three.js 使用)

5. 本地存储 (Client-Side Storage)

解决了 Cookie 存储空间小(4KB)、性能差的问题

  • localStorage:永久存储数据,除非手动删除
  • sessionStorage:会话级存储,关闭窗口后失效
  • IndexedDB:浏览器端的高性能 NoSQL 数据库,用于存储大量结构化数据

6. 新的 JavaScript API

这是广义 HTML5 最强大的部分,让 Web 应用的功能接近原生 App

  • 地理定位 (Geolocation API) :获取用户的经纬度坐标
  • 拖放 API (Drag and Drop) :原生支持元素拖拽
  • Web Workers:允许在后台线程运行 JS,不阻塞 UI 渲染(多线程处理)
  • WebSockets:全双工通信协议,实现真正的实时数据交互(如聊天、实时报价)
  • History APIpushStatereplaceState,允许不刷新页面修改 URL(单页应用 SPA 的基础)
  • 通知 (Notifications API) :向用户发送桌面弹窗通知
  • 离线缓存 (Service Workers / Cache API) :替代了早期的 AppCache,让网页在无网环境下也能运行(PWA 核心)

7. CSS3 (广义 HTML5 的一部分)

虽然 CSS3 是独立标准,但常被归入 H5 范畴

  • 布局:Flexbox(弹性盒子)、Grid(网格布局)
  • 视觉:圆角 (border-radius)、阴影 (box-shadow)、渐变 (gradient)、透明度 (rgba)
  • 动画:Transition(过渡)、Animation(关键帧动画)、Transform(旋转、缩放、位移)
  • 响应式:Media Queries(媒体查询),实现一套代码适配手机和电脑

8. 设备访问 (Device Access)

  • Device Orientation:访问陀螺仪、重力感应
  • Camera/Microphone API:通过 getUserMedia 调用摄像头和麦克风
  • Battery Status API:获取设备电量

总结

广义 HTML5 的核心价值在于:

  • 脱离插件:干掉了 Flash
  • 移动优先:完美适配手机浏览器
  • 应用化:让网页不再只是文档,而是能离线、能绘图、能定位、能实时通信的 "Web App"

HTML5 的核心目标是减少浏览器对插件的依赖,并提高 Web 应用的性能和用户体验。


二、HTML 文档的基本结构是什么?

一个符合 HTML5 标准的基础结构如下:

html

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>网页标题</title>
</head>
<body>
    <h1>这是一个标题</h1>
    <p>这是一个段落。</p>
</body>
</html>

各部分详细解释:

1. <!DOCTYPE html> (文档类型声明)
  • 必须位于 HTML 文档的第一行
  • 告诉浏览器当前文档使用的是 HTML5 标准
  • 它不是 HTML 标签,而是一个声明
2. <html> 标签 (根元素)
  • 所有其他 HTML 元素的容器(除了 <!DOCTYPE>
  • lang="zh-CN" 属性用于指定网页的语言(此处为简体中文),有助于搜索引擎优化(SEO)和屏幕阅读器
3. <head> 标签 (元数据区)
  • 该部分包含的内容不会直接显示在网页的正文区域

  • 主要存放关于文档的 "元数据"(Metadata),例如:

    • <meta charset="UTF-8">:指定网页的字符编码为 UTF-8,防止中文乱码
    • <meta name="viewport" ...>:确保网页在手机、平板等移动设备上能正确适配
    • <title>:定义网页在浏览器标签栏上显示的名称,也是 SEO 的关键
    • 外部资源引入:如连接 CSS 样式表 (<link>) 或 JavaScript 脚本 (<script>)
4. <body> 标签 (主体内容区)
  • 包含网页上所有可见的内容,如文本、图片、链接、视频、表格、按钮等
  • 用户在浏览器窗口中看到的每一处细节都写在这里

四、语义化标签是什么?

1. 形象对比

无语义写法(像"套娃") : 你用一堆盒子装东西,每个盒子上都只写着"盒子1"、"盒子2"。你想找勺子,必须把盒子一个个打开看。

html

<div id="header">这是头部</div>
<div id="nav">这是导航</div>
<div id="main">这是内容</div>
<div id="footer">这是底部</div>

语义化写法(有标签的盒子) : 你在盒子上清楚地标明"餐具盒"、"急救箱"。一眼看过去就知道里面是什么。

html

<header>这是头部区域</header>
<nav>这是导航区域</nav>
<main>这是网页的主体内容</main>
<footer>这是页脚区域</footer>

2. 常见的语义化标签(HTML5 引入)

这些标签在布局中非常常用:

html

<header>:页眉/头部
<nav>:导航链接部分
<main>:页面的主要内容(一个页面通常只有一个)
<article>:独立的文章内容(如博客文章、新闻)
<section>:文档中的某个章节或区块
<aside>:侧边栏(与主体内容间接相关的内容)
<footer>:页脚/底部
<figure> 和 <figcaption>:用于图片及其说明
<time>:表示日期和时间

3. 为什么要使用语义化标签?

使用语义化标签不仅仅是为了好看,它有三个核心价值:

① 对搜索引擎友好(SEO)

搜索引擎的爬虫(如 Google, 百度)在"阅读"你的网页时,会根据语义化标签来判断权重。比如,它知道 <main> 里的内容比 <footer> 重要,这有助于提升网页的搜索排名。

② 提升可访问性(Accessibility)

可访问性(Accessibility,通常缩写为 a11y)是指确保网页内容能够被所有人(包括有残障的人士)平等地获取和使用。

对于视障人士,他们使用"屏幕阅读器"来听网页内容。阅读器会告诉用户:"现在进入导航栏","现在是正文"。如果全是 div,而且没有起形象的类名的话,屏幕阅读器就会把所有 div 都读出来,用户会迷失在代码中。

深入理解 Accessibility,可以从以下四个核心维度展开

  1. 核心指导原则:POUR 原则 这是国际标准 WCAG(网页内容可访问性指南)的基础:

    • P (Perceivable) 可感知性:用户必须能通过视觉、听觉或触觉感知到信息(例如:图片要有文字描述,视频要有字幕)
    • O (Operable) 可操作性:用户必须能操作界面(例如:不能只有鼠标能点,键盘也得能控制;操作时间要充足)
    • U (Understandable) 可理解性:内容和操作必须清晰(例如:错误提示要明确,语言要简单)
    • R (Robust) 健壮性:内容必须能被各种技术(如不同的浏览器、屏幕阅读器)稳定解析
  2. ARIA 技术:HTML 的补丁 ARIA (Accessible Rich Internet Applications) 是一组特殊的 HTML 属性,用来增强标签的语义。

    • 什么时候用? 当原生 HTML 标签无法表达复杂的交互时

    • 第一金律: 如果能用原生标签(如 <button>),就永远不要用 ARIA

    • 常用属性示例

      • role (角色):告诉阅读器这是一个什么组件

        html

        <div role="progressbar"></div> <!-- 告诉用户这是进度条 -->
        
      • aria-label (标签):给没有文字的元素加描述

        html

        <button aria-label="关闭窗口">X</button> <!-- 阅读器会读出"关闭窗口按钮" -->
        
      • aria-expanded (状态):表示折叠菜单是否打开

        html

        <button aria-expanded="true">菜单</button>
        
  3. 键盘导航 (Keyboard Navigation) 很多肢体残障人士或极客用户不使用鼠标,只使用 Tab 键切换。

    • 焦点管理 (Focus)

      • 所有的交互元素(链接、按钮、输入框)必须能通过 Tab 键选中
      • 不要去掉焦点框!很多设计师喜欢用 outline: none 去掉那个"难看"的蓝色边框,但这对于键盘用户来说是灾难,因为他们不知道现在选到哪了
    • Tabindex

      • tabindex="0":让原本不能选中的元素(如 div)可以被 Tab 选中
      • tabindex="-1":元素不能被 Tab 选中,但可以用脚本聚焦
  4. 视觉设计细节 Accessibility 不仅仅是代码,也关乎视觉设计。

    • 色彩对比度:文字和背景的对比度至少要达到 4.5:1。如果颜色太接近,弱视或色盲用户无法阅读

    • 不要仅依靠颜色传达信息

      • ❌ 错误:输入框边框变红表示错误
      • ✅ 正确:边框变红的同时,旁边出现文字"错误:请输入邮箱"或者加一个感叹号图标
    • 图片 Alt 属性

      • 有意义的图:<img alt="一只穿着雨鞋的柯基犬">
      • 装饰性的图:<img alt="">(阅读器会自动跳过,如果不写 alt,阅读器可能会读出文件名,非常嘈杂)
  5. 屏幕阅读器 (Screen Readers) 的工作方式 了解视障人士如何"看"网页:

    • 按标题跳转:阅读器用户通常会按快捷键在 <h1><h6> 之间跳转来快速了解大意。所以标题等级严禁跳跃(不要从 h1 直接跳到 h3)
    • 地标区域 (Landmarks) :阅读器会识别 <header><nav><main>。用户可以一键跳到"主内容区",这就是为什么语义化标签对 a11y 至关重要
  6. 如何测试 Accessibility?

    • Lighthouse:Chrome 浏览器自带,在"开发者工具"里有一个 Accessibility 评分
    • WAVE:一个非常著名的插件,能直接在页面上标出哪里对比度不够,哪里缺标签
    • 尝试只用键盘控制你的网页:如果你发现自己无法完成登录或提交表单,说明 a11y 做得不够好
③ 提高代码可读性和维护性

当其他开发者(或者几个月后的你自己)阅读代码时,语义化标签能让他们迅速理解页面结构,而不需要从大量的 class 名中去猜这块代码的功能。

总结

语义化标签就是用正确的标签做正确的事。

  • 不要滥用 <div> 来搭建所有结构
  • 如果是文章,就用 <article>
  • 如果是导航,就用 <nav>
  • 如果是页脚,就用 <footer>

五、块级元素和内联元素有什么区别?

1. 块级元素 (Block) —— "霸道总裁"

代表标签<div><p><h1>~<h6><ul><header><footer>

特性

  • 换行:非常霸道,必须独占一行。即便你给它设置了很小的宽度,它后面的元素也必须另起一行
  • 尺寸:默认宽度是父容器的 100%。你可以随意设置 width(宽)和 height(高)
  • 边距:四个方向的 margin(外边距)和 padding(内边距)完全有效,能把周围的元素推开
  • 用途:网页的骨架(如侧边栏、导航条、文章区块)

2. 行级元素 (Inline) —— "邻家女孩"

代表标签<span><a><strong><em>

特性

  • 换行:很随和,不换行。它们会像文字一样,一个接一个地排在同一行,直到排不下才会折行

  • 尺寸:无法设置宽度和高度。它的宽高度完全由包裹的内容(文字或图片)撑开。你写 width: 100px; 是会被浏览器直接忽略的

  • 边距

    • 水平方向(左右):margin-left/rightpadding-left/right 有效
    • 垂直方向(上下):设置 margin-top/bottom 无效;设置 padding-top/bottom 视觉上有颜色,但不会推开上下行的文字(会产生重叠)
  • 用途:修改段落里的局部样式(如给某个词加粗或变红)

3. 行内块元素 (Inline-block) —— "全能选手"

代表标签<img><input><button>,或通过 display: inline-block 转换的元素

特性

  • 换行:像行级元素一样,不换行,可以和别人并排站
  • 尺寸:像块级元素一样,可以自由设置 widthheight
  • 边距:四个方向的 marginpadding 全部有效,且能完美推开周围的元素
  • 奇点:这种元素在代码里如果换行写,浏览器会在它们之间产生一个微小的空隙(这是因为换行符被当成了空格)
  • 用途:制作并排的导航菜单、商品卡片列表

核心区别对比表

特性 块级元素 (Block) 行级元素 (Inline) 行内块元素 (Inline-block)
换行 独占一行 不换行,可并排 不换行,可并排
设置宽高 ✅ 可以 ❌ 不可以 ✅ 可以
margin/padding 全部有效 水平有效,垂直无效 全部有效
默认宽度 父元素宽度 内容宽度 内容宽度(可设置)
常见标签 <div>, <p>, <h1>~<h6> <span>, <a>, <strong> <img>, <input>, <button>

补充:如何互相转换?

在 CSS 中,你可以通过 display 属性让它们"变身":

css

/* 想让 span 变高变宽? */
span { 
    display: block; 
    /* 或 display: inline-block; */
}

/* 想让几个 div 并排显示? */
div { 
    display: inline-block; 
    /* 或者使用现代的 Flex 布局 */
}

/* 想让链接 a 像按钮一样有间距? */
a { 
    display: inline-block; 
    padding: 10px 20px; 
}

总结

  • Block:独占一行,能定大小
  • Inline:挤在一起,不能定大小,上下边距没用
  • Inline-block:挤在一起,但能定大小,边距全有用

六、什么是空元素?

空元素(Void Elements)也叫自闭和标签,是指在 HTML 中不需要闭合标签的元素。它们通常用于插入某种内容或资源到文档中,而不包裹任何内容。

常见的空元素:

html

<img src="image.jpg" alt="描述文字">
<input type="text" name="username">
<br>
<hr>
<meta charset="UTF-8">
<link rel="stylesheet" href="style.css">

特性:

  1. 不能包含任何内容:空元素内部不能有子元素或文本内容
  2. 不需要闭合标签:在 HTML5 中,不需要写成 <img /> 形式(虽然 XML 风格也兼容)
  3. 用于引入资源或表示结构性分隔

正确写法:

html

<!-- HTML5 推荐写法(简洁) -->
<img src="photo.jpg" alt="照片">
<br>
<input type="email">

<!-- XHTML/XML 风格(也兼容) -->
<img src="photo.jpg" alt="照片" />
<br />
<input type="email" />

常见的空元素列表:

  • <area>:图像映射中的区域
  • <base>:文档中所有相对 URL 的基准 URL
  • <br>:换行符
  • <col>:表格列的属性
  • <embed>:外部内容的容器
  • <hr>:水平分隔线
  • <img>:图像
  • <input>:输入控件
  • <link>:链接到外部资源
  • <meta>:文档元数据
  • <param>(已废弃):对象参数
  • <source>:媒体元素的媒体资源
  • <track>:媒体元素的文本轨道
  • <wbr>:可选的换行点

七、data-* 自定义属性的作用是什么?如何在 JavaScript 中访问?

1. data-* 自定义属性的作用

【官方定义】 :data-* 属性(Custom Data Attributes)是 HTML5 引入的一种规范,允许我们在标准语义标签上,嵌入自定义的私有元数据,且不会被浏览器解析为布局或样式逻辑。

“data-* 属性是 HTML 语义化的延伸,它在 DOM 节点上建立了一个结构化的私有数据存储空间。通过 dataset API,我们可以实现数据与视图的轻量级绑定,尤其在处理事件委托、样式状态联动以及 SSR 初始化数据注入时,它是比频繁操作 ClassList 更加语义化、更易于静态分析的方案。

(1) 核心价值:语义化的数据存储

<!-- 语义化的数据声明,易于理解和维护 -->
<div 
    data-user='{"id": 101, "role": "admin"}'
    data-theme="dark"
    data-interaction-state="active"
    data-validation-rules='{"required": true, "minLength": 3}'
>
    用户控制面板
</div>

2. 核心应用场景

(1) 事件委托中的轻量级数据绑定

<!-- 列表项统一委托处理,避免为每个元素单独绑定事件 -->
<ul id="task-list" onclick="handleTaskClick(event)">
    <li data-task-id="t1" data-priority="high" data-status="pending">
        紧急任务
    </li>
    <li data-task-id="t2" data-priority="medium" data-status="in-progress">
        进行中任务
    </li>
    <li data-task-id="t3" data-priority="low" data-status="completed">
        已完成任务
    </li>
</ul>

<script>
function handleTaskClick(event) {
    const target = event.target.closest('li[data-task-id]');
    if (!target) return;
    
    // 从 data-* 属性获取完整上下文
    const taskData = {
        id: target.dataset.taskId,
        priority: target.dataset.priority,
        status: target.dataset.status,
        timestamp: Date.now()
    };
    
    // 统一的事件处理逻辑
    console.log('任务点击:', taskData);
    // 进一步处理...
}
</script>

(2) CSS 样式状态联动

<!-- 通过 data-* 属性控制 CSS 样式,实现状态驱动UI -->
<div class="progress-container">
    <div 
        class="progress-bar" 
        data-progress="75"
        data-status="warning"
        style="--progress: 75%;"
    >
        <span data-progress-text="75%">75%</span>
    </div>
</div>

<style>
/* CSS 可以通过属性选择器响应 data-* 状态变化 */
.progress-bar[data-status="normal"] {
    --color: #4CAF50;
}

.progress-bar[data-status="warning"] {
    --color: #FF9800;
}

.progress-bar[data-status="error"] {
    --color: #F44336;
}

.progress-bar::before {
    content: '';
    display: block;
    width: var(--progress);
    height: 100%;
    background-color: var(--color);
    transition: width 0.3s ease;
}

/* 通过 CSS 计数器显示 data-* 内容 */
.progress-bar::after {
    content: attr(data-progress) '%';
    position: absolute;
    right: 10px;
    color: white;
}
</style>

(3) SSR(服务端渲染)初始化数据注入

<!-- 服务端渲染时将初始状态注入到 data-* 属性中 -->
<div id="app" 
     data-initial-state='{"user": {"name": "张三", "role": "admin"}, "theme": "dark"}'
     data-config='{"apiEndpoint": "/api", "features": ["ssr", "pwa"]}'
>
    <!-- 客户端 JS 可以直接读取,无需二次请求 -->
</div>

<script>
// 客户端直接读取 SSR 注入的数据
const appElement = document.getElementById('app');
const initialState = JSON.parse(appElement.dataset.initialState);
const config = JSON.parse(appElement.dataset.config);

// 初始化应用状态
window.APP_STATE = {
    ...initialState,
    hydrationTime: Date.now()
};

// 基于配置启用功能
if (config.features.includes('pwa')) {
    // 注册 Service Worker...
}
</script>

3. 现代框架中的最佳实践

(1) 与 Vue 3 的整合

<!-- Vue 3 组件中使用 data-* 属性 -->
<template>
    <div 
        :data-user-id="user.id"
        :data-user-role="user.role"
        :data-component-state="state"
        @click="handleClick"
    >
        {{ user.name }}
    </div>
</template>

<script setup>
import { ref, computed } from 'vue';

const user = ref({ id: 1, name: '张三', role: 'admin' });
const state = ref('active');

// 在 Vue 中访问其他元素的 data-* 属性
function handleClick() {
    const element = document.querySelector('[data-user-id="2"]');
    if (element) {
        console.log('其他用户状态:', element.dataset);
    }
}
</script>

(2) 与 React 的整合

// React 组件中使用 data-* 属性
function UserCard({ user, status }) {
    return (
        <div
            data-user-id={user.id}
            data-user-role={user.role}
            data-component-state={status}
            data-user-info={JSON.stringify(user)}
            onClick={handleClick}
            className="user-card"
        >
            {user.name}
        </div>
    );
    
    function handleClick(event) {
        // React 中通过原生方式访问
        const element = event.currentTarget;
        const userId = element.dataset.userId;
        const userInfo = JSON.parse(element.dataset.userInfo);
        
        // 或者使用自定义 hook
        const data = useDataset(element);
        console.log(data);
    }
}

// 自定义 hook 封装 dataset 操作
function useDataset(elementRef) {
    const [dataset, setDataset] = useState({});
    
    useEffect(() => {
        if (!elementRef.current) return;
        
        const observer = new MutationObserver(() => {
            setDataset({ ...elementRef.current.dataset });
        });
        
        observer.observe(elementRef.current, {
            attributes: true,
            attributeFilter: Object.keys(elementRef.current.dataset).map(k => `data-${k}`)
        });
        
        return () => observer.disconnect();
    }, [elementRef]);
    
    return dataset;
}

4. 静态分析与工具支持

(1) TypeScript 类型定义

// 定义 data-* 属性的类型约束
interface CustomDataAttributes {
    'data-user-id'?: string;
    'data-user-role'?: 'admin' | 'user' | 'guest';
    'data-component-state'?: 'active' | 'inactive' | 'loading';
    'data-validation-state'?: 'pending' | 'valid' | 'invalid';
    'data-config'?: string; // JSON 字符串
}

// 扩展 HTML 元素类型
declare global {
    interface HTMLElement {
        dataset: DOMStringMap & {
            userId?: string;
            userRole?: string;
            componentState?: string;
            config?: string;
        };
    }
}

// 使用时的类型提示
const element = document.getElementById('app')!;
element.dataset.userId = '123';  // 有类型提示
element.dataset.userRole = 'admin';  // 只能赋值 'admin' | 'user' | 'guest'

八、defer和async属性有什么区别?

1. 核心概念

deferasync 都是 <script> 标签的布尔属性,用于控制外部 JavaScript 脚本的加载和执行时机,主要目的是优化页面加载性能,避免脚本阻塞页面渲染。

<!-- 普通脚本 - 阻塞渲染 -->
<script src="normal.js"></script>

<!-- 异步脚本 -->
<script async src="async.js"></script>

<!-- 延迟脚本 -->
<script defer src="defer.js"></script>

2. 三种加载模式对比

(1) 普通脚本(无属性)

执行流程

HTML解析 → 遇到<script> → 暂停解析 → 下载脚本 → 执行脚本 → 继续解析HTML
<script src="script.js"></script>
<!-- 后续的DOM元素需要等待脚本执行完毕才能渲染 -->

(2) async 异步加载

执行流程

HTML解析开始
    ↓
同时下载async脚本
    ↓
脚本下载完成 → 立即暂停HTML解析 → 执行脚本 → 继续解析HTMLHTML解析完成
<script async src="script1.js"></script>
<script async src="script2.js"></script>
<!-- 脚本下载完成后立即执行,执行顺序不确定 -->

(3) defer 延迟执行

执行流程

HTML解析开始
    ↓
同时下载defer脚本
    ↓
HTML解析完成 → 按顺序执行所有defer脚本 → 触发DOMContentLoaded
<script defer src="script1.js"></script>
<script defer src="script2.js"></script>
<!-- 所有defer脚本在HTML解析完成后,按顺序执行 -->

3. 详细对比表格

特性 普通脚本 async 脚本 defer 脚本
HTML解析是否阻塞 ❌ 立即暂停 ✅ 并行进行 ✅ 并行进行
脚本下载时机 遇到即下载 异步下载 异步下载
脚本执行时机 下载后立即执行 下载后立即执行 HTML解析完成后执行
执行顺序保证 ✅ 文档顺序 ❌ 不保证(先下载完先执行) ✅ 严格文档顺序
DOMContentLoaded 执行后才触发 可能阻塞或并行 执行前触发
适合场景 必要的初始化脚本 独立第三方脚本 依赖DOM的操作脚本

4. 总结

选择指南

使用 async

  • 脚本完全独立,不依赖其他脚本
  • 不操作 DOM,或操作可以安全延迟
  • 主要用于收集数据或跟踪
  • 执行顺序不重要

使用 defer

  • 脚本需要操作 DOM
  • 脚本之间有依赖关系
  • 需要在 DOM 完全加载后执行
  • 希望保持执行顺序

默认选择: 现代 Web 开发中,如果没有特殊要求,优先使用 defer,因为它提供了最佳的性能和可预测性。

记忆口诀

text

async:异步下载,立即执行,顺序不管
defer:异步下载,延迟执行,顺序不乱
普通:阻塞下载,立即执行,顺序照办

九、enctype属性的三种值分别代表什么?什么时候multipart/form-data?

1. enctype 是什么?

控制表单数据如何编码发送到服务器

<form method="post" enctype="值">
    <!-- 表单内容 -->
</form>

2. 三种值的区别

用途 特点
application/x-www-form-urlencoded (默认值) 普通文本表单 - 键值对编码 - 特殊字符转义 - 适合用户名、密码等
multipart/form-data 文件上传表单 - 支持二进制数据 - 每个字段独立部分 - 数据量较大
text/plain 纯文本调试 - 简单纯文本 - 人类可读 - 很少使用

3. 具体解释

(1) 默认值:application/x-www-form-urlencoded

<form method="post">
    <!-- 或 enctype="application/x-www-form-urlencoded" -->
    <input type="text" name="user" value="张三">
    <input type="password" name="pwd" value="123">
</form>

发送的数据

user=%E5%BC%A0%E4%B8%89&pwd=123

✅ 中文会转码,&符号连接字段

(2) 文件上传:multipart/form-data

<form method="post" enctype="multipart/form-data">
    <input type="text" name="title" value="头像">
    <input type="file" name="image">
</form>

发送的数据

------边界字符串
Content-Disposition: form-data; name="title"

头像
------边界字符串
Content-Disposition: form-data; name="image"; filename="pic.jpg"
Content-Type: image/jpeg

[二进制图片数据]
------边界字符串--

(3) 纯文本:text/plain

<form method="post" enctype="text/plain">
    <input type="text" name="name" value="测试">
</form>

发送的数据

name=测试

⚠️ 实际开发基本不用

4. 什么时候必须用 multipart/form-data?

只要表单中有文件上传,就必须用!

<!-- ✅ 正确:有文件,用 multipart/form-data -->
<form method="post" enctype="multipart/form-data">
    <input type="text" name="username">
    <input type="file" name="avatar">  <!-- 文件字段 -->
    <button>提交</button>
</form>

<!-- ❌ 错误:有文件但用了默认编码 -->
<form method="post">
    <!-- enctype 默认是 application/x-www-form-urlencoded -->
    <input type="file" name="avatar">  <!-- 文件会上传失败! -->
</form>

十、本地存储有哪几种方式?它们的区别是什么?

1. 核心概念:所有存储都在客户端

重要说明:以下所有存储方式的数据都保存在用户自己的设备上(浏览器中),不在网站服务器上。网站无法直接访问这些数据(除非你主动发送)。

2. 五种存储方式对比总表

方式 存储位置 容量 生命周期 是否自动发到服务器 数据结构 主要用途
Cookie 客户端硬盘+内存 4KB 可设置过期时间 ✅ 每次请求自动带 字符串键值 登录状态、会话
LocalStorage 客户端硬盘 5-10MB 永久(手动清除) ❌ 需主动发送 字符串键值 用户偏好设置
SessionStorage 客户端内存 5-10MB 标签页关闭消失 ❌ 需主动发送 字符串键值 临时表单数据
IndexedDB 客户端硬盘 ≥250MB 永久(手动清除) ❌ 需主动发送 对象数据库 离线应用数据
Cache API 客户端硬盘 动态 可编程控制 ❌ 需主动发送 请求/响应 PWA离线缓存

3. 详细说明与代码示例

(1) Cookie 🍪 - "服务员的小纸条"

特点:数据小,每次请求都自动带给服务器

javascript

// 设置Cookie(客户端操作)
document.cookie = "username=张三; max-age=3600"; // 1小时过期

// 读取
console.log(document.cookie); // "username=张三"

// 服务器也能设置(通过HTTP响应头)
// Set-Cookie: sessionid=abc123; HttpOnly

适用场景

  • 用户登录状态(最常用)
  • 购物车ID
  • 语言选择

数据流向

text

你的浏览器 → 自动附带 → 服务器
    ↑                         ↓
    ←───── 响应时带回 ←─────

(2) LocalStorage 📦 - "你的私人抽屉"

特点:只在你电脑里,网站刷新、关闭都还在

javascript

// 存数据(会一直保留)
localStorage.setItem('theme', 'dark');
localStorage.setItem('user', JSON.stringify({name: '张三'}));

// 取数据
const theme = localStorage.getItem('theme'); // "dark"
const user = JSON.parse(localStorage.getItem('user')); // {name: "张三"}

// 删数据
localStorage.removeItem('theme');
localStorage.clear(); // 清空所有

适用场景

  • 网站主题设置
  • 记住登录用户名(非密码)
  • 表单草稿保存

(3) SessionStorage 💼 - "临时办公桌"

特点:只存在当前标签页,关了就没

javascript

// 和LocalStorage用法一样
sessionStorage.setItem('cart', JSON.stringify(['苹果', '香蕉']));

// 但:开新标签页就访问不到了
// 刷新页面还在,关闭标签页就消失

适用场景

  • 购物车商品(当前会话)
  • 多步骤表单暂存
  • 页面间临时传值

(4) IndexedDB 🗄️ - "大型文件柜"

特点:能存大量数据,支持复杂查询

javascript

// 1. 打开数据库
const request = indexedDB.open('myDB', 1);

request.onupgradeneeded = (event) => {
    const db = event.target.result;
    // 创建"表"
    db.createObjectStore('products', { keyPath: 'id' });
};

// 2. 存数据
request.onsuccess = (event) => {
    const db = event.target.result;
    const transaction = db.transaction('products', 'readwrite');
    const store = transaction.objectStore('products');
    
    // 存对象
    store.add({ id: 1, name: '手机', price: 2999, stock: 100 });
};

适用场景

  • 离线邮件客户端
  • 图片/文档编辑器
  • 游戏存档

(5) Cache API 📚 - "网站备份本"

特点:存网页资源,没网也能看

javascript

// 存网页到缓存(在Service Worker中)
caches.open('v1').then(cache => {
    cache.addAll([
        '/index.html',
        '/style.css',
        '/logo.png'
    ]);
});

// 没网时从缓存读取
caches.match('/index.html').then(response => {
    if (response) {
        return response; // 有缓存,显示缓存内容
    }
    return fetch(event.request); // 没缓存,尝试网络
});

适用场景

  • PWA应用(如微博、Twitter移动版)
  • 离线阅读文章
  • 弱网环境优化

4. 简单选择指南

根据需求选:

你想存什么? 选这个 原因
登录状态、记住我 Cookie 自动带给服务器验证
主题、字体大小 LocalStorage 永久保存偏好
购物车商品 SessionStorage 关了网页就不要了
大量离线数据 IndexedDB 容量大,能查询
让网站离线能用 Cache API 专门干这个的

容量对比图:

text

容量从小到大:
Cookie (4KB) → LocalStorage (5MB) → IndexedDB (250MB+)

    🍪         📦              🗄️
  很小       中等            很大
自动发送     自己存        自己存+能查询

5. 安全注意事项 ⚠️

什么不能存?

javascript

// ❌ 绝对不要存!
localStorage.setItem('password', '123456');
localStorage.setItem('creditCard', '6225888888888888');
localStorage.setItem('token', 'jwt-secret-token');

// ✅ 可以存的
localStorage.setItem('theme', 'dark');      // 界面设置
localStorage.setItem('fontSize', '16px');   // 显示设置
localStorage.setItem('history', JSON.stringify(['搜索1', '搜索2'])); // 非敏感历史

敏感数据怎么存?

javascript

// 方案1:用HttpOnly Cookie(服务器设置)
// 响应头:Set-Cookie: auth=token123; HttpOnly; Secure
// JavaScript读不到,防XSS攻击

// 方案2:短期SessionStorage
sessionStorage.setItem('tempToken', 'short-lived-token');
// 关了网页就消失

// 方案3:加密后存
const encrypted = btoa('sensitive-data'); // Base64编码(只是简单演示)
localStorage.setItem('encryptedData', encrypted);

6. 实际例子:用户设置系统

html

<!DOCTYPE html>
<html>
<head>
    <title>用户设置</title>
</head>
<body>
    <select id="theme">
        <option value="light">浅色</option>
        <option value="dark">深色</option>
    </select>
    
    <input type="range" id="fontSize" min="12" max="24" value="16">
    
    <button onclick="saveSettings()">保存设置</button>
    <button onclick="clearSettings()">清除设置</button>

    <script>
        // 页面加载时恢复设置
        window.onload = function() {
            const theme = localStorage.getItem('theme') || 'light';
            const fontSize = localStorage.getItem('fontSize') || '16';
            
            document.getElementById('theme').value = theme;
            document.getElementById('fontSize').value = fontSize;
            
            applySettings(theme, fontSize);
        };
        
        // 保存设置
        function saveSettings() {
            const theme = document.getElementById('theme').value;
            const fontSize = document.getElementById('fontSize').value;
            
            // 存到LocalStorage
            localStorage.setItem('theme', theme);
            localStorage.setItem('fontSize', fontSize);
            
            applySettings(theme, fontSize);
            alert('设置已保存!');
        }
        
        // 应用设置
        function applySettings(theme, fontSize) {
            document.body.className = theme;
            document.body.style.fontSize = fontSize + 'px';
        }
        
        // 清除设置
        function clearSettings() {
            localStorage.removeItem('theme');
            localStorage.removeItem('fontSize');
            location.reload(); // 重新加载页面
        }
    </script>
    
    <style>
        body.light { background: white; color: black; }
        body.dark { background: #333; color: white; }
    </style>
</body>
</html>

7. 总结:一句话选择

  • Cookie:需要服务器知道的数据(如登录)
  • LocalStorage:想永久保存的数据(如主题)
  • SessionStorage:临时用用的数据(如购物车)
  • IndexedDB:数据很多很复杂时(如离线邮件)
  • Cache API:想让网站没网也能用时(如PWA)

记住:所有数据都存在你自己的电脑/手机里,不在网站服务器上。想给服务器,得主动发送(Cookie除外)。

十一、 Web Worker 是什么?

1. 官方定义 (The Law)

Web Worker 是 HTML5 标准引入的一种让脚本运行在后台线程的能力。它允许主线程(UI 线程)创建子线程,将耗时任务分配给后者,从而实现并行计算。

2. 白话翻译 (The Logic)

你可以把浏览器想象成一个 “手术室”

  • 主线程(UI 线程) :是主刀医生。他必须时刻保持专注,观察患者情况(渲染 UI)、对监护仪的点击做出反应(处理交互)。如果医生去搬运沉重的氧气瓶(计算大批量数据),手术台就会“断档”,发生医疗事故(界面卡死)。
  • Web Worker(子线程) :是巡回护士或设备工程师。他在旁边默默处理耗时工作(比如整理过去 4 小时的麻醉记录、计算药物泵注速率),处理完后通过对讲机(postMessage)告诉医生结果。这样医生(主线程)永远不会被琐事卡住。
3. 底层内幕 (The Metal)
  • 线程隔离:Worker 运行在另一个全局上下文中(DedicatedWorkerGlobalScope),与主线程完全独立。
  • 无 DOM 访问:由于不在主线程,它无法操作 DOM、无法访问 window、parent。
  • 通信机制:基于序列化拷贝(Structured Clone)或所有权转移(Transferable Objects)的通信。

进阶必杀:手术麻醉系统啥时候用?

麻醉系统中有三个典型的“夺命场景”,必须使用 Web Worker:

场景 1:高频实时波形数据的处理(如 ECG/压力波形)

痛点:麻醉监护仪上传的波形数据频率极高(如心电图 250Hz - 500Hz)。如果主线程每秒处理 500 个点并计算心率均值,同时还要绘制 Canvas 动画,界面会出现肉眼可见的掉帧。

  • Worker 方案:子线程负责接收原始二进制流,进行 滤波算法处理、基线漂移校正、峰值检测。计算出“干净”的点坐标后再传给主线程绘图。
2. 复杂药代动力学/药效学(PK/PD)模型计算

痛点:麻醉医生需要实时观察“靶控泵注(TCI)”的血药浓度预测曲线。这涉及到复杂的微分方程计算,计算量随时间轴非线性增长。

  • Worker 方案:将数学模型丢入子线程。主线程输入药物剂量,子线程实时计算未来 30 分钟的浓度走向,确保 UI 响应时间(Response Time)恒定在 16ms 以内。
3. 大容量历史病历/术中记录的离线解析与检索

痛点:大型手术可能持续十几个小时,术中采集的数据点(生命体征、给药、插管事件)可能有数万条。在生成“麻醉单 PDF”预览或进行趋势分析时,大数组的遍历和排序会直接让页面假死。

  • Worker 方案:子线程在后台处理这些大数组,执行全量搜索或统计分析。

实战代码:架构师级优化写法

我们要展示如何利用 Transferable Objects(所有权转移)来处理麻醉监测数据,避免大对象克隆带来的性能损耗。

主线程 (main.js)

// 创建子线程处理麻醉机原始数据
const dataWorker = new Worker('data-processor.js');

// 模拟从监护仪获取的高频原始数据(Uint8Array 二进制流)
const rawData = new Uint8Array(1024 * 1024); 

// 【优化点】:使用第二个参数 [rawData.buffer],实现内存所有权转移
// 这样数据不会被拷贝,而是直接“瞬移”到子线程,效率极高
dataWorker.postMessage({ buffer: rawData.buffer }, [rawData.buffer]);

dataWorker.onmessage = (e) => {
    const { heartRate, bloodPressure } = e.data;
    console.log(`主线程收到精准体征:心率 ${heartRate}, 血压 ${bloodPressure}`);
    // 更新 UI 仪表盘...
};

子线程 (data-processor.js)

self.onmessage = function(e) {
    // 1. 获取主线程传来的 Buffer
    const buffer = e.data.buffer;
    const view = new DataView(buffer);

    // 2. 执行复杂的滤波算法(模拟耗时操作)
    let result = complexMedicalAlgorithm(view);

    // 3. 将计算结果返回给医生(主线程)
    self.postMessage({
        heartRate: result.hr,
        bloodPressure: result.bp
    });
};

function complexMedicalAlgorithm(data) {
    // 这里执行微分方程、傅里叶变换等耗时逻辑
    return { hr: 75, bp: 120 };
}

性能优化与架构师思考 (The Differentiator)

在麻醉系统这种高可靠性软件中,你需要多考虑一步:

  1. 通信成本预估:Web Worker 通信本身有开销。如果任务太小(比如只是把 1+1 发过去计算),通信耗时可能大于计算耗时,得不偿失。只有处理大批量数据或超过 50ms 的逻辑才上 Worker。
  2. 线程池管理:不要无限制创建 Worker。通常建立一个 Worker Pool(线程池) ,数量保持在 navigator.hardwareConcurrency(CPU 核心数)左右。
  3. 异常兜底:如果子线程报错(如算法溢出),必须捕获 onerror。在麻醉系统中,如果子线程挂了,主线程应有备选方案(如显示最近一次缓存的数值),并在日志中记录。
  4. OffscreenCanvas (终极杀招) :在现代浏览器中,你可以把 canvas.transferControlToOffscreen() 传给 Worker。这意味着连绘图逻辑都可以不在主线程跑,彻底实现 UI 零阻塞。

面试通关词典
  • Q: Web Worker 会阻塞主线程吗?
  • A: 不会。它是真正的操作系统级线程。但要注意,如果子线程疯狂进行 I/O 或占用大量内存,可能会导致宿主进程不稳定。
  • Q: 手术室场景下,页面刷新了 Worker 会怎样?
  • A: Worker 会随之销毁。在麻醉系统中,建议配合 SharedWorkerService Worker 实现多标签页共享数据或离线状态保持,防止医生误刷页面导致监测中断。

结论:在手术麻醉系统中,Web Worker 是保障生命线数据流畅的最后一道技术屏障。

十二、浏览器渲染引擎全链路探秘

在前端圈有一句名言: “不懂渲染引擎,优化全靠撞大运。” 当你在浏览器输入 URL 到页面显示的这几百毫秒里,渲染引擎内部经历了一场极其复杂的“工业化流水线”协作。


image-20260114105500990

1、 灵魂拷问:为什么我们要懂渲染引擎?

  • 现状:大多数人只知道“HTML 转 DOM,CSS 转 CSSOM”。

  • 痛点:为什么 transform 比 left 性能好?为什么 JS 会阻塞渲染?为什么 will-change 不能乱用?

  • 本质:渲染引擎决定了 Web 应用的性能天花板

    1.1 为什么 transform 比 left 性能好?(线程与流水线视角)

    这是面试中最能拉开档次的问题。很多人的回答止步于“transform 开启了硬件加速”。

    【底层深度解构】

    • left 的地狱路径:left 是布局属性(Layout Property)。当你修改 left 时,浏览器必须执行 整个渲染流水线

      1. Layout (重排) :重新计算元素位置及其对周围元素的影响。

      2. Paint (重绘) :将受影响的区域重新转换成像素位图。

      3. Composite (合成) :将位图上传到 GPU 显示。

      • 痛点:这一切都发生在 主线程(Main Thread) 。如果主线程正在跑 JS 任务,动画就会卡顿。
    • transform 的绿色通道

      1. transform 既不触发重排,也不触发重绘。
      2. 提升层(Layer Promotion) :拥有 transform 的元素会被浏览器提升到一个独立的合成层(Compositing Layer)
      3. 独立线程:最关键的是,合成层的位移变换是在 合成线程(Compositor Thread) 处理的,完全不占用主线程
      4. 显存操作:它直接在 GPU 中操作已经生成的位图(纹理),就像移动一张已经画好的贴纸,而不需要重新画。

    【吊打面试官金句】

    “left 的性能瓶颈在于它耦合了主线程的 Layout 任务,受 JS 执行阻塞;而 transform 实现了逻辑与渲染的分离,通过合成线程在 GPU 层面完成位图变换,绕过了重排与重绘,这才是高性能动画的本质。”


    1.2 为什么 JS 会阻塞渲染?(临界资源与一致性视角)

    面试官可能会问:“为什么浏览器不能一边跑 JS 一边渲染?”

    【底层深度解构】

    • 单一主线程机制:浏览器的渲染进程中,JS 引擎和渲染引擎共用一个主线程。这是为了保证 DOM 的一致性

    • JS 的“特权”

      1. 修改权:JS 可以通过 document.write 或 appendChild 改变 DOM 结构。
      2. 查询权:JS 可以通过 getComputedStyle 查询最新的样式。
    • 浏览器的“保守策略” : 由于 JS 具备随时改变 DOM 和样式的能力,渲染引擎在执行 JS 时必须暂停所有工作。如果 JS 还没跑完,渲染引擎就开始绘制,那么绘制出来的可能是“过时”的内容。

    • CSS 的并发阻塞: 如果 JS 前面有一个 CSS 资源正在下载,JS 也会被阻塞(因为 JS 可能会访问样式,必须等 CSSOM 构建完)。这形成了一个 CSS -> JS -> Rendering 的阻塞链。

    【白话举例】 : 这就像装修时,施工队(渲染引擎) 必须等设计师(JS) 改完图纸才能动工。如果设计师还在改方案,施工队强行开工,最后拆改的成本更高。


    1.3 为什么 will-change 不能乱用?(内存与层爆炸视角)

    很多人以为加了 will-change 页面就快了,实际上滥用它会导致浏览器直接崩溃。

    【底层深度解构】

    • will-change 的本质:它是向浏览器提前索要 合成层提升(Layer Promotion) 。它告诉浏览器:“这个元素马上要动了,提前帮我把它从普通文档流里剥离出来,放到 GPU 显存里去。”

    • 副作用:层爆炸(Layer Explosion)

      1. 显存消耗(VRAM) :每个合成层都会生成独立的位图并存储在 GPU 显存中。移动端设备的显存极其有限。
      2. 层叠上下文陷阱:提升一个元素为合成层,可能会导致原本覆盖在它上面的元素也被动提升(为了保持层叠顺序),产生大量意料之外的层。
      3. 管理开销:层越多,合成线程在合并这些层时的计算量就越大,反而可能导致掉帧。

    【架构师建议】

    • 动态开关:在动画开始前(如 hover 或 mousedown)添加 will-change,动画结束立即移除。
    • 针对性使用:只给那些确实有复杂变换且引起卡顿的元素加。

    1.4 总结:性能天花板的本质

    当你理解了上述三点,你就能向面试官输出这个终极结论

    “Web 应用的性能治理,本质上是对 渲染管线同步点 的管理。

    1. 我们要利用 transform 这种属性将压力从主线程转移到合成线程
    2. 我们要通过 defer/async 或 Web Worker 减少 JS 对主线程渲染周期的霸占;
    3. 我们要通过按需层提升避免 GPU 显存溢出

    只有理解了引擎如何‘搬运像素’,我们才能真正触达 Web 性能的最优解。”


    【自测追问】 : 如果面试官接着问:“既然 transform 这么好,那为什么不把所有元素都设为合成层?” 神回复“那就像是把整本书的每一行字都单独印在一张透明胶片上。虽然你想动哪一行都很方便,但这本书的厚度(内存占用)和翻页时的校准(合成计算)会拖垮整个浏览器。”


2、 第一章:剥茧抽丝——渲染引擎是什么?

2.1. 官方定义 (The Law)

渲染引擎(Rendering Engine),也常被称为“浏览器内核”,负责取得网页的内容(HTML、XML、图像等)、整理讯息(加入 CSS 等),以及计算网页的显示方式,然后输出至显示器。

  • 常见引擎:Blink (Chrome/Edge)、WebKit (Safari)、Gecko (Firefox)。

2.2 白话翻译 (The Logic)

想象渲染引擎是一个高级装修施工队

  • HTML 是客户给的装修清单
  • CSS设计图纸
  • JS智能家居脚本
  • 渲染引擎 就是带班工头,他要把清单变成实物,还要确保开关(交互)灵敏,且墙皮(像素)不掉色。

3、 第二章:探究本质——渲染流水线 (The Pipeline)

渲染引擎的工作流程通常被称为 关键渲染路径(Critical Rendering Path)

3.1 构建对象模型 (Parsing)

  • DOM (Document Object Model) :引擎将 HTML 字节流解析为 Token,再转换为 Node,最后组成树状结构。
  • CSSOM (CSS Object Model) :解析 CSS 样式表,计算出每个节点的样式。
  • 【面试杀手锏 - Preload Scanner】 : 官方版:浏览器在解析 HTML 时,会启动一个轻量级的扫描器,提前下载后续的 JS/CSS。 架构师话术: “渲染引擎并不是死板地线性解析。Preload Scanner 解决了解析阻塞时的带宽浪费,这是现代浏览器首屏优化的核心机制。”

3.2 构建渲染树 (Render Tree)

  • 过程:将 DOM 和 CSSOM 合并。
  • 细节不可见节点(如 display: none)不会进入渲染树,但 visibility: hidden 的节点

3.3 布局 (Layout / Reflow)

  • 任务:计算每个节点在屏幕上的确切几何坐标(位置和大小)。
  • 白话:确定每个家具摆在客厅的哪个角落,占多大地方。

3.4 绘制 (Paint / Raster)

  • 任务:将计算好的节点转换为实际的像素点。涉及颜色、阴影、边框等绘制指令。

3.5 合成 (Compositing) —— 【重难点】

  • 原理:现代浏览器会将页面拆分为多个层(Layers) 。合成线程(Compositor Thread)负责将这些层合并并输出到屏幕。
  • GPU 加速:合成阶段主要在 GPU 中完成,这就是为什么 transform 动画流畅的原因——它跳过了布局和绘制,直接在 GPU 操纵层位移。

4、 第三章:进阶必杀——性能优化与底层逻辑

4.1 重排 vs 重绘 (Reflow vs Repaint)

概念 触发原因 性能开销 白话类比
重排 (Reflow) 几何属性改变(宽高、位置、DOM 增删) 极大(需重新计算整个布局流) 拆掉承重墙,重新规划户型
重绘 (Repaint) 视觉属性改变(颜色、背景色) 中等 墙皮旧了,重新刷个漆

4.2 架构师级优化:避开主线程

【专业技巧】 : 传统的动画通过修改 top/left 触发重排,由主线程计算,主线程一旦忙碌(JS 执行长任务),动画就卡顿。 优化方案:使用 transform 或 opacity。 原因:这两个属性会触发合成层提升。它们在 Compositor Thread 运行,完全不占用主线程,通过 GPU 直接渲染。

【代码详解】

// ❌ 新手版:频繁触发重排,性能差
element.style.left = '100px'; 

// ✅ 架构师版:跳过布局与绘制,直接交给合成线程
element.style.transform = 'translateX(100px)'; 
// 提示:配合 will-change: transform 提前告知引擎提升层,但不可滥用,否则会耗尽显存。

5、 第四章:现代浏览器架构 (Modern Architecture)

5.1 多进程架构

面试中如果能提到渲染进程(Renderer Process)GPU 进程 的分离,是巨大的加分项。

  • 渲染进程:每个标签页一个(沙箱环境),包含主线程、合成线程、解析线程。
  • 安全隔离:如果一个标签页崩溃,不会影响整个浏览器。

5.2 事件循环与渲染的节律

浏览器通常 16.7ms (60fps) 刷新一次。 底层逻辑:JS 代码执行 -> 微任务处理 -> RequestAnimationFrame -> 布局/绘制 -> 渲染。 如果你的 JS 执行超过 16ms,渲染引擎就会“丢帧”,用户就会感到卡顿。


6、 面试通关词典 (Interview Prep)

【吊打面试官话术】

“深入理解渲染引擎,本质上是在理解 关键渲染路径(CRP) 的资源调度。我不仅关注 DOM 的构建,更关注 合成线程(Compositor Thread) 的独立性。在高性能场景下,我会通过属性提升策略避开 Layout 和 Paint,直接利用 GPU 执行 Composite-only 动画。同时,我会监控 Long Tasks,确保主线程不会因为过载而导致渲染引擎的帧调度失效。”

【神回复追问】

  • :既然 CSS 不阻塞 DOM 解析,为什么还要建议把 CSS 放在头部?
  • “CSS 虽不阻塞 DOM 解析,但它会阻塞渲染树的构建和 JS 的执行(因为 JS 可能查询样式)。如果不把 CSS 放在头部,浏览器可能会先渲染出无样式的内容(FOUC),造成糟糕的用户体验。这属于渲染引擎的‘预加载策略’与‘渲染一致性’权衡。”

十三、 浏览器兼容性 (全链路治理:从“填坑”到“工程化闭环”)

在大多数开发者眼中,兼容性是写不完的 CSS Hack 和没完没了的 Polyfill。但在架构师眼中,兼容性是一场Web 标准的超前性宿主环境滞后性之间的博弈。

如果我们只停留在“修 Bug”层面,永远无法触及性能的天花板。


1、 知识图谱:兼容性治理的五层防御体系

首先要建立起宏观的防御模型,而不仅仅是罗列工具。

层次 防御手段 核心价值
第五层:体验策略 渐进增强 (PE) vs 优雅降级 (GD) 决定业务底线与上限
第四层:运行时拦截 特性检测 (Feature Detection) + 动态 Polyfill 解决“API 存在性”问题
第三层:渲染兼容 PostCSS + Autoprefixer + CSS Fallback 解决布局与视觉偏差
第二层:工具链转译 Babel + Browserslist + Core-js 解决语法兼容的工业化标准
第一层:基线决策 Browserslist + ROI 决策模型 唯一真相来源,控制工程成本

2、 核心解析:是什么?为什么?怎么做?

1. 现状剖析:为什么兼容性是“性能杀手”?

  • 痛点:为了兼容 1% 的 IE 用户,全量打包了庞大的 ES5 转换代码和 Polyfill,导致 99% 的现代浏览器用户多下载了 30% 的冗余包。
  • 本质:这是 “兼容性开销”对“现代性能”的霸凌

2. 特性检测 (Feature Detection) —— 【白话版】

  • 白话版:就像你进一家饭店,不要问“你是哪年哪月开业的(UA 探测)”,而是问“你们这儿能刷医保卡吗(特性检测)”。能刷就刷,不能刷就付现金(降级)。

  • 官方逻辑:不要依赖不稳定的 navigator.userAgent,而要直接判断 API 是否在 window 或 Element.prototype 上。

  • 代码详解

    // 架构师级写法:不检测浏览器,只检测能力
    if ('IntersectionObserver' in window) {
        // 只有支持该特性的浏览器才执行高性能观察逻辑
    } else {
        // 降级为监听 scroll 事件的传统方案
    }
    

3、 进阶必杀:架构师级的工程方案

1. Browserslist:全链路的“唯一真相”

  • 深度解析:很多项目在 Babel 里写一套,PostCSS 里写一套。架构师要求必须在 .browserslistrc 中统一配置。
  • 底层逻辑:它是连接“业务需求”与“编译工具”的纽带,确保语法转译和前缀补全遵循同一套基线。

2. 差异化打包 (Differential Serving) —— 【吊打点】

这是区分架构师与高级开发的关键。

  • 是什么:针对现代浏览器和旧版浏览器生成两套独立的 JS 包。

  • 为什么:现代浏览器原生支持 const/await/class,不需要转译和垫片,执行效率极高。

  • 怎么做

    <!-- 现代浏览器加载:不带垫片、不转译、代码量极小 -->
    <script type="module" src="app.modern.js"></script>
    
    <!-- 旧版浏览器加载:全量转译、带庞大 Polyfill -->
    <script nomodule src="app.legacy.js"></script>
    

3. 按需 Polyfill:动态垫片服务

  • 技术原理:利用 Polyfill.io 类似的原理,根据浏览器请求头的 UA 动态下发该环境缺失的补丁。
  • 优势:避免了在 Bundle 包中硬编码 Polyfill,将兼容性成本从“前端包体积”转移到“CDN 动态分发”。

4、 性能优化与个人思考:ROI 决策模型

在面试中,谈论兼容性一定要带上商业视角

  1. 四象限法则

    • 高流量+低成本(如 Chrome 前缀):必须做。
    • 低流量+高成本(如 IE8 兼容):坚决不做,引导用户升级或提供纯文版降级。
  2. CSS 逻辑回退(Fallback) : 利用 CSS 的解析忽略机制,实现零开销的降级。

    .container {
        display: block; /* 降级方案 */
        display: flex;  /* 现代方案:如果浏览器不认识 flex,会自动忽略上一行,保持 block */
    }
    

5、 面试通关词典 (Interview Prep)

【金句总结】

“解决兼容性不应是‘打补丁’,而应是 ‘构建治理闭环’

首先,通过 Browserslist 建立统一的环境基线; 其次,利用 PostCSSBabel 实现编译时的工业化转换; 接着,通过 特性检测 结合 差异化打包(Differential Serving) ,将兼容性开销精准限制在老旧设备上; 最后,建立 RUM(真实用户监控) ,动态分析不同环境下的白屏率,用数据驱动兼容性决策的迭代。

这种‘现代优先、向后兼容’的弹性架构,才是解决浏览器碎裂化的最优解。”

【神回复追问】

  • :如果某个新特性完全无法 Polyfill 怎么办?
  • 神回复“我会采用‘功能裁剪’策略。核心业务逻辑(如支付)走普通路径,增强型体验(如 WebGPU 动画)在不支持的环境下直接‘静默失效’。我们要兼容的是用户的使用权,而不是强制视觉像素的 100% 一致。”

十四、 跨文档通信全景解构:打破浏览器的“孤岛效应”

在现代 Web 应用中,跨文档通信本质上是解决 “多个窗口、多个标签页、或多个 Iframe 之间如何互通有无” 的问题。


1、 核心底座:为什么要通信?

  • 现状:为了安全,浏览器通过“同源策略”将每个标签页隔离在独立的“沙箱”里。
  • 痛点:用户在 A 标签页登录了,B 标签页如何实时更新头像?点击扫码登录后,主页面如何感知并跳转?
  • 本质:这是分布式 UI 状态同步的挑战。

2、 方案解构:从“暴力黑客”到“优雅总线”

跨文档通信分为两大战场:跨域通信(Cross-origin) 同源通信(Same-origin)

1. 跨域通信的“唯一真理”:postMessage

这是 W3C 定义的唯一合法跨域通信 API。

  • 底层内幕:基于结构化克隆算法(Structured Clone Algorithm) 。它不是简单的 JSON 序列化,而是能够处理循环引用、Date、Blob 等复杂对象的引擎级克隆。
  • 吊打点(安全性) :一定要提到 origin 校验。如果不校验 event.origin,就等于给 XSS 攻击开了后门。
  • 白话版:就像两个敌对国家(不同源)通信,必须通过外交部(postMessage)并在信封上盖好国家公章(Origin),对方确认公章后才开信。

2. 同源通信的“现代班车”:BroadcastChannel

  • 是什么:专门为同源页面设计的“发布/订阅”总线。
  • 为什么吊:它比 postMessage 更简洁,不需要获取 window 对象的引用,只要频道名称(Channel Name)一致,所有页面都能收到。
  • 架构价值:非常适合做多页面的状态同步(如全站静音、主题切换)。

3. 同源通信的“隐形大脑”:SharedWorker

  • 是什么:多个同源标签页共享同一个后台线程。
  • 深度解构:它是所有标签页的“中央控制器”。数据存放在 SharedWorker 的内存里,所有页面通过 port 连进来取。
  • 高级感:这能解决重复请求问题。多个页面都要拿配置数据,只需一个 Worker 去请求,然后分发给所有页面。

4. 同源通信的“被动监听”:StorageEvent

  • 做法:监听 window.addEventListener('storage', ...)。
  • 细节:只有当 localStorage 的值被改变且是在另一个窗口改变时,才会触发。

3、 进阶必杀:架构师级的选型与思考

1. 通信成本与性能

  • 痛点:频繁通信会导致主线程卡顿。
  • 优化:如果是传输超大数据(如图片像素、大数据量表格),不要直接发,要用 Transferable Objects(可转移对象)
  • 本质:直接转移内存控制权,零拷贝,性能炸裂。

2. 安全性决策 (Security)

  • 永远不要信任来源:任何通信进来的数据都要做严格的 Schema 校验。
  • 敏感信息:永远不要通过 postMessage 传输密码或 Token。

4、 面试通关词典 (Interview Prep)

【吊打话术总结】

“跨文档通信的选型取决于 **‘信任边界’**和 ‘实时性要求’

  1. 如果涉及到跨域(如嵌入第三方 Iframe) ,postMessage 是唯一的安全选择,但必须严格遵守 Origin Check 闭环;
  2. 如果是同源多页同步,我会优先考虑 BroadcastChannel,因为它提供了最纯粹的观察者模式实现;
  3. 如果需要更复杂的中央状态管理或减少网络冗余,我会引入 SharedWorker 作为所有标签页的‘脑干’;
  4. 在处理极端性能要求时,我会利用 Transferable Objects 绕过序列化开销,实现内存级的快速周转。

这种‘分场景治理’的思想,才是构建健壮多页应用的基础。”


🎨 技术对比一览表(记这个就行)

技术 范围 特点 架构师评价
postMessage 跨域/同源 需持有窗口引用 全能选手,安全第一
BroadcastChannel 同源 发布订阅,无需引用 多标签页同步首选
SharedWorker 同源 中央集权,共享状态 重型架构,减少冗余
localStorage 同源 事件驱动 兼容性老旧方案
Service Worker 同源 拦截网络,中转数据 离线架构下的副产品

面试官追问: “如果我关掉了主页面,SharedWorker 还会存在吗?” 神回复: “只要还有一个关联的标签页存活,SharedWorker 就不会销毁。它是真正的‘最后一个人关灯’模式。”

十五、如何实现拖放功能?

1、 核心解构:实现三步走

1. 赋予身份:谁能拖?

在 HTML 标签上加个“通行证”:draggable="true"

2. 托运货物:带什么走?

dragstart 事件里,把数据塞进浏览器的“快递盒”—— dataTransfer

codeJavaScript

source.ondragstart = (e) => {
    e.dataTransfer.setData('text/plain', '这是我的业务ID'); // 贴标签
};
3. 接收安检:准不准落?(最关键的一步)

在目标区域监听 dragover,并执行 e.preventDefault()

  • 吊打点:为什么要执行 preventDefault?因为浏览器的默认脾气是“拒绝在任何地方丢垃圾”。你拦截了默认行为,就是告诉浏览器:“这里是合法的投放区”。

2、 实战全流程(极简逻辑)

  1. Source(源) : 监听 dragstart,存入 ID。

  2. Target(目标) :

    • 监听 dragover,阻止默认行为(允许落下)。
    • 监听 drop,取出 ID,执行业务逻辑(比如移动 DOM 或调接口)。

3、 架构师级的深度“加分项”

如果你能随口提下面这几点,面试官会觉得你很有实操深度:

1. 性能优化:为什么原生 DnD 比较“丝滑”?
  • 内核机制:原生的拖拽“鬼影”(Ghost Image)是由浏览器在独立进程/合成线程中生成的。它不占用主线程的 JS 逻辑,所以即使页面很卡,拖拽的那个虚影依然是流畅的。
  • 对比:如果你用 mousedown 模拟拖拽,所有位移计算都在主线程,页面一卡,拖拽就掉帧。
2. 安全保护模式(DataTransfer Protected Mode)
  • 冷知识:在 dragover 事件中,你是读不到 getData() 里的具体内容的。
  • 原因:这是浏览器的安全隐私保护。只有在最终的 drop 瞬间,数据才会对目标开放。防止你拖着一段密码经过一个恶意广告位时,数据被偷走。
3. 跨文档/跨系统(核心优势)
  • 场景:原生 DnD 最强的地方在于跨界。你可以把一张桌面的图片直接拖进浏览器,或者把 A 网页的文本拖进 B 网页。
  • 实现:只需要检查 e.dataTransfer.files 是否存在,就能直接对接 File API 实现文件上传。

4、 面试官反问话术

问: “如果我想让拖拽的虚影更好看,或者换个形状怎么办?” 答: “我会使用 e.dataTransfer.setDragImage(element, x, y) 。它可以指定任何一个 DOM 节点(甚至是隐藏的)作为拖拽时的视觉反馈,这比手动写定位跟随要高效得多,而且利用了 GPU 加速。”

总结: 实现拖放就是:draggable 启身份,dataTransfer 传数据,preventDefault 准降落。 剩下的样式和逻辑,不过是基于这个协议的填空题。

十六、什么是同源策略?

同源策略(Same-Origin Policy, SOP) 不是一个 Bug 的解决方案,它是浏览器最核心、最基本的安全隔离机制。如果没有它,互联网将处于完全的“丛林状态”。


1、 剥茧抽丝:什么是同源策略? (The Essence)

1. 官方定义 (The Law)

同源必须同时满足三个条件:

  1. 协议相同 (Protocol, 如 http/https)
  2. 域名相同 (Domain, 如 example.com)
  3. 端口相同 (Port, 如 80/443)

2. 架构师视角的白话翻译 (The Logic)

同源策略本质上是定义了 “信任边界”

  • 白话版:就像你住在酒店里,你的房卡只能开你自己的房门(同源)。如果没有这个策略,意味着隔壁房间的人(恶意网站)可以随时走进你的房间,翻你的行李,甚至在你的床头柜里放监听器。
  • 本质:它是浏览器为了防止不同来源的文档相互干扰而建立的一套“防撬锁”机制。

2、 核心限制:它到底拦住了什么? (Restrictions)

同源策略主要在三个层面建立防火墙:

  1. DOM 层面:不能访问非同源页面的 DOM。

    • 痛点:如果没有它,你在 A 网站(钓鱼站)里嵌套一个 B 网站(银行)的 Iframe,A 就可以通过 JS 读取你输入银行页面的密码。
  2. 数据交互层面:AJAX / Fetch 请求受限。

    • 细节:默认情况下,无法读取跨域请求返回的数据。
  3. 存储层面:无法读取非同源的 Cookie、LocalStorage、IndexedDB。

    • 痛点:这防止了恶意网站盗取你的 Session Token。

3、 探究本质:浏览器是如何“拦截”的? (Deep Dive)

这是最体现深度的地方,请记住这个底层逻辑:

  • 面试官坑题:跨域请求发出了吗?

  • 神回复请求发出了,服务器也响应了,但浏览器把结果“扣押”了。

  • 底层内幕

    1. 浏览器在发起 AJAX 后,会正常接收 HTTP 响应。
    2. 渲染引擎在处理响应内容前,会先检查 CORS 响应头 或源信息。
    3. 如果发现不符合同源策略且没有正确的 CORS 授权,引擎会直接抛出错误并拒绝将数据交给 JS。
    4. 注意:同源策略是浏览器内核行为,不是网络传输行为。

4、 关键转折:为什么脚本和图片可以跨域? (The Loophole)

你一定见过

  • 架构师解析:同源策略允许 “跨域嵌入(Cross-origin embedding)” ,但限制了 “跨域读取(Cross-origin reading)”
  • 原因:Web 的本质是“链接”。如果连图片和 JS 都不给引用,互联网就退化成一个个孤岛了。
  • 安全风险(CSRF 根源) :正因为图片和表单可以跨域发送请求,才导致了 CSRF(跨站请求伪造) 。攻击者虽然读不到你的数据,但他可以借用你的 Cookie 发起一次“点击”。

5、 现代治理方案:如何优雅地“打破”同源?

在实际业务中,前后端分离必须跨域,我们有这套“武器库”:

  1. CORS (跨域资源共享) :官方钦定的标准。通过服务器返回 Access-Control-Allow-Origin 头,明确告诉浏览器:“这个邻居是我的朋友,让他进来”。
  2. Proxy (代理) :通过 Webpack 或 Nginx 转发。浏览器认为是在访问同源服务器,实际上服务器在后台帮你偷偷取了数据(服务器之间没有同源策略)。
  3. postMessage:用于不同窗口/Iframe 之间的跨域通信。

6、 面试通关词典 (Interview Prep)

【吊打话术总结】

“同源策略是 Web 安全的底座。它通过对 协议、域名、端口 的强匹配,在浏览器内部建立了一套严格的物理隔离机制。

深入底层来看,同源策略并不是阻止请求的发出,而是通过浏览器引擎在 数据回流阶段 的拦截,防止了非授信来源读取敏感数据。

它的核心哲学是‘限制读取而非限制嵌入’。这也导致了 CSRF 等安全风险。在现代微服务架构中,我会通过 CORS 的精细化配置或 Nginx 反向代理 来平衡安全性与灵活性,同时利用 HttpOnly Cookie 进一步加固同源边界以外的安全。”


【神回复追问】

  • :既然有同源策略,为什么还需要 CSRF Token?
  • 神回复“因为同源策略只能防止‘读’,不能完全防止‘写’(比如表单提交)。攻击者不需要读到你的响应结果,他只要让你的浏览器发出一笔转账请求并带上你的 Cookie 就够了。所以 CSRF Token 是为了验证请求的‘自愿性’,它是对同源策略在防御‘写操作’上的有力补充。”

十七、XSS和CRSF

1、 XSS (跨站脚本攻击) :评论区里的“内鬼”

业务场景: 你在开发一个电商网站的商品评论功能

1. 攻击过程(白话版):
  • 黑客的操作:黑客在评论框里不写“好评”,而是写了一段代码:

  • 网站的失误:你的后端没检查,直接把这段话存到了数据库。

  • 受害者的遭遇:普通用户张三打开这个商品页面想看评价。浏览器下载了这条评论,发现这是一段 了。

  • 结果:张三的登录 Cookie 瞬间被发到了黑客的服务器,黑客拿着 Cookie 就能直接登录张三的账号。

2. 架构师深挖(为什么会成功?):
  • 本质:浏览器分不清哪些代码是开发者写的,哪些是用户写的。它把用户输入的“剧毒脚本”当成了正常的业务逻辑去执行。
3. 吊打级解决方案:
  • 方案 A(最稳)HttpOnly。给 Cookie 加这个属性,JS 就读不到它了,黑客就算成功运行了代码也拿不走身份令牌。
  • 方案 B(标准)输入脱敏/输出转义。把 < 变成 <。这样脚本就不会被执行,而是像普通文字一样显示出来。

2、 CSRF (跨站请求伪造) :诱导点击的“远程遥控”

业务场景: 你在开发银行的转账功能。转账接口是:bank.com/transfer?to…

1. 攻击过程(白话版):
  • 前提:受害者张三刚刚登录了银行网站,没退出,浏览器里存着银行的登录 Cookie。
  • 黑客的操作:黑客发了一封邮件给张三,标题是“恭喜中奖,点击领钱”,诱导张三点开一个网页 evil.com
  • 内幕:这个恶意网页里隐藏了一个看不见的图片:
  • 结果:张三点开网页的瞬间,浏览器尝试加载图片。因为它发现地址是 bank.com 的,于是自动带上了张三的银行 Cookie。银行服务器一看:请求合法,Cookie 正确,确认是张三本人,于是划走了 1 万块。
2. 架构师深挖(为什么会成功?):
  • 本质“傻瓜式”的 Cookie 携带机制。浏览器在发请求时,只要地址匹配,就会自动带上该域下的 Cookie,它根本不管这个请求是你在银行页面点的,还是在黑客页面点的。
3. 吊打级解决方案:
  • 方案 A(现代)SameSite 属性。设置 Cookie 为 SameSite=Lax。这样从黑客网站发起的跨站请求,浏览器就不会自动带上 Cookie 了。
  • 方案 B(经典)CSRF Token。每次转账时,页面必须带一个随机生成的 Token。黑客可以借用你的 Cookie,但他拿不到你页面里的 Token(受同源策略保护),请求就会失败。

3、 总结:一张表看清业务差异

维度 XSS (内鬼) CSRF (遥控)
攻击载体 恶意脚本(在你的页面里跑) 恶意链接/请求(在别的页面发起)
黑客是否需要拿到 Cookie (通过脚本偷走) (不需要拿到,直接借用)
攻击发生的地点 你的网站内部 你的网站外部
打个比方 坏人混进你的公司,偷走了你的钥匙 坏人趁你在家,伪造你的签名去银行取钱

💡 吊打面试官的总结话术(建议背诵):

“在实际业务中,防范 XSS 的核心是 ‘隔离与不信任’。我会强制开启 CSP (内容安全策略) 限制脚本来源,并对所有 Cookie 开启 HttpOnly,从源头切断脚本窃取敏感信息的可能。

而防范 CSRF 的核心是 ‘来源确认’。因为跨域请求会自动携带 Cookie,我们不能仅依赖 Cookie 鉴权。我会引入 SameSite 属性来限制第三方 Cookie 传递,并配合 双重 Cookie 校验或自定义 Header Token。因为黑客虽然能伪造请求,但他受制于同源策略(SOP),无法读取我们页面内的私密 Token,从而实现逻辑闭环。”

十八、性能优化之HTML篇

当面试官问"HTML5对性能优化有什么帮助?"

黄金回答框架

"HTML5不是简单的标签更新,而是一整套性能优化原生方案

第一层:资源加载优化

  • loading="lazy":原生懒加载,省掉所有懒加载JS库
  • preload/prefetch:资源优先级管理,首屏提速30%

第二层:渲染优化

  • 语义化标签:浏览器内置渲染优化
  • 响应式图片:srcset<picture>,节省50%图片流量

第三层:计算优化

  • Web Workers:复杂计算移出主线程
  • Service Worker:离线缓存,重复访问秒开

第四层:现代API

  • Intersection Observer:替代scroll监听,性能提升100倍
  • Resize/Mutation Observer:高效监听DOM变化

总的来说,HTML5让很多需要JS实现的优化变成了一行HTML属性,这是质的飞跃。"

展现深度的追问回答

追问:"那具体怎么选择用哪个优化方案呢?"

回答

"我遵循性能优化金字塔

  1. 最底层(必须做) :语义化标签 + 懒加载 + 资源预加载
  2. 中间层(应该做) :响应式图片 + Service Worker缓存
  3. 顶层(高级优化) :Web Workers + 现代Observer API

具体执行时,我先测量再优化

  • Lighthouse跑分,看具体瓶颈
  • WebPageTest分析加载瀑布图
  • 真实用户监控(RUM)看实际情况

比如发现LCP(最大内容绘制)慢,就优先用preloadloading=lazy;发现CPU占用高,就考虑Web Workers。"


** 实战性能数据对比**

优化项 优化前 优化后 性能提升
图片懒加载(JS实现) 首屏2.5s 首屏1.8s 28%
图片懒加载(原生) 首屏1.8s 首屏1.5s 17%
资源预加载 FCP 1.2s FCP 0.8s 33%
Service Worker缓存 重复访问2s 重复访问0.3s 85%
Web Workers(计算) UI卡顿300ms UI流畅0ms 100%

一句话总结

"HTML5性能优化的核心思想:把性能优化从'JS补救'变成'HTML原生',用一行属性替代一堆代码,让浏览器原生能力为我们工作。"

记住这个口诀

  • 加载lazy延迟,preload优先
  • 渲染:语义标签,响应图片
  • 计算:Worker分担,主线程轻松
  • 缓存:Service Worker,离线能用

面试官:手写一个Promise.all

Promise.all是javascript中处理并发请求的核心方法。面试中经常会被要求手写实现,以考察对Promise状态机、异步处理以及数组遍历的掌握。

1. Promise.all的核心逻辑

在动手写之前,先明确它的四个特性:

  1. 输入:接收一个可迭代对象(通常是数组)

  2. 返回:返回一个新的Promise

  3. 成功条件:只有当数组中所有的Promise都成功时,新Promise才成功,并返回一个按顺序排列的结果数组

  4. 失败条件: 只要有一个Promise失败,新Promise立即失败(Fail-fase),并返回第一个失败的错误

2. 手写代码实现

    /**
 * 实现Promise.all
 * 
 * @param promises 一个包含多个Promise实例的数组
 * @returns 一个新的Promise实例,只有当所有传入的Promise实例都成功时才会成功,否则会在第一个失败的Promise实例时失败
 */ 


function promissAll<T>(promises: Promise<T>[]): Promise<T[]> {
    // 1. 返回一个新的Promise实例
    return new Promise<T[]>((resolve, reject) => {
        // 判断参数是否为可迭代对象
        if (!promises || typeof promises[Symbol.iterator] !== 'function') {
            return reject(new TypeError('Argument is not iterable'));
        }
        const results: T[] = []; // 用于存储每个Promise的结果
        let completedCount = 0; // 用于跟踪已完成的Promise数量
        const promiseArray = Array.from(promises); // 将传入的参数转换为数组
        const total = promiseArray.length; // 获取Promise的总数量

        // 2. 如果传入的数组为空,立即resolve一个空数组
        if (total === 0) {
            resolve(results);
            return;
        }
        // 3. 遍历每个Promise实例
        promiseArray.forEach((promise, index) => {
            // 使用Promise.resolve确保即使传入的不是Promise实例也能正确处理
            Promise.resolve(promise).then((value) => {
                results[index] = value; // 存储当前Promise的结果
                completedCount++;
                // 如果所有Promise都已完成,resolve最终结果数组
                if (completedCount === total) {
                    resolve(results);
                }
            })
        }, (error) => {
            // 4. 如果有任何一个Promise实例失败,立即reject
            reject(error);
        })
    })

}

3. 实现细节解析

Q1: 为什么不直接result.push(value)?

  • 原因: Promise是异步的,执行完成的时间不确定。如果使用push,返回的结果顺序会乱,必须通过result[index]确保结果和原数组的索引一一对应。 Q2: 为什么用count计数而不是判断result.length?
  • 原因:在javascript数组中,如果你先复制了索引为2的值result[2] = 'a', 此时result.length 会直接变成3,但索引0和1可能还没完成。所以必须使用独立的计数器。

Q3: Promise.resolve(promise)的作用?

  • 请在评论区回复

Q4: Fail-fase(快速失败)机制

  • 一旦其中一个Promise触发了reject,新的Promise的状态就会变成rejected。由于Promise的状态只能改变一次,后续Promise成功的调用会被忽略

4. 测试用例

const p1 = Promise.resolve(1);
const p2 = new Promise((resolve) => setTimeout(() => resolve(2), 1000));
const p3 = 3; // 普通值

promissAll([p1, p2, p3])
  .then(res => console.log('成功:', res)) // 1秒后输出: 成功: [1, 2, 3]
  .catch(err => console.log('失败:', err));

const p4 = Promise.reject('报错了');
promissAll([p1, p4, p2])
  .then(res => console.log(res))
  .catch(err => console.log('失败:', err)); // 立即输出: 失败: 报错了

5. 总结

手写 Promise.all 的口诀:返回一个新 Promise,遍历数组看结果,索引对应存数据,全部成功才 resolve,一个失败就 reject。

细心的老铁肯定也看到了,Q3没有输出答案,欢迎各位大牛在评论区留言

【LeetCode 刷题系列 | 第 1 篇】前端老司机拆解接雨水问题,从暴力到最优解💦

🌧️ 前言

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

欢迎来到我的 LeetCode 刷题系列专栏~ 作为一名深耕前端多年的老司机,我深知算法能力对前端工程师的重要性 —— 它不仅能帮我们在面试中脱颖而出,更能提升日常业务代码的逻辑严谨性和性能优化能力。

今天咱们要攻克的是 LeetCode 中的经典 hard 题「接雨水」,这道题堪称 “面试高频钉子户”,考察的核心是对数组遍历和边界判断的理解。

很多同学一开始会被它唬住,但只要咱们从基础思路慢慢拆解,再逐步优化,就能轻松拿捏!话不多说,咱们直奔主题~

一、LeetCode 接雨水题目详情

1. 题目描述

给定 n 个非负整数表示每个宽度为 1 的柱子的高度图,计算按此排列的柱子,下雨之后能接多少雨水。

题目链接42. 接雨水 - 力扣(LeetCode)

2. 示例演示

image.png
  • 输入:height = [0,1,0,2,1,0,1,3,2,1,2,1]
  • 输出:6
  • 解释:如题目中的高度图所示,下雨后能接住 6 单位的雨水。
  • 输入:height = [4,2,0,3,2,5]
  • 输出:9

3. 难度级别

🔴 困难:这道题之所以被归为困难,是因为它需要突破常规的遍历思维,从 “局部最优” 推导 “全局最优”。但只要掌握了核心逻辑,它其实是一道 “纸老虎” 题~

二、解题思路大剖析

1. 暴力解法:直捣黄龙的基础思路

暴力解法的核心逻辑很朴素:逐个计算每个柱子能接的雨水量,最后求和。而一个柱子能接多少水,完全取决于它左右两侧的 “最高屏障”—— 也就是左右两侧最高柱子中较矮的那一个,用这个较矮值减去当前柱子的高度,就是该位置能接的水量(若结果为负,则接 0 水)。

核心步骤拆解:

  1. 遍历数组中的每一个柱子(索引从 0 到 n-1);
  1. 对当前柱子 i,向左遍历所有柱子,找到左侧的最高高度leftMax
  1. 对当前柱子 i,向右遍历所有柱子,找到右侧的最高高度 rightMax
  1. 计算当前柱子的接水量:Math.max(0, Math.min(leftMax, rightMax) - height[i]);
  1. 累加所有柱子的接水量,得到总水量。

分步拆解演示(以输入 [0,1,0,2,1,0,1,3,2,1,2,1] 为例):

image.png

咱们逐个扒开每个位置的接水逻辑,从索引 0 开始:

  • 索引 0:左侧没有柱子,leftMax=0;右侧最高柱子高度是 3。min(0,3)=0,接水量 0-0=0;
  • 索引 1:左侧最高是 0,右侧最高是 3。min(0,3)=0,接水量 0-1=-1,取 0;
  • 索引 2:左侧遍历 [0,1],最高是 1;右侧遍历 [2,1,0,1,3,2,1,2,1],最高是 3。min(1,3)=1,接水量 1-0=1;
  • 索引 3:左侧最高是 1,右侧最高是 3。min(1,3)=1,接水量 1-2=-1,取 0;
  • 索引 4:左侧最高是 2,右侧最高是 3。min(2,3)=2,接水量 2-1=1;
  • 索引 5:左侧最高是 2,右侧最高是 3。min(2,3)=2,接水量 2-0=2;
  • 索引 6:左侧最高是 2,右侧最高是 3。min(2,3)=2,接水量 2-1=1;
  • 索引 7:左侧最高是 2,右侧最高是 2。min(2,2)=2,接水量 2-2=0;
  • 索引 8:左侧最高是 3,右侧最高是 2。min(3,2)=2,接水量 2-2=0;
  • 索引 9:左侧最高是 3,右侧最高是 2。min(3,2)=2,接水量 2-1=1;
  • 索引 10:左侧最高是 3,右侧最高是 1。min(3,1)=1,接水量 1-2=-1,取 0;
  • 索引 11:右侧没有柱子,接水量 0;

把这些有效接水量相加:1+1+2+1+1=6,和示例输出一致!

JavaScript 代码实现(暴力解法):

/**
 * @param {number[]} height
 * @return {number}
 */
var trap = function(height) {
    const n = height.length;
    let total = 0; // 总接水量
    // 遍历每个柱子(除了首尾也可以,但不影响结果,代码更简洁)
    for (let i = 0; i < n; i++) {
        let leftMax = 0; // 左侧最高柱子高度
        let rightMax = 0; // 右侧最高柱子高度
        // 向左遍历,找左侧最高
        for (let j = i; j >= 0; j--) {
            leftMax = Math.max(leftMax, height[j]);
        }
        // 向右遍历,找右侧最高
        for (let j = i; j < n; j++) {
            rightMax = Math.max(rightMax, height[j]);
        }
        // 计算当前柱子的接水量,累加到总水量
        total += Math.min(leftMax, rightMax) - height[i];
    }
    return total;
};
// 测试用例
console.log(trap([0,1,0,2,1,0,1,3,2,1,2,1])); // 输出6
console.log(trap([4,2,0,3,2,5])); // 输出9

暴力解法的优缺点:

  • 优点:思路简单直观,容易理解,适合作为入门思路;
  • 缺点:时间复杂度极高,为 O (n²)(每个柱子都要左右遍历一次),当 n 较大时(比如 10^4)会直接超时,不适合实际面试场景。

2. 双指针解法:空间优化的最优思路

暴力解法的问题在于 “重复遍历”,双指针的核心是利用左右两侧的最大值关系,在一次遍历中完成计算,把时间复杂度降到 O (n),空间复杂度优化到 O (1)。

核心原理:

  • 定义左右指针 left(初始 0)和 right(初始 n-1);
  • 定义 leftMax(左侧已遍历的最高高度)和 rightMax(右侧已遍历的最高高度);
  • 当 height[left] <= height[right] 时,左侧的最大值 leftMax 决定了当前 left 位置的接水量(因为右侧有更高的柱子兜底);
  • 反之,右侧的最大值 rightMax 决定当前 right 位置的接水量;
  • 遍历过程中不断更新 leftMax、rightMax 和总水量,直到指针相遇。

JavaScript 代码实现(双指针解法):

/**
 * @param {number[]} height
 * @return {number}
 */
var trap = function(height) {
    const n = height.length;
    if (n < 3) return 0; // 少于3个柱子无法接水
    let left = 0, right = n - 1;
    let leftMax = 0, rightMax = 0;
    let total = 0;
    while (left < right) {
        // 左侧柱子更矮,以leftMax为基准
        if (height[left] <= height[right]) {
            if (height[left] >= leftMax) {
                leftMax = height[left]; // 更新左侧最高
            } else {
                total += leftMax - height[left]; // 计算当前位置接水量
            }
            left++; // 左指针右移
        } else {
            // 右侧柱子更矮,以rightMax为基准
            if (height[right] >= rightMax) {
                rightMax = height[right]; // 更新右侧最高
            } else {
                total += rightMax - height[right]; // 计算当前位置接水量
            }
            right--; // 右指针左移
        }
    }
    return total;
};
// 测试用例
console.log(trap([0,1,0,2,1,0,1,3,2,1,2,1])); // 输出6
console.log(trap([4,2,0,3,2,5])); // 输出9

三、复杂度分析

1. 时间复杂度

  • 暴力解法:O (n²),双层循环导致重复遍历;
  • 双指针解法:O (n),一次遍历完成所有计算;

2. 空间复杂度

  • 暴力解法:O (1),仅使用常数级额外空间;
  • 双指针解法:O (1),同样仅使用常数级额外空间;

总结

好啦,今天的接雨水问题就讲到这里!相信大家已经对这道题的各种解法了如指掌。如果你有更优的思路,或者在刷题过程中遇到了疑问,欢迎在评论区留言讨论~

下一篇专栏,咱们将攻克另一道前端面试高频题,猜猜是什么?关注我,刷题不迷路!咱们下期再见~ 👋

【面试必问】手撕 LeetCode “三数之和”:双指针+去重,这一篇图解给你讲透!

前言:为什么你总倒在“三数之和”?

太多面试者在 LeetCode 第1题 “两数之和”  上重拳出击,用 HashMap 秒杀全场;然而一旦题目变成 第15题 “三数之和” ,由于无法直接套用 Hash 策略,很多人就开始支支吾吾,最后写出了一个 

O(n3)

 的暴力三层循环。

兄弟们,面试写

O(n3)

,基本上就是“回家等通知”的节奏了,还能打包一份"凉面"。  

其实,这道题是考察  “排序 + 双指针”  的经典范例。它的难点不在于找数字,而在于 如何优雅地去重。今天,就带你一步步拆解这道大厂必考题,保证你下次遇到能手撕得明明白白!


一、核心思路:降维打击(排序 + 双指针)

解决“三数之和”的核心在于将 三维问题降低到二维

如果我们固定其中一个数字(假设为 nums[i]),那么问题就变成了:在剩下的数组中,找到两个数 left 和 right,使得 nums[left] + nums[right] = -nums[i]

看!这不就变回我们熟悉的“两数之和”了吗?

但是,为了让双指针能跑起来,我们需要一个前提:数组必须是有序的

1. 为什么要排序?

这就要我们深刻理解 sort 的意义:

JavaScript

// a - b < 0  => a 在前 b 在后 (升序)
nums.sort((a, b) => a - b);

排序有两个巨大的好处:

  1. 单调性:数组有序后,如果三数之和偏大,我们只能通过左移右指针来减小总和;反之亦然。这是双指针能生效的物理基础。
  2. 方便去重:重复的元素会挨在一起,我们只需要判断 nums[i] === nums[i-1] 就能轻松跳过重复项。

2. 双指针布局

  1. 一层 for 循环,索引 i 从 0 到 length-2,这是我们的固定桩
  2. left 指针指向 i + 1(桩子的下一位)。
  3. right 指针指向 length - 1(数组末尾)。

屏幕截图 2026-01-18 210516.png


二、动图级流程解析:指针怎么动?

一旦 i 固定了,left 和 right 就开始向中间靠拢。在这个过程中,我们计算 sum = nums[i] + nums[left] + nums[right]。

这里有三种情况,逻辑非常清晰:

  1. sum > 0(和太大了)

    • 原因:数组是升序的,右边的数太大。
    • 动作:right--(右指针左移,找个小点的数)。
  2. sum < 0(和太小了)

    • 原因:左边的数太小。
    • 动作:left++ (左指针右移,找个大点的数)。
  3. sum == 0(中奖了!)

    • 动作:把 [nums[i], nums[left], nums[right]] 加入结果集 res。
    • 关键点:不仅要记录,还要同时收缩 left++ 和 right--,继续寻找下一组可能的解。

屏幕截图 2026-01-18 210923.png


三、地狱级细节:如何优雅地去重?💀

这道题 80% 的挂科率都出在去重上。题目要求结果集中不能包含重复的三元组(例如不能出现两个 [-1, 0, 1])。

我们在两个维度进行去重:

1. 外层循环去重(固定桩去重)

代码:

JavaScript

if (i > 0 && nums[i] == nums[i-1]) {
    continue;
}

面试官追问:  为什么是 nums[i] == nums[i-1] 而不是 nums[i] == nums[i+1]?

解析:

  • 如果是 nums[i] == nums[i+1],对于数组 [-1, -1, 2],当 i 指向第一个 -1 时,你就把它跳过了。那你就会漏掉 [-1, -1, 2] 这个有效解(因为这两个 -1 是不同位置的,可以共存)。
  • 我们用 nums[i] == nums[i-1] 的意思是: “如果当前的数字和上一个数字一样,说明上一个数字已经把所有可能的组合都找过了,我就不用再找一遍了”

2. 内层双指针去重

找到一个答案后,还没完!left 和 right 移动后的新位置可能还是和刚才一样的数字。
代码:

JavaScript

while(left < right && nums[left] == nums[left-1]) { left++; }
while(left < right && nums[right] == nums[right+1]) { right--; }

这步操作必须在 res.push 并且常规移动 left++/right-- 之后进行,确保彻底跳过重复段。


四、完整代码展示 (可以直接背诵版)

优化版本,加上了详细的注释:

JavaScript

function threeSum(nums) {
    const res = [];
    
    // 1. 必须先排序!这是双指针生效的前提
    // sort 是 JS 内置排序,a-b < 0 表示升序
    nums.sort((a, b) => a - b);
    
    const len = nums.length;
    
    // 2. 遍历每一个数字作为“固定桩” i
    for (let i = 0; i < len - 2; i++) {
        
        // 【核心去重1】:跳过重复的起点
        // 注意是 i > 0 且和 i-1 比,不是和 i+1 比
        if (i > 0 && nums[i] === nums[i-1]) {
            continue;
        }
        
        let left = i + 1;
        let right = len - 1;
        
        while (left < right) {
            const sum = nums[i] + nums[left] + nums[right];
            
            if (sum === 0) {
                // 找到一组解
                res.push([nums[i], nums[left], nums[right]]);
                
                // 无论如何都要移动指针
                left++;
                right--;
                
                // 【核心去重2】:跳过重复的 left
                while (left < right && nums[left] === nums[left-1]) {
                    left++;
                }
                
                // 【核心去重3】:跳过重复的 right
                while (left < right && nums[right] === nums[right+1]) {
                    right--;
                }
                
            } else if (sum < 0) {
                // 和太小,左指针右移让和变大
                left++;
            } else {
                // 和太大,右指针左移让和变小
                right--;
            }
        }
    }
    return res;
}

五、复杂度分析

  • 时间复杂度

    O(n2)
    
    • 数组排序通常是快排,复杂度 

      O(nlog⁡n)
      

    • 双指针遍历过程:外层循环 

      O(n)
      

      ,内层双指针 

      O(n)
      

      ,乘积是 

      O(n2)
      

    • 总体:

      O(n2)
      

      ,远优于暴力的 

      O(n3)
      

  • 空间复杂度

    O(1)
    

     或 

    O(log⁡n)
    
    • 如果你不计算存储结果的 res 数组,额外的空间主要是排序算法栈的空间(取决于语言底层实现),通常认为是 

      O(log⁡n)
      

       或 

      O(1)
      


六、总结

做这道题,心里要默念这句四步口诀

  1. 一排序:无序没法玩,sort 走在前。
  2. 二定桩:for 循环定 i,去重要判前(i-1)。
  3. 三双指:left、right 两头堵,大了左移小右顾。
  4. 四去重:找到答案别停步,while 循环跳重复。

学会了吗?别光看,赶紧打开编辑器 手撕 一遍吧!觉得有用的兄弟,点个赞再走呗!

2026前端面试题及答案

2026前端面试题及答案

HTML/CSS 部分

1. 什么是盒模型?标准盒模型和IE盒模型的区别是什么?

答案: 盒模型是CSS中用于布局的基本概念,每个元素都被表示为一个矩形盒子,由内容(content)、内边距(padding)、边框(border)和外边距(margin)组成。

区别:

  • 标准盒模型(W3C盒子模型)widthheight只包含内容(content)
  • IE盒模型(怪异模式盒子模型)widthheight包含内容(content)、内边距(padding)和边框(border)

可以通过box-sizing属性切换:

/* 标准盒模型 */
box-sizing: content-box;

/* IE盒模型 */
box-sizing: border-box;

2. CSS选择器优先级如何计算?

答案: CSS选择器优先级从高到低:

  1. !important
  2. 内联样式(style="")
  3. ID选择器(#id)
  4. 类选择器(.class)、属性选择器([type="text"])、伪类(:hover)
  5. 元素选择器(div)、伪元素(::before)
  6. 通配符(*)、关系选择器(>, +, ~)

计算规则:

  • ID选择器:100
  • 类/属性/伪类:10
  • 元素/伪元素:1
  • 相加比较,值大的优先级高

3. BFC是什么?如何创建BFC?

答案: BFC(Block Formatting Context)块级格式化上下文,是Web页面的可视化CSS渲染的一部分,是一个独立的渲染区域。

创建BFC的方法:

  • float不为none
  • position为absolute或fixed
  • display为inline-block、table-cell、table-caption、flex、inline-flex
  • overflow不为visible

BFC特性:

  1. 内部盒子垂直排列
  2. margin会重叠在同一个BFC中
  3. BFC区域不会与float box重叠
  4. BFC是独立容器,外部不影响内部

JavaScript部分

4. JavaScript中的事件循环机制是怎样的?

答案: JavaScript是单线程语言,通过事件循环机制实现异步。事件循环由以下部分组成:

  1. 调用栈(Call Stack):执行同步代码的地方
  2. 任务队列(Task Queue)
    • 宏任务(macrotask):script整体代码、setTimeout、setInterval、I/O、UI渲染等
    • 微任务(microtask):Promise.then/catch/finally、MutationObserver等

执行顺序:

  1. 执行同步代码(宏任务)
  2. 执行过程中遇到异步任务:
    • 微任务放入微任务队列
    • 宏任务放入宏任务队列
  3. 同步代码执行完毕,检查微任务队列并全部执行
  4. UI渲染(如果需要)
  5. 取出一个宏任务执行,重复上述过程

5. ES6中let/const与var的区别?

答案:

var let const
作用域 函数作用域 块级作用域 块级作用域
变量提升 暂时性死区 暂时性死区
重复声明 允许 不允许 不允许
全局属性 会成为 不会成为 不会成为
初始值 可不设 可不设 必须设置
修改值 可以 可以 不可以

6. Promise的原理是什么?手写一个简单的Promise实现。

答案: Promise是一种异步编程解决方案,主要解决回调地狱问题。它有三种状态:pending、fulfilled、rejected。

简单实现:

class MyPromise {
  constructor(executor) {
    this.state = 'pending';
    this.value = undefined;
    this.reason = undefined;
    this.onFulfilledCallbacks = [];
    this.onRejectedCallbacks = [];
    
    const resolve = (value) => {
      if (this.state === 'pending') {
        this.state = 'fulfilled';
        this.value = value;
        this.onFulfilledCallbacks.forEach(fn => fn());
      }
    };
    
    const reject = (reason) => {
      if (this.state === 'pending') {
        this.state = 'rejected';
        this.reason = reason;
        this.onRejectedCallbacks.forEach(fn => fn());
      }
    };
    
    try {
      executor(resolve, reject);
    } catch (err) {
      reject(err);
    }
  }
  
  then(onFulfilled, onRejected) {
    if (this.state === 'fulfilled') {
      onFulfilled(this.value);
    }
    
    if (this.state === 'rejected') {
      onRejected(this.reason);
    }
    
    if (this.state === 'pending') {
      this.onFulfilledCallbacks.push(() => onFulfilled(this.value));
      this.onRejectedCallbacks.push(() => onRejected(this.reason));
    }
  }
}

React/Vue框架部分

7.React中setState是同步还是异步的?

答案: 在React中,setState的行为表现有时"异步",有时"同步":

1.大部分情况下表现为异步(批量更新优化):

  • React合成事件处理函数中(setTimeout/setInterval/Promise回调等原生事件外)
  • React生命周期函数中

在这些情况下React会将多个setState合并为一个更新以提高性能。

2.某些情况下表现为同步:

  • setTimeout/setInterval回调中
  • DOM原生事件处理函数中
  • Promise.then等异步代码中

React18后所有情况都默认批量处理(auto batching),如需强制同步可使用flushSync。

原理原因: React通过isBatchingUpdates标志控制是否批量更新,合成事件和生命周期会开启此标志。

###8.Vue的响应式原理是怎样的?

答案: Vue2.x使用Object.defineProperty,Vue3使用Proxy实现响应式:

Vue2实现原理: 1.数据劫持:通过Object.defineProperty对data对象每个属性添加getter/setter追踪变化。

Object.defineProperty(obj, key, { 
 get() { //依赖收集 },
 set(newVal) { //触发更新 } 
})

2.依赖收集:在getter中将观察者Watcher实例添加到Dep订阅器中。 3.派发更新:setter被触发时通知Dep中的所有Watcher重新计算并更新视图。 缺点:无法检测对象属性的添加删除,数组变动需特殊处理。

Vue3使用Proxy改进:

new Proxy(data, { 
 get(target, key){},
 set(target, key, value){},
 deleteProperty(target, key){}
})

优势:可直接监听对象/数组的各种变化;无需递归遍历整个对象初始化。

##性能优化相关

###9.Webpack有哪些常见的性能优化手段?

构建速度优化:

1.缩小文件搜索范围

resolve:{ modules:[path.resolve(__dirname,'node_modules')] },
module:{ noParse:/jquery|lodash/ } //忽略未模块化库的解析  

2.缓存loader结果(cache-loader/babel-loader?cacheDirectory=true)

3.多进程构建(thread-loader/happyPack)

4.DllPlugin预编译不变模块

5.合理使用sourceMap(开发环境cheap-module-eval-source-map)

打包体积优化:

1.Tree Shaking(ES6模块+production模式+sideEffects配置)

2.Code Splitting:

optimization:{ splitChunks:{ chunks:'all' } },
entry:{ main:'./src/main.js', vendor:['lodash'] }  

3.Scope Hoisting(ModuleConcatenationPlugin) 4.UglifyJsPlugin压缩混淆代码

5.Gzip压缩(compression-webpack-plugin) 6.CDN引入外部资源(externals) 7.PurgeCSS移除无用CSS

8.OptimizeCSSAssetsPlugin压缩CSS

9.ImageMinimizerPlugin压缩图片

10.babel按需加载polyfill

##算法与编程题

###10.[编程题]手写防抖和节流函数

防抖(debounce):高频触发时只在停止触发后执行一次

function debounce(fn, delay){
 let timer=null;
 return function(...args){
   clearTimeout(timer);  
   timer=setTimeout(()=>fn.apply(this,args),delay);
 }
}

节流(throttle):高频触发时每隔一段时间执行一次

function throttle(fn, interval){
 let lastTime=0;  
 return function(...args){
   const now=Date.now();
   if(now-lastTime>=interval){
     fn.apply(this,args);  
     lastTime=now;  
   }  
 } 
}

//定时器版本节流:
function throttle(fn,delay){  
 let timer=null;   
 return function(...args){   
   if(!timer){     
     timer=setTimeout(()=>{      
       fn.apply(this,args);       
       timer=null;      
     },delay);     
   }   
 };   
}   

##HTTP与浏览器相关

###11.HTTPS的工作原理是什么?

HTTPS=HTTP+TLS/SSL加密层工作流程:

1.Client发送支持的加密算法列表+随机数A给Server

2.Server选择加密算法+发送数字证书+随机数B给Client

3.Client验证证书有效性(颁发机构/过期时间/域名匹配),生成随机数C并用证书公钥加密发送给Server

4.Server用私钥解密获取随机数C

5.Client和Server都用ABC三个随机数生成对称加密密钥(session key)

6.HTTP通信开始使用该密钥加密数据

关键点: -CA机构验证服务器身份防止中间人攻击
-非对称加密交换对称密钥提高安全性又保证性能
-TLS握手阶段采用非对称加密通信阶段采用对称加密

安全特性: 机密性(对称加密)+完整性(MAC校验)+身份认证(X509证书链)

2025总结:我在深圳做前端的第8年

转眼入行前端已经8个年头,我也算一名老前端了。可能自己对这一行谈不上特别喜欢,也不讨厌,工作上一直没有什么起色。

工作

去年年底我入职了一家外包公司,然后派去给一家上市公司干活。自己当时待的前端团队加上两个外包员工共有7人,涉及的项目有管理平台(微前端)以及对应的管理后台、Uniapp小程序、App(React Native)、可视化大屏系统。我主要参与的是pc端系统,都是基于Vue框架。其中管理平台主要是一些常见的业务需求的开发,但也有基于svg封装的实时监控主图组件还是比较复杂的;另外可视化大屏项目也参与的比较多,学习到了大屏适配的相关方案。

另外,今年工作过程中,自己也尝试用起了AI编程工具。我用的比较多的是阿里的通义灵码,不得不说对工作效率的提升还是很大。最近我开始转向字节的AI编辑器trae,体验上来说确实比插件要好很多。

在这家公司上班,还是比较清闲的,周末双休,平时也不会强制加班。领导和同事之间相处也比较愉快,在离场的时候,还一起吃了好几顿饭。

业余时间

其实今年自己的业余时间是比较多的,但还是没有很好的利用。可能我这个人比较懒吧,不肯放弃休闲娱乐的时间,到现在年初的目标也没实现几个。说好的多写点技术文章,结果就年终一篇总结,笑死!另外我也不是一个有耐心的人,今年本来想搭建一个自己的博客系统,但做了一半又去搞面试小程序去了,到现在两个都还没弄完。最让我气馁的还是软考,考了三次都还没过。今年考的两次在考前都刷题了很长一段时间,但最后都是其中一科差两分,太伤心了。

希望26年自己对自己要求高一点,养成自律的好习惯。

副业探索

今年我尝试的副业是虚拟店铺和网盘拉新。在网上搜罗了几十G的网盘资源,有小部分自己觉得比较好的放到了淘宝店铺上,最初还是出了几单的,但后面也慢慢没有流量了,就没有太上心。网盘拉新也差不多,特别是遭到各平台封号禁言之后,也没有去花时间了。两个副业一起大概收益不到200元,也算是副业探索上跨出的一步。其实我个人觉得这两个副业都挺好的,都不需要什么启动资金,就是要多花点时间去研究。

希望26年自己多花点时间在上面,争取副业收入月入过千。

二次被裁

年底的时候我又经历了一次裁员,与其说是被裁,其实是入职之初就能预料到的结果。因为继上一次裁员之后,我入职了一家外包公司,而且是不缴纳公积金和社保那种,最可恨的是在入职之前就让你签署各种主动放弃公积金和社保的协议。由于当时找工作几个月无果,最后无奈还是同意了。年底的时候由于驻场的甲方公司业务调整,所有外包员工都需要离场。其实在9月份的时候,外包公司迫于国家的压力,还是与我们签订了正式劳动合同,但同时也让我们签署放弃追缴赔偿的协议。虽然我也了解到这种违法劳动法的协议都是不合法的,但也不太想闹得去仲裁,就让他们配合我能领取失业金就行。

面试找工作

其实再次失业后,我心里也没有太过焦虑,也正好可以便找边休息一下。有了上一次的失业经历,我知道这次找工作也还是会很难,毕竟我的学历不行,还是非科班,技术能力也一般。其实没离场之前,我心里打定不再进外包了,但实际投简历的时候发现不考虑外包的话,面试机会就更少了。目前面了大概有5家公司,其中两家外包,有一家外包都发offer了,最后说甲方考虑到我是非统招学历,取消了offer。

这几年互联网行业下行,裁员失业的比较多,导致了市场供需不平衡。但毕竟是我工作了近8年的行业,而且目前我的副业也还没有发展起来。所以我未来几年也还是会继续深耕这一行,直到那天彻底找不到工作,或能有其它收入吧。

最后还是总结一下吧。

25年对我来说还是平淡的一年,工作和生活都没有什么大的变化。不过心态上来说,自己还是比较平和知足的,不用特别为生计发愁;而且国家也在日益强盛(虽然有产业转型的阵痛,如失业)。所以对未来,我还是有很多期待...

面试官 : “请你讲一下 JS 的 《垃圾回收机制》 ? ”

1. 垃圾回收到底是什么?

JavaScript 是自动内存管理的语言,你不用手动申请 / 释放内存(比如 C/C++ 需要 malloc/free),垃圾回收就是 JS 引擎(如 V8)自动做的两件事:

  • 找 “垃圾” :识别出程序中不再使用的变量 / 对象(占用的内存就是 “垃圾内存”);
  • 清垃圾:释放这些 “垃圾” 占用的内存,避免内存泄漏、提升性能。

举个简单例子:

function fn() {
  let num = 10; // 函数执行时,num 占用内存
}
fn(); // 函数执行完后,num 再也访问不到了 → 变成“垃圾”,GC 会回收它的内存

2. JS 怎么判断 “哪些是垃圾”?

GC 不是瞎回收的,核心判断标准是:一个对象 / 变量是否还能被 “访问到”(是否有引用指向它)

  • 能访问到 → 存活(不回收);
  • 访问不到 → 垃圾(会被回收)。

3. JS 垃圾回收的核心算法(V8 引擎为主)

不同 JS 引擎的 GC 算法略有差异,但核心是两种:标记 - 清除(主流)和引用计数(辅助 / 历史)。

算法 1:标记 - 清除(Mark-and-Sweep,现代引擎主流)

这是 V8 最核心的 GC 算法,分为 “标记” 和 “清除” 两步,逻辑很直观:

GC 启动

标记阶段:从根对象(如 window/global)出发,遍历所有可访问的对象,打上“存活”标记

清除阶段:遍历堆内存,清除所有没有“存活”标记的对象,释放内存

内存整理可选):将空闲内存碎片合并,方便后续分配

举个例子理解

// 根对象:window(浏览器环境)
let obj1 = { name: "John" }; // obj1 被 window 引用 → 标记为存活
let obj2 = obj1; // obj2 也引用 obj1 → 还是存活
obj1 = null; // 解除 obj1 的引用,但 obj2 还指向 → 仍存活
obj2 = null; // 所有引用都解除 → obj1 无法访问 → 标记为垃圾,下次 GC 清除

优点

  • 解决了引用计数的 “循环引用” 问题(下面会说);
  • 逻辑简单,效率高。

缺点

  • 清除后会产生内存碎片(比如内存里零散的空闲空间),但 V8 会通过 “内存整理” 优化。

算法 2:引用计数(Reference Counting,历史算法,已淘汰核心场景)

早期(如 IE8 之前)的算法,逻辑是:给每个对象记录 “被引用的次数”,次数为 0 就回收

  • 当对象被引用 → 计数 + 1;
  • 当引用解除 → 计数 - 1;
  • 计数 = 0 → 立即回收。

例子

let obj = { a: 1 }; // 引用计数 = 1
let obj2 = obj;     // 引用计数 = 2
obj = null;         // 引用计数 = 1(还不能回收)
obj2 = null;        // 引用计数 = 0 → 变成垃圾,被回收

致命缺点:无法处理循环引用(这也是它被标记 - 清除取代的核心原因):

// 循环引用:obj1 和 obj2 互相引用,引用计数都为 1,永远不会为 0
let obj1 = {};
let obj2 = {};
obj1.fn = obj2;
obj2.fn = obj1;

// 即使解除外部引用,计数仍为 1 → 引用计数算法不会回收,造成内存泄漏
obj1 = null;
obj2 = null;

👉 而标记 - 清除算法能解决这个问题:因为 obj1 / obj2 都无法从根对象访问到,会被标记为垃圾,最终回收。

4. V8 引擎的 GC 优化(进阶,面试高频)

V8 为了提升 GC 效率,还做了针对性优化,核心是 “分代回收”:

  • 将内存分为 新生代(Young Generation)老生代(Old Generation)

    • 新生代:存储短期存活的对象(如函数内部的临时变量),GC 频率高、速度快(用 “Scavenge 算法”,复制 - 清除);
    • 老生代:存储长期存活的对象(如全局变量),GC 频率低,用 “标记 - 清除 + 标记 - 整理” 算法。
  • 优点:避免对整个内存做全量 GC,减少卡顿(JS 是单线程,GC 时会暂停代码执行,分代回收能缩短暂停时间)。

5. 常见的内存泄漏场景(GC 没回收的 “伪垃圾”)

垃圾回收不是万能的,如果代码写得不好,会导致 “本该回收的对象没被回收”,也就是内存泄漏,常见场景:

  1. 意外的全局变量(最常见):

    function fn() {
      num = 10; // 没写 let/var/const → 自动挂载到 window → 全局变量,永远不回收
    }
    
  2. 未清除的定时器 / 事件监听

    // 定时器引用了 obj,即使页面关闭前不清除定时器,obj 永远存活
    let obj = { data: "xxx" };
    setInterval(() => { console.log(obj); }, 1000);
    // 解决:不用时 clearInterval(timer)
    
  3. 闭包滥用

    function outer() {
      let bigData = new Array(1000000); // 大数组
      return function() { // 闭包引用 bigData,outer 执行完后 bigData 也不回收
        console.log(bigData);
      };
    }
    let fn = outer();
    // 解决:不用时 fn = null,解除引用
    

最后总结 🤔

  1. 核心本质:JS 垃圾回收是引擎自动回收 “不可访问” 对象的内存,避免手动管理内存的繁琐和错误。
  2. 核心算法:现代引擎以标记 - 清除为主(解决循环引用),引用计数已淘汰核心场景。
  3. V8 优化:分代回收(新生代 + 老生代)减少 GC 卡顿,提升性能。
  4. 避坑重点:避免意外全局变量、未清除的定时器 / 监听、滥用闭包,防止内存泄漏。

搞混了 setState 同步还是异步问题

刚学 React 接触setState的时候,经常会想一个问题:setState 到底是同步的还是异步的?

“好像是异步的”,结果写代码时又发现有时候它“立刻生效”了。越想越糊涂,直到后来踩了坑、看了源码、再结合 React 18 的变化,才真正理清楚。

就最近遇到的切换页面主题的react项目,里面的有一下一段代码

const toggleTheme = () => {
  setTheme(previousState => previousState === 'light' ? 'dark' : 'light');
};

这又让我想起setState这个许久的问题,它和“同步/异步”有关系吗?决定写一篇文章来捋一捋。

一开始,我以为 setState 是“异步”的

脑子里立刻浮现出那个经典例子:

const [count, setCount] = useState(0);

const handleClick = () => {
  setCount(count + 1);
  console.log(count); 、
};

这里打印出来的还是老值,导致我一直以为是因为“setState 是异步的,还没执行完”。 但后来我才意识到——这个理解其实有点跑偏了

一、 所谓的“异步”,其实是 React 在“攒大招”

为什么 console.log(count) 打印的是 0?

并不是因为 setCountsetTimeout 或者接口请求那样真的是个异步任务,被扔到了微任务队列里。根本原因是 React 为了性能,开启了一个叫 “批处理” 的机制。

想象一下你去超市结账。如果你拿一瓶水,收银员算一次钱;再拿包薯片,收银员再算一次钱……收银员(渲染引擎)肯定会被你累死。 React 很聪明,它会把你的多次 setState 操作先“记在小本本上”,等你这一轮事件处理函数执行完了,它再一次性把所有账单结了,这个操作在react里面叫更新dom

所以,当你执行 console.log 的时候,React 甚至还没开始动手更新呢,你读到的自然是旧值。

为了验证这一点,咱们直接上代码测试,用 React 17 和 React 18 对比,真相立马浮出水面。

二、在 React 17 里的不同

后来我看了一些老教程,说“在 setTimeoutsetState 是同步的”。于是我兴奋地去试了一下:

// 环境:React 17 
const handleClick = () => {
  setTimeout(() => {
    setCount(c => c + 1);
    
    // 很多人(包括以前的我)以为这里能打印出 1
    // 结果控制台啪的一下打脸:依然是 0 !!!
    console.log(count); 
  }, 0);
};

image.png

当时我就懵了,直到我打开 Chrome 开发者工具的 Elements 面板,盯着那个 DOM 节点看,才发现了一个惊人的事实:

  1. DOM 确实变了!console.log 执行的那一瞬间,页面上的数字已经变成 1 了。说明 React 确实同步完成了渲染。
  2. count 变量没变! 因为我是用函数式组件写的。

这就触及到了知识盲区: 在 React 17 的 setTimeout 里,React 确实失去了“批处理”的能力,导致它被迫同步更新了视图。但是!由于函数式组件的闭包特性,我当前这个 handleClick 函数是在 count=0 的时候创建的,它手里拿的 count 永远是 0。

所以,视图是新的,变量是旧的。这才是最坑的地方。

三、React 18 的大一统

回到 React 18,官方推出了 自动批处理

现在,不管你是在 setTimeoutPromise 还是原生事件里,React 都会把门焊死,统统进行批处理。

setTimeout(() => {
  setCount(c => c + 1);
  setName('Alice');
  setIsLoading(false);
}, 0);

👉 结果:只 re-render 1 次!

React 18 无论你在哪调用状态更新(事件、定时器、Promise、fetch 回调等) ,都会自动把它们“攒起来”,在当前 tick 结束时一次性合并更新并渲染

这意味着,在 React 18 里,除非你用 flushSync 这种逃生舱,否则你几乎看不到 DOM 同步更新的情况了。这其实是好事,心智负担少了很多,不用再去记那些特例。

首先,我们来看最常见的场景。如果它是同步的,那我改三次,它就应该变三次

来看这段代码:

// React 18 环境
export default function App() {
  console.log("组件渲染了!"); // 埋点:监控渲染次数
  const [count, setCount] = useState(0);

  const handleClick = () => {
    // 连发三枪
    setCount(count + 1);
    setCount(count + 1);
    setCount(count + 1);
    
    // 马上查看
    console.log("点击时的 count:", count); 
  };

  return <button onClick={handleClick}>{count}</button>;
}

image.png

运行结果直接打脸:

  1. 控制台打印 点击时的 count: 0。(说明:代码执行到这行时,状态根本没变)
  2. "组件渲染了!" 只打印了 1 次。(说明:三次操作被合并了)
  3. 页面上的数字变成了 1,而不是 3

四、setState 同步的情况

我们可以逼 React 同步执行。在 React 18 里,我们需要用 flushSync 这个 API 来关掉自动批处理。

上代码:

import { useState } from 'react';
import { flushSync } from 'react-dom';

export default function App() {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    // 第一次更新:强制同步
    flushSync(() => {
      setCount(c => c + 1);
    });
    console.log("第一次 flushSync 结束,DOM 上的数字是:", document.getElementById('count-span').innerText);

    // 第二次更新:强制同步
    flushSync(() => {
      setCount(c => c + 1);
    });
    console.log("第二次 flushSync 结束,DOM 上的数字是:", document.getElementById('count-span').innerText);
  };

  return (
    <div>
      <span id="count-span">{count}</span>
      <button onClick={handleClick}>点击增加</button>
    </div>
  );
}

image.png

结论: 看,React 其实完全有能力同步更新。只要你用 flushSync 勒令它“立刻、马上干活”,它就会停下手头的工作,立刻执行更新流程。

所以,准确地说:setState 本质上是同步执行代码的,只是 React 默认挂了个“防抖”的机制,让它看起来像是异步的。

五、最坑的“假异步”(闭包陷阱)

既然上面的代码里,DOM 都已经同步变了,那我在 JS 里直接打印 count 变量

看这段代码:

const handleClick = () => {
  flushSync(() => {
    setCount(c => c + 1); 
  });
  
  // 刚才代码证明了,DOM 这里已经变成 1 
  // 那这里打印 count 应该是几?
  console.log("也就是现在的 count 是:", count); 
};

image.png

这不是 React 的锅,这是 JavaScript 闭包的锅。

我们这个 handleClick 函数,是在 count 为 0 的那次渲染中生成的。它就像一张照片,永远定格在了那一刻。

无论你用办法(比如 flushSync)让 React 在外部把 DOM 更新了,或者把 React 内部的 State 更新了,但你当前正在运行的这个 handleClick 函数作用域里,count 这个局部变量,它就是个常量 0,再怎么搞它也是 0

回到最初的问题

理清了这些,再回过头看开头那段代码:

const toggleTheme = () => {
  setTheme(previousState => previousState === 'light' ? 'dark' : 'light');
};

为什么要写成 previousState => ... 这种函数形式?

这和“同步/异步”有关系吗?有关系。

正因为 React 的 setState 是“异步”(批处理)的,而且函数式组件有闭包陷阱,如果直接写 setTheme(theme === 'light' ? ...),你拿到的 theme 很可能是旧值(也就是上面例子里那个永远是 0 的 count)。

当你传入一个函数时,你是在告诉 React:

“麻烦把当时最新的那个状态值传给我的函数。我不信我自己闭包里的旧变量,我只信你传给我的新值。”

总结一下

1、定性: “严格来说,setState 是由 React 调度的更新,表现得像异步(批处理的原因)。”

2、亮点:

  • “在 React 18 中,得益于自动批处理,无论在 React 事件还是 setTimeout 中,它都会合并更新,表现为异步。”

  • “但在 React 17 及以前,如果在 setTimeout 或原生事件中,它会脱离 React 的管控,表现为同步行为。”

3、补充特例: “如果需要在 React 18 中强制同步更新 DOM,我们可以使用 flushSync。”

4、最后补刀(闭包): “但无论 DOM 是否同步更新,在函数式组件中,由于 JS 闭包 的存在,我们在当前函数执行上下文中拿到的 state 永远是本次渲染的快照(旧值),要获取最新值应该依赖 useEffectuseRef。”

LeetCode 11. 盛最多水的容器

图解算法:为什么一定要移动那个短板?| LeetCode 11. 盛最多水的容器

前言:在面试中,有一类题目看似简单,暴力解法也能做,但面试官真正想看的是你如何将 

O(N2)

 的复杂度优化到 

O(N)

。LeetCode 11 题“盛最多水的容器”就是这类题目的典范。今天我们不背代码,而是深入探讨背后的贪心策略双指针思维。

一、 题目直觉与“木桶效应”

题目的目标非常直观:在一个数组中找到两条垂线,使得它们与 X 轴围成的容器能盛最多的水。

我们要计算的是矩形面积:

Area=Width×HeightArea=Width×Height

这里有一个物理常识至关重要,那就是木桶效应 (Short Board Effect)
一个木桶能装多少水,取决于最短的那块木板。

映射到题目中:

  • 宽度 (Width) :两条垂线在 X 轴上的距离 right - left。
  • 高度 (Height) :两条垂线中较矮的那一条,即 Math.min(height[left], height[right])。

二、 痛点:为什么暴力解法不行?

最容易想到的思路是双重循环:计算所有两两组合的面积,然后取最大值。

然而以我的经验,当你写下双循环的时候,你自己心中的无奈,没有人会比你更了解

面试官在了解到你的解题思路时,就已经将你pass掉了

任何算法题,写双循环的结果只有死路一条(因为他会认为你对空间与时间复杂度没有概念,或者你的实力就这么多)

JavaScript

//  暴力解法
let max = 0;
for (let i = 0; i < len; i++) {
    for (let j = i + 1; j < len; j++) {
        // 计算每一对组合...
    }
}

这种解法的时间复杂度是

O(N2)


题目提示中数组长度 

NN

 可达 

105105

。这意味着计算量高达 

10101010

 次。在通常的算法竞赛或面试标准中,这绝对会触发 TLE (Time Limit Exceeded)  超时错误。

我们需要一种更聪明的做法,将复杂度降维打击到

O(N)

三、 核心:双指针法与贪心策略

我们要优化的核心是:如何尽可能少地遍历,却能保证不漏掉最大值?

1. 初始布局:拉满宽度

既然面积 = 宽 × 高,我们不妨先让宽度最大
我们在数组的头尾各放置一个指针:left 指向开头,right 指向结尾。

此时,容器的底宽是最大的。接下来的每一步移动,宽度必然减小。为了弥补宽度的损失,我们必须寻找更高的垂线。

2. 决策困境:移动哪一根?

这是本题最难理解的点。假设现在的状况是:

  • 左边柱子高度 left_h = 2

  • 右边柱子高度 right_h = 8

  • 当前宽度 w = 10

  • 当前面积 = 

    10=202×10=20
    

现在我们需要向内移动一个指针,是移左边的(矮的),还是移右边的(高的)?

假设我们移动高的那一边(右边):

宽度肯定变小了(变成 9)。
而水位高度取决于谁?依然是左边那个不动的短板(高度 2)。
无论右边新遇到的柱子是高耸入云还是矮小不堪,容器的有效高度最高只能是 2

新面积=9×min⁡(2,新高度)≤18新面积=9×min(2,新高度)≤18

结论:  移动高板,宽度减小,高度受限于不动的短板(无法增加)。面积只会变小,绝对不可能变大。  这是一条死路。

贪心策略:移动矮的那一边(左边):

虽然宽度变小了(变成 9),但我们抛弃了当前的短板(高度 2)。
如果运气好,左边新遇到的柱子高度是 10,那么新的有效高度就变成了 8(受限于右边)。

新面积=9×8=72新面积=9×8=72

结论:  只有移动短板,我们才有可能找到更高的柱子来弥补宽度的损失。

这就是本题的贪心逻辑:  每一步我们都排除掉那个“导致当前高度受限”的短板,因为它已经发挥了它的最大潜力(在当前最宽的情况下),保留它没有任何意义。

四、 代码实现

理解了上述逻辑,代码实现就非常简单了。

JavaScript

/**
 * @param {number[]} height
 * @return {number}
 */
var maxArea = function(height) {
    // 1. 定义双指针,分别指向头尾
    let left = 0;
    let right = height.length - 1;
    let maxWater = 0;
    
    // 2. 当指针未相遇时循环
    while (left < right) {
        // 3. 计算当前面积
        // 高度取决于短板 (木桶效应)
        const currentHeight = Math.min(height[left], height[right]);
        const currentWidth = right - left;
        
        // 更新历史最大值
        maxWater = Math.max(maxWater, currentHeight * currentWidth);
        
        // 4. 核心决策:移动较矮的一侧
        // 如果左边是短板,那左边这块板子在当前宽度下已经发挥了最大价值,
        // 再往里缩宽度只会变小,保留左边没意义,不如向右移试试看有没有更高的。
        if (height[left] < height[right]) {
            left++;
        } else {
            right--;
        }
    }
    
    return maxWater;
};

五、 复杂度分析

  • 时间复杂度:

    O(N)
    

    双指针 left 和 right 总共遍历整个数组一次。相比于暴力解法的 

    O(N2)
    

    ,效率提升是巨大的。

  • 空间复杂度:

    O(1)O(1) 
    

    我们只需要存储指针索引和 maxWater 几个变量,不需要额外的数组空间。

六、 总结

所谓算法优化,往往不是代码写得有多复杂,而是思维模型的转换

LeetCode 11 题通过观察“木桶效应”,让我们明白:保留长板、抛弃短板是唯一可能获得更大收益的路径。这种通过排除法将搜索空间从二维矩阵(所有组合)压缩到一维线性扫描(双指针)的过程,就是算法中的降维打击

希望这篇文章能帮你彻底搞懂双指针解法!

| ES6 | 异步 | 闭包 | 原型链 | DOM操作 | 事件处理 |

一、ES6+ 新特性

ES6(ECMAScript 2015)及后续的 ES7-ES14 被统称为 ES6+,是 JavaScript 语言的重大升级,解决了 ES5 时代的语法冗余、作用域混乱、功能缺失等问题,大幅提升了代码的可读性、可维护性和开发效率。

1. 块级作用域与变量声明

ES5 中只有全局作用域和函数作用域,var 声明的变量存在 “变量提升” 和 “作用域穿透” 问题,极易引发 bug。ES6 新增 letconst 关键字,引入块级作用域({} 包裹的区域):

  • let:声明可变变量,仅在当前块级作用域有效,无变量提升,不允许重复声明;
  • const:声明常量,一旦赋值不可修改(引用类型仅保证地址不变),同样遵循块级作用域规则。示例:
// ES5 问题:变量提升+作用域穿透
if (true) {
  var a = 10;
}
console.log(a); // 10(全局作用域可访问)

// ES6 解决
if (true) {
  let b = 20;
  const c = 30;
}
console.log(b); // ReferenceError: b is not defined
console.log(c); // ReferenceError: c is not defined

2. 箭头函数

简化函数声明语法,核心特性:

  • 语法简洁:单参数可省略括号,单返回语句可省略大括号和 return
  • 无独立 this:箭头函数的 this 继承自外层作用域,解决了 ES5 中 this 指向混乱的问题(如回调函数中 this 丢失);
  • 不能作为构造函数:无法使用 new 调用,无 arguments 对象(可改用剩余参数)。示例:
// ES5 函数
const add = function(a, b) {
  return a + b;
};

// ES6 箭头函数
const add = (a, b) => a + b;

// this 指向示例
const obj = {
  name: "张三",
  fn1: function() {
    setTimeout(function() {
      console.log(this.name); // undefined(this 指向全局)
    }, 100);
  },
  fn2: function() {
    setTimeout(() => {
      console.log(this.name); // 张三(this 继承自 fn2 的作用域)
    }, 100);
  }
};
obj.fn1();
obj.fn2();

3. 解构赋值

允许从数组 / 对象中提取值,赋值给变量,简化数据提取逻辑:

  • 数组解构:按索引匹配,支持默认值;
  • 对象解构:按属性名匹配,支持重命名和默认值。示例:
// 数组解构
const [a, b, c = 30] = [10, 20];
console.log(a, b, c); // 10 20 30

// 对象解构
const { name: userName, age = 18 } = { name: "李四" };
console.log(userName, age); // 李四 18

4. 扩展运算符与剩余参数

  • 扩展运算符(...):将数组 / 对象展开为单个元素,用于合并数据、传递参数;
  • 剩余参数(...):收集剩余的参数,转为数组,替代 arguments。示例:
// 扩展运算符
const arr1 = [1, 2, 3];
const arr2 = [4, 5, 6];
const arr3 = [...arr1, ...arr2]; // [1,2,3,4,5,6]

const obj1 = { a: 1 };
const obj2 = { b: 2 };
const obj3 = { ...obj1, ...obj2 }; // {a:1, b:2}

// 剩余参数
const sum = (...args) => args.reduce((total, cur) => total + cur, 0);
console.log(sum(1,2,3)); // 6

5. 模板字符串

用反引号()包裹字符串,支持换行和变量插值(${变量}`),解决 ES5 字符串拼接繁琐的问题:

const name = "王五";
const age = 20;
// ES5 拼接
const str1 = "姓名:" + name + ",年龄:" + age + "岁";
// ES6 模板字符串
const str2 = `姓名:${name},年龄:${age}岁`;

6. 其他核心特性

  • Set/Map 数据结构:Set 用于存储唯一值(数组去重),Map 键值对集合(键可为任意类型,替代对象);
  • Class 类:语法糖,简化原型链继承,提供 constructorextendssuper 等关键字;
  • 模块化(import/export):替代 CommonJS/AMD,实现按需加载,提升代码模块化程度;
  • 可选链(?.)、空值合并(??):ES2020 特性,简化空值判断,避免 Cannot read property 'xxx' of undefined 错误。

ES6+ 新特性的核心价值在于 “语法简化” 和 “功能补全”,让 JavaScript 从 “脚本语言” 向 “工程化语言” 迈进,是现代前端开发(React/Vue/TypeScript)的基础。

二、异步(Promise, async/await)

JavaScript 是单线程语言,默认同步执行代码,但网络请求、定时器、文件操作等场景需要异步处理,否则会阻塞主线程。异步编程经历了 “回调函数 → Promise → async/await” 的演进,核心目标是解决 “回调地狱”,让异步代码更易读、易维护。

1. 异步编程的核心问题:回调地狱

ES5 中异步操作依赖回调函数,多个异步嵌套时会出现 “回调地狱”(代码层级深、可读性差、错误处理繁琐):

// 回调地狱:获取用户信息 → 获取用户订单 → 获取订单详情
$.get("/api/user", (user) => {
  $.get(`/api/order?userId=${user.id}`, (order) => {
    $.get(`/api/orderDetail?orderId=${order.id}`, (detail) => {
      console.log(detail);
    }, (err) => {
      console.error("获取订单详情失败", err);
    });
  }, (err) => {
    console.error("获取订单失败", err);
  });
}, (err) => {
  console.error("获取用户失败", err);
});

问题:层级嵌套过深,错误处理分散,代码难以调试和维护。

2. Promise:异步操作的标准化封装

Promise 是 ES6 引入的异步编程解决方案,本质是一个对象,代表异步操作的 “未完成 / 成功 / 失败” 状态,核心特性:

  • 三种状态:pending(进行中)、fulfilled(已成功)、rejected(已失败),状态一旦改变不可逆转;
  • 两个回调:then() 处理成功结果,catch() 处理失败结果,支持链式调用;
  • 解决回调地狱:通过链式调用替代嵌套,错误可统一捕获。

(1)Promise 基本用法

// 创建 Promise 对象
const getPromise = (url) => {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open("GET", url);
    xhr.onload = () => {
      if (xhr.status === 200) {
        resolve(JSON.parse(xhr.responseText)); // 成功:调用 resolve
      } else {
        reject(new Error(xhr.statusText)); // 失败:调用 reject
      }
    };
    xhr.onerror = () => {
      reject(new Error("网络请求失败"));
    };
    xhr.send();
  });
};

// 链式调用:解决回调地狱
getPromise("/api/user")
  .then((user) => getPromise(`/api/order?userId=${user.id}`))
  .then((order) => getPromise(`/api/orderDetail?orderId=${order.id}`))
  .then((detail) => console.log(detail))
  .catch((err) => console.error("请求失败", err)); // 统一捕获所有错误

(2)Promise 常用方法

  • Promise.all():接收多个 Promise 数组,全部成功才返回结果数组,一个失败则立即失败;
  • Promise.race():接收多个 Promise 数组,返回第一个完成的 Promise 结果(无论成功 / 失败);
  • Promise.resolve()/Promise.reject():快速创建成功 / 失败的 Promise 对象;
  • Promise.allSettled():等待所有 Promise 完成(无论成功 / 失败),返回所有结果(包含状态和值)。

示例(Promise.all):

// 同时请求多个接口,全部完成后处理
const promise1 = getPromise("/api/user");
const promise2 = getPromise("/api/goods");
Promise.all([promise1, promise2])
  .then(([user, goods]) => {
    console.log("用户信息", user);
    console.log("商品信息", goods);
  })
  .catch((err) => console.error("某个请求失败", err));

3. async/await:异步代码同步化

ES2017 引入的 async/await 是 Promise 的语法糖,允许用 “同步代码的写法” 处理异步操作,核心规则:

  • async 修饰函数:使函数返回一个 Promise 对象;
  • await 修饰 Promise:暂停函数执行,直到 Promise 状态变为成功,返回结果;若 Promise 失败,需用 try/catch 捕获错误。

(1)基本用法(解决回调地狱的终极方案)

// 封装异步请求函数(返回 Promise)
const getUser = () => getPromise("/api/user");
const getOrder = (userId) => getPromise(`/api/order?userId=${userId}`);
const getOrderDetail = (orderId) => getPromise(`/api/orderDetail?orderId=${orderId}`);

// async/await 写法:同步风格的异步代码
const getOrderInfo = async () => {
  try {
    const user = await getUser(); // 等待 getUser 完成
    const order = await getOrder(user.id); // 等待 getOrder 完成
    const detail = await getOrderDetail(order.id); // 等待 getOrderDetail 完成
    console.log(detail);
  } catch (err) {
    console.error("请求失败", err); // 统一捕获所有错误
  }
};

getOrderInfo();

(2)async/await 优势

  • 代码扁平化:无嵌套,可读性接近同步代码;
  • 错误处理统一:通过 try/catch 捕获所有异步错误,替代 Promise 的 catch()
  • 调试友好:可在 await 处打断点,调试流程与同步代码一致。

4. 异步编程的核心原则

  • 避免同步阻塞:异步操作始终不阻塞主线程(如定时器、网络请求由浏览器内核的线程处理);
  • 错误处理全覆盖:Promise 需加 catch(),async/await 需包 try/catch,避免未捕获的异步错误;
  • 并行处理优化:多个无依赖的异步操作,用 Promise.all() 替代串行 await,提升执行效率。

异步编程是前端开发的核心难点,Promise 解决了 “回调地狱” 的结构问题,async/await 则让异步代码的可读性达到了同步代码的水平,是现代前端处理网络请求、异步数据加载的标配。

三、闭包和原型链

闭包和原型链是 JavaScript 的两大核心特性,也是面试高频考点。闭包关乎作用域和变量生命周期,原型链则是 JavaScript 实现继承的底层机制,理解这两个概念能帮你突破 “语法使用” 到 “原理理解” 的瓶颈。

1. 闭包(Closure)

(1)闭包的定义

闭包是指 “有权访问另一个函数作用域中变量的函数”,本质是函数作用域链的保留:当内部函数被外部引用时,其所在的作用域不会被垃圾回收机制销毁,从而可以持续访问外层函数的变量。

(2)闭包的形成条件

  1. 存在嵌套函数(内部函数 + 外部函数);
  2. 内部函数引用外部函数的变量 / 参数;
  3. 外部函数执行后,内部函数被外部环境引用(如返回、赋值给全局变量)。

(3)基本用法与示例

// 基础闭包:外部函数执行后,内部函数仍能访问其变量
function outer() {
  const num = 10; // 外部函数的变量
  // 内部函数引用外部变量
  function inner() {
    console.log(num);
  }
  return inner; // 返回内部函数,使其被外部引用
}

const fn = outer(); // outer 执行完毕,但其作用域未被销毁
fn(); // 10(inner 仍能访问 num)

(4)闭包的核心应用场景

  • 封装私有变量:模拟 “私有属性 / 方法”,避免全局变量污染;

    // 封装计数器:count 是私有变量,只能通过方法修改
    function createCounter() {
      let count = 0;
      return {
        increment: () => count++,
        decrement: () => count--,
        getCount: () => count
      };
    }
    
    const counter = createCounter();
    counter.increment();
    counter.increment();
    console.log(counter.getCount()); // 2
    console.log(counter.count); // undefined(无法直接访问)
    
  • 防抖 / 节流函数:利用闭包保存定时器 ID、上次执行时间等状态;

    // 防抖函数(闭包保存 timer 变量)
    function debounce(fn, delay) {
      let timer = null; // 闭包保存 timer,多次调用共享同一个 timer
      return (...args) => {
        clearTimeout(timer);
        timer = setTimeout(() => {
          fn.apply(this, args);
        }, delay);
      };
    }
    
  • 柯里化函数:将多参数函数转为单参数函数,利用闭包缓存已传入的参数。

(5)闭包的注意事项

  • 内存泄漏风险:闭包会保留外层作用域,若长期引用未释放(如赋值给全局变量),会导致变量无法被垃圾回收,占用内存;
  • 解决:使用完闭包后,手动解除引用(如 fn = null),让作用域可以被回收。

2. 原型链(Prototype Chain)

JavaScript 是 “基于原型的面向对象语言”,没有类(ES6 Class 是语法糖),所有对象都通过 “原型” 实现属性和方法的继承,原型链是实现继承的核心机制。

(1)核心概念

  • 原型(prototype):函数特有的属性,指向一个对象,该对象是当前函数创建的所有实例的原型;
  • 隐式原型(__proto__):所有对象(包括函数)都有的属性,指向其构造函数的 prototype
  • 原型链:当访问对象的属性 / 方法时,先在自身查找,找不到则通过 __proto__ 向上查找,直到 Object.prototype,这个查找链条就是原型链。

(2)原型链的基本结构

// 构造函数
function Person(name) {
  this.name = name;
}
// 给原型添加方法
Person.prototype.sayHello = function() {
  console.log(`Hello, ${this.name}`);
};

// 创建实例
const p1 = new Person("张三");

// 原型链查找:p1 → Person.prototype → Object.prototype → null
console.log(p1.name); // 自身属性,直接返回
p1.sayHello(); // p1 自身无 sayHello,查找 p1.__proto__(Person.prototype)找到
console.log(p1.toString()); // p1 和 Person.prototype 无 toString,查找 Object.prototype 找到
console.log(p1.xxx); // 原型链末端为 null,返回 undefined

(3)原型链的核心应用:继承

ES5 中通过修改原型链实现继承(ES6 Class 的 extends 底层仍是原型链):

// 父类
function Parent(name) {
  this.name = name;
}
Parent.prototype.eat = function() {
  console.log(`${this.name} 吃饭`);
};

// 子类
function Child(name, age) {
  Parent.call(this, name); // 继承父类实例属性
  this.age = age;
}
// 继承父类原型方法
Child.prototype = Object.create(Parent.prototype);
Child.prototype.constructor = Child; // 修正构造函数指向

// 子类添加自有方法
Child.prototype.run = function() {
  console.log(`${this.name} 跑步,年龄 ${this.age}`);
};

const child = new Child("李四", 10);
child.eat(); // 继承父类方法
child.run(); // 子类自有方法

(4)原型链的关键规则

  • 所有对象的最终原型是 Object.prototype,其 __proto__null
  • 函数的 prototype 是普通对象,Function.prototype 是函数(特殊);
  • 修改原型会影响所有实例(原型共享特性)。

3. 闭包与原型链的关联

闭包关注 “作用域和变量保留”,原型链关注 “对象属性继承”,二者共同构成 JavaScript 的核心底层逻辑:闭包让函数可以突破作用域限制访问变量,原型链让对象可以突破自身结构继承方法,是理解 JavaScript 设计思想的关键。

四、DOM 操作和事件处理

DOM(文档对象模型)是浏览器将 HTML 文档解析成的树形结构,前端开发的核心是通过 JavaScript 操作 DOM 实现页面交互,事件处理则是响应用户操作(点击、输入、滚动等)的核心机制。

1. DOM 操作

DOM 操作分为 “查找节点”“创建 / 插入节点”“修改节点”“删除节点” 四类,核心是操作 DOM 树的节点(元素节点、文本节点、属性节点)。

(1)查找 DOM 节点(核心)

查找是 DOM 操作的第一步,常用方法:

  • 按 ID 查找:document.getElementById("id") → 返回单个元素(效率最高);
  • 按类名查找:document.getElementsByClassName("className") → 返回 HTMLCollection(动态集合);
  • 按标签名查找:document.getElementsByTagName("tagName") → 返回 HTMLCollection;
  • 按选择器查找:document.querySelector("selector")(返回第一个匹配元素)、document.querySelectorAll("selector")(返回 NodeList,静态集合)→ 最灵活,支持 CSS 选择器。

示例:

// 按 ID 查找
const box = document.getElementById("box");

// 按选择器查找
const item = document.querySelector(".list .item");
const items = document.querySelectorAll(".list .item"); // NodeList 可通过 forEach 遍历

(2)创建与插入节点

动态生成页面内容的核心,常用方法:

  • 创建元素:document.createElement("tagName")

  • 创建文本节点:document.createTextNode("text")

  • 插入节点:

    • parent.appendChild(child):将子节点插入父节点末尾;
    • parent.insertBefore(newNode, referenceNode):将新节点插入参考节点之前;
    • element.innerHTML:直接通过 HTML 字符串插入节点(简洁但有 XSS 风险)。

示例:

// 创建元素并插入
const ul = document.querySelector("ul");
const li = document.createElement("li");
li.textContent = "新列表项"; // 设置文本内容(安全,无 XSS)
ul.appendChild(li);

// innerHTML 方式(慎用,避免用户输入内容)
ul.innerHTML += "<li>新列表项</li>";

(3)修改 DOM 节点

  • 修改属性:element.setAttribute("attr", "value")(设置属性)、element.getAttribute("attr")(获取属性)、element.removeAttribute("attr")(移除属性);

    const img = document.querySelector("img");
    img.setAttribute("src", "new.jpg");
    console.log(img.getAttribute("src")); // new.jpg
    
  • 修改样式:

    • 行内样式:element.style.cssProperty = "value"(驼峰命名,如 backgroundColor);
    • 类名样式:element.classList.add("className")element.classList.remove("className")element.classList.toggle("className")(推荐,分离样式和逻辑)。
    const div = document.querySelector(".box");
    div.style.width = "200px";
    div.classList.add("active"); // 添加类名
    div.classList.toggle("show"); // 切换类名
    
  • 修改文本 / HTML:element.textContent(纯文本,安全)、element.innerHTML(HTML 字符串,有 XSS 风险)。

(4)删除 DOM 节点

  • parent.removeChild(child):父节点移除子节点;
  • element.remove():元素自身移除(ES6+ 方法,更简洁)。

示例:

const li = document.querySelector("li");
li.parentElement.removeChild(li); // 传统方式
// 或
li.remove(); // 简洁方式

(5)DOM 操作的性能优化

DOM 操作是 “重操作”,频繁修改会触发浏览器重排(Reflow)/ 重绘(Repaint),导致页面卡顿,优化手段:

  • 批量操作:先将节点脱离文档流(如隐藏父节点),操作完成后再恢复;

  • 使用文档碎片:document.createDocumentFragment(),批量插入节点仅触发一次重排;

    const fragment = document.createDocumentFragment();
    for (let i = 0; i < 1000; i++) {
      const li = document.createElement("li");
      li.textContent = `项 ${i}`;
      fragment.appendChild(li); // 先插入碎片,无重排
    }
    document.querySelector("ul").appendChild(fragment); // 仅一次重排
    
  • 避免频繁查询 DOM:将查询结果缓存到变量,减少 DOM 遍历。

2. 事件处理

事件是浏览器触发的 “信号”(如点击、输入、加载),事件处理是 JavaScript 响应用户操作的核心,分为 “事件绑定”“事件流”“事件对象”“事件优化” 四部分。

(1)事件绑定方式

  • 行内绑定(不推荐):<button onclick="handleClick()">点击</button> → 耦合度高,不利于维护;

  • DOM0 级绑定:element.onclick = function() {} → 简单,但一个事件只能绑定一个处理函数;

  • DOM2 级绑定:element.addEventListener("eventName", handler, useCapture) → 推荐,支持绑定多个处理函数,可控制事件阶段;

  • DOM0 级:浏览器原生支持,无官方规范 → element.onclick = function() {}

  • DOM1 级:仅规范 DOM 结构,未新增事件绑定方式 → 无事件相关内容

  • DOM2 级:W3C 发布标准,新增 addEventListener → 支持多绑定、事件阶段

  • DOM3 级:在 DOM2 基础上新增了更多事件类型(如键盘、鼠标滚轮事件)

const btn = document.querySelector("button");
// DOM0 级
btn.onclick = function() {
  console.log("点击1");
};
btn.onclick = function() {
  console.log("点击2"); // 覆盖上一个处理函数
};

// DOM2 级
const handleClick = () => console.log("点击1");
btn.addEventListener("click", handleClick);
btn.addEventListener("click", () => console.log("点击2")); // 可绑定多个
btn.removeEventListener("click", handleClick); // 可移除

(2)事件流(事件传播机制)

事件流分为三个阶段:

  1. 捕获阶段:事件从 document 向下传播到目标元素;
  2. 目标阶段:事件到达目标元素;
  3. 冒泡阶段:事件从目标元素向上传播到 document

addEventListener 的第三个参数 useCapturetrue 表示在捕获阶段触发,false(默认)表示在冒泡阶段触发。

(3)事件对象(Event)

事件处理函数的第一个参数是事件对象,包含事件的核心信息:

  • event.target:触发事件的原始元素(事件源);
  • event.currentTarget:绑定事件的元素;
  • event.preventDefault():阻止默认行为(如表单提交、链接跳转);
  • event.stopPropagation():阻止事件传播(冒泡 / 捕获);
  • event.stopImmediatePropagation():阻止事件传播,且阻止当前元素后续的事件处理函数执行。

示例:

// 阻止链接跳转
const a = document.querySelector("a");
a.addEventListener("click", (e) => {
  e.preventDefault(); // 阻止默认跳转
  console.log("点击链接,不跳转");
});

// 事件委托(利用事件冒泡)
const ul = document.querySelector("ul");
ul.addEventListener("click", (e) => {
  if (e.target.tagName === "LI") { // 判断点击的是 li 元素
    console.log("点击了列表项", e.target.textContent);
  }
});

(4)核心优化:事件委托

利用事件冒泡,将子元素的事件绑定到父元素,减少事件绑定数量,优化性能(尤其适合动态生成的元素):

// 动态生成的 li 无需单独绑定事件,父元素 ul 委托处理
const ul = document.querySelector("ul");
ul.addEventListener("click", (e) => {
  if (e.target.classList.contains("item")) {
    console.log("点击了动态生成的列表项");
  }
});

// 动态添加 li
const li = document.createElement("li");
li.classList.add("item");
li.textContent = "动态项";
ul.appendChild(li);

(5)常见事件类型

  • 鼠标事件:clickdblclickmouseovermouseoutmousedownmouseup
  • 键盘事件:keydownkeyupkeypress
  • 表单事件:inputchangesubmitfocusblur
  • 页面事件:loadDOMContentLoaded(DOM 解析完成)、scrollresize

DOM 操作和事件处理是前端交互的基础,核心原则是 “减少 DOM 操作次数”“合理利用事件机制”,既保证交互的流畅性,又避免性能问题。

总结

  1. ES6+ 新特性核心是简化语法、补全功能,是现代前端开发的基础,重点掌握块级作用域、箭头函数、解构、async/await 等高频用法;
  2. 异步编程从回调地狱演进到 Promise/async/await,核心是让异步代码更易读、易维护,async/await 是当前最优写法;
  3. 闭包是作用域链的保留,用于封装私有变量、实现防抖节流,需注意内存泄漏;原型链是 JS 继承的底层机制,所有对象通过 __proto__ 形成继承链条;
  4. DOM 操作需注重性能(批量操作、文档碎片),事件处理核心是事件委托,利用冒泡减少绑定数量,提升页面性能。

Vercel React 最佳实践 中文版

React 最佳实践

版本 1.0.0
Vercel 工程团队
2026年1月

注意:
本文档主要供 Agent 和 LLM 在 Vercel 维护、生成或重构 React 及 Next.js 代码库时遵循。人类开发者也会发现其对于保持一致性和自动化优化非常有帮助。


摘要

这是一份针对 React 和 Next.js 应用程序的综合性能优化指南,专为 AI Agent 和 LLM 设计。包含 8 个类别的 40 多条规则,按影响力从关键(消除瀑布流、减少打包体积)到增量(高级模式)排序。每条规则都包含详细的解释、错误与正确实现的真实代码对比,以及具体的影响指标,以指导自动重构和代码生成。


目录

  1. 消除瀑布流关键
  2. 打包体积优化关键
  3. 服务端性能
  4. 客户端数据获取中高
  5. 重渲染优化
  6. 渲染性能
  7. JavaScript 性能中低
  8. 高级模式

1. 消除瀑布流

影响力: 关键

瀑布流(Waterfalls)是头号性能杀手。每一个连续的 await 都会增加完整的网络延迟。消除它们能带来最大的收益。

1.1 推迟 Await 直到需要时

影响力: 高 (避免阻塞不使用的代码路径)

await 操作移动到实际使用它们的分支中,以避免阻塞不需要它们的代码路径。

错误:阻塞了两个分支

async function handleRequest(userId: string, skipProcessing: boolean) {
  const userData = await fetchUserData(userId)
  
  if (skipProcessing) {
    // 立即返回,但仍然等待了 userData
    return { skipped: true }
  }
  
  // 只有这个分支使用了 userData
  return processUserData(userData)
}

正确:仅在需要时阻塞

async function handleRequest(userId: string, skipProcessing: boolean) {
  if (skipProcessing) {
    // 不等待直接返回
    return { skipped: true }
  }
  
  // 仅在需要时获取
  const userData = await fetchUserData(userId)
  return processUserData(userData)
}

另一个例子:提前返回优化

// 错误:总是获取权限
async function updateResource(resourceId: string, userId: string) {
  const permissions = await fetchPermissions(userId)
  const resource = await getResource(resourceId)
  
  if (!resource) {
    return { error: 'Not found' }
  }
  
  if (!permissions.canEdit) {
    return { error: 'Forbidden' }
  }
  
  return await updateResourceData(resource, permissions)
}

// 正确:仅在需要时获取
async function updateResource(resourceId: string, userId: string) {
  const resource = await getResource(resourceId)
  
  if (!resource) {
    return { error: 'Not found' }
  }
  
  const permissions = await fetchPermissions(userId)
  
  if (!permissions.canEdit) {
    return { error: 'Forbidden' }
  }
  
  return await updateResourceData(resource, permissions)
}

当被跳过的分支经常被执行,或者被推迟的操作非常昂贵时,这种优化通过尤为有价值。

1.2 基于依赖的并行化

影响力: 关键 (2-10倍 提升)

对于具有部分依赖关系的操作,使用 better-all 来即最大化并行性。它会在尽可能早的时刻启动每个任务。

错误:profile 不必要地等待 config

const [user, config] = await Promise.all([
  fetchUser(),
  fetchConfig()
])
const profile = await fetchProfile(user.id)

正确:config 和 profile 并行运行

import { all } from 'better-all'

const { user, config, profile } = await all({
  async user() { return fetchUser() },
  async config() { return fetchConfig() },
  async profile() {
    return fetchProfile((await this.$.user).id)
  }
})

参考: github.com/shuding/bet…

1.3 防止 API 路由中的瀑布链

影响力: 关键 (2-10倍 提升)

在 API 路由和 Server Actions 中,即使此时还不 await 它们,也要立即启动独立的操作。

错误:config 等待 auth,data 等待两者

export async function GET(request: Request) {
  const session = await auth()
  const config = await fetchConfig()
  const data = await fetchData(session.user.id)
  return Response.json({ data, config })
}

正确:auth 和 config 立即启动

export async function GET(request: Request) {
  const sessionPromise = auth()
  const configPromise = fetchConfig()
  const session = await sessionPromise
  const [config, data] = await Promise.all([
    configPromise,
    fetchData(session.user.id)
  ])
  return Response.json({ data, config })
}

对于具有更复杂依赖链的操作,使用 better-all 自动最大化并行性(参见"基于依赖的并行化")。

1.4 对独立操作使用 Promise.all()

影响力: 关键 (2-10倍 提升)

当异步操作没有相互依赖关系时,使用 Promise.all() 并发执行它们。

错误:顺序执行,3 次往返

const user = await fetchUser()
const posts = await fetchPosts()
const comments = await fetchComments()

正确:并行执行,1 次往返

const [user, posts, comments] = await Promise.all([
  fetchUser(),
  fetchPosts(),
  fetchComments()
])

1.5 策略性 Suspense 边界

影响力: 高 (更快的首次绘制)

不要在异步组件中等待数据后再返回 JSX,而是使用 Suspense 边界在数据加载时更快地显示包装器 UI。

错误:包装器被数据获取阻塞

async function Page() {
  const data = await fetchData() // 阻塞整个页面
  
  return (
    <div>
      <div>Sidebar</div>
      <div>Header</div>
      <div>
        <DataDisplay data={data} />
      </div>
      <div>Footer</div>
    </div>
  )
}

即便只有中间部分需要数据,整个布局也会等待数据。

正确:包装器立即显示,数据流式传输

function Page() {
  return (
    <div>
      <div>Sidebar</div>
      <div>Header</div>
      <div>
        <Suspense fallback={<Skeleton />}>
          <DataDisplay />
        </Suspense>
      </div>
      <div>Footer</div>
    </div>
  )
}

async function DataDisplay() {
  const data = await fetchData() // 仅阻塞此组件
  return <div>{data.content}</div>
}

Sidebar、Header 和 Footer 立即渲染。只有 DataDisplay 等待数据。

替代方案:在组件间共享 promise

function Page() {
  // 立即开始获取,但不要 await
  const dataPromise = fetchData()
  
  return (
    <div>
      <div>Sidebar</div>
      <div>Header</div>
      <Suspense fallback={<Skeleton />}>
        <DataDisplay dataPromise={dataPromise} />
        <DataSummary dataPromise={dataPromise} />
      </Suspense>
      <div>Footer</div>
    </div>
  )
}

function DataDisplay({ dataPromise }: { dataPromise: Promise<Data> }) {
  const data = use(dataPromise) // 解包 promise
  return <div>{data.content}</div>
}

function DataSummary({ dataPromise }: { dataPromise: Promise<Data> }) {
  const data = use(dataPromise) // 复用同一个 promise
  return <div>{data.summary}</div>
}

两个组件共享同一个 promise,因此只会进行一次获取。布局立即渲染,而两个组件一起等待。

何时不使用此模式:

  • 布局决策所需的关键数据(影响定位)

  • 首屏(Above the fold)的 SEO 关键内容

  • Suspense 开销不值得的小型快速查询

  • 当你想要避免布局偏移(加载中 → 内容跳动)时

权衡: 更快的首次绘制 vs 潜在的布局偏移。根据你的 UX 优先级进行选择。


2. 打包体积优化

影响力: 关键

减少初始打包体积可以改善可交互时间 (TTI) 和最大内容绘制 (LCP)。

2.1 避免 Barrel 文件导入

影响力: 关键 (200-800ms 导入成本, 缓慢的构建)

直接从源文件导入而不是从 Barrel 文件导入,以避免加载数千个未使用的模块。Barrel 文件是重新导出多个模块的入口点(例如,执行 export * from './module'index.js)。

流行的图标和组件库在其入口文件中可能有 多达 10,000 个重导出。对于许多 React 包,仅导入它们就需要 200-800ms,这会影响开发速度和生产环境的冷启动。

为什么 tree-shaking 没有帮助: 当库被标记为外部(不打包)时,打包器无法对其进行优化。如果你将其打包以启用 tree-shaking,分析整个模块图会导致构建变得非常缓慢。

错误:导入整个库

import { Check, X, Menu } from 'lucide-react'
// 加载 1,583 个模块,开发环境额外耗时 ~2.8s
// 运行时成本:每次冷启动 200-800ms

import { Button, TextField } from '@mui/material'
// 加载 2,225 个模块,开发环境额外耗时 ~4.2s

正确:仅导入你需要的内容

import Check from 'lucide-react/dist/esm/icons/check'
import X from 'lucide-react/dist/esm/icons/x'
import Menu from 'lucide-react/dist/esm/icons/menu'
// 仅加载 3 个模块 (~2KB vs ~1MB)

import Button from '@mui/material/Button'
import TextField from '@mui/material/TextField'
// 仅加载你使用的内容

替代方案:Next.js 13.5+

// next.config.js - 使用 optimizePackageImports
module.exports = {
  experimental: {
    optimizePackageImports: ['lucide-react', '@mui/material']
  }
}

// 这样你可以保留符合人体工程学的 Barrel 导入:
import { Check, X, Menu } from 'lucide-react'
// 在构建时自动转换为直接导入

直接导入可提供 15-70% 更快的开发启动速度,28% 更快的构建速度,40% 更快的冷启动速度,以及显著更快的 HMR。

受影响的常见库:lucide-react, @mui/material, @mui/icons-material, @tabler/icons-react, react-icons, @headlessui/react, @radix-ui/react-*, lodash, ramda, date-fns, rxjs, react-use

参考: vercel.com/blog/how-we…

2.2 条件模块加载

影响力: 高 (仅在需要时加载大数据)

仅在功能激活时加载大数据或模块。

例子:懒加载动画帧

function AnimationPlayer({ enabled }: { enabled: boolean }) {
  const [frames, setFrames] = useState<Frame[] | null>(null)

  useEffect(() => {
    if (enabled && !frames && typeof window !== 'undefined') {
      import('./animation-frames.js')
        .then(mod => setFrames(mod.frames))
        .catch(() => setEnabled(false))
    }
  }, [enabled, frames])

  if (!frames) return <Skeleton />
  return <Canvas frames={frames} />
}

typeof window !== 'undefined' 检查可防止在 SSR 时打包此模块,从而优化服务端包体积和构建速度。

2.3 推迟非关键第三方库

影响力: 中 (水合后加载)

分析、日志记录和错误跟踪不会阻塞用户交互。应当在水合(Hydration)之后加载它们。

错误:阻塞初始包

import { Analytics } from '@vercel/analytics/react'

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        {children}
        <Analytics />
      </body>
    </html>
  )
}

正确:水合后加载

import dynamic from 'next/dynamic'

const Analytics = dynamic(
  () => import('@vercel/analytics/react').then(m => m.Analytics),
  { ssr: false }
)

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        {children}
        <Analytics />
      </body>
    </html>
  )
}

2.4 重型组件动态导入

影响力: 关键 (直接影响 TTI 和 LCP)

使用 next/dynamic 懒加载初始渲染不需要的大型组件。

错误:Monaco 随主 chunk 打包 ~300KB

import { MonacoEditor } from './monaco-editor'

function CodePanel({ code }: { code: string }) {
  return <MonacoEditor value={code} />
}

正确:Monaco 按需加载

import dynamic from 'next/dynamic'

const MonacoEditor = dynamic(
  () => import('./monaco-editor').then(m => m.MonacoEditor),
  { ssr: false }
)

function CodePanel({ code }: { code: string }) {
  return <MonacoEditor value={code} />
}

2.5 基于用户意图预加载

影响力: 中 (减少感知延迟)

在需要之前预加载繁重的包,以减少感知延迟。

例子:悬停/聚焦时预加载

function EditorButton({ onClick }: { onClick: () => void }) {
  const preload = () => {
    if (typeof window !== 'undefined') {
      void import('./monaco-editor')
    }
  }

  return (
    <button
      onMouseEnter={preload}
      onFocus={preload}
      onClick={onClick}
    >
      打开编辑器
    </button>
  )
}

例子:当功能标志启用时预加载

function FlagsProvider({ children, flags }: Props) {
  useEffect(() => {
    if (flags.editorEnabled && typeof window !== 'undefined') {
      void import('./monaco-editor').then(mod => mod.init())
    }
  }, [flags.editorEnabled])

  return <FlagsContext.Provider value={flags}>
    {children}
  </FlagsContext.Provider>
}

typeof window !== 'undefined' 检查可防止在 SSR 时打包预加载模块,从而优化服务端包体积和构建速度。


3. 服务端性能

影响力: 高

优化服务端渲染和数据获取可消除服务端瀑布流并减少响应时间。

3.1 跨请求 LRU 缓存

影响力: 高 (跨请求缓存)

React.cache() 仅在一个请求内有效。对于跨连续请求共享的数据(用户点击按钮 A 然后点击按钮 B),请使用 LRU 缓存。

实现:

import { LRUCache } from 'lru-cache'

const cache = new LRUCache<string, any>({
  max: 1000,
  ttl: 5 * 60 * 1000  // 5 分钟
})

export async function getUser(id: string) {
  const cached = cache.get(id)
  if (cached) return cached

  const user = await db.user.findUnique({ where: { id } })
  cache.set(id, user)
  return user
}

// 请求 1: DB 查询, 结果被缓存
// 请求 2: 缓存命中, 无 DB 查询

当顺序的用户操作在几秒钟内命中多个需要相同数据的端点时,请使用此方法。

配合 Vercel 的 Fluid Compute LRU 缓存特别有效,因为多个并发请求可以共享同一个函数实例和缓存。这意味着缓存可以跨请求持久化,而无需 Redis 等外部存储。

在传统 Serverless 中: 每次调用都是隔离运行的,因此请考虑使用 Redis 进行跨进而缓存。

参考: github.com/isaacs/node…

3.2 最小化 RSC 边界序列化

影响力: 高 (减少传输数据大小)

React Server/Client 边界会将所有对象属性序列化为字符串,并将它们嵌入到 HTML 响应和后续的 RSC 请求中。此序列化数据直接影响页面重量和加载时间,因此 大小非常重要。仅传递客户端实际使用的字段。

错误:序列化所有 50 个字段

async function Page() {
  const user = await fetchUser()  // 50 个字段
  return <Profile user={user} />
}

'use client'
function Profile({ user }: { user: User }) {
  return <div>{user.name}</div>  // 使用 1 个字段
}

正确:仅序列化 1 个字段

async function Page() {
  const user = await fetchUser()
  return <Profile name={user.name} />
}

'use client'
function Profile({ name }: { name: string }) {
  return <div>{name}</div>
}

3.3 通过组件组合并行获取数据

影响力: 关键 (消除服务端瀑布流)

React Server Components 在树中顺序执行。使用组合重构以并行化数据获取。

错误:Sidebar 等待 Page 的 fetch 完成

export default async function Page() {
  const header = await fetchHeader()
  return (
    <div>
      <div>{header}</div>
      <Sidebar />
    </div>
  )
}

async function Sidebar() {
  const items = await fetchSidebarItems()
  return <nav>{items.map(renderItem)}</nav>
}

正确:两者同时获取

async function Header() {
  const data = await fetchHeader()
  return <div>{data}</div>
}

async function Sidebar() {
  const items = await fetchSidebarItems()
  return <nav>{items.map(renderItem)}</nav>
}

export default function Page() {
  return (
    <div>
      <Header />
      <Sidebar />
    </div>
  )
}

使用 children prop 的替代方案:

async function Layout({ children }: { children: ReactNode }) {
  const header = await fetchHeader()
  return (
    <div>
      <div>{header}</div>
      {children}
    </div>
  )
}

async function Sidebar() {
  const items = await fetchSidebarItems()
  return <nav>{items.map(renderItem)}</nav>
}

export default function Page() {
  return (
    <Layout>
      <Sidebar />
    </Layout>
  )
}

3.4 使用 React.cache() 进行按请求去重

影响力: 中 (请求内去重)

使用 React.cache() 进行服务端请求去重。身份验证和数据库查询受益最大。

用法:

import { cache } from 'react'

export const getCurrentUser = cache(async () => {
  const session = await auth()
  if (!session?.user?.id) return null
  return await db.user.findUnique({
    where: { id: session.user.id }
  })
})

在单个请求中,对 getCurrentUser() 的多次调用只会执行一次查询。

3.5 使用 after() 处理非阻塞操作

影响力: 中 (更快的响应时间)

使用 Next.js 的 after() 来调度应在发送响应后执行的工作。这可以防止日志记录、分析和其他副作用阻塞响应。

错误:阻塞响应

import { logUserAction } from '@/app/utils'

export async function POST(request: Request) {
  // 执行变更
  await updateDatabase(request)
  
  // 日志记录阻塞了响应
  const userAgent = request.headers.get('user-agent') || 'unknown'
  await logUserAction({ userAgent })
  
  return new Response(JSON.stringify({ status: 'success' }), {
    status: 200,
    headers: { 'Content-Type': 'application/json' }
  })
}

正确:非阻塞

import { after } from 'next/server'
import { headers, cookies } from 'next/headers'
import { logUserAction } from '@/app/utils'

export async function POST(request: Request) {
  // 执行变更
  await updateDatabase(request)
  
  // 响应发送后记录日志
  after(async () => {
    const userAgent = (await headers()).get('user-agent') || 'unknown'
    const sessionCookie = (await cookies()).get('session-id')?.value || 'anonymous'
    
    logUserAction({ sessionCookie, userAgent })
  })
  
  return new Response(JSON.stringify({ status: 'success' }), {
    status: 200,
    headers: { 'Content-Type': 'application/json' }
  })
}

响应立即发送,而日志记录在后台发生。

常见用例:

  • 分析跟踪

  • 审计日志

  • 发送通知

  • 缓存失效

  • 清理任务

重要说明:

  • 即使响应失败或重定向,after() 也会运行

  • 适用于 Server Actions、Route Handlers 和 Server Components

参考: nextjs.org/docs/app/ap…


4. 客户端数据获取

影响力: 中高

自动去重和高效的数据获取模式减少了多余的网络请求。

4.1 去重全局事件监听器

影响力: 低 (N 个组件共用单个监听器)

使用 useSWRSubscription() 在组件实例之间共享全局事件监听器。

错误:N 个实例 = N 个监听器

function useKeyboardShortcut(key: string, callback: () => void) {
  useEffect(() => {
    const handler = (e: KeyboardEvent) => {
      if (e.metaKey && e.key === key) {
        callback()
      }
    }
    window.addEventListener('keydown', handler)
    return () => window.removeEventListener('keydown', handler)
  }, [key, callback])
}

当多次使用 useKeyboardShortcut 钩子时,每个实例都会注册一个新的监听器。

正确:N 个实例 = 1 个监听器

import useSWRSubscription from 'swr/subscription'

// 模块级 Map 跟踪每个键的回调
const keyCallbacks = new Map<string, Set<() => void>>()

function useKeyboardShortcut(key: string, callback: () => void) {
  // 在 Map 中注册此回调
  useEffect(() => {
    if (!keyCallbacks.has(key)) {
      keyCallbacks.set(key, new Set())
    }
    keyCallbacks.get(key)!.add(callback)

    return () => {
      const set = keyCallbacks.get(key)
      if (set) {
        set.delete(callback)
        if (set.size === 0) {
          keyCallbacks.delete(key)
        }
      }
    }
  }, [key, callback])

  useSWRSubscription('global-keydown', () => {
    const handler = (e: KeyboardEvent) => {
      if (e.metaKey && keyCallbacks.has(e.key)) {
        keyCallbacks.get(e.key)!.forEach(cb => cb())
      }
    }
    window.addEventListener('keydown', handler)
    return () => window.removeEventListener('keydown', handler)
  })
}

function Profile() {
  // 多个快捷键将共享同一个监听器
  useKeyboardShortcut('p', () => { /* ... */ }) 
  useKeyboardShortcut('k', () => { /* ... */ })
  // ...
}

4.2 使用 SWR 自动去重

影响力: 中高 (自动去重)

SWR 支持跨组件实例的请求去重、缓存和重新验证。

错误:无去重,每个实例都获取

function UserList() {
  const [users, setUsers] = useState([])
  useEffect(() => {
    fetch('/api/users')
      .then(r => r.json())
      .then(setUsers)
  }, [])
}

正确:多个实例共享一个请求

import useSWR from 'swr'

function UserList() {
  const { data: users } = useSWR('/api/users', fetcher)
}

对于不可变数据:

import { useImmutableSWR } from '@/lib/swr'

function StaticContent() {
  const { data } = useImmutableSWR('/api/config', fetcher)
}

对于变异 (Mutations):

import { useSWRMutation } from 'swr/mutation'

function UpdateButton() {
  const { trigger } = useSWRMutation('/api/user', updateUser)
  return <button onClick={() => trigger()}>更新</button>
}

参考: swr.vercel.app


5. 重渲染优化

影响力: 中

减少不必要的重渲染可最大限度地减少浪费的计算并提高 UI 响应能力。

5.1 推迟状态读取到使用点

影响力: 中 (避免不必要的订阅)

如果你只在回调中读取动态状态(搜索参数、localStorage),则不要订阅它。

错误:订阅所有 searchParams 更改

function ShareButton({ chatId }: { chatId: string }) {
  const searchParams = useSearchParams()

  const handleShare = () => {
    const ref = searchParams.get('ref')
    shareChat(chatId, { ref })
  }

  return <button onClick={handleShare}>分享</button>
}

正确:按需读取,无订阅

function ShareButton({ chatId }: { chatId: string }) {
  const handleShare = () => {
    const params = new URLSearchParams(window.location.search)
    const ref = params.get('ref')
    shareChat(chatId, { ref })
  }

  return <button onClick={handleShare}>分享</button>
}

5.2 提取为记忆化组件

影响力: 中 (启用提前返回)

将昂贵的工作提取到记忆化 (memoized) 组件中,以便在计算及以前提前返回。

错误:即使在加载时也计算头像

function Profile({ user, loading }: Props) {
  const avatar = useMemo(() => {
    const id = computeAvatarId(user)
    return <Avatar id={id} />
  }, [user])

  if (loading) return <Skeleton />
  return <div>{avatar}</div>
}

正确:加载时跳过计算

const UserAvatar = memo(function UserAvatar({ user }: { user: User }) {
  const id = useMemo(() => computeAvatarId(user), [user])
  return <Avatar id={id} />
})

function Profile({ user, loading }: Props) {
  if (loading) return <Skeleton />
  return (
    <div>
      <UserAvatar user={user} />
    </div>
  )
}

注意: 如果你的项目启用了 React Compiler,则无需使用 memo()useMemo() 进行手动记忆化。编译器会自动优化重渲染。

5.3 缩小 Effect 依赖范围

影响力: 低 (最小化 effect 重新运行)

指定原始值依赖项而不是对象,以最大限度地减少 effect 的重新运行。

错误:在任何用户字段更改时重新运行

useEffect(() => {
  console.log(user.id)
}, [user])

正确:仅在 id 更改时重新运行

useEffect(() => {
  console.log(user.id)
}, [user.id])

对于派生状态,在 effect 外部计算:

// 错误:在 width=767, 766, 765... 时运行
useEffect(() => {
  if (width < 768) {
    enableMobileMode()
  }
}, [width])

// 正确:仅在布尔值转换时运行
const isMobile = width < 768
useEffect(() => {
  if (isMobile) {
    enableMobileMode()
  }
}, [isMobile])

5.4 订阅派生状态

影响力: 中 (降低重渲染频率)

订阅派生的布尔状态而不是连续值,以降低重渲染频率。

错误:在每个像素变化时重渲染

function Sidebar() {
  const width = useWindowWidth()  // 持续更新
  const isMobile = width < 768
  return <nav className={isMobile ? 'mobile' : 'desktop'}>
}

正确:仅在布尔值更改时重渲染

function Sidebar() {
  const isMobile = useMediaQuery('(max-width: 767px)')
  return <nav className={isMobile ? 'mobile' : 'desktop'}>
}

5.5 使用函数式 setState 更新

影响力: 中 (防止闭包陷阱和不必要的回调重建)

当基于当前状态值更新状态时,使用 setState 的函数式更新形式,而不是直接引用状态变量。这可以防止闭包陷阱 (stale closures),消除不必要的依赖,并创建稳定的回调引用。

错误:需要 state 作为依赖

function TodoList() {
  const [items, setItems] = useState(initialItems)
  
  // 回调必须依赖 items,在每次 items 更改时重建
  const addItems = useCallback((newItems: Item[]) => {
    setItems([...items, ...newItems])
  }, [items])  // ❌ items 依赖导致重建
  
  // 如果忘记依赖,会有闭包陷阱风险
  const removeItem = useCallback((id: string) => {
    setItems(items.filter(item => item.id !== id))
  }, [])  // ❌ 缺少 items 依赖 - 将使用陈旧的 items!
  
  return <ItemsEditor items={items} onAdd={addItems} onRemove={removeItem} />
}

第一个回调每次 items 更改时都会重建,这可能会导致子组件不必要地重渲染。第二个回调有一个闭包陷阱 bug——它将始终引用初始的 items 值。

正确:稳定的回调,无闭包陷阱

function TodoList() {
  const [items, setItems] = useState(initialItems)
  
  // 稳定的回调,从未重建
  const addItems = useCallback((newItems: Item[]) => {
    setItems(curr => [...curr, ...newItems])
  }, [])  // ✅ 不需要依赖
  
  // 始终使用最新状态,无闭包陷阱风险
  const removeItem = useCallback((id: string) => {
    setItems(curr => curr.filter(item => item.id !== id))
  }, [])  // ✅ 安全且稳定
  
  return <ItemsEditor items={items} onAdd={addItems} onRemove={removeItem} />
}

好处:

  1. 稳定的回调引用 - 状态更改时无需重建回调

  2. 无闭包陷阱 - 始终对最新状态值进行操作

  3. 更少的依赖 - 简化了依赖数组并减少了内存泄漏

  4. 防止错误 - 消除了 React 闭包 bug 的最常见来源

何时使用函数式更新:

  • 任何依赖于当前状态值的 setState

  • 在需要 state 的 useCallback/useMemo 内部

  • 引用 state 的事件处理程序

  • 更新 state 的异步操作

何时直接更新是可以的:

  • 将 state 设置为静态值:setCount(0)

  • 仅从 props/参数设置 state:setName(newName)

  • State 不依赖于先前的值

注意: 如果你的项目启用了 React Compiler,编译器可以自动优化某些情况,但仍建议使用函数式更新以确保证正确性并防止闭包陷阱 bug。

5.6 使用惰性状态初始化

影响力: 中 (每次渲染都浪费计算)

将函数传递给 useState 用于昂贵的初始值。如果不使用函数形式,初始化程序将在每次渲染时运行,即使该值仅使用一次。

错误:每次渲染都运行

function FilteredList({ items }: { items: Item[] }) {
  // buildSearchIndex() 在每次渲染时运行,即使在初始化之后
  const [searchIndex, setSearchIndex] = useState(buildSearchIndex(items))
  const [query, setQuery] = useState('')
  
  // 当 query 更改时,buildSearchIndex 再次不必要地运行
  return <SearchResults index={searchIndex} query={query} />
}

function UserProfile() {
  // JSON.parse 在每次渲染时运行
  const [settings, setSettings] = useState(
    JSON.parse(localStorage.getItem('settings') || '{}')
  )
  
  return <SettingsForm settings={settings} onChange={setSettings} />
}

正确:仅运行一次

function FilteredList({ items }: { items: Item[] }) {
  // buildSearchIndex() 仅在初始渲染时运行
  const [searchIndex, setSearchIndex] = useState(() => buildSearchIndex(items))
  const [query, setQuery] = useState('')
  
  return <SearchResults index={searchIndex} query={query} />
}

function UserProfile() {
  // JSON.parse 仅在初始渲染时运行
  const [settings, setSettings] = useState(() => {
    const stored = localStorage.getItem('settings')
    return stored ? JSON.parse(stored) : {}
  })
  
  return <SettingsForm settings={settings} onChange={setSettings} />
}

当从 localStorage/sessionStorage 计算初始值、构建数据结构(索引、Map)、从 DOM 读取或执行繁重的转换是,请使用惰性初始化。

对于简单的原始值 (useState(0))、直接引用 (useState(props.value)) 或廉价的字面量 (useState({})),函数形式是不必要的。

5.7 对非紧急更新使用 Transitions

影响力: 中 (保持 UI 响应及)

将频繁的、非紧急的状态更新标记为 transitions,以保持 UI 响应能力。

错误:每次滚动都阻塞 UI

function ScrollTracker() {
  const [scrollY, setScrollY] = useState(0)
  useEffect(() => {
    const handler = () => setScrollY(window.scrollY)
    window.addEventListener('scroll', handler, { passive: true })
    return () => window.removeEventListener('scroll', handler)
  }, [])
}

正确:非阻塞更新

import { startTransition } from 'react'

function ScrollTracker() {
  const [scrollY, setScrollY] = useState(0)
  useEffect(() => {
    const handler = () => {
      startTransition(() => setScrollY(window.scrollY))
    }
    window.addEventListener('scroll', handler, { passive: true })
    return () => window.removeEventListener('scroll', handler)
  }, [])
}

6. 渲染性能

影响力: 中

优化渲染过程可减少浏览器需要做的工作。

6.1 动画化 SVG 包装器而非 SVG 元素

影响力: 低 (启用硬件加速)

许多浏览器不支持对 SVG 元素的 CSS3 动画进行硬件加速。将 SVG 包装在 <div> 中并对包装器进行动画处理。

错误:直接动画化 SVG - 无硬件加速

function LoadingSpinner() {
  return (
    <svg 
      className="animate-spin"
      width="24" 
      height="24" 
      viewBox="0 0 24 24"
    >
      <circle cx="12" cy="12" r="10" stroke="currentColor" />
    </svg>
  )
}

正确:动画化包装器 div - 硬件加速

function LoadingSpinner() {
  return (
    <div className="animate-spin">
      <svg 
        width="24" 
        height="24" 
        viewBox="0 0 24 24"
      >
        <circle cx="12" cy="12" r="10" stroke="currentColor" />
      </svg>
    </div>
  )
}

这适用于所有 CSS 变换和过渡(transform, opacity, translate, scale, rotate)。包装器 div 允许浏览器使用 GPU 加速来实现更流畅的动画。

6.2 长列表使用 CSS content-visibility

影响力: 高 (更快的首次渲染)

应用 content-visibility: auto 以推迟屏幕外渲染。

CSS:

.message-item {
  content-visibility: auto;
  contain-intrinsic-size: 0 80px;
}

例子:

function MessageList({ messages }: { messages: Message[] }) {
  return (
    <div className="overflow-y-auto h-screen">
      {messages.map(msg => (
        <div key={msg.id} className="message-item">
          <Avatar user={msg.author} />
          <div>{msg.content}</div>
        </div>
      ))}
    </div>
  )
}

对于 1000 条消息,浏览器会跳过 ~990 个屏幕外项目的布局/绘制(首次渲染快 10 倍)。

6.3 提升静态 JSX 元素

影响力: 低 (避免重新创建)

将静态 JSX 提取到组件外部以避免重新创建。

错误:每次渲染都重新创建元素

function LoadingSkeleton() {
  return <div className="animate-pulse h-20 bg-gray-200" />
}

function Container() {
  return (
    <div>
      {loading && <LoadingSkeleton />}
    </div>
  )
}

正确:复用相同元素

const loadingSkeleton = (
  <div className="animate-pulse h-20 bg-gray-200" />
)

function Container() {
  return (
    <div>
      {loading && loadingSkeleton}
    </div>
  )
}

这对于大型和静态的 SVG 节点特别有用,因为在每次渲染时重新创建它们可能会很昂贵。

注意: 如果你的项目启用了 React Compiler,编译器会自动提升静态 JSX 元素并优化组件重渲染,使得手动提升变得不必要。

6.4 优化 SVG 精度

影响力: 低 (减小文件大小)

降低 SVG 坐标精度以减小文件大小。最佳精度取决于 viewBox 大小,但在一般情况下,应考虑降低精度。

错误:过高的精度

<path d="M 10.293847 20.847362 L 30.938472 40.192837" />

正确:1 位小数

<path d="M 10.3 20.8 L 30.9 40.2" />

使用 SVGO 自动化:

npx svgo --precision=1 --multipass icon.svg

6.5 无闪烁防止水合不匹配

影响力: 中 (避免视觉闪烁和水合错误)

当渲染依赖于客户端存储(localStorage, cookies)的内容时,通过注入一个同步脚本在 React 水合之前更新 DOM,以避免 SSR 中断和水合后的闪烁。

错误:破坏 SSR

function ThemeWrapper({ children }: { children: ReactNode }) {
  // localStorage 在服务器上不可用 - 抛出错误
  const theme = localStorage.getItem('theme') || 'light'
  
  return (
    <div className={theme}>
      {children}
    </div>
  )
}

服务端渲染将失败,因为 localStorage 未定义。

错误:视觉闪烁

function ThemeWrapper({ children }: { children: ReactNode }) {
  const [theme, setTheme] = useState('light')
  
  useEffect(() => {
    // 在水合后运行 - 导致可见的闪烁
    const stored = localStorage.getItem('theme')
    if (stored) {
      setTheme(stored)
    }
  }, [])
  
  return (
    <div className={theme}>
      {children}
    </div>
  )
}

组件首先使用默认值(light)渲染,然后在水合后更新,导致不正确内容的可见闪烁。

正确:无闪烁,无水合不匹配

function ThemeWrapper({ children }: { children: ReactNode }) {
  return (
    <>
      <div id="theme-wrapper">
        {children}
      </div>
      <script
        dangerouslySetInnerHTML={{
          __html: `
            (function() {
              try {
                var theme = localStorage.getItem('theme') || 'light';
                var el = document.getElementById('theme-wrapper');
                if (el) el.className = theme;
              } catch (e) {}
            })();
          `,
        }}
      />
    </>
  )
}

内联脚本在显示元素之前同步执行,确保 DOM 已经具有正确的值。无闪烁,无水合不匹配。

此模式对于主题切换、用户偏好、身份验证状态以及任何应立即渲染而不闪烁默认值的仅客户端数据特别有用。

6.6 使用 Activity 组件进行显示/隐藏

影响力: 中 (保留状态/DOM)

使用 React 的 <Activity> 来为频繁切换可见性的昂贵组件保留状态/DOM。

用法:

import { Activity } from 'react'

function Dropdown({ isOpen }: Props) {
  return (
    <Activity mode={isOpen ? 'visible' : 'hidden'}>
      <ExpensiveMenu />
    </Activity>
  )
}

避免昂贵的重渲染和状态丢失。

6.7 使用显式条件渲染

影响力: 低 (防止渲染 0 或 NaN)

当条件可能为 0NaN 或其他会渲染的假值时,使用显式三元运算符 (? :) 而不是 && 进行条件渲染。

错误:当 count 为 0 时渲染 "0"

function Badge({ count }: { count: number }) {
  return (
    <div>
      {count && <span className="badge">{count}</span>}
    </div>
  )
}

// 当 count = 0, 渲染: <div>0</div>
// 当 count = 5, 渲染: <div><span class="badge">5</span></div>

正确:当 count 为 0 时不渲染任何内容

function Badge({ count }: { count: number }) {
  return (
    <div>
      {count > 0 ? <span className="badge">{count}</span> : null}
    </div>
  )
}

// 当 count = 0, 渲染: <div></div>
// 当 count = 5, 渲染: <div><span class="badge">5</span></div>

7. JavaScript 性能

影响力: 中低

对热路径的微优化可以累积成有意义的改进。

7.1 批量 DOM CSS 更改

影响力: 中 (减少重排/重绘)

避免通过一次修改一个属性的方式更改样式。通过类或 cssText 将多个 CSS 更改组合在一起,以最大程度地减少浏览器重排 (reflows)。

错误:多次重排

function updateElementStyles(element: HTMLElement) {
  // 每一行都会触发一次重排
  element.style.width = '100px'
  element.style.height = '200px'
  element.style.backgroundColor = 'blue'
  element.style.border = '1px solid black'
}

正确:添加类 - 单次重排

// CSS 文件
.highlighted-box {
  width: 100px;
  height: 200px;
  background-color: blue;
  border: 1px solid black;
}

// JavaScript
function updateElementStyles(element: HTMLElement) {
  element.classList.add('highlighted-box')
}

正确:改变 cssText - 单次重排

function updateElementStyles(element: HTMLElement) {
  element.style.cssText = `
    width: 100px;
    height: 200px;
    background-color: blue;
    border: 1px solid black;
  `
}

React 例子:

// 错误:逐个更改样式
function Box({ isHighlighted }: { isHighlighted: boolean }) {
  const ref = useRef<HTMLDivElement>(null)
  
  useEffect(() => {
    if (ref.current && isHighlighted) {
      ref.current.style.width = '100px'
      ref.current.style.height = '200px'
      ref.current.style.backgroundColor = 'blue'
    }
  }, [isHighlighted])
  
  return <div ref={ref}>内容</div>
}

// 正确:切换类
function Box({ isHighlighted }: { isHighlighted: boolean }) {
  return (
    <div className={isHighlighted ? 'highlighted-box' : ''}>
      内容
    </div>
  )
}

尽可能使用 CSS 类而不是内联样式。类会被浏览器缓存,并提供更好的关注点分离。

7.2 为重复查找构建索引 Map

影响力: 中低 (1M 操作 -> 2K 操作)

同一键的多次 .find() 调用应使用 Map。

错误 (每次查找 O(n)):

function processOrders(orders: Order[], users: User[]) {
  return orders.map(order => ({
    ...order,
    user: users.find(u => u.id === order.userId)
  }))
}

正确 (每次查找 O(1)):

function processOrders(orders: Order[], users: User[]) {
  const userById = new Map(users.map(u => [u.id, u]))

  return orders.map(order => ({
    ...order,
    user: userById.get(order.userId)
  }))
}

构建一次 Map (O(n)),然后所有查找都是 O(1)。

对于 1000 个订单 × 1000 个用户:100万次操作 → 2000 次操作。

7.3 在循环中缓存属性访问

影响力: 中低 (减少查找)

在热路径中缓存对象属性查找。

错误:3 次查找 × N 次迭代

for (let i = 0; i < arr.length; i++) {
  process(obj.config.settings.value)
}

正确:总过 1 次查找

const value = obj.config.settings.value
const len = arr.length
for (let i = 0; i < len; i++) {
  process(value)
}

7.4 缓存重复函数调用

影响力: 中 (避免冗余计算)

当在渲染期间使用相同的输入重复调用相同的函数时,使用模块级 Map 缓存函数结果。

错误:冗余计算

function ProjectList({ projects }: { projects: Project[] }) {
  return (
    <div>
      {projects.map(project => {
        // slugify() 对相同的项目名称调用了 100+ 次
        const slug = slugify(project.name)
        
        return <ProjectCard key={project.id} slug={slug} />
      })}
    </div>
  )
}

正确:缓存结果

// 模块级缓存
const slugifyCache = new Map<string, string>()

function cachedSlugify(text: string): string {
  if (slugifyCache.has(text)) {
    return slugifyCache.get(text)!
  }
  const result = slugify(text)
  slugifyCache.set(text, result)
  return result
}

function ProjectList({ projects }: { projects: Project[] }) {
  return (
    <div>
      {projects.map(project => {
        // 每个唯一的项目名称仅计算一次
        const slug = cachedSlugify(project.name)
        
        return <ProjectCard key={project.id} slug={slug} />
      })}
    </div>
  )
}

单值函数的更简单模式:

let isLoggedInCache: boolean | null = null

function isLoggedIn(): boolean {
  if (isLoggedInCache !== null) {
    return isLoggedInCache
  }
  
  isLoggedInCache = document.cookie.includes('auth=')
  return isLoggedInCache
}

// 身份验证更改时清除缓存
function onAuthChange() {
  isLoggedInCache = null
}

使用 Map(而不是 hook),这样它可以在任何地方工作:工具函数、事件处理程序,而不仅仅是 React 组件。

参考: vercel.com/blog/how-we…

7.5 缓存 Storage API 调用

影响力: 中低 (减少昂贵的 I/O)

localStorage, sessionStoragedocument.cookie 是同步且昂贵的。在内存中缓存读取。

错误:每次调用都读取存储

function getTheme() {
  return localStorage.getItem('theme') ?? 'light'
}
// 调用 10 次 = 10 次存储读取

正确:Map 缓存

const storageCache = new Map<string, string | null>()

function getLocalStorage(key: string) {
  if (!storageCache.has(key)) {
    storageCache.set(key, localStorage.getItem(key))
  }
  return storageCache.get(key)
}

function setLocalStorage(key: string, value: string) {
  localStorage.setItem(key, value)
  storageCache.set(key, value)  // 保持缓存同步
}

使用 Map(而不是 hook),这样它可以在任何地方工作:工具函数、事件处理程序,而不仅仅是 React 组件。

Cookie 缓存:

let cookieCache: Record<string, string> | null = null

function getCookie(name: string) {
  if (!cookieCache) {
    cookieCache = Object.fromEntries(
      document.cookie.split('; ').map(c => c.split('='))
    )
  }
  return cookieCache[name]
}

重要:在外部更改时失效

window.addEventListener('storage', (e) => {
  if (e.key) storageCache.delete(e.key)
})

document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'visible') {
    storageCache.clear()
  }
})

如果存储可以在外部更改(另一个标签页、服务器设置的 cookie),请使缓存失效。

7.6合并多个数组迭代

影响力: 中低 (减少迭代)

多个 .filter().map() 调用会多次迭代数组。合并为一个循环。

错误:3 次迭代

const admins = users.filter(u => u.isAdmin)
const testers = users.filter(u => u.isTester)
const inactive = users.filter(u => !u.isActive)

正确:1 次迭代

const admins: User[] = []
const testers: User[] = []
const inactive: User[] = []

for (const user of users) {
  if (user.isAdmin) admins.push(user)
  if (user.isTester) testers.push(user)
  if (!user.isActive) inactive.push(user)
}

7.7 数组比较前先检查长度

影响力: 中高 (避免长度不同时的昂贵操作)

在通过昂贵操作(排序、深度相等、序列化)比较数组时,先检查长度。如果长度不同,数组就不可能相等。

在实际应用中,当比较运行在热路径(事件处理程序、渲染循环)中时,此优化通过尤为有价值。

错误:总是运行昂贵的比较

function hasChanges(current: string[], original: string[]) {
  // 即使长度不同,也总是进行排序和连接
  return current.sort().join() !== original.sort().join()
}

即使 current.length 是 5 而 original.length 是 100,也会运行两次 O(n log n) 排序。连接数组和比较字符串也有开销。

正确 (先进行 O(1) 长度检查):

function hasChanges(current: string[], original: string[]) {
  // 如果长度不同,提前返回
  if (current.length !== original.length) {
    return true
  }
  // 仅当长度匹配时才排序/连接
  const currentSorted = current.toSorted()
  const originalSorted = original.toSorted()
  for (let i = 0; i < currentSorted.length; i++) {
    if (currentSorted[i] !== originalSorted[i]) {
      return true
    }
  }
  return false
}

这种新方法更高效,因为:

  • 当长度不同时,它避免了排序和连接数组的开销

  • 它避免了消耗内存来连接字符串(对于大数组尤其重要)

  • 它避免了修改原始数组

  • 发现差异时提前返回

7.8 函数提前返回

影响力: 中低 (避免不必要的计算)

确当定结果时提前返回,以跳过不必要的处理。

错误:即使找到答案也处理所有项目

function validateUsers(users: User[]) {
  let hasError = false
  let errorMessage = ''
  
  for (const user of users) {
    if (!user.email) {
      hasError = true
      errorMessage = 'Email required'
    }
    if (!user.name) {
      hasError = true
      errorMessage = 'Name required'
    }
    // 即使发现错误也继续检查所有用户
  }
  
  return hasError ? { valid: false, error: errorMessage } : { valid: true }
}

正确:一发现错误立即返回

function validateUsers(users: User[]) {
  for (const user of users) {
    if (!user.email) {
      return { valid: false, error: 'Email required' }
    }
    if (!user.name) {
      return { valid: false, error: 'Name required' }
    }
  }

  return { valid: true }
}

7.9 提升 RegExp 创建

影响力: 中低 (避免重新创建)

不要在 render 内部创建 RegExp。提升到模块作用域或使用 useMemo() 进行记忆化。

错误:每次渲染都创建新的 RegExp

function Highlighter({ text, query }: Props) {
  const regex = new RegExp(`(${query})`, 'gi')
  const parts = text.split(regex)
  return <>{parts.map((part, i) => ...)}</>
}

正确:记忆化或提升

const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/

function Highlighter({ text, query }: Props) {
  const regex = useMemo(
    () => new RegExp(`(${escapeRegex(query)})`, 'gi'),
    [query]
  )
  const parts = text.split(regex)
  return <>{parts.map((part, i) => ...)}</>
}

警告:全局 regex 具有可变状态

const regex = /foo/g
regex.test('foo')  // true, lastIndex = 3
regex.test('foo')  // false, lastIndex = 0

全局 regex (/g) 具有可变的 lastIndex 状态。

7.10 使用循环求最小/最大值而非排序

影响力: 低 (O(n) 而非 O(n log n))

查找最小或最大元素只需要遍历数组一次。排序是浪费且更慢的。

错误 (O(n log n) - 排序以查找最新):

interface Project {
  id: string
  name: string
  updatedAt: number
}

function getLatestProject(projects: Project[]) {
  const sorted = [...projects].sort((a, b) => b.updatedAt - a.updatedAt)
  return sorted[0]
}

仅为了查找最大值而对整个数组进行排序。

错误 (O(n log n) - 排序以查找最旧和最新):

function getOldestAndNewest(projects: Project[]) {
  const sorted = [...projects].sort((a, b) => a.updatedAt - b.updatedAt)
  return { oldest: sorted[0], newest: sorted[sorted.length - 1] }
}

仅需要最小/最大值时仍然不必要地排序。

正确 (O(n) - 单次循环):

function getLatestProject(projects: Project[]) {
  if (projects.length === 0) return null
  
  let latest = projects[0]
  
  for (let i = 1; i < projects.length; i++) {
    if (projects[i].updatedAt > latest.updatedAt) {
      latest = projects[i]
    }
  }
  
  return latest
}

function getOldestAndNewest(projects: Project[]) {
  if (projects.length === 0) return { oldest: null, newest: null }
  
  let oldest = projects[0]
  let newest = projects[0]
  
  for (let i = 1; i < projects.length; i++) {
    if (projects[i].updatedAt < oldest.updatedAt) oldest = projects[i]
    if (projects[i].updatedAt > newest.updatedAt) newest = projects[i]
  }
  
  return { oldest, newest }
}

单次遍历数组,无复制,无排序。

替代方案:Math.min/Math.max 用于小数组

const numbers = [5, 2, 8, 1, 9]
const min = Math.min(...numbers)
const max = Math.max(...numbers)

这对于小数组有效,但对于非常大的数组,由于展开运算符的限制,可能会更慢。为了可靠性,建议使用循环方法。

7.11 使用 Set/Map 进行 O(1) 查找

影响力: 中低 (O(n) -> O(1))

将数组转换为 Set/Map 以进行重复的成员身份检查。

错误 (每次检查 O(n)):

const allowedIds = ['a', 'b', 'c', ...]
items.filter(item => allowedIds.includes(item.id))

正确 (每次检查 O(1)):

const allowedIds = new Set(['a', 'b', 'c', ...])
items.filter(item => allowedIds.has(item.id))

7.12 使用 toSorted() 代替 sort() 以保证不可变性

影响力: 中高 (防止 React 状态中的变异 bug)

.sort() 会原地修改数组,这可能会导致 React 状态和 props 出现 bug。使用 .toSorted() 创建一个新的排序数组而不进行变异。

错误:修改原始数组

function UserList({ users }: { users: User[] }) {
  // 修改了 users prop 数组!
  const sorted = useMemo(
    () => users.sort((a, b) => a.name.localeCompare(b.name)),
    [users]
  )
  return <div>{sorted.map(renderUser)}</div>
}

正确:创建新数组

function UserList({ users }: { users: User[] }) {
  // 创建新的排序数组,原始数组未更改
  const sorted = useMemo(
    () => users.toSorted((a, b) => a.name.localeCompare(b.name)),
    [users]
  )
  return <div>{sorted.map(renderUser)}</div>
}

为什么这在 React 中很重要:

  1. Props/state 变异打破了 React 的不可变性模型 - React 期望 props 和 state 被视为只读

  2. 导致闭包陷阱 bug - 在闭包(回调、effects)内修改数组可能导致意外行为

浏览器支持:旧版浏览器回退

// 旧版浏览器的回退
const sorted = [...items].sort((a, b) => a.value - b.value)

.toSorted() 在所有现代浏览器(Chrome 110+, Safari 16+, Firefox 115+, Node.js 20+)中均可用。对于旧环境,使用展开运算符。

其他不可变数组方法:

  • .toSorted() - 不可变排序

  • .toReversed() - 不可变反转

  • .toSpliced() - 不可变拼接

  • .with() - 不可变元素替换


8. 高级模式

影响力: 低

针对需要谨慎实现的特定情况的高级模式。

8.1 在 Refs 中存储事件处理程序

影响力: 低 (稳定的订阅)

当在不应因回调更改而重新订阅的 effect 中使用时,将回调存储在 refs 中。

错误:每次渲染都重新订阅

function useWindowEvent(event: string, handler: () => void) {
  useEffect(() => {
    window.addEventListener(event, handler)
    return () => window.removeEventListener(event, handler)
  }, [event, handler])
}

正确:稳定的订阅

import { useEffectEvent } from 'react'

function useWindowEvent(event: string, handler: () => void) {
  const onEvent = useEffectEvent(handler)

  useEffect(() => {
    window.addEventListener(event, onEvent)
    return () => window.removeEventListener(event, onEvent)
  }, [event])
}

替代方案:如果你使用的是最新版 React,请使用 useEffectEvent

useEffectEvent 为相同的模式提供了更清晰的 API:它创建一个稳定的函数引用,该引用始终调用处理程序的最新版本。

8.2 使用 useLatest 获取稳定的回调 Refs

影响力: 低 (防止 effect 重新运行)

在不将值添加到依赖数组的情况下访问回调中的最新值。防止 effect 重新运行,同时避免闭包陷阱。

实现:

function useLatest<T>(value: T) {
  const ref = useRef(value)
  useEffect(() => {
    ref.current = value
  }, [value])
  return ref
}

错误:在每次回调更改时重新运行 effect

function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
  const [query, setQuery] = useState('')

  useEffect(() => {
    const timeout = setTimeout(() => onSearch(query), 300)
    return () => clearTimeout(timeout)
  }, [query, onSearch])
}

正确:稳定的 effect,新鲜的回调

function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
  const [query, setQuery] = useState('')
  const onSearchRef = useLatest(onSearch)

  useEffect(() => {
    const timeout = setTimeout(() => onSearchRef.current(query), 300)
    return () => clearTimeout(timeout)
  }, [query])
}

参考资料

  1. react.dev
  2. nextjs.org
  3. swr.vercel.app
  4. github.com/shuding/bet…
  5. github.com/isaacs/node…
  6. vercel.com/blog/how-we…
  7. vercel.com/blog/how-we…
❌