JavaScript高级程序设计-26-最佳实践

js的最佳实践分成若干类,在开发过程的不同阶段上应用。

可维护性

复杂Web应用有成千上万行js,执行各种复杂功能。这不得不使开发者考虑可维护性问题。

编写可维护的代码很重要,因为大部分开发人员都花费大量时间维护他人代码,很难从头开始开发新代码,很多情况下是以他人的工作成果为基础的,确保自己的代码的可维护性,以便其他开发人员在此基础上更好的开展工作。

可维护的代码概念可以广泛应用在各种编程语言上,不仅仅是js。

什么是可维护的代码

可维护的代码有一些特征,一般来说,需要遵循以下特点:

  • 可理解性,其他人可以接手代码并理解它的意图和一般途径,而需要开发人员的完整解释
  • 直观性,代码中的东西一看就能明白,不管其操作过程多么复杂
  • 可适应性,代码以一种数据上变化不要求完全重写的方法撰写
  • 可扩展性,在代码架构上已考虑到在未来允许对核心功能进行扩展
  • 可调试性,当出错时,代码可以给予足够的信息来尽可能直接的确定问题所在。

代码约定

让代码变得可维护的简单途径是形成一套js代码的书面约定,绝大多数语言都开发出各自的代码约定,网上一搜就很多相关文档。比如专业组织为开发者指定了详细的代码约定让代码对任何人都可维护,优秀开源项目有着严格的代码要求,让社区的任何人偶可以轻松的理解代码是如何组织的。

由于js的可适应性,代码约定很重要,由于和大多数面向对象语言不同,js不强制开发人员将所有东西都定义为对象,语言可以支持各种编程风格,从传统面向对象式到声明式到函数式。不同的开源库就有可能使用不同的创建对象、定义方法、管理环境的方式。

可读性

要让代码可维护,首先必须可读,可读性与代码作为文本文件的格式化方式有关,可读性的大部分内容都是和代码的缩进相关的,当所有代码缩进一致时整个项目中的代码才会更容易阅读。通常会使用若干空格而非制表符来进行缩进,因为制表符在不同的文本编辑器内显示效果不同,推荐4个空格。

可读性的另一方面是注释,在大多数编程语言中,对每个方法注释视为一个可行的实践,因为js可以在代码的任何地方创建函数,所以这点常常被忽略了,但正因为如此,在js中为每个函数编写文档就更重要了,一般而言,如下的地方需要进行注释:

  • 函数和方法,每个函数或方法都需要一个注释,描述其目的和用于完成任务可能使用的算法,陈诉假设也非常作业,如参数代码什么,函数是否有返回值(因为不能从函数定义推断出来)。
  • 大段代码,用于完成单个任务的多行代码应该在前面放一个描述任务的注释。
  • 复杂算法,若使用了一个独特的方式解决问题,则要在注释中解释你是如何做的,这样不仅可帮助其他浏览代码的人,也方便下次自己查阅代码帮助理解。
  • Hack,因为存在浏览器差异,js代码一般会包含一些hack,不要假设其他人在查看代码时能够理解hack所解决的问题,那么将信息放在注释中,能减少别人误删改的情况。

缩进和注释都可以带来更可读的代码,在未来则更容易维护。

变量和函数命名

适当给变量和函数起名字对于增加代码可读性和可维护性非常重要。命名的一般规则如下:

  • 变量名应该是名词
  • 函数名应该以动词开始,返回布尔值类型的函数一般以is开头
  • 变量和函数都应使用合乎逻辑的名字,不用担心长度,长度问题可以通过后处理和压缩来缓解
    必须避免出现无法表示所包含的数据类型的无用变量名,有了合适的命名,代码阅读起来就像讲述故事一样,更容易理解。
变量类型透明

由于js中变量是松散类型,很容易就忘记变量所应包含的数据类型,合适的命名方式可以一定程度上环境这个问题,但放到所有的情况下看,还不够,有三种表示变量数据类型的方式。

第一个是初始化,当定义了一个变量后,它应该初始化为一个值,暗示它将来应该如何应用,例如,将来保存布尔类型值的变量应该初始化为true或false,将来保存数字的变量初始化为一个数字。

初始化为一个特定的数据类型可以很好的指明变量的类型,但缺点是它无法用于函数声明中的函数参数。

第二种是使用匈牙利标记发来指定变量类型,即在变量名前加上一个或多个字符来表示数据类型。比如:o表示对象,s表示字符串,i表示整数,f表示浮点数,b表示布尔值。

1
2
var iCount = 1; // 整数
var oPerson = {}; // 对象

好处是,函数参数一样可以使用,但缺点是让代码某种程度上难以阅读,阻碍了代码的直观性和句子式的特质。

最后一种指定变量类型的方式是使用类型注释,类型注释放在变量名右边,但在初始化前面,这种方式是在变量旁边放一段指定类型的注释:

1
2
var count /*:int*/ = 1;
var person; /*:object*/ = null;

类型注释维持了代码整体的可读性,同时注入了类型信息,类型注释的缺点是你不能用多行注释依次注释大块的代码,因为类型注释也是多行注释,两者会冲突。

三种方式各有优缺点、可自行确定那种最适合项目并一致使用。

松散耦合

只要应用的某个部分过渡依赖于另一部分,代码就是耦合过紧,难以维护,典型的问题如:对象直接引用另一个对象,并且当修改其中一个的同时需要修改另一个,紧密耦合的软件难以维护并且常常需要重写。

而Web所用的技术有多种情况会使它耦合过紧,必须小心,尽可能采用若耦合的方式:

解耦HTML/JS

在Web中,HTML和js各自代表了解决方案中的不同层次,HTML是数据,js是行为,因为它们天生需要交互,所有有多种不同的方法将其关联起来,但一些方法会将HTML和js过于紧密的耦合在一起。比如直接写在HTML中的js,使用包含内联代码的script标签或使用html属性来分配事件处理程序。

当HTML和js过于紧密的耦合在一起时,出现js错误就要先判断错误是出现在HTML部分还是在js文件中,同时还会引入和代码是否可用的相关问题。而且对行为的修改需要同时触及HTML和js,因此影响了可维护性,而这些更改本该只在js中进行。

理想的情况下HTML和js应该完全分离,并通过外部文件和通过DOM注册行为来包含js。

HTML和js的紧密耦合也可以在相反的关系上成立,js包含HTML,通常是通过innerHTML插入HTML文本到页面上。

一般来说,应该避免在js中创建大量的HTML,HTML呈现应该尽可能与js分离,当用js插入数据时,尽量不要直接插入html标记,而是预先写好隐藏在页面内,使用js显示。

将HTML和js解耦可以在调试过程中节省时间,更容易定位错误,减轻维护难度。修改行为只在js中进行,更改标记只在渲染文件中。

解耦CSS/JS

解耦CSS/JS常常是通过js修改样式类名(class名),而非直接修改样式本身,即只修改类名不修改样式信息,样式信息预先定义在class中。

由于IE中CSS可以通过表达式嵌入js,这会导致维护困难,所以避免使用这种方式。

解耦逻辑/事件处理程序

Web一般都很相当多的事件处理程序、监听着无数的事件,但很难彻底将逻辑从事件处理程序分离,例如:

1
2
3
4
5
6
7
8
9
10
11
12
function handleKeyPress(event){
event = EventUtil.getEvent(event);
if(event.keyCode == 13){
var target = EventUtil.getTarget(event);
// 上面为事件处理,下面为应用逻辑
var value = 5 * parseInt(target.value);
if(value > 10){
document.getElementById('errmsg').style.display = 'block';
}

}
}

上例的事件处理程序除了包含应用逻辑,还进行了事件处理,这种方式的问题主要有2种:
首先,除了通过事件之外就在没有方法执行应用逻辑,这样调试很困难,若没有发生预期的结果怎么办?是不是表示事件处理程序没有被调用还是指应用逻辑失败?
其次,若一个后续的事件引发同样的应用逻辑,那么就必须复制功能代码或者将代码抽取到一个单独的函数中,但无论那种,都要做更多的改动。

较好的方法就是将应用逻辑和事件处理程序相分离,各自处理,一个事件处理程序应该从事件对象中提取相关信息,并将信息传递到处理应用逻辑的某个方法中。修改如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 事件处理
function handleKeyPress(event){
event = EventUtil.getEvent(event);
if(event.keyCode == 13){
var target = EventUtil.getTarget(event);
validate(target.value);
}
}

// 应用逻辑
function validate(value){
var value = 5 * parseInt(value);
if(value > 10){
document.getElementById('errmsg').style.display = 'block';
}
}

如此改动后,validate中没有任何东西会依赖事件处理程序的代码,它只接收一个值然后执行逻辑计算。

从事件处理程序中分离应用逻辑的好处如下:

  1. 更容易更改触发特定过程的事件,比如:若最开始由鼠标点击触发,但现在也需要按键触发,那么这样修改更容易。
  2. 可以在不附加到事件的情况下测试代码,使其更容易创建单元测试或自动化应用流程。

应用和业务逻辑之间松散耦合的原则如下:

  • 勿将event对象传给其他方法,只传来自event对象中所需的数据
  • 任何可以在应用层面的动作都应该可以在不执行任何事件处理程序的情况下进行
  • 任何事件处理程序都只应该处理事件,然后将处理转交给应用逻辑。

其他实践技巧

可维护的js并不仅仅是关于如何格式化代码,它还关系到代码做什么的问题,多人协作的情况下,应该确保每个人所使用的开发环境都一致,同时坚持一些好的编程实践:

尊重对象所有权

js的动态性质使得几乎任何东西在任何时间都可以修改,虽然在ES5中通过引入防篡改对象得以改变,但模式情况下所有的对象都是可以修改的。

在开发时,尤其是开发大型项目时,最重要的编程实践之一就是尊重对象所有权,即,不能任意修改不属于自己的对象。简单的说就是若不负责创建或维护某个对象、对象的属性或方法,那么就不能对它们进行修改,更具体的说:

  • 不要为实例或原型添加属性、方法
  • 不要重定义已经存在的方法

所谓拥有对象,就是指对象是有你创建的,即一个自定义的类型或对象字面量,而Array、document的原生对象就不是你拥有的,但通过一些方法可以为原生对象或其他自定义对象创建新功能:

  • 创建包含所需功能的新对象,并用它与相关对象进行交互
  • 创建自定义类型,继承需要进行修改的类型,然后为自定义类型添加额外功能
避免全局变量

与“尊重对象所有权”对应相关的就是尽可能避免全局变量和函数,同时也关系到创建一个脚本执行的一致并可维护环境,最多创建一个全局变量,让其他对象和函数存在其中。

单一的全局变量的延伸便是命名空间的概念,命名空间包括创建一个用于放置功能的对象,比如:

  • YAHOO.util.Dom 处理DOM的方法
  • YAHOO.util.Event 与事件交互的方法
  • YAHOO.lang 用于底层语言特性的方法

对于YUI,单一的全局对象YAHOO作为一个容器,其中定义了其他对象,用这种方法将功能组合在一起的对象就叫命名空间。

命名空间重要的就是确定每一个人都统一并唯一使用,大多数可用公司名字或项目名,比如YAHOO、Wrox。

避免与null进行比较

由于js不做任何自动类型检查,所以检查类型就成为很多代码执行前必须的工作,常见的就是查看某值是否为null,但直接与null比较却有问题,比如:

1
2
3
if(value != null){ // value是一个数组
value.sort(); // ...
}

上述的代码问题就是仅仅检查value是否为null,无法避免value为其他非数组类型,以及sort为undefined的错误。应该使用instanceof检测

1
2
3
if(value instanceof Array){
//...
}

一般来说,类型检测或容错检测有如下原则:

  • 检测一个引用类型,用instanceof检测其构造函数
  • 检测一个基本类型,用typeof检测
  • 若希望对象包含某特定方法,则用typeof确保指定名字的方法存在对象上
使用常量

尽管js没有常量的概念,但不可否则常量非常重要,而且有用,即将数据从应用逻辑分离出来的思想,可以在不引入错误的风险下改变数据。比如错误提示,就应该按照将提示信息抽离出来。

将数据和使用逻辑分离的原则如下:

  • 重复值 —— 任何在多处用到的值都应该抽取为一个常量,避免值的多次修改和不一致
  • 用户界面字符串 —— 任何用于显示给用户的字符串,都应该抽取出来以方便国际化
  • URL —— 在Web应用中,资源的位置很容易改变,所以应该在一个公共的地方存放所有的URL
  • 任意可能会更改的值 —— 每当用到字面量时,都要考虑是否在将来会被修改。若不会修改则可以作为常量

性能

由于js本身是解释性语言,执行的速度要比编译型语言慢,而现代浏览器(由Chrome开启的)内置优化引擎,将js编译为本地代码再执行,陆续实现了js的编译执行,但代码若本身低效则任何优化都是没用的。所以应该采用一些方法来改进代码的整体性能。

注意作用域

随着作用域链中的作用域数量的增加,访问当前作用域以外的变量的时间也在增加,访问全局变量总是要比访问局部变量慢,因为需要遍历作用域链,只要能减少花在作用域链上的时间,就能增加整体性能。

避免全局查找

优化脚本性能最重要的就是注意全局查找,使用全局变量和函数肯定比局部变量开销大,因为会涉及作用域链的查找。
比如对document,就可以用一个局部变量来引用,然后用局部变量代替document。

避免with语句

在性能上需要特别注意的就是避免使用with语句,因为with语句会创建自己的作用域,因此会增加其中执行的代码的作用域链的长度。

而with语句主要的用途是消除额外的字符,大多数情况下,可以用局部变量代替。

选择正确方法

和其他语言一样,性能与解决方法/算法是相关的,所以一些通用的优化策略也是可以在js中使用的。

避免不必要的属性查找

按照算法复杂度来看,O(1)操作性能是最佳的,比如读写字面量、数组、变量中的值。但读写对象上的属性和方法则是一个O(n)操作,所以要注意多重属性查找,比如:

1
2
3
4
5
// 6次属性查找
window.location.href.substring(window.location.href.infexOf('?'));
// 改进如下,只有4次属性查找,优化33%
var href = window.location.href;
href.substring(href.infexOf('?'))

一般的,尽量用局部变量代替属性查找是,能用数组进行方法则用数组方式。

优化循环

循环是编程中最常见的结构之一,优化循环的基本步骤如下:

  1. 减值迭代 —— 一般循环使用0作为起始,但从最大值开始迭代更高效
  2. 简化终止条件 —— 由于每次循环都会计算终止条件,所以必须保证尽快能快的计算出终止条件,即避免属性查找或其他O(n)的操作
  3. 简化循环体 —— 循环体是执行最多的,所以要最大限度优化它,尽量确保没有复杂计算
  4. 使用后测试循环 —— for和while循环都是前测试循环,而do-while则属性后测试循环,可以避免最初计算终止条件,因此能运行更快
    比如一个普通的for循环的改进:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    for(var i  = 0; i < values.length; i++){
    process(values[i]);
    }
    // 改进如下
    var i = values.length - 1;
    if(i > -1){
    do{
    process(values[i]);
    }while( --i >= 0);
    }

主要优化的是将终止条件和自减操作符组合成了单个语句。“后测试”循环时必须确保要处理的值至少有一个,空数组会导致多余的一次循环而“前测试”循环则可以避免。

展开循环

当循环的次数是确定的,消除循环并使用多次函数调用往往更快,这样展开循环可以消除建立循环和处理终止条件的额外开销,使代码运行更快,比如直接连续执行3次process比for循环3次要快。

若循环迭代次数事先无法确实,有一个Duff的概念可以将循环展开提高效率(以8为倍数执行):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var iterations = Math.floor(values.length / 8);
var leftover = values.length % 8;
var i = 0;

if(leftover > 0){
do{
process(values[i++]);
}while(--leftover > 0);
}

do{
process(values[i++]);
process(values[i++]);
process(values[i++]);
process(values[i++]);
process(values[i++]);
process(values[i++]);
process(values[i++]);
process(values[i++]);
}while(--iterations > 0);

这样剩余的计算部分不会在实际循环中处理,而是在一个初始化循环中进行除以8的操作,当处理掉了额外的元素,执行每次调用8次process的主循环。

此Duff对大数据集有效,但对小数据集的额外开销则可能得不偿失。

避免双重解释

当用js代码解析js的时候会存在双重解析的问题,比如eval函数或Function构造函数以及使用setTimeout传一个字符串参数时都会发生这种情况。

这些操作都需要解析包含js代码的字符串,而且不能在初始的解析过程中完成,因为代码是包含在字符串中的,即js代码运行的同时必须新启动一个解析器来解析新的代码,实例化一个新的解析器的开销比直接解析要大很多,也慢的多。

其他与性能有关的注意事项

除了以上的几点外,还有一些非主要的问题,但使用得当也能提升很多性能

  1. 原生方法较快 —— 原生方法是用C/C++之类的编译型语言写的,所以要比js快很多,js中最容易忘记的就是可以在Match对象中的复杂数学运算,这些方法比用js写的同样的方法快很多。
  2. Switch较快 —— 若有一些列复杂的if-else语句,可转换为单个switch,还可以通过将case语句按照最可能用到的顺序进行组织,进一步优化switch语句。
  3. 位运算符较快 —— 当进行数学运算时,位运算操作要比任何布尔运算或算数运算快,比如模、与、或等都可以用位运算代替。

最小化语句数

js代码中的语句数量也影响所执行的操作的速度,完成多个操作的单个语句要比完成单个操作的多个语句快,所以要找出可以组合在一起的语句,以减少脚本整体的执行时间。

  1. 多个变量声明 —— 声明变量时只用一个var
  2. 插入迭代值 —— 当使用迭代值时,尽可能合并语句,比如value[i++]
  3. 使用数组和对象字面量 —— 相比使用构造函数创建对象和数组,直接使用字面量的方式可以将操作在一个语句内完成

优化DOM交互

js中,DOM操作是最慢的,因为它们往往要重新渲染整个页面或某一部分,而且由于DOM要处理非常多的信息,所以看似很小的操作开销却不小。理解如何优化DOM操作可以极大提高脚本完成的速度。

最小化现场更新

现场更新的意思是,需要立即(现场)对页面中的显示进行更新。每一个更改,不管是插入字符,还是移除标签,都有较大的开销,因为浏览器要重新计算很多以进行更新。

一般采用的策略为使用文档片段来构建DOM,然后再一次性添加到文档中。

使用innerHTML

对于大的DOM更改,使用innerHTML比标准的DOM方法(createElement,appendChild)创建同样的DOM结构快。

当将innerHTML设置为某个值时,后台会创建一个HTML解析器,然后使用内部的DOM调用来创建DOM结构,而非基于js的DOM调用,由于内部方法是编译后执行的代码,所以执行快得多。

使用事件代理

大多数Web应用都有大量的事件处理程序,页面上的事件处理程序的数量和页面响应用户交互的速度成反比,事件代理的原理是由于事件冒泡,高层的节点能处理多个目标的事件。

注意HTMLCollection

HTMLCollection对象的问题在于查询开销非常大,所以需要限制使用。

首先一定要知道那些方法会返回HTMLCollection:

  • getElementsByTagName方法
  • 元素对象的childNodes属性
  • 元素对象的attributes属性
  • 访问特殊的集合,如document.forms, document.images等

一般来说在内部循环或函数执行时,用局部变量替代属性查询会非常有用。