前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Environments: JS变量查找的“罪魁祸首”

Environments: JS变量查找的“罪魁祸首”

作者头像
前端柒八九
发布2022-08-25 14:14:56
6390
发布2022-08-25 14:14:56
举报
文章被收录于专栏:柒八九技术收纳盒

❝累计的量变形成好几次质变后,彼此就不在同一个维度了 ❞

简明扼要

  1. Environment:管理变量的「数据结构」(字典类型)
  2. 作用域和Environment是「一一对应」
  3. 函数每次被调用,都需要为函数的变量(参数和局部变量)提供「新的存储空间」(Environment对象)
  4. 执行上下文是对Environment对象的「引用」
  5. Environment对象是存放在堆内存(heap)中的
  6. 每个作用域的环境变量通过一个称为outer的字段指向外部作用域的环境
  7. 每个函数对象都有一个名为[[Scope]]的内部属性,该属性指向它的「诞生环境」
  8. 环境变量在两个方面影响变量 1. 静态:通过每个环境变量的outer指针构建的作用域链反应了作用域之间的嵌套关系 2. 动态:执行上下文的堆栈反应了函数调用关系
  9. 将具有N个参数的函数转换为N个各具有一个参数的嵌套函数,称为「柯里化」(currying)

文章概要

  1. Environment:管理变量的数据结构
  2. 函数调用与Environment
  3. 作用域链与Environment
  4. 闭包与Environment

在我们之前的文章中,不管是讲作用域、闭包还是全局变量。Environment都作为关键部分,出现好多次。所以,今天我们从多方面来讨论一下Environment。(有些知识点可能在前面的文章中涉及到)

1. Environment:管理变量的数据结构

在ECMAScript规范定义中,Environment是用于管理变量的「数据结构」:它是字典类型,键值(key)是变量名,值(value)是对应存储变量的值(7+1:7种基本类型,1种引用类型[Object])。

每一个作用域都有与之关联的Environment,即:

❝作用域和Environment是「一一对应」的 ❞

同时,Environment能够支持如下与变量相关的语法特性:

  • 函数调用
  • 作用域链
  • 闭包

接下来,我们依次来解释Environment与各个语法特性直接的关系。

2. 函数调用与Environment

存在如下的函数调用关系:

代码语言:javascript
复制
function f(x) {
  return x * 2;
}
function g(y) {
  const tmp = y + 1;
  return f(tmp);
}
// 函数调用
g(4) == 10 // true 

函数每次被调用,都需要为函数的变量(参数和局部变量)提供「新的存储空间」(Environment对象)。V8通过被称为执行上下文(execution contexts)的栈结构(stack)来管理这些存储空间,而执行上下文是对Environment对象的「引用」

❝而Environment对象是存放在堆内存(heap)中的 ❞

之所以Environment对象被存放于堆内存,是因为虽然函数执行完(从执行上下文中出栈[pop]),但是在其他变量引用了该变量环境中的变量(形成了闭包)。

从V8垃圾回收的角度解释,刚才的环境变量处于可达到(reachable),即:GC不会把该部分的数据清除。而如果直接存放在执行上下文里的话,在函数执行完,也就是函数的栈帧被pop后,该部分的数据是无法被访问的。(说的有点远,后期会有专门针对GC的文章)

反正,绕来绕去,记住一点就是「Environment对象是存放在堆中的」

代码解析

我们模拟V8处理函数的过程,来窥探下执行上下文、作用域之间的关系。

代码语言:javascript
复制
function f(x) {
  // 断点 3
  return x * 2;
}
function g(y) {
  const tmp = y + 1;
  // 断点 2
  return f(tmp);
}
// 断点1
g(3) 
  • 断点1:执行g()之前
  • 断点2:执行g()
  • 断点3:执行f()

还有一点需要指出:每次执行到return时,就会从栈中删除(pop)一个执行上下文。

断点1:执行g()之前

此时,执行上下文堆栈只有一条记录,并且该记录指向全局作用域。在全局作用域中存在两个记录

  • 一个指向f()
  • 另外一个指向g()

断点2:执行g()

执行上下文的顶层记录(序号为1)指向由调用g()而生成的环境变量。在该环境变量中包含g()调用时需要的变量信息。

  • 参数y = 3
  • 局部变量 tmp = 4

断点3:执行f()

同断点2类似,执行上下文的顶层记录指向由调用f()而生成的环境变量。

3. 作用域链与Environment

接着,我们继续探索作用域链是如何通过Environment实现的。

代码语言:javascript
复制
function f(x) {
  function square() {
    const result = x * x;
    return result;
  }
  return square();
}
//函数调用
f(6)

上面代码中,存在3个作用域:全局作用域,函数作用域f()和内部函数作用域square()

通过平时开发和查询一些资料,我们可以得出两个结论:

  1. 内嵌作用域之间是存在联系的。内部作用域有权访问外部作用域的所有变量(除去一些和本地变量同名的变量)
  2. 「作用域链是和函数调用不同的机制」
  • 函数调用是通过执行上下文(stack)来引用一组「互不相关」的环境变量。
  • 作用域链保存着每个环境和创建该环境的外部环境之间的关联关系。

❝每个作用域的环境变量通过一个称为outerEnv(简称outer)的字段指向外部作用域的环境。 ❞

当我们查找一个变量的值时,我们首先在当前环境中搜索它的名称,如果当前环境没有;然后在外部环境中搜索,外部环境也没有;然后在外部环境的外部环境中搜索,一直搜到全局作用域,如果全局作用域也没有该变量,那该变量就是undefined

每一次的函数调用,都会创建一个新的环境变量。该环境变量的外部环境就是「定义」该函数的所在的环境。每个函数对象都有一个名为[[Scope]]的内部属性,该属性指向它的「诞生环境」,并且这个属性的值是在编译阶段被赋值的。

代码解析

代码语言:javascript
复制
function f(x) {
  function square() {
    const result = x * x;
    // 断点 3
    return result;
  }
  // 断点 2
  return square();
}
// 断点 1
f(6)
  • 断点1:执行f()之前
  • 断点2:执行f()
  • 断点3:执行square()

断点1:执行f()之前

此时,全局作用域有一个针对f()的记录。也就是说f()的诞生环境是全局作用域,因此,函数对象(f)的内部属性[[Scope]]指向全局作用域。

在JS全局变量中讲过,在全局作用域下,针对函数声明的变量是存放在变量环境对象中,同时JS中一切皆对象,函数变量也是一种变量类型。并且,该函数变量的初始化是在V8的编译阶段(变量提升)。最后的结果就是,在f()还没执行之前,在全局环境下已经存在了对应的变量,并且该变量指向了与之对应的函数对象。

断点2:执行f()

由于调用了f(6),此时会生成对应的环境变量。该环境变量的外部环境就是f()的诞生环境(全局环境,在作用域链的最顶层)。该环境变量的outer属性的值被赋为f函数[[Scope]]指向的值。同时,针对函数square的变量对象也被创建,于此同时,该对象的内部属性[[Scope]]被赋值为当前环境变量(通过调用f(6)创建)

断点3:执行square()

继续上述的处理模式。最近环境变量(调用square()生成的)的outer是通过对应函数变量的[[Scope]]来设置的。通过outer创建的作用域链,我们有权访问result/square/f

❝环境变量在两个方面影响变量 1. 静态:通过每个环境变量的outer指针构建的作用域链反应了作用域之间的嵌套关系 2. 动态:执行上下文的堆栈反应了函数调用关系 ❞


4. 闭包与Environment

还是老样子,通过一段代码来分析它们之间的关系。

代码语言:javascript
复制
function add(x) {
  return (y) => { // (A)
    return x + y;
  };
}
add(3)(1); // (B)

add()是一个返回函数的函数。当我们在B行调用嵌套函数add(3)(1)时,第一个参数用于add(),第二个参数用于它返回的函数。

为什么会这样呢?因为在A行创建的函数(箭头函数)在离开初始作用域时(调用add()生成的)不会失去与该作用域的连接。关联的环境通过该连接保持「鲜活」,并且函数仍然可以访问该环境中的变量x (x在内部函数中是有权访问的)。

这种调用嵌套函数add()的有一个优点:如果只进行第一次函数调用,就会得到一个版本的add(),它的形参x已经被填充了。

代码语言:javascript
复制
const plus2 = add(2);
plus2(5) == 7 //true

将具有N个参数的函数转换为N个各具有一个参数的嵌套函数,称为「柯里化」(currying)(这是函数式编程的概念)。add()函数就是被柯里化的函数。如果对React开发比较熟悉的同学,是不是会想到redux-middleWare,它也是利用柯里化处理参数的。

只填写函数的某些参数称为偏函数(partial application)。JS中Function.prototype.bind()就是偏函数的典型。

我们来简单讲一下bind():bind() 方法创建一个新的函数,在 bind() 被调用时,这个新函数的 this 被指定为 bind() 的第一个参数,而其余参数将作为新函数的参数,供调用时使用。

代码语言:javascript
复制
function addArguments(arg1, arg2) {
    return arg1 + arg2
}
// 创建一个函数,它拥有预设的第一个参数
var addThirtySeven = addArguments.bind(null, 37);
var result2 = addThirtySeven(5);
// 37 + 5 = 42
var result3 = addThirtySeven(5, 10);
// 37 + 5 = 42 ,第二个参数被忽略

跑偏了,我们回归到主线来。继续分析Environment。

代码解析

老样子,之间上代码。

代码语言:javascript
复制
function add(x) {
  return (y) => {
    // 断点 3: plus2(5)
    return x + y;
  }; // 断点 1: add(2)
}
const plus2 = add(2);
// 断点 2
plus2(5)
  • 断点1:执行add(2)
  • 断点2:add(2)执行之后
  • 断点3:执行plus2(5)

断点1:执行add(2)

从图中我可以看到几个信息:

  • 执行上下文的顶层记录指向由调用add(2)而生成的环境变量。
  • 通过add(2)返回的函数对象已经被实例化了(右下角),并且它的内部属性[[Scope]]指向了它的诞生环境:即当前执行环境(调用add(2)生成)。

虽然,函数对象被实例化,但是与之对应的变量plus2处于暂时性死区(temporal dead zone)并且值为undefined

断点2:add(2)执行之后

plus2指向了通过调用add(2)返回的函数。虽然add(2)已经从执行上下文堆栈中移除,但是由于plus2所指向的函数对象引用了add(2)的环境变量,使其还是处于可达到(reachable)。

断点3:执行plus2(5)

调用plus2(5)生成了对应的变量对象,并且将该变量对象的outer指针与plus2函数对象的[[Scope]]相等。通过outer将多个作用域进行关联,此时在plus2(5)中有权访问变量x

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2022-01-25,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 前端柒八九 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 简明扼要
  • 文章概要
  • 1. Environment:管理变量的数据结构
  • 2. 函数调用与Environment
    • 代码解析
      • 断点1:执行g()之前
      • 断点2:执行g()
      • 断点3:执行f()
  • 3. 作用域链与Environment
    • 代码解析
      • 断点1:执行f()之前
      • 断点2:执行f()
      • 断点3:执行square()
  • 4. 闭包与Environment
    • 代码解析
      • 断点1:执行add(2)
      • 断点2:add(2)执行之后
      • 断点3:执行plus2(5)
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档