脚本
1 脚本数量
每个<script>
标签初始下载时都会阻塞页面渲染,减少页面包含的<script>
标签数量有助于改善这一情况。同时,不仅是针对外链脚本,内嵌脚本的数量同样也要限制。浏览器在解析HTML页面的过程中每遇到一个script
标签,都会因执行脚本而导致一定的延时,因此最小化延迟时间将会明显改善页面的总体性能。
考虑到HTTP请求会带来额外的性能开销,因此下载单个100KB的文件将比下载4个25KB的文件更快。所以,减少页面中外链脚本文件的数量将会改善性能。
无阻塞脚本
减少JS文件大小并限制HTTP请求数仅仅是创建响应迅速的Web应用的第一步。尽管下载单个较大的JS文件只会产生一次HTTP请求,但这么做会锁死浏览器一大段时间。因此,避免这种情况,你需要向页面中逐步加载JS文件。
无阻塞脚本的好处在于页面加载完成后才会加载JS代码。即,在window.load
事件触发后才会下载脚本。
2 延迟脚本
HTML4中引入一个script
标签扩展属性:defer
。该属性指明元素所含的脚本不会修改DOM,代码能安全地延迟执行。
同时,HTML5中引入async
属性,用于异步加载脚本。async
与defer
的相同点是采用并行下载,在下载过程中不会产生阻塞。区别在于执行时机,async
是加载完成后自动执行,defer
需要等待页面完成后执行。
3 动态加载脚本
如下:
let script = document.createElement("script"); script.src = "1.js"; document.head.appendChild(script);
这个新建的script
元素加载了1.js文件。文件在该元素被添加到页面时开始下载。这种方式的重点在于:无论何时启动下载,文件的下载和执行过程不会阻塞页面其他进程。甚至,你可以将代码插入到<head>
区域而不会影响页面其他部分。因为,一般而言,把新建的<script>
标签添加到<head>
标签里比添加到<body>
里保险,尤其是在页面加载过程中执行代码时更是如此。当<body>
中的内容没有全部加载完成,IE可能会抛出一个“操作已终止”的错误信息。
使用动态脚本加载文件,返回的代码通常会立即执行。但是,当代码只包含供页面其他脚本调用的接口时,就会出问题。在这种情况下,你必须跟踪并确保脚本下载完成且准备就绪:
<script>
元素接收完成时会触发一个load
事件。你可以通过监听该事件来获得脚本加载完成时的状态:
let script = document.createElement("script"); script.onload = function() { console.log("script loaded!"); } script.src = "1.js"; document.head.appendChild(script);
而IE支持另一种实现方式,它会触发一个readystatechange
事件,script
元素提供一个readyState
属性,它的值在外链文件的下载过程的不同阶段会发生变化,共5种取值:
- “uninitialized”:初始状态
- “loading”:开始下载
- “loaded”: 下载完成(关注)
- “interactive”: 所有数据已准备就绪(关注)
实际中,最有用的两个状态是"loaded"和"complate"。IE下,readyState
的值并不一致,有事到达loaded
状态不会到达complate
;有时甚至不经过loaded
就到达complate
。所以,最保障的方式是对两种状态同时检查,只要有一个触发,就移除readystatechange
事件处理器。下面,我们把这个过程放在一个函数中处理:
function loadScript(url,callback) { let script = document.createElement("script"); if(script.readyState) { script.onreadystatechange = function() {//IE if(script.readyState == "loaded" || script.readyState == "complate") { script.onreadystatechange = null;//移除`readystatechange`事件处理器 callback(); }else {//其他浏览器 script.onload = function() { callback(); } } script.src = url; document.head.appendChild(script); } } }
如果需要的话,你可以动态加载尽可能多的JS文件到页面。但是:要考虑清楚文件的加载顺序。在所有主流浏览器中,只有Firefox和Opera能保证脚本会按照你指定的顺序执行,其他浏览器将会按照从服务器返回的顺序下载和执行代码。
对于多个文件,更好的做法还是把它们合并为一个文件。
4 XHR脚本注入
XHR脚本注入是另一种无阻塞脚本加载方法。
let xhr = new XMLHttpRequest(); xhr.open("get","1.js",true); xhr.onreadystatechange = function() { if(xhr.readyState === 4) { if(xhr.status => 200 && xhr.status < 300 || xhr.status === 304) { let script = document.createElement("script"); script.text = xhr.responseText; document.head.appendChild(script); } } }
- 优点:你可以下载JS代码但
不立即执行
。由于代码是在script
标签之外返回的,因此它下载后不会自动执行,这使得你可以把脚本的执行 推迟到你准备好的时候。 - 优点:在主流浏览器中能工作,不存在兼容性问题
- 缺点:xhr不支持跨域。
大型Web应用通常不会采用XHR脚本注入方式。
作用域
Javascript中有4中基本的数据存取位置:
- 字面量:只代表本身,不存储在特定位置。JS中的字面量有:
字符串、数字、布尔值、对象、数组、函数、正则表达式、null、undefined
. - 本地变量:使用关键词
let/const/var
定义的数据存储单元 - 数组元素:存储在js数组对象内部,以
数字
作为索引; - 对象成员:存储在JS对象内部,以
字符串
作为索引;
每一种数据存储的位置都有不同的读写消耗。从一个字面量和一个局部变量中存取数据的性能差异是微不足道的。访问数组元素和对象成员的代价则高一些,很大程度上取决于浏览器。
如果在乎运行速度,尽量使用字面量和局部变量
,减少数组项和对象成员的使用。
作用域对JS有许多影响,从确定哪些变量可以被函数访问,到确定this
的赋值。要理解性能和作用域的关系,首先要正确理解作用域的工作原理。
5 作用域链和标识符解析
每一个javascript函数是Function
对象的一个实例。其中它有一个内部属性:[[Scope]]
,包含了一个函数被创建的作用域中对象的集合
。这个集合被称为函数的 作用域链。它决定哪些数据能被函数访问
。函数作用域中每个对象被称为一个可变对象
,每个可变对象都以“键值对”
的形式存在。当一个函数创建后,它的作用域链被创建为此函数的作用域中可访问的数据对象所填充
。
例如:
function add(a,b) { let sum = a + b; return sum; }
当 函数add()
创建时,它的作用域链中插入了一个对象变量,这个全局对象代表着所有在全局范围内定义的变量。该全局对象包含如:window、navigator、document等
,如图:
Fig1. 函数add的作用域链
假设,我们调用add函数let total = add(3,4)
,现在看看add函数在执行时的作用域链:
Fig2. 函数add执行期
的作用域链
执行此函数会创建一个执行环境(执行上下文execution context)
的内部对象。一个执行环境定义了一个函数执行时的环境。函数每次执行时对应的执行环境都是独一无二的
。所以多次调用同一个函数就会导致创建多个执行环境。
当函数执行完毕,执行环境就被销毁
!
每个执行环境,都有自己的作用域链,用于解析标识符
。当执行环境被创建时,它的作用域链初始化为当前运行函数的[[Scope]]属性中的对象
。这些值按照它们出现在函数中的顺序,被复制到执行环境的作用域链中。
这个过程一旦完成,一个被称为活动对象
的新对象就为执行环境创建好了。
活动对象:作为函数运行时的变量对象,包含了所有局部变量,命名参数,参数集合以及this
。然后此对象被推入作用域链的最前端。但执行环境被销毁,活动对象也销毁。
在函数执行过程中,每遇到一个变量,都会经历一次标识符解析过程
,用以决定从哪里获取或存储数据。该过程搜索执行环境的作用域链,查找同名的标识符
。搜索过程从作用域链头部开始
,即当前运行函数的活动对象
。
如果找到,就使用这个标识符对应的变量
。
如果没有找到,继续搜索作用域链中的下一个对象
。搜索过程会持续进行,直到找到标识符。若无法搜索到,那么标识符将被视为未定义(undefined)
。
在函数执行过程中,每个标识符都要经历这样的搜索过程。正是这个搜索过程影响了性能
。
注意:如果名字相同的两个变量
存在于作用域链的不同部分,那么标识符就是遍历作用域链时最先找到的那个,即第一个变量遮蔽了第二个
。
6 标识符解析性能
在执行环境的作用域链中,一个标识符所在的位置越深,它的读写速度也就越慢
。因此,函数中读写局部变量总是最快的。而读写全局变量通常是最慢的
。
记住:全局变量总是存在于执行环境作用域链的最末端,因此它是最远的
。
一个好的经验法则是:如果某个跨作用域的值在函数中被引用一次以上,那么就把它存储到局部变量。
例如:
let links = document.getElementByTagName("a"), bd = document.body; ====> let doc = document, links = doc.getElementByTagName("a"), bd = doc.body; //访问全局变量的次数从2次,减少到1次
document
是全局对象,搜索该变量的过程必须遍历整个作用域链,直到最后在全局变量对象中找到。所以:我们将全局变量的引用存储在一个局部变量中,然后使用这个局部变量代替全局变量。
7 改变作用域链
一个执行环境的作用域是不会改变的。但是,有2个语句可以在执行时临时
改变作用域链:
with()
:给对象的所有属性创建了一个变量,它包含了参数指定的对象的所有属性。这个对象被推入作用域链首位,这意味着函数的所有局部变量现在处于第二个作用域链对象中
,因此访问的代价更高!因此,要避免使用with()
- try-catch:catch中也有同样的效果。当
try
代码中发生错误,执行过程会自动跳到catch
子句,然后把异常对象推入一个变量对象并置于作用域链首位
。一旦catch
子句执行完毕,作用域链就会返回到之前的状态。
try { //发生错误,进入catch }catch(ex) { console.log(ex.message); //作用域链在此处改变,ex.message 该代码访问了局部变量 }
但是try-catch
是个有用的语句,它不应该用来解决javascript错误。一种推荐的做法是将错误委托给一个函数来处理:
try { //发生错误,进入catch }catch(ex) { handleError(ex);//委托给错误处理函数,由于只有一条语句,且没有局部变量的访问,作用域链的临时改变就不会影响代码性能 }
with
和try-catch
或是eval()
都被认为是动态作用域,动态作用域只存在于代码执行过程中,因此无法通过静态分析检测出来。
8 闭包与内存
闭包,允许函数访问局部作用域之外的数据。由于闭包的
[[Scope]]属性包含了与执行环境作用域链相同的对象的引用
,因此会产生副作用。通常。函数的活动对象会随着执行环境一同销毁。但引入闭包时,由于引用仍然存在于闭包的[[Scope]]属性中,因此激活对象无法被销毁
,因此脚本中的闭包与非闭包函数相比,需要更多内存开销。在IE中,由于使用非原生javascript对象来实现DOM对象,因此闭包会导致内存泄漏
。
9 原型
javascript中的对象是基于原型的
。原型是其他对象的基础,它定义并实现了一个新创建的对象所必须包含的成员列表
。
对象通过一个内部属性,绑定到它的原型。一旦你创建一个内置对象(如Object、Array
)的实例,他们就会自动拥有一个Object实例作为原型。
因此,对象可以有两种成员类型:实例成员
和原型成员
。实例成员直接存在于对象实例中,原型成员则从对象原型继承而来
。
你可以使用hasOwnProperty()
方法来判断对象是否包含特定的实例成员
。
要确定对象是否包含特定的属性,可以使用in
操作符。
let book = { title: 'book1', author: 'zzz' } console.log(book.hasOwnProperty('title')); //true console.log(book.hasOwnProperty('toString')); //false console.log("title" in book); //true console.log("toString" in book); //true
- 使用
in
操作符,会既搜索实例
也会搜索原型
。toString
在原型对象中,因此true
。
10 原型链
对象的原型决定了实例的类型。默认下,所有对象都是Object的实例,并继承了所有基础方法,如toString()
。
function Book(title,author) { this.title = title; this.author = author; } Bool.prototype.sayTitle = function() { console.log(this.title); } let book1 = new Book("book1","zzz"); let book2 = new Book("book2","xxx"); console.log(book1 instanceof Book); //true console.log(book1 instanceof Object); //true book1.sayTitle(); // "book1" console.log(book1.toString());//[boject Object]
- 使用构造函数Book 创建一个新的Book实例。实例Bookl1的原型(
__proto__
)是Book.prototype
,而Book.prototype
原型是Object
。实例book1和book2共享同一个原型链。
11 嵌套成员
- 对象成员嵌套的越深,读取速度就会越慢。执行
location.href
总是比window.location.href
快。 - 大部分浏览器,通过
点表示法(object.name)
操作和通过括号表示法(object["name"])
操作并没有明显区别。只有在Safari
中,点符号始终更快,但这不意味不要用括号表示法。
12 缓存对象成员值
所有类似的性能问题都与对象成员
有关。因此应该尽可能避免使用它们。应该注意:只在必要时使用对象成员。例如:在同一函数中没有必要多次读取同一个对象成员。将要多次读取的对象成员属性,存储在一个局部变量中即可。
但是,不推荐将对象的成员方法保存在局部变量中,这会导致this绑定到window,
而this值的改变会使得javascript引擎无法正确解析它的成员对象。
DOM
浏览器通常会将DOM和Javascript操作分开独立实现。例如:在Chrome中使用Webkit的WebCore库来渲染页面,使用V8引擎实现Javascript。然而,实际中我们常常需要使用Javascript操作DOM,因此两个相互独立的功能通过接口相互连接时,会产生消耗。
访问DOM的次数越多,消耗就越多。因此,推荐的做法就是: 尽可能减少javascript操作DOM的次数。
13 修改元素
不仅修改DOM代价昂贵,修改元素更为昂贵
。因为它会导致浏览器重新计算页面的几何变化。
当然,最坏的情况是在循环中访问或修改元素
,尤其是对HTML元素集合循环操作
。
例如:
function innerHTMLLoop() { for(let count=0;count<100000;count++) { document.getElementById('sum').innerHTML += 'a' } }
上述代码每次循环迭代时,id=sum的元素都会被访问两次:
- 一次读取
innerHTML
属性值; - 一次是重写它。
换一种方式:将局部变量存储修改的内容,在循环结束后 一次性写入。
function innerHTMLLoop() { let content = ''; ; for(let count=0;count<100000;count++) { content += 'a' } document.getElemenyById('sum').innerHTML += content; }
通常的经验是:减少访问DOM的次数,把运算尽量浏览javascript中处理。
从下图可以看到:还是有区别的哈哈。
除此之外,在一个对性能有苛刻要求的操作中更新大段HTML
,推荐使用innerHTML
。因为它在绝大部分浏览器中运行更快。另,也建议使用 数组来合并大量字符串,让innerHTML的效率更高。
14 HTML集合
HTML集合是包含了DOM节点引用的类数组对象。例如,下列方法返回的是一个集合:
document.getElementsByName()
document.getElementsByClassName()
document.getElementsByTagName()
下列属性同样返回HTML集合:
document.images
: 页面中所有img元素document.links
:页面中所有a元素document.forms
:所有表单元素document.forms[0].elements
: 页面中第一个表单的所有字段
它们不是真正的数组,而是类数组列表(因为没有push()或slice()等方法)。但提供了一个类似数组中的length属性。并且还能以数字索引的方式访问列表中的元素。
HTML集合以一种”假定实时态
“的形式实时存在,这意味着当底层文档对象更新时,它也会自动更新。
HTML集合一直与文档保持连接,每次你需要最新的信息时,都会重复执行查询的过程,哪怕只是获取集合里的元素个数。所以,这是低效的源头。
在循环的条件中读取数组的length属性是不推荐的做法。读取一个集合的length比读取普通数组的length要慢的多。因为每次都要重新查询。
- 处理办法:将集合拷贝到数组中
//集合拷贝至数组中函数 function toArray(coll) { for(let i=0,a=[],len=coll.length;i<len;i++) { a[i] = coll[i]; } return a; }
let coll = document.getElementsByTagName('div');//获取div元素集合 let arr = toArray(coll);//将集合拷贝到数组中
在循环中读取:
//较慢 function loopCollection() { for(let count=0;count<coll.length;count++) { //todo } }
function loopCopiedArray() { for(let count=0;count<arr.length;count++) { //todo } }
每次迭代过程中,读取元素集合的length属性会引发集合进行 更新。这在所有浏览器中都有明显的性能问题 。我们再次进行优化:将集合的长度缓存到一个局部变量中,然后再循环的条件退出语句中使用该变量:
function loopCacheLengthCollection() { let coll = document.getElementsByTagName('div'), coll = coll.length; for(let count=0;count<len;count++) { //todo } }
但是,将集合拷贝到数组中,会带来额外的开销。而且会多遍历一次集合,因此应当评估在特定条件下使用数组拷贝是否有帮助。
15 遍历DOM
通常你需要某一个DOM元素开始,操作周围元素,或者递归查找所有子节点。你可以使用childNodes
得到元素集合,或者用nextSibling
获取每个相邻元素。
childNodes
返回的是一个元素集合
,在循环中我们应该缓存length属性
避免在每次迭代中更新。
大部分现代浏览器提供的API只返回元素节点。如果可用的话推荐使用这些API,效率会更高。例如:
属性名 | 被代替的属性 |
children | childNodes |
childElementCount | childNodes.length |
firstElementChild | firstChild |
lastElementChild | lastChild |
nextElementSibling | nextSibling |
previousElementSibling | previousSibling |
使用children替代childNodes会更快,因为集合项更少。 HTML源码中的空白实际上是文本节点,而且它并不包含在children集合中。 children都比 childNodes要快,尽管不会快太多。
另外,querySelectorAll()
使用CSS选择器作为参数并返回一个NodeList
,包含着匹配节点的类数组对象。这个方法不会返回HTML集合,因此返回的节点不会对应实时的文档结构。
如果需要处理大量组合查询,该方法会更高效。同时,querySelector()
可以获取第一个
匹配的节点。
16 重绘和重排
浏览器下载完页面中的所有组件——HTML标记、javascript、css、图片之后会解析并生成两个内部数据结构:
- DOM树:表示页面结构
- 渲染树:表示DOM节点
如何显示
DOM树中的每一个需要显示的节点在渲染树中至少存在一个对应的节点
(隐藏的DOM元素在渲染树中没有对应的节点)。渲染树中的节点被称为”帧 frames“或”盒 boxes“
,可理解成页面元素为一个具有内边距padding、外边距margin、边框border和位置position的盒子
。一旦DOM和渲染树构建完成,浏览器就开始绘制/显示页面元素。
重排reflow
:当DOM的变化影响了元素的几何属性(宽和高)
——比如改变边框宽度或给段落增加文字,导致行数增加
——浏览器需要重新计算元素的几何属性,同样其他元素的几何属性和位置也会因此受到影响
。浏览器会使渲染树中受到影响的部分失效,并重新构造渲染树
。重绘repaint
:完成重排后
,浏览器会重新绘制受影响的部分到屏幕中
,该过程为”重绘repaint“。
当然,并不是所有的DOM变化都会影响几何属性
。例如:改变一个元素的背景色并不会影响它的宽和高。这种情况,只会发生一次重绘,不需要重排,因为元素的布局没有改变。
因此,重排会导致重绘。重绘不一定会导致重排。
- 重排发生的时机:
- 添加或 删除可见的DOM元素
- 元素位置该百年
- 元素尺寸改变(如:margin、padding、border-width、width、height等属性改变)
- 内容改变,如文本改变或图片被另一个不同尺寸的图片替代
- 页面渲染器初始化
- 浏览器窗口尺寸改变
有些改变会触发整个页面的重排,例如:滚动条出现时。
- 渲染树变化的排队与刷新
大多数浏览器通过队列化修改并批量执行来优化重排过程。获取布局信息的操作也会导致队列刷新,如下面的属性和方法: offsetTop,offsetLeft,offsetWidth,offsetHeight
scrollTop,scrollLeft,scrollWidth,scrollHeight
clientTop,clientLeft,clientWidth,clientHeight
getComputedStyle()
以上属性和方法需要返回最新的布局信息,因此浏览器不得不执行渲染队列中“待处理变化”并触发重排以返回正确的值。
所以,在修改样式的过程中,最好避免使用上面列出的属性或方法
。它们都会刷新渲染队列,即使你是在获取最近未发生改变的或者与最近改变无关的布局信息。
一个更有效的方法是不要在布局信息改变时进行查询。
- 最小化重排和重绘
重排和重绘的代价昂贵,减少此类操作的发生能提升性能。为了减少发生次数,应该合并多次对DOM和样式的修改,然后一次性处理
。
- 批量修改样式
例如:下面这段代码,糟糕的情况下会发生三次重排。
let el = document.getElementById('mydiv'); el.style.borderLeft = '1px'; el.style.borderRight = '2px'; el.style.padding = '5px';
一个能达到同样效果且效率更高的方式是:合并所有的改变然后一次处理,这样只会修改DOM一次。这里我们使用cssText
处理:
let el = document.getElementById('mydiv'); el.style.cssText = 'borderLeft : 1px;border-right:2px;padding:5px;';
上述代码修改cssText
属性并覆盖了 已存在的样式信息,因此如果想保留现有样式,可以把它附加在cssText字符串后面。
el.style.cssText += ';border-left:1px;';
另一个办法
:修改CSS的class名称
,这使得改变CSS的class名称
的方法更清晰,如:
el.className = 'active';
- 批量修改DOM
可以通过下列步骤
减少重绘和重排的次数:
1.使元素脱离文档流(重排)
2. 对其应用多重改变
3. 把元素待会文档中(重排)
该过程里触发两次重排,即1和3。如果你忽略这两个步骤,那么在第二步产生的任何修改都会触发一次重排。
有3种基本方法可以使DOM脱离文档:
隐藏
元素,应用修改,重新显示
:
let ul = document.getElementById('lists'); ul.style.display = 'none';//临时隐藏 ... ul.style.display = 'block';//然后再恢复
- 推荐使用
文档片段(document fragment)在当前DOM之外构建一个子树
,再把它拷贝回文档
。这项技术所产生的DOM遍历和重排次数最少。
//文档片段是一个轻量级的document对象,它能更新和移动节点。 //文档片段一个便利的语法特性是: //当你附加一个片段到节点时,实际上被添加的是该片段的子节点,而不是片段本身。 //html <ul id="ul"> </ul> //js var element = document.getElementById('ul'); // assuming ul exists var fragment = document.createDocumentFragment(); var browsers = ['Firefox', 'Chrome', 'Opera', 'Safari', 'Internet Explorer']; browsers.forEach(function(browser) { var li = document.createElement('li'); li.textContent = browser; fragment.appendChild(li);//添加到文档片段中 }); element.appendChild(fragment);//把文档片段添加到ul容器中
- 将
原始元素拷贝到一个脱离文档的节点中
,修改副本,完成后再替换原始元素
:
let old = document.getElementById('list'); let clone = old.cloneNode(true);;//返回调用该方法的节点的一个副本. ...对副本进行操作... old.parentNode.replaceChild(clone,old);//使用新节点代替旧的节点
17 缓存布局信息
当你查询布局信息时,比如获取偏移量(offsets)、滚动位置(scroll )或计算出的样式值时,浏览器为了返回最新值,会刷新队列并应用所有变更。
最好的做法是:
- 尽量减少
布局信息
的获取次数,获取后把它赋值给局部变量
,然后再操作局部变量
。
18 让元素脱离动画流
重排只影响渲染树中的一小部分。但也可能影响很大部分,甚至整个渲染树,浏览器所需要重排的次数越少,应用程序的响应速度越快。因此当页面顶部的一个动画推移页面整个余下的部分时,会导致一次代价昂贵的大规模重排
。
避免大规模重排:
- 使用绝对定位(
position:absolute
)定位页面上的动画元素,将其脱离文档流 - 让元素动起来。当它扩大时,会临时覆盖部分页面。但这只是页面一个小区域的重绘过程,不会产生重排并重绘页面的大部分内容
- 当动画结束时恢复定位,从而只会下移一次文档的其他元素。
19 事件委托
事件绑定占用了处理时间,同时浏览器要跟踪每个事件处理器,也会占用更多内存。
事件委托:事件逐层冒泡并能被父级元素捕获
。使用事件代理,只需要给外层元素绑定一个处理器
,就可以处理在其子元素上出发的所有事件。
每个事件都要经历3个阶段:
- 捕获
- 到达目标
- 冒泡
IE不支持捕获,但对于委托而言,冒泡已经足够。
document.getElementById(menu').onclick = function(e) { //浏览器target,判断事件源 e = e || window.event; let target = e.target || e.srcElement; let pageid,hrefparts; //非链接点击则退出 if(target.nodeName !=='A') { return; } //从链接中找出页面ID hrefparts = target.href.split('/'); pageid = hrefparts[hrefparts.length-1]; pageid = pageid.replace('.html',''); //更新页面 ... //阻止浏览器默认行为并取消冒泡 if(typeof e.preventDefault === 'function') { e.preventDefault();//阻止浏览器默认行为 e.stopPropagation();//阻止冒泡 }else { e.returnValue = false; e.cancelBubble = true;//取消冒泡 } }
控制流程
代码数量少并不意味着运行速度快,代码数量多也不意味着运行速度一定慢。代码的组织结构和解决问题的思路是影响代码性能的主要因素。
循环
- 标准for循环
for(var i=0;i<10;i++){//循环祖逖}
:var
语句会创建一个函数级的变量
,而不是循环级。由于javascript只有函数级作用域,因此在for循环中 定义一个新变量相当于在循环体外定义一个新变量
。 - while:前测循环
- do-while:后测循环
- for-in:它可以枚举任何对象的属性名
for(var prop in object){}
20 循环性能
不断引发循环性能争论的源头是循环类型的选择。javascript四种循环中,只有for-in
循环比其他几种要慢。
由于每次迭代操作会同时搜索实例或原型属性,for-in
循环的每次迭代都会产生更多开销,所以比其他循环类型慢。因此,避免使用for-in
遍历对象或数组成员。
优化循环性能有两个可选因素:
- 每次迭代处理的事务
- 迭代的次数
- 减少迭代处理的事务:例如,查找
成员属性时,可以把值存储到一个局部变量
,然后再控制语句中使用这个变量。其次,你可以通过颠倒数组的顺序
来提高循环性能。数组项的顺序与所要执行的任务无关,因此从最后一项开始向前处理是个备选方案。例如:
var i=0,len = obj.length; while(i<len){ ... } //倒序 var j = obj.length; while(j--) { ... } 迭代次数从两次(1要比较总数;2要判断是否为true)减少到1次(判断是否为true)
- 减少迭代次数:广为人知的一种限制循环迭代次数的模式被称为“达夫设备”。它是一种
循环体展开
技术,使得一次迭代中实际上执行了多次迭代的操作。如:
var a = [0, 1, 2, 3, 4]; var sum = 0; for(var i = 0; i < 5; i++) sum += a[i]; console.log(sum);
我们将循环体展开来写:
var a = [0, 1, 2, 3, 4]; var sum = 0; sum += a[0]; sum += a[1]; sum += a[2]; sum += a[3]; sum += a[4]; console.log(sum);
因为少作了多次的for循环,很显然这段代码比前者效率略高,而且随着数组长度的增加,少作的for循环将在时间上体现更多的优势。
达夫设备这种思想或者说是策略,原来是运用在C语言上的,Jeff Greenberg将它从C语言移植到了JavaScript上,我们可以来看看他写的模板代码:
var iterations = Math.floor(items.length / 8), startAt = items.length % 8, i = 0; do { switch(startAt) { case 0: process(items[i++]); case 7: process(items[i++]); case 6: process(items[i++]); case 5: process(items[i++]); case 4: process(items[i++]); case 3: process(items[i++]); case 2: process(items[i++]); case 1: process(items[i++]); } startAt = 0; } while(--iterations);
注意看switch/case语句,因为没有写break,所以除了第一次外,之后的每次迭代实际上会运行8次!Duff’s Device背后的基本理念是:每次循环中最多可调用8次process()。循环的迭代次数为总数除以8。由于不是所有数字都能被8整除,变量startAt用来存放余数,便是第一次循环中应调用多少次process()。
此算法一个稍快的版本取消了switch语句,将余数处理和主循环分开:
var i = items.length % 8; while(i) { process(items[i--]); } i = Math.floor(items.length / 8); while(i) { process(items[i--]); process(items[i--]); process(items[i--]); process(items[i--]); process(items[i--]); process(items[i--]); process(items[i--]); process(items[i--]); }
尽管这种方式用两次循环代替了之前的一次循环,但它移除了循环体中的switch语句,速度比原始循环更快。
目前,老版本的浏览器运用达夫设备优化性能能得到大幅度的提升,而新版的浏览器引擎肯定对循环迭代语句进行了更强的优化,所以达夫设备能实现的优化效果日趋减弱甚至于没有。
21 基于函数的迭代
- Array.forEach(function(value,index,array){}):该方法由于每个数组项要调用外部方法,所以会带来开销。
在所有情况下,基于循环的迭代比基于函数的迭代要快8倍,因此在运行速度严格的情况下,基于函数的迭代不是合适的选择。
条件语句
使用if-else
还是switch
,常基于测试条件的数量来判断:条件数量越大,越倾向于使用switch
。因为大多数语言对switch
采用分支表索引
进行优化。
那么,如果是if-else
,那要怎么优化呢:
- 确保最可能出现的条件放在首位。即,语句中应该总是按照最大概率到最小概率的顺序排列
- 把
if-else
组织成一系列嵌套的if-else
语句。使用单个庞大的if-else
语句通常会导致运行缓慢。如:
if(value<6) { if(value<3) {//嵌套 }else if() {} }
使用二分法
把值域分成一系列的区间,然后逐步缩小范围
。这个方法非常适用于有多个值域需要测试的时候。
另一种方法:查找表,在JS中可以使用数组和普通对象来构建查找表
。通过查找表访问数据比用if-else或switch
快很多。特别是在条件语句数量很大的时候。
如:
switch(value) { case 0: return result0; case 1: return result1; ... case 9: return result9; default: return result10; }
使用查找表:
let results = [result0,result1,result2,...,result10]; ... return result[value];
当使用查找表时,这个过程就变成了数组项查询或对象成员查询。查找表的一个主要优点是:不用书写任何条件判断语句,即便候选值数量增加时,也几乎不会产生额外性能开销。
当单个键和单个值之间存在逻辑映射时,查找表的优势就能体现出来。switch语句更适合与每个键都有对应的独特的操作或一系列操作的场景。
22 调用栈
除了IE中调用栈的大小与系统内存相关,其他浏览器都有固定数量的调用栈限制。常常调用栈发送错误时会报stack
相关的错误,如Maximum call stack size exceeded
。
最常见的导致栈溢出的原因是不正确的终止条件。如果终止条件没问题,那么可能是算法中包含了太多层的递归,那么我们可以用迭代、Memoization
等来代替递归。
- Memoization():它是一种避免重复工作的方法。它缓存前一个计算结果供后续计算使用,避免重复工作。
memoize
函数:接收2个参数,一个是需要增加缓存功能的函数,一个是可选的缓存对象。如果你需要预设一些值,就给缓存对象传入一个预设的缓存对象,否则会创建一个新的缓存对象。然后创建一个封装了原始函数(fundamental
)的外壳函数(shell
),以确保只有当一个结果值之前从未被计算过时会产生新的计算。
function memoize(fundamental,cache) { cache = cache || {}; let shell = function(arg) { if(!cache.hasOwnProperty(arg)){ cache[arg] = fundamental(arg); } return cache[arg]; }; return shell; }
调用:
//缓存该阶乘函数 let memfactorial = memoize(factorial,{"0",1,“1”,1}); //调用新函数 let fact6 = memfactorial(6); let fact5 = memfactorial(5); let fact4 = memfactorial(4);
字符串操作优化
23 字符串连接
+、+=
是字符串连接最简单的方法。例如:
str += "one" + "two"
;此代码会经历4个步骤:
- 在
内存
中创建一个临时
字符串 - 连接后的字符串
"onetwo"
被赋值给该临时字符串 - 临时字符串与
str
当前的值连接 - 结果赋值给str。
实际上,我们可以用一个语句提升性能:
`str = str+“one”+“two”;
赋值表达式由str开始作为基础,每次给它附加一个字符串,由左向右依次连接,因此避免了使用临时字符串。如果改变连接顺序(如:str = "one"+str+"two";),优化向失效。
这与浏览器合并字符串时分配内存的方法有关。除IE外,其他浏览器会尝试为表达式左侧的字符串分配更多的内存,然后简单地将第二个字符串拷贝到它的末尾。如果在一个循环中,基础字符串位于最左端的位置,就可以避免重复拷贝一个逐渐变大的基础基础字符串。
基础字符串:理解为连接时排在前面的字符串。如:str+one
意味着拷贝one
并附加在str
之后,而one+str
则意味着拷贝str
并附加在one
之后。str如果很大,拷贝过程的性能损耗就很高。
24 String.prototype.concat
字符串原生方法concat
能接收任意数量的参数,并将每一个参数附加到所调用的字符串上。例如:
str = str.concat(s1); //附加一个字符串 str = str.concat(s1,s2,s3);//附加3个字符串 str = String.prototype.concat.apply(str,array); //如果传递一个数组,可以附加数组中所有字符串
但是,大多数情况下,String.prototype.concat
比+
和+=
稍慢。
定时器
浏览器的UI线程的工作,基于一个简单的队列系统。任务会被保存到队列中直到进程空闲。一旦空闲,队列中的下一个任务就被重新提取出来并运行
。这些任务要么是运行Javascript代码,要么是执行UI更新,包括重绘和重排。也会这个进程中最有趣的部分在于每一次输入(如:用户事件)可能会导致一个或多个任务被添加到队列。
队列在空闲状态下是理想的
,因为用户所有的交互都会立刻触发UI更新
。如果用户试图在任务运行期间与页面交互,不仅没有即时的UI更新,甚至可能新的UI更新任务都不会被创建并加入队列。事实上,大多数浏览器在Javascript运行时会停止把新任务加入UI线程的队列中,也就是说Javascript任务必须尽快结束
,避免对用户体验造成不良影响。
浏览器限制Javascript的运行时间的方式通常有2种:
- 调用栈大小限制(上节提到过)
- 长时间运行脚本限制:浏览器会记录一个脚本的运行时间,并在达到一定限度时终止它。不同浏览器检测脚本运行时间的方法略有不同。IE默认是限制500万条语句;Firefox默认限制10秒;Safari默认限制为5秒;Chrome没有单独的长运行脚本限制,其依赖其通过的
奔溃检测系统
来处理该问题;Opera没有该限制,它会让Javascript执行直到结束。
早在1968年,Robert在他的论文《Response time in man-computer conversational transactions》中提到:“单个Javascript操作花费的总时间不应该超过100毫秒
”。这个论证至今仍被重申。如果界面在100毫秒内响应用户输入,用户会认为自己在“直接操纵界面中的对象”。超过100毫秒意味着用户会感到自己与界面失去联系。 所以,我们应该限制所有Javascript任务在100毫秒内或更短时间内完成。
25 使用定时器让出时间片段
最理想的方法是让出UI线程的控制权,使得UI可以更新。让出控制权意味着停止执行Javascript,使UI线程有机会更新,然后再继续执行Javascript
。定时器与UI线程的交互方式有助于把运行耗时较长的脚本拆分为较短的片段。定时器会告诉Javascript引擎先等待一定时间,然后添加一个Javascript任务到UI队列。例如:
function greeting() { alert("hello"); } setTimeout(greeting,300);
这段代码将在300毫秒后,向UI队列插入一个执行greeting()函数的Javascript任务。在这个时间之前
,所有其他UI更新和Javascript任务
都会执行。setTimeout()的第二个参数表示任务何时被添加到UI队列,而不是一定会在这段时间后执行。这个任务会等待队列中其他所有任务执行完毕才会执行。
所以,定时器代码只有在创建它的函数执行完成后,才有可能执行。也因此,定时器不可用于测量实际时间。
如果定时器之前的任务在300毫秒后没有执行完成,那么该定时器会在这些任务完成后立即执行。
无论发生何种情况,创建一个定时器会造成UI线程暂停
,如同它从一个任务切换到下一个任务。因此,定时器代码会重置所有相关浏览器限制
,包括长时间运行脚本定时器。此外,调用栈也在定时器的代码中重置为0。
定时器中,setInterval()
和setTimeout()
最主要的区别:如果UI队列中已经存在由同一个setInterval()
创建的任务,那么后续任务不会被添加到UI队列中。
26 使用定时器处理数组
是否可用定时器处理数组有2个决定性因素:
- 处理过程是否必须同步
- 数据是否必须按顺序处理
如果这两个问题的答案都是“否”,那么代码将适用于定时器分解任务。一种基本的异步代码模式如下:
function processArray(items,process,callback) { let todo = items.concat(); //克隆原数组 setTimeout(function(){ let start = +new Date();//+号能将Date对象转换为数字 //取得数组的下个元素并进行处理 do { process(todo.shift());//返回数组第一个元素,并将其从数组中删除 }while(todo.length>0 && (+new Date() - start < 50));//使用时间检测机让定时器能处理多个数组元素 //如果todo中还有需要处理的元素,创建另一个定时器 if(todo.length>0) { setTimeout(arguments.callee,25);//arguments.callee指向当前正在运行的匿名函数 }else { //不再有条目需要处理 callback(items); } },25);//25毫秒,是对于普遍情况。因为再小的延时,对于UI更新来说不够用 }
调用:
let items = [21,23,45,67,86,34,54]; function outputValue(value) { console.log(value); } processArray(items,outputValue,function(){ console.log("Done!"); });
27 使用定时器分割任务
如果一个函数运行时间过长,那么可以检查一下是否可以把它拆分为一系列能在较短时间内完成的子函数,把每个独立的函数放在定时器中调用:
function multistep(steps,args,callback) { let tasks = steps.concat(); //克隆数组 setTimeout(function(){ //执行下一个任务 let task = tasks.shift(); task.apply(null,args||[]); //检查是否还有其他任务 if(tasks.length>0) { setTimeout(arguments.callee,25); }else { callback(); } },25); }
调用:
function todoList(id) { let tasks = [openDocument,writeText,closeDocument,updateUI]; multistep(tasks,[id],function(){ console.log("tasks completed!"); }) }
尽管定时器能让Javascript代码性能提升,但是过度使用也会对性能造成负面影响。最好的方法是:同一时间只有一个定时器存在,只有当这个定时器结束时才会新建一个
。这样的方式不会导致性能问题。
因为只有一个UI线程,当多个重复的定时器同时创建时,所有的定时器会争夺运行时间。通常,我们会把多个定时器的间隔时间设为1秒及以上,这种情况下定时器延迟远远超过UI线程产生瓶颈的值,可安全地使用。
数据传输
通常,有5中常用的向服务器请求数据的方式:
- XHR:只获取数据,应该使用GET方式。
经GET请求的数据会被缓存起来,如果需要多次请求统一数据的话,有助于提升性能
。只有当请求的URL加上参数的长度>2048个字符
,才应该使用POST方式获取数据。 - 动态脚本注入:克服
XHR不能跨域请求
数据的方式。
let scriptElement = document.createElement('script'); scriptElement.src = 'http://xxxx.js'; document.head.appendChild(scriptElement);
但是,该方式是有限的。参数传递的方式只能是GET。你不能设置请求的超时处理,且必须等待所有数据返回才可以访问它们。
3. iframes
4. Comet
5. Multipart XHR:允许客户端只用一个HTTP请求就可以从服务器项客户端传送多个资源。服务端会将多个资源(如HTML、CSS、Javascript代码、图片等)打包成一个有双方约定的字符串分割的长字符串并发送到客户端
。然后用Javascript代码处理这个长字符串,并根据他的mime-tyoe类型和传入的其他“头信息”解析出每个资源
。
例如:一个用来获取多张图片的请求发送到服务器
let req = new XMLHttpRequest(); req.open('GET','requestImages.php',true);//服务器代码需要读取图片,并转化为base64编码的字符串,然后将每张图片的字符串合并成一个长字符串,输出给前端 req.onreadystatechange = function() { if(req.readyState === 4 ) { splitImages(req.responseText); } } req.send(null);
function splitImages(imageString) { let imageData = imageString.split("\u0001"); let imageElement; for(let 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); } }
MXHR有一个缺点:这种方式获得的资源不能被浏览器缓存。
因此,适合用在页面中无需缓存的资源,以及页面中对多个文件已经进行打包后的文件。
28 发送数据
当数据只需要发送到服务器是,有两种广泛使用的技术:XHR和信标(beacons)。
- XHR:
function xhrPost(url,name,callback){ let req = new XMLHttpRequest(); req.onerror = function() { setTimeout(function(){ xhrPost(url,params,callback);//失败时,重试 },1000); }; req.onreadystatechange = function() { if(req.readyState === 4) { if(callback && typeof callback === 'function') { callback(); } } }; req.open('POST',url,true); req.setRequestHeader('Content-Type','application/x-www-form-urlencoded'); req.setRqeuestHeader('Content-Length',params.length); req.send(params.join('&')); }
当使用XHR发送数据到服务器,GET方式会更快。因为,对于少量数据而言,一个GET请求往服务器只发送一个数据包。一个POST请求,至少发送两个数据包。一个装载头信息,另一个装载POST正文。
POST更适合发送大量数据到服务器,因为它不关心额外布局包的数量。另一个原因:IE对URL长度有限制,不可能使用过长的GET请求。
- 信标(Beacons):类似动态脚本注入。例如,
图片信标
:使用Javascript创建一个新的image对象,把src属性设置为服务器上脚本的URL。该URL包含了我们要通过GET传回的键值对数据。当然:需要注意,我们没有创建img元素,并把它插入到DOM中。
let url = '/status_track.php'; let params = [ 'step=2', 'time=1234323423' ]; let beacon = new Image(); beacon.src = url + '?' + params.join('&'); beacon.onload = function() { if(this.width === 1) { //success }else if(this.width ===2){ //failed,重试并创建另一个信标 } }; beacon.onerror = function() { //error,重试并创建另一个信标 }
服务器会接收数据并保存下来。它无须向客户端发送任何反馈信息
,因此没有图片会实际显示出来。这是给服务器回传信息最有效的方式。它的性能消耗很小,而且服务端的错误完全不会影响到客户端。
这种方式有个缺点:客户端能接收到的响应类型是有限的。如果需要服务器返回大量数据给客户端,那么请使用XHR。
29 JSON
当使用XHR时,JSON数据会被当成字符串返回。该字符串紧接着被eval()
转换成原生对象。然而,在使用动态脚本注入时,JSON数据被当成另一个Javascript文件并作为原生代码执行。为实现这一点,这些数据必须封装在一个回调函数里。这就是“JSON填充(JSON with padding)”或"JSONP
"。
JSONP因为回调包装的原因,略微增大了文件尺寸,但与其解析性能的提升相比这点增加微不足道。因为在JSONP中,数据被转换成原生Javascript,因此解析速度跟原生js代码一样快。
有一种情景需要避免使用JSONP:因为JSONP是可执行的Javascript,它可能会被任何人调用并使用动态脚本注入技术插入到任何网站。而另一方面,JSON在eval前是无效的Javascript,使用XHR时它只是被当作字符串获取。所以不要把任何敏感数据编码在JSONP中。
30 自定义格式数据
理想情况下,数据的格式应该只包含必要的结构。例如:
1:alice:Alice Smith:alice@alicesmith.com; 2.bob:Bob Jones:bob@bobjones.com;
上面代码中,使用一个字符分隔自定义格式
的用户列表。
对于前端,只需要使用split()
对字符串进行拆分即可:
function parseCustomFormat(responseText) { let users = []; let usersEncoded = responseText.split(';'); let userArray; for(let i=0,len=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; }
当你创建自定义格式是,最重要的决定之一是采用哪种分隔符。理想下,它应该是一个单字符,而且不应该存在于你的数据中。
31 缓存数据
最快的请求就是没有请求。有2中主要的方法可以避免发生不必要的请求。
- 在服务端:设置
HTTP头信息
以确保你的响应会被浏览器缓存 - 在客户端:把获取到的
信息存储到本地
,避免再次的请求。
- 设置HTTP头信息:
Expires
头信息会告诉浏览器应该缓存多久。它的值是一个日期,过期后,对该URL的任何请求都不再从缓存中获取,而是会重新访问服务器。PHP中这样设置:
$lifetime = 7*24*60*60;//7天,单位:秒 header('Expires':. gmdate('D, d M Y H:i:s',time()+$lifetime) . 'GMT');
- 缓存数据到本地:将服务器的响应文本保存到一个对象中,以
URL为键值作为索引。
let localCache = {}; function xhrRequest (url,callback) { //检查此URL的本地缓存 if(localCache[url]){ callback.success(localCache[url]); return; } //此URL对应的缓存没有找到,则发送请求 let req = createXhrObject(); req.onerror = function() { callback.error(); }; req.onreadystatechange = function() { if(req.readyState === 4) { if(req.responseText === '' || req.status === '404') { callback.error(); return; } //存储响应文本到本地缓存 localCache[url] = req.responseText; callback.success(req.responseText); } }; req.open("GET",url,true); req.send(null); }
设置Expires头信息是更好的方案,因为它实现简单,并且能够跨页面和跨会话(session)。本地缓存有一个问题:你每次请求都会使用缓存数据,但用户执行了某些动作可能导致一个或多个已经缓存的响应失效。所以:这种情况需要删除那些响应:delete localCache['url/xxx/xxx']
。