不修改DOM的高亮黑科技,你可能还不知道

大家好,我是CC,在这里欢迎大家的到来~

背景

在传统实现文本高亮时通常使用 span 标签包裹文本,再给 span 标签添加相应高亮背景色。这种方式会修改原本的 DOM 结构,逻辑复杂,也会频繁导致页面重绘,消耗浏览器性能。而基于 HighLightCSS 自定义高亮 API 的这种方式可以实现在渲染层处理文本高亮,既不影响 DOM 树,而且完全独立于文档结构

实现步骤

创建 Range 对象

标识想要高亮的文本范围。

javascript 复制代码
<div id="foo">纯 CSS 实现文本高亮</div>


const parentNode = document.getElementById("foo");

const range1 = new Range();
range1.setStart(parentNode, 1);
range1.setEnd(parentNode, 2);

const range2 = new Range();
range2.setStart(parentNode, 4);
range2.setEnd(parentNode, 6);

为 Range 对象添加 Highlight 对象

多个 Range 对象可以与同一个 Highlight 对象关联,这样会以相同方式高亮显示多个文本片段。

javascript 复制代码
const highlight = new Highlight(range1, range2);

当然也可以在某些场景下创建多个 Highlight 对象,比如在使用协作文本编辑器中每个用户使用不同的文本颜色。

javascript 复制代码
const user1Highlight = new Highlight(user1Range1, user1Range2);
const user2Highlight = new Highlight(user2Range1, user2Range2, user2Range3);

HighlightRegistry 注册

注册表是一个 Map 对象,通过名称注册高亮。

javascript 复制代码
CSS.highlights.set("user-1-highlight", user1Highlight);
CSS.highlights.set("user-2-highlight", user2Highlight);

当然除了注册之外,也支持删除和清除。

javascript 复制代码
CSS.highlights.delete("user-1-highlight");
CSS.highlights.clear();

::highlight()伪元素定义高亮样式

为文本片段添加自定义样式进行高亮。

css 复制代码
::highlight(user-1-highlight) {
  background-color: yellow;
  color: black;
}
::highlight(user-2-highlight) {
  background-color: black;
  color: yellow;
}

应用场景

这里举例两个 CSS 自定义高亮 API 的应用场景。

搜索高亮文本

在多段文本中搜索检索到文本后直接高亮展示,方便用户查找。

tsx 复制代码
import { message } from "antd";
import { useEffect, useRef } from "react";
import "./index.less";

const HighlightText = ({
  text,
  highlightedWords,
  type = "text"
}: {
  text: string;
  highlightedWords: string[];
  type?: "text" | "html";
}) => {
  // 将 Node 改为更具体的 HTMLDivElement 以修复 ref 类型错误
  const textRef = useRef<HTMLDivElement | null>(null);

  useEffect(() => {
    if (!CSS.highlights) {
      message.warning("CSS Custom Highlight API not supported.");
      return;
    }
    CSS.highlights.clear();

    // 支持多个词语:去重、trim,并过滤空字符串
    const words = Array.from(new Set(highlightedWords.map((w) => w.trim()).filter(Boolean)));
    if (!words.length) {
      return;
    }

    if (!textRef.current) return;
    const treeWalker = document.createTreeWalker(textRef.current, NodeFilter.SHOW_TEXT);
    const allTextNodes: Text[] = [];
    let currentNode = treeWalker.nextNode() as Text | null;
    while (currentNode) {
      allTextNodes.push(currentNode);
      currentNode = treeWalker.nextNode() as Text | null;
    }

    // 为所有词语在所有文本节点中生成 range
    const ranges: Range[] = [];
    for (const el of allTextNodes) {
      const content = el.textContent || "";
      for (const word of words) {
        let startPos = 0;
        while (startPos < content.length) {
          const index = content.indexOf(word, startPos);
          if (index === -1) break;
          const range = new Range();
          range.setStart(el, index);
          range.setEnd(el, index + word.length);
          ranges.push(range);
          startPos = index + word.length;
        }
      }
    }

    if (ranges.length) {
      // 统一用一个高亮名称,样式在 ::highlight(search-results) 中定义
      const searchResultsHighlight = new Highlight(...ranges);
      CSS.highlights.set("search-results", searchResultsHighlight);
    }
  }, [highlightedWords, text]);

  return (
    <>
      {type === "html" ? (
      <div ref={textRef} dangerouslySetInnerHTML={
        {
          __html: text
        }
      }>
      </div>
    ) : (
      <div ref={textRef}>{text}</div>
    )}
    </>
  );
};

export default HighlightText;

文本差异对比

对两段文本进行对比时,左侧高亮"不存在于右侧"的文本为绿色(删除),右侧高亮"多于左侧"的文本为红色(新增),可以直观看到差异。

tsx 复制代码
import { message } from "antd";
import { useEffect, useRef } from "react";
import "./index.less";

type DiffProps = {
  left: string;
  right: string;
  type?: "text" | "html";
};

const TextDiff = ({ left, right, type = "text" }: DiffProps) => {
  const leftRef = useRef<HTMLDivElement | null>(null);
  const rightRef = useRef<HTMLDivElement | null>(null);

  useEffect(() => {
    if (!("highlights" in CSS)) {
      message.warning("CSS Custom Highlight API not supported.");
      return;
    }

    const collectNodes = (root: HTMLElement | null) => {
      if (!root) return { nodes: [] as Text[], starts: [] as number[], text: "" };
      const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
      const nodes: Text[] = [];
      const starts: number[] = [];
      let text = "";
      let cur = walker.nextNode() as Text | null;
      while (cur) {
        nodes.push(cur);
        starts.push(text.length);
        text += cur.textContent || "";
        cur = walker.nextNode() as Text | null;
      }
      return { nodes, starts, text };
    };

    const { nodes: leftNodes, starts: leftStarts, text: leftText } = collectNodes(leftRef.current);
    const { nodes: rightNodes, starts: rightStarts, text: rightText } = collectNodes(rightRef.current);

    const n = leftText.length;
    const m = rightText.length;
    const dp: number[][] = Array(n + 1)
      .fill(0)
      .map(() => Array(m + 1).fill(0));
    for (let i = 1; i <= n; i++) {
      for (let j = 1; j <= m; j++) {
        if (leftText[i - 1] === rightText[j - 1]) dp[i][j] = dp[i - 1][j - 1] + 1;
        else dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
      }
    }

    type Op = { t: "equal" | "del" | "add"; ai?: number; bi?: number };
    const ops: Op[] = [];
    let i = n,
      j = m;
    while (i > 0 || j > 0) {
      if (i > 0 && j > 0 && leftText[i - 1] === rightText[j - 1]) {
        ops.push({ t: "equal", ai: i - 1, bi: j - 1 });
        i--;
        j--;
      } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
        ops.push({ t: "add", bi: j - 1 });
        j--;
      } else if (i > 0) {
        ops.push({ t: "del", ai: i - 1 });
        i--;
      }
    }
    ops.reverse();

    type Span = { start: number; length: number };
    const leftDelSpans: Span[] = [];
    const rightAddSpans: Span[] = [];
    let posA = 0;
    let posB = 0;
    let delRunStart: number | null = null;
    let delRunLen = 0;
    let addRunStart: number | null = null;
    let addRunLen = 0;

    const flushDel = () => {
      if (delRunStart !== null && delRunLen > 0) leftDelSpans.push({ start: delRunStart, length: delRunLen });
      delRunStart = null;
      delRunLen = 0;
    };
    const flushAdd = () => {
      if (addRunStart !== null && addRunLen > 0) rightAddSpans.push({ start: addRunStart, length: addRunLen });
      addRunStart = null;
      addRunLen = 0;
    };

    for (const op of ops) {
      if (op.t === "equal") {
        flushDel();
        flushAdd();
        posA++;
        posB++;
      } else if (op.t === "del") {
        if (delRunStart === null) delRunStart = posA;
        delRunLen++;
        flushAdd();
        posA++;
      } else if (op.t === "add") {
        if (addRunStart === null) addRunStart = posB;
        addRunLen++;
        flushDel();
        posB++;
      }
    }
    flushDel();
    flushAdd();

    const locate = (starts: number[], nodes: Text[], pos: number) => {
      let idx = 0;
      while (idx < nodes.length) {
        const nodeLen = (nodes[idx].textContent || "").length;
        const s = starts[idx];
        if (pos < s + nodeLen) return { nodeIndex: idx, offset: pos - s };
        idx++;
      }
      const lastIdx = nodes.length - 1;
      return { nodeIndex: Math.max(0, lastIdx), offset: (nodes[lastIdx]?.textContent || "").length };
    };

    const spansToRanges = (spans: Span[], starts: number[], nodes: Text[]) => {
      const ranges: Range[] = [];
      for (const { start, length } of spans) {
        const end = start + length;
        const sLoc = locate(starts, nodes, start);
        const eLoc = locate(starts, nodes, end);
        const r = new Range();
        r.setStart(nodes[sLoc.nodeIndex], sLoc.offset);
        r.setEnd(nodes[eLoc.nodeIndex], eLoc.offset);
        ranges.push(r);
      }
      return ranges;
    };

    const leftRanges = spansToRanges(leftDelSpans, leftStarts, leftNodes);
    const rightRanges = spansToRanges(rightAddSpans, rightStarts, rightNodes);

    if (leftRanges.length) CSS.highlights.set("diff-del-left", new Highlight(...leftRanges));
    else CSS.highlights.delete("diff-del-left");

    if (rightRanges.length) CSS.highlights.set("diff-add-right", new Highlight(...rightRanges));
    else CSS.highlights.delete("diff-add-right");
  }, [left, right, type]);

  return (
    <div className="text-diff-container">
      {type === "html" ? (
      <div
        className="text-diff-pane"
        ref={leftRef}
        dangerouslySetInnerHTML={{ __html: left }}
        />
    ) : (
      <div className="text-diff-pane" ref={leftRef}>
        {left}
      </div>
    )}
      {type === "html" ? (
      <div
        className="text-diff-pane"
        ref={rightRef}
        dangerouslySetInnerHTML={{ __html: right }}
        />
    ) : (
      <div className="text-diff-pane" ref={rightRef}>
        {right}
      </div>
    )}
    </div>
  );
};

export default TextDiff;

总结

目前来看,CSS Custom Highlight API 就是网页文本高亮的"神器"。特别适合那些需要疯狂标记、又不能动原文档结构的应用,比如在线文档、代码编辑器。只要浏览器支持,用它就对了,绝对是未来的趋势。

相关推荐
虚诚2 小时前
vue2中树形表格怎么实现
前端·javascript·vue.js·ecmascript·vue2·树形结构
wuhen_n2 小时前
Promise与async/await
前端
LYFlied2 小时前
前端路由核心原理深入剖析
前端
用户19017684478652 小时前
vue3规范化示例
前端
用户19017684478652 小时前
Git分支管理与代码合并实践:保持特性分支与主分支同步
前端
哈__2 小时前
React Native 鸿蒙跨平台开发:下拉刷新功能
javascript·react native·react.js
没有鸡汤吃不下饭3 小时前
前端打包出一个项目(文件夹),怎么本地快速启一个服务运行
前端·javascript
liusheng3 小时前
Capacitor + React 的 iOS 侧滑返回手势
前端·ios
CUYG3 小时前
v-model封装组件(定义 model 属性)
前端·vue.js