你不知道的 JS 系列 - 作用域、变量提升、闭包

来看几个既基本又重要的概念查漏补缺

作用域

几乎所有编程语言最基础的功能之一 就是存储变量当中的值,并能在之后对这个值进行访问和修改
引出下面两个问题

  • 那这些变量存储在哪里?
  • 程序需要的时候 如何找到他们?

我们需要一套设计良好的规则来存储变量,并且之后可以方便的找到这些变量,它叫 作用域

js 程序编译原理

js 是一门编译语言,但是与传统的编译语言不同,他不是提前编译的

js 引擎进行编译的步骤和传统的编译语言非常相似,但是某些环节会更加复杂 例如:在语法分析和代码生成阶段有特定的步骤来对运行性能进行优化,包括对冗余元素的优化

  • 分词 / 词法分析
  • 解析 / 语法分析
1
将词法单元流(数组)转化为AST(抽象语法树)
  • 代码生成
1
将AST转化为可执行代码

js 引擎执行一段可执行代码时,会创建对应的执行上下文,对于每个执行上下文,都有三个重要属性:

  • 变量对象

    1
    每一个执行上下文都会分配一个变量对象,变量对象的属性由变量和函数声明构成,在函数上下文的情况下,参数列表也会被加入到变量对象作为属性,不同作用域的变量对象互不相同,它保存了当前作用域的所有函数和变量
  • 作用域链

  • this 指向

    当你看到 var a = 2 程序内部的工作过程

1
2
3
4
5
编译器将这端程序分解成词法单元 var a, a=2 然后将词法单元解析成一个树结构

1.遇到var a 编译器会询问作用域是否已经有该变量的名称存在于同一个作用域中,如果是,编译器忽略该声明 否则会要求作用域在当前作用域的集合中声明一个新的变量 命名为a
2.当为引擎生成运行时所需要的代码 a=2 js引擎运行时会询问作用域,当前作用域的集合中是否存在a 存在 就是用 不存在就继续查找 如果还是找不到 就抛出异常

总结:变量的赋值操作会执行两个过程

  • 编译器会在当前作用域中生成一个变量(之前没有生成过)这会在代码执行前进行
  • 运行时 js 引擎会在作用域中查找(LHS 查询)该变量能找到就给他赋值

js 引擎是如何查找变量的?

LHS 查询 (赋值操作的目标是谁)
RHS 查询 (谁是赋值操作的源头)

词法作用域

大部分的标准语言编译器的第一个工作就叫词法化
词法化的过程会对源代码进行检查
词法作用域就是定义在词法阶段的作用域
词法作用域意味着作用域是由书写代码时函数声明的位置决定的

函数作用域

函数是 js 中最常见的作用域单元 声明在一个函数内部的变量或函数会在所处的作用域中隐藏起来 这符合最小授权(暴露)原则

最小授权(暴露)原则的好处?

  1. 隐藏内部实现 API 友好
  2. 规避冲突 (同名标识符之间的冲突)

块作用域

块作用域是指变量和函数不仅可以属于所处的作用域也可以属于某个代码块儿 {…}
ES3 开始 try/catch 结构在 catch 分句中具有块作用域
ES6 引入了 let const 可以用在循环中 会将当前的值重新绑定到了循环的每一个迭代中


小结

  • 作用域是一套规则,用于确定在何处以及如何查找变量,如果查找的目的是对变量进行赋值,那么就会使用 LHS 查询,如果目的是为了获取变量的值 那就进行 RHS 查询 赋值操作会导致 LHS 查询
  • LHS 与 RHS 查询都会从当前作用域中开始,如果有需要就会向上级作用域继续查找目标标识符。这样儿每次上升一级作用域,最后抵达迁居作用域 无论找到没找到都会停止
  • 不成功的 RHS 引用会抛出异常 不成功的 RHS 引用会导致自动隐式创建一个全局变量
  • 词法作用域意味着作用域是由书写代码时函数声明的位置决定的。编译的词法分析阶段基本能够知道全部标识符在哪里以及如何声明的,从而能够在执行过程中如何对他们进行查找
  • eval () 与 with () 可以扩充词法作用域 但是会有性能问题
  • 函数是常见但不是唯一的作用域单元,函数作用域与块作用域的行为是一样儿的 任何声明在某个作用域内的变量都将附属于这个作用域
  • ES6 中引入了 let 关键字 用来在任意代码块中声明变量

提升

  • 先有声明 后有赋值
  • 只有声明本身会被提升,而赋值等其他运行逻辑会留在原地,提升不会改变代码的执行顺序
  • 注意避免重复声明
  • 每个作用域都会进行提升操作
  • 函数声明会被提升,函数表达式不会被提升
  • 函数提升优先于变量提升
  • 一个普通块内部的函数声明通常会被提升到所在作用域的顶部
  • 无论作用域中的声明出现在什么地方,都将会在代码本身被执行前首先被处理(所有的变量声明和函数声明)都会被移动到各自作用域的最顶端

作用域闭包

  • 闭包无处不在 你需要的是识别并且拥抱它
  • 闭包是基于词法作用域书写代码时产生的自然结果
  • 当函数可以记住并访问所在的词法作用域时就产生了闭包 即使函数是在当前词法作用域之外执行
  • 闭包可以使得函数可以继续访问定义时的词法作用域
  • 如果将函数当作第一级的值类型并到处传递 就会看到闭包在这些函数中的应用
  • 在定时器,事件监听器 ajax 请求 或者任何其他的异步任务重 之要使用了回调函数 实际上就是在使用闭包
  • 闭包 就是关于如何在函数作为值按需传递的词法环境中书写代码的

循环与闭包

块作用域和闭包联手便可天下无敌

使用 IIFE(自执行函数)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
for(var i =1; i<=5;i++){
(function(j){
setTimeout(function timer(){
console.log('j',j);
},i*100)
})(i);
}

let用来劫持块作用域 并且在这个块作用域中声明一个变量

for(var i =1; i<=5;i++){
let j = i
setTimeout(function timer(){
console.log(j);
},i*100)
}
for循环头部的let声明 每次迭代都会声明
for(let i =1; i<=5;i++){
setTimeout(function timer(){
console.log(i);
},i*100)
}