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

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

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

本教程将为 Vue 3 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 Vue 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` 类创建。对于渲染 Vue 组件的小部件，请使用 `@tiptap/vue-3` 中的 `VueWidgetRenderer`。

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

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

```vue
<!-- TiptapEditor.vue -->
<template>
  <div>
    <div class="highlight-toolbar">
      <input
        v-model="searchTerm"
        aria-label="Search"
        placeholder="Type a word to highlight..."
      />
    </div>
    <EditorContent :editor="editor" />
  </div>
</template>

<script setup lang="ts">
import StarterKit from '@tiptap/starter-kit'
import { Editor, EditorContent } from '@tiptap/vue-3'
import { onBeforeUnmount, ref } from 'vue'

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

// We store the search term in a ref so the input stays reactive
const searchTerm = ref('')

const editor = new Editor({
  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>
  `,
})

// Clean up the editor when the component unmounts
onBeforeUnmount(() => {
  editor.destroy()
})
</script>
```

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

## 创建高亮扩展

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

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

```ts
// highlight.ts
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`。稍后会从 Vue 组件中调用它。在 `addDecorations` 内，我们从 storage 读取搜索词。

## 创建徽章小部件组件

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

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

```vue
<!-- MatchBadge.vue -->
<template>
  <span class="match-badge" contenteditable="false">
    {{ matchNumber }}
  </span>
</template>

<script setup lang="ts">
import type { Editor } from '@tiptap/vue-3'

// editor and getPos are passed automatically by VueWidgetRenderer.
// We declare them so Vue does not warn about unknown props,
// but we do not use them for a static badge.
defineProps<{
  editor: Editor
  getPos: () => number | undefined
  matchNumber: number
}>()
</script>
```

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

> **Vue 小部件组件必须只有一个根元素:**
>
> Vue requires every component to have exactly one root element. The badge uses a single `<span>`,
> which is fine. If your widget needs multiple elements, wrap them in a container element.

> **什么是 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` 钩子。在这里扫描文档中的匹配项并返回装饰。

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

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

```ts
// highlight.ts
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.

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

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

```ts
// 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
},
```

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

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

```ts
// Add the imports at the top of highlight.ts:
import { VueWidgetRenderer } from '@tiptap/vue-3'
import MatchBadge from './MatchBadge.vue'

// 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 Vue badge after the match.
// VueWidgetRenderer takes the component and an options object.
// The component receives the props we pass plus editor and getPos.
matchNumber++
decorations.push(
  VueWidgetRenderer(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
> Vue 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.

## 完整扩展

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

```ts
// highlight.ts
import { Decoration, Extension } from '@tiptap/core'
import { VueWidgetRenderer } from '@tiptap/vue-3'

import MatchBadge from './MatchBadge.vue'

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 Vue badge after the match
            matchNumber++
            decorations.push(
              VueWidgetRenderer(MatchBadge, {
                editor,
                pos: matchTo,
                key: `match-badge-${matchFrom}`,
                props: { matchNumber },
                side: 1,
              }),
            )

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

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

## 连接搜索输入框

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

```vue
<!-- TiptapEditor.vue -->
<template>
  <div>
    <div class="highlight-toolbar">
      <input
        v-model="searchTerm"
        aria-label="Search"
        placeholder="Type a word to highlight..."
      />
    </div>
    <EditorContent :editor="editor" />
  </div>
</template>

<script setup lang="ts">
import StarterKit from '@tiptap/starter-kit'
import { Editor, EditorContent } from '@tiptap/vue-3'
import { onBeforeUnmount, ref, watch } from 'vue'

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

const searchTerm = ref('')
const editor = new Editor({
  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>
  `,
})

// Watch the search term ref. When it changes, call our custom command
// which stores the term and rebuilds decorations in one step.
watch(searchTerm, (value) => {
  editor.commands.setSearchTerm(value)
})

onBeforeUnmount(() => {
  editor.destroy()
})
</script>
```

流程是：用户输入，Vue ref 更新，watcher 调用 `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 [React tutorial](https://tiptap.zhcndoc.com/guides/decorations-react.md) for React.
