high performace javascript overview

最近看完了高性能javascript,粗略的回顾一下各章知识点。由于看到是英文原版,有些翻译成中文的内容可能不太准确,附上原文做参考。
因为这本书是2010年写的,很多内容不适合当代浏览器,比如第四章讲到的循环算法Duff’s Device, 实测在chrome浏览器中不适用。还有文末提到的构建工具和性能测试工具,都很旧了,所以谨慎参考。

Chapter 1: the most optimal ways to load javascript

Browsers don’t start rendering on the page until the opening tag is encountered.
Every time a <script> tag is encountered, the page must stop and wait for the code to download and execute before continuing to process the rest of the page. UI 渲染和js代码不能同时运行,所以js代码运行时一定会阻塞ui更新。

Chapter 1 Summary

  • 尽量把所有<script> 标签放在</body>前面,让页面在js代码执行前尽可能的渲染
  • 合并js代码,尽量少地加载<script>

Chapter 2 - 8: specific programming techniques to help js code run faster

Chapter 2: Data Access

数据的存放位置会影响到对数据内容的读写速度。此外数据的访问速度还与所处的作用域有关。作用域链从最前面到最末尾分别是:本地作用域(当前执行环境的作用域)、外层执行环境作用域、一次类推最后是全局环境作用域。访问变量越靠近作用域链前端,访问速度越快。

4种数据在代码中的存放

  • 字面值 (literal value): 经代表自身没有被赋值给其他任何东西。如’some string’, 27, true/false, null, undefined, {a: 1, b: 2}, [1,2,3], 匿名函数等等

  • 存在变量中 (variables): 被赋值给变量。如var str = 'some string';, var obj = {a: 1, b: 2}, var arr = [1,3,2]

  • 存在数组成员中 (array items): 存放在数组的索引下。 如arr[0], arr[1]

  • 存在对象成员中 (object members): 存放在对象属性下。如obj.a, ob['b']

Chapter 2 Summary

  • 相同作用域内访问速度:字面值 > 变量 > 数组成员 > 对象成员。 数组成员以数字作为索引相对会比对象成员访问更快。

  • 数据所处作用域越靠近作用域链前端,访问速度越快

  • 避免使用with,高效实用catch(实用统一的handlerError方法去处理错误,避免在catch内部又过多的数据读写)。

    with和try-catch的catch都会加长作用域链(会动态创建一个新的variable object并加到scope chain的最前端)。高效的js擎会只需要通过静态分析就能确定每个变量、函数的作用域,进而确定哪些变量可以在任意时刻被访问。这种作用域也称为静态作用域。可以加速数据的访问。with和catch都是在代码运行时创建作用域,属于动态作用域。无法仅通过静态技术实现,会导致运行时对变量的访问退回到传统访问方式。以下是书中原文:

    Optimizing JavaScript engines such as Safari’s Nitro try to speed up identifier resolution by analyzing the code to determine which variables should be accessible at any given time. These engines try to avoid the traditional scope chain lookup by indexing identifiers for faster resolution. When a dynamic scope is involved, however, this optimization is no longer valid. The engines need to switch back to a slower hash-based approach for identifier resolution that more closely mirrors traditional scope chain lookup

  • 嵌套对象成员的访问会对性能产生重大影下,应尽可能少的使用

  • 属性、方法在原型链的位置越深,访问越慢

  • 可通过将常用对象、数组、外部变量缓存为局部变量的做法提高访问效率。但是注意最好不要将对象方法存为局部变量,因为对象方法内部可能使用了this关键字,缓存最想方法可能会导致this指向变化。

Chapter 3: DOM scripting

DOM

文档对象模型是一个独立于语言的,对XML和HTML文档进行操作的应用程序接口(API)。DOM API在浏览器中的接口是通过javascript实现的。

为什么操作DOM会带来性能损耗?

Simply having two separate pieces of functionality interfacing with each other will always come at a cost.

原文中对此有一个比喻,我觉得非常形象:

An excellent analogy is to think of DOM as a piece of land and JavaScript (meaning ECMAScript) as another piece of land, both connected with a toll bridge. Every time your ECMAScript needs access to the DOM, you have to cross this bridge and pay the performance toll fee. The more you work with the DOM, the more you pay.

重排和重绘

浏览器并在并解析完所有的文件(html, js, css, 图片资源等),会创建两个内部数据结构:

  • DOM 树:表示页面结构,包含所有显示和隐藏的节点(隐藏节点可能是注释、<script>、display为none的元素等)

  • 渲染树:表示页面是如何渲染的,包含所有需要显示在页面的节点
    树构造完毕开始绘制页面元素

重排

DOM元素的几何属性(宽高)发生变化,甚至影响到其他元素的大小和位置。导致浏览器需要重构受影响部分的渲染树,这个过程叫重排。

重绘

重排完成后重新绘制受影响的部分叫重绘。注意几何属性发生变化才会导致重排,backgound color这种变化仅仅导致重绘

导致重排的场景

  • 添加、删除可见元素
  • 元素position变化
  • 元素尺寸变化(width/height/padding/border/margin, etc)
  • 元素内容变化
  • 第一次渲染时
  • window resize

批量修改DOM如何减少重排、重绘

原理:先将元素从文档流移除,应用修改,重新插入元素

  • 使用document fragment,应用修改,插入片段。注意像文档添加document fragment,实际添加的是片段内部的所有子节点群,而不会添加片段本身

    最推荐这个方法,因为它涉及最少数量的重排和重绘

  • 隐藏元素(display: none),应用修改, 显示元素(display)

  • 克隆原始节点、克隆修改、覆盖原始节点

Chapter 3 Summary

  • 最小化DOM访问,让javascript做尽可能多的事

  • 使用局部变量存储高频访问的dom引用

  • 谨慎处理HTML collections(如document.getElementsByClass的返回值),如someCollection.length会实时返回集合的长度,总是给出最新的值。HTML collections are alive.

  • 使用速度更快的api,如querySelectorAll(), firstElementChild等

  • 浏览器通过队列存放修改内容最后批量修改的方式减少重排次数,但是对布局信息的访问会导致立即重排。尽量减少对布局信息的查询次数

  • 通过绝对定位将必要元素提出文档流,减小渲染树重构面积

  • 使用事件委托

    元素与event handler之间的关联也是有代价的(关联元素与event handler占用处理时间和浏览器保存handler记录占用内存)。使用时间委托最小化handler数量

Chapter 4: Algorithms and Flow Control (算法和流程控制)

这一章中的一些知识点对于现代浏览器并不适用。比如作者提出的处理大量循环时使用的Duff’s Device算法在现代chrome中并不能提高效率,反而比for循环更慢。 所以本章只对常用技巧进行总结。

Chapter 4 Summary

  • for, while, do-while效率差不多,但是比forEach等方法快。

  • 逆序循环比正序效率更高

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
let arr = [1,2,3,4,5]
let len = arr.length
let i = 0, j = len
/* 正序 执行步骤
1. 读取i
2. 读取len
3. 比较(i<len)
4. 判断比较结果(true/false)
5. 如果true,console.log
6. i++, 回到第1
*/
while(i < len) {
console.log(arr[i])
i++
}

/* 逆序 执行步骤
1. 读取j
2. 判断j是否非0
3. 如果非0,j--
4. console.log, 返回第1
*/
while(j--) {
console.log(arr[j])
}
  • 除非遍历属性未知的对象,避免使用for-in loop

  • 改善循环效率的方法:减少迭代次数; 减少每次迭代中的运算量

  • 判断条件较多时使用lookup table比if-else或switch更快

  • 浏览器的调用栈(call stack)尺寸会限制递归算法的应用,可使用迭代代替

  • 使用memoization减少重复工作

    通过缓存先前计算结果方便后续计算再利用。有效减少重复工作,尤其在递归中。
    原文解释:

    Memoization is an approach to avoid work repetition by caching previous calculations for later reuse, which makes memoization a useful technique for recursive algorithms.

    两段代码解释Memoization算法:

1
2
3
4
5
6
7
8
9
10
11
function factorial(n) {
if (n == 0) {
return 1;
} else {
return n * factorial(n-1);
}
}

var fact6 = factorial(6); //加上factorial(6)本身,factorial被调用了7次
var fact5 = factorial(5); // 调用了6次
var fact4 = factorial(4); // 调用了5次

改进后

1
2
3
4
5
6
7
8
9
10
11
12
function factorial(n) {
if (!factorial.cache) {
factorial.cache = {
"0": 1,
"1": 1
};
}
if (!factorial.cache.hasOwnProperty(n)) {
factorial.cache[n] = n * factorial (n-1);
}
return factorial.cache[n]
}

Chapter 5: String and Regular Expressions

Chapter 5 Summary:

  • 计算速度: + 、 += > array.join

  • 正则表达式相关的内容及其匹配原理我觉得是另一个全新的话题了。它的回溯(backtracking)查找使其基本组成部分,也是影响效率的因素。有兴趣的可以去理解一下正则匹配的原理。

  • 关于space trimming,文中内容过于老化(作者提了一些编程方法来修正字符串),不适用如现代javascript。现在js中完全可以用String.prototype.trim实现。

Chapter 6: Responsive Interfaces 响应接口

Javscript和用户界面更新在同一个进程下运行,所以js运行时会阻塞页面响应,反之亦然。因此确保javascript不会运行太长时间阻塞用户与页面交互,影响用户体验。

下面这段代码说明js运行对ui的阻塞,

1
2
// html
<input id="textInput" class="custom" size="32">

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
/*
Browser Event Loop:
step1: execute all tasks in stack
-> step2: execute all micro tasks
-> step3: render UI
-> step4: execute first macro task
-> back to step2 - step4 until no more macro task then start new round of Event Loop

Note:
javasript execution will definitly prevent UI rendering. Cause javascript is single process.
It can either update UI or execute js one at a time. The way to free up UI rendering is to put js
code into macro task queue such as use a timer function
*/

function test(e) {
console.log('gonna sleep');
sleep(5000);
console.log('awake');
}
function sleep(duration) {
// ui won's update until code bellow done execution
let time1 = +new Date()
let time2
do {
time2 = +new Date()
} while(time2 - time1 < duration)

// code bellow won't prevent ui update
// setTimeout(() => {
// console.log('timer done')
// }, duration)
}

var input = document.querySelector('#textInput')
input.addEventListener('input', test)

向input输入‘a’,会执行test,js运行阻塞了ui更新,所以等待5秒后ui才会更新,所以用户输入’a’的头5s是没有看到页面有任何变化的。

Chapter 6 Summary

  • javascript不应该运行超过100ms,过长时间导致ui更新出现可察觉的延迟,影响用户体验。

  • timer(即定时器,eg. setTimeout)可用于安排代码推迟执行。使得可以将长运行脚本分解成较小任务。

  • Web Worker允许在ui线程外运行js,避免锁定ui。

  • 用户体验是最重要的,越大型的项目越要有效管理ui线程。

Chapter 7: Ajax

这部分内容我觉得也是比较旧了,总结一些常用的:

Chapter 7 Summary

  • 减少请求数量。网络请求都存在网络时延(这句是我自己加的)

  • 友好处理错误,不要直接显示给用户

Chapter 8: Programming Practice

这一章讲了一些通用技巧来优化js性能

Chapter 8 Summary

  • 避免使用eval和new Function()避免二次评估,另外给setTimeout和setInterval传递函数作为参数而不是传递字符串。

    这四个函数允许传入一串包含代码的字符串作为参数,js运行时会首先将代码当成正常代码执行,然后运行时会发生另一次评估,运行字符串中的代码。二次评估的代价昂贵,会占用更长时间。

  • 创建对象、数组是使用字面量,比使用他们的构造函数初始化更快

  • 避免重复工作,尽量使用延迟加载(lazy loading)或条件加载(conditional loading)

  • 执行数学运算时,考虑使用位运算符,它直接在数字底层进行操作,运算更快

    位运算的使用场景

    1. 常规数学计算
      1
      2
      3
      4
      5
      6
      var i = Math.floor(Math.random() * 100)
      if (i % 2) {
      console.log('even')
      } else {
      console.log('odd')
      }
    1
    2
    3
    4
    5
    6
    var i = Math.floor(Math.random() * 100)
    if (i & 1) {
    console.log('odd')
    } else {
    console.log('even')
    }
    1. 位掩码技术(bitmask): 可以同时判断多过选项返回布尔值,或者创建一个数字包含多个选项
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      var cat1 = 1 // 代表 'food' - 二进制 01
      var cat2 = 2 //'fruit' - 二进制 10
      var cat3 = 4 //'vegetable' - 二进制 100
      var cat4 = 8 //'beverage' - 二进制 1000

      // 对二进制位做或操作,其结果其实和cat1 + cat2相同,只是位运算更快
      var appleIs = cat1 & cat2 // 二进制码 11

      // 位掩码
      var isAppleFood = appleIs & cat1 // 11 & 01 --> 01 与cat1的值相等,非零表示为true
      var isAppleFruuit = appleIs & cat2 // 11 & 10 --> 10 与cat2的值相等
      var isAppleVege = appleIs & cat3 // 011 & 100 --> 000 即0
  • 原生方法运行更快,尽量使用原生方法

Chapter 9: the best to build & deploy js files

本章介绍了javscript构建和部署过程的基本知识,还给出了一些工具及用法,但过于陈旧不适合不适合现代前端构件流程。只记录一些基本知识。

Chapter 9 Summar

  • 构件流程的基本步骤:
    • 合并js文件,尽可能减少<script>的使用
    • 压缩文件
    • 以压缩形式(gzip)提供js文件
    • 配置缓存
    • 使用CDN提高性能

Chapter 10: performance tools that help identify further issues

本章介绍了一些javascript的性能测试工具。其中有一些工具已经过时,而且我没有全部研究过上面提到的工具,对于个人使用的性能分析工具有自己的喜好(eg. Chrome Audit),所以这一章略过。