Yuzhe's Blog

yuzhes

Leverage OJ 前端重写:Nuxt 4 + Naive UI SPA

Leverage OJ 的后端重写已经在进行中——干净的架构、正经的 migration、真实的认证体系——但前端还是原来那套:Vue 2 的应用,API 调用散落各处,没有类型安全,构建流水线需要点运气才能跑起来。既然要修地基,屋顶也一起修了吧。

这篇文章记录前端重写的全过程:为什么做、选了什么技术、哪些架构决策经受住了考验,以及 Playwright 测试实际跑起来之后才暴露的那几个 bug。

为什么要重写前端

旧前端积累了快速迭代项目的常见问题:

压垮骆驼的最后一根稻草:后端重写重新设计了 API 层,前端需要在太多地方同步更新,定向重构基本等于全改。既然如此,不如从头开始。

技术选型

Nuxt 4,SPA 模式

选择 Nuxt 4 SPA 模式而非 SSR,理由很具体:Leverage OJ 几乎所有页面都需要登录。SSR 会带来额外复杂度(认证状态 hydration、cookie 转发、SSR 安全的 localStorage 访问),但没有任何实际收益——搜索引擎不需要索引登录墙后面的题目内容。

SPA 模式保留了 Nuxt 的项目结构、自动导入、路由和构建工具,去掉了 hydration 的包袱。

Naive UI

旧前端已经开始引入 Naive UI,但使用不统一。新项目里,Naive UI 是唯一的组件库,覆盖所需的一切:表格、表单、弹窗、日期选择器、代码高亮——而且和 Vue 3 Composition API 配合很顺畅。

一个立竿见影的收益:Naive UI 的 n-data-table 组件在一个组件里搞定了分页、排序和加载状态。旧的表格代码是几百行手写 HTML。

CodeMirror 6

代码编辑器是 OJ 前端最重要的组件。用户跟它互动的时间比跟其他任何东西都多。

我们选了 CodeMirror 6 而不是 Monaco,理由是体积和灵活性。Monaco 很优秀,但体积大,而且对渲染方式有自己的主见。CodeMirror 6 的扩展模型让我们可以按需组合功能:C++/Python/Java 的语法高亮、vim 键位绑定(竞赛选手很喜欢),以及自定义主题。

与 Nuxt 的集成需要一些处理——详见后面的挑战部分。

KaTeX

竞赛编程的题目里数学公式很多。MathJax 是旧 OJ 系统的默认选择,但渲染慢,而且需要在 DOM 插入后再跑一遍。

KaTeX 同步渲染,速度快得多。我们用一个 Vue 指令,在元素挂载时处理题目内容里的 $ / $$ 分隔符。无论是简单的行内分数还是复杂的求和符号,都能处理,而且没有 MathJax 那种闪烁感。

架构设计

Composables 作为 API 层

不再在组件里散落 axios 调用,所有 API 交互都通过 composables/api/ 下的 composable 进行。每个 composable 封装一个领域:

// composables/api/useProblemApi.ts
export function useProblemApi() {
  const { request } = useRequest()

  return {
    async getProblems(params: ProblemQueryParams): Promise<PaginatedResponse<Problem>> {
      return request({ method: 'GET', url: '/problems', params })
    },
    async getProblem(id: number): Promise<Problem> {
      return request({ method: 'GET', url: `/problems/${id}` })
    },
    async submitSolution(id: number, body: SubmitBody): Promise<Submission> {
      return request({ method: 'POST', url: `/problems/${id}/submit`, data: body })
    },
  }
}

useRequest 是唯一负责附加认证头、统一处理错误、触发 token 刷新的地方。其他任何地方都不直接碰 axios。

Pinia Auth Store + JWT 自动刷新

认证状态存在 Pinia store 里——不是组件局部状态,不是 Vuex,不是旧的”每次页面加载都检查 localStorage”模式。

// stores/auth.ts
export const useAuthStore = defineStore('auth', () => {
  const token = ref<string | null>(null)
  const user = ref<UserProfile | null>(null)
  const refreshTimer = ref<ReturnType<typeof setTimeout> | null>(null)

  function scheduleRefresh(expiresIn: number) {
    if (refreshTimer.value) clearTimeout(refreshTimer.value)
    // 在过期前 60 秒刷新
    const delay = Math.max((expiresIn - 60) * 1000, 0)
    refreshTimer.value = setTimeout(doRefresh, delay)
  }

  async function doRefresh() {
    try {
      const res = await authApi.refresh()
      token.value = res.accessToken
      scheduleRefresh(res.expiresIn)
    } catch {
      logout()
    }
  }

  // ...
})

用户登录时,我们从 JWT payload 中提取过期时间,并在过期前自动调度刷新。如果刷新失败(网络错误、session 被吊销),调用 logout() 并跳转到登录页——不会静默失败。

Store 通过 pinia-plugin-persistedstate 持久化到 sessionStorage,刷新页面不会退出登录。

集成挑战

CodeMirror 6 在 Nuxt 里的使用

CodeMirror 6 的核心是纯 ESM,这没问题——但一些扩展包在 SSR 上下文中有微妙的导入问题。即使是 SPA 模式,Nuxt 的 Vite 构建在静态生成阶段也可能尝试分析引用了浏览器 API 的导入。

解决方案:用 ClientOnly 组件包裹编辑器,并懒加载 CodeMirror 导入:

// plugins/codemirror.client.ts
import { EditorView, basicSetup } from 'codemirror'
import { cpp } from '@codemirror/lang-cpp'
import { python } from '@codemirror/lang-python'

export default defineNuxtPlugin(() => {
  return {
    provide: {
      EditorView,
      basicSetup,
      languages: { cpp, python }
    }
  }
})

.client.ts 后缀告诉 Nuxt 这个插件只在浏览器端运行。编辑器组件从这个插件导入,保证它永远不会在服务端或构建时分析阶段执行。

KaTeX 数学公式渲染

KaTeX 本身运行良好,但在会更新的组件里渲染就要小心了。如果题目内容是异步加载的(从 API 获取),组件更新时 DOM 会被替换成原始 LaTeX 字符串,除非特别处理。

解决方案是一个 Vue 指令,在每次更新周期后运行 renderMathInElement

// directives/katex.ts
import renderMathInElement from 'katex/contrib/auto-render'

export const vKatex = {
  mounted: renderKatex,
  updated: renderKatex,
}

function renderKatex(el: HTMLElement) {
  renderMathInElement(el, {
    delimiters: [
      { left: '$$', right: '$$', display: true },
      { left: '$', right: '$', display: false },
    ],
    throwOnError: false,
  })
}

throwOnError: false 很重要——题目内容里的格式错误表达式应该显示 fallback,而不是让渲染器崩溃。

AI 辅助开发

大量重复的页面脚手架工作——列表页、详情页、CRUD 管理面板——都用 AI coding agent 生成的。架构先设计好(composable 规范、store 模式、组件约定),然后让 agent 按照这些模式生成符合规范的页面。

这个方式在高量低变的工作上效果很好,大概节省了两三周本来会花在复制粘贴上的时间。真正有意思的问题——composable 设计、认证流、编辑器集成——还是需要人来处理。

Playwright 发现了什么

基本页面功能可用之后,我们加了 Playwright E2E 测试,自动化完整的用户旅程:注册、登录、浏览题目、提交代码、查看结果。四个 bug 因此浮出水面,都是手动测试没发现的。

Bug 1:Naive UI 组件注册问题

Naive UI 在 Nuxt 中通过 unplugin-vue-components 自动导入。这个插件扫描模板里的 <n-xxx> 标签并自动导入对应组件。

问题是:这个插件根本没装。组件是在 app.vue 里全局粗暴导入的:

// app.vue — 错误做法
import { NButton, NInput, NForm } from 'naive-ui'

这对明确列出的组件有效,但对懒加载页面中使用的组件悄悄失败。Playwright 的提交表单测试发现语言选择器里用的 NSelect 不见了——没有控制台报错,就渲染成了一个空 div。

修复:安装 unplugin-vue-components 并配置 Naive UI resolver。

Bug 2:NuxtLayout 没有包裹异步页面

依赖异步数据的页面有一个闪烁问题:首次加载时,布局(导航栏、侧边栏)会先渲染,然后消失,然后在页面的 useAsyncData resolve 之后重新出现。

原因:在 Nuxt 4 中,<NuxtLayout> 必须在 app.vue 里包裹 <NuxtPage>,但如果布局组件内部用了 <Suspense>,而页面又是异步的,等待期间布局可能会卸载。我们在页面级别定义布局(definePageMeta({ layout: 'dashboard' })),这种方式和异步页面的交互方式与在 app 层级包裹不同。

修复:把 <NuxtLayout> 移到 app.vue,从各个页面移除 layout 定义。

Bug 3:imports.dirs 没有覆盖嵌套 composables

Nuxt 的自动导入默认覆盖 composables/,但只扫描一层。我们的 API composable 在 composables/api/ 下,没有被扫描到。

Playwright 的题目列表页测试抛出了运行时错误:useProblemApi is not defined。开发环境下能跑(Vite 的 HMR 更宽松),构建产物里就挂了。

修复:在 nuxt.config.ts 里加 imports.dirs

export default defineNuxtConfig({
  imports: {
    dirs: ['composables', 'composables/api', 'composables/utils']
  }
})

Bug 4:axios res.data 双重解包

useRequest composable 返回 axios 的 response.data——这是对的。但重构过程中,API composable 里也在做 return response.data,导致当后端把响应包在 { data: ... } 信封里时,最终拿到的是 response.data.data

开发时这个 bug 不可见,因为我们看的是 UI,不是原始对象。Playwright 断言 submission.status === 'AC' 失败,因为 submission 实际上是 { data: { status: 'AC' } }

修复:去掉双重解包——一层 .data 提取在 useRequest 里做,各个 API 函数里不再做。

回顾

重写花的时间比定向重构更长,但它消灭了整个类别的 bug,而不是一个一个打补丁。Playwright 测试套件现在在每次推送时运行,在到达生产环境之前捕获回归问题。

几件我会做不同的事:

整个技术栈——Nuxt 4 SPA + Naive UI + CodeMirror 6 + KaTeX——经受住了考验。选型本身没有遗憾,只是没有更早测试这件事有遗憾。