目录

🚈 JavaScript 变量作用域与闭包

普遍 JavaScript 有三种声明变量的方法: let / const / var (已经不被推荐使用),这里一般使用 let 或者 const 声明变量解释 变量的作用域。(因为 var 的声明变量比较特殊。)

# 变量作用域

# 代码块

{ } 看作是一个代码块,如果在这个代码块中声明变量,那么这个变量就只能在这个代码块中 可见

🌰 例子 :

{
  let msg = 'hello'
  console.log(msg)
}

console.log(msg) // Error: msg is not defined
1
2
3
4
5
6

🌰 例子 / 利用 代码块的特性 声明仅属于该代码块的变量:

{
  let msg = 'hello'
  console.log(msg)
}

{
  let msg = 'goodbye'
  console.log(msg)
}
1
2
3
4
5
6
7
8
9

如果 msg 不分别放在对应的代码块,则会报错。因为使用 let 声明变量,不能重复声明同一个变量名称的变量,否则报错。

🌰 例子 / 对于 if / for / while{ ... } 中声明的变量也仅在内部可见。

if (true) {
  let phrase = 'hello'
  console.log(phrase)
}

console.log(phrase) // phrase 变量不可见
1
2
3
4
5
6

就算是, if 条件符合运行后也是取不到 phrase 变量。

对于 for

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

console.log(i)
1
2
3
4
5

虽然变量 i 的声明不在 { ... } 内,但是在 for 中声明的变量也被视为块的一部分。

# 嵌套函数

如果一个函数是在另一个函数中创建的,该函数就被称为「嵌套」函数。

🌰 例子:

function sayHiBye(firstName, lastName) {
  function getFullName() {
    return firstName + " " + lastName;
  }
  
  console.log("Hello, " + getFullName())
  console.log("Bye, " + getFullName())
}
1
2
3
4
5
6
7
8

这里创建的 嵌套 函数 getFullName() 是为了更加方便,可以访问外部变量,因此可以返回全名。嵌套函数在 JavaScript 中很常见。

🌰 例子 / 返回一个嵌套函数作为新对象的属性或者作为结果返回,之后可以在其他地方使用。不论在哪里调用,它仍然可以访问相同的外部变量:

function makeCounter() {
  let count = 0
  return function() {
    return count++
  }
}
1
2
3
4
5
6

makeCounter 创建了一个返回 counter 嵌套函数,该函数在每次调用时返回下一个数字。

嵌套函数稍加变型就可以有很强的实际用途,比如可以封装一个 随机数生成器 生成用于自动化测试的随机数值。

# 词法环境

# 对于变量

在 JavaScript 中,每个运行的函数、代码块 {...} 以及整个脚本,都有一个被称为 词法环境 的内部(隐藏)的关联对象。有两部分组成:

  • 环境记录:存储所有局部变量作为其 属性(包括一些信息,例如 this 的值)的对象。
  • 外部词法环境 的引用,与外部代码 相关联

「一个变量」 只是这个特殊的内部对象的一个属性。所以 「获取或者修改变量」 意味着 「获取或修改 词法环境 的一个属性」

  • 变量是 词法环境 的属性,与当前正在执行的代码有关。
  • 操作变量实际上是操作该对象的属性。

🌰 例子 :一个变量被声明和修改值的过程:

// excution start  ---- phrase <uninitialized> => null

let phrase; //     ---- phrase: undefiend

phrase = 'hello'// ---- phrase: 'hello'
phrase = 'bye'  // ---- phrase: 'bye'
1
2
3
4
5
6
  • 起初当脚本开始运行,词法环境先填充了所有声明的变量。

    最初,它们处于「未初始化(Uninitialized)」状态。这是一种特殊的内部状态,这意味着引擎知道变量,但是在用 let 声明前,不能引用它。几乎就像变量不存在一样。

  • 然后执行 let phrase 定义。它尚未被赋值,因此它的值为 undefined 。从这一刻起,就可以使用变量了。

  • phrase 被赋予了一个值。

  • phrase 的值被修改。

词法环境是一个 规范对象: 意味着它仅仅是存在于 编程语言规范 中的 理论上 存在的,用于描述事物如何运作的对象,并不能在代码中获取该对象并且直接对其进行操作。

但是 JavaScript 引擎同样可以对它优化,比如 清除未被使用的变量以节省内存和执行其他内部技巧等。但是显性行为仍然不能直接操作。

# 对于函数声明

函数与变量相同,也可以看作是一个 。不同之处在于 函数声明的初始化会被立即完成

当创建了一个词法环境,函数声明会立即变为 即用型函数(不像 let 那样直到声明处才可用)。这就是为什么函数可以在声明之前调用函数声明。

🌰 例子:

// execution start --- phrase <uninitialized> outer=>null
//                      say: function

let phrase = 'hello' // --- ...

function say(name) {
  console.log(`${phrase}. ${name}`)
} 
1
2
3
4
5
6
7
8

这种行为只使用函数声明。不使用将函数分配给变量的函数表达式,如 let say = function(name)

# 内部和外部的词法环境

在一个函数运行时,在调用刚开始时,会自动创建一个 新的词法环境 以存储这个调用的 局部变量和参数

🌰 例子:

let phrase = 'hello'

function say(name) {
  console.log(`${phrase}. ${name}`)
}

say('John')
1
2
3
4
5
6
7
// lexical environment of the call
// name: 'john' outer=>| say: function    outer=> null
//                     | phrase: 'hello'
1
2
3

在这个函数调用期间,有两个词法环境,内部一个(函数调用)和外部一个(全局)。

  • 内部词法环境与函数当前的执行相对应。具有一个单独的属性 name (函数的参数)。调用该函数传入的是 John ,所以 nameJohn
  • 外部词法环境是全局词法环境。具有 phrase 变量和函数本身。

内部词法环境引用了 outer 外部的词法环境。当代码要访问一个变量时,首先会搜索内部词法环境,然后搜索外部环境,然后搜索更外部的环境,以此类推,直到全局词法环境。

如果在任何地方都找不到这个变量,那么在严格模式下就会报错(在非严格模式下,为了向下兼容,给未定义的变量赋值会创建一个全局变量)。

在上面的例子中,搜索的过程:对于 name 变量,当 say 中的 console.log() 试图访问 name 时,会立即在内部词法环境中找到它;当它试图访问 phrase 时,然而内部没有 phrase ,所以它顺着对 外部词法环境的引用 找到了它。

# 对于函数调用

makeCounter 例子中:

function makeCounter() {
  let count = 0;

  return function() {
    return count++;
  };
}

let counter = makeCounter();
1
2
3
4
5
6
7
8
9

image-20220509144248862

在每次 makeCounter() 调用的开始,都会创建一个新的词法环境对象,以存储该 makeCounter 运行时的变量。因此有两层的 嵌套 词法环境。

不同的是,在执行 makeCounter() 的过程中创建了一个仅占一行的嵌套函数: return count++ ,尚未运行它,仅创建了它。

所有的函数在「诞生」时都会记住创建它们的词法环境。:所有函数都有名为 [[Environment]]隐藏属性,该属性保存了对创建该函数的词法环境的引用。

image-20220509144307948

因此, counter.[[Environment]] 有对 {count: 0} 词法环境的引用。这就是函数记住它创建于何处的方式,与函数被在哪儿调用无关。

[[Environment]] 引用在函数创建时被设置并永久保存

当调用 counter() 时,会为该调用创建一个 新的词法环境,并且其外部词法环境引用获取于 counter.[[Environment]]

image-20220509144332128

现在,当 counter() 中的代码查找 count 变量时,它首先搜索自己的词法环境(为空,因为那里没有局部变量),然后是外部 makeCounter() 的词法环境,并且在哪里找到就在哪里修改。

在变量所在的词法环境中更新变量,执行后的状态:

image-20220509144426707

如果调用 counter() 多次, count 变量将在同一位置增加到 23 等。

# 闭包

记住 外部变量 并且可以访问这些变量的 函数。在 JavaScript 中,所有的函数都是 「天生」闭包(一个例外:)

JavaScript 中的函数会自动通过隐藏的 [[Environment]] 属性记住创建它们的位置,所以它们都可以访问外部变量。

🍎 什么是闭包?

  • 闭包的定义。
  • JavaScript 中的函数为什么都是闭包。
  • 关于 [[Environment]] 属性和词法环境原理的技术细节。

# 垃圾收集

通常在函数调用完成后,会将 词法环境和其中的所有变量 从内存中删除。因为现在没有任何对它们的引用了(「不可达」)。与 JavaScript 中的任何其他对象一样,词法环境仅在可达时才会被保留在内存中。

但是,如果有一个嵌套的函数在函数结束后 仍可达,则它将具有 引用词法环境[[Environment]] 属性。

🌰 例子 / 即便在外部函数执行完成后,它的词法环境仍然可达:

function f() {
  let value = 123;

  return function() {
    console.log(value);
  }
}

let g = f(); // g.[[Environment]] 存储了对相应 f() 调用的词法环境的引用
1
2
3
4
5
6
7
8
9

注意,如果多次调用 f() ,并且返回的函数被保存,那么所有相应的词法环境对象也会保留在内存中。

多次调用 f()

let arr = [f(), f(), f()]
1

当嵌套函数被删除后,其封闭的词法环境(以及其中的 value )也会被从内存中删除:

function f() {
  let value = 123;

  return function() {
    alert(value);
  }
}

let g = f() // g 函数存在时,该值会被保留在内存中
g = null    // g 函数不存在,从内存中删除
1
2
3
4
5
6
7
8
9
10

# 实际开发

理论上当函数可达时,它外部的所有变量也都将存在。

但在实际中,JavaScript 引擎会试图优化它。它们会分析变量的使用情况,如果从代码中可以明显看出有未使用的外部变量,那么就会将其删除。

🌰 例子 / 在 V8 引擎中(Chrome,Edge,Opera)此类变量在调试中将不可用。

function f() {
  let value = Math.random();

  function g() {
    debugger; // 在 Console 中:输入 alert(value); No such variable!
  }

  return g;
}

let g = f();
g();
1
2
3
4
5
6
7
8
9
10
11
12

虽然理论上应该可以访问,但是引擎把它优化掉了。

🌰 例子 / 引擎优化坑导致有趣的调试问题,有同名的外部变量但是不是预期的变量。

let value = 'surprise'

function (f) {
  let value = 'the closest value'
  
  function g() {
    debugger
  }
  
  return g
}

let g = f()
g()
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# 例子

# 函数的最新内容

函数 sayHi 使用外部变量。当函数运行时,将使用哪个值?

let name = 'john'

function sayHi() {
  alert("Hi, " + name);
}

name = "Pete";
1
2
3
4
5
6
7
点击查看

浏览器和服务器端开发中都很常见。一个函数可能被计划在创建之后一段时间后才执行,例如在用户行为或网络请求之后。

旧变量值不会保存在任何地方。当一个函数想要一个变量时,它会从自己的词法环境或外部词法环境中获取当前值。

# 可用的变量

function makeWorker() {
  let name = "Pete";

  return function() {
    alert(name);
  };
}

let name = "John";

// create a function
let work = makeWorker();

// call it
work(); 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
点击查看

函数 work() 在其被创建的位置通过外部词法环境引用获取 name

image-20220509150752513

但如果在 makeWorker() 中没有 let name ,那么将继续向外搜索并最终找到全局变量,正如可以从上图中看到的那样。在这种情况下,结果将是 "John"

# counter 独立性

用相同的 makeCounter 函数创建了两个计数器(counters): countercounter2

unction makeCounter() {
  let count = 0;

  return function() {
    return count++;
  };
}

let counter = makeCounter();
let counter2 = makeCounter();
1
2
3
4
5
6
7
8
9
10
点击查看

函数 countercounter2 是通过 makeCounter 的不同调用创建的。

因此,它们具有独立的外部词法环境,每一个都有自己的 count

# if 内的函数

let phrase = "Hello";

if (true) {
  let user = "John";

  function sayHi() {
    alert(`${phrase}, ${user}`);
  }
}

sayHi();
1
2
3
4
5
6
7
8
9
10
11
点击查看

if 代码块中定义函数 sayHi ,所以它只存在于 if 中。外部是没有 sayHi 的。所以运行会报错。

# 双括号的函数

sum(1)(2) = 3
sum(5)(-1) = 4
1
2
点击查看
function sum(a) {
  return function(b) {
    return a + b
  }
}
1
2
3
4
5

# 变量是否可见

在下面这段代码中:

let x = 1
function func() {
  console.log(x)
  let x = 2
}

func()
1
2
3
4
5
6
7
点击查看

可以看出「不存在变量」和「未初始化」 变量之间的特殊差异。

  • 从程序执行进入代码块(或函数)的那一刻起,变量就开始进入「未初始化」状态。它一直保持未初始化状态,直至程序执行到相应的 let 语句。
  • 一个变量从技术的角度来讲是存在的,但是在 let 之前还不能使用。

所以上面的代码:虽然知道 局部变量 x ,但是 x 一直处于未初始化的状态(无法使用),直到死区结束。

死区:变量暂时无法使用的区域,从代码块到 let

# 通过嵌套返回函数筛选数组元素

arr.filter(func) 方法可以通过函数 func 过滤函数,如果函数返回 true ,则元素会被返回到结果数据。

制造一系列 “即用型” 过滤器:

  • inBetween(a, b) —— 在 ab 之间或与它们相等(包括)。
  • inArray([...]) —— 包含在给定的数组中。

用法如下所示:

  • arr.filter(inBetween(3,6)) —— 只挑选范围在 3 到 6 的值。
  • arr.filter(inArray([1,2,3])) —— 只挑选与 [1,2,3] 中的元素匹配的元素。
点击查看
function inBetween(a, b) {
  return function(x) {
    return x >= a || x <= b
  }
}
1
2
3
4
5
function inArray(arr){
  return function(x) {
    return arr.includes(x)
  }
}
1
2
3
4
5

# 通过嵌套返回函数按字段排序对象

有一组要排序的对象:

let users = [
  { name: "John", age: 20, surname: "Johnson" },
  { name: "Pete", age: 18, surname: "Peterson" },
  { name: "Ann", age: 19, surname: "Hathaway" }
];
1
2
3
4
5

通常做法:

user.sort((a, b) => a.name > b.name ? 1 : -1)
1

或者

user.sort((a, b) => a.age > b.age ? 1 : -1)
1

要实现 只输入一个字段名称,就实现排序,如下:

users.sort(byField('name'))
users.sort(byField('age'))
1
2

:::

编写函数 byField

function byField(fieldName){
    return ((a, b) => a[fieldName] > b[fieldName] ? 1 : -1) 
}
1
2
3

:::

# 函数大军

在下面代码中,期望每次循环到的 添加到数组中的 输出 shooter 的编号函数。但是最后调用时的都是同样的值

function makeArmy(){
  let shooters = []
  
  let i = 0
  while(i < 10) {
    let shooter = function() {
      console.log(i)
    }
    shooters.push(shooter)
    i++
  }
  
  return shooters
}

let army = makeArmy()
army[0]() // 10
army[1]() // 10
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
点击查看

分析代码的运行过程:

  1. 首先创建了空数组 shooters

  2. while 循环中,通过 shooters.push(shooter) 填充 shooter 函数,相当于:

    shooters = [
      function () { console.log(i); },
      function () { console.log(i); },
      function () { console.log(i); },
      function () { console.log(i); },
      function () { console.log(i); },
      function () { console.log(i); },
      function () { console.log(i); },
      function () { console.log(i); },
      function () { console.log(i); },
      function () { console.log(i); },
    ];
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
  3. 然后返回这个数组。

  4. 创建数组并且填充数组之后,试图对数组中的任意数组项的函数进行调用。

所有的数组项函数都输出同一个数值 i 的原因,因为 i 是来自这个函数的外部 词法环境的。

  • 所有的 shooter 函数都是在 makeArmy() 的词法环境中被创建的。
  • army[5]() 被调用时, makeArmy 已经运行完了,最后 i 的值为 10while 循环在 i=10 时停止)。
  • 因此,所有的 shooter 函数获得的都是外部词法环境中的同一个值,即最后的 i=10

image-20220509171707254

while {...} 块的每次迭代中,都会创建一个新的词法环境。

要解决这个问题,可以通过

  • i 的值复制到 while {...} 块内的变量中:

    // ... 
    let i = 0 
    while (i < 10) {
          let j = i;
          let shooter = function() { // shooter 函数
            alert( j ); // 应该显示它自己的编号
          };
        shooters.push(shooter);
        i++;
      }
    // ... 
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11

    let j = i 声明了一个 “局部迭代” 变量 j ,并将 i 复制到其中。原始类型是「按值」复制的,因此实际上得到的是属于当前循环迭代的独立的 i 的副本。

    shooter 函数正确运行了,因为 i 值的位置更近了(译注:指转到了更内部的词法环境)。不是在 makeArmy() 的词法环境中,而是在与当前循环迭代相对应的词法环境中:

    image-20220509171920737

  • 使用 for 循环替代 while 循环:

    function makeArmy() {
      let shooters = [];
      for(let i = 0; i < 10; i++) {
        let shooter = function() { // shooter 函数
          alert( i ); // 应该显示它自己的编号
        };
        shooters.push(shooter);
      }
      return shooters;
    }
    
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11

    for 循环在每次迭代中,都会生成一个带有自己的变量 i 的新词法环境。因此,在每次迭代中生成的 shooter 函数引用的都是自己的 i

    image-20220509172053687

📢 上次更新: 2022/09/02, 10:18:16