五、通用封装架构:适配器模式(接上篇)
当项目中可能更换编辑器时,使用适配器模式隔离底层实现:
csharp
┌─────────────────────────────────────────────────────┐
│ 业务组件层 │
│ │
│ <RichEditor v-model="content" mode="full" /> │
│ │
├─────────────────────────────────────────────────────┤
│ 统一接口层 (Adapter) │
│ │
│ interface IEditor { │
│ getHTML(): string │
│ getJSON(): object │
│ getText(): string │
│ setContent(content): void │
│ clear(): void │
│ focus(): void │
│ destroy(): void │
│ on(event, handler): void │
│ } │
│ │
├──────────┬──────────┬──────────┬────────────────────┤
│ WangEditor│ Tiptap │ Quill │ Slate Adapter │
│ Adapter │ Adapter │ Adapter │ │
└──────────┴──────────┴──────────┴────────────────────┘
5.1 统一接口定义
javascript
// editor/interface.js
/**
* 编辑器统一接口
* @typedef {Object} EditorAdapter
*/
export class EditorAdapter {
/**
* @param {HTMLElement} container - 挂载容器
* @param {Object} options - 配置选项
*/
constructor(container, options = {}) {
this.container = container;
this.options = options;
this.listeners = new Map();
this.instance = null;
}
/** 初始化编辑器 */
init() { throw new Error('Must implement init()'); }
/** 获取 HTML */
getHTML() { throw new Error('Must implement getHTML()'); }
/** 获取纯文本 */
getText() { throw new Error('Must implement getText()'); }
/** 获取 JSON 结构 */
getJSON() { throw new Error('Must implement getJSON()'); }
/** 设置内容 */
setContent(content) { throw new Error('Must implement setContent()'); }
/** 清空 */
clear() { throw new Error('Must implement clear()'); }
/** 聚焦 */
focus() { throw new Error('Must implement focus()'); }
/** 设置是否可编辑 */
setEditable(editable) { throw new Error('Must implement setEditable()'); }
/** 销毁 */
destroy() { throw new Error('Must implement destroy()'); }
/** 插入图片 */
insertImage(url, alt) { throw new Error('Must implement insertImage()'); }
/** 注册事件 */
on(event, handler) {
if (!this.listeners.has(event)) {
this.listeners.set(event, []);
}
this.listeners.get(event).push(handler);
}
/** 触发事件 */
emit(event, ...args) {
const handlers = this.listeners.get(event) || [];
handlers.forEach((handler) => handler(...args));
}
/** 移除事件 */
off(event, handler) {
if (!handler) {
this.listeners.delete(event);
return;
}
const handlers = this.listeners.get(event) || [];
this.listeners.set(event, handlers.filter((h) => h !== handler));
}
}
5.2 各适配器实现
javascript
// editor/adapters/tiptap-adapter.js
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import Image from '@tiptap/extension-image';
import Link from '@tiptap/extension-link';
import Placeholder from '@tiptap/extension-placeholder';
import { EditorAdapter } from '../interface';
export class TiptapAdapter extends EditorAdapter {
init() {
this.instance = new Editor({
element: this.container,
content: this.options.initialContent || '',
editable: this.options.editable !== false,
extensions: [
StarterKit,
Image.configure({ inline: true }),
Link.configure({ openOnClick: false }),
Placeholder.configure({
placeholder: this.options.placeholder || '请输入内容...',
}),
...(this.options.extensions || []),
],
onUpdate: ({ editor }) => {
this.emit('change', {
html: editor.getHTML(),
text: editor.getText(),
json: editor.getJSON(),
});
},
onFocus: () => this.emit('focus'),
onBlur: () => this.emit('blur'),
});
return this;
}
getHTML() {
return this.instance.getHTML();
}
getText() {
return this.instance.getText();
}
getJSON() {
return this.instance.getJSON();
}
setContent(content) {
if (typeof content === 'string') {
this.instance.commands.setContent(content);
} else {
this.instance.commands.setContent(content); // JSON
}
}
clear() {
this.instance.commands.clearContent();
}
focus() {
this.instance.commands.focus();
}
setEditable(editable) {
this.instance.setEditable(editable);
}
insertImage(url, alt = '') {
this.instance.chain().focus().setImage({ src: url, alt }).run();
}
destroy() {
this.instance?.destroy();
this.listeners.clear();
}
}
javascript
// editor/adapters/wangeditor-adapter.js
import { createEditor, createToolbar } from '@wangeditor/editor';
import { EditorAdapter } from '../interface';
export class WangEditorAdapter extends EditorAdapter {
init() {
// wangEditor 需要两个容器
const toolbarContainer = document.createElement('div');
const editorContainer = document.createElement('div');
editorContainer.style.minHeight = '300px';
this.container.appendChild(toolbarContainer);
this.container.appendChild(editorContainer);
this.instance = createEditor({
selector: editorContainer,
html: this.options.initialContent || '<p><br></p>',
config: {
placeholder: this.options.placeholder || '请输入内容...',
readOnly: this.options.editable === false,
onChange: (editor) => {
this.emit('change', {
html: editor.getHtml(),
text: editor.getText(),
json: editor.children,
});
},
onFocus: () => this.emit('focus'),
onBlur: () => this.emit('blur'),
MENU_CONF: this.options.menuConf || {},
},
});
this.toolbar = createToolbar({
editor: this.instance,
selector: toolbarContainer,
config: this.options.toolbarConfig || {},
});
return this;
}
getHTML() {
return this.instance.getHtml();
}
getText() {
return this.instance.getText();
}
getJSON() {
return this.instance.children;
}
setContent(content) {
if (typeof content === 'string') {
this.instance.setHtml(content);
}
}
clear() {
this.instance.clear();
}
focus() {
this.instance.focus();
}
setEditable(editable) {
if (editable) {
this.instance.enable();
} else {
this.instance.disable();
}
}
insertImage(url, alt = '') {
this.instance.dangerouslyInsertNode({
type: 'image',
src: url,
alt,
children: [{ text: '' }],
});
}
destroy() {
this.instance?.destroy();
this.toolbar?.destroy?.();
this.listeners.clear();
}
}
5.3 工厂函数 + Vue 通用组件
javascript
// editor/factory.js
import { TiptapAdapter } from './adapters/tiptap-adapter';
import { WangEditorAdapter } from './adapters/wangeditor-adapter';
const ADAPTERS = {
tiptap: TiptapAdapter,
wangeditor: WangEditorAdapter,
// quill: QuillAdapter,
// slate: SlateAdapter,
};
/**
* 创建编辑器实例
* @param {'tiptap' | 'wangeditor' | 'quill'} type
* @param {HTMLElement} container
* @param {Object} options
* @returns {EditorAdapter}
*/
export function createEditorAdapter(type, container, options = {}) {
const AdapterClass = ADAPTERS[type];
if (!AdapterClass) {
throw new Error(`Unsupported editor type: ${type}. Available: ${Object.keys(ADAPTERS).join(', ')}`);
}
return new AdapterClass(container, options).init();
}
vue
<!-- components/RichEditor.vue ------ 通用富文本组件 -->
<template>
<div class="rich-editor-wrapper">
<div ref="editorContainer" class="editor-container"></div>
</div>
</template>
<script>
import { createEditorAdapter } from '@/editor/factory';
export default {
name: 'RichEditor',
props: {
// 编辑器类型:可通过配置文件全局切换
type: {
type: String,
default: process.env.VUE_APP_EDITOR_TYPE || 'tiptap',
validator: (v) => ['tiptap', 'wangeditor', 'quill'].includes(v),
},
value: { type: String, default: '' },
placeholder: { type: String, default: '请输入内容...' },
editable: { type: Boolean, default: true },
options: { type: Object, default: () => ({}) },
},
data() {
return {
adapter: null,
internalUpdate: false,
};
},
mounted() {
this.adapter = createEditorAdapter(this.type, this.$refs.editorContainer, {
initialContent: this.value,
placeholder: this.placeholder,
editable: this.editable,
...this.options,
});
// 监听内容变化
this.adapter.on('change', ({ html, text, json }) => {
this.internalUpdate = true;
this.$emit('input', html); // v-model 绑定
this.$emit('change', { html, text, json });
this.$nextTick(() => {
this.internalUpdate = false;
});
});
this.adapter.on('focus', () => this.$emit('focus'));
this.adapter.on('blur', () => this.$emit('blur'));
},
watch: {
value(newVal) {
// 避免循环更新
if (this.internalUpdate) return;
if (newVal !== this.adapter.getHTML()) {
this.adapter.setContent(newVal);
}
},
editable(val) {
this.adapter.setEditable(val);
},
},
methods: {
// 暴露给父组件的 API
getHTML() { return this.adapter.getHTML(); },
getText() { return this.adapter.getText(); },
getJSON() { return this.adapter.getJSON(); },
clear() { this.adapter.clear(); },
focus() { this.adapter.focus(); },
insertImage(url, alt) { this.adapter.insertImage(url, alt); },
},
beforeDestroy() {
this.adapter?.destroy();
this.adapter = null;
},
};
</script>
<style scoped>
.rich-editor-wrapper {
border: 1px solid #d9d9d9;
border-radius: 8px;
overflow: hidden;
}
.editor-container {
min-height: 300px;
}
</style>
业务组件使用:
vue
<template>
<div>
<RichEditor
v-model="articleContent"
type="tiptap"
placeholder="请输入文章内容..."
:options="editorOptions"
@change="onContentChange"
ref="editor"
/>
<button @click="handleSubmit">发布</button>
</div>
</template>
<script>
import RichEditor from '@/components/RichEditor.vue';
export default {
components: { RichEditor },
data() {
return {
articleContent: '',
editorOptions: {
// 传给底层适配器的额外配置
},
};
},
methods: {
onContentChange({ html, text }) {
console.log('纯文本长度:', text.length);
},
handleSubmit() {
const html = this.$refs.editor.getHTML();
const text = this.$refs.editor.getText();
// 提交到后端...
},
},
};
</script>
六、图片上传完整方案
6.1 上传服务封装
javascript
// services/upload.js
/**
* 通用图片上传服务
* 支持:直传OSS、服务端中转、Base64
*/
class UploadService {
constructor(options = {}) {
this.options = {
action: '/api/upload', // 上传接口
maxSize: 10 * 1024 * 1024, // 10MB
accept: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
enableCompress: true, // 是否压缩
compressQuality: 0.8, // 压缩质量
compressMaxWidth: 1920, // 压缩最大宽度
withCredentials: true,
headers: {},
...options,
};
}
/**
* 上传单个文件
* @param {File} file
* @param {Function} onProgress - 进度回调 (percent: number)
* @returns {Promise<{ url: string, width: number, height: number }>}
*/
async upload(file, onProgress) {
// 1. 校验
this.validate(file);
// 2. 压缩(可选)
let processedFile = file;
if (this.options.enableCompress && file.type !== 'image/gif') {
processedFile = await this.compress(file);
}
// 3. 上传
return this.doUpload(processedFile, onProgress);
}
/**
* 文件校验
*/
validate(file) {
if (!this.options.accept.includes(file.type)) {
throw new Error(
`不支持的文件类型: ${file.type},仅支持: ${this.options.accept.join(', ')}`
);
}
if (file.size > this.options.maxSize) {
const maxMB = (this.options.maxSize / 1024 / 1024).toFixed(1);
throw new Error(`文件大小超出限制,最大 ${maxMB}MB`);
}
}
/**
* 图片压缩
*/
compress(file) {
return new Promise((resolve, reject) => {
const img = new Image();
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
img.onload = () => {
let { width, height } = img;
const maxWidth = this.options.compressMaxWidth;
// 按比例缩放
if (width > maxWidth) {
height = Math.round((height * maxWidth) / width);
width = maxWidth;
}
canvas.width = width;
canvas.height = height;
ctx.drawImage(img, 0, 0, width, height);
canvas.toBlob(
(blob) => {
if (!blob) {
resolve(file); // 压缩失败,返回原文件
return;
}
// 如果压缩后更大,返回原文件
if (blob.size >= file.size) {
resolve(file);
return;
}
const compressed = new File([blob], file.name, {
type: file.type,
lastModified: Date.now(),
});
console.log(
`[Upload] 压缩: ${(file.size / 1024).toFixed(0)}KB → ${(compressed.size / 1024).toFixed(0)}KB`
);
resolve(compressed);
},
file.type,
this.options.compressQuality
);
};
img.onerror = () => resolve(file); // 失败不阻塞
img.src = URL.createObjectURL(file);
});
}
/**
* 执行上传(XMLHttpRequest,支持进度)
*/
doUpload(file, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const formData = new FormData();
formData.append('file', file);
formData.append('type', 'editor-image');
// 进度监听
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable && onProgress) {
const percent = Math.round((e.loaded / e.total) * 100);
onProgress(percent);
}
});
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
const res = JSON.parse(xhr.responseText);
if (res.code === 0 || res.success) {
resolve({
url: res.data?.url || res.url,
width: res.data?.width,
height: res.data?.height,
});
} else {
reject(new Error(res.message || '上传失败'));
}
} catch (e) {
reject(new Error('响应解析失败'));
}
} else {
reject(new Error(`上传失败: HTTP ${xhr.status}`));
}
});
xhr.addEventListener('error', () => reject(new Error('网络错误')));
xhr.addEventListener('abort', () => reject(new Error('上传已取消')));
xhr.addEventListener('timeout', () => reject(new Error('上传超时')));
xhr.open('POST', this.options.action);
xhr.withCredentials = this.options.withCredentials;
xhr.timeout = 30000;
// 设置请求头
Object.entries(this.options.headers).forEach(([key, value]) => {
xhr.setRequestHeader(key, value);
});
xhr.send(formData);
// 返回取消方法
this._currentXHR = xhr;
});
}
/**
* 取消当前上传
*/
abort() {
this._currentXHR?.abort();
}
/**
* 批量上传
*/
async uploadBatch(files, onProgress) {
const results = [];
for (let i = 0; i < files.length; i++) {
const result = await this.upload(files[i], (percent) => {
onProgress?.({
current: i + 1,
total: files.length,
percent,
overallPercent: Math.round(
((i * 100 + percent) / files.length)
),
});
});
results.push(result);
}
return results;
}
/**
* 将文件转为 Base64(离线场景降级方案)
*/
static toBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
}
// 单例导出
export const uploadService = new UploadService();
export default UploadService;
6.2 粘贴/拖拽上传
javascript
// plugins/paste-upload.js
/**
* 粘贴图片上传插件(通用,可用于任何编辑器)
*/
export function setupPasteUpload(editorElement, { onUpload, onError }) {
// 粘贴上传
const pasteHandler = async (event) => {
const items = event.clipboardData?.items;
if (!items) return;
for (const item of items) {
if (item.type.startsWith('image/')) {
event.preventDefault();
const file = item.getAsFile();
if (!file) continue;
try {
const result = await onUpload(file);
return result; // 返回给编辑器处理
} catch (err) {
onError?.(err);
}
}
}
};
// 拖拽上传
const dropHandler = async (event) => {
const files = event.dataTransfer?.files;
if (!files?.length) return;
for (const file of files) {
if (file.type.startsWith('image/')) {
event.preventDefault();
try {
const result = await onUpload(file);
return result;
} catch (err) {
onError?.(err);
}
}
}
};
const dragOverHandler = (event) => {
event.preventDefault();
event.dataTransfer.dropEffect = 'copy';
};
editorElement.addEventListener('paste', pasteHandler);
editorElement.addEventListener('drop', dropHandler);
editorElement.addEventListener('dragover', dragOverHandler);
// 返回清理函数
return () => {
editorElement.removeEventListener('paste', pasteHandler);
editorElement.removeEventListener('drop', dropHandler);
editorElement.removeEventListener('dragover', dragOverHandler);
};
}
七、XSS 安全防护
7.1 输入过滤(前端)
javascript
// utils/sanitize.js
import DOMPurify from 'dompurify';
/**
* HTML 安全过滤配置
*/
const SANITIZE_CONFIG = {
// 允许的标签
ALLOWED_TAGS: [
// 块级
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'p', 'div', 'blockquote', 'pre', 'code',
'ul', 'ol', 'li',
'table', 'thead', 'tbody', 'tr', 'th', 'td',
'hr', 'br',
// 行内
'strong', 'b', 'em', 'i', 'u', 's', 'del',
'a', 'img', 'span', 'sub', 'sup', 'mark',
],
// 允许的属性
ALLOWED_ATTR: [
'href', 'target', 'rel',
'src', 'alt', 'width', 'height',
'class', 'id',
'style',
'data-type', 'data-mention-id', 'data-mention-label',
'colspan', 'rowspan',
],
// 允许的 CSS 属性
ALLOWED_STYLE_PROPERTIES: [
'color', 'background-color', 'background',
'font-size', 'font-weight', 'font-style',
'text-align', 'text-decoration',
'margin', 'margin-top', 'margin-bottom', 'margin-left', 'margin-right',
'padding', 'padding-top', 'padding-bottom', 'padding-left', 'padding-right',
'border', 'border-radius',
'width', 'max-width', 'height',
'display', 'list-style-type',
],
// 允许的 URL 协议
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel):|[^a-z]|[a-z+.-]+(?:[^a-z+.-:]|$))/i,
// 不允许的标签直接移除(而不是转义)
FORBID_TAGS: ['script', 'style', 'iframe', 'object', 'embed', 'form', 'input'],
FORBID_ATTR: ['onerror', 'onclick', 'onload', 'onmouseover', 'onfocus'],
// 链接安全
ADD_ATTR: ['target'],
};
/**
* 过滤HTML(用于存储前)
*/
export function sanitizeHTML(dirtyHTML) {
// 配置钩子:给所有 a 标签加上 rel="noopener noreferrer"
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
if (node.tagName === 'A') {
node.setAttribute('target', '_blank');
node.setAttribute('rel', 'noopener noreferrer nofollow');
}
// img 标签限制来源
if (node.tagName === 'IMG') {
const src = node.getAttribute('src') || '';
// 只允许 https 和 已知 CDN 域名
const allowedDomains = [
'your-cdn.com',
'img.your-domain.com',
'res.cloudinary.com',
];
try {
const url = new URL(src);
if (url.protocol !== 'https:' || !allowedDomains.some(d => url.hostname.endsWith(d))) {
// 不安全的图片替换为占位图
node.setAttribute('src', '/images/blocked-image.png');
node.setAttribute('alt', '图片已被安全策略拦截');
}
} catch {
// data:image base64 允许
if (!src.startsWith('data:image/')) {
node.remove();
}
}
}
});
const clean = DOMPurify.sanitize(dirtyHTML, SANITIZE_CONFIG);
// 清除钩子(避免影响后续调用)
DOMPurify.removeHook('afterSanitizeAttributes');
return clean;
}
/**
* 过滤HTML(用于展示,更严格)
*/
export function sanitizeForDisplay(html) {
return DOMPurify.sanitize(html, {
...SANITIZE_CONFIG,
RETURN_DOM: false,
RETURN_DOM_FRAGMENT: false,
});
}
/**
* 纯文本转义(最安全,用于用户名、标题等)
*/
export function escapeHtml(str) {
const map = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": ''',
'/': '/',
};
return String(str).replace(/[&<>"'/]/g, (char) => map[char]);
}
7.2 后端二次过滤(Node.js)
javascript
// server/middleware/sanitize.js
const createDOMPurify = require('dompurify');
const { JSDOM } = require('jsdom');
const window = new JSDOM('').window;
const DOMPurify = createDOMPurify(window);
/**
* Express 中间件:过滤请求体中的 HTML 字段
*/
function sanitizeMiddleware(fields = ['content', 'body', 'html']) {
return (req, res, next) => {
if (req.body && typeof req.body === 'object') {
fields.forEach((field) => {
if (typeof req.body[field] === 'string') {
req.body[field] = DOMPurify.sanitize(req.body[field], {
ALLOWED_TAGS: [
'h1','h2','h3','h4','h5','h6',
'p','div','blockquote','pre','code',
'ul','ol','li',
'table','thead','tbody','tr','th','td',
'hr','br',
'strong','b','em','i','u','s','del',
'a','img','span',
],
ALLOWED_ATTR: [
'href','target','rel',
'src','alt','width','height',
'class','style',
'data-type','data-mention-id',
],
FORBID_TAGS: ['script','style','iframe','object','embed','form'],
FORBID_ATTR: ['onerror','onclick','onload','onmouseover'],
});
}
});
}
next();
};
}
module.exports = sanitizeMiddleware;
// 使用:
// app.post('/api/article', sanitizeMiddleware(['content']), articleController.create);
八、性能优化
8.1 大文档性能优化
javascript
// optimization/virtual-scroll.js
/**
* 编辑器虚拟滚动(Slate.js 大文档优化思路)
*
* 原理:只渲染可视区域内的块级节点
* Tiptap/ProseMirror 原生支持较好,一般不需要
* Slate.js 大文档(>500个块)需要考虑
*/
export class VirtualEditorScroll {
constructor(editor, container, options = {}) {
this.editor = editor;
this.container = container;
this.options = {
blockHeight: 40, // 预估块高度
overscan: 5, // 额外渲染行数
...options,
};
this.visibleRange = { start: 0, end: 50 };
this.observer = null;
this.init();
}
init() {
// 使用 IntersectionObserver 监测可见性
this.observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
const index = parseInt(entry.target.dataset.blockIndex);
if (entry.isIntersecting) {
entry.target.classList.remove('virtualized');
}
});
},
{
root: this.container,
rootMargin: '200px 0px', // 提前200px开始渲染
}
);
}
/**
* 获取当前应该渲染的块索引范围
*/
getVisibleRange() {
const { scrollTop, clientHeight } = this.container;
const { blockHeight, overscan } = this.options;
const totalBlocks = this.editor.children.length;
const start = Math.max(0, Math.floor(scrollTop / blockHeight) - overscan);
const end = Math.min(
totalBlocks,
Math.ceil((scrollTop + clientHeight) / blockHeight) + overscan
);
return { start, end };
}
destroy() {
this.observer?.disconnect();
}
}
/**
* 防抖的 onChange(避免高频触发导致的性能问题)
*/
export function createDebouncedOnChange(callback, delay = 300) {
let timer = null;
let lastEditorState = null;
return (editorState) => {
lastEditorState = editorState;
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
callback(lastEditorState);
timer = null;
}, delay);
};
}
/**
* 懒加载图片(编辑器内的图片使用懒加载)
*/
export function setupLazyImages(container) {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const img = entry.target;
if (img.dataset.src) {
img.src = img.dataset.src;
img.removeAttribute('data-src');
observer.unobserve(img);
}
}
});
},
{ rootMargin: '300px' }
);
// 监听 DOM 变化,自动处理新插入的图片
const mutationObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === 1) {
const images = node.tagName === 'IMG'
? [node]
: node.querySelectorAll('img[data-src]');
images.forEach((img) => observer.observe(img));
}
});
});
});
mutationObserver.observe(container, { childList: true, subtree: true });
return () => {
observer.disconnect();
mutationObserver.disconnect();
};
}
8.2 编辑器懒加载
javascript
// 按需加载编辑器(减少首屏体积)
// Vue 异步组件
const RichEditor = () => ({
component: import(/* webpackChunkName: "rich-editor" */ '@/components/RichEditor.vue'),
loading: {
template: `
<div style="min-height:300px;display:flex;align-items:center;justify-content:center;
border:1px dashed #d9d9d9;border-radius:8px;color:#999;">
<span>编辑器加载中...</span>
</div>
`,
},
error: {
template: `
<div style="min-height:300px;display:flex;align-items:center;justify-content:center;
border:1px dashed #ff4d4f;border-radius:8px;color:#ff4d4f;">
<span>编辑器加载失败,请刷新重试</span>
</div>
`,
},
delay: 200,
timeout: 15000,
});
九、编辑器选型对比总结
9.1 综合对比表
scss
┌─────────────┬──────────┬─────────┬─────────┬─────────┬──────────┐
│ 维度 │wangEditor│ Tiptap │ Slate.js │ Lexical │ Quill │
├─────────────┼──────────┼─────────┼─────────┼─────────┼──────────┤
│ 框架 │ Vue优先 │ 框架无关 │ React │ React │ 框架无关 │
│ 上手难度 │ ⭐⭐ │ ⭐⭐⭐ │ ⭐⭐⭐⭐⭐ │ ⭐⭐⭐⭐ │ ⭐⭐ │
│ 可扩展性 │ ⭐⭐⭐ │ ⭐⭐⭐⭐⭐ │ ⭐⭐⭐⭐⭐ │ ⭐⭐⭐⭐⭐ │ ⭐⭐⭐ │
│ 开箱即用 │ ⭐⭐⭐⭐⭐ │ ⭐⭐⭐⭐ │ ⭐⭐ │ ⭐⭐⭐ │ ⭐⭐⭐⭐ │
│ 协同编辑 │ ✗ │ ✓ (Yjs) │ 需自实现 │ 需自实现 │ 需自实现 │
│ 大文档性能 │ 一般 │ 好 │ 好 │ 最好 │ 一般 │
│ Bundle Size │ ~200KB │ ~150KB │ ~100KB │ ~60KB │ ~40KB │
│ TypeScript │ ✓ │ ✓ │ ✓ │ ✓ │ ✗ │
│ 中文文档 │ ⭐⭐⭐⭐⭐ │ ⭐⭐ │ ⭐⭐ │ ⭐⭐ │ ⭐⭐⭐ │
│ 社区活跃度 │ 中 │ 高 │ 高 │ 高 │ 中(维护少)│
│ 学习价值 │ ⭐⭐ │ ⭐⭐⭐⭐ │ ⭐⭐⭐⭐⭐ │ ⭐⭐⭐⭐ │ ⭐⭐ │
└─────────────┴──────────┴─────────┴─────────┴─────────┴──────────┘
9.2 选型决策树
markdown
你的需求是什么?
│
├── 管理后台 / 内部系统 / 快速交付
│ ├── Vue 项目 → wangEditor ✅
│ └── React 项目 → Quill(简单)或 Lexical(完整)
│
├── 内容创作平台(博客、CMS、知识库)
│ ├── 需要协同编辑 → Tiptap + Yjs ✅
│ ├── 不需要协同 → Tiptap 或 Slate.js
│ └── 类 Notion → Slate.js ✅(最灵活的Block模型)
│
├── 评论 / 反馈 / 轻量输入
│ └── Quill ✅(最小体积,功能够用)
│
├── 大型文档系统(万字+、高性能要求)
│ └── Lexical ✅(Meta出品,性能最优)
│
├── 面试 / 学习编辑器原理
│ └── Slate.js ✅(底层暴露最多,理解最深)
│
└── 需要在 Vue2/3 和 React 间迁移
└── 适配器模式 + Tiptap(Vue/React都支持) ✅
十、面试高频题
题目1:contentEditable 的原理和问题
markdown
Q: 为什么不直接用 contentEditable + execCommand?
A: 核心问题:
1. execCommand 已废弃,各浏览器行为不一致
- Chrome 按回车产生 <div>,Firefox 产生 <br>,Safari 产生 <div>
- 加粗可能产生 <b> 或 <strong>
2. 不可控的 DOM 变化
- 浏览器自行决定如何修改 DOM,无法预测结果
- 粘贴内容会引入大量脏标签
- 撤销/重做依赖浏览器原生实现,状态难以管理
3. 选区管理困难
- Selection/Range API 复杂且跨浏览器不一致
- 中文输入法(IME)的 compositionstart/end 处理
现代编辑器的解法:
- 维护独立的文档模型(Model)
- 自己控制 DOM 渲染(View)
- 拦截所有输入事件,通过模型操作来更新视图
- 这就是 ProseMirror / Slate 的核心理念
题目2:协同编辑的冲突解决
markdown
A: 主流方案对比:
1. OT(Operational Transformation)------ Google Docs 使用
* 将操作转换为 Operation(如 insert(pos, text), delete(pos, len))
* 当两个操作冲突时,通过转换函数调整:
示例:
初始文档:"abc"
用户A:insert(1, 'X') → "aXbc" 在位置1插入X
用户B:delete(2, 1) → "ab" 删除位置2的c
如果B先到服务端:
* B的操作:delete(2,1) → "ab"
* A的操作需要转换:insert(1,'X') 不受影响 → "aXb"
如果A先到服务端:
* A的操作:insert(1,'X') → "aXbc"
* B的操作需要转换:原本delete(2,1),但A在前面插了一个字符
→ 变成 delete(3,1) → "aXb"
最终结果一致:"aXb" ✓
缺点:
* 需要中心服务器排序
* 转换函数复杂度随操作类型指数增长
* 实现正确性极难保证
2. CRDT(Conflict-free Replicated Data Type)------ Yjs 使用
* 每个字符有全局唯一ID(clientId + clock)
* 字符之间通过"左邻居"和"右邻居"关系定位,而非绝对位置
* 无需中心服务器,天然最终一致
示例(Yjs的YATA算法):
初始文档:\[a(id:A1), b(id:A2), c(id:A3)]
用户A在a后插入X:
* X(id:B1, leftOrigin:A1, rightOrigin:A2)
用户B删除c:
* 标记 A3 为 tombstone(墓碑标记,不真正删除)
合并时:
* X 根据 leftOrigin=A1 找到位置 → 插在a后面
* A3 被标记删除 → 不显示
* 结果:"aXb" ✓
优点:
* 无需中心服务器
* 支持离线编辑后合并
* 数学上可证明最终一致性
缺点:
* 内存占用较大(tombstone不释放)
* 需要 GC 策略清理
题目3:编辑器文档模型设计
css
Q: 如何设计一个编辑器的文档模型?对比各框架的模型差异。
A:
1. ProseMirror / Tiptap 模型:
* 基于 Schema 约束的树形结构
* 强类型:每个节点类型必须在 Schema 中预定义
* 严格的嵌套规则(如 paragraph 只能在 doc 内,text 只能在 paragraph 内)
{
type: 'doc',
content: \[
{
type: 'heading',
attrs: { level: 2 },
content: \[
{ type: 'text', text: 'Hello', marks: \[{ type: 'bold' }] }
]
},
{
type: 'paragraph',
content: \[
{ type: 'text', text: 'World' }
]
}
]
}
特点:不可变(Immutable),每次修改产生新文档
2. Slate.js 模型:
* 自由的树形结构,无 Schema 约束
* 开发者自己定义什么是合法结构
\[ { type: 'heading', level: 2, children: \[ { text: 'Hello', bold: true } ]
},
{
type: 'paragraph',
children: \[
{ text: 'World' }
]
}
]
特点:格式信息直接放在 text 节点上(而非 marks)
3. Lexical 模型:
* 基于类继承的节点系统
* 每个节点是一个 class 实例,有自己的方法
class HeadingNode extends ElementNode {
\_\_tag: 'h1' | 'h2' | 'h3';
createDOM() { return document.createElement(this.\_\_tag); }
updateDOM(prevNode, dom) { /\* 增量更新 \*/ }
}
特点:双缓冲(current tree + pending tree),类似 React Fiber
4. Quill 模型(Delta):
* 扁平的操作序列,非树形结构
{
ops: \[
{ insert: 'Hello', attributes: { bold: true } },
{ insert: '\n', attributes: { header: 2 } },
{ insert: 'World\n' }
]
}
特点:简单直观,但难以表达复杂嵌套
题目4:如何防止XSS攻击
ini
Q: 富文本编辑器如何防XSS?只做前端过滤够不够?
A: 绝对不够。必须前后端双重过滤。
攻击向量:
1. 直接注入:<script>alert(1)</script>
2. 事件属性:<img src="转存失败,建议直接上传图片文件 x" onerror="alert(1)" alt="转存失败,建议直接上传图片文件">
3. 协议注入:<a href="javascript:alert(1)">click</a>
4. CSS注入:<div style="background:url(javascript:alert(1))">
5. 编码绕过:<img src="转存失败,建议直接上传图片文件 x" onerror="alert(1)" alt="转存失败,建议直接上传图片文件">
6. SVG注入:<svg onload="alert(1)">
7. 嵌套绕过:\<scr<script>ipt>alert(1)\</scr</script>ipt>
防御策略(纵深防御):
┌─────────────────────────────────────────────────┐
│ 第1层:编辑器层面 │
│ - 工具栏限制可插入的格式 │
│ - 粘贴时剥离危险标签(clipboard sanitize) │
│ - 不支持源码模式(或源码模式也过滤) │
├─────────────────────────────────────────────────┤
│ 第2层:提交前过滤 │
│ - DOMPurify 白名单过滤 │
│ - 只允许安全标签和属性 │
├─────────────────────────────────────────────────┤
│ 第3层:服务端过滤(最关键) │
│ - 永远不信任前端数据 │
│ - 服务端再次用 DOMPurify / sanitize-html 过滤 │
│ - 入库前过滤,而非读取时过滤 │
├─────────────────────────────────────────────────┤
│ 第4层:输出层 │
│ - CSP 头:Content-Security-Policy │
│ - 展示页面禁止 inline script │
│ - img 限制 src 域名白名单 │
├─────────────────────────────────────────────────┤
│ 第5层:HTTP安全头 │
│ - X-Content-Type-Options: nosniff │
│ - X-Frame-Options: SAMEORIGIN │
│ - Referrer-Policy: strict-origin │
└─────────────────────────────────────────────────┘
题目5:编辑器的选区(Selection)和光标管理
javascript
Q: 解释编辑器中选区的概念,为什么操作选区这么复杂?
A:
浏览器原生选区 API:
// 获取选区
const selection = window\.getSelection();
const range = selection.getRangeAt(0);
// Range 的四个关键属性
range.startContainer // 开始节点(DOM节点)
range.startOffset // 开始偏移
range.endContainer // 结束节点
range.endOffset // 结束偏移
// 选区 = 从 (startContainer, startOffset) 到 (endContainer, endOffset)
// 光标 = 选区折叠(start === end)
复杂的原因:
1. DOM 位置 ≠ 逻辑位置
<p>Hello <strong>World</strong></p>
光标在 "World" 的 W 前面,DOM表示可以是:
* (strong的firstChild, 0) → 在 strong 内部开头
* (p, 1) → 在 p 的第2个子节点前
* ("Hello "文本节点, 6) → 在文本末尾(视觉上一样)
三种DOM表示,视觉位置完全相同!
2. 中文输入法(IME)问题
输入 "中国" 的过程:
* compositionstart → 开始组合
* compositionupdate:"zh" → "zhong" → "中"
* compositionupdate:"中g" → "中gu" → "中国"
* compositionend → 确认输入 "中国"
在composition 期间,编辑器不能干预DOM,否则输入法会崩溃
React的受控组件模式 + 中文输入 = 灾难
→ Slate.js 为此做了大量兼容处理
3. 跨节点选区
选中 "llo Wor" 横跨两个DOM节点:
<p>He[llo <strong>Wor]ld</strong></p>
Range: startContainer="Hello ", startOffset=2
endContainer="World", endOffset=3
编辑器需要将DOM选区 ↔ 文档模型选区双向映射
各框架的解法:
ProseMirror: 用整数位置(pos)表示,整个文档是扁平的位置序列 <doc> <p> H e l l o </p> <p> W o r l d </p> </doc>
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
选区 = {from: 4, to: 11} 表示 "llo\nWor"
简单直观,但嵌套深时计算复杂
Slate: 用 Path + Offset
选区 = {
anchor: { path: \[0, 0], offset: 2 }, // 第1个块的第1个文本,偏移2
focus: { path: \[1, 0], offset: 3 }, // 第2个块的第1个文本,偏移3
}
Lexical: 用 NodeKey + Offset
选区 = {
anchor: { key: 'node\_3', offset: 2, type: 'text' },
focus: { key: 'node\_5', offset: 3, type: 'text' },
}
题目6:编辑器的历史记录(撤销/重做)实现
kotlin
Q: 如何实现编辑器的撤销/重做?
A: 两种主流方案:
方案1:快照栈(Snapshot Stack)------ 简单但内存大
undo栈: \[state1, state2, state3] ← 当前
redo栈: \[]
撤销:把 state3 移到 redo,恢复 state2
重做:把 state3 从 redo 移回 undo
优化:
* 用 Immutable.js 结构共享,减少内存
* 限制栈大小(如最多100步)
* 合并连续的相同操作(连续打字合并为一步)
方案2:操作栈(Operation Stack)------ 复杂但高效
undo栈: \[op1, op2, op3]
每个 operation 有对应的 inverse operation:
* insert("a", pos:5) 的逆操作 = delete(pos:5, len:1)
* setBold(true, range) 的逆操作 = setBold(false, range)
撤销:执行 op3 的逆操作
重做:重新执行 op3
ProseMirror 就是这种方案,通过 Transaction 的 mapping 来实现
合并策略(将连续小操作合并为一步):
* 连续字符输入 → 合并为一次文本插入
* 超过一定时间间隔(如500ms)→ 断开,作为新的一步
* 不同类型操作 → 断开
* 手动调用 addMark → 断开
代码示例(简化的快照栈):
class HistoryManager {
constructor(maxStack = 100) {
this.undoStack = \[];
this.redoStack = \[];
this.maxStack = maxStack;
this.lastPushTime = 0;
this.mergeInterval = 500; // 500ms内的操作合并
}
push(state) {
const now = Date.now();
// 合并策略:短时间内的操作合并
if (now - this.lastPushTime < this.mergeInterval && this.undoStack.length > 0) {
this.undoStack[this.undoStack.length - 1] = state;
} else {
this.undoStack.push(state);
if (this.undoStack.length > this.maxStack) {
this.undoStack.shift(); // 超出限制,丢弃最早的
}
}
// 新操作清空redo栈
this.redoStack = [];
this.lastPushTime = now;
}
undo() {
if (this.undoStack.length <= 1) return null; // 保留初始状态
const current = this.undoStack.pop();
this.redoStack.push(current);
return this.undoStack\[this.undoStack.length - 1]; // 返回上一个状态
}
redo() {
if (this.redoStack.length === 0) return null;
const state = this.redoStack.pop();
this.undoStack.push(state);
return state;
}
canUndo() { return this.undoStack.length > 1; }
canRedo() { return this.redoStack.length > 0; }
}
题目7:如何实现 @ 提及功能
javascript
// mention-plugin.js(以Tiptap为例)
import { Extension } from '@tiptap/core';
import { Plugin, PluginKey } from 'prosemirror-state';
import { Decoration, DecorationSet } from 'prosemirror-view';
import Suggestion from '@tiptap/suggestion';
/**
* @ 提及功能实现思路:
*
* 1. 检测输入:监听到 @ 字符时激活
* 2. 搜索过滤:根据 @ 后的文字搜索用户列表
* 3. 弹出面板:在光标位置显示候选列表
* 4. 插入节点:选择后插入特殊的 mention 节点
* 5. 渲染展示:mention 节点渲染为带样式的标签
*/
// Mention Node 定义
const MentionNode = Node.create({
name: 'mention',
group: 'inline',
inline: true,
selectable: false,
atom: true, // 原子节点,不可编辑内部
addAttributes() {
return {
id: { default: null },
label: { default: null },
};
},
parseHTML() {
return [{ tag: 'span[data-mention]' }];
},
renderHTML({ node }) {
return [
'span',
{
'data-mention': '',
'data-mention-id': node.attrs.id,
class: 'mention-tag',
style: 'color:#1890ff;background:#e6f7ff;padding:0 4px;border-radius:2px;',
},
`@${node.attrs.label}`,
];
},
// Suggestion 配置
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
char: '@', // 触发字符
// 当输入 @ 后:
items: async ({ query }) => {
// query 是 @ 后面输入的文字
const users = await fetchUsers(query);
return users.slice(0, 5); // 最多显示5个
},
render: () => {
let component; // 弹出面板组件实例
return {
onStart: (props) => {
// 在光标位置创建弹出面板
component = createMentionDropdown(props);
},
onUpdate: (props) => {
component.updateProps(props);
},
onKeyDown: (props) => {
// 方向键 / 回车 交给面板处理
if (props.event.key === 'Escape') {
component.destroy();
return true;
}
return component.onKeyDown(props);
},
onExit: () => {
component?.destroy();
},
};
},
command: ({ editor, range, props }) => {
// 选择用户后,替换 @xxx 为 mention 节点
editor
.chain()
.focus()
.insertContentAt(range, [
{
type: 'mention',
attrs: { id: props.id, label: props.name },
},
{ type: 'text', text: ' ' }, // 后面加个空格
])
.run();
},
}),
];
},
});
题目8:编辑器性能优化的具体措施
less
Q: 编辑器在大文档(10000+字,100+图片)场景下如何优化?
A: 分层优化策略:
┌─ 渲染层优化 ─────────────────────────────────┐
│ │
│ 1. 虚拟渲染:只渲染可视区域的块 │
│ - Lexical 原生支持 │
│ - Slate 需要自己实现 │
│ - Tiptap/PM 的 NodeView 可以做懒渲染 │
│ │
│ 2. 图片懒加载: │
│ - IntersectionObserver │
│ - 缩略图 + 点击加载原图 │
│ - 渐进式 JPEG │
│ │
│ 3. 减少重排(Reflow): │
│ - 固定编辑器高度,内部滚动 │
│ - 避免动态改变工具栏布局 │
│ - 使用 transform 代替 top/left │
│ │
├─ 模型层优化 ─────────────────────────────────┤
│ │
│ 4. 最小化更新: │
│ - ProseMirror: Transaction + Mapping │
│ - Slate: 操作级 normalize │
│ - Lexical: 双缓冲 + 增量 reconcile │
│ │
│ 5. 历史记录优化: │
│ - 限制 undo 栈大小 │
│ - 合并连续操作 │
│ - 超时快照压缩 │
│ │
│ 6. onChange 防抖: │
│ - 不要每次按键都序列化整个文档 │
│ - 用 requestIdleCallback 延迟处理 │
│ - 区分"内容变化"和"选区变化" │
│ │
├─ 网络层优化 ─────────────────────────────────┤
│ │
│ 7. 图片上传优化: │
│ - 前端压缩后再上传 │
│ - 并发控制(最多3张同时上传) │
│ - 失败重试 + 断点续传 │
│ │
│ 8. 自动保存优化: │
│ - 增量保存(只传diff) │
│ - 节流保存(5秒内最多1次) │
│ - 用 Web Worker 做序列化 │
│ │
│ 9. 协同编辑优化: │
│ - Awareness 消息合并 │
│ - 离线操作队列 │
│ - 连接状态回退(WS → SSE → Polling) │
│ │
├─ 加载优化 ───────────────────────────────────┤
│ │
│ 10. 代码分割: │
│ - 编辑器组件异步加载 │
│ - 扩展按需注册 │
│ - 工具栏代码懒加载 │
│ │
│ 11. 初始化优化: │
│ - 先显示骨架屏 │
│ - 编辑器初始化放到 requestIdleCallback │
│ - 大文档分块加载(先加载前2屏) │
│ │
└───────────────────────────────────────────────┘
javascript
// 实际的性能优化代码示例
// 1. onChange 防抖 + 区分变化类型
function createOptimizedOnChange(editor, callback) {
let timer = null;
let pendingOps = [];
return ({ editorState, dirtyElements, dirtyLeaves, prevEditorState, tags }) => {
// Lexical 的精确判断
if (dirtyElements.size === 0 && dirtyLeaves.size === 0) {
return; // 纯选区变化,忽略
}
// 收集变化
pendingOps.push({ dirtyElements, dirtyLeaves, tags });
// 防抖
clearTimeout(timer);
timer = setTimeout(() => {
callback({
editorState,
changes: pendingOps,
changeCount: pendingOps.length,
});
pendingOps = [];
}, 300);
};
}
// 2. 用 Web Worker 做序列化(避免阻塞主线程)
// serializer.worker.js
self.onmessage = function (e) {
const { type, data } = e.data;
switch (type) {
case 'toHTML': {
// 在 Worker 中执行耗时的序列化
const html = slateNodesToHTML(data.nodes);
self.postMessage({ type: 'htmlResult', html });
break;
}
case 'toMarkdown': {
const md = nodesToMarkdown(data.nodes);
self.postMessage({ type: 'markdownResult', md });
break;
}
}
};
// 主线程
const worker = new Worker('./serializer.worker.js');
function serializeAsync(nodes) {
return new Promise((resolve) => {
worker.onmessage = (e) => {
if (e.data.type === 'htmlResult') {
resolve(e.data.html);
}
};
worker.postMessage({ type: 'toHTML', data: { nodes } });
});
}
// 3. 图片上传并发控制
class UploadQueue {
constructor(maxConcurrency = 3) {
this.max = maxConcurrency;
this.running = 0;
this.queue = [];
}
add(task) {
return new Promise((resolve, reject) => {
this.queue.push({ task, resolve, reject });
this.run();
});
}
async run() {
while (this.running < this.max && this.queue.length > 0) {
const { task, resolve, reject } = this.queue.shift();
this.running++;
try {
const result = await task();
resolve(result);
} catch (err) {
reject(err);
} finally {
this.running--;
this.run();
}
}
}
}
// 使用
const uploadQueue = new UploadQueue(3);
async function handleMultipleImages(files) {
const tasks = Array.from(files).map((file) => () => uploadService.upload(file));
const results = await Promise.all(tasks.map((t) => uploadQueue.add(t)));
return results; // [{ url, width, height }, ...]
}
题目9:编辑器测试策略
php
Q: 富文本编辑器如何做自动化测试?
A:
┌─ 单元测试 ────────────────────────────────────┐
│ │
│ 测试文档模型的操作(不涉及DOM): │
│ - 插入文本后文档结构是否正确 │
│ - toggleBold 后 marks 是否正确 │
│ - 列表缩进后嵌套层级是否正确 │
│ - 序列化/反序列化是否可逆 │
│ │
│ 工具:Jest / Vitest │
│ │
│ // 示例(Slate.js) │
│ test('toggleBold adds bold mark', () => { │
│ const editor = createEditor(); │
│ editor.children = [ │
│ { type:'paragraph', │
│ children: [{ text: 'Hello' }] } │
│ ]; │
│ Transforms.select(editor, { │
│ anchor: { path:[0,0], offset:0 }, │
│ focus: { path:[0,0], offset:5 }, │
│ }); │
│ toggleMark(editor, 'bold'); │
│ expect(editor.children[0].children[0].bold) │
│ .toBe(true); │
│ }); │
│ │
├─ 集成测试 ────────────────────────────────────┤
│ │
│ 测试编辑器在真实DOM中的行为: │
│ - 工具栏按钮点击后编辑器内容变化 │
│ - 快捷键是否生效 │
│ - 粘贴HTML后内容是否正确 │
│ │
│ 工具:Cypress / Playwright │
│ │
│ // Cypress 示例 │
│ it('bold shortcut works', () => { │
│ cy.get('.editor').type('Hello'); │
│ cy.get('.editor').type('{selectall}'); │
│ cy.get('.editor').type('{ctrl+b}'); │
│ cy.get('.editor strong') │
│ .should('contain', 'Hello'); │
│ }); │
│ │
├─ E2E 测试 ───────────────────────────────────┤
│ │
│ 测试完整用户流程: │
│ - 创建文章 → 编辑 → 保存 → 重新打开查看 │
│ - 多人协同编辑 → 内容最终一致 │
│ - 图片上传 → 保存 → 展示页查看 │
│ │
│ 工具:Playwright(推荐,支持多浏览器) │
│ │
├─ 视觉回归测试 ────────────────────────────────┤
│ │
│ 编辑器渲染样式是否正确: │
│ - 截图对比 │
│ - 不同浏览器渲染一致性 │
│ │
│ 工具:Percy / Chromatic / reg-cli │
│ │
└────────────────────────────────────────────────┘