前言
自2000年以来,Web 开发一直在以惊人的速度发展。从最初毫无章法可循的 “野蛮生长”,到如今已发展出完整的规范体系,各种研究成果和最佳实 践层出不穷。Web 开发领域的最新技术和开发工具已经令人目不暇接。其中,前端三件套中的核心 JavaScript 尤其成为了研究和关注的焦点。 JavaScript 的最佳实践可以分成几类,适用于开发流程的不同阶段。
1. 可维护性
在早期网站中,JavaScript 主要用于实现一些小型动效或表单验证。今天的 Web 应用程序则动辄成千上万行 JavaScript 代码,用于完成各种各样的复杂处理。这些变化要求开发者把可维护能力放到重要位置上。正如更传统意义上的软件工程师一样,JavaScript 开发者受雇是要为公司创造价值的。他们不仅要保证产品如期上线,而且要随着时间推移为公司不断积累 知识资产。
编写可维护的代码十分重要,因为大多数开发者会花大量时间去维护别人写的代码。实际开发中,从第一行代码开始写起的情况非常少,通常是要在别人的代码之上构建自己的工作。让自己的代码容易维护,可以保证其他开发者更好地完成自己的工作。
注意:可维护代码的概念并不只适用于 JavaScript,其中很多概念适用于所有编程语言,尽管部分概念特定于 JavaScript。
1.1 什么是可维护的代码
通常,说代码“可维护”就意味着它具备如下特点。
- 容易理解 :无须求助原始开发者,任何人一看代码就知道它是干什么的,以及它是怎么实现的。
- 符合常识 :代码中的一切都显得顺理成章,无论操作有多么复杂。
- 容易适配 :即使数据发生变化也不用完全重写。
- 容易扩展 :代码架构经过认真设计,支持未来扩展核心功能。
- 容易调试 :出问题时,代码可以给出明确的信息,通过它能直接定位 问题。
能够写出可维护的 JavaScript 代码是一项重要的专业技能。这就是业余爱好者和专业开发人员之间的区别,前者用一个周末就拼凑出一个网站,而后者真正了解自己的技术。
1.2 编码规范
编写可维护代码的第一步是认真考虑编码规范。大多数编程语言会涉及编码规范,简单上网一搜,就可以找到成千上万的相关文章。专业组织有为开发者建立的编码规范,旨在让人写出更容易维护的代码。优秀开源项目有严格的编码规范,可以让社区的所有人容易地理解代码是如何组织的。
编码规范对 JavaScript 而言非常重要,因为这门语言实在太灵活了。与大多数面向对象语言不同, JavaScript 并不强迫开发者把任何东西都定义为对象。它支持任何编程风格,包括传统的面向对象编程、声明式编程,以及函数式编程。简单看几个开源的 JavaScript库,就会发现有很多方式可以创建对象、定义方法和管理环境。
接下来的几节会讨论制定编码规范的一些基础知识。这些话题很重要,当然每个人的需求不同,实现方式也可以不同。
1.2.1 可读性
要想让代码容易维护,首先必须使其可读。可读性必须考虑代码是一种文本文件。为此,代码缩进是保证可读性的重要基础。如果所有人都使用相同的缩进,整个项目的代码就会更容易让人看懂。缩进通常要使用空格数而不是Tab(制表符)来定义,因为后者在不同文本编辑器中的显示不同。一般来说,缩进是4个空格,当然具体多少个可以自己定。
可读性的另一方面是代码注释。在大多数编程语言中,广泛接受的做法是为每个方法都编写注释。因为 JavaScript 可以在代码中的任何地 方创建函数,所以这一点经常被忽视。正因为如此,可能给 JavaScript 中的每个函数都写注释才更重要。一般来说,以下这些地方应该写注释。
函数和方法 。每个函数和方法都应该有注释来描述其用途,以及完成任务所用的算法。同时,也写清使用这个函数或方法的前提 (假设)、每个参数的含义,以及函数是否返回值(因为通过函 数定义看不出来)。
大型代码块 。多行代码但用于完成单一任务的,应该在前面给出注释,把要完成的任务写清楚。
- 复杂的算法 。如果使用了独特的方法解决问题,要通过注释解释明白。这样不仅可以帮助别人查看代码,也可以帮助自己今后查 看代码。
- 使用黑科技 。由于浏览器之间的差异,JavaScript 代码中通常包含一些黑科技。不要假设其他人一看就能明白某个黑科技是为了解决某个浏览器的什么问题。如果某个浏览器不能使用正常方式达到目的,那要在注释里把黑科技的用途写出来。这样可以避免别人误以为黑科技没有用而把它“修复”掉,结果你已解决的问 题又会出现。
缩进和注释可以让代码更容易理解,将来也更容易维护。
1.2.2 变量和函数命名
代码中变量和函数的适当命名对于其可读性和可维护性至关重要。因为很多 JavaScript 开发者是业余爱好者出身,所以很容易用foo 、 bar 命名变量,用 doSomething 来命名函数。专业 JavaScript 开发者必须改掉这些习惯,这样才能写出可维护的代码。以下是关于命名的通用规则。
变量名应该是名词,例如 car 或 person 。
函数名应该以动词开始,例如
getName()
。返回布尔值的函数 通常以 is 开头,比如isEnabled()
。- 对变量和函数都使用符合逻辑的名称,不用担心长度。长名字的问题可以通过后处理和压缩解决(后面会讨论)。
- 变量、函数和方法应该以小写字母开头,使用驼峰大小写 (camelCase)形式,如
getName()
和isPerson
。类名应该首 字母大写,如Person 、RequestFactory 。常量值应该全部 大写并以下划线相接,比如REQUEST_TIMEOUT
。 - 名称要尽量用描述性和直观的词汇,但不要过于冗长。
getName()
一看就知道会返回名称,而PersonFactory
一看就知道会产生某个 Person 对象或实体。
1.2.3 变量类型透明化
因为 JavaScript 是松散类型的语言,所以很容易忘记变量包含的数据类型。适当命名可以在某种程度上解决这个问题,但还不够。有三种方式可以标明变量的数据类型。
第一种标明变量类型的方式是通过初始化。定义变量时,应该立即将其初始化为一个将来要使用的类型值。例如,要保存布尔值的变量,可以将其初始化为 true 或 false ;而要保存数值的变量,可以将其初始化为一个数值。再看几个例子:
// 通过初始化标明变量类型
let found = false; // 布尔值
let count = -1; // 数值
let name = ""; // 字符串
let person = null; // 对象
初始化为特定数据类型的值可以明确表示变量的类型。ES6之前,初始化方式不适合函数声明中函数的参数;ES6之后,可以在函数声明中为参数指定默认值来标明参数类型。
第二种标明变量类型的方式是使用匈牙利表示法。匈牙利表示法指的是在变量名前面前缀一个或多个字符表示数据类型。这种表示法曾在脚本语言中非常流行,很长时间以来也是 JavaScript 首选的格式。对于基本数据类型,JavaScript传统的匈牙利表示法用 o 表示对象,s 表示字符串,i 表示整数,f 表示浮点数,b 表示布尔值。示例如下:
// 使用匈牙利表示法标明数据类型
let bFound; // 布尔值
let iCount; // 整数
let sName; // 字符串
let oPerson; // 对象
匈牙利表示法也可以很好地应用于函数参数。它的缺点是使代码可读性下降、不够直观,并破坏了类似句子的自然阅读流畅性。因此,匈牙利表示法在开发者中失宠了。
最后一种标明变量类型的方式是使用类型注释。类型注释放在变量名后面、初始化表达式的前面。基本思路是在变量旁边使用注释说明类型,比如:
// 使用类型注释表明数据类型
let found /*:Boolean*/ = false
let count /*:int*/ = 10
let name /*:String*/ = 'Nicholas'
let person /*:Object*/ = null
类型注释在保持代码整体可读性的同时向其注释了类型信息。类型注释的缺点是不能再使用多行注释把大型代码块注释掉了。因为类型注释也是多行注释,所以会造成干扰,如下例所示:
// 这样多行注释不会生效
/*
let found /*:Boolean*/ = false;
let count /*:int*/ = 10;
let name /*:String*/ = "Nicholas";
let person /*:Object*/ = null;
*/
这里本来是想使用多行注释把所有变量声明都注释掉。但类型注释产生了干扰,因为第一个/ (第2行)的实例会与第一个/ (第3行) 的实例匹配,所以会导致语法错误。如果想注释掉使用类型注释的代码,则只能使用单行注释一行一行地注释掉每一行(很多编辑器可以自动完成)。
以上是最常用的三种标明变量数据类型方式。每种方式都有其优点和缺点,可以根据实际情况选用。关键要看哪一种最适合自己的项目, 并保证一致性。
1.3 松散耦合
只要应用程序的某个部分对另一个部分依赖得过于紧密,代码就会变成紧密耦合,因而难以维护。典型的问题是在一个对象中直接引用另一个对象,这样,修改其中一个,可能必须还得修改另一个。紧密耦合的软件难于维护,肯定需要频繁地重写。
考虑到相关的技术,Web 应用程序在某些情况下可能变得过于紧密耦合。关键在于有这个意识,随时注意不要让代码产生紧密耦合。
1.3.1 解耦HTML/JavaScript
Web 开发中最常见的耦合是 HTML/JavaScript 耦合。在网页中,HTML和 JavaScript 分别代表不同层面的解决方案。HTML 是数据,JavaScript 是行为。这是因为它们之间要交互操作,需要通过不同的方式将这两种技术联系起来。可惜的是,其中一些方式会导致 HTML 与 JavaScript 紧密耦合。
把 JavaScript 直接嵌入在 HTML 中,要么使用包含嵌入代码的 <script>
元素,要么使用 HTML 属性添加事件处理程序,这些都会造成紧密耦合。比如下面的例子:
<!-- 使用<script>造成HTML/JavaScript紧密耦合 -->
<script>
document.write('Hello world!')
</script>
<!-- 使用事件处理程序属性造成HTML/JavaScript紧密耦合 -->
<input type="button" value="Click Me" onclick="handleClick()" />
虽然技术上这样做没有问题,但实践中,这样会将表示数据的 HTML 与定义行为的 JavaScript 紧密耦合在一起。理想情况下,HTML 和 JavaScript 应该完全分开,通过外部文件引入 JavaScript,然后使用 DOM 添加行为。
HTML 与 JavaScript 紧密耦合的情况下,每次分析 JavaScript 的报错都要先确定错误来自 HTML 还是 JavaScript。这样也会引发代码可用性的新错误。在这个例子中,用户可能会在 handleClick()
函数可用之 前点击按钮,从而导致 JavaScript 报错。因为每次修改按钮的行为都需要既改 HTML 又改 JavaScript,而实际上只有后者才是有必要修改的,所以就会降低代码的可维护性。
在相反的情况下,HTML 和 JavaScript 也会变得紧密耦合:把 HTML 包含在 JavaScript 中。这种情况通常发生在把一段 HTML 通过 innerHTML 插入到页面中时,示例如下:
// HTML紧密耦合到了JavaScript
function insertMessage(msg) {
let container = document.getElementById('container')
container.innerHTML = `
<div class="msg">
<p> class="post">${msg}</p>
<p><em>Latest message above.</em></p>
</div>
`
}
一般来说,应该避免在 JavaScript 中创建大量 HTML。同样,这主要是为了做到数据层和行为层各司其职,在出错时更容易定位问题所在。使用上面的示例代码时,如果动态插入的 HTML 格式不对,就会造成页面布局出错。不过在这种情况下定位错误就更困难了,因为这时候通常首先会去找页面中出错的 HTML 源代码,但又找不到,因为它是动态生成的。修改数据或页面的同时还需要修改J avaScript,这说明两层是紧密耦合的。
HTML 渲染应该尽可能与 JavaScript 分开。在使用 JavaScript 插入数据时,应该尽可能不要插入标记。相应的标记可以包含并隐藏在页面中,在需要的时候 JavaScript 可以直接用它来显示,而不需要动态生成。另一个办法是通过 Ajax 请求获取要显示的 HTML,这样也可以保证同一个渲染层(PHP、JSP、Ruby等)负责输出标记,而不是把标记嵌 在 JavaScript 中。
解耦HTML和JavaScript可以节省排错时间,因为更容易定位错误来源。同样解耦也有助于保证可维护性。修改行为只涉及JavaScript, 修改标记只涉及要渲染的文件。
1.3.2 解耦CSS/JavaScript
Web 应用程序的另一层是 CSS,主要负责页面显示。JavaScript 和 CSS 紧密相关,它们都建构在 HTML 之上,因此也经常一起使用。与 HTML和 JavaScript 的情况类似,CSS 也可能与 JavaScript 产生紧密耦合。最常见的例子就是使用 JavaScript 修改个别样式,比如:
// CSS紧耦合到了JavaScript
element.style.color = 'red'
element.style.backgroundColor = 'blue'
因为CSS负责页面显示,所以任何样式的问题都应该通过CSS文件解决。可是,如果JavaScript直接修改个别样式(比如颜色),就会增 加一个排错时要考虑甚至要修改的因素。结果是JavaScript某种程度 上承担了页面显示的任务,与CSS成了紧密耦合。如果将来有一天要修 改样式,那么CSS和JavaScript可能都需要修改。这对负责维护的开发者来说是一个噩梦。层与层的清晰解耦是必需的。
现代Web应用程序经常使用JavaScript改变样式,因此虽然不太可能完全解耦CSS和JavaScript,但可以让这种耦合变成更松散。这主要可以通过动态修改类名而不是样式来实现,比如:
// CSS与JavaScript松散耦合
element.className = "edit";
通过修改元素的CSS类名,可以把大部分样式限制在CSS文件里。 JavaScript只负责修改应用样式的类名,而不直接影响元素的样式。 只要应用的类名没错,那么显示的问题就只跟CSS有关,而跟 JavaScript无关。
同样,保证层与层之间的适当分离至关重要。显示出问题就应该只到 CSS中解决,行为出问题就应该只找JavaScript的问题。这些层之间的松散耦合可以提升整个应用程序的可维护性。
1.3.3 解耦应用程序逻辑/事件处理程序
每个Web应用程序中都会有大量事件处理程序在监听各种事件。可是,其中很少能真正做到应用程序逻辑与事件处理程序分离。来看下面的例子:
function handleKeyPress(event) {
if (event.keyCode == 13) {
let target = event.target
let value = 5 * parseInt(target.value)
if (value > 10) {
document.getElementById('error-msg').style.display = 'block'
}
}
}
这个事件处理程序除了处理事件,还包含了应用程序逻辑。这样做的问题是双重的。
首先,除了事件没有办法触发应用程序逻辑,结果造成调试困难。如果没有产生预期的结果怎么办?是因为没有调用事件处理程序,还是因为应用程序逻辑有错误?
其次,如果后续事件也会对应相同的应用程序逻辑,则会导致代码重复,或者把它提取到单独的函数中。无论情况如何,都会导致原本不必要的多余工作。
更好的做法是将应用程序逻辑与事件处理程序分开,各自负责处理各自的事情。事件处理程序应该专注于event 对象的相关信息,然后把这些信息传给处理应用程序逻辑的某些方法。例如,前面的例子可以重写为如下代码:
function validateValue(value) {
value = 5 * parseInt(value)
if (value > 10) {
document.getElementById('error-msg').style.display = 'block'
}
}
function handleKeyPress(event) {
if (event.keyCode == 13) {
let target = event.target
validateValue(target.value)
}
}
这样修改之后,应用程序逻辑跟事件处理程序就分开了。 handleKeyPress()
函数只负责检查用户是不是按下了回车键 (event.keyCode
等于13 ),如果是则取得事件目标,并把目标值传给 validateValue()
函数,该函数包含应用程序逻辑。注意,validateValue()
函数中不包含任何依赖事件处理程序的代码。这个函数只负责接收一个值,并根据该值执行其他所有操作。
把应用程序逻辑从事件处理程序中分离出来有很多好处。
首先,这可以让我们以最少的工作量轻松地修改触发某些流程的事件。如果原来是通过鼠标单击触发流程,而现在又想增加键盘操作来触发,那么修改起来也很简单。
其次,可以在不用添加事件的情况下测试代码,这样创建单元测试或自动化应用程序流都会更简单。
以下是在解耦应用程序逻辑和业务逻辑时应该注意的几点。
不要把 event 对象传给其他方法,而是只传递 event 对象中必要的数据。
应用程序中每个可能的操作都应该无需事件处理程序就可以执行。
- 事件处理程序应该处理事件,而把后续处理交给应用程序逻辑。
做到上述几点能够给任何代码的可维护性带来巨大的提升,同时也能为将来的测试和开发提供很多可能性。
1.4 编码惯例
编写可维护的JavaScript不仅仅涉及代码格式和规范,也涉及代码做什么。企业开发Web应用程序通常需要很多人协同工作。这时候就需要保证每个人的浏览器环境都有恒定不变的规则。为此,开发者应该遵守某些编码惯例。
1.4.1 尊重对象所有权
JavaScript的动态特性意味着几乎可以在任何时候修改任何东西。过去有人说,JavaScript中没有什么是神圣不可侵犯的,因为不能把任何东西标记为最终结果或者恒定不变。但ECMAScript 5引入防篡改对象之后,情况不同了。当然,对象默认还是可以修改的。在其他语言中,在没有源代码的情况下,对象和类不可修改。JavaScript则允许在任何时候修改任何对象,因此就可能导致意外地覆盖默认行为。因为这门语言没有什么限制,所以就需要开发者自己限制自己。
在企业开发中,非常重要的编码惯例就是尊重对象所有权,这意味着不要修改不属于你的对象。简单来讲,如果你不负责创建和维护某个对象及其构造函数或方法,就不应该对其进行任何修改。更具体一点说,就是如下惯例。
- 不要给实例或原型添加属性。
- 不要给实例或原型添加方法。
- 不要重定义已有的方法。
问题在于,开发者会假设浏览器环境以某种方式运行。修改了多个人使用的对象也就意味着会有错误发生。假设有人希望某个函数叫作 stopEvent()
,用于取消某个事件的默认行为。然后,你把它给改了,除了取消事件的默认行为,又添加了其他事件处理程序。可想而知,问题肯定会接踵而至。别人还认为这个函数只做最开始的那点事,但由于对它后来添加的副作用并不知情,因此很可能就会用错或者造成损失。
以上规则不仅适用于自定义类型和对象,而且适用于原生类型和对象,比如Object 、String 、document 、window ,等等。考虑到浏览器厂商也有可能会在不公开的情况下以非预期方式修改这些对象,潜在的风险就更大了。
有个流行的Prototype库就发生过类似的事件。该库在document 对象上实现了 getElementsByClassName()
方法,返回一个Array 的实例,而这个实例上还增加了 each()
方法。jQuery的作者 John Resig 后来在自己的博客上分析了这个问题造成的影响。他在博客中指出这个问题是由于浏览器也原生实现了相同的 getElementsByClassName()
方法造成的,但 Prototype 的同名方法返回的是Array 而非NodeList ,NodeList 没有 each()
方法。使用这个库的开发者之前会写这样的代码:
document.getElementsByClassName("selected").each(Element.hide);
虽然这样写在没有原生实现getElementsByClassName() 方法的浏览器里没有问题,但在实现它的浏览器里就会出问题。这是因为两个同名方法返回的结果不一样。我们不能预见浏览器厂商将来会怎么修改原生对象,因此不管怎么修改它们都可能在将来某个时刻出现冲 突时导致问题。
为此,最好的方法是永远不要修改不属于你的对象。只有你自己创建的才是你的对象,包括自定义类型和对象字面量。Array 、 document 等对象都不是你的,因为在你的代码执行之前它们已经存在了。可以按如下这样为对象添加新功能。
- 创建包含想要功能的新对象,通过它与别人的对象交互。
- 创建新自定义类型继承本来想要修改的类型,可以给自定义类型添加新功能。
很多JavaScript库目前支持这种开发理念,这样无论浏览器怎样改变 都可以发展和适应。
1.4.2 不声明全局变量
与尊重对象所有权密切相关的是尽可能不声明全局变量和函数。同样,这也关系到创建一致和可维护的脚本运行环境。最多可以创建一 个全局变量,作为其他对象和函数的命名空间。来看下面的例子:
// 两个全局变量:不要!
var name = 'Nicholas'
function sayName() {
console.log(name)
}
以上代码声明了两个全局变量:name
和 sayName()
。可以像下面这样把它们包含在一个对象中:
// 一个全局变量:推荐
var MyApplication = {
name: 'Nicholas',
sayName: function() {
console.log(this.name)
}
}
这个重写后的版本只声明了一个全局对象 MyApplication 。该对象包含了 name
和 sayName()
。这样可以避免之前版本的几个问题。
- 首先,变量
name
会覆盖window.name
属性,而这可能会影响其他功能。 - 其次,有助于分清功能都集中在哪里。调用
MyApplication.sayName()
从逻辑上会暗示,出现任何问题都可以在MyApplication 的代码中找原因。
这样一个全局对象可以扩展为命名空间的概念。命名空间涉及创建一个对象,然后通过这个对象来暴露能力。比如,Google Closure 库就利用了这样的命名空间来组织其代码。下面是几个例子。
goog.string
:用于操作字符串的方法。goog.html.utils
:与HTML相关的方法。goog.i18n
:与国际化(i18n)相关的方法。
对象goog 就相当于一个容器,其他对象包含在这里面。只要使用对象以这种方式来组织功能,就可以称该对象为命名空间。整个Google Closure库都构建在这个概念之上,能够在同一个页面上与其他 JavaScript库共存。
关于命名空间,最重要的确定一个所有人都同意的全局对象名称。这个名称要足够独特,不可能与其他人的冲突。大多数情况下,可以使用开发者所在的公司名,例如goog 或Wrox 。下面的例子演示了使用 Wrox 作为命名空间来组织功能:
// 创建全局对象
var Wrox = {};
// 创建命名空间
Wrox.ProJS = {};
// 添加其他对象
Wrox.ProJS.EventUtil = { ... };
Wrox.ProJS.CookieUtil = { ... };
在这个例子中,Wrox 是全局变量,然后在它的下面又创建了命名空间。如果一本书所有代码都保存在Wrox.ProJS 命名空间中,那么其他作者的代码就可以使用自己的对象来保存。只要每个人都遵循这个模式,就不必担心有人会覆盖这里的EventUtil 或CookieUtil ,因为即使重名它们也只会出现在不同的命名空间中。比如下面的例子:
// 为另一本书创建命名空间
Wrox.ProAjax = {};
// 添加其他对象
Wrox.ProAjax.EventUtil = { ... };
Wrox.ProAjax.CookieUtil = { ... };
// 可以照常使用ProJS下面的对象
Wrox.ProJS.EventUtil.addHandler( ... );
// 以及ProAjax下面的对象
Wrox.ProAjax.EventUtil.addHandler( ... );
虽然命名空间需要多写一点代码,但从可维护性角度看,这个代价还是非常值得的。命名空间可以确保代码与页面上的其他代码互不干扰。
1.4.3 不要比较null
JavaScript不会自动做任何类型检查,因此就需要开发者担起这个责任。结果,很多JavaScript代码不会做类型检查。最常见的类型检查是看值是不是null。然而,与null 进行比较的代码太多了,其中很多因为类型检查不够而频繁引发错误。比如下面的例子:
function sortArray(values) {
if (values != null) {
// 不要这样比较!
values.sort(comparator)
}
}
这个函数的目的是使用给定的比较函数对数组进行排序。为保证函数正常执行,values 参数必须是数组。但是,if 语句在这里只简单地检查了这个值不是null 。实际上,字符串、数值还有其他很多值可以通过这里的检查,结果就会导致错误。
现实当中,单纯比较null 通常是不够的。检查值的类型就要真的检查类型,而不是检查它不能是什么。例如,在前面的代码中,values 参数应该是数组。为此,应该检查它到底是不是数组,而不是检查它不是null 。可以像下面这样重写那个函数:
function sortArray(values) {
if (values instanceof Array) {
// 推荐
values.sort(comparator)
}
}
此函数的这个版本可以过滤所有无效的值,根本不需要使用null 。
如果看到比较null 的代码,可以使用下列某种技术替换它。
如果值应该是引用类型,则使用
instanceof
操作符检查其构造函数。如果值应该是原始类型,则使用
typeof
检查其类型。- 如果希望值是有特定方法名的对象,则使用
typeof
操作符确保 对象上存在给定名字的方法。代码中比较null 的地方越少,就越容易明确类型检查的目的,从而消除不必要的错误。
1.4.4 使用常量
依赖常量的目标是从应用程序逻辑中分离数据,以便修改数据时不会引发错误。显示在用户界面上的字符串就应该以这种方式提取出来,可以方便实现国际化。URL也应该这样提取出来,因为随着应用程序越来越复杂,URL极有可能变化。基本上,像这种地方将来因为某种原因而需要修改时,可能就要找到某个函数并修改其中的代码。每次像这样修改应用程序逻辑,都可能引入新错误。为此,可以把这些可能会修改的数据提取出来,放在单独定义的常量中,以实现数据与逻辑分离。
关键在于把数据从使用它们的逻辑中分离出来。可以使用以下标准检查哪些数据需要提取。
重复出现的值 :任何使用超过一次的值都应该提取到常量中,这样可以消除一个值改了而另一个值没改造成的错误。这里也包括 CSS的类名。
用户界面字符串 :任何会显示给用户的字符串都应该提取出来,以方便实现国际化。
- URL :Web应用程序中资源的地址经常会发生变化,因此建议把所有URL集中放在一个地方管理。
- 任何可能变化的值 :任何时候,只要在代码中使用字面值,就问问自己这个值将来是否可能会变。如果答案是“是”,那么就应该把它提取到常量中。
使用常量是企业级JavaScript开发的重要技术,因为它可以让代码更容易维护,同时可以让代码免受数据变化的影响。
2. 性能
相比JavaScript刚问世时,目前每个网页中JavaScript代码的数量已有极大的增长。代码量的增长也带来了运行时执行JavaScript的性能问题。 JavaScript一开始就是一门解释型语言,因此执行速度比编译型语言要慢一些。Chrome是第一个引入优化引擎将JavaScript编译为原生代码的浏览器。随后,其他主流浏览器也紧随其后,实现了JavaScript编译。
即使到了编译JavaScript时代,仍可能写出运行慢的代码。不过,如果遵循一些基本模式,就能保证写出执行速度很快的代码。
2.1 作用域意识
随着作用域链中作用域数量的增加,访问当前作用域外部变量所需的时间也会增加。访问全局变量始终比访问局部变量慢,因为必须遍历作用域链。任何可以缩短遍历作用域链时间的举措都能提升代码性能。
2.1.1 避免全局查找
改进代码性能非常重要的一件事,可能就是要提防全局查询。全局变量和函数相比于局部值始终是最费时间的,因为需要经历作用域链查找。来看下面的函数:
function updateUI() {
let imgs = document.getElementsByTagName('img')
for (let i = 0, len = imgs.length; i < len; i++) {
imgs[i].title = `${document.title} image ${i}`
}
let msg = document.getElementById('msg')
msg.innerHTML = 'Update complete.'
}
这个函数看起来好像没什么问题,但其中三个地方引用了全局 document 对象。如果页面的图片非常多,那么 for 循环中就需要引用 document 几十甚至上百次,每次都要遍历一次作用域链。
- 通过在局部作用域中保存 document 对象的引用,能够明显提升这个函数的性能,因为只需要作用域链查找 。
- 通过创建一个指向 document 对象的局部变量,可以通过将全局查找的数量限制为一个来提高这个函数的性能:
function updateUI() {
let doc = document
let imgs = doc.getElementsByTagName('img')
for (let i = 0, len = imgs.length; i < len; i++) {
imgs[i].title = `${doc.title} image ${i}`
}
let msg = doc.getElementById('msg')
msg.innerHTML = 'Update complete.'
}
这里先把 document 对象保存在局部变量 doc 中。然后用 doc 替代 了代码中所有的 document。这样调用这个函数只会查找一次作用域链,相对上一个版本,肯定会快很多。
因此,一个经验规则就是,只要函数中有引用超过两次的全局对象,就应该把这个对象保存为一个局部变量。
2.1.2 不使用with语句
在性能很重要的代码中,应避免使用 with 语句。与函数类似,with 语句会创建自己的作用域,因此也会加长其中代码的作用域链。在 with 语句中执行的代码一定比在它外部执行的代码慢,因为作用域链查找时多一步。
实际编码时很少有需要使用with 语句的情况,因为它的主要用途是节省一点代码。大多数情况下,使用局部变量可以实现同样的效果,无须增加新作用域。下面看一个例子:
function updateBody() {
with (document.body) {
console.log(tagName)
innerHTML = 'Hello world!'
}
}
这段代码中的 with 语句让使用 document.body
更简单了。使用局部变量也可以实现同样的效果,如下:
function updateBody() {
let body = document.body
console.log(body.tagName)
body.innerHTML = 'Hello world!'
}
虽然这段代码多了几个字符,但比使用 with 语句还更容易理解了, 因为 tagName 和 innerHTML 属于谁很明确。这段代码还通过把 document.body
保存在局部变量中来省去全局查找。
2.2 选择正确的方法
与其他语言一样,影响性能的因素通常涉及算法或解决问题的方法。经验丰富的开发者知道用什么方法性能更佳。通常很多能在其他编程语言中提升性能的技术和方法同样也适用于JavaScript。
2.2.1 避免不必要的属性查找
在计算机科学中,算法复杂度使用大表示法来表示。最简单同时也最快的算法可以表示为常量值或。然后,稍微复杂一些的算法同时执行时间也更长一些。下表列出了JavaScript中常见算法的类型。
表示法 | 名称 | 说明 |
---|---|---|
O(1) | 常量 | 无论多少值,执行时间都不变。表示简单值和保存在变量中的值 |
O(log n) | 对数 | 执行时间随着值的增加而增加,但算法完成不需要读取每个值。 例子:二分查找 |
O(n) | 线性 | 执行时间与值的数量直接相关。例子:迭代数组的所有元素 |
O(n^2) | 二次方 | 执行时间随着值的增加而增加,而且每个值至少要读取 次。例 子:插入排序 |
常量值或O(1) ,指字面量和保存在变量中的值,表示读取常量值所需的时间不会因值的多少而变化。读取常量值是效率极高的操作,因此非常快。来看下面的例子:
let value = 5
let sum = 10 + value
console.log(sum)
以上代码查询了4次常量值:数值5、变量value 、数值10和变量sum 。整体代码的复杂度可以认为是O(1)。
在JavaScript中访问数组元素也是O(1)操作,与简单的变量查找一样。因此,下面的代码与前面的例子效率一样:
let values = [5, 10]
let sum = values[0] + values[1]
console.log(sum)
使用变量和数组相比访问对象属性效率更高,访问对象属性的算法复杂度是O(n)。访问对象的每个属性都比访问变量或数组花费的时间长,因为查找属性名要搜索原型链。简单来说,查找的属性越多,执行时间就越长。来看下面的例子:
let values = { first: 5, second: 10 }
let sum = values.first + values.second
console.log(sum)
这个例子使用两次属性查找来计算sum 的值。一两次属性查找可能不会有明显的性能问题,但几百上千次则绝对会拖慢执行速度。
特别要注意避免通过多次查找获取一个值。例如,看下面的例子:
let query = window.location.href.substring(window.location.href.indexOf('?'))
这里有6次属性查找:
- 3次是为查找
window.location.href.substring()
- 3次是为查找
window.location.href.indexOf()
通过数代码中出现的点号数量,就可以知道有几次属性查找。以上代码效率特别低,这是因为使用了两次 window.location.href
,即同样的查找执行了两遍。
只要使用某个object 属性超过一次,就应该将其保存在局部变量中。第一次仍然要用O(n)的复杂度去访问这个属性,但后续每次访问就都是O(1),这样就是质的提升了。例如,前面的代码可以重写为如下:
let url = window.location.href
let query = url.substring(url.indexOf('?'))
这个版本的代码只有4次属性查找,比之前节省了约33%。在大型脚本中如果能这样优化,可能就会明显改进性能。
通常,只要能够降低算法复杂度,就应该尽量通过在局部变量中保存值来替代属性查找。另外,如果实现某个需求既可以使用数组的数值索引,又可以使用命名属性(比如NodeList 对象),那就都应该使用数值索引。
2.2.2 优化循环
循环是编程中常用的语法构造,因此在JavaScript中也十分常见。优化这些循环是性能优化的重要内容,因为循环会重复多次运行相同的代码,所以运行时间会自动增加。其他语言有很多关于优化循环的研究,这些技术同样适用于JavaScript。优化循环的基本步骤如下。
简化终止条件 。因为每次循环都会计算终止条件,所以它应该尽可能地快。这意味着要避免属性查找或其他操作。
简化循环体 。循环体是最花时间的部分,因此要尽可能优化。要确保其中不包含可以轻松转移到循环外部的密集计算。
使用后测试循环 。最常见的循环就是 for 和 while 循环,这两种循环都属于先测试循环。do-while 就是后测试循环,避免了对终止条件初始评估 ,因此应该会更快。
注意:在旧版浏览器中,从循环迭代器的最大值开始递减至0的效率更高。之所以这样更快,是因为JavaScript引擎用于检查循环分支条件的指令数更少。在现代浏览器中,正序还是倒序不会有可感知的性能差异。因此可以选择最适合代码逻辑的迭代方式。
以上优化的效果可以通过下面的例子展示出来。这是一个简单的for 循环:
for (let i = 0; i < values.length; i++) {
process(values[i])
}
这个循环会将变量 i 从 0 递增至数组values 的长度。假设处理这些值的顺序不重要,那么可以将循环变量改为递减的形式,如下所示:
for (let i = values.length - 1; i >= 0; i--) {
process(values[i])
}
这一次,变量 i 每次循环都会递减。在这个过程中,终止条件的计算复杂度也从查找 values.length
的变成了访问 0 的。循环体只有一条语句,已不能再优化了。不过,整个循环可修改为后测试循环:
let i = values.length - 1
if (i > -1) {
do {
process(values[i])
} while (--i >= 0)
}
这里主要的优化是将终止条件和递减操作符合并成了一条语句。然后,如果再想优化就只能去优化 process()
的代码,因为循环已没有可以优化的点了。
使用后测试循环时要注意,一定是至少有一个值需要处理一次。如果 这里的数组是空的,那么会浪费一次循环,而先测试循环就可以避免这种情况。
2.2.3 展开循环
如果循环的次数是有限的,那么通常抛弃循环而直接多次调用函数会更快。仍以前面的循环为例,如果数组长度始终一样,则可能对每个元素都调用一次 process()
效率更高:
// 抛弃循环
process(values[0])
process(values[1])
process(values[2])
这个例子假设 values 数组始终只有3个值,然后分别针对每个元素调用一次process()
。像这样展开循环可以节省创建循环、计算终止条件的消耗,从而让代码运行更快。
如果不能提前预知循环的次数,那么或许可以使用一种叫作达夫设备 (Duff’s Device)的技术。该技术是以其发明者Tom Duff命名的,他最早建议在C语言中使用该技术。在JavaScript实现达夫设备的人是 Jeff Greenberg。达夫设备的基本思路是以 8 的倍数作为迭代次数从而将循环展开为一系列语句。来看下面的例子:
// 来源:Jeff Greenberg在 JavaScript 中实现的达夫设备
// 假设 values.length > 0
let iterations = Math.ceil(values.length / 8)
let startAt = values.length % 8
let i = 0
do {
switch (startAt) {
case 0:
process(values[i++])
case 7:
process(values[i++])
case 6:
process(values[i++])
case 5:
process(values[i++])
case 4:
process(values[i++])
case 3:
process(values[i++])
case 2:
process(values[i++])
case 1:
process(values[i++])
}
startAt = 0
} while (--iterations > 0)
这个达夫设备的实现首先通过用 values
数组的长度除以 8 计算需要多少次循环。Math.ceil()
用于保证这个值是整数。startAt
变量保存着仅按照除以 8 来循环不会处理的元素个数。第一次循环执行时,会检查 startAt
变量,以确定要调用 process()
多少次。例如,假设数组有 10 个元素,则 startAt
变量等于 2,因此第一次循环只会调用 process()
两次。第一次循环末尾,startAt
被重置为 0。于是后续每次循环都会调用 8 次 process()
。这样展开之后,能够加快大数据集的处理速度。
Andrew B. King 在 Speed Up Your Site 一书中提出了更快的达夫设备实现,他将 do-while 循环分成了两个单独的循环,如下所示:
// 来源:Speed Up Your Site(New Riders,2003)
let iterations = Math.floor(values.length / 8)
let leftover = values.length % 8
let 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)
在这个实现中,变量 leftover
保存着只按照除以 8 来循环不会处理, 因而会在第一个循环中处理的次数。处理完这些额外的值之后进入主循环,每次循环调用 8 次 process()
。这个实现比原始的实现快约 40%。
展开循环对于大型数据集可以节省很多时间,但对于小型数据集来说,则可能不值得。因为实现同样的任务需要多写很多代码,所以如果处理的数据量不大,那么显然没有必要。
2.2.4 避免重复解释
重复解释的问题存在于 JavaScript 代码尝试解释 JavaScript 代码的情形。在使用 eval()
函数或 Function
构造函数,或者给 setTimeout()
传入字符串参数时会出现这种情况。下面是几个例子:
// 对代码求值:不要
eval("console.log('Hello world!')")
// 创建新函数:不要
let sayHi = new Function("console.log('Hello world!')")
// 设置超时函数:不要
setTimeout("console.log('Hello world!')", 500)
在上面所列的每种情况下,都需要重复解释包含JavaScript代码的字符串。这些字符串在初始解析阶段不会被解释,因为代码包含在字符串里。这意味着在JavaScript运行时,必须启动新解析器实例来解析这些字符串中的代码。实例化新解析器比较费时间,因此这样会比直接包含原生代码慢。
这些情况都有对应的解决方案。很少有情况绝对需要使用 eval()
, 因此应该尽可能不使用它。此时,只要把代码直接写出来就好了。对于 Function
构造函数,重写为常规函数也很容易。而调用 setTimeout()
时则可以直接把函数作为第一个参数。比如:
// 直接写出来
console.log('Hello world!')
// 创建新函数:直接写出来
let sayHi = function() {
console.log('Hello world!')
}
// 设置超时函数:直接写出来
setTimeout(function() {
console.log('Hello world!')
}, 500)
为了提升代码性能,应该尽量避免使用要当作JavaScript代码解释的字符串。
2.2.5 其他性能优化注意事项
在评估代码性能时还有一些地方需要注意。下面列出的虽然不是主要问题,但在使用比较频繁的时候也可能有所不同。
原生方法很快 。应该尽可能使用原生方法,而不是使用 JavaScript 写的方法。原生方法是使用C或C++等编译型语言写的,因此比JavaScript写的方法要快得多。JavaScript 中经常被忽视的是
Math
对象上那些执行复杂数学运算的方法。这些方法总是比执行相同任务的JavaScript函数快得多,比如求正弦、余 弦等。switch 语句很快 。如果代码中有复杂的 if-else 语句,将其转换成switch 语句可以变得更快。然后,通过重新组织分支,把最可能的放前面,不太可能的放后面,可以进一步提升性能。
位操作很快 。在执行数学运算操作时,位操作一定比任何布尔值或数值计算更快。选择性地将某些数学操作替换成位操作,可以极大提升复杂计算的效率。像求模、逻辑AND与和逻辑OR或都很适合替代成位操作。
2.3 语句最少化
JavaScript代码中语句的数量影响操作执行的速度。一条可以执行多个操作的语句,比多条语句中每个语句执行一个操作要快。那么优化的目标就是寻找可以合并的语句,以减少整个脚本的执行时间。为此,可以参考如下几种模式。
2.3.1 多个变量声明
// 有四条语句:浪费
let count = 5
let color = 'blue'
let values = [1, 2, 3]
let now = new Date()
在强类型语言中,不同数据类型的变量必须在不同的语句中声明。但在JavaScript中,所有变量都可以使用一个 let 语句声明。前面的代码可以改写为如下:
// 一条语句更好
let count = 5,
color = 'blue',
values = [1, 2, 3],
now = new Date()
这里使用一个 let 声明了所有变量,变量之间以逗号分隔。这种优化很容易做到,且比使用多条语句执行速度更快。
2.3.2 插入迭代性值
任何时候只要使用迭代性值(即会递增或递减的值),都要尽可能使用组合语句。来看下面的代码片段:
let name = values[i]
i++
前面代码中的两条语句都只有一个作用:
- 第一条从 values 中取得一个值并保存到 name 中
- 第二条递增变量 i
把迭代性的值插入第一条语句就可以将它们合并为一条语句:
let name = values[i++]
这一条语句完成了前面两条语句完成的事情。因为递增操作符是后缀形式的,所以 i 在语句其他部分执行完成之前是不会递增的。只要遇到类似的情况,就要尽量把迭代性值插入到上一条使用它的语句中。
2.3.3 使用数组和对象字面量
两种使用数组和对象的方式:构造函数和字面量。使用构造函数始终会产生比单纯插入元素或定义属性更多的语句,而字面量只需一条语句即可完成全部操作。来看下面的例子:
// 创建和初始化数组用了四条语句:浪费
let values = new Array()
values[0] = 123
values[1] = 456
values[2] = 789
// 创建和初始化对象用了四条语句:浪费
let person = new Object()
person.name = 'Nicholas'
person.age = 29
person.sayName = function() {
console.log(this.name)
}
在这个例子中,分别创建和初始化了一个数组和一个对象。两件事都 用了四条语句:一条调用构造函数,三条添加数据。这些语句很容易转换成字面量形式:
// 一条语句创建并初始化数组
let values = [123, 456, 789]
// 一条语句创建并初始化对象
let person = {
name: 'Nicholas',
age: 29,
sayName() {
console.log(this.name)
}
}
重写后的代码只有两条语句:一条创建并初始化数组,另一条创建并初始化对象。相对于前面使用了8条语句,这里使用两条语句,减少了 75% 的语句量。对于数千行的JavaScript代码,这样的优化效果可能更明显。
应尽可能使用数组或对象字面量,以消除不必要的语句。
注意:减少代码中的语句量是很不错的目标,但不是绝对的法则。一味追求语句最少化,可能导致一条语句容纳过多逻辑,最终难以理解。
2.4 优化DOM交互
在所有JavaScript代码中,涉及DOM的部分无疑是非常慢的。DOM操作和交互需要占用大量时间,因为经常需要重新渲染整个或部分页面。此外,看起来简单的操作也可能花费很长时间,因为DOM中携带着大量信息。理解如何优化DOM交互可以极大地提升脚本的执行速度。
2.4.1 实时更新最小化
访问DOM时,只要访问的部分是显示页面的一部分,就是在执行实时更新操作。之所以称其为实时更新,是因为涉及立即(实时)更新页面 的显示,让用户看到。每次这样的更新,无论是插入一个字符还是删除页面上的一节内容,都会导致性能损失。这是因为浏览器需要为此重新计算数千项指标,之后才能执行更新。实时更新的次数越多,执行代码所需的时间也越长。反之,实时更新的次数越少,代码执行就越快。来看下面的例子:
let list = document.getElementById('myList'),
item
for (let i = 0; i < 10; i++) {
item = document.createElement('li')
list.appendChild(item)
item.appendChild(document.createTextNode('Item ${i}'))
}
以上代码向列表中添加了10项。每添加1项,就会有两次实时更新:一次添加<li>
元素,一次为它添加文本节点。因为要添加10项,所以整个操作总共要执行20次实时更新。
为解决这里的性能问题,需要减少实时更新的次数。有两个办法可以实现这一点。
第一个办法是从页面中移除列表,执行更新,然后再把 列表插回页面中相同的位置。这个办法并不可取,因为每次更新时页面都会闪烁。
第二个办法是使用文档片段构建 DOM 结构,然后一次性将 它添加到 list 元素。这个办法可以减少实时更新,也可以避免页面闪烁。比如:
let list = document.getElementById('myList'),
fragment = document.createDocumentFragment(),
item
for (let i = 0; i < 10; i++) {
item = document.createElement('li')
fragment.appendChild(item)
item.appendChild(document.createTextNode('Item ' + i))
}
list.appendChild(fragment)
这样修改之后,完成同样的操作只会触发一次实时更新。这是因为更新是在添加完所有列表项之后一次性完成的。文档片段在这里作为新创建项目的临时占位符。最后,使用 appendChild()
将所有项目都添加到列表中。别忘了,在把文档片段传给 appendChild()
时,会把片段的所有子元素添加到父元素,片段本身不会被添加。
只要是必须更新DOM,就尽量考虑使用文档片段来预先构建DOM结构,然后再把构建好的DOM结构实时更新到文档中。
2.4.2 使用innerHTML
在页面中创建新DOM节点的方式有两种:使用DOM方法如 createElement()
和 appendChild()
,以及使用 innerHTML
。 对于少量DOM更新,这两种技术区别不大,但对于大量DOM更新,使用 innerHTML 要比使用标准DOM方法创建同样的结构快很多。
在给 innerHTML 赋值时,后台会创建 HTML 解析器,然后会使用原生 DOM 调用而不是 JavaScript 的 DOM 方法来创建 DOM 结构。原生DOM方法速度更快,因为该方法是执行编译代码而非解释代码。前面的例子如果使用 innerHTML 重写就是这样的:
let list = document.getElementById('myList'),
html = ''
for (let i = 0; i < 10; i++) {
html += '<li>Item ${i}</li>'
}
list.innerHTML = html
以上代码构造了一个HTML字符串,然后将它赋值给 list.innerHTML
,结果也会创建适当的DOM结构。虽然拼接字符串也会有一些性能损耗,但这个技术仍然比执行多次DOM操作速度更快。
与其他DOM操作一样,使用 innerHTML 的关键在于最小化调用次数。 例如,下面的代码使用innerHTML 的次数就太多了:
let list = document.getElementById('myList')
for (let i = 0; i < 10; i++) {
list.innerHTML += '<li>Item ${i}</li>' // 不要
}
这里的问题是每次循环都会调用 innerHTML ,因此效率极低。事实上,调用 innerHTML 也应该看成是一次实时更新。构建好字符串然后调用一次 innerHTML 比多次调用 innerHTML 快得多。
注意:使用 innerHTML 可以提升性能,但也会暴露巨大的 XSS 攻击面。无论何时使用它填充不受控的数据,都有可能被攻击者注入可执行代码。此时必须要当心。
2.4.3 使用事件委托
大多数 Web 应用程序会大量使用事件处理程序实现用户交互。一个页面中事件处理程序的数量与页面响应用户交互的速度有直接关系。为了减少对页面响应的影响,应该尽可能使用事件委托。
事件委托利用了事件的冒泡。任何冒泡的事件都可以不在事件目标上,而在目标的任何祖先元素上处理。基于这个认知,可以把事件处理程序添加到负责处理多个目标的高层元素上。只要可能,就应该在文档级添加事件处理程序,因为在文档级可以处理整个页面的事件。
2.4.4 注意HTMLCollection
由于Web应用程序存在很大的性能问题,HTMLCollection
对象是有缺点。任何时候,只要访问HTMLCollection ,无论是它的属性还是方法,就会触发查询文档,而这个查询相当耗时。减少访问HTMLCollection 的次数可以极大地提升脚本的性能。
可能优化 HTMLCollection 访问最关键地方就是循环了。之前,我们讨论过要把计算 HTMLCollection 长度的代码转移到 for 循环初始化的部分。来看下面的例子:
let images = document.getElementsByTagName('img')
for (let i = 0, len = images.length; i < len; i++) {
// 处理
}
这里的关键是把 length 保存到了 len 变量中,而不是每次都读一次 HTMLCollection 的 length 属性。在循环中使用 HTMLCollection 时,应该首先取得对要使用的元素的引用,如下面所示。这样才能避免在循环体内多次调用 HTMLCollection :
let images = document.getElementsByTagName('img'),
image
for (let i = 0, len = images.length; i < len; i++) {
image = images[i]
// 处理
}
这段代码增加了image 变量,用于保存当前的图片。有了这个局部变 量,就不需要在循环中再访问images HTMLCollection 了。
编写JavaScript代码时,关键是要记住,只要返回HTMLCollection 对象,就应该尽量不访问它。以下情形会返回HTMLCollection :
- 调用 getElementsByTagName() ;
- 读取元素的 childNodes 属性;
- 读取元素的 attributes 属性;
- 访问特殊集合,如 document.form 、document.images 等。
理解什么时候会碰到 HTMLCollection 对象并适当地使用它,有助于明显地提升代码执行速度。
3. 部署
任何JavaScript解决方案最重要的部分可能就是把网站或Web应用程序部署到线上环境了。在此之前我们已完成了很多工作,包括架构方面和优化方面的。现在到了把代码移出开发环境,发布到网上,让用户去使用它的时候了。不过,在发布之前还需要解决一些问题。
3.1 构建流程
准备发布JavaScript代码时最重要一环是准备构建流程。开发软件的典型模式是编码、编译和测试。换句话说,首先要写代码,然后编译,之后运行并确保它能够正常工作。
但因为JavaScript不是编译型语言,所以这个流程经常会变成编码、测试。你写的代码跟在浏览器中测试的代码一样。这种方式的问题在于代码并不是最优的。你写的代码不应该不做任何处理就直接交给浏览器,原因如下。
- 知识产权问题 :如果把满是注释的代码放到网上,其他人就很容易了解你在做什么,重用它,并可能发现安全漏洞。
- 文件大小 :你写的代码可读性很好,容易维护,但性能不好。浏览器不会因为代码中多余的空格、缩进、冗余的函数和变量名而受益。
- 代码组织 :为保证可维护性而组织的代码不一定适合直接交付给浏览器。
为此,需要为JavaScript文件建立构建流程。
3.1.1 文件结构
构建流程首先定义在源代码控制中存储文件的逻辑结构。最好不要在一个文件中包含所有JavaScript代码。相反,要遵循面向对象编程语言的典型模式,把对象和自定义类型保存到自己独立的文件中。这样可以让每个文件只包含最小量的代码,让后期修改更方便,也不易引 入错误。
此外,在使用并发源代码控制系统(如Git、CVS或 Subversion)的环境中,这样可以减少合并时发生冲突的风险。
注意,把代码分散到多个文件是从可维护性而不是部署角度出发的。对于部署,应该把所有源文件合并为一个或多个汇总文件。Web应用程序使用的JavaScript文件越少越好,因为HTTP请求对某些Web应用程序而言是主要的性能瓶颈。而且,使用 <script>
标签包含 JavaScript 是阻塞性操作,这导致代码下载和执行期间停止所有其他下载任务。 因此,要尽量以符合逻辑的方式把JavaScript代码组织到部署文件中。
3.1.2 任务运行器
如果要把大量文件组合成一个应用程序,很可能需要任务运行器自动完成一些任务。任务运行器可以完成代码检查、打包、转译、启动本地服务器、部署,以及其他可以脚本化的任务。
很多时候,任务运行器要通过命令行界面来执行操作。因此你的任务运行器可能仅仅是一个辅助组织和排序复杂命令行调用的工具。从这个意义上说,任务运行器在很多方面非常像 .bashrc
文件。其他情况下,要在自动化任务中使用的工具可能是一个兼容的插件。
如果你使用 Node.js 和 npm 打印 JavaScript 资源,Grunt 和 Gulp 是两个主流的任务运行器。它们非常稳健,其任务和指令都是通过配置文件,以纯 JavaScript 形式指定的。使用 Grunt 和 Gulp 的好处是它们分别有各自的插件生态,因此可以直接使用npm包。
3.1.3 摇树优化
摇树优化(tree shaking)是非常常见且极为有效的减少冗余代码的策略。使用静态模块声明风格意味着构建工具可以确定代码各部分之间的依赖关系。更重要的是,摇树优化还能确定代码中的哪些内容是完全不需要的。
实现了摇树优化策略的构建工具能够分析出选择性导入的代码,其余模块文件中的代码可以在最终打包得到的文件中完全省略。假设下面是个示例应用程序:
import { foo } from './utils.js'
console.log(foo)
export const foo = 'foo'
export const bar = 'bar' // unused
这里导出的 bar 就没有被用上,而构建工具可以很容易发现这种情况。在执行摇树优化时,构建工具会将bar 导出完全排除在打包文件之外。静态分析也意味着构建工具可以确定未使用的依赖,同样也会排除掉。通过摇树优化,最终打包得到的文件可以瘦身很多。
3.1.4 模块打包器
以模块形式编写代码,并不意味着必须以模块形式交付代码。通常,由大量模块组成的JavaScript代码在构建时需要打包到一起,然后只交付一个或少数几个JavaScript文件。
模块打包器的工作是识别应用程序中涉及的JavaScript依赖关系,将它们组合成一个大文件,完成对模块的串行组织和拼接,然后生成最终提供给浏览器的输出文件。
能够实现模块打包的工具非常多。Webpack、Rollupt 和 Browserify 只是其中的几个,可以将基于模块的代码转换为普遍兼容的网页脚本。
3.2 验证
即使已出现了能够理解和支持JavaScript的IDE,大多数开发者仍通过在浏览器中运行代码来验证自己的语法。这种方式有很多问题。
首先,如此验证不容易自动化,也不方便从一个系统移植到另一个系统。
其次,除了语法错误,只有运行的代码才可能报错,没有运行到的代码则无法验证。有 一些工具可以帮我们发现JavaScript代码中潜在的问题,最流行的是 Douglas Crockford的JSLint和ESLint。
这些代码检查工具可以发现JavaScript代码中的语法错误和常见的编码错误。下面是它们会报告的一些问题:
- 使用 eval() ;
- 使用未声明的变量;
- 遗漏了分号;
- 不适当地换行;
- 不正确地使用逗号;
- 遗漏了包含语句的括号;
- 遗漏了switch 分支中的 break ;
- 重复声明变量;
- 使用了 with ;
- 错误地使用等号(应该是两个或三个等号);
- 执行不到的代码。
在开发过程中添加代码检查工具有助于避免出错。推荐开发者在构建流程中也加入代码检查环节,以便在潜在问题成为错误之前识别它们。
3.3 压缩
谈到JavaScript文件压缩,实际上主要是两件事:代码大小 (code size)和 传输负载 (wire weight)。
代码大小指的是浏览器需要解析的字节数
而传输负载是服务器实际发送给浏览器的字节数。
在Web开发的早期阶段,这两个数值几乎相等,服务器发送给浏览器的是未经修改的源文件。而今天,这两个数值不可能相等,实际上也不应该相等。
3.3.1 代码压缩
JavaScript不是编译成字节码,而是作为源代码传输的,所以源代码文件通常包含对浏览器的JavaScript解释器没有用的额外信息和格式。JavaScript压缩工具可以把源代码文件中的这些信息删除,并在保证程序逻辑不变的前提下缩小文件大小。
注释、额外的空格、长变量或函数名都能提升开发者的可读性,但对浏览器而言这些都是多余的字节。压缩工具可以通过如下操作减少代码大小:
- 删除空格(包括换行);
- 删除注释;
- 缩短变量名、函数名和其他标识符。
所有JavaScript文件都应该在部署到线上环境前进行压缩。在构建流程中加入这个环节压缩JavaScript文件是很容易的。
3.3.2 JavaScript编译
类似于最小化,JavaScript代码编译通常指的是把源代码转换为一种逻辑相同但字节更少的形式。与最小化的不同之处在于,编译后代码的结构可能不同,但仍然具备与原始代码相同的行为。编译器通过输入全部 JavaScript 代码可以对程序流执行稳健的分析。
编译可能会执行如下操作:
- 删除未使用的代码;
- 将某些代码转换为更简洁的语法;
- 全局函数调用、常量和变量行内化
3.3.3 JavaScript 转译
我们提交到项目仓库中的代码与浏览器中运行的代码不一样。ES6、 ES7和ES8都为ECMAScript规范扩充增加了更好用的特性,但不同浏览器支持这些规范的步调并不一致。
通过JavaScript转译,可以在开发时使用最新的语法特性而不用担心浏览器的兼容性问题。转译可以将现代的代码转换成更早的 ECMAScript版本,通常是ES3或ES5,具体取决于你的需求。这样可以 确保代码能够跨浏览器兼容。
注意:“转译”(transpilation)和“编译”(compilation) 经常被人当成同一个术语混用。编译是将源代码从一种语言转换为另一种语言。转译在本质上跟编译是一样的,只是目标语言与源语言是一种语言的不同级别的抽象。因此,把ES6/ES7/ES8代码 转换为ES3/ES5代码从技术角度看既是编译也是转译,只是转译更为确切一些。
3.3.4 HTTP压缩
传输负载是从服务器发送给浏览器的实际字节数。这个字节数不一定与代码大小相同,因为服务器和浏览器都具有压缩能力。所有当前主流的浏览器(IE/Edge、Firefox、Safari、Chrome和Opera)都支持客户端解压缩收到的资源。服务器则可以根据浏览器通过请求头部 (Accept-Encoding)标明自己支持的格式,选择一种用来压缩 JavaScript文件。
在传输压缩后的文件时,服务器响应的头部会有字段(Content-Encoding)标明使用了哪种压缩格式。浏览器看到这个头部字段后,就会根据这个压缩格式进行解压缩。结果是通过网络传输的字节数明显小于原始代码大小。
例如,使用Apache服务器上的两个模块(mod_gzip 和 mod_deflate )可以减少原始JavaScript文件的约70%。这很大程度上是因为JavaScript的代码是纯文件,所以压缩率非常高。减少通过网络传输的数据量意味着浏览器能更快收到数据。
注意,服务器压缩和浏览器解压缩都需要时间。不过相比于通过传入更少的字节数而节省的时间,整体时间应该是减少的。
注意:大多数Web服务器(包括开源的和商业的)具备HTTP压缩 能力。关于如何正确地配置压缩,请参考相关服务器的文档。
4. 小结
随着JavaScript开发日益成熟,最佳实践不断涌现。曾经的业余爱好如今 也成为了正式的职业。因此,前端开发也需要像其他编程语言一样,注重 可维护性、性能优化和部署。
1. 为保证JavaScript代码的可维护性,可以参考如下编码惯例。
其他语言的编码惯例可以作为添加注释和确定缩进的参考,但 JavaScript作为一门适合松散类型的语言也有自己的一些特殊要求。
由于JavaScript必须与HTML和CSS共存,因此各司其职尤为重要:JavaScript负责定义行为,HTML负责定义内容,而CSS负责定义外观。
- 如果三者职责混淆,则可能导致难以调试的错误和可维护性问题。
2. 随着Web应用程序中JavaScript代码量的激增,性能也越来越重要。因此应该牢记如下这些事项。
执行JavaScript所需的时间直接影响网页性能,其重要性不容忽视。
很多适合C语言的性能优化策略同样也适合JavaScript,包括循环展开和使用switch 语句而不是if 语句。
- 另一个需要重视的方面是DOM交互很费时间,因此应该尽可能限制DOM 操作的数量。
3. 开发Web应用程序的最后一步是上线部署。以下是本文讨论的相关要点。
为辅助部署,应该建立构建流程,将JavaScript文件合并为较少的(最好是只有一个)文件。
构建流程可以实现很多源代码处理任务的自动化。例如,可以运行 JavaScript验证程序,确保没有语法错误和潜在的问题。
- 压缩可以让文件在部署之前变得尽量小。
- 启用HTTP压缩可以让网络传输的JavaScript文件尽可能小,从而提升 页面的整体性能。