萝三画室

深入理解JS-part7-性能优化

本文是深入理解JS系列的part7,也是《高性能JavaScript》的读书笔记。本文基于一个正常的JS开发周期,从加载、编码、部署、测试四大方面展开讨论,力求在各个阶段提升Web应用性能。

参考书籍:《高性能JavaScript》

加载

深入理解JS-part2-单线程、异步和setInterval中我们知道,JS单线程执行,浏览器的JS引擎和渲染引擎互斥,同一时刻只有一个引擎运行,另一个被挂起。这也就意味着,每次script标签出现时,他都会阻塞页面渲染,等到脚本解析和执行完后再继续渲染。

那么如何减少阻塞呢?我们可以从脚本的位置、数量、加载方式三个方面着手。

  1. 位置 — 尽可能将script标签放到body标签的底部
    HTML 解析是由上至下依次进行的,将script标签放在body标签底部,就是等到DOM元素几乎全部渲染完毕的时候,再来处理脚本,这样就减少了脚本执行对整个页面下载的影响。
  2. 数量 — 合并多个脚本文件为一个
    每个script标签都会阻塞页面渲染,并且对于外链脚本还有额外的http请求开销,因此减少页面包含的script标签有助于改善阻塞情况,提高页面性能。
  3. 加载方式
    前面两种方法在JS代码体量较大的场景下表现不尽如人意。比如我们将多个脚本合并为一个并把它放在body标签底部,但如果这个脚本过大,下载、解析和执行耗时较多,就可能让浏览器假死一段时间。这时我们考虑,向页面中逐步加载脚本。
  • 用script标签的defer、async属性延迟加载脚本
    用defer、async属性的script标签可以放在文档的任意位置,它对应的脚本会在解析到此标签时开始下载,但并不会执行,下载不会阻塞产生阻塞,所以在下载脚本的同时可以并行渲染页面。
    defer和async属性有一些区别:
    defer属性对应的脚本会在所有DOM加载完成后,按照在script标签出现的顺序依次解析并执行;async属性对应的脚本会在脚本下载完立即解析和执行,并且顺序不是按照script标签出现的顺序,而是按下载完成的顺序。
  • 动态创建script元素
    script标签和其他元素如div、span是一样的,都可以通过DOM进行创建、修改、删除、移动等操作。通过这种方式创建的script元素,会在该元素被添加到页面时启动下载,并在下载完成后立刻执行。由于script没有触发渲染树的重绘,因此渲染引擎会在脚本下载的继续渲染剩下的DOM,在脚本执行时暂停渲染。动态script元素接收完成时会触发onload事件(IE是readstatechange事件),可以侦听此事件来获得脚本加载完成时的状态。通过这种方式创建的是外链脚本。
  • XMLHTTPRequest脚本注入
    通过XHR对象向后端请求JS文件,然后动态创建一个script元素,再将XHR对象返回脚本文件的内容作为文本赋值给script元素的text属性。也就是说,我们通过XHR请求得到脚本文件的内容,然后创建了一个内联脚本。这种方法的优点是,脚本的下载和执行分离,发出XHR请求时开始下载,script元素被添加到页面时执行。缺点是受同源策略限制,无法跨域。

结合以上方法,我们可以总结出来一个最大程度上减少阻塞的加载流程。
在减少脚本数量、将script标签写在body元素闭合前的前提下:

  1. 首先动态加载不参与页面初始化的脚本
  2. 第一步加载完成后,加载参与页面初始化的代码

编码

数据存取

数据的存储位置会很大程度上影响其读取速度。JS具有四种基本数据存储位置:字面量、本地变量、数组元素和对象成员。

  1. 字面量和局部变量的访问速度比数据元素和对象成员快
    因此,尽量使用字面量和局部变量可以节省查找开销。
  2. 变量在作用域链上的位置越深,查找开销越大。
    因此,如果一个跨作用域的变量如果被引用一次以上,就声明一个局部变量将它保存在本地。
  3. with、try-catch、eval会在执行时临时改变作用域链,将一个全新的对象推入作用域链的首位,其它位置顺序向后推一位,这就增加原本位于作用域链上的其它变量的访问开销。
    因此,尽量避免使用with、eval,谨慎使用try-catch
  4. 闭包也是跨作用域读取数据,并且对内存也存在影响
    因此,谨慎使用闭包
  5. 位于原型链越深的对象成员,访问开销越大
    多次访问原型链上的成员时,考虑将其保存为局部变量使用。

DOM编程

利用JS操作DOM元素,可能是对Web性能影响最大的因素了。DOM是一个独立于语言的API,它用于操作XML和HTML文档。浏览器通常将DOM和JS分离实现,二者是相互独立的。这就意味着,JS每访问一次DOM,就要付出代价产生消耗。访问次数越多,消耗就越大。

还需考虑的一点是,JS对DOM的操作,可能引发重排和重绘。重排和重绘都是开销很大的操作,应该尽量减少这类过程的发生。

基于以上思路,我们可以从以下几种方法提高Web性能:

  1. 尽可能减少DOM访问次数,如需多次访问某个DOM节点,用局部变量保存改节点的引用
  2. 使用节点克隆代替创建新元素
  3. DOM节点的集合参与循环时,将集合以及其length属性保存为局部变量(否则相当于每次循环都重新查询)。如果集合元素数量较多时,可将集合转换为数组存在局部环境中。
  4. 选择符API包括两大类:getElement系列和querySelector系列。当需要处理大量组合查询时,querySelector系列效率更高,它使用CSS选择器作为参数,通常可以一步到位;其它情况使用getElement系列效率更高。注意querySelector系列获取的是静态集合,不会随DOM更新而变化。
  5. 改变DOM元素的样式时,尽量合并所有修改一次处理,或者直接改变元素的css类。
  6. 需要对DOM进行一系列操作时,可以先使元素脱离文档流,再应用各种改变,最后将修改后的元素带回文档。
    有三种基本方法实现上述效果:
    • 隐藏元素: display:none;->修改->display:block/….
    • 使用文档片段: document.creatDocumentFragment();->修改->appendChild();
    • 拷贝副本修改: element.cloneNode();->修改->element.parentNode.replaceChild();
  7. 让动画脱离文档流,如展开/折叠
  8. 使用事件代理处理其子元素上触发的事件,这直接减少了DOM元素上绑定的事件处理器的数量,占用更少的内存,减轻了浏览器的负担。

算法和流程控制

JS代码的整体结构也是影响运行速度的主要因素之一。代码数量和运行速度并没有绝对的关系,影响速度的主要还是组织结构和具体思路。

  1. 循环
    在各种遍历方法中,只有for in循环速度明显比其他方法慢,因此追究循环的选型并没有多大意义,我们应该通过减少迭代工作量和减少迭代次数来提高性能。具体方法有:

    • 把控制条件保存为局部变量。比如for 循环中一般的控制条件是数组或对象长度,那么我们就可以将长度存为局部变量,避免在每次循环时都要获取一遍长度。如:

      1
      2
      3
      for( let i = 0, len = arr.length; i<len; i++){
      dosomething();
      }
    • 倒序循环,减少控制条件。

      1
      2
      3
      for( let i = arr.length; i--; ){
      dosomething();
      }
    • 减少迭代次数,具体实现参照达夫设备。

  2. 条件判断
    • i使用if-else语句时,将最可能出现的情况放在最前面
    • switch总是比if-else毒素更快,但需要结合具体需求觉得使用哪种方法
    • 当单个键和值存在映射关系时,可以用查找表的方式代替条件判断。
  3. 递归和迭代
    浏览器的调用栈大小限制了递归算法的应用,栈溢出错误会终端其他代码的运行,此时应将递归改为迭代或者使用Memoization避免重复计算

字符串和正则表达式

高效处理字符串和正则表达式:

  1. 数组合并(arr.join())是最慢的字符串拼接方法之一,推荐使用+和+=代替
  2. 考虑实际需求,JS原生提供了许多字符串查找方法,正则并不一定总是最好的选择
  3. 关注让匹配更快失败、使用非捕获组、减小分组数量和范围、将一个复杂的正则拆分为多个

UI响应

这里我们关注的是用户体验。让页面快速响应用户的操作,也是一个衡量性能的关键点。

比如用户点击页面上的某个按钮,这个按钮被点击后悔产生样式变化,并且绑定了一个onclick事件。

我们知道,JS是单线程执行的。如果在用户点击按钮的时候,JS引擎处于空闲状态,那么样式改变和Onclick事件将会立即执行;
如果此刻JS引擎正在运行其他代码的,那么这些事件会被推入到事件队列中,等待排在前面的事件执行完毕之后才会执行,这就产生了一个延迟。

我们要做的,就是尽可能缩短这个延时,让用户觉得网页立即响应了他的操作(其实并不是)。根据研究表明,单个JS操作花费的事件不应该超过100毫秒。我们的目标就是在用户操作100毫秒之内,让页面响应用户的操作。我们的解决方案大致分为2种:

  1. 用定时器将耗时较长的任务分割成一系列小任务。
    通过定时器将耗时较长的同步任务分割为异步小任务,可以实现“让出时间片段”。但是注意不能过度使用定时器,多个重复的定时器同时创建可能往往会出现性能问题。
  2. Web Workers
    Web workers创建了自己的独立线程,也就是说它可以与JS引擎线程并行运行。这样我们可以将一些不涉及到DOM更新的、耗时较大的操作放在web workers中(比如解析JSON数据),让它单独运行。

一个Web Workers是一个独立的JS文件,它不具有全局的window属性,只有只读的navigator属性、location属性,并独有一个self对象指向自身。在网页中,我们通过通过new Worker(jsPath)新建一个worker,利用postMessage传递数据,利用onmessage事件执行收到数据后的操作。

一个简单的栗子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//网页
//新建worker
let worker = new Worker("parseJSON.js");
//worker返回数据时执行
worker.onmessage = ( ev ) => {
let jsonData = ev.data;
use(jsonData);
};
//给worker传递数据
worker.postMessage(jsonText);
//worker
//网页中传入数据时执行
self.onmessage = ( ev ) => {
let jsonText = ev.data,
jsonData = JSON.parse(jsonText);
//将数据传回网页
self.postMessage(jsonData);
};

——本节完——