SpringBoot+Vue构建响应式问卷调查系统实践

发布时间:2026/10/14 14:30:52
SpringBoot+Vue构建响应式问卷调查系统实践
1. 项目概述这个基于SpringBoot和HTML5的问卷调查系统是我去年为一个教育机构开发的线上调研工具。它解决了传统纸质问卷效率低、数据统计困难的问题目前已经稳定运行了9个月累计完成3.2万份有效问卷收集。系统最大的特点是采用了响应式设计在任何设备上都能获得一致的填写体验。后台使用SpringBoot 2.7 MyBatis Plus架构前端基于Vue.js ElementUI问卷编辑器则完全基于HTML5的Canvas API开发。特别要提的是问卷逻辑跳转功能我们通过图数据库存储问题间的关联关系实现了比常规线性问卷更复杂的调研场景。2. 核心功能设计2.1 问卷编辑器实现编辑器采用Blockly可视化编程的思路每个问题类型都封装成独立的Web Component。比如单选题组件class SingleChoice extends HTMLElement { constructor() { super(); this.attachShadow({ mode: open }); this.shadowRoot.innerHTML style/* 组件样式 *//style div classquestion-block input typetext classquestion-title placeholder请输入问题 div classoptions-container/div button classadd-option 添加选项/button /div ; } } customElements.define(single-choice, SingleChoice);开发中遇到的坑拖拽排序时z-index冲突问题需要统一管理所有组件的层级移动端触摸事件与点击事件冲突使用hammer.js做手势识别撤销/重做功能实现采用命令模式记录操作历史2.2 问卷逻辑引擎采用Neo4j图数据库存储问题间的关系比如(Question1)-[SHOW_IF]-(Condition: answer A) (Question2)-[HIDE_IF]-(Condition: age 30)核心跳转逻辑处理代码public ListQuestion getNextQuestions(String currentQid, MapString, Object answers) { String cypher MATCH (q:Question {qid: $qid})-[r]-(c:Condition) WHERE eval(c.expression) RETURN r.type, q; MapString, Object params new HashMap(); params.put(qid, currentQid); params.putAll(answers); return neo4jTemplate.findAll(cypher, params, Question.class); }重要提示条件表达式需要使用沙箱执行我们最终采用了GraalVM的隔离环境2.3 响应式布局方案使用CSS Grid Flexbox实现多列布局关键代码.questionnaire-container { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1.5rem; } media (max-width: 768px) { .question-block { flex-direction: column; } }实测发现的问题iOS Safari对CSS Grid的兼容性问题需要添加-webkit前缀华为浏览器对rem单位的解析差异改用px作为基准单位3. 技术架构详解3.1 后端服务设计采用经典的三层架构Controller → Service → Repository ↓ Redis Cache性能优化点问卷模板使用Redis缓存TPS从120提升到2100MyBatis Plus开启二级缓存数据库查询减少40%使用HikariCP连接池配置如下spring: datasource: hikari: maximum-pool-size: 20 connection-timeout: 30000 idle-timeout: 600000 max-lifetime: 18000003.2 前端工程化方案基于Vue CLI 4搭建的项目结构src/ ├── components/ # 公共组件 ├── views/ # 页面视图 ├── store/ # Vuex状态管理 ├── router/ # 路由配置 └── assets/ # 静态资源打包优化配置// vue.config.js module.exports { chainWebpack: config { config.optimization.splitChunks({ chunks: all, maxSize: 244 * 1024 // 控制chunk大小 }); } }3.3 安全防护措施XSS防护前端使用DOMPurify过滤富文本后端设置HttpOnly的CookieCSRF防护Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { Override protected void configure(HttpSecurity http) throws Exception { http.csrf().csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()); } }数据脱敏处理JsonSerialize(using SensitiveInfoSerializer.class) public class User { SensitiveInfo(type SensitiveType.PHONE) private String phone; }4. 典型问题解决方案4.1 问卷数据导出性能问题初期实现方案public void exportToExcel(Long questionnaireId, HttpServletResponse response) { ListAnswer answers answerService.listByQuestionnaire(questionnaireId); // 生成Excel... }当答案数量超过1万条时出现OOM改进方案使用MyBatis的流式查询分批次写入Excel添加进度通知最终代码GetMapping(/export) public void export(RequestParam Long id, HttpServletResponse response) { StreamingResponseBody body outputStream - { try (ExcelWriter writer ExcelUtil.getWriter(response.getOutputStream())) { answerMapper.streamByQuestionnaire(id, resultContext - { writer.writeRow(convertToRow(resultContext.getResultObject())); if (resultContext.getResultCount() % 1000 0) { eventPublisher.publish(new ExportProgressEvent(id, resultContext.getResultCount())); } }); } }; response.setContentType(application/vnd.ms-excel); response.setHeader(Content-Disposition, attachment;filenameexport.xlsx); body.writeTo(response.getOutputStream()); }4.2 高并发提交控制遇到的核心问题同一用户短时间内重复提交网络延迟导致的双重提交解决方案前端防抖处理后端使用Redis分布式锁public Result submitAnswer(AnswerDTO dto) { String lockKey submit: dto.getUserId() : dto.getQuestionnaireId(); try { Boolean locked redisTemplate.opsForValue().setIfAbsent(lockKey, 1, 5, TimeUnit.SECONDS); if (!locked) { return Result.fail(操作太频繁); } return answerService.saveAnswer(dto); } finally { redisTemplate.delete(lockKey); } }4.3 移动端兼容性问题收集到的典型问题iOS日期选择器显示异常安卓键盘遮挡输入框移动端页面加载慢解决方案统一使用第三方日期组件如flatpickr监听resize事件自动滚动window.addEventListener(resize, () { const activeElement document.activeElement; if (activeElement.tagName INPUT) { activeElement.scrollIntoView({ behavior: smooth, block: center }); } });实施移动端专项优化图片懒加载关键CSS内联使用Intersection Observer延迟加载非核心资源5. 部署与监控方案5.1 Docker化部署完整的docker-compose.yml配置version: 3.8 services: app: image: questionnaire:1.0 ports: - 8080:8080 environment: - SPRING_PROFILES_ACTIVEprod depends_on: - redis - neo4j redis: image: redis:6-alpine ports: - 6379:6379 volumes: - redis_data:/data neo4j: image: neo4j:4.4 ports: - 7474:7474 - 7687:7687 volumes: - neo4j_data:/data volumes: redis_data: neo4j_data:5.2 监控指标采集使用Prometheus Grafana监控体系关键指标JVM内存使用接口响应时间Redis命中率问卷提交速率SpringBoot配置示例Bean public MeterRegistryCustomizerPrometheusMeterRegistry metricsCommonTags() { return registry - registry.config().commonTags( application, questionnaire-system, region, System.getenv(REGION) ); }5.3 日志收集方案采用ELK栈处理日志Logstash配置片段input { file { path /var/log/questionnaire/*.log codec json } } filter { grok { match { message %{TIMESTAMP_ISO8601:timestamp} %{LOGLEVEL:level} %{GREEDYDATA:msg} } } }日志打印规范Slf4j RestController public class QuestionnaireController { GetMapping(/api/questionnaire/{id}) public ResponseEntity? getQuestionnaire(PathVariable Long id) { log.info(获取问卷[id{}], id); // ... } }6. 项目演进方向当前正在开发的几个重要特性语音输入答题使用Web Speech APIconst recognition new webkitSpeechRecognition(); recognition.onresult (event) { const transcript event.results[0][0].transcript; answerInput.value transcript; };实时协作编辑基于WebSocket的实现ServerEndpoint(/ws/collaboration/{docId}) public class CollaborationEndpoint { OnOpen public void onOpen(Session session, PathParam(docId) String docId) { sessions.computeIfAbsent(docId, k - new CopyOnWriteArraySet()).add(session); } }数据分析增强使用Apache Spark处理大规模问卷数据集成ECharts实现可视化分析这个项目给我的深刻体会是技术方案的选择必须紧密围绕业务场景。比如我们最初考虑用MongoDB存储问卷数据但最终选择了关系型数据库就是因为调研场景更需要事务支持而非单纯的写入性能。