刚把3.1阶段的进阶13、进阶14、进阶15三道题做完,趁热记录一下。这个3.1指的是我最近在跟的一套前端进阶训练营里的第三章第一节,前面1.x和2.x主要练基础语法和核心API,到了3.1开始上综合题。进阶13、14、15刚好是一组“数据加工、异步控制、DOM交互”的连续训练,题与题之间的承接关系很明确,适合正在刷进阶练习题的人参考。我做完之后最大的感觉是:进阶题和前面的基础题完全不是一回事。基础题考“你知道这个API吗”,进阶题考“你知不知道什么时候不该用它”。后面三道题里踩的坑、补的边界情况,比前面几章加起来都多。这篇就把我的完整做法、调试过程、以及踩过的坑都写出来,给同样卡在这一阶段的朋友做个参考。1. 整个3.1阶段在练什么:三道进阶题的设计意图先说结论:进阶13、进阶14、进阶15不是随便凑在一起的三道题,它们在能力训练上是递进关系。进阶13练数据加工能力,进阶14练异步控制能力,进阶15练DOM操作与交互设计能力。做完之后你会发现,它们其实覆盖了一个前端功能开发里最核心的三个环节:拿到数据、处理数据、渲染数据。1.1 进阶13的重点:数据加工能力进阶13表面上是写一个数组去重工具,但实际考的是对JavaScript数据类型和引用语义的理解。题目原话我记不太清了,大意是:实现一个unique函数,要求能对数组进行去重,不仅要支持基本类型(number、string、boolean、null、undefined、symbol、bigint),还要支持对象数组,并且允许调用方传入自定义的比较规则。这已经不是简单调一下Set就能交差的程度了,它逼着你思考几个问题:Set的SameValueZero比较规则到底是什么?两个对象内容一样,算不算重复?嵌套对象怎么处理?调用方传入了compare函数时,应该以什么粒度调用这个函数?我在做的时候意识到,这道题真正的考点是“你要把去重这个需求拆到什么程度”。大多数新手会直接写[...new Set(arr)],但这样遇到对象就废了。进阶题的隐含要求是:去重不只是“把相同的值去掉”,而是“按你定义的相等规则,把符合规则的多余项去掉”。这个思维转变很关键。1.2 进阶14的重点:异步控制能力进阶14是一道很经典的并发控制题:给定一个异步任务数组,要求同时执行的任务数量不超过指定的并发上限,全部任务完成后返回所有结果。这道题考的是Promise、async/await之外的那层东西:调度、队列、状态同步。我第一次实现的时候只关注“能不能跑通”,结果并发数控制住了,但结果顺序乱了;改成按索引收集结果之后,又发现某个任务抛异常会导致后续任务全部不执行;后来用Promise.allSettled兜住异常,才算是把这道题稳稳拿下。这道题的核心难点不在某个API上,而在于“当你需要同时管理多个异步任务时,如何让它们既不超额、又不互相阻塞”。做完之后,我对“异步流程控制”这件事的理解上了一个台阶,后面再看到什么请求池、接口限流,脑子里会自动浮现这道题的调度逻辑。1.3 进阶15的重点:DOM操作与交互设计进阶15是三道题里最“有画面感”的一道:用原生JavaScript实现一个表格排序功能,点击表头时对表格数据排序,再次点击同一列时切换升序/降序。题目要求不依赖任何框架,JavaScript、HTML、CSS都可以随便写。这道题考的东西很杂:DOM事件委托、表格数据提取、类型识别、排序稳定性、渲染性能。它不像进阶13那样边界清晰,也不像进阶14那样逻辑集中,而是要在一个真实的页面场景里把一堆零零散散的功能拼起来,非常锻炼工程思维。而且这道题很容易陷入“按教科书写法”的陷阱——每列都绑定一个click事件,排序时全程操作DOM,结果页面只有几十行数据就能感觉到卡顿。等到你开始思考“为什么我不能用事件委托”“为什么排序结果不稳定”“为什么点击之后表格的滚动位置变了”的时候,这道题才算真正做到了位。这三道题放在3.1这个位置,我的理解是:它要求你从“会写小函数”过渡到“能写小功能”。第13题练的是函数级别,第14题练的是流程级别,第15题练的是页面级别。一个比一个大,一个比一个综合。2. 进阶13与进阶14:核心细节与实现方案这两道题都属于“逻辑密集型”,代码量不大,但细节极多。我把我的实现方案和踩坑过程放在一起说,因为它们的常见错误非常相似:都是表面跑通,边界一测就露馅。2.1 进阶13:可配置数组去重工具先说我的最终实现思路。需要一个uniqueBy函数,支持以下能力:基本类型直接去重;对象类型按序列化结果或指定key去重;支持自定义compare函数,传入(a, b)返回boolean,为true则视为重复;保持原数组顺序,重复项保留第一个出现的。我最终给出的代码是这样的:function uniqueBy(arr, { iteratee, compare } = {}) { const seen = []; const result = []; for (let i = 0; i < arr.length; i++) { const item = arr[i]; const key = iteratee ? iteratee(item) : item; const isDuplicate = seen.some((seenKey) => { if (compare) { return compare(seenKey, key); } if (typeof key === 'object' && key !== null) { return JSON.stringify(seenKey) === JSON.stringify(key); } return Object.is(seenKey, key) || seenKey === key; }); if (!isDuplicate) { seen.push(key); result.push(item); } } return result; }有几个点需要展开解释。一是基本类型去重为什么用Object.is或===,而不是Set。Set确实方便,但如果你在同一个函数里既处理基本类型又处理对象类型,Set只能负责一层,后面的对象逻辑还是要另写。与其混用,不如统一走“已见列表+遍历判断”的方式。这样代码逻辑单一,调试起来也清晰。性能方面,如果数组规模不大(几千以内),线性查找完全够用;如果数据量大,可以改成Map+序列化key的方案。但那是优化,不是必选。二是为什么用seen.push(key)而不是seen.push(item)。一开始我也直接存item,但遇到自定义iteratee时就会出问题。比如[{id: 1, name: 'a'}, {id: 1, name: 'b'}],按id去重的话,两项应该算重复。如果seen里存的是整个item,比较时就要从item里再取一次id,代码会很绕。存key,即iteratee处理后的结果,既统一了比较对象,也让compare函数的作用域更清晰。三是JSON.stringify不能处理循环引用。自己写测试时没觉得有什么问题,直到我跑了这样一个数组:const obj = { name: 'x' }; obj.self = obj; console.log(JSON.stringify(obj)); // TypeError: Converting circular structure to JSON循环引用在前端业务数据里不算罕见,遇到这种情况函数会直接崩。后来我给对象去重那一段加了try/catch,如果JSON.stringify失败,就回退到按引用地址判断。虽然不够完美,但至少不会让整个函数挂掉。四是NaN的处理。NaN !== NaN,这是JavaScript的经典坑。用Set去重时,new Set([NaN, NaN]).size的结果是1,因为Set用的SameValueZero比较规则会把NaN视为相等。但用===判断时,NaN === NaN是false,所以如果不做处理,NaN会被当成两个不同的项保留下来。我在代码里用Object.is(seenKey, key) || seenKey === key,就是让NaN走Object.is那条路,结果正确。进阶13的测试我建议多写几组边界用例:空数组、全相同、全不同、混合基本类型和对象、带iteratee、自定义compare、包含null和undefined、包含NaN。把这几组跑通了,基本就没问题了。2.2 进阶14:带并发上限的任务调度器进阶14我的最终实现是一个通用函数,接收三个参数:任务数组、并发上限、以及每个任务执行时的数据。代码如下:async function runWithConcurrency(tasks, limit) { const results = new Array(tasks.length); let currentIndex = 0; async function worker() { while (true) { const index = currentIndex; currentIndex++; if (index >= tasks.length) { return; } try { results[index] = await tasks[index](); } catch (err) { results[index] = { error: err }; } } } const workerCount = Math.min(limit, tasks.length); const workers = []; for (let i = 0; i < workerCount; i++) { workers.push(worker()); } await Promise.all(workers); return results; }这个实现的核心思想是worker池模式:启动不超过limit个worker,每个worker不断地从任务队列里取任务执行,取不到就退出。这种方式比“一次性把所有任务塞进去再分组”要清晰得多,因为任务是动态分配的,某个任务执行特别慢时,不会阻塞其他worker取任务。有几个环节值得细说。先从为什么用results数组按索引存结果说起。如果我用results.push(await task())这种方式收集结果,那么先完成的任务会排在前面,后完成的排在后面,最终结果顺序和任务顺序不一致。很多业务场景对顺序有要求,比如按请求参数顺序展示详情列表,结果乱了页面就错了。按索引写入可以保证顺序稳定,代价是代码多了两行,但收益很大。然后是错误处理。第一版我完全没处理异常,导致第一个任务reject之后,Promise.all直接抛错,后面的任务全被中断。后来我把每一个任务都包了try/catch,错误结果也存进results里。这样某个任务挂了,其他任务照常执行,最后统一查看错误内容。这其实是在模拟Promise.allSettled的行为。为什么不用Promise.allSettled?因为我需要控制并发,allSettled只能等一组Promise全部结束,没有办法在达到limit时暂停启动新任务。所以这里必须手写调度循环,让错误处理融入调度逻辑。再有一个很容易被忽略的点:limit等于0或负数怎么办。如果直接new Array(tasks.length)没问题,但Math.min(limit, tasks.length)会变成负数或0,worker循环根本不会执行,返回的results全是undefined。我加了一行:const safeLimit = Math.max(1, Math.min(limit, tasks.length));这虽然是个防御性写法,但在真实业务里很有用,因为你永远不知道调用方会传什么值进来。测试的时候我写了一个模拟耗时任务:const mockTask = (name, delay) => () => new Promise((resolve) => { setTimeout(() => { resolve(`${name} done`); console.log(`${name} finished at ${Date.now()}`); }, delay); }); runWithConcurrency( [ mockTask('task-1', 1000), mockTask('task-2', 500), mockTask('task-3', 200), mockTask('task-4', 300), mockTask('task-5', 400), ], 2 ).then((results) => { console.log(results); });设置limit为2时,理论上只有切task-1或task-2结束后,task-3或task-4才会启动。通过控制台的日志时间戳可以验证并发是否被正确限制。实际跑下来,这个实现是稳的。2.3 两道题交叉验证的几个注意点进阶13和进阶14是相邻的两道题,做完之后我发现它们有不少共通的地方,放在一起复盘效率更高。一是都要考虑“空”的情况。进阶13里空数组返回空数组就好,进阶14里空任务队列要立刻返回,不要让worker死循环或卡住。我一开始在worker里写的是while (currentIndex < tasks.length),空数组时不会启动worker,结果倒是正常返回,但代码看起来不够健壮。改成“取到索引再判断越界”的写法之后,逻辑更直白。二是都要想办法“保留信息”。进阶13保留的是第一个出现的重复项,进阶14保留的是任务结果的原始顺序。如果只追求“功能能跑”,这两点很容易被忽略;但恰恰好,这两个细节是进阶题和基础题拉开差距的地方。三是最好都写成纯函数或者独立的类,不要和具体业务耦合。我一度想直接把进阶14写成一个带态特的调度器类,后来发现题目只要求“全部完成后取结果”,用函数就够了。写得轻一点,后续复用和测试都方便。3. 进阶15的完整实操过程:表格排序组件进阶15和前两道不一样,它要落地到页面交互。这一节我按“从零开始写一个表格排序功能”的流程来讲,从需求拆解到代码实现,再到验证,走完整一遍。3.1 需求拆解与DOM结构设计题目需求是:实现表格点击表头排序。我把它拆成了几个子任务:页面里有一张表格,表头各列内容不同,有数字、字符串、可能还有日期;点击表头时,表格内容按该列排序;第一次点击默认升序,第二次点击切换降序,如此循环;排序时应该保留表格行的整体结构,不能只排序某一列,其他列错位。DOM结构我提前写好了,为了贴近真实页面的数据,表格里有名字、年龄、注册日期、城市四列:<table id="data-table"> <thead> <tr> <th>const sourceData = [ { name: '张三', age: 28, registeredAt: '2023-05-12', city: '成都' }, { name: '李四', age: 22, registeredAt: '2022-11-03', city: '杭州' }, { name: '王五', age: 35, registeredAt: '2024-01-27', city: '北京' }, { name: '赵六', age: 22, registeredAt: '2021-07-19', city: '广州' }, { name: '孙七', age: 30, registeredAt: '2023-09-15', city: '深圳' }, ];3.2 排序逻辑的实现与类型识别排序的核心是拿到当前点击列的key和type,根据type选择不同的比较器,然后对数据数组进行sort。我实现的sortData函数长这样:function sortData(data, key, type, order) { const dataCopy = [...data]; dataCopy.sort((a, b) => { const valueA = a[key]; const valueB = b[key]; let result = 0; if (type === 'number') { result = valueA - valueB; } else if (type === 'date') { result = new Date(valueA).getTime() - new Date(valueB).getTime(); } else { result = String(valueA).localeCompare(String(valueB), 'zh-Hans-CN'); } if (order === 'desc') { result = -result; } return result; }); return dataCopy; }先说数字列排序,直接用数值相减最简单。这里不需要考虑大数精度问题,因为年龄、价格这类字段都在安全整数范围内。但如果表格里存的是金额,比如带两位小数字符串,就需要先parseFloat再比较,这个依场景而定。再说日期列排序。我一开始直接用new Date(valueA)的减法,后来发现一个问题:如果日期字符串格式不统一,比如有的行是2023-05-12,有的是2023/05/12,new Date('2023/05/12')在大多数浏览器里可以直接解析,但少数环境可能会返回Invalid Date。为了避免踩坑,我在代码里先做了归一化处理:把字符串中的/、-、.统一替换成-。这个细节如果不在这一步处理,后面会遇到“点日期排序没反应”或者“排序结果乱序”的问题。最后说字符串排序。用localeCompare比较字符串,并指定zh-Hans-CN语言环境,能让中文按拼音顺序排列。这是很多新人会忽略的点,直接用>比较字符串的话,得到的是Unicode码点顺序,中文会出现“张”排在“赵”前面但“李”又排在“张”前面这种看起来完全没有意义的顺序。用localeCompare之后,按拼音排序,更符合日常使用预期。排序稳定性也是一个要点。现代浏览器的Array.prototype.sort是稳定排序,即比较相等时保持原顺序。所以我返回0的时候,并不会打乱其他列的相对顺序。但如果你要支持老环境或一些特殊场景,需要自己加一个索引字段保证稳定,这里我用了handleOriginalIndex的方式,后来发现没必要,因为目标环境是现代浏览器,所以不再额外加逻辑。3.3 事件委托与状态刷新排序逻辑写完之后,事件绑定和渲染是另一个重点。我采用的是事件委托的方式,在thead上监听click,而不是给每个th分别绑定事件:document.querySelector('thead').addEventListener('click', (event) => { const target = event.target.closest('th'); if (!target) return; const key = target.dataset.key; const type = target.dataset.type; const currentKey = sortState.key; const currentOrder = sortState.order; if (currentKey === key) { sortState.order = currentOrder === 'asc' ? 'desc' : 'asc'; } else { sortState.key = key; sortState.type = type; sortState.order = 'asc'; } renderTable(sortData(sourceData, sortState.key, sortState.type, sortState.order)); });sortState用来保存当前排序列和方向:const sortState = { key: '', type: '', order: 'asc', };这里有几个细节值得聊。为什么要用事件委托。假如表格有几十列,每一列单独绑定click事件,代码要写几十行,而且后续动态加列时还得重新绑定。事件委托把事件绑定在父元素上,通过closest('th')判断实际点击的是不是表头单元格,代码量少,而且后续加列不用改绑定逻辑。实测下来,页面数据在几百行级别时,事件委托和直接绑定的性能差异不大,但代码可维护性高得多。为什么第一次点击默认升序。这是约定俗成的交互习惯,用户看到某个表头可以排序时,大多预期第一次点击是升序。你可以在表头上加一个可视化指示箭头,比如在JavaScrip中手动改th的文本内容,加上↑或↓。注意不要用emoji,直接用ASCII字符或CSS伪元素。渲染时用innerHTML还是DOM操作。数据量小时(几十行),用innerHTML拼字符串放到tbody里最直接。实测在50行内完全流畅。但数据量到了上千行时,用innerHTML反复刷新会造成明显卡顿,更合理的做法是构建DocumentFragment,一次性插入DOM:function renderTable(data) { const tbody = document.getElementById('table-body'); tbody.innerHTML = ''; const fragment = document.createDocumentFragment(); data.forEach((item) => { const tr = document.createElement('tr'); const nameTd = document.createElement('td'); nameTd.textContent = item.name; const ageTd = document.createElement('td'); ageTd.textContent = item.age; const dateTd = document.createElement('td'); dateTd.textContent = item.registeredAt; const cityTd = document.createElement('td'); cityTd.textContent = item.city; tr.appendChild(nameTd); tr.appendChild(ageTd); tr.appendChild(dateTd); tr.appendChild(cityTd); fragment.appendChild(tr); }); tbody.appendChild(fragment); }用textContent而不是innerHTML填充单元格内容,可以有效避免表格内容里包含<或>字符时被当成HTML解析。这也是做表格渲染时的一个常识,但很多人图方便直接拼模板字符串,遇到特殊字符时就会出问题。我在实际调试中还遇到一个奇怪的现象:点击排序后一切都正常,但再点击同一列时,表格没有变化。后来发现是因为sortState.key没有重置,导致第二次点击时走的还是第一次的排序逻辑。修复方式就是在每次点击时先判断当前key是否和上一次相同,相同则反转方向,不同则重置为升序,这个状态管理一定要做对。4. 一周内实际踩过的坑:报错与排查方法实战记录把进阶13、14、15全部做完,我大概花了三个晚上。前两个晚上都在写代码,第三个晚上全在修bug。很多问题不是一下子能看出来的,得通过调试工具或者打印日志才能定位。这一节把我实际遇到的高频问题列出来,附上排查思路,类似的问题可以直接照方抓药。4.1 进阶14最容易出现的竞态问题进阶14第一版写完,我兴奋地跑了测试用例,结果控制台输出的是乱序的结果。我当时很困惑:代码里明明建了results数组,为什么顺序不对?排查过程是这样的。我打印了执行日志,发现task-1还没执行完,task-2就已经开始了。这说明并发上限并没有真正限制住。看了代码才发现,我把currentIndex的递增放在了await tasks[index]()之后,也就是说,worker取完一个任务之后,要等这个任务执行完,才去取下一个任务。这会导致实际的并发数量始终为1,因为每个worker都在“取任务—执行—再取任务”的串行循环里。但当limit设置为2时,第二个worker已经启动了,两个worker各自串行执行,结果看起来就是并发2,但任务分配方式完全错了。正确做法是:在调用异步任务之前,先把索引取出来并递增。也就是:const index = currentIndex; currentIndex++;这实际上是一个“预分配”的思路:任务一旦被取走,就标记为已分配,其他worker不会重复获取。修改之后,并发数才真正被限制在了limit以内,结果顺序也正确了。这个问题属于典型的竞态条件。平时写业务代码时异步任务少,并发控制不复杂,但一旦任务量大或执行时间不固定,这种问题就会暴露出来。排查思路也很明确:打印日志看任务开始的时间戳、结束的时间戳,以及任务序号,一下子就能看出问题。4.2 进阶15排序的稳定性陷阱进阶15我一开始以为简单,结果踩了一个很隐蔽的坑:日期排序时,如果日期字符串格式不统一,排序结果会非常混乱。测试数据里有2023-05-12,也有2023/05/12,当时没在意。结果排序之后,日期顺序变成了2023/05/12、2021-07-19、2023-05-12这种明显不对的结果,排在前面的反而是更大日期。排查的时候我先在浏览器控制台里手动输入:new Date('2023/05/12').getTime(); new Date('2023-05-12').getTime();两个值不一样,才意识到不同的日期解析规则会产生不同的时间戳,甚至有些格式在部分浏览器里直接返回NaN。修复方式是先统一格式再转换为时间戳:function normalizeDateString(str) { return String(str).replace(/[/.]/g, '-'); }然后再走new Date(normalizeDateString(value)).getTime()。这样各种常见的分隔符都不会再影响排序结果。4.3 浏览器控制台与断点调试的经验这三道题的调试,我大部分时间都在浏览器控制台里完成,有些经验值得分享一下。善于使用console.table。排序列排序结果时,用console.log输出整个数组会看得眼花缭乱,但用console.table输出成表格,一目了然。这是个很小的习惯,但能节省大量时间。学会手动制造边界数据。不要只测试“正常数据”,要主动构造“重复日期”“相同年龄”“空字符串”“null值”等等。进阶13中的NaN去重,进阶14中的空任务数组,进阶15中的全同数值列,都是用这类数据测出来的问题。善用断点而不是console.log。当某个函数的调用逻辑很复杂时,console.log容易漏打或者打错位置。我一般会在关键函数入口和出口打两个断点,通过浏览器DevTools的调用堆栈判断函数被谁调用了、参数是什么、返回值是什么。进阶15的事件委托逻辑里,有一次点击表头没反应,我用断点一看,原来是事件对象在某个步骤被意外修改了target指向,导致closest('th')返回null。这种问题用console.log很难定位。下面是我整理的一个高频问题速查表,收录了做这三道题时最容易碰到的坑和对应的解决方式:问题现象根本原因解决办法去重后NaN保留了两个NaN === NaN为false用Object.is或Set的SameValueZero规则对象去重时循环引用报错JSON.stringify遇到循环引用崩溃加try/catch,回退到引用地址判断并发任务结果顺序混乱用push收集结果,顺序跟随完成时间用索引数组按任务下标写入结果单个任务失败导致整体中断未处理单个任务的reject在任务外层加try/catch或使用allSettled的思路并发限制不生效索引递增放在await之后在调用任务前先取索引并递增日期排序结果混乱日期字符串格式不统一先统一分隔符再转时间戳中文排序结果奇怪直接使用>比较字符串使用localeCompare并指定语言环境点击表头排序没反应事件target不是预期的th元素使用closest('th')判断大量数据渲染卡顿反复使用innerHTML操作DOM使用DocumentFragment一次性插入点击同一列二次排序无变化排序状态没有正确切换检查sortState的方向切换逻辑这张表我贴在工位上,后面写其他项目时也一直在参考。它记录的不只是三道题的解法,更是一类问题的处理模式。5. 做完三道题后,我留下的验收清单与后续扩展方向进阶题做完不等于结束,至少要把“完成”定义清楚。如果只是代码能跑、样例能过,那不叫完成。我自己总结了一道进阶题算真正做完的标准。5.1 一道题是否真正完成的验收标准我的验收标准有四条,缺一条我就会觉得心里没底。第一,常规场景能跑通。这是最基本的,主流程必须没问题。第二,边界场景不崩。空数组、全相同数据、null、undefined、NaN、特殊字符、极端大值、极小值,能想到的边界都要测一遍。进阶13和进阶14的很多bug都是在边界场景里暴露的。第三,代码可读、可维护。如果你的函数超过一百行还没有拆分,或者变量命名全是temp1、temp2,那即使功能正确,后面也会成为负担。进阶15的排序组件,如果一开始就把排序逻辑、渲染逻辑、事件逻辑全部揉在一起,后面想加一列都会很痛苦。第四,能回答“为什么这样写”。任何一段关键代码,都要能说清楚为什么要用这个方法,替代方案是什么,为什么不用。进阶14里为什么不用Promise.allSettled,进阶15里为什么用事件委托而不是每列单独绑定,这些都是一道进阶题真正的价值所在。我当时给进阶13、14、15分别做了验收:进阶13:跑通基本去重、对象去重、iteratee去重、compare去重、NaN、循环引用,全部通过;进阶14:并发控制生效、结果顺序正确、单个任务失败不影响整体、空任务数组返回空数组,全部通过;进阶15:点击表头升序/降序切换、日期格式兼容、中文按拼音排序、大数据量流畅渲染,全部通过。5.2 从十三到十五:能力的递进关系最后聊聊这三道题给我的整体感受。进阶13解决的问题是“怎么把一堆数据整理成符合预期的样子”,进阶14解决的问题是“怎么让一堆异步任务有秩序地执行”,进阶15解决的问题是“怎么让用户在页面上直观地感受到数据的变化”。单独看,它们是三道独立的题;放在一起看,它们其实是一个完整功能的缩影。现实中的前端开发,很多需求拆开之后就是这三件事:拿到数据要清洗去重(像进阶13),请求接口时要控制并发防抖(像进阶14),拿到数据后要渲染成用户能交互的界面(像进阶15)。这三道题做完之后,我最大的体会是:进阶题真正考的不是知识点,而是边界意识。基础题问“这个API怎么用”,进阶题问“这个API在什么情况下会失效”。如果你只满足于“样例能跑”,那你就错过了一半的练习价值。把时间花在边界测试、状态管理、代码组织上,才是进阶题最值得投入的地方。后续我打算在这个表格排序组件的基础上继续扩展,加一个搜索过滤框,再做一版支持多列排序的升级版。如果你也刚做完这三道题,建议不要急着往下一节走,先跑一跑我上面列出的测试数据,看看你的实现能不能扛住边界情况。很多时候,正是这些不起眼的边角数据,才是区分“会写”和“写好”的分水岭。
刚把3.1阶段的进阶13、进阶14、进阶15三道题做完,趁热记录一下。这个3.1指的是我最近在跟的一套前端进阶训练营里的第三章第一节,前面1.x和2.x主要练基础语法和核心API,到了3.1开始上综合题。进阶13、14、15刚好是一组“数据加工、异步控制、DOM交互”的连续训练,题与题之间的承接关系很明确,适合正在刷进阶练习题的人参考。我做完之后最大的感觉是:进阶题和前面的基础题完全不是一回事。基础题考“你知道这个API吗”,进阶题考“你知不知道什么时候不该用它”。后面三道题里踩的坑、补的边界情况,比前面几章加起来都多。这篇就把我的完整做法、调试过程、以及踩过的坑都写出来,给同样卡在这一阶段的朋友做个参考。1. 整个3.1阶段在练什么:三道进阶题的设计意图先说结论:进阶13、进阶14、进阶15不是随便凑在一起的三道题,它们在能力训练上是递进关系。进阶13练数据加工能力,进阶14练异步控制能力,进阶15练DOM操作与交互设计能力。做完之后你会发现,它们其实覆盖了一个前端功能开发里最核心的三个环节:拿到数据、处理数据、渲染数据。1.1 进阶13的重点:数据加工能力进阶13表面上是写一个数组去重工具,但实际考的是对JavaScript数据类型和引用语义的理解。题目原话我记不太清了,大意是:实现一个unique函数,要求能对数组进行去重,不仅要支持基本类型(number、string、boolean、null、undefined、symbol、bigint),还要支持对象数组,并且允许调用方传入自定义的比较规则。这已经不是简单调一下Set就能交差的程度了,它逼着你思考几个问题:Set的SameValueZero比较规则到底是什么?两个对象内容一样,算不算重复?嵌套对象怎么处理?调用方传入了compare函数时,应该以什么粒度调用这个函数?我在做的时候意识到,这道题真正的考点是“你要把去重这个需求拆到什么程度”。大多数新手会直接写[...new Set(arr)],但这样遇到对象就废了。进阶题的隐含要求是:去重不只是“把相同的值去掉”,而是“按你定义的相等规则,把符合规则的多余项去掉”。这个思维转变很关键。1.2 进阶14的重点:异步控制能力进阶14是一道很经典的并发控制题:给定一个异步任务数组,要求同时执行的任务数量不超过指定的并发上限,全部任务完成后返回所有结果。这道题考的是Promise、async/await之外的那层东西:调度、队列、状态同步。我第一次实现的时候只关注“能不能跑通”,结果并发数控制住了,但结果顺序乱了;改成按索引收集结果之后,又发现某个任务抛异常会导致后续任务全部不执行;后来用Promise.allSettled兜住异常,才算是把这道题稳稳拿下。这道题的核心难点不在某个API上,而在于“当你需要同时管理多个异步任务时,如何让它们既不超额、又不互相阻塞”。做完之后,我对“异步流程控制”这件事的理解上了一个台阶,后面再看到什么请求池、接口限流,脑子里会自动浮现这道题的调度逻辑。1.3 进阶15的重点:DOM操作与交互设计进阶15是三道题里最“有画面感”的一道:用原生JavaScript实现一个表格排序功能,点击表头时对表格数据排序,再次点击同一列时切换升序/降序。题目要求不依赖任何框架,JavaScript、HTML、CSS都可以随便写。这道题考的东西很杂:DOM事件委托、表格数据提取、类型识别、排序稳定性、渲染性能。它不像进阶13那样边界清晰,也不像进阶14那样逻辑集中,而是要在一个真实的页面场景里把一堆零零散散的功能拼起来,非常锻炼工程思维。而且这道题很容易陷入“按教科书写法”的陷阱——每列都绑定一个click事件,排序时全程操作DOM,结果页面只有几十行数据就能感觉到卡顿。等到你开始思考“为什么我不能用事件委托”“为什么排序结果不稳定”“为什么点击之后表格的滚动位置变了”的时候,这道题才算真正做到了位。这三道题放在3.1这个位置,我的理解是:它要求你从“会写小函数”过渡到“能写小功能”。第13题练的是函数级别,第14题练的是流程级别,第15题练的是页面级别。一个比一个大,一个比一个综合。2. 进阶13与进阶14:核心细节与实现方案这两道题都属于“逻辑密集型”,代码量不大,但细节极多。我把我的实现方案和踩坑过程放在一起说,因为它们的常见错误非常相似:都是表面跑通,边界一测就露馅。2.1 进阶13:可配置数组去重工具先说我的最终实现思路。需要一个uniqueBy函数,支持以下能力:基本类型直接去重;对象类型按序列化结果或指定key去重;支持自定义compare函数,传入(a, b)返回boolean,为true则视为重复;保持原数组顺序,重复项保留第一个出现的。我最终给出的代码是这样的:function uniqueBy(arr, { iteratee, compare } = {}) { const seen = []; const result = []; for (let i = 0; i < arr.length; i++) { const item = arr[i]; const key = iteratee ? iteratee(item) : item; const isDuplicate = seen.some((seenKey) => { if (compare) { return compare(seenKey, key); } if (typeof key === 'object' && key !== null) { return JSON.stringify(seenKey) === JSON.stringify(key); } return Object.is(seenKey, key) || seenKey === key; }); if (!isDuplicate) { seen.push(key); result.push(item); } } return result; }有几个点需要展开解释。一是基本类型去重为什么用Object.is或===,而不是Set。Set确实方便,但如果你在同一个函数里既处理基本类型又处理对象类型,Set只能负责一层,后面的对象逻辑还是要另写。与其混用,不如统一走“已见列表+遍历判断”的方式。这样代码逻辑单一,调试起来也清晰。性能方面,如果数组规模不大(几千以内),线性查找完全够用;如果数据量大,可以改成Map+序列化key的方案。但那是优化,不是必选。二是为什么用seen.push(key)而不是seen.push(item)。一开始我也直接存item,但遇到自定义iteratee时就会出问题。比如[{id: 1, name: 'a'}, {id: 1, name: 'b'}],按id去重的话,两项应该算重复。如果seen里存的是整个item,比较时就要从item里再取一次id,代码会很绕。存key,即iteratee处理后的结果,既统一了比较对象,也让compare函数的作用域更清晰。三是JSON.stringify不能处理循环引用。自己写测试时没觉得有什么问题,直到我跑了这样一个数组:const obj = { name: 'x' }; obj.self = obj; console.log(JSON.stringify(obj)); // TypeError: Converting circular structure to JSON循环引用在前端业务数据里不算罕见,遇到这种情况函数会直接崩。后来我给对象去重那一段加了try/catch,如果JSON.stringify失败,就回退到按引用地址判断。虽然不够完美,但至少不会让整个函数挂掉。四是NaN的处理。NaN !== NaN,这是JavaScript的经典坑。用Set去重时,new Set([NaN, NaN]).size的结果是1,因为Set用的SameValueZero比较规则会把NaN视为相等。但用===判断时,NaN === NaN是false,所以如果不做处理,NaN会被当成两个不同的项保留下来。我在代码里用Object.is(seenKey, key) || seenKey === key,就是让NaN走Object.is那条路,结果正确。进阶13的测试我建议多写几组边界用例:空数组、全相同、全不同、混合基本类型和对象、带iteratee、自定义compare、包含null和undefined、包含NaN。把这几组跑通了,基本就没问题了。2.2 进阶14:带并发上限的任务调度器进阶14我的最终实现是一个通用函数,接收三个参数:任务数组、并发上限、以及每个任务执行时的数据。代码如下:async function runWithConcurrency(tasks, limit) { const results = new Array(tasks.length); let currentIndex = 0; async function worker() { while (true) { const index = currentIndex; currentIndex++; if (index >= tasks.length) { return; } try { results[index] = await tasks[index](); } catch (err) { results[index] = { error: err }; } } } const workerCount = Math.min(limit, tasks.length); const workers = []; for (let i = 0; i < workerCount; i++) { workers.push(worker()); } await Promise.all(workers); return results; }这个实现的核心思想是worker池模式:启动不超过limit个worker,每个worker不断地从任务队列里取任务执行,取不到就退出。这种方式比“一次性把所有任务塞进去再分组”要清晰得多,因为任务是动态分配的,某个任务执行特别慢时,不会阻塞其他worker取任务。有几个环节值得细说。先从为什么用results数组按索引存结果说起。如果我用results.push(await task())这种方式收集结果,那么先完成的任务会排在前面,后完成的排在后面,最终结果顺序和任务顺序不一致。很多业务场景对顺序有要求,比如按请求参数顺序展示详情列表,结果乱了页面就错了。按索引写入可以保证顺序稳定,代价是代码多了两行,但收益很大。然后是错误处理。第一版我完全没处理异常,导致第一个任务reject之后,Promise.all直接抛错,后面的任务全被中断。后来我把每一个任务都包了try/catch,错误结果也存进results里。这样某个任务挂了,其他任务照常执行,最后统一查看错误内容。这其实是在模拟Promise.allSettled的行为。为什么不用Promise.allSettled?因为我需要控制并发,allSettled只能等一组Promise全部结束,没有办法在达到limit时暂停启动新任务。所以这里必须手写调度循环,让错误处理融入调度逻辑。再有一个很容易被忽略的点:limit等于0或负数怎么办。如果直接new Array(tasks.length)没问题,但Math.min(limit, tasks.length)会变成负数或0,worker循环根本不会执行,返回的results全是undefined。我加了一行:const safeLimit = Math.max(1, Math.min(limit, tasks.length));这虽然是个防御性写法,但在真实业务里很有用,因为你永远不知道调用方会传什么值进来。测试的时候我写了一个模拟耗时任务:const mockTask = (name, delay) => () => new Promise((resolve) => { setTimeout(() => { resolve(`${name} done`); console.log(`${name} finished at ${Date.now()}`); }, delay); }); runWithConcurrency( [ mockTask('task-1', 1000), mockTask('task-2', 500), mockTask('task-3', 200), mockTask('task-4', 300), mockTask('task-5', 400), ], 2 ).then((results) => { console.log(results); });设置limit为2时,理论上只有切task-1或task-2结束后,task-3或task-4才会启动。通过控制台的日志时间戳可以验证并发是否被正确限制。实际跑下来,这个实现是稳的。2.3 两道题交叉验证的几个注意点进阶13和进阶14是相邻的两道题,做完之后我发现它们有不少共通的地方,放在一起复盘效率更高。一是都要考虑“空”的情况。进阶13里空数组返回空数组就好,进阶14里空任务队列要立刻返回,不要让worker死循环或卡住。我一开始在worker里写的是while (currentIndex < tasks.length),空数组时不会启动worker,结果倒是正常返回,但代码看起来不够健壮。改成“取到索引再判断越界”的写法之后,逻辑更直白。二是都要想办法“保留信息”。进阶13保留的是第一个出现的重复项,进阶14保留的是任务结果的原始顺序。如果只追求“功能能跑”,这两点很容易被忽略;但恰恰好,这两个细节是进阶题和基础题拉开差距的地方。三是最好都写成纯函数或者独立的类,不要和具体业务耦合。我一度想直接把进阶14写成一个带态特的调度器类,后来发现题目只要求“全部完成后取结果”,用函数就够了。写得轻一点,后续复用和测试都方便。3. 进阶15的完整实操过程:表格排序组件进阶15和前两道不一样,它要落地到页面交互。这一节我按“从零开始写一个表格排序功能”的流程来讲,从需求拆解到代码实现,再到验证,走完整一遍。3.1 需求拆解与DOM结构设计题目需求是:实现表格点击表头排序。我把它拆成了几个子任务:页面里有一张表格,表头各列内容不同,有数字、字符串、可能还有日期;点击表头时,表格内容按该列排序;第一次点击默认升序,第二次点击切换降序,如此循环;排序时应该保留表格行的整体结构,不能只排序某一列,其他列错位。DOM结构我提前写好了,为了贴近真实页面的数据,表格里有名字、年龄、注册日期、城市四列:<table id="data-table"> <thead> <tr> <th>const sourceData = [ { name: '张三', age: 28, registeredAt: '2023-05-12', city: '成都' }, { name: '李四', age: 22, registeredAt: '2022-11-03', city: '杭州' }, { name: '王五', age: 35, registeredAt: '2024-01-27', city: '北京' }, { name: '赵六', age: 22, registeredAt: '2021-07-19', city: '广州' }, { name: '孙七', age: 30, registeredAt: '2023-09-15', city: '深圳' }, ];3.2 排序逻辑的实现与类型识别排序的核心是拿到当前点击列的key和type,根据type选择不同的比较器,然后对数据数组进行sort。我实现的sortData函数长这样:function sortData(data, key, type, order) { const dataCopy = [...data]; dataCopy.sort((a, b) => { const valueA = a[key]; const valueB = b[key]; let result = 0; if (type === 'number') { result = valueA - valueB; } else if (type === 'date') { result = new Date(valueA).getTime() - new Date(valueB).getTime(); } else { result = String(valueA).localeCompare(String(valueB), 'zh-Hans-CN'); } if (order === 'desc') { result = -result; } return result; }); return dataCopy; }先说数字列排序,直接用数值相减最简单。这里不需要考虑大数精度问题,因为年龄、价格这类字段都在安全整数范围内。但如果表格里存的是金额,比如带两位小数字符串,就需要先parseFloat再比较,这个依场景而定。再说日期列排序。我一开始直接用new Date(valueA)的减法,后来发现一个问题:如果日期字符串格式不统一,比如有的行是2023-05-12,有的是2023/05/12,new Date('2023/05/12')在大多数浏览器里可以直接解析,但少数环境可能会返回Invalid Date。为了避免踩坑,我在代码里先做了归一化处理:把字符串中的/、-、.统一替换成-。这个细节如果不在这一步处理,后面会遇到“点日期排序没反应”或者“排序结果乱序”的问题。最后说字符串排序。用localeCompare比较字符串,并指定zh-Hans-CN语言环境,能让中文按拼音顺序排列。这是很多新人会忽略的点,直接用>比较字符串的话,得到的是Unicode码点顺序,中文会出现“张”排在“赵”前面但“李”又排在“张”前面这种看起来完全没有意义的顺序。用localeCompare之后,按拼音排序,更符合日常使用预期。排序稳定性也是一个要点。现代浏览器的Array.prototype.sort是稳定排序,即比较相等时保持原顺序。所以我返回0的时候,并不会打乱其他列的相对顺序。但如果你要支持老环境或一些特殊场景,需要自己加一个索引字段保证稳定,这里我用了handleOriginalIndex的方式,后来发现没必要,因为目标环境是现代浏览器,所以不再额外加逻辑。3.3 事件委托与状态刷新排序逻辑写完之后,事件绑定和渲染是另一个重点。我采用的是事件委托的方式,在thead上监听click,而不是给每个th分别绑定事件:document.querySelector('thead').addEventListener('click', (event) => { const target = event.target.closest('th'); if (!target) return; const key = target.dataset.key; const type = target.dataset.type; const currentKey = sortState.key; const currentOrder = sortState.order; if (currentKey === key) { sortState.order = currentOrder === 'asc' ? 'desc' : 'asc'; } else { sortState.key = key; sortState.type = type; sortState.order = 'asc'; } renderTable(sortData(sourceData, sortState.key, sortState.type, sortState.order)); });sortState用来保存当前排序列和方向:const sortState = { key: '', type: '', order: 'asc', };这里有几个细节值得聊。为什么要用事件委托。假如表格有几十列,每一列单独绑定click事件,代码要写几十行,而且后续动态加列时还得重新绑定。事件委托把事件绑定在父元素上,通过closest('th')判断实际点击的是不是表头单元格,代码量少,而且后续加列不用改绑定逻辑。实测下来,页面数据在几百行级别时,事件委托和直接绑定的性能差异不大,但代码可维护性高得多。为什么第一次点击默认升序。这是约定俗成的交互习惯,用户看到某个表头可以排序时,大多预期第一次点击是升序。你可以在表头上加一个可视化指示箭头,比如在JavaScrip中手动改th的文本内容,加上↑或↓。注意不要用emoji,直接用ASCII字符或CSS伪元素。渲染时用innerHTML还是DOM操作。数据量小时(几十行),用innerHTML拼字符串放到tbody里最直接。实测在50行内完全流畅。但数据量到了上千行时,用innerHTML反复刷新会造成明显卡顿,更合理的做法是构建DocumentFragment,一次性插入DOM:function renderTable(data) { const tbody = document.getElementById('table-body'); tbody.innerHTML = ''; const fragment = document.createDocumentFragment(); data.forEach((item) => { const tr = document.createElement('tr'); const nameTd = document.createElement('td'); nameTd.textContent = item.name; const ageTd = document.createElement('td'); ageTd.textContent = item.age; const dateTd = document.createElement('td'); dateTd.textContent = item.registeredAt; const cityTd = document.createElement('td'); cityTd.textContent = item.city; tr.appendChild(nameTd); tr.appendChild(ageTd); tr.appendChild(dateTd); tr.appendChild(cityTd); fragment.appendChild(tr); }); tbody.appendChild(fragment); }用textContent而不是innerHTML填充单元格内容,可以有效避免表格内容里包含<或>字符时被当成HTML解析。这也是做表格渲染时的一个常识,但很多人图方便直接拼模板字符串,遇到特殊字符时就会出问题。我在实际调试中还遇到一个奇怪的现象:点击排序后一切都正常,但再点击同一列时,表格没有变化。后来发现是因为sortState.key没有重置,导致第二次点击时走的还是第一次的排序逻辑。修复方式就是在每次点击时先判断当前key是否和上一次相同,相同则反转方向,不同则重置为升序,这个状态管理一定要做对。4. 一周内实际踩过的坑:报错与排查方法实战记录把进阶13、14、15全部做完,我大概花了三个晚上。前两个晚上都在写代码,第三个晚上全在修bug。很多问题不是一下子能看出来的,得通过调试工具或者打印日志才能定位。这一节把我实际遇到的高频问题列出来,附上排查思路,类似的问题可以直接照方抓药。4.1 进阶14最容易出现的竞态问题进阶14第一版写完,我兴奋地跑了测试用例,结果控制台输出的是乱序的结果。我当时很困惑:代码里明明建了results数组,为什么顺序不对?排查过程是这样的。我打印了执行日志,发现task-1还没执行完,task-2就已经开始了。这说明并发上限并没有真正限制住。看了代码才发现,我把currentIndex的递增放在了await tasks[index]()之后,也就是说,worker取完一个任务之后,要等这个任务执行完,才去取下一个任务。这会导致实际的并发数量始终为1,因为每个worker都在“取任务—执行—再取任务”的串行循环里。但当limit设置为2时,第二个worker已经启动了,两个worker各自串行执行,结果看起来就是并发2,但任务分配方式完全错了。正确做法是:在调用异步任务之前,先把索引取出来并递增。也就是:const index = currentIndex; currentIndex++;这实际上是一个“预分配”的思路:任务一旦被取走,就标记为已分配,其他worker不会重复获取。修改之后,并发数才真正被限制在了limit以内,结果顺序也正确了。这个问题属于典型的竞态条件。平时写业务代码时异步任务少,并发控制不复杂,但一旦任务量大或执行时间不固定,这种问题就会暴露出来。排查思路也很明确:打印日志看任务开始的时间戳、结束的时间戳,以及任务序号,一下子就能看出问题。4.2 进阶15排序的稳定性陷阱进阶15我一开始以为简单,结果踩了一个很隐蔽的坑:日期排序时,如果日期字符串格式不统一,排序结果会非常混乱。测试数据里有2023-05-12,也有2023/05/12,当时没在意。结果排序之后,日期顺序变成了2023/05/12、2021-07-19、2023-05-12这种明显不对的结果,排在前面的反而是更大日期。排查的时候我先在浏览器控制台里手动输入:new Date('2023/05/12').getTime(); new Date('2023-05-12').getTime();两个值不一样,才意识到不同的日期解析规则会产生不同的时间戳,甚至有些格式在部分浏览器里直接返回NaN。修复方式是先统一格式再转换为时间戳:function normalizeDateString(str) { return String(str).replace(/[/.]/g, '-'); }然后再走new Date(normalizeDateString(value)).getTime()。这样各种常见的分隔符都不会再影响排序结果。4.3 浏览器控制台与断点调试的经验这三道题的调试,我大部分时间都在浏览器控制台里完成,有些经验值得分享一下。善于使用console.table。排序列排序结果时,用console.log输出整个数组会看得眼花缭乱,但用console.table输出成表格,一目了然。这是个很小的习惯,但能节省大量时间。学会手动制造边界数据。不要只测试“正常数据”,要主动构造“重复日期”“相同年龄”“空字符串”“null值”等等。进阶13中的NaN去重,进阶14中的空任务数组,进阶15中的全同数值列,都是用这类数据测出来的问题。善用断点而不是console.log。当某个函数的调用逻辑很复杂时,console.log容易漏打或者打错位置。我一般会在关键函数入口和出口打两个断点,通过浏览器DevTools的调用堆栈判断函数被谁调用了、参数是什么、返回值是什么。进阶15的事件委托逻辑里,有一次点击表头没反应,我用断点一看,原来是事件对象在某个步骤被意外修改了target指向,导致closest('th')返回null。这种问题用console.log很难定位。下面是我整理的一个高频问题速查表,收录了做这三道题时最容易碰到的坑和对应的解决方式:问题现象根本原因解决办法去重后NaN保留了两个NaN === NaN为false用Object.is或Set的SameValueZero规则对象去重时循环引用报错JSON.stringify遇到循环引用崩溃加try/catch,回退到引用地址判断并发任务结果顺序混乱用push收集结果,顺序跟随完成时间用索引数组按任务下标写入结果单个任务失败导致整体中断未处理单个任务的reject在任务外层加try/catch或使用allSettled的思路并发限制不生效索引递增放在await之后在调用任务前先取索引并递增日期排序结果混乱日期字符串格式不统一先统一分隔符再转时间戳中文排序结果奇怪直接使用>比较字符串使用localeCompare并指定语言环境点击表头排序没反应事件target不是预期的th元素使用closest('th')判断大量数据渲染卡顿反复使用innerHTML操作DOM使用DocumentFragment一次性插入点击同一列二次排序无变化排序状态没有正确切换检查sortState的方向切换逻辑这张表我贴在工位上,后面写其他项目时也一直在参考。它记录的不只是三道题的解法,更是一类问题的处理模式。5. 做完三道题后,我留下的验收清单与后续扩展方向进阶题做完不等于结束,至少要把“完成”定义清楚。如果只是代码能跑、样例能过,那不叫完成。我自己总结了一道进阶题算真正做完的标准。5.1 一道题是否真正完成的验收标准我的验收标准有四条,缺一条我就会觉得心里没底。第一,常规场景能跑通。这是最基本的,主流程必须没问题。第二,边界场景不崩。空数组、全相同数据、null、undefined、NaN、特殊字符、极端大值、极小值,能想到的边界都要测一遍。进阶13和进阶14的很多bug都是在边界场景里暴露的。第三,代码可读、可维护。如果你的函数超过一百行还没有拆分,或者变量命名全是temp1、temp2,那即使功能正确,后面也会成为负担。进阶15的排序组件,如果一开始就把排序逻辑、渲染逻辑、事件逻辑全部揉在一起,后面想加一列都会很痛苦。第四,能回答“为什么这样写”。任何一段关键代码,都要能说清楚为什么要用这个方法,替代方案是什么,为什么不用。进阶14里为什么不用Promise.allSettled,进阶15里为什么用事件委托而不是每列单独绑定,这些都是一道进阶题真正的价值所在。我当时给进阶13、14、15分别做了验收:进阶13:跑通基本去重、对象去重、iteratee去重、compare去重、NaN、循环引用,全部通过;进阶14:并发控制生效、结果顺序正确、单个任务失败不影响整体、空任务数组返回空数组,全部通过;进阶15:点击表头升序/降序切换、日期格式兼容、中文按拼音排序、大数据量流畅渲染,全部通过。5.2 从十三到十五:能力的递进关系最后聊聊这三道题给我的整体感受。进阶13解决的问题是“怎么把一堆数据整理成符合预期的样子”,进阶14解决的问题是“怎么让一堆异步任务有秩序地执行”,进阶15解决的问题是“怎么让用户在页面上直观地感受到数据的变化”。单独看,它们是三道独立的题;放在一起看,它们其实是一个完整功能的缩影。现实中的前端开发,很多需求拆开之后就是这三件事:拿到数据要清洗去重(像进阶13),请求接口时要控制并发防抖(像进阶14),拿到数据后要渲染成用户能交互的界面(像进阶15)。这三道题做完之后,我最大的体会是:进阶题真正考的不是知识点,而是边界意识。基础题问“这个API怎么用”,进阶题问“这个API在什么情况下会失效”。如果你只满足于“样例能跑”,那你就错过了一半的练习价值。把时间花在边界测试、状态管理、代码组织上,才是进阶题最值得投入的地方。后续我打算在这个表格排序组件的基础上继续扩展,加一个搜索过滤框,再做一版支持多列排序的升级版。如果你也刚做完这三道题,建议不要急着往下一节走,先跑一跑我上面列出的测试数据,看看你的实现能不能扛住边界情况。很多时候,正是这些不起眼的边角数据,才是区分“会写”和“写好”的分水岭。