背景痛点:为什么“能跑”≠“能毕业”
过去两年帮校内学弟妹 Review 毕设,我总结了三条高频吐槽:
- 页面一刷新,登录态没了——全程无状态管理,Token 写 localStorage 就算“持久化”。
- 演示时网络抖动,接口 500,前端直接白屏——没有异常兜底,更没有测试用例。
- 答辩完老师问“项目地址发我”,学生支支吾吾——本地 npm run dev 跑通后,从未打过包,更别说部署。
结果论文只能写“实现了增删改查”,技术章节空洞,查重率高。想破局,得把“跑起来”升级为“可交付”,让代码、文档、线上环境三位一体。
技术选型对比:先给题目分类,再谈技术栈
高校常见选题可归为三类:
- 数据可视化类:教师科研数据、校园 IoT 传感器、疫情统计等。
- 协作工具类:低代码表单、任务看板、轻量级 Wiki。
- 跨端小程序类:基于 Taro/Uni-app,跑在微信或支付宝。
不同类别对技术侧重点差异大,我按“状态复杂度”“图表量级”“部署场景”三个维度给出对比表,方便直接抄作业。
| 维度 | 数据可视化 | 协作工具 | 跨端小程序 |
|---|---|---|---|
| 状态复杂度 | 中等(图表过滤、缓存) | 高(实时协同、权限) | 低(本地缓存为主) |
| 渲染性能 | 高(SVG/Canvas 重绘) | 中(列表虚拟滚动) | 低(原生组件) |
| 部署场景 | 纯 Web,可 SSR | 纯 Web,可 SSR | 微信云托管 |
| 推荐框架 | React + ECharts | React + Redux-Toolkit 或 Vue3 + Pinia | Taro3 + Vue3 |
| 构建工具 | Vite(库模式可打包 UMD) | Vite / webpack | 微信 CLI |
| 状态管理 | Zustand / Recoil | Redux-Toolkit / Pinia | 小程序原生 |
一句话总结:
- 重交互、重协作→React + Redux-Toolkit + TypeScript
- 重展示、重动画→Vue3 + Pinia + ECharts
- 要发小程序→Taro3 + Vue3,直接走微信云托管,省服务器钱。
核心实现细节:以“低代码表单系统”为例
“低代码表单”是老师最爱通过的题目:一眼能看懂,但又能扯到“动态渲染”“Schema 驱动”这些大词。下面用 React + RTK + Ant Design 示范如何把“能跑”拆成“工程”。
1. 目录先分层,别再把所有组件塞 src/
src/ ├─ api/ // 接口封装,统一出入参类型 ├─ components/ // 通用 UI ├─ features/ // 按业务切片,一个文件夹就是一个“模块” │ └─ formBuilder/ │ ├─ index.tsx │ ├─ builderSlice.ts │ ├─ hooks/useFormSchema.ts │ └─ services/form.ts ├─ pages/ // 路由级页面 ├─ utils/ // 纯函数、校验规则 └─ types/ // 全局 *.d.ts2. 用“动态 Schema”解耦 UI 与数据
// types/form.ts export interface FieldSchema { key: string; label: string; type: 'input' | 'select' | 'date'; options?: { label: string; value: string }[]; rules?: Rule[]; } // features/formBuilder/builderSlice.ts import { createSlice, PayloadAction } from '@reduxjs/toolkit'; interface BuilderState { schema: FieldSchema[]; selectedKey: string | null; } const initialState: BuilderState = { schema: [], selectedKey: null, }; const builderSlice = createSlice({ name: 'formBuilder', initialState, reducers: { addField: (state, action: PayloadAction<FieldSchema>) => { state.schema.push(action.payload); }, removeField: (state, action: PayloadAction<string>) => { state.schema = state.schema.filter(f => f.key !== action.payload); }, selectField: (state, action: PayloadAction<string | null>) => { state.selectedKey = action.payload; }, }, }); export const { addField, removeField, selectField } = builderSlice.actions; export default builderSlice.reducer;3. 错误边界 + 接口兜底,双保险
// components/ErrorBoundary/index.tsx import React from 'react'; interface Props { fallback: React.ReactNode; children: React.ReactNode; } class ErrorBoundary extends React.Component<Props> { state = { hasError: false }; static getDerivedStateFromError() { return { hasError: true }; } componentDidCatch(err: Error) { // 可上报 Sentry console.error('ErrorBoundary:', err); } render() { if (this.state.hasError) return this.props.fallback; return this.props.children; } } export default ErrorBoundary;// api/base.ts import axios, { AxiosError } from 'axios'; const instance = axios.create({ baseURL: import.meta.env.VITE_API_BASE, timeout: 8000, }); export async function safeRequest<T>(request: () => Promise<T>): Promise<T | null> { try { return await request(); } catch (e) { const err = e as AxiosError; // 统一 Toast alert(err.response?.data?.message || '网络异常'); return null; } }4. 路由级按需加载,减少首屏 JS
// router/index.tsx import { lazy, Suspense } from 'react'; const FormEdit = lazy(() => import('../pages/FormEdit')); <Suspense fallback={<div>Loading...</div>}> <Route path="/edit/:id" element={<FormEdit />} /> </Suspense>性能与安全性:让导师挑不出刺
首屏加载
- 开启 Vite 的
build.rollupOptions.output.manualChunks,把react、antd打进vendor.xxx.js,利用并行加载。 - 路由级懒加载 + HTTP2 多路复用,把 JS 切片控制在 ≤200 KB。
- 开启 Vite 的
XSS 防护
- 动态表单渲染时,禁止直接
dangerouslySetInnerHTML;富文本用DOMPurify.sanitize()过滤。 - 后端返回富文本,统一做白名单标签过滤,前端只做展示。
- 动态表单渲染时,禁止直接
权限控制
- 用 JWT + Refresh Token,AccessToken 存内存,RefreshToken 写 HttpOnly Cookie。
- 路由守卫封装高阶组件
withAuth,在 RTK 里存user.roles,根据角色渲染不同菜单。
生产环境避坑指南:把“能跑”搬到线上
Git 提交规范
用 Conventional Commits 模板,CI 自动打版本号:feat(form): 新增拖拽排序 fix(auth): 修复 token 过期未刷新 docs(readme): 更新部署地址环境变量管理
项目根目录建.env.development .env.production .env.staging只在 CI 中注入敏感变量,例如:
VITE_API_BASE=https://form-api.example.com VITE_SENTRY_DSN=https://xxx@sentry.io/123静态资源 CDN
打包后把dist/assets上传到阿里云 OSS + CDN,回源配置 4 小时,缓存命中率 95%+,回源流量费用立省 70%。一键部署脚本
GitHub Actions 示例:- name: Build & Deploy run: | npm ci npm run build rsync -avz --delete dist/ root@yourHost:/var/www/form/
完整代码片段:Clean Code + TypeScript
下面给出“动态表单渲染”最核心的一段,可直接粘进项目跑:
// components/DynamicForm/index.tsx import React from 'react'; import { Form, Input, Select, DatePicker } from 'antd'; import { FieldSchema } from '../../types/form'; interface Props { schema: FieldSchema[]; } const componentMap = { input: Input, select: Select, date: DatePicker, }; const DynamicForm: React.FC<Props> = ({ schema }) => { const [form] = Form.useForm(); return ( <Form form={form} layout="vertical"> {schema.map(({ key, label, type, options, rules }) => { const Component = componentMap[type]; return ( <Form.Item key={key} name={key} label={label} rules={rules} > {type === 'select' && options ? ( <Component placeholder="请选择"> {options.map(opt => ( <Select.Option key={opt.value} value={opt.value}> {opt.label} </Select.Option> ))} </Component> ) : ( <Component placeholder={`请输入${label}`} /> )} </Form.Item> ); })} </Form> ); }; export default React.memo(DynamicForm);要点:
- 用
componentMap把字符串类型与组件映射,避免 switch-case 地狱。 - 包裹
React.memo,父组件频繁重绘时子组件可跳过。 - 所有类型从
FieldSchema引入,保证重构时“一改全改”。
把课程设计升级为“可展示作品”的三步曲
- 先跑通 MVP,再补工程化:测试、CI、部署一样不能少。
- 用“可量化的结果”写论文:首屏减了 40%、接口异常率低于 1%、Lighthouse 90 分。
- 把代码、PPT、线上 Demo 打包成“作品集”,放到 GitHub 置顶,面试时直接甩链接。
毕业设计不是终点,而是第一份能放到简历上的“工程作品”。把今天这套闭环流程套用到你的题目上,让“能跑”真正进化成“能交付”,答辩时老师还没开口,你就用生产地址和性能报告把问题提前回答完了。祝你一次通过,也祝这份代码在未来面试里继续替你说话。