攻破javascript面試的完美指南【譯】

daaasheng發表於2018-10-20

攻破javascript面試的完美指南(開發者視角)

0. 前言

本文適合有一定js基礎的前端開發人員閱讀。原文是我google時無意發現的, 被一些知識點清晰的解析所打動, 決定翻譯並記錄下來。這個過程斷續進行了兩個月, 期間工作遇到的部分疑問也在文中找到了答案。這篇好的文章值得被推薦。

說明:因為外網的緣故, 原文中的一些視訊連線並沒有貼出。部分採用意譯, 示例程式碼有少許差別。由於英文水平有限, 歡迎指出錯誤和批評。

為了向你說明js面試的複雜性, 嘗試給出程式碼段的輸出。

console.log(2.0 == `2` == new Boolean(true) == `1`)
// true

十有八九的會給出false, 其實執行結果是true。

JavaScript是難的。 如果太聰明面試問類似問題, 我們也無可奈何。 但是什麼是我們應該準備的呢?深入學習這十一個基本知識點,有助於你的JS面試。

1.熟悉js函式

function 是JavaScript的精髓。不同於其他語言, 在js中, 一個函式可以分配成一個變數, 作為引數傳遞給其他函式也可以作為其他函式的返回值。

console.log(square1(5));
/* ... */
function square1(n) { return n * n; }
// 25

console.log(square2(5)); 
var square2 = function(n) { 
  return n * n; 
}
// square2 is not a function

JS中, 如果你把函式定義為變數, 變數的名字會被提升, 但是JS執行到它的定義才能被訪問。

你可能在一些程式碼中頻繁的見到如下程式碼。

var simpleLibrary = function() {
   var simpleLibrary = {
        a: 0,
        b: 0,
        add: function(a, b) {
            return a + b;
        },
        subtract: function(a, b) {
            return a - b;   
        }
   }
  return simpleLibrary;
}();

一個函式變數中變數和函式被分裝, 可以避免全域性變數汙染。 從JQuery到Lodash的庫採用這用技術提供$、_等

2.熟悉bind、apply和call

你可能在所有常用庫中看到過這三個函式。它們允許區域性套用, 我們可以把功能組合到不同的函式。一個優秀的js開發者可以隨時告訴你關於這三個函式。

首先, 這些都是函式的原型方法去改變行為來實現一些功能。依據JS開發者Chad, 用途描述如下:

當你想要函式在特定上下文中呼叫,使用.bind(), 很適用於事件。
當你期望立即呼叫函式並修改上下文, 使用.call()或.apply()

一個應急呼叫例項

解釋一下上述描述。假設你的數學老師要求你建立一個庫並提交。你寫了一個可以計算圓周長和麵積的抽象庫。

var mathLib = {
    pi: 3.14,
    area: function(r) {
        return this.pi * r * r;
    },
    circumference: function(r) {
        return 2 * this.pi * r;
    },
}

你把函式庫提交給老師。現在是時間提交被稱為計算庫的程式碼。

mathLib.area(2) // 12.56

當你提交第二個程式碼例項時, 你發現指南中老師要求你常量pi精確到小數點後5位數。你使用的是3.14, 不是3.14159。現在由於最後期限已過你沒有機會提交庫。 JS call函式可以幫你。 只需要呼叫你的程式碼如下。

mathLib.area.call({pi: 3.14159}, 2) //    12.56636

加入你注意到call函式具有兩個引數。

  • 上下文
  • 函式引數

在area函式中, 上下文是物件被關鍵詞this代替。後面的引數作為函式引數被傳遞。 如下:

var cylinder = {
    pi: 3.14,
    volume: function(r, h) {
        return this.pi * r * r * h;
    },
}

call 呼叫如下:

cylinder.volume.call({pi: 3.14159}, 2, 6); //    75.39815999999999

你看到這些函式的引數在上下文物件後被傳遞了嗎?

Apply 是相似的, 除了函式引數以列表的方式被傳遞。

cylinder.volume.apply({ pi: 3.14159 }, [2, 6]); //    75.39815999999999

你知道call的用法, apply用法反之亦然。 那麼 , bind的用法呢?

Bind函式的用途呢?它允許我們將上下文注入一個函式, 該函式返回一個帶有更新上下文的新函式。這意味著, 這個變數將是使用者提供的變數。當和JS事件一起執行時這是非常有用的。

你應該熟悉在JS中使用這三個函式去組合功能

3.熟悉js作用域(閉包)

JS作用域是一個潘多拉魔盒。數以百計的面試難題有這個概念構成。 有三種作用域:

  • 全域性作用域
  • 本地/函式作用域
  • 塊級作用域(ES6引進)

全域性作用域是我們通常做的那樣:

x = 10;
function Foo() {
  console.log(x); // Prints 10
}
Foo()

函式作用域生效當你定義一個區域性變數時:

pi = 3.14;
function circumference(radius) {    
     pi = 3.14159;
     console.log(2 * pi * radius); // Prints "12.56636" not "12.56"
}
circumference(2);

ES16標準介紹過新塊級作用域,限制一個變數作用域帶給定的括號塊。

var a = 10;
function Foo() {
    if (true) {
        let a = 4;
    }
    console.log(a);
}
Foo() //    10, 因為關鍵詞key

函式和條件都被視為塊。以上例子應該給出4,因為條件宣告已經生效。但是ES6銷燬了塊級變數的作用域,作用域進入全域性。

現在來自神奇的作用域。它可以通過閉包實現。JS閉包是一個返回另一個函式的函式。

如果有人要求你,實現輸入一個字串並逐次返回字元。如果給出一個新的字串, 需要替換舊字串。他被簡單成為生成器。

function generator(input) {
    var index = 0;
    return {
        next: function() {
            if (index < input.length) {
                index += 1;
                return input[index - 1];
            }
            return "";
        }
    }
}
var mygenerator = generator(`hello`);
mygenerator.next(); //    "h"
mygenerator.next(); //    "e"
mygenerator = generator(`word`);
mygenerator.next(); //    "w"

此時, 作用域扮演一個重要的角色。一個閉包是返回另一個函式和包裹資料的一個函式。以上字串生成器便是一個閉包。index的值在多個函式呼叫中被儲存。內部函式可以訪問父級函式中定義的變數。這是一個不同的作用域。假設你在二級函式中定義了一個函式, 它可以訪問所有父級變數。

JS作用域會給你帶來很多問題, 徹底理解它。

4.熟悉this(全域性域、函式域、物件域)

JS中, 我們經常把函式和物件組合。假設在瀏覽器中, 在全域性上下文中它涉及window物件。我的意思是, 如果你現在開啟瀏覽器控制檯輸入this, 改製為true

this === window //    true

當程式的上下文和作用域改變時, this隨之發生改變。現在觀察this在一個區域性上下文中:

function Foo() {
    console.log(this.a);
}
Foo() //    undefined
var food = { a: `hello--` };
Foo.call(food); //    hello--

你可以嘗試預測一下輸出:

function Roo(){
    console.log(this); // prints {}?
}
Roo() //    Window {postMessage: ƒ, blur: ƒ, focus: ƒ, close: ƒ, frames: Window, …}

不,你還沒有獲勝。因為this此時是一個全域性物件。記住, 無論父級作用域是什麼, 它都講被它的孩子繼承。因此, 它列印出了window物件。我們討論的三個方法實際上用於設定this物件。

現在,this的最後一個型別。在物件中的this, 如下:

var person = {
    name: `Tom`,
    age: 26,
    get identity() {
        return { 
            who: this.name,
            howOld: this.age
        }
    }
}

我僅僅使用getter語法, 它是一個可以作為變數呼叫的函式。

person.identity; //    {who: "Tom", howOld: 26}

因此, 這實際是物件自己。this正如我們前面所提到的不同地方的表現不同。

5.熟悉物件(freeze、seal屬性)

可以通過以下方式建立物件:

var marks = {}
var marks = new Object();

我們大多是熟悉的物件如下:

var marks  = { physics: 98, maths: 95, chemistry: 91 };

它是一個鍵值對儲存鍵、值。JS 物件具備的一個特殊屬性, 把任何東西可以視為value。這意味著, 我們可以把一個陣列、物件、函式作為value來儲存。有何不可呢?

你藉助JSON的stringify、parse防範可以輕鬆的把物件轉成一個JSON, 相應的可以再轉成物件。

JSON.stringify(marks); //    "{"physics":98,"maths":95,"chemistry":91}"
JSON.parse(`{"physics":98,"maths":95,"chemistry":91}`); //    {physics: 98, maths: 95, chemistry: 91}

因此,對於物件你有了解一些什麼呢。使用Object.keys很容易迭代物件

var highScore = 0;
for (k of Object.keys(marks)) {
    if (marks[k] > highScore) {
        highScore = marks[k];
    }
}
console.log(highScore); //    98

Object.values 以陣列的方式返回物件的值。

其他重要的物件函式:

  • Object.prototype(object)
  • Object.freeze(function)
  • Object.seal(function)

Object.prototype提供更多可以應用的重要函式。如下:

Object.prototype.hasOwnProperty 用於發現一個物件是否存在一個原型或鍵。

marks.hasOwnProperty(`physics`); //    true
marks.hasOwnProperty(`greek`); //    false

Object.prototype.instanceof 評估一個物件是否是特定原型的型別。

function Car(make, model, year) {
    this.make = make;
    this.model = model;
    this.year = year;
}
var newCar = new Car(`Jack`, `City`, `2008`);
console.log(newCar instanceof Car) //    true

現在介紹其它兩個函式。Object.freeze 允許我們凍結一個物件, 使得存在的屬性不能被改變。

var marks = {physics: 98, math: 95, chemisty: 91}
finalizedMarks = Object.freeze(marks); //    {physics: 98, maths: 95, chemistry: 91}
finalizedMarks[`physics`] = 86; 
console.log(marks); //    {physics: 98, maths: 95, chemistry: 91}

程式碼中, physics屬相併未被改變。我們可以使用Object.isFrozen來判斷,給定物件是否被凍結

Object.isFrozen(finalizedMarks); //    true

Object.seal 與freeze有細微差別。前者允許配置屬性, 但是不允許新增或刪除屬性。

var marks = {physics: 98, math: 95, chemisty: 91}
Object.seal(marks); //    {physics: 98, math: 95, chemisty: 91}
delete marks.chemisty //    false
marks.physics = 95;
console.log(marks); //    {physics: 95, math: 95, chemisty: 91}
marks.greek = 86;
console.log(marks); //    {physics: 95, math: 95, chemisty: 91}

同樣, 可以藉助Object.isSealed判斷物件是否被密封。

Object.isSealed(marks) //    true

6.熟悉原型繼承

在傳統的js中隱藏著繼承的概念, 使用原型技術。你在ES5、ES6中看到的所有new class語法僅僅是底層原型OOP的表層。使用js函式建立一個class.

var animalGroups = {
    MAMMAL: 1,
    REPTILE: 2,
    AMPHIBIAN: 3,
    INVERTEBRATE: 4,
};
function Animal(name, type) {
    this.name = name;
    this.type = type;
}
var dog = new Animal("dog", animalGroups.MAMMAL);
console.log(dog); //    Animal { name: `dog`, type: 1 }
var crocodile = new Animal("crocodile", animalGroups.REPTILE);
console.log(crocodile); //    Animal { name: `crocodile`, type: 2 }

此時, 我們建立一個類(使用關鍵詞new)。可以使用如下方式對class追加方法。

Animal.prototype.shout = function() {
  console.log(this.name+`is`+this.sound+`ing...`);
}

你可能有疑問。現在class中沒有sound屬性。是的。定義一個sound屬性幾乎沒有可能,可以由繼承它的子類進行傳遞。

js中, 如下實現繼承。

function Dog(name, type) {
  Animal.call(this, name, type);
  this.sound = `bow`;
}
// console.log(Dog); // [Function: Dog]

定義一個特殊的函式Dog。為了繼承Animal, 需要call傳遞this和其他引數。如下方式例項化一個Jack。

var pet = new Dog(`Jack`, animalGroups.MAMMAL);
console.log(pet);   // Dog { name: `Jack`, type: 1, sound: `bow` }
console.log(pet instanceof Dog); // true
console.log(pet instanceof Animal); // false

我們不能在子函式中分配name和type,但是可以呼叫超級函式Animal並設定屬性。。pet擁有其父的(name, type)屬性。是否也繼承了方法。

pet.shout(); // is not a function

為什麼沒有繼承呢? 因為不能繼承父class的方法。如何補救?

Dog.prototype = Object.create(Animal.prototype);
var pig = new Dog(`Jack`, animalGroups.MAMMAL);
pig.shout(); // Jackisbowing...

現在shout方法是有效的。Object.constructor函式檢查物件的class.

console.log(pig.constructor); // [Function: Animal]

檢查pig的結果。Animal是父類。這是因為Dog的類

console.log(Dog.prototype.constructor); // [Function: Animal]

輸出是Aimal。我們應該設定Dog為其本身, 這樣類的所有例項(物件)都應該在類所屬的地方給出正確的類名。

Dog.prototype.constructor = Dog;
console.log(Dog.prototype.constructor); // [Function: Dog]

關於原型繼承, 我們應該記住以下幾條:

  • class 屬性使用this繫結
  • class 方法使用prototype物件來繫結
  • 為了繼承原型, 使用call函式傳遞this
  • 為了繼承方法, 使用Object.create連線父和子的原型
  • 通設定子class建構函式本身為獲取正確的標識。

注意:即使使用新的class語法, 這些事情也會發生。瞭解這些對你熟悉js有幫助。

js中, call函式和原型物件提供繼承

7.熟悉callback和promise

callback 是 一個I/O執行完畢後執行的函式。一個耗時的I/O操作會阻塞程式碼, 因此在Python/Ruby不被允許。但是js中, 由於允許非同步執行, 我們可以提供非同步函式來回撥。這個例子是由瀏覽器到伺服器的AJAX(XMLHettpRequest)呼叫,由滑鼠、鍵盤事件生成。如下:

function reqListener() {
    console.log(this.responseText);
}
var req = new XMLHttpRequest();
req.addEventListener(`load`, reqListenter);
req.open(`GET`, `http://www.example.org/example.txt`);
req.send();

其中, reqListenter是GET請求成功後的回撥函式。

Promise 是回撥函式的優雅的封裝, 使得我們優雅的實現非同步程式碼。此時, 不再過多討論promise, 雖然對於熟悉Js及其重要。

8.熟悉正則表達

建立正規表示式,有如下兩種方式:

var re = /ar/;
var re = new RegExp(`ar`);

以上正則用於匹配字串。一旦正則已經定義, 可以使用exec函式匹配字串。

re.exec(`car`)
re.exec(`cab`)

存在複雜的符號, 來實現複雜的正規表示式。

  • 字元正則:w-字母數字, d-數字, D-沒有數字
  • 字元正則:[x-y]x-y區間, [^x]沒有x
  • 數量正則:+至少一個、?沒或多個、*多個
  • 邊界正則,^開始、$結尾

例子如下:

// 1
/d/.exec(`qwe`) // null
/d/.exec(`2344`) // ["2", index: 0, input: "2344", groups: undefined]
/d/.exec(`2cc4`) // ["2", index: 0, input: "2cc4", groups: undefined]

// 2
/e+/.exec(`qwe`) // ["e", index: 2, input: "qwe", groups: undefined]

// 3
[^x]/.exec(`xcc4`)  // ["c", index: 1, input: "xcc4", groups: undefined]

// 4
/^q/.exec(`qwe`) //["q", index: 0, input: "qwe", groups: undefined]
/e$/.exec(`qwe`) // ["e", index: 2, input: "qwe", groups: undefined]

除了exec, 還有match、search,以及replace可以返回一個字串使用正規表示式。但是主體是一個字串。

`hello 12345`.match(/d/) // ["1", index: 6, input: "hello 12345", groups: undefined]
`hello 12345`.replace(/1/, `c`) // "hello c2345"

正則是個重要的話題, 對於想要簡單解決複雜問題的開發人員來說。

正則不單單屬於js, 你也可以經常在其他語言中見到

9.熟悉map、reduce和filter

函數語言程式設計是最近討論的話題。許多程式語言的新版本開始包括lambdas等概念(如:java>7)。 js中, 支援函式式結構已經有很長一段時間。此處, 有三個函式需要我們深入學習。數學函式獲取輸出並給出返回。一個純正的函式總是依據輸入給出返回,如下討論的函式屬於此類函式。

9.1 map

map函式在js陣列中可用。使用這個函式, 我們通過對每一個元素進行轉換來獲取一個新的陣列。一般的js陣列map操作如下:

arr.map((elem){
    process(elem);
    return processedValue;
}); // return a new array

假設,我們最近工作的序列鍵不需要字元。 我們需要移除。可以使用map去執行相同的操作從而獲取結果數字,而不是通過迭代和發現的方式移除字元。

var data = [`2345-34r`, `2e345-211`, `543-67i4`, `346-598`];
var re = /[a-z A-Z]/;
var cleanedData = data.map((elem) => {
    return elem.replace(re, ``);
});
console.log(cleanedData); // ["2345-34", "2345-211", "543-674", "346-598"]

注意:使用es6的箭頭函式語法來定義函式

map接受一個作為引數的函式, 此函式接受一個來自陣列的引數。我們需要返回一個處理過的元素, 並應用於陣列中的所有元素。

9.2 reduce

reduce函式將一個給定的列表歸納出一個返回。我們通過迭代陣列執行相同的操作, 並儲存中間結果到一個變數中。此處是一個更簡潔的方式進行處理。js的reduce一般使用語法如下:

arr.reduce((accumulator, value, index) => {
    process(accumulator, value);
    return accumulator;
}, initAccumulator);
  • initAccumulator, 累加器的初始值
  • accumulator, 累加器用於儲存中間值和結果值
  • value, 對組對應的元素
  • index, 陣列對應的索引號

reduce 的一個實際應用是將一個陣列扁平化, 將內部陣列轉化為單個陣列, 如下:

var arr = [[1, 2], [3, 4], [5, 6]]
var flattenedArray = [1, 2, 3, 4, 5, 6]

我們可以通過正常的迭代實現, 神奇的是, 使用reduce會更加簡潔。

var arr = [[1, 2], [3, 4], [5, 6]]
var flattenedArray = arr.reduce((a, v) => {
    return a.concat(v)
}, [])
flattenedArray // (6) [1, 2, 3, 4, 5, 6]

9.3 filter

filter與map更為接近, 對陣列的每個元素進行操作並返回另外一個陣列(不同於reduce返回的值)。過濾後的陣列可能比原陣列長度更短。因為, 我們通過的可能排除 輸出陣列中更少/零的輸入。
filter執行如下:

arr.filter((v) => {
    return Boolean;
})

v是陣列中的元素, 通過true/false表示過濾元素包括/排除。假設, 我們過濾出以t開始以r結束的元素。

var words = ["tiger", "toast", "boat", "tumor", "track", "bridge"]
var newData = words.filter((str) => {
    return str.startsWith(`t`) && str.endsWith(`r`);
})
newData // (2) ["tiger", "tumor"]

當你被問到js方面的問題時, 這三個函式應該信手拈來。如你所看到的, 所有三個函式例子並沒有改變原陣列, 這也證明了這些函式的純淨性。

10. 熟悉錯誤(異常)處理模式

這部分是許多開發者最不關係的js部分。我瞭解到很少開發人員討論錯誤處理。好的開發方法是小心的將js程式碼包裹在try/catch周圍。

Nicholas C. Zakas, 雅虎的UI工程師, 2018 說過: “經常假設你的程式碼會失敗。事件處理可能不當。記錄到伺服器。丟擲你自己的問題。”

js中, 我們隨意碼的程式碼, 可能失敗, 如下:

$(`button`).click(function() {
    $.ajax({
        url: `user.json`,
        success: function(res) {
            updateUI(res[`posts`]);
        }
    });
});

此時, 我們落入ajax結果總是JSON物件的陷阱。有時, 伺服器會崩潰並返回null。這種情況下, null[“posts”]會丟擲錯誤。正確的處理方式如下:

$(`button`).click(function() {
    $.ajax({
        url: `user.json`,
        success: function(res) {
            try {
                updateUI(res[`posts`]);
            }
            catch(e) {
                logError();
                flashInfoMessage();
            }
        }
    });
});
  • logError函式打算向伺服器報告錯誤。
  • flashInfoMessage函式使用“當前伺服器不可用”等使用者友好型方式展示錯誤資訊。

Nicholas說過, 當你感到不可預期的事情發生時手動丟擲錯誤。區分致命和非致命錯誤。上面的錯誤與後臺伺服器掛機相關,是致命的。因此, 我們應該通知客戶伺服器因為一些原因掛機。這種情況下, 不是致命的, 但是最好通知伺服器。為了建立這樣的程式碼, 首先丟擲錯誤, 從window層級捕捉錯誤事件, 隨後記錄資訊到伺服器。

reportErrorToServer = function(error) {
    $.ajax({
        type: "POST",
        url: "http://api.xyz.com/report",
        data: error,
        success: function(res) {}
    });
}

// window error evnet
window.addEvnetListener(`error`, function(e) {
    reportErrorToServer({
        message: e.message
    });
});

function mainLogic() {
    throw new Error("error tip");
}

這個程式碼需要做如下三件事:

  • 監聽window層級錯誤
  • 出現錯誤時, API記錄
  • 在伺服器中記錄

你也可以使用新的Boolean函式(es5,es6)在程式之前監測變數的有效性並且不為null、undefined

if (Boolean()) {
    // block code
} else {
    throw new Error("Custom message");
}

始終考慮錯誤處理是你自己, 而不是瀏覽器。

11. 其他(提升機制和事件冒泡)

對於一個js開發者, 以上都是主要概念。瞭解少數內部細節可是非常有用的。js在瀏覽器中的工作機制。什麼是提升機制和事件冒泡?

11.1 提升機制

提升是 在程式碼執行過程中將宣告的變數推送到程式頂部 的一個過程。

function doSomething(v) {
    //
}
doSomething(foo);
var foo;

使用指令碼語言類似Python執行以上程式, 會丟擲錯誤。你需要先定義再使用。雖然js是指令碼語言, 但是它有提升機制。 在這種機制中, 一個js VM在執行程式是做了以下兩件事:

  • 首先,掃描程式收集所有變數和函式的宣告和分配記憶體空間。
  • 通過填充分配的變數來執行程式, 沒有分配則填充undefined

以上程式碼片段中列印“undefined”, 因為最初的掃描中已經收集了變數foo。VM查詢所有foo的值。

在 一些地方回丟擲錯誤 和 另外地方使用undefined js環境下的提升機制。學習一些例子來搞清楚提升。

author: 宣告可以被提升, 賦值不會。

11.2 事件冒泡

關於事件冒泡, 依據Arun P( 一個高階軟體工程)所描述:

“事件冒泡和捕獲在HTML DOM API中事件傳播的兩種方式,當同時註冊事件的父子元素中子元素觸發事件時。事件的傳播方式決定接受事件的元素順序 ”

關於冒泡, 事件最先由內部元素捕獲和處理, 隨後傳遞給父級元素。關於捕獲, 順序相反。我們通常使用addEventListener函式來捆綁事件和事件處理函式

addEventListener(`click`, handler, useCapture=false);

useCapture是第三個引數的關鍵詞, 預設為false。因此, 冒泡模式是事件由底部向上傳遞。 反之, 這是捕獲模式。

冒泡模式:

<div onClick="divHandler()">
    <ul onClick="ulHandler()">
        <li id="foo"></li>
    </ul>
</div>
<script>
function handler() {}
function divHandler() {}
function ulHandler() {}

documnet.getElementById("foo").addEventListener("click", handler)
</script>

點選li元素, 事件順序:handler() => ulHandler() => divHandler()

捕獲模式:

document.getElementById("foo").addEventListener("click", handler, true)

點選li元素, 事件順序divHandler => ulHandler() => handler()

以上都是基礎的js知識。 正如我最初提及的, 除了這些, 工作經歷和知識、準備對你攻克面試都有幫助。保持學習的習慣, 學習最新得技術(es6), 深入js各個方面的學習(如V6、測試等)。一些視訊也可以教會你一些知識。最後, 資料結構和演算法的準備也必不可少。Oleksii Trekhleb 的演算法倉庫值得學習

閱讀原文

其他blog;

相關文章