前端面试题手册

梳理高频技术问题,帮助你按主题复习和查漏补缺。

前端阅读 95月28日 03:39

什么是事件代理?原理、优缺点和应用场景是什么?

事件代理(事件委托)是利用事件冒泡机制,将子元素的事件监听器统一绑定到父元素上的一种模式。面试中常从原理、优缺点、边界问题、实战场景四个层面考察。核心原理DOM 事件流经历三个阶段:捕获阶段(从 window 向下传播到目标元素)→ 目标阶段(事件到达目标元素)→ 冒泡阶段(从目标元素向上传播回 window)。事件代理利用的就是冒泡阶段——子元素触发事件后,事件沿 DOM 树逐层向上传播,因此在父元素上可以统一捕获并处理。// 传统方式:每个子元素各自绑定,N 个元素需要 N 个监听器document.querySelectorAll('li').forEach(li => { li.addEventListener('click', handler);});// 事件代理:只在父元素绑定一次,无论多少子元素都只需 1 个监听器document.querySelector('ul').addEventListener('click', (e) => { if (e.target.matches('li')) { handler(e); }});优点减少内存占用:100 个按钮只需 1 个监听器,而非 100 个,显著降低内存消耗动态元素自动响应:新增的子元素无需重新绑定,天然具备事件响应能力,特别适合动态渲染的列表减少 DOM 操作:绑定和解绑只涉及父元素,降低与 DOM 的交互次数代码更易维护:事件处理逻辑集中在父元素,修改时只需改一处缺点不适用于不冒泡的事件:focus、blur、scroll、mouseenter/mouseleave 不冒泡,无法使用事件代理(可改用 focusin/focusout,它们冒泡)嵌套元素干扰判断:子元素内部还有子元素时,e.target 可能不是期望的目标元素非目标点击误触发:父元素区域内非目标元素的点击也会进入回调,需要手动过滤层级过深可能被拦截:冒泡链路中间如果调用了 stopPropagation(),事件无法到达代理层嵌套子元素干扰如何解决用 e.target.closest('li') 替代 e.target.matches('li')。closest 会沿 DOM 树向上查找最近匹配的祖先元素,即使点击的是 li 内部的 span 也能正确定位。而 matches 只检查元素自身,不向上查找。// matches 版本:点击 li 内的 span 会匹配失败ul.addEventListener('click', (e) => { if (e.target.matches('li')) handler(e); // 内部有 span 时失效});// closest 版本:点击 li 内的 span 仍能找到 liul.addEventListener('click', (e) => { const li = e.target.closest('li'); if (li) handler(e);});e.target 与 e.currentTarget 的区别e.target:实际触发事件的最深层元素(用户真正点击的那个元素)e.currentTarget:绑定监听器的元素,在事件代理中就是父元素代理场景下两者始终不同:e.currentTarget 是挂载监听器的父元素,e.target 是用户实际点击的子元素。理解这个区别是掌握事件代理的关键。实际应用场景列表/表格的行点击:导航菜单选中、数据表格行操作动态表单项:可增减的输入行、标签列表的添加与删除React 合成事件体系:React 17 前将所有事件代理到 document,17+ 代理到 root 节点,本质上就是事件代理思想在框架层的工程化实践事件代理 + 防抖:在滚动容器上代理子元素的点击,配合防抖避免误触就近委托是最佳实践:在最近的公共父元素上代理,而非一律挂载到 document 或 body,这样可以减少不必要的事件冒泡路径和回调触发次数。
前端阅读 335月28日 03:36

some、every、find、filter、map、forEach 有什么区别?

这 6 个方法是 JavaScript 数组最常用的迭代方法,面试几乎必考。核心区别在于返回值类型和是否短路,按返回值分三类记忆最清晰。一、遍历类(无返回值)forEach纯遍历,对每个元素执行回调,返回值永远是 undefined。不能中断:return 只跳过当前回调,break 语法不支持,想中途退出只能用 try/catch 抛异常(不推荐)不支持异步:回调里写 async/await 不会等待 Promise,因为 forEach 不关心返回值const list = [1, 2, 3];list.forEach(item => console.log(item)); // 1, 2, 3// return 只跳过当次,不会中断循环二、返回新数组map每个元素经回调映射后返回等长新数组,不改变原数组。const nums = [1, 2, 3];const doubled = nums.map(n => n * 2); // [2, 4, 6]filter返回满足条件的元素组成的新数组,长度可能小于原数组,不改变原数组。const nums = [1, 2, 3, 4, 5];const big = nums.filter(n => n > 3); // [4, 5]三、返回布尔值或单个元素find返回第一个满足条件的元素,找到即停止遍历(短路)。找不到返回 undefined。const users = [{id: 1, name: 'A'}, {id: 2, name: 'B'}];users.find(u => u.id === 2); // {id: 2, name: 'B'}some有任意一个满足条件就返回 true,找到即短路。全不满足返回 false。空数组返回 false。[1, 2, 3].some(n => n > 2); // true[1, 2, 3].some(n => n > 5); // false[].some(n => n > 0); // falseevery所有元素都满足条件才返回 true,遇到不满足即短路。空数组返回 true(空真逻辑 vacuous truth)。[1, 2, 3].every(n => n > 0); // true[1, 2, 3].every(n => n > 1); // false[].every(n => n > 0); // true(空真)四、对比速查表| 方法 | 返回值 | 是否短路 | 空数组返回 | 链式调用 | 修改原数组 ||------|--------|----------|-----------|---------|-----------|| forEach | undefined | 否 | undefined | 否 | 否 || map | 新数组 | 否 | [] | 是 | 否 || filter | 新数组 | 否 | [] | 是 | 否 || find | 单个元素/undefined | 是 | undefined | 否 | 否 || some | boolean | 是 | false | 否 | 否 || every | boolean | 是 | true | 否 | 否 |五、高频追问map 和 forEach 怎么选?需要返回新数组用 map,纯副作用(如 console.log、DOM 操作)用 forEach。关键区别:map 可链式调用,forEach 返回 undefined 不可链式。some 和 includes 有什么区别?includes(val) 判断数组是否包含某个具体值,用严格相等(===)比较some(fn) 判断是否有元素满足自定义条件includes 只能判断值存在性,some 可以写任意判断逻辑[1, 2, 3].includes(2); // true[1, 2, 3].some(n => n > 2); // true[{a: 1}].includes({a: 1}); // false(引用不同)[{a: 1}].some(o => o.a === 1); // true这些方法支持异步回调吗?都不原生支持。forEach 里写 async/await 不会等待 Promise resolve。需要异步迭代用 for...of + await 或 Promise.all + map。// 错误:forEach 不会等待 asyncids.forEach(async id => { const data = await fetch(id); // 并发执行,不会依次等待});// 正确方式1:for...offor (const id of ids) { const data = await fetch(id);}// 正确方式2:Promise.all + map(并行)const results = await Promise.all(ids.map(id => fetch(id)));find 和 filter 怎么选?只需第一个匹配用 find(性能更好,短路),需要所有匹配用 filter。reduce 为什么没列进来?reduce 是这 6 个方法的基础——map、filter、some、every、find 都可以用 reduce 实现。面试中常追问 reduce 的用法,但 reduce 更偏向"累加器"模式,功能更强大也更复杂,属于另一个考点的范畴。
前端阅读 325月28日 03:36

ES5 和 ES6 有什么区别?

ES6(ES2015)是 JavaScript 历史上最大的一次版本更新,面试中这道题考查的是你对 JS 语言演进的理解深度。回答的关键不是罗列特性,而是讲清楚每个变化解决了什么问题。变量声明:从 var 到 let/constES5 只有 var,存在两大问题:// 问题1:变量提升console.log(a); // undefined(不会报错,但容易出 bug)var a = 1;// 问题2:无块级作用域for (var i = 0; i < 3; i++) { setTimeout(() => console.log(i), 0); // 3, 3, 3}ES6 用 let/const 解决了这两个问题:// let 有块级作用域 + 暂时性死区console.log(b); // ReferenceError(声明前访问直接报错)let b = 1;for (let i = 0; i < 3; i++) { setTimeout(() => console.log(i), 0); // 0, 1, 2}// const 不可重新赋值(但对象属性仍可修改)const obj = { a: 1 };obj.a = 2; // OKobj = { a: 2 }; // TypeError面试要点:const 保证的是绑定不可变,不是值不可变。想冻结对象用 Object.freeze()。函数:箭头函数与 this 绑定ES5 中 this 指向取决于调用方式,经常需要 var self = this 或 .bind(this):// ES5var obj = { name: 'ES5', say: function() { var self = this; setTimeout(function() { console.log(self.name); // 必须用 self/cache }, 0); }};// ES6 — 箭头函数继承外层 thisconst obj2 = { name: 'ES6', say() { setTimeout(() => { console.log(this.name); // 直接用 this }, 0); }};注意:箭头函数没有自己的 arguments、super、new.target,不能用作构造函数。字符串:模板字符串// ES5var greeting = 'Hello, ' + name + '! You are ' + age + ' years old.';// ES6const greeting = `Hello, ${name}! You are ${age} years old.`;模板字符串支持多行、变量插值、标签模板,彻底告别字符串拼接。解构赋值与展开运算符// 对象解构const { name, age } = user;// 数组解构const [first, ...rest] = [1, 2, 3, 4]; // first=1, rest=[2,3,4]// 展开运算符 — 浅拷贝与合并const copy = [...arr];const merged = { ...defaults, ...config };解构让数据提取更简洁,展开运算符替代了 Object.assign 和 concat 的大多数场景。类与继承:class 语法// ES5 — 构造函数 + 原型链function Animal(name) { this.name = name;}Animal.prototype.speak = function() { return this.name + ' makes a sound';};// ES6 — class 语法class Animal { constructor(name) { this.name = name; } speak() { return `${this.name} makes a sound`; }}class Dog extends Animal { speak() { return `${this.name} barks`; }}class 本质是原型继承的语法糖,但有行为差异:内部默认严格模式、方法不可枚举、必须用 new 调用。模块系统:import/export// ES5 — CommonJS(Node.js)const module = require('./module');module.exports = { foo };// ES6 — ES Modulesimport { foo } from './module';export const bar = 1;export default function() {}ES Modules 是静态的,支持 Tree Shaking;CommonJS 是动态的,运行时加载。现代项目(Vite/Webpack)均以 ESM 为优先。异步编程:Promise 与 async/await// ES5 — 回调地狱getData(function(a) { getMore(a, function(b) { getEvenMore(b, function(c) { console.log(c); }); });});// ES6 — Promise 链式调用getData() .then(a => getMore(a)) .then(b => getEvenMore(b)) .then(c => console.log(c));// ES8 — async/await(同步写法)const a = await getData();const b = await getMore(a);const c = await getEvenMore(b);Promise 解决了回调地狱,async/await 让异步代码看起来像同步,是面试高频追问点。新数据结构与 API| 特性 | 用途 ||------|------|| Map | 键值对集合,键可以是任意类型(Object 的键只能是字符串/Symbol) || Set | 去重数组:[...new Set(arr)] || WeakMap/WeakSet | 键是弱引用,适合缓存和关联私有数据,不阻止 GC || Symbol | 创建唯一标识符,用于私有属性和内置协议 || Proxy/Reflect | 拦截对象操作(Vue 3 响应式核心) || Generator/Iterator | 可暂停函数,for...of 遍历统一接口 |追问ES6 之后还有什么重要的新特性?| 版本 | 关键特性 ||------|----------|| ES7 | Array.prototype.includes、指数运算符 ** || ES8 | async/await、Object.values/entries || ES9 | Promise.finally、异步迭代 for await...of || ES10 | flat/flatMap、Object.fromEntries || ES11 | ??(空值合并)、?.(可选链)、Promise.allSettled || ES12 | replaceAll、逻辑赋值 ||= &&= ??= || ES13 | at()、Object.hasOwn、Top-level await |let/const 和 var 最大的实际区别?块级作用域 — 解决 for 循环闭包问题暂时性死区 — 声明前访问报 ReferenceError,var 是 undefined不可重复声明 — 同一作用域内 let/const 不能重复声明同名变量const 不可重新赋值 — 但对象/数组内容仍可修改class 只是语法糖吗?基本是。class 编译后就是原型链模式(构造函数 + prototype + Object.create)。但有几个行为差异:class 内部默认严格模式class 方法不可枚举(for...in 遍历不到)只能用 new 调用(有 new.target 检查,直接调用报错)extends 内部用 Object.create 设置原型链,比 ES5 手动写更规范面试回答策略面试官问这道题,不是让你背特性列表。推荐的回答结构:一句话概括:ES6 让 JS 从脚本语言变成工程化语言按类别讲 3-4 个重点,每个说清楚"ES5 什么问题 → ES6 怎么解决"追问时深入:挑一个你最熟悉的特性展开(如 class 的原型链原理、Promise 的微任务机制)
前端阅读 405月28日 03:35

ES6 中的 Map 和原生的 Object 有什么区别?

Map 和 Object 都能存键值对,但 Map 是专门为"字典"场景设计的,解决了 Object 做字典时的几个硬伤。键的类型:Object 的 key 只能是字符串或 Symbol,数字 1 和字符串 "1" 是同一个 key。Map 的 key 可以是任意类型——对象、函数、NaN 都行,用 SameValueZero 算法比较(NaN 等于 NaN)。原型链污染:Object 有原型链,obj.__proto__、obj.toString 这类属性名会冲突。Object.create(null) 能规避,但写法不直觉。Map 天然没有这个问题。大小:Map 有 size 属性直接取。Object 要 Object.keys(obj).length。顺序:Map 严格按插入顺序迭代。Object 在 ES6 后基本也按插入顺序,但整数 key 会被提前排列,容易踩坑。遍历:Map 直接 for...of 或 forEach。Object 要先转数组(Object.entries())或用 for...in(还会遍历原型链)。性能:频繁增删键值对时 Map 更快。Object 在 V8 中对连续整数 key 有快属性优化,但这种优化对字典场景没帮助。序列化:JSON.stringify 能直接处理 Object。Map 不行,需要先转成数组或对象。const m = new Map();const obj = {};m.set(obj, 'value'); // 对象做 key,Object 做不到m.set(1, 'num');m.set('1', 'str'); // 1 和 '1' 是不同 keyconsole.log(m.size); // 3一句话:需要字典数据结构时优先用 Map,需要 JSON 序列化或简单配置对象时用 Object。追问WeakMap 和 Map 有什么区别?WeakMap 的 key 必须是对象,值任意。key 是弱引用——被 GC 回收后对应条目自动消失。不可迭代(没有 size、forEach、keys()),因为条目随时可能被回收。| | Map | WeakMap ||---|---|---|| key 类型 | 任意 | 仅对象 || 引用方式 | 强引用 | 弱引用 || 可迭代 | 是 | 否 || size | 有 | 无 || 典型场景 | 字典存储 | 关联私有数据 |项目里 WeakMap 用在什么地方?Vue 3 的响应式系统用 WeakMap 存对象 → 依赖关系,对象被销毁时依赖自动清理,不会内存泄漏。另一个常见场景:给 DOM 节点绑定额外数据,节点移除后数据自动释放。Object.create(null) 能替代 Map 吗?能解决原型链污染问题,但解决不了键类型限制、size 获取、顺序保证、迭代便利性。Map 是更完整的方案。Map 的 key 用 NaN 会怎样?Map 用 SameValueZero 算法比较键,NaN 等于 NaN,所以 NaN 可以正常作为 key,且不会重复。Object 中 NaN 作为 key 会被转成字符串 "NaN",行为一致,但 Map 的语义更明确。
前端阅读 315月28日 03:34

前端模块规范有哪些?模块如何异步加载?

JavaScript 模块化经历了从全局变量污染到标准化模块系统的漫长演进,不同规范解决了不同阶段的问题。IIFE:最早的模块化尝试在规范出现之前,开发者用立即执行函数表达式创建独立作用域:var MyModule = (function() { var privateVar = 'hidden'; function privateMethod() { return privateVar; } return { publicMethod: function() { return privateMethod(); } };})();IIFE 通过闭包隔离内部变量,只暴露全局接口。缺点是依赖关系靠全局变量传递,script 标签顺序一旦出错就全局崩溃。CommonJS:Node.js 的选择// math.jsmodule.exports = { add: (a, b) => a + b };// main.jsconst { add } = require('./math');console.log(add(1, 2));CommonJS 用 require 同步加载模块,module.exports 导出。核心特征:运行时加载,require 执行时才确定依赖;输出值的拷贝,模块内部变化不会影响已导入的值;this 指向当前模块。同步加载在服务端不是问题——文件在本地磁盘,读取极快。但在浏览器中,模块要从网络下载,同步阻塞会让页面卡死。AMD:为浏览器而生define(['jquery', './utils'], function($, utils) { return { init: function() { $('body').append(utils.format()); } };});AMD(Asynchronous Module Definition)用 define 声明模块和依赖,依赖在回调执行前全部加载完成。RequireJS 是最知名的实现。依赖必须前置声明,不管是否马上用到都会先加载。CMD:依赖就近define(function(require, exports, module) { var $ = require('jquery'); // 用到时才加载 exports.init = function() { $('body').append('hello'); };});CMD 由 SeaJS 推广,和 AMD 的核心区别是依赖就近声明——只有执行到 require 时才加载对应模块。两者在浏览器端都已退出主流,被 ESModule 取代。UMD:兼容方案(function(root, factory) { if (typeof exports === 'object') module.exports = factory(); else if (typeof define === 'function') define(factory); else root.MyModule = factory();})(this, function() { return { version: '1.0' };});UMD 判断运行环境,兼容 CommonJS、AMD 和全局变量三种方式。库开发者打包时常用,确保代码在任何环境都能正常加载。ESModule:统一标准// math.jsexport const add = (a, b) => a + b;// main.jsimport { add } from './math.js';ESModule 是 JavaScript 语言层面的模块标准。与 CommonJS 的关键区别:编译时静态分析,import/export 必须在顶层,引擎在执行前就确定依赖关系;输出值的引用,模块内部变化会同步反映到导入方;顶层 this 为 undefined;天然支持 Tree-Shaking。模块异步加载异步加载的核心场景是按需加载——首屏不需要的代码延迟到使用时再请求,减少初始包体积。ESModule 动态导入:import() 返回 Promise,可在任意位置调用,是实现代码分割和路由懒加载的标准方式:button.addEventListener('click', async () => { const { openDialog } = await import('./dialog.js'); openDialog();});AMD 异步加载:require([deps], callback) 本身就是异步的,依赖列表中的模块并行下载后再执行回调。CommonJS:require 本身是同步的,不支持浏览器原生的异步加载。但打包工具(Webpack 等)可以将 import() 语法编译为 CommonJS 环境下的异步加载 chunk。追问import() 和顶层 import 有什么区别?顶层 import 是静态声明,必须在模块顶层,编译时确定依赖关系,引擎可以做静态分析和 Tree-Shaking。import() 是动态函数调用,返回 Promise,可以在任何位置调用,运行时才加载模块。后者用于代码分割、路由懒加载等按需加载场景。静态 import 在严格模式下还会被提升到模块顶部执行。为什么浏览器不支持 CommonJS 的 require?require 是同步调用——读取文件、编译、执行,然后返回结果。在服务端文件在本地磁盘上,同步读取耗时可以忽略;但在浏览器里模块要从网络下载,网络延迟不可控,同步阻塞意味着页面卡死直到所有依赖下载完成。AMD 和 ESModule 都采用异步加载模型,不阻塞主线程。库作者应该发布什么格式?ESM + CJS 双格式——package.json 的 exports 字段同时声明两种格式的入口,CJS 兼容老工具和老版本 Node.js,ESM 支持 Tree-Shaking 和静态分析。典型配置:{ "exports": { ".": { "import": "./dist/index.mjs", "require": "./dist/index.cjs" } }}单独只发 CJS 会丢失 Tree-Shaking 能力,单独只发 ESM 会排除不支持 ESM 的旧环境。双格式是当前最稳妥的方案。
前端阅读 495月28日 03:32

React 组件抽离公共逻辑代码有哪些方式?

React 逻辑复用经历了三代方案的演进:Mixin → HOC / Render Props → Hooks。Mixin 已随 Class 组件淘汰,当前面试重点在后面三种。HOC(高阶组件)函数接受一个组件,返回增强后的新组件:function withAuth(WrappedComponent) { return function AuthComponent(props) { const isAuthenticated = checkAuth(); return isAuthenticated ? <WrappedComponent {...props} /> : <Navigate to="/login" />; };}// 使用const ProtectedPage = withAuth(Dashboard);核心问题:Wrapper Hell:多层 HOC 嵌套后,DevTools 里组件树极深,调试困难Props 来源不透明:<WrappedComponent {...props} /> 透传的 props 来自哪里不直观,容易命名冲突静态方法丢失:HOC 返回新组件,原组件的静态方法不会自动复制,需要 hoist-non-react-statics 手动提升Ref 丢失:ref 不属于 props,会被绑定到外层 HOC 组件而非原组件,需配合 React.forwardRef 转发Render Props组件接受一个返回 React 元素的函数 prop,由该函数决定渲染内容:function Mouse({ render }) { const [pos, setPos] = useState({ x: 0, y: 0 }); useEffect(() => { const handler = (e) => setPos({ x: e.clientX, y: e.clientY }); window.addEventListener(mousemove, handler); return () => window.removeEventListener(mousemove, handler); }, []); return render(pos);}// 使用<Mouse render={pos => <Cursor pos={pos} />} />核心问题:嵌套地狱:多个 Render Props 嵌套时,回调层级极深,可读性急剧下降性能隐患:每次父组件渲染,render 函数都会重新创建,导致子组件不必要的重渲染,需要额外做 useCallback 优化Hooks(推荐)在函数组件内调用自定义 Hook,逻辑与 UI 完全分离,无组件层级嵌套:function useAuth() { const [user, setUser] = useState(null); useEffect(() => { const unsub = onAuthStateChanged(setUser); return unsub; }, []); return user;}// 使用function Dashboard() { const user = useAuth(); if (!user) return <Navigate to="/login" />; return <main>...</main>;}Hooks 的注意事项:不能在条件语句、循环或嵌套函数中调用——React 依靠调用顺序匹配 Fiber 链表上的 Hook 节点闭包陷阱:useEffect 内部如果引用了 state 但未加入依赖数组,回调中捕获的始终是旧值,需用 useRef 或函数式更新 setState(prev => prev + 1) 解决三种方案对比| 维度 | HOC | Render Props | Hooks ||------|-----|-------------|-------|| 组件嵌套 | 多层包裹 | 回调嵌套 | 无嵌套 || Props 透明度 | 来源不透明 | 显式传递 | 显式调用 || 类型推导 | 困难(泛型丢失) | 较好 | 好 || 适用场景 | 旧代码维护、Class 组件 | 旧代码维护 | 新代码首选 |三种方式的核心思想一致——把可复用逻辑从 UI 中分离。Hooks 胜在零组件嵌套、逻辑内聚、类型友好,是当前最佳实践。追问为什么 Hooks 不能放在条件语句里?React 用 Fiber 节点上的链表结构存储 Hook 状态。每次渲染时,Hook 按调用顺序依次匹配链表上的节点。如果某个 Hook 在某次渲染被跳过,后续 Hook 就会错位匹配到前一个 Hook 的状态节点,导致状态混乱。这是 React 内部实现机制决定的,而非 API 设计限制。HOC 还用在哪些场景?React.memo(性能优化,浅比较 props)connect(mapStateToProps, mapDispatchToProps)(Redux v5 以前)withRouter(React Router v5)权限控制:withAuth(ProtectedComponent)日志/埋点:withTracker(InteractiveComponent)如何把 Class 组件中的 HOC 迁移到 Hooks?| HOC 模式 | Hooks 替代 ||----------|-----------|| withRouter | useNavigate() + useLocation() + useParams() || connect() | useSelector() + useDispatch() || withAuth | 自定义 useAuth() || withTracker | 自定义 useTracker() + useEffect || 通用 HOC | 自定义 Hook + 组件内直接调用 |Hooks 有哪些常见陷阱?闭包陷阱:useEffect 中引用了 state 但依赖数组遗漏,回调拿到旧值。用 useRef 存最新值或函数式更新解决无限渲染:useEffect 依赖项传入每次新建的对象/数组引用,用 useMemo 稳定引用依赖缺失:遗漏依赖导致 effect 不按预期执行,启用 eslint-plugin-react-hooks 的 exhaustive-deps 规则自动检查
前端阅读 335月28日 03:31

React setState 是同步还是异步?原理是什么?

setState 并非真正“异步”——它是批量延迟执行。调用 setState 时,React 把更新对象推入当前 Fiber 节点的 updateQueue(环形链表),然后调度一次重新渲染,而不是立即修改 state 和触发 DOM 更新。等调度机制在下一个工作单元执行时,才遍历 updateQueue 计算新 state 并渲染。批量更新的核心逻辑:同一个事件循环内的多次 setState,只会产生一次渲染调度。合并发生在遍历 updateQueue 阶段——React 依次执行每个 update 的计算函数,得到最终 state,再进入 render。// 直接值更新:三次调用,updateQueue 里三个 update// 但都基于同一个闭包中的 count,最终 count = 0 + 1 = 1setCount(count + 1);setCount(count + 1);setCount(count + 1);// 函数式更新:每个 update 拿到前一个 update 的结果// count = ((0+1)+1)+1 = 3setCount(c => c + 1);setCount(c => c + 1);setCount(c => c + 1);追问setState 是同步还是异步?都不是。它本质是同步入队 + 延迟渲染。React 17 中,事件处理器外(setTimeout、原生事件)没有批量机制,setState 后能立即在同步代码中读到新值,看起来像“同步”——但这不是真正的同步,只是批量边界不同。React 18 用 createRoot 统一了所有场景的批处理。React 18 自动批处理的原理是什么?React 18 引入新调度入口 scheduleUpdateOnFiber,替代了原来的 enqueueSetState。无论更新来自事件处理器、setTimeout 还是 Promise,都走同一条调度路径。内部用 lane 模型(替代 expiration time)管理优先级,Scheduler 模块按优先级安排回调执行时机,实现所有场景统一批处理。什么情况下 setState 后组件不会重新渲染?bailout 机制:React 在渲染前会比较新旧 state(浅比较)。如果值没变,直接跳过该组件的渲染。常见陷阱——setState(obj) 传入同一个引用,浅比较相等,不会触发更新。必须创建新对象:setState({...obj, key: newVal})。函数式更新什么场景必须用?依赖前一个 state 时必须用。典型场景:计数器、队列操作(往数组追加元素)。函数式更新能保证每次拿到的都是链表中上一个 update 计算后的最新值,而不是闭包捕获的旧值。
前端阅读 395月28日 03:31

浏览器渲染页面的过程是怎样的?

浏览器从收到 HTML 到画出画面,走的是这条流水线:DOM 树 → CSSOM 树 → Render 树 → Layout → Paint → Composite。面试时把这六个词按顺序说出来,再展开每步做了什么,就够了。解析 HTML → DOM 树:字节流 → 字符 → Token → 节点 → DOM 树。遇到 <script> 暂停解析,下载执行完 JS 再继续——因为 JS 可能改 DOM。CSS 和图片不阻塞 DOM 构建,但 CSS 会阻塞后续 JS 执行:JS 可能读 getComputedStyle(),浏览器必须等 CSSOM 好了才让 JS 跑。所以 CSS 放 head 不只是避免 FOUC(无样式内容闪烁),还防止 JS 等待 CSS 造成的卡顿。解析 CSS → CSSOM 树:样式表从右向左匹配选择器(.a .b p 先找所有 p 再逐级向上匹配),构建 CSSOM。CSS 不阻塞 DOM 构建,但阻塞渲染——CSSOM 没好之前页面白屏。DOM + CSSOM → Render 树:只包含可见节点。display: none 连 Render 树都进不了(不占空间),visibility: hidden 占位不可见,还在树里。伪元素 ::before/::after 也会进 Render 树。Layout(重排/回流):计算每个可见节点的精确位置和尺寸。首次叫 Initial Layout,后续改动触发 Reflow。一个元素的几何属性变了,可能级联触发整个子树重排——这就是为什么频繁操作 DOM 性能差。Paint(重绘):把 Layout 结果光栅化成像素。改 color、background 只触发重绘,不触发 Layout。重排一定触发重绘,反过来不会。Composite(合成):浏览器把页面分成多个图层(层叠上下文、will-change、3D transform 等会创建新层),GPU 合成各层。transform 和 opacity 的变化只走合成,跳过 Layout 和 Paint,所以动画性能最好——即使主线程卡死也能保持流畅。追问CSS 放 head,JS 放底部——还有更好的方案吗?CSS 必须放 head,没商量。JS 有三个选择:放底部(简单粗暴)、defer(下载不阻塞解析,DOM 构建完按顺序执行,推荐)、async(下载不阻塞,下载完立刻执行,顺序不可控,适合统计脚本)。defer 和 async 只对外部 <script src> 有效,内联脚本不支持。重排和重绘哪个更贵?怎么减少重排?重排贵得多——要重算布局,可能级联影响子树;重绘只更新像素。减少重排的实战方法:用 class 切换代替逐条改 style、离线操作 DOM(DocumentFragment 克隆节点改完再插回去)、读写分离(先把 offsetWidth 等布局信息读完缓存到变量,再批量写样式,避免强制同步布局)。Chrome DevTools Performance 面板里紫色 Layout 块高频出现,就说明重排有问题。为什么 transform 动画比 top/left 流畅?transform 和 opacity 在合成器线程(Compositor Thread)处理,跟主线程无关。主线程被 JS 堵住时合成器照样跑,动画不卡。top/left 动画走 Layout → Paint → Composite 全流程,绑死主线程。一句话:动画用 transform,别用 top/left。关键渲染路径如何优化?目标是缩短首屏白屏时间。三件事:减少关键资源数量(CSS 内联首屏样式、JS 用 defer)、减少关键资源体积(压缩 + Brotli)、减少关键路径往返(<link rel="preload"> 预加载关键资源)。用 Lighthouse 看 FCP 和 LCP 两个指标,直接反映渲染路径优化效果。哪些操作会触发重排?改几何属性:width/height/padding/margin/top/left。改 DOM 结构:appendChild/removeChild。读布局信息也会:offsetWidth/scrollTop/getComputedStyle()——浏览器被迫立刻算出最新值,强制同步布局。最坑的是循环里交替读写:for 里先读 offsetWidth 再改 style.width,每次迭代都触发一次重排,性能灾难。
前端阅读 305月28日 03:29

ES6 类继承中 super 关键字的作用是什么?

super 在 ES6 类继承中有两种用法:作为函数调用和作为对象引用。核心要点是——super() 调用父类构造函数,super.method() 调用父类原型方法,super.staticMethod() 在静态方法中调用父类静态方法。super() 作为函数调用在子类 constructor 中,super() 调用父类构造函数。ES6 的继承机制规定:父类负责创建 this 对象,子类负责在此基础上添加属性。因此 super() 必须在 this 之前调用,否则会抛出 ReferenceError。class Parent { constructor(name) { this.name = name; }}class Child extends Parent { constructor(name, age) { super(name); // 必须先调 super,否则下面用 this 会报错 this.age = age; }}如果子类没有显式定义 constructor,引擎会自动插入一个默认的 constructor(...args) { super(...args); }。super.method() 作为对象引用在子类普通方法中,super 指向父类的 prototype,可以调用父类原型上的方法:class Parent { greet() { return 'hello from Parent'; }}class Child extends Parent { greet() { return super.greet() + ' and Child'; }}new Child().greet(); // "hello from Parent and Child"静态方法中的 super在子类静态方法中,super 指向父类本身(而非 prototype),因此可以调用父类的静态方法:class Parent { static create() { return new this(); }}class Child extends Parent { static create() { return super.create(); // 调用 Parent.create() }}super 的内部指向总结| 使用场景 | super 指向 ||---------|-----------|| super() 在 constructor 中 | 父类构造函数 || super.method() 在普通方法中 | Parent.prototype || super.method() 在静态方法中 | 父类本身(Parent) || super.x = value | 触发父类原型上的 setter(如果有) |追问:子类 constructor 为什么必须先调 super?ES6 类的继承与 ES5 的寄生组合继承有本质区别。ES5 中是先创建 this(子类自己的对象),再用 Parent.apply(this) 借用父类构造函数挂属性。ES6 反过来了——由父类构造函数先创建并初始化 this,子类再修改。这个顺序由 new.target 控制:当 new Child() 执行时,new.target 是 Child,但 this 的创建权在 Parent 那里。super() 执行后 this 才可用。追问:super.x = value 有什么陷阱?给 super 的属性赋值时,并不会像直觉那样去修改父类原型上的属性。实际行为是:如果父类原型上定义了该属性的 setter,赋值操作会触发那个 setter,this 指向当前子类实例;如果没有 setter,则相当于直接在 this 上创建属性:class Parent { set x(val) { console.log('setter called with', val); }}class Child extends Parent { setX() { super.x = 42; // 触发 Parent.prototype 的 setter }}new Child().setX(); // "setter called with 42"追问:ES6 继承与 ES5 原型继承的区别| | ES5 寄生组合继承 | ES6 class 继承 ||--|----------------|---------------|| this 创建 | 子类先创建 this,再借用父类 | 父类构造函数创建 this || super | 无,用 Parent.call(this) | super() 必须 || 原型链 | 手动 Object.create(Parent.prototype) | extends 自动建立 || 静态方法 | 不会继承 | 自动继承 || new.target | 不存在 | 控制实例化行为 |
前端阅读 285月28日 03:29

如何实现 Web 图片懒加载?

面试官问:如何实现 Web 图片懒加载?图片懒加载的核心思路:图片不在视口内时不加载,滚动到接近视口时再加载,减少首屏请求数和带宽消耗。答案现代前端有三种主流实现方式,按推荐优先级排列:1. 原生 loading="lazy"(首选)<img src="image.jpg" loading="lazy" alt="描述文字" />一行搞定。Chrome 76+、Edge、Firefox 均支持,Safari 15.4+ 也已支持。浏览器自动根据视口距离判断加载时机,无需 JS。关键细节:对首屏内的图片不要加 loading="lazy",否则可能延迟 LCP。建议只对视口下方的图片使用。2. Intersection Observer(兼容方案)const observer = new IntersectionObserver( (entries) => { entries.forEach(entry => { if (entry.isIntersecting) { const img = entry.target; img.src = img.dataset.src; observer.unobserve(img); // 加载完停止观察 } }); }, { rootMargin: '200px' } // 提前 200px 开始加载,减少白屏);document.querySelectorAll('img[data-src]').forEach(img => observer.observe(img));rootMargin 可以让图片在进入视口前就开始加载,用户体验更平滑。threshold 控制交叉比例触发阈值,懒加载场景一般用默认 0 即可。3. scroll 事件监听(不推荐)// 需要配合 throttle 使用function throttle(fn, delay) { let timer = null; return function (...args) { if (timer) return; timer = setTimeout(() => { fn.apply(this, args); timer = null; }, delay); };}window.addEventListener('scroll', throttle(() => { document.querySelectorAll('img[data-src]').forEach(img => { if (img.getBoundingClientRect().top < window.innerHeight + 200) { img.src = img.dataset.src; } });}, 200));为什么不推荐:scroll 事件在主线程频繁触发,即使 throttle 也无法避免 getBoundingClientRect 触发强制重排(reflow)。Intersection Observer 在浏览器合成器线程异步执行,完全不阻塞主线程,性能差距显著。方案对比| 方案 | 兼容性 | 性能 | 代码量 | SEO 友好 ||------|--------|------|--------|----------|| loading="lazy" | Chrome 76+, Safari 15.4+ | 最优 | 1 行 | 最佳 || Intersection Observer | 所有现代浏览器 | 优 | ~10 行 | 需配合 noscript || scroll 监听 | 全兼容 | 差 | ~20 行 | 需配合 noscript |追问Intersection Observer 和 scroll 监听有什么区别?核心区别在执行线程和触发机制:执行线程:scroll 监听在主线程同步执行,频繁触发即使 throttle 也会有性能开销;Intersection Observer 在浏览器合成器线程异步执行,不阻塞主线程位置计算:scroll 监听需要手动调用 getBoundingClientRect,每次调用触发强制重排;Intersection Observer 由浏览器内部计算,无重排开销触发精度:scroll 监听依赖 throttle 间隔,可能错过或延迟;Intersection Observer 在元素进入视口时精确触发内存管理:Intersection Observer 的 unobserve 语义清晰;scroll 监听需要手动维护已加载列表懒加载对 SEO 有影响吗?有影响。搜索引擎爬虫一般不执行 JS,data-src 中的图片地址对爬虫不可见。解决方案:loading="lazy" 原生方式:爬虫能识别 src 属性,SEO 影响最小<noscript> 提供替代:<img data-src="image.jpg" alt="描述" /><noscript><img src="image.jpg" alt="描述" /></noscript>SSR/SSG 时直接渲染 src:服务端渲染时输出完整 src,客户端再按需懒加载配合 srcset + sizes:让浏览器和爬虫都能选择合适尺寸懒加载图片会导致 CLS 问题吗?会。图片加载前高度为 0,加载后撑开页面内容产生布局偏移——这是 CLS 扣分的主要原因。解决办法:/* 方案一:aspect-ratio(推荐) */.img-wrapper { aspect-ratio: 16 / 9; width: 100%;}/* 方案二:padding-bottom 百分比技巧(兼容旧浏览器) */.img-wrapper { position: relative; width: 100%; padding-bottom: 56.25%; /* 16:9 = 9/16 * 100% */}.img-wrapper img { position: absolute; top: 0; left: 0; width: 100%; height: 100%;}也可以用固定高度的占位容器或低分辨率模糊占位图(LQIP)预留空间。首屏图片应该懒加载吗?不应该。首屏图片是 LCP 关键元素,懒加载会延迟加载时机,直接拖慢 LCP 指标。正确做法:首屏图片直接设置 src,并加上 fetchpriority="high" 提升优先级对首屏以下图片使用 loading="lazy" 或 Intersection Observer可以结合 preload 提前加载首屏关键图片<!-- 首屏:不懒加载,提升优先级 --><link rel="preload" as="image" href="hero.jpg" /><img src="hero.jpg" fetchpriority="high" alt="首屏主图" /><!-- 非首屏:懒加载 --><img src="below-fold.jpg" loading="lazy" alt="描述" />最佳实践总结优先使用原生 loading="lazy",兼容性已足够需要精细控制时用 Intersection Observer + rootMargin避免对首屏图片懒加载必须预留图片空间防止 CLSSEO 敏感场景用 <noscript> 或 SSR 兜底废弃 scroll 监听方案