本系列第28篇,我们继续 Web 应用开发实战。很多同学在准备蓝桥杯 Web 应用开发方向时,最担心的往往不是 HTML 布局,也不是 CSS 样式,而是 AJAX 请求:按钮点击后页面没反应、后端明明有数据却渲染不出来、请求发出去了却报 404……这些问题看似零散,其实都指向同一个能力点——你能不能在前端页面中正确发起异步请求,并且拿到数据后完成页面动态更新。
本文以一道蓝桥杯真题风格的模拟题“组课神器”为例,完整拆解 AJAX 请求从理论到实战的完整链路。题目场景是:学生在前端页面选择课程方向和学习难度,页面通过 AJAX 请求向后端获取课程列表,再勾选若干课程,发送给后端进行课程时间冲突检测,并展示推荐组合结果。整个项目包含原生 XMLHttpRequest、jQuery 的$.ajax、axios 三种写法,以及 Flask 后端接口设计,覆盖了蓝桥杯 Web 应用开发中 AJAX 模块的高频考点。
无论你是刚接触 AJAX 的新手,还是准备蓝桥杯冲刺的备赛选手,这篇文章都能提供一套可以直接复制的代码模板和排查思路。
1. 题目背景与考点定位
1.1 “组课神器”是一个什么题
“组课神器”本质是一个课程组合推荐工具。学生用户打开页面后,会看到一个筛选表单,上面有“课程方向”和“难度等级”两个条件;选择并点击“查询课程”后,页面下方会动态显示符合条件的课程卡片。用户可以勾选 2 到 4 门课程,点击“智能组课”,系统会判断所选课程是否存在时间冲突,并返回组合后的总学分与推荐提示。
这个场景在真实业务里非常常见,换成“选课系统”“预约系统”“商品筛选系统”都说得通。蓝桥杯 Web 应用开发真题也经常以这类“表单查询 + 动态渲染 + 数据回传”的题目出现,考察点非常集中:
- 前端表单数据的获取与拼接;
- AJAX 请求的 URL、请求方法、请求参数设置;
- 后端接口返回 JSON 数据的结构设计;
- 前端拿到 JSON 后如何遍历渲染;
- POST 请求中 JSON 格式数据的序列化与解析。
1.2 蓝桥杯 Web 应用开发为什么爱考 AJAX
AJAX 是现代 Web 应用前后端交互的基石。在蓝桥杯 Web 应用开发方向的题目中,静态页面只能拿基础分,真正区分水平的就是动态数据交互。真题中经常出现的“数据列表加载”“条件筛选”“异步提交”都依赖 AJAX。
同时,蓝桥杯的 Web 应用开发赛项也支持 Python Flask/Django、Java Spring 等后端技术栈,前后端分离的开发方式越来越普遍。在考试中,选手需要在规定时间内完成后端接口编写,再在前端页面中通过 AJAX 请求调用接口,并完成数据渲染。只要 AJAX 请求这一步没掌握,哪怕后端写得再完整,页面也无法展示出结果,最终得分会受到很大影响。
1.3 本文使用的技术栈与运行环境
为了让代码最小可运行,本文后端以 Python Flask 为例,Flask 2.x / 3.x 均可运行。如果你参加蓝桥杯时使用的是 Django,原理完全一致,只需要把路由和请求解析方式替换为 Django 对应写法即可。
| 环境 | 说明 |
|---|---|
| 操作系统 | Windows / macOS / Linux 均可 |
| Python | 3.8 及以上 |
| Flask | 2.x 或 3.x |
| 浏览器 | Chrome / Edge 最新版 |
| 编辑器 | VS Code、PyCharm 均可 |
版本不一定需要完全一致,重点是把 AJAX 请求的开发思路掌握清楚。
2. 先搞懂 AJAX 请求的底层逻辑
2.1 什么是 AJAX,它解决了什么问题
AJAX 全称 Asynchronous JavaScript And XML,中文叫“异步 JavaScript 和 XML”。注意,这里虽然名字里有 XML,但在实际开发中,现代接口几乎都返回 JSON 而不是 XML。
在没有 AJAX 的时代,浏览器每次向服务器请求数据,都会刷新整个页面。用户体验差,服务器压力也比较大。AJAX 出现后,页面可以通过 JavaScript 在后台悄悄发出请求,拿到服务器返回的数据后,只更新页面中的某一块区域,不用刷新整个页面。
以“组课神器”为例:用户点击“查询课程”按钮后,页面不会跳到新地址,也不会整体刷新,而是课程列表区域出现新的卡片数据。这就是 AJAX 的典型应用。
2.2 一次 AJAX 请求包含哪些关键要素
无论使用哪种 AJAX 库,一次完整的请求都包含以下关键要素:
| 要素 | 作用 | 示例 |
|---|---|---|
| url | 请求的接口地址 | /api/courses |
| method / type | 请求方法 | GET、POST |
| data | 请求参数 | {direction: '后端开发', level: '中级'} |
| dataType | 期望的返回类型 | json |
| success | 请求成功后的回调 | 渲染课程列表 |
| error | 请求失败后的回调 | 弹出错误提示 |
其中最容易写错的就是请求参数的类型。GET 请求的参数通常是拼在 URL 后面的查询字符串,比如:
/api/courses?direction=后端开发&level=中级而 POST 请求的参数可以放在请求体中,常见有两种格式:
application/x-www-form-urlencoded,表单格式;application/json,JSON 字符串格式。
这个区别直接决定了后端解析参数的方式,后面实战部分会重点演示。
2.3 同步与异步:AJAX 的“异步”到底指什么
AJAX 中的 A 是 Asynchronous,异步。异步的意思是:浏览器发出请求后,不需要傻等服务器返回结果,可以继续执行后面的 JavaScript 代码。等服务器响应返回后,浏览器再触发回调函数。
理解这一点很重要。很多同学写代码时,想在发出请求的下一行直接使用返回数据,结果发现数据是undefined。原因就是请求还没完成,代码已经继续往下执行了。
$.ajax({ url: '/api/courses', success: function (res) { console.log('请求成功,拿到数据', res); } }); console.log('这行代码会在请求完成前先执行');这种异步特性需要一段时间适应,但只要记住“数据只能在 success 回调里使用”,就不会踩坑。后续我们也会提到,可以使用async/await把异步代码写成更接近同步阅读习惯的样式。
3. 环境准备与项目结构
3.1 搭建项目目录
先在本地创建一个项目文件夹,命名为group_course,目录结构如下:
group_course/ ├── app.py └── templates/ └── index.htmlFlask 默认会从templates目录下查找模板文件,所以把前端页面放在这里。如果后续代码量大,你还可以继续拆分出static/js和static/css目录,但本文示例为了专注于 AJAX 逻辑,将 JavaScript 内联在 HTML 页面中。
3.2 安装 Flask
在命令行进入项目目录,创建并激活虚拟环境(推荐),然后安装 Flask:
python -m venv venv # Windows venv\Scripts\activate # macOS / Linux source venv/bin/activate pip install flask安装完成后,可以先测试一下 Flask 是否能正常导入:
from flask import Flask app = Flask(__name__) print("Flask 导入成功")只要能正常输出,就说明环境准备好了。
3.3 设计后端接口
“组课神器”需要两个接口:
| 接口 | 请求方式 | 功能 |
|---|---|---|
/api/courses | GET | 根据方向、难度筛选课程列表 |
/api/combine | POST | 接收选中的课程 id 列表,检测冲突并返回组合信息 |
接口返回格式统一使用如下结构:
{ "code": 0, "msg": "success", "data": {} }其中code为业务状态码,0表示成功;msg是提示信息;data是具体业务数据。这个结构在后面讲解最佳实践时会进一步说明。
4. 原生 XMLHttpRequest 实现课程查询
4.1 为什么先学 XMLHttpRequest
虽然现在很多人直接使用 jQuery 或 axios,但蓝桥杯真题中偶尔会要求“不使用第三方库”,或者直接考察原生 JS 实现。因此先掌握原生写法非常重要。
XMLHttpRequest 是浏览器内置的对象,简称 XHR。它不需要安装任何依赖,所有现代浏览器都支持。一个最简单的 GET 请求如下:
var xhr = new XMLHttpRequest(); xhr.open('GET', '/api/courses', true); xhr.onreadystatechange = function () { if (xhr.readyState === 4 && xhr.status === 200) { var res = JSON.parse(xhr.responseText); console.log(res); } }; xhr.send();这段代码的逻辑分成三步:
- 创建 XHR 对象;
- 调用
open方法指定请求方式和地址,第三个参数true表示异步请求; - 调用
send发送请求,并在onreadystatechange中监听状态变化。
readyState有 0 到 4 五个值,通常只需要关心4,代表请求已完成。status是 HTTP 状态码,200代表成功。
4.2 带参数发送 GET 请求
在“组课神器”中,用户选择方向和难度后,查询参数是拼接到 URL 上的。直接使用字符串拼接很容易出错,尤其当参数里包含中文时,需要调用encodeURIComponent进行编码。
我们可以封装一个工具函数来处理参数拼接:
function buildQuery(params) { var arr = []; for (var key in params) { if (params.hasOwnProperty(key)) { arr.push(encodeURIComponent(key) + '=' + encodeURIComponent(params[key])); } } return arr.join('&'); }然后查询课程列表:
function loadCourses() { var params = { direction: document.getElementById('direction').value, level: document.querySelector('input[name="level"]:checked').value }; var query = buildQuery(params); var xhr = new XMLHttpRequest(); xhr.open('GET', '/api/courses?' + query, true); xhr.onreadystatechange = function () { if (xhr.readyState === 4 && xhr.status === 200) { var res = JSON.parse(xhr.responseText); renderCourses(res.data); } }; xhr.send(); }这里有一个常见误区:页面代码里使用了document.querySelector('input[name="level"]:checked'),如果用户没有选择任何难度,会返回null,再访问.value就会报错。所以前端在发送请求之前,应该先做空值校验。
4.3 用 XHR 发送 POST 请求,参数格式是关键
POST 请求与 GET 最大的区别在于参数放入请求体,且必须设置Content-Type。如果后端使用request.get_json()解析 JSON 数据,那么前端的Content-Type必须为application/json,并且send的参数必须是 JSON 字符串,而不是对象。
function combineCourses() { var checkedBoxes = document.querySelectorAll('input[name="courseIds"]:checked'); var ids = []; checkedBoxes.forEach(function (box) { ids.push(Number(box.value)); }); if (ids.length < 2) { alert('请至少选择两门课程'); return; } var xhr = new XMLHttpRequest(); xhr.open('POST', '/api/combine', true); xhr.setRequestHeader('Content-Type', 'application/json'); xhr.onreadystatechange = function () { if (xhr.readyState === 4 && xhr.status === 200) { var res = JSON.parse(xhr.responseText); renderCombineResult(res.data); } }; xhr.send(JSON.stringify({ ids: ids })); }需要注意:JSON.stringify({ ids: ids })把 JavaScript 对象转成了 JSON 字符串,Content-Type也显式设置为application/json。如果这两点缺少一个,后端很可能解析不到数据。
4.4 数据渲染:拿到 JSON 之后怎么展示
当loadCourses拿到课程列表后,需要把这些数据渲染成页面上的卡片。一般思路是先用innerHTML拼接 HTML 字符串,然后一次性赋值给容器节点。
function renderCourses(courses) { var container = document.getElementById('courseList'); if (courses.length === 0) { container.innerHTML = '<p class="empty">没有符合条件的课程</p>'; return; } var html = ''; courses.forEach(function (course) { html += '<div class="course-card">'; html += '<label>'; html += '<input type="checkbox" name="courseIds" value="' + course.id + '">'; html += '<span class="course-name">' + course.name + '</span>'; html += '<span class="course-meta">' + course.direction + ' / ' + course.level + '</span>'; html += '<span class="course-credit">' + course.credit + ' 学分</span>'; html += '</label>'; html += '</div>'; }); container.innerHTML = html; }这种拼接方式简单直接,适合比赛场景。需要注意的是,课程名称如果是用户输入的数据,直接拼接到innerHTML中可能存在 XSS 注入风险。在蓝桥杯题目中,课程数据一般是写死的模拟数据,所以问题不大;但如果是在真实业务中,就需要做 HTML 转义处理。
5. 更常用的 jQuery 与 axios 写法
5.1 引入方式
jQuery 和 axios 都是第三方库。在蓝桥杯考试环境中,通常已经允许使用本地引入或 CDN 引入。你也可以直接在页面顶部用 CDN:
<script src="https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/axios@1.7.2/dist/axios.min.js"></script>如果比赛机器不能联网,建议提前下载到本地static/js目录,再通过相对路径引入。
5.2 jQuery 的 $.ajax 写法
jQuery 的$.ajax是封装程度较高的一种写法,蓝桥杯历年真题中出现频率很高。它的参数是一个对象,优势是可以通过type、data、dataType等字段快速配置请求。
GET 请求查询课程:
$.ajax({ url: '/api/courses', type: 'GET', data: { direction: $('#direction').val(), level: $('input[name="level"]:checked').val() }, dataType: 'json', success: function (res) { renderCourses(res.data); }, error: function (xhr, status, error) { console.error('请求失败', error); } });POST 请求提交课程组合:
$.ajax({ url: '/api/combine', type: 'POST', contentType: 'application/json', data: JSON.stringify({ ids: ids }), dataType: 'json', success: function (res) { renderCombineResult(res.data); }, error: function () { alert('提交失败,请稍后重试'); } });如果不设置contentType: 'application/json',jQuery 默认会按表单格式application/x-www-form-urlencoded提交。此时 Flask 后端应该使用request.form.get来获取参数,而不能使用request.get_json()。这是非常经典的参数解析不一致问题。
5.3 表单快速序列化
在实际题目中,如果表单字段很多,可以使用 jQuery 的serialize()方法快速把表单内容拼成查询字符串:
var query = $('#filterForm').serialize(); // 输出类似:direction=后端开发&level=中级 $.ajax({ url: '/api/courses?' + query, type: 'GET', dataType: 'json', success: function (res) { renderCourses(res.data); } });serialize()会自动对特殊字符进行编码,对于中文和空格都能正确处理,推荐在比赛时优先使用,能减少手写参数拼接的错误。
5.4 axios 与 async/await 写法
axios 更符合现代前端项目的写法,配合async/await后,代码阅读起来接近同步逻辑:
async function loadCourses() { try { const response = await axios.get('/api/courses', { params: { direction: document.getElementById('direction').value, level: document.querySelector('input[name="level"]:checked').value } }); renderCourses(response.data.data); } catch (err) { console.error('请求失败', err); } }POST 请求:
async function combineCourses() { const ids = getCheckedCourseIds(); if (ids.length < 2) { alert('请至少选择两门课程'); return; } try { const response = await axios.post('/api/combine', { ids: ids }); renderCombineResult(response.data.data); } catch (err) { alert('提交失败,请稍后重试'); } }axios 与 jQuery 的$.ajax有一个明显区别:axios 请求成功后,返回对象response中的data才是后端返回的 JSON 内容,所以代码里会连续访问两次data。这一点初学者特别容易搞混,可以把后端返回结构打印到控制台确认一下。
6. 完整案例:组课神器的前后端实现
前面把知识点拆开了,这一节给出一个可以直接运行的完整项目。后端使用 Flask,前端使用原生 JavaScript + XMLHttpRequest,并同时提供 jQuery 版本的替代代码注释说明。
6.1 后端接口完整代码
文件路径:group_course/app.py
from flask import Flask, jsonify, render_template, request app = Flask(__name__) # 模拟数据库课程表 COURSES = [ {"id": 1, "name": "HTML网页基础", "direction": "前端开发", "level": "初级", "day": 1, "period": 3, "credit": 2}, {"id": 2, "name": "CSS布局实战", "direction": "前端开发", "level": "中级", "day": 1, "period": 5, "credit": 3}, {"id": 3, "name": "JavaScript核心语法", "direction": "前端开发", "level": "中级", "day": 2, "period": 3, "credit": 4}, {"id": 4, "name": "Vue组件开发", "direction": "前端开发", "level": "高级", "day": 3, "period": 3, "credit": 4}, {"id": 5, "name": "Python程序设计", "direction": "后端开发", "level": "初级", "day": 1, "period": 3, "credit": 2}, {"id": 6, "name": "Flask接口开发", "direction": "后端开发", "level": "中级", "day": 2, "period": 5, "credit": 3}, {"id": 7, "name": "Django Web开发", "direction": "后端开发", "level": "中级", "day": 4, "period": 3, "credit": 4}, {"id": 8, "name": "数据库设计", "direction": "后端开发", "level": "高级", "day": 5, "period": 3, "credit": 4}, {"id": 9, "name": "Python数据分析", "direction": "数据开发", "level": "初级", "day": 3, "period": 5, "credit": 3}, {"id": 10, "name": "SQL查询优化", "direction": "数据开发", "level": "中级", "day": 4, "period": 5, "credit": 3}, {"id": 11, "name": "数据可视化", "direction": "数据开发", "level": "高级", "day": 5, "period": 5, "credit": 2}, {"id": 12, "name": "爬虫与数据采集", "direction": "数据开发", "level": "高级", "day": 2, "period": 3, "credit": 4}, ] @app.route("/") def index(): return render_template("index.html") @app.route("/api/courses", methods=["GET"]) def get_courses(): direction = request.args.get("direction", "全部") level = request.args.get("level", "全部") result = [] for course in COURSES: if direction != "全部" and course["direction"] != direction: continue if level != "全部" and course["level"] != level: continue result.append(course) return jsonify({"code": 0, "msg": "success", "data": result}) @app.route("/api/combine", methods=["POST"]) def combine_courses(): payload = request.get_json(silent=True) if not payload or "ids" not in payload: return jsonify({"code": 1, "msg": "参数缺失", "data": None}) ids = payload.get("ids", []) # 过滤出不存在的课程 id,避免用户传入非法参数 selected = [course for course in COURSES if course["id"] in ids] # 检测时间冲突:day 和 period 都相同则冲突 conflict = False for i in range(len(selected)): for j in range(i + 1, len(selected)): if selected[i]["day"] == selected[j]["day"] and selected[i]["period"] == selected[j]["period"]: conflict = True total_credit = sum(course["credit"] for course in selected) data = { "courses": selected, "conflict": conflict, "total_credit": total_credit, } return jsonify({"code": 0, "msg": "success", "data": data}) if __name__ == "__main__": app.run(debug=True)后端说明:
request.args.get用于获取 GET 查询参数;request.get_json(silent=True)用于解析 JSON 请求体,加silent=True后即使解析失败也不会抛异常,而是返回None;jsonify会把字典转换为 JSON 响应;- 时间冲突检测使用了双重循环,题目数据量小,性能完全够用。
6.2 前端页面完整代码
文件路径:group_course/templates/index.html
<!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> * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: "Microsoft YaHei", "PingFang SC", sans-serif; background: #f4f6f9; padding: 30px; } .container { max-width: 900px; margin: 0 auto; background: #fff; border-radius: 12px; padding: 24px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); } h1 { font-size: 24px; margin-bottom: 6px; } .subtitle { color: #888; font-size: 14px; margin-bottom: 20px; } .filter-form { display: flex; flex-wrap: wrap; gap: 20px; align-items: center; border-bottom: 1px dashed #ddd; padding-bottom: 20px; margin-bottom: 20px; } .filter-form label { font-size: 14px; margin-right: 6px; } .filter-form select, .filter-form input[type="radio"] { cursor: pointer; } .btn { padding: 8px 18px; border: none; border-radius: 6px; font-size: 14px; cursor: pointer; background: #4a7cf7; color: #fff; transition: background 0.2s; } .btn:hover { background: #3b66db; } .btn:disabled { background: #a0b4e0; cursor: not-allowed; } .course-list { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; margin-bottom: 20px; } .course-card { border: 1px solid #e5e7eb; border-radius: 8px; padding: 12px; transition: border-color 0.2s; } .course-card:hover { border-color: #4a7cf7; } .course-card label { display: flex; align-items: center; cursor: pointer; } .course-card input { margin-right: 8px; } .course-name { font-weight: 600; margin-right: auto; } .course-meta { font-size: 12px; color: #999; margin-right: 8px; } .course-credit { font-size: 12px; color: #4a7cf7; background: #eef3ff; border-radius: 4px; padding: 2px 6px; } .empty { grid-column: 1 / -1; color: #999; text-align: center; padding: 30px 0; } .result-box { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 16px; } .result-box h3 { margin-bottom: 10px; font-size: 16px; } .result-item { font-size: 14px; color: #333; margin-bottom: 4px; } .tip-success { color: #16a34a; margin-top: 8px; } .tip-warning { color: #dc2626; margin-top: 8px; } </style> </head> <body> <div class="container"> <h1>组课神器</h1> <p class="subtitle">选择课程方向与难度,查看推荐课程组合</p> <div class="filter-form"> <div> <label for="direction">课程方向</label> <select id="direction"> <option value="全部">全部</option> <option value="前端开发">前端开发</option> <option value="后端开发">后端开发</option> <option value="数据开发">数据开发</option> </select> </div> <div> <label>难度等级</label> <label><input type="radio" name="level" value="全部" checked> 全部</label> <label><input type="radio" name="level" value="初级"> 初级</label> <label><input type="radio" name="level" value="中级"> 中级</label> <label><input type="radio" name="level" value="高级"> 高级</label> </div> <button class="btn" id="searchBtn">查询课程</button> </div> <div id="courseList" class="course-list"></div> <button class="btn" id="combineBtn">智能组课</button> <div id="resultBox" class="result-box" style="margin-top:20px; display: none;"> <h3>推荐结果</h3> <div id="resultContent"></div> </div> </div> <script> // 1. 构建查询字符串 function buildQuery(params) { var arr = []; for (var key in params) { if (params.hasOwnProperty(key)) { arr.push(encodeURIComponent(key) + '=' + encodeURIComponent(params[key])); } } return arr.join('&'); } // 2. 查询课程 function loadCourses() { var direction = document.getElementById('direction').value; var levelRadio = document.querySelector('input[name="level"]:checked'); var level = levelRadio ? levelRadio.value : '全部'; var query = buildQuery({ direction: direction, level: level }); var xhr = new XMLHttpRequest(); xhr.open('GET', '/api/courses?' + query, true); xhr.onreadystatechange = function () { if (xhr.readyState === 4) { if (xhr.status === 200) { var res = JSON.parse(xhr.responseText); renderCourses(res.data); } else { alert('课程查询失败,请检查后端服务是否启动'); } } }; xhr.send(); } // 3. 渲染课程卡片 function renderCourses(courses) { var container = document.getElementById('courseList'); if (!courses || courses.length === 0) { container.innerHTML = '<p class="empty">没有符合条件的课程</p>'; return; } var html = ''; courses.forEach(function (course) { html += '<div class="course-card">'; html += '<label>'; html += '<input type="checkbox" name="courseIds" value="' + course.id + '">'; html += '<span class="course-name">' + course.name + '</span>'; html += '<span class="course-meta">' + course.direction + ' / ' + course.level + '</span>'; html += '<span class="course-credit">' + course.credit + ' 学分</span>'; html += '</label>'; html += '</div>'; }); container.innerHTML = html; } // 4. 获取选中的课程 id 列表 function getCheckedIds() { var boxes = document.querySelectorAll('input[name="courseIds"]:checked'); var ids = []; boxes.forEach(function (box) { ids.push(Number(box.value)); }); return ids; } // 5. 提交组合 function combineCourses() { var ids = getCheckedIds(); if (ids.length < 2) { alert('请至少选择两门课程'); return; } if (ids.length > 4) { alert('最多选择四门课程'); return; } var xhr = new XMLHttpRequest(); xhr.open('POST', '/api/combine', true); xhr.setRequestHeader('Content-Type', 'application/json'); xhr.onreadystatechange = function () { if (xhr.readyState === 4 && xhr.status === 200) { var res = JSON.parse(xhr.responseText); renderCombineResult(res.data); } }; xhr.send(JSON.stringify({ ids: ids })); } // 6. 渲染组合结果 function renderCombineResult(data) { var box = document.getElementById('resultBox'); var content = document.getElementById('resultContent'); box.style.display = 'block'; var html = ''; if (!data.courses || data.courses.length === 0) { html = '<p class="result-item">未找到有效课程</p>'; } else { data.courses.forEach(function (course) { html += '<p class="result-item">' + course.name + '(' + course.direction + ')第' + course.day + '天 第' + course.period + '节</p>'; }); html += '<p class="result-item">总学分:' + data.total_credit + '</p>'; if (data.conflict) { html += '<p class="tip-warning">课程时间存在冲突,请调整选择</p>'; } else { html += '<p class="tip-success">课程时间无冲突,推荐组合成功</p>'; } } content.innerHTML = html; } // 7. 绑定事件 document.getElementById('searchBtn').addEventListener('click', loadCourses); document.getElementById('combineBtn').addEventListener('click', combineCourses); // 页面加载时默认展示全部课程 loadCourses(); </script> </body> </html>这段代码里需要注意两个细节:
- 使用
input[name="courseIds"]:checked选择被勾选的复选框,这与 HTML 中复选框的name属性一一对应; Number(box.value)把字符串类型的复选框值转换为数字,避免后续后端比对 id 时出现类型不一致的问题。
6.3 运行与验证
在项目目录下执行:
python app.py浏览器访问http://127.0.0.1:5000,页面上会默认显示全部课程。选择“后端开发”和“中级”,点击“查询课程”,页面会立即刷新课程列表,只显示两门课程:Flask 接口开发 和 Django Web 开发。
再勾选这两门课程,点击“智能组课”,页面下方会显示推荐结果:
Flask 接口开发(后端开发)第 2 天 第 5 节 Django Web 开发(后端开发)第 4 天 第 3 节 总学分:7 课程时间无冲突,推荐组合成功如果选择“HTML网页基础”和“Python程序设计”,两门课都是第 1 天第 3 节,就会提示“课程时间存在冲突,请调整选择”。这就是后端冲突检测逻辑生效了。
6.4 打开浏览器开发者工具观察请求
按 F12 打开开发者工具,切换到 Network 面板,再点击“查询课程”。可以看到浏览器发出了一条GET请求,地址为/api/courses?direction=后端开发&level=中级。点击该请求,在 Payload 或 Query String Parameters 中能看到完整参数。
再点击“智能组课”,可以看到一条POST /api/combine请求,请求头中带有Content-Type: application/json,请求体是:
{"ids": [6, 7]}这个面板是排查 AJAX 问题的第一现场。如果页面没有按预期更新,优先看 Network 面板里请求的状态码、请求参数和响应内容。
7. 常见问题与排查思路
蓝桥杯比赛时间紧张,遇到问题如果反复试错会浪费大量时间。下面整理了几类 AJAX 请求的高频问题,你可以按表排查。
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 点击按钮后页面无任何反应 | JavaScript 报错,事件未绑定 | F12 打开 Console 面板,查看报错行 |
| 请求返回 404 | URL 路径写错,或后端路由未定义 | 对比 Network 中的请求地址与 Flask 路由 |
| 返回 JSON 字符串,前端无法渲染 | 忘记JSON.parse,或 dataType 设置不一致 | 确认xhr.responseText后先JSON.parse |
| POST 请求返回 400 / 后端收到 None | Content-Type 不是application/json | 检查请求头,使用setRequestHeader设置 |
| 后端拿到参数但返回结果不对 | 参数名不一致 | 打印request.args或payload,逐一比对 |
| 页面中文显示乱码 | HTML 没有<meta charset="UTF-8"> | 在 HTML head 中补充编码声明 |
| 请求出现 CORS 跨域报错 | 前端页面与接口不在同源地址 | 开发时统一通过 Flask 服务访问,或配置 CORS |
| 按钮重复点击产生大量请求 | 没有做防重复提交 | 点击后禁用按钮,请求完成后恢复 |
| 异步请求拿到数据时页面已经执行后面代码 | 不理解异步回调 | 把数据渲染逻辑放入 success 回调中 |
其中“POST 请求后端收到 None”是最常见的问题,根本原因通常是前端没有设置Content-Type: application/json。你可以把请求头打印出来,或者在后端接口中临时加一行:
print(request.headers.get("Content-Type"))如果输出不是application/json,那就可以确定是请求头的问题。
8. 蓝桥杯备考与工程实践建议
8.1 比赛时优先保证“请求能通”
蓝桥杯 Web 应用开发是限时比赛,代码规范性和性能优化不是第一优先级。拿到题目后,先保证前后端接口能连通,再考虑代码优雅性。
我建议按这个顺序检查:
- 后端路由能否直接通过浏览器访问;
- 前端 AJAX 请求地址与路由是否完全一致(注意大小写和斜杠);
- 请求参数名是否与后端解析字段一致;
- 返回 JSON 是否与前端渲染字段一致;
- 页面渲染是否完成预期效果。
只要这个链路跑通,题目就已经拿下了大部分分数。
8.2 封装统一的 AJAX 工具函数
在真实项目和复赛中,建议把 AJAX 请求封装成一个函数,避免每个页面重复写 XMLHttpRequest 模板代码。下面是一个简单的封装示例:
function ajax(options) { var xhr = new XMLHttpRequest(); xhr.open(options.method || 'GET', options.url, true); if (options.headers) { for (var key in options.headers) { xhr.setRequestHeader(key, options.headers[key]); } } xhr.onreadystatechange = function () { if (xhr.readyState === 4) { if (xhr.status >= 200 && xhr.status < 300) { var data = JSON.parse(xhr.responseText); options.success && options.success(data); } else { options.error && options.error(xhr); } } }; var body = null; if (options.data) { if (options.contentType === 'application/json') { xhr.setRequestHeader('Content-Type', 'application/json'); body = JSON.stringify(options.data); } else { xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); body = buildQuery(options.data); } } xhr.send(body); }使用方式:
ajax({ url: '/api/courses', method: 'GET', data: { direction: '后端开发', level: '中级' }, success: function (res) { renderCourses(res.data); }, error: function () { alert('请求失败'); } });这样页面中所有请求都能保持一致的书写风格,排查问题也更方便。
8.3 统一后端返回格式
前后端分离开发时,接口返回格式一定要统一。推荐使用{ code, msg, data }结构。前端只判断code是否为 0,再读取data渲染页面。这样即使后端内部逻辑变化,前端代码也不需要大改。
如果code非 0,比如参数缺失、未登录、数据不存在,前端可以直接根据msg提示用户,错误处理逻辑也会简单很多。
8.4 安全边界不能忘
虽然蓝桥杯题目相对简单,但写代码时仍然要养成基本的安全意识:
- 后端接口不要信任前端传入的 id,要做存在性校验;
- 对传入参数做类型判断,避免
None类型的后续访问报错; - 动态拼接 HTML 时,如果是用户可控数据,要做 HTML 转义;
- 课程组合、订单提交这类接口,涉及写操作时建议使用 POST 而不是 GET;
- 如果接口涉及用户数据,生产环境中必须增加身份认证和权限校验。
这些习惯不一定能在比赛评分中直接体现,但能帮助你在实际项目中避免大量线上故障。
8.5 优先掌握一种写法,再扩展其他写法
原生 XMLHttpRequest、jQuery 的$.ajax、axios、fetch 这几种方式背后原理相同,区别只是封装程度。建议先把原生 XHR 写熟,再用 jQuery 和 axios 提高开发效率。考试时,优先使用自己最熟悉、最有把握的写法,不要为了展示技术栈去用不熟悉的库。
如果时间允许,可以继续学习FormData文件上传、async/await异步流程控制、JSONP 跨域原理等扩展内容。但就蓝桥杯 Web 应用开发这个方向而言,把 GET 请求和 POST 请求掌握扎实,已经能够覆盖大多数真题场景。
备赛阶段,建议手边放几个往年真题,把题目中所有的数据请求都改成 AJAX 实现,并在浏览器 Network 面板中观察请求参数和响应内容。练得多了,AJAX 的常见坑自然就心里有数了。