一、预编译
全局预编译
- 寻找
var声明的变量,将变量名作为 VO 对象的属性名,值为undefined - 寻找函数声明,将函数名作为 VO 对象的属性名,值为函数本身
- 变量名与函数名冲突,函数声明会覆盖变量声明
函数预编译
- 寻找
var声明的变量,将变量名作为 AO 对象的属性名,值为undefined - 寻找形参,将形参名作为 AO 对象的属性名,值为
undefined - 将实参值赋值给形参
- 寻找函数声明,将函数名作为 AO 对象的属性名,值为函数本身
- 变量名与函数名冲突,函数声明会覆盖变量声明
!NOTE
VO(Variable Object) :变量对象,全局作用域下叫
GO(Global Object),也常被称为 VO,用于存放全局变量、函数声明。AO(Activation Object):活动对象,函数执行时创建,是 VO 的一种,用于存放函数的形参、变量声明、函数声明等。
例题:
js
function fn(d) {
console.log(b);
if (!a) {
var b = 100;
}
console.log(b);
c = 234;
console.log(c);
}
var a;
fn(3);
a = 10;
console.log(c);
预编译与输出
js
//全局预编译
VO{
a:undefined 10
fn:fn
c:undefined 234
}
js
//函数预编译
AO{
b:undefined 100
d:3
}
js
//输出
undeined 100 234 234
二、EventLoop事件循环
一、核心概念
-
JS 是单线程语言
JS 同一时间只能做一件事,为了避免长任务阻塞页面渲染 / 用户交互,引入了事件循环机制。
-
任务分类:同步任务 + 异步任务
- 同步任务 :直接进入执行栈,从上到下依次执行。
- 异步任务 :不会阻塞主线程,会被推入异步队列等待执行时机。
-
异步队列细分:宏任务(MacroTask) + 微任务(MicroTask)
- 优先级:微任务队列 > 宏任务队列
- 执行规则:执行栈清空后,先清空所有微任务,再执行下一个宏任务,如此循环。
-
EventLoop 本质
它是 JS 运行环境(浏览器 / Node.js)的调度执行规则,不是 JS 语言本身的特性,由宿主环境实现。
二、宏任务 vs 微任务 对照表
| 任务代码 | 类型 | 运行环境 |
|---|---|---|
| 浏览器事件(click/scroll 等) | 宏任务 | 浏览器 |
| 网络请求(Ajax/fetch) | 宏任务 | 浏览器 |
setTimeout / setInterval |
宏任务 | 浏览器、Node.js |
fs.readFile(Node 文件读取) |
宏任务 | Node.js |
Promise.then / Promise.catch / Promise.finally |
微任务 | 浏览器、Node.js |
async/await(本质是 Promise 语法糖) |
微任务 | 浏览器、Node.js |
queueMicrotask |
微任务 | 浏览器、Node.js |
MutationObserver(DOM 变化监听) |
微任务 | 浏览器 |
补充:
async/await中,await之后的代码会被视为微任务,加入微任务队列。
三、经典案例解析
js
console.log(1);
async function async1() {
await async2();
console.log(2);
await async3();
console.log(3);
}
async function async2() {
console.log(4);
}
async function async3() {
console.log(5);
}
async1();
console.log(6);
执行顺序拆解(对应输出:1 4 6 2 5 3)
四、延伸知识点
1. 浏览器与 Node.js 事件循环的区别
| 环境 | 区别点 |
|---|---|
| 浏览器 | 执行栈清空后,一次性清空所有微任务,再执行下一个宏任务 |
| Node.js(v11+ 后趋同) | 早期版本按阶段执行(timers → pending → idle → poll → check → close),每个阶段执行完才处理微任务;v11 后改为和浏览器一致,宏任务执行完立即清空微任务 |
五、面试题通用答题模板
问题:说说你对 JS 事件循环的理解?
JS 是单线程语言,为了避免长任务阻塞页面,通过事件循环机制调度同步 / 异步任务。
同步任务直接进入执行栈执行;异步任务分为宏任务和微任务,会被推入对应的队列。
事件循环的核心规则是:执行栈清空后,先执行所有微任务,再执行下一个宏任务,如此循环往复。
其中微任务包括 Promise.then、async/await 等,宏任务包括 setTimeout、DOM 事件、Ajax 等,微任务的优先级高于宏任务。
三、区分数组和对象
1. 三种常见数据类型检测方案
方案 1:typeof 运算符
-
优点 :可以快速区分基本数据类型(
String/Number/Boolean/undefined/Symbol等),执行效率高。 -
缺点:
- 无法区分引用类型,
typeof []和typeof {}都会返回"object"。 typeof null也会返回"object",无法区分null和普通对象。
- 无法区分引用类型,
-
适用场景:快速判断基本数据类型,不适合做引用类型的精准区分。
方案 2:instanceof 运算符
-
原理 :通过检测构造函数的
prototype是否出现在对象的原型链上,来判断对象类型。 -
优点 :可以区分
Array、Object、Function等引用类型,如[] instanceof Array为true,[] instanceof Object也为true(因为数组原型链上存在Object.prototype)。 -
缺点:
- 无法判断
Number/Boolean/String等基本数据类型(除非是包装对象,如new Number(1) instanceof Number为true,但原始值1 instanceof Number为false)。 - 存在跨 iframe / 跨窗口的兼容性问题,不同全局环境的构造函数不同,会导致判断失效。
- 无法判断
-
适用场景:引用类型的原型链检测,如判断是否为数组、自定义类实例。
方案 3:Object.prototype.toString.call()
-
原理 :调用
Object原型上的toString方法,返回[object 类型名]格式的字符串。 -
优点 :可以精准判断所有数据类型,包括
null、undefined、数组、对象、日期、正则等。- 例:
Object.prototype.toString.call([])返回"[object Array]",Object.prototype.toString.call({})返回"[object Object]",Object.prototype.toString.call(null)返回"[object Null]"。
- 例:
-
缺点:写法繁琐,需要手动封装成工具函数,不便于直接使用。
-
适用场景:所有数据类型的精准判断,是生产环境最推荐的通用方案。
2. 数组与对象的区分方法(高频考点)
方法 1:Array.isArray()(推荐)
-
ES6 新增的专门判断数组的方法,兼容性好,直接返回布尔值:
Array.isArray([]) // true Array.isArray({}) // false
方法 2:Object.prototype.toString.call()
-
通用精准方案,直接通过返回值区分:
Object.prototype.toString.call([]) === '[object Array]' // true Object.prototype.toString.call({}) === '[object Object]' // true
方法 3:instanceof Array
-
前提:无跨环境问题时使用:
[] instanceof Array // true {} instanceof Array // false
方法 4:利用数组的 constructor 属性
-
不推荐,因为
constructor可以被修改,存在风险:[].constructor === Array // true {}.constructor === Object // true
三、拓展:封装通用数据类型检测工具函数
面试加分项,展示工程化思维:
js
function getType(val) {
return Object.prototype.toString.call(val).slice(8, -1).toLowerCase();
}
// 测试
getType([]) // "array"
getType({}) // "object"
getType(null) // "null"
getType(undefined) // "undefined"
getType(123) // "number"
getType(new Date()) // "date"
四、面试题回答模板(直接背诵)
JS 中常见的数据类型检测方案有三种:
typeof:优点是快速区分基本数据类型,缺点是无法区分数组、普通对象和null。instanceof:优点是可以区分引用类型(如数组、对象、函数),缺点是无法判断基本数据类型,且存在跨环境兼容性问题。Object.prototype.toString.call():优点是可以精准判断所有数据类型,包括数组、对象、null等,缺点是写法繁琐,通常需要封装后使用。区分数组和对象,推荐使用
Array.isArray()或Object.prototype.toString.call()方案,其中Object.prototype.toString.call()是通用且精准的方案,生产环境通常会封装成工具函数使用。
四、闭包
一、基础题(概念 + 手写)
题目 1:说说你对闭包的理解,闭包有什么作用?
参考答案:
闭包是指内部函数可以访问外部函数作用域中变量的现象,本质是函数与它的词法环境的绑定。
通俗来说:一个函数内部定义的函数,引用了外部函数的变量,就形成了闭包。
闭包的主要作用有:
- 封装私有变量 :可以实现数据私有化,对外暴露有限的操作接口,比如计数器案例中的
count变量,外部无法直接修改,只能通过闭包函数操作。 - 延长变量生命周期:外部函数执行结束后,其内部变量不会被回收,因为闭包函数还在引用它。
- 模块化 / 单例模式:早期 JS 实现模块化、单例的常用方式。
题目 2:手写一个闭包计数器,解释其原理
参考答案:
js
function createCounter() {
// 外部函数的变量,被内部函数捕获
let count = 0;
// 返回内部函数(闭包)
return function() {
count++;
return count;
}
}
// 实例化计数器
const counter1 = createCounter();
const counter2 = createCounter();
console.log(counter1()); // 1
console.log(counter1()); // 2
console.log(counter2()); // 1(独立的count变量,互不影响)
原理解析:
- 每次调用
createCounter()都会创建一个独立的count变量。 - 返回的内部函数形成闭包,持有对
count的引用。 - 即使
createCounter执行完毕,count也不会被 GC 回收,每次调用闭包函数都会操作这个被保留的变量。
二、进阶题(坑点 + 面试高频)
题目 3:下面代码的输出是什么?为什么?
js
for (var i = 1; i <= 3; i++) {
setTimeout(function() {
console.log(i);
}, 1000);
}
输出结果: 输出三次 4
解析:
var声明的变量i是全局变量,循环结束后i=4。- 三个
setTimeout的回调函数形成闭包,引用的是同一个全局i。 - 回调执行时,循环早已结束,
i的值已经变成了4。
解决方案(闭包经典用法):
js
// 方案1:用立即执行函数创建闭包,捕获每次循环的i
for (var i = 1; i <= 3; i++) {
(function(j) {
setTimeout(function() {
console.log(j);
}, 1000);
})(i);
}
// 方案2:用let声明,形成块级作用域
for (let i = 1; i <= 3; i++) {
setTimeout(function() {
console.log(i);
}, 1000);
}
题目 4:闭包的缺点是什么?如何避免?
参考答案:
-
缺点 :闭包会导致外部函数的变量无法被垃圾回收机制回收,容易造成内存泄漏。如果大量使用闭包,会占用过多内存,影响性能。
-
避免方式:
- 不再使用闭包时,手动将引用的变量设为
null,释放引用。 - 避免在循环中滥用闭包,防止多个闭包持有同一变量的引用。
- 合理使用
let/const块级作用域,减少不必要的闭包场景。
- 不再使用闭包时,手动将引用的变量设为
三、延伸知识点(面试加分项)
1. 闭包与作用域链的关系
闭包的实现依赖于作用域链:
- 内部函数在创建时,会保存对外部函数作用域的引用。
- 当内部函数执行时,会沿着作用域链向上查找变量,即使外部函数已经执行完毕,也能访问到外部作用域的变量。
2. 闭包的其他常见应用场景
-
函数柯里化:利用闭包保存参数,分步执行函数。
jsfunction add(a) { return function(b) { return a + b; } } const add5 = add(5); console.log(add5(3)); // 8 -
防抖 / 节流:用闭包保存定时器 ID 或上次执行时间。
js//防抖 function debounce(fn,delay=300){ let timer; return function(...args){ clearTimeout(timer); timer=setTimeout(()=>{ fn.apply(this,args); },delay) } } //节流(简洁版) function throttle(fn,delay=300){ let timer=null; return function(...args){ if(timer) return; timer=setTimeout(()=>{ fn.apply(this,args); timer=null; },delay) } } -
私有方法 / 属性:实现类似面向对象的封装。
3. 闭包的判断标准
判断一个场景是否形成闭包,需要满足三个条件:
- 存在嵌套函数(内部函数)。
- 内部函数引用了外部函数的变量 / 参数。
- 内部函数被外部函数返回或传递到外部使用。
四、面试答题模板
闭包是指内部函数可以访问外部函数作用域变量的现象,本质是函数与其词法环境的绑定。
它的主要作用是封装私有变量、延长变量生命周期,常用于实现计数器、模块化、柯里化等场景。
但闭包会导致变量无法被回收,可能造成内存泄漏,使用时需要注意及时释放引用。
五、this指向
一、核心知识点总结
1. 核心原则:this 由调用方式决定
this 是运行时绑定 的,不是定义时绑定,同一个函数,调用方式不同,this 指向也会不同。
this 不能在执行期间被赋值,每次调用都可能变化。
2. 普通函数(声明式 / 表达式)的 this 指向
| 调用方式 | this 指向 |
示例 |
|---|---|---|
| 直接调用(普通函数) | 浏览器中为 window,严格模式下为 undefined |
function fn(){console.log(this)}; fn() |
| 作为对象方法调用 | 调用该方法的对象(谁调用指向谁) | obj.fn() 中 this 指向 obj |
构造函数调用(new) |
新创建的实例对象 | new Person() 中 this 指向实例 |
call/apply/bind 调用 |
传入的第一个参数(指定的对象) | fn.call(obj) 中 this 指向 obj |
3. ES6 箭头函数的 this 指向(核心考点)
- 箭头函数本身没有自己的
this,它的this是从上一层作用域继承来的。 - 继承的是定义时 外层作用域的
this,和调用方式无关。 - 无法通过
call/apply/bind改变箭头函数的this指向。
4. 图 2 案例解析(帮你彻底理解)
var obj = {
say: function() {
var f1 = () => {
console.log(this);
}
f1();
},
pro: {
getPro: () => {
console.log(this);
}
}
}
var o = obj.say;
o(); // window
obj.say(); // obj
obj.pro.getPro(); // window
o():say被直接调用,普通函数的this指向window;箭头函数f1继承say的this,因此输出window。obj.say():say作为对象方法调用,this指向obj;箭头函数f1继承obj,因此输出obj。obj.pro.getPro():getPro是箭头函数,定义时外层作用域是全局,继承了全局的this(window),因此无论怎么调用,都输出window。
二、面试题
题目 1:基础题 - 说出下面代码的输出,并解释原因
function fn() {
console.log(this);
}
// 1. 直接调用
fn();
// 2. 作为对象方法调用
const obj = { fn: fn };
obj.fn();
// 3. call/apply 调用
fn.call({ name: 'test' });
// 4. new 调用
new fn();
参考答案:
fn():普通调用,非严格模式下this指向window(浏览器环境),严格模式下为undefined。obj.fn():作为对象方法调用,this指向调用者obj。fn.call({ name: 'test' }):call指定this,指向传入的对象{ name: 'test' }。new fn():构造函数调用,this指向新创建的实例对象。
题目 2:进阶题 - 箭头函数的 this
const obj = {
a: 10,
fn: () => {
console.log(this.a);
}
}
obj.fn(); // 输出什么?为什么?
参考答案:
输出 undefined。
原因:fn 是箭头函数,定义时外层作用域是全局作用域,继承了全局的 this(window),而全局中没有 a 变量,因此输出 undefined。
题目 3:坑点题 - 经典 this 陷阱
var name = '全局';
const obj = {
name: 'obj',
say: function() {
setTimeout(function() {
console.log(this.name);
}, 1000);
}
}
obj.say(); // 输出什么?如何修改才能输出 'obj'?
参考答案:
-
输出:
全局。原因:
setTimeout的回调是普通函数,执行时
this指向
window,因此访问的是全局的
name。
-
解决方案(任选其一):
-
用
that保存外层this:say: function() { const that = this; setTimeout(function() { console.log(that.name); }, 1000); } -
改为箭头函数,继承外层
this:say: function() { setTimeout(() => { console.log(this.name); }, 1000); }
-
三、延伸知识点(面试加分项)
1. this 绑定优先级
new 绑定 > call/apply/bind 绑定 > 对象方法绑定 > 普通函数默认绑定。
2. 箭头函数的适用场景与不适用场景
✅ 适用场景 :回调函数(如 setTimeout、数组方法),需要继承外层 this 时。
❌ 不适用场景:
- 作为对象方法(因为继承了外层
this,无法指向对象本身)。 - 作为构造函数(箭头函数没有
this,不能用new)。 - 需要动态
this的场景(如事件监听的回调)。
3. 改变 this 指向的三种方法
| 方法 | 特点 | 适用场景 |
|---|---|---|
call |
立即执行,参数逐个传入 | 临时改变 this,执行一次 |
apply |
立即执行,参数以数组形式传入 | 临时改变 this,执行一次,参数为数组 |
bind |
返回新函数,不立即执行 | 永久绑定 this,常用于回调函数 |
四、面试答题模板(直接背诵)
JS 中
this的指向由函数的调用方式决定,运行时绑定,定义时无法确定。普通函数的
this有四种绑定方式:默认绑定(普通调用指向window)、对象方法绑定(指向调用者)、new绑定(指向实例)、call/apply/bind绑定(指向指定对象)。箭头函数没有自己的
this,会继承外层作用域的this,无法通过call/apply/bind改变,适合用于回调函数,不适合作为对象方法或构造函数。
六、JS原型和原型链
一、核心知识点总结
1. 原型的基础概念
- 显示原型
prototype:每个函数都有,指向函数的原型对象,用于共享属性和方法。 - 隐式原型
__proto__:每个实例对象都有 ,指向创建它的构造函数的prototype。 - 核心关系:
实例.__proto__ === 构造函数.prototype
2. 原型链的特性
-
原型链决定了对象的属性访问顺序:
- 访问属性时,先在对象自身查找;
- 找不到就顺着
__proto__向上找(即原型链); - 原型链顶端是
Object.prototype.__proto__,值为null; - 整条链都找不到,返回
undefined。
3. 构造函数、原型对象、实例的三角关系
-
构造函数
Fn:Fn.prototype→ 指向原型对象Fn.prototype.constructor→ 指回构造函数Fn
-
实例
f = new Fn():f.__proto__ === Fn.prototypef.constructor === Fn(从原型链继承而来)
-
函数本身的原型链:
Fn.__proto__ === Function.prototypeFunction.__proto__ === Function.prototypeObject.prototype.__proto__ === null(原型链的终点)
二、高频面试题(由浅入深)
题目 1:基础题 - 原型与原型链的概念
问题: 什么是原型?什么是原型链?
参考答案:
- 原型(prototype) :JS 中每个函数都有一个
prototype属性,指向该函数的原型对象,用于存放共享的属性和方法,实现继承。 - 原型链 :每个对象都有
__proto__指向其构造函数的prototype,而原型对象也有自己的__proto__,这样层层向上形成的链式结构就是原型链。原型链的顶端是null,它决定了对象属性的查找顺序。
题目 2:判断题 - 核心关系验证
问题: 判断以下代码的输出,并解释原因:
js
function Fn() {}
const f = new Fn();
console.log(f.__proto__ === Fn.prototype);
console.log(Fn.prototype.constructor === Fn);
console.log(f.constructor === Fn);
console.log(Fn.__proto__ === Function.prototype);
console.log(Object.prototype.__proto__);
参考答案:
true:实例的__proto__指向构造函数的prototype。true:构造函数的prototype对象的constructor指回构造函数本身。true:实例本身没有constructor属性,会从原型链上继承Fn.prototype.constructor,即Fn。true:Fn是函数,所有函数的__proto__都指向Function.prototype。null:Object.prototype是原型链的顶端,其__proto__为null。
题目 3:坑点题 - 函数的 prototype 和 __proto__
问题: 函数的 prototype 和它的 __proto__ 是否指向同一个原型?为什么?
参考答案:
不指向同一个原型。
Fn.prototype是Fn构造出来的实例的原型对象,默认是一个普通对象,用于给实例共享属性。Fn.__proto__是Fn作为函数对象,其构造函数Function的prototype,即Function.prototype。- 两者完全不同:
Fn.prototype是给它的实例用的,Fn.__proto__是它自己作为对象继承的原型。
题目 4:进阶题 - 原型链继承
问题: 如何用原型链实现继承?有什么缺点?
参考答案:
js
function Parent(name) {
this.name = name;
}
Parent.prototype.say = function() { console.log(this.name); }
function Child(age) {
this.age = age;
}
// 核心:将 Child 的原型指向 Parent 的实例
Child.prototype = new Parent('parent');
Child.prototype.constructor = Child;
const c = new Child(18);
c.say(); // 'parent'
缺点:
- 子类实例无法向父类构造函数传参;
- 所有子类实例共享父类原型上的引用类型属性,修改一个会影响所有;
- 无法实现多继承。
三、延伸知识点(面试加分项)
1. new 关键字的执行过程
- 创建一个空对象
obj; - 让
obj.__proto__指向构造函数的prototype; - 绑定
this到obj,执行构造函数; - 如果构造函数返回非空对象,则返回该对象,否则返回
obj。
2. instanceof 的原理
a instanceof B 的本质是:检查 B.prototype 是否出现在 a 的原型链上。
js
function instanceOf(a, B) {
let proto = a.__proto__;
while (proto) {
if (proto === B.prototype) return true;
proto = proto.__proto__;
}
return false;
}
3. 原型链的性能问题
- 查找属性时,若在原型链的顶端才找到,性能会下降;
- 避免在
Object.prototype上添加属性,会污染所有对象的原型链,导致意外的属性访问。
4. ES6 class 与原型的关系
class 是原型继承的语法糖,本质上还是基于原型链实现的:
class Parent {}等价于function Parent() {};Parent.prototype.method等价于Parent.prototype.method = function() {};extends底层依然是通过修改prototype和__proto__实现继承。
四、面试答题模板(直接背诵)
JS 中每个函数都有
prototype(显示原型),每个对象都有__proto__(隐式原型),实例的__proto__指向其构造函数的prototype。多个对象通过__proto__层层向上形成的链式结构就是原型链,其顶端是null。原型链决定了对象属性的查找顺序,也实现了 JS 的继承机制。
七、腾讯另类考车数组去重
一、核心知识点解析
题目背景:找出 HTML 中所有不重复的标签名
图中给了两种写法,核心都是数组去重 + 类数组转数组:
1. 核心方法:Set 去重(ES6 推荐)
[...new Set([...document.querySelectorAll("*")].map(v => v.tagName))];
document.querySelectorAll("*"):获取页面所有 DOM 节点,返回类数组(NodeList)[...document.querySelectorAll("*")]:用扩展运算符将类数组转为真正数组.map(v => v.tagName):提取所有标签名,生成一个包含重复标签名的数组new Set(...):利用 Set 数据结构的特性,自动去除重复值[...new Set(...)]:再将 Set 转回数组,得到不重复的标签名数组
2. 兼容写法:Array.prototype.slice.call 转数组
[...new Set(Array.prototype.slice.call(document.querySelectorAll("*")).map(v => v.tagName))];
Array.prototype.slice.call(类数组):ES5 中类数组转数组的经典写法,和扩展运算符效果一致- 后续去重逻辑和上面完全相同
二、JS 数组去重 全方案总结(面试高频)
| 方法 | 原理 | 优点 | 缺点 |
|---|---|---|---|
Set 去重 |
利用 Set 元素唯一性 | 代码极简,性能好,支持所有类型 | ES6+,不兼容 IE |
indexOf 遍历 |
遍历数组,判断元素在新数组中是否存在 | 兼容所有浏览器 | 性能差,无法识别 NaN |
includes 遍历 |
遍历数组,用 includes 判断 |
能识别 NaN,写法简单 |
ES7+,兼容性一般 |
filter + indexOf |
过滤出第一次出现的元素 | 代码简洁,兼容 ES5 | 无法识别 NaN |
对象键值对 |
用对象 key 不重复的特性 | 性能好,可自定义规则 | 无法区分 1 和 '1',null/undefined 处理麻烦 |
reduce 累加 |
用 reduce 维护一个无重复数组 |
可同时实现去重和数据处理 | 代码可读性一般 |
三、高频面试题(由浅入深)
题目 1:基础题 - 手写 Set 去重
问题: 如何用 ES6 的 Set 实现数组去重?写出代码并说明原理。
参考答案:
js
function unique(arr) {
return [...new Set(arr)];
}
// 测试
console.log(unique([1,2,2,3,3,3,NaN,NaN]));
// 输出 [1,2,3,NaN]
原理: Set 是 ES6 新增的数据结构,它的元素具有唯一性,不允许重复值存在。利用这一特性,将数组传入 Set 构造函数,再转回数组即可完成去重,且可以正确识别 NaN。
题目 2:进阶题 - 手写兼容 ES5 的数组去重函数
问题: 不使用 Set/includes,写一个兼容 ES5 的数组去重函数,要求能识别 NaN。
参考答案:
js
function unique(arr) {
var result = [];
// 单独标记:数组里是否已经存过 NaN
var hasNaN = false;
for (var i = 0; i < arr.length; i++) {
var item = arr[i];
// 1. 判断是不是 NaN
if (item !== item) {
// 没存过NaN,才push
if (!hasNaN) {
result.push(item);
hasNaN = true; // 标记已存在
}
}
// 2. 普通正常值
else {
if (result.indexOf(item) === -1) {
result.push(item);
}
}
}
return result;
}
解析: 普通 indexOf 无法识别 NaN,因为 NaN !== NaN,所以需要单独判断 arr[i] !== arr[i] 的情况。
题目 3:场景题 - 去除 DOM 节点中的重复标签名
问题: 如何获取当前页面所有不重复的标签名?写出两种实现方式。
参考答案:
方式 1(ES6 写法):
js
const tags = [...new Set([...document.querySelectorAll('*')].map(el => el.tagName))];
方式 2(ES5 兼容写法):
js
var elements = document.getElementsByTagName('*');
var arr = Array.prototype.slice.call(elements);
var tags = arr.map(function(el) { return el.tagName; });
var uniqueTags = [];
for (var i = 0; i < tags.length; i++) {
if (uniqueTags.indexOf(tags[i]) === -1) {
uniqueTags.push(tags[i]);
}
}
题目 4:坑点题 - 不同类型值的去重
问题: 以下数组用 Set 去重后的结果是什么?为什么?
js
const arr = [1, '1', true, 'true', undefined, null, NaN, NaN, {}, {}];
console.log([...new Set(arr)]);
参考答案:
输出:[1, '1', true, 'true', undefined, null, NaN, {}, {}]
解析:
- 基本类型会按值去重:
NaN也会被正确去重; - 引用类型(对象 / 数组)比较的是引用地址,两个空对象地址不同,所以不会被去重。
四、延伸知识点(面试加分项)
1. 类数组转数组的方法
[...类数组](ES6 扩展运算符)Array.from(类数组)(ES6,可同时做映射)Array.prototype.slice.call(类数组)(ES5 兼容写法)Array.prototype.concat.apply([], 类数组)(ES5 兼容写法)
2. 复杂数组去重(对象数组)
如果数组元素是对象,需要根据某个属性去重,例如根据 id 去重:
js
const arr = [{id:1}, {id:2}, {id:1}];
const uniqueArr = arr.filter((item, index, self) =>
index === self.findIndex(el => el.id === item.id)
);
3. 性能对比
- 数据量小:所有方法性能差异不大;
- 数据量大:
Set和对象键值对方式性能远高于遍历 +indexOf; - 处理引用类型:需要自定义比较逻辑,
Set无法直接去重。
五、面试答题模板(直接背诵)
数组去重的核心是利用数据结构的唯一性特性或遍历判断实现。
ES6 推荐使用
Set去重,代码简洁且能识别NaN;ES5 可使用indexOf或filter遍历判断,但需要处理NaN等特殊值。对于类数组(如 DOM 节点集合),需要先转为数组再去重,常用的转数组方式有扩展运算符、
Array.from和Array.prototype.slice.call
八、深拷贝和浅拷贝
一、核心知识点总结(结合图中内容)
1. 前置概念:JS 的数据类型
-
基本数据类型 :
String/Number/Boolean/Null/Undefined/Symbol/BigInt- 存储在栈内存中,变量直接保存值本身,赋值时直接拷贝值。
-
引用数据类型 :
Object/Array/Function等- 存储在堆内存 中,栈内存中的变量保存的是堆内存的引用地址,赋值时拷贝的是地址。
2. 赋值、浅拷贝、深拷贝的区别
| 方式 | 特点 | 影响 |
|---|---|---|
| 赋值 | 直接拷贝引用地址 | 新旧对象指向堆内存中同一个对象,修改任意一方都会影响另一方 |
| 浅拷贝 | 只拷贝一层属性:基本类型直接拷贝值,引用类型拷贝地址 | 只修改第一层基本类型互不影响;修改嵌套引用类型会互相影响 |
| 深拷贝 | 递归拷贝所有层级属性,在堆内存中创建一个完全独立的对象 | 新旧对象完全独立,修改任意一方都不会影响另一方 |
3. 浅拷贝的常见实现方式
Object.assign({}, obj):合并对象实现浅拷贝Array.prototype.slice()/Array.prototype.concat():数组的浅拷贝- 扩展运算符
{...obj}/[...arr]:ES6 推荐写法 - 自定义遍历:手动遍历对象 / 数组的第一层属性赋值
4. 深拷贝的常见实现方式
JSON.parse(JSON.stringify(obj)):快速实现,但有局限性(无法处理函数、undefined、Symbol、循环引用)lodash.cloneDeep(obj):生产环境常用的成熟深拷贝方法- 递归实现:手写深拷贝函数,处理对象 / 数组的递归拷贝(图中代码就是这种方式)
5. 手写深拷贝函数解析(图中代码)
js
function deepClone(obj) {
// 处理 null、undefined、非对象类型
if (typeof obj !== 'object' || obj === null) {
return obj;
}
// 判断是数组还是对象,创建对应的空容器
const result = Object.prototype.toString.call(obj) === '[object Array]' ? [] : {};
// 遍历对象自身属性(排除原型链上的属性)
for (let key in obj) {
if (obj.hasOwnProperty(key)) {
// 递归拷贝属性值
result[key] = deepClone(obj[key]);
}
}
return result;
}
- 处理了基本类型、
null、数组 / 对象的区分 - 使用
hasOwnProperty过滤原型链上的属性 - 递归实现所有层级的拷贝
二、高频面试题(由浅入深)
题目 1:基础题 - 说说赋值、浅拷贝、深拷贝的区别
参考答案:
- 赋值:直接拷贝引用地址,新旧对象完全共享同一个堆内存数据,修改任意一方都会影响另一方。
- 浅拷贝:只拷贝第一层属性,基本类型直接拷贝值,嵌套的引用类型拷贝地址;修改第一层基本类型互不影响,修改嵌套引用类型会互相影响。
- 深拷贝:递归拷贝所有层级属性,在堆内存中创建一个完全独立的对象;新旧对象完全独立,修改任意一方都不会影响另一方。
题目 2:进阶题 - 手写一个深拷贝函数,需要处理数组和对象
参考答案:
js
function deepClone(obj, map = new Map()) {
// 处理 null、undefined、非对象类型
if (typeof obj !== 'object' || obj === null) {
return obj;
}
// 处理循环引用
if (map.has(obj)) {
return map.get(obj);
}
// 判断是数组还是对象,创建对应的空容器
const result = Object.prototype.toString.call(obj) === '[object Array]' ? [] : {};
map.set(obj, result);
// 遍历对象自身属性
for (let key in obj) {
if (obj.hasOwnProperty(key)) {
result[key] = deepClone(obj[key], map);
}
}
return result;
}
解析: 优化版加入了 Map 处理循环引用,避免栈溢出。
题目 3:坑点题 - JSON.parse(JSON.stringify(obj)) 实现深拷贝有什么局限性?
参考答案:
- 无法处理
undefined、Symbol、函数类型,会直接丢失这些属性; - 无法处理循环引用的对象,会直接报错;
- 无法处理
Date类型,会被转为字符串; - 无法处理
RegExp类型,会被转为空对象; - 无法处理
Set/Map等特殊对象类型。
题目 4:场景题 - 什么情况下会用到深拷贝?
参考答案:
- 需要完全复制一个对象,且后续修改复制对象时,不影响原对象;
- 处理复杂嵌套对象 / 数组,比如后端返回的嵌套 JSON 数据;
- 开发中修改状态数据(如 Vue/React 的 state),避免直接修改原数据导致的副作用;
- 实现对象的缓存、快照功能,需要保存对象的完整副本。
三、延伸知识点(面试加分项)
1. 循环引用的处理
当对象存在循环引用(如 obj.a = obj)时,普通递归深拷贝会栈溢出,需要用 Map 或 WeakMap 记录已经拷贝过的对象,避免重复拷贝。
2. 特殊对象的深拷贝处理
Date:需要通过new Date(obj.getTime())拷贝;RegExp:需要通过new RegExp(obj.source, obj.flags)拷贝;Set/Map:需要遍历元素,逐个拷贝后再创建新的Set/Map。
3. 浅拷贝的局限性
浅拷贝只拷贝第一层,当对象存在嵌套引用类型时,新旧对象依然会共享嵌套对象,无法完全隔离数据,因此只适合无嵌套的简单对象 / 数组。
四、面试答题模板(直接背诵)
JS 中赋值、浅拷贝、深拷贝的核心区别在于对引用类型的处理方式。赋值直接拷贝引用地址,新旧对象共享数据;浅拷贝只拷贝第一层,嵌套引用类型仍共享地址;深拷贝递归拷贝所有层级,创建完全独立的对象。
浅拷贝常用
Object.assign、扩展运算符实现;深拷贝可使用JSON.parse(JSON.stringify)快速实现(有局限性),或手写递归函数处理,生产环境推荐使用lodash.cloneDeep。
九、call,apply,bind
一、核心知识点总结(结合图中内容)
1. 三者的核心作用
都是用来改变函数执行时的 this 指向,但在执行方式、传参方式上有区别。
2. call、apply、bind 的区别
| 方法 | 执行方式 | 传参方式 | 特点 |
|---|---|---|---|
call |
立即执行 | 第一个参数为 this 指向,后续为参数列表 fn.call(obj, 1, 2, 3) |
临时改变 this,一次生效 |
apply |
立即执行 | 第一个参数为 this 指向,第二个为参数数组 fn.apply(obj, [1, 2, 3]) |
临时改变 this,一次生效,适合数组参数 |
bind |
不立即执行,返回新函数 | 第一个参数为 this 指向,后续可分多次传参 const newFn = fn.bind(obj, 1, 2) |
永久绑定 this,多次调用都生效 |
3. 关键特性补充
- 当第一个参数为
null/undefined时,非严格模式下默认指向window(浏览器环境)。 bind绑定后的函数,this无法再被call/apply改变。call和apply只是一次性修改this,不会影响原函数。
4. 手写 call 实现(图中代码解析)
js
Function.prototype.myCall = function(ctx, ...args) {
// 1. 校验调用者是否为函数
if (typeof this !== 'function') {
throw new TypeError('myCall must be called on a function');
}
// 2. 处理 ctx 为 null/undefined 的情况,默认指向 window
ctx = ctx || window;
// 3. 用 Symbol 生成唯一键,避免覆盖 ctx 原有属性
const key = Symbol('fn');
// 4. 将当前函数(this)挂载到 ctx 上
ctx[key] = this;
// 5. 执行函数,此时 this 指向 ctx
const res = ctx[key](...args);
// 6. 删除临时挂载的属性,不污染 ctx
delete ctx[key];
// 7. 返回函数执行结果
return res;
};
核心原理 :通过将函数临时挂载到目标对象上执行,利用对象方法调用时 this 指向对象的特性,实现 this 绑定。
二、高频面试题(由浅入深)
题目 1:基础题 - 说说 call、apply、bind 的区别
参考答案:
三者都可以改变函数的 this 指向,区别在于:
- 执行方式 :
call和apply会立即执行函数;bind不会立即执行,返回一个永久绑定了this的新函数。 - 传参方式 :
call接收参数列表;apply接收参数数组;bind既可以在绑定时传参,也可以在调用时传参。 - 生效次数 :
call和apply只是临时改变this,一次生效;bind永久绑定this,多次调用都生效,且绑定后无法被再次修改。
题目 2:进阶题 - 手写实现 call 函数
参考答案:
js
Function.prototype.myCall = function(ctx, ...args) {
if (typeof this !== 'function') {
throw new TypeError('myCall must be called on a function');
}
ctx = ctx || window;
const key = Symbol('fn');
ctx[key] = this;
const result = ctx[key](...args);
delete ctx[key];
return result;
};
解析 :利用对象方法调用的 this 指向特性,临时挂载函数并执行,执行后删除临时属性,不污染目标对象。
题目 3:进阶题 - 手写实现 bind 函数
参考答案:
js
Function.prototype.myBind = function(ctx, ...args1) {
if (typeof this !== 'function') {
throw new TypeError('myBind must be called on a function');
}
const self = this;
// 返回新函数
return function(...args2) {
// 合并绑定时和调用时的参数,用 call 绑定 this
return self.call(ctx, ...args1, ...args2);
};
};
解析 :bind 的核心是返回一个新函数,在新函数内部通过 call 绑定 this,并合并两次传入的参数。
题目 4:场景题 - 什么情况下用 apply?什么情况下用 bind?
参考答案:
- 用
apply的场景 :参数是数组形式,比如Math.max.apply(null, [1, 2, 3, 4])求数组最大值;或者需要动态传递数组参数的场景。 - 用
bind的场景 :需要固定函数的this指向,且函数不是立即执行的场景,比如事件监听的回调函数、定时器的回调函数,避免this丢失。
三、延伸知识点(面试加分项)
1. call、apply 的应用场景
Math.max.apply(null, arr):快速求数组最大值。Array.prototype.push.apply(arr1, arr2):合并两个数组。Object.prototype.toString.call(obj):精准判断数据类型。
2. bind 的应用场景与坑点
- 场景:React 类组件中绑定事件处理函数,防止
this丢失。 - 坑点:
bind绑定后的函数,无法被call/apply再次修改this;多次调用bind只会绑定第一次的this。
3. 严格模式下的区别
- 严格模式下,
call/apply的第一个参数为null/undefined时,this不会指向window,而是保持null/undefined。
四、面试答题模板(直接背诵)
call、apply、bind都可以改变函数的this指向。
call和apply会立即执行函数,区别在于传参方式:call接收参数列表,apply接收参数数组;它们都是临时改变this,一次生效。
bind不会立即执行,返回一个永久绑定了this的新函数,支持分多次传参,绑定后的this无法被修改。
十、隐式转换
1. console.log([] == 0); // true
-
规则:
==两边类型不同时,会触发隐式转换 -
过程:
- 左边
[]调用ToPrimitive,再转数字:Number([])→0 - 右边
0本身就是数字 - 比较
0 == 0→true
- 左边
2. console.log(![] == 0); // true
-
规则:逻辑非
!优先级高于==,先算![] -
过程:
[]转布尔值:非空对象都是true→![]→false- 比较
false == 0:false转数字为0→0 == 0→true
3. console.log([] == ![]); // true
-
过程:
- 先算右边
![]→false - 左边
[]转数字为0,右边false转数字为0 0 == 0→true
- 先算右边
4. console.log([] == []); // false
- 规则:两个引用类型比较,
==直接比较内存地址 - 两个不同的数组实例,地址不同 → 直接返回
false
5. console.log({} == !{}); // false
-
过程:
- 右边
!{}→false(对象转布尔值为true,取反为false) - 左边
{}调用ToPrimitive,会转成字符串"[object Object]" - 字符串和数字比较,
"[object Object]"转数字为NaN NaN == 0→false
- 右边
6. console.log({} == {}); // false
- 和数组同理:两个对象是不同实例,内存地址不同 →
false
二、核心知识点总结(面试必背)
1. == 隐式转换总规则(按优先级)
- 有
!先算!:!优先级高于==,先把值转布尔再取反 - 引用类型 vs 原始类型 :引用类型先调用
ToPrimitive(先valueOf再toString),再转数字比较 - 布尔值 vs 其他 :布尔值先转数字(
true→1,false→0) - 两个引用类型 :直接比较内存地址,地址不同永远
false NaN特殊 :NaN和任何值(包括自己)比较都是false
2. 关键转换细节
-
空数组
[]:- 转布尔:
true - 转数字:
0 - 转字符串:
""
- 转布尔:
-
空对象
{}:- 转布尔:
true - 转数字:
NaN - 转字符串:
"[object Object]"
- 转布尔:
三、延伸高频面试题(同考点,直接背)
基础巩固题
js
console.log(0 == false); // true
console.log('' == false); // true
console.log([] == false); // true
console.log({} == false); // false
console.log(null == undefined); // true
console.log(null == 0); // false
console.log(undefined == 0); // false
console.log(NaN == NaN); // false
进阶陷阱题
js
// 1. 优先级陷阱
console.log([] == [] == 0);
// 先算 [] == [] → false,再算 false == 0 → true
// 2. 引用类型 vs 数字
console.log([10] == 10); // true:[10] 转字符串是 "10",再转数字 10
console.log([10, 20] == "10,20"); // true:数组转字符串为逗号拼接
// 3. 链式赋值陷阱
var a = 0;
console.log(a == 0 && a == 1 && a == 2); // false
// (延伸:如果要让这个表达式为 true,怎么改?可以用 Object.defineProperty 劫持 a 的 get 方法)
十一、JS基础类型和复杂类型(⭐⭐⭐)
- JS数据基础类型有:
String、Number、Boolean、Null、undefined五种基本数据类型,加上新增的两种ES6的类型Symbol、BigInt - JS有三种 复杂类型 (引用数据类型):
Object(对象)、Array(数组)、function(函数)
十二、defer和async的区别
| 特性 | defer | async |
|---|---|---|
| 加载方式 | 异步下载脚本,不阻塞 HTML 解析 | 异步下载脚本,不阻塞 HTML 解析 |
| 执行时机 | HTML 解析完成后,DOMContentLoaded 前 执行 | 脚本下载完成后立刻执行(可能打断 HTML 解析) |
| 执行顺序 | 按标签书写顺序依次执行 | 谁先下载完,谁先执行(顺序无序) |
| 影响 DOMContentLoaded | 会等待所有 defer 脚本执行完毕,再触发 | 不等待,脚本执行和 DOMContentLoaded 互不干涉 |