Skip to content

系统设计面试题

概述

系统设计题是高级前端面试的重头戏,考察候选人的架构能力技术选型问题分解能力。前端工程师需要能够独立设计和实现复杂的前端系统。

系统设计答题框架

1. 需求澄清 (2-3分钟)
   - 功能需求:核心功能有哪些?
   - 非功能需求:用户量级、性能要求、可用性要求
   - 边界条件:异常情况如何处理?

2. 系统架构 (5-10分钟)
   - 画出整体架构图
   - 说明各模块职责
   - 数据流向

3. 核心模块设计 (10-15分钟)
   - API 设计
   - 数据库设计
   - 关键算法

4. 扩展性与优化 (5分钟)
   - 性能优化
   - 安全考虑
   - 扩展性设计

一、IM 即时通讯系统

需求分析

javascript
/**
 * IM 聊天系统核心需求:
 * 1. 实时消息收发
 * 2. 消息状态(发送中、已发送、已读)
 * 3. 离线消息
 * 4. 历史消息加载
 * 5. 多种消息类型(文本、图片、文件、语音)
 * 6. 群聊功能
 * 7. @功能、表情包
 * 8. 消息搜索
 */

系统架构图

┌─────────────────────────────────────────────────────────────────┐
│                         客户端 (Web/App)                         │
├─────────────────────────────────────────────────────────────────┤
│  ┌──────────┐  ┌──────────┐  ┌──────────┐  ┌──────────────────┐ │
│  │ UI 组件  │  │ 消息管理 │  │ 本地存储 │  │ WebSocket 管理   │ │
│  │ - 会话列表│  │ - 发送   │  │-IndexedDB│  │ - 心跳检测      │ │
│  │ - 消息列表│  │ - 接收   │  │ - 缓存  │  │ - 断线重连      │ │
│  │ - 输入框 │  │ - 状态   │  │ - 离线  │  │ - 消息队列      │ │
│  └──────────┘  └──────────┘  └──────────┘  └──────────────────┘ │
└─────────────────────────────────────────────────────────────────┘

                    WebSocket + HTTPS


┌─────────────────────────────────────────────────────────────────┐
│                        负载均衡 (Nginx)                          │
└─────────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────────┐
│                       WebSocket 网关集群                         │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐                       │
│  │  网关 1  │  │  网关 2  │  │  网关 3  │  ...                  │
│  └──────────┘  └──────────┘  └──────────┘                       │
│       │              │              │                            │
│       └──────────────┼──────────────┘                            │
│                      │                                           │
│              ┌───────▼───────┐                                   │
│              │ 用户连接映射表 │  (Redis)                          │
│              │ userId -> gwId │                                   │
│              └───────────────┘                                   │
└─────────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────────┐
│                         消息服务集群                             │
│  ┌────────────────────────────────────────────────────────────┐ │
│  │                     消息队列 (Kafka)                        │ │
│  │  ┌──────────┐  ┌──────────┐  ┌──────────┐                  │ │
│  │  │ 单聊消息 │  │ 群聊消息 │  │ 系统消息 │                  │ │
│  │  └──────────┘  └──────────┘  └──────────┘                  │ │
│  └────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────────┐
│                          数据存储层                              │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐          │
│  │    MySQL     │  │    Redis     │  │    MinIO     │          │
│  │  - 用户表   │  │  - 会话缓存  │  │  - 图片     │          │
│  │  - 消息表   │  │  - 未读数    │  │  - 文件     │          │
│  │  - 关系表   │  │  - 在线状态  │  │  - 语音     │          │
│  └──────────────┘  └──────────────┘  └──────────────┘          │
└─────────────────────────────────────────────────────────────────┘

消息流转时序图

┌──────┐          ┌──────┐          ┌──────┐          ┌──────┐
│用户 A│          │ 网关 │          │ 服务 │          │用户 B│
└──┬───┘          └──┬───┘          └──┬───┘          └──┬───┘
   │                 │                 │                 │
   │  1.发送消息     │                 │                 │
   │────────────────>│                 │                 │
   │                 │  2.转发消息     │                 │
   │                 │────────────────>│                 │
   │                 │                 │                 │
   │  3.ACK(已发送)  │                 │                 │
   │<────────────────│                 │                 │
   │                 │                 │  4.查询B在线状态│
   │                 │                 │────────────────>│
   │                 │                 │                 │
   │                 │  5.推送消息     │                 │
   │                 │<────────────────│                 │
   │                 │                 │                 │
   │                 │  6.消息送达     │                 │
   │                 │────────────────────────────────>│
   │                 │                 │                 │
   │                 │  7.送达确认     │                 │
   │                 │<────────────────────────────────│
   │                 │                 │                 │
   │  8.状态更新     │                 │                 │
   │  (已送达)       │                 │                 │
   │<────────────────│                 │                 │

数据库设计

sql
-- 用户表
CREATE TABLE users (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    username VARCHAR(50) UNIQUE NOT NULL,
    nickname VARCHAR(100),
    avatar VARCHAR(255),
    status TINYINT DEFAULT 0,  -- 0:离线 1:在线 2:忙碌
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- 会话表
CREATE TABLE conversations (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    type TINYINT NOT NULL,  -- 1:单聊 2:群聊
    name VARCHAR(100),
    avatar VARCHAR(255),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- 会话成员表
CREATE TABLE conversation_members (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    conversation_id BIGINT NOT NULL,
    user_id BIGINT NOT NULL,
    role TINYINT DEFAULT 0,  -- 0:成员 1:管理员 2:群主
    joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_conv_user (conversation_id, user_id)
);

-- 消息表 (按时间分表)
CREATE TABLE messages_202401 (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    conversation_id BIGINT NOT NULL,
    sender_id BIGINT NOT NULL,
    type TINYINT NOT NULL,  -- 1:文本 2:图片 3:文件 4:语音
    content TEXT,
    extra JSON,  -- 扩展字段 {"width":100,"height":100}
    status TINYINT DEFAULT 0,  -- 0:发送中 1:已发送 2:已送达 3:已读
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_conv_time (conversation_id, created_at)
);

-- 消息已读表
CREATE TABLE message_reads (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    message_id BIGINT NOT NULL,
    user_id BIGINT NOT NULL,
    read_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    UNIQUE KEY uk_msg_user (message_id, user_id)
);

API 设计

typescript
// RESTful API

// 获取会话列表
GET /api/conversations
Response: {
  conversations: [{
    id: string,
    type: 'single' | 'group',
    name: string,
    avatar: string,
    lastMessage: Message,
    unreadCount: number,
    updatedAt: string
  }]
}

// 获取历史消息
GET /api/conversations/:id/messages?before=timestamp&limit=20
Response: {
  messages: Message[],
  hasMore: boolean
}

// 发送消息
POST /api/messages
Body: {
  conversationId: string,
  type: 'text' | 'image' | 'file',
  content: string,
  extra?: object
}
Response: {
  messageId: string,
  timestamp: number
}

// WebSocket 消息格式
interface WSMessage {
  type: 'message' | 'ack' | 'read' | 'typing' | 'ping' | 'pong'
  data: {
    messageId?: string
    conversationId?: string
    content?: any
    timestamp: number
  }
}

技术架构

javascript
// 整体架构设计
class IMSystem {
  constructor() {
    this.wsManager = new WebSocketManager()      // WebSocket 管理
    this.messageStore = new MessageStore()       // 消息存储
    this.messageQueue = new MessageQueue()       // 消息队列
    this.syncManager = new SyncManager()         // 消息同步
    this.notificationManager = new NotificationManager()  // 通知管理
  }
}

核心实现

1. WebSocket 连接管理

javascript
class WebSocketManager {
  constructor(options = {}) {
    this.url = options.url
    this.ws = null
    this.heartbeatTimer = null
    this.reconnectTimer = null
    this.reconnectCount = 0
    this.maxReconnect = options.maxReconnect || 5
    this.heartbeatInterval = options.heartbeatInterval || 30000
    this.messageHandlers = new Map()
    this.pendingMessages = []  // 断线期间的待发送消息

    this.connect()
  }

  // 建立连接
  connect() {
    this.ws = new WebSocket(this.url)

    this.ws.onopen = () => {
      console.log('WebSocket 连接成功')
      this.reconnectCount = 0
      this.startHeartbeat()
      this.flushPendingMessages()  // 发送断线期间的消息
      this.emit('connected')
    }

    this.ws.onmessage = (event) => {
      const data = JSON.parse(event.data)
      this.handleMessage(data)
    }

    this.ws.onerror = (error) => {
      console.error('WebSocket 错误:', error)
      this.emit('error', error)
    }

    this.ws.onclose = () => {
      console.log('WebSocket 断开')
      this.stopHeartbeat()
      this.reconnect()
      this.emit('disconnected')
    }
  }

  // 心跳检测
  startHeartbeat() {
    this.heartbeatTimer = setInterval(() => {
      if (this.ws.readyState === WebSocket.OPEN) {
        this.send({ type: 'ping', timestamp: Date.now() })
      }
    }, this.heartbeatInterval)
  }

  stopHeartbeat() {
    if (this.heartbeatTimer) {
      clearInterval(this.heartbeatTimer)
      this.heartbeatTimer = null
    }
  }

  // 断线重连 (指数退避)
  reconnect() {
    if (this.reconnectCount >= this.maxReconnect) {
      console.error('达到最大重连次数')
      this.emit('reconnectFailed')
      return
    }

    // 指数退避: 1s, 2s, 4s, 8s, 16s
    const delay = Math.pow(2, this.reconnectCount) * 1000
    this.reconnectCount++

    console.log(`${delay / 1000}秒后重连, 第${this.reconnectCount}次`)

    this.reconnectTimer = setTimeout(() => {
      this.connect()
    }, delay)
  }

  // 发送消息
  send(data) {
    if (this.ws.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify(data))
    } else {
      // 断线时加入待发送队列
      this.pendingMessages.push(data)
    }
  }

  // 发送断线期间的消息
  flushPendingMessages() {
    while (this.pendingMessages.length > 0) {
      const message = this.pendingMessages.shift()
      this.send(message)
    }
  }

  // 消息处理
  handleMessage(data) {
    const { type } = data

    switch (type) {
      case 'pong':
        // 心跳响应
        break
      case 'message':
        this.emit('message', data)
        break
      case 'ack':
        this.emit('ack', data)
        break
      case 'read':
        this.emit('read', data)
        break
      default:
        console.warn('未知消息类型:', type)
    }
  }

  // 事件发布
  on(event, handler) {
    if (!this.messageHandlers.has(event)) {
      this.messageHandlers.set(event, [])
    }
    this.messageHandlers.get(event).push(handler)
  }

  emit(event, data) {
    const handlers = this.messageHandlers.get(event) || []
    handlers.forEach(handler => handler(data))
  }

  // 销毁
  destroy() {
    this.stopHeartbeat()
    clearTimeout(this.reconnectTimer)
    this.ws.close()
  }
}

2. 消息管理

javascript
class MessageStore {
  constructor() {
    this.conversations = new Map()  // 会话列表
    this.messages = new Map()       // 消息缓存
    this.db = null                  // IndexedDB
    this.initDB()
  }

  // 初始化 IndexedDB
  async initDB() {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open('IMDatabase', 1)

      request.onerror = () => reject(request.error)
      request.onsuccess = () => {
        this.db = request.result
        resolve(this.db)
      }

      request.onupgradeneeded = (event) => {
        const db = event.target.result

        // 创建消息存储
        if (!db.objectStoreNames.contains('messages')) {
          const messageStore = db.createObjectStore('messages', {
            keyPath: 'id'
          })
          messageStore.createIndex('conversationId', 'conversationId')
          messageStore.createIndex('timestamp', 'timestamp')
        }

        // 创建会话存储
        if (!db.objectStoreNames.contains('conversations')) {
          db.createObjectStore('conversations', { keyPath: 'id' })
        }
      }
    })
  }

  // 发送消息
  async sendMessage(message) {
    // 生成消息ID (客户端临时ID)
    const clientMsgId = this.generateId()

    const msg = {
      id: clientMsgId,
      ...message,
      status: 'sending',  // sending | sent | delivered | read | failed
      timestamp: Date.now(),
      isLocal: true
    }

    // 1. 先存本地 (乐观更新)
    await this.saveMessage(msg)

    // 2. 发送到服务器
    try {
      const response = await this.wsManager.sendAndWait({
        type: 'message',
        data: msg
      })

      // 3. 更新消息状态
      msg.status = 'sent'
      msg.serverMsgId = response.serverMsgId
      await this.updateMessage(msg)

    } catch (error) {
      // 4. 发送失败
      msg.status = 'failed'
      await this.updateMessage(msg)
      throw error
    }

    return msg
  }

  // 接收消息
  async receiveMessage(message) {
    // 去重检查
    const existing = await this.getMessageById(message.id)
    if (existing) return existing

    // 保存消息
    await this.saveMessage({
      ...message,
      status: 'delivered'
    })

    // 更新会话
    await this.updateConversation(message)

    return message
  }

  // 保存消息到 IndexedDB
  async saveMessage(message) {
    return new Promise((resolve, reject) => {
      const transaction = this.db.transaction(['messages'], 'readwrite')
      const store = transaction.objectStore('messages')
      const request = store.add(message)

      request.onsuccess = () => resolve(message)
      request.onerror = () => reject(request.error)
    })
  }

  // 更新消息
  async updateMessage(message) {
    return new Promise((resolve, reject) => {
      const transaction = this.db.transaction(['messages'], 'readwrite')
      const store = transaction.objectStore('messages')
      const request = store.put(message)

      request.onsuccess = () => resolve(message)
      request.onerror = () => reject(request.error)
    })
  }

  // 获取历史消息 (支持分页)
  async getMessages(conversationId, options = {}) {
    const { limit = 20, beforeTimestamp } = options

    return new Promise((resolve, reject) => {
      const transaction = this.db.transaction(['messages'], 'readonly')
      const store = transaction.objectStore('messages')
      const index = store.index('conversationId')

      const messages = []
      const range = IDBKeyRange.only(conversationId)
      const request = index.openCursor(range, 'prev')  // 倒序

      request.onsuccess = (event) => {
        const cursor = event.target.result

        if (cursor && messages.length < limit) {
          const msg = cursor.value

          // 时间过滤
          if (!beforeTimestamp || msg.timestamp < beforeTimestamp) {
            messages.push(msg)
          }

          cursor.continue()
        } else {
          resolve(messages.reverse())  // 恢复正序
        }
      }

      request.onerror = () => reject(request.error)
    })
  }

  // 生成消息ID
  generateId() {
    return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
  }
}

3. 消息渲染优化

vue
<template>
  <div class="message-list" ref="listRef" @scroll="handleScroll">
    <!-- 虚拟列表 -->
    <div class="list-phantom" :style="{ height: totalHeight + 'px' }"></div>
    <div class="list-content" :style="{ transform: `translateY(${offset}px)` }">
      <div
        v-for="msg in visibleMessages"
        :key="msg.id"
        :class="['message-item', msg.isSelf ? 'self' : 'other']"
        :ref="el => setItemRef(msg.id, el)"
      >
        <!-- 时间分割线 -->
        <div v-if="msg.showTime" class="time-divider">
          {{ formatTime(msg.timestamp) }}
        </div>

        <!-- 消息气泡 -->
        <div class="message-bubble">
          <img v-if="!msg.isSelf" :src="msg.avatar" class="avatar" />

          <div class="content">
            <!-- 不同消息类型 -->
            <TextMessage v-if="msg.type === 'text'" :content="msg.content" />
            <ImageMessage v-else-if="msg.type === 'image'" :content="msg.content" />
            <FileMessage v-else-if="msg.type === 'file'" :content="msg.content" />
            <VoiceMessage v-else-if="msg.type === 'voice'" :content="msg.content" />

            <!-- 消息状态 -->
            <div class="status">
              <span v-if="msg.status === 'sending'" class="sending">发送中...</span>
              <span v-else-if="msg.status === 'failed'" class="failed" @click="resend(msg)">
                发送失败,点击重试
              </span>
              <span v-else-if="msg.status === 'read'" class="read">已读</span>
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, watch, onMounted, nextTick } from 'vue'

const props = defineProps({
  messages: Array,
  estimatedItemHeight: { type: Number, default: 80 }
})

const listRef = ref(null)
const scrollTop = ref(0)
const itemHeights = ref(new Map())  // 存储实际高度

// 计算总高度
const totalHeight = computed(() => {
  let height = 0
  for (const msg of props.messages) {
    height += itemHeights.value.get(msg.id) || props.estimatedItemHeight
  }
  return height
})

// 计算可见消息
const visibleMessages = computed(() => {
  if (!listRef.value) return []

  const viewportHeight = listRef.value.clientHeight
  const buffer = 5  // 缓冲区

  let startIndex = 0
  let endIndex = props.messages.length
  let accHeight = 0

  // 找起始索引
  for (let i = 0; i < props.messages.length; i++) {
    const height = itemHeights.value.get(props.messages[i].id) || props.estimatedItemHeight
    if (accHeight + height > scrollTop.value) {
      startIndex = Math.max(0, i - buffer)
      break
    }
    accHeight += height
  }

  // 找结束索引
  for (let i = startIndex; i < props.messages.length; i++) {
    const height = itemHeights.value.get(props.messages[i].id) || props.estimatedItemHeight
    accHeight += height
    if (accHeight > scrollTop.value + viewportHeight) {
      endIndex = Math.min(props.messages.length, i + buffer)
      break
    }
  }

  return props.messages.slice(startIndex, endIndex)
})

// 计算偏移
const offset = computed(() => {
  let height = 0
  for (const msg of props.messages) {
    if (msg.id === visibleMessages.value[0]?.id) break
    height += itemHeights.value.get(msg.id) || props.estimatedItemHeight
  }
  return height
})

// 设置元素引用,更新高度
function setItemRef(id, el) {
  if (el) {
    const height = el.offsetHeight
    if (itemHeights.value.get(id) !== height) {
      itemHeights.value.set(id, height)
    }
  }
}

// 滚动处理
function handleScroll(e) {
  scrollTop.value = e.target.scrollTop
}

// 滚动到底部
async function scrollToBottom() {
  await nextTick()
  if (listRef.value) {
    listRef.value.scrollTop = listRef.value.scrollHeight
  }
}

// 新消息自动滚动
watch(() => props.messages.length, (newLen, oldLen) => {
  if (newLen > oldLen) {
    // 判断是否在底部附近
    const isNearBottom = listRef.value.scrollHeight - listRef.value.scrollTop - listRef.value.clientHeight < 100
    if (isNearBottom) {
      scrollToBottom()
    }
  }
})
</script>

安全性设计

javascript
// 1. 消息内容安全
class MessageSecurity {
  // XSS 防护 - 消息内容转义
  sanitizeContent(content) {
    const map = {
      '&': '&amp;',
      '<': '&lt;',
      '>': '&gt;',
      '"': '&quot;',
      "'": '&#x27;'
    }
    return content.replace(/[&<>"']/g, char => map[char])
  }

  // 敏感词过滤
  filterSensitiveWords(content, sensitiveList) {
    let result = content
    sensitiveList.forEach(word => {
      const regex = new RegExp(word, 'gi')
      result = result.replace(regex, '*'.repeat(word.length))
    })
    return result
  }

  // 消息加密 (端到端加密)
  async encryptMessage(content, publicKey) {
    const encoder = new TextEncoder()
    const data = encoder.encode(content)

    const key = await crypto.subtle.importKey(
      'spki',
      publicKey,
      { name: 'RSA-OAEP', hash: 'SHA-256' },
      false,
      ['encrypt']
    )

    return await crypto.subtle.encrypt(
      { name: 'RSA-OAEP' },
      key,
      data
    )
  }
}

// 2. 防刷屏和频率限制
class RateLimiter {
  constructor(options = {}) {
    this.maxMessages = options.maxMessages || 10
    this.timeWindow = options.timeWindow || 10000  // 10秒
    this.messageTimestamps = []
  }

  canSend() {
    const now = Date.now()
    // 移除过期记录
    this.messageTimestamps = this.messageTimestamps.filter(
      ts => now - ts < this.timeWindow
    )

    if (this.messageTimestamps.length >= this.maxMessages) {
      return false
    }

    this.messageTimestamps.push(now)
    return true
  }
}

// 3. 身份验证
class AuthManager {
  // Token 刷新
  async refreshToken() {
    const response = await fetch('/api/auth/refresh', {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${this.refreshToken}`
      }
    })
    const { accessToken } = await response.json()
    this.accessToken = accessToken
  }

  // WebSocket 鉴权
  getAuthenticatedUrl() {
    return `wss://im.example.com/ws?token=${this.accessToken}`
  }
}

扩展性设计

javascript
/**
 * 扩展性考虑:
 *
 * 1. 水平扩展
 *    - WebSocket 网关无状态,可水平扩展
 *    - 用户连接映射存储在 Redis 集群
 *    - 消息通过 Kafka 异步处理
 *
 * 2. 数据库扩展
 *    - 消息表按时间分表 (messages_202401, messages_202402...)
 *    - 读写分离 (主库写,从库读历史消息)
 *    - 热数据缓存到 Redis
 *
 * 3. 支持百万级在线
 *    - 单机 WebSocket 连接数约 10万
 *    - 10台网关服务器 = 100万在线
 *    - 使用一致性哈希分配用户到网关
 */

// 消息分表策略
class MessageSharding {
  getTableName(timestamp) {
    const date = new Date(timestamp)
    const year = date.getFullYear()
    const month = String(date.getMonth() + 1).padStart(2, '0')
    return `messages_${year}${month}`
  }

  // 查询跨月消息
  async queryMessages(conversationId, startTime, endTime) {
    const tables = this.getTablesInRange(startTime, endTime)
    const results = await Promise.all(
      tables.map(table => this.queryFromTable(table, conversationId, startTime, endTime))
    )
    return results.flat().sort((a, b) => a.timestamp - b.timestamp)
  }
}

// 群聊消息扇出优化
class GroupMessageFanout {
  // 小群:写扩散 (每个成员一份)
  // 大群:读扩散 (只存一份,读取时合并)

  async sendGroupMessage(groupId, message, memberCount) {
    if (memberCount < 500) {
      // 写扩散:给每个在线成员推送
      return this.writeFanout(groupId, message)
    } else {
      // 读扩散:存储一份,用户拉取
      return this.readFanout(groupId, message)
    }
  }
}

性能优化要点

优化点方案
消息列表渲染虚拟滚动,只渲染可视区域
图片消息缩略图 + 懒加载 + CDN
离线消息IndexedDB 本地存储
消息去重客户端 ID + 服务端 ID 双重校验
心跳优化30秒间隔,断线指数退避重连
批量操作已读状态批量上报

二、在线文档协同编辑

需求分析

javascript
/**
 * 在线文档核心需求:
 * 1. 多人实时协作
 * 2. 光标位置同步
 * 3. 冲突解决
 * 4. 版本历史
 * 5. 评论批注
 * 6. 权限控制
 */

系统架构图

┌─────────────────────────────────────────────────────────────────┐
│                         协同编辑客户端                           │
├─────────────────────────────────────────────────────────────────┤
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────────────┐  │
│  │   编辑器     │  │  协同管理    │  │   状态同步           │  │
│  │  - 富文本   │  │  - OT/CRDT  │  │   - 光标位置         │  │
│  │  - 选区     │  │  - 冲突解决  │  │   - 在线用户         │  │
│  │  - 格式化   │  │  - 版本控制  │  │   - 操作感知         │  │
│  └──────────────┘  └──────────────┘  └──────────────────────┘  │
└─────────────────────────────────────────────────────────────────┘

                    WebSocket (双向实时通信)


┌─────────────────────────────────────────────────────────────────┐
│                        协同服务器                                │
│  ┌──────────────────────────────────────────────────────────┐  │
│  │                    协同引擎                               │  │
│  │  ┌──────────┐  ┌──────────┐  ┌──────────┐               │  │
│  │  │ OT 转换  │  │ 版本管理 │  │ 冲突检测 │               │  │
│  │  └──────────┘  └──────────┘  └──────────┘               │  │
│  └──────────────────────────────────────────────────────────┘  │
│                                                                 │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐         │
│  │  会话管理   │  │  权限控制   │  │  评论服务   │         │
│  └──────────────┘  └──────────────┘  └──────────────┘         │
└─────────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────────┐
│                          数据存储                                │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐         │
│  │   MongoDB    │  │    Redis     │  │     OSS      │         │
│  │  - 文档内容 │  │  - 在线状态 │  │  - 历史快照 │         │
│  │  - 操作日志 │  │  - 操作缓存 │  │  - 附件文件 │         │
│  │  - 版本历史 │  │  - 锁机制   │  │              │         │
│  └──────────────┘  └──────────────┘  └──────────────┘         │
└─────────────────────────────────────────────────────────────────┘

OT vs CRDT 技术选型

javascript
/**
 * OT (Operational Transformation) vs CRDT (Conflict-free Replicated Data Type)
 *
 * ┌─────────────────┬────────────────────────┬────────────────────────┐
 * │     特性        │         OT             │         CRDT           │
 * ├─────────────────┼────────────────────────┼────────────────────────┤
 * │ 中心化要求      │ 需要中心服务器协调      │ 去中心化,点对点       │
 * │ 操作复杂度      │ 转换函数复杂            │ 数据结构复杂           │
 * │ 网络延迟       │ 对延迟敏感              │ 延迟容忍度高           │
 * │ 一致性保证     │ 依赖服务器顺序          │ 数学证明最终一致       │
 * │ 实现难度       │ 转换函数易出错          │ 数据结构设计复杂       │
 * │ 存储开销       │ 只存储操作              │ 需要存储元数据         │
 * │ 典型应用       │ Google Docs            │ Figma, Notion          │
 * │ 适用场景       │ 强一致性要求            │ 离线编辑、P2P 协作     │
 * └─────────────────┴────────────────────────┴────────────────────────┘
 *
 * 选择建议:
 * - 10人以下协作 + 强一致性要求 → OT
 * - 需要离线编辑 + 最终一致性可接受 → CRDT
 * - 复杂富文本编辑器 → OT (更成熟)
 * - 实时白板/画布 → CRDT (位置无关)
 */

// 选型决策函数
function chooseAlgorithm(requirements) {
  const {
    maxConcurrentUsers,  // 最大并发用户数
    needOfflineSupport,  // 是否需要离线支持
    contentType,         // 内容类型: 'text' | 'richtext' | 'canvas'
    consistencyLevel     // 一致性要求: 'strong' | 'eventual'
  } = requirements

  if (needOfflineSupport || contentType === 'canvas') {
    return 'CRDT'
  }

  if (consistencyLevel === 'strong' && maxConcurrentUsers < 50) {
    return 'OT'
  }

  if (contentType === 'richtext') {
    return 'OT'  // 富文本 OT 更成熟
  }

  return 'CRDT'  // 默认选择 CRDT
}

OT 算法 (Operational Transformation)

javascript
/**
 * OT 算法核心思想:
 * 1. 将所有编辑操作转换为三种原子操作: insert, delete, retain
 * 2. 当两个用户同时编辑时,通过转换函数解决冲突
 * 3. 保证最终一致性
 */

class OTDocument {
  constructor(initialContent = '') {
    this.content = initialContent
    this.version = 0
    this.pendingOperations = []
    this.acknowledgedOperations = []
  }

  // 操作定义
  // { type: 'insert', position: 5, text: 'hello' }
  // { type: 'delete', position: 5, length: 3 }
  // { type: 'retain', position: 5 }

  // 应用操作
  applyOperation(op) {
    switch (op.type) {
      case 'insert':
        this.content =
          this.content.slice(0, op.position) +
          op.text +
          this.content.slice(op.position)
        break
      case 'delete':
        this.content =
          this.content.slice(0, op.position) +
          this.content.slice(op.position + op.length)
        break
    }
    this.version++
  }

  // 操作转换 (Transform)
  // 当 op1 和 op2 并发时,转换 op2 使其在 op1 之后应用能得到正确结果
  transform(op1, op2) {
    // insert vs insert
    if (op1.type === 'insert' && op2.type === 'insert') {
      if (op1.position <= op2.position) {
        // op1 在前,op2 位置后移
        return {
          ...op2,
          position: op2.position + op1.text.length
        }
      } else {
        // op2 在前,不变
        return op2
      }
    }

    // insert vs delete
    if (op1.type === 'insert' && op2.type === 'delete') {
      if (op1.position <= op2.position) {
        return {
          ...op2,
          position: op2.position + op1.text.length
        }
      } else if (op1.position >= op2.position + op2.length) {
        return op2
      } else {
        // 插入点在删除范围内
        return {
          ...op2,
          length: op2.length + op1.text.length
        }
      }
    }

    // delete vs insert
    if (op1.type === 'delete' && op2.type === 'insert') {
      if (op1.position >= op2.position) {
        return op2
      } else if (op1.position + op1.length <= op2.position) {
        return {
          ...op2,
          position: op2.position - op1.length
        }
      } else {
        return {
          ...op2,
          position: op1.position
        }
      }
    }

    // delete vs delete
    if (op1.type === 'delete' && op2.type === 'delete') {
      if (op1.position >= op2.position + op2.length) {
        return {
          ...op2
        }
      } else if (op1.position + op1.length <= op2.position) {
        return {
          ...op2,
          position: op2.position - op1.length
        }
      } else {
        // 重叠删除
        const start = Math.min(op1.position, op2.position)
        const end1 = op1.position + op1.length
        const end2 = op2.position + op2.length

        if (op1.position <= op2.position && end1 >= end2) {
          // op1 包含 op2
          return { type: 'noop' }
        }

        return {
          type: 'delete',
          position: start,
          length: Math.max(end1, end2) - start - op1.length
        }
      }
    }

    return op2
  }
}

// 客户端协同编辑
class CollaborativeEditor {
  constructor(options) {
    this.docId = options.docId
    this.userId = options.userId
    this.ws = options.ws
    this.document = new OTDocument(options.initialContent)

    this.state = 'synchronized'  // synchronized | waiting | buffering
    this.pendingOperation = null
    this.buffer = null

    this.setupListeners()
  }

  // 本地编辑
  localEdit(operation) {
    // 应用到本地文档
    this.document.applyOperation(operation)

    switch (this.state) {
      case 'synchronized':
        // 直接发送
        this.sendOperation(operation)
        this.state = 'waiting'
        this.pendingOperation = operation
        break

      case 'waiting':
        // 缓冲操作
        if (this.buffer) {
          this.buffer = this.compose(this.buffer, operation)
        } else {
          this.buffer = operation
        }
        this.state = 'buffering'
        break

      case 'buffering':
        // 合并到缓冲区
        this.buffer = this.compose(this.buffer, operation)
        break
    }
  }

  // 收到服务器确认
  serverAck() {
    switch (this.state) {
      case 'waiting':
        this.state = 'synchronized'
        this.pendingOperation = null
        break

      case 'buffering':
        this.sendOperation(this.buffer)
        this.state = 'waiting'
        this.pendingOperation = this.buffer
        this.buffer = null
        break
    }
  }

  // 收到其他用户的操作
  remoteOperation(operation) {
    switch (this.state) {
      case 'synchronized':
        this.document.applyOperation(operation)
        this.renderContent()
        break

      case 'waiting':
      case 'buffering':
        // 转换操作
        const [transformedRemote, transformedPending] = this.transformPair(
          operation,
          this.pendingOperation
        )

        this.document.applyOperation(transformedRemote)
        this.pendingOperation = transformedPending

        if (this.buffer) {
          const [, transformedBuffer] = this.transformPair(
            transformedRemote,
            this.buffer
          )
          this.buffer = transformedBuffer
        }

        this.renderContent()
        break
    }
  }

  // 双向转换
  transformPair(op1, op2) {
    const transformed1 = this.document.transform(op2, op1)
    const transformed2 = this.document.transform(op1, op2)
    return [transformed1, transformed2]
  }

  // 发送操作
  sendOperation(operation) {
    this.ws.send({
      type: 'operation',
      docId: this.docId,
      userId: this.userId,
      version: this.document.version,
      operation
    })
  }

  // 渲染内容
  renderContent() {
    // 触发编辑器更新
    this.emit('contentChange', this.document.content)
  }

  // 合并操作
  compose(op1, op2) {
    // 简化实现: 返回操作序列
    return { type: 'compound', operations: [op1, op2] }
  }
}

CRDT 算法 (Conflict-free Replicated Data Type)

javascript
/**
 * CRDT 与 OT 的区别:
 * - OT: 需要中心服务器协调,操作需要转换
 * - CRDT: 无需中心协调,操作可交换,最终一致
 *
 * CRDT 文本编辑常用方案: YATA、RGA、Logoot
 */

// 简化的 CRDT 实现 (基于唯一位置标识)
class CRDTDocument {
  constructor(siteId) {
    this.siteId = siteId
    this.clock = 0
    this.characters = []  // [{ id, char, visible }]
  }

  // 生成唯一ID
  generateId(index) {
    this.clock++
    const leftId = this.characters[index - 1]?.id || { site: 0, clock: 0 }
    const rightId = this.characters[index]?.id || { site: Infinity, clock: 0 }

    return {
      site: this.siteId,
      clock: this.clock,
      // 位置在 left 和 right 之间
      position: (leftId.position || 0) + (rightId.position || 1) / 2
    }
  }

  // 插入字符
  insert(index, char) {
    const id = this.generateId(index)
    const charObj = { id, char, visible: true }

    // 按位置插入
    this.insertAtPosition(charObj)

    return { type: 'insert', id, char }
  }

  // 删除字符 (标记删除,不真正移除)
  delete(index) {
    const charObj = this.characters.filter(c => c.visible)[index]
    if (charObj) {
      charObj.visible = false
      return { type: 'delete', id: charObj.id }
    }
    return null
  }

  // 应用远程操作
  applyRemote(operation) {
    if (operation.type === 'insert') {
      const charObj = { id: operation.id, char: operation.char, visible: true }
      this.insertAtPosition(charObj)
    } else if (operation.type === 'delete') {
      const charObj = this.characters.find(c =>
        c.id.site === operation.id.site && c.id.clock === operation.id.clock
      )
      if (charObj) {
        charObj.visible = false
      }
    }
  }

  // 按位置插入
  insertAtPosition(charObj) {
    const index = this.findInsertIndex(charObj.id)
    this.characters.splice(index, 0, charObj)
  }

  // 找插入位置 (二分查找)
  findInsertIndex(id) {
    let left = 0
    let right = this.characters.length

    while (left < right) {
      const mid = Math.floor((left + right) / 2)
      if (this.compareIds(this.characters[mid].id, id) < 0) {
        left = mid + 1
      } else {
        right = mid
      }
    }

    return left
  }

  // 比较ID
  compareIds(id1, id2) {
    if (id1.position !== id2.position) {
      return id1.position - id2.position
    }
    if (id1.site !== id2.site) {
      return id1.site - id2.site
    }
    return id1.clock - id2.clock
  }

  // 获取文档内容
  getContent() {
    return this.characters
      .filter(c => c.visible)
      .map(c => c.char)
      .join('')
  }
}

三、前端权限系统设计

整体架构

javascript
/**
 * 权限系统核心功能:
 * 1. 路由权限 - 控制页面访问
 * 2. 按钮权限 - 控制操作权限
 * 3. 数据权限 - 控制数据范围
 * 4. 菜单权限 - 动态生成菜单
 */

class PermissionSystem {
  constructor() {
    this.roles = []          // 用户角色
    this.permissions = []    // 权限列表
    this.routes = []         // 路由配置
    this.menus = []          // 菜单配置
  }
}

路由权限控制

javascript
// 方式1: 前端定义全部路由,根据权限过滤
const asyncRoutes = [
  {
    path: '/dashboard',
    component: Dashboard,
    meta: { roles: ['admin', 'editor'] }
  },
  {
    path: '/user',
    component: UserManage,
    meta: { roles: ['admin'] }
  },
  {
    path: '/article',
    component: Article,
    meta: { permissions: ['article:read'] }
  }
]

// 过滤有权限的路由
function filterAsyncRoutes(routes, roles, permissions) {
  const res = []

  routes.forEach(route => {
    const tmp = { ...route }

    if (hasPermission(tmp, roles, permissions)) {
      if (tmp.children) {
        tmp.children = filterAsyncRoutes(tmp.children, roles, permissions)
      }
      res.push(tmp)
    }
  })

  return res
}

function hasPermission(route, roles, permissions) {
  if (route.meta?.roles) {
    return roles.some(role => route.meta.roles.includes(role))
  }
  if (route.meta?.permissions) {
    return permissions.some(perm => route.meta.permissions.includes(perm))
  }
  return true
}

// 方式2: 后端返回路由配置,前端动态添加
async function initRoutes() {
  const { routes } = await api.getUserRoutes()

  // 将后端路由配置转换为 Vue Router 格式
  const asyncRoutes = routes.map(route => ({
    path: route.path,
    name: route.name,
    component: loadComponent(route.component),
    meta: route.meta,
    children: route.children?.map(/* 递归处理 */)
  }))

  // 动态添加路由
  asyncRoutes.forEach(route => {
    router.addRoute(route)
  })
}

// 动态加载组件
function loadComponent(componentPath) {
  return () => import(`@/views/${componentPath}.vue`)
}

按钮权限控制

javascript
// 方式1: 自定义指令
// v-permission="['user:add', 'user:edit']"
app.directive('permission', {
  mounted(el, binding) {
    const { value } = binding
    const permissions = store.getters.permissions

    if (value && value instanceof Array) {
      const hasPermission = value.some(perm => permissions.includes(perm))

      if (!hasPermission) {
        el.parentNode?.removeChild(el)
      }
    }
  }
})

// 使用
<button v-permission="['user:add']">新增用户</button>

// 方式2: 组件封装
<template>
  <slot v-if="hasPermission"></slot>
</template>

<script setup>
import { computed } from 'vue'
import { usePermissionStore } from '@/stores/permission'

const props = defineProps({
  permission: {
    type: [String, Array],
    required: true
  }
})

const permissionStore = usePermissionStore()

const hasPermission = computed(() => {
  const permissions = permissionStore.permissions
  const required = Array.isArray(props.permission)
    ? props.permission
    : [props.permission]

  return required.some(perm => permissions.includes(perm))
})
</script>

// 使用
<Permission permission="user:add">
  <button>新增用户</button>
</Permission>

// 方式3: 函数式
const checkPermission = (permission) => {
  const permissions = store.getters.permissions
  return permissions.includes(permission)
}

// 模板中使用
<button v-if="checkPermission('user:add')">新增用户</button>

菜单权限

javascript
// Pinia Store
export const usePermissionStore = defineStore('permission', {
  state: () => ({
    routes: [],
    menus: [],
    permissions: []
  }),

  actions: {
    async generateRoutes() {
      // 获取用户信息和权限
      const { roles, permissions } = await api.getUserInfo()
      this.permissions = permissions

      // 过滤路由
      const accessedRoutes = filterAsyncRoutes(asyncRoutes, roles, permissions)
      this.routes = accessedRoutes

      // 生成菜单
      this.menus = this.generateMenus(accessedRoutes)

      return accessedRoutes
    },

    generateMenus(routes) {
      return routes
        .filter(route => !route.hidden)
        .map(route => ({
          path: route.path,
          title: route.meta?.title,
          icon: route.meta?.icon,
          children: route.children ? this.generateMenus(route.children) : []
        }))
    }
  }
})

// 路由守卫
router.beforeEach(async (to, from, next) => {
  const token = getToken()

  if (token) {
    if (to.path === '/login') {
      next({ path: '/' })
    } else {
      const permissionStore = usePermissionStore()

      if (permissionStore.routes.length === 0) {
        try {
          // 获取权限并生成路由
          const accessRoutes = await permissionStore.generateRoutes()

          // 动态添加路由
          accessRoutes.forEach(route => {
            router.addRoute(route)
          })

          // 重新导航
          next({ ...to, replace: true })
        } catch (error) {
          // 获取权限失败,跳转登录
          removeToken()
          next(`/login?redirect=${to.path}`)
        }
      } else {
        next()
      }
    }
  } else {
    // 白名单
    const whiteList = ['/login', '/register', '/404']
    if (whiteList.includes(to.path)) {
      next()
    } else {
      next(`/login?redirect=${to.path}`)
    }
  }
})

数据权限设计

javascript
/**
 * 数据权限:控制用户能看到哪些数据
 *
 * 常见模式:
 * 1. 全部数据 - 管理员
 * 2. 部门数据 - 部门经理
 * 3. 本人数据 - 普通员工
 * 4. 自定义数据范围
 */

// 数据权限配置
const DataScope = {
  ALL: 1,           // 全部数据
  DEPARTMENT: 2,    // 本部门数据
  DEPARTMENT_AND_CHILD: 3,  // 本部门及下级
  SELF: 4,          // 仅本人数据
  CUSTOM: 5         // 自定义
}

// 数据权限 SQL 构建器
class DataScopeBuilder {
  constructor(user) {
    this.user = user
    this.userId = user.id
    this.deptId = user.deptId
    this.dataScope = user.dataScope
  }

  // 构建 WHERE 条件
  buildCondition(tableAlias = '') {
    const prefix = tableAlias ? `${tableAlias}.` : ''

    switch (this.dataScope) {
      case DataScope.ALL:
        return '1=1'  // 无限制

      case DataScope.DEPARTMENT:
        return `${prefix}dept_id = ${this.deptId}`

      case DataScope.DEPARTMENT_AND_CHILD:
        return `${prefix}dept_id IN (
          SELECT id FROM departments
          WHERE id = ${this.deptId}
          OR parent_id = ${this.deptId}
        )`

      case DataScope.SELF:
        return `${prefix}creator_id = ${this.userId}`

      case DataScope.CUSTOM:
        // 从用户配置的部门列表中查询
        const deptIds = this.user.customDeptIds.join(',')
        return `${prefix}dept_id IN (${deptIds})`

      default:
        return `${prefix}creator_id = ${this.userId}`
    }
  }

  // 前端数据过滤
  filterData(dataList) {
    switch (this.dataScope) {
      case DataScope.ALL:
        return dataList

      case DataScope.DEPARTMENT:
        return dataList.filter(item => item.deptId === this.deptId)

      case DataScope.SELF:
        return dataList.filter(item => item.creatorId === this.userId)

      default:
        return dataList.filter(item => item.creatorId === this.userId)
    }
  }
}

// 权限服务
class PermissionService {
  // 检查操作权限
  checkPermission(user, resource, action) {
    // 1. 检查功能权限
    const hasPermission = user.permissions.includes(`${resource}:${action}`)
    if (!hasPermission) return false

    // 2. 检查数据权限
    return this.checkDataScope(user, resource)
  }

  // 检查数据范围
  checkDataScope(user, targetData) {
    const builder = new DataScopeBuilder(user)

    switch (user.dataScope) {
      case DataScope.ALL:
        return true

      case DataScope.DEPARTMENT:
        return targetData.deptId === user.deptId

      case DataScope.SELF:
        return targetData.creatorId === user.id

      default:
        return false
    }
  }
}

权限系统数据库设计

sql
-- 用户表
CREATE TABLE sys_user (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    username VARCHAR(50) UNIQUE NOT NULL,
    password VARCHAR(255) NOT NULL,
    dept_id BIGINT,
    status TINYINT DEFAULT 1,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- 角色表
CREATE TABLE sys_role (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(50) NOT NULL,
    code VARCHAR(50) UNIQUE NOT NULL,
    data_scope TINYINT DEFAULT 4,  -- 数据权限范围
    status TINYINT DEFAULT 1
);

-- 权限表
CREATE TABLE sys_permission (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(50) NOT NULL,
    code VARCHAR(100) UNIQUE NOT NULL,  -- 如 user:add, user:edit
    type TINYINT NOT NULL,  -- 1:菜单 2:按钮 3:API
    parent_id BIGINT DEFAULT 0
);

-- 用户角色关联表
CREATE TABLE sys_user_role (
    user_id BIGINT NOT NULL,
    role_id BIGINT NOT NULL,
    PRIMARY KEY (user_id, role_id)
);

-- 角色权限关联表
CREATE TABLE sys_role_permission (
    role_id BIGINT NOT NULL,
    permission_id BIGINT NOT NULL,
    PRIMARY KEY (role_id, permission_id)
);

-- 部门表
CREATE TABLE sys_department (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(100) NOT NULL,
    parent_id BIGINT DEFAULT 0,
    sort INT DEFAULT 0
);

-- 角色部门关联表 (自定义数据权限)
CREATE TABLE sys_role_dept (
    role_id BIGINT NOT NULL,
    dept_id BIGINT NOT NULL,
    PRIMARY KEY (role_id, dept_id)
);

安全性考虑

javascript
// 1. 前端权限校验只是体验优化,真正的安全在后端
// 2. 所有敏感操作必须后端二次校验
// 3. Token 安全存储和刷新机制

class SecurityGuard {
  // 防止越权访问
  async checkAuthorization(userId, resourceId, action) {
    // 1. 验证 Token
    const user = await this.verifyToken()

    // 2. 检查功能权限
    const hasPermission = await this.checkFunctionPermission(user, action)
    if (!hasPermission) {
      throw new ForbiddenError('无操作权限')
    }

    // 3. 检查数据权限
    const resource = await this.getResource(resourceId)
    const hasDataAccess = await this.checkDataPermission(user, resource)
    if (!hasDataAccess) {
      throw new ForbiddenError('无数据访问权限')
    }

    return true
  }

  // 敏感操作二次验证
  async sensitiveOperation(userId, operation) {
    // 要求重新输入密码或短信验证
    const verified = await this.requireSecondaryAuth(userId)
    if (!verified) {
      throw new UnauthorizedError('需要二次验证')
    }
  }

  // 操作审计日志
  async logOperation(user, action, resource, result) {
    await AuditLog.create({
      userId: user.id,
      action,
      resourceType: resource.type,
      resourceId: resource.id,
      result,
      ip: this.getClientIP(),
      userAgent: this.getUserAgent(),
      timestamp: new Date()
    })
  }
}

四、大文件上传系统

完整实现

javascript
class FileUploader {
  constructor(options = {}) {
    this.chunkSize = options.chunkSize || 5 * 1024 * 1024  // 5MB
    this.concurrency = options.concurrency || 3
    this.retryCount = options.retryCount || 3
    this.uploadUrl = options.uploadUrl
    this.mergeUrl = options.mergeUrl
  }

  // 计算文件 hash (使用 Web Worker)
  async calculateHash(file) {
    return new Promise((resolve) => {
      const worker = new Worker('/hash-worker.js')

      worker.postMessage({ file })

      worker.onmessage = (e) => {
        const { hash, progress } = e.data

        if (progress !== undefined) {
          this.onHashProgress?.(progress)
        }

        if (hash) {
          resolve(hash)
          worker.terminate()
        }
      }
    })
  }

  // 文件切片
  createChunks(file, hash) {
    const chunks = []
    let cur = 0
    let index = 0

    while (cur < file.size) {
      chunks.push({
        index,
        hash: `${hash}-${index}`,
        chunk: file.slice(cur, cur + this.chunkSize),
        progress: 0
      })
      cur += this.chunkSize
      index++
    }

    return chunks
  }

  // 检查已上传的分片
  async checkUploadedChunks(hash, filename) {
    const response = await fetch('/api/check', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ hash, filename })
    })

    const { uploaded, uploadedChunks } = await response.json()
    return { uploaded, uploadedChunks }
  }

  // 上传单个分片
  async uploadChunk(chunk, hash, filename) {
    const formData = new FormData()
    formData.append('chunk', chunk.chunk)
    formData.append('hash', chunk.hash)
    formData.append('fileHash', hash)
    formData.append('filename', filename)
    formData.append('index', chunk.index)

    let retries = 0

    while (retries < this.retryCount) {
      try {
        const response = await fetch(this.uploadUrl, {
          method: 'POST',
          body: formData
        })

        if (response.ok) {
          return true
        }
        throw new Error('Upload failed')
      } catch (error) {
        retries++
        if (retries >= this.retryCount) {
          throw error
        }
        // 等待后重试
        await this.sleep(1000 * retries)
      }
    }
  }

  // 并发上传控制
  async uploadChunksWithConcurrency(chunks, hash, filename, uploadedChunks) {
    // 过滤已上传的分片
    const pendingChunks = chunks.filter(chunk =>
      !uploadedChunks.includes(chunk.hash)
    )

    if (pendingChunks.length === 0) {
      return
    }

    // 并发上传
    const pool = []
    let completed = chunks.length - pendingChunks.length

    for (const chunk of pendingChunks) {
      const task = this.uploadChunk(chunk, hash, filename)
        .then(() => {
          completed++
          this.onProgress?.((completed / chunks.length) * 100)

          // 移出任务池
          const index = pool.indexOf(task)
          if (index > -1) pool.splice(index, 1)
        })
        .catch(error => {
          this.onError?.(error, chunk)
          throw error
        })

      pool.push(task)

      // 控制并发数
      if (pool.length >= this.concurrency) {
        await Promise.race(pool)
      }
    }

    // 等待所有任务完成
    await Promise.all(pool)
  }

  // 合并分片
  async mergeChunks(hash, filename, totalChunks) {
    const response = await fetch(this.mergeUrl, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        hash,
        filename,
        totalChunks,
        chunkSize: this.chunkSize
      })
    })

    return response.json()
  }

  // 主上传方法
  async upload(file) {
    // 1. 计算文件 hash
    this.onStatus?.('计算文件hash中...')
    const hash = await this.calculateHash(file)

    // 2. 检查秒传
    this.onStatus?.('检查文件状态...')
    const { uploaded, uploadedChunks } = await this.checkUploadedChunks(hash, file.name)

    if (uploaded) {
      this.onStatus?.('文件已存在,秒传成功')
      this.onProgress?.(100)
      return { success: true, hash }
    }

    // 3. 文件切片
    this.onStatus?.('准备上传...')
    const chunks = this.createChunks(file, hash)

    // 4. 并发上传
    this.onStatus?.('上传中...')
    await this.uploadChunksWithConcurrency(chunks, hash, file.name, uploadedChunks)

    // 5. 合并分片
    this.onStatus?.('合并文件中...')
    const result = await this.mergeChunks(hash, file.name, chunks.length)

    this.onStatus?.('上传完成')
    return result
  }

  // 暂停上传
  pause() {
    this.isPaused = true
  }

  // 恢复上传
  resume() {
    this.isPaused = false
  }

  sleep(ms) {
    return new Promise(resolve => setTimeout(resolve, ms))
  }
}

// hash-worker.js
self.importScripts('https://cdn.bootcss.com/spark-md5/3.0.0/spark-md5.min.js')

self.onmessage = async (e) => {
  const { file } = e.data
  const chunkSize = 5 * 1024 * 1024
  const chunks = Math.ceil(file.size / chunkSize)
  const spark = new self.SparkMD5.ArrayBuffer()
  const reader = new FileReader()
  let currentChunk = 0

  const loadNext = () => {
    const start = currentChunk * chunkSize
    const end = Math.min(start + chunkSize, file.size)
    reader.readAsArrayBuffer(file.slice(start, end))
  }

  reader.onload = (e) => {
    spark.append(e.target.result)
    currentChunk++

    // 上报进度
    self.postMessage({ progress: (currentChunk / chunks) * 100 })

    if (currentChunk < chunks) {
      loadNext()
    } else {
      // 计算完成
      self.postMessage({ hash: spark.end() })
    }
  }

  loadNext()
}

// 使用示例
const uploader = new FileUploader({
  uploadUrl: '/api/upload',
  mergeUrl: '/api/merge',
  chunkSize: 5 * 1024 * 1024,
  concurrency: 3
})

uploader.onProgress = (progress) => {
  console.log('上传进度:', progress + '%')
}

uploader.onStatus = (status) => {
  console.log('状态:', status)
}

uploader.onError = (error, chunk) => {
  console.error('分片上传失败:', chunk.index, error)
}

// 上传
const fileInput = document.querySelector('#file')
fileInput.onchange = async (e) => {
  const file = e.target.files[0]
  const result = await uploader.upload(file)
  console.log('上传结果:', result)
}

五、前端埋点系统设计

完整实现

javascript
class TrackingSystem {
  constructor(options = {}) {
    this.appId = options.appId
    this.userId = options.userId
    this.reportUrl = options.reportUrl
    this.batchSize = options.batchSize || 10
    this.flushInterval = options.flushInterval || 5000

    this.queue = []
    this.sessionId = this.generateSessionId()

    this.init()
  }

  init() {
    // 自动采集
    this.autoTrackPageView()
    this.autoTrackClick()
    this.autoTrackExposure()
    this.autoTrackError()
    this.autoTrackPerformance()

    // 定时上报
    this.startFlushTimer()

    // 页面卸载时上报
    this.trackBeforeUnload()
  }

  // 手动埋点
  track(eventName, properties = {}) {
    this.addToQueue({
      type: 'track',
      event: eventName,
      properties: {
        ...this.getCommonProperties(),
        ...properties
      }
    })
  }

  // 页面浏览埋点
  trackPageView(pageName, properties = {}) {
    this.addToQueue({
      type: 'pageview',
      page: pageName || location.pathname,
      referrer: document.referrer,
      properties: {
        ...this.getCommonProperties(),
        ...properties
      }
    })
  }

  // 自动页面浏览
  autoTrackPageView() {
    // 首次加载
    this.trackPageView()

    // SPA 路由变化
    let lastPath = location.pathname

    // 监听 history 变化
    const originalPushState = history.pushState
    const originalReplaceState = history.replaceState

    history.pushState = (...args) => {
      originalPushState.apply(history, args)
      this.onRouteChange(lastPath)
      lastPath = location.pathname
    }

    history.replaceState = (...args) => {
      originalReplaceState.apply(history, args)
      this.onRouteChange(lastPath)
      lastPath = location.pathname
    }

    window.addEventListener('popstate', () => {
      this.onRouteChange(lastPath)
      lastPath = location.pathname
    })
  }

  onRouteChange(previousPath) {
    this.trackPageView(location.pathname, {
      previousPath
    })
  }

  // 自动点击埋点
  autoTrackClick() {
    document.addEventListener('click', (event) => {
      const target = event.target

      // 只采集带有 data-track 属性的元素
      const trackElement = target.closest('[data-track]')
      if (!trackElement) return

      const trackData = trackElement.dataset

      this.track('click', {
        elementId: trackElement.id,
        elementClass: trackElement.className,
        elementText: trackElement.textContent?.slice(0, 50),
        trackName: trackData.track,
        trackExtra: this.parseJSON(trackData.trackExtra),
        position: {
          x: event.pageX,
          y: event.pageY
        }
      })
    }, true)
  }

  // 曝光埋点
  autoTrackExposure() {
    const exposedElements = new WeakSet()

    const observer = new IntersectionObserver((entries) => {
      entries.forEach(entry => {
        if (entry.isIntersecting && !exposedElements.has(entry.target)) {
          exposedElements.add(entry.target)

          const trackData = entry.target.dataset

          this.track('exposure', {
            elementId: entry.target.id,
            trackName: trackData.trackExposure,
            trackExtra: this.parseJSON(trackData.trackExtra),
            visibleRatio: entry.intersectionRatio
          })
        }
      })
    }, {
      threshold: 0.5  // 50% 可见时触发
    })

    // 监听带有 data-track-exposure 属性的元素
    const observeElements = () => {
      document.querySelectorAll('[data-track-exposure]').forEach(el => {
        observer.observe(el)
      })
    }

    // 初始监听
    observeElements()

    // DOM 变化时重新监听
    const mutationObserver = new MutationObserver(observeElements)
    mutationObserver.observe(document.body, {
      childList: true,
      subtree: true
    })
  }

  // 错误埋点
  autoTrackError() {
    // JS 错误
    window.addEventListener('error', (event) => {
      this.track('error', {
        errorType: 'js',
        message: event.message,
        filename: event.filename,
        lineno: event.lineno,
        colno: event.colno,
        stack: event.error?.stack
      })
    })

    // Promise 错误
    window.addEventListener('unhandledrejection', (event) => {
      this.track('error', {
        errorType: 'promise',
        reason: String(event.reason),
        stack: event.reason?.stack
      })
    })

    // 资源加载错误
    window.addEventListener('error', (event) => {
      if (event.target && (event.target.src || event.target.href)) {
        this.track('error', {
          errorType: 'resource',
          resourceUrl: event.target.src || event.target.href,
          resourceTag: event.target.tagName
        })
      }
    }, true)
  }

  // 性能埋点
  autoTrackPerformance() {
    window.addEventListener('load', () => {
      setTimeout(() => {
        const timing = performance.timing

        this.track('performance', {
          // DNS 解析时间
          dnsTime: timing.domainLookupEnd - timing.domainLookupStart,
          // TCP 连接时间
          tcpTime: timing.connectEnd - timing.connectStart,
          // 首字节时间
          ttfb: timing.responseStart - timing.navigationStart,
          // DOM 解析时间
          domParseTime: timing.domInteractive - timing.responseEnd,
          // DOM 加载完成
          domContentLoaded: timing.domContentLoadedEventEnd - timing.navigationStart,
          // 页面完全加载
          loadTime: timing.loadEventEnd - timing.navigationStart
        })

        // Core Web Vitals
        this.trackWebVitals()
      }, 0)
    })
  }

  trackWebVitals() {
    // LCP
    new PerformanceObserver((list) => {
      const entries = list.getEntries()
      const lastEntry = entries[entries.length - 1]

      this.track('webvital', {
        name: 'LCP',
        value: lastEntry.renderTime || lastEntry.loadTime
      })
    }).observe({ entryTypes: ['largest-contentful-paint'] })

    // FID
    new PerformanceObserver((list) => {
      const entry = list.getEntries()[0]

      this.track('webvital', {
        name: 'FID',
        value: entry.processingStart - entry.startTime
      })
    }).observe({ entryTypes: ['first-input'] })

    // CLS
    let clsValue = 0
    new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        if (!entry.hadRecentInput) {
          clsValue += entry.value
        }
      }

      this.track('webvital', {
        name: 'CLS',
        value: clsValue
      })
    }).observe({ entryTypes: ['layout-shift'] })
  }

  // 获取通用属性
  getCommonProperties() {
    return {
      timestamp: Date.now(),
      sessionId: this.sessionId,
      userId: this.userId,
      appId: this.appId,
      url: location.href,
      path: location.pathname,
      userAgent: navigator.userAgent,
      screenWidth: screen.width,
      screenHeight: screen.height,
      viewportWidth: window.innerWidth,
      viewportHeight: window.innerHeight,
      language: navigator.language
    }
  }

  // 添加到队列
  addToQueue(data) {
    this.queue.push(data)

    // 达到批量大小立即上报
    if (this.queue.length >= this.batchSize) {
      this.flush()
    }
  }

  // 上报数据
  flush() {
    if (this.queue.length === 0) return

    const data = this.queue.splice(0, this.batchSize)
    this.send(data)
  }

  // 发送数据
  send(data) {
    // 优先使用 sendBeacon
    if (navigator.sendBeacon) {
      navigator.sendBeacon(
        this.reportUrl,
        JSON.stringify(data)
      )
    } else {
      // 降级使用 fetch
      fetch(this.reportUrl, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(data),
        keepalive: true
      }).catch(console.error)
    }
  }

  // 定时上报
  startFlushTimer() {
    setInterval(() => {
      this.flush()
    }, this.flushInterval)
  }

  // 页面卸载前上报
  trackBeforeUnload() {
    window.addEventListener('beforeunload', () => {
      this.flush()
    })

    window.addEventListener('pagehide', () => {
      this.flush()
    })
  }

  // 生成会话ID
  generateSessionId() {
    return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
  }

  // 解析 JSON
  parseJSON(str) {
    try {
      return JSON.parse(str || '{}')
    } catch {
      return {}
    }
  }
}

// 使用
const tracker = new TrackingSystem({
  appId: 'my-app',
  userId: 'user-123',
  reportUrl: '/api/track'
})

// 手动埋点
tracker.track('button_click', {
  buttonName: 'submit',
  formId: 'login-form'
})

// 声明式埋点
// <button data-track="submit_btn" data-track-extra='{"formId":"login"}'>提交</button>
// <div data-track-exposure="banner_1" data-track-extra='{"position":1}'>...</div>

常见面试题

1. 设计一个前端监控系统,你会考虑哪些方面?

点击查看答案

监控维度:

  1. 性能监控

    • 页面加载性能 (FCP, LCP, FID, CLS)
    • 资源加载性能
    • API 响应时间
    • 长任务监控
  2. 错误监控

    • JS 运行时错误
    • Promise 异常
    • 资源加载错误
    • API 请求错误
    • 框架错误 (Vue/React)
  3. 行为监控

    • 页面访问 (PV/UV)
    • 用户点击
    • 路由变化
    • 页面停留时长
  4. 技术方案

javascript
// 数据采集
- Performance API
- Error 事件监听
- IntersectionObserver
- MutationObserver

// 数据上报
- navigator.sendBeacon (推荐)
- fetch keepalive
- 图片打点 (降级)

// 数据处理
- 批量上报
- 数据压缩
- 采样率控制
- 失败重试
  1. 面试加分点
  • 有 SourceMap 还原错误堆栈
  • 用户行为回放
  • 性能基线告警
  • 自定义指标扩展

2. 如何设计一个支持撤销/重做的编辑器?

点击查看答案

命令模式 + 操作栈:

javascript
class Editor {
  constructor() {
    this.content = ''
    this.undoStack = []
    this.redoStack = []
  }

  // 执行命令
  execute(command) {
    command.execute()
    this.undoStack.push(command)
    this.redoStack = []  // 清空重做栈
  }

  // 撤销
  undo() {
    if (this.undoStack.length === 0) return

    const command = this.undoStack.pop()
    command.undo()
    this.redoStack.push(command)
  }

  // 重做
  redo() {
    if (this.redoStack.length === 0) return

    const command = this.redoStack.pop()
    command.execute()
    this.undoStack.push(command)
  }
}

// 命令基类
class Command {
  constructor(editor) {
    this.editor = editor
  }
  execute() {}
  undo() {}
}

// 插入命令
class InsertCommand extends Command {
  constructor(editor, position, text) {
    super(editor)
    this.position = position
    this.text = text
  }

  execute() {
    const { content } = this.editor
    this.editor.content =
      content.slice(0, this.position) +
      this.text +
      content.slice(this.position)
  }

  undo() {
    const { content } = this.editor
    this.editor.content =
      content.slice(0, this.position) +
      content.slice(this.position + this.text.length)
  }
}

// 删除命令
class DeleteCommand extends Command {
  constructor(editor, position, length) {
    super(editor)
    this.position = position
    this.length = length
    this.deletedText = ''
  }

  execute() {
    const { content } = this.editor
    this.deletedText = content.slice(this.position, this.position + this.length)
    this.editor.content =
      content.slice(0, this.position) +
      content.slice(this.position + this.length)
  }

  undo() {
    const { content } = this.editor
    this.editor.content =
      content.slice(0, this.position) +
      this.deletedText +
      content.slice(this.position)
  }
}

3. WebSocket 断线重连如何实现?

点击查看答案

核心要点:

  1. 心跳检测: 定时发送 ping,检测连接状态
  2. 断线检测: 监听 close/error 事件
  3. 重连策略: 指数退避算法
  4. 消息缓存: 断线期间消息入队,重连后发送
  5. 状态同步: 重连后同步数据
javascript
class ReconnectingWebSocket {
  constructor(url, options = {}) {
    this.url = url
    this.reconnectInterval = options.reconnectInterval || 1000
    this.maxReconnectInterval = options.maxReconnectInterval || 30000
    this.reconnectDecay = options.reconnectDecay || 1.5
    this.maxReconnectAttempts = options.maxReconnectAttempts || Infinity

    this.reconnectAttempts = 0
    this.messageQueue = []
    this.ws = null

    this.connect()
  }

  connect() {
    this.ws = new WebSocket(this.url)

    this.ws.onopen = () => {
      this.reconnectAttempts = 0
      this.flushQueue()
      this.startHeartbeat()
    }

    this.ws.onclose = () => {
      this.stopHeartbeat()
      this.reconnect()
    }

    this.ws.onerror = () => {
      this.ws.close()
    }
  }

  reconnect() {
    if (this.reconnectAttempts >= this.maxReconnectAttempts) return

    const timeout = Math.min(
      this.reconnectInterval * Math.pow(this.reconnectDecay, this.reconnectAttempts),
      this.maxReconnectInterval
    )

    this.reconnectAttempts++

    setTimeout(() => {
      this.connect()
    }, timeout)
  }

  send(data) {
    if (this.ws.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify(data))
    } else {
      this.messageQueue.push(data)
    }
  }

  flushQueue() {
    while (this.messageQueue.length > 0) {
      this.send(this.messageQueue.shift())
    }
  }
}

总结

答题技巧

  1. 需求分析: 先理清核心功能和边界
  2. 方案设计: 画出系统架构图
  3. 技术选型: 说明为什么选择这个方案
  4. 核心实现: 写出关键代码
  5. 优化思路: 考虑性能、安全、扩展性
  6. 实际经验: 结合项目经验说明踩过的坑

加分项

  • 有完整的系统设计经验
  • 能从用户角度考虑体验
  • 了解前沿技术方案
  • 能权衡不同方案的优缺点
  • 考虑异常情况和边界条件