【问题标题】:Highlight a word of text on the page using .replace()使用 .replace() 突出显示页面上的文字
【发布时间】:2017-04-04 06:58:41
【问题描述】:

我正在开发一个 Google Chrome 扩展程序,它允许您自动将突出显示的 CSS 规则应用于您选择的单词。

我有以下代码

var elements = document.getElementsByTagName('*');

for (var i=0; i<elements.length; i++) {
    var element = elements[i];

    for (var j=0; j<element.childNodes.length; j++) {
        var node = element.childNodes[j];

        if(node.nodeType === 3) {
            var text = node.nodeValue;

            var fetchedText = text.match(/teste/gi);

            if(fetchedText) {
                var replacedText = element.innerHTML.replace(/(teste)/gi, "<span style=\"background-color: yellow\">$1</span>");

                if (replacedText !== text) {
                    element.innerHTML = replacedText;
                }
            }
        }
    }
}

这会破坏并冻结我的 Chrome 标签页。但是,如果我从element.innerHTML = replacedText; 切换到element.innerHTML = "text";,这将有效。

我似乎找不到以下代码有什么问题。

【问题讨论】:

  • 您记录了replacedText 的价值吗?
  • @ScottMarcus 当我登录replacedText 时,它显示了正确的值,例如&lt;span style=\"background-color: yellow\"&gt;teste&lt;/span&gt;。但是,如果我在 element.innerHTML 上使用它,它会使我的标签崩溃。
  • 您确定在控制台中显示了\" 转义序列吗?您是否尝试将字符串修改为:"&lt;span style='background-color: yellow'&gt;$1&lt;/span&gt;"
  • @ScottMarcus 以下是记录内容的示例:&lt;span style="background-color: yellow"&gt;Teste&lt;/span&gt; de velocidade &lt;strong&gt;MEO&lt;/strong&gt;。我已经从 " 更改为 ' 并且它仍然使标签崩溃。
  • 似乎当您用字符串替换内容时,该字符串包含需要替换的文本。而且,由于该文本是当前节点的子节点,因此您的循环尚未处理它。因此,您的循环随后会找到需要处理的新元素,这实际上是在创建一个无限循环。

标签: javascript google-chrome-extension textnode


【解决方案1】:

您首先测试#text 节点以查看文本是否包含您要突出显示的单词,然后在父元素的.innerHTML 上执行替换。这有几个问题。

  • 无限替换:当您修改父元素的.innerHTML 时,您会更改childNodes 数组。您这样做的方式是在包含要替换的文本的数组中进一步添加一个节点。因此,当您继续扫描childNodes 数组时,您总是会找到一个(新)节点,其中包含您要替换的文本。因此,您再次替换它,在childNodes 数组中创建另一个具有更高索引的节点。无限重复。
  • 使用 RegExp 替换 .innerHTML 属性中的文本。虽然您已经进行了测试以确保您想要替换的文本实际上包含在文本节点中,但这并不能阻止您的 RegExp 替换元素的实际 HTML 中的任何匹配词(例如在src="yourWord"href="http://foo.com/yourWord/bar.html" 中,或者如果试图突出显示诸如stylecolorbackgroundspanidheightwidthbuttonbutton等单词@、input 等)。
  • 您没有检查以确保您没有更改 &lt;script&gt;&lt;style&gt; 标记中的文本。
  • 您正在检查您是否只在文本节点中进行了更改(即检查node.nodeType === 3)。如果您没有对此进行检查,由于使用 .innerHTML 更改 HTML,您还可能遇到以下问题:
    • 您最终可能会更改属性或实际的 HTML 标记,具体取决于您使用 .replace() 更改的内容。这可能会完全破坏页面布局和功能。
    • 当您更改.innerHTML 时,页面该部分的DOM 将完全重新创建。这意味着元素,虽然新元素可能是具有相同属性的相同类型,但附加到旧元素的任何事件侦听器都不会附加到新元素。这会严重破坏页面的功能。
    • 重复更改大部分 DOM 可能需要大量计算以重新呈现页面。根据您执行此操作的方式,您可能会遇到严重的用户感知性能问题。

因此,如果要使用正则表达式替换文本,只需要对#text节点的内容进行操作,而不需要对父节点的.innerHTML进行操作。因为您想要创建额外的 HTML 元素(例如,新的 &lt;span style=""&gt; 元素,带有子 #text 节点),所以存在一些复杂性。

无法将 HTML 文本分配给文本节点以创建新的 HTML 节点:

无法将新 HTML 直接分配给文本节点并将其评估为 HTML,从而创建新节点。分配给文本节点的.innerHTML 属性将在对象上创建这样的属性(就像在任何对象上一样),但不会更改屏幕上显示的文本(即#text 节点的实际值)。因此,它不会完成您想要做的事情:它不会创建父节点的任何新 HTML 子节点。

对页面的 DOM 影响最小(即最不可能破坏页面上的现有 JavaScript)的方法是创建一个 &lt;span&gt; 以包含您正在创建的新文本节点(之前的文本在不在您的彩色&lt;span&gt; 中的#text 节点中,以及您正在创建的可能的多个&lt;span&gt; 元素。这将导致用单个 &lt;span&gt; 元素替换单个 #text 节点。虽然这会创建额外的后代,但它会使父元素中的子元素数量保持不变。因此,任何依赖它的 JavaScript 都不会受到影响。鉴于我们正在更改 DOM,没有办法不破坏其他 JavaScript,但这应该可以最大限度地减少这种可能性。

如何执行此操作的一些示例:请参阅this answer(将单词列表替换为按钮中的这些单词)和this answer(将所有文本放在由空格分隔的 &lt;p&gt; 元素中的按钮中)执行正则表达式的完整扩展替换为新的 HTML。参见this answer,它基本上做同样的事情,但建立了一个链接(它有一个不同的实现,它使用TreeWalker 遍历DOM 以找到#text 节点而不是其他两个示例中使用的NodeIterator) .

这里的代码将在 document.body 中的每个文本节点上执行您想要的替换,并创建新的 HTML 以使 style 在部分文本中有所不同:

function handleTextNode(textNode) {
    if(textNode.nodeName !== '#text'
        || textNode.parentNode.nodeName === 'SCRIPT' 
        || textNode.parentNode.nodeName === 'STYLE'
    ) {
        //Don't do anything except on text nodes, which are not children 
        //  of <script> or <style>.
        return;
    }
    let origText = textNode.textContent;
    let newHtml=origText.replace(/(teste)/gi
                                 ,'<span style="background-color: yellow">$1</span>');
    //Only change the DOM if we actually made a replacement in the text.
    //Compare the strings, as it should be faster than a second RegExp operation and
    //  lets us use the RegExp in only one place for maintainability.
    if( newHtml !== origText) {
        let newSpan = document.createElement('span');
        newSpan.innerHTML = newHtml;
        textNode.parentNode.replaceChild(newSpan,textNode);
    }
}

let textNodes = [];
//Create a NodeIterator to get the text nodes in the body of the document
let nodeIter = document.createNodeIterator(document.body,NodeFilter.SHOW_TEXT);
let currentNode;
//Add the text nodes found to the list of text nodes to process.
while(currentNode = nodeIter.nextNode()) {
    textNodes.push(currentNode);
}
//Process each text node
textNodes.forEach(function(el){
    handleTextNode(el);
});

还有其他方法可以做到这一点。但是,它们会对特定元素的子元素结构产生更显着的变化(例如,父元素上的多个附加节点)。这样做更有可能破坏页面上已经存在的依赖于页面当前结构的任何 JavaScript。实际上,像这样的任何更改都有可能破坏当前的 JavaScript。

这个答案中的代码是根据this other answer of mine中的代码修改的

【讨论】:

  • 实际上我没有收到这种类型的错误,因为我正在修改包含该文本节点的元素。如果我将我想要的内容替换为我正在寻找的单词的任何其他内容,效果很好
  • @rafaelcpalmeida,是的,我在描述问题的一部分时出错(做出假设,像往常一样是一件坏事)。我已经用更正的问题描述更新了答案(不会改变解决方案)。
【解决方案2】:

我遇到的错误是由于递归循环造成的,例如,我正在寻找关键字 teste 并且我正在插入一个内容为 &lt;span style=\"background-color: #ffff00\"&gt;teste&lt;/span&gt; 的新元素,这将强制脚本尝试替换新的关键字teste 再次等等。

我想出了这个功能:

function applyReplacementRule(node) {
    // Ignore any node whose tag is banned
    if (!node || $.inArray(node.tagName, hwBannedTags) !== -1) { return; }

    try {
        $(node).contents().each(function (i, v) {
            // Ignore any child node that has been replaced already or doesn't contain text
            if (v.isReplaced || v.nodeType !== Node.TEXT_NODE) { return; }

            // Apply each replacement in order
            hwReplacements.then(function (replacements) {
                replacements.words.forEach(function (replacement) {
                    //if( !replacement.active ) return;
                    var matchedText = v.textContent.match(new RegExp(replacement, "i"));

                    if (matchedText) {
                        // Use `` instead of '' or "" if you want to use ${variable} inside a string
                        // For more information visit https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals
                        var replacedText = node.innerHTML.replace(new RegExp(`(${replacement})`, "i"), "<span style=\"background-color: #ffff00\">$1</span>");

                        node.innerHTML = replacedText;
                    }
                });
            }).catch(function (reason) {
                console.log("Handle rejected promise (" + reason + ") here.");
            });

            v.isReplaced = true;
        });
    } catch (err) {
        // Basically this means that an iframe had a cross-domain source
        if (err.name !== "SecurityError")
        { throw err; }
    }
}

我在哪里修改节点属性并“告诉”我已经修改了该节点,所以我不会再次陷入递归无限循环。

附:如您所见,此解决方案使用 jQuery。我将尝试重写它以仅使用 Vanilla JS。

【讨论】:

  • 您的解决方案仍然使用正则表达式来更改父元素的.innerHTML。因此,当文本节点也包含该单词时,这仍然会破坏包含您要替换的单词的任何 HTML。换句话说,除非替换将在实际文本中发生,否则它不会进行替换,但它不会阻止替换也更改 HTML(例如在 src="yourWord"href="http://foo.com/yourWord/bar.html" 中)。
  • 只是一个评论,不打算成为批评:您使用两行 cmets 来解释您对模板文字的使用。虽然很好解释它,但在这种情况下使用它并没有多大意义,因为您可以用'(' + replacement + ')' 替换它。使用直接字符串连接不会让您觉得需要两行 cmets 来解释,也不会将您的代码限制为 Chrome >= ver。 41.
  • 仅供参考:您当前正在迭代 words 列表,您正在替换每个列表。当您只能使用一种时,您使用两种不同的 RegExp(存在性测试不关心在捕获组中是否包含该词)。预先创建一个包含words 数组中所有单词的单个正则表达式会更有效。这样做只会对所有单词执行一个.replace()。这将在您的内部循环中节省相当多的时间。 This answer 有一个这样做的例子。
  • @Makyen 感谢您的 cmets 和更新。我会记住你所说的,并尝试重写我的代码以提高效率
最近更新 更多