如何编写可维护的JavaScript代码?
JavaScript这门编程语言发展至今已经非常流行了,各种名词也层出不穷,我们随便列举下就有一大堆,比如Node.js、jQuery、 JavaScript MVC、Backbone.js、AMD、CommonJS、RequireJS、CoffeScript、Flexigrid、Highchart、 Script Loader、Script Minifier、JSLint、JSON、Ajax......这么多的东西席卷我们的脑海,无疑让人头晕目眩。但本质的东西总是不变的,而所谓本质就 是一些核心的基础概念。这里的基础不是指JavaScript的表达式、数据类型、函数API等基础知识,而是指支撑上面这么一大堆JavaScript 名词背后东西的基础。我知道这样会让我这篇文章很难写下去,因为那将包含太多主题,所以本文只打算管中窥豹:本文将先讲一些概念,然后讲一些实践指导原 则,最后涉及一些工具的讨论。
在正式开始这篇博客之前,我们需要问自己为什么代码可维护性值得我们关注。相信只要你写过相当量的代码后,都已经发现了这点:Fix Bug比写代码困难得多。花三个小时写的代码,而之后为了Fix其中的一个Bug花两三天时间,这种情况并不少见。再加上Fix Bug的人很可能不是代码原作者,这无疑更雪上加霜。所以代码可维护性是一个非常值得探讨的话题,提高代码可维护性就一定程度上能节省Fix Bug的时间,节省Fix Bug的时间进而就节省了人力成本。
No 1. 将代码组织成模块
基本任何一门编程语言都认为模块化能提升代码可维护性。我们知道软件工程的核心在于控制复杂度,而模块化本质上是分离关注点,从而分解复杂度。
IIFE模块模式
当我们最开始学习编写JavaSript代码时,基本都会写下面这样的代码:
- var myVar = 10;
- var myFunc = function() {
- // ...
- };
这样的代码本身没有什么问题,但是当这样的代码越来越多时,会给代码维护带来沉重的负担。原因是这样导致myVar和myFunc暴露给全局命名空 间,从而污染了全局命名空间。以我个人经验来看,一般当某个页面中的JavaScript代码达到200行左右时就开始要考虑这个问题了,尤其是在企业项 目中。那么我们该怎么办呢?
最简单的解决方法是采用IIFE(Immediate Invoked Function Expression,立即执行函数表达式)来解决(注意这里是函数表达式,而不是函数声明,函数声明类似 var myFunc = function() { // ... }),如下:
- (function() {
- var myVar = 10;
- var myFunc = function() {
- // ...
- };
- }) ();
现在myVar和myFunc的作用域范围就被锁定在这个函数表达式内部,而不会污染全局命名空间了。这有点类似”沙盒机制“(也是提供了一个安全的执行上下文)。我们知道JavaScript中没有块级作用域,能产生作用域只能借助函数,正如上面这个例子一样。
但是现在myVar、myFunc只能在函数表达式内部被使用,如果它需要向外提供一些借口或功能(像大部分JavaScript框架或JavaScript库一样),那么该怎么办呢?我们会采用下面的做法:
- (function(window, $, undefined) {
- var myFunc = function() {
- // ...
- }
- window.myFunc = myFuc;
- }) (window, jQuery);
我们来简单分析下,代码很简单:首先将window对象和jQuery对象作为立即执行函数表达式的参数,$只是传入的jQuery对象的别名;其 次我们并未传递第三个参数,但是函数却有一个名为undefined的参数,这是一个小技巧,正因为没有传第三个参数,所以这里第三个参数 undefined的值始终是undefined,就保证内部能放心使用undefined,而不用担心其他地方修改undefined的值;最后通过 window.myFunc导出要暴露给外部的函数。
比如我们看一个实际JavaScript类库的例子,比如 Validate.js,我们可以看到它是这样导出函数的:
- (function(window, document, undefined) {
- var FormValidator = function(formName, fields, callback) {
- // ...
- };
- window.FormValidator = FormValidator;
- }) (window, document);
是不是与前面说的基本一样?另一个例子是jQuery插件的编写范式中的一种,如下:
- (function($) {
- $.fn.pluginName = function() {
- // plugin implementation code
- };
- })(jQuery);
既然jQuery插件都来了,那再来一个jQuery源码的例子也无妨:
- (function( window, undefined ) {
- var jQuery = function( selector, context ) { // The jQuery object is actually just the init constructor 'enhanced'
- return new jQuery.fn.init( selector, context, rootjQuery );
- },
- // Expose jQuery to the global object
- window.jQuery = window.$ = jQuery;
- })( window );
上面这样写使得我们调用jQuery函数既可以用$("body"),又可以用jQuery("body")。
命名空间(Namespace)
虽然使用IIEF模块模式让我们的代码组织成一个个模块,维护性提升了,但如果代码规模进一步增大,比如达到2000-10000级别,这时前面方法的局限性又体现出来了?
怎么说呢?观察下前面的代码,所有函数都是通过作为window对象属性的方式导出的,这样如果有很多个开发人员同时在开发,那么就显得不太优雅了。尤其是有的模块与模块之间可能存在层级关系,这时候我们需要借助“命名空间”了,命名空间可以用来对函数进行分组。
我们可以这样写:
- (function(myApp, $, undefined) {
- // ...
- }) (window.myApp = window.myApp || {}, jQuery);
或者这样:
- var myApp = (function(myApp, $, undefined) {
- ...
- return myApp;
- }) (window.myApp || {}, jQuery);
现在我们不再往立即执行函数表达式传递window对象,而是传递挂载在window对象上的命名空间对象。第二段代码中的 || 是为了避免在多个地方使用myApp变量时重复创建对象。
Revealing Module Pattern
这种模块模式的主要作用是区分出私有变量/函数和公共变量/函数,达到将私有变量/函数隐藏在函数内部,而将公有变量/函数暴露给外部的目的。
代码示例如下:
- var myModule = (function(window, $, undefined) {
- var _myPrivateVar1 = "";
- var _myPrivateVar2 = "";
- var _myPrivateFunc = function() {
- return _myPrivateVar1 + _myPrivateVar2;
- };
- return {
- getMyVar1: function() { return _myPrivateVar1; },
- setMyVar1: function(val) { _myPrivateVar1 = val; },
- someFunc: _myPrivateFunc
- };
- }) (window, jQuery);
myPrivateVar1、 myPrivateVar2是私有变量,myPrivateFunc是私有函数。而getMyVar1(public getter)、getMyVar1(public setter)、someFunc是公共函数。是不是有点类似普通的Java Bean?
或者我们可以写成这种形式(换汤不换药):
- var myModule = (function(window, $, undefined) {
- var my= {};
- var _myPrivateVar1 = "";
- var _myPrivateVar2 = "";
- var _myPrivateFunc = function() {
- return _myPrivateVar1 + _myPrivateVar2;
- };
- my.getMyVar1 = function() {
- return _myPrivateVar1;
- };
- my.setMyVar1 = function(val) {
- _myPrivateVar1 = val;
- };
- my.someFunc = _myPrivateFunc;
- return my;
- }) (window, jQuery);
模块扩展(Module Augmentation)
有时候我们想为某个已有模块添加额外功能,可以像下面这样:
- var MODULE = (function (my) {
- my.anotherMethod = function () {
- // added method...
- };
- return my;
- }(MODULE || {}));
Tight Augmentation
上面的例子传入的MODULE可能是undefined,也就是说它之前可以不存在。与之对应Tight Augmentation模式要求传入的MODULE一定存在并且已经被加载进来。
- var MODULE = (function (my) {
- var old_moduleMethod = my.moduleMethod;
- my.moduleMethod = function () {
- // method override, has access to old through old_moduleMethod...
- };
- return my;
- }(MODULE));
代码意图很明显:实现了重写原模块的moduleMethod函数,并且可以在重写的函数中调用od_moduleMethod。但这种写法不够灵活,因为它假定了一个先决条件:MODULE模块一定存在并且被加载进来了,且它包含moduleMethod函数。
子模块模式
这个模式非常简单,比如我们为现有模块MODULE创建一个子模块如下:
- MODULE.sub = (function () {
- var my = {};
- // ...
- return my;
- }());
No 2. 利用OO
构造函数模式(Constructor Pattern)
JavaScript没有类的概念,所以我们不可以通过类来创建对象,但是可以通过函数来创建对象。比如下面这样:
- var Person = function(firstName, lastName, age) {
- this.firstName = firstName;
- this.lastName = lastName;
- this.age = age;
- };
- Person.prototype.country = "China";
- Person.prototype.greet = function() {
- alert("Hello, I am " + this.firstName + " " + this.lastName);
- };
这里firstName、lastName、age可以类比为Java类中的实例变量,每个对象有专属于自己的一份。而country可以类比为 Java类中的静态变量,greet函数类比为Java类中的静态方法,所有对象共享一份。我们通过下面的代码验证下(在Chrome的控制台输):
- var Person = function(firstName, lastName, age) {
- this.firstName = firstName;
- this.lastName = lastName;
- this.age = age;
- };
- Person.prototype.country = "China";
- Person.prototype.greet = function() {
- alert("Hello, I am " + this.firstName + " " + this.lastName);
- };
- var p1 = new Person("Hub", "John", 30);
- var p2 = new Person("Mock", "William", 23);
- console.log(p1.fistName == p2.firstName); // false
- console.log(p1.country == p2.country); // true
- console.log(p1.greet == p2.greet); // true
但是如果你继续测下面的代码,你得不到你可能预期的p2.country也变为UK:
- p1.country = "UK";
- console.log(p2.country); // China
这与作用域链有关,后面我会详细阐述。继续回到这里。既然类得以通过函数模拟,那么我们如何模拟类的继承呢?
比如我们现在需要一个司机类,让它继承Person,我们可以这样:
- var Driver = function(firstName, lastName, age) {
- this.firstName = firstName;
- this.lastName = lastName;
- this.age = age;
- };
- Driver.prototype = new Person(); // 1
- Driver.prototype.drive = function() {
- alert("I'm driving. ");
- };
- var myDriver = new Driver("Butter", "John", 28);
- myDriver.greet();
- myDriver.drive();
代码行1是实现继承的关键,这之后Driver又定义了它扩展的只属于它自己的函数drive,这样它既可以调用从Person继承的greet函数,又可以调用自己的drive函数了。
No3. 遵循一些实践指导原则
下面是一些指导编写高可维护性JavaScript代码的实践原则的不完整总结。
尽量避免全局变量
JavaScript使用函数来管理作用域。每个全局变量都会成为Global对象的属性。你也许不熟悉Global对象,那我们先来说说 Global对象。ECMAScript中的Global对象在某种意义上是作为一个终极的“兜底儿”对象来定义的:即所有不属于任何其他对象的属性和方 法最终都是它的属性和方法。所有在全局作用域中定义的变量和函数都是Global对象的属性。像escape()、 encodeURIComponent()、 undefined都是Global对象的方法或属性。
事实上有一个我们更熟悉的对象指向Global对象,那就是window对象。下面的代码演示了定义全局对象和访问全局对象:
- myglobal = "hello"; // antipattern
- console.log(myglobal); // "hello"
- console.log(window.myglobal); // "hello"
- console.log(window["myglobal"]); // "hello"
- console.log(this.myglobal); // "hello"
使用全局变量的缺点是:
全局变量被应用中所有代码共享,所以很容易导致不同页面出现命名冲突(尤其是包含第三方代码时)
全局变量可能与宿主环境的变量冲突
- function sum(x, y) {
- // antipattern: implied global
- result = x + y;
- return result;
- }
result现在就是一个全局变量。要改正也很简单,如下:
- function sum(x, y) {
- var result = x + y;
- return result;
- }
另外通过var声明创建的全局变量与未通过var声明隐式创建的全局变量有下面的不同之处:
通过var声明创建的全局变量无法被delete
而隐式创建的全局变量可以被delete
delete操作符运算后返回true或false,标识是否删除成功,如下:
- // define three globals
- var global_var = 1;
- global_novar = 2; // antipattern
- (function () {
- global_fromfunc = 3; // antipattern
- }());
- // attempt to delete
- delete global_var; // false
- delete global_novar; // true
- delete global_fromfunc; // true
- // test the deletion
- typeof global_var; // "number"
- typeof global_novar; // "undefined"
- typeof global_fromfunc; // "undefined"
推荐使用Single Var Pattern来避免全局变量如下:
- function func() {
- var a = 1,
- b = 2,
- sum = a + b,
- myobject = {},
- i,
- j;
- // function body...
- }
上面只用了一个var关键词就让a、b、sum等变量全部成为局部变量了。并且为每个变量都设定了初始值,这可以避免将来可能出现的逻辑错误,并提高可读性(设定初始值意味着能很快看出变量保存的到底是一个数值还是字符串或者是一个对象)。
局部变量相对于全局变量的另一个优势在于性能,在函数内部从函数本地作用域查找一个变量毫无疑问比去查找一个全局变量快。
避免变量提升(hoisting)陷阱
你很可能已经看到过很多次下面这段代码,这段代码经常用来考察变量提升的概念:
- myName = "global";
- function func() {
- console.log(myName); // undefined
- var myName = "local";
- console.log(myName); // local
- }
- func();
这段代码输出什么呢?JavaScript的变量提升会让这段代码的效果等价于下面的代码:
- myName = "global";
- function func() {
- var myName;
- console.log(myName); // undefined
- myName = "local";
- console.log(myName); // local
- }
- func();
所以输出为undefined、local就不难理解了。变量提升不是ECMAScript标准,但是却被普遍采用
对非数组对象用for-in,而对数组对象用普通for循环
虽然技术上for-in可以对任何对象的属性进行遍历,但是不推荐对数组对象用for-in,理由如下:
如果数组对象包含扩展函数,可能导致逻辑错误
for-in不能保证输出顺序
for-in遍历数组对象性能较差,因为会沿着原型链一直向上查找所指向的原型对象上的属性
所以推荐对数组对象用普通的for循环,而对非数组对象用for-in。但是对非数组对象使用for-in时常常需要利用hasOwnProperty()来滤除从原型链继承的属性(而一般不是你想要列出来的),比如下面这个例子:
- // the object
- var man = {
- hands: 2,
- legs: 2,
- heads: 1
- };
- // somewhere else in the code
- // a method was added to all objects
- if (typeof Object.prototype.clone === "undefined") {
- Object.prototype.clone = function () {};
- }
- for(var i in man) {
- console.log(i, ": ", man[i]);
- }
输出如下:
- hands : 2
- legs : 2
- heads : 1
- clone : function () {}
即多了clone,这个可能是另外一个开发者在Object的原型对象上定义的函数,却影响到了我们现在的代码,所以规范的做法有两点。第一坚决不允许在原生对象的原型对象上扩展函数或者属性 。 第二将代码改写为类似下面这种:
- for(var i in man) {
- if(man.hasOwnProperty(i)) {
- console.log(i, ": ", man[i]);
- }
- }
进一步我们可以改写代码如下:
- for(var i in man) {
- if(man.hasOwnProperty(i)) {
- console.log(i, ": ", man[i]);
- }
- }
这样有啥好处呢?第一点防止man对象重写了hasOwnProperty函数的情况;第二点性能上提升了,主要是原型链查找更快了。
进一步缓存Object.prototype.hasOwnProperty函数,代码变成下面这样:
- var i, hasOwn = Object.prototype.hasOwnProperty;
- for (i in man) {
- if (hasOwn.call(man, i)) { // filter
- console.log(i, ":", man[i]);
- }
- }
避免隐式类型转换
隐式类型转换可能导致一些微妙的逻辑错误。我们知道下面的代码返回的是true:
- 0 == false
- 0 == ""
建议做法是始终使用恒等于和恒不等于,即===和!===。
而对于下面的代码:
- null == false
- undefined == false
我们常常期望它返回true的,但却返回的是false。
那么我们可以用下面的代码来将其强制转换为布尔类型后比较:
- !!null === false
- !!undefined === false
避免eval()
eval()接受任意字符串并将其作为JavaScript代码进行执行,最初常用于执行动态生成的代码,但是eval()是有害的,比如可能导致XSS漏洞,如果根据某个可变属性名访问属性值,可以用[]取代eval(),如下:
- // antipattern
- var property = "name";
- alert(eval("obj." + property));
- // preferred
- var property = "name";
- alert(obj[property]);
注意传递字符串给setTimeout()、setInterval()和Function()也类似eval(),也应该避免。比如下面:
- // antipatterns
- setTimeout("myFunc()", 1000);
- setTimeout("myFunc(1, 2, 3)", 1000);
- // preferred
- setTimeout(myFunc, 1000);
- setTimeout(function () {
- myFunc(1, 2, 3);
- }, 1000);
如果你遇到非要使用eval()不可的场景,用new Function()替代,因为eval()的字符串参数中即使通过var声明变量,它也会成为一个全局变量,而new Function()则不会,如下:
- eval("var myName='jxq'");
则myName成了全局变量,而用newFunction()如下:
- var a = new Function("firstName, lastName", "var myName = firstName+lastName");
实际上a现在是一个匿名函数:
- function anonymous(firstName, lastName) {
- var myName = firstName+lastName
- }
则myName现在就不是全局变量了。当然如果还坚持用eval(),可以用一个立即执行函数表达式将eval()包起来:
- (function() {
- eval("var myName='jxq';");
- }) (); // jxq
- console.log(typeof myName); // undefined
另外一个eval()和Function()的区别是前者会影响作用域链,而后者不会,如下:
- (function() {
- var local = 1;
- eval("console.log(typeof local);");
- })(); // number
- (function() {
- var local = 1;
- Function("console.log(typeof local);");
- })(); // undefined
使用parseInt()时,指定第二个进制参数
这个不用多提,相信大家也都知道了
使用脚本引擎,让JavaScript解析数据生成HTML
传说中的12306在查询车票时返回的是下面这么一大串(我已无力吐槽,这个是我今天刚截的,实际大概100来行):
- <span id='id_240000G13502' class='base_txtdiv' onmouseover=javascript:onStopHover('240000G13502#VNP#AOH') onmouseout='onStopOut()'>G135</span>,<img src='/otsweb/images/tips/first.gif'> 北京南
- <br>
- 12:40,<img src='/otsweb/images/tips/last.gif'> 上海虹桥
- <br>
- 18:04,05:24,8,--,<font color='#008800'>有</font>,<font color='#008800'>有</font>,--,--,--,--,--,--,--,<a name='btn130_2' class='btn130_2' style='text-decoration:none;' onclick=javascript:getSelected('G135#05:24#12:40#240000G13502#VNP#AOH#18:04#北京南#上海虹桥#01#08#O*****0072M*****00629*****0008#8A72A4AD8B70A5E0FF02AC9290DDB39C6E0B6D5A0F8A9A8FB305FB11#P2')>预 订</a>\n1,<span id='id_240000G13705' class='base_txtdiv' onmouseover=javascript:onStopHover('240000G13705#VNP#AOH') onmouseout='onStopOut()'>G137</span>,<img src='/otsweb/images/tips/first.gif'> 北京南
- <br>
- 12:45,<img src='/otsweb/images/tips/last.gif'> 上海虹桥
为什么不能只返回数据(比如用JSON),然后利用JavaScript模板引擎解析数据呢?比如下面这样(使用了jQuery tmpl模板引擎,详细参考我的代码 JavaScript模板引擎使用):
- <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"
- "http://www.w3.org/TR/html4/loose.dtd">
- <html xmlns="http://www.w3.org/1999/xhtml">
- <head>
- <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
- <title>JavaScript tmpl Use Demo</title>
- <script type="text/javascript" src="http://ajax.googleapis.com/ajax/libs/jquery/1/jquery.min.js"></script>
- <script src="./tmpl.js" type="text/javascript"></script>
- <!-- 下面是模板user_tmpl -->
- <script type="text/html" id="user_tmpl">
- <% for ( var i = 0; i < users.length; i++ ) { %>
- <li><a href="<%=users[i].url%>"><%=users[i].name%></a></li>
- <% } %>
- </script>
- <script type="text/javascript">
- // 用来填充模板的数据
- var users = [
- { url: "http://baidu.com", name: "jxq"},
- { url: "http://google.com", name: "william"},
- ];
- $(function() {
- // 调用模板引擎函数将数据填充到模板获得最终内容
- $("#myUl").html(tmpl("user_tmpl", users));
- });
- </script>
- </head>
- <body>
- <div>
- <ul id="myUl">
- </ul>
- </div>
- </body>
- </html>
使用模板引擎可以将数据和HTML内容完全分离,这样有几个好处:
修改HTML结构时几乎可以不修改返回的数据的结构
只返回纯粹的数据,节省了网络带宽(网络带宽就是钱)
采用一致的命名规范
构造函数首字母大写。
而非构造函数的首字母小写,标识它们不应该通过new操作符被调用。
常量名称应该全大写。
私有变量或似有函数名称前带上下划线,如下:
- var person = {
- getName: function () {
- return this._getFirst() + ' ' + this._getLast();
- },
- _getFirst: function () {
- // ...
- },
- _getLast: function () {
- // ...
- }
- };
不吝啬注释,但也不要胡乱注释
为一些相对艰涩些的代码(比如算法实现)添加注释。
为函数的功能、参数和返回值添加注释。
不要对一些常识性的代码进行注释,也不要像下面这样多此一举地注释:
- var myName = "jxq"; // 声明字符串变量myName,其值为"jxq"
No4. 合理高效地使用工具
这里的工具包括JavaScript框架、JavaScript类库以及一些平时自己积累的Code Snippet。
使用JavaScript框架的好处是框架为我们提供了一种合理的组织代码方式,比如Backbone.js、Knockout.js这种框架能让我们更好地将代码按MVC或者MVP模式分离。
而使用JavaScript类库可以避免重复造轮子(而且往往造出一些不那么好的轮子),也可以让我们更专注于整体业务流程而不是某个函数的具体实现。一些通用的功能如日期处理、金额数值处理最好用现有的成熟类库。
最后使用自己平时积累的Code Snippet可以提高我们的编码效率,并且最重要的是可以提供多种参考解决方案。
下面是一些流行的工具列表。
它提供了完全自定制功能,支持选项组、回调函数等等。另外一个扩展Select控件的插件是jQuery Chosen,可以参考我分享的代码:美化Select下拉框
它提供了表单离线存储功能,能够自动保存用户未提交的表单数据。而当提交表单后会清除数据。
这个类库允许我们将HTML文本转换成输入域。
这是一个轻量级表单验证类库,它预定义了一系列验证规则(通过正则表达式),并且支持定制验证回调函数和验证失败消息,兼容所有主流浏览器(包括IE 6),更详细的信息有兴趣的话可以参考我的博客 Validate.js框架源码完全解读
jQuery文件上传插件,支持多文件上传
Handsontables: Excel-Like Tables For The Web
提供Web Excel功能的jQuery插件
通过Pivot我们可以很方便地展现大量数据。数据源可以是CSV或者JSON
一个很方便的日期处理类库。
使用很简单,下面是两个小实例:
- // What date is next thursday?
- Date.today().next().thursday();
- // Add 3 days to Today
- Date.today().add(3).days();
- ...
RequireJS是一个JavaScript文件和模块加载器。使用RequireJS可以显著提高代码的运行效率。据说百度音乐盒利用RequireJS后加载速度提高了好几秒(按需加载),号称神器。
Grunt.js是一个基于任务的命令行工具,可以用于JavaScript项目构建。它预包含几十个内置的任务:文件合并、项目脚手架(基于一个预定义的模板)、JSLint验证、UglifyJS代码压缩、qUnit单元测试、启动服务器等。