变量、作用域、闭包

来自Wikioe
(差异) ←上一版本 | 最后版本 (差异) | 下一版本→ (差异)
跳到导航 跳到搜索


变量[MDN 1]

ECMAScript 6 中引入了 let、const。

JavaScript 有三种声明方式:

  1. var:声明一个变量,初始化可选。
  2. let:声明一个变量,初始化可选。
  3. const:声明一个常量,必须初始化。
“局部变量”/“全局变量”只与其定义位置有关:在“函数内部”/“函数之外”声明的变量

var:变量提升[MDN 2]

提升(Hoisting):变量和函数的声明会在物理层面(在编译阶段被放入内存中)移动到代码的最前面 —— 变量/函数的“初始化”和“使用”可以在“声明”之前(先使用再声明)。

函数和变量相比,会被优先提升 —— 函数会被提升到更靠前的位置。
  1. 变量提升[MDN 3]
    bla = 2
    var bla;
    
    // 可以理解为:
    
    var bla;
    bla = 2;
    
  2. 函数提升[MDN 4]
    hoisted(); // logs "foo"
    function hoisted() {
        console.log('foo');
    }
    
    // 可以理解为:
    
    function hoisted() {
        console.log('foo');
    }
    hoisted(); // logs "foo"
    
 JavaScript 只会提升声明,不会提升其初始化 —— 如果在“声明”和“初始化”之前“使用”,将使用 undefined
示例:
// “使用”先于“初始化”
console.log(num); // Returns undefined
var num;
num = 6;

// “初始化”先于“使用”
num = 6;
console.log(num); // returns 6
var num;
 函数表达式(function expressions)不会被提升 
示例:
notHoisted(); // TypeError: notHoisted is not a function

var notHoisted = function() {
    console.log('bar');
};

let:暂时性死区[MDN 5]

暂时性死区(Temporal dead zone,TDZ):从“代码块的开始”直到“声明变量的行”,letconst 声明的变量都处于“暂时性死区”中 —— 这并不是“提升”!!!
  1. 在“暂时性死区”中访问变量将抛出 ReferenceError
    { 
        // TDZ starts at beginning of scope
        console.log(bar); // undefined
        console.log(foo); // ReferenceError
        var bar = 1;
        let foo = 2; 
        // End of TDZ (for foo)
    }
    
  2. “暂时性死区”取决于“执行顺序”(时间),而非“代码顺序”(位置):
    {
        // TDZ starts at beginning of scope
        const func = () => console.log(letVar); // OK
        let letVar = 3; 
        // End of TDZ (for letVar)
    
        func(); // Called outside TDZ!
    }
    

var 与 let 的区别[MDN 3][MDN 5]

var 与 let
\ var let
块作用域
变量提升
重新声明
var user = "Pete";
var user = "John";
console.log(user); // John
let user;
let user; // SyntaxError: 'user' has already been declared

作用域[MDN 6][Wikioe 1][1]

作用域是当前的执行上下文,值和表达式在其中“可见”或可被访问。
  1. 全局作用域:可被当前文档中的任何其他代码所访问;
  2. 函数作用域:可被当前函数内部的其他代码所访问;
  3. 块作用域:可被当前(由 { } 包围)内部的其他代码所访问;
    • 仅 let、const 支持
ECMAScript 6 之前没有“块作用域”:语句块中声明的变量将成为语句块所在函数(或全局作用域)的局部变量。

作用域链

作用域链:由于作用域的层层嵌套而形成的关系。

作用域链用于变量的查找过程:

var x = 10

function outer() {
    var x = 20
    function inner() {
        console.log(x)
    }
    inner()
}
outer() // 20
以上代码,包括三个作用域:
  1. 全局作用域:包含变量 x(=10)
  2. 函数作用域(outer):包含变量 x(=20)
  3. 函数作用域(inner):不包含变量
查找过程:
  1. console.log(x) 将在“函数作用域(inner)”中查找变量“x”:失败
  2. 在“函数作用域(outer)”查找变量“x”:成功(x = 20)
  3. 使用该变量:输出 20

作用域模型:词法作用域[2]

从语言的层面来说,作用域模型分两种:(根据作用域的生成时机)

  1. 静态作用域:在代码编写时完成划分,作用域沿以定义位置向外延伸
    • 也称“词法作用域”,是最为普遍的一种作用域模型
  2. 动态作用域:在代码运行时完成划分,作用域链以调用栈向外延伸
    • 由 bash、Perl 等语言所使用

词法作用域(Lexical Scope):即“词法分析时生成的作用域”,根据源代码中“声明变量的位置”来确定该变量在何处可用

示例:

let x = "Eijux";

function getX() {
  return x;
}

console.log(getX()) // Eijux
变量 x 的“词法作用域”为“全局作用域”(定义时的作用域)而非“getX 函数作用域”


 一个声明(定义变量、函数等)的“词法作用域”就是其“定义时的作用域” —— 注意:并不是“调用时的作用域”

示例:

var x = 0

function foo() {
    var x = 1
    bar()
}
function bar() {
    console.log(x) // 0
}

foo()
由于“词法作用域”(定义时的作用域)决定了:foo、bar 的上级作用域是“全局作用域”,所以 bar 中使用的 x 来自“全局作用域”而非“foo 函数作用域”


欺骗“词法作用域”

虽然“词法作用域”在代码编写时就确定,但仍然可以修改:

  1. eval()[MDN 7]:将传入的字符串当做 JavaScript 代码进行执行
    • 有性能和安全隐患,不建议使用
  2. with[MDN 8]:扩展一个语句的作用域链
    • 有语义不明和兼容问题,已弃用
  1. 通过 eval() 修改词法作用域:
    var num = 10
    var str = "var num = 20"
    
    function fn(str) {
        eval(str)
        console.log(num)
    }
    
    fn(str)
    
  2. 通过 with 修改词法作用域:
    var x = { a: 1 }
    
    function fn(obj) {
        with (obj) {
            a = 2
        }
    }
    
    console.log(x.a) // 1
    fn(x)
    console.log(x.a) // 2
    

闭包[MDN 9][javascript.info 1]

闭包(closure)函数以及其捆绑的词法环境(lexical environment)[Wikioe 1]的组合

—— “词法环境”包含了这个闭包创建时作用域内的任何局部变量

在 JavaScript 中,闭包会随着函数的创建而被同时创建。


示例:

function makeAdder(x) {
    // 返回一个函数
    return function (y) {
        return x + y
    }
}

var add5 = makeAdder(5) // 闭包
var add10 = makeAdder(10) // 闭包

console.log(add5(2));  // 7
console.log(add10(2)); // 12
以上代码包含两个闭包 add5、add10,二者:
  1. 共享相同的“函数定义”:makeAdder
  2. 保存不同的“词法环境”:add5 中 x 为 5,add10 中 x 为 10
简单来说,闭包让开发者可以从内部函数访问外部函数的作用域

一个有意思的例子[TypeScript 1]

一个涉及到“Event Loop”、“var 作用域”、“闭包”(“词法环境”)的例子。

对于以下代码:

for (var i = 0; i < 10; i++) {
    setTimeout(function() { console.log(i) }, 100 * i)
}
其输出如何 ❓
是否预期输出:
0
1
2
...
9
但,实际输出:
10
10
10
...
10

原因分析:

  1. 【var 作用域】:var 变量的作用域在 for 外部 —— 每次循环使用的都是同一个 i
    • var 变量是没有“块作用域”的 !!!
  2. 【闭包】:for 循环将会创建 10 个闭包,它们:
    共享相同的“函数定义”: function() { console.log(i) }
    共享相同的“词法环境”(“变量环境”),其“环境记录器”将使用同一个 var 变量 —— i
  3. 【Event Loop】:setTimeout 是一个任务,将在 for 所有循环完成之后执行 —— 而 i 在所有循环之后其值为 10

综上:各个闭包将在 for 所有循环完成之后,使用同一个 i 值(10)来执行函数。


解决问题有多种方式: 总之,使闭包不再使用相同的词法环境[Wikioe 1] 即可

思路一: 若“函数”执行依赖于“外部变量”,则使“词法环境”的“外部环境引用”不同

思路二: 若“函数”执行依赖于“内部变量 / 参数”,则使“词法环境”的“环境记录”不同

思路一:函数的执行依赖于“外部变量”,则“使用 let / const(块作用域)”保证每次迭代的“词法环境”的“外部环境引用”不同

  1. 使用 let 替换 var
    for (let i = 0; i < 10; i++) {
        setTimeout(function() { console.log(i) }, 100 * i)
    }
    
  2. 使用 let/const 捕获 var
    for (var i = 0; i < 10; i++) {
        let v = i
        setTimeout(function() { console.log(v) }, 100 * v)
    }
    

思路二:若“函数”执行依赖于“参数”,则“使用参数捕获外部变量”保证每次迭代的“词法环境”的“环境记录”不同

  1. 通过“立即执行的函数表达式(IIFE)[MDN 10][javascript.info 2]: —— 【将产生不必要的闭包(嵌套闭包)】
    for (var i = 0; i < 10; i++) {
        (function(v) {
            setTimeout(function() { console.log(v) }, 100 * v)
        })(i)
    }
    
  2. 通过“向函数传递变量作为参数”: —— 【没有不必要的闭包】
    for (var i = 0; i < 10; i++) {
        setTimeout(function(v) { console.log(v) }, 100 * i, i)
    }
    
    等同于:
    function fn(v) {
        console.log(v)
    }
    
    for (var i = 0; i < 10; i++) {
        setTimeout(fn, 100 * i, i)
    }
    

补充内容

var 没有“块作用域”[javascript.info 2]

 var 变量没有“块作用域”:将会使用(块所在的)“函数作用域”或“全局作用域” —— 这是因为在早期的 JavaScript 中,块没有词法环境[Wikioe 1]

var 将透传 if、for 和其它代码块

示例一:

if (true) {
    var x = 5;
}
console.log(x); // 5


if (true) {
    let y = 5;
}
console.log(y); // ReferenceError: y is not defined

示例二:

function f() {
    console.log(tmp);
    if (false) {
        var tmp = 'hello world';
    }
}

f(); // undefined
由于变量提升:即使 var 定义的代码块永远不会执行,变量也存在,但初始化不会执行

let 会不会“提升”?

关于这一点,MDN 都描述得前后不一致,也是很操蛋了……

1、MDN:《语法和数据类型》[MDN 1]:
    在 ECMAScript 6 中,let和const同样会被提升变量到代码块的顶部但是不会被赋予初始值。在变量声明之前引用这个变量,将抛出引用错误(ReferenceError)。这个变量将从代码块一开始的时候就处在一个“暂时性死区”,直到这个变量被声明为止。

2、MDN:《语法和数据类型》[MDN 5]:
    var 和 let 的另一个重要区别,let 声明的变量不会在作用域中被提升,它是在编译时才初始化(参考下面的暂时性死区)。

两种说法,以一个类似示例来说明:

var foo = 33;
if (foo) {
    console.log(foo) // ReferenceError: Cannot access 'foo' before initialization
    let foo = 44;
}

对于 ReferenceError 该如何解释呢 ❓

  1. 说法一:由于 let 变量提升,导致此处使用“块作用域内定义变量”,但该变量处于“暂时性死区”,所以出现此错误。
    网上有内容更是强行将 let 变量创建分为三个过程:创建、初始化、赋值 …… 有点扯。
  2. 说法二:由于词法作用域,导致此处使用“块作用域内定义变量”,但该变量处于“暂时性死区”,所以出现此错误。
💡 个人理解:let 并不会提升,以上示例是“词法作用域”影响。—— 或许看看 JS 引擎执行过程更清楚

什么是“隐式声明的全局变量”?

隐式声明的全局变量:直接对一个未声明的变量赋值,无论在函数内外都会将其“隐式地”声明为一个“全局变量”

示例,以下变量全都是“隐式声明的全局变量”:

x = '?'

function fn() {
    x = 'hello'
    y = 'bye'
}

fn()
console.log(x, y) // hello bye
严格模式下,“对一个未声明的变量赋值”将抛出 ReferenceError
  1. 非严格模式:
var x = 0;
function f() {
    var x = y = 1;
}
f();

console.log(x, y); // 0 1
  1. 严格模式:
'use strict';

var x = 0;
function f() {
    var x = y = 1; // ReferenceError: y is not defined
}
f();

console.log(x, y);

参考

Wikioe

MDN

javascript.info

TypeScript

其他