JavaScript
一、数据类型与类型检测
1. JavaScript 的数据类型及检测方法
问题:JavaScript 数据类型有哪些?基本数据类型有哪些?值类型与引用类型的区别是什么?JavaScript 数据类型检测方法有哪些?typeof 运算符返回值有哪些?typeof 与 instanceof 的区别及适用场景?
答案:
JavaScript 数据类型(共 8 种):
- 基本类型(值类型):Number、String、Boolean、Undefined、Null、Symbol(ES6)、BigInt(ES2020)
- 引用类型:Object(包含 Array、Function、Date、RegExp、Map、Set、WeakMap、WeakSet 等)
值类型 vs 引用类型对比表:
| 对比项 | 值类型 | 引用类型 |
|---|---|---|
| 存储位置 | 栈内存 | 堆内存,栈中存储引用地址 |
| 复制行为 | 复制值本身 | 复制引用地址 |
| 比较方式 | 比较值是否相等 | 比较引用地址是否相同 |
| 可变性 | 不可变(值本身) | 可变(对象属性可变) |
数据类型检测方法:
-
typeof:返回类型字符串,能识别基本类型(除 null 外)和 function
typeof 42; // "number" typeof 'hello'; // "string" typeof true; // "boolean" typeof undefined; // "undefined" typeof null; // "object"(历史遗留问题) typeof {}; // "object" typeof []; // "object" typeof function() {}; // "function" typeof Symbol(); // "symbol" typeof 10n; // "bigint" -
instanceof:检测对象是否为某个构造函数的实例
[] instanceof Array; // true ({}) instanceof Object; // true function() {} instanceof Function; // true -
Object.prototype.toString.call():最准确的类型检测方法
Object.prototype.toString.call(42); // "[object Number]" Object.prototype.toString.call('hello'); // "[object String]" Object.prototype.toString.call(null); // "[object Null]" Object.prototype.toString.call(undefined); // "[object Undefined]" Object.prototype.toString.call([]); // "[object Array]" Object.prototype.toString.call({}); // "[object Object]" Object.prototype.toString.call(function() {}); // "[object Function]" -
Array.isArray():判断是否为数组
Array.isArray([]); // true Array.isArray({}); // false
typeof vs instanceof 对比表:
| 特性 | typeof | instanceof |
|---|---|---|
| 作用 | 判断基本类型和函数 | 判断对象是否为某个类的实例 |
| 返回值 | 字符串 | 布尔值 |
| 对 null 的判断 | "object"(错误) | false(正确) |
| 对数组的判断 | "object"(错误) | true(正确) |
| 适用场景 | 基本类型检测、函数检测 | 对象类型检测、继承关系检测 |
补充说明:
typeof null === "object"是 JavaScript 设计初期的历史遗留错误instanceof通过原型链查找,跨 iframe 或跨 realm 时会失效- 推荐使用
Object.prototype.toString.call()进行精确类型判断 - 基本类型值不可变,对值的修改会创建新值
- 引用类型变量存储的是堆内存地址,复制时复制的是地址
二、类型转换
2. 类型转换规则与方法
问题:数据类型转换方式有哪些?JavaScript 隐式类型转换规则是什么?显式类型转换的方法有哪些?
答案:
隐式类型转换(自动转换)触发场景:
- 算术运算:
+、-、*、/、% - 比较运算:
==、!=、<、>、<=、>= - 逻辑运算:
!、&&、|| - 条件判断:
if、while、for条件表达式 - 属性访问:
obj[prop]中的 prop 如果不是字符串会被转成字符串
常用显式转换方法:
// 1. 转为数字
Number('123'); // 123
parseInt('123px', 10); // 123(推荐始终指定基数)
parseFloat('3.14px'); // 3.14
+'123'; // 123(一元加操作符)
~~'123'; // 123(双按位非)
// 2. 转为字符串
String(123); // "123"
(123).toString(); // "123"
123 + ''; // "123"
`${123}`; // "123"
// 3. 转为布尔值
Boolean(0); // false
!!'hello'; // true(双非操作符)
特殊值的转换规则:
// 空值转换
Number(''); // 0
Number(' '); // 0
Number(null); // 0
Number(undefined); // NaN
Boolean(null); // false
Boolean(undefined); // false
Boolean(''); // false
Boolean(0); // false
Boolean(NaN); // false
Boolean([]); // true(所有对象都为 true)
Boolean({}); // true
补充说明:
parseInt和parseFloat会从字符串开头解析数字直到遇到非数字字符- 使用
Number()转换空字符串或空白字符串会得到 0,而parseInt会得到 NaN - 数组转数字:空数组为 0,单个元素的数组看元素值,多个元素的数组为 NaN
- 对象转数字会先调用
valueOf()方法,如果没有返回基本类型再调用toString()
三、空值与特殊值
3. null、undefined 与 NaN
问题:null 和 undefined 的区别是什么?NaN 是什么?如何检测?isNaN() 的作用及实现原理?
答案:
null vs undefined 对比表:
| 特性 | null | undefined |
|---|---|---|
| 含义 | 表示"空值",表示一个对象指针为空 | 表示"未定义",变量声明但未赋值 |
| 类型 | object | undefined |
| 转换为数字 | 0 | NaN |
| 使用场景 | 主动赋空值,如对象初始化 | 变量未赋值、函数无返回值、访问对象不存在的属性 |
| 全等比较 | null === null 为 true | undefined === undefined 为 true |
| 历史渊源 | 设计缺陷导致 typeof null === "object" | 变量声明默认值 |
NaN(Not a Number)特性:
- NaN 是 Number 类型的一个特殊值,表示非数字
- NaN 与任何值(包括自身)比较都返回 false:
NaN === NaN为 false - NaN 参与任何数学运算结果都是 NaN
检测 NaN 的方法:
-
isNaN() 全局函数:先尝试转换为数字,再判断是否为 NaN
isNaN(NaN); // true isNaN('123'); // false(字符串"123"可转为数字123) isNaN('hello'); // true(字符串"hello"转数字为NaN) -
Number.isNaN()(ES6):严格判断,只有值确实是 NaN 才返回 true
Number.isNaN(NaN); // true Number.isNaN('hello'); // false(字符串不是NaN) Number.isNaN(123); // false -
利用 NaN 不等于自身的特性:
function isNaN(value) { return value !== value; }
补充说明:
- 推荐使用
Number.isNaN()而不是全局的isNaN(),避免隐式转换带来的误判 null == undefined为 true,但null === undefined为 false- 判断变量是否为 undefined 时,使用
typeof variable === "undefined"可避免变量未声明时报错
四、相等性比较
4. == 与 === 的区别
问题:== 与 === 的区别及使用场景?
答案:
对比表:
| 操作符 | 名称 | 比较方式 | 是否进行类型转换 |
|---|---|---|---|
== | 抽象相等 | 值相等 | 是,进行隐式类型转换 |
=== | 严格相等 | 值和类型都相等 | 否,不进行类型转换 |
== 的类型转换规则:
- 类型相同:直接比较值
- 类型不同:
- null 和 undefined 相等:
null == undefined为 true - 字符串和数字:将字符串转为数字再比较
- 布尔值和其他类型:将布尔值转为数字(true→1,false→0)再比较
- 对象和基本类型:调用对象的
valueOf()或toString()方法转为基本类型后比较
- null 和 undefined 相等:
示例:
'' == 0; // true(空字符串转数字为0)
'0' == 0; // true(字符串"0"转数字为0)
false == 0; // true(false转数字为0)
true == 1; // true(true转数字为1)
null == undefined; // true
[] == 0; // true(空数组转字符串为"",再转数字为0)
[1] == 1; // true(数组[1]转字符串为"1",再转数字为1)
推荐使用场景:
- 始终使用
===:避免隐式转换带来的意外结果 - 唯一使用
==的情况:判断变量是否为 null 或 undefined 时可以使用value == null(等价于value === null || value === undefined)
补充说明:
- 严格相等
===不仅检查值相等,还检查类型相同,更安全可预测 - 对象的比较是比较引用地址,不是比较内容
- 使用
Object.is()(ES6)可以处理NaN === NaN为 false 和-0 === +0为 true 的特殊情况
五、作用域与作用域链
5. 作用域、作用域链与变量提升
问题:JavaScript 作用域类型有哪些?什么是作用域和作用域链?什么是变量提升?
答案:
作用域类型:
- 全局作用域:在函数外部或代码块外部声明的变量
- 函数作用域:在函数内部声明的变量(ES5)
- 块级作用域:在
{}内声明的变量,使用let、const(ES6)
作用域链:
- 函数在定义时就会创建自己的作用域链,包含自身的作用域和所有父级作用域
- 查找变量时,从当前作用域开始,逐级向上查找,直到全局作用域
- 作用域链在函数定义时确定,与调用位置无关(词法作用域)
变量提升(Hoisting):
- 变量声明提升:使用
var声明的变量会被提升到作用域顶部,但赋值不提升 - 函数声明提升:函数声明整体提升到作用域顶部
- let/const 提升:也存在提升,但存在暂时性死区(TDZ),在声明前访问会报错
代码示例:
// 变量提升示例
console.log(a); // undefined(变量声明提升)
var a = 10;
// 相当于
var a;
console.log(a); // undefined
a = 10;
// 函数提升示例
foo(); // "Hello"
function foo() {
console.log('Hello');
}
// let/const 暂时性死区
console.log(b); // ReferenceError: Cannot access 'b' before initialization
let b = 20;
补充说明:
- 使用
let、const可以避免变量提升带来的问题 - 函数表达式不会被提升,只有函数声明会被提升
- 优先使用
let、const替代var,利用块级作用域减少错误
六、函数相关概念
6. 函数声明与表达式、arguments、this 指向
问题:函数声明与函数表达式的区别?arguments 对象是什么?JavaScript 中 this 的指向规则?
答案:
函数声明 vs 函数表达式对比表:
| 特性 | 函数声明 | 函数表达式 |
|---|---|---|
| 语法 | function fn() {} | const fn = function() {} |
| 提升 | 整体提升(函数名和函数体) | 变量声明提升,函数体不提升 |
| 命名 | 必须有函数名 | 可以是匿名函数或有名函数 |
| 使用场景 | 通用函数定义 | 回调函数、IIFE、赋值给变量 |
arguments 对象:
- 函数内部可用的类数组对象,包含调用时传入的所有参数
- 有
length属性,可通过索引访问:arguments[0]、arguments[1] - 不是真正的数组,但有
callee属性指向函数自身 - ES6 中可使用剩余参数
...args替代
this 指向规则(优先级从高到低):
- new 绑定:通过
new调用构造函数,this指向新创建的对象 - 显式绑定:通过
call、apply、bind指定this - 隐式绑定:作为对象方法调用,
this指向调用对象 - 默认绑定:普通函数调用,非严格模式指向
window/global,严格模式为undefined - 箭头函数:没有自己的
this,继承外层作用域的this
代码示例:
// 1. new 绑定
function Person(name) {
this.name = name;
}
const p = new Person('Alice'); // this 指向 p
// 2. 显式绑定
function sayHello() {
console.log(`Hello, ${this.name}`);
}
const obj = { name: 'Bob' };
sayHello.call(obj); // this 指向 obj
// 3. 隐式绑定
const user = {
name: 'Charlie',
greet() {
console.log(`Hi, ${this.name}`);
}
};
user.greet(); // this 指向 user
// 4. 默认绑定
function show() {
console.log(this); // 严格模式:undefined,非严格模式:window
}
show();
// 5. 箭头函数
const outer = {
name: 'David',
inner: () => {
console.log(this.name); // this 指向外层作用域的 this(可能是 window)
}
};
补充说明:
- 箭头函数的
this在定义时确定,不会因调用方式改变 - 使用
bind会创建新函数,this永久绑定,无法再次修改 - 事件处理函数中的
this通常指向触发事件的 DOM 元素
七、函数方法
7. call、apply、bind 的区别
问题:call、apply、bind 的区别及使用场景?
答案:
对比表:
| 方法 | 参数传递 | 返回值 | 执行时机 | 使用场景 |
|---|---|---|---|---|
call | 参数逐个传递 | 函数执行结果 | 立即执行 | 借用方法、改变 this 指向 |
apply | 参数以数组传递 | 函数执行结果 | 立即执行 | 参数数量不确定、传递数组 |
bind | 参数逐个传递 | 返回新函数 | 延迟执行 | 事件回调、预设参数 |
代码示例:
function introduce(age, city) {
console.log(`我叫${this.name},${age}岁,来自${city}`);
}
const person = { name: '张三' };
// 1. call - 立即执行,参数逐个传递
introduce.call(person, 25, '北京');
// 2. apply - 立即执行,参数数组传递
introduce.apply(person, [25, '北京']);
// 3. bind - 返回新函数,延迟执行
const boundFunction = introduce.bind(person, 25);
boundFunction('北京'); // 执行:我叫张三,25岁,来自北京
// bind 预设参数(局部应用)
const introduceFromBeijing = introduce.bind(null, 25, '北京');
introduceFromBeijing.call(person); // 我叫张三,25岁,来自北京
手写实现:
// 手写 call
Function.prototype.myCall = function(context = window, ...args) {
context.fn = this; // this 指向调用 myCall 的函数
const result = context.fn(...args);
delete context.fn;
return result;
};
// 手写 apply
Function.prototype.myApply = function(context = window, args = []) {
context.fn = this;
const result = context.fn(...args);
delete context.fn;
return result;
};
// 手写 bind
Function.prototype.myBind = function(context, ...presetArgs) {
const fn = this;
return function(...args) {
return fn.call(context, ...presetArgs, ...args);
};
};
补充说明:
apply在传递数组参数时比call更方便,如Math.max.apply(null, [1, 2, 3])bind常用于事件处理、定时器等需要保持this指向的场景- 多次调用
bind只有第一次有效
八、变量声明
8. var、let、const 的区别
问题:var、let、const 的区别?
答案:
对比表:
| 特性 | var | let | const |
|---|---|---|---|
| 作用域 | 函数作用域 | 块级作用域 | 块级作用域 |
| 变量提升 | 提升且初始化为 undefined | 提升但存在暂时性死区 | 提升但存在暂时性死区 |
| 重复声明 | 允许 | 不允许 | 不允许 |
| 全局属性 | 成为 window 对象的属性 | 不会成为 window 属性 | 不会成为 window 属性 |
| 初始值 | 可不初始化(undefined) | 可不初始化 | 必须初始化 |
| 重新赋值 | 可以 | 可以 | 不可以(但对象属性可修改) |
代码示例:
// 作用域差异
{
var a = 1;
let b = 2;
const c = 3;
}
console.log(a); // 1(var 没有块级作用域)
console.log(b); // ReferenceError: b is not defined
console.log(c); // ReferenceError: c is not defined
// 暂时性死区
console.log(x); // undefined(var 提升)
var x = 10;
console.log(y); // ReferenceError: Cannot access 'y' before initialization
let y = 20;
// 重复声明
var z = 1;
var z = 2; // 允许
let w = 1;
let w = 2; // SyntaxError: Identifier 'w' has already been declared
// const 必须初始化
const p; // SyntaxError: Missing initializer in const declaration
// const 对象属性可修改
const obj = { name: 'Alice' };
obj.name = 'Bob'; // 允许
obj = { name: 'Charlie' }; // TypeError: Assignment to constant variable
补充说明:
- 优先使用
const,除非需要重新赋值 - 使用
let替代var,利用块级作用域 - 暂时性死区(TDZ)提高了代码可预测性,避免声明前使用变量
九、闭包
9. 闭包的理解与应用
问题:什么是闭包?闭包的用途和缺点?闭包的典型应用场景有哪些?
答案:
闭包定义:
- 函数在定义时会创建作用域链,包含自身的作用域和所有父级作用域
- 当函数在其词法作用域外执行时,依然可以访问其词法作用域中的变量,形成闭包
闭包的优缺点:
优点:
- 封装私有变量:模拟私有方法和私有变量
- 数据持久化:函数执行后变量不会被垃圾回收
- 模块化:创建模块模式,实现信息隐藏
- 函数工厂:创建具有特定配置的函数
缺点:
- 内存泄漏:闭包中的变量不会被垃圾回收,可能导致内存占用过高
- 性能影响:闭包的作用域链较长,变量查找速度较慢
典型应用场景:
-
封装私有变量:
function createCounter() { let count = 0; // 私有变量 return { increment() { count++; }, decrement() { count--; }, getValue() { return count; } }; } const counter = createCounter(); counter.increment(); console.log(counter.getValue()); // 1 console.log(counter.count); // undefined(无法直接访问) -
回调函数和事件处理:
function setupClickHandler(buttonId) { const button = document.getElementById(buttonId); let clickCount = 0; button.addEventListener('click', function() { clickCount++; console.log(`按钮被点击了 ${clickCount} 次`); }); } -
函数工厂:
function createMultiplier(factor) { return function(x) { return x * factor; }; } const double = createMultiplier(2); const triple = createMultiplier(3); console.log(double(5)); // 10 console.log(triple(5)); // 15 -
模块模式:
const myModule = (function() { let privateVar = 0; function privateMethod() { return privateVar; } return { publicMethod() { privateVar++; return privateMethod(); } }; })(); console.log(myModule.publicMethod()); // 1
内存泄漏防范:
// 及时解除引用
function createHandler() {
const largeData = new Array(1000000).fill('data');
return function() {
console.log('handled');
};
}
const handler = createHandler();
// 使用后设置 handler = null 解除引用
补充说明:
- 闭包在现代 JavaScript 开发中无处不在,理解闭包是理解 JavaScript 核心概念的关键
- 合理使用闭包,避免不必要的内存占用
- 使用模块化(ES6 Modules)是更好的封装方式,但闭包仍有其应用场景
十、原型与原型链
10. 原型链、继承与 new 操作符
问题:什么是原型和原型链?JavaScript 继承的实现方式有哪些?prototype 与 __proto__ 的区别?constructor 属性的作用?ES5 和 ES6 继承的区别?instanceof 的原理及手动实现?new 操作符的执行过程?
答案:
原型链基本概念:
- 每个函数都有
prototype属性(原型对象) - 每个对象都有
__proto__属性(原型链指针) - 对象的
__proto__指向其构造函数的prototype - 查找属性时,先查找自身,再沿
__proto__链向上查找
prototype vs __proto__ vs constructor:
function Person(name) {
this.name = name;
}
const p = new Person('Alice');
// 关系图:
// p.__proto__ === Person.prototype
// Person.prototype.constructor === Person
// Person.__proto__ === Function.prototype
// Person.prototype.__proto__ === Object.prototype
// Object.prototype.__proto__ === null
new 操作符的执行过程:
- 创建一个空对象
obj - 设置
obj.__proto__指向构造函数的prototype - 将构造函数的
this绑定到obj - 执行构造函数,为
obj添加属性 - 如果构造函数返回对象,则返回该对象;否则返回
obj
手写 new:
function myNew(constructor, ...args) {
const obj = Object.create(constructor.prototype); // 步骤1-2
const result = constructor.apply(obj, args); // 步骤3-4
return result instanceof Object ? result : obj; // 步骤5
}
继承方式对比:
| 继承方式 | 优点 | 缺点 |
|---|---|---|
| 原型链继承 | 简单 | 1. 引用类型属性被所有实例共享 2. 不能向父类传参 |
| 构造函数继承 | 可向父类传参,引用类型不共享 | 方法都在构造函数中定义,无法复用 |
| 组合继承 | 结合两者优点 | 1. 父类构造函数被调用两次 2. 子类原型中有多余属性 |
| 寄生组合继承 | 最优方案 | 实现稍复杂 |
| ES6 class 继承 | 语法简洁,语义清晰 | 本质仍是原型继承的语法糖 |
寄生组合继承(最佳实践):
function Parent(name) {
this.name = name;
this.colors = ['red', 'blue'];
}
Parent.prototype.sayName = function() {
console.log(this.name);
};
function Child(name, age) {
Parent.call(this, name); // 继承实例属性
this.age = age;
}
// 使用 Object.create 建立原型链,避免调用 Parent 构造函数
Child.prototype = Object.create(Parent.prototype);
Child.prototype.constructor = Child; // 修复 constructor 指向
Child.prototype.sayAge = function() {
console.log(this.age);
};
const child1 = new Child('Tom', 10);
child1.colors.push('green');
console.log(child1.colors); // ['red', 'blue', 'green']
child1.sayName(); // Tom
child1.sayAge(); // 10
ES6 class 继承:
class Parent {
constructor(name) {
this.name = name;
this.colors = ['red', 'blue'];
}
sayName() {
console.log(this.name);
}
}
class Child extends Parent {
constructor(name, age) {
super(name); // 必须调用 super
this.age = age;
}
sayAge() {
console.log(this.age);
}
}
const child = new Child('Alice', 12);
child.sayName(); // Alice
child.sayAge(); // 12
instanceof 原理:
function myInstanceof(obj, constructor) {
let proto = obj.__proto__;
const prototype = constructor.prototype;
while (proto !== null) {
if (proto === prototype) return true;
proto = proto.__proto__;
}
return false;
}
补充说明:
- ES6 class 是语法糖,本质仍然是基于原型的继承
- 推荐使用 ES6 class 语法,代码更清晰易维护
- 理解原型链对于调试和性能优化非常重要
十一、事件机制
11. 事件冒泡、捕获、委托与事件流
问题:什么是事件冒泡和事件捕获?什么是事件委托?如何阻止事件冒泡?如何阻止默认行为?preventDefault 与 stopPropagation 的区别?DOM 事件流的三个阶段?DOM0、DOM2、DOM3 级事件的区别?addEventListener 的参数?哪些事件是不冒泡的?mouseenter 和 mouseover 的区别?如何用原生 JS 给一个按钮绑定多个 onclick 事件?
答案:
DOM 事件流三个阶段:
- 捕获阶段:从 window → document → ... → 目标元素
- 目标阶段:到达目标元素
- 冒泡阶段:从目标元素 → ... → document → window
事件绑定方式对比:
| 方式 | 特点 | 移除方式 | 事件流 |
|---|---|---|---|
DOM0:onclick | 简单,覆盖前一个 | 赋值为 null | 冒泡阶段 |
DOM2:addEventListener | 可添加多个,可控制阶段 | removeEventListener | 可选捕获或冒泡 |
| DOM3:新增事件类型 | 如 DOMContentLoaded | 同上 | 同上 |
代码示例:
// 1. DOM0 级事件
element.onclick = function() { console.log('click 1'); };
element.onclick = function() { console.log('click 2'); }; // 覆盖前一个
// 2. DOM2 级事件
element.addEventListener('click', handler1);
element.addEventListener('click', handler2); // 可添加多个
function handler1() { console.log('handler1'); }
function handler2() { console.log('handler2'); }
// 移除事件
element.removeEventListener('click', handler1);
事件委托:
- 将事件监听器添加到父元素,利用事件冒泡处理子元素事件
- 优点:减少事件监听器数量,提高性能;动态添加的子元素无需重新绑定
// 传统方式:为每个 li 绑定事件
const items = document.querySelectorAll('li');
items.forEach(item => {
item.addEventListener('click', function(e) {
console.log(e.target.textContent);
});
});
// 事件委托:只需一个事件监听器
const list = document.querySelector('ul');
list.addEventListener('click', function(e) {
if (e.target.tagName === 'LI') {
console.log(e.target.textContent);
}
});
// 动态添加元素也能正常工作
const newItem = document.createElement('li');
newItem.textContent = 'New Item';
list.appendChild(newItem); // 无需重新绑定事件
事件方法对比:
event.preventDefault():阻止默认行为(如链接跳转、表单提交)event.stopPropagation():阻止事件继续传播(冒泡或捕获)event.stopImmediatePropagation():阻止事件传播并阻止同一元素的其他监听器执行
不冒泡的事件:
focus、blur、load、unload、mouseenter、mouseleave、resize、scroll
mouseenter vs mouseover:
mouseenter:不冒泡,鼠标进入元素时触发一次mouseover:冒泡,鼠标进入元素或其子元素时触发
绑定多个 onclick 事件:
// 使用 addEventListener
const button = document.getElementById('btn');
button.addEventListener('click', function() {
console.log('第一个事件');
});
button.addEventListener('click', function() {
console.log('第二个事件');
});
// 或使用事件处理器组合
function handler1() { console.log('事件1'); }
function handler2() { console.log('事件2'); }
button.onclick = function() {
handler1();
handler2();
};
补充说明:
- 推荐使用
addEventListener,功能更强大,不覆盖已有事件 - 事件委托是性能优化的重要手段,尤其适合动态内容
- 注意事件处理函数中
this的指向(箭头函数和普通函数不同)
十二、异步编程
12. 异步编程方式与事件循环
问题:JavaScript 异步编程的方式有哪些?什么是回调地狱?Promise 是什么?有哪些状态?Promise.all 的作用?Promise.race 的作用?async/await 的原理?async/await 与 Promise 的关系?Generator 函数?如何手写实现一个 Promise?事件循环(Event Loop)机制?宏任务与微任务有哪些?setTimeout、Promise、async/await 的执行差异?
答案:
异步编程演进:
- 回调函数:
setTimeout、fs.readFile - Promise(ES6):解决回调地狱
- Generator(ES6):可暂停执行的函数
- async/await(ES7):基于 Promise 的语法糖,同步方式写异步代码
事件循环(Event Loop)机制:
- JavaScript 是单线程,通过事件循环实现异步
- 执行栈 → 微任务队列 → 宏任务队列
- 每次从宏任务队列取一个任务执行,然后执行所有微任务
宏任务 vs 微任务:
| 类型 | 示例 | 执行时机 |
|---|---|---|
| 宏任务 | setTimeout、setInterval、setImmediate、I/O、UI 渲染 | 每次事件循环执行一个 |
| 微任务 | Promise.then/catch/finally、process.nextTick、MutationObserver | 每个宏任务执行后清空微任务队列 |
执行顺序示例:
console.log('1'); // 同步
setTimeout(() => {
console.log('2'); // 宏任务
}, 0);
Promise.resolve().then(() => {
console.log('3'); // 微任务
});
console.log('4'); // 同步
// 输出顺序:1 → 4 → 3 → 2
Promise 状态:
- pending:初始状态
- fulfilled:操作成功完成
- rejected:操作失败
Promise 静态方法:
// 1. Promise.all:所有成功或一个失败
Promise.all([p1, p2, p3])
.then(values => console.log(values))
.catch(error => console.error(error));
// 2. Promise.race:第一个完成(成功或失败)
Promise.race([p1, p2, p3])
.then(value => console.log(value))
.catch(error => console.error(error));
// 3. Promise.allSettled:所有完成(无论成功失败)
Promise.allSettled([p1, p2, p3])
.then(results => console.log(results));
// 4. Promise.any:第一个成功
Promise.any([p1, p2, p3])
.then(value => console.log(value))
.catch(errors => console.error(errors));
async/await:
async函数返回 Promiseawait等待 Promise 解决,只能用在async函数中- 本质是 Generator + Promise 的语法糖
async function fetchData() {
try {
const response = await fetch('/api/data');
const data = await response.json();
return data;
} catch (error) {
console.error('Error:', error);
throw error;
}
}
// 等价于
function fetchData() {
return fetch('/api/data')
.then(response => response.json())
.catch(error => {
console.error('Error:', error);
throw error;
});
}
Generator 函数:
function* generator() {
const a = yield 1;
const b = yield a + 2;
return b + 3;
}
const gen = generator();
console.log(gen.next()); // { value: 1, done: false }
console.log(gen.next(5)); // { value: 7, done: false }(a = 5)
console.log(gen.next(10)); // { value: 13, done: true }(b = 10)
手写 Promise(简化版):
class MyPromise {
constructor(executor) {
this.state = 'pending';
this.value = undefined;
this.reason = undefined;
this.onFulfilledCallbacks = [];
this.onRejectedCallbacks = [];
const resolve = (value) => {
if (this.state === 'pending') {
this.state = 'fulfilled';
this.value = value;
this.onFulfilledCallbacks.forEach(fn => fn());
}
};
const reject = (reason) => {
if (this.state === 'pending') {
this.state = 'rejected';
this.reason = reason;
this.onRejectedCallbacks.forEach(fn => fn());
}
};
try {
executor(resolve, reject);
} catch (error) {
reject(error);
}
}
then(onFulfilled, onRejected) {
return new MyPromise((resolve, reject) => {
const handleFulfilled = () => {
try {
const result = onFulfilled ? onFulfilled(this.value) : this.value;
resolve(result);
} catch (error) {
reject(error);
}
};
const handleRejected = () => {
try {
const result = onRejected ? onRejected(this.reason) : this.reason;
reject(result);
} catch (error) {
reject(error);
}
};
if (this.state === 'fulfilled') {
setTimeout(handleFulfilled, 0);
} else if (this.state === 'rejected') {
setTimeout(handleRejected, 0);
} else {
this.onFulfilledCallbacks.push(() => setTimeout(handleFulfilled, 0));
this.onRejectedCallbacks.push(() => setTimeout(handleRejected, 0));
}
});
}
catch(onRejected) {
return this.then(null, onRejected);
}
static resolve(value) {
return new MyPromise(resolve => resolve(value));
}
static reject(reason) {
return new MyPromise((_, reject) => reject(reason));
}
}
补充说明:
- 理解事件循环是 JavaScript 异步编程的核心
- async/await 让异步代码更易读,但本质上仍是 Promise
- 注意 Promise 的错误处理,避免未捕获的异常
十三、手写代码题
13. 手写常用函数实现
手写规则:提供完整可运行的代码,包含中文注释,涵盖边界情况和错误处理。
1. 手写 Promise(见上文)
2. 手写 Promise.all
Promise.myAll = function(promises) {
return new Promise((resolve, reject) => {
if (!Array.isArray(promises)) {
return reject(new TypeError('Arguments must be an array'));
}
const results = new Array(promises.length);
let completedCount = 0;
if (promises.length === 0) {
return resolve(results);
}
promises.forEach((promise, index) => {
Promise.resolve(promise).then(
value => {
results[index] = value;
completedCount++;
if (completedCount === promises.length) {
resolve(results);
}
},
reason => {
reject(reason);
}
);
});
});
};
// 测试
const p1 = Promise.resolve(1);
const p2 = Promise.resolve(2);
const p3 = Promise.resolve(3);
Promise.myAll([p1, p2, p3])
.then(values => console.log(values)) // [1, 2, 3]
.catch(error => console.error(error));
3. 手写 Promise.race
Promise.myRace = function(promises) {
return new Promise((resolve, reject) => {
if (!Array.isArray(promises)) {
return reject(new TypeError('Arguments must be an array'));
}
if (promises.length === 0) {
return; // Promise 永远处于 pending 状态
}
promises.forEach(promise => {
Promise.resolve(promise).then(resolve, reject);
});
});
};
4. 手写深拷贝
function deepClone(obj, hash = new WeakMap()) {
// 基础类型直接返回
if (obj === null || typeof obj !== 'object') {
return obj;
}
// 处理 Date
if (obj instanceof Date) {
return new Date(obj.getTime());
}
// 处理 RegExp
if (obj instanceof RegExp) {
return new RegExp(obj);
}
// 处理 Array
if (Array.isArray(obj)) {
return obj.map(item => deepClone(item, hash));
}
// 防止循环引用
if (hash.has(obj)) {
return hash.get(obj);
}
// 处理普通对象
const cloneObj = Object.create(Object.getPrototypeOf(obj));
hash.set(obj, cloneObj);
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
cloneObj[key] = deepClone(obj[key], hash);
}
}
return cloneObj;
}
// 测试
const obj = {
name: 'Alice',
age: 25,
hobbies: ['reading', 'coding'],
address: {
city: 'Beijing',
street: 'Main St'
},
birthDate: new Date('1998-01-01'),
regex: /test/g,
sayHello: function() {
console.log('Hello');
}
};
// 循环引用
obj.self = obj;
const cloned = deepClone(obj);
console.log(cloned !== obj); // true
console.log(cloned.address !== obj.address); // true
console.log(cloned.hobbies !== obj.hobbies); // true
console.log(cloned.sayHello === obj.sayHello); // true(函数共享)
// 测试日期和正则
console.log(cloned.birthDate.getTime() === obj.birthDate.getTime()); // true
console.log(cloned.regex.test('test')); // true
5. 手写防抖(debounce)
function debounce(fn, delay, immediate = false) {
let timer = null;
let isInvoked = false;
return function(...args) {
const context = this;
// 清除之前的定时器
if (timer) {
clearTimeout(timer);
}
// 立即执行模式
if (immediate && !isInvoked) {
fn.apply(context, args);
isInvoked = true;
}
// 设置新的定时器
timer = setTimeout(() => {
timer = null;
if (!immediate) {
fn.apply(context, args);
}
isInvoked = false;
}, delay);
};
}
// 测试
const expensiveOperation = debounce(function(searchTerm) {
console.log(`搜索:${searchTerm}`);
}, 300);
// 快速连续输入只会执行一次
document.getElementById('search').addEventListener('input', (e) => {
expensiveOperation(e.target.value);
});
6. 手写节流(throttle)
function throttle(fn, delay, options = {}) {
const { leading = true, trailing = true } = options;
let timer = null;
let lastCallTime = 0;
let lastArgs = null;
let lastContext = null;
function execute() {
if (lastArgs) {
fn.apply(lastContext, lastArgs);
lastArgs = null;
lastContext = null;
lastCallTime = Date.now();
}
timer = null;
}
return function(...args) {
const now = Date.now();
const remaining = delay - (now - lastCallTime);
lastArgs = args;
lastContext = this;
if (!lastCallTime && !leading) {
lastCallTime = now;
}
if (remaining <= 0 || remaining > delay) {
if (timer) {
clearTimeout(timer);
timer = null;
}
execute();
} else if (!timer && trailing) {
timer = setTimeout(execute, remaining);
}
};
}
// 测试
const handleScroll = throttle(function() {
console.log('滚动事件', Date.now());
}, 1000);
window.addEventListener('scroll', handleScroll);
7. 手写 call/apply/bind(见上文)
8. 手写 new(见上文)
9. 手写 instanceof(见上文)
10. 手写数组 map
Array.prototype.myMap = function(callback, thisArg) {
if (this == null) {
throw new TypeError('this is null or not defined');
}
if (typeof callback !== 'function') {
throw new TypeError(callback + ' is not a function');
}
const O = Object(this);
const len = O.length >>> 0;
const result = new Array(len);
for (let i = 0; i < len; i++) {
if (i in O) {
result[i] = callback.call(thisArg, O[i], i, O);
}
}
return result;
};
// 测试
const arr = [1, 2, 3];
const doubled = arr.myMap(num => num * 2);
console.log(doubled); // [2, 4, 6]
11. 手写数组 filter
Array.prototype.myFilter = function(callback, thisArg) {
if (this == null) {
throw new TypeError('this is null or not defined');
}
if (typeof callback !== 'function') {
throw new TypeError(callback + ' is not a function');
}
const O = Object(this);
const len = O.length >>> 0;
const result = [];
for (let i = 0; i < len; i++) {
if (i in O) {
if (callback.call(thisArg, O[i], i, O)) {
result.push(O[i]);
}
}
}
return result;
};
// 测试
const numbers = [1, 2, 3, 4, 5];
const evens = numbers.myFilter(num => num % 2 === 0);
console.log(evens); // [2, 4]
12. 手写数组 reduce
Array.prototype.myReduce = function(callback, initialValue) {
if (this == null) {
throw new TypeError('this is null or not defined');
}
if (typeof callback !== 'function') {
throw new TypeError(callback + ' is not a function');
}
const O = Object(this);
const len = O.length >>> 0;
if (len === 0 && initialValue === undefined) {
throw new TypeError('Reduce of empty array with no initial value');
}
let accumulator = initialValue !== undefined ? initialValue : O[0];
let startIndex = initialValue !== undefined ? 0 : 1;
for (let i = startIndex; i < len; i++) {
if (i in O) {
accumulator = callback(accumulator, O[i], i, O);
}
}
return accumulator;
};
// 测试
const arr = [1, 2, 3, 4];
const sum = arr.myReduce((acc, cur) => acc + cur, 0);
console.log(sum); // 10
13. 手写数组 flat
Array.prototype.myFlat = function(depth = 1) {
if (depth < 1) {
return this.slice();
}
return this.reduce((acc, cur) => {
if (Array.isArray(cur)) {
acc.push(...cur.myFlat(depth - 1));
} else {
acc.push(cur);
}
return acc;
}, []);
};
// 测试
const nested = [1, [2, [3, [4, 5]]]];
console.log(nested.myFlat()); // [1, 2, [3, [4, 5]]]
console.log(nested.myFlat(2)); // [1, 2, 3, [4, 5]]
console.log(nested.myFlat(Infinity)); // [1, 2, 3, 4, 5]
14. 手写发布订阅模式
class EventEmitter {
constructor() {
this.events = new Map();
}
on(event, listener) {
if (!this.events.has(event)) {
this.events.set(event, new Set());
}
this.events.get(event).add(listener);
return this;
}
off(event, listener) {
if (this.events.has(event)) {
const listeners = this.events.get(event);
listeners.delete(listener);
if (listeners.size === 0) {
this.events.delete(event);
}
}
return this;
}
emit(event, ...args) {
if (this.events.has(event)) {
const listeners = this.events.get(event);
listeners.forEach(listener => {
try {
listener.apply(this, args);
} catch (error) {
console.error(`Error in event listener for ${event}:`, error);
}
});
}
return this;
}
once(event, listener) {
const onceWrapper = (...args) => {
listener.apply(this, args);
this.off(event, onceWrapper);
};
return this.on(event, onceWrapper);
}
}
// 测试
const emitter = new EventEmitter();
// 订阅事件
emitter.on('message', (msg) => {
console.log(`收到消息:${msg}`);
});
emitter.once('greet', (name) => {
console.log(`你好,${name}!`);
});
// 发布事件
emitter.emit('message', 'Hello World'); // 收到消息:Hello World
emitter.emit('greet', 'Alice'); // 你好,Alice!
emitter.emit('greet', 'Bob'); // 不执行(once 只执行一次)
15. 手写柯里化
function curry(fn) {
return function curried(...args) {
if (args.length >= fn.length) {
return fn.apply(this, args);
} else {
return function(...moreArgs) {
return curried.apply(this, args.concat(moreArgs));
};
}
};
}
// 测试
function add(a, b, c) {
return a + b + c;
}
const curriedAdd = curry(add);
console.log(curriedAdd(1)(2)(3)); // 6
console.log(curriedAdd(1, 2)(3)); // 6
console.log(curriedAdd(1)(2, 3)); // 6
// 实际应用:创建特定功能的函数
const add5 = curriedAdd(5);
const add5And10 = add5(10);
console.log(add5And10(15)); // 30
16. 手写 LRU 缓存
class LRUCache {
constructor(capacity) {
this.capacity = capacity;
this.cache = new Map();
}
get(key) {
if (!this.cache.has(key)) {
return -1;
}
// 将访问的元素移到最前面(最近使用)
const value = this.cache.get(key);
this.cache.delete(key);
this.cache.set(key, value);
return value;
}
put(key, value) {
// 如果 key 已存在,先删除
if (this.cache.has(key)) {
this.cache.delete(key);
}
// 如果容量已满,删除最久未使用的(Map 的第一个键)
if (this.cache.size >= this.capacity) {
const firstKey = this.cache.keys().next().value;
this.cache.delete(firstKey);
}
// 添加新元素
this.cache.set(key, value);
}
// 辅助方法:查看缓存内容
toString() {
return Array.from(this.cache.entries())
.map(([k, v]) => `${k}:${v}`)
.join(' -> ');
}
}
// 测试
const cache = new LRUCache(2);
cache.put(1, 'A');
cache.put(2, 'B');
console.log(cache.get(1)); // 'A'
cache.put(3, 'C'); // 容量已满,删除键 2
console.log(cache.get(2)); // -1(已被删除)
console.log(cache.get(3)); // 'C'
cache.put(4, 'D'); // 删除键 1
console.log(cache.get(1)); // -1
console.log(cache.get(3)); // 'C'
console.log(cache.get(4)); // 'D'
// 输出顺序变化
console.log(cache.toString()); // "3:C -> 4:D"
补充说明:手写代码时要注意边界条件、错误处理、性能优化和代码可读性。在实际面试中,能够手写这些基础函数并解释原理,能显著提升面试表现。
总结
JavaScript涵盖了 117 道题目中的核心考点,通过合并相似问题、提供详细解释、完整代码示例和实用补充说明,形成了系统化的知识体系。建议结合实际编码练习加深理解,重点关注高频考点和手写代码题。