前端温故知新之作用域/执行上下文/闭包

147 阅读9分钟

ovQiHx.png

编译

js 运行一般存在语法分析和解释执行等 2 个阶段

语法分析阶段:

  • 词法分析:将源代码分解成词法单元(代码块),如let str = 'Hello World'分解成letstr=Hello World

  • 语法分析:将词法单元流转换成一棵抽象语法树(AST

  • 代码生成:将抽象语法树转换为可执行代码,函数定义的时候作用域规则确定

解释执行阶段:

  • 创建执行上下文

  • 执行函数代码

  • 垃圾回收

1、作用域

1.1 概念

定义变量起作用的区域,决定了当前执行代码对变量的访问权限,达到隔离变量的作用

1.2 类型

  • 全局作用域:在代码中任何地方都能访问到的变量和函数,污染全局命名空间, 容易引起命名冲突

  • 函数作用域:属于这个函数的变量可以在整个函数范围内使用,内层函数作用域可以访问外层函数作用域的变量

  • 块级作用域:通过新增命令letconst声明,所声明的变量在指定块的作用域外无法被访问(try/catchcatch也会创建块级作用域),var声明的变量会挂载在Window上,而letconst声明的变量不会挂载到Window上,它形成了一个块作用域

iffor这些条件语句或者循环语句不会创建新的作用域,能不能访问到内部变量取决于声明方式事var还是let/const

if (true) {
  var name = 'su'
  const age = 18
}
console.log(name) // 'su'
console.log(age) // 报错,Uncaught ReferenceError: age is not defined

1.3 模型

作用域有词法作用域(静态作用域)和动态作用域两种模型,词法作用域(js 采用)在定义的时候就决定了,而动态作用域在调用的时候才决定

let count = 10
function test(){
  console.log(count)
}
function sum() {
  let count = 20
  test()
}

sum()   // 10,函数 test 是在全局作用域下创建的,上级作用域是 window,输出 10

1.4 作用域链

当访问变量时,会先查找本地作用域,如果找到目标变量即返回,否则会去父级作用域继续查找,一直找到全局作用域,通过作用域链,可以访问到外层环境的变量和函数

函数对象的内部属性[[Scope]]包含了一个函数被创建的作用域中对象的集合,这个集合存储的便是作用域链

2、执行上下文

2.1 概念

代码被解析和执行时所在的环境,即每当 js 代码在运行的时候,它都是在执行上下文中运行

2.2 类型

  • 全局执行上下文:一个程序中只会有一个全局执行上下文,全局上下文会生成一个全局对象(以浏览器环境为例,这个全局对象是window),并且将this值绑定到这个全局对象上

  • 函数执行上下文:每当一个函数被调用时,都会创建一个新的函数执行上下文

2.3 管理

js 引擎通过执行上下文栈来管理执行上下文,执行上下文栈是栈结构的,遵循后进先出的特性,代码执行期间创建的所有执行上下文,都会交给执行上下文栈进行管理。当 js 引擎开始解析代码时,会首先创建一个全局执行上下文,压入栈底(这个全局执行上下文从创建一直到程序销毁,都会存在于栈的底部),每当引擎发现一处函数调用,就会创建一个新的函数执行上下文压入栈内,并将控制权交给该上下文,待函数执行完成后,即将该执行上下文从栈内弹出销毁,将控制权重新给到栈内上一个执行上下文

执行栈本身也是有容量限制的,当执行栈内部的执行上下文对象积压到一定程度如果继续积压,就会报栈溢出的错误,栈溢出错误经常会发生在递归中

2.4 阶段

执行上下文的生命周期有创建、执行和销毁三个阶段

创建阶段(预编译阶段)

  • this绑定:在全局执行上下文中,this的值指向全局对象(浏览器中为window对象),在函数执行上下文中,this的值取决于该函数是如何被调用的

  • 词法环境:一种有标识符——变量映射的数据结构,标识符是指变量/函数名,变量是实际对象或原始数据的引用,内部有环境记录器(存储letconst的函数声明和变量)和一个外部环境的引用(访问父级作用域)

  • 变量环境:也是一个词法环境,只不过环境记录器存储的是var的函数声明和变量

执行阶段

完成对定义的变量赋值、开始顺着作用域链访问变量、如果内部有函数调用就创建一个新的执行上下文压入执行栈并把控制权交出

销毁阶段

当前执行上下文(局部环境)会被弹出执行上下文栈并且销毁(闭包情况不会销毁),控制权被重新交给执行栈上一层的执行上下文

3、闭包

3.1 概念

有权访问另一个函数作用域中变量的函数

3.2 形成原因

内部的函数存在外部作用域的引用就会导致闭包,即嵌套函数中使用了其它函数定义的变量

function foo(){
  let b =14
  function test(){
    console.log(b)
    return b
  }
  test()
}
foo()   // test 内部函数中有上级作用域变量 b 的引用,从而产生闭包

ofSr4O.png

3.3 用途

  • 函数外部能够访问到函数内部的变量,实现变量私有化

  • 保护函数的私有变量不受外部的干扰,形成不被销毁的变量

function createCounter() {
  let counter = 0
  const myFunction = function() {
    counter = counter + 1
    return counter
  }
  return myFunction
}
const increment = createCounter()
const c1 = increment()
const c2 = increment()
const c3 = increment()
console.log(c1, c2, c3)  // 1, 2, 3  increment 函数能够访问 createCounter 函数的内部变量 counter,并且 counter 变量保留在内存中

3.4 注意点

不合理的使用闭包,会导致某些变量一直被留在内存当中 容易导致内存泄漏,所以要避免滥用闭包

4、垃圾回收

4.1 概念

代码运行时,需要分配内存空间来储存变量和值,当变量不在参与运行时,就需要系统收回被占用的内存空间

4.2 方式

标记清除(常用)

可达性,以某种方式可访问或者说可用的值,它们被保证存储在内存中,反之不可访问则需回收

  • 标记:把所有活动对象做上标记
  • 清除:把没有标记(也就是非活动对象)销毁

实现比较简单,会出现内存碎片化(标记整理可解决)

引用计数

跟踪记录每个值被引用的次数,当对象被引用则加 1,覆盖减 1,当这个引用次数变为 0 时,这个变量所占有的内存空间就会被释放出来

循环引用会导致无法回收,需要手动释放内存

5、其它扩展

5.1 变量提升

在预编译阶段,var定义的变量和函数声明会被移动到函数或者全局代码的开头位置

// 函数提升优先级比变量提升要高,且不会被变量声明覆盖,但是会被变量赋值覆盖
console.log(foo)  // function foo(){}
var foo = 'ss'
function foo(){}
console.log(foo)  // 'ss',变量赋值覆盖函数

// 函数声明会提升,函数表达式不会提升
console.log(func())  // 2,函数声明提前
var func = function() {
  return 1
}
function func(){
  return 2
}
console.log(func())   // 1,函数表达式覆盖函数声明
// 函数形参的变量提升,优先级:函数声明 > 函数实参 > 变量声明
function fn (a) {
  console.log(a) // 1 实参值
  var a = 2
}
fn(1)

function fn (a) {
  console.log(a)  // function a () {}
  var a = 2
  function a () {}
  console.log(a)
}
fn(1)


var x = 1
function func( x, y = function anonmymous1() {
    console.log(x)    // 5, 本地作用域形参 5,而不是局部变量 3
    x = 2
  }
) {
  var x = 3     // 形成新的局部变量,去掉 var ,则修改和读取的都是形参
  y()
  console.log(x)    // 3
}
func(5)
console.log(x)    // 1

5.2 循环异步赋值

for (var i = 1; i <= 5; i++) {
  setTimeout(function timer() {
    console.log(i)    // 6,循环5次,每秒输出一个6
  }, i * 1000)
}

使用var定义的变量i为全局作用域的变量,所以全局只有一个变量isetTimeout为异步函数,当主线程for循环处理完,全局变量i6,所以输出会是 5 个 6,所以我们需要一个独立的作用域,且每次循环都要存储不同的i

函数传参

for (var i = 1; i <= 5; i++) {
  output(i)   // 通过形参传值,复制i值
}
function output(i) {
  setTimeout(function timer() {
    console.log(i)
  }, i * 1000)
}

setTimeout 传参

for (var i = 1; i <= 5; i++) {
  setTimeout(function timer(i) {
    console.log(i)
  }, i * 1000, i)     // setTimeout 的第三个参数会被当成 timer 函数的参数传入
}

闭包

for (var i = 1; i <= 5; i++) {
  // 使用立即执行函数将 i 传入函数内部
  ;(function (i) {
    setTimeout(function timer() {
      console.log(i)
    }, i * 1000)
  })(i)
}

let 块级作用域

// 使用 let 形成块级作用域,每次 for 执行都会创建新的变量 i
for (let i = 1; i <= 5; i++) {
  setTimeout(function timer() {
    console.log(i)
  }, i * 1000)
}

5.3 闭包函数调用

function mainFun(n, s) {
  console.log(s)
  return {
    fun: function (m) {
      return mainFun(m, n)
    },
  }
}
let a = mainFun(0)  // undefined
a.fun(1)  // 0
a.fun(2)  // 0
a.fun(3)  // 0
let b = mainFun(0).fun(1).fun(2).fun(3)   // undefined 0 1 2
let c = mainFun(0).fun(1)   // undefined 0
c.fun(2)  // 1
c.fun(3)  // 1
// let b = mainFun(0).fun(1).fun(2).fun(3) 等价于
let b = mainFun(0)
let b1 = b.fun(1)
let b2 = b1.fun(2)
let b3 = b2.fun(3)

主要是解析 mainFun(m, n) 函数所获取的 n 变量,a.fun(1)、a.fun(2)和 a.fun(3)都是获取的同一个闭包,而 let b = mainFun(0).fun(1).fun(2).fun(3) 则是层层递进获取新的 b1、b2 闭包中的变量

参考