《高性能javascript》一书要点和延伸(下)
第六章 快速响应的用户界面
本章开篇介绍了浏览器UI线程的概念,我也突然想到一个小例子,这是写css3动画的朋友都经常会碰到的一个问题:
<head> <meta charset="UTF-8"> <title>Title</title> <style> div{width:50px; height:50px; background:yellow;} .act{width:100px;transition:width 0.5s;} </style> </head> <body> <div class="act"></div> <button>click me</button> <script> var btn = document.querySelector('button'); var div = document.querySelector('div'); btn.onclick = function(){ div.className = ''; div.className = 'act'; } </script> </body>
如代码所示,我们希望点击按钮的时候,div能通过移除class瞬间变回50px,然后再给其加回class来触发动画(0.5秒内,宽度由50px延伸到100px),
不过这段代码的执行效果是——没有效果(录屏软件在win10下有点兼容bug,鼠标都偏移了):
其解决方案却也简单——套上一个setTimeout即可:
btn.onclick = function(){ div.className = ''; setTimeout(function(){ div.className = 'act'; }, 0) }
执行如下:
原理是,我们通过 setTimeout,将div的第一次UI事件得以优先执行,而非放到 div.className = 'act' 的后方执行。
在用户点击按钮(未加setTimeout时的代码)的时候其实发生了这样的事情:
⑴ UI事件——更新按钮的UI,让用户能“看到”它被点击了。同时把回调事件放入事件队列。
⑵ JS事件A——执行回调事件,先执行首行的 div.className = '' ,移除div的类名,这时候会生成一个UI事件A(重渲染div)放入事件队列中等候空闲。
⑶ JS事件B——继续执行回调事件,给div加上名为“act”的类,这时候依旧又生成了一个UI事件B(重新渲染div)并放入队列中等候。
⑷ UI事件A——鉴于浏览器的UI线程已不存在任何执行中的任务(回调已执行完毕,处空闲状态),那么事件队列中的UI事件便开始以FIFO的形式进入UI线程来被处理。
⑸ UI事件B——跟UI事件A是一样的,即根据div的当前样式来做渲染处理。
(制图的时候没记清楚,把事件A/B写为事件1/2了,大家自行脑部替换吧)
而加上 setTimeout 之后则变为:
⑴ UI事件——更新按钮的UI,让用户能“看到”它被点击了。同时把回调事件(JS事件A和B)放入事件队列。
⑵ JS事件A——执行回调事件,先执行首行的 div.className = '' ,移除div的类名,这时候会生成一个UI事件A(重渲染div)放入事件队列中等候空闲。
⑶ UI事件A——由于JS事件B带延迟特性,故先放行事件队列后方的队列成员,让UI事件A先执行。这时候div失去了类,依据当前有效样式,将其渲染为50px宽度。
⑷ JS事件B——继续执行回调事件,给div加上名为“act”的类,依旧又生成了一个UI事件B(重新渲染div)并放入队列中等候。
⑸ UI事件B——div加上了类,故根据当前的有效样式,将其渲染为100px宽度。
⑹ UI事件C——鉴于div的宽度发生了变化,故触发动画事件。
综上我们稍微了解了浏览器UI线程(主线程)的一个工作流程,但常规浏览器并不仅仅只有一个线程在运作,其主要线程可归类为:
另外我们回过头看看 setTimeout/setInterval 这两个时间机制,它们实际上只是把回调事件放入队列中以“礼让”的状态等候,若后方有事件成员则礼让给后方先出队。
这点跟 node 的 setImmediate 是一样的,不同的是 setImmediate 不受延时限制,当event loop当轮结束时则执行。
那么给 setTimeout 配置一个数值为 0 的延时,是否就实现了 setImmediate 的功能呢?答案是否定的,在书中“定时器精度”一节有提及,js的时间机制是不精准的,它受到了系统/客户端定时器分辨率(如window下为15毫秒)的影响,所以会存在毫秒级的偏差。
不过这里需要了解的事实是—— JS中的时间机制并不是一个纯粹的异步事件,它依旧走的UI单线程,只是当事件队列为空时候才“见缝插针”到UI线程中去执行,营造出了一种“异步”的假象。
顺道也在这里提一提,JS中真正走了异步的应该是下面几个事件:
1. Ajax
2. event(如监听click)
3. requestAninmationFrame
4. WebSQL、IndexDB
5. Web Worker
6. postMessage
第七章 Ajax
“动态脚本注入”一节介绍了JSONP原理——前后端约定好一个回调名,让script请求的回包数据包裹在该回调名内,客户端拉取到该回包时通过 eval 来即时触发回调函数。
除了 JSONP 我们还是能有许多跨域通信的实现,可参照我的旧文章。
本章提及的“Multipart XHR”其实是域名收敛的一种实现,比如下面的单条请求就一口气返回了对应的多个脚本资源:
不过这里提及了一个有趣的处理——若MXHR响应的出局非常多,等到全部数据返回过来才做处理有点慢,我们可以通过监听XHR的 readyState 来提前处理。
当 readyState 为3时其实表示客户端已经开始下载回包(含报头)了,这时候我们就可以通过轮询来提前处理(主要是拆开、提取回包中的合并资源):
var req = new XMLHttpRequest(); var getLatestPacketInterval, lastLength = 0; req.open('GET', 'rollup_images.php', true); req.onreadystatechange = readyStateHandler; req.send(null); function readyStateHandler{ if (req.readyState === 3 && getLatestPacketInterval === null) { // 开始轮询 getLatestPacketInterval = window.setInterval(function() { getLatestPacket(); }, 15); } if (req.readyState === 4) { // 停止轮询 clearInterval(getLatestPacketInterval); // 获取最后一个数据包 getLatestPacket(); } } function getLatestPacket() { var length = req.responseText.length; var packet = req.responseText.substring(lastLength, length); processPacket(packet); lastLength = length; }
接着提及的 Beacons 其实是一种 image ping 技术,常规也是用来跨域通信的(主要用于统计)。不过这里提及的服务端响应处理还是值得一看:
1. 服务端返回真实的图片数据,客户端可通过判断图片宽度来了解状态;
2. 若客户端无须了解服务端状态,则返回不带消息正文的204即可。
第八章 编程实践
本章提供一些建议,让读者能避免使用一些性能上不太好的编程习惯。
1. 避免双重求值
js中提供了某些接口允许你输入字符串来编译执行,eval是其中最耳熟能详的方法了。除却eval还包括如下方法:
⑴ 以 new Function() 的形式来创建函数; ⑵ 让 setTimeout/setInterval 执行字符串。
这些方法都会让js引擎先做字符串解析,再做求值处理,导致了双重求值,性能开销会变大,所以常规不建议这么来使用。
如果不得已要解析服务端返回的大规模json字符串,可以开个 Web Worker 做异步处理。
2. 使用 Object/Array 直接量
//不推荐 var o = {}; o.a = 1; o.b = 2; //推荐 var o = { a: 1, b: 2 } //不推荐 var arr = new Array(); arr[0] = 1; arr[1] = 2; //推荐 var arr = [1, 2];
使用“推荐”的直接量处理来定义一个对象将获得更快的执行速度也有助减小文件体积。
3. 避免重复工作
大部分开发都会忽略的地方,即封装在某个方法中的功能分支判断,在每次方法被调用的时候都会重新做一次冗余判断:
function addHandler(target, eventType, handler){ if(target.addEventListener){ target.addEventListener(eventType, handler, false) } else { target.attachEvent('on'+eventType, handler) } }
如上述的事件绑定接口在每次被调用时,都需要做一次事件添加句柄判断。
解决该问题的方法是内部重写接口(延迟加载):
function addHandler(target, eventType, handler){ if(target.addEventListener){ addHandler = function(target, eventType, handler){ target.addEventListener(eventType, handler, false) } } else { addHandler = function(target, eventType, handler){ target.attachEvent('on'+eventType, handler) } } addHandler(target, eventType, handler); //延迟加载 }
4. 用速度最快的部分
⑴ 位操作
JS的位操作会相比其它的计算处理快得多,若妥当使用可以提升脚本执行速度。
例如常规我们会以 if(i%2) 来判断 i 是奇数或偶数,若把条件更改为 if(i & 1) 会得到一样的结果,不过速度快了50%。
本节也提及了“位掩码”的使用,是种有趣的逻辑识别处理。
打个比方,在手Q web 页面开发中,我们会通过一个“_wv”的参数来知会客户端(手Q)是否显示返回按钮、分享按钮,以及如何显示分享面板等功能。
关于这个参数有类似这样的映射:
当我们给 url 的 _wv 参数取值 21 (即 16 + 4 + 1)的时候,手Q针对该参数的值来隐藏返回按钮和底栏,并配置分享面板中不出现空间的选项。
而常规我们在写JS时,可以利用位掩码来实现相同处理。
我们依旧使用上方的映射表,不过不再使用累加处理,而是使用位处理:
var wv = 16 | 4 | 1; //识别处理 if(wv & 1){ //隐藏返回按钮 } if(wv & 2){ //隐藏分享按钮 } ...//省略4和8的分支 if(wv & 16){ //分享面板隐藏空间分享 }
⑵ 原生方法
即多使用原生的 Math 接口来实现复杂的计算,多使用原生的选择器(如 querySelector)来选择DOM。
至于后面两章主要提及的是前端构建和检测工具,其中部分技术还是淘汰掉的东西就不赘述了。共勉~