阅读视图

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

万字长文:从零实现 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界面。

四、小结

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

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

万字长文:从零实现 JWT 鉴权

一、JWT 鉴权概述

今天来回顾一下之前做的 JWT 鉴权。

JWT(JSON Web Token)鉴权的核心不是加密,而是无状态协议下的身份校验。

在 Express 环境下,一个完整的 JWT 鉴权流程通常包含三个关键环节:

  1. 颁发(Issue):用户登录成功后,服务器生成 Token。
  2. 存储与传递(Storage & Transmission):前端如何保存,请求时如何携带。
  3. 拦截与校验(Middleware):后端如何识别并解析这个字符串。

官方文档(RFC 7519) EN: JSON Web Token (JWT) is a compact, URL-safe means of representing claims to be transferred between two parties. The claims in a JWT are encoded as a JSON object that is used as the payload of a JSON Web Signature (JWS) structure or as the plaintext of a JSON Web Encryption (JWE) structure, enabling the claims to be digitally signed or integrity protected with a Message Authentication Code (MAC) and/or encrypted. 中文:JSON Web Token(JWT)是一种紧凑、URL 安全的表示声明的方式,用于在两方之间传输声明信息。JWT 中的声明被编码为 JSON 对象,用作 JSON Web Signature(JWS)结构的载荷或 JSON Web Encryption(JWE)结构的明文,使声明能够通过消息认证码(MAC)进行数字签名或完整性保护,和/或进行加密。

二、后端实现:中间件与 JWT 校验

2.1 中间件的概念与职责

Express 中间件是在请求进入路由处理、响应返回客户端之前,执行逻辑校验、数据加工、拦截等操作的函数。

官方文档(Express 官方文档) EN: Middleware functions are functions that have access to the request object (req), the response object (res), and the next middleware function in the application’s request-response cycle. The next middleware function is commonly denoted by a variable named next. 中文:中间件函数是可以访问应用请求-响应周期中请求对象(req)、响应对象(res)以及下一个中间件函数的函数。下一个中间件函数通常由名为 next 的变量表示。

在实现这个模块时,采用的是后端先行、接口先行的方式,先从后端 API 开始写。

这里有一件值得反思的工程实践:后端接口是否可用,不应该等到所有代码写完以后再去测试,效率较低。更合理的方式是在接口链路打通后,就在 Postman 里测一下是否可用。

在后端工程流程里,可以采用 JWT 官方提供的一些方法。它的调用方式是:中间件就像一道安检,校验是否携带了所需的 token,是否能从 JWT 中拿到需要的状态。

关于中间件的写法,我也是参考官方示例。有一个比较重要的函数是 nextnext 用于声明当前处理完成,然后交给下一个处理程序。next 必须显式调用,否则 request 会一直处于挂起状态,无法返回 response。

官方文档(Express 官方文档) EN: The next function is a callback function that invokes the next middleware function in the stack. If the current middleware function does not end the request-response cycle, it must call next() to pass control to the next middleware function. Otherwise, the request will be left hanging. 中文next 函数是一个回调函数,用于调用堆栈中的下一个中间件函数。如果当前中间件函数未结束请求-响应周期,则必须调用 next() 将控制权传递给下一个中间件函数,否则请求将处于挂起状态。

Express 中错误处理中间件必须定义 4 个参数 (err, req, res, next),只有这样才会被识别为错误捕获中间件;普通中间件/路由处理函数为 2–3 个参数,不存在“两个参数即为终止型中间件”的规则。

官方文档(Express 官方文档) EN: Error-handling middleware functions are defined the same way as other middleware functions, except with four arguments instead of three: (err, req, res, next). 中文:错误处理中间件函数的定义方式与其他中间件函数相同,区别在于需要传入四个参数而非三个:(err, req, res, next)

2.2 auth 中间件实现

express 中间件 auth,用于验证 JWT token 并将用户信息注入到请求对象中

import type { NextFunction, Request, Response } from "express";
import jwt from "jsonwebtoken";

export interface AuthPayload extends jwt.JwtPayload {
  userId: string;
  username: string;
}

export interface AuthRequest extends Request {
  user?: AuthPayload;
}
const JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET) {
  throw new Error("FATAL ERROR: JWT_SECRET is not defined.");
}
export const auth = (req: AuthRequest, res: Response, next: NextFunction) => {
  const token = req.headers.authorization?.split(" ")[1];

  if (!token) return res.sendStatus(401);

  try {
    const decoded = jwt.verify(token, JWT_SECRET);

    if (typeof decoded === "string") {
      return res.status(403).json({ message: "Token invalid" });
    }

    if (!decoded.userId || !decoded.username) {
      return res.status(403).json({ message: "Token invalid" });
    }

    req.user = decoded as AuthPayload;

    next();
  } catch (_) {
    return res.status(403).json({ message: "Token invalid" });
  }
};

2.3 JWT 验证的两个核心问题

我记得这里重要的 api 是 verify,还有弄清楚 JWT 承载用户信息的部分是哪里,要回答两个问题:

1. 如何验证 JWT token?

const decoded = jwt.verify(token, JWT_SECRET);

官方文档(jsonwebtoken 官方文档) EN: The verify function takes a token, a secret or public key, and an optional callback function. It verifies the token's signature, checks if the token is expired, and decodes the payload. 中文verify 函数接收一个 token、一个密钥或公钥,以及一个可选的回调函数。它会验证 token 的签名、检查 token 是否过期,并对载荷进行解码。

jwt.verify() 的执行顺序:先对 Token 做 base64url 解码(无需密钥),再验证签名是否合法,最后检验 exp 等过期/时效声明;任何一步不通过都会抛出错误,解码一定会发生,只是非法结果不会被业务使用。

2. 如何把 JWT 内承载的用户信息注入到 req 里面?

注入发生在验证成功后:

req.user = decoded as AuthPayload;

requser 属性被解密后的明文赋值,注入数据,后续路由就可以通过调用这个中间件得到 token 里包含的信息。

其实也就是中间件在后端的作用。我认为中间件是在 request 和 response 之间进行逻辑校验或数据加工。

请求 → 中间件1 → 中间件2 → 路由处理 → 响应
         ↑
    这里做校验、加工、拦截

JWT 的核心是签名验证,而不是加密。payload 只是 base64url 编码,可以解码查看,但无法篡改,因为篡改后签名会失效。

官方文档(RFC 7519) EN: JSON Web Signature (JWS) is an integrated set of specifications for representing content secured with digital signatures or Message Authentication Codes (MACs) using JSON-based data structures. 中文:JSON Web Signature(JWS)是一套集成的规范集,用于使用基于 JSON 的数据结构表示通过数字签名或消息认证码(MAC)保护的内容。

三、数据层设计:MongoDB 与 Mongoose

把中间件处理、也就是 JWT 校验做好后,就开始设计数据库集合(Collection)的 schema,确定要存入哪些数据、登录需要哪些字段,把 schema 字段和加密逻辑配置好。

import mongoose, { Document, Schema } from "mongoose";
import bcrypt from "bcrypt";

export interface IUser extends Document {
  username: string;
  password: string;
  createdAt: Date;
  updatedAt: Date;
}

const userSchema = new Schema<IUser>(
  {
    username: {
      type: String,
      required: [true, "用户名不能为空"],
      unique: true,
      trim: true,
      minlength: [3, "用户名至少 3 个字符"],
      maxlength: [20, "用户名最多 20 个字符"],
    },
    password: {
      type: String,
      required: [true, "密码不能为空"],
      minlength: [6, "密码至少 6 个字符"],
      select: false,
    },
  },
  {
    timestamps: true,
  },
);
userSchema.pre("save", async function (next) {
  if (!this.isModified("password")) {
    return next();
  }

  const salt = await bcrypt.genSalt(10);
  this.password = await bcrypt.hash(this.password, salt);
  next();
});

const User = mongoose.model<IUser>("User", userSchema);

export default User;

其中,密码加密的 pre("save") 中间件负责密码哈希。

官方文档(Mongoose 官方文档) EN: Mongoose schemas support pre and post hooks for middleware functions. These hooks are functions that are executed before or after a certain event (like save, find, etc.) occurs. 中文:Mongoose 的 Schema 支持用于中间件函数的 pre 和 post 钩子。这些钩子是在特定事件(如 savefind 等)发生之前或之后执行的函数。

3.1 关于 MongoDB 和 Mongoose

MongoDB 本身是 schemaless(无模式) 的,意思是:

  • 你可以往同一个集合里存完全不同的结构
  • 没有强制的字段类型、必填校验
  • 没有自动的钩子(如密码加密)

这很灵活,但大型项目里容易造成:

  • 数据混乱(有的文档有 username,有的没有)
  • 业务逻辑散落各处
  • 难以维护

官方文档(MongoDB 官方文档) EN: MongoDB is a document-oriented database program. Classified as a NoSQL database program, MongoDB uses JSON-like documents with optional schemas. 中文:MongoDB 是一个面向文档的数据库程序。作为 NoSQL 数据库程序,MongoDB 使用具有可选模式的类 JSON 文档。

Mongoose 的作用就是给 MongoDB 加上“规矩”:

  • 定义数据结构(Schema)
  • 自动验证类型、必填、长度等
  • 提供钩子(pre/post)自动处理逻辑(如密码加密)
  • 封装常用的 CRUD 方法

官方文档(Mongoose 官方文档) EN: Mongoose is a MongoDB object modeling tool designed to work in an asynchronous environment. Mongoose provides a straight-forward, schema-based solution to model your application data. 中文:Mongoose 是一个设计用于异步环境的 MongoDB 对象建模工具。Mongoose 提供了一种直观的、基于模式的解决方案来为你的应用数据建模。

前端 → 后端控制器(Controller) → 服务层(Service) → Model → MongoDB
  • Model 是数据层(Data Layer):它封装了所有与数据库直接交互的逻辑
  • 业务逻辑层(Service) 调用 Model 的方法来读写数据
  • 控制器(Controller) 处理 HTTP 请求,调用 Service
  • 这样分层的好处:替换数据库时只需改动 Model 层,业务逻辑不变

Model 设计的必要性

  • 集中管理数据规则(验证、加密、默认值)
  • 避免在多个地方重复写密码加密、字段校验的代码
  • 保证数据一致性

四、业务逻辑层:Controller 实现

于是在 model 定义好以后,我们可以写好 controller,对应业务逻辑。这一块比较核心,代码也比较多,我贴出示例的完整代码参考思路:

// ============================================
// authController.ts - 业务逻辑
// ============================================

import jwt from "jsonwebtoken";
import bcrypt from "bcrypt";
import { Request, Response } from "express";
import User from "../models/userModel.js";
import { AuthRequest } from "../middleware/auth.js";

const JWT_SECRET = process.env.JWT_SECRET!;

// 辅助函数:生成 JWT 并组装返回数据
const buildAuthPayload = (user: any) => {
  const token = jwt.sign(
    { userId: String(user._id), username: user.username },
    JWT_SECRET,
    { expiresIn: "24h" }
  );
  
  return {
    token: token,
    user: {
      id: String(user._id),
      username: user.username
    }
  };
};

// ========== 登录 ==========
export const login = async (req: Request, res: Response) => {
  const { username, password } = req.body;
  
  if (!username || !password) {
    return res.status(400).json({ message: "用户名和密码不能为空" });
  }
  
  const user = await User.findOne({ username }).select("+password");
  
  if (!user) {
    return res.status(401).json({ message: "用户名或密码错误" });
  }
  
  const isMatch = await bcrypt.compare(password, user.password);
  
  if (!isMatch) {
    return res.status(401).json({ message: "用户名或密码错误" });
  }
  
  return res.status(200).json(buildAuthPayload(user));
};

// ========== 注册 ==========
export const register = async (req: Request, res: Response) => {
  const { username, password } = req.body ?? {};
  
  if (!username || !password) {
    return res.status(400).json({ message: "用户名和密码不能为空" });
  }
  
  if (password.length < 6) {
    return res.status(400).json({ message: "密码至少6个字符" });
  }
  
  try {
    const existingUser = await User.findOne({ username });
    if (existingUser) {
      return res.status(409).json({ message: "用户名已存在" });
    }
    
    const user = await User.create({ username, password });
    // ↑ 保存时自动触发 pre("save") 钩子加密密码
    
    return res.status(201).json({
      message: "注册成功",
      ...buildAuthPayload(user)
    });
  } catch (error) {
    if (error instanceof Error) {
      return res.status(400).json({ message: error.message });
    }
    return res.status(500).json({ message: "服务器错误" });
  }
};

// ========== 获取当前用户 ==========
export const me = async (req: AuthRequest, res: Response) => {
  try {
    const userId = req.user?.userId;
    
    if (!userId) {
      return res.sendStatus(401);
    }
    
    const user = await User.findById(userId).select("_id username");
    
    if (!user) {
      return res.sendStatus(401);
    }
    
    return res.status(200).json({
      user: {
        id: String(user._id),
        username: user.username
      }
    });
  } catch (error) {
    return res.status(500).json({ message: "服务器错误" });
  }
};

五、路由层:接口注册与请求流程

最后编写注册、登录相关路由。

1. 主应用挂载路由模块
   app.use("/api/auth", authRoutes)
              ↓
2. 请求进入,匹配前缀 "/api/auth"
              ↓
3. 进入 authRoutes 模块,匹配具体路径
   router.post("/login", login)
              ↓
4. 完整路径 = "/api/auth/login"
              ↓
5. 执行对应的控制器函数
import "dotenv/config";
import { Router } from "express";
import { login, me, register } from "../controllers/authController.js";
import { auth } from "../middleware/auth.js";

const router: Router = Router();

router.post("/login", login);
router.post("/register", register);
router.get("/me", auth, me);

export default router;

官方文档(Express 官方文档) EN: A router is an isolated instance of middleware and routes. You can use a router to group related routes together and apply middleware to a subset of your application's routes. 中文:Router 是中间件和路由的独立实例。你可以使用 Router 将相关路由分组,并将中间件应用到应用程序路由的子集上。

导入依赖以后,创建全局 Router 实例,定义路由并进行后端注册,目的是之后前端路由请求可以匹配到后端,后端也就调用相关的 controller 处理数据。

router.get("/me", auth, me) 为例:

  • 方法:GET
  • 路径/me
  • 中间件链authme
  • 场景:获取当前登录用户的信息
  • 执行流程
    1. 请求先进入 auth 中间件
    2. auth 验证 token,把用户信息挂到 req.user
    3. 验证通过后调用 next(),进入 me 控制器
    4. me 控制器从 req.user 读取信息返回

5.1 后端完整请求流程图

以上可以得到后端的完整请求流程图:

┌─────────────────────────────────────────────────────────────────┐
│                        注册流程                                  │
├─────────────────────────────────────────────────────────────────┤
│ POST /api/auth/register { username, password }                  │
│                           ↓                                     │
│ authRoutes → router.post("/register", register)                 │
│                           ↓                                     │
│ register 控制器                                                  │
│   ├── 验证 username/password 存在                                │
│   ├── 验证密码长度 ≥ 6                                           │
│   ├── 检查用户名是否已存在                                       │
│   ├── User.create({ username, password })                       │
│   │        ↓                                                    │
│   │   pre("save") 钩子: bcrypt 哈希密码                          │
│   │        ↓                                                    │
│   │   存入 MongoDB                                               │
│   ├── buildAuthPayload() → jwt.sign() 生成 token                 │
│   └── 返回 { token, user }                                       │
└─────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────┐
│                        登录流程                                 │
├─────────────────────────────────────────────────────────────────┤
│ POST /api/auth/login { username, password }                     │
│                           ↓                                     │
│ authRoutes → router.post("/login", login)                       │
│                           ↓                                     │
│ login 控制器                                                     │
│   ├── 验证 username/password 存在                                │
│   ├── User.findOne({ username }).select("+password")             │
│   ├── bcrypt.compare(明文密码, 哈希密码)                          │
│   ├── buildAuthPayload() → jwt.sign() 生成 token                 │
│   └── 返回 { token, user }                                       │
└─────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────┐
│                      获取当前用户流程                            │
├─────────────────────────────────────────────────────────────────┤
│ GET /api/auth/me                                                │
│ Header: Authorization: Bearer <token>                           │
│                           ↓                                     │
│ authRoutes → router.get("/me", auth, me)                        │
│                           ↓                                     │
│ auth 中间件                                                      │
│   ├── 提取 token                                                 │
│   ├── jwt.verify(token, JWT_SECRET)                             │
│   └── req.user = { userId, username }                           │
│                           ↓                                     │
│ me 控制器                                                        │
│   ├── userId = req.user?.userId                                 │
│   ├── User.findById(userId).select("_id username")              │
│   └── 返回 { user: { id, username } }                            │
└─────────────────────────────────────────────────────────────────┘

5.2 加密与验证对照表

关于加密与验证:

环节 位置 方法 目的
密码加密 userModel.ts bcrypt.hash() 注册时把明文密码转成哈希存储
密码比对 login 控制器 bcrypt.compare() 登录时验证用户输入的密码
JWT 签发 buildAuthPayload() jwt.sign() 登录/注册成功后生成 token
JWT 验证 auth 中间件 jwt.verify() 后续请求验证 token 有效性

实际上,这个时候应该可以测一测后端接口了,用 Postman 测试一下后端已启动服务时是否能接通。我之前是前端也写了才去测,觉得效率很低。


六、前端实现:路由与状态管理

我来看一下前端的 router 导航

import React from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import App from "../App";
import { LoginPage } from "../components/Login";
import { useAuth } from "../contexts/authContext";

const RequireAuth = ({ children }: { children: React.ReactElement }) => {
  const { user, isLoading } = useAuth();

  if (isLoading) return <div>Loading...</div>;
  if (!user) return <Navigate to="/login" replace />;

  return children;
};

const RedirectIfAuthenticated = ({
  children,
}: {
  children: React.ReactElement;
}) => {
  const { user, isLoading } = useAuth();

  if (isLoading) return <div>Loading...</div>;
  if (user) return <Navigate to="/wiki" replace />;

  return children;
};

const AppRoutes: React.FC = () => {
  return (
    <Routes>
      <Route path="/" element={<Navigate to="/login" replace />} />
      <Route
        path="/login"
        element={
          <RedirectIfAuthenticated>
            <LoginPage />
          </RedirectIfAuthenticated>
        }
      />
      <Route
        path="/wiki"
        element={
          <RequireAuth>
            <App />
          </RequireAuth>
        }
      />
      <Route
        path="/wiki/:docId"
        element={
          <RequireAuth>
            <App />
          </RequireAuth>
        }
      />
      <Route path="*" element={<Navigate to="/login" replace />} />
    </Routes>
  );
};

export default AppRoutes;

官方文档(React Router 官方文档) EN: React Router enables “client side routing” for React apps. It allows you to build single-page applications with navigation that doesn’t require a page refresh. 中文:React Router 为 React 应用提供“客户端路由”能力,允许你构建具有导航功能且无需页面刷新的单页应用。

后端已经通了,前端的作用是发起请求,这里的路由导航只是跳转页面,并且前端服务要遵循所有业务功能都在用户认证通过后才能使用的原则,也就是 service 的服务调用逻辑,这些是前端要做的事情。

6.1 前端 API 服务层

前端 service 封装好 API 调用层,封装与后端认证接口的通信逻辑。

以 auth 为例

import apiClient from "./client";
import type { AuthUser } from "../contexts/authContext";

interface AuthResponse {
  token: string;
  user: AuthUser;
}

export const loginApi = (data: { username: string; password: string }) =>
  apiClient.post<AuthResponse>("/api/auth/login", data);

export const registerApi = (data: { username: string; password: string }) =>
  apiClient.post<AuthResponse>("/api/auth/register", data);

export const meApi = () => apiClient.get<{ user: AuthUser }>("/api/auth/me");

6.2 前后端数据流

完整的数据流

业务代码调用 apiClient.post("/api/auth/me")
                ↓
        【请求拦截器】
   从 localStorage 读取 token
   添加 Authorization: Bearer <token>
                ↓
        发送请求到后端
                ↓
       后端验证 token
                ↓
┌───────────────────────────────────────┐
│ token 有效 → 返回 200 + 用户数据       │
│ token 无效/过期 → 返回 401             │
└───────────────────────────────────────┘
                ↓
        【响应拦截器】
                ↓
┌───────────────────────────────────────┐
│ 200 → 直接返回 response               │
│ 401 → 清除 token,跳转 /login         │
└───────────────────────────────────────┘
                ↓
        业务代码拿到结果

6.3 全局认证状态管理

之前我在想项目逻辑要求登录后才能使用相关功能,也就是原来无登录状态的所有路由,都要在登录路由保护下才能访问。怎样让这些接口自动带上鉴权,认为实现起来比较难。

其实也不是很难,可以用一个 authContext 登录状态的全局状态分发。

import React, { createContext, useEffect, useState } from "react";
import { meApi } from "../services/auth";

export interface AuthUser {
  id: string;
  username: string;
}

export interface AuthContextType {
  // 1. 核心状态:当前用户是谁?
  user: AuthUser | null;

  // 2. 状态:是否正在初始化(从 LocalStorage 加载中)?
  // 提示:这能防止页面在检查 Token 时闪现“未登录”状态
  isLoading: boolean;

  // 3. 方法:登录成功后调用的函数
  // 它需要接收后端给的 token 和 user 对象
  login: (token: string, user: AuthUser) => void;

  // 4. 方法:退出登录
  // 它需要清理 LocalStorage 和 context 状态
  logout: () => void;
}
export const AuthContext = createContext<AuthContextType | undefined>(
  undefined,
);
export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
  const [user, setUser] = useState<AuthUser | null>(null);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    const initAuth = async () => {
      try {
        const token = localStorage.getItem("token");
        if (!token) {
          return;
        }

        const res = await meApi();
        setUser(res.data.user);
      } catch (_) {
        localStorage.removeItem("token");
        setUser(null);
      } finally {
        // 保证无论成功/失败都结束 loading
        setIsLoading(false);
      }
    };

    initAuth();
  }, []);

  const login = (token: string, user: AuthUser) => {
    localStorage.setItem("token", token);
    setUser(user);
  };

  const logout = () => {
    localStorage.removeItem("token");
    setUser(null);
    window.location.href = "/login";
  };

  return (
    <AuthContext.Provider value={{ user, isLoading, login, logout }}>
      {children}
    </AuthContext.Provider>
  );
};
export const useAuth = () => {
  const context = React.useContext(AuthContext);
  if (!context) throw new Error("useAuth must be used within an AuthProvider");
  return context;
};

存储方案上选择了 localStorage,因为 sessionStorage 在会话关闭、页面关闭后重新打开需要重新登录,使用起来比较麻烦。

但需要注意:localStorage 易受到 XSS 攻击,生产环境更推荐使用 httpOnly Cookie 存储 JWT。(这一块后续我要了解一下)

这里涉及请求头、axios 实例配置(client.ts)、前端请求拦截器(axios)、前端注册接口、文档相关接口、全局状态管理、登录状态管理等等。

最后在前端导航要做重定向,默认定向到登录页,实现UX上的交互。

六、小结

其实这里的内容是前天做的,中间耽搁了一会儿,当时实现的时候觉得困难重重——主要是因为之前没有建立好后端实现的思路,具体实现的细节,并且也没有相对应的概念。

从项目的角度来讲,我更深体会到了前后端协作,也就是之前看到的前端要懂业务,虽说这里有关前端的细节写得不是太多,但是前端要能知道后端要做什么,从产品的角度来讲每一个开发者都要有全栈能力,但是从公司的角度来说业务体系庞大才拆分的前端与后端等等岗位。

从面试的角度,这里也涉及到很多JWT鉴权细节上的考量,随便深挖就会揪出更多底层原理,例如我随意问一问:什么是 JWT?为什么要用 JWT?JWT 和 Session 的区别?登录流程:如何颁发 Token?请求流程:如何校验 Token?前端怎么存?怎么带?中间件在 JWT 里做什么?

啊哈......现在一看又觉得自己不懂了,只是没有把这些表达再深入内化一下,先慢慢来,慢慢深挖学习更多内容。

坚持学习,坚持反思,加油!

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

AJAX vs Fetch API:Promise 与异步 JavaScript 怎么用?

今天在学习promise的时候,看到一些比较早的教程,其中提到有一个重要的概念就是AJAX

尽管也许现代的做法更常见的是用Fetch API ,但是我也可以了解一下旧版实现里的做法,也能够帮助理解早期的异步 API,理解老项目的代码是如何做的。

关于异步JS(Promise)的前置知识,有关细节补充可阅读文档:异步 JavaScript 简介

我理解为promise的出现是异步编程中防止传统回调嵌套函数写法(回调地狱)。promise是现代 JavaScript 异步编程的基础。

常常见到的await async等其实是一种语法糖,使得写法简洁易读,并且有关try catch 错误异常的捕获和管理会比较方便(对比于原先采用catch统一管理错误的办法...)。这样的写法看起来是同步代码的长相,其实底层是异步编程。

早期异步Web API: XMLHttpRequest(AJAX)

AJAX全称为Asynchronous JavaScript and XML(异步JavaScript和XML),是一种在无需重新加载整个网页的情况下,能够更新部分网页的技术。

它通过在后台与服务器进行少量数据交换,使得网页可以实现异步更新。这意味着可以在不重新加载整个网页的情况下,对网页的某部分进行更新。

示例:

const log = document.querySelector(".event-log");
document.querySelector("#xhr").addEventListener("click", () => {
  log.textContent = "";
  const xhr = new XMLHttpRequest();
  xhr.addEventListener("loadend", () => {
    log.textContent = `${log.textContent}完成!状态码:${xhr.status}`;
  });
  xhr.open(
    "GET",
    "https://raw.githubusercontent.com/mdn/content/main/files/en-us/_wikihistory.json",
  );
  xhr.send();
  log.textContent = `${log.textContent}请求已发起\n`;
});
document.querySelector("#reload").addEventListener("click", () => {
  log.textContent = "";
  document.location.reload();
});
<button id="xhr">点击发起请求</button>
<button id="reload">重载</button>

<pre readonly class="event-log"></pre>

点击“点击发起请求”按钮来发送一个请求。我们将创建一个新的 XMLHttpRequest 并监听它的 loadend 事件。loadend 事件在请求完成时总会触发,无论成功还是失败。如果需要区分成功和失败,可以分别监听 load(成功)和 error(失败)事件。

而我们的事件处理程序则会在控制台中输出一个“完成!”的消息和请求的状态代码。

AJAX的工作原理基于一系列现有的互联网标准,主要包括以下几个方面:

  • XMLHttpRequest对象:这是AJAX的核心,它提供了在网页加载后从服务器请求数据的能力。
  • JavaScript/DOM:用于动态显示和交互的信息。
  • CSS:用于定义数据的样式。
  • XML:作为数据传输的格式,尽管现在JSON格式更为常用。

XMLHttpRequest

XMLHttpRequest API 使 web 应用能够通过 JavaScript 向 web 服务器发起 HTTP 请求并接收响应。这使得网站能够仅更新页面中的部分内容(使用服务器返回的数据),而无需跳转至全新页面。这种做法有时也被称为 AJAX

Fetch API 是取代 XMLHttpRequest API 的更灵活、更强大的方案。

Fetch API 使用 promise 替代事件机制处理异步响应,对 service worker 支持良好,并支持 HTTP 的高级特性,如跨源资源共享控制

基于这些优势,现代 web 应用通常采用 Fetch API 替代 XMLHttpRequest

XMLHttpRequest 用于在后台与服务器交换数据。这意味着可以在不重新加载整个网页的情况下,对网页的某部分进行更新。XMLHttpRequest(XHR)对象用于与服务器交互。通过 XMLHttpRequest 可以在不刷新页面的情况下请求特定 URL,获取数据。这允许网页在不影响用户操作的情况下,更新页面的局部内容。

AJAX能允许网页在不影响用户操作的情况下,与服务器进行数据交换和更新。例如Google地图、新浪微博等,依托核心还是XMLHttpRequest。

实现AJAX

通常需要以下几个步骤:

  1. 创建XMLHttpRequest对象:这是所有AJAX请求的起点。

  2. 发送请求到服务器:使用*open()send()*方法,可以指定请求的类型(如GET或POST),URL以及是否异步。

  3. 处理服务器响应:通过监听onreadystatechange事件,可以在请求的不同阶段执行不同的操作。当readyState属性变为4,且status属性表示请求成功时,可以处理响应数据。

  4. 更新网页内容:使用JavaScript操作DOM,可以根据服务器的响应更新网页的特定部分。

跨域问题和解决方法

在使用AJAX时,可能会遇到跨域问题,即浏览器出于安全考虑,限制了来自不同源的HTTP请求。解决跨域问题的方法包括:

CORS(Cross-Origin Resource Sharing):通过服务器设置适当的HTTP响应头,可以允许特定的外部域访问资源。

JSONP(JSON with Padding):通过动态创建*

AJAX的优势和注意事项

AJAX的主要优势在于提高了用户体验,通过异步更新可以减少等待时间,使得Web应用程序更加快速和响应。然而,也需要注意一些问题,例如:

浏览器兼容性:不同浏览器对AJAX的支持程度可能不同,需要进行充分的测试。

用户体验:需要合理设计用户界面,以便在数据加载过程中给予用户适当的反馈。

网络延迟:应考虑到网络延迟对用户体验的影响,并采取相应的优化措施。

总的来说,AJAX技术使得Web开发进入了一个新的阶段,它允许开发者创建出更加动态和交互性强的网页应用。


使用Fetch API与Promise

如何使用 Promise

MDN的教程已经讲解的非常好了,我们一起来跟着学一学,现代使用Fetch API 的做法。

在基于 Promise 的 API 中,异步函数会启动操作并返回一个 Promise 对象。

首先,Promise 有三种状态:

  • 待定(pending):初始状态,既没有被兑现,也没有被拒绝。这是调用 fetch() 返回 Promise 时的状态,此时请求还在进行中。
  • 已兑现(fulfilled):意味着操作成功完成。当 Promise 完成时,它的 then() 处理函数被调用。
  • 已拒绝(rejected):意味着操作失败。当一个 Promise 失败时,它的 catch() 处理函数被调用。

注意,这里的“成功”或“失败”的含义取决于所使用的 API:例如,fetch() 认为服务器返回一个错误(如 404 Not Found)时请求成功,但如果网络错误阻止请求被发送,则认为请求失败。

有时我们用已敲定(settled)这个词来同时表示已兑现(fulfilled)和已拒绝(rejected)两种情况。

如果一个 Promise 已敲定,或者如果它被“锁定”以跟随另一个 Promise 的状态,那么它就是已解决(resolved)的。

(关于术语:Let's talk about how to talk about promises


然后,你可以将处理函数附加到 Promise 对象上,当操作完成时(成功或失败),这些处理函数将被执行。

const fetchPromise = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);

console.log(fetchPromise);

fetchPromise.then((response) => {
  console.log(`已收到响应:${response.status}`);
});

console.log("已发送请求……");
  1. 调用 fetch() API,并将返回值赋给 fetchPromise 变量。
  2. 紧接着,输出 fetchPromise 变量,输出结果应该像这样:Promise { <state>: "pending" }。这告诉我们有一个 Promise 对象,它有一个 state属性,值是 "pending""pending" 状态意味着操作仍在进行中。
  3. 将一个处理函数传递给 Promise 的 then() 方法。当(如果)获取操作成功时,Promise 将调用我们的处理函数,传入一个包含服务器的响应的 Response 对象。
  4. 输出一条信息,说明我们已经发送了这个请求。
Promise { <state>: "pending" }
已发送请求……
已收到响应:200

与之前的 XMLHttpRequest 不同的是,事件处理程序并不是添加在 XMLHttpRequest 的对象中,我们这一次将处理程序传递到返回的promise对象的then方法里面。

Promise链

const fetchPromise = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);

fetchPromise.then((response) => {
  const jsonPromise = response.json();
  jsonPromise.then((json) => {
    console.log(json[0].name);
  });
});

等等!还记得上一篇文章吗?我们好像说过,**在回调中调用另一个回调会出现多层嵌套的情况?我们是不是还说过,这种“回调地狱”使我们的代码难以理解?**这不是也一样吗,只不过变成了用 then() 调用而已?

当然如此。但 Promise 的优雅之处在于 then() 本身也会返回一个 Promise,这个 Promise 将指示 then() 中调用的异步函数的完成状态

官方教程划重点:Promise 的优雅之处在于 then() 本身也会返回一个 Promise,这个 Promise 将指示 then() 中调用的异步函数的完成状态

所以以上代码等价于:

const fetchPromise = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);

fetchPromise
  .then((response) => response.json())
  .then((data) => {
    console.log(data[0].name);
  });

我们需要在尝试读取请求之前检查服务器是否接受并处理了该请求。我们将通过检查响应中的状态码来做到这一点,如果状态码不是“OK”,就抛出一个错误:

const fetchPromise = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);

fetchPromise
  .then((response) => {
    if (!response.ok) {
      throw new Error(`HTTP 请求错误:${response.status}`);
    }
    return response.json();
  })
  .then((json) => {
    console.log(json[0].name);
  });

错误捕获

const fetchPromise = fetch(
  "bad-scheme://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);

fetchPromise
  .then((response) => {
    if (!response.ok) {
      throw new Error(`HTTP 请求错误:${response.status}`);
    }
    return response.json();
  })
  .then((json) => {
    console.log(json[0].name);
  })
  .catch((error) => {
    console.error(`无法获取产品列表:${error}`);
  });

catch处理函数的输出错误。

  • 注意fetch() 只有在网络层面失败时才会进入 catch。服务器返回 404 或 500 状态码时,Promise 依然是 fulfilled 状态,需要通过 response.ok 手动判断。

合并使用多个promise

有时你需要所有的 Promise 都得到实现,但它们并不相互依赖。在这种情况下,将它们一起启动然后在它们全部被兑现后得到通知会更有效率。这里需要 Promise.all() 方法。它接收一个 Promise 数组,并返回一个单一的 Promise。

Promise.all()

Promise.all()返回的 Promise:

  • 当且仅当数组中所有的 Promise 都被兑现时,才会通知 then() 处理函数并提供一个包含所有响应的数组,数组中响应的顺序与被传入 all() 的 Promise 的顺序相同。
  • 会被拒绝——如果数组中有任何一个 Promise 被拒绝。此时,catch() 处理函数被调用,并提供被拒绝的 Promise 所抛出的错误。
const fetchPromise1 = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);
const fetchPromise2 = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/not-found",
);
const fetchPromise3 = fetch(
  "https://mdn.github.io/learning-area/javascript/oojs/json/superheroes.json",
);

Promise.all([fetchPromise1, fetchPromise2, fetchPromise3])
  .then((responses) => {
    for (const response of responses) {
      console.log(`${response.url}${response.status}`);
    }
  })
  .catch((error) => {
    console.error(`获取失败:${error}`);
  });

promise.all用于批量处理不是相互依赖的promise,这样提高了效率,但是弊端是只有全部成功才会成功,如果有一个失败(rejected)则所有all包含在内的promise都不能被兑现。此时错误会用catch抛出。

Promise.any()

有时,你可能需要一组 Promise 中的某一个 Promise 的兑现,而不关心是哪一个。在这种情况下,你需要 Promise.any()

这就像 Promise.all(),不过在 Promise 数组中的任何一个被兑现时它就会被兑现,如果所有的 Promise 都被拒绝,它也会被拒绝。

在这种情况下,我们无法预测哪个获取请求会先被兑现。

const fetchPromise1 = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);
const fetchPromise2 = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/not-found",
);
const fetchPromise3 = fetch(
  "https://mdn.github.io/learning-area/javascript/oojs/json/superheroes.json",
);

Promise.any([fetchPromise1, fetchPromise2, fetchPromise3])
  .then((response) => {
    console.log(`${response.url}${response.status}`);
  })
  .catch((error) => {
    console.error(`获取失败:${error}`);
  });

async 和 await

async function fetchProducts() {
  try {
    const response = await fetch(
      "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
    );
    if (!response.ok) {
      throw new Error(`HTTP 请求错误:${response.status}`);
    }
    const data = await response.json();
    return data;
  } catch (error) {
    console.error(`无法获取产品列表:${error}`);
  }
}

const promise = fetchProducts();
promise.then((data) => console.log(data[0].name));

这里我们调用 await fetch(),我们的调用者得到的并不是 Promise,而是一个完整的 Response 对象,就好像 fetch() 是一个同步函数一样。

我们甚至可以使用 try...catch 块来处理错误,就像我们在写同步代码时一样。

但请注意,这个写法只在异步函数中起作用。异步函数总是返回一个 Promise。也就意味着async 函数总是返回一个 Promise。即使你返回一个普通值,它也会被自动包装成 Promise。

小结与更多Promise

Promise 是现代 JavaScript 异步编程的基础。它避免了深度嵌套回调,使表达和理解异步操作序列变得更加容易,并且它们还支持一种类似于同步编程中 try...catch 语句的错误处理方式。

asyncawait 关键字使得从一系列连续的异步函数调用中建立一个操作变得更加容易,避免了创建显式 Promise 链,并允许你像编写同步代码那样编写异步代码。

Promise 在所有现代浏览器的最新版本中都可以使用;唯一会出现支持问题的地方是 Opera Mini 和 IE11 及更早的版本。

在这篇文章中,我们没有涉及到所有的 Promise 功能,只是介绍了最有趣和最有用的那一部分。随着你开始学习更多关于 Promise 的知识,你会遇到更多有趣的特性。

许多现代 Web API 是基于 Promise 的,包括 WebRTCWeb Audio API媒体捕捉与媒体流等等。

❌