我在实际接手这个需求之前,一直以为“改个全局加载动画”就是把某个 loading 图标的图片换掉,或者换一行 CSS 的事。真正把 HOJ 这套开源在线评测系统的前端工程拉下来以后才发现,全局加载动画并不等于某一个文件里的某一个组件,它被拆散在入口 HTML、路由守卫、异步组件挂载、请求拦截器里,至少出了好几个形态。你只改其中一个地方,刷新页面时旧动画照样会出现,视觉上就是没改干净。这篇文章就记录一下我在 HOJ 上做全局加载动画替换的完整过程,包含代码定位思路、实际修改步骤、构建验证以及一堆缓存和兼容性相关的坑。
这个项目适合正在做 HOJ 二次开发,想给自己的 OJ 系统做品牌化定制的前端开发者参考,也适合任何基于 Vue 生态的开源后台项目想要统一加载动画的同学。我会尽量把“为什么这么改”讲清楚,而不是直接丢一段代码让你复制。
1. 开始改之前,先搞清楚 HOJ 里的“全局加载动画”到底有几层
1.1 把加载过程拆开看,很容易理解为什么改一处不够
HOJ 这类项目基本都是前后端分离的单页应用。用户在浏览器里输入地址后,看到最终页面要经过好几个阶段,每个阶段都可能有“加载中”的视觉反馈。
第一个阶段是浏览器还在下载 JavaScript、CSS 等静态资源的时候。这个阶段 VUE 应用其实还没有启动,页面展示的是index.html里预先写好的静态内容,通常是居中的转圈图标或者logo。第二个阶段是页面框架已经启动,但路由对应的组件还在懒加载,这时多数管理系统会触发一个路由进度条,比如浏览器最顶部那个细长的蓝色进度条。第三个阶段是页面组件已经渲染出来,但异步请求后端接口还没返回,这时页面上会出现请求 loading、表格 loading、按钮 loading 等等。
所以说“全局加载动画”不是一个技术概念,而是一个体验概念。它至少对应了三层:首屏占位,路由切换反馈,接口请求反馈。HOJ 的代码仓库里也完全遵循这个规律,三者分别用不同的机制实现。
1.2 我在 HOJ 前端工程里找到的几类加载相关代码
我 clone 下来的是 GitHub 上常见的 HOJ 前端仓库,工程结构就是典型的 Vue 项目。直接全局搜关键词,排查顺序是这样的:
| 动画出现的场景 | 建议搜索的关键词 | 常见实现方式 |
|---|---|---|
| 浏览器下载静态资源时的整屏提示 | loader、loading、spinner | index.html原生 HTML + CSS |
| 路由懒加载期间的顶部进度条 | nprogress、progress、start()、done() | NProgress 库或自定义进度条 |
| 页面异步数据返回前的加载态 | v-loading、loading="、Spin、ElLoading | 组件库指令或自定义 Loading 组件 |
| 内容区域首屏的骨架提示 | skeleton、Skeleton | 骨架屏组件 |
我当时先打开根目录下的public/index.html,发现里面果然有一段完整的静态 loading 结构,放了一个 logo 和“正在加载评测系统”之类的字样。然后在路由相关的 JS 文件里找到了 NProgress 的引用。页面组件里面,大量使用了 UI 组件库表格自带的 loading 属性。
请记住这三层结构。后续无论想做什么样式的统一,都要按三层分别操作,缺一层都会漏。
2. 动手前的重要准备:环境、版本和代码仓库现状
2.1 HOJ 前端工程依赖安装时先确认 Node 版本
HOJ 这类老牌开源项目经历过多轮版本迭代,不同 commit 的技术栈差异其实不小。有的分支还在用 Vue CLI + Webpack,有的已经很激进地切换到 Vite。不同构建工具对 Node 版本的要求不同,最典型的坑是:用高版 Node 跑旧版 Webpack 项目时,经常会报 OpenSSL 相关的 hash 错误,因为新版本 Node 默认的 hash 算法变了。
我建议动手第一件事先打开package.json,看里面的构建脚本和依赖版本。比如webpack主版本是 4 还是 5,vue是 2 还是 3。然后根据构建工具在本地装一个合适的 Node 版本,用nvm管理最方便。
命令行依次试一下:
node -v npm -v npm config get registry如果 npm 默认源下载速度不理想,我通常会临时切到国内镜像源再装依赖,但不要全局永久切,避免后续发布维护时搞混:
npm install --registry=https://registry.npmmirror.com依赖安装阶段出现报错是正常的。很多编译型依赖包需要本机有 Python 和 C++ 构建环境,如果失败先补环境变量或者直接用 npm 官方预编译包。踩过一次坑后我明白了一个道理:这种老项目别追求依赖版本最新,先把 lock 文件提交好,让所有人都用同一套依赖才是最高优先级。
2.2 先给前端代码做一次本地化备份
这点看起来多余,但“改了全局动画之后发现改错了想回滚,却找不到原文件”的情况是真的会发生。HOJ 仓库本身是开源的,可如果你在上面叠加过很多定制功能,本地代码就比远程仓库珍贵,不要随便覆盖。
我个人的习惯是启动修改前先打一个干净的 tag 或者分支:
git checkout -b feature/custom-loading-animation git add -A && git commit -m "backup before loading animation changes"如果是别人交过来的压缩包,没有 git 历史,那就直接把整个前端工程压缩一份放旁边。这个操作最多浪费 1 分钟,后面能省掉很多心慌。
2.3 把“当前生效的动画”记录成截图,方便对照
由于 HOJ 部署环境可能同时存在好几个版本,你本地改的和线上运行的未必是同一套代码。所以我强烈建议在动手前先把系统当前加载动画的表现录个屏或者截几张图:首屏转圈、路由切换顶部条、列表加载状态都截一下。后面验证修改是否生效时,拿截图对比会有非常直观的反馈,不至于靠记忆判断。
3. 修改 HOJ 加载动画的核心操作:一层一层替换
3.1 先改首屏静态内容,这是最容易漏掉的一层
单页应用的index.html在被浏览器打开后,会马上执行其中的 HTML 和 CSS,这时候应用本体还没下载完。也就是说用户在真正看到 HOJ 系统首页之前,最先看到的就是这份静态页面里的内容。如果你只是喜欢高性能的 JS 组件方案,这层不改,用户每次打开系统第一眼看到的还是旧动画,其他修改就全部白费。
HOJ 的public/index.html里一般有一个挂载点节点,例如<div id="app"></div>,而且还包含一个内联的 loading 区域。手写替换成自己的 CSS 动画时,建议把静态 loading 放在<div id="app">内部,这样 Vue 在初始化挂载时会把挂载点里的内容直接替换掉,不需要额外写清理逻辑。
我当时替换成了一个极简的 CSS 加载动画。核心结构是这样的:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>在线评测系统</title> <style> .boot-loader { position: fixed; top: 0; left: 0; right: 0; bottom: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; background: #ffffff; z-index: 99999; } .boot-spinner { width: 48px; height: 48px; border: 4px solid rgba(0, 0, 0, 0.1); border-top-color: #386ee7; border-radius: 50%; animation: boot-spin 0.9s linear infinite; } .boot-text { margin-top: 14px; font-size: 14px; color: #666666; letter-spacing: 1px; } @keyframes boot-spin { to { transform: rotate(360deg); } } </style> </head> <body> <div id="app"> <div class="boot-loader"> <div class="boot-spinner"></div> <div class="boot-text">正在加载评测系统...</div> </div> </div> </body> </html>这里有个细节值得讲一下:静态 loading 内的 CSS 动画不要依赖任何外部资源,尽量全部内联。如果你在外链一个 CSS 文件或者一张很大的背景图,而这个文件恰好和前端应用资源走同一个服务器,那首屏 loading 自己的加载都会因为网络排队而出现长时间空白,反而起不到加载提示效果。内联样式虽然有点丑,但它是 HTML 文件本身的组成部分,浏览器拿到 HTML 后立刻就能渲染。
3.2 替换路由切换时的 NProgress 顶部进度条
HOJ 进入页面后,点击左侧菜单或者跳转路由,通常会看到浏览器顶部有一个细长的进度条,这就是 NProgress 库干的活。它的原理是在路由切换开始时调用start()在页面顶部插入一个半透明进度条图层,路由切换结束后调用done()让进度条快速滑完并移除。
我在 HOJ 前端代码里的查找方式是在src目录下搜索:
grep -r "NProgress" src/正常情况下会定位到路由配置文件,或者在src/main.js、src/router/index.js里,代码大致是:
import NProgress from 'nprogress' import 'nprogress/nprogress.css' router.beforeEach((to, from, next) => { NProgress.start() next() }) router.afterEach(() => { NProgress.done() })如果你不想改变这个组件库,只想换个颜色或速度,完全可以通过覆盖 CSS 实现,成本最低。在全局样式文件里加这么一段,颜色换成自己学校的主题色或系统主色:
#nprogress .bar { background: #386ee7 !important; height: 3px !important; } #nprogress .peg { box-shadow: 0 0 12px #386ee7, 0 0 6px #386ee7 !important; } #nprogress .spinner-icon { border-top-color: #386ee7 !important; border-left-color: #386ee7 !important; }这里说明一下为什么要加!important。NProgress 初始化时会把它的样式直接插入到页面head区域,动态插入的样式在加载顺序上很可能晚于你的业务样式,所以不加!important的话不一定覆盖得掉。虽然从代码洁癖角度讲!important不太优雅,但在做样式覆盖这种场景下它就是最可靠的选择。
如果你不甘于只是换颜色,想彻底换掉 NProgress 的默认动画形态,那就需要同时处理组件库文件和样式文件。可以在路由开始前不调用NProgress.start(),而是自己维护一个isRouteLoading状态,配合一个全屏遮罩组件,不过这会增加不少代码量。对于只是做系统视觉定制的场景,多数情况下没有必要把事情搞复杂。
3.3 处理页面组件中的请求 loading 和骨架屏
第三层主要集中在业务组件内部。HOJ 中用到的表格、卡片、提交按钮都可能会有自己的 loading 状态,这些状态一般由 UI 组件库控制。UI 组件库普遍提供了全局配置主题变量的能力,你可以把加载图标的颜色、遮罩颜色、字体颜色统一改成新主题。
以 Element 系的组件库为例,列表页里最常见的是这样的写法:
<el-table v-loading="tableLoading" :data="rows">这种指令式 loading 修改起来非常直接,替换为设计稿要求的效果时可以先把 loading 遮罩的样式单独抽出来:
.el-loading-mask { background-color: rgba(255, 255, 255, 0.8); } .el-loading-spinner .circular { stroke: #386ee7; }需要注意的是,样式覆盖要放在全局样式中才能生效,不要放在组件的scoped样式里,因为很多组件库的 loading 内容是通过body级插入,DOM 结构上不在当前组件内部,scoped 选择器会限制住样式作用域。
对于 HOJ 中一些页面加载榜单、提交列表等较重数据时的体验,如果觉得简单转圈太单调,可以把它改成骨架屏。骨架屏的效果是模拟页面未来真实布局的灰块,在页面还没拿到数据时就先把框画出来,用户会在视觉上感觉页面更快。组件级骨架屏并不是 HOJ 框架侧的必需能力,但属于二次开发中很受欢迎的低成本优化方向。
3.4 用 Vue 组件库自带 Loading 指令统一请求态
在组件里想要手动触发一个“全屏加载中”状态,通常不需要自己造轮子,直接使用 UI 组件库暴露的全局方法。比如:
import { ElLoading } from 'element-plus' const loadingInstance = ElLoading.service({ fullscreen: true, text: '加载中...' }) setTimeout(() => { loadingInstance.close() }, 2000)执行这类全局 loading 时最容易踩的坑是多个请求并发:一个请求还没结束时另一个请求又打开了一个新的 Loading 实例,如果服务端返回时间不一,先回来的那个请求把 Loading 关掉,后面请求还挂着没回来,用户就会看到页面已经可以操作、但数据还在加载的诡异状态。
所以一般需要引入一个计数器来维护打开和关闭的次数,比较好用的是封装一个请求锁,只在有请求进行中时显示 loading,所有请求结束后才关闭。HOJ 源码里如果本身就封装了 axios,通常在src/utils/request.js里处理。我在自己项目里加了一个简单计数:
let loadingCount = 0 let loadingInstance = null function openGlobalLoading() { loadingCount++ if (loadingCount === 1) { loadingInstance = ElLoading.service({ fullscreen: true, lock: true }) } } function closeGlobalLoading() { if (loadingCount <= 0) return loadingCount-- if (loadingCount === 0) { loadingInstance.close() } }在 axios 请求拦截器里调用openGlobalLoading(),在响应拦截器里调用closeGlobalLoading(),这样就不会出现闪烁和提前关闭的问题。
4. 构建验证阶段容易误判的几个现象
4.1 本地开发模式验证时需要关注控制台和缓存
本地跑开发模式的命令一般在package.json的 scripts 里写着,HOJ 前端多数是npm run serve或npm run dev。启动后改public/index.html里的静态内容,开发服务器有时不会自动热更新这份模板文件,需要手动刷新浏览器。
我遇到过一种情况:改完public/index.html之后刷新,页面还是老加载动画。排查了半天发现是浏览器缓存了之前访问的 HTML 页面。HTML 文件在某些服务器配置下会被浏览器强缓存,尤其是通过 Nginx 托管静态资源且没有设置Cache-Control: no-cache时,浏览器可能在短时间内直接使用本地副本。验证时先用开发者工具勾选“Disable cache”,再强刷一下,能有效排除缓存干扰。
4.2 生产构建后需要区别对待“首次访问”和“登录后访问”
执行npm run build之后,所有页面资源会生成到dist目录。这里再次强调:把dist/index.html打开看一眼,确认里面就是你要的新首屏动画,这一步能规避绝大多数“为什么线上没变”的疑问。
HOJ 的部署方式不同,替换静态文件的方式也不同。如果线上用 Nginx 指向前端目录,直接同步dist内容上去就可以,但要注意旧文件名缓存问题。现在绝大多数构建工具都会给 JS、CSS 文件名加内容哈希,新构建后文件名变化,用户重新拉取 HTML 时自然会加载新文件。但如果旧的dist目录里残留大量没用的老文件,也不会影响新页面读取,顶多就是占一点磁盘空间。
如果 HOJ 是把前端资源内嵌到后端 Jar 包里一并部署的,替换流程就不是直接传dist这么简单,需要先把后端包解包或者重新构建整个后端工程。遇到这种部署结构时,建议先看一遍项目的实际发布脚本,不要想当然地用 Nginx 替换。
4.3 排查线上资源版本是否正确的一个实用技巧
线上页面如果还是旧动画,第一件事是在浏览器开发者工具里查看网络请求,找到页面返回的 HTML 响应,看看内容中是否包含你新写的那段 loader 节点。如果 HTML 已经是新的但显示还是旧动画,那是组件层的某个请求状态还在渲染旧样式,需要在动态加载组件和请求拦截器方向继续查。如果 HTML 都不是新的,那就是部署路径错了或者服务器缓存没清干净,这时候继续改前端代码也没用。
5. 遇到过的典型问题与排查方法整理
5.1 改了 index.html 但刷新后首屏依旧是旧动画
这种情况大概率不是代码问题,而是“看错了文件”。有的 Vue 工程模板根目录是public/index.html,但也有老项目把模板放在根目录直接叫index.html。HOJ 相关工程遵循 Vue CLI 规范的话通常是前者。你可以同时检查两个路径,确认平时开发服务器使用的是哪一个。通过看编译日志里html-webpack-plugin输出的入口文件名,也能快速确定最终被打包进dist/index.html的是哪个模板。
另外还要确认是否有全局搜索文件被忽略。某些代码编辑器的全局搜索默认排除了public目录,或者将node_modules排除范围过大。这时可以用编辑器里的find in folder手动目录选中public搜loader,避免漏找。
5.2 新动画替换后页面出现一段时间白屏
白屏一般是挂载顺序问题。如果把静态 loader 放在<div id="app">外部,Vue 在初始化时并不会主动清掉它,除非你在代码里手动 remove,否则它会一直覆盖在整个页面上,表现为白屏或是动画不消失。如果放在#app内部,Vue 在 mount 时会清空挂载点内容,把 loader 直接顶掉。
如果你让新 loader 在 Vue 应用启动后被移除,而应用启动过程中又因为异步加载报错中断了,那就可能出现 loader 永久留在屏幕上的情况。排查时可以把浏览器控制台切到 Console,看看有没有 JavaScript 运行时报错,很多白屏都是某个组件加载超时或接口异常引起的。
5.3 路由进度条有时启动后永远不会停止
NProgress 依赖路由守卫里的done()被正确调用。如果某次路由跳转因为权限校验失败、组件加载抛出异常等原因导致afterEach没有被执行,进度条就会卡在中间一直转。HOJ 这类带权限控制的系统最容易出现:登录状态过期后,一次主动跳转被路由守卫拦截重定向到登录页,如果重定向逻辑写在了错误处理分支里,进度条就悬空了。
稳妥方案是在路由守卫的finally阶段调用NProgress.done(),而不是只写在成功回调里。用router.onError单独捕获错误同样有必要,这样至少出现异常时进度条能够关闭。
5.4 打包后页面中文字体或中文文案乱码
如果你在index.html或者 JS 配置里添加了中文提示文字,而构建后的 HTML 文件没有声明正确字符集,浏览器可能按系统默认编码解析,导致中文变成乱码。检查index.html的<head>里是否有<meta charset="UTF-8">,并且确认保存文件时编码是 UTF-8。
5.5 多维护人员协作时,Loading 组件被改了又改
如果团队里不止一个人负责前端定制,很容易出现动画效果反复被覆盖的情况。我给这种场景提个建议:把三层 loading 使用的主题色都抽成 Sass/SCSS 变量或 CSS 变量,统一放在一个变量文件里,代码里不要直接写死颜色值。以后再想调整动画配色,改一个变量即可,不需要再去仓库里逐个搜索颜色值。
6. HOJ 二次开发中加载动画外围要一起处理的细节
6.1 Title、Logo、Favicon 和错误页其实同属加载体验
用户在等待加载时看到的不仅是动画,还有浏览器标签页标题和 favicon。如果首屏动画已经换成了一套新视觉,但浏览器标签页还是 HOJ 默认的小图标,页面加载完成后登录框里又冒出原来的 logo,整套定制就会显得很割裂。
所以做这次修改时最好把几件事合并成一个任务:更换标签页标题,替换public/favicon.ico,顺便看看 404 页面和 500 错误页面里是否还保留旧主题的文字。其中隐藏最深的是清理浏览器里保存的旧 favicon,很多时候你明明替换了服务器文件,标签页刷新图标还是旧的,需要 Ctrl+F5 或清除站点数据才能看到新图标。
6.2 切换成骨架屏需要评估页面数据加载速度
HOJ 的榜单页面和提交结果页面数据量比较大,后端处理加网络传输时间比较长。如果只做一个全屏转圈,用户等待时会一直盯着同一个图案,心理体感时间会被拉长。骨架屏在呈现真实页面结构的“轮廓”时,能让用户提前感知到页面大体布局,降低焦虑。但它也有明显缺点:如果接口数据很快就回来了,骨架屏还没来得及展示就闪没,效果不但没有反而显得卡顿。
所以一般会在平均响应时间超过 800ms 的较重页面里使用骨架屏,轻量页面继续用简单转圈就行。这个选择没有绝对标准,建议看一下 HOJ 实际页面的接口耗时分布后再决定范围。
6.3 进度条颜色、动画时长与系统主题保持一致
统一加载动画不只是换一份颜色,还包括动效的缓动曲线和持续时间。HOJ 默认的顶部进度条动画时长很短,干净利落。如果你把它改成缓慢的呼吸灯效果,又和页面其他动效风格不一致,用户反而会觉得系统反应迟钝。做视觉定制时要统一出几个基础规范:主色来自系统主题,动画时长控制在 0.2s 到 0.5s,转圈动画不要用不规则缓动,不然看起来轻微卡顿。
仔细记录一次全局加载动画修改涉及的全部文件和元素,在以后升级 HOJ 版本时就能快速拿到补丁清单。项目社区存在持续迭代风险,升级后自定义改动可能被新版本覆盖,如果改动点不清晰,每次升级都要重新定位一次,成本很高。
6.4 二次开发尽量保持代码可追溯
这一点在 HOJ 这类持续迭代的项目里尤其重要。最好把加载动画相关的代码改动都集中放在同一个分支和同一个目录逻辑下,不要今天改在组件里,明天改在公共样式里,后天又改到路由文件里。改完后的每个文件写清楚负责的层级,比如boot-loader负责首屏、NProgress覆盖负责路由切换、el-loading覆盖负责组件请求态。后续再做主题切换时,只动这些位置就够了。
7. 我这次实操之后的几个经验和验证建议
给 HOJ 改全局加载动画本身不是高难度需求,但完整做下来所涉及的知识点很典型:如何理解 SPA 应用的渲染时序、如何通过全局搜索快速定位源码、如何覆盖组件库样式,以及如何处理构建产物和浏览器缓存。这些都是前端二次开发中反复用到的基本功。
如果让我给准备动手的同学一条最实在的建议,我会说:先花十分钟把整个前端工程的结构和部署方式看明白,再开始改。不要上来就在编辑器里全文搜索动画两个字。加载动画的整改是一个横跨入口模板、路由层、异步组件和请求层的系统性工作,仓促动手容易陷入改一个文件、重新构建一次、上线后发现问题再改一次的循环。
最后一次诚实的经验分享:改完所有代码后,请在“未登录状态”和“登录后状态”各刷新一次,分别在普通网络环境和弱网环境各看一遍。很多加载问题在本地秒开的环境里根本不会暴露,真正上线以后用户网络一波动,首屏动画和请求 loading 之间的衔接问题就会全部浮出来。我后来还养成了一个习惯,遇到用户反馈加载动画不对时,第一句话问的不是“你改了哪个文件”,而是“你改的是哪个环境、哪条访问路径”,因为这决定了问题出在缓存层还是代码层。排查问题的思维顺序对了,这类小需求通常不会占太多时间。