❝
简单的欲望,只需要放纵就可以实现;而高级的欲望,需要自律和克制来实现
❞
一语中的
- 作用域控制着变量和函数的可见性和生命周期
- JS的作用域(scope)是静态的(static)
- ES6块级作用域和函数作用域属于同一大类(声明式作用域)
- ES6块级作用域是函数作用域的子集
with
会扩展作用域链- 在全局作用域下,声明式(块级)ER优先级高
- 块级作用域中的(let/const)变量查找路径 1. 词法环境 2. 变量环境 3. OuterEnv对象
- 作用域链 是由环境记录(ER)的内部属性
OuterEnv
串联起来的 - 作用域只是执行上下文有权访问的一组有限的变量/对象
- 同一个执行上下文上可能存在多个作用域
- 每个执行上下文都有自己的作用域
- 使用 setTimeout 来解决栈溢出:setTimeout 会将包装函数封装成一个新的宏任务,并将其添加到消息队列中
文章概要
- 作用域(Scopes)
- 词法环境(Lexical environments)
- 作用域链
- 执行上下文
- 调用栈
作用域(Scopes)
变量的词法作用域(简称:作用域)是程序中可以访问变量的区域。通俗地理解,作用域就是变量与函数的可访问范围,即作用域控制着变量和函数的可见性和生命周期。
JS的作用域是静态的(不会在运行时被改变)
作用域可以被嵌套。
function func() { // (A) const a = 1; if (true) { // (B) const b = 2; } } 复制代码
if语句引入的作用域(B行)嵌套在函数func()的作用域内(A行)。
词法环境(Lexical environments)
在ecma262(自备🪜)语言规范中定义:作用域是通过词法环境实现的。
而词法环境由两个重要部分组成:
- 环境记录(environment record): 将变量名映射到变量值(类似于Map)。这是作用域变量的实际存储空间。记录中的名称-值条目称为绑定。
OuterEnv
内部属性:指向外部环境(outer environment)的引用
并且,由于 Environment Record(以下简称:ER
) 是一个抽象类,它由三个具体的子类实现:
- 声明式ER
而声明式ER又派生出 1. 函数ER 2. module ER - 对象ER 通过
with
扩展作用域链 - 全局ER
所以,我们可以这样认为:
❝
作用域被分为3类\
- 声明式作用域(函数作用域/module作用域)\
- 对象作用域\
- 全局作用域
❞
1. 声明式作用域
声明式ER可以通过 var
/const
/let
/class
/module
/import
/function
生成。
也就是说我们常说的ES6块级作用域和函数作用域属于同一大类(声明式作用域) 。
根据实现层级,还有一个更准确的结论:
❝
ES6块级作用域是函数作用域的子集
❞
2. 对象作用域
对于对象作用域(使用with
)不常见,我们简单用代码描述一下。
function test(){ let ob = { name:'789' }; // 利用with扩展了作用域 with(ob){ console.log(`my name is ${name}`) } } test(); // my name is 789 复制代码
这里多说一嘴:
HTML事件处理程序,作为事件处理程序执行的代码可以访问全局作用域中的一切。
<script> function showMessage() { // 可以访问全局作用域中的一切 } </script> <input type="button" value="你过来啊!" onclick="showMessage()"/> 复制代码
以这种方式指定的事件处理程序,会创建一个函数来封装属性的值,这个函数有一个特殊的局部变量 event
,其中保存的就是 event 对象。这个函数的作用域链被扩展了。这个函数中,document
和元素自身的成员都可以被当成局部变量来访问。
// 通过使用 with 实现的 function() { with(document) { with(this) { // 属性值 } } } 复制代码
3.全局作用域
全局作用域是最外面的作用域,它没有外部作用域。
❝
全局环境的
OuterEnv
为null。❞
全局ER使用两个ER来管理其变量:
- 对象ER : 将变量存储在全局对象中
- 声明式ER : 使用内部对象来存储变量
<script> const one = 1; var two = 2; </script> <script> // 所有脚本都共享顶层作用域 console.log(one); // 1 console.log(two); // 2 // 并非所有的声明都被存入到全局对象中 console.log(globalThis.one); // undefined console.log(globalThis.two); // 2 </script> 复制代码
- 顶层作用域下,
const
/let
/class
声明的变量被绑定在声明ER里 - 顶层作用域下,
var
和function
声明的变量被绑定在对象ER里(在浏览器环境下,window
指向全局对象)
当声明式ER和对象ER有共同的变量,声明式优先级高。
<script> let gv = 1; // declarative ER globalThis.gv = 2; // object ER console.log(gv); // 1 (声明式优先级高) console.log(globalThis.gv); // 2 </script> 复制代码
变量查找是从下往上的。也就是声明式优先级高。
用一个图来收个尾
作用域链
在 JS 执行过程中,其作用域链是由词法作用域决定的。变量的可访问性在编译阶段(执行之前)已经确定了。所以,在函数进行变量查找时,我们只根据词法作用域(函数编码位置)来确定变量的可见性。
function tool(){ console.log(myName) } function test(){ var myName = 'inner'; tool(); } var myName = 'outer'; test(); // outer 复制代码
❝
词法作用域是代码阶段就决定好的,和函数是怎么调用的没有关系
❞
这里再多说一嘴,ES6 是支持块级作用域的,当执行到代码块时,如果代码块中有 let 或者 const 声明的变量,针对变量的查询路径为: 1. 词法环境 2. 变量环境 3. OuterEnv对象(上一层作用域继续先1后2)
执行上下文
❝
1.作用域只是执行上下文有权访问的一组有限的变量/对象
2.同一个执行上下文上可能存在多个作用域
❞
执行上下文是执行其代码的函数的环境。每个函数都有自己的执行上下文。
我们通过一个例子,来窥探下,执行上下文的内部结构。
话不多说,上菜。
小提示:存在两个用let
定义的b
function foo(){ var a = 1 let b = 2 { let b = 3 var c = 4 let d = 5 console.log(a) // {A} console.log(b) } console.log(b) console.log(c) console.log(d) } foo() // 1,3,2,4,undefined 复制代码
我们在v8如何处理JS中讲过,执行JS代码核心流程 1. 先编译 2. 后执行。
针对如上代码,先对其进行编译并创建执行上下文,然后再按照顺序执行代码。
函数内部通过 var
声明的变量在编译阶段全都被存放到变量环境里面了。通过 let 声明的变量,在编译阶段会被存放到词法环境(Lexical Environment)中。 继续执行代码,当执行到代码块里面时,变量环境中 a 的值已经被设置成了 1,词法环境中 b 的值已经被设置成了 2。
当进入函数的作用域块,作用域块中通过 let 声明的变量会被存放在词法环境的一个单独的区域中。这个区域中的变量并不影响作用域块外面的变量。
❝
在词法环境内部,维护了一个小型栈结构,栈底是函数最外层的变量,进入一个作用域块后,就会把该作用域块内部的变量压到栈顶。当作用域执行完成之后,该作用域的信息就会从栈顶弹出。
❞
当执行到作用域块中的{A}
这行代码时就需要在词法环境和变量环境中查找变量 a 的值。
具体查找方式是:沿着词法环境的栈顶向下查询,如果在词法环境中的某个块中查找到了,就直接返回给 JavaScript 引擎。如果没有查找到,那么继续在变量环境中查找。
作用域块执行结束之后,其内部定义的变量就会从词法环境的栈顶弹出。
其实,在ECMA262规范定义中,针对执行上下文还有更多的属性和方法。其中,最重要的就是我们上文讲到的词法环境和语法环境(变量环境) 。
还有用于描述该执行上下文代码状态的 Code State
:是处于执行(perform)、挂起(suspend)还是继续执行(resume)。(盲猜这里是不是和协程、生成器有关系)
调用栈
执行上下文都都介绍了,那调用栈还远吗?
调用栈就是用来管理函数调用关系的一种数据结构,是 JavaScript 引擎追踪函数执行的一个机制。
function bar() { } function foo(fun){ fun() } foo(bar) 复制代码
V8 准备执行这段代码时,先将全局执行上下文压入到调用栈。 V8 在主线程上执行 foo 函数,创建 foo 函数的执行上下文,并将其压入栈中。 V8 执行 bar 函数时,创建 bar 函数的执行上下文,并将其压入栈中。 bar 函数执行结束,V8 就会从栈中弹出 bar 函数的执行上下文。 foo 函数执行结束,V8 会将 foo 函数的执行上下文从栈中弹出。
堆栈溢出
过多的执行上下文堆积在栈中便会导致栈溢出。
function foo(){ foo() } foo() 复制代码
foo 函数内部嵌套调用它自己,调用栈会一直向上增长。最后会爆栈,把主线程给阻塞。
使用 setTimeout 来解决栈溢出的问题
我们可以利用setTimeout
来解决栈溢出问题。setTimeout
的本质是将同步函数调用改成异步函数调用。
function foo() { setTimeout(foo, 0) } foo() 复制代码
异步调用是将 foo 封装成事件,并将其添加进消息队列中,主线程再按照一定规则循环地从消息队列中读取下一个任务。
主线程会从消息队列中取出需要执行的宏任务。 V8 就要执行 foo 函数,并创建 foo 函数的执行上下文,将其压入栈中。 V8 执行执行 foo 函数中的 setTimeout 时,setTimeout 会将 foo 函数封装成一个新的宏任务,并将其添加到消息队列中。 foo 函数执行结束,V8 就会结束当前的宏任务,调用栈也会被清空。
当一个宏任务执行结束之后,主线程会一直重复取宏任务、执行宏任务的过程,通过 setTimeout 封装的回调宏任务,会在某一时刻被主线取出并执行。
foo 函数并不是在当前的父函数内部被执行的,而是封装成了宏任务,并丢进了消息队列中,等待主线程从消息队列中取出该任务,再执行该回调函数 foo。