在前端开发中,JavaScript的变量提升、作用域、Promise异步、this指向以及隐式类型转换,是高频面试考点,也是实际开发中容易踩坑的核心知识点。很多开发者在入门时会被这些概念绕晕,本文将用“通俗类比+专业拆解”的方式,结合具体代码案例,把每个难点讲透,帮你彻底理清逻辑、避开陷阱。
一、变量提升:var的“穿透性”与函数提升的特殊规则
变量提升(Hoisting)是JS引擎在代码执行前的“预编译”行为——简单说,就是JS会把变量和函数的声明“提前”到当前作用域的顶部,但赋值不会提升。这里重点区分var变量提升和函数提升的差异,尤其是if块内的函数提升,是高频易错点。
1.1 核心规则:var没有块级作用域,会“穿透”所有块
在ES6之前,JS中只有“函数作用域”和“全局作用域”,没有“块级作用域”——也就是说,if(){}、for(){}、while(){}、单独{}这些代码块,根本“关不住”var声明的变量。var声明的变量会穿透这些块,提升到最近的函数顶部(如果在函数内)或全局顶部(如果在函数外)。
举个通俗的例子:var就像一个“不受约束的调皮蛋”,不管你把它放在哪个小房间(代码块)里,它都会偷偷跑到房间的最外层(最近函数/全局),提前“占位置”。
1.2 易错点:if内的函数提升(关键案例解析)
函数提升分两种情况:普通函数声明(function fn(){})会提升,而立即执行函数((function(){})())本身不会提升。其中,if块内的函数提升最为特殊,浏览器会做特殊处理,我们结合具体案例看:
var a = 0;
console.log("第一次输出a: ", a); // 输出0
if (true) {
a = 1;
console.log("第二次输出a: ", a); // 输出1
function a() {} // 函数声明,会触发特殊提升
a = 2;
console.log("第三次输出a: ", a); // 输出2
}
console.log("第四次输出a: ", a); // 输出1
很多人会疑惑:第四次输出为什么是1?不是应该是2吗?这里浏览器的执行逻辑分3步,顺序至关重要(记牢这3步,避免踩坑):
-
先把【当前块内a的值】同步给外部a(此时块内a已经被赋值为1)→ 外部a从0变成1;
-
再把函数a赋值给块内a → 块内a从1变成函数a;
-
最后立刻断开块内a和外部a的关联 → 后续块内a赋值为2,不会影响外部a。
简单总结:if块内的函数声明,会先同步块内变量值到外部,再赋值函数,最后断开关联,这是浏览器的特殊处理逻辑,也是面试常考的“坑点”。
1.3 延伸案例:var声明的“全局污染”陷阱
(function(){
var x = y = 1; // 关键:y没有var声明
})();
var z;
console.log(y); // 输出1
console.log(z); // 输出undefined
console.log(x); // 报错:Uncaught ReferenceError: x is not defined
解析(通俗版):var x = y = 1; 等价于 y = 1; var x = y; —— y没有var声明,会自动变成全局变量(相当于window.y = 1),而x有var声明,是函数内的局部变量,函数执行完后销毁,所以外部访问不到x。
专业重点:没有var/let/const声明的变量,会自动挂载到全局对象(window)上,造成全局污染,这是开发中要坚决避免的写法。
1.4 再练一个:函数内var提升的“undefined陷阱”
var friendName = 'World';
(function() {
if (typeof friendName === 'undefined') {
var friendName = 'Jack';
console.log('Goodbye ' + friendName); // 输出Goodbye Jack
} else {
console.log('Hello ' + friendName);
}
})();
解析:函数内的var friendName会提升到函数顶部,相当于“var friendName;”(只声明,未赋值),所以typeof friendName === 'undefined'为true,进入if块赋值为Jack并输出。这里的关键是:函数内的var提升,会覆盖外部的全局变量(作用域优先级:局部>全局)。
二、作用域与作用域链:函数“出生地”决定能访问哪些变量
作用域的核心:函数能访问哪些变量,取决于它的“定义时”的作用域,而不是“调用时”的作用域。作用域链就是函数定义时形成的“变量查找链条”,从自身作用域开始,逐级向上查找,直到全局作用域。
通俗类比:作用域就像“你出生的家庭”,你能拿到家里的东西(变量),取决于你出生在哪个家(定义时的作用域),而不是你后来去了哪个家(调用时的作用域)。
2.1 基础案例:函数定义在内部,能访问外部变量
function a() {
var temp = 10;
function b() {
console.log(temp); // 输出10
}
b();
}
a();
解析:函数b定义在函数a内部,所以b的作用域链包含a的作用域和全局作用域,能访问到a内的temp变量。
2.2 反例:函数定义在全局,访问不到局部变量
function a() {
var temp = 10;
b(); // 调用全局的b
}
function b() {
console.log(temp); // 报错:temp is not defined
}
a();
解析:函数b定义在全局作用域,它的作用域链只有全局作用域,找不到a内的局部变量temp,所以报错。这就是“定义时决定作用域”的核心体现。
2.3 进阶:闭包与作用域的结合(高频面试题)
闭包的本质:函数嵌套函数,内部函数引用外部函数的变量,外部函数执行后,内部函数依然能访问到外部函数的变量(因为闭包保存了外部函数的作用域)。每次调用外部函数,都会生成一个新的闭包环境,互不影响。
function fun(n, o) {
console.log(o)
return {
fun: function(m){
return fun(m, n); // 内部函数引用外部函数的n,形成闭包
}
};
}
// 三个案例,重点区分闭包环境
var a = fun(0); a.fun(1); a.fun(2); a.fun(3); // 输出:undefined、0、0、0
var b = fun(0).fun(1).fun(2).fun(3); // 输出:undefined、0、1、2
var c = fun(0).fun(1); c.fun(2); c.fun(3); // 输出:undefined、0、1、1
解析(通俗版):
-
var a = fun(0):调用fun(0),o是undefined,返回一个对象,这个对象的fun方法引用了外部fun的n=0(闭包保存n=0),所以后续a.fun(1)、a.fun(2),都是调用fun(m, 0),o始终是0;
-
var b = fun(0).fun(1).fun(2).fun(3):每次调用fun都会生成新闭包——fun(0)返回的对象fun引用n=0,调用fun(1)时,fun(1,0)返回的对象fun引用n=1,以此类推,o依次是0、1、2;
-
var c = fun(0).fun(1):fun(0)返回的对象fun引用n=0,调用fun(1)得到fun(1,0),返回的对象fun引用n=1,后续c.fun(2)、c.fun(3),都是调用fun(m,1),o始终是1。
核心记住:闭包保存的是“定义时”的作用域,每次调用外部函数,都会生成新的闭包环境,变量值互不干扰。
三、Promise与异步执行:微任务、宏任务的执行顺序
Promise是JS处理异步操作的核心,很多开发者会被它的执行顺序、状态变化、链式调用绕晕。核心要点:同步代码先执行,再执行所有微任务,最后执行宏任务;每执行一个宏任务后,都会清空当前所有微任务。
先明确两个概念(通俗版):
-
微任务:“优先级高”的异步任务,比如Promise.then/catch/finally、async/await(本质也是Promise)、process.nextTick(Node环境);
-
宏任务:“优先级低”的异步任务,比如setTimeout、setInterval、DOM事件、AJAX请求。
3.1 基础案例1:Promise未resolve/reject,状态不变
const promise = new Promise((resolve, reject) => {
console.log(1); // 同步代码,先执行
console.log(2); // 同步代码,继续执行
});
promise.then(() => {
console.log(3); // 不会执行,因为Promise未resolve/reject
});
console.log(4); // 同步代码,执行
输出结果:1 2 4。解析:Promise构造函数内的代码是同步执行的,而then方法只有在Promise状态变为resolved(成功)或rejected(失败)时才会执行,这里没有调用resolve/reject,状态一直是pending,所以then回调不执行。
3.2 基础案例2:Promise状态变化与then回调执行
const promise1 = new Promise((resolve, reject) => {
console.log('promise1'); // 同步执行
resolve('resolve1'); // 改变状态为resolved
})
const promise2 = promise1.then(res => {
console.log(res); // 微任务,同步代码执行完后执行
})
console.log('1', promise1); // 同步执行,promise1状态为fulfilled
console.log('2', promise2); // 同步执行,promise2状态为pending
输出结果:promise1 → 1 fulfilled resolve1 → 2 pending → resolve1。解析:promise1调用resolve后,状态变为fulfilled,then回调进入微任务队列;同步代码执行完后,执行微任务,打印res的值。
3.3 进阶案例:微任务与宏任务的执行顺序(高频面试题)
Promise.resolve().then(() => {
console.log('promise1'); // 微任务1
const timer2 = setTimeout(() => {
console.log('timer2') // 宏任务2
}, 0)
});
const timer1 = setTimeout(() => {
console.log('timer1') // 宏任务1
Promise.resolve().then(() => {
console.log('promise2') // 微任务2
})
}, 0)
console.log('start'); // 同步代码
输出结果:start → promise1 → timer1 → promise2 → timer2。解析步骤(记牢这个顺序,搞定所有异步面试题):
-
执行所有同步代码:console.log('start');
-
执行所有微任务:Promise.resolve().then(),打印promise1,同时创建宏任务timer2;
-
执行宏任务队列中的第一个宏任务:timer1,打印timer1;
-
执行当前宏任务产生的所有微任务:Promise.resolve().then(),打印promise2;
-
执行下一个宏任务:timer2,打印timer2。
3.4 常见陷阱:Promise.then的值穿透、return错误不进catch
陷阱1:then传非函数,发生值穿透
Promise.resolve(1)
.then(2) // 传数字,非函数 → 值穿透
.then(Promise.resolve(3)) // 传Promise,非函数 → 值穿透
.then(console.log) // 传函数,打印1
解析:Promise.then()必须接收一个函数作为回调,如果传的是数字、Promise、对象等非函数,会发生“值穿透”——把前一个then的有效结果,直接传给下一个then,所以最终打印1。
陷阱2:return错误对象,不会进入catch
Promise.resolve().then(() => {
return new Error('error!!!') // return非Promise错误对象
}).then(res => {
console.log("then: ", res) // 打印then: Error: error!!!
}).catch(err => {
console.log("catch: ", err) // 不会执行
})
核心原理(一句话记牢):return任意非Promise值(包括错误对象),都会把这个值当作成功结果,传给下一个then;只有throw错误,或return Promise.reject(),才会进入catch。
3.5 补充:async/await的执行逻辑(Promise的语法糖)
async/await本质是Promise的语法糖,await会“让出线程”,把后面的代码包装成微任务,放到微任务队列,等待await后面的Promise完成后执行。注意:如果await后面的Promise永远不resolve,会永久阻塞。
async function async1() {
console.log("async1 start"); // 同步执行
await async2(); // 等待async2完成,后面的代码变成微任务
console.log("async1 end"); // 微任务,同步代码执行完后执行
}
async function async2() {
console.log("async2"); // 同步执行
}
async1();
console.log('start');
输出结果:async1 start → async2 → start → async1 end。解析:async1调用后,先执行同步代码async1 start,再执行async2(同步),await让出线程,执行外部同步代码start,最后执行微任务async1 end。
四、this指向:谁调用,指向谁(核心规则+易错案例)
this指向是JS中最灵活也最易错的知识点,核心规则只有一句话:谁调用函数,this就指向谁;没有调用者(独立函数调用),this指向全局对象(window,严格模式下是undefined)。但有几个特殊情况(箭头函数、new、apply/call/bind)需要重点记。
4.1 基础案例1:独立函数调用,this指向window
function foo() {
console.log( this.a );
}
function doFoo() {
foo(); // 独立函数调用,没有调用者
}
var obj = {
a: 1,
doFoo: doFoo
};
var a = 2; // 全局变量a,挂载到window上
obj.doFoo(); // 输出2
解析:obj.doFoo()调用doFoo,doFoo内部调用foo()——foo是独立调用,没有任何前缀(比如obj.foo()),所以this指向window,window.a是2,输出2。
4.2 特殊情况1:箭头函数没有自己的this,继承外层作用域
var a = 10;
var obj = {
a: 20,
say: () => {
console.log(this.a); // 输出10
}
}
obj.say();
var anotherObj = { a: 30 };
obj.say.apply(anotherObj); // 输出10
解析:箭头函数没有自己的this,this继承外层作用域(这里外层是全局作用域),所以this指向window,window.a是10;而且箭头函数的this一旦确定,无法通过apply、call、bind修改(即使传了参数,也无效)。
4.3 特殊情况2:new调用构造函数,this指向新对象
var obj = {
name : 'cuggz',
fun : function(){
console.log(this.name);
}
}
obj.fun(); // 输出cuggz(this指向obj)
new obj.fun(); // 输出undefined(this指向新创建的空对象)
解析:new关键字的作用是创建一个新对象,然后让构造函数的this指向这个新对象。obj.fun()是obj调用fun,this指向obj;new obj.fun()是new调用,this指向新创建的空对象,空对象没有name属性,所以输出undefined。
4.4 易错案例:立即执行函数(IIFE)的this指向
var myObject = {
foo: "bar",
func: function() {
var self = this; // 保存当前this(指向myObject)
console.log(this.foo); // 输出bar(this指向myObject)
console.log(self.foo); // 输出bar(self指向myObject)
(function() {
console.log(this.foo); // 输出undefined(this指向window)
console.log(self.foo); // 输出bar(self指向myObject)
}());
}
};
myObject.func();
解析:立即执行函数((function(){})())是独立函数调用,没有调用者,所以this指向window,window没有foo属性,输出undefined;而self是提前保存的myObject的this,所以能访问到foo。
4.5 进阶案例:arguments调用函数,this指向arguments
var length = 10;
function fn() {
console.log(this.length);
}
var obj = {
length: 5,
method: function(fn) {
fn(); // 独立调用,this指向window,输出10
arguments[0](); // arguments调用fn,this指向arguments,输出2
}
};
obj.method(fn, 1); // arguments长度是2(fn和1两个参数)
解析:arguments是函数的内置对象,保存了函数的参数。arguments0相当于“arguments调用fn”,所以this指向arguments,arguments的length是2(传入了fn和1两个参数),所以输出2。
五、隐式类型转换:JS的“自动转换”陷阱(高频面试)
JS是弱类型语言,当不同类型的值进行比较或运算时,会自动进行类型转换(隐式转换),这是很多bug的来源,也是面试常考的点。核心记住:对象转原始值的规则、字符串比较规则、==与===的差异。
5.1 核心规则:对象转原始值(ToPrimitive)
任何对象(包括数组、普通对象)在转换为原始值时,遵循“先valueOf()后toString()”的优先级:
-
调用obj.valueOf():默认返回对象自身(除非被重写,比如Date对象的valueOf()返回时间戳);
-
若valueOf()返回的还是对象,则调用obj.toString():
-
普通对象默认返回"[object Object]";
-
数组的toString()返回元素拼接的字符串(如[] → "",[1,2] → "1,2");
-
函数的toString()返回函数源码字符串。
-
5.2 高频面试题:6道经典转换案例(必掌握)
案例1:![] == 0 的结果(true)
解析步骤:
-
![]:逻辑非运算,先把[]转布尔值——任意对象/数组转布尔值都是true,所以![] → false;
-
false == 0:布尔值与数字比较,先把false转数字(Number(false) → 0);
-
0 == 0 → true。
案例2:{} + [] 的结果(0)
解析步骤:
-
JS引擎会把左边的{}当作“空代码块”(不是对象),直接忽略;
-
剩下+[]:+是一元运算符,会把[]转数字——先调用valueOf()返回[](对象),再调用toString()返回""(空字符串),Number("") → 0;
-
最终结果:0。
案例3:[] + {} 的结果("[object Object]")
解析步骤:
-
+两边都是引用类型(数组、对象),优先进行字符串拼接;
-
[]转字符串:toString() → "";
-
{}转字符串:toString() → "[object Object]";
-
"" + "[object Object]" → "[object Object]"。
案例4:"a" > "b"(false)、"10" > "2"(false)
字符串比较规则(核心):从第一个字符开始,依次比较对应位置的字符,基于Unicode编码(编码值小的字符更小),一旦比较出结果,就提前结束;如果短字符串是长字符串的前缀,则短字符串更小。
解析:
-
"a" > "b":"a"的Unicode编码是97,"b"是98,97<98 → false;
-
"10" > "2":先比较第一个字符,"1"的编码是49,"2"是50,49<50 → false(不会继续比较第二个字符)。
案例5:Object(123)、Object("abc") 的返回值类型
解析:Object()是包装对象构造函数,传入基本类型值,会返回对应的包装对象:
-
Object(123) → Number包装对象,typeof 结果是"object";
-
Object("abc") → String包装对象,typeof 结果是"object"。
注意:包装对象是对象类型,不是基本类型,所以typeof返回"object"。
案例6:let a = {}; a == true(false)、a === true(false)
解析(结合对象转原始值规则):
-
a == true:
-
对象a转原始值:valueOf()返回{}(对象),再调用toString()返回"[object Object]";
-
布尔值true转数字:Number(true) → 1;
-
比较变为"[object Object]" == 1:字符串转数字,"[object Object]" → NaN,NaN == 1 → false。
-
-
a === true:严格相等,要求类型和值都相同,a是object类型,true是boolean类型,类型不同 → false。
总结:核心要点速记
本文梳理了JS中最核心、最易错的5个知识点,每个知识点都结合了案例和通俗解析,方便大家理解和记忆,最后用几句话速记核心:
-
变量提升:var穿透块,函数声明提升,立即执行函数不提升;
-
作用域:函数能访问的变量,取决于定义时的作用域,闭包保存定义时的环境;
-
Promise:同步→微任务→宏任务,每执行一个宏任务清空微任务,then传非函数会值穿透;
-
this指向:谁调用指向谁,箭头函数继承外层this,new指向新对象;
-
隐式转换:对象转原始值先valueOf后toString,字符串比较按Unicode编码,==会自动转换类型,===不转换。