高性能JavaScript
《高性能JavaScript》
前言!
《高性能JavaScript》分别从 浏览器的部分原理,JavaScript的加载执行,数据存取,DOM编程,算法和流程控制,字符串和正则表达式,Ajax通信,编程实践,构建并部署高性能JavaScript应用,工具等方面带来对高性能JavaScript开发的认识!
入手《高性能JavaScript》已经很久,但是一直没有抽出时间去仔细的读一下这本书。狗尾草计划这几天开始细品这本书并开始逐步做出总结。主要用于知识的分享与积累。
注:本书与2018年发版,前端更新速度跑的比狗还快,因此尽管18年是第13次印刷,里面的很多知识点并不一定是最佳实践,但是可以保证的是它确实告诉了我们需要注意的细节问题。
加载和执行
管理浏览器中的JavaScript代码是个棘手的问题,因为JavaScript的单线程特性导致了代码执行过程会阻塞浏览器其他进程,比如页面的Render,每次遇到<script>标签之后就会停下来,等待JavaScript代码加载完毕,然后再继续往下执行。
对此问题《高性能JavaScript》总结了一下方法来提高JavaScript加载时候的性能:
</body>闭合标签之前,将所有的<script>标签放到页面底部,确保在脚本执行前页面已经完成Render
减少没必要的JavaScript文件,考虑将多个JavaScript按照所需顺序进行合并,减少资源的HTTP请求。
需要注意的是,合并文件如果太大则需要拆分请求,需要把握一个度来确保最优的请求效率
无阻塞脚本加载:
— 使用<script>标签的defer属性和async属性实现异步加载
— 使用动态创建的<script>元素来下载并执行代码
— 使用XHR对象下载JavaScript代码并注入页面。
好的代码块
在IE中如何实现JavaScript资源引入的顺序控制(不推荐)
// js 加载顺序
function loadScript(url, callback) {
var script = document.createElement("script");
script.type = 'text/javascript';
if(script.readyState) {
// IE
script.onreadystatechange = function() {
if(script.readyState == 'loaded' || script.readyState == "complete") {
script.onreadystatechange = null;
callback();
}
};
} else {
script.onload = function() {
callback();
};
}
script.src = url;
document.head.appendChild(script);
}
// 引用
loadScript('one.js', function() {
loadScript('two.js', function() {
loadScript('three.js', function() {
alert("All files are loaded!");
})
})
})
译注
两者区别:执行时机的不同,defer是JavaScript文件加载后,不会立即执行而是等待页面加载完毕再执行,async则是加载完毕后立即执行
数据的存取
作用域链和标识符解析
作用域:字面意思可以理解为作用范围,变量的作用范围,函数的作用范围,他们可以在什么位置可以被访问到,什么时候不能被访问到。
作用域又分为全局作用域和局部作用域。全局作用域可以简单理解为window对象下的直接饮用。局部作用域则为仅是某代码片段才能访问的。
作用域链:每一个JavaScript函数都可以表示为一个对象,万物皆对象。更准确的说是Function对象的一个实例。Function对象同其他对象一样拥有一个仅供JavaScript引擎访问的编程属性[[scope]]属性。
[[scope]]属性包含了函数被创建的作用域中对象的集合。这个集合被称为函数的作用域链,它决定哪些数据能被函数访问。
function add(num1,num2) {
var sum = num1 + num2;
return sum;
}
函数add的作用域链如图所示:
函数add创建后,它的作用域链会被 创建此函数的作用域中可访问的数据对象所填充
作用域链在函数创建后创建完成,在函数执行时会被调用。
var total = add(5,10);
执行上下文(执行环境):函数add被调用时,会创建一个被称为执行环境的内部对象。一个执行环境定义了一个函数执行时的环境。函数每次执行时对应的执行环境都是独一无二的。所以多次调用同一个函数就会导致创建多个执行环境。当函数执行完毕,执行环境就会被销毁。
执行环境的作用域链包含环境引用记录和外部环境引用记录
环境引用记录为函数执行时,可访问到的函数内部的变量。this,arguments,num1,num2,sum
外部环境引用记录为该函数可访问到的外部的变量。this,window,document,add
在JavaScript中,数据存储的位置会对代码整体性能产生重大影响。数据存储共四种方式:字面量,变量,数组项,对象成员。他们有各自的性能特点。
- 访问字面量和局部变量的速度最快,相反,访问数组和对象成员相对较慢
- 由于局部变量存在于作用域链的起始位置,因此访问局部变量速度相对较快一些,变量在作用域链中的位置越深,访问所需时间就越长。由于全局变量在作用域链的最末端,因此访问速度也最慢。
- 避免使用with语句,他会改变执行环境的作用域链。同样,try-catch语句中的catch也有同样的影响,但是try-catch的使用确实也能解决一部分问题,因此不建议废除使用,不过还是要小心谨慎。
- 嵌套的对象成员会明显影响性能,尽量少用
- 属性或方法在原型中的位置越深,访问速度也就越慢
- 通常来说,我们可以吧常用的对象成员,数组元素,跨域变量保存在局部变量中来改善JavaScript性能,因为局部变量访问速度更快。
好的代码块
- try-catch
try {
methodThatMightCauseAnError();
} catch {
handleError(ex); // 委托给错误处理器函数
}
DOM编程
本章节信息量较大,且是非常重要的章节
用脚本进行DOM操作的代价很昂贵,他是富Web应用中最常见的性能瓶颈。本节内容主要讨论三类问题:
- 访问和修改DOM元素
- 修改DOM元素的样式会导致重绘和重排。
- 通过DOM事件处理与用户的交互
浏览器中的DOM
文档对象模型(DOM)是独立于语言的,用于操作XML和HTML文档的程序接口(API)。
浏览器通常会把DOM和JavaScript独立实现。
比如在IE中,Js位于jscript.dll文件中,DOm的实现则存在于mshtml.dll中。
在Safari中的DOM和渲染是使用WebKit中的WebCore实现,JavaScript部分是由独立的JavaScriptCore引擎来实现。
在Chrome中,同样使用WebKit中的WebCore库来渲染页面,但JavaScript引擎是他们自己研发的名为V8。
在Firefox的JavaScript引擎名为SpiderMonkey,于名为Gecko的渲染引擎相互独立
DOM和JavaScript相互独立的特性,意味着他们之间相互通信的成本和速度。
DOM访问于修改
减少DOM的访问次数,把运算尽量留在ECMAScrip这一端处理。
// bad
function innerHTMLLoop() {
for (var count = 0; count < 15000; count++) {
document.getElementById('here').innerHTML += 'a';
}
}
// better
function innerHTMLLoop() {
var content = '';
for (var count = 0; count < 15000; count++) {
content += 'a';
}
document.getElemetnById('here').innerHTML += content;
}
把运算留给ECMAScript这一端处理带来的一个好处是:innerHTMLLoop2()比innerHTMLLoop()快上百倍
innerHTML对比DOM方法
在除过最新版的WebKit内核之外的所有浏览器中,对于DOM元素的修改中innerHTML相比于**document.createElement()**会更快一些。
如果在一个队性能有着苛刻要求的操作中更新一大段HTML,推荐使用innerHTML,因为它在绝大部分浏览器中都运行的更快。但对大多数日常操作而言,并没有太大区别,所以你更应该根据可读性,团队习惯,代码风格来综合决定使用哪种方式!
节点克隆
使用DOM方法更新页面内容的另一个途径是使用克隆已有元素,而不是创建新元素!即使用element.cloneNode()替代document.createElement();
HTML集合
HTML集合时包含了DOM节点引用的类数组对象。例如:
- document.getElementsByTagName();
- document.getElementsByClassName();
- document.getElementsByTagName();
- document.images;
- document.links;
- document.forms;
- document.forms[0].elements;
HTML集合以一种“假定实时态”实时存在,这意味着当底层文档对象更新时,他也会自动更新
昂贵的集合
集合的实时性:
// 死循环
var alldivs = documents.getElementsByTagName('div');
for (var i = 0; i < alldivs.length; i++) {
document.body.appendChild(document.createElement('div'));
}
在div的遍历过程中,新增div元素。但是退出循环的条件是div的length。然鹅每新增一个元素,length就会发生变化。最终导致了死循环!
因此访问集合的代价是高昂的。我们怎么做出优化呢?
将HTMl集合拷贝到普通数组,将length放到局部变量中来使用
var coll = document.getElementsByTagName('div');
var ar = toArray(coll);
// bad
function loopCollection() {
for (var count = 0; count < coll.length; count++) {
// 代码处理
}
}
// better
function loopCopiedArray() {
for (var count = 0; count < arr.length; count++);
// 代码处理
}
在相同的内容和数量下,遍历一个数组的速度明显快于一个HTML集合!
在每次迭代过程中,读取元素集合的length属性会引发集合进行更新。我们只需要把length属性缓存到局部变量中即可:
function loopCachLengthCollection() {
var coll = document.getElementsByTagName('div'),
len = coll.length;
for (var count = 0; count < len; count ++) {
// 代码处理
}
}
此方法的运行速度和loopCopiedArray()一样快。
toArray()可作为通用的集合转数组的方法
访问集合元素时使用局部变量
当遍历一个集合时,第一优化原则是把集合存储在局部变量中,并把length缓存在循环外部,然后使用局部变量替代这些需要多次读取的元素。
// bad
function collectionGlobal() {
var coll = document.getElementsByTagName('div'),
len = coll.length,
name = '';
for (var count = 0; count < len; count++) {
name = document.getElementsByTagName('div')[count].nodeName;
name = document.getElementsByTagName('div')[count].nodeType;
name = document.getElementsByTagName('div')[count].tagName;
}
return name;
};
// good
function collectionLocal() {
var coll = document.getElementsByTagName('div'),
len = coll.length,
name = '';
for (var count = 0; count < len; count++) {
name = coll[count].nodeName;
name = coll[count].nodeType;
name = coll[count].tagName;
}
return name;
}
// better
function collectionLocal() {
var coll = document.getElementsByTagName('div'),
len = coll.length,
el = null,
name = '';
for (var count = 0; count < len; count++) {
el = coll[count];
name = el.nodeName;
name = el.nodeType;
name = el.tagName;
}
return name;
}
在循环中使用局部变量存集合金庸和集合元素带来的速度提升
遍历DOM
DOM API提供了多种读取文档结构中的特定部分。当你需要从多方案中选择时,最好为特定操作选择最高效的API。
大部分浏览器中nextSibling和childNodes查询DOM的速度是非常接近的。
但在老版本的IE中推荐使用nextSibling来查找DOM节点,其他情况取决于个人或团队偏好。另外需要记住的是childrenNodes是一个元素集合记得缓存length
元素节点
在某些情况下,只需要访问元素节点,因此在循环中很可能需要检查返回节点的类型病过滤掉非元素节点。这些类型检查和过滤其实是不必要的DOM操作
大部分现在浏览器提供的API只返回元素节点。如果可以的话推荐使用这些API。这些属性如下:
属性名 | 被替代的属性 |
---|---|
children | childNodes |
childElementCount | childNodes.length |
firstElementChild | firstChild |
lastElementChild | lastChild |
nextElementSibling | nextSibling |
previousElementSibling | previousSibling |
选择器API
JavaScript库提供了大量选择器,但是如果条件允许的情况,还是建议使用querySelectorAll()和querySelector()
为什么说建议使用querySelectorAll();
var elements = document.querySelectorAll('#menu a');
elements的值包含了一个引用列表,只想位于id=”menu”的元素之中的所有a元素。
querySelectorAll()方法使用css选择器作为参数并返回一个nodeList,包含匹配节点的类数组对象。不会返回HTML集合,因此返回的节点不会对应实时的文档结构。
重绘于重排
本节内容属于开发者不常接触的层面,因此总结较为详细。
DOM树:表示页面结构.渲染树:表示DOM节点如何显示
DOM树中的每一个需要显示的节点在渲染树中至少存在一个对应的节点(隐藏的节点在渲染树中没有对应的节点)。渲染树中的节点被称为帧或盒,符合CSS模型的定义,一旦DOM盒渲染树构建完成,浏览器就开始显示绘制页面元素
当DOM的变化影响了元素的几何属性(宽和高),比如改变边框宽高或给段落增加文字,导致行数增加,浏览器需要重新计算元素的几何属性,同样其他元素的几何属性和位置也会因此受到影响。浏览器会使渲染树中受到影响的部分失效,病重新构造渲染树。这个过程被称为重排(reflow)。完成重排后,浏览器会重新绘制受影响的部分到屏幕中,该过程为重绘(repaint)。
并不是所有的DOM变化都会影响DOM的重排,比如当修改了某元素的color属性后,是不会触发一次重绘。
重绘和重排都是代价非常昂贵的操作,他们会导致Web应用程序的UI反应迟钝。所以应当尽可能减少这类过程的发生
重排何时触发
- 添加或删除可见的DOM元素。
- 元素位置改变。
- 元素尺寸改变(包括:外边距,内边距,边框厚度,宽度,高度等属性改变)。
- 文本内容的改变。
- 页面渲染器初始化
- 浏览器窗口尺寸改变。
有些改变会触发整个页面的重排:例如,当滚动条出现时。
渲染树变化的排队于刷新
大多数浏览器通过队列修改并批量执行来优化重排过程。
有以下方法会触发强制刷新队列并要求计划任务立刻执行。
- offsetTop,offsetLeft,offsetWidth,offsetHeight
- scrollTop,scrollLeft,scrollWidth,scrollHeight
- clientTop,clientLeft,clientWidth,clientHeight
- getComputedStyle()(currentStyle in IE)
以上属性和方法需要返回最新的布局信息,因此浏览器不得不执行渲染列队中的“带处理变化”并触发重排以返回正确的值。
在修改样式的过程中,最好避免使用上面列出的属性。
// 定义变量获取样式
var computed,
tmp = '',
bodyStyle = document.body.style;
if (document.body.currentStyle) {
computed = document.body.currentStyle;
} else {
computed = document.defaultView.getComputedStyle(document.body,'');
}
// bad
bodyStyle.color = 'red';
tmp = computed.backgroundColor;
bodyStyle.color = 'white';
tmp = computed.backgroundImage;
bodyStyle.color = 'green';
tmp = computed.backgroundAttachment;
// better
bodyStyle.color = 'red';
bodyStyle.color = 'white';
bodyStyle.color = 'green';
tmp = computed.backgroundColor;
tmp = computed.backgroundImage;
tmp = computed.backgroundAttachment;第一段
body的字体颜色被修改了三次,然后读起来三个computed的属性,尽管与color无关,但是依然需要刷新渲染队列于重排。因为computed的样式属性被请求了。
一个更有效的方法是,不要再布局信息改变时查询它。
最小化重绘于重排
1.1改变样式
合并代码样式的修改,减少重排
var el = document.querySelector('.mydiv');
el.style.borderLeft = '1px';
el.style.borderRight = '2px';
以上代码会触发两次重排。更好的做法是合并样式的修改减少重排
el.style.cssText = 'border-left:1px;border-right: 2px;'
// 如果不想覆盖原有样式
el.style.cssText += ';border-left: 1px;border-right: 2px;'
这样的做法浏览器仅会触发一次重排。
1.2修改class类型减少重排**
它有助于保持你的脚本于免除显示性代码,尽管他可能带来轻微的性能影响,因为改变时需要检查级联样式。
var el = document.querySelector('.mydiv');
el.className = 'active';
2.1批量修改DOM
当需要对DOM元素进行一系列操作时,可以通过以下步骤来减少重绘和重排的次数:
- 使元素脱离文档流。
- 隐藏元素,应用修改,重新显示
- 使用文档片断在当前DOM之外构建一个子树,再把它拷贝回文档。
- 将原始元素拷贝到一个脱离文档的节点中,修改副本,完成后再替换原始元素。
- 对其应用多重改变
- 把元素带回文档中
<ul id="mylist">
<li><a href="https://www.bgwhite.cn">Stoyan</a></li>
<li><a href="http://manage.bgwhite.cn">Amily</a></li>
</ul>
如果要循环一个数据去插入列表
var data = [
{
"name": "Tok",
"url": "http://www.baidu.com"
},
{
"name": "Ros",
"url": "http://jianshu.com"
}
]
// 公用的更新节点的方法
function appendDataToElement(appendToEle, data) {
var a,li;
for (var i = 0; max = data.length; i < max; i++) {
a = document.createElement('a');
a.href = data[i].url;
a.appendChild(document.createTextNode(data[i].name));
li.appendChild(a);
appendToEle.appendChild(li);
}
};
更新列表最长见得做法,也是笔者之前会毫不犹豫使用的方法
var ul = document.getElementById('mylist');
appendDataToElement(ul, data);
data数组的每一个新条目被附加到当前DOM树时都会导致重排。
一个减少重排的方法是通过改变display属性,临时从文档中删除ul元素,然后再恢复它。
var ul = document.getElementById('mylist');
ul.style.display = 'none';
appendDataToElement(ul, data);
ul.style.display = 'block';
另一个减少重排次数的方法是创建文档片断,然后把它附加到原始列表中。
var fragment = document.createDocumentFragment();
appendDataToElement(fragment, data);
document.getElementById('mylist').appendChild(fragment);
第三种方案是克隆节点,然后对副本进行操作。操作完成再替换旧的节点,有点vue中根据diff算法进行渲染的意思。
var old = document.getElementById('mylist');
var clone = old.cloneNode(true);
appendDataToElement(clone, data);
old.parentNode.replaceChild(clone, old);
推荐尽可能的使用第二种优化方案,因为它所产生的DOM遍历和重排次数最少。唯一潜在的问题是文档片断未被充分利用,有些团队成员可能不熟悉这项技术。
缓存布局信息
前文所述都是浏览器尝试通过队列化修改和批量执行的方式最小化重排次数。
还有另一个场景也是我们可以优化的地方:比如偏移量,滚动位置或计算出的样式时,浏览器为了返回最新值,会刷新对垒并应用所有变更。所以我们应该尽可能的减少布局信息的获取次数,然后把它复制给局部变量。
// bad
myElement.style.left = 1 + myElement.offsetLeft + 'px';
myElement.style.top = 1 + myElement.offsetTop + 'op';
if (myElement.offsetLeft >= 500) {
stopAnimation();
}
// better
current++
myElement.style.left = current + 'px';
myElement.style.top = current + 'px';
if (current >= 500) {
stopAnimation();
}
事件委托
循环的元素都绑定事件时,要么加重了页面负担,要么增加了运行期的执行时间,需要修改的dom元素越多,应用程序也就越慢。
一个简单而优雅的处理DOM事件的技术是事件委托。
通过给某父元素添加事件,通过判断点击源是否为我们需要处理的元素。以一般来说会有三个阶段
捕获,传递,冒泡阶段
总结
访问和操作DOM是现在Web应用的重要部分。但每次穿越链接ECMAScript和DOM两个岛屿之间的桥梁,都会收取“过桥费”。为了减少DOM编程带来的性能损失,请记住一下几点:
- 最小化DOM访问次数,尽可能在JavaScript端处理
- 如果需要多次访问某个DOM节点,请使用局部变量存储它的应用
- 小心处理HTML集合,因为它实时联系着底层文档。把集合的长度缓存到一个变量中,并在迭代中使用它。如果需要经常操作集合,建议把它拷贝到一个数组中。
- 如果可能的话,使用速度更快的API,比如querySelectorAll()和firstElementChild.
- 要留意重绘和重排,批量修改样式时,“离线”操作DOM树,使用缓存,并减少访问布局信息的次数。
- 动画中使用绝对定位,使用拖放代理。
- 使用事件委托来减少事件处理器的数量。
算法和流程控制
前言
代码的真题结构是影响运行速度的主要因素之一。代码数量多少并不能决定执行速度的快慢。代码的组织结构和解决问题的具体思路是影响代码性能的主要因素!
循环类型
JavaScript中常见的循环共有四种类型:
- 标准for循环,初始化,前测条件,循环体,后执行体四部分
for (var i =0;i<10;i++){}
- white循环
var i = 0;
while(i<10) {}
- do while后测循环
var i = 0;
do {} while(i++ < 10)
- for in 循环
for (var i in object) {}
在上述四种循环类型中。for in循环最终只有其他类型的1/7.因为它在迭代的过程中会同时搜索实力或原型属性。
Tip:不要用for in循环来遍历数组成员。
循环性能
深究哪种循环最快是没有意义的。循环类型的选择应该基于需求而不是性能!
// bad
for (var i = 0; i<arr.length;i++) {}
// good
for (var i =0,len = arr.length;i<len;i++) {}
// better
for (var i = arr.length;i--) {}
第三种倒叙循环在使用时,我们需要注意的是使用的场景,配出额外操作带来的影响。
倒叙循环:每个控制条件只是简单的于0比较。控制条件与true比较时,任何非零数会自动转换为true,而0值等同于false。实际上控制条件已经从两次比较(迭代数少于总数嘛?它是否为true?)减少到了一次比较(他是否为true)。进一步提升了循环速度。
Duff’s Device
达夫设备:限制循环迭代次数的一种模式。是一个循环展开技术,它使得一次迭代中世纪上执行了多次迭代的操作。每次循环中最多可调用8次process().循环的迭代次数为总数除以8.用变量存放余数。如果是12次,那第一次循环就是4次,第二次循环就是8次,代替一共的12次。
var i = arr.length % 8;
while(i) {
process(arr[i--]);
}
i = Math.floor(arr.length / 8);
while(i) {
process(arr[i--]);
}
使用建议:迭代次数超过1000时,Suff’s Device的执行效率才会有明显提升
基于函数的迭代
forEach,map,filter,find等都是基于函数的迭代。如果在运行速度要求严格时,基于函数的迭代不是合适的选择。
条件语句
当条件数量越大,越倾向于使用switch而不是if-else。通常归结于代码的易读性。
大多数情况下,switch比if-else的运行要快。但只有当条件数量很大时,才快的明显。
优化if-else
判断条件的位置应该是最可能出现的判断存放在首位
if (val < 5) {
} else if (val > 5 && val < 10){
} else {}
判断条件较为平均时,可以重写为嵌套的if-else
// bad
if (val==0){
return 0;
}else if(val==1){
return 1;
}else if(val==2){
return 2;
}else if(val==3){
return 4;
}......
// good
if (val < 6) {
if (val < 3) {
if (val==0){
return 0;
} else if (val==1) {
return 1;
} else if (val==2) {
return 2;
}
} else {
if (val==3) {
return 3;
} else if (val==4) {
return 4;
} else if (val==5) {
return 5;
}
}
} else {
if (val < 8) {
} else {
if (val==8) {
...
}...
}
}
采用二分法把值域分成一系列的区间,然后逐步缩小范围。但是相对于易读性而言却是不太友好的。
查找表
当有大量的离散值需要测试时,查找表无疑是更好的一种方式。
var results = [0,1,2,3,4,5,6,7,8,9,10];
return results[val];
查找表:必须完全抛弃条件语句,将过程变成数组项查询或对象成员查询。
查找表的一个优点是:不用书写任何条件判断语句。可扩展性好,不会产生额外的性能开销~
递归
递归可以把复杂的算法变简单。但是由于浏览器的不同,我们可能遇到浏览器的“调用栈大小限制”
为了避免因递归造成的整体功能的影响。我们可以使用try和catch来进行错误的捕获。但是并不推荐这么做。那些有潜在的调用栈溢出问题的脚本就不应该发布上线。
调用模式
- 直接递归模式
function test() {
test();
}
test();
- 隐伏模式
function first() {
second();
}
function second() {
first();
}
first();
隐伏模式是经常出现问题的递归使用。两个函数相互调用,一个不慎变回造成永恒的循环中。
建议改用迭代,Memoization。或者两者结合使用。
迭代
运行一个循环,总比反复调用一个函数的开销要小的多。
合并排序算法是最常见的用递归实现的算法。
function merge(left,right) {
var result = [];
while(left.length > 0 && right.length >0) {
if (left[0] < right[0]) {
result.push(left.shift());
} else {
result.push(right.shift());
}
}
return result.concat(left).concat(right);
}
// bad 递归
function mergeSort(items) {
if (items.length == 1) {
return items;
}
var middle = Math.floor(items.length / 2),
left = items.slice(0, middle),
right = items.slice(middle);
return merge(mergeSort(left),mergeSort(right));
}
// good 迭代
function mergeSort(items) {
if (items.length == 1) {
return items;
}
var work = [];
for (var i = 0, len = items.length;i < len;i++) {
work.push([items[i]]);
}
work.push([]); // 如果数组长度为技术
for(var lim = len;lim > 1;lim = (lim+1) / 2) {
for (var j =0,k=0; k < lim; j++,k +=2) {
work[j] = merge(work[k],work[k+1]);
}
work[j] = []; // 如果数组长度为技术
}
return work[0];
}
迭代的执行效率或许并没有递归快,但是可以有效避免浏览器执行栈限制的问题。
Memoization
减少工作量就是最好的性能优化技术。Memoization缓存前一个计算结果供后续计算使用,避免重复计算。
function factorial(n) {
if (n == 0) {
return 1;
} else {
return n * factorial(n-1);
}
}
// good
function memfactorial(n) {
if (!memfactorial.cach) {
memfactorial.cach = {
'0': 1,
"1": 1
};
}
if (!memfactorial.cach.hasOwnProperty(n)) {
memfactorial.cach[n] = n * memfactorial (n - 1);
}
return memfactorial.cach[n];
}
简单来说可以理解为将可预见的简单的运行进行缓存,供后续计算直接使用。
总结
- for,while和do-while循环性能特性相当,并没有一种循环类型明显快于或慢于其他类型。
- 避免使用for-in循环,除非你需要遍历一个属性数量未知的对象。
- 改善循环性能的最佳方式就是减少每次迭代的运算量和减少循环迭代次数。
- 通常来说,switch总是比if-else快,但并不总是最佳解决发难。
- 在判断条件较多时,可使用查找表答题if-else和switch。
- 浏览器的调用栈大小限制了递归算法在JavaScript中的应用,我们可以通过try-catch捕获减少影响。或者我们将递归更改为迭代算法和Memoization或者两者结合使用来避免上述调用栈大小限制的问题。
字符串和正则表达式
前言
对字符串的拼接,正则的匹配如何更高效的完成,我们该关注那些点来进行优化是本章节主要钻研的问题。本章节需要有一定的正则的使用基础。
关于正则的使用篇大家可以查阅《JavaScript基础捡漏之正则表达式》
字符串拼接
JavaScript中字符串的拼接方式常用的有四种方法:
方法 | 示例 |
---|---|
The + Operator | str = ‘a’ + ‘b’ + ‘c’ |
The += Operator | str = ‘’a’; |
str +=’b’; | |
str+= ‘c’; | |
array.join() | str = [‘a’,’b’,’c’].join(‘’); |
string.concat() | str = str.concat(‘b’,’c’); |
字符串较少时,使用熟悉的方法,随着需要合并的字符串的长度和数量增加,有一些方法开始展现出优化。
str += 'one' + 'two'; // bad
上述代码的大概运行步骤
- 在内存中创建临时字符串。
- 连接后的字符串’onetwo’被赋值给临时字符串。
- 临时字符串与str当前的值连接。
- 结果赋值给str。
str += 'one';
str += 'two';
str = str + 'one' + 'two'; // good
这里的两行代码是对上述代码作出的优化,避免了临时字符串的产生以提高了性能。
原理
赋值表达式由str开始作为基础,每次给他附加一个字符串,由左向右一次连接,因此避免了使用临时字符串。
如果改变连接顺序(例如:str = ‘one’ + str + ‘two’),其他浏览器会尝试为表达式左侧的字符分配更多的内存,然后简单的将第二个字符拷贝第一个字符的末尾。
如果在一个循环中,基础字符串位于最左端的位置,就可以避免拷贝一个逐渐变大的基础字符串。
FireFox的优化
在赋值表达式中所有要连接的字符串都属于编译器常量,FieFox会在编译 过程中自动合并他们。有个方法可以看到这一过程。
function foldingDemo() {
var str = 'compile' + 'time' + 'folding';
str += 'this' + 'works' + 'too';
str = str + 'but' + 'not' + 'this';
}
alert(foldingDemo().toString());
// FireFox
function foldingDemo() {
var str = 'compiletimefolding';
str += 'thisworkstoo';
str = str + 'but' + 'not' + 'this';
}
通过减少中间字符串,减少连接过程中的时间来提升效率。
但是更多的时候是运行期的数据构建字符串,而不是编译器常量。
数组项合并
arr.join("");
仅在IE7及更早的版本中拥有高效的处理。但是在大多数主流浏览器中,比其他字符串的拼接方法更慢。
字符串concat合并
str = str.concat(s1);
str = str.concat(s1,s2,s3); // 合并多个字符串
str = String.ptototype.concat.apply(str,array); // 附加数组中的所有字符串。
多数情况下,使用concat比其他的=和+=稍慢,尤其在Chrome中慢的更明显。
正则表达式优化
两个正则表达式匹配相同的文本并不意味着他们有相同的速度。
当然不同的浏览器对正则表达式引擎有着不同程度的内部优化
正则表达式工作原理
第一步编译
当你创建了一个正则表达式对象(使用正则直接量或RegExp构造函数),浏览器会验证你的表达式,然后把它转化为一个原生代码程序,用于执行匹配工作。如果你把正则对象赋值给一个变量,可以避免重复执行这一步骤。
第二步设置起始位置
当正则类进入使用状态,首先要确定目标字符串的起始搜索位置。
他是字符串的起始字符,或者由正则表达式的lastIndex属性指定,但是当他从第四步骤返回到这里时,此位置则在最后一次匹配的起始位置的下一位字符的位置上。
浏览器厂商优化正则表达式引擎的办法是,通过提前决定跳过一些不必要的步骤,来避免大量无意义的工作。
举个例子,如果正则表达式由^开始,IE和Chrome通常会判断字符串的起始位置能否匹配,如果匹配失败,那么可以避免愚蠢的搜索或许位置。
另一个例子是匹配第三个字母是x的字符串,一个聪明的做法是先找到x,然后再将起始位置回退两个字符(最新版本的Chrome包含了这项优化)。
第三步匹配每个正则表达式字元
一旦正则表达式知道开始位置,他会诸葛检查文本和正则表达式模式。当一个特定的子元匹配失败是,正则表达式会试着回溯到之前尝试匹配的位置上,然后尝试前天可能的路径。
第四步匹配成功或失败
如果在字符串当前的位置发现了一个完全匹配,那么正则表达式宣布匹配成功。
如果正则表达式所有的可能路径都没有匹配到,正则表达式引擎会回退到第三步,然后从下一个字符重新尝试。当字符串的每个字符(以及最后一个字符串后面的位置)都经理这个过程,如果还没有成功匹配,那么正则表达式就宣布彻底匹配失败。
理解回溯
回溯是匹配过程的基础组成部分。尽量回溯只是影响整体性能的其中一环,但理解他的工作原理以及如何最少化地使用它可能是编写高效正则表达式的关键所在。
当正则表达式匹配目标字符串时,它从左到右逐个测试表达式的组成部分,看是否能找到匹配项。在遇到量词和分支时,需要决定下一步如何处理。
如果遇到量词(诸如:*,+?或{2,}),正则表达式需决定何时尝试匹配更多字符;如果遇到分支(来自|操作符),那么必须从可选项中选择一个尝试匹配。
每当正则表达式做类似的决定时,如果有必要,就会记录其他选择,以备返回时使用。
发生回溯的场景
回溯是正则的低效之源,如果条件允许,我们应尽可能的避免回溯。
- 分支字符
- 贪婪量词
回溯失控
当正则表达式导致你的浏览器家私数秒,数分钟,甚至更长时间,问题很可能是因为回溯失控。
有哪些方式可以尽可能的避免回溯呢?
具体化
尽可能具体化分隔符之间的字符串匹配模式。比如模式".*?",它用来匹配一个双引号之间的任何字符串。通过吧这个过于宽泛的.\*?替换成更为具体的\[^"\r\n"]*,就去除了回溯时可能发生的几种情况。
使用预查和反向引用的模拟原子组
一旦原子组中存在一个正则表达式,改组的任何回溯位置都会被丢弃。
如果你将[\s\S]?序列和他后后面的HTML标签放在一个原子组中,每当所需要的的HTML标签被发现一次,这次匹配基本上就被锁定了,如果该正则表达式的后续部分匹配失败,原子组中的量词不会记录回溯点,因此[\s\S]?序列已经匹配的部分不会再被展开。
但是JavaScript不支持原子组,需要通过模拟原子组来实现。原子组语法:
(?=(pattern to make atomic))\1
demo:
/(A+A+)+B/; // bad
/AA+B/; // good
/((?=(A+A+))\2)+B/; // good
更多提高正则表达式效率的方法
关注如何让匹配更快失败
正则表达式慢的原因通常是匹配失败的过程慢,而不是匹配成功的过程慢。这是因为如果你使用正则表达式匹配一个大字符串的一小部分时,该正则表达式匹配失败的位置比匹配成功的位置要多得多。
正则表达式以简单,必须的子元开始
最理想的情况是,一个正则表达式的起始标记应该尽可能地快速测试并排除明显不匹配的位置。
一个好的起始标记通常是一个锚(^或$),特定字符串(x或\u263A),字符类([a-z]或\d的速记符)和单词边界(\b)。
如果可以尽量避免以分组或选择资源开头,避免类似/one|two的顶层分支,因为
使用量词模式,使他们后面的字元互斥
当字符与字元相邻或子表达式能够重叠匹配时,正则表达式尝试拆解文本的路径数量将增加。
为避免这种情况,尽量具体化你的匹配模式。当你想表达”[^”\s\n]“时,不要使用”.?”(它依赖回溯)
减少分支数量。缩小分支范围
分支使用竖线|可能要求在字符串的每一个位置上测试的所有分支选项。你通常可以通过使用字符集合选项组来减少分支的需求,或将分支在正则表达式的位置退后。
替换前 | 替换后 |
---|---|
cat|bat | [cb]at |
red|read | rea?d |
red|raw | r(?:ed|aw) |
(.|\r|\n) | [\s\S] |
字符集比分支更快,因为他使用位向量(或其他快速实现方式)而不是回溯。
当分支必不可少时,将常用分支放到前面(在不影响正则表达式匹配的前提下)。分支选项一般从左向右一次尝试,一个选项被匹配到的机会越多,你越希望他尽快被检测到。
使用非捕获组
捕获组需要消耗时间和内存来记录反向引用,并使它保持最新。如果你不需要一个反向引用,可使用非捕获组来避免这些开销,比如使用(?:xxx)来代替(xxx)。当需要全文匹配的反向引用时,人们喜欢把正则表达式包装在一个捕获组中。这不是必要的,因为你可以使用其他方法引用全文匹配,比如使用regex.exec()返回数组的第一项,或在替换字符串中使用$&。
只捕获感兴趣的文本以减少后续处理
作为上一条的补充说明,如果你需要引用匹配的一部分,应该采取一切手段捕获那些片段,在使用反向引用来处理。例如,如果你正在编写代码处理一个正则表达匹配到引号括起来的字符串内容,应该使用/"(\[^"]*)"/并使用反向引用处理,而不是使用/"\[^"]*"/,当在循环中使用时,减少这类工作可以节省大量时间。
暴露必须的字元
为了帮助正则表达式引擎在优化查询过程中做出明智的决策,可尝试让他更容易地判断哪些字元是必须的。当字元应用在子表达式或分支中,正则表达式引擎很难判断他们是不是必须的。有些引擎甚至不做次方面的尝试。
例如:正则表达式/(^(ab|cd)/暴露他的字符串起始锚。IE和Chrome注意到了这一点,并组织正则表达式查找首字符以外的匹配,因此使查找瞬间完成而不用关心字符串长度。然而,由于等价正则表达式/(^ab|^cd)/没有暴露他的锚^,IE无法做出同样的优化,最终无异议的搜索字符串并在每一个位置匹配。
使用合适的量词
贪婪和惰性量词的匹配过程有较大区别,即便是处理相同的字符串。使用更合适的量词类型(基于预期的回溯数量)可以显著提升性能,尤其是在处理长字符串时。
把正则表达式复制给变量并重用他们
将正则表达式赋给变量以避免对他们重新编译。有的人更是夸张的使用正则表达式缓存策略,以避免对给定的模式和标记组合进行多次编译。其实不必如此,正则表达式编译很快,这样的策略所增加的负担可能超过他们所避免的。重要的是避免在循环体中重复编译正则表达式。
// bad
while (/regex1/.test(str1)) {
/regex2/.exec(str2)
}
// good
var regex1 = /regex1/,
regex2 = /regex2/;
while (regex1.test(str1)) {
regex2.exec(str2)
};
将复杂的正则表达式拆分为简单的片段(化繁为简)
避免在一个正则表达式中处理太多任务。复杂的搜索问题需要条件逻辑,拆分为两个或多个正则表达式更容易解决,通常也会更高效,每个正则表达式只在最后的匹配结果中执行查找。在一个模式中完成所有任务的怪兽正则表达式很难维护,而且最终以引起回溯相关的问题。
使用正则表达式的时机
当你搜索那些并不依赖正则表达式复杂特性的字符串时,所有的字符串方法都很快,他们有助于避免正则表达式带来的性能开销。
例如:检测字符串结尾是不是分号
/;$/.test(str); // 没必要
str.charAt(str.length - 1) == ';';
去除字符串首位空白
去除字符串首尾空白的正则表达式写法有很多中,就目前来说跨浏览器的最好的方法是:使用两个子表达式
this.replace(/^\s/,'').replace(/\s$/,''); // best
this.replace(/^s+|\s+$/g,'');
this.replace(/^\s*([\s\S]*?)\s*$/, '$1');
this.replace(/^\s*([\s\S]*\S)?\s*$/,'$1')
this.replace(/^\s*(\S*(\s+\S+)*)\s*$/,'$1');
总结
- 当连接数量巨大或尺寸大的字符串时,数组项合并是唯一在IE7及更早版本中性能合理的方法。
- 如果不需要考虑IE7级更早版本的性能,数组项合并是最慢的字符串连接方法之一。推荐使用简单的+和+=操作符替代,避免不必要的中间字符串。
- 回溯既是正则表达式匹配功能的基本组成部分,也是正则表达式的低效之源。
- 回溯失控发生在正则表达式本应快速匹配的地方,但因为某些特殊的字符串匹配动作导致运行缓慢甚至浏览器崩溃。避免这个问题的办法是:使相邻的字元互斥,避免嵌套量词对同一字符串的相同部分多次匹配,通过重复利用预查的原子组去除不必要的回溯。
- 提高正则表达式效率的各种技术手段会有助于正则表达式更快的匹配,并在非匹配位置上花更少的时间。
- 正则表达式并不总是完成工作的最佳工具,尤其当你值搜索字面字符串的时候。
- 尽管有许多方法可以去除字符串的首尾空白,但使用两个简单的正则表达式(一个去除头部,一个去除尾部),来处理大量字符串内容能提供一个简洁而跨浏览器的方法。从字符串末尾开始循环向前搜索第一个非空白字符,或者将此技术同正则表达式结合起来,会提供一个更好的替代方案,他很少受到字符串长度影响。
快速响应的用户界面
前言
再也没有比点击网页后毫无动静更令人沮丧的事情了。我们常见的”请不要重复提交“的信息提示之所以会遇到很大程度就是因为事务性的Web应用含有较大的性能问题。
所以确保Web应用的响应速度是一个非常重要的性能关注点。
浏览器的UI线程
用于执行JavaScript和更新用户界面的进程通常称为”浏览器UI线程“。
大多数浏览器在JavaScript运行时,会停止把新任务加入UI线程的队列中,也就是说JavaScript任务必须尽快结束,以避免对用户体验造成不良影响。
浏览器限制
浏览器限制了JavaScript任务运行时间。这种限制是有必要的,它可以确保某些恶意代码不能通过永不停止的密集操作锁住用户的浏览器或计算机。此类限制分两种:
- 调用栈大小限制
- 长时间运行脚本限制
浏览器 | 限制 |
---|---|
IE4+ | 500万条语句 |
Firefox | 10s |
Safari | 5s |
Chrome | 没有单独的长运行脚本限制,依赖其通用崩溃检测系统来处理 |
Opera | 没有厂运行脚本限制 |
多久才算太久
浏览器允许脚本持续运行好几秒,但这并不意味着你也允许它这样做。事实上,为了更好的用户体验,你的JavaScript代码运行的持续时间应当远远小于浏览器的限制。
事实证明,哪怕是一秒钟,对脚本运行而言太长了。单个JavaScript操作花费的总时间(最大值)不应该超过100ms。
由于JavaScript运行时无法更新UI,所以如果JavaScript运行时间超过100ms,用户就会感觉失去了对界面的控制。
使用定时器让出时间片段
尽管你尽了最大努力,但难免会有一些复杂的JavaScript任务不能在100ms或更短时间内完成。这个时候最理想的方法是让出UI线程的控制权,使得UI可以更新。让出控制权意味着停止执行JavaScript,使UI线程有机会更新,然后再继续执行JavaScript。于是JavaScript定时器走进来我们的视野。
setInterval()和setTimeout()几近相同,除了前者会重复添加JavaScript任务到UI队列。他们最主要的区别是,入股UI队列中已经存在由同一个setInterval()创建的任务,那么后续任务不会被添加到UI队列中。
定时器的精度
JavaScript定时器延迟通常不太精确,相差大约几毫秒,正因如此,定时器不可用于测量实际时间。
在Windows系统中定时器分辨率为15ms,也就是说一个延时15ms的定时器将根据最后一次系统时间刷新而转换为0或15.设置定时器延时小于15会导致IE锁定,所以延迟的最小值建议为25ms以确保至少有15ms延迟。
使用定时器处理数组
常见的一种造成长时间运行脚本的起因就是耗时过长的循环。如果你已经尝试了细品《高性能JavaScript》之算法与流程控制中介绍的循环优化技术,但是还是没能减少足够的运行时间,name下一步的优化就是使用定时器。他的基本方法就是把循环的工作分解到一系列定时器中。
是否可以使用定时器处理数组的两个决定因素
- 处理过程是否必须同步
- 数据是否必须按顺序处理
function timedProcessArray(items, process, cb) {
var todo = items.concat();
setTimeout(function() {
var start = +new Date();
do {
process(todo.shift());
} while (todo.length > 0 && (+new Date() - start < 50));
if (todo.length > 0) {
setTimeout(arguments.callee, 25);
} else {
cb(todo);
}
}, 25)
}
// 使用
var items = [{name:'张三',id:1},{name:'wangwu',id:2}];
function process(e) {
e.checked = false;
console.log('每一项数据', e);
};
timedProcessArray(items, process,function(data) {
console.log('complete', data);
})
Web Workers
本章节针对于Web Workers会单独再次深入研究。
Web Workers能使代码运行且不占用浏览器UI线程的时间。它没有绑定UI线程,却也意味着他不能访问浏览器的许多资源。
适用场景:
- 编码/解码字符串。
- 复杂数学运算(包括图像或视频处理)
- 大数组排序
做一个1亿次的循环,普通的for循环可能需要3000+ms。但是适用web workers后,可能只需要1ms。但是前提是浏览器的支持。
小结
- 任何JavaScript任务都不应该执行超过100ms。过长的运行时间会导致UI更新出现明显的延迟,从而对用户体验产生负面影响。
- JavaScript运行期间,浏览器响应用户交互的行为存在差异。无论如何,JavaScript长时间运行将导致用户体验变得混乱和脱节。
- 定时器可用来安排代码延迟执行,他使得你可以吧长时间运行脚本拆分成一系列的小任务。
- Web Workers是新版浏览器支持的特性,他运行你在UI线程外部执行JavaScript代码,从而避免锁定UI。
Web应用越复杂,积极主动的管理UI线程就越重要。即使JavaScript代码再重要,也不应该影响用户体验。
Ajax
前言
Ajax是高性能JavaScript的基础。他可以通过延迟下载体积较大的资源文件来使得页面加载速度更快。他通过异步的方式在客户端和服务端之间传输数据,从而避免页面资源一窝蜂地下载。他甚至可以只用一个HTTP请求就获取整个页面的资源。选择合适的传输方式和最有效的数据格式,可以显著地改善用户和网站的交互体验。
数据传输
Ajax通信从基本的层面来讲,是一种与服务器通信而无须重载页面的方法;数据可以从服务器获取或发送给服务器。有多种不同的方法建立这种通信通道。
请求数据
有5种常用技术用于向服务器请求数据:
- XMLHttpRequest(XHR)
- Dynamic script tag insertion 动态脚本注入
- iframes
- Comet
- Multipart XHR
在现代高性能JavaScript中使用的三种技术是:XHR,动态脚本注入和Multipart XHR。Cometheiframes(作为数据传输技术)往往用在极端情况下。
XMLHttpRequest
var url = '/data.php';
var params = [
'id=9123',
'limit=20'
];
var req = new XMLHttpRequest();
req.onreadystatechange = function() {
if (req.readyState === 4) {
var responseHeaders = req.getAllResponseHeaders();
var data = req.responseText; // 获取数据
}
}
req.open('GET',url + '?' + params.join('&'), true);
req.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
req.send(null);
优点:是目前最常用的技术,它允许异步发送和接收数据。所有主流浏览器对他都有完善的支持 ,而且还能精确地控制发送请求和数据接收。可以在请求头中添加任何头信息和参数,并读取服务器返回的所有头信息,以及响应文本。
缺点:跨域问题,低版本IE不支持”流”,也不会提供readyState为3的状态。处理大量数据将会很慢。
补充:当请求的URL加上参数的长度接近或超过2048个字符时,才应该使用POST获取数据。这是因为IE限制URL长度,过长时将会导致请求的URL被拦断。
动态脚本注入
var scriptElement = document.createElement('script');
scriptElement.src = 'http://any-domain.com/javascript/lib.js';
document.getElementsByTagName('head')[0].appendChild(scriptElement);
function jsonCallback(jsonString) {
var data = eval('(' + jsonString + ')');
// 处理数据
}
jsonCallback({'status':1,'colors':['#fff','#000','#ff0000']});
优点:支持跨域。通信快。
缺点:
- 不能设置请求头信息。
- 参数传递只能是GET方式。
- 不能设置请求的超时处理或重试;失败了也不一样知道。
- 不能访问请求的头信息,也不能响应消息作为字符串来处理。
- 它必须是可执行的JavaScript代码。
- 容易受到攻击。不安全
Multipart XHR
MXHR 允许客户端只用一个HTTP请求就可以从服务端向客户端传送多个资源。
它通过在服务端将资源(CSS文件,HTML片段,JavaScript代码或bease64编码的图片)打包成一个由双方的字符串分割的字符串并发送到客户端。然后用JavaScript代码处理这个长字符串,并根据他的mime-type类型和传入的其他”头信息”解析出每个资源。
// 一个http请求发送三个图片
$images = array('kitten.jpg','sunsetjpg','baby.jpg');
foreach($images as $image) {
$image_fh = fopen($image, 'r');
$image_data = fread($image_fh,filesize($image));
fclose($image_fh);
$payloads[] = base64_encode($image_data);
}
// 把字符串合并成一个长字符串,然后输入它
$newline = chr(1) // 该字符串不会出现任何base64字符串中
echo implode($newline,$payloads);
上述php代码读取三张图片,并把它们转换成base64编码的长字符串。他们之间用一个简单的Unicode编码的字符1链接,然后返回给客户端。
function splitImages(imageString) {
var imageData = imageString.split('\u0001');
var imageElement;
for (var i = 0, len = imageData.length; i < len; i++) {
imageElement = document.createElement('img');
imageElement.src = 'data:image/jpeg;base64,' + imageData[i];
document.getElementById('container').appendChild(imageElement);
}
}
此方法将链接后的字符串分解成三段。每一段用来创建一个图片元素然后将图片元素插入页面中。图片不是由base64字符串转换成二进制,而是使用data:URL的方式创建,并指定mime-type为images/jpeg。
在一次HTTP请求中向浏览器传送了三张图片。你也可以传送20张或100张图片,这样的话响应消息会更大,但仍然只用了一次HTTP请求。
任何数据类型都可以被JavaScript作为字符串发送
MXHR转换js代码,css样式和图片为可用的资源
function handleImageData(data,mimeType) {
var img = document.createElement('img');
img.src = 'data:' + mimeType + ';base64,' + data;
return img;
}
function handleCss(data) {
var style = document.createElement('style');
style.type = 'text/css';
var node = document.createTextNode(data);
style.appendChild(node);
document.getElementsByTagName('head')[0].appendChild(style);
}
function handleJavaScript(data) {
eval(data);
}
由于MXHR响应消息的体积越来越大,因此有必要在每个资源受到的时候就立刻处理,而不是等所有消息得到响应后再处理。
对上述js接受代码做出优化
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;
}
当readyState 为3的状态 第一次触发时,启动一个定时器,每隔15ms检查一次响应中的新数据。数据片段会被收集起来,直到发现一个分隔符,然后就把遇到分隔符之间收集的所有数据作为一个完整的资源进行处理。
缺点:
使用MXHR获取的资源无法被浏览器缓存。
老版本的IE不支持readyState为3的状态和data:URL
优点:
- 页面包含了大量其他地方用不到的资源(因此也无需缓存)。尤其是图片。
- 网站已经在每个页面中使用一个独立打包的JavaScript或CSS文件以减少HTTP请求;因为对每个页面来说这些文件都是唯一的。所以不需要从缓存汇中读取数据,除非重载页面。
- 减少了HTTP请求次数,三次握手,三次挥手
发送数据
当我们只需要给服务器发送数据时,有两种广泛的技术:XHR和信标(beacons)
XHR既可以作为数据获取也可以用来发送数据,这里就不再做阐述。
Beacons
信标:它的实现思路跟JSONP方式十分类似,不过它是借由new Image()的方式来实现数据的发送,无需向客户端发送任何回馈信息。
var url = '/status_tracker.php';
var params = [
'step=2',
'time=1248027314'
];
var beacon = new Image();
beacon.src = url + '?' + params.join('&');
beacon.onload = function() {
if (this.width == 1) {
// 成功
} else if (this.width == 2) {
// 失败,请重试并创建另一个信标
}
};
beacon.onerror = function() {
// 出错,稍后重试并创建另一个信标。
};
信标是想服务器回传数据最快且最有效的方式。如果你只关心发送数据到服务器(可能需要极少的返回信息),name请使用图片信标。
数据格式
当考虑数据格式时,唯一需要比较的标准就是速度。
数据格式有很多,优劣取决于要传输的数据一级它在页面上的用途,一种可能下载快,一种可能解析快。
XML
优势:极佳的通用性(服务端和客户端都完美支持),格式严格,且易于验证。几乎所有的服务器都有操作XML的类库。
<?xml version="1.0"encoding=utf-8?>
<users total="4">
<user id="1">
<username>alice</username>
<realname>Alice Smith</realname>
<email>alice@alicesmith com</email>
</user>
<user id="2">
<username>bob</username>
<realname>Bob Jones</realname>
<email>bob@bobjones com</email>
</user>
<user id="3">
<username>carol</username>
<realname> Carol Williams</realname>
<email>carol@carolwilliams com</email>
</user>
<user id="4">
<username>dave</username>
<realname> Dave Johnson</realname>
<email>dave@davejohnson com</email>
</user>
</users>
XML格式的缺点就是极其冗长,每个单独的数据片段都依赖大量的结构,所以有效数据的比例非常低,而且XML的语法比较模糊,当把一个数据结构转换成XML时,你应该吧对象参数放到对象元素的属性中,还是放在独立的子元素中?你应该使用描述清晰的长标签名,还是搞笑但难以辨认的短标签名?
响应解析成一个对象
function parseXML(responseXML)(
var users=[];
var userNodes = response.XMLgetElementsByTagName('users');
var node, usernameNodes, usernameNode,username,realnameNodes, realnameNode, realname,
emailNodes, emailNode, email;
for(var i=0, len = userNodes.length;i<len;i++)(
node = userNodes[i];
username = realname = email = '';
usernameNodes = node.getElementsByTagName('username');
if(usernameNodes && usernameNodes[0])(
usernameNode=usernameNodes[0];
username=(usernameNodes.firstchild) ? usernameNodes.firstchild.nodevalue : '';
}
realnameNodes=node.getElementsByTagName('realname');
if(realnameNodes&&realnameNodes[0])(
realnameNode = realnameNodes[0];
realname=(realnameNodes.firstchild) ? realnameNodes.firschild.nodevalue : '';
}
emailNodes = node. getElementsByTagName('email');
if (emailNodes && emailNOdes[0]) {
emailNode = emailNodes[0];
email = (emailNodes.firstChild) ? emailNodes.firstChild.nodeValue : '';
}
users[i] = {
id: node.getAttribute('id'),
username: username,
realname: realname,
email: email
};
}
return users;
}
关于XML的一个结构的优化方案是将减少结构标签,将内容移到属性上,减少文件尺寸,加快解析速度。
<?xml version="1.0" encoding="UTF-8"?>
<users total="4">
<user id="1-id001" username="article" realname="Alice Smith" email="alice@alicesmith.com" />
<user id="2-id001" username="bob" realname="Bob Jones" email="bob@bobjones.com" />
<user id="3-id001" username="carol" realname="Carol Williams" email="carol@carolwilliams.com" />
<user id="4-id001" username="dave" realname="Dave Johnson" email="dave@davejohnson.com" />
</users>
解析的方式同步优化:
function parseXML(responseXML) {
var users = [];
var userNodes = responseXML.getElementsByTagName('users');
for (var i =0,i = userNodes.length;i < len;i++) {
users[i] = {
id: userNodes[i].getAttribute('id'),
username: userNodes[i].getAttribute('username'),
realname: userNOdes[i].getAttribute('realname'),
email: userNodes[i].getAttribute('email')
};
}
return users;
}
XPath 是一门在 XML 文档中查找信息的语言。XPath 可用来在 XML 文档中对元素和属性进行遍历。
使用XPath在解析XML文档时比getElementsByTagName快许多。需要注意的是,他并未得到广泛支持。
因此必须使用旧式DOM便利方法编写降级的代码。
相比使用子标签,使用属性时文件更小,解析速度更快。但很大程度上是因为你不需要给予XML结构便利DOM,而只是简单的读取属性。
在高性能Ajax中,XML并没有立足之地
JSON
JSON格式的数据结构是现在主流的数据交互格式,但是它仍然有多重可优化的写法,尽管我们并不常用。
[
{
"id": 1, "username": "alice", "realname": "Alice Smith", "email": "alice@alicesmith.com"
},
{
"id": 2, "username": "sum", "realname": "Sum Smith", "email": "sum@summith.com"
}
]
进一步优化:
[
[1,'alice','Alice Smith','alice@alicesmith.com'],
[2,'sum','Sum Smith', 'sum@sumith.com']
]
但是相对于优化后的json格式而言,我们并不常用它,因为它的可读性非常低,维护非常的困难。
但是不得不说的是数组形式的JSON格式确实是尺寸最小,平均下载速度最快,平均解析速度最快。
JSON-P
在使用XHR时,JSON数据会被转换为字符串,紧接着被eval转换成原生对象。然鹅在使用动态脚本时,JSON数据会被当成另一个JavaScript文件并作为原生代码执行。为实现这一点,这些数据必须封装在一个回调函数里。这就是所谓的JSON填充或JSON-P。
数组形式的JSON-P相比较数组形式的JSON而言,比XHR的JSON格式而言稍快一点。
避免使用JSON-P:
因为他是可执行的JavaScript,它可能被任何人调用并使用动态脚本注入技术插入到任何网站。
另一方面JSON在eval前是无效的JavaScript,使用XHR时他只是被当做字符串被JavaScript获取,所以不要把任何敏感数据编码在JSON-P中,因为你无法确认它是否保持这私有调用状态,即便是带有甚至随机URL或坐了cookie判断。
HTML
HTML格式的通常用法即为我们所说的SSR模式,在服务端完成整个HTML结构的输出,页面的输出直接交由后端来实现,是很好的一种响应方式,因为他不用经过前端再次处理,可以直接使用。
自定义格式
自定义格式是一种没有任何约束的数据传输格式,不过需要注意的是前后端需要注意约定一个解析的格式,便于前端收到数据后,进行数据解析。
常用的就是传递一串字符串,多个属性通过分隔符分割,js收到字符串后。经过split()
当你决定使用自定格式时,最重要的决定之一是采用哪种分隔符
1:alice:Alice Smith:alice@alicesmith.com;
2:bob:BOb Jones:bob@bobjones.com;
3:carol:Carol Williams:carol@carolWilliams.com;
解析如下:
function parseCustomFormat(responseText) {
var users = [];
var usersEncoded = responseText.split(';');
var userArray;
for (var i = 0,i=usersEncoded.length;i<len;i++) {
userArray = usersEncoded[i].split(':');
users[i]= {
id: userArray[0],
username: userArray[1],
realname: userArray[2],
email: userArray[3]
};
}
return users;
}
对于非常大的数据收集,它是最快的格式,甚至在解析速度和平均加载时间上都能击败本地执行的JSON。当你需要在很短的时间内向客户端传送大量数据时可以考虑使用这种格式。
数据格式总结
通常来说数据格式越轻量越好。JSON格式和字符分隔的自定义格式是最好的。如果数据集很大并且对解析时间有要求,那么就从如下两种格式做出选择:
- JSON-P数据,使用动态脚本注入获取。它把数据当做可执行JavaScript而不是字符串,解析速度极快。它能跨域使用,但涉及敏感数据时不应该使用它。
- 字符分隔的自定义格式,使用XHR或动态脚本注入获取,用split()解析。这项技术解析大数据集比JSON-P略快,而且通常其他格式无法直接比较。
格式 | 大小 | 下载耗时 | 解析耗时 | 总耗时 |
---|---|---|---|---|
标准XML | 582960字节 | 999.4ms | 343.1ms | 1342.5ms |
标准JSON-P | 487913字节 | 298.2ms | 0.0ms | 298.2ms |
简化的XML | 437960字节 | 475.1ms | 83.1ms | 558.2ms |
标准的JSON | 487859字节 | 527.7ms | 26.7ms | 554.4ms |
简化的JSON | 392895字节 | 498.7ms | 29.0ms | 527.7ms |
简化的JSON-P | 392913字节 | 454.0ms | 3.1ms | 457.1ms |
数组JSON | 292895字节 | 305.4ms | 18.6ms | 324.0ms |
数组JSON-P | 292912字节 | 316.0ms | 3.4ms | 319.4ms |
自定义格式(脚本注入) | 222912字节 | 66.3ms | 11.7ms | 78.0ms |
自定义格式(XHR) | 222892字节 | 63.1ms | 14.5ms | 77.6ms |
Ajax性能指南
选择了合适的数据传输技术和数据格式后,我们就可以做其他方面的优化。
数据缓存
最快的Ajax请求就是没有请求。有两种方式可以避免不必要的请求
- 在服务端,设置HTTP头信息以确保你的响应会被浏览器缓存。
- 在客户端,把获取到的信息存储到本地,从而避免再次请求。
设置HTATP头信息
如果需要我们的响应被浏览器缓存起来,那么我们必须使用get请求,并且在响应中发送正确的HTTP头信息。
Expires头信息会告诉浏览器应该缓存响应多久。他的值是一个日期,过期之后,对该URL的任何请求都不再从缓存中获取,而是会重新访问服务器。
Expires: Mon,28 Jul 2014 23:30:00 GMT// 花奴才能到2014年7月
它对那些从不改变的内容非常有用,比如图片和其他静态资源。
本地数据存储
这种方式可以说是我们通过收工的方式对从服务器返回的数据做出缓存,当再次使用时,先去查询看是否已进行缓存,而后判断是否有必要再次发起请求。
var localCache = {};
function xhrRequest(url, cb) {
if (localCache[url]) {
cb.success(localCache[url]);
return;
}
var req = createXhrObject();
req.onerror = function() {
cb.error();
};
req.onreadystatechange = function() {
if (req.readyState == 4) {
if (req.responseText === '' || req.status == '404') {
cb.error();
return;
}
localCache[url] = req.responseText;
cb.success(req.responseText);
}
};
req.open('GET',url,true);
req.send(null);
}
总的来说,设置一个Expires头信息是更好的方案。他实现起来比较容易,而且其缓存内容能跨页面和跨会话。
而收工管理的缓存在你希望编程废止缓存内容并获取更新数据的时候会很有用。
XHR对低版本浏览器的兼容
function createXhrObject() {
var msxml_progid = [
'MSXML2.XMLHTTP.6.0',
"MSXML3.XMLHTTP",
"Microsoft.XMLHTTP",
"MSXML2.XMLHTTP.3.0",
];
var req;
try {
req = new XMLHttpRequest(); // 尝试标准方法
}
catch (e) {
for (var i =0,len = msxml_progid.length;i<len;++i) {
try {
req = new ActiveXObject(msxml_progid[i]);
break;
}
catch(e2){}
}
}
finally {
return req;
}
}
小结
高性能的Ajax包括以下方面:了解你项目的具体需求,选择正确的数据格式和与之匹配的传输技术。
作为数据格式,纯文本和HTML只适用于特定场合,但他们可以节省客户端的CPU周期。
XML被广泛应用而且支持良好,但是他十分笨重且解析缓慢。JSON是轻量级的,解析速度快(被视为原生代码而不是字符串),通用性与XML相当。字符分隔的自定义格式十分轻量,在解析大量数据集时非常快,但需要编写额外的服务端构造程序,并在客户端解析。
当从页面当前所处的域下请求数据时,XHR提供了最完善的控制和灵活性,尽管它会把接受到的所有数据当成一个字符串,且这有可能降低解析速度。另一方面,动态脚本注入允许跨域请求和本地执行JavaScript和JSON,但是它的接口不那么安全,而且还不能读取头信息和响应代码。Multipart XHR 可以用来减少请求数,并处理一个响应中的各种文件类型,但是它不能缓存接受到的响应。当需要发送数据时,图片信标是一种简单有效的方法。XHR还可以用POST发送大量数据。
- 减少请求数,可通过合并JavaScript和css文件,或使用MXHR,合并资源文件,减少请求次数
- 缩短页面的加载时间,页面主要内容加载完成后,用Ajax获取那些次要的文件。
- 确保你的代码错误不会输出给用户,并在服务端处理错误。
- 知道何时使用成熟的Ajax类库,以及如何编写自己的底层Ajax代码。
编程实践
前言
每种编程语言都有自己的痛点,JavaScript的低效之源在不断的发展过程中,逐渐暴露出来,且更好的编程方式被提出。本节将作为补充从其他方面对JavaScript的编程性能进行提升
避免双重求值
在JavaScript中,双重求值可以简单理解为对字符串处理后,字符串内部的可执行代码的处理。
共有四种标准方法可以实现:eval(),Function()构造函数,setTimeout(),setInterval()。每个方法都允许传入字符串并执行它。
var num1 = 5,
num2 = 6;
var result = eval('num1 + num2);
var sum = new Function('arg1','arg2','return arg1 + arg2');
setTimeout('sum = num1 + num2',100);
setInterval('sum = num1 + num2', 100);
诸如上述代码块,在JavaScript代码中执行另一段代码时,都会导致双重求值的性能消耗。
此代码会以正常的方式求值,然后在执行过程中对包含于字符串中的代码发起另一个新的求值运算。
避免双重求值是实现JavaScript运行期间性能最优化的关键所在
提示:优化后的JavaScript引擎通常会缓存住那些使用了eval()且重复运行的代码。如果你在Safari4和所有的版本的Chrome中就会发现,对同一段代码字符串的反复求值,会有显著的性能提升,而这归功于JavaScript引擎的缓存功能
使用Object/Array直接量
如果条件允许则使用直接量代替new关键字创建
// bad
var obj = new Object();
obj.name = '张三';
obj.age = 18;
var arr = new Array();
arr[0] = 123;
arr[1] = 456;
// good
var obj = {name: '张三',age: 18};
var arr = [123,456];
对象属性和数组项越多,使用直接量的好处就越明显!
避免重复工作
避免重复工作的两重意思:别做无关紧要的工作,别重复做已经完成的工作。第一部分在reafactor的时候很容易被发现。但是第二部分往往很难界定
function addHandler(target, eventType, handler) {
if (target.addEventListener) {
target.addEventListener(eventType, handler, false);
} else {
target.attachEvent('on' + eventType, handler);
}
}
function removeHandler(target, eventType, handler) {
if (target.removeEventListener) {
target.removeEventListener(eventType, handler, false);
} else {
target.detachEvent('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();
}
function removeHandler(target, eventType, handler) {
if (target.removeEventListener) {
removeHandler = function(target, eventType, handler) {
target.removeEventListener(eventType, handler, false);
}
} else {
target.detachEvent('on' + eventType, handler);
}
removeHandler();
}
该方式主要通过对方法的复写来实现避免后续的重复判断。
但是使用此方式需要注意的是:如果该方法在可预见的生命周期中,仅执行一次,则完全没有必要,因为这种情况下,他也不属于重复的工作。
使用延迟加载的劣势之处在于初次加载时耗时将会增加。当一个函数在页面中不会立刻被调用时,使用延时加载时最佳的方案!
条件预加载
var addHandler = document.body.addEventListener ?
function(target, eventType, handler) {
target.addEventListener(eventType, handler, false);
} :
function(target, eventType, handler) {
target.attachEvent('on' + eventType, handler);
};
var removeHandler = document.body.removeEventListener ?
function(target, eventType, handler) {
target.removeEventListener(eventType, handler, false);
} :
function(target, eventType, handler) {
target.detachEvent('on' + eventType, handler);
}
先判断是否存在,然后执行对应的函数,将检测提前化以提升一定的性能。
使用速度快的部分
JavaScript中依然有运行较快的部分,因为JavaScript引擎由低级语言构建并且经过编译。引擎是处理过程中最快的部分,运行速度较慢的实际上是我们的代码,引擎的某些部分比其他部分快很多,因此可以绕过慢的部分。
位操作
由于运算是低级语言的运算,因此速度往往是最快的部分。在JavaScript中,通常比较少见的原因也很明确,
它的使用情景较少,或者说很多开发者并不知道他的使用场景。
JavaScript中的数字都一招AIEEE-754标准位格式存储。在位操作中,数字被转换为有符号32位格式。每次运算符会直接操作该32位数以得到结果。尽管需要转换,但这个过程比JavaScript其他数学运算和布尔操作相比要快很多。
str.toString(2); //转换数值为二进制
JavaScript常用四种位逻辑操作符
- 按位与:两个操作数的对应为都是1时,则在该位返回1
- 按位或:两个操作数的对应位主要有一个为1时,则在该位返回1
- 按位异或:两个操作数的对应为只有一个为1时,则在该位返回1
- 按位取反:遇0则返回1,反之亦然。
常用使用情景:
- 设置表格隔行换色
// bad
for (var i = 0,len = rows.length;i++) {
if (i % 2) {
className = 'even';
} else {
className = 'odd';
}
}
// god
for (var i = 0,len = rows.length;i++) {
if (i & 1) {
className = 'odd';
} else {
className = 'even';
}
}
- 拉掩码
var num1 = 1;
var num2 = 2;
var num3 = 4;
var num4 = 8;
var num5 = 16;
var options = num1 | num2 | num3 | num4 | num5;
if (options & num1) {
// 选项一是否在options列表中
}
if (options & num2) {
选项二是否在options列表中
}
// js总0为false,1为true。按位与满足条件后会返回1.1为true因此可以在这里用作判断。
- 取整
function toInt(num) {
return num | 0;
}
console.log(toInt(2.3)); // 2
console.log(toInt(2.8)); // 2
- 判断奇偶
return num & 1 === 1; // 奇数的二进制最后一位必然为1,所以任意一个奇数 & 1一定为1。
- 交换两个变量的值
let a = 5,
b = 5;
// 位运算
a = a ^ b;
b = a ^ b;
a = a ^ b;
// 加减运算
a = a + b;
b = a - b;
a = a - b;
// es6
[a,b] = [b,a]
- 判断数组中某项是否存在
if (arr.indexOf(item) > -1) {
// code
}
if (~arr.indexOf(item)) {
// code
}
原生方法
JS中内置的很多方法运行速度都是比较快的,越来越多的三方js操作库其实都是基于原生进行的封装,供开发更方便的进行开发。所以论性能而言,原生其实是比这些三方库运行更快的。
比如css选择器就比Jquery的快。
另外原生的数字的运算方法也是非常快的,这也是我们常常忽略的
方法 | 含义 |
---|---|
Math.abs(num) | 返回num的绝对值 |
Math.exp(num) | 返回E的指数 |
Math.log(num) | 返回num的自然对数 |
Math.pow(num, power) | 返回num的power次幂 |
Math.sqrt(num) | 返回num的平方根 |
Math.acos(x) | 返回x的反余弦值 |
Math.asin(x) | 返回x的反正弦值 |
Math.atan(x) | 返回x的反正切值 |
Math.atan2(y,x) | 返回从X轴到(y,x)点的角度 |
Math.cos(x) | 返回x的余弦值 |
Math.sin(x) | 返回x的正弦值 |
Math.tan(x) | 返回x的正切值 |
当需要进行复杂的数学运算时,可以优先考虑上述的Math对象方法。
小结
- 避免使用eval和Function()构造器,setInterval()字符串,setTimeout()字符串来避免双重求值带来的性能消耗。
- 尽量使用直接量创建对象和数组。直接量的创建比初始化都比非直接量形式要快。
- 避免做重复的工作。当需要检测浏览器时,可使用延迟加载或条件预加载。
- 在进行数学计算时,考虑使用直接操作数字的二进制形式的位运算。
- JavaScript的原生方法总会比我们写的任何代码都要快,尽量是原生方法。
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!