Skip to content

学员数据查询与统计 ​

学员管理后台需要显示当前查询的人数、平均分和学员摘要。如果原始成绩是字符串,或当前筛选没有结果,还能直接计算吗?本节先验证输入,再筛选、查找、转换和统计,最后把结果输出到 Console,为下一部分的页面展示准备数据。

案例目标 ​

给定学员数组,完成:

  1. 检查学员记录的关键字段是否合法。
  2. 按状态、课程和关键词筛选。
  3. 按 ID 查找学员。
  4. 计算人数、平均分、最高分和及格率。
  5. 生成简化的摘要数组。
  6. 使用 Map 建立 ID 索引。
  7. 使用 Set 得到不重复课程。

不使用 document、事件、存储和 fetch。

数据约定与样本 ​

本章按“准备数据 → 定义函数 → 组合查询”衔接。各小节的示范调用用于观察局部结果;最后组合程序时保留数据和函数定义,再统一执行查询,不必重复所有示范调用。运行附件提供整理好的完整版本。输入已经完成基本清洗,遵循以下约定:

字段本例约定
id正整数;准备的数据中编号唯一
name字符串,去掉两端空白后至少 2 个字符
score0 到 100 之间的数字,包含两端;不把字符串直接当成绩
statusactive、pending、disabled 之一
course非空课程代码字符串
contactnull,或包含字符串 mobile 的对象

如果输入仍是 "86" 这样的原始文本,先按 c03 的方法清洗,再进入本章流程。手机号保持字符串,便于搜索,也避免丢失前导零。

js
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" },
  },
];

状态约定:

js
const allowedStatuses = new Set(["active", "pending", "disabled"]);

数据验证与查询 ​

字段验证 ​

js
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,统计函数仍要单独处理人数为零。

以下分步示例使用上方的有效数据。最后的“综合查询流程”会把验证分支接入执行过程。

条件筛选 ​

js
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 索引 ​

js
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() 版本完成相同任务,可对照理解:

js
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。

前几轮的变化可以直接核对:

当前成绩总分最高分及格人数
8686861
72158862
93251933

先读懂这三个变量怎样随循环更新,再看下一种表达方式。

reduce 归并 ​

reduce() 接收“累计结果”和“当前元素”,最后返回一个归并值:

js
const totalScore = students.reduce((total, student) => {
  return total + student.score;
}, 0);

console.log("总分:", totalScore); // 397

0 是初始累计值。执行过程可以理解为:

text
0 + 86
  ↓
86 + 72
  ↓
158 + 93
  ↓
……

先用循环理解累计,再用 reduce() 表达“把一组值归并成一个结果”。不要为了使用它而写难读的多层逻辑。

统计摘要 ​

现在把循环中的三个累计变量放入一个对象。每轮接收上一轮的 result,计算出新的累计对象并返回,下一轮继续使用它。

Math.max(a, b) 返回两个数中较大的一个。-Infinity 小于所有有限成绩,因此适合作为最高分比较的初始值;空数组必须提前返回,避免把它当成真实成绩。

js
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() 转回数字。真实项目的精度与显示格式应由业务规则决定。

结果转换 ​

课程去重 ​

js
const courseCodes = new Set(
  students.map((student) => student.course),
);

console.log("课程代码:", [...courseCodes]);

map() 先得到课程数组,Set 去重,展开语法再转回数组。

学员摘要 ​

js
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 不要重复声明。完整文件见下方的运行附件。这样验证一定先于查询和统计执行:

js
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();
}

数据流保持清楚:

text
原始数组
  ↓ every(isValidStudent)
验证通过(未通过则提示并停止)
  ↓ filterStudents
匹配数组
  ├── map → 展示摘要
  └── reduce → 统计摘要

筛选、摘要和统计都返回新的结果,原始 students 数组仍作为输入数据保留。这里强调的是“输入数据 → 新结果”的处理方式;如果后续要编辑一条学员记录,应使用展开语法创建更新后的对象,而不是在查询函数中直接修改原对象。

averageScore 声明在分支外,以便下一步读取;验证未通过时保留 null,表示尚未产生有效结果。验证通过后才赋为当前查询的平均分。每次修改数据,应从本章的数据与验证步骤重新运行,不能沿用修改前的验证结果。

页面数据衔接 ​

到这里,数据仍然只输出到 Console,不操作 DOM。为了衔接下一部分,先把下一节要显示的两个值单独命名:

js
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 数据处理:

text
目标:为学员查询与统计函数补充边界测试。
上下文:代码只在 Console 运行,输入是学员对象数组。
约束:不能查询 DOM、监听事件、使用 localStorage 或调用 API;函数不能修改原数组。
验收:至少验证正常数组、空数组和一条字段异常的数据,并输出容易核对的结果。
请先说明每个测试想证明什么,再给出最小测试代码。

运行后逐条阅读 Console。除了看“有没有报错”,还要人工核对筛选数量、统计结果和原数组是否保持不变。AI 如果为了展示结果创建页面,应要求它改回 Console 测试,这也是判断技术边界的一部分。

每次只补充一类输入,先写预期结果,再运行对照。向 AI 提供虚构的学员数据即可;不要提供真实手机号、个人资料或密钥,粘贴错误输出前也应删除其中的敏感信息。

案例验收 ​

完成本节案例后,对照下面的标准检查数据处理结果。整个案例只处理 JavaScript 数据,不查询 DOM、不监听事件、不调用 API。

先使用本章原始数据核对正常结果。运行附件的 app.js 会依次输出下面四组查询统计,不必反复改动默认查询;同时会单独显示关键词、空数组、缺失联系方式和找不到 ID 的边界结果:

查询条件人数平均分最高分及格人数及格率
不限制条件579.493480%
status: "active"382883100%
status: "active", course: "web"186861100%
course: "not-exists"00null00%

passRate 保存百分数对应的数字,如 80;表格中的 % 是阅读标记。

如果手动练习正文代码,也可以把下面的片段接在函数定义后运行。每一轮先筛选,再统计当前匹配数组;输出顺序与上表一致:

js
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)]);
}

再逐项完成边界练习,每次只调整一处:

  1. 空数组:统计结果采用上表的空结果约定,不出现除零或无穷大。
  2. 关键词 "13800000004":只找到赵敏;查找 ID 9999:显示未找到。
  3. 缺失联系方式:王五的摘要显示“暂无手机号”。
  4. 在附件的 data.js 中,分别把一条成绩改为 "86" 或 101,每次刷新页面:验证失败,app.js 输出提示,不输出查询统计表;恢复原值后再继续。
  5. 用 Map 按数字 ID 查找,用 Set 得到 web、python、data 三种课程。
  6. 查询前后对照原始数组的顺序和字段,筛选、转换和统计不应直接修改原记录。

最后能够说明“为什么统计当前匹配数组,而不是始终统计全部学员”,就建立了下一部分页面搜索与统计联动所需的数据基础。

可复制的练习源码 ​

复制后保持导入路径中的目录结构。纯 HTML 页面可直接在浏览器打开;需要 ES Module 或测试 API 的案例,按本节前文的启动命令运行并访问对应路径。

完整运行附件:.code/p03/c08-data-case/01-data-case/
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>
  <script type="module" src="./app.js"></script>
</head>
<body>
  <h1>学员数据查询与统计</h1>
  <p>本案例只在 Console 输出结果,请打开浏览器开发者工具查看。</p>
</body>
</html>
js
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;
js
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();
}

小结 ​

  1. 完整数据流程包括验证、筛选、查找、转换和统计。
  2. reduce() 适合把多项归并成一个结果,应提供清楚的初始值。
  3. Map 用于按键索引,Set 用于不重复值。
  4. 纯数据函数通过参数与返回值协作,不依赖页面。
  5. 下一部分会先把两个结果接入 DOM,再逐步学习事件、存储和接口。