作用域和闭包

作用域Scope

作用域是源代码中定义变量的区域;

作用域是根据名称查找变量的一套规则。

词法作用域

词法作用域就是定义在词法阶段的作用域,词法作用域是由你在写代码时将变量和块作用域写在哪里来决定的,因此当词法分析器处理代码时会保持作用域不变。

JavaScript使用词法作用域(lexical scoping)。所以函数执行时使用的是定义函数时生效的变量作用域(静态作用域) ,而不是调用函数时生效的变量作用域。

而为了实现词法作用域,JavaScript函数对象的内部状态不仅要包括函数代码,还要包括对函数定义所在作用域的引用( [[Scope]] ).

简单图解:

scope.png

  1. 包含着整个全局作用域,其中只有一个标识符:foo

  2. 包含着 foo 所创建的作用域,其中有三个标识符:a、bar 和 b

  3. 包含着 bar 所创建的作用域,其中只有一个标识符:c

词法作用域查找规则

沿着作用域链向上逐级查询,直到查到或查不到为止.

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

函数作用域(局部作用域)

函数内部拥有自己的函数作用域,外部无法访问到函数内部的变量;即属于这个函数的全部变量都可以在整个函数的范围内使用及复用(事实上在嵌套的作用域中也可以使用)。

function outFun(a){
    var b = 3.14;
    function inFun(){
    // ...
    }
    let c = 'c';
}

console.log(b); // Refferce Error!

outFun()的作用域包含了标识符a、b、c和inFun()。而inFun()又会拥有自己的函数作用域。

var和函数作用域

var 声明的范围是函数作用域(或全局作用域)。在函数内部使用var关键字声明的变量会成为函数的局部变量,当该函数执行完毕后就会摧毁。而且var声明的变量存在提升(后面有说)。

function test(){
  var str = 'string';
}
test();
console.log(str); // => Uncaught ReferenceError: str is not defined

但是,不使用关键字修饰所定义的变量又回成为全局作用域的全局变量,js就是这么奇奇怪怪。。

function test2(){
  global = "global";
}
test2();
console.log(global); // => "global"

隐藏内部实现(私有化)

由于函数拥有的独特的作用域,外部不能够访问,所以利用这个特性可以将函数和变量进行隐藏。

function fun(a){
    var privateVar;
    function doSomething(){ // 不能够访问,实现隐藏
        return a*3.14;
    }
    privateVar = a + doSomething(); //不能够访问,实现隐藏
    return privateVar;
}

隐藏作用域的一个好处就是能够规避同名标识符之间的冲突 .

命名空间Namespace

库通常会在全局作用域中声明一个名字足够独特的变量,通常是一个对象。这个对象 被用作库的命名空间,所有需要暴露给外界的功能都会成为这个对象(命名空间)的属性,而不是将自己的标识符暴漏在顶级的词法作用域中.

const myLir = {
    getReq: function(){ do something...},
    priVar: 3.14
    ...
}

IIFE立即执行函数表达式

在上面的隐藏作用域实现代码中,必须声明一个具名函数,这会污染了所在作用域(一般是全局作用域),然后必须显式调用才能实现隐藏。所以便出现了立即执行函数表达式

//格式一
var a = 3.14;
(
    function(para) { // 可以匿名可以具名
        var a = 123;
        console.log(a + para); // => "123hi"
    }
)('hi');
console.log(a); // => 3.14
//具名
var b = 3.1415;
(
    function b(){
    b = 2.15;
    console.log(b);
  }
)();
console.log(b); // => 3.1415

//格式二
var c = 'hello';
(
    function(para) {
        var c = 'world';
        console.log(c + paras); // => "world123"
    }(123)
)
console.log(c); // => "hello"

//还有经常见到的 !function
//因为匿名 function(){}声明是报语法错误的,注意这现在是一个函数声明
function (){console.log("IIFE");}(); // => SyntaxError Function statements require a function name

//所以在function前面加上感叹号!(英文感叹号)后,function(){} 是一个函数表达式了
//当然该表达式返回值为 !undefined === true
!function (){console.log("IIFE");}(); // => IIFE

要注意的是IIFE的作用域依旧是函数作用域,我们看以下代码:

var foo = "global";
(
    function foo(){
    console.log(a); // 赋值表达式不提升,var a声明提升
    var a = "a";
  }
)(); // => undefined

但是,具名IIFE(且变量名和函数名一致)是一个特别的存在:

var same = "global";
(
    function same(){
    same = "IIFE";
    console.log(same);
  }
)(); 
// => 
/*
        function same(){
        same = "IIFE";
        console.log(same);
    }
*/
console.log(same); // => "global" 

可以看到不使用关键字声明的same = "IIEF"并没有成为全局变量。

这是因为这个具名函数是立即执行的,这个名字(变量)不能够再重新赋值,这个名字(变量)将会一直直接引用这个函数本身。

使用严格模式可以让它报错:

"use strict";
var same = "global";
(
    function same(){
    same = "IIFE";
    console.log(same);
  }
)(); 
// => Uncaught TypeError: Assignment to constant variable.

块作用域

块级作用域由最近的一对包含花括号{}界定,块级作用域是函数作用域的子集。

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

JavaScript 的 ES3 规范中规定 try/catch 的 catch 分句会创建一个块作用域,其中声明的变量仅在 catch 内部有效.

try {
    undefined(); // 执行一个非法操作来强制制造一个异常
}
catch (err) {
    console.log( err ); // 能够正常执行! 
}
console.log( err ); // ReferenceError: err not found

let

ES6的新特性之一就是引入了新的let关键字,let 关键字可以将变量绑定到所在的任意作用域中(通常是一对花括号内 { ... } )。即let能过“劫持”所声明变量的块作用域。(即let和后面的const定义范围是块级作用域)。

var bool = true;
if(bool) {
    let a = 3;
    a = a**;
    console.log(a);// => 9
}
console.log(a); // ReferenceError

let 将变量附加在一个已经存在的块作用域上的行为是隐式的.

显式的块作用域

{
     let num = 123;
}
console.log(num); // ReferenceError

var bool = true;
if(bool) {
    // 前面代码
    {  // 这样不会影响前面和后续的代码
        let a = 3;
        a = a**;
        console.log(a);// => 9  
    }
    // 后续代码
}
console.log(a); // ReferenceError

let和循环

for (let i=0; i<10; i++) { 
    console.log( i );
}
console.log( i ); // ReferenceError

for 循环头部的 let 不仅将 i 绑定到了 for 循环的块中,事实上它将其重新绑定到了循环的每一个迭代中,确保使用上一个循环迭代结束时的值重新进行赋值。

我们可以这样理解:

每一次循环,都会使用let进行声明,而使用let声明可以"劫持"就近的块级作用域(即for语句的花括号),因此相当于,每一次都相当于下面代码:

{
  let i = 0;
  console.log(i);
};
{
  let i = 1;
  console.log(i);
};
...

let和条件声明

使用 var 声明变量时,由于声明会被提升,JavaScript 引擎会自动将多余的声明在作用域顶部合并为一个声明。因为 let 的作用域是块,所以不可能检查前面是否已经使用 let 声明过同名变量,同时也就不可能在没有声明的情况下声明它。

{
        var a = 1;
        var a = 2;
        var a = 3;
}
console.log(a); // => 3 最终只有最后的声明赋值有效

{
    let pi = 3.14;
    let pi = 3.1415926; // Uncaught SyntaxError: Identifier 'pi' has already been declared
}

看以下代码:

你觉得这个代码会错吗?

{
    let str;
}
str = "123";
console.log(str);

答案是不会。{}中的str被限制在{}块的作用域中,而下面的无声明的str声明则是全局赋值,将作为全局对象的属性。

const

ES6 同时还增加了 const 关键字。使用 const 声明的变量必须同时初始化为某个值。 一经声明,在其生命周期的任何时候都不能再重新赋予新值。

const TI; // 报错
var bool = true;
if(bool){
    var a = 2;
    const b = 3.14;
    a = 4;
    b = 6.28;// TypeError: Assignment to constant variable
}
console.log(a);// 2
console.log(b);//ReferencError

因此const不能用来声明迭代变量(因为迭代变量会自增)。

for(const i = 0;i < 10;i++){ // Uncaught TypeError: Assignment to constant variable.
  ...
}

对于const关键字修饰的对象变量,赋值为对象的 const 变量不能再被重新赋值为其他引用值,但对象的则不受限制。(地址引用不能改变)

const obj = {};
obj.name = "cat"; //不会报错

如果想让整个对象都不能修改,可以使用 Object.freeze(),这样再给属性赋值时虽然不会报错, 但会静默失败

const o3 = Object.freeze({});
o3.name = 'Jake';
console.log(o3.name); // undefined

垃圾回收

一个块作用域非常有用的原因和闭包及回收内存垃圾的回收机制相关.

function process(data) {
    // do something
}
var someBigData = { ... };
process(someBigData);

var btn = document.querySelector('#btn');
btn.addEventListener('click', event => console.log("clicked"));

当点击事件触发回调函数执行后,someBigData就不再需要了,someBigData占用的空间理应被回收,但因为这个回调函数形成了覆盖整个作用域的闭包,JS引擎极有可能依然保留该结构.

我们可以通过块作用域解决这个问题!

function process(data) {
    // do something
}
{
    let someBigData = { ... }; //显式创建块作用域块,并用let声明劫持
    process(someBigData);
}

var btn = document.querySelector('#btn');
btn.addEventListener('click', event => console.log("clicked"));

提升hoisting

var

对于以下代码:

a = 3.14;
var a;
console.log(a); // => 3.14

console.log(b); // => undefined
var b = 3.14; // 赋值表达式没有被提升

可知声明优先于执行,(和C语言的#预变量类似)。

在使用 var 声明变量时,变量会被自动添加到最接近的上下文。由于声明会被提升,JavaScript引擎会自动将多余的声明在作用域顶部合并为一个声明。

var 声明会被拿到函数或全局作用域的顶部,位于作用域中所有代码之前。但是函数表达式赋值表达式是不会被提升的。

function fun(){
    console.log(a); // => undefined
    var a = 3.14;
}

//等价于
function fun(){
    var a;  //提升到函数作用域顶部
    console.log(a); // => undefined
    a = 3.14;
}

函数表达式

{
    var n = 3.14; //被提升到全局作用域
    let funExpression = function() { 
        console.log('hello world');
    }
    function funDeclare(){ 
        console.log('hi');
    }
}
n; // => 3.14
funDeclare();// => 'hi' 同样被提升到全局作用域
funExpression();// ReferenceError

缺省情况下声明的变量会被添加到全局作用域

function mult(a, b){
    var result = a*b; //被提升到函数作用域顶部
    return result;
}
mult(2, 3); // => 6
console.log(result); // ReferenceError: result is not defined

function add(a, b){
    result = a+b; // 被提升到全局作用域
    return result;
}
add(2, 3); // => 5
console.log(result); // => 5

函数优先于变量

函数会首先被提升,然后才是变量

foo(); // 1
var foo; // 上面所说的重复声明被忽略
                // 由于声明会被提升,JavaScript引擎会自动将多余的声明在作用域顶部合并为一个声明
function foo() { 
    console.log( 1 );
}
foo = function() { 
    console.log( 2 );
};

//js引擎所理解的:
function foo() { 
    console.log( 1 );
}
foo(); // 1
foo = function() { 
    console.log( 2 );
};

闭包 Closure

闭包一直是JavaScript中比较晦涩难懂的概念。在计算机科学中是这样定义闭包的 —> 函数对象与作用域(一组变量绑定)组合起来解析函数变量的机制。

而JS为了实现其词法作用域,其函数对象不仅要包含函数代码,还得包括对函数定义所在的作用域的引用

闭包指的是那些引用了另一个函数作用域中变量的函数

对于以下代码:

function outter() {
    let scope = "hello world";
    function inner(){
        console.log(scope);
    }
}
outter(); // "hello world"

上面代码还不能说算真正意义的闭包,只能说是词法作用域查找规则。

一个典型诠释闭包的例子就是 函数的返回值是一个函数对象

let scope = "global scope";
function checkscope() {
    let scope = "local scope";
    function fun(){
        console.log(scope);
    }
    reutrn fun;
}
let f = checkscope();
f(); // "local scope" 这就是闭包的作用

checkscope()函数调用后返回fun()函数的引用,然后赋值给变量f,正是因为fun()函数对象依然含有对定义自己函数的作用域的引用,使得函数调用正常执行。可以看到的是,fun()在自己定义的词法作用域以外的地方执行了。

阻止垃圾回收

在上面代码中,函数checkscope() 执行完毕后通常其整个作用域都被销毁,因为引擎引擎有垃圾回收器用来释放不再使用的内存空间。但是闭包的神奇之处就是能够阻止其被回收,这是因为fun()函数声明的位置在checkscope()内部,它覆盖了checkscope()内部作用域的闭包,使得作用域能够一直存活。

回调函数与闭包

看一个经典定时器回调函数例子:

function wait(message) {
    setTimeout(function(){
        console.log(message);
    },1000);
}
wait("hello closure"); // 回调函数依旧包含对函数wait(...)作用域的闭包

深入到引擎的内部原理中,内置的工具函数 setTimeout(..) 持有对一个参数的引用,这个参数也许叫作 fn 或者 func,或者其他类似的名字。引擎会调用这个函数,在例子中就是 内部的 timer 函数,而词法作用域在这个过程中保持完整

IIFE和闭包

let n = 3.14;
(
    function IIFE() {
        console.log(n);
    }
)(); // => 3.14

循环和闭包

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

上面代码会输出数字6连续5次,并不是预期的1~5,显而易见,setTimeout()函数中回调函数(形成闭包!)会在for循环之后才能被执行,而i的值最后是6,以上代码等价于下面的代码:

for(var i = 1; i<= 5; i++){}; // i => 6
setTimeout(() => console.log(i), 1000);  // 6
setTimeout(() => console.log(i), 2000);  // 6
setTimeout(() => console.log(i), 3000);  // 6
setTimeout(() => console.log(i), 4000);  // 6
setTimeout(() => console.log(i), 5000);  // 6

我们看到for语句参数是用var关键字进行声明定义的,所以变量提升,具有函数作用域,循环结束后,重复的var声明合并为最后一个声明(即i = 6),而这些回调函数共用该词法作用域,i的值始终为6.

因此我们需要更多的闭包(作用域绑定)

for (var i = 1;i <=5; i++){
    (function() {
        var j = i; // j记住了i的值,并绑定到了匿名函数 
                             // 此时最后的值为5,因为for内部的判断条件已经不满足了,i为6的值赋值不了
        setTimeout(() => console.log(j), j*1000);
    })();
}
// => 1 2 3 4 5

使用let关键字(块级作用域)

将一个块转换成一个可以被关闭的作用域。

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

// 终极形态
for(let i = 1;i <=6; i++) {
    setTimeout(function() {
        console.log(i);
    }, i*1000);
}

JavaScript 引擎会为 for 循环中的 let 声明分别创建独立的变量实例

Reference: