商汤科技2018校招Web前端开发工程师笔试第一场,这个话题放到现在来看依然很有的聊。虽然那是五年前的老题了,但每一年都有准备校招的同学翻出来研究,原因很简单:商汤作为AI公司里的技术派,它的前端笔试考的东西不一定难,但考得很杂、很细,尤其看重基本功和手写代码能力。我当初也刷过很多AI公司(旷视、依图、商汤)的笔试题型,发现它们的出题套路高度一致,就是“基础题+手写题+少量算法”的组合拳。这篇文章我把这场笔试的题型结构、高频考点、典型题目和解题思路完整复盘一遍,也结合我后来实际带人和面试的经验,补充了一些真实的踩坑记录。不管是正在准备秋招的应届生,还是工作两三年想跳槽去AI公司的前端,这套内容都值得花一个下午认真过一遍。
1. 笔试整体结构与考点分布
1.1 这场笔试到底考什么
商汤这场笔试的题量不算大,但覆盖范围非常广,整体来看分为三大块:选择题/填空题、手写代码题、简答题。选择题和填空题集中在JavaScript基础、浏览器机制、网络协议、CSS布局这些方向上;手写代码题是重头戏,占比最高,基本每题都需要在编辑器里独立实现一个功能;简答题则考察对工程化、框架原理的理解。
我根据参加过的同学回忆和网上流传的题目还原,整理了一个考点分布表:
| 考点方向 | 大致占比 | 常见出题形式 |
|---|---|---|
| JavaScript基础(闭包、原型、this、异步) | 30% | 选择题、手写题 |
| 浏览器与网络(缓存、事件循环、渲染) | 20% | 选择题、简答题 |
| CSS布局与样式 | 15% | 选择题、手写题 |
| 框架与工程化 | 20% | 选择题、简答题 |
| 算法与逻辑 | 15% | 手写题 |
看到这个分布就能发现一个规律:JS基础是绝对的核心。这其实代表了2018年前后AI公司前端招聘的主流审美,不考花哨的框架API,而是疯狂试探你对语言本身的理解。原因也合理,AI公司的前端工程师往往要做数据可视化、标注平台、内部工具这类复杂业务,框架可以快速上手,但语言功底决定了能不能写出稳定、高效、可维护的代码。
1.2 为什么AI公司的笔试这么考
很多同学会困惑,商汤不是搞人工智能的吗,怎么前端笔试全是JS底层、手写机制?这里需要理解AI公司前端的真实工作场景。AI公司的前端跟互联网公司不一样,业务往往集中在三块:算法效果展示平台、数据标注工具、模型训练可视化看板。这三类系统的共同特点是交互复杂、数据量大、需要处理大量异步逻辑和高频渲染。
拿数据标注工具举例,一张图片上的标注框要支持拖拽、缩放、撤销,还要保持canvas和DOM的状态同步,这种场景下闭包、事件循环、内存管理能力直接决定功能能不能做出来。再比如训练看板,需要实时拉取训练日志并用图表展示,这考验网络请求管理和渲染性能优化的能力。笔试考的那些东西,表面上像是“八股文”,实际上每一道题都能映射到这类业务场景中。
还有一个很实际的原因:2018年正是前端工程化快速演进的时期,Vue和React已经普及,但市场上大量前端开发者停留在“会用框架”的阶段,对原理一知半解。商汤这类公司需要通过笔试快速筛掉“只会写页面”的候选人,考查语言基础是最高效的方式。所以这不是为了难为人,而是为了过滤。
2. JavaScript基础,怎么考都不为过
2.1 原型链与继承,一道题看出功底
原型链是JavaScript笔试的“钉子户”,商汤这场也不例外。我当时根据同学的回忆,整理出来的典型题目是这样的:
function Parent() { this.name = 'parent'; this.say = function() { console.log('I am ' + this.name); }; } Parent.prototype.sayName = function() { console.log(this.name); }; function Child() {} Child.prototype = new Parent(); const child = new Child(); child.sayName(); child.say();题目多半会问:上面的代码输出什么?child.sayName()是否能正常执行?child.say()和child.sayName()有什么区别?
拆解一下考点。Child.prototype = new Parent()这种写法是经典的原型式继承,这样Child.prototype就指向了一个Parent实例,因此child实例通过原型链可以访问到sayName方法。而say是Parent构造函数内部的属性,它的值是在创建Parent实例时赋值给实例自身的,所以Child.prototype这个Parent实例身上有say属性,child访问say时通过原型链也能拿到。
但是这里有一个隐藏的坑:如果代码改成Parent.prototype.say = function() {...},那child.say()和child.sayName()的行为是一样的,因为它们都定义在原型上。区别在于,构造函数内定义的属性在每个实例上都会复制一份,原型上定义的属性是所有实例共享的。笔试考这种题,就是为了看你能不能准确区分“实例属性”和“原型属性”。
还有一个高频变体是手写new的过程,这道题我会建议每个前端都背下来,因为它综合考察了原型链、this指向和构造函数的返回值处理。一个成熟的答案大致长这样:
function myNew(Constructor, ...args) { // 1. 创建一个新对象,原型指向构造函数的prototype const obj = Object.create(Constructor.prototype); // 2. 将构造函数的this指向这个新对象,并执行 const result = Constructor.apply(obj, args); // 3. 如果构造函数返回了引用类型,则返回该结果,否则返回新对象 return (result !== null && (typeof result === 'object' || typeof result === 'function')) ? result : obj; }注意第三步的返回值判断很容易被忽略。如果构造函数内显式返回了一个对象,那么new出来的结果就是这个对象,而不是你创建的那个实例。这个边界条件在阿里、字节、商汤的笔试里都出现过,属于典型的“看着简单,写全了不容易”。
2.2 this指向和闭包,选择题的重灾区
this指向是选择题里最能拉分的点,考法千变万化。商汤这场出现过一道很经典的综合题,代码我整理成了这样:
var name = 'window'; var obj = { name: 'obj', fn: function() { console.log(this.name); } }; var fn1 = obj.fn; fn1(); obj.fn(); var fn2 = obj.fn; fn2.call(obj);这道题分别输出什么?答案是:window、obj、obj。
第一行fn1()是普通函数调用,this指向全局对象;第二行obj.fn()是方法调用,this指向obj;第三行call(obj)显式指定this。这里最容易出错的是第一问,很多同学会和第二问混淆,核心判断标准就是函数调用时.前面是谁,this就指向谁(在严格模式和箭头函数之外)。
不过真正有区分度的题目是闭包结合定时器的这个经典场景:
for (var i = 0; i < 5; i++) { setTimeout(function() { console.log(i); }, 1000); }运行结果是连续输出5个5。原因是var声明的i是函数作用域的,循环结束后i已经变成5,而定时器的回调函数需要等到循环结束才执行,此时访问的i是同一个变量。
如果改成let声明,则输出0到4,因为let每次循环都会创建一个新的绑定。手写这个问题的终极解法是使用闭包:
for (var i = 0; i < 5; i++) { (function(j) { setTimeout(function() { console.log(j); }, 1000); })(i); }这里闭包的作用是把每次循环的i值“冻结”在函数参数j中。我在面试别人的时候经常用这道题考察候选人,能准确讲出为什么用闭包、闭包到底捕获了什么的人,JS基础基本是扎实的。特别要注意的是,闭包捕获的是变量本身而不是变量的值,这是很多人理解上的最大误区。
2.3 深拷贝与数组去重,高频手写题
手写代码题里,深拷贝和数组去重出现的频率非常高,商汤这场把它们放在一起考,而且明确要求不能用JSON.parse(JSON.stringify())。这其实是个提示,出题人希望你理解更深层的原理。
数组去重有多重写法,笔试时推荐写出利用Set的一行版本,同时额外写一个复杂度更优的版本:
// 利用Set,简单高效 function unique(arr) { return [...new Set(arr)]; } // 利用object作为哈希表,O(n)复杂度,兼容性好 function unique(arr) { const map = {}; const result = []; for (let i = 0; i < arr.length; i++) { const key = arr[i]; if (!map[key]) { map[key] = true; result.push(key); } } return result; }第二种写法要注意,如果数组元素存在数字1和字符串'1',它们会被当作同一个key,导致去重出错,更严谨的方案是用Map,但笔试时如果没特殊要求,能写出思路并说明局限即可。
深拷贝这道题,核心考点是:基本类型直接复制、引用类型递归处理、数组和对象的区分、循环引用的处理。我整理了一个能应对大部分笔试的版本:
function deepClone(target, map = new WeakMap()) { if (target === null || typeof target !== 'object') { return target; } // 处理循环引用,避免递归死循环 if (map.has(target)) { return map.get(target); } const result = Array.isArray(target) ? [] : {}; map.set(target, result); for (let key in target) { if (target.hasOwnProperty(key)) { result[key] = deepClone(target[key], map); } } return result; }WeakMap用来记录已经拷贝过的对象,解决循环引用问题。严格来说这个版本还有改进空间,比如没有处理Date、RegExp、函数等特殊类型,但笔试时能写出递归、能指出循环引用问题、能说明JSON.parse(JSON.stringify())的局限(函数丢失、Date变成字符串、正则变成空对象),已经能拿到绝大部分分数了。不要在笔试中追求完美版本,老师看的是你的思路完整度和核心边界条件的处理能力。
3. 事件循环与浏览器机制,答错率最高的部分
3.1 事件循环输出题,先画栈再下笔
浏览器事件循环的题目几乎每次笔试都会出现,商汤这场也不例外。我的建议是遇到这种题,先在草稿纸上画出任务队列的调度过程,再写下输出。一道典型的题目是这样的:
console.log('1'); setTimeout(function() { console.log('2'); new Promise(function(resolve) { console.log('3'); resolve(); }).then(function() { console.log('4'); }); }, 0); new Promise(function(resolve) { console.log('5'); resolve(); }).then(function() { console.log('6'); }); console.log('7');正确答案是:1、5、7、6、2、3、4。
很多人会在第三部犯错误。需要注意new Promise的构造函数是同步执行的,所以console.log('3')会立即执行,而.then注册的回调是微任务,要等当前宏任务执行完后才进入微任务队列。setTimeout的回调是一个宏任务,而Promise的then是两个微任务,所以顺序是“宏任务1(同步代码+微任务)→ 宏任务2(setTimeout回调+它的微任务)”。
其中最容易答错的点是4为什么在3后面,很多同学以为resolve()后then马上执行,其实then中的回调是在当前宏任务代码执行完后统一调度的。我当时复习的时候总结了一句话:同步代码永远最先,微任务清空后才会执行下一个宏任务,宏任务内部又会嵌套新的微任务。
3.2 浏览器缓存与页面渲染
浏览器缓存也是选择题的常客。核心考点是强缓存和协商缓存的区别,2018年时Expires和Cache-Control并存,现在基本只用Cache-Control了。我整理了这道题的高频考法:
| 缓存类型 | 关键字 | 状态码 | 触发条件 |
|---|---|---|---|
| 强缓存 | Cache-Control / Expires | 200 (from memory/disk cache) | 未过期,不发起请求 |
| 协商缓存 | Last-Modified / ETag | 304 | 缓存过期/失效,需要服务端确认 |
回答这类问题时,除了把定义讲清楚,还应该补充一句:Cache-Control的优先级高于Expires,ETag的优先级高于Last-Modified。这些细节点是拉开分数的关键。
页面渲染流程那部分,商汤笔试问到过“从输入URL到页面渲染完成,发生了什么”,这是一个经典的综合性问题。完整的回答链条是:DNS解析 → TCP连接 → HTTP请求 → 服务端响应 → 浏览器解析HTML构建DOM树 → 解析CSS构建CSSOM树 → 合成渲染树 → 布局 → 绘制 → 合成。这里要特别强调DOM树和CSSOM树是并行解析的,JavaScript的加载和执行会阻塞DOM树的构建,所以script标签应该放在body底部或者使用defer、async属性。
4. 框架与工程化,检验实战能力
4.1 Vue/React那些必问的点
2018年笔试时Vue 2和React 15/16是主流,两道方向都有公司考。商汤的题里框架部分不算深,但有一道关于Vue双向绑定原理的题几乎每年都有人讨论,考点是Object.defineProperty。需要能说清楚:Vue 2通过Object.defineProperty劫持数据的getter和setter,在getter中收集依赖(订阅者),在setter中触发依赖更新(发布者)。
一个常考点是为什么Vue 2无法检测到对象属性的新增和删除,原因是Object.defineProperty只能劫持已经存在的属性,所以Vue 2提供Vue.set和Vue.delete来手动处理。这道题只要能把“依赖收集”和“视图更新”的关系讲清楚,面试官就基本认可了。
React方向如果考到,大概率是这些:key的作用是什么?为什么不能用index作为key?虚拟DOM是什么,解决了什么问题?setState是同步还是异步?核心就是:key用于高效的diff复用;index作为key在列表顺序变化或插入元素时会导致组件状态错乱;虚拟DOM的核心价值是让跨平台渲染成为可能,并通过diff减少真实DOM操作。这里我建议也了解一下setState在React 18里自动批处理的变化,虽然笔试考的是老版本,但你能主动提到新特性,会显得一直在跟进技术演进。
4.2 模块化与构建工具,从使用到原理
工程化相关题目更多出现在简答题或者选择题里,考法比较固定。CommonJS和ES Module的区别是一个极高频考点。简单来说:require是运行时加载、同步加载,import是编译时加载、静态分析基础,支持tree-shaking。另一个常考点是webpack中loader和plugin的区别,我当时的记忆方法是:loader是“翻译官”,负责把文件从一种语言/格式转换成另一种;plugin是“调味料”,负责在打包的各个生命周期里做额外的事情,比如压缩、注入环境变量、拆分代码。
笔试中如果遇到“如何优化webpack打包速度”这类问题,可以从这几个角度答:thread-loader做多线程打包、cache-loader做持久化缓存、DllPlugin预编译公共依赖库、合理配置resolve.modules减少搜索范围、使用mini-css-extract-plugin拆分CSS。能答出两三个点,并说明它们的作用原理,这道题就过关了。
5. CSS与页面布局,容易被忽视的送分题
5.1 水平垂直居中,五种姿势
CSS布局题在整个前端笔试中属于“简单却不简单”的板块。说简单,是因为方法多且固定;说不简单,是因为很多人只会用flex,问“还有没有别的方法”就卡住了。商汤笔试中有一道题是“实现一个元素在容器中水平垂直居中,至少写出两种方法”。
最常见的三种答案分别是:
/* 方法一:flex */ .parent { display: flex; justify-content: center; align-items: center; } /* 方法二:绝对定位 + transform */ .parent { position: relative; } .child { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); } /* 方法三:absolute + margin auto */ .parent { position: relative; } .child { position: absolute; left: 0; right: 0; top: 0; bottom: 0; margin: auto; }两种方法都各有适用场景。transform方案适合子元素尺寸未知的情况,但需要浏览器支持transform;margin auto方案需要子元素有明确的宽高或内容是块级元素。面试时如果能够补充一句“如果容器高度由内容撑开,flex的align-items会失效,需要显式设置高度”,会显得很有实战经验。
5.2 BFC,布局题的隐藏主角
BFC(块级格式化上下文)是CSS部分最有区分度的考点。常见问法是:什么是BFC?如何创建?解决了什么问题?我当时回答的思路是这样:BFC可以理解成一个独立的渲染容器,容器内部的元素布局不会影响外部,外部也不会影响内部。
创建BFC的常见方式有:overflow: hidden/auto、display: flow-root、position: absolute/fixed、display: inline-block、float等。BFC能解决的主要问题包括:清除浮动带来的父元素高度塌陷、避免margin垂直方向重叠、阻止元素被浮动元素覆盖。
笔试考BFC时,不要只背概念,最好能配合一个例子说明。比如经典的“父元素高度塌陷”问题:子元素全部浮动后,父元素没有撑开,解决方法是给父元素设置overflow: hidden或display: flow-root。能现场画一个简图并写代码演示,这道题基本就稳了。这里也提示一下,现在display: flow-root已经很好用了,创建BFC的标准方式应该优先考虑它,而不是依赖有副作用的overflow。
6. 算法与逻辑题,前端笔试的分水岭
6.1 字符串与数组操作类题目
AI公司的笔试里算法题比重比普通互联网公司略高,但难度基本在leetcode easy到medium之间。商汤这场出现过几道比较典型的题,我挑一道有代表性的说说思路:实现一个函数,判断一个字符串是否为合法的括号序列。
比如输入"()[]{}"返回true,输入"(]"返回false。解题思路是使用栈:
function isValid(str) { const stack = []; const map = { ')': '(', ']': '[', '}': '{' }; for (let i = 0; i < str.length; i++) { const char = str[i]; if (char === '(' || char === '[' || char === '{') { stack.push(char); } else { if (stack.pop() !== map[char]) { return false; } } } return stack.length === 0; }这道题考察的核心是“栈”这个数据结构的理解和应用。如果不给语言限制,前端笔试中很常见的题目还包括数组扁平化、字符串去重、找最大连续子串等。这些题目难度不高,但能看出候选人的编码习惯和边界处理能力。我建议遇到这类题先花30秒想清楚输入输出和边界条件(空字符串、全匹配、单括号),再开始写代码,能有效减少低级错误。
6.2 算法题的答题节奏
算法题忌讳上手就写,尤其是笔试时间有限的时候,方法不对会让你卡在一个细节上浪费大量时间。我给自己定的节奏是“三步走”:第一步,读题并划出关键条件(时间限制、空间限制、输入规模);第二步,先用暴力法想清楚逻辑,再考虑优化;第三步,写出代码并在脑中跑几个测试用例。
举个典型的优化题例子:两数之和。
暴力法是双重循环,O(n²);优化是使用哈希表,把时间复杂度降到O(n):
function twoSum(nums, target) { const map = new Map(); for (let i = 0; i < nums.length; i++) { const complement = target - nums[i]; if (map.has(complement)) { return [map.get(complement), i]; } map.set(nums[i], i); } return []; }笔试中如果能写出优化版,并主动解释“用空间换时间”的思路,会比只写暴力解的同学拿到明显更高的分数。在商汤这类AI公司,算法能力代表的可迁移的逻辑思维能力,他们宁可要一个算法基础好但框架经验少的人,也不想要只会写业务逻辑的人。
7. 复盘后的避坑心得与备考建议
7.1 笔试中最容易丢分的地方
复盘这场笔试,也复盘了周围很多同学的答题过程,我发现丢分点其实集中在几件“小事”上。
第一是手写代码不处理边界条件。很多同学写数组去重,主逻辑写完了,没考虑空数组;写深拷贝,主递归写完了,没考虑null;写防抖节流,没考虑初始调用。这些边界条件在笔试中占的分值不小,甚至会被单独标注为考察点。我的建议是写完之后,自己用实际输入在脑中跑一遍,空值、单元素、极端值都要过一遍。
第二是变量命名和代码注释。笔试不是只给机器看,更给面试官看。变量名用a、b、temp,不会减分但也不会加分;但如果函数名和变量名能清晰表达含义,并在关键逻辑处加一行注释说明思路,阅卷人会更容易抓到你的得分点。尤其是手写题,面试官通常要在很短时间内批改大量试卷,清爽的代码天然带有好感度。
第三是只会在浏览器里跑代码,不考虑Node环境。笔试现场通常要求提交纯JS代码,有些同学的代码里混用了window、alert、document,在浏览器里能跑,在Node或评测系统里直接报错。平时练习时就应该在两个环境里都试一下,尤其是那些涉及模块导出、require/import的题目。
7.2 针对这类笔试的备考策略
如果你正在准备前端校招或跳槽去AI公司,怎么复习才能在短期内拿到这套题的合格分?我整理了三条我自己验证过有效的策略。
第一,做知识树而不是刷题海。先把考点列出树状结构:JS基础、浏览器、CSS、框架、算法、工程化六大分支,每个分支下再细化到具体知识点。逐个节点检查自己“能说出来”和“能写出来”是两个层面,笔试考的是后者,所以每个知识点都要用代码验证过。
第二,手写代码要像背单词一样每天练。闭包应用、深拷贝、防抖节流、数组扁平化、事件循环输出题,这五类题我建议每天花30分钟各写一遍,连续两周效果非常明显。这些题出现频率高,而且思路相通,练熟之后能形成肌肉记忆,考场上紧张也不会完全写不出来。
第三,用真题做模拟考。找一套完整的笔试题目,限时90分钟,屏蔽所有干扰,模拟真实考试的节奏。做完之后对照答案复盘,重点看是“不会”还是“会但没时间写”。如果是后者,说明时间分配有问题,选择题耗费太久,导致后面的手写题没时间,建议先扫一遍全部题目,把有把握的题放在前面做。
另外要特别提醒的是,笔试中如果遇到不会的题,一定不要留白。手写题即使只写出大体思路或部分代码,也能拿到步骤分。我在批改笔试的时候,最怕的不是候选人写得不对,而是答得一片空白,因为你根本找不到依据给他分。哪怕只写一个关键函数的签名,或者写两行伪代码,都比空着强。
7.3 跨年份真题还能不能刷
很多人会问,2018年的题放到现在还有参考价值吗?我的回答是:考点层面非常有价值,但需要注意几个变化。
框架部分变化最大,Vue 2的Object.defineProperty已经被Vue 3的Proxy取代,key和diff的原理也发生了演进。答Vue原理相关题目时,如果还在讲老版本的实现,面试官会觉得你的技术栈陈旧。建议把Vue 2和Vue 3的响应式原理都学一遍,能说清楚两者的差异和各自的局限性,这反而是加分的亮点。
CSS部分变化不大,Flex和Grid依然是主流,注意新增的aspect-ratio、gap等属性的使用即可。JS基础部分基本没有变化,闭包、原型链、事件循环这些语言层面的东西不会过时,反而因为面试官自己也在用新框架,更依赖这些基础题来区分候选人的真实水平。
所以我的建议是:这套题要刷,但不要背答案,而是用“为什么”的视角去思考每个考点背后的原理,然后对照当前主流技术栈更新你的知识库。这样你掌握的不是某一年的真题答案,而是一套能够应对各种前端笔试的底层能力。
打个小广告式的建议,如果你是首次接触这类笔试,建议从JS基础的手写题开始练,尤其是防抖节流、深拷贝、数组去重这三件套,覆盖面广、难度适中、效果立竿见影。我当年就是先把这三道题练到闭着眼能写出来,再去攻克事件循环和算法题,整体的备考效率高了不少。