前言:于无声处听惊雷
当你写下自己的第一行 JavaScript 代码console.log('Hello world!')
并成功运行,为迸现的 “Hello world!” 感到欣喜时,可曾想过这样一行简单的代码执行前要经过多少 JS 设计师精心设计的预处理?善战者无赫赫之功。JavaScript 的精华所在,恰恰就是这些我们经常忽视的地方。现在,让我们重新注视这些被 JS 设计师藏起来的细节,领略 JS 的独特魅力。
本文将带你探索 JS 中的各种声明对应的提升机制(如变量提升、函数提升)及其是否存在 TDZ。
PS:本文内容有点长,本人第一次写小长文,有什么不足请您在评论区提出建议。
一. 前置知识准备(已有了解的朋友请移步“提升”)
var、let、const
var
、let
、const
是 JavaScript 的三种最常用的变量声明方式。
var
是 JavaScript 早期的变量声明方式,可以重复声明,具有函数作用域,存在变量提升的情况。let
和const
是 ES6 新增的声明方式,不可以重复声明,它们都具有块级作用域,并且不存在变量提升的现象。const
专门用于声明常量,并且必须初始化,而且一旦赋值就不能再重新赋值,但如果是引用类型的常量(如对象、数组),可以修改其内部的属性。
在实际开发中,由于 var
存在函数作用域和变量提升等问题,容易导致意外错误,已经很少用到var
了,主要都是使用const
(优先使用)或者let
。
函数声明
在 JavaScript 里,函数声明是创建可复用代码块的基础方式。
函数是 JavaScript 中的第一等公民,地位极高。别的对象能干的活它能干,别的对象不能干的活,它也能干,可以作为函数参数,可以作为函数返回值,也可以赋值给变量,简直是为所欲为。
语法格式如下:
function 函数名(参数1, 参数2, ...) {
// 函数体:实现特定功能的代码
return 返回值; // 可选,用于返回函数执行结果
}
class、import
class
与import
声明也是 ES6 新增的声明方式,同样不可重复声明。
-
class
声明用于声明 JavaScript 中的类,其用法为class Person { // 构造器 constructor(name, age) { this.name = name; this.age = age; } // 方法 sleep() { console.log(`${this.age}岁的${this.name}正在睡大觉!`); } } const tom = new Person('张三', 18); tom.sleep(); // 18岁的张三正在睡大觉!
class
声明还有一种类表达式的写法:const Person = class { // 构造器 constructor(name, age) { this.name = name; this.age = age; } // 方法 sleep() { console.log(`${this.age}岁的${this.name}正在睡大觉!`); } } const tom = new Person('张三', 18); tom.sleep(); // 18岁的张三正在睡大觉! console.log(typeof Person);
其实 JavaScript 中的类可以看作是一种特殊的函数,可以通过以下方式验证:
console.log(typeof Person); // function
-
import
声明用于从其他模块引入功能(如变量、函数、类等)。这是 ES6(ES2015)引入的模块系统的核心特性,让代码可以模块化并相互引用。其用法为
// 1. 从模块中导入特定的变量/函数/类
import { function1, variable2 } from './module.js';
// 2. 导入模块的默认导出(每个模块只能有一个默认导出)
import MyClass from './module.js';
// 3. 使用 `as` 关键字重命名导入的内容
import { originalName as newName } from './module.js';
// 4. 将模块的所有导出内容封装到一个对象中
import * as MyModule from './module.js';
// 使用:MyModule.function1()
// 5. 执行模块中的代码,但不导入任何内容
import './sideEffects.js';
注意:import
语句必须处于模块的顶层(也就是最外围的作用域中),不能出现在条件语句或者函数内部,这是因为引擎需要在代码执行前就确定模块间的依赖关系。
作用域
JavaScript 中与提升相关的作用域有全局作用域、函数作用域、块级作用域(ES6)。
-
全局作用域: 是最外围的作用域,浏览器环境中由
window
对象表示,Node.js 环境中由global
对象表示。- 没有被任何函数或者块包括的变量或者函数,就处于全局作用域中。
- 全局作用域中的变量,在代码的任何位置都能被访问(少用,容易引发命名冲突)。
-
函数作用域: 每个函数都会创建独立的函数作用域,函数作用域嵌套在其定义所在的作用域中(如全局作用域、其他函数作用域或块级作用域)。
- 在函数内部定义的变量、函数,只能在该函数内部被访问,函数外部无法访问。
- 函数作用域内的变量在整个函数体内都是可见的,存在变量提升现象。
-
块级作用域: ES6 引入了块级作用域(通过
{}
包裹的代码块创建独立作用域),与函数作用域平级,可以嵌套存在。let
和const
是具有块级作用域特性的变量声明方式,仅在声明所在的代码块{}
内可访问。- 块级作用域由
{}
包裹形成,可以独立存在,if
、for
、while
等语句的代码块(由{}
包裹的部分)也会创建块级作用域。
借用《你不知道的JavaScript》这本书中的例子来讲解一下作用域:
前 ES6 时代
ES6 之前的全局作用域中,一个个函数作用域嵌套其中,var
声明的变量也只能在所处的当前作用域进行提升。函数内部定义的变量以及函数被保护得严严实实。 为了方便理解,我将用小故事为你们讲解作用域。
function func(){
console.log(a); // undefined(具体为什么,后面的变量提升会讲解)
var a = 1;
console.log(a); // 1
}
func();
console.log(a); // 无法访问函数作用域中的变量,报错:ReferenceError: a is not defined
全局作用域就像一个小村庄,函数作用域就像其中的一栋栋居民楼,其中包括房间(嵌套的其他函数作用域)以及家具(声明的变量),夜晚常有强盗游弋,居民楼的大门落锁,外面的人无法得知其中的具体情况,也无法染指其中的财产。而居民可以安全地通过小窗观察外面,并接收外面传进来的物资(全局作用域中的变量以及函数)。
但是有一种特殊的材料(即为{})制成的小房子,它也可以安全地通过小窗观察外面,并接收外面传进来的物资(全局作用域中的变量以及函数)。但是它有一个致命缺陷,它的门是透明的,内部情况一览无余。住进去的居民var
也是马大哈,毫不在意隐私外泄(在{}中声明的var
变量会泄露到外部),有时候忘记关门,家中所有财产都被强盗拿完了。虽然有时候记得关了门,外面的歹人无法破门而入,但是也将var
记在了小本本上(undefined
),非常危险。
// 忘记关门的情况({}内的代码执行了)
if (true) {
var a = 1;
function func() {
console.log('函数已被调用!');
}
}
console.log(a); // 1
func(); // 函数已被调用!
// 关门的情况({}的代码未执行)
if (false) {
var a = 1;
function func() {
console.log('函数已被调用!');
}
}
console.log(a); // undefined
// 根据 ES5 规范,块内的函数声明会被提升到 最近的函数作用域或全局作用域,无论块是否执行。
func(); // 输出:TypeError: func is not a function(这不是 ES6 之前的行为,而是现在的浏览器为了向前兼容 ES6 规范做的调整。ES6 之前的真正原生行为应该是“函数已被调用!”,大家可以找到真正的 ES5 旧环境去尝试一下。)
ES6 时代
ES6 来临之后,小村庄接纳了新居民(let
、const
声明),新居民很有安全意识,即使住进了这些由{}建成的小房子,仍然很安全,let
和const
住进去的时候,会拉上自己房间的第二道门(支持块级作用域),保护自己的利益,而var
仍然我行我素(不支持块级作用域),即便和let
、const
住在一起,也难以保障自己的安全。
// 大门没关的情况({}内的代码执行了)
if (true) {
var a = 1;
let b = 2;
const c = 3;
function func() {
console.log('函数已被调用!');
}
}
console.log(a); // 1
func(); // 函数已被调用!
console.log(b); // ReferenceError: b is not defined
// 大门关上的情况({}内的代码未执行)
if (false) {
var a = 1;
let b = 2;
const c = 3;
function func() {
console.log('函数已被调用!');
}
}
console.log(a); // undefined
// func(); 函数的是否泄露取决于你的运行环境,我的运行环境为Node.js v22,结果为TypeError: func is not a function,即为不泄露,但是在旧环境中,仍有可能泄露
console.log(b); // ReferenceError: b is not defined
二. 提升(Hositing)
MDN 中提升的定义
在 JavaScript 中,提升是指解释器在执行代码之前,似乎将函数、变量、类或导入的声明移动到其作用域的顶部的过程。
以下任何行为都可以被视为提升:
- 能够在声明变量之前在其作用域中使用该变量的值。(“值提升”)
- 能够在声明变量之前在其作用域中引用该变量而不抛出
ReferenceError
,但值始终是undefined
。(“声明提升”) - 变量的声明导致在声明行之前的作用域中行为发生变化。
- 声明的副作用在评估包含该声明的其余代码之前产生。
是不是感觉很晦涩难懂?没事,下面我将一一为你讲解。
1. 变量提升(var)
var
声明的变量提升按类别属于上述行为的第 2 种行为。
- 编译阶段: 声明的变量名被添加到当前作用域的顶部,并初始化为
undefined
。 - 执行阶段: 赋值操作按代码顺序执行。
示例:
console.log(x); // undefined(变量已提升但未赋值)
var x = 10;
console.log(x); // 10
实际上的执行顺序(逻辑顺序):
var x; // 编译阶段:提升变量声明并初始化为 undefined
console.log(x); // undefined
x = 10; // 执行阶段:赋值操作
console.log(x); // 10
注意:因为var
不支持块级作用域,所以在某些情况下,var
声明也不算提升。
{
var x = 1;
}
console.log(x); // 1
这里没有“在声明前访问”,所以不算提升!
2. 函数提升
函数声明的提升表现为上述行为的第 1 种行为,函数提升时,不但会提升声明,还会把定义也一并提升。
- 编译阶段: 与变量提升不同,函数提升时,整个函数体会被提升到作用域顶部。
- 执行阶段: 按顺序执行代码,此时函数已存在于作用域中。
作为 JavaScript 的一等公民,函数在 JavaScript 总是有着一些特权的。
整个函数体被提升到作用域顶部,这意味着你可以在函数声明之前调用它。函数提升的优先级高于变量提升,因此函数声明会先于变量声明被提升,并且不会被同名变量的声明所覆盖(但是变量赋值时会被其覆盖)。
示例:
sayHello(); // 可以正常调用,输出:"Hello world!"
function sayHello() {
console.log("Hello world!");
}
不被同名变量声明所覆盖:
// 不被同名变量覆盖
console.log(func); // 输出:[Function func]
var func = "Hello world!";
console.log(func); // 输出:"Hello world!"
function func() {}
实际执行顺序:
// 1. 函数提升(优先)
function func() {}
// 2. 变量声明提升(但赋值留在原地)
var func; // 重复声明被忽略
// 3. 执行代码
console.log(func); // 此时声明为函数 func,输出:[Function: func]
func = "Hello world!"; // 这时变量执行赋值操作,覆盖了同名函数
console.log(func); // "Hello world!"
但并不是和函数搭上边,就能畅通无阻。以下几种情况并不能拥有最高优先级。
-
函数表达式不会被提升
函数表达式不是使用
function
声明来声明函数,其本质上是使用变量存储函数,遵守的是变量提升的规矩。示例:
sayHi(); // 报错:TypeError: sayHi is not a function(sayHi 不是函数) var sayHi = function () { console.log("Hi!"); };
实际执行顺序:
// 1. 变量声明被提升,但初始值为 undefined var sayHi; // 2. 执行代码 sayHi(); // 此时 sayHi 为 undefined,调用会报错 sayHi = function() { /* ... */ }; // 赋值操作留在原地
-
箭头函数不会被提升
箭头函数本质上也是函数表达式,因此同样不会被提升。
示例:
greet(); // 报错:TypeError: greet is not a function(greet 不是函数) var greet = () => console.log("Hello!");
3. import
import
声明应该是最特殊的提升了,按照 MDN 的分类属于第1、4种行为,别人都是单属一种,它独占两者。而且第 4 种提升行为描述的所谓”声明的副作用在评估包含该声明的其余代码之前产生“,使得import
的提升更为晦涩难懂。
但是它其实很简单,MDN 中指出导入声明是提升的。原文:“在这种情况下,这意味着导入的值在模块代码中声明之前就可用,并且导入模块的副作用在模块代码的其余部分开始运行之前就已经产生”。
剖析一下这句话,也就是说,import
声明导入的值优先级很高,JavaScript 引擎在执行模块代码之前,会先处理所有的 import
声明,把依赖模块加载进来并建立好绑定关系,直接可以用导入的值。而模块的副作用(比如顶层代码的执行)会在包含该模块的代码执行之前就产生。
示例:
// utils.js
export const PI = 3.14;
console.log('utils.js loaded'); // 将这行代码看作是声明的副作用
// main.js
console.log('Before import, PI =',PI);
import { PI } from './utils.js';
console.log('After import, PI =', PI);
/*
运行结果:
utils.js loaded
Before import 3.14
After import 3.14
*/
执行顺序:模块的副作用(utils.js loaded) -> 主脚本继续执行(Before import 3.14 -> After import 3.14)
这就是“模块的副作用(比如顶层代码的执行)会在包含该模块的代码执行之前就产生”。
从模块内的代码在执行时,导入的内容已经准备妥当可以看出,导入的内容在当前模块的整个作用域变得可用,即便 import
语句位于文件的末尾(但是推荐写在文件的顶部,这是规范,能省去很多不必要的麻烦),这与我们之前说过的函数提升极为相似。
三. TDZ(Temporal dead zone,暂时性死区)
TDZ 是提升的一种特殊情况,指从作用域开始到变量正式声明的这一段区域。在 TDZ 内访问变量会导致ReferenceError
,即便变量实际上已经被提升了。
let、const、class
典型代表就是let
、const
与class
声明,按照 MDN 的分类,它们属于第 3 种行为。
console.log(a);
let a = 1;
// ReferenceError: Cannot access 'a' before initialization
console.log(b);
const b = 2;
// ReferenceError: Cannot access 'b' before initialization
console.log(c);
class c {
constructor(name) {
this.name = name;
}
test() {
console.log('已调用!');
}
}
// ReferenceError: Cannot access 'c' before initialization
注意:类表达式的提升规则与class
声明一致,在定义之前无法使用。
也就是说,只要在变量正式声明之前访问该变量,就一定会报错。
TDZ 到底算不算提升
有人认为这三者都不算一种提升,因为 TDZ 严格禁止了在声明之前调用变量,将它们认定为提升似乎是全无意义的。但是 MDN 仍然认为它们是提升行为,其实是有一番考量的。
MDN 给出了证据:
const x = 1;
{
console.log(x); // ReferenceError
const x = 2;
}
正常情况下,块级作用域可以拿到其父作用域的变量或函数,那么按常理来说,这里打印的应该是 ”1“,但结果却是ReferenceError: Cannot access 'x' before initialization
。这恰恰说明了块级作用域内部的const
发挥了提升的作用,它的声明覆盖了父作用域中传进来的 x
的声明,但是它声明的常量x
在这段 TDZ 中处于一种未初始化的状态,从而引起了报错。
将目光投到let
和class
上,你会收获一样的答案:
// let 声明
let x = 1;
{
console.log(x); // ReferenceError: Cannot access 'x' before initialization
let x = 2;
}
// class 声明
class c {
constructor(name) {
this.name = name;
}
test() {
console.log('已调用全局作用域中的类方法!');
}
}
{
console.log(c); // ReferenceError: Cannot access 'c' before initialization
class c {
constructor(name, age) {
this.name = name;
this.age = age;
}
test() {
console.log('已调用块级作用域中的类方法!');
}
}
}
如果你把块级作用域中的声明注释掉,那么你就会得到无提升作用下的结果,可以动手试一试,实践出真知!
import 声明的提升是否不存在 TDZ
介绍了 TDZ 之后,我们可以很轻松地看出函数声明的函数提升和 var
声明的变量提升都是不存在 TDZ 的(可以在正式声明前访问)。那么对于作用极为类似于函数提升,但是看起来很复杂的import
声明,我们是否可以说“import
不存在 TDZ ”?
我觉得是可以的,因为根据 ECMAScript 规范,import
声明要经过两个阶段:
-
模块实例化阶段
引擎会先创建所有
import
的绑定,但此时这些绑定处于 “未初始化” 状态(类似let/const
的 TDZ)。 此时绑定不可访问,任何访问都会抛出错误。 -
模块执行阶段
在模块执行前,所有
import
的值会被初始化为对应模块的导出值,因此在模块代码开始执行时,导入的值已经可用。
与 TDZ 的关键区别:
虽然 import
在技术上经历了 “未初始化” 阶段,但这个阶段在模块执行前就已完成,并且模块执行之前,所有import
的值已经被初始化为了对应模块的导出值,导致在实际代码中无法观察到 TDZ 错误。也就是说这个阶段对开发者不可见,因此在实际代码中无需考虑,我们大可以直接认为import
的提升就不存在 TDZ。
四. 总结
现在我们可以对今天所学的知识做一个总结了,如下表所示:
声明方式 | 提升特性 | TDZ | 声明前访问结果 |
---|---|---|---|
var | 提升声明并初始化为 undefined | 不存在 | undefined |
let /const | 提升但未初始化 | 存在 | ReferenceError |
函数声明 | 整体提升(声明、定义和赋值) | 不存在 | 可正常访问 |
函数表达式/箭头函数 | 按照变量的规则(取决于使用let /const /var ) | 存在(let /const 时)/不存在(var 时) | ReferenceError (let /const );undefined (var 声明时以变量形式访问); TypeError (var 声明时以函数形式调用) |
class 类声明 | 提升但未初始化 | 存在 | ReferenceError |
import 声明 | 提升且在模块执行前完成初始化 | 不存在 | 可正常访问(模块执行时) |
被 JS 设计师藏起来的细节还有很多,让我们来慢慢发现这些细节,领略 JS 的魅力所在。如果这篇文章能让你爱上 JS,那么我的努力就没有白费。
第一次写这种小长文,我肯定有很多不足的地方,希望大家能在评论区提出,帮助我改进,万分感谢!