有一定Web前端开发经验的人,很多都会有这么个想法:那些写框架的人好厉害,什么时候我才能写一个自己的框架呢?有时候看看别人的框架代码,又觉得很复杂,不知道从何看起,只有很少的人突破了这个界限,领悟到了更深层的东西。
对于这种情况,我觉得有必要改变一下。为此,打算自己写几个系列的文章来让很多人能从中领会一些前端框架的知识,带领他们走进框架开发的殿堂。
为了说明框架的一些基本原理,我写了一个简单的框架,取名为thin。thin框架的核心是模块定义和加载机制,整个框架唯一暴露的全局变量是thin,包含了模块定义,模块获取,日志等基本功能,其余一切功能都按照模块挂接在框架上。
thin框架的最小发布单元是模块定义和加载机制,其他一切功能都作为可选组件。
可选组件包括:
– 通用帮助类
– DOM操作
– 远程调用
– 视图模型和数据绑定
– 控件库
1. 模块的定义和加载
1.1 模块的定义
一个框架想要能支撑较大的应用,首先要考虑怎么做模块化。有了内核和模块加载系统,外围的模块就可以一个一个增加。不同的JavaScript框架,实现模块化方式各有不同,我们来选择一种比较优雅的方式作个讲解。
先问个问题:我们做模块系统的目的是什么?如果觉得这个问题难以回答,可以从反面来考虑:假如不做模块系统,有什么样的坏处?
我们经历过比较粗放、混乱的前端开发阶段,页面里充满了全局变量,全局函数。那时候要复用js文件,就是把某些js函数放到一个文件里,然后让多个页面都来引用。
考虑到一个页面可以引用多个这样的js,这些js互相又不知道别人里面写了什么,很容易造成命名的冲突,而产生这种冲突的时候,又没有哪里能够提示出来。所以我们要有一种办法,把作用域比较好地隔开。
JavaScript这种语言比较奇怪,奇怪在哪里呢,它的现有版本里没package跟class,要是有,我们也没必要来考虑什么自己做模块化了。那它是要用什么东西来隔绝作用域呢?
在很多传统高级语言里,变量作用域的边界是大括号,在{}里面定义的变量,作用域不会传到外面去,但我们的JavaScript大人不是这样的,他的边界是function。所以我们这段代码,i仍然能打出值:
1 2 3 4 |
for (var i=0; i<5; i++) { //do something } alert(i); |
那么,我们只能选用function做变量的容器,把每个模块封装到一个function里。现在问题又来了,这个function本身的作用域是全局的,怎么办?我们想不到办法,拔剑四顾心茫然。
我们有没有什么可参照的东西呢?这时候,脑海中一群语言飘过: C语言飘过:“我不是面向对象语言哦~不需要像你这么组织哦~”,“死开!” Java飘过:“我是纯面向对象语言哦,连main都要在类中哦,编译的时候通过装箱清单指定入口哦~”,“死开!” C++飘过:“我也是纯面向对象语言哦”,等等,C++是纯面向对象的语言吗?你的main是什么???main是特例,不在任何类中!
啊,我们发现了什么,既然无法避免全局的作用域,那与其让100个function都全局,不如只让一个来全局,其他的都由它管理。
本来我们打算自己当上帝的,现在只好改行先当个工商局长。你想开店吗?先来注册,不然封杀你!于是良民们纷纷来注册。店名叫什么,从哪进货,卖什么的,一一登记在案,为了方便下面的讨论,我们连进货的过程都让工商局管理起来。
店名,指的就是这里的模块名,从哪里进货,代表它依赖什么其他模块,卖什么,表示它对外提供一些什么特性。
好了,考虑到我们的这个注册管理机构是个全局作用域,我们还得把它挂在window上作为属性,然后再用一个function隔离出来,要不然,别人也定义一个同名的,就把我们覆盖掉了。
1 2 3 4 5 6 7 |
(function() { window.thin = { define: function(name, dependencies, factory) { //register a module } }; })(); |
在这个module方法内部,应当怎么去实现呢?我们的module应当有一个地方存储,但存储是要在工商局内部的,不是随便什么人都可以看到的,所以,这个存储结构也放在工商局同样的作用域里。
用什么结构去存储呢?工商局备案的时候,店名不能跟已有的重复,所以我们发现这是用map的很好场景,考虑到JavaScript语言层面没有map,我们弄个Object来存。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
(function() { var moduleMap = {}; window.thin = { define: function(name, dependencies, factory) { if (!moduleMap[name]) { var module = { name: name, dependencies: dependencies, factory: factory }; moduleMap[name] = module; } return moduleMap[name]; } }; })(); |
现在,模块的存储结构就搞好了。
1.2 模块的使用
存的部分搞好了,我们来看看怎么取。现在来了一个商家,卖木器的,他需要从一个卖钉子的那边进货,卖钉子的已经来注册过了,现在要让这个木器厂能买到钉子。现在的问题是,两个商家处于不同的作用域,也就是说,它们互相不可见,那通过什么方式,我们才能让他们产生调用关系呢?
个人解决不了的问题还是得靠政府,有困难要坚决克服,没有困难就制造困难来克服。现在困难有了,该克服了。商家说,我能不能给你我的进货名单,你帮我查一下它们在哪家店,然后告诉我?这么简单的要求当然一口答应下来,但是采用什么方式传递给你呢?这可犯难了。
我们参考AngularJS框架,写了一个类似的代码:
1 2 3 4 5 6 7 8 |
thin.define("A", [], function() { //module A }); thin.define("B", ["A"], function(A) { //module B var a = new A(); }); |
看这段代码特别在哪里呢?模块A的定义,毫无特别之处,主要看模块B。它在依赖关系里写了一个字符串的A,然后在工厂方法的形参写了一个真真切切的A类型。嗯?这个有些奇怪啊,你的A类型要怎么传递过来呢?其实是很简单的,因为我们声明了依赖项的数组,所以可以从依赖项,挨个得到对应的工厂方法,然后创建实例,传进来。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
use: function(name) { var module = moduleMap[name]; if (!module.entity) { var args = []; for (var i=0; i<module.dependencies.length; i++) { if (moduleMap[module.dependencies[i]].entity) { args.push(moduleMap[module.dependencies[i]].entity); } else { args.push(this.use(module.dependencies[i])); } } module.entity = module.factory.apply(noop, args); } return module.entity; } |
我们可以看到,这里面递归获取了依赖项,然后当作参数,用这个模块的工厂方法来实例化了一下。这里我们多做了一个判断,如果模块工厂已经执行过,就缓存在entity属性上,不需要每次都创建。以此类推,假如一个模块有多个依赖项,也可以用类似的方式写,毫无压力:
1 2 3 4 5 6 |
thin.define("D", ["A", "B", "C"], function(A, B, C) { //module D var a = new A(); var b = new B(); var c = new C(); }); |
注意了,D模块的工厂,实参的名称未必就要是跟依赖项一致,比如,以后我们代码较多,可以给依赖项和模块名称加命名空间,可能变成这样:
1 2 3 4 5 6 |
thin.define("foo.D", ["foo.A", "foo.B", "foo.C"], function(A, B, C) { //module D var a = new A(); var b = new B(); var c = new C(); }); |
这段代码仍然可以正常运行。我们来做另外一个测试,改变形参的顺序:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
thin.define("A", [], function() { return "a"; }); thin.define("B", [], function() { return "b"; }); thin.define("C", [], function() { return "c"; }); thin.define("D", ["A", "B", "C"], function(B, A, C) { return B + A + C; }); var D = thin.use("D"); alert(D); |
试试看,我们的D打出什么结果呢?结果是”abc”,所以说,模块工厂的实参只跟依赖项的定义有关,跟形参的顺序无关。我们看到,在AngularJS里面,并非如此,实参的顺序是跟形参一致的,这是怎么做到的呢?
我们先离开代码,思考这么一个问题:如何得知函数的形参名数组?对,我们是可以用func.length得到形参个数,但无法得到每个形参的变量名,那怎么办呢?
AngularJS使用了一种比较极端的办法,分析了函数的字面量。众所周知,在JavaScript中,任何对象都隐含了toString方法,对于一个函数来说,它的toString就是自己的实现代码,包含函数签名和注释。下面我贴一下AngularJS里面的这部分代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
var FN_ARGS = /^function\s*[^\ |