富文本编辑器知识体系(二)

五、通用封装架构:适配器模式(接上篇)

当项目中可能更换编辑器时,使用适配器模式隔离底层实现:

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 = {
    '&': '&amp;',
    '<': '&lt;',
    '>': '&gt;',
    '"': '&quot;',
    "'": '&#x27;',
    '/': '&#x2F;',
  };
  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               │
│                                                │
└────────────────────────────────────────────────┘

相关推荐
品克缤1 小时前
Trading-Analysis:基于“规则+LLM”的行情分析终端(兼谈 Vibe Coding 实战感)
前端·后端·node.js·vue·express·ai编程·llama
隔壁小邓2 小时前
前端Vue项目打包部署实战教程
前端·javascript·vue.js
TON_G-T2 小时前
javascript中 Iframe 处理多端通信、鉴权
开发语言·前端·javascript
周淳APP2 小时前
【JS之闭包防抖节流,this指向,原型&原型链,数据类型,深浅拷贝】简单梳理啦!
开发语言·前端·javascript·ecmascript
kyriewen2 小时前
console.log 骗了我一整个通宵:原来它才是时间旅行者
前端·javascript·chrome
忆江南2 小时前
# iOS 动态库与静态库全面解析
前端
冴羽2 小时前
在浏览器控制台调试的 6 个秘密技巧
前端·javascript·chrome
青莲8432 小时前
查找算法详解
android·前端
前端Hardy2 小时前
别再手动调 Prompt 了!这款开源神器让 AI 输出质量提升 300%,支持 Claude、GPT、Gemini,还免费开源!
前端·javascript·面试