接续:项目结构 + 自动保存 + 内容展示 + 完整面试答题模板
十一、完整项目结构参考(续)
csharp
src/
├── editor/
│ ├── interface.js # 编辑器统一接口定义
│ ├── factory.js # 工厂函数
│ │
│ ├── adapters/ # 适配器层
│ │ ├── tiptap-adapter.js
│ │ ├── wangeditor-adapter.js
│ │ ├── slate-adapter.js
│ │ └── quill-adapter.js
│ │
│ ├── extensions/ # 自定义扩展
│ │ ├── mention/
│ │ │ ├── MentionNode.js # @提及节点
│ │ │ ├── MentionList.vue # 候选人列表弹窗
│ │ │ └── suggestion.js # 触发逻辑
│ │ │
│ │ ├── image/
│ │ │ ├── ImageNode.js # 图片节点(支持拖拽缩放)
│ │ │ ├── ImageUploadPlugin.js # 粘贴/拖拽上传
│ │ │ └── ImageResizer.vue # 缩放手柄组件
│ │ │
│ │ ├── video/
│ │ │ ├── VideoNode.js
│ │ │ └── VideoEmbed.vue
│ │ │
│ │ ├── code-block/
│ │ │ ├── CodeBlockNode.js # 代码块(+语法高亮)
│ │ │ └── LanguageSelect.vue
│ │ │
│ │ ├── table/
│ │ │ ├── TableNode.js
│ │ │ └── TableMenu.vue # 表格右键菜单
│ │ │
│ │ └── slash-command/
│ │ ├── SlashCommand.js # / 命令面板
│ │ └── CommandList.vue
│ │
│ ├── plugins/ # 通用插件
│ │ ├── auto-save.js # 自动保存
│ │ ├── word-count.js # 字数统计
│ │ ├── paste-handler.js # 粘贴处理
│ │ ├── drag-handle.js # 块级拖拽
│ │ ├── placeholder.js # 占位符
│ │ └── read-time.js # 阅读时间估算
│ │
│ ├── toolbar/ # 工具栏
│ │ ├── Toolbar.vue # 固定工具栏
│ │ ├── BubbleMenu.vue # 浮动工具栏(选中文字时出现)
│ │ ├── FloatingMenu.vue # 空行菜单(新行时出现)
│ │ └── toolbar-config.js # 工具栏配置
│ │
│ └── collab/ # 协同编辑
│ ├── CollabProvider.js # Yjs Provider 封装
│ ├── CursorPlugin.js # 协同光标
│ ├── AwarenessWidget.vue # 在线用户显示
│ └── collab-server.js # 服务端
│
├── components/
│ ├── RichEditor.vue # 通用编辑器组件(业务组件直接使用)
│ ├── ReadonlyRenderer.vue # 只读展示组件
│ └── EditorSkeleton.vue # 加载骨架屏
│
├── services/
│ ├── upload.js # 上传服务
│ └── article.js # 文章 CRUD 服务
│
├── utils/
│ ├── sanitize.js # XSS 过滤
│ ├── html-to-markdown.js # HTML ↔ Markdown 转换
│ └── delta-diff.js # 内容差异对比
│
└── styles/
├── editor-base.scss # 编辑器基础样式
├── editor-content.scss # 内容区域样式(编辑和展示共用)
└── editor-theme.scss # 主题变量
十二、自动保存完整实现
javascript
// plugins/auto-save.js
/**
* 自动保存插件
*
* 功能:
* 1. 内容变化后自动保存(防抖)
* 2. 定时保存(兜底)
* 3. 离开页面前保存
* 4. 保存失败重试
* 5. 本地草稿箱(localStorage 兜底)
* 6. 增量保存(只传变化部分)
* 7. 保存状态指示
*/
export class AutoSavePlugin {
constructor(options = {}) {
this.options = {
// 防抖延迟(内容变化后多久触发保存)
debounceDelay: 3000,
// 定时保存间隔(兜底)
intervalDelay: 30000,
// 保存函数(由业务传入)
saveFn: null,
// 文档唯一标识
docId: null,
// 最大重试次数
maxRetry: 3,
// 重试延迟
retryDelay: 2000,
// 本地缓存 key 前缀
cachePrefix: 'editor_draft_',
// 状态变化回调
onStatusChange: null,
...options,
};
// 内部状态
this.state = {
status: 'saved', // saved | saving | unsaved | error
lastSavedAt: null,
lastContent: null,
retryCount: 0,
dirty: false,
};
this.debounceTimer = null;
this.intervalTimer = null;
this.destroyed = false;
}
/**
* 初始化(在编辑器挂载后调用)
*/
init(getContent) {
this.getContent = getContent;
this.state.lastContent = getContent();
// 启动定时保存
this.intervalTimer = setInterval(() => {
if (this.state.dirty && !this.destroyed) {
this.save();
}
}, this.options.intervalDelay);
// 页面离开前保存
this._beforeUnload = (e) => {
if (this.state.dirty) {
// 同步保存到 localStorage
this.saveToLocal();
// 提示用户
e.preventDefault();
e.returnValue = '您有未保存的更改,确定要离开吗?';
return e.returnValue;
}
};
window.addEventListener('beforeunload', this._beforeUnload);
// 页面可见性变化时保存(切tab时)
this._visibilityChange = () => {
if (document.hidden && this.state.dirty) {
this.save();
}
};
document.addEventListener('visibilitychange', this._visibilityChange);
// 检查是否有未恢复的本地草稿
this.checkLocalDraft();
return this;
}
/**
* 内容变化时调用(由编辑器的onChange触发)
*/
onChange() {
this.state.dirty = true;
this.updateStatus('unsaved');
// 防抖保存
clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(() => {
this.save();
}, this.options.debounceDelay);
// 每次变化都保存到localStorage(同步,快速)
this.saveToLocal();
}
/**
* 执行保存
*/
async save() {
if (this.destroyed) return;
if (!this.state.dirty) return;
if (this.state.status === 'saving') return; // 避免重复保存
const content = this.getContent();
// 内容没有实际变化
if (content === this.state.lastContent) {
this.state.dirty = false;
this.updateStatus('saved');
return;
}
this.updateStatus('saving');
try {
if (typeof this.options.saveFn !== 'function') {
throw new Error('saveFn is not configured');
}
await this.options.saveFn({
docId: this.options.docId,
content,
// 增量信息(可选,后端根据这个做diff存储)
previousContent: this.state.lastContent,
timestamp: Date.now(),
});
// 保存成功
this.state.lastContent = content;
this.state.dirty = false;
this.state.retryCount = 0;
this.state.lastSavedAt = new Date();
this.updateStatus('saved');
// 清除本地草稿(服务端已保存)
this.clearLocalDraft();
console.log(`[AutoSave] 保存成功 ${this.state.lastSavedAt.toLocaleTimeString()}`);
} catch (error) {
console.error('[AutoSave] 保存失败:', error);
// 重试逻辑
if (this.state.retryCount < this.options.maxRetry) {
this.state.retryCount++;
this.updateStatus('error');
console.log(
`[AutoSave] ${this.options.retryDelay}ms 后重试 (${this.state.retryCount}/${this.options.maxRetry})`
);
setTimeout(() => this.save(), this.options.retryDelay * this.state.retryCount);
} else {
this.updateStatus('error');
// 确保本地有备份
this.saveToLocal();
console.error('[AutoSave] 达到最大重试次数,内容已保存到本地');
}
}
}
/**
* 保存到 localStorage(兜底方案)
*/
saveToLocal() {
try {
const key = this.options.cachePrefix + this.options.docId;
const data = {
content: this.getContent(),
timestamp: Date.now(),
docId: this.options.docId,
};
localStorage.setItem(key, JSON.stringify(data));
} catch (e) {
// localStorage 可能满了
console.warn('[AutoSave] localStorage 保存失败:', e.message);
// 尝试清理旧草稿
this.cleanOldDrafts();
}
}
/**
* 检查本地草稿
*/
checkLocalDraft() {
try {
const key = this.options.cachePrefix + this.options.docId;
const stored = localStorage.getItem(key);
if (!stored) return null;
const data = JSON.parse(stored);
const age = Date.now() - data.timestamp;
// 超过7天的草稿忽略
if (age > 7 * 24 * 60 * 60 * 1000) {
localStorage.removeItem(key);
return null;
}
return data;
} catch {
return null;
}
}
/**
* 恢复本地草稿
*/
recoverLocalDraft() {
const draft = this.checkLocalDraft();
if (draft) {
return draft.content;
}
return null;
}
/**
* 清除本地草稿
*/
clearLocalDraft() {
const key = this.options.cachePrefix + this.options.docId;
localStorage.removeItem(key);
}
/**
* 清理旧草稿(localStorage 空间不足时)
*/
cleanOldDrafts() {
const prefix = this.options.cachePrefix;
const keys = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key.startsWith(prefix)) {
try {
const data = JSON.parse(localStorage.getItem(key));
keys.push({ key, timestamp: data.timestamp });
} catch {
localStorage.removeItem(key); // 损坏的数据直接删
}
}
}
// 按时间排序,删除最旧的一半
keys.sort((a, b) => a.timestamp - b.timestamp);
const deleteCount = Math.ceil(keys.length / 2);
keys.slice(0, deleteCount).forEach(({ key }) => {
localStorage.removeItem(key);
});
}
/**
* 更新状态
*/
updateStatus(status) {
this.state.status = status;
this.options.onStatusChange?.({
status,
lastSavedAt: this.state.lastSavedAt,
retryCount: this.state.retryCount,
});
}
/**
* 手动保存(用户点击保存按钮)
*/
async forceSave() {
this.state.dirty = true;
clearTimeout(this.debounceTimer);
await this.save();
}
/**
* 获取当前状态
*/
getStatus() {
return { ...this.state };
}
/**
* 销毁
*/
destroy() {
this.destroyed = true;
// 最后保存一次
if (this.state.dirty) {
this.saveToLocal();
}
clearTimeout(this.debounceTimer);
clearInterval(this.intervalTimer);
window.removeEventListener('beforeunload', this._beforeUnload);
document.removeEventListener('visibilitychange', this._visibilityChange);
}
}
在 Vue 组件中集成:
vue
<template>
<div class="editor-page">
<!-- 保存状态指示器 -->
<div class="save-status">
<span v-if="saveStatus === 'saved'" class="status-saved">
✓ 已保存
<small v-if="lastSavedAt">{{ formatTime(lastSavedAt) }}</small>
</span>
<span v-else-if="saveStatus === 'saving'" class="status-saving">
<i class="loading-icon" /> 保存中...
</span>
<span v-else-if="saveStatus === 'unsaved'" class="status-unsaved">
● 未保存
</span>
<span v-else-if="saveStatus === 'error'" class="status-error">
✗ 保存失败
<button @click="retrySave">重试</button>
</span>
</div>
<!-- 草稿恢复提示 -->
<div v-if="hasDraft" class="draft-banner">
<span>检测到未保存的草稿({{ formatTime(draftTime) }})</span>
<button @click="recoverDraft">恢复草稿</button>
<button @click="dismissDraft">忽略</button>
</div>
<!-- 编辑器 -->
<RichEditor
ref="editor"
v-model="content"
@change="onContentChange"
/>
<!-- 手动保存按钮 -->
<div class="actions">
<button @click="handleSave" :disabled="saveStatus === 'saving'">
{{ saveStatus === 'saving' ? '保存中...' : '保存' }}
</button>
<span class="shortcut-hint">Ctrl+S</span>
</div>
</div>
</template>
<script>
import RichEditor from '@/components/RichEditor.vue';
import { AutoSavePlugin } from '@/editor/plugins/auto-save';
import { saveArticle } from '@/services/article';
export default {
components: { RichEditor },
props: {
articleId: { type: String, required: true },
},
data() {
return {
content: '',
saveStatus: 'saved',
lastSavedAt: null,
hasDraft: false,
draftTime: null,
autoSave: null,
};
},
async mounted() {
// 1. 加载文章内容
await this.loadArticle();
// 2. 初始化自动保存
this.autoSave = new AutoSavePlugin({
docId: this.articleId,
debounceDelay: 3000,
intervalDelay: 30000,
maxRetry: 3,
saveFn: async ({ docId, content }) => {
await saveArticle(docId, { content });
},
onStatusChange: ({ status, lastSavedAt }) => {
this.saveStatus = status;
this.lastSavedAt = lastSavedAt;
},
}).init(() => this.content);
// 3. 检查本地草稿
const draft = this.autoSave.checkLocalDraft();
if (draft && draft.timestamp > this.serverTimestamp) {
this.hasDraft = true;
this.draftTime = new Date(draft.timestamp);
}
// 4. Ctrl+S 快捷键
this._keydown = (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
this.handleSave();
}
};
document.addEventListener('keydown', this._keydown);
},
methods: {
async loadArticle() {
const res = await fetch(`/api/articles/${this.articleId}`);
const data = await res.json();
this.content = data.content;
this.serverTimestamp = data.updatedAt;
},
onContentChange() {
this.autoSave?.onChange();
},
async handleSave() {
await this.autoSave?.forceSave();
},
retrySave() {
this.autoSave.state.retryCount = 0;
this.autoSave.save();
},
recoverDraft() {
const draftContent = this.autoSave.recoverLocalDraft();
if (draftContent) {
this.content = draftContent;
this.hasDraft = false;
}
},
dismissDraft() {
this.autoSave.clearLocalDraft();
this.hasDraft = false;
},
formatTime(date) {
if (!date) return '';
const d = new Date(date);
return `${d.getHours()}:${String(d.getMinutes()).padStart(2, '0')}`;
},
},
beforeDestroy() {
this.autoSave?.destroy();
document.removeEventListener('keydown', this._keydown);
},
};
</script>
<style scoped>
.save-status {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
font-size: 13px;
}
.status-saved { color: #52c41a; }
.status-saving { color: #1890ff; }
.status-unsaved { color: #faad14; }
.status-error { color: #ff4d4f; }
.loading-icon {
display: inline-block;
width: 12px;
height: 12px;
border: 2px solid #1890ff;
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.draft-banner {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
background: #fffbe6;
border: 1px solid #ffe58f;
border-radius: 4px;
margin: 8px 16px;
font-size: 14px;
}
</style>
十三、内容安全展示组件
vue
<!-- components/ReadonlyRenderer.vue -->
<!-- 用于文章详情页、评论展示等,安全渲染 HTML -->
<template>
<div
class="rich-content-renderer"
:class="[`theme-${theme}`, { 'content-compact': compact }]"
v-html="sanitizedHtml"
/>
</template>
<script>
import { sanitizeForDisplay } from '@/utils/sanitize';
export default {
name: 'ReadonlyRenderer',
props: {
html: { type: String, default: '' },
theme: {
type: String,
default: 'default', // default | github | notion
},
compact: { type: Boolean, default: false },
// 是否允许图片点击放大
imagePreview: { type: Boolean, default: true },
// 是否自动添加锚点
autoAnchor: { type: Boolean, default: true },
},
computed: {
sanitizedHtml() {
let html = sanitizeForDisplay(this.html);
// 处理标题锚点
if (this.autoAnchor) {
html = this.addHeadingAnchors(html);
}
// 外链处理
html = this.processLinks(html);
// 代码块处理
html = this.processCodeBlocks(html);
return html;
},
},
mounted() {
if (this.imagePreview) {
this.setupImagePreview();
}
// 代码块添加复制按钮
this.setupCodeCopy();
},
updated() {
if (this.imagePreview) {
this.setupImagePreview();
}
this.setupCodeCopy();
},
methods: {
/**
* 标题添加锚点
*/
addHeadingAnchors(html) {
const div = document.createElement('div');
div.innerHTML = html;
const headings = div.querySelectorAll('h1,h2,h3,h4,h5,h6');
headings.forEach((heading, index) => {
const text = heading.textContent.trim();
const id = `heading-${index}-${encodeURIComponent(text)}`;
heading.id = id;
heading.style.position = 'relative';
// 添加锚点链接
const anchor = document.createElement('a');
anchor.href = `#${id}`;
anchor.className = 'heading-anchor';
anchor.textContent = '#';
anchor.setAttribute('aria-hidden', 'true');
heading.prepend(anchor);
});
return div.innerHTML;
},
/**
* 外链添加安全属性 + 图标
*/
processLinks(html) {
const div = document.createElement('div');
div.innerHTML = html;
const links = div.querySelectorAll('a[href]');
links.forEach((link) => {
const href = link.getAttribute('href');
// 外链
if (href && (href.startsWith('http') || href.startsWith('//'))) {
link.setAttribute('target', '_blank');
link.setAttribute('rel', 'noopener noreferrer nofollow');
link.classList.add('external-link');
}
});
return div.innerHTML;
},
/**
* 代码块添加语言标签
*/
processCodeBlocks(html) {
const div = document.createElement('div');
div.innerHTML = html;
const codeBlocks = div.querySelectorAll('pre > code');
codeBlocks.forEach((code) => {
const pre = code.parentElement;
pre.classList.add('code-block-wrapper');
// 从 class 中提取语言
const langClass = Array.from(code.classList).find((c) => c.startsWith('language-'));
const lang = langClass ? langClass.replace('language-', '') : 'text';
// 添加语言标签
const langLabel = document.createElement('span');
langLabel.className = 'code-lang-label';
langLabel.textContent = lang;
pre.insertBefore(langLabel, code);
});
return div.innerHTML;
},
/**
* 图片点击预览
*/
setupImagePreview() {
const images = this.$el.querySelectorAll('img');
images.forEach((img) => {
img.style.cursor = 'zoom-in';
img.removeEventListener('click', this._imageClickHandler);
img.addEventListener('click', this._imageClickHandler = () => {
this.showImagePreview(img.src);
});
});
},
/**
* 图片预览弹窗(简化版)
*/
showImagePreview(src) {
const overlay = document.createElement('div');
overlay.style.cssText = `
position:fixed; top:0; left:0; right:0; bottom:0;
background:rgba(0,0,0,0.8); display:flex;
align-items:center; justify-content:center;
z-index:9999; cursor:zoom-out;
`;
const img = document.createElement('img');
img.src = src;
img.style.cssText = `
max-width:90vw; max-height:90vh;
object-fit:contain; border-radius:4px;
`;
overlay.appendChild(img);
overlay.addEventListener('click', () => overlay.remove());
document.addEventListener('keydown', function handler(e) {
if (e.key === 'Escape') {
overlay.remove();
document.removeEventListener('keydown', handler);
}
});
document.body.appendChild(overlay);
},
/**
* 代码块复制按钮
*/
setupCodeCopy() {
const codeBlocks = this.$el.querySelectorAll('pre');
codeBlocks.forEach((pre) => {
if (pre.querySelector('.code-copy-btn')) return; // 已添加
const btn = document.createElement('button');
btn.className = 'code-copy-btn';
btn.textContent = '复制';
btn.addEventListener('click', async () => {
const code = pre.querySelector('code');
const text = code ? code.textContent : pre.textContent;
try {
await navigator.clipboard.writeText(text);
btn.textContent = '已复制 ✓';
setTimeout(() => {
btn.textContent = '复制';
}, 2000);
} catch {
// 降级方案
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.cssText = 'position:fixed;opacity:0;';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
btn.textContent = '已复制 ✓';
setTimeout(() => {
btn.textContent = '复制';
}, 2000);
}
});
pre.style.position = 'relative';
pre.appendChild(btn);
});
},
},
beforeDestroy() {
// 清理事件监听
const images = this.$el.querySelectorAll('img');
images.forEach((img) => {
img.removeEventListener('click', this._imageClickHandler);
});
},
};
</script>
<style lang="scss">
.rich-content-renderer {
font-size: 16px;
line-height: 1.8;
color: #333;
word-wrap: break-word;
overflow-wrap: break-word;
// ===== 标题 =====
h1, h2, h3, h4, h5, h6 {
margin-top: 1.5em;
margin-bottom: 0.5em;
font-weight: 600;
line-height: 1.4;
&:first-child { margin-top: 0; }
}
h1 { font-size: 1.75em; }
h2 { font-size: 1.5em; border-bottom: 1px solid #eee; padding-bottom: 0.3em; }
h3 { font-size: 1.25em; }
// 标题锚点
.heading-anchor {
position: absolute;
left: -1.2em;
color: #1890ff;
text-decoration: none;
opacity: 0;
transition: opacity 0.2s;
font-weight: normal;
}
h1:hover .heading-anchor,
h2:hover .heading-anchor,
h3:hover .heading-anchor {
opacity: 1;
}
// ===== 段落 =====
p {
margin: 0.8em 0;
&:first-child { margin-top: 0; }
&:last-child { margin-bottom: 0; }
}
// ===== 列表 =====
ul, ol {
padding-left: 1.5em;
margin: 0.5em 0;
}
li {
margin: 0.25em 0;
}
// ===== 引用 =====
blockquote {
margin: 1em 0;
padding: 0.5em 1em;
border-left: 4px solid #1890ff;
background: #f6f8fa;
color: #555;
border-radius: 0 4px 4px 0;
p { margin: 0.3em 0; }
}
// ===== 代码 =====
code {
padding: 0.15em 0.4em;
background: #f0f0f0;
border-radius: 3px;
font-family: 'Fira Code', 'Consolas', monospace;
font-size: 0.9em;
color: #d56161;
}
pre {
margin: 1em 0;
padding: 16px;
background: #282c34;
border-radius: 8px;
overflow-x: auto;
position: relative;
code {
padding: 0;
background: none;
color: #abb2bf;
font-size: 14px;
line-height: 1.6;
border-radius: 0;
}
// 语言标签
.code-lang-label {
position: absolute;
top: 8px;
right: 60px;
font-size: 12px;
color: #636d83;
text-transform: uppercase;
user-select: none;
}
// 复制按钮
.code-copy-btn {
position: absolute;
top: 8px;
right: 8px;
padding: 2px 10px;
font-size: 12px;
color: #abb2bf;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 4px;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s, background 0.2s;
&:hover {
background: rgba(255, 255, 255, 0.2);
}
}
&:hover .code-copy-btn {
opacity: 1;
}
}
// ===== 图片 =====
img {
max-width: 100%;
height: auto;
border-radius: 4px;
margin: 0.5em 0;
transition: transform 0.2s;
&:hover {
transform: scale(1.01);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
}
// ===== 表格 =====
table {
width: 100%;
border-collapse: collapse;
margin: 1em 0;
font-size: 14px;
th, td {
border: 1px solid #ddd;
padding: 10px 14px;
text-align: left;
}
th {
background: #f6f8fa;
font-weight: 600;
color: #333;
}
tr:nth-child(even) {
background: #fafbfc;
}
tr:hover {
background: #f0f7ff;
}
}
// ===== 链接 =====
a {
color: #1890ff;
text-decoration: none;
border-bottom: 1px solid transparent;
transition: border-color 0.2s;
&:hover {
border-bottom-color: #1890ff;
}
// 外链图标
&.external-link::after {
content: '↗';
font-size: 0.75em;
margin-left: 2px;
vertical-align: super;
}
}
// ===== 分割线 =====
hr {
border: none;
border-top: 1px solid #e8e8e8;
margin: 2em 0;
}
// ===== 提及 =====
.mention-tag {
color: #1890ff;
background: #e6f7ff;
padding: 1px 4px;
border-radius: 2px;
cursor: pointer;
&:hover {
background: #bae7ff;
}
}
// ===== 删除线 =====
del, s {
color: #999;
text-decoration: line-through;
}
// ===== 标记高亮 =====
mark {
background: #ffffb8;
padding: 1px 2px;
border-radius: 2px;
}
// ===== 紧凑模式(用于评论等场景) =====
&.content-compact {
font-size: 14px;
line-height: 1.6;
h1, h2, h3 { font-size: 1.15em; margin-top: 0.8em; }
p { margin: 0.4em 0; }
blockquote { padding: 0.3em 0.8em; margin: 0.5em 0; }
pre { padding: 12px; margin: 0.5em 0; }
img { max-height: 400px; object-fit: contain; }
}
// ===== GitHub 主题 =====
&.theme-github {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, sans-serif;
color: #24292f;
h2 { border-bottom: 1px solid #d0d7de; }
blockquote { border-left-color: #d0d7de; color: #656d76; }
code { background: rgba(175, 184, 193, 0.2); color: inherit; }
}
// ===== Notion 主题 =====
&.theme-notion {
font-family: 'Segoe UI', Helvetica, sans-serif;
color: rgb(55, 53, 47);
h1, h2, h3 { font-weight: 700; }
blockquote {
border-left-color: #000;
background: transparent;
font-size: 1.1em;
}
code { background: rgba(135, 131, 120, 0.15); color: #eb5757; }
}
}
</style>
十四、目录提取组件
vue
<!-- components/TableOfContents.vue -->
<!-- 从编辑器内容中提取标题,生成可点击的目录 -->
<template>
<nav class="toc" v-if="headings.length > 0">
<h4 class="toc-title">目录</h4>
<ul class="toc-list">
<li
v-for="(heading, index) in headings"
:key="index"
:class="[
`toc-level-${heading.level}`,
{ active: activeId === heading.id }
]"
>
<a
:href="`#${heading.id}`"
@click.prevent="scrollToHeading(heading.id)"
:title="heading.text"
>
{{ heading.text }}
</a>
</li>
</ul>
</nav>
</template>
<script>
export default {
name: 'TableOfContents',
props: {
html: { type: String, default: '' },
// 监听哪个容器的滚动
scrollContainer: { type: String, default: null },
// 偏移量(固定头部高度)
offsetTop: { type: Number, default: 80 },
},
data() {
return {
headings: [],
activeId: '',
observer: null,
};
},
watch: {
html: {
immediate: true,
handler(val) {
this.$nextTick(() => {
this.extractHeadings(val);
this.setupScrollSpy();
});
},
},
},
methods: {
/**
* 从 HTML 中提取标题
*/
extractHeadings(html) {
const div = document.createElement('div');
div.innerHTML = html;
const elements = div.querySelectorAll('h1, h2, h3, h4');
this.headings = Array.from(elements).map((el, index) => {
const text = el.textContent.trim();
const level = parseInt(el.tagName[1]);
const id = el.id || `heading-${index}-${encodeURIComponent(text)}`;
return { id, text, level };
});
},
/**
* 滚动到指定标题
*/
scrollToHeading(id) {
const el = document.getElementById(id);
if (!el) return;
const top = el.getBoundingClientRect().top + window.pageYOffset - this.offsetTop;
window.scrollTo({ top, behavior: 'smooth' });
this.activeId = id;
// 更新 URL hash(不触发跳转)
history.replaceState(null, '', `#${id}`);
},
/**
* 滚动监听:高亮当前所在标题
*/
setupScrollSpy() {
// 清理旧的 observer
this.observer?.disconnect();
this.observer = new IntersectionObserver(
(entries) => {
// 找到第一个进入视口的标题
for (const entry of entries) {
if (entry.isIntersecting) {
this.activeId = entry.target.id;
break;
}
}
},
{
rootMargin: `-${this.offsetTop}px 0px -70% 0px`,
}
);
// 观察所有标题元素
this.$nextTick(() => {
this.headings.forEach(({ id }) => {
const el = document.getElementById(id);
if (el) {
this.observer.observe(el);
}
});
});
},
},
beforeDestroy() {
this.observer?.disconnect();
},
};
</script>
<style scoped>
.toc {
position: sticky;
top: 80px;
max-height: calc(100vh - 120px);
overflow-y: auto;
padding: 16px;
border-left: 2px solid #f0f0f0;
}
.toc-title {
font-size: 14px;
color: #999;
margin: 0 0 12px 0;
text-transform: uppercase;
letter-spacing: 1px;
}
.toc-list {
list-style: none;
padding: 0;
margin: 0;
}
.toc-list li {
margin: 4px 0;
transition: all 0.2s;
}
.toc-list li a {
display: block;
padding: 4px 8px;
font-size: 13px;
color: #666;
text-decoration: none;
border-radius: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: all 0.2s;
}
.toc-list li a:hover {
color: #1890ff;
background: #f0f7ff;
}
/* 缩进层级 */
.toc-level-1 { padding-left: 0; }
.toc-level-1 a { font-weight: 600; font-size: 14px; }
.toc-level-2 { padding-left: 12px; }
.toc-level-3 { padding-left: 24px; }
.toc-level-3 a { font-size: 12px; }
.toc-level-4 { padding-left: 36px; }
.toc-level-4 a { font-size: 12px; color: #999; }
/* 高亮当前标题 */
.active a {
color: #1890ff !important;
font-weight: 600;
background: #e6f7ff;
border-left: 3px solid #1890ff;
}
</style>
十五、面试完整答题模板
模板:当被问到"你们项目的富文本编辑器是怎么做的?"
markdown
============================================
答题框架(3-5分钟完整回答)
============================================
第一部分:背景和选型(30秒)
──────────────────────────
"我们项目是一个内容管理平台/知识库/博客系统,
核心场景是文章编辑,需要支持基础排版、图片上传、
@提及、代码块等功能。
选型上我们对比了几个方案:
- wangEditor:开箱即用但扩展性不足
- Quill:体积小但大文档性能一般
- Tiptap:基于ProseMirror,扩展性最好
- Slate.js:灵活但上手成本高
最终选了 Tiptap/wangEditor(根据实际情况),
主要考虑:Vue生态适配好、社区活跃、满足功能需求。"
第二部分:架构设计(1分钟)
──────────────────────────
"架构上我做了几个关键设计:
1)适配器模式封装:
定义了统一的编辑器接口(getHTML/setContent/insertImage等),
底层通过适配器对接具体编辑器。
这样如果将来需要换编辑器,只需要新增适配器,
业务组件完全不用改。
2)插件化扩展:
图片上传、@提及、自动保存都做成独立插件,
按需加载,职责分离。
3)工具栏配置化:
工具栏通过配置数组驱动,不同场景(文章编辑/评论/邮件)
传不同配置即可复用同一个编辑器组件。"
第三部分:核心难点(1-2分钟,挑2-3个讲)
──────────────────────────
难点1:图片上传
"图片支持工具栏按钮、粘贴、拖拽三种方式上传。
流程是:前端压缩 → 上传服务(支持进度展示)→ 返回CDN URL → 插入编辑器。
做了并发控制(最多3张同时传)、失败重试(3次)、大图压缩(>1920px缩放)。"
难点2:XSS 安全
"富文本的XSS防护我们做了纵深防御:
- 编辑器层:粘贴时剥离危险标签
- 提交前:DOMPurify 白名单过滤
- 服务端:二次过滤(最关键,不信任前端)
- 展示层:CSP头 + img域名白名单
主要防的攻击向量包括script注入、事件属性注入、
javascript:协议注入等。"
难点3:自动保存
"做了防抖保存(3秒)+ 定时兜底(30秒)+
beforeunload 保存 + localStorage 草稿箱。
保存失败自动重试3次,重试失败保证本地有备份。
还做了草稿恢复提示------如果检测到本地有比服务端更新的草稿,
会提示用户选择恢复或忽略。"
难点4(如果问到协同编辑):
"协同编辑用的 Yjs + WebSocket。
Yjs 是 CRDT 方案,每个字符有全局唯一ID,
通过 leftOrigin/rightOrigin 定位,天然支持冲突合并。
相比 OT 方案不需要中心服务器排序,支持离线后合并。
在线用户的光标位置通过 Awareness 协议同步,
做了颜色区分和名字标签展示。"
第四部分:性能优化(30秒)
──────────────────────────
"性能方面做了几个优化:
- 编辑器组件异步加载(减少首屏体积200KB+)
- onChange 防抖(避免高频序列化)
- 图片懒加载(IntersectionObserver)
- 自动保存用增量比较,内容没变不发请求
- 历史记录限制100步 + 连续输入合并"
第五部分:收尾(15秒)
──────────────────────────
"整体这套方案上线后运行稳定,
支撑了日均 XX 篇文章的编辑需求,
用户反馈编辑体验比之前好很多。
如果要继续优化的话,我会考虑:
- Markdown 快捷输入(输入##自动转标题)
- 更完善的表格编辑
- 离线编辑能力"
十六、最后总结
less
┌────────────────────────────────────────────────────────┐
│ 富文本编辑器知识体系总览 │
├────────────────────────────────────────────────────────┤
│ │
│ 理论基础 │
│ ├── contentEditable 原理与局限 │
│ ├── 文档模型设计(树形 vs 扁平) │
│ ├── 选区(Selection/Range)管理 │
│ ├── 中文输入法(IME Composition)处理 │
│ └── 操作变换(OT)与 CRDT 冲突解决 │
│ │
│ 工程实践 │
│ ├── 编辑器选型(场景驱动决策) │
│ ├── 适配器模式封装(可替换底层实现) │
│ ├── 插件化架构(图片/提及/@/自动保存) │
│ ├── 图片上传(压缩/并发控制/粘贴拖拽) │
│ ├── XSS 纵深防御(前端+后端+CSP) │
│ ├── 自动保存(防抖+定时+本地兜底+恢复) │
│ ├── 安全展示(过滤HTML+代码复制+图片预览) │
│ └── 性能优化(懒加载/防抖/Worker序列化) │
│ │
│ 面试要点 │
│ ├── 能说清楚选型理由和取舍 │
│ ├── 能画出整体架构图 │
│ ├── 能深入 2-3 个技术难点 │
│ ├── 有性能优化意识 │
│ └── 有安全意识(XSS不是说说而已) │
│ │
│ 加分项 │
│ ├── 了解 ProseMirror 文档模型和 Transaction 机制 │
│ ├── 了解 CRDT/OT 原理 │
│ ├── 了解 Lexical 的双缓冲架构 │
│ ├── 做过协同编辑 │
│ └── 做过编辑器自定义扩展/插件 │
│ │
└────────────────────────────────────────────────────────┘
以上是完整的富文本编辑器技术方案,涵盖了:
| 模块 | 内容 |
|---|---|
| 选型 | 5大编辑器横向对比 + 决策树 |
| 架构 | 适配器模式 + 统一接口 + 工厂函数 |
| 封装 | Vue通用组件 + v-model双向绑定 |
| 图片上传 | 压缩/并发/粘贴拖拽/进度/重试 |
| XSS安全 | DOMPurify白名单 + 后端二次过滤 + CSP |
| 自动保存 | 防抖+定时+离开保存+本地草稿+恢复提示 |
| 内容展示 | 安全渲染+代码复制+图片预览+目录提取 |
| 性能优化 | 懒加载/防抖/Worker/虚拟滚动 |
| 面试 | 9道高频题 + 完整答题模板 |