---
title: "Tiptap 中的装饰"
description: "使用装饰 API 为 Tiptap 编辑器添加仅影响视图的高亮、搜索结果、注释和小部件。详情请参阅文档！"
canonical_url: "https://tiptap.zhcndoc.com/editor/core-concepts/decorations"
---

# Tiptap 中的装饰

使用装饰 API 为 Tiptap 编辑器添加仅影响视图的高亮、搜索结果、注释和小部件。详情请参阅文档！

装饰会改变内容在编辑器中的外观，但不会改变文档本身。将编辑器保存为 JSON 或 HTML 时，装饰不会被包含在其中。

扩展通过 `addDecorations()` 添加装饰。使用 `Decoration` 类创建装饰，Tiptap 会负责将其添加到编辑器中。

> **Interactive demo:** [Decorations](https://embed.tiptap.dev/preview/Examples/Decorations?inline=false\&hideSource=false)

## 何时使用装饰而不是节点视图

当标记纯粹是视觉效果且位于文档之外时，请使用装饰。当内容属于文档本身时，请使用[节点视图](https://tiptap.zhcndoc.com/editor/extensions/custom-extensions/node-views.md)。

> **装饰与节点视图:**
>
> 对于不会改变文档的高亮、搜索结果、评论、注释和临时 UI 标记，请使用**装饰**。当你需要持久化的自定义文档内容、可编辑的自定义块或拥有文档结构的复杂嵌入式 UI 时，请使用\*\*[节点视图](https://tiptap.zhcndoc.com/editor/extensions/custom-extensions/node-views.md)\*\*。

## 三种装饰类型

装饰有三种类型，全部通过 `Decoration` 类创建：

- **node** 为一个节点添加属性，例如 CSS 类。
- **inline** 为一段文本或其他行内内容设置样式。
- **widget** 在文档中的某个位置添加 DOM 元素或框架组件。

## 声明装饰

扩展通过 `addDecorations()` 生命周期钩子声明装饰，该钩子返回 `DecorationSpec`（没有装饰时返回 `null`）。使用从 `@tiptap/core` 导入的 `Decoration` 类创建装饰。

```ts
import { Decoration, Extension } from '@tiptap/core'

const MyExtension = Extension.create({
  name: 'myExtension',

  addDecorations() {
    return {
      create: ({ editor, state, view }) => [Decoration.Inline(1, 5, { class: 'highlight' })],
    }
  },
})
```

和其他扩展钩子一样，`addDecorations()` 绑定到 `this`（`{ name, options, storage, editor, type, parent }`），因此你可以在声明装饰时读取选项和 storage。

> **从 state 参数读取状态，而不是 editor.state:**
>
> 在 `create()` 和 `createInRange()` 内始终从 `state` 参数读取文档，而不是读取 `editor.state`。事务执行期间，编辑器视图状态尚未更新，因此 `editor.state` 指向事务前的文档。`state` 参数才是当前正在构建的正确状态。

### `Decoration` 类

`Decoration` 类提供静态方法，返回描述 Tiptap 应显示内容的装饰实例。请从 `@tiptap/core` 导入它：

```ts
import { Decoration } from '@tiptap/core'

// Decorate a single node (attrs go on the node's DOM wrapper)
Decoration.Node(from, to, attrs, spec)

// Decorate a range of inline content (wraps it in a styled span)
Decoration.Inline(from, to, attrs, spec)

// Insert a widget (a DOM node) at a position; `key` is REQUIRED
Decoration.Widget(pos, render, options)
```

`attrs` accepts `class`, `style`, `nodeName`, and any other HTML attribute (ProseMirror's `DecorationAttrs`).

### 节点装饰

节点装饰会为单个节点的 DOM 包裹元素添加属性。例如，可以用它以较低成本为每个标题添加描边类：

```ts
addDecorations() {
  return {
    create: ({ state }) => {
      const decorations = []
      state.doc.descendants((node, pos) => {
        if (node.type.name === 'heading') {
          decorations.push(Decoration.Node(pos, pos + node.nodeSize, { class: 'is-heading' }))
        }
      })
      return decorations
    },
  }
}
```

### 行内装饰

行内装饰会将一段行内内容包裹在带样式的 span 中，适合高亮和搜索结果：

```ts
Decoration.Inline(matchFrom, matchTo, { class: 'is-match' })
```

### 小部件装饰

小部件装饰会在一个位置插入 DOM 节点。与节点装饰和行内装饰不同，小部件需要 `render` 回调以及**必需且稳定的 `key`**：

```ts
Decoration.Widget(
  pos,
  (view, getPos) => {
    const el = document.createElement('span')
    el.textContent = '★'
    return el
  },
  { key: `marker-${pos}`, side: -1 },
)
```

`side` controls which side of the position the widget belongs to. See ProseMirror's [`Decoration.widget`](https://prosemirror.net/docs/ref/#view.Decoration%5Ewidget). Every widget also needs a `key`.

## Widget keys

Every widget decoration requires a stable, position-independent `key`. This is the most common source of bugs when working with widgets.

> **Choose stable, globally unique keys:**
>
> ProseMirror reuses a widget's DOM across redraws **only when the key matches** the previous render. Without
> a stable key the widget is destroyed and recreated on every update, causing flicker and lost component
> state. Keys must also be **globally unique** across all widget decorations in the editor. Duplicate keys
> cause ProseMirror to misplace the widget DOM and crash.
>
> Tiptap logs a development warning when it detects two widget decorations with the same key in a single
> build, naming the offending extension:
>
> `[tiptap warn]: Duplicate widget decoration key "<key>" in extension "<name>". Widget decoration keys must
>     be globally unique…`
>
> The warning helps you find the problem. You must still make every key unique. Otherwise,
> ProseMirror misplaces the widget DOM.

**Good keys** use an ID that belongs to the item: `comment-${id}`, `paragraph-${node.attrs.id}`, `suggestion-${id}`.

**Bad keys** are unstable or position-dependent: a loop or paragraph index, a document position (`marker-${from}`), or any position-derived value.

If you need two widgets for one entity, suffix them: `comment-${id}-start` and `comment-${id}-end`.

> **Exception for stateless widgets:**
>
> For stateless widgets in demos and simple examples (such as a static marker), index- or
> position-based keys are acceptable. Widgets that hold state must use a stable item ID instead.

## Default behavior

- On editor init, every extension's `create` runs once to build the initial decorations.
- When the document changes, Tiptap builds the decorations again by default.
- When only the selection changes, Tiptap keeps the current decorations and updates their positions.

## 性能

Decorations use the `document` update strategy by default. This builds them again after every document change. For a large document, you can choose a different strategy to reduce the amount of work.

### Use `shouldUpdate` to skip updates

Return `false` from `shouldUpdate` when an edit cannot affect your decorations. Tiptap will keep them and update their positions instead of building them again.

```ts
addDecorations() {
  return {
    create: ({ state }) => buildHeadingOutline(state),
    shouldUpdate: ({ tr, oldState, newState }) => {
      // Only rebuild when the number of headings changed
      return countHeadings(oldState.doc) !== countHeadings(newState.doc)
    },
  }
}
```

By default, `shouldUpdate` runs after every document change.

### Use `changedRanges` for edited blocks

With `update: 'changedRanges'` and a `createInRange` callback, on a document change the manager:

1. Updates the positions of the current decorations.
2. Computes the changed range(s) of the transaction and expands them to the **enclosing top-level block boundaries**, so a match that overlaps the raw edit (for example typing in the middle of a word) is still fully contained.
3. Removes the now-stale decorations anchored in those blocks and rebuilds **only those blocks** by calling `createInRange({ state, from, to })`.

During normal editing, Tiptap does not call `create`. It only calls `create` when the editor starts or when you call [`updateDecorations()`](#build-decorations-again). This avoids scanning the full document after every key press.

`createInRange` receives a **block-aligned** `from`/`to` and must return only decorations within that range. It typically shares a scan helper with `create`:

```ts
addDecorations() {
  const scan = (editor, state, from, to) => {
    const decorations = []
    state.doc.nodesBetween(from, to, (node, pos) => {
      // …build decorations for nodes/text within [from, to]…
    })
    return decorations
  }

  return {
    update: 'changedRanges',
    create: ({ editor, state }) => scan(editor, state, 0, state.doc.content.size),
    createInRange: ({ editor, state, from, to }) => scan(editor, state, from, to),
  }
}
```

> **createInRange contract:**
>
> `createInRange` must return **only** decorations whose start position lies within `[from, to)`.
> `from` for inline and node decorations, `pos` for widget decorations. `to` is exclusive: it is
> the next block's start, and that block rebuilds it, so decorations anchored at `to` are ignored.
> The last block is the exception, because it owns the end of the document. Decorations anchored
> before `from` belong to a neighbouring block that the manager did not remove, so returning them
> leaks duplicate decorations that are never cleaned up. If a decoration depends on content outside
> the supplied range, use the `document` strategy instead.

TypeScript enforces the pairing: `update: 'changedRanges'` requires `createInRange`, while the other strategies do not accept it.

> **Incremental mode is only correct for block-local decorations:**
>
> Incremental mode is **only correct when each decoration depends solely on the content within its own
> block or range.** Tiptap only scans edited blocks again. It does not check decorations in other blocks.
>
> **Safe (local):** "highlight every occurrence of a word", "outline every heading", "underline misspelled
> words". Each decoration depends only on its own block.
>
> **Not safe (depends on the whole document):** use the default `document` strategy and `create`, or force a
> full rebuild with `updateDecorations()`:
>
> - Ordinal/counting logic: "highlight only the first match", "color every 3rd occurrence".
> - Cross-document relationships: "mark duplicate words across the document".
> - Structural relations: "the first heading gets a special class".
> - Anything depending on the selection or external state. Changed-range updates react to *content* changes only;
>   trigger `updateDecorations()` on selection or state changes instead.

### Use `manual` for external state

Use `update: 'manual'` when document transactions should only map existing decorations to their new positions. Manual decorations are rebuilt only during initialization and when you call `updateDecorations()`.

Use the manual strategy when decorations depend on a value outside the document, such as a search query or filter. You cannot use it with `shouldUpdate` or `createInRange`.

## Build decorations again

Sometimes decorations depend on something outside the document, such as a search term or filter. Call `updateDecorations` when that value changes:

```ts
editor.commands.updateDecorations() // Update every extension.
editor.commands.updateDecorations('myExtension') // Update one extension.
```

`updateDecorations` always calls `create` for the full document. It ignores the update strategy and `shouldUpdate` for this update.

### Runtime parameters via storage

Store changing values in the extension's [`storage`](https://tiptap.zhcndoc.com/editor/extensions/custom-extensions/create-new/extension.md#addstorage). Read them inside `create`, then call `updateDecorations` after changing them:

```ts
editor.storage.myExtension.term = 'foo'
editor.commands.updateDecorations('myExtension')
```

## Rendering framework components as widgets

Widget decorations can render React or Vue components. The component stays inside your editor's React or Vue app, so hooks, context, and `provide`/`inject` continue to work.

> **Interactive demo:** [DecorationComponents](https://embed.tiptap.dev/preview/Examples/DecorationComponents?inline=false\&hideSource=false)

### React: `ReactWidgetRenderer`

Import `ReactWidgetRenderer` from `@tiptap/react`. It returns a `WidgetDecoration` you return from `create` or `createInRange`, alongside `Decoration.Node` and `Decoration.Inline`. Your component additionally receives `editor` and `getPos` as props.

```tsx
import { Extension } from '@tiptap/core'
import { ReactWidgetRenderer } from '@tiptap/react'
import type { Editor } from '@tiptap/core'

function CommentMarker({
  editor,
  getPos,
  label,
}: {
  editor: Editor
  getPos: () => number | undefined
  label: string
}) {
  return <button onClick={() => console.log(getPos())}>{label}</button>
}

const Comments = Extension.create({
  name: 'comments',

  addDecorations() {
    return {
      create: ({ editor, state }) =>
        findComments(state.doc).map((c) =>
          ReactWidgetRenderer(CommentMarker, {
            editor,
            pos: c.pos,
            key: `comment-${c.id}`, // stable domain key → component state preserved
            props: { label: c.label },
          }),
        ),
    }
  },
})
```

A few things worth knowing:

- Tiptap sends new `props` when `create` or `createInRange` runs again. It reuses the renderer for the same key, so the component keeps its state.
- The cache is swept when the editor is destroyed.

### Vue: `VueWidgetRenderer`

Import `VueWidgetRenderer` from `@tiptap/vue-3` (or `@tiptap/vue-2`). It reuses Tiptap's `VueRenderer`, so the component shares the editor's app context (`provide`/`inject` work). The component receives `editor` and `getPos` in addition to your `props`.

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

const Comments = Extension.create({
  name: 'comments',

  addDecorations() {
    return {
      create: ({ editor, state }) =>
        findComments(state.doc).map((c) =>
          VueWidgetRenderer(CommentMarker, {
            editor,
            pos: c.pos,
            key: `comment-${c.id}`,
            props: { label: c.label },
          }),
        ),
    }
  },
})
```

> **Vue-specific notes:**
>
> The component **must render a single root element**. The `editor` is passed raw (with `markRaw`)
> on purpose. Do not wrap it in reactivity.

## Worked example: a search-term highlighter

This example uses all three kinds of decoration and incremental mode. It highlights every occurrence of a term (inline), renders a star marker before each match (widget), and outlines every heading (node). The term lives in storage and is changed at runtime via `updateDecorations`.

Each match and each heading depends only on its own block, so `update: 'changedRanges'` is safe here.

```ts
import { Decoration, Extension } from '@tiptap/core'
import type { Editor } from '@tiptap/core'
import type { EditorState } from '@tiptap/pm/state'

export interface HighlightOptions {
  term: string
}
export interface HighlightStorage {
  term: string
}

declare module '@tiptap/core' {
  interface Storage {
    highlight: HighlightStorage
  }
}

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

  addOptions: () => ({ term: 'tiptap' }),

  addStorage() {
    return { term: this.options.term }
  },

  addDecorations() {
    const scan = (editor: Editor, state: EditorState, from: number, to: number) => {
      const decorations: Decoration[] = []
      const term = editor.storage.highlight.term.trim().toLowerCase()

      state.doc.nodesBetween(from, to, (node, pos) => {
        if (node.type.name === 'heading') {
          decorations.push(Decoration.Node(pos, pos + node.nodeSize, { class: 'is-heading' }))
        }
        if (!term || !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
          decorations.push(Decoration.Inline(matchFrom, matchTo, { class: 'is-match' }))
          decorations.push(
            Decoration.Widget(
              matchFrom,
              () => {
                const el = document.createElement('span')
                el.textContent = '★'
                return el
              },
              { key: `marker-${matchFrom}`, side: -1 }, // stateless → position key OK
            ),
          )
          index = text.indexOf(term, index + term.length)
        }
      })
      return decorations
    }

    return {
      update: 'changedRanges', // each match/heading is block-local → safe
      create: ({ editor, state }) => scan(editor, state, 0, state.doc.content.size),
      createInRange: ({ editor, state, from, to }) => scan(editor, state, from, to),
    }
  },
})
```

Change the term by updating storage and rebuilding the decorations:

```ts
editor.storage.highlight.term = 'editor'
editor.commands.updateDecorations('highlight')
```

## 下一步

- Read the full [Decorations API reference](https://tiptap.zhcndoc.com/editor/api/decorations.md) for every signature and option.
- Build a term highlighter with the [Vanilla JS tutorial](https://tiptap.zhcndoc.com/guides/decorations-vanilla.md), [React tutorial](https://tiptap.zhcndoc.com/guides/decorations-react.md), or [Vue 3 tutorial](https://tiptap.zhcndoc.com/guides/decorations-vue.md).
- Learn how the [`addDecorations` hook](https://tiptap.zhcndoc.com/editor/extensions/custom-extensions/create-new/extension.md#adddecorations) fits into the extension lifecycle.
- Compare with [node views](https://tiptap.zhcndoc.com/editor/extensions/custom-extensions/node-views.md) when you need persisted, editable custom content.
