是 JS 引擎 在执行代码时创建的抽象环境。它可以被理解为代码运行时的"临时工作表"或"舞台",记录了当前代码能访问哪些变量、函数、参数 以及 this 指向谁。
一、执行上下文的类型
1、全局执行上下文
- 创建时机:脚本加载时立即创建
- 数量:整个程序只有一个
- 生命周期:直到页面关闭或结束时才销毁
- 特点:在浏览器中,全局对象时 window,this 指向 window;在 Node.js 中,全局对象时 global 。
2、函数执行上下文
- 创建时机:每次调用函数时创建
- 数量:无限制,调用几次就创建几个独立的上下文
- 生命周期:函数执行完毕后通常会被销毁(从调用栈弹出)
- 特点:每个函数调用独立的环境,互不干扰
3、Eval执行上下文
- 创建时机:仅在 eval() 函数内部执行代码时创建
- 现状:由于安全和性能问题,现代开发中极少使用,一般了解即可
二、执行上下文的生命周期
每个执行上下文的生命周期分为三个阶段:创建阶段、执行阶段、回收/销毁阶段。
1、创建阶段:
代码时机运行前的准备阶段,JS 引擎在此阶段完成以下关键工作
-
确定 this 绑定:根据函数的调用方式(普通调用、方法调用、构造调用等)确定 this 指向
-
简历作用域链:由当前上下文的变量对象和外层上下文的变量对象组成,用于后续变量的层级查找。
-
初始化变量对象/环境记录
-
变量提升:扫描代码中的声明
-
var 声明的变量被初始化为 undefined
-
function 声明的函数被整体提升(函数名指向内存中的函数体)
-
let 和 count 声明的变量被记录,但处于**"暂时性死区"(TDZ)**,此时访问会报错,直到执行阶段复制后才可用
-
-
参数处理:将函数的形参和实参映射到环境中
-
2、执行阶段:代码逐行执行
- 变量赋值 :将实际值赋值给之前初始化的变量
- 如: var a = 10;此时 a 由 undefined 变为 10
- 代码执行:执行表达式、逻辑判断、循环等
- 函数调用:如果遇到函数调用,会暂停当前上下文,创建新的函数执行上下文兵压入调用栈顶部
3、回收/销毁阶段
-
出栈 :函数执行完毕(遇到 return 或 自然结束),当前执行上下文从调用栈中弹出。
-
内存回收 :如果没有其他引用(如闭包)指向上下文中变量,垃圾回收机制会标记并回收这部分内存
-
控制权移交 :控制权返回到上一层执行上下文。全局执行上下文则在程序结束时销毁。
4、执行上下文的核心组件
ES6 规范后,执行上下文内部结构更加细化
1、词法环境
- 负责存储 let、count 声明的变量以及块级作用域信息
- 包含环境记录 (实际存储数据的地方)和外部引用(指向外层作用域,形成作用域链)
2、变量环境
- 主要负责存储 var 声明的变量 和函数声明
- 在全局上下文中,变量环境和词法环境通常合并,在函数上下文中,它们共同构成了当前的作用域
3、This 绑定
- 既可以当前上下文中 this 关键字指向的对象
- 规则简述 :
- 全局上下文 :指向全局对象(window / global)
- 普通函数调用 :非严格模式下指向全局对象 ,严格模式下为 undefined
- 方法调用:指向调用该方法的对象
- 构造函数调用:指向新常见的实例对象
- 显示绑定 (call / apply / bind):指向指定的对象
- 箭头函数 :自身没有 this ,继承外层执行上下文的 this。
5、执行上下文栈
JS 是单线程语言,引擎使用 调用栈 来管理执行上下文
- 后进先出(LIFO):最后创建的上下文最先被执行和销毁
- 栈底:永远是全局执行上下文
- 栈顶:当前正在执行的执行上下文
- 流程:当函数 A 调用函数 B时,B 的上下文被压入栈顶;B 执行完后弹出,控制权回到 A
6、衍生
1、变量提升
是 JS 引擎在执行代码前的一种预处理机制。简单来说,就是引擎在编译阶段会将变量和函数的声明移动到当前作用域的顶部,而赋值操作则保留在原位。
理解变量提升的关键在于区分"声明"与"初始化/赋值",以及不同声明方式(var、let、const、函数声明)的行为差异。
1、核心机制:声明提升,赋值提升
JS 代码的执行分为两个阶段:
- 编译阶段:引擎扫描代码,收集所有的变量和函数声明,并在内存中为其分配空间。
- 执行阶段:按代码顺序逐行执行赋值和操作逻辑。
因此,"提升"并不是物理上把代码行搬到了文件顶部,而是内存中的登记行为提前发生了。
2、不同声明方式的表现
1、var 声明的变量
- 行为:声明会被提升到作用域(全局或函数)顶部,并自动初始化为 undefined;
- 结果:在声明之前访问变量不会报错,但值为 undefined。
javascript
console.log(a); // 输出 undefined
var a = 10;
console.log(a) // 输出 10
// 等价于
// var a;
// console.log(a); // 输出 undefined
// a = 10
// console.log(a); // 输出 10
2、函数声明
- 行为:整个函数(包括函数名和函数体)都会提升。
- 结果:可以在声明之前正常调用函数。
javascript
sayHello() // 输出 Hello
function sayHello() {
console.log("Hello")
}
3、let 和 const 声明
- 行为:声明也会被提升,但不会初始化。它们在声明之前处于暂时性死区**(Temporal Dead Zone, TDZ)**
- 结果:在声明前访问会之前抛出 ReferenceError。这是 ES6 引入的更严格的安全机制。
javascript
console.log(b) // ReferenceError: Cannot access 'b' before initialization
let b = 20;
4、函数表达式(Function Expression)
- 行为:遵循变量的提升规则。如果使用
var定义函数表达式,只有变量名被提升(值为undefined),函数体未被提升。 - 结果:在赋值前调用会报错(因为此时变量值是
undefined,不是函数)。
javascript
myFunc(); // 报错: TypeError: myFunc is not a function
var myFunc = function() {
console.log("Hi");
};
// 等价于:
// var myFunc;
// myFunc(); // undefined() -> TypeError
// myFunc = function() {...};
3、常见陷阱与作用域遮蔽
变量提升最容易导致 Bug 的场景是**作用域遮蔽(Shadowing)**,特别是在函数内部重新声明了与外部同名的变量。
javascript
var num = 5;
function test() {
console.log(num); // 输出: undefined (而不是 5)
var num = 10;
}
test();
原因解析:
- 函数
test内部有var num声明。 - 该声明被提升到函数顶部,此时局部变量
num存在且值为undefined。 console.log(num)访问的是局部变量(因为局部作用域优先),所以输出undefined,而不是去外层查找全局变量5。
4、最佳实践
为了避免变量提升带来的困惑和潜在 Bug,建议遵循以下规范:
- **优先使用
let和const**:它们具有块级作用域且存在暂时性死区,能在编码阶段就捕获"未声明先使用"的错误,代码行为更符合直觉。 - 先声明,后使用:无论使用哪种声明方式,都应在变量使用前进行声明和初始化。
- 避免在块级结构中使用
var:var没有块级作用域(如if、for块内声明的var会泄露到外部函数或全局),容易导致意外覆盖。 - 函数声明置于顶部:虽然函数声明可以提升,但为了代码可读性,建议将函数定义放在调用之前,或者统一放置在作用域顶部。
2、暂时性死区
是 JS ES6 引入 let 和 const 后出现的一个重要概念。它指的是在块级作用域中,从作用域开始到变量声明语句执行之前的这段区域。在这段区域内,变量虽然已经存在(已被引擎登记),但处于"未初始化"状态,任何访问尝试都会抛出 ReferenceError。
1、核心本质:提升但不初始化
很多开发者误以为 let 和 const 没有变量提升,这是一个常见的误解。事实上,**let 和 const 同样存在变量提升** ,只是它们的行为与 var 不同:
- **
var** :声明被提升到作用域顶部,并立即初始化 为undefined。因此,在声明前访问不会报错,只会得到undefined。 - **
let/const** :声明被提升到作用域顶部,但不进行初始化 。引擎会在内存中为该变量保留位置,并标记为"未初始化"。只有当代码执行流运行到声明语句那一行时,变量才会被初始化(let可后续赋值,const必须同时赋值)。 - TDZ 就是"声明提升"与"初始化完成"之间的时间差。
2、直观对比
javascript
// 情况 1: var (无 TDZ)
console.log(a); // 输出: undefined
var a = 10;
// 情况 2: let (存在 TDZ)
console.log(b); // 报错: ReferenceError: Cannot access 'b' before initialization
let b = 20;
// 情况 3: 完全未声明
console.log(c); // 报错: ReferenceError: c is not defined
注意区别:情况 2 的报错信息是"初始化前无法访问",说明引擎知道 b 存在于当前作用域;而情况 3 的报错是"未定义",说明引擎在当前及上层作用域都找不到 c。这证明了 let 确实发生了提升(注册到了作用域中)。
3. 为什么需要 TDZ?
TDZ 的设计初衷是为了消除隐患 ,让代码行为更符合直觉,避免 var 带来的隐性 Bug:
- 防止意外使用未初始化的变量 :
var返回undefined可能导致逻辑错误难以排查(例如undefined + 1得到NaN而不是报错)。TDZ 强制要求在声明后使用,一旦提前使用立即报错,便于开发阶段发现错误。 - 确保作用域绑定的确定性 :JS 是词法作用域(静态作用域),引擎需要在编译阶段确定变量属于哪个作用域。如果
let不提升,那么在声明之前,引擎可能会错误地去外层作用域查找同名变量,导致不可预测的行为。TDZ 确保了在块级作用域内,只要声明了变量,整个块内都绑定该局部变量,屏蔽外层同名变量。
三、调用栈
也称 执行栈 ,是 JS引擎 用来追踪代码执行位置 和管理函数 调用顺序的核心数据结构。理解调用栈 对于掌握 JS 的单线程机制、异步编程原理 以及调试错误至关重要。
1、核心概念和工作原理
JS 是单线程语言 ,意味着它一次只能执行一个任务 。调用栈 遵循**LIFO(Last In,First Out,后进先出)**的原则,类似于厨房中叠放的盘子,最后放上去的盘子必须最先拿走。
- 栈帧 :每当一个函数被调用时,JS 引擎 会创建一个"栈帧 "并将其压入调用栈的顶部 。这个栈帧包含了该函数执行所需的所有信息,通常对应一个 执行上下文。
- 入栈 :当代码执行遇到函数调用时,该函数的执行上下文被创建并加入栈顶,当正在执行的函数暂停,控制权转移给新入栈的函数
- 出栈:当函数执行完毕时,其对应的栈帧从栈顶弹出,控制权返回给栈中下一个上下文(即调用它的那个函数)
- 栈底 :始终是全局执行上下文 ,它在脚本加载时 创建,直到页面关闭或进程结束才销毁。
2、栈帧(执行上下文)包含的内容
每个栈帧不仅仅是一个标记,他是一个复杂的对象,主要包含以下关键信息
- 变量环境 / 词法环境 :存储函数内部的局部变量、参数、let / const 声明等
- 作用域链 :指向外层作用域的引用,用于 变量查找 和 实现闭包。
- this 绑定 :确定当前执行时 this 指向的对象
- 返回地址:记录函数执行完毕后,代码应该回到哪一行继续执行。
3、调用栈与异步操作(事件循环)
由于 JS 是单线程,如果所有代码都同步执行,耗时操作(如网络请求、定时器 )会阻塞整个程序。调用栈 与 事件循环 协同工作来解决这个问题。
- 同步代码:直接压入调用栈执行
- 异步代码(如 setTimeout、fetch、Promise):
- 主线程 遇到异步 API,将其给 浏览器内核 或 Node.js 环境 处理(Web APIs)
- 异步操作完成后,其回调函数被放入任务队列(宏任务队列 或 微任务队列)
- 关键点 :只有当调用栈为空时,事件循环才会检查任务队列,并将队列中的回调函数压入调用栈执行。着就是为什么 setTimeout(fn, 0) 中的 fn 不会立即执行,而是需等当前同步代码全部执行完成(调用栈清空)后才会执行
4、常见问题
调用栈的大小是有限的 (通常在几千到几万帧 之间,取决于引擎和内存限制)。如果出现以下情况,会导致栈溢出错误(RangeError: Maximum call stack size exceeded):
-
无限递归:函数没有终止条件,不断调用自身
-
过审的嵌套调用:函数 A -> 函数 B -> 函数 C ......层级过深。
5、避免方法:
- 确保 递归函数 由正确的基准情况以停止递归
- 将 递归 改为 循环迭代
- 使用异步操作(setTimeout 或 Promise)将部分执行推迟到下一轮时间循环,从而清空当前栈。
6、如何查看调用栈
- 浏览器开发者工具 :在 Sources 面板设置断点 或 在 Console 面板查看错误信息 ,右侧通常会显示**"Call Stack"**面板,展示当前函数调用层级。
- 代码中获取 :使用 new Error().stack可获取当前执行位置的堆栈跟踪字符串,包含文件名、行号和列号,常用语调试日志。
javascript
function myFunction() {
console.log(new Error().stack);
}
myFunction();
// 输出类似:
// Error
// at myFunction (<anonymous>:2:15)
// at <anonymous>:4:1
理解调用栈 有助于理清代码的执行顺序 ,定位 bug(尤其是递归和异步问题) ,并优化性能,避免因深层调用导致的内存溢出。
四、作用域 & 作用域链
JS 的作用域(Scope)和作用域链(Scope Chain)是理解变量访问规则、闭包机制以及内存管理的核心基础。
1、JS 的作用域(Scope)
作用域决定了变量和函数的可访问范围(可见性)及其生命周期。简单来说,它规定了代码在何处可以读取或修改某个标识符。JS 采用 词法作用域(也称 静态作用域)。着意味着作用域在函数定义时就已经确定,而不是在调用时确定。
1、主要作用域类型
1、全局作用域
- 定义:脚本最外层,不在任何函数或块内声明的变量
- 关键字 :var,let,const在全局声明
- 特点:
- 在浏览器中绑定到 window 对象
- 生命周期伴随页面存在
- 易造成命名冲突,应尽量减少使用
2、函数作用域
- 定义:由函数定义创建的独立空间
- 关键字 :function,var
- 特点:
- var声明的变量仅在函数内部有效
- 支持变量提升
- 函数外部无法访问内部变量
3、块级作用域
- 定义 :ES6 新增,由**{}**包裹的代码块形成。
- 关键字:let,const
- 特点:
- 常见于 if,for,while,try-catch等语句块
- var不具备块级作用域(会泄漏到外层函数或全局)
- 解决了循环中闭包的经典问题
2、事例代码
javascript
// 全局作用域
var globalVar = "this is global scoped"
function testFn() {
// 函数作用域
var funcVar = "this is fn scoped"
if (true) {
// 块级作用域
let blockLet = "this is block scoped Let";
const blockConst = "this is block scoped Const"
// 函数作用域,var 没有块级作用域
var funcVar1 = "this is fn scoped too"
}
console.log(funcVar1) // this is fn scoped too
}
testFn()
2、作用域链(Scope Chain)
作用域链是 JS 引擎用于查找变量的机制。当代码需要访问一个变量时,引擎会从当前作用域开始查找,如果未找到,则沿着作用域链向上一级作用域查找,直到全局作用域。如果最终仍未找到,则抛出 ReferenceError。
1、构建原理
1、\[Scopes] 属性
每个函数在定义时都会创建一个内部属性\[Scopes](不可直接访问),它存储了该函数可以放嗯的所有作用域对象的引用集合。
2、链表结构
作用域链本质上是一个指向变量对象(Variable Object)或词法环境的链表。
3、查找顺序
- 当前执行上下文的活动对象(AO)/ 词法环境
- 外层函数的活动对象(逐层向外)
- 全局上下文的全局对象
2、查找流程
javascript
var a = 1; // 全局作用域
function outer () {
var b = 2; // outer 函数作用域
function inner () {
var c = 3; // inner 函数作用域
console.log(c) // 在当前作用域 找到 3
console.log(b) // 当前作用域未找到,在outer作用域找到 2
console.log(a) // 当前作用域未找到,在outer作用域未找到,在全局作用域找到 1
console.log(d) // 全局也未找到,抛出 ReferenceError
}
inner()
}
outer()
注意 :作用域链是单向的。内部可以访问外部函数的变量,但外部函数不能防卫内部函数的变量。
3、性能优化建议
- 减少查找层级 :变量在作用域链中的位置越深,查询速度越慢。尽量将频繁使用的变量缓存为局部变量
- 避免全局污染 :或多全局变量会延长作用域链的末端查找时间,且易引发冲突
- 谨慎使用闭包 :虽然闭包强大,但会导致内存占用增加。不再需要时应手动解除引用(如设为 null),以便垃圾回收
五、闭包(Closure)
JS 闭包是 JS最核心、也是最具特色的概念之一。它是日常开发中实现数据封装、状态保持和函数式编程技巧的基础。
1、什么是闭包?
从本质上讲,闭包 是 函数及其周围状态(词法环境)的引用组合。
通俗来说,当一个内部函数被保存到外部作用域(例如作为返回值返回,或赋值给全局变量)时,该内部函数依然能够访问其定义时所在的外部函数的变量。即使外部函数已经执行完毕兵出栈,这些变量也不会被垃圾回收机制(GC)销毁,因为内部函数仍然持有对它们的引用。
闭包形成的三个必要条件:
- 函数嵌套:一个函数内部定义了另一个函数
- 引用外部变量 :内部函数访问了外部函数的变量(参数 或 局部变量)
- 外部引用 :内部函数被"传递"到了外部作用域(如通过 return 返回 或 作为回调函数传递)
2、底层原理:为什么变量不消失?
理解闭包需要结合 作用域链 和 垃圾回收机制:
- 词法作用域
JS 采用 静态作用域 ,函数的作用域在定义时就确定了。内部函数在定义时会创建一个指向外部函数变量环境的引用(在 V8引擎 中通常体现为**\[scope]** 或 词法环境链)
- 垃圾回收(GC)
通常函数执行完成后,其执行上下文会被销毁,局部变量随之释放。但是,如果内部函数被外部引用 ,GC 会检测到外部函数的变量环境仍然被 可达,因此不会回收这部分内存
- 结果
内部函数"捕获"了外部变量,形成了一个封闭的环境,即闭包。
3、代码示例:
1、基础示例:访问外部变量
javascript
function outer () {
let message = "hello Closure" // 外部函数定义的 局部变量
function inner () {
console.log(message) // 内部函数访问外部变量
}
return inner() // 将内部函数返回
}
const myFunc = outer(); // outer 执行完毕,但 message 未被回收
myFunc(); // 输出 "hello Closure"
2、进阶事例:独立的闭包实例
每次调用外部函数都会创建新的词法环境,因此生成的闭包互不干涉
javascript
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 counter2 拥有独立的 count
4、核心应用场景
闭包在实际开发中有许多重要用途
1、模拟私有变量(数据封装)
JS 在 ES6 Class 之前没有原生的私有属性支持。利用闭包,可以创建外界无法直接访问修改,只能通过特定方法操作的"私有"变量,保护数据安全。
javascript
function createWallet (initialBalance) {
let balance = initialBalance; // 私有变量,外部无法直接访问
return {
getBalance: () => balance,
deposit: (amount) => {
if (amount > 0) {
balance += amount
}
},
withdraw: (amount) => {
if (amount <= balance) {
balance -= amount;
}
}
}
}
const wallet = createWallet(100);
wallet.deposit(50);
console.log(wallet.getBalance()); // 150
// console.log(wallet.balance); // undefined,无法直接访问
2、函数防抖(Debounce)与 节流(Throttle)
在处理高频事件(如窗口缩放、滚动、输入搜索 )时,闭包 用于保存定时器ID或上次执行时间,从而控制函数的执行频率,提升性能。
1、防抖:
- 核心原理:在事件被触发 n 秒后再执行回调,如果 n 秒内又被触发,则重新计时。
- 适用场景:搜索框输入联想、窗口 resize、表单验证。
javascript
/**
* 防抖函数
* @param {Function} func - 需要防抖的目标函数
* @param {Number} wait - 等待时间,单位毫秒
* @param {Boolean} immediate - 是否立即执行。true: 首次触发立即执行,后续触发重置计时;false: 只有最后一次触发后等待wait时间才执行
* @returns {Function} 包装后的防抖函数
*/
function debounce(func, wait, immediate = false) {
let timeout = null;
let result = null; // 用于保存 func 的执行结果
const debounced = function (...args) {
// 保存 this 上下文,确保 func 内部 this 指向正确(如 DOM 元素)
const context = this
// 如果存在定时器,清除它(重置计时)
if (timeout) clearTimeout(timeout)
// 立即执行模式
if (immediate) {
const callNow = !timeout; // 如果 timeout 为 null,说明是首次触发或冷却期
timeout = setTimeout(() => {
timeout = null; // 冷却期结束后,允许下次再次立即执行
}, wait)
if (callNow) {
result = func.apply(context, args);
}
} else {
// 延迟执行模式(默认)
timeout = setTimeout(() => {
result = func.apply(context, args);
}, wait)
}
}
// 添加取消方法,用于组件写在等场景清理定时器,防止内存泄漏
debounced.cancel = function () {
if (timeout) {
clearTimeout(timeout)
timeout = null
}
}
return debounced
}
使用示例
javascript
// 搜索框输入联想:用户停止输入500ms后发送请求
const handleSearch = debounce((keyword) => {
console.log(keyword)
}, 500)
// 取消防抖
handleSearch.cancel()
2、节流
- 核心原理:规定在一个单位时间内,只能触发一次函数。如果这个单位时间内触发多次函数,只有一次生效。
- 适用场景:页面滚动加载(scroll)、拖拽、高频点击提交按钮。
javascript
/**
* 节流函数
* @param {Function} func - 需要节流的目标函数
* @param {Number} delay - 时间间隔,单位毫秒
* @returns {Function} 包装后的节流函数
*/
function throttle(func, delay) {
let timer = null;
let previous = 0; // 记录上一次执行时间戳
const throttled = function (...args) {
const context = this;
const now = Date.now();
// 计算剩余时间
const remaining = delay - (now - previous);
// 如果剩余时间 <= 0,说明距离上次执行已经超过delay,可以立即执行
if (remaining < 0) {
if (timer) {
clearTimeout(timer)
timer = null;
}
previous = now;
func.apply(context, args)
}
// 如果还在冷却器,且没有设置定时器,则设置定时器在剩余时间后执行
else if (!timer) {
timer = setTimeout(() => {
previous = Date.now(); // 更新上次执行时间为当前实际执行时间
timer = null;
func.apply(context, args)
}, remaining)
}
}
// 取消节流方法
throttled.cancel = function () {
if (timer) {
clearTimeout(timer)
timer = null;
}
previous = 0
}
return throttled;
}
使用示例
javascript
// 滚动事件:每 200ms 最多执行一次,保证流畅度
const handleScroll = throttle(() => {
console.log("滚动位置:", window.scrollY)
}, 200)
window.addEventListener('scroll', handleScroll)
// 取消节流
handleScroll.cancel()
3、注意事项
- this 指向问题
在实现中使用了 func.apply(this, args) 。这是因为当函数作为事件监听器绑定到 DOM元素 时,this 通常指向 DOM 元素 。如果不使用 apply 或 call 绑定上下文,this 可能会指向 window 或 undefined(严格模式下),导致错误。
- 参数透传
使用 ES6 的剩余参数 ...args 接收所有传入参数,并通过 apply 完整传递给原函数,确保事件对象(Event Object) 或 其他参数不丢失。
- 内存泄漏与清理
在单页应用(SPA) 如 VUE 或 React 中,组件卸载 时必须调用 .cancel() 方法清除定时器。否则,定时器可能在组件销毁后继续尝试访问已销毁的 DOM 状态,导致报错货内存泄漏。
- 选择建议
- 防抖:关注"最后一次"操作。适用于搜索输入、表单提交防重复点击。
- 节流:关注"固定频率"执行。适用于滚动加载、Canvas 动画帧控制、窗口大小调整。
3、函数柯里化(Currying)
柯里化 是 JS 函数式编程中的一种重要技术。核心思想:将一个接受多个参数的函数,转换为一系列只接受单个参数(或部分参数)的函数链。
简单来说,就是把f(a, b, c) --> f(a)(b)(c) ,每次调用只传入部分参数,返回一个新函数来接收剩余参数,直到所有参数收集完毕后,才执行原是函数并返回结果。
1、核心原理与实现
- 闭包:用于保存已经传入的参数
- 递归:判断参数是否收集完毕,若未完毕则返回新函数继续收集
2、基础版实现
javascript
/**
* 通用柯里化函数
* @param {Function} fn - 需要柯里化的原函数
* @returns {Function} - 柯里化后的函数
*/
function curry(fn) {
// 获取原函数期望的参数个数
const arity = fn.length;
return function curried(...args) {
// 如果当前收集的参数数量 >= 原函数期望参数数量
if (args.length >= arity) {
// 执行原函数,并绑定 this 上下文
return fn.apply(this, args)
} else {
// 参数不足,返回一个新函数,继续收集剩余参数
return function (...nextArgs) {
// 递归调用 curried,合并已收集参数和新参数
return curried.apply(this, args.concat(nextArgs))
}
}
}
}
使用示例
javascript
// 定义一个普通多参数函数
function add(a, b, c) {
return a + b + c;
}
// 转换为柯里化函数
const curriedAdd = curry(add);
// 多种调用方式均有效
console.log(curriedAdd(1)(2)(3)); // 6
console.log(curriedAdd(1, 2)(3)); // 6
console.log(curriedAdd(1)(2, 3)); // 6
console.log(curriedAdd(1, 2, 3)); // 6
3、核心价值
- 参数复用:通过预置部分参数,可以生成更专用的函数,避免复杂代码
javascript
// 假设有一个日志函数
function log(level, timestamp, message) {
console.log(`[${level}] ${timestamp}: ${message}`)
}
const curriedLog = curry(log);
// 创建一个专门记录错误日志的函数,固定了 level 和 timestamp
const errorLog = curriedLog('ERROR')(new Date().toISOString());
// 后续只需传入消息即可
errorLog('文件读取失败')
errorLog('数据库连接超时')
- 延迟执行:在参数未收集齐全前不会执行。这允许我们将"配置"和"执行"分离。这在异步编程或事件处理中非常有用。
- 函数组合的基础:在函数式编程中,柯里化使得函数更容易组合。因为每个函数只接受一个参数,输出作为下一个函数的输入,形成流水线。
javascript
const compose = (f, g) => x => f(g(x));
const double = x => x * 2;
const addFive = x => x + 5;
// 柯里化让组合更自然
const doubleThenAddFive = compose(addFive, double);
console.log(doubleThenAddFive(10)); // 25
4、注意事项和局限性
- fn.length 的限制
- 原因 :基础实现依赖 fn.length 来判断参数个数。如果原函数使用了默认参数、解构赋值 或Rest参数(...args) ,fn.length 可能不准确(通常为 0 或不包含可选参数),导致柯里化失效。
- 解决方案:对于复杂函数,建议 手动指定期望的参数数量,或使用 TypeScript 等类型系统参数辅助
- 性能开销
柯里化 涉及多次函数调用和闭包创建,相比直接调用会有轻微的性能损耗 。在极高频率调用的场景(如动画帧、大量数据循环)中需权衡使用。
- 调试难度
柯里化后的函数调用栈较深,报错信息可能不如普通函数直观。
- 与偏函数的区别
- 柯里化 :严格将多参数函数拆分为单参数函数链
- 偏函数 :固定部分参数,生成一个参数更少的新函数 f(a,b) -> g(c),不强制要求单参数链
- 注意:上述实现的 curry 函数实际上兼具了偏函数的功能,因为它允许一次传入多个参数 curriedAdd(1, 2)(3)
5、总结
函数柯里化是提升 JS 代码复用性、灵活性 和 可维护性的强大工具。特别适合用于:
- 创建配置化的工具函数(如日志、请求封装)
- 函数式编程中的函数组合
- 事件处理中预设上下文参数
6、注意
掌握柯里化有助于写出更声明式、更优雅 的代码,但应避免在简单场景中过度使用,以免增加代码理解成本。
五、this 绑定规则
JS 中的 this 是函数执行时的上下文对象,其指向 不是在定义时确定的,而是在调用时确定的。理解 this 的核心在于判断函数的调用方式。
以下时 this 绑定的核心规则:
1、箭头函数
箭头函数没有自己的 this 。它在定义时,会捕获其外层作用域 (词法作用域)的 this 值。无论之后如何调用(包括使用 call/apply ),箭头函数的 this 都不会改变。
应用场景:
- 解决回调函数中 this 丢失问题
- 在对象方法中,如果需要访问对象自身属性,应避免在方法本身使用箭头函数(因为此时外层可能是全局作用域),而应在内部的回调中使用箭头函数。
javascript
const user = {
name: "前端小D",
greet() {
// 普通函数 greet 的 this 指向 user
setTimeout(() => {
// 箭头函数继承外层作用域的 this,即 user
console.log(this.name) // 前端小D
}, 1000)
}
}
user.greet();
// 错误示范,对象方法直接用箭头函数
const badUser = {
name: "这不是前端小D",
greet: () => {
console.log(this.name) // undefined,因为外层是全局作用域
}
}
2、new 绑定(构造函数调用)
当使用 new 关键字调用函数时,this 指向新创建的实例对象。这是优先级最高 的绑定方式(除了被 bind 硬绑定后又被 new 调用的特殊情况)。
javascript
function Person(name) {
this.name = name
}
const p = new Person('前端小D');
console.log(p.name) // 前端小D
底层逻辑:
- 创建一个空对象
- 将该对象的**proto** 指向构造函数的 prototype
- 将构造函数中的 this 绑定到这个新对象
- 执行构造函数代码
- 返回该新对象(除非构造函数显示返回另一个对象)
3、显示绑定(call / apply / bind)
通过函数 call / apply / bind 方法,可以手动指定 this 的指向。
- call / apply :立即执行函数 ,并临时绑定 this 。区别仅在于参数传递形式(call 逐个传惨,apply 数组传参)。
- bind :返回一个新函数 ,该函数的 this 被 永久绑定到指定对象 。即使后续在对返回的函数使用 call / apply ,也无法改变其 this 指向(除非再次使用 new 调用)。
javascript
function greet() {
console.log(this.name)
}
const user = {name: "前端小D"}
greet.call(user) // 前端小D
greet.apply(user) // 前端小D
const newGreet = greet.bind(user);
newGreet() // 前端小D
const newUser = {name: "前端小DD"}
newGreet.call(newUser) // 前端小D
newGreet.apply(newUser) // 前端小D
const _newGreet = new newGreet()
_newGreet.call(newUser) // 前端小DD
_newGreet.apply(newUser) // 前端小DD
4、隐式绑定(对象方法调用)
当函数作为对象的属性被调用时(即通过 obj.fn() 形式),this 指向调用该函数的对象。
关键细节:
- 只看最后一层:
如果是多层嵌套对象 obj1.obj2.fn() ,this 指向最后调用函数的那个对象(obj2)
- 隐式丢失:
如果将对象的方法赋值给一个变量,然后独立调用该变量,this 会丢失绑定,退化为默认绑定
javascript
const obj = {
name: "前端小D",
sayHi() {
console.log(this.name)
}
}
obj.sayHi() // 前端小D this 指向 obj
// 隐式丢失示例
const fn = obj.sayHi;
fn() // undefined(非严格模式下指向window),因为 fn() 是独立调用
5、默认绑定(独立函数调用)
当函数没有通过任何对象调用,也没有使用 call / apply / bind / new 时,触发默认绑定。
- 非严格模式 :this 指向全局对象(浏览器 window 对象,Node.js 中是 global)
- 严格模式('use strict') :this 为 undefined
常见陷阱:
- 回调函数:
在 setTimeout**、setInterval** 或 事件监听器 中传入普通函数,这些函数通常是独立调用的,因此 this 会指向 全局对象 或 undefined。
- 内部函数:
在对象方法内部定义的普通函数 ,如果直接调用,也会丢失 this
javascript
const name = 'Global';
function showName() {
console.log(this.name);
}
showName(); // 'Global' (非严格模式)
// 严格模式下
'use strict';
function strictShow() {
console.log(this); // undefined
}
strictShow();
6、判断 this 指向的实战步骤
遇到不确定的 this 指向时,可以按照以下顺序判断
1、是箭头函数吗?
- 是:向上查找外层作用域 this。
- 否:继续下一步
2、是通过 new 调用的吗?
- 是:this 指向 新创建的实例。
- 否:继续下一步
3、是通过 call、apply、bind 调用的吗?
- 是:this 指向 指定的对象
- 否:继续下一步
4、是通过对象方法调用的吗?(obj.fn())?
- 是:this 指向 最后一层调用函数的对象(obj) 。注意是否发生了隐式丢失。
- 否:继续下一步
5、默认绑定
- 非严格模式:指向全局对象(window / global)
- 严格模式:undefined
7、常见易错场景总结
|-------------|----------------------------|------------------------------|-----------------------|
| 场景 | 代码示例 | this 指向 | 原因 |
| 全局调用 | fn() | window / global | undefined | 默认绑定 |
| 对象方法 | obj.fn() | obj | 隐式绑定 |
| 方法赋值后调用 | const f = obj.fn; fn() | window / global | undefined | 隐式丢失,退化为默认绑定 |
| 定时器回调 | setTimeout(obj.fn,100) | window / global | undefined | 回调函数独立执行,隐式丢失,退化为默认绑定 |
| DOM事件 | btn.onclick = fn | btn | 浏览器特殊处理,隐式绑定到元素 |
| 箭头函数 | () => {} | 外层作用域的 this | 词法继承,无自身 this |
| 构造函数 | new fn() | 新实例 | new 绑定 |
六、垃圾回收机制(标记清除、引用计数)
1、核心判断标准:可达性
JS 垃圾回收的核心判断依据是 可达性:从根对象处罚,通过引用链能访问到的对象为"可达",会被保留;无法到达的对象则被标记为垃圾 等待回收。
根对象通常包含三类:
- 全局对象
- 当前执行函数的局部变量和参数
- 调用栈上的其他函数变量、部分内置对象
2、引用计数算法
这是早起JS引擎曾使用的基础回收方案,核心逻辑简单直接。
- 核心原理
每个对象维护一个引用计数器,记录被引用的次数。当引用计数将为 0 时,对象立即被回收
- 实例代码
javascript
let a = {value:1} // 引用计数 1
let b = a; // 引用计数 2
a = null; // 引用计数 1
b = null; // 引用计数 0 -> 立即回收
- 致命缺陷
无法处理循环引用场景,两个对象互相引用时,引用计数永远不为0,导致内存泄漏
- 优缺点
实现简单、回收及时无明显暂停;但循环引用问题无法解决,计数器维护有额外开销。现代JS引擎已不将其作为主算法,仅在部分特殊场景保留。
3、标记清除算法
这是现代 JS引擎 的核心垃圾回收算法,完美解决了循环引用问题。
- 核心流程
- 标记阶段:从根对象处罚,深度便利所有可达对象,标记为"活跃"状态
- 清除阶段:遍历整个堆内存,回收所有未被标记的对象,将内存归还给空闲列表
- 循环引用处理示例
javascript
function foo() {
let a = {};
let b = {};
a.b = b;
b.a = a;
}
foo()
// 函数执行后,a、b无法从根对象到达,标记阶段不会标记它们,最终被清除
- 优缺点
能完美处理循环引用,无需维护额外计数器;但回收后会产生内存碎片,标记和清除阶段可能处罚全停顿,导致页面短暂卡顿。
4、现代引擎配套优化方案
为弥补基础算法的缺陷,主流JS引擎(如V8)引入了多种优化策略。
- 标记-整理算法:标记完成后,将所有存活对象移动到内存,直接清理边界外的全部内存,彻底消除内存碎片。
- 分代回收 :将对内存划分为新生代 (1~8MB,存放段生命周期对象 )和老生代 (存放长生命周期对象),分别使用 Scavenge 复制算法、标记清除 + 标记整理组合策略回收,大幅提升回收效率。
- 增量标记:将完整的标记过程拆解为多个小步骤,利用浏览器空闲时间分布执行,避免长时间阻塞主线程,降低页面卡顿概率。
5、常见内存泄漏避坑要点
即使有自动垃圾回收,不规范的代码仍会导致内存泄漏,需重点规避四类场景:
- 意外创建的未声明全局变量,脱离作用域后无法被回收
- 未及时清理的定时器、事件回调,闭包持续持有大对象引用
- DOM元素从页面移除后,JS代码仍保留对该元素的引用
- 闭包意外持有大量临时数据,导致数据长期无法释放
6、衍生问题
1、JS为什么需要垃圾回收?和C/C++的手动内存管理有什么区别?
JS中字符串、对象、数组等数据没有固定大小,运行时才动态分配内存。如果没有自动回收机制,长期运行会耗尽系统内存导致崩溃。和 C/C++ 手动 malloc/free 不同,JS引擎自动完成内存分配和释放,降低开发门槛,但也隐藏了内存泄漏的风险
2、JS垃圾回收的核心判断依据是什么?
核心是 可达性,从GC根对象出发,通过引用链能访问到的对象判定为存活,无法访问的对象则被标记为垃圾等待回收。
3、标记清除算法的完整流程是什么?它的优势和缺陷分别是什么?
分为两个阶段:标记阶段 和 清除阶段;
- 标记阶段:从根对象便利所有可达对象,给存活对象打上标记;
- 清除阶段:便利对内存,回收所有未被标记的对象
优势是完美解决循环引用问题,逻辑简单效率高;
缺陷是回收后会产生内存碎片,圈梁回收时可能触发主线程短暂停顿。
4、引用计数算法的核心逻辑是什么?为什么现代JS引擎不再将它作为主回收算法?
每个对象维护一个引用计数器,引用次数为0时立即回收。它的致命缺陷是无法处理循环引用场景,两个对象互相引用时引用计数永远不为0,会造成永久内存泄漏,因此被标记清除算法取代,仅在部分兼容场景保留。
5、请用代码示例说明循环引用场景下,标记清除和引用计数的回收差异
javascript
function demo() {
let obj1 = {};
let obj2 = {};
obj1.ref = obj2;
obj2.ref = obj1;
}
demo();
函数执行后,引用计数算法中obj1和obj2的引用计数都为1,永远不会被回收;标记清除算法中两个对象都无法从根对象访问到,会被正常标记回收。
6、V8引擎的分代回收策略是什么?为什么要做分代?
V8将堆内存划分为新生代(1~8MB,存放短生命周期临时对象)和老生代(存放长生命周期对象)。新生代使用Scavenge复制算法快速回收,老生代使用标记清除+标记整理组合策略。分代后无需每次都对整个堆做全量扫描,大幅降低垃圾回收的停顿时间,提升运行性能。
7、什么是全停顿(Stop-The-World)?V8是如何缓解这个问题的?
垃圾回收执行时,JS主线程会暂停执行业务代码,直到回收完成,长时间停顿会导致页面卡顿。V8通过增量标记、并发标记等优化手段,将完整的标记过程拆分为多个小步骤,利用浏览器空闲时间分步执行,大幅缩短单次停顿的时长。
8、大型前端项目中,最常见的几类内存泄漏场景是什么?如何规避?
- 意外创建的未声明全局变量,自动挂载到window后永远无法回收,需严格使用let/const声明变量
- 未及时清理的定时器、事件监听回调,组件销毁前必须调用clearInterval、removeEventListener释放引用
- 闭包滥用导致大对象意外被长期持有,避免闭包无意义地持有不需要的大型数据
- DOM元素从页面移除后,JS代码仍保留对该元素的引用,及时将DOM引用置空。
9、电商平台商品详情页频繁切换商品后内存持续上涨,排查发现是闭包导致的内存泄漏,你会怎么处理?
通过Chrome DevTools的Memory Profiler抓取内存快照,定位到事件监听闭包意外持有了2MB的完整商品数据对象。在组件卸载时手动解除事件监听,将闭包引用的大对象置空,避免其进入老生代长期存活,即可解决内存持续上涨的问题。
10、如何减少不必要的垃圾回收触发,优化页面运行性能?
尽量复用已创建的对象、数组,避免在循环中频繁创建临时对象;避免在热点代码路径中创建大量短生命周期对象;合理使用对象池模式,减少高频场景下的内存分配次数,降低GC触发频率。
七、原型 & 原型链 & 原型继承 & 类继承
在 JS 中,原型(Prototype)、原型链(ProtoType Chain)、原型继承 和 类继承 是构建面向对象编程 体系的四大核心概念。他们之间并非平行关系,而是从底层机制到上层实现的递进关系。
1、核心概念定义
1、原型(Prototype)
原型 是 JS 实现对象属性共享的机制
- 显示原型(prototype)
每个函数(构造函数)都有一个 prototype 属性,指向一个对象。这个对象包含了所有实例共享的属性和方法。
- 隐式原型(proto / \[Prototype])
每个对象(包括函数对象)内部都有一个隐藏属性 \[Prototype] (浏览器中通常暴露为 (proto ),它指向创建该对象的构造函数的 prototype。
- 核心作用
解决内存浪费问题 。通过将方法定义在原型上,所有实例可以共用同一份方法,而不是每个实例都复制一份。
2、原型链(Prototype Chain)
原型链是 JS 引擎查找属性的底层逻辑
1、工作原理
当访问一个对象的属性时,引擎首先在对象自身查找;如果找不到,就沿着 proto 向上查找其原型对象 ;如果还没找到,继续向上查找原型的原型......直到抵达 Object.prototype 。若仍未找到,则返回 undefined。
2、查找流程(逐级向上委托)
当你执行 obj.toString() 时,JS 引擎会按以下步骤操作:
- 第一步:查自身
- 检查 o
bj对象自身有没有toString属性? - 结果:没有(o
bj只有name)。
- 检查 o
- 第二步:查第一层原型
- 沿着 o
bj.__proto__找到Person.prototype。 - 检查
Person.prototype有没有toString? - 结果:没有(这里只有我们定义的
sayHi)。
- 沿着 o
- 第三步:查第二层原型
- 沿着
Person.prototype.__proto__找到Object.prototype。 - 注:因为
Person.prototype本身也是一个对象,它是被Object构造函数创建的,所以它的__proto__指向Object.prototype。 - 检查
Object.prototype有没有toString? - 结果:**找到了!** 执行该方法。
- 沿着
- 第四步:终点
- 如果
Object.prototype里也没有,引擎会继续找Object.prototype.__proto__。 - 结果是
null。 - 查找结束 ,返回
undefined。
- 如果
3、原型链结构图示
bash
alice (实例)
│
│ __proto__
▼
Person.prototype (原型对象)
│
│ __proto__
▼
Object.prototype (顶级原型对象)
│
│ __proto__
▼
null (链条终点)
结论 :原型链本质上就是由 __proto__ 串联起来的一系列对象。JavaScript 的继承,就是通过这条链实现的------子类实例可以顺着链子找到父类原型上的方法。
3、原型继承(Prototype Inheritance)
原型继承是基于原型链机制实现的传统继承方案(ES6 之前)。它通过手动操作构造函数和原型链对象,让子类实例能够访问父类的属性和方法。
1、本质
对象之间的委托关系。子类原型指向父类原型,从而形成链条。
2、常见模式
1、原型链继承
实现:Child.prototype = new Parent()。
缺点:引用类型属性共享,无法传参。
2、借用构造函数
实现:Parent.call(this, args)。
缺点:无法继承原型上的方法。
3、组合继承
实现:结合上述两者。
缺点:父类构造函数被执行了两次。
4、寄生组合继承
实现:Child.prototype = Object.create(Parent.prototype)。
这是 ES5 时代最完美的继承方案,避免了重复调用父构造函数。
4、类继承(Classical Inheritance / Class Syntax)
类继承是 ES6 引入的 语法糖,旨在提供更清晰、更接近传统面向对象语言的写法。
1、本质
底层依然完全依赖原型链实现。 class 关键字并没有改变 JS 基于原型的本质,知识封装了原型的操作细节。
2、特性
- 使用 extends 关键字声明继承关系
- 必须使用 super() 调用父类构造函数
- 强制开启严格模式,且类声明不会提升
2、原型继承 vs 类声明
虽然两者底层机制相同,但在开发体验、语法约束和使用场景上有显著差异
1、纬度对比
|------------|----------------------------------------|-----------------------------------|
| 纬度 | 原型继承(ES6 之前) | 类继承(ES6 Class) |
| 底层实现 | 手动操作 prototype、proto、call/apply | 编译器/引擎自动处理原型链接,底层仍是原型链 |
| 语法形态 | 命令式、过程化。需手动维护原型链指向和 constructor 修复 | 声明式、结构化。语义清晰,类似Java / C++ |
| 构造函数调用 | 可选。可以选择不调用父构造函数,或多次调用 | 强制。子类构造函数中必须先调用 super() 才能使用 this |
| 方法枚举性 | 原型上的方法默认是可枚举的(enumerable: true) | 类中定义的方法默认是不可枚举的,更符合封装原则 |
| 静态方法支持 | 需手动挂载到构造函数上(Parent.staticMethod = ...) | 原生支持 static 关键字,语法直观 |
| 灵活性 | 极高。可以在运行时动态修改原型链,实现 Mixin、多继承模拟等复杂逻辑 | 较低。结构固定,不支持直接的多继承,修改原型链收到更多限制 |
| 错误处理 | 容易因忘记修复 constructor 或 原型指向错误导致隐蔽 bug | 提供了更严格的检查(如未调用 super 会直接报错) |
| 适用场景 | 底层库开发、需要极致性能优化、动态混合行为、兼容老旧环境 | 现代应用开发、大型项目、团队协作、配合 TypeScript 使用 |
2、代码对比直观展示
1、原型继承(寄生组合式 - 最佳实践)
javascript
function Animal(name) {
this.name = name;
}
Animal.prototype.eat = function() {
console.log(`${this.name} is eating`)
}
function Dog(name, breed) {
// 1. 借用构造函数继承属性
Animal.call(this, name)
this.breed = breed;
}
// 2. 建立原型链继承方法(关键:避免 new Animal() 带来的副作用)
Dog.prototype = Object.create(Animal.prototype);
// 3. 修复 constructor 指向
Dog.prototype.constructor = Dog;
Dog.prototype.bark = function() {
console.log('Woof!')
}
2、类继承(ES6语法糖)
javascript
class Animal {
constructor(name) {
this.name = name;
}
eat() {
console.log(`${this.name} is eating`)
}
}
class Dog extends Animal {
constructor(name, breed) {
super(name); // 必须在使用 this 前调用
this.name = name;
this.breed = breed;
}
bark() {
console.log('Woof!');
}
}
3、总结
- 原型是仓库,原型链是查找路径
- 原型继承是手动搭建这条路径的传统工艺,灵活但繁琐,易出错
- 类继承是现代化刘姝贤,封装了搭建过程,规范且易读,但底层依然是那条原型链
八、typeof / instanceof 原理
在 JS 中,typeof 和 instanceof 是两种常用的类型检测机制,但它们的底层实现原理、应用场景以及局限性有着本质的区别。
1、typeof 的原理
typeof 是一个 一元运算符 而非函数,它主要用于检测变量的基本数据类型。
1、核心机制
typeof 的底层实现依赖于 JS 引擎在存储变量时保留的类型标签(Type Tag) 或 机器码低位信息。
- 直接读取内部标记
JS 引擎 在创建值时,会在其底层二进制表示中写入类型信息。typeof 操作符直接读取这些内部标记来判断类型,因此它的执行速度非常快。
- 不依赖原型链
由于它的读取的是值的内部元数据,因此无法通过修改原型链来欺骗 typeof 的判断结果。
- 特殊处理 null
在JS 早期版本中,null 被设计为表示"空对象引用 ",其二进制前三位为000 ,与对象类型的标记相同。为了保持向后兼容性,ECMAScript 规范规定typeof null 返回 "object",是一个历史遗留 Bug。
2、返回值规则
|----------------|-------------|----------------------------------------|
| 数据类型 | 返回值 | 示例 |
| Undefined | "undefined" | typeof undefined |
| Null | "object" | typeof null (历史遗留) |
| Boolean | "boolean" | typeof true |
| Number | "number" | typeof 23、typeof NaN |
| BigInt | "bigint" | typeof 10n |
| String | "string" | typeof "hello" |
| Symbol | "symbol" | typeof Symbol() |
| Function | "function" | typeof function() {} |
| Object(其他) | "object" | typeof {}、typeof \[\]、typeof newDate() |
3、局限性
1、无法区分具体对象类型
对于数组、正则表达式、日期对象 等,typeof 均返回 "object",无法进一步细分。
2、null 误判:
返回 "object " 而非 "null".
3、未声明变量安全访问
typeof 是唯一可以安全访问未声明变量而不抛出 ReferenceError 的操作符 ,它会返回 "undefined"
2、instanceof 原理
instanceof 时一个二元运算符 用于检测构造函数的 prototype 属性是否出现在某个实例对象的原型链上。它主要用于判断引用数据类型(对象)的具体归属
1、核心机制
instanceof 的判断逻辑是*基于 **原型链(Prototype Chain)*的遍历查找。其大致执行步骤如下:
1、获取目标原型
获取右侧构造函数的显示原型对象 ,即 Constructor.prototype 。
2、获取实例原型
获取左侧对象(Object)的隐式原型 ,即 Object.getPrototypeOf(obj)。
3、逐级向上查找
- 比较当前原型对象是否**严格等于(===)**的目标原型
- 如果相等,返回 true
- 如果不相等,则继续沿着原型链向上查找 (即获取当前原型的原型 Object.getPrototypeOf(proto)).
4、终止条件
如果在原型链尽头(即原型为 null) 仍未找到匹配项,返回 false。
2、简化实现代码
javascript
function myInstanceof(obj, constructor) {
// 1. 基本类型直接返回 false
if (obj == null || (typeof obj !== 'object' && typeof obj !== 'function')) {
return false;
}
// 2. 获取构造函数的 prototype
let proto = Object.getPrototypeOf(obj);
const targetProto = constructor.prototype;
// 3. 沿原型链查找
while (proto) {
if (proto === targetProto) {
return true;
}
proto = Object.getPrototypeOf(proto);
}
return false;
}
3、局限性与注意事项
1、仅适用于对象
原始类型 (如 string、number、boolean )不是对象,没有原型链,因此 instanceof 对它们始终返回 false (除非使用包装对象 ,如 new String('a') instanceof String === true ,但 'a' instanceof String === false)。
2、跨全局环境失效
在不同的 iframe 或窗口中,每个环境都有自己独立的构造函数 (如 Array) 。如果一个数组从 iframe A 传递到iframe B ,arr instanceof Array 在 B 中可能返回 false ,因为它们的 Array.prototype 引用不同。
3、原型被篡改
如果手动修改了改造函数的 prototype 属性,或者修改了对象的原型链,instanceof 的结果可能不符合预期。
3、总结对比
|-----------------|-----------------------------|--------------------------|
| 特性 | typeof | instanceof |
| 本质 | 一元运算符 | 二元运算符 |
| 返回值 | 字符串('number'、'object')等 | 布尔值 |
| 判断依据 | 变量底层的类型标签(Type Tag) | 原型链上的原型对象匹配 |
| 主要用途 | 检测基本数据类型 | 检测引用数据类型(对象的具体构造来源) |
| 对 null | 返回 "object"(Bug) | 返回 false |
| 对 Array | 返回 "object" | 返回 true(若由 Array 构建) |
| 对未声明变量 | 返回**"undefined"(不报错)** | 抛出 ReferenceError |
| 跨域 / iframe | 不受影响 | 可能失效 |
4、实践建议
- 判断基本类型优先使用 typeof 。
- 判断数组推荐使用 Array.isArray() (比 instanceOf 更可靠,能处理跨 frame 问题)
- 判断普通对象或特定类实例时使用 instanceof 。
- 若需最精确的类型判断(如区分**object Array** 和 object object ),可使用 Object.prototype.toString.call(value)。