本轮要点: 本次面试主要考察前端基础知识和实际应用能力。
本轮共 20 道题。答案默认折叠,便于先自行作答。
1. 实现一个宽度自适应的搜索框。
题目要点
- CSS布局能力: 面试官希望确认候选人对CSS的布局方式(如Flexbox、Grid或传统布局)的掌握程度,以及如何实现元素的响应式和自适应。
- 响应式设计: 考察候选人是否能考虑不同屏幕尺寸下的用户体验,并运用CSS媒体查询、视口单位等技术实现宽度自适应。
- HTML结构: 确认候选人是否能编写语义化且结构清晰的HTML代码。
参考答案
1.1 原理说明
宽度自适应的搜索框是指搜索框的宽度能够根据其父容器的宽度或视口宽度自动调整,以适应不同的屏幕尺寸和设备。实现这种效果通常依赖于CSS的弹性布局(Flexbox)或网格布局(Grid),结合相对单位(如百分比、vw/vh)或弹性单位(如flex属性)。其核心思想是让元素能够"伸缩自如",而不是固定尺寸。
1.2 核心用法 + 示例代码
以下是使用Flexbox实现宽度自适应搜索框的示例:
<div class="search-container">
<input type="text" class="search-input" placeholder="请输入搜索内容">
<button class="search-button">搜索</button>
</div>
.search-container {
display: flex;
width: 100%; /* 占据父容器的全部宽度 */
max-width: 600px; /* 设置最大宽度,防止过宽 */
margin: 20px auto; /* 居中显示 */
border: 1px solid #ccc;
border-radius: 5px;
overflow: hidden; /* 防止内容溢出 */
}
.search-input {
flex-grow: 1; /* 占据剩余空间 */
padding: 10px;
border: none;
outline: none; /* 移除聚焦时的边框 */
font-size: 16px;
}
.search-button {
padding: 10px 15px;
background-color: #007bff;
color: white;
border: none;
cursor: pointer;
font-size: 16px;
}
.search-button:hover {
background-color: #0056b3;
}
/* 媒体查询,适应小屏幕 */
@media (max-width: 768px) {
.search-container {
max-width: 90%;
}
}
场景说明:
- 响应式布局: 在PC端,搜索框可以占据较大的宽度;在移动端,通过媒体查询和百分比宽度,可以使其适应屏幕宽度。
- 组件化开发: 这种自适应的搜索框可以作为一个独立的UI组件,方便在不同页面或项目中复用。
- 提升用户体验: 无论用户在何种设备上访问,都能获得良好的搜索体验,避免了内容溢出或布局错乱的问题。
该方案利用Flexbox的flex-grow: 1特性,使得输入框能够自动占据父容器的剩余空间,从而实现宽度自适应。同时,通过max-width和margin: auto实现了居中和最大宽度限制,通过媒体查询增强了在小屏幕设备上的适应性。相比于传统的浮动布局或固定宽度布局,Flexbox在实现响应式方面更加简洁和高效。
1.3 常见误区或面试陷阱
- 只使用固定宽度: 候选人可能只考虑了桌面端布局,使用固定的像素宽度,导致在移动设备上显示不佳,未体现"自适应"的特性。
- 滥用
position: absolute: 尝试使用绝对定位来实现布局,这会脱离文档流,导致布局难以维护且响应性差。 - 忽略
max-width: 在设计自适应布局时,如果只设置width: 100%而不设置max-width,在大屏幕上搜索框可能会变得过宽,影响美观和用户体验。 - 未考虑输入框和按钮的对齐: 在使用Flexbox时,如果没有正确设置
align-items等属性,可能会导致输入框和按钮不对齐。 - 未处理聚焦和点击效果: 忽略了搜索框在聚焦时的样式以及按钮的点击反馈,影响用户交互体验。
2. HTML5 提供了哪些本地存储方式?
题目要点
- 前端存储技术: 考察候选人对HTML5提供的多种本地存储机制的了解程度及其特性。
- 应用场景: 面试官希望确认候选人是否能根据不同的业务需求,选择合适的存储方式。
- 存储限制与安全性: 考察候选人是否了解各种存储方式的容量限制、生命周期以及潜在的安全问题。
参考答案
2.1 原理说明
HTML5提供了多种本地存储方式,它们允许Web应用程序在客户端(用户浏览器)存储数据,而不需要通过网络与服务器进行交互。这有助于提升应用性能、减少网络请求、实现离线功能以及改善用户体验。主要的本地存储方式包括:
- LocalStorage: 提供了一个持久化的存储区域,数据没有过期时间,除非被清除,否则会一直存在。数据以键值对的形式存储,且只能存储字符串。
- SessionStorage: 与LocalStorage类似,但其生命周期仅限于当前会话。当浏览器标签页或窗口关闭时,SessionStorage中的数据会被清除。同样以键值对的形式存储字符串。
- IndexedDB: 一个低级的API,用于在客户端存储大量的结构化数据,并提供了索引功能,支持事务操作。它是一个非关系型数据库,适用于需要存储大量复杂数据的场景。
- Web SQL Database: 这是一个废弃的规范,允许在浏览器中存储关系型数据,使用SQL查询语言。虽然一些浏览器仍支持,但不推荐在新项目中使用。
- Cookies: 尽管不是HTML5特有的,但Cookies是传统的客户端存储方式。数据会在每次HTTP请求时发送到服务器,容量小(约4KB),且有过期时间。主要用于会话管理、个性化设置等。
LocalStorage与SessionStorage的区别:
- 生命周期: LocalStorage数据永久保存,SessionStorage数据随会话结束而清除。
- 作用域: LocalStorage在同一浏览器中的所有同源页面共享;SessionStorage只在当前浏览器标签页(或窗口)中有效,不同标签页之间不共享。
2.2 核心用法 + 示例代码
LocalStorage 和 SessionStorage 的使用:
它们提供了相同的API,主要包括:
setItem(key, value): 存储数据。getItem(key): 获取数据。removeItem(key): 删除数据。clear(): 清空所有数据。key(index): 获取指定索引的键名。length: 获取存储的键值对数量。
// LocalStorage 示例
localStorage.setItem('username', 'Alice');
const username = localStorage.getItem('username');
console.log('LocalStorage username:', username); // Alice
localStorage.removeItem('username');
// SessionStorage 示例
sessionStorage.setItem('tempData', 'This is temporary data');
const tempData = sessionStorage.getItem('tempData');
console.log('SessionStorage tempData:', tempData); // This is temporary data
// 当标签页关闭时,tempData 将被清除
IndexedDB 的基本使用流程:
- 打开数据库。
- 创建对象仓库(Object Store)。
- 添加、获取、更新或删除数据。
- 处理事务和游标。
由于IndexedDB API较为复杂,这里提供一个简化的打开数据库和添加数据的示例:
// IndexedDB 示例 (简化)
const request = indexedDB.open('myDatabase', 1); // 打开数据库,版本号1
request.onupgradeneeded = function(event) {
// 数据库升级或首次创建时触发
const db = event.target.result;
if (!db.objectStoreNames.contains('users')) {
db.createObjectStore('users', { keyPath: 'id' }); // 创建对象仓库
}
};
request.onsuccess = function(event) {
const db = event.target.result;
const transaction = db.transaction(['users'], 'readwrite');
const objectStore = transaction.objectStore('users');
const user = { id: 1, name: 'Bob', age: 30 };
const addRequest = objectStore.add(user); // 添加数据
addRequest.onsuccess = function() {
console.log('User added to IndexedDB');
};
addRequest.onerror = function() {
console.error('Error adding user');
};
};
request.onerror = function(event) {
console.error('IndexedDB error:', event.target.errorCode);
};
项目中的应用场景:
- LocalStorage:
- 存储用户偏好设置: 例如主题模式、语言设置,用户关闭浏览器后再次打开仍能保持。
- 离线缓存: 存储不经常变动的数据,如静态资源、文章内容,实现离线访问或加速加载。
- 购物车数据: 暂存用户未提交的购物车商品信息。
- SessionStorage:
- 表单数据: 在多步骤表单中暂存用户输入,防止页面刷新或跳转后数据丢失。
- 临时会话数据: 存储一次性使用的会话相关数据,如登录凭证的临时副本(不推荐存储敏感信息)。
- IndexedDB:
- 大量数据存储: 适用于需要存储大量结构化数据(如离线邮件、笔记、大型游戏数据)的Web应用。
- 复杂查询: 提供了索引和事务支持,适合进行复杂的查询操作。
优势:
- 性能提升: 从本地读取数据比从服务器获取更快,减少网络延迟。
- 离线支持: 允许Web应用在没有网络连接的情况下也能工作。
- 减轻服务器负担: 减少了服务器的数据请求压力。
- 用户体验优化: 快速加载内容,提供更流畅的交互。
2.3 常见误区或面试陷阱
- 混淆LocalStorage和SessionStorage的生命周期和作用域: 这是最常见的错误,需要明确它们在数据持久性、跨标签页共享方面的区别。
- LocalStorage存储敏感信息: LocalStorage数据是明文存储且容易被XSS攻击获取,不适合存储用户的敏感信息(如密码、银行卡号)。
- 不了解IndexedDB的复杂性: 认为IndexedDB像LocalStorage一样简单,但在实际使用中其异步操作、事务管理和游标等概念相对复杂,需要更深入的理解。
- 将Cookies作为主要存储: 尽管Cookies也是存储方式,但其容量小,每次请求都会携带,对性能有影响,且安全性较低,不应作为主要的数据存储方案,而应专注于其会话管理的传统用途。
- 未考虑数据类型: LocalStorage和SessionStorage只能存储字符串。如果需要存储对象或数组,必须先使用
JSON.stringify()进行序列化,并在读取后使用JSON.parse()反序列化,面试时可能会忘记提及这一点。 - 不处理错误: 在使用IndexedDB等异步API时,未添加错误处理逻辑,可能导致应用崩溃或数据丢失。
3. JavaScript 中的原型和原型链。
题目要点
- 核心概念理解: 面试官希望确认候选人对JavaScript中"原型"(
prototype)和"原型链"(prototype chain)这两个核心概念的深刻理解,它们是JavaScript实现继承和对象属性查找的基础。 - 属性查找机制: 考察候选人是否清楚当访问一个对象的属性时,JavaScript引擎是如何沿着原型链进行查找的。
- 继承实现: 确认候选人是否能阐述原型和原型链在实现JavaScript面向对象继承中的作用。
- 与
__proto__和constructor的关系: 考察候选人对这些相关属性的认知及其在原型机制中的作用。
参考答案
3.1 原理说明
在JavaScript中,每个对象都有一个内部属性[[Prototype]],通常通过__proto__(非标准,但广泛实现)或Object.getPrototypeOf()访问。这个属性指向该对象的原型对象。当试图访问一个对象的属性时,如果该对象本身没有这个属性,JavaScript会沿着[[Prototype]]链向上查找,直到找到该属性或到达原型链的顶端(null)。这就是原型链的工作方式。
而**原型(prototype)**是函数特有的一个属性。每个函数在创建时都会自动获得一个prototype属性,这个属性是一个对象,它包含了所有由该函数创建的实例对象所共享的属性和方法。也就是说,当一个函数作为构造函数被调用时,新创建的实例对象的__proto__属性就会指向构造函数的prototype对象。
联系和区别:
- 联系: 原型链是由一系列对象的
__proto__属性连接起来的,而这些__proto__属性最终指向的是构造函数的prototype对象。prototype是构造函数定义其实例共享属性和方法的"蓝图",而__proto__则是实例与这个"蓝图"建立连接的桥梁。 - 区别:
prototype是函数独有的属性,它是一个普通的JavaScript对象,用于实现继承。__proto__是每个对象(包括函数对象)都有的内部属性,它指向该对象的原型。通常情况下,Function.prototype.__proto__指向Object.prototype,而Object.prototype.__proto__为null,构成了原型链的顶端。
3.2 核心用法 + 示例代码
1. 原型属性的添加与访问:
function Person(name) {
this.name = name;
}
// 在Person的原型上添加方法
Person.prototype.sayHello = function() {
console.log(`Hello, my name is ${this.name}`);
};
const person1 = new Person('Alice');
person1.sayHello(); // 输出: Hello, my name is Alice
const person2 = new Person('Bob');
person2.sayHello(); // 输出: Hello, my name is Bob
// 验证sayHello方法是共享的
console.log(person1.sayHello === person2.sayHello); // true
上述代码中,sayHello方法被定义在Person.prototype上,而不是每个Person实例上。这意味着所有Person的实例都会共享同一个sayHello方法,节省了内存。
2. 原型链的体现(属性查找):
function Animal(name) {
this.name = name;
}
Animal.prototype.eat = function() {
console.log(`${this.name} is eating.`);
};
function Dog(name, breed) {
Animal.call(this, name); // 调用父类构造函数继承属性
this.breed = breed;
}
// 建立原型链:Dog.prototype 继承自 Animal.prototype
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog; // 修复构造函数指向
Dog.prototype.bark = function() {
console.log(`${this.name} (${this.breed}) is barking.`);
};
const myDog = new Dog('Buddy', 'Golden Retriever');
myDog.bark(); // 输出: Buddy (Golden Retriever) is barking.
myDog.eat(); // 输出: Buddy is eating.
// 属性查找过程:
// 1. myDog 对象本身没有 eat 方法。
// 2. 沿着 myDog.__proto__ (即 Dog.prototype) 查找,也没有 eat 方法。
// 3. 沿着 Dog.prototype.__proto__ (即 Animal.prototype) 查找,找到 eat 方法。
在这个例子中,myDog实例可以直接调用eat方法,尽管eat方法并没有直接定义在myDog对象或Dog.prototype上。这是因为JavaScript引擎通过原型链向上查找,最终在Animal.prototype上找到了该方法。
项目中的应用:
- 继承: 原型链是JavaScript实现继承的核心机制。通过调整原型链,可以实现类(ES6 Classes本质上是语法糖)和对象之间的继承关系。
- 性能优化: 将方法和共享属性定义在原型上,可以避免在每个实例上创建这些属性,从而节省内存。
- 扩展内置对象: 尽管不推荐直接修改内置对象的原型,但在某些特定场景下,可以通过原型扩展其功能(例如,polyfill)。
3.3 常见误区或面试陷阱
混淆
prototype和__proto__: 这是最常见的错误。prototype是函数的属性,而__proto__是实例的属性(指向其构造函数的prototype)。误以为
this指向原型: 在原型方法中,this始终指向调用该方法的对象实例,而不是原型对象本身。直接修改
__proto__: 尽管某些环境中可以直接修改__proto__,但这种做法是反模式的,会带来性能问题和调试困难。应该使用Object.setPrototypeOf()或Object.create()。不理解
constructor属性的作用: 当通过Object.create()修改原型时,常常会忘记修复constructor属性的指向,这可能导致instanceof等判断出现问题。引用类型属性的共享问题: 如果在原型上定义了引用类型(如数组或对象),所有实例都会共享同一个引用。修改一个实例的引用类型属性会影响到所有其他实例,这通常不是期望的行为。
function MyObject() {} MyObject.prototype.arr = []; // 引用类型属性 const obj1 = new MyObject(); const obj2 = new MyObject(); obj1.arr.push(1); // 修改obj1.arr console.log(obj2.arr); // 输出: [1] - obj2.arr 也被修改了为了避免这个问题,引用类型属性通常应该在构造函数中初始化,而不是在原型上。
原型链过长导致的性能问题: 尽管不常见,但如果原型链过长,属性查找的开销会增加,可能影响性能。但在绝大多数实际应用中,原型链的长度都在可接受范围内。
4. 如果要实现一个继承自 Array 的自定义构造函数,应该怎么做
题目要点
- ES5/ES6 继承机制: 面试官希望确认候选人对JavaScript中不同版本实现继承的方式(特别是ES6的
class和extends)的理解,以及如何处理内置对象的继承。 - 内置对象继承的特殊性: 考察候选人是否了解继承内置对象(如
Array、Error、Map等)与继承普通对象之间的区别和挑战。 super关键字: 确认候选人对super在子类构造函数中调用父类构造函数的作用的理解。- 原型链的维护: 考察候选人是否能正确维护继承后的原型链,以确保子类实例拥有父类的行为和属性。
参考答案
4.1 原理说明
在JavaScript中实现继承自 Array 的自定义构造函数,意味着我们希望创建一个新的类,它不仅拥有 Array 的所有内置方法(如 push, pop, map, filter 等),还能添加我们自己的自定义方法和属性,并且其实例的行为与普通数组一致。这个过程涉及到正确地建立原型链和处理 this 的指向。
在ES5及之前,直接继承内置构造函数(如 Array)非常复杂且有许多坑,因为内置构造函数通常会创建具有特殊内部属性的实例,这些内部属性很难在自定义构造函数中正确初始化。例如,ES5中无法直接在自定义构造函数中调用Array.apply(this, arguments)来创建一个真正的数组实例,因为Array构造函数在非严格模式下调用时,并不会初始化this为一个数组对象,而是返回一个新的数组对象,这导致了this绑定问题。
ES6引入了class语法糖和extends关键字,极大地简化了内置对象的继承。在ES6中,当一个类extends另一个内置构造函数时,子类的构造函数会正确地处理父类的this上下文,并允许子类实例拥有父类实例的特殊内部特性。这意味着super()在子类构造函数中的调用,不仅执行父类构造函数,还会正确地初始化this指向父类实例,并返回这个实例。
4.2 核心用法 + 示例代码
使用ES6 class 和 extends 实现继承:
这是实现继承自 Array 的自定义构造函数的最推荐和最简洁的方式。
class MyArray extends Array {
constructor(...args) {
super(...args); // 调用父类(Array)的构造函数
this.customProperty = 'This is a custom array';
}
// 添加自定义方法
first() {
return this[0];
}
last() {
return this[this.length - 1];
}
// 可以重写Array的内置方法(不推荐,除非有特殊需求)
// push(item) {
// console.log('Adding item:', item);
// return super.push(item);
// }
}
const arr = new MyArray(1, 2, 3);
console.log(arr); // MyArray [1, 2, 3] { customProperty: 'This is a custom array' }
console.log(arr instanceof Array); // true
console.log(arr instanceof MyArray); // true
console.log(arr.length); // 3
arr.push(4);
console.log(arr); // MyArray [1, 2, 3, 4]
console.log(arr.first()); // 1
console.log(arr.last()); // 4
const newArr = MyArray.from([5, 6, 7]); // 也可以使用静态方法from
console.log(newArr); // MyArray [5, 6, 7] { customProperty: 'This is a custom array' }
代码解释:
class MyArray extends Array: 定义了一个名为MyArray的类,它继承自内置的Array类。constructor(...args): 子类的构造函数。...args允许我们将任意数量的参数传递给父类构造函数。super(...args): 这是关键。在子类构造函数中,必须先调用super()。对于继承内置构造函数的情况,super()不仅会调用Array的构造函数来初始化this,还会确保this是一个功能完备的Array实例。如果省略super()或在super()之前使用this,将会抛出ReferenceError。this.customProperty = ...: 在调用super()之后,我们可以像对待普通对象一样,为MyArray实例添加自定义属性。first(),last(): 在MyArray的原型上添加自定义方法,这些方法可以通过MyArray实例直接调用。
应用场景:
- 增强型数组工具类: 创建一个带有额外实用方法的数组,例如
unique()(去重)、sum()(求和)、groupBy()(分组)等,而不需要每次都封装一个普通数组。 - 特定业务逻辑的集合: 例如,一个
UserList类继承自Array,并添加了findUserByName()、filterActiveUsers()等针对用户数据的特定操作。 - 框架或库的内部实现: 某些库可能需要创建具有数组行为的自定义集合对象,以提供更丰富的功能。
优势:
- 简洁易懂: ES6
class语法比ES5的复杂原型链操作更直观。 - 功能完备: 继承的实例天然拥有
Array的所有内置方法和特性。 - 符合预期: 行为上与普通数组一致,可以无缝地在需要数组的地方使用。
4.3 常见误区或面试陷阱
- ES5中尝试直接继承内置对象: 许多候选人会尝试使用ES5的传统继承模式(如组合继承)来继承
Array,但这通常会失败或导致不完整的功能。他们可能会遇到无法正确调用Array构造函数来初始化实例内部属性的问题,或者实例的length属性不自动更新等问题。 - 在
super()之前使用this: 在ES6的类继承中,子类构造函数必须在访问this之前调用super()。这是因为子类实例的this是由父类构造函数创建的。如果违反此规则,会抛出ReferenceError。 - 忘记修复
constructor属性: 如果在ES5中手动设置原型链(例如MyArray.prototype = Object.create(Array.prototype)),可能会忘记设置MyArray.prototype.constructor = MyArray;。这会导致instanceof和constructor属性指向错误。 - 误解
Array.from()和super(): 有些人可能认为只要使用Array.from()就能实现继承,但Array.from()只是创建了一个新的Array实例,并不能使自定义类拥有Array的原型方法和正确的原型链。 - 过度封装而非继承: 对于需要数组功能并添加少量自定义方法的场景,有些人可能会选择封装一个普通数组而不是继承。虽然封装在某些情况下是可行的,但继承能提供更原生的数组行为,且代码更简洁,例如可以直接使用数组字面量展开自定义数组实例。
5. 闭包,以及写一个你使用过的闭包
题目要点
- 闭包概念理解: 面试官希望确认候选人对闭包(Closure)这一JavaScript核心概念的准确理解,包括其定义、形成条件和作用。
- 作用域链和变量持久化: 考察候选人是否理解闭包如何利用作用域链来"记住"并访问外部函数作用域中的变量,以及这些变量的生命周期。
- 实际应用场景: 确认候选人是否能在实际开发中识别和应用闭包,解决特定问题。
- 内存管理: 考察候选人是否了解闭包可能导致的内存占用问题,以及如何避免。
参考答案
5.1 原理说明
闭包 是指函数和声明该函数的词法环境(lexical environment)的组合。简单来说,闭包是函数能够记住并访问其创建时所在的作用域,即使该外部函数已经执行完毕并从调用栈中移除,闭包仍然能够访问和操作外部函数的变量。
形成条件:
- 函数嵌套: 存在一个内部函数,它被定义在另一个外部函数之内。
- 内部函数引用外部函数的变量: 内部函数引用了外部函数作用域中的变量(包括参数)。
- 内部函数被返回或传递到外部: 内部函数被作为返回值返回,或者作为参数传递到外部函数之外,使其在外部函数执行结束后仍能被访问和调用。
工作机制:
当一个外部函数被调用时,会创建一个执行上下文(包括作用域链)。如果外部函数内部定义了一个内部函数,并且该内部函数引用了外部函数作用域中的变量,那么即使外部函数执行完毕,其执行上下文(以及其中被内部函数引用的变量)也不会被销毁。这是因为内部函数形成了一个闭包,它"持有"了对外部函数变量的引用,阻止了垃圾回收器回收这些变量所占用的内存。当内部函数在外部被调用时,它依然可以通过其作用域链访问到这些外部变量。
5.2 核心用法 + 示例代码
1. 经典的计数器(私有变量):
这是闭包最常见的应用之一,用于创建私有变量,避免变量污染全局作用域。
function createCounter() {
let count = 0; // 这是一个私有变量,外部无法直接访问
return function() {
count++;
console.log(count);
};
}
const counter1 = createCounter();
counter1(); // 输出: 1
counter1(); // 输出: 2
const counter2 = createCounter(); // 创建一个新的闭包,拥有独立的count
counter2(); // 输出: 1
counter1(); // 输出: 3 (counter1的count不受影响)
解释: createCounter函数返回了一个匿名函数。这个匿名函数形成了闭包,它记住了createCounter函数作用域中的count变量。即使createCounter执行完毕,count变量也没有被销毁,而是被闭包所"持有",每次调用counter1或counter2时,都会操作各自独立的count变量。
2. 函数柯里化(Currying)/ 参数复用:
闭包在函数柯里化中非常有用,允许你分步接收函数的参数。
function add(a) {
return function(b) {
return a + b;
};
}
const add5 = add(5); // add5 是一个闭包,记住了 a = 5
console.log(add5(3)); // 输出: 8
console.log(add5(10)); // 输出: 15
const add10 = add(10);
console.log(add10(1)); // 输出: 11
解释: add函数返回的内部函数记住了外部函数add的参数a。每次调用add都会创建一个新的闭包,每个闭包都拥有独立的a值,从而实现了参数的复用和灵活调用。
3. 事件监听器中的闭包:
在循环中为元素添加事件监听器时,闭包可以帮助我们捕获正确的索引或变量。
<!-- HTML 部分 -->
<button id="btn0">Button 0</button>
<button id="btn1">Button 1</button>
<button id="btn2">Button 2</button>
// JavaScript 部分
const buttons = document.querySelectorAll('button');
for (let i = 0; i < buttons.length; i++) {
// 使用立即执行函数 (IIFE) 创建闭包,捕获当前的 i 值
// 或者在ES6中使用 let 关键字,let 具有块级作用域,每次迭代都会创建一个新的 i
((index) => {
buttons[index].addEventListener('click', function() {
console.log(`You clicked button ${index}`);
});
})(i);
}
// 如果不使用闭包(在ES5中,使用 var):
// for (var i = 0; i < buttons.length; i++) {
// buttons[i].addEventListener('click', function() {
// console.log(`You clicked button ${i}`); // 此时 i 已经是 buttons.length
// });
// }
解释: 在循环中,由于addEventListener的回调函数是异步执行的,当点击事件触发时,循环可能已经结束,i的值已经变成了最终值(buttons.length)。通过立即执行函数(IIFE),每次迭代都会创建一个新的作用域,并将当前的i值作为参数index传递进去,从而在闭包中捕获到正确的index值。在ES6中,使用let声明的变量具有块级作用域,每次循环都会创建一个新的i,所以可以直接使用let i。
我使用过的闭包场景:
在前端项目中,我经常使用闭包来实现模块化和数据封装。例如,我曾在一个用户管理模块中,通过闭包来创建私有函数和变量,只暴露公共API,从而避免了全局变量污染,并实现了数据的安全访问。
// 用户管理模块
const userManager = (() => {
let users = []; // 私有变量,只在该闭包作用域内可见
let nextId = 1;
function generateId() {
return nextId++;
}
function findUserById(id) {
return users.find(user => user.id === id);
}
return {
addUser: (name, email) => {
const id = generateId();
const newUser = { id, name, email };
users.push(newUser);
console.log(`User ${name} added with ID: ${id}`);
return newUser;
},
getUser: (id) => {
return findUserById(id);
},
getAllUsers: () => {
return [...users]; // 返回副本,防止外部直接修改私有数组
},
removeUser: (id) => {
const initialLength = users.length;
users = users.filter(user => user.id !== id);
if (users.length < initialLength) {
console.log(`User with ID ${id} removed.`);
return true;
} else {
console.log(`User with ID ${id} not found.`);
return false;
}
}
};
})();
// 使用模块
userManager.addUser('Alice', 'alice@example.com');
userManager.addUser('Bob', 'bob@example.com');
const user = userManager.getUser(1);
console.log(user); // { id: 1, name: 'Alice', email: 'alice@example.com' }
const allUsers = userManager.getAllUsers();
console.log(allUsers); // [ { id: 1, name: 'Alice', ... }, { id: 2, name: 'Bob', ... } ]
userManager.removeUser(1);
console.log(userManager.getAllUsers()); // [ { id: 2, name: 'Bob', ... } ]
// 外部无法直接访问或修改 users 和 nextId
// console.log(userManager.users); // undefined
解释: 这个模块通过立即执行函数表达式(IIFE)创建了一个闭包。users和nextId变量以及generateId和findUserById函数都成为了私有成员,外部无法直接访问。userManager对象只暴露了addUser、getUser、getAllUsers和removeUser等公共方法,这些公共方法构成了闭包的一部分,它们能够访问和操作users和nextId。这种模式有效地实现了数据封装和模块化,提高了代码的组织性和安全性。
5.3 常见误区或面试陷阱
- 误解闭包导致内存泄漏: 闭包本身并不会导致内存泄漏,而是它会导致其引用的外部变量无法被垃圾回收。如果闭包长时间不被释放,并且其引用的外部变量占用大量内存,才可能间接导致内存占用过高。关键在于合理管理闭包的生命周期。
- 循环中
var变量的问题: 像上面事件监听器的例子,如果在循环中使用var声明变量,并且在闭包中引用它,会导致所有闭包共享同一个变量的最终值。这是经典的闭包陷阱,需要通过立即执行函数(IIFE)或let/const来解决。 - 过度使用闭包导致性能问题: 虽然闭包强大,但每个闭包都会额外占用内存来存储其词法环境。如果创建了大量不必要的闭包,可能会增加内存消耗。在性能敏感的场景下,需要权衡使用。
- 不理解闭包是词法作用域的特性: 有些候选人可能认为闭包与函数的执行时机有关,但实际上闭包是在函数定义时就已经确定其能访问的词法环境,与函数何时执行无关。
- 误以为只有返回内部函数才形成闭包: 闭包的形成条件是内部函数引用外部变量并被"带到外部"。除了返回内部函数,将内部函数作为回调函数传递、作为对象的方法等,也都能形成闭包。
- 闭包和普通函数调用的区别: 普通函数调结束后,其作用域链和变量会被销毁。而闭包由于被外部引用,其词法环境会保留,这是核心区别。
6. 实现一个 JavaScript 函数,对数组进行去重操作
题目要点
- 数组操作和算法: 面试官希望确认候选人对JavaScript数组基本操作的熟练程度,以及对去重算法的理解和实现能力。
- 数据结构的应用: 考察候选人是否能利用合适的数据结构(如
Set、Map、Object)来高效地解决去重问题。 - 时间/空间复杂度分析: 确认候选人是否能对不同去重方法的性能有基本认知,并能选择最优方案。
- ES6新特性: 考察候选人是否了解并能灵活运用ES6及更高版本提供的便利特性(如
Set、Array.from()、展开运算符)。
参考答案
6.1 原理说明
数组去重是指从一个数组中移除重复的元素,使得数组中所有元素都是唯一的。实现数组去重有多种方法,每种方法在性能、代码简洁性、对不同数据类型(如对象、NaN等)的处理上有所差异。其核心思想通常是记录已经出现过的元素,并在遍历过程中跳过重复的元素。
常见的去重原理包括:
- 利用新数据结构(如
Set):Set是ES6新增的一种数据结构,它类似于数组,但是成员的值都是唯一的,没有重复的值。这是最简洁高效的去重方式。 - 利用哈希表(
Object或Map): 通过将数组元素作为对象的键或Map的键来记录其是否出现过。由于对象的键是唯一的,因此可以达到去重的目的。 - 遍历比较(嵌套循环): 传统方法,通过双重循环比较每个元素,如果发现重复则移除。这种方法效率较低,但易于理解。
indexOf/includes判断: 遍历数组,将不重复的元素添加到一个新数组中,在添加之前判断元素是否已在新数组中存在。
6.2 核心用法 + 示例代码
以下是几种常见的数组去重方法及其示例代码:
1. 使用 Set (ES6+,推荐):
这是最推荐和最简洁的方法。Set本身就具有去重功能。
function uniqueArrayBySet(arr) {
return [...new Set(arr)]; // 或者 Array.from(new Set(arr));
}
console.log(uniqueArrayBySet([1, 2, 2, 3, 4, 4, 5])); // [1, 2, 3, 4, 5]
console.log(uniqueArrayBySet(['a', 'b', 'a', 'c'])); // ['a', 'b', 'c']
console.log(uniqueArrayBySet([1, '1', 2, 1])); // [1, '1', 2] (Set区分类型)
console.log(uniqueArrayBySet([NaN, 0, NaN, 1])); // [NaN, 0, 1] (Set认为两个NaN是相等的)
- 优点: 代码简洁,性能好,能够正确处理
NaN和undefined。 - 缺点: 无法直接对数组中的对象进行深度去重(它会根据对象的引用地址来判断是否重复)。
- 时间复杂度: O(n)
- 空间复杂度: O(n)
2. 使用 reduce + includes:
function uniqueArrayByReduceIncludes(arr) {
return arr.reduce((acc, current) => {
if (!acc.includes(current)) {
acc.push(current);
}
return acc;
}, []);
}
console.log(uniqueArrayByReduceIncludes([1, 2, 2, 3, 4, 4, 5])); // [1, 2, 3, 4, 5]
console.log(uniqueArrayByReduceIncludes(['a', 'b', 'a', 'c']));
// 注意:includes 无法正确处理 NaN (NaN !== NaN)
console.log(uniqueArrayByReduceIncludes([NaN, 0, NaN, 1])); // [NaN, 0, NaN, 1]
- 优点: 易于理解。
- 缺点:
includes在每次迭代中都会遍历acc数组,导致性能下降;无法正确处理NaN。 - 时间复杂度: O(n^2)
- 空间复杂度: O(n)
3. 使用 filter + indexOf:
function uniqueArrayByFilterIndexOf(arr) {
return arr.filter((item, index, self) => {
return self.indexOf(item) === index;
});
}
console.log(uniqueArrayByFilterIndexOf([1, 2, 2, 3, 4, 4, 5])); // [1, 2, 3, 4, 5]
console.log(uniqueArrayByFilterIndexOf(['a', 'b', 'a', 'c']));
// 注意:indexOf 无法正确处理 NaN (NaN !== NaN)
console.log(uniqueArrayByFilterIndexOf([NaN, 0, NaN, 1])); // [NaN, 0, NaN, 1]
- 优点: 代码相对简洁。
- 缺点:
indexOf在每次迭代中都会从头开始查找,性能较差;无法正确处理NaN。 - 时间复杂度: O(n^2)
- 空间复杂度: O(n)
4. 使用 Map (可处理NaN,但对对象去重仍是引用地址):
Map作为哈希表,其键可以是任意类型的值(包括对象),但对于原始类型的值,它会根据值本身判断是否重复。
function uniqueArrayByMap(arr) {
const map = new Map();
const result = [];
for (let i = 0; i < arr.length; i++) {
const item = arr[i];
if (!map.has(item)) {
map.set(item, true); // 值作为key,存储一个标记
result.push(item);
}
}
return result;
}
console.log(uniqueArrayByMap([1, 2, 2, 3, 4, 4, 5])); // [1, 2, 3, 4, 5]
console.log(uniqueArrayByMap(['a', 'b', 'a', 'c'])); // ['a', 'b', 'c']
console.log(uniqueArrayByMap([1, '1', 2, 1])); // [1, '1', 2]
console.log(uniqueArrayByMap([NaN, 0, NaN, 1])); // [NaN, 0, 1] (Map认为两个NaN是相等的)
- 优点: 性能较好,能够正确处理
NaN,键可以是任意类型。 - 缺点: 对对象去重依然是基于引用地址,而不是内容。
- 时间复杂度: O(n)
- 空间复杂度: O(n)
项目中的应用场景:
- 数据清洗: 在从后端获取数据或用户输入数据时,经常需要对数据列表进行去重,以确保数据的唯一性。
- 标签/分类筛选: 显示唯一的标签或分类列表,避免重复。
- 日志记录: 记录唯一的事件或错误类型。
- 渲染列表优化: 在React/Vue等框架中,如果列表数据有重复,去重可以避免不必要的渲染或提高渲染效率。
6.3 常见误区或面试陷阱
- 只考虑数字和字符串去重: 许多候选人在实现去重时,往往只考虑了数字和字符串,而忽略了其他数据类型,尤其是
NaN和对象。例如,传统的indexOf方法无法正确处理NaN(NaN !== NaN),Set和Map则能正确处理。 - 不了解对象去重的复杂性: 对于包含对象的数组,简单的去重方法(如
Set或Map的默认行为)是基于引用地址判断的,而不是对象内容的深度比较。如果面试官期望的是根据对象内容去重,需要更复杂的逻辑(例如,将对象序列化为字符串再比较,或手动遍历比较对象属性)。这通常是下一道题的考察点。 - 时间复杂度过高: 使用嵌套循环(如
for循环 +splice)或filter+indexOf等方法,其时间复杂度为O(n^2),在处理大量数据时性能会非常差。面试官希望看到候选人能够选择更优的O(n)方案(如Set或Map)。 - 修改原数组: 有些去重方法(例如使用
splice)会直接修改原数组。虽然在某些情况下可以接受,但通常建议返回一个新数组,保持函数的纯粹性。 - 对
Set和Map的特性不熟悉: 候选人可能不知道ES6的Set和Map可以直接用于去重,或者不了解它们在处理特殊值(如NaN)时的行为。 - 过度优化或考虑不足: 有些候选人可能会过度考虑各种极端情况而写出过于复杂的代码,而另一些则可能考虑不足,没有处理常见的边缘情况。
7. 如果数组中包含对象,你的去重函数还能正常工作吗?如何修改代码以支持对象的去重?
题目要点
- JavaScript数据类型比较: 考察候选人对JavaScript中原始类型和引用类型比较机制的理解,特别是对象比较是基于引用而不是值。
- 深拷贝与浅拷贝: 确认候选人是否能理解在处理对象时,简单的赋值操作是浅拷贝,以及在特定场景下深拷贝的必要性。
- 自定义比较逻辑: 考察候选人是否能设计并实现自定义的比较逻辑,以满足对复杂数据类型(如对象)的去重需求。
- 性能考量: 确认候选人是否能权衡不同去重方案在性能上的差异,尤其是在处理大量对象数据时。
- JSON序列化: 考察候选人是否能想到通过将对象序列化为字符串进行比较的简单方法,以及其局限性。
参考答案
7.1 原理说明
如果数组中包含对象,前面实现的去重函数(尤其是基于 Set、Map 或 indexOf/includes 的方法)通常不能正常工作以实现"内容"上的去重。
这是因为JavaScript中对象的比较是基于引用(reference)而不是值(value)。这意味着即使两个对象具有完全相同的属性和值,只要它们在内存中不是同一个引用,JavaScript就会认为它们是不同的对象。
- 对于
Set和Map:它们在判断元素是否重复时,对于对象类型会比较其内存地址。所以{ id: 1 }和另一个{ id: 1 }会被视为两个不同的元素并都被存储。 - 对于
indexOf和includes:同样,它们在查找对象时也是基于引用进行比较,因此无法找到内容相同的不同对象。
要实现数组中对象的去重,我们需要定义一种"相等"的规则。常见的策略是:
- 基于唯一标识属性去重: 如果对象有一个或多个属性可以作为其唯一标识(如
id,uuid),我们可以利用这些属性来判断对象是否重复。 - 基于对象内容序列化去重: 将对象转换为字符串(如使用
JSON.stringify()),然后对这些字符串进行去重。这种方法简单,但有局限性(如属性顺序、undefined/function/Symbol的丢失)。 - 深度比较去重: 实现一个深度比较函数,逐个比较对象的属性值。这种方法最准确,但也最复杂和耗性能。
7.2 核心用法 + 示例代码
场景示例: 假设我们有一个包含用户对象的数组,我们希望根据用户的 id 属性来去重,或者根据整个用户对象的内容来去重。
const users = [
{ id: 1, name: 'Alice', age: 30 },
{ id: 2, name: 'Bob', age: 25 },
{ id: 1, name: 'Alice', age: 30 }, // 重复项 (id和内容都重复)
{ id: 3, name: 'Charlie', age: 35 },
{ id: 2, name: 'Robert', age: 25 } // id 重复,但 name 不同
];
1. 基于唯一标识属性(如 id)去重 (推荐且常用):
这是最常见且高效的方法,适用于对象具有明确唯一标识的场景。
function uniqueObjectsById(arr, key) {
const seen = new Set();
const result = [];
for (const item of arr) {
const identifier = item[key]; // 使用对象的某个属性作为唯一标识
if (!seen.has(identifier)) {
seen.add(identifier);
result.push(item);
}
}
return result;
}
const uniqueUsersById = uniqueObjectsById(users, 'id');
console.log('根据ID去重:', uniqueUsersById);
// 预期输出:
// [
// { id: 1, name: 'Alice', age: 30 },
// { id: 2, name: 'Bob', age: 25 },
// { id: 3, name: 'Charlie', age: 35 }
// ]
// 注意:如果id重复但内容不同(如 id:2 的Bob和Robert),此方法会保留第一个遇到的。
解释: 我们创建一个 Set 来存储已经见过的 id。遍历数组时,对于每个对象,我们提取其 id 值,如果 Set 中没有这个 id,则说明是新对象,将其 id 加入 Set 并将整个对象添加到结果数组。
2. 基于 JSON.stringify() 序列化去重 (简单但有局限性):
这种方法适用于对象结构相对简单,且属性顺序不影响相等判断的场景。
function uniqueObjectsByJsonStringify(arr) {
const seenStrings = new Set();
const result = [];
for (const item of arr) {
const itemString = JSON.stringify(item); // 将对象转换为字符串
if (!seenStrings.has(itemString)) {
seenStrings.add(itemString);
result.push(item);
}
}
return result;
}
const uniqueUsersByContent = uniqueObjectsByJsonStringify(users);
console.log('根据JSON字符串去重:', uniqueUsersByContent);
// 预期输出:
// [
// { id: 1, name: 'Alice', age: 30 },
// { id: 2, name: 'Bob', age: 25 },
// { id: 3, name: 'Charlie', age: 35 },
// { id: 2, name: 'Robert', age: 25 }
// ]
// 备注:这里 { id: 2, name: 'Bob' } 和 { id: 2, name: 'Robert' } 会被保留,因为JSON字符串不同。
// { id: 1, name: 'Alice', age: 30 } 的重复项会被去除。
解释: 将每个对象序列化为JSON字符串,然后利用 Set 对这些字符串进行去重。当遇到重复的JSON字符串时,就跳过该对象。
- 优点: 相对简单,可以实现基于内容的去重(对于属性顺序和类型一致的对象)。
- 缺点:
- 属性顺序敏感:
{ a: 1, b: 2 }和{ b: 2, a: 1 }序列化后的字符串可能不同,导致去重失败。 - 数据类型丢失:
JSON.stringify()会跳过undefined、函数、Symbol等类型,如果对象中包含这些类型,可能导致误判。 - 循环引用问题: 无法处理对象中存在循环引用的情况,会抛出错误。
- 属性顺序敏感:
3. 实现深度比较去重 (最准确但最复杂/耗性能):
如果需要对复杂嵌套对象进行精确的内容去重,需要实现一个深度比较函数。这通常涉及到递归遍历对象的所有属性。
// 辅助函数:深度比较两个对象是否相等
function deepEqual(obj1, obj2) {
if (obj1 === obj2) return true; // 相同引用或原始类型相等
if (typeof obj1 !== 'object' || obj1 === null || typeof obj2 !== 'object' || obj2 === null) {
return false; // 类型不同或不是对象
}
const keys1 = Object.keys(obj1);
const keys2 = Object.keys(obj2);
if (keys1.length !== keys2.length) return false; // 属性数量不同
for (const key of keys1) {
if (!keys2.includes(key) || !deepEqual(obj1[key], obj2[key])) {
return false; // 属性不匹配或递归比较不相等
}
}
return true;
}
function uniqueObjectsByDeepComparison(arr) {
const result = [];
for (const item of arr) {
let isDuplicate = false;
for (const existingItem of result) {
if (deepEqual(item, existingItem)) {
isDuplicate = true;
break;
}
}
if (!isDuplicate) {
result.push(item);
}
}
return result;
}
const uniqueUsersByDeepComparison = uniqueObjectsByDeepComparison(users);
console.log('根据深度比较去重:', uniqueUsersByDeepComparison);
// 预期输出:
// [
// { id: 1, name: 'Alice', age: 30 },
// { id: 2, name: 'Bob', age: 25 },
// { id: 3, name: 'Charlie', age: 35 },
// { id: 2, name: 'Robert', age: 25 }
// ]
// 备注:这里 { id: 2, name: 'Bob' } 和 { id: 2, name: 'Robert' } 会被保留,因为内容不同。
// { id: 1, name: 'Alice', age: 30 } 的重复项会被去除。
解释: 这种方法通过一个deepEqual辅助函数来递归比较两个对象的属性。uniqueObjectsByDeepComparison函数遍历原始数组,对于每个元素,与结果数组中的现有元素进行深度比较。
- 优点: 最准确,可以处理任意深度的嵌套对象。
- 缺点: 性能最差,时间复杂度高,尤其是在处理大量数据或深度嵌套对象时。
项目中的选择:
在实际项目中,最常用的策略是基于唯一标识属性去重,因为它性能最好且业务逻辑清晰。只有当对象没有唯一标识,且需要严格根据所有内容判断重复时,才考虑JSON.stringify()或深度比较,并注意其局限性和性能开销。
7.3 常见误区或面试陷阱
- 误以为对象比较是值比较: 最常见的误区就是不理解JavaScript中对象是引用类型,简单地使用
==或===无法比较对象内容。 - 忽略
JSON.stringify()的局限性: 认为JSON.stringify()是万能的去重方法,而忽略了它对属性顺序、特定数据类型(如函数)的处理以及循环引用等问题。面试时如果提出此方案,应主动指出其局限性。 - 直接使用
Set或Map而不加处理: 对于包含对象的数组,直接将对象放入Set或作为Map的键,会导致所有对象都被视为唯一(因为引用地址不同)。 - 编写低效的深度比较: 如果需要深度比较,但实现的比较逻辑效率低下(如重复遍历、没有处理循环引用),或者在不必要的场景下使用深度比较,都会被认为是不足。
- 未考虑嵌套对象的情况: 仅仅考虑了对象的第一层属性,而没有考虑对象内部可能还有嵌套的对象,导致去重不完全。
- 无法根据不同条件去重: 候选人可能只能实现一种去重方式(例如只根据
id),而没有想到可以根据不同的业务需求(例如根据id去重,或根据name去重,或根据所有内容去重)来修改去重逻辑。
8. 实现链式调用get(0).add(1).sub(2).mul(3)
题目要点
- 链式调用机制: 面试官希望确认候选人对JavaScript中链式调用(Method Chaining)实现原理的理解,即每个方法返回当前对象实例。
this上下文: 考察候选人对函数中this指向的掌握,确保在链式调用中this始终指向正确的实例。- 面向对象设计: 确认候选人是否能通过封装(如使用类或构造函数)来实现带有状态和行为的对象。
- 代码可读性与设计模式: 考察候选人是否能理解链式调用的优势(提高代码可读性和流畅性)以及其作为一种设计模式的应用。
参考答案
8.1 原理说明
链式调用(Method Chaining)是一种在编程中常见的设计模式,它允许在同一行代码中,通过点语法(.)连续调用一个对象的多个方法。这种模式能够显著提高代码的可读性和简洁性,使代码更接近自然语言的表达方式。例如,obj.method1().method2().method3();。
实现链式调用的核心原理是:每个方法在执行其逻辑后,都必须返回当前对象实例本身。在JavaScript中,这意味着在每个方法内部,我们通常会 return this;。这样,下一个方法就可以在返回的同一个对象实例上被调用,从而形成一条连续的调用链。
对于 get(0).add(1).sub(2).mul(3) 这样的表达式,我们需要设计一个对象(或类),它:
- 能够存储一个内部的数值状态。
get(0)应该初始化这个状态。 add、sub、mul方法能够对这个内部状态进行运算。- 每个运算方法执行完毕后,都返回当前对象实例,以便后续方法可以继续链式调用。
- 最终,可能需要一个方法来获取计算结果(虽然题目没有明确要求,但在实际应用中通常会有一个
value()或result()方法)。
8.2 核心用法 + 示例代码
我们可以使用ES6的class语法来实现这个支持链式调用的计算器:
class Calculator {
constructor(initialValue = 0) {
this.value = initialValue; // 存储当前计算结果的内部状态
}
// 初始化或设置值
get(num) {
this.value = num;
return this; // 返回当前实例,实现链式调用
}
// 加法
add(num) {
this.value += num;
return this; // 返回当前实例
}
// 减法
sub(num) {
this.value -= num;
return this; // 返回当前实例
}
// 乘法
mul(num) {
this.value *= num;
return this; // 返回当前实例
}
// 获取最终结果 (非链式,用于结束调用链并获取值)
result() {
return this.value;
}
}
// 示例用法:
const calculator = new Calculator();
const finalResult = calculator.get(0).add(1).sub(2).mul(3).result();
console.log(finalResult); // 预期输出: (0 + 1 - 2) * 3 = (-1) * 3 = -3
// 另一个示例
const anotherResult = new Calculator(10).sub(5).mul(2).add(3).result();
console.log(anotherResult); // 预期输出: (10 - 5) * 2 + 3 = 5 * 2 + 3 = 10 + 3 = 13
// 如果没有 .result() 方法,可以直接访问实例的 value 属性 (不推荐,破坏封装性)
const calcInstance = new Calculator().get(5).add(5);
console.log(calcInstance.value); // 10
代码解释:
Calculator类: 定义了一个Calculator类,其constructor用于初始化内部的value属性。get(num)方法: 用于设置初始值。它将传入的num赋值给this.value,并返回this,使其可以被链式调用。add(num)、sub(num)、mul(num)方法: 这些方法执行各自的数学运算,更新this.value,并且在每次运算后都返回this。这是实现链式调用的关键所在。result()方法: 这个方法用于获取最终的计算结果。它不返回this,而是返回this.value,标志着链式调用的结束。
项目中的应用场景:
- DOM操作库(如jQuery): jQuery是链式调用的经典范例,其
$(selector).css().animate().hide()等操作极大地方便了开发。 - Promise链式调用:
Promise.then().catch().finally()也是基于链式调用的原理。 - 数据处理和转换: 在数据处理管道中,可以通过链式调用一系列的过滤、映射、排序等操作。
- 构建器模式(Builder Pattern): 链式调用常用于实现构建器模式,例如在创建复杂对象时,通过一系列方法设置对象的属性。
- 配置API: 很多库或框架提供配置API时也会采用链式调用,如
config.setKey().setValue().build()。
优势:
- 代码简洁: 避免了重复的对象引用,减少了中间变量的声明。
- 可读性强: 代码流更自然,易于理解每个操作的顺序和目的,提高了代码的表达力。
- API设计优雅: 提供了一种流畅的API接口,提升了开发体验。
8.3 常见误区或面试陷阱
- 忘记返回
this: 这是实现链式调用最常见的错误。如果一个方法执行了操作但没有return this;,那么后续的方法就无法在该对象上继续调用,链式调用会中断。 this上下文丢失: 如果方法内部的this绑定不正确(例如,方法作为回调函数传入,但没有正确绑定this),会导致操作作用在错误的对象上或报错。在类方法中,通常会自动绑定,但在普通函数中需要注意bind、call、apply或箭头函数。- 过度设计或不必要的链式: 并非所有操作都适合链式调用。如果链式调用使得代码难以调试或理解,或者方法之间没有紧密的顺序依赖,那么强行使用链式调用可能适得其反。
- 混淆状态和返回值的处理: 链式调用通常是修改对象状态并返回对象本身,而不是返回新的计算结果。如果需要返回中间结果,则应该中断链式调用或设计专门的
result()方法。 - 不考虑构造函数的初始化: 在实现链式调用时,需要确保有一个合适的起点来初始化对象的状态。
get(0)在这里起到了初始化的作用,或者可以在constructor中设置默认值。 - 方法名称的语义化: 虽然题目要求
get、add等,但在实际项目中,方法名称应更具语义化,清晰表达其功能,避免歧义。
9. HTTP 和 HTTPS 的区别,以及 HTTPS 的工作原理。
题目要点
- 网络协议基础: 面试官希望确认候选人对HTTP和HTTPS这两种核心网络协议的基本概念、工作方式以及它们之间主要差异的理解。
- 安全性考量: 考察候选人对HTTPS在数据传输安全性方面所做的改进(加密、认证、完整性)的认知。
- 加密原理: 确认候选人是否能阐述HTTPS如何利用对称加密和非对称加密(公钥私钥)以及数字证书来保障通信安全。
- 性能与部署: 考察候选人是否了解HTTPS可能带来的性能开销以及在实际部署中的考虑因素(如证书)。
参考答案
9.1 原理说明
HTTP(HyperText Transfer Protocol) 是一种用于分布式、协作式和超媒体信息系统的应用层协议,是万维网数据通信的基础。HTTP协议是明文传输的,这意味着客户端和服务器之间传输的数据是未加密的,容易被第三方窃听(嗅探)、篡改或伪造。
HTTPS(HyperText Transfer Protocol Secure) 是HTTP的安全版本,它在HTTP协议的基础上,加入了SSL/TLS(Secure Sockets Layer/Transport Layer Security)协议来对网络连接进行加密。SSL/TLS协议位于HTTP和TCP之间,提供数据加密、身份验证和数据完整性保护,从而确保了数据传输的安全性。
HTTP 与 HTTPS 的主要区别:
| 特性 | HTTP | HTTPS |
|---|---|---|
| 安全性 | 数据明文传输,不安全,易被窃听、篡改、伪造 | 数据加密传输,安全,可防止窃听、篡改、伪造 |
| 端口 | 默认使用80端口 | 默认使用443端口 |
| 成本 | 无需额外成本 | 需要购买SSL/TLS证书,有额外成本 |
| 加密机制 | 无加密 | 使用SSL/TLS协议进行加密 |
| 证书 | 无需证书 | 需要CA(Certificate Authority)颁发的数字证书 |
| SEO影响 | 排名可能受影响 | 有利于SEO排名(Google等搜索引擎推荐) |
HTTPS 的工作原理(SSL/TLS 握手过程):
Https协议的通信过程,实际上是SSL/TLS协议的握手过程和加密数据传输过程。核心在于利用对称加密和非对称加密的优势,并通过数字证书来验证服务器的身份。
握手过程(简化版):
客户端发起"Client Hello":
- 客户端向服务器发送请求,包含客户端支持的SSL/TLS协议版本、加密算法套件(如AES、RSA、SHA等)、随机数A(用于后续生成会话密钥)。
服务器回应"Server Hello":
- 服务器从客户端提供的加密算法套件中选择一套双方都支持的加密算法。
- 向客户端发送选择的协议版本、加密算法套件、随机数B。
- 发送数字证书: 这是关键一步。服务器会将其数字证书发送给客户端。数字证书包含了服务器的公钥、服务器的身份信息(域名、组织等)以及CA机构对证书的签名。
客户端验证证书并生成预主密钥:
- 客户端接收到服务器的数字证书后,会进行以下验证:
- 验证证书的合法性: 检查证书是否由受信任的CA机构颁发、证书是否过期、证书的域名是否与访问的域名匹配等。如果验证失败,会向用户发出警告。
- 从证书中提取服务器公钥。
- 生成预主密钥(Pre-Master Secret): 客户端生成一个随机数,这个随机数是用于后续生成会话密钥的关键,通常称为预主密钥。客户端使用从证书中获取的服务器公钥加密这个预主密钥。
- 发送加密后的预主密钥: 客户端将加密后的预主密钥发送给服务器。
- 客户端接收到服务器的数字证书后,会进行以下验证:
服务器解密预主密钥并生成会话密钥:
- 服务器使用自己的私钥解密客户端发送过来的预主密钥(这是非对称加密的体现)。
- 服务器和客户端各自根据之前交换的随机数A、随机数B以及预主密钥,通过相同的算法生成会话密钥(Master Secret)。这个会话密钥将用于后续的对称加密通信。
加密通信:
- 握手完成后,客户端和服务器都拥有相同的会话密钥。后续的所有应用层数据(如HTTP请求和响应)都将使用这个会话密钥进行对称加密和解密。这样就保证了数据传输的机密性、完整性和身份验证。
为什么要用非对称加密来传输对称加密的密钥?
- 效率: 对称加密的效率比非对称加密高很多,适合用于大量数据的加密传输。
- 密钥交换: 非对称加密的优势在于可以安全地交换密钥。如果直接用对称加密,那么如何安全地把对称密钥传递给对方就成了问题(因为密钥本身也需要加密传输)。通过非对称加密,服务器的公钥可以公开,客户端用公钥加密预主密钥,只有拥有私钥的服务器才能解密,从而安全地协商出对称密钥。
9.2 核心用法 + 示例代码
HTTPS不是直接在前端代码中实现或使用的,它主要是在服务器端配置SSL/TLS证书,并在浏览器和服务器之间自动完成握手和加密通信。作为前端开发者,我们主要关注的是确保网站使用HTTPS,并处理相关安全性问题。
前端角度的"用法"和"场景":
确保网站通过HTTPS访问:
- 在开发和生产环境中,确保所有资源(图片、CSS、JS、API请求等)都通过HTTPS加载,避免"混合内容"(Mixed Content)警告。
- 可以通过在HTML中强制使用
https://前缀,或者在服务器端进行HTTP到HTTPS的重定向。
<!-- 确保引入的脚本、样式等使用HTTPS --> <script src="https://example.com/js/app.js"></script> <link rel="stylesheet" href="https://example.com/css/style.css"> <img src="https://example.com/images/logo.png" alt="Logo"> <!-- 或者在服务器端配置HTTP到HTTPS的301重定向 --> <!-- Nginx 配置示例 --> # server { # listen 80; # server_name yourdomain.com; # return 301 https://$host$request_uri; # }使用
Strict-Transport-Security(HSTS) HTTP头:- HSTS是一个HTTP响应头,它告诉浏览器在未来的一段时间内,强制使用HTTPS访问该网站,即使是用户输入
http://也会自动转为https://。这有助于防止中间人攻击。
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload场景: 提升网站安全性,防止用户误操作或攻击者降级攻击。
- HSTS是一个HTTP响应头,它告诉浏览器在未来的一段时间内,强制使用HTTPS访问该网站,即使是用户输入
处理API请求:
- 前端发送AJAX或Fetch请求时,总是使用
https://协议来请求后端API,确保数据传输的加密。
// 使用Fetch API发送HTTPS请求 fetch('https://api.yourdomain.com/data') .then(response => response.json()) .then(data => console.log(data)) .catch(error => console.error('Error fetching data:', error)); // 使用XMLHttpRequest发送HTTPS请求 const xhr = new XMLHttpRequest(); xhr.open('GET', 'https://api.yourdomain.com/data'); xhr.onload = function() { if (xhr.status === 200) { console.log(xhr.responseText); } }; xhr.send();场景: 传输用户敏感数据(如登录凭证、支付信息)、保护商业敏感数据不被窃取或篡改。
- 前端发送AJAX或Fetch请求时,总是使用
HTTPS解决了什么问题,相比HTTP有什么优势:
- 数据机密性: 所有传输的数据都被加密,即使被截获也无法直接读取,防止敏感信息泄露。
- 数据完整性: 数据在传输过程中无法被篡改,任何篡改都会被发现,确保数据与发送时一致。
- 身份验证: 客户端可以验证服务器的身份(通过数字证书),防止连接到伪造的恶意网站(钓鱼网站)。
- SEO优势: 搜索引擎(如Google)倾向于优先收录和排名HTTPS网站,有助于提高网站的可见性。
- 信任度: 浏览器通常会在HTTPS网站的地址栏显示安全锁标志,增加用户对网站的信任度。
- 新特性支持: 许多现代Web特性(如Service Worker、Push Notification、Geolocation等)要求在安全上下文(HTTPS)中运行。
9.3 常见误区或面试陷阱
- 误以为HTTPS是加密HTTP: 简单地认为HTTPS只是HTTP的加密版本,而没有理解它是在HTTP和TCP之间加入了SSL/TLS协议层,并且涉及公钥私钥、数字证书等复杂机制。
- 不清楚SSL/TLS握手细节: 无法清晰阐述客户端和服务器如何通过非对称加密交换对称密钥,以及数字证书在其中扮演的角色。
- 混淆对称加密和非对称加密的作用: 认为全程都是非对称加密,或不清楚为何最终通信要转为对称加密(效率问题)。
- 忽略"混合内容"问题: 在HTTPS网站中,如果加载了HTTP资源,会导致浏览器警告(混合内容),影响用户体验和网站信任度。面试时需要提到避免这种情况。
- 对证书的理解不足: 不知道数字证书的颁发机构(CA)、证书的作用(身份验证)以及证书过期或不匹配的后果。
- 认为HTTPS能解决所有安全问题: HTTPS主要解决数据传输过程中的安全问题(窃听、篡改、伪造),但无法解决所有安全隐患,例如XSS、CSRF等应用层攻击仍需其他安全措施。
- 未提及HSTS: 了解HSTS(HTTP Strict Transport Security)可以体现对HTTPS部署和安全优化的深入理解。
- 性能开销的忽视: 虽然HTTPS的性能开销已大幅降低,但在高并发场景下,加密解密和握手过程仍会带来一定的CPU和网络延迟,但相比安全性收益,通常是值得的。面试时可以简单提一下。
10. 在 HTTPS 通信过程中,证书验证失败,客户端会如何处理?
题目要点
- HTTPS安全机制: 面试官希望确认候选人对HTTPS安全验证流程的理解,特别是数字证书在其中扮演的角色及其重要性。
- 客户端行为: 考察候选人是否了解浏览器(客户端)在遇到安全问题(如证书验证失败)时的标准处理方式和用户交互。
- 安全风险意识: 确认候选人是否能识别证书验证失败可能带来的安全风险(如中间人攻击)。
- 用户体验与安全性平衡: 考察候选人是否能理解在安全性受到威胁时,浏览器如何平衡用户体验与安全提示。
参考答案
10.1 原理说明
在HTTPS通信过程中,客户端(通常是浏览器)在接收到服务器发送的数字证书后,会执行一系列的验证步骤,以确保服务器的身份是可信的,并且通信没有被中间人(Man-in-the-Middle, MITM)攻击篡改。这些验证步骤包括:
- 信任链验证: 检查证书是否由客户端信任的根证书颁发机构(Root CA)或其下级CA(中间CA)签发。浏览器内置了一个受信任的根证书列表。
- 证书有效期: 检查证书是否在有效期内(未过期或未生效)。
- 域名匹配: 检查证书中包含的域名(Common Name 或 Subject Alternative Name)是否与用户访问的网站域名一致。
- 证书撤销状态: 检查证书是否被吊销(通过CRL或OCSP)。
- 证书用途: 检查证书的用途(如是否用于服务器身份验证)。
如果这些验证中的任何一个失败,就意味着服务器的身份可能存在问题,或者通信可能遭受了攻击。此时,客户端会采取措施来阻止潜在的安全风险,并向用户发出明确的警告。
10.2 核心用法 + 示例代码
当HTTPS证书验证失败时,客户端(主要是Web浏览器)不会静默地继续连接,而是会立即采取以下处理方式,并不会向服务器发送任何敏感数据(如用户凭证):
中断连接: 浏览器会立即终止与服务器的HTTPS连接,以防止数据泄露或被篡改。这是最核心的安全措施。
显示安全警告页面: 这是客户端最直接的用户交互方式。浏览器会弹出一个显眼的警告页面(通常是红色背景或带有醒目图标),明确告知用户证书验证失败,并解释可能的原因和风险(例如"您的连接不是私密的"、“该网站的安全证书有问题"等)。
- 警告内容通常包括:
- 安全风险提示: 警告用户数据可能被窃取、网站可能是伪造的。
- 错误代码/原因: 提供具体的错误信息(如
NET::ERR_CERT_COMMON_NAME_INVALID表示域名不匹配,NET::ERR_CERT_DATE_INVALID表示证书过期)。 - 选择权: 大多数情况下,浏览器会提供一个"高级"或"继续访问"的选项(通常不推荐),允许用户自行决定是否承担风险继续访问。但某些严重的安全问题(如HSTS强制要求)可能不允许用户绕过。
场景: 用户访问一个证书过期的网站,或者访问一个使用自签名证书(未被CA信任)的内部系统,浏览器就会弹出此类警告。
<!-- 实际中,这由浏览器内部处理,开发者无法直接编写代码控制警告页面, 但可以理解其行为和提示语。例如Chrome浏览器可能会显示: --> <!-- <div class="interstitial-wrapper"> <h1>您的连接不是私密的</h1> <p>攻击者可能会试图从 example.com 窃取您的信息(例如密码、消息或信用卡信息)。</p> <p class="error-code">NET::ERR_CERT_COMMON_NAME_INVALID</p> <button id="proceed-button">继续访问 example.com (不安全)</button> </div> -->- 警告内容通常包括:
在地址栏显示不安全提示: 即使通过高级选项强行访问了证书有问题的网站,浏览器也会在地址栏明确显示"不安全"或红色警告图标,而不是通常的绿色安全锁标志,持续提醒用户当前连接存在风险。
禁止访问某些Web API: 为了进一步保护用户安全,一些敏感的Web API(如Geolocation、Service Worker、Push Notification等)可能要求在安全上下文(HTTPS且证书有效)中才能被调用。证书验证失败会阻止这些API的使用。
为什么客户端要这么处理?解决了什么问题?
- 防止中间人攻击(MITM): 这是最主要的目的。攻击者可能冒充合法服务器,通过伪造证书来截获并篡改用户数据。浏览器通过严格的证书验证机制,可以有效识别并阻止这种攻击,保护用户的隐私和数据完整性。
- 保护用户数据安全: 阻止未经加密或被篡改的数据传输,防止密码、信用卡号等敏感信息泄露。
- 建立用户信任: 通过明确的安全提示和地址栏标志,让用户清楚地了解当前连接的安全状况,帮助用户做出明智的决策,避免误入恶意网站。
- 强制网站采用HTTPS: 浏览器对HTTPS的严格要求和安全警告,也在客观上推动了网站开发者采用更安全的HTTPS协议,提升了整个互联网的安全性。
10.3 常见误区或面试陷阱
- 误以为会静默降级到HTTP: 有些候选人可能错误地认为证书失败时会自动切换到HTTP。实际上,浏览器会立即中断连接并发出警告,而不是降级。降级到HTTP会失去加密保护,违背了HTTPS的初衷。
- 不清楚证书验证的具体环节: 无法详细列举证书验证的各项检查(信任链、有效期、域名匹配、撤销状态等)。
- 忽略用户警告页面的作用: 仅仅提到中断连接,而没有说明浏览器如何通过用户友好的警告页面来教育和引导用户,以及提供选择权(尽管不推荐)。
- 认为证书错误是服务器的唯一责任: 虽然证书配置是服务器的任务,但客户端的验证机制是整个安全链的关键一环。客户端的验证失败直接导致了用户的安全风险。
- 不了解HSTS对证书验证失败的影响: 如果网站配置了HSTS,并且用户之前访问过,那么即使证书有问题,浏览器也可能不允许用户绕过警告,强制终止连接,进一步提升安全性。
- 混淆证书验证失败与网络连接问题: 证书验证失败是安全问题,而网络连接问题(如DNS解析失败、服务器不可达)是连接问题,两者不是一回事,但都可能导致连接中断。
11. 浏览器缓存有哪些类型?
题目要点
- 前端性能优化: 面试官希望确认候选人对浏览器缓存机制的理解,这是前端性能优化的重要手段之一。
- 缓存分类与原理: 考察候选人是否能清晰区分各种缓存类型(强缓存、协商缓存),并阐述它们的工作原理。
- HTTP协议深入: 确认候选人对HTTP头(如
Cache-Control、Expires、Last-Modified、ETag)的认知及其在缓存控制中的作用。 - 应用场景: 考察候选人是否能根据不同的业务需求和资源特性,选择合适的缓存策略。
参考答案
11.1 原理说明
浏览器缓存(Browser Caching)是Web性能优化的重要组成部分,它允许浏览器在本地存储Web资源(如HTML、CSS、JavaScript文件、图片等),以便在后续访问同一资源时可以直接从本地加载,而无需再次从服务器下载。这显著减少了网络延迟和服务器负载,从而提高了网站的加载速度和用户体验。
浏览器缓存可以分为两大类:强缓存(Strong Cache) 和 协商缓存(Negotiated Cache / Conditional Request)。
强缓存:
- 原理: 浏览器在第一次请求资源时,根据响应头中的缓存信息(如
Cache-Control和Expires),判断资源在一定时间内是否可以直接使用本地缓存,而无需向服务器发送请求。如果资源在有效期内,浏览器会直接从本地缓存中获取资源,状态码为200 (from disk cache)或200 (from memory cache)。浏览器不会与服务器进行通信。 - 控制字段:
Cache-Control(HTTP/1.1 新增):优先级最高。max-age=<seconds>:表示资源在指定秒数内是新鲜的,可以直接从缓存中获取。no-cache:表示客户端缓存服务器响应,但每次使用缓存前都必须先与服务器进行协商,验证缓存的有效性。no-store:表示客户端不得缓存任何响应,每次都向服务器重新获取。public:表示响应可以被任何缓存(包括CDN和代理服务器)缓存。private:表示响应只能被用户浏览器缓存,不能被共享缓存(如CDN)缓存。
Expires(HTTP/1.0):一个具体的日期和时间,表示资源过期时间。受限于客户端本地时间,可能导致不一致,优先级低于Cache-Control。
- 原理: 浏览器在第一次请求资源时,根据响应头中的缓存信息(如
协商缓存:
- 原理: 当强缓存失效(或设置了
no-cache)时,浏览器会向服务器发送请求,携带缓存标识,由服务器判断缓存资源是否仍然有效。如果有效,服务器会返回304 Not Modified状态码,告诉浏览器可以直接使用本地缓存,浏览器无需重新下载资源。如果失效,服务器会返回新的资源和200 OK状态码。 - 控制字段:
Last-Modified/If-Modified-Since:Last-Modified(响应头):服务器在响应头中指明资源的最后修改时间。If-Modified-Since(请求头):浏览器在后续请求中将Last-Modified的值发送给服务器,询问资源自该时间后是否被修改。如果未修改,服务器返回304。
ETag/If-None-Match:ETag(响应头):服务器为资源生成的唯一标识符(类似于文件内容的哈希值)。If-None-Match(请求头):浏览器在后续请求中将ETag的值发送给服务器,询问资源的ETag是否与服务器端的匹配。如果匹配,服务器返回304。
ETag和Last-Modified的优先级:ETag的优先级高于Last-Modified。当两者都存在时,浏览器会优先使用ETag进行协商缓存。
- 原理: 当强缓存失效(或设置了
强缓存和协商缓存的流程图:
graph TD
A[浏览器请求资源] --> B{是否存在强缓存?}
B -- 是 --> C[检查 Cache-Control/Expires]
C -- 未过期 --> D[直接使用本地缓存 (HTTP 200 From Cache)]
C -- 已过期 --> E{是否存在协商缓存标识?}
B -- 否 --> E
E -- 是 --> F[携带 If-Modified-Since/If-None-Match 发送请求到服务器]
F --> G{服务器判断资源是否已修改?}
G -- 未修改 --> H[服务器返回 304 Not Modified]
H --> D
G -- 已修改 --> I[服务器返回新资源 (HTTP 200 OK)]
I --> J[浏览器更新缓存并使用新资源]
E -- 否 --> K[直接发送请求到服务器]
K --> I
11.2 核心用法 + 示例代码
浏览器缓存的配置主要在服务器端完成,通过设置HTTP响应头来实现。前端开发者通常无需直接编写代码控制缓存,但需要了解其工作原理以便进行性能优化和问题排查。
服务器端配置示例(以Nginx为例):
# 强缓存配置示例
location /static/ {
# 静态资源(CSS, JS, 图片等)设置强缓存,缓存一年
expires 1y;
add_header Cache-Control "public";
}
# 协商缓存配置示例
location /data/ {
# 动态数据或经常变化的资源,可以利用协商缓存
# Nginx 默认会启用 Last-Modified 和 ETag
# etag on;
# last_modified on;
# 如果需要禁用,可以设置为 etag off; last_modified off;
}
# 对于不希望缓存的资源
location /api/ {
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate";
# 确保每次都从服务器获取最新数据
}
项目中的应用场景:
- 静态资源缓存: 对于不经常变动的CSS、JavaScript文件、图片、字体等静态资源,可以设置较长的强缓存时间(如一年),让浏览器直接从本地加载,极大提升二次访问速度。
- 首页HTML缓存: 首页HTML文件通常设置为协商缓存,以便在内容更新时能及时获取最新版本,同时在内容未变时利用
304减少流量。 - API数据缓存: 对于不经常变化的API数据(如配置信息、字典数据),可以在客户端(前端)或服务器端设置合适的缓存策略,减少重复请求。
- 图片优化: 对图片资源进行缓存,并结合图片压缩、CDN等,提升图片加载速度。
优势:
- 提升加载速度: 减少网络请求和数据传输,显著加快页面加载速度。
- 降低服务器负载: 减少服务器处理请求的次数和带宽消耗。
- 改善用户体验: 用户无需等待资源重新下载,浏览体验更流畅。
- 支持离线访问(配合Service Worker): 结合Service Worker可以实现更强大的离线缓存能力。
11.3 常见误区或面试陷阱
- 混淆强缓存和协商缓存: 无法清晰区分两种缓存的工作机制、控制字段以及它们之间优先级和触发条件。
- 对
Cache-Control指令不熟悉: 特别是对max-age、no-cache、no-store等指令的含义和作用理解不深入,或误用。 - 不了解
Expires和Cache-Control的区别及优先级:Expires是HTTP/1.0的,受客户端时间影响,优先级低于Cache-Control。 - 误认为
no-cache是不缓存:no-cache并不意味着不缓存,而是每次使用缓存前都必须向服务器验证。真正不缓存的是no-store。 - 忽略
ETag和Last-Modified的优先级:ETag优先级更高,能够更精确地识别资源是否修改(即使文件修改时间相同,内容也可能不同)。 - 缓存更新问题: 缓存可能导致资源更新不及时。解决策略通常包括:
- 版本号/哈希值: 对于静态资源,通过在文件名中添加版本号或文件内容的哈希值(如
bundle.js?v=1.2.3或bundle.js?hash=abc),强制浏览器加载新文件。 - 强缓存设置为
no-cache或短时间,配合协商缓存。
- 版本号/哈希值: 对于静态资源,通过在文件名中添加版本号或文件内容的哈希值(如
- 安全风险: 缓存可能导致敏感数据长时间滞留在用户本地。应避免缓存包含敏感信息的响应。
- 不了解CDN在缓存中的作用: CDN(内容分发网络)作为中间缓存层,也能大幅提升资源访问速度。虽然不是浏览器缓存本身,但在讨论缓存策略时通常会提及。
12. 什么是跨域?有哪些常见的跨域解决方案?
题目要点
- 浏览器安全策略: 面试官希望确认候选人对同源策略(Same-Origin Policy)的理解,这是浏览器安全的基础。
- 跨域问题本质: 考察候选人是否清楚跨域问题的由来,即为何浏览器会限制跨域请求。
- 多种解决方案: 确认候选人是否掌握常见的跨域解决方案及其原理、优缺点和适用场景。
- 安全性与复杂性: 考察候选人是否能权衡不同解决方案的安全性、部署复杂性以及对不同类型请求的支持。
参考答案
12.1 原理说明
什么是跨域(Cross-Origin)?
“跨域"是指当一个Web页面向另一个不同源的服务器发起HTTP请求时,浏览器会根据同源策略(Same-Origin Policy) 实施的安全限制。如果请求的协议、域名(主机)、端口号三者中任意一个与当前页面不同,就被认为是跨域。
同源策略 是浏览器的一项核心安全功能,它限制了不同源的文档或脚本之间进行交互的能力。它的目的是为了保护用户的信息安全,防止恶意网站通过JavaScript读取或操作用户在其他网站上的数据(例如,防止银行网站的数据被钓鱼网站的脚本窃取)。
虽然同源策略增强了安全性,但也给合法的跨域通信带来了障碍,因此需要各种解决方案来突破这种限制。
12.2 核心用法 + 示例代码
常见的跨域解决方案包括:
1. CORS (Cross-Origin Resource Sharing - 跨域资源共享) - 推荐方案
原理: CORS是一种W3C标准,它允许服务器通过设置HTTP响应头来告知浏览器,允许哪些域的Web应用程序访问其资源。它解决了浏览器同源策略的限制。
核心: 服务器在响应头中添加
Access-Control-Allow-Origin,明确允许特定来源的请求。类型:
- 简单请求: 满足特定条件的GET、HEAD、POST请求(请求方法为GET、HEAD、POST之一;HTTP头信息不超出Accept、Accept-Language、Content-Language、Content-Type,且Content-Type只支持
application/x-www-form-urlencoded、multipart/form-data、text/plain)。浏览器直接发送请求,服务器返回相应CORS头。 - 预检请求(Preflight Request): 对于非简单请求,浏览器会先发送一个OPTIONS请求(预检请求)到服务器,询问服务器是否允许当前域的真实请求。如果服务器响应允许,浏览器才会发送真正的请求。这增加了请求次数,但提供了额外的安全检查。
- 简单请求: 满足特定条件的GET、HEAD、POST请求(请求方法为GET、HEAD、POST之一;HTTP头信息不超出Accept、Accept-Language、Content-Language、Content-Type,且Content-Type只支持
示例代码 (服务器端配置,以Node.js Express为例):
// 安装:npm install cors const express = require('express'); const cors = require('cors'); const app = express(); // 允许所有来源访问(不推荐在生产环境直接使用) app.use(cors()); // 只允许特定来源访问 // app.use(cors({ origin: 'http://example.com' })); // 允许特定方法和头部 // app.use(cors({ // origin: 'http://example.com', // methods: ['GET', 'POST'], // allowedHeaders: ['Content-Type', 'Authorization'] // })); app.get('/api/data', (req, res) => { res.json({ message: 'Data from cross-origin' }); }); app.listen(3000, () => console.log('Server listening on port 3000'));适用场景: 现代Web开发中最主流、最推荐的跨域解决方案,由服务器端控制,对前端透明。
优势: 官方标准,安全性高,支持所有HTTP方法和自定义请求头,配置灵活。
劣势: 需要服务器端配合,对于非简单请求会多一次预检请求。
2. JSONP (JSON with Padding) - 仅限GET请求
原理: 利用HTML中
<script>标签没有同源策略限制的特点。前端通过动态创建<script>标签,将回调函数名作为参数传递给后端,后端返回一段JavaScript代码,执行该回调函数并将数据作为参数传入。核心:
<script src="cross_domain_api?callback=myCallback"></script>示例代码:
// 前端 function myCallback(data) { console.log('JSONP data:', data); } const script = document.createElement('script'); script.src = 'http://api.example.com/data?callback=myCallback'; document.body.appendChild(script);// 后端 (Node.js Express 示例) app.get('/data', (req, res) => { const callback = req.query.callback; const data = { message: 'Hello from JSONP' }; res.send(`${callback}(${JSON.stringify(data)})`); // 返回可执行的JS代码 });适用场景: 只需要进行GET请求的场景,且浏览器版本较老不支持CORS的情况(现代浏览器很少需要)。
优势: 兼容性好,支持老旧浏览器。
劣势: 仅支持GET请求,不安全(易受XSS攻击),需要后端配合修改返回格式,无法处理错误(如404、500)。
3. 代理 (Proxy) - 后端转发请求
原理: 浏览器向与当前页面同源的代理服务器发送请求,代理服务器再将请求转发给目标跨域服务器,并将响应返回给浏览器。整个过程中,浏览器与代理服务器同源,避免了跨域问题。
核心: 在同源服务器上设置一个转发接口。
示例代码 (Nginx配置代理):
server { listen 80; server_name yourdomain.com; location /api/ { proxy_pass http://api.cross-domain.com/; # 其他代理配置,如重写请求头等 # proxy_set_header Host $host; } }前端请求:
fetch('/api/data')适用场景: 开发环境中(如Webpack Dev Server代理),或生产环境中通过Nginx/Apache等服务器进行反向代理。
优势: 对前端完全透明,可以处理任何类型的请求(GET/POST/PUT/DELETE等),安全性高(中间层可以增加安全控制)。
劣势: 增加了服务器的负担和复杂度,需要额外部署代理服务器或配置Web服务器。
4. WebSocket - 基于TCP的长连接
原理: WebSocket是一种独立于HTTP的协议,它允许在客户端和服务器之间建立全双工(双向)的、持久化的连接。WebSocket本身没有同源策略的限制。
核心:
ws://或wss://协议。示例代码 (前端):
const ws = new WebSocket('ws://api.example.com/websocket'); ws.onopen = function(event) { console.log('WebSocket Opened'); ws.send('Hello Server!'); }; ws.onmessage = function(event) { console.log('Message from Server:', event.data); }; ws.onerror = function(event) { console.error('WebSocket Error:', event); }; ws.onclose = function(event) { console.log('WebSocket Closed'); };适用场景: 需要实时通信的场景,如聊天室、在线游戏、股票行情、协同编辑等。
优势: 支持双向通信,开销小,性能高。
劣势: 不适用于传统的请求-响应模式,需要专门的WebSocket服务器支持。
其他不常用或特殊场景的方案:
document.domain+ iframe: 适用于主域相同、子域不同的情况。通过设置document.domain使两者同源。window.postMessage: 用于不同源窗口或iframe之间的安全通信。- Node.js 中间件代理: 在开发环境中常用,如
http-proxy-middleware。
项目中的选择:
在现代Web开发中,CORS 是最通用和推荐的解决方案,因为它符合标准、安全性高且对前端友好。当CORS无法满足需求(如需要长连接)时,会考虑WebSocket。代理常用于开发环境解决跨域,或在生产环境通过Nginx等实现反向代理。JSONP因其安全性问题和功能限制,已逐渐被淘汰。
12.3 常见误区或面试陷阱
- 不理解同源策略的本质: 认为跨域是由于网络不通或协议不同,而没有理解其根本原因是浏览器基于安全考虑的同源策略限制。
- 混淆不同解决方案的适用场景和优缺点: 例如,认为JSONP可以处理POST请求,或者CORS不需要服务器配置。
- 对CORS预检请求的理解不足: 无法解释为什么某些CORS请求会先发送OPTIONS请求,以及预检请求的作用。
- 忽略安全性问题: 比如在CORS中设置
Access-Control-Allow-Origin: *(允许所有来源)虽然方便,但可能带来安全隐患(特别是对于敏感API),面试时应指出其风险。 - 认为所有跨域问题都应由前端解决: 实际上,许多跨域问题(如CORS)需要后端进行配置,前端主要负责发起请求和处理响应。
- 将
JSONP与JSON混淆:JSONP是一种跨域技术,而JSON是一种数据格式,两者概念不同。 - 不了解WebSocket不遵循同源策略: WebSocket协议独立于HTTP,有自己的握手机制,一旦连接建立,就不受同源策略限制。
13. 在飞书这样的复杂应用中,如何确保不同子域之间的安全通信?你更倾向于使用哪种跨域方案?
题目要点
- 复杂应用架构下的安全通信: 面试官希望确认候选人对大型多子域应用(如飞书)中安全通信挑战的理解,以及如何应对这些挑战。
- 同源策略与子域: 考察候选人对同源策略在子域间通信中的具体应用和限制的理解。
- 多维度安全考量: 确认候选人是否能从数据传输、身份验证、数据隔离等多个维度思考安全性问题。
- 跨域方案的综合运用: 考察候选人是否能根据不同场景选择和组合使用多种跨域解决方案,并能权衡其优劣。
- 个人倾向与理由: 确认候选人是否具备独立思考和技术选型的能力,并能清晰阐述选择理由。
参考答案
13.1 原理说明
在像飞书这样复杂的应用中,通常会采用多个子域来承载不同的业务模块(例如 docs.feishu.cn, mail.feishu.cn, www.feishu.cn 等)。虽然它们共享同一个主域名,但由于子域名不同,浏览器仍然会根据同源策略对不同子域之间的直接JavaScript操作进行限制,以防止恶意脚本跨子域窃取或篡改数据。例如,docs.feishu.cn 下的脚本无法直接访问 mail.feishu.cn 的DOM内容或LocalStorage。
确保不同子域之间的安全通信,核心在于在满足同源策略限制的前提下,实现安全的数据交换和功能协同。这不仅涉及到数据传输的加密,更重要的是实现身份信息(如登录状态)的共享、消息传递以及避免安全漏洞(如CSRF、XSS)。
挑战主要体现在:
- 数据隔离与共享: 如何在不同子域间安全地共享用户身份、配置等数据,同时保证其他敏感数据不被非法访问。
- 消息传递: 不同子域的页面之间需要相互通信,触发事件或传递指令。
- 统一认证: 用户在一个子域登录后,如何在其他子域实现免登。
- 安全漏洞: 防止通过子域间的通信引发XSS、CSRF等攻击。
13.2 核心用法 + 示例代码
在飞书这样的复杂多子域应用中,确保安全通信通常会采用多种方案的组合,并以HTTPS作为基础安全层。以下是几种关键方案及其应用:
1. HTTPS (SSL/TLS加密) - 基础且强制要求
- 原理: 确保所有子域都强制使用HTTPS。HTTPS提供了数据加密、身份认证和数据完整性保护,是保障任何网络通信安全的基础。它防止了数据在传输过程中被窃听和篡改。
- 作用: 无论采用哪种跨域通信方案,HTTPS都是必不可少的,它保障了通信通道本身的安全性。
- 场景: 所有前端页面和API接口都必须通过
https://协议访问。
2. document.domain (配合 iframe 或新窗口) - 适用于同主域下的简单通信
- 原理: 浏览器允许子域将
document.domain设置为其主域。例如,docs.feishu.cn和mail.feishu.cn都可以将document.domain设置为feishu.cn。一旦设置,这两个子域就会被浏览器视为同源,从而允许它们直接访问彼此的DOM和JavaScript对象(如LocalStorage、Cookie)。 - 示例代码:
// 在 docs.feishu.cn 页面 (父页面) document.domain = 'feishu.cn'; const iframe = document.getElementById('mail-iframe'); // 等待 iframe 加载完成且设置 document.domain 后 iframe.onload = function() { try { const iframeDoc = iframe.contentWindow.document; console.log('可以访问 iframe 的文档', iframeDoc.title); // 也可以访问 iframe 的全局变量或函数 iframe.contentWindow.someIframeFunction(); } catch (e) { console.error('访问 iframe 失败:', e); } }; // 在 mail.feishu.cn 页面 (iframe 内部) document.domain = 'feishu.cn'; window.someIframeFunction = function() { console.log('Iframe function called from parent.'); }; - 适用场景: 当父页面和iframe处于不同子域但同主域下,需要进行DOM操作或共享LocalStorage等简单通信时。例如,一个主页面嵌入了来自另一个子域的模块。
- 优势: 实现相对简单,对Cookie和LocalStorage的共享更为方便。
- 劣势: 存在安全隐患,如果其中一个子域被XSS攻击,攻击者可以利用
document.domain来访问和控制其他子域的内容,可能导致会话劫持。因此,应谨慎使用,并确保所有涉及的子域都高度可信和安全。
3. window.postMessage (跨文档消息通信) - 安全且推荐的异步消息传递
原理:
window.postMessageAPI 允许来自不同源的文档安全地进行双向通信。它通过异步发送消息,并强制指定接收方和发送方的源,从而防止消息被未经授权的源接收或发送。核心:
targetWindow.postMessage(message, targetOrigin)和window.addEventListener('message', ...)。示例代码:
// 在 docs.feishu.cn 页面 (父页面) const iframe = document.getElementById('mail-iframe'); // 向 iframe 发送消息,并指定 iframe 的预期来源 iframe.contentWindow.postMessage( { type: 'AUTH_TOKEN', token: 'some-jwt-token' }, 'https://mail.feishu.cn' // 必须指定接收方的确切源 ); // 监听来自 iframe 的消息 window.addEventListener('message', (event) => { // 务必验证消息来源,防止接收恶意消息 if (event.origin === 'https://mail.feishu.cn') { console.log('Received message from iframe:', event.data); // 处理收到的数据 } else { console.warn('Ignoring message from untrusted origin:', event.origin); } }); // 在 mail.feishu.cn 页面 (iframe 内部) window.addEventListener('message', (event) => { // 务必验证消息来源,防止接收恶意消息 if (event.origin === 'https://docs.feishu.cn') { console.log('Received message from parent:', event.data); // 处理收到的数据,然后可以回复消息 event.source.postMessage( { type: 'OPERATION_DONE', status: 'success' }, event.origin // 回复时指定发送方的源 ); } else { console.warn('Ignoring message from untrusted origin:', event.origin); } });适用场景: 任何需要跨域(包括跨子域)进行安全异步消息传递的场景。例如,父应用与嵌入的第三方应用(即使是自家其他子域的应用)进行通信、统一登录状态同步、数据上报等。
优势: 安全性高(强制源验证),支持所有类型的消息数据(通过结构化克隆算法),简单易用,是现代Web开发中跨域通信的首选方案之一。
劣势: 基于消息传递,不适合直接DOM操作或共享内存。
4. CORS (跨域资源共享) - API通信的基石
- 原理: 即使是不同子域之间的API请求,也仍然受同源策略限制。CORS通过服务器设置
Access-Control-Allow-Origin等响应头,明确允许来自其他子域的请求访问API资源。 - 场景: 飞书的应用模块(如文档、日历、会议)后端API可能由不同的服务提供,但客户端(前端)需要通过API进行数据交互。此时需要配置CORS来允许这些跨子域的API请求。
- 优势: 官方标准,安全性高,支持所有HTTP方法,适用于传统的请求-响应模式。
- 劣势: 需要后端配合配置,对于非简单请求有预检请求开销。
5. 统一登录(SSO)与Cookie域设置
- 原理: 对于用户登录状态的共享,通常通过统一认证系统(Single Sign-On, SSO)实现。SSO通常会结合Cookie的
Domain属性。将登录相关的Cookie的Domain设置为主域名(例如.feishu.cn),这样该Cookie就可以在所有子域下共享和访问,实现一次登录,多处免登。 - 场景: 用户在
www.feishu.cn登录后,访问docs.feishu.cn或mail.feishu.cn时无需再次登录。 - 优势: 实现用户体验的无缝衔接。
- 劣势: 需要精心设计SSO系统,并注意Cookie的
HttpOnly和Secure属性以提高安全性。
我更倾向于使用的跨域方案:
在飞书这样的复杂应用中,我更倾向于组合使用 window.postMessage、CORS,并以统一的HTTPS和SSO策略作为基础。具体原因如下:
- 安全性优先:
window.postMessage提供了严格的源验证机制,确保消息不会被恶意源发送或接收,极大降低了跨域通信的安全风险。CORS 同样是官方标准,通过服务器配置保障了API层面的安全。而document.domain由于潜在的安全隐患(如果某个子域被XSS,可能影响其他子域),应尽量避免或在严格控制下使用,且仅限于同主域场景。 - 职责分离与模块化:
window.postMessage适合于不同子域(或模块)之间进行异步的消息传递和事件通知,能够很好地支持微前端或模块化应用架构,使各模块保持相对独立,职责清晰。 - API通信的灵活性: CORS 是进行跨域API请求的标准且灵活的方式,无论是简单请求还是复杂请求,都可以通过后端配置轻松支持,满足业务数据交互的需求。
- 统一用户体验: 结合SSO和Cookie
Domain配置,可以确保用户在不同子域之间的无缝登录体验,这是大型应用不可或缺的部分。 - 可维护性和可扩展性: 采用标准化、安全的方案,能够降低开发和维护成本,并为未来业务扩展提供坚实的基础。
简而言之,对于页面间消息通信,window.postMessage是首选;对于数据API请求,CORS是首选;而HTTPS和SSO则是所有这些通信的安全基石和用户体验保证。
13.3 常见误区或面试陷阱
忽略HTTPS的根本性作用: 许多候选人会直接跳到跨域方案,而忘记强调HTTPS是所有安全通信的基础,没有HTTPS,任何其他方案的安全性都大打折扣。
对
document.domain的安全风险认知不足: 认为document.domain是一个完美无缺的解决方案,而没有指出其在子域被XSS攻击时可能导致的严重安全问题。不验证
postMessage的event.origin: 这是postMessage最常见的安全陷阱。如果接收方不验证event.origin,那么任何恶意页面都可以向当前页面发送消息,可能导致安全漏洞。混淆Cookie的
Domain和Path属性: 不清楚如何正确设置Cookie的Domain属性来实现子域共享,或与Path属性混淆。只提出单一解决方案: 复杂应用通常需要多种方案组合,如果只提到一种解决方案(如只说CORS),可能显得思考不全面。
未提及SSO: 对于飞书这类大型应用,统一登录是跨子域通信中非常重要的一环,如果面试时未提及,可能显得对企业级应用了解不足。
未提及SSO: 对于飞书这类大型应用,统一登录是跨子域通信中非常重要的一环,如果面试时未提及,可能显得对企业级应用了解不足。
14. 简单说说 CommonJS 和 ES6 Module 的区别。
题目要点
- 模块化概念: 面试官希望确认候选人对JavaScript模块化历史、发展及其核心目的的理解。
- CommonJS规范: 考察候选人对CommonJS(主要用于Node.js)的语法、加载机制(同步加载)、模块导出/导入方式的掌握。
- ES6 Module规范: 考察候选人对ES6 Module(浏览器和Node.js通用)的语法、加载机制(异步加载)、模块导出/导入方式、以及其静态特性的理解。
- 异同点对比: 确认候选人是否能清晰对比两者在语法、加载机制、使用场景、
this指向、循环依赖处理等方面的异同。
参考答案
14.1 原理说明
在JavaScript早期,由于缺乏原生的模块系统,导致全局变量冲突、代码难以组织和维护等问题。为了解决这些问题,社区和语言本身先后提出了不同的模块化规范。其中,CommonJS 和 ES6 Module 是目前最主流的两种模块化方案。
CommonJS: CommonJS 规范主要用于服务器端(如 Node.js 环境),它采用同步加载模块的方式。当代码执行到
require语句时,会立即加载并执行目标模块,然后返回其导出的内容。这是因为在服务器端,模块文件通常位于本地磁盘,读取速度快,同步加载不会造成阻塞。ES6 Module (ESM): ES6 Module 是 ECMAScript 2015 (ES6) 引入的官方模块化规范,旨在成为浏览器和服务器端通用的模块系统。它采用异步加载模块的方式(尽管在打包工具处理后看起来是同步的),并具有静态化的特性,即模块的导入和导出在编译阶段(而非运行阶段)就已经确定。
CommonJS 和 ES6 Module 的核心区别:
加载机制:
- CommonJS:同步加载。 模块在运行时加载,
require是一个函数,用于加载并返回模块的导出对象。当遇到require时,会阻塞当前代码的执行,直到所需模块加载完成。 - ES6 Module:异步加载/静态解析。 模块的导入导出在编译阶段就完成,
import和export是关键字。ESM 旨在支持浏览器环境下的异步加载,尽管通过构建工具(如Webpack、Rollup)打包后,看起来像同步加载,但其本质是静态的,允许进行 Tree Shaking(摇树优化)。
- CommonJS:同步加载。 模块在运行时加载,
导出和导入:
- CommonJS:值拷贝。 模块导出的内容是值的拷贝,一旦模块导出后,即使原始模块内部的变量发生改变,已导入的模块也无法感知到这些变化。
- 导出:
module.exports = ...或exports.property = ... - 导入:
const module = require('./module')
- 导出:
- ES6 Module:引用传递 (动态绑定)。 模块导出的内容是原始值的引用(live binding),这意味着导入的模块可以实时反映导出模块内部变量的最新变化。
- 导出:
export default ...或export const name = ... - 导入:
import ... from './module'
- 导出:
- CommonJS:值拷贝。 模块导出的内容是值的拷贝,一旦模块导出后,即使原始模块内部的变量发生改变,已导入的模块也无法感知到这些变化。
this指向:- CommonJS: 模块内部的
this指向当前模块的exports对象。 - ES6 Module: 模块内部的
this严格为undefined(在非严格模式下也是)。
- CommonJS: 模块内部的
循环依赖:
- CommonJS: 当出现循环依赖时,CommonJS 会返回一个部分完成的模块对象(通常是未完全执行的
exports对象),以避免死锁。 - ES6 Module: ES6 Module 通过动态绑定的特性,可以更好地处理循环依赖,因为它导出的是值的引用,即使在模块完全加载前也能访问到。
- CommonJS: 当出现循环依赖时,CommonJS 会返回一个部分完成的模块对象(通常是未完全执行的
使用场景:
- CommonJS: 主要用于 Node.js 服务器端编程。
- ES6 Module: 既可用于浏览器端(通过
<script type="module">),也可用于 Node.js (需要配置package.json中的type: "module"或.mjs扩展名)。
14.2 核心用法 + 示例代码
CommonJS 示例:
// commonjs_module.js (导出模块)
let count = 0;
function increment() {
count++;
return count;
}
exports.count = count; // 导出的是当前 count 的值拷贝 (0)
exports.increment = increment;
// main.js (导入模块)
const myModule = require('./commonjs_module');
console.log(myModule.count); // 输出: 0
console.log(myModule.increment()); // 输出: 1
console.log(myModule.increment()); // 输出: 2
console.log(myModule.count); // 输出: 0 (因为导出的是值拷贝,外部无法感知 count 的变化)
ES6 Module 示例:
// es6_module.js (导出模块)
export let count = 0;
export function increment() {
count++;
return count;
}
// main.mjs (导入模块) 或在 package.json 中设置 "type": "module"
import { count, increment } from './es6_module.js';
console.log(count); // 输出: 0
console.log(increment()); // 输出: 1
console.log(increment()); // 输出: 2
console.log(count); // 输出: 2 (因为导出的是引用,外部能实时感知 count 的变化)
项目中的应用:
- CommonJS: 在 Node.js 后端服务中,仍然广泛使用 CommonJS 来组织模块。例如,使用
require引入express框架、数据库连接模块等。 - ES6 Module:
- 前端框架和库: 现代前端项目(如 React, Vue, Angular)的组件和工具库普遍采用 ES6 Module 进行模块化,方便打包工具进行 Tree Shaking 优化。
- Node.js 新项目: 随着 Node.js 对 ES6 Module 的支持日益完善,越来越多的 Node.js 新项目也开始采用 ES6 Module。
解决了什么问题,相比其他方案有什么优势:
- 解决了全局变量污染问题: 将代码封装在各自的模块中,避免不同文件之间变量名冲突。
- 代码组织与维护: 提供了清晰的模块依赖关系,使代码结构更清晰,易于理解和维护。
- 按需加载: ES6 Module 的静态特性和异步加载设计,使得打包工具可以进行 Tree Shaking,只打包实际使用的代码,减小最终文件体积。
- 更好的兼容性: ES6 Module 是官方标准,旨在统一浏览器和服务器端的模块化方案。
14.3 常见误区或面试陷阱
- 混淆加载机制: 最常见的是不清楚 CommonJS 是同步加载,ES6 Module 是静态解析和异步加载的。很多人会认为 ES6 Module 也是运行时加载。
- 混淆导出值的类型: 误以为 CommonJS 导出的是引用,ES6 Module 导出的是值拷贝。记住:CommonJS 是值拷贝,ES6 Module 是引用(动态绑定)。
- 对
this指向的误解: 在 CommonJS 模块中this指向exports,但在 ES6 Module 中this严格为undefined。 - 认为 CommonJS 已被淘汰: 尽管 ES6 Module 是未来的趋势,但 CommonJS 在 Node.js 生态中仍然非常活跃和重要。
- 不了解 Tree Shaking: 不清楚 ES6 Module 的静态特性如何支持 Tree Shaking,从而实现代码优化。
- 循环依赖的处理: 对 CommonJS 和 ES6 Module 在处理循环依赖时的行为差异不清晰。
15. 虚拟 DOM 的工作原理及其在 React 中的作用,为何它能提升性能?
题目要点
- DOM 操作的性能瓶颈: 面试官希望确认候选人是否理解直接操作真实 DOM 的性能开销。
- 虚拟 DOM 的定义: 考察候选人对虚拟 DOM(Virtual DOM)概念的理解,即它是一个轻量级的JavaScript对象表示。
- 工作原理: 确认候选人是否能阐述虚拟 DOM 的核心工作流程:构建虚拟 DOM、比较差异(Diff 算法)、更新真实 DOM。
- 在 React 中的作用: 考察虚拟 DOM 在 React 组件生命周期和 UI 更新机制中的具体角色。
- 性能提升的原因: 确认候选人是否能解释虚拟 DOM 如何通过减少直接操作真实 DOM 的次数来提升性能。
参考答案
15.1 原理说明
在前端开发中,频繁地直接操作真实 DOM(Document Object Model)是导致性能瓶颈的主要原因之一。每次对 DOM 的修改(如添加、删除、更新元素)都可能触发浏览器的重排(reflow)和重绘(repaint)操作,这些操作非常耗费性能,尤其是在大型、复杂的应用中。
为了解决这个问题,React 等现代前端框架引入了 虚拟 DOM (Virtual DOM) 的概念。
虚拟 DOM 的定义: 虚拟 DOM 是一个轻量级的 JavaScript 对象,它用 JavaScript 对象来模拟真实 DOM 树的结构。它不是真实的 DOM 元素,而是一个存在于内存中的 JavaScript 数据结构。当组件的状态发生变化时,React 不会直接去操作真实 DOM,而是先操作这个虚拟 DOM。
工作原理:
虚拟 DOM 的工作流程可以概括为以下三个步骤:
构建虚拟 DOM (Render):
- 当 React 组件的
render方法被调用时(通常是由于state或props发生变化),它不会直接返回真实 DOM 元素,而是返回一个描述 UI 结构的虚拟 DOM 树(JSX 最终会被编译成React.createElement调用,生成虚拟 DOM)。 - 这个虚拟 DOM 树是一个 JavaScript 对象,包含了标签名、属性、子元素等信息。
- 当 React 组件的
比较差异 (Diff):
- 当组件的状态再次发生变化,触发重新渲染时,React 会重新构建一个新的虚拟 DOM 树。
- 然后,React 会将这个新的虚拟 DOM 树与上一次生成的虚拟 DOM 树进行比较。这个比较过程就是著名的 Diff 算法(或协调算法 Reconciliation)。
- Diff 算法会找出两棵虚拟 DOM 树之间最小的差异,并生成一个“补丁集”(patch)。
更新真实 DOM (Patch):
- Diff 算法计算出差异后,React 会将这个“补丁集”一次性地应用到真实的 DOM 上。
- 这意味着,React 不会对真实 DOM 进行频繁的、细粒度的操作,而是将所有必要的修改集中起来,进行批量操作。
在 React 中的作用:
- 抽象层: 虚拟 DOM 作为真实 DOM 的一个抽象层,使得开发者可以专注于组件的状态和 UI 的逻辑,而不需要直接与复杂的浏览器 DOM API 打交道。
- 性能优化: 它是 React 性能优化的核心机制。
- 跨平台能力: 虚拟 DOM 不仅可以渲染到浏览器 DOM,还可以渲染到其他平台(如 React Native 渲染到原生组件,或渲染到 Canvas、WebGL),增强了 React 的跨平台能力。
15.2 核心用法 + 示例代码
虚拟 DOM 并不是我们直接去“使用”的 API,而是 React 内部的一种机制。作为开发者,我们编写 JSX,React 会自动处理虚拟 DOM 的创建、比较和更新。
示例代码(概念层面):
假设我们有一个计数器组件:
// 假设这是 React 的内部机制
// 首次渲染
const oldVnode = {
type: 'div',
props: { className: 'container' },
children: [
{ type: 'h1', props: {}, children: ['Count: ', 0] },
{ type: 'button', props: { onClick: () => {} }, children: ['Increment'] }
]
};
// React 会根据 oldVnode 生成真实的 DOM 结构并挂载到页面
// state 更新后,重新渲染
const newVnode = {
type: 'div',
props: { className: 'container' },
children: [
{ type: 'h1', props: {}, children: ['Count: ', 1] }, // 只有这里有变化
{ type: 'button', props: { onClick: () => {} }, children: ['Increment'] }
]
};
// React 内部的 Diff 算法会比较 oldVnode 和 newVnode
// 发现只有 h1 内部的文本内容从 0 变成了 1
// 最终,React 只会去更新真实 DOM 中 h1 元素的文本内容,而不是重新渲染整个 div
项目中的应用场景:
- 所有 React 应用: 任何使用 React 开发的应用程序,都在底层利用了虚拟 DOM。
- 复杂 UI 更新: 特别是在 UI 状态频繁变化、需要大量更新操作的场景下(如实时图表、拖拽操作、大型数据列表),虚拟 DOM 的批量更新能力能显著提升性能。
- SSR (Server-Side Rendering): 虚拟 DOM 也可以在服务器端生成,然后发送到客户端进行“注水”(hydration),加速首屏渲染。
解决了什么问题,相比其他方案有什么优势:
- 解决了直接操作真实 DOM 的性能瓶颈: 这是最核心的优势。避免了频繁触发浏览器的重排和重绘。
- 简化了开发者心智负担: 开发者无需关心底层 DOM 操作的细节和性能优化,只需声明式地描述 UI 应该是什么样子,React 会自动处理高效的更新。
- 提升了开发效率: 声明式编程模型使得 UI 开发更加直观和高效。
- 跨平台能力: 虚拟 DOM 本身是一个抽象层,可以方便地扩展到其他渲染目标,如 React Native、Canvas、WebGL 等。
15.3 常见误区或面试陷阱
- 认为虚拟 DOM 性能一定比真实 DOM 快: 虚拟 DOM 并不总是比直接操作真实 DOM 快。在少量 DOM 操作的场景下,虚拟 DOM 的构建和 Diff 过程反而会增加额外的开销。其优势在于大量、频繁的 DOM 操作场景下,它能够通过批量更新来最小化真实 DOM 操作,从而提升整体性能。
- 混淆虚拟 DOM 和真实 DOM: 认为虚拟 DOM 就是真实 DOM,或者直接将其等同于 JSX。虚拟 DOM 是一个 JavaScript 对象,JSX 是语法糖,最终会被编译成虚拟 DOM。
- 不了解 Diff 算法是核心: 虚拟 DOM 的性能优势很大程度上依赖于高效的 Diff 算法。如果 Diff 算法不够智能,可能会导致不必要的真实 DOM 更新。
- 误以为 Diff 算法是全量对比: Diff 算法并非简单地将两棵树全量对比,而是采用了启发式算法(如“同层比较”),以 O(n) 的复杂度进行比较,牺牲了一定的准确性来换取性能。
- 忽略了
key的重要性: 在列表渲染中,key的作用就是帮助 Diff 算法更高效地识别元素的变化,防止不必要的重渲染,这是虚拟 DOM 性能优化的一个关键点。 - 认为虚拟 DOM 解决了所有性能问题: 虚拟 DOM 解决了 DOM 操作的性能问题,但网络请求、图片加载、JavaScript 执行效率等其他方面的性能问题仍需其他优化手段。
16. React Diff 算法的「同层比较」策略如何实现?列表渲染时为何需要 key?
题目要点
- Diff 算法核心策略: 面试官希望确认候选人对 React Diff 算法(协调 Reconcilation)的核心优化策略——“同层比较”(或“层级比较”)的理解。
- 实现细节: 考察候选人是否能阐述 Diff 算法在比较同层节点时如何识别组件类型、元素的标签类型,以及如何处理节点移动、删除、新增。
key的作用: 确认候选人是否清楚列表渲染中key的必要性,以及它如何帮助 Diff 算法优化更新性能和避免潜在问题。- 不使用
key的后果: 考察候选人是否了解不使用key或使用不当key(如index)可能导致的问题。
参考答案
16.1 原理说明
React 的 Diff 算法(也称为协调 Reconciliation)是虚拟 DOM 能够高效更新真实 DOM 的核心。由于直接计算两棵任意树的最小编辑距离(将其一棵树转换为另一棵树所需的最少操作)是一个复杂的问题,时间复杂度高达 O(n³)。React 为了提升性能,基于以下两个假设提出了一个启发式算法,将时间复杂度降低到 O(n):
- 两个不同类型的元素会产生不同的树结构。 当根元素类型不同时,React 会直接销毁旧树,创建新树。
- 开发者可以通过
keyprop 来暗示哪些子元素在不同的渲染中可能是稳定的。
基于这两个假设,React Diff 算法的核心策略是**“同层比较”(Level-by-Level Comparison)**。
「同层比较」策略的实现:
React Diff 算法在比较两棵虚拟 DOM 树时,不会跨层级进行比较,而是只在同一层级内进行子节点的比较。它会从根节点开始,逐层遍历比较:
比较根节点:
- 如果新旧根节点的元素类型不同(如
<div>变成了<span>),React 会直接销毁旧的组件或 DOM 节点及其所有子节点,并完全创建并挂载新的组件或 DOM 节点。这是一种代价较高的操作。 - 如果新旧根节点的元素类型相同,React 会保留该 DOM 节点,只更新其属性(
props)。然后继续递归比较其子节点。
- 如果新旧根节点的元素类型不同(如
比较同层子节点:
- 当比较同一父节点下的子节点列表时,React 会遍历新旧子节点列表,并尝试进行匹配。
- 默认情况下,React 会按索引顺序进行比较。例如,比较旧列表的第一个子节点和新列表的第一个子节点,旧列表的第二个子节点和新列表的第二个子节点,依此类推。
- 在这个过程中,
key的作用就显得尤为重要。
16.2 核心用法 + 示例代码
列表渲染时为何需要 key?
在处理列表渲染时,React Diff 算法的“同层比较”策略如果仅依赖索引进行比较,会遇到一个问题:当列表项的顺序发生变化、有新增、删除或移动时,仅仅依靠索引会导致 React 无法准确识别哪些是同一个元素,从而进行不必要的 DOM 操作,甚至导致状态错乱。
key 的作用:
key 是 React 用于识别列表中每个元素的唯一标识。当列表中的元素顺序发生变化时,React 会根据 key 来判断哪些是相同的组件或 DOM 元素。
具体来说,key 帮助 React 在以下场景中优化性能和确保正确性:
- 高效更新: 当列表项发生变化(新增、删除、移动)时,React 不会傻瓜式地销毁并重建所有 DOM 节点。而是根据
key值,它能够:- 识别元素移动: 如果一个元素的
key在新旧列表中都存在,但位置变了,React 会将该 DOM 元素直接移动到新位置,而不是销毁旧的再创建新的。这比重新渲染所有元素效率高得多。 - 识别元素新增: 如果一个元素的
key只在新列表中出现,说明它是新增的,React 会将其插入到正确位置。 - 识别元素删除: 如果一个元素的
key只在旧列表中出现,说明它被删除了,React 会将其对应的 DOM 元素移除。
- 识别元素移动: 如果一个元素的
- 维护组件状态: 对于列表中的组件,
key能够确保在列表更新时,组件实例能够正确地被复用或销毁。如果没有key或key不稳定,React 可能会错误地复用组件,导致内部状态混乱。例如,一个有输入框的列表,如果列表顺序变化但key不正确,可能导致输入框的值显示在错误的行上。
何时应该使用 key:
- 当你在 React 中渲染一个列表(如
map方法)时,每个列表项都应该有一个稳定的、唯一的keyprop。
key 的选择:
- 唯一且稳定:
key必须是列表项的唯一标识,并且在列表的整个生命周期中保持稳定。最理想的key是来自数据本身的唯一 ID(如数据库 ID)。 - 避免使用
index作为key(除非列表是静态且永不变化的):- 不稳定性: 当列表的顺序发生变化、有新增、删除或移动操作时,索引会发生变化,导致 React 无法正确识别元素。
- 性能问题: 此时 Diff 算法会认为所有元素都改变了,从而导致不必要的 DOM 重新创建,而不是简单的移动或更新。
- 状态错乱: 对于受控组件或包含内部状态的组件,使用索引作为
key容易导致状态错乱。
示例代码:
// 不推荐:使用 index 作为 key
function BadList({ items }) {
return (
<ul>
{items.map((item, index) => (
<li key={index}>{item.text}</li>
))}
</ul>
);
}
// 推荐:使用唯一的 ID 作为 key
function GoodList({ items }) {
return (
<ul>
{items.map((item) => (
<li key={item.id}>{item.text}</li> // 假设 item 有唯一的 id
))}
</ul>
);
}
// 示例数据
const initialItems = [
{ id: 1, text: 'Apple' },
{ id: 2, text: 'Banana' },
{ id: 3, text: 'Cherry' },
];
// 如果将 Banana 和 Cherry 交换位置
const swappedItems = [
{ id: 1, text: 'Apple' },
{ id: 3, text: 'Cherry' },
{ id: 2, text: 'Banana' },
];
// 如果使用 index 作为 key,React 会认为索引 1 的元素从 Banana 变成了 Cherry,索引 2 的元素从 Cherry 变成了 Banana
// 实际上它会更新这些元素的内容,而不是移动 DOM 元素,导致不必要的 DOM 操作和潜在状态问题。
// 如果使用 id 作为 key,React 会识别到 id 为 2 和 3 的元素位置发生变化,然后直接移动对应的 DOM 元素,效率更高。
解决了什么问题,相比其他方案有什么优势:
- 解决了列表元素频繁变动时的性能问题: 通过
key标识,Diff 算法能够更精确地找到变化的最小集,避免了不必要的 DOM 操作,大幅提升了列表更新性能。 - 确保了组件状态的正确性:
key保证了即使列表顺序变化,组件也能保持其正确的内部状态,避免了 UI 混乱。 - 提高了用户体验: 更高效的更新意味着更流畅的 UI 交互。
16.3 常见误区或面试陷阱
- 不理解 Diff 算法的启发式性质: 认为 Diff 算法能够找到全局最优解,而没有意识到它为了性能牺牲了一定的准确性,只在同层进行比较。
- 误以为
key只是为了性能优化: 很多候选人只知道key能提升性能,但不知道它更重要的是确保列表渲染的正确性,特别是在列表项包含内部状态或受控组件时。 - 滥用
index作为key: 这是最常见的错误。尤其是在列表会增删改、排序的场景下,使用index会导致严重的性能和状态问题。面试时必须明确指出其弊端。 - 不了解
key的唯一性要求:key必须在同级兄弟元素中是唯一的,而不是全局唯一。但为了代码的可维护性和避免潜在问题,通常建议使用全局唯一的ID。 - 对
key的稳定性要求理解不足:key不仅仅是唯一,更重要的是在多次渲染中要保持稳定。如果key每次渲染都变化,那么 React 将无法有效复用组件,导致性能下降。 - 认为
key会被传递给子组件:key是 React 内部使用的属性,它不会作为props传递给子组件。如果你需要在子组件中使用唯一标识,需要额外传递一个idprop。
17. React Hooks 如何实现函数组件的状态管理?
题目要点
- Hooks 核心概念: 面试官希望确认候选人对 React Hooks 这一新特性的理解,它解决了类组件的哪些痛点。
useState的原理: 考察候选人对useState这个最基础 Hook 的工作原理、如何声明状态、如何更新状态的掌握。useEffect的原理: 考察候选人对useEffect的理解,它如何处理副作用(数据获取、订阅、手动修改 DOM 等),以及依赖项数组的作用。- 其他 Hooks 的应用: 考察候选人是否了解其他常用 Hooks(如
useContext,useReducer,useRef,useMemo,useCallback)在状态管理和性能优化中的作用。 - 与类组件状态管理的对比: 确认候选人是否能比较 Hooks 与类组件在状态管理上的异同和优势。
参考答案
17.1 原理说明
在 React 16.8 版本之前,函数组件是无状态的,无法拥有自己的 state 或生命周期方法,只能作为“展示性组件”存在。组件的状态管理主要通过类组件的 this.state 和 this.setState 以及生命周期方法来实现。然而,类组件存在一些痛点:
- 逻辑复用困难: 状态逻辑难以在组件之间复用(高阶组件 HOC 和 Render Props 模式引入了额外的嵌套)。
- 复杂组件难以维护: 生命周期方法中可能包含不相关的逻辑,导致代码难以阅读和维护。
this的困扰: 类组件中的this指向问题常常令人困惑。
为了解决这些问题,React 团队引入了 Hooks。Hooks 是一组特殊的函数,它们允许你在不编写 Class 的情况下使用 React 的 state 和其他特性,将状态逻辑从组件中抽离出来,实现更细粒度的控制和复用。
React Hooks 实现函数组件状态管理的核心思想是:通过在函数组件内部“钩入”React 的状态和生命周期等特性,让函数组件也能够拥有和管理自己的状态。
核心 Hooks 及其原理:
useState:- 原理:
useState是一个 Hook,用于在函数组件中添加 Reactstate。它接收一个初始状态值作为参数,并返回一个数组,数组的第一个元素是当前的状态值,第二个元素是一个更新状态的函数(类似于类组件的this.setState)。 - 当调用状态更新函数时,React 会重新渲染组件。
useState内部通过一种特殊的机制(通常是基于 React Fiber 架构的链表结构)来追踪每个 Hook 的状态,保证在每次渲染时能够获取到正确的状态值。
- 原理:
useEffect:- 原理:
useEffect是一个 Hook,用于在函数组件中处理副作用 (side effects),如数据获取、订阅事件、手动修改 DOM、定时器等。 - 它接收一个函数作为第一个参数(Effect 函数),该函数在每次组件渲染后执行。
- 它可以选择性地接收一个依赖项数组作为第二个参数。如果依赖项数组存在且所有依赖项没有发生变化,Effect 函数将不会重新执行,从而避免不必要的副作用。
- Effect 函数可以返回一个清理函数(Cleanup Function),该函数会在组件卸载或 Effect 重新执行前运行,用于清理副作用(如取消订阅、清除定时器)。
- 原理:
useContext:- 原理:
useContextHook 用于在函数组件中订阅 React Context 的值。它接收一个 Context 对象作为参数,并返回该 Context 当前的值。 - 当 Context 值发生变化时,组件会重新渲染。它提供了一种在组件树中传递数据而无需显式通过 props 逐层传递的方法。
- 原理:
useReducer:- 原理:
useReducer是useState的替代方案,适用于更复杂的组件状态逻辑,特别是当状态逻辑涉及多个子值或下一个状态依赖于前一个状态时。它接收一个 reducer 函数和初始状态作为参数,返回当前状态和一个dispatch函数。 - 它与 Redux 中的 reducer 概念类似,通过
dispatch一个 action 来触发状态更新。
- 原理:
useRef:- 原理:
useRefHook 用于在函数组件中创建可变的ref对象。它返回一个ref对象,其.current属性可以在组件的整个生命周期内保持不变,并且可以在多次渲染之间共享数据。 - 主要用于获取 DOM 元素的引用、存储任何可变值(如定时器 ID),而不会导致组件重新渲染。
- 原理:
17.2 核心用法 + 示例代码
useState 示例:
import React, { useState } from 'react';
function Counter() {
// 声明一个名为 'count' 的 state 变量,初始值为 0
const [count, setCount] = useState(0);
// 声明一个名为 'name' 的 state 变量,初始值为 'Alice'
const [name, setName] = useState('Alice');
const handleIncrement = () => {
// 使用 setCount 更新 count,会导致组件重新渲染
setCount(prevCount => prevCount + 1); // 推荐使用函数式更新,确保获取到最新状态
};
const handleNameChange = (e) => {
setName(e.target.value);
};
return (
<div>
<h1>Count: {count}</h1>
<button onClick={handleIncrement}>Increment</button>
<hr />
<input type="text" value={name} onChange={handleNameChange} />
<p>Hello, {name}!</p>
</div>
);
}
useEffect 示例:
import React, { useState, useEffect } from 'react';
function DataFetcher({ userId }) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
// 副作用函数:数据获取
setLoading(true);
setError(null);
fetch(`https://api.example.com/users/${userId}`)
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(result => {
setData(result);
})
.catch(err => {
setError(err);
})
.finally(() => {
setLoading(false);
});
// 清理函数:如果组件卸载或 userId 改变,取消之前的操作(如果支持)
return () => {
// 例如:取消进行中的 fetch 请求(如果 fetch API 提供了取消功能)
// 或者清除定时器等
};
}, [userId]); // 依赖项数组:只有当 userId 改变时,Effect 才会重新执行
if (loading) return <p>Loading data...</p>;
if (error) return <p>Error: {error.message}</p>;
if (!data) return <p>No data</p>;
return (
<div>
<h2>User Data:</h2>
<p>Name: {data.name}</p>
<p>Email: {data.email}</p>
</div>
);
}
项目中的应用场景:
- 组件局部状态: 使用
useState管理组件内部的 UI 状态,如表单输入、按钮点击状态、模态框的打开/关闭等。 - 副作用处理:
useEffect广泛用于数据获取、DOM 操作、订阅/取消订阅事件、定时器设置/清除、动画、与第三方库集成等所有与外部系统交互的逻辑。 - 全局状态管理: 结合
useContext和useReducer可以实现轻量级的全局状态管理,避免 Redux 等大型库的引入。 - 性能优化:
useMemo和useCallback用于优化昂贵的计算和防止不必要的组件重新渲染,提升应用性能。 - 代码逻辑复用: 通过自定义 Hooks,可以将可复用的状态逻辑(如
useFormInput,useLocalStorage)从组件中抽离,并在多个组件中共享。
解决了什么问题,相比其他方案有什么优势:
- 逻辑复用更简单: 通过自定义 Hooks,可以轻松地在不同组件之间共享状态逻辑,避免了高阶组件和 Render Props 带来的嵌套问题。
- 告别
this困扰: 函数组件中没有this,使得代码更简洁,减少了this绑定带来的错误。 - 更清晰的代码组织:
useEffect允许你根据逻辑关注点将相关的副作用代码组织在一起,而不是分散在不同的生命周期方法中。 - 拥抱函数式编程: Hooks 鼓励使用函数式编程范式,使得组件更像纯函数,易于测试和理解。
- 减少组件层级: 避免了 HOC 和 Render Props 模式带来的组件层级嵌套过深问题。
17.3 常见误区或面试陷阱
- 不理解 Hooks 的规则: 无法说出 Hooks 必须在函数组件的顶层调用,不能在循环、条件语句或嵌套函数中调用的原因(这与 React 如何追踪 Hook 的执行顺序有关)。
- 不正确使用
useState的更新函数:- 直接修改状态对象(如
setObject(obj.property = 'newVal'))而不是返回新对象。useState的更新函数是异步的,并且会进行浅合并(对于对象),或者完全替换(对于原始类型)。 - 在需要基于前一个状态更新时,不使用函数式更新 (
setCount(prevCount => prevCount + 1)),导致闭包陷阱(过期值问题)。
- 直接修改状态对象(如
- 对
useEffect依赖项的误解:- 忘记添加依赖项,导致 Effect 只在组件挂载时执行一次,后续状态变化不触发。
- 添加了错误的依赖项(如函数、对象字面量),导致 Effect 无限循环。
- 不理解空数组
[]表示只在挂载和卸载时执行,没有依赖项数组表示每次渲染都执行。
- 不编写
useEffect的清理函数: 对于需要清理的副作用(如定时器、事件监听、订阅),忘记返回一个清理函数,导致内存泄漏或不必要的行为。 - 混淆
useRef和useState: 认为useRef也可以触发组件重新渲染。useRef用于存储可变值且不触发重新渲染,而useState触发重新渲染。 - 将 Hooks 视为类组件的简单替代: 虽然 Hooks 解决了类组件的一些痛点,但它们有自己的一套规则和心智模型,不能简单地进行一对一的映射。
- 过度使用
useMemo/useCallback: 盲目地对所有函数和值进行 memoization,可能带来额外的开销,反而导致性能下降。只有在性能瓶颈出现时才考虑使用。
18. useState 的闭包机制如何导致过期值问题?
题目要点
- 闭包概念: 面试官希望确认候选人对 JavaScript 闭包核心概念(函数可以记住并访问其定义时的词法环境)的深刻理解。
- React 函数组件的渲染机制: 考察候选人是否理解函数组件每次渲染都是独立调用,其内部变量在每次调用时都会被重新创建。
useState更新的异步性: 确认候选人是否知道useState的状态更新是异步的,并且是批处理的。- 过期值问题: 能够清晰地阐述
useState的闭包机制如何导致在某些情况下(如异步操作或多次连续更新)引用到旧的状态值。 - 解决方案: 考察候选人是否能提出并解释解决过期值问题的常见方案(函数式更新、
useRef、useEffect依赖)。
参考答案
18.1 原理说明
useState 的闭包机制导致“过期值”(stale closures)问题,是 React Hooks 初学者常遇到的一个痛点。理解这个问题需要深入理解以下几个概念:
函数组件的每次渲染都是独立调用:
- 与类组件不同,React 函数组件在每次重新渲染时,整个函数都会被再次执行。
- 每次执行时,函数内部的变量(包括
props、state变量以及函数内部声明的局部变量)都会被重新声明和初始化,形成一个全新的“快照”(snapshot)作用域。 - 这意味着,当你在某个渲染周期内定义一个函数(例如事件处理函数或
useEffect的回调函数),这个函数会“捕获”当前渲染周期中的props和state值。
useState的更新是异步且批处理的:- 调用
setSomething(newValue)并不会立即更新state变量的值,而是将更新操作放入一个队列。React 会在稍后的某个时机批量处理这些更新,并触发一次重新渲染。
- 调用
过期值问题(Stale Closures):
过期值问题发生在当一个函数(通常是事件处理函数、useEffect 的回调函数或任何异步回调)在某个渲染周期中被创建,并捕获了该渲染周期中的 state 值。如果这个函数在后续的渲染周期中被调用,并且在此期间 state 已经发生了变化,那么这个函数内部引用的 state 变量仍然是它被创建时捕获的那个旧值,而不是最新的值。
这是因为:
- 当组件第一次渲染时,
useState返回的state变量count是 0,setCount函数也被创建,并“记住”了当前渲染作用域中的count值为 0。 - 当用户点击按钮,调用
setCount(count + 1)时,count此时仍然是这个函数创建时捕获的 0。所以count + 1实际上是0 + 1。 - 如果在此之后,用户在
setCount还没来得及更新真实状态并重新渲染之前,又快速点击了多次按钮,那么每次点击时调用的setCount函数,其内部引用的count依然是最初的那个 0。
举例说明:
假设 count 初始值为 0。
- 第一次渲染:
count = 0。事件处理函数handleClick被定义,其内部引用count为 0。 - 用户点击按钮:
handleClick执行setCount(count + 1),此时count是 0,所以相当于setCount(1)。 - 用户快速再次点击按钮(在第一次更新完成前): 此时执行的
handleClick仍然是第一次渲染时定义的那个函数,它“捕获”的count值仍是 0。所以它再次执行setCount(count + 1),相当于setCount(1)。 - 最终结果: 尽管点击了两次,由于每次都基于旧的
count值进行计算,最终count可能只增加到了 1。
18.2 核心用法 + 示例代码
过期值问题最常发生在以下两种情况:
- 依赖于前一个状态的连续更新:
- 异步操作中引用旧状态:
导致过期值问题的示例:
import React, { useState } from 'react';
function CounterProblem() {
const [count, setCount] = useState(0);
// 这是一个会产生过期值问题的函数
const handleClick = () => {
// 第一次点击:count = 0,setCount(0 + 1) -> 1
// 假设在 setCount(1) 还没生效前,又快速点击第二次
// 第二次点击:这里的 count 仍然是 0 (因为 handleClick 捕获的是第一次渲染时的 count)
// 所以再次 setCount(0 + 1) -> 1
// 结果是:两次点击,count 从 0 变成 1,而不是 2
setCount(count + 1);
};
// 另一个示例:异步操作中的过期值
const handleAsyncClick = () => {
setTimeout(() => {
// 假设点击后 1 秒才执行
// 如果在这 1 秒内 count 已经通过其他方式改变了,这里的 count 仍然是旧值
console.log('Async update. Current captured count:', count); // 会是旧值
setCount(count + 1);
}, 1000);
};
return (
<div>
<p>Count: {count}</p>
<button onClick={handleClick}>Increment (Problem)</button>
<button onClick={handleAsyncClick}>Increment Async (Problem)</button>
</div>
);
}
解决过期值问题的方案:
使用
useState的函数式更新: 当新的state依赖于前一个state时,将一个函数传递给set函数。这个函数会接收前一个state作为参数,并返回新的state。React 会保证这个prevCount总是最新的。import React, { useState } from 'react'; function CounterSolution() { const [count, setCount] = useState(0); const handleClick = () => { // 使用函数式更新:prevCount 总是最新的状态 setCount(prevCount => prevCount + 1); }; const handleAsyncClick = () => { setTimeout(() => { // 在异步回调中,使用函数式更新依然能获取到最新值 setCount(prevCount => { console.log('Async update. Current prevCount:', prevCount); // 总是最新值 return prevCount + 1; }); }, 1000); }; return ( <div> <p>Count: {count}</p> <button>Increment (Solution)</button> <button>Increment Async (Solution)</button> </div> ); }使用
useRef存储可变值: 如果需要在不引起组件重新渲染的情况下引用某个值,并且这个值在多次渲染之间需要保持最新,可以使用useRef。useRef返回一个可变的ref对象,其.current属性可以在组件的整个生命周期中被修改,且不会触发重新渲染。import React, { useState, useRef } from 'react'; function CounterWithRef() { const [count, setCount] = useState(0); const latestCountRef = useRef(count); // 创建一个 ref 存储最新的 count // 在每次渲染后更新 ref // useEffect(() => { // latestCountRef.current = count; // }); // 没有依赖项,每次渲染都执行 // 更简洁的方式:直接在渲染函数体内更新 ref (推荐) latestCountRef.current = count; const handleAsyncClick = () => { setTimeout(() => { // 异步回调中通过 ref 获取最新的 count 值 console.log('Async update. Current count from ref:', latestCountRef.current); setCount(latestCountRef.current + 1); }, 1000); }; return ( <div> <p>Count: {count}</p> <button>Increment Async (useRef Solution)</button> </div> ); }注意: 尽管
useRef可以解决过期值问题,但它不应该被滥用。当状态的改变需要触发 UI 重新渲染时,应优先使用useState。useRef更适合存储那些不需要触发渲染但需要跨渲染周期保持最新的值(如定时器 ID、DOM 元素引用等)。调整
useEffect的依赖项: 如果过期值问题发生在useEffect中,并且该 Effect 确实需要最新的状态,那么确保将相关的状态变量添加到useEffect的依赖项数组中。这样,当这些状态变量改变时,Effect 会重新执行,从而捕获到最新的值。import React, { useState, useEffect } from 'react'; function EffectExample() { const [count, setCount] = useState(0); useEffect(() => { // 这个 effect 会在 count 变化时重新执行,因此内部的 count 总是最新的 console.log('Effect current count:', count); const id = setInterval(() => { // 这里也会存在过期值问题,如果直接使用 count + 1 // 如果 interval 每秒执行,但 count 只是在 effect 挂载时捕获的 0 // 应该使用函数式更新 // setCount(count + 1); // PROBLEM: count 是旧值 setCount(prevCount => prevCount + 1); // SOLUTION }, 1000); return () => clearInterval(id); }, [count]); // 将 count 加入依赖项,当 count 改变时,effect 会重新运行,创建新的 setInterval return ( <div> <p>Count: {count}</p> </div> ); }
项目中的应用场景:
- 计数器或累加器: 任何需要基于当前值进行递增/递减操作的场景,都应该使用函数式更新。
- 复杂的异步请求: 在
useEffect或事件处理函数中发起异步请求,需要访问最新的状态来决定请求参数或处理响应。 - 连续的表单输入处理: 确保在输入值不断变化时,事件处理函数能够获取到最新的输入值。
18.3 常见误区或面试陷阱
- 不理解函数组件的渲染机制: 许多人没有意识到函数组件每次渲染都是一次全新的调用,导致对闭包行为的误解。
- 将
setState视为同步操作: 认为调用setCount(count + 1)后count会立即更新,这是错误的。setState是异步且批处理的。 - 混淆
count变量和 React 内部的state: 在一个渲染周期中,const [count, setCount] = useState(0)声明的count变量在当前作用域内是固定不变的,它只代表了当前渲染时的状态快照,不会在当前渲染周期内实时更新。 - 滥用
useRef解决所有过期值问题: 虽然useRef可以解决某些场景下的过期值问题,但对于需要触发 UI 重新渲染的状态管理,useState的函数式更新是更恰当且推荐的方式。 - 忽略
useEffect依赖项的作用: 忘记添加或错误添加useEffect的依赖项,导致 Effect 内部引用的状态值过期或 Effect 频繁不必要地重新执行。 - 无法解释闭包的本质: 面试官希望听到你对闭包原理的深入理解,以及它与函数组件渲染机制结合导致过期值的具体过程。
19. 为什么 Hooks 必须在函数顶层调用?React 如何追踪 Hook 的执行顺序?
题目要点
- Hooks 规则: 面试官希望确认候选人对 React Hooks 使用规则(尤其是在顶层调用)的理解。
- React 内部机制: 考察候选人对 React 如何在底层管理和追踪 Hooks 状态的理解。
- Hook 的执行顺序: 确认候选人是否清楚 React 是通过严格的 Hook 执行顺序来关联状态和 Effect 的。
- 打破规则的后果: 考察候选人是否了解违反 Hooks 规则可能导致的问题(如状态错乱、行为异常)。
参考答案
19.1 原理说明
React Hooks 的两条核心规则是:
- 只在 React 函数组件或自定义 Hook 中调用 Hook。
- 只在最顶层调用 Hook,不要在循环、条件语句或嵌套函数中调用 Hook。
本题主要围绕第二条规则展开。为什么 Hooks 必须在函数顶层调用? 这个问题与 React 如何追踪 Hook 的执行顺序? 紧密相关。
核心原理:React 内部的 Hook 链表和按顺序追踪。
React 内部并没有像类组件那样为每个 state 或 effect 创建一个固定的“槽位”或者属性。相反,它依赖于一种非常巧妙且高效的机制来追踪和管理函数组件中多个 Hook 的状态:通过 Hook 的调用顺序来识别它们。
可以把 React 内部维护的 Hooks 状态想象成一个链表(或数组)。在组件的每次渲染过程中,React 会:
- 初始化/重置 Hook 指针: 在函数组件开始渲染时,React 会将一个内部指针(或索引)重置到该组件的 Hooks 链表的开头。
- 顺序执行 Hook: 每当组件调用一个 Hook(如
useState、useEffect等),React 都会做以下事情:- 读取或更新当前指针指向的 Hook 节点的状态。
- 将内部指针移动到下一个 Hook 节点。
- 严格的顺序匹配: 当组件第一次渲染时,React 会按照 Hook 调用的顺序,将它们的状态存储在内部的 Hook 链表中。在后续的重新渲染时,React 会期望 Hook 仍然以相同的顺序被调用。这样,它就可以通过指针的移动,准确地找到每个 Hook 对应的上一次保存的状态。
为什么必须在函数顶层调用?
正是因为 React 依赖于 Hook 的稳定调用顺序来匹配状态,所以 Hooks 必须在函数顶层调用:
- 保证顺序的稳定性: 如果将 Hook 放在循环、条件语句或嵌套函数中,那么在不同的渲染周期中,这些 Hook 的调用顺序可能会发生变化,或者某些 Hook 可能根本不会被调用。
- 导致状态错乱: 一旦 Hook 的调用顺序发生变化,React 的内部指针就会指向错误的 Hook 节点。例如,你第一次渲染时调用了
useState,第二次渲染时由于条件判断跳过了useState,而直接调用了useEffect。此时,React 就会尝试将useEffect的状态与上一次useState的状态进行匹配,导致状态错乱、行为异常,甚至直接报错。
19.2 核心用法 + 示例代码
错误示范(违反规则):
import React, { useState, useEffect } from 'react';
function BadComponent({ isLoggedIn }) {
// 错误:Hook 在条件语句中调用
if (isLoggedIn) {
const [user, setUser] = useState(null); // Hook 在条件内部
useEffect(() => {
// ... fetch user data
}, []);
}
// 错误:Hook 在循环中调用
const [dataList, setDataList] = useState([]);
for (let i = 0; i < 5; i++) {
// const [itemCount, setItemCount] = useState(0); // 错误:Hook 在循环内部
}
// 错误:Hook 在嵌套函数中调用
function doSomething() {
// const [tempValue, setTempValue] = useState(0); // 错误:Hook 在嵌套函数内部
}
return (
<div>
{/* ... */}
</div>
);
}
为什么会错?
假设 BadComponent 第一次渲染时 isLoggedIn 为 true,useState 和 useEffect 都会被调用。React 内部记录了这两个 Hook 的状态。
第二次渲染时,如果 isLoggedIn 变为 false,那么 useState 和 useEffect 将不会被调用。此时,React 会期望按照第一次的顺序读取 Hook 状态,但由于这些 Hook 没有被调用,React 就会发生混淆,导致内部状态管理混乱,抛出错误。
正确示范:
import React, { useState, useEffect } from 'react';
function GoodComponent({ isLoggedIn }) {
// 正确:所有 Hook 都在顶层无条件调用
const [user, setUser] = useState(null);
const [dataList, setDataList] = useState([]);
// 可以根据条件在 Hook 内部执行逻辑,但 Hook 本身必须无条件调用
useEffect(() => {
if (isLoggedIn) {
// 只有当 isLoggedIn 为 true 时才执行数据获取
// ... fetch user data
// setUser(fetchedUser);
}
}, [isLoggedIn]); // 依赖 isLoggedIn
// 另一个 useEffect,独立于 isLoggedIn
useEffect(() => {
// ... some other effect
}, []);
return (
<div>
{isLoggedIn ? <p>Welcome, {user ? user.name : 'Guest'}!</p> : <p>Please log in.</p>}
{/* ... */}
</div>
);
}
项目中的应用场景:
- 所有使用 React Hooks 的函数组件: 任何需要管理状态、副作用或使用其他 React 特性的函数组件,都必须遵守这两条 Hook 规则。
- 自定义 Hooks: 自定义 Hooks 本质上也是一个函数,其内部也可以调用其他 Hooks,并且也必须遵守同样的规则。
解决了什么问题,相比其他方案有什么优势:
- 简化了 Hook 的内部实现: 通过强制 Hooks 的调用顺序,React 的核心代码可以变得更简单、更高效,因为它不需要复杂的机制来“猜测”哪个 Hook 对应哪个状态。
- 高性能: 基于数组/链表的按序查找比基于哈希表查找性能更高。
- 确保状态一致性: 严格的顺序保证了在多次渲染中,每个 Hook 都能准确地找到并管理其对应的状态。
- 清晰的编程模型: 尽管规则看起来有些限制,但它提供了一个简单、可预测的编程模型,有助于开发者理解和编写可靠的组件。
19.3 常见误区或面试陷阱
- 不清楚 Hook 规则的原因: 许多候选人知道 Hook 规则,但无法解释其背后的原理(即 React 依赖调用顺序追踪状态)。这是面试官考察深度的关键点。
- 认为可以在条件语句中调用 Hook (但只在 Hook 内部使用条件): 容易混淆“Hook 本身不能在条件语句中”和“在 Hook 内部可以使用条件语句”。Hook 必须无条件地被调用,但 Hook 回调函数内部的逻辑可以有条件。
- 不了解违反规则的后果: 仅仅说“会报错”,而没有进一步解释为什么会报错,例如“导致内部状态指针错乱”、“Hook 无法正确地读取和更新状态”。
- 将 Hook 的执行顺序与生命周期混淆: 虽然
useEffect和useLayoutEffect与生命周期相关,但 Hook 的执行顺序指的是在组件函数内部调用 Hook 的顺序,而不是 Hook 自身的执行时机。 - 自定义 Hook 中的规则: 忘记强调自定义 Hook 也必须遵守同样的规则,以及自定义 Hook 必须以
use开头。
20. 从调用 setState 到页面渲染的完整流程是什么?
题目要点
- React 更新机制: 面试官希望确认候选人对 React 内部组件更新和渲染流程的整体理解。
- setState 的异步性与批处理: 考察候选人是否理解
setState(或useState的更新函数)不是立即同步更新状态和 DOM 的。 - Fiber 架构: 确认候选人对 React 16+ 引入的 Fiber 架构(协调阶段和提交阶段)的基本认识。
- Diff 算法和虚拟 DOM: 考察 Diff 算法在找出差异和生成更新补丁中的作用。
- 浏览器渲染流程: 确认候选人是否了解最终浏览器是如何将 React 的更新应用到页面上的(重排和重绘)。
参考答案
20.1 原理说明
在 React 中,无论是类组件的 this.setState 还是函数组件的 useState 返回的更新函数,它们触发的 UI 更新过程都远非简单的同步操作。从调用 setState 到最终页面渲染,涉及到 React 内部复杂而精密的协调(Reconciliation)和提交(Commit)机制,尤其是在 React 16+ 引入 Fiber 架构之后,这个过程变得更加高效和可中断。
这个完整流程可以概括为以下几个主要阶段:
触发更新 (Trigger Update):
- 原因: 用户交互(点击、输入)、数据获取完成、定时器触发、父组件重新渲染导致子组件
props变化,或者手动调用forceUpdate等。 - 动作: 调用
this.setState()或setXxx()函数。
- 原因: 用户交互(点击、输入)、数据获取完成、定时器触发、父组件重新渲染导致子组件
调度更新 (Schedule Update):
- 异步与批处理: React 不会立即执行
setState的更新。它通常是异步的,并且会进行批处理(Batching)。这意味着在同一个事件循环周期内,即使多次调用setState,React 也会将这些更新合并为一次,只进行一次重新渲染,从而提高性能。 - 优先级: 在 Fiber 架构中,更新还被赋予了不同的优先级,React 会根据优先级和当前浏览器帧的空闲时间来调度更新。
- 异步与批处理: React 不会立即执行
协调阶段 (Reconciliation Phase / Render Phase):
- 这个阶段的主要任务是构建新的 Fiber 树并找出需要更新的最小差异。这个阶段是可中断的。
- 创建新的 Fiber 树: React 从根节点开始,遍历当前组件树,为每个组件生成一个新的 Fiber 节点。
- Diff 算法(对比新旧 Fiber 树):
- React 会将新的 Fiber 树与旧的 Fiber 树进行对比。
- 它会执行著名的 Diff 算法(或协调算法),找出两棵树之间的差异。
- Diff 算法会尝试复用尽可能多的现有 Fiber 节点和 DOM 元素,只标记需要进行修改(插入、更新、删除、移动)的节点。
- 在这个过程中,
key的作用至关重要,它帮助 React 识别列表元素的移动和身份,避免不必要的 DOM 操作。
- 生成副作用列表 (Effect List): Diff 算法的结果不是直接操作 DOM,而是一个包含了所有需要对真实 DOM 进行操作的“副作用列表”(或称“更新队列”,如插入、删除、更新属性、更新文本内容等)。
提交阶段 (Commit Phase):
- 这个阶段的主要任务是将协调阶段计算出的差异一次性地应用到真实 DOM 上。这个阶段是不可中断的。
- 执行 DOM 操作: React 遍历副作用列表,并高效地执行所有必要的 DOM 操作(插入、更新、删除等),将最新的 UI 状态反映到页面上。
- 触发生命周期/Hook 回调: 在这个阶段,React 还会执行与 DOM 操作相关的生命周期方法(如类组件的
componentDidMount,componentDidUpdate,componentWillUnmount)以及 Hooks 的 Effect 函数(useEffect的回调,useLayoutEffect的回调)。 - 浏览器重排和重绘: 当 React 将修改应用到真实 DOM 后,浏览器会根据这些变化进行:
- 重排 (Reflow/Layout): 计算元素在文档中的位置和大小。
- 重绘 (Repaint/Paint): 绘制元素的外观。
- 合成 (Compositing): 将所有层叠加在一起,显示在屏幕上。
总结流程图:
graph TD
A[调用 setState / setXxx] --> B[React 调度更新 (异步/批处理)]
B --> C[协调阶段 (Render Phase) - 可中断]
C --> C1[构建新的 Fiber 树]
C1 --> C2[执行 Diff 算法]
C2 --> C3[生成副作用列表 (更新队列)]
C3 --> D[提交阶段 (Commit Phase) - 不可中断]
D --> D1[执行所有 DOM 操作]
D1 --> D2[触发生命周期/Effect 回调]
D2 --> E[浏览器进行重排、重绘、合成]
E --> F[页面更新完成]
20.2 核心用法 + 示例代码
这个流程是 React 内部的运行机制,开发者通常无需直接干预,但理解它对于性能优化和问题排查至关重要。
示例代码(触发更新):
import React, { useState, useEffect, PureComponent } from 'react';
// 函数组件
function MyFunctionComponent() {
const [count, setCount] = useState(0);
const handleClick = () => {
// 触发更新
setCount(prevCount => prevCount + 1);
};
useEffect(() => {
// 这个 Effect 会在提交阶段,DOM 更新后执行
console.log('Function component effect after DOM update. Count:', count);
}, [count]);
console.log('Function component rendering. Count:', count); // 在协调阶段执行
return (
<div>
<h1>Function Count: {count}</h1>
<button onClick={handleClick}>Increment Function</button>
</div>
);
}
// 类组件
class MyClassComponent extends PureComponent { // 使用 PureComponent 避免不必要的渲染
constructor(props) {
super(props);
this.state = {
count: 0,
};
}
handleClick = () => {
// 触发更新
this.setState(prevState => ({
count: prevState.count + 1,
}));
};
componentDidUpdate(prevProps, prevState) {
// 这个生命周期方法会在提交阶段,DOM 更新后执行
console.log('Class component componentDidUpdate after DOM update. Count:', this.state.count);
}
render() {
console.log('Class component rendering. Count:', this.state.count); // 在协调阶段执行
return (
<div>
<h1>Class Count: {this.state.count}</h1>
<button onClick={this.handleClick}>Increment Class</button>
</div>
);
}
}
function App() {
return (
<div>
<MyFunctionComponent />
<MyClassComponent />
</div>
);
}
export default App;
项目中的应用场景:
- 性能优化: 理解这个流程可以帮助你识别性能瓶颈。例如:
- 批处理: 避免在事件处理器中进行过多的同步
setState调用。 shouldComponentUpdate/PureComponent/React.memo: 通过跳过不必要的协调阶段,减少 Diff 算法的开销。key的使用: 在列表渲染中正确使用key,优化 Diff 算法的效率。useCallback/useMemo: 避免在协调阶段创建不必要的函数和对象引用,减少子组件的重新渲染。
- 批处理: 避免在事件处理器中进行过多的同步
- 调试与问题排查: 当组件行为不符合预期时(如状态更新不及时、DOM 没有按预期更新),理解这个流程有助于定位问题发生在哪个阶段。
- 高阶概念理解: 它是理解 React 并发模式、Suspense 等高级特性的基础。
解决了什么问题,相比其他方案有什么优势:
- 高效的 DOM 更新: 通过虚拟 DOM 和 Diff 算法,React 能够最小化真实 DOM 操作,避免了昂贵的重排和重绘,大大提升了 UI 更新效率。
- 声明式 UI: 开发者只需声明 UI 应该是什么样子,而无需手动操作 DOM,简化了开发心智负担。
- 可中断的渲染: Fiber 架构使得协调阶段可以被中断和恢复,从而实现时间分片(Time Slicing)和并发模式,使长任务不会阻塞主线程,提升了用户体验(尤其是在复杂应用中)。
- 跨平台能力: 渲染过程与具体平台解耦,虚拟 DOM 可以渲染到不同的宿主环境(浏览器 DOM、原生移动应用、Canvas 等)。
20.3 常见误区或面试陷阱
- 误以为
setState是同步的: 这是最常见的误区。许多人认为调用setState后状态会立即更新,并且 DOM 也会立即反映变化。 - 不了解批处理机制: 无法解释为什么多次
setState往往只会触发一次渲染,以及哪些情况下批处理会失效(例如,setTimeout中的setState默认不批处理,但在 React 18+ 的createRoot下会批处理)。 - 混淆协调阶段和提交阶段: 不清楚哪个阶段是可中断的,哪个阶段是不可中断的,以及每个阶段的主要任务。
- 对 Diff 算法的理解停留在表面: 无法深入阐述 Diff 算法如何通过同层比较、类型检查和
key来优化更新。 - 不了解浏览器渲染流程(重排、重绘): 缺乏对 React 更新最终如何反映到浏览器屏幕上的认识。
- 忽略 Fiber 架构的作用: 没有提及 Fiber 如何实现可中断渲染、优先级调度和并发模式,这将显得对 React 16+ 的更新机制了解不足。
- 认为
setState的callback参数是 DOM 更新完成的保证:setState的第二个回调函数 (() => {}) 确实会在状态更新并触发渲染后执行,但它通常指的是 React 内部的更新完成,不一定意味着浏览器已经完成了重排和重绘,除非使用ReactDOM.flushSync或useLayoutEffect(对于同步操作 DOM 而言)。