阅读视图

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

万字长文:从零实现 Yjs + Hocuspocus 协同文档

一、整体概述

今天我来复盘一下关于协同文档的部分,这一部分如果要拆解库的原理其实比较复杂,我所做的功能实现是接通这个过程,实现当前项目后端 Express 框架下的集成与调用。

技术栈:Yjs + Hocuspocus + Express + MongoDB + React + BlockNote

回顾一下我实际落地的MVP产品实现:整个协同文档功能从后端服务开始搭建、再接口开发,到前端链路接入、权限模型设计,再到并发冲突处理与断线重连,逐步完成了全流程实现,目前功能基本可用。

整体协同链路:

前端拿 docId 建立 Hocuspocus 连接并带 JWT → 服务端在 onAuthenticate 校验用户、canAccessDocument 校验文档权限 → 通过后加载/保存 yjsState 实现多人实时同步。

二、后端基础服务搭建

Hocuspocus——Yjs 生态下支持 WebSocket 的服务端组件。

Hocuspocus

A reliable and scalable WebSocket server for Yjs, the foundation for real-time collaboration.

一个可靠且可扩展的 Yjs WebSocket 服务器,实时协作的基础。

— Hocuspocus 官方文档

项目后端采用 Express 框架,在开发初期,先把 Hocuspocus 在 Express 后端框架下搭建好,并接入 WebSocket 支持。

项目结构的组织是直接在后端建立了一个 collaboration 文件夹。

── collaboration/        # 协作相关模块目录
    ├── hocuspocus.ts    # Hocuspocus 协作服务
    ├── index.ts         # 入口文件(模块导出/主逻辑)
    └── websocket.ts     # WebSocket 通信逻辑

遇到的困难是 AI 对版本上下文缺失的幻觉,会一直给出已不在当前 Hocuspocus 版本支持的方法。

我的经验是要回归官方文档,而不能依赖 AI 随意生成代码!

也要关注自己实际采用的框架版本,最终按照官方文档支持的 express-ws 实现 WebSocket 连接。

websocket.ts

import { Server as HttpServer, IncomingMessage } from "http";
import { WebSocket } from "ws";
import type { Instance as WsInstance } from "express-ws";
import { hocuspocusServer } from "./hocuspocus.js";
export interface WebSocketHandler {
  setup: (httpServer: HttpServer, wsApp: WsInstance["app"]) => void;
}

export const createWebSocketHandler = (): WebSocketHandler => {
  return {
    setup: (httpServer, wsApp) => {
      wsApp.ws("/", (ws: WebSocket, req: IncomingMessage) => {
        hocuspocusServer.handleConnection(ws, req);
      });

      wsApp.ws("/collaboration", (ws: WebSocket, req: IncomingMessage) => {
        hocuspocusServer.handleConnection(ws, req);
      });

      wsApp.ws(
        "/collaboration/:documentName",
        (ws: WebSocket, req: IncomingMessage) => {
          hocuspocusServer.handleConnection(ws, req);
        },
      );
    },
  };
};

在这一部分做了多路径兼容握手,是因为在控制台测试 WebSocket 连通性时发现了握手阶段就直接被关闭了,有报错。

  • 为什么需要多个路由? HocuspocusProvider 常见连接方式会先连到 url 本身(如 ws://host:port/),也可能尝试连接固定路径或带文档名的动态路径。WebSocket 配置里兼容根路径、固定路径与动态路径,避免握手阶段直接被关闭。

hocuspocus.ts 是服务端协同核心(鉴权、加载 Yjs 状态、持久化 Yjs 状态)。

1. 服务器实例化

export const hocuspocusServer = new Hocuspocus({
  name: "hocuspocus-wiki",
  extensions: [new Logger()],
  // ... 配置选项
});

extensions 为插件系统,Logger() 提供内置的日志记录功能

2. 鉴权 - onAuthenticate

onAuthenticate

Called when a client tries to connect. Use this to verify the token and decide whether to allow the connection.

当客户端尝试连接时调用。用于验证令牌并决定是否允许连接。

— Hocuspocus 官方文档

async onAuthenticate({ token, documentName }) {
  const user = verifyCollaborationToken(token);
  const allowed = await canAccessDocument(documentName, user.userId);
  if (!allowed) {
    throw new Error("permission-denied");
  }
  
  return { user };
}

客户端首次建立 WebSocket 连接时触发认证

  • 令牌验证:verifyCollaborationToken 解析并验证 JWT 或自定义 token
  • 权限检查:canAccessDocument 验证用户是否有权访问该文档
  • 失败处理:抛出异常,WebSocket 连接被拒绝
  • 成功返回:返回用户信息,存储在 context.user 中供后续钩子使用

3. 加载 Yjs 状态 - onLoadDocument

onLoadDocument

Called when a document needs to be loaded. Return a Y.Doc instance to restore the document state.

当需要加载文档时调用。返回 Y.Doc 实例以恢复文档状态。

— Hocuspocus 官方文档

async onLoadDocument({ documentName, context }) {
  const userId = context?.user?.userId as string | undefined;
  if (!userId) {
    throw new Error("permission-denied");
  }
  
  const allowed = await canAccessDocument(documentName, userId);
  if (!allowed) {
    throw new Error("permission-denied");
  }
  
  const stored = await Document.findById(documentName, "yjsState").lean();
  if (!stored?.yjsState) {
    return null;
  }
  
  const ydoc = new Y.Doc();
  const update = Uint8Array.from(stored.yjsState);
  Y.applyUpdate(ydoc, update);
  return ydoc;
}
  • 二次权限验证(双重保险,防止认证后的权限变更)
  • 数据库查询(只返回 yjsState 字段,.lean() 提高性能)
  • 创建 Yjs 文档并应用更新,恢复文档状态
  • 如果数据库中没有文档,返回 null(创建新文档)

Yjs 核心概念

Y.Doc is the root of a Yjs document. It holds all shared data and manages updates.

Y.Doc 是 Yjs 文档的根节点。它包含所有共享数据并管理更新。

An update is a binary representation of changes. applyUpdate applies an update to a Y.Doc to restore state.

Update 是变更的二进制表示。applyUpdate 将更新应用于 Y.Doc 以恢复状态。

— Yjs 官方文档

4. 持久化 Yjs 状态 - onStoreDocument

onStoreDocument

Called periodically to persist the document state. Use this to save the Y.Doc to your database.

定期调用以持久化文档状态。用于将 Y.Doc 保存到数据库。

— Hocuspocus 官方文档

async onStoreDocument({ documentName, document }) {
  const yjsState = Buffer.from(Y.encodeStateAsUpdate(document));
  await Document.findByIdAndUpdate(documentName, {
    yjsState,
    yjsStateUpdatedAt: new Date(),
  });
}

定期自动保存(默认每 30 秒),所有客户端断开连接时手动触发保存

三、核心权限模型与数据库设计

实现思路是分享连接加入协作,但核心不是生成链接本身,而是权限模型,也就是要区分是否为协作成员。

本质是一个文档实体对应多个用户的访问关系,我采用的方式是建立独立的关系表

documentMember.ts 协作成员权限模型。

import mongoose from "mongoose";
const { Schema } = mongoose;

const DocumentMemberSchema = new Schema({
  documentId: { type: Schema.Types.ObjectId, ref: "Document", required: true },
  userId: { type: Schema.Types.ObjectId, ref: "User", required: true },
  role: { type: String, enum: ["editor"], default: "editor" },
  joinedAt: { type: Date, default: Date.now },
});
DocumentMemberSchema.index({ documentId: 1, userId: 1 }, { unique: true });
export default mongoose.model("DocumentMember", DocumentMemberSchema);

代码的其他细节上也需要注意,比如 MongoDB 用 1/-1 排序索引可以提升排序与查询效率;针对时间范围等场景的 Schema 是自定义的,兼顾数据库索引与代码逻辑复用,避免重复编写。

四、后端接口开发与测试

截止这个开发阶段,前端还没有接入相关链路。

前端 service 层要注册两个新路由:获取分享链接、加入文档协作。

export const createShareLink = async (id: string) => {
  const response = await apiClient.post(`/api/documents/${id}/share-link`);
  return response.data;
};

export const joinDocument = async (id: string) => {
  const response = await apiClient.post(`/api/documents/${id}/join`);
  return response.data;
};

核心逻辑在后端的 Controller。

对于协同功能的扩展,这里主要理清了两件事:

  • 旧接口加入身份验证
  • 新接口:加入协作、分享链接。

旧接口中的权限校验加入逻辑:

const isOwner = await Document.exists({ _id: docId, owner: userId });
const isMember = await DocumentMember.exists({ documentId: docId, userId });
if (!isOwner && !isMember) {
  return res.status(404).json({ message: "文档不存在" });
}

分享链接接口:

export const createShareLink = async (req: AuthRequest, res: Response) => {
  try {
    const userId = req.user?.userId;
    if (!userId) {
      return res.status(401).json({ error: "Unauthorized: User not authenticated" });
    }

    const { id: docId } = req.params;
    if (!docId) {
      return res.status(400).json({ error: "Bad Request: docId is required" });
    }

    const document = await Document.findOne({
      _id: docId,
      owner: userId,
    });

    if (!document) {
      return res.status(404).json({ error: "Document not found or you are not the owner" });
    }

    const clientOrigin = process.env.CLIENT_ORIGIN || "http://localhost:5173";
    const shareUrl = `${clientOrigin}/wiki/${docId}?join=1`;

    return res.status(200).json({ shareUrl });
  } catch (error) {
    console.error("Error creating share link:", error);
    return res.status(500).json({ error: "Internal server error" });
  }
};

加入协作接口({ documentId, userId } 幂等加入):

export const createDocumentMember = async (req: AuthRequest, res: Response) => {
  try {
    const userId = req.user?.userId;
    if (!userId) {
      return res.status(401).json({ error: "Unauthorized: User not authenticated" });
    }

    const { id: docId } = req.params;
    if (!docId) {
      return res.status(400).json({ error: "Bad Request: docId is required" });
    }

    const document = await Document.findById(docId);
    if (!document) {
      return res.status(404).json({ error: "Document not found" });
    }

    if (document.owner.toString() === userId) {
      return res.status(200).json({
        message: "You are already the owner of this document",
        isOwner: true,
      });
    }

    await DocumentMember.findOneAndUpdate(
      { documentId: docId, userId: userId },
      {
        documentId: docId,
        userId: userId,
        role: "editor",
        joinedAt: new Date(),
      },
      {
        upsert: true,
        new: true,
        setDefaultsOnInsert: true,
      },
    );

    return res.status(200).json({
      message: "Successfully joined the document",
      documentId: docId,
      role: "editor",
    });
  } catch (error) {
    console.error("Error creating document member:", error);
    return res.status(500).json({ error: "Internal server error" });
  }
};

对于后端接口的测试,是采用 Postman 进行参数输入,并填写对应的 Content-Type 或者参数等,验证接口是否可用。

因为之前项目开发的经验,我的教训是:完成一个路由注册与接口设计后,就可以立即验证是否可用。

五、具体加入协作的流程

这个 Hook 会在检测到 URL 包含 ?join=1 参数时,自动执行加入文档的操作

import { useEffect, useRef } from "react";
import { useParams, useSearchParams } from "react-router-dom";
import { useAuth } from "../contexts/authContext";
import { joinDocument } from "../services/document";
import { notifyDocumentsChanged } from "../constants/documentEvents";

export const useJoinDocumentFromUrl = () => {
  const { user } = useAuth();
  const { docId } = useParams<{ docId: string }>();
  const [searchParams, setSearchParams] = useSearchParams();
  const joinedKeyRef = useRef<string | null>(null);

  useEffect(() => {
    const shouldJoin = searchParams.get("join") === "1";
    if (!shouldJoin) return;
    if (!user || !docId) return;

    const joinKey = `${user.id}:${docId}`;
    if (joinedKeyRef.current === joinKey) return;
    joinedKeyRef.current = joinKey;

    joinDocument(docId)
      .then(() => {
        notifyDocumentsChanged();
      })
      .catch((error) => {
        console.error("加入文档失败:", error);
      })
      .finally(() => {
        setSearchParams(
          (prev) => {
            const next = new URLSearchParams(prev);
            next.delete("join");
            return next;
          },
          { replace: true },
        );
      });
  }, [searchParams, user, docId, setSearchParams]);
};

六、协同开关逻辑与数据加载区分

核心协同 useCollaboration hook 封装实现了 BlockNote 编辑器与 Hocuspocus 协作服务器的集成。

这个 hook 的实现功能包括:

  1. 管理 WebSocket 连接状态
  2. 配置 Yjs 文档同步
  3. 集成 BlockNote 的协作功能

6.1 类型定义

export type CollaborationStatus =
  | "disabled"      // 协作功能未启用
  | "connecting"    // 正在连接服务器
  | "connected"     // 已连接,可协作编辑
  | "disconnected"; // 连接断开

interface CollaborationOptions {
  docId?: string;           // 文档ID(可选,不传则禁用协作)
  userName?: string;        // 用户显示名称
  userColor?: string;       // 用户光标颜色
  editorOptions?: Parameters<typeof useCreateBlockNote>[0]; // 编辑器配置
}

6.2 WebSocket URL 处理

函数:getCollabWsUrl(),智能构建 WebSocket URL,支持多种配置格式:

const getCollabWsUrl = () => {
  const raw = import.meta.env.VITE_COLLAB_WS_URL?.trim();
  
  if (raw) {
    if (raw.startsWith("ws://") || raw.startsWith("wss://")) {
      return raw.replace(/\/+$/, "");
    }
    
    if (raw.startsWith("/")) {
      const protocol = window.location.protocol === "https:" ? "wss" : "ws";
      return `${protocol}://${window.location.host}${raw}`.replace(/\/+$/, "");
    }
    
    const protocol = window.location.protocol === "https:" ? "wss" : "ws";
    return `${protocol}://${raw}`.replace(/\/+$/, "");
  }
  
  return "ws://localhost:3001";
};

6.3 Hook 主体

开发中遇到过页面已打开但协同一直不接通/状态卡住的典型时序问题。

原因在于事件监听注册时机晚于 WebSocket 连接建立,导致 onConnect 等回调未捕获到首连事件。

解决方案:

  • 提前注册回调:在 HocuspocusProvider 构造时同步传入 onConnect、onDisconnect、onStatus,避免依赖 effect 中“后挂”监听导致时序丢失。
  • 显式触发连接:启用协同时主动调用 provider.configuration.websocketProvider.connect(),而非依赖隐式自动连接。
  • 资源清理:组件卸载时执行 provider.destroy() 和 ydoc.destroy(),防止内存泄漏和连接残留。

状态初始化

const isCollaborationEnabled = Boolean(docId);
const [status, setStatus] = useState<CollaborationStatus>(
  isCollaborationEnabled ? "connecting" : "disabled",
);

创建 Provider 和 YDoc

const { provider, ydoc } = useMemo(() => {
  if (!isCollaborationEnabled || !docId) {
    return { provider: null, ydoc: null };
  }

  const ydoc = new Y.Doc();
  const provider = new HocuspocusProvider({
    url: getCollabWsUrl(),
    name: docId,
    document: ydoc,
    token: () => localStorage.getItem("token") || "",
    onConnect: () => setStatus("connected"),
    onDisconnect: () => setStatus("disconnected"),
    onAuthenticationFailed: () => setStatus("disconnected"),
    onStatus: ({ status: nextStatus }) => {
      if (nextStatus === "connected") setStatus("connected");
      else if (nextStatus === "disconnected") setStatus("disconnected");
      else setStatus("connecting");
    },
  });

  return { provider, ydoc };
}, [docId, isCollaborationEnabled]);

协作配置

const collaborationConfig = useMemo(() => {
  if (!provider || !ydoc || !isCollaborationEnabled) return undefined;

  return {
    provider: provider as any,
    fragment: ydoc.getXmlFragment("blocknote"),
    user: {
      name: userName,
      color: userColor,
    },
  };
}, [provider, ydoc, userName, userColor, isCollaborationEnabled]);

创建编辑器实例

const editor = useCreateBlockNote(
  {
    ...(editorOptions ?? {}),
    ...(collaborationConfig ? { collaboration: collaborationConfig } : {}),
  },
  [editorOptions, collaborationConfig, docId, userName, userColor],
);

连接生命周期管理

useEffect(() => {
  if (!isCollaborationEnabled) {
    setStatus("disabled");
    return;
  }

  if (!provider || !ydoc) return;

  setStatus("connecting");
  void provider.configuration.websocketProvider.connect();

  return () => {
    provider.destroy();
    ydoc.destroy();
  };
}, [provider, ydoc, isCollaborationEnabled]);

6.4 与后端对应关系

前端 后端
docId → 文档名 onLoadDocument({ documentName })
token → 认证 onAuthenticate({ token, documentName })
连接状态 WebSocket 连接
ydoc 变更 onStoreDocument 触发

6.5 docId 如何作为协同开关?

App 根组件通过 useParams 从 URL 中取出 ID,并传递给 useCollaboration。

  const { docId } = useParams<{ docId: string }>();

如果当前没有 docId,协同被禁用;如果有 docId,则建立 Hocuspocus 连接,并以 name=docId 启用协同。

  const isCollaborationEnabled = Boolean(docId);

并且在加载逻辑上做了协同/非协同区分:

  // 当 docId 或 editor 变化时,从 API 加载文档内容
  useEffect(() => {
    if (!editor) return;

    // 协同模式下由 Yjs/Hocuspocus 负责文档状态同步,避免 REST 全量覆盖引发冲突。
    if (isCollaborationEnabled) return;

    if (!docId) {
      // 无文档时清空编辑器
      try {
        editor.replaceBlocks(editor.document, [{ type: "paragraph" }]);
      } catch (_) {}
      return;
    }
    let cancelled = false; // 竞态条件:如果在 fetch 过程中 docId 发生变化,或者组件卸载了,就不再执行 setState
    getDocumentById(docId)
      .then((doc) => {
        if (cancelled) return;
        const content =
          doc.content && Array.isArray(doc.content) && doc.content.length > 0
            ? doc.content
            : [{ type: "paragraph" }];
        try {
          editor.replaceBlocks(editor.document, content);
        } catch (_) {}
      })
      .catch(() => {});
    return () => {
      cancelled = true;
    };
  }, [docId, editor, isCollaborationEnabled]);

  • 协同模式:采用 Yjs 二进制存储,数据链路通过 WebSocket 与 Yjs 增量同步
  • 非协同模式:采用 RESTful API 一次性拉取并保存

非协同时走 REST 加载逻辑,避免协同状态下 REST 全量覆盖导致实时同步数据被本地覆盖的冲突风险。

七、鉴权体系设计

项目有两条鉴权链:

1) HTTP 接口鉴权链(REST)(这个之前在我的 JWT 一文中写到过)

  • 前端: axios 请求拦截器把 Authorization Bearer Token 加到每个 API 请求头
  • 后端路由入口: documentRoutes.ts 中 router.use(auth)
  • 后端鉴权中间件: auth.ts 从 Authorization 取 token → jwt.verify 校验 → 挂到 req.user
  • 业务控制器: 再做资源权限判断
  • 前端失败处理: 响应拦截器处理 401,清 token 并跳转 /login

2) 协同 WebSocket 鉴权链(Yjs/Hocuspocus)

  • 前端: useCollaboration.ts 中 HocuspocusProvider 的 token 参数携带 token
  • 后端 WS 接入: websocket.ts 中 hocuspocusServer.handleConnection
  • Hocuspocus 生命周期鉴权: hocuspocus.ts 中 onAuthenticate 调用 verifyCollaborationToken
  • 文档级权限: 调用 canAccessDocument(owner/member 才能协同)
  • 状态加载/存储: 通过后才加载/存储 Yjs 状态(onLoadDocument / onStoreDocument)

八、并发冲突处理与断线重连

关于协同并发,我做了 F12 测试,验证用户并发编辑同一行文字时的处理逻辑。

验证方法是:将其中一个用户通过 F12 Network 切换至断网状态并编辑,另一位协作者在联网状态下对同一行进行编辑,断网用户也在同一行编辑,之后恢复网络。

我观察到的现象是:Yjs 的文档并发处理并非按物理时间先后,而是按身份区分——文档创建者的内容会排在前面,协作者的内容会顺延展示,不依据谁先编辑的时间顺序写入,这样能比较好地处理并发冲突,也借此了解了其他 CRDT 算法的思路。

CRDT (Conflict-free Replicated Data Type)

CRDTs are data structures that enable seamless collaboration by resolving conflicts automatically without a central server. Yjs implements CRDTs to handle concurrent edits.

CRDT 是一种数据结构,通过自动解决冲突实现无缝协作,无需中央服务器。Yjs 实现了 CRDT 来处理并发编辑。

— Yjs 官方文档

CRDT(Conflict-free Replicated Data Type) 的核心:

修改不是按"时间顺序"合并,而是按"身份(client ID) + 某种确定性规则"合并。两个写入永不覆盖,只会按身份优先级排列。

断线重连与状态恢复依赖前端 useCollaboration 通过 Hocuspocus 的 Provider 管理连接生命周期,重连逻辑依托官方文档自带的连接管理能力,属于库内部实现。

Provider & Reconnection

HocuspocusProvider automatically handles reconnection when the WebSocket connection is lost. It will attempt to reconnect and synchronize the document state once the connection is restored.

当 WebSocket 连接丢失时,HocuspocusProvider 会自动处理重连。它将在连接恢复后尝试重新连接并同步文档状态。

— Hocuspocus 官方文档

九、实时感知 awareness 库托管 + 轻量业务注入

Awareness

Awareness is a mechanism for sharing user presence information like cursor positions, selections, and user data. It's built on top of Yjs and works seamlessly with Hocuspocus.

Awareness 是一种共享用户状态信息的机制,如光标位置、选区和用户数据。它基于 Yjs 构建,并与 Hocuspocus 无缝协作。

— Yjs 官方文档

前端把用户感知信息注入协同配置,把本地用户身份(显示名、颜色)交给协同层。

  const collaborationConfig = useMemo(() => {
    if (!provider || !ydoc || !isCollaborationEnabled) return undefined;

    return {
      provider: provider as any,
      fragment: ydoc.getXmlFragment("blocknote"),
      user: {
        name: userName,
        color: userColor,
      },
    };
  }, [provider, ydoc, userName, userColor, isCollaborationEnabled]);

在协同连接状态上增加了一个连接状态指示器,使用 collaborationStatus 入参保留协同状态,提升用户体验。

const currentStatus = STATUS_MAP[collaborationStatus];
const STATUS_MAP: Record<CollaborationStatus, { key: string; color: string }> =
  {
    disabled: {
      key: "footbar.collab_disabled",
      color: "var(--toolbar-icon-color)",
    },
    connecting: {
      key: "footbar.collab_connecting",
      color: "var(--toolbar-icon-color)",
    },
    connected: {
      key: "footbar.collab_connected",
      color: "var(--toolbar-icon-color)",
    },
    disconnected: {
      key: "footbar.collab_disconnected",
      color: "var(--toolbar-icon-color)",
    },
  };

十、总结

现在整体功能基本可用。本次协同文档开发,从服务搭建、前后端链路、权限设计,到并发与重连机制,均围绕 Yjs + Hocuspocus 实现,过程中也明确了依赖官方文档、区分加载与鉴权逻辑等关键经验。

协同文档的底层比较复杂,需要追溯到Yjs相关的算法论文,也常常是前端开发遇到的难点,而我只不过是做了最基础的仅区分owner(文档所有者)和 editor(协作者)两个身份。

这个项目对于我个人收获最大的是权限设计,自定义封装 useCollaboration hook,以及接触了websocket的搭建还有hocuspocus作为协作gateway,在全栈思路上给我很多启发。

特别声明:本次代码实现仅仅是能跑通功能,并不是优雅的做法,存在设计等层面的缺陷,还请见谅。限于个人经验,文中若有疏漏,还请不吝赐教。

Lexical依赖版本冲突与标题渲染

一、概述

昨天主要修复了两个bug:一是 PDF/Doc 导出时的依赖冲突导致的崩溃问题,二是文档标题更新后的 UI 同步问题。本文记录问题现象、排查过程、根因分析及解决方案,并对过程中的技术选型与排查方法进行复盘总结。

二、文件导出

导出文件PDF Doc 格式的时候存在id冲突,文件导出失败。

是因为Lexical编辑器基于节点框架设计,存在依赖冲突或多次初始化导致的问题。

依赖库版本不一致,后续也证实了是这个原因,BlockNote官方开源库已经明确说明,依赖版本要保持唯一、保持一致。 多节点重复使用选择 JSON ID · 问题 #1718 · TypeCellOS/BlockNote --- Duplicate use of selection JSON ID multiple-node · Issue #1718 · TypeCellOS/BlockNote 在这里插入图片描述

关于这个问题的bug追踪,我学会了通过F12控制台查看调用堆栈,分析错误具体出现在哪几层,事件是如何冒泡的。 在这里插入图片描述从下往上看(按时间顺序):

  • 用户点击:在菜单上点了一下(onInternalClick -> onMenuClick)。
  • 进入代码:程序运行到了 useFileExport.ts 的第 43 行,进入了 exportFile 函数。
  • 触发报错:在执行导出逻辑时,代码内部调用了 Lexical 的选择器逻辑(MultipleNodeSelection.ts)。
  • 报错根源:Selection.jsonID 发现 multiple-node 这个 ID 已经被注册过了。

项目可能同时加载了两个不同版本的 @lexical/selection 或相关插件包。当它们各自尝试注册 MultipleNodeSelection 时,ID 就会发生冲突。

Ctrl+P 查找相关文件,查看编译产物是否存在多份。

如果存在多份,可能是依赖版本冲突,也可能是模块解析路径不一致、代码分割策略或构建配置导致的重复打包。需要进一步查看产物中的版本号或模块路径来确认根本原因。

BlockNote 是基于 Lexical 构建的开源富文本编辑器框架,在 Lexical 基础上做了业务层封装。其底层的Lexical编辑器版本可能存在细微差异,在文档导出时,节点对应的ID发生了冲突。各自注册multiple selection相关包时,ID出现冲突,导致无法正确匹配对应实例。

我排查后发现,是之前引入AI库时做了全局配置分发,没有校验BlockNote开源库的版本一致性,这里涉及到依赖配置问题。 在这里插入图片描述 同时pnpm在打包编译和预加载时,也必须保证依赖版本统一。直接采用了pnpm overrides。

因为BlockNote是多层封装的开源库,所以在package.json里无法直接找到对应的Lexical编辑器依赖,需要通过命令行做深度检索和依赖输出。

根本原因:

  • 依赖树分析: 由于 pnpm 的依赖提升(hoisting)机制,以及部分包通过动态 import 懒加载, 导致 @lexical/selection 在不同模块中被解析到了不同的版本路径。 虽然 package.json 中版本范围一致,但实际安装时存在版本漂移或重复实例。

  • 打包逻辑分析: 在 Vite 构建时,由于模块解析(module resolution)的路径规则, 主包和 exporter 模块中的 Lexical 依赖被解析到了不同的 node_modules 路径, 导致运行时加载了多个 Lexical 实例。

  • 运行时分析: 导出操作触发了动态加载 -> 加载了第二个 Lexical 实例 -> 第二个实例尝试在全局注册已存在的 ID -> 崩溃

这次的收获主要是学会看堆栈信息,也考虑一下报错的更多原因。判断编译文件是否存在多个版本,从而快速定位错误。也思考了为什么出现报错的,除了排查代码逻辑,还要思考一下版本冲突等问题。

关于版本管理的问题,比较好的做法我认为应该在CI流程中加入脚本,检查所有 @blocknote/* 包的版本号是否一致。但是现在我还没有着手做这个。目前的package.json也比较少,现在看来自己维护也不是很麻烦。

三、文档标题提取

我一开始认为应该创建一个hook,然后在对应的wikiList(UI层的渲染)里调用这个hook。

但其实不是所有逻辑都适合用hook,写一个纯工具函数也可以。我当时也没太分清hook和utils的区别,自定义hook和utils呢,我个人感觉是有没有涉及到状态变更,像utils其实是没有动态变化的状态要管理的,更多是直接处理数据层。

React 官方文档在 Building Your Own Hooks 中强调:

Custom Hooks let you share stateful logic, not state itself. 自定义 Hook 让你共享有状态的逻辑,而不是状态本身。

关键在"有状态的逻辑"——这意味着自定义 Hook 的本质是封装那些涉及状态管理和副作用的行为。

核心还是要做到UI和逻辑解耦。

然后wiki list要怎么获取当前动态更新的数据呢?

 useEffect(() => {
    const handleUpdate = (e: any) => {
      const { id, title } = e.detail;
      setDocs((prevDocs) =>
        prevDocs.map((doc) =>
          doc._id === id ? { ...doc, title: title } : doc,
        ),
      );
    };

    window.addEventListener("WIKI_TITLE_UPDATED", handleUpdate);
    return () => window.removeEventListener("WIKI_TITLE_UPDATED", handleUpdate);
  }, []);

这里用了事件广播,把事件监听挂载到window对象上。这个做法因为直接挂载在浏览器web上,其实不是太优雅,但是胜在简单。

我觉得需要注意的一点是前后端与数据库的交互逻辑:新建了一个标题title之后,title是怎么存入数据库的,页面刷新之后也要保证逻辑正常。

 updateDocument(currentDocId, {
          title: note.title,
          content: note.content,
        }).catch(() => {});

新增了title字段。这里bug的验证是打印控制台,看看是否存入后端了,当然应该也可以直接看database的GUI界面。

四、小结

本次修复的两个问题虽然规模不大,但涉及依赖管理、架构设计、技术选型等多个维度。日常开发中,应保持对报错信息的敏感度,深入排查根因而非仅修复表象,同时注重代码的可维护性与可扩展性。

特别声明:本次代码实现仅仅是能跑通功能,并不是优雅的做法,存在设计等层面的缺陷,还请见谅。限于个人经验,文中若有疏漏,还请不吝赐教。

彻底淘汰老旧 SVG 插件:unplugin-icons 与 Tailwind CSS v4 自定义图标最佳实践

最近在打包项目的时候发现打包极其的慢,直接vite打包栈溢出,打包失败 进行了一下排查,优化,最终发现罪魁祸首是 vite-plugin-svg-icons

主要原因是:loader 的时候每次都会完整构建一遍,复杂度随着项目文件数目和 svg 文件数目指数上升

  • 依赖极其老旧,停止维护:仓库最后一次更新定格在 4 年前。

  • 严重的性能与内存问题:由于其处理机制的问题,在大型项目中会导致打包极其缓慢,甚至出现栈溢出(OOM)报错(详见 Issue #112#124)。

  • 安全风险:安全扫描工具频频报出底层依赖的漏洞(详见 Issue #123)。

image.png

为了彻底解决这些痛点,我决定将图标系统重构。采用知名开源大佬 Anthony Fu (antfu) 维护的unplugin-icons来处理组件化图标,并结合最新的 Tailwind CSS v4 及 Iconify 官方插件来实现 CSS 类的自定义图标方案。这不仅极大提升了打包速度,还让图标的使用变得前所未有的灵活。

image.png

下面是详细的迁移与配置流程。

第一步:安装插件

pnpm i -D unplugin-icons

第二步:安装图标数据

使用 Iconify 作为图标数据源(支持 100+ 个图标集)

VS Code 用户:安装 Iconify IntelliSense 扩展以获得内联预览、自动完成和悬停信息

pnpm i -D @iconify/json

完整安装 这将安装所有图标集(约 120MB)。只有你实际使用的图标才会在生产环境中被打包。

安装单个图标集

仅安装你需要的图标集:

pnpm i -D @iconify-json/mdi @iconify-json/carbon

自动安装(实验性)

让 unplugin-icons 在你导入图标集时自动安装它们:

Icons({
  autoInstall: true, // Auto-detects npm/yarn/pnpm
})

构建工具配置

// vite.config.ts
import Icons from 'unplugin-icons/vite'

export default defineConfig({
  plugins: [
    Icons({ /* options */ }),
  ],
})

依据使用的框架配置 compiler 选项

Icons({ compiler: 'vue3' })

通过在导入路径中添加 ?raw 来将图标作为原始 SVG 字符串导入。适用于直接在 HTML 模板中嵌入 SVG。

<script setup lang='ts'>
import RawMdiAlarmOff from '~icons/mdi/alarm-off?raw&width=4em&height=4em'
import RawMdiAlarmOff2 from '~icons/mdi/alarm-off?raw&width=1em&height=1em'
</script>

<template>
  <!-- raw example -->
  <pre>
    import RawMdiAlarmOff from '~icons/mdi/alarm-off?raw&width=4em&height=4em'
    {{ RawMdiAlarmOff }}
    import RawMdiAlarmOff2 from '~icons/mdi/alarm-off?raw&width=1em&height=1em'
    {{ RawMdiAlarmOff2 }}
  </pre>
  <!-- svg example -->
  <span v-html="RawMdiAlarmOff" />
  <span v-html="RawMdiAlarmOff2" />
</template>

每一个图标就是一个组件

自定义图标

unplugin-icons 默认支持通过 @iconify/json 使用海量的开源图标库,但由于我们是从旧插件迁移,项目里肯定有大量业务专属的本地 SVG 文件。

我们需要通过 FileSystemIconLoader 来加载这些自定义图标。

// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' // 如果你使用的是 Vue
import Icons from 'unplugin-icons/vite'
import { FileSystemIconLoader } from 'unplugin-icons/loaders'
import tailwindcss from '@tailwindcss/vite'

export default defineConfig({
  plugins: [
    vue(),
    tailwindcss(), // Tailwind v4 的 Vite 插件
    
    // unplugin-icons 配置
    Icons({
      // 指定编译器,根据你的框架选择 'vue3', 'react', 'svelte' 等
      compiler: 'vue3', 
      autoInstall: true,
      customCollections: {
        // 这里的 'custom' 是你自定义图标集合的名称
        // 参数一是你本地 SVG 文件夹的相对路径
        // 参数二是可选的转换函数,通常用于将 svg 的 fill 或 stroke 替换为 currentColor 以支持 CSS 动态改色
        'custom': FileSystemIconLoader(
          './src/assets/svg', 
          svg => svg.replace(/^<svg /, '<svg fill="currentColor" ')
        ),
      },
    }),
  ],
})

使用方式为

import IconAccount from '~icons/my-icons/account'
import IconFoo from '~icons/my-other-icons/foo'
import IconBar from '~icons/my-yet-other-icons/bar'

使用解析器自动导入

使用自动导入时,注册你的自定义集合名称:

// vite.config.ts
IconResolver({
  customCollections: [
    'local',
    'my-other-icons',
    'my-yet-other-icons',
  ],
})

直接使用

<i-local-account/>

组件命名

图标按照以下命名规则自动导入:

{prefix}-{collection}-{icon}

prefix : 组件名称前缀(默认值: i )

collection : Iconify 集合 ID(例如, mdi 、 carbon 、 fa-solid )

icon : 图标名称(kebab-case)

自定义前缀

IconsResolver({
  prefix: 'icon', // Use 'icon' instead of 'i'
})
无前缀: false
  <icon-mdi-account />
 <mdi-account />

设置图标集别名

IconsResolver({
  alias: {
    park: 'icon-park',  // Use <icon-park-* /> instead of <icon-icon-park-* />
    fas: 'fa-solid',    // Use <icon-fas-* /> instead of <icon-fa-solid-* />
  }
})

配置 Tailwind CSS v4 及自定义图标(CSS 类方案)

安装

pnpm i -D @iconify/tailwind4

插件不包含图标。您需要添加要使用的图标集。

您也可以通过安装 @iconify-json/{prefix} 依赖项(其中"{prefix}"是图标集前缀)来仅安装您想要使用的图标集,例如 @iconify-json/mdi-light

Tailwind CSS v4 带来了革命性的变化,最大的区别就是去掉了 tailwind.config.js,所有的配置直接在 CSS/全局样式文件中通过 CSS At-rules(@规则)完成。

借助 @iconify/tailwind4 插件,我们不仅能用原子类写公共开源图标,还能直接把本地存放 SVG 的文件夹映射为 Tailwind 的原子类!

修改你的主 CSS 文件(例如 src/style.csssrc/main.css):

/* 引入 Tailwind v4 核心 */
@import "tailwindcss";

/* 1. 全局配置:直接引入完整的 Iconify 支持(如果需要用到海量开源图标) */
@plugin "@iconify/tailwind4";

/* 2. 自定义本地 SVG 配置 */
@plugin "@iconify/tailwind4" {
  /* from-folder(前缀名, 文件夹路径)
    这里我们将 src/assets/svg 文件夹映射为 `local` 集合
  */
  icon-sets: from-folder(local, "./src/assets/icons");
}

注意:Iconify 插件在底层会自动清理并优化 from-folder 加载的 SVG,如果图片是单色,它会自动转化为 mask,以完美支持 Tailwind 的 text-red-500 等颜色类名。

要使用图标,请为图标添加动态选择器,例如

<span class="icon-[mdi-light--home]"></span>

还可以自定义设置图标的前缀和大小(默认为1em)

@plugin "@iconify/tailwind4" {
  prefix: "iconify";
  scale: 1.2;
}

自定义图标

加载图标集有两种方法:

  • 加载以 IconifyJSON 格式预解析的图标集。
  • 加载本地文件夹中的所有svg文件

配置示例

@plugin "@iconify/tailwind4" {
  icon-sets: from-json(test, "./icon-sets/test.json"), from-folder(test2, "./icon-sets/svgs");
}

在 CSS 的插件配置中添加“icon-sets”选项,选项集以逗号分隔。

从 JSON 文件加载速度更快,因为无需进行清理操作

文件必须为 IconifyJSON 格式,可使用 Iconify Tools 生成。

如果您项目里的本地 SVG 图标非常多(比如几百上千个),每次项目启动时使用 from-folder 让 Vite 在运行时去逐个读取、清理和转化 SVG,依然会消耗一定的构建时间。

更优雅且极致的解决方案是:使用 Iconify 官方提供的 @iconify/tools,编写一个独立js脚本或是vite插件,将所有的本地 SVG 预先处理、压缩,并打包成一个 .json 文件。 之后无论是 Tailwind v4 还是 unplugin-icons,直接读取这个 JSON 文件即可,实现“零运行时开销”。

具体详细配置可查看文档@iconify/tools

弊端:脚本在处理文件时会把所有颜色都换成了 currentColor。如果你的图标全是单色的菜单 Icon,这很完美。但如果你的文件夹里混入了一个多色的插画 SVG(比如带有蓝色衣服、黄色帽子的彩色 Logo),经过脚本处理后,它会变成黑乎乎的一团(也就是失去了原本的彩色)

额外类名

每个图标有 2 个类名:

图标的类名,例如“mdi-light--home”。

渲染模式的类名:"iconify" 或 "iconify-color"(可配置)。

所有图标均遵循相同的规则,图片 URL 除外。

为避免代码重复,通用规则已被拆分为实用类。此外,这还允许您选择图标的渲染方式:

“iconify” 会将图标渲染为蒙版图像,因此图标会采用与文本相同的颜色。若要更改图标颜色,请更改文本颜色。此方法适用于未硬编码配色方案的图标。

“iconify-color” 将图标渲染为背景图像。此功能适用于具有硬编码调色板的图标。

为什么需要配置?

Tailwind CSS 的工作原理是查找代码中的类名,并为这些类名生成相应的 CSS 样式。

在使用动态类名(例如“icon-[mdi-light--home]”)时,Tailwind CSS 会查找所有此类类名,并将它们传递给插件以生成 CSS。这意味着插件知道使用了哪些图标,并仅加载所需的图标。

然而,当使用普通类名(例如“mdi-light--home”)时,Tailwind CSS 需要先通过插件为所有可能的类名生成 CSS,然后再在项目中查找类名,最后移除未使用的类名。这意味着插件必须为所有可能存在的图标生成 CSS。

为每个图标生成 CSS 并非快速的过程。鉴于可用的图标超过 275,000 个,这可能会耗费大量时间。此外,Tailwind CSS 会将所有内容保存在内存中,这可能会导致 Tailwind CSS 内存不足。为避免这种情况,您必须指定要使用的图标集列表。

配置完后直接通过类名生成图标

<i class="text-blue-500 text-xl icon-[local--user]" />

总结

通过移除四年前的 vite-plugin-svg-icons,并引入 unplugin-icons + @iconify/tailwind4

  1. 彻底告别了项目打包时的内存泄漏(OOM) ,打包速度肉眼可见地提升。
  2. 我们享受到了 antfuIconify 社区持续活跃维护带来的红利,告别了安全漏洞警告。
  3. 拥抱了下一代构建工具 Tailwind CSS v4 的极简 CSS 架构。

希望这篇文章能帮助正在使用老旧 Vben 等模板架构的开发者们成功渡劫!如果有问题,欢迎在评论区交流。

使用micro-app 多层嵌套的问题

micro-app 多层嵌套问题解决方案

版本说明:本文讨论的 micro-app 版本为截止发稿日期的最新版 1.0.0-rc.27

一、问题背景

1.1 业务场景

在实际开发中,我们遇到了一个三层嵌套的微前端场景:

基座应用 → 中间应用 → 子应用
  • 技术栈:Vue 3 + Vite
  • 架构层级:三层嵌套结构
  • 业务需求:中间应用和子应用需要进行频繁的数据交互的场景

1.2 官方文档说明

micro-app 官方文档针对 Vite 项目给出了使用 iframe 模式的建议: image.png 官方文档虽然提到了支持多层嵌套,但并未给出具体的实现示例和注意事项: image.png

1.3 问题现象

当中间层应用使用 iframe 模式时,第三层子应用会出现**栈溢出(Stack Overflow)**错误:

Maximum call stack size exceeded

image.png

这个问题在 GitHub Issues 中也有多人反馈,但官方尚未给出明确的解决方案。


二、问题原因分析

2.1 根本原因

经过深入分析和测试,问题的根本原因如下:

  1. 资源查找机制问题:当基座应用和中间层应用都启用 iframe 模式后,第三层子应用在查找 iframe 标签资源时,会向上查找父级应用。

  2. 循环查找导致栈溢出

    • 第三层应用向上查找时,找到的是基座应用而非中间层应用
    • 基座应用再次下发资源
    • 第三层应用继续向上查找
    • 形成无限循环,最终导致栈溢出
  3. iframe 标签的资源查找逻辑:micro-app 在处理 Vite 项目的 iframe 模式时,资源查找机制在多层级嵌套场景下存在缺陷。

2.2 测试验证

我们对不同技术栈和框架进行了测试,测试结果如下: image.png

基座应用 中间应用 子应用 是否出现栈溢出
Vite + iframe Vite + iframe Vite ❌ 是
Vite + iframe Vite + iframe Webpack ❌ 是
Vite + iframe Webpack Vite ✅ 否
Vite + iframe Webpack Webpack ✅ 否

结论:不论第三层使用什么技术栈,只要第二层(中间应用)使用了 iframe 模式,就会出现栈溢出问题。


三、解决方案

方案一:使用原生 iframe 标签(不推荐)

实现方式

第三层子应用使用原生的 `` 标签,而不是 micro-app 标签。

优点
  • ✅ 完全避免栈溢出问题
  • ✅ 实现简单,无需额外配置
缺点
  • ❌ 失去了 micro-app 的所有优势(样式隔离、JS 沙箱、通信机制等)
  • ❌ 需要重新实现微前端的各种能力
  • ❌ 与现有架构不兼容,需要大量改造工作
  • ❌ 性能较差,用户体验不佳
适用场景

仅适用于对微前端能力要求不高的简单嵌入场景。 不需要频繁的进行数据交互及ui风格统一等。


方案二:中间层不使用 iframe 模式(不推荐)

实现方式

中间层应用不使用 iframe 模式,改用 Webpack 构建或其他方式。

优点
  • ✅ 可以避免栈溢出问题
  • ✅ 保持 micro-app 的完整能力
缺点
  • ❌ 需要将 Vite 项目改回 Webpack,技术倒退
  • ❌ 失去 Vite 的快速构建和开发体验
  • ❌ 不符合当前主流技术趋势
  • ❌ 团队需要重新学习 Webpack 配置
适用场景

仅适用于可以接受技术栈变更的项目。


方案三:第三层使用基座应用的标签(推荐⭐)

这是本文重点推荐的解决方案,通过让第三层子应用直接使用基座应用的 micro-app 标签,绕过中间层的资源查找问题。

3.1 核心思路
  • 第三层子应用不再通过中间层应用加载
  • 直接使用基座应用的 micro-app 标签进行渲染
  • 通过基座应用实现中间层和子应用之间的通信
3.2 实现步骤
步骤一:将基座应用的 micro-app 挂载到全局

在基座应用中,将 micro-app 实例挂载到全局对象,以便子应用能够访问:

// 基座应用:main.js 或 bootstrap.js
import microApp from '@micro-zoe/micro-app';

// 权限校验函数(可选)
function accessMicroAppName(appName) {
  // 根据业务需求实现权限校验逻辑
  // 例如:检查当前子应用是否有权限访问指定的子应用
  return true;
}

// 将 micro-app 方法挂载到全局
window.microApp = {
  setData(...args) {
    if (!accessMicroAppName(args[0])) {
      return;
    }
    microApp.setData(...args);
  },

  addDataListener(...args) {
    if (!accessMicroAppName(args[0])) {
      return;
    }
    microApp.addDataListener(...args);
  },

  getData(...args) {
    if (!accessMicroAppName(args[0])) {
      return null;
    }
    return microApp.getData(...args);
  },

  removeDataListener(...args) {
    if (!accessMicroAppName(args[0])) {
      return;
    }
    microApp.removeDataListener(...args);
  },
};

注意事项

  • 建议添加权限校验,防止子应用越权访问
  • 可以根据业务需求选择性暴露方法
步骤二:基座应用设置动态标签名称

基座应用在初始化时,设置动态标签名称,并通过 setGlobalData 传递给子应用:

// 基座应用:micro-app 初始化
import microApp from '@micro-zoe/micro-app';

// 定义动态标签名称常量
const MICRO_APP_TAGNAME = 'micro-app-base';

// 初始化 micro-app
microApp.start({
  tagName: MICRO_APP_TAGNAME, // 使用自定义标签名
  lifeCycles: {
    // 生命周期钩子
  },
  preFetchApps: [
    // 预加载应用列表
  ],
});

// 通过 setGlobalData 将标签名传递给子应用
microApp.setGlobalData({
  microAppTagName: MICRO_APP_TAGNAME,
});

子应用接收 image.png

步骤三:中间层应用创建动态组件

在中间层应用中,创建一个动态组件,使用基座应用的标签名称:



  



import { ref, computed, onMounted } from 'vue';

interface Props {
  appName: string;
  appUrl: string;
  embedPath?: string;
  appData?: Record;
}

const props = defineProps();

// 从全局数据中获取基座应用的标签名
const microAppTagName = ref('micro-app');

// 监听全局数据变化,获取标签名
onMounted(() => {
  if (window.microApp) {
    window.microApp.addDataListener((data: any) => {
      if (data?.microAppTagName) {
        microAppTagName.value = data.microAppTagName;
      }
    }, true); // true 表示立即执行一次

    // 获取初始数据
    const globalData = window.microApp.getData();
    if (globalData?.microAppTagName) {
      microAppTagName.value = globalData.microAppTagName;
    }
  }
});

const handleDataChange = (e: CustomEvent) => {
  // 处理子应用数据变化
  emit('dataChange', e.detail.data);
};

const emit = defineEmits(['dataChange']);

简单版: image.png

步骤四:使用动态组件并传递参数

在中间层应用的页面中,使用动态组件:



  <div class="sub-app-container">
    
  </div>



import { ref, watch } from 'vue';
import MicroApp from './MicroApp.vue';

const subAppName = ref('sub-app-name');
const subAppUrl = ref('https://sub-app.example.com');
const embedPath = ref('/page1'); // 通过 default-page 传递路由参数
const appData = ref({});

// 监听参数变化,更新子应用
watch(embedPath, (newPath) => {
  // 参数变化时,子应用会自动更新
});

const handleSubAppDataChange = (data: any) => {
  // 处理子应用数据变化
  console.log('子应用数据变化:', data);
};

简版: image.png

步骤五:实现参数传递和数据通信

中间层应用通过基座应用的 setData 方法向子应用传递数据:

// 中间层应用:参数传递
import { ref } from 'vue';

const embedPath = ref('/page1');

// 更新子应用参数
const updateSubAppPath = (newPath: string) => {
  embedPath.value = newPath;

  // 通过基座应用向子应用传递数据
  if (window.microApp) {
    window.microApp.setData(subAppName.value, {
      path: newPath,
      timestamp: Date.now(),
    });
  }
};

// 监听子应用数据变化
if (window.microApp) {
  window.microApp.addDataListener((data: any) => {
    console.log('收到子应用数据:', data);
    // 处理子应用返回的数据
  }, subAppName.value);
}

image.png

3.3 方案优势
  • 解决栈溢出问题:第三层直接使用基座应用的标签,绕过中间层的资源查找
  • 保持微前端能力:仍然可以使用 micro-app 的所有功能
  • 支持频繁交互:通过基座应用实现中间层和子应用之间的数据通信
  • 避免白屏问题:子应用不会因为参数变化而重新加载,提升用户体验
  • 支持多子应用:每个子应用都可以使用独立的标签,互不干扰
  • 技术栈兼容:支持 Vite + Vue 3 技术栈
3.4 注意事项
  1. 通信机制:中间层应用和子应用的通信需要通过基座应用进行,不能直接通信
  2. 权限控制:建议在基座应用中实现权限校验,防止子应用越权访问
  3. 标签名称:确保基座应用的标签名称唯一,避免冲突
  4. 数据管理:需要合理设计数据传递机制,避免数据混乱
3.5 架构示意图
┌─────────────────────────────────────┐
│           基座应用                   │
│  ┌───────────────────────────────┐  │
│  │  micro-app (tagName: 'base')  │  │
│  │  ┌─────────────────────────┐  │  │
│  │  │    中间层应用             │  │  │
│  │  │  ┌───────────────────┐  │  │  │
│  │  │  │  动态组件          │  │  │  │
│  │  │  │             │  │  │  │
│  │  │  │    ┌───────────┐  │  │  │  │
│  │  │  │    │ 子应用    │   │  │  │  │
│  │  │  │    └───────────┘  │  │  │  │
│  │  │  └───────────────────┘  │  │  │
│  │  └─────────────────────────┘  │  │
│  └───────────────────────────────┘  │
└─────────────────────────────────────┘

四、方案对比

方案 解决栈溢出 保持微前端能力 技术栈兼容 实现复杂度 推荐度
方案一:原生 iframe ⭐⭐
方案二:中间层不用 iframe ⭐⭐⭐ ⭐⭐
方案三:使用基座标签 ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐

五、总结

5.1 问题根源

micro-app 1.x 版本在处理 Vite 项目的多层嵌套场景时,当中间层应用使用 iframe 模式,会导致第三层子应用在资源查找时出现循环查找,最终引发栈溢出。

5.2 最佳实践

推荐使用方案三:让第三层子应用直接使用基座应用的 micro-app 标签,通过基座应用实现中间层和子应用之间的通信。这样既解决了栈溢出问题,又保持了微前端的完整能力。

5.3 注意事项

  1. 确保基座应用的标签名称唯一且可配置
  2. 实现完善的权限校验机制
  3. 合理设计数据传递和通信机制
  4. 注意处理子应用的生命周期管理

5.4 未来展望

希望 micro-app 官方能够在后续版本中:

  • 修复多层嵌套场景下的资源查找问题
  • 提供更完善的多层嵌套示例和文档
  • 优化 Vite 项目的 iframe 模式支持

【更新】有人已经给出了解决方案,大家如果遇到同类问题,可以用此方案试试~ github.com/jd-opensour…

image.pnggithub.com/jd-opensour…

企业微信截图_5798ebde-4dc0-4c49-a0b2-4eb23d46cb9a.png

六、参考资料


SwiftUI路由管理架构揭秘:从混乱到优雅的蜕变

引言

想象一下:当你打开一个 App,点击不同标签页,切换页面时,所有导航状态都能完美保持;当你从详情页返回时,TabBar 能智能地重新出现;当你需要传递数据时,类型安全的导航能让你告别字符串硬编码的烦恼。这一切,都离不开一个优秀的路由管理架构。

在现代 iOS 应用开发中,路由管理常常被视为"基础设施"而被忽视,但其重要性却不亚于任何核心功能。一个设计良好的路由系统,不仅能让代码结构更清晰,还能显著提升用户体验。今天,我将带大家深入剖析我项目中的路由管理架构,分享从设计到实现的全过程,希望能为你的项目带来启发。

路由架构概览

我项目的路由管理基于 SwiftUI 的 NavigationStackNavigationPath,采用了集中式的路由管理方案。核心组件包括:

  • Router 类:全局导航路由器,管理所有 Tab 的导航路径
  • MainTab 枚举:定义应用的标签页结构
  • MainContainerView:主容器视图,负责整合标签页和导航逻辑
  • App 启动注入:在应用启动时将 Router 注入到环境中

路由的启动注入

EviApp.swift 中,我们通过 @StateObject 创建 Router 实例,并通过 environmentObject 将其注入到应用环境中:

import SwiftUI

@main
struct EviApp: App {
    // 把 AppDelegate 接进来,系统会照常调用 didFinishLaunchingWithOptions 等
    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
    
    // 全局弹框管理器
    @StateObject private var overlay = GlobalOverlayManager.shared
    // 全局导航路由器
    @StateObject private var router = Router()
    
    var body: some Scene {
        WindowGroup {
            MainContainerView()
                .environmentObject(overlay)
                .environmentObject(router)
        }
    }
}

这样,在应用的任何视图中,都可以通过 @EnvironmentObject 来访问 Router 实例,实现全局路由管理。

核心组件分析

1. Router 类:路由管理的核心

import SwiftUI

/// 全局导航路由器,管理所有Tab的导航路径
class Router: ObservableObject {
    
    // 当前选中的Tab
    @Published var selectedTab: MainTab = .home
    
    // 为每个tab单独存储NavigationPath
    @Published var homePath = NavigationPath()
    @Published var hotPath = NavigationPath()
    @Published var creationPath = NavigationPath()
    @Published var stylePath = NavigationPath()
    @Published var profilePath = NavigationPath()
    
    // MARK: - 获取导航路径
    
    /// 获取指定tab的导航路径
    func getNavigationPath(for tab: MainTab) -> NavigationPath {
        switch tab {
        case .home: return homePath
        case .hot: return hotPath
        case .creation: return creationPath
        case .style: return stylePath
        case .profile: return profilePath
        }
    }
    
    /// 获取指定tab的导航路径绑定
    func getNavigationPathBinding(for tab: MainTab) -> Binding<NavigationPath> {
        switch tab {
        case .home: return binding(for: \.homePath)
        case .hot: return binding(for: \.hotPath)
        case .creation: return binding(for: \.creationPath)
        case .style: return binding(for: \.stylePath)
        case .profile: return binding(for: \.profilePath)
        }
    }
    
    // MARK: - 清空导航路径
    
    /// 清空指定tab的导航路径
    func clearPath(for tab: MainTab) {
        switch tab {
        case .home: clear(\.homePath)
        case .hot: clear(\.hotPath)
        case .creation: clear(\.creationPath)
        case .style: clear(\.stylePath)
        case .profile: clear(\.profilePath)
        }
    }
    
    /// 清空所有导航路径
    func clearAllPaths() {
        clear(\.homePath)
        clear(\.hotPath)
        clear(\.creationPath)
        clear(\.stylePath)
        clear(\.profilePath)
    }
    
    // MARK: - 当前Tab操作
    
    /// 获取当前选中Tab的导航路径
    func getCurrentNavigationPath() -> NavigationPath {
        return getNavigationPath(for: selectedTab)
    }
    
    /// 获取当前选中Tab的导航路径绑定
    func getCurrentNavigationPathBinding() -> Binding<NavigationPath> {
        return getNavigationPathBinding(for: selectedTab)
    }
    
    /// 清空当前选中Tab的导航路径
    func clearCurrentPath() {
        clearPath(for: selectedTab)
    }
    
    // MARK: - 私有辅助方法
    
    /// 创建导航路径的绑定
    private func binding(for keyPath: ReferenceWritableKeyPath<Router, NavigationPath>) -> Binding<NavigationPath> {
        Binding {
            self[keyPath: keyPath]
        } set: {
            self[keyPath: keyPath] = $0
        }
    }
    
    /// 清空指定的导航路径
    private func clear(_ keyPath: ReferenceWritableKeyPath<Router, NavigationPath>) {
        self[keyPath: keyPath].removeLast(self[keyPath: keyPath].count)
    }
}

设计亮点

  • 集中管理:所有路由逻辑集中在一个类中,便于统一管理
  • Tab 隔离:为每个标签页维护独立的导航路径,确保切换标签时不会影响其他标签的导航状态
  • 响应式设计:使用 @Published 修饰符,实现路由状态的自动更新
  • 便捷方法:提供了丰富的方法来操作导航路径,如获取路径、清空路径等

2. MainTab 枚举:标签页定义

import SwiftUI

/// 主标签栏枚举
enum MainTab {
    case home
    case hot
    case creation
    case style
    case profile
}

extension MainTab {
    
    /// 根据选中状态返回对应的图标名称
    func iconName(isSelected: Bool) -> String {
        switch self {
        case .home:
            return isSelected ? "tabbar_home_sel" : "tabbar_home_nor"
        case .hot:
            return isSelected ? "tabbar_hot_sel" : "tabbar_hot_nor"
        case .creation:
            return "tabbar_add"
        case .style:
            return isSelected ? "tabbar_style_sel" : "tabbar_style_nor"
        case .profile:
            return isSelected ? "tabbar_me_sel" : "tabbar_me_nor"
        }
    }
}

设计亮点

  • 类型安全:使用枚举定义标签页,避免了字符串硬编码
  • 扩展功能:通过扩展为枚举添加了获取图标名称的功能,使代码更整洁

3. MainContainerView:路由的实际应用

import SwiftUI

/// 主容器视图,包含悬浮TabBar
struct MainContainerView: View {
    
    // 获取指定tab的导航路径
    private func getNavigationPath(for tab: MainTab) -> NavigationPath {
        return router.getNavigationPath(for: tab)
    }
    
    /// 创建带有NavigationStack的标签页视图
    private func tabView(_ tab: MainTab) -> some View {
        NavigationStack(path: router.getNavigationPathBinding(for: tab)) {
            switch tab {
            case .home:
                HomeView()
            case .hot:
                HotHomeView()
            case .creation:
                CreationHomeView()
            case .style:
                StyleHomeView()
            case .profile:
                ProfileHomeView()
            }
        }
        .tag(tab)
    }
    
    @StateObject private var appConfigManager = AppConfigManager.shared
    
    @EnvironmentObject private var overlay: GlobalOverlayManager
    @EnvironmentObject private var router: Router
    
    var body: some View {
        if appConfigManager.appConfig != nil {
            ZStack {
                
                // 真正负责页面生命周期的容器
                TabView(selection: $router.selectedTab) {
                    tabView(.home)
                    tabView(.hot)
                    tabView(.creation)
                    tabView(.style)
                    tabView(.profile)
                }
                
                // 你的悬浮TabBar,根据当前选中标签的导航路径长度控制显示
                if isTabBarVisible {
                    VStack {
                        Spacer()
                        FloatingTabBar(selectedTab: $router.selectedTab)
                            .padding(.horizontal, 16)
                            .padding(.bottom, 20)
                    }
                }
                
                // 全局弹框显示
                if let current = overlay.current {
                    
                    // 遮罩
                    Color.black.opacity(0.4)
                        .ignoresSafeArea()
                        .onTapGesture {
                            overlay.dismiss()
                        }
                    
                    switch current {
                    case .login:
                        LoginOverlayView(onClose: {
                            overlay.dismiss()
                        })
                        .transition(.flipFromBottom)
                    }
                }
                
            }
            .animation(.easeInOut(duration: 0.25), value: overlay.current)
        } else {
            // 显示空View
            EmptyView()
                .background(ThemeManager.Background.global)
        }
    }
    
    var isTabBarVisible: Bool {
        return getNavigationPath(for: router.selectedTab).count == 0
    }
}

设计亮点

  • NavigationStack 集成:为每个标签页创建独立的 NavigationStack
  • TabBar 智能显示:根据当前导航路径长度控制 TabBar 的显示/隐藏
  • 环境对象注入:使用 @EnvironmentObject 注入 Router,实现全局访问
  • 动画效果:添加了平滑的过渡动画,提升用户体验

路由管理的实现细节

1. 路径管理机制

路由系统的核心是 NavigationPath 的管理。NavigationPath 是 SwiftUI 4.0+ 引入的类型,它是一个类型擦除的容器,可以存储任意类型的导航目的地。

在我们的实现中:

  • 每个标签页都有自己的 NavigationPath 实例
  • 通过 getNavigationPathBinding 方法获取路径的绑定,用于 NavigationStack
  • 提供了 clearPathclearAllPaths 方法来清空导航路径

2. 标签页切换逻辑

当用户切换标签页时:

  1. router.selectedTab 的值会更新
  2. TabView 会根据新的 selectedTab 显示对应的标签页
  3. 由于每个标签页有独立的 NavigationPath,切换标签不会影响其他标签的导航状态

3. 导航路径的实际使用

在具体的视图中,可以通过以下方式使用路由:

// 在视图中注入 Router
@EnvironmentObject private var router: Router

// 使用全局路由管理进行导航
let currentPath = router.getCurrentNavigationPathBinding()
// 向当前路径添加新页面
currentPath.wrappedValue.append(AppNavigationDestination.materialDetail(material))

// 清空当前标签页的导航路径
router.clearCurrentPath()

4. 导航目的地定义

项目使用 AppNavigationDestination 枚举来定义导航目的地:

import Foundation
import SwiftUI

/// 导航目标枚举
enum AppNavigationDestination: Hashable {
    case accountLogin
    case materialDetail(MaterialListDTOElement)
}

这种方式的优势:

  • 类型安全:使用枚举定义导航目的地,避免了字符串硬编码
  • 参数传递:可以在导航时传递相关数据,如 materialDetail 中的 MaterialListDTOElement
  • 可扩展性:可以轻松添加新的导航目的地

5. NavigationStack 中处理导航目的地

在使用 NavigationStack 时,需要处理导航目的地的显示逻辑。通常在根视图中添加 navigationDestination 修饰符:

NavigationStack(path: router.getNavigationPathBinding(for: tab)) {
    HomeView()
        .navigationDestination(for: AppNavigationDestination.self) { destination in
            switch destination {
            case .accountLogin:
                AccountLoginView()
            case .materialDetail(let material):
                MaterialDetailView(material: material)
            }
        }
}

这样,当我们通过 currentPath.wrappedValue.append(AppNavigationDestination.materialDetail(material)) 导航时,NavigationStack 会自动显示对应的目标视图。

6. 完整导航流程示例

下面是一个完整的导航流程示例,展示从触发导航到显示目标页面的全过程:

// 1. 在视图中注入 Router
@EnvironmentObject private var router: Router

// 2. 定义导航触发事件
Button("查看素材详情") {
    // 3. 获取当前路径绑定
    let currentPath = router.getCurrentNavigationPathBinding()
    // 4. 向路径添加导航目的地
    currentPath.wrappedValue.append(AppNavigationDestination.materialDetail(selectedMaterial))
}

// 5. 在根视图中处理导航目的地
NavigationStack(path: router.getNavigationPathBinding(for: .home)) {
    HomeView()
        .navigationDestination(for: AppNavigationDestination.self) { destination in
            switch destination {
            case .materialDetail(let material):
                MaterialDetailView(material: material)
            default:
                EmptyView()
            }
        }
}

// 6. 从详情页返回
Button("返回") {
    // 清空当前路径,返回根视图
    router.clearCurrentPath()
}

7. 导航路径与 TabBar 显示的关联

MainContainerView 中,通过 isTabBarVisible 计算属性控制 TabBar 的显示:

var isTabBarVisible: Bool {
    return getNavigationPath(for: router.selectedTab).count == 0
}

当导航路径为空时(即处于标签页的根视图),显示 TabBar;当导航路径不为空时(即进入了子页面),隐藏 TabBar,为用户提供更大的内容显示区域。

优势与最佳实践

优势

  1. 清晰的职责分离:路由逻辑与 UI 逻辑分离,使代码更易于维护
  2. 类型安全:使用枚举和类型化的导航路径,减少运行时错误
  3. 状态管理:集中管理路由状态,避免状态分散
  4. 灵活性:可以轻松添加新的标签页和导航目的地
  5. 用户体验:标签页切换时保持各自的导航状态,提升用户体验

最佳实践

  1. 统一的路由入口:所有导航操作都通过 Router 进行,避免直接操作 NavigationPath
  2. 合理的路径清理:在适当的时机清理导航路径,避免内存占用过高
  3. 导航目的地的类型定义:为导航目的地创建明确的类型,提高代码可读性
  4. 错误处理:添加适当的错误处理,确保导航操作的稳定性
  5. 测试:为路由逻辑编写单元测试,确保其正确性

代码优化建议

  1. 导航目的地类型化

    // 建议为每个标签页创建导航目的地枚举
    enum HomeDestination {
        case detail(id: String)
        case search
    }
    
    // 然后在导航时使用
    router.homePath.append(HomeDestination.detail(id: "123"))
    
  2. 添加导航日志

    // 添加导航日志,便于调试和分析用户行为
    func appendToPath(_ value: some Hashable, for tab: MainTab) {
        let path = getNavigationPathBinding(for: tab)
        path.wrappedValue.append(value)
        print("Navigate to \(value) in tab \(tab)")
    }
    
  3. 导航路径持久化

    // 可以考虑在应用进入后台时保存导航状态,在应用启动时恢复
    func saveNavigationState() {
        // 保存导航状态到 UserDefaults 或其他存储
    }
    
    func restoreNavigationState() {
        // 从存储中恢复导航状态
    }
    
  4. 添加路由拦截器

    // 可以添加路由拦截器,用于处理登录验证等场景
    func appendToPath(_ value: some Hashable, for tab: MainTab) {
        if needsAuthentication(for: value) {
            // 显示登录界面
            overlay.present(.login)
        } else {
            let path = getNavigationPathBinding(for: tab)
            path.wrappedValue.append(value)
        }
    }
    

总结

通过以上分析,我们可以看到,一个良好的路由管理架构对于 iOS 应用的重要性。我项目中的路由架构采用了集中式管理、Tab 隔离、响应式设计等原则,通过 Router 类、MainTab 枚举和 MainContainerView 的配合,实现了清晰、灵活、用户友好的导航体验。

这种路由架构不仅适用于当前项目,也可以作为其他 SwiftUI 项目的参考。通过不断优化和扩展,可以构建更加完善的路由系统,为用户提供更加流畅的应用体验。

希望这篇文章能够帮助大家更好地理解和实现 iOS 项目中的路由管理架构。如果你有任何问题或建议,欢迎在评论区留言讨论!

❌