学员数据查询与统计
学员管理后台需要显示当前查询的人数、平均分和学员摘要。如果原始成绩是字符串,或当前筛选没有结果,还能直接计算吗?本节先验证输入,再筛选、查找、转换和统计,最后把结果输出到 Console,为下一部分的页面展示准备数据。
案例目标
给定学员数组,完成:
- 检查学员记录的关键字段是否合法。
- 按状态、课程和关键词筛选。
- 按 ID 查找学员。
- 计算人数、平均分、最高分和及格率。
- 生成简化的摘要数组。
- 使用
Map建立 ID 索引。 - 使用
Set得到不重复课程。
不使用 document、事件、存储和 fetch。
数据约定与样本
本章按“准备数据 → 定义函数 → 组合查询”衔接。各小节的示范调用用于观察局部结果;最后组合程序时保留数据和函数定义,再统一执行查询,不必重复所有示范调用。运行附件提供整理好的完整版本。输入已经完成基本清洗,遵循以下约定:
| 字段 | 本例约定 |
|---|---|
id | 正整数;准备的数据中编号唯一 |
name | 字符串,去掉两端空白后至少 2 个字符 |
score | 0 到 100 之间的数字,包含两端;不把字符串直接当成绩 |
status | active、pending、disabled 之一 |
course | 非空课程代码字符串 |
contact | null,或包含字符串 mobile 的对象 |
如果输入仍是 "86" 这样的原始文本,先按 c03 的方法清洗,再进入本章流程。手机号保持字符串,便于搜索,也避免丢失前导零。
const students = [
{
id: 1001,
name: "张三",
score: 86,
status: "active",
course: "web",
contact: { mobile: "13800000001" },
},
{
id: 1002,
name: "李四",
score: 72,
status: "active",
course: "python",
contact: { mobile: "13800000002" },
},
{
id: 1003,
name: "王五",
score: 93,
status: "disabled",
course: "web",
contact: null,
},
{
id: 1004,
name: "赵敏",
score: 58,
status: "pending",
course: "web",
contact: { mobile: "13800000004" },
},
{
id: 1005,
name: "陈晨",
score: 88,
status: "active",
course: "data",
contact: { mobile: "13800000005" },
},
];状态约定:
const allowedStatuses = new Set(["active", "pending", "disabled"]);数据验证与查询
字段验证
const isValidStudent = (student) => {
if (student === null || typeof student !== "object" || Array.isArray(student)) {
return false;
}
const hasValidId = Number.isInteger(student.id) && student.id > 0;
const hasValidName =
typeof student.name === "string" && student.name.trim().length >= 2;
const hasValidScore =
Number.isFinite(student.score) &&
student.score >= 0 &&
student.score <= 100;
const hasValidStatus = allowedStatuses.has(student.status);
const hasValidCourse =
typeof student.course === "string" && student.course.trim().length > 0;
const hasValidContact =
student.contact === null ||
(typeof student.contact === "object" &&
!Array.isArray(student.contact) &&
typeof student.contact.mobile === "string");
return hasValidId && hasValidName && hasValidScore &&
hasValidStatus && hasValidCourse && hasValidContact;
};
const allStudentsValid = students.every(isValidStudent);
console.log("所有学员数据有效:", allStudentsValid);验证函数只返回布尔值,不输出也不修改原数据,容易单独测试。Number.isFinite() 不把 "86" 转成数字;它与上一节的成绩验证规则一致。
这里先排除非记录值,再按字段验证。hasValidContact 先接受 null;否则确认是对象后再读取手机号,使用了前面学过的短路求值。
验证结果应控制后续处理:本例约定只要存在不合法记录,就提示修正,并停止本次查询与统计。空数组可以作为有效输入,表示当前没有学员;every() 对空数组返回 true,统计函数仍要单独处理人数为零。
以下分步示例使用上方的有效数据。最后的“综合查询流程”会把验证分支接入执行过程。
条件筛选
const filterStudents = (
studentList,
{ status = null, course = null, keyword = "" } = {},
) => {
const normalizedKeyword = keyword.trim().toLowerCase();
return studentList.filter((student) => {
const matchesStatus = status === null || student.status === status;
const matchesCourse = course === null || student.course === course;
const matchesKeyword =
normalizedKeyword === "" ||
student.name.toLowerCase().includes(normalizedKeyword) ||
student.contact?.mobile?.includes(normalizedKeyword);
return matchesStatus && matchesCourse && matchesKeyword;
});
};
const activeWebStudents = filterStudents(students, {
status: "active",
course: "web",
});
console.table(activeWebStudents);筛选配置使用对象传入,调用时能直接看出每个值的含义。null 表示“不限制该条件”,空关键词表示不进行文字过滤。
函数参数中的 = {} 允许调用时省略整个配置对象,字段旁的默认值则处理省略的单个条件。例如 filterStudents(students) 返回全部记录。关键词约定为字符串,先去掉两端空白,再统一大小写。
当前“正常且课程为 Web”的查询只得到张三。姓名与手机号是“任一匹配即可”,而状态、课程、关键词三个条件之间必须同时满足。
ID 索引
const createStudentIndex = (studentList) => {
const studentById = new Map();
for (const student of studentList) {
studentById.set(student.id, student);
}
return studentById;
};
const studentById = createStudentIndex(students);
const targetStudent = studentById.get(1003) ?? null;
console.log("编号 1003:", targetStudent);少量数据直接使用 find() 已足够;需要频繁按 ID 查找时,可以先建立 Map。这不是要求所有数组都转换成映射。
本例编号唯一;如果换用其他数据,应先确定重复编号怎样处理,因为 Map.set() 会覆盖相同键的旧值。查找也要使用数字编号 1003,字符串 "1003" 是不同的键。
汇总统计
循环统计
先沿用 c04、c05 的循环,分别维护总分、最高分和及格人数。下面的函数与后面的 reduce() 版本完成相同任务,可对照理解:
const calculateStatisticsWithLoop = (studentList) => {
let totalScore = 0;
let highestScore = null;
let passedCount = 0;
for (const student of studentList) {
totalScore += student.score;
if (highestScore === null || student.score > highestScore) {
highestScore = student.score;
}
if (student.score >= 60) {
passedCount += 1;
}
}
const count = studentList.length;
return {
count,
averageScore: count === 0 ? 0 : Number((totalScore / count).toFixed(1)),
highestScore,
passedCount,
passRate: count === 0 ? 0 : Number(((passedCount / count) * 100).toFixed(1)),
};
};
console.table([calculateStatisticsWithLoop(students)]);toFixed(1) 将结果格式化为保留一位小数的字符串,Number() 再转回数字。空数组返回人数、平均分和及格率 0,最高分为 null,表示没有可比较的成绩;有效的 0 分则仍是数字 0。
前几轮的变化可以直接核对:
| 当前成绩 | 总分 | 最高分 | 及格人数 |
|---|---|---|---|
86 | 86 | 86 | 1 |
72 | 158 | 86 | 2 |
93 | 251 | 93 | 3 |
先读懂这三个变量怎样随循环更新,再看下一种表达方式。
reduce 归并
reduce() 接收“累计结果”和“当前元素”,最后返回一个归并值:
const totalScore = students.reduce((total, student) => {
return total + student.score;
}, 0);
console.log("总分:", totalScore); // 3970 是初始累计值。执行过程可以理解为:
0 + 86
↓
86 + 72
↓
158 + 93
↓
……先用循环理解累计,再用 reduce() 表达“把一组值归并成一个结果”。不要为了使用它而写难读的多层逻辑。
统计摘要
现在把循环中的三个累计变量放入一个对象。每轮接收上一轮的 result,计算出新的累计对象并返回,下一轮继续使用它。
Math.max(a, b) 返回两个数中较大的一个。-Infinity 小于所有有限成绩,因此适合作为最高分比较的初始值;空数组必须提前返回,避免把它当成真实成绩。
const calculateStatistics = (studentList) => {
if (studentList.length === 0) {
return {
count: 0,
averageScore: 0,
highestScore: null,
passedCount: 0,
passRate: 0,
};
}
const totals = studentList.reduce(
(result, student) => ({
totalScore: result.totalScore + student.score,
highestScore: Math.max(result.highestScore, student.score),
passedCount:
result.passedCount + (student.score >= 60 ? 1 : 0),
}),
{
totalScore: 0,
highestScore: -Infinity,
passedCount: 0,
},
);
return {
count: studentList.length,
averageScore: Number(
(totals.totalScore / studentList.length).toFixed(1),
),
highestScore: totals.highestScore,
passedCount: totals.passedCount,
passRate: Number(
((totals.passedCount / studentList.length) * 100).toFixed(1),
),
};
};
console.table([calculateStatistics(students)]);空数组提前返回,避免除以零和 -Infinity 泄漏到结果中。
toFixed() 返回字符串,这里再用 Number() 转回数字。真实项目的精度与显示格式应由业务规则决定。
结果转换
课程去重
const courseCodes = new Set(
students.map((student) => student.course),
);
console.log("课程代码:", [...courseCodes]);map() 先得到课程数组,Set 去重,展开语法再转回数组。
学员摘要
const getScoreLevel = (score) => {
if (score >= 90) {
return "优秀";
}
if (score >= 80) {
return "良好";
}
if (score >= 60) {
return "合格";
}
return "需要补考";
};
const statusTextMap = {
active: "正常",
pending: "待审核",
disabled: "已停用",
};
const createStudentSummary = (student) => ({
id: student.id,
name: student.name,
level: getScoreLevel(student.score),
statusText: statusTextMap[student.status] ?? "未知",
mobile: student.contact?.mobile ?? "暂无手机号",
});
const summaries = students.map(createStudentSummary);
console.table(summaries);摘要不修改原始记录,而是产生一组更适合后续页面展示的新对象。
综合查询流程
保留原始 students、allowedStatuses 和前面定义的函数,移除分步演示中的调用及其结果变量,再接上下面的入口。例如,前面演示用的 allStudentsValid、studentById、courseCodes 不要重复声明。完整文件见下方的运行附件。这样验证一定先于查询和统计执行:
const className = "FastAPI Web 1 班";
const allStudentsValid = students.every(isValidStudent);
const query = {
status: "active",
course: null,
keyword: "",
};
let averageScore = null;
if (!allStudentsValid) {
console.error("学员数据验证未通过,请修正后重新查询");
} else {
const matchedStudents = filterStudents(students, query);
const matchedSummaries = matchedStudents.map(createStudentSummary);
const matchedStatistics = calculateStatistics(matchedStudents);
const studentById = createStudentIndex(students);
const courseCodes = new Set(students.map((student) => student.course));
averageScore = matchedStatistics.averageScore;
console.group("学员查询结果");
console.table(matchedSummaries);
console.table([matchedStatistics]);
console.log("编号 1003:", studentById.get(1003) ?? "未找到学员");
console.log("不重复课程:", [...courseCodes]);
console.groupEnd();
}数据流保持清楚:
原始数组
↓ every(isValidStudent)
验证通过(未通过则提示并停止)
↓ filterStudents
匹配数组
├── map → 展示摘要
└── reduce → 统计摘要筛选、摘要和统计都返回新的结果,原始 students 数组仍作为输入数据保留。这里强调的是“输入数据 → 新结果”的处理方式;如果后续要编辑一条学员记录,应使用展开语法创建更新后的对象,而不是在查询函数中直接修改原对象。
averageScore 声明在分支外,以便下一步读取;验证未通过时保留 null,表示尚未产生有效结果。验证通过后才赋为当前查询的平均分。每次修改数据,应从本章的数据与验证步骤重新运行,不能沿用修改前的验证结果。
页面数据衔接
到这里,数据仍然只输出到 Console,不操作 DOM。为了衔接下一部分,先把下一节要显示的两个值单独命名:
console.log("下一节页面摘要:", {
className,
averageScore,
});className 是当前查询场景的班级名称,验证通过后,averageScore 来自查询分支中的 matchedStatistics.averageScore,不是重新手写的成绩。当前正常状态学员为张三、李四、陈晨,共 3 人,平均分为 82。下一节会把班级名称与平均分分别写入预设的 <h2> 和 <span>,先建立“计算结果 → 页面节点”的直接对应关系,再扩展到更多 DOM 查询方式。
完整示例见学员数据查询与统计(项目目录:./.code/p03/c08-data-case/01-data-case/)。data.js 保存数据、纯函数和默认查询结果;app.js 导入后在 Console 输出,完整源码见本节末尾。附件沿用正文的验证规则:验证失败时不生成查询和统计结果。p04c01 的落页附件会直接导入 data.js 中有效数据计算出的 averageScore。
在课程目录运行 python -m http.server 5500,打开 http://localhost:5500/.code/p03/c08-data-case/01-data-case/index.html,再查看 Console。ES Module 需要通过本地 HTTP 服务加载,不要用 file:// 直接打开;若 Console 出现模块加载错误,先检查地址和文件相对位置。
AI 协作与边界测试
让 AI 帮助检查数据函数时,必须明确本章仍是纯 JavaScript 数据处理:
目标:为学员查询与统计函数补充边界测试。
上下文:代码只在 Console 运行,输入是学员对象数组。
约束:不能查询 DOM、监听事件、使用 localStorage 或调用 API;函数不能修改原数组。
验收:至少验证正常数组、空数组和一条字段异常的数据,并输出容易核对的结果。
请先说明每个测试想证明什么,再给出最小测试代码。运行后逐条阅读 Console。除了看“有没有报错”,还要人工核对筛选数量、统计结果和原数组是否保持不变。AI 如果为了展示结果创建页面,应要求它改回 Console 测试,这也是判断技术边界的一部分。
每次只补充一类输入,先写预期结果,再运行对照。向 AI 提供虚构的学员数据即可;不要提供真实手机号、个人资料或密钥,粘贴错误输出前也应删除其中的敏感信息。
案例验收
完成本节案例后,对照下面的标准检查数据处理结果。整个案例只处理 JavaScript 数据,不查询 DOM、不监听事件、不调用 API。
先使用本章原始数据核对正常结果。运行附件的 app.js 会依次输出下面四组查询统计,不必反复改动默认查询;同时会单独显示关键词、空数组、缺失联系方式和找不到 ID 的边界结果:
| 查询条件 | 人数 | 平均分 | 最高分 | 及格人数 | 及格率 |
|---|---|---|---|---|---|
| 不限制条件 | 5 | 79.4 | 93 | 4 | 80% |
status: "active" | 3 | 82 | 88 | 3 | 100% |
status: "active", course: "web" | 1 | 86 | 86 | 1 | 100% |
course: "not-exists" | 0 | 0 | null | 0 | 0% |
passRate 保存百分数对应的数字,如 80;表格中的 % 是阅读标记。
如果手动练习正文代码,也可以把下面的片段接在函数定义后运行。每一轮先筛选,再统计当前匹配数组;输出顺序与上表一致:
const queryCases = [
{ label: "全部学员", options: {} },
{ label: "正常状态", options: { status: "active" } },
{ label: "正常状态的 Web 学员", options: { status: "active", course: "web" } },
{ label: "不存在的课程", options: { course: "not-exists" } },
];
for (const { label, options } of queryCases) {
const matched = filterStudents(students, options);
console.log(label);
console.table([calculateStatistics(matched)]);
}再逐项完成边界练习,每次只调整一处:
- 空数组:统计结果采用上表的空结果约定,不出现除零或无穷大。
- 关键词
"13800000004":只找到赵敏;查找 ID9999:显示未找到。 - 缺失联系方式:王五的摘要显示“暂无手机号”。
- 在附件的
data.js中,分别把一条成绩改为"86"或101,每次刷新页面:验证失败,app.js输出提示,不输出查询统计表;恢复原值后再继续。 - 用
Map按数字 ID 查找,用Set得到web、python、data三种课程。 - 查询前后对照原始数组的顺序和字段,筛选、转换和统计不应直接修改原记录。
最后能够说明“为什么统计当前匹配数组,而不是始终统计全部学员”,就建立了下一部分页面搜索与统计联动所需的数据基础。
可复制的练习源码
复制后保持导入路径中的目录结构。纯 HTML 页面可直接在浏览器打开;需要 ES Module 或测试 API 的案例,按本节前文的启动命令运行并访问对应路径。
完整运行附件:.code/p03/c08-data-case/01-data-case/
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>学员数据查询与统计</title>
<script type="module" src="./app.js"></script>
</head>
<body>
<h1>学员数据查询与统计</h1>
<p>本案例只在 Console 输出结果,请打开浏览器开发者工具查看。</p>
</body>
</html>export const students = [
{
id: 1001,
name: "张三",
score: 86,
status: "active",
course: "web",
contact: { mobile: "13800000001" },
},
{
id: 1002,
name: "李四",
score: 72,
status: "active",
course: "python",
contact: { mobile: "13800000002" },
},
{
id: 1003,
name: "王五",
score: 93,
status: "disabled",
course: "web",
contact: null,
},
{
id: 1004,
name: "赵敏",
score: 58,
status: "pending",
course: "web",
contact: { mobile: "13800000004" },
},
{
id: 1005,
name: "陈晨",
score: 88,
status: "active",
course: "data",
contact: { mobile: "13800000005" },
},
];
const allowedStatuses = new Set(["active", "pending", "disabled"]);
export const isValidStudent = (student) => {
if (student === null || typeof student !== "object" || Array.isArray(student)) {
return false;
}
const hasValidId = Number.isInteger(student.id) && student.id > 0;
const hasValidName =
typeof student.name === "string" && student.name.trim().length >= 2;
const hasValidScore =
Number.isFinite(student.score) &&
student.score >= 0 &&
student.score <= 100;
const hasValidStatus = allowedStatuses.has(student.status);
const hasValidCourse =
typeof student.course === "string" && student.course.trim().length > 0;
const hasValidContact =
student.contact === null ||
(typeof student.contact === "object" &&
!Array.isArray(student.contact) &&
typeof student.contact.mobile === "string");
return hasValidId && hasValidName && hasValidScore &&
hasValidStatus && hasValidCourse && hasValidContact;
};
export const filterStudents = (
studentList,
{ status = null, course = null, keyword = "" } = {},
) => {
const normalizedKeyword = keyword.trim().toLowerCase();
return studentList.filter((student) => {
const matchesStatus = status === null || student.status === status;
const matchesCourse = course === null || student.course === course;
const matchesKeyword =
normalizedKeyword === "" ||
student.name.toLowerCase().includes(normalizedKeyword) ||
student.contact?.mobile?.includes(normalizedKeyword);
return matchesStatus && matchesCourse && matchesKeyword;
});
};
export const createStudentIndex = (studentList) => {
const studentById = new Map();
for (const student of studentList) {
studentById.set(student.id, student);
}
return studentById;
};
export const calculateStatistics = (studentList) => {
if (studentList.length === 0) {
return {
count: 0,
averageScore: 0,
highestScore: null,
passedCount: 0,
passRate: 0,
};
}
const totals = studentList.reduce(
(result, student) => ({
totalScore: result.totalScore + student.score,
highestScore: Math.max(result.highestScore, student.score),
passedCount: result.passedCount + (student.score >= 60 ? 1 : 0),
}),
{ totalScore: 0, highestScore: -Infinity, passedCount: 0 },
);
return {
count: studentList.length,
averageScore: Number(
(totals.totalScore / studentList.length).toFixed(1),
),
highestScore: totals.highestScore,
passedCount: totals.passedCount,
passRate: Number(
((totals.passedCount / studentList.length) * 100).toFixed(1),
),
};
};
const getScoreLevel = (score) => {
if (score >= 90) return "优秀";
if (score >= 80) return "良好";
if (score >= 60) return "合格";
return "需要补考";
};
const statusTextMap = {
active: "正常",
pending: "待审核",
disabled: "已停用",
};
export const createStudentSummary = (student) => ({
id: student.id,
name: student.name,
level: getScoreLevel(student.score),
statusText: statusTextMap[student.status] ?? "未知",
mobile: student.contact?.mobile ?? "暂无手机号",
});
export const className = "FastAPI Web 1 班";
const query = {
status: "active",
course: null,
keyword: "",
};
export const allStudentsValid = students.every(isValidStudent);
const matchedStudents = allStudentsValid ? filterStudents(students, query) : null;
export const matchedSummaries = matchedStudents?.map(createStudentSummary) ?? null;
export const matchedStatistics = matchedStudents === null
? null
: calculateStatistics(matchedStudents);
export const averageScore = matchedStatistics?.averageScore ?? null;
export const studentById = allStudentsValid ? createStudentIndex(students) : null;
export const courseCodes = allStudentsValid
? new Set(students.map((student) => student.course))
: null;import {
allStudentsValid,
averageScore,
calculateStatistics,
className,
courseCodes,
createStudentSummary,
filterStudents,
matchedStatistics,
matchedSummaries,
studentById,
students,
} from "./data.js";
if (!allStudentsValid) {
console.error("学员数据验证未通过,请修正后重新查询");
} else {
console.group("正常状态学员查询结果");
console.table(matchedSummaries);
console.table([matchedStatistics]);
console.log("编号 1003:", studentById.get(1003) ?? "未找到学员");
console.log("不重复课程:", [...courseCodes]);
console.log("下一节页面摘要:", { className, averageScore });
console.groupEnd();
const queryCases = [
{ label: "全部学员", options: {} },
{ label: "正常状态", options: { status: "active" } },
{ label: "正常状态的 Web 学员", options: { status: "active", course: "web" } },
{ label: "不存在的课程", options: { course: "not-exists" } },
];
console.group("四组查询统计");
for (const { label, options } of queryCases) {
const matched = filterStudents(students, options);
console.log(label);
console.table([calculateStatistics(matched)]);
}
console.groupEnd();
console.group("边界检查");
console.table(filterStudents(students, { keyword: "13800000004" }));
console.table([calculateStatistics([])]);
console.log("编号 1003 摘要:", createStudentSummary(studentById.get(1003)));
console.log(studentById.get(9999) ?? "未找到学员");
console.groupEnd();
}小结
- 完整数据流程包括验证、筛选、查找、转换和统计。
reduce()适合把多项归并成一个结果,应提供清楚的初始值。Map用于按键索引,Set用于不重复值。- 纯数据函数通过参数与返回值协作,不依赖页面。
- 下一部分会先把两个结果接入 DOM,再逐步学习事件、存储和接口。