本文围绕 JavaScript 的各类重点知识,通过对面试高频考题的归纳与讲解,让你了解面试考核重心,掌握问题背后的底层知识。
第一章 JavaScript 变量
var、let、const 的差异?
var、let 和 const 的主要区别在于作用域、可变性和提升行为:
-
作用域:
var:函数作用域,声明的变量在函数内有效。let和const:块级作用域,声明的变量仅在块内有效(如{}内)。
-
可变性:
var和let:可以重新赋值。const:不可重新赋值,必须在声明时赋值。
-
提升(Hoisting) :
var:会提升到作用域顶部,但未赋值前值为undefined。let和const:也会提升,但在赋值前无法访问("暂时性死区")。
谈谈作用域?
作用域控制了变量的可见性和生命周期,块级作用域和函数作用域有助于限制变量的范围,避免污染全局作用域。
作用域(Scope)决定了代码中变量、函数的可访问性和生命周期,通常分为以下几种:
-
全局作用域(Global Scope) :
- 在任何函数或块外声明的变量、函数都会拥有全局作用域。
- 这些变量可以在程序的任何地方访问,直到页面或程序关闭。
- 示例:
var globalVar = 'I am global'; // 全局作用域 -
函数作用域(Function Scope) :
- 由函数创建,使用
var声明的变量属于函数作用域。 - 函数内的变量只能在该函数内访问,外部无法访问。
- 示例:
function foo() { var functionVar = 'I am in a function'; // 函数作用域 } console.log(functionVar); // 报错:未定义 - 由函数创建,使用
-
块级作用域(Block Scope) :
- 由
{}创建,使用let或const声明的变量属于块级作用域。 - 这些变量只能在其块内访问,块外不可访问。
- 示例:
if (true) { let blockVar = 'I am in a block'; // 块级作用域 } console.log(blockVar); // 报错:未定义 - 由
-
词法作用域(Lexical Scope) :
- JavaScript 中的作用域是词法作用域,这意味着作用域是在代码书写时决定的,而不是在运行时。
- 嵌套函数可以访问外部函数的变量,但反过来不行。
- 示例:
function outer() { var outerVar = 'Outer'; function inner() { console.log(outerVar); // 可以访问外部作用域的变量 } inner(); } -
作用域链(Scope Chain) :
- 当查找变量时,JavaScript 会沿着作用域链向上查找,直到找到变量或到达全局作用域。
- 如果变量在当前作用域不存在,JavaScript 会继续向上查找。
什么是变量提升?
变量提升(Hoisting)是指在 JavaScript 中,变量声明(不包括赋值)会被提升到其作用域的顶部进行处理。JavaScript 引擎在执行代码前,会先扫描声明,确保它们可以在代码执行时被访问。
具体表现
var声明的变量:
等同于:
var x;
console.log(x); // 输出:undefined
x = 5;
var声明的变量会被提升到函数或全局作用域的顶部,但不会提升赋值部分。未赋值前,变量的值为undefined。- 示例:
console.log(x); // 输出:undefined
var x = 5;
-
let和const声明的变量:let和const也会提升,但它们处于“暂时性死区(Temporal Dead Zone) ”,在声明之前无法访问。未声明前访问会报错。- 示例:
console.log(y); // 报错:y is not defined
let y = 10;
-
函数提升:
- 函数声明会被完整提升,意味着可以在函数声明之前调用它。
- 示例:
greet(); // 输出:Hello function greet() { console.log('Hello'); }- 函数表达式(如
var、let、const的函数赋值)不会被提升。
greet(); // 报错:greet is not a function var greet = function() { console.log('Hello'); };
总结
var声明的变量:声明被提升,赋值不被提升,默认值undefined。let和const:声明被提升,但会在声明前的暂时性死区内不可访问。- 函数声明:整个函数被提升,可以在声明前调用。
第二章 JavaScript 数据类型
JavaScript 数据类型有哪些?
JavaScript 中的数据类型可以分为两大类:原始类型(Primitive Types) 和 引用类型(Reference Types) 。
1. 原始类型(Primitive Types)
这些类型的值是不可变的,存储的是值本身。
Number:表示数字类型,包括整数和浮点数。
let num = 42;
let pi = 3.14;
String:表示文本数据,由一组字符组成的字符串。
let str = "Hello, world!";
Boolean:表示布尔值,只有true和false。
let isActive = true;
Undefined:表示未定义的值。变量声明了但未赋值时默认为undefined。
let a; // undefined
Null:表示空值,通常用于表示变量没有值或对象为空。
let obj = null;
Symbol:表示唯一的标识符,常用于对象属性的唯一键。
let sym = Symbol('unique');
BigInt:用于表示大整数,可以安全地表示大于Number类型的范围。
let bigInt = 1234567890123456789012345678901234567890n;
2. 引用类型(Reference Types)
这些类型的值是可变的,存储的是对象的引用。
Object:对象是键值对的集合。对象类型包含更多的具体类型,如数组、函数等。
let obj = { name: 'Alice', age: 25 };
Array:对象的一种特殊形式,用于存储有序的值列表。
let arr = [1, 2, 3, 4];
Function:也是对象的一种,表示可调用的代码块。
function greet() {
console.log('Hello');
}
Date、RegExp(正则表达式)、Map、Set等都是对象类型的扩展。
总结
- 原始类型:
Number,String,Boolean,Undefined,Null,Symbol,BigInt。 - 引用类型:
Object(包括Array、Function、Date、Map等)。
原始数据类型和引用数据类型的区别?
原始数据类型(Primitive Types)和引用数据类型(Reference Types)在 存储方式、赋值方式 和 比较方式 上有明显区别。
1. 存储方式
-
原始数据类型:
- 存储的是值本身。
- 数据值直接存放在栈内存中(因为原始数据类型占用空间固定,大小可预知)。
- 变量直接持有值。
- 例如:
let a = 10; // 变量 `a` 直接存储值 10
-
引用数据类型:
- 存储的是对象的引用(内存地址) ,而不是值本身。
- 对象实际的数据存储在堆内存中,栈内存中保存对堆内存中对象的引用地址。
- 例如:
let obj = { name: 'Alice' }; // 变量 `obj` 存储的是对象的内存地址
2. 赋值方式
-
原始数据类型:
- 赋值时是值的拷贝,每个变量都有自己独立的值。
- 改变一个变量不会影响另一个变量。
- 例如:
let a = 5;
let b = a; // `b` 复制了 `a` 的值
a = 10;
console.log(b); // 仍然是 5,互不影响
-
引用数据类型:
- 赋值时是引用的拷贝,多个变量共享同一个对象的引用。
- 改变一个变量中的对象,会影响所有指向该对象的变量。
- 例如:
let obj1 = { name: 'Alice' };
let obj2 = obj1; // `obj2` 拷贝了 `obj1` 的引用
obj1.name = 'Bob';
console.log(obj2.name); // 输出 'Bob',因为两个变量指向同一个对象
3. 比较方式
-
原始数据类型:
- 比较的是值本身。
- 如果两个原始数据类型的变量值相同,则它们相等。
- 例如:
let x = 10;
let y = 10;
console.log(x === y); // 输出 true
-
引用数据类型:
- 比较的是引用地址,即使两个对象的内容相同,它们的引用地址不同也会被视为不相等。
- 例如:
let obj1 = { name: 'Alice' };
let obj2 = { name: 'Alice' };
console.log(obj1 === obj2); // 输出 false,因为它们是不同的对象,引用不同
总结
- 原始数据类型:存储值本身,赋值时拷贝值,比较时根据值相等。
- 引用数据类型:存储对象的引用,赋值时拷贝引用,比较时根据引用地址是否相同。
为什么 0.1 + 0.2 !== 0.3 ?
在 JavaScript 中,
0.1 + 0.2 !== 0.3这个问题源于浮点数的表示精度问题。计算机中的浮点数使用二进制(基于 IEEE 754 标准)来表示,某些十进制数不能被精确表示为二进制数,因此会产生精度误差。
问题原因
-
浮点数表示精度:
- 浮点数在计算机中以有限的二进制位存储,但有些十进制数(如
0.1和0.2)在二进制中无法精确表示。 - 例如,
0.1的二进制表示为0.00011001100110011001100110011001100110011001100110011...(无限循环)。在有限的位数中表示时,会出现精度丢失。
- 浮点数在计算机中以有限的二进制位存储,但有些十进制数(如
-
计算结果误差:
- 当计算
0.1 + 0.2时,实际上会得到一个接近但不完全等于0.3的结果。这就是为什么0.1 + 0.2的结果与0.3比较时不相等。
- 当计算
解决方法
-
使用四舍五入:
- 对计算结果进行四舍五入以减少精度误差:
let result = 0.1 + 0.2;
let isEqual = Math.abs(result - 0.3) < Number.EPSILON;
console.log(isEqual); // 输出 true
-
使用精度容差:
- 允许一些小的误差范围:
function areCloseEnough(a, b, tolerance = 1e-10) {
return Math.abs(a - b) < tolerance;
}
let result = 0.1 + 0.2;
console.log(areCloseEnough(result, 0.3)); // 输出 true
-
使用整数代替浮点数:
- 在需要高精度计算时,可以将浮点数转换为整数进行计算:
let a = 0.1 * 10;
let b = 0.2 * 10;
let c = 0.3 * 10;
console.log(a + b === c); // 输出 true
谈谈 undefined 和 null ?
注意:
undefined == null为true,而undefined === null为false在 JavaScript 中,
undefined和null是两个不同的数据类型,它们用于表示“缺失”的概念,但有不同的语义和使用场景:
undefined
-
定义:
undefined是一种原始数据类型,表示一个变量尚未被赋值。- 当变量被声明但没有初始化时,它的值为
undefined。
-
用途:
- 用于表示变量或属性的值尚未定义或初始化。
- 函数未显式返回值时,默认返回
undefined。
-
例子:
let x;
console.log(x); // 输出:undefined,因为 `x` 尚未赋值
function foo() {}
console.log(foo()); // 输出:undefined,因为 `foo` 没有返回值
-
检测:
- 可以使用
typeof来检查是否是undefined:
- 可以使用
let y;
console.log(typeof y === 'undefined'); // 输出:true
null
-
定义:
null是一种原始数据类型,表示“空值”或“无值”。- 这是一个显式的值,表示一个变量应当有一个对象值,但目前为空。
-
用途:
- 通常用作占位符,表示某个变量应该有值,但当前没有值。
- 用于表示对象的空值或未找到的结果。
-
例子:
let obj = null;
console.log(obj); // 输出:null,表示 `obj` 当前为空
function findUser(id) {
// 如果没有找到用户,返回 null
return null;
}
-
检测:
- 可以直接比较
null:
- 可以直接比较
let z = null;
console.log(z === null); // 输出:true
主要区别
-
含义:
undefined表示变量尚未被赋值,通常由 JavaScript 引擎自动赋值。null是由程序员手动赋值,表示“无值”或“空”。
-
类型:
undefined是undefined类型。null是object类型(历史遗留问题)。
-
用途:
undefined通常用于检测变量是否被赋值或函数是否有返回值。null通常用于表示空值,作为一个占位符,用于初始化对象等场景。
总结
undefined:表示变量尚未定义或赋值,由 JavaScript 引擎自动赋值。null:表示空值或无值,由程序员显式赋值,用于指示意图上的空对象或占位符。
typeof null 的结果是什么?
在 JavaScript 中,
typeof null的结果是"object"。这是由于历史遗留问题造成的。
原因分析
-
历史遗留问题:
- 在早期的 JavaScript 实现中,
null被设计为一种“对象”类型。这是因为早期的 JavaScript 中,将对象的值存储在一个指向对象的引用中,而null被用来表示一个空对象引用。
- 在早期的 JavaScript 实现中,
-
类型检测:
typeof操作符用于检测值的基本数据类型。在最早的 JavaScript 实现中,所有的对象类型(包括null)都被统一标识为"object"。- 虽然
null被认为是一个“对象”类型,但它实际上并不是一个对象,而是一个特殊的原始值。
示例
console.log(typeof null); // 输出: "object"
总结
typeof null 返回 "object" 是由于历史原因的实现细节,这个行为在现代 JavaScript 引擎中仍然保留。为了准确检测 null,应该直接与 null 进行比较:
let value = null;
console.log(value === null); // 输出:true
通常判断一个变量是对象:
const obj = {}
console.log(typeof obj === 'object' && obj !== null) //输出:true
这样可以确保正确地判断变量是否为 null。
JavaScript 如何做类型转换?
JavaScript 提供了多种方法来进行类型转换,将数据从一种类型转换为另一种类型。主要包括以下几种方式:
1. 隐式类型转换(自动转换)
JavaScript 会在需要时自动进行类型转换,例如在运算、比较等操作中。
- 字符串拼接:当与字符串进行拼接时,其他类型会被自动转换为字符串。
let num = 5;
let str = 'The number is ' + num; // 自动转换为 'The number is 5'
- 数值运算:在进行数值运算时,字符串会被自动转换为数值(如果可能的话)。
let result = '10' - 5; // '10' 被转换为数字 10,结果为 5
- 布尔值转换:在布尔上下文中(如条件判断),值会被自动转换为布尔值。
if ('text') { // 非空字符串被转换为 true
console.log('True');
}
2. 显式类型转换(手动转换)
JavaScript 提供了多种方法进行显式的类型转换:
-
字符串转换:
String():将其他类型的值转换为字符串。
let num = 123; let str = String(num); // '123'toString():对象和原始值(除了null和undefined)都有这个方法。
let num = 456; let str = num.toString(); // '456' -
数值转换:
Number():将其他类型的值转换为数值。
let str = '123'; let num = Number(str); // 123parseInt():将字符串解析为整数,支持基数(如二进制、十六进制)。
let str = '42'; let num = parseInt(str, 10); // 42parseFloat():将字符串解析为浮点数。
let str = '3.14'; let num = parseFloat(str); // 3.14 -
布尔值转换:
Boolean():将其他类型的值转换为布尔值。
let num = 0; let bool = Boolean(num); // false!!:将其他类型的值转换为布尔值。
let num = 0; let bool = !!num ; // false
3. 特殊情况
-
+操作符:- 如果操作数中有字符串,
+操作符会将其他操作数转换为字符串。
let result = 5 + '5'; // '55' - 如果操作数中有字符串,
-
==操作符:- 在进行非严格比较时(
==),JavaScript 会进行类型转换以尝试比较不同类型的值。
console.log(5 == '5'); // true,因为 '5' 被转换为数字 5 - 在进行非严格比较时(
总结
- 隐式转换:JavaScript 自动进行的类型转换,通常发生在运算和比较时。
- 显式转换:使用
String(),Number(),Boolean(),parseInt(),parseFloat()等函数手动转换数据类型。 - 特殊情况:某些操作符(如
+和==)会引发自动类型转换。
==、 === 和 Object.is() 的区别是什么?
==、=== 和 Object.is() 是 JavaScript 中用于比较值的操作符和方法,它们的行为有所不同:
1. == (相等操作符)
-
类型转换:
==操作符在比较时会进行类型转换。如果两个值的类型不同,JavaScript 会尝试将它们转换为相同的类型,然后再进行比较。
-
行为:
- 数字和字符串比较:字符串会被转换为数字。
null和undefined相等:null == undefined是true。- 布尔值比较:布尔值会被转换为数字(
true转换为1,false转换为0)。 - 对象与原始值比较:对象会被转换为布尔值
true,并进行进一步比较。
-
示例:
console.log(5 == '5'); // true,因为 '5' 被转换为数字 5
console.log(null == undefined); // true
console.log(0 == false); // true,因为 false 被转换为数字 0
2. === (严格相等操作符)
-
类型不转换:
===操作符在比较时不会进行类型转换。如果两个值的类型不同,它们被认为是不相等的。
-
行为:
- 值和类型都必须相等才会返回
true。 - 对象的引用比较:两个对象只有在引用相同的内存地址时才被认为相等。
- 值和类型都必须相等才会返回
-
示例:
console.log(5 === '5'); // false,因为类型不同
console.log(null === undefined); // false,因为类型不同
console.log(0 === false); // false,因为类型不同
3. Object.is()
-
无类型转换:
Object.is()进行精确的值比较,没有任何类型转换。
-
行为:
- 对比
NaN:Object.is(NaN, NaN)是true(与===不同,NaN与NaN在严格相等比较中是false)。 - 对比
-0和+0:Object.is(-0, +0)是false(与===不同,-0和+0在严格相等比较中是true)。
- 对比
-
示例:
console.log(Object.is(5, 5)); // true
console.log(Object.is(5, '5')); // false
console.log(Object.is(NaN, NaN)); // true
console.log(Object.is(-0, +0)); // false
总结
==:相等操作符,进行类型转换后比较值,可能会导致意外结果。===:严格相等操作符,不进行类型转换,值和类型必须完全相等。Object.is():精确比较两个值,处理NaN和-0/+0的比较与===不同,适用于需要精准比较的场景。
JavaScript 判断数据类型有哪些方法?
1. typeof 操作符
- 用途:用于判断原始数据类型(如
number、string、boolean等)和对象类型(如object和function)。 - 用法:
console.log(typeof 123); // 'number'
console.log(typeof 'hello'); // 'string'
console.log(typeof true); // 'boolean'
console.log(typeof undefined); // 'undefined'
console.log(typeof null); // 'object' (历史遗留问题)
console.log(typeof { name: 'Alice' }); // 'object'
console.log(typeof function() {}); // 'function'
2. instanceof 操作符
- 用途:用于判断一个对象是否是某个构造函数的实例,或是否继承自某个构造函数的原型链。
- 用法:
let arr = [1, 2, 3];
console.log(arr instanceof Array); // true
let date = new Date();
console.log(date instanceof Date); // true
console.log(arr instanceof Object); // true
3. Object.prototype.toString.call() 方法
- 用途:提供更精确的对象类型判断,特别是在区分
null、Array和其他对象类型时非常有用。 - 用法:
console.log(Object.prototype.toString.call(123)); // '[object Number]'
console.log(Object.prototype.toString.call('hello')); // '[object String]'
console.log(Object.prototype.toString.call(true)); // '[object Boolean]'
console.log(Object.prototype.toString.call(undefined)); // '[object Undefined]'
console.log(Object.prototype.toString.call(null)); // '[object Null]'
console.log(Object.prototype.toString.call([1, 2, 3])); // '[object Array]'
console.log(Object.prototype.toString.call({ name: 'Alice' })); // '[object Object]'
console.log(Object.prototype.toString.call(function() {})); // '[object Function]'
4. Array.isArray() 方法
- 用途:专门用于判断一个值是否为数组。
- 用法:
console.log(Array.isArray([1, 2, 3])); // true
console.log(Array.isArray({})); // false
console.log(Array.isArray('hello')); // false
5. Number.isNaN() 方法
- 用途:专门用于判断一个值是否是
NaN,避免isNaN()的全局函数带来的误判。 - 用法:
console.log(Number.isNaN(NaN)); // true
console.log(Number.isNaN(123)); // false
console.log(Number.isNaN('NaN')); // false
6. typeof 与 constructor
- 用途:结合使用
typeof和constructor属性来判断类型。 - 用法:
let num = 123;
console.log(num.constructor === Number); // true
let str = 'hello';
console.log(str.constructor === String); // true
7. isFinite() 函数
- 用途:判断一个值是否是有限数值,不包括
Infinity、-Infinity和NaN。 - 用法:
console.log(isFinite(123)); // true
console.log(isFinite(Infinity)); // false
console.log(isFinite(NaN)); // false
8. isInteger() 方法
- 用途:判断一个值是否为整数。
- 用法:
console.log(Number.isInteger(123)); // true
console.log(Number.isInteger(123.45)); // false
总结
typeof:适用于基本数据类型和函数判断,但对null和数组不够准确。instanceof:用于判断对象是否是某个构造函数的实例,适用于复杂类型的判断。Object.prototype.toString.call():用于更精确地判断对象类型,包括区分null和数组。Array.isArray():专门用于判断是否为数组。Number.isNaN():用于判断是否为NaN,避免全局isNaN的误判。isFinite()和isInteger():用于判断数值的具体特性。
第三章 操作符
||= 、&&= 和 ??= 是什么?
||=、&&=, 和??=是 JavaScript 中的逻辑赋值操作符,这些操作符提供了一种简洁的方式来执行逻辑操作并赋值。它们是在 ECMAScript 2021(ES12)中引入的。
1. ||= (逻辑或赋值操作符)
-
用途:如果左侧的操作数是假值(falsy),则将右侧的值赋给左侧的操作数。
-
行为:
- 如果左侧的值为假(例如
0、""、null、undefined、false),则将右侧的值赋给左侧的操作数。 - 如果左侧的值为真(truthy),则左侧的值保持不变。
- 如果左侧的值为假(例如
-
示例:
let a = 0;
a ||= 5; // 因为 a 是假值 0,a 现在变为 5
console.log(a); // 输出:5
let b = 10;
b ||= 20; // 因为 b 是真值 10,b 保持不变
console.log(b); // 输出:10
2. &&= (逻辑与赋值操作符)
-
用途:如果左侧的操作数是真值(truthy),则将右侧的值赋给左侧的操作数。
-
行为:
- 如果左侧的值为真,则将右侧的值赋给左侧的操作数。
- 如果左侧的值为假,则左侧的值保持不变。
-
示例:
let x = 5;
x &&= 10; // 因为 x 是真值 5,x 现在变为 10
console.log(x); // 输出:10
let y = 0;
y &&= 20; // 因为 y 是假值 0,y 保持不变
console.log(y); // 输出:0
3. ??= (空值合并赋值操作符)
-
用途:如果左侧的操作数是
null或undefined,则将右侧的值赋给左侧的操作数。 -
行为:
- 如果左侧的值是
null或undefined,则将右侧的值赋给左侧的操作数。 - 如果左侧的值既不是
null也不是undefined,则左侧的值保持不变。
- 如果左侧的值是
-
示例:
let p = null;
p ??= 10; // 因为 p 是 null,p 现在变为 10
console.log(p); // 输出:10
let q = 20;
q ??= 30; // 因为 q 不是 null 或 undefined,q 保持不变
console.log(q); // 输出:20
总结
||=:仅当左侧的值为假值时,才将右侧的值赋给左侧的操作数。&&=:仅当左侧的值为真值时,才将右侧的值赋给左侧的操作数。??=:仅当左侧的值为null或undefined时,才将右侧的值赋给左侧的操作数。
可选链 ?. 有什么用?
可选链操作符(
?.)是 JavaScript 的一个重要特性,提供了一种简洁的方式来安全地访问嵌套对象属性。它是在 ECMAScript 2020(ES11)中引入的。其主要用途是在访问对象属性时避免因中间某个属性为null或undefined而导致的错误。
用途和行为
-
避免中间值为
null或undefined的错误:- 当访问深层嵌套的对象属性时,如果中间某个属性可能为
null或undefined,使用可选链可以避免出现运行时错误。
- 当访问深层嵌套的对象属性时,如果中间某个属性可能为
-
简化代码:
- 在传统的访问方式中,通常需要多重条件检查来确保每一级属性都存在。使用可选链可以简化代码,减少显式的检查。
语法和示例
-
属性访问:
let user = { profile: { name: 'Alice' } }; console.log(user.profile?.name); // 输出:'Alice' let user2 = { profile: null }; console.log(user2.profile?.name); // 输出:undefined- 使用
?.可以安全地访问对象的嵌套属性。如果中间某个属性是null或undefined,整个表达式的结果会是undefined,而不会抛出错误。
- 使用
-
方法调用:
let obj = { greet: function() { return 'Hello!'; } }; console.log(obj.greet?.()); // 输出:'Hello!' let obj2 = {}; console.log(obj2.greet?.()); // 输出:undefined- 使用
?.可以安全地调用对象的方法。如果方法不存在(即属性为null或undefined),则不会抛出错误,而是返回undefined。
- 使用
-
数组索引:
let arr = [1, 2, 3]; console.log(arr?.[1]); // 输出:2 let arr2 = null; console.log(arr2?.[1]); // 输出:undefined- 使用
?.可以安全地访问数组的元素。如果数组为null或undefined,则不会抛出错误。
- 使用
-
链式调用:
let data = { user: { profile: { email: 'alice@example.com' } } }; console.log(data.user?.profile?.email); // 输出:'alice@example.com' let data2 = { user: null }; console.log(data2.user?.profile?.email); // 输出:undefined- 可选链可以与其他操作符结合使用,实现复杂的链式访问。
总结
- 安全访问:可选链操作符可以安全地访问对象的嵌套属性、调用方法或访问数组元素,避免因中间属性为
null或undefined导致的错误。 - 简化代码:减少了显式的条件检查,使代码更加简洁和易读。
- 适用场景:特别适用于处理动态或不确定的对象结构,例如在处理 API 响应时。
第四章 对象
JavaScript 创建对象有哪些方式?
1. 字面量方式
- 语法:
let obj = {
key1: value1,
key2: value2,
// 可以包含方法
methodName() {
// 方法体
}
};
-
特点:
- 简单直接,适用于创建静态对象。
- 支持定义属性和方法。
-
示例:
let person = {
name: 'Alice',
age: 25,
greet() {
return `Hello, my name is ${this.name}`;
}
};
2. 构造函数方式
- 语法:
function Person(name, age) {
this.name = name;
this.age = age;
this.greet = function() {
return `Hello, my name is ${this.name}`;
};
}
let alice = new Person('Alice', 25);
-
特点:
- 适用于创建具有相似结构的多个对象。
- 可以通过
new关键字实例化对象。
-
示例:
function Car(make, model) {
this.make = make;
this.model = model;
}
let myCar = new Car('Toyota', 'Corolla');
3. Object.create() 方法
- 语法:
let obj = Object.create(prototypeObject, {
property1: {
value: value1,
enumerable: true,
writable: true,
configurable: true
}
// 可以添加更多属性
});
-
特点:
- 创建一个新对象,使用指定的原型对象(
prototypeObject)。 - 可以定义新对象的属性和方法。
- 创建一个新对象,使用指定的原型对象(
-
示例:
let proto = {
greet() {
return `Hello, ${this.name}`;
}
};
let obj = Object.create(proto);
obj.name = 'Alice';
4. class 语法(ES6+)
- 语法:
class Person {
constructor(name, age) {
this.name = name;
this.age = age;
}
greet() {
return `Hello, my name is ${this.name}`;
}
}
let alice = new Person('Alice', 25);
-
特点:
- 提供面向对象的语法,支持继承和方法。
- 是基于构造函数的语法糖,使代码更具可读性。
-
示例:
class Animal {
constructor(name) {
this.name = name;
}
speak() {
console.log(`${this.name} makes a noise.`);
}
}
class Dog extends Animal {
speak() {
console.log(`${this.name} barks.`);
}
}
let dog = new Dog('Rex');
dog.speak(); // 输出:Rex barks.
5. Object 构造函数
- 语法:
let obj = new Object();
obj.key1 = value1;
obj.key2 = value2;
-
特点:
- 类似于字面量方式,但通过
new Object()创建。 - 通常不如字面量方式直观。
- 类似于字面量方式,但通过
-
示例:
let person = new Object();
person.name = 'Alice';
person.age = 25;
person.greet = function() {
return `Hello, my name is ${this.name}`;
};
总结
- 字面量方式:直接创建对象,适合静态对象。
- 构造函数方式:适合创建多个结构相似的对象。
Object.create()方法:创建具有指定原型的对象,灵活性高。class语法:提供面向对象编程的语法糖,支持继承和方法。Object构造函数:创建空对象并添加属性,通常不如字面量方式直观。
如何理解继承和原型链?
原型链
原型链是 JavaScript 对象继承机制的基础,允许对象通过链式查找的方式访问其父对象的属性和方法。
基本概念
- 每个对象都有一个内部属性
[[Prototype]],它指向另一个对象(即对象的原型)。可以通过Object.getPrototypeOf(obj)获取一个对象的原型,或者使用obj.__proto__(不推荐)。 - 对象的原型本身也有一个原型,这种链式关系可以一直延续,直到遇到
null。这个链式结构就是原型链。 - 当访问对象的属性或方法时,如果对象自身没有该属性或方法,JavaScript 会沿着原型链向上查找,直到找到该属性/方法或者到达原型链的末端(
null)。
示例
// 定义一个对象
let animal = {
eats: true
};
// 定义另一个对象,animal 是它的原型
let rabbit = Object.create(animal);
rabbit.hops = true;
console.log(rabbit.eats); // true, 从 animal 继承
console.log(rabbit.hops); // true, rabbit 自身的属性
console.log(Object.getPrototypeOf(rabbit) === animal); // true
继承
继承是在 JavaScript 中实现对象间共享属性和方法的一种机制。它可以通过原型链来实现。
构造函数和原型继承
-
构造函数继承:通过构造函数创建的对象可以继承其他对象的属性和方法。构造函数可以设置原型来实现继承。
-
方法:
- 设置原型:通过修改构造函数的
prototype属性来实现继承。 Object.create()方法:创建一个具有指定原型的新对象,从而实现继承。- ES6 类:使用
class语法更直观地实现继承。
- 设置原型:通过修改构造函数的
示例
- 构造函数继承:
function Animal(name) {
this.name = name;
}
Animal.prototype.eat = function() {
console.log(`${this.name} eats.`);
};
function Dog(name) {
Animal.call(this, name); // 继承属性
}
Dog.prototype = Object.create(Animal.prototype); // 继承方法
Dog.prototype.constructor = Dog; // 修正 constructor
Dog.prototype.bark = function() {
console.log(`${this.name} barks.`);
};
let dog = new Dog('Rex');
dog.eat(); // Rex eats.
dog.bark(); // Rex barks.
- ES6 类继承:
class Animal {
constructor(name) {
this.name = name;
}
eat() {
console.log(`${this.name} eats.`);
}
}
class Dog extends Animal {
bark() {
console.log(`${this.name} barks.`);
}
}
let dog = new Dog('Rex');
dog.eat(); // Rex eats.
dog.bark(); // Rex barks.
总结
- 原型链:是 JavaScript 的对象继承机制的底层实现方式,通过原型链,JavaScript 可以在对象上查找属性和方法,直到找到为止。
- 继承:利用原型链实现的机制,允许对象之间共享属性和方法。可以通过构造函数、
Object.create()、以及 ES6 类语法来实现。
继承有哪几种方式?
1. 构造函数继承
构造函数继承是最传统的继承方式,通过构造函数创建对象,并在子类构造函数中调用父类构造函数来继承属性。
-
特点:
- 继承父类的实例属性。
- 不继承父类原型上的方法。
-
示例:
function Animal(name) {
this.name = name;
}
Animal.prototype.eat = function() {
console.log(`${this.name} eats.`);
};
function Dog(name) {
Animal.call(this, name); // 继承实例属性
}
let dog = new Dog('Rex');
console.log(dog.name); // Rex
dog.eat(); // 运行时错误:Dog 没有继承 Animal.prototype.eat 方法
2. 原型链继承
原型链继承是通过将子类的原型设置为父类的实例来实现的,这样子类可以访问父类原型上的属性和方法。
-
特点:
- 继承父类原型上的方法和属性。
- 子类实例共享父类实例属性(如果父类实例属性是对象)。
-
示例:
function Animal(name) {
this.name = name;
}
Animal.prototype.eat = function() {
console.log(`${this.name} eats.`);
};
function Dog(name) {}
Dog.prototype = new Animal(); // 原型链继承
let dog = new Dog('Rex');
console.log(dog.name); // Rex
dog.eat(); // Rex eats.
3. 组合继承
组合继承结合了构造函数继承和原型链继承,既可以继承父类的实例属性,又可以继承父类的原型方法。
-
特点:
- 继承父类的实例属性和原型方法。
- 解决了原型链继承中实例属性共享的问题。
-
示例:
function Animal(name) {
this.name = name;
}
Animal.prototype.eat = function() {
console.log(`${this.name} eats.`);
};
function Dog(name, breed) {
Animal.call(this, name); // 继承实例属性
this.breed = breed;
}
Dog.prototype = Object.create(Animal.prototype); // 继承原型方法
Dog.prototype.constructor = Dog; // 修正 constructor
let dog = new Dog('Rex', 'Golden Retriever');
console.log(dog.name); // Rex
console.log(dog.breed); // Golden Retriever
dog.eat(); // Rex eats.
4. 寄生继承
寄生继承通过使用一个函数来创建一个继承自父类实例的新对象,然后用这个对象来作为子类的原型。
-
特点:
- 实现简单,适合轻量级的继承需求。
- 不如组合继承全面。
-
示例:
function createObject(proto) {
function F() {}
F.prototype = proto;
return new F();
}
function Animal(name) {
this.name = name;
}
Animal.prototype.eat = function() {
console.log(`${this.name} eats.`);
};
function Dog(name, breed) {
Animal.call(this, name);
this.breed = breed;
}
Dog.prototype = createObject(Animal.prototype); // 寄生继承
Dog.prototype.constructor = Dog; // 修正 constructor
let dog = new Dog('Rex', 'Golden Retriever');
console.log(dog.name); // Rex
console.log(dog.breed); // Golden Retriever
dog.eat(); // Rex eats.
5. ES6 类继承
ES6 引入了 class 语法,使得继承变得更直观和易读。通过 extends 关键字可以实现类的继承。
-
特点:
- 提供了更简洁的语法来实现继承。
- 支持类的构造函数、方法、静态方法等。
-
示例:
class Animal {
constructor(name) {
this.name = name;
}
eat() {
console.log(`${this.name} eats.`);
}
}
class Dog extends Animal {
constructor(name, breed) {
super(name); // 调用父类构造函数
this.breed = breed;
}
bark() {
console.log(`${this.name} barks.`);
}
}
let dog = new Dog('Rex', 'Golden Retriever');
console.log(dog.name); // Rex
console.log(dog.breed); // Golden Retriever
dog.eat(); // Rex eats.
dog.bark(); // Rex barks.
总结
- 构造函数继承:继承实例属性。
- 原型链继承:继承原型上的属性和方法。
- 组合继承:结合构造函数和原型链继承,全面继承。
- 寄生继承:使用一个函数来实现继承,简单但不全面。
- ES6 类继承:提供现代化的继承语法,直观且功能丰富。
如何判断一个对象属于某个类?
1. instanceof 运算符
- 用途:判断一个对象是否是某个构造函数的实例,或者是否继承自某个构造函数的原型。
- 语法:
object instanceof Constructor - 示例:
class Animal {
constructor(name) {
this.name = name;
}
}
class Dog extends Animal {
constructor(name, breed) {
super(name);
this.breed = breed;
}
}
let dog = new Dog('Rex', 'Golden Retriever');
console.log(dog instanceof Dog); // true
console.log(dog instanceof Animal); // true
console.log(dog instanceof Object); // true
2. constructor 属性
- 用途:通过检查对象的
constructor属性来判断其是否属于某个类。 - 语法:
object.constructor === Constructor - 示例:
class Animal {
constructor(name) {
this.name = name;
}
}
class Dog extends Animal {
constructor(name, breed) {
super(name);
this.breed = breed;
}
}
let dog = new Dog('Rex', 'Golden Retriever');
console.log(dog.constructor === Dog); // true
console.log(dog.constructor === Animal); // false
3. Object.getPrototypeOf 方法
- 用途:通过检查对象的原型链是否包含某个类的原型来判断。
- 语法:
Object.getPrototypeOf(object) === Constructor.prototype - 示例:
class Animal {
constructor(name) {
this.name = name;
}
}
class Dog extends Animal {
constructor(name, breed) {
super(name);
this.breed = breed;
}
}
let dog = new Dog('Rex', 'Golden Retriever');
console.log(Object.getPrototypeOf(dog) === Dog.prototype); // true
console.log(Object.getPrototypeOf(dog) === Animal.prototype); // false
4. Symbol.hasInstance 方法
- 用途:自定义类的
Symbol.hasInstance方法以实现自定义的实例判断逻辑。 - 语法:
Constructor[Symbol.hasInstance](object) - 示例:
class Animal {
constructor(name) {
this.name = name;
}
}
class Dog extends Animal {
static [Symbol.hasInstance](instance) {
return instance instanceof Animal && instance.breed !== undefined;
}
}
let dog = new Dog('Rex', 'Golden Retriever');
console.log(dog instanceof Dog); // true (自定义判断逻辑)
总结
instanceof运算符:最常用且直观的方式,检查对象是否为构造函数的实例。constructor属性:检查对象的构造函数是否与目标类匹配。Object.getPrototypeOf方法:检查对象的原型是否与目标类的原型匹配。Symbol.hasInstance方法:用于自定义实例检查逻辑。
Map 和 WeakMap 有什么区别?
Map和WeakMap都是 JavaScript 中用于存储键值对的集合类型,但它们有一些重要的区别,这些区别主要体现在键的类型、垃圾回收和性能等方面。
1. 键的类型
-
Map:- 可以使用任何类型的值作为键,包括对象、原始数据类型(如
number、string、boolean)、函数等。 - 示例:
- 可以使用任何类型的值作为键,包括对象、原始数据类型(如
let map = new Map();
map.set('key', 'value');
map.set(1, 'number');
map.set({}, 'object');
-
WeakMap:- 只能使用对象作为键(
null不允许作为键),不能使用原始数据类型。 - 示例:
- 只能使用对象作为键(
let weakMap = new WeakMap();
let obj = {};
weakMap.set(obj, 'value');
// weakMap.set('key', 'value'); // TypeError: Invalid value used as weak map key
2. 垃圾回收
-
Map:Map对其键值对持有强引用,即使键对象不再被使用,Map中的键值对也不会被垃圾回收。- 示例:
let map = new Map();
let obj = {};
map.set(obj, 'value');
obj = null; // obj 对象不再被引用,但 map 依然保持对 obj 的引用
-
WeakMap:WeakMap对键对象持有弱引用,这意味着如果一个对象作为键的引用被销毁,WeakMap可以自动将该键值对从集合中移除,帮助进行垃圾回收。- 示例:
let weakMap = new WeakMap();
let obj = {};
weakMap.set(obj, 'value');
obj = null; // obj 对象被垃圾回收,weakMap 也会自动删除该键值对
3. 迭代
-
Map:- 支持迭代,使用
forEach方法或者for...of循环可以遍历所有的键值对。 - 示例:
- 支持迭代,使用
let map = new Map();
map.set('key1', 'value1');
map.set('key2', 'value2');
for (let [key, value] of map) {
console.log(key, value);
}
// 输出:
// key1 value1
// key2 value2
-
WeakMap:- 不支持迭代,因为
WeakMap的键值对是弱引用的,可能在任何时候被垃圾回收,因此不能提供完整的迭代接口。
- 不支持迭代,因为
4. 应用场景
-
Map:- 适用于需要持久存储键值对的场景,支持各种数据类型作为键。
- 常用于缓存数据、对象属性存储等。
-
WeakMap:- 适用于存储对象的元数据或关联数据,尤其是当你希望在对象被垃圾回收时自动移除对应的值时。
- 常用于实现私有属性、缓存对象状态等。
总结
- 键类型:
Map可以使用任何类型的键,WeakMap只能使用对象作为键。 - 垃圾回收:
Map持有强引用,WeakMap持有弱引用,适合对象自动垃圾回收场景。 - 迭代:
Map支持迭代,WeakMap不支持。 - 应用场景:
Map适用于各种持久存储需求,WeakMap适合需要与对象生命周期同步的情况。
如何实现深拷贝和浅拷贝?
浅拷贝
浅拷贝仅复制对象的第一层属性,对于嵌套的对象属性,它们仍然引用原始对象中的同一个引用。换句话说,浅拷贝不会复制嵌套对象的内容,而是复制它们的引用。
常用方法
-
Object.assign()- 用途:复制一个对象的属性到另一个对象中。
- 示例:
let original = { a: 1, b: { c: 2 } };
let copy = Object.assign({}, original);
copy.a = 10;
copy.b.c = 20; // 修改嵌套对象会影响原对象
console.log(original.a); // 1
console.log(original.b.c); // 20
-
扩展运算符(Spread Operator)
- 用途:将对象的属性展开到新的对象中。
- 示例:
let original = { a: 1, b: { c: 2 } };
let copy = { ...original };
copy.a = 10;
copy.b.c = 20; // 修改嵌套对象会影响原对象
console.log(original.a); // 1
console.log(original.b.c); // 20
深拷贝
深拷贝会递归地复制对象的所有层级,包括嵌套的对象。每个层级的对象都会创建新的副本,修改副本不会影响原对象。
常用方法
-
JSON.parse()和JSON.stringify()-
用途:将对象序列化为 JSON 字符串,然后再解析为新的对象。
-
限制:
- 不能复制函数、
undefined、Symbol、Date、RegExp、Map、Set和循环引用的对象。
- 不能复制函数、
-
示例:
-
let original = { a: 1, b: { c: 2 } };
let copy = JSON.parse(JSON.stringify(original));
copy.a = 10;
copy.b.c = 20; // 修改嵌套对象不会影响原对象
console.log(original.a); // 1
console.log(original.b.c); // 2
-
递归函数
- 用途:手动编写深拷贝逻辑,能够处理更多数据类型和复杂的对象。
- 示例:
function deepClone(obj, map = new WeakMap()) {
if (obj === null || typeof obj !== 'object') {
return obj; // 基本数据类型或 null
}
if (obj instanceof Date) {
return new Date(obj);
}
if (obj instanceof RegExp) {
return new RegExp(obj);
}
if (map.has(obj)) {
return map.get(obj); // 循环引用处理
}
let copy = Array.isArray(obj) ? [] : {};
map.set(obj, copy);
Object.keys(obj).forEach(key => {
copy[key] = deepClone(obj[key], map);
});
return copy;
}
let original = { a: 1, b: { c: 2 } };
let copy = deepClone(original);
copy.a = 10;
copy.b.c = 20; // 修改嵌套对象不会影响原对象
console.log(original.a); // 1
console.log(original.b.c); // 2
总结
-
浅拷贝:
- 使用
Object.assign()或扩展运算符(Spread Operator)。 - 仅复制第一层属性,嵌套对象的引用保持不变。
- 使用
-
深拷贝:
- 使用
JSON.parse()和JSON.stringify(),适用于简单对象。 - 使用自定义递归函数,适用于更复杂的对象,能够处理更多数据类型和循环引用。
- 使用
第五章 函数
什么是闭包?
闭包(Closure)是 JavaScript 中的一个重要概念,它允许一个函数访问和操作函数外部的变量,即使在外部函数已经执行完毕之后。闭包是通过函数作用域和词法作用域机制实现的。
定义
闭包是指一个函数和其相关的变量环境的组合。在 JavaScript 中,当一个函数定义在另一个函数内部时,这个内部函数就可以访问外部函数的变量,即使外部函数已经执行完毕。
工作原理
- 词法作用域:函数在定义时会绑定它的作用域链,即它可以访问的变量。
- 闭包:内部函数可以访问定义在外部函数中的变量,并且这些变量会保存在闭包中。
示例
function outer() {
let outerVar = 'I am from outer';
function inner() {
console.log(outerVar); // 访问外部函数的变量
}
return inner; // 返回内部函数,形成闭包
}
let innerFunc = outer(); // 调用外部函数,得到内部函数
innerFunc(); // 输出: 'I am from outer'
特点
- 保留变量环境:即使外部函数已经执行完毕,闭包依然保留了对其变量的访问权限。
- 数据封装:闭包可以封装私有数据,防止外部访问。
应用场景
-
私有变量:
- 通过闭包可以创建私有变量和方法,防止外部直接访问或修改。
- 示例:
function createCounter() {
let count = 0;
return {
increment: function() {
count++;
console.log(count);
},
decrement: function() {
count--;
console.log(count);
}
};
}
let counter = createCounter();
counter.increment(); // 1
counter.increment(); // 2
counter.decrement(); // 1
-
回调函数:
- 闭包常用于回调函数中,保持对外部状态的引用。
- 示例:
function makeCounter() {
let count = 0;
return function() {
count++;
return count;
};
}
let counter = makeCounter();
console.log(counter()); // 1
console.log(counter()); // 2
总结
闭包是函数和其词法作用域的组合,允许内部函数访问外部函数的变量。它在数据封装、回调函数和创建私有变量等场景中非常有用。通过理解闭包,可以更好地控制变量的作用域和生命周期。
this 的指向有哪些?
在 JavaScript 中,this 的指向是一个常见且复杂的概念,主要取决于 this 被调用的上下文。以下是 this 指向的几种常见情况:
1. 全局上下文
- 指向:在全局上下文中(即在函数之外),
this指向全局对象(在浏览器中是window,在 Node.js 中是global)。 - 示例:
console.log(this); // 在浏览器中输出: Window
2. 函数上下文
-
普通函数:
- 指向:在普通函数中,
this指向全局对象(在严格模式下是undefined)。 - 示例:
- 指向:在普通函数中,
function show() {
console.log(this);
}
show(); // 在浏览器中输出: Window(严格模式下输出: undefined)
-
箭头函数:
- 指向:箭头函数不具有自己的
this,它会继承外部函数或全局上下文的this。 - 示例:
- 指向:箭头函数不具有自己的
function outer() {
const arrow = () => {
console.log(this);
};
arrow();
}
outer(); // 输出:在浏览器中为全局对象(如果 outer 是全局函数),否则为 outer 函数的 `this`
3. 对象方法
- 指向:当
this出现在对象的方法中时,它指向调用该方法的对象。 - 示例:
const obj = {
name: 'Alice',
greet: function() {
console.log(this.name);
}
};
obj.greet(); // 输出: Alice
4. 构造函数
- 指向:在构造函数中,
this指向新创建的实例对象。 - 示例:
function Person(name) {
this.name = name;
}
const person = new Person('Bob');
console.log(person.name); // 输出: Bob
5. call 和 apply 方法
- 指向:
call和apply方法可以显式地设置this的值。 - 示例:
function greet() {
console.log(this.name);
}
const obj = { name: 'Charlie' };
greet.call(obj); // 输出: Charlie
6. bind 方法
- 指向:
bind方法创建一个新的函数,在该函数中this被固定为传入的值。 - 示例:
function greet() {
console.log(this.name);
}
const obj = { name: 'Dana' };
const boundGreet = greet.bind(obj);
boundGreet(); // 输出: Dana
7. class 方法
- 指向:在 ES6 类的方法中,
this指向类的实例对象。注意,在使用箭头函数作为类方法时,this会继承外部上下文。 - 示例:
class Animal {
constructor(name) {
this.name = name;
}
speak() {
console.log(this.name);
}
}
const animal = new Animal('Elephant');
animal.speak(); // 输出: Elephant
总结
- 全局上下文:
this指向全局对象(在严格模式下为undefined)。 - 普通函数:
this指向全局对象(在严格模式下为undefined)。 - 箭头函数:
this继承外部函数或全局上下文的this。 - 对象方法:
this指向调用方法的对象。 - 构造函数:
this指向新创建的实例对象。 call和apply:显式设置this。bind:创建一个新函数,固定this为指定值。- 类方法:
this指向类的实例对象。
类数组的转化方式有哪些?
在 JavaScript 中,类数组对象(
arguments、NodeList、HTMLCollection等)是一种具有length属性和按索引访问元素的对象,但不具有数组的方法。将类数组对象转换为真正的数组可以使用多种方法。
1. 使用 Array.from()
- 用途:将类数组对象转换为数组。
- 示例:
let args = (function() {
return arguments;
})(1, 2, 3);
let arr = Array.from(args);
console.log(arr); // 输出: [1, 2, 3]
2. 使用扩展运算符(Spread Operator)
- 用途:利用扩展运算符将类数组对象展开为数组。
- 示例:
let nodeList = document.querySelectorAll('div');
let arr = [...nodeList];
console.log(arr); // 输出: 一个包含所有 div 元素的数组
3. 使用 Array.prototype.slice.call()
- 用途:通过
Array.prototype.slice方法将类数组对象转换为数组。 - 示例:
let args = (function() {
return arguments;
})(1, 2, 3);
let arr = Array.prototype.slice.call(args);
console.log(arr); // 输出: [1, 2, 3]
4. 使用 Array.prototype.slice.apply()
- 用途:通过
apply方法将Array.prototype.slice应用于类数组对象。 - 示例:
let nodeList = document.querySelectorAll('div');
let arr = Array.prototype.slice.apply(nodeList);
console.log(arr); // 输出: 一个包含所有 div 元素的数组
5. 使用 Array.prototype.concat()
- 用途:通过
concat方法将类数组对象转换为数组。此方法的this指向一个空数组。 - 示例:
let args = (function() {
return arguments;
})(1, 2, 3);
let arr = [].concat.call([], args);
console.log(arr); // 输出: [1, 2, 3]
总结
Array.from()和 扩展运算符 是现代和简洁的方法,推荐使用。Array.prototype.slice.call()和Array.prototype.slice.apply()是老旧但有效的方法,兼容性好。Array.prototype.concat()也是一种经典的方法,但略显繁琐。
如何模拟实现函数方法:call()、apply()、bind()?
1. Function.prototype.call()
call() 方法调用一个具有给定 this 值的函数,和参数一起传递。
模拟实现
Function.prototype.myCall = function(context, ...args) {
context = context || globalThis; // `globalThis` 是全局对象(浏览器中为 `window`,Node.js 中为 `global`)
const fn = this; // `this` 是调用 `myCall` 的函数
context.fn = fn; // 将函数赋值给 context 上的新属性
const result = context.fn(...args); // 调用函数,并传递参数
delete context.fn; // 删除临时属性
return result; // 返回函数执行结果
};
// 示例
function greet(greeting, punctuation) {
console.log(`${greeting}, ${this.name}${punctuation}`);
}
const person = { name: 'Alice' };
greet.myCall(person, 'Hello', '!'); // 输出: Hello, Alice!
2. Function.prototype.apply()
apply() 方法调用一个具有给定 this 值的函数,和一个数组(或类似数组对象)作为参数。
模拟实现
Function.prototype.myApply = function(context, args) {
context = context || globalThis;
const fn = this;
context.fn = fn; // 将函数赋值给 context 上的新属性
const result = context.fn(...(args || [])); // 使用展开运算符传递参数数组
delete context.fn; // 删除临时属性
return result; // 返回函数执行结果
};
// 示例
function greet(greeting, punctuation) {
console.log(`${greeting}, ${this.name}${punctuation}`);
}
const person = { name: 'Bob' };
greet.myApply(person, ['Hi', '?']); // 输出: Hi, Bob?
3. Function.prototype.bind()
bind() 方法创建一个新函数,当被调用时,它将 this 关键字设置为提供的值,并且预置部分参数。
模拟实现
Function.prototype.myBind = function(context, ...boundArgs) {
const fn = this;
// 返回一个新函数
return function(...args) {
// 使用 `myCall` 方法来调用 `fn`,`this` 是新的上下文
return fn.myCall(context, ...boundArgs, ...args);
};
};
// 示例
function greet(greeting, punctuation) {
console.log(`${greeting}, ${this.name}${punctuation}`);
}
const person = { name: 'Charlie' };
const greetCharlie = greet.myBind(person, 'Hello');
greetCharlie('!'); // 输出: Hello, Charlie!
总结
call:调用函数,设置this值,逐个传递参数。apply:调用函数,设置this值,通过数组传递参数。bind:创建一个新函数,预置this值和部分参数。
立即调用函数表达式(IIFE)有什么特点?
立即调用函数表达式(IIFE, Immediately Invoked Function Expression)是一种 JavaScript 编程模式,用于创建一个自执行的函数。
1. 自执行
IIFE 在定义后立即执行。它是一个函数表达式,不是函数声明,因此可以立即调用。
2. 创建私有作用域
通过 IIFE 可以创建一个私有作用域,避免了与外部作用域的变量冲突。它可以用于封装代码,隐藏实现细节。
3. 避免全局污染
IIFE 常用于避免将变量和函数污染到全局作用域中,保护全局命名空间。
4. 使用函数表达式
IIFE 使用函数表达式而不是函数声明,因为函数声明会被提升(hoisted),而函数表达式在定义时不会被提升。
5. 支持参数传递
IIFE 可以接受参数,并在内部执行相应的操作。
示例
基本用法:
(function() {
const message = 'Hello, World!';
console.log(message); // 输出: Hello, World!
})();
带参数的 IIFE:
(function(a, b) {
console.log(a + b); // 输出: 3
})(1, 2);
与外部作用域隔离:
let globalVar = 'I am global';
(function() {
let localVar = 'I am local';
console.log(globalVar); // 输出: I am global
console.log(localVar); // 输出: I am local
})();
console.log(globalVar); // 输出: I am global
console.log(localVar); // 报错: localVar is not defined
总结
- 自执行:IIFE 定义后立即执行。
- 私有作用域:创建局部作用域,保护全局命名空间。
- 避免全局污染:减少全局变量的使用。
- 使用函数表达式:利用函数表达式而非函数声明。
IIFE 是 JavaScript 中一种常见的编程模式,特别是在需要创建私有作用域和避免全局污染时非常有用。
箭头函数有什么特点?
箭头函数(Arrow Function)是 ES6 引入的一种简洁的函数定义方式,它具有以下几个主要特点:
1. 更简洁的语法
箭头函数使用更简洁的语法,省略了 function 关键字和大括号(在表达式的情况下)。
示例:
// 普通函数
function add(x, y) {
return x + y;
}
// 箭头函数
const add = (x, y) => x + y;
2. 不绑定自己的 this
箭头函数不会创建自己的 this,它会继承外部作用域的 this。这对于处理回调函数中的 this 特别有用。
示例:
function Timer() {
this.seconds = 0;
setInterval(() => {
this.seconds++; // `this` 继承自 Timer 对象
console.log(this.seconds);
}, 1000);
}
new Timer(); // 正确地打印秒数
3. 没有 arguments 对象
箭头函数没有自己的 arguments 对象,arguments 在箭头函数中是不可用的。如果需要访问 arguments 对象,必须使用普通函数。
示例:
function normalFunction() {
console.log(arguments); // 正常打印 arguments 对象
}
const arrowFunction = () => {
console.log(arguments); // 报错: arguments is not defined
};
normalFunction(1, 2, 3);
arrowFunction(1, 2, 3);
4. 不能用作构造函数
箭头函数不能作为构造函数使用,即不能使用 new 关键字调用它们。
示例:
const Person = (name) => {
this.name = name;
};
// Person("Alice"); // 报错: Person is not a constructor
5. 简单的单行函数
如果箭头函数的主体只有一个表达式,可以省略大括号和 return 关键字。这使得简短的函数更简洁。
示例:
const square = x => x * x; // 单行函数,省略了大括号和 return 关键字
6. this 绑定的静态性
箭头函数中的 this 是在函数定义时静态绑定的,而不是在函数调用时动态绑定的。这使得箭头函数非常适合用于回调函数和闭包中。
7. 不具有 prototype 属性
箭头函数没有 prototype 属性,因此不能作为构造函数。
总结
- 简洁语法:使用更简洁的语法来定义函数。
- 继承
this:this是从外部作用域继承的,不会创建自己的this。 - 无
arguments:没有自己的arguments对象。 - 不能构造:不能用作构造函数。
- 简短表达式:支持简洁的单行函数表达式。
箭头函数简化了函数定义的语法,并在处理 this 时提供了一种一致的行为,这使得在回调函数和闭包中使用箭头函数非常方便。
如何实现防抖和节流?
防抖(Debounce)和节流(Throttle)是控制函数调用频率的两种常见技术,用于优化性能,尤其是在处理用户输入、滚动事件、窗口调整大小等高频事件时。
1. 防抖(Debounce)
防抖用于限制函数的执行频率,以避免频繁触发函数。它会在事件触发后,延迟执行函数,直到事件停止触发一段时间后再执行。常用于输入框的实时搜索等场景。
实现原理
- 在每次事件触发时,清除上次的定时器。
- 重新设置一个新的定时器,延迟执行目标函数。
示例代码
function debounce(func, wait) {
let timeout;
return function(...args) {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), wait);
};
}
// 使用示例
const handleResize = debounce(() => {
console.log('Resize event handler');
}, 300);
window.addEventListener('resize', handleResize);
2. 节流(Throttle)
节流用于控制函数的执行频率,确保函数在指定时间内只执行一次。适用于限制函数调用的频率,以降低高频事件的处理负载。
实现原理
- 在函数执行后,设定一个时间间隔,在此时间间隔内不再执行函数。
- 一旦时间间隔结束,可以再次执行函数。
示例代码
function throttle(func, limit) {
let lastFunc;
let lastRan;
return function(...args) {
if (!lastRan) {
func.apply(this, args);
lastRan = Date.now();
} else {
clearTimeout(lastFunc);
lastFunc = setTimeout(() => {
if ((Date.now() - lastRan) >= limit) {
func.apply(this, args);
lastRan = Date.now();
}
}, limit - (Date.now() - lastRan));
}
};
}
// 使用示例
const handleScroll = throttle(() => {
console.log('Scroll event handler');
}, 200);
window.addEventListener('scroll', handleScroll);
总结
- 防抖:防止函数在事件频繁触发时多次执行,只有在事件停止触发一段时间后才执行。适用于输入框实时搜索等需要等待用户输入完成后再执行的场景。
- 节流:限制函数在一定时间内的执行次数,确保函数在指定时间间隔内只执行一次。适用于滚动、窗口调整大小等高频事件的处理。
第六章 Promise & Async/await & Generators
如何模拟实现 Promise?
Promise是一种用于处理异步操作的对象,它有三种状态:pending(待定)、fulfilled(已完成)和rejected(已拒绝)。
核心功能
- 状态管理:管理
Promise的状态,确保状态不可变。 - 回调处理:支持
then方法,处理fulfilled和rejected状态的回调。 - 链式调用:支持链式调用
then方法。 - 异步执行:支持异步操作,确保
then方法中的回调在异步操作完成后执行。
模拟实现
以下是一个简化的 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(callback => callback(value));
}
};
// 失败回调
const reject = (reason) => {
if (this.state === 'pending') {
this.state = 'rejected';
this.reason = reason;
this.onRejectedCallbacks.forEach(callback => callback(reason));
}
};
// 执行 executor 函数
try {
executor(resolve, reject);
} catch (error) {
reject(error);
}
}
// `then` 方法
then(onFulfilled, onRejected) {
// 默认的回调函数
onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : (value) => value;
onRejected = typeof onRejected === 'function' ? onRejected : (reason) => { throw reason; };
// 返回一个新的 Promise 对象
return new MyPromise((resolve, reject) => {
const handleFulfilled = () => {
try {
const result = onFulfilled(this.value);
resolve(result);
} catch (error) {
reject(error);
}
};
const handleRejected = () => {
try {
const result = onRejected(this.reason);
resolve(result);
} catch (error) {
reject(error);
}
};
// 处理已完成或已拒绝状态
if (this.state === 'fulfilled') {
handleFulfilled();
} else if (this.state === 'rejected') {
handleRejected();
} else {
this.onFulfilledCallbacks.push(handleFulfilled);
this.onRejectedCallbacks.push(handleRejected);
}
});
}
}
// 测试
const promise = new MyPromise((resolve, reject) => {
setTimeout(() => {
resolve('Success');
}, 1000);
});
promise.then(result => {
console.log(result); // 输出: Success
return 'Another success';
}).then(result => {
console.log(result); // 输出: Another success
});
解释
- 构造函数:
MyPromise的构造函数接受一个executor函数,该函数接收resolve和reject两个函数,用于改变Promise的状态。 - 状态管理:
state属性跟踪Promise的状态,初始为pending,可以变为fulfilled或rejected。 - 回调队列:
onFulfilledCallbacks和onRejectedCallbacks用于存储回调函数,等待Promise状态改变后执行。 then方法:then方法返回一个新的Promise对象。它接受两个回调函数,一个处理fulfilled状态,另一个处理rejected状态。回调函数的结果将决定新的Promise的状态。- 异步处理:在
then方法中,根据当前Promise的状态决定如何处理回调函数。
这只是一个简化版的 Promise 实现,真实的 Promise 规范包括更多细节和优化(如异步执行、微任务队列等)。
简单介绍下 ES6 中的 Iterator 和 Iterable
在 ES6 中,
Iterator(迭代器)和Iterable(可迭代对象)是用于遍历数据结构的新机制,它们为像数组、字符串、Set、Map 等集合提供了一种统一的迭代访问方式。
1. Iterable(可迭代对象)
一个对象被称为可迭代对象(Iterable),意味着它实现了 @@iterator 方法(通常是通过 [Symbol.iterator] 属性)。该方法返回一个迭代器对象,供 for...of 循环等操作使用。
常见的可迭代对象
- 数组(Array)
- 字符串(String)
- Set
- Map
- 类数组对象(如
arguments对象、NodeList) - 自定义可迭代对象
示例:数组是可迭代对象
const arr = [1, 2, 3];
for (const value of arr) {
console.log(value); // 输出 1, 2, 3
}
数组实现了 Symbol.iterator,因此它是可迭代的,可以使用 for...of 进行遍历。
2. Iterator(迭代器)
迭代器是一个对象,具有 next() 方法。每次调用 next() 方法,会返回一个包含 value 和 done 属性的对象:
value:当前迭代的值。done:布尔值,表示迭代是否完成。如果为true,表示迭代结束。
如何使用迭代器
可迭代对象的 Symbol.iterator 方法会返回一个迭代器对象。
const arr = [1, 2, 3];
const iterator = arr[Symbol.iterator](); // 获取数组的迭代器
console.log(iterator.next()); // { value: 1, done: false }
console.log(iterator.next()); // { value: 2, done: false }
console.log(iterator.next()); // { value: 3, done: false }
console.log(iterator.next()); // { value: undefined, done: true }
3. 自定义可迭代对象
可以通过实现 Symbol.iterator 自定义一个可迭代对象:
const myIterable = {
data: [1, 2, 3],
[Symbol.iterator]() {
let index = 0;
return {
next: () => {
if (index < this.data.length) {
return { value: this.data[index++], done: false };
} else {
return { value: undefined, done: true };
}
}
};
}
};
// 使用 for...of 迭代
for (const value of myIterable) {
console.log(value); // 输出 1, 2, 3
}
总结
- Iterable:实现了
Symbol.iterator方法的对象,允许它们被for...of等迭代语法使用。常见的内置可迭代对象包括数组、字符串、Set、Map 等。 - Iterator:通过
next()方法逐个返回值的对象,直到迭代完成,done变为true。
Iterator 和 Iterable 提供了一个统一的接口来遍历各种数据结构,增强了 ES6 对不同集合类型的操作能力。
谈谈对生成器(Generator)的理解
生成器(
Generator)是 ES6 引入的一种特殊类型的函数,能够控制函数的执行过程,允许函数暂停执行和恢复。它为异步编程提供了新的思路,同时为创建迭代器提供了简单的语法。
生成器的特点
- 可暂停函数:生成器函数可以在执行过程中暂停,稍后再恢复执行,从暂停的地方继续。生成器通过
yield关键字来暂停函数的执行。 - 迭代器接口:生成器函数返回的是一个迭代器对象,能用
next()方法来控制执行,并返回一个带有value和done属性的对象。每次调用next()会推进生成器到下一个yield表达式。 - 生成器函数的定义:生成器函数使用
function*语法定义,与普通函数不同的是它可以分段执行。
生成器函数的语法
function* generatorFunction() {
yield 1;
yield 2;
yield 3;
return 4; // 生成器的终点
}
const gen = generatorFunction();
console.log(gen.next()); // { value: 1, done: false }
console.log(gen.next()); // { value: 2, done: false }
console.log(gen.next()); // { value: 3, done: false }
console.log(gen.next()); // { value: 4, done: true } // 生成器结束,done 为 true
生成器的核心概念
-
function*和yield关键字:生成器函数通过function*定义,函数执行过程中可以多次暂停,yield是用来暂停的关键字。 -
next()方法:每次调用生成器对象的next()方法,生成器从上一个yield语句停止的地方继续执行,直到遇到下一个yield或者函数结束。next()方法返回一个对象{ value: ..., done: ... },其中:value是yield语句后面表达式的值。done是一个布尔值,表示生成器是否执行完成。
-
双向通信:除了用
next()启动生成器,外部还可以通过next(value)向生成器传递值,生成器内部可以接收外部的传递值。
function* counter() {
const first = yield 1;
const second = yield first + 2;
return second + 3;
}
const gen = counter();
console.log(gen.next()); // { value: 1, done: false }
console.log(gen.next(10)); // { value: 12, done: false }
console.log(gen.next(20)); // { value: 23, done: true }
生成器的应用场景
- 异步编程:生成器可以用于编写异步代码,以同步的方式描述异步流程(类似
async/await的思想)。通过yield来等待异步操作完成再继续执行。示例:
function* asyncFlow() {
const result1 = yield fetchData1(); // 假设 fetchData1 返回 Promise
const result2 = yield fetchData2(result1);
return result2;
}
- 实现迭代器:生成器可以用来简化迭代器的实现。示例:
function* range(start, end) {
for (let i = start; i <= end; i++) {
yield i;
}
}
for (const value of range(1, 5)) {
console.log(value); // 输出 1, 2, 3, 4, 5
}
- 控制流:生成器可以用于复杂的控制流管理,比如任务调度器、协程等。
- 无限序列生成:生成器允许你创建无限数据序列,而不会导致内存溢出,因为它们是惰性求值的,只有当你调用
next()时才会生成下一个值。示例:
function* infiniteNumbers() {
let i = 0;
while (true) {
yield i++;
}
}
const gen = infiniteNumbers();
console.log(gen.next().value); // 0
console.log(gen.next().value); // 1
console.log(gen.next().value); // 2
介绍一下 async/await
async/await是 ES2017 引入的语法,用于简化基于Promise的异步操作,使异步代码看起来更像同步代码。它是Promise的语法糖,能有效地处理异步代码中的回调地狱问题,让代码更清晰、易读。
1. async 和 await 的基本概念
async函数:声明一个async函数,意味着这个函数总是返回一个Promise。如果函数中返回的是非Promise的值,JavaScript 会自动将其包装为Promise.resolve()。await关键字:await只能在async函数中使用,它会暂停async函数的执行,等待一个Promise完成,并返回Promise的结果。如果Promise被拒绝(rejected),它会抛出异常。
2. async 和 await 的用法
简单示例
async function fetchData() {
const response = await fetch('https://api.example.com/data');
const data = await response.json();
return data;
}
fetchData().then(data => console.log(data)).catch(error => console.error(error));
在这个例子中,fetchData 是一个 async 函数,它等待 fetch 请求完成,并解析 JSON 数据。await 停止了函数的执行,直到 fetch 完成,保证在继续执行代码之前得到了期望的数据。
3. async/await 的核心特性
- 简化 Promise 链:
async/await的一个主要优点是避免了传统Promise链式调用(.then().catch()),使代码更加直观。 - 错误处理:可以通过
try/catch块处理await的错误,就像同步代码中处理异常一样。示例:
async function fetchDataWithErrorHandling() {
try {
const response = await fetch('https://api.example.com/data');
const data = await response.json();
return data;
} catch (error) {
console.error('Error fetching data:', error);
}
}
- 返回值:
async函数总是返回一个Promise。即使你显式地返回一个普通值,async函数也会将其包装在一个Promise中。
async function simpleValue() {
return 42;
}
simpleValue().then(value => console.log(value)); // 输出: 42
- 并行执行:虽然
await会暂停async函数的执行,但多个await表达式可以并行执行,使用Promise.all可以有效地并行化异步操作。示例:
async function fetchDataInParallel() {
const [data1, data2] = await Promise.all([
fetch('https://api.example.com/data1'),
fetch('https://api.example.com/data2')
]);
const jsonData1 = await data1.json();
const jsonData2 = await data2.json();
return [jsonData1, jsonData2];
}
4. async/await 与 Promise 对比
- 代码风格:
Promise通过链式.then()和.catch()处理异步操作,而async/await则通过同步风格的写法来管理异步操作,使代码更清晰。 - 错误处理:
async/await使用try/catch块捕获错误,比Promise的.catch()链更接近传统的同步代码错误处理方式。 - 可读性:
async/await的风格更贴近同步代码,避免了嵌套和回调地狱的问题,让异步操作更直观。
5. 注意事项
- 阻塞问题:
await会暂停async函数的执行,直到Promise完成。如果在多个await表达式之间没有依赖关系,建议使用Promise.all()以并行执行异步任务,避免性能问题。
// 不推荐:顺序执行多个异步操作
async function sequentialFetch() {
const data1 = await fetch('https://api.example.com/data1');
const data2 = await fetch('https://api.example.com/data2');
return [data1, data2];
}
// 推荐:并行执行多个异步操作
async function parallelFetch() {
const [data1, data2] = await Promise.all([
fetch('https://api.example.com/data1'),
fetch('https://api.example.com/data2')
]);
return [data1, data2];
}
- 顶级 await:
await只能在async函数中使用,不过从 ES2022 开始,JavaScript 支持顶级await,这意味着你可以在模块的顶层直接使用await而不需要async函数包裹。示例:
const response = await fetch('https://api.example.com/data');
const data = await response.json();
console.log(data);
总结
async/await让处理异步操作的代码更加简洁、易读、类似同步代码,解决了传统异步回调地狱的问题。- 使用
await来暂停异步操作,并通过try/catch处理错误,让异步编程的代码结构变得更加清晰。
如何实现红绿灯效果?
1. 使用 setTimeout 和回调函数
最基础的方式是通过 setTimeout 和回调函数来实现,这样可以保证异步任务按顺序执行。
function redLight(duration, callback) {
console.log('Red Light');
setTimeout(callback, duration);
}
function yellowLight(duration, callback) {
console.log('Yellow Light');
setTimeout(callback, duration);
}
function greenLight(duration, callback) {
console.log('Green Light');
setTimeout(callback, duration);
}
function trafficLight() {
redLight(3000, () => {
yellowLight(1000, () => {
greenLight(2000, () => {
trafficLight(); // 循环
});
});
});
}
trafficLight();
2. 使用 Promise 实现
通过 Promise 的链式调用,能够避免嵌套回调的问题,并使代码更加清晰。
function redLight(duration) {
return new Promise((resolve) => {
console.log('Red Light');
setTimeout(resolve, duration);
});
}
function yellowLight(duration) {
return new Promise((resolve) => {
console.log('Yellow Light');
setTimeout(resolve, duration);
});
}
function greenLight(duration) {
return new Promise((resolve) => {
console.log('Green Light');
setTimeout(resolve, duration);
});
}
function trafficLight() {
redLight(3000)
.then(() => yellowLight(1000))
.then(() => greenLight(2000))
.then(trafficLight); // 循环
}
trafficLight();
3. 使用 async/await 实现
async/await 是基于 Promise 的语法糖,可以让代码看起来更像同步代码,易读性更强。
function redLight(duration) {
return new Promise((resolve) => {
console.log('Red Light');
setTimeout(resolve, duration);
});
}
function yellowLight(duration) {
return new Promise((resolve) => {
console.log('Yellow Light');
setTimeout(resolve, duration);
});
}
function greenLight(duration) {
return new Promise((resolve) => {
console.log('Green Light');
setTimeout(resolve, duration);
});
}
async function trafficLight() {
while (true) {
await redLight(3000); // 红灯亮3秒
await yellowLight(1000); // 黄灯亮1秒
await greenLight(2000); // 绿灯亮2秒
}
}
trafficLight();
4. 使用 Generator 实现
通过 Generator 和 yield 机制,可以按顺序执行异步操作。
function redLight(duration) {
return new Promise((resolve) => {
console.log('Red Light');
setTimeout(resolve, duration);
});
}
function yellowLight(duration) {
return new Promise((resolve) => {
console.log('Yellow Light');
setTimeout(resolve, duration);
});
}
function greenLight(duration) {
return new Promise((resolve) => {
console.log('Green Light');
setTimeout(resolve, duration);
});
}
function* lightGenerator() {
while (true) {
yield redLight(3000); // 红灯亮3秒
yield yellowLight(1000); // 黄灯亮1秒
yield greenLight(2000); // 绿灯亮2秒
}
}
const trafficLight = lightGenerator();
function run(generator) {
const { value } = generator.next();
value.then(() => run(generator));
}
run(trafficLight);
5. 使用 setInterval 实现
setInterval 可以通过固定的时间间隔来循环执行任务,但需要自己管理状态。
let current = 0;
const lights = ['Red Light', 'Yellow Light', 'Green Light'];
const durations = [3000, 1000, 2000];
function trafficLight() {
console.log(lights[current]);
setTimeout(() => {
current = (current + 1) % 3; // 切换到下一个灯
trafficLight();
}, durations[current]);
}
trafficLight();
总结
- 回调函数:简单但容易造成回调地狱,不适合复杂异步逻辑。
- Promise:避免了回调地狱,更加直观,适合处理链式异步调用。
async/await:基于Promise,使代码像同步一样,易于阅读和维护。- Generator:可以通过
yield控制执行顺序,但需要外部调用器来处理。 setInterval:虽然可以自动执行,但需要自己管理状态,适合简单的定时任务。
第七章 模块(Modules)
谈谈模块化的发展历程
JavaScript 模块化的发展经历了多个阶段,从最初没有模块化支持到现代模块化标准的广泛应用。下面简要介绍 JavaScript 模块化的发展历程:
1. 早期没有模块化支持
最初的 JavaScript 是为浏览器设计的,只有全局作用域,没有内建的模块化机制。所有脚本文件都共享同一个全局命名空间,容易造成变量冲突、命名污染和维护困难。开发者需要手动管理脚本文件的加载顺序,模块化编程非常不便。
2. IIFE(立即调用函数表达式)与命名空间模式
为了避免全局作用域的污染,开发者开始使用 IIFE(立即调用函数表达式)将代码封装在局部作用域内,模拟模块化。这种方法有效地将变量隔离在函数作用域中,避免命名冲突。
示例:
var module = (function() {
var privateVar = 'I am private';
return {
publicVar: 'I am public'
};
})();
3. CommonJS 模块化
随着 Node.js 的流行,JavaScript 在服务器端的使用越来越广泛。Node.js 引入了 CommonJS 规范,定义了一种模块化的方式。每个文件都是一个模块,使用 require 引入模块,用 module.exports 导出模块内容。
特点:
- 主要用于服务器端的模块化。
- 模块是同步加载的,适合服务器端使用。
示例:
// math.js
module.exports = {
add: function(a, b) {
return a + b;
}
};
// app.js
const math = require('./math');
console.log(math.add(1, 2)); // 3
4. AMD(Asynchronous Module Definition)
为了应对浏览器环境中模块加载的异步需求,AMD 规范被提出。RequireJS 是 AMD 的一个实现,允许在浏览器中异步加载模块,解决了 CommonJS 同步加载在浏览器中的不足。
特点:
- 模块异步加载,适合前端使用。
- 解决了浏览器端加载时间较长的问题。
示例:
// 使用 AMD 规范
define(['math'], function(math) {
console.log(math.add(1, 2)); // 3
});
5. UMD(Universal Module Definition)
UMD 是一种兼容 AMD 和 CommonJS 的模块化标准。它尝试统一服务器端和浏览器端的模块化模式,目的是让同一个模块在不同的环境中都能运行。
特点:
- 兼容性好,支持 AMD、CommonJS 和全局变量模式。
- 适用于跨平台的 JavaScript 库。
示例:
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD
define([], factory);
} else if (typeof exports === 'object') {
// Node, CommonJS-like
module.exports = factory();
} else {
// Browser globals (root is window)
root.myModule = factory();
}
}(this, function() {
return {
sayHello: function() {
console.log('Hello!');
}
};
}));
6. ES6 模块化(ES Module)
在 ECMAScript 2015(ES6)中,JavaScript 原生支持了模块化。ES6 模块通过 import 和 export 关键字实现了模块的引入和导出,支持静态分析,更加现代和高效。
特点:
- 原生支持,成为标准,现代浏览器和 Node.js 都支持。
- 支持静态分析和树摇优化(Tree Shaking),提升性能。
示例:
// math.js
export function add(a, b) {
return a + b;
}
// app.js
import { add } from './math.js';
console.log(add(1, 2)); // 3
7. 现代模块打包工具
随着前端项目复杂度的提升,模块化逐渐成为开发的必需品。为了解决浏览器对 ES6 模块支持不全面的问题,工具如 Webpack 和 Rollup 应运而生。它们能够将多个模块打包为单个文件,并支持各种模块化规范,如 CommonJS 和 ES6 模块。
- Webpack:提供丰富的插件系统,适合复杂的应用打包。
- Rollup:更侧重于打包小型库,支持 Tree Shaking,减小打包文件体积。
8. ESM in Node.js
随着时间的推移,Node.js 也开始逐步支持 ES6 模块化。在 Node.js v12 及以上版本中,已默认支持 ES Modules(ESM),不过在使用时需要将文件名扩展名改为 .mjs,或在 package.json 中设置 "type": "module"。
总结
JavaScript 模块化经历了从没有模块化支持,到 CommonJS、AMD、UMD 以及最终的 ES6 模块化。现在,ES6 模块化逐渐成为主流,结合打包工具如 Webpack,现代 JavaScript 开发已经非常依赖模块化来构建可维护、可扩展的应用。
第八章 Proxy & Reflection
谈谈 Object.defineProperty 与 Proxy 的区别?
Object.defineProperty 和 Proxy 都是 JavaScript 用来定义对象行为的工具,它们各自有不同的应用场景和能力。以下是二者的主要区别:
1. 功能范围
Object.defineProperty:用于直接在对象上定义或修改某个属性的特性(如可写性、可枚举性、可配置性等),并允许为属性设置 getter 和 setter。它只能作用于对象的单个属性,无法监控对象的整体行为。功能局限:只能劫持对象的单个属性的读取和写入行为。
const obj = {};
Object.defineProperty(obj, 'name', {
value: 'John',
writable: false, // 不可修改
enumerable: true, // 可枚举
configurable: false // 不可删除或重新配置
});
console.log(obj.name); // John
obj.name = 'Jane'; // 修改无效,属性是不可写的
Proxy:代理整个对象,可以拦截和重新定义对对象的各种操作,不仅包括属性的读取和写入,还可以拦截函数调用、属性删除、in操作符检查、Object.keys()等操作。功能强大:不仅可以对对象的单个属性进行拦截,还能劫持对整个对象的所有操作。
const obj = { name: 'John' };
const proxy = new Proxy(obj, {
get(target, prop) {
console.log(`Getting ${prop}`);
return target[prop];
},
set(target, prop, value) {
console.log(`Setting ${prop} to ${value}`);
target[prop] = value;
return true;
}
});
proxy.name; // 输出: Getting name
proxy.age = 30; // 输出: Setting age to 30
2. 适用场景
-
Object.defineProperty:主要用于对对象某个属性进行精细控制,适合在已有的对象上修改或定义具体的属性行为,通常用于数据绑定和观察属性的变化。常见应用:- Vue 2.x 中响应式数据的实现使用
Object.defineProperty来观察对象属性的变化。 - 设置不可修改、不可枚举的属性。
- Vue 2.x 中响应式数据的实现使用
-
Proxy:适用于对对象的全面拦截与控制。它提供了比Object.defineProperty更灵活和强大的拦截机制,能够处理更广泛的对象操作行为,适合需要对整个对象进行拦截的场景。常见应用:- Vue 3.x 的响应式系统改用
Proxy,因为它可以直接观察到整个对象的变化,不需要逐个遍历对象的属性。 - 实现
Proxy可以用于封装、监控、缓存等高级场景,例如拦截函数调用,或者防止外部代码篡改对象行为。
- Vue 3.x 的响应式系统改用
3. 操作的对象
Object.defineProperty:只能操作已有的对象,且只能控制该对象上的某个属性,无法拦截非属性的操作(如Object.keys()或delete操作)。Proxy:不仅可以代理对象,还可以代理函数、数组等其他复杂的数据结构,并且可以拦截任何操作,如读取、写入、删除、函数调用、in操作符等等。
4. 性能
Object.defineProperty:由于只针对某个具体属性,性能开销相对较小,但由于它不能直接监听对象新增的属性,需要手动递归处理深层次对象属性,这在大规模数据处理时效率较低。Proxy:功能强大,但性能开销较大,特别是在频繁进行属性访问、设置和删除时。虽然它能够实现深层次的监听,但由于拦截所有操作,性能可能受到一定影响,特别是在大规模数据处理中。
5. 支持性
Object.defineProperty:在 ES5 中引入,支持较早的浏览器环境和 JavaScript 引擎。Proxy:在 ES6 中引入,浏览器的支持范围比Object.defineProperty要更晚,可能在某些旧浏览器或 JavaScript 环境中不可用。
总结
Object.defineProperty:适合对对象的单个属性进行精细化控制,无法拦截整个对象的操作,主要用于属性的读写控制和数据绑定。Proxy:更强大,可以拦截对象的所有操作,适合对整个对象进行全方位的操作控制和代理,功能丰富,但性能开销相对较大。
如果需要对整个对象的行为进行全面监控和拦截,Proxy 是更好的选择;如果只需要控制单个属性的行为,Object.defineProperty 就足够了。
Reflect 有什么用?
Reflect是 JavaScript 中的一个内置对象,提供了一组静态方法,用于操作对象的属性以及与代理 (Proxy) 对象协同工作。Reflect中的方法与之前的对象操作(如对象的属性访问、赋值、删除等)有着相同的功能,但通过Reflect调用这些方法更加规范且可控。此外,Reflect的方法与Proxy对象一起使用时,可以更容易地实现默认的行为。
以下是 Reflect 的主要用途和功能:
1. 为对象操作提供函数化的标准接口
Reflect提供了与 JavaScript 操作符(如delete、in、[]等)相对应的函数形式。使用Reflect的方法可以让对象操作更具可读性和一致性。
示例:
const obj = { name: 'John' };
// 直接使用操作符
console.log('name' in obj); // true
delete obj.name;
console.log('name' in obj); // false
// 使用 Reflect 提供的函数形式
const obj2 = { age: 25 };
console.log(Reflect.has(obj2, 'age')); // true
Reflect.deleteProperty(obj2, 'age');
console.log(Reflect.has(obj2, 'age')); // false
2. 与 Proxy 配合使用,提供默认行为
Proxy 拦截对象的操作时,通常需要手动处理对象的默认行为。Reflect 提供的操作与默认行为相同,因此可以在 Proxy 中轻松使用 Reflect 来恢复默认行为。
示例:
const obj = { name: 'John' };
const proxy = new Proxy(obj, {
get(target, prop, receiver) {
console.log(`Getting property: ${prop}`);
return Reflect.get(target, prop, receiver); // 使用 Reflect 进行默认的 get 操作
},
set(target, prop, value, receiver) {
console.log(`Setting property: ${prop} to ${value}`);
return Reflect.set(target, prop, value, receiver); // 使用 Reflect 进行默认的 set 操作
}
});
proxy.name; // 输出: Getting property: name
proxy.age = 30; // 输出: Setting property: age to 30
3. 返回操作成功与否的布尔值
与传统的对象操作不同,Reflect 方法在操作对象时会返回 true 或 false 来表示操作是否成功。这对于需要明确知道操作结果的场景非常有用,特别是在代理和元编程中。
示例:
const obj = {};
const success = Reflect.defineProperty(obj, 'name', {
value: 'John',
configurable: true
});
console.log(success); // true,表示定义属性成功
4. 替代一些不推荐直接使用的操作
JavaScript 中有些操作,如使用 Object.defineProperty、delete 等,虽然功能强大,但不够直观。Reflect 提供了更清晰的方式来完成同样的任务。例如,使用 Reflect.deleteProperty 代替直接的 delete 操作。
示例:
const obj = { name: 'John' };
// 使用 delete 操作符
delete obj.name;
console.log(obj); // {}
// 使用 Reflect.deleteProperty
Reflect.deleteProperty(obj, 'name');
console.log(obj); // {}
5. 简化函数调用和构造函数调用
Reflect.apply 和 Reflect.construct 提供了对函数的调用和构造函数的标准方式,特别适合在动态场景中使用,如当你需要灵活地调用函数或构造函数时。
示例:
// Reflect.apply - 调用函数
function sum(a, b) {
return a + b;
}
console.log(Reflect.apply(sum, undefined, [1, 2])); // 3
// Reflect.construct - 调用构造函数
function Person(name) {
this.name = name;
}
const person = Reflect.construct(Person, ['John']);
console.log(person.name); // John
6. 避免意外行为或错误
Reflect 的方法可以防止一些 JavaScript 中不一致的行为。例如,Reflect.set 在无法设置属性时返回 false,而不是像直接赋值那样静默失败。
示例:
const obj = {};
Object.freeze(obj); // 冻结对象,不允许修改
const success = Reflect.set(obj, 'name', 'John');
console.log(success); // false,不允许修改冻结对象的属性
常用的 Reflect 方法
Reflect.get(target, property, receiver):获取对象属性。Reflect.set(target, property, value, receiver):设置对象属性。Reflect.has(target, property):判断属性是否存在(相当于in操作符)。Reflect.deleteProperty(target, property):删除对象属性(相当于delete操作符)。Reflect.ownKeys(target):获取对象所有键(包括 symbol 键)。Reflect.defineProperty(target, property, descriptor):定义对象属性(相当于Object.defineProperty)。Reflect.getPrototypeOf(target):获取对象的原型。Reflect.setPrototypeOf(target, prototype):设置对象的原型。Reflect.apply(func, thisArg, args):调用函数。Reflect.construct(target, args):调用构造函数。
总结
Reflect 提供了一种更加标准化、函数化的方式来操作对象,它不仅可以与 Proxy 配合,还能简化对象的操作,并且可以防止一些不直观的 JavaScript 行为。Reflect 使得元编程更为简洁,且大大提升了可维护性和代码的健壮性。
第九章 JavaScript 运行时
谈谈对执行上下文的理解?
在 JavaScript 中,执行上下文(Execution Context)是一个重要的概念,用于理解代码是如何在不同的环境中运行的。执行上下文定义了代码执行时的环境,包括变量、函数和对象的作用域。它为代码执行提供了必要的背景信息。
1. 执行上下文的定义
执行上下文是一个抽象的概念,它描述了 JavaScript 代码的执行环境。每当 JavaScript 代码运行时,都会创建一个新的执行上下文,执行上下文会包含代码执行所需的变量、函数和对象。
2. 执行上下文的类型
JavaScript 中有几种主要的执行上下文类型:
-
全局执行上下文:
- 当 JavaScript 代码第一次执行时,创建全局执行上下文。
- 这是程序的入口点,用于定义全局变量和函数。
- 只有一个全局执行上下文存在于 JavaScript 执行栈中。
-
函数执行上下文:
- 每当一个函数被调用时,都会创建一个新的函数执行上下文。
- 用于存储函数的局部变量、参数和
this绑定。 - 每个函数调用都会创建一个新的函数执行上下文。
-
块级执行上下文(ES6 引入的块级作用域) :
- 与
let和const的块级作用域相关联,在块级作用域(如if、for块)中创建。 - 主要用于块级作用域变量的管理。
- 与
3. 执行上下文的生命周期
执行上下文的生命周期包括以下几个阶段:
-
创建阶段:
- 变量对象的创建:创建一个变量对象(Variable Object, VO),用于存储变量和函数声明。
- 作用域链的创建:建立作用域链,确定变量和函数的可访问性。
this绑定:确定this的指向。
-
执行阶段:
- 代码执行:执行代码中的语句,并在变量对象中分配值,处理函数调用等操作。
4. 作用域链
作用域链是执行上下文中变量和函数的查找机制。每个执行上下文都有一个作用域链,它决定了变量和函数的可访问性。
- 作用域链的形成:每当创建执行上下文时,会形成一个新的作用域链。函数的执行上下文会包含父作用域的引用,从而形成链式结构。
- 查找变量:在访问变量时,首先会在当前执行上下文的作用域链中查找,如果找不到则继续向外层作用域查找,直到全局执行上下文。
5. this 绑定
-
全局上下文:在全局上下文中,
this绑定到全局对象(浏览器中是window,Node.js 中是global)。 -
函数上下文:在函数执行上下文中,
this的值取决于函数的调用方式:- 普通函数调用:
this绑定到全局对象(严格模式下是undefined)。 - 方法调用:
this绑定到调用方法的对象。 - 构造函数调用:
this绑定到新创建的实例对象。 - 箭头函数:
this绑定到创建箭头函数时的上下文,即函数定义时的上下文(没有自己的this)。
- 普通函数调用:
6. 执行上下文栈
执行上下文栈(Call Stack)是一个用于管理多个执行上下文的栈结构。它遵循先进后出(LIFO)的原则:
- 入栈:当创建一个新的执行上下文时,将其推入栈中。
- 出栈:当当前执行上下文的代码执行完毕后,将其从栈中弹出。
示例:
function outer() {
var outerVar = 'I am outer';
function inner() {
console.log(outerVar);
}
inner();
}
outer();
- 执行
outer函数时,创建一个outer执行上下文并入栈。 - 在
outer执行上下文中调用inner函数,创建一个inner执行上下文并入栈。 inner执行上下文中的代码执行完毕后,inner执行上下文出栈。outer执行上下文中的代码执行完毕后,outer执行上下文出栈。
7. 执行上下文与闭包
闭包是执行上下文的一种特殊用法。当函数内部定义了一个或多个函数,并返回这些内部函数时,这些内部函数仍然可以访问其外部函数的变量,即使外部函数已经执行完毕。这是因为内部函数保持了对其创建时的执行上下文的引用。
示例:
function createCounter() {
let count = 0;
return function() {
count++;
return count;
};
}
const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2
在上述示例中,createCounter 的执行上下文在函数返回后已经出栈,但 counter 函数仍然可以访问 count 变量。这是因为 counter 形成了一个闭包,保持了对 createCounter 执行上下文的引用。
总结
- 执行上下文 是 JavaScript 执行代码的环境,用于存储代码的变量、函数和对象。
- 全局上下文 和 函数上下文 是最常见的两种上下文类型。
- 作用域链 和
this绑定 是执行上下文的关键特性。 - 执行上下文栈 管理着代码执行的顺序。
- 闭包 是执行上下文的一个重要应用,允许函数访问其外部作用域的变量。
简单介绍一下垃圾回收机制
垃圾回收(Garbage Collection,GC)是自动管理内存的一种机制,目的是释放不再使用的内存资源,从而避免内存泄漏并优化程序的性能。
1. 垃圾回收的目标
垃圾回收的主要目标是自动识别和释放不再使用的内存,以防止内存泄漏并优化程序的内存使用。这些不再使用的内存通常指的是那些不再被任何引用所持有的对象或变量。
2. 标记-清除算法
JavaScript 引擎使用的主要垃圾回收算法是标记-清除(Mark-and-Sweep)算法,其基本步骤如下:
-
标记阶段:
- 从根对象(如全局对象、活动函数的局部变量、闭包等)开始,遍历所有可以到达的对象。
- 对这些可达的对象进行标记,标记它们为“活动”状态,即这些对象仍然在使用中。
-
清除阶段:
- 遍历所有对象,检查哪些对象没有被标记为活动状态。
- 删除这些未标记的对象,从而释放它们占用的内存。
3. 引用计数
除了标记-清除算法,JavaScript 还使用了引用计数(Reference Counting)算法:
- 引用计数:每个对象都有一个引用计数,记录当前有多少引用指向该对象。当引用计数变为零时,表示对象不再被使用,可以安全地释放它的内存。
- 问题:引用计数的一个主要问题是无法处理循环引用的情况(即两个或多个对象相互引用),这种情况下即使对象不再使用,它们的引用计数也不会降为零,从而造成内存泄漏。
4. 分代收集
现代 JavaScript 引擎(如 V8)通常采用分代收集(Generational Collection)策略,这种策略基于以下观察:
- 短生命周期的对象:大多数对象的生命周期较短(如局部变量),它们在创建后很快就不再使用。
- 长生命周期的对象:一些对象(如全局对象和长时间存活的对象)会被长期使用。
分代收集策略将对象分为几个代(代际),如新生代和老生代:
- 新生代:存储生命周期较短的对象。垃圾回收在这个代上比较频繁,以快速清理那些不再使用的对象。
- 老生代:存储生命周期较长的对象。垃圾回收在这个代上不那么频繁,主要关注长时间存活的对象,减少对老生代的回收频率。
5. 标记-清除和分代收集结合
现代垃圾回收器结合了标记-清除和分代收集策略:
- 新生代使用标记-整理:在新生代中,标记-清除会在垃圾回收过程中整理对象,回收不再使用的内存。
- 老生代使用标记-压缩:在老生代中,标记-清除后还会进行压缩,减少内存碎片,使得连续的内存块更加紧凑,提高内存利用率。
6. 垃圾回收的触发
垃圾回收通常由 JavaScript 引擎自动管理,触发时机可能是以下情况:
- 内存不足:当系统检测到内存使用达到一定阈值时,会触发垃圾回收。
- 内存压力:在程序执行过程中,如果内存使用较高,也可能触发垃圾回收。
7. 优化内存使用
尽管垃圾回收是自动的,但我们仍然可以通过一些方法优化内存使用:
- 避免全局变量:全局变量的生命周期与程序相同,避免不必要的全局变量可以减少内存泄漏的风险。
- 清理引用:及时清理不再使用的对象和引用,帮助垃圾回收器更快地释放内存。
- 小心闭包:闭包会持有对其外部作用域的引用,可能导致内存泄漏。合理使用闭包,避免不必要的引用。
总结
垃圾回收是 JavaScript 自动管理内存的机制,主要包括标记-清除算法、引用计数、分代收集等策略。现代 JavaScript 引擎通过结合这些策略来有效管理内存,优化程序的性能和稳定性。
如何判断当前脚本运行在浏览器还是 Node 环境中?
在 JavaScript 中,有时需要判断代码是运行在浏览器环境还是 Node.js 环境中。以下是几种常见的方法来进行这种判断:
1. 检查全局对象
- 浏览器环境:全局对象通常是
window。 - Node.js 环境:全局对象是
global。
示例代码:
function isBrowser() {
return typeof window !== 'undefined' && typeof window.document !== 'undefined';
}
function isNode() {
return typeof global !== 'undefined' && typeof global.process !== 'undefined';
}
2. 检查环境特定的属性
- 浏览器环境:通常可以检查
window、document、navigator等对象是否存在。 - Node.js 环境:可以检查
process、module、require等对象是否存在。
示例代码:
function checkEnvironment() {
if (typeof window !== 'undefined' && typeof window.document !== 'undefined') {
// Browser environment
console.log('Running in a browser');
} else if (typeof global !== 'undefined' && typeof global.process !== 'undefined') {
// Node.js environment
console.log('Running in Node.js');
} else {
console.log('Unknown environment');
}
}
3. 检查环境特定的功能或模块
- 浏览器环境:可以检查
window对象中的浏览器特有的属性。 - Node.js 环境:可以检查
process对象中的属性,如process.version、process.versions等。
示例代码:
function isBrowser() {
return typeof window === 'object' && typeof window.document === 'object';
}
function isNode() {
return typeof process === 'object' && typeof process.versions === 'object' && typeof process.versions.node !== 'undefined';
}
4. 使用 typeof 检查环境特定的模块或对象
在 Node.js 中,尝试访问 require 函数;在浏览器中,尝试访问 window 对象。
示例代码:
function detectEnvironment() {
if (typeof window === 'object' && typeof window.document === 'object') {
console.log('Running in a browser');
} else if (typeof global === 'object' && typeof global.process === 'object') {
console.log('Running in Node.js');
} else {
console.log('Unknown environment');
}
}
5. 使用模块系统特性
- Browser: 通常可以检测是否使用了模块化系统(如 ES Modules)。
- Node.js: 使用
require或module相关的属性和方法。
示例代码:
function detectEnvironment() {
if (typeof window !== 'undefined' && typeof window.document !== 'undefined') {
// Browser environment
console.log('Running in a browser');
} else if (typeof global !== 'undefined' && typeof global.process !== 'undefined') {
// Node.js environment
console.log('Running in Node.js');
} else {
console.log('Unknown environment');
}
}
总结
通过检查全局对象、环境特定的属性、功能或模块,可以确定代码运行的环境。
JavaScript 事件循环是什么?
JavaScript 事件循环(Event Loop)是 JavaScript 运行时(如浏览器或 Node.js)管理和调度异步操作的一种机制。它确保了 JavaScript 单线程环境中异步代码的执行顺序和同步任务的协调。理解事件循环有助于理解 JavaScript 的异步编程和性能优化。
1. JavaScript 单线程
JavaScript 是单线程的,这意味着它一次只能执行一个任务。为了处理异步操作(如事件、定时器、网络请求),JavaScript 使用了事件循环机制来协调和调度这些操作。
2. 执行栈(Call Stack)
执行栈是一个 LIFO(Last In, First Out)结构,用于管理和执行代码。每当调用一个函数时,它会被推入栈中;函数执行完毕后,它会被从栈中弹出。
3. 任务队列(Task Queue)
任务队列(也称为消息队列或事件队列)用于存储异步操作的回调函数。异步操作完成后,其回调函数会被放入任务队列中,等待事件循环将其取出并执行。
4. 微任务队列(Microtask Queue)
微任务队列用于存储 Promise 的回调(如 .then 和 .catch),以及其他微任务(如 MutationObserver)。微任务具有比任务队列更高的优先级,会在每次事件循环的宏任务(任务队列)执行前执行。
5. 事件循环的工作原理
事件循环的工作原理如下:
-
执行栈为空时:事件循环会检查任务队列和微任务队列是否有待执行的任务。
-
执行微任务:
- 事件循环会从微任务队列中取出并执行所有微任务。
- 微任务会持续执行直到微任务队列为空。
-
执行宏任务:
- 事件循环会从任务队列中取出一个宏任务(如
setTimeout、setInterval的回调、I/O 操作的回调),并将其放入执行栈中执行。
- 事件循环会从任务队列中取出一个宏任务(如
-
渲染更新(浏览器环境):
- 在执行微任务和宏任务之间,浏览器可能会进行 DOM 更新和渲染。
-
重复:
- 事件循环会重复上述过程,直到所有任务和微任务都被处理完毕。
示例:
console.log('Start');
setTimeout(() => {
console.log('Timeout');
}, 0);
Promise.resolve().then(() => {
console.log('Promise');
});
console.log('End');
输出:
Start
End
Promise
Timeout
解释:
console.log('Start')和console.log('End')在主线程上同步执行。Promise.resolve().then()的回调被放入微任务队列。setTimeout的回调被放入宏任务队列。- 微任务(Promise 的回调)会在宏任务(setTimeout 的回调)之前执行。
6. 浏览器与 Node.js 的事件循环
-
浏览器:
- 事件循环除了处理宏任务和微任务外,还会进行渲染更新。
- 常见的宏任务包括
setTimeout、setInterval、setImmediate等。
-
Node.js:
- Node.js 的事件循环有不同的阶段(如 timers、I/O callbacks、idle, prepare、poll、check、close callbacks),每个阶段有不同的任务队列。
setImmediate在 Node.js 中用于将回调函数放入事件循环的check阶段。
JavaScript 中内存泄漏有哪几种情况?
在 JavaScript 中,内存泄漏指的是程序在运行过程中未能释放不再使用的内存,导致内存不断增长,从而影响程序的性能和稳定性。
1. 全局变量
问题:意外创建的全局变量不会被垃圾回收器自动清除,因为它们的生命周期与整个程序相同,可能会一直占用内存。
示例:
function createGlobalVariable() {
globalVar = 'This is a global variable'; // 缺少 `var`, `let` 或 `const` 关键字
}
createGlobalVariable();
console.log(globalVar); // 'This is a global variable'
解决方法:始终使用 var、let 或 const 声明变量,避免创建隐式全局变量。
2. 闭包导致的内存泄漏
问题:闭包可以导致函数内部引用外部函数的变量,从而使得这些变量无法被垃圾回收器释放,即使外部函数已经执行完毕。
示例:
function createClosure() {
let largeArray = new Array(1000000).fill('data'); // 大量数据
return function() {
console.log(largeArray[0]);
};
}
const closure = createClosure();
解决方法:谨慎使用闭包,确保在不再需要时及时释放不再使用的对象。
3. DOM 引用
问题:如果 JavaScript 保持对 DOM 元素的引用,而这些元素已从文档中删除,可能会导致这些元素无法被垃圾回收器释放。
示例:
let element = document.getElementById('myElement');
element.parentNode.removeChild(element); // 从 DOM 中移除元素
// 如果 JavaScript 代码中仍持有对 `element` 的引用,可能会导致内存泄漏
解决方法:在移除 DOM 元素时,确保清理所有对该元素的引用,避免造成内存泄漏。
4. 定时器和回调
问题:未清除的定时器(如 setInterval)和回调函数可能会导致内存泄漏,特别是当它们持有对大对象或 DOM 元素的引用时。
示例:
let intervalId = setInterval(function() {
// 执行某些操作
}, 1000);
// 当不再需要时没有清除定时器
解决方法:在不再需要定时器时,使用 clearInterval 或 clearTimeout 来停止它们。
5. 事件监听器
问题:添加的事件监听器如果不再需要时没有被移除,可能会导致内存泄漏,因为事件监听器会保持对目标对象的引用。
示例:
function addEventListener() {
const button = document.getElementById('myButton');
button.addEventListener('click', function() {
console.log('Button clicked');
});
}
// 持续添加事件监听器但未移除
解决方法:在不再需要事件监听器时,使用 removeEventListener 进行移除。
6. 计时器回调和长时间运行的操作
问题:长时间运行的操作(如大数据处理、复杂计算)可能会导致内存泄漏,因为它们可能会持有大量数据或复杂的状态。
示例:
function longRunningOperation() {
let largeData = new Array(1000000).fill('data');
// 长时间运行的操作
}
longRunningOperation();
解决方法:优化长时间运行的操作,确保及时清理不再需要的数据。
7. 意外的闭包和递归
问题:不正确的闭包和递归调用可能导致内存泄漏,尤其是在大量数据或复杂结构的情况下。
示例:
function recursiveFunction() {
let largeData = new Array(1000000).fill('data');
recursiveFunction(); // 递归调用可能导致堆栈溢出
}
recursiveFunction();
解决方法:优化递归调用,避免无限递归和过深的递归嵌套。
8. 缓存和存储
问题:某些缓存机制和存储(如全局缓存)可能导致内存泄漏,特别是在缓存未及时清除的情况下。
示例:
const cache = new Map();
function cacheData(key, value) {
cache.set(key, value);
}
// 未清除缓存
解决方法:使用适当的缓存策略,定期清理不再需要的缓存。
总结
内存泄漏通常是由于未能及时释放不再使用的内存或对象引用。常见的内存泄漏情况包括全局变量、闭包、DOM 引用、未清除的定时器和事件监听器、长时间运行的操作以及缓存和存储。通过小心管理变量引用、及时清理不再需要的对象和使用现代工具(如内存分析器),可以有效地减少和防止内存泄漏。
JavaScript 的本地存储有哪些方式?
在 JavaScript 中,本地存储(Local Storage)是指浏览器提供的一种机制,用于在用户的浏览器中持久化存储数据。
1. localStorage
- 描述:
localStorage是一种用于存储键值对的机制,这些键值对在浏览器关闭后仍然保持。数据持久化存储在浏览器中,直到显式删除。 - 存储大小:一般在 5MB 左右(具体大小依浏览器而异)。
- 使用方法:
// 设置数据
localStorage.setItem('key', 'value');
// 获取数据
const value = localStorage.getItem('key');
// 删除数据
localStorage.removeItem('key');
// 清除所有数据
localStorage.clear();
2. sessionStorage
- 描述:
sessionStorage是一种用于存储键值对的机制,这些键值对在浏览器会话(即标签页或窗口)中保持。数据在浏览器标签页或窗口关闭后被清除。 - 存储大小:一般在 5MB 左右(具体大小依浏览器而异)。
- 使用方法:
// 设置数据
sessionStorage.setItem('key', 'value');
// 获取数据
const value = sessionStorage.getItem('key');
// 删除数据
sessionStorage.removeItem('key');
// 清除所有数据
sessionStorage.clear();
3. Cookies
- 描述:Cookies 是一种用于存储小量数据的机制。数据会随每个 HTTP 请求发送到服务器,并且可以在浏览器中持久化存储,直到过期。
- 存储大小:每个 cookie 最大约为 4KB,总大小受到浏览器的限制(通常每个域名可存储 20 个左右的 cookies)。
- 使用方法:
// 设置 cookie
document.cookie = "key=value; expires=Thu, 01 Jan 2025 00:00:00 GMT; path=/";
// 获取 cookie
const cookies = document.cookie; // 注意:需要手动解析
// 删除 cookie
document.cookie = "key=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/";
4. IndexedDB
- 描述:IndexedDB 是一个低级 API,用于在浏览器中存储大量结构化数据,包括文件和二进制数据。它支持事务和索引,提供了更强大的查询和存储能力。
- 存储大小:通常没有明确限制,但浏览器可能会对单个域名设置存储配额。
- 使用方法:
// 打开数据库
const request = indexedDB.open('myDatabase', 1);
request.onupgradeneeded = function(event) {
const db = event.target.result;
// 创建对象存储(表)
db.createObjectStore('myStore', { keyPath: 'id' });
};
request.onsuccess = function(event) {
const db = event.target.result;
// 开始事务
const transaction = db.transaction('myStore', 'readwrite');
const store = transaction.objectStore('myStore');
// 添加数据
store.add({ id: 1, name: 'John' });
// 读取数据
const getRequest = store.get(1);
getRequest.onsuccess = function() {
console.log(getRequest.result);
};
};
5. WebSQL(不再推荐使用)
- 描述:WebSQL 是一个过时的 API,用于在浏览器中存储数据,使用 SQL 语法进行查询。已被 IndexedDB 替代,并且在新项目中不推荐使用。
- 存储大小:大小依浏览器实现而异。
- 使用方法:
// 打开数据库
const db = openDatabase('myDatabase', '1.0', 'Test DB', 2 * 1024 * 1024);
// 创建表并插入数据
db.transaction(function(tx) {
tx.executeSql('CREATE TABLE IF NOT EXISTS myTable (id unique, name)');
tx.executeSql('INSERT INTO myTable (id, name) VALUES (1, "John")');
});
// 查询数据
db.transaction(function(tx) {
tx.executeSql('SELECT * FROM myTable', [], function(tx, results) {
console.log(results.rows);
});
});
总结
localStorage和sessionStorage:适用于存储较小的数据,localStorage数据持久化,sessionStorage数据在会话结束时被清除。- Cookies:适用于少量数据的持久化存储,但数据会随每个请求发送,存储空间较小。
- IndexedDB:适用于存储大量结构化数据,支持事务和索引。
- WebSQL:过时的 API,建议使用 IndexedDB 替代。
第十章 应用
如何实现大文件上传?
1. 分片上传(Chunked Upload)
分片上传是将大文件拆分成小块(分片),逐个上传这些小块,并在服务器端重组。这种方法可以提高上传稳定性,允许恢复中断的上传,并减少单个请求的负载。
基本步骤:
- 拆分文件:将文件拆分成多个小块。
- 上传分片:逐个上传这些小块。
- 服务器端处理:在服务器端接收并重组分片,生成最终的完整文件。
- 处理进度:监控上传进度并更新用户界面。
示例代码(前端):
function uploadFile(file) {
const chunkSize = 1024 * 1024; // 1MB
const totalChunks = Math.ceil(file.size / chunkSize);
function uploadChunk(chunk, index) {
const formData = new FormData();
formData.append('file', chunk, file.name);
formData.append('index', index);
formData.append('totalChunks', totalChunks);
return fetch('/upload-chunk', {
method: 'POST',
body: formData
});
}
function uploadNextChunk(index) {
if (index >= totalChunks) {
return;
}
const start = index * chunkSize;
const end = Math.min(file.size, start + chunkSize);
const chunk = file.slice(start, end);
uploadChunk(chunk, index)
.then(() => uploadNextChunk(index + 1))
.catch(error => console.error('Upload failed:', error));
}
uploadNextChunk(0);
}
示例代码(服务器端,Node.js):
const express = require('express');
const fs = require('fs');
const path = require('path');
const app = express();
app.post('/upload-chunk', (req, res) => {
const { index, totalChunks } = req.body;
const file = req.files.file;
const uploadPath = path.join(__dirname, 'uploads', 'file.part');
const writeStream = fs.createWriteStream(uploadPath, { flags: 'a' });
file.pipe(writeStream);
writeStream.on('finish', () => {
if (parseInt(index) + 1 === parseInt(totalChunks)) {
// All chunks uploaded, merge chunks
fs.renameSync(uploadPath, path.join(__dirname, 'uploads', 'file'));
}
res.sendStatus(200);
});
});
2. 使用 FormData 和 XMLHttpRequest
使用 FormData 和 XMLHttpRequest 可以支持大文件的上传,同时能够监控上传进度。
示例代码(前端):
function uploadFile(file) {
const xhr = new XMLHttpRequest();
const formData = new FormData();
formData.append('file', file);
xhr.upload.onprogress = function(event) {
if (event.lengthComputable) {
const percentComplete = (event.loaded / event.total) * 100;
console.log(`Upload progress: ${percentComplete}%`);
}
};
xhr.onload = function() {
if (xhr.status === 200) {
console.log('Upload successful!');
} else {
console.error('Upload failed:', xhr.statusText);
}
};
xhr.open('POST', '/upload', true);
xhr.send(formData);
}
3. 使用 Blob 和 FileReader
使用 Blob 和 FileReader 来读取和上传文件的不同部分,以便在上传过程中处理更大的文件。
示例代码(前端):
function uploadFile(file) {
const reader = new FileReader();
reader.onload = function() {
const xhr = new XMLHttpRequest();
xhr.open('POST', '/upload', true);
xhr.setRequestHeader('Content-Type', 'application/octet-stream');
xhr.send(reader.result);
};
reader.readAsArrayBuffer(file);
}
4. 使用 Web Workers
Web Workers 可以在后台线程中执行上传操作,防止阻塞主线程,提高用户体验。
示例代码(前端):
// worker.js
self.onmessage = function(event) {
const { file, url } = event.data;
const xhr = new XMLHttpRequest();
xhr.open('POST', url, true);
xhr.send(file);
xhr.onload = function() {
self.postMessage('Upload complete');
};
};
// main.js
const worker = new Worker('worker.js');
worker.postMessage({ file: yourFile, url: '/upload' });
worker.onmessage = function(event) {
console.log(event.data);
};
5. 使用第三方库
许多第三方库和框架可以帮助处理大文件上传,例如:
- Dropzone: 提供了拖放上传和进度监控功能。
- Fine Uploader: 支持分片上传、拖放上传等。
- Plupload: 支持多种文件上传方式,包括分片上传。
总结
实现大文件上传的方法包括分片上传、使用 FormData 和 XMLHttpRequest、使用 Blob 和 FileReader、Web Workers 以及使用第三方库。选择哪种方法取决于具体的需求,例如文件大小、用户体验、兼容性和后端处理能力。
如何实现树形结构列表和扁平列表的互相转换?
树形结构列表和扁平列表的互相转换是数据处理中的常见需求,尤其是在处理分类数据或目录结构时。
1. 树形结构转换为扁平列表
树形结构示例
假设我们有一个树形结构的列表,每个节点包含子节点:
const tree = [
{
id: 1,
name: 'Root',
children: [
{
id: 2,
name: 'Child 1',
children: []
},
{
id: 3,
name: 'Child 2',
children: [
{
id: 4,
name: 'Grandchild 1',
children: []
}
]
}
]
}
];
转换函数
我们可以使用递归方法将树形结构转换为扁平列表:
function flattenTree(tree) {
const result = [];
function recurse(nodes, parentId) {
nodes.forEach(node => {
const { children, ...rest } = node;
result.push({ ...rest, parentId });
if (children && children.length > 0) {
recurse(children, node.id);
}
});
}
recurse(tree, null); // Root nodes have no parentId
return result;
}
const flatList = flattenTree(tree);
console.log(flatList);
输出:
[
{ id: 1, name: 'Root', parentId: null },
{ id: 2, name: 'Child 1', parentId: 1 },
{ id: 3, name: 'Child 2', parentId: 1 },
{ id: 4, name: 'Grandchild 1', parentId: 3 }
]
2. 扁平列表转换为树形结构
扁平列表示例
假设我们有一个扁平列表,每个项都有一个 parentId:
const flatList = [
{ id: 1, name: 'Root', parentId: null },
{ id: 2, name: 'Child 1', parentId: 1 },
{ id: 3, name: 'Child 2', parentId: 1 },
{ id: 4, name: 'Grandchild 1', parentId: 3 }
];
转换函数
我们可以通过构建一个映射来将扁平列表转换为树形结构:
function buildTree(flatList) {
const map = {};
const tree = [];
flatList.forEach(item => {
map[item.id] = { ...item, children: [] };
});
flatList.forEach(item => {
if (item.parentId === null) {
tree.push(map[item.id]);
} else {
if (map[item.parentId]) {
map[item.parentId].children.push(map[item.id]);
}
}
});
return tree;
}
const tree = buildTree(flatList);
console.log(tree);
输出:
[
{
id: 1,
name: 'Root',
parentId: null,
children: [
{
id: 2,
name: 'Child 1',
parentId: 1,
children: []
},
{
id: 3,
name: 'Child 2',
parentId: 1,
children: [
{
id: 4,
name: 'Grandchild 1',
parentId: 3,
children: []
}
]
}
]
}
]
总结
- 树形结构转换为扁平列表:递归遍历树形结构,将每个节点及其
parentId添加到扁平列表中。 - 扁平列表转换为树形结构:构建一个映射以便根据
parentId组织节点,然后形成树形结构。
什么是单点登录?
单点登录(Single Sign-On, SSO)是一种认证过程,通过这种方式,用户只需要登录一次即可访问多个应用程序或系统,而无需对每个系统或应用程序重复登录。它简化了用户的认证过程,并提高了用户体验。
单点登录的基本概念
- 用户登录:用户在单点登录系统中输入用户名和密码进行认证。
- 生成令牌:认证成功后,单点登录系统生成一个认证令牌(如令牌、票据、或会话ID),并将其传递给用户。
- 访问应用:用户访问其他需要认证的应用程序时,单点登录系统会自动提供认证令牌。
- 验证令牌:其他应用程序验证用户的认证令牌,以确保用户已经通过认证,从而允许用户访问其服务。
单点登录的工作流程
-
用户登录到单点登录服务:
- 用户向单点登录服务发送登录请求。
- 单点登录服务验证用户的凭据(如用户名和密码)。
-
单点登录服务生成和返回认证令牌:
- 登录成功后,单点登录服务生成一个认证令牌并返回给用户。
-
用户访问其他应用程序:
- 用户访问其他应用程序(这些应用程序已集成单点登录服务)。
- 用户的浏览器将认证令牌附加到请求中,或通过其他方式传递认证信息。
-
应用程序验证认证令牌:
- 应用程序验证认证令牌的有效性。
- 验证成功后,用户可以访问该应用程序的资源和功能。
-
用户注销:
- 用户可以选择注销,单点登录服务会撤销令牌,并使所有相关应用程序的会话失效。
单点登录的优点
- 简化用户体验:用户只需一次登录即可访问多个应用程序,无需重复输入凭据。
- 减少密码疲劳:减少了用户记住多个密码的需要。
- 集中管理:便于集中管理用户认证和授权。
- 提高安全性:可以集中处理安全策略,例如多因素认证(MFA),并减少密码暴露的机会。
单点登录的缺点
- 单点故障:如果单点登录服务出现故障,所有依赖该服务的应用程序也可能受到影响。
- 复杂的实现:集成和配置可能较为复杂,尤其是在处理不同系统和协议时。
- 安全风险:一个被攻破的账号可能会影响所有集成的系统,因此需要确保单点登录服务的安全性。
实现单点登录的常见协议和技术
- OAuth 2.0:一种授权框架,允许用户授权第三方应用程序访问其资源。常用于访问令牌的传递。
- OpenID Connect:基于 OAuth 2.0 的身份层协议,提供认证功能,允许应用程序验证用户身份。
- SAML (Security Assertion Markup Language) :基于 XML 的协议,主要用于 Web 单点登录。它通过 XML 断言传递认证信息。
- Kerberos:一种网络认证协议,使用票据来验证用户身份,主要用于内部网络环境。
总结
单点登录(SSO)是为了简化用户认证过程而设计的一种机制,使用户在登录一次后能够访问多个系统或应用程序。它提高了用户体验,并有助于集中管理认证和授权。实现 SSO 的方法有多种,常用的协议包括 OAuth 2.0、OpenID Connect 和 SAML。尽管 SSO 提供了许多便利,但也需要注意其潜在的安全风险和实施复杂性。
Web 常见的攻击方式有哪些?
1. SQL 注入(SQL Injection)
- 描述:攻击者通过在 Web 表单或 URL 中插入恶意 SQL 代码来操控数据库查询,从而获取、修改或删除数据库中的数据。
- 防护措施:使用参数化查询或预编译语句,避免将用户输入直接嵌入 SQL 语句中。
2. 跨站脚本(XSS, Cross-Site Scripting)
- 描述:攻击者在网页中注入恶意脚本,这些脚本会在其他用户的浏览器中执行。XSS 攻击可以窃取用户的 cookie、会话信息或执行其他恶意操作。
- 防护措施:对用户输入进行严格的验证和过滤,使用安全的编码方法,应用内容安全策略(CSP)。
3. 跨站请求伪造(CSRF, Cross-Site Request Forgery)
- 描述:攻击者诱使用户在已认证的会话中执行不期望的操作,例如发送未授权的请求。
- 防护措施:使用 CSRF 令牌来验证请求的合法性,确保每个状态改变请求都有唯一的令牌。
4. 会话劫持(Session Hijacking)
- 描述:攻击者窃取用户的会话 ID,从而获得对用户会话的控制权。
- 防护措施:使用安全的 cookie 属性(如 HttpOnly 和 Secure),定期更新会话 ID,使用 HTTPS 来保护数据传输。
5. 会话固定(Session Fixation)
- 描述:攻击者在用户认证前设置一个已知的会话 ID,用户认证后,攻击者可以利用这个会话 ID 获得用户的会话权限。
- 防护措施:在用户登录后更新会话 ID,确保会话 ID 在认证过程中不会被固定。
6. 目录遍历(Directory Traversal)
- 描述:攻击者通过访问不应公开的文件或目录,试图访问服务器文件系统上的敏感文件。
- 防护措施:对用户输入进行严格验证,限制文件访问权限,确保 Web 应用程序不允许访问不应暴露的文件和目录。
7. 命令注入(Command Injection)
- 描述:攻击者通过在应用程序中注入恶意命令,执行操作系统命令,从而获得系统权限或访问敏感信息。
- 防护措施:对用户输入进行严格验证,避免直接将输入嵌入系统命令中,使用安全的系统调用方法。
8. 文件上传漏洞(File Upload Vulnerability)
- 描述:攻击者上传恶意文件(如脚本文件)到服务器,通过这些文件执行恶意操作。
- 防护措施:对上传的文件进行严格验证,限制文件类型和大小,存储文件在隔离的目录中并设置适当的权限。
9. 服务器端请求伪造(SSRF, Server-Side Request Forgery)
- 描述:攻击者诱使服务器发起未经授权的请求,可能访问内部服务或外部资源。
- 防护措施:限制服务器发出的请求的目的地,验证和过滤用户输入,避免直接将用户输入作为请求目标。
10. 中间人攻击(MITM, Man-In-The-Middle)
- 描述:攻击者在用户和服务器之间拦截和篡改通信,窃取或修改数据。
- 防护措施:使用 HTTPS 加密通信,确保数据传输的安全性,使用证书验证和密钥交换机制。
11. 暴力破解(Brute Force Attack)
- 描述:攻击者通过尝试所有可能的密码或密钥组合,强行破解用户账户或加密数据。
- 防护措施:实施账户锁定策略、强密码策略,使用 CAPTCHA 防止自动化攻击。
12. 拒绝服务攻击(DoS/DDoS, Denial of Service/Distributed Denial of Service)
- 描述:攻击者通过大量无效请求或流量使服务器无法处理合法请求,从而导致服务中断。
- 防护措施:使用流量过滤、负载均衡和防火墙,监控和限制异常流量。