最近用户跟我反馈了一个问题,他们使用的浏览器高亮关键字的插件在我们的网站不生效,我看了一下,因为使用了 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,这样应该会有更好的编码体验。