攻破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 的演算法倉庫值得學習