---
title: "使用装饰在 React 中高亮搜索词"
description: "使用行内、节点和小部件装饰，为 React Tiptap 编辑器构建词语高亮功能。"
canonical_url: "https://tiptap.zhcndoc.com/guides/decorations-react"
---

# 使用装饰在 React 中高亮搜索词

使用行内、节点和小部件装饰，为 React Tiptap 编辑器构建词语高亮功能。

本教程将为 React Tiptap 编辑器构建词语高亮功能。用户在输入框中输入搜索词，文档中的每个匹配项都会被高亮。文档本身不会发生变化，高亮效果纯粹影响视觉呈现。

完成的功能会使用三种装饰类型：

- **Inline decorations** highlight every match with a yellow background.
- **Node decorations** outline every block that contains at least one match.
- **Widget decorations** render a small numbered React badge after each match.

## 什么是装饰？

装饰是显示在文档之上的视觉标记。它们会改变用户看到的内容，但不会改变文档内容。将编辑器保存为 JSON 或 HTML 时，装饰不会被包含在其中。

Tiptap 有三种装饰类型：

- **Inline** wraps a range of text in a styled span. Use it for highlights.
- **Node** adds attributes (like a CSS class) to the DOM wrapper of a whole block. Use it to outline paragraphs or headings.
- **Widget** inserts a DOM element at a single position. Use it for badges, markers, or small UI elements.

三种装饰都使用从 `@tiptap/core` 导入的 `Decoration` 类创建。对于渲染 React 组件的小部件，请使用 `@tiptap/react` 中的 `ReactWidgetRenderer`。

## 设置编辑器和搜索输入框

先创建一个同时渲染搜索输入框和编辑器的基础 React 组件：

```tsx
// Editor.tsx
import { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import { useState } from 'react'

import { Highlight } from './Highlight'
import './styles.css'

export function TiptapEditor() {
  // We store the search term in React state so the input stays controlled
  const [searchTerm, setSearchTerm] = useState('')

  const editor = useEditor({
    extensions: [StarterKit, Highlight],
    content: `
      <h2>Tiptap decorations tutorial</h2>
      <p>Tiptap is a headless editor toolkit built on ProseMirror.</p>
      <p>You can highlight words without changing the document.</p>
      <p>Try typing "tiptap" in the search box above.</p>
    `,
  })

  if (!editor) {
    return null
  }

  return (
    <div>
      <div className="highlight-toolbar">
        <input
          aria-label="Search"
          placeholder="Type a word to highlight..."
          value={searchTerm}
          onChange={(event) => setSearchTerm(event.target.value)}
        />
      </div>
      <EditorContent editor={editor} />
    </div>
  )
}
```

`Highlight` 扩展还不存在，我们会在下一步创建它。输入框已经连接到 React state，但还没有连接到编辑器；扩展准备好后再完成连接。

## 创建高亮扩展

扩展是 Tiptap 的构建块。从粗体文本到代码块，每项功能都存在于扩展中。我们需要一个读取搜索词并生成装饰的自定义扩展。

先创建一个空扩展，并为搜索词添加 storage 字段：

```tsx
// Highlight.tsx
import { Extension } from '@tiptap/core'

// This tells TypeScript what our storage looks like
interface HighlightStorage {
  term: string
}

// Tiptap keeps every extension's storage in one shared object: editor.storage.
// TypeScript does not know about our "highlight" key by default, so we use
// declare module to add it. This gives us type checking on editor.storage.highlight.
declare module '@tiptap/core' {
  interface Storage {
    highlight: HighlightStorage
  }
}

export const Highlight = Extension.create<{}, HighlightStorage>({
  name: 'highlight',

  // addStorage returns the initial values for this extension's storage.
  // We store the search term here so the decoration logic can read it.
  addStorage() {
    return {
      term: '',
    }
  },

  // addCommands defines custom commands callable via editor.commands.
  // Writing to storage alone does not trigger a decoration rebuild, so we
  // create one command that stores the term and refreshes decorations together.
  addCommands() {
    return {
      setSearchTerm: (term: string) => ({ editor, commands }) => {
        editor.storage.highlight.term = term
        commands.updateDecorations('highlight')
        return true
      },
    }
  },
})
```

Storage 是位于扩展实例上的普通对象。单独写入 storage 不会触发装饰重建，因此我们添加了 `setSearchTerm` 命令，在一步操作中存储搜索词并调用 `updateDecorations`。稍后会从 React 组件中调用它。在 `addDecorations` 内，我们从 storage 读取搜索词。

## 创建徽章小部件组件

在添加装饰之前，先创建小部件要渲染的 React 组件。它是一个显示在每个匹配项后的编号小徽章。

`ReactWidgetRenderer` automatically passes `editor` and `getPos` to your component as props, alongside any props you provide. For this badge we only need the match number:

```tsx
// MatchBadge.tsx
import type { ReactWidgetDecorationProps } from '@tiptap/react'

// ReactWidgetDecorationProps gives us editor and getPos.
// We add our own prop: the match number to display.
interface MatchBadgeProps extends ReactWidgetDecorationProps {
  matchNumber: number
}

export function MatchBadge({ matchNumber }: MatchBadgeProps) {
  return (
    <span className="match-badge" contentEditable={false}>
      {matchNumber}
    </span>
  )
}
```

`contentEditable={false}` 属性很重要。没有它，用户可能在徽章内输入内容，从而干扰 ProseMirror 的编辑逻辑。

> **什么是 editor 和 getPos？:**
>
> Every widget component receives `editor` (the Tiptap editor instance) and `getPos` (a function
> that returns the widget's current document position). We do not need them for a static badge, but
> they are essential for interactive widgets. For example, a "replace" button would use `getPos()`
> to know which part of the document to replace. Always call `getPos()` when you need the position,
> never store its result, because the position changes as the user edits.

## 查找并高亮匹配项

现在为扩展添加 `addDecorations` 钩子。在这里扫描文档中的匹配项并返回装饰。

我们分三步完成：先添加行内装饰，再添加节点装饰，最后添加小部件装饰。

### 第一步：使用行内装饰进行高亮

```tsx
// Highlight.tsx
import { Decoration, Extension } from '@tiptap/core'

// ... HighlightStorage, declare module, addStorage, and addCommands stay the same ...

export const Highlight = Extension.create<{}, HighlightStorage>({
  name: 'highlight',

  addStorage() {
    return { term: '' }
  },

  addDecorations() {
    return {
      // 'manual' means Tiptap will not rebuild decorations on every keystroke.
      // We decide when to rebuild by calling editor.commands.updateDecorations().
      update: 'manual',

      // create runs on init and every time we call updateDecorations().
      create: ({ editor, state }) => {
        // Read the search term from storage and clean it up
        const term = editor.storage.highlight.term.trim().toLowerCase()

        // If there is no search term, return an empty array (no decorations)
        if (!term) return []

        const decorations: Decoration[] = []

        // descendants() walks every node in the document.
        // Each node comes with its starting position (pos).
        state.doc.descendants((node, pos) => {
          // We only care about text nodes. Skip headings, paragraphs, etc.
          if (!node.isText || !node.text) return

          // Search inside this text node (case-insensitive)
          const text = node.text.toLowerCase()
          let index = text.indexOf(term)

          // Keep searching until we run out of matches in this text node
          while (index !== -1) {
            // pos is where the text node starts.
            // index is where the match starts inside the text node.
            // So the match's document position is pos + index.
            const matchFrom = pos + index
            const matchTo = matchFrom + term.length

            // Create an inline decoration that wraps the match in a span
            // with the CSS class "highlight-match"
            decorations.push(
              Decoration.Inline(matchFrom, matchTo, { class: 'highlight-match' }),
            )

            // Move past this match and look for the next one
            index = text.indexOf(term, index + term.length)
          }
        })

        return decorations
      },
    }
  },
})
```

> **为什么使用手动更新策略？:**
>
> The search term comes from outside the document (an input field). When the user types in the
> editor, the document changes but the search term does not. There is no reason to rebuild
> decorations on every keystroke. With `update: 'manual'`, Tiptap only maps existing decorations
> to their new positions (which is fast) and waits for us to call `updateDecorations()` when the
> search term actually changes.

### 第二步：使用节点装饰描边块

行内装饰会高亮单个词语。节点装饰可以描边包含匹配项的整个块（段落、标题等）。我们会记录已经装饰过的块，以避免重复：

```tsx
// Inside create, before the descendants() call, add a Set to track decorated blocks:

create: ({ editor, state }) => {
  const term = editor.storage.highlight.term.trim().toLowerCase()
  if (!term) return []

  const decorations: Decoration[] = []
  // Track block start positions we have already outlined
  const decoratedBlocks = new Set<number>()

  state.doc.descendants((node, pos) => {
    if (!node.isText || !node.text) return

    const text = node.text.toLowerCase()
    let index = text.indexOf(term)

    while (index !== -1) {
      const matchFrom = pos + index
      const matchTo = matchFrom + term.length

      // Inline: highlight the match
      decorations.push(
        Decoration.Inline(matchFrom, matchTo, { class: 'highlight-match' }),
      )

      // Node: outline the block that contains this match.
      // resolve() gives us info about the position, including which block it sits in.
      const $match = state.doc.resolve(matchFrom)

      // depth is how deeply the match is nested: 1 is a top-level block,
      // 2 is a block inside another block. The calculation selects the
      // innermost block around the match.
      const depth = Math.max(1, $match.depth)

      // before() and after() give us the start and end of the containing block
      const blockStart = $match.before(depth)
      const blockEnd = $match.after(depth)

      // Only outline each block once, even if it has multiple matches
      if (!decoratedBlocks.has(blockStart)) {
        decoratedBlocks.add(blockStart)
        decorations.push(
          Decoration.Node(blockStart, blockEnd, { class: 'has-match' }),
        )
      }

      index = text.indexOf(term, index + term.length)
    }
  })

  return decorations
},
```

### 第三步：使用小部件装饰显示编号徽章

现在添加小部件。我们使用 `ReactWidgetRenderer` 渲染之前创建的 `MatchBadge` 组件。需要一个计数器为所有文本节点中的匹配项编号：

```tsx
// Add the import at the top of Highlight.tsx:
import { ReactWidgetRenderer } from '@tiptap/react'
import { MatchBadge } from './MatchBadge'

// Inside create, add a counter before the descendants() call:
let matchNumber = 0

// Then inside the while loop, after the node decoration:

// Widget: render a numbered React badge after the match.
// ReactWidgetRenderer takes the component and an options object.
// The component receives the props we pass plus editor and getPos.
matchNumber++
decorations.push(
  ReactWidgetRenderer(MatchBadge, {
    editor,
    // Place the widget right after the match
    pos: matchTo,
    // The key identifies this widget across rebuilds.
    // Position-based keys are fine here because the badge has no state.
    // For stateful widgets, use a stable key like `match-${id}` instead.
    key: `match-badge-${matchFrom}`,
    // These props are passed to the MatchBadge component
    props: { matchNumber },
    // side: 1 places the widget after the match (right side)
    side: 1,
  }),
)
```

> **小部件 key 说明:**
>
> Every widget needs a `key`. ProseMirror uses the key to decide whether to reuse the widget's DOM
> across redraws or destroy and recreate it. If the key stays the same, the widget stays mounted and
> React preserves its state. If the key changes, the widget is recreated.
>
> For stateless widgets like this badge, a position-based key (`match-badge-${matchFrom}`) is fine.
> For stateful widgets (anything that holds data the user changed), use a stable key tied to the
> item's identity, like `comment-${id}`. See the
> [widget keys section](https://tiptap.zhcndoc.com/editor/core-concepts/decorations.md#widget-keys) in the core concepts guide.

## 完整扩展

下面是包含三种装饰类型的完整扩展：

```tsx
// Highlight.tsx
import { Decoration, Extension } from '@tiptap/core'
import { ReactWidgetRenderer } from '@tiptap/react'

import { MatchBadge } from './MatchBadge'

interface HighlightStorage {
  term: string
}

// Augment Tiptap's Storage interface so editor.storage.highlight is typed
declare module '@tiptap/core' {
  interface Storage {
    highlight: HighlightStorage
  }
}

export const Highlight = Extension.create<{}, HighlightStorage>({
  name: 'highlight',

  addStorage() {
    return { term: '' }
  },

  addCommands() {
    return {
      setSearchTerm: (term: string) => ({ editor, commands }) => {
        editor.storage.highlight.term = term
        commands.updateDecorations('highlight')
        return true
      },
    }
  },

  addDecorations() {
    return {
      update: 'manual',
      create: ({ editor, state }) => {
        const term = editor.storage.highlight.term.trim().toLowerCase()
        if (!term) return []

        const decorations: Decoration[] = []
        let matchNumber = 0
        const decoratedBlocks = new Set<number>()

        state.doc.descendants((node, pos) => {
          if (!node.isText || !node.text) return

          const text = node.text.toLowerCase()
          let index = text.indexOf(term)

          while (index !== -1) {
            const matchFrom = pos + index
            const matchTo = matchFrom + term.length

            // Inline: highlight the match
            decorations.push(
              Decoration.Inline(matchFrom, matchTo, { class: 'highlight-match' }),
            )

            // Node: outline the containing block once
            const $match = state.doc.resolve(matchFrom)
            const depth = Math.max(1, $match.depth)
            const blockStart = $match.before(depth)
            const blockEnd = $match.after(depth)

            if (!decoratedBlocks.has(blockStart)) {
              decoratedBlocks.add(blockStart)
              decorations.push(
                Decoration.Node(blockStart, blockEnd, { class: 'has-match' }),
              )
            }

            // Widget: numbered React badge after the match
            matchNumber++
            decorations.push(
              ReactWidgetRenderer(MatchBadge, {
                editor,
                pos: matchTo,
                key: `match-badge-${matchFrom}`,
                props: { matchNumber },
                side: 1,
              }),
            )

            index = text.indexOf(term, index + term.length)
          }
        })

        return decorations
      },
    }
  },
})
```

## 连接搜索输入框

现在将输入框连接到扩展。当搜索词变化时，将其写入扩展的 storage，并通知 Tiptap 重建装饰：

```tsx
// Editor.tsx
import { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import { useState } from 'react'

import { Highlight } from './Highlight'
import './styles.css'

export function TiptapEditor() {
  const [searchTerm, setSearchTerm] = useState('')
  const editor = useEditor({
    extensions: [StarterKit, Highlight],
    content: `
      <h2>Tiptap decorations tutorial</h2>
      <p>Tiptap is a headless editor toolkit built on ProseMirror.</p>
      <p>You can highlight words without changing the document.</p>
      <p>Try typing "tiptap" in the search box above.</p>
    `,
  })

  // Called every time the input value changes
  const onSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    const value = event.target.value
    setSearchTerm(value)

    if (!editor) return

    // Our custom command stores the term and rebuilds decorations.
    // No need to touch storage or call updateDecorations separately.
    editor.commands.setSearchTerm(value)
  }

  if (!editor) {
    return null
  }

  return (
    <div>
      <div className="highlight-toolbar">
        <input
          aria-label="Search"
          placeholder="Type a word to highlight..."
          value={searchTerm}
          onChange={onSearchChange}
        />
      </div>
      <EditorContent editor={editor} />
    </div>
  )
}
```

流程是：用户输入，React state 更新，`setSearchTerm` 命令存储搜索词并触发重建，Tiptap 再调用我们的 `create` 函数扫描文档并返回新装饰。

## 设置装饰样式

装饰会向文档添加 CSS 类。添加以下样式使其可见：

```css
/* styles.css */

/* Inline decoration: yellow highlight on each match */
.highlight-match {
  background: #fff3a3;
  border-radius: 2px;
}

/* Node decoration: outline blocks that contain matches */
.has-match {
  outline: 2px solid #ffc857;
  outline-offset: 2px;
  border-radius: 2px;
}

/* Widget decoration: numbered badge after each match */
.match-badge {
  margin-inline-start: 0.25rem;
  padding: 0.1rem 0.4rem;
  background: #1f2937;
  color: white;
  font-size: 0.75rem;
  border-radius: 0.25rem;
  vertical-align: middle;
  user-select: none;
}
```

## 下一步

- Review [decoration types and update strategies](https://tiptap.zhcndoc.com/editor/core-concepts/decorations.md) in the core concepts guide.
- Read the full [Decorations API reference](https://tiptap.zhcndoc.com/editor/api/decorations.md) for every signature and option.
- See the [Vanilla JS tutorial](https://tiptap.zhcndoc.com/guides/decorations-vanilla.md) if you want to build widgets by hand without a framework, or the [Vue 3 tutorial](https://tiptap.zhcndoc.com/guides/decorations-vue.md) for Vue.
