多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

JS作业三实战:省市区联动、动态表格与表单校验

JS作业三实战:省市区联动、动态表格与表单校验 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 langzh-CN head meta charsetUTF-8 titleJS作业三综合交互页面/title link relstylesheet hrefcss/style.css /head body h1信息登记与动态表格/h1 !-- 表单区 -- section idform-area label forname-input姓名/label input typetext idname-input placeholder请输入姓名 label forprovince-select省份/label select idprovince-select/select label forcity-select城市/label select idcity-select/select label fordistrict-select区县/label select iddistrict-select/select label forremark-input备注/label textarea idremark-input rows2/textarea button idsave-btn保存/button /section !-- 表格区 -- section idtable-area button idadd-row-btn添加行/button table idinfo-table border1 thead tr th序号/th th姓名/th th地区/th th备注/th /tr /thead tbody idtable-body tr td1/td td示例A/td td北京市-北京市-朝阳区/td td第一行/td /tr tr td2/td td示例B/td td浙江省-杭州市-西湖区/td td第二行/td /tr /tbody /table /section !-- 计时器区 -- section idtimer-area p剩余时间span idcountdown60/span 秒/p /section script srcjs/main.js/script /body /html这里的 table 使用 border1 是为了方便观察单元格合并效果实际项目里会用 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 srcx 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 trtd3/tdtd.../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; // 从第 i1 行开始删掉它们的地区单元格 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(...) ! -1str.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把出错的那一行打印出来通常比盯着代码猜要快得多。希望这份作业三的记录能帮你顺利交差也顺带把背后的知识点真正学扎实。
返回列表