《高性能javascript》一书要点和延伸(上)
前些天收到了HTML5中国送来的《高性能javascript》一书,便打算将其做为假期消遣,顺便也写篇文章记录下书中一些要点。
个人觉得本书很值得中低级别的前端朋友阅读,会有很多意想不到的收获。
第一章 加载和执行
基于UI单线程的逻辑,常规脚本的加载会阻塞后续页面脚本甚至DOM的加载。如下代码会报错:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title></title>
<script src="http://libs.baidu.com/jquery/1.11.1/jquery.min.js"></script>
</head>
<body>
<script>
console.log($);
document.querySelector('div').innerText='中秋快乐';
</script>
<div>9999999999999</div>
</body>
</html>
原因是 div 被置于脚本之后,它还没被页面解析到就先执行了脚本(当然这属于最基础的知识点了)。
书中提及了使用 defer 属性可以延迟脚本到DOM加载完成之后才执行。
我们常规喜欢把脚本放到页面的末尾,并裹上 DOMContentLoaded 事件,事实上只需要给 script 标签加上 defer 属性会比前者做法更简单也更好(只要没有兼容问题),毕竟连 DOMContentLoaded 的事件绑定都先绕过了。
书中没有提及 async 属性,其加载执行也不会影响页面的加载,跟 defer 相比,它并不会等到 DOM 加载完才执行,而是脚本自身加载完就执行(但执行是异步的,不会阻塞页面,脚本和DOM加载完成的先后没有一个绝对顺序)。
第二章 数据存储
本章在一开始提及了作用域链,告诉了读者“对浏览器来说,一个标识符(变量)所在的位置越深,它的读写速度也就越慢(性能开销越大)”。
我们知道很多库都喜欢这么做封装:
(function(win, doc, undefined) { // TODO })(window, document, undefined)
以IIFE的形式形成一个局部作用域,这种做法的优势之一当然是可避免产生污染全局作用域的变量,不过留意下,我们还把 window、document、undefined 等顶层作用域对象传入该密封的作用域中,可以让浏览器只检索当层作用域既能正确取得对应的顶层对象,减少了层层向上检索对象的性能花销,这对于类似 jQuery 这种动辄几千处调用全局变量的脚本库而言是个重要的优化点。
我们常规被告知要尽量避免使用 with 来改变当前函数作用域,本书的P22页介绍了该原因,这里来个简单的例子:
function a(){
var foo = 123;
with (document){
var bd = body;
console.log(bd.clientHeight + foo)
}
}
在 with 的作用域块里面,执行环境(上下文)的作用域链被指向了 document,因此浏览器可以在 with 代码块中更快读取到 document 的各种属性(浏览器最先检索的作用域链层对象变为了 document)。
但当我们需要获取局部变量 foo 的时候,浏览器会先检索一遍 document,检索不到再往上一层作用域链检索函数 a 来取得正确的 foo,由此一来会增加了浏览器检索作用域对象的开销。
书中提及的对同样会改变作用域链层的 try-catch 的处理,但我觉得不太受用:
try {
methodMightCauseError();
} catch (ex){
handleError(ex) //留意此处
}
书中的意思是,希望在 catch 中使用一个独立的方法 handleError 来处理错误,减少对 catch 外部的局部变量的访问(catch代码块内的作用域首层变为了ex作用域层)。
我们来个例子:
(function(){
var t = Date.now();
function handleError(ex){
alert(t + ':' +ex.message)
}
try {
//TODO:sth
} catch (ex){
handleError(ex);
}
})()
我觉得不太受用的原因是,当 handleError 被执行的时候,其作用域链首层指向了 handleError 代码块内的执行环境,第二层的作用域链才包含了变量t。
所以当在 handleError 中检索 t 时,事实上浏览器还是依旧翻了一层作用域链(当然检索该层的速度还是会比检索ex层的要快一些,毕竟ex默认带有一些额外属性)。
后续提及的原型链也是非常重要的一环,无论是本书抑或《高三》一书均有非常详尽的介绍,本文不赘述,不过大家可以记住这么一点:
对象的内部原型 __proto__ 总会指向其构造对象的原型 prototype,脚本引擎在读取对象属性时会先按如下顺序检索:
对象实例属性 → 对象prototype → 对象__proto__指向的上一层prototype → .... → 最顶层(Object.prototype)
想进一步了解原型链生态的,可以查看这篇我收藏已久的文章。
在第二章最后提及的“避免多次读取同一个对象属性”的观点,其实在JQ源码里也很常见:
这种做法一来在最终构建脚本的时候可以大大减小文件体积,二来可以提升对这些对象属性的读取速度,一石二鸟。
第三章 DOM编程
本章提及的很多知识点在其它书籍上其实都有描述或扩展的例子。如在《Webkit内核技术内幕》的开篇(第18页)就提到JS引擎与DOM引擎是分开的,导致脚本对DOM树的访问很耗性能;在曾探的《javascript设计模式》一书中也提及了对大批量DOM节点操作应做节流处理来减少性能花销,有兴趣的朋友可以购入这两本书看一看。
本章在选择器API一处建议使用 document.querySelectorAll 的原生DOM方法来获取元素列表,提及了一个挺重要的知识点——仅返回一个 NodeList 而非HTML集合,因此这些返回的节点集不会对应实时的文档结构,在遍历节点时可以比较放心地使用该方法。
本章重排重绘的介绍可以参考阮一峰老师的《网页性能管理详解》一文,本章不少提及的要点在阮老师的文章里也被提及到。
我们需要留意的一点是,当我们调用了以下属性/方法时,浏览器会“不得不”刷新渲染队列并触发重排以返回正确的值:
offsetTop/offsetLeft/offsetWidth/offsetHeight
scrollTop/scrollLeft/scrollWidth/scrollHeight
clientTop/clientLeft/clientWidth/clientHeight
getComputedStyle()
因此如果某些计算需要频繁访问到这些偏移值,建议先把它缓存到一个变量中,下次直接从变量读取,可有效减少冗余的重排重绘。
本章在介绍批量修改DOM如何减少重排重绘时,提及了三种让元素脱离文档流的方案,值得记录下:
方案⑴:先隐藏元素(display:none),批量处理完毕再显示出来(适用于大部分情况);
方案⑵:创建一个文档片段(document.createDocumentFragment),将批量新增的节点存入文档片段后再将其插入要修改的节点(性能最优,适用于新增节点的情况);
方案⑶:通过 cloneNode 克隆要修改的节点,对其修改后再使用 replaceChild 的方法替换旧节点。
在这里提个扩展,即DOM大批量操作节流的,指的是当我们需要在一个时间单位内做很大数量的重复的DOM操作时,应主动减少DOM操作处理的数量。
打个比方,在手Q公会大厅首页使用了iscroll,用于在页面滚动时能实时吸附导航条,大致代码如下:
var myscroll = new iScroll("wrapper",
{
onScrollMove : dealNavBar,
onScrollEnd : dealNavBar
}
);
其中的 dealNavBar 方法用于处理导航条,让其保持吸附在viewport顶部。
这种方式的处理导致了页面滚动时出现了非常严重的卡顿问题,原因是每次 iscroll 的滚动就会执行非常多次的 dealNavBar 方法计算(当然我们还需要获取容器的scrollTop来计算导航条的吸附位置,导致不断重排重绘,这就更加悲剧了)。
对于该问题有一个可行的解决方案—— 节流,在iscroll容器滚动时舍得在某个时间单位(比如300ms)里才执行一次 dealNavBar:
var throttle = function (fn, delay) {
var timer = null;
return function () {
var context = this, args = arguments;
clearTimeout(timer);
timer = setTimeout(function () {
fn.apply(context, args);
}, delay);
};
};
var myscroll = new iScroll("wrapper",
{
onScrollMove : throttle.bind(this, dealNavBar, 300)
}
);
当然这种方法会导致导航条的顶部吸附不在那么实时稳固了,会一闪一闪的看着不舒服,个人还是倾向于只在 onScrollEnd 里对其做处理即可。
那么什么时候需要节流呢?
常规在会频繁触发回调的事件里我们推荐使用节流,比如 window.onscroll、window.onresize 等,另外在《设计模式》一书里提及了一个场景 —— 需要往页面插入大量内容,这时候与其一口气插入,不妨节流分几次(比如每秒最多插入80个)来完成整个操作。
第四章 算法和流程控制
本章主要介绍了一些循环和迭代的算法优化,适合仔细阅读,感觉也没多余可讲解或扩展的地方,不过本章提及了“调用栈/Call Stack”,想起了我面试的时候遇到的一道和调用栈相关的问题,这里就讲个题外话。
当初的问题是,如果某个函数的调用出错了,我要怎么知道该函数是被谁调用了呢?注意只允许在 chrome 中调试,不允许修改代码。
答案其实也简单,就是给被调用的函数设断点,然后在 Sources 选项卡查看“Call Stack”区域信息:
另外关于本章最后提及的 Memoization 算法,实际上属于一种代理模式,把每次的计算缓存起来,下次则绕过计算直接到缓存中取,这点对性能的优化还是很有帮助的,这个理念也不仅仅是运用在算法中,比如在我的 smartComplete 组件里就运用了该缓存理念,每次从服务器获得的响应数据都缓存起来,下次同样的请求参数则直接从缓存里取响应,减少冗余的服务器请求,也加快了响应速度。
第五章 字符串和正则表达式
开头提及的“通过一个循环向字符串末尾不断添加内容”来构建最终字符串的方法在“某些浏览器”中性能糟糕,并推荐在这些浏览器中使用数组的形式来构建字符串。
要留意的是在主流浏览器里,通过循环向字符串末尾添加内容的形式已经得到很大优化,性能比数组构建字符串的形式还来的要好。
接着文章提及的字符串构建原理很值得了解:
var str = "";
str += "a"; //没有产生临时字符串
str += "b" + "c"; //产生了临时字符串!
/* 上一行建议更改为
str = str + "b" + "c";
避免产生临时字符串 */
str = "d" + str + "e" //产生了临时字符串!
“临时字符串”的产生会影响字符串构建过程的性能,加大内存开销,而是否会分配“临时字符串”还是得看“基本字符串”,若“基本字符串”是字符串变量本身(栈内存里已为其分配了空间),那么字符串构建的过程就不会产生多余的“临时字符串”,从而提升性能。
以上方代码为例,我们看看每一行的“基本字符串”都是谁:
var str = "";
str += "a"; //“基本字符串”是 str
str += "b" + "c"; //“基本字符串”是"b"
/* 上一行建议更改为
str = str + "b" + "c"; //“基本字符串”是 str
避免产生临时字符串 */
str = "d" + str + "e" //“基本字符串”是"d"
以最后一行为例,计算时浏览器会分配一处临时内存来存放临时字符串"b",然后依次从左到右把 str、"e"的值拷贝到"b"的右侧(拷贝的过程中浏览器也会尝试给基础字符串分配更多的内存便于扩展内容)。
至于前面提到的“某些浏览器中构建字符串很糟糕”的情况,我们可以看看《高三》一书(P33)是怎么描述这个“糟糕”的原因:
var lang = "Java"; //在内存开辟一个空间存放"Java"
lang = lang + "script"; //创建一个能容纳10个字符的空间,
//拷贝字符串"Java"和"script"(注意这两个字符串也都开辟了内存空间)到这个空间,
//接着销毁原有的"Java"和"script"字符串
我们继续扩展一个基础知识点——字符串的方法是如何被调用到的?
我们知道字符串属于基本类型,它不是对象为何咱们可以调用 concat、substring等字符串属性方法呢?
别忘了万物皆对象,在前面我们提及原型链时也提到了最顶层是 Object.prototype,而每个字符串,实际上都属于一个包装对象。
我们分析下面的例子,整个过程发生了什么:
var s1 = "some text";
var s2 = s1.substring(2);
s1.color = "red";
alert(s1.color);
在每次调用 s1 的属性方法时,后台总会在这之前默默地先做一件事——执行 s1=new String('some text') ,从而让我们可以顺着原型链调用到String对象的属性(比如第二行调用了 substring)。
在调用完毕之后,后台又回默默地销毁这个先前创建了的包装对象。这就导致了在第三行我们给包装对象新增属性color后,该对象立即被销毁,最后一行再次创建包装对象的时候不再有color属性,从而alert了undefined。
在《高三》一书里是这么描述的:
“引用类型与基本包装类型的主要区别就是对象的生存期。使用new操作符创建的引用类型的实例,在执行流离开当前作用域之前都一直保存在内存中。而自动创建的基本包装类型的对象,则只存在于一行代码的执行瞬间,然后立即被销毁。这意味着我们不能在运行时为基本类型值添加属性和方法。”
正则的部分提及了“回溯法”,在维基百科里是这样描述的:
回溯法采用试错的思想,它尝试分步的去解决一个问题。在分步解决问题的过程中,当它通过尝试发现现有的分步答案不能得到有效的正确的解答的时候,它将取消上一步甚至是上几步的计算,再通过其它的可能的分步解答再次尝试寻找问题的答案。回溯法通常用最简单的递归方法来实现,在反复重复上述的步骤后可能出现两种情况:
1. 找到一个可能存在的正确的答案
2. 在尝试了所有可能的分步方法后宣告该问题没有答案
在最坏的情况下,回溯法会导致一次复杂度为指数时间的计算。
常规我们应当尽可能减少正则的回溯,从而提升匹配性能:
var str = "<p>123</p><img src='1.jpg' /><p>456</p>";
var r1 = /<p>.*<\/p>/i.test(str); //贪婪匹配会导致较多回溯
var r2 = /<p>.*?<\/p>/i.test(str); //推荐,惰性匹配减少回溯
对于书中建议对正则匹配优化的部分,我总结了一些比较重要的点,也补充对应的例子:
1. 让匹配失败更快结束
正则匹配中最耗时间的部分往往不是匹配成功,而是匹配失败,如果能让匹配失败的过程更早结束,可以有效减少匹配时间:
var str = 'eABC21323AB213',
r1 = /\bAB/.test(str), //匹配失败的过程较长
r2 = /^AB/.test(str); //匹配失败的过程很短
2. 减少条件分支+具体化量词
前者指的是尽可能避免条件分支,比如 (.|\r|\n) 可替换为等价的 [\s\S];
具体化量词则是为了让正则更精准匹配到内容,比如用特定字符来取代抽象的量词。
这两种方式都能有效减少回溯。来个示例:
var str = 'cat 1990'; //19XX年出生的猫或蝙蝠
var r1 = /(cat|bat)\s\d{4}/.test(str); //不推荐
var r1 = /[bc]at\s19\d{2}/.test(str); //推荐
3. 使用非捕获组
捕获组会消耗时间和内存来记录反向引用,因此当我们不需要一个反向引用的时候,利用非捕获组可以避免这些开销:
var str = 'teacher VaJoy';
var r1 = /(teacher|student)\s(\w+)/.exec(str)[2]; //不推荐
var r2 = /(?:teacher|student)\s(\w+)/.exec(str)[1]; //推荐
4. 只捕获感兴趣的内容以减少后处理
很多时候可以利用分组来直接取得我们需要的部分,减少后续的处理:
var str = 'he says "I do like this book"';
var r1 = str.match(/"[^"]*"/).toString().replace(/"/g,''); //不推荐
var r2 = str.replace(/^.*?"([^"]*)"/, '$1'); //推荐
var r3 = /"([^"]*)"/.exec(str)[1]; //推荐
5. 复杂的表达式可适当拆开
可能会有个误区,觉得能尽量在单条正则表达式里匹配到结果总会优于分多条匹配。
本章则告诉读者应“避免在一个正则表达式中处理太多任务。复杂的搜索问题需要条件逻辑,拆分成两个或多个正则表达式更容易解决,通常也会更高效”。
这里就不举复杂的例子了,直接用书上去除字符串首尾空白的两个示例:
//trim1
String.prototype.trim = function(){
return this.replace(/^\s+/, "").replace(/\s+$/, "")
} //trim2
String.prototype.trim = function(){
return this.replace(/^\s+|\s+$/, "")
}
事实上 trim2 比 trim1 还要慢,因为 trim1 只需检索一遍原字符串,并再检索一遍去除了了头部空白符的字符串。而 trim2 需要检索两遍原字符串。
主要还是条件分支导致的回溯问题,常规复杂的正则表达式总会带有许多条件分支,这时候就很有必要对其进行拆解了。
当然去掉了条件分支的话,单条正则匹配结果还是一个优先的选择,例如书中给出 trim 的建议方案为:
String.prototype.trim = function(){
return this.replace(/^\s*([\s\S]*\S)?\s*$/, "$1")
}
本书上半部分就先总结到这里,共勉~
《高性能javascript》一书要点和延伸(上)的更多相关文章
- 《高性能javascript》一书要点和延伸(下)
第六章 快速响应的用户界面 本章开篇介绍了浏览器UI线程的概念,我也突然想到一个小例子,这是写css3动画的朋友都经常会碰到的一个问题: <head> <meta charset=& ...
- 高性能JavaScript 编程实践
前言 最近在翻<高性能JavaScript>这本书(2010年版 丁琛译),感觉可能是因为浏览器引擎的改进或是其他原因,书中有些原本能提高性能的代码在最新的浏览器中已经失效.但是有些章节的 ...
- 高性能javascript学习笔记系列(4) -算法和流程控制
参考高性能javascript for in 循环 使用它可以遍历对象的属性名,但是每次的操作都会搜索实例或者原型的属性 导致使用for in 进行遍历会产生更多的开销 书中提到不要使用for in ...
- 高性能JavaScript 达夫设备
前言 在<高性能JavaScript>一书的第四章算法和流程控制中,提到了减少迭代次数加速程序的策略—达夫设备(Duff's device).达夫设备本身很好理解,但是其效果是否真的像书中 ...
- 高性能JavaScript(您值得一看)
众所周知浏览器是使用单进程处理UI更新和JavaScript运行等多个任务的,而同一时间只能有一个任务被执行,如此说来,JavaScript运行了多长时间就意味着用户得等待浏览器响应需要花多久时间. ...
- 《高性能javascript》学习总结
本文是学习<高性能javascript>(Nichols C. Zakes著)的一些总结,虽然书比较过时,里面的知识点也有很多用不上了,但是毕竟是前人一步步探索过来的,记录着javascr ...
- 《高性能JavaScript》学习笔记——日更中
------------------2016-7-20更------------------ 最近在看<高性能JavaScript>一书,里面当中,有讲很多提高js性能的书,正在看的过程中 ...
- 高性能javascript 文件加载阻塞
高性能javascript javascript脚本执行过程中会中断页面加载,直到脚本执行完毕,此操作阻塞了页面加载,造成性能问题. 脚本位置和加载顺序:如果将脚本放在head内,那么再脚本执行完 ...
- 第二篇,前端高性能JavaScript优化
加载和执行 JavaScript是单线程,所以JavaScript的加载和执行是从上下文加载执行完一个继续加载执行下一个文件会阻塞页面资源的加载,所以一般情况下JavaScript文件放在body标签 ...
随机推荐
- 来,给Entity Framework热热身
先来看一下Entity Framework缓慢的初始化速度给我们更新程序带来的一种痛苦. 我们手动更新程序时通常的操作步骤如下: 1)把Web服务器从负载均衡中摘下来 2)更新程序 3)预热(发出一个 ...
- 算法与数据结构(十一) 平衡二叉树(AVL树)
今天的博客是在上一篇博客的基础上进行的延伸.上一篇博客我们主要聊了二叉排序树,详情请戳<二叉排序树的查找.插入与删除>.本篇博客我们就在二叉排序树的基础上来聊聊平衡二叉树,也叫AVL树,A ...
- 如何理解DT将是未来IT的转型之路?
如今的IT面临着内忧外患的挑战. 一方面,企业多多少少都建立了信息化,有些企业或集团甚至会有数几十个分公司,包含直销.代理.零售以及第三方物流等多种业态.越是复杂的业务,信息化建设越困难,比如运用大量 ...
- 一个简单的网站web项目的详解
有不对的术语,或者不好理解的部分,欢迎大家批评指正,谢谢大家! 近期做的网站web项目,实现登录功能,查询功能.首先把这个项目分为几个模块来处理,当前用户模块,历史用户模块,历史记录模块,数据库模块, ...
- appium+robotframework环境搭建
appium+robotframework环境搭建步骤(Windows系统的appium自动化测试,只适用于测试安卓机:ios机需要在mac搭建appium环境后测试) 搭建步骤,共分为3部分: 一. ...
- MyBatis3.2从入门到精通第一章
第一章一.引言mybatis是一个持久层框架,是apache下的顶级项目.mybatis托管到goolecode下,再后来托管到github下.(百度百科有解释)二.概述mybatis让程序将主要精力 ...
- BZOJ 3238: [Ahoi2013]差异 [后缀数组 单调栈]
3238: [Ahoi2013]差异 Time Limit: 20 Sec Memory Limit: 512 MBSubmit: 2326 Solved: 1054[Submit][Status ...
- [每日Linux]Linux下xsell和xftp的使用
实验缘由: 1.xsell在Linux下的作用就是远程登录的一个界面,也就是实现访问在Windows下访问Linux服务器的功能.之前在数据挖掘实验中因为自己电脑的内存不够,曾经使用过实验室的服务器跑 ...
- ucos实时操作系统学习笔记——任务间通信(信号量)
ucos实时操作系统的任务间通信有好多种,本人主要学习了sem, mutex, queue, messagebox这四种.系统内核代码中,这几种任务间通信机制的实现机制相似,接下来记录一下本人对核心代 ...
- 字符型图片验证码识别完整过程及Python实现
字符型图片验证码识别完整过程及Python实现 1 摘要 验证码是目前互联网上非常常见也是非常重要的一个事物,充当着很多系统的 防火墙 功能,但是随时OCR技术的发展,验证码暴露出来的安全问题也越 ...