javascript高频知识点

一、预编译

全局预编译

  1. 寻找var声明的变量,将变量名作为 VO 对象的属性名,值为undefined
  2. 寻找函数声明,将函数名作为 VO 对象的属性名,值为函数本身
  3. 变量名与函数名冲突,函数声明会覆盖变量声明

函数预编译

  1. 寻找var声明的变量,将变量名作为 AO 对象的属性名,值为undefined
  2. 寻找形参,将形参名作为 AO 对象的属性名,值为undefined
  3. 将实参值赋值给形参
  4. 寻找函数声明,将函数名作为 AO 对象的属性名,值为函数本身
  5. 变量名与函数名冲突,函数声明会覆盖变量声明

!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事件循环

一、核心概念

  1. JS 是单线程语言

    JS 同一时间只能做一件事,为了避免长任务阻塞页面渲染 / 用户交互,引入了事件循环机制。

  2. 任务分类:同步任务 + 异步任务

    • 同步任务 :直接进入执行栈,从上到下依次执行。
    • 异步任务 :不会阻塞主线程,会被推入异步队列等待执行时机。
  3. 异步队列细分:宏任务(MacroTask) + 微任务(MicroTask)

    • 优先级:微任务队列 > 宏任务队列
    • 执行规则:执行栈清空后,先清空所有微任务,再执行下一个宏任务,如此循环。
  4. 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等),执行效率高。

  • 缺点

    1. 无法区分引用类型,typeof []typeof {} 都会返回 "object"
    2. typeof null 也会返回 "object",无法区分 null 和普通对象。
  • 适用场景:快速判断基本数据类型,不适合做引用类型的精准区分。

方案 2:instanceof 运算符
  • 原理 :通过检测构造函数的 prototype 是否出现在对象的原型链上,来判断对象类型。

  • 优点 :可以区分 ArrayObjectFunction 等引用类型,如 [] instanceof Arraytrue[] instanceof Object 也为 true(因为数组原型链上存在 Object.prototype)。

  • 缺点

    1. 无法判断 Number/Boolean/String 等基本数据类型(除非是包装对象,如 new Number(1) instanceof Numbertrue,但原始值 1 instanceof Numberfalse)。
    2. 存在跨 iframe / 跨窗口的兼容性问题,不同全局环境的构造函数不同,会导致判断失效。
  • 适用场景:引用类型的原型链检测,如判断是否为数组、自定义类实例。

方案 3:Object.prototype.toString.call()
  • 原理 :调用 Object 原型上的 toString 方法,返回 [object 类型名] 格式的字符串。

  • 优点 :可以精准判断所有数据类型,包括 nullundefined、数组、对象、日期、正则等。

    • 例: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 中常见的数据类型检测方案有三种:

  1. typeof :优点是快速区分基本数据类型,缺点是无法区分数组、普通对象和 null
  2. instanceof:优点是可以区分引用类型(如数组、对象、函数),缺点是无法判断基本数据类型,且存在跨环境兼容性问题。
  3. Object.prototype.toString.call() :优点是可以精准判断所有数据类型,包括数组、对象、null 等,缺点是写法繁琐,通常需要封装后使用。

区分数组和对象,推荐使用 Array.isArray()Object.prototype.toString.call() 方案,其中 Object.prototype.toString.call() 是通用且精准的方案,生产环境通常会封装成工具函数使用。

四、闭包

一、基础题(概念 + 手写)

题目 1:说说你对闭包的理解,闭包有什么作用?

参考答案:

闭包是指内部函数可以访问外部函数作用域中变量的现象,本质是函数与它的词法环境的绑定。

通俗来说:一个函数内部定义的函数,引用了外部函数的变量,就形成了闭包。

闭包的主要作用有:

  1. 封装私有变量 :可以实现数据私有化,对外暴露有限的操作接口,比如计数器案例中的count变量,外部无法直接修改,只能通过闭包函数操作。
  2. 延长变量生命周期:外部函数执行结束后,其内部变量不会被回收,因为闭包函数还在引用它。
  3. 模块化 / 单例模式:早期 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

解析:

  1. var声明的变量i是全局变量,循环结束后i=4
  2. 三个setTimeout的回调函数形成闭包,引用的是同一个全局i
  3. 回调执行时,循环早已结束,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:闭包的缺点是什么?如何避免?

参考答案:

  • 缺点 :闭包会导致外部函数的变量无法被垃圾回收机制回收,容易造成内存泄漏。如果大量使用闭包,会占用过多内存,影响性能。

  • 避免方式

    1. 不再使用闭包时,手动将引用的变量设为null,释放引用。
    2. 避免在循环中滥用闭包,防止多个闭包持有同一变量的引用。
    3. 合理使用let/const块级作用域,减少不必要的闭包场景。

三、延伸知识点(面试加分项)

1. 闭包与作用域链的关系

闭包的实现依赖于作用域链

  • 内部函数在创建时,会保存对外部函数作用域的引用。
  • 当内部函数执行时,会沿着作用域链向上查找变量,即使外部函数已经执行完毕,也能访问到外部作用域的变量。

2. 闭包的其他常见应用场景

  • 函数柯里化:利用闭包保存参数,分步执行函数。

    js 复制代码
    function 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. 闭包的判断标准

判断一个场景是否形成闭包,需要满足三个条件:

  1. 存在嵌套函数(内部函数)。
  2. 内部函数引用了外部函数的变量 / 参数。
  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 继承 saythis,因此输出 window
  • obj.say()say 作为对象方法调用,this 指向 obj;箭头函数 f1 继承 obj,因此输出 obj
  • obj.pro.getPro()getPro 是箭头函数,定义时外层作用域是全局,继承了全局的 thiswindow),因此无论怎么调用,都输出 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();

参考答案:

  1. fn():普通调用,非严格模式下 this 指向 window(浏览器环境),严格模式下为 undefined
  2. obj.fn():作为对象方法调用,this 指向调用者 obj
  3. fn.call({ name: 'test' })call 指定 this,指向传入的对象 { name: 'test' }
  4. new fn():构造函数调用,this 指向新创建的实例对象。

题目 2:进阶题 - 箭头函数的 this

复制代码
const obj = {
  a: 10,
  fn: () => {
    console.log(this.a);
  }
}
obj.fn(); // 输出什么?为什么?

参考答案:

输出 undefined

原因:fn 是箭头函数,定义时外层作用域是全局作用域,继承了全局的 thiswindow),而全局中没有 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

  • 解决方案(任选其一):

    1. that 保存外层 this

      复制代码
      say: function() {
        const that = this;
        setTimeout(function() {
          console.log(that.name);
        }, 1000);
      }
    2. 改为箭头函数,继承外层 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. 原型链的特性

  • 原型链决定了对象的属性访问顺序

    1. 访问属性时,先在对象自身查找;
    2. 找不到就顺着 __proto__ 向上找(即原型链);
    3. 原型链顶端是 Object.prototype.__proto__,值为 null
    4. 整条链都找不到,返回 undefined

3. 构造函数、原型对象、实例的三角关系

  • 构造函数 Fn

    • Fn.prototype → 指向原型对象
    • Fn.prototype.constructor → 指回构造函数 Fn
  • 实例 f = new Fn()

    • f.__proto__ === Fn.prototype
    • f.constructor === Fn(从原型链继承而来)
  • 函数本身的原型链:

    • Fn.__proto__ === Function.prototype
    • Function.__proto__ === Function.prototype
    • Object.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__);

参考答案:

  1. true:实例的 __proto__ 指向构造函数的 prototype
  2. true:构造函数的 prototype 对象的 constructor 指回构造函数本身。
  3. true:实例本身没有 constructor 属性,会从原型链上继承 Fn.prototype.constructor,即 Fn
  4. trueFn 是函数,所有函数的 __proto__ 都指向 Function.prototype
  5. nullObject.prototype 是原型链的顶端,其 __proto__null

题目 3:坑点题 - 函数的 prototype__proto__

问题: 函数的 prototype 和它的 __proto__ 是否指向同一个原型?为什么?

参考答案:

不指向同一个原型。

  • Fn.prototypeFn 构造出来的实例的原型对象,默认是一个普通对象,用于给实例共享属性。
  • Fn.__proto__Fn 作为函数对象,其构造函数 Functionprototype,即 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. 子类实例无法向父类构造函数传参;
  2. 所有子类实例共享父类原型上的引用类型属性,修改一个会影响所有;
  3. 无法实现多继承。

三、延伸知识点(面试加分项)

1. new 关键字的执行过程

  1. 创建一个空对象 obj
  2. obj.__proto__ 指向构造函数的 prototype
  3. 绑定 thisobj,执行构造函数;
  4. 如果构造函数返回非空对象,则返回该对象,否则返回 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 可使用 indexOffilter 遍历判断,但需要处理 NaN 等特殊值。

对于类数组(如 DOM 节点集合),需要先转为数组再去重,常用的转数组方式有扩展运算符、Array.fromArray.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)):快速实现,但有局限性(无法处理函数、undefinedSymbol、循环引用)
  • 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)) 实现深拷贝有什么局限性?

参考答案:

  • 无法处理 undefinedSymbol、函数类型,会直接丢失这些属性;
  • 无法处理循环引用的对象,会直接报错;
  • 无法处理 Date 类型,会被转为字符串;
  • 无法处理 RegExp 类型,会被转为空对象;
  • 无法处理 Set/Map 等特殊对象类型。

题目 4:场景题 - 什么情况下会用到深拷贝?

参考答案:

  • 需要完全复制一个对象,且后续修改复制对象时,不影响原对象;
  • 处理复杂嵌套对象 / 数组,比如后端返回的嵌套 JSON 数据;
  • 开发中修改状态数据(如 Vue/React 的 state),避免直接修改原数据导致的副作用;
  • 实现对象的缓存、快照功能,需要保存对象的完整副本。

三、延伸知识点(面试加分项)

1. 循环引用的处理

当对象存在循环引用(如 obj.a = obj)时,普通递归深拷贝会栈溢出,需要用 MapWeakMap 记录已经拷贝过的对象,避免重复拷贝。

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 改变。
  • callapply 只是一次性修改 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 指向,区别在于:

  • 执行方式callapply 会立即执行函数;bind 不会立即执行,返回一个永久绑定了 this 的新函数。
  • 传参方式call 接收参数列表;apply 接收参数数组;bind 既可以在绑定时传参,也可以在调用时传参。
  • 生效次数callapply 只是临时改变 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. callapply 的应用场景

  • 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

四、面试答题模板(直接背诵)

callapplybind 都可以改变函数的 this 指向。

callapply 会立即执行函数,区别在于传参方式:call 接收参数列表,apply 接收参数数组;它们都是临时改变 this,一次生效。

bind 不会立即执行,返回一个永久绑定了 this 的新函数,支持分多次传参,绑定后的 this 无法被修改。

十、隐式转换

1. console.log([] == 0); // true

  • 规则:== 两边类型不同时,会触发隐式转换

  • 过程:

    1. 左边 [] 调用 ToPrimitive,再转数字:Number([])0
    2. 右边 0 本身就是数字
    3. 比较 0 == 0true

2. console.log(![] == 0); // true

  • 规则:逻辑非 ! 优先级高于 ==,先算 ![]

  • 过程:

    1. [] 转布尔值:非空对象都是 true![]false
    2. 比较 false == 0false 转数字为 00 == 0true

3. console.log([] == ![]); // true

  • 过程:

    1. 先算右边 ![]false
    2. 左边 [] 转数字为 0,右边 false 转数字为 0
    3. 0 == 0true

4. console.log([] == []); // false

  • 规则:两个引用类型比较,== 直接比较内存地址
  • 两个不同的数组实例,地址不同 → 直接返回 false

5. console.log({} == !{}); // false

  • 过程:

    1. 右边 !{}false(对象转布尔值为 true,取反为 false
    2. 左边 {} 调用 ToPrimitive,会转成字符串 "[object Object]"
    3. 字符串和数字比较,"[object Object]" 转数字为 NaN
    4. NaN == 0false

6. console.log({} == {}); // false

  • 和数组同理:两个对象是不同实例,内存地址不同 → false

二、核心知识点总结(面试必背)

1. == 隐式转换总规则(按优先级)

  1. ! 先算 !! 优先级高于 ==,先把值转布尔再取反
  2. 引用类型 vs 原始类型 :引用类型先调用 ToPrimitive(先 valueOftoString),再转数字比较
  3. 布尔值 vs 其他 :布尔值先转数字(true1false0
  4. 两个引用类型 :直接比较内存地址,地址不同永远 false
  5. 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 互不干涉
相关推荐
道友可好1 小时前
Git Worktree:一个仓库,多个分身
前端·后端·程序员
Wonderful U1 小时前
基于Python+Django的在线题库与智能阅卷系统:从痛点分析到完整实现
开发语言·python·django
码语智行1 小时前
拦截器、接口限流、过滤器、防重发/幂等性功能说明
开发语言·网络·python
liulilittle1 小时前
麻将牌堆渲染(Lua)
开发语言·lua
道友可好1 小时前
AI 写代码太快了,快到你对齐不了它
前端·人工智能
雨落在了我的手上1 小时前
初始java(十七):常⽤⼯具类介绍
java·开发语言
无风听海1 小时前
Bearer Token 权威指南:从原理到生产级安全实践
前端·javascript·安全
jerrywus1 小时前
别只换模型!Claude Opus 4.8 努力控制 + Fast模式,真实能省钱3倍
前端·agent·claude
凤凰院凶涛QAQ1 小时前
《Java版数据结构 & 集合类剖析》集合框架的封装设计与顺序表:“从 Iterable 到 ArrayList:集合框架的‘职业树“
java·开发语言·数据结构