要应用替换映射而不更改新插入的文本,只搜索原始字符串,并单独构建结果。本指南使用 Mac 上 Safari 中的 JavaScript,通过字面、可预测的替换来更新一小段文字。

假设某项桌面编辑任务要求将 blue 改为 green,并将 green 改为 gold。原文中的 blue 应只替换一次,变为 green。原文中的 green 应变为 gold。下面的方法可处理这一区别、重叠短语、引文文本,以及包含标点的替换值。

为什么替换循环会重复更改文本?

依次将每条规则应用于上一条规则的输出会产生级联。使用这两条颜色规则时,第一次操作会插入 green;第二次操作随后会找到这个新插入的词,并将其改为 gold。重新排列规则或许能修正某个示例,但无法为每种映射定义可靠的行为。

JavaScript 的 replace() 会返回一个新字符串,而不改变输入。后续调用如果接收这个返回的字符串,就会发生级联。因此,任务需要一条明确的规则:每个匹配项都必须来自原始输入。参见 MDN 的 String.prototype.replace() 文档。

对于输入 The blue cup is beside the green cup.,预期结果是 The green cup is beside the gold cup. 原文中的两个颜色词都会改变,而插入的 green 保持不变。下面的实现正是执行这种转换。

替换键重叠时应如何处理?

避免级联后,还需要决定另一件事:多个键都匹配同一段源文本时,哪个键优先?对于这项编辑任务,请采用以下明确策略。这些是本示例的设计选择,并非 JavaScript 自动提供的规则。

  • 从左到右扫描原始输入。
  • 在每个位置选择匹配的最长键。
  • 消耗整个源文本匹配项后再继续。
  • 按字面插入映射值,不搜索该值。
  • 原样复制未匹配的文本。
  • 精确匹配大小写和空格。

例如,如果 blue 和 blue vase 都是键,那么在起始位置相同时,短语 blue vase 优先。较早开始的匹配优先于较晚开始的较长匹配。源文本片段一旦被消耗,重叠的匹配就不能再使用它。

这项策略适用于少量字面短语编辑:具体短语优先于较短的前缀。它还使输出不受不同长度键的输入顺序影响。如果任务需要采用不同的重叠策略,请在应用映射前先确定策略。

如何在 Mac 上运行转换?

Safari 提供 JavaScript 控制台,因此此示例无需安装其他应用。通过 Safari → 设置… → 高级 → 显示网页开发者功能启用 Web 检查器。然后打开 开发 → 显示网页检查器,或按 Option-Command-I。这些步骤参考了 WebKit 的 Web 检查器设置文档。

在 Safari 地址栏中输入 about:blank 打开空白页面,然后打开检查器并选择 控制台 标签。将下面的完整程序粘贴到控制台提示符中并运行。WebKit 的文档说明,提示符会执行 JavaScript 并在控制台中显示结果。参见 Web 检查器界面。

此示例是一个完整的浏览器程序,使用虚构文本且不需要导入任何内容。外层函数将声明限制在局部作用域中,因此编辑示例后,可以再次粘贴并运行整个代码块。

(() => {
  function applyReplacementMap(input, replacements) {
    if (typeof input !== "string" || !(replacements instanceof Map)) {
      throw new TypeError("Use a string and a Map.");
    }

    const entries = [...replacements];

    for (const [key, value] of entries) {
      if (
        typeof key !== "string" ||
        key.length === 0 ||
        typeof value !== "string"
      ) {
        throw new TypeError(
          "Keys must be nonempty strings; values must be strings."
        );
      }
    }

    entries.sort((a, b) => b[0].length - a[0].length);

    const output = [];
    let position = 0;

    while (position < input.length) {
      const entry = entries.find(([key]) =>
        input.startsWith(key, position)
      );

      if (entry) {
        output.push(entry[1]);
        position += entry[0].length;
      } else {
        output.push(input.slice(position, position + 1));
        position += 1;
      }
    }

    return output.join("");
  }

  const replacements = new Map([
    ["blue", "green"],
    ["green", "gold"],
    ["blue vase", "green bowl"],
    ["[tag]", "$&"],
    ["(draft)", ""]
  ]);

  const input =
    'The blue vase is beside the green cup. Label: "[tag]". Note: (draft)';

  const result = applyReplacementMap(input, replacements);
  console.log(result);
})();

记录的文本如下所示。Note: 后面保留了一个尾随空格,因为规则只删除 (draft),保留了前面的空格。

The green bowl is beside the gold cup. Label: "$&". Note: 

要调整此示例,请编辑 input 和 new Map(...) 中的键值对。每对都包含精确的源字符串及其替换值。每个键只使用一条条目:JavaScript 映射的键是唯一的,因此重复键会更新其值,而不是创建另一条相互竞争的规则。参见 MDN 的 Map 参考文档。

为什么插入的文本会保持不变

名为 position 的游标只在 input 中前进。匹配使用 input.startsWith(key, position),它会检查指定位置是否有字符串,并且区分大小写,详见 MDN 的 startsWith() 参考文档。输出数组从不作为搜索目标。

没有规则匹配时,slice(position, position + 1) 会复制下一个源文本单元。slice 的第二个索引不包含在切片范围内,而且切片不会修改原始字符串。这些有文档说明的语义使函数能够在单独构建结果的同时保留未匹配内容。参见 MDN 的 slice() 参考文档。

此示例将 blue vase 替换为 green bowl。虽然插入的短语中包含另一个键 green,但函数已经越过了原始短语。它不会检查 green bowl。输入中稍后出现的原文 green 仍会改为 gold。

字面值和删除的处理方式

此实现会直接附加替换值。因此,$& 会保持为 $&,方括号或圆括号也会作为普通字符出现在键中。空替换字符串会删除匹配的源文本片段。此函数不会进行正则表达式或替换模板解析。

空键会被拒绝,因为它不会消耗源文本;如果允许空键,匹配分支就无法向前推进。转换开始前也会拒绝非字符串值,这样输入错误的值会显式报错,而不会被静默转换或忽略。空映射会原样返回输入,空输入则返回空字符串。

复制结果前应如何检查?

使用能区分目标行为的小型示例。以下预期结果均根据上面的实现推导得出。除非另有说明,均使用示例中的映射。

检查项 输入 预期结果
插入的文本保持不变 blue green green gold
较长短语优先 blue vase green bowl
替换中的标点按字面保留 [tag] $&
匹配区分大小写 Blue Blue
匹配包括子串 blueprint greenprint
引号保持存在 "blue" "green"
空映射 blue blue

替换函数及这些行为已在 Node.js 20.19.2 中检查,同时检查了删除、空输入、重叠匹配和无效映射的拒绝情况。这验证了该 JavaScript 转换在该运行时中的行为;Safari 界面步骤则依据 WebKit 文档。

请特别留意子串检查。此函数不会推断单词边界,也不会修复语法。键可能匹配较长单词中的一部分,删除文本也可能留下多余空格。对于短语编辑任务,请检查周围字符,并根据所需更改的范围将映射设得足够具体。

还要区分单次应用和重复应用。在原始文本上运行一次函数,可以避免本次运行中的级联。再次将结果输入函数会开始新一轮转换,此时先前插入的词就可能匹配。重新运行编辑时,请始终以原始的 input 为源文本。

单次 JavaScript replace 调用能实现相同效果吗?

可以。对于一小组固定的简单键,一个全局正则表达式和替换回调可以实现相同的非级联行为。ECMAScript 规范会从源字符串中收集匹配项,然后构造替换结果。参见规范中定义的正则表达式替换算法。

下面这个独立的完整示例处理两个固定颜色键:

(() => {
  const replacements = new Map([
    ["blue", "green"],
    ["green", "gold"]
  ]);

  const input = "The blue cup is beside the green cup.";
  const result = input.replace(
    /blue|green/g,
    (word) => replacements.get(word)
  );

  console.log(result);
  // The green cup is beside the gold cup.
})();

g 标志会使表达式替换所有匹配项。通过回调返回值也可以避免 $& 这样的替换字符串模式;否则它表示匹配到的子字符串。MDN 在 replace() 参考文档中明确说明了这两种行为。

对于包含标点且内容会变化的映射,构造正则表达式会引入额外要求。键必须转义为字面模式,重叠的备选项也需要明确优先级。JavaScript 提供了 RegExp.escape() 来构造字面模式,但 MDN 指出较旧的浏览器可能不支持它。参见 MDN 的 RegExp.escape() 文档。

对于这里的桌面短语编辑任务,字面扫描器会清楚地呈现这些决策:它读取原始文本,在每个位置选择最长的键,并原样附加每个替换值。针对原始段落运行它,检查结果文本和空格,然后将完成后的文本复制到文档中。

来源