perf(前端): 首页和题目详情页按需加载,首页 477 → 380KB、详情页 1031 → 682KB(传输量)
Deploy / deploy (push) Canceled after 0s
Deploy / deploy (push) Canceled after 0s
浏览器实测(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 <noreply@anthropic.com>
This commit is contained in:
1 parent
ca1df38440
commit
a698ebf585
6 files changed
+63
-23
No files matched your search
+24
-15
@@ -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<unknown>(endpoint))
|
||||
const [{ problemDetailSchema }, response] = await Promise.all([
|
||||
loadDetailSchemas(),
|
||||
api.get<unknown>(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<Submission> {
|
||||
const response = await api.get<unknown>(
|
||||
`submissions/${encodeURIComponent(id)}`,
|
||||
)
|
||||
const [{ submissionDetailSchema }, response] = await Promise.all([
|
||||
loadDetailSchemas(),
|
||||
api.get<unknown>(`submissions/${encodeURIComponent(id)}`),
|
||||
])
|
||||
return contract("GET /submissions/:id", submissionDetailSchema, response)
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { MdPreview } from "md-editor-v3"
|
||||
import "md-editor-v3/lib/preview.css"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { useLoginSummaryStore } from "shared/store/loginSummary"
|
||||
import { parseTime } from "utils/functions"
|
||||
|
||||
// md-editor-v3 按需加载:这个弹框挂在默认布局上,静态 import 的话首页每次都要多拉
|
||||
// ~160KB JS + ~76KB CSS,而 AI 分析只在登录后、且期间提交够 3 次才有
|
||||
const MdPreview = defineAsyncComponent(
|
||||
() => import("./LoginSummaryPreview.vue"),
|
||||
)
|
||||
|
||||
const loginSummaryStore = useLoginSummaryStore()
|
||||
const { isDesktop } = useBreakpoints()
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { MdPreview } from "md-editor-v3"
|
||||
import "md-editor-v3/lib/preview.css"
|
||||
|
||||
defineProps<{ modelValue: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MdPreview :model-value="modelValue" />
|
||||
</template>
|
||||
Reference in new issue
Block a user