背景
最近用户跟我反馈了一个问题,他们使用的浏览器高亮关键字的插件在我们的网站不生效,我看了一下,因为使用了 webComponent 但插件没有进行兼容。他们说这个功能非常重要,因此,我们就要在系统内自己实现高亮页面关键字。
在做一个新功能的时候,首先要先调研一下方案。
但在网上找了一圈,发现基本上都是以下这种方案:
// js部分 var bodyContent = document.body.innerHTMl; // 获取页面内容 var pattern = new RegExp('关键字', 'g'); // 生成正则表达式 // 匹配关键词并替换 document.body.innerHTMl = bodyContent.replace(pattern, '<span class="highlight">$&</span>');
在 innerHTML 匹配关键字,将关键字替换成高亮的 span,然后重新替换 innerHTML这样实现虽然非常简单,但会导致 shadowRoot 和 iframe 失效。
于是我决定问问 GPT,看看它能不能给我一点惊喜
使用 GPT 编写代码
我:我要写一段 js 代码,传入指定的文本,在整个页面高亮
GPT: 你可以使用 JavaScript 中的 DOM 操作来实现这个功能。以下是一段示例代码:
function highlightText(text) { const body = document.body; const regex = new RegExp(text, 'gi'); let match; while ((match = regex.exec(body.innerText))) { const span = document.createElement('span'); span.style.backgroundColor = 'yellow'; span.textContent = match[0]; const range = document.createRange(); range.setStart(body, match.index); range.setEnd(body, regex.lastIndex); range.deleteContents(); range.insertNode(span); } }
这段代码会将页面中所有匹配指定文本的文本节点用黄色高亮显示。你可以将需要高亮的文本作为参数传入 highlightText
函数中,例如:
highlightText('JavaScript');
点评:代码是写出来了,用了一个我还没见过的 API。。。赶紧学习了一下 原理是:用 Range API 去选择对应的文本,然后使用 Range API 删除和新增高亮节点,思路看起来是没问题的,而且跟网上找到的不同
撤销高亮
我: 还要能够撤销高亮
GPT: 好的,为了实现撤销高亮的功能,我们可以在高亮文本时将每个被高亮的文本节点保存到一个数组中,然后在需要撤销高亮时,遍历这个数组,将每个高亮的文本节点恢复到原来的状态。
以下是修改后的代码:
let highlightedNodes = []; function highlightText(text) { const body = document.body; const regex = new RegExp(text, 'gi'); let match; while ((match = regex.exec(body.innerText))) { const span = document.createElement('span'); span.style.backgroundColor = 'yellow'; span.textContent = match[0]; const range = document.createRange(); range.setStart(body, match.index); range.setEnd(body, regex.lastIndex); range.deleteContents(); range.insertNode(span); regex.lastIndex -= match[0].length - span.textContent.length; highlightedNodes.push(span); } } function clearHighlight() { while (highlightedNodes.length > 0) { const node = highlightedNodes.pop(); const parent = node.parentNode; parent.replaceChild(node.firstChild, node); parent.normalize(); } }
点评:牛逼,GPT 还是很聪明的,居然还会把 span 存起来,用于撤销高亮,这个思路也是非常的正确的。
Range 范围算不准
但是实际运行后,这段代码是会报错的,我排查了一下,发现计算出来的 Range 的 start
和 end
的位置是错误的,于是我跟 GPT 说一下,看看它如何纠正
我:算不准 start 和 end 的位置
GPT: 如果 setStart
和 setEnd
方法的位置计算不准确,可能是因为在计算位置时没有考虑到文本节点之间的空格和换行符。在这种情况下,你可以使用 createTreeWalker
方法来遍历文档树中的所有文本节点,并计算出每个文本节点的起始和结束位置。
以下是一段示例代码:
function highlightText(text) { const body = document.body; const regex = new RegExp(text, 'gi'); let match; const walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT, null, false); while (walker.nextNode()) { const node = walker.currentNode; while ((match = regex.exec(node.nodeValue))) { const span = document.createElement('span'); span.style.backgroundColor = 'yellow'; span.textContent = match[0]; const range = document.createRange(); range.setStart(node, match.index); range.setEnd(node, regex.lastIndex); range.deleteContents(); range.insertNode(span); } } }
在这个修改后的代码中,我们使用 createTreeWalker
方法来遍历文档树中的所有文本节点。对于每个文本节点,我们使用 exec
方法来查找匹配指定文本的子串,并将每个匹配的子串用黄色高亮显示。在计算位置时,我们使用 setStart
和 setEnd
方法来设置范围的起始和结束位置,以确保高亮显示的文本范围正确。
点评:有点东西,我其实是不知道为什么算不准 start
和 end
的位置,但 GPT 自己也能发现问题,并且给出了另一个解决方案,并且非常贴心解析了这段代码
根据 GPT 的提示,是空字符的文本节点导致的问题,跟 GPT 提出的假设类似。
页面卡死
但我把上面代码运行,发现页面卡死了,原因是,在 TreeWalker 遍历过程中,新增了高亮节点,然后又进入高亮节点进行遍历,然后又创建高亮节点。。。
因此应该在 TreeWalker 遍历节点之后再进行高亮节点替换,于是我让 GPT 继续改
我:在 TreeWalker 遍历过程中修改节点,会遍历到新节点,需要延迟修改节点
GPT: 是的,你说的很对。在 TreeWalker
遍历过程中修改节点,会导致遍历到新节点,从而可能会出现意外的结果。为了避免这种情况,我们可以将需要修改的节点保存到一个数组中,在遍历完成后再对这些节点进行修改。
以下是一段示例代码:
function highlightText(text) { const body = document.body; const regex = new RegExp(text, 'gi'); let match; const nodesToHighlight = []; const walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT, null, false); while (walker.nextNode()) { const node = walker.currentNode; while ((match = regex.exec(node.nodeValue))) { const span = document.createElement('span'); span.style.backgroundColor = 'yellow'; span.textContent = match[0]; const range = document.createRange(); range.setStart(node, match.index); range.setEnd(node, regex.lastIndex); range.deleteContents(); range.insertNode(span); nodesToHighlight.push(span); } } nodesToHighlight.forEach(node => { const parent = node.parentNode; parent.replaceChild(node.firstChild, node); parent.normalize(); }); }
在这个修改后的代码中,我们新增了一个名为 nodesToHighlight
的数组,用于保存需要高亮的节点。在遍历过程中,每当找到一个需要高亮的节点时,我们将这个节点添加到 nodesToHighlight
数组中。在遍历完成后,我们遍历 nodesToHighlight
数组,对每个节点进行高亮处理。
这样做可以确保在遍历过程中不会修改节点,从而避免出现意外的结果。
点评:改了,但没完全改好,TreeWalker 中仍然有修改 DOM 的操作,于是我换了个说法
我:不能在遍历过程中调用 deleteContents
和 insertNode
GPT: 是的,你说的很对。在遍历过程中调用 deleteContents
和 insertNode
方法,会导致遍历到新节点,从而可能会出现意外的结果。为了避免这种情况,我们可以将需要修改的节点保存到一个数组中,在遍历完成后再对这些节点进行修改。
以下是一段示例代码:
function highlightText(text) { const body = document.body; const regex = new RegExp(text, 'gi'); let match; const nodesToHighlight = []; const walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT, { acceptNode: function(node) { if (node.parentNode.nodeName.match(/^(script|style)$/i)) { return NodeFilter.FILTER_REJECT; } return NodeFilter.FILTER_ACCEPT; } }, false); while (walker.nextNode()) { const node = walker.currentNode; while ((match = regex.exec(node.nodeValue))) { const span = document.createElement('span'); span.style.backgroundColor = 'yellow'; span.textContent = match[0]; const range = document.createRange(); range.setStart(node, match.index); range.setEnd(node, regex.lastIndex); nodesToHighlight.push({ range, span }); regex.lastIndex -= match[0].length - span.textContent.length; } } setTimeout(() => { nodesToHighlight.forEach(({ range, span }) => { range.deleteContents(); range.insertNode(span); }); }, 0); }
在这个修改后的代码中,我们新增了一个名为 nodesToHighlight
的数组,用于保存需要高亮的节点和它们对应的范围。在遍历过程中,每当找到一个需要高亮的节点时,我们将这个节点和它对应的范围添加到 nodesToHighlight
数组中。在遍历完成后,我们使用 setTimeout
方法将对节点的修改延迟到下一个事件循环中执行。
这样做可以确保在遍历过程中不会修改节点,从而避免出现意外的结果。同时,使用 setTimeout
方法可以将对节点的修改延迟到下一个事件循环中执行,从而确保在修改节点时不会影响遍历过程。
点评:这段代码终于能运行了,非常的优秀!
支持 shadowRoot
我:但这样还不能遍历 webComponent shadowRoot 里面的元素
GPT:
function highlightText(text) { const body = document.body; const regex = new RegExp(text, 'gi'); let match; const nodesToHighlight = []; const walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT, { acceptNode: function(node) { if (node.parentNode.nodeName.match(/^(script|style)$/i)) { return NodeFilter.FILTER_REJECT; } return NodeFilter.FILTER_ACCEPT; } }, false); while (walker.nextNode()) { const node = walker.currentNode; while ((match = regex.exec(node.nodeValue))) { const span = document.createElement('span'); span.style.backgroundColor = 'yellow'; span.textContent = match[0]; const range = document.createRange(); range.setStart(node, match.index); range.setEnd(node, regex.lastIndex); nodesToHighlight.push({ range, span }); regex.lastIndex -= match[0].length - span.textContent.length; } } const shadowHosts = document.querySelectorAll('*'); shadowHosts.forEach(shadowHost => { const shadowRoot = shadowHost.shadowRoot; if (shadowRoot) { const walker = document.createTreeWalker(shadowRoot, NodeFilter.SHOW_TEXT, { acceptNode: function(node) { if (node.parentNode.nodeName.match(/^(script|style)$/i)) { return NodeFilter.FILTER_REJECT; } return NodeFilter.FILTER_ACCEPT; } }, true); while (walker.nextNode()) { const node = walker.currentNode; while ((match = regex.exec(node.nodeValue))) { const span = document.createElement('span'); span.style.backgroundColor = 'yellow'; span.textContent = match[0]; const range = document.createRange(); range.setStart(node, match.index); range.setEnd(node, regex.lastIndex); nodesToHighlight.push({ range, span }); } } } }); setTimeout(() => { nodesToHighlight.forEach(({ range, span }) => { range.deleteContents(); range.insertNode(span); }); }, 0); }
在这个修改后的代码中,我们使用 querySelectorAll
方法来获取所有元素,然后对于每个元素,我们检查它是否有 shadowRoot
。如果有,我们使用 createTreeWalker
方法来遍历它的 shadowRoot
中的所有文本节点。在遍历过程中,我们使用与之前相同的方式来查找需要高亮的节点,并将它们添加到 nodesToHighlight
数组中。
这样做可以确保遍历到 shadowRoot
中的所有文本节点,并将需要高亮的节点保存到 nodesToHighlight
数组中。然后,我们使用 setTimeout
方法将对节点的修改延迟到下一个事件循环中执行,从而确保在修改节点时不会影响遍历过程。
点评:这段代码也是能运行的,很牛逼,不过没考虑代码的复用性,shadowRoot 的处理,可以复用普通的 DOM 的高亮逻辑
撤销操作支持 shadowRoot
我:撤销操作也要作用到 shadowRoot
GPT: 是的,你说的很对。如果需要对 shadowRoot
中的高亮文本进行撤销操作,我们可以使用 Range
对象的 deleteContents()
方法来删除高亮文本所在的节点,并将原始文本重新插入到文档中。
以下是一段示例代码:
function removeHighlight() { const highlightedSpans = document.querySelectorAll('span[style="background-color: yellow;"]'); highlightedSpans.forEach(span => { const range = document.createRange(); range.selectNode(span); range.deleteContents(); range.insertNode(document.createTextNode(span.textContent)); }); const shadowHosts = document.querySelectorAll('*'); shadowHosts.forEach(shadowHost => { if (shadowHost.shadowRoot) { const highlightedSpans = shadowHost.shadowRoot.querySelectorAll('span[style="background-color: yellow;"]'); highlightedSpans.forEach(span => { const range = document.createRange(); range.selectNode(span); range.deleteContents(); range.insertNode(document.createTextNode(span.textContent)); }); } }); }
点评:代码可以运行,但有以下缺点:
- 同样是复用性不足,
- 高亮节点的选择器
span[style="background-color: yellow;"]
不太优雅,不够健壮 但问题不大,我自己能改hhh
highligh 函数需要支持重复调用
目前的代码会有一个问题,就是重复调用 highlightText
,已高亮的节点,仍然会创建 span,这就会导致 span 重复嵌套。需要先判断是否已经高亮了。
我:highlightText
如果同一个值多次调用,会给文本节点再嵌套一层高亮节点,要支持重复调用不会重复嵌套节点
GPT: 好的,如果需要支持重复调用 highlightText
函数时不会重复嵌套高亮节点,我们可以在每次调用函数之前先检查文本节点是否已经被高亮过,如果已经被高亮过,则不再重复嵌套高亮节点。
以下是修改后的 highlightText
函数代码:
function highlightText(text) { const body = document.body; const regex = new RegExp(text, 'gi'); let match; const nodesToHighlight = []; const walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT, { acceptNode: function(node) { if (node.parentNode.nodeName.match(/^(script|style)$/i)) { return NodeFilter.FILTER_REJECT; } return NodeFilter.FILTER_ACCEPT; } }, false); while (walker.nextNode()) { const node = walker.currentNode; if (node.parentNode.classList.contains('highlighted')) { continue; } while ((match = regex.exec(node.nodeValue))) { const span = document.createElement('span'); span.style.backgroundColor = 'yellow'; span.textContent = match[0]; const range = document.createRange(); range.setStart(node, match.index); range.setEnd(node, regex.lastIndex); nodesToHighlight.push({ range, span }); regex.lastIndex -= match[0].length - span.textContent.length; } } const shadowHosts = document.querySelectorAll('*'); shadowHosts.forEach(shadowHost => { if (shadowHost.shadowRoot) { const shadowRoot = shadowHost.shadowRoot; const walker = document.createTreeWalker(shadowRoot, NodeFilter.SHOW_TEXT, { acceptNode: function(node) { if (node.parentNode.nodeName.match(/^(script|style)$/i)) { return NodeFilter.FILTER_REJECT; } return NodeFilter.FILTER_ACCEPT; } }, true); while (walker.nextNode()) { const node = walker.currentNode; if (node.parentNode.classList.contains('highlighted')) { continue; } while ((match = regex.exec(node.nodeValue))) { const span = document.createElement('span'); span.style.backgroundColor = 'yellow'; span.textContent = match[0]; const range = document.createRange(); range.setStart(node, match.index); range.setEnd(node, regex.lastIndex); nodesToHighlight.push({ range, span }); regex.lastIndex -= match[0].length - span.textContent.length; } } } }); nodesToHighlight.forEach(nodeToHighlight => { const range = nodeToHighlight.range; const span = nodeToHighlight.span; range.deleteContents(); span.classList.add('highlighted'); range.insertNode(span); }); }
在这个代码中,我们添加了一个名为 highlighted
的 CSS 类,用于标记已经被高亮过的文本节点。在每次遍历文本节点时,我们检查当前节点的父节点是否已经被标记为 highlighted
,如果是,则跳过当前节点,不再重复嵌套高亮节点。在插入高亮节点时,我们还将其添加到 highlighted
类中,以便下次遍历时可以正确地跳过已经被高亮过的节点。
点评:完美符合要求~我只需要稍微复用以下代码,进行一些小修改即可。
最终还是成为了一名代码美化工程师~~~
使用体验
GPT 确实非常强大,它知道的非常多,有我不知道的知识,如果不是 GPT 我的确不会知道可以使用 Range API 去操作 DOM 实现高亮。
它还能较好的根据提示,纠正自己的错误
我们可以让 GPT 提供思路,写一个简单的 Demo,然后自己进行代码优化。
目前这种使用方式的效率还有待提升。整个过程耗费了 1 个多小时,写了不到 100 行代码。在对话的过程中,还需要将代码复制到 IDE 中,这并没有将 GPT 和 IDE 串联起来。
不过,我们可以尝使用 Cursor
这个 IDE,它可以直接在 IDE 中使用 GPT,这样应该会有更好的编码体验。
如果这篇文章对您有所帮助,可以点赞加收藏👍,您的鼓励是我创作路上的最大的动力。也可以关注我的公众号订阅后续的文章:Candy 的修仙秘籍(点击可跳转)