let/const 与 var 的区别?TDZ 是什么?
在 ES6 中引入了let
和const
,它们与var
存在多方面区别。
作用域方面:var
具有函数作用域,意味着在函数内部使用var
声明的变量,在整个函数体中都可以访问。例如:
function testVar() {
if (true) {
var x = 10;
}
console.log(x); // 输出 10
}
testVar();
而let
和const
具有块级作用域,块级作用域由一对花括号{}
构成,变量只能在声明它的块级作用域内访问。示例如下:
function testLet() {
if (true) {
let y = 20;
}
console.log(y); // 报错,y 未定义
}
testLet();
变量提升方面:var
存在变量提升现象,即变量可以在声明之前使用,只是值为undefined
。示例:
console.log(a); // 输出 undefined
var a = 1;
let
和const
不存在变量提升,在声明之前访问会报错。示例:
console.log(b); // 报错,Cannot access 'b' before initialization
let b = 2;
重复声明方面:var
允许在同一作用域内重复声明同一个变量,后面的声明会覆盖前面的声明。示例:
var c = 3;
var c = 4;
console.log(c); // 输出 4
let
和const
不允许在同一作用域内重复声明同一个变量,否则会报错。示例:
let d = 5;
let d = 6; // 报错,Identifier 'd' has already been declared
值的修改方面:var
和let
声明的变量可以重新赋值,而const
声明常量,一旦声明必须赋值,且不能重新赋值,但如果const
声明的是引用类型(如对象、数组),可以修改其内部属性。示例:
const obj = { key: 1 };
obj.key = 2; // 可以修改对象属性
console.log(obj.key); // 输出 2
TDZ 即暂时性死区(Temporal Dead Zone),它是指从块级作用域开始到变量声明语句之前的这个区域。在 TDZ 内,使用let
或const
声明的变量不能被访问,访问会报错。这是因为虽然变量已经存在于作用域中,但还未完成初始化。TDZ 的存在强化了块级作用域的概念,使得代码更加严谨,避免了一些潜在的错误。
箭头函数与普通函数的区别?箭头函数能否作为构造函数?
箭头函数和普通函数存在诸多不同之处。
语法方面:箭头函数的语法更加简洁,特别是在处理简单逻辑时优势明显。普通函数使用function
关键字来定义,而箭头函数使用箭头=>
来定义。例如,一个简单的加法函数,普通函数的写法是:
function add(a, b) {
return a + b;
}
箭头函数的写法是:
const add = (a, b) => a + b;
当箭头函数只有一个参数时,可以省略括号;当函数体只有一条语句时,可以省略花括号和return
关键字。
this 指向方面:普通函数的this
指向是动态的,取决于函数的调用方式。在全局作用域中,this
指向全局对象(在浏览器中是window
对象);在函数作为对象的方法调用时,this
指向调用该方法的对象;在构造函数中,this
指向新创建的对象。例如:
const obj = {
name: 'John',
sayName: function() {
console.log(this.name);
}
};
obj.sayName(); // 输出 'John'
而箭头函数没有自己的this
,它的this
继承自外层函数,且在定义时就确定了,不会随调用方式的改变而改变。示例:
const outer = {
name: 'Jane',
sayName: function() {
const arrow = () => console.log(this.name);
arrow();
}
};
outer.sayName(); // 输出 'Jane'
arguments 对象方面:普通函数内部有一个arguments
对象,它是一个类数组对象,包含了函数调用时传递的所有参数。例如:
function showArgs() {
console.log(arguments);
}
showArgs(1, 2, 3); // 输出 [1, 2, 3]
箭头函数没有自己的arguments
对象,如果要访问参数,需要使用剩余参数语法。示例:
const showArgs = (...args) => console.log(args);
showArgs(4, 5, 6); // 输出 [4, 5, 6]
使用yield
关键字方面:普通函数可以使用yield
关键字,从而成为生成器函数。而箭头函数不能使用yield
关键字,不能作为生成器函数。
关于箭头函数能否作为构造函数,答案是否定的。构造函数的作用是创建对象实例,在使用new
关键字调用构造函数时,会经历创建新对象、将this
指向新对象、执行构造函数代码、返回新对象等步骤。而箭头函数没有自己的this
,也没有prototype
属性,无法通过new
关键字来创建对象实例。如果尝试使用new
关键字调用箭头函数,会抛出错误。例如:
const ArrowConstructor = () => {};
new ArrowConstructor(); // 报错,ArrowConstructor is not a constructor
模板字符串的嵌套表达式和标签模板用法?
模板字符串是 ES6 引入的一种新的字符串表示方式,使用反引号(`)来包裹字符串内容,它提供了更强大的字符串处理能力,包括嵌套表达式和标签模板用法。
嵌套表达式:模板字符串允许在字符串中嵌入表达式,使用 ${}
语法来包裹表达式。在 ${}
内部可以放置任何有效的 JavaScript 表达式,包括变量、函数调用、算术运算等。例如:
const name = 'Alice';
const age = 25;
const message = `My name is ${name} and I am ${age} years old.`;
console.log(message); // 输出 'My name is Alice and I am 25 years old.'
更重要的是,模板字符串支持嵌套表达式,即 ${}
内部可以再嵌套另一个模板字符串。例如:
const num1 = 10;
const num2 = 20;
const result = `The sum of ${num1} and ${`${num2}`} is ${num1 + num2}.`;
console.log(result); // 输出 'The sum of 10 and 20 is 30.'
这种嵌套表达式的方式使得在处理复杂的字符串拼接时更加灵活和方便,可以根据需要动态地生成字符串内容。
标签模板用法:标签模板是指在模板字符串前面加上一个函数名,这个函数被称为标签函数。标签函数会接收模板字符串的各个部分作为参数,通过对这些参数的处理,可以实现自定义的字符串处理逻辑。标签函数的第一个参数是一个包含模板字符串中纯字符串部分的数组,后续参数依次是模板字符串中各个表达式的值。例如:
function highlight(strings, ...values) {
let result = '';
strings.forEach((string, index) => {
result += string;
if (index < values.length) {
result += `<span class="highlight">${values[index]}</span>`;
}
});
return result;
}
const person = 'Bob';
const occupation = 'Engineer';
const output = highlight`My name is ${person} and I am an ${occupation}.`;
console.log(output); // 输出 'My name is <span class="highlight">Bob</span> and I am an <span class="highlight">Engineer</span>.'
在这个例子中,highlight
函数作为标签函数,将模板字符串中的表达式部分用 <span class="highlight">
标签包裹起来,实现了对特定内容的高亮显示。标签模板的用法可以用于实现字符串的格式化、国际化、安全过滤等功能,为字符串处理提供了更多的可能性。
解构赋值的应用场景及对象 / 数组解构差异?
解构赋值是 ES6 引入的一种语法糖,它允许从数组或对象中提取值,并赋值给变量,在很多场景中都有广泛的应用。
解构赋值的应用场景:
- 交换变量值:在传统的交换变量值的方法中,通常需要借助一个临时变量。而使用解构赋值可以更简洁地实现变量交换。例如:
let a = 1;
let b = 2;
[a, b] = [b, a];
console.log(a); // 输出 2
console.log(b); // 输出 1
- 函数参数解构:当函数接收一个对象或数组作为参数时,可以使用解构赋值直接提取所需的属性或元素。例如:
function printPerson({ name, age }) {
console.log(`Name: ${name}, Age: ${age}`);
}
const person = { name: 'Charlie', age: 30 };
printPerson(person); // 输出 'Name: Charlie, Age: 30'
- 提取对象或数组的部分值:在处理复杂的对象或数组时,可能只需要其中的部分属性或元素。使用解构赋值可以方便地提取所需的值。例如:
const numbers = [1, 2, 3, 4, 5];
const [first, second] = numbers;
console.log(first); // 输出 1
console.log(second); // 输出 2
- 设置默认值:在解构赋值时,可以为变量设置默认值,当对象或数组中不存在对应的属性或元素时,使用默认值。例如:
const { city = 'Unknown' } = { name: 'David' };
console.log(city); // 输出 'Unknown'
对象 / 数组解构差异:
- 语法形式:数组解构使用方括号
[]
,按照元素的顺序进行匹配赋值。例如:
const arr = [10, 20];
const [x, y] = arr;
console.log(x); // 输出 10
console.log(y); // 输出 20
对象解构使用花括号 {}
,通过属性名进行匹配赋值。例如:
const obj = { key1: 'value1', key2: 'value2' };
const { key1, key2 } = obj;
console.log(key1); // 输出 'value1'
console.log(key2); // 输出 'value2'
- 匹配规则:数组解构是根据元素的位置进行匹配,变量的顺序与数组元素的顺序一一对应。如果某个位置不需要赋值,可以使用逗号跳过。例如:
const [, third] = [1, 2, 3];
console.log(third); // 输出 3
对象解构是根据属性名进行匹配,变量名必须与对象的属性名相同才能正确赋值。如果需要使用不同的变量名,可以使用别名语法。例如:
const { key1: newKey1 } = { key1: 'new value' };
console.log(newKey1); // 输出 'new value'
- 剩余元素处理:数组解构可以使用剩余参数语法
...
来获取数组中剩余的元素,剩余元素会被收集到一个新的数组中。例如:
const [firstNum, ...restNums] = [1, 2, 3, 4];
console.log(firstNum); // 输出 1
console.log(restNums); // 输出 [2, 3, 4]
对象解构也可以使用剩余参数语法 ...
来获取对象中剩余的属性,剩余属性会被收集到一个新的对象中。例如:
const { key1, ...restObj } = { key1: 'v1', key2: 'v2', key3: 'v3' };
console.log(key1); // 输出 'v1'
console.log(restObj); // 输出 { key2: 'v2', key3: 'v3' }
函数参数默认值的生效条件及暂时性死区问题?
函数参数默认值是 ES6 引入的一个特性,它允许在定义函数时为参数指定默认值,当调用函数时没有提供该参数的值或者提供的值为 undefined
时,会使用默认值。
函数参数默认值的生效条件:
当函数调用时没有传递某个参数,或者传递的参数值为 undefined
时,函数参数的默认值会生效。例如:
function greet(name = 'Guest') {
console.log(`Hello, ${name}!`);
}
greet(); // 输出 'Hello, Guest!'
greet(undefined); // 输出 'Hello, Guest!'
greet('Alice'); // 输出 'Hello, Alice!'
需要注意的是,如果传递的参数值为 null
、false
、0
等其他假值,默认值不会生效,而是使用传递的值。例如:
function multiply(a, b = 1) {
return a * b;
}
console.log(multiply(5, 0)); // 输出 0
console.log(multiply(5, null)); // 输出 0
暂时性死区问题:
在函数参数默认值的定义中,也存在暂时性死区(TDZ)的概念。函数参数的默认值是按照从左到右的顺序依次初始化的,在参数默认值初始化之前,该参数处于暂时性死区,不能被访问。例如:
function test(x = y, y = 2) {
console.log(x, y);
}
test(); // 报错,Cannot access 'y' before initialization
在这个例子中,参数 x
的默认值依赖于参数 y
,但在 x
的默认值初始化时,y
还处于暂时性死区,因此会报错。要避免这种情况,可以调整参数的顺序或者避免参数默认值之间的依赖。例如:
function test(y = 2, x = y) {
console.log(x, y);
}
test(); // 输出 2 2
此外,函数参数的默认值也会创建自己的作用域,在参数默认值的表达式中可以访问该作用域内的变量,但不能访问函数内部定义的变量。例如:
function test(a = b, b = 3) {
let c = 4;
console.log(a, b);
}
test(); // 输出 3 3
这里 a
的默认值可以访问 b
的默认值,但不能访问函数内部的变量 c
。函数参数默认值的暂时性死区问题提醒开发者在定义函数参数默认值时要注意参数的初始化顺序和作用域的问题,以避免出现错误。
展开运算符(...)在数组 / 对象中的使用场景?
展开运算符(...
)是 ES6 引入的一个非常实用的特性,在数组和对象操作中都有广泛的应用场景。
在数组方面,展开运算符可用于数组的合并。以往合并数组通常使用concat
方法,现在使用展开运算符会更简洁直观。例如:
const arr1 = [1, 2, 3];
const arr2 = [4, 5, 6];
const combined = [...arr1, ...arr2];
console.log(combined); // 输出 [1, 2, 3, 4, 5, 6]
它还能用于数组的复制。需要注意的是,这是浅拷贝,如果数组元素是引用类型,复制的只是引用。示例如下:
const original = [7, 8, 9];
const copy = [...original];
console.log(copy); // 输出 [7, 8, 9]
在函数调用时,展开运算符可将数组展开为一个个独立的参数。比如Math.max
方法需要多个独立参数,使用展开运算符可以方便地处理数组。示例:
const numbers = [10, 20, 30];
const max = Math.max(...numbers);
console.log(max); // 输出 30
在对象方面,展开运算符可用于对象的合并。它会将多个对象的属性合并到一个新对象中,如果有相同属性名,后面的对象属性会覆盖前面的。例如:
const obj1 = { a: 1, b: 2 };
const obj2 = { b: 3, c: 4 };
const merged = { ...obj1, ...obj2 };
console.log(merged); // 输出 { a: 1, b: 3, c: 4 }
也可用于对象的复制,同样是浅拷贝。示例:
const originalObj = { x: 5, y: 6 };
const copiedObj = { ...originalObj };
console.log(copiedObj); // 输出 { x: 5, y: 6 }
在解构赋值时,展开运算符可收集剩余的属性。例如:
const { x, ...rest } = { x: 7, y: 8, z: 9 };
console.log(x); // 输出 7
console.log(rest); // 输出 { y: 8, z: 9 }
Symbol 类型的特性及实际应用场景?
Symbol 是 ES6 引入的一种新的原始数据类型,表示独一无二的值。
Symbol 类型具有多个特性。首先,通过Symbol()
函数创建的每个 Symbol 值都是唯一的,即使传入相同的描述符也不例外。例如:
const sym1 = Symbol('desc');
const sym2 = Symbol('desc');
console.log(sym1 === sym2); // 输出 false
其次,Symbol 值不能与其他类型的值进行运算,否则会报错。但可以显式地将其转换为字符串或布尔值。示例:
const sym = Symbol('test');
// console.log(sym + 'abc'); // 报错
console.log(String(sym)); // 输出 'Symbol(test)'
console.log(Boolean(sym)); // 输出 true
再者,Symbol 作为对象的属性名时,不会出现在for...in
、for...of
循环中,也不会被Object.keys()
、Object.getOwnPropertyNames()
返回,但可以通过Object.getOwnPropertySymbols()
获取。示例:
const obj = {};
const symProp = Symbol('prop');
obj[symProp] = 'value';
for (let key in obj) {
console.log(key); // 无输出
}
console.log(Object.getOwnPropertySymbols(obj)); // 输出 [Symbol(prop)]
在实际应用场景中,Symbol 可用于创建私有属性或方法。由于 Symbol 的唯一性,外部代码很难直接访问到对象中以 Symbol 作为属性名的属性或方法。例如:
const privateMethod = Symbol('private');
class MyClass {
constructor() {
this[privateMethod] = function() {
console.log('This is a private method.');
};
}
publicMethod() {
this[privateMethod]();
}
}
const instance = new MyClass();
instance.publicMethod(); // 输出 'This is a private method.'
// instance[privateMethod](); // 报错,privateMethod 未定义
还可用于定义枚举值。使用 Symbol 作为枚举值,能确保每个枚举值的唯一性。示例:
const COLORS = {
RED: Symbol('red'),
GREEN: Symbol('green'),
BLUE: Symbol('blue')
};
function printColor(color) {
switch (color) {
case COLORS.RED:
console.log('Red');
break;
case COLORS.GREEN:
console.log('Green');
break;
case COLORS.BLUE:
console.log('Blue');
break;
default:
console.log('Unknown color');
}
}
printColor(COLORS.RED); // 输出 'Red'
for...of 循环与 for...in 循环的区别?
for...of
循环和for...in
循环在功能和使用场景上有明显的区别。
for...in
循环主要用于遍历对象的可枚举属性,包括对象自身的属性和继承的属性。它遍历的是对象属性的键(字符串类型)。例如:
const obj = { a: 1, b: 2, c: 3 };
for (let key in obj) {
console.log(key); // 依次输出 'a', 'b', 'c'
}
需要注意的是,for...in
循环的遍历顺序并不一定是按照属性定义的顺序,特别是对于非整数属性名。同时,它会遍历到对象原型链上的可枚举属性。示例:
function MyClass() {
this.prop1 = 'value1';
}
MyClass.prototype.prop2 = 'value2';
const instance = new MyClass();
for (let key in instance) {
console.log(key); // 依次输出 'prop1', 'prop2'
}
如果只想遍历对象自身的属性,可以使用hasOwnProperty
方法进行过滤。
for...of
循环主要用于遍历可迭代对象,如数组、字符串、Set、Map 等。它遍历的是可迭代对象的值。例如:
const arr = [4, 5, 6];
for (let value of arr) {
console.log(value); // 依次输出 4, 5, 6
}
for...of
循环不关心对象的属性名,只关注值本身。而且它不会遍历对象原型链上的属性。对于自定义的可迭代对象,需要实现Symbol.iterator
方法。示例:
const myIterable = {
[Symbol.iterator]() {
let index = 0;
const values = [7, 8, 9];
return {
next() {
if (index < values.length) {
return { value: values[index++], done: false };
}
return { done: true };
}
};
}
};
for (let value of myIterable) {
console.log(value); // 依次输出 7, 8, 9
}
Array.from () 和 Array.of () 的作用?
Array.from()
和Array.of()
是 ES6 为数组操作提供的两个实用方法,它们的作用有所不同。
Array.from()
方法用于将类数组对象或可迭代对象转换为真正的数组。类数组对象是指具有length
属性和索引属性的对象,可迭代对象是指实现了Symbol.iterator
方法的对象。例如,arguments
对象是一个类数组对象,使用Array.from()
可以将其转换为数组。示例:
function convertArgs() {
const arr = Array.from(arguments);
console.log(arr);
}
convertArgs(1, 2, 3); // 输出 [1, 2, 3]
Array.from()
还可以接收第二个参数,它是一个映射函数,用于对每个元素进行处理。例如:
const numbers = { 0: 1, 1: 2, 2: 3, length: 3 };
const squared = Array.from(numbers, num => num * num);
console.log(squared); // 输出 [1, 4, 9]
另外,Array.from()
可以用于复制一个数组,并且是浅拷贝。示例:
const original = [4, 5, 6];
const copy = Array.from(original);
console.log(copy); // 输出 [4, 5, 6]
Array.of()
方法用于创建一个具有可变数量参数的新数组,无论参数的数量或类型如何。它解决了Array()
构造函数的一些不一致性问题。例如,当使用Array()
构造函数传入一个参数时,如果该参数是一个数字,它会创建一个指定长度的空数组。而Array.of()
会将传入的参数作为数组的元素。示例:
const arr1 = Array(3);
console.log(arr1); // 输出 [ , , ]
const arr2 = Array.of(3);
console.log(arr2); // 输出 [3]
Array.of()
可以接收任意数量和类型的参数,并将它们作为数组的元素。示例:
const arr3 = Array.of(1, 'two', [3]);
console.log(arr3); // 输出 [1, 'two', [3]]
Object.assign () 的深拷贝问题?
Object.assign()
是 ES6 中用于对象合并的方法,它将一个或多个源对象的所有可枚举属性复制到目标对象,并返回目标对象。然而,Object.assign()
只进行浅拷贝,这会引发一些问题。
浅拷贝意味着Object.assign()
只复制对象的一层属性,如果对象的属性是引用类型(如对象、数组),它只会复制引用,而不是对象本身。例如:
const source = {
prop1: { subProp: 'value' },
prop2: [1, 2, 3]
};
const target = {};
Object.assign(target, source);
console.log(target.prop1 === source.prop1); // 输出 true
console.log(target.prop2 === source.prop2); // 输出 true
在这个例子中,target
对象的prop1
和prop2
属性只是复制了source
对象对应属性的引用,它们指向同一个内存地址。因此,如果修改source
对象中引用类型属性的值,target
对象中对应的属性值也会改变。示例:
source.prop1.subProp = 'new value';
source.prop2.push(4);
console.log(target.prop1.subProp); // 输出 'new value'
console.log(target.prop2); // 输出 [1, 2, 3, 4]
要实现深拷贝,即复制对象及其所有嵌套的对象,可以使用一些其他的方法。一种常见的方法是使用JSON.parse(JSON.stringify())
,但这种方法有局限性,它不能处理包含函数、Symbol
类型属性、undefined
值或循环引用的对象。示例:
const sourceObj = { a: { b: 1 } };
const deepCopy = JSON.parse(JSON.stringify(sourceObj));
sourceObj.a.b = 2;
console.log(deepCopy.a.b); // 输出 1
对于更复杂的对象,可以使用第三方库如lodash
的cloneDeep
方法来实现深拷贝。示例:
const _ = require('lodash');
const originalObj = { x: { y: [1, 2] } };
const deeplyCopiedObj = _.cloneDeep(originalObj);
originalObj.x.y.push(3);
console.log(deeplyCopiedObj.x.y); // 输出 [1, 2]
Promise 三种状态及链式调用原理?
Promise 是 ES6 引入的用于处理异步操作的对象,它有三种状态:pending
(进行中)、fulfilled
(已成功)和rejected
(已失败)。
当 Promise 被创建时,它的初始状态为pending
。在异步操作执行过程中,Promise 一直处于这个状态。例如,当发起一个网络请求时,从请求发出到服务器响应的这段时间,Promise 就处于pending
状态。
如果异步操作成功完成,Promise 的状态会从pending
变为fulfilled
,此时可以通过then
方法来处理成功的结果。示例如下:
const promise = new Promise((resolve, reject) => {
setTimeout(() => {
resolve('Success');
}, 1000);
});
promise.then((result) => {
console.log(result); // 输出 'Success'
});
若异步操作出现错误,Promise 的状态会从pending
变为rejected
,可以使用catch
方法来处理错误。示例:
const promise2 = new Promise((resolve, reject) => {
setTimeout(() => {
reject(new Error('Something went wrong'));
}, 1000);
});
promise2.catch((error) => {
console.error(error); // 输出错误信息
});
Promise 的链式调用原理基于then
和catch
方法会返回一个新的 Promise 对象。这使得可以在一个 Promise 操作完成后接着执行另一个 Promise 操作。then
方法可以接收两个回调函数,第一个处理成功结果,第二个处理失败结果;catch
方法相当于then
方法只传入第二个回调函数的简写形式。例如:
const promise3 = new Promise((resolve, reject) => {
resolve(1);
});
promise3.then((value) => {
return value + 1;
}).then((newValue) => {
console.log(newValue); // 输出 2
}).catch((error) => {
console.error(error);
});
在链式调用中,如果前一个then
或catch
方法返回一个 Promise 对象,后续的then
或catch
方法会等待这个新的 Promise 对象状态改变后再执行;如果返回的是一个普通值,后续的then
方法会立即执行,并将这个值作为参数传递。这种链式调用的方式使得异步操作的处理更加清晰和简洁,避免了回调地狱的问题。
Promise.all () 和 Promise.race () 的区别?
Promise.all()
和Promise.race()
都是 Promise 对象的静态方法,用于处理多个 Promise 对象,但它们的功能和使用场景有所不同。
Promise.all()
接收一个可迭代对象(通常是数组)作为参数,该数组中的每个元素都是一个 Promise 对象。Promise.all()
返回一个新的 Promise 对象,当数组中的所有 Promise 对象都成功(状态变为fulfilled
)时,新的 Promise 对象才会成功,并且其结果是一个包含所有 Promise 对象成功结果的数组,结果的顺序与传入的 Promise 对象顺序一致。例如:
const promise1 = Promise.resolve(1);
const promise2 = Promise.resolve(2);
const promise3 = Promise.resolve(3);
Promise.all([promise1, promise2, promise3]).then((results) => {
console.log(results); // 输出 [1, 2, 3]
});
如果数组中有任何一个 Promise 对象失败(状态变为rejected
),则新的 Promise 对象会立即失败,并且其结果是第一个失败的 Promise 对象的错误信息。示例:
const promise4 = Promise.resolve(4);
const promise5 = Promise.reject(new Error('Error in promise5'));
const promise6 = Promise.resolve(6);
Promise.all([promise4, promise5, promise6]).catch((error) => {
console.error(error); // 输出错误信息
});
Promise.race()
同样接收一个可迭代对象作为参数,返回一个新的 Promise 对象。这个新的 Promise 对象的状态会随着数组中第一个状态改变的 Promise 对象而改变。也就是说,只要数组中有一个 Promise 对象的状态变为fulfilled
或rejected
,新的 Promise 对象就会以相同的状态和结果结束。例如:
const promise7 = new Promise((resolve) => {
setTimeout(() => {
resolve('Promise 7 resolved');
}, 2000);
});
const promise8 = new Promise((resolve) => {
setTimeout(() => {
resolve('Promise 8 resolved');
}, 1000);
});
Promise.race([promise7, promise8]).then((result) => {
console.log(result); // 输出 'Promise 8 resolved'
});
在实际应用中,Promise.all()
适用于需要等待多个异步操作都完成后再进行下一步处理的场景,比如同时请求多个接口,需要所有接口数据都返回后再进行页面渲染。而Promise.race()
适用于只关心多个异步操作中第一个完成的结果的场景,例如在多个请求中选择最快响应的那个。
async/await 的实现原理及错误处理?
async/await
是 ES8 引入的用于处理异步操作的语法糖,它基于 Promise 实现,使得异步代码看起来更像同步代码,提高了代码的可读性和可维护性。
其实现原理是:async
函数会返回一个 Promise 对象。当async
函数内部的代码执行时,如果遇到await
关键字,它会暂停函数的执行,等待await
后面的 Promise 对象状态改变。当 Promise 对象状态变为fulfilled
时,会将结果返回并继续执行async
函数后面的代码;如果 Promise 对象状态变为rejected
,则会抛出错误。例如:
function asyncOperation() {
return new Promise((resolve) => {
setTimeout(() => {
resolve('Operation completed');
}, 1000);
});
}
async function main() {
const result = await asyncOperation();
console.log(result); // 输出 'Operation completed'
}
main();
实际上,async/await
是对 Promise 链式调用的一种封装。上面的代码可以等价于以下 Promise 链式调用的代码:
function asyncOperation() {
return new Promise((resolve) => {
setTimeout(() => {
resolve('Operation completed');
}, 1000);
});
}
function main() {
return asyncOperation().then((result) => {
console.log(result); // 输出 'Operation completed'
});
}
main();
在错误处理方面,await
后面的 Promise 对象如果状态变为rejected
,会抛出错误。可以使用try...catch
语句来捕获和处理这些错误。例如:
function asyncOperationWithError() {
return new Promise((resolve, reject) => {
setTimeout(() => {
reject(new Error('Something went wrong'));
}, 1000);
});
}
async function handleError() {
try {
const result = await asyncOperationWithError();
console.log(result);
} catch (error) {
console.error(error); // 输出错误信息
}
}
handleError();
在async
函数外部,由于async
函数返回的是一个 Promise 对象,也可以使用catch
方法来处理错误。例如:
async function anotherAsyncFunction() {
throw new Error('Another error');
}
anotherAsyncFunction().catch((error) => {
console.error(error); // 输出错误信息
});
async/await
结合try...catch
语句为异步操作的错误处理提供了一种简洁而强大的方式,使得代码的错误处理逻辑更加清晰。
Generator 函数与 yield 关键字的执行机制?
Generator 函数是 ES6 引入的一种特殊函数,通过在函数名前加上*
来定义。它的执行机制与普通函数有很大不同,而yield
关键字在其中起到了关键作用。
Generator 函数在调用时不会立即执行函数体中的代码,而是返回一个迭代器对象。这个迭代器对象具有next()
方法,每次调用next()
方法,Generator 函数会从上次暂停的位置(如果有的话)继续执行,直到遇到yield
关键字。yield
关键字用于暂停函数的执行,并返回一个值。例如:
function* generatorFunction() {
yield 1;
yield 2;
return 3;
}
const iterator = generatorFunction();
console.log(iterator.next()); // 输出 { value: 1, done: false }
console.log(iterator.next()); // 输出 { value: 2, done: false }
console.log(iterator.next()); // 输出 { value: 3, done: true }
next()
方法返回一个对象,包含两个属性:value
表示yield
后面的值或return
语句返回的值,done
表示 Generator 函数是否已经执行完毕。
yield
关键字不仅可以返回值,还可以接收外部传递的值。在调用next()
方法时传入参数,这个参数会作为上一次yield
语句的返回值。例如:
function* generatorWithInput() {
const input1 = yield 'First yield';
console.log(input1);
const input2 = yield 'Second yield';
console.log(input2);
}
const iterator2 = generatorWithInput();
console.log(iterator2.next()); // 输出 { value: 'First yield', done: false }
console.log(iterator2.next('Input for first yield')); // 输出 { value: 'Second yield', done: false }
console.log(iterator2.next('Input for second yield')); // 输出 { value: undefined, done: true }
除了next()
方法,迭代器对象还有throw()
方法和return()
方法。throw()
方法用于在 Generator 函数内部抛出一个错误,使函数在当前位置停止执行并进入错误处理流程。return()
方法用于提前结束 Generator 函数的执行,并返回指定的值。例如:
function* generatorWithThrow() {
try {
yield 1;
} catch (error) {
console.error(error);
}
}
const iterator3 = generatorWithThrow();
console.log(iterator3.next()); // 输出 { value: 1, done: false }
iterator3.throw(new Error('Error thrown')); // 输出错误信息
Generator 函数和yield
关键字的这种执行机制为异步编程提供了一种新的思路,通过控制函数的暂停和继续执行,可以实现复杂的异步操作流程。
宏任务与微任务的执行顺序差异?
在 JavaScript 中,异步任务分为宏任务和微任务,它们的执行顺序存在差异,这与 JavaScript 的事件循环机制密切相关。
宏任务包括setTimeout
、setInterval
、setImmediate
(Node.js 环境)、requestAnimationFrame
(浏览器环境)、I/O 操作、UI 渲染等。微任务包括Promise.then
、MutationObserver
(浏览器环境)、process.nextTick
(Node.js 环境)等。
事件循环机制的基本流程是:当主线程上的同步代码执行完毕后,会从任务队列中取出任务来执行。任务队列分为宏任务队列和微任务队列。
执行顺序上,首先执行主线程上的同步代码。当同步代码执行完后,会检查微任务队列,如果微任务队列中有任务,会依次执行微任务队列中的所有任务,直到微任务队列为空。然后从宏任务队列中取出一个宏任务执行,执行完这个宏任务后,又会再次检查微任务队列,重复上述过程,即执行微任务队列中的所有任务,再执行下一个宏任务,如此循环。例如:
console.log('1. Synchronous code');
setTimeout(() => {
console.log('2. setTimeout (macro - task)');
}, 0);
Promise.resolve().then(() => {
console.log('3. Promise.then (micro - task)');
});
console.log('4. Synchronous code');
在这段代码中,首先会输出1. Synchronous code
和4. Synchronous code
,因为这是主线程上的同步代码。然后遇到Promise.then
,它会被添加到微任务队列中,遇到setTimeout
,它会被添加到宏任务队列中。当主线程同步代码执行完后,会检查微任务队列,执行Promise.then
,输出3. Promise.then (micro - task)
。此时微任务队列为空,从宏任务队列中取出setTimeout
任务执行,输出2. setTimeout (macro - task)
。
在 Node.js 环境中,宏任务队列又分为多个阶段,如timers
、I/O callbacks
、idle, prepare
、poll
、check
、close callbacks
,不同阶段处理不同类型的宏任务,但微任务的执行时机仍然是在每个宏任务阶段执行完后,先清空微任务队列再进入下一个宏任务阶段。
理解宏任务和微任务的执行顺序差异对于处理复杂的异步操作和避免一些潜在的问题非常重要,例如在需要确保某些操作在其他操作之后立即执行时,可以使用微任务来实现。
setTimeout、Promise、async/await 的执行顺序?
JavaScript 是单线程语言,通过事件循环来处理异步操作。setTimeout
属于宏任务,Promise
的回调函数属于微任务,async/await
是基于Promise
实现的异步处理方式。
- 一般情况下,
async
函数内部如果有await
,会暂停函数执行,等待await
后的Promise
完成或拒绝。当同步代码执行完后,会先检查微任务队列,执行所有微任务,然后再执行宏任务。比如:
async function asyncFunc() {
console.log('async start');
await Promise.resolve();
console.log('async end');
}
setTimeout(() => {
console.log('setTimeout');
}, 0);
asyncFunc();
Promise.resolve().then(() => {
console.log('Promise then');
});
console.log('global');
上述代码会先输出async start
、global
,然后是Promise then
、async end
,最后是setTimeout
。
- 如果有多个
setTimeout
和Promise
以及async/await
混合,setTimeout
根据其设定的延迟时间,在指定时间后进入宏任务队列等待执行,而Promise
的微任务会在当前宏任务执行完后,下一个宏任务执行前执行。
如何中断 Promise 链?
通常Promise
链是通过then
方法或catch
方法来连接的。中断Promise
链可以通过以下方式实现:
Promise.resolve()
.then(() => {
return Promise.reject('中断原因');
})
.then(() => {
console.log('不会执行到这里');
})
.catch(error => {
console.log('捕获到中断:', error);
});
- 抛出错误:在
then
方法的回调函数中抛出错误,也可以达到中断Promise
链的目的,使后续的then
方法不执行,转而执行catch
方法。如:
Promise.resolve()
.then(() => {
throw new Error('中断原因');
})
.then(() => {
console.log('不会执行到这里');
})
.catch(error => {
console.log('捕获到中断:', error);
});
- 使用 Promise.race 并传入一个很快会被拒绝的 Promise:
Promise.race
方法会返回最先完成或拒绝的Promise
的结果。可以传入一个很快会被拒绝的Promise
,使Promise
链在race
这里中断。比如:
Promise.resolve()
.then(() => {
return Promise.race([
Promise.reject('中断原因'),
// 其他可能会成功的Promise
]);
})
.then(() => {
console.log('不会执行到这里');
})
.catch(error => {
console.log('捕获到中断:', error);
});
async 函数中多个 await 的并行优化策略?
在async
函数中,多个await
默认是顺序执行的,如果多个await
之间没有依赖关系,可以采用并行执行的方式来提高效率。
async function getData() {
const promise1 = fetch('url1');
const promise2 = fetch('url2');
const [response1, response2] = await Promise.all([promise1, promise2]);
const data1 = await response1.json();
const data2 = await response2.json();
return [data1, data2];
}
const urls = ['url1', 'url2', 'url3'];
async function getData() {
const promises = urls.map(url => fetch(url));
const responses = await Promise.all(promises);
const data = await Promise.all(responses.map(response => response.json()));
return data;
}
ES6 Class 与 ES5 构造函数的本质区别?
- 语法层面:ES6 的
Class
是一种更简洁、更清晰的语法糖,它让定义类和创建对象更加直观。而 ES5 是通过构造函数和prototype
属性来模拟类和继承。例如 ES5 构造函数定义:
function Person(name) {
this.name = name;
}
Person.prototype.sayHello = function() {
console.log(`Hello, ${this.name}`);
};
ES6 Class
定义:
class Person {
constructor(name) {
this.name = name;
}
sayHello() {
console.log(`Hello, ${this.name}`);
}
}
- 继承方面:ES5 的继承通过改变
prototype
链实现,需要手动调用Parent.call(this)
来继承父类的属性,容易出现问题。ES6 的Class
通过extends
和super
关键字实现继承,更符合面向对象的思维,并且内部实现了更合理的prototype
链设置和绑定。 - 函数提升:ES5 构造函数存在函数提升,可以在定义之前使用,而 ES6 的
Class
不存在提升,必须先定义后使用,否则会报错。
super 关键字在构造函数和静态方法中的用法?
- 在构造函数中:在子类的构造函数中,
super
用于调用父类的构造函数,并且必须在使用this
之前调用。它可以将父类的属性和方法初始化到子类实例中。例如:
class Parent {
constructor(name) {
this.name = name;
}
}
class Child extends Parent {
constructor(name, age) {
super(name);
this.age = age;
}
}
const child = new Child('小明', 18);
console.log(child.name);
console.log(child.age);
- 在静态方法中:在子类的静态方法中,
super
用于调用父类的静态方法。静态方法属于类本身,而不属于类的实例。比如:
class Parent {
static sayHello() {
console.log('Parent says hello');
}
}
class Child extends Parent {
static sayHi() {
super.sayHello();
console.log('Child says hi');
}
}
Child.sayHi();
静态属性和实例属性的定义方式?
在 JavaScript 中,静态属性和实例属性的定义方式有所不同。
静态属性:是属于类本身的属性,而不是类的实例。在 ES6 的class
语法中,可以使用static
关键字来定义静态属性。例如:
class MyClass {
static staticProperty = '静态属性的值';
}
console.log(MyClass.staticProperty); // 输出:静态属性的值
在 ES5 中没有直接定义静态属性的语法,通常是在构造函数的原型上添加属性来模拟静态属性,如MyClass.staticProperty = '静态属性的值';
。
实例属性:是属于类的每个实例的属性,每个实例都有自己独立的一份。在 ES6 的class
中,可以在构造函数constructor
中使用this
关键字来定义实例属性。例如:
class MyClass {
constructor() {
this.instanceProperty = '实例属性的值';
}
}
const myInstance = new MyClass();
console.log(myInstance.instanceProperty); // 输出:实例属性的值
在 ES5 中,也是在构造函数中通过this
来定义实例属性,如:
function MyClass() {
this.instanceProperty = '实例属性的值';
}
const myInstance = new MyClass();
console.log(myInstance.instanceProperty); // 输出:实例属性的值
如何实现类的私有属性和方法?
在 JavaScript 中,实现类的私有属性和方法有多种方式。
使用#
私有字段语法:这是 ES6 之后引入的语法,在属性或方法名前加上#
表示私有。例如:
class MyClass {
#privateProperty = '私有属性的值';
#privateMethod() {
console.log('私有方法');
}
publicMethod() {
console.log(this.#privateProperty);
this.#privateMethod();
}
}
const myInstance = new MyClass();
myInstance.publicMethod(); // 可以在内部访问私有属性和方法
console.log(myInstance.#privateProperty); // 报错,无法在外部访问
myInstance.#privateMethod(); // 报错,无法在外部调用
使用闭包和 WeakMap:利用闭包的特性,将私有属性和方法封装在函数内部,通过 WeakMap 来存储私有数据。例如:
const privateData = new WeakMap();
class MyClass {
constructor() {
const privateObj = {
privateProperty: '私有属性的值'
};
privateData.set(this, privateObj);
}
getPrivateProperty() {
return privateData.get(this).privateProperty;
}
}
const myInstance = new MyClass();
console.log(myInstance.getPrivateProperty()); // 可以访问私有属性
console.log(myInstance.privateProperty); // 报错,无法直接访问
extends 继承的实现原理?
在 JavaScript 中,extends
关键字用于实现类的继承,其原理主要涉及原型链和构造函数的调用。
当使用extends
时,子类会继承父类的原型方法和属性。子类的prototype
对象会被设置为一个新的对象,其__proto__
属性指向父类的prototype
,这样就建立了原型链关系,使得子类可以访问父类的方法和属性。
在构造函数方面,子类的构造函数中如果没有显式调用super()
,则不能使用this
关键字。super()
会调用父类的构造函数,用于初始化父类的属性和执行父类构造函数中的逻辑。例如:
class Parent {
constructor(name) {
this.name = name;
}
sayName() {
console.log(`我的名字是 ${this.name}`);
}
}
class Child extends Parent {
constructor(name, age) {
super(name);
this.age = age;
}
sayAge() {
console.log(`我 ${this.age} 岁了`);
}
}
const child = new Child('小明', 10);
child.sayName(); // 输出:我的名字是小明
child.sayAge(); // 输出:我10岁了
在这个例子中,Child
类通过extends
继承了Parent
类,Child
的实例可以访问Parent
类的sayName
方法,同时也有自己的sayAge
方法和age
属性。
如何通过 Class 实现 Mixin 模式?
Mixin 模式是一种将多个类的功能混合到一个类中的设计模式。在 JavaScript 中,可以通过class
来实现 Mixin 模式。
一种常见的方式是创建多个 Mixin 类,每个 Mixin 类都包含一些特定的方法,然后通过将这些 Mixin 类的方法混合到目标类中来实现功能的组合。例如:
class Mixin1 {
method1() {
console.log('Mixin1的方法1');
}
}
class Mixin2 {
method2() {
console.log('Mixin2的方法2');
}
}
class MyClass {
// 空的类,用于接收Mixin的方法
}
// 将Mixin1的方法混合到MyClass中
Object.assign(MyClass.prototype, Mixin1.prototype);
// 将Mixin2的方法混合到MyClass中
Object.assign(MyClass.prototype, Mixin2.prototype);
const myInstance = new MyClass();
myInstance.method1(); // 输出:Mixin1的方法1
myInstance.method2(); // 输出:Mixin2的方法2
还可以使用函数来封装 Mixin 的逻辑,使代码更具可复用性和灵活性。例如:
function mixin(target, source) {
Object.assign(target.prototype, source.prototype);
return target;
}
class MyClass {}
class Mixin1 {
method1() {
console.log('Mixin1的方法1');
}
}
class Mixin2 {
method2() {
console.log('Mixin2的方法2');
}
}
const MyClassWithMixins = mixin(mixin(MyClass, Mixin1), Mixin2);
const myInstance = new MyClassWithMixins();
myInstance.method1(); // 输出:Mixin1的方法1
myInstance.method2(); // 输出:Mixin2的方法2
Set/Map 与 Array/Object 的核心差异?
Set
、Map
与Array
、Object
在 JavaScript 中是不同的数据结构,它们有以下核心差异:
数据存储与唯一性
Set
是一种集合,它存储的元素是唯一的,不会有重复的值。例如new Set([1, 2, 2, 3])
得到的集合只有1
、2
、3
三个元素。Map
是键值对的集合,键是唯一的,每个键对应一个值。Array
是有序的元素列表,可以包含重复的元素,如[1, 2, 2, 3]
是一个合法的数组。Object
是键值对的集合,但键通常是字符串类型(也可以是 Symbol 类型),并且在使用字面量定义时,如果有重复的键,后面的会覆盖前面的。
数据访问与遍历
Set
可以通过for...of
循环或Set.prototype.forEach
方法来遍历,它没有像数组那样的索引访问方式。Map
可以通过for...of
循环或Map.prototype.forEach
方法遍历,也可以使用keys()
、values()
、entries()
方法获取键、值或键值对的迭代器来进行遍历。Array
可以通过索引来访问元素,如array[0]
,并且可以使用多种遍历方法,如for
循环、forEach
、map
等。Object
通常通过Object.keys()
、Object.values()
、Object.entries()
方法来获取键、值或键值对的数组,然后进行遍历,也可以使用for...in
循环,但要注意它会遍历到原型链上的可枚举属性。
使用场景
Set
常用于需要保证元素唯一性的场景,如去重操作、存储不重复的集合数据等。Map
适用于需要根据键来快速查找和存储值的场景,比如缓存数据、存储配置信息等。Array
适合存储和操作有序的、可重复的数据列表,如列表渲染、数据排序等。Object
常用于表示具有特定结构和属性的对象,如存储对象的属性和方法、JSON 数据等。
WeakSet/WeakMap 的垃圾回收机制?
WeakSet 和 WeakMap 是 ES6 引入的两种弱引用数据结构,它们的垃圾回收机制与普通的 Set 和 Map 有很大不同。
WeakSet 只能存储对象,且对这些对象是弱引用。所谓弱引用,就是当对象的其他强引用都被移除后,即使该对象还被 WeakSet 引用着,它也可以被垃圾回收机制回收。WeakSet 不会阻止对象被垃圾回收。例如:
let obj = {};
const weakSet = new WeakSet();
weakSet.add(obj);
obj = null;
// 此时obj对象没有其他强引用,即使在WeakSet中,也可能被垃圾回收
由于是弱引用,WeakSet 没有size
属性,也不能被遍历,因为无法预知其中的对象何时会被回收。
WeakMap 的键必须是对象,对键也是弱引用。当键对象的其他强引用被移除后,该键值对会从 WeakMap 中自动删除。这一特性使得 WeakMap 非常适合用于存储一些与对象关联但不影响对象生命周期的数据。例如:
let key = {};
const weakMap = new WeakMap();
weakMap.set(key, 'value');
key = null;
// 此时key对象没有其他强引用,对应的键值对可能会被从WeakMap中删除
同样,WeakMap 没有size
属性,也不能被遍历,它的主要方法有get
、set
、has
和delete
。
这种弱引用和垃圾回收机制使得 WeakSet 和 WeakMap 在处理一些需要临时关联对象数据,同时又不希望影响对象生命周期的场景中非常有用,比如为 DOM 元素关联额外的数据,当 DOM 元素被移除时,关联的数据也能自动被清理。
Map 的键名类型限制及与 Object 的性能对比?
Map 的键名类型限制相对较少,它可以使用任意类型的值作为键,包括对象、函数、基本数据类型等。例如:
const map = new Map();
const objKey = {};
const funcKey = function() {};
map.set(objKey, 'value for object key');
map.set(funcKey, 'value for function key');
map.set('stringKey', 'value for string key');
而 Object 的键名通常是字符串或 Symbol 类型。如果使用其他类型的值作为键,会被自动转换为字符串。例如:
const obj = {};
const keyObj = {};
obj[keyObj] = 'value';
// 实际上键名会变成 '[object Object]'
在性能方面,Map 和 Object 在不同场景下有不同的表现。当需要频繁地添加、删除键值对时,Map 的性能通常更好。因为 Map 内部有更高效的数据结构来管理键值对,它的set
和delete
操作的时间复杂度接近 O (1)。而 Object 在添加和删除属性时,可能会涉及到原型链的查找和修改,性能相对较差。
在查找操作上,如果键名是字符串,Object 和 Map 的性能差异不大。但如果键名是对象等其他类型,Map 的查找性能更优,因为它不需要进行类型转换。另外,Map 的size
属性可以直接获取键值对的数量,而 Object 需要通过Object.keys
等方法来获取属性数量,这也会带来一定的性能开销。
使用 Set 实现数组去重?
Set 是 ES6 引入的一种数据结构,它的特点是存储的元素具有唯一性,利用这一特性可以很方便地实现数组去重。
可以通过将数组转换为 Set,再将 Set 转换回数组来实现去重。例如:
const arr = [1, 2, 2, 3, 4, 4, 5];
const uniqueArr = [...new Set(arr)];
console.log(uniqueArr);
上述代码中,new Set(arr)
将数组arr
转换为 Set,Set 会自动去除重复的元素。然后使用扩展运算符...
将 Set 转换回数组。
还可以使用Array.from
方法来实现同样的功能,代码如下:
const arr = [1, 2, 2, 3, 4, 4, 5];
const uniqueArr = Array.from(new Set(arr));
console.log(uniqueArr);
Array.from
方法可以将可迭代对象转换为数组,Set 是可迭代对象,因此可以使用它将 Set 转换为去重后的数组。
这种利用 Set 实现数组去重的方法简洁高效,时间复杂度接近 O (n),因为 Set 的内部实现可以快速判断元素是否已经存在。
如何实现 LRU 缓存策略(Map 应用)?
LRU(Least Recently Used)缓存策略是一种常见的缓存淘汰算法,当缓存满时,会优先淘汰最近最少使用的数据。可以使用 Map 来实现 LRU 缓存策略。
Map 的特点是可以按照插入顺序迭代,并且可以使用delete
和set
方法来更新元素的顺序。实现 LRU 缓存可以维护一个最大容量,当插入新元素时,如果缓存已满,就删除最旧的元素。
以下是一个使用 Map 实现 LRU 缓存的示例代码:
class LRUCache {
constructor(capacity) {
this.capacity = capacity;
this.cache = new Map();
}
get(key) {
if (this.cache.has(key)) {
const value = this.cache.get(key);
this.cache.delete(key);
this.cache.set(key, value);
return value;
}
return -1;
}
put(key, value) {
if (this.cache.has(key)) {
this.cache.delete(key);
} else if (this.cache.size >= this.capacity) {
this.cache.delete(this.cache.keys().next().value);
}
this.cache.set(key, value);
}
}
在上述代码中,get
方法用于获取缓存中的值,如果键存在,会先删除该键值对,再重新插入,以更新其使用顺序。put
方法用于插入或更新键值对,如果缓存已满,会删除最旧的元素。
ES Module 与 CommonJS 的加载机制差异?
ES Module 和 CommonJS 是 JavaScript 中两种不同的模块系统,它们的加载机制存在显著差异。
ES Module 是 ES6 引入的官方模块系统,采用静态加载的方式。在代码编译阶段,就会确定模块的依赖关系,并且可以进行静态分析。这意味着可以在编译时进行一些优化,比如 Tree Shaking,去除未使用的代码。ES Module 的导入和导出是通过import
和export
关键字实现的。例如:
// 导出模块
export const variable = 'value';
export function func() {}
// 导入模块
import { variable, func } from './module.js';
ES Module 是异步加载的,在浏览器环境中,使用<script type="module">
标签引入模块时,会以异步方式加载模块,不会阻塞页面的渲染。在 Node.js 环境中,也可以使用.mjs
文件来使用 ES Module。
CommonJS 是 Node.js 早期使用的模块系统,采用动态加载的方式。模块的依赖关系是在代码运行时确定的,无法进行静态分析。CommonJS 的导入和导出是通过require
和module.exports
实现的。例如:
// 导出模块
const variable = 'value';
function func() {}
module.exports = { variable, func };
// 导入模块
const { variable, func } = require('./module.js');
CommonJS 是同步加载的,当使用require
导入模块时,会暂停当前模块的执行,等待被导入模块加载完成并执行后,再继续执行当前模块。这种同步加载的方式在服务器端环境中比较合适,但在浏览器环境中可能会导致页面卡顿。
总的来说,ES Module 更适合现代的前端开发,尤其是在构建工具和打包优化方面有更好的支持;而 CommonJS 在 Node.js 服务器端开发中仍然被广泛使用。
动态导入(import ())的应用场景?
动态导入(import()
)是 ES2020 引入的一项重要特性,它允许开发者在运行时按需加载模块,打破了传统静态导入在编译时就确定依赖的限制,带来了更灵活的模块加载方式,在多个场景中发挥着关键作用。
在代码分割方面,对于大型 Web 应用,初始加载的代码量过大会导致页面加载速度变慢。动态导入可以将应用拆分成多个较小的模块,在需要时再进行加载。比如单页面应用(SPA)中,不同的路由页面可以作为独立的模块,当用户访问特定路由时,才使用动态导入加载相应的模块。示例如下:
const route = 'about';
if (route === 'about') {
import('./aboutModule.js')
.then(module => {
module.showAboutPage();
})
.catch(error => {
console.error('Failed to load about module:', error);
});
}
处理可选依赖时,动态导入也非常有用。某些功能模块在特定条件下才需要使用,如果将其静态导入,即使在不需要时也会被加载,造成资源浪费。通过动态导入,可以根据运行时的条件决定是否加载这些模块。例如,根据用户的浏览器特性,决定是否加载支持特定功能的模块:
if ('serviceWorker' in navigator) {
import('./serviceWorkerModule.js')
.then(module => {
module.registerServiceWorker();
})
.catch(error => {
console.error('Failed to load service worker module:', error);
});
}
在懒加载场景中,动态导入同样表现出色。比如在图片懒加载的基础上,对于一些与图片交互相关的功能模块,也可以采用动态导入。当用户滚动到图片区域时,再加载处理图片交互的模块,减少初始加载时间。
循环依赖的处理方式?
循环依赖指的是两个或多个模块之间相互依赖的情况,这可能会导致模块加载和初始化出现问题。在不同的模块系统中,有不同的处理循环依赖的方法。
在 CommonJS 模块系统中,它采用同步加载的方式,当出现循环依赖时,模块在加载过程中可能会提前导出未完全初始化的对象。不过可以通过在模块内部使用函数或在合适的时机访问依赖模块的属性来解决。例如,模块 A 和模块 B 相互依赖,模块 A 可以在某个函数内部访问模块 B 的属性,而不是在模块顶层直接访问:
// moduleA.js
const moduleB = require('./moduleB');
function useModuleB() {
return moduleB.someFunction();
}
module.exports = { useModuleB };
// moduleB.js
const moduleA = require('./moduleA');
function someFunction() {
return 'Module B function';
}
module.exports = { someFunction };
在 ES Module 中,采用静态分析和异步加载的方式,处理循环依赖相对更优雅。ES Module 在解析模块依赖时,会先建立模块之间的引用关系,即使存在循环依赖,也会确保在所有模块都被解析后再进行初始化。模块在导入时,不会立即执行被导入模块的代码,而是在需要时再执行。例如:
// moduleA.js
import { someFunction } from './moduleB.js';
export function useModuleB() {
return someFunction();
}
// moduleB.js
import { useModuleB } from './moduleA.js';
export function someFunction() {
return 'Module B function';
}
此外,还可以通过重构代码来避免循环依赖。将相互依赖的部分提取到一个新的模块中,让原来的模块都依赖这个新模块,从而打破循环依赖的关系。
Tree Shaking 的实现条件?
Tree Shaking 是一种优化技术,用于在打包过程中去除代码中未使用的部分,减少打包后的文件体积。要实现 Tree Shaking,需要满足一定的条件。
从模块系统方面来看,Tree Shaking 主要适用于 ES Module。因为 ES Module 采用静态导入和导出的方式,在编译阶段就可以确定模块之间的依赖关系,便于进行静态分析。而 CommonJS 是动态加载模块,在运行时才能确定依赖,难以进行静态分析,所以 Tree Shaking 对其支持有限。
代码的导出和导入方式也很关键。要实现 Tree Shaking,需要使用 ES Module 的命名导出和导入。例如:
// utils.js
export function add(a, b) {
return a + b;
}
export function subtract(a, b) {
return a - b;
}
// main.js
import { add } from './utils.js';
// 这里只导入了 add 函数,subtract 函数可以被 Tree Shaking 移除
如果使用默认导出和导入,Tree Shaking 的效果可能会受到影响,因为默认导出会将整个模块作为一个整体导出,难以精确判断哪些部分未被使用。
另外,代码的副作用也会影响 Tree Shaking。如果代码中存在副作用,比如在模块顶层执行了一些会影响全局状态的代码,打包工具为了保证代码的正确性,可能不会对这些代码进行 Tree Shaking。可以通过在package.json
中设置sideEffects
字段来告诉打包工具哪些文件有副作用,哪些没有,从而更好地进行 Tree Shaking。例如:
{
"name": "my-project",
"sideEffects": false
}
表示项目中的所有文件都没有副作用,可以进行 Tree Shaking。
Proxy 拦截器的常用场景?
Proxy 是 ES6 引入的一个强大特性,它可以对对象的基本操作进行拦截和自定义处理,在多个场景中有着广泛的应用。
在数据验证方面,Proxy 可以用于拦截对象属性的赋值操作,对赋值进行验证。例如,创建一个只能存储数字的对象:
const numbers = {};
const validator = {
set(target, property, value) {
if (typeof value === 'number') {
target[property] = value;
return true;
} else {
console.error('Value must be a number');
return false;
}
}
};
const proxyNumbers = new Proxy(numbers, validator);
proxyNumbers.num = 10;
proxyNumbers.str = 'abc';
实现私有属性也是 Proxy 的一个常见应用。通过拦截对象属性的访问和赋值操作,控制对某些属性的访问权限。例如:
const privateData = {
_secret: 'private value'
};
const privateProxy = new Proxy(privateData, {
get(target, property) {
if (property.startsWith('_')) {
console.error('Access to private property is restricted');
return undefined;
}
return target[property];
},
set(target, property, value) {
if (property.startsWith('_')) {
console.error('Cannot set private property');
return false;
}
target[property] = value;
return true;
}
});
console.log(privateProxy._secret);
privateProxy._newSecret = 'new value';
在函数调用劫持方面,Proxy 可以拦截函数的调用,对函数的参数和返回值进行处理。例如,为函数添加日志记录功能:
function originalFunction(a, b) {
return a + b;
}
const logProxy = new Proxy(originalFunction, {
apply(target, thisArg, args) {
console.log(`Function called with arguments: ${args}`);
const result = target.apply(thisArg, args);
console.log(`Function returned: ${result}`);
return result;
}
});
logProxy(2, 3);
Reflect 对象的设计目的?
Reflect 对象是 ES6 引入的一个内置对象,它提供了一系列与 Proxy 拦截器方法对应的静态方法,其设计目的主要有以下几个方面。
统一对象操作的 API 是 Reflect 的重要设计目标之一。在 ES6 之前,JavaScript 中对对象的操作有多种不同的语法和方法,比较混乱。Reflect 对象将这些操作统一封装成方法,使代码更加简洁和规范。例如,Reflect.get
、Reflect.set
、Reflect.has
等方法分别对应对象的属性获取、属性设置和属性检查操作,使用起来更加一致。示例如下:
const obj = { name: 'John' };
const hasName = Reflect.has(obj, 'name');
const getName = Reflect.get(obj, 'name');
Reflect.set(obj, 'age', 30);
与 Proxy 配合使用也是 Reflect 的核心设计目的。Proxy 可以拦截对象的基本操作,而 Reflect 提供了默认的操作实现。在 Proxy 的拦截器中,可以使用 Reflect 的方法来执行默认操作,同时进行额外的处理。例如:
const target = { x: 1 };
const handler = {
get(target, property) {
console.log(`Getting property ${property}`);
return Reflect.get(target, property);
}
};
const proxy = new Proxy(target, handler);
console.log(proxy.x);
增强错误处理能力也是 Reflect 的一个重要作用。传统的对象操作在某些情况下会抛出错误,而 Reflect 的方法在操作失败时会返回布尔值或 undefined
,这样可以更方便地进行错误处理。例如,Reflect.defineProperty
方法在定义属性失败时会返回 false
,而不是抛出错误。
此外,Reflect 对象还为元编程提供了支持,使得开发者可以在运行时对对象的行为进行更灵活的控制和扩展。