From a698ebf58533c8df229b4f1e7a2dd3e28e3afda9 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Sun, 27 Sep 2026 16:34:32 +0800 Subject: [PATCH] =?UTF-8?q?perf(=E5=89=8D=E7=AB=AF):=20=E9=A6=96=E9=A1=B5?= =?UTF-8?q?=E5=92=8C=E9=A2=98=E7=9B=AE=E8=AF=A6=E6=83=85=E9=A1=B5=E6=8C=89?= =?UTF-8?q?=E9=9C=80=E5=8A=A0=E8=BD=BD=EF=BC=8C=E9=A6=96=E9=A1=B5=20477=20?= =?UTF-8?q?=E2=86=92=20380KB=E3=80=81=E8=AF=A6=E6=83=85=E9=A1=B5=201031=20?= =?UTF-8?q?=E2=86=92=20682KB=EF=BC=88=E4=BC=A0=E8=BE=93=E9=87=8F=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 浏览器实测(preview 构建,生产规模数据):首页解压后 1428 → 1112KB, 未登录的题目详情页 3317 → 2021KB。后端接口 6–8ms,不是瓶颈,没动。 首页: - 登录速报弹框挂在默认布局上,静态 import 了 md-editor-v3(~160KB JS + 76KB CSS), 而 AI 分析只在登录后、提交满 3 次才有。MdPreview 改成异步组件,目标是本地包装 LoginSummaryPreview.vue —— 直接 import("md-editor-v3") 会让 rolldown 把它和 CodeMirror 语言包并成一个 467KB 的 chunk,排名页反而涨了 560KB(实测) - 题目列表经 oj/api.ts 还带着 zod(~70KB):那个文件静态 import 了题目详情、提交详情 两个 schema。改成和请求并行动态加载(单独的 oj/detailSchemas.ts,理由同 profileSchema.ts),闸门仍是同步的 —— submissionDetailSchema 有 .catch(), 不能改 contractDeferred 题目详情页: - skulpt(~226KB gzip)原来一选 Python 就立刻预取。改成登录后才取(没登录交不了), 且等 requestIdleCallback,不和题面、编辑器抢带宽 - StatisticsPanel 只有老师可见,却被 EditorToolbar 静态 import,每个学生都白拉一份 chart.js(~68KB gzip)。改成异步组件,打开课堂统计时才加载 - SubmissionResult(带 DataTable)改成异步组件,第一次提交弹出结果面板时才加载 浏览器核过:首页正常;详情页未登录不再加载 skulpt / chart.js / DataTable;学生登录后 skulpt 空闲预取、提交后结果面板正常弹出;老师点课堂统计按需加载图表并正常渲染。 登录速报的 AI 分析与语法错误拦截路径本机没能触发,未在浏览器里核。 Co-Authored-By: Claude Opus 5.5 --- apps/web/src/oj/api.ts | 39 ++++++++++++------- apps/web/src/oj/detailSchemas.ts | 4 ++ .../oj/problem/components/EditorToolbar.vue | 6 ++- .../src/oj/problem/components/SubmitCode.vue | 19 ++++++--- .../shared/components/LoginSummaryModal.vue | 8 +++- .../shared/components/LoginSummaryPreview.vue | 10 +++++ 6 files changed, 63 insertions(+), 23 deletions(-) create mode 100644 apps/web/src/oj/detailSchemas.ts create mode 100644 apps/web/src/shared/components/LoginSummaryPreview.vue diff --git a/apps/web/src/oj/api.ts b/apps/web/src/oj/api.ts index cd3685a..73dc101 100644 --- a/apps/web/src/oj/api.ts +++ b/apps/web/src/oj/api.ts @@ -34,8 +34,6 @@ import { type ProblemSetProblem, type ProblemSetProgressList, type UserBadge, - problemDetailSchema, - submissionDetailSchema, type FlowchartStatistics, type SubmissionStatistics, type SubmissionStatisticsItems, @@ -64,16 +62,12 @@ import type { } from "utils/types" /** - * 题目详情。走契约的 zod 解析,形状即契约 —— 之前这里手抄了一份 camel→snake 的 - * 键名映射,抄漏一个字段就是静默 undefined。 - * - * 走 `contract()` 而不是裸 `parse()`:原来是 `problemDetailSchema.parse(v) as ProblemDetail`, - * `as` 把校验结果又断言回去、等于没校验,而 `parse` 抛错会让整个题目页白屏。 - * 现在形状不符时记一条控制台分歧再放行原始数据。 + * 题目详情 / 提交详情的 schema 按需加载,和请求并行发出。静态 import 会把 zod + * 运行时带进首页 —— 题目列表的 getProblemList 也在这个文件里。 + * 闸门本身仍是同步的(等 schema 到了再返回),submissionDetailSchema 有 `.catch()`, + * 放行原文和解析结果不等价,所以不能像 /me 那样改成 contractDeferred。 */ -function detailProblem(value: unknown): ProblemDetail { - return contract("GET /problems/:id", problemDetailSchema, value) -} +const loadDetailSchemas = () => import("./detailSchemas") /** 当前在线人数。只有聚合数字,「谁在线」在榜单接口里、且只对老师下发 */ export function getOnlineCount() { @@ -104,7 +98,21 @@ export async function getProblem(problemID: string, contestID: string) { const endpoint = contestID ? `contests/${encodeURIComponent(contestID)}/problems/${encodeURIComponent(problemID)}` : `problems/${encodeURIComponent(problemID)}` - return detailProblem(await api.get(endpoint)) + const [{ problemDetailSchema }, response] = await Promise.all([ + loadDetailSchemas(), + api.get(endpoint), + ]) + // 形状即契约 —— 之前这里手抄了一份 camel→snake 的键名映射,抄漏一个字段就是 + // 静默 undefined。走 `contract()` 而不是裸 `parse()`:原来是 + // `problemDetailSchema.parse(v) as ProblemDetail`,`as` 把校验结果又断言回去、 + // 等于没校验,而 `parse` 抛错会让整个题目页白屏。现在形状不符时记一条控制台 + // 分歧再放行原始数据。 + const problem: ProblemDetail = contract( + "GET /problems/:id", + problemDetailSchema, + response, + ) + return problem } // 未登录返回 "0",登录后返回百分比字符串 @@ -113,9 +121,10 @@ export function getProblemBeatRate(problemID: number) { } export async function getSubmission(id: string): Promise { - const response = await api.get( - `submissions/${encodeURIComponent(id)}`, - ) + const [{ submissionDetailSchema }, response] = await Promise.all([ + loadDetailSchemas(), + api.get(`submissions/${encodeURIComponent(id)}`), + ]) return contract("GET /submissions/:id", submissionDetailSchema, response) } diff --git a/apps/web/src/oj/detailSchemas.ts b/apps/web/src/oj/detailSchemas.ts new file mode 100644 index 0000000..9368f7e --- /dev/null +++ b/apps/web/src/oj/detailSchemas.ts @@ -0,0 +1,4 @@ +// 只给 oj/api.ts 的 getProblem / getSubmission 动态 import 用。 +// 静态 import 的话 zod 运行时会跟着 oj/api.ts 进首页(题目列表也从那个文件取函数)。 +// 单独一个模块而不是直接 import("@oj2/contract"),理由同 shared/profileSchema.ts。 +export { problemDetailSchema, submissionDetailSchema } from "@oj2/contract" diff --git a/apps/web/src/oj/problem/components/EditorToolbar.vue b/apps/web/src/oj/problem/components/EditorToolbar.vue index 0b21942..588a597 100644 --- a/apps/web/src/oj/problem/components/EditorToolbar.vue +++ b/apps/web/src/oj/problem/components/EditorToolbar.vue @@ -15,7 +15,6 @@ import { useBreakpoints } from "shared/composables/breakpoints" import { useUserStore } from "shared/store/user" import storage from "utils/storage" import type { LANGUAGE } from "utils/types" -import StatisticsPanel from "shared/components/StatisticsPanel.vue" import { Icon } from "@iconify/vue" import { NFlex } from "naive-ui" import SubmitCode from "./SubmitCode.vue" @@ -23,6 +22,11 @@ import SubmitCode from "./SubmitCode.vue" const SubmitFlowchart = defineAsyncComponent( () => import("./SubmitFlowchart.vue"), ) +// 只有老师看得见(下面的弹框挂了 isTeacherOrAbove),静态 import 的话每个学生 +// 打开题目都要白拉一份 chart.js(~68KB gzip) +const StatisticsPanel = defineAsyncComponent( + () => import("shared/components/StatisticsPanel.vue"), +) interface Props { storageKey: string diff --git a/apps/web/src/oj/problem/components/SubmitCode.vue b/apps/web/src/oj/problem/components/SubmitCode.vue index 03611f0..a156d22 100644 --- a/apps/web/src/oj/problem/components/SubmitCode.vue +++ b/apps/web/src/oj/problem/components/SubmitCode.vue @@ -9,7 +9,6 @@ import { useFireworks } from "oj/problem/composables/useFireworks" import { useSubmissionMonitor } from "oj/problem/composables/useSubmissionMonitor" import { LANGUAGE_FORMAT_VALUE, SubmissionStatus } from "utils/constants" import type { SubmitCodePayload } from "utils/types" -import SubmissionResult from "./SubmissionResult.vue" import { getSubmitButtonState } from "./submitButtonState" import { useBreakpoints } from "shared/composables/breakpoints" import { useUserStore } from "shared/store/user" @@ -24,6 +23,11 @@ import { const ProblemReaction = defineAsyncComponent( () => import("./ProblemReaction.vue"), ) +// 结果面板第一次弹出(也就是第一次提交)时才加载:它带着 DataTable,而判题要等 +// 好几秒,这点下载时间藏得住。进页面就加载的话,只看题不提交的人也要付这笔 +const SubmissionResult = defineAsyncComponent( + () => import("./SubmissionResult.vue"), +) // ==================== 基础状态 ==================== const userStore = useUserStore() @@ -57,11 +61,16 @@ const isFormatting = ref(false) const isSubmittingRequest = ref(false) // ==================== Python 语法检测器预取 ==================== -// 选中 Python 时就把 Skulpt 拉下来,避免点提交时才开始下载 +// 选中 Python 时就把 Skulpt 拉下来,避免点提交时才开始下载。 +// 但它有 ~226KB gzip,比题面和编辑器加起来还大:没登录的提交不了,不拉; +// 登录了也等浏览器空闲再拉,别和首屏的题面、编辑器抢带宽 watch( - () => codeStore.code.language, - (language) => { - if (language === "Python") prefetchPythonSyntaxChecker() + () => codeStore.code.language === "Python" && userStore.isAuthed, + (needed) => { + if (!needed) return + if ("requestIdleCallback" in window) + requestIdleCallback(prefetchPythonSyntaxChecker) + else setTimeout(prefetchPythonSyntaxChecker, 1000) }, { immediate: true }, ) diff --git a/apps/web/src/shared/components/LoginSummaryModal.vue b/apps/web/src/shared/components/LoginSummaryModal.vue index de5fa6b..0c8fdf3 100644 --- a/apps/web/src/shared/components/LoginSummaryModal.vue +++ b/apps/web/src/shared/components/LoginSummaryModal.vue @@ -1,10 +1,14 @@ + +