大厂面试官爱深挖的作用域底层原理(看完征服面试官)

1,406 阅读10分钟

🐟前言

谈起作用域,我们首先要知道什么是作用域,他是几乎所有编程语言最基本的功能之一,就是能访问储存变量这个值,并且在之后能对这个值进行访问和修改。事实上,正是这种储存和访问变量的能力将状态带给了程序。

若没了状态这个概念,程序虽然也能执行,但做不非常有趣,他的高度有所限制。、将变量引入程序会带来几个非常有意思的问题,就是变量住在哪里(储存在哪里),程序需要它的时候怎么找到它?这些问题说明了我们需要一套规则来制定存储变量,并且在之后方便找到变量,这个规则就是作用域。

接下来我们来看看JS这门语言是怎么设置这套规则的!

我们来看一行简单的JS代码

var a = 1;

简单来说的话,他就是定义了一个变量a,值为1,但是JS执行在执行它之前,可谓经历了九九八十一难,在现代浏览器环境中,JavaScript 代码实际上会经历一个编译过程。这个编译过程发生在代码执行之前。我们来看看编译过程中发生了什么?

  1. **词法分析

    • 这一步将源代码转换成一系列的标记(tokens)。每个标记代表源代码中的一个基本单位,如关键字、标识符、操作符等。
    • 例如,var a = 2; 会被分解成 ['var', 'a', '=', '2', ';']
  2. **语法分析

    • 这一步将标记序列转换成抽象语法树(Abstract Syntax Tree, AST)。
    • AST 是一个树状结构,表示代码的逻辑结构。
    • 例如,var a = 2; 可能会被解析成一个表示变量声明和赋值的树结构。
  3. 代码生成

  • 将 AST 转换为可执行代码。AST转化为一组机器指令,用来创建一个叫作a的变量(包括分配内存等),并将一个值储存在 a 中。

  • 生成的代码可能包括以下几个步骤:

    1. 分配内存给变量 a
    2. 将数值 2 载入。
    3. 将数值 2 存储到变量 a 中。

理解作用域

为了更好地理解作用域,我们可以将这个过程模拟成几个人物之间的对话。主要的角色包括:

  • 引擎:从头到尾负责整个 JavaScript 程序的编译及执行过程。
  • 编译器:负责语法分析及代码生成等任务。
  • 作用域:负责收集并维护由所有声明的标识符(变量)组成的一系列查询,并实施一套非常严格的规则,确定当前执行的代码对这些标识符的访问权限。

引擎、作用域、编译器的对话开始了

当我们看见 var a = 2 的时候很可能认为这是一句声明。但是我们的朋友引擎却不这么认为,他认为这里有两种截然不同的声明,一个由编译器在编译时处理,另一个在引擎运行时处理。下面我们来看看引擎和编译器,作用域几兄弟是怎么协同工作的!

编译器首先会进行词法分析分解成词法单元,然后语法分析为抽象语法树,但是编译器的代码生成时,他爱对这段程序的处理方式会和预期不太一样。

首先遇到 var a,编译器会问作用域,是否已经存在一个该名称的变量在作用域了,没有的话就在当前作用域声明一个新的变量,命名为a,没有的话就不管他,忽略该声明,继续编译

接下来编译器会为引擎生成运行时需要执行的代码,这些代码是用来处理 a = 2 这个赋值操作的,引擎运行的时候会先问作用域,是否在当前的执行作用域中存在a这个变量呢,如果是的话,引擎就会拿走它,不是的话,引擎就会顺着作用域链继续查找改变量。(作用域链下文会提及

简单来说就是,变量的赋值操作其实分为两步,一步是声明该变量,如果变量已经在当前作用域存在,则忽略该声明,另一步是运行时引擎会在当前作用域查找该变量,找到的话就为他赋值。

进一步理解 var a = 2

编译器在编译过程的第二步生成代码,之后引擎执行这些代码,执行中对变量(如 a)的处理包括判断其是否声明,这需通过查找完成,而作用域在变量查找过程中起协助作用,引擎不同的查找方式会使最终查找结果产生差异。

在我们的例子var a = 2中,引擎会对变量a进行LHS查询,直观翻译的话,他就是LEFT-HAND-SEARCH,右左呢就肯定就有右对吧,没错与他的对应的是RHS,可以理解为当变量出现在赋值操作的左侧就进行LHS查询,出现在赋值操作的右侧就进行RHS查询,其实这样理解比较片面,我们更倾向于将RHS理解为查询谋个变量的值,即retrieve his source value得到他的源值,LHS则是找到变量的容器这样一个操作,即locate the variable's container

我们来看两个例子理解 LHS 和 RHS

console.log(a)

这里我们根本不需要给a赋值,也不需要找到他的容器,我们只想知道他的值为多少,查找并取得a的值,这样才可以传递给console.log(...)

a = 2

这里我们需要找到a的容器,我们不关心当前的值,只想找到2它应该塞到什么地方去!

再来看一段复杂一点的分析

function foo(a) {
    console.log(a);  //2
}
foo(2)

你知道上面进行了几次RHS,几次LHS吗?肯定很多人认为这里只有RHS,没有发生LSH,其实并不然,这里发生了LHS,但是他很隐蔽!它发生在实参给形参传递值,即将2分配给参数a,这里就是一个我们容易忽略的LHS

我们可以请引擎和作用域两位演员为我们呈现这个过程

image.png 看完他们两兄弟的对话是否让你对LSH,RHS兄弟俩有了更深层的认识呢,呢就让我们趁热打铁,再分析一段代码。

    function foo(a){
        var b = a;
        return a + b;
    }
    var c = foo(2);
  • LHS有三处 c=...; a=2(隐式); b=...
  • RHS有四处 foo ; =a ; return 中的 a 和 b

作用域嵌套

何为作用域嵌套

当一个块或函数嵌套在另一个块或函数中时,就发生了作用域的嵌套。因此,在当前作用 域中无法找到某个变量时,引擎就会在外层嵌套的作用域中继续查找,直到找到该变量, 或抵达最外层的作用域(也就是全局作用域)为止。

上文提及的作用域链

上面中我们提及了作用域链,说如果引擎在执行代码的时候,访问到了一个变量,会有一个查找顺序,先是在当前执行作用域,然后顺着作用域进行查找。接下来我们可以好好认识一下它到底是什么? 上文中说过作用域它是一套用于查找访问变量的规则,但是在程序运行的时候,实际上往往不止存在一个作用域,会有许多作用域共同作用于程序.

爱情故事小栗子

我可以举一个例子给大家,在大家情窦初开之时,往往会有所念不可得之人,你在校园中遇到了一个让你一见倾心的女生,你很想知道他是哪个班的,但是没等你跟着她背后去看看,他就已经消失在了人海之中,你只有在呢栋教学楼中寻找她的班级,第一层没找到,去第二层,一直找到最顶楼,如果还没找到的话,呢么有可能她根本不存在,你会心灰意冷的停止查找。

image.png

一段代码进行加强理解
function foo(a) {
    console.log(a+b);  
}
var b = 2;
foo(2)  //4

对于变量 b 的 RHS 引用,在函数 foo 内部是 没办法实现的。不过,可以在它的上一级作用域中完成,在这个例子里,上一级作用域就是全局作用域。也就是说,当在函数 foo 内试图获取变量 b 的值(即进行 RHS 查询)失败后,引擎会顺着作用域链到全局作用域去查找 b 的值。

为什么要区分LSHRHS

这是因为在变量还没有被声明的时候(就是变量哪里都找不到的时候),他们两种查询方式得到的结果不一样的! 我们来看两段例子去理解上面这句话

function foo(a) {
    console.log(a + b);
    b = a
}
foo(2)

大家可知道上面代码的执行结果

image.png 想必大家都能知道 因为b确实没定义过,但是下面这段呢?

function foo() {
    b = 2
}
foo()
console.log(b)

image.png 什么?他居然输出了b,b我们没有定义呀,这不科学!不,这很科学,这就是细节中的细节,因为他们两个的查询操作不同,第一段代码是RHS,第二段是LHS.

  1. 不成功的 RHS 引用会导致抛出 ReferenceError 异常。
  2. 不成功的 LHS 引用会导致自动隐式 地创建一个全局变量(非严格模式下

当引擎执行LHS查询时,如果在顶层(全局作用域)中也无法找到目标变量, 全局作用域中就会创建一个具有该名称的变量,并将其返还给引擎,前提是程序运行在非 “严格模式”下。执行RHS查询在所有嵌套的作用域中遍寻不到所需的变量,引擎就会抛出 ReferenceError异常。

但是在严格模式中它禁止自动或隐式创建全局变量,LHS 查询失败时会抛 ReferenceError 异常,和 RHS 查询失败情况类似。

END

理解了作用域的底层原理之后,你就能明白为什么有时候访问不到变量,访问变量和存储变量的规则是什么。只有深入掌握了作用域的底层原理,才能更好地理解代码中变量的行为,从而更准确地编写和调试代码,将这些底层原理讲给面试官听,保证面试官对你刮目相看!

如有问题可以在评论区讨论提问,大家一起进步!