在教育数字化转型的浪潮中,题库系统已成为在线学习平台的核心组件。前端开发作为用户直接交互的界面,其设计和实现直接影响用户体验。本文将为您提供一份全面的前端开发实战指南,帮助您从零开始搭建一个高效、稳定的码头题库系统,并解决常见的性能与兼容性问题。

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.lazySuspense实现代码分割,按需加载组件,减少初始包大小。

const Quiz = React.lazy(() => import('./Quiz'));

function App() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <Quiz quizId={1} />
    </Suspense>
  );
}

5. 兼容性问题解决方案

5.1 浏览器兼容性

  • Polyfills: 使用core-jsregenerator-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强制
  • ✅ 安全头设置
  • ✅ 敏感信息脱敏

通过本指南,您应该能够构建一个高效、稳定、安全的码头题库前端系统。记住,持续的性能监控和用户反馈是保持系统高质量的关键。建议定期进行代码审查和性能测试,确保系统始终处于最佳状态。