代码改变世界

高性能Javascript【四】算法和流程控制高性能Javascript【五】字符串和正则表达式

2011-09-27 09:20  danhuang  阅读(470)  评论(0编辑  收藏  举报
  1. 当连接大量或很长的字符串时,join数组成员的方法是ie7以及以下的版本中性能最高的,但是在其他的现代浏览器中,join数组成员是最慢的,推荐使用简单的+和+=替代,同时要避免不必要的中间字符串。
  2. 回溯既是正则表达式匹配功能的基本组成部分,也是她的低效之源,要谨慎。
  3. 在正则表达式匹配某个字符串的时候,如果回溯失控,本来应该快速匹配的正则表达式,会变得很慢甚至导致浏览器崩溃;避免回溯失控的方法有:使相邻的字元互斥、避免嵌套量词对同一个字符串的相同部分进行多次匹配、通过重复利用向前查看(lookahead)的原子组(atomic groups)特性去除不必要的回溯。
  4. 提高正则表达式效率的各种技术手段有助于正则更快的匹配,并在非匹配位置上花更少的时间(详见下文提高正则表达式效率的方法)。
  5. 正则表达式并不总是最好的工具,尤其是当你只想搜索一个特定的字符串的时候。
  6. 清除字符串首尾空白的方法很多,一个简洁并且跨浏览器的方法是:使用2个简单的正则(一个用于头部,一个用于尾部),从尾部开始;循环向前搜索第一个非空白字符,或者把这个方法和正则结合起来使用,是另一个更好的替代方案,不会受到字符串长度的影响。

字符串连接

+/+=操作符连接

str = str + “one” + “two”在现代浏览器中性能更好;str += “one” + “two”在ie7及以下版本性能更好。

str += "one" + "two";

这是常用的连接字符串的方法,她运行的时候会经历下面四个步骤:

  1.  在内存中创建一个临时字符串;
  2. 连接后的”onetwo”被赋值给这个临时字符串;
  3. 临时字符串与str的当前值连接;
  4. 连接后的结果赋值给str。

下面的2行代码可以避免产生临时字符串(上面的第一、二步),在大多数浏览器中会提速10%-40%。

str += "one";
str += "two";

用一行代码也可以达到同样的效果。

str = str + "one" + "two";
//等价于 str = ((str + "one") + "two")

在这里str本身代替的临时字符串的作用,如果后面的这个str不再第一的位置的话(str = “one” + str + “two”),是达不到优化的效果的。这个和浏览器合并字符串是分配内存的方法有关:除ie外,现代浏览器会尝试为表达式左侧的字符串分配更多的内存,然后简单的把第二个字符串拷贝到她的尾部。在循环中,如果基础字符串位于最左侧,就可以避免重复拷贝一个不断变大的基础字符串。

在ie8中,连接字符串的时候,只是对字符串的引用关系进行记录,只有最后的时刻,才会发生合并把各个字符串拷贝到一个”真正的”字符串中,因此上面的优化也是有一定效果的。

在ie7以及更早的版本中,每连接一对字符串,都要把它复制到一块新分配的内存中,而不是拷贝到第一个的尾部,这样上面的优化只会更慢,因为这样会多次复制大字符串(longstr+s1、longstr+s2)。

数组项join连接

Array.prototype.join方法适用于ie7及更早版本,避免了+和+=带来的不断增大的大字符串的重复拷贝,消耗时间答复减少,而且消耗时间和连接的字符串的数量由平方递增(+\+=)变为线性递增(join);在大部分的现代浏览器中join方法比+/+=等方法更慢。

var strs = [];
strs.push(str1);
strs.push(str...);
newStr = strs.join("");

concat连接

String.prototype.concat要避免使用,concat比+和+=稍慢,而且和ie7中的+/+=操作一样,存在重复拷贝大字符串的性能问题。

正则表达式优化

正则表达式工作原理,了解原理有助于更好的解决各种影响正则性能的问题。

  1. 编译:浏览器验证正则表达式对象,之后把她转换成原生代码程序;把正则对象赋值给一个变量,可以避免重复编译。
  2. 设置起始位置:目标字符串的起始搜索位置,一般是字符串的其实字符、或者正则的lastIndex属性指定位置(限于带有/g的exec和test)、或者从第四步返回时的最后一次匹配的字符的下一个字符。
    浏览器优化正则表达式引擎的办法是,在这一阶段中通过早期预测跳过一些不必要的工作。例如,如果一个正则表达式以^开头,IE 和Chrome通常判断在字符串起始位置上是否能够匹配,然后可避免愚蠢地搜索后续位置。另一个例子是匹配第三个字母是x的字符串,一个聪明的办法是先找到x,然后再将起始位置回溯两个字符。
  3. 匹配每个正则表达式字元:从字符串的起始位置开始,逐个检查文本和正则模式,当一个特定的字元匹配失败时,回溯到之前尝试匹配的位置,尝试其他可能的路径。
  4. 匹配成功或失败:如果在当前的字符串位置有一个完全匹配,则宣布匹配成功;如果当前位置没有所有可能的路径都没有成功匹配,会退回第二步,重新设置起始位置,开始另一轮匹配…直到以最后一个字符串为其实位置,仍未成功,则宣布匹配失败。

理解回溯(Backtracking)

回溯是匹配过程的基本组成部分,是正则如此强大的根源,也是正则的性能消耗所在,因此如何减少回溯是提高正则的关键所在。回溯一般在分支和重复的情况下出现:

分支与回溯

/h(ello|appy) hippo/.test("hello there, happy hippo");
  1. 正则开始的h与字符串起始位置的h匹配,接下来的分支,按从左到右的原则,(ello|appy)中的ello先尝试匹配,字符串h后面也是ello,匹配成功,于是继续匹配正则中(ello|appy)之后的空格,仍然匹配成功,继续匹配正则中空格之后的h,字符串空格之后是t,匹配失败。
  2. 回到正则的分支(ello|appy)(这就是回溯),尝试用appy对字符串第一位个字符h之后的字符进行匹配,失败,这里没有更多的选项,不再回溯。
  3. 第一个起始位置匹配失败,起始位置后延一位,重新匹配h…直到字符串起始位置为14时,匹配到h。
  4. 于是开启新一轮的字元匹配,进入分支(ello|appy)中的ello,匹配失败。
  5. 回到正则的分支(ello|appy)(再次回溯),appy匹配成功,退出分支,匹配后续的 hippo,匹配字符串happy hippo,匹配成功,结束匹配。

重复与回溯

var str = "<p>Para 1.</p><img src='smiley.jpg'><p>Para 2.</p><div>Div.</div>";
/<p>.*<\/p>/i.test(str);
  1. 正则开始的<p>与字符串起始位置的<p>匹配,接下来是.*(.匹配换行以外任意字符,*是贪婪量词,表示重复0次或多次,匹配尽可能多的次数),.*匹配后续一直到字符串尾部的所有字符。
  2. 尝试匹配正则中.*后面的<,在字符串最后匹配失败,然后每次向前回溯一个字符尝试匹配…,直到</div>的第一个字符匹配成功,接下来正则中的\/也与字符串中的/匹配成功,继续匹配正则中的p,匹配失败,返回</div>,继续向前回溯,直到第二段的</p>,匹配成功,返回<p>Para 1.</p><img src=’smiley.jpg’><p>Para 2.</p>,里面有2个段落和一张图片,结束匹配。但这个可能并不是我们真正需要的结果,我们需要的可能是一个单一的段落。

贪婪与惰性

我们可以通过把贪婪量词*改成惰性量词*?来匹配单个段落/<p>.*?<\/p>/i.test(str)。惰性量词回溯的方式与贪婪连词的相反,当匹配到.*?时,她会先尝试匹配尽可能少的次数(0次),直接进入正则接下来的匹配部分<\/p>,但是字符串的<p>后面并没<,于是进行回溯,尝试对.*?进行一次重复匹配,仍然不行,继续回溯,两次重复匹配…直到找到最近的</p>,完成匹配,返回<p>Para 1.</p>。

参考:http://blog.stevenlevithan.com/archives/greedy-lazy-performance

回溯失控

回溯失控的时候,可能导致浏览器假死数秒、数分钟或更长时间,以下面这个正则为例:

/<html>[\s\S]*?<head>[\s\S]*?<\/head>[\s\S]*?<body>[\s\S]*?<\/body>[\s\S]*?<\/html>/

匹配结构完整的html文件时,一切正常,但是有些标签缺失时问题就出现了:假如html文件最后的</html>缺失,最后一个[\s\S]*?重复会扩展到字符串末尾,匹配</html>失败,正则会依次向前搜索可以继续回溯的位置,在这里是<\/body>之前的倒数第二个[\s\S]*?,用它匹配到第一个</body>之后,继续向后扩展(惰性量词不是重复最少次数),查找第二个</body>,直到末尾仍然没有匹配,于是继续回溯到倒数第三个[\s\S]*?,依次类推下去…没有意义的回溯不断扩展开来,会消耗掉很多的资源。

回溯失控解决方案:具体化

比如用[^"\r\n]*替代.*?,可以避免回溯时.对”的匹配,同时避免超出预期的无效匹配,上面的例子可以改为。

/<html>(?:(?!<head>)[\s\S])*<head>(?:(?!<\/head>)[\s\S])*<\/head>(?:(?!<body>)[\s\S])*<body>(?:(?!<\/body>)[\s\S])*<\/body>(?:(?!<\/html>)[\s\S])*<\/html>/

以上通过重复一个非捕获组((?:(?!<head>)[\s\S]) 目标标签以外的任何字符)达到相同的效果:其中包含一个否定的向前查看(?!<head>)(排除目标标签),和[\s\S](任意字符)元序列,这样不但保证了非目标标签(非<head>字符)的匹配,并且保证了[\s\S]不会被扩展,但是这个优化是缺乏效率的,因为对每一个字符进行匹配时,都重复了一次向前查看。

回溯失控终极方案:模拟原子组(向前查看+反向引用):(?=([\s\S]*?<head>))\1。

/<html>(?=([\s\S]*?<head>))\1(?=([\s\S]*?<\/head>))\2(?=([\s\S]*?<body>))\3(?=([\s\S]*?<\/body>))\4[\s\S]*?<\/html>/

原子组(向前查看)的任何回溯位置都会被丢弃,从根源上避免了回溯失控,但是向前查看不会消耗任何字符作为全局匹配的一部分,捕获组+反向引用在这里可以用来解决这个问题,需要注意的是这的反向引用次数,即上面的\1、\2、\3、\4对应的位置。

嵌套量词与回溯失控

嵌套量词(例如(x+)*)在匹配时,内部量词与外部量词的排列组合,会产生数量巨大分支路径,这在匹配失败之前会尝试所有的路径,这时候的消耗是巨大的。

最糟糕的情况是用/(A+A+)+B/来匹配10个A的字符串(非实际情况):

  1. 第一个A+匹配到10个A,回溯一个字符,第二个A+匹配到最后一个A,然后开始查找B,没有匹配
  2. 尝试所有的路径,第一个A+匹配到8个A,第二个A+匹配2个A…;或者分组(A+A+)+的重复中,第一个A+匹配到2个A,第二个A+匹配3个A…一共尝试2的10(字符串长度)次方1024次回溯
  3. 显示这个B不可能匹配到,但是正则会把所有可能的路径都尝试一遍,最后才宣布匹配失败。

要预防这种情况,需要确保表达式的2部分不能对字符串的相同部分进行匹配,/(A+A+)+B/可以优化为/AA+B/,或者用终极招式:模拟原子组(/((?=(A+A+))\2)+B/)来彻底消除回溯问题。

基准测试的说明

正则表达式的性能和她匹配的字符串紧密相关,结果差异也很大,因此需要根据具体的情况,用各种字符串来测试,包括不同的长度,不匹配的和近似匹配的。

更多提高正则表达式效率的方法

  • 让匹配更快失败,尤其是匹配很长的字符串时,匹配失败的位置要比成功的位置多得多。
  • 以简单、必须的字元开始,排除明显不匹配的位置,如锚点(^或$),特殊字符(x或\u263A)字符类([a-z]或\d之类的速记符),和单词边界(\b);尽量避免使用分组、选择、重复量词开头,如/one|two/、\s、\s{1,}等。
  • 使用量词模式时,尽量让重复部分具体化,让字元互斥,如用”[^"\r\n]*”代替”.*?”(这个依赖回溯)。
  • 减少分支数量、缩小分支范围,用字符集和选项组件来减少分支的出现,或把分支在正则上出现的位置推后,把分支中最常出现的情况放在分支的最前面。
    cat|bat -> [cb]at;
    red|read -> rea?d;
    red|raw -> r(?:ed|aw);
    (.|\r|\n) -> [\s\S]
  • 使用非捕获组,因为捕获组需要消耗时间和内存来记录反向引用,并不断更新,如果不需要反向引用,可用非捕获组(?:…)代替捕获组(…);当需要全文匹配的反向引用时,可用regex.exec()返回的结果或者在替换字符串是使用$&。
    此优化在firefox中效果较小,但其他浏览器中处理长字符串时有较大影响。
  • 精确匹配需要的文本以减少后续的处理,如果需要引用匹配的一部分,可使用捕获,然后通过反向引用来处理。
  • 暴露必需的字元,用/^(ab|cd)/而不是/(^ab|^cd)/。
  • 使用合适的量词,基于预期的回溯数量,使用合适的量词类型。
  • 把正则表达式赋值给变量以便复用和提升提升性能,这样可以让正则减少不必要的编译过程。
    while (/regex1/.test(str1)) {
    /regex2/.exec(str2);

    }
    用下面的代替上面的
    var regex1 = /regex1/,regex2 = /regex2/;
    while (regex1.test(str1)) {
    regex2.exec(str2);

    }
  • 将复杂的正则表达式拆分成简单的片段,每个正则只在上一个成功的匹配中查找,更高效,而且可以减少回溯。

何时不使用正则表达式

如果仅仅是搜索字符串,而且事先知道字符串的哪部分需要被测试时,正则并不是最佳的解决方案。比如,检查一个字符串是否以分号结尾:

/;$/.test(str);正则会从第一个字符开始,逐个测试整个字符串,看她是否是分号,在判断是否在字符串的最后,当字符串很长时,需要的时间越多。

str.charAt(str.length – 1) == “;”;这个直接跳到最后一个字符,检查是否为分号,字符串很小是可能只是快一点点,但是对于长字符串,长度不会影响所需的时间。

字符串的原生方法都是很快的,比如slice、substr、substring、indexOf、lastIndexOf等,他们可以避免正则带来的性能开销。

去除字符串首尾空白

String.prototype.trim = function() {
  var str = this.replace(/^\s+/, ""),
  end = str.length - 1,
  ws = /\s/;
  while (ws.test(str.charAt(end))) {
    end--;
  }
  return str.slice(0, end + 1);
}

这个解决方案用正则来去除头部的空白,位置锚^,会很快,主要是尾部的空白处理,像上面何时不使用正则表达式里说的,用正则并不是最佳的,这里用字符串原生方法结合正则来解决,可以避免性能受到字符串长度和空白的长度的影响。

说明:http://blog.stevenlevithan.com/archives/faster-trim-javascript