2019年度已經過去了,2020年面試高峯期又來了。經過2019年的學習和麪試經歷,統計了下面一些最常見的面試題。
JavaScript 中的強制轉型(coercion)是指什麼?
難度:簡單
在 JavaScript 中,兩種不同的內置類型間的轉換被稱爲強制轉型。強制轉型在 JavaScript 中有兩種形式:顯式和隱式。
這是一個顯式強制轉型的例子:
var a = "42";
var b = Number( a );
a; // "42" -- 字符串
b; // 42 -- 是個數字!
這是一個隱式強制轉型的例子:
var a = "42";
var b = a * 1; // "42" 隱式轉型成 42
a; // "42"
b; // 42 -- 是個數字!
JavaScript 中的作用域(scope)是指什麼?
難度:簡單
在 JavaScript 中,每個函數都有自己的作用域。作用域基本上是變量以及如何通過名稱訪問這些變量的規則的集合。只有函數中的代碼才能訪問函數作用域內的變量。
同一個作用域中的變量名必須是唯一的。一個作用域可以嵌套在另一個作用域內。如果一個作用域嵌套在另一個作用域內,最內部作用域內的代碼可以訪問另一個作用域的變量。
解釋 JavaScript 中的相等性。
難度:簡單
JavaScript 中有嚴格比較和類型轉換比較:
- 嚴格比較(例如 ===)在不允許強制轉型的情況下檢查兩個值是否相等;
- 抽象比較(例如 ==)在允許強制轉型的情況下檢查兩個值是否相等。
var a = "42";
var b = 42;
a == b; // true
a === b; // false
一些簡單的規則:
- 如果被比較的任何一個值可能是 true 或 false,要用 ===,而不是 ==;
- 如果被比較的任何一個值是這些特定值(0、“”或 []),要用 ===,而不是 ==;
- 在其他情況下,可以安全地使用 ==。它不僅安全,而且在很多情況下,它可以簡化代碼,並且提升代碼可讀性。
解釋什麼是回調函數,並提供一個簡單的例子。
難度:簡單
回調函數是可以作爲參數傳遞給另一個函數的函數,並在某些操作完成後執行。下面是一個簡單的回調函數示例,這個函數在某些操作完成後打印消息到控制檯。
function modifyArray(arr, callback) {
// 對 arr 做一些操作
arr.push(100);
// 執行傳進來的 callback 函數
callback();
}
var arr = [1, 2, 3, 4, 5];
modifyArray(arr, function() {
console.log("array has been modified", arr);
});
“use strict”的作用是什麼?
難度:簡單
use strict 出現在 JavaScript 代碼的頂部或函數的頂部,可以幫助你寫出更安全的 JavaScript 代碼。如果你錯誤地創建了全局變量,它會通過拋出錯誤的方式來警告你。例如,以下程序將拋出錯誤:
function doSomething(val) {
"use strict";
x = val + 10;
}
它會拋出一個錯誤,因爲 x 沒有被定義,並使用了全局作用域中的某個值對其進行賦值,而 use strict 不允許這樣做。下面的小改動修復了這個錯誤:
function doSomething(val) {
"use strict";
var x = val + 10;
}
解釋 JavaScript 中的 null 和 undefined。
難度:簡單
JavaScript 中有兩種底層類型:null 和 undefined。它們代表了不同的含義:
- 尚未初始化的東西:
undefined
- 目前不可用的東西:
null
- typeof 也不一樣
編寫一個可以執行如下操作的函數。
難度:較簡單
var addSix = createBase(6);
addSix(10); // 返回 16
addSix(21); // 返回 27
可以創建一個閉包來存放傳遞給函數 createBase 的值。被返回的內部函數是在外部函數中創建的,內部函數就成了一個閉包,它可以訪問外部函數中的變量,在本例中是變量 baseNumber。
function createBase(baseNumber) {
return function(N) {
// 我們在這裏訪問 baseNumber,即使它是在這個函數之外聲明的。
// JavaScript 中的閉包允許我們這麼做。
return baseNumber + N;
}
}
var addSix = createBase(6);
addSix(10);
addSix(21);
解釋 JavaScript 中的值和類型
難度:簡單
JavaScript 有類型值,但沒有類型變量。JavaScript 提供了以下幾種內置類型:
- string
- number
- boolean
- null 和 undefined
- object
- symbol (ES6 中新增的)
- bigint
解釋事件冒泡以及如何阻止它?
難度:簡單
事件冒泡是指嵌套最深的元素觸發一個事件,然後這個事件順着嵌套順序在父元素上觸發。
防止事件冒泡的一種方法是使用 event.cancelBubble 或 event.stopPropagation()(低於 IE 9)。
JavaScript 中的 let 關鍵字有什麼用?
難度:簡單
除了可以在函數級別聲明變量之外,ES6 還允許你使用 let 關鍵字在代碼塊({..})中聲明變量。
如何檢查一個數字是否爲整數?
難度:簡單
檢查一個數字是小數還是整數,可以使用一種非常簡單的方法,就是將它對 1 進行取模,看看是否有餘數。
function isInt(num) {
return num % 1 === 0;
}
console.log(isInt(4)); // true
console.log(isInt(12.2)); // false
console.log(isInt(0.3)); // false
什麼是 IIFE(立即調用函數表達式)?
難度:簡單
它是立即調用函數表達式(Immediately-Invoked Function Expression),簡稱 IIFE。函數被創建後立即被執行:
(function IIFE(){
console.log( "Hello!" );
})();
// "Hello!"
如何在 JavaScript 中比較兩個對象?
難度:中等
對於兩個非原始值,比如兩個對象(包括函數和數組),== 和 === 比較都只是檢查它們的引用是否匹配,並不會檢查實際引用的內容。
例如,默認情況下,數組將被強制轉型成字符串,並使用逗號將數組的所有元素連接起來。所以,兩個具有相同內容的數組進行 == 比較時不會相等:
var a = [1,2,3];
var b = [1,2,3];
var c = "1,2,3";
a == c; // true
b == c; // true
a == b; // false
乞丐版深拷貝
var obj1 = {
a: 1,
b: 2,
c: 3
}
var objString = JSON.stringify(obj1);
var obj2 = JSON.parse(objString);
obj2.a = 5;
console.log(obj1.a); // 1
console.log(obj2.a); // 5
對於對象的深度比較,可以使用 deep-equal 這個庫,或者自己實現遞歸比較算法。
解釋一下 ES5 和 ES6 之間的區別嗎?
難度:中等
- ECMAScript 5(ES5):ECMAScript 的第 5 版,於 2009 年標準化。這個標準已在所有現代瀏覽器中完全實現。
- ECMAScript 6(ES6)或 ECMAScript 2015(ES2015):第 6 版 ECMAScript,於 2015 年標準化。這個標準已在大多數現代瀏覽器中部分實現。
具體可以去看 阮一峯老師的博客
Javascript 中的“閉包”是什麼?舉個例子?
難度:中等
閉包是在另一個函數(稱爲父函數)中定義的函數,並且可以訪問在父函數作用域中聲明和定義的變量。
閉包可以訪問三個作用域中的變量:
- 在自己作用域中聲明的變量;
- 在父函數中聲明的變量;
- 在全局作用域中聲明的變量。
舉例:實現防抖 || 節流函數
如何在 JavaScript 中創建私有變量?
難度:中等
要在 JavaScript 中創建無法被修改的私有變量,你需要將其創建爲函數中的局部變量。即使這個函數被調用,也無法在函數之外訪問這個變量。例如:
function func() {
var priv = "secret code";
}
console.log(priv); // throws error
要訪問這個變量,需要創建一個返回私有變量的輔助函數。
function func() {
var priv = "secret code";
return function() {
return priv;
}
}
var getPriv = func();
console.log(getPriv()); // => secret code
請解釋原型設計模式。
難度:中等
原型模式可用於創建新對象,但它創建的不是非初始化的對象,而是使用原型對象(或樣本對象)的值進行初始化的對象。原型模式也稱爲屬性模式。
原型模式在初始化業務對象時非常有用,業務對象的值與數據庫中的默認值相匹配。原型對象中的默認值被複制到新創建的業務對象中。
經典的編程語言很少使用原型模式,但作爲原型語言的 JavaScript 在構造新對象及其原型時使用了這個模式。
判斷一個給定的字符串是否是同構的。
難度:中等
如果兩個字符串是同構的,那麼字符串 A 中所有出現的字符都可以用另一個字符替換,以便獲得字符串 B,而且必須保留字符的順序。字符串 A 中的每個字符必須與字符串 B 的每個字符一對一對應。
- paper 和 title 將返回 true。
- egg 和 sad 將返回 false。
- dgg 和 add 將返回 true。
isIsomorphic("egg", 'add'); // true
isIsomorphic("paper", 'title'); // true
isIsomorphic("kick", 'side'); // false
function isIsomorphic(firstString, secondString) {
// 檢查長度是否相等,如果不相等, 它們不可能是同構的
if (firstString.length !== secondString.length) return false
var letterMap = {};
for (var i = 0; i < firstString.length; i++) {
var letterA = firstString[i],
letterB = secondString[i];
// 如果 letterA 不存在, 創建一個 map,並將 letterB 賦值給它
if (letterMap[letterA] === undefined) {
letterMap[letterA] = letterB;
} else if (letterMap[letterA] !== letterB) {
// 如果 letterA 在 map 中已存在, 但不是與 letterB 對應,
// 那麼這意味着 letterA 與多個字符相對應。
return false;
}
}
// 迭代完畢,如果滿足條件,那麼返回 true。
// 它們是同構的。
return true;
}
“Transpiling”是什麼意思?
難度:中等
對於語言中新加入的語法,無法進行 polyfill。因此,更好的辦法是使用一種工具,可以將較新代碼轉換爲較舊的等效代碼。這個過程通常稱爲轉換(transpiling),就是 transforming + compiling 的意思。
通常,你會將轉換器(transpiler)加入到構建過程中,類似於 linter 或 minifier。現在有很多很棒的轉換器可選擇:
- Babel:將 ES6+ 轉換爲 ES5
- Traceur:將 ES6、ES7 轉換爲 ES5
“this”關鍵字的原理是什麼?請提供一些代碼示例。
難度:中等
在 JavaScript 中,this 是指正在執行的函數的“所有者”,或者更確切地說,指將當前函數作爲方法的對象。
function foo() {
console.log( this.bar );
}
var bar = "global";
var obj1 = {
bar: "obj1",
foo: foo
};
var obj2 = {
bar: "obj2"
};
foo(); // "global"
obj1.foo(); // "obj1"
foo.call( obj2 ); // "obj2"
new foo(); // undefined
如何向 Array 對象添加自定義方法,讓下面的代碼可以運行?
難度:中等
var arr = [1, 2, 3, 4, 5];
var avg = arr.average();
console.log(avg);
JavaScript 不是基於類的,但它是基於原型的語言。這意味着每個對象都鏈接到另一個對象(也就是對象的原型),並繼承原型對象的方法。你可以跟蹤每個對象的原型鏈,直到到達沒有原型的 null 對象。我們需要通過修改 Array 原型來向全局 Array 對象添加方法。
Array.prototype.average = function() {
// 計算 sum 的值
var sum = this.reduce(function(prev, cur) { return prev + cur; });
// 將 sum 除以元素個數並返回
return sum / this.length;
}
var arr = [1, 2, 3, 4, 5];
var avg = arr.average();
console.log(avg); // => 3
以下代碼輸出的結果是什麼?
難度:中等
0.1 + 0.2 === 0.3
這段代碼的輸出是 false,這是由浮點數內部表示導致的。0.1 + 0.2 並不剛好等於 0.3,實際結果是 0.30000000000000004。解決這個問題的一個辦法是在對小數進行算術運算時對結果進行舍入。
寫 React/Vue 項目時爲什麼要在組件中寫 key,其作用是什麼?
難度:中等
key 的作用是爲了在 diff 算法執行時更快的找到對應的節點,提高 diff 速度。
vue 和 react 都是採用 diff 算法來對比新舊虛擬節點,從而更新節點。在 vue 的 diff 函數中。可以先了解一下 diff 算法。
在交叉對比的時候,當新節點跟舊節點頭尾交叉對比沒有結果的時候,會根據新節點的 key 去對比舊節點數組中的 key,從而找到相應舊節點(這裏對應的是一個 key => index 的 map 映射)。如果沒找到就認爲是一個新增節點。而如果沒有 key,那麼就會採用一種遍歷查找的方式去找到對應的舊節點。一種一個 map 映射,另一種是遍歷查找。相比而言。map 映射的速度更快。
vue 部分源碼如下:
// vue 項目 src/core/vdom/patch.js -488 行
// oldCh 是一箇舊虛擬節點數組,
if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
創建 map 函數:
function createKeyToOldIdx (children, beginIdx, endIdx) {
let i, key
const map = {}
for (i = beginIdx; i <= endIdx; ++i) {
key = children[i].key
if (isDef(key)) map[key] = i
}
return map
}
遍歷尋找:
// sameVnode 是對比新舊節點是否相同的函數
function findIdxInOld (node, oldCh, start, end) {
for (let i = start; i < end; i++) {
const c = oldCh[i]
if (isDef(c) && sameVnode(node, c)) return i
}
}
解析 ['1', '2', '3'].map(parseInt)
難度:中等
第一眼看到這個題目的時候,腦海跳出的答案是 [1, 2, 3],但是 真正的答案是 [1, NaN, NaN]。
首先讓我們回顧一下,map 函數的第一個參數 callback:
var new_array = arr.map(function callback(currentValue[, index[, array]]) { // Return element for new_array }[, thisArg])
這個 callback 一共可以接收三個參數
- 其中第一個參數代表當前被處理的元素,而第二個參數代表該元素的索引。
- 而 parseInt 則是用來解析字符串的,使字符串成爲指定基數的整數。
- parseInt(string, radix)接收兩個參數,第一個表示被處理的值(字符串),第二個表示爲解析時的基數。
瞭解這兩個函數後,我們可以模擬一下運行情況;
- parseInt('1', 0) //radix 爲 0 時,且 string 參數不以“0x”和“0”開頭時,按照 10 爲基數處理。這個時候返回 1;
- parseInt('2', 1) // 基數爲 1(1 進制)表示的數中,最大值小於 2,所以無法解析,返回 NaN;
- parseInt('3', 2) // 基數爲 2(2 進制)表示的數中,最大值小於 3,所以無法解析,返回 NaN。
map 函數返回的是一個數組,所以最後結果爲 [1, NaN, NaN]。
什麼是防抖和節流?有什麼區別?如何實現?
難度:中等
防抖
觸發高頻事件後 n 秒內函數只會執行一次,如果 n 秒內高頻事件再次被觸發,則重新計算時間;
思路:每次觸發事件時都取消之前的延時調用方法:
乞丐版:
function debounce(fn) {
let timeout = null; // 創建一個標記用來存放定時器的返回值
return function () {
clearTimeout(timeout); // 每當用戶輸入的時候把前一個 setTimeout clear 掉
timeout = setTimeout(() => {
// 然後又創建一個新的 setTimeout
// 這樣就能保證輸入字符後的 interval 間隔內如果還有字符輸入的話,就不會執行 fn 函數
fn.apply(this, arguments);
}, 500);
};
}
function sayHi() {
console.log('防抖成功');
}
var inp = document.getElementById('inp');
inp.addEventListener('input', debounce(sayHi)); // 防抖
節流
高頻事件觸發,但在 n 秒內只會執行一次,所以節流會稀釋函數的執行頻率。
思路:每次觸發事件時都判斷當前是否有等待執行的延時函數。
乞丐版:
function throttle(fn) {
let canRun = true; // 通過閉包保存一個標記
return function () {
if (!canRun) return; // 在函數開頭判斷標記是否爲 true,不爲 true 則 return
canRun = false; // 立即設置爲 false
setTimeout(() => { // 將外部傳入的函數的執行放在 setTimeout 中
fn.apply(this, arguments);
// 最後在 setTimeout 執行完畢後再把標記設置爲 true(關鍵) 表示可以執行下一次循環了
// 當定時器沒有執行的時候標記永遠是 false,在開頭被 return 掉
canRun = true;
}, 500);
};
}
function sayHi(e) {
console.log(e.target.innerWidth, e.target.innerHeight);
}
window.addEventListener('resize', throttle(sayHi));
介紹下 Set、Map、WeakSet 和 WeakMap 的區別?
難度:中等
Set
- 成員唯一、無序且不重複;
- [value, value],鍵值與鍵名是一致的(或者說只有鍵值,沒有鍵名);
- 可以遍歷,方法有:add、delete、has。
WeakSet
- 成員都是對象;
- 成員都是弱引用,可以被垃圾回收機制回收,可以用來保存 DOM 節點,不容易造成內存泄漏;
- 不能遍歷,方法有 add、delete、has。
Map
- 本質上是鍵值對的集合,類似集合;
- 可以遍歷,方法很多,可以跟各種數據格式轉換。
WeakMap
- 只接受對象最爲鍵名(null 除外),不接受其他類型的值作爲鍵名;
- 鍵名是弱引用,鍵值可以是任意的,鍵名所指向的對象可以被垃圾回收,此時鍵名是無效的;
- 不能遍歷,方法有 get、set、has、delete。
介紹下深度優先遍歷和廣度優先遍歷,如何實現?
難度:中等
深度優先遍歷(DFS)
深度優先遍歷(Depth-First-Search),是搜索算法的一種,它沿着樹的深度遍歷樹的節點,儘可能深地搜索樹的分支。當節點 v 的所有邊都已被探尋過,將回溯到發現節點 v 的那條邊的起始節點。這一過程一直進行到已探尋源節點到其他所有節點爲止,如果還有未被發現的節點,則選擇其中一個未被發現的節點爲源節點並重復以上操作,直到所有節點都被探尋完成。
簡單的說,DFS 就是從圖中的一個節點開始追溯,直到最後一個節點,然後回溯,繼續追溯下一條路徑,直到到達所有的節點,如此往復,直到沒有路徑爲止。
DFS 可以產生相應圖的拓撲排序表,利用拓撲排序表可以解決很多問題,例如最大路徑問題。一般用堆數據結構來輔助實現 DFS 算法。
注意:深度 DFS 屬於盲目搜索,無法保證搜索到的路徑爲最短路徑,也不是在搜索特定的路徑,而是通過搜索來查看圖中有哪些路徑可以選擇。
步驟:
- 訪問頂點 v;
- 依次從 v 的未被訪問的鄰接點出發,對圖進行深度優先遍歷;直至圖中和 v 有路徑相通的頂點都被訪問;
- 若此時途中尚有頂點未被訪問,則從一個未被訪問的頂點出發,重新進行深度優先遍歷,直到所有頂點均被訪問過爲止。
實現
Graph.prototype.dfs = function() {
var marked = []
for (var i=0; i<this.vertices.length; i++) {
if (!marked[this.vertices[i]]) {
dfsVisit(this.vertices[i])
}
}
function dfsVisit(u) {
let edges = this.edges
marked[u] = true
console.log(u)
var neighbors = edges.get(u)
for (var i=0; i<neighbors.length; i++) {
var w = neighbors[i]
if (!marked[w]) {
dfsVisit(w)
}
}
}
}
廣度優先遍歷(BFS)
廣度優先遍歷(Breadth-First-Search)是從根節點開始,沿着圖的寬度遍歷節點,如果所有節點均被訪問過,則算法終止,BFS 同樣屬於盲目搜索,一般用隊列數據結構來輔助實現 BFS。
BFS 從一個節點開始,嘗試訪問儘可能靠近它的目標節點。本質上這種遍歷在圖上是逐層移動的,首先檢查最靠近第一個節點的層,再逐漸向下移動到離起始節點最遠的層。
步驟:
- 創建一個隊列,並將開始節點放入隊列中;
- 若隊列非空,則從隊列中取出第一個節點,並檢測它是否爲目標節點;
- 若是目標節點,則結束搜尋,並返回結果;
- 若不是,則將它所有沒有被檢測過的字節點都加入隊列中;
- 若隊列爲空,表示圖中並沒有目標節點,則結束遍歷。
實現
Graph.prototype.bfs = function(v) {
var queue = [], marked = []
marked[v] = true
queue.push(v) // 添加到隊尾
while(queue.length > 0) {
var s = queue.shift() // 從隊首移除
if (this.edges.has(s)) {
console.log('visited vertex: ', s)
}
let neighbors = this.edges.get(s)
for(let i=0;i<neighbors.length;i++) {
var w = neighbors[i]
if (!marked[w]) {
marked[w] = true
queue.push(w)
}
}
}
}
請寫出下面代碼的運行結果:
難度:中等
async function async1() {
console.log('async1 start')
await async2()
console.log('async1 end')
}
async function async2() {
console.log('async2')
}
console.log('script start')
setTimeout(function () {
console.log('settimeout')
})
async1()
new Promise(function (resolve) {
console.log('promise1')
resolve()
}).then(function () {
console.log('promise2')
})
console.log('script end')
題目的本質,就是考察setTimeout、promise、async await的實現及執行順序,以及 JS 的事件循環的相關問題。
答案
script start
async1 start
async2
promise1
script end
async1 end
promise2
settimeout
將數組扁平化並去除其中重複數據,最終得到一個升序且不重複的數組
難度:中等
Array.from(new Set(arr.flat(Infinity))).sort((a,b)=>{ return a-b})
JS 異步解決方案的發展歷程以及優缺點
難度:中等
回調函數(callback)
setTimeout(() => {
// callback 函數體
}, 1000)
缺點:回調地獄,不能用 try catch 捕獲錯誤,不能 return
回調地獄的根本問題在於:
- 缺乏順序性: 回調地獄導致的調試困難,和大腦的思維方式不符;
- 嵌套函數存在耦合性,一旦有所改動,就會牽一髮而動全身,即(控制反轉);
- 嵌套函數過多的多話,很難處理錯誤。
ajax('XXX1', () => {
// callback 函數體
ajax('XXX2', () => {
// callback 函數體
ajax('XXX3', () => {
// callback 函數體
})
})
})
Promise
Promise 就是爲了解決 callback 的問題而產生的。
Promise 實現了鏈式調用,也就是說每次 then 後返回的都是一個全新 Promise,如果我們在 then 中 return ,return 的結果會被 Promise.resolve() 包裝。
優點:解決了回調地獄的問題。
ajax('XXX1')
.then(res => {
// 操作邏輯
return ajax('XXX2')
}).then(res => {
// 操作邏輯
return ajax('XXX3')
}).then(res => {
// 操作邏輯
})
Generator
特點:可以控制函數的執行,可以配合 co.js 函數庫使用。(也就是 koa早期使用的庫)
function *fetch() {
yield ajax('XXX1', () => {})
yield ajax('XXX2', () => {})
yield ajax('XXX3', () => {})
}
let it = fetch()
let result1 = it.next()
let result2 = it.next()
let result3 = it.next()
Async/await
async、await 是異步的終極解決方案。
優點是:代碼清晰,不用像 Promise 寫一大堆 then 鏈,處理了回調地獄的問題;
缺點:await 將異步代碼改造成同步代碼,如果多個異步操作沒有依賴性而使用 await 會導致性能上的降低。
async function test() {
// 以下代碼沒有依賴性的話,完全可以使用 Promise.all 的方式
// 如果有依賴性的話,其實就是解決回調地獄的例子了
await fetch('XXX1')
await fetch('XXX2')
await fetch('XXX3')
}
下面來看一個使用 await 的例子:
let a = 0
let b = async () => {
a = a + await 10
console.log('2', a) // -> '2' 10
}
b()
a++
console.log('1', a) // -> '1' 1
上述解釋中提到了 await 內部實現了 generator,其實 await 就是 generator 加上 Promise的語法糖,且內部實現了自動執行 generator。如果你熟悉 co 的話,其實自己就可以實現這樣的語法糖。
最後
- 只看不點贊就是耍流氓!!!
- 歡迎關注公衆號「前端進階課」認真學前端,一起進階。