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

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

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

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

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

websocket.ts

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. 服务器实例化

ts 复制代码
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 官方文档

ts 复制代码
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 官方文档

ts 复制代码
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 官方文档

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

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

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

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

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

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

typescript 复制代码
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 层要注册两个新路由:获取分享链接、加入文档协作。

typescript 复制代码
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。

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

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

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

typescript 复制代码
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: "文档不存在" });
}

分享链接接口:

typescript 复制代码
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 } 幂等加入):

typescript 复制代码
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 参数时,自动执行加入文档的操作

typescript 复制代码
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 类型定义

typescript 复制代码
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,支持多种配置格式:

typescript 复制代码
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(),防止内存泄漏和连接残留。

状态初始化

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

创建 Provider 和 YDoc

typescript 复制代码
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]);

协作配置

typescript 复制代码
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]);

创建编辑器实例

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

连接生命周期管理

typescript 复制代码
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。

typescript 复制代码
  const { docId } = useParams<{ docId: string }>();

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

typescript 复制代码
  const isCollaborationEnabled = Boolean(docId);

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

typescript 复制代码
  // 当 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 官方文档

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

typescript 复制代码
  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 入参保留协同状态,提升用户体验。

typescript 复制代码
const currentStatus = STATUS_MAP[collaborationStatus];
typescript 复制代码
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,在全栈思路上给我很多启发。

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

相关推荐
weixin_382395232 小时前
为小工厂量身打造:本地部署的物料管理系统带缺料计算
前端·制造
__zRainy__2 小时前
解决pnpm v10+不自动构建
前端·pnpm·工程化
猫猫不是喵喵.3 小时前
Vue3 Props 属性
前端·javascript·vue.js
醉城夜风~4 小时前
CSS元素显示模式(display)
前端·css
AI大模型-小华5 小时前
Codex 三方充值快速入门指南
java·前端·数据库·chatgpt·ai编程·codex·chatgpt pro
做前端的娜娜子7 小时前
同一链接实现 PC Web 与移动 H5 自适应
前端·掘金·金石计划
小帅不太帅7 小时前
架构没变、规模没变,DeepSeek V4 Flash 正式版凭什么暴涨 47 分?
前端·aigc·deepseek
jarvisuni7 小时前
DeepSeekFlash前端依旧拉垮,而且变慢了很多!
前端·javascript·算法
卷福同学9 小时前
AI编程出海第二步:验证关键词能否做站
前端·人工智能·后端