阅读视图

发现新文章,点击刷新页面。

从零实现 MCP 客户端:blade-code 的 MCP 集成实战

本文是 blade-code 技术深度系列的第 1 篇,深入剖析如何从零实现一个生产级的 MCP(Model Context Protocol)客户端,包括连接管理、OAuth 认证、健康监控、工具注册等核心功能。

目录


什么是 MCP?

MCP(Model Context Protocol)是 Anthropic 推出的开放协议,用于 AI 应用与外部工具/数据源的标准化通信。它解决了以下问题:

  • 工具碎片化:每个 AI 应用都要重新实现工具集成
  • 协议不统一:没有标准的工具调用格式
  • 扩展性差:添加新工具需要修改核心代码

MCP 提供了:

  • 📡 标准传输层:stdio、SSE、HTTP
  • 🔧 工具发现机制:动态获取可用工具列表
  • 🔐 认证支持:OAuth 2.0 集成
  • 📊 资源管理:统一的资源读取接口

架构设计

blade-code 的 MCP 集成采用三层架构:

graph TB
    A[Agent Runtime 调用层] --> B[McpRegistry 管理层]
    B --> C[McpClient 通信层]
    
    A1[工具调用<br/>参数验证] -.-> A
    B1[服务器注册/注销<br/>工具冲突处理<br/>状态监控] -.-> B
    C1[连接管理<br/>协议通信<br/>错误重试<br/>OAuth 认证] -.-> C
    
    style A fill:#e1f5ff
    style B fill:#fff4e1
    style C fill:#ffe1f5

设计原则:

  1. 单一职责:每层只负责自己的核心功能
  2. 事件驱动:通过 EventEmitter 解耦组件
  3. 容错优先:网络错误自动重试,永久错误快速失败
  4. 可观测性:完整的状态机和事件日志

核心实现

1. McpClient:连接与通信

McpClient 是 MCP 集成的核心,负责与单个 MCP 服务器的通信。

1.1 传输层抽象

MCP 支持三种传输方式,blade-code 通过工厂模式统一创建:

private async createTransport(): Promise<Transport> {
  const { type, command, args, env, url, headers } = this.config;

  if (type === 'stdio') {
    // 子进程通信(本地工具)
    return new StdioClientTransport({
      command,
      args: args || [],
      env: { ...process.env, ...env },
      stderr: 'ignore', // 忽略子进程的 stderr 输出
    });
  } else if (type === 'sse') {
    // Server-Sent Events(远程服务)
    return new SSEClientTransport(new URL(url), {
      requestInit: { headers },
    });
  } else if (type === 'http') {
    // HTTP 长轮询
    const { StreamableHTTPClientTransport } = await import(
      '@modelcontextprotocol/sdk/client/streamableHttp.js'
    );
    return new StreamableHTTPClientTransport(new URL(url), {
      requestInit: { headers },
    });
  }

  throw new Error(`不支持的传输类型: ${type}`);
}

关键点:

  • stdio 适合本地工具(如文件系统、数据库)
  • sse 适合远程服务(实时推送)
  • http 适合 RESTful API

1.2 连接管理与重试

生产环境中,网络不稳定是常态。blade-code 实现了智能重试机制:

async connectWithRetry(maxRetries = 3, initialDelay = 1000): Promise<void> {
  let lastError: Error | null = null;

  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      await this.doConnect();
      this.reconnectAttempts = 0; // 重置重连计数
      return; // 成功连接
    } catch (error) {
      lastError = error as Error;
      const classified = classifyError(error);

      // 如果是永久性错误,不重试
      if (!classified.isRetryable) {
        console.error('[McpClient] 检测到永久性错误,放弃重试:', classified.type);
        throw error;
      }

      // 指数退避
      if (attempt < maxRetries) {
        const delay = initialDelay * Math.pow(2, attempt - 1);
        console.warn(`[McpClient] 连接失败(${attempt}/${maxRetries}),${delay}ms 后重试...`);
        await new Promise((resolve) => setTimeout(resolve, delay));
      }
    }
  }

  throw lastError || new Error('连接失败');
}

错误分类:

enum ErrorType {
  NETWORK_TEMPORARY = 'network_temporary', // 临时网络错误(可重试)
  NETWORK_PERMANENT = 'network_permanent', // 永久网络错误
  CONFIG_ERROR = 'config_error',           // 配置错误
  AUTH_ERROR = 'auth_error',               // 认证错误
  PROTOCOL_ERROR = 'protocol_error',       // 协议错误
  UNKNOWN = 'unknown',                     // 未知错误
}

function classifyError(error: unknown): ClassifiedError {
  const msg = error.message.toLowerCase();

  // 永久性配置错误(不应重试)
  const permanentErrors = [
    'command not found',
    'no such file',
    'permission denied',
    'invalid configuration',
  ];

  if (permanentErrors.some((permanent) => msg.includes(permanent))) {
    return { type: ErrorType.CONFIG_ERROR, isRetryable: false, originalError: error };
  }

  // 临时网络错误(可重试)
  const temporaryErrors = [
    'timeout',
    'connection refused',
    'econnreset',
    'etimedout',
    '503',
    '429',
  ];

  if (temporaryErrors.some((temporary) => msg.includes(temporary))) {
    return { type: ErrorType.NETWORK_TEMPORARY, isRetryable: true, originalError: error };
  }

  // 默认视为临时错误(保守策略:允许重试)
  return { type: ErrorType.UNKNOWN, isRetryable: true, originalError: error };
}

为什么这样设计?

  • 快速失败:配置错误立即抛出,避免无意义的重试
  • 指数退避:避免雪崩效应,给服务器恢复时间
  • 保守策略:未知错误默认可重试,提高容错性

1.3 意外断连处理

MCP 服务器可能随时断开(进程崩溃、网络中断),blade-code 通过监听 onclose 事件自动重连:

this.sdkClient.onclose = () => {
  this.handleUnexpectedClose();
};

private handleUnexpectedClose(): void {
  if (this.isManualDisconnect) {
    return; // 手动断开,不重连
  }

  if (this.status === McpConnectionStatus.CONNECTED) {
    console.warn('[McpClient] 检测到意外断连,准备重连...');
    this.setStatus(McpConnectionStatus.ERROR);
    this.emit('error', new Error('MCP服务器连接意外关闭'));
    this.scheduleReconnect();
  }
}

private scheduleReconnect(): void {
  if (this.reconnectAttempts >= this.MAX_RECONNECT_ATTEMPTS) {
    console.error('[McpClient] 达到最大重连次数,放弃重连');
    this.emit('reconnectFailed');
    return;
  }

  // 指数退避:1s, 2s, 4s, 8s, 16s(最大30s)
  const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
  this.reconnectAttempts++;

  this.reconnectTimer = setTimeout(async () => {
    try {
      await this.doConnect();
      console.log('[McpClient] 重连成功');
      this.reconnectAttempts = 0;
      this.emit('reconnected');
    } catch (error) {
      const classified = classifyError(error);
      if (classified.isRetryable) {
        this.scheduleReconnect(); // 继续重连
      } else {
        this.emit('reconnectFailed'); // 永久失败
      }
    }
  }, delay);
}

关键点:

  • 区分手动断开和意外断连
  • 最多重连 5 次,避免无限循环
  • 重连成功后重置计数器

2. McpRegistry:服务器管理

McpRegistry 是单例模式的注册中心,管理多个 MCP 服务器。

2.1 服务器注册

async registerServer(name: string, config: McpServerConfig): Promise<void> {
  if (this.servers.has(name)) {
    throw new Error(`MCP服务器 "${name}" 已经注册`);
  }

  const client = new McpClient(config, name, config.healthCheck);
  const serverInfo: McpServerInfo = {
    config,
    client,
    status: McpConnectionStatus.DISCONNECTED,
    tools: [],
  };

  // 设置客户端事件处理器
  this.setupClientEventHandlers(client, serverInfo, name);

  this.servers.set(name, serverInfo);
  this.emit('serverRegistered', name, serverInfo);

  try {
    await this.connectServer(name);
  } catch (error) {
    console.warn(`MCP服务器 "${name}" 连接失败:`, error);
  }
}

2.2 工具冲突处理

多个 MCP 服务器可能提供同名工具,blade-code 通过前缀解决冲突:

async getAvailableTools(): Promise<Tool[]> {
  const tools: Tool[] = [];
  const nameConflicts = new Map<string, number>();

  // 第一遍:检测冲突
  for (const [_serverName, serverInfo] of this.servers) {
    if (serverInfo.status === McpConnectionStatus.CONNECTED) {
      for (const mcpTool of serverInfo.tools) {
        const count = nameConflicts.get(mcpTool.name) || 0;
        nameConflicts.set(mcpTool.name, count + 1);
      }
    }
  }

  // 第二遍:创建工具(冲突时添加前缀)
  for (const [serverName, serverInfo] of this.servers) {
    if (serverInfo.status === McpConnectionStatus.CONNECTED) {
      for (const mcpTool of serverInfo.tools) {
        const hasConflict = (nameConflicts.get(mcpTool.name) || 0) > 1;
        const toolName = hasConflict
          ? `${serverName}__${mcpTool.name}`
          : mcpTool.name;

        const tool = createMcpTool(serverInfo.client, serverName, mcpTool, toolName);
        tools.push(tool);
      }
    }
  }

  return tools;
}

命名策略:

  • 无冲突:toolName
  • 有冲突:serverName__toolName

示例:

服务器 A: read_file
服务器 B: read_file
 最终工具: A__read_file, B__read_file

3. OAuth 认证

MCP 支持 OAuth 2.0 认证,blade-code 实现了完整的 OAuth 流程。

3.1 令牌存储

export class OAuthTokenStorage {
  private readonly tokenFilePath: string;

  constructor() {
    const homeDir = os.homedir();
    const configDir = path.join(homeDir, '.blade');
    this.tokenFilePath = path.join(configDir, 'mcp-oauth-tokens.json');
  }

  async saveToken(
    serverName: string,
    token: OAuthToken,
    clientId?: string,
    tokenUrl?: string
  ): Promise<void> {
    const credentials = await this.loadAllCredentials();

    const credential: OAuthCredentials = {
      serverName,
      token,
      clientId,
      tokenUrl,
      updatedAt: Date.now(),
    };

    credentials.set(serverName, credential);
    await this.saveAllCredentials(credentials);
  }

  isTokenExpired(token: OAuthToken): boolean {
    if (!token.expiresAt) {
      return false; // 没有过期时间,认为不过期
    }

    // 提前 5 分钟视为过期,留出刷新时间
    const buffer = 5 * 60 * 1000;
    return Date.now() >= token.expiresAt - buffer;
  }
}

安全措施:

  • 令牌文件权限设置为 0o600(仅所有者可读写)
  • 提前 5 分钟刷新令牌,避免过期
  • 支持 refresh_token 自动续期

3.2 OAuth 流程

export class OAuthProvider {
  private tokenStorage = new OAuthTokenStorage();

  async getValidToken(
    serverName: string,
    oauthConfig: OAuthConfig
  ): Promise<string | null> {
    const credentials = await this.tokenStorage.getCredentials(serverName);

    if (!credentials) {
      return null; // 没有令牌,需要认证
    }

    // 检查是否过期
    if (this.tokenStorage.isTokenExpired(credentials.token)) {
      // 尝试刷新
      if (credentials.token.refreshToken) {
        try {
          const newToken = await this.refreshToken(credentials, oauthConfig);
          await this.tokenStorage.saveToken(
            serverName,
            newToken,
            credentials.clientId,
            credentials.tokenUrl
          );
          return newToken.accessToken;
        } catch (error) {
          console.error('[OAuthProvider] 刷新令牌失败:', error);
          return null; // 刷新失败,需要重新认证
        }
      }
      return null; // 没有 refresh_token,需要重新认证
    }

    return credentials.token.accessToken;
  }

  async authenticate(
    serverName: string,
    oauthConfig: OAuthConfig
  ): Promise<OAuthToken> {
    // 1. 生成授权 URL
    const authUrl = this.buildAuthUrl(oauthConfig);
    console.log(`请访问以下 URL 进行授权:\n${authUrl}`);

    // 2. 启动本地回调服务器
    const code = await this.startCallbackServer(oauthConfig.redirectUri);

    // 3. 用授权码换取令牌
    const token = await this.exchangeCodeForToken(code, oauthConfig);

    // 4. 保存令牌
    await this.tokenStorage.saveToken(
      serverName,
      token,
      oauthConfig.clientId,
      oauthConfig.tokenUrl
    );

    return token;
  }
}

流程图:

graph TD
    A[用户请求] --> B{检查令牌}
    B -->|有效| C[返回令牌]
    B -->|无效/过期| D{有 refresh_token?}
    D -->|是| E[刷新令牌]
    E --> F[返回新令牌]
    D -->|否| G[启动 OAuth 流程]
    G --> H[返回新令牌]
    
    style C fill:#90EE90
    style F fill:#90EE90
    style H fill:#90EE90

4. 健康监控

生产环境中,MCP 服务器可能"僵死"(连接正常但不响应)。blade-code 实现了主动健康检查:

export class HealthMonitor extends EventEmitter {
  private intervalTimer: NodeJS.Timeout | null = null;
  private consecutiveFailures = 0;

  constructor(
    private client: McpClient,
    private config: HealthCheckConfig
  ) {
    super();
  }

  start(): void {
    if (this.intervalTimer) {
      return; // 已经启动
    }

    this.intervalTimer = setInterval(async () => {
      try {
        await this.performHealthCheck();
        this.consecutiveFailures = 0; // 重置失败计数
      } catch (error) {
        this.consecutiveFailures++;
        console.warn(
          `[HealthMonitor] 健康检查失败 (${this.consecutiveFailures}/${this.config.maxFailures}):`,
          error
        );

        if (this.consecutiveFailures >= this.config.maxFailures) {
          this.emit('unhealthy', this.consecutiveFailures, error);
          await this.attemptReconnect();
        }
      }
    }, this.config.intervalMs);
  }

  private async performHealthCheck(): Promise<void> {
    const timeout = this.config.timeoutMs || 5000;

    await Promise.race([
      this.client.listTools(), // 调用一个轻量级方法
      new Promise((_, reject) =>
        setTimeout(() => reject(new Error('健康检查超时')), timeout)
      ),
    ]);
  }

  private async attemptReconnect(): Promise<void> {
    console.log('[HealthMonitor] 尝试重连...');
    try {
      await this.client.disconnect();
      await this.client.connect();
      this.consecutiveFailures = 0;
      this.emit('reconnected');
    } catch (error) {
      console.error('[HealthMonitor] 重连失败:', error);
    }
  }
}

配置示例:

{
  enabled: true,
  intervalMs: 30000,    // 每 30 秒检查一次
  timeoutMs: 5000,      // 超时时间 5 秒
  maxFailures: 3        // 连续失败 3 次触发重连
}

工具动态注册

MCP 工具需要转换为 blade-code 的 Tool 接口:

export function createMcpTool(
  client: McpClient,
  serverName: string,
  mcpTool: McpToolDefinition,
  toolName?: string
): Tool {
  return {
    name: toolName || mcpTool.name,
    description: mcpTool.description || `MCP工具: ${mcpTool.name}`,
    parameters: mcpTool.inputSchema || { type: 'object', properties: {} },
    metadata: {
      source: 'mcp',
      serverName,
      originalName: mcpTool.name,
    },

    async execute(args: Record<string, unknown>): Promise<ToolResult> {
      try {
        const response = await client.callTool(mcpTool.name, args);

        return {
          success: true,
          output: formatMcpResponse(response),
          metadata: {
            serverName,
            toolName: mcpTool.name,
          },
        };
      } catch (error) {
        return {
          success: false,
          error: error instanceof Error ? error.message : String(error),
          metadata: {
            serverName,
            toolName: mcpTool.name,
          },
        };
      }
    },
  };
}

关键点:

  • 保留原始工具名(originalName)用于调试
  • 统一错误处理格式
  • 支持元数据传递

错误处理与重连

blade-code 的错误处理遵循以下原则:

1. 错误分类

临时错误(可重试):
- 网络超时
- 连接被拒绝
- 速率限制(429)
- 服务不可用(503)

永久错误(不重试):
- 配置错误(命令不存在)
- 认证失败(401)
- 权限不足(403)
- 协议错误(格式错误)

2. 重试策略

指数退避:
- 第 1 次:1 秒后重试
- 第 2 次:2 秒后重试
- 第 3 次:4 秒后重试
- 第 4 次:8 秒后重试
- 第 5 次:16 秒后重试
- 最大延迟:30

3. 状态机

stateDiagram-v2
    [*] --> DISCONNECTED
    DISCONNECTED --> CONNECTING: connect()
    CONNECTING --> CONNECTED: 成功
    CONNECTING --> ERROR: 失败
    ERROR --> CONNECTING: 重试
    CONNECTED --> ERROR: 意外断连
    CONNECTED --> DISCONNECTED: disconnect()

实战案例

案例 1:集成文件系统 MCP 服务器

// 配置文件
{
  "mcpServers": {
    "filesystem": {
      "type": "stdio",
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/workspace"],
      "env": {
        "NODE_ENV": "production"
      }
    }
  }
}

// 使用
const registry = McpRegistry.getInstance();
await registry.registerServer('filesystem', config.mcpServers.filesystem);

const tools = await registry.getAvailableTools();
// 输出: [{ name: 'read_file', ... }, { name: 'write_file', ... }]

案例 2:集成远程 API(带 OAuth)

{
  "mcpServers": {
    "github": {
      "type": "sse",
      "url": "https://api.github.com/mcp",
      "oauth": {
        "enabled": true,
        "authUrl": "https://github.com/login/oauth/authorize",
        "tokenUrl": "https://github.com/login/oauth/access_token",
        "clientId": "your-client-id",
        "clientSecret": "your-client-secret",
        "scopes": ["repo", "user"],
        "redirectUri": "http://localhost:3000/callback"
      }
    }
  }
}

// 首次使用会自动触发 OAuth 流程
await registry.registerServer('github', config.mcpServers.github);
// 输出: 请访问以下 URL 进行授权: https://github.com/login/oauth/authorize?...

案例 3:健康监控与自动恢复

{
  "mcpServers": {
    "database": {
      "type": "stdio",
      "command": "mcp-database-server",
      "healthCheck": {
        "enabled": true,
        "intervalMs": 30000,
        "timeoutMs": 5000,
        "maxFailures": 3
      }
    }
  }
}

// 监听健康事件
registry.on('serverError', (name, error) => {
  console.error(`服务器 ${name} 出错:`, error);
});

registry.on('healthMonitorReconnected', () => {
  console.log('健康监控触发重连成功');
});

总结

blade-code 的 MCP 集成实现了以下核心功能:

已实现

  1. 多传输层支持:stdio、SSE、HTTP
  2. 智能重试:错误分类 + 指数退避
  3. 自动重连:意外断连自动恢复
  4. OAuth 认证:完整的 OAuth 2.0 流程
  5. 健康监控:主动检测僵死连接
  6. 工具冲突处理:自动添加前缀
  7. 事件驱动:完整的状态机和事件系统

设计亮点

  • 容错优先:网络错误不会导致整个系统崩溃
  • 可观测性:丰富的日志和事件,便于调试
  • 扩展性:新增 MCP 服务器只需修改配置文件
  • 安全性:令牌加密存储,权限最小化

相关资源:

讨论:欢迎在 GitHub Issues 或我的博客评论区交流!

从零手写JavaScript继承函数:一场关于"家族传承"的编程之旅

从零手写JavaScript继承函数:一场关于"家族传承"的编程之旅

引言:JavaScript的"与众不同"

在JavaScript的世界里,继承不是简单的复制粘贴,而是一场关于"原型链"的奇妙冒险。想象一下:别的语言继承就像领养孩子,直接给一套新房子和新衣服;而JavaScript的继承更像是家族传承——孩子不仅有自己的家,还能随时去祖辈家里串门拿东西!

今天,就让我们一起揭开JavaScript继承的神秘面纱,亲手打造一个属于自己的"家族传承"系统。

一、原型链继承:直截了当的"家族企业"

让我们先来看看JavaScript中最"朴实"的继承方式。

一个动物王国的故事

假设我们有一个Animal(动物)家族:

function Animal(name, age) {
    this.name = name;   // 名字
    this.age = age;     // 年龄
}
Animal.prototype.species = '动物';  // 所有动物都有的物种属性

现在,Cat(猫)家族想要继承Animal家族的优良传统。最简单的做法是什么?

方法一:直接"认祖归宗"

function Cat(name, age, color) {
    // 先把Animal家族的基本功学过来
    Animal.call(this, name, age);
    this.color = color;  // 猫特有的毛色
}

// 关键一步:成为Animal家族的"亲传弟子"
Cat.prototype = new Animal();
// 但别忘了改个名,不然别人还以为你是Animal
Cat.prototype.constructor = Cat;

const garfield = new Cat('加菲猫', 2, '黄色');
console.log(garfield.species);  // ✅ 输出:动物(成功继承了物种!)

这里发生了什么?

  • Cat.prototype = new Animal():相当于Cat家族把Animal请来当顾问
  • 现在所有Cat都可以通过"顾问"访问Animal家族的资源

但这种做法有个大问题...

场景想象:你想请Animal当顾问,结果人家拖家带口、把全部家当都搬来了!new Animal()创建了一个完整的Animal实例,但我们需要的仅仅是Animal的"知识库"(原型),而不是它的全部身家。

三大痛点

  1. 浪费内存:Animal实例可能很大,但Cat只需要它的原型
  2. 参数尴尬new Animal()时需要参数,但作为原型时不知道传什么
  3. 效率低下:每次继承都要创建一个可能永远用不着的实例

二、走捷径的诱惑:直接"共享家谱"

有人可能想:"既然只是要原型,那直接共享不就行了?"

// 看似聪明的偷懒方法
Cat.prototype = Animal.prototype;
Cat.prototype.constructor = Cat;

危险!这是个陷阱!

// 猫家族想给自己加个技能
Cat.prototype.eatFish = function() {
    console.log('我爱吃鱼!');
};

// 但意外发生了...
const dog = new Animal('旺财', 3);
dog.eatFish();  // 😱 输出:我爱吃鱼!(狗怎么爱吃鱼了?!)

问题所在

  • Cat.prototypeAnimal.prototype指向同一个对象
  • 给Cat添加方法,Animal也会"被学会"
  • 就像两个部门共用同一个印章,一方修改,另一方遭殃

三、终极方案:聪明的"中间人"策略

我们需要一个既能继承知识,又不造成混乱的方法。这就是我们的"空函数中介"模式——一个聪明的"传话筒"。

手写extends函数:打造完美的家族传承

function extend(Parent, Child) {
    // 1. 请一个"中间人"(空函数F)
    // 它就像家族间的专业翻译,只传话,不添乱
    var F = function() {};
    
    // 2. 让中间人学习Parent的知识库
    F.prototype = Parent.prototype;
    
    // 3. 让Child拜中间人为师
    Child.prototype = new F();
    
    // 4. 给Child正名:你姓Child,不是Parent
    Child.prototype.constructor = Child;
}

来看看这个精妙的传承系统如何工作

// 使用我们的extend函数
function Cat(name, age, color) {
    // 继承Animal的"个人能力"
    Animal.apply(this, [name, age]);
    this.color = color;  // 猫的独有特征
}

// 启动传承仪式!
extend(Animal, Cat);

// 猫家族发展自己的特色
Cat.prototype.purr = function() {
    console.log('喵呜~发出呼噜声');
};

// 见证奇迹的时刻
const kitty = new Cat('小橘', 1, '橘色');
console.log(kitty.species);  // ✅ "动物"(继承了Animal的物种)
kitty.purr();               // ✅ "喵呜~发出呼噜声"(猫的独有技能)

const bird = new Animal('小鸟', 0.5);
console.log(bird.purr);     // ✅ undefined(完全没影响到Animal!)

为什么这个方案如此优雅?

三层隔离保护

  1. 第一层:Cat有自己的原型对象
  2. 第二层:通过中间人F访问Animal的原型
  3. 第三层:对Cat原型的修改完全不影响Animal

内存关系图

kitty(猫实例)
    ↓ "我可以找我的家族要东西"
Cat.prototype(猫家族知识库)
    ↓ "我学自中间人F"
F.prototype(= Animal.prototype)
    ↓ "我来自Animal家族"
Animal.prototype(动物家族知识库)
    ↓ "我是所有对象的起点"
Object.prototype

四、完整实战:打造动物世界的继承体系

让我们把理论变成实战代码:

// 增强版extend:更智能的传承系统
function extend(Child, Parent) {
    // 1. 请专业中间人(开销极小)
    var F = function() {};
    
    // 2. 中间人学习Parent的全部知识
    F.prototype = Parent.prototype;
    
    // 3. Child拜师学艺
    Child.prototype = new F();
    Child.prototype.constructor = Child;
    
    // 4. 给Child一个"家谱"(可选但很贴心)
    Child.uber = Parent.prototype;
    
    // 5. 现代JavaScript的额外支持
    if (Object.setPrototypeOf) {
        Object.setPrototypeOf(Child.prototype, Parent.prototype);
    }
}

// 动物家族基类
function Animal(name, age) {
    this.name = name;
    this.age = age;
}
Animal.prototype.breathe = function() {
    return '我在呼吸新鲜空气';
};

// 猫家族
function Cat(name, age, color) {
    // 先学Animal的"生存技能"
    Animal.call(this, name, age);
    this.color = color;
}

// 启动传承
extend(Cat, Animal);

// 猫家族的独门绝技
Cat.prototype.climbTree = function() {
    return '我能爬上最高的树!';
};

// 看看成果
const tom = new Cat('汤姆', 3, '蓝灰色');
console.log(tom.breathe());    // ✅ "我在呼吸新鲜空气"
console.log(tom.climbTree());  // ✅ "我能爬上最高的树!"
console.log(tom.color);        // ✅ "蓝灰色"

五、现代JavaScript:语法糖背后的真相

ES6给了我们更优雅的写法:

class Animal {
    constructor(name, age) {
        this.name = name;
        this.age = age;
    }
    
    breathe() {
        return '我在呼吸新鲜空气';
    }
}

class Cat extends Animal {
    constructor(name, age, color) {
        super(name, age);  // 这行相当于 Animal.call(this, name, age)
        this.color = color;
    }
    
    climbTree() {
        return '我能爬上最高的树!';
    }
}

重要提醒class只是"语法糖",底层依然是我们的原型继承。理解原型,才能真正掌握JavaScript的继承精髓。

总结:继承的智慧

通过这次探索,我们学到了:

  1. 原型实例化继承 → 简单粗暴但笨重(请整个家族当顾问)
  2. 直接原型继承 → 危险捷径(共用家谱,一损俱损)
  3. 空函数中介模式 → 优雅方案(专业中间人,隔离又高效)

编程就像家族传承

  • 好的继承应该像家训传承:后代学习前辈的智慧,但有自己的发展
  • 坏的继承就像财产纠纷:边界不清,互相影响
  • 我们的extend函数就像是找到了完美的家族信托方案

进阶思考

如果你要继续优化这个extend函数,你会添加哪些功能?

  1. 多重继承:像继承多个家族的优秀基因?
  2. 方法混入:像选择性学习不同师父的绝招?
  3. 静态方法继承:连家族的传统仪式也一起继承?

动手挑战:尝试实现一个支持多重继承的extend函数,让一个类可以同时继承多个父类的特性。把你的代码分享到评论区,看看谁的实现最优雅!

记住:在JavaScript的世界里,理解原型链就像掌握家族的秘密通道。通过这些通道,你可以在不破坏原有结构的前提下,构建出强大而灵活的代码"家族"。现在,你也是掌握这个秘密的开发者了!

深入浅出:手写 new 操作符,彻底理解 JavaScript 的实例化过程

深入浅出:手写 new 操作符,彻底理解 JavaScript 的实例化过程

引言

在 JavaScript 中,new 操作符是我们创建对象实例最常用的方式之一。但你真的了解 new 背后发生了什么吗?今天我们就来深入探讨一下 new 的奥秘,并亲手实现一个自己的 new 函数。

在解释手写new函数的之前,我们先解释一些知识点方便我们后面理解手写new的过程

一、构造函数被实例化的完整过程

什么是构造函数?

构造函数其实就是一个普通的函数,但当我们使用 new 关键字调用它时,它就变成了一个"构造函数"。

function Person(name, age) {
    this.name = name;
    this.age = age;
}

// 作为普通函数调用
Person('张三', 18);  // this 指向全局对象(浏览器中是 window)

// 作为构造函数调用
const person = new Person('张三', 18);  // this 指向新创建的对象

new 实例化的完整步骤

比喻:想象一下工厂生产产品的过程:

  1. 准备原材料(创建空对象)
  2. 按照设计图纸加工(调用构造函数)
  3. 贴上品牌标签(设置原型链)
  4. 出厂检验(返回对象)

具体来说,new 操作符执行以下4个步骤:

步骤1:创建一个空对象
const obj = {};
步骤2:将新对象的 __proto__ 指向构造函数的 prototype
obj.__proto__ = Constructor.prototype;
步骤3:将构造函数的 this 绑定到这个新对象,并执行构造函数
Constructor.apply(obj, args);
步骤4:如果构造函数返回了一个对象,则返回该对象;否则返回新创建的对象
function Person(name) {
    this.name = name;
    // 如果没有显式返回,默认返回 this
}

function Person2(name) {
    this.name = name;
    return { custom: 'object' };  // 如果返回对象,则替代新创建的对象
}

const p1 = new Person('张三');  // Person {name: "张三"}
const p2 = new Person2('李四'); // {custom: "object"}

二、apply、call 和 bind 的区别

这三个方法都用于改变函数执行时的 this 指向,但使用方式略有不同。

比喻说明

想象你是一家公司的CEO(函数),你需要给员工(对象)下达指令:

  • call:直接告诉某个员工该做什么
  • apply:告诉某个员工该做什么,并给他一袋资料(数组参数)
  • bind:预先告诉员工,将来某个时间点需要做什么

1. call 方法

function introduce(greeting, punctuation) {
    console.log(`${greeting}, 我是${this.name}${punctuation}`);
}

const person = { name: '张三' };

// call 接受参数列表
introduce.call(person, '你好', '!');  // "你好, 我是张三!"

2. apply 方法

// apply 接受参数数组
introduce.apply(person, ['你好', '!']);  // "你好, 我是张三!"

3. bind 方法

// bind 返回一个新函数,而不是立即执行
const boundIntroduce = introduce.bind(person, '你好');
boundIntroduce('!');  // "你好, 我是张三!"

总结对比

方法 立即执行 参数形式 返回值
call 参数列表 函数执行结果
apply 数组 函数执行结果
bind 参数列表 新函数

三、arguments 对象详解

什么是 arguments?

arguments 是函数内部的一个特殊对象,它包含了函数调用时传入的所有参数。

function showArgs() {
    console.log(arguments);
    console.log(arguments.length);
    console.log(arguments[0]);
}

showArgs(1, 2, 3);
// 输出:
// Arguments(3) [1, 2, 3]
// 3
// 1

arguments 的特点

1. 类数组对象(Array-like Object)

arguments 看起来像数组,但不是真正的数组:

function checkArguments() {
    console.log('长度:', arguments.length);
    console.log('可索引:', arguments[0], arguments[1]);
    console.log('是数组吗?', Array.isArray(arguments));  // false
    console.log('类型:', Object.prototype.toString.call(arguments)); // [object Arguments]
}

checkArguments('a', 'b', 'c');
2. 不能使用数组的方法
function tryArrayMethods() {
    // 这些会报错
    // arguments.map(item => item * 2);  // ❌ 错误
    // arguments.reduce((sum, num) => sum + num);  // ❌ 错误
    
    // 但可以这样遍历
    for (let i = 0; i < arguments.length; i++) {
        console.log(arguments[i]);
    }
    
    // 或者用 for...of(ES6+)
    for (const arg of arguments) {
        console.log(arg);
    }
}

如何将 arguments 转为真正的数组?

方法1:Array.from (ES6)
function convertArguments1() {
    const argsArray = Array.from(arguments);
    console.log(Array.isArray(argsArray));  // true
    console.log(argsArray.map(x => x * 2));  // 可以正常使用数组方法
}
方法2:扩展运算符 (ES6)
function convertArguments2(...args) {  // 直接在参数中使用
    console.log(Array.isArray(args));  // true
}

function convertArguments3() {
    const argsArray = [...arguments];
    console.log(Array.isArray(argsArray));  // true
}
方法3:Array.prototype.slice.call (ES5)
function convertArguments4() {
    const argsArray = Array.prototype.slice.call(arguments);
    console.log(Array.isArray(argsArray));  // true
}

arguments 的注意事项

  1. 箭头函数没有 arguments
const arrowFunc = () => {
    console.log(arguments);  // ❌ 报错:arguments is not defined
};

// 箭头函数应该这样获取参数
const arrowFunc2 = (...args) => {
    console.log(args);  // ✅ 正确
};
  1. arguments 和参数变量联动(非严格模式)
function linkedArguments(a, b) {
    console.log('a:', a, 'arguments[0]:', arguments[0]);
    
    a = 'changed';
    console.log('修改后 a:', a, 'arguments[0]:', arguments[0]);
    
    arguments[0] = 'changed again';
    console.log('再次修改后 a:', a, 'arguments[0]:', arguments[0]);
}

linkedArguments('original', 2);
// 输出:
// a: original arguments[0]: original
// 修改后 a: changed arguments[0]: changed
// 再次修改后 a: changed again arguments[0]: changed again

四、开始手写实现 new 操作符

现在,让我们结合以上知识点,一步步实现自己的 new 函数。

基础版本实现

function objectFactory(Constructor, ...args) {
    // 1. 创建一个空对象
    const obj = {};
    
    // 2. 将新对象的原型指向构造函数的原型
    obj.__proto__ = Constructor.prototype;
    
    // 3. 将构造函数的 this 绑定到新对象,并执行构造函数
    Constructor.apply(obj, args);
    
    // 4. 返回新对象
    return obj;
}

增强版本(处理构造函数返回值)

function objectFactory(Constructor, ...args) {
    // 1. 创建新对象,并设置原型链
    const obj = Object.create(Constructor.prototype);
    
    // 2. 执行构造函数,绑定 this
    const result = Constructor.apply(obj, args);
    
    // 3. 判断构造函数返回的是否是对象
    // 如果是对象则返回该对象,否则返回新创建的对象
    return typeof result === 'object' && result !== null ? result : obj;
}

完整实现(兼容 ES5)

function objectFactory() {
    // 1. 获取构造函数(第一个参数)
    const Constructor = [].shift.call(arguments);
    
    // 2. 创建空对象,并继承构造函数的原型
    const obj = Object.create(Constructor.prototype);
    
    // 3. 执行构造函数,将 this 指向新对象
    const result = Constructor.apply(obj, arguments);
    
    // 4. 返回结果
    return typeof result === 'object' ? result : obj;
}

使用示例

function Person(name, age) {
    this.name = name;
    this.age = age;
}

Person.prototype.sayHello = function() {
    console.log(`你好,我是${this.name},今年${this.age}岁`);
};

// 使用原生的 new
const person1 = new Person('张三', 18);
person1.sayHello();  // "你好,我是张三,今年18岁"

// 使用我们手写的 objectFactory
const person2 = objectFactory(Person, '李四', 20);
person2.sayHello();  // "你好,我是李四,今年20岁"

console.log(person1 instanceof Person);  // true
console.log(person2 instanceof Person);  // true
console.log(person1.sayHello === person2.sayHello);  // true(共享原型方法)

处理特殊情况

// 1. 构造函数返回对象的情况
function Car(model) {
    this.model = model;
    return { custom: 'special object' };  // 返回对象
}

const car = objectFactory(Car, 'Tesla');
console.log(car);  // {custom: "special object"},而不是 Car 实例

// 2. 构造函数返回基本类型的情况
function Bike(brand) {
    this.brand = brand;
    return 'not an object';  // 返回基本类型,会被忽略
}

const bike = objectFactory(Bike, 'Giant');
console.log(bike);  // Bike {brand: "Giant"},返回新创建的对象

五、实际应用场景

1. 库或框架中的使用

许多库(如早期的 jQuery)会使用类似的技术来创建对象,避免使用 new 关键字:

// jQuery 风格的初始化
function $(selector) {
    return new jQuery(selector);
}

// 或者
function $(selector) {
    return objectFactory(jQuery, selector);
}

2. 创建对象池

function createObjectPool(Constructor, count) {
    const pool = [];
    
    for (let i = 0; i < count; i++) {
        pool.push(objectFactory(Constructor));
    }
    
    return pool;
}

// 创建 10 个默认的 Person 对象
const personPool = createObjectPool(Person, 10);

3. 实现单例模式

function singleton(Constructor, ...args) {
    let instance = null;
    
    return function() {
        if (!instance) {
            instance = objectFactory(Constructor, ...args);
        }
        return instance;
    };
}

const getSingletonPerson = singleton(Person, '单例', 100);
const p1 = getSingletonPerson();
const p2 = getSingletonPerson();
console.log(p1 === p2);  // true

总结

通过手写 new 操作符,我们深入理解了 JavaScript 对象实例化的过程:

  1. 创建空对象:建立对象的"肉身"
  2. 设置原型链:连接对象的"灵魂"(继承)
  3. 执行构造函数:赋予对象"个性"(属性)
  4. 返回对象:决定最终"出厂"的是什么

理解这些底层机制,不仅可以帮助我们更好地使用 JavaScript,还能在面试中脱颖而出。更重要的是,这种"知其然知其所以然"的学习方式,能够让我们在面对复杂问题时,有能力从底层原理出发,找到最优雅的解决方案。

记住,每个看似简单的 new 背后,都隐藏着 JavaScript 原型链、this 绑定、函数执行等多个核心概念的完美协作。掌握了这些,你就真正理解了 JavaScript 面向对象编程的精髓。


实战解密:我是如何用Vue 3 + Buffer实现AI“打字机”效果的

实战解密:我是如何用Vue 3 + Buffer实现AI“打字机”效果的

从一行代码到一个完整AI聊天应用

最近我在做一个AI聊天应用时,遇到了一个关键问题:如何让AI的回复像真人打字一样,一个字一个字地出现?  经过一番探索,我发现了流式输出 + Buffer的组合方案。今天,我就用我的实际代码,带你彻底搞懂这个技术!

这个应用是做什么的?

想象你有一个 智能聊天机器人🤖:

  1. 你输入一个问题(比如:"讲一个笑话")
  2. 点击"提交"按钮
  3. 机器人开始思考并回复你
  4. 回复可以一个字一个字出现(流式模式),或者一下子全部出现

第一部分:理解 Vue 3 的基础

1.1 什么是响应式数据?

生活例子📺: 想象你家电视的遥控器:

  • 按"音量+" → 电视音量变大
  • 按"频道+" → 电视换台

这里的 响应式 就是:按遥控器(改变数据),电视立即响应(页面更新)。

// 创建响应式数据就像给数据装上"遥控器"
const question = ref('你好');  // 创建一个能"遥控"的数据

// 在模板中显示
<div>{{ question }}</div>  <!-- 显示:你好 -->

// 如果改变数据
question.value = 'Hello';   // 按下"遥控器"

// 页面自动变成
<div>Hello</div>            <!-- 页面自动更新! -->

1.2 ref 是什么?

ref 就是把普通数据包装成一个特殊的盒子📦:

// 普通数据
let name = "小明";  
// 改变时,Vue不知道,页面不会更新

// 响应式数据
const nameRef = ref("小明");
// 实际上变成了:{ value: "小明" }

// 访问时要加 .value
console.log(nameRef.value);  // "小明"

// 改变数据
nameRef.value = "小红";      // Vue 知道数据变了,会更新页面

第二部分:模板语法

2.1 v-model - 双向绑定

双向绑定 就像 同步的记事本📝:

<!-- 创建一个输入框 -->
<input v-model="question" />

<!-- 这相当于做了两件事:
1. 输入框显示 question 的值
2. 你在输入框打字时,自动更新 question 的值
-->

实际效果:

// 你输入"你好"
question.value = "你好";

// 页面显示
<input value="你好" />

// 你再输入"大家好"
// question.value 自动变成 "大家好"

2.2 @click - 事件监听

就像给按钮装上 门铃🔔:

<button @click="askLLM">提交</button>

<!-- 意思是:点击这个按钮时,执行 askLLM 函数 -->

第三部分:核心功能 - 调用 AI

3.1 基本流程(像点外卖)

const askLLM = async () => {
  // 1. 准备问题(像写菜单)
  if (!question.value) {
    console.log('问题不能为空');
    return;
  }
  
  // 2. 显示"思考中..."(像显示"商家接单中")
  content.value = "思考中...";
  
  // 3. 准备外卖信息
  const endpoint = 'https://api.deepseek.com/chat/completions';  // 外卖平台地址
  const headers = {
    'Authorization': `Bearer ${你的API密钥}`,  // 支付凭证
    'Content-Type': 'application/json',        // 说要送JSON格式
  };
  
  // 4. 下订单
  const response = await fetch(endpoint, {
    method: 'POST',      // 点外卖用POST
    headers,             // 告诉商家信息
    body: JSON.stringify({  // 具体订单内容
      model: 'deepseek-chat',
      stream: stream.value,  // 要不要流式(分批送)
      messages: [{
        role: 'user',
        content: question.value
      }]
    })
  });
  
  // 5. 等外卖送到并处理
  // ... 后面详细讲
}

第四部分:流式响应详细解释

4.1 什么是"流式"?

比喻🎬:

  • 非流式:等电影全部下载完(5GB)才能看
  • 流式:下载一点(10MB)就能开始看,边下载边看

在这个应用中:

  • 非流式:等AI全部生成完文字,一次性显示
  • 流式:AI生成一个字就显示一个字

4.2 流式响应代码详解(逐步讲解)

if (stream.value) {  // 如果用户选了流式模式
  // 第一步:清空上次的回答
  content.value = "";  // 清空显示区域
  
  // 第二步:创建"水管"和"水龙头"
  const reader = response.body?.getReader();  
  // reader 就像水龙头,可以控制水流
  
  const decoder = new TextDecoder();
  // decoder 就像净水器,把脏水(二进制)变成干净水(文字)
  
  let done = false;  // 记录水是否流完了
  let buffer = '';   // 临时水桶,装不完整的水
  
  // 第三步:开始接水(循环读取)
  while (!done) {  // 只要水没流完就一直接
    
    // 接一瓢水(读一块数据)
    const { value, done: doneReading } = await reader?.read();
    // value: 接到的水(二进制数据)
    // doneReading: 这一瓢接完了吗?
    
    done = doneReading;  // 更新是否流完的状态
    
    // 第四步:处理接到的水
    // 把这次的水和上次没处理完的水合在一起
    const chunkValue = buffer + decoder.decode(value);
    buffer = '';  // 清空临时水桶
    
    console.log("收到数据:", chunkValue);
    // 数据格式类似:
    // data: {"delta": {"content": "你"}}
    // data: {"delta": {"content": "好"}}
    // data: [DONE]
    
    // 第五步:把一大块水分成一行一行
    const lines = chunkValue.split('\n')  // 按换行分割
      .filter(line => line.startsWith('data: '));  // 只保留以"data: "开头的行
    
    // 第六步:处理每一行水
    for (const line of lines) {
      const incoming = line.slice(6);  // 去掉开头的"data: "
      // 现在 incoming = '{"delta": {"content": "你"}}'
      
      // 如果是结束标志
      if (incoming === '[DONE]') {
        done = true;  // 停止接水
        break;        // 跳出循环
      }
      
      try {
        // 第七步:解析JSON(把水变成能喝的东西)
        const data = JSON.parse(incoming);
        // data = { delta: { content: "你" } }
        
        const delta = data.choices[0].delta.content;
        // delta = "你"
        
        if (delta) {
          // 第八步:显示出来
          content.value += delta;  // 把"你"加到显示内容里
          // 第一次:content = "你"
          // 第二次:content = "你好"
          // 第三次:content = "你好世"
          // ... 直到完成
        }
      } catch (error) {
        // 如果JSON解析失败(比如收到了不完整的JSON)
        buffer += `data: ${incoming}`;  // 存起来等下一瓢水
      }
    }
  }
}

4.3 为什么需要 buffer

情景模拟: 假设AI要回复"你好世界",但网络传输时可能这样:

第一次收到data: {"delta": {"content": "你 (JSON不完整,少了右括号)

第二次收到好世界"}}

如果直接解析第一次的数据:

JSON.parse('{"delta": {"content": "你');  // 报错!JSON不完整

所以我们需要:

  1. 第一次:buffer = 'data: {"delta": {"content": "你'
  2. 第二次:buffer + 新数据 = 'data: {"delta": {"content": "你好世界"}}'
  3. 现在可以正确解析了

完整工作流程演示

让我用具体的执行过程展示这个系统的精妙:

javascript

// 用户输入:"你好"
// 服务器响应流开始...

// 第1次循环:
收到数据: data: {"delta": {"content": "你"}}\n
分割成行: ['data: {"delta": {"content": "你"}}']
解析成功!→ 显示:"你"

// 第2次循环:
收到数据: data: {"delta": {"content": "好
分割成行: ['data: {"delta": {"content": "好']
JSON解析失败!→ 存入buffer: 'data: {"delta": {"content": "好'

// 第3次循环:
收到数据: "}}\n
当前数据: buffer + 新数据 = 'data: {"delta": {"content": "好"}}'
分割成行: ['data: {"delta": {"content": "好"}}']
解析成功!→ 显示:"你好"

// 第4次循环:
收到数据: data: [DONE]\n
检测到[DONE] → 结束循环

第五部分:完整交互流程

你打开页面
    ↓
看到输入框:[讲一个笑话]
    ↓
点击"提交"
    ↓
Vue调用 askLLM() 函数
    ↓
显示"思考中..."
    ↓
发送请求到DeepSeek
    ↓
AI开始思考
    ↓
【流式模式】
    ↓
收到第一个字:"有"
    ↓
页面显示:有
    ↓
收到第二个字:"个"
    ↓
页面显示:有个
    ↓
收到第三个字:"人"
    ↓
页面显示:有个人
    ↓
...(持续)
    ↓
收到"[DONE]"
    ↓
显示完整:有个人去面试...

第六部分:关键概念总结

概念 比喻 作用
ref() 遥控器📱 让数据变化时页面自动更新
v-model 双向镜子🪞 输入框和数据的双向同步
@click 门铃🔔 点击时执行函数
fetch() 外卖小哥🚴 发送网络请求
getReader() 水龙头🚰 读取流式数据
TextDecoder() 翻译官👨‍💼 把二进制变成文字
JSON.parse() 拆包裹📦 把JSON字符串变成对象

给初学者的建议

  1. 先理解整体:不要一开始就陷入细节
  2. 分块学习
    • 先学会 Vue 基础(ref, v-model)
    • 再学网络请求(fetch)
    • 最后学流式处理
  3. 动手实践:修改代码看看效果
    • stream.value 改成 false 看看区别
    • console.log 里看数据变化
  4. 遇到问题:用 console.log() 打印每一步的结果

这个代码虽然看起来复杂,但每个部分都有明确的作用。就像搭积木一样,每块积木(函数)都有特定的功能,组合起来就实现了强大的AI聊天功能!😊

附录:完整的Vue 3 AI流式输出代码

App.vue 完整代码

<script setup>
import { ref } from 'vue';

const question = ref('讲一个光头强和一个白富美之间的故事,20字');
const stream = ref(true);
const content = ref("");

const askLLM = async () => {
  if (!question.value) {
    console.log('question is empty');
    return;
  }
  
  content.value = "思考中...";
  
  const endpoint = 'https://api.deepseek.com/chat/completions';
  const headers = {
    'Authorization': `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`,
    'Content-Type': 'application/json',
  };
  
  const response = await fetch(endpoint, {
    method: 'POST',
    headers,
    body: JSON.stringify({
      model: 'deepseek-chat',
      stream: stream.value,
      messages: [{
        role: 'user',
        content: question.value
      }]
    })
  });
  
  if (stream.value) {
    content.value = "";
    const reader = response.body?.getReader();
    const decoder = new TextDecoder();
    let done = false;
    let buffer = '';
    
    while (!done) {
      const { value, done: doneReading } = await reader?.read();
      console.log(value, doneReading);
      done = doneReading;
      
      const chunkValue = buffer + decoder.decode(value);
      console.log(chunkValue);
      buffer = '';
      const lines = chunkValue.split('\n')
        .filter(line => line.startsWith('data: '));
      
      for (const line of lines) {
        const incoming = line.slice(6);
        if (incoming === '[DONE]') {
          done = true;
          break;
        }
        
        try {
          const data = JSON.parse(incoming);
          const delta = data.choices[0].delta.content;
          if (delta) {
            content.value += delta;
          }
        } catch (error) {
          buffer += `data: ${incoming}`;
        }
      }
    }
  } else {
    const data = await response.json();
    console.log(data);
    content.value = data.choices[0].message.content;
  }
}
</script>

<template>
  <div class="container">
    <div>
      <label>输入:</label>
      <input class="input" v-model="question"/>
      <button @click="askLLM">提交</button>
    </div>
   
    <div class="output">
      <div>
        <label>Streaming</label>
        <input type="checkbox" v-model="stream"/>
        <div>{{content}}</div>
      </div>
    </div>
  </div>  
</template>

<style scoped>
* {
  margin: 0;
  padding: 0;
}
.container {
  display: flex;
  flex-direction: column;
  align-items: start;
  justify-content: start;
  height: 100vh;
  font-size: 0.85rem;
}
.input {
  width: 200px;
}
button {
  padding: 0 10px;
  margin-left: 6px;
}
.output {
  margin-top: 10px;
  min-height: 300px;
  width: 100%;
  text-align: left;
}
</style>

说点不一样的。GPT-5.3 与 Claude Opus 4.6 同时炸场,前端变天了?

大家好,我是 Sunday。

早上一起来,手机就被刷屏了...各大号主都在写 OpenAI 和 Anthropic 在昨天晚上发布的最新模型 GPT-5.3-CodexClaude Opus 4.6

我看了一眼各大号主的文章,发现大家都在狂欢(真假不重要,至少文章中都在狂欢),都在发跑分截图。有的说 Claude 的 一百万(1M)上下文无敌 了,有的说 GPT-5.3 的 OSWorld 分数太吓人。

Claude 提供百万上下文

GPT-5.3 的 OSWorld 跑分

但是,Sunday 作为一个写了十几年代码、现在天天琢磨怎么用 AI 提效的“老前端”。看了这两篇长达数万字的技术文档之后,却并没有其他博主反应的那么兴奋。

为什么呢?

因为,Sunday 发现在这两篇文章中,顶级 AI 模型目前争论的焦点,已经完全不是“谁写的代码 Bug 少”这么简单了,他们正在重新定义什么是“写代码”?

所以,今天这篇文章,Sunday 就我不带大家像看热闹一样去比那些虚无缥缈的跑分(虽然跑分都很高),我想带大家从另外一个角度,来看看这次更新的核心内容。或许大家看完之后,能有不一样的收获。

一、 Claude Opus 4.6

先说 Anthropic 的 Claude Opus 4.6

大家都在吹 100 万 Token 上下文(1M Context)

很多人对这个数字没概念,觉得不就是:“可以把一个超级大的文档扔给 Claude 让他处理了吗?”

错。大错特错。

想要明白这个,我们需要先知道 AI 写代码到底是怎么去写的!

在 Opus 4.6 之前,我们用 AI 写代码是 “切片式” 的。

什么意思呢?

比如:你的项目可能有 500 个文件,但你每次只能复制粘贴那 3 个相关的文件给 AI。 AI 每次也只是读取其中的部分文件的部分代码。简单理解就是:AI 是看不见的全局架构设计的。它看不见你的隐藏依赖、架构设计方案、更不了解你 CSS 中可能会存在的全局污染问题。

所以,AI 经常会写出那种 “局部完美,全局崩盘” 的代码。

之前我们的处理方式都是:先让 AI 进行逻辑拆解,然后根据具体的步骤在一步步执行,这样会好很多。

但是现在不一样了,100 万 Token 上下文已经足够我们去理解非常大的项目代码。

除此之外,Claude 还提供了一个叫做 Context Compaction(上下文压缩) 的技术,这个技术会会自动总结并替换旧的上下文,让 Claude 能够执行更长时间的任务而不会达到限制。

这意味着什么?

这意味着:你可以把 整个 前端项目(src 目录 + 配置 + 文档)一次性扔给 Claude,他们通过上下文压缩方案,来逐步处理你的所有源代码。

除了这个之外,还有一个叫做 Agent Teams(代理团队) 的东西。这个和 Sunday 之前写的一篇文章有点相似,感兴趣的同学可以看看:我创建了一个全 AI 员工的一人公司

这玩意是啥意思呢?

简单来说就是:以前我们用 Claude Code,是一对一。现在,他可以直接创建出一个 Claude Code 的团队。 团队中每个人(Agent)各司其事,各自独立,并还可以相互写作,相互吵架。

  • Agent A(架构师):负责拆解需求,不写代码。
  • Agent B(开发):负责具体实现。
  • Agent C(测试):负责写单元测试,并试图为了 Agent B 的代码找茬。

就像 claude 的更新公告在结尾所展示的一样:这次更新是解锁了新的长期任务的开始

二、 GPT-5.3 Codex

如果说 Claude 是在横向扩展(容量),那 OpenAI 的 GPT-5.3 Codex 就是在纵向钻孔(深度)。

这篇博客里最让我起鸡皮疙瘩的一句话是:

"GPT-5.3 Codex is the first model to play a key role in its own development." (GPT-5.3 Codex 是第一个在自身开发过程中发挥关键作用的模型。)

兄弟们,这剧本我看过,这就叫“左脚踩右脚上天”。

在 OpenAI 的程序员现在已经开始使用 GPT 来开发新的 GPT 了....

这意味着: 目前的 AI 模型已经完全可以应用在复杂的商业项目中。

而在看完整个 GPT 5.3 的更新公告之后,Sunday 发现其中有两个点是最重要的:

1. “交互式”纠偏(Interactive Collaboration)

以前用 Agent 写代码,大家最害怕的是什么?

最怕的是它 “一条道走到黑”!

你给个需求,它跑了半小时,最后给你一坨跑不通的代码,你还不知道它是哪一步歪的。

GPT-5.3 Codex 引入了 “人机介入” 机制。

当它在构建一个复杂的 Web 游戏(比如博客里展示的那个赛车游戏)时

如果它卡住了,或者方向偏了,你可以实时暂停它,给它喂一句反馈,它能无缝接住你的思路继续干。

这解决了 Agent 落地最大的痛点:不可控性

2. 真正的 GUI 操作能力(OSWorld 64.7%)

OSWorld 是 AI 模型中的一个专业属于,他表示的是 对视觉任务的识别程度

通常情况下,普通人的 OSWorld 大约是在 72% 左右。

但是,这次 GPT 5.3 的更新 OSWorld 分数飙到了 64.7%

这意味着,GPT 现在不只是只能在终端里面跑命令行了。他现在可以像人一样,打开浏览器,打开 Chrome DevTools,点击那个报错的按钮,查看 Network 面板,然后切回编辑器改代码。

这下感觉 “测试要失业了...”。之前咱们还得写个测试代码啥的,现在都已经不需要了。

我们可以直接告诉 GPT-5.3:“去把那个下单流程测一遍,如果支付失败了,截个图发我。”

它现在完全可以做到了!

三、 思考总结

不知道大家看完这些升级之后是什么感觉?

Sunday 个人的感觉是:“前端又要失业了”。毕竟 2025 年前端一年就死了 10 次...

没那么严重!

不过,大家需要注意的是:如果你还是把自己定义为 “切图仔” 或者 “API 调包侠” ,那是真完了

但是,如果你把自己定义为 “产品工程师” !那么属于你的黄金时代才刚刚开始。

为什么这么说?

1. 技术的“平权”

以前,我们要想做一个全栈应用,门槛太高了。你要懂 Docker,要懂 K8s,要懂数据库调优。

现在,GPT-5.3 Codex 连自己的训练集群都能管理。你只要有系统设计的能力,你一个人就是一个团队(AI 虽然不便宜,但是比请人可便宜多了)。

前端开发者,凭借对 用户体验(UX) 的敏感度,加上 AI 强大的后端填补能力,将成为最容易转型为“独立开发者”或“超级个体”的人群。

2. 从 “怎么实现” 到 “如何解决问题”

在以前,我们这些程序员 80% 的时间在研究 "如何去实现某一个功能"(这个效果怎么用 CSS 实现?这个状态怎么管理?)。

但是现在,不需要了。

现在,无论是 Claude 还是 GPT 都可以帮助我们去解决如何实现功能的问题。

因此,我们需要把重点改为:我们要解决什么问题?用户的痛点在哪里?系统的数据流怎么设计才合理?

在这种场景下,对用户和需求的感知力 正变的越来越重要。

最后,我想问大家一个问题:

如果明天,你不需要再写一行具体的业务代码,只需要审核 Agent 提交的 PR,你会把你省下来的这 8 个小时,用来干什么?

是去学新的技术?还是去深入理解业务?或者,去创造一个属于你自己的产品?

这才是 2026 我们思考的事情

别光看着跑分激动了。行动起来,去申请 API,去把你的 IDE 换成 Cursor 或者 Windsurf,去感受一下这种 “与硅基生物结对编程” 的感觉。

我是 Sunday。如果你在用这两个模型的过程中遇到了什么坑,或者发现了什么新玩法,随时在评论区或者群里告诉我。我们一起研究,绝不掉队。

JS-AbortController:优雅中止请求操作

前言

在前端开发中,我们经常遇到需要中途撤回请求的情况(例如:搜索框快速输入、大型文件上传取消、或是 AI 聊天流的即时中断)。传统的 Promise 一旦启动就无法在外部“叫停”,而 AbortController 的出现,完美填补了这一空白。

一、 核心概念与原理

AbortController 是 JavaScript 内置的信号控制对象,它是实现异步操作可控制、可中止的核心。

1. 关键组成部分

  • controller.signal:一个 AbortSignal 对象实例。它充当“监听器”,将其传递给异步操作后,该操作会持续观察信号状态。

  • controller.abort() :触发中止的方法。调用后,signal 上的 abort 事件会被触发,同时将 signal.aborted 设为 true


二、 基础使用模式

1. 实现步骤

  1. 使用 new AbortController() 生成实例。
  2. 将实例中的 signal 属性传递给需要支持中止的异步 API(如 fetch)。
  3. 在合适的时机调用 controller.abort() 即可主动终止。

2. 代码示例

// 1. 创建 AbortController 实例
const controller = new AbortController();
const { signal } = controller;

// 2. 发起请求并绑定信号
fetch("/api/data", { signal })
  .then((response) => response.json())
  .then((data) => console.log("请求成功:", data))
  .catch((err) => {
    // 3. 捕获中止错误
    if (err.name === "AbortError") {
      console.log("主动取消:请求被成功截断");
    } else {
      console.error("请求失败:", err);
    }
  });

// 2 秒后主动取消请求
setTimeout(() => {
  controller.abort(); 
}, 2000);

三、 进阶技巧与场景

1. 批量取消请求

如果想同时取消多个相关的请求,可以给这些请求共享同一个 signal。当调用 abort() 时,所有关联的任务都会收到中止信号。

2. 示例

// 使用同一个 AbortController 取消多个请求
const controller = new AbortController();

// 请求1
const request1 = fetch('url1', {
  signal: controller.signal
});

// 请求2
const request2 = fetch('url2', {
  signal: controller.signal
});

// 请求3
const request3 = fetch('url3', {
  signal: controller.signal
});

// 同时取消所有请求
document.getElementById('cancelBtn').addEventListener('click', () => {
  controller.abort();
  console.log('所有请求已取消');
});

// 等待所有请求
Promise.all([request1, request2, request3])
  .then(responses => Promise.all(responses.map(r => r.json())))
  .then(data => console.log('所有数据:', data))
  .catch(error => {
    if (error.name === 'AbortError') {
      console.log('请求被取消');
    }
  });

3. 注意事项

  • 兼容性:并非所有 API 都原生支持。目前 fetchAxios (v0.22+) 模块已提供支持。
  • 幂等性abort() 方法只能生效一次。多次调用虽然不会报错,但只有第一次调用会触发中止逻辑。

四、 总结对比

特性 传统 Promise 带有 AbortController 的 Promise
可控性 开启后无法干预 可随时通过 abort() 中止
异常处理 只有成功/失败 增加 AbortError 类型,方便区分主动取消与网络异常
应用场景 简单的数据获取 复杂交互、流式输出、性能调优

前端向架构突围系列 - 状态数据设计 [8 - 2]:前端框架的“细粒度响应式”原理

写在前面

React 的痛: 在 React 中,一个 State 变了,组件就会重新执行(Re-render)。为了性能,我们不得不搞出 Fiber 架构,搞出时间切片,搞出 useMemo。这就好比:为了能在干草堆里找到一根针,React 发明了一台超级高科技的“干草堆翻找机”。

Signal 的解: 细粒度响应式(Signal)的思路是:在扔针进去的时候,就给针系上一根绳子。要找针的时候,拉绳子就行了。

本篇我们将深入内核,手写一个迷你 Signal 系统,看清它的本质。

image.png


一、 宏观对决:VDOM vs. Fine-Grained (细粒度)

要理解 Signal,首先要理解它想革谁的命。

1.1 VDOM 的“地毯式搜索”

React 的更新模型是 Snapshot(快照) 式的。

  • 流程: 数据变了 -> 运行整个组件函数 -> 生成新的 VDOM 树 -> 对比新旧树 (Diff) -> 找出差异 -> 更新 DOM。
  • 复杂度: 跟组件树的大小成正比。
  • 问题: 哪怕只改了一个文本节点,整个组件(甚至子组件)的逻辑都要重跑一遍。

1.2 Signal 的“点对点狙击”

SolidJS 或 Vue 的更新模型是 Dependency Graph(依赖图) 式的。

  • 流程: 数据变了 -> 直接定位到绑定了该数据的 DOM 节点 -> 更新 DOM。
  • 复杂度: 跟动态节点的数量成正比(通常是 O(1))。
  • 核心: 组件函数只在初始化时运行一次!之后再也不会运行了。

二、 解剖 Signal:发布订阅的进化体

Signal 并不神秘,它本质上就是 “保存值的容器” + “自动依赖追踪” 。 它由两个核心动作组成:Track (追踪/读)Trigger (触发/写)

2.1 核心 API 模拟

以 SolidJS/React 风格为例,我们造一个 Signal:

// 这是一个全局变量,用来记录“当前谁在查我不?”
let activeEffect = null;

function createSignal(initialValue) {
  let value = initialValue;
  const subscribers = new Set(); // 订阅者名单

  // Getter (读)
  const read = () => {
    if (activeEffect) {
      // 1. 依赖收集 (Track):如果有人在关注我,把他记下来
      subscribers.add(activeEffect);
    }
    return value;
  };

  // Setter (写)
  const write = (newValue) => {
    value = newValue;
    // 2. 派发更新 (Trigger):通知名单里所有人干活
    subscribers.forEach(fn => fn());
  };

  return [read, write];
}

2.2 魔法的粘合剂:Effect

光有 Signal 没用,得有人“读”它,订阅关系才能建立。这就需要 createEffect(在 Vue 里叫 watchEffect)。

function createEffect(fn) {
  // 把自己标记为“正在执行的副作用”
  activeEffect = fn;
  
  // 执行一次函数。
  // 注意:函数内部会读取 Signal,从而触发 Signal 的 Getter,
  // 进而把这个 fn 添加到 subscribers 里。
  fn();
  
  // 执行完复原
  activeEffect = null;
}

2.3 跑起来看看

const [count, setCount] = createSignal(0);

createEffect(() => {
  console.log("数字变了:", count()); 
});
// 输出: 数字变了:0 (初始化执行)

setCount(1);
// 输出: 数字变了:1 (自动触发!)

这就是细粒度响应式的最简内核。没有任何 VDOM,没有 Diff,只有精准的函数调用链。


三、 进阶: computed 与依赖图的自动构建

Signal 系统最强大的地方在于它能自动构建依赖图。 在架构设计中,我们经常使用 computed (派生状态)。

computed 既是 消费者(它依赖别的 Signal),又是 生产者(别的 Effect 依赖它)。

3.1 懒计算与缓存 (Memoization)

细粒度框架中的 computed 通常是惰性的(Lazy)。

  • 只有当有人读它时,它才计算。
  • 如果它依赖的 Signal 没变,它直接返回缓存。

3.2 动态依赖收集

这是 React useMemo 永远做不到的。 React 的依赖数组 [a, b] 是手动声明的(静态)。而 Signal 的依赖是运行时动态收集的。

const [show, setShow] = createSignal(true);
const [name, setName] = createSignal("Gemini");
const [age, setAge] = createSignal(18);

createEffect(() => {
  // 动态依赖!
  if (show()) {
    console.log(name()); // 此时依赖是 [show, name]
  } else {
    console.log(age());  // 此时依赖变成 [show, age]
  }
});

架构意义: 这种机制保证了最小化计算。当 show 为 false 时,改变 name 根本不会触发这个 Effect,因为系统知道这一刻 name 不重要。


四、 为什么 React 还在坚持?

既然 Signal 这么好,性能这么高,为什么 React 不把 useState 换成 Signal? 这涉及到底层哲学的冲突。

4.1 UI = f(state) vs. UI = Bind(state)

  • React 哲学: UI 是数据的投影(Snapshot) 。每次渲染都是丢弃旧世界,重建新世界。这符合函数式编程的直觉,心智模型最简单。
  • Signal 哲学: UI 是数据的绑定(Binding) 。初始渲染后,组件就消失了,剩下的只有数据和 DOM 之间的连线。

4.2 代数效应 (Algebraic Effects)

React 团队认为,手动处理 .value 或者 [get, set] 是对开发者心智的负担。他们追求的是 "It just works" 。 React 正在搞的 React Compiler (React Forget) ,其实是一条殊途同归的路:

  • Signal:运行时通过 Proxy 收集依赖,实现细粒度更新。
  • React Compiler:编译时分析代码,自动插入 memoization,模拟细粒度更新的效果。

五、 总结:架构师的选择

理解了原理,我们在架构设计中就能明白:

  1. Vue 3 / Solid: 适合高性能仪表盘、即时通讯、即时编辑类应用。因为它们对 CPU 的利用率极高,没有 VDOM 的 Overhead。

  2. React: 适合大型业务系统、生态依赖重的应用。虽然有一些性能损耗,但其编程模型的一致性(Pure Render)能降低逻辑复杂度。

  3. 趋势: 越来越多的状态管理库(MobX, Valtio, Preact Signals)允许你在 React 中使用 Signal。

    • 架构模式: 使用 Signal 管理频繁变化的局部状态(避免 React 顶层重渲染),使用 React Context 管理低频的全局状态

Next Step: 我们搞懂了前端“怎么存数据”(Redux/Atomic)和“怎么更新数据”(Signal)。 但还有一个最大的麻烦没解决:API 数据。 我们以前总是把后端返回的 JSON 也塞进 Redux 里,导致 Redux 变得臃肿不堪。这真的是对的吗? 下一节,我们将通过 React Query (TanStack Query) 来一场架构大扫除。 请看**《第三篇:分治——把 API 赶出 Redux:服务端状态 (Server State) 与客户端状态的架构分离》**。

Vue 权限控制实战:从前端到全局的精细化管理

引言

在现代前后端分离的 Web 应用中,权限管理(Permission Control) 已不再是“后端的专属问题”。
一个成熟的 Vue 项目,往往需要对 菜单、路由、按钮、接口调用 等多个层面进行前端权限管控。

如果没有完善的权限体系,应用就可能出现以下问题:

  • 普通用户访问管理页面;
  • 按钮级别的功能误操作;
  • 多角色系统(如管理员 / 审核员 / 普通用户)逻辑混乱;
  • 甚至前端被篡改导致安全漏洞。

本文将系统讲解 Vue 项目的权限管理机制,涵盖从设计思路、技术实现到工程最佳实践,帮助你构建一个高扩展、高安全性的权限控制体系


正文

🧩 一、问题定义与背景

1. 什么是前端权限控制?

前端权限控制是指在用户登录成功后,根据其角色或授权信息,动态控制:

  • 页面路由访问;
  • 菜单导航展示;
  • 按钮与组件渲染;
  • 接口请求校验。

典型应用场景:

  • SaaS后台管理系统(多角色权限结构);
  • 企业内部系统(分层审批流);
  • 平台运营端(租户隔离数据控制)。

2. Vue 中常见权限类型

权限类型 控制对象 技术实现
路由权限 页面级访问控制 动态路由 / 路由守卫
菜单权限 导航展示项 过滤菜单树
按钮权限 组件细粒度控制 自定义指令(v-permission)
数据权限 接口或字段访问 请求拦截或后端过滤

⚙️ 二、实现方案与技术细节

1. 后端返回权限结构

后端在用户登录后返回一份权限数据,常见格式为:

{
  "roles": ["admin"],
  "permissions": ["user:view", "user:edit", "order:list"]
}

2. 路由动态加载实现

Vue Router 提供了动态注册路由的能力,我们可以在登录时动态添加用户可访问的路由。

// permission.js

const allRoutes = [
  { path: '/dashboard', name: 'Dashboard', meta: { permission: 'dashboard:view' } },
  { path: '/user', name: 'User', meta: { permission: 'user:view' } },
  { path: '/user/edit', name: 'UserEdit', meta: { permission: 'user:edit' } }
]

export function filterRoutesByPermission(userPerms) {
  return allRoutes.filter(route => userPerms.includes(route.meta.permission))
}

在登录成功后:

import router from '@/router'
import { filterRoutesByPermission } from './permission'

const userPerms = ['dashboard:view', 'user:view']
const accessRoutes = filterRoutesByPermission(userPerms)
accessRoutes.forEach(route => router.addRoute(route))

💡 效果:
只有被授权的用户,才能访问定义在 router 中对应 meta.permission 的页面。


3. 菜单动态渲染

基于相同的权限结构,我们可以将菜单配置与路由信息结合:

// menuConfig.js
export const menuMap = [
  { name: 'Dashboard', path: '/dashboard', permission: 'dashboard:view' },
  { name: '用户列表', path: '/user', permission: 'user:view' },
  { name: '编辑用户', path: '/user/edit', permission: 'user:edit' }
]

// 过滤菜单
export function getVisibleMenus(perms) {
  return menuMap.filter(menu => perms.includes(menu.permission))
}

在模板中动态渲染菜单:

<template>
  <ul>
    <li v-for="item in visibleMenus" :key="item.path">
      <router-link :to="item.path">{{ item.name }}</router-link>
    </li>
  </ul>
</template>

<script setup>
import { getVisibleMenus } from '@/config/menuConfig'
import { useUserStore } from '@/store/user'

const user = useUserStore()
const visibleMenus = getVisibleMenus(user.permissions)
</script>

4. 按钮级权限控制(自定义指令)

在 Vue 3 中,我们可通过自定义指令实现按钮级权限控制。

// directives/permission.js
export default {
  mounted(el, binding) {
    const { value } = binding
    const userPerms = JSON.parse(localStorage.getItem('permissions') || '[]')
    
    if (value && !userPerms.includes(value)) {
      el.parentNode && el.parentNode.removeChild(el)
    }
  }
}

注册指令:

import { createApp } from 'vue'
import App from './App.vue'
import permission from './directives/permission'

const app = createApp(App)
app.directive('permission', permission)
app.mount('#app')

模板使用示例:

<button v-permission="'user:edit'">编辑用户</button>

👉 当用户没有 user:edit 权限时,该按钮将不会被渲染。


5. 接口与数据权限策略

在请求层面控制访问数据安全:

// axios 拦截器
axios.interceptors.request.use((config) => {
  const token = localStorage.getItem('token')
  if (token) config.headers.Authorization = `Bearer ${token}`
  return config
})

后端验证时再结合角色或租户ID过滤查询结果,形成“前后端协同保护”。


🔍 三、优缺点分析与最佳实践建议

优点 缺点
前端控制 响应快、体验好、可见即所得 安全依赖后端配合,易被篡改
后端控制 数据安全性高 增加接口设计复杂度
前后端协同 安全与体验兼顾 系统架构复杂度提升

💡 实战建议:

  1. 权限粒度由粗到细: 先实现路由级,再扩展到按钮级与数据级;
  2. 前后端统一权限标识码:user:vieworder:delete
  3. 封装统一权限校验函数: hasPermission(perms, code),便于复用;
  4. 在 CI/CD 中加入权限检查脚本,防止新增路由遗漏权限配置。

结论

在 Vue 项目中构建完善的权限体系,是前端架构成熟度的重要标志。
它不仅仅是“限制访问”,更是“清晰定义角色职责”的手段。

通过 动态路由加载 + 指令权限验证 + 后端协同管控,我们可以:

  • 提升系统的安全性与扩展性;
  • 降低维护成本;
  • 优化用户体验与操作感知。

未来,随着 Vue 与服务端协同框架(如 NestJS、GraphQL)的发展,权限管理将朝着**“策略引擎化(Policy Engine)”与“配置即规则化(Config-as-Policy)”**方向演进。


参考资料与拓展阅读

  1. Vue 官方文档:vuejs.org/guide
  2. Vue Router 动态路由指南:router.vuejs.org/
  3. JSON Web Token (JWT) 权限模型:jwt.io/
  4. RBAC 权限管理算法解析 – Martin Fowler
  5. Ant Design Pro + Vue 权限实现范例

💬 一句话总结:

权限系统是 Vue 项目的“安全大脑”。
没有权限控制的前端,就像一个没有门锁的房子 —— 漂亮,但不安全。

为什么ChatGPT能"打字"给你看?从Buffer理解AI流式输出

什么是Buffer?

Buffer(缓冲区)是计算机内存中用于临时存储数据的一块区域。想象一下你正在用杯子接水龙头的水:水龙头直接流到杯子里,如果水流太快,杯子可能会溢出。但如果你在中间放一个水壶(缓冲区),水先流到水壶里,再从水壶倒到杯子里,整个过程就更加可控了。

在JavaScript中,Buffer就是那个"水壶"——它帮助我们在处理二进制数据(如图片、音频、网络传输等)时更加高效和可控。

为什么需要Buffer?

1. 文本 vs 二进制

计算机中一切数据最终都以二进制形式存储,但我们在编程时通常处理的是文本(字符串)。当需要处理非文本数据时,就需要Buffer。

生活比喻:就像快递运输,文本数据就像明信片,内容直接可见;二进制数据就像密封的包裹,你需要专门的工具(Buffer)来查看和处理里面的内容。

2. 效率问题

直接操作二进制数据比操作字符串更高效,特别是在处理大量数据时。

HTML5中的Buffer操作

1. TextEncoder 和 TextDecoder

这是HTML5提供的编码/解码工具:

// 编码:将字符串转换为二进制数据
const encoder = new TextEncoder();
const myBuffer = encoder.encode('你好 HTML5');
console.log(myBuffer); // Uint8Array(10) [228, 189, 160, 229, 165, 189, 32, 72, 84, 77, ...]

// 解码:将二进制数据转换回字符串
const decoder = new TextDecoder();
const originalText = decoder.decode(myBuffer);
console.log(originalText); // "你好 HTML5"

注意:中文字符通常占用3个字节,英文字符占用1个字节,空格也是1个字节。

2. ArrayBuffer - 原始的二进制缓冲区

// 创建一个12字节的缓冲区(就像申请一块12格的内存空间)
const buffer = new ArrayBuffer(12);

// 但ArrayBuffer本身不能直接操作,需要视图(View)来读写

3. 视图(TypedArray)- 操作缓冲区的"眼镜"

ArrayBuffer就像一块空白画布,而TypedArray就是不同颜色的画笔:

const buffer = new ArrayBuffer(16); // 16字节的缓冲区

// 不同的视图类型,用不同的方式"看待"同一块内存
const uint8View = new Uint8Array(buffer);   // 视为8位无符号整数(0-255)
const uint16View = new Uint16Array(buffer); // 视为16位无符号整数
const int32View = new Int32Array(buffer);   // 视为32位有符号整数

// 使用Uint8Array视图操作数据
const view = new Uint8Array(buffer);
const encoder = new TextEncoder();
const data = encoder.encode('Hello');

for(let i = 0; i < data.length; i++) {
    view[i] = data[i]; // 将数据复制到缓冲区
}

实际应用场景

1. 流式数据处理(AI响应示例)

// 模拟AI流式输出
async function simulateAIStreaming() {
    const responses = ["思考", "中", "请", "稍", "候"];
    const buffer = new ArrayBuffer(100);
    const view = new Uint8Array(buffer);
    const decoder = new TextDecoder();
    
    let position = 0;
    
    for (const word of responses) {
        // 模拟网络延迟
        await new Promise(resolve => setTimeout(resolve, 500));
        
        // 将每个词编码并添加到缓冲区
        const encoded = new TextEncoder().encode(word);
        for (let i = 0; i < encoded.length; i++) {
            view[position++] = encoded[i];
        }
        
        // 实时解码已接收的部分
        const receivedSoFar = decoder.decode(view.slice(0, position));
        console.log(`已接收: ${receivedSoFar}`);
    }
}

// 这就是streaming:true的效果——边生成边显示

2. 文件处理

// 读取图片文件并获取其二进制数据
fileInput.addEventListener('change', async (event) => {
    const file = event.target.files[0];
    const buffer = await file.arrayBuffer(); // 获取文件的二进制数据
    
    // 现在可以操作这个buffer
    const view = new Uint8Array(buffer);
    console.log(`文件大小: ${buffer.byteLength} 字节`);
    console.log(`前10个字节: ${view.slice(0, 10)}`);
});

关键概念对比

概念 比喻 作用
ArrayBuffer 空白的内存空间 分配一块原始二进制内存
TypedArray 有刻度的量杯 以特定格式(如整数、浮点数)读取/写入数据
DataView 多功能测量工具 更灵活地读写不同格式的数据
TextEncoder 打包机 将文本打包成二进制
TextDecoder 拆包机 将二进制解包成文本

常见TypedArray类型

// 不同"眼镜"看同一数据的不同效果
const buffer = new ArrayBuffer(16);
const data = [1, 2, 3, 4];

// 使用Uint8Array:每个数字占1字节
const uint8 = new Uint8Array(buffer);
uint8.set(data);
console.log(uint8); // [1, 2, 3, 4, 0, 0, ...]

// 使用Uint16Array:每个数字占2字节
const uint16 = new Uint16Array(buffer);
console.log(uint16); // [513, 1027, 0, 0, ...] 
// 为什么是513?因为1+2*256=513(小端序存储)

性能优化技巧

  1. 复用Buffer:避免频繁创建和销毁Buffer
  2. 批量操作:使用set()方法而不是循环赋值
  3. 适当大小:不要分配过大的Buffer,会浪费内存
// 优化示例:批量操作
const source = new Uint8Array([1, 2, 3, 4, 5]);
const targetBuffer = new ArrayBuffer(10);
const targetView = new Uint8Array(targetBuffer);

// 好:批量复制
targetView.set(source);

// 不好:逐个复制
for (let i = 0; i < source.length; i++) {
    targetView[i] = source[i];
}

总结

Buffer是JavaScript处理二进制数据的核心工具,特别是在:

  • 网络通信(流式传输)
  • 文件操作(图片、音频处理)
  • 加密算法
  • 与WebGL、Web Audio等API交互

记住这个流程: 文本 → TextEncoder → 二进制 → ArrayBuffer → TypedArray操作 → TextDecoder → 文本

就像快递系统:商品(数据)被包装(编码)→ 运输(二进制传输)→ 拆包(解码)→ 使用。

掌握Buffer操作,你就打开了JavaScript处理二进制世界的大门!


延伸学习

  1. Blob对象:文件相关的二进制操作
  2. Streams API:更高级的流式数据处理
  3. WebSocket.binaryType:网络通信中的二进制传输
  4. Canvas图像数据处理:getImageData()返回的就是Uint8ClampedArray

前端毛玻璃组件的位置/尺寸动态变化产生的闪烁问题及解决方案

当 CSS backdrop-filter 遇上动态尺寸变化,一个肉眼看不见的 Bug 如何在录屏时暴露无遗?

前言

CSS backdrop-filter 是实现毛玻璃(Frosted Glass)效果的标准方案,被广泛应用于模态框、侧边栏、卡片等 UI 组件中。当毛玻璃元素的尺寸固定时,一切都很美好。但当你需要让毛玻璃背景动态变化尺寸(比如随着内容高度自动增长),一个诡异的问题就会出现:

正常使用时看着还行,但一旦用录屏软件录制GIF或视频,就会出现明显的闪烁。

这篇文章将深入分析这个问题的根本原因,以及如何优雅地解决它。

相关项目:


问题描述

典型场景

假设你有一个带毛玻璃效果的容器,它的高度需要根据内容动态变化:

.glass-container {
  backdrop-filter: blur(20px);
  -webkit-backdrop-filter: blur(20px);
  height: auto; /* 或者通过 JS 动态设置 */
  transition: height 0.1s;
}

问题现象

正常使用时的效果,即使动态改变该区域的位置和尺寸,肉眼看起来也比较流畅,毛玻璃背景随内容高度变化平滑过渡。但当使用 ShareX、OBS、Loom 或其他录屏软件录制时,毛玻璃区域在位置或高度变化过程中会出现不怎么规律的闪烁

GIF1.gif

关键特征:

特征 描述
闪烁规律 不规律,非固定频率
肉眼可见性 完全看不见(刷新率太快)
浏览器 Chrome、Edge 均受影响(Chromium 内核)
触发条件 元素尺寸变化时
对照实验 移除 backdrop-filter 后问题消失

根本原因分析

backdrop-filter 的工作原理

backdrop-filter 与普通的 filter 不同,它不是对元素本身应用滤镜,而是对元素背后的内容实时采样并应用滤镜。

┌─────────────────────────────────┐
│         页面背景内容             │
│    (文字、图片、其他元素)         │
│                                 │
│    ┌─────────────────────┐      │
│    │  backdrop-filter    │      │
│    │  blur(20px)         │ ←── 实时采样这个区域背后的像素并模糊
│    └─────────────────────┘      │
│                                 │
└─────────────────────────────────┘

尺寸变化时发生了什么

当带有 backdrop-filter 的元素尺寸改变时,浏览器需要:

  1. 重新计算采样区域 - 元素变大了,需要采样更多像素
  2. 重新应用模糊滤镜 - 对新的采样区域重新计算模糊
  3. 合成渲染结果 - 将模糊后的结果与其他图层合成

这个过程会产生中间状态——可能是还没模糊完成的帧,或者是尺寸计算还没同步的帧。

为什么肉眼看不见但录屏能捕获

现代显示器刷新率通常是 60Hz 或更高,人眼很难捕捉到持续时间仅几毫秒的中间状态。但录屏软件是逐帧捕获的,它会忠实地记录每一帧的状态,包括那些转瞬即逝的异常帧。

这就是为什么:

  • ✅ 肉眼看:完美流畅
  • ❌ 录屏看:明显闪烁

失败的尝试

在找到正确方案之前,我尝试了多种常见的性能优化方法,但都失败了。记录这些失败的尝试同样重要。

尝试 1:CSS 硬件加速

.glass-container {
  transform: translateZ(0);
  will-change: width, height, opacity;
  backface-visibility: hidden;
}

结果:❌ 失败 — 硬件加速只优化了合成阶段,但 backdrop-filter 的采样和模糊计算仍然需要执行。

尝试 2:使用 requestAnimationFrame 同步更新

const updateHeight = height => {
  requestAnimationFrame(() => {
    element.style.height = `${Math.round(height)}px`;
  });
};

const observer = new ResizeObserver(entries => {
  updateHeight(entries[0].contentRect.height);
});

结果:❌ 失败 — rAF 确保了更新在正确的时机发生,但无法阻止 backdrop-filter 本身的重计算。

尝试 3:绕过 React 渲染周期

如果是 React 项目,你可能会尝试用 ref 直接操作 DOM 来避免重渲染:

// 直接操作 CSS 变量,不触发 React 重渲染
backdropRef.current.style.setProperty('--height', `${height}px`);

结果:❌ 失败 — 问题不在 React,而在浏览器的渲染管线。


解决方案:双层裁剪结构

核心思想

唉,折腾半天还得是曲线救国——既然问题是 backdrop-filter 元素尺寸变化触发了模糊重算,那解决方案就是:

让带有 backdrop-filter 的元素永不改变尺寸。

实现方式

使用"外层裁剪容器 + 内层固定尺寸"的双层结构:

<!-- 外层:负责动态尺寸,设置 overflow: hidden 裁剪 -->
<div class="glass-clipper">
  <!-- 内层:固定大尺寸,应用 backdrop-filter -->
  <div class="glass-inner"></div>
</div>

CSS 实现

/* 外层裁剪容器 */
.glass-clipper {
  position: relative;
  height: var(--dynamic-height, 200px); /* 动态变化 */
  overflow: hidden; /* 关键:裁剪超出部分 */
  border-radius: 16px;
  border: 1px solid rgba(255, 255, 255, 0.3);

  /* ⚠️ 重要:不要使用以下属性 */
  /* transform: translateZ(0); */
  /* will-change: ...; */
}

/* 内层毛玻璃 */
.glass-inner {
  position: absolute;
  width: 1000px; /* 固定大尺寸,覆盖所有可能的显示区域 */
  height: 1000px;
  bottom: 0;
  right: 0; /* 根据你的布局调整锚点 */

  background: rgba(255, 255, 255, 0.1);
  backdrop-filter: blur(20px) saturate(180%);
  -webkit-backdrop-filter: blur(20px) saturate(180%);
}

工作原理图解

┌─────────────────────────────────────────┐
│           外层容器 (动态高度)            │
│           overflow: hidden              │
│  ┌─────────────────────────────────┐    │
│  │                                 │    │
│  │         可见区域                 │    │  ← 用户看到的区域
│  │                                 │    │
│  │   ┌──────────────────────────┐  │    │
│  │   │                          │  │    │
│  │   │    内层 (1000x1000)      │  │    │
│  │   │    backdrop-filter       │  │    │
│  │   │                          │  │    │
│  └───│──────────────────────────│──┘    │
│      │                          │       │
│      │   (被裁剪隐藏的部分)      │       │
│      │                          │       │
│      └──────────────────────────┘       │
└─────────────────────────────────────────┘

当高度增加时:
✅ 外层容器高度增加
✅ 更多内层区域变得可见
❌ 内层尺寸不变 → backdrop-filter 不重算

重要注意事项

1. 边框必须放在外层

如果把边框放在内层,会出现边框不完整的问题(部分边框被裁掉):

[IMAGE_PLACEHOLDER: border-issue.png]
备注:如果边框放在内层,会出现上下左右边框不一致的问题。

2. 外层不能使用 transform/will-change

这是最容易踩的坑。以下属性会创建隔离的层叠上下文 (Stacking Context):

  • transform: translateZ(0) / transform: translate3d(...)
  • will-change: transform / will-change: opacity
  • filter: ...
  • isolation: isolate

当外层创建了隔离的层叠上下文,内层的 backdrop-filter 就只能"看到"外层容器的背景,而看不到页面上真正的背景内容,毛玻璃效果会完全失效

chrome_RXSOrpd2Nl.png

chrome_R0yzXsL8ZM.png

3. 内层尺寸要足够大

内层的固定尺寸需要覆盖外层可能达到的最大尺寸。如果内层太小,当外层扩展超过内层时,会出现毛玻璃覆盖不全的问题。

4. 锚点位置根据布局调整

示例中使用 bottom: 0; right: 0; 将内层锚定到右下角。根据你的实际布局,可能需要调整为:

  • top: 0; left: 0; - 锚定左上角
  • top: 0; right: 0; - 锚定右上角
  • 等等

最终效果

GIF2.gif

总结

问题本质

项目 说明
根本原因 backdrop-filter 在元素尺寸变化时需要重新采样和计算模糊
可见性 中间状态持续时间极短,肉眼不可见,但录屏可捕获
影响范围 Chromium 内核浏览器(Chrome、Edge、Opera 等)

解决方案

[外层容器 - 动态尺寸, overflow:hidden]
  └── [内层 - 固定大尺寸, backdrop-filter]

调试 Checklist

如果你遇到了类似问题,按以下步骤排查:

  • 确认触发条件:临时移除 backdrop-filter,闪烁是否消失?
  • 检查尺寸变化:元素尺寸是否在动态变化?如果固定,可能是其他问题
  • 检查层叠上下文:外层是否有 transformwill-changefilter 等属性?
  • 尝试双层结构:使用本文的外层裁剪 + 内层固定方案

适用场景

  • ✅ 高度/宽度动态变化的毛玻璃容器
  • ✅ 内容驱动尺寸的对话框、侧边栏
  • ✅ 带 backdrop-filter 的可折叠/展开组件
  • ❌ 固定尺寸和位置的毛玻璃元素(不需要此方案)

参考资料


如果这篇文章对你有帮助,欢迎分享给其他开发者,一起避坑!

micro-app 微前端项目部署指南

部署指南

本文档介绍如何将 micro-app 微前端项目部署到生产环境。

目录


部署方式

micro-app 微前端项目支持两种部署方式:

  1. 同域部署:主应用和所有子应用部署在同一域名下

    • 优点:无需配置 CORS,部署简单
    • 缺点:所有应用必须部署在同一服务器
  2. 跨域部署:主应用和子应用部署在不同域名

    • 优点:可以独立部署和扩展
    • 缺点:需要配置 CORS,配置相对复杂

同域部署

目录结构

/usr/share/nginx/html/
├── main-app/
│   └── dist/          # 主应用构建产物
├── sub-app-1/
│   └── dist/          # 子应用 1 构建产物
├── sub-app-2/
│   └── dist/          # 子应用 2 构建产物
└── sub-app-3/
    └── dist/          # 子应用 3 构建产物

URL 映射

https://example.com/              → 主应用
https://example.com/sub-app-1/    → 子应用 1
https://example.com/sub-app-2/    → 子应用 2
https://example.com/sub-app-3/    → 子应用 3

Nginx 配置

使用 nginx/main-app.conf 配置文件:

# 复制配置文件
sudo cp nginx/main-app.conf /etc/nginx/sites-available/micro-app

# 创建符号链接
sudo ln -s /etc/nginx/sites-available/micro-app /etc/nginx/sites-enabled/

# 修改配置中的路径和域名
sudo nano /etc/nginx/sites-available/micro-app

# 测试配置
sudo nginx -t

# 重载配置
sudo nginx -s reload

环境变量配置

主应用 .env.production:

# 同域部署不需要配置,自动使用 window.location.origin
# VITE_DEPLOY_MODE=same-origin  # 默认值,可省略

跨域部署

目录结构

# 主应用服务器
/usr/share/nginx/html/main-app/dist/

# 子应用 1 服务器
/usr/share/nginx/html/sub-app-1/dist/

# 子应用 2 服务器
/usr/share/nginx/html/sub-app-2/dist/

# 子应用 3 服务器
/usr/share/nginx/html/sub-app-3/dist/

URL 映射

https://main.example.com/        → 主应用
https://sub1.example.com/         → 子应用 1
https://sub2.example.com/         → 子应用 2
https://sub3.example.com/         → 子应用 3

Nginx 配置

主应用服务器:使用 nginx/main-app.conf(仅配置主应用部分)

子应用服务器:每个子应用使用 nginx/sub-app.conf

# 为每个子应用复制配置文件
sudo cp nginx/sub-app.conf /etc/nginx/sites-available/sub-app-1
sudo cp nginx/sub-app.conf /etc/nginx/sites-available/sub-app-2
sudo cp nginx/sub-app.conf /etc/nginx/sites-available/sub-app-3

# 创建符号链接
sudo ln -s /etc/nginx/sites-available/sub-app-1 /etc/nginx/sites-enabled/
sudo ln -s /etc/nginx/sites-available/sub-app-2 /etc/nginx/sites-enabled/
sudo ln -s /etc/nginx/sites-available/sub-app-3 /etc/nginx/sites-enabled/

# 修改每个配置文件中的 server_name 和路径
sudo nano /etc/nginx/sites-available/sub-app-1
sudo nano /etc/nginx/sites-available/sub-app-2
sudo nano /etc/nginx/sites-available/sub-app-3

# 测试配置
sudo nginx -t

# 重载配置
sudo nginx -s reload

重要:子应用必须配置 CORS 头,否则 micro-app 无法加载。

环境变量配置

主应用 .env.production:

# 跨域部署模式
VITE_DEPLOY_MODE=cross-origin

# 子应用入口地址
VITE_SUB_APP_1_ENTRY=https://sub1.example.com
VITE_SUB_APP_2_ENTRY=https://sub2.example.com
VITE_SUB_APP_3_ENTRY=https://sub3.example.com

Nginx 配置

主应用配置

参考 nginx/main-app.conf,主要配置:

  1. 根路径:主应用部署在根路径 /
  2. 子应用路径:每个子应用部署在 /sub-app-X/ 路径下
  3. 路由回退:使用 try_files 确保 SPA 路由正常工作
  4. 静态资源缓存:配置长期缓存策略

子应用配置

参考 nginx/sub-app.conf,主要配置:

  1. CORS 头:必须配置,micro-app 需要跨域支持
  2. OPTIONS 预检:处理跨域预检请求
  3. 路由回退:使用 try_files 确保 SPA 路由正常工作
  4. 静态资源缓存:配置长期缓存策略

关键配置说明

CORS 配置(子应用必须):

add_header Access-Control-Allow-Origin * always;
add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS, PUT, DELETE' always;
add_header Access-Control-Allow-Headers 'Content-Type, Authorization, X-Requested-With' always;

路由回退(SPA 必需):

location / {
    try_files $uri $uri/ /index.html;
}

静态资源缓存:

location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
    expires 1y;
    add_header Cache-Control "public, immutable";
}

环境变量配置

开发环境

主应用 .env.development:

# 通常不需要修改,使用默认的 localhost 地址

子应用 .env.development:

# 根据实际需求配置
# VITE_API_BASE_URL=http://localhost:8080/api

生产环境

同域部署:

主应用 .env.production:

# 不需要配置,自动使用 window.location.origin

跨域部署:

主应用 .env.production:

VITE_DEPLOY_MODE=cross-origin
VITE_SUB_APP_1_ENTRY=https://sub1.example.com
VITE_SUB_APP_2_ENTRY=https://sub2.example.com
VITE_SUB_APP_3_ENTRY=https://sub3.example.com

子应用 .env.production:

# 根据实际需求配置
# VITE_API_BASE_URL=https://api.example.com

构建和部署步骤

1. 构建所有应用

# 在项目根目录执行
pnpm run build

构建产物会生成在:

  • main-app/dist/
  • sub-app-1/dist/
  • sub-app-2/dist/
  • sub-app-3/dist/

2. 配置环境变量

根据部署方式配置 .env.production 文件(见上文)。

3. 上传构建产物

同域部署:

# 上传所有构建产物到同一服务器
scp -r main-app/dist/* user@server:/usr/share/nginx/html/main-app/dist/
scp -r sub-app-1/dist/* user@server:/usr/share/nginx/html/sub-app-1/dist/
scp -r sub-app-2/dist/* user@server:/usr/share/nginx/html/sub-app-2/dist/
scp -r sub-app-3/dist/* user@server:/usr/share/nginx/html/sub-app-3/dist/

跨域部署:

# 主应用
scp -r main-app/dist/* user@main-server:/usr/share/nginx/html/main-app/dist/

# 子应用 1
scp -r sub-app-1/dist/* user@sub1-server:/usr/share/nginx/html/sub-app-1/dist/

# 子应用 2
scp -r sub-app-2/dist/* user@sub2-server:/usr/share/nginx/html/sub-app-2/dist/

# 子应用 3
scp -r sub-app-3/dist/* user@sub3-server:/usr/share/nginx/html/sub-app-3/dist/

4. 配置 Nginx

参考 Nginx 配置 部分。

5. 测试部署

  1. 访问主应用:https://example.com
  2. 检查子应用加载是否正常
  3. 检查路由跳转是否正常
  4. 检查数据通信是否正常
  5. 检查浏览器控制台是否有错误

常见问题

Q1: 子应用加载失败,显示 CORS 错误?

A: 检查子应用的 Nginx 配置是否包含 CORS 头:

add_header Access-Control-Allow-Origin * always;
add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS' always;

Q2: 路由跳转后显示 404?

A: 检查 Nginx 配置是否包含路由回退:

location / {
    try_files $uri $uri/ /index.html;
}

Q3: 静态资源加载失败?

A: 检查:

  1. 资源路径是否正确
  2. Nginx 配置中的 alias 路径是否正确
  3. 文件权限是否正确

Q4: 生产环境子应用地址不正确?

A: 检查主应用的 .env.production 配置:

# 跨域部署
VITE_DEPLOY_MODE=cross-origin
VITE_SUB_APP_1_ENTRY=https://sub1.example.com

Q5: 如何启用 HTTPS?

A: 参考 Nginx 配置文件中的 HTTPS 配置示例:

  1. 获取 SSL 证书
  2. 配置证书路径
  3. 启用 HTTPS server 块
  4. 配置 HTTP 重定向到 HTTPS

Q6: 如何配置 CDN?

A: 修改环境变量中的入口地址为 CDN 地址:

VITE_SUB_APP_1_ENTRY=https://cdn.example.com/sub-app-1

从零到一:基于 micro-app 的企业级微前端模板完整实现指南

本文是一篇完整的技术实践文章,记录了如何从零开始构建一个企业级 micro-app 微前端模板项目。文章包含完整的技术选型、架构设计、核心代码实现、踩坑经验以及最佳实践,适合有一定前端基础的开发者深入学习。

📋 文章摘要

本文详细记录了基于 micro-app 框架构建企业级微前端模板的完整实现过程。项目采用 Vue 3 + TypeScript + Vite 技术栈,实现了完整的主子应用通信、路由同步、独立运行等核心功能。文章不仅包含技术选型分析、架构设计思路,还提供了大量可直接使用的代码示例和实战经验,帮助读者快速掌握微前端开发的核心技能。

🎯 你将学到什么

  • ✅ micro-app 框架的核心特性和使用技巧
  • ✅ 微前端架构设计思路和最佳实践
  • ✅ 主子应用双向通信的完整实现方案
  • ✅ 路由同步和跨应用导航的实现细节
  • ✅ TypeScript 类型安全的微前端开发实践
  • ✅ 事件总线解耦和代码组织技巧
  • ✅ 开发/生产环境配置管理方案
  • ✅ 常见问题的解决方案和踩坑经验

💎 项目亮点

  • 🚀 开箱即用:完整的项目模板,可直接用于生产环境
  • 🔒 类型安全:完整的 TypeScript 类型定义,零 @ts-ignore,零 any
  • 🎨 企业级实践:可支撑真实企业项目
  • 📦 独立运行:子应用支持独立开发和调试
  • 🔄 智能通信:策略模式处理不同类型事件,代码清晰易维护
  • 🛠️ 一键启动:并行启动所有应用,提升开发效率

🎉 开源地址

micro-app-front-end


📑 目录


一、项目背景与需求分析

1.1 为什么选择微前端?

随着前端应用规模的不断增长,传统的单体应用架构面临诸多挑战:

  • 团队协作困难:多个团队维护同一个代码库,容易产生冲突
  • 技术栈限制:难以引入新技术,升级成本高
  • 部署效率低:任何小改动都需要整体发布
  • 性能问题:应用体积过大,首屏加载慢

微前端架构通过将大型应用拆分为多个独立的小应用,每个应用可以独立开发、测试、部署,有效解决了上述问题。

1.2 项目需求

基于企业级微前端项目实践,我们需要构建一个开箱即用的 micro-app 微前端模板,具备以下核心特性:

  1. 完整的通信机制:主子应用之间的双向数据通信,支持多种事件类型
  2. 路由同步:自动处理路由同步,支持浏览器前进后退,用户体验流畅
  3. 独立运行:子应用支持独立开发和调试,提升开发效率
  4. 类型安全:完整的 TypeScript 类型定义,避免运行时错误
  5. 环境适配:支持开发/生产环境,同域/跨域部署
  6. 错误处理:完善的错误处理和降级方案,提高系统稳定性

1.3 项目目标

  • ✅ 提供可直接用于生产环境的完整模板
  • ✅ 代码结构清晰,易于维护和扩展
  • ✅ 完整的文档和最佳实践指南
  • ✅ 解决常见问题,避免重复踩坑

技术选型

微前端框架: micro-app

选择理由:

  1. 基于 WebComponent: 天然实现样式隔离
  2. 原生路由模式: 子应用使用 createWebHistory,框架自动劫持路由
  3. 内置通信机制: 无需额外配置,开箱即用
  4. 轻量级: 相比 qiankun 更轻量,性能更好

版本: @micro-zoe/micro-app@1.0.0-rc.28

前端框架: Vue 3 + TypeScript

选择理由:

  1. 组合式 API: 更好的逻辑复用和类型推导
  2. TypeScript 支持: 完整的类型安全
  3. 生态成熟: 丰富的插件和工具链

构建工具: Vite

选择理由:

  1. 极速开发体验: HMR 速度快
  2. 原生 ES 模块: 更好的开发体验
  3. 配置简单: 开箱即用

注意: Vite 作为子应用时,必须使用 iframe 沙箱模式


三、架构设计详解

3.1 项目结构

micro-app/
├── main-app/              # 主应用(基座应用)
│   ├── src/
│   │   ├── components/    # 组件目录
│   │   │   └── MicroAppContainer.vue  # 子应用容器组件
│   │   ├── config/        # 配置文件
│   │   │   └── microApps.ts  # 子应用配置管理
│   │   ├── router/        # 路由配置
│   │   ├── types/         # TypeScript 类型定义
│   │   │   └── micro-app.ts  # 微前端相关类型
│   │   ├── utils/         # 工具函数
│   │   │   ├── microAppCommunication.ts  # 通信工具
│   │   │   └── microAppEventBus.ts  # 事件总线
│   │   ├── views/         # 页面组件
│   │   ├── App.vue        # 根组件
│   │   └── main.ts        # 入口文件
│   ├── vite.config.ts     # Vite 配置
│   └── package.json
│
├── sub-app-1/             # 子应用 1
│   ├── src/
│   │   ├── plugins/       # 插件目录
│   │   │   └── micro-app.ts  # MicroAppService 通信服务
│   │   ├── router/        # 路由配置
│   │   ├── utils/         # 工具函数
│   │   │   ├── env.ts     # 环境检测
│   │   │   └── navigation.ts  # 导航工具
│   │   ├── types/         # 类型定义
│   │   ├── views/         # 页面组件
│   │   ├── App.vue        # 根组件
│   │   └── main.ts        # 入口文件(支持独立运行)
│   ├── vite.config.ts     # Vite 配置
│   └── package.json
│
├── sub-app-2/             # 子应用 2(结构同 sub-app-1)
├── sub-app-3/             # 子应用 3(结构同 sub-app-1)
├── docs/                  # 文档目录
│   ├── TROUBLESHOOTING.md  # 踩坑记录
│   ├── IMPLEMENTATION_LOG.md  # 实现过程记录
│   └── FAQ.md             # 常见问题
├── package.json           # 根目录配置(一键启动脚本)
└── README.md              # 项目说明文档

3.2 核心模块设计

3.2.1 主应用通信模块 (microAppCommunication.ts)

设计思路

主应用通信模块是整个微前端架构的核心,负责主子应用之间的数据通信。我们采用策略模式处理不同类型的事件,使代码结构清晰、易于扩展。

核心功能

  1. 向子应用发送数据microAppSetData()

    • 自动添加时间戳,确保数据变化被检测
    • 自动添加来源标识,便于调试
  2. 跨应用路由跳转microAppTarget()

    • 智能区分同应用内跳转和跨应用跳转
    • 同应用内:通过通信让子应用自己跳转
    • 跨应用:通过主应用路由跳转
  3. 统一的数据监听处理器microAppDataListener()

    • 使用策略模式处理不同类型的事件
    • 支持扩展新的事件类型

代码示例

/**
 * 向指定子应用发送数据
 * 自动添加时间戳,确保数据变化被检测到
 */
export const microAppSetData = (name: string, data: Partial<MicroData>): void => {
  const targetName = name || getServiceName();

  if (!targetName) {
    devWarn("无法发送数据:未指定子应用名称");
    return;
  }

  // 自动添加时间戳,确保数据变化
  const dataWithTimestamp: MicroData = {
    ...data,
    t: Date.now(),
    source: getServiceName(),
  };

  try {
    microApp.setData(targetName, dataWithTimestamp);
    devLog(`向 ${targetName} 发送数据`, dataWithTimestamp);
  } catch (error) {
    console.error(`[主应用通信] 发送数据失败:`, error);
  }
};

💡 完整代码:代码仓库中包含完整的通信模块实现,包含所有事件类型的处理逻辑。

3.2.2 事件总线模块 (microAppEventBus.ts)

设计思路

事件总线模块用于解耦生命周期钩子和业务逻辑。当子应用生命周期发生变化时,通过事件总线通知业务代码,而不是直接在生命周期钩子中处理业务逻辑。

核心特性

  • ✅ 支持一次性监听 (once)
  • ✅ 支持静默模式(避免无监听器警告)
  • ✅ 完整的 TypeScript 类型定义
  • ✅ 支持移除监听器

代码示例

/**
 * 事件总线类
 * 用于解耦生命周期钩子和业务逻辑
 */
class EventBus {
  private listeners: Map<string, EventListener[]> = new Map();
  private silent: boolean = true; // 默认静默模式

  /**
   * 监听事件
   */
  on<T = any>(event: string, callback: EventCallback<T>, once = false): void {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, []);
    }

    this.listeners.get(event)!.push({ callback, once });
  }

  /**
   * 触发事件
   */
  emit<T = any>(event: string, data: T): void {
    const listeners = this.listeners.get(event);

    if (!listeners || listeners.length === 0) {
      if (!this.silent) {
        devWarn(`事件 ${event} 没有监听器`);
      }
      return;
    }

    // 执行监听器,并移除一次性监听器
    listeners.forEach((listener, index) => {
      listener.callback(data);
      if (listener.once) {
        listeners.splice(index, 1);
      }
    });
  }
}

💡 完整实现:代码仓库中包含完整的事件总线实现,包含所有方法和类型定义。

3.2.3 子应用通信服务 (MicroAppService)

设计思路

子应用通信服务是一个类,负责初始化数据监听器、处理主应用发送的数据、向主应用发送数据以及清理资源。采用策略模式处理不同类型的事件,使代码结构清晰。

核心方法

  1. init(): 初始化数据监听器
  2. handleData(): 处理主应用数据(策略模式)
  3. sendData(): 向主应用发送数据
  4. destroy(): 清理监听器

代码示例

/**
 * 子应用通信服务类
 */
export class MicroAppService {
  private serviceName: string;
  private dataListener: ((data: MicroData) => void) | null = null;

  constructor() {
    this.serviceName = getServiceName();
    this.init();
  }

  /**
   * 初始化数据监听器
   */
  public init(): void {
    if (!isMicroAppEnvironment()) {
      return;
    }

    const microApp = (window as any).microApp;
    if (!microApp) {
      return;
    }

    // 创建数据监听器
    this.dataListener = (data: MicroData) => {
      this.handleData(data);
    };

    // 添加数据监听器
    microApp.addDataListener(this.dataListener);
  }

  /**
   * 处理主应用发送的数据(策略模式)
   */
  private handleData(data: MicroData): void {
    switch (data.type) {
      case "target":
        // 处理路由跳转
        break;
      case "menuCollapse":
        // 处理菜单折叠
        break;
      // ... 其他事件类型
    }
  }
}

💡 完整实现:代码仓库中包含完整的 MicroAppService 实现,包含所有事件类型的处理逻辑。


四、核心功能实现

4.1 主应用通信模块

4.1.1 数据发送功能

主应用向子应用发送数据时,需要自动添加时间戳,确保 micro-app 能检测到数据变化:

/**
 * 向指定子应用发送数据
 */
export const microAppSetData = (name: string, data: Partial<MicroData>): void => {
  const dataWithTimestamp: MicroData = {
    ...data,
    t: Date.now(),  // 自动添加时间戳
    source: getServiceName(),  // 自动添加来源
  };

  try {
    microApp.setData(name, dataWithTimestamp);
  } catch (error) {
    console.error(`[主应用通信] 发送数据失败:`, error);
  }
};

4.1.2 跨应用路由跳转

智能区分同应用内跳转和跨应用跳转,提供更好的用户体验:

/**
 * 跨应用路由跳转
 */
export const microAppTarget = (
  service: string,
  url: string
): void => {
  const currentService = getServiceName();

  // 同应用内:通过通信让子应用自己跳转
  if (currentService === service) {
    microAppSetData(service, { type: "target", path: url });
    return;
  }

  // 跨应用:通过主应用路由跳转
  const routeMapping = routeMappings.find((m) => m.appName === service);
  if (routeMapping) {
    const fullPath = `${routeMapping.basePath}${url}`;
    router.push(fullPath).catch((error) => {
      console.error(`[主应用通信] 路由跳转失败:`, error);
    });
  }
};

4.1.3 统一的数据监听处理器

使用策略模式处理不同类型的事件,代码结构清晰、易于扩展:

/**
 * 统一的数据监听处理器(策略模式)
 */
export const microAppDataListener = (params: DataListenerParams): void => {
  const { service, data } = params;

  if (!data || !data.type) {
    return;
  }

  // 使用策略模式处理不同类型的事件
  const eventHandlers: Record<MicroAppEventType, (data: MicroData) => void> = {
    target: (eventData) => {
      // 处理路由跳转
      const targetService = eventData.service || eventData.data?.service;
      const targetUrl = eventData.url || eventData.path || "";
      if (targetService && targetUrl) {
        microAppTarget(targetService, targetUrl);
      }
    },
    navigate: (eventData) => {
      // 处理跨应用导航
      // ...
    },
    logout: () => {
      // 处理退出登录
      // ...
    },
    // ... 其他事件类型
  };

  const handler = eventHandlers[data.type];
  if (handler) {
    handler(data);
  }
};

4.2 事件总线模块

事件总线用于解耦生命周期钩子和业务逻辑,使代码更易维护:

/**
 * 监听生命周期事件
 */
export const onLifecycle = (
  event: LifecycleEventType,
  callback: (data: LifecycleEventData) => void,
  once = false
): void => {
  eventBus.on(event, callback, once);
};

/**
 * 触发生命周期事件
 */
export const emitLifecycle = (
  event: LifecycleEventType,
  data: LifecycleEventData
): void => {
  eventBus.emit(event, data);
};

4.3 子应用通信服务

子应用通过 MicroAppService 类管理通信逻辑:

/**
 * 子应用通信服务类
 */
export class MicroAppService {
  private serviceName: string;
  private dataListener: ((data: MicroData) => void) | null = null;

  constructor() {
    this.serviceName = getServiceName();
    this.init();
  }

  /**
   * 处理主应用发送的数据(策略模式)
   */
  private handleData(data: MicroData): void {
    switch (data.type) {
      case "target": {
        // 路由跳转
        const path = data.path || data.data?.path || "";
        if (path && path !== router.currentRoute.value.path) {
          router.push(path);
        }
        break;
      }
      case "menuCollapse": {
        // 菜单折叠
        const collapse = data.data?.collapse ?? false;
        // 处理菜单折叠逻辑
        break;
      }
      // ... 其他事件类型
    }
  }

  /**
   * 向主应用发送数据
   */
  public sendData(data: Partial<MicroData>): void {
    if (!isMicroAppEnvironment()) {
      return;
    }

    const microApp = (window as any).microApp;
    if (!microApp || typeof microApp.dispatch !== "function") {
      return;
    }

    const dataWithTimestamp: MicroData = {
      ...data,
      t: Date.now(),
      source: this.serviceName,
    };

    microApp.dispatch(dataWithTimestamp);
  }

  /**
   * 清理监听器
   */
  public destroy(): void {
    if (this.dataListener) {
      const microApp = (window as any).microApp;
      if (microApp && typeof microApp.removeDataListener === "function") {
        microApp.removeDataListener(this.dataListener);
      }
      this.dataListener = null;
    }
  }
}

💡 完整代码:代码仓库中包含所有核心模块的完整实现,包含详细的注释和类型定义。


五、关键实现细节

5.1 类型安全优先

决策背景

在微前端项目中,类型安全尤为重要。主子应用之间的通信如果没有类型约束,很容易出现运行时错误。

实现方案

1. 全局类型声明

window.microApp 添加全局类型声明:

// types/micro-app.d.ts
declare global {
  interface Window {
    microApp?: {
      setData: (name: string, data: any) => void;
      getData: () => any;
      addDataListener: (listener: (data: any) => void) => void;
      removeDataListener: (listener: (data: any) => void) => void;
      dispatch: (data: any) => void;
    };
    __MICRO_APP_ENVIRONMENT__?: boolean;
    __MICRO_APP_BASE_ROUTE__?: string;
    __MICRO_APP_NAME__?: string;
  }
}

2. 完整的类型定义

定义所有通信数据的类型:

/**
 * 微前端通信数据类型
 */
export interface MicroData {
  /** 事件类型(必填) */
  type: MicroAppEventType;
  /** 事件数据 */
  data?: Record<string, any>;
  /** 时间戳(确保数据变化) */
  t?: number;
  /** 来源应用 */
  source?: string;
  /** 路径(用于路由跳转) */
  path?: string;
  /** 目标服务(用于跨应用跳转) */
  service?: string;
  /** 目标URL(用于跨应用跳转) */
  url?: string;
}

3. 类型守卫

使用类型守卫确保类型安全:

function isMicroAppEnvironment(): boolean {
  return !!window.__MICRO_APP_ENVIRONMENT__;
}

收益

  • ✅ 更好的 IDE 提示和自动补全
  • ✅ 编译时错误检查,避免运行时错误
  • ✅ 代码可维护性显著提升
  • ✅ 重构更安全,类型系统会提示所有需要修改的地方

5.2 事件总线解耦

决策背景

在微前端项目中,子应用的生命周期钩子需要触发各种业务逻辑。如果直接在生命周期钩子中处理业务逻辑,会导致代码耦合度高,难以维护。

实现方案

1. 事件总线设计

class EventBus {
  private listeners: Map<string, EventListener[]> = new Map();
  private silent: boolean = true;

  on<T = any>(event: string, callback: EventCallback<T>, once = false): void {
    // 添加监听器
  }

  emit<T = any>(event: string, data: T): void {
    // 触发事件
  }

  off(event: string, callback?: EventCallback): void {
    // 移除监听器
  }
}

2. 生命周期钩子触发事件

// 生命周期钩子中触发事件
const onMounted = () => {
  emitLifecycle("mounted", { name: props.name });
};

3. 业务代码监听事件

// 业务代码中监听事件
onLifecycle("mounted", (data) => {
  // 处理业务逻辑
  console.log(`子应用 ${data.name} 已挂载`);
});

收益

  • ✅ 代码解耦,生命周期钩子和业务逻辑分离
  • ✅ 业务逻辑可以独立测试
  • ✅ 支持多个监听器,扩展性强
  • ✅ 代码结构清晰,易于维护

5.3 日志系统优化

决策背景

在开发环境中,详细的日志有助于调试。但在生产环境中,过多的日志会影响性能,还可能泄露敏感信息。

实现方案

const isDev = import.meta.env.DEV;

/**
 * 开发环境日志输出
 */
const devLog = (message: string, ...args: any[]) => {
  if (isDev) {
    console.log(`%c[标签] ${message}`, "color: #1890ff", ...args);
  }
};

/**
 * 开发环境警告输出
 */
const devWarn = (message: string, ...args: any[]) => {
  if (isDev) {
    console.warn(`%c[标签] ${message}`, "color: #faad14", ...args);
  }
};

/**
 * 错误日志(始终输出)
 */
const errorLog = (message: string, ...args: any[]) => {
  console.error(`[标签] ${message}`, ...args);
};

收益

  • ✅ 生产环境性能更好,无日志开销
  • ✅ 开发环境调试更方便,彩色日志易于识别
  • ✅ 避免敏感信息泄露
  • ✅ 错误日志始终输出,便于问题排查

5.4 Vite 子应用 iframe 沙箱

决策背景

根据 micro-app 官方文档,Vite 作为子应用时,必须使用 iframe 沙箱模式,否则会出现脚本执行错误。

实现方案

<micro-app
  :name="name"
  :url="url"
  router-mode="native"
  iframe  <!-- 必须添加此属性 -->
/>

收益

  • ✅ 解决 Vite 开发脚本执行错误
  • ✅ 更好的隔离性,样式和脚本完全隔离
  • ✅ 符合官方最佳实践

六、最佳实践与优化

6.1 统一配置管理

使用配置文件统一管理所有子应用地址,支持环境感知:

// config/microApps.ts
const envConfigs: Record<string, EnvConfig> = {
  "sub-app-1": {
    dev: "http://localhost:3000",
    prod: "//your-domain.com/sub-app-1",
    envKey: "VITE_SUB_APP_1_ENTRY",
  },
  // ...
};

export function getEntry(appName: string): string {
  const config = envConfigs[appName];

  // 优先级 1: 环境变量覆盖
  if (import.meta.env[config.envKey]) {
    return import.meta.env[config.envKey];
  }

  // 优先级 2: 根据环境选择配置
  if (import.meta.env.DEV) {
    return config.dev;
  }

  // 生产环境根据部署模式选择
  const deployMode = import.meta.env.VITE_DEPLOY_MODE || "same-origin";
  return deployMode === "same-origin"
    ? `${window.location.origin}/${appName}`
    : config.prod;
}

优势

  • ✅ 配置集中管理,易于维护
  • ✅ 自动适配开发/生产环境
  • ✅ 支持环境变量覆盖
  • ✅ 支持同域/跨域部署

6.2 自动添加时间戳

发送数据时自动添加时间戳,确保 micro-app 能检测到数据变化:

const dataWithTimestamp: MicroData = {
  ...data,
  t: Date.now(),  // 自动添加时间戳
  source: getServiceName(),  // 自动添加来源
};

优势

  • ✅ 确保 micro-app 能检测到数据变化
  • ✅ 避免数据未更新的问题
  • ✅ 便于调试,可以看到数据来源

6.3 智能路由跳转

区分同应用内跳转和跨应用跳转,提供更好的用户体验:

// 同应用内:通过通信让子应用自己跳转
// 跨应用:通过主应用路由跳转
if (currentService === service) {
  microAppSetData(service, { type: "target", path: url });
} else {
  router.push(fullPath);
}

优势

  • ✅ 避免路由记录混乱
  • ✅ 更好的用户体验
  • ✅ 支持浏览器前进后退

6.4 完善的错误处理

所有关键操作都使用 try-catch,提供降级方案:

try {
  microApp.setData(targetName, dataWithTimestamp);
} catch (error) {
  console.error(`[主应用通信] 发送数据失败:`, error);
  // 降级处理
}

优势

  • ✅ 提高系统稳定性
  • ✅ 更好的错误提示
  • ✅ 便于问题排查

6.5 子应用独立运行

子应用支持独立运行,便于开发调试:

// main.ts
if (!isMicroAppEnvironment()) {
  // 独立运行时直接挂载
  render();
} else {
  // 微前端环境导出生命周期函数
  window.mount = () => render();
  window.unmount = () => app.unmount();
}

优势

  • ✅ 提升开发效率
  • ✅ 便于独立调试
  • ✅ 支持独立部署

七、踩坑经验总结

在实现过程中,我们遇到了许多问题,以下是主要问题和解决方案:

7.1 Vue 无法识别 micro-app 自定义元素

问题:Vue 3 默认会将所有标签当作 Vue 组件处理,但 micro-app 是 WebComponent 自定义元素。

解决方案:在 vite.config.ts 中配置 isCustomElement

vue({
  template: {
    compilerOptions: {
      isCustomElement: (tag) => tag === "micro-app",
    },
  },
})

7.2 Vite 子应用必须使用 iframe 沙箱

问题:Vite 作为子应用时,如果不使用 iframe 沙箱,会出现脚本执行错误。

解决方案:在 MicroAppContainer 组件中添加 iframe 属性:

<micro-app
  :name="name"
  :url="url"
  router-mode="native"
  iframe  <!-- 必须添加 -->
/>

7.3 通信数据未接收

问题:发送数据后,子应用未接收到数据。

解决方案

  1. 确保添加了时间戳,确保数据变化被检测
  2. 使用 forceDispatch 强制发送数据
  3. 检查监听器是否正确注册

7.4 路由不同步

问题:子应用路由变化时,浏览器地址栏未更新。

解决方案

  1. 确保主应用使用 router-mode="native"
  2. 确保子应用使用 createWebHistory
  3. 检查基础路由配置是否正确

💡 更多踩坑记录:代码仓库中包含完整的踩坑记录文档(docs/TROUBLESHOOTING.md),包含所有遇到的问题和解决方案。


八、项目总结与展望

8.1 已完成功能

完整的通信机制:主子应用双向通信,支持多种事件类型 ✅ 路由同步:自动处理路由同步,支持浏览器前进后退 ✅ 独立运行:子应用支持独立开发和调试 ✅ 类型安全:完整的 TypeScript 类型定义,零 @ts-ignore,零 any事件总线:解耦生命周期和业务逻辑 ✅ 一键启动:并行启动所有应用,提升开发效率 ✅ 错误处理:完善的错误处理和降级方案 ✅ 日志优化:开发/生产环境区分,性能优化 ✅ 环境适配:支持开发/生产环境,同域/跨域部署

8.2 技术亮点

  1. 类型安全优先:完整的 TypeScript 类型定义,避免运行时错误
  2. 企业级实践:参考真实企业项目,但改进其不足
  3. 开箱即用:完整的配置和文档,快速上手
  4. 最佳实践:遵循 micro-app 官方推荐实践
  5. 代码质量:清晰的代码结构,完善的注释
  6. 可维护性:模块化设计,易于扩展

8.3 项目价值

  • 🎯 学习价值:完整的微前端实现示例,适合深入学习
  • 🚀 实用价值:可直接用于生产环境,节省开发时间
  • 📚 参考价值:最佳实践和踩坑经验,避免重复踩坑

📚 参考资源

官方文档

接口可不可以多版本共存?

比如第一版开发了路由 aaa,假设后面我们又开发了一版接口,但路由还是 aaa,怎么做?

nest new version-demo

创建 aaa 模块

nest g resource aaa --no-spec

跑起来

npm run start:dev

image.png

又开发了一版接口,但路由还是 aaa 怎么区分开 ?

image.png

image.png

image.png

不带 version 请求不到

image.png

带上 version

image.png

image.png

想所有版本都能访问这个接口,可以用 VERSION_NEUTRAL 这个常量

但此时都一样 如何区分开

image.png

单独建一个 version 2 的 controller

nest g controller aaa/aaa-v2 --no-spec --flat

更新为

import { Controller, Get, Version } from '@nestjs/common';
import { AaaService } from './aaa.service';

@Controller({
  path: 'aaa',
  version: '2',
})
export class AaaV2Controller {
  constructor(private readonly aaaService: AaaService) {}

  @Get()
  findAllV2() {
    return this.aaaService.findAll() + '222';
  }
}

一般是这样做,有一个 Controller 标记为 VERSION_NEUTRAL,其他版本的接口放在单独 Controller 里

注意,controller 之间同样要注意顺序,前面的 controller 先生效

image.png

image.png

image.png

除了用自定义 header 携带版本号,还可以这样

image.png

此处需要这样写

image.png

image.png

image.png

这些指定版本号的方式都不满足需求,可以自己写

main.ts

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { VersioningType } from '@nestjs/common';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  //   app.enableVersioning({
  // type: VersioningType.HEADER,
  // header: 'version',

  //     type: VersioningType.URI,
  //   });

  const extractor = (request: Request) => {
    if (request.headers['disable-custom']) {
      return '';
    }
    return request.url.includes('fuhao') ? '2' : '1';
  };

  app.enableVersioning({
    type: VersioningType.CUSTOM,
    extractor,
  });

  await app.listen(process.env.PORT ?? 3000);
}
bootstrap();

如果 url 里包含 fuhao,就返回版本 2 的接口,否则返回版本 1

image.png

image.png

image.png

useRoutes和createBrowserRouter的对比

一、 useRoutes

本质:Hook
版本:V6+
限制:需要配合BrowserRouter或者HashRouter进行使用
使用范围:在组件内部使用
返回值:得到路由匹配到的元素

二、createBrowserRouter

本质:函数
版本:V6.4+
限制:不需要配合BrowserRouter或者HashRouter进行使用,但是需要配合RouterProvider
使用范围:在组件外部创建路由配置
返回值:得到路由匹配到的元素

三、对比优缺点

useRoutes:简单、灵活,适合基础路由,但是不支持数据加载(loader)和错误处理(errorElement)
createBrowserRouter:功能更强,支持数据加载、错误处理等,适合复杂应用

四、代码示例:

useRouter:

function Router() { return useRoutes([ { path: "/", element: <Layout />, children: [ {const router = createBrowserRouter([ { path: "/", element: <Layout />, children: [ { path: "home", element: <Home />, loader: async () => { // 加载数据 return await fetchHomeData(); }, errorElement: <ErrorBoundary />, }, ], }, ]); path: "home", element: <Home /> }, ], }, ]); }

import { BrowserRouter as Router } from "react-router-dom"; root.render( <Router> {/* 需要 BrowserRouter */} <App /> </Router> );

createBrowserRouter:

const router = createBrowserRouter([ { path: "/", element: <Layout />, children: [ { path: "home", element: <Home />, loader: async () => { // 加载数据 return await fetchHomeData(); }, errorElement: <ErrorBoundary />, }, ], }, ]);

import { RouterProvider } from "react-router-dom"; import router from "./router/index"; function App() { return <RouterProvider router={router} />; // 不需要 BrowserRouter }

高德地图「点标记+点聚合」加载慢、卡顿问题 解决方案

coffee.joiepink.space/

Coffee网页的设计灵感来自于一个普通的在星巴克喝咖啡的下午,突发奇想能不能把全国的星巴克门店都整合到一起,用地图可视化的形式展示门店分布密度,为咖啡爱好者提供便捷的门店查询和导航服务。

于是便诞生了。

技术栈:Vue Amap UnoCSS Icônes Vant ESLint Vite

coffee-performance-01.png

痛点:本项目有8000+的数据量,需要在高德地图上点标记每家星巴克门店且支持交互,由于数据量过大,首次进入页面加载速度很慢,且把8000+点标记显示在地图上,点标记的交互动作会崩盘,地图操作响应速度也会变慢、卡顿,非常影响用户的使用体验

基于此,我从以下几个方面对项目进行了性能优化

  1. Amap SDK按需、动态加载
  2. 用 shallowRef 存地图相关实例
  3. 点聚合 + 只渲染视野内点位
  4. 视口变化防抖 + 只绑一次
  5. 首屏后再拉数据
  6. 主题切换与地图样式

1. Amap SDK按需、动态加载

在地图页面的js中,我并不在js顶部写import AMapLoader from '@amap/amap-jsapi-loader'

这种使用方法有两个弊端:

第一方面,在{Vite}打包的时候,这个依赖会被打包进入首屏就要加载的bundle(主chunk或和主入口一起被加载的chunk),用户第一次打开页面的时候,浏览器就会一起下载这份包含高德的JS,导致首屏体积变大,加载速度变慢。

第二方面,这种方式在模块被Node执行的时候们就会运行,于是会加载@amap/amap-jsapi-loader及其内部依赖, 而{Amap}内部SDK/loader会用到window,但是Node里面是没有window的,所以会导致报错(例如 Reference Error: window is not defined)。

为了避免以上两种问题,我在初始化{Amap}的函数里写const {default: AMapLoader} = await import('@amp/amp-jsapi-loader'),这个函数只在onMounted生命周期中调用,也就是说只在浏览器里、页面挂载之后才会执行

在{Vite}打包的时候,@amap/amap-jsapi-loader会被打包成单独的chunk,只有执行到const {default: AMapLoader} = await import('@amp/amp-jsapi-loader')的时候才会加载这段JS,首屏主bundle里并没有{Amap}相关代码,所以首包更小、首屏更快。

SSG时Node不会执行onMounred钩子,所以不会执行这段import,自然也就不会在Node里加载高德,不会碰到window,避免了报错。

async function initMap() {
  const { default: AMapLoader } = await import('@amap/amap-jsapi-loader') // [!code hl]
  const amapKey = import.meta.env.VITE_AMAP_KEY
  if (!amapKey)
    return Promise.reject(new Error('Missing VITE_AMAP_KEY'))
  window._AMapSecurityConfig = { securityJsCode: amapKey }
  return AMapLoader.load({
    key: amapKey,
    version: '2.0',
    plugins: ['AMap.Scale', 'AMap.MarkerCluster', 'AMap.Geolocation'], // [!code hl]
  })
}

initMap函数中,我使用{Amap}2.0的按插件加载特性,通过AMapLoader.load({plugins: [...]})按需加载需要的插件,这种方式在项目中精准引入需要使用的插件,使得项目请求更少、解析更少、地图初始化更轻,从而加快了加载速度、减小了打包的包体积。

2. 用 shallowRef 存地图相关实例

const map = shallowRef(null)
const currentLocationMarker = shallowRef(null)
const geolocation = shallowRef(null)
const Amap = shallowRef(null)

/** 根据 isDark 设置地图底图样式;返回 Promise,地图样式切换完成后再 resolve */
function applyMapStyle() {
    if (!map.value) return Promise.resolve()
  const style = i
   sDark.value ? 'amap://styles/dark' : 'amap://styles/normal'
  return new Promise((resolve) => {
      nextTick(() => {
        map.value?.setMapStyle(style)
      setTimeout(resolve, 1800)
    })
})
}

/** 点击切换主题 */
function onThemeToggle() {
    themeChanging.value = true
  nextTick(() => {
      toggleDark()

/** 监听 isDark,切完样式再关 loading */
watch(isDark, () => {
    const p = applyMapStyle()
  if (p) {
      p.finally(() => {
        themeChanging.value = false

    se {
  }
    themeChanging.value = false
}

先来说一下{Vue}中refshallowRef的区别

ref: {Vue}会对你塞进去的整个对象做深度响应式代理——递归的把对象里每一层属性都报称getter/setter(Proxy), 这样任意一层属性变了都会触发更新

shallowRef:只对[.value这一层]做响应式。当把一个对象赋值给shallowRef.value的时候,{Vue}不会去递归代理这个对象内部,内部属性变了{Vue}并不知道,但只有把整个对象换掉(重新赋值.value)时候才会触发更新

如果使用ref来存储{Amap}实例,会出现因深度代理造成的postMessage克隆报错,例如:DOMException: Failed to execute 'postMessage' on 'Worker': AMap.Map#xxx could not be cloned.

{Amap}的实例如Map Marker Geolocation MarkerCluster等内部会用到postMessage例如和地图iframe/worker通信

浏览器在发postMessage的时候,会对要传的数据做结构化克隆 structured clone,把对象序列号之后再在另一边反序列化,而Proxy对象不支持被克隆,所以会报错

能被结构化克隆的:普通对象、数组、部分简单类型

不能被结构化克隆的:函数、DOM节点、Proxy对象(Vue的响应式对象就是Proxy)

如果使用shallowRef存储的话,赋值给shallowRef.value的是{Amap}原始的实例对象,{Vue}不会去递归代理它里面的属性,也就不会报错。而ref存储会递归遍历、创建大量的Proxy,但其实并不需要在地图内部某个坐标变了就触发Vue更新,我们只需要在换地图、换marker、换聚合的这种整实例替换的时候更新即可。所以shallowRef的时候内存和CPU开销都更小,从而最性能有利。

3. 点聚合 + 只渲染视野内点位

为了解决数据量过大导致DOM数量巨大(每个门店创建一个marker标记)导致卡顿崩盘的问题,我使用{Amap}的点聚合 MarkerCluster将距离近的一批点在逻辑上归为一组,地图上只画一个聚合点,用户放大地图时,聚合会拆开变成更小的簇或者单点,缩小地图的时候,会合并成更大的簇。

即使使用点聚合 MarkerCluster,如果把全国所有门店(8000+)的数据量一次性都塞给点聚合 MarkerCluster,聚合算法要对这所有点都做距离计算、分簇、计算量十分之庞大,而绝大部分点并不在当前用户所见的屏幕内,用户根本看不到,却还是在耗费后台进行参与计算和内部管理,所以,更合理的做法是只把当前视野 current viewport内的点标记交给点聚合 MarkerCluster进行计算,而视野外的点不参与计算和渲染,当用户拖动地图画布或者放大缩小当前视野的时候,再进行计算参与。

具体做法如下:

function updateClusterByViewport(AmapInstance) {
    if (!map.value || !pointList.value?.length) return
  // 返回当前地图可视区域的地理范围(一个矩形,有西南角、东北角经纬度)。

  const b = map.value.getBounds()
  if (!b) return
  // 过滤当前
   视野内的点数据
  const inBounds = pointList.value.filter((point) => {
      const ll = point.lnglat
    const lng = Array.isArray(ll) ? ll[0] : (ll?.lng ?? ll?.longitude)
    const lat = Array.isArray(ll) ? ll[1] : (ll?.lat ?? ll?.latitude)
    // 判断点是否在当前视野矩形内
    return lng != null && lat != null && b.contains(new AmapInstance.LngLat(lng, lat))
   }
  // 把「视野内点数」存起来给界面用
  pointsInBounds.value = inBounds

    // 销毁旧聚合并只拿视野内的点重建聚合
  if (clusterRef.value) {
      clusterRef.value.setMap(null)
    clusterRef.value = null
   }
  if (!inBounds.length) return
  const myList = inBoun
   ds.map((point) => ({
      lnglat: Array.isArray(pont.lnlat)
        ? point.lnglat
      : [point.lnglat?.lng ?? point.lnglat?.longitude, point.lnglat?.lat ?? point.lnglat?.latitude],
   id:point.id,
  })
  const gridSize = 60
  const cluster = new AmapInstance.MarkerCluster(map.value, myList, {
      gridSize,
    renderClusterMarker: createRenderClusterMarker(AmapInstance, myList.length),
    renderMarker: createRenderStoreMarker(AmapInstance),
  })
  setupClusterClickZoom(cluster)
  clusterRef.value = cluster
}

4. 视口变化防抖 + 只绑一次

当用户拖拽、缩放地图的时候,地图会连续触发很多次moveend/zoomend的事件,如果每次触发都执行上文的updateClusterByViewport方法,计算执行过于频繁,容易造成页面卡顿、浪费CPU,因此为这些操作都加上防抖

const onViewportChange = useDebounceFn(() => updateClusterByViewport(Amap.value), 150)
map.value.on('moveend', onViewportChange)
map.value.on('zoomend', onViewportChange)

5. 首屏后再拉数据

首屏加载的时候,应该把注意力放在地图容器快速渲染上面,从而给用户一个比较好的使用体验。而加载数据(loadAndRenderStores)会执行请求数据、处理数据、渲染视野内点聚合这一系列操作,逻辑较重,因此如果在地图还没准备好、或者首屏还在渲染的时候同步做这些事情,就会占用主线程,从而拖慢首屏DOM的渲染、拖慢地图SDK的首次绘制,所以把目标变成:先让首屏和地图第一次渲染完成,再在浏览器空闲的时候去拉取数据、计算聚合。

map.value.on('complete', () => {
  const run = () => loadAndRenderStores(AmapInstance)
  if (typeof requestIdleCallback !== 'undefined') {
    requestIdleCallback(run, { timeout: 500 })
  }
  else {
    setTimeout(run, 0)
  }
  mapReady.value = true

  tryEndFirstScreenLoading()
})

6. 主题切换与地图样式

项目中 设置了DarkLight两种主题模式,而在切换的时候,地图样式切换是异步的,比导航条样式切换慢,这就会导致出现导航条已经变化主题,但地图主题还没更新,中间有一小段时间两者颜色不一致,甚至会闪动一下,带给用户不好的体验效果

为了解决这个问题,我在切换主题的[中间态]中,将页面用全屏loading遮罩层罩住,等地图样式基本切换完毕再隐藏,避免了中间态的闪烁问题

小结

至此,{Amap}相关的性能优化结束,首屏加载从原先的8,304ms优化到了4,181ms加载时间减少了4,123ms,性能提升了约49.65%,加载速度快了一倍

优化前:

coffee-performance-02.webp

优化后:

coffee-performance-03.png

Shadcn组件库的终极进化!React+Vite全速驱动,Ant-Design扩展组件+大屏可视化方案一网打尽🎯

vite-shadcn

VITE-SHADCN 是一个基于 Shadcn , Vite , React,Zustand,React-Router 等构建的项目 。已经参照ant-design扩展组件扩展了shadcn大量shadcn缺少的组件。并且实现了各种大屏以及可视化方案。

dashboard-zh-CN.png

disaster-command-zh-CN.png

rechart-zh-CN.png

dashboard-zh-CN.png

仓库地址:github.com/yluiop123/v…

项目访问地址:yluiop123.github.io/vite-shadcn

✅ 任务清单

  • 初始化项目
  • 配置 Vite + React + TypeScript + React-Router + Zustand + Axios + MSW +ShadCN 环境
  • 动态权限路由加载
  • 国际化、主题色切换、暗黑模式
  • 多布局
登录和路由权限控制
  • 登录功能
  • 路由权限控制
  • 角色切换(支持融合模式)
仪表盘
  • 基本仪表盘
  • 态势感知-网络安全
  • 态势感知-军事
  • 灾情指挥
  • 农林业管理
  • 工业管理
组件库
  • 普通组件
  • 布局组件
  • 导航组件
  • 数据展示组件
  • 反馈组件
  • react-hook-form 表单组件
  • tanstack/react-table 表格组件
  • 自定义组件
图表
  • Rechart 图表组件
  • Echart 图表组件
  • D3 图表组件
  • Antv 图表组件
三维
  • Babylon
  • Three
地图
  • Cesium地图
  • Deckgl地图
  • L7地图
  • Leaflet地图
  • Openlayers地图
系统管理
  • 用户管理
  • 角色管理
  • 权限管理
  • 组织管理
后端规划
  • springboot实现后端接口
  • 实现微服务

快速开始

1)环境

  • Node.js: v18+
  • pnpm: pnpm v10.28.2

2)技术栈

  • 框架: React 19 + Vite6
  • 状态管理: Zustand
  • UI 组件库: ShadCN + TailwindCSS
  • 国际化: react-intl
  • 路由: React Router v7
  • 接口模拟: Mock Service Worker (MSW)
  • 构建工具: Vite6

3)安装启动

# 克隆项目
git clone https://github.com/yluiop123/vite-shadcn.git
cd <项目目录>

# 安装依赖
pnpm install   

# 本地开发启动
pnpm dev    

#项目启动后访问 http://localhost:3000/   

4)命令行

我将为您更新表格,添加说明列:

命令 描述 说明
dev vite 启动开发服务器,支持热重载和实时编译
build tsc -b && vite build 构建生产版本,先进行 TypeScript 类型检查,再打包项目
build:github tsc -b && vite build --mode github 构建 GitHub 部署版本,使用特定的构建配置
lint eslint . 运行 ESLint 检查代码质量,识别潜在问题
preview vite preview 预览生产构建的项目,用于本地测试构建结果
preview:github vite preview --mode github 预览 GitHub 部署版本的构建结果
analyze cross-env ANALYZE=true vite build 分析打包结果,生成 bundle 分析报告

5)环境变量

项目默认使用 .env 文件作为环境变量配置。当通过 --mode 参数指定特定模式时,Vite 会自动加载对应的环境变量文件。例如,build:github 命令会加载 .env.github 文件中的配置。

以下是常用的环境变量配置及其说明:

VITE_BASE=/              # 项目部署的相对路径,用于指定应用的基础 URL
VITE_ROUTE=browserRouter # 路由类型,决定应用使用的路由策略
VITE_MOCK_ENABLE=true    # 是否启用 Mock 数据服务,用于开发和测试
VITE_BASE_API=/api/      # API 请求的统一前缀,用于后端接口调用
VITE_CESIUM_TOKEN=###    # Cesium 地图服务的认证令牌

目录结构

vite-shadcn
├── .github/                     # GitHub 配置文件
│   ├── workflows/
│   │   └── main.yml            # CI/CD 工作流配置
│   ├── copilot-instructions.md  # Copilot 指令
├── .trae/                       # Trae IDE 规则
│   └── rules/
├── public/                      # 静态资源目录
├── src/                         # 源代码目录
│   ├── assets/                  # 静态资源
│   ├── components/              # 通用组件
│   │   ├── ext/                 # 扩展组件
│   │   ├── ui/                  # Shadcn UI 基础组件
│   │   ├── app-sidebar.tsx      # 应用侧边栏
│   │   ├── chart-area-interactive.tsx # 交互式面积图
│   │   ├── color-switcher.tsx   # 颜色切换器
│   │   ├── dialog-form.tsx      # 表单对话框
│   │   ├── group-tree-select.tsx # 分组树选择器
│   │   ├── nav-main.tsx         # 主导航
│   │   ├── nav-user.tsx         # 用户导航
│   │   ├── permission-tree-select.tsx # 权限树选择器
│   │   ├── permission-tree-single-select.tsx # 权限单选树
│   │   ├── permission-type.tsx  # 权限类型
│   │   ├── role-select.tsx      # 角色选择器
│   │   ├── section-cards.tsx    # 区域卡片
│   │   ├── sidebar-menutree.tsx # 侧边栏菜单树
│   │   ├── site-header.tsx      # 站点头部
│   │   └── ...                  # 更多组件
│   ├── hooks/                   # React Hooks
│   │   └── use-mobile.ts        # 移动端检测 Hook
│   ├── lib/                     # 工具库
│   │   ├── axios.ts             # Axios 配置
│   │   ├── dict.ts              # 字典工具
│   │   ├── fixLeafletIcon.ts    # Leaflet 图标修复
│   │   ├── notify.ts            # 通知工具
│   │   └── utils.ts             # 通用工具函数
│   ├── locale/                  # 国际化
│   │   ├── en-US.ts             # 英文翻译
│   │   └── zh-CN.ts             # 中文翻译
│   ├── mock/                    # Mock 数据
│   ├── pages/                   # 页面组件
│   │   ├── chart/               # 图表页面
│   │   ├── component/           # 组件示例页面
│   │   ├── dashboard/           # 仪表板页面
│   │   ├── system/              # 系统管理页面
│   ├── store/                   # 状态管理
│   ├── themes/                  # 主题色文件
│   ├── App.tsx                  # 应用根组件
│   ├── index.css                # 全局样式
│   ├── layout.tsx               # 应用布局
│   └── main.tsx                 # 应用入口
├── .env                         # 环境变量
├── .env.github                  # GitHub 环境变量
├── .gitignore                   # Git 忽略文件
├── .hintrc                      # Webhint 配置
├── CODE_OF_CONDUCT.md           # 行为准则
├── LICENSE                      # 许可证
├── components.json              # 组件配置
└── package.json                 # 项目配置

路由与菜单

路由示例(React Router v7):

//src\routes.ts
const routeSetting: NavItem[] = [
  {
    key: "dashboard",
    title: "menu.dashboard",
    icon: LayoutDashboard,
    children: [
      { key: "normal", title: "menu.dashboard.normal", icon: Gauge },
    ],
  },
];

路由配置包含四个核心参数:

  • key: 路由路径标识符,用于唯一确定导航目标
  • title: 国际化配置键值,用于多语言文本映射
  • icon: 菜单图标元素,用于视觉标识
  • children: 子菜单数组,用于构建嵌套导航结构

如下,是其中一个页面的配置示例:

  1. 配置路由dashboard\normal
//src\routes.ts
const routeSetting: NavItem[] = [
  {
    key: "dashboard",
    title: "menu.dashboard",
    icon: LayoutDashboard,
    children: [
      { key: "normal", title: "menu.dashboard.normal", icon: Gauge },
    ],
  },
];

2.国家化文件中配置title中的key

//src\locale\en-US.ts
export default {
    'menu.dashboard': 'Dashboard',
    'menu.dashboard.normal': 'Normal',
};
//src\locale\zh-CN.ts
export default {
    'menu.dashboard': '仪表盘',
    'menu.dashboard.normal': '普通仪表盘',
};

3.增加页面

src\pages\component\general\index.tsx

注意必须在index.tsx下。

4.mock权限增加

下面这段模拟的是获取当前用户权限,需要在这段代码里增加新增菜单的权限。

//src\mock\system\permission.ts
    http.get<{ id: string }>(
    "/api/system/permissions/detail/:id",

对应的function 是getPermissionList

//src\mock\system\permission.ts
function getPermissionList(locale: string) {
    const dataArray: Permission[] = [
            //supper menu permissions
            {id: '0000', parentId:'',order: 0, path: "/dashboard",type: "directory",name:localeMap[locale]['menu.dashboard'] },
            {id: '0001', parentId:'',order: 1, path: "/component", type: "menu",name:localeMap[locale]['menu.component'] },
            {id: '000100', parentId:'0001',order: 0, path: "/component/general", type: "menu",name:localeMap[locale]['menu.component.general'] },

component/general 页面对应的权限标识为 id: '000100',其中 type 字段表示权限类型:

  • directory: 目录权限,包含该目录下所有子菜单的访问权限
  • menu: 菜单项权限,仅控制当前菜单项的访问权限

国际化

配置示例(react-intl):

//src\locale\en-US.ts
export default {
    'menu.dashboard': 'Dashboard',
};
//src\locale\zh-CN.ts
export default {
    'menu.dashboard': '仪表盘',
};

页面使用示例:

import { useIntl } from "react-intl";

const { formatMessage } = useIntl();
<div>{formatMessage({ id: "menu.dashboard", defaultMessage: "Dashboard" })}</div>

模拟数据

项目使用 MSW 模拟数据,msw的引入代码如下

//src\main.tsx
const mockEnable = (import.meta.env.VITE_MOCK_ENABLE||'true')=='true';
if(mockEnable){
  initMSW().then(()=>{
    createRootElement();
  })
}else{
  createRootElement();
}

mock数据的入口在如下文件,如果要新增mock的话,参照如下代码新增一个handlers就行了

//src\mock\index.ts
import { setupWorker } from "msw/browser";
import groupHandlers from "./components/group";
import permissionHandlers from "./components/permission";
import loginUserHandlers from "./login/user";
import systemGroupHandlers from "./system/group";
import systemPermissionHandlers from "./system/permission";
import systemRoleHandlers from "./system/role";
import systemUserHandlers from "./system/user";
const mockHandlers = [
  ...loginUserHandlers,
  ...groupHandlers,
  ...permissionHandlers,
  ...systemUserHandlers,
  ...systemRoleHandlers,
  ...systemGroupHandlers,
  ...systemPermissionHandlers
];
let worker: ReturnType<typeof setupWorker> | null = null;
export default async function initMSW() {
  if (worker) return worker;
  worker = setupWorker(...mockHandlers);
  // 启动 MSW
  await worker.start({

    serviceWorker: {
      url: `${import.meta.env.BASE_URL}mockServiceWorker.js`,
      options: { type: 'module', updateViaCache: 'none' },
    },
    onUnhandledRequest: (req) => {
      if (!req.url.startsWith('/api')) {
        return // 直接跳过,不拦截
      }
    },
  });
  return worker;
}

权限控制

用户权限从userInfo中获取

import { useUserStore } from '@/store';
const { userInfo} = useUserStore();

系统权限管理包含以下概念:

  • rolePermissions: 角色权限集合,定义特定角色所拥有的权限
  • userPermissions: 用户权限集合,定义用户账户级别的权限
  • currentPermission: 当前生效权限,为用户权限与所选角色权限的并集
  • currentMenuPermission: 当前菜单权限,用于控制具体菜单项的显示
  • currentDirectoryPermission: 当前目录权限,用于控制目录节点的显示,拥有目录权限时自动获得其下所有子菜单权限

系统支持多角色管理模式。当用户选择"全部角色"时,系统将整合用户权限与所有角色权限的并集作为当前权限集,实现灵活的权限控制策略。

主题

1.新增主题色在src\themes下

2.新增主题色后,需要导入

//src\index.css
@import "@/themes/blue.css";
@import "@/themes/green.css";
@import "@/themes/orange.css";
@import "@/themes/red.css";
@import "@/themes/rose.css";
@import "@/themes/violet.css";
@import "@/themes/yellow.css";
  • 主题色切换

下面可以配置主题色,Color的字符串颜色和src\themes中的一致

//src\store\theme.ts
export type Color =
  | "default"
  | "blue"
  | "green"
  | "orange"
  | "red"
  | "rose"
  | "violet"
  | "yellow";
import {useThemeStore } from '@/store/index';
const {color,setColor} = useThemeStore();
setColor('blue')

去中心化预测市场实战开发:Solidity+Viem 从合约设计到工程化落地

前言

在 Web3 生态迈向 2026 年的新阶段,去中心化预测市场(Decentralized Prediction Market,DPM)早已突破单纯的博弈工具属性,成为融合群体智慧价格发现去中心化金融对冲现实世界事件价值映射的核心 DeFi 组件。与中心化预测平台相比,基于智能合约的 DPM 凭借链上透明、无需信任、资产自管的特性,成为 Web3 落地现实应用的重要载体。

本文将从底层核心逻辑出发,拆解去中心化预测市场的设计精髓,通过Solidity实现可落地的智能合约架构,并结合 Web3 开发新标椎Viem完成工程化测试,同时给出从基础版本到生产级应用的优化方向与技术栈选型,让开发者能够快速上手并搭建属于自己的去中心化预测市场。

一、核心定位与底层逻辑

1. 市场定位

2026 年的 DPM 已突破博弈属性,成为融合群体智慧价格发现去中心化金融对冲现实世界事件价值映射的核心 DeFi 组件,核心优势是链上透明、无需信任、资产自管。

2. 底层核心法则:抵押品守恒(1=1+1)

所有设计围绕 “1 单位抵押品 = 1 份 Yes 代币 + 1 份 No 代币” 展开,分三个核心环节:

环节 核心动作 价值逻辑
资产对冲 用户存入抵押品,合约 1:1 铸造 Yes/No 结果代币 抵押品等价于 Yes+No 代币组合,单一代币成为投注头寸
概率定价 Yes/No 代币二级市场自由交易 代币价格直接反映市场对事件结果的概率预期(如 Yes=0.7ETH→70% 发生概率)
最终结算 预言机提交结果,胜出代币 = 1 单位抵押品,失败代币归零 抵押品总量不变,用户销毁胜出代币赎回抵押品

二、技术实现(Solidity+Viem)

智能合约

IOutcomeToken接口合约

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

interface IOutcomeToken is IERC20 {
    function mint(address to, uint256 amount) external;
    function burn(address from, uint256 amount) external;
}

OutcomeToken(结果代币)

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "./IOutcomeToken.sol";

contract OutcomeToken is ERC20, IOutcomeToken {
    address public immutable market;

    error OnlyMarketAllowed();

    constructor(
        string memory name, 
        string memory symbol, 
        address _market
    ) ERC20(name, symbol) {
        market = _market;
    }

    modifier onlyMarket() {
        if (msg.sender != market) revert OnlyMarketAllowed();
        _;
    }

    function mint(address to, uint256 amount) external onlyMarket {
        _mint(to, amount);
    }

    function burn(address from, uint256 amount) external onlyMarket {
        _burn(from, amount);
    }
}

MockV3Aggregator(预言机合约):在本地开发和测试环境中部署模拟合约,而在正式生产环境的项目中,则使用 Chainlink 提供的 MockV3Aggregator 合约

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract MockV3Aggregator4 {
    int256 private _price;
    constructor(int256 initialPrice) { _price = initialPrice; }
    function updatePrice(int256 newPrice) external { _price = newPrice; }
    function latestRoundData() external view returns (uint80, int256 price, uint256, uint256, uint80) {
        return (0, _price, 0, 0, 0);
    }
}

PredictionMarket(预测市场合约)

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import "@chainlink/contracts/src/v0.8/shared/interfaces/AggregatorV3Interface.sol";
import "./OutcomeToken.sol";

contract PredictionMarket is Ownable, ReentrancyGuard {
    IOutcomeToken public immutable yesToken;
    IOutcomeToken public immutable noToken;
    AggregatorV3Interface public immutable priceFeed;

    uint256 public immutable targetPrice;
    bool public resolved;
    bool public winningOutcome; // true = Yes, false = No

    event MarketMinted(address indexed user, uint256 amount);
    event MarketResolved(bool winningOutcome, int256 finalPrice);
    event Redeemed(address indexed user, uint256 amount);

    error AlreadyResolved();
    error NotResolved();
    error InsufficientBalance();

    constructor(
        address _priceFeed, 
        uint256 _targetPrice
    ) Ownable(msg.sender) {
        priceFeed = AggregatorV3Interface(_priceFeed);
        targetPrice = _targetPrice;
        
        // 实例化 Outcome 代币
        yesToken = new OutcomeToken("Predict YES", "YES", address(this));
        noToken = new OutcomeToken("Predict NO", "NO", address(this));
    }

    /**
     * @notice 存入 ETH 铸造 1:1 的 Yes 和 No 头寸
     */
    function mintPositions() external payable nonReentrant {
        if (resolved) revert AlreadyResolved();
        if (msg.value == 0) revert InsufficientBalance();

        yesToken.mint(msg.sender, msg.value);
        noToken.mint(msg.sender, msg.value);

        emit MarketMinted(msg.sender, msg.value);
    }

    /**
     * @notice 调用 Chainlink 获取价格并结算市场
     */
    function resolveMarket() external onlyOwner {
        if (resolved) revert AlreadyResolved();

        (, int256 price, , , ) = priceFeed.latestRoundData();
        
        // 判定逻辑:当前价 > 目标价则 Yes 赢
        winningOutcome = uint256(price) > targetPrice;
        resolved = true;

        emit MarketResolved(winningOutcome, price);
    }

    /**
     * @notice 结算后,胜方销毁代币取回 1:1 的 ETH
     */
    function redeem(uint256 amount) external nonReentrant {
        if (!resolved) revert NotResolved();

        if (winningOutcome) {
            yesToken.burn(msg.sender, amount);
        } else {
            noToken.burn(msg.sender, amount);
        }

        (bool success, ) = payable(msg.sender).call{value: amount}("");
        require(success, "Transfer failed");

        emit Redeemed(msg.sender, amount);
    }

    receive() external payable {}
}

测试脚本

测试用例

  1. Minting (铸造头寸)
  2. Resolution & Redemption (结算与兑付)
  3. 当价格超过目标时,YES 持有者应能兑现
  4. 未结算前不应允许兑现
import assert from "node:assert/strict";
import { describe, it, beforeEach } from "node:test";
import { parseEther, formatEther } from 'viem';
import { network } from "hardhat";

describe("PredictionMarket", function () {
    let market: any, mockOracle: any;
    let yesToken: any, noToken: any;
    let publicClient: any;
    let owner: any, user1: any;
    let deployerAddress: string;

    const TARGET_PRICE = 3000n * 10n**8n; // 假设目标价 3000 (8位小数)
    const INITIAL_PRICE = 2500n * 10n**8n; // 初始价 2500

    beforeEach(async function () {
        const { viem } = await (network as any).connect();
        publicClient = await viem.getPublicClient();
        [owner, user1] = await viem.getWalletClients();
        deployerAddress = owner.account.address;

        // 1. 部署 Mock 预言机 (初始价格 2500)
        mockOracle = await viem.deployContract("MockV3Aggregator4", [INITIAL_PRICE]);
        
        // 2. 部署预测市场主合约
        market = await viem.deployContract("PredictionMarket", [
            mockOracle.address,
            TARGET_PRICE
        ]);

        // 3. 获取生成的 YES/NO 代币合约实例
        const yesAddr = await market.read.yesToken();
        const noAddr = await market.read.noToken();
        
        yesToken = await viem.getContractAt("OutcomeToken", yesAddr);
        noToken = await viem.getContractAt("OutcomeToken", noAddr);

        console.log(`市场部署成功: ${market.address}`);
    });

    describe("Minting (铸造头寸)", function () {
        it("应该允许用户存入 ETH 并获得 1:1 的 Yes/No 代币", async function () {
            const mintAmount = parseEther("1");

            // 执行铸造
            const hash = await market.write.mintPositions({
                value: mintAmount,
                account: user1.account
            });
            await publicClient.waitForTransactionReceipt({ hash });

            // 检查余额
            const userYesBalance = await yesToken.read.balanceOf([user1.account.address]);
            const userNoBalance = await noToken.read.balanceOf([user1.account.address]);

            assert.equal(userYesBalance, mintAmount, "YES 代币数量不匹配");
            assert.equal(userNoBalance, mintAmount, "NO 代币数量不匹配");
            
            console.log(`用户1 成功铸造: ${formatEther(userYesBalance)} YES & NO`);
        });
    });

    describe("Resolution & Redemption (结算与兑付)", function () {
        beforeEach(async function () {
            // 预先为 user1 铸造 2 ETH 的头寸
            await market.write.mintPositions({
                value: parseEther("2"),
                account: user1.account
            });
        });

        it("当价格超过目标时,YES 持有者应能兑现", async function () {
            // 1. 模拟价格上涨至 3500 (超过目标 3000)
            const newPrice = 3500n * 10n**8n;
            await mockOracle.write.updatePrice([newPrice]);

            // 2. 所有者结算市场
            await market.write.resolveMarket();
            
            const winningOutcome = await market.read.winningOutcome();
            assert.equal(winningOutcome, true, "应该是 YES 赢");

            // 3. 用户兑现 YES 代币
            const redeemAmount = parseEther("2");
            const balanceBefore = await publicClient.getBalance({ address: user1.account.address });

            const hash = await market.write.redeem([redeemAmount], {
                account: user1.account
            });
            await publicClient.waitForTransactionReceipt({ hash });

            // 4. 检查结果
            const yesBalanceAfter = await yesToken.read.balanceOf([user1.account.address]);
            const balanceAfter = await publicClient.getBalance({ address: user1.account.address });

            assert.equal(yesBalanceAfter, 0n, "兑现后代币应销毁");
            assert.ok(balanceAfter > balanceBefore, "用户余额应增加 (忽略 Gas)");
            
            console.log("✅ YES 胜出,用户成功兑回 ETH");
        });

        it("未结算前不应允许兑现", async function () {
            await assert.rejects(
                market.write.redeem([parseEther("1")], { account: user1.account }),
                /NotResolved/,
                "未结算时不应允许 redeem"
            );
        });
    });
});

部署脚本

// scripts/deploy.js
import { network, artifacts } from "hardhat";
async function main() {
  // 连接网络
  const { viem } = await network.connect({ network: network.name });//指定网络进行链接
  
  // 获取客户端
  const [deployer] = await viem.getWalletClients();
  const publicClient = await viem.getPublicClient();
 
  const deployerAddress = deployer.account.address;
   console.log("部署者的地址:", deployerAddress);
  // 加载合约
  const MockV3AggregatorArtifact = await artifacts.readArtifact("MockV3Aggregator4");
  const PredictionMarketArtifact = await artifacts.readArtifact("PredictionMarket");
  const TARGET_PRICE = 3000n * 10n**8n; // 假设目标价 3000 (8位小数)
 const INITIAL_PRICE = 2500n * 10n**8n; // 初始价 2500
  // 部署(构造函数参数:recipient, initialOwner)
  const MockV3AggregatorHash = await deployer.deployContract({
    abi: MockV3AggregatorArtifact.abi,//获取abi
    bytecode: MockV3AggregatorArtifact.bytecode,//硬编码
    args: [INITIAL_PRICE],//部署者地址,初始所有者地址
  });
   const MockV3AggregatorReceipt = await publicClient.waitForTransactionReceipt({ hash: MockV3AggregatorHash });
   console.log("预言机合约地址:", MockV3AggregatorReceipt.contractAddress);
//
const PredictionMarketHash = await deployer.deployContract({
    abi: PredictionMarketArtifact.abi,//获取abi
    bytecode: PredictionMarketArtifact.bytecode,//硬编码
    args: [MockV3AggregatorReceipt.contractAddress,TARGET_PRICE],//部署者地址,初始所有者地址
  });
  // 等待确认并打印地址
  const PredictionMarketReceipt = await publicClient.waitForTransactionReceipt({ hash: PredictionMarketHash });
  console.log("预测市场合约地址:", PredictionMarketReceipt.contractAddress);
}
main().catch(console.error);

三、生态价值与未来展望

在 2026 年的 Web3 生态中,去中心化预测市场不仅是 DeFi 的核心组件,更是连接链上与链下世界的重要桥梁,其生态价值早已超越单纯的预测功能

1. 核心生态价值

价值方向 具体应用场景
群体智慧价格发现 金融对冲、企业决策、政策制定的概率数据支撑
DeFi 生态补充 开发事件保险、对冲工具、合成资产等创新产品
DAO 治理工具 为 DAO 提案提供社区预期参考,提升治理科学性
RWA 价值映射 实现现实事件(大宗商品 / 体育赛事 / 宏观数据)的链上价值映射

2. 未来演进方向

  • 技术层面:结合 ZKP、乐观预言机、跨链技术,提升去中心化程度、降低参与门槛;
  • 待解决问题:合规性、流动性、用户教育;
  • 核心目标:实现 “群体智慧的价值化”,成为 Web3 落地现实应用的核心载体。

总结

  • 底层逻辑:DPM 的核心是 “1=1+1 抵押品守恒法则”,贯穿铸造、定价、结算全流程;

  • 技术实现:合约采用模块化设计,结合 OpenZeppelin 保障安全,Viem 替代 Ethers 实现高效测试 / 部署;

  • 生态价值:DPM 的核心价值是挖掘群体智慧、推动链上链下融合,而非单纯的博弈功能;

  • 拓展性:本文代码为基础框架,可基于此拓展 AMM(提升流动性)、ERC1155(多结果支持)等生产级功能。

Cursor 500MB 太重?试试这个 5MB 的 CLI 方案

Cursor 500MB 太重?试试这个 5MB 的 CLI 方案

为什么我放弃了 Cursor

上个月团队让我试用 Cursor。下载完 500MB 安装包后,我开始怀疑人生。

启动要 10 秒,打开大项目要 30 秒,内存占用 2GB+。我只是想让 AI 帮我写个脚本,为什么要装个这么重的 IDE?

后来发现了 Blade Code。

Blade Code 是什么

一个 5MB 的 Node.js CLI 工具,专门做一件事:让 AI 快速完成编程任务。

不是 IDE,不是编辑器,就是个命令行工具。

对比数据

维度 Cursor Blade Code
安装包大小 500MB 5MB (npm 包)
启动速度 10秒 1秒
内存占用 2GB+ 50MB
适用场景 完整开发环境 快速任务、脚本、自动化
学习成本 需要适应新 IDE 会用命令行就行
价格 $20/月 MIT 开源

真实场景

场景 1:快速重构代码

blade "把这个文件的所有 var 改成 let/const"

3 秒完成,不用打开 IDE。

场景 2:批量处理文件

blade "把 src/ 下所有 .js 文件加上 'use strict'"

20 个文件,5 秒搞定。

场景 3:生成测试用例

blade "给 utils.ts 生成单元测试"

自动分析代码,生成完整测试文件。

为什么这么快

  1. 无 GUI 开销 - 纯命令行,没有渲染负担
  2. 按需加载 - 只加载需要的工具
  3. 流式响应 - 边生成边输出,不等全部完成
  4. 轻量设计 - 核心只有几 MB

20+ 内置工具

Blade Code 不只是个 AI 对话工具,它内置了 20+ 实用工具:

  • 文件操作:读、写、搜索、批量处理
  • 代码分析:AST 解析、依赖分析
  • Shell 执行:安全的命令执行
  • Git 集成:提交、分支、历史查询
  • Web 搜索:实时查询最新信息

安全设计

很多人担心 AI 工具会误删代码。Blade Code 有三层保护:

  1. 权限控制 - 危险操作需要确认
  2. 工具白名单 - 只能用预定义的工具
  3. 操作日志 - 所有操作可追溯

5 分钟上手

安装

npm install -g blade-code

配置 API Key

blade config

支持 OpenAI、Claude、Gemini、国产大模型。

开始使用

blade "帮我重构这个函数"

就这么简单。

适合谁用

适合:

  • 需要快速完成小任务的开发者
  • 喜欢命令行的极客
  • 想要轻量级 AI 工具的人
  • 需要脚本化 AI 能力的场景

不适合:

  • 需要完整 IDE 功能的人
  • 不习惯命令行的人
  • 需要图形界面的场景

和其他工具对比

vs Cursor

  • Cursor:完整 IDE,适合长时间开发
  • Blade Code:快速任务,适合脚本化场景

vs GitHub Copilot

  • Copilot:代码补全,需要在编辑器里用
  • Blade Code:独立工具,可以批量处理

vs OpenCode

  • OpenCode:95K stars,功能全面但复杂
  • Blade Code:专注 CLI,简单直接

开源 + 可扩展

Blade Code 是 MIT 开源的,代码在 GitHub: github.com/echoVic/bla…

支持 MCP (Model Context Protocol),可以自己写插件扩展功能。

总结

如果你觉得 Cursor 太重,需要快速完成小任务,喜欢命令行,想要免费的 AI 编程工具,试试 Blade Code。

5MB,1 秒启动,MIT 开源。

项目地址:github.com/echoVic/bla…

虚拟列表:从定高到动态高度的 Vue 3 & React 满分实现

前言

在处理海量数据渲染(如万级甚至十万级列表)时,直接操作 DOM 会导致严重的页面卡顿甚至崩溃。虚拟列表(Virtual List) 作为前端性能优化的“核武器”,通过“只渲染可视区”的策略,能将渲染性能提升数个量级。本文将带你从零实现一个支持动态高度的通用虚拟列表。

定高虚拟列表滚动.gif

一、 核心原理解析

虚拟列表本质上是一个“障眼法”,其结构通常分为三层:

  1. 外层容器(Container) :固定高度,设置 overflow: auto,负责监听滚动事件。
  2. 占位背景(Placeholder) :高度等于“总数据量 × 列表项高度”,用于撑开滚动条,模拟真实滚动的视觉效果。
  3. 渲染内容区(Content Area) :绝对定位,根据滚动距离动态计算起始索引,并通过 translateY 偏移到当前可视区域。

image.png


二、 定高虚拟列表

1. 设计思路

  • 可视项数计算Math.ceil(容器高度 / 固定高度) ± 缓冲区 (BUFFER)
  • 起始索引Math.floor(滚动距离 / 固定高度)
  • 偏移量起始索引 * 固定高度

2. Vue 3 + TailwindCSS实现

<template>
  <div
    class="min-h-screen bg-gradient-to-br from-indigo-600 to-purple-600 py-10 px-5"
  >
    <div class="bg-white mt-20 h-[calc(100vh-200px)] rounded-xl">
      <!-- 滚动容器 -->
      <div
        ref="virtualListRef"
        class="h-full overflow-auto relative"
        @scroll="handleScroll"
      >
        <!-- 占位容器:用于撑开滚动条,高度 = 总数据量 * 每项高度 -->
        <div :style="{ height: `${totalHeight}px` }"></div>

        <!-- 可视区域列表:通过 transform 定位到滚动位置 -->
        <div
          class="absolute top-0 left-0 right-0"
          :style="{ transform: `translateY(${offsetY}px)` }"
        >
          <div
            v-for="item in visibleList"
            :key="item.id"
            class="py-2 px-4 border-b border-gray-200"
            :class="{
              'bg-pink-200 h-[100px]': item.id % 2 !== 0,
              'bg-green-200 h-[100px]': item.id % 2 === 0,
            }"
          >
            {{ item.name }}
          </div>
        </div>
      </div>
    </div>
    <div
      class="fixed top-2 left-24 -translate-x-1/2 px-8 py-3 bg-white text-indigo-600 rounded-full text-base font-semibold cursor-pointer shadow-lg transition-all duration-300 hover:-translate-x-1/2 hover:-translate-y-0.5 hover:shadow-2xl"
      @click="goBack"
    >
      ← 返回首页
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { useRouter } from 'vue-router';

const router = useRouter();

const ITEM_HEIGHT = 100; // 列表项固定高度(与样式中的 h-[100px] 一致)
const BUFFER = 5; // 缓冲区数量,避免滚动时出现空白

const virtualListRef = ref<HTMLDivElement | null>(null);

const ListData = ref<any[]>([]); // 完整列表数据
const scrollTop = ref(0); // 滚动容器的滚动距离

// 总列表高度(撑开滚动条用)
const totalHeight = computed(() => ListData.value.length * ITEM_HEIGHT);

// 可视区域高度(滚动容器的高度)
const viewportHeight = computed(() => {
  return virtualListRef.value?.clientHeight || 0;
});

// 可视区域可显示的列表项数量(向上取整 + 缓冲区)
const visibleCount = computed(() => {
  return Math.ceil(viewportHeight.value / ITEM_HEIGHT) + BUFFER;
});

// 当前显示的起始索引
const startIndex = computed(() => {
  // 滚动距离 / 每项高度 = 跳过的项数(向下取整)
  const index = Math.floor(scrollTop.value / ITEM_HEIGHT);
  // 防止索引为负数
  return Math.max(0, index);
});

// 当前显示的结束索引
const endIndex = computed(() => {
  const end = startIndex.value + visibleCount.value;
  // 防止超出总数据长度
  return Math.min(end, ListData.value.length);
});

// 可视区域需要渲染的列表数据
const visibleList = computed(() => {
  return ListData.value.slice(startIndex.value, endIndex.value);
});

// 可视区域的偏移量(让列表项定位到正确位置)
const offsetY = computed(() => {
  return startIndex.value * ITEM_HEIGHT;
});

// 处理滚动事件
const handleScroll = () => {
  if (virtualListRef.value) {
    scrollTop.value = virtualListRef.value.scrollTop;
  }
};

// 返回首页
const goBack = () => {
  router.push('/home');
};

// 初始化
onMounted(() => {
  // 生成模拟数据
  ListData.value = Array.from({ length: 1000 }, (_, index) => ({
    id: index,
    name: `Item ${index}`,
  }));
});
</script>

3. 实现效果图

定高虚拟列表滚动.gif


三、 进阶:不定高(动态高度)虚拟列表

在实际业务(如社交动态、聊天记录)中,每个 Item 的高度往往是不固定的。

1. 核心改进思路

  • 高度映射表(Map) :记录每一个 Item 渲染后的真实高度。
  • 累计高度数组(Cumulative Heights) :存储每一项相对于顶部的偏移位置。
  • ResizeObserver:利用该 API 监听子组件高度变化,实时更新映射表,解决图片加载或文本折行导致的位移。

2. Vue 3 + tailwindCSS 实现(子组件抽离)

子组件: 负责上报真实高度:

<template>
  <div
    ref="itemRef"
    class="py-2 px-4 border-b border-gray-200"
    :class="{
      'bg-pink-200': item.id % 2 !== 0,
      'bg-green-200': item.id % 2 === 0,
    }"
    :style="{ height: item.id % 2 === 0 ? '150px' : '100px' }"
  >
    {{ item.name }}
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUpdated, onUnmounted, watch, nextTick } from 'vue';

// 定义props:接收父组件传递的item数据
const props = defineProps<{
  item: {
    id: number;
    name: string;
  };
}>();

// 定义emit:向父组件传递高度更新事件
const emit = defineEmits<{
  (e: 'update-height', id: number, height: number): void;
}>();

const itemRef = ref<HTMLDivElement | null>(null);
let resizeObserver: ResizeObserver | null = null;

// 计算并发送当前组件的高度
const sendItemHeight = () => {
  if (!itemRef.value) return;
  const realHeight = itemRef.value.offsetHeight;
  emit('update-height', props.item.id, realHeight);
};

// 监听组件挂载:首次发送高度 + 监听高度变化
onMounted(() => {
  // 首次渲染完成后发送高度
  nextTick(() => {
    sendItemHeight();
  });

  // 监听元素高度变化(适配动态内容导致的高度变化)
  if (window.ResizeObserver) {
    resizeObserver = new ResizeObserver(() => {
      sendItemHeight();
    });
    if (itemRef.value) {
      resizeObserver.observe(itemRef.value);
    }
  }
});

// 组件更新后重新发送高度(比如内容变化)
onUpdated(() => {
  nextTick(() => {
    sendItemHeight();
  });
});

// 组件卸载:清理监听
onUnmounted(() => {
  if (resizeObserver) {
    resizeObserver.disconnect();
    resizeObserver = null;
  }
});

// 监听item变化:如果item替换,重新计算高度
watch(
  () => props.item.id,
  () => {
    nextTick(() => {
      sendItemHeight();
    });
  }
);
</script>

父组件:核心逻辑

<template>
  <div
    class="min-h-screen bg-gradient-to-br from-indigo-600 to-purple-600 py-10 px-5"
  >
    <div class="bg-white mt-20 h-[calc(100vh-200px)] rounded-xl">
      <!-- 滚动容器 -->
      <div
        ref="virtualListRef"
        class="h-full overflow-auto relative"
        @scroll="handleScroll"
      >
        <!-- 占位容器:撑开滚动条 -->
        <div :style="{ height: `${totalHeight}px` }"></div>

        <!-- 可视区域列表 -->
        <div
          class="absolute top-0 left-0 right-0"
          :style="{ transform: `translateY(${offsetY}px)` }"
        >
          <!-- 渲染子组件,监听高度更新事件 -->
          <VirtualListItem
            v-for="item in visibleList"
            :key="item.id"
            :item="item"
            @update-height="handleItemHeightUpdate"
          />
        </div>
      </div>
    </div>
    <div
      class="fixed top-2 left-24 -translate-x-1/2 px-8 py-3 bg-white text-indigo-600 rounded-full text-base font-semibold cursor-pointer shadow-lg transition-all duration-300 hover:-translate-x-1/2 hover:-translate-y-0.5 hover:shadow-2xl"
      @click="goBack"
    >
      ← 返回首页
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, computed, onUnmounted, nextTick } from 'vue';
import { useRouter } from 'vue-router';
import VirtualListItem from './listItem.vue'; // 引入子组件

const router = useRouter();

const MIN_ITEM_HEIGHT = 100; // 子项预设的最小高度
const BUFFER = 5; //上下缓冲区数目
const virtualListRef = ref<HTMLDivElement | null>(null); // 滚动容器引用

const ListData = ref<any[]>([]); // 完整列表数据
const scrollTop = ref(0); // 滚动距离
const itemHeights = ref<Map<number, number>>(new Map()); // 子组件高度映射表
const cumulativeHeights = ref<number[]>([0]); // 累计高度数组
const scrollTimer = ref<number | null>(null); // 滚动节流定时器
const isUpdatingCumulative = ref(false); // 累计高度更新防抖

// 初始化位置数据
const initPositionData = () => {
  // 初始化高度映射表(默认最小高度)
  const heightMap = new Map<number, number>();
  ListData.value.forEach((item) => {
    heightMap.set(item.id, MIN_ITEM_HEIGHT);
  });
  // 初始化累计高度
  updateCumulativeHeights();
};

// 更新累计高度(核心)
const updateCumulativeHeights = () => {
  if (isUpdatingCumulative.value) return;
  isUpdatingCumulative.value = true;

  const itemCount = ListData.value.length;
  const cumulative = [0];
  let sum = 0;

  for (let i = 0; i < itemCount; i++) {
    const itemId = ListData.value[i].id;
    sum += itemHeights.value.get(itemId) || MIN_ITEM_HEIGHT;
    cumulative.push(sum);
  }

  cumulativeHeights.value = cumulative;
  isUpdatingCumulative.value = false;
};

// 处理子组件的高度更新事件
const handleItemHeightUpdate = (id: number, height: number) => {
  // 高度未变化则跳过
  if (itemHeights.value.get(id) === height) return;

  // 更新高度映射表
  itemHeights.value.set(id, height);

  // 异步更新累计高度(避免同步更新导致的性能问题)
  nextTick(() => {
    updateCumulativeHeights();
  });
};

// 总高度,根据统计高度数组最后一个值计算得出
const totalHeight = computed(() => {
  return cumulativeHeights.value[cumulativeHeights.value.length - 1] || 0;
});

// 列表可视区域高度
const viewportHeight = computed(() => {
  return virtualListRef.value?.clientHeight || MIN_ITEM_HEIGHT * 5;
});

// 计算起始索引
const startIndex = computed(() => {
  const totalItemCount = ListData.value.length;
  if (totalItemCount === 0) return 0;
  if (scrollTop.value <= 0) return 0;

  let baseStartIndex = 0;
  // 反向遍历找起始索引
  for (let i = cumulativeHeights.value.length - 1; i >= 0; i--) {
    if (cumulativeHeights.value[i] <= scrollTop.value) {
      baseStartIndex = i;
      break;
    }
  }
  const finalIndex = Math.max(0, baseStartIndex - BUFFER); // 确保不小于0
  return Math.min(finalIndex, totalItemCount - 1);
});

// 计算结束索引
const endIndex = computed(() => {
  const totalItemCount = ListData.value.length;
  const viewportHeightVal = viewportHeight.value;
  if (totalItemCount === 0) return 0;

  const targetScrollBottom = scrollTop.value + viewportHeightVal; // 目标滚动到底部位置
  let baseEndIndex = totalItemCount - 1;
  for (let i = 0; i < cumulativeHeights.value.length; i++) {
    if (cumulativeHeights.value[i] > targetScrollBottom) {
      baseEndIndex = i - 1;
      break;
    }
  }
  const finalEndIndex = Math.min(baseEndIndex + BUFFER, totalItemCount - 1); // 确保不大于总项数-1
  return finalEndIndex;
});

// 可见列表
const visibleList = computed(() => {
  const start = startIndex.value;
  const end = endIndex.value;
  return start <= end ? ListData.value.slice(start, end + 1) : [];
});

const offsetY = computed(() => {
  return cumulativeHeights.value[startIndex.value] || 0;
});

// 滚动节流处理
const handleScroll = () => {
  if (!virtualListRef.value) return;

  if (scrollTimer.value) clearTimeout(scrollTimer.value);
  scrollTimer.value = window.setTimeout(() => {
    scrollTop.value = virtualListRef.value!.scrollTop;
  }, 20);
};

const handleResize = () => {
  if (virtualListRef.value) {
    scrollTop.value = virtualListRef.value.scrollTop;
  }
};

const goBack = () => {
  router.push('/home');
};

// 生命周期
onMounted(() => {
  // 生成模拟数据
  ListData.value = Array.from({ length: 1000 }, (_, index) => ({
    id: index,
    name: `Item ${index}`,
  }));
  initPositionData();
  window.addEventListener('resize', handleResize); // 监听窗口大小变化
});

onUnmounted(() => {
  window.removeEventListener('resize', handleResize);
  if (scrollTimer.value) clearTimeout(scrollTimer.value);
  isUpdatingCumulative.value = false;
  itemHeights.value.clear();
});
</script>

3. React + tailwindCSS 实现(子组件抽离)

子组件:

import React, { useEffect, useRef, useState, useCallback } from 'react';

interface VirtualListItemProps {
  item: {
    id: number;
    name: string;
  };
  onUpdateHeight: (id: number, height: number) => void; // 替代 Vue 的 emit
}

const VirtualListItem: React.FC<VirtualListItemProps> = ({
  item,
  onUpdateHeight,
}) => {
  const itemRef = useRef<HTMLDivElement>(null);
  // 存储 ResizeObserver 实例(避免重复创建)
  const resizeObserverRef = useRef<ResizeObserver | null>(null);

  // 计算并上报高度
  const sendItemHeight = useCallback(() => {
    if (!itemRef.current) return;
    const realHeight = itemRef.current.offsetHeight;
    onUpdateHeight(item.id, realHeight);
  }, [item.id, onUpdateHeight]);

  useEffect(() => {
    const timer = setTimeout(() => {
      sendItemHeight();
    }, 0);

    // 初始化 ResizeObserver 监听高度变化
    if (window.ResizeObserver) {
      resizeObserverRef.current = new ResizeObserver(() => {
        sendItemHeight();
      });
      if (itemRef.current) {
        resizeObserverRef.current.observe(itemRef.current);
      }
    }

    // 清理定时器(对应 Vue 的 onUnmounted 部分)
    return () => {
      clearTimeout(timer);
      if (resizeObserverRef.current) {
        resizeObserverRef.current.disconnect();
        resizeObserverRef.current = null;
      }
    };
  }, [sendItemHeight]); // 仅首次挂载执行

  //监听 item 变化重新计算高度
  useEffect(() => {
    const timer = setTimeout(() => {
      sendItemHeight();
    }, 0);
    return () => clearTimeout(timer);
  }, [item.id, sendItemHeight]); // item.id 变化时执行

  const itemClass = `py-2 px-4 border-b border-gray-200 ${
    item.id % 2 !== 0 ? 'bg-pink-200' : 'bg-green-200'
  }`;

  const itemStyle: React.CSSProperties = {
    height: item.id % 2 === 0 ? '150px' : '100px',
  };

  return (
    <div ref={itemRef} className={itemClass} style={itemStyle}>
      {item.name}
    </div>
  );
};

export default VirtualListItem;


父组件:

import React, {
  useEffect,
  useRef,
  useState,
  useCallback,
  useMemo,
} from 'react';
import VirtualListItem from './listItem';

const VirtualList: React.FC = () => {
  const MIN_ITEM_HEIGHT = 100; // 最小项高度
  const BUFFER = 5; // 缓冲区项数

  const virtualListRef = useRef<HTMLDivElement>(null); // 虚拟列表容器引用

  const [listData, setListData] = useState<Array<{ id: number; name: string }>>(
    []
  ); // 列表数据
  const [scrollTop, setScrollTop] = useState(0); // 滚动位置
  const [itemHeights, setItemHeights] = useState<Map<number, number>>(
    new Map()
  ); // 高度映射表(Map 结构)
  const [cumulativeHeights, setCumulativeHeights] = useState<number[]>([0]); // 累计高度数组
  const scrollTimerRef = useRef<number | null>(null); // 滚动节流定时器

  // 初始化模拟数据
  const initData = () => {
    const mockData = Array.from({ length: 1000 }, (_, index) => ({
      id: index,
      name: `Item ${index}`,
    }));
    setListData(mockData);
    // 初始化高度映射表(默认最小高度)
    const initHeightMap = new Map<number, number>();
    mockData.forEach((item) => {
      initHeightMap.set(item.id, MIN_ITEM_HEIGHT);
    });
    setItemHeights(initHeightMap);
    // 初始化累计高度
    updateCumulativeHeights(initHeightMap, mockData);
  };

  useEffect(() => {
    initData();
    // 监听窗口大小变化
    const handleResize = () => {
      if (virtualListRef.current) {
        setScrollTop(virtualListRef.current.scrollTop);
      }
    };
    window.addEventListener('resize', handleResize);

    // 清理监听
    return () => {
      window.removeEventListener('resize', handleResize);
      if (scrollTimerRef.current) {
        clearTimeout(scrollTimerRef.current);
      }
      itemHeights.clear(); // 清空 Map 释放内存
    };
  }, []);

  // 更新累计高度(核心函数)
  const updateCumulativeHeights = useCallback(
    (heightMap: Map<number, number>, data: typeof listData) => {
      const cumulative = [0];
      let sum = 0;
      for (let i = 0; i < data.length; i++) {
        const itemId = data[i].id;
        sum += heightMap.get(itemId) || MIN_ITEM_HEIGHT;
        cumulative.push(sum);
      }
      setCumulativeHeights(cumulative);
    },
    [MIN_ITEM_HEIGHT]
  );

  // 处理子组件的高度更新事件(对应 Vue 的 handleItemHeightUpdate)
  const handleItemHeightUpdate = useCallback(
    (id: number, height: number) => {
      // 高度未变化则跳过
      if (itemHeights.get(id) === height) return;

      // 更新高度映射表
      const newHeightMap = new Map(itemHeights);
      newHeightMap.set(id, height);
      setItemHeights(newHeightMap);

      // 异步更新累计高度
      setTimeout(() => {
        updateCumulativeHeights(newHeightMap, listData);
      }, 0);
    },
    [itemHeights, listData, updateCumulativeHeights]
  );

  // 滚动节流处理
  const handleScroll = useCallback(() => {
    if (!virtualListRef.current) return;

    // 节流:20ms 内只更新一次 scrollTop
    if (scrollTimerRef.current) {
      clearTimeout(scrollTimerRef.current);
    }
    scrollTimerRef.current = setTimeout(() => {
      setScrollTop(virtualListRef.current!.scrollTop);
    }, 20);
  }, []);

  // 可视区域高度
  const viewportHeight = useMemo(() => {
    return virtualListRef.current?.clientHeight || MIN_ITEM_HEIGHT * 5;
  }, []);

  //  总列表高度
  const totalHeight = useMemo(() => {
    return cumulativeHeights[cumulativeHeights.length - 1] || 0;
  }, [cumulativeHeights]);

  // 起始索引
  const startIndex = useMemo(() => {
    const totalItemCount = listData.length;
    if (totalItemCount === 0) return 0;
    if (scrollTop <= 0) return 0;

    // 反向遍历找起始索引
    let baseStartIndex = 0;
    for (let i = cumulativeHeights.length - 1; i >= 0; i--) {
      if (cumulativeHeights[i] <= scrollTop) {
        baseStartIndex = i;
        break;
      }
    }

    const finalIndex = Math.max(0, baseStartIndex - BUFFER);
    return Math.min(finalIndex, totalItemCount - 1);
  }, [
    scrollTop,
    viewportHeight,
    totalHeight,
    cumulativeHeights,
    listData.length,
  ]);

  // 结束索引
  const endIndex = useMemo(() => {
    const totalItemCount = listData.length;
    if (totalItemCount === 0) return 0;

    const targetScrollBottom = scrollTop + viewportHeight;
    let baseEndIndex = totalItemCount - 1;

    for (let i = 0; i < cumulativeHeights.length; i++) {
      if (cumulativeHeights[i] > targetScrollBottom) {
        baseEndIndex = i - 1;
        break;
      }
    }

    let finalEndIndex = baseEndIndex + BUFFER;
    finalEndIndex = Math.min(finalEndIndex, totalItemCount - 1);
    return finalEndIndex;
  }, [scrollTop, viewportHeight, cumulativeHeights, listData.length]);

  // 可视区列表
  const visibleList = useMemo(() => {
    return startIndex <= endIndex
      ? listData.slice(startIndex, endIndex + 1)
      : [];
  }, [startIndex, endIndex, listData]);

  // 偏移量
  const offsetY = useMemo(() => {
    return cumulativeHeights[startIndex] || 0;
  }, [startIndex, cumulativeHeights]);

  return (
    <div className="h-full bg-gradient-to-br from-indigo-600 to-purple-600 py-10 px-5">
      <div className="bg-white mt-10 h-[calc(100vh-200px)] rounded-xl">
        {/* 滚动容器 */}
        <div
          ref={virtualListRef}
          className="h-full overflow-auto relative"
          onScroll={handleScroll}
        >
          {/* 占位容器:撑开滚动条 */}
          <div style={{ height: `${totalHeight}px` }}></div>

          {/* 可视区域列表:transform 偏移 */}
          <div
            className="absolute top-0 left-0 right-0"
            style={{ transform: `translateY(${offsetY}px)` }}
          >
            {visibleList.map((item) => (
              <VirtualListItem
                key={item.id}
                item={item}
                onUpdateHeight={handleItemHeightUpdate}
              />
            ))}
          </div>
        </div>
      </div>
    </div>
  );
};

export default VirtualList;

4. 实现效果图

动高虚拟列表滚动.gif


四、 总结与避坑指南

1. 为什么需要缓冲区(BUFFER)?

如果只渲染可见部分,用户快速滚动时,异步渲染可能会导致瞬间的“白屏”。设置上下缓冲区可以预加载部分 DOM,让滑动更顺滑。

2. 性能进一步优化

  • 滚动节流(Throttle) :虽然滚动监听很快,但在 handleScroll 中加入 requestAnimationFrame 或 20ms 的节流,能有效减轻主线程压力。
  • Key 的选择:在虚拟列表中,key 必须是唯一的 id,绝对不能使用 index,否则在滚动重用 DOM 时会出现状态错乱。

3. 注意事项

  • 定高:逻辑简单,性能极高。
  • 不定高:依赖 ResizeObserver,需注意频繁重排对性能的影响,建议对 updateCumulativeHeights 做异步批处理。
❌