前言

虽然在当前,相比其他语言的社区,JavaScript有着大量的开发人员,但仍然有很多浅显甚至错误的理解,以及坏的影响存在于社区成员中。

在本文中,我们总结了一个小技巧的列表,它能让你的JavaScript程序变得更快。

这篇文章不是关于 Dev-Ops 的,也不会讨论诸如压缩你的文件、配置 Redis 或者使用Docker和Kubernetes来提高你你应用的表现。这篇文章是关于如何通过代码的编写来提高JavaScript 性能的。

我大多数是在说通用的JavaScript,但也有一些仅仅适用于Node.js,而还有一些是只适用于浏览器端的javaScript。然而,现在大多数的JavaScript开发者都是全栈了,我认为你会很容易理解这些知识的。

1. 不要使用全局变量

这是最简单的一个技巧了。很多开发者都已经熟悉它,但是,还是有少量的开发者不知道为甚么要这样做。你可以把它想象,每个单作用域都像是整个结构树中的一个节点。
每当你要使用一个变量的时候,JavaScript会从离你最近的地方开始搜索(在方法内部)。如果没有找到,他会转向他的父作用域继续搜索,如果还没找到,那么它还将继续往上搜索,这样一直继续下去,直到搜索到root或者全局作用域下。

因此,无论何时你使用一个全局变量的时候,他便会依次搜索整个作用域树,直到找到这个变量,这非常消耗性能。

第二点是,全局变量不会被垃圾收集器收集。因此,不要一直不断地增加全局变量

2. 数组中只存放同类数据

千万不要往数组中 push 不同类的数据项,除非这是是必要操作。
这样做的原因你可以在这篇文章中找到。简单地说,如果你使用混合着不同类型数据的数组,JavaScript引擎(或者说V8)将不能使用合适的数组将其解构为字典类型,这就让搜索操作变得非常耗费性能。

3. 把相同的代码封装成函数

在继续之前,先让我给你讲讲即时编译。

这些编译器在V8以及其他JavaScript引擎中,被用来分析热点代码。那,什么是热点代码呢?热点代码就是经常被使用到的函数或者对象。V8会存储一份经常使用到且不变的代码的编译版本,这将大大提升你程序的性能。

那,如果你把你常用的代码封装进函数中,并且不再改变它的使用方式(参数以及参数的类型),V8将会将其编译并优化。因此,遵从好的编码信条,会给你的代码带来可观的性能提升。

4. 避免使用 Delete

我之前是做 C/C++ 开发的,习惯使用 malloc 等申请内存的操作,也同样尽可能地释放不用的内存。

当我转向 JavaScript 后发现 delete 这个操作指令可以删除属性和变量。我便开始使用这个命令,因为我觉得这很聪明😂。

但是,当我开始了解到 JavaScript 引擎中的 JIT (Just in Time)编译器时,我发现,使用 delete 是一个多么大的错误。

呐,在上面说把相同的代码封装成函数我已经提到过 JIT 编译器。这里也是同样的原因。

当你使用 delete 关键字删除一个对象的属性时,比如:delete obj.prop,他实际上是修改了这个 obj 对应的隐藏类。由于这个原因,之前编译好的代码已经失效了,V8 变重新再单独编译一份这个对象。这对性能来说是一个沉重的打击。

因此,不要使用 delete,除非非常必要并且你知道这意味着什么。你可以将这个属性设置为 null,这将不会改变该对象的隐藏类,也同样不会触发重新编译。

5. 闭包与计时器 - 固定组合

想象一下下面这个例子:

1
2
3
4
5
6
7
8
9
10
11
var foo = {
bar: function () {
var self = this;
var timer = setTimeout(function () {
console.log('Timeout called');
self.bar();
}, 100);
}
};
foo.bar();
foo = null;

其中有一个简单的对象 foo,他有一个名为 bar 的方法,方法中就只有一个 setTimeout 计时器。

当调用 bar 后,我将 foo 设为 null。第一反应,我们之前的 foo 对象已经没有被引用了,它应当被垃圾收集器收集。但是不幸的是,这个计时器还保留着他自己的引用以便每次调用。这便会导致垃圾收集器无法处理这段内存,从而造成内存泄漏。

所以在你的脚本中需要注意上面这类情况。

6. 为相近的对象创建类

这是另一个 JIT 编译器应用的例子。

当你创建一些具有相同代码的对象(比如具有一组相同的属性)时,试着为它创建一个类或者一个 constructor

因为这样做的话,你便能够为这些实例创建一个单一的隐藏类,这也给了 JIT 编译器一个优化你代码的机会,从而让你的代码执行得更快。

所以,下面这个代码:

1
2
3
4
5
6
7
8
9
10
11
var b = {
p: 2,
q: "something else",
r: true
}

var c = {
p: 10,
q: "other things",
r: false
}

替换成这样写:

1
2
3
4
5
6
7
8
9
function Some(p, q, r) {
this.p = p;
this.q = q;
this.r = r;
}

var a = new Some(1, "something", false);
var b = new Some(2, "something else", true);
var c = new Some(10, "other things", false);

7. forEach 与 for 的对比

你或许通过“使用内置函数会更好”这种说法。那么问题来了,for 循环和 forEach 该用哪个呢?
除掉那非常微小的执行效率差别,你应该更倾向于使用 forEach,因为不像 for,forEach 会自动跳过那些未定义的元素,下面这个例子很好的说明了这一点

1
2
3
4
5
6
7
8
9
10
11
var arr = new Array(1000);
arr[0] = 20;
arr[434] = 200;

for(var i=0; i<arr.length; i++){
console.log("I am for");
}

arr.forEach(function(element){
console.log("I am forEach");
});

在上面的代码中,I am for 将会被打印 1000 次,而 I am forEach 只会打印两次。

8. 避免使用 for…in

尤其是在对象拷贝的时候,我经常看到有人用 for in。不幸的是,他的设计导致它没法有效的运行。所以,尽可能避免使用它,你可以用其他克隆对象的办法

9. 构造产生 Array 而不是用 push

如果你通过直接构造的方式生成数组,V8 能够很容易地分析出它的结构,而不是通过建立一个空数组然后往里面 push 数据。

1
2
3
4
5
6
7
8
9
10
11
// 好
var arr = [1,2,6,2,10,3];

// 不好
var arr = [];
arr.push(1);
arr.push(2);
arr.push(6);
arr.push(2);
arr.push(10);
arr.push(3);

你可能要说了,谁会选择第二种呢?大家不都是用第一种方法的么。

不,并不完全是。由于某些原因,我们会不经意地写出上面第二种代码。

比如,我们需要通过一定的规则从一个已有数组中创建出另一个数组。

通常,开发者都是先建立一个空数组,然后通过 forEach 或者其他方式遍历之前的数组,然后将符合条件的项目 push 到这个新数组中。不过,更好的办法是使用 Array.map()

10. Web-workers 与 Shared buffer

JavaScript 是单线程运行的。同样,这个线程被用作事件循环。因此,在 Node.js 中处理请求以及浏览器中的渲染,都是使用的一种非并行的方式。

所以,当你有一个需要大量计算的任务要执行时,你需要将其分配给 web-worker。在 node.js 中,并没有内建 worker,但是你可以使用 npm 模块或者通过新建一个线程来达到这个目的。

一个使用 worker 经常遇到的问题便是,如何在他们之间同步数据(并不是等待完成后发送信息)。呐,SharedArrayBuffer 能够达到这个目的。

从在2018年1月5日开始,SharedArrayBuffer 就被禁用了,不过这个特性(或者类似的特性)已经在 ECMA stage 4 提议中了。

11. 使用 setImmediate 而不是 setTimeout(fn,0)

这个建议只是针对 node.js 开发者。很多开发者都不用 setImmediate 或者 process.nextTick ,而是使用 setTimeout(fn,0) 来让程序腾出执行权。

但是,在我们的 setImmediate 与 setTimeout 比较中,setImmediate 要比 setTimeout 快 200倍(对,不止是 200%!)

因此,尽量使用 setImmediate 而不是 setTimeout。不过要谨慎使用 process.nextTick,除非你真的了解它的运行机制。

12. 避免在 node.js web 服务器上存放静态文件

为什么呢?因为 node.js 并不擅长这种处理。node.js 适用于处理 tcp/http 请求,因为这能很好发挥它异步的特性。

如果你让它总是忙于读取静态文件(这个操作是由 libUV 线程池来处理的),它的性能将会大打折扣,因为 libUV 是事先定好了线程数的(默认是4),这意味着你最大只能有 128 个线程。如果有更多的读文件请求,那这个处理过程将会变成同步的。

13. Promise.all(async await)

ES6 对于 Promise 的原生实现让我们的代码更简单。但是更让我们欣喜的是 async await 指令的提供。它使得我们能够用看起来是同步的代码,写异步的程序。

但是,有些开发者会因为太自信或者了解不充分,而写出了真正的同步代码。下面便是一个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// bad
async function someAsyncFunc() {
const user = await asyncGetUser();
const categories = await asyncGetCategories();
const mapping = await asyncMapUserWithCategory(user, categories);
}

// good
async function someAsyncFunc() {
const [user, categories] = await Promise.all([
asyncGetUser(),
asyncGetCategories()
]);
const mapping = await asyncMapUserWithCategory(user, categories);
}

这是一个很普遍的问题,尤其是在多重 async 调用的时候。不过庆幸的是,你可以使用 Promise.all 来保证所有的请求都执行完成。

14. 移除事件监听

我们经常使用事件监听,尤其是在浏览器端。并且,我们经常由于各种原因需要移除被绑定了事件的这个元素。但是,我们需要注意的是,当该元素被移除时,事件监听并不会自动被移除。这种情况就容易造成内存泄漏。

因此,你需要时刻注意,在移除元素的时候确保将其绑定的事件也移除掉了。或者使用更好的一个实现方式:事件代理。

15. 使用客户端自带的函数

在 node.js 出现以前,JavaScript 都是运行在浏览器端的。但不幸的是,开发者并不知道当前浏览器是用的哪个 ECMAScript 版本。这也是很多 JavaScript 库在浏览器端还需要垫片函数(polyfile)以适应不同的版本,当然这也比较臃肿。

但是,当你使用 node.js 时,你是明确知道当前运行的版本的。所以,尽量避免使用浏览器端的带有垫片的库,比如实现 Promise 的、实现彩色 console 输出的等等。要总是倾向于使用原生函数。

16. 二进制模块

如果你在开发一个比较大的,或者会被频繁调用的模块时,我建议你最好将其转换为二进制模块

LinkedIn 这家公司就是用的 node.js 做的后端,他们在二进制模块这一点做得很好。

17. 避免 O(n) 尝试达到 O(1)

O 是一个时间复杂度的表示方式,O(n)表示该程序执行时间与条目数量成正相关,而 o(1) 则表示,该程序的执行时间和条目的数量没有直接的关联。

在 JavaScript 中,对象都是弱类型的,所以,字面上说,你能够通过一切 string 类型(或者其他可被转换成 Sting 的类型)作为 key 访问属性。

现在,假如你有 N个学生以及他们对应的 ID 号以及他们各自的总分。当输入 ID 时,要在页面上显示出他的名字以及分数。

比较常用的办法就是,创建一个数组,然后通过遍历查询数据。但这样操作的时间复杂度将会是 O(n),随着学生人数的增多,这将会带来一定的性能问题。

但是,当我们通过新建一个对象,然后将每个学生的 ID 作为 key,将他的名字和总分作为属性,这样我们查询某个学生时,就会很快了,时间复杂度为 O(1)。

不过这并不是让你总是用 Object 来代替 Array,这应该具体问题具体分析。

总结

通过一些熟知的方式升级你的服务器配置文件,让你的应用能够有更好的性能。

但是,如果你的代码有内存泄漏或者非异步的处理,所有的优化手段都无济于事,你的服务将会变慢,甚至挂掉。

因此,在我们编码的时候,你必须要有意识地去遵循一些好的实践、注意边界效应以及性能优化点等。

Happy coding😀