德鲁克说:“在人类历史上,管理首次解释了为什么我们能够在生产领域中雇用了大量知识工作者与技术人员,这使得生产效率有了很大的提高,在以往的任何一个社会却无法做到这一点。事实的确如此,以往的社会无法容纳如此众多的人员。即使在不久以前,仍然没有人知道如何把具备不同技术与不同知识的人集合在一起,以实现一个共同的目标。” 其实不止人类社会,编程语言JavaScript也经历过这样的过程。
少年JavaScript之烦恼
科技是推动历史前进的主要动力,不过历史并不是直线向前的,而是走螺旋式上升路线。比如上网这个事,自从进入4G时代,通过手机上网方便得很。实际上,在G前时代,也就是上个世纪90年代之前,大家也是通过电话上网的。只是那时候的电话很顾家,不会跟着主人到处跑。我们总是从电话上引出一根电话线来插到“猫”上,从电脑上引出一根网线来也插到“猫”上。每次上网前要先拨号,等着猫叫了一阵儿后,就可以到网上去冲浪了。都是靠电话上网,但形式上已经螺旋式上升了。回想起来,那时候条件确实艰苦,不像现在这样,走路吃饭的时候都可以泡在浪里,可以泡的整个人浪得不要不要的。
那时候的页面还都是静态的,人们连上网之后,会打开Mosaic(早期最流行的浏览器之一,中文名“马赛克”),输入网址,冲好咖啡,对着白白的屏幕安静地等Mosaic从服务器上把HTML下载下来,然后再等着它稳稳地把页面渲染出来。一般在等了几分钟后,就可以开始阅读本文这种严肃认真的文字了。
本来Mosaic可以一直这么稳稳地幸福下去的。可它的一个开发人员Marc Andreessen大学毕业后搬到了加利福尼亚,又遇到了肯投资给他的Jim Clark,于是Marc创办了网景公司,推出了革命性的Navigator(现代浏览器鼻祖,中文名“导航者”)。Marc知道大家都很猴急,不能等,所以Navigator可以一边下载HTML一边渲染页面。在敲响了Mosaic的丧钟之后,Navigator很快就坐到了浏览器霸主的宝座上。
然而Marc并没有停下前进的脚步,因为他知道,互联网的广阔天地大有可为。1995年4月,网景请到了编程小能手Brendan Eich,希望他能让Schema在Navigator中跑起来。不过这个当时还只有34岁的小伙子明显有更远大的抱负,经过10天的不懈努力,愣是赶在Navigator 2.0 Beta版发布之前搞出了一种新语言。啥也别说了,赶紧跪拜吧!
不过网景众神最初对这门语言的期望应该并不是很高,从起名字的过程就能看出来。这门语言刚出来的时候叫Mocha,后来可能觉得抹茶这样的名字太不严肃了,所以在9月份改成了LiveScript,然而9月还没过完,就又改成了JavaScript。如果按我们中国人给孩子起名字的习惯,大致相当于走了一条宝宝-》狗剩-》富贵这样的路线。
扯了这么多闲篇,其实只是想说JavaScript这孩子先天条件不太好,最突出的问题是天生不带搞组织工作的细胞。这对于一个孩子来说不是什么大问题,毕竟在早些年,JavaScript干的都是跟用户互动一下,检查检查表单数据有效性这样的任务,一般有一两行代码也就够了,多了也就是百十来行,根本遇不到码际关系这么成人的问题。所有代码往<script>
标签里一丢就行了,最多也就是多搞几个标签的事儿。
后来网站越来越多,而交互体验好的网站明显更受欢迎,JavaScript这个倒霉孩子肩上的担子也就越来越重。2004年4月1日,谷歌推出的Gmail更是靠JavaScript完成了大部分页面渲染工作。大家都想要Gmail那样的效果,但基本上没有几家公司能出得起那样的工资。所以2006年jQuery一出来就迅速流行开,直到现在,排名在一千万名之前的网站里大概依然有百分之六七十用到了jQuery。用JavaScript随便写个什么也动辄要成千上万行的代码了,代码多了,就要考虑变量名冲突、依赖管理等等一系列问题。面对没有组织能力的JavaScript,码农们表示很愁,非常愁,恰似面对一堆乱线头。
这么多年了,是时候解决组织问题了,JavaScript长大了,就要有长大的样子!
大块头JavaScript的小奋斗
模块化编程不是什么新玩意,别的语言已经搞了几十年。JavaScript因为出身问题之前没搞,半路走模块化路线比较困难。但码农是战斗族群,怎么可能会被困难吓倒呢?老司机,有套路,先搞清楚需求:
- 我希望,代码可以分开,能切成一块块,不大不小在文件里存放;
- 我希望,代码不用复制,可以分享,需要时可以出现在另一个现场;
- 我希望,代码可以依赖,还要管理好,需要的时候加载一次就好;
- 我希望,代码都有自己的空间,你给变量用的名字,别人用也无妨。
需求明确了就好办,办法总比困难多,语法层面不支持可以靠模式,模式不够强还可以用库。所以就有了:
- 对象字面值模式
- IIFE/揭示模块模式
- CommonJS
- AMD
- UMD
虽然这些现在都不用了,可了解历史才能明白现在的幸福生活来之不易,才能明白现状为什么会这样。所以我们还是有必要一个个看看。
对象字面值模式
其实JavaScript也不是完全没有组织的,它有Object
,可以完成小范围的组织工作。
这种办法最大的好处就是容易理解,实现起来简单。但要保证这个全局的person变量的安全很困难,一旦别人再声明一个person变量,那你的东西就全丢了。
另外这种代码是没办法重用的,比如要再写一个会打字的猴子,只能把代码再复制一次:
并且这种代码对文件的加载顺序也有很严格的要求,一搞不好就会出错。
IIFE/揭示模块模式
IIFE(一飞)是Immediately Invoked Function Expression的缩写。就是把一个函数的定义放在括号里,然后再紧跟着一个括号。如果那个函数需要参数,就把参数放到第二个括号里,这个函数会立即执行。让这个函数返回一个对象,就是揭示模块模式。
借助闭包的特性,我们在IIFE内部的控制力度大大增强了。比如说,innerName
基本上是私有的啦,因为外部代码都访问不到它,而同时也给开了个口子,可以通过返回对象的name
属性访问。我们有了构造器一样的功能,可以给person
内部的name
设定默认值。如果需要从外部重新设定name
,可以在参数中指定。最后,将这些技术整合到一起,我们可以在author
中实现修饰器模式,从而将author
的代码跟它所依赖的创建者解耦。
IIFE/揭示模块模式提供了很多特性,并且对后面要介绍的AMD产生了很大影响。但这个写法。。。太丑了。并且它依然没能完全解决全局作用域的问题。
CommonJS
其实早在Node.js之前,JavaScript就有服务器端版本。不提网景的Server Side JavaScript,还有Ringo、Narwhal和Wakanda等等先驱者,不过最终只有Node.js火起来了。服务器端代码都是比较复杂的,组织不好就是灾难性的后果。所以大家成立了一个工作组,想搞一个服务器端JavaScript的模块标准。
有个笑话,骆驼是委员会设计出来的马。设计这种活不是委员会的长项,CommonJS工作组一直也没能搞出什么像样的成果来。
Ryan Dahl写Node.js时借鉴了CommonJS的一些思想,所以Node.js的模块管理机制被大家误以为是CommonJS的成果。但Ryan Dahl说:
I generally support the CommonJS idea, but let’s be clear: it’s hardly a specification handed down by the gods (like ES5); it’s just some people discussing ideas on a mailing list. Most of these ideas are without actual implementations. – Ryan Dahl, creator of node.js
由于是为服务器端设计的,所以使用这种模式要求有一个符合标准的脚本加载器。这个加载器要支持require
和module.export
,虽然这种实现在浏览器中没流行起来,但确实可以通过Browserify等工具在浏览器中使用。
这种写法即清晰又简洁,ES 6的模块语法在很大程度上就是借鉴了CJS。另外,模块限定了变量的作用域,我们甚至都不能在模块中声明全局变量了。但它不适合在比较慢的网络环境或比较弱的设备上使用,因为它必须在所有的require
都加载完之后才能开始执行,不能异步加载,懒加载实现起来也很困难。
AMD
CommonJS工作组干的最搞笑的事就是这个,他们没能在服务器端模块标准上形成统一意见,倒是通过他们的讨论促成了AMD(异步模块定义)的形成。AMD的幕后推手主要是IBM和BBC,并且很快在前端开发中形成了主流。
AMD也要有脚本加载器的支持,不过它的加载器只要支持一个define
方法,这个方法有三个参数:分别是模块的名称;运行这个模块所需的依赖项,放在数组中;一个在所有依赖项都加载完后运行的函数。只有最后这个参数是必须的。
AMD的define
方法跟CJS的export
作用一样,但AMD没有指定跟require
对应的方法。模块内的依赖项是由define
的第二个参数指定的。
AMD辛苦打拼多年,但这只丑小鸭最终也没能变成天鹅。一直要写很多套路化的代码,并且由于浏览器要求的异步特性,无法进行静态分析。
UMD
据说好的折中方案就是最后谁都不满意。UMD(统一模块定义)试图统一CJS和AMD,但通常只是在跟AMD兼容的封装器中封装CJS语法。没得到多少好处,两边的毛病倒是一个都没少的拿过来了。不过它确实同时兼顾了服务器端和浏览器端,所以这里还是提一下好了。
ES 6的“露”和“要”
负责设计ES 6的TC39委员会充分吸取了CJS和AMD的经验教训,时隔15年后推出的这个版本终于开始支持模块化编程。虽然现在还有很多浏览器不支持,但有Bable等工具可以转换,所以最起码可以像写出下面这种整洁的模块化代码了:
在ES 6中,一个包含JS代码的文件就是一个模块,跟模块相关的关键字只有两个:export
和import
,用法也比较简单。(哦,default
、from
和as
是配角,不算。)
export
JS文件中所有顶层的function
、class
、var
、let
和 const
之前都可以出现export
,也就是说这些关键字定义的元素都可以开发给别的模块使用。
也可以把所有要export
的元素放在一个对象中:
export {
MessageManager,
UserManager,
MenuManager,
CustomerMessageManager,
CustomerServiceManager
}
不过这种方式并不是为了减少输入,虽然可以少写很多export
,但元素的名称都要重新写一次。目前为止我只发现了要使用这种写法的两个场景。
第一个是要把底层开放的API向上层传递时。比如在wechat-es中,要把所有的manager从managers目录下向上层目录传递。可以在managers目录下创建了一个index.js文件,然后把这个目录下的所有Manager import
进来,再export
出去。
还有一个场景跟这个有关,上面提到的每个Manager中都有很多function
,需要把这些function
export
出去,但要能作为一个对象中的方法调用。所以我将这些function
放到了一个对象中,然后将这个对象作为default
export
出去。
你看到了,export
后面还可以出现default
,不过这个default
只是表示它后面所跟的值名称是default
,没有任何其它特殊的含义。所以export default myObject
跟export {myObject as default}
是一样的。
default
后面只能是值,在JavaScript中,字符串、数值是值,function
、class
是值,对象字面量也是值,所以它们都可以出现在export default
后面,但var
、let
和 const
不行。不过,可以把变量名放在export default
后面。也就是说虽然export default const test = 1
不行,但
const test =1
export default test
是可以的。不过,既然default
跟test
一样都是值的名称,似乎也没必要多此一举,export default 1
就可以了。
export
除了可以开放自身模块中的元素之外,还可以加上from
开放其他模块中的元素。这种情况通常出现在一个包的主模块中,因为主模块通常就是为包中各模块开放的元素提供一个统一的访问入口。比如:
不过要注意的是,这样并不会把export
后面的元素引入到当前模块中。还有,要慎用*
,因为它可能会引起名称冲突。为了避免冲突,export
语句中可以用as
给元素指定一个新名字。
import
import
有三种用法,带括号{}
的,不带括号的,和*
。
带括号的import
要在括号中指定从from
指定的模块中导入哪些元素,括号里的名称要和那个模块export
中指定的名称一致。为了避免命名冲突,括号中的每个名称都可以用as
指定一个新名称,比如:
import {each as _each} from "lodash"
不带括号的相当于从from
指定的模块中导入它的default
,并指定一个名称。即import _ from "lodash";
相当于import {default as _} from "lodash";
,其效果等同于CJS中的var _ = require("loadsh");
。
import * as cows from "cows"
这样的语句导入的其实是一个模块命名空间对象。也就是说,如果一个模块有多个export
,但没有export default
时,import *
的作用相当于将那个模块的default
指定为cows
,而cows
中包含了它所有的export
元素。也就是说,如果“cows”模块导出一个名为moon()
的函数,用上面这种方法将“cows”导入后,我们就可以调用cows.moo()
了。但从Babel编译的结果来看,最好还是在cows
模块中定义一个default
。
from
from
既可以出现在import
之后,也可以出现在export
后面,用来指定要引入或开放的模块。from
后面一般是指定文件名,.js
可以忽略;如果是目录,则是表示该目录下的index.js
文件中的模块。
综上所述,ES 6中的模块化编程就是用好export
、import
,以及配合它们两个用好default
、*
、as
和from
。很简单,并不需要了解JS引擎运行模块代码的过程:
- 语法解析:读源码,检查语法错误。
- 加载:加载所有被导入的模块,以及被导入模块导入的模块,这是个层层递归的过程。
- 连接:每遇到一个新加载的模块,为其创建作用域,并将模块内声明的所有绑定填充到该作用域中,其中包括由其它模块导入的内容。举个栗子,如果代码中有
import {cake} from "paleo"
这样的语句,而“paleo”模块并没有export cake
,这段代码就会触发一个错误。 - 运行时:最终,在每一个新加载的模块体内执行所有语句。导入的过程在连接阶段就已经结束了,所以当执行到达有
import
语句的时候什么都不会发生!