单页应用详解(Single page apps in depth)
(2).用模块化提高可维护性:停止使用命名空间(Maintainability depends on modularity: Stop using namespaces!)
英文原文:http://singlepageappbook.com/maintainability1.html
【译者注:这个篇章里主要讲了些模块化的思路,主要是引导到CommonJS。后面的例子基本上也是基于Node.js来的,对这部分有了解的基本上pass了,不了解的,NND的我估计看完他写的也不会理解的太清楚。。。。当然偶滴翻译水平也有限,谅解,谅解~~~】
模块化是任何事情的核心。最初我接触时不这么认为,但事实证明,经过20多次的草稿之后,没有什么比正确的模块化更重要了。
好的模块化可以使为浏览器构建和打包应用更简单,可以让测试变得更简单,而且它定义了如何去维护这份代码。所以,编写可测试,可打包,可维护的代码很关键。
什么是可维护的代码?
-
容易理解,容易调试
-
容易测试
-
容易重构
什么是难以维护的饿代码?
-
太多的依赖,难以理解,难以独立测试。
-
从全局域里访问和写入数据,难以为测试构建环境。
-
有副作用,就意味着不能在测试中容易和重复的实例化。
-
暴露太多,没有封装好实现细节,如果不破坏公开公开接口对外部组件的过多依赖就难以重构。
如果你仔细想下, 这些话就是直接关于正确的代码模块化或者影响代码拆分到不同模块里的方式。
什么是模块代码?
模块代码就是拆分成独立模块的代码,模块的内部细节被封装在一个公用接口里面,让模块容易理解,测试和重构,与别的模块相互独立。(蛋很疼~~~~但是不翻译这段吧,又感觉少了点啥~~~~)
模块化不仅仅是组织代码。你有些代码看起来像模块,其实不是。你可以把你的代码放在多个模块里,有命名空间,但这仍然会暴露出你的内部细节,并且通过期望其它部分的代码会造成复杂的依赖。
比较下上面2个例子。左边蓝色模块知道橙色模块的某些特定部分。有可能是通过全局变量名引用的,也有可能是调用了其它模块不经意间暴露出来的内部函数。不管怎样,如果这部分特定模块不在的这里话就无法工作了。
右边的例子,每个模块只知道一个公开的接口,不知道其它模块的任何东西。蓝色的模块可以使用任何其它实现了同样接口的模块,更重要的是,只要接口保持不变,橙色模块可以改变它的内部实现,比如一个虚假的测试对象。
命名空间的问题
浏览器本身并没有模块管理机制,它只会加载js文件。在这些文件中根域下的一切东西都会根据文件的顺序挂载在window变量下。人们谈论到“模块化javascript”,通常指使用命名空间。最基本的方式就是用一个前缀,比如“window.MyApp”,然后把所有东西都定义在这下面,每个对象都有自己的一个全局的名称,这样就完成了模块化。命名空间可以创建层次结构,但是会带来2个问题:
必须选择在全局范围内私有。在只有命名空间的系统中,你可以有私有变量和函数,但是只能选择一个独立文件暴露在一个全局域环境下做私有。要么在全局命名空间下暴露接口,要么什么都没有。
这种方式没有提供足够的控制;使用命名空间你不能开放某些细节给“关联”或者“友元”的调用者(比如在同一个子系统中),同时又不开放给全局访问。
这就导致耦合在全局命名空间下。你暴露出了细节,你就无法控制别人的代码访问和依赖你本来只想限制给子系统访问的部分。
我们应该给相关联的代码提供某些内部细节的访问,又不暴露在全局域环境下。对毫无关联的模块影藏每部细节是非常有用的,可以在不破坏依赖的情况下修改内部实现。
(我勒个去啊,表达一个意思反复说了这么多句,元方都要看不下去了。。。。)
模块依赖全局状态。使用命名空间的另一个问题就是,它不提供对全局状态的隔离。全局命名空间导致马虎的想法:因为它控制可见性方面比较弱,所以非常容易会陷入一种模式,只需要在全局空间下添加或修改东西(或者某个命名空间下)。
它之所以复杂的一个主要原因就是,代码写的有远程输入(依赖于全局命名空间的定义)或者全局副作用(比如一个模块的载入顺序会影响到其它模块,因为它会改变全局变量)。用全局变量编写代码会有不同的结果,这依赖于全局域下有些什么(比如window.*)。
模块不应该在全局域下添加东西。局部的数据会比全局数据更容易理解,变化和测试。如果需要放在全局域下,代码应该隔离出来,作为一个初始化的一部分。命名空间就不能提供这种方式,事实上,它更怂恿你去改变全局状态,然后注入到你想要调用的地方。
不良实践的例子
下面的例子说明了一些不良的做法。
不要暴露全局变量
除非必要,不要在全局域下添加变量。下面这种方式就隐式的添加了一个全局变量。
// Bad: 添加了一个"window.foo"全局变量 var foo = 'bar';
避免在全局环境下定义变量,可以把代码放到一个闭包或者匿名函数里,或者有构建系统自动为你做:
;(function() { // Good: local variable is inaccessible from the global scope var foo = 'bar'; }());
如果真的想注册一个全局变量,你应该慎重考虑,并且把代码放在一个特定地方。把初始化和定义相隔离,强制你看到这些恶心的初始化状态,而不是影藏在多个地方(可能会触发惊人的影响):
function initialize(win) { // Good: if you must have globals, // make sure you separate definition from instantiation win.foo = 'bar'; }
上面的函数,变量明确的绑定在传入的win对象下。这是使用函数的原因是,模块在载入的时候不应该有副作用。我们可以把函数调用延迟到想要把变量注入到全局域下时调用。
不要暴露内部实现细节
对模块不相关的调用者应该影藏内部细节。不要把所有东西都绑定在命名空间下。否则任何人想重构你的代码时不得不把所有函数都当做公用接口看待,除非真的被证明是没有被外部调用。
不要在同一个文件中定义多个模块,不管你当时是认为多么的方便。一个文件应该只定义一个模块和,公开一个模块的接口。
;(function() { // Bad: global names = global state window.FooMachine = {}; // Bad: implementation detail is made publicly accessible FooMachine.processBar = function () { ... }; FooMachine.doFoo = function(bar) { FooMachine.processBar(bar); // ... }; // Bad: exporting another object from the same file! // No logical mapping from modules to files. window.BarMachine = { ... }; })();
下面的代码才是正确的做法:内部“processBar”函数在局部域内,在外部不能访问。只暴露当前这个模块。
;(function() { // Good: the name is local to this module var FooMachine = {}; // Good: implementation detail is clearly local to the closure function processBar() { ... } FooMachine.doFoo = function(bar) { processBar(bar); // ... }; // Good: only exporting the public interface, // internals can be refactored without worrying return FooMachine; })();
在一个类中(比如:从原型实例的对象)通常用下划线开头来表示私有方法。你可以通过用.call或.apply的方式传递this来影藏类的方法,这是一个更更小的细节,不在这里做展示。
不要把定义,实例化,初始化混在一起(Do not mix definition and instantiation/initialization)
定义和实例化/初始化的代码应该要分开来,把这2部分混在一起经常会导致测试问题和组件重用问题。
不要这样做:
function FooObserver() { // ... } var f = new FooObserver(); FooObserver.observe('window.Foo.Bar'); module.exports = FooObserver;
这是一个正常的模块(这里省略了外围包装代码),这里把初始化和定义混在一起了。应该分成2部分,一部分负责定义,另一部分负责特定的初始化。比如:foo_observer.js:
function FooObserver() { // ... } module.exports = FooObserver;
和 bootstrap.js:
module.exports = { initialize: function(win) { win.Foo.Bar = new Baz(); var f = new FooObserver(); FooObserver.observe('window.Foo.Bar'); } };
这样,FooObserver就可以单独初始化了了,而不是自动的初始化。尽管FooObserver的用途就是关联到window.Foo.Bar,但这种方式也是很有用的,因为这样可以为构建测试的时候传入不同的配置。
不要修改不属于你的对象
前面这些例子是关于方式别的代码因你的代码而引起问题,下面这些例子是关于防止你的代码因其它的代码而引起问题。
很多框架提供reopen函数,允许你修改一个已经定义了的对象的原型(比如class)。不要在你的模块里这么做,除非用同样的代码定义了这个对象(并且你应该把它放到定义里面)。
如果你认为类继承是一个以解决的问题,那就仔细想想。在大多数情况下,可以找到比继承更好的解决方案:开放接口给用户调用,或者触发用户能接收的事件,而不是强制的去继承一个类型。这有些列子,继承很有用,但大部分情况下局限于框架。
;(function() { // Bad: redefining the behavior of another module window.Bar.reopen({ // e.g. changing an implementation on the fly }); // Bad: modifying a builtin type String.prototype.dasherize = function() { // While you can use the right API to hide this function, // you are still monkey-patching the language in a unexpected way }; })();
如果你编写一个框架,不管他妈的什么原因(f*ck's sake)都不要修改内置对象,比如给String添加一个新的函数。当然你可以通过这个方法(指上面代码中的dasherize)保存一些字符(比如,_(str).dasherize()和str.dasherize() ),但这跟使你的框架有全局依赖性没什么差别。要跟大家融洽的相处:把这类特殊的函数放到一个工具类库里。
使用CommonJS来构建你的模块和包(Building modules and packages using CommonJS)
前面我们将了些不好的实践,现在我们来看看比较好的一些实践方法:我们如何为单页应用实现模块和包?
我们要解决3个问题:
-
私有:我们需要更加精细的私有化,而不仅仅是针对全局或者当前域下。
-
不要紧紧是为了可以不访问就把东西放在全局命名空间下。
-
我们应该能够创建包,它可以包含多个文件或者目录,可以把整个子系统包装到一个单独的闭包里。
CommonJS 模块。CommonJS是node.js内置的模块方式。一个CommonJS模块就是一段简单的js代码,它主要做2件事:
-
用require()来声明要引入依赖
-
用
exports变量来导出公用接口
简单例子:
var Model = require('./lib/model.js'); // require a dependency // module implementation function Foo(){ /* ... */ } module.exports = Foo; // export a single variable
var Model 在这里不是全局域下吗?不是的,这里没有全局域。每个模块都有自己的域。就像每个模块包含在一个匿名的function里一样(这就意味着所有变量的定义都是在模块内部的)。【译者注:这是node.js本身实现的内置机制,每个js文件都在一个模块作用域下】
好,那么如何引入jQuery或者其它库呢?有2种基本的方法引入一个文件:通过路径(比如:./lib/model.js)或者名称(var $ = require('jquery'));通过文件引入是根据本地目录结构,通过名称引入是包管理机制。在Node中,它使用的是简单的目录搜索(simple directory search),在浏览器中,我们可以定义绑定,后面会讲到。
这样做的好处是什么?
看起来有点像仅仅是把所有东西包在一个闭包里,可能你已经是这么做了?,不,这远远不止这样。
它不会突然的修改全局状态,而且指开放出一个东西。每个CommonJS模块都在自身的上下文中执行。变量是模块内部局域的,而不是全局的。每个模块只能导出一个对象。
因为没有修改和访问全局域环境,依赖很容易定位。曾经会困惑一个函数从哪里来的,一段代码它依赖哪些部分?现在就不存在了:依赖已经明确的指明了,定位一段代码从它引入的文件路径就可以看出来。已经没有影藏的全局变量了。
声明依赖是不是有点冗余,没有遵循DRY(don't repeat yourself,不做重复的事情)?确实如此,它不像使用定义在window下的全局变量那么简单。但是简单的方法不一定最合适的架构;打字很容易,但坚持就很难了。
模块没有自己的名字。每个模块都是匿名的,一个模块导出一个类或者一组functions,但是它不会指定导出的接口如何被调用。这就意味着任何使用模块的人都可以给它命名一个局部变量名称,而不用关心这个变量是否在某个特定命名空间里已经存在。
通过固定名称引入模块,模块的版本冲突问题就会令人发狂。所以在同一个系统中,就算不同的部分也不能引入相同名字的模块。CommonJS就不会遇到这种问题,require()仅仅是返回一个模块,你可以自己给这个模块一个任意的局部变量名称。
它是一个分布式的系统结构。CommJS模块是分布式的,可以通过Node的npm包管理工具来管理。在下一章会讲更多这部分内容。
已经有上千个相兼容的模块。当然,这说的有点夸张。所有在npm里的模块都是基于CommonJS规范。而且不是所有的都是基于浏览器相关方面的,有很多关于其它方面的好的模块。
最后,CommonJS模块可以嵌套在包里。虽然require()的意思很简单,但是它提供了创建包的能力,它可以导出内部实现细节(跨文件)的同时又不开放给包外访问。这让封装内部细节变得更简单,可以在内部共享而又不开放给全局访问。
创建一个CommonJS包
我们来了看看如何在模块上创建一个包。创建包要通过构建系统。假设我们已经有了一个构建系统,可以把指定的一些js文件合并成一个文件。
[ [./model/todo.js] [./view/todo_list.js] [./index.js] ]
[ Build process ]
[ todo_package.js ]
构建过程就是把所有文件代码串联起来,包裹在一个闭包里,放到一个文件中,并添加一个内部require()实现,require的功能跟前面描述的一样(通过文件路径引入包内的文件,用路径名来扩展整个包库)。
代码类似这样:
;(function() { function require() { /* ... */ } modules = { 'jquery': window.jQuery }; modules['./model/todo.js'] = function(module, exports, require){ var Dependency = require('dependency'); // ... module.exports = Todo; }); modules['index.js'] = function(module, exports, require){ module.exports = { Todo: require('./model/todo.js') }; }); window.Todo = require('index.js'); }());
这里有一个内部的require()可以获取文件。每个模块遵循CommonJS的模式返回接口。最后,构建出来的包只有一个文件index.js,定义了从模块中导出的接口。通常这是一个公用API,或者模块中类的一个子集。
每个包导出一个单独的变量,比如:window.Todo = require('index.js');。这样,只有模块中相应的部分被导出,而且比较明显。一个包不能访问另一个包中的子模块,除非在index.js中被导出。避免模块产生被隐式的依赖。
在包之外构建应用
整体文件目录接口像下面这样:
assets
- css
- layouts
common
- collections
- models
index.js
modules
- todo
- public
- templates
- views
index.js
node_modules
package.json
server.js
公用的资源目录./assets/;公用库目录,包含可重用的部分,比如集合,模块等(./common)。
./modules/目录包括子目录,每个目录都是应用的一个可初始化的部分,每个目录是一个包,可以独立的加载(只要common库已经加载了)。
每个包里的index.js文件会导出一个initialize()方法,当这个包被激活时执行初始化,通过URL或者app配置传入参数。
使用胶水构建系统(glue build system)
现在,我们已经有点概念该如何去构建模块了。Node有内置原生支持require(),但是浏览器怎么办呢?我们是否需要引入一个框架来解决这个问题?
完全不用,这并不难:构建系统本身也就150几行的代码,外加90来行的require()的实现。我这里构建指的是超轻量化的:把代码包装在一个闭包里,提供一个浏览器中,内部的require()实现。为了不增加更多的讨论,这里就不贴代码了,可以看下这里
我曾经用过onejs和browserbuild。我想要更脚本化点的东西,所以我写了个gluejs(在我为这个项目贡献了些代码之后),真实我为这个系统定制的(通常有更复杂的API)。
有了glusjs,就可以写一小段构建脚本了。它可以很好的把你的build系统挂载到其它工具中,比如,当一个HTTP请求完成之后build一个包,或者创建自定义构建脚本,用来包含或踢出某些功能(比如debug构建)。
从npm安装gluejs:
$ npm install gluejs
现在我们来build一点东西。
引入文件,构建包
用include(path)添加文件,path可以使一个文件或者一个目录。如果想引入目录并执行一些文件,用exclude(regexp)来过滤文件。
用main(name)来定义main文件,在下面代码中是"index.js"。这个文件是从包中获取所有export。
var Glue = require('gluejs'); new Glue() .include('./todo') .main('index.js') .export('Todo') .render(function (err, txt) { console.log(txt); });
每个包导出一个变量,这个变量需要制定一个名字。在这个例子中是“Todo”(假设这个包定义到window.Todo).
最后,有一个render(callback)函数。接受一个函数function(err,txt){},返回text render的结果(第一个参数err用来返回错误)。上面这个例子仅仅是把txt打印下。如果你把上面代码放到一个文件中(包括"./todo"里面的js文件),就可以在控制台输出整个包的代码了。
如果你更新喜欢自动重复build,用.watch()替换.render().传给watch()的回调函数就会在build变化后调用。
绑定成全局函数
我们经常想绑定一个特定的名称成为扩展库,像require('jquery')。可以用replace(moduleName, string).
下面这个例子,构建一个包用来响应HTTP GET请求:
var fs = require('fs'), http = require('http'), Glue = require('gluejs'); var server = http.createServer(); server.on('request', function(req, res) { if(req.url == '/minilog.js') { new Glue() .include('./todo') .basepath('./todo') .replace('jquery', 'window.$') .replace('core', 'window.Core') .export('Module') .render(function (err, txt) { res.setHeader('content-type', 'application/javascript'); res.end(txt); }); } else { console.log('Unknown', req.url); res.end(); } }).listen(8080, 'localhost');
把多个包合并到一个文件,用concat([packageA, packageB], function(err, txt)):
var packageA = new Glue().export('Foo').include('./fixtures/lib/foo.js'); var packageB = new Glue().export('Bar').include('./fixtures/lib/bar.js'); Glue.concat([packageA, packageB], function(err, txt) { fs.writeFile('./build.js', txt); });
请注意,把多个包串联在一起只是把它们定义在一个文件里,它们内部的模块之间不能互相访问。
参考:
[1] The modularity illustration was adapted from Rich Hickey's presentation Simple Made Easy
http://www.infoq.com/presentations/Simple-Made-Easy
http://blog.markwshead.com/1069/simple-made-easy-rich-hickey/
http://code.mumak.net/2012/02/simple-made-easy.html
http://pyvideo.org/video/880/stop-writing-classes
http://substack.net/posts/b96642