最佳实践
web 发展日益更新,JavaScript 也变得极其复杂,本文将从三个方面来介绍 JavaScript 的最佳实践。
可维护性
编写可维护性高的 JavaScript 代码非常重要,因为大多数开发者在实际开发中都不会从零开始自己写代码,这种场景是很少的,通常都是会去维护别人写的代码,让自己的代码变得容易维护,也可以保证其他开发者能够更好的完成开发工作,也能让自己以后得维护变得更轻松。
可维护的代码
那么什么是可维护的代码呢?
我认为有如下几点:
- 容易理解: 简单说来就是你的代码,别人一眼就看出来是做什么的,而不需要征求你的帮助。
- 符合常识: 就是你的代码逻辑性是顺理成章的事情。
- 很好适配: 无论需求或者相关数据如何变动,你的代码也不会完全重写。
- 容易扩展: 你的代码是经过精细的设计,也支持未来扩展核心功能。
- 容易调试: 当你的代码出现 bug 时,可以很轻松的通过代码来定位问题,而不需要相关调试工具。
代码规范
编写可维护的代码的第一步自然是要定制代码规范,因为 JavaScript 太灵活了,它支持任何编程风格,因此我们自然需要有一个好的代码规范。
可读性
编写代码规范的第一步自然是要考虑可读性,首先当然是要考虑代码的可读性,可读性主要包含以下两大方面。
- 缩进: 使用固定个数的空格来当做缩进,而非其他符号,每个人使用相同的缩进也能让人容易看懂代码,代码缩进是保证可读性的重要基础。
- 注释: 写好注释也方便后来维护的开发者更容易理解代码的作用,一般说来,在大型代码块,函数,黑科技代码,以及复杂的算法这四方面的代码都需要添加注释。
变量与函数命名
编写代码规范的第二步则是命名,代码中的变量和函数命名对可读性和维护性至关重要。命名规则通常如下:
- 变量名通常应该是名称,例如: myName。
- 函数名通常是动词,例如: getMyName。
- 变量和函数名都应该符合逻辑,也不用担心长度问题,因为我们可以使用压缩来解决。
- 变量名和函数名应该采用驼峰式大小写形式,例如 getMyName,类名应该是首字母大小,例如: Student,常量应该全部大写或者下划线拼接,例如: DEV 和 DEV_ERROR。
- 使用到的名称也要尽量使用直观的词汇,描述性很强,例如 getMyName 让人一看就知道这个名字表示获取我的名字。
让变量类型透明化
由于 JavaScript 是松散类型的语言,所以很容易让人忘记变量的类型,虽然适当的命名可以在某种程度上解决这种问题,但这还不够,我们有三种方式来标注变量的类型。
变量初始化
在变量初始化的时候就可以标注变量的类型,来看如下几个示例:
let isDisabled = false; // 布尔类型
let totalCount = 100; // 数值类型
let myName = ''; // 字符串
let student = {}; // 对象
let list = []; // 数组
匈牙利表示法
第二种方式就是匈牙利表示法,匈牙利表示法指的是在变量名前面前缀一个或多个字符表示数据类型,对于数据类型,o 表示对象,s 表示字符串,i 表示整数,f 表示浮点数,b 表示布尔值。示例如下:
let oStudent = {
name: 'eveningwater'
};
let sName = 'eveningwater';
let iCount = 1;
let bStatus = true;
匈牙利表示法也可以很好的应用于函数参数,但是这个方式会让代码可读性下降,因此不推荐使用。
类型注释
第三种方式就是使用类型注释,在变量名的后面,初始化表达式的前面,思路就是在变量旁边使用类型注释说明类型。比如:
// 使用类型注释表明数据类型
let bool /*:Boolean*/ = false;
let count /*:int*/ = 10;
let name /*:String*/ = 'eveningwater';
let student /*:Object*/ = {};
类型注释在保持代码可读性的同时还注明了类型信息,缺点就是不能使用多行注释把大型代码块给注释掉,因为类型注释也是多行注释。
以上三种方式可以根据实际情况来选择。
松散耦合
只要应用程序某一个部分对另一个部分过于紧密,代码就会变得紧密耦合,因此难以维护,在 web 开发中紧密耦合主要分为三种情况。
html 与 JavaScript 紧密耦合
无论是在 html 中写 JavaScript 还是在 JavaScript 中写 html,都是一种耦合,如以下的示例代码:
在 html 中写 JavaScript:
<script>
alert('hello,world');
</script>
<button onclick="alert('hello,world')" type="button">点击我</button>
在 JavaScript 中写 html:
function render(obj) {
const container = document.getElementById('container');
container.innerHTML = `<p>${obj.name}</p>`;
}
html 于 JavaScript 耦合的结果就会导致修改需要在 html 和 js 文件中同时修改,而有时候我们也许只需要修改 html 或者 js 代码就可以了,因此要避免两者耦合。
好的做法就是将 js 代码写于外部。
css 与 JavaScript 的耦合
同样的 css 与 JavaScript 代码也会有耦合的情况,如以下示例代码:
element.style.width = '100px';
element.style.height = '100px';
虽然 css 与 js 不太可能做到完全解耦,但我们应该采用修改动态类名的方式来修改样式,如:
element.className = 'el-size';
这样一来,我们要修改 css 样式就只需要修改样式文件即可,或者是只修改 js 中的 css 类名即可。
应用程序与事件逻辑程序的耦合
当我们在编写 web 应用程序的时候难免会有大量的事件逻辑,可是,我们却很少将事件逻辑和应用程序逻辑区分开来,来看如下一个示例:
const handleKeyPress = e => {
if (e.keyCode === 13) {
const target = e.target;
const v = parseInt(target.value) * 15;
if (v > 20) {
document.getElementById('errorMsg').textContent = '输入值不能大于20';
}
}
};
这个事件处理程序除了处理事件,还包含了应用程序逻辑。这样做的问题是双重的。首先,除了事件是没有办法触发应用程序逻辑,结果造成调试困难。其次,如果没有产生预期的结果怎么办?是因为没有调用事件处理程序,还是因为应用程序逻辑有错误?再者,如果后续事件也会有对应相同的应用程序逻辑,则会导致代码重复,或者把它提取到单独的函数中。无论情况如何,都会导致原本不必要的多余工作。
更好的做法是将应用程序逻辑与事件处理程序分开,各自负责处理各自的事情。事件处理程序应该专注于 event 对象的相关信息,然后把这些信息传给处理应用程序逻辑的某些方法。例如,前面的例子可以重写为如下代码:
const validateValue = v => {
v = parseInt(target.value) * 15;
if (v > 20) {
document.getElementById('errorMsg').textContent = '输入值不能大于20';
}
};
const handleKeyPress = e => {
if (e.keyCode === 13) {
const target = e.target;
validateValue(target.value);
}
};
这样修改之后,应用程序逻辑跟事件处理程序就分开了。handleKeyPress()函数只负责检查用户是不是按下了回车键(e.keyCode 等于 13),如果是则取得事件目标,并把目标值传给 validateValue()函数,该函数包含应用程序逻辑。注意,validateValue()函数中不包含任何依赖事件处理程序的代码。这个函数只负责接收一个值,并根据该值执行其他所有操作。
把应用程序逻辑从事件处理程序中分离出来有很多好处。首先,这可以让我们以最少的工作量轻松地修改触发某些流程的事件。如果原来是通过鼠标单击触发流程,而现在又想增加键盘操作来触发,那么修改起来也很简单。其次,可以在不用添加事件的情况下测试代码,这样创建单元测试或自动化应用程序流都会更简单。
在解耦应用程序逻辑和业务逻辑时,我们也需要注意以下几点。
- 不要把 event 对象传递给其他方法,只需要传递 event 对象相关数据即可。
- 应用程序每个可执行的操作都无需事件处理程序就可以执行。
- 事件处理程序应只需要处理事件即可,后续处理则交给应用程序逻辑。
编码惯例
编写可维护的 JavaScript 代码不仅仅涉及代码格式和规范,也涉及代码做什么。开发 Web 应用程序通常需要很多人协同工作。这时候就需要保证每个人的浏览器环境都有恒定不变的规则。为此,开发者应该遵守某些编码惯例。
尊重对象所有权
在企业开发当中,尊重对象所有权是非常重要的编码惯例,这意味着不要修改不属于你的对象,简单来说,就是如果你不负责创建和维护某个对象及其构造函数或方法,就不应该对其进行任何修改,更具体如下:
- 不要给实例或原型添加属性。
- 不要给实例或原型添加方法。
- 不要重定义已有的方法。
不声明全局变量
与尊重对象所有权密切相关的是尽可能不声明全局变量和函数。同样,这也关系到创建一致和可维护的脚本运行环境。最多可以创建一个全局变量,作为其他对象和函数的命名空间。来看下面的例子:
// 两个全局变量:不要!
var name = 'eveningwater';
function sayName() {
console.log(name);
}
以上代码声明了两个全局变量:name 和 sayName()。可以像下面这样把它们包含在一个对象中:
// 一个全局变量:推荐
var myObject = {
name: 'eveningwater',
sayName: function () {
console.log(this.name);
}
};
这个重写后的版本只声明了一个全局对象 myObject。该对象包含了 name 和 sayName()。这样可以避免之前版本的几个问题,首先,变量 name 会覆盖 window.name 属性,而这可能会影响其他功能。其次,有助于分清功能都集中在哪里,调用 myObject.sayName()从逻辑上会暗示,出现任何问题都可以在 myObject 的代码中找原因。
这样一个全局对象也会涉及到一个命名空间的概念,命名空间涉及创建一个对象,然后通过这个对象来暴露能力。
不要比较 null
JavaScript 不会自动做任何类型检查,因此就需要开发者主动担起这个责任。结果,很多 JavaScript 代码不会做类型检查。最常见的类型检查是看值是不是 null。然而,与 null 进行比较的代码太多了,其中很多因为类型检查不够而频繁引发错误。比如下面的例子:
function sortArr(values) {
if (values != null) {
// 不要这样比较!
values.sort(comparator);
}
}
这个函数的目的是使用给定的比较函数对数组进行排序。为保证函数正常执行,values 参数必须是数组。但是,if 语句在这里只简单地检查了这个值不是 null。实际上,字符串、数值还有其他很多值可以通过这里的检查,结果就会导致错误。
现实当中,单纯比较 null 通常是不够的。检查值的类型就要真的检查类型,而不是检查它不能是什么。例如,在前面的代码中,values 参数应该是数组。为此,应该检查它到底是不是数组,而不是检查它不是 null。可以像下面这样重写那个函数:
function sortArr(values) {
if (values instanceof Array) {
// 推荐
values.sort(comparator);
}
}
此函数的这个版本可以过滤所有无效的值,根本不需要使用 null。
如果看到比较 null 的代码,可以使用下列某种技术替换它。
- 如果值应该是引用类型,则使用 instanceof 操作符检查其构造函数。
- 如果值应该是原始类型,则使用 typeof 检查其类型。
- 如果希望值是有特定方法名的对象,则使用 typeof 操作符确保对象上存在给定名字的方法。
代码中比较 null 的地方越少,就越容易明确类型检查的目的,从而消除不必要的错误。
使用常量
依赖常量的目标是从应用程序逻辑中分离数据,以便修改数据时不会引发错误。显示在用户界面上的字符串就应该以这种方式提取出来,可以方便实现国际化。URL 也应该这样提取出来,因为随着应用程序越来越复杂,URL 极有可能变化。基本上,像这种地方将来因为某种原因而需要修改时,可能就要找到某个函数并修改其中的代码。每次像这样修改应用程序逻辑,都可能引入新错误。为此,可以把这些可能会修改的数据提取出来,放在单独定义的常量中,以实现数据与逻辑分离。
关键在于把数据从使用它们的逻辑中分离出来,可以使用以下标准检查哪些数据需要提取。
- 重复出现的值:任何使用超过一次的值都应该提取到常量中,这样可以消除一个值改了而另一个值没改造成的错误。这里也包括 CSS 的类名。
- 用户界面字符串:任何会显示给用户的字符串都应该提取出来,以方便实现国际化。
- URL:Web 应用程序中资源的地址经常会发生变化,因此建议把所有 URL 集中放在一个地方管理。
- 任何可能变化的值:任何时候,只要在代码中使用字面值,就问问自己这个值将来是否可能会变。如果答案是“是”,那么就应该把它提取到常量中。
使用常量是企业级 JavaScript 开发的重要技术,因为它可以让代码更容易维护,同时可以让代码免受数据变化的影响。
性能
要想避免写出运行慢的 JavaScript 代码,就需要注意以下几点。
作用域意识
在 JavaScript 中存在作用域的概念,正因为有作用域的存在,所以我们应该缩短访问作用域时间,这样也有助于我们提升代码的性能。具体如下:
避免全局查找
首先当然是需要避免全局查询,全局变量和全局函数相对于局部值始终是最浪费时间的,因为要经历作用域链的查找,来看如下一个示例:
const updateImg = () => {
const imgList = document.querySelectorAll('img');
for (let i = 0, len = imgList.length; i < len; i++) {
imgList[i].title = `${document.title} image ${i}`;
}
const msg = document.querySelector('.msg');
msg.innerHTML = 'update template';
};
这个函数看起来似乎没什么问题,但实际上这里引用了三个全局 document 对象,如果页面的图片非常多,那么 for 循环中就需要引用 document 几十甚至上百次,每次都要遍历一次作用域链。通过在局部作用域中保存 document 对象的引用,能够明显提升这个函数的性能,因为只需要作用域链查找。通过创建一个指向 document 对象的局部变量,可以通过将全局查找的数量限制为一个来提高这个函数的性能:
const updateImg = () => {
const doc = document;
const imgList = doc.querySelectorAll('img');
for (let i = 0, len = imgList.length; i < len; i++) {
imgList[i].title = `${doc.title} image ${i}`;
}
const msg = doc.querySelector('.msg');
msg.innerHTML = 'update template';
};
这里先把 document 对象保存在局部变量 doc 中。然后用 doc 替代了代码中所有的 document。这样调用这个函数只会查找一次作用域链,相对上一个版本的代码,肯定会快很多,因此,一个经验规则就是,只要函数中有引用超过两次的全局对象,就应该把这个对象保存为一个局部变量。
避免使用 with 语句
在性能很重要的代码中,应避免使用 with 语句。与函数类似,with 语句会创建自己的作用域,因此也会加长其中代码的作用域链。在 with 语句中执行的代码一定比在它外部执行的代码慢,因为作用域链查找时多一步。
实际编码时很少有需要使用 with 语句的情况,因为它的主要用途是节省一点代码。大多数情况下,使用局部变量可以实现同样的效果,无须增加新作用域。下面看一个例子:
const updateBody = () => {
with (document.body) {
console.log(tagName);
innerHTML = 'Hello world!';
}
};
这段代码中的 with 语句让使用 document.body 更简单了。使用局部变量也可以实现同样的效果,如下:
const updateBody = () => {
let body = document.body;
console.log(body.tagName);
body.innerHTML = 'Hello world!';
};
虽然这段代码多了几个字符,但比使用 with 语句还更容易理解了,因为 tagName 和 innerHTML 属于谁很明确,这段代码还通过把 document.body 保存在局部变量中来省去全局查找。
使用正确的方法
与其他语言一样,影响性能的因素通常涉及算法或解决问题的方法。如下有几点需要注意:
避免不必要的属性查找
在计算机科学中,算法复杂度使用大 O 表示法来表示。最简单同时也最快的算法可以表示为常量值或 O(1)。因此我们需要降低算法复杂度,在查找或者获取一个值时,我们尽量减少获取次数,此时的算法复杂度也在 O(n)或者 O(1),这样的代码性能最佳,特别要注意避免通过多次查找获取一个值。例如,看下面的例子:
const 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),这样就是质的提升了。例如,前面的代码可以重写为如下:
const url = window.location.href;
const query = url.substring(url.indexOf('?'));
这个版本的代码只有 4 次属性查找,比之前节省了约 33%。在大型脚本中如果能这样优化,可能就会明显改进性能,通常,只要能够降低算法复杂度,就应该尽量通过在局部变量中保存值来替代属性查找。另外,如果实现某个需求既可以使用数组的数值索引,又可以使用命名属性(比如 NodeList 对象),那就都应该使用数值索引。
循环优化
由于循环会重复运行,因此会增加很多时间,所以优化循环则成了提升性能的重要内容,通常优化循环有如下步骤:
- 优化终止条件:因为每次循环都会计算终止条件,所以它应该尽可能地快,这意味着要避免属性查找或其他 O(n)操作。
- 优化循环体: 循环体是最花时间的部分,因此要尽可能优化,要确保其中不包含可以轻松转移到循环外部的密集计算。
- 可以使用后测试循环:最常见的循环就是 for 和 while 循环,这两种循环都属于先测试循环,do-while 就是后测试循环,避免了对终止条件初始评估 ,因此应该会更快。
比如以下一个示例:
for (let i = 0; i < values.length; i++) {
run(values[i]);
}
首先可以使用递减循环来优化这个循环,如下所示:
for (let i = values.length - 1; i >= 0; i--) {
run(values[i]);
}
这一次,变量 i 每次循环都会递减,在这个过程中,终止条件的计算复杂度也从查找 values.length 的 O(n)变成了访问 0 的 O(1)。
虽然循环语句已经无法再优化,但是可以将整个循环修改为后测试循环,如下:
let i = values.length - 1;
if (i > -1) {
do {
run(values[i]);
} while (--i >= 0);
}
使用后测试循环时要注意,一定是至少有一个值需要处理一次。如果这里的 values 数组是空的,那么会浪费一次循环,而先测试循环就可以避免这种情况。
展开循环
如果循环的次数是有限的,那么通常抛弃循环而直接多次调用函数会更快,例如如果数组长度只有 3,如前面示例,这可以写成如下代码:
run(values[0]);
run(values[1]);
run(values[2]);
以上这个示例是假设数组长度只有 3,分别对每个元素执行了一次 run 方法。像这样展开循环可以节省创建循环,计算终止条件的消耗,从而让代码运行的更快。如果不能提前预知循环的次数,那么可以使用一种叫做达夫设备的技术。它的基本思路就是以 8 的倍数作为迭代次数从而将循环展开为一系列的语句,示例代码如下所示:
// 假设values的长度大于0
let i = Math.ceil(values.length / 8),
start = values.length % 8,
j = 0;
do {
switch (start) {
case 0:
run(values[i++]);
case 7:
run(values[i++]);
case 6:
run(values[i++]);
case 5:
run(values[i++]);
case 4:
run(values[i++]);
case 3:
run(values[i++]);
case 2:
run(values[i++]);
case 1:
run(values[i++]);
}
start = 0;
} while (--i > 0);
这个达夫设备的实现首先通过用 values 数组的长度除以 8 计算需要多少次循环。Math.ceil()用于保证这个值是整数。start 变量保存着仅按照除以 8 来循环不会处理的元素个数。第一次循环执行时,会检查 start 变量,以确定要调用 run()多少次。例如,假设数组有 10 个元素,则 start 变量等于 2,因此第一次循环只会调用 run()两次。第一次循环末尾,start 被重置为 0。于是后续每次循环都会调用 8 次 run()。这样展开之后,能够加快大数据集的处理速度。
此外,还有一种更快的达夫设备技术,示例代码如下所示:
// 来源:Speed Up Your Site(New Riders,2003)
let iterations = Math.floor(values.length / 8);
let j = values.length % 8;
let i = 0;
if (j > 0) {
do {
run(values[i++]);
} while (--j > 0);
}
do {
run(values[i++]);
run(values[i++]);
run(values[i++]);
run(values[i++]);
run(values[i++]);
run(values[i++]);
run(values[i++]);
run(values[i++]);
} while (--iterations > 0);
该技术的实现思路,就是将前者的一个循环展开成两个循环,在这个实现中,变量 j 保存着只按照除以 8 来循环不会处理,因而会在第一个循环中处理的次数。处理完这些额外的值之后进入主循环,每次循环调用 8 次 run()。这个实现比原始的实现快约 40%。
展开循环对于大型数据集可以节省很多时间,但对于小型数据集来说,则可能不值得。因为实现同样的任务需要多写很多代码,所以如果处理的数据量不大,那么显然没有必要。
避免重复解释
重复解释的问题存在于 JavaScript 代码尝试解释 JavaScript 代码的情形。在使用 eval()函数或 Function 构造函数,或者给 setTimeout()传入字符串参数时会出现这种情况。下面是几个例子:
// 对代码求值:不要
eval("console.log('Hello eveningwater!')");
// 创建新函数:不要
let sayHi = new Function("console.log('Hello eveningwater!')");
// 设置超时函数:不要
setTimeout("console.log('Hello eveningwater!')", 500);
在上面所列的每种情况下,都需要重复解释包含 JavaScript 代码的字符串。这些字符串在初始解析阶段不会被解释,因为代码包含在字符串里。这意味着在 JavaScript 运行时,必须启动新解析器实例来解析这些字符串中的代码。实例化新解析器比较费时间,因此这样会比直接包含原生代码慢。
这些情况都有对应的解决方案。很少有情况绝对需要使用 eval(),因此应该尽可能不使用它。此时,只要把代码直接写出来就好了。对于 Function 构造函数,重写为常规函数也很容易。而调用 setTimeout()时则可以直接把函数作为第一个参数。比如:
// 直接写出来
console.log('Hello eveningwater!');
// 创建新函数:直接写出来
let sayHi = function () {
console.log('Hello eveningwater!');
};
// 设置超时函数:直接写出来
setTimeout(function () {
console.log('Hello eveningwater!');
}, 500);
为了提升代码性能,应该尽量避免使用要当作 JavaScript 代码解释的字符串。
其它注意事项
在评估代码性能时还有一些地方需要注意。下面列出的虽然不是主要问题,但在使用比较频繁的时候也可能有所不同。
- 原生方法很快。应该尽可能使用原生方法,而不是使用 JavaScript 写的方法。原生方法是使用 C 或 C++等编译型语言写的,因此比 JavaScript 写的方法要快得多。JavaScript 中经常被忽视的是 Math 对象上那些执行复杂数学运算的方法。这些方法总是比执行相同任务的 JavaScript 函数快得多,比如求正弦、余弦等。
- switch 语句很快。如果代码中有复杂的 if-else 语句,将其转换成 switch 语句可以变得更快。然后,通过重新组织分支,把最可能的放前面,不太可能的放后面,可以进一步提升性能。
- 位操作很快。在执行数学运算操作时,位操作一定比任何布尔值或数值计算更快。选择性地将某些数学操作替换成位操作,可以极大提升复杂计算的效率。像求模、逻辑 AND 与和逻辑 OR 或都很适合替代成位操作。
语句最少化
JavaScript 代码中语句的数量影响操作执行的速度。一条可以执行多个操作的语句,比多条语句中每个语句执行一个操作要快。那么优化的目标就是寻找可以合并的语句,以减少整个脚本的执行时间。为此,可以参考如下几种模式。
多个变量声明
声明多个变量时很容易出现多条语句。比如,下面使用多个 let 声明多个变量的情况很常见:
// 有四条语句:浪费
let count = 7;
let color = 'red';
let values = [1, 2, 3, 4];
let now = new Date();
在强类型语言中,不同数据类型的变量必须在不同的语句中声明。但在 JavaScript 中,所有变量都可以使用一个 let 语句声明。前面的代码可以改写为如下:
// 有四条语句:浪费
let count = 7,
color = 'red',
values = [1, 2, 3, 4],
now = new Date();
这里使用一个 let 声明了所有变量,变量之间以逗号分隔。这种优化很容易做到,且比使用多条语句执行速度更快。
插入迭代性值
任何时候只要使用迭代性值(即会递增或递减的值),都要尽可能使用组合语句。来看下面的代码片段:
let name = values[i];
i++;
前面代码中的两条语句都只有一个作用:第一条从 values 中取得一个值并保存到 name 中,第二条递增变量 i。把迭代性的值插入第一条语句就可以将它们合并为一条语句:
let name = values[i++];
这一条语句完成了前面两条语句完成的事情。因为递增操作符是后缀形式的,所以 i 在语句其他部分执行完成之前是不会递增的。只要遇到类似的情况,就要尽量把迭代性值插入到上一条使用它的语句中。
使用数组和对象字面量
javascript 有两种使用数组和对象的方式:构造函数和字面量。使用构造函数始终会产生比单纯插入元素或定义属性更多的语句,而字面量只需一条语句即可完成全部操作。来看下面的例子:
// 创建和初始化数组用了四条语句:浪费
let values = new Array();
values[0] = 123;
values[1] = 456;
values[2] = 789;
// 创建和初始化对象用了四条语句:浪费
let person = new Object();
person.name = 'eveningwater';
person.age = 28;
person.sayName = function () {
console.log(this.name);
};
在这个例子中,分别创建和初始化了一个数组和一个对象。两件事都用了四条语句:一条调用构造函数,三条添加数据。这些语句很容易转换成字面量形式:
// 一条语句创建并初始化数组
let values = [123, 456, 789];
// 一条语句创建并初始化对象
let person = {
name: 'eveningwater',
age: 28,
sayName() {
console.log(this.name);
}
};
重写后的代码只有两条语句:一条创建并初始化数组,另一条创建并初始化对象。相对于前面使用了 8 条语句,这里使用两条语句,减少了 75% 的语句量。对于数千行的 JavaScript 代码,这样的优化效果可能更明显。
应尽可能使用数组或对象字面量,以消除不必要的语句。
注意: 减少代码中的语句量是很不错的目标,但不是绝对的法则。一味追求语句最少化,可能导致一条语句容纳过多逻辑,最终难以理解。
优化 DOM 交互
在所有 JavaScript 代码中,涉及 DOM 的部分无疑是非常慢的。DOM 操作和交互需要占用大量时间,因为经常需要重新渲染整个或部分页面。此外,看起来简单的操作也可能花费很长时间,因为 DOM 中携带着大量信息。理解如何优化 DOM 交互可以极大地提升脚本的执行速度。
实时更新最小化
访问 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 结构实时更新到文档中。
使用 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 攻击面。无论何时使用它填充不受控的数据,都有可能被攻击者注入可执行代码。此时必须要当心。
使用事件委托
大多数 Web 应用程序会大量使用事件处理程序实现用户交互。一个页面中事件处理程序的数量与页面响应用户交互的速度有直接关系。为了减少对页面响应的影响,应该尽可能使用事件委托。
事件委托利用了事件的冒泡。任何冒泡的事件都可以不在事件目标上,而在目标的任何祖先元素上处理。基于这个认知,可以把事件处理程序添加到负责处理多个目标的高层元素上。只要可能,就应该在文档级添加事件处理程序,因为在文档级可以处理整个页面的事件。如以下一个示例代码:
// 不推荐写法
const buttons = document.getElementsByClassName('.button');
buttons.onclick = () => {
console.log(buttons.type);
};
// 推荐写法
const btnGroup = document.getElementById('.btn-group');
btnGroup.onclick = e => {
if (e.target?.classList.contains('button')) {
console.log(e.target?.type);
}
};
注意 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 对象并适当地使用它,有助于明显地提升代码执行速度。
部署
任何 JavaScript 解决方案最重要的部分可能就是把网站或 Web 应用程序部署到线上环境了。在此之前我们已完成了很多工作,包括架构方面和优化方面的。现在到了把代码移出开发环境,发布到网上,让用户去使用它的时候了。不过,在发布之前,还需要解决一些问题。
构建流程
准备发布 JavaScript 代码时最重要一环是准备构建流程。开发软件的典型模式是编码、编译和测试。换句话说,首先要写代码,然后编译,之后运行并确保它能够正常工作。但因为 JavaScript 不是编译型语言,所以这个流程经常会变成编码、测试。你写的代码跟在浏览器中测试的代码一样。这种方式的问题在于代码并不是最优的。你写的代码不应该不做任何处理就直接交给浏览器,原因如下。
- _知识产权问题_:如果把满是注释的代码放到网上,其他人就很容易了解你在做什么,重用它,并可能发现安全漏洞。
- _文件大小_:你写的代码可读性很好,容易维护,但性能不好。浏览器不会因为代码中多余的空格、缩进、冗余的函数和变量名而受益。
- _代码组织_:为保证可维护性而组织的代码不一定适合直接交付给浏览器。
为此,需要为 JavaScript 文件建立构建流程。
文件结构
构建流程首先定义在源代码控制中存储文件的逻辑结构。最好不要在一个文件中包含所有 JavaScript 代码。相反,要遵循面向对象编程语言的典型模式,把对象和自定义类型保存到自己独立的文件中。这样可以让每个文件只包含最小量的代码,让后期修改更方便,也不易引入错误。此外,在使用并发源代码控制系统(如 Git、CVS 或 Subversion)的环境中,这样可以减少合并时发生冲突的风险。
注意,把代码分散到多个文件是从可维护性而不是部署角度出发的。对于部署,应该把所有源文件合并为一个或多个汇总文件。Web 应用程序使用的 JavaScript 文件越少越好,因为 HTTP 请求对某些 Web 应用程序而言是主要的性能瓶颈。而且,使用<script>
标签包含 JavaScript 是阻塞性操作,这导致代码下载和执行期间停止所有其他下载任务。因此,要尽量以符合逻辑的方式把 JavaScript 代码组织到部署文件中。
任务运行器
如果要把大量文件组合成一个应用程序,很可能需要任务运行器自动完成一些任务。任务运行器可以完成代码检查、打包、转译、启动本地服务器、部署,以及其他可以脚本化的任务。
很多时候,任务运行器要通过命令行界面来执行操作。因此你的任务运行器可能仅仅是一个辅助组织和排序复杂命令行调用的工具。从这个意义上说,任务运行器在很多方面非常像.bashrc 文件。其他情况下,要在自动化任务中使用的工具可能是一个兼容的插件。
如果你使用 Node.js 和 npm 打印 JavaScript 资源,Grunt 和 Gulp 是两个主流的任务运行器。它们非常稳健,其任务和指令都是通过配置文件,以纯 JavaScript 形式指定的。使用 Grunt 和 Gulp 的好处是它们分别有各自的插件生态,因此可以直接使用 npm 包。
摇树优化
摇树优化(tree shaking)是非常常见且极为有效的减少冗余代码的策略,使用静态模块声明风格意味着构建工具可以确定代码各部分之间的依赖关系。更重要的是,摇树优化还能确定代码中的哪些内容是完全不需要的。
实现了摇树优化策略的构建工具能够分析出选择性导入的代码,其余模块文件中的代码可以在最终打包得到的文件中完全省略。假设下面是个示例应用程序:
import { foo } from './utils.js';
console.log(foo);
export const foo = 'foo';
export const bar = 'bar'; // unused
这里导出的 bar 就没有被用上,而构建工具可以很容易发现这种情况。在执行摇树优化时,构建工具会将 bar 导出完全排除在打包文件之外。静态分析也意味着构建工具可以确定未使用的依赖,同样也会排除掉。通过摇树优化,最终打包得到的文件可以瘦身很多。
模块打包器
以模块形式编写代码,并不意味着必须以模块形式交付代码。通常,由大量模块组成的 JavaScript 代码在构建时需要打包到一起,然后只交付一个或少数几个 JavaScript 文件。
模块打包器的工作是识别应用程序中涉及的 JavaScript 依赖关系,将它们组合成一个大文件,完成对模块的串行组织和拼接,然后生成最终提供给浏览器的输出文件。
能够实现模块打包的工具非常多。Webpack、Rollup 和 Browserify 只是其中的几个,可以将基于模块的代码转换为普遍兼容的网页脚本。
验证
即使已出现了能够理解和支持 JavaScript 的 IDE,大多数开发者仍通过在浏览器中运行代码来验证自己的语法。这种方式有很多问题。首先,如此验证不容易自动化,也不方便从一个系统移植到另一个系统。其次,除了语法错误,只有运行的代码才可能报错,没有运行到的代码则无法验证。有一些工具可以帮我们发现 JavaScript 代码中潜在的问题,最流行的是 JSLint 和 ESLint。
这些代码检查工具可以发现 JavaScript 代码中的语法错误和常见的编码错误。下面是它们会报告的一些问题:
- 使用 eval();
- 使用未声明的变量;
- 遗漏了分号;
- 不适当地换行;
- 不正确地使用逗号;
- 遗漏了包含语句的括号;
- 遗漏了 switch 分支中的 break;
- 重复声明变量;
- 使用了 with;
- 错误地使用等号(应该是两个或三个等号);
- 执行不到的代码。
在开发过程中添加代码检查工具有助于避免出错。推荐开发者在构建流程中也加入代码检查环节,以便在潜在问题成为错误之前识别它们。
压缩
谈到 JavaScript 文件压缩,实际上主要是两件事:_代码大小_(code size)和传输负载(wire weight)。代码大小指的是浏览器需要解析的字节数,而传输负载是服务器实际发送给浏览器的字节数。在 Web 开发的早期阶段,这两个数值几乎相等,服务器发送给浏览器的是未经修改的源文件。而今天,这两个数值不可能相等,实际上也不应该相等。
代码压缩
JavaScript 不是编译成字节码,而是作为源代码传输的,所以源代码文件通常包含对浏览器的 JavaScript 解释器没有用的额外信息和格式。JavaScript 压缩工具可以把源代码文件中的这些信息删除,并在保证程序逻辑不变的前提下缩小文件大小。
注释、额外的空格、长变量或函数名都能提升开发者的可读性,但对浏览器而言这些都是多余的字节。压缩工具可以通过如下操作减少代码大小:
- 删除空格(包括换行);
- 删除注释;
- 缩短变量名、函数名和其他标识符。
所有 JavaScript 文件都应该在部署到线上环境前进行压缩。在构建流程中加入这个环节压缩 JavaScript 文件是很容易的。
注意 在 Web 开发的上下文中,“压缩”(compression)经常意味着“最小化”(minification)。虽然这两个术语可以互换使用,但实际上它们的含义并不相同。最小化是指把文件大小减少到比原始大小还要小,但结果文件包含的仍是语法正确的代码。通常,最小化只适合 JavaScript 等解释型语言,编译为二进制的语言自然会被编译器最小化。压缩与最小化的区别在于前者得到的文件不再包含语法正确的代码。压缩后的文件必须通过解压缩才能恢复为代码可读的格式。压缩通常能得到比最小化更小的文件,压缩算法不用考虑保留语法结构,因此自由度更高。
JavaScript 编译
类似于最小化,JavaScript 代码编译通常指的是把源代码转换为一种逻辑相同但字节更少的形式。与最小化的不同之处在于,编译后代码的结构可能不同,但仍然具备与原始代码相同的行为。编译器通过输入全部 JavaScript 代码可以对程序流执行稳健的分析。
编译可能会执行如下操作:
- 删除未使用的代码;
- 将某些代码转换为更简洁的语法;
- 全局函数调用、常量和变量行内化。
JavaScript 转译
我们提交到项目仓库中的代码与浏览器中运行的代码不一样。ES6、ES7 和 ES8 都为 ECMAScript 规范扩充增加了更好用的特性,但不同浏览器支持这些规范的步调并不一致。
通过 JavaScript 转译,可以在开发时使用最新的语法特性而不用担心浏览器的兼容性问题。转译可以将现代的代码转换成更早的 ECMAScript 版本,通常是 ES3 或 ES5,具体取决于你的需求。这样可以确保代码能够跨浏览器兼容。本文档附录将介绍一些转译工具。
注意 “转译”(transpilation)和“编译”(compilation)经常被人当成同一个术语混用。编译是将源代码从一种语言转换为另一种语言。转译在本质上跟编译是一样的,只是目标语言与源语言是一种语言的不同级别的抽象。因此,把 ES6/ES7/ES8 代码转换为 ES3/ES5 代码从技术角度看既是编译也是转译,只是转译更为确切一些。
HTTP 压缩
传输负载是从服务器发送给浏览器的实际字节数。这个字节数不一定与代码大小相同,因为服务器和浏览器都具有压缩能力。所有当前主流的浏览器(IE/Edge、Firefox、Safari、Chrome 和 Opera)都支持客户端解压缩收到的资源。服务器则可以根据浏览器通过请求头部(Accept-Encoding)标明自己支持的格式,选择一种用来压缩 JavaScript 文件。在传输压缩后的文件时,服务器响应的头部会有字段(Content-Encoding)标明使用了哪种压缩格式。浏览器看到这个头部字段后,就会根据这个压缩格式进行解压缩。结果是通过网络传输的字节数明显小于原始代码大小。
例如,使用 Apache 服务器上的两个模块(mod_gzip 和 mod_deflate)可以减少原始 JavaScript 文件的约 70%。这很大程度上是因为 JavaScript 的代码是纯文件,所以压缩率非常高。减少通过网络传输的数据量意味着浏览器能更快收到数据。注意,服务器压缩和浏览器解压缩都需要时间。不过相比于通过传入更少的字节数而节省的时间,整体时间应该是减少的。
注意 大多数 Web 服务器(包括开源的和商业的)具备 HTTP 压缩能力。关于如何正确地配置压缩,请参考相关服务器的文档。
小结
随着 JavaScript 开发日益成熟,最佳实践不断涌现。曾经的业余爱好如今也成为了正式的职业。因此,前端开发也需要像其他编程语言一样,注重可维护性、性能优化和部署。
为保证 JavaScript 代码的可维护性,可以参考如下编码惯例。
- 其他语言的编码惯例可以作为添加注释和确定缩进的参考,但 JavaScript 作为一门适合松散类型的语言也有自己的一些特殊要求。
- 由于 JavaScript 必须与 HTML 和 CSS 共存,因此各司其职尤为重要:JavaScript 负责定义行为,HTML 负责定义内容,而 CSS 负责定义外观。
- 如果三者职责混淆,则可能导致难以调试的错误和可维护性问题。随着 Web 应用程序中 JavaScript 代码量的激增,性能也越来越重要。因此应该牢记如下这些事项。
- 执行 JavaScript 所需的时间直接影响网页性能,其重要性不容忽视。
- 很多适合 C 语言的性能优化策略同样也适合 JavaScript,包括循环展开和使用 switch 语句而不是 if 语句。
- 另一个需要重视的方面是 DOM 交互很费时间,因此应该尽可能限制 DOM 操作的数量。开发 Web 应用程序的最后一步是上线部署。以下是本章讨论的相关要点。
- 为辅助部署,应该建立构建流程,将 JavaScript 文件合并为较少的(最好是只有一个)文件。
- 构建流程可以实现很多源代码处理任务的自动化。例如,可以运行 JavaScript 验证程序,确保没有语法错误和潜在的问题。
- 压缩可以让文件在部署之前变得尽量小。
- 启用 HTTP 压缩可以让网络传输的 JavaScript 文件尽可能小,从而提升页面的整体性能。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。