努力让学习成为一种习惯,自信来源于充分的准备
如果你觉得该文章对你有帮助,欢迎大家点赞 、关注 和分享
前言
作用域 、作用域链 一直是js
中非常重要同时容易让人产生困惑的一个点。更有些小伙伴可能会把作用域 的规则与this规则给搞混。这一篇文章来帮助大家彻底清除这方面的困惑
这是js基础巩固系列文章的第二篇,旨在帮助自己巩固js相关知识,同时也希望能给大家带来些新的认识,如有疑问出入,欢迎评论区一起讨论交流
作用域
js
let foo = '1'
{
let bar = '2'
}
function func() {
let u = 3
}
console.log(bar) // ReferenceError
console.log(u) // ReferenceError
上面的代码只要是有编程基础的人都可以很直观的感受到:这段代码会报错。因为它符合一个常识:外部的代码块不能访问内部的变量
,而这个看似自然而生的规则
制定者就是作用域
作用域是规定在代码执行时根据
标识符名称
查找变量、函数的一套规则,它决定了变量、函数的可访问范围(权限)
词法作用域
作用域 有两种主要的工作模型:词法作用域 、动态作用域 ,其中词法作用域被大多数主流编程语言采用
词法作用域:用于定义在词法阶段的作用域
V8引擎
在执行js
脚本代码之前会对整体脚本代码先进行编译
,在编译
的过程中会对代码进行词法分析
、语法分析
后生成AST(抽象语法树)
,同时会生成全局作用域
词法分析
词法分析 又称为分词,其作用是将一行行的源码拆解成一个个token。所谓token,指的是语法上不可能再分的、最小的单个字符或字符串
js
var name = 'lxy'
这段代码经过javascript-ast站点分词后:
token有许多不同的类型,以上图为例子:可以看出一个简单的赋值语句var name = 'lxy'
,被分成关键字
token, 标识符
token, 赋值运算
token, 字符串
token
了解完了分词
,我们需要明白一个点:js引擎在执行脚本的时候,需要对整个脚本代码先进行编译
,编译
的过程中会对代码进行词法、语法分析
。词法分析
是编译过程进行的,所以在词法分析
过程中产生的词法作用域
也是编译时候产生的,此时代码还没执行。说了这么多到底想表达啥呢?
js中函数和变量的作用域在一开始就决定好了,取决于他们声明的位置,与他们执行时候的位置无关
我们来通过一段代码来体验一下
js
let b = 'outer'
function bar() {
console.log(b) // outer
}
function foo() {
let b = 'inner'
bar()
}
foo()
因为js是基于词法作用域
,所以上述代码输出为outer
我们可以通过下面两种方式深入探究下:
js
// a.js
var $aa = 1
let b = 'outer'
{
let c = 'block'
}
function bar() {
console.log(b) // outer
}
function foo() {
let b = 'inner'
console.dir(bar)
}
foo()
通过上面的控制台打印我们可以得出以下结论:
- bar函数并没有执行,只是编译了。但其作用域已经确定了
- 全局声明的
let
、const
的变量会被单独放在Script
作用域中。这也是我们无法通过 window.X访问的原因
另外一种是直接基于V8
指令生成对应scope: d8 --print-scopes ./a.js
具体操作流程可以参考 使用 jsvu 快速调试 v8
php
Inner function scope:
function bar () { // (0x14e04cfd0) (62, 94)
// NormalFunction
// 2 heap slots
}
Inner function scope:
function foo () { // (0x14e04d1c0) (107, 150)
// NormalFunction
// 2 heap slots
// local vars:
LET b; // (0x14e04f030) never assigned
}
Global scope:
global { // (0x14e04ca30) (0, 157)
// will be compiled
// NormalFunction
// 2 stack slots
// 4 heap slots
// temporary vars:
TEMPORARY .result; // (0x14e04d470) local[0]
// local vars:
VAR bar; // (0x14e04d190)
VAR foo; // (0x14e04d380)
VAR $aa; // (0x14e04cc50)
LET b; // (0x14e04cd10) context[3]
function foo () { // (0x14e04d1c0) (107, 150)
// lazily parsed
// NormalFunction
// 2 heap slots
}
function bar () { // (0x14e04cfd0) (62, 94)
// lazily parsed
// NormalFunction
// 2 heap slots
}
block { // (0x14e04cda0) (28, 49)
// local vars:
LET c; // (0x14e04cf38) local[1], never assigned, hole initialization elided
}
}
Global scope:
function foo () { // (0x14e04cc20) (107, 150)
// will be compiled
// NormalFunction
// 1 stack slots
// local vars:
LET b; // (0x14e04ce70) local[0], never assigned, hole initialization elided
}
基于上面结果我们可以再次确认:js中变量、函数的作用域(全局、函数、块级)在编译阶段就已经确认了,取决于它声明的位置
作用域类型
全局作用域
声明在全局的变量、函数。可以在全部范围内访问
js
// 全局对象上的属性和方法
// window.document
// window.location
// window.localStorage
// window.setTimeout
let a = 1
function func() {}
我们把上面代码使用v8指令生成下对应的 scope
csharp
Inner function scope:
function func () { // (0x11d04cee0) (23, 28)
// NormalFunction
// 2 heap slots
}
Global scope:
global { // (0x11d04cc30) (0, 29)
// will be compiled
// NormalFunction
// 1 stack slots
// 4 heap slots
// temporary vars:
TEMPORARY .result; // (0x11d04d130) local[0]
// local vars:
LET a; // (0x11d04ce50) context[3]
VAR func; // (0x11d04d0a0)
function func () { // (0x11d04cee0) (23, 28)
// lazily parsed
// NormalFunction
// 2 heap slots
}
}
在浏览器上打印
可能有小伙伴会有疑问🤔:浏览器的打印全局作用域有 window 对象,而 v8解析出来的全局作用域没有
答案是:全局对象是由宿主环境
提供的(目前我们熟知的有 node、浏览器)。V8在解析代码生成作用域前需要先准备好一系列环境(全局执行上下文、全局变量、事件循环系统等
),这些均由宿主环境
提供。 v8引擎只负责执行js。它运行在浏览器渲染主线程
上
函数作用域
函数的作用域由声明位置决定。函数块可以产生作用域。函数内声明的变量只能被子级作用域访问,无发被外层作用域访问
scss
function func() {
var a = 1
}
function ac() {
console.log(a)
}
ac() // ReferenceError: a is not defined
console.log(a) // ReferenceError: a is not defined
块级作用域
为什么需要有块级作用域、怎么实现块级作用域的。有关块级作用域的详细解读可以参考我之前的文章: 【js基础巩固计划】你真的理解变量提升吗
作用域链
在这之前大家可以花一首歌的时间想想下面几个问题:
- 什么是作用域链?
- 函数的内部属性[[scopes]]是什么?
- 上面两者的关系或者说区别的是什么?
[[scopes]]
通过前面的例子我们可以知道[[scopes]]
属性在编译阶段就已经确定好了,这里我们可以通过一个例子来看看[[scopes]]里面的细节
js
var g1 = 1
let g2 = 2
let g3 = 3
function func() {
const func1 = 1
const func2 = 2
function innerFunc() {
const innerFunc1 = 1
console.log('g1 :>> ', g1);
console.log('g2 :>> ', g2);
console.log('func1 :>> ', func1);
console.log('innerFunc1 :>> ', innerFunc1);
}
console.dir(innerFunc)
}
func()
控制台的输出如下:
通过观察,我们可以发现[[scopes]]中存放着多个对象,包括以下几种
Closure对象
:闭包-只会包含外层作用域被当前内部作用域使用的变量
(比如上例中fun2
),这个后续会单独用一篇文章讲解Script对象
:全局作用域下通过let
、const
声明的变量都会存放到这个对象Global对象
:全局对象,包含了全局作用域中声明的各种变量,以及宿主环境
提供的变量,浏览器环境下即是window
这里心细的小伙伴也许会发现一个关键点:innerFunc
函数本身内部声明的变量并不在里面。这个很好理解,因为[[scopes]]
是在编译阶段产生的,此时innerFunc
函数并没有执行,可见[[scopes]]并不是完整的作用域链
接下来我们来看看完整的作用域链(接下来执行上下文均以ES5规范为标准说明
),是怎么产生的
依然是之前的代码,我们打个断点
js
var g1 = 1
let g2 = 2
let g3 = 3
function func() {
const func1 = 1
const func2 = 2
function innerFunc() {
debugger
const innerFunc1 = 1
console.log('g1 :>> ', g1);
console.log('g2 :>> ', g2);
console.log('func1 :>> ', func1);
}
innerFunc()
}
func()
这里我们可以明显的看到Local对象是在函数执行的过程中产生的
,它包含了当前执行上下文中词法/变量环境里的变量以及this
绑定,而且它是动态
的。随着函数的执行,innerFunc1
将会被赋值为1。 所以完整的作用域链在[[scopes]]内置属性的基础上增添了Local对象
接下来我们用伪代码的行为来看看整体流程是怎么样的
func
函数被创建,保存作用域链到内部属性[[scopes]]
js
func[[scopes]] = [
// globalContext
{
LexicalEnvironment: { // 词法环境
EnvironmentRecord: {
g2: 2,
g3: 3
}
},
VariableEnvironment: {
EnvironmentRecord: {
g1: 1
}
}
}
]
注意这里func函数没有被执行,innerFunc函数还不会被创建(但是其实他的词法作用域已经确定了-因为此时AST已经生成了,只是其内部属性[[scopes]]在创建的时候才会被添加)
func
函数被执行,执行上下文被创建。入执行上下文栈
ini
ECStack = [
funcContext,
globalContext
];
func
函数并不立刻执行,开始做准备工作,复制函数[[scopes]]
属性创建作用域链存到执行上下文中
lua
funcContext = {
scope: func[[scopes]]
}
- 用 arguments 创建词法环境、变量环境,随后初始化,加入形参、函数声明、变量声明(这里细化应该是对应的
环境记录器
,执行上下文后续专门讲解,这里简单了解下)
yaml
funcContext = {
LexicalEnvironment: { // 词法环境
EnvironmentRecord: {
func1: <value unavailable>,
func2: <value unavailable>,
innerFunc: f innerFunc()
}
},
VariableEnvironment: {
EnvironmentRecord: {
}
}
scope: func[[scopes]]
}
- 将
词法、变量环境
压入作用域链
头部
css
funcContext = {
LexicalEnvironment: { // 词法环境
EnvironmentRecord: {
func1: <value unavailable>,
func2: <value unavailable>,
innerFunc: f innerFunc()
}
},
VariableEnvironment: {
EnvironmentRecord: {
}
}
scope: [{LexicalEnvironment,VariableEnvironment},...func[[scopes]]]
}
同时在这个过程中就已经创建了 innerFunc函数
(变量提升),所以这里会同步保存其作用域链到内部属性[[scopes]],注意这里会产生闭包
lua
innerFunc[[scopes]] = [Closure(func), ...func[[scopes]]]
6.func
函数执行,修改对应词法/环境的值,此时 innerFunc[[scopes]]里面的属性值也会变
innerFunc函数
执行,创建执行上下文但不会立刻执行......(后续步骤与func
函数类似,这里不过多重复了)
最后 innerFunc的执行作用域Scope = [{LexicalEnvironment,VariableEnvironment},...innerFunc[[scopes]]]
好了,我们再回头看之前的问题:
- 什么是作用域链?
- 函数的内部属性[[scopes]]是什么?
- 上面两者的关系或者说区别的是什么?
相信小伙伴对这几个问题已经比较清晰了,这里给出一个总结:
- 代码在执行的时候,遇到变量或者函数,会先从当前的
执行上下文
中查找(var
声明的变量在变量环境中查找,let、const、function
在词法环境中查找),然后往父级(词法层面的父级)执行上下文中查找,直到全局执行上下文,这个查找变量的链条便是作用域链 [[scopes]]
是一个对象数组,每一个对象里面都包含相对父级(词法层面)执行上下文中词法环境/变量环境声明的变量(闭包会比较特殊,只包含了内部使用了的变量)- [[scopes]]是代码最初编译阶段函数的词法作用域,作为函数的一个内部属性。函数执行时会创建执行上下文,此时复制函数[[scope]]属性创建作用域链添加到执行上下文中。随后创建变量/词法环境时将其添加到作用域链的头部形成自己完整的作用域。
作用域链
是执行上下文
中的某个属性
结语
到这里,就是本篇文章的全部内容了
在这整个过程有一个一直让人头疼的点没有详细介绍,那就是闭包。限于篇幅,闭包后续将单独抽一篇文章讲解。
如果你觉得该文章对你有帮助,欢迎大家点赞 、关注 和分享。
如果你有疑问或者出入,评论区告诉我,我们一起讨论。