本文是阅读《高性能JavaScript》一书后,从 快速响应的用户界面 模块对JavaScript性能优化做了部分总结,记录一下。可能总结的不好,不是很完整,也希望各位大佬能多给出一些建议。万分感谢!


浏览器 UI 线程

用于执行 JavaScript 和更新用户界面的进程通常被称为“浏览器 UI 线程”。UI 线程的工作基于一个简单的队列系统,任务会被保存到队列中知道进程空闲。一旦空闲,队列中的下一个任务就被重新提取出来并运行。这些任务要么是运行 JavaScript 代码,要么是执行 UI 更新,包括重绘和重排。一次输入(包括:响应用户时间,自执行的 JavaScript 代码等)可能会导致一个或多个任务被加入队列。

当所有 UI 线程任务都执行完毕,进程进入空闲状态,并等待更多任务加入队列。空闲状态是理想的,因为用户所有的交互都会立刻出发 UI 更新。如果用户试图在任务运行期间与页面交互,不仅没有即时的 UI 更新,甚至可能新的 UI 更新任务都不会被创建并加入到队列。事实上,大多数浏览器在 JavaScript 运行时会停止把新任务加入 UI 线程的队列中,也就是说 JavaScript 任务必须尽快结束,以避免对用户体验造成不良影响。

浏览器限制

浏览器限制了 JavaScript 任务的运行时间。这种限制是有必要的,它确保某些恶意代码不能通过永不停止的密集操作锁住用户的浏览器或计算机。此类限制分两种:调用栈大小限制和长时间运行脚本限制

有两种方法可以度量脚本运行了多“长”:

  1. 记录脚本开始以来执行的语句的数量

  2. 记录脚本执行的总时长

不同浏览器检测长时间运行脚本的方法会略有不同:

  • IE 自第 4 版开始,设置默认限制为 500 万条语句;此限制存放在 Windows 注册表中,叫做 HKEY_CURRENT_USER\Software\Microsoft\InternetExplorer\Styles\MaxScriptStatements

  • Firefox 的默认限制时间为 10 秒;该限制记录在浏览器配置设置中(通过地址栏输入 about: config 访问),键名为 dom.max_script_run_time

  • Safari 的默认限制为 5 秒;该限制无法更改,但是你可以通过 Develop 菜单选择 DisableRunaway JavaScript Timer 来禁用定时器。

  • Chrome 没有单独的长运行脚本限制,替代做法是依赖其通用崩溃检测系统来处理此类问题

  • Opera 没有长于宁脚本限制,它会继续执行 JavaScript 代码知道结束,鉴于 Opera 的架构,脚本运行结束时不会导致系统不稳定

多久才算“太久”

浏览器允许脚本持续运行好几秒,但并不意味着你也允许它这样做。

单个 JavaScript 操作花费的总时间(最大值)不应该超过 100 毫秒。这个数字源自 Robert Miller 于 1968 年的研究。

IE 会控制用户交互行为触发的 JavaScript 任务,因此它会识别连续两次的重复的动作。例如,当有脚本运行时点击一个按钮四次,最终按钮的 onclick 事件处理器只被调用两次。


使用定时器让出时间片段

尽管你进了最大努力,但难免有一些复杂的 JavaScript 任务不能再 100 毫秒内完成。这个时候,最理想的方法是让出 UI 线程的控制权,使得 UI 可以更新,然后再继续执行 JavaScript。于是引入了 JavaScript 定时器。

定时器基础

在 JavaScript 中可以使用 setTimeout() 和 setInterval() 创建定时器,他们接收相同的参数:要执行的函数和执行前的等待时间。setTimeout() 创建一个只执行一次的定时器,而 setInterval() 创建了一个周期性重复运行的定时器。
这个执行前等待时间是从 setTimeout() 或 setInterval() 调用时开始计算,而不是在整个函数运行结束后才开始计算。

setTimeout() 和 setInterval() 几近相同,除了前者会重复添加 JavaScript 任务到队列。它们最主要的区别是,如果 UI 队列中已经存在由同一个 setInterval() 创建的任务,那么后续任务不会被添加到 UI 队列中。

每个定时器的真实延时时间在很大程度上屈居于具体情况。普通来讲,最好使用至少 25 毫秒,因为更小的延时,对大多数 UI 更新来说不够用。

定时器的精度

JavaScript 定时器延迟通常不太精准,相差大约几毫秒。因此,定时器不可用于测量实际时间。

定时器延时的最小值有助于避免在其他浏览器和其他操作系统中的定时器出现分辨率问题。大多数浏览器在定时器延时等于或小于 10 毫秒时表现不太一致。

使用定时器处理数组

  • 使用定时器取代循环的两个决定性因素:

    1. 处理过程是否必须同步?否
    2. 数据是否必须按顺序处理?否
  • 实例代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    // 普通循环
    for (var i = 0, len = items.length; i < len; i++) {
    process(items[i]);
    }

    // 使用定时器处理

    var todo = items.concat(); // 克隆原数组

    setTimeout(function() {

    // 取得数组的下个元素并进行处理
    process(todo.shift());

    // 如果还有需要处理的元素,创建另一个定时器
    if (todo.length > 0) {
    setTimeout(arguments.callee, 25);
    } else {
    callback(items);
    }
    }, 25);

    这种优化模式的基本私立:创建一个原始数组的克隆,并将它作为数组向队列来处理。第一次调用 setTimeout() 创建一个定时器处理数组中的第一个条目。调用 todo.shift() 返回它的第一个条目然后把它从数组中删除。这个值作为参数传给 process()。处理完后,检查是否还有更多条目需要处理。如果 todo 数组中还有条目,那么就再启动一个定时器。因为下一个定时器需要运行相同的代码,所以第一个参数为 arguments.callee。该值指向当前正在运行的匿名函数。如果不再有条目需要处理,那么调用 callback() 函数。

  • 功能封装

    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
    // 封装后的函数
    function processArray(items, process, callback) {

    var todo = items.concat(); // 克隆原数组

    setTimeout(function() {

    // 取得数组的下个元素并进行处理
    process(todo.shift());

    // 如果还有需要处理的元素,创建另一个定时器
    if (todo.length > 0) {
    setTimeout(arguments.callee, 25);
    } else {
    callback(items);
    }
    }, 25);
    }

    // 使用
    var items = [123, 789, 323, 778, 232, 654, 219, 543, 321, 160];

    function outputValue(value) {
    console.log(value);
    }

    processArray(items, outputValue, function() {
    console.log('Done!');
    })

使用定时器处理数组的副作用是处理数组的总时长增加了。因为在每一个条目处理完后 UI 线程会空闲出来,并且在下一条目开始处理之前会有一段延时。尽管如此,为避免锁定浏览器给用户带来的糟糕体验,这种取舍是有必要的。

分割任务

我们通常会把一个任务分解成一些列子任务。如果一个函数运行时间太长,那么检查一下是否可以把它拆分成一系列能在较短时间内完成的子函数。

  • 例如:

    1
    2
    3
    4
    5
    6
    7
    8
    function saveDocument(id) {
    openDocument(id);
    writeText(id);
    closeDocument(id);

    // 将成功信息更新至界面
    updateUI(id);
    }
  • 如果这个函数运行时间太长,可以把每个独立的方法放在定时器中调用。使用上一节提到的数组处理模式:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    function saveDocument(id) {

    var tasks = [openDocument, writeText, closeDocument, updateUI];

    setTimeout(function () {

    // 执行下一个任务
    var task = tasks.shift();
    task(id);

    // 检查是否还有其他任务
    if (tasks.length > 0) {
    setTimeout(arguments.callee, 25);
    }
    }, 25)
    }
  • 封装以备用:

    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

    // 功能函数
    function multistep(steps, args, callback) {
    var tasks = steps.concat();
    setTimeout(function() {

    // 执行下一个任务
    var task = tasks.shuift();
    task.apply(null, args || []);

    // 检查是否还有其他任务
    if (tasks.length > 0) {
    setTimeout(arguments.callee, 25);
    }
    }, 25)
    }

    // 调用时
    function saveDoucment(id) {

    var tasks = [openDocument, writeText, closeDocument, updateUI];
    multistep(tasks, [id], function() {
    alert("save completed!");
    })
    }

    正如数组处理那样,使用此函数的前提条件是:任务可以异步处理而不影响用户体验或造成相关代码错误。

记录代码运行时间

  • 使用原生的 Date 对象来跟踪代码的运行时间
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    var start = + new Date();
    var stop;

    someLongProcsee();

    stop = + new Date();

    if(stop - start < 50) {
    alert('Juest about right.');
    } else {
    alert('Taking too long.);
    }

定时器与性能

当多个重复的定时器同时创建旺旺会出现性能问题。因为只有一个 UI 线程,而所有的定时器都在争夺运行时间

优化建议:

  • 在 Web 应用重限制高频率重复定时器的数量

  • 创建一个独立的重复定时器,每次执行多个操作

Web Workers

每个新的 Worker 都在自己的线程中运行代码。意味着 Worker 运行代码不仅不会影响浏览器 UI,也不会影响其他 Worker 中运行的代码

Worker 运行环境

  • 一个 navigator 对象,只包括四个属性:appName、appVersion、user Agent 和 platform

  • 一个 location 对象(与 window。location 相同,不过所有的属性都是只读的)

  • 一个 Self 对象,指向全局 Worker 对象

  • 一个 importScripts() 方法,用来加载 Worker 所用到的外部 JavaScript 文件

  • 所有的 ECMAScript 对象,诸如:Object、Array、Date 等

  • XMLHttpRequest 构造器

  • setTimeout() 和 setInterval() 方法

  • 一个 close() 方法,它能立刻停止 Worker 运行

与 Worker 通信

消息系统是网页和 Worker 通信的唯一途径

  • 网页代码可以通过 postMessage() 方法给 Worker 传递数据,它接受一个参数,即需要传递给 Worker 的数据

  • Worker 还有一个用来接收信息的 onmessage 事件处理器

  • 例子:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15

    // 创建 Worker
    var worker = new Worker('code.js');
    worker.onmessage = function(event) {
    // 此处的 event.data 是 code.js 中组合之后的字符串
    alert(event.data);
    }

    worker.postMessage('any message');

    // code.js 内部
    self.onmessage = function(event) {
    // 此处的 event.data 是上面传过来的 'any message'
    sele.postMessage('Hello, ' + event.data + '!');
    }

加载外部文件

Worker 通过 importScripts() 方法家在外部 JavaScript 文件,该方法接受一个或多个 JavaScript 文件URL 作为参数。

importScripts() 调用过程是阻塞式的,知道所有文件加载并执行完成之后,脚本才会继续执行。由于 Worker 在 UI 线程之外运行,所以这种阻塞并不会影响 UI 相应

1
2
3
4
5
6
// code.js 内部代码
importScripts('file1.js', 'file2.js');

self.onmessage = function(event) {
self.postMessage('Hello, ' + event.data + '!');
}

实际应用

  • 适用于处理纯数据或者与浏览器 UI 无关的长时间运行脚本

  • 解析一个大字符串

  • 编码/解码大字符串

  • 复杂数学运算(包括图像或视频处理)

  • 大数组排序

  • 任何超过 100 毫秒的处理过程

解析一个很大的 JSON 字符串的例子:

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
/**
* 创建 Worker 的代码
*/

var worker = new Worker('jsonparser.js');

// 数据就位时,调用事件处理器
worker.onmessage = function(event) {

// JSON 结构被回传回来
var jsonData = event.data;

// 使用 JSON 结构
evaluateData(jsonData);
};

worker.postMessage(jsonText);

/**
* jsonparser.js 内部代码
*/
self.onmessage = function(event) {

// JSON 字符串由 event.data 传入
var jsonText = event.data;

// 解析
var jsonData = JSON.parse(jsonText);

// 回传结果
self.postMessage(jsonData);
}

小结

  • 任何 JavaScript 任务都不应当执行超过 100 毫秒。过长的运行时间会导致 UI 更新出现明显的延迟,从而对用户体验产生负面影响

  • JavaScript 运行期间,浏览器响应用户交互的行为存在差异。无论如何,JavaScript 长时间运行将导致用户体验变得混乱和脱节

  • 定时器可用来安排代码延迟执行,它使得你可以长时间运行脚本分解成一系列的小任务

  • Web Workers 是新版浏览器支持的特性,它允许你在 UI 线程外部执行 JavaScript 代码,从而避免锁定 UI

Web 应用越复杂,积极主动地管理 UI 线程就越重要。即使 JavaScript 代码再重要,也不应该影响用户体验。