JS作用域和变量提升看这一篇就够了

Wesley13
• 阅读 652

作用域是JS中一个很基础但是很重要的概念,面试中也经常出现,本文会详细深入的讲解这个概念及其他相关的概念,包括声明提升,块级作用域,作用域链及作用域链延长等问题。

什么是作用域

第一个问题就是我们要弄清楚什么是作用域,这不是JS独有的概念,而是编程领域中通用的一个概念。我们以下面这个语句为例:

let x = 1;

这一个简单的语句其实包含了几个基本的概念:

> 1. 变量(variable):这里x就是一个变量,是用来指代一个值的符号。 > 2. (value):就是具体的数据,可以是数字,字符串,对象等。这里1就是一个值。 > 3. 变量绑定(name binding):就是变量和值之间建立对应关系,x = 1就是将变量x1联系起来了。 > 4. 作用域(scope):作用域就是变量绑定(name binding)的有效范围。就是说在这个作用域中,这个变量绑定是有效的,出了这个作用域变量绑定就无效了。

就整个编程领域而言的话,作用域又分为静态作用域和动态作用域两类。

静态作用域

静态作用域又叫词法作用域,JS就是静态作用域,比如如下代码:

let x = 10;

function f() {
  return x;
}

function g() {
  let x = 20;
  return f();
}

console.log(g());  // 10

上述代码中,函数f返回的x是外层定义的x,也就是10,我们调用g的时候,虽然g里面也有个变量x,但是在这里我们并没有用它,用的是f里面的x。也就是说我们调用一个函数时,如果这个函数的变量没有在函数中定义,就去定义该函数的地方查找,这种查找关系在我们代码写出来的时候其实就确定了,所以叫静态作用域。这是一段很简单的代码,大家都知道输出是10,难道还能输出20?还真有输出20的,那就是动态作用域了!

动态作用域

Perl语言就采用的动态作用域,还是上面那个代码逻辑,换成Perl语言是这样:

$x = 10;

sub f
{
    return $x;
}

sub g
{
    local $x = 20;
    return f();
}

print g();

上述代码的输出就是20大家可以用Perl跑下看看,这就是动态作用域。所谓动态作用域就是我们调用一个函数时,如果这个函数的变量没有在函数中定义,就去调用该函数的地方查找。因为一个函数可能会在多个地方被调用,每次调用的时候变量的值可能都不一样,所以叫动态作用域。动态作用域的变量值在运行前难以确定,复杂度更高,所以目前主流的都是静态作用域,比如JS,C,C++,Java这些都是静态作用域。

声明提前

变量声明提前

在ES6之前,我们申明变量都是使用var,使用var申明的变量都是函数作用域,即在函数体内可见,这会带来的一个问题就是申明提前。

var x = 1;
function f() {
  console.log(x);
  var x = 2;
}

f();

上述代码的输出是undefined,因为函数f里面的变量x使用var申明,所以他其实在整个函数f可见,也就是说,他的声明相当于提前到了f的最顶部,但是赋值还是在运行的x = 2时进行,所以在var x = 2;上面打印x就是undefined,上面的代码其实等价于:

var x = 1;
function f() {
  var x
  console.log(x);
  x = 2;
}

f();

函数声明提前

看下面这个代码:

function f() {
  x();
  
  function x() {
    console.log(1);
  }
}

f();

上述代码x()调用是可以成功的,因为函数的声明也会提前到当前函数的最前面,也就是说,上面函数x会提前到f的最顶部执行,上面代码等价于:

function f() {
  function x() {
    console.log(1);
  }
  
  x();
}

f();

但是有一点需要注意,上面的x函数如果换成函数表达式就不行了:

function f() {
  x();
  
  var x = function() {
    console.log(1);
  }
}

f();

这样写会报错Uncaught TypeError: x is not a function。因为这里的x其实就是一个普通变量,只是它的值是一个函数,它虽然会提前到当前函数的最顶部申明,但是就像前面讲的,这时候他的值是undefined,将undefined当成函数调用,肯定就是TypeError

变量申明和函数申明提前的优先级

既然变量申明和函数申明都会提前,那谁的优先级更高呢?答案是**函数申明的优先级更高!**看如下代码:

var x = 1;
function x() {}

console.log(typeof x);  // number

上述代码我们申明了一个变量x和一个函数x,他们拥有同样的名字。最终输出来的typeofnumber,说明函数申明的优先级更高,x变量先被申明为一个函数,然后当我们再用var申明x的时候,这个var会被忽略,但是x=1的赋值语句会运行,最后x就是1,类型是number

块级作用域

前面的申明提前不太符合人们正常的思维习惯,对JS不太熟悉的初学者如果不了解这个机制,可能会经常遇到各种TypeError,写出来的代码也可能隐含各种BUG。为了解决这个问题,ES6引入了块级作用域。块级作用域就是指变量在指定的代码块里面才能访问,也就是一对{}中可以访问,在外面无法访问。为了区分之前的var,块级作用域使用letconst声明,let申明变量,const申明常量。看如下代码:

function f() {
  let y = 1;
  
  if(true) {
    var x = 2;
    let y = 2;
  }
  
  console.log(x);   // 2
  console.log(y);   // 1
}

f();

上述代码我们在函数体里面用let申明了一个y,这时候他的作用域就是整个函数,然后又有了一个if,这个if里面用var申明了一个x,用let又申明了一个y,因为var是函数作用域,所以在if外面也可以访问到这个x,打印出来就是2,if里面的那个y因为是let申明的,所以他是块级作用域,也就是只在if里面生效,如果在外面打印y,会拿到最开始那个y,也就是1.

不允许重复申明

块级作用域在同一个块中是不允许重复申明的,比如:

var a = 1;
let a = 2;

这个会直接报错Uncaught SyntaxError: Identifier 'a' has already been declared

但是如果你都用var申明就不会报错:

var a = 1;
var a = 2;

不会变量提升?

经常看到有文章说: 用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规范规定的。

循环语句中的应用

下面这种问题我们也经常遇到,在一个循环中调用异步函数,期望是每次调用都拿到对应的循环变量,但是最终拿到的却是最后的循环变量:

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)
  })
}

这种写法也适用于for...infor...of循环:

let obj = {
  x: 1,
  y: 2,
  z: 3
}

for(let k in obj){
  setTimeout(() => {
    console.log(obj[k])
  })
}

那能不能使用const来申明循环变量呢?对于for(const i = 0; i < 3; i++)来说,const i = 0是没问题的,但是i++肯定就报错了,所以这个循环会运行一次,然后就报错了。对于for...infor...of循环,使用const声明是没问题的。

let obj = {
  x: 1,
  y: 2,
  z: 3
}

for(const k in obj){
  setTimeout(() => {
    console.log(obj[k])
  })
}

不影响全局对象

在最外层(全局作用域)使用var申明变量,该变量会成为全局对象的属性,如果全局对象刚好有同名属性,就会被覆盖。

var JSON = 'json';

console.log(window.JSON);   // JSON被覆盖了,输出'json'

而使用let申明变量则没有这个问题:

let JSON = 'json';

console.log(window.JSON);   // JSON没有被覆盖,还是之前那个对象

上面这么多点其实都是letconst对以前的var进行的改进,如果我们的开发环境支持ES6,我们就应该使用letconst,而不是var

作用域链

作用域链其实是一个很简单的概念,当我们使用一个变量时,先在当前作用域查找,如果没找到就去他外层作用域查找,如果还没有,就再继续往外找,一直找到全局作用域,如果最终都没找到,就报错。比如如下代码:

let x = 1;

function f() {
  function f1() {
    console.log(x);
  }
  
  f1();
}

f();

这段代码在f1中输出了x,所以他会在f1中查找这个变量,当然没找到,然后去f中找,还是没找到,再往上去全局作用域找,这下找到了。这个查找链条就是作用域链。

作用域链延长

前面那个例子的作用域链上其实有三个对象:

f1作用域 -> f作用域 -> 全局作用域

大部分情况都是这样的,作用域链有多长主要看它当前嵌套的层数,但是有些语句可以在作用域链的前端临时增加一个变量对象,这个变量对象在代码执行完后移除,这就是作用域延长了。能够导致作用域延长的语句有两种:try...catchcatch块和with语句。

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的。

总结

  1. 作用域其实就是一个变量绑定的有效范围。
  2. JS使用的是静态作用域,即一个函数使用的变量如果没在自己里面,会去定义的地方查找,而不是去调用的地方查找。去调用的地方找到的是动态作用域。
  3. var变量会进行申明提前,在赋值前可以访问到这个变量,值是undefined
  4. 函数申明也会被提前,而且优先级比var高。
  5. 使用var的函数表达式其实就是一个var变量,在赋值前调用相当于undefined(),会直接报错。
  6. letconst是块级作用域,有效范围是一对{}
  7. 同一个块级作用域里面不能重复申明,会报错。
  8. 块级作用域也有“变量提升”,但是行为跟var不一样,块级作用域里面的“变量提升”会形成“暂时性死区”,在申明前访问会直接报错。
  9. 使用letconst可以很方便的解决循环中异步调用参数不对的问题。
  10. letconst在全局作用域申明的变量不会成为全局对象的属性,var会。
  11. 访问变量时,如果当前作用域没有,会一级一级往上找,一直到全局作用域,这就是作用域链。
  12. try...catchcatch块会延长作用域链,往最前面添加一个错误对象。
  13. with语句可以手动往作用域链最前面添加一个对象,但是严格模式下不可用。
  14. 如果开发环境支持ES6,就应该使用letconst,不要用var

文章的最后,感谢你花费宝贵的时间阅读本文,如果本文给了你一点点帮助或者启发,请不要吝啬你的赞和GitHub小星星,你的支持是作者持续创作的动力。

“前端进阶知识”系列文章及示例源码: https://github.com/dennis-jiang/Front-End-Knowledges

欢迎关注我的公众号进击的大前端第一时间获取高质量原创~

JS作用域和变量提升看这一篇就够了

点赞
收藏
评论区
推荐文章
blmius blmius
3年前
MySQL:[Err] 1292 - Incorrect datetime value: ‘0000-00-00 00:00:00‘ for column ‘CREATE_TIME‘ at row 1
文章目录问题用navicat导入数据时,报错:原因这是因为当前的MySQL不支持datetime为0的情况。解决修改sql\mode:sql\mode:SQLMode定义了MySQL应支持的SQL语法、数据校验等,这样可以更容易地在不同的环境中使用MySQL。全局s
Dax Dax
3年前
JS核心原理理解闭包
前置概念在正式看闭包之前,我们先来学习一下前置知识,那就是JS中的作用域,我们知道,在ES5之中,作用域分为两种:全局作用域和函数作用域,随着ES6的到来,新增了块级作用域,想更好的理解闭包,那么搞清楚作用域是首要条件全局作用域我们知道,对于变量而言,我们一般会分成两类:全局变量和局部变量,一般定义在最外围环境的为全局变量,定义在函数当中的为局部变量,在we
Symbol卢 Symbol卢
3年前
秒懂js作用域与作用域链
JavaScript中有一个被称为作用域(Scope)的特性。虽然对于许多新手开发者来说,作用域的概念并不是很容易理解,本文我会尽我所能用最简单的方式来解释作用域和作用域链,希望大家有所收获!好了下面开始我们的正文作用域常见的解释(什么是作用域)1.一段程序代码中所用到的名字并不总是有效,而限定它的可用性的范围就是这个名字的作用域;2.作用域规定了
Karen110 Karen110
3年前
一篇文章带你了解JavaScript作用域
在JavaScript中,对象和函数也是变量。在JavaScript中,作用域是你可以访问的变量、对象和函数的集合。JavaScript有函数作用域:这个作用域在函数内变化。一、本地JavaScript变量一个变量声明在JavaScript函数内部,成为函数的局部变量。局部变量有局部作用域:它们只能在函数中访问。JS://codeherecann
待兔 待兔
4个月前
手写Java HashMap源码
HashMap的使用教程HashMap的使用教程HashMap的使用教程HashMap的使用教程HashMap的使用教程22
Jacquelyn38 Jacquelyn38
3年前
你所知道的JS变量作用域
变量的作用域,指的是变量在脚本代码中的可读、可写的有效范围,也就是脚本代码中可以使用这个变量的区域。在ES6之前,变量的作用域主要分为全局作用域、局部作用域(也称函数作用域)两种;在ES6及其之后,变量的作用域主要分为全局作用域、局部作用域、块级作用域这3种。相应作用域变量分别称为全局变量、局部变量、块级变量。全局变量声明在所有函数之外;局部变量是在函数体内
Wesley13 Wesley13
3年前
JAVA记录
singleton作用域:当把一个Bean定义设置为singleton作用域是,SpringIoC容器中只会存在一个共享的Bean实例,并且所有对Bean的请求,只要id与该Bean定义相匹配,则只会返回该Bean的同一实例。值得强调的是singleton作用域是Spring中的缺省作用域。prototype作用域:protot
Wesley13 Wesley13
3年前
ES6 简单整理
1.变量声明let和constlet与const都是块级作用域,letfunctionname(){letage12;//age只在name()函数中存在}constconstname'tom'name'jack'//
Wesley13 Wesley13
3年前
ES6的语法
一,定义变量let(类似var)在js一直有一个bug是var:1、var声明的变量会有变量提升console.log(name);//jhonvarname'jhon';2、var没有块级作用域varname2'jjjon';{varname2'tom';
Wesley13 Wesley13
3年前
Java连载7
一、变量1.注意点:在同一个“作用域”中,变量名不能重名,但是变量可以重新赋值。2.什么是作用域?答:描述的是变量的有效范围,在范围之内是可以被访问的,只要出了作用域就无法访问(也就是在大括号里面才行)3.关于变量的分类(1)局部变量:在方法体中声明的变量;(2)成员变量:在方法体外声明的变量。4.在不同的作用域中,变量名是可