Выделите слово текста на странице, используя .replace()

Я разрабатываю расширение 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";, это сработает.

Кажется, я не могу найти, что не так со следующим кодом.


person rafaelcpalmeida    schedule 20.11.2016    source источник
comment
Вы записали, какое значение имеет replacedText?   -  person Scott Marcus    schedule 21.11.2016
comment
@ScottMarcus, когда я регистрирую replacedText, он показывает правильное значение, например, <span style=\"background-color: yellow\">teste</span>. Однако, если я использую это на element.innerHTML, моя вкладка выходит из строя.   -  person rafaelcpalmeida    schedule 21.11.2016
comment
Вы уверены, что в консоли отображается управляющая последовательность \"? Вы пытались изменить строку на: "<span style='background-color: yellow'>$1</span>"?   -  person Scott Marcus    schedule 21.11.2016
comment
@ScottMarcus Вот пример того, что регистрируется: <span style="background-color: yellow">Teste</span> de velocidade <strong>MEO</strong>. Я изменил с " на ', и вкладка все равно вылетает.   -  person rafaelcpalmeida    schedule 21.11.2016
comment
Кажется, что когда вы заменяете содержимое своей строкой, эта строка включает текст, который необходимо заменить. И, поскольку этот текст является дочерним элементом текущего узла, он еще не обработан вашим циклом. Итак, ваш цикл затем находит новый элемент, который необходимо обработать, по существу создавая бесконечный цикл.   -  person Scott Marcus    schedule 21.11.2016


Ответы (2)


Сначала вы проверяете узлы #text, чтобы увидеть, содержит ли текст слово, которое вы пытаетесь выделить, а затем выполняете замену на .innerHTML родительского элемента. Есть несколько проблем с этим.

  • Бесконечные замены: когда вы изменяете .innerHTML родительского элемента, вы изменяете массив childNodes. Вы делаете это таким образом, что добавляете узел дальше в массив, содержащий текст, который нужно заменить. Таким образом, когда вы продолжаете сканирование массива childNodes, вы всегда находите (новый) узел, содержащий текст, который вы хотите заменить. Итак, вы снова заменяете его, создавая другой узел с более высоким индексом в массиве childNodes. Повторять бесконечно.
  • Использование регулярного выражения для замены текста в свойстве .innerHTML. Хотя вы уже проверили, что текст, который вы хотите заменить, действительно содержится в текстовом узле, это не мешает вашему регулярному выражению также заменять любые совпадающие слова в фактическом HTML-элементе (например, в src="yourWord", href="http://foo.com/yourWord/bar.html" или при попытке выделить такие слова, как style, color, background, span, id, height, width, button, form, input и т. д.).
  • Вы не проверяете, не изменяете ли текст в тегах <script> или <style>.
  • You are checking that you only make changed in text nodes (i.e. you check for node.nodeType === 3). If you weren't checking for this you would also have the following possible problems due to using .innerHTML to change HTML:
    • You could end up changing attributes, or actual HTML tags, depending on what you are changing with .replace(). This could completely disrupt the page layout and functionality.
    • Когда вы меняете .innerHTML, DOM для этой части страницы полностью воссоздается. Это означает, что элементы, в то время как новые элементы могут быть того же типа с теми же атрибутами, любые прослушиватели событий, которые были прикреплены к старым элементам, не будут присоединены к новым элементам. Это может существенно нарушить функциональность страницы.
    • Многократное изменение больших частей DOM может потребовать больших вычислительных ресурсов для повторного рендеринга страницы. В зависимости от того, как вы это сделаете, вы можете столкнуться со значительными проблемами производительности, воспринимаемыми пользователями.

Таким образом, если вы собираетесь использовать RegExp для замены текста, вам нужно выполнить операцию только с содержимым узла #text, а не с .innerHTML родительского узла. Поскольку вы хотите создать дополнительные элементы HTML (например, новые элементы <span style=""> с дочерними узлами #text), возникают некоторые сложности.

Невозможно назначить текст HTML текстовому узлу для создания новых узлов HTML:

Невозможно назначить новый HTML непосредственно текстовому узлу и оценить его как HTML, создав новые узлы. Присвоение свойству .innerHTML текстового узла создаст такое свойство в Объекте (так же, как и в любом Объекте), но не изменит текст, отображаемый на экране (т. е. фактическое значение узла #text). Таким образом, он не выполнит то, что вы хотите сделать: он не создаст никаких новых дочерних элементов HTML родительского узла.

Способ сделать это, который имеет наименьшее влияние на DOM страницы (т.е. с наименьшей вероятностью нарушить существующий JavaScript на странице), состоит в том, чтобы создать <span> для включения новых текстовых узлов, которые вы создаете (текст, который был в узле #text, который не находится в вашем цветном <span>) вместе с потенциально несколькими элементами <span>, которые вы создаете. Это приведет к замене одного узла #text одним элементом <span>. Хотя это создаст дополнительных потомков, количество дочерних элементов в родительском элементе останется неизменным. Таким образом, любой JavaScript, который полагался на это, не будет затронут. Учитывая, что мы меняем модель DOM, невозможно не сломать другой JavaScript, но это должно свести к минимуму эту возможность.

Некоторые примеры того, как вы можете это сделать: см. этот ответ (заменяет список слов этими словами в кнопках) и этот ответ (помещает весь текст в элементы <p>, разделенные пробелами на кнопки) для полных расширений, выполняющих замену регулярных выражений новым HTML . См. этот ответ, который в основном делает то же самое, но создает ссылку (у него другая реализация, которая пересекает DOM с помощью TreeWalker для поиска #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.

Код в этом ответе был изменен по сравнению с кодом в другом моем ответе

person Makyen♦    schedule 21.11.2016
comment
На самом деле я не получил такой ошибки, потому что я модифицировал элемент, содержащий этот текстовый узел. Это работало нормально, если я заменял контент, который хотел, на что-то еще, кроме слова, которое я искал. - person rafaelcpalmeida; 23.11.2016
comment
@rafaelcpalmeida, да, моя ошибка в описании одной части проблемы (сделанные предположения, как обычно, плохо). Я обновил ответ с исправленным описанием проблем (не меняет решение). - person Makyen♦; 23.11.2016

Ошибка, с которой я столкнулся, была связана с рекурсивным циклом, потому что, например, я искал ключевое слово teste и вставлял новый элемент с содержимым <span style=\"background-color: #ffff00\">teste</span>, что заставляло сценарий снова пытаться заменить новое ключевое слово 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; }
    }
}

Где я изменяю свойство узла и «сообщаю», что я уже изменил этот узел, чтобы снова не попасть в рекурсивный бесконечный цикл.

P.S. Как видите, это решение использует jQuery. Я попытаюсь переписать это, чтобы использовать только Vanilla JS.

person rafaelcpalmeida    schedule 23.11.2016
comment
Ваше решение по-прежнему использует RegExp для изменения .innerHTML родительского элемента. В результате это по-прежнему будет нарушать работу любого HTML, содержащего слово, которое вы заменяете, если текстовый узел также содержит это слово. Другими словами, хотя замена не выполняется, если замена не произойдет в реальном тексте, она не препятствует замене также изменять HTML (например, в src="yourWord" или href="http://foo.com/yourWord/bar.html"). - person Makyen♦; 23.11.2016
comment
Просто комментарий, не предназначенный для критики: вы используете две строки комментариев, чтобы объяснить использование литерала шаблона. Хотя это и приятно объяснить, нет особого смысла использовать его в этой ситуации, когда вы могли бы заменить его на '(' + replacement + ')' . Использование прямой конкатенации строк не заставило бы вас чувствовать, что вам нужны две строки комментариев для объяснения, и не ограничило бы ваш код Chrome ›= ver. 41. - person Makyen♦; 23.11.2016
comment
К вашему сведению: в настоящее время вы перебираете список words, каждый из которых вы заменяете. Вы используете два разных RegExp, когда можете использовать только один (тест на существование не заботится о наличии слова в группе захвата). Гораздо эффективнее было бы предварительно создать одно регулярное выражение, включающее все слова в массиве words. Это приведет к выполнению только одного .replace() для всех слов. Это сэкономит довольно много времени в вашем внутреннем цикле. В этом ответе есть пример этого. - person Makyen♦; 23.11.2016
comment
@Makyen спасибо за ваши комментарии и обновления. Я учту то, что вы сказали, и постараюсь переписать свой код, чтобы он был более эффективным. - person rafaelcpalmeida; 24.11.2016