"JS作业三"这个标题一开始看到我是有点懵的。前两次作业都是控制台里打印九九乘法表、写个简易计算器这种基础题,突然冒出个"作业三",我以为又是换个姿势复习函数,结果题目一发下来,直接傻眼了:省市区三级联动下拉、动态表格带单元格合并、表单校验里还要判断字符串包含关系、页面里还要有两个能联动的计时器。这哪是作业,这分明是把一周的课浓缩成一道综合题。
不过话说回来,这份作业做完之后我反而觉得收获特别大,很多之前"背过但没懂"的知识点,比如 rowSpan 合并单元格、setTimeout 返回值的坑、原型链上的方法查找,都是在写作业三的时候才真正想通的。这篇文章就是我完成这份作业的完整记录,包含我的实现思路、翻车现场、修复过程和最后提交前整理的自测清单。如果你也在做类似的 JS 综合练习,或者刚学完基础语法准备上手写一个像样的页面,这份记录应该能帮你少走不少弯路。
1. 拿到作业先别碰编辑器,把题目里藏着的考点一个个翻出来
我第一次看到题目的时候,第一反应是"完了,这题我不会"。但第二天冷静下来重新读题,发现其实题目里每一句话都对应着一个已经学过的知识点,只是以前是单个地考,这次要求把知识点串在同一个页面里。所以我建议你拿到任何题目后的第一件事,不是打开 VS Code,而是拿张纸,把题目要求拆成功能点,再给每个功能点标注它对应的考点。
1.1 从题目文字里提取功能模块与对应考点
我拿到的 JS 作业三是这样一张卷面(不同学校题目会有差异,但核心模块很相似):页面左侧是一个表单区域,里面有一个姓名输入框、一个下拉菜单组(省/市/区三级联动)、一个备注文本域;右侧是一个动态表格区域,初始有一行表头和两行示例数据,要求能通过一个"添加行"按钮向表格里插入新数据;表单里还有一个"保存"按钮,点击后要做非空校验,且校验姓名时忽略大小写,只要名称包含指定关键字就通过;页面上方还有一个倒计时数字,要求每秒刷新一次。
拆完之后就很清楚了,这道题其实在考五件事:
- DOM 操作:getElementById、querySelector、createElement、appendChild、insertRow、deleteCell
- 事件绑定:onchange、onclick、addEventListener 以及它们的触发时机
- 字符串方法:includes / indexOf、toLowerCase / toUpperCase、trim、判断空对象
- 定时器:setTimeout / setInterval、清除定时器、this 指向问题
- 数据与视图同步:用一个 JS 对象或数组存储页面数据,再根据数据渲染 DOM
我把这张拆解图贴在显示器旁边,后面每写一个模块就划掉一个,心理压力小了很多。
1.2 搭建一个不会"跑不起来"的目录与 HTML 骨架
很多同学作业三翻车不是因为逻辑不会写,而是代码写到一半,页面都没有建出来。我的建议是最开始就建好标准三个文件,并让 HTML 里先把所有空容器定义好,JS 里先用 console.log 测试脚本有没有正常加载。
js作业三/ ├── index.html ├── css/ │ └── style.css └── js/ └── main.jsindex.html 里我放了下面这样的骨架。这里有一个关键决定:把 script 标签放在了 body 末尾。因为作业里大量代码要直接操作 DOM 元素,如果 script 放在 head 里且没有用 defer 或 DOMContentLoaded 包裹,脚本执行时后面的元素还没解析出来,getElementById 会拿到 null,整个页面就废了。放在 body 末尾是初学者最简单可靠的方案。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>JS作业三:综合交互页面</title> <link rel="stylesheet" href="css/style.css"> </head> <body> <h1>信息登记与动态表格</h1> <!-- 表单区 --> <section id="form-area"> <label for="name-input">姓名</label> <input type="text" id="name-input" placeholder="请输入姓名"> <label for="province-select">省份</label> <select id="province-select"></select> <label for="city-select">城市</label> <select id="city-select"></select> <label for="district-select">区县</label> <select id="district-select"></select> <label for="remark-input">备注</label> <textarea id="remark-input" rows="2"></textarea> <button id="save-btn">保存</button> </section> <!-- 表格区 --> <section id="table-area"> <button id="add-row-btn">添加行</button> <table id="info-table" border="1"> <thead> <tr> <th>序号</th> <th>姓名</th> <th>地区</th> <th>备注</th> </tr> </thead> <tbody id="table-body"> <tr> <td>1</td> <td>示例A</td> <td>北京市-北京市-朝阳区</td> <td>第一行</td> </tr> <tr> <td>2</td> <td>示例B</td> <td>浙江省-杭州市-西湖区</td> <td>第二行</td> </tr> </tbody> </table> </section> <!-- 计时器区 --> <section id="timer-area"> <p>剩余时间:<span id="countdown">60</span> 秒</p> </section> <script src="js/main.js"></script> </body> </html>这里的 table 使用 border="1" 是为了方便观察单元格合并效果,实际项目里会用 CSS 控制边框,但作业阶段怎么直观怎么来。表头用 thead,动态数据行放 tbody,后面讲单元格合并时你就知道为什么必须这样分了——thead 和 tbody 是两套独立的行集合,操作 tbody 里的行完全不影响表头。
2. 三级联动下拉框:数据结构和绑定时机决定成败
三级联动是这份作业里最"像真实项目"的部分。虽然页面上只是三个 select 框,但它的核心其实跟 UI 没关系,而是数据结构。我一开始想用三个各自独立的数组分别存省、市、区,写了一半发现联动时根本找不到"这个省有哪些城市"的关系,代码里全是 if else 穷举,写到广东省我就想摔键盘了。后来换成嵌套对象,一切突然变得顺理成章。
2.1 用嵌套对象组织"省-市-区"数据
我最终使用的数据结构长这样:
const regionData = { "北京市": { "北京市": ["东城区", "西城区", "朝阳区", "海淀区", "丰台区"] }, "浙江省": { "杭州市": ["西湖区", "拱墅区", "滨江区", "余杭区"], "宁波市": ["海曙区", "江北区", "鄞州区"] }, "广东省": { "广州市": ["天河区", "越秀区", "白云区"], "深圳市": ["福田区", "南山区", "罗湖区"] } };每一级"是什么"和"包含谁"都表达得很明确:省份对象里每个 key 对应一个城市对象,城市对象里每个 key 对应一个区数组。这样写还有一个额外好处——渲染三级下拉的时候,循环 key 就足够,不需要额外维护 id 关联表。说实话,如果当初直接去 CSDN 找一段"省市区完整 JSON 数据"复制进来,后面反而会被海量数据搞晕,因为作业只需要演示联动逻辑,数据能体现三层关系就够了。
接下来是渲染函数。我写了三个函数,分别负责省、市、区的填充,但它们共用同一个核心逻辑:清空旧的 option,再从数据源循环创建新的 option。
function fillSelect(selectEl, arr) { // 先清空,避免重复累加 selectEl.innerHTML = ''; const defaultOption = document.createElement('option'); defaultOption.value = ''; defaultOption.textContent = '请选择'; selectEl.appendChild(defaultOption); arr.forEach(function (item) { const option = document.createElement('option'); option.value = item; option.textContent = item; selectEl.appendChild(option); }); }这里选择重新赋值 innerHTML 而不是逐个 removeChild 旧的 option,原因是这段代码在作业场景下可读性更好,而且 select 的子节点只有 option,不存在用户输入内容被当作 HTML 解析的安全隐患。但同样的做法不能直接用在后文要讲的动态表格上——表格里很多内容来自用户输入,用 innerHTML 拼接会让输入内容里的<img src=x onerror=...>这种字符串变成真实 DOM 节点,这是非常危险的习惯,作业阶段就要建立这个意识。
2.2 onchange 事件的绑定顺序,错一步全盘崩
三级联动的事件绑定逻辑看起来很自然:省份 change 了,填城市;城市 change 了,填区县。但因为区县下一次是建立在城市的基础上的,绑定顺序就非常敏感。
我的做法是在页面初始化时先只填充省份下拉,然后给省份 select 绑定 onchange:每次省份改变,先重置城市和区县,再根据新省份填充城市。同理,城市 change 时先重置区县,再填充新区县。
const provinceSelect = document.getElementById('province-select'); const citySelect = document.getElementById('city-select'); const districtSelect = document.getElementById('district-select'); function initRegion() { fillSelect(provinceSelect, Object.keys(regionData)); provinceSelect.onchange = function () { const province = provinceSelect.value; // 关键:先清空下级,再填充 citySelect.innerHTML = '<option value="">请选择</option>'; districtSelect.innerHTML = '<option value="">请选择</option>'; if (province && regionData[province]) { fillSelect(citySelect, Object.keys(regionData[province])); } }; citySelect.onchange = function () { const province = provinceSelect.value; const city = citySelect.value; districtSelect.innerHTML = '<option value="">请选择</option>'; if (province && city && regionData[province][city]) { fillSelect(districtSelect, regionData[province][city]); } }; } initRegion();这段代码里最容易被忽视的是"先清空下级"这一步。如果省份变化时不清空城市列表,用户能看到上一省份的城市,而且如果那些城市在新省份里不存在,后面取 regionData[province][city] 时会直接 undefined 然后报错。我调试时最常看到的错误就是从Cannot read properties of undefined开始的——它不是某个复杂算法的问题,而是联动时没有把中间状态的旧数据清掉。
还有一个经验是:不要一上来就把"编辑回显"功能做进去。很多人做作业时想着"既然能做联动,顺手把点击表格行、把数据回显到表单里的功能也做了吧"。回显本身不难,难的是回显时也要触发 onchange 来刷新下级下拉——直接给省 select 的 value 赋值不会触发 onchange 事件,必须手动调用 provinceSelect.onchange()。这个坑我踩了整整一个下午,后面写一篇专门的文章讲它。但作为作业,先保证"选择→联动→保存"这条主链路是通的,回显属于加分项,能写就写,坏掉了千万别硬撑。
3. 动态表格的创建与单元格合并:insertRow 与 rowSpan 的正确姿势
动态表格是作业三里最能体现 DOM 操作功底的部分。普通添加一行不复杂,难点在于题目要求"区域列要把同一个省市区的连续行合并成一个大单元格"。这就涉及到 rowSpan 属性,而动态合并又比静态 HTML 里手写 rowSpan 要难一个量级——因为随时可以添加和删除行,合并状态必须根据最新数据重新计算。
3.1 添加行数据:insertRow 从语义上就比 innerHTML 合理
我刚写完三级联动的时候,对 innerHTML 的依赖还非常重,往 tbody 里追加一行,下意识就想写:
tableBody.innerHTML += '<tr><td>3</td><td>...</td></tr>';这在作业三里会埋两个雷。第一,这种写法会丢失已有的 DOM 状态,比如之前行里绑定的点击事件会全部失效,因为 innerHTML 赋值相当于把原来所有子节点销毁重建。第二,如果你把用户输入的姓名直接拼进字符串里,用户输入了<b>张三</b>,表格里显示的就是加粗的张三,如果输入的是带事件的标签结构,浏览器会尝试执行它——这就是传说中的"页面被注入了"。
用 innerHTML 做模板渲染不是完全不行,大型前端框架的底层也在变相使用类似机制。但作业三的场景是"逐行动态插入并保持每行的独立性",更合适的是 DOM API。看我的做法:
const addRowBtn = document.getElementById('add-row-btn'); addRowBtn.onclick = function () { const tbody = document.getElementById('table-body'); const rows = tbody.getElementsByTagName('tr'); const newIndex = rows.length + 1; const newRow = tbody.insertRow(); // 插入到最后 newRow.insertCell(0).textContent = newIndex; // 序号 newRow.insertCell(1).textContent = '新同学'; // 姓名 newRow.insertCell(2).textContent = '浙江省-杭州市-西湖区'; newRow.insertCell(3).textContent = '动态插入的一行'; };insertRow 和 insertCell 的好处是返回一个"活"的 tr / td 对象,后续要挂事件、改样式、做合并,都是对这个对象直接操作,而不是去找一段字符串里的某个标记。注意这里 insertRow() 不传参数就是追加到最后一行,insertRow(0) 才是插入到第一行,作业里最容易记混。
3.2 动态合并中的 rowSpan:先把行去掉两个,再给上一行加属性
单元格合并的语义是:一个 td 占住两行,所以它下面的那一行里对应的 td 就不再需要存在,否则表格会多出一列变形。静态表格合并很简单,但动态表格合并要把这个"缺少 td"的性质保持下去。
我研究了一会儿,最终采用的做法是——每次渲染表格时都从数据数组重新生成 tbody 的全部内容,合并也只发生在临时渲染逻辑里,而不是在现有的 DOM 上行增行删。这样思路最清晰,无论用户点击了多少次添加行,我只要保证"当前数据长什么样",渲染函数就能生成一个正确的、合并好的表格。这其实是"数据驱动视图"的雏形,你现在觉得绕,以后学框架时会感谢这个作业。
先定义一个数组存所有行数据:
let tableData = [ { name: '示例A', region: '北京市-北京市-朝阳区', remark: '第一行' }, { name: '示例B', region: '浙江省-杭州市-西湖区', remark: '第二行' } ];添加行时 push 到数组里,然后调用 renderTable():
addRowBtn.onclick = function () { tableData.push({ name: '新同学', region: '浙江省-杭州市-西湖区', remark: '动态插入的一行' }); renderTable(); };renderTable 的第一步是清空 tbody,第二步是按顺序插入行和单元格,第三步才是合并。关键代码在第三步:
function renderTable() { const tbody = document.getElementById('table-body'); tbody.innerHTML = ''; // 清空,完全按新数据重建 tableData.forEach(function (item, index) { const tr = tbody.insertRow(); tr.insertCell(0).textContent = index + 1; tr.insertCell(1).textContent = item.name; tr.insertCell(2).textContent = item.region; tr.insertCell(3).textContent = item.remark; }); // 合并单元格:按地区分组,同组的连续行中只有第一行保留"地区"单元格 const rows = tbody.getElementsByTagName('tr'); let i = 0; while (i < rows.length) { const currentRegion = rows[i].cells[2].textContent; let spanCount = 1; let j = i + 1; while (j < rows.length && rows[j].cells[2].textContent === currentRegion) { spanCount++; j++; } if (spanCount > 1) { // 只保留第一行的地区单元格,并让它跨行 rows[i].cells[2].rowSpan = spanCount; // 从第 i+1 行开始,删掉它们的地区单元格 for (let k = i + 1; k < j; k++) { rows[k].deleteCell(2); } } i = j; // 跳过这段连续区域 } }这个方法里最容易错的是循环变量 i 的更新方式。第一次写时我把 i 写成了 i++,结果合并完第一组之后 i 落到了被删过单元格的行里,cells[2] 根本不存在,直接报错。正确的做法是"合并完一整组,跳到这一组的最后一行再继续处理下一组"——所以使用 i = j 而不是 i++。
删除单元格时我从 k = i + 1 循环到 j - 1,也就是把同一地区下所有后续行里的第 3 个单元格全部删掉。这样表格展现出来就是"第一行占两行高度,底下那行对应位置是空的",视觉上正好是合并效果。
这里有个值得提醒的点:rowSpan 的值如果设置过大或者区域内行数统计错误,表格会整体错位,甚至表头跟数据列对不上。所以合并逻辑最好在一个"纯数据"的表格上测试——先只放两三行相同地区的数据,确认合并后 td 总数和列数一致,再放复杂数据。我用开发者工具逐个检查过删掉单元格之后的 DOM 结构,确认每一行都恰好剩 4 个 td(第一行因为 rowSpan 占了两份位置,所以它只显示 4 个 td 但视觉上占满两行的 4 列),这才放心。
4. 表单校验里的字符串功夫:忽略大小写、包含关系与"空"的判断
表单校验这块以前是最让我头大的,因为所有逻辑全是字符串处理,而我过去总觉得"字符串不就等于一串字嘛,有什么好处理的"。写完作业三我才意识到,字符串的"相等"、"包含"、"为空"、"忽略大小写后相等"这四件事的语义完全不同,面试题和实际项目翻来覆去问的也是这些。
4.1 判断字符串是否包含关键字:includes 与 indexOf 的取舍
作业题目里有一句话让我琢磨了很久:"点击保存时,如果姓名输入的内容忽略大小写后含有关键词 js_test,则提示校验成功,否则提示失败。"这里有两个考点:包含判断和忽略大小写。
JS 里判断字符串包含关系主要两招:indexOf 和 includes。它们的区别对初学者来说很关键:
| 方式 | 返回值 | 使用习惯 |
|---|---|---|
| str.indexOf('关键词') | 找不到返回 -1,找到返回下标(>=0) | 判断条件写indexOf(...) !== -1 |
| str.includes('关键词') | 返回 true / false | 判断条件写includes(...) |
很多人刚学 indexOf 时死活想不通"为什么找到了要返回那串数字",其实返回值是字符在字符串中的起始位置,比如"hello".indexOf("ll")结果是 2,而 -1 是这个方法选定的"找不到"哨兵值。includes 是 ES6 推出的语法糖,内部实现也依赖类似的查找,但对调用者来说只给出一个布尔值,语义更干净。
作业里两者都能用,但在这个论坛里吵了这么多年,我的态度很明确:新代码优先用 includes,除非你需要知道具体下标位置。原因包括:代码可读性高、不会出现indexOf() > 0这种"从下标 1 开始找"的经典 bug(很多人误写成 > 0 就漏掉关键字在开头的情况),语义也更接近自然语言。
4.2 忽略大小写:先统一大小写,再比较
忽略大小写,最朴素也最可靠的方式不是用正则i修饰符(虽然正则也能做),而是把所有参与比较的字符串统一转成小写,再比较。
function validateName(rawValue) { const keyword = 'js_test'; // 转小写后包含判断 return rawValue.toLowerCase().indexOf(keyword.toLowerCase()) !== -1; }之所以两边都调 toLowerCase,是为了防止用户输入"JS_TEST"或"Js_Test"或"js_TEST"这些变体。如果你只把输入值转小写但关键字还是"js_test"那也是对的,因为关键字本身已经是小写了。但养成"两边统一处理"的习惯更安全——以后碰到从接口返回、或大小写混合的关键字,就不会漏处理。
除了 toLowerCase,还有个 toLocaleLowerCase 与 toLowerCase 的差异值得知道:toLocaleLowerCase 会根据当前系统语言环境做更彻底的大小写转换,比如土耳其语里的特殊字符,toLowerCase 不会处理而 toLocaleLowerCase 会。但在作业里用 toLowerCase 足够,且它的行为在所有 JS 引擎中完全一致,更可控。我一般给同学的答案是:"写作业用 toLowerCase,你永远不会因为这个被扣分;真要处理多语言文本再考虑 toLocaleLowerCase。"
4.3 判断空对象、空字符串和"看起来空"的字符串
作业里保存前要校验备注不能为空。最直接的想法是remarkInput.value !== '',但这只拦住了"完全没输入"的情况。用户输入三个空格,value 不是空字符串,页面会放行,但数据里存的又是"三个空格"这种没意义的内容。
我的做法是先 trim 再判断。trim 会去掉字符串首尾的空白字符(空格、制表符、换行符),所以:
function isBlank(str) { return str.trim().length === 0; }类似的坑还有"判断是不是空对象"。如果表单数据被收集到一个对象里:
const formData = { name: '张三', region: '浙江省-杭州市-西湖区', remark: '备注' };你不能用formData === {}判断它是不是空对象,因为对象是引用类型,{}每次都是一个新的对象,两个新对象永远不相等。正确的判断方式是Object.keys(formData).length === 0。Object.keys 返回对象自身可枚举属性的数组,如果长度为 0,说明这个对象没有自己的属性,它就是"空"的。
function isEmptyObject(obj) { return Object.keys(obj).length === 0; }至于为什么不用JSON.stringify(formData) === '{}'这种写法——虽然也能用,但遇到对象里有函数、undefined 值、Symbol 属性时会失真(JSON.stringify 会自动忽略这些),而且性能上要先序列化整个对象再比较,不如 Object.keys 直接。作业阶段跑不出性能差距,但养成用 Object.keys 的习惯会让你后面看框架源码时不至于一头雾水。
5. setTimeout 返回值那道"灵魂拷问":到底返回什么,范围是什么
作业里有个倒计时模块,要求每隔一秒把页面上的数字减 1。这就要用到 setInterval 或 setTimeout 配合递归调用。而我写完之后,一边调试一边想起最近在论坛看到的讨论帖:"js setTimeout 返回值的范围 有0存在么"——说实话,这个问题初看很怪,但它确实能把定时器机制挖得很深。
5.1 定时器 ID:一个正整数,而不是"时间值"
setTimeout 的返回值不是剩余时间,也不是有没有设定成功的布尔值,而是一个正整数 ID。这个 ID 的唯一用途是传给 clearTimeout 来取消对应定时器。
let timerId = setTimeout(function () { console.log('一秒后执行'); }, 1000); // 如果中途取消 clearTimeout(timerId);浏览器实现中,这个 ID 通常从 1 开始递增,且每个页面环境下是独立的。所以严格意义上"返回值的范围"就是正整数,肯定不会是 0,也不可能出现负数。你在 Console 里执行 setTimeout 会看到输出 1、2、3……这就是当前页面第几个定时器。在 Node.js 里行为类似,但返回值类型是对象而不是数字,这点只对服务端开发者影响大,做作业不用管。
那"js setTimeout 返回值的范围 有0存在么"这个问法为什么会出现?我猜测是因为很多人把 setTimeout 的第一个参数当成了返回值——比如setTimeout(0)这种写法,其实第一个参数应该是函数而不是数字,而setTimeout(fn, 0)里的 0 是延迟毫秒数,不是返回值。所以这个问题应该拆成两个:延迟时间可以传 0(表示尽快执行,但仍有最小钳制值),而返回值是定时器 ID,范围是正整数,不会是 0。
5.2 倒计时正确姿势:setInterval? 还是 setTimeout 递归?以及隐藏的小问题
倒计时代码我有两种方案。第一种是 setInterval,代码最短:
let remain = 60; const countdownEl = document.getElementById('countdown'); const intervalId = setInterval(function () { remain--; countdownEl.textContent = remain; if (remain <= 0) { clearInterval(intervalId); } }, 1000);第二种是 setTimeout 递归,每次执行完再设定下一次:
let remain = 60; const countdownEl = document.getElementById('countdown'); function tick() { remain--; countdownEl.textContent = remain; if (remain > 0) { setTimeout(tick, 1000); } } setTimeout(tick, 1000);两者都能完成作业,但面试常追问的一个问题是"setInterval 和递归 setTimeout 有什么区别"。一种常见说法是"setInterval 可能丢帧,递归 setTimeout 更准确"。实际上前者的 tick 与 tick 之间是固定间隔但会受到执行时间影响可能累积延迟;后者是执行完再等下 1000 毫秒,能保证"上一次真正执行完"之后再进入下一次,但总时间可能略长。作业层面选哪个都不影响功能,但如果你希望页面在切到后台再回来时倒计时仍然是"真实剩余时间",那就不该用递减变量,而是记录一个结束时间戳,用 Date.now() 计算差值。这属于超纲优化,但写了这道题的代码之后,面试官如果追问,就能答出"用时间戳差值校准"这个优化方向,印象分会明显提升。
还有一个小坑:定时器回调里如果直接使用 this,它指向的不是外部对象,而是全局对象(浏览器里是 window,严格模式下是 undefined)。这在作业里最容易出问题的是把定时器和表单数据联动时,想在回调里调用 this.xxx 却拿不到。作业阶段的稳妥方案是"用箭头函数代替普通 function 声明",箭头函数没有自己的 this,它会沿用定义时所在作用域的 this。
const vm = { remain: 60, start() { setInterval(() => { this.remain--; console.log(this.remain); }, 1000); } }; vm.start();这里如果把箭头函数换成像下面这样:
setInterval(function () { this.remain--; // this 是 window,不是 vm }, 1000);this.remain 会变成 window.remain,结果就是页面上完全没反应。这个细节我提交作业前一小时才用 console.log 查出来,浪费了不少时间。你要是也写了 this 而没反应,先检查是不是定时器回调里的 this 跑丢了。
6. 原型与原型链:这份作业里最该吃透的"超纲题"
作业三里没有任何一行字提到原型链,但代码里处处都在用原型链。比如数组有 forEach、push、getElementsByTagName 返回的对象有什么用 onchange 事件的能力、甚至"abc".toLowerCase()能执行,都是因为字符串对象可以从 String.prototype 上继承到这些方法。我建议每一位做完作业三的同学,都顺手把这个知识点搞懂,因为它几乎是所有前端面试第一轮必问的题,而且懂了原型链,你就看懂了前面写的所有代码的"底层服务"。
6.1 从"为什么每个对象都能 toLowerCase"开始找原型链
在控制台输入"hello".toLowerCase(),能返回小写。可你并没有在任何地方给这个字符串定义过 toLowerCase 方法,为什么能调用?因为 JS 引擎在查找属性或方法时,如果当前对象身上没有,就会沿着一条"隐藏的链"向上找。这条链的顶端之一就是 String.prototype,而 String.prototype 上有 toLowerCase、trim、toUpperCase 这些方法。
当你访问str.toLowerCase时:
- 先在 str 自身查,没有
- 顺着 str 的
[[Prototype]](也就是Object.getPrototypeOf(str))找到 String.prototype,上面有 toLowerCase - 找到了,立刻调用
数组也一样:
arr.forEach不是数组自己的属性,而是 Array.prototype 上定义的方法- 数组对象通过原型链拿到 Array.prototype 上的 forEach、map、push 等
作业里tbody.getElementsByTagName('tr')返回的是一个 HTMLCollection 对象,它能被 forEach 遍历吗?这里有个经典坑:HTMLCollection 并不一定从 Array.prototype 继承,只有 NodeList 在部分浏览器实现了 forEach。作业里如果直接对tbody.getElementsByTagName('tr')调用 forEach,可能会报错。我在合并单元格时就是先const rows = tbody.getElementsByTagName('tr');然后想用 rows.forEach,结果运行报错。后来改成 Array.from(rows) 再遍历,或者用 for 循环。这个小坑背后的原理就是原型链结构不同——不是所有长得像数组的东西都有 Array.prototype 上的方法,判断标准就是看它的原型链上到底有没有那个方法。
6.2 "JS 里函数是对象吗"——是,但它是带特殊能力的对象
最近热词里还有一个"js中函数是对象吗",这个问题的答案直接关系到原型链理解。函数确实是对象,typeof function 返回 "function" 而不是 "object" 是语言层面的特意区分,但函数拥有对象的全部能力:它可以有属性,可以被添加属性,也可以被当作值传来传去。
function add(a, b) { return a + b; } add.description = '两个数相加'; console.log(add.description); // 两个数相加 console.log(add.length); // 2,参数个数函数对象身上默认有 length 属性表示形参个数,有 name 属性表示函数名,这些在普通对象里是没有的。函数与普通对象的核心区别在于它的特殊内部特性[[Call]],这让它可以被调用。所以更准确的说法是:函数是"可调用"的对象,它既有对象的属性能力,又有函数的调用能力。
放在作业里最直观的例子是 setTimeout 的第一个参数。你传进去的是一个函数,它本质上也是一个对象,被定时器"保存"起来,等到时间到了再调用。如果你传进去的是一个字符串(setTimeout('console.log(1)', 1000)),JS 会隐式把它当成代码去 eval 执行,但强烈建议不要这么写,既难看又危险,能传函数对象就传函数对象。
6.3 原型链查询带来的两个作业级注意事项
第一,for...in 循环会遍历原型链上可枚举的属性。如果用 for...in 遍历一个对象,你会把 Object.prototype 上所有的可枚举方法也带出来,结果打印出莫名其妙的信息。解决办法是用 hasOwnProperty 过滤,这也是"判断是不是空对象"问题升级版的工具。
for (const key in formData) { if (formData.hasOwnProperty(key)) { console.log(key, formData[key]); } }第二,不要随意修改内置原型。比如给 Array.prototype 加一个自定义方法来自嗨。作业阶段也许没事,但当你引入多个第三方库时,这些库都在各自的代码里假设"数组没有其他额外方法",你加了之后可能在某处触发奇怪的行为。真正的项目里我从来不覆盖内置原型,宁可多写一个工具函数。
7. 提交作业前的最后一小时:我翻车过的细节与自测清单
作业三我前后写了两个版本。第一版功能全跑通了,但提交前检查时发现三个低级问题:一个是从省切换到"请选择"时城市和区县恢复正常了,但表格里的数据没跟着变化;另一个是倒计时归零后如果再次点击保存,表单校验居然还显示"剩余时间 0 秒",逻辑上没毛病但体验很怪;第三个是快速反复点击"添加行"按钮,表格里的序号出现重复。这些问题都不涉及复杂算法,但很影响整体观感,也直接影响老师对作业的评价。
7.1 我踩过的三个低级错误
问题一:事件重复绑定 我最初把"添加行"的 onclick 写在初始化函数里,初始化函数又在某个条件下被调用了两次,结果点击一次按钮执行了两次渲染逻辑,表格一次加两行。后来用 console.log 统计执行次数才发现。定位这类问题最快的方式:在按钮事件回调的第一行打印一条日志,如果点一次出现两条日志,百分之百是事件被绑定了两次。
问题二:增删行时忘记同步数组 表格数据存在 tableData 数组里,手动新增和删除都要修改数组而不是直接操作 DOM。我第一版写了一个"删除选中行"的功能,直接调用
row.remove(),数组里却没过滤对应数据,结果表格整体重渲染时被删掉的行又"复活"了。这其实再次说明一个核心观念:DOM 是视图,数组是数据,永远先改数据,再让视图跟着数据变。问题三:定时器没有清理 如果用户在倒计时过程中又触发了一次开始(比如重置按钮),新的 setInterval 与旧的并存,两个 interval 同时减少 remain,倒计时速度直接翻倍。修复方法:开启新定时器前先用 clearInterval 清掉旧的,或者用一个状态变量做锁。
7.2 提交前必看的 12 项自测清单
我在作业三提交之前做了一张自查表,每检查完一项就打勾。这里直接分享给你,可以照抄去检查你自己的作业:
| 检查项 | 预期结果 |
|---|---|
| 首次加载页面无报错 | 控制台 Console 干净 |
| 省份下拉默认只有"请选择" | 不预填城市 |
| 省份变化后城市清空再填充 | 没有残留旧数据 |
| 城市变化后区县清空再填充 | 同上 |
| 连续快速切换省份不报错 | 没有 undefined 相关异常 |
| 添加行后表格序号连续 | 与当前行数一致 |
| 相邻同行地区的行能正确合并 rowSpan | 视觉上是一整块 |
| 合并发生后再添加不同地区行 | 新的不相同行不参与合并 |
| 姓名忽略大小写校验通过 | JS_TEST、js_test、Js_Test 都放行 |
| 备注输入全空格时提示为空 | trim 后长度 0 |
| 倒计时每秒减 1 且归零停止 | 不出现负数 |
| 定时器回调里的 this 可以正确访问外部对象 | 用箭头函数或保存 this |
做完这 12 项检查,我基本可以放心把作业交上去了。上面列的每一项都是我用 console.log 和开发者工具的断点逐条验证过的,虽然过程有点烦琐,但正因为如此,我对这份作业里涉及的 DOM 操作和数据流有了比课堂上深得多的理解。
最后再分享一个写作业三时养成的小习惯:每完成一个小功能,立刻在浏览器里手动点一遍,不要攒到最后一次性测试。我第一版把所有功能写完才开始测,结果屏幕上二十多个报错堆在一起,根本不知道先修哪个。改成"每写完三级联动就测三级联动,每写完表格合并就测表格合并"之后,每个模块出错的成本都降到了分钟级。这个习惯一直带到了我后来实习写的真实项目里,帮我躲过了不少上线前的"惊喜"。如果你现在正卡在某个模块报错,也别急——在自己代码里打断点或者加 console.log,把出错的那一行打印出来,通常比盯着代码猜要快得多。希望这份作业三的记录能帮你顺利交差,也顺带把背后的知识点真正学扎实。