什么是作用域
在任何语言中,都需要存储一些变量,之后可以对变量进行修改和查找。
而作用域就是一组规则,告诉你在变量是应该在什么位置查找,什么位置存储,这就是作用域
编译器是什么
首先要明确的概念就是JavaScript是一种编译型语言,但它不像是其他编译型语言会在执行前被编译完成,而是边执行边编译,具体来说,编译仅仅是在执行前几秒中完成的。
那编译是什么呢?简单来说,就是将代码转化成引擎可以使用的形态,一般会有如下三个步骤:
- 分词/词法分析:就是将你的代码拆分开发,比方说
let a = 1;
,可能会被拆分成let
、a
、=
、1
、;
这几个部分,得到一个token流。 - 解析:将上面token流根据位置和节点转化成AST树(Abstract Syntax Tree),也就是所谓的“抽象语法树”。这部分在之前写Babel的文章中提到过,有兴趣的同学可以去看看。
- 代码生成:对AST语法树进行翻译,也就是根据树的内容一层层找到相应的解析后的代码,将对应的内容转译出来,得到转译结果。
如此便是编辑器的基本概念了,当然了,实际中编辑器的操作会复杂很多,此处为了方便理解最大程度上简化了编译器的内容,理解即可,重点不在此处。
理解作用域
要想了解作用域,首先我们需要了解三个基础概念:引擎、编译器和作用域。
编译器的概念之前说过了,就是讲代码翻译成引擎可以识别的语句。引擎则是执行编译后代码的具体内容。而作用域是辅助引擎在执行代码时如何访问和操作对象的一种规则。
有一点需要注意的是,在很多人眼里,编译器和引擎的工作可能是这样的:
代码:
let a = 1;
;
编译器转译代码,翻译let a = 1;
引擎执行翻译后的代码
很不幸的是,这是一种错误的理解,编译器和引擎是这样工作的:
代码:
let a = 1;
;
编译器转译代码,翻译let a
和a = 1
,因为let a
的存在,编辑器会让作用域在合适的位置来声明a
变量
引擎执行a = 1
,给a
变量赋值
这才是代码真正的执行逻辑,变量声明和赋值是分开的,也就是引擎的执行是有基础的,比方说我们要拍一场电影,那么首先我们需要找到合适的演员,场地等等,还要准备好剧本,之后子在拍的时候演员就要开始表演了。在JS中,编辑器会帮我们找到合适的演员,找到合适的场地,并将演员放到合适的位置上,把剧本给引擎。之后,在引擎执行,也就是电影开拍时,引擎根据剧本会给演员赋予角色内容,也就是变量的赋值操作。而引擎怎么找到演员呢?就是通过询问作用域,来进行演员的寻找。
虽然这样的比喻不是很恰当,当就编译器、引擎和作用域的理解来说,已经是比较合理的,三者的分工基本上就是这样的一种情况。
LHS和RHS
刚才说编译器会将let a = 1;
拆分成let a;
和a = 1;
。其实是将整个语句分成了“声明”和“赋值”量部分,在编译器中,声明语句被称为LHS(Left-hand Side),赋值语句被称为RHS(Right-hand Side)。其实也就是左手边和右手边,这样说也不太准确,因为右手边不仅仅是有赋值操作,从某种意义上来说,右手边意味着不是左手边。或者可以说RHS的意思是“取...的值”。 举个例子:
console.log( a );
复制代码
这里的a
的引用就是一个RHS引用,因为没有a
没有相关的赋值语句,所以在查询是,a
的值被传递到了console.log(...)
。 再举个例子:
a = 1;
复制代码
这里的a
引用就是一个LHS引用,因为不管a
是什么,这条语句的目标就是先找到a
,之后将= 1
赋值给a
。 总的来说,LHS和RHS并非代表着等号两遍的内容,而是“赋值的目标(LHS)”和“赋值的源(RHS)”。 最后举个二者都有的例子:
const consoleA = (a) => {
console.log(a); // 2
};
consoleA(2);
复制代码
上例用到的查询就相对来说,先是是consoleA
的调用,也是一个RHS查询。有一个细节部分就是a
被赋值成了2,这发生在2作为参数传递给consoleA
时,因为赋值的目标,所以是一个LHS查询。还有一个就是console
中包含RHS查询,这个在上面说过。需要注意的就是a
的隐形赋值,没有明确的语句将2赋值给a
,因为在调用中默默的就赋值了,这是比较容易忽视的一个点。
引起和作用域的交互
还是上面的例子:
const consoleA = (a) => {
console.log(a); // 2
};
consoleA(2);
复制代码
这里的引擎和作用域的工作流程是这样的:
第一次交互
引擎:询问作用域,需要一个consoleA
的RHS引用。
作用域:找到被编译器声明的consoleA
,返回给引擎。
引擎:执行consoleA
第二次交互
引擎:在函数内部找到a
参数,询问作用域a
参数的值
作用域:找到隐含赋值的2,返回给引擎
引擎:将2赋值给a
第三次交互
引擎:执行到console
,需要RHS查询console
是什么,询问作用域
作用域:拿到内建的console
,返回给引擎
引擎:执行console
,并在console
找到log
方法,执行
第四次交互
引擎:console.log()
中引用到了a
,RHS查询a
,询问作用域
作用域:查找a
的值,将2返回给引擎
引擎:执行console.log(2)
...
仅仅个简单到不能再简单的方法啊,引擎和作用域却有了四次交互,足以证明作用域在代码中有多么终于。因为作用域规范了变量的位置和值,所以一切的RHS和LHS查询都需要交给作用域。
作用域也有自己的规范,它会由内而外的查询,查到内容之后立刻返回,如果没找到就会报错了。就像俄罗斯套娃一样,从里面最小的套娃中开始查找,如果没有再在大一号的娃娃中查找,如此往复,一直会查找到最外层的套娃。
也是因为作用域由内而外的查询,LHS和RHS在查询失败时也会报出不同的错误。比方说下面这段代码:
const consoleA = (a) => {
console.log( a + b );
b = a;
}
foo( 2 );
复制代码
在查找b
的RHS查询自然是查询不到,因为b
根本就没有被定义,所以会报ReferenceError
的错误,也就是关联错误。我们对一个变量以函数的方式调用呢?比方说下面这样:
const a = 1;
a();
复制代码
这里a
的RHS虽然有查结果,但是a
并不是一个函数,而是一个变量,以函数的方式调用显然是不可以的,所以引擎会抛出一个TypeError
错误,意为类型错误。
所以简单来说,ReferenceError
报错是作用域解析失败,证明变量根本就没有被声明。而TypeError
意味着作用域解析成功,但是调用方式错了。
函数中的作用域
通过上面的内容我们了解了什么是作用域以及作用域的基本概念,那么什么可以创建作用域呢?首当其冲的就是函数了。
function eg() {
const a = 123;
console.log(a);
}
eg(); // 123
console.log(eg); // function eg (){<-->}
console.log(a); // a is not defined
复制代码
上例中声明了eg
函数,里面新建了a
变量,之后输出了a
。在函数外层,调用和打印eg
函数是没有任何问题的,但打印a
变量时就出现了undefined
的问题,因为a
是在函数eg
中声明了,在函数外层无法找到,也就证明了函数确实可以创建自己的作用域。
函数声明作用域的特点就是可以将函数内部的代码“隐藏”起来,外部无法访问函数内部的代码,假装“隐藏一下”。而在函数内部声明变量或者函数可以在很大程度上减少全局变量的产生,因为尽量不使用全局变量是开发的基本原则,也就是最低权限原则。这时函数产生的作用域就可以很好的实现这个原则,必反说有段代码如下所示:
function bo(a) {
b = a + ao( a * 2 );
console.log( b * 3 );
}
function ao(a) {
return a - 1;
}
let b = 0;
bo( 2 ); // 15
复制代码
此处有ao
和bo
两个函数,bo
中调用了ao
。但此处将ao
暴露成全局函数是完全没有必要的,因为没有别的地方调用了ao
。所以完全可以将ao
放到bo
中,不仅仅避免了在函数外部被无意中调用,也减少了全局变量。如下所示:
function bo(a) {
function ao(a) {
return a - 1;
}
let b = 0;
b = a + ao( a * 2 );
console.log( b * 3 );
}
bo( 2 ); // 15
复制代码
将ao
放到bo
中就很方便了,只有在bo
内部才能调用ao
,避免了很多不必要问题的产生。
IIFE——立即被调用的函数表达式
日常生活中,我们还会遇到一些比较尴尬的问题。有时候函数只会被调用一次,却依然要声明,这就十分尴尬了。声明后不仅仅污染了全局作用域,同时还需要调用才能执行,比方说下面这个例子:
const a = 2;
function ao () {
const a = 3;
console.log(a); // 3
}
ao();
console.log(a); // 2
复制代码
从例中可以很直观的看到在函数内部声明函数外部同名变量是不会有任何问题的,ao
中的a
不会覆盖ao
外部的a
。同时的问题就是ao
函数。如上所说,ao
污染了全局作用域,而且还需手动调用。解决这个办法的方法很简单:
const a = 2;
(function ao (){
const a = 3;
console.log(a); // 3
})()
console.log(a); // 2
复制代码
乍一看可能不好理解,其实比较简单。首先将两个()
拆开来看,第一个()
包裹了一个函数,那么这个函数就不再是一个函数的声明了,而是成为了函数表达式。表达式很好理解,可以即时运行的代码。区分函数和表达式也很简单,主要看function
是否是这行第一个东西:如果是,则为函数声明;若不是,则是函数表达式。
那么第二个括号呢?就是正常函数调用后面的括号,也可以往里面填充参数,若是第一个括号中的函数需要参数,即可直接获取。
如此便可直接调用函数了,简直不要太方便。
块级作用域
首先举个例子:
for (var i=0; i<10; i++) {
console.log( i );
}
复制代码
这种for
循环在ES5时代是再常见不过的了,很多前端初学者也写过这样的代码。其实这段代码有个很严重的弊端,就是在全局作用域中创建了i
变量。如果有别的地方使用了i
变量,则会导致循环失败或者无限循环,这是一个比较严重的问题。为了解决这个问题,需要使用块级作用域来将这段代码包裹起来,i
变量则不会污染全局作用域。
在ES6中,我们可以完美的解决这个问题,就是使用let
声明变量。let
将其声明的变量默认绑定在当前作用域中,也就是说除了当前作用域,在别的作用域中几乎无法访问let
声明的变量。也就是说上面的代码可以改成这样。
for (let i=0; i<10; i++) {
console.log( i );
}
console.log( i ); // ReferenceError
复制代码
此处使用let
将i
变量附着在了for
循环中的作用域,所以在别的地方是无法访问i
变量,打印的结果也是ReferenceError
。足以证明i
变量没有污染全局作用域。
与let
类似的,const
也有一样的功能, 只是const
声明的变量无法修改,无法修改也是相对的。
const a = [];
a = [1, 2, 3]; // Assignment to constant variable.
a.push(1);
console.log(a); // [1, 2, 3]
复制代码
直接赋值浏览器会直接抛出一个错误,但可以间接赋值,比方说使用push()
方法往数组中增加一个元素,打印结果证明确实完成了修改,这涉及到了另外一个问题,在后面的“入门到放弃”系列文章中详细解释。
提升
在很多人眼中,JS的代码都是自上到下一行一行执行的,在很多情况中这也确实是正确的。但注意了,这只是在很多情况下
a = 1;
var a;
console.log(a);
复制代码
你觉得这里的console
会打印出什么?并不是undefined
,而是1
。这就是上面所提到的那一小部分情况。再举个例子:
console.log(a);
var a = 1;
复制代码
你觉得这里会输出什么?并不是ReferenceError
而是undefined
。这是为什么呢?其实是文章开头提到的编译器的知识。
编译器会在引擎执行之前解释代码,而解释代码中有一部分工作就是找到所有的声明,并将其放在合适的位置上。所以在所有的代码被执行前,所有的声明,包括变量和函数都会被首先处理。所以上面两个例子应该是这种运行顺序:
// 例1
var a;
a = 2;
console.log( a ); // 2
// 例2
var a;
console.log( a ); // undefined
a = 2;
复制代码
虽然这种顺序看上去很诡异,但实际上确实是这种运行逻辑,声明被提升到最顶端,而且首先执行。需要注意的的,仅仅是函数或者变量声明才会被提示,之前提到的函数表达式并不会被提升。同时提升是以作用域为单位了,每个作用域内容的声明也会在其内部提升。
那么函数声明和变量声明有先后顺序么?有点,函数会被变量更先声明。举个例子:
// 代码顺序
ao(); // 1
var ao;
function ao() {
console.log( 1 );
}
ao = function() {
console.log( 2 );
};
---------------------
// 执行顺序
function ao() {
console.log( 1 );
}
ao(); // 1
ao = function() {
console.log( 2 );
};
复制代码
因为函数声明优先于变量声明,那么此处理所应当输出1
,很好理解。最后需要注意的就是若有多个同名声明,或者是重复声明,那么后续的声明会覆盖前一个。
作用域闭包
了解了上面的内容后,下面可以正式开始了解作用域闭包了,这个看起来很难的概念在了解了上面的基础知识后会变得十分清晰明了。
在一些语言中,在函数中可以(嵌套)定义另一个函数时,如果内部的函数引用了外部的函数的变量,则可能产生闭包。闭包可以用来在一个函数与一组“私有”变量之间创建关联关系。在给定函数被多次调用的过程中,这些私有变量能够保持其持久性。
闭包的官方解释可能不是很好理解,其实用通俗的语言来说,就是一个函数可以记住其在创建时的作用域,并且可以在其作用域外部执行。举个例子:
function ao() {
var a = 2;
function bo() {
console.log( a );
}
return bo;
}
var co = ao();
co(); // 2 闭包
复制代码
此处的co
就是一个闭包。其本身的作用域是函数ao
内容,但我们却在全局作用域调用了co
函数,co
函数中又调用了bo
函数,此时的bo
已经脱离其原本ao
的作用域了,转而在全局作用域中被调用。bo
依然拥有对那个作用域的引用,而这个引用称为闭包。
当然了,函数也可以作为参数传递给另外一个函数,而且在这种传递是间接时也可以。
var do;
function ao() {
var a = 2;
function bo() {
console.log( a );
}
do = bo; // 将`bo`赋值给一个全局变量
}
function co() {
do(); // 闭包
}
ao();
co(); // 2
复制代码
代码比较复杂,共有三个函数和一个变量。函数为:ao
、bo
和co
。do
是个变量。在ao
函数中包含了bo
函数,并且将bo
函数赋值给全局变量do
。在co
函数中调用了全局变量do
。在函数声明完成后,首先调用ao
函数,声明bo
函数,并且赋值给do
变量。最后调用co
方法,即是闭包。
也就是在调用co
时会调用do
函数变量,而do
又是ao
中的bo
,而bo
的作用域在ao
内部,被调用时却在全局作用域,因为co
执行时调用了ao
作用域中的变量,这中引用也就构成了闭包。
还记得之前提到的IIFE
么?其实从严格意义上来说,这并不是个闭包,因为函数并没有在其作用域外止息,它仍然在被声明的作用域中被调用。
最后举个最经典的例子:
for (var i=1; i<=5; i++) {
setTimeout( function timer(){
console.log( i );
}, i*1000 );
}
复制代码
相信很多前端开发者都看过这个问题,但可能对其原理不是很理解。首先说说结果,上面的代码并不会像我们预想中那样以1秒为间隔依次打印:1,2,3,4,5。而是会以一秒为间隔打印6次5。这怕不是石乐志,完全令人无法理解。
首先来说说6次是从何而来,在i
为5时,满足了i<=5
的条件,所以依然会有下一次循环,也就是i
为6时才会终止循环,所以会有6次打印。那么为什么一直都是5呢?这因为5是i
在循环结束后的最终值,虽然在i
为6时依然循环到了,但因为不符合条件,i
没有被执行++
操作,所以是上次循环的5。
那么现在的问题就是为什么会出现这种情况?到底缺少了什么才导致了这种情况的发生?
其实归根结底还是作用域的问题,代码的本意是在循环每次迭代时都会获取到迭代的值,并且传递给setTimeout
函数,但实际上并没有发生这种事,因为i
的作用域被放在了全局作用域上,相当于每次迭代的值都会被下一次迭代的值覆盖,所以最后只能得到最后的i
值,也就是5。想解决这个问题,只有在每次迭代时都给它一个作用域,让其i
值停留在迭代时的状态。想要这么做,首先可以使用之前提到过的IIFE方法。
for (var i=1; i<=5; i++) {
(function(i){
setTimeout( function timer(){
console.log(i);
}, i*1000 );
})(i);
}
console.log(i) // 5
复制代码
在IIFE中,括号内部的内容有着自己的作用域,此时将i
作为参数传递进去,IIFE内部的作用域即可获取固定的i
值,不会随着全局作用域中i
值的变化而变化。当然了,全局作用域中的i
得到的还是i
最后迭代的值,但这并不影响IIFE中i
的值,因为在作用域内部声明的同名变量会直接遮挡外部作用域,这一点之前也提到过。
那么有没有别的办法来解决这种问题呢?幸运的是,ES6提供了一种更简单的方法——let
。上文中提到过,每次使用let
时,都会劫持一个作用域,并且在其中声明一个变量。那么上面的代码就可以变成下面的样子:
for (let i=1; i<=5; i++) {
setTimeout( function timer(){
console.log( i );
}, i*1000 );
}
复制代码
方便快捷,不留遗憾。使用let
后,i
的值不会被重复的声明而覆盖,每次迭代的i
都会被绑定在当前迭代的作用域上,于是当前迭代中的setTimeout
即可获取到当前迭代中的i
值,而不是全局变量的i
。
上面的问题就是利用块级作用域和闭包联手解决的,当然了,这样的例子还有很多很多,更多的会出现在日常的工作者,熟练使用这两者可以再很大程度上让我们的开发更加顺利。
模块
闭包的使用中很重要的一部分就是模块,不仅仅是因为模块在日常的工作中十分重要,跟多的是模块对闭包的使用十分到位与彻底。
function GoodModule() {
const something = "ok";
const somethingElse = [1, 2, 3];
function somethingFuc() {
console.log( something );
}
function somethingElseFuc() {
console.log( somethingElse.join( "-" ) );
}
return {
somethingFuc,
somethingElseFuc,
};
}
const ao = GoodModule();
ao.somethingFuc(); // ok
ao.somethingElseFuc(); // 1-2-3
复制代码
上例是一个简单模块例子,由于GoodModule
只是一个函数,所以它需要被调用之后才能产生自己的作用域和闭包。作用域必须在函数调用时才会被创建,而闭包是当前函数在当前作用域外部被调用时同时可以访问原生作用域时产生的。上例中的somethingFuc
和somethingElseFuc
函数的作用域都是在GoodModule
函数中,而调用却是在全局作用域中,这时闭包的作用就完全展示了出来,无论在何处调用,somethingFuc
和somethingElseFuc
函数都会访问GoodModule
内部定义的变量。
当然了,在ES6中, JS提供了新的导入模块的方法——import
和module
。
ao.js
const hello = (name) => {
console.oog(`hello ${name}!`);
}
export { hello };
复制代码
bo.js
import hello from 'ao';
const words = 'welcome';
const world = (name) => {
hello();
console.log(words);
}
export { world };
复制代码
其他文件
import ao from 'ao';
import bo from 'bo';
ao.hello('red'); // hello rex!
bo.world(); // hello rex! /n welcome
复制代码
这种模块的方法脱离了曾经的AMD和CMD等等引入方法,统一的规则带来了更加规整的配饰,代码风格也更趋近于统一,开发更加方便。
小结
本文从最开始的引擎、编译器和作用域的解释,介绍了三者的关系,同时了解了LHS和RHS查询的关系。这对后期了解作用域与闭包的关系提供了必要的信息,之后对作用域进行详细的解释,最后结合作用域讲解了闭包和两者联动的操作,从JS原理上开始一步步增加对闭包和作用域的理解,记忆也会更加深刻。
文章较长,看了这么久,辛苦了,如有问题欢迎讨论!