普通视图

发现新文章,点击刷新页面。
今天 — 2026年3月17日掘金 前端

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

2026年3月17日 19:20

接续:项目结构 + 自动保存 + 内容展示 + 完整面试答题模板


十一、完整项目结构参考(续)

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            # 主题变量

十二、自动保存完整实现

// 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 组件中集成:

<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>

十三、内容安全展示组件

<!-- 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>

十四、目录提取组件

<!-- 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>

十五、面试完整答题模板

模板:当被问到"你们项目的富文本编辑器是怎么做的?"

============================================
答题框架(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 快捷输入(输入##自动转标题)
- 更完善的表格编辑
- 离线编辑能力"

十六、最后总结

┌────────────────────────────────────────────────────────┐
│              富文本编辑器知识体系总览                     │
├────────────────────────────────────────────────────────┤
│                                                        │
│  理论基础                                              │
│  ├── 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道高频题 + 完整答题模板

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

2026年3月17日 19:17

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

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

┌─────────────────────────────────────────────────────┐
│                  业务组件层                           │
│                                                     │
│     <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 统一接口定义

// 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 各适配器实现

// 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();
  }
}
// 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 通用组件

// 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();
}
<!-- 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>

业务组件使用:

<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 上传服务封装

// 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 粘贴/拖拽上传

// 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 输入过滤(前端)

// 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)

// 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 大文档性能优化

// 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 编辑器懒加载

// 按需加载编辑器(减少首屏体积)

// 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 综合对比表

┌─────────────┬──────────┬─────────┬─────────┬─────────┬──────────┐
│    维度       │wangEditor│  Tiptap  │ Slate.js │ Lexical │  Quill   │
├─────────────┼──────────┼─────────┼─────────┼─────────┼──────────┤
│ 框架         │ Vue优先   │ 框架无关  │ React    │ React   │ 框架无关  │
│ 上手难度     │ ⭐⭐       │ ⭐⭐⭐     │ ⭐⭐⭐⭐⭐  │ ⭐⭐⭐⭐   │ ⭐⭐       │
│ 可扩展性     │ ⭐⭐⭐     │ ⭐⭐⭐⭐⭐  │ ⭐⭐⭐⭐⭐  │ ⭐⭐⭐⭐⭐ │ ⭐⭐⭐     │
│ 开箱即用     │ ⭐⭐⭐⭐⭐  │ ⭐⭐⭐⭐   │ ⭐⭐       │ ⭐⭐⭐    │ ⭐⭐⭐⭐   │
│ 协同编辑     │ ✗        │ ✓ (Yjs) │ 需自实现  │ 需自实现 │ 需自实现  │
│ 大文档性能   │ 一般      │ 好       │ 好       │ 最好    │ 一般     │
│ Bundle Size  │ ~200KB   │ ~150KB  │ ~100KB   │ ~60KB   │ ~40KB    │
│ TypeScript   │ ✓        │ ✓       │ ✓        │ ✓       │ ✗        │
│ 中文文档     │ ⭐⭐⭐⭐⭐  │ ⭐⭐      │ ⭐⭐       │ ⭐⭐     │ ⭐⭐⭐     │
│ 社区活跃度   │ 中       │ 高       │ 高       │ 高      │ 中(维护少)│
│ 学习价值     │ ⭐⭐      │ ⭐⭐⭐⭐   │ ⭐⭐⭐⭐⭐  │ ⭐⭐⭐⭐  │ ⭐⭐       │
└─────────────┴──────────┴─────────┴─────────┴─────────┴──────────┘

9.2 选型决策树

你的需求是什么?
│
├── 管理后台 / 内部系统 / 快速交付
│   ├── 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 的原理和问题

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:协同编辑的冲突解决


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:编辑器文档模型设计


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 节点上(而非 marks3.  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攻击


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)和光标管理


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:编辑器的历史记录(撤销/重做)实现


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:如何实现 @ 提及功能

// 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:编辑器性能优化的具体措施

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屏)           │
│                                               │
└───────────────────────────────────────────────┘
// 实际的性能优化代码示例

// 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:编辑器测试策略

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               │
│                                                │
└────────────────────────────────────────────────┘

A2UI:让 AI Agent "说出"用户界面的开放协议

作者 王小酱
2026年3月17日 18:48

引言:Agent 时代的 UI 困境

想象这样一个场景——你对一个 AI 助手说:"帮我订一张明天晚上 7 点的两人桌。" 如果 Agent 只能回复文本,接下来将是一连串低效的对话:"请问哪一天?""什么时间?""几位?"……一个本可以用一个表单瞬间解决的事情,变成了五六个回合的文字乒乓球。

更好的方式显然是:Agent 直接生成一个表单界面,带有日期选择器、时间选择器、人数输入框和确认按钮。用户在 UI 上操作,一次提交,搞定。

但这件"显然更好"的事情,在技术上却极其棘手。Agent 可能运行在远程服务器上,甚至跨越组织的信任边界。它不能直接操控你的 UI,只能发送消息。传统的方案——在 iframe 中嵌入 Agent 返回的 HTML/JavaScript——不仅笨重、风格割裂,还引入了严重的安全隐患。

Google 发起的开源项目 A2UI(Agent-to-User Interface) 就是为了解决这个问题而生的。它定义了一种让 Agent "说 UI" 的通用语言:Agent 发送声明式的 JSON 消息来描述界面的意图,客户端应用用自己原生的组件库来渲染。安全如数据,表达如代码。


一、A2UI 是什么?一句话理解核心理念

A2UI 是一个声明式 UI 协议,而不是一个框架。它的核心思想可以拆成三层:

Agent 生成一段 JSON,描述"我想展示一个标题、一个日期选择器和一个按钮"。这段 JSON 通过任意传输通道(A2A 协议、WebSocket、SSE 等)到达客户端。客户端的 A2UI 渲染器读取 JSON,将抽象的组件描述映射为自己代码库中的原生组件——可以是 Flutter Widget、Angular Component、Lit Web Component 或 React 组件。

这意味着同一份 A2UI JSON 可以在 Web、移动端和桌面端被不同的渲染器渲染为风格统一、性能原生的界面,同时 Agent 完全无法执行任何代码——它只能从客户端预先批准的"组件目录"中选取组件来组合界面。


二、为什么需要 A2UI?三大设计支柱

安全优先。 A2UI 是一种声明式数据格式,不是可执行代码。Agent 不能注入 JavaScript,不能操纵 DOM——它只能请求渲染客户端目录中已经存在的、预先审核过的组件(如 Card、Button、TextField)。安全性由客户端完全掌控。

LLM 友好且支持增量更新。 UI 被表示为一个扁平的组件列表,组件之间通过 ID 引用来建立父子关系,而不是深层嵌套的 JSON 树。这种"邻接表"模型让 LLM 可以逐步生成组件、流式发送,客户端可以渐进式渲染——用户看到界面逐步构建,而不是盯着转圈等待。当需要更新时,只需发送带有已有 ID 的新定义即可,无需重新生成整个 UI。

框架无关且可移植。 Agent 发送的是对组件树和数据模型的抽象描述。渲染的责任完全在客户端。同一份 JSON 可以被 Lit 渲染为 Web Component,被 Flutter 渲染为原生移动控件,被 Angular 渲染为 Angular 组件,甚至未来被 SwiftUI 或 Jetpack Compose 渲染为 iOS 和 Android 原生视图。


三、核心概念详解

3.1 Surface(画布)

Surface 是 A2UI 中承载组件的容器,可以理解为一个完整的 UI 单元——一个对话框、一个侧边栏或一个主视图。每个 Surface 有唯一的 surfaceId,拥有自己的组件树和数据模型。Agent 通过创建、更新和删除 Surface 来管理界面的生命周期。

3.2 组件与邻接表模型

这是 A2UI 最独特的设计决策。传统的 UI 描述通常使用嵌套树结构,但 LLM 生成深层嵌套 JSON 时很容易出错,且难以流式传输和增量更新。A2UI 采用了扁平的邻接表模型:所有组件排成一个列表,每个组件有唯一 ID,通过 ID 引用子组件。

举个例子,一个包含标题和两个按钮的简单界面,在 v0.9 中是这样描述的:根组件 Column 声明子组件列表为 ["greeting", "buttons"],greeting 是一个 Text 组件,buttons 是一个 Row 组件再引用两个 Button。所有组件平级排列,通过 ID 建立层次关系。

A2UI 提供了一套标准组件目录,按用途分为布局类(Row、Column、List)、展示类(Text、Image、Icon、Divider)、交互类(Button、TextField、CheckBox、DateTimeInput、Slider、ChoicePicker)和容器类(Card、Tabs、Modal)。

3.3 数据绑定

A2UI 将 UI 结构与应用状态分离。每个 Surface 拥有一个 JSON 数据模型,组件通过 JSON Pointer 路径(如 /user/name/cart/items/0/price)绑定到数据模型中的值。

这种分离带来了强大的响应式更新能力:当 Agent 更新数据模型中的某个路径时,绑定到该路径的所有组件自动更新显示内容,无需重新发送组件定义。同时,交互组件(如 TextField)支持双向绑定——用户输入立即写入本地数据模型,当用户点击提交按钮时,按钮的 Action 从数据模型中解析出最新值发送给 Agent。

动态列表是数据绑定的一个精彩应用:一个模板组件绑定到数据模型中的数组路径,数组中每增加一个元素就自动渲染一个新实例,且模板内的路径自动限定到当前数组元素的作用域。

3.4 消息类型与生命周期

以 v0.9 为例,Agent 与客户端之间通过四种核心消息通信。createSurface 创建画布并指定使用的组件目录。updateComponents 定义或更新 UI 组件。updateDataModel 更新应用状态。deleteSurface 移除一个界面。

一个典型的餐厅预订流程是这样的:Agent 先发送 createSurface 创建画布,然后通过 updateComponents 定义表单结构(标题、人数输入框、提交按钮),再通过 updateDataModel 填充初始数据(日期、人数)。用户修改人数,客户端自动更新本地数据模型。用户点击确认,客户端将按钮 Action 中引用的数据路径解析为当前值,封装为 action 消息发送给 Agent。Agent 处理后可以更新界面或删除 Surface。

3.5 Catalog(组件目录)

Catalog 是 A2UI 安全模型的关键枢纽。它是一个 JSON Schema 文件,定义了 Agent 可以使用的所有组件、函数和主题。客户端告诉 Agent 自己支持哪些 Catalog,Agent 在创建 Surface 时选择一个 Catalog 并锁定。之后 Agent 生成的所有 JSON 都会在双端被验证——Agent 端在发送前验证,客户端在接收后再验证。如果验证失败,客户端会发送 VALIDATION_FAILED 错误,Agent 可以据此自我纠正。

A2UI 团队维护了一个"Basic Catalog"作为起步用的通用组件集,但大多数生产应用会定义自己的 Catalog 来反映自己的设计系统。你可以从零开始定义,也可以导入 Basic Catalog 中的部分组件再扩展自定义组件(如图表、地图、股票行情组件)。Catalog 之间通过 URI 作为唯一标识符进行协商和版本管理。

3.6 传输层

A2UI 是传输无关的——任何能传递 JSON 的机制都可以工作。目前与 A2A 协议和 AG UI 有成熟的集成,REST、WebSocket 和 SSE 在路线图中。消息通常以 JSON Lines(JSONL)格式流式传输,每行一个完整的 JSON 对象。在 A2A 绑定中,A2UI 消息被编码为 DataPart,MIME 类型为 application/json+a2ui


四、v0.8 → v0.9:从"结构化输出优先"到"提示词优先"

A2UI 的版本演进体现了对 LLM 生成能力更深层的理解。v0.8 被设计为通过 LLM 的"结构化输出"模式生成,依赖深层嵌套和特定的包装结构。v0.9 则做了一次哲学性的转变——为"嵌入系统提示词"而优化。

最直观的变化是组件格式从嵌套键变成了扁平判别器。v0.8 写作 "component": { "Text": { "text": { "literalString": "Hello" } } },v0.9 则简化为 "component": "Text", "text": "Hello"。数据模型更新从键值对数组变成了标准 JSON 对象。子组件列表从 {"explicitList": [...]} 变成了简单的数组。这些改动大幅减少了 token 消耗,也更符合 LLM 天然擅长生成的 JSON 模式。

v0.9 还引入了几个重要的新能力:createSurface 消息要求指定 catalogId,使目录协商变得显式;新增了 sendDataModel 标志,允许客户端在每条消息的元数据中自动附带完整数据模型,实现"无状态 Agent"模式;引入了 formatString 函数支持字符串插值;以及结构化的 VALIDATION_FAILED 错误反馈机制,让 LLM 可以在"生成-验证-纠正"循环中自我改进。


五、双向交互:Action 与数据同步

A2UI 不只是单向的 UI 推送,它支持完整的双向通信。交互组件(如 Button)可以定义 Action,分为两种:Server Event 发送到 Agent 处理(如提交表单),Local Function Call 在客户端本地执行(如打开 URL、格式化字符串、输入验证)。

Action 中的 context 是一个精心设计的机制——它允许按钮在触发时从数据模型中"摘取"特定路径的当前值,封装成一个简洁的上下文对象发送给 Agent,避免 Agent 需要解析整个数据模型。

v0.9 的 Data Model Sync 更是支持了一种优雅的"口头提交"模式:当 sendDataModel 启用时,用户甚至不需要点击按钮——只要说"好的,提交吧",客户端会将当前完整数据模型附在消息元数据中,Agent 从元数据中读取所有表单值即可完成处理。

在多 Agent 架构中,协调器(Orchestrator)需要维护 Surface 到子 Agent 的所有权映射,确保用户的 Action 被路由回正确的子 Agent,并且在转发消息时剥离其他 Agent 的数据模型,防止跨 Agent 的数据泄露。


六、生态系统与真实应用

A2UI 不是一个纸上协议——它已经在多个 Google 产品和合作伙伴项目中投入使用。

Google Opal 使用 A2UI 驱动 AI 小应用的动态生成式 UI 系统,让数十万用户可以用自然语言创建、编辑和分享 AI 应用。Flutter GenUI SDK 在底层使用 A2UI 作为服务端 Agent 与 Flutter 应用之间的通信协议,实现跨 iOS、Android、Web、桌面的原生渲染。Google ADK(Agent Development Kit)内置了 A2UI v0.8 标准目录的原生渲染支持。CopilotKit / AG UI 提供了 A2UI 的 Day-zero 兼容,AG UI 作为传输层,A2UI 作为 UI 内容格式,形成互补。

A2UI 与同类方案的定位也值得理解:MCP Apps 让远程服务器通过 iframe 提供完整的 UI 体验,适合服务器完全掌控 UI 的场景;AG UI 是一个前后端连接的传输协议;A2UI 则是 UI 负载本身的格式标准。三者可以组合使用——A2UI + AG UI 用 AG UI 做管道、A2UI 做内容,A2UI + A2A 用 A2A 协议在多 Agent 系统中传递 A2UI 消息。


七、动手试一试

体验 A2UI 最快的方式是运行仓库自带的餐厅查找器 Demo。克隆仓库、设置 Gemini API Key、进入 samples/client/lit 目录运行 npm run demo:all,就能在浏览器中看到一个由 Gemini 驱动的 Agent 实时生成交互式 UI 的完整流程。

如果你想更深入地开发,有三条路径可选:前端开发者可以将 A2UI 渲染器集成到自己的应用中(目前支持 Lit、Angular,React 在路线图中);后端开发者可以使用 Google ADK 构建生成 A2UI 响应的 Agent;或者直接使用 AG UI/CopilotKit 或 Flutter GenUI SDK 等已内置 A2UI 支持的框架。


结语

A2UI 的出现代表了 Agent 交互范式的一次重要进化——从"Agent 只能说文字"到"Agent 可以说 UI"。它用声明式数据格式解决了安全问题,用邻接表模型解决了 LLM 生成和流式渲染的问题,用框架无关的抽象解决了跨平台问题,用 Catalog 协商机制解决了可扩展性问题。

作为 Google 发起、Apache 2.0 许可的开源项目,A2UI 目前处于 v0.8(稳定)和 v0.9(草案)阶段,正在积极向 v1.0 迈进。如果你正在构建 AI Agent 驱动的应用,无论是对话式界面、企业工作流还是多 Agent 系统,A2UI 都值得关注和尝试。

console.log 骗了我一整个通宵:原来它才是时间旅行者

作者 kyriewen
2026年3月17日 18:30

引言

“这个 bug 我明明修好了,为什么控制台还在报错?”

凌晨三点,我盯着屏幕上的代码,眼袋比眼睛还大。明明我已经在 15 行打印了 user.name,显示的是 '张三';到 30 行修改了 user.name = '李四',然后又在 45 行打印了一次,结果控制台第一次打印的地方展开一看,居然也变成了 '李四'

那一刻我差点把电脑吃了——难道代码在时间旅行?还是说 JavaScript 引擎有自己的想法?

直到隔壁工位的老王路过,瞄了一眼我的屏幕,幽幽地说:“小伙子,你也被 console.log 骗了吧?”

一、案发现场:被篡改的历史记录

先来看一段“案发代码”:

const user = { name: '张三', age: 18 };
console.log(user); // 打印 user
user.name = '李四';
console.log(user); // 打印修改后的 user

你觉得控制台会输出什么?按照常理,应该是两次不同的对象:第一次 {name: '张三', age: 18},第二次 {name: '李四', age: 18}

但如果你在 Chrome 控制台里运行,展开第一次打印的那个对象,你会发现——它也是 {name: '李四', age: 18}!仿佛历史被篡改了一样。

console.log 陷阱示意图转存失败,建议直接上传图片文件(这里可以配个图:一个侦探看着控制台,气泡里显示“李四”)

二、为什么 console.log 会说谎?

2.1 凶手是谁?

答案是:引用类型 + 控制台的“惰性”展示

当你执行 console.log(user) 时,浏览器并没有立刻把 user 对象的快照保存下来,而是保存了对象的引用。在控制台的界面上,对象是可展开的,当你点击展开图标时,控制台才会去读取当前内存中该对象的属性值

也就是说,console.log 打印的是一个“活的”对象——它像一台摄像机,记录的不是当时的照片,而是一个实时直播的摄像头。等你点开看的时候,看到的是直播画面,而非当时的回放。

2.2 基本类型为什么没问题?

let name = '张三';
console.log(name); // '张三'
name = '李四';
console.log(name); // '李四'

这里打印的都是基本类型,不会出现篡改历史的问题,因为基本类型是直接存储值,没有引用关系。控制台直接显示当时的字符串值。

2.3 浏览器们的小心思

  • Chrome/Edge:上面描述的行为最常见。你第一次打印的对象,展开后可能会显示最新的值。
  • Firefox:早期版本也有类似问题,但现在似乎在打印时会对对象进行“快照”?具体版本有差异。
  • Safari:表现也不同,有时会保留快照。

所以跨浏览器调试时更要小心——你以为 Safari 没毛病,结果 Chrome 给你来个篡改。

三、真实案例:因为一个 console.log 通宵加班

我曾经维护过一个老项目,有一个函数负责更新用户信息,其中有一段:

function updateUser(user) {
  console.log('更新前:', user); // 调试用
  user.name = '新名字';
  console.log('更新后:', user); // 调试用
  saveToServer(user);
}

当时我发现控制台里两个 log 展开后 name 都是 '新名字',于是以为 saveToServer 之前 user 已经被改过了,所以怀疑其他代码也修改了 user 引用。我在整个项目里搜索,一无所获。

后来我用 JSON.stringify 打印:

console.log('更新前:', JSON.stringify(user));

终于看到真实的“当时的值”是旧名字。原来 user 对象根本没有被外部修改,是 console.log 骗了我!

四、如何让 console.log 说真话?

4.1 快照大法:深拷贝

在打印前把对象深拷贝一份:

console.log('user 当时的值:', JSON.parse(JSON.stringify(user)));

注意:这种方法无法处理循环引用、函数、undefined、Symbol 等,但对于普通对象足够了。

4.2 展开运算符?小心!

console.log({ ...user });

这样会创建一个新的对象,但它的属性值如果是引用类型,仍然是指向原对象的引用。比如 user.friends 是一个数组,展开后 friends 还是原来的数组,之后修改 user.friends.push('王五'),你打印的那个副本里的 friends 也会变。所以只适用于一层浅拷贝。

4.3 用 console.table 打印表格

对于数组或对象,console.table 会生成一个表格,它会取打印时刻的值,但同样可能受引用影响?实际上 console.table 也是读取当前属性值,所以如果之后修改了原始对象,表格里的数据不会自动更新(因为已经渲染成静态表格了)。这一点比展开对象要可靠。

4.4 使用断点 debugger

最好的办法:直接打断点,在 Sources 面板里查看作用域中的变量值,那是真正的“当时的值”。

debugger; // 代码执行到这里会暂停,你可以慢慢看变量

4.5 自定义一个 safeLog

function safeLog(...args) {
  args.forEach(arg => {
    if (typeof arg === 'object' && arg !== null) {
      console.log(JSON.parse(JSON.stringify(arg)));
    } else {
      console.log(arg);
    }
  });
}

五、其他类似的“时间旅行”陷阱

5.1 数组的 console.log

同样的问题,数组也是对象。

const arr = [1, 2, 3];
console.log(arr); // 展开后可能变成 [1,2,3,4]
arr.push(4);

5.2 异步中的闭包

for (var i = 0; i < 3; i++) {
  setTimeout(() => {
    console.log(i); // 全是 3
  }, 100);
}

这不是 console 的问题,而是闭包捕获了同一个变量。但也是常见的“以为当时的值是 0,1,2”的坑。

5.3 事件监听中的“旧”数据

let count = 0;
button.addEventListener('click', () => {
  console.log(count); // 每次点击打印最新的 count,而不是绑定时的值
});

这也不是 console 的问题,但同样是“值”与“引用”的区别。

六、总结:别太相信 console.log,它只是个演员

console.log 是我们调试的利器,但它也有自己的脾气。理解它的行为,才能避免在 bug 排查时被误导。

  • 记住:打印对象时,控制台保留的是引用,展开时看到的是当前值。
  • 对策:深拷贝、console.table、断点,或者打印基本类型。
  • 心态:遇到奇怪现象,先怀疑工具,再怀疑代码。

最后,分享一个老程序员的玩笑:“当你把 console.log 删干净之后,bug 就消失了。”——有时候,真的是 console.log 在搞鬼。

每日一问:你在调试时还遇到过哪些让人抓狂的“假象”?是 console.log 的延时?还是 sourcemap 错位?欢迎在评论区吐槽,让我们一起长点记性!


(本文虚构故事如有雷同,纯属你也经历过)

在浏览器控制台调试的 6 个秘密技巧

作者 冴羽
2026年3月17日 18:19

有一些神奇的快捷键只在浏览器 DevTools 控制台中工作,但它们绝对是调试时的秘密武器。

技巧 1:$0:最后选中的元素

单击“元素”选项卡中的任意元素,然后切换到控制台:

$0; // 查看元素
$0.style.backgroundColor = "red"; // 修改样式
$0.classList.add("highlight"); // 添加类名

更棒的是:$1$2$3$4 会引用最后选中的 5 个元素。非常适合快速比较或操作多个元素。

技巧 2:$$:更优的选择器

忘掉 document.querySelectorAll() 吧,以后使用 $$:

// 旧方式
Array.from(document.querySelectorAll(".product-card")).forEach((card) => console.log(card.textContent));

// 新方式
$$(".product-card").forEach((card) => console.log(card.textContent));

$$() 返回一个真正的数组(而非 NodeList),因此你可以立即使用数组方法。无需再用 Array.from() 包装一层。

技巧 3:$:快速 querySelector

$("#header"); // 等同于 document.querySelector('#header')
$(".btn-primary"); // 等同于 document.querySelector('.btn-primary')

注意: 这仅在 jQuery 未加载时有效。

如果加载了 jQuery,$ 就是 jQuery。

技巧 4:$_: 最后一个结果

2 + 2; // 4

$_ * 10; // 40

$_ 存储控制台中最后一个表达式的计算结果。这对于无需重新输入即可链式执行操作非常有用。

技巧 5:copy():即时剪贴板

const data = { users: [...], products: [...] };
copy(data);

无需再右键单击并选择“复制对象”,也无需尝试将文本字符串化并选中。只需copy(anything)点击一下,它就复制到剪贴板了。

技巧 6:getEventListeners() 事件检查器

getEventListeners($0);

这对于调试事件处理器问题或查找未移除的监听器导致的内存泄漏非常有用。

最后

正确的调试技巧可以为你节省数小时的试错时间。

但比这些快捷键更有价值的,是一种「工具思维」的习惯。

当你不再把 DevTools 看作一个静态的调试面板,而是一个可以对话、可以编程、可以无限扩展的工作台时,你的调试方式就会发生质变。

每一次敲击 $0copy(),本质上都是在训练一种能力:用最小的摩擦,把想法变成验证。

真正的效率不在于记住多少快捷键,而在于你是否愿意停下来,花十分钟学习一个能每天为你节省一分钟的工具。

这种「时间复利」的思维方式,才是优秀开发者与普通开发者的分水岭。掌握这些工具,让自己成为那个在控制台里游刃有余、从容应对复杂问题的人。

我是冴羽,10 年笔耕不辍,专注前端领域,更新了 10+ 系列、300+ 篇原创技术文章,翻译过 Svelte、Solid.js、TypeScript 文档,著有小册《Next.js 开发指南》、《Svelte 开发指南》、《Astro 实战指南》。

欢迎围观我的“网页版朋友圈”,关注我的公众号:冴羽(或搜索 yayujs),每天分享前端知识、AI 干货。

同题画图大考,AiPy 章鱼适配性拉满,OpenClaw 龙虾全程 “哑火”

作者 Ekehlaft
2026年3月17日 17:51

作为一名长期关注AI生产力的创作者,我决定用一个硬核测试来验证各大模型的真实能力:让AI控制Windows画图软件,画一辆汽车

这不是简单的文本生成测试,而是一场对AI系统操控能力的全面检验。

一、测试背景:为什么选择"画图"这个场景?

很多人觉得大模型只能聊聊天、写写文案,其实不然。随着AI原生应用的兴起,大模型正逐渐从"对话工具"向"操作系统助手"转变。

本次测试的核心目的,就是验证不同大模型控制鼠标自动操作电脑的能力:

  • 各大模型到底能不能找到Windows电脑上的画图软件?
  • 能不能操作鼠标在正确的位置开始作画?
  • 画出来的图形到底准确不准确?

为保证公平,所有测试都在同一台Windows 11电脑上完成,使用统一的AiPY Pro 0.14.1作为控制平台,提示词固定为:"打开Windows画图软件,控制鼠标画一个汽车"。

二、章鱼开画:一气呵成

当任务交给AiPy时,过程非常顺畅:

  1. 指令理解阶段:准确拆解任务为"启动画图软件→最大化窗口→选择铅笔工具→绘制汽车各部分→保存文件"5个步骤
  2. 代码生成阶段:生成的PyAutoGUI代码逻辑清晰,坐标计算准确,考虑了窗口定位、等待时间等细节
  3. 执行阶段:鼠标移动流畅,绘制顺序合理
  4. 完成效果:汽车比例协调,线条流畅,细节完整,最终自动保存为PNG文件到指定目录

整个过程完全不需要人工干预,从启动软件到保存结束,Gemini 3.1 Pro在AiPy章鱼平台上耗时100秒,顺利完成了画图任务。虽然完成质量算不上顶级,但它确实做到了

三、龙虾登场:全程哑火

在章鱼顺利完成测试后,我换成OpenClaw,使用完全相同的Gemini 3.1 Pro模型,输入同样的提示词,再来一次。

结果出人意料:

耗时20秒,一笔未画,全程哑火。

从启动到结束,全程20秒,模型响应了,但是什么都没干

四、为什么差距这么大?

同样的模型,同样的任务,为什么章鱼能行,龙虾不行?从我这次测试的结果来看,差距主要体现在三个方面:

1. 适配性差距

AiPy Pro作为一款专注于系统控制的AI软件,对各大模型生成的代码格式、坐标计算、执行逻辑都做了深度适配优化。大模型输出的PyAutoGUI代码,能被准确解析并转化为实际的鼠标键盘操作。

而OpenClaw在这方面显然存在适配缺陷。同样的代码,在AiPy平台能跑,在龙虾平台就是执行不了。

2. 场景理解差距

AiPy的设计定位非常清晰:让大模型拥有"动手能力",成为真正能够操作电脑的生产力工具。从做PPT到分析股票再到爬虫,所有功能都围绕"解决实际问题"展开。

OpenClaw给人的感觉,更偏向于框架层面的搭建,对于具体使用场景的打磨还不够深入。当遇到画图这种需要精确坐标和流畅执行的任务,短板立刻显现。

3. 用户体验差距

在AiPy平台上,整个执行过程清晰可见,代码运行、鼠标移动都能实时反馈,就算出错也能快速定位问题。

而在OpenClaw这次测试中,程序说"执行完成",但屏幕上什么都没发生,用户连问题出在哪都不知道。这种体验对于想要用AI干活的用户来说,几乎是不可接受的。

在这场画图大考中,AiPy章鱼用实际行动证明了:好的AI工具,就是要让大模型能干活、会干活,干好活。适配性不是小事,是决定AI工具生死存亡的大事。

未来,越来越多的任务会交给AI去执行——自动整理Excel、批量处理文件、操作专业软件……只有那些真正做好适配的框架,才能在AI生产力的浪潮中站稳脚跟。

别再手动调 Prompt 了!这款开源神器让 AI 输出质量提升 300%,支持 Claude、GPT、Gemini,还免费开源!

作者 前端Hardy
2026年3月17日 17:44

你是不是也这样?

  • 写 10 版提示词,AI 还是答非所问
  • 想让小模型做角色扮演,结果它“人格分裂”
  • 提取知识图谱,输出格式天天变
  • 本地部署 Ollama,但 Web 工具连不上……

而今天,我要介绍的这个 GitHub 23k Star 的开源神器,能一键优化你的提示词,让 GPT-4、Claude、Gemini 甚至 7B 小模型都稳定输出高质量结果

更惊人的是——
纯前端,数据不上传
支持 Chrome 插件,边写边优化
内置文生图(T2I)和图生图(I2I)
可私有化部署,还能接入 Claude Desktop
完全免费 + 开源 + 中文友好

它就是 —— Prompt Optimizer(提示词优化器)


一、为什么你需要一个“提示词优化器”?

大模型很强大,但提示词的质量决定输出上限
普通用户写:“写一首诗” → AI 随便糊弄。
高手写:“以‘春夜细雨’为主题,七言绝句,押平水韵,意象含柳、灯、纸伞,情感含蓄哀而不伤” → 出精品。

但没人天生会写好 Prompt。
Prompt Optimizer 能自动帮你把模糊需求转化为精准指令,并支持多轮迭代优化。

它不是“替代你思考”,而是“放大你的意图”。


二、三大核心场景,效果炸裂

场景 1:激发小模型潜力(降本增效)

在成本敏感或隐私要求高的场景(如本地 Ollama),结构化提示词能让 Qwen2.5-7B 稳定扮演“--”,对话一致性提升 300%。

无需微调,仅靠提示词工程,小模型也能“演得像”。

场景 2:保障生产环境稳定性

当你要程序化解析文本(如提取知识图谱),高质量提示词能让 输出格式 100% 符合 JSON Schema,避免后端解析崩溃。

降低对模型智能度的要求,经济的小模型也能扛生产。

场景 3:辅助创意探索

从“写首诗”到“指定意象+情感+格律”,工具帮你把模糊灵感细化为可执行指令,与 AI 共创独一无二的作品。


三、五大隐藏能力,远超想象

能力 说明
双模式优化 同时优化系统提示词(System Prompt)和用户提示词(User Prompt)
多模型集成 支持 OpenAI、Gemini、DeepSeek、智谱、SiliconFlow、Ollama(兼容 OpenAI 接口)
图像生成 内置 T2I(文生图)和 I2I(图生图),支持 Gemini、Seedream
高级测试模式 上下文变量管理、多轮会话测试、Function Calling 调试
纯客户端架构 所有数据直连 AI 服务商,不经过任何中间服务器

甚至支持 MCP 协议,可直接在 Claude Desktop 中调用优化服务!


四、四种使用方式,总有一款适合你

在线体验(最快)

直接访问:prompt.always200.com

  • 无需安装
  • 数据仅存浏览器本地
  • 支持所有核心功能

Chrome 插件(最方便)

  • 安装地址:Chrome 商店
  • 点击图标即可打开优化器
  • 边写提示词边优化

桌面应用(最稳定)

  • 下载地址:GitHub Releases
  • 无跨域限制(可直连本地 Ollama)
  • 自动更新
  • 独立运行,性能更强

Docker 私有化部署(最安全)

docker run -d -p 8081:80 \
  -e VITE_OPENAI_API_KEY=your_key \
  -e ACCESS_PASSWORD=your_password \
  --name prompt-optimizer \
  linshen/prompt-optimizer
  • 支持访问密码保护
  • 国内镜像加速:registry.cn-guangzhou.aliyuncs.com/prompt-optimizer/prompt-optimizer

五、为什么它能火遍全球?

  • 真正解决痛点:不是玩具,而是生产级工具
  • 极致用户体验:Web + 桌面 + 插件 + Docker 全覆盖
  • 开源精神:AGPL-3.0 协议,可商用但需开源衍生作品
  • 社区驱动:23k+ Star,2.8k+ Fork,23 位贡献者

项目文档极其完善:Vercel 部署指南MCP 使用说明


六、如何开始?30 秒上手

  1. 打开 prompt.always200.com
  2. 在左侧输入你的原始提示词(如“写一首关于春天的诗”)
  3. 点击“优化提示词”
  4. 查看优化前后对比,一键复制!

首次使用?点击右上角⚙️配置你的 API 密钥(OpenAI/Gemini/DeepSeek 等)


结语:好工具,值得被更多人看见

在这个 AI 爆发的时代,
不会写 Prompt 的人,正在被会用工具的人甩开

而 Prompt Optimizer 的出现,
让普通人也能写出接近专家级的提示词。

它不炫技,不炒作,
只是默默帮你——
把“随便问问”,变成“精准得到”。

抓紧转发给你身边有需要的人吧

在线体验:prompt.always200.com
GitHub:github.com/linshenkx/p…


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

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

2026年3月17日 17:41

前端富文本编辑器技术选型完全指南

一、富文本编辑器的三大技术流派

在深入选型之前,先理解富文本编辑器的底层实现原理差异:

┌─────────────────────────────────────────────────────────────────┐
                    富文本编辑器技术流派                            
├──────────────────┬──────────────────┬───────────────────────────┤
   L0: 基于            L1: 基于             L2: 完全自绘制            
   contentEditable     contentEditable      不依赖浏览器              
   + execCommand       + 自定义 Model       contentEditable          
├──────────────────┼──────────────────┼───────────────────────────┤
 浏览器原生能力        接管数据模型          自己实现光标、选区、       
 简单但不可控         可控性大幅提升        排版、渲染                 
├──────────────────┼──────────────────┼───────────────────────────┤
 代表:              代表:               代表:                      
 - UEditor          - Slate.js          - Google Docs              
 - wangEditor(v4)  - ProseMirror       - 腾讯文档                  
 - Quill(部分)     - Tiptap            - Canvas/自定义渲染         
                    - Draft.js                                     
                    - Lexical                                      
└──────────────────┴──────────────────┴───────────────────────────┘

二、主流编辑器横向对比

2.1 一览表

维度 wangEditor Quill TinyMCE Slate.js ProseMirror Tiptap Lexical
技术架构 L0→L1 L0/L1混合 L0 L1 L1 L1(基于ProseMirror) L1
框架依赖 无(v5) React Vue/React React
学习曲线 ⭐⭐ ⭐⭐⭐ ⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐
可扩展性 中高 极高 极高 极高
开箱即用 ✅ 极好 ✅ 好 ✅ 极好 ❌ 需大量开发 ❌ 需大量开发 ✅ 好 ⚠️ 一般
协同编辑 付费插件 需自行实现 Yjs集成 Yjs集成 需自行实现
中文支持 ⭐⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐
包体积 ~200KB ~40KB ~400KB+ ~100KB ~100KB ~150KB ~60KB
维护状态 活跃 较慢 活跃(商业) 活跃 活跃 活跃 活跃(Meta)
适用场景 CMS/后台 轻量评论 企业级CMS 定制编辑器 定制编辑器 中高定制 高性能场景
协议 MIT BSD MIT/商业 MIT MIT MIT MIT

2.2 选型决策树

你的需求是什么?
│
├── 快速上线,功能标准,后台管理系统
│   ├── Vue 项目 → wangEditor v5Tiptap
│   ├── React 项目 → Tiptap(@tiptap/react) 或 Lexical
│   └── 不限框架 → TinyMCE(功能最全)或 wangEditor
│
├── 需要高度定制(自定义块、嵌套结构、特殊交互)
│   ├── React 项目 → Slate.js(最灵活)或 Lexical
│   ├── Vue 项目 → Tiptap(基于ProseMirror,生态好)
│   └── 不限框架 → ProseMirror(底层能力最强)
│
├── 需要协同编辑
│   ├── 预算充足 → TinyMCE 商业版
│   └── 开源方案 → Tiptap + Yjs / ProseMirror + Yjs
│
├── 轻量级(评论框、简单富文本)
│   └── QuillwangEditor(配置精简模式)
│
└── 超大文档、极致性能
    └── LexicalMeta出品,虚拟化渲染)

三、各方案详细代码实战

3.1 wangEditor v5 —— 开箱即用之王

适用场景: 后台管理系统、CMS、博客编辑、中文场景

安装
npm install @wangeditor/editor @wangeditor/editor-for-vue
# Vue3:
# npm install @wangeditor/editor @wangeditor/editor-for-vue@next
Vue 2 完整示例
<template>
  <div class="editor-wrapper">
    <!-- 工具栏 -->
    <Toolbar
      :editor="editor"
      :defaultConfig="toolbarConfig"
      :mode="mode"
      class="toolbar"
    />
    <!-- 编辑区 -->
    <Editor
      :defaultConfig="editorConfig"
      :mode="mode"
      v-model="html"
      class="editor"
      @onCreated="handleCreated"
      @onChange="handleChange"
    />

    <!-- 预览 -->
    <div class="preview">
      <h3>输出 HTML:</h3>
      <div v-html="html" class="preview-content"></div>
    </div>
  </div>
</template>

<script>
import { Editor, Toolbar } from '@wangeditor/editor-for-vue';
import '@wangeditor/editor/dist/css/style.css';

export default {
  name: 'WangEditorDemo',
  components: { Editor, Toolbar },

  data() {
    return {
      editor: null,
      html: '<p>Hello <strong>wangEditor</strong>!</p>',
      mode: 'default', // 或 'simple' 精简模式

      // 工具栏配置
      toolbarConfig: {
        // 排除不需要的功能
        excludeKeys: [
          'fullScreen', // 排除全屏
          'group-video', // 排除视频
        ],
        // 或者用 toolbarKeys 自定义工具栏顺序
        // toolbarKeys: [ 'bold', 'italic', 'underline', '|', ... ]
      },

      // 编辑器配置
      editorConfig: {
        placeholder: '请输入内容...',
        // 所有粘贴配置
        MENU_CONF: {
          // 上传图片配置
          uploadImage: {
            server: '/api/upload/image',
            fieldName: 'file',
            maxFileSize: 5 * 1024 * 1024, // 5MB
            maxNumberOfFiles: 10,
            allowedFileTypes: ['image/*'],
            // 自定义请求头
            headers: {
              Authorization: 'Bearer xxx',
            },
            // 自定义插入图片(服务端返回格式不统一时)
            customInsert(res, insertFn) {
              const { url, alt, href } = res.data;
              insertFn(url, alt, href);
            },
            // 上传进度回调
            onProgress(progress) {
              console.log('上传进度:', progress);
            },
            onSuccess(file, res) {
              console.log('上传成功:', file.name);
            },
            onFailed(file, res) {
              console.error('上传失败:', file.name);
            },
            onError(file, err, res) {
              console.error('上传错误:', err);
            },
          },

          // 上传视频配置
          uploadVideo: {
            server: '/api/upload/video',
            fieldName: 'file',
            maxFileSize: 100 * 1024 * 1024, // 100MB
          },

          // 代码高亮语言配置
          codeSelectLang: {
            codeLangs: [
              { text: 'JavaScript', value: 'javascript' },
              { text: 'TypeScript', value: 'typescript' },
              { text: 'HTML', value: 'html' },
              { text: 'CSS', value: 'css' },
              { text: 'Python', value: 'python' },
              { text: 'Java', value: 'java' },
            ],
          },
        },
      },
    };
  },

  methods: {
    handleCreated(editor) {
      this.editor = Object.seal(editor); // 用 Object.seal 冻结 editor
      console.log('编辑器创建完成', editor);
    },

    handleChange(editor) {
      // 获取纯文本
      const text = editor.getText();
      // 获取 HTML
      const html = editor.getHtml();
      // 获取 JSON(结构化数据)
      const json = editor.children;

      console.log('内容变化:', { textLength: text.length });

      // 你可以在这里做自动保存、字数统计等
      this.$emit('change', { html, text, json });
    },

    // 外部调用:获取内容
    getContent() {
      return {
        html: this.editor.getHtml(),
        text: this.editor.getText(),
        json: this.editor.children,
      };
    },

    // 外部调用:设置内容
    setContent(html) {
      this.editor.setHtml(html);
    },

    // 外部调用:清空内容
    clear() {
      this.editor.clear();
    },

    // 外部调用:禁用/启用编辑
    toggleDisable(disabled) {
      if (disabled) {
        this.editor.disable();
      } else {
        this.editor.enable();
      }
    },
  },

  // 组件销毁时,销毁编辑器
  beforeDestroy() {
    if (this.editor) {
      this.editor.destroy();
    }
  },
};
</script>

<style scoped>
.editor-wrapper {
  border: 1px solid #e8e8e8;
  border-radius: 4px;
  overflow: hidden;
}
.toolbar {
  border-bottom: 1px solid #e8e8e8;
}
.editor {
  height: 400px;
  overflow-y: auto;
}
.preview {
  padding: 16px;
  border-top: 1px dashed #e8e8e8;
  background: #fafafa;
}
.preview-content {
  padding: 12px;
  background: white;
  border: 1px solid #eee;
  border-radius: 4px;
  min-height: 100px;
}
</style>
自定义扩展:@提及功能
// mention-plugin.js
import { Boot } from '@wangeditor/editor';

// 定义 mention 元素节点
const mentionModule = {
  // 注册新元素
  editorPlugin(editor) {
    const { isInline, isVoid } = editor;

    // mention 是行内元素
    editor.isInline = (elem) => {
      if (elem.type === 'mention') return true;
      return isInline(elem);
    };

    // mention 是 void 元素(不可编辑内部)
    editor.isVoid = (elem) => {
      if (elem.type === 'mention') return true;
      return isVoid(elem);
    };

    return editor;
  },

  // 渲染为 HTML
  elemsToHtml: [
    {
      type: 'mention',
      elemToHtml: (elem) => {
        const { value, info } = elem;
        return `<span data-w-e-type="mention" data-w-e-is-void data-w-e-is-inline data-value="${value}" data-info='${JSON.stringify(info)}'>@${info.name}</span>`;
      },
    },
  ],

  // 从 HTML 解析
  parseElemsHtml: [
    {
      selector: 'span[data-w-e-type="mention"]',
      parseElemHtml: (domElem) => {
        const value = domElem.getAttribute('data-value') || '';
        const info = JSON.parse(domElem.getAttribute('data-info') || '{}');
        return { type: 'mention', value, info, children: [{ text: '' }] };
      },
    },
  ],

  // 渲染到编辑器
  renderElems: [
    {
      type: 'mention',
      renderElem: (elem) => {
        const span = document.createElement('span');
        span.style.cssText = 'color: #1890ff; background: #e6f7ff; padding: 0 4px; border-radius: 2px; cursor: pointer;';
        span.textContent = `@${elem.info?.name || ''}`;
        return span;
      },
    },
  ],
};

// 注册模块
Boot.registerModule(mentionModule);

3.2 Tiptap —— 现代化最佳实践

适用场景: 中高度定制需求、Notion-like 编辑器、需要协同编辑

安装
# 核心
npm install @tiptap/vue-2 @tiptap/starter-kit

# 常用扩展
npm install @tiptap/extension-image @tiptap/extension-link \
  @tiptap/extension-placeholder @tiptap/extension-code-block-lowlight \
  @tiptap/extension-color @tiptap/extension-text-style \
  @tiptap/extension-task-list @tiptap/extension-task-item \
  @tiptap/extension-table @tiptap/extension-table-row \
  @tiptap/extension-table-cell @tiptap/extension-table-header \
  @tiptap/extension-character-count

# 代码高亮
npm install lowlight
Vue 2 完整示例
<template>
  <div class="tiptap-editor" :class="{ focused: isFocused }">
    <!-- 工具栏 -->
    <div v-if="editor" class="toolbar">
      <!-- 标题 -->
      <div class="toolbar-group">
        <select
          :value="currentHeading"
          @change="setHeading($event.target.value)"
          class="toolbar-select"
        >
          <option value="paragraph">正文</option>
          <option value="1">标题 1</option>
          <option value="2">标题 2</option>
          <option value="3">标题 3</option>
          <option value="4">标题 4</option>
        </select>
      </div>

      <div class="toolbar-divider"></div>

      <!-- 基础格式 -->
      <div class="toolbar-group">
        <button
          @click="editor.chain().focus().toggleBold().run()"
          :class="{ active: editor.isActive('bold') }"
          title="加粗 (Ctrl+B)"
        >
          <strong>B</strong>
        </button>
        <button
          @click="editor.chain().focus().toggleItalic().run()"
          :class="{ active: editor.isActive('italic') }"
          title="斜体 (Ctrl+I)"
        >
          <em>I</em>
        </button>
        <button
          @click="editor.chain().focus().toggleStrike().run()"
          :class="{ active: editor.isActive('strike') }"
          title="删除线"
        >
          <s>S</s>
        </button>
        <button
          @click="editor.chain().focus().toggleCode().run()"
          :class="{ active: editor.isActive('code') }"
          title="行内代码"
        >
          &lt;/&gt;
        </button>
      </div>

      <div class="toolbar-divider"></div>

      <!-- 文字颜色 -->
      <div class="toolbar-group">
        <input
          type="color"
          :value="editor.getAttributes('textStyle').color || '#000000'"
          @input="editor.chain().focus().setColor($event.target.value).run()"
          title="文字颜色"
          class="color-picker"
        />
      </div>

      <div class="toolbar-divider"></div>

      <!-- 列表 -->
      <div class="toolbar-group">
        <button
          @click="editor.chain().focus().toggleBulletList().run()"
          :class="{ active: editor.isActive('bulletList') }"
          title="无序列表"
        >
          • 列表
        </button>
        <button
          @click="editor.chain().focus().toggleOrderedList().run()"
          :class="{ active: editor.isActive('orderedList') }"
          title="有序列表"
        >
          1. 列表
        </button>
        <button
          @click="editor.chain().focus().toggleTaskList().run()"
          :class="{ active: editor.isActive('taskList') }"
          title="任务列表"
        >
          ☑ 任务
        </button>
      </div>

      <div class="toolbar-divider"></div>

      <!-- 引用 & 代码块 -->
      <div class="toolbar-group">
        <button
          @click="editor.chain().focus().toggleBlockquote().run()"
          :class="{ active: editor.isActive('blockquote') }"
          title="引用"
        >
          引用
        </button>
        <button
          @click="editor.chain().focus().toggleCodeBlock().run()"
          :class="{ active: editor.isActive('codeBlock') }"
          title="代码块"
        >
          代码块
        </button>
      </div>

      <div class="toolbar-divider"></div>

      <!-- 插入 -->
      <div class="toolbar-group">
        <button @click="addImage" title="插入图片">
          🖼 图片
        </button>
        <button @click="setLink" title="插入链接">
          🔗 链接
        </button>
        <button
          @click="editor.chain().focus().setHorizontalRule().run()"
          title="分割线"
        >
          ── 分割线
        </button>
        <button @click="insertTable" title="插入表格">
          📊 表格
        </button>
      </div>

      <div class="toolbar-divider"></div>

      <!-- 撤销/重做 -->
      <div class="toolbar-group">
        <button
          @click="editor.chain().focus().undo().run()"
          :disabled="!editor.can().undo()"
          title="撤销 (Ctrl+Z)"
        >
          ↩ 撤销
        </button>
        <button
          @click="editor.chain().focus().redo().run()"
          :disabled="!editor.can().redo()"
          title="重做 (Ctrl+Shift+Z)"
        >
          ↪ 重做
        </button>
      </div>
    </div>

    <!-- 编辑区域 -->
    <editor-content :editor="editor" class="editor-content" />

    <!-- 底部状态栏 -->
    <div v-if="editor" class="status-bar">
      <span>{{ characterCount }} 字符</span>
      <span>{{ wordCount }} 词</span>
    </div>
  </div>
</template>

<script>
import { Editor, EditorContent } from '@tiptap/vue-2';
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 CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
import TextStyle from '@tiptap/extension-text-style';
import Color from '@tiptap/extension-color';
import TaskList from '@tiptap/extension-task-list';
import TaskItem from '@tiptap/extension-task-item';
import Table from '@tiptap/extension-table';
import TableRow from '@tiptap/extension-table-row';
import TableCell from '@tiptap/extension-table-cell';
import TableHeader from '@tiptap/extension-table-header';
import CharacterCount from '@tiptap/extension-character-count';
import { lowlight } from 'lowlight';

export default {
  name: 'TiptapEditor',
  components: { EditorContent },

  props: {
    value: { type: String, default: '' },
    editable: { type: Boolean, default: true },
    maxLength: { type: Number, default: null },
  },

  data() {
    return {
      editor: null,
      isFocused: false,
    };
  },

  computed: {
    characterCount() {
      return this.editor?.storage.characterCount.characters() || 0;
    },
    wordCount() {
      return this.editor?.storage.characterCount.words() || 0;
    },
    currentHeading() {
      if (!this.editor) return 'paragraph';
      for (let i = 1; i <= 4; i++) {
        if (this.editor.isActive('heading', { level: i })) return String(i);
      }
      return 'paragraph';
    },
  },

  mounted() {
    this.editor = new Editor({
      // 内容
      content: this.value,

      // 是否可编辑
      editable: this.editable,

      // 扩展列表——Tiptap 的核心设计:一切皆扩展
      extensions: [
        // StarterKit 包含了基础扩展(段落、标题、加粗、斜体等)
        // 但我们要用 CodeBlockLowlight 替换默认的 codeBlock
        StarterKit.configure({
          codeBlock: false, // 禁用默认代码块,用高亮版替换
        }),

        // 图片
        Image.configure({
          inline: true,
          allowBase64: true,
          HTMLAttributes: {
            class: 'editor-image',
          },
        }),

        // 链接
        Link.configure({
          openOnClick: false, // 编辑模式下点击不跳转
          autolink: true,     // 自动识别URL
          linkOnPaste: true,  // 粘贴时自动转链接
          HTMLAttributes: {
            target: '_blank',
            rel: 'noopener noreferrer',
          },
        }),

        // 占位符
        Placeholder.configure({
          placeholder: '开始写作...',
        }),

        // 代码块 + 语法高亮
        CodeBlockLowlight.configure({
          lowlight,
          defaultLanguage: 'javascript',
        }),

        // 文字颜色
        TextStyle,
        Color,

        // 任务列表
        TaskList,
        TaskItem.configure({
          nested: true, // 支持嵌套
        }),

        // 表格
        Table.configure({
          resizable: true,
        }),
        TableRow,
        TableCell,
        TableHeader,

        // 字数统计
        CharacterCount.configure({
          limit: this.maxLength,
        }),
      ],

      // 事件回调
      onUpdate: ({ editor }) => {
        const html = editor.getHTML();
        this.$emit('input', html); // v-model 支持
        this.$emit('change', {
          html,
          json: editor.getJSON(),
          text: editor.getText(),
        });
      },

      onFocus: () => {
        this.isFocused = true;
        this.$emit('focus');
      },

      onBlur: () => {
        this.isFocused = false;
        this.$emit('blur');
      },

      onSelectionUpdate: ({ editor }) => {
        this.$emit('selection-change', editor);
      },
    });
  },

  methods: {
    // 设置标题级别
    setHeading(level) {
      if (level === 'paragraph') {
        this.editor.chain().focus().setParagraph().run();
      } else {
        this.editor
          .chain()
          .focus()
          .toggleHeading({ level: parseInt(level) })
          .run();
      }
    },

    // 插入图片
    addImage() {
      const url = window.prompt('请输入图片 URL:');
      if (url) {
        this.editor.chain().focus().setImage({ src: url }).run();
      }
    },

    // 文件上传方式插入图片
    async uploadImage(file) {
      const formData = new FormData();
      formData.append('file', file);
      try {
        const res = await fetch('/api/upload', {
          method: 'POST',
          body: formData,
        });
        const { url } = await res.json();
        this.editor.chain().focus().setImage({ src: url }).run();
      } catch (err) {
        console.error('图片上传失败:', err);
      }
    },

    // 设置链接
    setLink() {
      const previousUrl = this.editor.getAttributes('link').href;
      const url = window.prompt('请输入链接 URL:', previousUrl);

      if (url === null) return; // 取消
      if (url === '') {
        // 移除链接
        this.editor.chain().focus().extendMarkRange('link').unsetLink().run();
        return;
      }

      this.editor
        .chain()
        .focus()
        .extendMarkRange('link')
        .setLink({ href: url })
        .run();
    },

    // 插入表格
    insertTable() {
      this.editor
        .chain()
        .focus()
        .insertTable({ rows: 3, cols: 3, withHeaderRow: true })
        .run();
    },

    // 外部API:获取内容
    getContent() {
      return {
        html: this.editor.getHTML(),
        json: this.editor.getJSON(),
        text: this.editor.getText(),
      };
    },

    // 外部API:设置内容
    setContent(content) {
      this.editor.commands.setContent(content);
    },
  },

  watch: {
    value(newVal) {
      const currentHtml = this.editor.getHTML();
      if (newVal !== currentHtml) {
        this.editor.commands.setContent(newVal, false);
      }
    },
    editable(newVal) {
      this.editor.setEditable(newVal);
    },
  },

  beforeDestroy() {
    this.editor?.destroy();
  },
};
</script>

<style>
/* 编辑器外框 */
.tiptap-editor {
  border: 1px solid #d9d9d9;
  border-radius: 8px;
  overflow: hidden;
  transition: border-color 0.2s;
}
.tiptap-editor.focused {
  border-color: #1890ff;
  box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.1);
}

/* 工具栏 */
.toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 2px;
  padding: 8px;
  border-bottom: 1px solid #e8e8e8;
  background: #fafafa;
}
.toolbar-group {
  display: flex;
  gap: 2px;
}
.toolbar-divider {
  width: 1px;
  height: 24px;
  background: #d9d9d9;
  margin: 0 6px;
}
.toolbar button {
  padding: 4px 8px;
  border: 1px solid transparent;
  border-radius: 4px;
  background: transparent;
  cursor: pointer;
  font-size: 13px;
  color: #333;
  transition: all 0.15s;
  white-space: nowrap;
}
.toolbar button:hover {
  background: #e6f7ff;
  border-color: #91d5ff;
}
.toolbar button.active {
  background: #1890ff;
  color: white;
  border-color: #1890ff;
}
.toolbar button:disabled {
  opacity: 0.4;
  cursor: not-allowed;
}
.toolbar-select {
  padding: 4px 8px;
  border: 1px solid #d9d9d9;
  border-radius: 4px;
  font-size: 13px;
  background: white;
  cursor: pointer;
}

.color-picker {
  width: 32px;
  height: 28px;
  border: 1px solid #d9d9d9;
  border-radius: 4px;
  padding: 2px;
  cursor: pointer;
  background: white;
}

/* 编辑区域 */
.editor-content {
  padding: 16px 20px;
  min-height: 300px;
  max-height: 600px;
  overflow-y: auto;
}

/* ============ Tiptap 内部内容样式(ProseMirror)============ */
/* 注意:这些样式不能加 scoped,因为是渲染在 .ProseMirror 内部的 */
.ProseMirror {
  outline: none;
  font-size: 15px;
  line-height: 1.75;
  color: #333;
}

.ProseMirror > * + * {
  margin-top: 0.75em;
}

/* 占位符 */
.ProseMirror p.is-editor-empty:first-child::before {
  color: #adb5bd;
  content: attr(data-placeholder);
  float: left;
  height: 0;
  pointer-events: none;
}

/* 标题 */
.ProseMirror h1 { font-size: 2em; font-weight: 700; margin-top: 1em; }
.ProseMirror h2 { font-size: 1.5em; font-weight: 700; margin-top: 0.8em; }
.ProseMirror h3 { font-size: 1.25em; font-weight: 600; margin-top: 0.6em; }
.ProseMirror h4 { font-size: 1.1em; font-weight: 600; margin-top: 0.5em; }

/* 引用 */
.ProseMirror blockquote {
  border-left: 4px solid #1890ff;
  padding-left: 16px;
  margin-left: 0;
  color: #666;
  background: #f9f9f9;
  padding: 12px 16px;
  border-radius: 0 4px 4px 0;
}

/* 代码块 */
.ProseMirror pre {
  background: #282c34;
  color: #abb2bf;
  border-radius: 8px;
  padding: 16px;
  overflow-x: auto;
  font-family: 'Fira Code', 'Consolas', monospace;
  font-size: 14px;
  line-height: 1.5;
}
.ProseMirror pre code {
  background: none;
  color: inherit;
  padding: 0;
}

/* 行内代码 */
.ProseMirror code {
  background: #f0f0f0;
  color: #d63384;
  padding: 2px 6px;
  border-radius: 3px;
  font-size: 0.9em;
}

/* 链接 */
.ProseMirror a {
  color: #1890ff;
  text-decoration: underline;
  cursor: pointer;
}

/* 图片 */
.ProseMirror img.editor-image {
  max-width: 100%;
  height: auto;
  border-radius: 4px;
  margin: 8px 0;
}

/* 任务列表 */
.ProseMirror ul[data-type="taskList"] {
  list-style: none;
  padding-left: 0;
}
.ProseMirror ul[data-type="taskList"] li {
  display: flex;
  align-items: flex-start;
  gap: 8px;
}
.ProseMirror ul[data-type="taskList"] li label {
  margin-top: 3px;
}
.ProseMirror ul[data-type="taskList"] li[data-checked="true"] > div > p {
  text-decoration: line-through;
  color: #999;
}

/* 表格 */
.ProseMirror table {
  border-collapse: collapse;
  width: 100%;
  margin: 12px 0;
}
.ProseMirror th,
.ProseMirror td {
  border: 1px solid #d9d9d9;
  padding: 8px 12px;
  text-align: left;
  min-width: 80px;
}
.ProseMirror th {
  background: #fafafa;
  font-weight: 600;
}
.ProseMirror .selectedCell {
  background: #e6f7ff;
}

/* 分割线 */
.ProseMirror hr {
  border: none;
  border-top: 2px solid #e8e8e8;
  margin: 20px 0;
}

/* 状态栏 */
.status-bar {
  display: flex;
  gap: 16px;
  padding: 6px 16px;
  border-top: 1px solid #e8e8e8;
  background: #fafafa;
  font-size: 12px;
  color: #999;
}
</style>

3.3 Tiptap 自定义扩展:@提及(Mention)

这是 Tiptap 最强大的能力——自定义 Node/Mark 扩展:

src/extensions/MentionExtension.js

import { Node, mergeAttributes } from '@tiptap/core';
import { VueRenderer } from '@tiptap/vue-2';
import tippy from 'tippy.js';
import MentionList from './MentionList.vue';

/**
 * 自定义 Mention 扩展
 * 输入 @ 后弹出用户列表,选择后插入 @用户名 标签
 */
export default Node.create({
  name: 'mention',

  // 定义为行内元素、void 元素(不可编辑内部内容)
  group: 'inline',
  inline: true,
  selectable: false,
  atom: true, // 作为一个原子节点(整体选中/删除)

  // 定义该节点的属性
  addAttributes() {
    return {
      id: {
        default: null,
        parseHTML: (element) => element.getAttribute('data-mention-id'),
        renderHTML: (attributes) => ({
          'data-mention-id': attributes.id,
        }),
      },
      label: {
        default: null,
        parseHTML: (element) => element.getAttribute('data-mention-label'),
        renderHTML: (attributes) => ({
          'data-mention-label': attributes.label,
        }),
      },
    };
  },

  // 从 HTML 解析
  parseHTML() {
    return [
      {
        tag: 'span[data-type="mention"]',
      },
    ];
  },

  // 渲染为 HTML
  renderHTML({ node, HTMLAttributes }) {
    return [
      'span',
      mergeAttributes(
        {
          'data-type': 'mention',
          class: 'mention-tag',
        },
        HTMLAttributes
      ),
      `@${node.attrs.label}`,
    ];
  },

  // 渲染为文本(用于 getText())
  renderText({ node }) {
    return `@${node.attrs.label}`;
  },

  // 添加输入建议(核心:@ 触发)
  addProseMirrorPlugins() {
    const editor = this.editor;

    return [
      // 使用 ProseMirror 插件监听输入
      createMentionPlugin({
        editor,
        char: '@', // 触发字符
        // 查询用户列表的函数
        items: async (query) => {
          // 这里可以调用 API 搜索用户
          const allUsers = [
            { id: 1, name: '张三', avatar: '👤' },
            { id: 2, name: '李四', avatar: '👤' },
            { id: 3, name: '王五', avatar: '👤' },
            { id: 4, name: '赵六', avatar: '👤' },
            { id: 5, name: 'Admin', avatar: '👑' },
          ];

          return allUsers
            .filter((user) =>
              user.name.toLowerCase().includes(query.toLowerCase())
            )
            .slice(0, 5);
        },
        // 渲染下拉列表
        render: () => {
          let component;
          let popup;

          return {
            onStart: (props) => {
              component = new VueRenderer(MentionList, {
                parent: this,
                propsData: props,
              });

              popup = tippy('body', {
                getReferenceClientRect: props.clientRect,
                appendTo: () => document.body,
                content: component.element,
                showOnCreate: true,
                interactive: true,
                trigger: 'manual',
                placement: 'bottom-start',
              });
            },

            onUpdate: (props) => {
              component.updateProps(props);
              popup[0].setProps({
                getReferenceClientRect: props.clientRect,
              });
            },

            onKeyDown: (props) => {
              if (props.event.key === 'Escape') {
                popup[0].hide();
                return true;
              }
              return component.ref?.onKeyDown(props.event);
            },

            onExit: () => {
              popup[0].destroy();
              component.destroy();
            },
          };
        },
      }),
    ];
  },
});

/**
 * 创建 Mention 的 ProseMirror 插件(简化版)
 */
function createMentionPlugin({ editor, char, items, render }) {
  const { Plugin, PluginKey } = require('prosemirror-state');

  return new Plugin({
    key: new PluginKey('mention'),

    state: {
      init() {
        return { active: false, query: '', range: null };
      },
      apply(tr, prev) {
        const meta = tr.getMeta('mention');
        if (meta) return meta;
        if (tr.docChanged) return { active: false, query: '', range: null };
        return prev;
      },
    },

    view() {
      let rendererInstance = null;

      return {
        update: async (view, prevState) => {
          const { state } = view;
          const { selection } = state;
          const { $from } = selection;

          // 检测光标前是否有 @ 字符
          const textBefore = $from.parent.textContent.slice(0, $from.parentOffset);
          const match = textBefore.match(new RegExp(`\\${char}([\\w\\u4e00-\\u9fa5]*)$`));

          if (!match) {
            if (rendererInstance) {
              rendererInstance.onExit();
              rendererInstance = null;
            }
            return;
          }

          const query = match[1];
          const from = $from.pos - query.length - 1;
          const to = $from.pos;

          const matchedItems = await items(query);

          const props = {
            editor,
            query,
            items: matchedItems,
            clientRect: () => {
              const coords = view.coordsAtPos(from);
              return new DOMRect(coords.left, coords.top, 0, coords.bottom - coords.top);
            },
            command: (item) => {
              editor
                .chain()
                .focus()
                .deleteRange({ from, to })
                .insertContent({
                  type: 'mention',
                  attrs: { id: item.id, label: item.name },
                })
                .insertContent(' ')
                .run();
            },
          };

          if (!rendererInstance) {
            rendererInstance = render();
            rendererInstance.onStart(props);
          } else {
            rendererInstance.onUpdate(props);
          }
        },

        destroy() {
          if (rendererInstance) {
            rendererInstance.onExit();
          }
        },
      };
    },
  });
}

src/extensions/MentionList.vue

<template>
  <div class="mention-list">
    <div
      v-for="(item, index) in items"
      :key="item.id"
      class="mention-item"
      :class="{ selected: index === selectedIndex }"
      @click="selectItem(index)"
      @mouseenter="selectedIndex = index"
    >
      <span class="avatar">{{ item.avatar }}</span>
      <span class="name">{{ item.name }}</span>
    </div>
    <div v-if="!items.length" class="mention-empty">
      未找到匹配用户
    </div>
  </div>
</template>

<script>
export default {
  name: 'MentionList',
  props: {
    items: { type: Array, required: true },
    command: { type: Function, required: true },
  },

  data() {
    return { selectedIndex: 0 };
  },

  watch: {
    items() {
      this.selectedIndex = 0;
    },
  },

  methods: {
    onKeyDown(event) {
      if (event.key === 'ArrowUp') {
        this.selectedIndex =
          (this.selectedIndex - 1 + this.items.length) % this.items.length;
        return true;
      }
      if (event.key === 'ArrowDown') {
        this.selectedIndex = (this.selectedIndex + 1) % this.items.length;
        return true;
      }
      if (event.key === 'Enter') {
        this.selectItem(this.selectedIndex);
        return true;
      }
      return false;
    },

    selectItem(index) {
      const item = this.items[index];
      if (item) {
        this.command(item);
      }
    },
  },
};
</script>

<style scoped>
.mention-list {
  background: white;
  border: 1px solid #e8e8e8;
  border-radius: 8px;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
  padding: 4px;
  min-width: 180px;
}
.mention-item {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 8px 12px;
  border-radius: 4px;
  cursor: pointer;
  transition: background 0.1s;
}
.mention-item.selected,
.mention-item:hover {
  background: #e6f7ff;
}
.avatar {
  font-size: 18px;
}
.name {
  font-size: 14px;
  color: #333;
}
.mention-empty {
  padding: 12px;
  text-align: center;
  color: #999;
  font-size: 13px;
}
</style>

3.4 Slate.js (React) —— 极致灵活的底层框架

适用场景: 高度自定义编辑器(类 Notion、飞书文档)

安装
npm install slate slate-react slate-history
完整示例
import React, { useState, useCallback, useMemo } from 'react';
import { createEditor, Editor, Transforms, Text, Element as SlateElement } from 'slate';
import { Slate, Editable, withReact, useSlate, useSelected, useFocused } from 'slate-react';
import { withHistory } from 'slate-history';

// ============ 1. 自定义元素渲染 ============

/**
 * Slate 的核心理念:你完全控制每个节点如何渲染
 * 通过 renderElement 和 renderLeaf 两个函数
 */
const RenderElement = ({ attributes, children, element }) => {
  // attributes 必须展开到最外层 DOM
  // children 必须作为子节点渲染
  switch (element.type) {
    case 'heading-one':
      return <h1 {...attributes} style={{ fontSize: '2em', fontWeight: 700, marginTop: '0.5em' }}>{children}</h1>;

    case 'heading-two':
      return <h2 {...attributes} style={{ fontSize: '1.5em', fontWeight: 700, marginTop: '0.4em' }}>{children}</h2>;

    case 'heading-three':
      return <h3 {...attributes} style={{ fontSize: '1.25em', fontWeight: 600 }}>{children}</h3>;

    case 'blockquote':
      return (
        <blockquote
          {...attributes}
          style={{
            borderLeft: '4px solid #1890ff',
            paddingLeft: 16,
            color: '#666',
            background: '#f9f9f9',
            padding: '12px 16px',
            borderRadius: '0 4px 4px 0',
            margin: '8px 0',
          }}
        >
          {children}
        </blockquote>
      );

    case 'code-block':
      return (
        <pre
          {...attributes}
          style={{
            background: '#282c34',
            color: '#abb2bf',
            padding: 16,
            borderRadius: 8,
            fontFamily: "'Fira Code', monospace",
            fontSize: 14,
            overflow: 'auto',
          }}
        >
          <code>{children}</code>
        </pre>
      );

    case 'bulleted-list':
      return <ul {...attributes} style={{ paddingLeft: 24 }}>{children}</ul>;

    case 'numbered-list':
      return <ol {...attributes} style={{ paddingLeft: 24 }}>{children}</ol>;

    case 'list-item':
      return <li {...attributes}>{children}</li>;

    case 'image':
      return <ImageElement attributes={attributes} element={element}>{children}</ImageElement>;

    case 'divider':
      return (
        <div {...attributes} contentEditable={false} style={{ margin: '20px 0' }}>
          <hr style={{ border: 'none', borderTop: '2px solid #e8e8e8' }} />
          {children}
        </div>
      );

    default:
      return <p {...attributes} style={{ marginBottom: '0.5em', lineHeight: 1.75 }}>{children}</p>;
  }
};

/**
 * 叶子节点渲染(处理文本级别的格式:加粗、斜体、颜色等)
 */
const RenderLeaf = ({ attributes, children, leaf }) => {
  let el = children;

  if (leaf.bold) {
    el = <strong>{el}</strong>;
  }
  if (leaf.italic) {
    el = <em>{el}</em>;
  }
  if (leaf.underline) {
    el = <u>{el}</u>;
  }
  if (leaf.strikethrough) {
    el = <s>{el}</s>;
  }
  if (leaf.code) {
    el = (
      <code
        style={{
          background: '#f0f0f0',
          color: '#d63384',
          padding: '2px 6px',
          borderRadius: 3,
          fontSize: '0.9em',
        }}
      >
        {el}
      </code>
    );
  }
  if (leaf.color) {
    el = <span style={{ color: leaf.color }}>{el}</span>;
  }

  return <span {...attributes}>{el}</span>;
};

// ============ 2. 图片元素组件(Void 元素) ============

const ImageElement = ({ attributes, children, element }) => {
  const selected = useSelected();
  const focused = useFocused();

  return (
    <div {...attributes} contentEditable={false}>
      <img
        src={element.url}
        alt={element.alt || ''}
        style={{
          display: 'block',
          maxWidth: '100%',
          borderRadius: 4,
          boxShadow: selected && focused ? '0 0 0 3px #1890ff' : 'none',
          margin: '8px 0',
        }}
      />
      {children}
    </div>
  );
};

// ============ 3. 工具栏组件 ============

const Toolbar = () => {
  const editor = useSlate(); // 获取编辑器实例

  return (
    <div style={{
      display: 'flex',
      flexWrap: 'wrap',
      gap: 2,
      padding: 8,
      borderBottom: '1px solid #e8e8e8',
      background: '#fafafa',
    }}>
      {/* 文本格式 */}
      <MarkButton format="bold" label="B" style={{ fontWeight: 700 }} />
      <MarkButton format="italic" label="I" style={{ fontStyle: 'italic' }} />
      <MarkButton format="underline" label="U" style={{ textDecoration: 'underline' }} />
      <MarkButton format="strikethrough" label="S" style={{ textDecoration: 'line-through' }} />
      <MarkButton format="code" label="</>" />

      <Divider />

      {/* 块级格式 */}
      <BlockButton format="heading-one" label="H1" />
      <BlockButton format="heading-two" label="H2" />
      <BlockButton format="heading-three" label="H3" />
      <BlockButton format="blockquote" label="引用" />
      <BlockButton format="code-block" label="代码块" />

      <Divider />

      {/* 列表 */}
      <BlockButton format="bulleted-list" label="• 列表" />
      <BlockButton format="numbered-list" label="1. 列表" />

      <Divider />

      {/* 插入 */}
      <InsertImageButton />
      <InsertDividerButton />
    </div>
  );
};

const Divider = () => (
  <span style={{
    width: 1, height: 24, background: '#d9d9d9', margin: '0 6px', alignSelf: 'center',
  }} />
);

// ============ 4. 工具栏按钮组件 ============

/**
 * Mark按钮(文本级格式:加粗、斜体等)
 */
const MarkButton = ({ format, label, style = {} }) => {
  const editor = useSlate();
  const isActive = isMarkActive(editor, format);

  return (
    <button
      style={{
        padding: '4px 8px',
        border: '1px solid transparent',
        borderRadius: 4,
        background: isActive ? '#1890ff' : 'transparent',
        color: isActive ? 'white' : '#333',
        cursor: 'pointer',
        fontSize: 13,
        ...style,
      }}
      onMouseDown={(e) => {
        e.preventDefault(); // 防止失去焦点
        toggleMark(editor, format);
      }}
    >
      {label}
    </button>
  );
};

/**
 * Block按钮(块级格式:标题、引用等)
 */
const BlockButton = ({ format, label }) => {
  const editor = useSlate();
  const isActive = isBlockActive(editor, format);

  return (
    <button
      style={{
        padding: '4px 8px',
        border: '1px solid transparent',
        borderRadius: 4,
        background: isActive ? '#1890ff' : 'transparent',
        color: isActive ? 'white' : '#333',
        cursor: 'pointer',
        fontSize: 13,
      }}
      onMouseDown={(e) => {
        e.preventDefault();
        toggleBlock(editor, format);
      }}
    >
      {label}
    </button>
  );
};

/**
 * 插入图片按钮
 */
const InsertImageButton = () => {
  const editor = useSlate();

  return (
    <button
      style={{
        padding: '4px 8px', border: '1px solid transparent',
        borderRadius: 4, cursor: 'pointer', fontSize: 13,
      }}
      onMouseDown={(e) => {
        e.preventDefault();
        const url = window.prompt('请输入图片URL:');
        if (url) {
          insertImage(editor, url);
        }
      }}
    >
      🖼 图片
    </button>
  );
};

/**
 * 插入分割线按钮
 */
const InsertDividerButton = () => {
  const editor = useSlate();

  return (
    <button
      style={{
        padding: '4px 8px', border: '1px solid transparent',
        borderRadius: 4, cursor: 'pointer', fontSize: 13,
      }}
      onMouseDown={(e) => {
        e.preventDefault();
        Transforms.insertNodes(editor, {
          type: 'divider',
          children: [{ text: '' }],
        });
        // 在分割线后插入空段落
        Transforms.insertNodes(editor, {
          type: 'paragraph',
          children: [{ text: '' }],
        });
      }}
    >
      ── 分割线
    </button>
  );
};

// ============ 5. 编辑器操作工具函数 ============

const LIST_TYPES = ['bulleted-list', 'numbered-list'];

/**
 * 检查 Mark 是否激活
 */
function isMarkActive(editor, format) {
  const marks = Editor.marks(editor);
  return marks ? marks[format] === true : false;
}

/**
 * 切换 Mark
 */
function toggleMark(editor, format) {
  const isActive = isMarkActive(editor, format);
  if (isActive) {
    Editor.removeMark(editor, format);
  } else {
    Editor.addMark(editor, format, true);
  }
}

/**
 * 检查 Block 是否激活
 */
function isBlockActive(editor, format) {
  const [match] = Editor.nodes(editor, {
    match: (n) =>
      !Editor.isEditor(n) &&
      SlateElement.isElement(n) &&
      n.type === format,
  });
  return !!match;
}

/**
 * 切换 Block 类型
 */
function toggleBlock(editor, format) {
  const isActive = isBlockActive(editor, format);
  const isList = LIST_TYPES.includes(format);

  // 先解除所有列表包裹
  Transforms.unwrapNodes(editor, {
    match: (n) =>
      !Editor.isEditor(n) &&
      SlateElement.isElement(n) &&
      LIST_TYPES.includes(n.type),
    split: true,
  });

  // 设置节点类型
  Transforms.setNodes(editor, {
    type: isActive ? 'paragraph' : isList ? 'list-item' : format,
  });

  // 如果是列表,需要包裹
  if (!isActive && isList) {
    Transforms.wrapNodes(editor, {
      type: format,
      children: [],
    });
  }
}

/**
 * 插入图片
 */
function insertImage(editor, url) {
  const image = { type: 'image', url, children: [{ text: '' }] };
  Transforms.insertNodes(editor, image);
  // 在图片后插入空段落
  Transforms.insertNodes(editor, {
    type: 'paragraph',
    children: [{ text: '' }],
  });
}

// ============ 6. 自定义 withInlines 插件 ============

/**
 * 告诉编辑器哪些是 Void 元素(不可编辑内部内容的)
 */
function withCustomElements(editor) {
  const { isVoid, isInline } = editor;

  editor.isVoid = (element) => {
    return ['image', 'divider'].includes(element.type)
      ? true
      : isVoid(element);
  };

  return editor;
}

// ============ 7. 主组件 ============

const initialValue = [
  {
    type: 'heading-one',
    children: [{ text: 'Slate.js 富文本编辑器' }],
  },
  {
    type: 'paragraph',
    children: [
      { text: '这是一个' },
      { text: '完全自定义', bold: true },
      { text: '的富文本编辑器。Slate 让你控制' },
      { text: '每一个细节', italic: true, color: '#1890ff' },
      { text: '。' },
    ],
  },
  {
    type: 'blockquote',
    children: [{ text: 'Slate 的理念:提供构建编辑器的积木,而不是一个完整的编辑器。' }],
  },
  {
    type: 'code-block',
    children: [{ text: 'const editor = useMemo(\n  () => withCustomElements(withHistory(withReact(createEditor()))),\n  []\n);' }],
  },
  {
    type: 'paragraph',
    children: [{ text: '' }],
  },
];

export default function SlateEditor() {
  // 创建编辑器实例(useMemo 确保只创建一次)
  const editor = useMemo(
    () => withCustomElements(withHistory(withReact(createEditor()))),
    []
  );

  const [value, setValue] = useState(initialValue);

  const renderElement = useCallback((props) => <RenderElement {...props} />, []);
  const renderLeaf = useCallback((props) => <RenderLeaf {...props} />, []);

  // 快捷键处理
  const handleKeyDown = useCallback(
    (event) => {
      // Ctrl/Cmd + B = 加粗
      if (event.ctrlKey || event.metaKey) {
        switch (event.key) {
          case 'b':
            event.preventDefault();
            toggleMark(editor, 'bold');
            break;
          case 'i':
            event.preventDefault();
            toggleMark(editor, 'italic');
            break;
          case 'u':
            event.preventDefault();
            toggleMark(editor, 'underline');
            break;
          case '`':
            event.preventDefault();
            toggleMark(editor, 'code');
            break;
          default:
            break;
        }
      }

      // Markdown 快捷输入(在行首输入特定字符后按空格触发)
      if (event.key === ' ') {
        const { selection } = editor;
        if (selection && selection.anchor.offset > 0) {
          const [node] = Editor.node(editor, selection);
          if (Text.isText(node)) {
            const textBeforeCursor = node.text.slice(0, selection.anchor.offset);

            // # + 空格 = H1
            if (textBeforeCursor === '#') {
              event.preventDefault();
              Transforms.delete(editor, {
                at: {
                  anchor: { ...selection.anchor, offset: 0 },
                  focus: selection.anchor,
                },
              });
              toggleBlock(editor, 'heading-one');
              return;
            }
            // ## + 空格 = H2
            if (textBeforeCursor === '##') {
              event.preventDefault();
              Transforms.delete(editor, {
                at: {
                  anchor: { ...selection.anchor, offset: 0 },
                  focus: selection.anchor,
                },
              });
              toggleBlock(editor, 'heading-two');
              return;
            }
            // ### + 空格 = H3
            if (textBeforeCursor === '###') {
              event.preventDefault();
              Transforms.delete(editor, {
                at: {
                  anchor: { ...selection.anchor, offset: 0 },
                  focus: selection.anchor,
                },
              });
              toggleBlock(editor, 'heading-three');
              return;
            }
            // > + 空格 = 引用
            if (textBeforeCursor === '>') {
              event.preventDefault();
              Transforms.delete(editor, {
                at: {
                  anchor: { ...selection.anchor, offset: 0 },
                  focus: selection.anchor,
                },
              });
              toggleBlock(editor, 'blockquote');
              return;
            }
            // - 或 * + 空格 = 无序列表
            if (textBeforeCursor === '-' || textBeforeCursor === '*') {
              event.preventDefault();
              Transforms.delete(editor, {
                at: {
                  anchor: { ...selection.anchor, offset: 0 },
                  focus: selection.anchor,
                },
              });
              toggleBlock(editor, 'bulleted-list');
              return;
            }
            // 1. + 空格 = 有序列表
            if (/^\d+\.$/.test(textBeforeCursor)) {
              event.preventDefault();
              Transforms.delete(editor, {
                at: {
                  anchor: { ...selection.anchor, offset: 0 },
                  focus: selection.anchor,
                },
              });
              toggleBlock(editor, 'numbered-list');
              return;
            }
            // ``` + 空格 = 代码块
            if (textBeforeCursor === '```') {
              event.preventDefault();
              Transforms.delete(editor, {
                at: {
                  anchor: { ...selection.anchor, offset: 0 },
                  focus: selection.anchor,
                },
              });
              toggleBlock(editor, 'code-block');
              return;
            }
          }
        }
      }

      // Enter 在代码块中:插入换行而不是新段落
      if (event.key === 'Enter' && !event.shiftKey) {
        const [codeBlock] = Editor.nodes(editor, {
          match: (n) => SlateElement.isElement(n) && n.type === 'code-block',
        });
        if (codeBlock) {
          event.preventDefault();
          editor.insertText('\n');
          return;
        }
      }
    },
    [editor]
  );

  return (
    <div style={{
      border: '1px solid #d9d9d9',
      borderRadius: 8,
      overflow: 'hidden',
      maxWidth: 800,
      margin: '40px auto',
    }}>
      <Slate
        editor={editor}
        value={value}
        onChange={(newValue) => {
          setValue(newValue);

          // 检查内容是否真的变了(排除纯选区变化)
          const isContentChange = editor.operations.some(
            (op) => op.type !== 'set_selection'
          );
          if (isContentChange) {
            // 自动保存、同步等
            console.log('内容变化:', JSON.stringify(newValue));
          }
        }}
      >
        <Toolbar />
        <div style={{ padding: '16px 20px', minHeight: 300 }}>
          <Editable
            renderElement={renderElement}
            renderLeaf={renderLeaf}
            onKeyDown={handleKeyDown}
            placeholder="开始写作...(支持 Markdown 快捷输入)"
            spellCheck
            autoFocus
            style={{
              outline: 'none',
              fontSize: 15,
              lineHeight: 1.75,
            }}
          />
        </div>

        {/* 底部状态栏 */}
        <StatusBar />
      </Slate>
    </div>
  );
}

/**
 * 状态栏组件
 */
const StatusBar = () => {
  const editor = useSlate();

  const getStats = () => {
    const text = Editor.string(editor, []);
    return {
      chars: text.length,
      words: text.trim() ? text.trim().split(/\s+/).length : 0,
      blocks: editor.children.length,
    };
  };

  const stats = getStats();

  return (
    <div style={{
      display: 'flex',
      gap: 16,
      padding: '6px 16px',
      borderTop: '1px solid #e8e8e8',
      background: '#fafafa',
      fontSize: 12,
      color: '#999',
    }}>
      <span>{stats.chars} 字符</span>
      <span>{stats.words} 词</span>
      <span>{stats.blocks} 块</span>
    </div>
  );
};
Slate.js 序列化:JSON ↔ HTML 互转
// serializer.js

import { Text } from 'slate';
import escapeHtml from 'escape-html';

/**
 * Slate JSON → HTML
 */
export function slateToHtml(nodes) {
  return nodes.map((node) => serializeNode(node)).join('');
}

function serializeNode(node) {
  // 文本节点
  if (Text.isText(node)) {
    let text = escapeHtml(node.text);
    if (node.bold) text = `<strong>${text}</strong>`;
    if (node.italic) text = `<em>${text}</em>`;
    if (node.underline) text = `<u>${text}</u>`;
    if (node.strikethrough) text = `<s>${text}</s>`;
    if (node.code) text = `<code>${text}</code>`;
    if (node.color) text = `<span style="color:${node.color}">${text}</span>`;
    return text;
  }

  // 元素节点
  const children = node.children.map((n) => serializeNode(n)).join('');

  switch (node.type) {
    case 'heading-one':
      return `<h1>${children}</h1>`;
    case 'heading-two':
      return `<h2>${children}</h2>`;
    case 'heading-three':
      return `<h3>${children}</h3>`;
    case 'blockquote':
      return `<blockquote>${children}</blockquote>`;
    case 'code-block':
      return `<pre><code>${children}</code></pre>`;
    case 'bulleted-list':
      return `<ul>${children}</ul>`;
    case 'numbered-list':
      return `<ol>${children}</ol>`;
    case 'list-item':
      return `<li>${children}</li>`;
    case 'image':
      return `<img src="${escapeHtml(node.url)}" alt="${escapeHtml(node.alt || '')}" />`;
    case 'divider':
      return '<hr />';
    case 'paragraph':
    default:
      return `<p>${children}</p>`;
  }
}

/**
 * HTML → Slate JSON(简化版,生产环境建议用 slate-html-serializer)
 */
export function htmlToSlate(html) {
  const parser = new DOMParser();
  const doc = parser.parseFromString(html, 'text/html');
  return deserializeElement(doc.body);
}

function deserializeElement(el) {
  if (el.nodeType === Node.TEXT_NODE) {
    return [{ text: el.textContent }];
  }

  const children = Array.from(el.childNodes)
    .flatMap((child) => deserializeElement(child))
    .filter(Boolean);

  if (children.length === 0) {
    children.push({ text: '' });
  }

  switch (el.nodeName) {
    case 'BODY':
      return children;
    case 'H1':
      return [{ type: 'heading-one', children }];
    case 'H2':
      return [{ type: 'heading-two', children }];
    case 'H3':
      return [{ type: 'heading-three', children }];
    case 'BLOCKQUOTE':
      return [{ type: 'blockquote', children }];
    case 'PRE':
      return [{ type: 'code-block', children: [{ text: el.textContent }] }];
    case 'UL':
      return [{ type: 'bulleted-list', children }];
    case 'OL':
      return [{ type: 'numbered-list', children }];
    case 'LI':
      return [{ type: 'list-item', children }];
    case 'P':
      return [{ type: 'paragraph', children }];
    case 'IMG':
      return [{ type: 'image', url: el.src, children: [{ text: '' }] }];
    case 'HR':
      return [{ type: 'divider', children: [{ text: '' }] }];
    case 'STRONG':
    case 'B':
      return children.map((child) => ({ ...child, bold: true }));
    case 'EM':
    case 'I':
      return children.map((child) => ({ ...child, italic: true }));
    case 'U':
      return children.map((child) => ({ ...child, underline: true }));
    case 'S':
    case 'DEL':
      return children.map((child) => ({ ...child, strikethrough: true }));
    case 'CODE':
      return children.map((child) => ({ ...child, code: true }));
    default:
      return children;
  }
}

3.5 Lexical (React) —— Meta 出品的新一代编辑器

适用场景: 高性能、大文档、Meta 技术栈

安装
npm install lexical @lexical/react @lexical/rich-text @lexical/list \
  @lexical/link @lexical/code @lexical/table @lexical/utils \
  @lexical/selection @lexical/html
完整示例
import React, { useCallback } from 'react';
import { LexicalComposer } from '@lexical/react/LexicalComposer';
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin';
import { ListPlugin } from '@lexical/react/LexicalListPlugin';
import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin';
import { MarkdownShortcutPlugin } from '@lexical/react/LexicalMarkdownShortcutPlugin';
import LexicalErrorBoundary from '@lexical/react/LexicalErrorBoundary';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';

// 节点类型
import { HeadingNode, QuoteNode } from '@lexical/rich-text';
import { ListNode, ListItemNode } from '@lexical/list';
import { LinkNode, AutoLinkNode } from '@lexical/link';
import { CodeNode, CodeHighlightNode } from '@lexical/code';
import { TableNode, TableCellNode, TableRowNode } from '@lexical/table';

// 格式化命令
import {
  FORMAT_TEXT_COMMAND,
  FORMAT_ELEMENT_COMMAND,
  UNDO_COMMAND,
  REDO_COMMAND,
  $getSelection,
  $isRangeSelection,
  $createParagraphNode,
} from 'lexical';
import { $createHeadingNode, $isHeadingNode } from '@lexical/rich-text';
import { $createQuoteNode } from '@lexical/rich-text';
import {
  INSERT_ORDERED_LIST_COMMAND,
  INSERT_UNORDERED_LIST_COMMAND,
  REMOVE_LIST_COMMAND,
} from '@lexical/list';
import { $generateHtmlFromNodes, $generateNodesFromDOM } from '@lexical/html';
import { TRANSFORMERS } from '@lexical/markdown';

// ============ 编辑器配置 ============

const editorConfig = {
  namespace: 'MyLexicalEditor',

  // 主题样式映射
  theme: {
    paragraph: 'editor-paragraph',
    heading: {
      h1: 'editor-h1',
      h2: 'editor-h2',
      h3: 'editor-h3',
    },
    text: {
      bold: 'editor-bold',
      italic: 'editor-italic',
      underline: 'editor-underline',
      strikethrough: 'editor-strikethrough',
      code: 'editor-code-inline',
    },
    quote: 'editor-quote',
    code: 'editor-code-block',
    list: {
      ul: 'editor-ul',
      ol: 'editor-ol',
      listitem: 'editor-li',
    },
    link: 'editor-link',
  },

  // 注册所有用到的节点类型
  nodes: [
    HeadingNode,
    QuoteNode,
    ListNode,
    ListItemNode,
    LinkNode,
    AutoLinkNode,
    CodeNode,
    CodeHighlightNode,
    TableNode,
    TableCellNode,
    TableRowNode,
  ],

  onError(error) {
    console.error('Lexical Error:', error);
  },
};

// ============ 工具栏插件 ============

function ToolbarPlugin() {
  const [editor] = useLexicalComposerContext();
  const [activeFormats, setActiveFormats] = React.useState({
    bold: false,
    italic: false,
    underline: false,
    strikethrough: false,
    code: false,
  });
  const [blockType, setBlockType] = React.useState('paragraph');

  // 监听选区变化,更新工具栏状态
  React.useEffect(() => {
    return editor.registerUpdateListener(({ editorState }) => {
      editorState.read(() => {
        const selection = $getSelection();
        if ($isRangeSelection(selection)) {
          setActiveFormats({
            bold: selection.hasFormat('bold'),
            italic: selection.hasFormat('italic'),
            underline: selection.hasFormat('underline'),
            strikethrough: selection.hasFormat('strikethrough'),
            code: selection.hasFormat('code'),
          });

          // 检查块级类型
          const anchorNode = selection.anchor.getNode();
          const parent = anchorNode.getParent();
          if ($isHeadingNode(parent)) {
            setBlockType(parent.getTag()); // 'h1', 'h2', 'h3'
          } else {
            setBlockType(parent?.getType?.() || 'paragraph');
          }
        }
      });
    });
  }, [editor]);

  // 格式化文本
  const formatText = (format) => {
    editor.dispatchCommand(FORMAT_TEXT_COMMAND, format);
  };

  // 设置块类型
  const formatBlock = (type) => {
    editor.update(() => {
      const selection = $getSelection();
      if ($isRangeSelection(selection)) {
        switch (type) {
          case 'h1':
            // 如果当前已经是 h1,切回段落
            if (blockType === 'h1') {
              selection.getNodes().forEach((node) => {
                if ($isHeadingNode(node)) {
                  node.replace($createParagraphNode());
                }
              });
            } else {
              const heading = $createHeadingNode('h1');
              selection.insertNodes([heading]);
            }
            break;
          case 'h2': {
            const heading = $createHeadingNode('h2');
            selection.insertNodes([heading]);
            break;
          }
          case 'h3': {
            const heading = $createHeadingNode('h3');
            selection.insertNodes([heading]);
            break;
          }
          case 'quote': {
            const quote = $createQuoteNode();
            selection.insertNodes([quote]);
            break;
          }
          default:
            break;
        }
      }
    });
  };

  const btnStyle = (active) => ({
    padding: '4px 8px',
    border: '1px solid transparent',
    borderRadius: 4,
    background: active ? '#1890ff' : 'transparent',
    color: active ? 'white' : '#333',
    cursor: 'pointer',
    fontSize: 13,
  });

  return (
    <div style={{
      display: 'flex',
      flexWrap: 'wrap',
      gap: 2,
      padding: 8,
      borderBottom: '1px solid #e8e8e8',
      background: '#fafafa',
    }}>
      {/* 文本格式 */}
      <button style={btnStyle(activeFormats.bold)}
        onClick={() => formatText('bold')}>
        <strong>B</strong>
      </button>
      <button style={btnStyle(activeFormats.italic)}
        onClick={() => formatText('italic')}>
        <em>I</em>
      </button>
      <button style={btnStyle(activeFormats.underline)}
        onClick={() => formatText('underline')}>
        <u>U</u>
      </button>
      <button style={btnStyle(activeFormats.strikethrough)}
        onClick={() => formatText('strikethrough')}>
        <s>S</s>
      </button>
      <button style={btnStyle(activeFormats.code)}
        onClick={() => formatText('code')}>
        {'</>'}
      </button>

      <span style={{ width: 1, height: 24, background: '#d9d9d9', margin: '0 6px', alignSelf: 'center' }} />

      {/* 块级格式 */}
      <button style={btnStyle(blockType === 'h1')}
        onClick={() => formatBlock('h1')}>H1</button>
      <button style={btnStyle(blockType === 'h2')}
        onClick={() => formatBlock('h2')}>H2</button>
      <button style={btnStyle(blockType === 'h3')}
        onClick={() => formatBlock('h3')}>H3</button>
      <button style={btnStyle(blockType === 'quote')}
        onClick={() => formatBlock('quote')}>引用</button>

      <span style={{ width: 1, height: 24, background: '#d9d9d9', margin: '0 6px', alignSelf: 'center' }} />

      {/* 列表 */}
      <button style={btnStyle(false)}
        onClick={() => editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND)}>
        • 列表
      </button>
      <button style={btnStyle(false)}
        onClick={() => editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND)}>
        1. 列表
      </button>

      <span style={{ width: 1, height: 24, background: '#d9d9d9', margin: '0 6px', alignSelf: 'center' }} />

      {/* 撤销/重做 */}
      <button style={btnStyle(false)}
        onClick={() => editor.dispatchCommand(UNDO_COMMAND)}>
        ↩ 撤销
      </button>
      <button style={btnStyle(false)}
        onClick={() => editor.dispatchCommand(REDO_COMMAND)}>
        ↪ 重做
      </button>
    </div>
  );
}

// ============ HTML导出插件 ============

function HtmlExportPlugin({ onHtmlChange }) {
  const [editor] = useLexicalComposerContext();

  React.useEffect(() => {
    return editor.registerUpdateListener(({ editorState }) => {
      editorState.read(() => {
        const html = $generateHtmlFromNodes(editor);
        onHtmlChange?.(html);
      });
    });
  }, [editor, onHtmlChange]);

  return null;
}

// ============ 自动保存插件 ============

function AutoSavePlugin({ interval = 5000 }) {
  const [editor] = useLexicalComposerContext();

  React.useEffect(() => {
    let hasChanges = false;

    const unregister = editor.registerUpdateListener(() => {
      hasChanges = true;
    });

    const timer = setInterval(() => {
      if (hasChanges) {
        const editorState = editor.getEditorState();
        const json = JSON.stringify(editorState.toJSON());
        localStorage.setItem('lexical-draft', json);
        console.log('自动保存成功');
        hasChanges = false;
      }
    }, interval);

    return () => {
      unregister();
      clearInterval(timer);
    };
  }, [editor, interval]);

  return null;
}

// ============ 恢复草稿插件 ============

function RestoreDraftPlugin() {
  const [editor] = useLexicalComposerContext();

  React.useEffect(() => {
    const draft = localStorage.getItem('lexical-draft');
    if (draft) {
      try {
        const state = editor.parseEditorState(draft);
        editor.setEditorState(state);
        console.log('草稿已恢复');
      } catch (e) {
        console.warn('草稿恢复失败:', e);
      }
    }
  }, [editor]);

  return null;
}

// ============ 主组件 ============

export default function LexicalEditor() {
  const [htmlOutput, setHtmlOutput] = React.useState('');

  const onChange = useCallback((editorState) => {
    // editorState 是不可变的,可以安全序列化
    const json = editorState.toJSON();
    console.log('Editor state:', json);
  }, []);

  return (
    <div style={{ maxWidth: 800, margin: '40px auto' }}>
      <LexicalComposer initialConfig={editorConfig}>
        <div style={{
          border: '1px solid #d9d9d9',
          borderRadius: 8,
          overflow: 'hidden',
        }}>
          {/* 工具栏 */}
          <ToolbarPlugin />

          {/* 编辑区 */}
          <div style={{ padding: '16px 20px', minHeight: 300, position: 'relative' }}>
            <RichTextPlugin
              contentEditable={
                <ContentEditable
                  style={{
                    outline: 'none',
                    fontSize: 15,
                    lineHeight: 1.75,
                    minHeight: 250,
                  }}
                />
              }
              placeholder={
                <div style={{
                  position: 'absolute',
                  top: 16,
                  left: 20,
                  color: '#adb5bd',
                  pointerEvents: 'none',
                  fontSize: 15,
                }}>
                  开始写作...(支持 Markdown 快捷输入)
                </div>
              }
              ErrorBoundary={LexicalErrorBoundary}
            />
          </div>

          {/* 功能插件(不渲染UI,纯逻辑) */}
          <HistoryPlugin />
          <ListPlugin />
          <LinkPlugin />
          <MarkdownShortcutPlugin transformers={TRANSFORMERS} />
          <OnChangePlugin onChange={onChange} />
          <HtmlExportPlugin onHtmlChange={setHtmlOutput} />
          <AutoSavePlugin interval={5000} />
          <RestoreDraftPlugin />
        </div>
      </LexicalComposer>

      {/* HTML 输出预览 */}
      <div style={{
        marginTop: 24,
        padding: 16,
        border: '1px solid #e8e8e8',
        borderRadius: 8,
        background: '#fafafa',
      }}>
        <h3>HTML 输出:</h3>
        <pre style={{
          background: '#282c34',
          color: '#abb2bf',
          padding: 12,
          borderRadius: 4,
          overflow: 'auto',
          maxHeight: 200,
          fontSize: 13,
        }}>
          {htmlOutput}
        </pre>
      </div>
    </div>
  );
}

Lexical 对应的 CSS:

/* lexical-theme.css */
.editor-paragraph { margin-bottom: 8px; line-height: 1.75; }
.editor-h1 { font-size: 2em; font-weight: 700; margin: 16px 0 8px; }
.editor-h2 { font-size: 1.5em; font-weight: 700; margin: 12px 0 6px; }
.editor-h3 { font-size: 1.25em; font-weight: 600; margin: 10px 0 4px; }
.editor-bold { font-weight: 700; }
.editor-italic { font-style: italic; }
.editor-underline { text-decoration: underline; }
.editor-strikethrough { text-decoration: line-through; }
.editor-code-inline {
  background: #f0f0f0;
  color: #d63384;
  padding: 2px 6px;
  border-radius: 3px;
  font-family: monospace;
  font-size: 0.9em;
}
.editor-quote {
  border-left: 4px solid #1890ff;
  padding: 12px 16px;
  margin: 8px 0;
  color: #666;
  background: #f9f9f9;
}
.editor-code-block {
  background: #282c34;
  color: #abb2bf;
  padding: 16px;
  border-radius: 8px;
  font-family: 'Fira Code', monospace;
  font-size: 14px;
  overflow: auto;
}
.editor-ul { padding-left: 24px; list-style-type: disc; }
.editor-ol { padding-left: 24px; list-style-type: decimal; }
.editor-li { margin: 4px 0; }
.editor-link { color: #1890ff; text-decoration: underline; }

3.6 Quill —— 轻量级快速方案

适用场景: 评论框、简单编辑、快速集成

npm install quill@1.3.7
# Vue 封装
npm install vue-quill-editor
<template>
  <div class="quill-wrapper">
    <quill-editor
      ref="editor"
      v-model="content"
      :options="editorOptions"
      @change="onEditorChange"
      @focus="onEditorFocus"
      @blur="onEditorBlur"
    />
    <div class="char-count">{{ charCount }} / {{ maxLength }} 字</div>
  </div>
</template>

<script>
import 'quill/dist/quill.snow.css';
import { quillEditor } from 'vue-quill-editor';

// 自定义图片上传handler
function imageHandler() {
  const input = document.createElement('input');
  input.setAttribute('type', 'file');
  input.setAttribute('accept', 'image/*');
  input.click();

  input.onchange = async () => {
    const file = input.files[0];
    if (!file) return;

    // 文件大小校验
    if (file.size > 5 * 1024 * 1024) {
      alert('图片不能超过5MB');
      return;
    }

    const formData = new FormData();
    formData.append('file', file);

    try {
      const res = await fetch('/api/upload', {
        method: 'POST',
        body: formData,
      });
      const { url } = await res.json();

      // 获取光标位置,插入图片
      const quill = this.quill;
      const range = quill.getSelection(true);
      quill.insertEmbed(range.index, 'image', url);
      quill.setSelection(range.index + 1);
    } catch (err) {
      console.error('上传失败:', err);
    }
  };
}

export default {
  name: 'QuillEditorDemo',
  components: { quillEditor },

  props: {
    value: { type: String, default: '' },
    maxLength: { type: Number, default: 10000 },
  },

  data() {
    return {
        content: this.value,
        editorOptions: {
        theme: 'snow',
        placeholder: '请输入内容...',
        modules: {
          toolbar: {
            container: [
              [{ header: [1, 2, 3, 4, false] }],
              ['bold', 'italic', 'underline', 'strike'],
              [{ color: [] }, { background: [] }],
              [{ align: [] }],
              [{ list: 'ordered' }, { list: 'bullet' }],
              [{ indent: '-1' }, { indent: '+1' }],
              ['blockquote', 'code-block'],
              ['link', 'image', 'video'],
              ['clean'], // 清除格式
            ],
            // 自定义处理函数
            handlers: {
              image: imageHandler,
            },
          },

          // 剪贴板配置:控制粘贴行为
          clipboard: {
            matchVisual: false, // 不匹配视觉样式(减少脏HTML)
          },

          // 语法高亮(需要额外安装 highlight.js)
          // syntax: {
          //   highlight: (text) => hljs.highlightAuto(text).value,
          // },

          // 历史记录
          history: {
            delay: 1000,
            maxStack: 100,
            userOnly: true,
          },
        },

        // 支持的格式白名单(安全考虑)
        formats: [
          'header',
          'bold', 'italic', 'underline', 'strike',
          'color', 'background',
          'align',
          'list', 'indent',
          'blockquote', 'code-block',
          'link', 'image', 'video',
        ],
      },
    };
  },

  computed: {
    charCount() {
      // 获取纯文本长度
      if (!this.$refs.editor) return 0;
      const quill = this.$refs.editor.quill;
      if (!quill) return 0;
      return quill.getText().trim().length;
    },
  },

  watch: {
    value(newVal) {
      if (newVal !== this.content) {
        this.content = newVal;
      }
    },
    content(newVal) {
      this.$emit('input', newVal);
    },
  },

  methods: {
    onEditorChange({ quill, html, text }) {
      // 字数限制
      if (text.trim().length > this.maxLength) {
        quill.deleteText(this.maxLength, quill.getLength());
        return;
      }
      this.$emit('change', { html, text, delta: quill.getContents() });
    },

    onEditorFocus(quill) {
      this.$emit('focus', quill);
    },

    onEditorBlur(quill) {
      this.$emit('blur', quill);
    },

    // ====== 外部 API ======
    getQuill() {
      return this.$refs.editor?.quill;
    },
    getHTML() {
      return this.content;
    },
    getText() {
      return this.getQuill()?.getText()?.trim() || '';
    },
    getDelta() {
      return this.getQuill()?.getContents();
    },
    setHTML(html) {
      this.content = html;
    },
    clear() {
      this.content = '';
    },
    focus() {
      this.getQuill()?.focus();
    },
    disable() {
      this.getQuill()?.enable(false);
    },
    enable() {
      this.getQuill()?.enable(true);
    },
    // 插入文本到光标位置
    insertText(text) {
      const quill = this.getQuill();
      const range = quill.getSelection(true);
      quill.insertText(range.index, text);
    },
    // 插入嵌入内容
    insertEmbed(type, value) {
      const quill = this.getQuill();
      const range = quill.getSelection(true);
      quill.insertEmbed(range.index, type, value);
      quill.setSelection(range.index + 1);
    },
  },

  mounted() {
    // 可在此注册自定义 Blot(Quill 的扩展机制)
    this.registerCustomBlots();
  },

  methods: {
    // ...上面的方法

    registerCustomBlots() {
      const Quill = require('quill');
      const Inline = Quill.import('blots/inline');

      // 自定义 @提及 Blot
      class MentionBlot extends Inline {
        static create(data) {
          const node = super.create();
          node.setAttribute('data-mention-id', data.id);
          node.setAttribute('data-mention-name', data.name);
          node.textContent = `@${data.name}`;
          node.style.cssText = 'color:#1890ff;background:#e6f7ff;padding:0 4px;border-radius:2px;';
          return node;
        }
        static value(node) {
          return {
            id: node.getAttribute('data-mention-id'),
            name: node.getAttribute('data-mention-name'),
          };
        }
        static formats(node) {
          return {
            id: node.getAttribute('data-mention-id'),
            name: node.getAttribute('data-mention-name'),
          };
        }
      }
      MentionBlot.blotName = 'mention';
      MentionBlot.tagName = 'span';
      MentionBlot.className = 'mention-tag';

      Quill.register(MentionBlot);
    },
  },
};
</script>

<style scoped>
.quill-wrapper {
  position: relative;
}
.quill-wrapper >>> .ql-container {
  min-height: 300px;
  font-size: 15px;
  line-height: 1.75;
}
.quill-wrapper >>> .ql-editor {
  min-height: 300px;
  padding: 16px 20px;
}
.quill-wrapper >>> .ql-toolbar {
  border-top-left-radius: 8px;
  border-top-right-radius: 8px;
}
.quill-wrapper >>> .ql-container {
  border-bottom-left-radius: 8px;
  border-bottom-right-radius: 8px;
}
.char-count {
  position: absolute;
  bottom: 8px;
  right: 12px;
  font-size: 12px;
  color: #999;
}
</style>

四、协同编辑实现(Tiptap + Yjs)

这是最常被问到的高级功能,以下是完整的实现方案:

4.1 架构图

┌──────────────────────────────────────────────────────────┐
│                   协同编辑架构                             │
│                                                          │
│  ┌─────────┐    WebSocket    ┌──────────────┐            │
│  │ 客户端A  │ ◄────────────► │              │            │
│  │ Tiptap   │                │   Yjs Server │            │
│  │ + Y.js   │                │  (y-websocket│            │
│  └─────────┘                │   provider)  │            │
│                              │              │            │
│  ┌─────────┐    WebSocket    │              │            │
│  │ 客户端B  │ ◄────────────► │              │            │
│  │ Tiptap   │                └──────┬───────┘            │
│  │ + Y.js   │                       │                    │
│  └─────────┘                   持久化存储                 │
│                              ┌──────┴───────┐            │
│  ┌─────────┐                 │   LevelDB /  │            │
│  │ 客户端C  │ ...            │   PostgreSQL │            │
│  └─────────┘                 └──────────────┘            │
│                                                          │
│  Yjs 使用 CRDT 算法,无需中心化冲突解决                     │
│  每个客户端维护本地文档副本,增量同步                        │
└──────────────────────────────────────────────────────────┘

4.2 服务端

npm install y-websocket y-leveldb
// server/collab-server.js

const http = require('http');
const WebSocket = require('ws');
const { setupWSConnection } = require('y-websocket/bin/utils');
const { LeveldbPersistence } = require('y-leveldb');

// 持久化存储
const persistence = new LeveldbPersistence('./yjs-docs');

const server = http.createServer((req, res) => {
  res.writeHead(200, { 'Content-Type': 'text/plain' });
  res.end('Yjs WebSocket Server Running');
});

const wss = new WebSocket.Server({ server });

wss.on('connection', (ws, req) => {
  // 从 URL 中获取文档 ID
  const docName = req.url.slice(1).split('?')[0] || 'default';

  console.log(`[Collab] Client connected to doc: ${docName}`);
  console.log(`[Collab] Active connections: ${wss.clients.size}`);

  // 建立 Yjs WebSocket 连接
  setupWSConnection(ws, req, {
    docName,
    persistence,
    // gc: true, // 垃圾回收
  });

  ws.on('close', () => {
    console.log(`[Collab] Client disconnected from doc: ${docName}`);
  });
});

const PORT = 1234;
server.listen(PORT, () => {
  console.log(`[Collab] WebSocket server running on ws://localhost:${PORT}`);
});

4.3 客户端(Tiptap + Yjs)

# 安装协同依赖
npm install yjs y-websocket @tiptap/extension-collaboration \
  @tiptap/extension-collaboration-cursor
<template>
  <div class="collab-editor">
    <!-- 在线用户列表 -->
    <div class="online-users">
      <span class="label">在线:</span>
      <span
        v-for="user in onlineUsers"
        :key="user.clientId"
        class="user-badge"
        :style="{ background: user.color }"
      >
        {{ user.name }}
      </span>
      <span v-if="!connected" class="status-disconnected">⚠ 连接断开,尝试重连中...</span>
    </div>

    <!-- 工具栏(复用上面Tiptap的工具栏,此处省略) -->
    <div v-if="editor" class="toolbar">
      <button
        @click="editor.chain().focus().toggleBold().run()"
        :class="{ active: editor.isActive('bold') }"
      >B</button>
      <button
        @click="editor.chain().focus().toggleItalic().run()"
        :class="{ active: editor.isActive('italic') }"
      >I</button>
      <!-- ...其他按钮 -->
    </div>

    <!-- 编辑区 -->
    <editor-content :editor="editor" class="editor-content" />

    <!-- 连接状态 -->
    <div class="status-bar">
      <span :class="connected ? 'status-online' : 'status-offline'">
        {{ connected ? '● 已连接' : '○ 离线' }}
      </span>
      <span>{{ onlineUsers.length }} 人在线</span>
    </div>
  </div>
</template>

<script>
import { Editor, EditorContent } from '@tiptap/vue-2';
import StarterKit from '@tiptap/starter-kit';
import Collaboration from '@tiptap/extension-collaboration';
import CollaborationCursor from '@tiptap/extension-collaboration-cursor';
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';

// 随机颜色
const COLORS = [
  '#f44336', '#e91e63', '#9c27b0', '#673ab7',
  '#3f51b5', '#2196f3', '#03a9f4', '#00bcd4',
  '#009688', '#4caf50', '#8bc34a', '#ff9800',
];
function getRandomColor() {
  return COLORS[Math.floor(Math.random() * COLORS.length)];
}

// 获取当前用户信息(实际从登录状态获取)
function getCurrentUser() {
  const stored = localStorage.getItem('collab-user');
  if (stored) return JSON.parse(stored);

  const user = {
    name: '用户' + Math.floor(Math.random() * 1000),
    color: getRandomColor(),
  };
  localStorage.setItem('collab-user', JSON.stringify(user));
  return user;
}

export default {
  name: 'CollabEditor',
  components: { EditorContent },

  props: {
    // 文档ID,不同ID对应不同文档
    docId: {
      type: String,
      required: true,
    },
    wsUrl: {
      type: String,
      default: 'ws://localhost:1234',
    },
  },

  data() {
    return {
      editor: null,
      provider: null,
      ydoc: null,
      connected: false,
      onlineUsers: [],
      currentUser: getCurrentUser(),
    };
  },

  mounted() {
    this.initCollabEditor();
  },

  methods: {
    initCollabEditor() {
      // 1. 创建 Yjs 文档
      this.ydoc = new Y.Doc();

      // 2. 创建 WebSocket Provider(连接服务端)
      this.provider = new WebsocketProvider(
        this.wsUrl,
        this.docId, // 文档标识符
        this.ydoc,
        {
          connect: true,
          // 自动重连配置
          resyncInterval: 3000,
          maxBackoffTime: 10000,
          // WebSocket 参数
          params: {
            // token: 'xxx', // 可传认证token
          },
        }
      );

      // 3. 监听连接状态
      this.provider.on('status', ({ status }) => {
        this.connected = status === 'connected';
        console.log(`[Collab] 连接状态: ${status}`);
      });

      // 4. 设置当前用户的 awareness 信息(光标、用户名等)
      this.provider.awareness.setLocalStateField('user', {
        name: this.currentUser.name,
        color: this.currentUser.color,
      });

      // 5. 监听在线用户变化
      this.provider.awareness.on('change', () => {
        const states = this.provider.awareness.getStates();
        this.onlineUsers = [];
        states.forEach((state, clientId) => {
          if (state.user) {
            this.onlineUsers.push({
              clientId,
              ...state.user,
            });
          }
        });
      });

      // 6. 创建编辑器
      this.editor = new Editor({
        extensions: [
          StarterKit.configure({
            // 使用 Collaboration 的历史记录,禁用默认的
            history: false,
          }),

          // 协同编辑核心扩展
          Collaboration.configure({
            document: this.ydoc,
            // 指定 Yjs 中的 XML Fragment 字段名
            field: 'content',
          }),

          // 协同光标
          CollaborationCursor.configure({
            provider: this.provider,
            user: this.currentUser,
            // 自定义光标渲染
            render: (user) => {
              const cursor = document.createElement('span');
              cursor.classList.add('collab-cursor');
              cursor.style.borderColor = user.color;

              const label = document.createElement('span');
              label.classList.add('collab-cursor-label');
              label.style.background = user.color;
              label.textContent = user.name;
              cursor.appendChild(label);

              return cursor;
            },
          }),
        ],

        // 不需要设置初始 content,Yjs 会从服务端同步
      });
    },

    // 断开连接
    disconnect() {
      this.provider?.disconnect();
    },

    // 重新连接
    reconnect() {
      this.provider?.connect();
    },

    // 获取文档快照(用于导出)
    getSnapshot() {
      return {
        html: this.editor.getHTML(),
        json: this.editor.getJSON(),
        // Yjs 二进制快照(可用于恢复)
        yjsState: Y.encodeStateAsUpdate(this.ydoc),
      };
    },

    // 从快照恢复
    restoreFromSnapshot(yjsState) {
      Y.applyUpdate(this.ydoc, new Uint8Array(yjsState));
    },
  },

  watch: {
    docId(newId, oldId) {
      if (newId !== oldId) {
        // 文档切换时,销毁旧连接,建立新连接
        this.destroy();
        this.initCollabEditor();
      }
    },
  },

  beforeDestroy() {
    this.destroy();
  },

  methods: {
    // ... 上面的methods

    destroy() {
      this.editor?.destroy();
      this.provider?.disconnect();
      this.provider?.destroy();
      this.ydoc?.destroy();
    },
  },
};
</script>

<style>
.collab-editor {
  border: 1px solid #d9d9d9;
  border-radius: 8px;
  overflow: hidden;
}

/* 在线用户列表 */
.online-users {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 8px 12px;
  background: #f0f0f0;
  border-bottom: 1px solid #e8e8e8;
  font-size: 13px;
}
.online-users .label {
  color: #666;
}
.user-badge {
  padding: 2px 8px;
  border-radius: 12px;
  color: white;
  font-size: 12px;
}
.status-disconnected {
  color: #ff4d4f;
  margin-left: auto;
}

/* 协同光标样式 */
.collab-cursor {
  position: relative;
  border-left: 2px solid;
  margin-left: -1px;
  margin-right: -1px;
  pointer-events: none;
  word-break: normal;
}
.collab-cursor-label {
  position: absolute;
  top: -1.4em;
  left: -1px;
  padding: 1px 6px;
  border-radius: 4px 4px 4px 0;
  color: white;
  font-size: 11px;
  font-weight: 500;
  white-space: nowrap;
  user-select: none;
  pointer-events: none;
  line-height: 1.4;
}

/* 状态栏 */
.status-bar {
  display: flex;
  gap: 16px;
  padding: 6px 16px;
  border-top: 1px solid #e8e8e8;
  background: #fafafa;
  font-size: 12px;
}
.status-online { color: #52c41a; }
.status-offline { color: #ff4d4f; }
</style>

深度拆解 fetch-event-source库实现原理

2026年3月17日 17:38

前言

在 AI 大模型火热的今天,流式输出(Streaming)已成为标配。虽然浏览器原生提供了 EventSource (SSE),但在复杂的业务实战中,它却显得力不从心。本文将带你深度剖析 fetch-event-source 的底层实现,看看它是如何突破原生限制,优雅实现流式交互的。

一、 为什么原生 EventSource 走到了尽头?

原生 EventSource 在 AI 聊天场景中有两个“死穴”:

  1. 方法受限:只能发送 GET 请求。AI 聊天往往需要携带庞大的上下文(Context),URL 长度限制是无法逾越的障碍。
  2. 鉴权困境:无法自定义 Header。在需要通过 Authorization 传递 Token 的现代 Web 应用中,这非常致命。

fetch-event-source 的出现,本质上是给 fetch 套上了一层 SSE 的协议外壳,完美继承了 fetch 的灵活性。


二、 核心原理:基于 ReadableStream 的流式解析

fetch-event-source 的核心魔法在于利用了 fetch 返回值中的 Response.body。它是一个 ReadableStream(可读流),允许我们在数据还没全部到达时,就开始处理已经“流”进来的字节块。

1. 协议头强制对齐

要模拟 SSE,请求头必须严格遵守规范:

  • Accept: text/event-stream:告知后端我们需要流式响应。
  • Cache-Control: no-cache:禁用缓存,确保实时性。
  • Connection: keep-alive:保持长连接。

2. 状态机解析逻辑

由于 SSE 格式具有高度可预测性(以 \n 分隔行,以 \n\n 分隔消息块),我们可以通过一个简单的状态机进行逐行扫描:

  • data: 开头 -> 暂存数据片段。
  • event: 开头 -> 记录事件类型。
  • retry: 开头 -> 更新客户端的重连等待时间。
  • 空行 (\n\n) -> 表示一条消息解析完成,触发 onmessage 回调。

三、 手写一个简易版

理解原理最好的方式就是复刻它。以下是基于 fetchTextDecoder 的核心实现逻辑:

async function fetchEventSource(url, options) {
  const { signal, onopen, onmessage, onerror, retryDelay = 1000 } = options;
  let retryCount = 0;

  // 1. 循环处理(失败重试)
  while (!signal.aborted) {
    try {
      const response = await fetch(url, {
        method: 'POST', // 突破 GET 限制,支持 POST 发送上下文
        headers: {
          'Accept': 'text/event-stream',
          'Cache-Control': 'no-cache',
          'Content-Type': 'application/json',
          ...options.headers,
        },
        body: JSON.stringify(options.body),
        signal,
      });

      // 2. 响应合法性校验
      if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
      if (!response.headers.get('Content-Type')?.includes('text/event-stream')) {
        throw new Error('Invalid Content-Type, expected text/event-stream');
      }

      onopen?.({ response });

      // 3. 读取流式响应体 (核心)
      const reader = response.body.getReader();
      const decoder = new TextDecoder('utf-8');
      let buffer = ''; 

      while (true) {
        const { done, value } = await reader.read();
        if (done) break;

        // 解码二进制数据并追加到缓冲区
        buffer += decoder.decode(value, { stream: true });
        
        // 4. 按 SSE 规范拆分消息块 (\n\n)
        let parts = buffer.split('\n\n');
        buffer = parts.pop(); // 最后一个可能是残缺的,留到下一轮处理

        for (const part of parts) {
          // 这里解析 data: event: 等字段
          const parsed = parseSSEPart(part); 
          onmessage?.(parsed);
        }
      }

      await reader.releaseLock();
      if (signal.aborted) break;

      throw new Error('Connection closed by server');
    } catch (error) {
      // 5. 错误处理与指数退避重连
      const retry = onerror?.(error) ?? true;
      if (!retry || signal.aborted) break;

      const delay = retryDelay * Math.pow(2, retryCount);
      await new Promise(resolve => setTimeout(resolve, delay));
      retryCount++;
    }
  }
}

四、 总结

fetch-event-source 并不是魔法,它只是站在了 fetchReadableStream 的肩膀上,通过手动实现 SSE 协议解析,解决了原生 API 的痛点。在 AI 对话应用中,它是实现实时、鉴权、高扩展性流式输出的最佳实践。

ES2025 JavaScript 新特性预览

2026年3月17日 17:29

ES2025 JavaScript 新特性预览

从 Temporal 日期时间 API 到装饰器语法,抢先了解 JavaScript 语言的最新演进方向


前言

JavaScript 语言一直在不断演进,每年都有新特性加入 ECMAScript 标准。

ES2025 即将带来哪些令人期待的新特性?

  • Temporal 日期时间 API 终于要来了
  • 装饰器语法历经多年修订即将定稿
  • 显式资源管理让代码更简洁
  • 模块延迟加载优化大型应用性能

下面我们来详细了解这些新特性,掌握 JavaScript 语言的最新发展方向。


一、Temporal 日期时间 API

现有 Date API 的问题

JavaScript 的 Date API 一直被开发者诟病:

// 不推荐:Date 是可变对象
const date = new Date();
date.setMonth(date.getMonth() + 1); // 修改了原对象

// 不推荐:月份从 0 开始,容易混淆
const date = new Date(2024, 1, 1); // 2 月 1 日,不是 1 月 1 日

// 不推荐:时区处理复杂
const date = new Date();
date.getTimezoneOffset(); // 返回分钟数,需要手动计算

// 不推荐:精度低
const now = Date.now(); // 毫秒级精度

Temporal 的核心优势

1. 不可变对象

const date = Temporal.Now.plainDateISO();
const nextMonth = date.add({ months: 1 }); // 返回新对象,不修改原对象

2. 直观的 API

// 创建日期
const date = Temporal.PlainDate.from('2024-02-01');
console.log(date.month); // 2(不是 1)
console.log(date.day); // 1

// 日期计算
const tomorrow = date.add({ days: 1 });
const nextYear = date.add({ years: 1 });

3. 时区友好

// 获取当前时区的日期时间
const now = Temporal.Now.plainDateTimeISO();

// 指定时区
const tokyoTime = Temporal.Now.plainDateTimeISO('Asia/Tokyo');

// 时区转换
const zoned = date.toZonedDateTime({ timeZone: 'America/New_York' });

4. 高精度

const instant = Temporal.Now.instant();
console.log(instant.epochNanoseconds); // 纳秒级精度

实战示例

// 计算两个日期之间的差异
const start = Temporal.PlainDate.from('2024-01-01');
const end = Temporal.PlainDate.from('2024-12-31');

const diff = end.since(start, { largestUnit: 'months' });
console.log(diff.months); // 11

// 日期比较
if (end.compareTo(start) > 0) {
  console.log('end 在 start 之后');
}

// 日历计算
const chineseNewYear = Temporal.PlainDate.from('2024-02-10');
const duration = chineseNewYear.since(start);
console.log(duration.days); // 40

二、装饰器语法(Decorators)

装饰器的作用

装饰器为类和类方法提供元编程能力,可以在不修改原有代码的情况下添加功能。

最终语法(2025 版)

// 类装饰器
function logged(target, context) {
  return function(...args) {
    console.log(`Calling ${context.name}`);
    return target(...args);
  };
}

class MyClass {
  @logged
  myMethod() {
    console.log('执行中');
  }
}

// 使用
const obj = new MyClass();
obj.myMethod();
// 输出:
// Calling myMethod
// 执行中

常见装饰器示例

1. 只读属性

function readonly(target, context) {
  return {
    get() {
      return target.get.call(this);
    },
    set(value) {
      throw new Error('Cannot assign to read-only property');
    }
  };
}

class Person {
  @readonly
  name = 'John';
}

const p = new Person();
console.log(p.name); // John
p.name = 'Jane'; // 抛出错误

2. 缓存方法结果

function cached(target, context) {
  const cache = new WeakMap();
  
  return function(...args) {
    const key = JSON.stringify(args);
    if (cache.has(this) && cache.get(this).has(key)) {
      return cache.get(this).get(key);
    }
    
    const result = target.call(this, ...args);
    
    if (!cache.has(this)) {
      cache.set(this, new Map());
    }
    cache.get(this).set(key, result);
    
    return result;
  };
}

class Calculator {
  @cached
  expensiveCalculation(x, y) {
    console.log('计算中...');
    return x + y;
  }
}

const calc = new Calculator();
calc.expensiveCalculation(1, 2); // 计算中...
calc.expensiveCalculation(1, 2); // 直接从缓存返回

3. 自动绑定 this

function autobind(target, context) {
  return function(...args) {
    return target.call(this, ...args);
  };
}

class Button {
  constructor() {
    this.clicked = false;
  }
  
  @autobind
  handleClick() {
    this.clicked = true;
    console.log('Button clicked');
  }
}

const button = new Button();
const handler = button.handleClick;
handler(); // this 正确绑定

三、显式资源管理

问题背景

JavaScript 中经常需要手动管理资源:

// 需要手动关闭文件
const file = openFile('test.txt');
try {
  const content = file.read();
  console.log(content);
} finally {
  file.close(); // 容易忘记
}

using 关键字

// 自动管理资源
{
  using file = openFile('test.txt');
  const content = file.read();
  console.log(content);
} // 自动调用 file.close()

// 多个资源
{
  using file1 = openFile('a.txt');
  using file2 = openFile('b.txt');
  // 按相反顺序自动关闭
}

实现原理

// 可处置对象
class Resource {
  [Symbol.dispose]() {
    console.log('资源已释放');
  }
}

// 使用
{
  using resource = new Resource();
  // 使用资源
} // 自动调用 [Symbol.dispose]()

异步资源管理

// await using 用于异步资源
{
  await using connection = await openDatabase();
  const data = await connection.query('SELECT * FROM users');
  console.log(data);
} // 自动调用 await connection.close()

四、模块延迟加载

问题背景

大型应用中,模块加载可能影响首屏性能:

// 所有模块立即加载
import { heavyModule } from './heavy.js';
import { anotherModule } from './another.js';

// 即使暂时用不到,也会阻塞加载

defer 关键字

// 延迟加载
import defer { heavyModule } from './heavy.js';

// 模块不会立即加载,直到实际使用
heavyModule.doSomething(); // 此时才加载

条件加载

// 根据条件加载
if (featureEnabled) {
  import defer { featureModule } from './feature.js';
  featureModule.init();
}

五、其他新特性

1. Source Phase Imports

允许在编译时导入资源:

// 导入源码而非执行结果
import source mySource from './module.js';

console.log(mySource); // 模块的源代码字符串

应用场景

  • 类型定义导入
  • 代码转换工具
  • 静态分析

2. Atomics.pause

优化自旋锁性能:

// 等待共享内存状态改变
while (Atomics.load(sharedArray, 0) === 0) {
  Atomics.pause(); // 让出 CPU,降低功耗
}

3. Array Buffer 转移

// 转移 ArrayBuffer 的所有权
const buffer1 = new ArrayBuffer(1024);
const buffer2 = buffer1.transfer();

console.log(buffer1.byteLength); // 0
console.log(buffer2.byteLength); // 1024

优势:避免复制大缓冲区,提升性能。


六、特性成熟度

特性 Stage 预计发布
Temporal Stage 3 2025 年
Decorators Stage 3 2025 年
Explicit Resource Management Stage 3 2025 年
Deferring Module Evaluation Stage 2 2026 年
Source Phase Imports Stage 2 2026 年
Atomics.pause Stage 2 2025 年

Stage 说明

  • Stage 2:提案阶段,可能变化
  • Stage 3:候选阶段,基本稳定
  • Stage 4:完成阶段,将加入标准

七、如何使用新特性

1. Babel 转译

Babel 是最常用的 JavaScript 转译工具,可以将新特性转译为兼容旧环境的代码。

安装方式

npm install @babel/core @babel/cli

配置 .babelrc

{
  "plugins": [
    "@babel/plugin-proposal-decorators",
    "@babel/plugin-proposal-explicit-resource-management"
  ]
}

适用场景:需要在生产环境使用 ES2025 新特性,且目标浏览器不支持时。

2. TypeScript 支持

TypeScript 对部分 ES2025 特性提供了实验性支持。

安装方式

npm install -D typescript

配置 tsconfig.json

{
  "compilerOptions": {
    "experimentalDecorators": true,
    "target": "ESNext"
  }
}

适用场景:使用 TypeScript 开发,需要类型检查和新特性支持。

3. 浏览器支持

查看兼容性:caniuse.com/

目前大多数新特性需要转译才能在生产环境使用。建议在实验性项目中尝试,生产环境谨慎使用。


总结

ES2025 带来的新特性将显著提升 JavaScript 的开发体验:

  1. Temporal - 解决 Date API 的所有痛点
  2. 装饰器 - 提供强大的元编程能力
  3. 显式资源管理 - 自动清理,避免泄漏
  4. 模块延迟加载 - 优化大型应用性能

建议关注 TC39 提案进展,在实验性项目中尝试新特性,生产环境使用 Babel 或 TypeScript 转译。

JavaScript 语言仍在快速发展,保持学习才能跟上时代。


参考资料

  1. TC39 Proposals: github.com/tc39/propos…
  2. Temporal Proposal: tc39.es/proposal-te…
  3. Decorators Proposal: github.com/tc39/propos…
  4. Explicit Resource Management: github.com/tc39/propos…
  5. MDN - JavaScript 新特性:developer.mozilla.org/zh-CN/docs/…

觉得文章对你有帮助?欢迎点赞收藏,分享给更多需要的朋友!

SSE 流式传输:中断超时处理

2026年3月17日 17:23

前言

在开发 AI 聊天应用时,fetch-event-source 几乎是前端标配。但你是否思考过:为什么原生的 EventSource 不行?它是如何解析二进制流的?当网络波动导致连接“假死”时,如何实现无感重连和数据去重?本文将带你拆解这些核心细节。

一、 为什么原生 EventSource 在 AI 场景“退环境”了?

原生 EventSource 虽好,但在复杂的 AI 业务场景中有两个“致命伤”:

  1. 仅支持 GET 请求:AI 对话通常需要发送长篇累牍的上下文(Context),URL 长度限制会导致请求失败。
  2. 无法自定义 Header:无法在请求头中携带 Authorization 令牌,给鉴权带来了麻烦。

fetch-event-source 的原理:它是基于原生 fetchReadableStream(可读流) 实现的。它通过手动解析 HTTP 响应体中的二进制数据,模拟了 SSE 的行为,同时继承了 fetch 支持各种 Method 和 Header 的灵活性。


二、 核心实战:如何处理 SSE 异常中断与超时?

在长连接中,最怕“连接还在,但数据没了”的假死状态。我们需要对库进行二次封装,引入超时检测指数退避重连

1. 超时检测机制

设置一个心跳定时器。如果在规定时间内(如 15s)没有收到任何 onmessage 信号,说明连接可能已失效。

  • 动作:主动调用 abort() 中断当前请求,并触发重连。
  • 重置:每当有新数据到达或连接开启时,重置该定时器。

2. 指数退避自动重连

为了减轻服务器压力,重连间隔不应是固定的。

  • 策略:从 2s 开始,每次失败翻倍(2s → 4s → 8s...),上限 30s。
  • 终止:设置最大重连次数(如 10 次),失败后提示用户“服务器繁忙,请手动重试”。

3. 断点续传与去重

重连后,后端可能会重新推送历史数据。

  • 前端方案:维护一个 lastMsgId。请求时带上这个标识,让后端从断点处开始推送;或者前端根据 id 对收到的消息进行 Map 去重。

三、 中断超时处理实现:基于fetchEventSource 简易实现

import { fetchEventSource } from '@microsoft/fetch-event-source'
import { ElMessage, ElMessageBox } from 'element-plus'

// 全局状态管理(避免多请求冲突)
let controller = new AbortController()
let timeoutTimer = null // 超时定时器
let reconnectCount = 0 // 重连次数
let reconnectInterval = 2000 // 初始重连间隔(2s)
const MAX_RECONNECT_COUNT = 10 // 最大重连次数
const MAX_RECONNECT_INTERVAL = 30000 // 最大重连间隔(30s)
let lastMessageId = '' // 记录最后一条消息ID(断点续传用)

/**
 * 重置超时定时器(收到消息/建立连接时调用)
 * @param {number} timeout 超时时间(默认30s)
 */
const resetTimeoutTimer = (timeout = 30000) => {
  // 清除原有定时器
  if (timeoutTimer) clearTimeout(timeoutTimer)
  // 新建超时定时器:超时未收到消息则主动中断
  timeoutTimer = setTimeout(() => {
    ElMessage.warning('连接超时,正在尝试重连...')
    controller.abort() // 主动中断请求
    reconnectStream() // 触发重连
  }, timeout)
}

/**
 * 重连流式请求(指数退避策略)
 * @param {string} url 接口地址
 * @param {Object} headers 请求头
 * @param {Object} data 请求参数
 * @param {Function} handleMessage 消息处理回调
 */
const reconnectStream = async (url, headers, data, handleMessage) => {
  // 超过最大重连次数,停止自动重连
  if (reconnectCount >= MAX_RECONNECT_COUNT) {
    ElMessageBox.alert('服务器繁忙,请稍后手动重试', '重连失败', {
      confirmButtonText: '确定'
    })
    // 重置重连状态
    reconnectCount = 0
    reconnectInterval = 2000
    return
  }

  // 指数退避:间隔翻倍,不超过30s
  const currentInterval = Math.min(reconnectInterval, MAX_RECONNECT_INTERVAL)
  ElMessage.info(`第${reconnectCount + 1}次重连,间隔${currentInterval / 1000}s...`)

  // 延迟重连
  await new Promise((resolve) => setTimeout(resolve, currentInterval))

  // 更新重连状态
  reconnectCount++
  reconnectInterval *= 2

  // 重新发起请求(携带最后一条消息ID,实现断点续传)
  requestStream(
    url,
    headers,
    {
      ...data,
      lastMessageId: lastMessageId // 传给后端,让后端从断点续传
    },
    handleMessage
  )
}

/**
 * 流式请求核心方法(带超时、重连、断点续传)
 * @param {string} url 接口地址
 * @param {Object} headers 请求头
 * @param {Object} data 请求参数
 * @param {Function} handleMessage 消息处理回调(接收流式数据)
 */
export const requestStream = (url, headers, data, handleMessage) => {
  // 中断原有请求
  if (controller) controller.abort()
  controller = new AbortController()

  // 初始化超时定时器(30s超时检测)
  resetTimeoutTimer()

  fetchEventSource(url, {
    method: 'POST',
    signal: controller.signal,
    headers: {
      ...headers,
      Accept: 'text/event-stream', // SSE必需头
      'Cache-Control': 'no-cache'
    },
    body: JSON.stringify(data),
    openWhenHidden: true, // 页面隐藏时继续请求
    async onopen(response) {
      console.log('建立连接的回调')
      // 连接建立:重置超时定时器+重连状态
      resetTimeoutTimer()
      reconnectCount = 0
      reconnectInterval = 2000

      // 校验响应合法性
      if (!response.ok) {
        throw new Error(`连接失败,状态码:${response.status}`)
      }
    },
    onmessage(msg) {
      // 收到消息:重置超时定时器
      resetTimeoutTimer()

      // 记录最后一条消息ID(断点续传核心)
      if (msg.id) lastMessageId = msg.id
      // 处理消息(去重逻辑:避免重连后数据重复)
      handleMessage(msg)
    },
    onclose() {
      console.log('连接正常关闭')
      // 清除定时器+中断请求
      if (timeoutTimer) clearTimeout(timeoutTimer)
      controller.abort()
      // 重置状态
      reconnectCount = 0
      reconnectInterval = 2000
      lastMessageId = ''
    },
    onerror(err) {
      // 清除超时定时器
      if (timeoutTimer) clearTimeout(timeoutTimer)

      // 手动中断不触发重连(比如用户点击停止)
      if (controller.signal.aborted) {
        console.log('用户手动中断请求')
        return
      }

      // 异常重连
      ElMessage.error(`连接异常:${err.message || '网络错误'}`)
      reconnectStream(url, headers, data, handleMessage)

      // 必须抛出错误才会停止当前请求循环
      throw err
    }
  })
}

/**
 * 停止流式请求(手动中断)
 */
export const stopRequest = () => {
  // 清除超时定时器
  if (timeoutTimer) {
    clearTimeout(timeoutTimer)
    timeoutTimer = null
  }
  // 中断请求
  if (controller) {
    controller.abort()
    controller = new AbortController()
  }
  // 重置重连状态
  reconnectCount = 0
  reconnectInterval = 2000
  lastMessageId = ''
  ElMessage.info('已停止数据请求')
}


四、 注意:关于 Nginx 与浏览器限制

  1. Nginx 缓存屏蔽:一定要记得设置 proxy_buffering off;,否则 Nginx 会等缓冲区满了才一次性吐给前端,导致流式效果失效。
  2. 浏览器连接数限制:如果是 HTTP/1.1,浏览器对同一个域名的长连接通常限制在 6 个。如果打开多个 AI 对话页,可能会导致后续连接卡死。建议升级 HTTP/2,它可以多路复用,避开此限制。
  3. 手动停止 vs 自动重连:当用户点击“停止生成”时,必须标记一个 manualStop 状态位,否则 onerror 可能会误以为是网络异常而不断尝试重连。

五、💡 扩展:异步并发池 (Async Pool)

它不直接用于单个 SSE 连接,但在批量 AI 任务处理(例如一次性给 100 张图片生成描述)时非常有用。它可以限制同时进行的 HTTP 请求数量,防止瞬间撑爆浏览器带宽或后端并发限制。

1. 归属识别:唯一 ID + 专属缓存

  • 每个请求分配requestId(如stream-request-0);
  • streamDataCacherequestId为 key,每个请求的片段只往自己的缓存里加;
  • 即使多个请求的onmessage同时触发,也不会串数据(比如stream-request-0的片段绝不会跑到stream-request-1的缓存里)。

2. 有序拼接:数组按顺序存储片段

  • 每个请求的缓存里用fragments数组存储片段;
  • onmessage每次触发时,cache.fragments.push(msg.data)保证片段按返回顺序存储;
  • 收到结束标识[DONE]时,用join('')拼接数组,得到完整结果。

3. 并发控制:不等待 Promise 完成,只控制启动数

  • runningRequestCount记录正在运行的请求数;
  • runTasks里用while (runningRequestCount >= limit)等待,直到有请求结束、并发数下降;
  • 每个请求结束后(onclose/onerror),runningRequestCount--,并自动执行下一个任务;
  • 这种方式既限制了并发数,又不阻塞流式请求的 “持续返回片段”。
/**
 * 异步任务池(适配流式请求的并发控制)
 * @param {Array<Object>} requestList 批量请求列表(含url/headers/data)
 * @param {number} limit 最大并发数
 * @param {Function} onComplete 单个请求完成回调(参数:requestId, fullResult)
 */
export const batchStreamRequest = async (requestList, limit = 3, onComplete) => {
  // 为每个请求分配唯一ID
  const requestListWithId = requestList.map((item, index) => ({
    ...item,
    requestId: `stream-request-${index}`
  }))

  // 任务执行队列:递归执行,控制并发数
  const runTasks = async (taskIndex = 0) => {
    // 所有任务处理完毕
    if (taskIndex >= requestListWithId.length) return

    const currentTask = requestListWithId[taskIndex]
    const { requestId, url, headers, data } = currentTask

    // 等待:直到并发数低于限制
    while (runningRequestCount >= limit) {
      await new Promise((resolve) => setTimeout(resolve, 100)) // 每100ms检查一次
    }

    // 启动当前流式请求
    runningRequestCount++
    console.log(`启动请求${requestId},当前并发数:${runningRequestCount}`)

    // 执行单个流式请求(不等待完成,只标记启动)
    singleStreamRequest(requestId, url, headers, data, onComplete)
      .catch((err) => console.error(`请求${requestId}失败:`, err))
      .finally(() => {
        // 当前请求结束后,自动执行下一个任务
        runTasks(taskIndex + 1)
      })

    // 立即执行下一个任务(检查并发数)
    runTasks(taskIndex + 1)
  }

  // 启动任务队列
  await runTasks(0)
}

/**
 * 停止单个/所有流式请求
 * @param {string} [requestId] 可选:指定停止的请求ID,不传则停止所有
 */
export const stopStreamRequest = (requestId) => {
  if (requestId) {
    // 停止指定请求
    const controller = requestControllers[requestId]
    if (controller) {
      controller.abort()
      delete requestControllers[requestId]
      // 标记缓存为完成
      if (streamDataCache[requestId]) {
        streamDataCache[requestId].isCompleted = true
      }
      runningRequestCount--
    }
  } else {
    // 停止所有请求
    Object.keys(requestControllers).forEach((id) => {
      requestControllers[id].abort()
      delete requestControllers[id]
      if (streamDataCache[id]) {
        streamDataCache[id].isCompleted = true
      }
    })
    runningRequestCount = 0
    ElMessage.info('已停止所有流式请求')
  }
}

// ---------------------- 调用示例 ----------------------
// 批量请求列表
const batchRequests = [
  { url: '/api/stream/ai', headers: {}, data: { prompt: '介绍SSO单点登录' } },
  { url: '/api/stream/ai', headers: {}, data: { prompt: '介绍Token无感刷新' } },
  { url: '/api/stream/ai', headers: {}, data: { prompt: '介绍SSE流式请求' } },
  { url: '/api/stream/ai', headers: {}, data: { prompt: '介绍asyncPool并发控制' } }
]

// 执行批量请求(限制最大并发数2)
batchStreamRequest(batchRequests, 2, (requestId, fullResult) => {
  // 单个请求完成后的回调:拿到拼接好的完整结果
  console.log(`请求${requestId}完成,完整结果:`, fullResult)
  // 这里可以做后续处理:渲染、入库等
})

前端 PDF 导出:从文件流下载到自动分页

作者 许留山
2026年3月17日 17:14

在工作中,我们经常会遇到需要生成 PDF 的业务,比如合同、报告等。

前后端合作

对于前端来说,最省事的就是后端生成 PDF 文件,前端根据返回的 URL 地址进行下载。

URL 下载

如果后端直接返回一个可访问的 URL 地址,我们可以通过以下几种方式进行下载:

1. 使用 window.openlocation.href

这是最简单的方式,但缺点是无法控制下载后的文件名,且受浏览器拦截政策影响。

const downloadByUrl = (url: string) => {
  window.open(url, '_blank')
}

2. 使用 <a> 标签(推荐)

通过创建虚拟锚点并利用 download 属性,可以更好地控制下载行为。

/**
 * 通过 URL 下载文件
 * @param url 文件地址
 * @param fileName 自定义文件名
 */
export const downloadFileByUrl = (url: string, fileName?: string) => {
  const link = document.createElement('a')
  link.href = url

  // 如果提供了文件名,则设置 download 属性
  if (fileName) {
    link.download = fileName
  }

  link.target = '_blank'
  link.style.display = 'none'
  document.body.appendChild(link)

  link.click()

  // 清理
  document.body.removeChild(link)
}

文件流下载

如果后端返回的是文件流(Blob),由于浏览器无法直接解析这种数据格式作为下载源,我们需要通过 URL.createObjectURL 将其转换为一个临时的 blob:URL,然后利用 <a> 标签触发下载。

/**
 * 通过文件流下载文件
 * @param data 文件流数据 (Blob | ArrayBuffer | string)
 * @param fileName 下载后的文件名
 * @param mimeType 文件的 MIME 类型 (可选,如果不传则尝试从 data 中获取或使用默认值)
 */
export const downloadFileByStream = (data: any, fileName: string, mimeType?: string) => {
  // 1. 优先获取数据的类型
  const type = mimeType || (data instanceof Blob ? data.type : 'application/octet-stream')

  // 2. 将数据封装为 Blob 对象
  const blob = data instanceof Blob ? data : new Blob([data], { type })

  // 3. 创建一个临时的 URL 指向该 Blob 对象
  const blobURL = window.URL.createObjectURL(blob)

  // 4. 创建虚拟锚点触发下载
  const link = document.createElement('a')
  link.href = blobURL
  link.download = fileName
  link.style.display = 'none'
  document.body.appendChild(link)

  link.click()

  // 5. 下载执行后释放 URL 对象和 DOM 节点
  document.body.removeChild(link)
  // 不释放可能导致内存泄露,过早释放可能会导致下载失败,可以延迟触发
  window.URL.revokeObjectURL(blobURL)
}

前端生成 PDF

在有些业务上,需要纯前端生成 PDF。

window.print() 方法

这是调用浏览器原生打印功能最简单的方法。它会将当前页面的内容渲染到打印预览窗口中,用户可以选择保存为 PDF。

其实并不推荐,因为在很多复杂的结构中,需要做很多工作,才能达到理想的效果。 并且会有打印预览弹窗,无法实现无感打印。

const handlePrint = () => {
  window.print()
}

CSS 控制

为了让打印出来的效果更好,我们通常需要使用 @media print 查询来控制打印时的样式。

@media print {
  /* 隐藏不需要打印的元素,如导航栏、侧边栏、按钮 */
  .no-print {
    display: none !important;
  }

  /* 调整打印区域的宽度 */
  .print-container {
    width: 100%;
    margin: 0;
    padding: 0;
  }

  /* 强制分页 */
  .page-break {
    page-break-after: always;
  }
}

html2canvas-pro + jsPDF

html2canvas 可以将网页内容转换为图片,然后 jsPDF 可以将图片转换为 PDF。

html2canvas-prohtml2canvas 的加强版分叉,完全兼容原版 API。它可以作为无缝替代品直接安装并导入(只需将 import html2canvas from 'html2canvas' 改为 import html2canvas from 'html2canvas-pro')。它修复了原版在处理现代 CSS(如 object-fitclip-path)时的许多渲染 Bug。

下面是通用的代码,可用于 95% 的场景,该方法会自动分页,且不会切断元素。

import html2canvas from 'html2canvas-pro' // 推荐使用 pro 版本无缝替代
import jsPDF from 'jspdf'

/**
 * 将指定 DOM 导出为 PDF
 * @param domId 目标 DOM 元素的 ID
 * @param title 导出的文件名
 */
export const exportPdf = async (domId: string, title?: string): Promise<void> => {
  const ele = document.getElementById(domId)
  if (!ele) throw new Error('未找到目标元素')

  const scale = window.devicePixelRatio > 1 ? window.devicePixelRatio : 2

  // 获取所有防截断元素(防止元素被分页切开,如表格行、标题、段落等)
  const nodes = ele.querySelectorAll('tr, h2, h3, h4, h5, p, img')
  const containerRect = ele.getBoundingClientRect()

  // 同时收集元素的 top 和 bottom 坐标
  const breakPointsPx = Array.from(nodes).map((node) => {
    const rect = node.getBoundingClientRect()
    return {
      top: rect.top - containerRect.top,
      bottom: rect.bottom - containerRect.top,
    }
  })

  // 生成画布
  const canvas = await html2canvas(ele, {
    scale,
    useCORS: true, // 允许图片跨域
    backgroundColor: '#ffffff',
  })

  const imgDataUrl = canvas.toDataURL('image/jpeg', 1.0)

  // 初始化 PDF 对象:p-竖向,pt-点(单位),a4-纸张规格
  const pdf = new jsPDF('p', 'pt', 'a4')
  const a4Width = pdf.internal.pageSize.getWidth()
  const a4Height = pdf.internal.pageSize.getHeight()

  // 计算图片缩放比例:根据宽度适配 A4
  const ratio = a4Width / canvas.width
  const imgWidth = a4Width
  const imgHeight = canvas.height * ratio

  // 将坐标单位从 px 转换为 pt (符合 PDF 内部计算)
  const breakPointsPt = breakPointsPx.map((bp) => ({
    top: bp.top * ratio,
    bottom: bp.bottom * ratio,
  }))

  const topMargin = 30 // 页眉预留
  const bottomMargin = 30 // 页脚预留
  const pageContentHeight = a4Height - topMargin - bottomMargin

  let currentRenderY = 0 // 已完成渲染的 Y 轴偏移

  while (currentRenderY < imgHeight) {
    let expectedPageBottom = currentRenderY + pageContentHeight
    let actualPageBottom = expectedPageBottom

    // 判断是不是最后一页
    if (expectedPageBottom >= imgHeight) {
      actualPageBottom = imgHeight
    } else {
      // 只有不是最后一页,才去遍历判断是否被截断
      for (let i = 0; i < breakPointsPt.length; i++) {
        const { top, bottom } = breakPointsPt[i]

        // 核心判断:元素的头在当前页,但尾巴超出了当前页的底部,说明被“腰斩”了
        if (top > currentRenderY && top < expectedPageBottom && bottom > expectedPageBottom) {
          actualPageBottom = top // 在被截断元素的顶部切一刀,将其整体推到下一页
          break
        }
      }
    }

    if (actualPageBottom === currentRenderY) actualPageBottom = expectedPageBottom

    // 1. 渲染当前页图像(利用负偏移显示指定区域)
    pdf.addImage(imgDataUrl, 'JPEG', 0, topMargin - currentRenderY, imgWidth, imgHeight)

    // 2. 顶部遮罩(覆盖负偏移区域产生的重叠部分)
    if (currentRenderY > 0) {
      pdf.setFillColor(255, 255, 255)
      pdf.rect(0, 0, a4Width, topMargin, 'F')
    }

    // 3. 底部遮罩(留白并遮挡截断处的残影)
    const currentRenderBottomY = topMargin + (actualPageBottom - currentRenderY)
    pdf.setFillColor(255, 255, 255)
    pdf.rect(0, currentRenderBottomY, a4Width, a4Height - currentRenderBottomY, 'F')

    currentRenderY = actualPageBottom

    // 如果还没画完,添加新的一页
    if (currentRenderY + 5 < imgHeight) {
      pdf.addPage()
    }
  }
  const fileName = title ? `${title}_${Date.now()}` : Date.now().toString()
  pdf.save(`${fileName}.pdf`)
}

CodeSandbox 案例

用法案例

在 React 中使用该方案:

import { exportPdf } from './utils/pdf'

const ReportPage = () => {
  const handleDownload = async () => {
    try {
      // 传入容器 ID 和文件名
      await exportPdf('pdf-content', '月度分析报告')
    } catch (error) {
      console.error('生成 PDF 失败:', error)
    }
  }

  return (
    <div>
      <button onClick={handleDownload}>下载报告</button>

      {/* 这里的 ID 必须与 exportPdf 传入的一致 */}
      <div id="pdf-content" style={{ padding: '20px', background: '#fff' }}>
        <h2>报表标题</h2>
        <p>这里是很长很长的内容,可能会跨页...</p>
        <table>
          <tbody>
            <tr>
              <td>数据行 1</td>
            </tr>
            {/* 这里的 tr 会被防截断逻辑自动推送到下一页容器中 */}
            <tr>
              <td>数据行 2</td>
            </tr>
          </tbody>
        </table>
      </div>
    </div>
  )
}

进阶:PDF 模板架构设计

当项目中需要管理多个 PDF 模板时,建议采用“容器与显示分离”的架构,这样可以保证模板的纯净度(只负责 UI),同时方便在后台静默生成 PDF。

1. 目录结构建议
src/
  ├── components/
  │   └── pdf-templates/      # 所有的 PDF UI 模板
  │       ├── Contract.tsx    # 合同模板
  │       ├── Invoice.tsx     # 发票模板
  │       └── index.ts        # 统一导出
  └── utils/
      └── pdf.ts              # 核心 exportPdf 方法
2. 模板编写建议

模板组件应该只接收 data Props,不处理任何业务逻辑。

// src/components/pdf-templates/ContractTemplate.tsx
interface IProps {
  data: any
}

export const ContractTemplate = ({ data }: IProps) => (
  <div id="pdf-render-target" style={{ width: '800px', padding: '40px' }}>
    <h1>{data.title}</h1>
    {/* 自由编写复杂的 PDF 样式 */}
  </div>
)
3. 数据获取与导出架构

推荐在需要导出 PDF 的页面中,通过一个隐藏的“渲染容器”来实现。这样可以在不影响主页面 UI 的情况下,获取最新的业务数据并生成 PDF。

// src/pages/OrderDetails.tsx
import { useState } from 'react'
import { createPortal } from 'react-dom'
import { exportPdf } from '../utils/pdf'
import { ContractTemplate } from '../components/pdf-templates'

const OrderDetails = () => {
  const [isExporting, setIsExporting] = useState(false)
  const [data, setData] = useState(null)

  const startExport = async () => {
    setIsExporting(true)

    // 1. 获取业务数据 (如从 API 获取)
    const res = await fetchOrderData()
    setData(res)

    // 2. 等待 React 渲染 DOM (利用 setTimeout 确保渲染完成)
    setTimeout(async () => {
      try {
        await exportPdf('pdf-render-target', '业务合同')
      } finally {
        setIsExporting(false)
      }
    }, 100)
  }

  return (
    <div>
      <button onClick={startExport} disabled={isExporting}>
        {isExporting ? '正在生成...' : '下载 PDF'}
      </button>

      {/* 通过 Portal 将模板渲染在屏幕外,实现“无感”生成 */}
      {isExporting &&
        data &&
        createPortal(
          <div style={{ position: 'absolute', left: '-9999px', top: 0 }}>
            <ContractTemplate data={data} />
          </div>,
          document.body
        )}
    </div>
  )
}
4. 架构优势
  • 关注点分离:页面只管触发,模板只管绘制,utils 只管转换。
  • 数据解耦:PDF 模板的数据可以由父页面统一注入,也可以在 exportPdf 调用前按需加载。
  • 用户无感:通过 createPortal 将渲染目标移出可视区域,用户在页面上感知不到“截图”的过程。

OpenClaw github 技能:让 GitHub 操作像聊天一样简单

作者 少卿
2026年3月17日 17:08

作为前端开发者,我们每天要在 IDE、GitHub、终端、文档之间反复切换。创建 Issue、提 PR、查代码历史、追 Actions 状态……这些机械操作消耗了大量注意力。

OpenClaw 的 github 技能彻底改变了这个现状。它让你用自然语言直接操作 GitHub,无需记忆 gh CLI 命令,无需打开浏览器,在终端里一句话就能完成复杂协作。


一、技能定位

github 是 OpenClaw 官方维护的基础设施级技能,提供 GitHub 全栈自动化能力。它与 summarize 技能并称"效率双子星",是前端开发者必装的核心工具。

核心价值:

  • 🎯 意图驱动:说人话,不用背命令
  • 上下文保留:在开发流程中无缝集成 GitHub 操作
  • 🔗 生态联动:与 summarizereact 等技能组合威力倍增

二、安装与授权

快速安装

# 官方源安装
clawhub install github

# 国内镜像(如遇 rate limit)
clawhub install github --registry https://clawhub-mirror.aliyuncs.com

首次授权

# 方式一:交互式登录
clawhub login

# 方式二:环境变量(CI/CD 场景)
export GITHUB_TOKEN=ghp_xxxxxxxxxxxx

Token 权限建议:

开发场景 所需权限 说明
个人开源项目 public_repo 仅操作公开仓库
团队私有项目 repo 读写私有仓库代码
自动化部署 repo + workflow 修改 Actions 配置

安全提示:使用 Fine-grained PAT,仅授权必要仓库,定期轮换 Token。


三、前端开发实战场景

场景 1:Issue 驱动开发(IDD)

从需求文档直接生成开发任务:

openclaw chat "读取产品需求文档,
用 github 技能创建 5 个开发 Issue:

1. [UI] 实现响应式导航栏 - Priority: High
2. [Feature] 集成 JWT 登录 - Priority: High  
3. [API] 封装用户管理接口 - Priority: Medium
4. [Test] 编写登录流程 E2E 测试 - Priority: Medium
5. [Deploy] 配置生产环境部署 - Priority: Low

每个 Issue 包含:
- 验收标准(3-5 条可验证项)
- 预估工时
- 关联标签:frontend, sprint-1
- 分配给 @frontend-team"

效果:需求文档 → 结构化开发任务,5 秒完成,无需手动复制粘贴。


场景 2:PR 自动化工作流

2.1 智能创建 PR

openclaw chat "用 github 技能为当前分支创建 PR:
- 标题:使用 conventional commits 格式(feat:/fix:/refactor:)
- 描述包含:
  * 变更摘要(自动读取 commit history)
  * 截图占位区(提示上传)
  * Breaking changes 说明(如有)
  * 测试覆盖情况
- 分配审查者:@tech-lead @senior-frontend
- 添加标签:needs-review, frontend
- 关联相关 Issue(自动检测 commit 中的 #issue 编号)"

2.2 审查辅助(与 summarize 联动)

openclaw chat "获取 PR #42 的代码变更,
用 summarize 技能生成审查清单:

---
📊 变更概览
- 影响文件:12 个
- 新增代码:+340 行
- 删除代码:-120 行
- 测试文件:3 个

🔍 审查要点
1. [性能] 检查 useEffect 依赖项是否正确
2. [安全] 验证用户输入是否有 XSS 过滤
3. [样式] 确认 Tailwind 类名未硬编码颜色值

⚠️ 潜在风险
- 第 45 行直接操作 DOM,建议改用 ref
- 新增 API 调用未设置超时处理

✅ 通过标准
- [ ] 所有审查点已检查
- [ ] CI 全部通过
- [ ] 至少 2 人批准
---"

场景 3:每日站会自动化

黄金组合github + summarize

openclaw chat "用 github 技能查询昨日数据:
- 合并的 PR(作者、标题、影响范围)
- 关闭的 Issue(类型分布:bug/feature/docs)
- 今日待合并 PR 清单
- 代码提交热力图(可选)

用 summarize 技能生成站会报告,格式:

---
📅 2024-03-15 开发日报

✅ 昨日交付(3 项)
| PR | 作者 | 说明 | 影响 |
|----|------|------|------|
| #123 | @alice | 重构登录表单 | 修复内存泄漏 |
| #124 | @bob | 升级 React 18 | 并发特性就绪 |

🎯 今日计划(2 项待合并)
- #125 支付页面 UI(需 @tech-lead 审查)
- #126 优化首屏加载(CI 运行中)

⚠️ 阻塞问题
- #127 等待后端 API 文档更新 → @backend-team

📈 数据亮点
- 代码审查平均耗时:4.2 小时 ↓ 20%
- 测试覆盖率:82% ↑ 5%
---"

效果:每日早晨一句话,自动生成数据驱动的站会报告。


场景 4:代码追溯与故障排查

# 快速定位问题代码
openclaw chat "github 搜索 'useEffect' 在 src/hooks/ 目录的使用,
找出最近 2 周内新增且未添加 cleanup 函数的调用"

# 查看文件历史
openclaw chat "github 查看 src/utils/api.ts 的 blame 记录,
找出引入 axios 拦截器的那次提交,
并显示当时的 PR 描述和审查意见"

# 对比发布版本
openclaw chat "github 比较 v1.2.0 和 v1.3.0 的差异,
用 summarize 技能生成面向产品经理的变更说明(非技术语言)"

四、高级组合技

组合 1:自动化发布流水线

# 安装配套技能
clawhub install github summarize

# 执行发布
openclaw chat "执行发布流程:
1. github 检查 main 分支 CI 状态
2. github 对比上次 tag,用 summarize 生成 changelog
3. github 创建 Release v1.3.0,附带 changelog
4. github 触发 Actions 工作流:部署到生产环境
5. 在 Slack 频道发送发布通知(含变更摘要)"

组合 2:智能代码审查门禁

openclaw chat "检查当前 PR 是否满足合并条件:

1. github 检查 CI 状态(Actions 全部通过?)
2. github 检查审查批准(≥2 人?是否包含 Tech Lead?)
3. github 检查冲突(可自动 rebase?)
4. github 检查提交信息(符合 conventional commits?)

输出:
- 合规状态:✅ 通过 / ❌ 阻塞
- 阻塞项清单(如有)
- 建议操作(如 '请 @alice 审查' 或 '需要 rebase')"

组合 3:开源项目维护

# 批量处理社区贡献
openclaw chat "github 查询本周新提交的 PR(状态:待审查),
按影响范围分类:
- 文档改进 → 快速合并
- Bug 修复 → 优先审查  
- 新功能 → 评估是否符合路线图

用 summarize 技能为每个 PR 生成一句话评估建议,
并自动添加标签:docs/bug/feature,分配审查者"

五、常用指令速查表

表格

意图 自然语言指令 传统 CLI 等价
创建仓库 "github 创建私有仓库 react-dashboard,添加 README 和 MIT 许可证" gh repo create ...
提交 Issue "github 创建 Bug:登录页在 Safari 下白屏,标签 bug, priority-high" 打开浏览器手动填写
创建 PR "github 从当前分支创建 PR,标题用 conventional commits" gh pr create ...
审查查询 "github 显示 PR #123 的审查状态和冲突情况" gh pr view ... + gh pr checks ...
代码搜索 "github 搜索项目中所有 console.log 未删除的位置" gh search code ...
发布管理 "github 创建 Release v2.0.0,生成对比报告" 多步骤手动操作

六、安全与最佳实践

Token 管理

# 检查当前授权状态
clawhub whoami

# 安全轮换 Token
clawhub logout && clawhub login

敏感操作确认

# 高风险操作(如删除仓库、强制推送)建议开启确认
openclaw chat "github 删除仓库 --confirm"
# 或配置环境变量:OPENCLAW_CONFIRM_DANGEROUS=true

审计日志

# 查看最近操作记录(用于排查问题)
openclaw chat "显示我最近 10 次 github 技能的操作日志"

七、故障排查

现象 原因 解决
401 Bad credentials Token 过期或无效 clawhub login 重新授权
403 Resource not accessible Token 权限不足 在 GitHub 设置中添加 repo 权限
404 Not Found 仓库不存在或无权访问 检查仓库名拼写和权限
操作超时 网络或 API 延迟 重试或检查 GitHub Status
Rate limit 请求过于频繁 使用 GITHUB_TOKEN 或等待重置

八、参考资源

资源 获取方式
官方文档 docs.openclaw.ai
技能市场 clawhub.ai / clawhub.com
CLI 帮助 clawhub --help / clawhub github --help
安全审计 clawsecure.ai

总结

github 技能将 GitHub 从"需要专门操作的网站"转变为"开发流程的自然延伸"。对于前端开发者,它特别适合:

敏捷迭代:Issue/PR 快速创建,减少上下文切换成本
代码质量:自动化审查清单,确保交付标准
团队协作:数据驱动的日报/周报,信息透明同步
开源维护:批量处理社区贡献,提升维护效率

推荐安装组合:

# 前端开发黄金套装(效率三件套)
clawhub install github summarize self-improving-agent

# 完整工作流(可选)
clawhub install react-component-generator frontend-performance browser-devtools-inspector

立即体验:

clawhub install github
openclaw chat "github 查看我的最近 Issue"

Vue3 动态路由实战:基于权限的动态路由管理与常见坑点解析

作者 留声
2026年3月17日 17:02

引言

在后台管理系统中,不同角色的用户看到的菜单和可访问的页面往往不同。传统的静态路由配置无法满足这种按需加载的需求,因此 动态路由 成为了现代前端工程的标配。Vue Router 提供了 addRoute 方法,允许我们在应用运行时动态添加路由,结合路由守卫,可以优雅地实现基于用户权限的路由控制。

本文将从零开始,带你掌握 Vue3 + JavaScript 环境下动态路由的核心概念、完整实现步骤,并深入剖析刷新后路由丢失、重复添加等经典坑点的解决方案。所有代码均采用组合式 API,可直接用于实战项目。

模块一:动态路由概念与使用场景

概念解析

动态路由 是指在应用运行期间,根据某些条件(如用户权限、角色)动态添加或移除的路由。与传统的静态路由(在 routes 配置中一次性定义所有路由)相比,动态路由有以下特点:

  • 按需加载:只有具备权限的用户才能访问对应的页面,避免未授权访问。
  • 灵活性:路由表可由后端返回,前端动态生成,实现权限与路由的完全解耦。
  • 可扩展性:支持多角色、多租户等复杂场景。

在 Vue Router 4.x 中,动态添加路由主要通过两个方法实现:

  • router.addRoute(route: RouteRecordRaw):添加一条新路由。
  • router.removeRoute(name: string | symbol):移除已添加的路由。

使用场景

  • 权限控制:不同角色(管理员、普通用户)看到不同的菜单,访问不同的页面。
  • 多级菜单动态生成:根据后端返回的菜单结构,递归生成嵌套路由。
  • 功能模块按需加载:例如,某些模块仅在特定条件下启用(如节日活动页面)。

模块二:基于权限的动态路由实现

概念解析

实现权限动态路由的核心思路:

  1. 静态路由:所有用户都能访问的基础路由(如登录页、404、注册页等)。
  2. 异步路由:需要权限才能访问的路由,通常在后端定义,前端通过接口获取。
  3. 路由守卫:在路由跳转前判断用户是否登录、是否有权限,并动态添加异步路由。
  4. 状态管理:存储用户信息和权限路由表,防止刷新后丢失。

实战步骤

我们将实现一个简单的权限控制示例:管理员能看到“用户管理”和“仪表盘”,普通用户只能看到“仪表盘”。

1. 定义静态路由和异步路由

// router/index.js
import { createRouter, createWebHistory } from 'vue-router'

// 静态路由(所有用户可访问)
export const constantRoutes = [
  {
    path: '/login',
    name: 'Login',
    component: () => import('@/views/Login.vue'),
    meta: { hidden: true } // 不在菜单中显示
  },
  {
    path: '/404',
    name: '404',
    component: () => import('@/views/404.vue'),
    meta: { hidden: true }
  },
  {
    path: '/',
    redirect: '/dashboard'
  }
]

// 异步路由(需要权限)
export const asyncRoutes = [
  {
    path: '/dashboard',
    name: 'Dashboard',
    component: () => import('@/views/Dashboard.vue'),
    meta: { title: '仪表盘', icon: 'dashboard', roles: ['admin', 'user'] } // 允许的角色
  },
  {
    path: '/user',
    name: 'User',
    component: () => import('@/views/User.vue'),
    meta: { title: '用户管理', icon: 'user', roles: ['admin'] }
  }
]

const router = createRouter({
  history: createWebHistory(),
  routes: constantRoutes // 初始只挂载静态路由
})

export default router

2. 使用 Pinia 存储用户状态和权限路由

// stores/user.js
import { defineStore } from 'pinia'
import { constantRoutes, asyncRoutes } from '@/router'
import router from '@/router'

// 模拟后端返回的权限路由名称
const mockFetchUserRoutes = (role) => {
  return asyncRoutes.filter(route => route.meta.roles.includes(role))
}

export const useUserStore = defineStore('user', {
  state: () => ({
    token: localStorage.getItem('token') || '',
    role: '', // 当前用户角色
    routes: [] // 当前用户拥有的路由(静态+异步)
  }),
  actions: {
    // 登录
    async login(role) {
      // 模拟登录,存储 token 和 role
      this.token = 'mock-token'
      this.role = role
      localStorage.setItem('token', this.token)
      
      // 根据角色获取路由
      const dynamicRoutes = mockFetchUserRoutes(role)
      this.routes = [...constantRoutes, ...dynamicRoutes]
      
      // 动态添加路由
      dynamicRoutes.forEach(route => {
        router.addRoute(route)
      })
      // 添加 404 通配路由(必须最后添加)
      router.addRoute({ path: '/:pathMatch(.*)*', redirect: '/404', meta: { hidden: true } })
    },
    // 登出
    logout() {
      this.token = ''
      this.role = ''
      this.routes = []
      localStorage.removeItem('token')
      
      // 重置路由(移除所有动态添加的路由)
      const dynamicRoutes = mockFetchUserRoutes(this.role) // 此时 role 为空,获取空数组
      // 移除动态路由(需要遍历 remove)
      // 但更简单的方法是重新创建 router 实例,或者使用 resetRouter 函数
      resetRouter() // 自定义函数
      router.push('/login')
    }
  }
})

// 重置路由工具函数
function resetRouter() {
  // 获取所有动态路由的 name,并移除
  const dynamicRouteNames = asyncRoutes.map(route => route.name)
  dynamicRouteNames.forEach(name => {
    if (router.hasRoute(name)) {
      router.removeRoute(name)
    }
  })
  // 移除 404 通配路由(如果有)
  if (router.hasRoute('404')) {
    router.removeRoute('404')
  }
}

3. 路由守卫:判断权限并动态添加路由

// router/permission.js
import router from './index'
import { useUserStore } from '@/stores/user'

// 白名单:不需要登录就能访问的路由
const whiteList = ['/login', '/404']

router.beforeEach(async (to, from, next) => {
  const userStore = useUserStore()
  const hasToken = userStore.token
  
  if (hasToken) {
    if (to.path === '/login') {
      // 已登录,跳转到首页
      next('/')
    } else {
      // 判断是否已有角色信息(防止刷新后路由丢失)
      if (!userStore.role) {
        try {
          // 模拟从 token 中解析角色(实际应从后端获取用户信息)
          const role = 'admin' // 假设当前用户是 admin
          // 调用登录 action 动态添加路由
          await userStore.login(role)
          // 确保路由添加完成后再进入目标路由
          next({ ...to, replace: true })
        } catch (error) {
          // 获取用户信息失败,重置 token 并跳转登录
          userStore.logout()
          next(`/login?redirect=${to.path}`)
        }
      } else {
        // 已有角色,正常跳转
        next()
      }
    }
  } else {
    // 未登录,检查白名单
    if (whiteList.includes(to.path)) {
      next()
    } else {
      next(`/login?redirect=${to.path}`)
    }
  }
})

4. 生成动态菜单(侧边栏组件)

<!-- components/Sidebar.vue -->
<template>
  <ul>
    <li v-for="route in userRoutes" :key="route.path" v-if="!route.meta?.hidden">
      <router-link :to="route.path">{{ route.meta?.title }}</router-link>
    </li>
  </ul>
</template>

<script setup>
import { computed } from 'vue'
import { useUserStore } from '@/stores/user'

const userStore = useUserStore()

// 只显示 meta.hidden 不为 true 的路由,且过滤掉重定向路由(如 '/')
const userRoutes = computed(() => {
  return userStore.routes.filter(route => !route.meta?.hidden && route.path !== '/')
})
</script>

5. 在 main.js 中引入路由守卫

// main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import './router/permission' // 引入守卫

const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

代码示例说明

  • 静态路由:包含登录页和404页,所有用户可访问。
  • 异步路由:通过 meta.roles 标注允许的角色,模拟后端返回。
  • 路由守卫:在每次跳转前检查 token 和角色,若角色为空则调用 login action 动态添加路由。
  • 菜单生成:从 store 中获取 userRoutes 并渲染,自动过滤隐藏项。

注意事项

  1. 动态添加 404 路由:必须在所有动态路由添加完成后最后添加,否则会匹配到 404。
  2. 防止重复添加:在 login action 中,应先移除之前添加的动态路由(如 resetRouter),或者判断 router.hasRoute 避免重复。
  3. 刷新路由丢失:刷新页面会导致 store 中的 role 和 routes 丢失,但 token 可能还存在。解决方案是在路由守卫中判断 !userStore.role 时重新获取用户信息并动态添加路由。
  4. 路由替换(replace: true) :动态添加路由后,需要使用 next({ ...to, replace: true }) 重走一遍导航,确保新路由生效。

模块三:常见坑点与解决方案

1. 路由重复添加

问题:多次调用 router.addRoute 添加同名路由,会导致控制台警告,甚至路由混乱。

解决方案

  • 添加前使用 router.hasRoute(route.name) 检查是否存在。
  • 或在添加前统一移除所有动态路由(如上面的 resetRouter)。

javascript

// 安全添加
if (!router.hasRoute(route.name)) {
  router.addRoute(route)
}

2. 刷新后路由丢失

问题:刷新页面后,store 中的 role 和 routes 被重置,但 token 可能还在,此时用户访问非静态路由会报错。

解决方案:在路由守卫中判断若 token 存在但角色为空,则调用接口获取用户信息并重新添加路由。如上面的 beforeEach 实现。

3. 动态添加的路由在菜单中不显示

原因:菜单组件直接使用 router.options.routes 获取路由表,但 addRoute 添加的路由不会自动合并到 options.routes 中。

解决方案:将动态路由保存在 store 中,菜单组件基于 store 中的路由渲染,而非直接从 router 实例获取。

4. 404 路由匹配问题

问题:如果在动态路由之前添加了 404 路由,所有未匹配的路由都会跳到 404,导致动态路由无法访问。

解决方案:确保 404 路由在所有动态路由之后添加,并且只添加一次。可以在登录成功后添加,并配合 resetRouter 在登出时移除。

5. 嵌套路由的动态添加

问题addRoute 支持添加嵌套路由,但需要指定父路由的 name

示例

router.addRoute('Parent', {
  path: 'child',
  name: 'Child',
  component: () => import('...')
})

注意父路由必须已存在。


扩展思考

更细粒度的权限控制

除了路由级别的权限,实际项目中还常需要按钮级别的权限控制。可以通过在 meta 中添加 permissions 数组,或在 store 中存储权限标识,然后在组件中使用自定义指令或函数判断。

// 自定义指令 v-permission
app.directive('permission', {
  mounted(el, binding) {
    const userStore = useUserStore()
    const required = binding.value
    if (!userStore.permissions.includes(required)) {
      el.parentNode?.removeChild(el)
    }
  }
})

动态路由与菜单联动

当后端返回的菜单结构可能包含多级时,需要递归生成路由和菜单。可以定义一个递归函数,将后端返回的 JSON 转换为 Vue Router 支持的 RouteRecordRaw 数组。

结合路由元信息(meta)进行更多控制

在 meta 中可以存放标题、图标、缓存标识等,配合 router.beforeEach 实现页面标题动态更新、页面缓存控制等功能。


总结

本文详细讲解了 Vue3 动态路由的核心概念、实现步骤以及常见问题的解决方案。通过实战代码,你学会了如何根据用户权限动态添加路由,如何处理刷新后路由丢失,以及如何避免重复添加路由。动态路由是构建大型后台管理系统的基础,掌握它将使你的前端工程更具灵活性和可维护性。

在实际项目中,你可能还需要结合后端接口、WebSocket 通知等实时更新权限,但本文提供的模式已经足够应对绝大多数场景。希望你能将这些知识应用到自己的项目中,构建出健壮、安全的前端应用。

项目部署后->这样通知用户刷新

作者 蓝鲸有腿
2026年3月17日 16:44

大家好,结合我平时的开发经验,今天想和大家慢慢聊聊 window.addEventListener('vite:preloadError',(event)=>{}) 这个方法。我知道大家在开发Vite项目时,尤其是处理生产环境问题时,可能会遇到页面长时间停留后,因为项目更新导致资源报错的情况,而这个方法,正是解决这类问题的关键,接下来我就以第一人称的视角,委婉地和大家讲清楚它的作用、使用场景、错误区分,还会附上实用的代码示例,希望能帮到大家避开一些坑。

一、它的核心作用(结合生产环境痛点)

其实刚开始接触这个方法时,我也和大家一样,觉得它不起眼,但在处理过几次生产环境的问题后,我才发现它的实用性。它的核心作用,简单来说,就是捕获Vite环境下,资源预加载和动态导入时出现的所有错误,并且允许我们自定义错误处理逻辑,而不是让错误直接抛出,导致页面卡死或功能异常。

尤其在生产环境中,最常见的就是用户长时间打开页面(比如一整天没关闭),这期间我们更新了项目,重新部署后,旧版本的资源文件(比如带哈希值的JS、CSS文件)会被删除,而用户页面还在尝试加载这些已失效的旧资源,这时就会触发预加载错误。如果没有这个监听方法,用户会看到页面空白、控制台报错,体验非常差;而有了它,我们就能捕获到这个错误,做一些友好的处理,比如提示用户刷新页面,避免用户困惑。

另外还要和大家说一句,它和普通的try-catch不一样,try-catch很难覆盖Vite内置的资源预加载逻辑,而这个全局事件监听,能精准捕捉到所有预加载相关的错误,这也是它的优势所在。而且我们可以通过 event.preventDefault() 阻止Vite默认的错误抛出,完全由我们控制错误的处理方式,既保护了用户体验,也方便我们排查问题。

二、使用场景(重点讲项目更新导致的资源报错)

结合我平时的开发经历,我总结了几个最实用的场景,其中最核心、最常见的,就是“生产环境页面长时间停留,项目更新导致资源报错”,另外还有两个补充场景,大家可以一并了解,方便后续遇到类似问题时能快速应对。

场景1:生产环境页面长时间停留,项目更新后资源失效(核心场景)

这是我遇到最多的场景,相信大家也可能遇到过:我们把Vite项目部署到服务器后,用户打开页面后一直没关闭(比如办公电脑后台挂着),过了一段时间,我们更新了项目并重新部署,此时服务器上的旧资源会被新资源替换、删除。而用户的旧页面,还会按照之前的路径,尝试加载那些已经被删除的旧资源,这时就会触发 vite:preloadError 事件,控制台会报错,页面可能会卡死、功能无法使用。

比如我之前做的一个项目,有用户反馈“打开页面一上午,下午操作时突然报错,页面用不了”,排查后发现,就是因为中午我们更新了项目,用户页面长时间停留,加载旧资源失败导致的。这时用这个监听方法,就能完美解决——捕获到错误后,自动提示用户“检测到新版本,即将刷新页面”,然后强制刷新,让用户加载到最新的资源,既解决了问题,也提升了用户体验。

场景2:动态导入(路由懒加载、组件按需加载)失败

我们在开发Vite项目时,为了减少首屏加载体积,常会用import()语法做动态导入,比如Vue、React的路由懒加载,或者组件按需加载。如果我们不小心写错了导入路径,或者动态导入的资源不存在,就会触发预加载错误,这时这个监听方法就能捕获到错误,帮我们快速定位问题,而不是只看到模糊的报错信息。

场景3:网络异常或跨域导致的资源加载失败

还有一种情况,就是用户访问页面时,网络中断、网络延迟过高,或者我们的资源部署在不同域名,没有配置CORS跨域,导致资源加载失败,这时也会触发这个错误。我们可以通过这个监听方法,给用户提示“网络异常,请检查网络后重试”,而不是让页面一直空白,提升用户的体验感。

三、错误区分(避免混淆,精准排查)

在开发过程中,我发现很多同学会把 vite:preloadError 捕获的错误,和普通的JS错误、接口错误混淆,导致排查问题时走弯路。这里我就和大家慢慢区分一下,帮大家精准判断什么时候是这个方法能捕获的错误,什么时候是其他类型的错误,这样大家后续排查问题时会更高效。

1. 属于vite:preloadError捕获的错误(核心区分点)

这类错误的核心特点是:和Vite的资源预加载、动态导入直接相关,错误根源是“资源加载失败”,常见的有3种:

  • 项目更新后,旧资源被删除,页面加载旧资源失败(最核心的场景);
  • 动态导入(import())的资源路径错误、资源不存在;
  • 资源加载时遇到网络问题(中断、延迟)、跨域拦截、权限不足导致的加载失败。

这类错误的共性是:控制台会出现“preload error”相关的提示,且错误信息会挂载在 event.payload 上,我们可以通过打印 event.payload 看到具体的错误详情,比如“找不到某个资源文件”“跨域拦截”等。

2. 不属于vite:preloadError捕获的错误(避免混淆)

大家要注意,不是所有的错误都能被这个方法捕获,以下几种常见错误,就和它无关,需要用其他方式处理:

  • 普通JS语法错误、逻辑错误(比如变量未定义、函数调用错误),这类错误需要用try-catch捕获;
  • 接口请求错误(比如接口404、500),这类错误需要在接口请求的catch回调中处理;
  • DOM操作错误(比如操作不存在的DOM元素),这类错误也需要用try-catch或其他方式处理。

简单总结一下:判断是否是这个方法能捕获的错误,就看“错误是否和Vite的资源预加载、动态导入有关”,如果是,就用它;如果不是,就用其他对应的错误处理方式。

四、实用代码示例(贴合生产环境,可直接复用)

结合上面讲的核心场景(项目更新导致旧资源报错),我给大家准备了一段简单易懂的代码,大家可以直接复制到自己的Vite项目入口文件(比如main.js)中使用,代码里有详细的注释,大家可以慢慢看,也可以根据自己的项目需求稍作修改。

代码示例1:处理项目更新后旧资源报错(最常用)

// 建议放在项目入口文件(main.js)最顶部,确保提前监听,不遗漏错误
window.addEventListener('vite:preloadError', (event) => {
  // 阻止Vite默认抛出错误,避免页面卡死
  event.preventDefault();
  
  // 打印错误详情,方便我们排查问题(生产环境可注释,或上报到监控平台)
  console.log('Vite资源预加载错误:', event.payload);
  
  // 自定义处理逻辑:提示用户刷新页面,获取最新资源(贴合生产环境痛点)
  if (confirm('检测到项目已更新,为了保证使用体验,请点击确定刷新页面~')) {
    window.location.reload(); // 强制刷新页面,加载最新资源
  } else {
    // 若用户取消刷新,可提示用户手动刷新,避免功能异常
    alert('若后续页面出现异常,请手动刷新页面哦~');
  }
});

代码示例2:区分错误类型,精准处理

如果大家想更精准地处理不同类型的预加载错误,可以参考这段代码,能区分“资源不存在”“网络异常”“跨域”等情况,针对性给出提示:

window.addEventListener('vite:preloadError', (event) => {
  event.preventDefault();
  const error = event.payload;
  console.log('预加载错误详情:', error);
  
  // 区分不同错误类型,给出不同提示
  if (error.message.includes('Failed to fetch') || error.message.includes('net::ERR_INTERNET_DISCONNECTED')) {
    // 网络异常
    alert('网络连接异常,请检查网络后,手动刷新页面重试~');
  } else if (error.message.includes('404') || error.message.includes('not found')) {
    // 资源不存在(大概率是项目更新后旧资源被删除)
    alert('检测到项目已更新,即将为您刷新页面~');
    window.location.reload();
  } else if (error.message.includes('CORS') || error.message.includes('跨域')) {
    // 跨域错误
    alert('资源加载遇到跨域问题,已通知开发人员处理,请稍后再试~');
  } else {
    // 其他预加载错误
    alert('页面资源加载异常,请手动刷新页面重试~');
  }
});

最后温馨提醒

还有几点小建议想分享给大家:

  • 第一,这个方法建议放在项目入口文件最顶部,确保在资源预加载开始前就完成监听,避免遗漏错误;
  • 第二,生产环境中,可根据需求隐藏控制台的错误打印,或把错误信息上报到监控平台,方便我们排查问题;
  • 第三,一定要调用 event.preventDefault(),否则Vite会默认抛出错误,可能导致页面卡死。

其实这个方法不难,只要大家记住它的核心作用——捕获Vite预加载错误,重点掌握“项目更新导致旧资源报错”这个场景,再结合代码多练习几次,就能熟练运用啦。如果大家还有更好的方式,也可以随时交流哦。

最新版vue3+TypeScript开发入门到实战教程之watch详解

作者 angerdream
2026年3月17日 16:43

1、watch概述

watch本意是监视、观察。它的功能就是监视数据的变化。数据一旦变化,就会产生两种数据:新数据、旧数据。 如业务场景中,当订单量大多某个数时,就发放优惠卷。watch非常重要,掌握好响应式数据、computed、watch,vue写功能不会有太大问题。 在vue官网明确表达,watch可以监视以下四种数据:

  • ref定义的数据
  • reactive定义的数据
  • 函数返回一个值(getter函数
  • 包含上诉三种值的数组

2、监视ref定义的基本类型数据

  • 创建组件Fish
  • 引入ref、watch
  • 创建响应式数据name、price
  • watch函数监视price变化
  • 当price超过10,watch停止监视price变化
<template>
  <h2>鱼类:{{ name }}</h2>
  <h2>价格:{{ price }}</h2>
  <button @click="addPrice()">增加价格</button>
</template>
<script setup>
import { ref, watch } from 'vue'
let name = ref('鲫鱼');
let price = ref(5);
function addPrice() {
  price.value += 1;
}
let stopWatchPrice = watch(price, (newValue, oldValue) => {
  console.log(newValue, oldValue);
  if (newValue > 10) {
    console.log(stopWatchPrice);
    stopWatchPrice.stop();
  }
})
</script>

运行效果事例: 在这里插入图片描述 注意watch函数,监视的是price,而不是price.value。当点击按钮,price超过10,虽然数据在增加,但不再监视price。watch函数返回对象,有stop函数,调用此函数,即可解除监视。控制台打印,其结构如下:

() => {
    effect2.stop();
    if (scope && scope.active) {
      remove(scope.effects, effect2);
    }
  }

3、监视ref定义的对象类型数据

监视对象类型的数据,与基础类型的数据不同。当对象中的数据变化时,是无法监视到,但当整个数据改变时,是可以监视的。特点如下:

  • 创建组件Fish,引入ref、watch
  • 创建响应式对象fish,let fish = ref({ name: '鲫鱼', price: 5 });
  • 当改变fish.name值时,无法监视fish的变化
  • 当改变fish.price值时,无法监视fish的变化
  • 当改变整条鱼时,能够监视fish变化
<template>
  <h2>鱼类:{{ fish.name }}</h2>
  <h2>价格:{{ fish.price }}</h2>
  <button @click="changeName()">修改鱼类</button>
  <button @click="changePrice()">修改鱼价</button>
  <button @click="changeFish()">更换真个鱼</button>
</template>
<script setup>
import { ref, watch } from 'vue'
let fish = ref({ name: '鲫鱼', price: 5 });

function changeName() {
  fish.value.name += '~';
}
function changePrice() {
  fish.value.price += 1;
}
function changeFish() {
  fish.value = { name: '鲤鱼', price: 10 };
}
watch(fish, (newValue, oldValue) => {
  console.log(newValue, oldValue);
})

运行效果如下: 在这里插入图片描述 当修改响应式对象成员变量时,不会引起fish watch函数运行。原因在于watch监视的不是fish.name而是fish。那么如何才能监视fish.namefish.price数据变化呢? watch函数,它有三个参数

  • 一是监视对象
  • 二是监视回调函数,
  • 三是配置对象参数,如deep等等 只有在配置对象开启deep即可。
<template>
  <h2>鱼类:{{ fish.name }}</h2>
  <h2>价格:{{ fish.price }}</h2>
  <button @click="changeName()">修改鱼类</button>
  <button @click="changePrice()">修改鱼价</button>
  <button @click="changeFish()">更换真个鱼</button>
</template>
<script setup>
import { ref, watch } from 'vue'
let fish = ref({ name: '鲫鱼', price: 5 });

function changeName() {
  fish.value.name += '~';
}
function changePrice() {
  fish.value.price += 1;
}
function changeFish() {
  fish.value = { name: '鲤鱼', price: 10 };
}
watch(fish, (newValue, oldValue) => {
  console.log(newValue, oldValue);
}, { deep: true })
</script>

运行效果,仔细观看控制台打印的新数据、旧数据。

  • fish.name改变时,新旧数据一样
  • fish.price改变时,新旧数据一样
  • 当fish整个改变时,新旧数据不一样 效果如图: 在这里插入图片描述 注意fish.namefish.price,新旧数据是一样的。因为watch是从对象地址取到的数据。

4、watch监视函数返回一个值(getter函数)

它的功能是wath监视响应式对象中一个属性,如监视fish.name,是不允许直接监视,需要写成一个函数的形式。

  • 创建组件Fish,引入reactive, watch
  • 创建响应式对象fish,鱼的名字,鱼的体型:长度、重量
  • 分别监听鱼的名字与体型
  • 点击按钮修改鱼类,鱼的长度、鱼的重量、鱼的体型
<template>
  <h2>鱼类:{{ fish.name }}</h2>
  <h2>鱼长度:{{ fish.body.long }}</h2>
  <h2>鱼重量:{{ fish.body.weight }}</h2>
  <button @click="changeName()">修改鱼类</button>
  <button @click="changeFishLong()">修改鱼的长度</button>
  <button @click="changeFishWeight()">修改鱼的重量</button>
  <button @click="changeFishbody()">修改鱼的体型</button>
</template>
<script setup>
import { reactive, watch } from 'vue'
let fish = reactive({ name: '鲫鱼', body: { long: 1, weight: 24 } });

function changeName() {
  fish.name += '~';
}
function changeFishLong() {
  fish.body.long += 1;
}
function changeFishWeight() {
  fish.body.weight += 1;
}
function changeFishbody() {
  fish.body = { long: 100, weight: 300 };
}
watch(() => { return fish.name }, (newValue, oldValue) => {
  console.log('监听fish.name', newValue, oldValue);
})
watch(() => { return fish.body }, (newValue, oldValue) => {
  console.log('监听fish.body', newValue, oldValue);
})
</script>

监听响应式对象中的参数,需要写成一个箭头函数,并返回监听参数即可。具体操作,看下图: 在这里插入图片描述 当点击按钮,发现只有修改鱼类、修改鱼的体型,才能监听到变化。这是因为watch监听的地址。若想要能够监听到鱼的长度、鱼的重量,需要再watch加入deep参数即可。

watch(() => { return fish.body }, (newValue, oldValue) => {
  console.log('监听fish.body', newValue, oldValue);
}, { deep: true })

在这里插入图片描述 注意点击按钮修改鱼的长度、修改鱼的重量,新旧数据是一致的。

5、watch监视含有响应式对象数组的数据

watch监视的对象是一个数组,数组内可以是ref定义基本类型数据,也可是对象,可以是函数。

watch([() => { return fish.name },() => { return fish.body }], (newValue, oldValue) => {
  console.log('监听素组', newValue, oldValue);
}, { deep: true })

由于使用配置参数deep,操作效果如下: 在这里插入图片描述

具体代码

<template>
  <h2>鱼类:{{ fish.name }}</h2>
  <h2>鱼长度:{{ fish.body.long }}</h2>
  <h2>鱼重量:{{ fish.body.weight }}</h2>
  <button @click="changeName()">修改鱼类</button>
  <button @click="changeFishLong()">修改鱼的长度</button>
  <button @click="changeFishWeight()">修改鱼的重量</button>
  <button @click="changeFishbody()">修改鱼的体型</button>
</template>
<script setup>
import { reactive, watch } from 'vue'
let fish = reactive({ name: '鲫鱼', body: { long: 1, weight: 24 } });

function changeName() {
  fish.name += '~';
}
function changeFishLong() {
  fish.body.long += 1;
}
function changeFishWeight() {
  fish.body.weight += 1;
}
function changeFishbody() {
  fish.body = { long: 100, weight: 300 };
}
watch([() => { return fish.name },() => { return fish.body }], (newValue, oldValue) => {
  console.log('监听素组', newValue, oldValue);
}, { deep: true })
</script>

6、总结 watch可以监视四种数据,再加上配置函数,内容多且难记。在项目中多练习几次就能熟记。

  • ref定义的数据
  • reactive定义的数据
  • 函数返回一个值(getter函数
  • 包含上诉三种值的数组 在现实开发中,第一种和第三种情况最常用。尤其第三种情况,加函数,加配置参数deep。属于重中之重。
watch(() => { return fish.body }, (newValue, oldValue) => {
  console.log('监听fish.body', newValue, oldValue);
}, { deep: true })

React 性能优化完全指南:从渲染机制到实战技巧

2026年3月17日 16:41

深入理解 React 渲染原理,掌握性能优化的核心技巧


前言

在日常开发中,我们经常会遇到 React 应用性能问题:组件频繁重渲染、页面卡顿、响应延迟等。很多人第一反应是使用 memouseMemouseCallback 等优化手段,但往往治标不治本。

真正的性能优化,始于理解 React 的渲染机制。

本文将从 React 的渲染流程入手,带你从原理到实践,系统掌握性能优化的核心技巧。


一、React 渲染的三个阶段

React 的 UI 更新过程可以分为三个关键步骤:

1. 触发渲染(Trigger)

组件渲染的触发原因只有两种:

  • 首次渲染:应用启动时,通过 createRoot().render() 触发
  • 状态更新:组件或其祖先组件的 state 发生变化
// 首次渲染
const root = createRoot(document.getElementById('root'));
root.render(<App />);

// 状态更新触发重渲染
const [count, setCount] = useState(0);
// 调用 setCount 会自动触发重渲染

2. 渲染组件(Render)

渲染 = React 调用你的组件函数

  • 首次渲染时,React 调用根组件
  • 重渲染时,React 调用状态更新的组件及其子组件

这是一个递归过程:如果组件返回了其他组件,React 会继续渲染那些组件,直到确定屏幕上应该显示什么。

function Gallery() {
  return (
    <section>
      <h1>精彩图片</h1>
      <Image />
      <Image />
      <Image />
    </section>
  );
}

上述代码中,React 会调用 Gallery() 和三个 Image() 组件。

3. 提交到 DOM(Commit)

渲染完成后,React 会更新 DOM:

  • 首次渲染:使用 appendChild() 创建所有 DOM 节点
  • 重渲染:只应用必要的最小操作来更新 DOM

关键点:React 只有在渲染结果与上次不同时才会更新 DOM。


二、常见性能陷阱

1. 不必要的子组件重渲染

function Parent() {
  const [count, setCount] = useState(0);
  
  return (
    <div>
      <button onClick={() => setCount(count + 1)}>
        计数:{count}
      </button>
      <ExpensiveChild />
    </div>
  );
}

问题:每次 count 变化,ExpensiveChild 都会重渲染,即使它的 props 没有变化。

2. 对象/数组引用变化

function Parent() {
  const [count, setCount] = useState(0);
  
  const config = { theme: 'dark' }; // 每次渲染都创建新对象
  
  return (
    <Child config={config} />
  );
}

问题config 每次都是新引用,导致 Child 认为 props 变化了。

3. 函数引用变化

function Parent() {
  const [count, setCount] = useState(0);
  
  const handleClick = () => {
    console.log('clicked');
  }; // 每次渲染都创建新函数
  
  return <Child onClick={handleClick} />;
}

问题handleClick 每次都是新引用,即使函数内容相同。


三、性能优化实战技巧

1. React.memo - 记忆化组件

const ExpensiveChild = React.memo(function ExpensiveChild({ data }) {
  console.log('ExpensiveChild rendered');
  return <div>{data}</div>;
});

function Parent() {
  const [count, setCount] = useState(0);
  const data = 'static data';
  
  return (
    <div>
      <button onClick={() => setCount(count + 1)}>
        计数:{count}
      </button>
      <ExpensiveChild data={data} />
    </div>
  );
}

效果:当 data 不变时,ExpensiveChild 不会重渲染。

自定义比较函数

const Child = React.memo(function Child({ user, config }) {
  return <div>{user.name} - {config.theme}</div>;
}, (prevProps, nextProps) => {
  // 自定义比较逻辑
  return prevProps.user.id === nextProps.user.id &&
         prevProps.config.theme === nextProps.config.theme;
});

2. useMemo - 记忆化计算结果

function ExpensiveComponent({ items, filter }) {
  // 避免每次渲染都执行昂贵的过滤操作
  const filteredItems = useMemo(() => {
    console.log('Filtering items...');
    return items.filter(item => item.category === filter);
  }, [items, filter]);
  
  return (
    <ul>
      {filteredItems.map(item => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  );
}

使用场景

  • 复杂的计算逻辑
  • 大数据集的过滤/排序
  • 依赖多个状态的派生值

3. useCallback - 记忆化函数引用

function Parent() {
  const [count, setCount] = useState(0);
  
  // 使用 useCallback 保持函数引用稳定
  const handleClick = useCallback(() => {
    console.log('clicked', count);
  }, [count]);
  
  return (
    <div>
      <button onClick={() => setCount(count + 1)}>
        计数:{count}
      </button>
      <Child onClick={handleClick} />
    </div>
  );
}

const Child = React.memo(function Child({ onClick }) {
  console.log('Child rendered');
  return <button onClick={onClick}>点击</button>;
});

4. 稳定对象引用

function Parent() {
  const [count, setCount] = useState(0);
  
  // 使用 useMemo 保持对象引用稳定
  const config = useMemo(() => ({ theme: 'dark' }), []);
  
  // 或使用 useState 存储不变的对象
  const [config] = useState({ theme: 'dark' });
  
  return <Child config={config} />;
}

5. 列表渲染优化

function List({ items }) {
  return (
    <ul>
      {items.map(item => (
        // 推荐:使用稳定的唯一 ID
        <ListItem key={item.id} data={item} />
        
        // 不推荐:避免使用索引作为 key
        // <ListItem key={index} data={item} />
      ))}
    </ul>
  );
}

注意:仅在列表项顺序固定且无唯一 ID 时,才考虑使用索引作为 key。

6. 代码分割与懒加载

// 路由级别代码分割
const Dashboard = lazy(() => import('./Dashboard'));

function App() {
  return (
    <Suspense fallback={<Loading />}>
      <Dashboard />
    </Suspense>
  );
}

// 组件级别代码分割
const HeavyComponent = lazy(() => import('./HeavyComponent'));

function Page() {
  const [show, setShow] = useState(false);
  
  return (
    <>
      <button onClick={() => setShow(true)}>加载组件</button>
      {show && (
        <Suspense fallback={<Loading />}>
          <HeavyComponent />
        </Suspense>
      )}
    </>
  );
}

四、性能分析工具

1. React DevTools Profiler

安装方式:Chrome/Firefox 浏览器扩展

主要功能

  • 记录组件渲染时间和次数
  • 可视化渲染火焰图
  • 识别性能瓶颈

适用场景:日常开发中的性能调试

使用示例

// 在代码中包裹需要分析的组件
import { Profiler } from 'react';

function onRenderCallback(
  id, phase, actualDuration, baseDuration, startTime, commitTime
) {
  console.log(`${id} 渲染耗时:${actualDuration}ms`);
}

<Profiler id="ExpensiveComponent" onRender={onRenderCallback}>
  <ExpensiveComponent />
</Profiler>

使用技巧

  • 在开发环境中使用,生产环境移除
  • 关注 actualDuration 明显大于 baseDuration 的组件
  • 结合火焰图分析渲染瓶颈

2. Chrome Performance 面板

安装方式:Chrome 浏览器内置

主要功能

  • 录制页面交互过程
  • 分析长任务(Long Tasks)
  • 识别渲染瓶颈和内存泄漏
  • 查看帧率(FPS)

适用场景:生产环境性能问题排查

使用步骤

  1. 打开 Chrome DevTools → Performance 面板
  2. 点击录制按钮
  3. 执行需要分析的用户交互
  4. 停止录制,分析结果

关键指标

  • FCP(First Contentful Paint):首次内容绘制时间
  • LCP(Largest Contentful Paint):最大内容绘制时间
  • TTI(Time to Interactive):可交互时间
  • TBT(Total Blocking Time):总阻塞时间

3. why-did-you-render

安装方式

npm install @welldone-software/why-did-you-render

主要功能

  • 检测不必要的组件重渲染
  • 提示 props 变化原因
  • 帮助发现性能问题

适用场景:React 应用性能优化

使用示例

import whyDidYouRender from '@welldone-software/why-did-you-render';
import React from 'react';

whyDidYouRender(React, {
  trackAllPureComponents: true,
});

输出示例

Component "ExpensiveChild" re-rendered even though props did not change.
Previous props: { data: "static" }
New props: { data: "static" }

4. Lighthouse

安装方式:Chrome DevTools 内置

主要功能

  • 自动化性能审计
  • 生成性能报告和优化建议
  • 评估 PWA 合规性

适用场景:网站性能基线测试

使用步骤

  1. 打开 Chrome DevTools → Lighthouse 面板
  2. 选择审计类别(Performance、Accessibility 等)
  3. 点击"生成报告"
  4. 查看评分和优化建议

关键指标

  • Performance Score:性能得分(0-100)
  • First Contentful Paint:首次内容绘制
  • Speed Index:速度指数
  • Time to Interactive:可交互时间

五、优化策略总结

何时优化?

场景 推荐方案
子组件频繁重渲染 React.memo
复杂计算重复执行 useMemo
函数作为 props 传递 useCallback
大型列表渲染 虚拟列表 + key 优化
首屏加载慢 代码分割 + 懒加载
内存占用高 清理副作用 + 避免泄漏

优化优先级

  1. 先测量,后优化 - 使用 Profiler 找到真正的瓶颈
  2. 避免过早优化 - 简单的应用不需要复杂优化
  3. 关注用户感知 - 优先优化可见区域的性能

常见误区

// 不推荐:过度使用 useMemo/useCallback
const value = useMemo(() => props.value, [props.value]);
// props.value 是原始值,不需要记忆化

// 不推荐:忽略依赖数组
const result = useMemo(() => compute(a, b), []);
// a 和 b 变化时不会重新计算

// 不推荐:memo 包裹所有组件
const SimpleComponent = memo(function SimpleComponent({ text }) {
  return <div>{text}</div>;
});
// 简单组件的 memo 开销可能大于收益

六、实战案例

案例 1:优化数据表格

案例背景: 一个展示大量数据的数据表格组件,用户反馈滚动卡顿,尤其是在排序和筛选时。

性能问题

  • 每次父组件状态变化,表格都重新排序
  • 每行数据都重新渲染,即使数据未变化
  • 大数据量(1000+ 行)时明显卡顿

优化前

// 优化前
function DataTable({ data, sortConfig }) {
  const sortedData = data.sort((a, b) => {
    // 每次渲染都排序,且修改原数组
    return a[sortConfig.key] > b[sortConfig.key] ? 1 : -1;
  });
  
  return (
    <table>
      {sortedData.map(row => (
        <TableRow key={row.id} data={row} />
      ))}
    </table>
  );
}

优化后

// 优化后
function DataTable({ data, sortConfig }) {
  // 使用 useMemo 缓存排序结果
  const sortedData = useMemo(() => {
    // 创建副本,避免修改原数组
    return [...data].sort((a, b) => {
      return a[sortConfig.key] > b[sortConfig.key] ? 1 : -1;
    });
  }, [data, sortConfig]);
  
  // 使用 useMemo 缓存行元素
  const rows = useMemo(() => {
    return sortedData.map(row => (
      <TableRow key={row.id} data={row} />
    ));
  }, [sortedData]);
  
  return <table>{rows}</table>;
}

// 使用 React.memo 优化行组件
const TableRow = React.memo(function TableRow({ data }) {
  return <tr>{/* 渲染行数据 */}</tr>;
});

优化效果

  • 排序计算从每次渲染变为仅当 data 或 sortConfig 变化时执行
  • 行组件仅在数据变化时重新渲染
  • 1000 行数据滚动帧率从 15fps 提升至 55fps

关键要点

  1. 使用 useMemo 缓存昂贵计算
  2. 避免修改原数组,使用 [...data] 创建副本
  3. 使用 React.memo 减少子组件重渲染

案例 2:优化表单输入

案例背景: 一个多字段表单组件,用户输入时整体表单频繁重渲染,导致输入延迟。

性能问题

  • 每次输入都触发整个表单重渲染
  • 回调函数每次渲染都创建新引用
  • 子组件无法使用 React.memo 优化

优化前

// 优化前
function Form() {
  const [formData, setFormData] = useState({});
  
  // 每次渲染都创建新函数
  const handleChange = (field, value) => {
    setFormData({ ...formData, [field]: value });
  };
  
  return (
    <>
      <Input value={formData.name} onChange={v => handleChange('name', v)} />
      <Input value={formData.email} onChange={v => handleChange('email', v)} />
      <SubmitButton data={formData} />
    </>
  );
}

优化后

// 优化后
function Form() {
  const [formData, setFormData] = useState({});
  
  // 使用 useCallback 保持函数引用稳定
  const handleChange = useCallback((field, value) => {
    // 使用函数式更新,避免依赖 formData
    setFormData(prev => ({ ...prev, [field]: value }));
  }, []);
  
  // 提交函数也使用 useCallback
  const handleSubmit = useCallback(() => {
    submit(formData);
  }, [formData]);
  
  return (
    <>
      <Input 
        value={formData.name} 
        onChange={useCallback(v => handleChange('name', v), [handleChange])} 
      />
      <Input 
        value={formData.email} 
        onChange={useCallback(v => handleChange('email', v), [handleChange])} 
      />
      <SubmitButton data={formData} onSubmit={handleSubmit} />
    </>
  );
}

// 使用 React.memo 优化输入组件
const Input = React.memo(function Input({ value, onChange }) {
  return <input value={value} onChange={onChange} />;
});

优化效果

  • handleChange 函数引用稳定,不会触发子组件不必要的重渲染
  • 使用函数式更新 prev => ({ ...prev, [field]: value }),移除对 formData 的依赖
  • 输入响应时间从 150ms 降至 50ms

关键要点

  1. 使用 useCallback 保持回调函数引用稳定
  2. 使用函数式更新避免不必要的依赖
  3. 配合 React.memo 优化子组件

七、核心要点

  1. 理解渲染机制 - 知道何时、为何渲染是优化的前提
  2. 测量优先 - 使用 Profiler 找到真正的瓶颈,不要过早优化
  3. 适度优化 - 避免过度使用 memouseMemouseCallback
  4. 关注用户体验 - 优化的最终目标是提升用户体验,而非追求完美指标

参考资料

  1. React 官方文档 - Render and Commit: react.dev/learn/rende…
  2. React 官方文档 - 性能优化指南:react.dev/learn/optim…
  3. React 官方文档 - React DevTools Profiler: react.dev/learn/react…
  4. patterns.dev - React 性能模式:www.patterns.dev/react/
  5. Web.dev - Web Vitals 性能指标:web.dev/vitals/
  6. MDN - Chrome DevTools Performance 面板:developer.mozilla.org/zh-CN/docs/…

觉得文章对你有帮助?欢迎点赞收藏,分享给更多需要的朋友!

Flutter Provider 状态管理深度指南

2026年3月17日 16:36

Flutter Provider 状态管理深度指南

本文档深入解析 Flutter Provider 状态管理库的核心原理、各类 Provider 的用法,并通过多个复杂场景示例详细讲解每个环节的实现方式。


目录

  1. 概述与核心原理
  2. Provider 类型详解
  3. 读取值的方式
  4. 场景一:基础计数器应用
  5. 场景二:多 Provider 依赖与 ProxyProvider
  6. 场景三:异步数据 FutureProvider
  7. 场景四:流式数据 StreamProvider
  8. 场景五:购物车与复杂业务状态
  9. 场景六:性能优化 Selector 与 Consumer
  10. 场景七:接口与实现分离
  11. 组件化项目中的用法
  12. 常见问题与最佳实践

1. 概述与核心原理

1.1 什么是 Provider

Provider 是 Flutter 官方推荐的状态管理方案之一,是对 InheritedWidget 的封装,使其更易用、更易复用。它由 Remi Rousselet 开发,是 Flutter Favorite 认证包。

核心优势:

特性 说明
资源管理 自动处理对象的创建、监听和销毁
懒加载 默认延迟创建,仅在首次被消费时创建
代码简洁 大幅减少 InheritedWidget 的样板代码
DevTools 支持 可在 Flutter DevTools 中查看应用状态
统一消费模式 context.watchcontext.readConsumerSelector
可扩展性 针对 ChangeNotifier 等监听机制做了优化

1.2 依赖安装

# pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  provider: ^6.1.0

1.3 工作原理简述

┌─────────────────────────────────────────────────────────────┐
│                    Widget Tree                                │
│  ┌─────────────────────────────────────────────────────┐    │
│  │  MultiProvider / ChangeNotifierProvider              │    │
│  │  (在 InheritedWidget 中存储状态对象)                   │    │
│  │  ┌───────────────────────────────────────────────┐   │    │
│  │  │  Child Widget 1  → context.watch() 监听变化    │   │    │
│  │  │  Child Widget 2  → context.read()  仅读取      │   │    │
│  │  │  Child Widget 3  → context.select() 选择性监听 │   │    │
│  │  └───────────────────────────────────────────────┘   │    │
│  └─────────────────────────────────────────────────────┘    │
└─────────────────────────────────────────────────────────────┘

当 ChangeNotifier.notifyListeners() 被调用时:
  → 所有通过 watch/select 监听的 Widget 会重建
  → 通过 read 获取的 Widget 不会重建

2. Provider 类型详解

2.1 类型对照表

Provider 类型 适用场景 说明
Provider 不可变值、简单对象 最基础形式,直接暴露值
ChangeNotifierProvider 可变状态、业务逻辑 监听 ChangeNotifier,自动调用 dispose
ListenableProvider 任意 Listenable ChangeNotifierProvider 的父类
FutureProvider 异步初始化数据 监听 Future,完成时更新
StreamProvider 流式数据 监听 Stream,每次 emit 时更新
ProxyProvider 依赖其他 Provider 派生 组合多个 Provider 生成新值
ChangeNotifierProxyProvider 依赖其他 Provider 的 ChangeNotifier ProxyProvider + ChangeNotifier

2.2 创建 vs 复用

创建新对象(推荐):

ChangeNotifierProvider(
  create: (_) => MyModel(),  // 在 create 中创建
  child: MyApp(),
)

复用已有对象:

MyModel existingModel = MyModel();

ChangeNotifierProvider.value(
  value: existingModel,  // 使用 .value 构造函数
  child: MyApp(),
)

⚠️ 注意:用默认构造函数传入已有对象会导致 Provider 在 dispose 时错误地调用 existingModel.dispose(),可能引发问题。


3. 读取值的方式

3.1 context.watch<T>()

作用:监听 Provider,当值变化时触发当前 Widget 重建

Widget build(BuildContext context) {
  final counter = context.watch<Counter>();
  return Text('${counter.count}');
}
  • 适用于:需要根据状态更新 UI 的场景
  • 注意:在 build 内调用,每次 Counter 变化都会重建

3.2 context.read<T>()

作用:仅读取值,不监听,不会因变化而重建。

FloatingActionButton(
  onPressed: () => context.read<Counter>().increment(),
  child: Icon(Icons.add),
)
  • 适用于:事件回调、initStatedidChangeDependencies
  • ⚠️ 不能在 build 中调用(会导致逻辑错误)

3.3 context.select<T, R>()

作用:只监听对象的部分属性,只有该部分变化时才重建。

// 仅当 person.name 变化时重建
final name = context.select((Person p) => p.name);
return Text(name);
  • 适用于:大对象中只关心少量字段时的性能优化

3.4 Provider.of 等价写法

// 等价于 watch
Provider.of<Counter>(context)

// 等价于 read
Provider.of<Counter>(context, listen: false)

4. 场景一:基础计数器应用

4.1 需求

实现一个简单计数器:点击按钮数字 +1,界面实时更新。

4.2 状态模型

import 'package:flutter/foundation.dart';

class Counter with ChangeNotifier, DiagnosticableTreeMixin {
  int _count = 0;

  int get count => _count;

  void increment() {
    _count++;
    notifyListeners();  // 通知所有监听者重建
  }

  @override
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
    super.debugFillProperties(properties);
    properties.add(IntProperty('count', count));  // DevTools 中可读
  }
}

要点:

  • ChangeNotifier:提供 notifyListeners()
  • DiagnosticableTreeMixin:便于 DevTools 调试
  • 修改状态后必须调用 notifyListeners()

4.3 注入 Provider

void main() {
  runApp(
    ChangeNotifierProvider(
      create: (_) => Counter(),
      child: const MyApp(),
    ),
  );
}

4.4 消费状态

// 显示数字的 Widget - 需要监听
class Count extends StatelessWidget {
  const Count({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Text(
      '${context.watch<Counter>().count}',
      style: Theme.of(context).textTheme.headlineMedium,
    );
  }
}

// 按钮 - 只需触发操作,不监听
class IncrementButton extends StatelessWidget {
  const IncrementButton({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return FloatingActionButton(
      onPressed: () => context.read<Counter>().increment(),
      child: const Icon(Icons.add),
    );
  }
}

设计说明:将 Count 抽成独立 Widget,只有它会在 Counter 变化时重建,避免整个页面重建。


5. 场景二:多 Provider 依赖与 ProxyProvider

5.1 需求

  • 有一个 Counter
  • 有一个 Translations,其内容依赖 Counter 的值
  • Translations 需要在 Counter 变化时自动更新

5.2 实现

class Counter with ChangeNotifier {
  int _value = 0;
  int get value => _value;
  void increment() {
    _value++;
    notifyListeners();
  }
}

class Translations {
  const Translations(this._value);
  final int _value;
  String get title => '你点击了 $_value 次';
}

// 使用 ProxyProvider 建立依赖关系
Widget build(BuildContext context) {
  return MultiProvider(
    providers: [
      ChangeNotifierProvider(create: (_) => Counter()),
      ProxyProvider<Counter, Translations>(
        update: (_, counter, __) => Translations(counter.value),
      ),
    ],
    child: const Foo(),
  );
}

class Foo extends StatelessWidget {
  const Foo({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final translations = context.watch<Translations>();
    return Text(translations.title);
  }
}

5.3 ProxyProvider 变体

类型 依赖数量 说明
ProxyProvider<A, R> 1 个 依赖 A,产出 R
ProxyProvider2<A, B, R> 2 个 依赖 A、B,产出 R
ProxyProvider3<A, B, C, R> 3 个 依赖 A、B、C,产出 R
ChangeNotifierProxyProvider<A, R> 1 个 产出的 R 是 ChangeNotifier

示例:依赖两个 Provider

ProxyProvider2<UserRepository, SettingsRepository, AppConfig>(
  update: (_, userRepo, settingsRepo, __) {
    return AppConfig(
      userId: userRepo.currentUserId,
      theme: settingsRepo.theme,
    );
  },
  child: MyApp(),
)

6. 场景三:异步数据 FutureProvider

6.1 需求

从网络或本地加载配置,加载中显示 Loading,完成后显示内容。

6.2 实现

FutureProvider<AppConfig?>(
  initialData: null,  // 5.0+ 必须提供,加载期间使用此值
  create: (context) => fetchConfigFromNetwork(),
  child: MyApp(),
)

// 消费:watch 得到的是 T?,加载中为 initialData,完成后为实际值
class ConfigConsumer extends StatelessWidget {
  const ConfigConsumer({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final config = context.watch<AppConfig?>();

    if (config == null) {
      return const CircularProgressIndicator();
    }
    return Text('主题: ${config.theme}');
  }
}

说明:provider 包的 FutureProvider 直接暴露 T? 类型。加载中为 initialData,完成或出错后更新。如需区分 loading/data/error,可配合 FutureBuilder 或自定义包装类。

6.3 结合 ProxyProvider 使用

MultiProvider(
  providers: [
    Provider<AuthService>(create: (_) => AuthService()),
    FutureProvider<User?>(
      initialData: null,
      create: (context) => context.read<AuthService>().getCurrentUser(),
    ),
    ProxyProvider<User?, UserProfile?>(
      update: (_, user, __) => user == null ? null : UserProfile(user),
    ),
  ],
  child: MyApp(),
)

7. 场景四:流式数据 StreamProvider

7.1 需求

实时显示 WebSocket 消息、数据库变化、传感器数据等流式数据。

7.2 实现

MultiProvider(
  providers: [
    Provider<ChatService>(create: (_) => ChatService()),
    StreamProvider<ChatMessage>(
      initialData: ChatMessage.empty(),
      create: (context) => context.read<ChatService>().messageStream,
    ),
  ],
  child: const ChatPage(),
)

// 消费:watch 得到的是 Stream 最新一次 emit 的值
class LatestMessageTile extends StatelessWidget {
  const LatestMessageTile({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final message = context.watch<ChatMessage>();
    return ListTile(
      title: Text(message.content),
      subtitle: Text(message.timestamp.toString()),
    );
  }
}

7.3 捕获 Stream 错误

StreamProvider<int>.value(
  initialData: 0,
  value: myStream.handleError((e) => 0),
  child: MyApp(),
)

8. 场景五:购物车与复杂业务状态

8.1 需求

  • 商品列表、购物车、总价
  • 添加/删除商品、清空购物车
  • 多处 UI 需要同步更新

8.2 状态模型

class CartItem {
  final String id;
  final String name;
  final double price;
  final int quantity;

  CartItem({
    required this.id,
    required this.name,
    required this.price,
    this.quantity = 1,
  });

  CartItem copyWith({int? quantity}) => CartItem(
    id: id,
    name: name,
    price: price,
    quantity: quantity ?? this.quantity,
  );
}

class CartModel with ChangeNotifier {
  final List<CartItem> _items = [];

  List<CartItem> get items => List.unmodifiable(_items);

  int get itemCount => _items.fold(0, (sum, item) => sum + item.quantity);

  double get totalPrice =>
      _items.fold(0.0, (sum, item) => sum + item.price * item.quantity);

  void add(CartItem item) {
    final index = _items.indexWhere((i) => i.id == item.id);
    if (index >= 0) {
      _items[index] = _items[index].copyWith(
        quantity: _items[index].quantity + 1,
      );
    } else {
      _items.add(item);
    }
    notifyListeners();
  }

  void remove(String id) {
    _items.removeWhere((i) => i.id == id);
    notifyListeners();
  }

  void clear() {
    _items.clear();
    notifyListeners();
  }
}

8.3 注入与消费

void main() {
  runApp(
    ChangeNotifierProvider(
      create: (_) => CartModel(),
      child: const MyApp(),
    ),
  );
}

// 购物车图标 - 只关心数量
class CartBadge extends StatelessWidget {
  const CartBadge({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final count = context.select((CartModel c) => c.itemCount);
    return Badge(
      label: Text('$count'),
      child: Icon(Icons.shopping_cart),
    );
  }
}

// 总价 - 只关心 totalPrice
class CartTotal extends StatelessWidget {
  const CartTotal({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final total = context.select((CartModel c) => c.totalPrice);
    return Text('合计: ¥${total.toStringAsFixed(2)}');
  }
}

// 商品列表 - 需要完整 items
class CartItemList extends StatelessWidget {
  const CartItemList({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final cart = context.watch<CartModel>();
    return ListView.builder(
      itemCount: cart.items.length,
      itemBuilder: (_, i) {
        final item = cart.items[i];
        return ListTile(
          title: Text(item.name),
          subtitle: Text('x${item.quantity}'),
          trailing: IconButton(
            icon: Icon(Icons.delete),
            onPressed: () => context.read<CartModel>().remove(item.id),
          ),
        );
      },
    );
  }
}

要点:使用 context.selectCartBadgeCartTotal 只在各自关心的字段变化时重建,避免不必要的刷新。


9. 场景六:性能优化 Selector 与 Consumer

9.1 问题

当状态对象很大时,context.watch<BigModel>() 会导致任何 BigModel 变化都触发重建,即使只用到其中一个字段。

9.2 使用 Selector

// 仅当 Person.name 变化时重建
class PersonName extends StatelessWidget {
  const PersonName({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final name = context.select((Person p) => p.name);
    return Text(name);
  }
}

9.3 使用 Consumer 限定重建范围

Foo(
  child: Consumer<CartModel>(
    builder: (context, cart, child) {
      return Column(
        children: [
          Text('${cart.itemCount} 件商品'),
          child!,  // child 不会因 cart 变化而重建
        ],
      );
    },
    child: const ExpensiveWidget(),  // 稳定的子组件
  ),
)

9.4 使用 Selector Widget

Selector<CartModel, int>(
  selector: (_, cart) => cart.itemCount,
  builder: (_, count, __) => Text('$count'),
)

10. 场景七:接口与实现分离

10.1 需求

  • 定义抽象接口 AuthService
  • 提供实现 FirebaseAuthService
  • UI 层只依赖接口,便于测试和替换实现

10.2 实现

abstract class AuthService with ChangeNotifier {
  User? get currentUser;
  Future<void> signIn(String email, String password);
  Future<void> signOut();
}

class FirebaseAuthService with ChangeNotifier implements AuthService {
  // 实现细节...
}

// 注入时指定接口类型,创建时返回实现
ChangeNotifierProvider<AuthService>(
  create: (_) => FirebaseAuthService(),
  child: MyApp(),
)

// 消费时使用接口类型
class ProfilePage extends StatelessWidget {
  const ProfilePage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final auth = context.watch<AuthService>();
    return auth.currentUser == null
        ? LoginPage()
        : UserProfile(user: auth.currentUser!);
  }
}

11. 组件化项目中的用法

11.1 组件化项目结构

在模块化/组件化架构中,项目通常按功能或业务域拆分:

my_app/
├── app/                    # 主应用入口
│   └── main.dart
├── core/                   # 核心层(路由、主题、常量)
│   ├── router/
│   └── theme/
├── shared/                 # 共享层(公共组件、工具、基础 Provider)
│   ├── providers/          # 全局 Provider 定义
│   └── widgets/
├── modules/                # 业务模块
│   ├── auth/               # 登录注册模块
│   │   ├── providers/
│   │   ├── models/
│   │   └── views/
│   ├── home/               # 首页模块
│   └── profile/            # 个人中心模块
└── pubspec.yaml

11.2 分层注入策略

全局 Provider(应用根级)

main.dart 或根 Widget 注入跨模块共享的状态:

// app/main.dart
void main() {
  runApp(
    MultiProvider(
      providers: [
        // 全局:用户认证、主题、语言等
        ChangeNotifierProvider(create: (_) => AuthProvider()),
        ChangeNotifierProvider(create: (_) => ThemeProvider()),
        Provider<ApiClient>(create: (_) => ApiClientImpl()),
      ],
      child: const MyApp(),
    ),
  );
}
模块级 Provider(按需挂载)

仅在进入某模块时注入,离开时自动 dispose:

// modules/home/home_page.dart
class HomePage extends StatelessWidget {
  const HomePage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MultiProvider(
      providers: [
        // 仅首页需要的状态
        ChangeNotifierProvider(create: (_) => HomeFeedProvider()),
        ChangeNotifierProvider(create: (_) => BannerProvider()),
      ],
      child: const HomeView(),
    );
  }
}

好处:未进入首页时不会创建 HomeFeedProvider,减少内存和初始化开销。

路由级 Provider(与 GoRouter/路由结合)
// 路由配置中为不同路由挂载不同 Provider
GoRoute(
  path: '/cart',
  builder: (context, state) => MultiProvider(
    providers: [
      ChangeNotifierProvider(create: (_) => CartProvider()),
    ],
    child: const CartPage(),
  ),
),

11.3 模块间共享状态

方式一:通过根级 Provider 共享

// shared/providers/cart_provider.dart
class CartProvider with ChangeNotifier {
  final List<CartItem> _items = [];
  // ...
}

// main.dart 根级注入
ChangeNotifierProvider(create: (_) => CartProvider()),

// 任意模块中消费
context.watch<CartProvider>()

方式二:通过 ChangeNotifierProxyProvider 依赖其他模块

// 购物车依赖当前用户(AuthProvider 需有 currentUserId,CartProvider 需有 userId 可写属性)
MultiProvider(
  providers: [
    ChangeNotifierProvider(create: (_) => AuthProvider()),
    ChangeNotifierProxyProvider<AuthProvider, CartProvider>(
      create: (_) => CartProvider(),
      update: (_, auth, cart) => cart!..userId = auth.currentUserId,
    ),
  ],
  child: MyApp(),
)

11.4 可复用组件的 Provider 可选依赖

组件可能在不同上下文中使用:有时在 Provider 内,有时在 Provider 外。使用可空类型避免强依赖:

// shared/widgets/optional_cart_badge.dart
class OptionalCartBadge extends StatelessWidget {
  const OptionalCartBadge({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    // 找不到 CartProvider 时返回 null,不抛错
    final cart = context.watch<CartProvider?>();
    if (cart == null) return const SizedBox.shrink();

    return Badge(
      label: Text('${cart.itemCount}'),
      child: const Icon(Icons.shopping_cart),
    );
  }
}

11.5 模块内私有 Provider

模块内部状态不暴露给其他模块,仅在模块内使用:

// modules/auth/login_page.dart
class LoginPage extends StatelessWidget {
  const LoginPage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider(
      create: (_) => LoginFormProvider(),  // 仅登录页使用
      child: const LoginView(),
    );
  }
}

11.6 依赖注入与模块解耦

通过接口 + Provider 实现模块解耦,便于单测和替换实现:

// core/contracts/auth_repository.dart(接口定义在 core)
abstract class AuthRepository {
  Future<User?> getCurrentUser();
  Future<void> signOut();
}

// modules/auth/data/auth_repository_impl.dart(实现在模块内)
class AuthRepositoryImpl implements AuthRepository {
  // ...
}

// AuthProvider 需有 repository 可写属性
// app/main.dart - 根级注入接口,实现由模块提供
MultiProvider(
  providers: [
    Provider<AuthRepository>(
      create: (_) => AuthRepositoryImpl(),
    ),
    ChangeNotifierProxyProvider<AuthRepository, AuthProvider>(
      create: (_) => AuthProvider(),
      update: (_, repo, auth) => auth!..repository = repo,
    ),
  ],
  child: MyApp(),
)

11.7 组件化项目中的 Provider 分层示意

┌─────────────────────────────────────────────────────────────────┐
│  main.dart - 根级 MultiProvider                                   │
│  ├── AuthProvider          (全局)                                │
│  ├── ThemeProvider         (全局)                                │
│  └── ApiClient             (全局)                                │
│       └── MyApp()                                                │
│            └── Router                                            │
│                 ├── /login  → LoginPage + LoginFormProvider      │
│                 ├── /home   → HomePage + HomeFeedProvider        │
│                 └── /cart   → CartPage + CartProvider            │
└─────────────────────────────────────────────────────────────────┘

规则:
• 全局、跨模块共享 → 根级
• 单模块、单页面 → 模块/路由级
• 可复用组件 → 使用 context.watch<T?>() 支持可选依赖

11.8 小结

场景 注入位置 示例
全局共享 main.dart 根级 AuthProvider、ThemeProvider
模块私有 模块/页面根 Widget LoginFormProvider、HomeFeedProvider
路由级 路由 builder 内 CartProvider(仅购物车页)
可选依赖 消费时用 T? context.watch<CartProvider?>()
模块解耦 接口 + Provider Provider<AuthRepository>

12. 常见问题与最佳实践

12.1 initState 中访问 Provider

错误:

@override
void initState() {
  super.initState();
  context.watch<Foo>().value;  // 会报错
}

正确:

@override
void initState() {
  super.initState();
  context.read<Foo>().fetchData();  // 不监听,只读取
}

12.2 在 initState 中触发可能同步的状态更新

错误:initState 中直接调用可能同步完成并触发 notifyListeners 的方法,会导致 build 阶段异常。

@override
void initState() {
  super.initState();
  context.read<MyNotifier>().fetchSomething();  // 若同步完成会触发 notifyListeners
}

正确: 使用 Future.microtask 推迟到当前帧之后执行。

@override
void initState() {
  super.initState();
  Future.microtask(() =>
    context.read<MyNotifier>().fetchSomething(),
  );
}

12.3 可选依赖(Provider 可能不存在)

// 找不到时抛错
context.watch<ThemeModel>()

// 找不到时返回 null
context.watch<ThemeModel?>()

12.4 同一类型多个 Provider

通过不同类型区分,而不是都用 String

Provider<Country>(create: (_) => Country('中国')),
Provider<City>(create: (_) => City('北京')),

12.5 热重载时重新初始化

class MyModel with ChangeNotifier implements ReassembleHandler {
  @override
  void reassemble() {
    // 热重载时调用
    reset();
  }
}

12.6 Provider 过多导致 StackOverflowError

  • 使用 lazy: false 分批加载
  • 在启动流程中分步挂载 Provider
  • 减少 MultiProvider 的嵌套层级

附录:快速参考

操作 代码
注入 ChangeNotifierProvider(create: (_) => Model(), child: ...)
监听并重建 context.watch<Model>()
只读不监听 context.read<Model>()
选择性监听 context.select<Model, R>((m) => m.field)
多 Provider MultiProvider(providers: [...], child: ...)
依赖派生 ProxyProvider<A, B>(update: (_, a, __) => B(a))

参考资源


弃用 vue-i18n?只用 uView Pro 即可实现 uni-app 全端国际化

2026年3月16日 14:58

一. uView Pro 全面开启多语言

uView Pro 是一款基于 uni-app 和 Vue 3开发的跨平台UI组件库,致力于为开发者提供高质量、易用的组件解决方案。支持H5、小程序、Android、iOS、鸿蒙等多端部署,开箱即用,性能优异。

uView Pro 演示应用已经正式上架鸿蒙应用商店,重要显示均支持国际化,欢迎体验:点击体验

image.png

随着越来越多的应用需要“走向世界”,为不同国家和地区的用户服务,国际化(i18n)成了开发中必不可少的一环。uView Pro一直希望开发者用起来更方便、更顺手,早在几个月前,uView Pro就已经开始了将所有组件i18n化的工作。

目前,很高兴地告诉大家:uView Pro全系 80+ 组件现在都支持国际化了!有了这个功能,可以更简单地让应用支持多种语言,不止组件,整个项目也可以使用,完全可以不用 vue-i18n 了。

官网:uView Pro

快速启动项目:uView Pro Starter

二. uView Pro 的国际化

uView Pro 的国际化功能基于Vue 3的响应式系统设计,也参考vue-i18n的实现方式,具有以下核心特性:

1. 核心特性

  1. 内置双语支持:开箱即用地支持中文和英文,可无限拓展其他语言
  2. 灵活配置:支持全局配置和组件级配置
  3. 响应式更新:语言切换时组件自动更新显示
  4. 持久化存储:用户选择的语言偏好会自动保存
  5. 扩展性强:轻松添加新的语言包或修改现有文案
  6. 组件覆盖全:所有组件的交互文案都支持国际化

2. 支持的组件类型

uView Pro的国际化支持涵盖了所有常用组件,包括但不限于:

  • 基础组件:Button、Input、Modal、Picker等
  • 表单组件:Form、Select、Upload、VerificationCode等
  • 数据展示:Calendar、Pagination、Loadmore等
  • 交互组件:ActionSheet、Keyboard、Search等
  • 状态组件:Empty、NoNetwork、CountDown等

提示:uView Pro内置80+常用组件,目前已经全部支持国际化。如有遗漏,请提交issue,我会及时修复。

三. 快速开始:5分钟上手国际化

第一步:在main.ts中全局配置

最简单的使用方式是在应用入口文件中配置国际化:

// main.ts
import { createApp } from 'vue'
import App from './App.vue'
import uViewPro from 'uview-pro'

const app = createApp(App)

// 配置国际化(使用中文)
app.use(uViewPro, {
  locale: 'zh-CN'  // 或 'en-US',默认为 'zh-CN'
})

// 如果需要更详细的配置
app.use(uViewPro, {
  locale: {
    locales: [], // 自定义语言包数组
    defaultLocale: 'zh-CN' // 默认语言
  }
})

1.png

第二步:在组件中使用

你也可以在具体组件中配置国际化:

<template>
  <u-config-provider
    :locales="locales"
    :current-locale="currentLocale"
  >
    <!-- 你的应用内容 -->
    <u-modal v-model="show" :content="content"></u-modal> 
    <u-button @click="open"> 打开模态框 </u-button>
  </u-config-provider>
</template>

<script setup lang="ts">
import { useLocale } from 'uview-pro'

const { currentLocale, locales } = useLocale()
const show = ref<boolean>(false)
const content = ref<string>('东临碣石,以观沧海')

const open = () => {
    show.value = true
}
</script>

第三步:编程式切换语言

<script setup lang="ts">
import { useLocale } from 'uview-pro'

const { setLocale } = useLocale()

// 切换到英文
const switchToEnglish = () => {
  setLocale('en-US')
}

// 切换到中文
const switchToChinese = () => {
  setLocale('zh-CN')
}
</script>

四. 内置语言支持详解

1. 中文语言包 (zh-CN)

uView Pro内置了完整的中文语言包,覆盖所有组件的交互文案:

// 中文语言包示例
export default {
    name: 'zh-CN', // 必要
    label: '简体中文', // 必要
    locale: 'zh-Hans', // 必要
    uActionSheet: {
        cancelText: '取消'
    },
    uUpload: {
        uploadText: '选择图片',
        retry: '点击重试',
        overSize: '超出允许的文件大小',
        overMaxCount: '超出最大允许的文件个数',
        reUpload: '重新上传',
        uploadFailed: '上传失败,请重试',
        modalTitle: '提示',
        deleteConfirm: '您确定要删除此项吗?',
        terminatedRemove: '已终止移除',
        removeSuccess: '移除成功',
        previewFailed: '预览图片失败',
        notAllowedExt: '不允许选择{ext}格式的文件',
        noAction: '请配置上传地址'
    },
    // ... 其他组件文案
}

2. 英文语言包 (en-US)

对应的中文语言包提供了完整的英文翻译:

// 英文语言包示例
export default {
    name: 'en-US', // 必要
    label: 'English', // 必要
    locale: 'en', // 必要
    uActionSheet: {
        cancelText: 'Cancel'
    },
    uUpload: {
        uploadText: 'Select Image',
        retry: 'Retry',
        overSize: 'File size exceeds allowed limit',
        overMaxCount: 'Exceeds maximum allowed number of files',
        reUpload: 'Re-upload',
        uploadFailed: 'Upload failed, please try again',
        modalTitle: 'Notice',
        deleteConfirm: 'Are you sure you want to delete this item?',
        terminatedRemove: 'Removal cancelled',
        removeSuccess: 'Removed successfully',
        previewFailed: 'Failed to preview image',
        notAllowedExt: 'Files with {ext} format are not allowed',
        noAction: 'Please configure upload address'
    },
    // ... 其他组件文案
}

部分中、英文字段对照:

2.png

以Calendar日历组件为例,对比如下:

3.png

五. 高级用法:深度定制国际化

支持将语言包进行深度定制,覆盖或添加新的语言包。

1. 部分覆盖内置语言包

有时候你可能需要调整某些组件的默认文案,以更好地符合你的业务场景。所以,如果你只需要修改部分文案,uView Pro 会通过合并的方式来覆盖:

// main.ts
app.use(uViewPro, {
  theme: themes,
  locale: {
    locales: [{
      name: 'zh-CN',
      uModal: {
        confirmText: '好的',  // 自定义确认按钮文案
        cancelText: '算了'   // 自定义取消按钮文案
      },
      uUpload: {
        uploadText: '选择文件'  // 自定义上传文案
      }
    }],
    defaultLocale: 'zh-CN'
  }
})

4.png

2. 添加新的国际化语言

假设你需要为应用添加法语支持,你需要做以下几件事情:

添加法语文件

假设我们要为应用添加法语支持:

// 首先创建法语语言包文件
// src/locales/fr-FR.ts
export default {
  name: 'fr-FR', // 必须要有
  uActionSheet: {
    cancelText: 'Annuler'
  },
  uModal: {
    title: 'Avertissement',
    content: 'Contenu',
    confirmText: 'Confirmer',
    cancelText: 'Annuler'
  },
  uCalendar: {
    startText: 'Début',
    endText: 'Fin',
    confirmText: 'Confirmer',
    toolTip: 'Sélectionner une date',
    // ... 其他法语翻译
  },
  uUpload: {
    uploadText: 'Sélectionner une image',
    retry: 'Réessayer',
    overSize: 'Le fichier dépasse la taille autorisée',
    // ... 更多法语文案
  },
  // ... 继续添加其他组件的法语翻译
}

根据已有的中文语言包,将所有需要翻译的字段添加法语翻译。

在应用中集成新语言

// main.ts
import { createApp } from 'vue'
import uViewPro from 'uview-pro'
import frFR from './locales/fr-FR'

const app = createApp(App)

app.use(uViewPro, {
  theme: themes,
  locale: {
    locales: [frFR], // 添加法语语言包
    defaultLocale: 'fr-FR' // 设置默认语言为法语,为语言包中的name字段
  }
})

5.png

3. 语言切换功能

创建语言切换组件,示例:

<!-- LanguageSwitcher.vue -->
<template>
  <view class="language-switcher">
    <u-select
      v-model="selectedLocale"
      :options="localeOptions"
      @change="handleLocaleChange"
    />
  </view>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue'
import { useLocale } from 'uview-pro'

const { setLocale, currentLocale, locales } = useLocale()

const selectedLocale = ref(currentLocale.value?.name || 'zh-CN')

const localeOptions = computed(() => {
  return locales.value.map(locale => ({
    label: locale.name,
    value: locale.name
  }))
})

const handleLocaleChange = (value: string) => {
  setLocale(value)
}
</script>

六. 组件级国际化使用

在项目中仍然可以以uView Pro内部hooks来使用国际化,也可以使用国际化如下:

1. 在具体组件中使用翻译

虽然uView Pro的组件会自动使用当前语言的文案,但你也可以在自定义组件中集成国际化:

<template>
  <view class="custom-component">
    <u-button @click="showMessage">
      {{ t('buttonText') }}
    </u-button>

    <u-modal v-model="showModal">
      <view class="modal-content">
        <text>{{ t('modalContent') }}</text>
      </view>
    </u-modal>
  </view>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { useLocale } from 'uview-pro'

const { t } = useLocale('custom-component') // 指定组件命名空间
const showModal = ref(false)

const showMessage = () => {
  showModal.value = true
}

// 注意:需要在语言包中添加对应的文案
// custom-component.buttonText 和 custom-component.modalContent
</script>

如果你不用vue-i18n,完全可以借用uView Pro的内部Hook来实现项目级国际化!只需要追加固定的翻译字段即可。更加简便快捷

2. 使用内置组件命名空间

uView Pro的所有内置组件都有自己的命名空间,你可以直接使用:

import { useLocale } from 'uview-pro'

// 使用 actionSheet 组件的命名空间
const { t: actionSheetT } = useLocale('uActionSheet')
const cancelText = actionSheetT('cancelText') // 等价于 t('uActionSheet.cancelText')

// 使用 modal 组件的命名空间
const { t: modalT } = useLocale('uModal')
const confirmText = modalT('confirmText') // 等价于 t('uModal.confirmText')

// 使用 calendar 组件的命名空间
const { t: calendarT } = useLocale('uCalendar')
const startText = calendarT('startText') // 等价于 t('uCalendar.startText')

3. 动态参数替换

支持在翻译中使用动态参数:

const { t } = useLocale()

// 语言包中定义:'welcome': '欢迎您,{name}!'
// 语言包中定义:'itemsCount': '共 {count} 个项目'

const welcomeMessage = t('welcome', { name: '张三' })
// 输出:"欢迎您,张三!"

const itemsMessage = t('itemsCount', { count: 25 })
// 输出:"共 25 个项目"

更多hooks使用方式参考 uView Pro 官方文档

七. 配合vue-i18n实现项目级多语言切换

如果你已经在项目中使用vue-i18n,uView Pro可以完美配合,实现完整的多语言切换功能。这样既可以使用vue-i18n处理业务文案,也可以使用uView Pro处理组件文案。

1. 项目配置

首先在项目中配置vue-i18n:

// src/locales/index.ts
import { createI18n } from 'vue-i18n';

import zhHans from './langs/zh-Hans.json'; // 简体中文
import en from './langs/en.json'; // 英文

const messages = {
  'zh-Hans': zhHans,
  en,
};

// 自动检测用户语言
const getDefaultLocale = () => {
  try {
    const lang = uni.getLocale?.() || 'zh-Hans';
    return lang.startsWith('zh') ? 'zh-Hans' : 'en';
  } catch {
    return 'zh-Hans';
  }
};

const i18n = createI18n({
  locale: getDefaultLocale(),
  fallbackLocale: 'zh-Hans',
  messages,
  allowComposition: true,
  legacy: false,
  globalInjection: true
});

export default i18n;

在main.ts中集成vue-i18n和uView Pro:

// main.ts
import { createSSRApp } from 'vue';
import App from './App.vue';
import i18n from './locales';
import uViewPro from 'uview-pro';

const app = createSSRApp(App);

// 先使用vue-i18n
app.use(i18n);

// 再使用uView Pro,并配置国际化
app.use(uViewPro, {
  locale: {
    defaultLocale: 'zh-CN'
  }
});

2. 语言切换

<!-- components/LanguageSwitcher.vue -->
<template>
  <view class="language-switcher">
    <u-select
      v-model="selectedLocale"
      :options="localeOptions"
      @change="handleLocaleChange"
    />
  </view>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useLocale } from 'uview-pro';

const { t, locale } = useI18n();
const { setLocale } = useLocale();

const selectedLocale = ref(locale.value);

const localeOptions = computed(() => [
  { label: '中文', value: 'zh-Hans' },
  { label: 'English', value: 'en' }
]);

const handleLocaleChange = (value: string) => {
  // 设置vue-i18n语言
  locale.value = value;

  // 设置系统语言
  uni.setLocale(value);

  // 同步到uView Pro
  const uViewLocale = value === 'zh-Hans' ? 'zh-CN' : 'en-US';
  setLocale(uViewLocale);
};
</script>

3. 在组件中使用双重国际化

<template>
  <view class="page-container">
    <!-- 使用vue-i18n的业务文案 -->
    <view class="page-title">{{ t('page.title') }}</view>

    <!-- 使用uView Pro的组件 -->
    <u-button @click="showModal">{{ t('buttons.submit') }}</u-button>

    <u-modal v-model="showModal" :show-confirm-button="false">
      <!-- 模态框内的文案使用vue-i18n -->
      <view class="modal-content">
        <text>{{ t('modal.confirmMessage') }}</text>
      </view>

      <!-- uView Pro组件的按钮文案会自动使用对应语言 -->
      <view>
        <u-button @click="cancel">{{ t('buttons.cancel') }}</u-button>
        <u-button type="primary" @click="confirm">{{ t('buttons.confirm') }}</u-button>
      </view>
    </u-modal>
  </view>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';

const { t } = useI18n();
const showModal = ref(false);

const confirm = () => {
  // 处理确认逻辑
  showModal.value = false;
};

const cancel = () => {
  showModal.value = false;
};
</script>

4. 语言包示例

// src/locales/langs/zh-CN.json
{
  "name": "zh-CN",
  "page": {
    "title": "用户设置"
  },
  "buttons": {
    "submit": "提交",
    "cancel": "取消",
    "confirm": "确认"
  },
  "modal": {
    "confirmMessage": "您确定要执行此操作吗?"
  }
}
// src/locales/langs/en-US.json
{
  "name": "en-US",
  "page": {
    "title": "User Settings"
  },
  "buttons": {
    "submit": "Submit",
    "cancel": "Cancel",
    "confirm": "Confirm"
  },
  "modal": {
    "confirmMessage": "Are you sure you want to perform this action?"
  }
}

5. 优势特点

  • 无缝集成:vue-i18n处理业务文案,uView Pro处理组件文案
  • 统一切换:一次操作同步切换所有文案
  • 性能优化:两个语言系统独立工作,互不影响性能

八. 总结

uView Pro的国际化功能为开发者提供了强大的多语言支持能力,让构建全球化应用变得前所未有的简单。

核心价值

  1. 开箱即用:内置中英文支持,无需额外配置
  2. 灵活定制:支持修改内置文案,满足个性化需求
  3. 扩展性强:轻松添加新语言,适应全球市场
  4. 性能优异:基于Vue 3响应式系统,性能卓越
  5. 开发友好:完善的TypeScript支持和开发体验

未来规划

我们还会不断改进国际化相关的功能,未来会做到这些:

  • 增加更多默认语言,比如日语、法语、西班牙语等,让更多用户能直接用;
  • 支持把语言包存放和管理在云端,方便多人协作和随时更新;
  • 集成AI翻译,让添加新语言更省力。

一点建议

如果你准备用uView Pro做国际化,或者已经在做,强烈推荐你用最新版,这样能第一时间体验到这些新功能。无论你是做全球业务的创业公司,还是有多语种需求的大公司,uView Pro都能帮你轻松搞定。


关于uView Pro

uView Pro 是全面支持 Vue3.0、TypeScript 的 uni-app 生态框架,提供 80+ 精选 UI 组件、便捷工具、常用模板等,支持多语言、多主题、暗黑模式,支持 H5、小程序、Android、iOS、鸿蒙等多端,开箱即用。

技术资源

❌
❌