这学期的《网页前端开发》课刚讲到 DOM 操作和事件处理,第三次作业就跟着下来了。题目就叫"JS作业三",打开需求文档扫一眼,表单校验、动态表格、三级联动全堆在一个页面里;再扫一眼学长留下的示例代码,满屏的缩写和魔法数字。作为一个刚把语法背完、一打开浏览器就报错的新手,第一次做这种综合练习确实容易发怵。
这篇就还原我完成这份 JS 作业的完整过程:先怎么审题拆需求,哪些知识点绕不开,核心代码到底怎么写,最后报错怎么一条一条查。如果你的作业正好也是表格、表单、联动这"老三样",或者单纯想找一份能直接套用的 JavaScript 练习思路,这篇可以当带注释的参考答案来读。我不会贴一堆课程大纲式的超纲内容,全是这份作业真正用得上、老师也爱考的东西。
1. 把作业当成一个小项目来做
很多人拿到作业的第一反应是打开编辑器直接写,写到一半发现需求理解偏了,又推倒重来。我自己第一次做就吃过这个亏。后来养成的习惯是:先花半小时把题目翻译成功能点,再动手。磨刀不误砍柴工,作业这种小项目尤其如此。
1.1 先审题:把需求翻译成功能点
作业需求通常是一段口语化描述,比如"表单提交前校验用户名不为空、手机号格式正确;点击按钮后把学生成绩动态渲染成表格,相同成绩合并单元格;省市区下拉框实现三级联动"。读这种话,手上拿支笔,把动词圈出来:
- "校验"对应什么?对应事件监听 + 正则表达式 + 阻止默认提交。
- "动态渲染成表格"对应什么?对应
document.createElement、appendChild这类 DOM 操作。 - "合并单元格"对应什么?对应表格单元格的
rowSpan属性。 - "三级联动"对应什么?对应下拉框的
change事件 + 一份地区数据映射。
这一步的意义在于,把一句含糊的需求变成一串明确的技术动作。比如"合并单元格"如果没想清楚是横向合并还是纵向合并、按哪一列合并,写代码时就会卡住。我当时就是在草稿纸上画了个表格草样,标清楚"成绩相同就纵向合并成一个格子",后面写 rowSpan 才不迷糊。
1.2 按"能跑通"优先排开发顺序
作业功能有三块,不等于要按题目顺序写。我的建议是先做静态骨架,再逐个功能独立实现,每完成一块就立刻在浏览器里验证。顺序上我一般这么排:
- 先把 HTML 结构写完整,包括表单、表格容器、三个下拉框。
- 先写最不容易出错的功能,比如表单校验,因为逻辑独立、见效快。
- 再做动态表格,因为要用到 DOM 创建和数组数据,能练到重点。
- 最后做三级联动,因为它依赖前两个模块的数据结构,放最后不容易返工。
"能跑通"优先的意思是,你要保证任何一个时间点打开页面,页面不是白屏、不是全屏报错。哪怕功能没做完,至少静态部分正常显示。这看起来是废话,但很多新手写代码习惯憋一整段再刷新,然后面对一堆报错无从下手。分步验证能让你始终知道"最近一次改动搞坏了什么"。
1.3 开发环境和文件组织
作业虽然可以只交一个 HTML 文件,但我还是建议在 VS Code 里拆成三个文件:index.html、style.css、script.js。拆开的好处是职责清楚,后面排查问题不用在一个几百行的文件里上下翻。
有一个特别实用的建议:装一个 Live Server 插件,用 http://127.0.0.1:5500 这种方式打开页面,而不是双击 HTML 文件。原因有两条:一是作业里如果用了 ES6 模块语法(import/export),直接双击会触发跨域报错,本地服务不会;二是 Live Server 改完代码自动刷新,省得每次手动 F5。我在作业阶段就因为"本地打开没问题、一上服务器就报错"这种问题卡过很久,其实就是协议和路径的差异。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 作业里绕不开的四个知识点
这份作业号称综合练习,其实核心知识点就那么几个。把这些点单独嚼碎了,代码写起来会顺手很多。
2.1 字符串判断与忽略大小写
"判断字符串是否包含"几乎是每次作业都会出现的需求。最常用的两个方法是 includes 和 indexOf:
javascript复制const title = 'JavaScript 作业三';
console.log(title.includes('作业')); // true
console.log(title.indexOf('作业') >= 0); // true
区别在于 includes 返回布尔值,语义更直白;indexOf 返回下标,没有找到返回 -1,所以判断时要写成 >= 0。作业里我一般推荐 includes,因为不容易写错。
还有一个高频需求是忽略大小写。比如要求搜索"js"时也能匹配"JS"、"Js"。最简单的方式是统一转小写再比较:
javascript复制const keyword = 'js';
const text = 'JavaScript 是一门语言';
const result = text.toLowerCase().includes(keyword.toLowerCase());
如果要做更复杂的匹配,比如"以 xxx 开头""符合某种格式",用正则表达式更合适,后面校验手机号、邮箱就是典型的正则场景。
2.2 循环控制:break、continue 和 return
写动态表格和数据筛选时,循环控制是最容易出错的点。三个关键字看着像,作用范围完全不同:
| 关键字 | 作用 | 影响范围 |
|---|---|---|
break |
立即终止当前循环 | 只作用于最内层循环 |
continue |
跳过本次迭代,进入下一轮 | 只作用于最内层循环 |
return |
直接退出整个函数 | 整个函数立刻结束 |
举个作业里的实际场景:校验表单时,只要发现一条数据不合法,整个校验函数就应该结束,返回 false。这时候应该用 return,而不是 break:
javascript复制function validateAll(items) {
for (const item of items) {
if (!item.valid) {
console.log('发现不合法数据:' + item.name);
return false;
}
}
return true;
}
另外提醒一个容易踩的坑:在 for 循环里用 splice 删除数组元素时,删除一个元素后后面的元素会往前挪,下一轮循环会跳过一项。作业里如果要"删除不符合条件的数据",优先用 filter 生成新数组,而不是一边遍历一边删,能省掉很多莫名其妙的 bug。
2.3 函数的三种写法与变量作用域
作业里函数有三种常见写法,考试和面试也经常问区别:
javascript复制// 函数声明
function add(a, b) {
return a + b;
}
// 函数表达式
const add = function (a, b) {
return a + b;
};
// 箭头函数
const add = (a, b) => a + b;
最大的区别在"提升"。函数声明会被整体提升到当前作用域顶部,所以你可以先调用后定义:
javascript复制console.log(add(1, 2)); // 3,函数声明被提升了
function add(a, b) {
return a + b;
}
函数表达式不行,它在赋值之前是 undefined,强行调用会报 add is not a function。
箭头函数和普通函数的区别更大,最核心的一条是:箭头函数没有自己的 this,它继承的是定义时所处作用域的 this。作业里用 addEventListener 绑定按钮事件时,普通函数的 this 指向触发事件的元素,箭头函数则不一定。新手阶段我建议事件回调里不要依赖 this,而是直接通过 document.getElementById 拿元素,逻辑更直白。
变量作用域同样重要。var 只有函数作用域,let 和 const 有块级作用域。作业里最经典的坑是循环绑定事件:
javascript复制for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100); // 输出 3、3、3
}
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100); // 输出 0、1、2
}
原因就是 var 声明的 i 在整个函数里是同一个变量,循环结束后变成 3,三个定时器读到的都是 3。改成 let 后,每次循环都创建独立绑定。作业里如果发现"循环里给按钮绑定的点击事件,点击后全部输出最后一个值",先检查是不是用了 var。
2.4 原型和原型链:一次讲透
"原型和原型链"看起来跟作业没关系,但老师特别喜欢在代码注释或答辩环节问一句"你创建的对象方法存在哪"。说白了,这里面就两个概念:
javascript复制function Student(name) {
this.name = name;
}
Student.prototype.sayHi = function () {
console.log('我是 ' + this.name);
};
const s1 = new Student('张三');
s1.sayHi();
s1 自己身上没有 sayHi,但调用时沿着一根链条往上找,在 Student.prototype 上找到了。这条链就是:s1 -> Student.prototype -> Object.prototype -> null。用 hasOwnProperty 可以验证:
javascript复制console.log(s1.hasOwnProperty('name')); // true
console.log(s1.hasOwnProperty('sayHi')); // false
console.log('sayHi' in s1); // true
理解原型链有个生活化的类比:有点像家族家谱,你身上没有某个技能,就往父辈、祖辈上找,谁有就借谁的用。把方法放在 prototype 上而不是构造函数里,是为了共享,十个学生实例共用同一个 sayHi,不会每个实例都复制一份函数,省内存。知道这一层,作业里如果题目要求"用构造函数创建多条记录并给它们加公共方法",你就知道方法该写在原型上而不是构造函数里。
3. 实操:一个可直接复现的作业范例
光讲知识点太散,我直接把我做的一份"学生成绩登记页面"完整走一遍。需求就是前面说的老三样:提交表单时校验数据、校验通过后把数据动态渲染成表格并合并相同成绩的单元格、页面上再放一个省市区三级联动。这个组合基本覆盖了常见作业的典型模块。
3.1 表单校验:从提交事件开始
先看 HTML 部分,注意提交按钮我写的是 type="submit",这样可以在 submit 事件里统一处理校验:
html复制<form id="studentForm">
<div>
<label>姓名</label>
<input type="text" id="username" placeholder="请输入姓名">
</div>
<div>
<label>手机号</label>
<input type="text" id="phone" placeholder="请输入手机号">
</div>
<div>
<label>邮箱</label>
<input type="text" id="email" placeholder="请输入邮箱">
</div>
<div>
<label>成绩</label>
<input type="number" id="score" min="0" max="100">
</div>
<button type="submit">添加学生</button>
</form>
JS 部分的核心是监听 submit 事件,然后用 preventDefault 阻止页面刷新。很多新手发现"点提交后页面一闪,数据没了",十有八九是漏了这一行:
javascript复制const form = document.getElementById('studentForm');
form.addEventListener('submit', function (event) {
event.preventDefault();
const username = document.getElementById('username').value.trim();
const phone = document.getElementById('phone').value.trim();
const email = document.getElementById('email').value.trim();
const score = document.getElementById('score').value.trim();
if (!username) {
alert('姓名不能为空');
return;
}
if (!/^[\u4e00-\u9fa5a-zA-Z]{2,6}$/.test(username)) {
alert('姓名需要是2到6位中文或字母');
return;
}
if (!/^1[3-9]\d{9}$/.test(phone)) {
alert('手机号格式不正确');
return;
}
if (!/^\S+@\S+\.\S+$/.test(email)) {
alert('邮箱格式不正确');
return;
}
if (Number(score) < 0 || Number(score) > 100) {
alert('成绩需要在0到100之间');
return;
}
addStudent({ name: username, phone, email, score: Number(score) });
});
几个细节说明一下。第一,trim() 去掉首尾空格,避免用户输入一串空格还能通过"非空校验"。第二,正则里用的是 \d 表示数字,^ 和 $ 表示整段匹配,不加的话部分匹配也会通过。第三,成绩用 Number(score) 转成数字,否则 "90" 和 90 在严格相等比较时是不同的。
如果题目还要求校验 URL,可以用正则,也可以直接用浏览器自带的 URL 构造函数:
javascript复制function isValidUrl(str) {
try {
new URL(str);
return true;
} catch {
return false;
}
}
new URL('abc') 会直接抛错,捕获到就说明格式不合法。这个方法简单可靠,比写一个又长又容易出错的正则省事。不过在作业里最好加一句注释说明思路,老师看到会加分。
3.2 动态表格与单元格合并
表单校验通过后,把学生数据推进一个数组,然后重新渲染表格。渲染部分全部用 DOM 操作完成,不拼接 HTML 字符串,这是作业考核的重点之一:
javascript复制const studentList = [];
function addStudent(student) {
studentList.push(student);
renderTable();
}
function renderTable() {
const wrap = document.getElementById('tableWrap');
const table = document.createElement('table');
const headRow = document.createElement('tr');
['姓名', '手机号', '成绩'].forEach(text => {
const th = document.createElement('th');
th.textContent = text;
headRow.appendChild(th);
});
table.appendChild(headRow);
studentList.forEach(item => {
const tr = document.createElement('tr');
const tdName = document.createElement('td');
tdName.textContent = item.name;
const tdPhone = document.createElement('td');
tdPhone.textContent = item.phone;
const tdScore = document.createElement('td');
tdScore.textContent = item.score;
tr.appendChild(tdName);
tr.appendChild(tdPhone);
tr.appendChild(tdScore);
table.appendChild(tr);
});
wrap.innerHTML = '';
wrap.appendChild(table);
mergeScoreCell(table);
}
用 createElement 加 textContent 而不是拼 innerHTML 字符串,好处是安全、不容易出拼接错误,而且 textContent 会把内容当成纯文本而不是 HTML,避免特殊字符被解析执行。这一点在作业答辩时主动提出来,会显得你比单纯抄代码的同学明白得多。
合并单元格是这份作业里最绕的地方。思路是用 rowSpan:如果当前行的成绩和上一行一样,就把上一行的成绩单元格的 rowSpan 加 1,然后删掉当前行的成绩单元格。注意要从最后一行往前遍历,因为删除单元格后,当前行的 cells 集合索引会变,倒着遍历能避开索引错位的问题:
javascript复制function mergeScoreCell(table) {
for (let i = table.rows.length - 1; i > 0; i--) {
const curScore = table.rows[i].cells[2];
const preScore = table.rows[i - 1].cells[2];
if (!curScore || !preScore) continue;
if (curScore.textContent === preScore.textContent) {
preScore.rowSpan = (preScore.rowSpan || 1) + 1;
curScore.remove();
}
}
}
这个函数我踩过一个大坑:一开始我从第一行往下遍历,删掉当前成绩单元格后,下一行的 cells 位置变了,比较就全乱套。改成倒序遍历之后问题解决。还有一个前提,合并只在"成绩相同的行紧挨着"时才有效,如果数据顺序是 92、85、92,那中间隔了别的分数,必然合不到一起。作业里最简单的方式是要求数据按成绩排序,或者在插入时维护一个按成绩排序的数组。我当时是在 addStudent 里先排序再渲染,保证相同的分数靠在一起。
3.3 三级联动:数据结构和事件配合
三级联动不复杂,但代码量不小。关键点有两个:一是把省市区数据组织成清晰的嵌套结构,二是每次上级变化时先清空下级,再填充新数据。
javascript复制const regionData = {
'广东省': {
'广州市': ['天河区', '越秀区', '海珠区'],
'深圳市': ['南山区', '福田区', '宝安区']
},
'江苏省': {
'南京市': ['玄武区', '鼓楼区'],
'苏州市': ['姑苏区', '工业园区']
}
};
初始化时先把省份填进去,然后监听省份和城市的 change 事件:
javascript复制const provinceSelect = document.getElementById('province');
const citySelect = document.getElementById('city');
const districtSelect = document.getElementById('district');
// 页面加载时填充省份
function initRegion() {
Object.keys(regionData).forEach(name => {
const op = document.createElement('option');
op.value = name;
op.textContent = name;
provinceSelect.appendChild(op);
});
}
provinceSelect.addEventListener('change', function () {
// 清空城市和区县,注意顺序:先清空下一级,再填充当前级
citySelect.innerHTML = '<option value="">请选择城市</option>';
districtSelect.innerHTML = '<option value="">请选择区县</option>';
const cities = regionData[provinceSelect.value];
if (!cities) return;
Object.keys(cities).forEach(name => {
const op = document.createElement('option');
op.value = name;
op.textContent = name;
citySelect.appendChild(op);
});
});
citySelect.addEventListener('change', function () {
districtSelect.innerHTML = '<option value="">请选择区县</option>';
const cityData = regionData[provinceSelect.value];
const districts = cityData ? cityData[citySelect.value] : undefined;
if (!districts) return;
districts.forEach(name => {
const op = document.createElement('option');
op.value = name;
op.textContent = name;
districtSelect.appendChild(op);
});
});
initRegion();
这里有个初学者容易忽略的细节:省份变化时,不仅城市要清空重建,区县也必须一起清空。因为"广东省"下面的"广州市"和"江苏省"下面的"南京市"完全不同,不清空的话,用户切换省份后区县列表里还残留上一个省的数据,逻辑就错了。清空的顺序也有讲究,先清最下级的区县,再清城市,最后才填充城市的新选项。
我当时还写了个小优化:用户重新选择省份时,把城市和区县都重置为"请选择"占位项,这样即使用户中途后悔改选,界面也不会出现"区县列表有值但城市却是空白"的不一致状态。这种细节在作业里很加分。
4. 调试与排错实录
写完功能不等于完事。我第一次交作业前,光是调试就花了和写代码差不多的时间。这里把高频报错和排查思路整理成一份速查表,能省掉你大量翻搜索引擎的时间。
4.1 高频报错速查表
| 报错信息 | 常见原因 | 快速排查方法 |
|---|---|---|
Cannot read properties of undefined |
对象不存在,或取到了数组越界的值 | 在报错行之前 console.log 打印变量,确认是哪个对象为 undefined |
xxx is not a function |
函数名拼写错误,或变量被覆盖成了非函数 | 检查是不是用 var 定义了同名变量,或者函数表达式在赋值前被调用 |
Unexpected token |
语法错误,多为少括号、少引号、少逗号 | 看编辑器红波浪线,通常能定位到具体行 |
| 表单提交后页面刷新 | 没有调 event.preventDefault() |
在 submit 回调第一行加 preventDefault |
| 事件绑定了但没反应 | 脚本在 DOM 元素之前执行,元素还没渲染 | 把 <script> 放到 </body> 前面,或用 DOMContentLoaded 包裹 |
| 页面报跨域错误 | 用 ES6 模块或 fetch,但用双击 HTML 方式打开 |
改用 Live Server 或本地静态服务器打开 |
| 点击多个按钮都输出同一个值 | 经典 var 闭包问题 |
把 var i 改成 let i |
表格里出现 NaN |
字符串和数字做了加法运算 | 用 Number() 或 parseInt 显式转换 |
4.2 三步定位法
遇到 undefined 报错别慌,按三步走基本都能解决。第一步,看 Console 面板里红色报错的第一行,它会告诉你是哪个文件的哪一行出了问题,照着速查表对号入座。第二步,在报错那一行之前加 console.log 打印关键变量的值,确认数据到底是什么。很多时候不是代码逻辑有问题,是数据结构和你想的不一样,比如你以为 cityData 是数组,实际它是 undefined。
第三步,如果还查不出来,就用"最小复现"思路:把不相关的代码暂时注释掉一半,只留报错相关的部分,再简化数据,比如把数组从十条减到一条。这样做的好处是排除干扰项。我有一次表格合并怎么都不对,把数据简化成两条才发现是 rowSpan 初始值的问题,跟数据量完全无关。
如果 console.log 不够用,可以在代码里写一行 debugger;,然后用浏览器的 Sources 面板打开文件,刷新页面后代码会停在 debugger 那一行。这时候可以单步执行、鼠标悬停看变量值,比人肉数变量靠谱得多。作业虽然简单,但这个技能学会了,后面做任何前端项目都用得上。
4.3 几个"看起来没错但就是不对"的坑
还有一种情况是代码不报错,但结果不对。最典型的是 == 和 === 的混淆。'1' == 1 是 true,因为会做类型转换;'1' === 1 是 false,因为类型不同。作业里我统一用 ===,避免莫名其妙的隐式转换。
还有一个是 setTimeout 的返回值。有人问它的返回值范围里有没有 0,浏览器里它返回的是一个计时器编号,通常是正整数,但不同浏览器可能不同。这个返回值别拿来当业务数据用,它的用途只有一个:传给 clearTimeout 取消定时器。
javascript复制const timerId = setTimeout(() => {
console.log('执行');
}, 1000);
clearTimeout(timerId);
另外提醒一个浏览器调试的临时性:如果你在开发者工具里手动修改了页面的 DOM 或脚本,刷新页面就会全部还原。所以"改完发现页面不能访问了",刷新一下确认是临时改动还是你自己代码的问题,别把调试器里的操作当成永久修改。
5. 交作业前,说几个我自己养成的习惯
所有功能写完之后,先别急着打包提交。我交作业前一般会做四件事,都是踩过坑换来的教训。
第一件事是备份版本。给每个能跑的阶段存一份副本,或者干脆 git init 建个仓库,每次改完提交一次。这个习惯能救命,我亲眼见过同学把能跑的版本覆盖了,最后赶工重新写。作业文件名也别叫"最终版""最终版2""真最终版",版本管理用工具,别用文件夹名。
第二件事是给关键函数写注释。不需要长篇大论,每行注释写清楚"这个函数接收什么、返回什么、做了什么"就行。比如 mergeScoreCell,注释里写一句"将成绩相同的相邻行纵向合并,倒序遍历避免索引错位",老师一眼就能看懂你的思路,答辩时也能顺着注释讲。
第三件事是多测几种输入。空数据提交、手机号少一位、成绩填负数、连续点击多次提交按钮,每个场景都过一遍。有一个隐藏坑:重复点击提交会触发多次 addStudent,表格里会出现重复数据。解决办法是提交后禁用按钮几秒钟,或者校验通过后立即把按钮设为 disabled。这种细节老师测试时很容易点出来。
第四件事是拿原需求文档逐条打勾。我认识不少同学,功能全做完了,结果漏了"相同成绩合并单元格"这一条,自己以为写了但其实没写。逐条核对最稳妥。
最后说点题外话。做"JS作业三"这种综合性作业,目的不是交差,而是把散装的知识串起来。函数、作用域、DOM 操作、事件、数组方法,单独学都是小点,拼在一个页面里才会暴露你真正哪里没懂。所以报错了别急着删代码,多花点时间搞清楚为什么报错,比多写十行代码都值。这份作业做完,如果你能把每个报错都按我上面的方法查一遍、弄明白,那它的价值就远远超过那一个平时分了。
