如果我是前端面试官-JS篇
本文主要以面试官的视角由浅入深的探讨下
JS
的相关面试题思路和问题答疑,包含 JS基础操作
->原理进阶
->底层实现
->性能优化
。供自己以后查漏补缺,也欢迎同道朋友交流学习。
引言
前面分享了 html
和 css
相关的面试题及解答,本文主要以面试官的视角由浅入深的探讨下 JS
(不含 ES6
)的相关面试题思路和问题答疑。
本文主要介绍 JS 基础操作 (数据类型
、变量声明与作用域
、运算符和表达式
、数组操作
、对象操作
、函数
、window对象
、document对象
) -> 原理进阶(原型链与继承
、闭包
、异步编程
、事件循环
、错误处理
、消息队列
) -> 底层实现(内存结构
、垃圾回收
、编译原理
) -> 性能优化
。
数据类型
JS 是一种动态类型
语言,支持多种数据类型。这些数据类型可以分为两类:原始类型
(Primitive Types) 和 引用类型
(Reference Types)。
数据类型这个知识点,主要考察面试者对基本数据类型
和引用数据类型
的了解程度,以及对类型判断
、类型转换
的理解。
经典面试题:
- JavaScript中有哪几种数据类型?它们是如何存储的?
- 如何准确判断一个变量的数据类型?
typeof
操作符有哪些局限性
,如何弥补这些局限性? - JS 中有哪些
隐式类型转换
的场景,请举例说明,并解释其转换规则。 - 为什么
0.1 + 0.2
不等于0.3
,请解释其背后的原理。 - 字符串和数组有哪些相似之处和不同之处,在操作上有哪些需要注意的地方?
答案解析:
-
数据类型主要分为两种:
基本数据类型
和引用数据类型
。- 基本数据类型:
Number
、String
、Boolean
、Undefined
、Null
、Symbol
(ES6新增)、BigInt
(ES2020新增,表示大于2^53 - 1的整数)。存储在栈内存
中。栈内存的特点是访问速度快,存储的数据大小需要在编译时就确定。 - 引用数据类型:
Object
(包含Array
、Function
等)。存储在堆内存
中。堆内存的特点是存储空间大,用于存储大型数据,大小不需要在编译时确定,但访问速度相对较慢。
- 基本数据类型:
-
类型判断和
typeof
操作符的局限性与弥补方法:- 使用
typeof
操作符:typeof
操作符可以返回一个字符串,表示变量的数据类型。可以判断基本数据类型
,但是对于引用数据类型
,typeof
操作符会返回object
,对null
返回结果也不准确。 - 使用
instanceof
操作符:instanceof
操作符用于检测一个对象是否在另一个对象的原型链上。 - 使用
Object.prototype.toString.call(value)
方法:这种方法的原理是利用了Object.prototype.toString
方法在不同数据类型上的不同表现。当调用Object.prototype.toString.call(value)
时,会根据value
的内部属性[[Class]]
来返回一个特定的字符串,从而准确判断数据类型。
- 使用
-
隐式类型转换
的场景及规则:- 算术运算场景:当参与
运算
的变量中有字符串
时,会将其他类型
转换为字符串
。例如:1 + "2"
会将数字 1 转换为字符串 "1",然后进行字符串拼接,结果为 "12"。 - 关系运算场景:在进行
比较运算
时,如果两个操作数类型不同,会进行类型转换。例如:"2" > 1
会将字符串 "2" 转换为数字 2,然后进行比较。 - 逻辑运算场景:在逻辑与(
&&
)和逻辑或(||
)运算中,会根据操作数的类型进行类型转换。例如:"" && "hello"
会将空字符串转换为false
,然后返回空字符串
。 - 条件运算符场景:在
条件运算
符(?:
)中,会根据条件表达式的值进行类型转换。例如:"" ? "yes" : "no"
会将空字符串转换为false
,然后返回"no"
。 - 转换规则:
- 布尔值转换:布尔值
true
转换为数字 1,false
转换为数字 0。 - 字符串转换:字符串转换为
数字
时,如果字符串是有效的数字字符串(如"123"
),则转换为对应的数字;如果字符串包含非数字字符(如"abc"
),则转换为NaN
。字符串转换为布尔值
时,空字符串""
转换为false
,非空字符串转换为true
。 - 数字转换:数字转换为
字符串
时,直接将数字转换为对应的字符串形式,如123
转换为"123"
。数字转换为布尔值时,0、-0、NaN
转换为false
,其他数字转换为 true。 - 对象转换:对象转换为
布尔值
时,除了null
和undefined
,其他对象都转换为true
。对象转换为字符串
时,会调用对象的toString()
方法进行转换。
- 布尔值转换:布尔值
- 算术运算场景:当参与
-
0.1 + 0.2
不等于0.3
的原因及原理:0.1
和0.2
是二进制浮点数,在计算机中无法精确表示。- 二进制浮点数在进行计算时,会出现舍入误差。
-
字符串和数组的相似之处和不同之处:
- 相似之处:
- 都可以通过索引访问元素。
- 都可以使用
length
属性获取元素个数。 - 都可以使用
for
循环遍历元素。
- 不同之处:
- 字符串是不可变的,而数组是可变的。
- 数组可以存储不同类型的元素,而字符串只能存储字符。
- 方法不同:字符串有
charAt
方法,数组有push
、pop
等方法。
- 相似之处:
变量声明与作用域
变量声明
是创建变量的过程,它为变量分配内存空间
,并可以初始化变量的值
。作用域
定义了变量和函数的可访问性范围
。
变量声明与作用域
这个知识点,主要考察面试者对变量声明
、作用域
、变量提升
、块级作用域
的理解程度。
经典面试题:
- 请解释
var
、let
和const
的区别,包括它们的作用域、提升行为以及适用场景。 - 如何利用
变量提升
的特点来优化代码结构,避免
一些常见的错误? - 什么是
块级作用域
的概念? - 请解释
对象解构
和数组解构
的语法和用途,它们在实际开发中的优势是什么?
答案解析:
-
var
、let
和const
的区别:- 作用域:
var
具有函数作用域
,如果在函数外部声明,var
的作用域是全局作用域。let
具有块级作用域
。它的作用域是包含它的最近一层花括号{}
所界定的区域。let
具有块级作用域
,但它声明的变量必须被初始化,并且一旦初始化之后就不能再被重新赋值。
- 提升行为:
var
存在变量提升,let
和const
不存在变量提升,但存在暂时性死区。 - 适用场景:
var
:在函数内部使用,需要在函数外部访问时,但在现代JS
开发中,一般不推荐使用var
。let
:在循环内部使用,需要在循环外部访问时,适用于需要在块级作用域内声明变量的场景。const
:在声明常量时使用,需要保证值不会被修改时。
- 作用域:
-
利用
变量提升
优化代码结构及避免错误:- 提前声明变量:由于
var
存在变量提升,我们可以将所有的var
变量声明集中在函数或全局作用域的顶部。这样可以使代码结构更加清晰,方便阅读和维护。 - 避免重复声明:利用
var
的变量提升特性,可以避免在同一个作用域内重复声明同一个变量。因为var
声明的变量会被提升到作用域顶部,所以即使在代码的不同位置多次声明同一个变量,也只会被提升一次。
- 提前声明变量:由于
-
块级作用域:块级作用域是指由一对花括号
{}
所界定的作用域区域。在这个区域内声明的变量,只能在这个区域内访问,外部无法访问。 -
对象解构和数组解构的语法和用途:
- 语法:
const { prop1, prop2 } = obj; const [var1, var2] = arr;
- 用途:可以从对象或数组中快速提取多个值,并将它们赋值给对应的变量。
- 优势:可以使代码更加简洁,避免了多次使用对象属性访问的方式,提高代码阅读性和可维护性。
- 语法:
运算符和表达式
在 JS 中,运算符和表达式是编程的基础组成部分。运算符
用于执行特定的操作,而表达式
则是由运算符和操作数组成的代码片段,用于计算结果
。
运算符和表达式
这个知识点,主要考察面试者对运算符
、表达式
、运算符优先级
的理解程度。
经典面试题:
==
和===
有什么区别?- 使用
==
可能会导致意外的结果? 位运算符
有哪些,如何正确使用它们?位运算符
的优势是什么?运算符优先级
有哪些,如何正确使用它们?
答案解析:
-
==
和===
的区别:==
是相等运算符
,它会进行类型转换后再进行比较,可能导致意外结果。===
是严格相等运算符
,它不会进行类型转换,只会比较值和类型是否完全相同,更加严格,推荐在大多数情况下使用。
-
使用
==
可能会导致意外的结果:- 类型转换:
==
会进行类型转换,可能导致不同类型的数据被转换为相同的类型后再进行比较。 - 特殊情况:
==
有一些特殊情况,例如null
和undefined
被认为是相等的。
- 类型转换:
-
常见的
位运算符
包括:&
(按位与)|
(按位或)^
(按位异或)~
(按位取反)<<
(左移)>>
(右移)>>>
(无符号右移)
-
位运算符
通常比算术运算符
和逻辑运算符
更快:位运算符直接操作二进制位
,通常比算术运算符和逻辑运算符更高效。在性能敏感的场景(如图形处理
、加密算法
等)中,使用位运算符可以提高代码性能。 -
常见的
运算符优先级
(从高到低):- 括号:()(最高优先级)
- 后缀运算符:
++
、--
- 一元运算符:
+
(正号)、-
(负号)、!
(逻辑非)、~
(按位取反)、typeof
、void
、delete
- 乘除运算符:
*
、/
、%
- 加减运算符:
+
、-
- 位运算符:
<<
、>>
、>>>
、&
、^
、|
- 关系运算符:
<
、<=
、>
、>=
、in
、instanceof
- 相等运算符:
==
、!=
、===
、!==
- 逻辑运算符:
&&
、||
- 条件运算符:
? :
- 赋值运算符:
=
、+=
、-=
、*=
、/=
、%=
、<<=
、>>=
、&=
、^=
、|=
、**=
- 逗号运算符:
,
(最低优先级)
-
正确使用运算符优先级:
- 使用括号明确优先级
- 可以通过拆分表达式或使用括号来提高可读性
数组操作
数组
是一种非常灵活且常用的数据结构,用于存储有序
的元素集合。数组可以包含多种类型
的数据,如数字、字符串、对象等。数组的每个元素都有一个索引
(从 0 开始),可以通过索引访问
和修改数组
中的元素。
数组操作这个知识点,主要考察面试者对数组
、数组方法
、数组遍历
的理解程度。
经典面试题:
- 数组有哪些
遍历
方法?对比下它们的适用场景和优缺点。 - 在遍历数组时,如何
提前终止循环
?不同遍历方法提前终止的方式有何不同? - 如何实现
数组去重
?
答案解析:
-
数组的遍历方法:
for
:最基本的循环方式,适用于需要控制循环的开始、结束和步长的情况;性能好
,适用于需要频繁操作索引的场景;但代码冗长
,需要手动管理索引和边界条件,容易出错,如索引越界
等问题。for...of
:适用于遍历数组元素
,无需关心索引;可读性好
,代码更清晰易懂;但无法直接访问索引
,性能略低于传统的for
循环。forEach
:适用于简单的遍历操作,无法提前终止循环(但可以通过抛出错误来模拟);性能略低于传统的for
循环。map
:适用于对数组元素进行变换
,不修改原数组,返回一个新数组;但对于单纯的遍历任务,map
可能显得过于复杂,会浪费内存。filter
:适用于对数组元素进行筛选
,不修改原数组,返回一个新数组;但如果只需遍历数组而不需要筛选数据,filter
会浪费内存。reduce
:适用于对数组元素进行累加
、累乘
等操作,不修改原数组,返回一个新值;但语法相对复杂,初学者可能难以理解。
-
提前终止循环:
for
循环:使用break
语句。for...of
循环:使用break
语句。forEach
方法:无法直接使用break
,但可以通过抛出错误(throw new Error
)来模拟。some
和every
方法:使用return
语句。
-
数组去重:
Set
:Set
是 ES6 新增的数据结构,它可以存储唯一的值,通过将数组转换为Set
再转换回数组,可以实现去重。filter 和 indexOf
:使用filter
方法结合indexOf
或includes
方法,可以实现去重。reduce
方法:使用reduce
方法结合indexOf
或includes
方法,可以实现去重。
对象操作
对象是一种无序
的键值对集合
,每个键值对由一个键(key
)和一个值(value
)组成。键是唯一的
,值可以是任何类型的数据(包括其他对象),几乎所有的复杂数据都可以通过对象来表示。
对象操作这个知识点,主要考察面试者对对象
、对象属性
、对象方法
、对象遍历
的理解程度。
经典面试题:
- 如何使用
Object.defineProperty()
和Object.getOwnPropertyDescriptor()
- 请列举常见的对象
遍历
方法。 - 什么是对象的
深拷贝
和浅拷贝
,如何实现? - 请解释 JS 中
对象继承
的实现方式,如原型链继承
、构造函数继承
、组合继承
等,并说明它们的优缺点。 Object.create()
方法怎么实现对象的继承?new
的原理是什么?
答案解析:
-
Object.defineProperty()
和Object.getOwnPropertyDescriptor()
:Object.defineProperty(obj, prop, descriptor)
方法用于直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回该对象。- obj:要在其上定义属性的对象。
- prop:要定义或修改的属性的名称。
- descriptor:将被定义或修改的属性描述符。
value
:属性的值;writable
:布尔值,表示属性值是否可以被修改;configurable
:布尔值,表示属性是否可以被删除或修改其属性描述符;enumerable
:布尔值,表示属性是否可以被枚举。get
:一个函数,返回属性的值。set
:一个函数,当属性值被修改时调用。
Object.getOwnPropertyDescriptor(obj, prop)
方法返回指定对象上一个自有属性对应的属性描述符。- obj:需要检索其自身属性的对象。
- prop:需要检索其属性描述符的属性的名称。
-
对象的遍历方法:
for...in
循环:遍历对象的所有可枚举属性
,包括继承的属性。Object.keys()
:返回对象自身的所有可枚举属性的键名
。Object.values()
:返回对象自身的所有可枚举属性的键值
。Object.entries()
:返回对象自身的所有可枚举属性的键值对
。for...of
循环(结合Object.entries()
):遍历对象的所有可枚举属性
,返回一个键值对数组。
-
对象的深拷贝和浅拷贝:
浅拷贝
:只复制对象的第一层属性,如果属性值是引用类型,则复制引用而不是实际对象。常用的方法有Object.assign()
、扩展运算符{...obj}
。深拷贝
:递归
地复制对象的所有层次的属性,确保原对象和新对象完全独立。常用的方法有JSON.parse(JSON.stringify())
、lodash.cloneDeep()
。
-
对象继承的实现方式:
-
原型链继承
:通过将子类的原型
指向父类的实例
,实现继承。简单易懂,父类的实例属性
可以被所有子类实例共享
,可能导致意外修改,并且无法向父类构造函数传递参数。function Parent() { this.name = 'Parent'; } Parent.prototype.sayHello = function () { console.log('Hello from Parent'); }; function Child() { this.name = 'Child'; } Child.prototype = new Parent(); let child = new Child(); child.sayHello(); // Hello from Parent
-
构造函数继承
:通过在子类构造函数
中调用父类构造函数
,实现继承。子类可以访问父类的实例属性
,但无法访问
父类原型上的方法
,需要手动复制。function Parent(name) { this.name = name; } Parent.prototype.sayHello = function () { console.log('Hello from Parent'); }; function Child(name) { Parent.call(this, name); // 调用父类构造函数 } // Child.prototype = new Parent(); // 不需要这行代码 let child = new Child('Child'); console.log(child.name); // Child // child.sayHello(); // TypeError: child.sayHello is not a function
-
组合继承
:通过在子类构造函数中调用父类构造函数,并将子类的原型指向父类的原型,实现继承。实现复杂
,需要手动修复构造函数指针,代码较为复杂。function Parent(name) { this.name = name; } Parent.prototype.sayHello = function () { console.log('Hello from Parent'); }; function Child(name) { Parent.call(this, name); // 调用父类构造函数 } Child.prototype = Object.create(Parent.prototype); // 子类的原型指向父类的原型 Child.prototype.constructor = Child; // 修复构造函数指针 let child = new Child('Child'); console.log(child.name); // Child child.sayHello(); // Hello from Parent
-
寄生组合继承:通过创建一个
临时构造函数
来避免多余的属性
继承,实现继承。结合了构造函数继承和原型链继承的优点,高效且功能完整,但实现复杂。function Parent(name) { this.name = name; } Parent.prototype.sayHello = function () { console.log('Hello from Parent'); }; function Child(name) { Parent.call(this, name); // 调用父类构造函数 } // 创建一个临时构造函数 function Temp() {} Temp.prototype = Parent.prototype; Child.prototype = new Temp(); // 子类的原型指向临时构造函数的实例 Child.prototype.constructor = Child; // 修复构造函数指针 let child = new Child('Child'); console.log(child.name); // Child child.sayHello(); // Hello from Parent
-
-
Object.create()
方法:Object.create(proto, [propertiesObject])
方法创建一个新对象,使用现有的对象来提供新创建的对象的__proto__
,可以轻松实现对象继承。- proto:新对象的原型对象。
- propertiesObject(可选):对象的属性描述符。
-
new
用于创建对象实例
,通过构造函数
初始化对象,并设置其原型
。其核心原理是创建一个空对象
,绑定构造函数的this
,并返回结果
。- 创建一个空对象,并将其原型指向构造函数的
prototype
属性。 - 将构造函数的
this
绑定到新创建的对象上,并执行构造函数。 - 如果构造函数返回一个对象,则返回该对象;否则返回步骤1创建的对象。
function myNew(constructor, ...args) { const obj = Object.create(constructor.prototype); // 创建一个空对象,并设置其原型 const result = constructor.apply(obj, args); // 调用构造函数 return result instanceof Object ? result : obj; // 返回对象 }
- 创建一个空对象,并将其原型指向构造函数的
函数定义与调用
在 JS
中,函数是编程的核心概念之一,它是一种可以重复使用的代码块
,用于执行特定任务。JS 提供了多种函数定义方式,每种方式都有其特点和适用场景。
函数
这个知识点,主要考察面试者对函数
、函数调用this绑定
、函数作用域
、递归函数
、回调函数
的理解程度。
经典面试题:
箭头函数
和普通函数
的区别?它们在函数调用、this
指向等方面的区别是什么?- 如何改变函数调用时的
this
指向?请分别解释call
、apply
和bind
方法的用法和区别。 - 如何实现函数的
链式调用
? instanceof
操作符的原理是什么?如何实现一个polyfill
?
答案解析:
-
箭头函数和普通函数的区别:
- 定义方式:箭头函数使用
=>
语法,普通函数使用function
关键字。 - this 指向:箭头函数没有
this
,this 指向是根据定义时的上下文
决定的,无法通过call
、apply
或bind
改变。普通函数的this
指向取决于函数的调用方式
。 - arguments 对象:箭头函数没有
arguments
对象,但可以通过 rest 参数代替,普通函数有arguments
对象。 - 构造函数:箭头函数不能作为构造函数,因为没有自己的 this,也没有 prototype 属性;普通函数可以通过
new
关键字创建实例。 - 是否可以使用 yield 命令:箭头函数不能使用
yield
命令,普通函数可以使用yield
命令,可用于Generator
函数。
- 定义方式:箭头函数使用
-
call
、apply
和bind
方法用于改变函数调用时的this
指向。call
方法:- 语法:
call(thisArg, arg1, arg2, ...)
,其中thisArg
是函数内部this
要指向的对象,arg1
、arg2
等是函数的参数。 - 特点:立即执行函数,并将 this 绑定到指定对象。
- 语法:
apply
方法:- 语法:
apply(thisArg, [argsArray])
,其中thisArg
是函数内部this
要指向的对象,argsArray
是一个数组,包含函数的参数。 - 特点:与
call
类似,但参数以数组
形式传入。
- 语法:
bind
方法:- 语法:
bind(thisArg, arg1, arg2,...)
,其中thisArg
是函数内部this
要指向的对象,arg1
、arg2
等是函数的参数。 - 特点:返回一个绑定指定
this
的新函数,不会立即执行
,可以稍后调用。
- 语法:
-
函数的链式调用:
-
函数的
链式调用
是指在一个函数调用后,继续调用该函数的返回值上的方法,形成一个连续的调用链。 -
实现方式:在函数的返回值上调用其他方法,通常使用
return this
来实现链式调用。class Chain { constructor(value = 0) { this.value = value; } setValue(value) { this.value = value; return this; } addValue(value) { this.value += value; return this; } getValue() { return this.value; } } const chain = new Chain(); let res = chain.setValue(5).addValue(10).getValue(); console.log(res); // 15
-
-
instanceof
操作符的原理:-
instanceof
操作符用于检测构造函数的prototype
属性是否出现在某个实例对象的原型链中。 -
原理:通过检查实例对象的
__proto__
属性是否等于构造函数的prototype
属性来判断。 -
polyfill
实现:通过遍历实例对象的原型链,检查是否存在构造函数的prototype
属性。function myInstanceof(left, right) { if (typeof left !== 'object' || left === null) return false; let proto = Object.getPrototypeOf(left); while (true) { if (proto === null) return false; if (proto === right.prototype) return true; proto = Object.getPrototypeOf(proto); } }
-
window对象
window
对象是 JavaScript 中的全局对象,它代表了当前窗口或标签页。window
对象提供了许多属性和方法,用于访问和操作浏览器的各种功能。
这个知识点主要考察面试者对 window
对象的理解程度,包括它的属性和方法、与其他对象的关系等。
经典面试题:
- 如何避免全局变量污染
window
对象?请列举一些常见的方法和最佳实践。 - 如何使用
window.scrollTo()
方法将窗口滚动到指定的位置? - 如何使用
window.requestAnimationFrame()
方法实现平滑的动画效果? - 如何使用
window.history
操作浏览器的历史记录?
答案解析:
-
避免全局变量污染
window
对象:全局变量会污染全局作用域,可能导致命名冲突
和难以维护
的代码。- 使用
模块模式
:通过模块模式,将代码封装在一个自执行函数中,避免变量泄漏到全局作用域。 - 使用
IIFE
(立即执行函数表达式):将代码封装在一个立即执行的函数中,创建一个独立的作用域,避免变量污染全局作用域。 - 使用
命名空间
:将相关的变量和函数组织到一个命名空间对象中,减少全局变量的数量。 - 使用
const
和let
:const
和let
具有块级作用域,不会像var
那样被提升到全局作用域。 - 使用
严格模式
('use strict'
):在严格模式下,未声明的变量赋值会抛出错误,有助于避免意外创建全局变量。
- 使用
-
使用
window.scrollTo()
方法将窗口滚动到指定位置:它接受两个参数,水平方向的滚动位置(x)和垂直方向的滚动位置(y)。- 语法:
window.scrollTo(x-coord, y-coord)
-
x-coord
:水平方向的滚动位置,单位为像素。 -
y-coord
:垂直方向的滚动位置,单位为像素。// 将窗口滚动到页面顶部 window.scrollTo(0, 0); // 将窗口滚动到页面底部 window.scrollTo(0, document.body.scrollHeight); // 平滑滚动到指定位置 window.scrollTo({ top: 1000, behavior:'smooth' // 如果需要平滑滚动,可以将 behavior 设置为 'smooth'。 });
-
- 语法:
-
使用
window.requestAnimationFrame()
方法实现平滑的动画效果:-
用于调度在下一次重绘之前执行的回调函数,常用于实现平滑的动画效果。它比传统的
setTimeout()
或setInterval()
方法更高效,因为它会自动调整帧率以匹配屏幕刷新率
。它在浏览器标签页未激活时自动暂停动画
,节省资源 -
语法:
window.requestAnimationFrame(callback)
,callback
在下一次重绘之前执行的回调函数。function animate(time) { // 计算动画状态 const x = Math.min(time / 1000, 1); // 更新动画 document.getElementById('box').style.transform = `translateX(${x * 100}px)`; // 如果动画未完成,继续调度 if (x < 1) { window.requestAnimationFrame(animate); } } // 开始动画 window.requestAnimationFrame(animate);
-
-
使用
window.history
操作浏览器的历史记录:-
history.pushState(state, title, url)
:向浏览器历史记录中添加一个新状态,不会触发页面刷新。 -
history.replaceState(state, title, url)
:替换当前历史记录状态,不会触发页面刷新。 -
history.back()
:回退到上一个历史记录状态。 -
history.forward()
:前进到下一个历史记录状态。 -
history.go(n)
:根据参数n
向前或向后移动n
个历史记录状态。 -
history.length
:返回历史记录的长度。 -
history.state
:返回当前历史记录状态的状态对象。 -
history.scrollRestoration
:控制浏览器是否自动恢复滚动位置。 -
监听 popstate 事件
:当用户通过浏览器的后退或前进按钮导航时,会触发 popstate 事件。// 监听 popstate 事件 window.addEventListener('popstate', function(event) { console.log(event.state); // 获取 state 数据 });
-
document对象
document
对象是 Window
对象的一部分,表示当前浏览器窗口中的 HTML
文档。它是 DOM
树的根节点,通过它可以访问和操作文档中的元素、样式、事件等。
这个知识点主要考察面试者对 document
对象的理解程度,包括它的属性和方法、与其他对象的关系等。
经典面试题:
- 如何获取文档的标题、URL 和字符集等基本信息?请列举
document
对象的相关属性。 - 如何处理文档的加载事件?
document.documentElement
和document.body
的有什么区别?
答案解析:
-
获取文档的标题、URL 和字符集等基本信息:
document.title
:获取文档的标题。document.URL
:获取当前文档的完整 URL。document.documentURI
:获取当前文档的 URI。document.baseURI
:获取文档的基准 URI。document.characterSet
:获取文档的字符集。document.contentType
:获取文档的 MIME 类型。document.domain
:获取文档的域名。document.referrer
:获取加载当前文档的前一个文档的 URL。
-
处理文档的加载事件:
window.onload
:它会在页面的所有资源
(包括图片、样式表、脚本等)加载完成后触发。只能绑定一个事件
处理函数。如果多次绑定,后面的绑定会覆盖前面的绑定。document.onload
:document.onload
并不是一个标准的事件绑定方式,通常不会被触发,不推荐使用。DOMContentLoaded
:它会在文档的DOM
内容加载完成后触发,不包括图片、样式表、脚本等外部资源。可以绑定多个事件
处理函数,不会被覆盖。
-
document.documentElement
和document.body
的区别:- 层级结构:
document.documentElement
是文档的根元素,即<html>
元素。document.body
是文档的<body>
元素。
- 用途:
document.documentElement
用于操作整个文档的根节点,适用于全局样式和文档尺寸等。document.body
用于操作页面的主要内容区域,适用于页面内容的样式和操作。
- 滚动相关属性:
- 在某些浏览器中,
document.documentElement.scrollHeight
和document.documentElement.scrollWidth
更可靠,尤其是在处理滚动条时。 document.body.scrollHeight
和document.body.scrollWidth
也可以被使用,但可能不如documentElement
的属性稳定。
- 在某些浏览器中,
- 兼容性:
document.documentElement
在所有浏览器中都被支持。document.body
可能在某些浏览器中存在兼容性问题。
- 层级结构:
原型链与继承
原型链
(Prototype Chain) 是实现继承
和共享属性
的核心机制。
这个知识点主要考察面试者对 原型链
的理解程度,包括它的作用和原理、与其他对象的关系等。
经典面试题:
- 什么是
原型链
?它的作用和原理
是什么? - 如何通过原型链实现对象的
继承
?给出一个继承的示例代码。 - 原型链继承有哪些
优点和缺点
? - 如何避免在使用原型链继承时出现
原型链循环
的问题?
答案解析:
-
每个 JS 对象都有一个内部属性
[[Prototype]]
,通常通过__proto__
或Object.getPrototypeOf()
访问。这个属性指向另一个对象,称为该对象的原型。如果一个对象的原型不为null
,那么它的原型也可能有自己的原型,从而形成一个链状结构,这就是原型链
。- 作用:
属性和方法的共享
:通过原型链,多个对象可以共享同一组属性和方法,从而节省内存。实现继承
:通过原型链,可以实现对象之间的继承关系,子对象可以继承父对象的属性和方法。
- 原理:
自身属性查找
:首先在对象自身查找该属性或方法。原型链查找
:如果在对象自身中找不到,JS 引擎会沿着原型链向上查找,直到找到该属性或方法,或者到达原型链的末端(null
)。
- 作用:
-
如何通过原型链实现对象的继承:
-
实现步骤:
- 定义父类构造函数:定义一个父类构造函数,并在其
prototype
上添加共享的方法。 - 定义子类构造函数:定义一个子类构造函数,并在子类构造函数中调用父类构造函数。
- 设置子类的原型:将子类的原型设置为父类的一个实例,从而实现继承。
- 修改构造函数指向:将子类的
constructor
属性修改为子类构造函数。
- 定义父类构造函数:定义一个父类构造函数,并在其
-
示例代码:
// 定义父类构造函数 function Animal(name) { this.name = name; } Animal.prototype.speak = function() { console.log(`${this.name} makes a noise.`); }; // 定义子类构造函数 function Dog(name) { Animal.call(this, name); // 调用父类构造函数 } Dog.prototype = Object.create(Animal.prototype); // 设置子类的原型 Dog.prototype.constructor = Dog; // 修改构造函数指向 Dog.prototype.speak = function() { console.log(`${this.name} barks.`); }; // 创建子类实例 let dog = new Dog("Rex"); dog.speak(); // 输出:Rex barks. console.log(dog instanceof Dog); // true console.log(dog instanceof Animal); // true console.log(dog instanceof Object); // true
-
-
原型链继承优点:
共享属性和方法
:多个对象可以共享同一组属性和方法,从而节省内存
。实现继承
:通过原型链,可以实现对象之间的继承关系,子对象可以继承
父对象的属性和方法。动态性
:可以在运行时动态修改
原型链,从而动态地添加或覆盖属性和方法。
-
原型链继承缺点:
原型链过长
:过长的原型链可能导致性能问题
,因为访问属性或方法时需要沿着原型链逐级查找。共享引用问题
:如果原型链中的属性是引用类型
(如数组、对象),所有实例共享同一个引用,可能导致意外的副作用
。
-
如何避免在使用原型链继承时出现原型链循环的问题:
原型链循环
是指在设置原型时,不小心将一个对象的原型设置为它自己或其子对象,从而导致无限循环
。避免直接操作原型
:不要直接修改原型对象的属性,而是通过Object.create()
方法创建新的原型对象。修改构造函数指向
:在设置原型后,修改构造函数的constructor
属性,确保它指向正确的构造函数。
闭包与作用域
闭包
(Closure) 和 作用域
(Scope) 是两个非常重要的概念,它们共同决定了变量的可访问性和生命周期。
这个知识点主要考察面试者对 闭包
和 作用域
的理解程度,如何正确使用它们,以及它们的应用场景等。
经典面试题:
- 什么是
作用域
?主要有几种? - 什么是
闭包
?闭包有哪些常见的应用场景? - 闭包可能会导致哪些问题?如何避免这些问题?
答案解析:
-
作用域(
Scope
) 是指在程序中定义变量的区域,它决定了变量的可访问性和生命周期。作用域定义了变量和函数的可见性范围
。主要作用域类型如下:- 全局作用域:全局作用域是程序的
最外层
作用域,全局变量在整个程序中都可以访问。 - 函数作用域:函数作用域是指在函数内部声明的变量只能在该
函数内部
访问。var
声明的变量具有函数作用域。 - 块级作用域:块级作用域是指在代码块(如
if
、for
、while
等)内部声明的变量只能在该代码块内
访问。let
和const
声明的变量具有块级作用域。 - 模块作用域:模块作用域是指在模块(
import
和export
)内部声明的变量只能在该模块内部
访问。ES6
模块提供了模块作用域,有助于避免全局污染。
- 全局作用域:全局作用域是程序的
-
闭包
是指一个函数以及该函数创建时
的词法环境
的组合。闭包使函数能够记住并访问
其创建时所在的作用域链中的变量
,即使该函数在其创建上下文之外
执行。 -
常见应用场景:
数据封装
、事件处理
、函数工厂
、模块模式
。 -
闭包可能导致的问题:
- 内存泄漏:闭包会捕获其创建时的作用域链,如果闭包没有被正确释放,可能会导致
内存泄漏
。 - 性能问题:闭包会增加作用域链的长度,可能会影响性能。
- 变量污染:闭包可能会导致变量污染,特别是在使用全局变量时。
- 内存泄漏:闭包会捕获其创建时的作用域链,如果闭包没有被正确释放,可能会导致
-
避免闭包导致的问题:
- 及时释放闭包:确保闭包不再被引用,从而允许垃圾回收器释放内存。
- 避免不必要的闭包:尽量减少闭包的使用,特别是在性能敏感的场景中。
- 使用弱引用:使用
WeakMap
或WeakSet
来存储对象的弱引用,避免内存泄漏。
异步编程
异步编程
是一种允许程序在等待某些操作完成时继续执行其他代码的编程范式。这有助于提高程序的响应性和性能,尤其是在处理网络请求
、文件操作
或定时任务
时。JS 提供了多种异步编程的机制,包括回调函数
、Promises
、async/await
等。
这个知识点主要考察面试者对 异步编程
的理解程度,如何正确使用它们,以及它们的应用场景和性能优化等。
经典面试题:
- 什么是
异步
编程,它与同步
编程有什么区别? - 异步编程方式有哪些?它们的基本原理是什么?
- 如何使用
事件监听
解决异步问题? - 如何
优化
异步编程的性能
?
答案解析:
-
异步编程
是一种编程模型,允许程序在等待某个操作(如I/O
操作)完成的同时继续执行其他任务。这种方式通过非阻塞
和回调机制
实现并发处理,从而提高程序的效率和响应速度。 -
异步
编程与同步
编程的区别:- 执行顺序:
- 同步:任务按
顺序执行
,一个任务完成后才能开始下一个任务。 - 异步:任务可以
并发执行
,程序无需等待任务完成即可继续执行其他任务。
- 同步:任务按
- 性能:
- 同步:在等待 I/O 操作完成时,程序会
阻塞
,导致性能下降。 - 异步:通过并发执行多个任务,尤其是在涉及到 I/O 操作时,能够显著提高程序处理的并发能力和
资源利用
效率。
- 同步:在等待 I/O 操作完成时,程序会
- 响应性:
- 同步:程序在等待任务完成时无法响应其他操作,用户体验较差。
- 异步:程序可以继续响应其他操作,用户体验更好。
- 执行顺序:
-
异步编程方式:
- 回调函数:通过将回调函数作为
参数
传递给异步函数,在异步操作完成后调用回调函数。简单直接,易于理解;但多个回调函数嵌套时会造成“回调地狱”
,导致代码难以阅读和维护。 - Promise:一种用于处理异步操作的对象,它表示一个异步操作的最终完成或失败及其结果值。解决了回调地狱问题,提供了
链式调用
的能力,使异步代码更加清晰和易于管理。虽然比回调函数有所改进,但有时会造成多个.then()
的链式调用,导致代码语义不明确。 - async/await:一种基于
Promises
的语法糖,async
关键字用于声明一个异步函数,await
关键字用于等待一个 Promise 完成,并返回其结果。极大地简化了异步代码的编写和理解,看起来更像是同步代码。但需要基于 Promise,且只能在async
函数内部使用await
。 - 生成器(Generators):可以
暂停
和恢复执行
的函数。可以实现更复杂的异步流程控制,例如并发控制。但相对来说比较复杂
,需要一定的理解成本。 - Observables(RxJS):一种处理
异步数据流
的库,提供了强大的操作符和工具,用于处理异步数据流。非常强大,可以处理复杂的异步数据流,例如WebSockets
、用户输入
等。但需要较高的学习成本
。
- 回调函数:通过将回调函数作为
-
事件监听
是一种常见的异步编程方式,用于处理用户交互或异步操作完成后的回调。通过为特定事件绑定处理函数,可以在事件发生时执行相应的逻辑。在处理异步操作时,可以结合事件循环
和回调队列
来实现。例如,当一个异步任务完成时,将其回调函数放入回调队列,事件循环会在主线程空闲时执行这些回调函数。 -
如何优化异步编程的性能:
- 合理使用并发:通过并发执行多个任务,尤其是在涉及到
I/O
操作时,能够显著提高程序处理的并发能力和资源利用效率。 - 避免回调地狱:使用
Promises
或async/await
来避免嵌套的回调函数,使代码更加清晰和易于维护。 - 合理处理错误:确保正确处理异步操作中的错误,避免程序崩溃。
- 优化事件循环:避免长时间运行的同步代码阻塞事件循环,确保异步任务能够及时执行。
- 使用合适的数据结构:使用合适的数据结构来管理异步任务,例如
Promise
、Map
、Set
等。
- 合理使用并发:通过并发执行多个任务,尤其是在涉及到
事件循环
事件循环(Event Loop)是 JavaScript 运行时环境中的一个核心机制,它负责管理和执行异步任务,处理宏任务
和微任务
,并按照一定的顺序执行它们。
这个知识点主要考察面试者对 事件循环
的理解程度,对 宏任务
和微任务
的理解,以及执行顺序
和优先级
等。
经典面试题:
-
什么是
Event Loop
,它是如何工作的? -
宏任务
和微任务
分别是什么?它们在事件循环
中的执行顺序
是怎样的? -
说出下面程序的
执行结果
和原因
:async function async1() { console.log('async1 start'); await async2(); console.log('async1 end'); } async function async2() { console.log('async2'); } console.log('script start'); setTimeout(function() { console.log('setTimeout'); }, 0) async1(); new Promise(function(resolve) { console.log('promise1'); resolve(); }).then(function() { console.log('promise2'); }); console.log('script end');
答案解析:
-
Event Loop
是 JS 运行时环境中的一个核心机制,用于管理异步操作和回调函数。它确保程序在等待异步操作完成时仍然可以响应其他任务,从而实现非阻塞
的并发执行。 -
事件循环
的核心是任务队列(Task Queue
)和调用栈(Call Stack
)。任务队列分为宏任务队列(Macrotask Queue
)和微任务队列(Microtask Queue
)。事件循环的工作机制如下:- 调用栈为空时:检查
微任务
队列,依次执行微任务,直到微任务队列为空。 - 执行宏任务:从
宏任务
队列中取出一个任务执行,执行完成后再次检查微任务队列,依次执行微任务。 - 重复步骤2:继续执行宏任务,直到宏任务队列为空。
- 事件循环结束:如果宏任务队列和微任务队列都为空,事件循环结束。
- 调用栈为空时:检查
-
宏任务:
宏任务
是指那些在事件循环的每次迭代中执行的任务
,包括setTimeout
、setInterval
、I/O
操作、事件回调
等。 -
微任务:
微任务
是指那些在当前任务执行完毕后,但在下一次事件循环之前执行的任务,包括Promise
的then
、catch
、finally
、async/await
的await
等。 -
执行顺序:
同步代码
->微任务队列
->宏任务队列
->微任务队列
-> ... -
程序的执行结果:
script start
async1 start
async2
promise1
script end
async1 end
promise2
setTimeout
。
-
原因:
- 首先执行同步代码,输出
script start
。 async1()
被调用,进入调用栈,输出:async1 start
。await async2()
暂停async1
的执行,async2
被调用,输出:async2
。async1
执行完毕,将console.log('async1 end')
加入微任务队列。new Promise
被调用,执行同步部分,输出:promise1
。then
被调用,将回调函数加入微任务队列。- 继续执行同步代码,输出:
script end
。 - 调用栈为空,执行微任务队列。
- 执行
console.log('async1 end')
,输出:async1 end
。 - 执行
then
中的回调函数,输出:promise2
。 - 微任务队列执行完毕。
- 执行宏任务队列。
- 执行
setTimeout
,输出:setTimeout
。 - 宏任务队列执行完毕。
- 事件循环结束。
- 首先执行同步代码,输出
错误处理
错误处理是编程中非常重要的一部分,它可以帮助我们更好地理解和调试代码,提高程序的可靠性和稳定性。JS 提供了多种错误处理的机制,包括 try...catch
、throw
和 try...finally
等。
这个知识点主要考察面试者对 错误处理
的理解程度,如何进行错误的捕获
和处理
,以及如何处理异步代码中的错误等。
经典面试题:
- 请解释 JS 中的错误处理机制,并说明常见的错误类型
- 在使用
fetch
进行网络请求时,如何处理请求失败
的情况? - 如何在前端应用中实现错误日志的
收集
和上报
?
答案解析:
-
错误处理机制:
try...catch
:用于捕获同步代码中的错误。Promise.catch
:用于捕获异步代码中的错误。async/await
:结合try...catch
捕获异步代码中的错误。全局错误处理
:通过监听全局事件(如window.onerror
和window.onunhandledrejection
)捕获未捕获的错误。
-
常见错误类型:
SyntaxError
:语法错误
,如忘记括号、逗号或引号。ReferenceError
:引用错误
,如访问未定义的变量。TypeError
:类型错误
,如对非对象进行.map()
操作。RangeError
:范围错误
,如数组的负数索引。EvalError
:eval()
函数的错误。URIError
:URI
相关错误,如decodeURIComponent()
使用非法的URI
。
-
fetch
是一个异步函数,返回一个Promise
对象。可以使用try...catch
捕获错误,并检查响应状态码来处理请求失败的情况。 -
如何在前端应用中实现错误日志的收集和上报:
- 监听全局错误事件:监听
window.onerror
和window.onunhandledrejection
事件,捕获未处理的错误。 - 使用第三方库:使用第三方库(如
Sentry
、LogRocket
)来收集和上报错误日志。 - 自定义日志上报逻辑:在
捕获错误
后,通过HTTP
请求将错误信息发送到后端服务器。
- 监听全局错误事件:监听
消息队列
消息队列(Message Queue
) 是事件循环机制的一部分,用于管理异步任务的执行。
这个知识点主要考察面试者对 消息队列
的理解程度,如何通过消息队列实现异步任务的执行,以及如何实现延迟队列和定时任务等。
经典面试题:
- 什么是
消息队列
? - 如何在消息队列中实现
延迟队列
和定时任务
? - 在大规模使用消息队列时,如何进行有效的
容量规划
和扩展
?
答案解析:
-
消息队列(
MQ
) 是一种异步通信机制
,允许不同的系统或进程在无需直接相互通信的情况下,实现数据的传输和处理。消息队列的核心组件包括生产者(Producer
)、消费者(Consumer
)和消息队列本身
。生产者
负责生成消息并发送到队列中,消费者
从队列中接收并处理消息。 -
通过消息队列实现延迟队列和定时任务:
- 延迟队列:
延迟队列
是指消息在发送到队列后,会在一定的时间内被延迟处理,直到预设的延迟时间结束,消息才会被消费者消费。 - 定时任务:
定时任务
是指在指定的时间间隔内,周期性地执行某个任务。 - 实现方式:使用
RabbitMQ
、RocketMQ
等消息队列系统,通过设置消息的延迟时间或定时时间,实现延迟队列和定时任务。
- 延迟队列:
-
大规模使用消息队列时的容量规划和扩展:
- 容量规划:在大规模使用消息队列时,需要根据
实际业务需求
和消息的生产速率
、消费速率
等因素,合理规划消息队列的容量
。 - 扩展策略:
水平扩展
:通过增加更多的服务器节点,分散负载。例如,RocketMQ
支持集群模式,可以通过增加Broker
节点来扩展。负载均衡
:合理分配生产者和消费者到不同的节点,避免单点过载
。分区和副本
:通过分区(Sharding
)和副本(Replication
)机制,提高系统的可用性和可靠性。动态调整
:根据实时监控数据,动态调整资源分配,如自动扩展
或收缩集群
。
- 容量规划:在大规模使用消息队列时,需要根据
内存结构
V8
的内存结构主要分为栈区
和堆区
,其中堆区又细分为多个区域。
这个知识点主要考察面试者对 内存结构
的理解程度,如何进行内存的分配
和回收
,以及如何优化内存使用等。
经典面试题:
- 请简述
V8
引擎的内存结构
,如何进行内存分配
? - 什么是
弱引用
和软引用
?它们在内存管理中的作用是什么? - 哪些操作可能会导致
内存泄漏
?如何优化?
答案解析:
-
V8
引擎的内存结构:栈区(Stack)
:用于存储函数调用栈帧
、局部变量
(基本数据类型)和对象引用
。栈是线程私有的
,操作速度快
,但空间有限
。函数执行完毕后,栈帧会被自动销毁
。堆区(Heap)
:用于存储对象
和动态分配的内存
。堆区进一步细分为多个区域:新生代(New Space)
:存储生命周期较短
的对象。包含From Space
和To Space
,采用Scavenge
算法进行垃圾回收。老生代(Old Space)
:存储生命周期较长
的对象。采用标记-清除
和标记-整理
算法进行垃圾回收。大对象空间(Large Object Space)
:存储大型对象
(如大数组、大字符串等)。 这些对象通常不会被移动,以避免内存碎片化
。代码空间(Code Space)
:存储JIT
编译后的代码块。Map Space
:存储对象的内部结构Map
,用于优化对象属性的访问速度。
-
V8 的内存分配策略基于对象的
生命周期
和大小
。- 新生代分配:小型对象(通常小于
1MB
)首先被分配到新生代的From Space
。 - 老生代分配:大型对象或经过多次垃圾回收后仍然存活的对象被分配到老生代。
- 大对象分配:大型对象(如
大数组
、大字符串
等)直接分配到大对象空间(Large Object Space
)。
- 新生代分配:小型对象(通常小于
-
弱引用和软引用:
- 弱引用:
弱引用
是指对对象的引用,但不会阻止垃圾回收
器回收该对象。常用于实现缓存机制
,如WeakMap
和WeakSet
。这些对象在垃圾回收时会被自动清理
,避免内存泄漏
。 - 软引用:
软引用
是指对对象的引用,但垃圾回收器在内存不足时会优先回收
这些对象。常用于实现缓存机制
,如 SoftMap(虽然JS
原生没有SoftMap
,但可以通过自定义实现
)。这些对象在内存不足时会被回收,以释放内存
。
- 弱引用:
-
常见导致内存泄漏的操作:
全局变量未释放
:全局变量在不再使用时未被正确释放
,导致内存泄漏。定时器未清理
:定时器未被正确清理,导致内存泄漏。事件监听器未移除
:事件监听器未被正确移除,导致内存泄漏。未释放的 DOM 元素
:未被正确移除的DOM
元素会导致内存泄漏。未释放的资源
:未被正确释放的资源(如文件句柄、数据库连接等)会导致内存泄漏。闭包
:闭包会捕获外部变量,导致内存泄漏。
-
优化内存使用:
- 使用
WeakMap
和WeakSet
来存储临时数据,避免内存泄漏。 - 在
组件销毁
或不再需要时,清理定时器
和移除事件监听器
。 - 避免在循环中创建
大量对象
,尽量复用对象。 - 确保
闭包
中引用的变量在不再需要时被释放。 - 定期
检查内存使用
情况,及时发现并修复内存泄漏问题。
- 使用
垃圾回收
垃圾回收机制是基于分代收集
(Generational Garbage Collection
)的策略,将内存分为新生代
和老生代
,并针对不同代使用不同的回收算法。
这个知识点主要考察面试者对 垃圾回收
的理解程度,如何进行垃圾回收,以及如何优化垃圾回收等。
经典面试题:
- JS的
垃圾回收机制
是什么? - V8引擎使用了哪些
垃圾回收算法
? - V8是如何
优化
垃圾回收过程的?
答案解析:
-
垃圾回收机制(
GC
)是自动管理
内存分配和释放的过程,主要目的是回收那些不再被使用的内存空间,以防止内存泄漏,确保程序能够高效运行。垃圾回收机制主要依赖于以下几种策略: -
引用计数(Reference Counting)
:- 工作原理:每个对象都有一个
引用计数器
,当有一个引用指向该对象时,计数器加1
;当一个引用不再指向该对象
时,计数器减1
。如果某个对象的引用计数变为0,则表示该对象不再被任何地方引用,可以安全地释放。 - 缺点:
无法处理循环引用
的问题,即两个或多个对象相互引用,但实际已经不可达。
- 工作原理:每个对象都有一个
-
标记-清除(Mark-and-Sweep)
:- 工作原理:从
根对象
(如全局对象、执行栈中的变量等)开始,递归标记所有可达对象
。标记完成后,清除所有未被标记的对象。 - 缺点:可能导致
内存碎片化
,因为清理后的内存空间可能是不连续的。
- 工作原理:从
-
V8
引擎采用了分代收集策略,将内存分为新生代和老生代,并针对不同代使用不同的回收算法:新生代(Young Generation)
:- Scavenge 算法:新生代空间被分为两个区域:
From Space
和To Space
。新对象被分配到From Space
,当From Space
满时,触发垃圾回收。存活对象被复制到To Space
,然后交换两者的角色。这种方式可以有效回收短生命周期的对象。 - 对象晋升:经过多次垃圾回收后仍然存活的对象会被
晋升
到老生代。
- Scavenge 算法:新生代空间被分为两个区域:
老生代(Old Generation)
:- 标记-清除(Mark-and-Sweep):从根对象开始,递归标记所有可达对象,然后清除未标记的对象。
- 标记-整理(Mark-Compact):在标记阶段后,将存活对象向一端移动,消除内存碎片。
-
为了减少垃圾回收对主线程的
阻塞
时间,V8
引擎采用了多种优化策略:增量标记(Incremental Marking)
:将标记阶段分解为多个小步骤
,每一步完成后让出线程
,减少卡顿。惰性清理(Lazy Sweeping)
:在标记阶段完成后,如果发现剩余空间足够,可以延迟清理非活动对象
,或者只清理部分垃圾
。并行垃圾回收(Parallel GC)
:在新生代垃圾回收中,使用多个辅助线程并行处理
,提高垃圾回收效率。并发垃圾回收(Concurrent GC)
:在老生代垃圾回收中,标记阶段可以在后台线程中并发执行
,主线程继续执行 JS 代码。
编译原理
V8 引擎的编译原理是一个复杂而高效的过程,涉及多个阶段和优化策略。编译代码的过程大致为:脚本
-> 词法分析
-> 语法分析
-> AST(抽象语法树)
-> 字节码
-> 解释执行
。在解释执行的过程中,还会运用JIT
(Just-In-Time)实时优化,将热点代码
直接编译成机器码
。
这个知识点主要考察面试者对 编译原理
的理解程度,如何进行代码的编译
,以及如何优化代码的执行效率等。
经典面试题:
- V8 的
编译过程
大致可以分为哪几个阶段?每个阶段的主要任务是什么? - 请介绍一下 V8 引擎的架构和工作原理。
- 什么是即时编译(
JIT
)技术? - 如何进行
死代码消除
和内联展开
等编译优化技术? - 在大型项目中,如何通过编译优化来提升代码性能和加载速度?
答案解析:
-
V8 的编译过程大致可以分为以下几个阶段,每个阶段都有其特定的任务:
- 词法分析(Lexical Analysis):将源代码分解成一系列的词法单元(
tokens
),如标识符、关键字、操作符和字面量等。 - 语法分析(Syntax Analysis):将词法单元转换为抽象语法树(
AST
),AST 是代码的抽象表示,捕捉了代码的结构和关系。 - 字节码生成(Bytecode Generation):V8 的
Ignition
解释器将AST
转换为字节码。字节码
是一种中间表示形式,比机器码更易于生成和管理。 - 即时编译(JIT):V8 的
TurboFan
编译器会进一步分析字节码,识别出热点代码
(即频繁执行的代码段),并将其编译成高效的机器码。 - 优化与执行:在运行时,V8 会根据代码的执行情况动态调整优化策略。如果优化后的代码在运行时不符合预期,V8 会退出优化,恢复为字节码解释执行。
- 词法分析(Lexical Analysis):将源代码分解成一系列的词法单元(
-
V8 引擎的架构和工作原理:
- 解析器(Parser):将源代码解析为抽象语法树(
AST
),为后续的编译步骤提供代码的结构化
表示。 - 编译器(Compiler):将 AST 转换为
字节码
或机器码
,提高代码的执行效率。 - 执行引擎(Execution Engine):负责执行编译后的代码,并根据运行时信息进行优化,动态调整代码的执行策略,确保高效运行。
- 垃圾回收器(Garbage Collector):
自动管理内存
,回收不再使用的对象,防止内存泄漏。
- 解析器(Parser):将源代码解析为抽象语法树(
-
即时编译(
JIT
)技术:- 工作原理:
JIT
编译器在运行时将字节码转换为机器码,以提高代码的执行效率。 - 优点:动态优化,根据运行时信息生成更高效的机器码。
- 缺点:可能会导致性能下降,因为需要在运行时进行优化。
- 工作原理:
-
如何进行
死代码消除
和内联展开
等编译优化技术:- 死代码消除:通过
分析代码
的执行路径,识别出那些永远不会
被执行的代码片段,并将其移除。减少不必要的代码执行
,降低内存占用
,提高程序的运行效率。 - 内联展开:将被
调用函数
的代码直接插入到调用点,消除函数调用的开销。减少
函数调用的开销
,增加其他优化机会,但可能会增加代码体积。
- 死代码消除:通过
-
在大型项目中,如何通过编译优化来提升代码性能和加载速度:
- 优化代码结构:合理
划分模块
,减少不必要的重新编译。减少头文件的重复包含,提高编译速度
。 - 利用编译器优化选项:启用
增量编译
、优化选项
等,让编译器进行更激进的优化。 - 使用工具和技术:性能
分析工具
、分布式编译
等,识别编译过程中的瓶颈。
- 优化代码结构:合理
JS性能优化
JS 性能优化是指通过优化代码
、减少资源加载
、提高页面加载速度
、减少 DOM 操作
、使用合适的算法
和数据结构
等方式,来提升 JS 应用的性能。
这个知识点主要考察面试者对 JS性能优化
的理解程度,如何进行代码的优化
,以及如何优化代码的执行效率等。
经典面试题:
- 如何减少 JS 中的
DOM
操作以提升性能? - 什么是
防抖
和节流
?以及它们的应用场景。 - 如何实现
懒加载
以提升页面加载速度? - 如何减少页面的
重绘
和回流
操作以提升渲染性能? - 如何使用
Chrome DevTools
来分析和优化 JS 性能?
答案解析:
-
减少 JS 中的
DOM
操作以提升性能:- 缓存 DOM 查询结果:将查询结果
缓存
起来,避免重复查询。例如,将document.getElementById
的结果存储在变量中。 - 使用 DocumentFragment:在进行大量
DOM
操作时,先将元素添加到DocumentFragment
中,然后一次性插入到文档中。 - 批量更新 DOM:将
多次
DOM 操作合并为一次
,例如使用innerHTML
替换多个节点的操作。 - 离线操作 DOM:在对
DOM
进行大量操作时,可以先将元素从DOM
树中移除,完成操作后再插入回来。 - 避免在布局变化时读取布局信息:在布局发生变化时,如果立即读取布局信息(如
offsetTop
),会导致浏览器强制进行回流
。可以通过使用requestAnimationFrame
或setTimeout
来延迟读取布局信息。
- 缓存 DOM 查询结果:将查询结果
-
防抖(Debounce)
:- 工作原理:在事件被触发后,等待一段时间,如果在这段时间内事件再次被触发,则重新计时。
- 应用场景:适用于需要在事件触发后
等待一段时间
的场景,如搜索框输入联想
、窗口大小调整
等。
-
节流(Throttle)
:- 工作原理:在一定时间内只允许事件触发一次,限制事件触发的频率。
- 应用场景:适用于需要
限制事件触发频率
的场景,如滚动事件
。
-
如何实现
懒加载
以提升页面加载速度:- 懒加载:将资源的加载推迟到需要时再进行,减少页面初始加载时间。
- 实现方式:
- 使用 Intersection Observer API:这是一种现代且高效的方法,通过观察目标元素的可见性来触发加载。
- 使用滚动事件监听:使用滚动事件监听,当元素进入视口时触发加载。
-
如何减少页面的
重绘
和回流
操作以提升渲染性能:重绘
是指重新绘制页面的一部分,回流
是指重新计算页面的布局。减少重绘和回流可以显著提升渲染性能。- 实现方式:
- 避免频繁操作样式:将多次操作样式合并为一次操作,或者使用
CSS
类来批量修改样式。 - 使用 DocumentFragment:在进行大量 DOM 操作时,先将元素添加到
DocumentFragment
中,然后一次性插入到文档中。 - 使用虚拟 DOM:
虚拟 DOM
可以减少不必要的 DOM 操作,从而降低回流和重绘的次数。 - 避免使用
table
布局:table
布局会触发大量的回流和重绘,尽量避免使用table
布局。 - 避免使用过多的浮动:
浮动
会导致周围元素重新计算位置,引发回流
。可以使用CSS
的flex
布局或者使用绝对定位
来代替浮动。
- 避免频繁操作样式:将多次操作样式合并为一次操作,或者使用
-
如何使用
Chrome DevTools
来分析和优化 JS 性能:- 打开
Chrome DevTools
,选择“Performance”
面板,点击“Record”
按钮开始记录页面加载过程中的性能数据。 - 当页面加载完成后,点击
“Stop”
按钮结束记录。 - 在性能报告中,观察页面加载过程中的各个时间节点和函数调用。
- 打开