富文本开发核心
富文本编辑器的开发核心在于支持多样化的文本操作和功能,包括:
- 文本格式化:加粗,斜体,下划线,颜色,背景色等多种文本样式。
- 多媒体插入:图片,视频,文本,连接等。
- 撤销重做:用户在编辑过程中随时撤销和重做操作。
contentenditable基础
-
contenteditle属性:- 是HTML属性,可以使任何元素变为可编辑状态。通过设置
contentEditable="true",用户可以直接在元素内输入或删除文本。 - 常用于构建富文本编辑器的编辑区域,简单高效。
- 是HTML属性,可以使任何元素变为可编辑状态。通过设置
创建一个编辑的div元素。
HTML
<div id="editor-content" contentEditable="true">
<p>
这里是一个可编辑层
</p>
</div>
创建多个可编辑区域
HTML
<div id="editor-content" contentEditable="true">
<div contentEditable="true">
<div contentEditable="true">1</div>
<div contentEditable="true">2</div>
<div contentEditable="true">3</div>
</div>
</div>

文字加粗和斜体
HTML
<div contentEditable="true">
<div contentEditable="true">1</div>
<div contentEditable="true">2</div>
<div contentEditable="true">3</div>
<i>斜体<span>元素</span></i>
<div contentEditable="true">4</div>
<b>加粗</b>
</div>

HTML字符串作为富文本存储的性能问题
- 析与序列化开销大。
- 存储效率低
- 修改与操作困难
- Diff与合并性能差
使用JSON Context
JSON Context是结构化数据表示(通常为树状JSON对象),核心是用"节点类型+属性+子节点"的模型抽象富文本内容,完美解决HTML的性能痛点
定义(上面HTML描述):
js
const doc = {
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: '1' }] },
{ type: 'paragraph', content: [{ type: 'text', text: '2' }] },
{ type: 'paragraph', content: [{ type: 'text', text: '3' }] },
{
type: 'paragraph',
content: [
{ type: 'text', text: '斜体', marks: [{ type: 'italic' }] },
{
type: 'text',
text: '元素',
marks: [{ type: 'italic' }]
}
]
},
{ type: 'paragraph', content: [{ type: 'text', text: '3' }] },
{
type: 'paragraph',
content: [
{ type: 'text', text: '加粗', marks: [{ type: 'bold' }] }
]
}
]
};
给富文本添加 onInput 事件
js
const handleInput = (e:InputEvent) => {
const target = e.currentTarget as HTMLElement;
console.log(target.innerHTML)
// DOMParser 是浏览器原生 API,用于将字符串解析为 完整的 HTML 文档对象(Document)。
const domParser = new DOMParser();
const doc = domParser.parseFromString(target.innerHTML, "text/html");
console.log(target.innerHTML,'target.innerHTML')
console.log(doc.body.childNodes,'doc')
}

将html dom 转换为AST
ts
type TextStyle = {
bold?: boolean; // 加粗
italic?: boolean; // 斜体
underline?: boolean; // 下划线
};
/**
* 关于AST 节点定义
*/
type ASTNode = {
//
type: "text" | "paragraph" | "mention" | "custom" |'heading';
content?: string;
style?: TextStyle;
attrs?:any;
children?: ASTNode[];
data?: any;
};
/**
* @param html html字符串
* 将html dom 转换为AST
*/
export function parseHtml (html:string) {
// DOMParser 是浏览器原生 API,用于将字符串解析为 完整的 HTML 文档对象(Document)。
const parse = new DOMParser()
const doc = parse.parseFromString(html,'text/html')
// nodes doc.body 的所有直接子节点
const nodes = doc.body.childNodes
console.log(nodes,'doc')
/**
*
* @param node 节点
* 遍历每一个node,将 dom node 转换为 AST
*
*/
function parseNode (node:Node):ASTNode | null {
console.log(node)
// 如果是文本节点
if(node.nodeType === Node.TEXT_NODE){
return {
type: "text",
content: node.textContent||'', // 文本节点内容
style: {}, // 样式
data: {}
}
}
// 如果节点是元素节点
if(node.nodeType === Node.ELEMENT_NODE){
const element = node as HTMLElement
// 小写的元素名称
const tagName = element.tagName.toLowerCase()
// 获取元素属性
const data = element.dataset
// 获取元素样式
const style = element.style
console.log(data,style)
// 判断不同元素节点,返回不同的AST节点
switch(tagName){
case "h1":
return {
type:'heading',
attrs:{
level:1
},
content:element.textContent||'',
}
case "h2":
return {
type:'heading',
attrs:{
level:2
},
content:element.textContent||'',
}
case "p":
return {
type: "paragraph",
content:element.textContent||'',
}
default:
break
}
}
return null
}
return Array.from(doc.body.childNodes).map(parseNode)
}

鼠标选中文字通过按钮添加文字样式
在这里插入图片描述
添加事件
tsx
<div>
<button onClick={()=>format('bold')}>加粗</button>
<button onClick={()=>format('italic')}>斜体</button>
<button>@</button>
<div contentEditable="true" onInput={handleInput}>
<h1>我是H1</h1>
<h2>我是H2</h2>
<p>元素</p>
444
</div>
</div>
实现逻辑
tsx
const format = (commod: 'bold' | 'italic') => {
// 1. 获取当前页面的 Selection 对象(表示用户选中的文本范围或光标位置)
const selection = window.getSelection();
// 如果没有选区(例如在非浏览器环境或选区为空),直接退出
if (!selection) return;
// 2. 获取选区中的第一个 Range(通常只有一个,除非跨多区域选择)
// Range 表示文档中连续的一段内容(可跨节点)
const range = selection.getRangeAt(0);
console.log(range, 'range'); // 用于调试:查看当前选区的 Range 对象
// 3. 创建一个新元素来包裹格式化后的文本
// - 如果是 'bold',创建 <b> 标签;如果是 'italic',创建 <i> 标签
const formattedText = document.createElement(commod === 'bold' ? 'b' : 'i');
// 将选中的纯文本内容设置为新元素的文本内容
// ⚠️ 注意:这会丢失原有 HTML 结构(如嵌套标签)!
formattedText.textContent = range.toString();
// 4. 删除原选区中的内容(包括文本和任何内联元素)
range.deleteContents();
// 5. 将新创建的格式化元素插入到原选区位置
range.insertNode(formattedText);
// ✅ 可选增强:将光标移动到格式化文本之后,提升用户体验
// selection.removeAllRanges();
// const newRange = document.createRange();
// newRange.setStartAfter(formattedText);
// newRange.collapse(true);
// selection.addRange(newRange);
// 富文本编辑器不存在受控组件
};
Selection+Range+Compiler
ange接口
Range 表示文档中连续的一段内容(可跨节点),是操作 DOM 的底层单位。
常用属性
| 属性 | 说明 |
|---|---|
startContainer / startOffset |
起始容器节点和偏移 |
endContainer / endOffset |
结束容器节点和偏移 |
commonAncestorContainer |
起止节点的最近公共祖先 |
常用方法
| 方法 | 说明 |
|---|---|
cloneContents() |
克隆选中内容(返回 DocumentFragment) |
extractContents() |
提取并删除选中内容 |
deleteContents() |
仅删除选中内容 |
insertNode(node) |
在 Range 起始处插入节点 |
surroundContents(newNode) |
用新节点包裹 Range 内容(要求 Range 是"可包裹"的) |
selectNode(node) / selectNodeContents(node) |
选中整个节点或其内容 |
setStart(node, offset) / setEnd(...) |
手动设置边界 |
Selection 接口
Selection对象表示用户或脚本在文档中选中的文本范围。它通常与光标或鼠标选择操作相关联。
Selection 表示用户在页面中选中的文本范围(或光标位置)。可通过 window.getSelection() 获取。
常用属性
| 属性 | 说明 |
|---|---|
anchorNode / anchorOffset |
选择起点的节点和偏移 |
focusNode / focusOffset |
选择终点的节点和偏移 |
isCollapsed |
是否为光标(无选中文本) |
rangeCount |
包含的 Range 数量(通常为1) |
常用方法
| 方法 | 说明 |
|---|---|
getRangeAt(0) |
获取当前选区的 Range 对象 |
removeAllRanges() |
清除所有选区 |
addRange(range) |
添加一个 Range 到选区 |
collapse(node, offset) |
将选区折叠为光标 |
deleteFromDocument() |
删除选中的内容 |
富文本内容更新
通常文本内容比较多。如果每次更新数据,用全量更新导致页面卡顿。这个时候就只能对比新旧AST树来增量更新。
fast-json-patch 是一个高性能的 JavaScript 库,用于生成和应用 JSON Patch(RFC 6902)操作,常用于:
- 状态同步(如富文本协作编辑、实时协同)
- 撤销/重做(Undo/Redo)
- 高效传输数据变更(只传 diff,不传全量)
安装
bash
npm install fast-json-patch
核心功能
1. 生成 Patch(比较两个 JSON 对象)
js
import { compare } from 'fast-json-patch';
const obj1 = { name: "Alice", age: 30 };
const obj2 = { name: "Alice", age: 31, city: "Beijing" };
const patch = compare(obj1, obj2);
console.log(patch);
// 输出:
// [
// { op: "replace", path: "/age", value: 31 },
// { op: "add", path: "/city", value: "Beijing" }
// ]
✅
compare(a, b)返回将a变成b所需的操作序列。
2.应用 Patch(将操作应用到目标对象)
js
import { applyPatch } from 'fast-json-patch';
const doc = { title: "Hello" };
const patch = [{ op: "replace", path: "/title", value: "Hi" }];
const result = applyPatch(doc, patch, true); // 第三个参数:是否 mutate 原对象
console.log(result.newDocument); // { title: "Hi" }
⚠️ 默认
applyPatch不会修改原对象 (返回新对象)。若设
mutate: true(第三个参数为true),则直接修改原对象。
3. 创建可观察对象(自动记录变更)
js
import { observe, generate } from 'fast-json-patch';
const doc = { count: 0 };
const observer = observe(doc); // 开启监听
doc.count = 1;
doc.flag = true;
const patches = generate(observer); // 获取自 observe 以来的所有变更
console.log(patches);
// [
// { op: "replace", path: "/count", value: 1 },
// { op: "add", path: "/flag", value: true }
// ]
历史记录栈与Undo/Redo

点击撤销

tsx
import {compare} from "fast-json-patch"
const [ast, setAST] = useState<ASTNode[]>([]); // 当前 AST 状态
const [undoStack, setUndoStack] = useState<ASTNode[][]>([]); // 撤销栈
const [redoStack, setRedoStack] = useState<ASTNode[][]>([]); // 重做栈
const handleInput = () => {
if (!editorRef.current) return;
const html = editorRef.current.innerHTML;
const newAST = parseHTML(html);
// 🔄 生成 patch:从旧 AST 到新 AST 的变更
const patches = compare(ast, newAST);
console.log('JSON Patch:', patches);
// 💾 保存当前状态到撤销栈
setUndoStack((prevStack) => [...prevStack, ast]);
// 🚫 清空重做栈(因为重新开始)
setRedoStack([]);
// 🔁 更新 AST
setAST(newAST);
};
// ⏪ 撤销操作
const undo = () => {
if (undoStack.length === 0) return;
const prevAST = undoStack[undoStack.length - 1];
// 将当前状态推入重做栈
setRedoStack(prev => [...prev, ast]);
// 设置回上一个状态
setAST(prevAST);
setUndoStack((prevStacl)=> prevStacl.slice(0, -1));
};
// ⏩ 重做操作
const redo = () => {
if (redoStack.length === 0) return;
const nextAST = redoStack[redoStack.length - 1];
const newRedoStack = redoStack.slice(0, -1);
// 推入撤销栈
setUndoStack(prev => [...prev, ast]);
// 应用重做状态
setAST(nextAST);
setRedoStack(newRedoStack);
};
- 每操作一步,把当前状态推入撤销栈。
- 点击撤销:取撤销栈最后一步 → 设为当前状态 → 删除撤销栈最后一步 → 当前原状态推入重做栈。
- 点击重做:取重做栈最后一步 → 设为当前状态 → 删除重做栈最后一步 → 当前原状态推入撤销栈。
Tiptap
Tiptap,一款为开发富文本编辑器为生的框架
Tiptap 是一个基于 ProseMirror 的现代化、可扩展的 React/Vue/原生 JavaScript 富文本编辑器框架 ,具有 无头(headless)设计 、TypeScript 支持 、协作编辑能力 等优势
安装依赖
bash
# 核心包(必须)
npm install @tiptap/react @tiptap/pm
# 常用扩展(按需安装) 包含:段落、标题、加粗、斜体、列表、链接、历史记录等常用功能。
npm install @tiptap/starter-kit
基础使用
tsx
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import React,{useState, useEffect, useRef} from "react";
export default function RichTextEditor() {
const editor = useEditor({
extensions: [StarterKit],
content: '<p>开始编辑...</p>',
});
if (!editor) return null;
return (
<div>
<div style={{ marginBottom: '10px', padding: '8px', border: '1px solid #ccc' }}>
<button onClick={() => editor.chain().focus().toggleBold().run()}>
<strong>B</strong>
</button>
<button onClick={() => editor.chain().focus().toggleItalic().run()}>
<em>I</em>
</button>
<button onClick={() => editor.chain().focus().toggleBulletList().run()}>
•
</button>
<button onClick={() => editor.chain().focus().undo().run()}>↩️ Undo</button>
<button onClick={() => editor.chain().focus().redo().run()}>↪️ Redo</button>
</div>
<EditorContent editor={editor} />
</div>
);
}

| 功能 | 包 |
|---|---|
| 协作编辑 | @tiptap/extension-collaboration + WebSocket |
| 图片上传 | @tiptap/extension-image |
| 表格 | @tiptap/extension-table |
| 代码块 | @tiptap/extension-code-block |
| 字体颜色 | @tiptap/extension-text-style + @tiptap/extension-color |