《高性能javascript》一书要点和延伸(上)

前些天收到了HTML5中国送来的《高性能javascript》一书,便打算将其作为假期消遣,顺便也写篇文章记录下书中一些要点。javascript

我的以为本书很值得中低级别的前端朋友阅读,会有不少意想不到的收获。html

 

第一章 加载和执行前端

基于UI单线程的逻辑,常规脚本的加载会阻塞后续页面脚本甚至DOM的加载。以下代码会报错:html5

<!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 被置于脚本以后,它还没被页面解析到就先执行了脚本(固然这属于最基础的知识点了)java

书中说起了使用 defer 属性能够延迟脚本到DOM加载完成以后才执行。jquery

咱们常规喜欢把脚本放到页面的末尾,并裹上 DOMContentLoaded 事件,事实上只须要给 script 标签加上 defer 属性会比前者作法更简单也更好(只要没有兼容问题),毕竟连 DOMContentLoaded 的事件绑定都先绕过了。web

书中没有说起 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")
}

 

本书上半部分就先总结到这里,共勉~

donate

相关文章
相关标签/搜索