我见过太多新人拿到 "web前端小实训面试题01" 这种文件名的第一反应:先去百度搜答案,然后把某个仓库的 README 背下来。实际上真正在面试现场写过这套题的人都知道,它考察的从来不是"你背过什么",而是"你能不能在一个小时内,像干活一样把页面搭出来,并且能讲明白每一步为什么这么做"。这篇文章就以我常用的第一套小实训面试题为底,拆解它的设计逻辑、完整实操流程,以及我在批改几十份作业后总结出来的高频翻车点。如果你正准备找前端实习或刚转行,建议从头到尾看完,最好能跟着敲一遍代码。
1. 先搞清楚这套题在考什么
1.1 题目背景与交付物
这套面试题虽然叫"小实训",但它的考核时长被严格控制在 60 到 90 分钟,要求最终交付三个东西:一个可本地打开的单页应用、一段核心函数的实现代码、以及用口头方式讲清楚某个页面的渲染过程。很多新人看到"待办事项列表"这个题目觉得简单,结果一动手就暴露了真实水平:没有语义化标签、布局靠 flex 硬撑、事件绑定时 this 指向错误、防抖函数写出来一调用就报错。这套题的目的不是为难人,而是通过一个足够小的场景,快速判断一个人是否具备独立完成前端小任务的能力。
我一般会给考生一个残缺的 HTML 文件,里面已经写好了待办事项的数据结构和基础样式,但结构混乱、没有事件交互,需要他们补齐。文件里明确标注了三个必做项:重构 HTML 使其语义清晰、用纯 CSS 实现一个左侧菜单加右侧内容的双栏布局、手写一个带取消功能的防抖函数用于输入搜索。题面看起来是三个独立任务,实际上是一个完整的小项目拆解,考察的是从接到需求到落地拆解的综合能力。
1.2 三个核心素养:语义化、布局手感、异步直觉
先说语义化。这道题里特意放了一段没有任何header、nav、main、aside、footer的 div 嵌套结构,所有标签都是<div class="box">。为什么要把这个作为第一关?因为语义化是搜索引擎、屏幕阅读器和后续维护的基础,一个只会在页面上用 div 堆内容的人,很难在真实项目里和别人协同。面试官不需要你背出所有 HTML5 标签,但至少能看到ul用于列表、button用于可点击操作、label关联表单控件,这种肌肉记忆不是临时能补出来的。
第二个是布局手感。双栏布局有很多种实现方式,float、flex、grid、绝对定位都能做,但不同方案的适应性和代码可维护性差别很大。这套题要求左侧栏固定 240px,右侧自适应的双栏布局,我见过不少人直接用flex加flex: 1,这当然没错,但追问一句"如果左侧栏内容超过视口高度,右侧怎么独立滚动"就答不上来了。布局手感不是背代码,而是理解每个属性的约束条件,知道什么场景用什么方案。
第三个是异步直觉。防抖函数是前端最常见的手写题,但它只是表象,真正考的是对闭包、this、定时器和作用域的理解。这套题把防抖放在一个"输入即搜索"的场景里,要求函数能取消,并且在连续输入时只触发最后一次。如果能把取消功能实现出来,说明你是真的会了,而不是背了一个模板。
1.3 时间分配建议
我建议拿到题目先花 10 分钟通读全部代码和需求,不要急着敲键盘。很多考生栽在时间分配上,前 40 分钟纠结一个装饰性动画,后面核心功能没写完。这套题的合理时间分配是:前 10 分钟梳理结构、构思布局方案,中间 30 分钟完成 HTML 重构和 CSS 双栏布局,再用 20 分钟完成防抖函数和搜索联动,最后 10 分钟做自测和整理讲稿。如果某个环节卡住超过 10 分钟,先跳过,把能拿的分拿到。面试官关注的不是你某一题是否完美,而是整体完成度和解决问题的思路。
2. HTML 与 CSS:一套双栏页面直接手写
2.1 语义化重构:别再用 div 一把梭
第一道实操题,我提供的项目骨架里有这样的代码:<div class="header">待办清单</div>、<div class="content">里面有无数嵌套<div>。正确的重构方式是把它改成如下结构:
<header> <h1>待办清单</h1> </header> <div class="layout"> <aside class="sidebar"> <nav aria-label="分类导航"> <ul> <li><button>全部</button></li> <li><button>进行中</button></li> <li><button>已完成</button></li> </ul> </nav> </aside> <main class="main"> <section aria-labelledby="todo-title"> <h2 id="todo-title">待办事项</h2> <ul id="todo-list"></ul> </section> </main> </div> <footer> <p>双击待办事项可标记完成</p> </footer>注意几个细节:导航里的每个分类在语义上不是链接而是筛选按钮,所以用button而不是<a>;列表使用ul;aria-labelledby让屏幕阅读器能把标题和内容区域关联起来。这些东西不会影响视觉样式,但能体现出你对"页面是给所有人用的"这个基本认知。很多新人会漏掉footer,其实语义化不是越多越好,而是清楚区分页头、主体、页脚,让结构一目了然。
双栏布局这里,我强烈建议用flex而不是float。float不是不能用,但清除浮动、子元素高度计算、响应式适配都很麻烦。下面这个方案是最稳的:
.layout { display: flex; align-items: flex-start; min-height: calc(100vh - 100px); } .sidebar { flex: 0 0 240px; width: 240px; position: sticky; top: 0; height: 100vh; overflow-y: auto; } .main { flex: 1; min-width: 0; }这段代码里最重要的不是flex: 1,而是min-width: 0。很多人只写flex: 1,结果右侧内容如果有一个很长的英文单词或者固定宽度的表格,flex 项目默认会基于内容撑破容器。加上min-width: 0后,右侧才能在宽度不足时真正收缩。position: sticky可以让侧栏在页面滚动时固定在顶部,配合height: 100vh和overflow-y: auto,实现左右独立滚动的效果。如果面试官问"右侧滚动时左侧为什么不动",答案就是 sticky 和独立 overflow 的配合。
2.2 CSS 动画与进度条细节
这套题另一个容易翻车的小地方是给"待办完成度"做一个进度条。题目要求:进度条宽度随完成比例变化,且变化过程要有 0.3 秒的平滑过渡。新人常见的错误是把过渡写在宽度变化之后,希望用 JS 控制动画,其实纯 CSS 就能解决:
.progress { height: 8px; background: #eee; border-radius: 4px; overflow: hidden; } .progress-bar { height: 100%; background: #4caf50; width: 0; transition: width 0.3s ease; }渲染性能的细节在于不要用left或transform模拟所有动画,这里宽度变化会触发 layout,但待办列表是小规模页面,性能影响可忽略。真正的加分项是理解overflow: hidden在这里的作用——它把进度条子元素的圆角裁剪到父容器内,避免进度条宽度变化时边缘溢出。如果不用overflow: hidden,进度条在 90% 宽度时左侧是直角、右侧圆角,看起来非常不自然。
在面试现场我还会让考生解释transition: width 0.3s ease的ease函数,很多人口头能说出来"先快后慢",但不知道如何查看实际效果。建议在浏览器开发者工具的性能面板里录制一次宽度变化,观察是否有长任务或者布局抖动。如果页面元素很多,可以改用transform: scaleX()配合transform-origin: left,这样可以避免触发布局,但这道题里不是硬性要求。
2.3 布局选型的取舍思路
有考生会问:用 grid 不是更好吗?确实,这个双栏布局用 grid 也能实现,而且代码更简洁:
.layout { display: grid; grid-template-columns: 240px 1fr; min-height: 100vh; }但 flex 和 grid 的取舍不是谁先进选谁,而是看团队维护习惯和场景复杂度。如果整体页面只有左右两栏,flex 的编写门槛更低,后续新增内容不容易打乱网格定义。如果布局是"左侧 240px、右侧顶部一个头部、头部下方两列卡片",grid 的优势才会更明显。回答问题的时候,不要只说"我用 grid 写的",把选择依据讲清楚,这正是面试官想听到的东西。
3. JavaScript 基础:闭包、防抖与原型链
3.1 防抖函数为什么必须带 cancel
题目中的防抖函数要求用原生 JS 实现,不能使用 Lodash。常规防抖写法是这样:
function debounce(fn, delay = 300) { let timer = null; return function (...args) { if (timer) clearTimeout(timer); timer = setTimeout(() => { fn.apply(this, args); }, delay); }; }这个版本能应付 90% 的背诵型面试,但实训题里要求带取消功能,其实是额外考验你是否清楚防抖的边界情况。取消功能通常用于组件卸载时清理定时器,防止回调在组件销毁后仍然执行并引发状态更新。升级版是这样的:
function debounce(fn, delay = 300) { let timer = null; function debounced(...args) { if (timer) clearTimeout(timer); timer = setTimeout(() => { fn.apply(this, args); timer = null; }, delay); } debounced.cancel = function () { if (timer) clearTimeout(timer); timer = null; }; return debounced; }这里有个关键细节:fn.apply(this, args)中的this是通过debounced调用时的宿主对象确定的,而不是定义时确定的。很多人在面试时能写出第一版,但问"为什么这里要用apply"就愣住了。原因是setTimeout内的回调在非严格模式下this指向window,不传递this的话,如果原函数内部访问了调用对象的属性,就会拿到错误值。至于args,使用剩余参数是为了让输入框事件里的event对象能传给处理函数。
3.2 从事件循环看经典输出题
面试题里还包含一道口述题:输出下面代码的执行结果,并说明原因。
console.log(1); setTimeout(() => { console.log(2); }, 0); Promise.resolve().then(() => { console.log(3); }); console.log(4);正确答案是1, 4, 3, 2。解释的关键是事件循环的分层:同步代码先执行,微任务在宏任务之前执行。setTimeout属于宏任务,Promise.then属于微任务。这里最容易出错的点,是新人误以为setTimeout(..., 0)会立即执行,但浏览器端定时器的最小延迟时间约为 4ms,而且即使0ms,它也会被放到下一次宏任务里。
如果面试官继续追问"如果 Promise 里又嵌套一个 Promise 呢",本质还是考察微任务队列的出队顺序。我建议在讲这类题的时候,永远用"同步代码 -> 微任务 -> 宏任务"这个顺序来口头推演,而不是死记答案。掌握这个框架以后,输出题基本不会错。另一个高频变体是在setTimeout里调用Promise.resolve(),这时会先清空当前宏任务里的微任务,再进入下一个宏任务,理解这点比记住任何具体例子都重要。
3.3 原型链与 this 的纠缠
这套题里另一个必问题:写出以下代码的输出结果。
function Person(name) { this.name = name; } Person.prototype.sayName = function () { console.log(this.name); }; const p = new Person("Tom"); const fn = p.sayName; fn();输出结果是undefined,因为fn被剥离了p这个调用上下文,this指向全局对象。在浏览器非严格模式下是window.name,如果window.name恰好有默认值,会输出那个值。很多新人在这里栽跟头,是因为没有理解this的绑定规则:默认绑定、隐式绑定、显式绑定、new 绑定。当函数作为对象方法调用时,this指向对象,但一旦把它赋值给一个独立变量,隐式绑定丢失,就变成了默认绑定。
这道题的另一个版本是问Person.prototype.constructor指向谁。答案是Person,但要提醒的是,如果你重写了Person.prototype = { sayName() {} },constructor就不再指向Person了。我见过新手把"原型对象上的 constructor 属性一定会指向构造函数"当成真理,其实只有默认原型才保持这个关系。回答这类问题的时候,最好在纸上画一下p.__proto__ === Person.prototype和Person.prototype.constructor === Person两条线,思路会清晰很多。
4. 浏览器层:渲染过程与性能优化
4.1 输入 URL 到首屏渲染的链路
口试题里一定有一条:"浏览器从地址栏输入网址到页面展示,发生了什么?"很多人的回答是"发请求、拿 HTML、解析、渲染"八个字,这远远不够。一个完整且让面试官满意的回答应该包含:输入 URL 后先进行 DNS 解析得到 IP 地址、建立 TCP 连接并发起 HTTP 请求、服务器返回 HTML 和相应资源、浏览器解析 HTML 生成 DOM 树、解析 CSS 生成 CSSOM 树、合成渲染树、计算布局、绘制像素、合成图层并显示在屏幕上。
我习惯用一条时间线把过程串起来:先拿到 HTML,边解析边遇到外部 CSS 或 JS;CSS 会阻塞渲染,JS 会阻塞解析,所以要合理放置标签;HTML 解析完成后先触发 DOMContentLoaded,等所有资源加载完再触发 load 事件。回答时如果能主动提到"CSS 阻塞渲染但不会阻塞 HTML 解析"和"普通 JS 会阻塞 HTML 解析,async 与 defer 的区别",就已经超过 80% 的考生。这套实训题的面试官通常还会追问"为什么把 script 标签放在 body 尾部",所以这里要提前准备好。
4.2 关键渲染路径优化
在实训项目的代码里,如果考生把<script>放在了head里,我会直接扣分。一个待办清单页面虽然只有一个纯文本逻辑,但代码习惯很重要。正确的优化方式有两种:把脚本放到body底部,或者给外部脚本加defer。defer和普通底部脚本的区别在于执行时机,defer在文档解析完成后、DOMContentLoaded之前执行,保证 DOM 已经解析完;而async是加载完立即执行,执行顺序不保证,所以依赖 DOM 的脚本不要用async。
这个题看起来不是"题目",但它出现在代码走查环节,权重不低。CSS 方面,如果一个页面有多张背景图和多个样式表,应该尽量合并请求,因为浏览器针对同一域名下的并发连接数是有限的。待办事项这种小页面不需要过度优化,但你要能说出content-visibility可以跳过屏外渲染、loading="lazy"可以直接写在img标签上,这就说明你有性能意识。
4.3 性能体检清单
最后一个浏览器层的任务是让考生打开开发者工具的 Lighthouse 面板,跑一次性能测试并解释结果。这是实训题里最接近真实工作的一环。我建议按这个顺序自查:先看 First Contentful Paint(FCP),再看 Largest Contentful Paint(LCP),最后看 Cumulative Layout Shift(CLS)。CLS 对新手来说比较陌生,它衡量的是页面加载过程中元素位移程度,比如图片没有占位导致文字跳动、异步渲染导致表单移位,都会拉高 CLS。
在待办清单这个小项目里,最容易引起 CLS 的是异步往列表里插入数据时没有预留列表高度。解决办法是在数据请求前先给ul设置min-height,或者在生成列表后把高度变化用transition过渡掉。性能优化这个部分不是要求你把分数做到 100,而是要有"我能发现并定位问题"的基本功。哪怕你的优化只是给图片加了一行width和height,也值得写在自述里,因为这证明你在用工程思维做事。
5. 实训全流程实操与复盘
5.1 拿到题先画草图再写代码
我在带人的时候一直强调,前端的实训题不是"把题做完",而是"模拟一次小需求开发"。正确流程应该是:读完题以后,先在草稿纸上画出页面的基本框架,确定哪些数据需要展示、按钮什么交互、跳转是什么逻辑。以这套待办清单为例,第一步不是写代码,而是画出 header、侧栏、主区域三块,并列出交互点:点击分类按钮筛选列表、输入框输入时防抖搜索、点击复选框切换完成状态、双击条目删除。交互点理清以后,再去写 HTML 结构,这样代码结构不会乱。
有同学图省事,一上来就只写一个 HTML 文件,把所有布局和交互都堆在一起。小项目能跑通,但一旦面试官要求"把数据请求改成异步"或者"再增加一个标签页",代码就会迅速失控。更合理的做法是把 CSS 单独放到style.css,把 JS 放到main.js,在 HTML 底部引入defer的脚本。模块划分不是繁文缛节,是协同的基础。
5.2 代码验收清单
我建议在提交前对着下面的清单自查一遍:
- HTML 中是否还有多余的
div包裹? - 每个按钮是否有响应鼠标和键盘的能力?原生
button比div加onClick更合适。 - 双栏布局在窗口宽度变窄时是否出现横向滚动条?
- 防抖函数是否能在连续触发时正确清除上一次定时器?
- 取消功能是否真的生效?用一个已卸载组件的模拟测试即可。
- 浏览器控制台是否报错?尤其注意
Cannot read property ... of undefined这类原型链问题。 - 列表渲染出来的 DOM 是否用事件委托绑定,而不是每个
li都加一个监听器?
事件委托是面试中特别爱问的点,简单来说就是把监听器绑定在父容器ul上,点击目标通过event.target匹配具体按钮。这样新增条目不需要重新绑定事件,性能更好。示例代码可以这样写:
todoList.addEventListener("click", (e) => { const item = e.target.closest("li"); if (!item) return; if (e.target.classList.contains("delete-btn")) { item.remove(); } });使用closest能避免e.target命中内部子元素时找不到父级li的问题,这是新手最容易忽略的坑。记得在代码注释里写明为什么要用事件委托,面试官会看注释来表达习惯。
5.3 复盘与追问准备
提交完代码后,还有最后一个环节:向面试官演示,并讲解你的实现思路。很多新人觉得代码写完就万事大吉,结果让他讲一下防抖函数内部发生了什么,就支支吾吾。我建议你在演练时反复问自己几个问题:为什么这么设计?如果超过约定时间怎么处理?如果把数据换成从接口请求回来,代码要改哪些地方?这些问题都不会超出你代码本身的边界,只要是自己一行一行写出来的,基本都能答。
如果有的地方确实没用过,比如对grid不够熟悉,宁可说"我了解 grid 但是在这个场景下 flex 更简洁",也不要硬说自己很懂。面试官更看重诚实和能力边界感,瞎编一个原理,追问三句就露馅了。
6. 高频错误与排查经验速查
6.1 必挂的五个低级错误
我把这些年批改实训题时最常见的错误列成一个速查表,你在提交前可以逐条核对:
| 错误表现 | 出现原因 | 解决办法 |
|---|---|---|
| 页面左侧栏随内容整体滚动 | 没有用 sticky 或固定定位 | 给侧栏加position: sticky并设height |
| 输入框连续触发搜索请求 | 防抖没写或没生效 | 检查返回的是否是函数,而非立即执行结果 |
点击按钮后this指向 window | 事件回调剥离了上下文 | 用箭头函数或在绑定前bind |
| 列表新增项点击无效 | 事件绑定在初始化时完成,新增项没有监听 | 用事件委托 |
| 页面加载时 CSS 闪烁 | 样式放在 JS 之后加载 | 将 CSS 放到 head 中优先加载 |
第五个错误很多人不理解,因为link标签放哪里看似无所谓。实际上浏览器渲染 HTML 是边解析边处理的,如果 CSS 在页面底部,页面初期会先渲染无样式内容,造成闪烁,也就是 FOUC。所以 CSS 放头部,JS 放尾部,是前端最基础也最重要的代码习惯。
6.2 现场调试技巧
面试实训过程中你会被允许使用开发者工具,但要注意区分"调试"和"依赖工具找答案"。正确的调试顺序是:先在控制台输出关键变量的值,确认数据到了哪一步;再查看 Network 面板确认请求是否发出;最后用 Elements 面板检查生成节点的 class 是否被命中。我见过不少考生遇到样式不生效时,第一反应是改代码,而不是先看 Elements 里有没有对应类名。实际上 80% 的样式问题都是选择器优先级或者类名拼写错误,Elements 面板一眼就能看出来。
如果代码逻辑报错,不要直接在整个文件里加console.log,可以用断点调试。Chrome 的 Sources 面板可以给代码行添加断点,逐行查看this的值、作用域链里的变量、调用栈。这个技能对新人有点门槛,但面试官看到你会用断点,会非常加分。调试的价值不是好看,而是能帮助你系统梳理代码执行顺序。
6.3 如何应对追问:别急着给结论
面试官追问通常不是为了难倒你,而是想看你的思考路径。比如问"防抖和节流有什么区别",你如果直接说"防抖是只执行最后一次,节流是固定频率执行",没有错,但是太单薄。更好的回答是先打个比方:电梯门关闭前如果有人进来就重新计时,这是防抖;地铁每隔固定时间发一班车,不管有没有人,这是节流。然后再回到代码层面:防抖适合输入搜索、窗口 resize 停止后统一处理,节流适合滚动事件、按钮点击限制频率。这样既通俗又能展示你理解应用场景。
面对不确定的问题,可以先说"我不确定,但我可以从原理上推断一下",然后给出推理过程。这在技术面试中是加分项,比沉默或硬编一个答案强十倍。这背后反映的能力,恰恰是这套实训题想考察的:遇到没做过的事情,你敢不敢基于已有知识去拆解它。
我在带 A 同学准备这套题的时候,让他把所有任务拆成最小单元,一个函数、一个样式、一个交互,逐个验证,最后他只用了 45 分钟就完成了全部功能。相反,那些一开始就想着"我要写一个完美框架"的人,反而容易栽在基础细节上。如果你最近也在准备入职或实习的面试,不妨就把这套题当成一个小项目来做,不要背答案,而是真的去经历一遍从需求到交付的过程。等你把页面搭起来、把防抖调通、把渲染链路讲明白的时候,你收获的不只是一份面试作品,而是前端工程师处理问题的基本节奏。