1. 别急着对答案:把课后题当成"微型需求单"来过一遍
我拿到《JavaScript前端开发案例教程》这类教材的第一件事,从来不是翻到书末找答案。原因很简单,JavaScript前端开发这门手艺,眼睛会了和手会了之间隔着一条很宽的沟——你能看懂一段代码在干什么,不代表你能从一张空白的编辑器里把它敲出来,更不代表它报错的第三分钟你还能保持冷静。教材的课后习题恰好是那条沟上成本最低的桥:一道题通常只涉及一到两个知识点,做砸了不会有人追责;题干又往往自带一个小场景,比如"根据输入的年月日判断这一天是这一年的第几天",这其实已经是一份缩到极致的需求文档了。
所以我的做法是,把每一道题按"接需求"的流程走一遍:先读题干,把输入、输出、边界条件在纸上写清楚;再想数据结构,是数组还是对象,是字符串还是数字;最后才动手敲。答案只在两种情况下才翻——一种是我写完并且自测通过后,去比对思路差异;另一种是我卡住超过二十分钟,需要一点提示。这个次序看起来啰嗦,但它把习题从"背诵材料"变成了"训练场",这两者的长期收益差得非常远。
1.1 习题和真实业务代码之间到底差了什么
很多人工作一两年之后回头看教材习题,会觉得"太简单了,跟工作没关系"。这个判断只对了一半。习题确实没有接口联调、没有构建配置、没有跨端兼容,但它训练的恰恰是业务代码里最不能出错的那一层——对数据形态的直觉。真实的业务代码里,一个诡异 bug 的源头经常不是什么高深机制,而是某个字段本该是数字却成了字符串,某个数组本该去重却出现了重复项,某个对象被赋值传引用之后被下游函数悄悄改掉了。
习题无法给你的是工程环境,但它能给你的东西也不少:一个明确的输入输出契约、一段可以独立验证的逻辑、一个不依赖任何框架的纯函数思维。我见过太多新人能熟练用框架把页面跑起来,却写不出一个干净的去重函数;也见过工作三年的人在for循环里嵌套了三层if,导致同事接手时无从下手。这些问题,本质上都是在基础阶段缺少"独立完成一小段逻辑"的刻意练习。
1.2 我用了很多年的"三栏笔记法"
做习题的时候,我习惯开一个 Markdown 文件,分三栏记:题干要点、我的思路、翻车点。第一栏只写关键词,比如"输入:数组;输出:去重后的新数组;边界:空数组、非数组输入、NaN"。第二栏写我打算用什么方法、为什么,比如"用Set去重,因为它天然不重复,且NaN在Set里被认为是同一个值"。第三栏最关键,记我实际踩到的坑,比如"忘了Set转数组需要Array.from或者展开运算符"。
第三栏才是这份笔记真正的价值所在。我自己统计过,前端基础题里我重复犯的错,八成集中在四个地方:类型判断、引用与值、this指向、异步时序。前三个在纯基础习题里都能练到,第四个要等到后面的异步章节。把翻车点单独攒起来,隔一周再回看,你会发现那些坑在脑子里已经有了"警示牌",下次手还没敲完大脑就先拦住了。
提示:笔记别写成"标准答案的抄写本"。抄答案只会给你一种虚假的掌握感,而人的记忆对"自己犯过的错"远比"别人写对的东西"牢固。
2. 数据类型与运算符:题面考语法,底下考的是隐式转换的心智模型
教材前几章的习题,表面上是让你判断typeof的输出、让你算一个表达式的值,实际考查的是一件更根本的事:你对 JavaScript 隐式转换的心里有没有一张正确的图。这张图不清楚,后面写任何业务逻辑都是踩雷。举个我最早被绊住的例子:'5' - 1得 4,而'5' + 1得'51'。当年我觉得这就是语言设计得怪,后来才明白,减号在 JavaScript 里只承担数值语义,所以它必须把两边转成数字;而加号身兼"加法"和"字符串拼接"两职,一旦有一边是字符串,它就优先走拼接。
理解了这个动机,很多看起来莫名其妙的题目就有了统一的解释,而不是零散的记忆点。我在做这部分习题时有个习惯:每遇到一个隐式转换,就顺手写出它的等价显式写法。比如+'3'等价于Number('3'),!!''等价于Boolean('')。这个动作做上十几遍之后,代码里就基本不会再出现靠隐式转换"蒙"过去的写法了。
2.1typeof认不出 null 和数组,这不是陷阱而是历史包袱
typeof null === 'object'这个结论几乎每本教材都会提。很多习题用它来设置"陷阱题",但我觉得更重要的是知道它为什么这样。这是语言早期实现留下的问题,因为当时的类型标记机制里,null和对象用了同一个标记位。理解它是历史遗留,而不是语言故意的设计,你的心态就会从"我怎么记住这个奇葩规则"变成"我知道它不可靠,所以我不用它做判断"。
对于数组和null的判断,我的实际做法是一张固定的对照表:
| 想判断的东西 | 推荐写法 | 不推荐写法 | 原因 |
|---|---|---|---|
| 是否为数组 | Array.isArray(x) | typeof x === 'object' | 后者把 null、普通对象全算进来 |
| 是否为 null | x === null | !x | 后者把 0、''、undefined 也算进去 |
| 是否为纯数字 | typeof x === 'number' && !Number.isNaN(x) | isNaN(x) | 全局isNaN会先做隐式转换 |
| 是否为整数 | Number.isInteger(x) | x % 1 === 0 | 后者对Infinity会给出误判 |
| 是否为可用值 | x !== null && x !== undefined | Boolean(x) | 后者会误伤 0 和空字符串 |
这张表我建议直接背下来,比背十道题的答案有用得多。业务代码里"0 被当成空值处理掉了"这种 bug,十个里面有七八个都能靠这张表提前拦掉。
2.2 相等判断的两套规则,以及练习题为什么偏爱它
==和===的差别,习题里出现频率极高。我的经验是不要在这上面玩花活:业务代码里统一用===,只有在明确知道对面可能是字符串形式的数字、并且需要兼容时,才考虑显式转换后再比。原因在于==的转换规则涉及一长串优先级判断,团队里每个人脑中的版本都不完全一样,讨论成本比收益高得多。
但做习题的时候,我依然会把==的规则过一遍,因为它能帮你理解语言底层的类型转换链路。比如null == undefined为真,但null === undefined为假;NaN和任何值包括它自己都不相等。第二点特别重要,因为它是Number.isNaN存在的原因——你没法用相等去检测一个"不等于自身"的值。
2.3 短路求值和运算符优先级在页面里的真实用途
运算符那一章很多人觉得枯燥,我却觉得它是全书性价比最高的部分之一。短路求值在实际开发中到处都是:给一个可能为空的配置取默认值、在渲染前判断数据是否就绪、在函数入口做参数兜底。你写const name = user && user.name这种形式的次数,会比想象中多。
不过这里有个必须提醒的点:用&&做默认值是有坑的。const count = input || 10在input为 0 的时候会错误地拿到 10。这就是为什么后来有了空值合并运算符??,它只在左边是null或undefined时才取右边。做习题时如果遇到"给 0 做默认值"的题型,我会刻意用两种写法各写一遍,加深印象:
const raw = 0; const wrong = raw || 100; // 100,被误判 const right = raw ?? 100; // 0,符合预期 const explicit = raw === undefined ? 100 : raw; // 0,语义最直白排错时我特别偏爱第三种写法,因为它不依赖读者对运算符优先级的记忆,任何水平的同事拿起来都能一眼看懂。可读性和技巧性之间,我通常优先选可读性,除非那行代码在十万次循环里。
3. 条件、循环与字符串:把"能跑通"提到"能读、能改"
基础语法章节的习题很容易让人产生"我会了"的错觉,因为写出来的代码确实能输出正确结果。但能跑通只是及格线。我在带新人时最常问的一句话是:"如果需求改一个字,你这行代码要动几处?"如果改一处需求要动五处代码,那说明抽象层次没设计好。
循环和条件这一块最容易出问题的不是语法,而是嵌套深度。三层以上的嵌套循环加判断,几乎必然藏着可优化的结构。做习题的时候不妨给自己加一条约束:任何函数的嵌套层级不超过两层。这个约束会逼着你去拆函数、用提前返回、用数组方法替代手工索引管理,长期下来代码质量的提升非常明显。
3.1 循环三件套什么时候该换掉
for、while、do...while在习题里通常分开考,但真实项目里用得最多的是更高层的迭代方式。我的选择顺序大致是:能用map、filter、reduce表达的,优先用它们;需要中途跳出或者性能敏感的,用for;不确定循环次数且依赖外部状态的,用while。
注意一个细节:for...of和for...in完全是两回事。前者遍历的是可迭代对象的值,后者遍历的是对象的可枚举属性名,包括从原型链上继承来的。习题里如果考对象遍历,正确姿势通常是先拿到键数组再遍历:
const user = { name: '林一', age: 26, city: '杭州' }; // 只遍历自身属性,顺序可控 Object.keys(user).forEach((key) => { console.log(key, user[key]); }); // 需要 key 和 value 一起拿 for (const [key, value] of Object.entries(user)) { console.log(key, value); }我在早期的项目里因为for...in遍历到一个原型链上的方法名,排查了大半天。那次之后我就给自己定了规矩:遍历对象一律用Object.keys或Object.entries,绝不用for...in,除非我明确想要原型链上的属性。
3.2 字符串处理的三个高频动作及其取舍
字符串题的考点集中在截取、查找、替换、拼接。拼接这件事,现在几乎没有理由再用+去拼一长串内容,模板字符串的可读性优势太大。截取方面有三个方法:slice、substring、substr。我的建议是只用slice,因为它对负索引的处理最符合直觉,而且它是唯一一个在数组和字符串上行为一致的方法,记忆负担最小。
替换方面,replace默认只替换第一个匹配项,这一点习题里经常设坑。需要全局替换时要么用带g标志的正则,要么用replaceAll。我更喜欢replaceAll,因为不写正则就不会被正则里的特殊字符咬到——用户输入的内容里带个.或者(,正则就得额外转义,这是很常见的翻车点。
const id = 'user.1001.profile'; // 用正则需要转义点号 const byRegex = id.replace(/\./g, '-'); // replaceAll 直接写字符,更省心 const byMethod = id.replaceAll('.', '-'); console.log(byRegex === byMethod); // true3.3 用一个表单校验的小例子把前面几章串起来
我最喜欢的一道综合练习是"写一个注册表单的校验函数"。它天然需要类型判断(是不是字符串)、字符串处理(去掉首尾空格、判断长度)、条件分支(多个规则依次校验)、以及返回值的结构设计。自己写一遍会发现很多书本上不会讲的取舍。
function validateUser(input) { const errors = []; const name = typeof input.name === 'string' ? input.name.trim() : ''; if (name.length < 2 || name.length > 12) { errors.push('昵称长度需在 2 到 12 个字符之间'); } const age = Number(input.age); if (!Number.isInteger(age) || age < 12 || age > 120) { errors.push('年龄需要是 12 到 120 之间的整数'); } return { ok: errors.length === 0, errors }; } console.log(validateUser({ name: ' 林一 ', age: '26' })); console.log(validateUser({ name: 'A', age: 'abc' }));这段代码值得琢磨的地方有三处。第一,我先生成了错误数组再统一判断,而不是一路return false,因为实际页面需要把所有错误一次性展示给用户,而不是让用户改一个提交一次。第二,我显式做了类型转换并给了兜底值,避免input.name是undefined时trim直接抛错。第三,返回的是对象而不是布尔值,调用方既能知道是否通过,也能拿到具体的错误信息。这三点都是习题标准答案里通常不会写、但真实项目必须考虑的东西。
4. 函数、参数与作用域:习题里最容易被低估的一章
函数那一章的习题往往是"写出某个函数实现某个功能",看起来简单,但它是分水岭。一个人能不能写好函数,基本能看出他的代码素养:参数是不是合理、有没有副作用、边界情况怎么处理、返回值结构是否稳定。我自己的评判标准很朴素——如果我不能在完全不看实现的情况下,仅凭函数名和参数就猜出它做什么、返回什么,那这个函数的接口设计就不合格。
做这部分习题时,我会强迫自己给每个函数补一行"契约注释":输入什么、输出什么、什么情况下会抛错。这个动作看起来是多写了注释,实际是在逼自己把职责边界想清楚。想不清楚边界的函数,写出来一定是揉了一堆不相干逻辑的大杂烩。
4.1 剩余参数和展开运算符,一对容易搞混的兄弟
这两者用同一个...符号,方向却完全相反:剩余参数是在函数形参位置把多个参数"收"成一个数组,展开运算符是在调用或者字面量里把数组"摊"开。习题里经常把它们放在一起考,很多人第一次做会绕晕。
// 剩余参数:收集 function sum(...nums) { return nums.reduce((acc, cur) => acc + cur, 0); } // 展开运算符:摊开 const values = [1, 2, 3, 4]; console.log(sum(...values)); // 10 console.log([0, ...values, 5]); // [0, 1, 2, 3, 4, 5] console.log(Math.max(...values)); // 4记忆的窍门是看位置:写在函数定义的形参表里,就是收集;写在函数调用的实参里,或者数组、对象字面量里,就是摊开。我用这个办法之后就没再搞混过。还有一个实战要点:剩余参数必须是最后一个形参,因为它要吞掉后面所有的东西,后面再跟别的形参就没有语义了。
4.2 默认参数的求值时机,比想象中更值得关注
默认参数不只是语法糖,它的求值时机有个很实用的特性:只有当对应实参是undefined时才会求值。这意味着传null不会触发默认值,传0也不会。习题里如果出现"给空值兜底"的需求,用默认参数是解决不了null的,这点必须清楚。
另一个我常用的技巧是让后面的参数默认值引用前面的参数,做参数联动:
function createCard(title, subtitle = title + '的副标题') { return { title, subtitle }; } console.log(createCard('周报')); // { title: '周报', subtitle: '周报的副标题' }这个特性在实际项目里用来做配置项的层层继承很方便,但不要滥用,因为参数之间的依赖会让接口变难理解。我的原则是:只在参数存在明显的主从关系时才这么写。
4.3 闭包题为什么反复出现,它到底在考什么
闭包是基础章节里最抽象的概念,也是习题里出现频率最高、失分率也最高的题型。我最初的理解是"函数里面套函数",这个理解能应付一部分题,但一到计数器、循环里绑定事件这种题就崩了。后来我换了个说法:闭包是"函数带着它出生时所在的那片变量环境一起旅行"。函数被带出去执行的时候,它依然能访问那片环境里的变量,哪怕外层函数早就执行完了。
一个典型场景就是给一组按钮绑定点击事件,期望点击时拿到各自的序号:
const buttons = [{ id: 'a' }, { id: 'b' }, { id: 'c' }]; // 用 let 天然形成每次迭代独立的作用域 buttons.forEach((btn, index) => { console.log(`按钮 ${index} 的 id 是 ${btn.id}`); }); // 需要延迟执行的场景,把值先固定下来 const tasks = buttons.map((btn, index) => { return () => `${index}:${btn.id}`; }); console.log(tasks[0](), tasks[1](), tasks[2]());这段代码里,map回调每执行一次,都会生成一个新的index和btn绑定,返回的箭头函数把这对值一起带走了。等后面调用tasks[0]()的时候,拿到的依然是当初那一次迭代的值。这就是闭包在实战里的样子——它不是什么炫技工具,而是"让每个回调记住属于自己的那份状态"的常规手段。
5. 数组与对象:前端日常数据的九成操作都在这里
如果只允许我保留一章内容,我会保留数组和对象这一章。前端业务说到底就是"把后端给的一堆数据,变成页面上的一堆界面元素",中间的全部工作都是对数组和对象的遍历、筛选、映射、合并。这部分习题做得扎实,后面学框架的时候会轻松很多,因为框架里那些list、computed之类的概念,本质上都是数组方法的高层封装。
我练这一章的方法是"一题多解"。同一道题,先用最朴素的手工索引写一遍,再用数组方法写一遍,最后比较两者在可读性和性能上的差异。这个过程比只写一遍有效得多,因为它让你对方法的适用边界有了体感,而不是死记硬背它的定义。
5.1 数组方法选型对照表
方法名字长得像的时候最容易选错,我整理过一张自己常看的表:
| 需求 | 推荐方法 | 注意点 |
|---|---|---|
| 每个元素变成新形态,长度不变 | map | 必须有返回值,忘了写会得到一堆 undefined |
| 挑出符合条件的元素 | filter | 回调返回真值即保留,返回非布尔值会做隐式转换 |
| 判断是否存在符合条件的一项 | some | 找到就短路返回,不会遍历完整个数组 |
| 判断是否全都符合 | every | 空数组调用返回 true,这点常被忽略 |
| 累加、汇总、统计 | reduce | 一定要传初始值,否则空数组会直接抛错 |
| 查找第一个匹配项 | find | 找不到返回 undefined,记得判空 |
| 查找匹配项的下标 | findIndex | 找不到返回 -1,和indexOf语义不同 |
| 原地增删改 | splice | 会改变原数组,链式调用前必须确认这一点 |
reduce是最容易劝退的一个方法,很多人第一次看它的四个参数就晕了。我的建议是把它理解成"一个滚雪球的过程":第一个参数是当前累积起来的雪球,第二个参数是刚滚进来的那片雪,返回值是新的雪球。想清楚这个比喻,reduce的题目基本就不会做了。
5.2 对象的拷贝与"看起来改了其实没改"
对象这一章最大的坑是引用。习题里如果出现"复制一个对象然后修改副本",直接赋值一定会出问题,因为两个变量指向的是同一块内存。浅拷贝能解决一层的问题,但嵌套对象还是共享的。这是我见过的新人 bug 高发区,也是我得单独拿出来说的原因。
const origin = { name: '林一', tags: ['前端', '读书'] }; const shallow = { ...origin }; shallow.name = '赵二'; shallow.tags.push('跑步'); console.log(origin.name); // 林一,第一层没被改 console.log(origin.tags); // ['前端', '读书', '跑步'],第二层被改了要彻底切断联系,需要深拷贝。实际项目里我会优先选择结构化克隆:
// 浏览器环境下的深拷贝,能处理嵌套对象、数组、日期 const deep = structuredClone(origin); deep.tags.push('爬山'); console.log(origin.tags.length); // 2,原对象不受影响JSON.parse(JSON.stringify(obj))这种老办法也能深拷贝,但它会丢掉函数、undefined、Date会变成字符串,还会在遇到循环引用时直接报错。我在项目里已经基本不用它了,除非是很简单的纯数据对象。
5.3 从一道"去重加排序"的题看链式思维
"给一个数组去重并按指定规则排序"是教材里非常经典的题目。用数组方法链式表达,可以写得很清爽:
const raw = [3, 1, 2, 3, 5, 1, 4]; const result = [...new Set(raw)].sort((a, b) => a - b); console.log(result); // [1, 2, 3, 4, 5] // 对象数组按字段排序,注意不要直接改原数组 const list = [{ score: 80 }, { score: 95 }, { score: 70 }]; const ranked = [...list].sort((a, b) => b.score - a.score); console.log(ranked.map((item) => item.score)); // [95, 80, 70]这里有两个细节值得单独说。第一,sort是原地排序,会改变原数组,所以我在排序前先展开复制了一份,这在处理组件状态时非常重要,直接改原数组可能导致界面不更新或者更新得莫名其妙。第二,默认的sort是按字符串比较的,[10, 9, 100]排出来会是[10, 100, 9],必须传比较函数。这个坑我在真实项目里踩过一次,导致一个排行榜的数字顺序完全乱了,排查了很久才想到是排序规则的问题。
6. 把答案跑起来:控制台、断点和最小复现
做习题的过程中,最有价值的技能不是写代码,而是排错。教材的答案只能告诉你"正确的代码长什么样",但没法告诉你"为什么我这段看起来差不多的代码结果不对"。这个能力必须自己练,而练习的场地就是浏览器的开发者工具。
我的习惯是每道题都在控制台里手动跑一遍,把中间变量打出来看。很多人写代码是一口气写完再运行,出错之后两眼一抹黑,只能靠猜。我更推荐增量式开发:写两行,跑一次,确认这一步的输出符合预期,再往下写。听起来慢,实际上比写完再回头找 bug 快得多,因为 bug 的可能范围被限制在最后几行里。
6.1 console 家族的正确打开方式
只用console.log做调试是效率很低的做法。我常用的几个方法各自有明确场景:
console.table(arr):数组或对象数组直接出表格,字段对比一目了然,比打印一堆对象友好太多。console.dir(obj):查看对象的完整结构,尤其是 DOM 元素这种特殊对象。console.count(label):统计某段代码执行了多少次,排查循环次数异常特别好用。console.time和console.timeEnd:测量一段逻辑的耗时,做性能对比时必备。console.trace():打印当前调用栈,排查"到底是谁调用了这个函数"时非常有效。
用console.log的时候也有个小技巧:不要直接打印对象然后就放着不管,因为控制台里打印的对象是引用快照,后面代码改了它,你在控制台看到的内容可能也跟着变,容易产生误判。稳妥的做法要么是打断点看,要么打印它的序列化结果:
const state = { count: 0, list: [] }; console.log(JSON.stringify(state));6.2 断点怎么打才有用
我的断点策略分三种。第一种是在报错行之前的那一行打上,看看到底是哪个变量不符合预期。第二种是在函数入口打上,配合条件断点,只在特定参数下停下来,避免在循环里停几百次。第三种是在debugger语句硬编码,适合那种必须复现特定交互路径才能触发的问题。
条件断点是很多人不知道的功能。在开发者工具的源码面板里右键断点,可以设置表达式,比如只在index === 5的时候停下来。排查数组处理出错的时候,这个功能能帮你直接跳到问题发生的那一次迭代,而不是靠肉眼一次次数循环。
6.3 一个报错定位的完整排查链路
我拿一个很典型的报错走一遍思路。假设控制台给出TypeError: Cannot read properties of undefined (reading 'name')。
第一步,先不急着改代码,而是读报错。它已经明确告诉你:某个值是undefined,你在它身上取name。所以问题不在name,而在"那个本该是对象的东西是 undefined"。
第二步,看报错右侧的文件名和行号,点进去定位到具体那一行。假设这一行是const title = data.user.name;。
第三步,逐个往前推。data是不是 undefined?data.user是不是 undefined?在断点里分别求值就能确认。我遇到的绝大多数情况是data.user为空,根源是接口在某些场景下没有返回user字段。
第四步,想清楚这属于数据问题还是代码问题。如果接口确实可能不返回这个字段,那代码就应该做防御;如果是接口不该为空却空了,那应该去查数据源。两种情况处理方式完全不同,改代码之前必须判断清楚,否则容易把真正的数据缺陷掩盖掉。
第五步,加防御并验证。我通常用可选链加兜底:
const title = data?.user?.name ?? '未命名用户';可选链的好处是它只对null和undefined短路,不会误伤 0 和空字符串,这比早年的data && data.user && data.user.name既短又准确。
沿着这条链路走几遍之后,再看到类似的报错,我基本上能在十几秒内定位到大致范围。这个能力在真实项目里的价值,远高于多背几道题的答案。
7. 刷完这一轮之后:怎么判断自己是真的会了
做完一本教材的习题不难,难的是判断自己到底掌握了没有。我后来总结了一个比较靠谱的检验方式:把题目关掉,从零写一个综合性的小项目,不查资料,遇到卡壳就记下来。写完再对照,卡壳的地方就是真正的薄弱点。这个过程比刷十道题更能暴露问题,因为它不再给你"这道题考什么"的提示。
我还会做一件事:把之前做过的题目拿出来,换个说法重新做一遍。比如原来题目是"求数组最大值",我就改成"求一组交易记录里的最高金额,并返回对应那条记录"。题目变复杂了,但核心逻辑没变。如果能顺利迁移,说明掌握的是方法;如果必须回到原题才能做出来,说明记住的只是答案的模式。
7.1 一份我常用的自测清单
每次复习到这个阶段,我会对着下面的清单过一遍,答不上来的就回去补:
- 能不能在不查资料的情况下,说清
==和===的差别并给出一个不该用==的例子? - 能不能写出一个不会改变原数组的排序实现?
- 能不能解释为什么
for...in不适合遍历数组? - 能不能说清浅拷贝和深拷贝的区别,并各写一种实现?
- 能不能用
reduce完成一次分组统计? - 能不能解释闭包捕获的是变量本身还是变量的值?
- 能不能说清
typeof null和Array.isArray各自解决什么问题? - 看到一个
undefined相关的报错,能不能在三十秒内说出排查步骤?
这八个问题覆盖了基础阶段最容易在真实项目里吃亏的几个点。它们没有一个是偏题怪题,全是日常写业务代码绕不开的东西。
7.2 "看着会、写着废"的高频清单
最后分享几个我自己和身边同事反复出现的"伪掌握"现象,这几种情况最容易让人误判自己的水平:
第一种是能读懂数组方法的链式调用,但自己写的时候分不清哪个方法会改原数组。解决办法是每用一个方法前,先想一下它的返回值是新数组还是原数组。第二种是能说出闭包的定义,但写不出一个"每个回调记住自己索引"的例子。解决办法是手写三遍计数器或者事件绑定的场景,直到不假思索。第三种是知道要用===,但遇到字符串数字比较时又犹豫。解决办法是把类型转换显式写出来,永远不让读者猜。第四种是能背出this的几条规则,但一放到对象方法或者事件回调里就判断不准。这种情况只能靠多写多跑,用实际输出校正脑中的判断。
我自己的体会是,基础阶段最忌讳的就是"用看答案的方式学代码"。答案给你的是结论,而你需要的是判断过程。做习题的时候多问自己一句"如果不看答案,我下一步会怎么做",然后老老实实把手放上键盘敲出来,哪怕敲错了再改。这一来一回的折腾,才是真正长在身上的东西。