由同步到异步讲解js执行环境和闭包

680 阅读11分钟

这篇文章主要介绍了js的执行环境和闭包,最后延伸到异步,这篇文章属于对各种知识的梳理和串联,可以按需阅读,欢迎大佬指出问题。

一些基础概念

执行环境

当一段 JavaScript 代码在运行的时候,它实际上是运行在执行环境中。下面3种类型的代码会创建一个新的执行环境(参考MDN):

  • 全局执行环境是为运行代码主体而创建的执行环境,也就是说它是为那些存在于JavaScript 函数之外的任何代码而创建的。
  • 每个函数会在执行的时候创建自己的执行环境。
  • 使用 eval() 函数也会创建一个新的执行环境。

每个执行环境中都有一个变量对象(函数是活动对象),不同执行环境的变量对象通过作用域链联系起来(参考红宝书第三版)

  • 变量对象是存在堆中,环境中定义的所有的变量和函数都在这个对象中。
  • 作用域链是一个指向本层及以上所有层级的执行环境的变量对象的指针列表。保证对执行环境有权访问的所有变量和函数有序访问。

标识符解析:引擎从当前的执行作用域开始查找变量,如果找不到,会继续向作用域链上一级进行查找。(参考《你不知道的js(上)》)

词法作用域

词法作用域:编译阶段会找到所有的声明,并用适当的作用域将他们联系起来。也就是在代码运行之前,他们的作用域就被确定好了。(参考《你不知道的js(上)》)

不太理解也没事,总之要知道这一点:一个变量或函数属于哪个作用域,在他们声明的时候就确定好了。作用域嵌套关系是变量和函数声明时就确定的,不是调用函数时确定的。

闭包的定义

闭包: 通常说到闭包,就是说一个函数有权访问另一个函数的作用域。

我觉得从原理来说,其实函数在全局作用域中声明,也算闭包,因为声明在全局作用域的函数可以访问全局作用域中的变量 (这个理由是不是听上去有点扯淡?) 。如果函数在另一个函数中声明,它就可以访问这个外部函数作用域内的变量,并且也能访问到这个外部函数能访问到的所有作用域。

MDN上关于闭包的描述)A closure is the combination of a function bundled together (enclosed) with references to its surrounding state (the lexical environment). In other words, a closure gives you access to an outer function’s scope from an inner function. In JavaScript, closures are created every time a function is created, at function creation time.

但是在下面的描述中,还是把闭包特指为一个函数有权访问另一个函数的作用域,方便解释。

函数执行过程

调用一个函数之后,会在当前作用域中执行完这个函数后,再执行当前作用域的其他内容。

// 这是一段同步执行,没有闭包的代码
const a = '全局作用域的a'
console.log('start')
function foo() {
	const a = 'foo作用域的a'
    console.log('foo')
    bar()
    console.log('function-end')
}
function bar() {
    console.log(a)
}
foo()
console.log('end')
// log: start foo 全局作用域的a function-end end
// 从输出结果可以看到,执行完bar函数后,function-end才打印出来

虽然bar和foo在声明的时候是独立的,但是由于在foo中调用了bar,导致bar执行完之后,foo的执行环境才可以被销毁。如果嵌套调用了一堆函数,就很耗内存,因此可以牵扯到一个尾调用优化(可以看一下 阮一峰老师的《ECMAScript 6 入门》),以及递归这个东西。

还有如何用循环代替递归(深拷贝的时候,可以搜一下掘金上的文章,面试可能会考深拷贝)等。

注意:bar( )这个语句在foo作用域中执行,因此可以获取foo作用域中的变量,并作为参数传给bar函数

闭包的情况:

const a = '全局作用域的a'
console.log('start')
function foo() {
	const a = 'foo作用域的a'
    function bar() {
    	console.log(a)
    }
    console.log('foo')
    bar()
    console.log('function-end')
}
foo()
console.log('end')
// log: start foo foo作用域的a function-end end

如果bar函数在foo函数内部定义,bar就形成了对foo函数闭包。

上面那种情况,bar作用域链上级是全局作用域;

但是当bar在foo里面定义的时候,bar的作用域链上级是foo作用域,再上级是全局作用域。 函数从创建到执行到销毁到再调用的全流程(参考了红宝书第三版)

  1. 声明函数时,会创建函数的作用域链(所以函数的闭包是对函数创建时所在作用域的引用),保存在内部[[scope]]属性中。(参考基本概念那里进行理解,对函数声明时就确定了函数的作用域,但这时这个作用域链中还没有引用函数自己的活动对象)

  2. 调用函数时,会创建函数的执行环境

  3. 然后复制函数的[[scope]]属性来创建执行环境的作用域链(这时里面没有对该函数本身活动对象的引用),

  4. 然后创建活动对象

  5. 函数自己的活动对象被推入作用域链的最下面

  6. 函数中的语句开始运行,在函数中访问变量时,会按照标识符解析在作用域链中从下往上查找变量

    1. 函数内所有语句都运行完,(参考上面的图,有一个函数调用语句,则这个函数会等到内部函数的执行环境销毁后再销毁),环境的作用域链销毁(只是销毁了一个对多个对象的引用列表,不是把对应的对象也销毁了),如果没有闭包,函数自己的活动对象会被销毁。

    2. 如果有闭包,这个函数的活动对象不销毁(因为别的函数的作用域链中,有对这个活动对象的引用),作用域链依旧销毁

  7. 再次调用这个函数,执行2 --7的步骤,如果又有闭包了,则内存中又多出来一个新的活动对象,和之前闭包保存的那个活动对象不是一个!然后这两个变量对象中每个属性对应的值可以是不同的,因为本来就不是一个对象,所以就不会互相影响。

注:函数之外的语句,他们的执行环境是全局环境。

当所有代码执行完,主程序退出,全局执行环境从执行栈中弹出。此时栈中所有的执行环境都已经弹出,这个js脚本执行完毕。都是同步的代码,没有异步回调,脚本执行完了,就没有更多内容了。

闭包的影响

上面也说过了:闭包会导致函数执行完其活动变量不会被销毁,因为其他函数执行环境的作用域链中保有对改活动对象的引用。

还是要再强调一下:函数每次执行,会创建一个新的作用域,会有一个新的变量对象,可以联想vue里面组件的data必须是个函数。

function data() {
    const obj = {
        a: 1
    }
    return obj
}
const vm1 = data() // data函数执行后创建新的活动对象
const vm2 = data() // data函数执行后创建新的活动对象
console.log(vm1.a) // 1
console.log(vm2.a) // 1
vm1.a = 233 // 改变一个实例
console.log(vm1.a) // 233
console.log(vm2.a) // 1

所以多次调用函数,产生闭包所引用的是不同的变量对象,所以每个闭包中可以引用不同的私有变量

function data() {
    const obj = {
        a: 1
    }
    function bar() {
        return obj
    }
    return bar
}
const vm1 = data()() // data执行创建新的活动对象,并且活动对象不会销毁
const vm2 = data()() // data执行创建新的活动对象,并且活动对象不会销毁
console.log(vm1.a) // 1
console.log(vm2.a) // 1
vm1.a = 233 
console.log(vm1.a) // 233
console.log(vm2.a) // 1

闭包的作用: 闭包可以让变量私有化,自调用函数也可以私有化变量,用let劫持块作用域也算是私有化变量,但是闭包可以创造出不同的活动对象。 闭包最大的应用就是模块模式

模块模式的延伸

模块就是提供公开访问的API,但是隐藏了内部实现

下面是最简单的模块模式

一般是引用外部模块,比如import一个js文件。

function dataModule() {
    const obj = {
        'major': 'civil engineering'
    }
    function getData() {
        return obj
    }
    function setPro(newData) {
        Object.assign(obj, newData)
    }
    return {
        getData,
        setPro
    }
}

const userData1 = dataModule()
userData1.setPro({ 'name': '小烈', 'age': 33 })

const userData2 = dataModule()
userData2.setPro({ 'name': '小郭', 'age': 142 })

console.log(JSON.stringify(userData1.getData(), null, 2))
console.log(JSON.stringify(userData2.getData(), null, 2))

事件循环和异步的学习指南

这次讲的主要是同步代码。但如果在执行同步代码的时候,有promise,await,setTimeout指定的回调,同步代码执行完后就还要去处理这些异步回调函数。

为什么需要异步

上面例子也看到了,foo中要等到bar执行完才能继续执行foo函数下面的语句,万一bar要运行很长时间,为了不阻塞代码就需要异步。

js实现异步的本质

要理解各种异步实现方式的第一个关键点,就是知道异步就是把回调函数添加到一个任务队列中,在同步代码执行完再执行队列中的回调函数(再具体一点,就要了解微任务,宏任务,事件循环)。 异步就是回调函数,但回调函数不一定是异步

const a = '全局作用域的a'
console.log('start')
function foo(fn) {
    const a = 'foo作用域的a'
    console.log('foo')
    fn()
    console.log('function-end')
}
// 虽然是回调函数,但还是同步的
foo(function bar() {
    console.log(a)
})
console.log('end')
// log: start foo 全局作用域的a function-end end

async|await这种语法的本质也是把回调函数放到任务队列中,从而不阻塞同步代码

为什么用promise,async|await实现异步

传统的可以通过回调函数+定时器的方式来避免阻塞代码,利用ajax发送请求本身也是异步的,因为定时器和ajax会把回调函数放在任务队列中,利用传统回调不止代码嵌套层级太多难以阅读这个问题,如果有时间,推荐阅读《你不知道js(中)》,可以知道promise解决了什么问题。但是里面没讲async。

学习promise和async、await语法的文章很多,平常写代码肯定也经常接触,多少会懂一点,面试的时候也经常考,多查查MDN,刷刷面试题。学到一定程度,可以尝试手写原生方法。

事件循环学习指南

上面只提到了任务队列,其实还分微任务队列(microtask queue)和宏任务队列(macrotask queue),有些地方,直接用任务(task)来表示宏任务(macrotask)。上面提到的定时器和ajax是添加回调到宏任务,promise添加回调到微任务

从事件循环的角度js脚本本身就是一个宏任务,js脚本同步代码执行完了,全局执行环境从栈中弹出,栈被清空,这时查看微任务队列并按先进先出的原则,依次把微任务队列中的回调函数推入执行栈,开始执行直到微任务队列清空, 然后会渲染DOM, 然后执行宏任务队列中的下一个宏任务,执行完这个宏任务后,再查看微任务队列,一直循环。

理解事件循环,宏任务,微任务的一个关键点是要知道,虽然js是单线程的,但是浏览器不是单线程的,很多东西比如DOM,AJAX,定时器,也并不是JS的语法规范,不都是由js引擎控制的。这部分可以查看「前端进阶」从多线程到Event Loop全面梳理

promise,async|await是js的语法规范,应该是js引擎线程负责把他们的回调添加到微任务队列中的,js引擎如何做到这一点的,我也不知道,我觉得也不用知道。

事件循环和任务队列可以参考下面的文章

一个外国友人讲解的微任务,很生动,适合入门

MDN上微任务与Javascript运行时环境的文章

MDN上在 JavaScript 中通过 queueMicrotask() 使用微任务

实现宏任务和微任务的方式其实有很多种,比如requestAnimationFrame, MutationObserver等,有时间也可以了解一下。