本文是阅读《高性能JavaScript》一书后,从 字符串和正则表达式 模块对JavaScript性能优化做了部分总结,记录一下。可能总结的不好,不是很完整,也希望各位大佬能多给出一些建议。万分感谢!


字符串连接

使用循环

  • 向字符串末尾不断地添加内容。这种方式在某些浏览器中性能很糟糕

使用 + 或者 += 操作符

  • 常用方法:str += "one" + "two"
    此代码运行会经历四个步骤

    1. 在内存中穿件一个临时字符串
    2. 连接后的字符串 “onetwo” 被赋值给临时字符串
    3. 临时字符串与 str 当前的值连接
    4. 结果赋值给 str
  • 优化版本

    1
    2
    str += "one";
    str += "two";
  • 更简化的写法:str = str + "one" + "two"

下面这两种优化写法避免了产生临时字符串。但是如果改变连接顺序(例如:str = “one” + str + “two”),本优化将失效。
这与浏览器合并字符串时分配内存的方法有关。除 IE 外,其他浏览器会尝试为表达式左侧的字符串分配更多的内存,然后简单地将第二个字符串拷贝至它的欧威。如果在一个循环中,基础字符串位于最左端的位置,就可以避免重复拷贝一个逐渐变大的基础字符串。

Firefox 和编译器合并

在复制变大是中所有要连接的字符串都属于编译期常量,Firefox 会在编译过程中自动合并它们。下面的方式可以看到这一过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function foldingDemo() {
var str = "complie" + "time" + "folding";
str += "this" + "works" + "too";
str = str + "but" + "not" + "this";
}

alert(foldingDemo.toString);

// alert 出的结果
function foldingDemo() {
var str = "complietimefolding";
str += "thisworkstoo";
str = str + "but" + "not" + "this";
}

当字符串通过这种方式合并在一起时,由于运行期没有中间字符串,所以花在连接过程的时间和内存可以减少到零。这种做法非常不错,但它不是经常起作用,因为更多的时候是用运行期的数据构建字符串,而不是用编译期常量。

数据项合并(Array.prototype.join)

在大多数浏览器中,数据项合并比其他字符串连接方法更慢,但事实上,它却是IE7及更早版本浏览器中合并大量字符串 唯一高效的途径
因为 IE7 的连接算法要求浏览器在循环过程中为逐渐增大的字符串不断复制并分配内存。结果是运行时间和内存消耗以平方关系递增。

  • 实例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    var str = "I'm a thirty-five character string.",
    newStr = "",
    appends = 5000;

    while (appends --) {
    newStr += str;
    }

    // 在 IE 中优化
    var str = "I'm a thirty-five character string.",
    strs = [],
    newStr,
    appends = 5000;

    while (appends --) {
    strs[strs.length] = str;
    }

    newStr = strs.join("");

    性能提升原因:当把所有的数组元素连接在一起时,浏览器会分配足够的内存来存放整个字符串,而且不会多次拷贝最终字符串中相同的部分。

String.prototype.concat

这是最灵活的字符串合并方法,因为你可以用它只附加一个字符串,或者同事附加多个字符串,以至整个字符串数组。

遗憾的是,在多数情况下,使用 concat 比使用简单的 + 和 += 稍慢,尤其是在 IE、Opear、Chrome 中慢的更明显。
此外,尽管使用 concat 合并字符串数组与前面讨论的数组项连接类似,但它通常更慢一些(Opera 除外),并且它也潜伏着灾难性的性能问题,就像在 IE7 及早期版本中使用 + 和 += 构建大字符串时那样。


正则表达式优化

正则表达式处理的基本步骤

  1. 编译
  2. 设置起始位置
  3. 匹配每个正则表达式字元
  4. 匹配成功或失败

回溯

回溯先关篇幅过长,文字描述过多,想要更多了解请参阅《高性能 JavaScript》第五章:“字符串和正则表达式”。第 89 页

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

  • 关注如何让匹配更快失败
    正则表达式处理慢往往是因为匹配失败过程慢,而不是匹配成功过程慢。如果你使用正则表达式匹配一个很大字符串的一小部分,情况更为严重,正则表达式匹配失败的位置比匹配成功的位置要多得多。如果 一个修改使正则表达式匹配更快但失败更慢(例如,通过增加所需的回溯次数去尝试所有分支的排列组合),这通常是一个失败的修改。

  • 正则表达式以简单、必需的字元开始
    最理想的情况是,一个正则表达式的起始字元应当尽可能快速地测试并排除明显不匹配的位置。用于此目的好的起始字元通常是一个锚(^或$),特定字符(例如 x 或\u363A),字符类(例如,[a-z]或速记符例如\d),和单词边界(\b)。如果可能的话,避免以分组或选择字元开头,避免顶级分支例如/one|two/,因为它强迫正则表达式识别多种起始字元。Firefox 对起始字元中使用的任何量词都很敏感,能够优化的更好,例如,以\s\s*替代\s+或\s{1,}。其他浏览器大多优化掉这些差异。

  • 使用量词模式,使它们后年的字元互斥
    当字符与字元毗邻或子表达式能够重叠匹配时,一个正则表达式尝试分解文本的路径数量将增加。为避免此现象,尽量具体化你的模板。当你想表达“[^"\r\n]*”时不要使用“.*?”(依赖回溯)

  • 减少分支数量,缩小分支范围
    分支使用 | ,竖线,可能要求在字符串的每一个位置上测试所有的分支选项。你通常可通过使用字符类和选项组件减少对分支的需求,或将分支在正则表达式上的位置推后(允许到达分支之前的一些匹配尝试失败)。

  • 使用非捕获组
    捕获组花费时间和内存用于记录后向引用,并保持它们是最新的。如果你不需要一个后向引用,可通过使用非捕获组避免这种开销——例如,(?:…)替代(…)。有些人当他们需要一个完全匹配的后向引用时,喜欢将他们的正则表达式包装在一个捕获组中。这是不必要的,因为你能够通过其他方法引用完全匹配,例如,使用 regex.exec()返回数组的第一个元素,或替换字符串中的$&。

  • 只捕获感兴趣的文本以减少后处理
    作为上一条的补充说明,如果你需要引用匹配的一部分,应当通过一切手段,捕获那些片断,再使用后向引用处理。例如,如果你写代码处理一个正则表达式所匹配的引号中的字符串内容,使用/"([^"]*)"/然后使用一次后向引用,而不是使用/"[^"]*"/然后从结果中手工剥离引号。当在循环中使用时,减少这方面的工作可以节省大量时间。

  • 暴露必须的字元
    为帮助正则表达式引擎在如何优化查询例程时做出明智的决策,应尽量简单地判断出那些必需的字元。当字元应用在子表达式或者分支中,正则表达式引擎很难判断他们是不是必需的,有些引擎并不作此方面努力。例如,正则表达式/^(ab|cd)/暴露它的字符串起始锚。IE 和 Chrome 会注意到这一点,并阻止正则表达式尝试查找字符串头端之后的匹配,从而使查找瞬间完成而不管字符串长度。但是,由于等价正则表达式/(^ab|^cd)/不暴露它的^锚,IE 无法应用同样的优化,最终无意义地搜索字符串并在每一个位置上匹配。

  • 使用适当的量词
    在确保正确等价的前提下,使用更合适的量词类型(基于预期的回溯次数)可以显著提高性能,尤其在处理长字符串时。

  • 把正则表达式赋值给变量并重用它们
    将正则表达式赋给变量以避免对它们重新编译。有人做的太过火,使用正则表达式缓存池,以避免对给定的模板和标记组合进行多次编译。不要过分忧虑,正则表达式编译很快,这样的缓存池所增加的负担可能超过他们所避免的。重要的是避免在循环体中重复编译正则表达式。

  • 将复杂的正则表达式拆分成简单的片段(化繁为简)
    尽量避免一个正则表达式做太多的工作。复杂的搜索问题需要条件逻辑,拆分为两个或多个正则表达式更容易解决,通常也更高效,每个正则表达式只在最后的匹配结果中执行查找。在一个模板中完成所有工作的正则表达式怪兽很难维护,而且容易引起回溯相关的问题。

何时不使用正则表达式

  • 当只是搜索字面字符串时,可以使用 string 的 charAt()、slice()、 substr、substring() 等方法替代正则表达式

去除字符串首尾空白

使用正则表达式去首尾空白

  • 第一种

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    if (!Strig.prototype.trim) {
    // trim 1
    Strig.prototype.trim = function() {
    return this.replace(/^\s+/, "").replace(/\s+$/, "");
    }
    }

    // 测试新方法
    // 头部空白中包含了制表(\t)和换行符(\n)
    var str = "\t\n test string ".trim();
    alert(str == "test string"); // 弹出信息为 true
  • 第二种:使用分支功能合并了两个简单正则表达式

    1
    2
    3
    4
    // trim 2
    Strig.prototype.trim = function() {
    return this.replace(/^\s+|\s+$/g, "");
    }

    在处理较长字符串时要比第一种使用两个简单的子表达式要慢。

  • 第三种

    1
    2
    3
    4
    // trim 3
    Strig.prototype.trim = function() {
    return this.replace(/^\s*([\s\S]*?)\s*$/, "$1");
    }

    这个正则表达式的作用是匹配整个字符串,捕获从第一个到最后一个非空白字符串。在处理较长字符串时就变得很慢。

  • 第四种

    1
    2
    3
    4
    // trim 4
    Strig.prototype.trim = function() {
    return this.replace(/^\s*([\s\S]*\S)?\s*$/, "$1")
    }

    这个与第三种类似,但是考虑到性能原因,它把惰性量词替换成了一个贪婪量词。速度很快,甚至超过第一种使用两个子表达式的方案。(除非字符串里的尾部空白比其他文字还多)。但是这个方案在 Firefox 和 Opera 9 中相当慢。因此,第一种方案目前仍是最好的跨浏览器方案。

  • 第五种

    1
    2
    3
    4
    // trim 5
    String.prototype.trim = function() {
    return this.replace(/^\s*(\S*(\s+\S+)\s*$/), "$1");
    }

    无论在何种情况下,这种方案都是上述所有方法中最慢的。

使用正则表达式去除空白时的缺陷:当字符串的末尾有一小段空白时,正则表达式会陷入疯狂工作状态,这是因为正则表达式很好的去除了字符串头部的空白,但它们却不能同样快速地去除长字符串尾部的空白。

不使用正则表达式去除字符串首尾空白

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// trim 6
String.prototype.trim = function() {
var start = 0,
end = this.length - 1,
ws = " \n\r\t\f\x0b\xa0\u1680\u180e\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u200b\u2028\u2029\u202f\u205f\u3000\ufeff";

while (ws.indexOf(this.charAt(start)) > -1) {
start++;
}

while (end > start && ws.indexOf(this.charAt(end)) > -1) {
end--;
}

return this.slice(start, end + 1);
}

不使用正则表达式去除字符串首尾空白时的缺陷:不宜用来处理前后打断的空白字符。因为通过循环遍历字符串来确定白字符的效率比不上正则表达式使用的优化后的搜索代码。

混合解决方案

使用正则表达式方法过滤头部空白,用非正则表达式的方法过滤尾部字符。

1
2
3
4
5
6
7
8
9
10
11
12
// trim 7
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);
}

这种混合方法在过来吧一小段空白时速度非常快,在处理头部有很多空白或者仅由空白组成的字符串时,也没有性能风险(尽管在处理尾部长空白时仍然存在不足)。

不同 trim 实现版本在各种浏览器上的性能

浏览器类别 Trim1 Trim2 Trim3 Trim4 Trim5 Trim6 Trim6
IE 7 80/80 315/312 547/539 36/42 218/224 14/1015 18/409
IE 8 70/70 252/256 512/425 26/30 216/222 4/334 12/205
Firefox 3 136/147 164/174 650/600 1098/1525 1416/1488 21/151 20/144
Firefox 3.5 130/147 157/172 500/510 1004/1437 1344/1394 21/332 18/50
Safari 3.2.3 253/253 424/425 351/359 27/29 541/554 2/140 5/80
Safari 4 37/37 33/31 69/68 32/33 510/514 <0.5/29 4/18
Opera 9.64 494/517 731/748 9066/9601 901/955 1953/2016 <0.5/210 20/241
Opera 10 75/75 94/100 360/370 46/46 514/514 2/186 12/198
Chrome 2 78/78 66/68 100/101 59/59 140/142 1/37 24/55
  • 报告时间以毫秒为单位,是修剪一个大字符串(40KB)100次所用的时间,每个字符串以 10 个空格开头,以 1000 个空格结尾
  • trim 1 方案测试时关闭 /\s\s*$\优化
  • trim 5 方案测试时关闭非捕获组优化

小结

密集的字符串操作和草率地编写正则表达式可能产生严重的性能障碍,下面的的优化方案可以帮助你避免这些常见的陷阱:

  • 当连接数量巨大或尺寸巨大的字符串时,数组项合并是唯一在 IE7 及更早版本中性能合理的方法

  • 如果不考虑 IE7 及更早版本的性能,数组向合并是最慢的字符串链接方法之一。推荐使用简单的 + 或者 += 操作符替代,避免不必要的中间字符串

  • 回溯既是正则表达式匹配功能的基本组成部分,也是正则表达式的低效之源

  • 回溯失控发生在正则表达式本应快速匹配的地方,但因为某些特殊的字符串匹配动作导致运行缓慢甚至浏览器崩溃。避免这个问题的方法是:使相邻的字元互斥,避免嵌套两次对同一字符串的相同部分多次匹配,通过重复利用预查的原子组去除不必要的回溯

  • 提高正则表达式效率的各种手段会有助于正则表达式更快的匹配,并在非匹配位置上花更少的时间

  • 正则表达式并不总是完成工作的最佳工具,尤其当你只搜索字面字符串的时候

  • 尽管有许多方法可以去除字符串的首尾空白,但使用两个简单的正则表达式(一个用来去除头部空白,一个用来去除尾部空白)来去除大量字符串内容能提供一个简洁而跨浏览器的方法。从字符串末尾开始循环向前搜索第一个非空白字符,或者将此技术同正则表达式结合起来,会提供一个更好的替代方案,它很少受到字符串长度的影响