在教育数字化转型的浪潮中,题库系统已成为在线学习平台的核心组件。前端开发作为用户直接交互的界面,其设计和实现直接影响用户体验。本文将为您提供一份全面的前端开发实战指南,帮助您从零开始搭建一个高效、稳定的码头题库系统,并解决常见的性能与兼容性问题。
1. 题库系统前端概述
1.1 什么是题库系统?
题库系统是一个用于存储、管理和展示大量试题的平台。它通常包括题目录入、分类管理、组卷、在线答题、自动评分等功能。前端部分负责将这些功能以直观、易用的界面呈现给用户。
1.2 为什么选择前端开发?
前端开发是构建用户界面的关键环节。通过HTML、CSS和JavaScript,我们可以创建响应式、交互性强的网页应用。对于题库系统,前端需要处理大量数据展示、实时交互和复杂逻辑,因此高效的前端架构至关重要。
2. 技术选型与环境搭建
2.1 技术栈选择
为了构建高效稳定的题库系统,我们推荐以下技术栈:
- 框架: React.js(组件化开发,生态丰富)
- 状态管理: Redux 或 MobX(管理复杂状态)
- UI库: Ant Design(提供丰富的UI组件,适合企业级应用)
- 构建工具: Webpack 或 Vite(快速构建和热更新)
- HTTP客户端: Axios(处理HTTP请求)
- CSS预处理器: Sass 或 Less(增强CSS的可维护性)
2.2 环境搭建
首先,确保您的开发环境已安装Node.js(建议版本14.x以上)。然后,使用Create React App快速创建项目:
npx create-react-app dock-quiz-system
cd dock-quiz-system
npm install axios antd redux react-redux @reduxjs/toolkit
3. 核心模块设计与实现
3.1 题目展示模块
题目展示是题库系统的核心功能之一。我们需要实现一个分页展示题目列表的组件,支持搜索和筛选。
3.1.1 组件结构
import React, { useState, useEffect } from 'react';
import { Table, Input, Button, Select } from 'antd';
import axios from 'axios';
const { Option } = Select;
const QuestionList = () => {
const [questions, setQuestions] = useState([]);
const [loading, setLoading] = useState(false);
const [pagination, setPagination] = useState({ current: 1, pageSize: 10 });
const [filters, setFilters] = useState({ category: '', difficulty: '' });
const fetchQuestions = async (page = 1, pageSize = 10, filters = {}) => {
setLoading(true);
try {
const response = await axios.get('/api/questions', {
params: { page, pageSize, ...filters }
});
setQuestions(response.data.items);
setPagination({ current: page, pageSize, total: response.data.total });
} catch (error) {
console.error('Failed to fetch questions:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchQuestions(pagination.current, pagination.pageSize, filters);
}, [filters]);
const handleTableChange = (pagination) => {
fetchQuestions(pagination.current, pagination.pageSize, filters);
};
const columns = [
{ title: '题目', dataIndex: 'content', key: 'content' },
{ title: '分类', dataIndex: 'category', key: 'category' },
{ title: '难度', dataIndex: 'difficulty', key: 'difficulty' },
{ title: '操作', key: 'action', render: (_, record) => <Button>编辑</Button> }
];
return (
<div>
<div style={{ marginBottom: 16 }}>
<Input
placeholder="分类"
style={{ width: 120, marginRight: 8 }}
onChange={(e) => setFilters({ ...filters, category: e.target.value })}
/>
<Select
placeholder="难度"
style={{ width: 120, marginRight: 8 }}
onChange={(value) => setFilters({ ...filters, difficulty: value })}
>
<Option value="easy">简单</Option>
<Option value="medium">中等</Option>
<Option value="hard">困难</Option>
</Select>
<Button type="primary" onClick={() => fetchQuestions(1, 10, filters)}>搜索</Button>
</div>
<Table
columns={columns}
dataSource={questions}
rowKey="id"
pagination={pagination}
loading={loading}
onChange={handleTableChange}
/>
</div>
);
};
export default QuestionList;
3.1.2 代码说明
- 状态管理: 使用
useState管理题目数据、加载状态、分页和筛选条件。 - 数据获取: 使用
useEffect在组件挂载时和筛选条件变化时获取数据。 - 分页与筛选: 通过
Table组件的onChange事件处理分页变化,通过输入框和下拉框更新筛选条件。
3.2 在线答题模块
在线答题模块需要实现题目展示、答案选择、计时和提交功能。
3.2.1 组件结构
import React, { useState, useEffect } from 'react';
import { Card, Radio, Button, message } from 'antd';
import axios from 'axios';
const Quiz = ({ quizId }) => {
const [questions, setQuestions] = useState([]);
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
const [answers, setAnswers] = useState({});
const [timeLeft, setTimeLeft] = useState(0);
const [submitted, setSubmitted] = useState(false);
useEffect(() => {
const fetchQuiz = async () => {
try {
const response = await axios.get(`/api/quizzes/${quizId}`);
setQuestions(response.data.questions);
setTimeLeft(response.data.duration * 60); // duration in minutes
} catch (error) {
console.error('Failed to fetch quiz:', error);
}
};
fetchQuiz();
}, [quizId]);
useEffect(() => {
if (timeLeft <= 0) {
handleSubmit();
return;
}
const timer = setInterval(() => {
setTimeLeft(timeLeft - 1);
}, 1000);
return () => clearInterval(timer);
}, [timeLeft]);
const handleAnswerChange = (e) => {
const { value } = e.target;
setAnswers({
...answers,
[currentQuestionIndex]: value
});
};
const handleNext = () => {
if (currentQuestionIndex < questions.length - 1) {
setCurrentQuestionIndex(currentQuestionIndex + 1);
}
};
const handlePrev = () => {
if (currentQuestionIndex > 0) {
setCurrentQuestionIndex(currentQuestionIndex - 1);
}
};
const handleSubmit = async () => {
setSubmitted(true);
try {
const response = await axios.post('/api/quizzes/submit', {
quizId,
answers
});
message.success(`答题完成,得分:${response.data.score}`);
} catch (error) {
message.error('提交失败,请重试');
}
};
if (submitted) {
return <Card title="答题结束">请查看您的得分。</Card>;
}
if (questions.length === 0) {
return <Card loading>加载中...</Card>;
}
const currentQuestion = questions[currentQuestionIndex];
return (
<Card title={`题目 ${currentQuestionIndex + 1}/${questions.length}`} extra={`剩余时间:${Math.floor(timeLeft / 60)}:${timeLeft % 60}`}>
<p>{currentQuestion.content}</p>
<Radio.Group onChange={handleAnswerChange} value={answers[currentQuestionIndex]}>
{currentQuestion.options.map((option, index) => (
<Radio key={index} value={option}>{option}</Radio>
))}
</Radio.Group>
<div style={{ marginTop: 16 }}>
<Button onClick={handlePrev} disabled={currentQuestionIndex === 0}>上一题</Button>
<Button onClick={handleNext} disabled={currentQuestionIndex === questions.length - 1} style={{ marginLeft: 8 }}>下一题</Button>
<Button type="primary" onClick={handleSubmit} style={{ marginLeft: 8 }}>提交</Button>
</div>
</Card>
);
};
export default Quiz;
3.2.2 代码说明
- 计时器: 使用
useEffect实现倒计时功能,时间到自动提交。 - 答案管理: 使用对象存储每道题的答案,键为题目索引。
- 导航: 提供上一题、下一题和提交按钮,根据当前题目索引控制禁用状态。
4. 性能优化策略
4.1 数据懒加载
对于大量题目数据,采用懒加载策略,只加载当前页的数据,减少初始加载时间。
4.2 虚拟列表
如果题目列表非常长,可以使用虚拟列表技术(如react-window)只渲染可见区域的内容,提升滚动性能。
npm install react-window
import { FixedSizeList as List } from 'react-window';
const QuestionListVirtual = ({ questions }) => {
const Row = ({ index, style }) => (
<div style={style}>
{questions[index].content}
</div>
);
return (
<List
height={400}
itemCount={questions.length}
itemSize={50}
width={300}
>
{Row}
</List>
);
};
4.3 缓存策略
使用浏览器缓存或Service Worker缓存静态资源,减少服务器请求。
4.4 代码分割
使用React的React.lazy和Suspense实现代码分割,按需加载组件,减少初始包大小。
const Quiz = React.lazy(() => import('./Quiz'));
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<Quiz quizId={1} />
</Suspense>
);
}
5. 兼容性问题解决方案
5.1 浏览器兼容性
- Polyfills: 使用
core-js和regenerator-runtime为旧浏览器添加支持。 - CSS前缀: 使用
autoprefixer自动添加浏览器前缀。
5.2 移动端适配
- 响应式设计: 使用媒体查询和Flexbox/Grid布局。
- 触摸事件: 处理移动端的触摸事件,确保交互流畅。
5.3 跨浏览器测试
使用工具如BrowserStack进行跨浏览器测试,确保在不同浏览器上的一致性。
6. 安全性考虑
6.1 防止XSS攻击
- 对用户输入进行转义,避免恶意脚本注入。
- 使用React的
dangerouslySetInnerHTML时需谨慎。
6.2 防止CSRF攻击
- 在HTTP请求中携带CSRF Token。
- 使用HTTPS加密通信。
6.3 数据验证
- 前端进行基本验证,后端进行严格验证。
7. 总结
通过本文的指南,您应该能够从零开始搭建一个高效、稳定的码头题库系统。我们涵盖了技术选型、核心模块实现、性能优化和兼容性解决方案。记住,前端开发是一个持续优化的过程,根据用户反馈和实际使用情况不断调整和改进您的系统。
希望这份指南对您的项目有所帮助!如果您有任何问题或需要进一步的帮助,请随时联系。# 码头题库前端开发实战指南:从零搭建高效稳定题库系统
1. 项目概述与技术选型
1.1 题库系统核心需求分析
码头题库系统作为在线教育平台的核心组件,需要满足以下关键需求:
- 海量题目展示:支持数千道题目的高效加载和展示
- 多样化题型支持:单选题、多选题、判断题、填空题、简答题等
- 智能筛选功能:按难度、知识点、题型等多维度筛选
- 实时答题反馈:即时评分和解析展示
- 高性能要求:快速响应,流畅的用户体验
1.2 技术栈选择
基于项目需求,我们推荐以下技术栈:
// package.json 核心依赖
{
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.8.0",
"axios": "^1.3.0",
"antd": "^5.0.0",
"zustand": "^4.3.0", // 轻量级状态管理
"react-query": "^4.0.0", // 数据获取和缓存
"react-window": "^1.8.0", // 虚拟滚动
"dayjs": "^1.11.0" // 时间处理
},
"devDependencies": {
"vite": "^4.0.0",
"typescript": "^4.9.0",
"eslint": "^8.0.0"
}
}
1.3 项目架构设计
src/
├── components/ # 通用组件
│ ├── QuestionCard/ # 题目卡片
│ ├── FilterPanel/ # 筛选面板
│ └── Pagination/ # 分页组件
├── pages/ # 页面组件
│ ├── QuestionList/ # 题目列表页
│ ├── QuestionDetail/ # 题目详情页
│ └── ExamMode/ # 考试模式
├── stores/ # 状态管理
│ ├── questionStore.ts # 题目状态
│ └── userStore.ts # 用户状态
├── services/ # API服务
│ ├── questionService.ts
│ └── apiClient.ts
├── utils/ # 工具函数
│ ├── validator.ts # 验证工具
│ └── formatter.ts # 格式化工具
└── types/ # TypeScript类型定义
└── question.ts
2. 环境搭建与基础配置
2.1 项目初始化
使用Vite快速搭建React项目:
npm create vite@latest dock-quiz-system -- --template react-ts
cd dock-quiz-system
npm install
2.2 路由配置
// src/router/index.tsx
import { createBrowserRouter } from 'react-router-dom';
import QuestionList from '../pages/QuestionList';
import QuestionDetail from '../pages/QuestionDetail';
import ExamMode from '../pages/ExamMode';
export const router = createBrowserRouter([
{
path: '/',
element: <QuestionList />
},
{
path: '/question/:id',
element: <QuestionDetail />
},
{
path: '/exam/:examId',
element: <ExamMode />
}
]);
2.3 API客户端封装
// src/services/apiClient.ts
import axios, { AxiosError, AxiosResponse } from 'axios';
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api';
const apiClient = axios.create({
baseURL: API_BASE_URL,
timeout: 10000,
headers: {
'Content-Type': 'application/json'
}
});
// 请求拦截器
apiClient.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// 响应拦截器
apiClient.interceptors.response.use(
(response: AxiosResponse) => response.data,
(error: AxiosError) => {
if (error.response?.status === 401) {
// 处理认证失效
window.location.href = '/login';
}
return Promise.reject(error);
}
);
export default apiClient;
3. 核心组件开发
3.1 题目卡片组件
// src/components/QuestionCard/index.tsx
import React, { useState } from 'react';
import { Card, Radio, Checkbox, Button, Space, Tag, Typography } from 'antd';
import { CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
import { Question, QuestionType, AnswerStatus } from '../../types/question';
const { Text } = Typography;
interface QuestionCardProps {
question: Question;
mode?: 'view' | 'exam' | 'result';
onAnswer?: (answer: string | string[]) => void;
showFeedback?: boolean;
}
const QuestionCard: React.FC<QuestionCardProps> = ({
question,
mode = 'view',
onAnswer,
showFeedback = false
}) => {
const [userAnswer, setUserAnswer] = useState<string | string[]>();
const [answerStatus, setAnswerStatus] = useState<AnswerStatus>('unanswered');
const isCorrect = () => {
if (!userAnswer) return false;
if (question.type === QuestionType.SINGLE || question.type === QuestionType.JUDGE) {
return userAnswer === question.correctAnswer;
} else if (question.type === QuestionType.MULTIPLE) {
return Array.isArray(userAnswer) &&
userAnswer.length === question.correctAnswer.length &&
userAnswer.every(ans => question.correctAnswer.includes(ans));
}
return false;
};
const handleAnswer = (value: any) => {
if (mode !== 'exam') return;
setUserAnswer(value);
if (onAnswer) {
onAnswer(value);
}
// 自动评分
if (showFeedback) {
const correct = isCorrect();
setAnswerStatus(correct ? 'correct' : 'incorrect');
}
};
const renderAnswerComponent = () => {
const disabled = mode !== 'exam';
switch (question.type) {
case QuestionType.SINGLE:
return (
<Radio.Group
value={userAnswer}
onChange={(e) => handleAnswer(e.target.value)}
disabled={disabled}
>
<Space direction="vertical">
{question.options?.map((option, index) => (
<Radio key={index} value={option.value}>
{option.label}
</Radio>
))}
</Space>
</Radio.Group>
);
case QuestionType.MULTIPLE:
return (
<Checkbox.Group
value={userAnswer as string[]}
onChange={(values) => handleAnswer(values)}
disabled={disabled}
>
<Space direction="vertical">
{question.options?.map((option, index) => (
<Checkbox key={index} value={option.value}>
{option.label}
</Checkbox>
))}
</Space>
</Checkbox.Group>
);
case QuestionType.JUDGE:
return (
<Radio.Group
value={userAnswer}
onChange={(e) => handleAnswer(e.target.value)}
disabled={disabled}
>
<Space>
<Radio value="true">正确</Radio>
<Radio value="false">错误</Radio>
</Space>
</Radio.Group>
);
case QuestionType.FILL:
return (
<input
type="text"
value={userAnswer as string}
onChange={(e) => handleAnswer(e.target.value)}
disabled={disabled}
style={{
width: '100%',
padding: '8px',
border: '1px solid #d9d9d9',
borderRadius: '6px'
}}
placeholder="请输入答案"
/>
);
default:
return null;
}
};
const renderFeedback = () => {
if (!showFeedback || mode !== 'exam' || !userAnswer) return null;
const correct = isCorrect();
return (
<div style={{ marginTop: 16, padding: 12, backgroundColor: correct ? '#f6ffed' : '#fff1f0', borderRadius: 6 }}>
<Space>
{correct ? <CheckCircleOutlined style={{ color: '#52c41a' }} /> : <CloseCircleOutlined style={{ color: '#ff4d4f' }} />}
<Text strong>{correct ? '回答正确!' : '回答错误!'}</Text>
</Space>
{!correct && (
<div style={{ marginTop: 8 }}>
<Text type="secondary">正确答案:{String(question.correctAnswer)}</Text>
</div>
)}
{question.explanation && (
<div style={{ marginTop: 8 }}>
<Text type="secondary">解析:{question.explanation}</Text>
</div>
)}
</div>
);
};
return (
<Card
title={
<Space>
<Tag color={getDifficultyColor(question.difficulty)}>{question.difficulty}</Tag>
<Tag color="blue">{question.type}</Tag>
<Text>{question.content}</Text>
</Space>
}
extra={mode === 'exam' && <Tag color="processing">答题中</Tag>}
style={{ marginBottom: 16 }}
bodyStyle={{ paddingTop: 0 }}
>
{question.image && (
<div style={{ marginBottom: 16 }}>
<img src={question.image} alt="题目图片" style={{ maxWidth: '100%', borderRadius: 6 }} />
</div>
)}
<div style={{ marginBottom: 16 }}>
{renderAnswerComponent()}
</div>
{renderFeedback()}
{mode === 'view' && question.tags && (
<div style={{ marginTop: 12 }}>
<Space wrap>
{question.tags.map(tag => (
<Tag key={tag} color="default">{tag}</Tag>
))}
</Space>
</div>
)}
</Card>
);
};
const getDifficultyColor = (difficulty: string) => {
switch (difficulty) {
case '简单': return 'success';
case '中等': return 'processing';
case '困难': return 'error';
default: return 'default';
}
};
export default QuestionCard;
3.2 虚拟滚动列表优化
对于大量题目数据,使用虚拟滚动技术:
// src/components/VirtualQuestionList/index.tsx
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { FixedSizeList as List } from 'react-window';
import { Spin, Empty } from 'antd';
import QuestionCard from '../QuestionCard';
import { Question } from '../../types/question';
interface VirtualQuestionListProps {
questions: Question[];
loading: boolean;
hasMore: boolean;
onLoadMore: () => void;
}
const VirtualQuestionList: React.FC<VirtualQuestionListProps> = ({
questions,
loading,
hasMore,
onLoadMore
}) => {
const listRef = useRef<List>(null);
const [containerHeight, setContainerHeight] = useState(600);
// 监听窗口大小变化
useEffect(() => {
const updateHeight = () => {
setContainerHeight(window.innerHeight - 200);
};
updateHeight();
window.addEventListener('resize', updateHeight);
return () => window.removeEventListener('resize', updateHeight);
}, []);
// 滚动到底部加载更多
const handleScroll = useCallback(({ scrollOffset, scrollUpdateWasRequested }: any) => {
const listElement = listRef.current;
if (!listElement || !hasMore || loading) return;
const scrollHeight = listElement.props.height;
const totalHeight = questions.length * 180; // 估算每项高度
if (scrollOffset + scrollHeight >= totalHeight - 100) {
onLoadMore();
}
}, [questions.length, hasMore, loading, onLoadMore]);
// 渲染每个列表项
const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => {
if (index >= questions.length) return null;
const question = questions[index];
return (
<div style={style}>
<QuestionCard question={question} mode="view" />
</div>
);
};
if (loading && questions.length === 0) {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 40 }}>
<Spin size="large" tip="加载题目中..." />
</div>
);
}
if (questions.length === 0) {
return <Empty description="暂无题目" />;
}
return (
<div style={{ height: containerHeight }}>
<List
ref={listRef}
height={containerHeight}
itemCount={questions.length + (hasMore ? 1 : 0)}
itemSize={180} // 估算每项高度
width="100%"
onScroll={handleScroll}
>
{Row}
</List>
{loading && hasMore && (
<div style={{ textAlign: 'center', padding: 12 }}>
<Spin size="small" /> 加载更多...
</div>
)}
</div>
);
};
export default VirtualQuestionList;
4. 状态管理与数据获取
4.1 使用Zustand管理全局状态
// src/stores/questionStore.ts
import { create } from 'zustand';
import { Question, QuestionFilter } from '../types/question';
import questionService from '../services/questionService';
interface QuestionStore {
questions: Question[];
loading: boolean;
filters: QuestionFilter;
pagination: {
current: number;
pageSize: number;
total: number;
};
fetchQuestions: (page?: number, pageSize?: number) => Promise<void>;
updateFilters: (filters: Partial<QuestionFilter>) => void;
resetFilters: () => void;
}
export const useQuestionStore = create<QuestionStore>((set, get) => ({
questions: [],
loading: false,
filters: {
difficulty: '',
type: '',
keyword: '',
tags: []
},
pagination: {
current: 1,
pageSize: 20,
total: 0
},
fetchQuestions: async (page = 1, pageSize = 20) => {
set({ loading: true });
try {
const { filters } = get();
const response = await questionService.getQuestions({
page,
pageSize,
...filters
});
set({
questions: response.items,
pagination: {
current: page,
pageSize,
total: response.total
},
loading: false
});
} catch (error) {
console.error('Failed to fetch questions:', error);
set({ loading: false });
}
},
updateFilters: (filters) => {
set(state => ({
filters: { ...state.filters, ...filters }
}));
// 筛选变化时重新获取数据
const { fetchQuestions } = get();
fetchQuestions(1, get().pagination.pageSize);
},
resetFilters: () => {
set({
filters: {
difficulty: '',
type: '',
keyword: '',
tags: []
}
});
const { fetchQuestions } = get();
fetchQuestions(1, get().pagination.pageSize);
}
}));
4.2 React Query数据管理
// src/services/questionService.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import apiClient from './apiClient';
import { Question, QuestionFilter } from '../types/question';
export const questionService = {
// 获取题目列表
getQuestions: async (params: QuestionFilter & { page: number; pageSize: number }) => {
const response = await apiClient.get('/questions', { params });
return response.data;
},
// 获取单个题目
getQuestionById: async (id: string) => {
const response = await apiClient.get(`/questions/${id}`);
return response.data;
},
// 创建题目
createQuestion: async (data: Partial<Question>) => {
const response = await apiClient.post('/questions', data);
return response.data;
},
// 更新题目
updateQuestion: async (id: string, data: Partial<Question>) => {
const response = await apiClient.put(`/questions/${id}`, data);
return response.data;
},
// 删除题目
deleteQuestion: async (id: string) => {
const response = await apiClient.delete(`/questions/${id}`);
return response.data;
}
};
// React Query Hooks
export const useQuestions = (page: number, pageSize: number, filters: QuestionFilter) => {
return useQuery({
queryKey: ['questions', page, pageSize, filters],
queryFn: () => questionService.getQuestions({ page, pageSize, ...filters }),
staleTime: 1000 * 60 * 5, // 5分钟缓存
keepPreviousData: true
});
};
export const useQuestion = (id: string) => {
return useQuery({
queryKey: ['question', id],
queryFn: () => questionService.getQuestionById(id),
enabled: !!id
});
};
export const useCreateQuestion = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: questionService.createQuestion,
onSuccess: () => {
queryClient.invalidateQueries(['questions']);
}
});
};
export const useUpdateQuestion = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<Question> }) =>
questionService.updateQuestion(id, data),
onSuccess: () => {
queryClient.invalidateQueries(['questions']);
}
});
};
export const useDeleteQuestion = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: questionService.deleteQuestion,
onSuccess: () => {
queryClient.invalidateQueries(['questions']);
}
});
};
5. 性能优化实战
5.1 代码分割与懒加载
// src/App.tsx
import React, { Suspense, lazy } from 'react';
import { Routes, Route } from 'react-router-dom';
import { Spin } from 'antd';
// 懒加载页面组件
const QuestionList = lazy(() => import('./pages/QuestionList'));
const QuestionDetail = lazy(() => import('./pages/QuestionDetail'));
const ExamMode = lazy(() => import('./pages/ExamMode'));
function App() {
return (
<Suspense fallback={
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<Spin size="large" tip="加载中..." />
</div>
}>
<Routes>
<Route path="/" element={<QuestionList />} />
<Route path="/question/:id" element={<QuestionDetail />} />
<Route path="/exam/:examId" element={<ExamMode />} />
</Routes>
</Suspense>
);
}
export default App;
5.2 图片懒加载优化
// src/components/LazyImage/index.tsx
import React, { useState, useEffect, useRef } from 'react';
import { Spin } from 'antd';
interface LazyImageProps {
src: string;
alt?: string;
placeholder?: string;
style?: React.CSSProperties;
}
const LazyImage: React.FC<LazyImageProps> = ({
src,
alt = '',
placeholder = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjZmZmZmZmIi8+PC9zdmc+',
style
}) => {
const [isLoaded, setIsLoaded] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const imageRef = useRef<HTMLImageElement>(null);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
setIsVisible(true);
observer.disconnect();
}
});
},
{ rootMargin: '50px' }
);
if (imageRef.current) {
observer.observe(imageRef.current);
}
return () => observer.disconnect();
}, []);
const handleLoad = () => {
setIsLoaded(true);
};
return (
<div style={{ position: 'relative', ...style }}>
{!isLoaded && (
<div style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#f5f5f5'
}}>
<Spin size="small" />
</div>
)}
<img
ref={imageRef}
src={isVisible ? src : placeholder}
alt={alt}
onLoad={handleLoad}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
opacity: isLoaded ? 1 : 0,
transition: 'opacity 0.3s'
}}
/>
</div>
);
};
export default LazyImage;
5.3 防抖与节流优化
// src/utils/performance.ts
import { useRef, useEffect, useCallback } from 'react';
// 防抖Hook
export function useDebounce<T extends (...args: any[]) => any>(
fn: T,
delay: number
): T {
const timeoutRef = useRef<NodeJS.Timeout>();
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
return useCallback((...args: Parameters<T>) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
fn(...args);
}, delay);
}, [fn, delay]) as T;
}
// 节流Hook
export function useThrottle<T extends (...args: any[]) => any>(
fn: T,
delay: number
): T {
const lastExecTime = useRef(0);
const timeoutRef = useRef<NodeJS.Timeout>();
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
return useCallback((...args: Parameters<T>) => {
const now = Date.now();
if (now - lastExecTime.current >= delay) {
fn(...args);
lastExecTime.current = now;
} else {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
fn(...args);
lastExecTime.current = Date.now();
}, delay - (now - lastExecTime.current));
}
}, [fn, delay]) as T;
}
// 使用示例
export const useSearchQuestions = () => {
const [searchTerm, setSearchTerm] = useState('');
const debouncedSearch = useDebounce((term: string) => {
// 执行搜索逻辑
console.log('Searching for:', term);
}, 500);
const handleSearch = (term: string) => {
setSearchTerm(term);
debouncedSearch(term);
};
return { searchTerm, handleSearch };
};
6. 兼容性问题解决方案
6.1 浏览器兼容性处理
// src/utils/polyfills.ts
// 检查并添加必要的Polyfill
// 检查IntersectionObserver支持
if (!('IntersectionObserver' in window)) {
import('intersection-observer').then(() => {
console.log('IntersectionObserver polyfill loaded');
});
}
// 检查Promise支持
if (!window.Promise) {
window.Promise = require('es6-promise').Promise;
}
// 检查Array.includes支持
if (!Array.prototype.includes) {
Array.prototype.includes = function(searchElement: any) {
return this.indexOf(searchElement) !== -1;
};
}
// 检查Object.assign支持
if (!Object.assign) {
Object.assign = function(target: any, ...sources: any[]) {
if (target == null) {
throw new TypeError('Cannot convert undefined or null to object');
}
target = Object(target);
for (let i = 0; i < sources.length; i++) {
if (sources[i] != null) {
for (let key in sources[i]) {
if (Object.prototype.hasOwnProperty.call(sources[i], key)) {
target[key] = sources[i][key];
}
}
}
}
return target;
};
}
6.2 CSS兼容性处理
// src/styles/compatibility.scss
/* 自动添加浏览器前缀 */
@import 'antd/dist/antd.css';
/* 移动端适配 */
@media screen and (max-width: 768px) {
.question-card {
margin: 0 !important;
border-radius: 0 !important;
.ant-card-head {
padding: 12px;
}
.ant-card-body {
padding: 12px;
}
}
}
/* 高对比度模式支持 */
@media (prefers-contrast: high) {
.question-card {
border: 2px solid #000;
.ant-tag {
border-width: 2px;
}
}
}
/* 减少动画模式 */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* 暗色模式支持 */
@media (prefers-color-scheme: dark) {
body {
background-color: #121212;
color: #e0e0e0;
}
.question-card {
background-color: #1e1e1e;
border-color: #333;
}
}
6.3 移动端触摸优化
// src/components/TouchOptimizedCard/index.tsx
import React, { useState, useRef } from 'react';
import { Card } from 'antd';
interface TouchOptimizedCardProps {
children: React.ReactNode;
onClick?: () => void;
}
const TouchOptimizedCard: React.FC<TouchOptimizedCardProps> = ({
children,
onClick
}) => {
const [touchStart, setTouchStart] = useState<{ x: number; y: number } | null>(null);
const [isScrolling, setIsScrolling] = useState(false);
const cardRef = useRef<HTMLDivElement>(null);
const handleTouchStart = (e: React.TouchEvent) => {
const touch = e.touches[0];
setTouchStart({ x: touch.clientX, y: touch.clientY });
setIsScrolling(false);
};
const handleTouchMove = (e: React.TouchEvent) => {
if (!touchStart) return;
const touch = e.touches[0];
const deltaX = Math.abs(touch.clientX - touchStart.x);
const deltaY = Math.abs(touch.clientY - touchStart.y);
// 如果垂直移动距离大于水平移动距离,说明是滚动
if (deltaY > deltaX && deltaY > 10) {
setIsScrolling(true);
}
};
const handleTouchEnd = (e: React.TouchEvent) => {
if (!touchStart || isScrolling) {
setTouchStart(null);
return;
}
const touch = e.changedTouches[0];
const deltaX = Math.abs(touch.clientX - touchStart.x);
const deltaY = Math.abs(touch.clientY - touchStart.y);
// 如果移动距离很小,认为是点击
if (deltaX < 10 && deltaY < 10 && onClick) {
onClick();
}
setTouchStart(null);
};
return (
<div
ref={cardRef}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
style={{ userSelect: 'none', WebkitUserSelect: 'none' }}
>
<Card>{children}</Card>
</div>
);
};
export default TouchOptimizedCard;
7. 安全性最佳实践
7.1 XSS防护
// src/utils/security.ts
import DOMPurify from 'dompurify';
// HTML内容净化
export function sanitizeHTML(dirtyHTML: string): string {
return DOMPurify.sanitize(dirtyHTML, {
ALLOWED_TAGS: ['b', 'i', 'u', 'em', 'strong', 'p', 'br', 'span'],
ALLOWED_ATTR: ['class', 'style'],
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover']
});
}
// 用户输入验证
export function validateUserInput(input: string, maxLength: number = 1000): boolean {
// 长度检查
if (input.length > maxLength) return false;
// 禁止特殊字符组合
const forbiddenPatterns = [
/<script\b/i,
/javascript:/i,
/on\w+\s*=/i,
/data:text\/html/i
];
return !forbiddenPatterns.some(pattern => pattern.test(input));
}
// 安全的JSON解析
export function safeJSONParse<T>(jsonString: string, defaultValue: T): T {
try {
return JSON.parse(jsonString);
} catch (error) {
console.error('JSON parse error:', error);
return defaultValue;
}
}
7.2 CSRF防护
// src/services/csrfProtection.ts
// 生成CSRF Token
export function generateCSRFToken(): string {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
}
// 存储和获取CSRF Token
export function getCSRFToken(): string {
let token = localStorage.getItem('csrf_token');
if (!token) {
token = generateCSRFToken();
localStorage.setItem('csrf_token', token);
}
return token;
}
// 在API请求中添加CSRF Token
export function addCSRFHeader(headers: Record<string, string> = {}): Record<string, string> {
return {
...headers,
'X-CSRF-Token': getCSRFToken()
};
}
8. 测试策略
8.1 单元测试示例
// src/components/QuestionCard/QuestionCard.test.tsx
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import QuestionCard from './index';
import { Question, QuestionType } from '../../types/question';
describe('QuestionCard', () => {
const mockQuestion: Question = {
id: '1',
content: '2 + 2 = ?',
type: QuestionType.SINGLE,
difficulty: '简单',
options: [
{ value: '3', label: '3' },
{ value: '4', label: '4' },
{ value: '5', label: '5' }
],
correctAnswer: '4',
explanation: '基础加法运算'
};
it('renders question content correctly', () => {
render(<QuestionCard question={mockQuestion} />);
expect(screen.getByText('2 + 2 = ?')).toBeInTheDocument();
});
it('handles answer selection in exam mode', () => {
const onAnswer = jest.fn();
render(
<QuestionCard
question={mockQuestion}
mode="exam"
onAnswer={onAnswer}
/>
);
const radio = screen.getByLabelText('4');
fireEvent.click(radio);
expect(onAnswer).toHaveBeenCalledWith('4');
});
it('shows feedback when enabled', () => {
render(
<QuestionCard
question={mockQuestion}
mode="exam"
showFeedback={true}
/>
);
// 选择错误答案
const wrongRadio = screen.getByLabelText('3');
fireEvent.click(wrongRadio);
expect(screen.getByText('回答错误!')).toBeInTheDocument();
});
});
8.2 E2E测试示例
// cypress/e2e/question-list.cy.ts
describe('Question List Page', () => {
beforeEach(() => {
cy.intercept('GET', '/api/questions**', {
fixture: 'questions.json'
}).as('getQuestions');
cy.visit('/');
});
it('should load and display questions', () => {
cy.wait('@getQuestions');
cy.get('.question-card').should('have.length.greaterThan', 0);
});
it('should filter questions by difficulty', () => {
cy.wait('@getQuestions');
cy.get('[data-testid="difficulty-filter"]').click();
cy.contains('简单').click();
cy.intercept('GET', '/api/questions**', {
fixture: 'questions-easy.json'
}).as('getFilteredQuestions');
cy.wait('@getFilteredQuestions');
cy.get('.question-card').each(($el) => {
cy.wrap($el).should('contain', '简单');
});
});
it('should handle pagination', () => {
cy.wait('@getQuestions');
cy.get('.ant-pagination-item-2').click();
cy.intercept('GET', '/api/questions**', {
fixture: 'questions-page2.json'
}).as('getPage2');
cy.wait('@getPage2');
cy.url().should('include', 'page=2');
});
});
9. 部署与监控
9.1 Docker部署配置
# Dockerfile
FROM node:18-alpine as builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
# 生产阶段
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/nginx.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
# nginx.conf
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Gzip压缩
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# 缓存策略
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# SPA路由支持
location / {
try_files $uri $uri/ /index.html;
}
# 安全头
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
}
}
9.2 性能监控
// src/utils/monitoring.ts
// 性能数据收集
export function collectPerformanceMetrics() {
if (!('performance' in window)) return;
const metrics = {
// 导航时间
navigation: performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming,
// 资源加载时间
resources: performance.getEntriesByType('resource').map(entry => ({
name: entry.name,
duration: entry.duration,
size: (entry as any).transferSize
})),
// 核心Web指标
fcp: 0, // 首次内容绘制
lcp: 0, // 最大内容绘制
cls: 0, // 累积布局偏移
fid: 0 // 首次输入延迟
};
// 发送到监控服务
fetch('/api/metrics', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(metrics)
}).catch(console.error);
}
// 错误监控
export function initErrorMonitoring() {
// JavaScript错误
window.addEventListener('error', (event) => {
console.error('Global error:', event.error);
// 发送到错误收集服务
fetch('/api/errors', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: event.message,
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
error: event.error?.stack,
timestamp: new Date().toISOString()
})
});
});
// Promise错误
window.addEventListener('unhandledrejection', (event) => {
console.error('Unhandled promise rejection:', event.reason);
fetch('/api/errors', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'unhandled_promise',
reason: event.reason?.stack || String(event.reason),
timestamp: new Date().toISOString()
})
});
});
}
10. 总结与最佳实践
10.1 性能优化清单
- ✅ 使用虚拟滚动处理长列表
- ✅ 实现代码分割和懒加载
- ✅ 图片懒加载和优化
- ✅ API请求防抖和节流
- ✅ 合理使用缓存策略
- ✅ 压缩和Gzip传输
10.2 兼容性检查清单
- ✅ 测试主流浏览器(Chrome, Firefox, Safari, Edge)
- ✅ 移动端触摸事件处理
- ✅ 无障碍访问支持
- ✅ 网络环境测试(3G, 弱网)
- ✅ 不同屏幕尺寸适配
10.3 安全加固清单
- ✅ XSS防护
- ✅ CSRF防护
- ✅ 输入验证
- ✅ HTTPS强制
- ✅ 安全头设置
- ✅ 敏感信息脱敏
通过本指南,您应该能够构建一个高效、稳定、安全的码头题库前端系统。记住,持续的性能监控和用户反馈是保持系统高质量的关键。建议定期进行代码审查和性能测试,确保系统始终处于最佳状态。
