5 道颇具挑战性的前端面试题

本文最初发布于 Medium 博客,经原作者授权由 InfoQ 中文站翻译并分享。

去年我面试了多家科技公司的软件工程师职位。由于其中多数都是 Web 开发岗位,因此我当然要回答许多客户端开发方面的问题。有些问题很简单,比如:什么是事件委托?如何在 Java 中实现继承?还有一些是更具挑战性的上手编程问题,而在本文中我就会分享其中我最喜欢的 5 道面试题。

毫无疑问,面试成功的关键是做好充分的准备。因此,无论你是在积极参加面试,抑或只是有些好奇,想知道科技公司面试前端岗位时可能会问什么样的问题,这篇文章都能帮得上你的忙,让你为将来的面试打下更好的基础。

目录

  1. 模拟 Vue.js
  2. async series 和 parallel
  3. 能更改背景色的可拖动按钮
  4. 滑出动画
  5. Giphy 客户端

模拟 Vue.js

我在一次电话面试中遇到了这个挑战。对方让我转到 Vue.js 文档,并将以下代码段复制到我用的编辑器中:

复制代码


{{ message }}

复制代码

varapp = newVue({
el: '#app',
data: {
message: 'HelloVue!'
}
})

你大概能猜得到这里的目标是用 Hello Vue! 取代{{message}},当然不能将 Vue.js 添加成依赖项。

在开始研究代码之前,请务必与面试官交流,澄清你可能对问题抱有的任何疑问,并确保你完全理解输入、输出的内容,以及需要考虑的任何极端情况。

首先我们创建 Vue 类,并将其添加到 Javascript 代码段上方。

复制代码

classVue{
constructor(options) {
}
}

这样,我们的小项目至少应该能正确运行。

现在为了用提供​​的文本替换模板字符串,可能最简单的方法是,一旦我们可以访问#app 元素,就在其 innerHTML 属性上使用 String.replace():

复制代码

classVue{
constructor(options) {
constel =document.querySelector(options.el);
constdata = options.data;

Object.keys(data).forEach(key=>{
el.innerHTML = el.innerHTML.replace(
`{{${key}}}`,
data[key]
);
});
}

这样工作就完成了,但是我们绝对可以做得更好。例如,如果我们有两个名称相同的模板字符串,那么这个实现就无法按预期正常运行。只有第一次出现的字符串才会被替换。

复制代码


{{ message }}and{{ message }}, what's the{{ message }}

这很容易解决,我们使用一个正则表达式( https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp ),带有全局标记 newRegExp( {{ ${key}}} , “g”) 而不是 {{ ${key} }}

另外,innerHTML 开销很大,因为值会被解析为 HTML,所以我们应该使用 textContent 或 innerText。要进一步了解三者之间的区别,请看这里:

https://developer.mozilla.org/en-US/docs/Web/API/Node/textContent#Differences_from_innerText

对于我们的简单标记来说只需将 innerHTML 替换为 innerText 或 textContent 即可,但是一旦标记变得更加复杂就很快不够用了:

复制代码


{{ message }}
another{{ message }}inside a paragraph

你会注意到 < p> 标签将从 DOM 中删除。这是因为 innerText 和 textContent 仅返回文本,当我们将其用作 setter 时,它会将标记替换为仅文本。

一种解决方法是遍历 DOM,找到所有文本节点,然后替换文本。

复制代码

Vue {
constructor(options) {
this.el = document.querySelector(options.el);
this.data= options.data;
this.replaceTemplateStrings();
}
replaceTemplateStrings() {
conststack = [this.el];
while(stack.length) {
constn = stack.pop();
if(n.childNodes.length) {
stack.push(...n.childNodes);
}
if(n.nodeType === Node.TEXT_NODE) {
Object.keys(this.data).forEach(key => {
n.textContent = n.textContent.replace(
new RegExp(`{{ ${key} }}`,"g"),
this.data[key]
);
});
}
}
}
}

还有一件事情也需要我们改进。每次我们要找到一个文本节点时,我们都会查找模板字符串 n 次(在本例中 n 是数据条目的数量)。因此,如果我们有 200 个条目,即便我们的 DOM 节点实际上如此简单:

复制代码

Nothing to see here

我们仍将迭代 200 次来查找模板字符串。

解决这个问题的一种方法是实现一个简单的状态机,这个状态机只查看一次文本,并随即替换模板字符串(如果存在):

复制代码

<

pre>classVue{
constructor(options) {
this.el = document.querySelector(options.el);
this.data = options.data;
this.replaceTemplateStrings();
}
replaceTemplateStrings() {
conststack= [this.el];
while(stack.length) {
constn =stack.pop();
if(n.childNodes.length) {
stack.push(…n.childNodes);
}
if(n.nodeType === Node.TEXT_NODE) {
this.replaceText(n);
}
}
}
replaceText(node) {
lettext= node.textContent;
let result =””;
let state =0;// 0 searching template, 1 searching key
letcursor=0;
for(let i =0; i <text.length -1; i++) {
switch(state) {
case0:
if(text[i] ===”{“&&text[i +1] ===”{“) {
state =1;
result +=text.substring(cursor, i);
cursor= i;
}
break;
case1:
if(text[i] ===”}”&&text[i +1] ===”}”) {
state =0;
result +=this.data[text.substring(cursor+2, i -1).trim()];
cursor= i +2;
}
break;
default:
}
}
result +=text.substring(cursor);
node.textContent = result;

到这一步离生产就绪还差不少,但你应该能在大约 30-45 分钟的时间内完成。

一定要说说你下一步的改进方向,谈谈性能问题(顺便炫耀一把你的 VirtualDOM 知识),要是能进一步讨论如何实现循环和条件( https://vuejs.org/v2/guide/#Conditionals-and-Loops )并处理用户输入( https://vuejs.org/v2/guide/#Handling-User-Input )就更好了。

你可以在下面的沙箱中看到上面代码的运行效果(译注:平台所限无法展示原文的沙箱,请点击文末的原文链接查看沙箱运行效果,后同):

async series 和 parallel

在 RxJ、Promises 和 async/await 成为行业标准之前,编写 Javascript 异步代码并不是一件容易的事情,而且你经常会掉进回调地狱( http://callbackhell.com/ )里面。正因如此,像 async 这样的库诞生了。

接下来的两部分是我在一次现场面试中遇到的挑战。他们让我带上自己的笔记本电脑,所以我知道面试中会有现场编程环节。

async.series

async.series( http://caolan.github.io/async/v3/docs.html#series )会依次运行 task 集合中的函数,每一个函数运行完毕后开始运行下一个。如果序列中的任何函数向其回调传递了一个错误,则不会再运行任何函数,并且会立即使用这个错误的值调用 callback。否则,当 task 完成时,callback 将收到一个结果数组。

复制代码

async.series([
function(callback){
// do some stuff ...
callback(null,'one');
},
function(callback){
// do some more stuff ...
callback(null,'two');
}
],
// optional callback
function(err, results){
// results is now equal to ['one', 'two']
});

首先我们来创建一个异步对象:

复制代码

constasync= {
series:(tasks, callback) =>{}
};

这项挑战的主要内容是,我们需要确保函数是一个个执行的,换句话说我们只在上一个函数完成后才执行下一个函数:

复制代码

constasync= {
series:(tasks, callback) =>{
leti =0;
constresults = [];
const_callback =(err, result) =>{
results[i] = result;
if(err || ++i >= tasks.length) {
callback(err, results);
return;
}
tasksi;
};
tasks0;
}
};

我们使用一个变量 i 来跟踪正在执行的当前函数,并创建一个内部回调以检查错误、递增 i 并执行下一个函数。

简单起见,我们不会验证输入或使用 try/catch 来改善错误处理,但你应该同面试官谈到这些做法。

async.parallel

async.parallel( http://caolan.github.io/async/v3/docs.html#parallel )会并行运行函数的 task 集合,而无需等待上一个函数完成。如果任何一个函数将一个错误传递给它的回调,则立即使用这个错误的值调用主 callback。tasks 完成后,结果将作为一个数组传递到最终的 callback。

复制代码

async.parallel([
function(callback){
setTimeout(function(){
callback(null,'one');
},200);
},
function(callback){
setTimeout(function(){
callback(null,'two');
},100);
}
],
// optional callback
function(err, results){
// the results array will equal ['one','two'] even though
// the second function had a shorter timeout.
});

首先,向我们的异步对象添加一个新的并行函数:

复制代码

const async = {
series:(tasks, callback)=>{}
parallel:(tasks, callback)=>{}
};

parallel 与 series 有所不同,在某种意义上说我们可以同时触发所有函数,我们只需小心收集结果,将它们放置在数组的正确位置上。

复制代码

parallel:(tasks,callback) =>{
letdone=false;
letcount =0;
const results =[];
const _callback =(i,err,result) =>{
count++;
results[i]= result;
if(!done&&(err||count===tasks.length)) {
callback(err, results);
done=true;
return;
}
};
tasks.forEach((task,i)=> {
task((err, result) =>_callback(i,err,result));
});
}
};

我们从 done 标志开始,该标志可以防止在发生错误后调用回调,另外 count 可以跟踪已完成的函数数量,这样我们就能知道何时应该停止。我们有一个内部回调,负责收集结果并调用用户的回调。最后,我们会一次性触发所有函数。

最终代码效果如下:

用来更改背景颜色的可拖动按钮

在一次现场面试中,他们要求我在屏幕中间实现一个可拖动的按钮。当它移向边缘时,背景颜色从白色变为红色。

在讨论可能的解决方案之前,请在此处查看结果和代码:

https://codesandbox.io/s/drag-to-change-background-color-57dvw

首先我们来创建标记:

复制代码



overlay 将覆盖整个屏幕,这是我们用来更改背景颜色的元素。#button 是我们的可拖动按钮。

下面是 CSS 代码,用来给按钮添加样式并加入 overlay:

复制代码

#button{
cursor: pointer;
background-color: black;
width:50px;
height:50px;
border-radius:50px;
position: absolute;
top:50%;
left:50%;
transform:translateX(-50%)translateY(-50%);
}
#overlay{
background-color: red;
width:100vw;
height:100vh;
z-index: -1;
opacity:0;
}

我们更改颜色的方法是调整覆盖层(overlay)的不透明度。默认值为 0(透明),我们将使用 javascript 来做相应的更改。

在这次挑战期间他们允许我使用任何库,因为我知道这家公司使用的是 Typescript 和 RxJS,所以我决定使用它们。我们需要做两件事:订阅和处理拖动事件,并根据事件 X 和 Y 的坐标确定覆盖层的不透明度。

我们将使用 fromEvent( https://rxjs-dev.firebaseapp.com/api/index/function/fromEvent )和 subscribe( https://rxjs-dev.firebaseapp.com/api/index/class/Observable#subscribe )来解决前者。这里全都可以使用标准 javascript 来完成(参见 addEventListener「 https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener 」)。

复制代码

import{ fromEvent }from"rxjs";
import{ distinctUntilChanged, filter }from"rxjs/operators";
const button =document.querySelector("#button")asHTMLElement;
const overlay =document.querySelector("#overlay")asHTMLElement;
fromEvent(document,"drag")
.pipe(
filter((event: DragEvent)=>event.target === button),
distinctUntilChanged((e1: DragEvent, e2: DragEvent)=>
e1.clientX === e2.clientX && e1.clientY === e2.clientY)
)
.subscribe((event: DragEvent)=>{
//calculate overlay opacity
});

我们 filter 掉所有目标不是#button 的拖动事件,并使用 distinctUntilChanged 阻止所有重复事件。

我们需要做一些数学运算才能解决后者。

复制代码

constmaxY =window.innerHeight /2;
consty =Math.abs(event.clientY - maxY);
constpY = y / maxY;
constmaxX =window.innerWidth /2;
constx =Math.abs(event.clientX - maxX);
constpX = x / maxX;
overlay.style.opacity =String(Math.max(pY, pX));

event.clientY 和 event.clientX 表示可拖动按钮在屏幕上的位置。基于这些,我们需要计算一个介于 0 和 1 之间的数字,这将是覆盖层的不透明度。

我们将 x 和 y 的最大值分别设置为 window.innerHeight 和 window.innerWidth 除以 2。我们将 x 和 y 归一化为介于 0 和最大值之间的值。最后,我们计算 pY 和 pX(它们是介于 0 和 1 之间的值),并将不透明度设置为其中较高的那个值。

滑出动画

以我的经验,关于元素如何动画化的问题是很常见的。我参加的那次面试中,他们要求我做的事是为元素点击实现一个滑出动画,而不能使用 CSS 动画和过渡。

首先我们来做 HTML:

复制代码



然后是 CSS:

复制代码

#box{
width:50px;
height:50px;
background-color: blue;
}

使用 Java 脚本实现动画的方法不止一种。我建议使用 window.requestAnimationFrame( https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame ):

复制代码

constslideOut =(element, duration) =>{
constinitial =0;
consttarget =window.innerWidth;
conststart =newDate();
constloop =()=>{
consttime = (newDate().getTime() - start.getTime()) /1000;// in seconds
constvalue = (time * target) / duration + initial;
box.style.transform =`translateX(${value}px)`;

if(value >= target) {
box.style.transform =``;
return;
}
window.requestAnimationFrame(loop);
};
window.requestAnimationFrame(loop);
};
constbox =document.getElementById("box");
box.addEventListener("click",event=>{
slideOut(event.target,1);
});

我们添加了一个单击事件侦听器,以便每次单击#box 时,都会使用元素和动画的持续时间来调用 slideOut。

slideOut 函数定义了 transformX 转换的 initial 和 target。创建一个 loop 并使用 requestAnimationFrame 调用它。循环将一直执行到#box 到达屏幕底部为止。使用线性方程式计算每个新 value。

经常会问到的一个后续问题是,你将如何实现一个 easing 函数( https://easings.net/en# )?

还好我们已经有了将线性方程切换到某个 Penner 方程( http://robertpenner.com/easing/penner_chapter7_tweening.pdf )上所需的所有参数( http://blog.moagrius.com/actionscript/jsas-understanding-easing/ )。这里就用 easeInQuad:

复制代码

easeInQuad =function(t, b, c, d) {returnc*(t/=d)*t+ b; };

把第 9 行改为:

复制代码

constvalue= target * (time/duration) * (time/duration) + initial;

结果如下:

如果你对 Javascript 动画感兴趣,我写了一篇关于它的文章以供参考:

https://medium.com/better-programming/creating-a-proximity-graph-animation-an-introduction-to-html5-canvas-and-the-animation-loop-45719d82d1a3

Giphy 客户端

对于我们要解决的最后一个挑战,我的任务是实现一个小型 Web 应用程序,该程序能让用户搜索和浏览 gif,用的是 Giphy API( https://developers.giphy.com/docs/api#quick-start-guide )。

面试时我可以自由选择我喜欢的框架和库。在本文中我将使用 React 和 fetch( https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API )。

我们首先创建一个简单的 React 组件,其表单将处理用户输入:

复制代码

importReact, { useState } from"react";
exportdefaultfunctionApp() {
const [query, setQuery] = useState("");
return(

Giphy Client

setQuery(e.target.value)}/>
); }

如果时间允许,你应该考虑创建子组件以使代码井井有条。在面试中你的时间一般是没那么充裕的。所以即使你没有时间去做这种事情,也一定要让面试官知道你打算如何改进代码。

现在,为了使用 Giphy API,我们需要生成一个 API Key( http://y1zfwiomdykwy80gtsxu4iedv165yeod/ )。有了它就可以向组件中添加一个函数,以从搜索端点( https://developers.giphy.com/docs/api/endpoint#search )中获取数据。

复制代码

constsearch =()=>{
if(!query) {
setData(undefined);
return;
}
fetch(
`https://api.giphy.com/v1/gifs/search?q=${query}&api_key=`
)
.then(response=>response.json())
.then(json=>{
setData(json.data);
});
};

简单起见,对于任何 API 异常都没有错误处理。

现在,当用户点击 Search 或单击 ENTER 时,我们需要使 < form> 调用 search 方法。

复制代码

{ e.preventDefault();// prevents the page from reloading search(); }} >

最后,我们扩展组件以从搜索结果中渲染 GIF:

复制代码

{data && (

Results

    {data.map(d => ( ))}
)}

再加上一些基本的 CSS 后,结果如下:

感谢你的阅读,希望你今天学到了一些新知识。

延伸阅读

https://medium.com/better-programming/5-front-end-interview-coding-challenges-6cd9f31d1169#8e35

About The Author

bjmayor

程序员,码农,php,python,ios,android,go,产品经理,创业。