第28章 最佳实践
# 可维护性
实际开发中,大多数开发者会花大量时间去维护别人写的代码。
# 什么是可维护的代码
可维护的特点如下:
容易理解:无须求助原始开发者,任何人一看代码就知道它是干什么的,以及它是怎么实现的。
符合常识:代码中的一切都显得顺理成章,无论操作有多么复杂。
容易适配:即使数据发生变化也不用完全重写。
容易扩展:代码架构经过认真设计,支持未来扩展核心功能。
容易调试:出问题时,代码可以给出明确的信息,通过它能直接定位问题。
# 编码规范
可读性
代码风格,比如eslint,如代码缩进。
代码注释,一般以下地方需要注释。
函数和方法。每个函数和方法都应该有注释来描述其用途,以及完成任务所用的算法。同时,也写清使用这个函数或方法的前提(假设)、每个参数的含义,以及函数是否返回值(因为通过函数定义看不出来)。
大型代码块。多行代码但用于完成单一任务的,应该在前面给出注释,把要完成的任务写清楚。
复杂的算法。如果使用了独特的方法解决问题,要通过注释解释明白。这样不仅可以帮助别人查看代码,也可以帮助自己今后查看代码。
使用黑科技。由于浏览器之间的差异,JavaScript 代码中通常包含一些黑科技。不要假设其他人一看就能明白某个黑科技是为了解决某个浏览器的什么问题。如果某个浏览器不能使用正常方式达到目的,那要在注释里把黑科技的用途写出来。这样可以避免别人误以为黑科技没有用而把它“修复”掉,结果你已解决的问题又会出现。
变量和函数命名
以下是关于命名的通用规则。
变量名应该是名词,例如 car 或 person。
函数名应该以动词开始,例如 getName()。返回布尔值的函数通常以 is 开头,比如 isEnabled()。
对变量和函数都使用符合逻辑的名称,不用担心长度。长名字的问题可以通过后处理和压缩解
决。
- 变量、函数和方法应该以小写字母开头,使用驼峰大小写(camelCase)形式,如 getName()和
isPerson。类名应该首字母大写,如 Person、RequestFactory。常量值应该全部大写并以
下划线相接,比如 REQUEST_TIMEOUT。
- 名称要尽量用描述性和直观的词汇,但不要过于冗长。getName()一看就知道会返回名称,而
PersonFactory 一看就知道会产生某个 Person 对象或实体。
要完全避免没有用的变量名。
变量类型透明化
有三种方式可以标明变量的数据类型。不过最好的方式是使用typescript。
初始化标明变量类型
// 通过初始化标明变量类型 let found = false; // 布尔值 let count = -1; // 数值 let name = ""; // 字符串 let person = null; // 对象
1
2
3
4
5ES6 之后,可以在函数声明中为参数指定默认值来标明参数类型。
匈牙利表示法
用 o 表示对象,s 表示字符串,i 表示整数,f 表示浮点数,b 表示布尔值。示例如下:
// 使用匈牙利表示法标明数据类型 let bFound; // 布尔值 let iCount; // 整数 let sName; // 字符串 let oPerson; // 对象
1
2
3
4
5缺点是使代码可读性下降、不够直观。因此不推荐。
使用类型注释
基本思路是在变量旁边使用注释说明类型:
// 使用类型注释表明数据类型 let found /*:Boolean*/ = false; let count /*:int*/ = 10; let name /*:String*/ = "Nicholas"; let person /*:Object*/ = null;
1
2
3
4
5缺点是多行注释不会生效。
# 松散耦合
应用程序的某个部分对另一个部分依赖得过于紧密,代码就会变成紧密耦合。紧密耦合时,会增加调试难度,降低可维护性。
典型的问题是在一个对象中直接引用另一个对象。
- 解耦 HTML/JavaScript
理解HTML 是数据,JavaScript 是行为。
<!-- 使用<script>造成 HTML/JavaScript 紧密耦合 -->
<script>
document.write("Hello world!");
</script>
<!-- 使用事件处理程序属性造成 HTML/JavaScript 紧密耦合 -->
<input type="button" value="Click Me" onclick="doSomething()"/>
// 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>`;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
不过,现代web开发一般使用框架,比如Vue/React,但也需要时刻警惕类似的耦合出现。
- 解耦 CSS/JavaScript
CSS主要负责页面显示,但有时会使用js修改css。
// CSS 紧耦合到了 JavaScript
element.style.color = "red";
element.style.backgroundColor = "blue";
2
3
更好的方案是通过动态修改类名而不是直接修改样式来实现:
// CSS 与 JavaScript 松散耦合
element.className = "edit";
2
保证层与层之间的适当分离至关重要。显示出问题就应该只到 CSS 中解决,行为出问题就应该只找 JavaScript 的问题。
- 解耦应用程序逻辑/事件处理程序
看个例子:
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";
}
}
}
2
3
4
5
6
7
8
9
这个事件处理程序除了处理事件,还包含了应用程序逻辑。只要逻辑互相掺杂,那可维护性就会大大降低。
更好的做法是将应用程序逻辑与事件处理程序分开,各自负责处理各自的事情。事件处理程序应该专注于 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);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
以下是在解耦应用程序逻辑和业务逻辑时应该注意的几点。
不要把 event 对象传给其他方法,而是只传递 event 对象中必要的数据。
应用程序中每个可能的操作都应该无须事件处理程序就可以执行。
事件处理程序应该处理事件,而把后续处理交给应用程序逻辑。
# 编码惯例
- 尊重对象所有权
永远不要修改不属于你的对象。
简单来讲,如果你不负责创建和维护某个对象及其构造函数或方法,就不应该对其进行任何修改。即:
不要给实例或原型添加属性。
不要给实例或原型添加方法。
不要重定义已有的方法。
- 不声明全局变量
最多可以创建一个全局变量,作为其他对象和函数的命名空间。
// 两个全局变量:不要!
var name = "Nicholas";
function sayName() {
console.log(name);
}
// 一个全局变量:推荐
var MyApplication = {
name: "Nicholas",
sayName: function() {
console.log(this.name);
}
};
2
3
4
5
6
7
8
9
10
11
12
13
命名空间涉及创建一个对象,然后通过这个对象来暴露能力。
命名空间相当于一个容器,其他对象包含在这里面。
- 不要比较 null
准确的说,在做类型推断时,不要简单的比较是否不为null,而是要真的检查类型。
如果值应该是引用类型,则使用 instanceof 操作符检查其构造函数。
如果值应该是原始类型,则使用 typeof 检查其类型。
如果希望值是有特定方法名的对象,则使用 typeof 操作符确保对象上存在给定名字的方法。
- 使用常量
尽可能把可能会修改的数据提取出来,放在单独定义的常量中,以实现数据与逻辑分离。
可以使用以下标准检查哪些数据需要提取。
重复出现的值:任何使用超过一次的值都应该提取到常量中,这样可以消除一个值改了而另一个值没改造成的错误。这里也包括 CSS 的类名。
用户界面字符串:任何会显示给用户的字符串都应该提取出来,以方便实现国际化。
URL:Web 应用程序中资源的地址经常会发生变化,因此建议把所有 URL 集中放在一个地方管理。
任何可能变化的值:任何时候,只要在代码中使用字面值,就问问自己这个值将来是否可能会变。如果答案是“是”,那么就应该把它提取到常量中。
# 性能
# 作用域意识
访问全局变量始终比访问局部变量慢,因为必须遍历作用域链。任何可以缩短遍历作用域链时间的举措都能提升代码性能。
- 避免全局查找
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.";
}
2
3
4
5
6
7
8
这段代码有三个地方引用了全局 document 对象,如果有for循环可能引用更多,每次都要遍历一次作用域链。改写如下:
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.";
}
2
3
4
5
6
7
8
9
这里先把 document 对象保存在局部变量 doc 中。然后用 doc 替代了代码中所有的 document。这样调用这个函数只会查找一次作用域链,肯定会快很多。
因此,一个经验规则就是,只要函数中有引用超过两次的全局对象,就应该把这个对象保存为一个局部变量。
- 不使用 with 语句
with 语句会创建自己的作用域,因此也会加长其中代码的作用域链。
此外,with语句可读性非常差,不推荐书写。
function updateBody() {
with(document.body) {
console.log(tagName);
innerHTML = "Hello world!";
}
}
2
3
4
5
6
改写如下:
function updateBody() {
let body = document.body;
console.log(body.tagName);
body.innerHTML = "Hello world!";
}
2
3
4
5
# 选择正确的方法
- 避免不必要的属性查找
表 示 法 | 名 称 | 说 明 |
---|---|---|
O(1) | 常量 | 无论多少值,执行时间都不变。表示简单值和保存在变量中的值 |
O(logn) | 对数 | 执行时间随着值的增加而增加,但算法完成不需要读取每个值。例子:二分查找 |
O(n) | 线性 | 执行时间与值的数量直接相关。例子:迭代数组的所有元素 |
O(n^2) | 二次方 | 执行时间随着值的增加而增加,而且每个值至少要读取 n 次。例子:插入排序 |
访问数组元素也是 O(1)操作。
访问对象属性的算法复杂度是 O(n)。因为查找属性名要搜索原型链。
只要使用某个 object 属性超过一次,就应该将其保存在局部变量中。第一次仍然要用 O(n)的复杂度去访问这个属性,但后续每次访问就都是 O(1)。
let query = window.location.href.substring(window.location.href.indexOf("?"));
// 改写如下
let url = window.location.href;
let query = url.substring(url.indexOf("?"));
2
3
4
5
- 优化循环
(1) 简化终止条件。因为每次循环都会计算终止条件,所以它应该尽可能地快。这意味着要避免属性查找或其他 O(n)操作。
(2) 简化循环体。循环体是最花时间的部分,因此要尽可能优化。要确保其中不包含可以轻松转移到循环外部的密集计算。
(3) 使用后测试循环。最常见的循环就是 for 和 while 循环,这两种循环都属于先测试循环。do-while就是后测试循环,避免了对终止条件初始评估 ,因此应该会更快。
// 时间复杂度为O(n)
for (let i = 0; i < values.length; i++) {
process(values[i]);
}
// 时间复杂度为O(1)
for (let i = values.length - 1; i >= 0; i--) {
process(values[i]);
}
// 后测试循环 优化是将终止条件和递减操作符合并成了一条语句
let i = values.length-1;
if (i > -1) {
do {
process(values[i]);
}while(--i >= 0);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
- 展开循环
如果循环的次数是有限的,那么通常抛弃循环而直接多次调用函数会更快。
注意,这里与代码的可复用原则相违背。
// 抛弃循环
process(values[0]);
process(values[1]);
process(values[2]);
2
3
4
展开循环可以节省创建循环、计算终止条件的消耗,从而让代码运行更快。
此外还有达夫设备技术来处理大型数据集,小型数据则不必要。
- 避免重复解释
在 JavaScript 运行时,实例化新解析器比较费时间。
// 对代码求值:不要
eval("console.log('Hello world!')");
// 创建新函数:不要
let sayHi = new Function("console.log('Hello world!')");
// 设置超时函数:不要
setTimeout("console.log('Hello world!')", 500);
2
3
4
5
6
不过上面这3种代码,在日常开发中基本不会遇到。
- 其他性能优化注意事项
- 原生方法很快。原生方法是使用 C 或 C++等编译型语言写的。比如Math 对象上那些执行复杂数学运算的方法。
- switch 语句很快。把最可能的放前面,不太可能的放后面,可以进一步提升性能。
- 位操作很快。在执行数学运算操作时,位操作一定比任何布尔值或数值计算更快。
# 语句最少化
一条可以执行多个操作的语句,比多条语句中每个语句执行一个操作要快。
- 多个变量声明
// 有四条语句:浪费
let count = 5;
let color = "blue";
let values = [1,2,3];
let now = new Date();
// 一条语句更好
let count = 5,
color = "blue",
values = [1,2,3],
now = new Date();
2
3
4
5
6
7
8
9
10
11
- 插入迭代性值
let name = values[i];
i++;
// 使用组合语句
let name = values[i++];
2
3
4
5
- 使用数组和对象字面量
使用构造函数始终会产生比单纯插入元素或定义属性更多的语句,而字面量只需一条语句即可完成全部操作。
// 创建和初始化数组用了四条语句:浪费
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);
}
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
注意,如果减少语句会造成代码难以理解,那么减少语句不再成为优化项。
# 优化 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}');
}
2
3
4
5
6
7
以上代码向列表中添加了 10 项。每添加 1 项,就会有两次实时更新:一次添加
优化方案是使用文档片段构建 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); // +
2
3
4
5
6
7
8
9
这样修改之后,完成同样的操作只会触发一次实时更新。
- 使用 innerHTML
在页面中创建新 DOM节点的方式有两种:
使用 DOM方法如 createElement()和 appendChild()。
使用 innerHTML。
对于少量 DOM 更新,这两种技术区别不大,但对于大量 DOM 更新,使用innerHTML 要比使用标准 DOM 方法创建同样的结构快很多。
let list = document.getElementById("myList"),
html = "";
for (let i = 0; i < 10; i++) {
html += '<li>Item ${i}</li>';
}
list.innerHTML = html;
2
3
4
5
6
- 使用事件委托
一个页面中事件处理程序的数量与页面响应用户交互的速度有直接关系。为了减少对页面响应的影响,应该尽可能使用事件委托。
事件委托利用了事件的冒泡。任何冒泡的事件都可以不在事件目标上,而在目标的任何祖先元素上处理。
- 注意 HTMLCollection
只要访问 HTMLCollection,无论是它的属性还是方法,就会触发查询文档,而这个查询相当耗时。减少访问 HTMLCollection 的次数可以极大地提升脚本的性能。
以下情形会返回 HTMLCollection:
调用 getElementsByTagName();
读取元素的 childNodes 属性;
读取元素的 attributes 属性;
访问特殊集合,如 document.form、document.images 等。
let images = document.getElementsByTagName("img");
for (let i = 0, len = images.length; i < len; i++) {
// 处理
}
// 避免访问images HTMLCollection
let images = document.getElementsByTagName("img"),
image;
for (let i = 0, len=images.length; i < len; i++) {
image = images[i];
// 处理
}
2
3
4
5
6
7
8
9
10
11
12
# 部署
# 构建流程
一般开发中的代码不会直接部署到线上,原因如下:
知识产权问题:如果把满是注释的代码放到网上,其他人就很容易了解你在做什么,重用它,并可能发现安全漏洞。
文件大小:你写的代码可读性很好,容易维护,但性能不好。浏览器不会因为代码中多余的空格、缩进、冗余的函数和变量名而受益。
代码组织:为保证可维护性而组织的代码不一定适合直接交付给浏览器。
- 文件结构
要遵循面向对象编程语言的典型模式,把对象和自定义类型保存到自己独立的文件中。这样可以让每个文件只包含最小量的代码,让后期修改更方便,也不易引入错误。
为减少合并时发生冲突的风险,可以使用Git工具。
Web 应用程序使用的 JavaScript 文件越少越好,因为 HTTP 请求对某些 Web应用程序而言是主要的性能瓶颈。
- 任务运行器
任务运行器可以完成代码检查、打包、转译、启动本地服务器、部署,以及其他可以脚本化的任务。
通常使用Node.js和npm来完成,
- 摇树优化
摇树优化(tree shaking)是非常常见且极为有效的减少冗余代码的策略。
ES6模块使用静态模块声明风格,意味着构建工具可以确定代码各部分之间的依赖关系。,实现了摇树优化策略的构建工具能够分析出选择性导入的代码,其余模块文件中的代码可以在最终打包得到的文件中完全省略。
- 模块打包器
模块打包器的工作是识别应用程序中涉及的 JavaScript 依赖关系,将它们组合成一个大文件,完成对模块的串行组织和拼接,然后生成最终提供给浏览器的输出文件。
目前流行的打包器有webpack、vite等。
# 验证
有一些工具可以帮我们发现 JavaScript 代码中潜在的问题,比如JSLint 和 ESLint,它们会报告语法错误和常见的编码错误。
# 压缩
js文件压缩是指:代码大小(code size)和传输负载(wire weight)。
- 代码大小,指的是浏览器需要解析的字节数。
- 传输负载,是服务器实际发送给浏览器的字节数。
- 代码压缩
JavaScript 压缩工具可以把源代码文件中的没有用的额外信息和格式删除,并在保证程序逻辑不变的前提下缩小文件大小。
所有 JavaScript 文件都应该在部署到线上环境前进行压缩。
- JavaScript 编译
JavaScript 代码编译通常指的是把源代码转换为一种逻辑相同但字节更少的形式。编译后代码的结构可能不同,但仍然具备与原始代码相同的行为。
编译可能会执行如下操作:
删除未使用的代码;
将某些代码转换为更简洁的语法;
全局函数调用、常量和变量行内化。
- JavaScript 转译
通过 JavaScript 转译,可以在开发时使用最新的语法特性而不用担心浏览器的兼容性问题。转译可以将现代的代码转换成更早的 ECMAScript 版本,通常是 ES3 或 ES5。
- HTTP 压缩
所有当前主流的浏览器(IE/Edge、Firefox、Safari、Chrome 和 Opera)都支持客户端解压缩收到的资源。服务器则可以根据浏览器通过请求头部(Accept-Encoding)标明自己支持的格式,选择一种用来压缩 JavaScript 文件。在传输压缩后的文件时,服务器响应的头部会有字段(Content-Encoding)标明使用了哪种压缩格式。浏览器看到这个头部字段后,就会根据这个压缩格式进行解压缩。结果是通过网络传输的字节数明显小于原始代码大小。