第 10 章: 正则表达式

在前面的章节中,我们多次需要在字符串值中查找模式。在 第 4 章 中,我们通过写出日期中数字所在的确切位置来从字符串中提取日期值。后来,在 第 6 章 中,我们看到了一些非常难看的代码,用于在字符串中查找特定类型的字符,例如必须在 HTML 输出中转义的字符。

正则表达式是一种用于描述字符串模式的语言。它们形成了一种小型、独立的语言,被嵌入到 JavaScript(以及各种其他编程语言中,以某种方式)。它不是一种非常易读的语言——大型正则表达式往往完全不可读。但是,它是一个有用的工具,可以真正简化字符串处理程序。


就像字符串用引号括起来一样,正则表达式模式用斜杠 (/) 括起来。这意味着表达式中的斜杠必须用反斜杠转义。

var slash = /\//;
show("AC/DC".search(slash));

search 方法类似于 indexOf,但它搜索的是正则表达式而不是字符串。正则表达式指定的模式可以做一些字符串无法做的事情。首先,它们允许某些元素匹配多个字符。在 第 6 章 中,从文档中提取标记时,我们需要找到字符串中的第一个星号或左大括号。这可以用以下方法完成:

var asteriskOrBrace = /[\{\*]/;
var story =
  "We noticed the *giant sloth*, hanging from a giant branch.";
show(story.search(asteriskOrBrace));

[] 字符在正则表达式中具有特殊含义。它们可以包含一组字符,意思是“这些字符中的任何一个”。大多数非字母数字字符在正则表达式中都有特殊含义,因此当您使用它们来表示实际字符时,最好始终用反斜杠1 转义它们。


有一些常用的字符集快捷方式。点 (.) 可以用来表示“除换行符之外的任何字符”,转义的“d”(\d)表示“任何数字”,转义的“w”(\w)匹配任何字母数字字符(包括下划线,出于某种原因),转义的“s”(\s)匹配任何空白字符(制表符、换行符、空格)。

var digitSurroundedBySpace = /\s\d\s/;
show("1a 2 3d".search(digitSurroundedBySpace));

转义的“d”、“w”和“s”可以被它们的字母大写替换,表示它们的相反意思。例如,\S 匹配任何非空白字符。在使用 [] 时,可以使用 ^ 字符来反转模式:

var notABC = /[^ABC]/;
show("ABCBACCBBADABC".search(notABC));

如您所见,正则表达式使用字符来表达模式的方式使得它们 A)非常短,B)非常难读。


示例 10.1

编写一个正则表达式,匹配格式为 "XX/XX/XXXX" 的日期,其中 X 是数字。针对字符串 "born 15/11/2003 (mother Spot): White Fang" 测试它。

var datePattern = /\d\d\/\d\d\/\d\d\d\d/;
show("born 15/11/2003 (mother Spot): White Fang".search(datePattern));

有时您需要确保模式从字符串开头开始或从字符串结尾结束。为此,可以使用特殊字符 ^$。第一个匹配字符串的开头,第二个匹配字符串的结尾。

show(/a+/.test("blah"));
show(/^a+$/.test("blah"));

第一个正则表达式匹配任何包含 a 字符的字符串,第二个只匹配完全由 a 字符组成的字符串。

注意,正则表达式是对象,并且具有方法。它们的 test 方法返回一个布尔值,表示给定字符串是否与表达式匹配。

代码 \b 匹配“词边界”,可以是标点符号、空白符或字符串的开头或结尾。

show(/cat/.test("concatenate"));
show(/\bcat\b/.test("concatenate"));

模式的一部分可以重复多次。在元素后添加星号 (*) 允许它重复任意次数,包括零次。加号 (+) 做同样的事情,但要求模式至少出现一次。问号 (?) 使元素“可选”——它可以出现零次或一次。

var parenthesizedText = /\(.*\)/;
show("Its (the sloth's) claws were gigantic!".search(parenthesizedText));

必要时,可以使用大括号来更精确地指定元素出现的次数。大括号中的数字 ({4}) 给出了它必须出现的次数。两个数字之间用逗号隔开 ({3,10}) 表示模式必须至少出现与第一个数字一样多次,最多出现与第二个数字一样多次。类似地,{2,} 表示两次或更多次出现,而 {,4} 表示四次或更少次出现。

var datePattern = /\d{1,2}\/\d\d?\/\d{4}/;
show("born 15/11/2003 (mother Spot): White Fang".search(datePattern));

片段 /\d{1,2}//\d\d?/ 都表示“一位或两位数字”。


示例 10.2

编写一个匹配电子邮件地址的模式。为简单起见,假设 @ 符号之前和之后的部分只包含字母数字字符和 .-(点和连字符)字符,而地址的最后一部分(最后一个点之后的国家代码)可能只包含字母数字字符,并且必须是两个或三个字符长。

var mailAddress = /\b[\w\.-]+@[\w\.-]+\.\w{2,3}\b/;

show(mailAddress.test("[email protected]"));
show(mailAddress.test("I mailt [email protected], but it didn wrok!"));
show(mailAddress.test("[email protected]"));

模式开头和结尾处的 \b 确保第二个字符串不匹配。


正则表达式的一部分可以用括号分组。这使我们能够对多个字符使用 * 等。例如

var cartoonCrying = /boo(hoo+)+/i;
show("Then, he exclaimed 'Boohoooohoohooo'".search(cartoonCrying));

那个正则表达式结尾的 i 从哪里来?在结束斜杠之后,可以在正则表达式中添加“选项”。这里的 i 表示表达式不区分大小写,这允许模式中的小写 B 与字符串中的大写 B 匹配。

管道字符 (|) 用于允许模式在两个元素之间进行选择。例如

var holyCow = /(sacred|holy) (cow|bovine|bull|taurus)/i;
show(holyCow.test("Sacred bovine!"));

通常,查找模式只是从字符串中提取某些内容的第一步。在前面的章节中,这种提取是通过多次调用字符串的 indexOfslice 方法来完成的。现在我们已经了解了正则表达式的存在,可以使用 match 方法代替。当字符串与正则表达式匹配时,如果匹配失败,结果将为 null;如果匹配成功,结果将为匹配字符串的数组。

show("No".match(/Yes/));
show("... yes".match(/yes/));
show("Giant Ape".match(/giant (\w+)/i));

返回数组中的第一个元素始终是与模式匹配的字符串部分。如最后一个示例所示,当模式中存在带括号的部分时,它们匹配的部分也会添加到数组中。通常,这使得提取字符串片段变得非常容易。

var parenthesized = prompt("Tell me something", "").match(/\((.*)\)/);
if (parenthesized != null)
  print("You parenthesized '", parenthesized[1], "'");

示例 10.3

重写我们在 第 4 章 中编写的 extractDate 函数。当给定一个字符串时,此函数查找遵循我们之前看到的日期格式的内容。如果它能找到这样的日期,它会将这些值放入 Date 对象中。否则,它会抛出异常。使它接受日期,其中日期或月份只写一位数字。

function extractDate(string) {
  var found = string.match(/(\d\d?)\/(\d\d?)\/(\d{4})/);
  if (found == null)
    throw new Error("No date found in '" + string + "'.");
  return new Date(Number(found[3]), Number(found[2]) - 1,
                  Number(found[1]));
}

show(extractDate("born 5/2/2007 (mother Noog): Long-ear Johnson"));

这个版本比前一个版本稍微长一些,但它有一个优点,它实际上检查它正在做什么,并在给定无意义的输入时大声喊出来。没有正则表达式,这会困难得多——需要多次调用 indexOf 才能确定数字是一位还是两位,以及破折号是否在正确的位置。


字符串值的 replace 方法(我们在 第 6 章 中见过)可以接受一个正则表达式作为它的第一个参数。

print("Borobudur".replace(/[ou]/g, "a"));

注意正则表达式后面的 g 字符。它代表“全局”,意思是应该替换字符串中与模式匹配的每个部分。当省略此 g 时,只会替换第一个 "o"

有时需要保留被替换字符串的部分。例如,我们有一个大型字符串,其中包含人员的姓名,每行一个姓名,格式为“姓氏, 名字”。我们要交换这些名字,并删除逗号,以获得简单的“名字 姓氏”格式。

var names = "Picasso, Pablo\nGauguin, Paul\nVan Gogh, Vincent";
print(names.replace(/([\w ]+), ([\w ]+)/g, "$2 $1"));

替换字符串中的 $1$2 指的是模式中的带括号的部分。$1 被替换为与第一对括号匹配的文本,$2 被替换为与第二对括号匹配的文本,依此类推,一直到 $9

如果您在模式中有超过 9 个带括号的部分,这将不再有效。但是,还有一个方法可以替换字符串的片段,这在其他一些棘手的情况下也很有用。当给 replace 方法的第二个参数是一个函数值而不是一个字符串时,每次找到匹配项都会调用此函数,并且匹配的文本将被函数返回的任何内容替换。传递给函数的参数是匹配的元素,类似于 match 返回的数组中找到的值:第一个是整个匹配项,之后是模式中每个带括号部分的一个参数。

function eatOne(match, amount, unit) {
  amount = Number(amount) - 1;
  if (amount == 1) {
    unit = unit.slice(0, unit.length - 1);
  }
  else if (amount == 0) {
    unit = unit + "s";
    amount = "no";
  }
  return amount + " " + unit;
}

var stock = "1 lemon, 2 cabbages, and 101 eggs";
stock = stock.replace(/(\d+) (\w+)/g, eatOne);

print(stock);

示例 10.4

最后一个技巧可以用来使 第 6 章 中的 HTML 转义器更有效。您可能记得它看起来像这样

function escapeHTML(text) {
  var replacements = [["&", "&"], ["\"", """],
                      ["<", "&lt;"], [">", "&gt;"]];
  forEach(replacements, function(replace) {
    text = text.replace(replace[0], replace[1]);
  });
  return text;
}

编写一个新的函数 escapeHTML,它做同样的事情,但只调用 replace 一次。

function escapeHTML(text) {
  var replacements = {"<": "&lt;", ">": "&gt;",
                      "&": "&amp;", "\"": "&quot;"};
  return text.replace(/[<>&"]/g, function(character) {
    return replacements[character];
  });
}

print(escapeHTML("The 'pre-formatted' tag is written \"<pre>\"."));

replacements 对象是一种将每个字符与其转义版本关联起来的方法。这样使用它是安全的(即不需要 Dictionary 对象),因为唯一使用的属性是与 /[<>&"]/ 表达式匹配的属性。


在某些情况下,您需要匹配的模式在编写代码时是未知的。假设我们正在为留言板编写一个(非常简单的)色情过滤器。我们只允许包含不包含淫秽词语的留言。留言板管理员可以指定他们认为不可接受的词语列表。

检查文本片段中是否包含一组词语的最有效方法是使用正则表达式。如果我们将词语列表作为数组,我们可以像这样构建正则表达式

var badWords = ["ape", "monkey", "simian", "gorilla", "evolution"];
var pattern = new RegExp(badWords.join("|"), "i");
function isAcceptable(text) {
  return !pattern.test(text);
}

show(isAcceptable("Mmmm, grapes."));
show(isAcceptable("No more of that monkeybusiness, now."));

我们可以围绕这些词语添加 \b 模式,这样关于葡萄的事情就不会被归类为不可接受。不过,这也将使第二个成为可接受的,这可能不正确。色情过滤器很难做到(而且通常过于烦人,不值得一试)。

RegExp 构造函数的第一个参数是一个包含模式的字符串,第二个参数可用于添加不区分大小写或全局性。在构建用于保存模式的字符串时,您需要小心反斜杠。因为通常,反斜杠在解释字符串时会被删除,所以任何必须最终出现在正则表达式本身中的反斜杠都需要转义

var digits = new RegExp("\\d+");
show(digits.test("101"));

关于正则表达式,最重要的是它们的存在,并且可以极大地增强您字符串操作代码的功能。它们非常神秘,您可能需要在第一次使用它们的前十次查看有关它们的详细信息。坚持下去,您很快就会不经意地编写出看起来像神秘咒语的表达式

(漫画来自 Randall Munroe.)

  1. 在这种情况下,反斜杠实际上并不必要,因为字符出现在 [] 之间,但无论如何将它们转义更容易,这样您就不必考虑它。