【JS进阶】作用域与作用域链

本文详细介绍了JavaScript中的作用域,包括全局作用域、函数作用域和ES6新增的块级作用域。var、let和const声明变量的区别在于作用域、变量提升和暂时性死区。作用域链决定了变量查找的顺序,而执行上下文则涉及到代码的解释和执行阶段,其中this的指向是在运行时确定的。文章还提到了try/catch和with语句对作用域链的影响。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

作用域与作用域链

1.什么是作用域?

作用域指一个变量的作用的范围 。通常来说,一段程序代码中所用到的名字并不总是有效和可用的,而限定这个名字的可用性的代码范围就是这个名字的作用域。作用域的使用提高了程序逻辑的局部性,增强了程序的可靠性,减少了名字冲突。

JS中作用域有:

  1. 全局作用域

  2. 函数作用域

最开始并没有块作用域的概念。

ES6中新增了块级作用域,使用let声明的变量只能在块级作用域里访问,有“暂时性死区”的特性(也就是说声明前不可用)。

块作用域由 { } 包括,if语句和for语句里面的{ }也属于块作用域。

2.作用域与执行上下文的区别

有不少人会把作用域与执行上下文的概念混淆,所以这里先来说一下这两者的区别。

JavaScript属于解释型语言,执行分为解释和执行两个阶段:

解释阶段:

  • 词法分析
  • 语法分析
  • 作用域规则确定

执行阶段

  • 创建执行上下文
  • 执行函数代码
  • 垃圾回收

JavaScript 解释阶段便会确定作用域规则,因此作用域在函数定义时就已经确定了。

执行上下文最明显的就是this的指向是执行时确定的。而作用域访问的变量是编写代码的结构确定的。

总的来说执行上下文在运行时确定,随时可能改变,作用域在定义时就确定,并且不会改变

3.变量与声明变量的关键字

提到作用域,就不得不提到变量与 JS 中声明变量的三个关键字。

ECMAScript 变量是松散类型的,意思是变量可以用于保存任何类型的数据。每个变量只不过是一个用于保存任意值的命名占位符。有 3 个关键字可以声明变量:var、const 和 let。其中,var 在 ECMAScript 的所有版本中都可以使用,而 const 和 let 只能在 ECMAScript 6 及更晚的版本中使用。

4.var

4.1 var声明变量的作用域

看下面这段代码:

function test() {
    var message = "hi"; // 局部变量
}
test();
console.log(message); // 出错

使用var操作符定义的变量会成为包含它的函数的局部变量。比如,使用var在一个函数内部定义一个变量,就意味着该变量将在函数退出时被销毁。

不过,在函数内定义变量时省略var操作符,可以创建一个全局变量

function test() {
    message = "hi"; // 全局变量
}
test();
console.log(message); // "hi"

这里去掉了var后,变量就成为了全局的,可以在函数外部访问到。

但是在严格模式下,如果像这样给未声明的变量赋值,则会导致抛出 ReferenceError!

4.2 var声明提升

使用var时,下面的代码不会报错,但也不会输出26,而是输出undefined

function foo() {
 console.log(age);
 var age = 26;
}
foo(); // undefined

因为 ECMAScript 运行时把它看成等价于如下代码:

function foo() {
 var age;
 console.log(age);
 age = 26;
}
foo(); // 此时变量赋值在console的后面,所以输出为undefined

这就是所谓的“提升”(hoist),也就是把所有变量声明都拉到函数作用域的顶部

此外,反复多次 使用var声明同一个变量也没有问题:

function foo() {
 var age = 16;
 var age = 26;
 var age = 36;
 console.log(age);
}
foo(); // 36

5.let

letvar的作用差不多,但有着非常重要的区别。最明显的区别是,let声明的范围是块作用域, 而var声明的范围是函数作用域

这也是 JS 中的新概念。块级作用域由最近的一对**包含花括号{}**界定。

换句话说,if 块、while 块、function 块,甚至连单独的块也是let声明变量的作用域.

例如:

if (true) {
    var name = 'Matt';
    console.log(name); // Matt
}
console.log(name); // Matt,说明name变量的作用域为全局
if (true) {
    let age = 26;
    console.log(age); // 26
}
console.log(age); // 报错,说明let声明的变量age的作用域只在if块中!

同时,let也不允许同一个块作用域中出现冗余声明。这样会导致报错:

var name;
var name;	//var是允许重复声明的
let age;
let age;
//SyntaxError: Identifier 'age' has already been declared

对声明冗余报错不会因混用letvar而受影响。这两个关键字声明的并不是不同类型的变量, 它们只是指出变量在相关作用域如何存在

var name1;
let name1; // SyntaxError
let age1;
var age1; // SyntaxError

可以看到,用不同关键字声明同名变量仍然会报错!

5.1 暂时性死区

letvar的另一个重要的区别,就是**let声明的变量不会在作用域中被提升**。

// name 会被提升
console.log(name); // undefined
var name = 'Matt';
// age 不会被提升
console.log(age); //  Cannot access 'age' before initialization
let age = 26;

在解析代码时,JavaScript 引擎也会注意出现在块后面的let声明,只不过在此之前不能以任何方式来引用未声明的变量。

let声明之前,该变量都是不可用的。这在语法上被称为**“暂时性死区”(temporal dead zone)**,使用let声明的变量都是先声明再使用 ,不存在变量提升问题。

同样地,使用 const 声明的变量也存在暂时性死区。那么,为什么letconst有暂时性死区,而var没有?

因为var有预处理机制,也就是变量提升

5.2 let全局声明

var不同,使用let在全局作用域中声明的变量并不会成为 window 对象的属性

例如:

var name = 'Matt';
console.log(window.name); // 'Matt'
let age = 26;
console.log(window.age); // undefined

不过,let声明仍然是在全局作用域中发生的,相应变量会在页面的生命周期内存续。

即,let声明的变量仍为全局变量,但其不会作为 window 对象的一个属性!因此,为了 避免 SyntaxError,必须确保页面不会重复声明同一个变量

5.3 let与条件声明模式

在使用var声明变量时,由于声明会被提升,JavaScript 引擎会自动将多余的声明在作用域顶部合并为一个声明

而因为let的作用域是块,所以不可能检查前面是否已经使用let声明过同名变量,同 时也就不可能在没有声明的情况下声明它。

<script>
 var name = 'Nicholas';
 let age = 26;
</script>
<script>
 // 假设脚本不确定页面中是否已经声明了同名变量
 // 那它可以假设还没有声明过
 var name = 'Matt';
 // 这里没问题,因为可以被作为一个提升声明来处理
 // 不需要检查之前是否声明过同名变量
 let age = 36;
 // 但这里age在前面的块中声明过,这里会报错
</script>

使用try/catch语句或typeof操作符也不能解决,因为条件块中let声明的作用域仅限于该块。

<!-- 采用条件声明模式 -->
<script>
    let name = 'Nicholas';
    let age = 36;
</script>
<script>
    // 假设脚本不确定页面中是否已经声明了同名变量
    // 那它可以假设还没有声明过
    if (typeof name === 'undefined') {
        let name;
    }
    // name 被限制在 if {} 块的作用域内
    // 因此这个赋值形同全局赋值
    name = 'Matt';
    try {
        console.log(age); // 如果 age 没有声明过,则会报错
    }
    catch (error) {
        let age;
    }
    // age 被限制在 catch {}块的作用域内
    // 因此这个赋值形同全局赋值
    age = 26;
</script>

所以,对于let这个新的 ES6 声明关键字,不能依赖条件声明模式

5.4 for循环中的let声明

let出现之前,for循环定义的迭代变量会渗透到循环体外部:

for (var i = 0; i < 5; ++i) {
    // 循环逻辑
}
console.log(i); // 5

改成使用let之后,这个问题就消失了,因为迭代变量的作用域仅限于for循环块内部

for (let i = 0; i < 5; ++i) {
    // 循环逻辑
}
console.log(i);// i is not defined

6.const

const的行为与let基本相同,唯一一个重要的区别是用它声明变量时必须同时初始化变量,且尝试修改const声明的变量会导致运行时错误。

它与let的相似点在于以下两点:

// const 也不允许重复声明
const name1 = 'Matt';
const name1 = 'Nicholas'; 
// const 声明的作用域也是块
const name2 = 'Matt';
if (true) {
    const name2 = 'Nicholas';
}
console.log(name2);//Matt

但注意:const 声明的限制只适用于它指向的变量的引用

换句话说,如果 const 变量引用的是一个对象, 那么修改这个对象内部的属性并不违反 const 的限制!

例如:

const person = {};
person.name = 'Matt'; // ok

同时const也不能用于声明for循环中的迭代变量,因为迭代变量会自增。

6.作用域链

6.1 了解作用域链

想了解作用域链,首先需要了解执行上下文与执行栈的概念。

而至于作用域链,在《你不知道的JavaScript》中给出了描述:

当一个块或函数嵌套在另一个块或函数中时,就发生了作用域的嵌套。因此,在当前作用域中无法找到某个变量时,引擎就会在外层嵌套的作用域中继续查找,直到找到该变量,或抵达最外层的作用域(也就是全局作用域)为止。

来看下面这个例子:

var color = "blue";
function changeColor() {
    console.log(color)//blue
    if (color === "blue") {
        color = "red";
    } else {
        color = "blue";
    }
}
changeColor();

函数changeColor()的作用域链包含两个对象:一个是它自己的变量对象(就 是定义 arguments 对象的那个),另一个是全局上下文的变量对象。这个函数内部之所以能够访问变量color,就是因为可以在作用域链中找到它

使用letconst时也是一样的:

let color = "blue";
const color_b = 'red'
function changeColor() {
    console.log(color)//blue
    console.log(color_b)//red
}
changeColor();

那如果在函数的内部重新声明一个同名变量呢?

let color = "blue";
const color_b = 'red'
function changeColor() {
    let color = 'yellow'
    console.log(color)//yellow
    const color_b = 'black'
    console.log(color_b)//black
}
changeColor();
console.log(color)//blue
console.log(color_b)//red

所以,可以看出,搜索过程始终从作用域链的最前端开始,然后逐级往后,直到找到标识符

而这里,在执行后面两句log时,函数内部的变量已经被销毁了,因此此时只能拿到全局变量对象身上的两个属性!

同时注意,函数参数被认为是当前上下文中的变量,因此也跟上下文中的其他变量遵循相同的访问规则。

6.2 标识符查找

实际上,上面的几个例子都可以通过标识符查找来解释

当在特定上下文中为读取或写入而引用一个标识符时,必须通过搜索确定这个标识符表示什么。

搜索开始于作用域链前端,以给定的名称搜索对应的标识符。如果在局部上下文中找到该标识符,则搜索停止,变量确定;如果没有找到变量名,则继续沿作用域链搜索。(注意,作用域链中的对象也有一个 原型链,因此搜索可能涉及每个对象的原型链。)

这个过程一直持续到搜索至全局上下文的变量对象,如果仍然没有找到标识符,则说明其未声明。

下面来看一个例子:

var color = 'blue';
function getColor() {
 return color;
}
console.log(getColor()); // 'blue' 

在这个例子中,调用函数getColor()时会引用变量color。为确定color的值会进行两步搜索。

  • 第一步,搜索 getColor()的变量对象,查找名为color的标识符,没找到。
  • 第二步,继续搜索下一 个变量对象(来自全局上下文),因为全局变量对象上有 color的定义,然后就找到了名为color的标识符,进而搜索结束。

对这个搜索过程而言,引用局部变量会让搜索停止,而不继续搜索下一级变量对象。也就是说, 如果局部上下文中有一个同名的标识符,那就不能在该上下文中引用父上下文中的同名标识符

再看下面这个例子:

var color = 'blue';
function getColor() {
 let color = 'red';
 return color;
}
console.log(getColor()); // 'red'

上面这个例子,getColor()执行时,返回了color,它会优先在当前的上下文中查找,而此时getColor()的变量对象上有其定义,所以此时引用局部变量color即可,搜索结束。

而当我们进一步添加层级时:

var color = 'blue';
function getColor() {
 let color = 'red';
 {
     let color = 'green';
     return color;
 }
}
console.log(getColor()); // 'green' 

在这个修改后的例子中,getColor()内部声明了一个名为color的局部变量。

在调用这个函数时,首先在函数作用域中声明了值为 red 的color,但是后续,有添加了一个块作用域,并在里面声明了一个值为 green 的变量color。在执行到函数返回语句时,代码引用了变量color

于是开始在局部上下文中搜索这个标识符,结果找到了值为 green 的变量color。因为变量已找到,搜索随即停止,所以就使用这个局部变量。这意味着函数会返回 green 。

因此,在局部变量color声明之后,在这个块中的任何代码都无法访问全局变量color,除非使用完全限定的写法 window.color

这就是标识符查找。

注意,标识符查找并非没有代价。访问局部变量比访问全局变量要快,因为不用切换作用域。不过,JavaScript 引擎在优化标识符查找上做了很多工作,将来这个差异可能就微不足道了。

6.3 作用域增强

虽然执行上下文主要有全局上下文和函数上下文两种(eval()调用内部存在第三种上下文),但有其他方式来增强作用域链

某些语句会导致在作用域链前端临时添加一个上下文,这个上下文在代码执行后会被删除。通常在两种情况下会出现这个现象,即代码执行到下面任意一种情况时:

  • try/catch语句的 catch 块
  • with语句

这两种情况下,都会在作用域链前端添加一个变量对象。

with 语句来说,会向作用域链前端添加指定的对象

catch 语句而言,则会创建一个新的变量对象,这个变量对象会包含要抛出的错误对象的声明。

看下面这个例子:

function buildUrl() {
     let qs = "?debug=true";
     with(location){
		// let url = href + qs;
     	var url = href + qs;
     }
     return url;
} 

这里,with语句将location对象作为上下文,因此location会被添加到作用域链前端。buildUrl()函数中定义了一个变量qs

with语句中的代码引用变量href时,实际上引用的是location.href,也就是自己作用域的变量对象身上的属性。在引用qs时,引用的则是定义在buildUrl()中的那个变量,它定义在函数上下文的变量对象上。

另外,在with语句中使用var声明的变量url会成为函数上下文的一部分,可以作为函数的值被返回;但像使用let声明的变量url,因为被限制在块级作用域,所以在with块之外没有定义.

这里只是简单地介绍作用域链增强,更加具体的内容可以参加下面的文章。

7.深入了解

JS作用域

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值