前端面试题手册

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

前端阅读 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 监听方案
前端阅读 395月28日 03:28

Webpack 有哪些优化手段?

Webpack 有哪些优化手段?从构建速度和产物体积两个方向回答,面试中最常考察的优化点如下:构建速度优化1. 持久化缓存// webpack.config.jsmodule.exports = { cache: { type: 'filesystem', // Webpack 5 支持 buildDependencies: { config: [__filename] } }}首次构建后缓存写入 node_modules/.cache/webpack,二次构建可减少 60%-80% 时间。修改配置文件会自动失效。2. 缩小 Loader 处理范围module.exports = { module: { rules: [{ test: /\.js$/, include: path.resolve(__dirname, 'src'), // 只处理 src 目录 exclude: /node_modules/, use: 'babel-loader' }] }}不配置 include 时,Babel 会遍历 node_modules 中所有 JS 文件,构建时间翻倍。3. 多线程编译thread-loader:将耗时的 loader 放到 worker 池中并行执行terser-webpack-plugin 开启 parallel: true:并行压缩 JSWebpack 5 原生支持 parallel 配置4. 优化模块解析module.exports = { resolve: { extensions: ['.ts', '.tsx', '.js'], // 按使用频率排列,减少尝试次数 alias: { '@': path.resolve(__dirname, 'src') }, // 别名减少路径查找 }, module: { noParse: /lodash|jquery/, // 跳过已打包库的解析 }}noParse 告诉 Webpack 这些库没有 import/require,不需要解析依赖关系。5. 使用更快的编译器swc-loader 替代 babel-loader(Rust 实现,速度快 20 倍以上)esbuild-loader 替代 terser-webpack-plugin(Go 实现)6. DLL 预编译(Webpack 4 适用)// webpack.dll.config.jsnew webpack.DllPlugin({ name: '[name]', path: path.join(__dirname, 'dll', '[name]-manifest.json')})将 React、Vue 等不变库提前编译成 DLL,开发构建跳过。Webpack 5 的持久化缓存已基本替代此方案。产物体积优化1. 代码分割(Code Splitting)splitChunks — 提取公共依赖:module.exports = { optimization: { splitChunks: { chunks: 'all', cacheGroups: { vendors: { test: /[\\/]node_modules[\\/]/, name: 'vendors', chunks: 'all' } } } }}将 node_modules 中的库打包到独立 chunk,利用浏览器缓存。动态 import() — 按需加载:const Home = React.lazy(() => import('./Home'));const About = React.lazy(() => import('./About'));路由级懒加载,首屏只加载当前路由代码。 splitChunks 是配置层面的自动提取,动态 import() 是代码层面的手动分割,两者配合使用。2. Tree Shakingmodule.exports = { mode: 'production', // 自动开启 optimization: { usedExports: true, minimize: true, }}前提条件:必须使用 ESM(import/export),不能用 CommonJS(require)babel-loader 配置 "modules": false 防止转译为 CJSpackage.json 标记 "sideEffects": false 告知安全移除未使用导出为什么需要 ESM? ESM 的 import/export 是静态声明,编译时可确定依赖关系;CommonJS 的 require 是运行时调用,可以在 if 中使用,打包工具无法静态分析。3. Scope Hoisting(作用域提升)module.exports = { optimization: { concatenateModules: true, // 生产模式默认开启 }}将模块合并到同一个闭包中,减少函数声明和闭包数量,体积可减小 5%-10%。4. 压缩JS:terser-webpack-plugin(Webpack 5 生产模式默认启用)CSS:css-minimizer-webpack-pluginconst CssMinimizerPlugin = require('css-minimizer-webpack-plugin');module.exports = { optimization: { minimizer: [ new CssMinimizerPlugin(), ], }}5. externals 排除大型库module.exports = { externals: { react: 'React', 'react-dom': 'ReactDOM' }}将 React、Lodash 等通过 CDN 引入,不打包进 bundle,显著减小产物体积。6. 图片与资源优化小于 8KB 的图片转 base64 内联(Webpack 5 的 asset/inline)大图使用 image-webpack-loader 压缩或转 WebP{ test: /\.(png|jpg)$/, type: 'asset', parser: { dataUrlCondition: { maxSize: 8 * 1024 } // 8KB 以下内联 }}7. IgnorePlugin 排除无用模块new webpack.IgnorePlugin({ resourceRegExp: /^\.\/locale$/, contextRegExp: /moment$/})忽略 moment.js 的 locale 文件,体积减少约 200KB。构建分析工具| 工具 | 用途 ||------|------|| webpack-bundle-analyzer | 可视化分析产物体积,找出大依赖 || speed-measure-webpack-plugin | 统计各 loader/plugin 耗时 || webpack --profile --json > stats.json | 导出构建统计数据 |Webpack 5 新特性补充Module Federation:跨应用共享模块,实现微前端,避免重复打包公共依赖持久化缓存:替代 DLL 方案,配置更简单Asset Modules:内置资源模块,替代 url-loader/file-loader更好的 Tree Shaking:支持嵌套的 Tree Shaking 和内部模块的 Tree Shaking追问splitChunks 和动态 import 有什么区别?splitChunks 是 Webpack 配置层面的自动优化,根据策略提取公共依赖(如 node_modules 中的库),开发者无需手动干预。动态 import() 是代码层面的手动分割,由开发者决定哪些模块按需加载。两者互补:splitChunks 处理共享依赖,动态 import 处理路由级懒加载。Tree-Shaking 为什么需要 ESM?ESModule 的 import/export 是静态声明,在编译阶段就能确定模块的依赖关系和哪些导出被使用。CommonJS 的 require 是运行时调用,可以写在 if 语句、循环或函数中,打包工具无法在编译时判断哪些代码会被执行,因此无法安全移除未使用的代码。此外,需确保 Babel 不将 ESM 转译为 CJS(配置 "modules": false),并在 package.json 中声明 "sideEffects": false。webpack 的 cache 缓存了什么?为什么能加快构建?缓存了每个模块的 loader 处理结果和编译产物。首次构建后写入磁盘(node_modules/.cache/webpack),二次构建时 Webpack 检查文件时间戳和内容 hash,只重新编译变化的模块及其依赖链上的模块,未变化的模块直接使用缓存。典型场景:修改一个组件文件,只重新编译该文件和引用它的文件,其余数千模块跳过,构建时间从 30s 降到 5s 以内。Webpack 5 的 filesystem 缓存相比 Webpack 4 的 memory 缓存,重启开发服务器后缓存不丢失。Module Federation 解决了什么问题?多个独立构建的应用之间共享模块,避免重复打包相同依赖。典型场景:微前端架构中,主应用和子应用都依赖 React,传统方案各自打包 React,用 Module Federation 可以在运行时共享一份 React,减少总体加载量。核心配置是 exposes(暴露模块)和 remotes(消费远程模块),实现应用间的模块级复用。
前端阅读 355月28日 03:23

JavaScript 如何实现自定义事件?

基本用法:CustomEvent 三步走自定义事件的核心流程就三步:创建 → 监听 → 触发。// 1. 创建事件const event = new CustomEvent('user-login', { detail: { userId: 42, role: 'admin' }, // 携带数据 bubbles: true, // 是否冒泡 cancelable: true // 能否被 preventDefault() 取消});// 2. 监听element.addEventListener('user-login', (e) => { console.log(e.detail); // { userId: 42, role: 'admin' }});// 3. 触发element.dispatchEvent(event);detail 是 CustomEvent 独有的字段,用于携带任意数据,和 Event 构造函数的唯一区别就在这里。Event 只能表示"某件事发生了",CustomEvent 还能告诉你"发生了什么"。事件的冒泡与委托bubbles: true 让事件沿 DOM 树向上冒泡,这意味着你可以在父元素上统一监听子元素发出的自定义事件:// 子元素派发child.dispatchEvent(new CustomEvent('item-delete', { detail: { id: 1 }, bubbles: true}));// 父元素委托监听parent.addEventListener('item-delete', (e) => { console.log('删除了:', e.detail.id); e.stopPropagation(); // 阻止继续冒泡});这是事件委托在自定义事件上的直接应用——不需要给每个子元素绑定监听器,一个父元素就够了。手写 EventEmitter:脱离 DOM 的事件系统自定义事件依赖 DOM,但面试常考的是纯 JS 的发布订阅实现。核心就是一个事件名到回调数组的映射:class EventEmitter { constructor() { this.events = new Map(); } on(event, handler) { if (!this.events.has(event)) { this.events.set(event, []); } this.events.get(event).push(handler); return this; // 支持链式调用 } off(event, handler) { const handlers = this.events.get(event); if (handlers) { this.events.set(event, handlers.filter(h => h !== handler)); } return this; } emit(event, ...args) { const handlers = this.events.get(event); if (handlers) { handlers.forEach(h => h(...args)); } return this; } once(event, handler) { const wrapper = (...args) => { handler(...args); this.off(event, wrapper); }; this.on(event, wrapper); return this; }}once 的实现要点:用包装函数代替原函数注册,触发后自动解绑。面试写到这里基本够用。自定义事件 vs 发布订阅:何时选谁| 维度 | CustomEvent | EventEmitter ||------|-------------|--------------|| 依赖 | DOM 元素 | 纯 JS,无依赖 || 事件传播 | 冒泡/捕获,支持委托 | 无传播机制 || 数据传递 | detail 字段 | 直接参数 || 内存管理 | 移除元素自动清理 | 需手动 off || Shadow DOM | composed: true 可穿透 | 不涉及 |简单判断:需要 DOM 层级传播用 CustomEvent,纯数据流用 EventEmitter。无框架组件通信没有 Vue/React 时,自定义事件就是组件通信的基础设施:// 子组件:触发事件class MyInput extends HTMLElement { connectedCallback() { this.addEventListener('input', () => { this.dispatchEvent(new CustomEvent('value-change', { detail: { value: this.value }, bubbles: true })); }); }}// 父组件:监听子组件事件parent.querySelector('my-input') .addEventListener('value-change', (e) => { console.log(e.detail.value); });这就是 Web Components 通信的基本模式,也是浏览器原生组件化方案的核心机制。Shadow DOM 边界与 composedShadow DOM 默认隔离事件。composed: true 让 CustomEvent 穿透 Shadow DOM 边界,否则事件被封闭在 Shadow DOM 内部:// Shadow DOM 内部派发shadow.dispatchEvent(new CustomEvent('inner-action', { detail: { data: 1 }, bubbles: true, composed: true // 关键:穿透 Shadow DOM}));composedPath() 方法可以查看事件经过的完整路径,调试 Shadow DOM 事件传播时很有用。内存泄漏防护自定义事件常见的内存陷阱:移除元素前没解绑监听器,或者闭包引用了大对象。// 错误示范:匿名函数无法解绑el.addEventListener('my-event', (e) => { /* ... */ });// 无法 off,因为匿名函数没有引用// 正确做法:保存引用const handler = (e) => { /* ... */ };el.addEventListener('my-event', handler);// 用完解绑el.removeEventListener('my-event', handler);更安全的方案是用 AbortController:const controller = new AbortController();el.addEventListener('my-event', handler, { signal: controller.signal});// 批量清理controller.abort();一个 abort() 就能清理同一 controller 下的所有监听器,比逐个 removeEventListener 方便得多。追问自定义事件和发布订阅有什么区别?自定义事件绑定在 DOM 元素上,有冒泡/捕获机制、事件委托、stopPropagation 等 DOM 事件系统的完整能力。发布订阅(EventEmitter)是纯 JS 模式,没有 DOM 树概念。需要跨层级通信、事件委托时用自定义事件;需要纯数据流、完全与 DOM 无关时用发布订阅。不用框架(Vue/React)怎么用自定义事件做组件通信?父组件给子组件传一个 DOM 元素引用,子组件在这个元素上 dispatchEvent(new CustomEvent('change', { detail: value })),父组件监听这个元素的 change 事件。这是 Web Components 的通信基础。也可以用自定义元素的属性变化回调 observedAttributes + attributeChangedCallback 配合事件派发做双向通信。自定义事件可以跨 Shadow DOM 吗?可以。composed: true 的 CustomEvent 会穿透 Shadow DOM 边界,不设置则封闭在 Shadow DOM 内部。用 event.composedPath() 可以查看事件传播的完整路径,这对调试 Shadow DOM 内的事件流向很有帮助。
前端阅读 655月28日 03:23

CSS 选择器的权重怎么计算?

CSS 权重(Specificity)用四元组 (a, b, c, d) 表示,从左往右逐位比较,高位相同才比下一位:| 位置 | 含义 | 示例 | 每个贡献值 ||------|------|------|-----------|| a | 行内样式 | style="" | 1 || b | ID 选择器 | #header | +1 || c | 类/属性/伪类 | .nav、[type="text"]、:hover | +1 || d | 标签/伪元素 | div、::before | +1 |:::tip通配符 * 、组合符(>、+、~、空格)不贡献权重。 :where() 始终零权重, :is() 取参数中最高权重参与计算。:::权重计算实战逐个拆解选择器,把各部分归入对应位:/* (0, 1, 0, 0) */#header { }/* (0, 0, 2, 1) — 2个类 + 1个标签 */.nav .item a { }/* (0, 1, 1, 0) — 1个ID + 1个类 */#sidebar .active { }/* (0, 0, 1, 2) — 1个伪类 + 2个标签 */div p:hover { }/* (0, 0, 0, 0) — 通配符不贡献 */* { }比较规则:高位相同才比下一位。(0,1,0,0) 永远大于 (0,0,99,99) ——ID 列永远比 class 列大,不可进位。!important 与权重的关系!important 不参与四元组计算,它独立于权重体系之外:优先级从高到低:!important > 行内 > ID > class > tag > *当多条 !important 规则冲突时,回到权重比较决定谁胜出;权重也相同则后写的覆盖先写的。.box { color: red !important; } /* 同为 !important,回到权重比较 */#box { color: blue !important; } /* #box 权重更高,blue 胜出 */:::warning滥用 !important 会导致样式不可维护,只能在覆盖第三方库等少数场景使用,并注释原因。::::is() 与 :where() 的权重差异这是面试高频追问点:/* :is() 取参数最高权重 */:is(.nav, #main) a { }/* 等价于 #main a → (0, 1, 0, 1) *//* :where() 始终零权重 */:where(.nav, #main) a { }/* 等价于 a → (0, 0, 0, 1) */:where() 的零权重特性非常适合写基础/重置样式——使用者用任意 class 即可覆盖,无需担心权重冲突。@layer 对权重的影响CSS Cascading Layers(@layer)引入了层叠层的概念,层的优先级规则:无层样式 > 具名层样式(不管权重多高)层内按声明顺序:后声明的层优先级更高同一层内才按权重比较@layer base { #header { color: blue; } /* (0,1,0,0) 但在 base 层 */}/* 无层 —— 即使权重低也会赢 */.header { color: red; } /* (0,0,1,0) 无层,优先级更高 */继承的权重继承的样式没有权重,甚至低于通配符 *:* { color: gray; } /* (0,0,0,0) 但属于直接匹配 */body { color: black; } /* 子元素通过继承得到 black *//* p 元素会显示 gray —— 直接匹配 > 继承 */实际项目中的权重管理策略BEM 命名:只用 class,杜绝 ID 和嵌套选择器,权重始终为 (0,0,1,0) 级别选择器嵌套不超过 3 层:.block__element--modifier 足够,避免 .a .b .c .d用 :where() 写重置样式:where(reset) 可被任意 class 轻松覆盖CSS Modules / Scoped CSS:工具自动处理作用域,天然避免权重冲突避免 !important:仅在覆盖第三方库样式时使用,务必注释原因追问多个类选择器和一个 ID 选择器哪个权重高?ID 权重更高。#foo 是 (0,1,0,0),.a.b.c.d.e 再多类也是 (0,0,5,0)——高位相同才比下一位,b 列 1 > c 列 5,ID 永远胜出。怎么快速判断两个选择器的优先级?分三步:(1) 先看有没有 !important;(2) 再看是否在 @layer 中(无层 > 有层);(3) 最后按四元组从左往右逐位比。同权重时后写的覆盖先写的。内联样式和 !important 谁优先?!important 优先。!important > 行内样式 > ID > class > tag。但两条都是 !important 时,再回到权重体系比较。伪类 :not() 的权重怎么算?:not() 本身不贡献权重,但它括号内的选择器参与计算。:not(.foo) 的权重等于 .foo,即 (0,0,1,0)。同理 :not(#bar) 按 ID 计算权重 (0,1,0,0)。注意 :not() 内可以写复杂选择器,取其完整权重。
前端阅读 525月28日 03:23

什么是柯里化函数?JavaScript 中有哪些使用场景?

什么是柯里化?柯里化(Currying)是将一个接受多个参数的函数,转换成一系列每次只接受一个参数的函数。转换后的函数会逐步收集参数,直到参数齐全才执行原函数。// 普通函数function add(a, b, c) { return a + b + c;}add(1, 2, 3); // 6// 柯里化后function curryAdd(a) { return function(b) { return function(c) { return a + b + c; }; };}curryAdd(1)(2)(3); // 6核心价值是参数复用和延迟执行——你可以先固定部分参数,得到一个更具体的函数,等剩余参数就位再调用。手写通用 curry 函数面试中最常考的是手写一个通用的 curry,它能将任意多参数函数转为柯里化形式:function curry(fn) { return function curried(...args) { // 收集的参数数量 >= 原函数参数数量,直接执行 if (args.length >= fn.length) { return fn.apply(this, args); } // 否则返回新函数继续收集 return (...nextArgs) => curried.apply(this, args.concat(nextArgs)); };}验证:function sum(a, b, c) { return a + b + c;}const curriedSum = curry(sum);curriedSum(1)(2)(3); // 6curriedSum(1, 2)(3); // 6 —— 支持一次传多个参数curriedSum(1)(2, 3); // 6关键点:利用 fn.length 判断原函数期望的参数个数,利用闭包在调用链中持续传递已收集的参数。柯里化的实际使用场景参数复用:创建预设函数function log(level, timestamp, message) { console.log(`[${level}] ${timestamp}: ${message}`);}const curriedLog = curry(log);const errorLog = curriedLog('ERROR'); // 预设 levelconst errorLogNow = errorLog(Date.now()); // 预设 timestamperrorLogNow('接口超时'); // [ERROR] 1748400000000: 接口超时errorLogNow('数据解析失败'); // 复用前两个参数bind 方法的本质Function.prototype.bind 就是偏函数应用——预设 this 和部分参数,返回新函数:const obj = { x: 42 };function getX(extra) { return this.x + extra;}const boundGetX = getX.bind(obj, 8); // 预设 this + 第一个参数boundGetX(); // 50Redux 中间件的函数链Redux 中间件签名 store => next => action => {} 就是三级柯里化:const logger = store => next => action => { console.log('dispatching', action); const result = next(action); console.log('next state', store.getState()); return result;};第一层拿到 store,第二层拿到 next(下一个中间件),第三层处理 action。分层柯里化使得每层的职责清晰,也方便测试——你可以只传入部分参数来测试中间件的某一步。React 事件处理中的参数绑定// 方式一:bind 预设参数<button onClick={handleClick.bind(null, item.id)}>删除</button>// 方式二:高阶函数(本质也是柯里化)const handleClick = (id) => (e) => { e.stopPropagation(); deleteItem(id);};<button onClick={handleClick(item.id)}>删除</button>柯里化与偏函数的区别两者容易混淆,核心区别在于参数接收方式:柯里化:将函数转为一系列一元函数,每次只接受一个参数(f(a)(b)(c))偏函数:预先填充部分参数,返回的函数仍可接受多个参数(f(a, b) → f'(c, d))// 柯里化:每次一个参数const curriedAdd = curry((a, b, c) => a + b + c);curriedAdd(1)(2)(3); // 6// 偏函数:一次传剩余参数function partial(fn, ...presetArgs) { return (...laterArgs) => fn(...presetArgs, ...laterArgs);}const addFive = partial((a, b) => a + b, 5);addFive(3); // 8bind 是偏函数,不是严格柯里化——因为 bind 返回的函数可以一次接收多个参数。柯里化的局限与注意事项性能开销:每次柯里化调用都会创建新的闭包和函数对象,在性能敏感场景(如高频事件处理、大循环内)应避免过度使用。只处理显式参数:fn.length 无法识别默认参数、rest 参数。带默认值的函数柯里化后行为可能不符合预期:function greet(name, greeting = 'Hello') { return `${greeting}, ${name}`;}greet.length; // 1,只有 name 是显式参数curry(greet)('Alice'); // 直接返回 "Hello, Alice",跳过了 greeting 的柯里化步骤调试困难:柯里化链越长,调用栈越深,错误堆栈的可读性越差。生产代码中应控制嵌套层级。追问柯里化和偏函数有什么区别?柯里化将函数拆成一元函数链(f(a)(b)(c)),偏函数预设部分参数但返回函数仍可多参(f(a, b) → f'(c, d))。bind 是偏函数不是严格柯里化。能手写一个通用的 curry 函数吗?利用闭包持续收集参数,用 fn.length 判断参数是否齐全,齐全则执行,否则返回新函数继续收集。见上方"手写通用 curry 函数"部分。柯里化在 React 中有什么应用?事件处理器的参数预绑定:onClick={handleClick(id)} 用高阶函数延迟执行;Hooks 中的自定义 Hook 参数分步传入也是类似思路。
前端阅读 335月28日 03:22

JavaScript 继承方式有哪几种?各自的优缺点是什么?

JavaScript 的继承方式有以下七种,按演进顺序理解更容易记住:原型链继承、构造函数继承、组合继承、原型式继承、寄生式继承、寄生组合继承、ES6 class extends。面试中重点掌握组合继承的问题和寄生组合继承的优化思路。原型链继承将子类的原型指向父类的实例:function Parent() { this.name = 'parent'; this.colors = ['red', 'blue'];}Parent.prototype.getName = function() { return this.name;};function Child() {}Child.prototype = new Parent();const child1 = new Child();const child2 = new Child();child1.colors.push('green');console.log(child2.colors); // ['red', 'blue', 'green'] — 共享了!核心问题:所有子实例共享父实例的引用类型属性,一个实例修改了 colors,其他实例也受影响。构造函数继承在子类构造函数中调用父类构造函数:function Parent() { this.colors = ['red', 'blue'];}function Child() { Parent.call(this); // 借用构造函数}const child1 = new Child();child1.colors.push('green');console.log(new Child().colors); // ['red', 'blue'] — 不共享了解决了引用属性共享的问题,但新的问题出现了:方法只能定义在构造函数里,每次创建实例都会重新创建方法,无法复用。而且根本访问不到父类原型上的方法。组合继承把上面两种方式组合起来——属性用构造函数继承,方法用原型链继承:function Parent(name) { this.name = name; this.colors = ['red', 'blue'];}Parent.prototype.getName = function() { return this.name;};function Child(name, age) { Parent.call(this, name); // 第二次调用 Parent this.age = age;}Child.prototype = new Parent(); // 第一次调用 ParentChild.prototype.constructor = Child;这是最常用的经典方案,但有一个效率问题:Parent 被调用了两次。第一次 new Parent() 在子类原型上创建了 name 和 colors,第二次 Parent.call(this) 又在实例上创建了同名的属性,原型上的那份其实是多余的。原型式继承不定义构造函数,直接基于已有对象创建新对象:function createObj(o) { function F() {} F.prototype = o; return new F();}// ES5 标准化了这个模式Object.create(proto, propertiesObject);和原型链继承有同样的共享问题——引用类型的属性会被所有派生对象共享。适用于不需要单独创建构造函数、只想让一个对象类似于另一个对象的场景。寄生式继承在原型式继承的基础上,增强对象后返回:function createChild(original) { const clone = Object.create(original); clone.sayHi = function() { console.log('hi'); }; return clone;}通过封装函数给对象添加能力,但和构造函数继承一样——每个实例都会重新创建方法,无法复用。寄生组合继承组合继承的优化版,也是目前公认的最优继承方案:function Parent(name) { this.name = name; this.colors = ['red', 'blue'];}Parent.prototype.getName = function() { return this.name;};function Child(name, age) { Parent.call(this, name); // 只调用一次 Parent this.age = age;}// 关键:用 Object.create 代替 new Parent()Child.prototype = Object.create(Parent.prototype);Child.prototype.constructor = Child;和组合继承相比,只改了一行:把 Child.prototype = new Parent() 换成了 Child.prototype = Object.create(Parent.prototype)。这一改做到了两件事:只调用一次父类构造函数,子类原型上不会出现多余的实例属性。ES6 的 class extends 经 Babel 编译后,底层就是寄生组合继承。ES6 class extendsclass Parent { constructor(name) { this.name = name; this.colors = ['red', 'blue']; } getName() { return this.name; }}class Child extends Parent { constructor(name, age) { super(name); // 必须在 this 之前调用 this.age = age; }}语法糖,底层仍然是原型链 + 寄生组合继承,但做了额外约束:不用 new 调用会报错(内置 new.target 检查)类方法不可枚举extends 同时继承静态属性和原型方法super 作为关键字比 Parent.call(this) 更安全,确保父类构造函数在访问 this 之前执行七种方式对比| 方式 | 引用属性共享 | 方法复用 | 父类调用次数 | 适用场景 ||------|:---:|:---:|:---:|------|| 原型链继承 | 共享 | 可以 | 1 | 不含引用属性的简单继承 || 构造函数继承 | 不共享 | 不可以 | 1 | 只需继承属性不需方法 || 组合继承 | 不共享 | 可以 | 2 | 传统项目,兼容性要求高 || 原型式继承 | 共享 | 可以 | 0 | 基于对象快速派生 || 寄生式继承 | 共享 | 不可以 | 0 | 给对象添加增强能力 || 寄生组合继承 | 不共享 | 可以 | 1 | ES5 环境下最优选择 || ES6 class extends | 不共享 | 可以 | 1 | 现代开发首选 |追问寄生组合继承为什么是最优方案?只调用一次父类构造函数,避免了组合继承中子类原型上出现冗余实例属性的问题。原型链保持干净——子类原型是通过 Object.create(Parent.prototype) 创建的空对象,只包含指向父类原型的方法,不包含父类实例属性。这是 ES5 环境下兼顾效率和正确性的最佳方案。ES6 class 和寄生组合继承有什么本质区别?class 不只是语法糖,它在语义层面做了额外约束:1) 不用 new 调用直接报错(new.target 检查);2) 类方法默认不可枚举;3) extends 同时继承静态属性,寄生组合继承做不到这点需要手动处理;4) super 是关键字而非函数调用,引擎保证父类构造函数在子类访问 this 之前执行,比 Parent.call(this) 更安全。Object.create 和 new 有什么区别?Object.create(proto) 创建一个新对象并将其 __proto__ 指向 proto,不执行任何构造函数逻辑。new Constructor() 会执行构造函数,将函数体内的 this 绑定到新对象,执行初始化逻辑后返回该对象。寄生组合继承用 Object.create 替代 new Parent(),目的就是避免多执行一次父类构造函数中的初始化逻辑。为什么组合继承中父类被调用了两次?第一次是 Child.prototype = new Parent(),这一步在子类原型上创建了 name、colors 等实例属性。第二次是 Parent.call(this, name),在子类实例自身上又创建了同名属性。实例访问属性时先找自身再找原型,所以原型上那些属性虽然存在但永远不会被访问到,属于冗余创建。寄生组合继承用 Object.create 绕过了第一次调用,只保留实例属性的正确初始化。
前端阅读 675月28日 03:22

什么是伪类和伪元素?它们之间有什么区别?

伪类和伪元素都是 CSS 选择器的扩展机制,但本质不同:伪类(单冒号 :):选择 DOM 中已有元素的某种状态。元素本身存在,伪类只是在特定条件下"筛选"它。比如 :hover 选中的还是那个 <a> 元素,只不过它正处于鼠标悬停状态。伪元素(双冒号 ::):在 DOM 树中创建一个不存在的虚拟节点,然后对这个虚拟节点施加样式。比如 ::before 在元素内容前面插入一个匿名盒子,这个盒子在 HTML 源码里根本不存在。用一个类比:伪类是给已有的人拍一张特定状态的照片(站着、坐着),伪元素则是凭空造出一个不存在的人再给他拍照。/* 伪类:选择处于悬停状态的 a 元素 */a:hover { color: red;}/* 伪元素:在元素内容前插入虚拟内容 */a::before { content: "→ ";}语法规范的演变CSS1 和 CSS2 时代,伪类和伪元素都用单冒号 :,比如 :before、:after、:first-letter 和 :hover、:focus 混在一起,容易混淆。CSS3 为了区分二者,规定:伪类继续用单冒号 :hover伪元素改用双冒号 ::before浏览器为了向后兼容,仍然支持 :before 这种单冒号写法,但在新项目中应该统一使用双冒号。常见伪类分类| 类别 | 示例 | 说明 ||------|------|------|| 交互状态 | :hover :active :focus :focus-within | 用户交互触发的状态 || 位置匹配 | :first-child :last-child :nth-child(n) :nth-of-type(n) | 基于元素在兄弟中的位置 || 否定与匹配 | :not() :is() :where() :has() | 逻辑组合选择器 || 表单相关 | :checked :disabled :valid :invalid :required | 表单元素的状态 || 链接状态 | :link :visited | 未访问/已访问链接 |常见伪元素分类| 伪元素 | 作用 ||--------|------|| ::before / ::after | 在元素内容前后插入虚拟盒子 || ::first-letter | 选中块级元素首字母(可实现首字下沉效果) || ::first-line | 选中块级元素首行(字号变化时首行范围自适应) || ::selection | 用户选中文本的样式 || ::placeholder | 输入框占位文字的样式 |选择器组合规则一个选择器可以同时使用多个伪类,它们叠加生效:/* 合法:同时匹配"第一个子元素"和"悬停状态" */li:first-child:hover { background: yellow;}但一个选择器只能使用一个伪元素,且伪元素必须出现在选择器末尾:/* 合法:伪元素在最后 */a:hover::before { content: "🔗";}/* 非法:伪元素后面不能再接伪类 */a::before:hover { } /* 无效 */原因是伪元素创建了一个新的虚拟盒子,它不是 DOM 节点,无法拥有状态,所以 ::before:hover 没有意义。渲染层面的差异伪类不影响渲染树的构建——它只是让选择器在匹配阶段多了一个条件,匹配到的元素照常进入渲染树。伪元素则会在渲染树中额外生成一个匿名盒子。浏览器在布局计算时,::before 和 ::after 生成的盒子会参与父元素的排版,占用空间(如果设置了宽高或内容的话)。你可以打开 DevTools 的 Elements 面板,看到 ::before 和 ::after 出现在元素节点下方。追问:nth-child(n) 和 :nth-of-type(n) 有什么区别?:nth-child(n) 在所有兄弟元素中数第 n 个,不管标签类型。:nth-of-type(n) 只在同标签类型的兄弟中数第 n 个。<div> <h2>标题</h2> <p>第一段</p> <!-- p:nth-child(2) 匹配失败:它是第2个子元素但不是h2 --> <p>第二段</p> <!-- p:nth-of-type(2) 匹配成功:它是第2个p --></div>关键记忆:nth-child 先数位置再验类型,nth-of-type 先筛类型再数位置。::before 和 ::after 必须配合 content 属性吗?是的。没有 content 属性,伪元素不会生成盒子。哪怕不需要任何文字内容,也必须写 content: ''。content 支持的值包括字符串、attr() 函数、图片 url()、计数器 counter() 等:.tooltip::after { content: attr(data-tip); /* 读取元素属性作为内容 */}.counter::before { content: counter(section) ". "; /* 配合 counter-increment 使用 */}伪元素可以绑定 JS 事件吗?不能。伪元素不存在于 DOM 树中,document.querySelector 无法选中它,JS 事件也无法直接绑定。如果想间接检测伪元素的点击,可以通过判断 event.offsetX / event.offsetY 是否落在伪元素的渲染区域内。实际开发中更推荐用真实 DOM 元素替代伪元素来实现交互需求。:has() 伪类和伪元素有什么关系?:has() 是 CSS4 引入的关系型伪类(不是伪元素),被称为"父选择器"。它可以根据子元素的状态来选择父元素:/* 选择包含 img 子元素的 a 标签 */a:has(> img) { border: 1px solid #ccc;}/* 选择后面紧跟着 h2 的 h1(兄弟关系) */h1:has(+ h2) { margin-bottom: 0;}:has() 的出现弥补了 CSS 长期缺失的"向上选择"能力,目前主流浏览器已全面支持。
前端阅读 425月28日 03:21

JavaScript 如何使用 setTimeout 模拟实现 setInterval?

直接用 setInterval 有一个经典问题:如果回调执行时间超过了间隔时间,回调会在事件队列中堆积。用 setTimeout 递归模拟能解决这个痛点——每次回调执行完再设置下一次定时器,确保上一次执行完毕才开始下一轮计时。function mySetInterval(fn, delay) { let timer = null; function loop() { fn(); timer = setTimeout(loop, delay); } timer = setTimeout(loop, delay); return () => clearTimeout(timer);}const cancel = mySetInterval(() => console.log('tick'), 1000);// cancel(); 需要停止时调用核心思路:在回调函数末尾递归调用 setTimeout,使得下一次定时器只有在上一次回调执行完毕后才会被注册。返回一个取消函数,调用时 clearTimeout 清除当前等待中的定时器,递归链就此中断。setInterval 的回调堆积问题setInterval(fn, 100) 的行为是:每隔 100ms 向任务队列放入一个 fn,不管前一个 fn 是否执行完毕。如果 fn 执行需要 150ms:100ms:第一个回调进入队列,开始执行200ms:第二个回调进入队列(第一个还没执行完)300ms:第三个回调进入队列结果是回调不断排队,等当前任务执行完后,队列中的回调会连续执行,间隔远小于预期。这不是"跳过间隔",而是"堆积后连续执行"。setTimeout 模拟的优势与局限优势:不会回调堆积,每次执行完才开始计时下一轮间隔更可控,实际间隔 ≈ delay + 回调执行时间局限:仍然不精确——setTimeout 只保证"至少延迟 delay 毫秒",受事件循环和主线程阻塞影响如果回调本身执行时间很长,实际间隔会远大于设定值带错误处理的增强实现基础版本有个隐患:回调抛异常时,递归链中断,定时器静默停止。加上 try-catch 可以保证异常不会打断后续执行:function mySetIntervalSafe(fn, delay) { let timer = null; function loop() { try { fn(); } catch (e) { console.error('定时器回调异常:', e); } timer = setTimeout(loop, delay); } timer = setTimeout(loop, delay); return () => clearTimeout(timer);}支持 this 绑定和参数传递原生 setInterval 支持 setInterval(fn, delay, arg1, arg2) 的参数传递和 this 绑定。补全这两个能力:function mySetIntervalFull(fn, delay, ...args) { let timer = null; const context = this; function loop() { try { fn.apply(context, args); } catch (e) { console.error('定时器回调异常:', e); } timer = setTimeout(loop, delay); } timer = setTimeout(loop, delay); return () => clearTimeout(timer);}// 用法:传递参数mySetIntervalFull((name, count) => { console.log(`${name}: ${count}`);}, 1000, 'tick', 42);追问如果需要真正精确的定时循环怎么办?setTimeout 模拟解决的是回调堆积问题,不能解决定时精度问题。精确计时需要其他方案:requestAnimationFrame + performance.now():适合动画循环,每帧检查时间戳决定是否执行Web Worker 定时器:在独立线程运行,不受主线程阻塞影响时间戳补偿:记录预期执行时间和实际已执行次数,根据偏差动态调整下一轮 delay能不能用 setInterval 模拟 setTimeout?可以,执行一次后立即 clearInterval:function mySetTimeout(fn, delay) { const id = setInterval(() => { fn(); clearInterval(id); }, delay);}但实际中没人这么做——setTimeout 本身就是更合适的 API,这个追问考查的是对两个定时器语义的理解。递归 setTimeout 会不会导致栈溢出?不会。setTimeout 的回调是宏任务,每次执行时调用栈已经清空,递归是通过事件循环调度的,不是真正的函数递归调用,调用栈深度始终为 1。
前端阅读 885月28日 03:20

什么是 MVVM 模式?它是为了解决什么问题?

MVVM 是 Model-View-ViewModel 的缩写,是一种将 UI 表现层与业务逻辑层分离的架构模式。三层的职责:Model:数据与业务逻辑,包括数据模型、API 请求、数据校验等View:UI 展示层,负责渲染界面和接收用户交互ViewModel:连接 Model 与 View 的桥梁,通过数据绑定让两者自动同步MVVM 要解决的核心问题是 View 与 Model 之间的直接耦合。在传统 MVC 中,View 可能直接读取 Model 数据,Model 变化后手动通知 View 更新,随着业务增长,Controller 膨胀成 Massive View Controller,View 和 Model 互相引用导致维护困难。MVVM 通过 ViewModel 把两者彻底解耦——View 只依赖 ViewModel,Model 完全不知道 View 的存在,通信通过数据绑定自动完成。Vue 是典型的 MVVM 实践:template 是 View,data/computed 是 Model,Vue 实例充当 ViewModel。通过响应式系统(Vue 2 的 Object.defineProperty / Vue 3 的 Proxy)拦截数据变化,配合模板编译和虚拟 DOM diff,实现 View 与 Model 的自动同步,开发者不再需要手动操作 DOM。双向绑定的实现原理双向绑定是 MVVM 的核心机制,Vue 的实现分为三个关键部分:数据劫持(Observer):递归遍历 data 对象,用 Object.defineProperty(Vue 2)或 Proxy(Vue 3)拦截属性的 get/set,每个属性对应一个 Dep(依赖收集器)模板编译(Compiler):解析模板中的指令(v-model、插值语法、v-bind 等),为每个绑定创建一个 Watcher依赖收集与派发更新(Watcher + Dep):get 时收集依赖(Watcher 订阅 Dep),set 时通知 Dep 派发更新,Watcher 触发视图重新渲染以 v-model 为例:编译时为 input 元素添加 input 事件监听,用户输入时将值赋给数据属性(View 到 Model);同时为该属性创建 Watcher,属性变化时更新 input 的 value(Model 到 View),由此形成双向数据流。MVVM 与 MVC、MVP 的区别| 对比项 | MVC | MVP | MVVM ||--------|-----|-----|------|| 通信方式 | View 和 Model 可直接通信 | View 和 Model 通过 Presenter 通信 | View 和 Model 通过 ViewModel + 数据绑定通信 || 中间层职责 | Controller 处理路由和输入 | Presenter 包含业务逻辑 | ViewModel 暴露视图状态 || View 与 Model 耦合 | 可能直接引用 | 完全解耦 | 完全解耦 || 手动同步 | 需要手动更新 View | Presenter 手动调用 View 接口 | 数据绑定自动同步 || 可测试性 | 较低 | 较高 | 最高(ViewModel 无 UI 依赖) |MVC 适合服务端渲染场景(如 Rails),MVP 适合 Android 早期开发,MVVM 适合数据驱动的 UI 框架(Vue、WPF)。选择哪种模式取决于项目复杂度和框架特性。Vue 严格来说是 MVVM 吗?不完全。尤雨溪明确表示 Vue 受 MVVM 启发但不是严格的 MVVM 实现,原因如下:Model 不是独立层:Vue 的 Model 是普通 JS 对象(data),而非独立的领域模型抽象ViewModel 不是独立可测试层:Vue 实例同时包含响应式数据、生命周期钩子、方法等,与组件耦合提供了 $refs:$refs 允许直接操作 DOM 子组件,打破了 ViewModel 与 View 的隔离原则组件化优先于架构模式:Vue 的核心思想是组件化组合而非严格的分层架构所以 Vue 更准确的定位是响应式组件化框架,MVVM 只是它的灵感来源和教学标签。为什么现在新框架很少强调 MVVM 了?因为框架的竞争维度变了:2012-2016 年:框架竞争点是架构模式——AngularJS 推 MVC,Knockout 推 MVVM,Backbone 推 MVP,开发者需要被教怎么组织代码2016 年至今:架构模式已成共识,竞争点转向组件化、响应式粒度、编译优化、开发体验。React Hooks 让函数式组件替代 Class,Vue 3 Composition API 让逻辑复用更灵活,Svelte 用编译时抹掉运行时MVVM 作为概念依然存在(数据绑定仍是所有现代框架的基石),但不再是框架的卖点,而成了底层实现细节。面试中被问到时,重点是理解其解决耦合问题的思路,而非背诵定义。追问MVVM 的缺点是什么?调试困难:数据绑定是隐式的,数据流向不如命令式代码直观,Bug 定位需要追踪绑定链路内存开销:每个绑定都创建 Watcher,大量绑定会占用更多内存过度绑定风险:双向绑定容易导致数据流混乱,特别是跨组件通信时状态变更难以追溯不适合简单 UI:对于静态展示页面,引入 MVVM 框架是过度设计Vue 3 为什么从 Object.defineProperty 改用 Proxy?Object.defineProperty 有三个缺陷:无法监听属性的新增和删除(所以 Vue 2 需要 Vue.set / Vue.delete)无法监听数组索引和 length 变化(Vue 2 通过重写数组方法修补)需要递归遍历对象的每个属性,初始化性能差Proxy 可以代理整个对象,一次性解决以上所有问题,且是惰性响应式(只在访问时才递归代理子对象),初始化性能更好。这也是 Vue 3 大型项目性能提升的关键因素之一。面试中如何简洁地回答什么是 MVVM?三句话结构:MVVM 是 Model-View-ViewModel 架构模式,通过 ViewModel 的数据绑定让 View 和 Model 自动同步它解决了传统 MVC 中 View 和 Model 直接耦合的问题,用 ViewModel 把两者彻底解耦Vue 是典型实践,通过响应式系统拦截数据变化配合模板编译实现双向绑定,但 Vue 并非严格 MVVM(有 $refs 等例外)
前端阅读 365月28日 03:20

如何实现 JavaScript 的 bind 方法?

bind 是 JavaScript 中显式绑定 this 的三种方式之一,与 call、apply 不同,bind 不会立即执行函数,而是返回一个绑定了 this 和预设参数的新函数。面试中考察 bind 的实现,重点在于:this 绑定、参数柯里化、new 调用兼容、原型链维护。最简实现:绑定 this + 预设参数bind 的核心行为只有两件事:绑定 this 上下文,预设部分参数。Function.prototype.myBind = function(context, ...args) { const fn = this; return function(...newArgs) { return fn.apply(context, [...args, ...newArgs]); };};这段代码保存了原函数引用 fn,返回的新函数在调用时将预设参数 args 和新传入参数 newArgs 合并,通过 apply 将 this 绑定到 context 执行。验证:const obj = { name: 'Alice' };function greet(greeting, punctuation) { return `${greeting}, ${this.name}${punctuation}`;}const bound = greet.myBind(obj, 'Hello');console.log(bound('!')); // "Hello, Alice!"兼容 new 调用上面的实现在 new bound() 时会出错——new 创建的实例本应作为 this,但被 bind 的 context 覆盖了。bind 的规范要求:new 的优先级高于 bind 绑定的 this。Function.prototype.myBind = function(context, ...args) { const fn = this; return function bound(...newArgs) { // new 调用时 this instanceof bound 为 true,应使用 new 创建的实例 return fn.apply( this instanceof bound ? this : context, [...args, ...newArgs] ); };};this instanceof bound 是判断是否通过 new 调用的关键:当 new bound() 执行时,this 指向新创建的实例对象,该实例的原型链上有 bound.prototype,因此 instanceof 返回 true。验证:function Person(name) { this.name = name;}const BoundPerson = Person.myBind({ name: 'ignored' });const p = new BoundPerson('Bob');console.log(p.name); // "Bob",而非 "ignored"维护原型链上面的实现仍有一个问题:通过 new bound() 创建的实例无法访问原函数原型上的属性和方法。Person.prototype.sayHi = function() { return `Hi, I'm ${this.name}`;};const p2 = new BoundPerson('Carol');console.log(p2.sayHi()); // TypeError: p2.sayHi is not a function原因是 bound 函数的 prototype 并没有指向 fn.prototype。需要用一个空对象作为桥接:Function.prototype.myBind = function(context, ...args) { if (typeof this !== 'function') { throw new TypeError('Bind must be called on a function'); } const fn = this; const fNOP = function() {}; const bound = function(...newArgs) { return fn.apply( this instanceof bound ? this : context, [...args, ...newArgs] ); }; // 维护原型链:让 bound 的实例能访问原函数的原型 fNOP.prototype = fn.prototype; bound.prototype = new fNOP(); return bound;};为什么用 fNOP 做中介而不是直接 bound.prototype = fn.prototype?因为直接赋值后,修改 bound.prototype 会同步影响 fn.prototype,这不符合预期。中介对象隔断了这种引用关系。验证:const p3 = new BoundPerson('Dave');console.log(p3.sayHi()); // "Hi, I'm Dave"console.log(p3 instanceof Person); // true边界情况与细节this 不是函数时抛出错误原生 bind 在非函数值上调用会抛出 TypeError,实现中应当对齐这一行为,已在上面的完整实现中包含。箭头函数的 bind 无效箭头函数没有自己的 this,其 this 由外层词法作用域决定。对箭头函数调用 bind 虽然不会报错,但绑定的 this 不会生效。const arrow = () => this;const boundArrow = arrow.myBind({ x: 1 });console.log(boundArrow()); // 仍是外层 this,而非 { x: 1 }bind 返回的函数没有 prototype 属性原生 bind 返回的绑定函数 prototype 为 undefined,不能作为构造函数使用(虽然通过 new 仍可调用,但原型链由内部 [[Constructor]] 处理)。这一点在深度追问时需要注意。多次 bind 不会叠加 thisbind 返回的函数内部 this 已固定,再次 bind 无法改变。但预设参数会叠加。function fn(a, b, c) { return [a, b, c]; }const bound1 = fn.bind({ x: 1 }, 1);const bound2 = bound1.bind({ x: 2 }, 2);console.log(bound2(3)); // [1, 2, 3],this 仍是 { x: 1 }追问bind 返回的函数再用 new 调用会发生什么?new 优先级高于 bind。new 创建的新对象会作为 this,bind 指定的 this 被忽略,但预设参数仍然生效。这是 ES5 规范明确规定的优先级:new > bind > apply/call > 默认绑定。call、apply、bind 三者区别?call(thisArg, arg1, arg2, ...):逐个传参,立即执行apply(thisArg, [arg1, arg2]):数组传参,立即执行bind(thisArg, arg1, arg2):预设 this 和参数,返回新函数,不立即执行三者的核心差异在于执行时机和传参方式。实际开发中 bind 常用于事件回调保留上下文,call/apply 常用于借用方法或类数组转数组。为什么要用 fNOP 做中介而不是直接赋值 prototype?直接 bound.prototype = fn.prototype 会导致两者引用同一个对象,后续对 bound.prototype 的修改(如添加属性)会污染原函数的原型。fNOP 中介创建了一个以 fn.prototype 为原型的新对象,既保证 instanceof 正确,又隔离了修改影响。
前端阅读 415月28日 03:20

如何删除一个 Cookie 值?

前端删除 Cookie 的核心思路是让浏览器判定它已过期,浏览器会在下次清理时自动移除。JavaScript 没有原生的 deleteCookie API,只能通过 document.cookie 重新设置同名 Cookie 来"覆盖"并使其失效。方法一:设置 expires 为过去时间document.cookie = 'key=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/';这是最经典的做法,兼容所有浏览器。expires 接收一个 HTTP 日期格式的字符串,只要时间早于当前时间,浏览器就会删除该 Cookie。方法二:设置 max-age 为 0document.cookie = 'key=; max-age=0; path=/';max-age 是 HTTP/1.1 的属性,单位为秒。设为 0 表示立即过期。现代浏览器优先使用 max-age,当 expires 和 max-age 同时存在时,max-age 优先生效。IE9 以下不支持 max-age,但这类浏览器已基本退出市场。删除失败的常见原因这是面试中最容易踩坑的地方,也是区分"背答案"和"真正理解"的分水岭。path 和 domain 必须匹配Cookie 的唯一标识是 name + domain + path 三元组。删除时这三项必须与原 Cookie 完全一致,否则浏览器会创建一个新 Cookie 而非删除旧的:// 原 Cookie 设置时指定了 path=/ 和 domain=.example.com// 删除时也必须带上相同的 path 和 domaindocument.cookie = 'key=; max-age=0; path=/; domain=.example.com';// 错误示例:漏掉 path,默认使用当前路径(如 /user)// 这会在 /user 下创建一个新 Cookie,原来的 Cookie 仍在document.cookie = 'key=; max-age=0'; // 删除失败面试中经常追问:如果不指定 path,默认值是什么? 答案是当前页面的路径(location.pathname),不是 /。这是很多人删除 Cookie 失败的首要原因。HttpOnly 的 Cookie 前端无法删除HttpOnly 标记的 Cookie 对 JavaScript 完全不可见,document.cookie 读取不到,自然也无法删除。只能由服务端通过 Set-Cookie 响应头来删除:Set-Cookie: key=; max-age=0; path=/; HttpOnly子域名与父域名的权限隔离浏览器的安全策略规定:子域名页面无法删除父域名设置的 Cookie。如果 Cookie 的 domain 是 .example.com,在 sub.example.com 页面上删除时必须指定 domain=.example.com:// 在 sub.example.com 页面执行document.cookie = 'key=; max-age=0; path=/; domain=.example.com';反过来,父域名页面也无法删除子域名专属的 Cookie(即 domain 设为 sub.example.com 的 Cookie)。这是浏览器的域隔离机制,防止跨域篡改。服务端删除 Cookie当 Cookie 设置了 HttpOnly 或者需要确保一定删除成功时,应该由服务端通过 Set-Cookie 响应头操作:// Node.js / Express 示例res.setHeader('Set-Cookie', 'key=; max-age=0; path=/; HttpOnly; Secure; SameSite=Lax');服务端删除的好处是可以操作所有属性的 Cookie,包括 HttpOnly 和 Secure 标记的。退出登录场景通常由后端统一清除认证 Cookie,前端只负责跳转。批量删除当前域所有 Cookie实际开发中有时需要清除当前域名下所有 Cookie(如退出登录、切换账号):function clearAllCookies() { const cookies = document.cookie.split(';'); for (const cookie of cookies) { const name = cookie.split('=')[0].trim(); // 尝试多种 path 组合,确保能匹配到 document.cookie = `${name}=; max-age=0; path=/`; document.cookie = `${name}=; max-age=0`; }}注意这个方法只能删除非 HttpOnly 的 Cookie,且如果 Cookie 设置了特定 domain,单纯遍历 document.cookie 是拿不到 domain 信息的,可能删不干净。追问Secure Cookie 前端能操作吗?HTTPS 环境下可以。Secure 属性只限制传输层面——Cookie 不会随 HTTP(非加密)请求发送,但 document.cookie 的读写是 JavaScript 层面的操作,与传输协议无关。在 HTTPS 页面上,JS 可以正常读取和删除带 Secure 的 Cookie。SameSite 属性对删除有影响吗?没有。SameSite 控制的是 Cookie 在跨站请求中是否发送(防 CSRF),不影响 JS 对 Cookie 的删除操作。删除时不需要指定 SameSite 属性。但需要注意:Chrome 80+ 将未设置 SameSite 的 Cookie 默认视为 Lax,这影响的是请求发送行为,与删除无关。会话 Cookie(Session Cookie)怎么删除?会话 Cookie 没有设置 expires 或 max-age,浏览器关闭后自动消失。但如果想在当前会话中主动删除它,方法一样——设置 max-age=0 或 expires 为过去时间即可。会话 Cookie 和持久化 Cookie 的删除方式没有区别。删除 Cookie 时 value 要留空吗?推荐留空但不强制。document.cookie = 'key=; max-age=0' 和 document.cookie = 'key=deleted; max-age=0' 都能删除。留空只是语义更清晰。真正触发删除的是 max-age=0 或过去的 expires,与 value 无关。
前端阅读 455月28日 03:20

React 项目中常见的内存泄漏场景有哪些?

核心场景React 组件卸载后,与之关联的副作用如果没有同步清除,就会产生内存泄漏。实际项目中主要分四类:异步操作更新已卸载组件的状态在组件内发起 fetch 请求,组件卸载了请求才回来,此时 setState 会触发 React 的 warning(React 18 下已移除该 warning,但逻辑上的泄漏仍然存在)。setTimeout/setInterval 同理——组件卸载了定时器还在跑,回调里访问了过期的 state 或调用 setState。// 泄漏写法useEffect(() => { fetch('/api/data').then(res => res.json()).then(setData);}, []);// 修复:用 AbortController 取消请求useEffect(() => { const controller = new AbortController(); fetch('/api/data', { signal: controller.signal }) .then(res => res.json()) .then(setData) .catch(err => { if (err.name !== 'AbortError') throw err; }); return () => controller.abort();}, []);事件监听未移除在 useEffect 里给 window、document 或 DOM 节点添加了 resize、scroll、keydown 等监听,但 cleanup 里没有 removeEventListener。监听回调持有组件作用域的引用,组件实例无法被 GC 回收。useEffect(() => { const handleResize = () => setWidth(window.innerWidth); window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize);}, []);闭包持有大对象引用useCallback/useMemo/useRef 的闭包里引用了不再需要的大对象(比如接口返回的完整列表、Blob 数据等),只要闭包还在,这些对象就无法被回收。常见于列表页缓存整个数据集却只展示部分。// 泄漏:闭包引用了全量数据const filteredList = useMemo(() => largeList.filter(fn), [largeList]);// 优化:只保留需要的字段,尽早释放原引用const filteredList = useMemo( () => largeList.map(item => ({ id: item.id, name: item.name })).filter(fn), [largeList]);未清理的订阅与第三方实例WebSocket、EventSource、IntersectionObserver、ResizeObserver、MutationObserver 这些 API 都需要手动 close()/disconnect()。第三方库(如 Redux 的 subscribe、RxJS 的 Observable.subscribe、ECharts 实例)同理,组件卸载时必须调用对应的销毁方法。useEffect(() => { const ws = new WebSocket('wss://example.com'); ws.onmessage = (e) => setMessage(JSON.parse(e.data)); return () => ws.close();}, []);修复思路核心原则:useEffect 的 setup 和 cleanup 必须对称——setup 里获取了什么资源,cleanup 里就要释放什么。具体做法:异步请求用 AbortController 取消定时器用 clearTimeout/clearInterval 清除DOM 事件用 removeEventListener 移除订阅类 API 调用 close()/disconnect()/unsubscribe()大对象引用用 useRef 配合手动置 null 释放追问useEffect 的 cleanup 什么时候执行?组件卸载时,或者下一次 effect 执行前(依赖项变化时先跑旧 cleanup 再跑新 effect)。React 18 StrictMode 下开发模式会额外执行一次 setup+cleanup 来暴露遗漏的清理逻辑。怎么排查 React 内存泄漏?Chrome DevTools → Memory 面板 → 取 Heap Snapshot。操作组件(挂载→卸载→挂载→卸载),对比两次 snapshot。如果 Detached DOM 节点或组件实例数量持续上升,说明有泄漏。也可以用 React DevTools Profiler 观察 React 组件树是否出现已卸载组件仍存在的现象。React 18 对内存泄漏有什么影响?React 18 移除了 "Can't perform a React state update on an unmounted component" 的 warning,但泄漏本身并未消失——未清理的闭包引用和事件监听仍然存在。同时 StrictMode 在开发模式下双重挂载组件,更容易暴露 cleanup 遗漏的问题。此外并发特性(Suspense、useTransition)引入了组件挂载-卸载-重新挂载的新生命周期,对 cleanup 的正确性要求更高。
前端阅读 895月28日 03:16

什么是 Virtual DOM?React 为什么用它替代直接操作 DOM?

Virtual DOM 是一棵用 JavaScript 对象描述的 DOM 树。React 状态变化时,先在内存里生成新的虚拟 DOM 树,和旧树做 diff,算出最小变更集,再批量更新真实 DOM。这么做的核心原因是——直接操作真实 DOM 太贵了,一次 appendChild 可能触发重排+重绘+合成三层渲染管线。Virtual DOM 把"手动精确定位变更"这件事自动化了:你只管声明 UI 长什么样,React 负责高效地同步到真实 DOM。顺带一提,正因为 UI 描述和渲染层解耦,React Native 才能复用同一套组件模型渲染原生控件。追问Virtual DOM 一定比直接操作 DOM 快吗?不是。单改一个文本节点,el.textContent = 'xxx' 比 Virtual DOM diff 整棵子树更快。Virtual DOM 的价值在复杂 UI 场景:几十个组件同时更新时,它能自动算出最优更新路径,避免开发者手动 diff。所以它不是"最快",而是"在绝大多数场景下足够快,且不需要你操心"。React 的 diff 算法怎么做到 O(n) 的?朴素的树 diff 是 O(n³),React 用三个策略降到 O(n):| 策略 | 含义 ||------|------|| 类型不同直接重建 | <div> 变成 <span>,整个子树丢弃重建,不跨类型 diff || key 标识稳定性 | 通过 key 追踪同一组子元素中的身份,避免错位复用 || 只比较同级 | 不跨层级比较,父亲和儿子不会互相匹配 |代价是可能遗漏极少数跨层级移动的最优解,但实际场景中跨层级移动极少,这个权衡是值得的。Vue 的 Virtual DOM 和 React 有什么区别?Vue 的响应式系统精确追踪了"哪个组件依赖了哪个数据",数据变了可以直接跳过无关组件的 diff。React 默认从触发更新的组件开始整棵子树 diff,需要 React.memo、useMemo 手动优化。但 React 18 并发模式可以在调度层面拆分任务、让高优先级更新插队,这是 Vue 目前没有的。实际项目里 Virtual DOM 有什么常见的坑?index 做 key:列表增删元素时 diff 错位,导致非预期复用和状态错乱。用唯一 ID 做 key。大列表没有虚拟化:Virtual DOM 只解决 diff 效率,不解决渲染量。几千行的长列表必须上 react-window / react-virtuoso 做虚拟滚动。不必要的重渲染:父组件 state 变了,即使子组件 props 没变也会 diff。React.memo 和 useMemo 不是过早优化,是 React 开发的日常操作。