本文最初发布于 Medium 博客,经原作者授权由 InfoQ 中文站翻译并分享。
去年我面试了多家科技公司的软件工程师职位。由于其中多数都是 Web 开发岗位,因此我当然要回答许多客户端开发方面的问题。有些问题很简单,比如:什么是事件委托?如何在 Java 中实现继承?还有一些是更具挑战性的上手编程问题,而在本文中我就会分享其中我最喜欢的 5 道面试题。
毫无疑问,面试成功的关键是做好充分的准备。因此,无论你是在积极参加面试,抑或只是有些好奇,想知道科技公司面试前端岗位时可能会问什么样的问题,这篇文章都能帮得上你的忙,让你为将来的面试打下更好的基础。
目录
模拟 Vue.js
async series 和 parallel
能更改背景色的可拖动按钮
滑出动画
Giphy 客户端
模拟 Vue.js
我在一次电话面试中遇到了这个挑战。对方让我转到 Vue.js 文档,并将以下代码段复制到我用的编辑器中:
<div id="app">
{{ message }}
</div>
复制代码
var app = new Vue({
el: '#app',
data: {
message: 'Hello Vue!'
}
})
复制代码
你大概能猜得到这里的目标是用 Hello Vue!取代{{message}},当然不能将 Vue.js 添加成依赖项。
在开始研究代码之前,请务必与面试官交流,澄清你可能对问题抱有的任何疑问,并确保你完全理解输入、输出的内容,以及需要考虑的任何极端情况。
首先我们创建 Vue 类,并将其添加到 Javascript 代码段上方。
class Vue {
constructor(options) {
}
}
复制代码
这样,我们的小项目至少应该能正确运行。
现在为了用提供的文本替换模板字符串,可能最简单的方法是,一旦我们可以访问 #app 元素,就在其 innerHTML 属性上使用 String.replace():
class Vue {
constructor(options) {
const el = document.querySelector(options.el);
const data = options.data;
Object.keys(data).forEach(key => {
el.innerHTML = el.innerHTML.replace(
`{{ ${key} }}`,
data[key]
);
});
}
复制代码
这样工作就完成了,但是我们绝对可以做得更好。例如,如果我们有两个名称相同的模板字符串,那么这个实现就无法按预期正常运行。只有第一次出现的字符串才会被替换。
<div id="app">
{{ message }} and {{ message }}, what's the {{ message }}
</div>
复制代码
这很容易解决,我们使用一个正则表达式(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 即可,但是一旦标记变得更加复杂就很快不够用了:
<div id="app">
{{ message }}
<p> another {{ message }} inside a paragraph </p>
</div>
复制代码
你会注意到< p>标签将从 DOM 中删除。这是因为 innerText 和 textContent 仅返回文本,当我们将其用作 setter 时,它会将标记替换为仅文本。
一种解决方法是遍历 DOM,找到所有文本节点,然后替换文本。
Vue {
constructor(options) {
this.el = document.querySelector(options.el);
this.data = options.data;
this.replaceTemplateStrings();
}
replaceTemplateStrings() {
const stack = [this.el];
while (stack.length) {
const n = 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 节点实际上如此简单:
<p>Nothing to see here</p>
复制代码
我们仍将迭代 200 次来查找模板字符串。
解决这个问题的一种方法是实现一个简单的状态机,这个状态机只查看一次文本,并随即替换模板字符串(如果存在):
class Vue {
constructor(options) {
this.el = document.querySelector(options.el);
this.data = options.data;
this.replaceTemplateStrings();
}
replaceTemplateStrings() {
const stack = [this.el];
while (stack.length) {
const n = stack.pop();
if (n.childNodes.length) {
stack.push(...n.childNodes);
}
if (n.nodeType === Node.TEXT_NODE) {
this.replaceText(n);
}
}
}
replaceText(node) {
let text = node.textContent;
let result = "";
let state = 0; // 0 searching template, 1 searching key
let cursor = 0;
for (let i = 0; i < text.length - 1; i++) {
switch (state) {
case 0:
if (text[i] === "{" && text[i + 1] === "{") {
state = 1;
result += text.substring(cursor, i);
cursor = i;
}
break;
case 1:
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']
});
复制代码
首先我们来创建一个异步对象:
const async = {
series: (tasks, callback) => {}
};
复制代码
这项挑战的主要内容是,我们需要确保函数是一个个执行的,换句话说我们只在上一个函数完成后才执行下一个函数:
const async = {
series: (tasks, callback) => {
let i = 0;
const results = [];
const _callback = (err, result) => {
results[i] = result;
if (err || ++i >= tasks.length) {
callback(err, results);
return;
}
tasks[](_callback);
};
tasks[](_callback);
}
};
复制代码
我们使用一个变量 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) => {
let done = false;
let count = 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
首先我们来创建标记:
<html>
<body>
<div id="overlay"></div>
<div id="button" draggable="true"></div>
</body>
<html>
复制代码
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") as HTMLElement;
const overlay = document.querySelector("#overlay") as HTMLElement;
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 阻止所有重复事件。
我们需要做一些数学运算才能解决后者。
const maxY = window.innerHeight / 2;
const y = Math.abs(event.clientY - maxY);
const pY = y / maxY;
const maxX = window.innerWidth / 2;
const x = Math.abs(event.clientX - maxX);
const pX = 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:
<html>
<body>
<div id="box"></div>
</body>
<html>
复制代码
然后是 CSS:
#box {
width: 50px;
height: 50px;
background-color: blue;
}
复制代码
使用 Java 脚本实现动画的方法不止一种。我建议使用 window.requestAnimationFrame(https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame):
const slideOut = (element, duration) => {
const initial = 0;
const target = window.innerWidth;
const start = new Date();
const loop = () => {
const time = (new Date().getTime() - start.getTime()) / 1000; // in seconds
const value = (time * target) / duration + initial;
box.style.transform = `translateX(${value}px)`;
if (value >= target) {
box.style.transform = ``;
return;
}
window.requestAnimationFrame(loop);
};
window.requestAnimationFrame(loop);
};
const box = 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) { return c*(t/=d)*t + b; };
复制代码
把第 9 行改为:
const value = 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 组件,其表单将处理用户输入:
import React, { useState } from "react";
export default function App() {
const [query, setQuery] = useState("");
return (
<div>
<h1>Giphy Client</h1>
<div>
<form>
<input value={query} onChange={e => setQuery(e.target.value)} />
<input type="submit" value="Search" />
</form>
</div>
</div>
);
}
复制代码
如果时间允许,你应该考虑创建子组件以使代码井井有条。在面试中你的时间一般是没那么充裕的。所以即使你没有时间去做这种事情,也一定要让面试官知道你打算如何改进代码。
现在,为了使用 Giphy API,我们需要生成一个 API Key(http://y1zfwiomdykwy80gtsxu4iedv165yeod/)。有了它就可以向组件中添加一个函数,以从搜索端点(https://developers.giphy.com/docs/api/endpoint#search)中获取数据。
const search = () => {
if (!query) {
setData(undefined);
return;
}
fetch(
`https://api.giphy.com/v1/gifs/search?q=${query}&api_key=<API_KEY>`
)
.then(response => response.json())
.then(json => {
setData(json.data);
});
};
复制代码
简单起见,对于任何 API 异常都没有错误处理。
现在,当用户点击 Search 或单击 ENTER 时,我们需要使< form>调用 search 方法。
<form
onSubmit={e => {
e.preventDefault(); // prevents the page from reloading
search();
}}
>
复制代码
最后,我们扩展组件以从搜索结果中渲染 GIF:
{data && (
<div>
<h2>Results</h2>
<ul>
{data.map(d => (
<li key={d.id}>
<img src={d.images.fixed_width.url} alt={d.id} />
</li>
))}
</ul>
</div>
)}
复制代码
再加上一些基本的 CSS 后,结果如下:
感谢你的阅读,希望你今天学到了一些新知识。
延伸阅读
https://medium.com/better-programming/5-front-end-interview-coding-challenges-6cd9f31d1169#8e35
评论