你必须知道的javascript最佳实践-爱代码爱编程
我正在参加「掘金·启航计划」
前言
一个合格的程序员,对于自己写的代码,不仅要关注是否能准确地执行业务逻辑,还要保证代码的干净优雅。
JavaScript 的最佳实践可以分成几类,适用于开发流程的不同阶段。
最佳实践起初比较棘手,但最终会让你发现这是明智之举。
可维护性
编写可维护的代码十分重要,因为大多数开发者会花大量时间去维护别人写的代码。在实际开发中,从第一行代码开始写起的情况非常少,通常是要在别人的代码之上构建自己的工作。
让自己的代码容易维护,可以保证其他开发者更好地完成自己的开发工作,这是作为一名程序员必备的专业素养。
什么是可维护的代码
通常,说代码可维护就意味着它具备以下特点:
-
容易理解:无需求助原始开发者,任何人一看代码就知道它是干什么的,以及它是怎么实现的。
-
符合常识:代码中的一切都显得顺理成章,无论操作有多么复杂。
-
容易适配:即使数据发生变化也不用完全重写。
-
容易扩展:代码架构经过认真设计,支持未来扩展核心功能。
-
容易调试:出问题时,代码可以给出明确的信息,通过它能直接定位问题。
能够写出可维护的代码是一项重要的专业技能,这就是业务爱好者和专业开发人员之间的区别。
编写规范
可读性
要想让代码容易维护,首先必须使其可读。可读性必须考虑代码是一种文本文件,为此,代码缩进是保证可读性的重要基础。如果所有人都使用相同的缩进,整个项目的代码就会更容易让人看懂。一般来说,缩进是4个空格,当然具体多少个可以自己定。
可读性的另一方面是代码注释。在大多数编程语言中,广泛接受的做法是为每个方法都编写一个注释。一般来说,以下这些地方都应该写注释:
-
函数和方法:每个函数和方法都应该有注释来描述其用途,以及完成任务所用的算法。同时,也写清使用这个函数或方法的前提、每个参数的含义,以及函数是否返回值。
-
大型代码块:多行代码但用于完成单一任务的,应该在前面给出注释,把要完成的任务写清楚。
-
复杂的算法:如果使用了独特的方法解决问题,要通过注释解释明白。这样不仅可以帮助别人查看代码,也有助于自己今后查看代码。
-
使用黑科技:如果不能使用正常的方法达到目的,那么要在注释里把黑科技的用途写出来。
变量和函数名
代码中变量和函数的适当命名对于其可读性和可维护性至关重要。以下是命名的通用规则:
-
变量名应该是名词。
-
函数名应该以动词开头,例如 getName()。
-
返回布尔值的函数通常以 is 开头,例如 isEnabled()。
-
对变量和函数都使用符合逻辑得名称,不用担心长度,长名字的问题可以通过后处理和压缩解决。
-
变量、函数和方法应该以小写字母开头,使用驼峰大小写形式,如 getName()。
-
名称要尽量用描述性和直观的词汇,但不要过于冗长。
变量类型透明化
可以通过初始化来标明变量类型。定义变量时,应该立即将其初始化为一个将来要使用的类型值。例如,要保存布尔值的变量,可以将其初始化为 true 或 false;要保存数值的变量,可以将其初始化为一个数值。再看几个例子:
let visible = false; // 布尔值
let num = 0; // 数值
let name = ''; // 字符串
let person = null; // 对象
松散耦合
只要应用程序的某个部分对另一个部分依赖得过于紧密,代码就会紧密耦合,因而难以维护。
典型的问题是在一个对象中直接引用另一个对象,这样,修改其中一个,可能必须还得修改另外一个。紧密耦合的程序难以维护,肯定需要频繁的重写。
我们需要有这个意识:随时注意不要让代码紧密耦合。
解耦 HTML/JavaScript
Web 开发中最常见的耦合是 HTML/JavaScript 耦合。在网页中,HTML 和 JavaScript 分别代表不同层面的解决方案,HTML 是数据,JavaScript 是行为。因为它们之间要交互操作,需要通过不同的方式将这两种技术联系起来,其中一些方式会导致 HTML 与 JavaScript 紧密耦合:
<!-- 使用 <script> 元素造成紧密耦合 -->
<script>
document.write('Hello');
</script>
<!-- 使用事件处理程序属性造成紧密耦合 -->
<input type="button" value="Click Me" onclick="doSomething()" />
虽然技术上这样做没有问题,但在实践中,更推荐将 HTML 与 JavaScript 完全分开,通过外部文件引入 JavaScript,然后使用 DOM 添加行为。
HTML 与 JavaScript 紧密耦合的情况下,每次分析 JavaScript 的报错都要先确定错误来自 HTML 还是 JavaScript。这样也会引入代码可用性的新错误。在上栗中,用户可能会在 doSomething() 函数可用之前点击按钮,从而导致 JavaScript 报错。因为每次修改按钮的行为都需要既改 HTML 又改 JavaScript,而实际上只有后者才是有必要修改的,所以就会降低代码的可维护性。
解耦 CSS/JavaScript
JavaScript 与 CSS 紧密相关,它们都构建在 HTML 之上,因此也经常一起使用。与 HTML 与 JavaScript 的情况类似,CSS 也可能与 JavaScript 产生紧密耦合。最常见的例子就是使用 JavaScript 修改个别样式,比如:
element.style.color = 'red';
element.style.backgroundColor = 'blue';
因为 CSS 负责页面显示,所以任何样式的问题都应该通过 CSS 文件解决。如果 JavaScript 直接修改个别样式,就会增加一个排错时要考虑甚至修改的因素。如果将来有一天要修改样式,那么 CSS 和 JavaScript 可能都要修改。这时对负责维护的开发者来说是一个噩梦。层与层的清晰解耦是必须的。
现在 Web 应用程序经常使用 JavaScript 改变样式,因此我们不太可能完全解耦 CSS 和 JavaScript,但可以让这种耦合变得更松散。可以通过动态修改类名而不是样式来实现,比如:
element.className = 'edit';
通过修改元素的 CSS 类名,可以把大部分样式限制在 CSS 文件里。JavaScript 只负责修改应用样式的类名,而不直接影响元素的样式。
只要应用的类名没错,显示出问题就应该只到 CSS 中解决,行为出问题就应该只找 JavaScript 的问题。这些层与层之间的松散耦合可以提升整个应用程序的可维护性。
解耦应用程序逻辑/事件处理程序 每个 Web 应用程序中都会有大量事件处理程序在监听各种事件。可是,其中很少能真正做到应用程序逻辑与事件处理程序分离。看下面的例子:
function handleKeyPress(e) {
if (e.keyCode === 13) {
let target = e.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(e) {
if (e.keyCode == 13) {
let target = e.target;
validateValue(target.value);
}
}
这样修改之后,应用程序逻辑跟事件处理程序就分开了。validateValue() 函数中不包含任何依赖事件处理程序的代码,这个函数只负责接收一个值,并根据该值执行其他所有操作。
以下是在解耦事件处理程序和业务逻辑时应该注意的几点:
-
不要把 event 对象传给其他方法,而是只传递 event 对象中必要的数据。
-
应用程序中每个可能的操作都应该无须事件处理程序就可以执行。
-
事件处理程序应该处理事件,把后续处理交给应用程序逻辑。
做到上述几点能够给任何代码的可维护性带来巨大的提升,同时也能为将来的测试和开发提供更多可能性。
编码惯例
编写可维护的 JavaScript 不仅仅涉及代码格式和规范,也涉及代码做什么。企业开发 Web 应用程序通常需要很多人协同工作,为此,开发者应该遵守某些编码惯例。
尊重对象所有权
尊重对象所有权,就是不要修改不属于你的对象。如果你不负责创建和维护某个对象及其构造函数或方法,就不应该对其进行任何修改。更具体一点:
- 不要给实例或原型添加属性
- 不要给实例或原型添加方法
- 不要重定义已有的方法
为避免发生错误,永远不要修改不属于你的对象,只有你自己创建的才是你的对象。
不声明全局变量
尽可能不声明全局变量和函数。可以创建一个全局变量,作为其他对象和函数的命名空间:
// bad
var name = 'Jack';
function sayName() {
console.log(name);
}
// good
var MyApplication = {
name: 'Jack',
sayName: function () {
console.log(this.name);
}
}
重写之后的版本只声明了一个全局对象 MyApplication,这样可以避免一些问题。首先,变量 name 会覆盖 window.name 属性,而这可能会影响其他功能。其次,有助于分清功能都集中在哪里。
不要比较 null
先看一个例子:
function sortArray(values) {
if (values != null) {
values.sort(comparator);
}
}
这个函数的目的是使用给定的比较函数对数组进行排序。为保证函数正常执行,values 参数必须是数组。但是,if 语句在这里只简单地检查了这个值是不是 null。实际上,字符串、数值还有其他很多值可以通过这里的检查,结果就会导致错误。
实际开发中,单纯检查 null 通常是不够的。检查值的类型就要真的检查类型,而不是检查它不能是什么。下面我们重写这个函数:
function sortArray(values) {
if (values instanceof Array) {
values.sort(comparator);
}
}
这个版本可以过滤所有无效的值,根本不需要使用 null。
使用常量
定义常量是为了从应用程序逻辑中将可能会修改的数据提取出来,以便需要修改数据时只需要修改一个地方,且不会引发错误,使代码更容易维护。显示在用户界面的字符串就应该以这种方式提取出来,可以方便实现国际化。URL 也应该这样提取出来,因为 URL 极有可能变化。定义常量可遵循以下原则:
-
重复出现的值:任何使用超过一次的值都应该提取到常量中,这样可以消除一个值改了而另一个值忘记改造成的错误。
-
用户界面字符串:任何会显示给用户的字符串都应该提取出来,以方便实现国际化。
-
URL:URL 经常发生变化,因此建议把所有 URL 集中放在一个地方管理。
-
任何可能变化的值:任何时候,只要在代码中使用字面值,就问问自己这个值将来是否可能会变。如果是,那么就应该把它提取到常量中。
性能
现代网页中 JavaScript 代码的数量已有极大的增长,代码量的增长带来了运行时执行 JavaScript 的性能问题。JavaScript 一开始是一门解释型语言,因此执行速度比编译型语言要慢一些。Chrome 是第一个引入优化引擎将 JavaScript 编译为原生代码的浏览器,其他主流浏览器也紧随其后,实现了 JavaScript 编译。
即使到了编译 JavaScript 时代,仍可能写出运行慢的代码。 不过,如果遵循一些基本模式,就能保证写出执行速度很快的代码。
作用域意识
随着作用域链中作用域数量的增加,访问当前作用域外部变量所需的时间也会增加。访问全局变量始终比访问局部变量慢,因为必须遍历作用域链。任何可以缩短遍历作用域链时间的举措都能提升代码性能。
避免全局查找
全局变量和函数相比于局部值始终是最费时间的,因为需要经历作用域链查找。看下面的函数:
function updateUI() {
let imgs = document.getElementByTagName('img');
for (let i = 0; len = imgs.length; i < len; i++) {
imgs[i].title = `${document.title} image ${i}`;
}
let msg = ducument.getElementById('msg');
msg.innerHTML = 'Update complete';
}
这个函数看起来好像没啥问题,但其中三个地方引用了全局 document 对象。如果页面的图片非常多,那么 for 循环中就需要引用 document 几十甚至上百次,每次都要遍历一次作用域链。
通过在局部作用域中保存 document 对象的引用,能够明显提升这个函数的性能,因为只需要查找一次作用域链。创建一个指向 document 对象的局部变量,可以将全局查找的数量限制为一个,从而提高函数的性能:
function updateUI() {
let doc = document;
let imgs = doc.getElementByTagName('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';
}
我们需要记住一个经验规则:只要函数中有引用超过两次的全局对象,就应该把这个对象保存为一个局部变量。
不使用 with 语句
与函数类似,with 语句会创建自己的作用域,因此也会加长其中代码的作用域链。在 with 语句中执行的代码一定比在它外部执行的代码慢,因为作用域链查找时多一步。
实际编码中很少需要使用 with 语句,它的主要用途是节省一些代码。大多数情况下,使用局部变量可以实现同样的效果,无须增加新作用域。
function uodateBody() {
with (document.body) {
console.log(tagName);
innerHTML = 'Hello';
}
}
上面的代码使用 with 语句让访问 document.body 更简单了。使用局部变量也可以实现同样的效果:
function uodateBody() {
let body = document.body;
console.log(body.tagName);
body.innerHTML = 'Hello';
}
选择正确的方法
与其他语言一样,影响性能的因素通常涉及算法或解决问题的方法。经验丰富的开发者知道用什么方法性能更佳。
避免不必要的属性查找
访问对象的每个属性都比访问变量或数组花费的时间长,因为查找属性名要搜索原型链。查找的属性越多,执行时间就越长。
let values = { first: 1, second: 2 };
let res1 = values.first + values.second;
let res2 = values.first - values.second;
console.log(res1);
console.log(res2);
上栗中一共使用了四次属性查找来计算值,几次属性查找可能不会有明显的性能问题,但几百上千次则绝对会拖慢执行速度。
只要使用某个 object 属性超过一次,就应该将其保存在局部变量中:
let values = { first: 1, second: 2 };
let first = values.first;
let second = values.second;
let res1 = first + second;
let res2 = first - second;
console.log(res1);
console.log(res2);
通常,只要能够降低算法复杂度,就应该尽量通过在局部变量中保存值来替代属性查找。另外,如果实现某个需求既可以使用数组的数值索引,又可以使用命名属性,那就都应该使用数值索引。
优化循环
优化循环的基本步骤如下:
-
简化终止条件:因为每次循环都会计算终止条件,所以它应该尽可能地快,这意味着要避免属性查找或其他复杂操作。
-
简化循环体:循环体是最花时间的部分,因此要尽可能优化,要确保其中不包含可以轻松转移到循环外部的密集计算。
展开循环
如果循环的次数是有限的,那么抛弃循环而直接多次调用函数会更快。展开循环可以节省创建循环、计算终止条件的消耗。
switch 语句
switch 语句很快,如果代码中有复杂的 if/else 语句,将其转换成 switch 语句可以变得更快。然后,通过重新组织分支,把最可能的放前面,不太可能的放后面,可以进一步提升性能。
语句最少化
JavaScript 代码中语句的数量会影响操作执行的速度,一条可以执行多个操作的语句,比多条语句中每个语句执行一个操作要快。那么优化的目标就是寻找可以合并的语句,以减少整个脚本的执行时间。
声明多个变量时很容易出现多条语句:
// 浪费
let count = 0;
let color = 'red';
let now = new Date();
// 使用一个语句声明
let count = 0,
color = 'red',
now = new Date();
任何时候只要使用迭代性值(即会递增或递减的值),都要尽可能使用组合语句。
let res = values[i];
i++;
上面的代码可以优化成一条语句:
let res = values[i++];
优化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);
这样修改之后,完成同样的操作只会触发一次实时更新。文档片段在这里作为新创建项目的临时占位符,片段本身不会被添加到父元素 list 中。
使用 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;
虽然拼接字符串也会有一些性能损耗,但这个技术仍然比执行多次 DOM 操作速度更快。
使用 innerHTML 的关键在于最小化调用次数。下面的代码使用 innerHTML 次数太多,因此效率会极低:
let list = document.getElementById('myList');
for (let i = 0; i < 10; i++) {
list.innerHTML += '<li>Item ${i}</li>';
}
使用事件委托
一个页面中事件处理程序的数量与页面响应用户交互的速度有直接关系,为了减少对页面响应的影响,应该尽可能使用事件委托。
事件委托利用了事件的冒泡,将事件处理程序放到祖先元素上进行处理。只要可能,就应该在文档级添加事件处理程序,因为在文档级可以处理整个页面的事件。
注意 HTMLCollection
任何时候,只要访问 HTMLCollection ,无论是它的属性还是方法,就会触发查询文档,而这个查询相当耗时。减少访问 HTMLCollection 的次数可以极大地提升脚本性能。
可能优化 HTMLCollection 访问最关键的地方就是循环了。
let imgs = document.getElementByTagName('img');
for (let i = 0; len = imgs.length; i < len; i++) {
}
上栗中,把 length 保存到了 len 变量中,而不是每次都读一次 HTMLCollection 的 length 属性。
编写 JavaScript 代码时,要记住,只要返回 HTMLCollection 对象,就应该尽量不访问它。以下情形会返回 HTMLCollection:
- 调用 getElementByTagName()
- 读取元素的 childNodes 属性
- 读取元素的 attributes 属性
- 访问特殊集合,如 document.form、document.images 等
部署
构建流程
开发软件的典型模式是编码、编译和测试。但因为 JavaScript 不是编译型语言,所以这个流程经常会变成编码、测试。
我们写的代码跟在浏览器中测试的代码是一样的。这种方式的问题在于代码并不是最优的。你写的代码不应该不做任何处理就直接交给浏览器,原因如下:
-
知识产权问题:如果把满是注释的代码放到网上,其他人就很容易了解代码要做什么,可以重用它,并可能发现安全漏洞。
-
文件大小:我们写的代码可读性很好,易维护,但性能不好。因为代码中多余的空格、缩进、冗余的函数和变量名不会使浏览器受益。
-
代码组织:为保证可维护性而组织的代码不一定适合交给浏览器,为此,需要为 JavaScript 文件建立构建流程。
文件结构
最好不要在一个文件中包含所有 JavaScript 代码,相反,要遵循面向对象编程语言的典型模式,把对象和自定义类型保存到自己独立的文件中。这样可以让每个文件只包含最小量的代码,让后期修改方便,也不易引入错误。此外,在使用版本控制工具时,可以减少合并时发生冲突的风险。
注意,把代码分散到多个文件是从可维护性而不是部署角度出发的。对于部署,应该把所有源文件合并为一个或多个汇总文件。Web 应用程序使用的 JavaScript 文件越少越好,因为 HTTP 请求对某些 Web 应用程序而言是最主要的性能瓶颈。
任务运行器
任务运行器可以完成代码检查、打包、转译、启动本地服务、部署,以及其他可以脚本化的任务。Grunt 和 Gulp 是两个主流的任务运行器。
摇树优化
摇树优化是非常常见且极为有效的减少冗余代码的策略,摇树优化能分析出代码中的哪些内容是完全不需要的,在最终打包得到的文件中将其省略。
模块打包器
以模块形式编写代码,并不意味着必须以模块形式交付代码。通常,由大量模块组成的 JavaScript 代码在构建时需要打包到一起,然后只交付一个或少数几个 JavaScript 文件。
模块打包器的工作是识别应用程序中涉及的 JavaScript 依赖关系,将它们组合成一个大文件,完成对模块的串行组织和拼接,然后生成最终提供给浏览器的输出文件。
能够实现模块打包的工具非常多,最主流的是 Webpack。
验证
有一些工具可以帮我们发现 JavaScript 代码中潜在的问题,最流行的是 JSLint 和 ESLint。这些代码检查工具可以发现 JavaScript 代码中的语法错误和常见的编码错误,在开发过程中添加代码检查工具有助于避免出错。推荐在构建流程中也加入检查环节,以便在潜在问题成为错误之前识别它们。
压缩
谈到 JavaScript 文件压缩,实际上主要是两件事:代码大小和传输负载。代码大小指的是浏览器需要解析的字节数,而传输负载是服务器实际发送给浏览器的字节数。在 Web 开发的早期阶段,这两个数值几乎相等,服务器发送给浏览器的是未经修改的源文件。而今天,这两个数值不可能相等,实际上也不该相等。
代码压缩
JavaScript 不是编译成字节码,而是作为源代码传输的,所以源文件通常包含对浏览器的 JavaScript 解释器无用的额外信息和格式。JavaScript 压缩工具可以把源文件中的这些冗余信息删除,并在保证程序逻辑不变的前提下缩小文件大小。
注释、额外的空格、长变量或函数名都能提升可读性,但对浏览器而言这些都是多余的。压缩工具可以通过如下操作减少代码大小:
- 删除空格
- 删除注释
- 缩短变量、函数名和其他标识符
所有 JavaScript 文件都应该在部署到线上环境前进行压缩。
JavaScript 编译
类似于最小化,JavaScript 代码编译通常指的是把源代码转换为一种逻辑相同但字节更少的形式。与最小化的不同之处在于,编译后代码的结构可能不同,但仍具备与原始代码相同的行为。编译器通过输入全部 JavaScript 代码可以对程序流执行稳健的分析。编译可能会执行如下操作:
- 删除未使用的代码
- 将某些代码转换为更简洁的语法
- 全局函数调用、常量和变量行内化
JavaScript 转译
ES6+ 都为 ECMAScript 规范扩充了更好用的特性,但不同浏览器支持这些规范的步调并不一致。通过 JavaScript 转译,可以在开发时使用最新的语法特性而不用担心浏览器的兼容性问题。转译可将现代的代码转换成更早的 ECMAScript 版本。
HTTP压缩
服务器和浏览器一样具有压缩能力,所有主流的浏览器也都支持客户端解压缩收到的资源。服务器在传输文件之前会进行压缩,所以通过网络传输的字节数明显小于原始代码的大小。注意,服务器压缩和浏览器压缩都需要时间,不过相比于通过传入更少的字节数而节省的时间,整体时间应该是减少的。
最后
如果文中有错误或者不足之处,欢迎大家在评论区指正。
你的点赞是对我莫大的鼓励!感谢阅读~