【你不知道的JavaScript】作用域是什么?

时间:2021-2-20 作者:admin

前言

本系列为学习笔记,我将在此记录我从【你不知道的JavaScript】中获取到的知识,如果你也有兴趣,可以跟我一起学习。

编译过程

我们知道JS是一门动态语言,对于程序员来说,这门语言不能在构建时提前编译(加了Typescript就可以),但是对于程序来说,JS依然是一门编译语言,只是它的编译的时机与其他静态类型编程语言不同,但是其编译的方式步骤都是类似的

传统编译语言

在传统编译语言的流程中,程序中的一段源代码在执行之前会经历三个步骤,统称为“编译”。

  • 分词/词法分析

负责将字符串分解成代码块,例如var a=2,会被分解成var、a、2、;等,

  • 解析/语法分析

对于分词后的词法单元进行解析,比如上面的代码会被分解成抽象语法树(AST)

    variableDeclaration(var)
        /                 \
     Identifier(标识符)    AssignmentExpression(赋值表达式)
      变量a                 |
                 Numericliteral(数值文字)
                            number 2
  • 代码生成

代码生成就是将AST转化成机器指令,用来开辟内存,给这个内存储存值等

JS

JS在语法分析和代码生成的过程中会加一下额外的性能优化,例如垃圾处理等。

并且JS的编译在执行前的几微秒就完成,也就是说,等编译时对于程序员来说代码就已经执行了。

处理程序三要素

在处理程序代码时,一共有三个不同的要素:编译器、引擎、作用域

编译器负责的就是上面说的编译过程中的事

引擎全程参与编译执行环境

作用域在整个过程中负责所有声明的访问权限,它制定了一套严格的规则

场景

假设现在遇到 var a =2
对于引擎来说,这是两种不同的声明
分别是编译时期的处理,还有一种是引擎运行时的处理

编译器处理:

1、遇到 var a,它会询问作用域是否已经有一个已存在的变量。如果是,编译器会忽略这个声明,继续编译。如果不是,那么就要求作用域在当前作用域的集合里声明一个新变量,并命名为 a。

2、编译器为引擎生成运行时的代码,这些代码会被用来处理 a=2 这个操作。

引擎运行时处理:

询问作用域,在当前作用域是否有变量a,有就赋值,没有就找上一层作用域继续查找,直到找到并且给他赋值a=2

引擎查找方式

上面说到引擎会询问作用域并且查找变量,这里分为两种方式的查找RHS和LHS,简单记忆R就是右,L就是左,这里的基准是标识符。

例如var a=2,a在左边,就是LHS,a在右边就是RHS

当然,我们最好不要单纯以=记忆,因为很多形式的查找并不取决于=符号,例如

function fn(a){
    console.log(a)
}
fn(2)

这里的fn(2)会往回查找fn,这时候是获取fn函数,那么对于fn这个变量来说,就是RHS查找。这里有个细节,2会找到a这个变量,对它进行赋值,那么就类似于a=2,所以这是一次LHS查找。

最好理解赋值操作的目标跟源头的概念,这有助于我们区分LHS跟RHS。

我们把console.log(a)这句话分解为,获取a的值,并且交给console.log打印,就可以知道这是一次RHS查询。

作用域嵌套

作用域在整个过程中负责制定一套确定、查找标识符的方式规则,这个规则将影响引擎查找变量。

如果当前作用域中引擎没有找到变量,那么就会往外层查找,直到抵达全局作用域。

异常

区分RHS跟LHS是有必要的,因为这将影响查找异常后的提示,举个例子

var a=2
console.log(a)
console.log(b)

上面会进行两次RHS查询和一次LHS查询,LHS就是2赋值给a,而RHS查询就是log读取a跟b的值。

当读取到b的值时,会因为找不到声明而提示ReferenError

但是LHS查询就不一样,如果在全局作用域也没有找到某个声明,那么全局作用域会很好心地帮助创建一个全局变量,试试以下代码

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

上面的代码并没有声明a,在内存做LHS查询时,会往外层看有没有a的声明,没有就创建一个全局的a,所以代码就变成了

var a //全局作用域帮创建
function fn(){
...
}

tips:ES5之后引入的严格模式会阻止自动创建全局变量

当RHS变量查找到一个变量,但是你却做了不该做的事,比如你要取一个undefined的value值,就会报错TypeError

小结

  • 作用域是一套规则,用于帮助引擎查找变量。当引擎在此作用域找不到时,就会往外层查找。

  • 引擎查找的规则分两种:RHS和LHS。

  • 当需要获取变量内的值时,是一种RHS查找,当需要给这个变量赋值时,是一种LHS查找。

  • 如果RHS没找到,则会报ReferenError错误,如果找到了但是操作错误,则报TypeError

  • LHS如果不成功,在非严格模式下会创建一个全局作用域,严格模式下会有ReferenError报错

  • 赋值操作符会导致LHS查询。=操作符或调用函数时传入参数的操作都会导致关联作用域的赋值操作

词法作用域

上面介绍到,在编译时第一个阶段是分词/词法分析,这个工作阶段也称词法化,词法化的过程会对源代码进行检查,如果是有状态的解析过程,还会赋予单词语义。

这个概念是理解词法作用域的基础。

简单说,词法作用域就是定义在词法阶段的作用域,通俗点就是,你写在哪个作用域块里,哪里就是作用域。

举个例子

var a ="全局作用域下的a"
var b="全局作用域下的b"
function fn(){
    var a = "第一层函数下的a"
    console.log(a) //"第一层函数下的a"
   function fn2(){
        var a="第二层函数下的a"
        console.log(a) //"第二层函数下的a"
        console.log(b)//"全局作用域下的b"
        console.log(window.a)//"全局作用域下的a"
    }
    fn2()
}
console.log(a)//"全局作用域下的a"
fn()

上面会产生三种不同的作用域,分别是全局、fn、fn2,当在fn2这个作用域中查找b的值时,由于内层找不到,就会透出外层查找,作用域查找会在找到第一个匹配的标识符时停止。

在多层的嵌套作用域中可以定义同名的标识符,这叫作“遮蔽效应”。全局作用域会挂到window下,所以可以通过window.a来访问那些被同名变量所遮蔽的全局变量,但是函数内层却不能这样做。

小tips:只能访问到第一层标识符,并不能越层读取。

var a={value:1}
function fn1(){
  var a={name:2}
  function fn2(){
    console.log(a.value)
}
  fn2()
}
fn1()//undefined

通过上面代码我们只能访问到第一层函数a的值,不能越过去读取全局作用域下的value。

欺骗词法

词法作用域完全由写代码期间函数所声明的位置来定义,但是我们可以通过两种方式来欺骗词法作用域。

注意:下面两种方式会导致性能下降,一般情况下,不要使用。

eval

eval是一个函数,接受一个字符串为参数,并将其中的内容视为好像在书写时就存在于程序中这个位置的代码。换句话说,可以在你写的代码中用程序生成代码并运行,就好像代码是写在那个位置的一样。

在执行eval(..)之后的代码时,引擎并不“知道”或“在意”前面的代码是以动态形式插入进来,并对词法作用域的环境进行修改的。引擎只会如往常地进行词法作用域查找。

function fn(str){
    eval(str);
    console.log(b)
}
var b=2
fn("var b=1")

在调用eval后,var b=1就好像写在词法里一样,引擎并不知道是eval动态插入了一段字符串,所以解析时依然按照正常词法作用域解析,所以最后打印出的结果是1。

严格模式下不会出这样的问题,但是谁也不会用啊~

所以尽量避免使用eval

with

with通常被当作重复引用同一个对象中的多个属性的快捷方式,可以不需要重复引用对象本身。
例如

var obj={a:1,b:2,c:3}
// 重复引用代码
obj.a
obj.b
obj.c
//使用with
with(obj){
    a=2
}
obj.a //2

上面的代码其实就是少写了obj.,这样的方式目前已经弃用,但是这里要说的是它造成的bug,运行以下代码

function foo(obj){
with(obj){
a=2
}
}
var o1={a:3}
var o2={b:3}
foo(o1)
console.log(o1.a) //2
foo(o2)
console.log(o2.a) //undefined
console.log(a) //2

当我们修改o2的属性a时,因为不存在,所以不会修改。我们打印o2.a时是undefine也印证了这个情况,但是JS却把a=2当作LHS查找,产生了一个新的词法,导致生成了全局作用域a。

实际上还有一些欺骗性词法,我们尽量不要使用,因为其会影响到JS引擎的性能。

小结

词法作用域就是你在写代码时写在哪个块级作用域下,词法就定在哪。

我们可以通过eval或者with来骗取词法作用域,但是这样会造成性能缺失。使用这其中任何一个机制都将导致代码运行变慢。不要使用它们。

函数作用域和块级作用域

每个函数都有自身的作用域,根据最小暴露原则和最小特权原则,我们应当使用作用域的特点将变量隐藏起来,这样做得好处是避免全局变量污染。

目前我们可以使用两种方式来隐藏变量

1、立即执行函数来包装全局变量,这样就可以防止全局函数名的污染

(function(){}())
(function(){})()

2、模块化处理
类似于react、vue的模块化,实际上就是不同文件互不影响

块级作用域

在es6之前是没有块级作用域的,这句话很多人都说过,但不一定对

实际上with关键字跟try-catch都能创建一个块级作用域,就是除了函数作用域外受到{}的影响,在里面定义的变量对外层来说没有用处

let的出现改变了现状,我们都应该使用let进行声明,let不单单创造块级作用域,甚至会去除var的变量提升声明,不过还是有暂时性死区问题(如果想要理解请翻越我的这篇文章薛定谔的变量提升

垃圾回收

块级作用域还一个显著的特点,就是优化垃圾收集机制,试想如果我们执行了一段包含当前作用域的函数,里面产生了闭包,由于闭包机制使数据一直存在,那么有一些当前作用域下无用的变量也会一直存在,但是加上{}后,就相当于告诉引擎,这段代码执行完就可以删掉了。

所以我们应该经常使用{}来包围声明的变量。

let循环

试着比较这两段代码

for(let i=0;i<6;i++){
    console.log(i)
}
console.log(i)
//-----
for(var i=0;i<6;i++{
    console.log(i)
}
console.log(i)

你会发现使用var定义出来的i,变成了全局变量。而let的却不会

说明let声明会附带一个新的作用域而不是当前作用域。

constlet的效果是差不多的,不一样的仅仅是const是常量声明,必须赋值且不能修改

小结

我们应当有良好的编程习惯,使用声明时,将变量通过函数作用域或者块级作用域包起来,遵守最小暴露原则和最小特权原则。

尽可能拥抱es6,使用let或者const进行声明,防止var代码出现不必要的疑惑以及劫持某些变量

提升

对于var变量的提升,请查看薛定谔的变量提升

《你不知道的JavaScript》中讲解了更多原理

原理

原因是在编译过程中,编译的一部分工作就是找到所有声明,并且用作用域把它们包起来,这也产生了词法作用域

因此,对于编译来说,所有被声明的东西都应优先被放到首位处理,其次再执行代码,这样才会明确作用域和执行代码之间的关系。

否则的话作用域就乱套了。这也是最初的JavasCript设计者的构思。

以下这段代码

console.log(a)
var a=2

会被当成这样处理

var a
console.log(a) //undefined
a=2

具名函数函数也会产生类似的情况,我可以把调用放在定义的上面。

但是要注意,函数表达式不会被提升。

foo() //TypeError
var foo=function bar(){}

TypeError和ReferenceError的区别是TypeError是RHS查询成功但是操作失败的提示,而ReferenceError是查询不成功的提示。

说明var foo 被提升了,但是它还不是函数,是undefined,你不可能对undefined进行调用.

如果我把代码改成这样呢?

foo()
bar()
var foo=function bar(){}

上面代码我又使用了函数表达式,又使用了具名函数,会出现怎样的结果?

//TypeError
//ReferenceError

说明foo被找到了但是操作失败,bar压根没找到这个东西。换成以下形式大概就可以理解了。

var foo
foo()
bar()
foo=function(){
    var bar=...
}

函数优先

函数是JavaScript的一等公民,对于声明来说,同时声明相同变量会取函数。

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

上面代码中,function的foo会把var的声明提升覆盖掉,演变成以下代码

// var 被覆盖了
function foo(){console.log(1)}
foo()
foo =2

我们也可以说var是第一个提升上去的,然后被函数覆盖了,怎样都行。总之,JS选择了函数的提升。

小结

对于我们来说,var a =2 有可能只是一段声明,但是对于JS引擎来说,它会被分成编译阶段和执行阶段的任务。

首先编译会取分词、语法分析等工作,这时候把var a 取出来生成代码,同时还让作用域明确好了读写权限。

引擎运行时根据作用域查找变量a,最后给予赋值操作。

这就诞生了一种机制,无论相同作用域内变量声明放在哪,都会被优先处理,这个过程就是提升。

声明会提升,但是赋值操作不会被提升(包括函数表达式)

我们不管什么时候都应该避免重复声明以防不必要的bug

作用域和闭包

记住一点:当我们通过某种方式获取到一个函数内部的作用域时,就会产生闭包

function foo(){
    var a=2
    function bar(){
        console.log(a)
    }
    return bar
}
var baz= foo()
baz() //2

bar()的词法作用域能够访问foo()内部的作用域,而我们传递出bar,就实现了闭包的效果。

这种效果可不仅仅只是访问函数内部作用域,它还让垃圾回收器不再回收foo()的内存空间。

上面的代码中,变量a跟函数bar拥有涵盖foo作用域的闭包。使得这个作用域一直存在。我们可以把变量a+bar函数称为闭包。

无论通过何种手段将内部函数传递到所在的词法作用域以外,它都会持有对原始定义作用域的引用,无论在何处执行这个函数都会使用闭包。

6个6

看这段代码

for(var i=0;i<6;i++){
    setTimeout(()=>{console.log(i)},1000)
}

会打印出什么?

我们预期会打印出1-5,每秒一个,但是实际呢?

会输出6个6

这是为什么?

因为setTimeout会在循环结束时才执行。这时候i已经变成6了。

这是为什么?

因为我们试想循环中每个迭代都在运行时给自己捕获一个副本,但是在上面代码中,全部都是共享了同一个作用域,而此作用域下,i只有一个。

相当于这样

var i
for (i=0;i<6;i++){
...
}

此时i只有一个,作用域只有一个。

我们此时需要创造更多作用域。

将var改成let,会产生多种作用域。

使用立即执行函数,也可以

for(var i=1;i<=5;i++){
(function(){setTimeout(()=>{console.log(i)},i*1000)})()
}

上面这段代码并不会产生效果,因为立即执行函数虽然产生了作用域,但是i始终只有一个,我们需要在其内部创造更多的i。

for(var i=1;i<=5;i++){
    (function(i){                setTimeout(()=>{console.log(i)},i*1000)})(i)
}

上面的代码每次都将i传递进去,这样就相当于在多个作用域内产生了多个i。此时问题解决。

ES6模块化

ES6的import可以可以将一个模块中的一个或多个API导入到当前作用域中,并分别绑定在一个变量上

module会将整个模块的API导入并绑定到一个变量上。export会将当前模块的一个标识符(变量、函数)导出为公共API。这些操作可以在模块定义中根据需要使用任意多次。模块文件中的内容会被当作好像包含在作用域闭包中一样来处理,就和前面介绍的函数闭包模块一样。

总结

这里我抛出几个问题来作为这篇文章的总结
如果能够回答,那么基本上掌握了本章的内容

1、什么是词法作用域?

2、为什么会有变量提升?

3、函数提升的特点

4、什么是闭包?

5、闭包的特点

参考文档

weread.qq.com/web/reader/…

声明:本文内容由互联网用户自发贡献自行上传,本网站不拥有所有权,未作人工编辑处理,也不承担相关法律责任。如果您发现有涉嫌版权的内容,欢迎进行举报,并提供相关证据,工作人员会在5个工作日内联系你,一经查实,本站将立刻删除涉嫌侵权内容。