精通JavaScript--04增强JavaScript性能
在本章,我们将学习会如何使用编码窍门、方法、最佳实践和现代API来提升JavaScript应用程序的性能。要全局性地着眼于性能问题,就意味着要考虑整个应用程序的生命周期。也就是说,与其单纯地注重所执行的代码行数,我更欣赏通过实现更快捷,更高效的JavaScript的文件加载,以及高效的调用、DOM元素选择,甚至是通过结合CSS和HTML优化,来提升应用程序的可感知性能。然而,在本章中我们将重点介绍如何在JavaScript代码和文件上下功夫,来提升网站和应用程序的性能。
4.1 优化页面加载时间
在修改JavaScript代码以改进应用程序的性能之前,我们首先要了解浏览器与JavaScript代码之间的相互处理。JavaScript代码是通过HTML<script>标签进行引用来实现加载的。
在这个阶段我们所做的变化处理将确保代码会快速、高效地加载,即代码可以更快地准备就绪来执行,从而提升应用程序的感知能力。
4.1.1 HTML标签顺序
当浏览器遇到<script>标签时,在大多数情况下,它会停止渲染页面,知道完成对脚本的读取和解析。这是为了防止脚本中包含document.write()方法调用,这意味着在这一时刻会对当前渲染的页面进行改变。处于这个原因,把所有能够移动的<script>标签挪至HTML的</body>标签前。这样,这个页面都将会在各项脚本加载和解析之前进行渲染,从而提升了页面的可感知响应能力。
4.1.2 JavaScript文件的GZip编码传输
在服务器上的一个简单设定就能确保JavaScript、HTML、CSS和任何基于文本的文件能够以最高效的方式传输到浏览器中。在发送之前,可以进行压缩,当数据到达浏览器之后,进行压缩,这样一来,通过线路传输的数据更少,文件达到浏览器的时间更快。这项设置称作gzip编码,并且几乎所有的服务器都支持启动这项设置,前提是你对服务器的设置有控制修改权。
□ http://httpd.apache.org/docs/2.4/mod/mod_deflate.html 来启动gzip设置
□ 对于布署在Microsoft的IIS7中的网站,请打开IIS Manager程序,并选择Features功能视图。打开Compression项目并选择框中勾选相应选项,为静态和(或)动态内容启动gzip压缩。静态内容是指那些每次都产生相同输出的内容,例如CSS或普通HTML文件;而动态内容使用应用程序服务器端代码来产生不同的输出。
□ 对于布署在Node.js上并使用Express框架,可以尽早在应用程序的代码中直接使用Express对象compress()方法。接下来的所有响应将会进行gzip编码.
在每个请求发生时进行即时的gzip编码处理会消耗服务器上额外的资源和CPU处理时间。如果担心所使用服务器的性能,你可以预先把JavaScript和其他基于文本的文件进行压缩。但要确保用服务器提供这些预压缩文件时,
要在HTTP报头中增加额外的Content-Encoding:gzip。这样,你就获得了相同的性能提升,又无需额外耗用服务器的性能.
4.1.3 缩编、混淆和编译
JavaScript文件越小,通过线路把文件下载至浏览器的时间就越快,浏览器对文件的读取和解析也就更快。因此,我们要想方设法让代码的体积尽可能的小。可以通过三个过程来实现,分别为缩编,混编和编译。
缩编是JavaScript代码中所有的空格和换行符进行移除,以产生更小体积的代码文件,但处理后的代码仍然保留着有开发者编写的完全相同语句。
混淆是一种更高级的代码优化方法,它会着眼于变量和函数名称,并找出那些是可全局性访问,那些限定在特定的作用域之内。所有的全局变量和全局函数会保留其原来的名称,但那些在限定作用域中则会重命名为简短的名称,从而显著地减少名称在代码中文件中所占的体积。通过将那些名称在恰当的位置以恰当的方式进行替换,代码将会向混淆处理发生前那样继续运作。代码中所用的全局变量和函数越少(这是一个值得采纳的好习惯,因为它减少了你的代码与其他代码发生冲突的机会),经过混淆处理后的代码就会越小。
编译是一种更先进的处理方法,它会全面对代码进行分析,并对代码中的语句进行简化,缩减,整合,生成有着相同行为的另一个语句。虽然能够实施这一特定类型优化操作的工具为数不多,但是当与缩编和混淆结合时,这是产生最小体积文件的最有效方法。
让我们使用代码清单4-1中的函数作为处理对象,并分别用缩编,混淆,编译来减少它的体积。
代码清单4-1 需要进行缩编、混淆和编译的函数
1 var add=function(){ 2 var total=0, 3 index=0, 4 length=arguments.length; 5 for (; index < length; index++) { 6 total=total+arguments[index]; 7 } 8 return total; 9 }
1.使用JSMin进行对代码的缩编
2.使用UgifyJS进行代码混淆
3.使用google closure compiler进行代码编译
4.避免全局变量的使用以实现更优的压缩
代码清单4-5 使用匿名函数,自执行的函数闭包来减少全局变量的使用
1 //定义一个全局变量 2 var myGlobalVariable; 3 //建立一个自执行,匿名(未命名)函数包裹在代码的周围 4 (function(){ 5 //放置代码(之前是全局性的),这些代码的作用域是一个新的,非全局的范围 6 //从而可以更加高效地运作缩编,混淆和编译来压缩代码文件的体积 7 8 //声明一个局部变量 9 var myLocalVariable="Local"; 10 //把一个字符串赋值给该全局变量 11 myGlobalVariable="Global"; 12 13 //在开放式闭括号组合内的代码会自动执行 14 }());
4.1.4 请求时才延迟加载JavaScript文件
当使用平常的手法加载JavaScript文件时(在HTML文件中使用文件引用),浏览器会阻塞页面其余内容的下载和解析,直至脚本文件完成下载和执行。这并不理想,特别是在需要下载很多代码的情况下。有一种方法可以克服这一性能瓶颈——利用JavaScript本身,但在使用的时候需要保持谨慎。脚本通常会阻塞浏览器,使其不能并行下载多个脚本,以免出现竞态条件,否则一块代码会在另一块代码之前完成下载,导致代码按照错误顺序执行。如果打算用这种方法下载脚本,就要在文件下载完成时即关联JavaScript代码使其得以运行,从而防止竞态条件的出现。
可以动态地使用JavaScript创建有一个新<script>标签,设置其src标签特性执行目标文件以实现异步加载,从而建立一个非阻塞来下载JavaScript文件。通过关联一个函数至该新标签的onload方法,我们可以执行那些依赖于这个脚本(异步下载的JavaScript文件)加载后才能运行的指定代码。代码清单4-6显示的是一个函数,这里将以上行为包裹在一个简单的函数中。该函数有两个参数,分别是要加载的文件的位置,以及一旦该文件成功加载后所执行的函数。
代码清单4-6 请求时才加载JavaScript文件,从而不阻塞浏览器
1 function loadScript(src,onLoad){ 2 var scriptTag=document.createElement("script"); 3 scriptTag.src=src; 4 if(typeof onLoad=="function"){ 5 scriptTag.onload=onLoad; 6 scriptTag.onreadystatechange=function(){ 7 if(scriptTag.readyState===4){ 8 onLoad(); 9 } 10 } 11 } 12 document.body.appendChild(scriptTag); 13 } 14 //可以在代码中的任何位置使用loadScript方法,如下所示 15 16 //加载my-script文件,当完成时,弹出“script loaded and available”提示框 17 loadScript("../vue/js/vue.js",function(){ 18 alert("script loaded and available"); 19 }); 20 loadScript("../vue/js/vue.js");
4.2 优化文档对象的操作
导致大多数网站和应用程序出现性能缓滞的一个最大因素是通过JavaScript低效访问HTML页面元素。因为所有浏览器的JavaScript引擎独立于其渲染引擎,通过JavaScript获取对页面元素的引用要涉及从一个引擎跳转至另一个引擎,浏览器则充当两者之间的中介。为了提高性能,我们需要减少这种跳转所出现的次数。本节列举了若干技巧来帮助你避免一些JavaScript对HTML页面元素的不必要的访问。
4.2.1 实现对页面元素的最小访问
减少对页面元素的访问也相当简单,只要JavaScript中获取了对页面元素的引用,就直接把该引用保存在一个变量中,在代码中引用该变量而不是回到页面上再次获取相同的引用,如代码清单4-7所示
代码清单4-7 以变量保存对DOM元素的引用以便后续使用
var header=document.getElementById("header"), nav=document.getElementById("nav"); header.className+=" "+nav.className;
如果需要访问的一些页面元素均位于同一父元素之下,则只需获取该对父元素的引用,并使用JavaScript从该引用中寻找各项子元素,以单独的请求访问父元素来实现从页面中取得所有相关子元素,如代码清单4-8所示。要抵制诱惑,不要直接获取对整个页面的引用并使用它,因为所需的内存占用的是页面的这个DOM树,再加上JavaScript要深入该树查找需要访问的元素所花费的时间,这势必会对应用程序产生负面的性能影响。
代码清单4-8 通过对单独父元素的引用来访问其子DOM元素
1 var wrapper=document.getElementById("wrapper"), 2 header=wrapper.getElementsByTagName("header")[0], 3 nav=wrapper.getElementsByTagName("nav")[0]; 4 header.className+=" "+nav.className;
如果你需要动态地创建DOM元素并将其添加至页面上,则先配置好该新元素的各项标签特性(attribute)并设置好所需的对象属性(property),在把该新元素添加至页面上,如代码清单4-9所示。这样,浏览器就不用不断地处理实时的HTML页面来实施更改操作。
代码清单4-9 对新建元素实施DOM修改操作后才将其添加至当前实时页面
1 var wrapper=document.getElementById("wrapper"), 2 header=wrapper.getElementsByTagName("header")[0], 3 nav=wrapper.getElementsByTagName("nav")[0]; 4 header.className+=" "+nav.className; 5 6 var list=document.createElement("ul"), 7 listItem=document.createElement("li"); 8 //先在JavaScript中尽可能地实施所有的DOM操作 9 listItem.innerHTML="I am a list item"; 10 list.appendChild(listItem); 11 12 //最后,当你确定不再需要做出任何修改时再把该元素添加到页面进行显示 13 document.body.appendChild(list);
4.2.2 尽量利用已有元素
在网站和应用程序中,动态地创建DOM元素是一个相当普遍的需求,但每一次使用标准的document.createElement()方法来创建元素以及用类似的方法将其配置到已经存在的元素之上,都会带来性能上的损失,为了提高性能,可以复制已经存在的元素,而不是重新建立新的元素,如代码4-10所示。如果你是在创建多个有着相似标签特性的元素,那么可以使用DOM元素的cloneNode()方法来复制该元素以及它的相关标签特性。
代码清单4-10 复制已经存在的元素以提高性能
1 var list1=document.createElement("ul"), 2 list2, 3 listItem1=document.createElement("li"), 4 listItem2, 5 listItem3; 6 listItem1.className="list-item"; 7 listItem1.innerHTML="I am a list item"; 8 9 //cloneNode方法可以高效地复制元素。将其可选参数设置为“true”则会复制该元素及其属下的所有子元素 10 //(相关的对象属性也会复制)。不填写该参数(或设为“false”)则会只复制该元素本身 11 listItem2=listItem1.cloneNode(true); 12 listItem3=listItem1.cloneNode(true); 13 14 //添加三个列表项至该无序列表元素 15 list1.appendChild(listItem1); 16 list1.appendChild(listItem1); 17 list1.appendChild(listItem1); 18 19 //复制整个无序列表 20 list2= list1.cloneNode(true); 21 22 //把这两个一模一样的无序列表元素添加至实时页面 23 document.body.appendChild(list1); 24 document.body.appendChild(list2);
4.2.3 离线DOM的利用
除了在JavaScript中不断地访问实时页面来创建和管理元素,我们可以使用一项经常被忽略的DOM规范,即文档片段(document fragment),或称为离线DOM(offline DOM),这是一个轻量级版本的DOM,用于创建和操控小型的元素树结构以在稍后将其添加至当前实时页面,如代码4-11所示。比起使用实时页面的DOM,使用这项技术来操控元素可以获得更佳的性能。
代码清单4-11 利用离线DOM来避免访问页面中实时的DOM
1 //创建一个DocumentFragment对象作为离线DOM结构,不与实时DOM交互 2 var offlineDOM=document.createDocumentFragment(), 3 //创建各个将用于动态地添加至页面中的元素 4 header=document.createElement("header"), 5 nav=document.createElement("nav"); 6 //将每个元素添加到离线DOM上 7 offlineDOM.appendChild(header); 8 offlineDOM.appendChild(nav); 9 10 //把离线DOM的一份副本添加至当前实时页面 11 document.body.appendChild(offlineDOM);
4.2.4 使用CSS而非JavaScript来操控页面样式
可以通过DOM来直接操控CSS样式属性,方法是使用元素的style属性来实现。修改页面元素的style属性会影响它的布局,从而在浏览器中引发一次重排(reflow)。代码清单4-12展示了这样的修改对页面效果以及页面上其他元素的影响。重排需要时间,并且接下来还要处理多个style属性,会导致更多不必要重排。
代码清单4-12 演示当直接更新DOM元素的style属性时所引发的浏览器重排
1 var nav=document.getElementsByTagName("nav"); 2 nav.style.background="#000";//在浏览器中引发了一次重排 3 nav.style.color="#fff";//引发了一次重排 4 nav.style.opacity=0.6;//引发了一次重排
处理这个问题有两个解决方案。第一个是通过JavaScript而非单独样式应用一个CSS类至页面元素。这使得所有的CSS规则一次性地同时应用至该元素,而且只引发了一次重排,如代码清单4-13所示。这还增加了逻辑方面的好处,即能够将这项操作所需的视觉和布局规则整合在一个单独的CSS文件中专门表示这项任务,而不是混淆不清地将某些样式通过CSS实现,某些又通过JavaScript实现。
代码清单4-13 应用CSS类至DOM元素以减少浏览器重排
var nav=document.getElementsByTagName("nav"); nav.className+="selected";//名称为“selected”的CSS类中包含着多项样式设定
如果在特定环境下无法实现第一种方法,则可以使用第二种方法,就是先设置display样式的属性为none,在进行其他样式属性的修改。这会从页面流中移除该元素的可视显示,并引发一次浏览器重排,但也意味着当该元素离开页面流后的其他样式属性的修改都不会再引发浏览器重排。一旦修改操作完成后,应该通过设置该元素的display属性为block或其他可设置值来把该元素重新放回页面流中,如代码清单4-14所示。
1 var nav=document.getElementsByTagName("nav"); 2 nav.style.display="none";//隐藏元素不让其显示,引发一次浏览器重排 3 nav.style.backgroundColor="#fff";//不会引起浏览器重排 4 nav.style.opacity=0.5;//不会引起浏览器重排 5 nav.style.display="block"; //使该元素重新显示,引发一次浏览器重排
4.3 提升DOM事件性能
糟糕的附加事件或事件处理会引起性能问题,但幸运的是,通过事件委托和事件框架化,我们得以使事件处理对性能的影响最小化。
4.3.1 委托事件至父元素
DOM事件会从是其首次被触发的元素起开始冒泡,一直到文档结构的最顶端。这就意味着,当用户点击一个链接时,JavaScript会为该链接发出一个click事件,接着其父元素发出click事件,如此类推一直沿DOM树向树根行进,直至到达树根的元素。这称作时间的冒泡阶段。在此阶段之前,还有一个捕捉阶段。此阶段的事件先于DOM树的顶端(根)的元素发出,沿DOM树向该元素(触发事件的元素)行进。当使用某个元素的addEventListener()方法来设置事件处理函数时,我们要提供事件的名称。当该元素发出此事件时,事件处理函数将会执行。事件处理函数的第三个参数是布尔true或false,分别表示我们希望究竟是在事件生命周期的捕捉阶段、还是在冒泡阶段来发出该事件。
我们可以利用冒泡阶段来提升应用程序的性能,因为这意味着只需要添加一个单独的事件处理函数,通过在那些由用户操作的多个子元素对应的的父元素上应用该事情处理函数即可。然后,就可以根据出现事件的元素的属性来实现指派动作的发生,如代码清单4-15所示。这里以一个单独的事件处理函数来处理一个子元素所发生的事件。它们被称作事件委托,因为它们变称逻辑块,基于事件和页面元素的属性来委托相应的运作。假设此代码清单运行在HTML页面上下文中,其中包含以下标记语言代码:
1 <ul id="list"> 2 <li class="list-item"><a href="/" class="list-item-link">Home</a></li> 3 <li class="list-item"><a href="/news" class="list-item-link">News</a></li> 4 <li class="list-item"><a href="/events" class="list-item-link">Events</a></li> 5 </ul>
代码清单4-15 在含有多个链接的列表上的事件委托
1 //获取对列表元素的引用,此列表元素包含我们想要为其指派事件处理函数的所有链接 2 var list=document.getElementById("list"); 3 4 //定义一个函数,当链接或链接中的某个元素被执行时(点击或回车),执行该函数 5 function onClick(evt){ 6 //利用事件的target属性来获取对点击的实际元素的引用 7 var clickedElem=evt.target, 8 tagNameSought="A"; 9 10 //检查所点击的元素是否为我们所要的类型,在这里就是检查它是否是一个<a>标签 11 if(clickedElem && clickedElem.tagName===tagNameSought){ 12 //如果是,则获取该链接的href值并在新窗口中打开这个链接地址 13 window.open(clickedElem.href); 14 } 15 } 16 17 //为列表元素(ul)指派事件处理函数。该列表元素是包含着所有链接的父元素。比起为每个链接都添加一个 18 //事件处理函数,只为父元素添加一个事件处理函数会更为快捷。第三个参数设置为“false”,表示是在事件 19 //生命周期的冒泡阶段进行事件处理,即从事件发出的元素起,沿DOM树行进至列表元素本身 20 list.addEventListener("click",onClick,false);
通过利用事件委托,可以确保当前页面加载时只需要添加少量的时间至页面元素,从而减少了DOM元素的访问并提升了性能。这有助于实现减少最终用户能够与页面进行交互所需的时间这一总体目标。
4.3.2 使用框架化处理频密发出的事件
某些事件可能会在连续状态下快速频密发出,比如更改页面时所出现的浏览器的resize事件,使用鼠标或触屏所出现的mousemove和touchmove事件,或是滚动页面时所出现的scroll事件;这些事件可能会频密地每个几毫秒就出现。把需要执行大量代码的或潜在的计算密集型操作的事件处理函数直接关联至这些事件上会引发性能问题。如果某事件处理代码正在执行而此时另一事件又发出,则其函数调用会被积压;只有当第一个事件的代码执行完毕后,后续的第二个事件的代码才能开始执行。如果有很多事件接连不断快速发出,很快地,浏览器将因为其所承受的负荷而出现卡顿现象,使得用户界面的更新出现延迟,从而导致不响应的,槽糕的用户体验。
对于这些类型的频密发出事件,我们可以对代码进行调整,使事件处理函数只负责把事件的当前值保存在变量中。这就意味着,对于每一次的mousemove,touchmove,resize或scroll事件,其事件处理函数只是负责把光标位置,触摸位置,浏览器的宽度和高度以及滚动位置保存至各变量中。这并不会引起任何的性能问题。把计算密集型的代码移至单独的函数中,此函数按较长时间的计时器或时间间隔执行来运行代码,所使用的是保存在变量中的数据而不是直接取自于事件处理函数。这一原则被称作事件框架化,如代码清单4-16所示。
代码清单4-16 运用事件框架化来提升性能
1 //创建两个变量来保存页面的滚动位置 2 var scrollTopPosition=0, 3 scrollLeftPosition=0, 4 body=document.body, 5 header=document.getElementById("header"); 6 //创建一个事件处理函数,它只负责保存当前的滚动位置 7 function onScroll(){ 8 scrollTopPosition=body.scrollTop; 9 scrollLeftPosition=body.scrollLeft; 10 } 11 12 //增加一个函数,把当前的滚动位置显示在页面中id为header的元素中 13 function writeScrollPosition(){ 14 header.innerHTML=scrollTopPosition+"px,"+scrollLeftPosition+"px"; 15 } 16 17 //像往常一样关联事件至事件处理函数 18 document.addEventListener("scroll",onScroll,false); 19 20 //每500毫秒执行一次writeScrollPosition函数,而不是每次scroll事件发出时都执行 21 //从而提升应用程序的性能 22 window.setInterval(writeScrollPosition,500);
无论如何,都要避免把计算密集型的事件处理函数直接指派给会在连续状态下快速频密发出的事件。我们可以使用事件框架化技术作为替代从而提升事件处理的性能。
4.4 提升函数性能
改进JavaScript引用程序的性能很大程度上是提升所执行代码的效率。函数是进行效率改进的主要部分,函数所执行的每一行代码都可能影响到代码整体运行速度和性能。减少所执行的代码行数时关键所在,因此,我们可以通过一种称作“memorization”(记忆功能)的技术来实现这一点。
使用记忆功能保存先前函数的返回结果
当论及要减少应用程序所要执行的代码行数时,我们需要确保无论在什么情况下当同一函数以相同的参数来执行两次时,都可以将第一次执行的结果保存在一个变量中,以代替对同一函数的第二次调用。代码清单4-17展示了一个函数,它用作计算数值的教学阶乘结果,该函数可能会在应用程序中多次进行调用。
代码清单4-17 用于计算数值阶乘结果的函数
1 //getFactorial函数计算数值的结果,即将每个数值从1起相乘直至数值本身,例如3的乘阶为(1*2*3)=6 2 3 function getFactorial(num) { 4 var result = 1, 5 i = 1; 6 for (; i < num; i++) { 7 result*=i; 8 } 9 return result; 10 } 11 //示例 12 13 alert(getFactorial(3));//=(1*2*3)=6 14 alert(getFactorial(4));//=(1*2*3*4)=24
一旦调用了代码清单4-17中的函数来计算一次某数值的阶乘,最好是可以将结果保存在一个变量中,以便以后在代码中可以再次使用,从而避免再次执行整个函数。然而理想情况下,我们希望拥有一个方法来自动实现这一点,因为在大型应用程序中,我们可能不清楚某个函数之前是否已被我们将会提供的相同输入参数调用过。这时函数记忆功能就派上用场了———如果函数拥有储存机制,就可以把之前以特定输入参数执行的结果保存起来,然后调用保存的内容以返回之前函数执行的输出结果,而不是再次执行这个函数,从而使用性能得到提升。如代码清单4-18展示了如何为这个计算阶乘的函数增加记忆功能,方法是使用对象直接量,对象直接量的各项属性名称、各属性的值是基于提供给函数的输入参数以及函数的执行结果设定的。
代码清单4-18 为代码清单4-17中的函数增加记忆功能以提高性能
1 function getFactorial(num) { 2 var result = 1, 3 i = 1; 4 if(!getFactorial.storage){ 5 getFactorial.storage={}; 6 }else if(getFactorial.storage[num]){ 7 return getFactorial.storage[num]; 8 } 9 10 for(; i < num; i++) { 11 result *= i; 12 } 13 14 getFactorial.storage[num]=result; 15 return result; 16 } 17 //示例 18 19 alert(getFactorial(50));//执行函数里的全部内容 20 alert(getFactorial(50));//返回一个保存的值。避免执行函数的全部内容,增强了性能
记忆功能技术有着显著的效果,可以大幅度地提升复制函数的性能;然而,要让它变得真正有使用价值,我们需要一种方法来实现不需要每次都经手工处理才能把记忆功能添加至函数。代码清单4-19展示了如果建立一个功能函数,来为任意函数添加保存处理结果值的功能,尽量从函数内部用作存储功能的属性中自动返回函数的结果以增强性能。
代码清单4-19 一个一般性函数,可与其他函数一起使用以提升其性能
1 //函数memoize()需要传一个函数作为输入参数,返回相同的函数但增加了存储功能 2 function memoize(fn){ 3 4 return function(){ 5 var propertyName; 6 7 //如果这个函数还没有用作记忆的对象属性(该属性为一个对象直接量),则为其添加一个 8 fn.storage=fn.storage||{}; 9 10 //在“storage”对象直接量中建立属性名称,用于保存和重新获取函数的执行结果。属性名称应该基于传给函数的所有参数而设定, 11 //以确保属性名称是唯一的,基于所有可能性输入参数组合的 12 //我们借用Array类型的“join”方法,因为“arguments”本身不是数组类型,它并不包含此方法 13 propertyName=Array.prototype.join.call(arguments,"|"); 14 15 //该键(属性名称)是否存在用于记忆的对象中? 16 if(propertyName in fn.storage){ 17 //如果存在,则返回相关的值以避免再次执行整个函数 18 return fn.store[propertyName]; 19 }else{ 20 //如果不存在,执行相关函数并将结果保存在用作记忆的对象中 21 fn.storage[propertyName]=fn.apply(this,arguments); 22 23 //返回保存新保存的值,即函数的执行结果 24 return fn.storage[propertyName]; 25 } 26 } 27 }
代码清单4-20中的代码展示了应用代码清单4-19中的memoize()函数至代码清单4-17中的原始getFactorial函数.
代码清单4-20 应用一般性记忆功能至函数
1 //添加一般性记忆功能至函数 2 var getFactorialMemoized=memoize(getFactorial); 3 4 //示例 5 alert(getFactorialMemoized(50));//执行整个函数 6 alert(getFactorialMemoized(50));//返回所保存的值。避免了执行整个函数,增强了性能
对你编写的JavaScript函数应用记忆功能,基于给定的输入参数返回特定的输出结果,你会发现应用程序的性能会得到显著提高,特别是对对那些计算密集型的函数而言。
4.5 使用正则表达式实现更快速的字符串操作
正则表达式提供了一种强大而有效的方法来实施字符串的查找,操作和模式匹配———比其他任何方法都快速。这解释了为什么自从20世纪60年代正在表达式首次在软件中使用以来,许多编程语言的开发者一直在使用它们。
在JavaScript中,正在表达式可以通过两种方法定义,分别为使用对象构造函数或通过直接量表达式,如代码4-21所示。
代码清单4-21 在JavaScript中定义正在表达式
1 //通过JavaScript的RegExp构造函数定义一个正则表达式,表达式以字符串形式作为第一个参数传入 2 //修饰符以字符串的形式作为第二个参数传入 3 var caps1=new RegExp("[A-Z]","g"); 4 5 //定义一个正则表达式的直接量,表达式有正斜杠“/”分隔,后直接跟修饰符 6 var caps2=/[A-Z]/g;
RegExp构造函数接收一个字符串并将其转换为一个正则表达式,然而直接量的形式可以直接使用,而无须额外的处理;这使得利用直接量形式成为建立正则表达式最快速的方法。因此,要避免使用RegExp,除非是在必须动态生成正则表达式的情况下。
表4-1 正则表达式中常用的字符
字符 | 描述 |
[exp] | 对于中括号([])包裹的字符序列,正则表达式处理程序会匹配中括号内的的任意一个字符。例如,[ABC]匹配字符A、B或C的任意一个字符 |
[^exp] | 在中括号内前方加上^符号,将匹配中括号外的任意一个字符。例如,[^ABC]匹配处理字符A、B或C之外的任意一个字符 |
[exp1-exp2] | 使用-连字符表示该表达式会匹配一个字符序列中的任意一个字符。例如,[A-Z]匹配A-Z的任意一个字符,含A和Z;而[0-9]匹配0至9的任意一个字符,含0和9 |
(exp) | 对于使用小括号包裹的字符串序列,该表达式会以字符指定的次序确切地进行匹配。例如,(great)只是确切地匹配字符序列"great" |
(exp1|exp2) | 使用管道字符|表示该正则表达式会匹配一个或多个所提供的表达式。例如(great|escape)匹配字符序列great或字符序列escape |
exp+ | 在表达式后使用+字符,表示只有当该表达式被包含了一次或多次,才匹配该表达式。例如,A+,只有当字符A出现了一次或多次时,才实现匹配 |
exp* | 在表达式后使用*字符,表示只有当该表达式不出现或多次时才匹配,也就是说它可能出现也可能不出现,这适用于匹配可选的表达式的情况。例如,A*匹配的是字符A出现一次或多次,或根本不出现的情况。 |
exp? | 对于在表达式后使用?字符,匹配的是该表达式出现零次或一次。例如,A?匹配的是当字符A出现零次或一次,不超过一次的情况 |
\s | 匹配空白字符,即空格,tab制表符、回车符、换行符、换页符(form feed)。例如,A\sB所匹配的是表达式中包含中一个A字符,后跟一个空白字符,再跟一个B字符 |
\S | 匹配除了空白字符以外的任意一个字符 |
\d | 匹配从0至9的一个数字 |
\D | 匹配出了数字以外的任意一个字符 |
\w | 匹配一个文字字符,即一个字,数字或字母 |
\W | 匹配除了文字字符以外的任意一个字符 |
正则表达式修饰符是应用选项,用来定义表达式如何使用。它有三种可能性的值,可以作为选项单独使用,也可以多个一起使用,如表4-2所示。
修饰符 | 描述 |
g | 应用正则表达式在比较字符串中找到所有匹配项,而不是只返回第一个匹配项 |
i | 应用表达式进行比较匹配时,忽略大小写 |
m | 应用表达式对多行的文本进行比较匹配,而不是只是比较第一行 |
正则表达式最常见的用法是在字符串中进行子字符串的查找或替换操作。有三种String类型的方法可以使用正在表达式——match()、replace()和search()。match()方法查找出能匹配正在表达式的所有子字符串并将结果以字符串数组的形式返回,replace()方法查找和前者相同的子字符串并用传入该方法的另一个字符串进行替换,search()方法只是定位能匹配正则表达式的第一个子字符串的位置,并以数字序号形式返回该子字符串处于整个字符串中的位置。每种方法的使用如代码清单4-22所示。
代码清单4-22 在字符串中查找子字符串的正在表达式方法
1 //以下正则表达式查找从A至M的所有大写字母,含A和M。'g'修饰符表示当找到第一个匹配项时, 2 //表达式的查找并不会停止,而是会继续在目标字符串的其余部分进行查找 3 var regEx=/[A-M]/g, 4 string="The Great Escape", 5 match, 6 search, 7 replace; 8 //match()返回一个数组,各数组项正则表达式在目标字符串中找到各个匹配项(这里是各个字母) 9 match=string.match(regEx);//=["G","E"] 10 11 //search()返回第一个找到的匹配项的索引位置————正则表达式的‘g’修饰符被忽略 12 search=string.search(regEx);//4 13 14 //replace()会使用第二个参数中的值来替换目标字符串中由正则表达式查找到的匹配项 15 replace=string.replace(regEx,"_");//"The _reat _scape"
字符串的replace()方法比大多数开发者所预期的还要更强大,特别是当结合正则表达式使用的时候。第二个参数中使用特定的字符序列能够实现根据所找到的文本灵活地添加文本至要替换的问题,如表4-3所示
表4-3 可用作JavaScript字符串的replace()方法第二个参数的特殊字符
字符序列 | 含义 |
$$ | 把找到的子字符串用一个单数的$字符代替。例如:"Hello world".replace(/o/g,"$$");//"Hell$ w$rld" |
$& | 使用第一个参数 |
$` | 使用所找到的子字符串之前的文本来替换该子字符串(即处于匹配子串左侧的所有文本)。例如:"Hello World".replace(/o/g,"$`");//"HellHell WHello Wrld" |
$' | 使用所找到的子字符串之前的文本来替换该子字符串(即处于匹配子串右侧的所有文本)。例如:"Hello World".replace(/o/g,"$'");//"Hell World Wrldrld" |
$1,$2,etc | 当第一个参数中包含的正在表达式使用小括号对进行表达式分组,则可以实现提取出特定的表达式所匹配的字符串($1对应第一个小括号对的匹配项,$2对应第二个小括号对的匹配项,以此类推)。例如:"Hello World".replace(/(o)(\s)/g,"$1$1$2");//"Helloo World" |
。
有关字符串的replace()方法的另一个鲜为人知的用法是,其第二个参数可以用函数的方式传入,而不只是一个字符串值。在这种情况下,原始字符串中每出现一项匹配的字符串,都会执行一次该函数,并传入所匹配的子字符串。会使用函数的返回值来替换原子字符串,如代码清单4-23所示。
代码清单4-23 调用字符串的replace()方法时,使用函数作为第二个参数
1 //初始化一个值,作用计数器 2 var count=0; 3 4 //声明一个函数,当每找到一项匹配的子字符串时,执行该函数,函数的参数是所匹配的子字符本身 5 function replaceWithCount(value){ 6 //计数器加一 7 count=count+1; 8 9 //将传入的值结合计数器当前的值返回值目标字符串,替换掉匹配的子字符串 10 return value+count; 11 } 12 //使用样例 13 alert("Hello World".replace(/o/g,replaceWithCount));//Hello1 Wo2rld 14 alert("Hello World".replace(/\s/g,replaceWithCount));//Hello 3World
正则表达式可以是非常复杂而又极其强大的,需要你通过多年的时间来理解和掌握。这里介绍的是非常基本的内容;如果你希望深入探索这个神奇的领域,请学习Mozilla开发者网络上一篇关于JavaScript的正在表达式介绍(非常详细)
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions
4.6 更快速地使用数组
处理大型数据的数据可能会减慢代码的执行速度,因为你会发现,你将需要对数组进行建立、访问、排序和遍历。这些操作通常涉及访问或操作数组中每一个独立的数据项。因此,数据的规模越大就会越缓慢,使代码实现最极致的高效运行也就显得越重要。
4.6.1 快速创建数组
在JavaScript中创建数组有两种,创建并初始化数组最快速的方法是后者.
var myArray=new Array(); var myArray=[];
4.6.2 快速进行数组循环
大多数JavaScript代码中会充满各种循环,你需要处理数组或迭代对象直接量来对其中保存的数据实施计算或操作。迭代数据在JavaScript中是一项出了名的慢的任务,特别是在老式浏览器中。代码清单4-24的代码展示的是一段经典的JavaScript的for循环,以及一段类似版本的相同功能循环,但它却令人难以置信的高效。
代码清单4-24 两种类型的for循环,后者比前者更加高效
1 var myArray=[10,20,30,40,50,60,70,80,90,100]; 2 3 //最常见的循环 4 for (var i = 0; i < myArray.length; i++) { 5 //在循环中的每一次迭代,myArray.length的值必须重新计算以确保自上次迭代以来, 6 //该值没有发生变化————这就慢了 7 } 8 9 //一个类似的相同功能循环、但更为高效 10 for (var i = 0,length=myArray.length; i < length; i++) { 11 //只计算一次得出myArray.length的值,并保存在一个变量中 12 //在每一次迭代中,该值从变量中取回,而不是再进行重新计算,这样就更加快速 13 }
有两个命令可以用来管理循环语句
□ break使当前整个循环停止执行,继续执行该循环语句后面的代码;
□ continue使本次循环的迭代挺尸,并跳至下一次迭代。
如果已经找出了所要寻找的值,或要跳过执行某些与该循环的当前轮迭代不相关的代码块,则可以利用以下两个命令来有效地停止相应的循环迭代。代码清单4-25中的代码展示了这两个命名的使用样例.
代码清单4-25 使用break和continue命令来缩短循环的迭代次数
1 var myArray=[10,20,30,40,50,60,70,80,90,100]; 2 for (var i = 0,length=myArray.length; i < length; i++) { 3 if(myArray[i]<50){ 4 //忽略小写50的数组项的值 5 //continue会马上执行下一轮的迭代,忽略该轮迭代的其他代码 6 continue; 7 } 8 if(myArray[i]==90){ 9 //忽略大于90的数据项的值 10 //break马上停止循环迭代,忽略其他所有代码,这个循环就此结束 11 break; 12 } 13 14 }
对大量数据最快速的迭代方式是反向while循环(reverse-while)。在这项技术中,使用的是while循环,而不是for循环,并从数组的最后一个元素开始进行倒计数来进行循环的每一轮迭代,
直至第一个元素。这个方式比之前提及的for循环更快速的原因是,在for循环的每一轮迭代中,JavaScript解释器必须运行一次比较,例如index<length,以获知何时停止循环。面对于while循环,
循环停止的时刻是传给while循环的参数是一个逻辑假值的时候。如果参数的值是0,循环就会停止。这样,我们就可以倒计时至0(即数组的第一项的序号)来停止循环,而且因为不需要执行更为复杂的比较,因此,这一种循环是最快速的。反向while循环的使用如代码清单4-26所示.
代码清单4-26 反向while循环
1 var daysOfWeek=["Monday","Tuesday","wednesday","Thursday","Friday","Saturday","Sunday"], 2 len=daysOfWeek.length, 3 //循环的起始索引号对应的是数组的最后一个数据项 4 index=len, 5 daysOfWeekInReverse=[]; 6 //在作为while循环的参数进行比较之后,循环的每一轮迭代对索引号进行递减("--"在值得右边代表着该值先用,再减)。当索引号为0时,while循环将停止循环 7 while (index--){ 8 daysOfWeekInReverse.push(daysOfWeek[index]); 9 } 10 //因为while循环中的递减操作,在代码运行的最后,索引号的值将为-1 11 alert(index);
要强调的是,虽然这是最快速的方法,但对于大型数组来说也只是快了零点零几秒。因此,就其本身来说,这更像理论教学而不是编程技巧。
避免在循环中创建函数
为了使用数组处理更为高效,你应该知道一个陷阱,就是在循环中创建函数。每一次创建函数时,都会为该函数分配一定的机器内存,并填充到表示该函数的对象数据。迭代100个数组项并在循环的每一次迭代都创建一个一模一样的函数,就将会在内存中创建出100个独立而功能相同的函数。代码清单4-27展示了这个原理,其中使用了一个较小的数组,数组中有7个数组项。在每一次的迭代中,会在一个所产生的对象直接量中添加一个函数,用于返回来自原来数组项字符串的逆序值,此函数不会更改原数组的数组项的值,只是返回对象直接量中各个星期属性中name所保存的值的逆序值。
代码清单4-27 在循环中创建函数
1 var daysOfWeek=["Monday","Tuesday","wednesday","Thursday","Friday","Saturday","Sunday"], 2 index=0, 3 length=daysOfWeek.length, 4 daysObj={}, 5 dayOfWeek; 6 7 //循环迭代一周的每一天 8 for (; index < length; index++) { 9 dayOfWeek=daysOfWeek[index]; 10 11 //为daysObj对象直接量添加一项属性,该属性是一周的每一天,在该属性中添加一个函数,返回星期的逆序名称,循环结束后,该对象直接量增加了7个属性。可以用以下语句观察运行结果,:“console.log(daysObj.Friday.name);”
//以及console.log(daysObj.friday.getReverseName()); 12 daysObj[daysOfWeek]={ 13 name:dayOfWeek, 14 getReverseName:function(){ 15 return this.name.split("").reverse().join(""); 16 } 17 } 18 }
为了避免需要在每一次的迭代中创建一个函数,可以直接在循环语句之前创建并声明一个单独的函数,并在循环体内引用该函数,如代码清单4-28所示
代码清单4-28 在循环迭代中使用一个单独的函数
1 2 var daysOfWeek=["Monday","Tuesday","wednesday","Thursday","Friday","Saturday","Sunday"], 3 index=0, 4 length=daysOfWeek.length, 5 daysObj={}, 6 dayOfWeek; 7 8 //定义一个单独的函数来在循环的每次迭代内使用 9 function getReverseName(){ 10 //当函数被调用时,this关键字将指向函数被调用时所处的上下文,即调用函数时对应的daysObj对象直接量的属性,即7个星期名称中的某一个,因循环迭代的轮次而定 11 return this.name.split("").reverse().join(""); 12 } 13 for(;index<length;index++){ 14 dayOfWeek=daysOfWeek[index]; 15 daysObj[dayOfWeek]={ 16 name:dayOfWeek, 17 18 //这里直接指向那个已存在的函数,而不是创建新的函数 19 getReverseName:getReverseName 20 } 21 }
4.7 转移密集型任务至Web Worker
你可能已经敏锐地意识到,在浏览器中JavaScript是以独立的单线程运行的。通常一个浏览器会至少有三个线程,js引擎线程,GUI渲染线程以及用于控制交互的浏览器时间触发线程。js引擎线程是处理JavaScript的单线程。当它进行异步函数调用的处理时,例如向服务器发出ajax调用,整个用户页面并不会锁定。然而,如果建立一个要在每一轮迭代中进行大量计算的循环、你就会看到它的局限性了,整个用户界面在某些情况下还是会锁定。
Web worker(由Google创建的"Gears"名下的W3C标准版本)可以解决此问题。它允许加速一个运行特定密集代码的新进程,使得原来的线程不会锁定浏览器,其实就是在后台运行。如果你熟悉操作系统的线程概念,你将会很高兴地获知由web worker创建的线程实际上是操作系统中开启了一个全新的线程,意味着该线程与原来的线程完全不同,并不涉及虚拟实现。事实上,创建的每一个线程与原线程都是不同的,它甚至不能访问DOM中的页面元素,也不能访问页面上所有的全局变量。为了能在web worker线程中使用变量或对象,变量或对象必须显式地传给该线程。所有浏览器的最新版本均已支持web worker,而Internet Explorer从版本10开始支持web worker。
创建web worker线程的方法颇为简单,如代码清单4-29所示,需要传给Worker构造函数的只是一个JavaScript文件,该文件包含着要在worker线程中执行的代码。以下方式建立了worker对象并进行初始化,但还没有使代码在worker线程中运行。worker对象建立之后还要进行配置,然后才能进入真正的运行阶段。
代码清单4-29 轻而易举就可建立一个web worker对象
var workerthread=new Worker("filename.js");
能创建worker固然很好,但在大多数情况下,我们需要转移一些计算密集型的代码至一个新的worker线程,以获取从该线程的代码计算得到的输出并供原来浏览器的代码利用。web worker规范定义了两种事件:message和error,分别在worker线程回发消息和出现错误时进行调用。可以使用worker线程对象的postMessage方法把消息发给worker线程。要使worker线程启动,首先要向它发送一个含有要以这种方式进行处理的输入数据的消息以触发线程进入工作状态,如代码清单4-30所示
代码清单4-30 配置一个web worker以监听来自该worker线程发出的消息
1 var workerthread=new Worker("filename.js"); 2 3 //创建worker线程 4 var workerThread=new Worker("filename.js"); 5 6 //开始监听从该线程发出的消息 7 workerThread.addEventListener("message",function(e){ 8 9 //传入事件处理函数的对象e在它的data属性中包含着所发送的消息 10 alert(e.data); 11 12 },false); 13 14 //运行worker线程中的代码 15 workerThread.postMessage("");
对于worker线程本身,我们可以使用self.addEventListener来监听收到的消息,并使用self.postMessage向发出调用的浏览器脚本回发消息,这样就再两个脚本之间完成了通信网络。
如果worker已经完成了它的实名并需要被关闭,可以在浏览器端或worker自身内实现。在浏览器中,调用worker对象的terminate()方法将使worker中的代码使worker中的代码立即停止运行,无论它当时处于何种状态,它的线程都会立即终止;而在worker线程本身,调用self.close()方法将使worker停止运行并终止它的线程,同时发送一个close消息至调用它的浏览器脚本。
使用Web Worker来处理图像数据
有一些计算量大的图像处理任务,如果使用是标准的浏览器脚本来处理会锁定用户界面,我们可以使用wek worker来处理。我们将会提取页面中一张图片的原始图像数据,对像素进行处理以创建出同一图像的变体,一旦处理完成则用其对原始对象进行替换。
为了从页面中图像中提取出原始的像素数据,我们需要获得该图像并将它绘制到一个HTML<canvas>元素中。<canvas>元素用来在页面的某个区域上绘制像素数据,该元素可以使用JavaScript动态创建。这样,我们就可以获得该原始像素数据并进行处理,使用web worker可以避免在进行数据处理时锁定页面的界面。此worker将创建一套新的像素数据,我们会将这些像素数据绘制至同一<canvas>元素上,并使用处理所得的图像替换原来的图像,然后把<canvas>元素添加至页面中。我们将在11章介绍<canvas>更多内容。
代码清单4-31 使用web worker进行图像处理
1 <!DOCTYPE html> 2 <html> 3 4 <head> 5 <meta charset="UTF-8"> 6 <title></title> 7 </head> 8 9 <body> 10 <img src="worker.png" id="image" /> 11 </body> 12 13 </html>
代码清单4-31展示了一个简单的HTML页面,其中包含有一张图片,可以通过id为image对其进行引用,并通过JavaScript很容易地找到此DOM元素。然后,页面将加载代码清单4-32中的JavaScript代码。
代码清单4-32 用于启动图像处理的JavaScript文件
1 //使用JavaScript动态地创建一个<canvas>元素,并获取对其2d绘制上下文的引用 2 var canvas = document.createElement("canvas"), 3 context = canvas.getContext("2d"), 4 5 //获取对页面中图像的引用 6 img = document.getElementById("image"); 7 //定义一个函数来处理图像数据 8 function processImage() { 9 //保存图像的width和height,以避免每次都进行查找 10 var imgWidth = img.width, 11 imgHeight = img.height, 12 13 //使用"Listing4-33.js"文件的代码定义一个新的web worker 14 workerThread = new Worker("Listing4-33.js"); 15 //设置该新<canvas>元素的尺寸以匹配原图 16 canvas.width = imgWidth; 17 canvas.height = imgHeight; 18 //复制原图像至canvas,开始位置对其左上角 19 content.drawImage(img, 0, 0, imgWidth, imgHeight); 20 21 //定义一些代码,一旦从web worker接收到消息(当图像数据处理完成就会发出消息),执行这些代码 22 workerThread.addEventListener("message", function(e) { 23 24 //从该时间的data属性获取消息中所发送的图像数据 25 var imageData = e.data; 26 27 //把新处理所得的像素数据填充至canvas,开始位置为左上角 28 content.putImageData(imageData, 0, 0); 29 30 //现在,把所产生的<canvas>元素添加至页面中。在把<canvas>元素添加至页面之前,先实施了对 31 //<canvas>元素的所有的必要性操作,因此当时canvas上所显示的图像进行添加和替换操作时,就避免了浏览器对canvas元素的重绘 32 document.body.appendChild(canvas); 33 34 }, false); 35 //向web worker线程发生canvas中所显示的原始图像数据,使该线程启动 36 workerThread.postMessage(content.getImageData(0, 0, imgWidth, imgHeight)); 37 } 38 39 //一旦该图像加载完成,执行processImage函数 40 img.addEventListener("load", processImage, false);
代码清单4-32中的代码获取对页面中某图像的引用,一旦该图像加载完成,得出它的宽度和高度以创建一个相同尺寸的<canvas>元素。然后,提取出原始图像像素数据并利用代码清单4-33中的代码创建一个web worker对象。通过使用worker线程对象的postMessage()方法,把原始像素发送给worker线程,由worker线程对数据进行处理。一旦处理完成,worker调用它自己的self.postMessage()方法将处理后所得的数据回发。所返回数据回呗发出调用的脚本的消息事件监听函数接收(即workerThread.addEventListener(“message”,function(e){})),此函数将所返回的,经处理的图像数据绘制至<canvas>元素,最后把该<canvas>元素添加至页面。
代码清单4-33 以web worker处理图像——颜色反相
1 2 //当此worker收到来自来于发出调用的脚本的消息时,调用invertImage方法 3 //self对象所包含的web worker所能访问的方法只有那有由web worker自身进行定义和创建方法 4 self.addEventListener("message",invertImage,false); 5 6 //定义一个函数来接收图像的原始数据,并按像素逐粒地实施反想操作 7 function invertImage(e){ 8 //message事件的data属性包含着发出调用的脚本所包含的像素数据 9 var message=e.data, 10 //所发送消息中的data属性包含着原始图像的像素数据 11 imagePixels=message.data, 12 x=0, 13 len=imagePixels.length; 14 //循环遍历每一粒像素,使原始像素数据的数组中所保存的值反相。像素数据按4个值进行分组, 15 //分别表示屏幕上所见像素的红,绿,蓝和透明度数值。因此,循环的每轮迭代的加书为4 16 for(;x<len;x+=4){ 17 //要对像素值进行反相,可以用最大的可能值(即255)进行相减 18 imagePixels[x]=255-imagePixels[x]; 19 imagePixels[x+1]=255-imagePixels[x+1]; 20 imagePixels[x+2]=255-imagePixels[x+2]; 21 } 22 //最后,把包含着更新后的像素数据的消息发回至发出调用的脚本 23 self.postMessage(message); 24 }
4.8 简单的性能测试
1 //定义计量次函数执行时长所用的变量 2 var startTime, 3 endTime, 4 duration; 5 6 //我们所要计量的执行函数 7 function doSomething(){ 8 var index=0, 9 length=10000000, 10 counter=0; 11 for (; index < length; index++) { 12 counter+=index 13 } 14 } 15 16 //在函数开始执行之前,将初始时间设置为此时刻的当前日期和时间 17 startTime=new Date(); 18 //执行函数 19 doSomething(); 20 //执行时间 21 endTime=new Date(); 22 //延时为结束时间减去开始时间、两者表示毫秒,即JavaScript计时的最小精度 23 duration=endTime.getTime()-startTime.getTime(); 24 25 alert(duration);//25毫秒