作用域是一套管理和查找标识符(变量)的规则

JavaScript 虽然被归类为 解释执行 语言,但是在实际的运行过程中仍然具有 编译 这个步骤。只是相对于传统的编译语言来说,它没有大量的时间进行预先编译,而是一边编译一边执行,这个过程非常短暂(几微妙甚至更短),紧接着代码就会执行。

编译

我们先来看看传统编译的流程:

分词/词法分析,将源代码处理为词法单元。
解析/语法分析,利用上一步生成的词法单元流组合生成抽象语法树(AST)。
将 AST 转换为目标平台可执行的代码。

这三步是传统编译器在执行编译时的必要步骤,实际当中 JavaScript 引擎所调用的编译器更加复杂,其中包含了许多优化执行效率的操作。

运行

了解完基础的编译步骤,接下来我们通过一句简单的声明语句 var a = 2 来了解 JavaScript 代码运行的两个阶段 —— 编译阶段执行阶段,顺序如下:

  • 编译阶段:编译器 发现需要声明一个名为 a 的标识符,此时它就会通知 作用域 进行查找。如果当前作用域集合中已经存在相同名称的标识符,编译器会忽略本次声明继续编译;否则它会要求在当前作用域集合中生成一个新的标识符,并命名为 a
  • 执行阶段:编译结束后,编译器 会将可执行代码提供给 引擎 。执行过程中 引擎 发现需要对标识符 a 进行赋值。引擎 就会询问 作用域 是否能查找到名为 a 的标识符,查找成功引擎就将 2 赋值给 a,严格模式下失败则抛出异常,非严格模式下若不存在,则顺着作用域链向上查找,若最终找到了变量a则将其赋值2,若没有找到,则本作用域声明一个变量a并赋值为2。

通过上述的分析我们得知,这句简单的语句在运行时会被分解为 var aa = 2声明 在编译阶段就会执行,而 赋值 是在执行阶段才发生,作用域 在这两个阶段当中就起到查找并且管理标识符的作用

如下代码:

  1. var a = "qwqw"
  2. {function(){
  3. console.log(a)
  4. a=3
  5. console.log(window.a)
  6. var a= 20
  7. console.log(a)
  8. }
  9. }()
  10. // 解析步骤
  11. // var a = undefined
  12. // {function(){
  13. // 变量提升 (编译器解析阶段 var a = undefined)变量找寻的原则就是如果本作用域有就在本作用域中查找
  14. // 不然就就会向上去找父级作用有没有;直到找到
  15. // console.log(a)
  16. // 这个时候打印出来为undefined
  17. // a=3
  18. // 函数作用域中的a已经被赋值为3
  19. // console.log(window.a) 这个打印的全局作用域上的a
  20. //
  21. // var a= 20 相当于 函数作用域a = 20
  22. // console.log(a)
  23. // 打印函数作用域中的a的值
  24. }
  25. }()

变量提升

在 JavaScript 中标识符的声明会“移动”到各自作用域的顶端,我们将这种现象称为 变量提升 ,它的实际原理就在于 JavaScript 代码在 编译阶段 就将 声明 这个动作完成了。所以在运行时 作用域 当中已经包含了所有的标识符,感觉上就好像变量提升到了当前作用域的顶端,我们重点关注一下不同类型的标识符提升的特点:

  • 普通变量只会提升声明部分,并将为其赋初值 undefined ,当在声明之前访问变量时将获得 undefined

  • 函数声明属于完全提升,所以 JavaScript 中允许在 函数声明 之前发生 函数调用 。并且函数提升 优先 于普通变量,当存在函数与普通变量同名时,普通变量的声明将会被忽略

var、let、const 区别

var 命令会发生“变量提升”现象,即变量可以在声明之前使用,值为 undefined 。
内层变量可能覆盖外层变量
用来计数的循环变量泄露为全局变量

声明的全局变量不会挂在顶层对象下面
所声明的变量一定要在声明后使用,否则报错,报错 ReferenceError(并不会变量提升)
暂时性死区,只要块级作用域内存在 let 命令,它所声明的变量就“绑定”( binding )这个区域,不再受外部的影响,在代码块内,使用 let 命令声明变量之前,该变量都是不可用的。 ( 块级作用域 )
不允许重复声明

const 声明的全局变量不会挂在顶层对象下面
const 声明之后必须马上赋值,否则会报错
const 简单类型一旦声明就不能再更改,�复杂类型(数组、对象等)指针指向的地址不能更改,内部数据可以更改。
const 一旦声明变量,就必须立即初始化,不能留到以后赋值。
const 命令声明的常量也是不提升,同样存在暂时性死区,只能在声明的位置后面使用。(并不会变量提升)

var的话会直接在栈内存里预分配内存空间,然后等到实际语句执行的时候,再存储对应的变量,如果传的是引用类型,那么会在堆内存里开辟一

let的话,是不会在栈内存里预分配内存空间,而且在栈内存分配变量时,做一个检查,如果已经有相同变量名存在就会报错

const的话,也不会预分配内存空间,在栈内存分配变量时也会做同样的检查。不过const存储的变量是不可修改的,对于基本类型来说你无法修改定义的值,对于引用类型来说你无法修改栈内存里分配的指针,但是你可以修改指针指向的对象里面的属性

栈内存与堆内存

stack&Heap

LHS、RHS

接下来我们重点来了解一下 引擎 查找标识符的两种方式 —— LHSRHS

  • LHS,可以理解为对标识符的 内存地址 的查询。
  • RHS,可以理解为对标识符所存储的 的查询。
  1. function foo(a) {
  2. var b = a
  3. }
  4. foo(2)
  5. 复制代码

我们来分析下上面的代码,上面这段代码中包含了 2 次 LHS 和 2 次 RHS,实际执行过程如下:

  1. 当需要调用 foo 函数时,引擎 会执行 RHS 查询 foo 的值。
  2. foo 函数在执行时,为了给型参 a 进行赋值会执行 LHS 查询标识符 a 的内存地址,并将 2 赋值给 a
  3. 当执行 var b = a 语句时,引擎 会执行对标识符 a 的值的 RHS 查询,以获取值 2。然后对标识符 b

LHS 查询得到内存地址后,将 2 赋值给 b

异常

首先我们来了解下 JavaScript 语言目前拥有的三类作用域:

  1. 全局作用域:其中包含一些必要的公共函数和变量,属于顶层作用域。
  2. 函数作用域:该作用域会包含函数的参数和在函数当中声明的所有标识符。
  3. 块级作用域:ES6 开始引入了块级作用域的概念,{} 括号内包裹的区域可以看作是一个块级作用域。

在 JavaScript 中作用域是可以相互 嵌套 的,最外层的作用域被称为 全局作用域 ,当作用域嵌套时就形成了我们所谓的 作用域链 。当执行查询操作时,如果在当前的作用域中无法查找到相应的标识符就会顺着作用域链向外层寻找,直到查询成功就停止。假如一直到全局作用域当中都查询不到,此时引擎就会抛出 ReferenceError 异常。

这里重点来讲一下 LHS 查询与 RHS 查询在查找不到标识符时的区别:

  • RHS 查询与一开始我们所讲的规则是一致的,当一路查询到全局作用域中都没有结果时就会抛出异常。
  • LHS 查询稍有不同,在 严格模式 下抛出异常的规则和上述的一致。但是在 非严格模式 下查询不到结果时,引擎会在全局作用域中创建一个同名的标识符。

小结

今天为大家讲解了 作用域 的概念,现在对今天的内容做个总结:

  • 作用域是一套严谨的管理和查找标识符的规则。
  • 变量提升的本质原因是因为 JavaScript 代码是先编译后执行的,在编译阶段标识符的声明就已经完成了。
  • 作用域分为全局作用域 、函数作用域和块级作用域。作用域是可以相互嵌套的,标识符查找会顺着嵌套关系向外层作用域移动,直至查询到全局作用域时结束,查询失败时引擎会抛出 ReferenceError 异常。
  • 引擎查询标识符存在两种形式 —— LHS 和 RHS,LHS 查询标识符的内存地址,RHS 查询存放的值