JS核心理论之《作用域、变量提升、闭包与IIFE》

736 阅读5分钟

作用域

作用域是程序源代码中定义变量的区域。通俗讲,作用域就是查找变量的地方。

词法作用域(lexical scoping),也就是静态作用域,是由书写代码时函数声明的位置来决定的。JS语言采用的是词法作用域。

作用域链:先在函数作用域中查找,没有找到,再去全局作用域中查找,有一个往外层查找的过程。我们好像是顺着一条链条从下往上查找变量。

在ES5中,js只有两种形式的作用域:全局作用域函数作用域;在ES6中,又新增了一个块级作用域(最近的大括号涵盖的范围),且只有let与const方式申明的变量有效。

  • 不在任何函数内定义的变量就具有全局作用域。实际上,JavaScript默认有一个全局对象window,全局作用域的变量实际上被绑定到window的一个属性。
  • 而在一个函数内定义的变量只能在函数内部访问或者这个函数内部的函数访问。如果声明一个变量的时候没有使用var关键字,那么这个变量将是一个全局变量。
  • 块级作用域:一对大括号就可以看成是一块,在这块区域中使用let或const定义的变量,只能在这个区域中使用。

变量提升

JavaScript的函数定义有个特点,它会先扫描整个函数体的语句,把所有申明的变量“提升”到函数顶部。注意提升的只是声明,不是赋值。

首先来明确一下概念

声明

var a;就是变量声明
function f (){}就是函数声明

赋值

a=1;就是赋值

需要注意的是同一个变量申明只进行一次,后面的申明都会被忽略。 所以 var b = 2;是两个过程,分别是声明和赋值,等于下面的代码:

var b;//声明
b = 2;//赋值

function f (){}函数在声明时,声明与赋值都同时进行。

示例:

'use strict';

function foo() {
    var x = 'Hello, ' + y;
    console.log(x);
    var y = 'Bob';
}

foo(); // Hello, undefined

JS引擎看到的代码相当于:

function foo() {
    var y; // 提升变量y的申明,此时y为undefined
    var x = 'Hello, ' + y;
    console.log(x);
    y = 'Bob';
}

在Javascript中,变量进入一个作用域可以通过下面四种方式:

  1. 语言自定义变量:所有的作用域中都存在this和arguments这两个默认变量
  2. 函数形参:函数的形参存在函数作用域中
  3. 函数声明:function foo() {}
  4. 变量声明:var foo

而它们的优先级是:this/arguments > 形参 > 函数声明 > 变量声明, 意味着this的优先级最高,没人能覆盖它,函数声明会覆盖变量声明。 下面我们通过两个示例来验证一下:

var foo = 1;
function bar() {
    if (!foo) {
        var foo = 10;
    }
    console.log(foo); // 10
}
bar();
var a = 1;
function b() {
    a = 10;
    return;
    function a() {}
}
b();
console.log(a); // 1

思考一下为什么?根据变量提升的原理,其实相当于:

var foo = 1;
function bar() {
    var foo; //foo此时被初始化为undefined
    if (!foo) { //!undefined是true
        foo = 10;
    }
    console.log(foo);//输出的是局部变量foo,10
}
bar();
var a = 1;
function b() {
    function a() {}
    a = 10;//相当于只是覆盖了在b函数这个作用域内声明的a函数,所以这个a=10只是局部变量,影响不到外面的a
    return;
}
b();
console.log(a); //仍然输出外层的a

我们再来看这样一个例子。

var foo1 = 1;
var foo2 = 2;
var foo3 = 3;
function bar() {
  //case1
  console.log(foo1);
  var foo1 = 10;
  console.log(foo1);

  //case2
  console.log(foo2);
 
  //case3
  console.log(foo3);
  foo3 = 30
  console.log(foo3);
}
bar();

通过结果输出:

undefined
10
2
3
30

我们可以得出结论:

  • 如果函数作用域内重新定义一个与全局作用域同名的局部变量时,会局部变量为准,即全局变量被覆盖,如foo1,注意此时会把foo1变量定义提升到函数内顶部
  • 如果函数作用域内不对全局变量做任何操作,则直接读取全局变量,如foo2
  • 如果函数作用域内不使用var,定义了同名的全局变量,则实际定义的是一个全局变量,则仍然覆盖了外面的全局变量,且不存在变量提升,如foo3

闭包

闭包是基于词法作用域写代码时产生的一种现象,通俗讲,闭包就是能够读取其他函数内部变量的函数。

由于变量的作用域无非就是两种:全局变量和局部变量。Javascript语言的特殊之处,就在于函数内部可以直接读取全局变量。但是反过来,从外部得到函数内的局部变量,正常情况是不行的。 于是,就要采用闭包的写法来达到此目的。

示例:

function f1(){
  var n=999;
  function f2(){
    alert(n);
  }
  return f2;
}

var result=f1();
result(); // 999

闭包的用途:

  1. 可以读取函数内部的变量
  2. 让这些变量的值始终保持在内存中

闭包的缺陷:

  1. 常驻内存会增大内存使用量,并且使用不当很容易造成内存泄露。

经典问题: 循环中使用闭包解决 var 定义函数的问题

for (var i = 1; i <= 5; i++) {
  setTimeout(function timer() {
    console.log(i)
  }, i * 1000)
}

会输出一堆 6

解决办法一:使用闭包

for (var i = 1; i <= 5; i++) {
  ;(function(j) {
    setTimeout(function timer() {
      console.log(j)
    }, j * 1000)
  })(i)
}

解决办法二:使用let

for (let i = 1; i <= 5; i++) {
  setTimeout(function timer() {
    console.log(i)
  }, i * 1000)
}

IIFE

使用"立即执行函数"(Immediately-Invoked Function Expression,IIFE),可以达到不暴露私有成员的目的。

var module1 = (function(){
    var _count = 0;
    var m1 = function(){    
        //...
    };
    return {
        m1 : m1
    };
})();

console.info(module1._count); //undefined

使用上面的写法,外部代码无法读取内部的_count变量。而如果直接使用对象形式,内部状态可以被外部改写的。

var module1 = {
    var _count = 0;
    var m1 = function(){    
        //...
    };
};

module1._count = 5;

所以,IIFE就是Javascript模块的基本写法。IIFE还有两种演进变体,称为"放大模式"与"宽放大模式"。

所谓"放大模式",是指一个模块继承另一个模式。如下面的module2就继承了module1:

var module2 = (function(m){
    m.m3 = function(){    
        //...
    };
    return m
})(module1);

所谓"宽放大模式",就是允许"立即执行函数"传入的参数是空对象。

var module3 = (function(m){
    m.m4 = function(){    
        //...
    };
    return m
})(module1||{});