JS中的变量提升和作用域|青训营笔记

86 阅读9分钟

这是我参与「第四届青训营 」笔记创作活动的的第4天。为了应对面试八股,也顺便梳理一下基础知识,做出一点关于变量和JS作用域链方面的个人总结心得。

反思

了解JS在浏览器或者底层的执行逻辑,我们才能从容的应对代码输出问题,由普遍到特殊,影响的就是执行顺序,从进入执行上下文开始,预处理将变量和函数做一个优化提升处理,然后边解释边执行,各个任务栈按照既定规则执行。

作用域

let x=1

语句包含了以下概念:

变量(variable):这里x就是一个变量,是用来指代一个值的符号。

(value):就是具体的数据,可以是数字,字符串,对象等。这里1就是一个值。

变量绑定(name binding):就是变量和值之间建立对应关系,x = 1就是将变量x1联系起来了。

作用域(scope):作用域就是变量绑定(name binding)的有效范围。就是说在这个作用域中,这个变量绑定是有效的,出了这个作用域变量绑定就无效了。

静态作用域

又叫词法作用域

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。也就是说我们调用一个函数时,如果这个函数的变量没有在函数中定义,就去定义该函数的地方查找,这种查找关系在我们代码写出来的时候其实就确定了,所以叫静态作用域。

词法作用域只由函数被声明所处位置决定

function foo(a) {
  var b = a * 2;
  function foo2(c) {
    console.log(a,b,c)
  }
  foo2(b * 3);
}
foo(1); // 1 2 6

例子中三个作用域,第一个是全局作用域,有一个标识符foo

第二个是foo()创建的作用域,有三个标识符a,b,foo2()

第三个是foo2()创建的作用域,有一个标识符c

多层嵌套的作用域可以定义同名的标识符,内层标识符会覆盖外层的标识符,称作遮蔽效应

var a = 1;
function foo() {
  var a = 2;
  console.log(a); // 2
}
foo();

全局的变量会自动成为全局对象的属性,可以通过全局对象window来访问

var a = 1;
function foo() {
  var a = 2;
  console.log(window.a); // 1
}
foo();

动态作用域

词法作用域(静态作用域)的特点是它是在代码书写阶段确定的,动态作用域特点它是在代码运行阶段确定的。

动态作用域只由函数被调用时的位置确定

var a = 1;
function foo() {
  console.log(a);
}
​
function foo2() {
  var a = 2;
  foo();
}
​
foo2(); // 1

处于词法作用域下的时候,会正常打印1,处于动态作用域下的时候,a会先在foo()下寻找,没有找到,之后会沿着调用栈找到foo2(),找到并打印a为2>动态作用域的变量值在运行前难以确定,复杂度更高,所以目前主流的都是静态作用域,比如JS,C,C++,Java这些都是静态作用域。

变量提升

变量声明提前

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

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

输出为undefined,因为变量声明提前了,但是变量的赋值还是在console.log(x)之后执行的,变量的赋值操作没有提前

等价于:

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

函数声明提前

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

输出为1,在f()中定义的x()声明被提前了,相当于:

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

如果将函数换成函数表达式的话就无法提前

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

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

var x=1
function x(){}
console.log(typeof x)//number

函数声明提前的优先级更高,x会先被提前声明为一个函数,然后再被赋值为1,所以输出typeof x时为number

块级作用域

块级作用域指:变量在指定代码块中才可以访问,为了区分var块级作用域中常量使用const变量使用let来表示

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

输出的x为2,y为1,第一个在函数f()中声明的let y=1的作用域是整个函数f(),在if中声明的var x的作用域是整个函数,所以在打印时可以被访问到,在

if中的let y作用域不包括整个函数,只包括了if,在打印时无法被访问到。

块级作用域在同一个块下不允许重复声明

let x=1
var x=2

Uncaught SyntaxError: Identifier 'x' has already been declared

都使用var声明时不会报错

var x=1
var x=2
console.log(x)//2

使用letconst进行声明的变量不会变量提升,这句话是不准确的

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

程序运行的结果为报错,如果将let x=2注释掉,它就会找到函数外的var x并打印1,如果let声明的变量不会变量提升,那么一开始也不会报错而是直接打印1,说明了let声明的变量会进行变量提升,只是和var声明的变量提升的行为不同,var声明的变量提升之后会读到undefinedlet提升的变量(块级作用域提升行为)会造成一个暂时性死区(Temporal Dead Zone,TDZ) 暂时性死区的现象就是在块级顶部到变量正式申明这块区域去访问这个变量的话,直接报错,这个是ES6规范规定的。

在循环语句中的应用

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

上面的代码块打印的是3,3,3而不是原本要的效果0,1,2因为setTimeout是异步函数,而循环中的语句是同步函数,一次执行完,在setTimeout开始执行时,循环已经结束,i++已经执行完了,访问到的i的值为3.

为了实现期望的效果,可以使用自执行函数

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

或者直接使用let

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

也可以用于for...in和for..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++)语句中,第一个循环是可以正常执行的,但是,在执行i++时就会出错

for(const i=0;i<3;i++){
  setTimeout(()=>{
    console.log(i)
  })//0,error
}

对于for..infor..of循环时可以正常使用const

let obj = {
  x: 1,
  y: 2,
  z: 3
}
​
for(const k in obj){
  setTimeout(() => {
    console.log(obj[k])
  })
}

let不影响全局对象

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

使用var来声明变量时,如果和全局对象重名,则会覆盖原先的全局对象

但使用let不会产生此类问题

let JSON="hello"
console.log(window.JSON)//JSON{...}

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

作用域链

在使用一个变量时,首先在当前作用域下查找此变量,如果没找到就向外层作用域来进行查找,最终找到全局作用域,最终没找到就报错

let x = 1;

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

f();//1

f1()下找变量x没有找到就去外层作用域f()下查找,最后在全局作用域下找到x并输出

作用域链延长

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

总结

作用域其实就是一个变量绑定的有效范围。

JS使用的是静态作用域,即一个函数使用的变量如果没在自己里面,会去定义的地方查找,而不是去调用的地方查找。去调用的地方找到的是动态作用域。

var变量会进行申明提前,在赋值前可以访问到这个变量,值是undefined

函数申明也会被提前,而且优先级比var高。

使用var的函数表达式其实就是一个var变量,在赋值前调用相当于undefined(),会直接报错。

letconst是块级作用域,有效范围是一对{}

同一个块级作用域里面不能重复申明,会报错。

块级作用域也有“变量提升”,但是行为跟var不一样,块级作用域里面的“变量提升”会形成“暂时性死区”,在申明前访问会直接报错。

使用letconst可以很方便的解决循环中异步调用参数不对的问题。

letconst在全局作用域申明的变量不会成为全局对象的属性,var会。

访问变量时,如果当前作用域没有,会一级一级往上找,一直到全局作用域,这就是作用域链。

try...catchcatch块会延长作用域链,往最前面添加一个错误对象。

with语句可以手动往作用域链最前面添加一个对象,但是严格模式下不可用。

如果开发环境支持ES6,就应该使用letconst,不要用var

\