跳到主要内容

了解作用域与作用域链

什么是作用域

Scope(作用域)- MDN (mozilla.org)

作用域是当前的执行上下文,值和表达式在其中“可见”或可被访问。如果一个变量或表达式不在当前的作用域中,那么它是不可用的。

作用域也可以堆叠成层次结构,子作用域可以访问父作用域,反过来则不行。

JavaScript 的作用域分以下三种:

  • 全局作用域:脚本模式运行所有代码的默认作用域
  • 模块作用域:模块模式中运行代码的作用域
  • 函数作用域:由函数创建的作用域

此外,用 let或 const 声明的变量属于额外的作用域:

  • 块级作用域:用一对花括号{}(一个代码块)创建出来的作用域

由于函数会创建作用域,因而在函数中定义的变量无法从该函数外部访问,也无法从其他函数内部访问

模块作用域

每个js文件都是一个模块,每个模块中的区域即模块作用域,其他文件要使用模块中的变量/函数,必须对外导出模块中的变量/函数,并在目标文件中引入。

全局作用域

在代码中任何地方都能访问到的对象拥有全局作用域,一般来说以下几种情形拥有全局作用域

  1. 最外层函数 和在最外层函数外面定义的变量拥有全局作用域
var outVariable = "我是最外层变量"; //最外层变量
function outFun() { //最外层函数
var inVariable = "内层变量";
function innerFun() { //内层函数
console.log(inVariable);
}
innerFun();
}
console.log(outVariable); //我是最外层变量
outFun(); //内层变量
console.log(inVariable); // 报错:inVariable is not defined
innerFun(); // 报错:innerFun is not defined
  1. 所有末定义直接赋值的变量自动声明为拥有全局作用域
function outFun2() {
variable = "未定义直接赋值的变量";
var inVariable2 = "内层变量2";
}

console.log(variable); //未定义直接赋值的变量
console.log(inVariable2); // 报错:inVariable2 is not defined
  1. 所有window对象的属性拥有全局作用域
    • 一般情况下,window对象的内置属性都拥有全局作用域,例如window.name、window.location、window.top等等

全局作用域有很大的弊端,过多的全局作用域变量会污染全局命名空间,容易引起命名冲突

函数作用域

函数作用域,是指声明在函数内部的变量,和全局作用域相反,局部作用域一般只在固定的代码片段内可访问到,最常见的例如函数内部

function doSomething(){
var blogName="浪里行舟";
function innerSay(){
console.log(blogName);
}
innerSay();
}
doSomething()
console.log(blogName); // 报错:blogName is not defined
innerSay(); // 报错:innerSay is not defined

作用域是分层的,内层作用域可以访问外层作用域的变量,反之则不行

值得注意的是:块语句(大括号“{}”中间的语句),如 if 和 switch 条件语句或 for 和 while 循环语句,不像函数,它们不会创建一个新的作用域。在块语句中定义的变量将保留在它们已经存在的作用域中

if (true) {
// 'if' 条件语句块不会创建一个新的作用域
var name = 'Hammad'; // name 依然在全局作用域中
}
console.log(name); // 'Hammad'

对于这样的问题,在ES6中引入了块级作用域

块级作用域

使用ES6中新增的let和const指令可以声明块级作用域

块级作用域在如下情况被创建:

  • 在一个函数内部
  • 在一个代码块(由一对花括号{}包裹)内部

块级作用域有以下常见的几个特点:

  1. 声明变量不会提升到代码块顶部:let/const 声明并不会被提升到当前代码块的顶部,因此你需要手动将 let/const 声明放置到顶部,以便让变量在整个代码块内部可用。(不会变量提升?/在下面细说)
{
console.log(a) // Cannot access 'a' before initialization
let a = '111'
let b = '222'
console.log(b) // 222
}
  1. 禁止重复声明
{
var a = 1;
let a = 2;
}
// Identifier 'a' has already been declared
  1. 循环语句的应用
for(var i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i)
})
}

上述代码我们期望的是输出0,1,2,但是最终输出的却是三个3,这是因为setTimeout是异步代码,会在下次事件循环执行,而i++却是同步代码,而全部执行完,等到setTimeout执行时,i++已经执行完了,此时i已经是3了。以前为了解决这个问题,我们一般使用立即执行函数:

for(var i = 0; i < 3; i++) {
(function(i) {
setTimeout(() => {
console.log(i)
})
})(i)
}

现在有了let我们直接将var改成let就可以了:

for(let i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i)
})
}

经常看到有文章说: 用letconst申明的变量不会提升。其实这种说法是不准确的,比如下面代码:

var x = 1;
if(true) {
console.log(x);

let x = 2;
}

上述代码会报错Uncaught ReferenceError: Cannot access 'x' before initialization。如果let申明的x没有变量提升,那我们在他前面console应该拿到外层var定义的x才对。但是现在却报错了,说明执行器在if这个块里面其实是提前知道了下面有一个let申明的x的,所以说变量完全不提升是不准确的。只是提升后的行为跟var不一样,var是读到一个undefined而块级作用域的提升行为是会制造一个暂时性死区(temporal dead zone, TDZ)。暂时性死区的现象就是在块级顶部到变量正式申明这块区域去访问这个变量的话,直接报错,这个是ES6规范规定的。

其他作用域概念

词法作用域/静态作用域

词法作用域,就意味着函数被定义的时候,它的作用域就已经确定了,和拿到哪里执行没有关系,因此词法作用域也被称为 “静态作用域”。

词法作用域Lexical Scopes)是 javascript 中使用的作用域类型,词法作用域 也可以被叫做 静态作用域,与之相对的还有 动态作用域。那么 javascript 使用的 词法作用域动态作用域 的区别是什么呢?看下面这段代码:

var value = 1;

function foo() {
console.log(value);
}

function bar() {
var value = 2;
foo();
}

bar();

// 结果是 ???

上面这段代码中,一共有三个作用域:

  • 全局作用域
  • foo 的函数作用域
  • bar 的函数作用域

一直到这边都好理解,可是 foo 里访问了本地作用域中没有的变量 value 。根据前面说的,引擎为了拿到这个变量就要去 foo 的上层作用域查询,那么 foo 的上层作用域是什么呢?是它 调用时 所在的 bar 作用域?还是它 定义时 所在的全局作用域?

这个关键的问题就是 javascript 中的作用域类型——词法作用域

如果是动态作用域类型,那么上面的代码运行结果应该是 bar 作用域中的 2 。也许你会好奇什么语言是动态作用域?bash 就是动态作用域,感兴趣的小伙伴可以了解一下。

作用域链

我们在查找自己想要的数据的时候,先在当前作用域中查找所需变量,如果在自己作用域找不到该变量就去父级作用域查找,依次向上级作用域查找,直到访问到window对象,如果最终都没找到就会报错。这一层层的关系就是形成了作用域链。

  • 作用域链的作用是保证对执行环境有权访问的所有变量和函数的有序访问,通过作用域链,可以访问到外层环境的变量和函数。
  • 作用域链的本质上是一个指向变量对象的指针列表。变量对象是一个包含了执行环境中所有变量和函数的对象。
  • 作用域链的前端始终都是当前执行上下文的变量对象。全局执行上下文的变量对象(也就是全局对象)始终是作用域链的最后一个对象。
  • 当查找一个变量时,如果当前执行环境中没有找到,可以沿着作用域链向后查找。

作用域延长

既然是个链,能不能延长呢? 答案当然是可以。

延长作用域链: 执行环境的类型只有两种,全局和局部(函数)。 但是有些语句可以在作用域链的前端临时增加一个变量对象,该变量对象会在代码执行后被移除。

具体来说就是执行这两个语句时,作用域链都会得到加强。

try...catch

let x = 1;
try {
x = x + y;
} catch(e) {
console.log(e);
}

上述代码try里面我们用到了一个没有申明的变量y,所以会报错,然后走到catchcatch会往作用域链最前面添加一个变量e,这是当前的错误对象,我们可以通过这个变量来访问到错误对象,这其实就相当于作用域链延长了。这个变量e会在catch块执行完后被销毁。

with

with语句可以操作作用域链,可以手动将某个对象添加到作用域链最前面,查找变量时,优先去这个对象查找,with块执行完后,作用域链会恢复到正常状态。

function f(obj, x) {
with(obj) {
console.log(x); // 1
}

console.log(x); // 2
}

f({x: 1}, 2);

上述代码,with里面输出的x优先去obj找,相当于手动在作用域链最前面添加了obj这个对象,所以输出的x是1。with外面还是正常的作用域链,所以输出的x仍然是2。

需要注意的是with语句里面的作用域链要执行时才能确定,引擎没办法优化,所以严格模式下是禁止使用with的。

参考链接

深入理解JavaScript作用域和作用域链 - 掘金 (juejin.cn)

JS作用域和变量提升看这一篇就够了 - 掘金 (juejin.cn)

面试官:说说作用域和闭包吧 - 掘金 (juejin.cn)