perf(前端): zod 移出首屏包,首屏 JS 721KB → 594KB(gzip 237 → 199KB)

首屏包里带着整个 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 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-09-27 15:23:31 +08:00
1 parent 6b2da41882
commit 83b9d23c68
9 files changed
+60 -22

No files matched your search

-10
View File
@@ -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<WebsiteConfig>("site")
}
/** 当前在线人数。只有聚合数字,「谁在线」在榜单接口里、且只对老师下发 */
export function getOnlineCount() {
return api.get<OnlineCount>("site/online")
@@ -373,10 +367,6 @@ export function getAIHeatmapData(username?: string) {
})
}
export function getAILoginSummary() {
return api.get<LoginSummary>("ai/login-summary")
}
export function getAIPinnedReport() {
return api.get<AiAnalysisRecord | null>("ai/pinned")
}
+20 -3
View File
@@ -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<string[]>(`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<WebsiteConfig>("site")
}
export function getAILoginSummary() {
return api.get<LoginSummary>("ai/login-summary")
}
+4
View File
@@ -0,0 +1,4 @@
// 只给 shared/api.ts 的 getProfile 动态 import 用,见那边的注释。
// 单独一个模块而不是直接 import("@oj2/contract"):动态引入整个契约桶会让 rolldown
// 重排共享 chunk,把一批原本懒加载的页面依赖提进首屏预加载(实测首屏 703KB → 1811KB)。
export { userProfileSchema } from "@oj2/contract"
+1 -1
View File
@@ -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", () => {
+1 -1
View File
@@ -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"]
+21
View File
@@ -45,3 +45,24 @@ export function contract<T extends z.ZodType>(
// 「能渲染的东西」而不是一个异常,分歧已经通过上面那条日志暴露出来了。
return value as z.infer<T>
}
/**
* 同 `contract`,但校验挪到后台:先原样交出数据,schema 按需加载之后再核、只记日志。
*
* 给**首屏路径**用(启动就要拉的 /me)。同步校验得在入口包里带上 schema,也就带上
* 整个 zod 运行时(~100KB);而这道闸本来就只记日志、不改数据 —— schema 里没有
* default / transform 时,放行原文和放行解析结果只差 zod 剥掉的多余字段,前端用不上。
* 给 schema 加 default / transform 之前,得先把调用方从这里挪回 `contract`。
*/
export function contractDeferred<T extends z.ZodType>(
endpoint: string,
loadSchema: () => Promise<T>,
value: unknown,
): z.infer<T> {
loadSchema()
.then((schema) => contract(endpoint, schema, value))
.catch(() => {
// schema 那个 chunk 加载失败(部署换了哈希之类)不影响页面,闸门本来就只记日志
})
return value as z.infer<T>
}
+5 -2
View File
@@ -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
}
+5 -1
View File
@@ -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 侧连键都不该出现),
+3 -4
View File
@@ -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` 的显式列举写法。