From 83b9d23c68fb66eb4d3ffe4850e136df73f0d24e Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Sun, 27 Sep 2026 15:23:31 +0800 Subject: [PATCH] =?UTF-8?q?perf(=E5=89=8D=E7=AB=AF):=20zod=20=E7=A7=BB?= =?UTF-8?q?=E5=87=BA=E9=A6=96=E5=B1=8F=E5=8C=85=EF=BC=8C=E9=A6=96=E5=B1=8F?= =?UTF-8?q?=20JS=20721KB=20=E2=86=92=20594KB=EF=BC=88gzip=20237=20?= =?UTF-8?q?=E2=86=92=20199KB=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 首屏包里带着整个 zod 运行时(~100KB),来路有三条,缺一条都挪不出去(逐条撤回实测过): - 契约包没有 sideEffects: false,入口从桶里取任何一个值都会把所有 schema 模块带上。 契约包里没有顶层副作用(核过),加上之后 rolldown 才能按导出摇树 - roles.ts 里的 USER_TYPE / toAdminType 是入口要的,同文件却定义了两个 z.enum。 schema 挪到 admin.ts(唯一用处),roles.ts 文件头写明不许 import zod - 启动就要调的 getWebsiteConfig / getAILoginSummary 在 oj/api.ts 里,而那个文件同步 import 了题目详情、提交详情两个 schema。这两个函数挪到 shared/api.ts;那两道 闸门保持同步 —— submissionDetailSchema 有 .catch(),放行原文和解析结果不等价 - /me 的契约闸门改成 contractDeferred:先放行原文,schema 按需加载后在后台核、只记 日志。userProfileSchema 没有 default / transform,两者等价。动态 import 的目标是 单独的 shared/profileSchema.ts —— 直接 import 契约桶会让 rolldown 重排共享 chunk, 实测首屏反而涨到 1811KB 浏览器实跑核过:登录后 profile 正常加载、profileSchema 按需拉取、无控制台报错; 故意喂一份不符的数据,后台闸门照常打出 [契约] 分歧日志。 Co-Authored-By: Claude Opus 5.5 --- apps/web/src/oj/api.ts | 10 ---------- apps/web/src/shared/api.ts | 23 ++++++++++++++++++++--- apps/web/src/shared/profileSchema.ts | 4 ++++ apps/web/src/shared/store/config.ts | 2 +- apps/web/src/shared/store/loginSummary.ts | 2 +- apps/web/src/utils/contract.ts | 21 +++++++++++++++++++++ packages/contract/package.json | 7 +++++-- packages/contract/src/admin.ts | 6 +++++- packages/contract/src/roles.ts | 7 +++---- 9 files changed, 60 insertions(+), 22 deletions(-) create mode 100644 apps/web/src/shared/profileSchema.ts diff --git a/apps/web/src/oj/api.ts b/apps/web/src/oj/api.ts index f5ed70f..690c58b 100644 --- a/apps/web/src/oj/api.ts +++ b/apps/web/src/oj/api.ts @@ -27,7 +27,6 @@ import { type AiDetail, type DurationData, type HeatmapItem, - type LoginSummary, type SolvedList, type ProblemSet, type ProblemSetBadge, @@ -60,7 +59,6 @@ import type { SubmissionListPayload, SubmitCodePayload, OnlineCount, - WebsiteConfig, Tutorial, TutorialProgress, } from "utils/types" @@ -77,10 +75,6 @@ function detailProblem(value: unknown): Problem { return contract("GET /problems/:id", problemDetailSchema, value) } -export function getWebsiteConfig() { - return api.get("site") -} - /** 当前在线人数。只有聚合数字,「谁在线」在榜单接口里、且只对老师下发 */ export function getOnlineCount() { return api.get("site/online") @@ -373,10 +367,6 @@ export function getAIHeatmapData(username?: string) { }) } -export function getAILoginSummary() { - return api.get("ai/login-summary") -} - export function getAIPinnedReport() { return api.get("ai/pinned") } diff --git a/apps/web/src/shared/api.ts b/apps/web/src/shared/api.ts index 284f6b2..82fd656 100644 --- a/apps/web/src/shared/api.ts +++ b/apps/web/src/shared/api.ts @@ -1,6 +1,6 @@ -import { userProfileSchema, type Quote } from "@oj2/contract" +import type { LoginSummary, Quote, WebsiteConfig } from "@oj2/contract" import api from "utils/api" -import { contract } from "utils/contract" +import { contractDeferred } from "utils/contract" import type { Profile, Tag } from "utils/types" export function login(data: { username: string; password: string }) { @@ -29,7 +29,12 @@ export async function getProfile( // `userProfileSchema.parse(response) as Profile`,`as` 把校验结果又断言回本地 // 类型(Profile 把 user 收窄成 SessionUser、acmProblemsStatus 收窄成具体形状), // 形状对不上时页面白屏。现在记一条分歧日志后放行原始数据。 - return contract("GET /profiles/:username", userProfileSchema, response) + // 用 deferred 版:启动就要拉 /me,同步校验会把 zod 整个打进首屏包 + return contractDeferred( + "GET /profiles/:username", + () => import("./profileSchema").then((m) => m.userProfileSchema), + response, + ) } export function getProblemTagList() { @@ -43,3 +48,15 @@ export function getHitokoto() { export function getClassUsernames(classroom: string) { return api.get(`classes/${encodeURIComponent(classroom)}/usernames`) } + +// 下面两个是启动就要调的(config / loginSummary 两个 store),所以放在 shared 而不是 +// oj/api.ts:oj/api.ts 同步 import 了题目详情、提交详情两个 schema(它们的契约闸门必须 +// 同步,submissionDetailSchema 里有 `.catch()`,放行原文和解析结果不等价),入口一旦 +// 引到它,zod 整个就进了首屏包。 +export function getWebsiteConfig() { + return api.get("site") +} + +export function getAILoginSummary() { + return api.get("ai/login-summary") +} diff --git a/apps/web/src/shared/profileSchema.ts b/apps/web/src/shared/profileSchema.ts new file mode 100644 index 0000000..8ff035b --- /dev/null +++ b/apps/web/src/shared/profileSchema.ts @@ -0,0 +1,4 @@ +// 只给 shared/api.ts 的 getProfile 动态 import 用,见那边的注释。 +// 单独一个模块而不是直接 import("@oj2/contract"):动态引入整个契约桶会让 rolldown +// 重排共享 chunk,把一批原本懒加载的页面依赖提进首屏预加载(实测首屏 703KB → 1811KB)。 +export { userProfileSchema } from "@oj2/contract" diff --git a/apps/web/src/shared/store/config.ts b/apps/web/src/shared/store/config.ts index 8396a4c..023f8af 100644 --- a/apps/web/src/shared/store/config.ts +++ b/apps/web/src/shared/store/config.ts @@ -1,4 +1,4 @@ -import { getWebsiteConfig } from "oj/api" +import { getWebsiteConfig } from "shared/api" import type { WebsiteConfig } from "utils/types" export const useConfigStore = defineStore("config", () => { diff --git a/apps/web/src/shared/store/loginSummary.ts b/apps/web/src/shared/store/loginSummary.ts index 90a7d0c..c19a1c4 100644 --- a/apps/web/src/shared/store/loginSummary.ts +++ b/apps/web/src/shared/store/loginSummary.ts @@ -1,5 +1,5 @@ import type { LoginSummary as ContractLoginSummary } from "@oj2/contract" -import { getAILoginSummary } from "oj/api" +import { getAILoginSummary } from "shared/api" type LoginSummary = ContractLoginSummary["summary"] diff --git a/apps/web/src/utils/contract.ts b/apps/web/src/utils/contract.ts index 5ba1173..0536485 100644 --- a/apps/web/src/utils/contract.ts +++ b/apps/web/src/utils/contract.ts @@ -45,3 +45,24 @@ export function contract( // 「能渲染的东西」而不是一个异常,分歧已经通过上面那条日志暴露出来了。 return value as z.infer } + +/** + * 同 `contract`,但校验挪到后台:先原样交出数据,schema 按需加载之后再核、只记日志。 + * + * 给**首屏路径**用(启动就要拉的 /me)。同步校验得在入口包里带上 schema,也就带上 + * 整个 zod 运行时(~100KB);而这道闸本来就只记日志、不改数据 —— schema 里没有 + * default / transform 时,放行原文和放行解析结果只差 zod 剥掉的多余字段,前端用不上。 + * 给 schema 加 default / transform 之前,得先把调用方从这里挪回 `contract`。 + */ +export function contractDeferred( + endpoint: string, + loadSchema: () => Promise, + value: unknown, +): z.infer { + loadSchema() + .then((schema) => contract(endpoint, schema, value)) + .catch(() => { + // schema 那个 chunk 加载失败(部署换了哈希之类)不影响页面,闸门本来就只记日志 + }) + return value as z.infer +} diff --git a/packages/contract/package.json b/packages/contract/package.json index a85b09e..7c7acb5 100644 --- a/packages/contract/package.json +++ b/packages/contract/package.json @@ -5,8 +5,11 @@ "type": "module", "main": "./src/index.ts", "types": "./src/index.ts", - "exports": { ".": "./src/index.ts" }, + "exports": { + ".": "./src/index.ts" + }, "dependencies": { "zod": "^4.4.3" - } + }, + "sideEffects": false } diff --git a/packages/contract/src/admin.ts b/packages/contract/src/admin.ts index 93a7821..b77fa4a 100644 --- a/packages/contract/src/admin.ts +++ b/packages/contract/src/admin.ts @@ -1,6 +1,6 @@ import { z } from "zod" -import { adminTypeSchema, problemPermissionSchema } from "./roles" +import { ADMIN_TYPES, PROBLEM_PERMISSIONS } from "./roles" import { achievementRaritySchema } from "./achievement" import { rankProfileSchema } from "./account" @@ -14,6 +14,10 @@ import { sqlDisplaySchema, } from "./problem" +// 放这里而不是 roles.ts:roles.ts 要保持不 import zod,理由见那边的文件头 +export const adminTypeSchema = z.enum(ADMIN_TYPES) +export const problemPermissionSchema = z.enum(PROBLEM_PERMISSIONS) + /** * 后台侧的契约。与 oj 侧分开放:同一张表在两侧下发的字段集通常不同 * (后台要 `visible` 这类管理字段,oj 侧连键都不该出现), diff --git a/packages/contract/src/roles.ts b/packages/contract/src/roles.ts index 0095c44..e4c323a 100644 --- a/packages/contract/src/roles.ts +++ b/packages/contract/src/roles.ts @@ -1,5 +1,3 @@ -import { z } from "zod" - /** * 角色与题目权限的字面量。**全仓唯一的定义处。** * @@ -10,6 +8,9 @@ import { z } from "zod" * ADMIN_ROLES / TEACHER_ROLES,`["Regular User", "Student Admin"]` 这个学生口径 * 在四个文件里各写一遍,另有二十多处散落的 `=== "Super Admin"`。拼错一个字母 * TypeScript 一个字都不会说,只会在运行时静默放行或静默拒绝。 + * + * **这个文件不能 import zod。** 前端入口要用这里的 USER_TYPE / toAdminType, + * 一 import 就把整个 zod 运行时(~100KB)拖进首屏包。对应的 zod schema 在 admin.ts。 */ export const ADMIN_TYPES = [ "Regular User", @@ -18,11 +19,9 @@ export const ADMIN_TYPES = [ "Super Admin", ] as const export type AdminType = (typeof ADMIN_TYPES)[number] -export const adminTypeSchema = z.enum(ADMIN_TYPES) export const PROBLEM_PERMISSIONS = ["None", "Own", "All"] as const export type ProblemPermission = (typeof PROBLEM_PERMISSIONS)[number] -export const problemPermissionSchema = z.enum(PROBLEM_PERMISSIONS) /** * 三个分组一律是**白名单**,对齐旧后端 `account/models.py:65-73` 的显式列举写法。