面试题[JavaScript]
1. JavaScript 基础知识
1.1 数据类型
- JavaScript 中将数据类型分为基本数据类型和引用数据类型。
- 基本数据类型:boolean、string、number、undefined、null、symbol、bigint。
- 引用数据类型:array、object。
- 另外一种说法是,JavaScript 有八种基本数据类型,分为原始类型(Primitive Types)和引用类型(Reference Types):
- 原始类型:boolean、string、number、undefined、null、symbol、bigint。
- 引用类型:Object(包括普通对象、数组、函数等)
- 两者区别
- 存储区别
- 原始类型存储在栈(stack)中,值直接保存在变量访问的位置,由于其大小固定且频繁使用,存储在栈中具有更高的性能。
- 引用类型存储在堆(heap)中,占用空间较大且大小不固定,变量保存的是对实际对象的引用(即指针),这些引用存储在栈中。
- 赋值方式区别
- 原始类型:复制的是值本身。例如,将一个 number 类型的变量赋值给另一个变量,两个变量互不影响。
- 引用类型:复制的是引用(指针)。多个变量引用同一个对象时,一个变量的修改会影响其他变量。
- 存储区别
- 类型检测
- 使用 typeof 检查原始类型(例如:typeof 123 === "number")。
- 使用 instanceof 检查引用类型(例如:[] instanceof Array === true)。
- null 是一个特殊情况,typeof null 返回 "object",这是 JavaScript 早期实现中的一个 bug,但被保留了下来。
- 每个对象都有一个 constructor 属性,指向创建该对象的构造函数。
- 类型转换
- 自动类型转换:如字符串与数字相加时,数字会被转换为字符串。
- 显式类型转换:使用 Number()、String()、Boolean() 等函数将值转换为指定类型。
- 堆和栈的区别
- 栈:内存分配效率高,自动管理(由编译器分配和释放)。
- 堆:内存分配灵活,但需要由开发者手动管理内存(通过垃圾回收机制)。
// 判断变量是否是数组
Array.isArray(arr)
arr instanceof Array
Object.prototype.toString.call(arr) === '[object Array]'
1.2 typeof 和 instanceof 的区别
typeof 和 instanceof 是 JavaScript 中用于检查变量类型的两个关键字,但它们的使用场景和功能有所不同。
typeof
typeof 操作符用于检测变量的类型,返回一个字符串,表示操作数的数据类型,常见的返回值如下:
- 1)"undefined":表示值未定义。
- 2)"boolean":表示布尔值。
- 3)"number":表示数字。
- 4)"string":表示字符串。
- 5)"object":表示对象(包括 null,数组,对象字面量等)。
- 6)"function":表示函数。
- 7)"symbol":表示符号(ES6 引入)。
- 8)"bigint":表示大整数(ES11 引入)。 示例如下:
javascriptconsole.log(typeof undefined); // "undefined" console.log(typeof true); // "boolean" console.log(typeof 42); // "number" console.log(typeof "hello"); // "string" console.log(typeof {}); // "object" console.log(typeof []); // "object" console.log(typeof null); // "object" (特殊情况) console.log(typeof function(){}); // "function" console.log(typeof Symbol()); // "symbol" console.log(typeof 10n); // "bigint" console.log(typeof NaN); // 输出: "number"
instanceof
MDN:
instanceof
运算符用于检测构造函数的prototype
属性是否出现在某个实例对象的原型链上。instanceof 操作符用于检测某个对象是否是另一个对象(构造函数)的实例,返回一个布尔值,一些使用场景如下:
- 1)用于检测复杂类型,比如对象、数组、函数等。
- 2)检测某个对象是否继承自某个构造函数的原型链。 示例如下:
javascriptconsole.log({} instanceof Object); // true console.log([] instanceof Array); // true console.log(function(){} instanceof Function); // true console.log(new Date() instanceof Date); // true function MyClass() {} let myInstance = new MyClass(); console.log(myInstance instanceof MyClass); // true
两者区别
- 检测类型的范围:typeof 主要用于检测基本数据类型(如 number,string,boolean 等)以及函数、未定义类型和 symbol,而 instanceof 主要用于检测对象的具体类型,检查某个对象是否是某个构造函数的实例。
- 检测基本类型和引用类型:typeof 对于基本类型非常有用,但对于复杂引用类型(如数组、对象字面量)只会返回 "object",而 instanceof 只能用于引用类型,不能用于检测基本数据类型。
- 特殊情况:typeof null 返回 "object",这是一个 JavaScript 语言的历史遗留问题,而 instanceof 可以用来检测自定义对象的类型,通过检查原型链来确认实例关系。
1.3 typeof null 的结果是什么?为什么?
在 JavaScript 中,typeof null
的结果是 "object"
。这是一个历史遗留问题,而不是出于设计上的考虑。
在 JavaScript 中,所有事物都是对象,但有一个例外:null
。然而,为了简化类型检测,typeof
操作符对于原始数据类型(如 number
、string
、boolean
)返回的是它们的小写形式字符串(如 "number"
、"string"
、"boolean"
),但对于复杂的数据类型(如对象和函数),它返回的是 "object"
。
原因可以追溯到 JavaScript 的早期版本,特别是 ECMAScript 3 和更早的版本。在 JavaScript 最初的实现中,JavaScript 中的值是由一个表示类型的标签和实际数据值表示的。对象的类型标签是 0。由于 null
代表的是空指针(大多数平台下值为 0x00),因此,null 的类型标签是 0,typeof null
也因此返回 "object"
。(参考来源 (opens new window))
曾有一个 ECMAScript 的修复提案(通过选择性加入的方式),但被拒绝了 (opens new window)。该提案会导致 typeof null === "null"
。
1.4 字符串的常用方法
- 字符串的创建与转换
- String():将给定值转换为字符串。
- toString():调用对象上的方法,将其转换为字符串。
- charAt(index):返回指定索引处的字符。
- charCodeAt(index):返回指定索引处字符的Unicode编码。
- fromCharCode():静态方法,接受一个或多个字符编码,将它们组合成一个字符串。
- 字符串的截取与分割
- slice(beginIndex, endIndex):提取字符串的片段,并在新的字符串中返回被提取的部分,不包括
endIndex
处的字符。 - substring(startIndex, endIndex):提取字符串中介于两个指定下标之间的字符。
- substr(startIndex, length):从起始索引号提取指定长度的子字符串。
- split(separator, limit):将一个字符串分割成字符串数组。
- slice(beginIndex, endIndex):提取字符串的片段,并在新的字符串中返回被提取的部分,不包括
- 字符串的查找与匹配
- indexOf(searchValue, fromIndex):返回在字符串中首次找到指定值的索引,如果未找到则返回-1。
- lastIndexOf(searchValue, fromIndex):返回字符串中指定值最后出现的位置,如果未找到则返回-1。
- includes(searchString, position):判断一个字符串是否包含在另一个字符串中,根据情况返回true或false。
- startsWith(searchString, position):判断字符串是否以指定的子字符串开头。
- endsWith(searchString, length):判断字符串是否以指定的子字符串结尾。
- match(regexp):检索返回一个字符串匹配正则表达式的结果。
- search(regexp):检索与正则表达式相匹配的值。
- 字符串的替换与连接
- replace(searchValue, newValue):在字符串中查找匹配的子串并替换与正则表达式匹配的子串。
- concat(string2, string3[, ..., stringN]):连接两个或多个字符串,并返回新的字符串。
- padStart(targetLength, padString):在当前字符串的开始填充指定的字符串,直到达到指定的长度。
- padEnd(targetLength, padString):在当前字符串的末尾填充指定的字符串,直到达到指定的长度。
- 字符串的比较与转换大小写
- localeCompare(target):比较两个字符串,并返回基于字符串顺序的指示符。
- toLowerCase():将字符串中的所有字符转换为小写。
- toUpperCase():将字符串中的所有字符转换为大写。
- 其他常用方法
- trim():去除字符串两端的空白字符。
- trimStart():去除字符串开始处的空白字符。
- trimEnd():去除字符串末尾的空白字符。
- repeat(count):返回一个新字符串,该字符串包含被连接在一起的指定数量的字符串副本。
1.5 数组的常用方法
- 创建和初始化数组
- Array() 构造函数:创建一个新的数组实例。
- 数组字面量:使用方括号
[]
直接创建数组。
- 添加和删除元素
- push(element1, ..., elementN):在数组的末尾添加一个或多个元素,并返回新的长度。
- pop():移除数组的最后一个元素,并返回该元素的值。
- shift():移除数组的第一个元素,并返回该元素的值。
- unshift(element1, ..., elementN):在数组的开头添加一个或多个元素,并返回新的长度。
- 查找元素
- indexOf(searchElement[, fromIndex]):返回在数组中可以找到一个给定元素的第一个索引,如果不存在,则返回-1。
- includes(searchElement[, fromIndex]):判断一个数组是否包含一个指定的值,根据情况返回
true
或false
。 - lastIndexOf(searchElement[, fromIndex]):返回在数组中可以找到一个给定元素的最后一个索引,如果不存在,则返回-1。
- find(callback[, thisArg]):返回数组中满足提供的测试函数的第一个元素的值。
- findIndex(callback[, thisArg]):返回数组中满足提供的测试函数的第一个元素的索引。
- 迭代数组
- forEach(callback[, thisArg]):为数组中的每个元素执行一次提供的函数。
- map(callback[, thisArg]):创建一个新数组,其结果是该数组中的每个元素是调用一次提供的函数后的返回值。
- filter(callback[, thisArg]):创建一个新数组,其包含通过所提供函数实现的测试的所有元素。
- reduce(callback[, initialValue]):对数组中的每个元素执行一个由您提供的reducer函数(升序执行),将其结果汇总为单个返回值。
- some(callback[, thisArg]):测试数组中是不是至少有1个元素通过了被提供的函数测试。
- every(callback[, thisArg]):测试数组的所有元素是否都通过了指定函数的测试。
- 修改数组
- sort([compareFunction]):对数组的元素进行排序并返回数组。
- reverse():颠倒数组中元素的顺序。
- splice(start[, deleteCount[, item1[, item2[, ...]]]]):通过删除或替换现有元素或者添加新元素来修改数组,返回由被删除的元素组成的数组。
- copyWithin(target, start[, end]):在数组内部,将指定位置的成员复制到另一个指定位置,并返回这个数组。
- 合并数组
- concat(array2[, array3[, ...arrayN]]):合并两个或多个数组。此方法不会改变现有数组,而是返回一个新数组。
- 其他方法
- join(separator):将一个数组的所有元素连接成一个字符串。
- slice([begin[, end]]):返回一个新的数组对象,这一对象是一个由原数组中的从开始到结束(不包括结束)的一个浅拷贝。
- toLocaleString():返回一个字符串,该字符串表示数组中的所有元素。元素将通过各自的
toLocaleString
方法进行转换。 - toString():返回一个字符串,该字符串表示数组中的所有元素。
- length:数组的长度属性,不是方法,但经常用于操作数组。
- 扩展知识
- 在 JavaScript 中,map 和 forEach 方法中是不能直接使用 break 或 continue 语句来结束循环的。这是因为 map 和 forEach 是高阶函数,它们的设计初衷就是要遍历整个数组。
1.6 对象的常用方法
通用对象方法
这些方法适用于所有JavaScript对象,因为它们是从
Object
原型继承的。toString()
:返回对象的字符串表示。toLocaleString()
:返回对象的本地化字符串表示。valueOf()
:返回对象的原始值。hasOwnProperty(prop)
:检查对象自身(而不是原型链)是否具有指定的属性。isPrototypeOf(obj)
:检查一个对象是否存在于另一个对象的原型链上。propertyIsEnumerable(prop)
:检查对象的某个属性是否可枚举。getOwnPropertyDescriptor(prop)
:返回指定属性的属性描述符。getOwnPropertyNames()
:返回一个数组,其包含对象自身的所有属性名(非可枚举属性除外)。
创建和修改对象
Object.create(proto[, propertiesObject])
:创建一个新对象,使用现有的对象来提供新创建的对象的__proto__
。Object.defineProperty(obj, prop, descriptor)
:在对象上定义一个新属性,或修改一个对象的现有属性,并返回该对象。Object.defineProperties(obj, props)
:在对象上定义多个新属性或修改现有属性,并返回该对象。Object.assign(target, ...sources)
:将所有可枚举属性的值从一个或多个源对象复制到目标对象。返回目标对象。
遍历对象属性
for...in
循环:遍历对象的可枚举属性(包括原型链上的属性)。Object.keys(obj)
:返回一个数组,其包含对象自身的所有可枚举属性名。Object.values(obj)
:返回一个数组,其包含对象自身的所有可枚举属性值的集合。Object.entries(obj)
:返回一个给定对象自身可枚举属性的键值对数组。
其他对象方法
Object.freeze(obj)
:阻止新属性添加到对象,并标记所有现有属性为不可配置。同时,阻止修改现有属性的值,以及阻止删除属性。Object.seal(obj)
:阻止新属性添加到对象,并标记所有现有属性为不可配置。但属性的值仍然可以修改。Object.isFrozen(obj)
:判断一个对象是否被冻结。Object.isSealed(obj)
:判断一个对象是否被密封。Object.isExtensible(obj)
:判断一个对象是否是可扩展的(即是否能够添加新的属性)。Object.preventExtensions(obj)
:阻止新属性添加到对象。
特定内置对象的方法
除了上述通用方法外,JavaScript还提供了一些特定内置对象的方法,如
Array
、String
、Date
和Math
等对象的方法。这些方法在各自的对象文档中有详细描述,并用于执行与该对象相关的特定操作。
1.7 什么是可迭代对象
可迭代对象是一个允许你对其元素进行遍历的对象。
在 JavaScript 中,可迭代对象(iterable)是一个实现了迭代协议的对象,允许你在对象上创建迭代器(iterator)。迭代器是一个实现了迭代器协议的对象,它保持了对集合中每个元素的跟踪,并记住遍历的位置。
可迭代对象:一个对象要实现可迭代协议,必须实现一个特殊的方法 @@iterator()
,这个方法在对象上调用时会返回一个迭代器。在大多数情况下,你会通过调用对象的 Symbol.iterator
方法([Symbol.iterator]()
)来获取迭代器,但更常见的是使用扩展运算符(...
)、for...of
循环或 Array.from()
等内置功能来自动处理迭代。
迭代器:迭代器必须实现一个迭代器协议,这个协议定义了一个对象的 next()
方法,该方法返回一个对象,该对象具有两个属性:value
和 done
。value
属性表示迭代器返回的当前元素的值,done
属性是一个布尔值,当迭代器遍历完所有元素时,它为 true
。
常见的可迭代对象:
- 数组(Array):数组是可迭代对象,因为它们实现了
@@iterator()
方法。 - 字符串(String):字符串也是可迭代对象,可以按字符迭代。
- Map 和 Set:ES6 引入的
Map
和Set
对象也是可迭代对象。 - arguments 对象:函数中的
arguments
对象是可迭代的。 - NodeList:在DOM操作中,
NodeList
对象(例如,通过document.querySelectorAll
获取的节点列表)通常是可迭代的(尽管这取决于具体的浏览器实现)。
使用示例:
// 数组是可迭代的
const arr = [1, 2, 3];
for (const value of arr) {
console.log(value); // 输出 1, 2, 3
}
// 字符串是可迭代的
const str = 'hello';
for (const char of str) {
console.log(char); // 输出 'h', 'e', 'l', 'l', 'o'
}
// 自定义可迭代对象
const iterable = {
0: 'a',
1: 'b',
2: 'c',
length: 3,
[Symbol.iterator]() {
let index = 0;
return {
next: () => {
if (index < this.length) {
return { value: this[index++], done: false };
} else {
return { done: true };
}
}
};
}
};
for (const value of iterable) {
console.log(value); // 输出 'a', 'b', 'c'
}
1.8 什么是类数组对象
类数组对象(Array-like Object)指的是具有类似数组结构的对象。
定义与特性
- 定义:类数组是一个对象,它拥有length属性,并且其属性名通常为非负整数。这使得它在外观上类似于数组,但实际上并不是数组。
- 特性:
- length属性:类数组对象通常具有一个length属性,用于表示对象中元素的个数。
- 索引访问:可以通过索引值从类数组对象中获取元素,就像访问数组元素一样。
- 无数组方法:尽管类数组对象具有length属性和可以通过索引访问元素,但它们并不具备数组原型上的方法(如push、pop、forEach等)。因此,无法直接使用这些方法对类数组对象进行操作。
常见类数组对象
- arguments对象:在JavaScript函数内部自动创建的对象,用于存储函数调用时传递的参数。它是一个类数组对象,可以通过索引访问参数,并具有length属性。
- HTMLCollection和NodeList:DOM操作返回的一些对象,如document.getElementsByTagName()返回的对象集合,也是类数组对象。
- 字符串:在JavaScript中,字符串也可以被视为类数组对象,因为可以通过索引访问每个字符,并具有length属性。但需要注意的是,字符串本身并不是数组。
类数组与数组的区别
- 原型链:类数组的原型关系与数组不同,因此无法直接调用数组的方法。而数组则继承自Array.prototype,可以使用数组的所有方法。
- 类型:类数组本质上是对象,而数组则是一种特殊的数据结构。
类数组转换为数组
由于类数组对象无法直接使用数组的方法,因此有时需要将其转换为真正的数组。在JavaScript中,可以使用以下方法将类数组对象转换为数组:
Array.from()
方法:这是一个静态方法,它接受一个类数组对象或可迭代对象,并返回一个新的数组实例。javascriptconst arrayLike = {0: 'a', 1: 'b', length: 2}; const array = Array.from(arrayLike); console.log(array); // ['a', 'b']
扩展运算符(
...
):在ES6中,扩展运算符可以用于将类数组对象或可迭代对象展开为数组元素。javascriptconst arrayLike = {0: 'a', 1: 'b', length: 2}; const array = [...arrayLike]; console.log(array); // ['a', 'b']
Array.prototype.slice.call()
方法:在ES6之前,这种方法常用于将类数组对象转换为数组。它利用了Array.prototype.slice
方法,该方法可以接受一个类数组对象作为上下文(this
值),并返回一个新的数组。javascriptconst arrayLike = {0: 'a', 1: 'b', length: 2}; const array = Array.prototype.slice.call(arrayLike); console.log(array); // ['a', 'b']
1.9 执行上下文、执行栈
在 JavaScript 中,执行上下文(Execution Context)和执行栈(Call Stack)是理解代码执行流程的两个核心概念。让我们详细探讨一下它们。
执行上下文(Execution Context)
执行上下文是 JavaScript 代码执行时的一个抽象环境。它包含了三个重要的部分:
- 变量对象(Variable Object, VO):
- 在 ES5 中,变量对象包含了函数声明和变量声明。
- 在 ES6 及以后,引入了
let
和const
,这些声明不再存储在变量对象中,而是有其特定的行为(块级作用域)。但为简化理解,可以认为变量对象存储了所有可访问的变量和函数。
- 作用域链(Scope Chain):
- 作用域链是一个对象列表,它决定了变量和函数在何处查找它们的值。它基于嵌套函数的层级关系形成。
this
值:this
值在函数调用时确定,并且与函数的调用方式有关。
执行上下文分为两类:
- 全局执行上下文(Global Execution Context):
- 在脚本开始执行时创建,并且在整个脚本的生命周期内都存在。
- 全局对象(在浏览器中为
window
,在 Node.js 中为global
)是全局执行上下文的变量对象。
- 函数执行上下文(Function Execution Context):
- 每次调用函数时都会创建一个新的函数执行上下文。
- 变量对象(Variable Object, VO):
执行栈(Call Stack)
执行栈(也称为调用栈)是一种后进先出(LIFO)的数据结构,用于管理执行上下文。JavaScript 引擎使用执行栈来跟踪当前正在执行的函数。
执行栈的工作流程
- 全局执行上下文:
- 当 JavaScript 引擎开始执行脚本时,它会首先创建一个全局执行上下文并将其推入执行栈。
- 函数调用:
- 当一个函数被调用时,JavaScript 引擎会为该函数创建一个新的执行上下文,并将其推入执行栈的顶部。
- 函数执行完毕后,其对应的执行上下文会从执行栈中弹出。
- 递归调用:
- 如果函数内再次调用函数(包括递归调用),新的执行上下文会被创建并推入执行栈。
- 当所有调用都返回时,这些执行上下文会依次从栈中弹出。
- 栈溢出(Stack Overflow):
- 如果执行栈中的执行上下文过多(例如,递归调用没有正确的终止条件),JavaScript 引擎会抛出栈溢出错误。
- 栈清空:
- 当执行栈为空时,JavaScript 引擎认为当前脚本执行完毕。
- 全局执行上下文:
示例
javascriptfunction firstFunction() { console.log('First function'); secondFunction(); } function secondFunction() { console.log('Second function'); thirdFunction(); } function thirdFunction() { console.log('Third function'); } firstFunction();
执行流程:
- 创建全局执行上下文,并将其推入执行栈。
- 调用
firstFunction
,创建firstFunction
的执行上下文,并将其推入执行栈。 firstFunction
调用secondFunction
,创建secondFunction
的执行上下文,并将其推入执行栈。secondFunction
调用thirdFunction
,创建thirdFunction
的执行上下文,并将其推入执行栈。thirdFunction
执行完毕,其执行上下文从执行栈中弹出。secondFunction
执行完毕,其执行上下文从执行栈中弹出。firstFunction
执行完毕,其执行上下文从执行栈中弹出。- 全局执行上下文是最后一个从执行栈中弹出的(但通常不会显式弹出,因为它会一直存在)。
理解执行上下文和执行栈对于调试和优化 JavaScript 代码非常重要。通过掌握这些概念,你可以更好地理解和预测代码的行为。
1.10 闭包和作用域
闭包定义
根据 JavaScript 中的词法作用域规则,内部函数总是可以访问其外部函数中声明的变量。当内部函数被返回到外部函数之外时,即使外部函数执行结束了,但是内部函数引用了外部函数的变量,这些变量仍然会被保存在内存中。这个现象称为闭包。
相关概念
- JavaScript 中常见的作用域包括全局作用域、函数作用域、块级作用域。
- JavaScript 中自由变量的查找是在函数定义的地方,向上级作用域查找,不是在执行的地方。
作用域
作用域,其实就是一个变量或函数在代码中的可访问范围。在 JavaScript 中,主要有两种作用域:全局作用域和局部作用域。全局作用域的变量在整个脚本中都可访问,而局部作用域的变量只能在特定的代码块、函数内使用。
- 全局作用域:定义在所有函数体以及其他代码块之外的变量,称为全局变量。它们在脚本的任何地方都是可访问的。
- 局部作用域:局部变量定义在函数内或代码块内(如
if
、for
块),它们只能在函数内或代码块内访问。局部作用域又可细分为函数作用域和块作用域。 - 函数作用域:只在函数内部可见的变量,这种作用域在早期的 JavaScript 中非常常见。
- 块作用域:ES6 引入的
let
和const
关键字,使得可以在块级代码(类似{}
)内部定义变量,即所谓的块作用域。
作用域扩展
- 提升(Hoisting):发生在变量声明和函数声明上,旨在解释为什么即使在声明之前使用变量也不会报错。变量提升是指不论变量在代码中的位置,它们会被提升到代码的顶部进行声明,而函数提升不仅是声明,它会把整个函数提升到顶部。
- 作用域链:当查找一个变量时,JavaScript 引擎会首先在当前作用域中查找,如果未找到,它会沿着作用域链向上查找,直到全局作用域。如果还未找到,则返回
undefined
。 - 闭包(Closure):函数内定义的函数能够访问外部函数的变量,这就是闭包。它是一种特殊的作用域情况,能让我们创建私有变量和函数。
- 立即执行函数表达式(IIFE):一种常见的技术,通过定义和立即调用一个匿名函数,创建一个新的作用域,从而保护内部变量不受外部干扰。同时它也会避免全局变量污染的问题。
- 严格模式(Strict Mode):严格模式扩展了 ECMAScript 3 的语法和语义范围,使 JavaScript 在更严格的条件下执行,有助于更好地调试和提升代码的安全性。
- 模块化(Modules):现代 JavaScript 越来越依赖模块化,通过
import
和export
关键字,可以在不同模块之间共享代码,避免作用域污染,并且更好地组织代码。
闭包的应用场景
- 闭包是作用域应用的特殊场景。常见的闭包使用有两种场景:一种是函数作为参数被传递;一种是函数作为返回值被返回。
怎么看待闭包的副作用?
- 闭包的正面副作用
- 数据封装与状态维护:
- 闭包可以用来封装数据,从而避免全局变量的污染。
- 它们可以帮助维护内部状态,这对于某些特定的算法和数据结构(如私有变量、计数器、工厂函数等)特别有用。
- 创建高阶函数:
- 闭包使得函数可以作为参数传递给其他函数,并可以返回另一个函数。
- 这种能力允许我们创建更具灵活性和复用性的代码。
- 回调函数与异步编程:
- 在处理异步操作时,闭包常用于传递上下文数据给回调函数。
- 这对于在JavaScript等单线程语言中进行异步编程特别重要。
- 数据封装与状态维护:
- 闭包的负面副作用
- 内存泄漏:
- 如果闭包持续引用其外部作用域中的变量,而这些变量不再被需要,可能会导致内存无法被垃圾回收器回收,进而造成内存泄漏。
- 这在长时间运行的服务器或前端应用中尤为关键。
- 难以调试:
- 闭包可能导致代码的隐藏依赖,使得理解代码的上下文变得更加困难。
- 特别是在大型或复杂的代码库中,闭包的使用可能会使得调试过程变得更加繁琐。
- 性能问题:
- 如果闭包被大量使用,尤其是在密集计算或高频率调用的场景下,可能会导致性能下降。
- 这主要是因为闭包创建时会捕捉其外部作用域,这可能会增加内存开销和运行时复杂度。
- 内存泄漏:
- 如何合理使用闭包
- 明确用途:
- 在使用闭包之前,确保明确其用途和目的。
- 避免在没有必要的情况下使用闭包。
- 谨慎管理内存:
- 时刻注意闭包对外部变量的引用,确保不再需要的变量可以被垃圾回收。
- 尤其是在使用闭包进行事件监听或定时器时,要记得及时移除它们以避免内存泄漏。
- 代码文档化:
- 对于复杂的闭包逻辑,要确保编写详细的文档和注释。
- 这有助于其他开发者(或未来的你)更好地理解代码的意图和行为。
- 测试和性能监控:
- 对使用闭包的代码进行充分的测试,确保它们按预期工作。
- 使用性能监控工具来评估闭包对应用性能的影响,并在必要时进行优化。
- 明确用途:
综上所述,闭包是一个强大的工具,但也需要谨慎使用。通过明确其用途、谨慎管理内存、编写文档以及进行测试和性能监控,我们可以最大化地利用闭包的优点,同时最小化其潜在的副作用。
- 闭包的正面副作用
1.11 变量提升
在 JavaScript 中,变量提升(Hoisting)是指在变量声明和函数声明被提升到其作用域的顶部的行为。这意味着,无论这些声明在代码中的哪个位置,它们都会被提升到作用域的开头,但需要注意的是,只有声明会被提升,赋值操作不会。
这里有几点需要注意:
变量声明提升:使用
var
关键字声明的变量会被提升到作用域的顶部,但赋值操作不会提升。javascriptconsole.log(a); // 输出: undefined var a = 2;
在上述代码中,
var a
被提升到了作用域的顶部,但a = 2
没有被提升,所以在console.log(a)
时,a
是undefined
。函数声明提升:函数声明也会被提升到作用域的顶部,并且整个函数定义(包括函数体和参数)都会被提升。
javascriptconsole.log(foo()); // 输出: "Hello, world!" function foo() { return "Hello, world!"; }
在上面的例子中,
function foo() {...}
被提升到了作用域的顶部。函数表达式提升:函数表达式不同于函数声明,它们不会被提升。如果你尝试在函数表达式声明之前调用它,将会导致
ReferenceError
。javascriptconsole.log(bar()); // ReferenceError: bar is not defined var bar = function() { return "Hello, world!"; }; console.log(bar); // 输出: undefined var bar = function() { return "Hello, world!"; };
在上面的例子中,
var bar
被提升,但bar
被初始化为函数表达式的过程没有提升,所以在console.log(bar())
时,bar
还未被定义。使用
let
和const
的声明:let
和const
声明的变量不会被提升到作用域的顶部,而是被“块级作用域”(block scope)所限制,并且存在“暂时性死区”(Temporal Dead Zone,TDZ)。在变量声明之前的区域被称为 TDZ,访问这些变量会导致ReferenceError
。javascriptconsole.log(b); // ReferenceError: Cannot access 'b' before initialization let b = 3;
在上述代码中,尝试在
let b
声明之前访问b
会导致ReferenceError
。
理解变量提升对于编写健壮的 JavaScript 代码非常重要,因为它可以帮助你避免一些常见的陷阱和错误。
1.12 对 this 的理解
1.13 原型和原型链
1.14 new 操作符做了什么
在 JavaScript
中,new
操作符用于创建一个给定构造函数的实例对象。
// 构造函数返回值是基本数据类型
function Test(name) {
this.name = name;
return 1;
}
const t = new Test('xxx');
console.log(t.name); // 'xxx'
// 构造函数返回值是对象
function Test(name) {
this.name = name;
console.log(this);
return { age: 26 };
}
const t = new Test('xxx'); // Test { name: 'xxx' }
console.log(t); // { age: 26 }
console.log(t.name); // undefined
流程:
- 【1】创建一个新的对象
obj
; - 【2】将对象与构造函数通过原型链连接起来;
- 【3】将构造函数中的
this
绑定到新建的对象obj
上; - 【4】根据构造函数返回类型作判断,如果是原始值则被忽略,如果是返回对象,需要正常处理(需要特别注意一下,上面有对比例子)。
// 手写 new 的执行过程:
function mynew(Func, ...args) {
// 1.创建一个新对象
const obj = {};
// 2.新对象原型指向构造函数原型对象
obj.__proto__ = Func.prototype;
// 3.将构造函数的this指向新对象
let result = Func.apply(obj, args);
// 4.根据返回值判断
return result instanceof Object ? result : obj;
}
function Person(name, age) {
this.name = name;
this.age = age;
}
Person.prototype.say = function () {
console.log(this.name)
}
let p = mynew(Person, "huihui", 123)
console.log(p) // Person { name: "huihui", age: 123 }
p.say() // huihui
1.15 实现继承
1.16 事件模型
事件冒泡(Event Bubbling)、事件捕获(Event Capturing)、事件委托(Event Delegation)。
JavaScript 中的事件处理模型主要分为两种:事件冒泡和事件捕获。简单来说,事件冒泡是事件从最深的嵌套元素开始逐级向上传播,直到传到顶层元素;事件捕获则相反,事件从顶层元素开始逐级向下传递,直到触发目标元素。默认的事件传递机制是事件冒泡。
事件冒泡(Bubbling):事件先从目标元素开始,一层一层向上传播到根元素。
事件捕获(Capturing):事件从根元素一层一层向下传播到目标元素。
事件委托:事件冒泡机制允许我们使用事件委托(Event Delegation)技术,以减少事件监听器的数量,从而提高性能。例如,把所有子元素的点击事件委托给父元素处理:
parentElement.addEventListener('click', function(event) {
if(event.target.matches('.childClass')) {
// 子元素被点击的逻辑处理
}
});
事件流阶段
- 捕获阶段:从 document 根对象往目标元素传播。
- 目标阶段:事件到达目标元素处。
- 冒泡阶段:从目标元素往 document 根对象传播。
1.17 事件循环
1.18 异步编程
在 JavaScript 中,异步编程是一项非常核心且重要的技能,它允许程序在等待某些操作(如网络请求、文件读取或定时器等)完成时,继续执行其他任务,从而提高应用程序的响应性和性能。以下是 JavaScript 中处理异步编程的几种主要方法:
回调函数(Callbacks)
回调函数是最基本的异步编程技术。你可以将一个函数作为参数传递给另一个函数,并在异步操作完成时调用这个回调函数。
javascriptfunction fetchData(callback) { setTimeout(() => { const data = "Hello, World!"; callback(data); }, 1000); } fetchData((data) => { console.log(data); });
Promise
Promise 是一种更强大和灵活的异步编程模式,用于处理更复杂的异步操作链。一个 Promise 对象表示一个最终可能完成(并得到一个结果值)或失败(并得到一个原因)的异步操作。
javascriptfunction fetchData() { return new Promise((resolve, reject) => { setTimeout(() => { const success = true; if (success) { resolve("Hello, World!"); } else { reject("Error fetching data"); } }, 1000); }); } fetchData() .then((data) => { console.log(data); }) .catch((error) => { console.error(error); });
async/await
async
和await
是基于 Promise 的语法糖,使得异步代码看起来更像同步代码,增强了可读性和可维护性。async
函数返回一个 Promise,而await
表达式用于等待 Promise 完成,并返回 Promise 的结果。javascriptfunction fetchData() { return new Promise((resolve, reject) => { setTimeout(() => { const success = true; if (success) { resolve("Hello, World!"); } else { reject("Error fetching data"); } }, 1000); }); } async function getData() { try { const data = await fetchData(); console.log(data); } catch (error) { console.error(error); } } getData();
事件循环(Event Loop)
Generator 函数
Generator 函数提供了一种更为复杂的方式来处理异步操作,但相对于
async/await
,它们更不常用。Generator 函数可以暂停和恢复执行,使得可以在等待异步操作完成时返回控制权。javascriptfunction* fetchDataGenerator() { const promise = new Promise((resolve, reject) => { setTimeout(() => { resolve("Hello, World!"); }, 1000); }); const data = yield promise; console.log(data); } const gen = fetchDataGenerator(); gen.next().value.then((data) => { gen.next(data); });
1.19 箭头函数
箭头函数(Arrow Functions)是ES6(ECMAScript 2015)引入的一种更简洁的函数写法。相比于传统的函数表达式,箭头函数提供了一种更简短的语法,并且不绑定自己的this
,arguments
,super
,或new.target
。这些特性使得箭头函数在某些场景下更加适用和简洁。
基本语法
javascript// 箭头函数的基本语法如下: const functionName = (parameters) => { // function body }; // 如果函数体只有一条语句,并且需要返回结果,可以省略花括号和大括号内的return关键字: const functionName = (parameters) => expression; // 如果参数只有一个,可以省略参数的小括号: const functionName = parameter => expression; // 返回对象字面量 // 由于箭头函数中的花括号会被解析为函数体的开始和结束,所以在返回对象字面量时需要用小括号包裹起来: const createPerson = (name, age) => ({ name: name, age: age }); console.log(createPerson("Alice", 30)); // 输出: { name: "Alice", age: 30 }
特性
不绑定自己的
this
:箭头函数不绑定自己的this
,它会捕获其所在上下文的this
值,作为自己的this
值,这使得在回调函数中处理this
变得更加容易:javascriptfunction Person() { this.age = 0; setInterval(() => { this.age++; // 这里的this指向Person实例 console.log(this.age); }, 1000); } const p = new Person();
没有
arguments
对象:箭头函数不提供arguments
对象。如果需要访问函数的参数列表,可以使用剩余参数(...args
)代替:javascriptconst showArguments = (...args) => { console.log(args); }; showArguments(1, 2, 3); // 输出: [1, 2, 3]
不能用作构造函数:箭头函数不能使用
new
操作符,否则会抛出一个错误:javascriptconst Foo = () => {}; const bar = new Foo(); // TypeError: Foo is not a constructor
没有
prototype
属性:由于箭头函数不能用作构造函数,它们也没有prototype
属性:javascriptconst Foo = () => {}; console.log(Foo.prototype); // 输出: undefined
不能改变
this
的绑定:call()
、apply()
、bind()
等方法对于箭头函数来说不起作用,因为箭头函数的this
是在定义时就确定好的:javascriptconst func = () => { return this; }; const boundFunc = func.bind({ a: 42 }); console.log(boundFunc()); // 输出: 取决于func被定义时的上下文,而不是{ a: 42 }
总结
箭头函数提供了一种更简洁、更优雅的函数定义方式,特别适用于回调函数和需要保持
this
上下文不变的场景。然而,由于它们的一些限制(如不能使用new
、没有arguments
对象、this
不可变等),在某些情况下仍需使用传统的函数表达式。
2. JavaScript 拓展知识
2.1 ECMA 标准从提案到发布有几个阶段?哪个阶段是具有里程碑意义的
ECMA标准从提案到发布经历了以下几个阶段:
- Stage 0 - Strawman(草案):这个阶段是最初的提案阶段,通常由个人或小组提出,并还没有经过正式的标准化流程。提案可能只是一个想法或初步的概念。
- Stage 1 - Proposal(提案):在这个阶段,提案开始进入正式的标准化流程。提案需要详细说明其功能、语法和语义,并且需要提供示例代码和使用案例。
- Stage 2 - Draft(草稿):在这个阶段,提案转化为一份详细的草稿,其中包含了具体的语法规范和语义定义。草稿需要经过审查和讨论,并且需要有多个独立实现的证明。
- Stage 3 - Candidate(候选):在这个阶段,提案已经足够成熟,可以被视为候选标准。这意味着提案已经通过了实际应用并经过广泛的测试和实现。
- Stage 4 - Finished(完成):在这个阶段,提案被接受为最终的标准,已经准备好发布。提案的规范细节已经完善,并且已经有多个独立实现通过了所有测试。
这些阶段代表了ECMA标准的不同发展阶段,其中最具里程碑意义的是Stage 4 - Finished(完成)阶段。在这个阶段,提案被接受为最终的标准,意味着它已经经过了广泛的实现、测试和审查,并被认为是稳定和可靠的。完成阶段的标准可以被广泛采用和应用于实际的编程环境中。
2.2 JavaScript 中的严格模式
"use strict" 是 JavaScript 中的一个指令,用于启用严格模式。
"use strict" 是什么
"use strict" 是一个字符串声明,放在脚本或函数的开头,用来指定代码应该在严格模式下执行。
javascript"use strict"; // 后面的代码会在严格模式下执行
严格模式的主要作用
严格模式主要是为了捕获一些常见的编程错误,并防止使用一些可能在未来版本中定义的语法。
使用 "use strict" 的主要区别
1)变量必须声明后再使用
javascript"use strict"; x = 3.14; // 错误:x 未定义
2)禁止使用 with 语句
javascript"use strict"; with (Math){x = cos(2)}; // 语法错误
3) 创建 eval 作用域
在严格模式下,eval() 中的代码会在自己的作用域中执行,而不是在当前作用域中。
4)禁止 this 关键字指向全局对象
javascript"use strict"; function f(){ return this; } f(); // 返回 undefined,而不是全局对象
5)函数参数不能有重名
javascript"use strict"; function sum(a, a, c){ // 语法错误 return a + a + c; }
6)禁止八进制数字语法
javascript"use strict"; var sum = 015 + 197 + 142; // 语法错误
7)禁止对只读属性赋值
javascript"use strict"; var obj = {}; Object.defineProperty(obj, "x", { value: 0, writable: false }); obj.x = 3.14; // 抛出错误
8)禁止删除不可删除的属性
javascript"use strict"; delete Object.prototype; // 抛出错误
使用 "use strict" 的好处
- 消除 JavaScript 语法的一些不合理、不严谨之处,减少一些怪异行为。
- 消除代码运行的一些不安全之处,保证代码运行的安全。
- 提高编译器效率,增加运行速度。
- 为未来新版本的 JavaScript 做好铺垫。
拓展
在类和模块中,严格模式会自动启用。使用 "use strict" 可以帮助我们写出更加规范、安全的代码,并且可以在开发阶段就发现一些潜在的问题。在现代 JavaScript 开发中,特别是使用 ES6 模块或类时,严格模式已经成为默认行为。
2.3 JavaScript 中的包装类型
JavaScript 中,原始值没有方法或属性,但为了能够使用方法和属性,JavaScript 提供了包装类型,使得原始值可以像对象一样被操作。
包装类型的概念
包装类型是 JavaScript 中的一种机制,它允许原始值临时拥有对象的属性和方法。JavaScript 提供了三个包装类型:
- String
- Number
- Boolean
这些包装类型分别对应于原始值 string、number 和 boolean。
包装类型的行为
当你试图访问一个原始值的属性或方法时,JavaScript 会在后台自动创建一个对应的包装对象,然后在该对象上调用方法或访问属性。一旦操作完成,这个临时创建的对象就会被销毁,示例如下:
javascriptlet str = "hello"; console.log(str.toUpperCase()); // "HELLO" let num = 42; console.log(num.toFixed(2)); // "42.00" let bool = true; console.log(bool.toString()); // "true"
原始值和包装对象的区别
虽然包装类型使得原始值可以像对象一样操作,但它们本质上是不同的,示例如下:
javascriptlet strPrimitive = "hello"; let strObject = new String("hello"); console.log(typeof strPrimitive); // "string" console.log(typeof strObject); // "object" console.log(strPrimitive === strObject); // false console.log(strPrimitive == strObject); // true
这个例子中,strPrimitive 是一个原始值,而 strObject 是一个 String 对象。它们在类型上是不同的,严格相等(===)比较时会返回 false,但宽松相等(==)比较时会返回 true,因为 strObject 会被转换为原始值进行比较。
2.4 不创建新变量的前提下,如何交换两个变量的位置
- 解构赋值:适用于数组、对象和其他可迭代的数据结构。
let a = 10;
let b = 20;
[a, b] = [b, a];
- 位运算符 -- 位异或(^)运算符:只适用于数字类型的变量。
let a = 1;
let b = 2;
a = a ^ b;
b = a ^ b;
a = a ^ b;
- 加法和减法、乘法和除法:只适用于数字类型的变量。
let a = 1;
let b = 2;
a = a + b;
b = a - b;
a = a - b;
a = a * b;
b = a / b;
a = a / b;
2.5 JavaScript 文件相互引用有什么问题
可能导致代码执行错误或者无法正常工作。下面是一些常见的问题及其解决方法:
- 循环依赖(Circular Dependency): 循环依赖指的是两个或多个模块相互依赖,直接或间接地引用对方,导致模块无法正确加载。解决循环依赖问题的方法包括:
- 重构代码结构,将共享的逻辑抽离到单独的模块中,避免直接相互引用。
- 使用异步加载模块的方式,如
import()
动态导入语法,可以延迟加载模块,避免循环依赖问题。
- 加载顺序错误: 当 JavaScript 文件相互引用时,确保它们之间的加载顺序是正确的非常重要。如果加载顺序错误,可能会导致某些模块在使用时还未被加载,从而出现错误。解决加载顺序错误的方法包括:
- 明确定义模块之间的依赖关系,确保先加载依赖的模块,再加载依赖它们的模块。
- 使用模块打包工具(如 Webpack、Rollup 等)来管理模块之间的依赖关系和加载顺序,确保打包后的文件能正确加载所有模块。
- 使用命名导出和默认导出: 在模块间引用时,可以使用命名导出和默认导出来更清晰地定义模块之间的关系。通过明确导出和导入需要的函数、变量等,可以减少不必要的引用问题。
- 使用事件订阅/发布模式: 如果存在模块之间需要通信的情况,可以考虑使用事件订阅/发布模式(Event Emitter),模块之间通过事件进行通信,避免直接引用对方。
总的来说,避免 JavaScript 文件相互引用时的常见问题,需要注意模块之间的依赖关系、加载顺序以及合理设计模块之间的通信机制。通过合理的代码组织和模块化设计,可以有效减少相互引用带来的问题,并确保代码的可维护性和可扩展性。
2.6 JavaScript 脚本延迟加载的方式
在JavaScript中,脚本延迟加载的方式主要有以下几种:
async 属性:
async 属性用于让脚本尽可能地异步加载。它不会阻塞HTML解析,脚本一旦下载完成就立即执行。但是需要注意,如果有多个 async 脚本,它们的执行顺序是不确定的。通常用于独立性较高的脚本,比如第三方的统计代码、广告代码等。
html<script src="example.js" async></script>
defer 属性:
defer 属性同样用于脚本延迟加载,但是它保证了所有 defer 脚本会按照在文档中出现的顺序执行,并且是在HTML解析完成后才执行。因此,适合于依赖于HTML结构的脚本,比如需要操作DOM的脚本。
html<script src="example.js" defer></script>
动态创建脚本元素:
通过JavaScript动态创建
<script>
标签并插入到文档中。这种方式可以较为灵活地控制脚本的加载和执行时机。常在需要某些条件触发时才加载的场景中使用。javascriptvar script = document.createElement('script'); script.src = 'example.js'; document.head.appendChild(script);
使用模块化加载工具:
现在有很多前端模块化工具,比如RequireJS,Webpack的动态加载等等。这些工具提供了更为强大的依赖管理和延迟加载功能。适用于大型项目中,解决代码拆分和按需加载的问题。
javascript// 以Webpack的动态加载为例 import('example.js').then(module => { // 使用加载的模块 });
以上就是脚本延迟加载的几种主要方式,它们适用于不同的需求和场景。在实际开发中,我们可以根据项目的具体情况选择合适的延迟加载方案,这样不仅可以提高页面的加载速度,还可以提升用户体验。
2.7 JavaScript 数值精度问题
在 JavaScript中,由于浮点数的表示方式(基于IEEE 754 标准),我们有时会遇到数值精度问题。这些问题主要源于二进制浮点数的表示限制,导致无法精确表示所有的十进制小数。
以下是一些解决 JavaScript 数值精度问题的方法:
- toFixed() 方法:
- 当你需要将浮点数转换为字符串并保留一定的小数位数时,可以使用
toFixed()
方法。但请注意,toFixed()
返回的是一个字符串,而不是一个数字。
- 当你需要将浮点数转换为字符串并保留一定的小数位数时,可以使用
- Math.round(), Math.floor(), Math.ceil():
- 这些函数可以帮助你四舍五入、向下取整或向上取整到最接近的整数。
- Number.EPSILON:
- 设置一个误差范围,通常称为“机器精度”。
- 使用第三方库:
- 有些第三方库,如
math.js
、decimal.js
或bignumber.js
,提供了高精度的十进制数运算。这些库可以处理比JavaScript内置Number类型更大或更精确的数值。
- 有些第三方库,如
在处理金融或需要高精度计算的场景时,使用第三方库(如 math.js
、decimal.js
或 bignumber.js
)通常是一个好主意,因为它们提供了比JavaScript内置Number类型更高的精度和可靠性。
2.8 事件 level0、level1
在HTML中,level0
和level1
并不是标准的事件处理术语。然而,我们可以讨论HTML中的事件处理机制,以及如何在不同层级(如元素层级)上添加事件。
基本的 HTML 事件处理(类似于 “level0”)
在HTML中,你可以直接在元素标签内使用
on<event>
属性来添加事件监听器。例如:html<button onclick="alert('Button clicked!')">Click Me</button>
使用 JavaScript 添加事件监听器(类似于 “level1” 或更高级别)
为了更灵活和可维护的代码,通常建议使用JavaScript来添加事件监听器。这可以通过
addEventListener
方法来实现。html<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Event Handling Example</title> </head> <body> <button id="myButton">Click Me</button> <script> // 获取按钮元素 var button = document.getElementById('myButton'); // 添加点击事件监听器 button.addEventListener('click', function() { alert('Button clicked via addEventListener!'); }); </script> </body> </html>
层级概念
在 HTML 和 JavaScript 中,事件可以在不同的层级上被捕获和处理。这通常涉及事件冒泡(event bubbling)和事件捕获(event capturing)的概念。
- 事件冒泡:事件从触发它的最内层元素(目标元素)开始,然后向外层元素传播,直到到达文档的根元素(通常是
document
对象)。 - 事件捕获:与事件冒泡相反,事件从文档的根元素开始,然后向目标元素传播,直到到达目标元素本身。
在大多数情况下,你会使用事件冒泡,因为它更符合直觉,并且更容易管理。但是,在某些情况下,你可能需要使用事件捕获来阻止事件冒泡或实现特定的交互逻辑。
- 事件冒泡:事件从触发它的最内层元素(目标元素)开始,然后向外层元素传播,直到到达文档的根元素(通常是
level0、level1 对比
虽然 “level0” 和 “level1” 不是 HTML 或 JavaScript 中的标准术语,但你可以理解它们为不同级别的事件处理方式。基本的 HTML 事件处理(如
onclick
属性)可以看作是较低级别的方式,而使用 JavaScript 的addEventListener
方法则提供了更高级别、更灵活的事件处理机制。场景
在HTML和JavaScript中,如果一个元素同时被添加了两种事件处理器(例如,通过HTML属性如
onclick
和通过JavaScript的addEventListener
方法),那么事件的触发顺序通常遵循以下规则:- HTML属性事件处理器(如
onclick
):这些事件处理器是在元素标记中直接定义的,它们通常会在JavaScript添加的事件监听器之前被触发。这是因为这些事件处理器是在元素解析和构建DOM时就已经存在的,而JavaScript添加的事件监听器则是在页面加载和JavaScript执行时才被添加到DOM中的。 - JavaScript添加的事件监听器(
addEventListener
):这些事件监听器是通过JavaScript代码动态添加到元素上的。它们会在HTML属性事件处理器之后被触发。这是因为addEventListener
方法允许更精细的控制,包括指定事件是否在捕获阶段触发,以及添加多个事件监听器而不会相互覆盖。
然而,需要注意的是,这种触发顺序并不是绝对的,因为它可能受到多种因素的影响,包括浏览器的实现细节、JavaScript代码的执行时机和顺序等。在某些情况下,如果JavaScript代码在页面加载的非常早期就执行了(例如,在
<head>
标签中或通过DOMContentLoaded
事件),并且添加了事件监听器,那么这些监听器可能会在页面完全解析和构建DOM之前就已经存在,从而可能改变事件的触发顺序。此外,如果使用了事件委托(即在父元素上监听事件,然后根据事件的目标元素来执行相应的操作),那么事件的触发顺序也会受到影响,因为事件是在父元素上捕获的,然后根据需要冒泡到目标元素或停止传播。
总的来说,为了确保事件的正确处理和触发顺序,建议避免在同一个元素上同时使用HTML属性事件处理器和JavaScript添加的事件监听器。相反,应该选择其中一种方法,并保持一致的事件处理策略。这样可以减少潜在的冲突和不确定性,使代码更加清晰和可维护。
- HTML属性事件处理器(如
2.9 CommonJS 和 ES Module 的区别
CommonJS 和 ES Module 是 JavaScript 中两种不同的模块规范,它们在定义、使用以及特性上存在显著的差异。以下是对这两者的详细比较:
- 定义与用途
- CommonJS
- 定义:CommonJS是一种服务器端的模块规范,旨在为非浏览器的JavaScript环境(如Node.js)提供模块化支持。
- 用途:主要用于服务器端JavaScript开发,特别是在Node.js平台上。
- ES Module(ESM)
- 定义:ES Module是ECMAScript 2015(ES6)中引入的官方模块系统,也称为ESM。
- 用途:适用于浏览器和服务器端环境,是现代JavaScript开发中广泛使用的模块规范。
- CommonJS
- 模块导入导出语法
- CommonJS
- 导出:使用
module.exports
或exports
对象来导出模块成员。 - 导入:使用
require
函数来导入其他模块。
- 导出:使用
- ES Module
- 导出:使用
export
关键字来导出模块成员,可以导出变量、函数、类等。 - 导入:使用
import
关键字来导入其他模块的成员,支持默认导入和命名导入。
- 导出:使用
- CommonJS
- 加载时机与方式
- CommonJS
- 加载时机:同步加载,即模块在运行时被加载和执行。
- 加载方式:动态加载,可以在代码的任何地方使用
require
来加载模块。
- ES Module
- 加载时机:静态加载,即模块在编译时就被确定和加载。
- 加载方式:静态导入,
import
语句必须位于模块的顶层,不能在条件语句或函数内部使用。
- CommonJS
- 缓存与值绑定
- CommonJS
- 缓存:对每一个加载的模块都存在缓存,一旦模块被加载,除非手动清除缓存,否则在后续的
require
调用中会返回相同的模块实例。 - 值绑定:导出的是值的拷贝,如果导出的是一个对象,则后续对该对象的修改会影响到导入模块中的对象(引用类型),但对于原始类型(如数字、字符串)的修改则不会。
- 缓存:对每一个加载的模块都存在缓存,一旦模块被加载,除非手动清除缓存,否则在后续的
- ES Module
- 缓存:浏览器环境对ES Module的加载也实现了缓存机制,但缓存策略可能因浏览器而异。
- 值绑定:导出的是值的引用,即导入和导出的值都指向同一个内存地址,因此导入模块中的值会随着导出模块中的值的变化而变化。
- CommonJS
- 其他特性
- CommonJS
- 支持循环引用:由于采用同步加载和缓存机制,CommonJS能够很好地处理循环引用问题。
- 顶层
this
指向:在CommonJS模块中,顶层的this
指向模块本身。
- ES Module
- 严格模式:ES Module默认在严格模式下执行,无需显式声明
use strict
。 - 顶层
this
指向:在ES Module中,顶层的this
值为undefined
。 - Tree Shaking:ES Module支持Tree Shaking,即静态分析并移除未使用的代码,从而减小打包后的文件体积。
- Code Splitting:ES Module支持Code Splitting,即按需加载代码,提高应用的加载速度和性能。
- 严格模式:ES Module默认在严格模式下执行,无需显式声明
- CommonJS
综上所述,CommonJS和ES Module在定义、语法、加载时机与方式、缓存与值绑定以及其他特性上均存在显著差异。开发者在选择使用哪种模块规范时,应根据具体的应用场景和需求进行权衡。
2.10 setTimeout 和 setInterval 的区别
setTimeout 和 setInterval 都是 JavaScript 中常用的定时器函数,用于在指定的时间点或周期性地执行代码。以下是两者的详细区别:
触发时间与执行频率
- setTimeout
- 触发时间:一次性定时器,在设定的延迟时间之后执行一次指定的函数。
- 执行频率:只执行一次,之后不再自动触发。
- setInterval
- 触发时间:重复性定时器,会以设定的时间间隔重复执行指定的函数。
- 执行频率:按照设定的时间间隔不断重复执行,直到被取消。
- setTimeout
用法与参数
setTimeout
语法:
setTimeout(function, delay, [param1, param2, ...])
function
:要执行的函数。delay
:延迟的毫秒数。[param1, param2, ...]
:可选参数,传递给要执行的函数的参数。
示例:
setTimeout(function() { console.log("Hello, World!"); }, 2000);
这段代码将在2秒后执行一次指定的函数,打印“Hello, World!”到控制台。
setInterval
语法:
setInterval(function, milliseconds, [param1, param2, ...])
function
:要重复执行的函数或代码块。milliseconds
:重复执行的时间间隔(以毫秒为单位)。[param1, param2, ...]
:可选参数,传递给要执行的函数的参数。
示例:
setInterval(function() { console.log("Hello, World!"); }, 1000);
这段代码将每隔1秒执行一次指定的函数,不断打印“Hello, World!”到控制台。
执行时间准确性
- setTimeout:不保证在指定的毫秒数之后立即执行,它只是将函数放入异步任务队列中,并在队列中的等待时间过后执行。如果队列中有其他任务需要执行,那么可能会稍微延迟执行。
- setInterval:同样受到 JavaScript 运行环境的影响,可能会有一些微小的延迟。特别是当设定的时间间隔较短时,可能会因为浏览器的性能限制而导致定时器不准确。建议将时间间隔设为100毫秒或以上,以保证计时器的准确性。
实际应用场景
- setTimeout:一般用于需要延迟执行的场合,例如动画效果的延迟、按钮的防抖等。
- setInterval:一般用于需要定时执行的场合,例如轮播图的切换、时钟的更新等。
取消定时器
- clearTimeout:用于取消由setTimeout设置的定时器。
- clearInterval:用于取消由setInterval设置的定时器。
综上所述,setTimeout 和 setInterval 在触发时间、执行频率、用法与参数、执行时间准确性以及实际应用场景等方面都存在明显的区别。在编写 JavaScript 代码时,应根据具体需求选择合适的定时器函数。
2.11 for...in 和 for...of 的区别
for...in 和 for...of 的区别主要有 2 点,最关键的区别在于它们遍历的对象和返回的值不同:
for...in 循环
- 用于遍历对象的可枚举属性,包括其原型链上的可枚举属性。
- 返回的是属性名(键名)。
- 可以遍历普通对象,也可以遍历数组(但不推荐)。
- ES3 语法。
for...of 循环
- 用于遍历可迭代对象(如数组、字符串、Map、Set等)。
- 返回的是每次迭代的值。
- 不能直接用于遍历普通对象。
- ES6 语法。
示例代码
javascript// for...in 示例 const obj = { a: 1, b: 2, c: 3 }; for (const key in obj) { console.log(key); // 输出: "a", "b", "c" } // for...of 示例 const arr = [1, 2, 3]; for (const value of arr) { console.log(value); // 输出: 1, 2, 3 }
重要区别
- 遍历顺序:for...in 不保证遍历顺序,而 for...of 会按照迭代器定义的顺序进行遍历。
- 性能:通常来说,for...of 的性能比 for...in 更好,特别是在遍历数组时。所以遍历优先选 for...of
- 继承属性:for...in 会遍历对象的原型链,而 for...of 不会。
- 使用场景:for...in 更适合用于遍历对象的属性,而 for...of 更适合用于遍历数组或其他可迭代对象的值。
2.12 Object.prototype.toString.call
Object.prototype.toString.call() 方法返回一个表示对象内部属性 [[Class]] 的字符串,通过它可以准确判断对象的类型,示例如下:
Object.prototype.toString.call(42); // "[object Number]"
Object.prototype.toString.call("hello"); // "[object String]"
Object.prototype.toString.call(true); // "[object Boolean]"
Object.prototype.toString.call(undefined); // "[object Undefined]"
Object.prototype.toString.call(null); // "[object Null]"
Object.prototype.toString.call({}); // "[object Object]"
Object.prototype.toString.call([]); // "[object Array]"
Object.prototype.toString.call(function() {}); // "[object Function]"
2.13 undefined 和 null 的区别
undefined 是 JavaScript 的一种内置数据类型,表示变量声明了但未赋值。null 同样是一种内置数据类型,表示一个空对象引用。
- 类型检测
- 使用 typeof 检测 undefined 会返回 "undefined"。
- 使用 typeof 检测 null 会返回 "object",这是一个历史遗留问题。
- 比较操作
- undefined 和 null 使用双等号 == 比较时会被认为相等,因为它们都代表“没有值”的概念。
- 使用严格等号 === 比较时,它们是不相等的,因为它们是不同类型的值。
- 变量赋值
- undefined 是 JavaScript 引擎自动赋予未赋值变量的值。
- null 是开发者显式赋值以表示变量没有值。
- 数值转换
- undefined 转换成数值的时候返回的是 NaN。
- null 转换为数组返回的是 0。
2.14 如何获取安全的 undefined 值
使用 void 运算符对其后的表达式进行求值,然后返回 undefined。因为 void 运算符总是返回 undefined,而且 0 是一个非常短的常量表达式,所以 void 0 是一种简洁且安全的方式来获得 undefined,示例如下:
let safeUndefined = void 0;
console.log(safeUndefined); // 输出: undefined
补充:为什么 void 0 就是安全的,为什么不直接使用 undefined?
因为 undefined 在较低版本的 Node/浏览器 环境中可以被重定义。如果被重新定义了,全局的 undefined 就会受到污染,会导致之后的代码出现问题,例如:
let undefined = 'hello';
let test;
console.log(test === undefined ? 'undefined' : 'not undefined')
// 输出:not undefined
console.log(test === void 0 ? 'undefined' : 'not undefined')
// 输出:undefined
2.15 NaN
NaN 的特点
NaN 是唯一一个不等于自身的值。这意味着 NaN !== NaN 总是返回 true,示例如下:
javascriptconsole.log(NaN === NaN); // 输出: false console.log(NaN !== NaN); // 输出: true
判断 NaN 可以使用 isNaN() 函数来判断,示例如下:
javascriptconsole.log(isNaN(NaN)); // 输出: true console.log(isNaN(123)); // 输出: false
NaN 产生的原因
数学运算结果未定义或无法表示,示例如下:
javascriptconsole.log(0 / 0); // 输出: NaN
将无法解析为数字的字符串转换为数字,示例如下:
javascriptconsole.log(Number("abc")); // 输出: NaN
计算结果超出 JavaScript 能表示的数字范围,示例如下:
javascriptconsole.log(Math.sqrt(-1)); // 输出: NaN
isNaN 和 Number.isNaN 函数有什么区别
isNaN 函数会先尝试将传入的参数转换为数字,然后检查转换后的值是否为 NaN。这意味着它不仅检测 NaN 本身,还会将那些不能转换为有效数字的值视为 NaN,示例如下:
javascriptconsole.log(isNaN(NaN)); // 输出: true console.log(isNaN('hello')); // 输出: true console.log(isNaN(undefined)); // 输出: true console.log(isNaN({})); // 输出: true console.log(isNaN(123)); // 输出: false console.log(isNaN('123')); // 输出: false
Number.isNaN 函数不会进行类型转换,只会在参数本身是 NaN 的情况下返回 true。它更为严格,只有传入的值是 NaN 时才会返回 true,示例如下:
javascriptconsole.log(Number.isNaN(NaN)); // 输出: true console.log(Number.isNaN('hello')); // 输出: false console.log(Number.isNaN(undefined)); // 输出: false console.log(Number.isNaN({})); // 输出: false console.log(Number.isNaN(123)); // 输出: false console.log(Number.isNaN('123')); // 输出: false
2.16 == 操作符的强制类型转换规则
== 会在比较两个值时进行强制类型转换。这种类型转换遵循一套规则,使得不同类型的值可以相互比较。
强制转换规则
null 和 undefined:null 和 undefined 仅相等于自身和对方,示例如下:
javascriptconsole.log(null == undefined); // true console.log(null == null); // true console.log(undefined == undefined); // true console.log(null == 0); // false console.log(undefined == 0); // false
Boolean 类型:如果有一个操作数是布尔值,JavaScript 会将布尔值转换为数字,然后再进行比较,示例如下:
javascriptconsole.log(true == 1); // true console.log(false == 0); // true console.log(true == 2); // false
字符串和数字:如果是字符串和数字比较,JavaScript 会将字符串转换为数字,然后再进行比较,示例如下:
javascriptconsole.log('42' == 42); // true console.log('42' == '42'); // true console.log('42' == 43); // false console.log('0' == false); // true
对象和原始类型:如果有一个操作数是对象,另一个是原始类型(字符串、数字、布尔值),JavaScript 会尝试调用对象的 toPrimitive 方法(valueOf 或 toString)将对象转换为原始类型,然后再进行比较,示例如下:
javascriptconsole.log([1, 2] == '1,2'); // true console.log([1] == 1); // true console.log({} == '[object Object]'); // true
Symbol 和其他类型:Symbol 类型只能与 Symbol 类型进行比较,与其他类型的比较总是返回 false,示例如下:
javascriptconsole.log(Symbol() == Symbol()); // false console.log(Symbol() == 'symbol'); // false console.log(Symbol() == false); // false
特殊情况
空字符串:空字符串会被转换为数字 0 进行比较,示例如下:
javascriptconsole.log('' == 0); // true console.log('' == false); // true
对象转换为原始类型:对象的比较会触发类型转换,通过调用 toPrimitive 方法(valueOf 或 toString),转换为原始类型后再比较,示例如下:
javascriptlet obj = { toString: () => '42' }; console.log(obj == '42'); // true console.log(obj == 42); // true
2.17 Object.is() 与比较操作符 == 和 === 的区别
双等号(==)
双等号进行相等判断时,如果两边的类型不一致,则会进行类型转换后再进行比较,规则如下:
- 如果类型不同,会进行类型转换。
- 将 null 和 undefined 视为相等。
- 将布尔值转换为数字再进行比较。
- 将字符串和数字进行比较时,会将字符串转换为数字。
- 对象与原始类型进行比较时,会将对象转换为原始类型。
三等号(===)
三等号进行相等判断时,不会进行类型转换。如果两边的类型不一致,则直接返回 false,规则如下:
- 如果类型不同,返回 false。
- 如果类型相同,再进行值的比较。
Object.is()
Object.is() 在大多数情况下与三等号的行为相同,但它处理了一些特殊情况,如 -0 和 +0,以及 NaN,规则如下:
- 如果类型不同,返回 false。
- 如果类型相同,再进行值的比较。
- 特殊情况:-0 和 +0 不相等,两个 NaN 是相等的。
2.18 逻辑操作符
逻辑或操作符 (||)
逻辑或操作符 || 会在找到第一个真值时立即返回该值。如果所有操作数都为假值,则返回最后一个操作数。具体规则如下:
- 对第一个操作数进行条件判断。
- 如果第一个操作数的条件判断结果为 true,则返回第一个操作数的值。
- 如果第一个操作数的条件判断结果为 false,则返回第二个操作数的值。
逻辑与操作符 (&&)
逻辑与操作符 && 会在找到第一个假值时立即返回该值。如果所有操作数都为真值,则返回最后一个操作数。具体规则如下:
- 对第一个操作数进行条件判断。
- 如果第一个操作数的条件判断结果为 false,则返回第一个操作数的值。
- 如果第一个操作数的条件判断结果为 true,则返回第二个操作数的值。
2.19 JavaScript 中如何进行隐式类型转换
隐式类型转换也称为类型强制转换,是指 JavaScript 在表达式求值时自动将一种数据类型转换为另一种数据类型的过程。隐式类型转换主要发生在以下三种情况下:算术运算、比较运算和逻辑运算。常见的隐式类型转换规则如下:
算术运算
在算术运算中,JavaScript 会将操作数转换为数字类型,示例如下:
javascriptconsole.log(5 + "5"); // "55"(字符串拼接) console.log("5" + 5); // "55"(字符串拼接) console.log(5 + 5); // 10(数值相加) console.log(5 - "2"); // 3 console.log("6" * "2"); // 12 console.log("8" / 2); // 4 console.log("10" % 3); // 1
比较运算
在比较运算中,JavaScript 会将操作数转换为相同的类型再进行比较,示例如下:
javascript// 双等号 == console.log(5 == "5"); // true(字符串 "5" 被转换为数字 5) console.log(false == 0); // true(false 被转换为数字 0) console.log(true == 1); // true(true 被转换为数字 1) console.log(null == undefined); // true // 三等号 === console.log(5 === "5"); // false console.log(false === 0); // false console.log(true === 1); // false console.log(null === undefined); // false
其他比较运算符
对于其他比较运算符(>、<、>=、<=),操作数会被转换为数字或字符串,示例如下:
javascriptconsole.log(5 > "2"); // true console.log("6" < "12"); // false(字符串比较) console.log("8" >= 8); // true console.log("10" <= 20); // true
逻辑运算
javascript// 逻辑! console.log(!0); // true(0 被转换为 false,然后取反为 true) console.log(!1); // false(1 被转换为 true,然后取反为 false) console.log(!""); // true(空字符串被转换为 false,然后取反为 true) console.log(!"hello"); // false(非空字符串被转换为 true,然后取反为 false) // 逻辑|| console.log(0 || 1); // 1(0 被转换为 false,因此返回第二个操作数 1) console.log(1 || 0); // 1(1 被转换为 true,因此返回第一个操作数 1) console.log(0 && 1); // 0(0 被转换为 false,因此返回第一个操作数 0) console.log(1 && 2); // 2(1 被转换为 true,因此返回第二个操作数 2) // 逻辑&& console.log(0 || 1); // 1(0 被转换为 false,因此返回第二个操作数 1) console.log(1 || 0); // 1(1 被转换为 true,因此返回第一个操作数 1) console.log(0 && 1); // 0(0 被转换为 false,因此返回第一个操作数 0) console.log(1 && 2); // 2(1 被转换为 true,因此返回第二个操作数 2)
字符串与数字之间的转换
字符串和数字之间的隐式转换在很多情况下都会发生,示例如下:
javascriptconsole.log("5" - 2); // 3("5" 被转换为数字 5,然后 5 - 2 = 3) console.log("5" * "2"); // 10(两个字符串都被转换为数字) console.log("5" / 2); // 2.5("5" 被转换为数字 5,然后 5 / 2 = 2.5)
2.20 BigInt 解决了什么问题
JavaScript 中的数字类型是基于 IEEE 754 双精度浮点数标准实现的。这种实现方式虽然在绝大多数情况下足够,但在处理非常大的整数时会出现精度问题。为了处理和表示任意精度的整数,JavaScript 引入了 BigInt 类型。BigInt 可以表示和操作任意大的整数而不会丢失精度,从而解决了大整数运算中的问题,问题如下:
console.log(Number.MAX_SAFE_INTEGER); // 9007199254740991
console.log(Number.MAX_SAFE_INTEGER + 1); // 9007199254740992
console.log(Number.MAX_SAFE_INTEGER + 2); // 9007199254740992 (错误)
BigInt 的优势
- 支持任意大整数:BigInt 可以表示任意大的整数,而不会丢失精度。
- 专门设计用于整数运算:与浮点数不同,BigInt 专门用于整数运算,确保了精度和一致性。
- 与 Number 类型区别明确:BigInt 是一种新的原始数据类型,与现有的 Number 类型区别明确,避免了混淆。
使用示例
使用 BigInt 可以非常简单,只需在整数后加上 n 后缀,或者使用 BigInt 构造函数,示例如下:
javascriptconst bigInt1 = 1234567890123456789012345678901234567890n; const bigInt2 = BigInt("1234567890123456789012345678901234567890"); console.log(bigInt1); // 1234567890123456789012345678901234567890n console.log(bigInt1 + 1n); // 1234567890123456789012345678901234567891n
需要注意的是,BigInt 和 Number 类型不能直接混合使用,示例如下:
javascriptconst num = 42; const bigInt = 12345678901234567890n; console.log(num + bigInt); // TypeError: Cannot mix BigInt and other types
需要进行显式转换,示例如下:
javascriptconsole.log(BigInt(num) + bigInt); // 12345678901234567932n
2.21 Map 和 Object 的区别
Map 和 Object 都是以 key-value(键值对)的形式对数据进行存储的集合,但它们在多个方面存在显著的区别。以下是Map和Object之间的主要差异:
- 键的类型:
- Object:键类型必须是 string 或者 Symbol,如果非 String 类型,会进行数据类型转换。
- Map:键可以是任意类型,包括对象、数组、函数等,不会进行数据类型转换。
- 键的顺序:
- Object:key 是无序的,不会按照添加到顺序返回。对于大于等于0的整数,会按照大小的顺序进行排序;对于小数和负数会当做字符串处理;对于 Symbol 类型,会直接过滤掉,不会进行输出。如果想要输出 Symbol 类型属性,可以通过 Object.getOwnPropertySymbols() 方法。
- Map:key 是有序的,按照插入的顺序返回。
- 键值对的数量:
- Object:只能手动计算,通过 Object.keys() 方法或者通过 for...in 循环统计。
- Map:直接通过 size 属性访问。
- 键值对的访问:
- Object:添加或者修改属性,通过点(.)或者中括号([])的形式。判断属性是否存在用'属性名' in 对象;删除属性使用delete关键字。
- Map:添加和修改键值对使用 set 方法,判断属性是否存在用 has 方法,获取值用 get 方法,删除键值对用 delete 方法,清空所有键值对用 clear 方法。
- 迭代器:
- Object:不具有 iterator 特性,默认情况下不能使用 for...of 进行遍历。
- Map:Map 结构的 keys()、values()、entries() 方法返回值都具有 iterator 特性,因此可以使用 for...of 进行遍历。
- JSON序列化:
- Object:Object 类型可以通过 JSON.stringify() 进行序列化操作。
- Map:Map 结构不能直接进行 JSON 序列化,但可以通过先将 Map 对象转换为数组(例如使用 Array.from() 方法),然后再进行 JSON 序列化。
- 构造方式和应用场景:
- Object:是 ECMAScript 1st 里添加的一种引用类型数据,是最常用的存储键值对的集合。
- Map:是 ECMAScript 2015 版本里新增的键值对的集合,采用 Hash 结构存储。它提供了一种更灵活和强大的键值对存储方式,特别适用于需要保持键值对插入顺序的场合。
综上所述,Map 和 Object 在键的类型、顺序、键值对数量的获取、访问方式、迭代器支持、JSON 序列化以及构造方式和应用场景等方面都存在显著差异。开发者可以根据具体需求选择适合的集合类型来存储和操作数据。
2.22 arguments
为什么 JavaScript 函数的 arguments 参数是类数组而不是数组?如何遍历类数组?
关于为什么 arguments 是类数组而不是数组:
- 历史原因: arguments 对象是在 JavaScript 语言早期就被引入的。那时候,JavaScript 还没有真正的数组对象,所以 arguments 被设计成了一个类数组对象。
- 性能考虑: 将 arguments 实现为真正的数组可能会带来一些性能开销。类数组对象可以更高效地实现某些操作。 arguments 是一个对象,它的属性是从 0 开始依次递增的数字,还有 callee 和 length 等属性,与数组相似;但是它却没有数组常见的方法属性,如 forEach, reduce 等,所以叫它们类数组。
要遍历类数组,有三个方法:
将数组的方法应用到类数组上,这时候就可以使用 call 和 apply 方法,如:
javascriptfunction foo(){ Array.prototype.forEach.call(arguments, a => console.log(a)) }
使用Array.from方法将类数组转化成数组:
javascriptfunction foo(){ const arrArgs = Array.from(arguments) arrArgs.forEach(a => console.log(a)) }
使用展开运算符将类数组转化成数组:
javascriptfunction foo(){ const arrArgs = [...arguments] arrArgs.forEach(a => console.log(a)) }
拓展
在现代 JavaScript 中,我们通常推荐使用剩余参数(rest parameters)来替代 arguments。剩余参数提供了一个真正的数组,更易于使用:
javascriptfunction example(...args) { args.forEach(arg => console.log(arg)); }
3. JavaScript 进阶
3.1 什么是 JavaScript 的尾调用
尾调用是指函数内部的最后一个操作是调用另一个函数的情况。在 JavaScript 中,当一个函数调用发生在另一个函数的尾部(即调用结束后直接返回其结果,而无需进一步操作)时,这种调用称为尾调用。
使用尾调用的主要好处在于其对栈内存的优化。通常情况下,每一个函数调用都会在栈内存中占据一个新的框架(frame),直到函数执行完成。而尾调用因为不需要保留当前函数的执行上下文,因此可以直接复用当前的栈帧,从而使递归操作更加高效,避免栈溢出(stack overflow)的风险。
尾调用优化(Tail Call Optimization,TCO):
尽管尾调用本身是一种编程方法,但要实现其内存优化效果,离不开 JavaScript 引擎对尾调用优化的支持。尾调用优化指的是 JavaScript 引擎在检测到尾调用时,会复用当前的栈帧,从而节省内存开销。需要注意的是,并非所有的 JavaScript 环境都支持尾调用优化。例如,某些现代浏览器和 Node.js 在严格模式下才会执行优化。
尾递归(Tail Recursion):
尾递归是尾调用的一种特殊形式,指的是在递归函数中,递归调用是函数的最后一个操作。因为尾递归可以直接复用当前的栈帧,所以在处理深度递归时,尾递归能显著减少栈内存的使用量。举个简单的例子:
javascript// 非尾递归 function factorial(n) { if (n === 1) return 1; return n * factorial(n - 1); } // 尾递归 function factorialTail(n, acc = 1) { if (n === 1) return acc; return factorialTail(n - 1, n * acc); }
在上述例子中,
factorialTail
函数是尾递归,因而在支持尾调用优化的环境下,即使n
很大也不会导致栈溢出。JavaScript 严格模式:
在 JavaScript 中,为了使尾调用优化生效,代码需要运行在严格模式(strict mode)下,最简单的方法是在文件或函数的头部添加
"use strict";
指令:javascript"use strict"; function exampleFunction() { // 函数内容 }
实际应用场景:
尾调用和尾递归特别适合解决那些包含大量迭代步骤的算法问题。常见的场景包括计算阶乘、斐波那契数列、Hanoi 塔问题等。通过将递归过程转化为尾递归,可以显著提高程序在处理大数据量时的性能表现。