JS性能优化策略

2,000 阅读13分钟

JS 是一门弱类型语言,拥有独特的原型链机制,在宿主中的拥有一套 DOM、BOM 操作接口,增加其性能控制的复杂性。JavaScript 主要应用场景依然围绕浏览器展开,所以,它在浏览器中的行为表现依然重要。本篇将从笔者的实践经验出发,分别从加载解析、语法优化、DOM 操作等各方面归纳总结优秀的 JS 代码性能优化策略。与此同时,关注如何编写更优雅干净的 JS 代码。

加载解析

JS 文件的加载解析涉及到浏览器对于文档的解析和渲染策略,一些不好的文档结构,会导致渲染空屏、卡顿,甚至出现页面机能混乱等问题。对于 JS 来说,在加载解析阶段可以从以下几个方面作出优化。

  1. 将 JS 文件放到文档最后(之前)引入

JS 代码通过 <script> 标签引入页面加载,<script> 标签是一个霸道的主儿,当文档遇到它时,会暂停解析,等待它执行完毕,再继续解析剩余的部分。在新浏览器中,多个 <script> 标签的内容彼此不会阻塞,可以并行下载,但其他资源仍然会被阻塞,所以,将 JS 文件放到文档最后引入,仍是最有效的优化策略。

  1. 合并 JS 文件

减少 HTTP 请求是最常见的性能优化策略,引入四个 10 kb 的文件需要做四次请求,要比引入一个 40kb 更耗性能,所以,当需要引入的 JS 文件过多时,必要的脚本合并是很有必要的。

但需要注意的是,如果一个文件过大,其解析的用时将会很大,这样无疑是得不偿失的,所以,不要有的没的全怼在一起。

  1. 使用 defer 无阻塞下载脚本

defer 是标准中为 <script> 标签提供的一个属性,其会使 JS 文件的下载和文档的渲染并行展开,同时延迟 JS 的执行时间到文档加载完毕,所以这个属性十分有用。

顺便提一下另一个可以应用在 <script> 上的属性 async,顾名思义,这个属性的作用是,使得脚本的加载和执行与文档的渲染并行进行。它与 defer 在下载脚本的时机是一致的,只不过,执行时机不同。下面这张图很形象地表明了这两个属性以及不带属性的脚本加载执行机制。

语法优化

  1. 慎用全局变量

全局变量的查找作用域链更长,生命周期也更长,且有内存泄漏的风险,甚至会产生不可预估的 bug 出现。项目中的第三方库一般都会暴露一些全局变量,这和你声明的全局变量也可能会发生冲突。所以,尽量谨慎地使用全局变量。

在 ES6 之后,我们使用 let/const 来声明变量是更好的选择。

  1. 使用性能更优的遍历操作

经过测试,JS 中的循环操作,耗时从小到大排名为:for -> forEach/for-of -> for-in

也就是说,对于大规模的遍历操作,优先使用 for 循环完成,其次是 forEach 以及 for-of,这两者处于一个数量级,最慢的属于 for-in,它之所以慢,是因为它常用于对象属性的遍历,并且会访问自身属性以及其原型链的属性(包括不可枚举属性)。

  1. 避免使用 with 和 eval

with 可以改变当前的作用域环境,将一个对象推入作用域链头部,这样,使得作用域环境内的局部变量的访问效率降低。

eval 将传入的字符串当做脚本执行,会大幅度降低脚本执行性能,避免使用。

  1. 尽量少地使用闭包

闭包提供了一些便捷性,但同时也会有一些性能影响,由于保留着原应被回收的变量引用,增加了作用域链的长度,影响性能。

同时它会可能会有内存泄漏的风险。

  1. 不要修改引用类型的原型方法

修改原型方法,在团队协作中,很可能带来不可预估的影响,尽量避免这样做。

  1. 当判断值很多时,优先考虑 switch 替代 if-else

当判断条件过多,例如超过3个,就应该考虑使用 switch 来替换 if-else。这样,不止可以提高代码的可读性,降低代码的理解成本。

对于 if 语句的优化,还有一些策略:提前 return;使用三元操作符;借用 ES6 的 Map 结构进行优化等,感兴趣的同学可以阅读这篇文章:juejin.cn/post/684490…

  1. 避免在循环中创建函数

每次循环创建一个函数不是明智之举,创建函数意味着内存分配与消耗,这是无用功,应该提前创建函数。

  1. 总是使用 === 和 !== 进行相等判断

== 和 != 操作符会引起 JS 的数据类型隐式转换,导致一些不可预估的负面作用,所以,更明智的选择是,总是去使用 === 和 !== 进行相等判断。

  1. 使用字面量新建对象

通过 new 操作符新建一个对象,类似于函数调用,同时会做一些关联原型链等操作,性能会慢很多,字面量则在写法上更直观友好且高效。

新建数组类似。

  1. 不要省略花括号

很多同学喜欢省略条件判断语句后面的花括号,像下面这样:

if (somethingIsTrue)
  a = 100
  doSomething()

这样的代码,你的目的可能是这样的效果:

if (somethingIsTrue) {
  a = 100
  doSomething()
}

但其实它会按这样执行:

if (somethingIsTrue) {
  a = 100
}
doSomething()

所以,还是老老实实地加上花括号,以避免上面这样的情况。

  1. 要不要加分号

近年来,加不加分号在 JS 中的讨论很激烈,如果你足够了解 JS 的解释机制,那么你可以选择不加分号,但是如果你仅仅是为了少写几个字符,我认为还是加上分号比较好。

注:主要有以下几个字符会引起 JS 上下文解析有误:括号,方括号,正则开头的斜杠,加号,减号。

还有一个参考标准是,这只是一个风格问题,应该根据你的项目风格而定,与团队保持一致最好。而且,成熟的 JS 的编译器都会判断什么地方该加分号,所以说,不加分号出错的概率极低,如果你能够采取更好的换行策略,不加分号是完全没问题的。

  1. 优先使用原生方法

虽然一些诸如 lodash、jQuery 这样的操作库大大提升 JS 开发者的生产力,但是,对于原生 JS 可以实现的功能,使用原生 JS 一般都会获得更快的解析速度。

例如这个例子:

$('input').on('focus', function() {
  if ($(this).val() === 'some text') { ... }
})

很明显,这里没有必要使用 val() 方法,我们可以使用原生方法代替:

$('input').on('focus', function() {
  if (this.value === 'some text') { ... }
})

DOM 操作优化

大量的 DOM 操作会引发页面卡顿,极耗性能,这是因为,在浏览器中,ECMAScript 的解释引擎和 DOM 的渲染引擎由两个部分实现,例如 Chrome 的 JS 引擎为 V8,而 DOM 则是 WebCore 实现。而 DOM 操作,你可以理解为跨模块操作,将 JS 和 DOM 比作两座岛屿,而操作 DOM,就是 JS 跨过大桥,去 DOM 岛上做文章,每次操作,就要过一次桥,频繁过桥的话,会引发巨大的性能损耗(参考文末《天生就慢的DOM如何优化?》)。

这个过桥过程,主要发生在以下的操作中:

  • 访问和修改 DOM 元素
  • DOM 元素的重绘(Reflow)或重排(Repaint)

这也是为什么现代框架都使用 virtual DOM 的原因之一。若不使用现代 JS 框架,DOM 操作的优化原则是:尽量减少过桥的次数,也就是尽量少地访问 DOM 元素,尽量减少 DOM 结构的重绘(Reflows)或重排(Repaints)

常用的优化策略有:

  1. 最小化 DOM 访问次数
  2. 合并多次 DOM 操作,一次性插入页面

当你需要对文档元素进行一系列操作时,应该是先将元素脱离文档,多重操作完成后,再插入文档(这一点经常通过 DocumentFragment 实现)

  1. 使用本地变量进行缓存频繁访问的 DOM 元素
  2. 不要遍历 HTML 元素集合,而是将它们转为数组之后执行

HTML 元素集合与底层的文档元素相关联,每次操作 HTML 元素,会引发元素集合的更新)

  1. 使用速度更快的 API

优先使用 querySelectorAll() 以及 querySelector() 方法获取元素。这两个方法返回的节点列表,不会对应实时的文档结构,也就避免了上一条提到的性能问题。

  1. 引发重排的动画元素脱离文档流之后再操作

动画操作引发的重排,很可能会影响整个文档流,引发页面卡顿,所以,可以将发生这类动画的元素,使用定位脱离文档流,出发 BFC,动画完成后,回归正常定位。

使用事件委托

试想这样一种场景,一个 ul 中有一大堆 li,你需要为所有的 li 元素绑定点击事件,最直观的方法是,循环为每一个 li 绑定:

for (let i = 0; i < uls.length; i++) {
  uls[i].onClick = function() {
    // do something...
  }
}

这种循环写法,一方面增加了内存开销,另一方面,每次点击时,增加了循环时间,损耗页面性能。这种情况的解决办法是:使用事件委托。

顾名思义,事件委托指的是,将事件的响应,委托到另外的元素上,一般指父元素或者上层元素。事件委托是利用 JS 的时间冒泡机制,子层的事件会向外层冒泡,所以,在事件发生元素的父元素以及更外层元素都可以监听到事件的发生。我们可以使用 addEventListener 来简单实现:

uls.addEventListener('click', function(e) {
  if (e.target.tagName.toLowerCase() === 'li') {
    // do something
  }
})

事件委托的好处是,动态添加的元素,都可以响应到。

编写更优雅的 JS 代码

程序员的工作,很大一部分并非只考虑解释器,而是要考虑和你合作的同事,在关注准确高效的业务逻辑的同时,代码的可读性、干净和优雅,是十分重要的。

所谓干净优雅,我的理解是,使得读你代码的人可以基本不依赖注释就可以顺畅地理解你的逻辑,和写作类似,第一要务是准确、简洁地传达信息。或者说,借用网络上的一个说法,优雅的代码是自解释的。如果你的代码被后来者拿到,一头雾水,怀疑人生,那就很有问题。以下是一些编写优雅 JS 代码的建议:

  1. 使用有意义的变量名称

翻开任何一本讲如何写好代码的书,这条都是要被反复提及的一点,但怎么强调都不为过,最基础的部分往往是最重要的部分。好的变量名,可以大幅度提高代码的可读性,不需要反复通过上下文逻辑去推敲。《代码大全》指出,好的变量名有以下的特征:

首先,它们很容易理解 好的名字应该尽可能明确。好的名字通常表达的是“什么”(what),而不是“如何”(how)。

至于具体的操作,我的建议是,打开你手头的项目,去看看你写下的变量名,想想有没有优化的地方,或者说,你自己写的代码,你能明确地知道眼前的变量表示什么吗?如果不能,那就不是一个好名字。

  1. 使用肯定的判断方法

以否定方法来做判断条件,会让人乍看过去很疑惑,例如 isNumNotValid,当其结合条件控制语句时,会大大增加阅读负担:

if (!isNumNotValid) { ... }

前面加上 ! 操作符后,很令人疑惑 num 到底应该是 valid 还是 not valid,应该改为 isNumValid

if (isNumValid) { ... }
  1. 避免冗余的代码

你的代码工作区就像一个营地,你离开的时候,不应该丢下大量垃圾。冗余的代码,主要指重复的代码,以及不会被执行到的代码,例如写在 return 语句之后的代码,以及一些“暂时”用到的 trick,或者测试代码,这些代码都会大大干扰代码的可读性。

所以,写代码的人应该常常读读自己的代码,看看有哪些代码时冗余的,及时地删除它们,并且可以采用一些策略来优化重复的代码,例如类的抽离,组件的抽离,模块化,变量的缓存,使用三元操作符代替条件判断语句等等。

  1. 好的函数具备哪些特征

在 JS 中,函数被称为一等公民,可见其重要性,编写好的函数,对于你的代码整洁和可读极其重要,那么好的函数有哪些特征呢?

  • 尽量短小。

一个函数如果动辄几百行,这对于后面的开发者是噩梦,过长的函数意味着逻辑复杂,或者干了太多事情,这时候你应该去优化,讲这样的函数进行分离。

  • 一个函数就应该只做一件事。

这一条和上一条呼应,如果一个函数干了太多事情,肯定会导致它变长,它的逻辑也会复杂不可控。

  • 尽量少的参数。

参数过多,同样意味着一个函数干了太多事情,所以,一般来讲,函数的参数最好不要超过3个。

  1. 跟随团队的风格指南

大部分开发团队都拥有自己的开发指南,例如业界著名的 Google、AirBnb 等都有自己的 JavaScript 指南,每个团队都应该制定适合自己的代码风格指南,一般包含了代码的风格以及一些最佳的实践策略等,按照指南的指引,勤于进行 code review,这样,才能打造一个战斗力超强的队伍。

小结

本篇主要从代码层面提出了一些 JavaScript 应该注意的优化写法,对于开发者来讲,我们常常是面向项目进行编程,所以,这要求我们在深入代码的同时,又要学会跳出来,从工程化的层面去考虑,现代流行的 JS 框架,正是从整体架构的角度来优化整个 JS 项目的写法,在学习这些框架的时候,我们更应该去考虑 JS 底层的东西,它们到底在解决什么问题?而这些问题,很大一部分就是和这里所说的性能以及最佳实践息息相关的,这也是开发者从一个简单的码农向工程师升级的关键所在。

参考资料