1、JavaScript基础重温:变量提升、作用域链、闭包、this指向、执行上下文
说实话,很多同学学 JavaScript 学到后面,发现越写越迷糊。其实根子往往不在新特性上,而是最基础的这几个概念没吃透。今天我就带大家把这几个老朋友重新认识一遍。
1.1 变量提升(Hoisting)
先问一个问题:下面这段代码,输出什么?
console.log(a);
var a = 10;
答案是 undefined,不是报错。为什么会这样?
因为 JavaScript 引擎在执行代码之前,会先做一遍「预解析」。它会找到所有的 var 声明和 function 声明,然后把它们「提升」到当前作用域的顶部。注意,只是声明被提升了,赋值并没有。
所以上面的代码,实际执行顺序是这样的:
var a; // 声明被提升
console.log(a); // undefined
a = 10; // 赋值留在原地
关键点:let 和 const 也会被提升,但存在「暂时性死区」。在声明之前访问它们,会报 ReferenceError。
我在项目中遇到过一个问题:一个同事用 var 在循环里声明变量,结果所有异步回调都拿到了同一个值。说白了就是变量提升 + 函数作用域搞的鬼。后来改用 let 就解决了。
1.2 作用域与作用域链
作用域,说白了就是变量能生效的范围。JavaScript 有三种作用域:
- 全局作用域:任何地方都能访问
- 函数作用域:只在函数内部能访问
- 块级作用域:
let和const带来的,只在{}内有效
作用域链是什么?当你访问一个变量时,JavaScript 会先从当前作用域找,找不到就往上一层找,直到全局作用域。这条查找路径,就是作用域链。
var x = 10;
function outer() {
var y = 20;
function inner() {
var z = 30;
console.log(x + y + z); // 60
}
inner();
}
outer();
你看,inner 函数里没有 x 和 y,但它沿着作用域链往上找到了。嗯,这里要注意:作用域链是在函数定义时确定的,不是在调用时。这叫「词法作用域」。
个人习惯:我写代码时,尽量把变量声明在最小的作用域里。这样既清晰,又不容易污染外部。
1.3 闭包(Closure)
闭包这个概念,很多新手觉得玄乎。其实说白了就是:一个函数能记住它定义时的作用域,即使它在外层作用域之外执行。
看个经典例子:
function createCounter() {
let count = 0;
return function() {
count++;
return count;
};
}
const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3
createCounter 执行完了,按理说 count 应该被销毁。但返回的函数还「记住」了它,所以 count 一直活着。这就是闭包。
我曾经在做一个数据上报的 SDK 时,就用闭包来保存用户的配置信息。每次调用上报函数,都能访问到初始化时传入的参数,非常方便。
避坑指南:闭包虽然好用,但容易造成内存泄漏。如果你创建了大量闭包,并且它们引用了大型对象,这些对象可能无法被垃圾回收。我曾经在一个长列表渲染中没注意,结果页面越来越卡……
1.4 this 指向
this 是 JavaScript 里最容易让人头疼的东西之一。我刚开始学的时候也绕了很久。其实记住一条核心规则就行:this 指向调用函数的对象。
具体来说,有这几种情况:
| 调用方式 | this 指向 | 示例 |
|---|---|---|
| 普通函数调用 | window(严格模式下为 undefined) | foo() |
| 对象方法调用 | 该对象 | obj.foo() |
| 构造函数调用 | 新创建的实例 | new Foo() |
| call/apply/bind | 手动指定的对象 | foo.call(obj) |
| 箭头函数 | 定义时的外层 this | () => {} |
箭头函数是个特例。它没有自己的 this,而是继承定义时外层作用域的 this。我建议在回调函数、定时器里多用箭头函数,能省去很多 var self = this 的麻烦。
const obj = {
name: 'Alice',
greet: function() {
setTimeout(() => {
console.log(`Hello, ${this.name}`); // 箭头函数,this 指向 obj
}, 100);
}
};
obj.greet(); // Hello, Alice
1.5 执行上下文(Execution Context)
执行上下文,可以理解成 JavaScript 代码运行时的「环境」。每次调用一个函数,都会创建一个新的执行上下文。
每个执行上下文包含三部分:
- 变量环境:存放
var声明的变量、函数声明 - 词法环境:存放
let、const声明的变量 - this 绑定:确定当前上下文的
this值
这些上下文会按照调用顺序,被压入「执行上下文栈」。栈顶是当前正在执行的上下文,执行完就弹出。
function a() {
console.log('a');
b();
}
function b() {
console.log('b');
c();
}
function c() {
console.log('c');
}
a();
执行过程是这样的:先压入全局上下文,然后调用 a 压入 a 的上下文,a 里调用 b 压入 b 的上下文,b 里调用 c 压入 c 的上下文。c 执行完弹出,b 执行完弹出,a 执行完弹出,最后回到全局上下文。
总结一下:变量提升、作用域链、闭包、this 指向、执行上下文,这五个概念其实是环环相扣的。理解了执行上下文,就能理解变量提升为什么存在;理解了作用域链,就能理解闭包是怎么「记住」外部变量的;理解了 this 绑定规则,就能避免很多莫名其妙的 bug。
我个人建议,如果你有时间,可以把这些概念手写一遍,或者用调试工具一步步看执行栈的变化。相信我,搞懂这些,后面学原型链、Promise、模块化都会轻松很多。
好了,这一章就到这里。下一章我们聊聊「原型与原型链」,又是一个让无数人头疼的话题。到时候我会分享一些我在实际项目中的踩坑经验,敬请期待。