Categories
程式開發

5道頗具挑戰性的前端面試題


圖片

本文最初發佈於Medium博客,經原作者授權由InfoQ中文站翻譯並分享。

去年我面試了多家科技公司的軟件工程師職位。由於其中多數都是Web開發崗位,因此我當然要回答許多客戶端開發方面的問題。有些問題很簡單,比如:什麼是事件委託?如何在Java中實現繼承?還有一些是更具挑戰性的上手編程問題,而在本文中我就會分享其中我最喜歡的5道面試題。

毫無疑問,面試成功的關鍵是做好充分的準備。因此,無論你是在積極參加面試,抑或只是有些好奇,想知道科技公司面試前端崗位時可能會問什麼樣的問題,這篇文章都能幫得上你的忙,讓你為將來的面試打下更好的基礎。

目錄

  1. 模擬Vue.js
  2. async series和parallel
  3. 能更改背景色的可拖動按鈕
  4. 滑出動畫
  5. Giphy客戶端

模擬Vue.js

我在一次電話面試中遇到了這個挑戰。對方讓我轉到Vue.js文檔,並將以下代碼段複製到我用的編輯器中:

{{ message }}
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]
      );
    });
}

這樣工作就完成了,但是我們絕對可以做得更好。例如,如果我們有兩個名稱相同的模板字符串,那麼這個實現就無法按預期正常運行。只有第一次出現的字符串才會被替換。

{{ 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

你會注意到

標籤將從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節點實際上如此簡單:

Nothing to see here

我們仍將迭代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;
      }
      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) => {
    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

首先我們來創建標記:


  
    

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:


  
    

然後是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 (
    

Giphy Client

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

如果時間允許,你應該考慮創建子組件以使代碼井井有條。在面試中你的時間一般是沒那麼充裕的。所以即使你沒有時間去做這種事情,也一定要讓面試官知道你打算如何改進代碼。

現在,為了使用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=`
  )
    .then(response => response.json())
    .then(json => {
      setData(json.data);
    });
};

簡單起見,對於任何API異常都沒有錯誤處理。
現在,當用戶點擊Search或單擊ENTER時,我們需要使調用search方法。

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

最後,我們擴展組件以從搜索結果中渲染GIF:

{data && (
  

Results

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

再加上一些基本的CSS後,結果如下:

圖片

感謝你的閱讀,希望你今天學到了一些新知識。

延伸閱讀

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