读书笔记 – 浏览器端高性能JavaScript编程1

一份良好的JavaScript代码,不仅要美观,还得执行效率高。正所谓上得了厅堂下得了厨房……
在规范先行的今天,使用一套代码规范和预编译工具可以让我们的代码质量跃层提升,但是这还不够。
我希望自己的代码更优雅,执行效率更高。我认为,代码不仅仅是给人看的,还得是机器喜欢的那种口味~
本篇是我开始这个系列的第一部分。

了解执行环境

当前讨论的范畴限定在浏览器中执行JavaScript。我想先把JS与浏览器的关系屡清楚,以后再说NodeJS。
我们经常听说,现代主流浏览器的内核有:
Blink(Chrome, Opera), Webkit(Safari), Gecko(Firefox), EdgeHTML(MS Edge) 。
内核引擎通常主宰了网页的layout渲染。

而现代浏览器layout引擎与JS引擎是分离的,目前主流JS引擎有:
V8(Chrome), Nitro(Safari), SpiderMonkey(Firefox), Chakra(MS Edge), Carakan(Opera)。

国产浏览器有些采用Webkit进行深度优化,比如腾讯X5,有些择是直接集成了Chrome和IE的内核。JS引擎则很有可能使用V8。相比国外厂商,中国的厂商表现的十分“神秘”。

JS引擎可以从内容库、框架、语言功能等几个主要方面决定JavaScript的运行性能。
比如array.sort()、array.reverse()在不同浏览器中执行的效率是有量级差异的。所以了解引擎功能和性能是写出好代码的一个前提。

了解浏览器中JS的加载和执行

虽然浏览器内核五花八门,但基本遵循以下架构。以Chrome为例:

在Rendering Engine(渲染引擎)的下一层,包含了JavaScript Interpreter 解析引擎(翻译引擎)。Interpreter的功能主要是JS脚本的解析和执行。
因此,基于此类架构的浏览器会产生阻塞问题。
简单说,就是页面中script标签出现的地方,页面渲染都被暂停,需要等待JS脚本加载、解析、执行后才能继续。
特别地,通过外链进来的javascript代码,需要等待下载的完成才能进行解析并执行。
我们再看另外一个层面的,在W3C的规范中,对于HTML4文档,允许script标签多次出现在head和body中,而在HTML5中,这种约束变得更加随意。因为浏览器对文档的解析变得更智能,兼容性更好。

所以长久以来,很多教程都建议将script标签放入页面body中的底部位置,从而让script标签前的内容可以顺利加载并完成渲染。这被认为是应对script脚本阻塞的一个良方。当然阻塞也可以完全避免,那就是无阻塞脚本,这是基于HTML5规范标准的,使得script加载过程不阻塞页面渲染。在此之前,远程script会被先加载但不执行或延迟执行,这种方法的简单实现如下:

<!-- async 是让js脚本在脚本下载完成后立即解析执行 -->
< script async>< /script >

<!-- defer 是等整个页面加载完成后再执行 -->
< script defer>< /script >

<!-- 备注:在HTML5之前,defer也是被部分浏览器支持的,但并不属于HTML4的正式规范内容 -->

以上过程,虽然避开了加载,JS脚本执行仍会阻塞页面渲染。所以,有了动态加载方法:

var script= document.createElement('script');
script.type= 'text/javascript';
script.onreadystatechange = function () {
  if (script.readyState == 'complete' || script.readState == 'loaded') {
    console.log("Script loaded");
  }
}
script.src= 'script.js';
document.getElementsByTagName('head')[0].appendChild(script);

在脚本的动态下载、执行过程不会阻塞页面的其他进程。

另外还有一种异步加载的方式为我们所熟悉,那就是ajax(XMLHTTPRequest),原理不多讲,它与上面方法的区别是,存在跨域的限制。所以在处理无法跨域的请求中,如CDN请求、第三方资源请求等,就略显薄弱。

因此,推荐的做法通过动态生成script标签的方法异步加载脚本。

与异步加载脚本的方式类似的,今天我们可以使用现代浏览器的新特性,诸如fetch、import方法以及异步编程手段,在此不多展开。

了解浏览器的其他机制

UI线程

用于执行JS并更新用户界面的进程被称为浏览器UI线程。这种“线程”通常是一个工作队列,UI的更新会排在队列里。UI更新包括两部分内容,重绘与重排(重排,有资料称之为回流)。简单说,重绘是对元素外观的重新渲染,重排是对元素位置、大小的重新调整。
往UI线程中添加任务的时机是随机的。比如在人机交互过程中,触发了一个按钮点击事件,此时按钮的UI被更新,然后执行按钮的click事件回调。如果重复这个操作,用慢镜头来回放这个过程:
第一次点击:第一次UI变化写入线程队列
第一次点击:第一次JS执行写入线程队列
第二次点击:第一次JS执行还在进行,第二次UI变化写入线程队列
第二次点击:第二次UI变化写入线程队列,第二次JS执行写入线程队列
……
大多数浏览器的机制保证在JS运行时,UI的更新任务可以添加到队列。我们可以设想,如果JS在频繁触发UI更新,那么JS执行时间要小于UI更新的时间,用户是感觉不到卡顿的(所谓的不掉帧)。这就要求JS的执行速度要更快!代码性能要更好!

通常200ms是普通人反映能力的临界值(视觉神经引发肌肉变化),这意味着有200ms的执行时间可以让用户在点击按钮后做出反映,前提Ta看到了界面出现了变化。而视觉神经对图像运动的感知要更敏感,通常低至每秒24帧图像的连续变化也不会引起闪烁,意味着每一帧的时间是42ms。所以针对不同的体验要求,JS的执行时间也需要精细地控制。

当JS执行了“太久”,以至于产生明显的“卡顿”,这是极不好的一种体验。并且由于JS的执行是单线程,也就意味着这种长时间的效果是会累积的,当连续执行同一段代码时,UI更新的不连贯就会形成“卡死”的现象。

所以我们需要做的是解放UI线程。

方法之一,使用定时器。setTimeout、setInterval这俩兄弟是最长使用的“异步”JS编程手段,所谓异步,指的就是定时器中的脚本,不会立刻写入UI线程队列,此时位于定时器设置之后的其他非异步JS代码会插入UI线程队列中,通常这个过程被称作控制权出让。大家一定会注意到,setTimeout和setInterval都有第二个参数————时间参数。

const tmr = setTimeout(() => {}, 200);
console.log("注意倒计时设定的时间:200ms");

这个时间参数是表示这个任务被添加到UI线程队列中的时机,而并不一定是真正执行的时间!
这就意味着,如果队列中有其他任务未执行完,倒计时结束也会等待之前的任务。

Web Workers

除了UI线程外,Web Workers 是浏览器在HTML5规范内提供的用于运行JS代码的另一个独立线程。这意味着Web Worker中的JS运行不会占用UI线程的资源。但由于线程间的独立,Web Workers中的JS无法访问浏览器的许多资源。这就解释了为什么在worker里无法访问DOM。另外每个Web Worker只包含了部分JS对象能力:
– navigator: appName, appVersion, userAgent, platform
– location: 拥有window.location的全部属性,但仅只读
– self:指向全局worker对象
– importScripts()方法:加载外部javascript文件
– ECMAScript的对象: Object、Array、Set、Map、Date等
– XMLHttpRequest构造器
– setTimeout, setInterval
– close()方法,可立即停止Worker运行

创建的每个worker都具有不同的全局运行环境,也就意味着拥有不同的上下文。
浏览器还为网页代码与worker通信提供了支持。就是利用worker的postMessage方法和事件监听,这也是唯一的通信方式。

// 页面脚本
const worker = new Worker("script.js");
worker.onmessage = function(event) {
  console.log(event.data);
}
worker.postMessage("Greetings!");

// script.js
self.onmessage = function(event) {
  self.postMessage("I hear you" + event.data + "!");
}

以下描述上面代码里的整个通信过程
1. 当页面脚本调用worker.postMessage()方法时,传入“Greetings!”字符串。
2. Worker脚本监听到消息,用event.data获取消息内容。
3. Worker脚本通过self.postMessage()发出消息。
4. 页面脚本通过worker.onmessage监听到消息,从event.data拿到数据。

Web Worker的实际应用
1. 处理复杂数学计算(图像、视频)
2. 大数组排序
3. 编码/解码大字符串

Web Worker与setTimeout/setInterval能互相替代吗,你是否此刻也在思考这个问题?
我想大概有两点,一方面是环境不同而产生的js能力不同,在worker里多了很多限制;另一方面,本质上定时器的任务还是在UI任务队列里,而web worker是独立的环境。连续创建多个定时器,在队列中仍然是队列排列,而连续创建多个worker,更像是一种并行任务,并且不会锁定UI线程。所以用web worker处理大计算量的任务是比较明智的选择。

待续…

参考

  1. 英文原版链接
    《高性能JavaScript》Nicholas C. Zakas著,由丁琛翻译,赵泽欣校准,2015。英文原版的第一版时间是2010年。

  2. Wiki-火狐浏览器JS引擎

  3. 性能测试工具Speedometer

  4. 最详细·浏览器工作过程讲解

发表评论

电子邮件地址不会被公开。 必填项已用*标注