在每一门编程语言中,作用域都是一个最基础也是最重要的组成部分。学习JS后感觉到相对于Java,JavaScript的作用域比较「飘忽不定」,或者说有一些规则很容易被忽视,因为它们是隐式的。
一、作用域
1 什么是作用域
「作用域」从字面上理解便是:可发挥作用的区域。而从编程语言的角度来说,可以这么形容:「作用域维护了对所有变量的查询,以一套固定的规则判定当前的查询是否有权限访问这个变量。」通俗地讲就是你可以提出查询需求,但是能不能得到想要的由作用域决定。
在JavaScript中分为「全局作用域」和「函数作用域」,与其他类型语言不同的是它没有块级作用域(从ES6开始对于函数有块级作用域)。
if(true){ var a = 1;//全局作用域}console.log(a); // 1function foo(){ var b = 1;//函数作用域 console.log(a); //1}console.log(b); // ReferenceError 复制代码
在上面的代码中,a属于全局作用域,if后的花括号并没有形成块级作用域,而b属于foo函数的作用域,在JavaScript中外层作用域是访问不到内层作用域的(当然在其他与语言中也是不行的)。可类比于你可以从自己的房间中走出去到大厅拿东西,但是想从大厅随意到你房间拿东西却是不被允许的,因为隐私。
块作用域函数 在ES6之前,用一个花括号包裹函数声明并不会让它无法在花括号外被引用,但是从ES6开始,对于函数来说是有块级作用域的:
{ var a = a; function printMsg(){ console.log('hello'); }}console.log(a);//无论是ES6还是ES6以前的环境,都可访问到变量aprintMsg();//如果是ES6,抛出 ReferenceError: printMsg is not defined//如果是ES6之前的环境,则输出'hello'复制代码
2 作用域如何工作
2.1 声明提升
刚开始接触JavaScript的同学可能会对「变量先使用后声明」的现象十分不解,这并不是bug,要理解它我们得了解JavaScript编译的两个原则:①编译时声明 ②运行时赋值。
var a = 1;//相当于↓var a;//编译时a = 1;//运行时复制代码
以上面的代码为例,编译器在编译时将会使它为两部分:「var a ;」「 a = 1;」。前者为「声明」,后者为「赋值」——声明时首先在当前作用域中查询是否已经存在该变量,如果存在则忽略本次声明,否则在当前作用域创建一个新的变量a。声明后继续编译,生成运行时的赋值代码a=1,当引擎运行到这里时会在当前作用域查询名为a的变量,如果有便赋值给它否则继续寻找(后面将会介绍如何在作用域链上继续寻找)。
从以上两个原则我想大家很容易就看出来:运行时在当前作用域已经存在了一个a变量。如果你之前写过其他语言如Java,第一次看到下面的代码时你一定会十分惊讶,但现在我想你已经明白发生了什么。
a = 1;console.log('a:' + a );var a ;复制代码
由于「编译时声明」,当程序运行到a=1时,当前作用域已经存在了一个a变量,所以赋值时并不会出错,后续的调用也不会出错。「声明提升」中,函数的优先级更高,也就是说函数首先提升,然后才到其他变量。还需要注意的是,多次声明同名函数只有最后一个才会生效,前面的全部被覆盖。
function foo(){ console.log('foo 1');}function foo(){ console.log('foo 2');}foo();//'foo 2'复制代码
另外,在函数声明与函数表达式之间还有些区别:
foo('hello');//'hello' foo'function foo(a){ //函数声明 console.log(a); console.log('foo');}bar();//TypeError: bar is not a functionvar bar = function bar(){ //函数表达式 console.log('bar');}复制代码
上面的代码显示了函数声明与函数表达式的区别,函数声明在声明时「的确」声明了一个函数,标识符foo指向了一个函数,即后函数已存在可以正常调用;而函数表达式与普通变量的声明赋值方式是一样的,首先在编译的时候声明了一个变量,然后运行时构建一个函数再将函数的引用赋给这个变量。所以运行到bar()时,bar虽然已经存在,但是他只是一个普通的变量而不是函数类型,所以对于它进行函数调用将会报错。只有当程序运行到赋值代码将函数的引用赋值给bar变量时,才能进行函数调用。
在foo()函数中还隐藏了一个操作:被调用时,foo会自动在当前函数作用域内声明一个变量a,以便进行a = 'hello'赋值操作时进行左查询。
2.2 变量查询——LHS与RHS
既然作用域是用来规范变量查询的,那么就必须了解一下JS中的变量查询方式分类:①左查询(LHS)②右查询(RHS)。他们有着不同的行为,同时也是JavaScript中一些「怪异」现象的源头。
左查询(LHS)是指当变量位于赋值操作符的左侧时将进行左查询,引擎将要查找到存储变量的容器本身,以便对变量进行赋值操作。右查询则是指当变量位于赋值操作符右侧时进行的查询操作,但更确切地说是所有「非左侧」查询都属于右查询。一句话说就是左查询发生在赋值时,而右查询发生在引用变量时。
他们的区别在于:在非严格模式下,在当前作用域以及沿着作用域链向上查找都找不到目标变量时,右查询(RHS)将会抛出ReferenceError异常,这符合我们的预期,但是左查询在同样的情况下会在全局作用域中自动声明一个同名变量然后返回给查询者。在严格模式下如果找不到变量,两种查询方式都会报ReferenceError异常。
//一次右查询RHSconsole.log(a); //ReferenceError复制代码
//一次左查询LHSa = 1;console.log(a); // 1——自动创建了全局变量a复制代码
2.3 作用域嵌套
在本文开头我们讲到了内层作用域可以访问外层作用域中的变量,而外层作用域却不可以访问内层作用域的变量。它们实质上形成了一个嵌套结构,最外层的作用域是全局作用域,全局作用域中包含了许多函数作用域,而函数作用域内部又可以包含另一个函数作用域,由此形成了「作用域嵌套」。作用域嵌套可以无限嵌套,但是为了可读性最好不要这么做。
上图每一个蓝色方框代表一个作用域,进行变量查询时从当前作用域出发,如果找不到则往外一层,直至到达最外层(全局),最后依据是LHS还是RHS返回结果。
如果有多个作用域有我们需要查找的同名变量,我们会得到哪一个呢?原则是「作用域查找会在找到第一个匹配的标识符时停止。」例如:寻找_global变量将会依据3-2-1的路线寻找,一旦找到即停止,也就是说如果在作用域2中存在一个_global变量,将会导致查询终止返回作用域2中的变量,作用域1中的变量就永远不会被访问到,相当于作用域1中的_global变量被屏蔽了。不过对于全局变量来说,它会自动成为全局对象window的属性,所以我们依然可以通过window._global进行精确访问,但是对于其他作用域中的属性来说,一旦被屏蔽将无法访问到。
另外需要注意的是,JavaScript中的作用域是词法作用域,无论在哪里调用、如何被调用,词法作用域都只由函数被声明时所处的位置决定,调用一个函数并不会改变其外层函数。
2.4 运行时修改作用域
虽说JavaScript中的作用域在声明时已经决定了,但是还是有方法在运行时修作用域:通过eval()或者with()方法。
**eval(...)**接受一个代码字符串为参数,它将会让引擎认为参数中的代码在我们写代码的时候就在那里了,而不会知道这些代码是动态传递的。所以如果我们使用eval(...)并向其中传入声明变量或者函数的语句,将会修改当前作用域。
function foo(str){ var a = 1; eval(str); console.log(b);}foo('var b = 2;');// 2复制代码
**with(...)**用于将某个对象处理为一个完全隔离的词法作用域并置于最前,所以可以直接使用这个对象中的属性。
var obj = { a:1, b:2};function foo(obj){ with(obj){ console.log(a); x = 'inside with'; }}foo(obj); // 1console.log(x);//'inside with'复制代码
with(...)将使用一个对象生成新的词法作用域,并置于最前所以上面的代码能访问到a。而在上面的代码中我们发现,x在全局作用域中能访问到,这是为什么呢?因为with(...)块的内部的所有变量声明都将属于with所处于的函数作用域,所以依据LHS查询规则,x将会在全局作用域中被声明。
但是并不推荐使用这两个函数,它们在严格模式下将会受到限制或者禁用,另外当编译器编译时遇到这两个函数将会导致编译器放弃优化代码,因为它无法确定变量的具体情况,这两个函数对作用于的影响要到运行时才知道。
二、函数作用域
在第一部分中我们介绍了JavaScript中的两个作用域类型——全局作用域和函数作用域,同时也介绍了在发生嵌套的情况下如何查询变量;虽然JS中的确没有花括号自动形成的块级作用域,但是我们可以通过一些方法实现类似于块级作用域的效果。本部分首先介绍为什么要划分作用域以及函数作用域的表现,然后介绍JavaScript中的块级作用域实现方式。
1.划分作用域的意义
在第一部分我们提到过,外层作用域是无法访问内层作用域的,也就是除了调用函数返回结果外我们对细节一无所知。这种机制让我们有机会对特定的变量和函数进行「隐藏」,从而实现软件设计中常用的最小暴露原则,分离关注点。我们将私有的变量封装在作用域内部,一些必要的则暴露出来。客户需要一辆汽车我们便给他一辆汽车,客户不用了解我们是如何得到这辆汽车。
划分函数作用域的另一个好处就是让我们能规避同名变量之间的冲突,请看下面的代码:
function foo(){ function bar(msg){ i = 3;//将会修改for循环中的变量i console.log(msg); } for(var i = 0;i < 10; i ++){ bar('this is :' + i);//bar()运行后i=3,进入死循环 }}复制代码
for循环中的i被声明到了foo函数作用域中,每次循环调用bar()修改变量i,此举导致循环结束后i总等于3,永远小于10进入死循环。解决方法就是讲bar中的变量限制在bar函数作用域内——使用var声明i变量。但除非必要,我们最好不要使用同名变量。
命名空间 冲突最常发生的地方在于全局作用域,在开发中难免用到第三方库,当它们没有很好地隐藏内部变量与函数时便会造成冲突。所以它们一般会在全局作用域中声明一个名字独特的变量,通常是一个对象,用来暴露相关的api。我们将这个对象称为「命名空间」。
var myLib = { doSomething:function (){ }, finish:function (){ }}myLib.doSomething();复制代码
模块管理 模块管理与上述命名空间相似,将功能以模块划分并导入一个特定的作用域中,保持全局作用域的「干净」。
2.函数作用域
实现「隐藏」最简便的方法就是使用函数封装代码,但并不够彻底——我们必须声明一个函数并调用它。如果这个函数只需要调用一次,我们需要在调用后让这个函数消失,然而使用声明函数封装的方式会始终将函数留在外层作用域中。
function foo(){ //doSomething here}foo();//运行后foo仍处在全局作用域中。复制代码
我们需要一个会自动运行的「无名」(通称匿名)函数,而JavaScript提供了这样一个解决方案:立即执行函数(IIFE)。
立即执行函数(IIFE)
我们已经知道创建一个函数的方式有两种:函数声明和函数表达式。区分它们的方法是:在声明中如果function关键字位于第一位,那么就是函数声明,否则是函数表达式。这两种方式除了前面讲到的在编译声明时的区别,在函数标识符绑定位置上也有区别:函数声明将会把函数标识符绑定到声明所在的作用域,而函数表达式将会将函数标识符绑定到自身函数作用域。
下面来看看立即执行函数的实现方式:
(function foo(msg){ console.log(msg);'hello!IIFE'})('hello!IIFE');复制代码
注意这里有两个'()',根据刚讲到的判定原则,第一个()使得这个函数声明成为一个函数表达式,此外第二个()实现了函数调用。
为什么会这样呢?在JavaScript中,表达式计算完成后会返回一个结果,例如1+1表达式会返回结果2,而我们平常书写的var a = 1 + 1;则是将1+1表达式的计算结果赋值给a,所以a为2。对于函数来说也是一样,只是函数是复杂类型,所以返回的是引用。也就是说上面代码中的(function foo(msg){...})返回了foo函数的引用,然后我们在这个引用后面加上一个()就形成了调用,就像我们平常使用foo()进行调用一样,foo是函数引用,后面加上()形成函数调用。调用完成后,这个表达式构建的函数没有任何变量引用它,所以会被垃圾回收器回收。
另外我们提到过,函数表达式会将函数标识符绑定到自身函数作用域,所以上面代码中foo只存在于foo函数作用域中,不会污染外界作用域。
上一小节我们提到,我们想要一个会自动执行的无名(匿名)函数,之所以想要匿名就是害怕污染了外层作用域,上面我们看到了——即使有名也不会污染到外层作用域。但其实IIFE使用匿名函数也是可以的,因为函数表达式可以是匿名的,而函数声明不可以——在JavaScript中这是非法的。
(function (msg){ console.log(msg);'欢迎光临!' arguments.callee(msg);})('欢迎光临!');复制代码
这里使用了匿名函数实现立即执行函数,另外做了修改:通过arguments.callee(msg)递归调用实现了一个复读机不断输出「欢迎光临」。但是不推荐使用匿名函数,原因有三:
- 匿名函数在调试中不会显示出有意义的函数名,不便于理解。
- 如果想在匿名函数中使用递归,得像上面一样使用已经不被推荐的arguments.callee()。
- 匿名函数明显没有具名函数可读性强,代码不仅是写给自己看的,还要给别人看。
根据函数表达式绑定函数标识符的规则,始终给函数表达式添加一个函数名有利无弊:
setTimeout(function timeoutHandler(){ console.log('timeout!');},3000);复制代码
立即执行函数还有另一种形式——将第二个括号移入第一个括号中:
(function foo(msg){ console.log(msg);'hello!IIFE'}('hello!IIFE'));复制代码
选用哪种方式取决于你的喜好,它们都能表达出立即执行函数生效的机理。
三、块级作用域
JavaScript之外的很多编程语言都支持块级作用域,虽然JavaScript中最常见的是函数作用域,但是仍有方法实现块级作用域。我们先来看一看一段很容易让人误解的代码:
for(var i = 0;i < 6;i ++){ setTimeout(function timeoutHandler(){ console.log(i); },i*1000)}复制代码
如果你像我一样之前从事Java开发,那么在使用JavaScript第一次运行这段代码时,一定满心期待地想看到控制台输出0-5。嗯,你不会等太久,它输出了6个6!我们知道,JavaScript中有全局作用域、函数作用域,但就是没有花括号形成的块级作用域,所以这里的i被声明在了for循环所在的作用域中,6个timeoutHandler回调共享着外层作用域中的这个变量,当for循环运行完后i为6,轮到timeoutHandler回调依次运行,它们很诚实地给了你6个6。
我们一开始以为i会属于for循环后的花括号块级作用域中,但是却出乎意料,如果你不继续深入很可能就会下定论:JavaScript太坑了,完全看不懂。正常情况下我们都不希望变量i污染到外层作用域,它应该在for循环后的花括号块级作用域中,但是JavaScript真的没有这个实现。不过还是有办法的,而且也不难。
1 实现块级作用域的方法
with(...) 我们前面讲过,通过with传入的对象创建的作用域仅会在with的声明中生效而不是在外部生效,不过这个结构很复杂也不好用,请不要用它。 try-catch try-catch会在catch块中创建一个块级作用域,抛出的错误对象只能在catch后的花括号中使用。
try{ undefined();//执行非法操作}catch(err){ //此处为块级作用域 err.a = 1; console.log(err.a); // 1}console.log(err.a); // ReferenceError复制代码
虽然这是一个标准,但是谁也不想代码里到处是try...catch,所以这个方法也很少用。但是它却有很大的用处,比如用在ES6编译为ES5或更低版本时。
let 这是一个最方便使用块级作用域的方法了,let与var一样用于变量声明,不同在于let可以将变量绑定到所在的任意作用域中(通常是{...}内部),也就是let隐式地为其声明的变量绑定了块级作用域。
if(true){ var a = 'var'; let b = 'let';}console.log(a);//'var'console.log(b);//ReferenceError: b is not defined复制代码
看到这一幕,我想来自其他编程语言的开发者终于如释重负地舒了一口气:不用去面对混乱的作用域绑定了。回到我们的for循环,也终于能按照我们的期望输出而不是嘲讽般的666了:
for(let i = 0;i < 6;i ++){ setTimeout(function timeoutHandler(){ console.log(i); },i*1000)}//最后输出0-5复制代码
在这里i不但被绑定到了for循环块中,每次迭代它都被重新绑定了这样就确保了每一次绑定的i都是实时值。关于重新绑定涉及到闭包,后面会仔细讲一下。
不过需要注意的是,let不会像var那样进行变量声明提升。也就是如果在let声明前引用了它,将会引发错误。
const const与let相似,只是用const声明的变量无法第二次赋值,也就是说它是常量。如果它引用的是基本类型值,则一直引用着这个值;如果是一个对象,实际上不能改变的是对这个对象的引用,而不是对象。
//引用基本类型值const a = 1;a = 2;//TypeError: Assignment to constant variable复制代码
//引用复杂类型值const a = { name:'a'};a.name = 'b';console.log(a);//'b'a = {};//TypeError: Assignment to constant variable.复制代码
四、闭包(Closure)
很可能你已经大量地使用了闭包,但是却不知道闭包为何物,接下来我们一起探究一下闭包理解闭包。
1 什么是闭包
闭包是基于我们前面提到的词法作用域书写代码时自然产生的,并不需要多高深的技巧才能运用闭包。首先看一段我们常用的代码:
function foo(){ var a = 1; function bar(){ console.log(a); } bar();}foo();复制代码
我们一眼就能看出来,这里能访问到a是由于前面提到的作用域嵌套,这属于RHS引用查询。这是闭包吗?可以说是但不太准确,但是基于词法作用域的查询却是组成闭包的重要规则之一。我们来看一看闭包的实现:
function foo(){ var a = 1; function bar(){ console.log(a); } return bar;}var fn = foo();fn();// 1复制代码
上面这个就是闭包。从JavaScript的垃圾回收角度来看,这里foo函数只被调用了一次并且再也没有在其他地方被调用,一般情况下当foo函数运行完后这段代码就处于无用状态了,所以垃圾回收器会将这段代码销毁,随着销毁而消失的还有foo函数的作用域以及其内部嵌套的所有作用域。
也就是说,按理在上面的代码中bar()不应该是输出1的,因为如果函数foo被销毁了,a也被销毁。唯一的解释只能是foo函数在运行完后并没有被销毁仍然存在,可这不就和我们之前讲的「运行完后再无其他引用的变量或函数将会被销毁」矛盾吗?其实并没有,因为foo在运行完后虽然没有明面上的引用,但是却被隐式地引用了,所以它没有被销毁。
那谁在引用它呢?我们看回代码,foo运行返回的结果是bar函数的引用,我们将bar函数的引用赋值给了全局变量fn,所以现在是foo运行完后,有个fn变量引用了其内部的函数bar,而bar函数在运行时将要引用foo函数作用域的变量a。引擎知道foo函数执行完后其作用域还要保留着不能销毁,因为未来某一个时间点将会有一段代码要引用变量a。
这就是闭包的运用,无论通过什么手段将内部函数传递到所在的词法作用域外,它都会持有对原始定义作用域的引用,无论这个传递出去的函数在何处执行,它都将会使用闭包,而闭包就是它引用的那个作用域。闭包的阻止了某部分代码被垃圾回收,它说「你别死,再活一会儿。」
之所以说我们可能不知道闭包,但是一定使用了很多闭包,是因为下面这一段熟悉的代码就运用了闭包:
function wait(msg){ setTimeout(function printMsg(){ console.log(msg); },1000);}wait('hello!');//复制代码
setTimeout(...)在1s后将传给它的函数添加到运行队列,这个函数将会在全局作用域中运行,也就是说这个函数并不在定义它的词法作用域中运行,所以很明显地就产生了闭包运用。wait函数作用域将一直保留,直到printMsg函数运行完毕。
如果你把函数的引用传到了其他地方,例如常用的回调函数运用中,我们就是将一个函数的引用传递给了其他函数,从而使这个函数的运行环境不再同于定义它时的词法作用域,就是使用了闭包。也就是说,在定时器、事件监听、Ajax请求或其他任何异步(或同步)任务中,只要使用了回调函数就使用了闭包。
function print(msg){ console.log(msg);}function showMsg(msg,printor){ printor(msg);}//使用闭包showMsg('hello there!');//'hello there!'复制代码
2 循环与闭包
记得我们之前使用过的for循环吗?如果不使用let来声明变量i,将得不到我们想要的结果。现在我们就来研究一下为什么使用了let就可以得到我们想要的结果。再看一遍循环代码:
function testLoop(){ for(var i = 0; i < 6; i++){ setTimeout(function printNum(){ console.log(i); },i*1000); }}testLoop();复制代码
现在我们明白了:运行testLoop()时执行一个循环,先后六次将PrintNum函数添加到执行队列中,而这6个printNum函数都持有同一个闭包,引用了这个闭包中的变量i。在testLoop函数运行结束之前他们不会有运行的机会,当testLoop函数运行完后i也迭代到了6,这时候第一个PrintNum运行了,他到闭包中寻找变量i,如它所愿它得到了值为6的i,后续的printNum也是一样。
他们共享了一个闭包给我们造成了意想不到的麻烦,为了解决这个麻烦我们必须得让它们各自拥有自己的闭包,我们可以利用前面提到的立即执行函数(IIFE)来创建一个作用域。
function testLoop(){ for(var i = 0; i < 6; i++){ (function(){ setTimeout(function printNum(){ console.log(i); },i*1000); })(); }}testLoop();复制代码
但是向上面这样肯定是不行的,它们的确都拥有了各自的闭包,但是这些闭包都是空的没有任何变量,当需要引用i时还得根据作用域链的规则往上层寻找,最后还是找到了testLoop函数作用域中的i,它是6。既然这样,我们就得将变量保存在IIFE函数作用域中,以免回调函数向上查找找到i:
function testLoop(){ for(var i = 0; i < 6; i++){ (function(){ var j = i; setTimeout(function printNum(){ console.log(j); },j*1000); })(); }}testLoop();复制代码
由于变量i保存的是基本类型值,所以使用var j = i;实际上是复制了那个值,从而我们保存了当时i的值。另外每个IIFE都形成了函数作用域,所以所有的printNum回调函数都拥有了正确的引用值。
我们说过给函数传参实际上会隐式地声明一个变量,所以上述代码可以改进为:
function testLoop(){ for(var i = 0; i < 6; i++){ (function(j){ setTimeout(function printNum(){ console.log(j); },j*1000); })(i); }}testLoop();复制代码
在迭代内使用IIFE会为每个迭代都生成一个新的作用域,使得延迟函数的回调可以将新的作用域封闭在每个迭代内部,每个迭代中都会含有一个具有正确值的变量供我们访问——问题解决!
let
还记得我们之前使用过let关键字实现刚才那么多代码实现的功能吗?我们知道,let声明可以劫持块作用域,并在这个块作用域声明一个变量,也就是说它将一个代码块转换成了一个可以关闭的作用域。 所以解决方法又可以改进为:
function testLoop(){ for(var i = 0; i < 6; i++){ let j = i;//将此块转化为作用域 setTimeout(function printNum(){ console.log(j); },j*1000); }}testLoop();复制代码
好的,完美运行!但是还有更进一步的方法:for循环头部的let声明其实在每一次循环时都重新声明了变量,并用上一次迭代结束时的值赋值。 也就是说如果我们像下面这样写,也会使得循环快每次都被转换为作用域,并且let声明的变量属于这个作用域。
function testLoop(){ for(let i = 0; i < 6; i++){ //将此块转化为作用域 setTimeout(function printNum(){ console.log(i); },i*1000); }}testLoop();复制代码
3 模块化
前面提到过封装的一个作用就是实现模块管理,通过闭包我们可以很好地运用模块模式,隐藏一些不必要的细节,暴露一些必要的接口:
function myModule(){ var name = 'coolModule'; var version = '1.0.0'; function getInfo(){ return 'name: ' + name + 'ver'+version; } function update(){ version += 1; console.log('update to ' + version + 'successfully!'); } return { getInfo:getInfo, update:update }}复制代码
myMosule函数封装了两个私有变量以及两个私有函数,外界是访问不到的,我们通过调用myModule函数,返回一个持有内部函数引用的对象来操作内部数据。在运行时,返回的对象中的两个方法持有了myModule函数作用域的闭包。
可以这么描述模块:
- 必须拥有外部的封闭函数,该函数必须至少被调用一次以获取模块。
- 封闭函数必须至少返回一个内部函数,这样内部函数才能在私有作用域中形成闭包,并且可以访问或修改私有的状态。
一个具有函数属性的对象本身并不是真正的模块。从方便观察的角度看,一个从函数调用所返回的,只有数据属性而没有闭包函数的对象并不是真正的模块。
如果要求只能有一个返回的实例,即单例模式,那么可以使用立即执行函数:
var module = (function myModule(){ var name = 'coolModule'; var version = '1.0.0'; function getInfo(){ return 'name: ' + name + 'ver'+version; } function update(){ version += 1; console.log('update to ' + version + 'successfully!'); } return { getInfo:getInfo, update:update }})();复制代码
模块也是普通的函数,所以能向他传递参数实现动态构建模块,来看一个书上的例子:
var foo = (function CoolModule(id) { function change() { // 修改公共API publicAPI.identify = identify2; } function identify1() { console.log( id ); } function identify2() { console.log( id.toUpperCase() ); } var publicAPI = { change: change, identify: identify1 }; return publicAPI;})( "foo module" );foo.identify(); // foo modulefoo.change();foo.identify(); // FOO MODULE复制代码
这个例子在构建的时候传入了一个模块名,之后还动态地修改了模块方法的引用,从而实现功能切换,十分灵活。
3.1 模块管理
大多数模块管理/模块加载器本质上都是将模块定义封装在一个友好的API里。还是以书上的例子来说:
var MyModules = (function Manager() { var modules = {}; function define(name, deps, impl) { for (var i=0; i
其中,modules是一个模块存储容器,定义的模块以名字为索引存储在容器中。define用于定义新模块,它接受3个参数:模块名、依赖模块、模块定义函数;get则用于获取模块。下面是它的用法:
MyModules.define( "bar", [], function() { function hello(who) { return "Let me introduce: " + who; } return { hello: hello }; });MyModules.define( "foo", ["bar"], function(bar) { var hungry = "hippo"; function awesome() { console.log( bar.hello( hungry ).toUpperCase() ); } return { awesome: awesome }; });var bar = MyModules.get( "bar" );var foo = MyModules.get( "foo" );console.log( bar.hello( "hippo" )); // Let me introduce: hippofoo.awesome(); // LET ME INTRODUCE: HIPPO复制代码
在这里定义了foo和bar两个模块,而foo模块定义时以["bar"]来指定了模块依赖。仔细看一下这个模块管理器,它完全符合我们之前关于模块的定义。
五、小结
本篇文章从JavaScript简单的作用域切入,探讨了作用域的类型及作用域是如何影响变量的引用,最后深入研究了JavaScript中的闭包特性以及它的运用场景。希望通过本文能让读者对JavaScript中的作用域有大概的了解,同时明白JS中的一些「坑」是怎么来的,在工作中能尽量避免踩坑。
本文要点:
- 作用域以及作用域嵌套(作用域链)
- 函数作用域与块级作用域
- JavaScript中实现块级作用域的方式
- 立即执行函数(IIFE)
- 闭包
- 模块化
参考书籍:《JavaScript高级语言程序设计》《你不知道的JavaScript(上卷)》《JavaScript权威指南》