閱讀前重要提示:
本文非百科全書,只專為面試復(fù)習(xí)準(zhǔn)備、查漏補(bǔ)缺、深入某知識點(diǎn)的引子、了解相關(guān)面試題等準(zhǔn)備。
https://github.com/KieSun/fucking-frontend
筆者一直都是崇尚學(xué)會面試題底下涉及到的知識點(diǎn),而不是刷一大堆面試題,結(jié)果變了個題型就不會的那種。所以本文和別的面經(jīng)不一樣,旨在提煉面試題底下的常用知識點(diǎn),而不是甩一大堆面試題給各位看官。
數(shù)據(jù)類型
JS 數(shù)據(jù)類型分為兩大類,九個數(shù)據(jù)類型:
- 原始類型
- 對象類型
其中原始類型又分為七種類型,分別為:
- boolean
- number
- string
- undefined
- null
- symbol
- bigint
對象類型分為兩種,分別為:
- Object
- Function
其中 Object 中又包含了很多子類型,比如 Array、RegExp、Math、Map、Set 等等,也就不一一列出了。
原始類型存儲在棧上,對象類型存儲在堆上,但是它的引用地址還是存在棧上。
注意:以上結(jié)論前半句是不準(zhǔn)確的,更準(zhǔn)確的內(nèi)容我會在閉包章節(jié)里說明。
常見考點(diǎn)
- JS 類型有哪些?
- 大數(shù)相加、相乘算法題,可以直接使用 bigint,當(dāng)然再加上字符串的處理會更好。
- NaN 如何判斷
另外還有一類常見的題目是對于對象的修改,比如說往函數(shù)里傳一個對象進(jìn)去,函數(shù)內(nèi)部修改參數(shù)。
function test(person) {
person.age = 26
person = {}
return person
}
const p1 = {
age: 25
}
這類題目我們只需要牢記以下幾點(diǎn):
- 對象存儲的是引用地址,傳來傳去、賦值給別人那都是在傳遞值(存在棧上的那個內(nèi)容),別人一旦修改對象里的屬性,大家都被修改了。
- 但是一旦對象被重新賦值了,只要不是原對象被重新賦值,那么就永遠(yuǎn)不會修改原對象。
類型判斷
類型判斷有好幾種方式。
typeof
原始類型中除了 null,其它類型都可以通過 typeof 來判斷。
typeof null 的值為 object,這是因?yàn)橐粋€久遠(yuǎn)的 Bug,沒有細(xì)究的必要,了解即可。如果想具體判斷 null 類型的話直接 xxx === null 即可。
對于對象類型來說,typeof 只能具體判斷函數(shù)的類型為 function,其它均為 object。
instanceof
instanceof 內(nèi)部通過原型鏈的方式來判斷是否為構(gòu)建函數(shù)的實(shí)例,常用于判斷具體的對象類型。
[] instanceof Array
都說 instanceof 只能判斷對象類型,其實(shí)這個說法是不準(zhǔn)確的,我們是可以通過 hake 的方式得以實(shí)現(xiàn),雖然不會有人這樣去玩吧。
class CheckIsNumber {
static [Symbol.hasInstance](number) {
return typeof number === 'number'
}
}
// true
1 instanceof CheckIsNumber
另外其實(shí)我們還可以直接通過構(gòu)建函數(shù)來判斷類型:
// true
[].constructor === Array
Object.prototype.toString
前幾種方式或多或少都存在一些缺陷,Object.prototype.toString 綜合來看是最佳選擇,能判斷的類型最完整。
上圖是一部分類型判斷,更多的就不列舉了,[object XXX] 中的 XXX 就是判斷出來的類型。
isXXX API
同時還存在一些判斷特定類型的 API,選了兩個常見的:
常見考點(diǎn)
- JS 類型如何判斷,有哪幾種方式可用
- instanceof 原理
- 手寫 instanceof
類型轉(zhuǎn)換
類型轉(zhuǎn)換分為兩種情況,分別為強(qiáng)制轉(zhuǎn)換及隱式轉(zhuǎn)換。
強(qiáng)制轉(zhuǎn)換
強(qiáng)制轉(zhuǎn)換就是轉(zhuǎn)成特定的類型:
Number(false) // -> 0
Number('1') // -> 1
Number('zb') // -> NaN
(1).toString() // '1'
這部分是日常常用的內(nèi)容,就不具體展開說了,主要記住強(qiáng)制轉(zhuǎn)數(shù)字和布爾值的規(guī)則就行。
轉(zhuǎn)布爾值規(guī)則:
- undefined、null、false、NaN、''、0、-0 都轉(zhuǎn)為 false。
- 其他所有值都轉(zhuǎn)為 true,包括所有對象。
轉(zhuǎn)數(shù)字規(guī)則:
- true 為 1,false 為 0
- null 為 0,undefined 為 NaN,symbol 報錯
- 字符串看內(nèi)容,如果是數(shù)字或者進(jìn)制值就正常轉(zhuǎn),否則就 NaN
- 對象的規(guī)則隱式轉(zhuǎn)換再講
隱式轉(zhuǎn)換
隱式轉(zhuǎn)換規(guī)則是最煩的,其實(shí)筆者也記不住那么多內(nèi)容。況且根據(jù)筆者目前收集到的最新面試題來說,這部分考題基本絕跡了,當(dāng)然講還是講一下吧。
對象轉(zhuǎn)基本類型:
- 調(diào)用 Symbol.toPrimitive,轉(zhuǎn)成功就結(jié)束
- 調(diào)用 valueOf,轉(zhuǎn)成功就結(jié)束
- 調(diào)用 toString,轉(zhuǎn)成功就結(jié)束
- 報錯
四則運(yùn)算符:
- 只有當(dāng)加法運(yùn)算時,其中一方是字符串類型,就會把另一個也轉(zhuǎn)為字符串類型
- 其他運(yùn)算只要其中一方是數(shù)字,那么另一方就轉(zhuǎn)為數(shù)字
== 操作符
常見考點(diǎn)
如果這部分規(guī)則記不住也不礙事,確實(shí)有點(diǎn)繁瑣,而且考得也越來越少了,拿一道以前常考的題目看看吧:
[] == ![] // -> ?
this
this 是很多人會混淆的概念,但是其實(shí)他一點(diǎn)都不難,不要被那些長篇大論的文章嚇住了(我其實(shí)也不知道為什么他們能寫那么多字),你只需要記住幾個規(guī)則就可以了。
普通函數(shù)
function foo() {
console.log(this.a)
}
var a = 1
foo()
var obj = {
a: 2,
foo: foo
}
obj.foo()
// 以上情況就是看函數(shù)是被誰調(diào)用,那么 `this` 就是誰,沒有被對象調(diào)用,`this` 就是 `window`
// 以下情況是優(yōu)先級最高的,`this` 只會綁定在 `c` 上,不會被任何方式修改 `this` 指向
var c = new foo()
c.a = 3
console.log(c.a)
// 還有種就是利用 call,Apply,bind 改變 this,這個優(yōu)先級僅次于 new
箭頭函數(shù)
因?yàn)榧^函數(shù)沒有 this,所以一切妄圖改變箭頭函數(shù) this 指向都是無效的。
箭頭函數(shù)的 this 只取決于定義時的環(huán)境。比如如下代碼中的 fn 箭頭函數(shù)是在 windows 環(huán)境下定義的,無論如何調(diào)用,this 都指向 window。
var a = 1
const fn = () => {
console.log(this.a)
}
const obj = {
fn,
a: 2
}
obj.fn()
常見考點(diǎn)
這里一般都是考 this 的指向問題,牢記上述的幾個規(guī)則就夠用了,比如下面這道題:
const a = {
b: 2,
foo: function () { console.log(this.b) }
}
function b(foo) {
// 輸出什么?
foo()
}
b(a.foo)
閉包
首先閉包正確的定義是:假如一個函數(shù)能訪問外部的變量,那么這個函數(shù)它就是一個閉包,而不是一定要返回一個函數(shù)。這個定義很重要,下面的內(nèi)容需要用到。
let a = 1
// fn 是閉包
function fn() {
console.log(a);
}
function fn1() {
let a = 1
// 這里也是閉包
return () => {
console.log(a);
}
}
const fn2 = fn1()
fn2()
大家都知道閉包其中一個作用是訪問私有變量,就比如上述代碼中的 fn2 訪問到了 fn1 函數(shù)中的變量 a。但是此時 fn1 早已銷毀,我們是如何訪問到變量 a 的呢?不是都說原始類型是存放在棧上的么,為什么此時卻沒有被銷毀掉?
接下來筆者會根據(jù)瀏覽器的表現(xiàn)來重新理解關(guān)于原始類型存放位置的說法。
先來說下數(shù)據(jù)存放的正確規(guī)則是:局部、占用空間確定的數(shù)據(jù),一般會存放在棧中,否則就在堆中(也有例外)。 那么接下來我們可以通過 Chrome 來幫助我們驗(yàn)證這個說法說法。
上圖中畫紅框的位置我們能看到一個內(nèi)部的對象 [[Scopes]],其中存放著變量 a,該對象是被存放在堆上的,其中包含了閉包、全局對象等等內(nèi)容,因此我們能通過閉包訪問到本該銷毀的變量。
另外最開始我們對于閉包的定位是:假如一個函數(shù)能訪問外部的變量,那么這個函數(shù)它就是一個閉包,因此接下來我們看看在全局下的表現(xiàn)是怎么樣的。
let a = 1
var b = 2
// fn 是閉包
function fn() {
console.log(a, b);
}
從上圖我們能發(fā)現(xiàn)全局下聲明的變量,如果是 var 的話就直接被掛到 globe 上,如果是其他關(guān)鍵字聲明的話就被掛到 Script 上。雖然這些內(nèi)容同樣還是存在 [[Scopes]],但是全局變量應(yīng)該是存放在靜態(tài)區(qū)域的,因?yàn)槿肿兞繜o需進(jìn)行垃圾回收,等需要回收的時候整個應(yīng)用都沒了。
只有在下圖的場景中,原始類型才可能是被存儲在棧上。
這里為什么要說可能,是因?yàn)?JS 是門動態(tài)類型語言,一個變量聲明時可以是原始類型,馬上又可以賦值為對象類型,然后又回到原始類型。這樣頻繁的在堆棧上切換存儲位置,內(nèi)部引擎是不是也會有什么優(yōu)化手段,或者干脆全部都丟堆上?只有 const 聲明的原始類型才一定存在棧上?當(dāng)然這只是筆者的一個推測,暫時沒有深究,讀者可以忽略這段瞎想。
因此筆者對于原始類型存儲位置的理解為:局部變量才是被存儲在棧上,全局變量存在靜態(tài)區(qū)域上,其它都存儲在堆上。
當(dāng)然這個理解是建立的 Chrome 的表現(xiàn)之上的,在不同的瀏覽器上因?yàn)橐娴牟煌赡艽鎯Φ姆绞竭€是有所變化的。
常見考點(diǎn)
閉包能考得很多,概念和筆試題都會考。
概念題就是考考閉包是什么了。
筆試題的話基本都會結(jié)合上異步,比如最常見的:
for (var i = 0; i < 6; i++) {
setTimeout(() => {
console.log(i)
})
}
這道題會問輸出什么,有哪幾種方式可以得到想要的答案?
new
new 操作符可以幫助我們構(gòu)建出一個實(shí)例,并且綁定上 this,內(nèi)部執(zhí)行步驟可大概分為以下幾步:
- 新生成了一個對象
- 對象連接到構(gòu)造函數(shù)原型上,并綁定 this
- 執(zhí)行構(gòu)造函數(shù)代碼
- 返回新對象
在第四步返回新對象這邊有一個情況會例外:
function Test(name) {
this.name = name
console.log(this) // Test { name: 'yck' }
return { age: 26 }
}
const t = new Test('yck')
console.log(t) // { age: 26 }
console.log(t.name) // 'undefined'
當(dāng)在構(gòu)造函數(shù)中返回一個對象時,內(nèi)部創(chuàng)建出來的新對象就被我們返回的對象所覆蓋,所以一般來說構(gòu)建函數(shù)就別返回對象了(返回原始類型不影響)。
常見考點(diǎn)
- new 做了哪些事?
- new 返回不同的類型時會有什么表現(xiàn)?
- 手寫 new 的實(shí)現(xiàn)過程
作用域
作用域可以理解為變量的可訪問性,總共分為三種類型,分別為:
- 全局作用域
- 函數(shù)作用域
- 塊級作用域,ES6 中的 let、const 就可以產(chǎn)生該作用域
其實(shí)看完前面的閉包、this 這部分內(nèi)部的話,應(yīng)該基本能了解作用域的一些應(yīng)用。
一旦我們將這些作用域嵌套起來,就變成了另外一個重要的知識點(diǎn)「作用域鏈」,也就是 JS 到底是如何訪問需要的變量或者函數(shù)的。
首先作用域鏈?zhǔn)窃诙x時就被確定下來的,和箭頭函數(shù)里的 this 一樣,后續(xù)不會改變,JS 會一層層往上尋找需要的內(nèi)容。
其實(shí)作用域鏈這個東西我們在閉包小結(jié)中已經(jīng)看到過它的實(shí)體了:[[Scopes]]
圖中的 [[Scopes]] 是個數(shù)組,作用域的一層層往上尋找就等同于遍歷 [[Scopes]]。
常見考點(diǎn)
- 什么是作用域
- 什么是作用域鏈
原型
原型在面試?yán)镏恍枰獛拙湓挕⒁粡垐D的概念就夠用了,沒人會讓你長篇大論講上一堆內(nèi)容的,問原型更多的是為了引出繼承這個話題。
根據(jù)上圖,原型總結(jié)下來的概念為:
- 所有對象都有一個屬性 __proto__ 指向一個對象,也就是原型
- 每個對象的原型都可以通過 constructor 找到構(gòu)造函數(shù),構(gòu)造函數(shù)也可以通過 prototype 找到原型
- 所有函數(shù)都可以通過 __proto__ 找到 Function 對象
- 所有對象都可以通過 __proto__ 找到 Object 對象
- 對象之間通過 __proto__ 連接起來,這樣稱之為原型鏈。當(dāng)前對象上不存在的屬性可以通過原型鏈一層層往上查找,直到頂層 Object 對象,再往上就是 null 了
常見考點(diǎn)
- 聊聊你理解的原型是什么
繼承
即使是 ES6 中的 class 也不是其他語言里的類,本質(zhì)就是一個函數(shù)。
class Person {}
Person instanceof Function // true
其實(shí)在當(dāng)下都用 ES6 的情況下,ES5 的繼承寫法已經(jīng)沒啥學(xué)習(xí)的必要了,但是因?yàn)槊嬖囘€會被問到,所以復(fù)習(xí)一下還是需要的。
首先來說下 ES5 和 6 繼承的區(qū)別:
- ES6 繼承的子類需要調(diào)用 super() 才能拿到子類,ES5 的話是通過 apply 這種綁定的方式
- 類聲明不會提升,和 let 這些一致
接下來就是回字的幾種寫法的名場面了,ES5 實(shí)現(xiàn)繼承的方式有很多種,面試了解一種已經(jīng)夠用:
function Super() {}
Super.prototype.getNumber = function() {
return 1
}
function Sub() {}
Sub.prototype = Object.create(Super.prototype, {
constructor: {
value: Sub,
enumerable: false,
writable: true,
configurable: true
}
})
let s = new Sub()
s.getNumber()
常見考點(diǎn)
- JS 中如何實(shí)現(xiàn)繼承
- 通過原型實(shí)現(xiàn)的繼承和 class 有何區(qū)別
- 手寫任意一種原型繼承
深淺拷貝
淺拷貝
兩個對象第一層的引用不相同就是淺拷貝的含義。
我們可以通過 assign 、擴(kuò)展運(yùn)算符等方式來實(shí)現(xiàn)淺拷貝:
let a = {
age: 1
}
let b = Object.assign({}, a)
a.age = 2
console.log(b.age) // 1
b = {...a}
a.age = 3
console.log(b.age) // 2
深拷貝
兩個對象內(nèi)部所有的引用都不相同就是深拷貝的含義。
最簡單的深拷貝方式就是使用 JSON.parse(JSON.stringify(object)),但是該方法存在不少缺陷。
比如說只支持 JSON 支持的類型,JSON 是門通用的語言,并不支持 JS 中的所有類型。
同時還存在不能處理循環(huán)引用的問題:
如果想解決以上問題,我們可以通過遞歸的方式來實(shí)現(xiàn)代碼:
// 利用 WeakMap 解決循環(huán)引用
let map = new WeakMap()
function deepClone(obj) {
if (obj instanceof Object) {
if (map.has(obj)) {
return map.get(obj)
}
let newObj
if (obj instanceof Array) {
newObj = []
} else if (obj instanceof Function) {
newObj = function() {
return obj.apply(this, arguments)
}
} else if (obj instanceof RegExp) {
// 拼接正則
newobj = new RegExp(obj.source, obj.flags)
} else if (obj instanceof Date) {
newobj = new Date(obj)
} else {
newObj = {}
}
// 克隆一份對象出來
let desc = Object.getOwnPropertyDescriptors(obj)
let clone = Object.create(Object.getPrototypeOf(obj), desc)
map.set(obj, clone)
for (let key in obj) {
if (obj.hasOwnProperty(key)) {
newObj[key] = deepClone(obj[key])
}
}
return newObj
}
return obj
}
上述代碼解決了常見的類型以及循環(huán)引用的問題,當(dāng)然還是一部分缺陷的,但是面試時候能寫出上面的代碼已經(jīng)足夠了,剩下的能口述思路基本這道題就能拿到高分了。
比如說遞歸肯定會存在爆棧的問題,因?yàn)閳?zhí)行棧的大小是有限制的,到一定數(shù)量棧就會爆掉。
因此遇到這種問題,我們可以通過遍歷的方式來改寫遞歸。這個就是如何寫層序遍歷(BFS)的問題了,通過數(shù)組來模擬執(zhí)行棧就能解決爆棧問題,有興趣的讀者可以咨詢查閱。
Promise
Promise 是一個高頻考點(diǎn)了,但是更多的是在筆試題中出現(xiàn),概念題反倒基本沒有,多是來問 Event loop 的。
對于這塊內(nèi)容的復(fù)習(xí)我們需要熟悉涉及到的所有 API,因?yàn)榭碱}里可能會問到 all、race 等等用法或者需要你用這些 API 實(shí)現(xiàn)一些功能。
對于 Promise 進(jìn)階點(diǎn)的知識可以具體閱讀筆者的這篇文章,這里就不復(fù)制過來占用篇幅了:Promise 你真的弄明白了么?
常見考點(diǎn)
- 使用 all 實(shí)現(xiàn)并行需求
- Promise all 錯誤處理
- 手寫 all 的實(shí)現(xiàn)
另外還有一道很常見的串行題目:
頁面上有三個按鈕,分別為 A、B、C,點(diǎn)擊各個按鈕都會發(fā)送異步請求且互不影響,每次請求回來的數(shù)據(jù)都為按鈕的名字。 請實(shí)現(xiàn)當(dāng)用戶依次點(diǎn)擊 A、B、C、A、C、B 的時候,最終獲取的數(shù)據(jù)為 ABCACB。
這道題目主要兩個考點(diǎn):
- 請求不能阻塞,但是輸出可以阻塞。比如說 B 請求需要耗時 3 秒,其他請求耗時 1 秒,那么當(dāng)用戶點(diǎn)擊 BAC 時,三個請求都應(yīng)該發(fā)起,但是因?yàn)?B 請求回來的慢,所以得等著輸出結(jié)果。
- 如何實(shí)現(xiàn)一個隊(duì)列?
其實(shí)我們無需自己去構(gòu)建一個隊(duì)列,直接利用 promise.then 方法就能實(shí)現(xiàn)隊(duì)列的效果了。
class Queue {
promise = Promise.resolve();
excute(promise) {
this.promise = this.promise.then(() => promise);
return this.promise;
}
}
const queue = new Queue();
const delay = (params) => {
const time = Math.floor(Math.random() * 5);
return new Promise((resolve) => {
setTimeout(() => {
resolve(params);
}, time * 500);
});
};
const handleClick = async (name) => {
const res = await queue.excute(delay(name));
console.log(res);
};
handleClick('A');
handleClick('B');
handleClick('C');
handleClick('A');
handleClick('C');
handleClick('B');
async、await
await 和 promise 一樣,更多的是考筆試題,當(dāng)然偶爾也會問到和 promise 的一些區(qū)別。
await 相比直接使用 Promise 來說,優(yōu)勢在于處理 then 的調(diào)用鏈,能夠更清晰準(zhǔn)確的寫出代碼。缺點(diǎn)在于濫用 await 可能會導(dǎo)致性能問題,因?yàn)?nbsp;await 會阻塞代碼,也許之后的異步代碼并不依賴于前者,但仍然需要等待前者完成,導(dǎo)致代碼失去了并發(fā)性,此時更應(yīng)該使用 Promise.all。
下面來看一道很容易做錯的筆試題。
var a = 0
var b = async () => {
a = a + await 10
console.log('2', a) // -> ?
}
b()
a++
console.log('1', a) // -> ?
這道題目大部分讀者肯定會想到 await 左邊是異步代碼,因此會先把同步代碼執(zhí)行完,此時 a 已經(jīng)變成 1,所以答案應(yīng)該是 11。
其實(shí) a 為 0 是因?yàn)榧臃ㄟ\(yùn)算法,先算左邊再算右邊,所以會把 0 固定下來。如果我們把題目改成 await 10 + a 的話,答案就是 11 了。
事件循環(huán)
在開始講事件循環(huán)之前,我們一定要牢記一點(diǎn):JS 是一門單線程語言,在執(zhí)行過程中永遠(yuǎn)只能同時執(zhí)行一個任務(wù),任何異步的調(diào)用都只是在模擬這個過程,或者說可以直接認(rèn)為在 JS 中的異步就是延遲執(zhí)行的同步代碼。另外別的什么 Web worker、瀏覽器提供的各種線程都不會影響這個點(diǎn)。
大家應(yīng)該都知道執(zhí)行 JS 代碼就是往執(zhí)行棧里 push 函數(shù)(不知道的自己搜索吧),那么當(dāng)遇到異步代碼的時候會發(fā)生什么情況?
其實(shí)當(dāng)遇到異步的代碼時,只有當(dāng)遇到 Task、Microtask 的時候才會被掛起并在需要執(zhí)行的時候加入到 Task(有多種 Task) 隊(duì)列中。
從圖上我們得出兩個疑問:
- 什么任務(wù)會被丟到 Microtask Queue 和 Task Queue 中?它們分別代表了什么?
- Event loop 是如何處理這些 task 的?
首先我們來解決問題一。
Task(宏任務(wù)):同步代碼、setTimeout 回調(diào)、setInteval 回調(diào)、IO、UI 交互事件、postMessage、MessageChannel。
MicroTask(微任務(wù)):Promise 狀態(tài)改變以后的回調(diào)函數(shù)(then 函數(shù)執(zhí)行,如果此時狀態(tài)沒變,回調(diào)只會被緩存,只有當(dāng)狀態(tài)改變,緩存的回調(diào)函數(shù)才會被丟到任務(wù)隊(duì)列)、Mutation observer 回調(diào)函數(shù)、queueMicrotask 回調(diào)函數(shù)(新增的 API)。
宏任務(wù)會被丟到下一次事件循環(huán),并且宏任務(wù)隊(duì)列每次只會執(zhí)行一個任務(wù)。
微任務(wù)會被丟到本次事件循環(huán),并且微任務(wù)隊(duì)列每次都會執(zhí)行任務(wù)直到隊(duì)列為空。
假如每個微任務(wù)都會產(chǎn)生一個微任務(wù),那么宏任務(wù)永遠(yuǎn)都不會被執(zhí)行了。
接下來我們來解決問題二。
Event Loop 執(zhí)行順序如下所示:
- 執(zhí)行同步代碼
- 執(zhí)行完所有同步代碼后且執(zhí)行棧為空,判斷是否有微任務(wù)需要執(zhí)行
- 執(zhí)行所有微任務(wù)且微任務(wù)隊(duì)列為空
- 是否有必要渲染頁面
- 執(zhí)行一個宏任務(wù)
如果你覺得上面的表述不大理解的話,接下來我們通過代碼示例來鞏固理解上面的知識:
console.log('script start');
setTimeout(function() {
console.log('setTimeout');
}, 0);
Promise.resolve().then(function() {
queueMicrotask(() => console.log('queueMicrotask'))
console.log('promise');
});
console.log('script end');
- 遇到 console.log 執(zhí)行并打印
- 遇到 setTimeout,將回調(diào)加入宏任務(wù)隊(duì)列
- 遇到 Promise.resolve(),此時狀態(tài)已經(jīng)改變,因此將 then 回調(diào)加入微任務(wù)隊(duì)列
- 遇到 console.log 執(zhí)行并打印
此時同步任務(wù)全部執(zhí)行完畢,分別打印了 'script start' 以及 'script end',開始判斷是否有微任務(wù)需要執(zhí)行。
- 微任務(wù)隊(duì)列存在任務(wù),開始執(zhí)行 then 回調(diào)函數(shù)
- 遇到 queueMicrotask,將回到加入微任務(wù)隊(duì)列
- 遇到 console.log 執(zhí)行并打印
- 檢查發(fā)現(xiàn)微任務(wù)隊(duì)列存在任務(wù),執(zhí)行 queueMicrotask 回調(diào)
- 遇到 console.log 執(zhí)行并打印
此時發(fā)現(xiàn)微任務(wù)隊(duì)列已經(jīng)清空,判斷是否需要進(jìn)行 UI 渲染。
- 執(zhí)行宏任務(wù),開始執(zhí)行 setTimeout 回調(diào)
- 遇到 console.log 執(zhí)行并打印
執(zhí)行一個宏任務(wù)即結(jié)束,尋找是否存在微任務(wù),開始循環(huán)判斷...
其實(shí)事件循環(huán)沒啥難懂的,理解 JS 是個單線程語言,明白哪些是微宏任務(wù)、循環(huán)的順序就好了。
最后需要注意的一點(diǎn):正是因?yàn)?JS 是門單線程語言,只能同時執(zhí)行一個任務(wù)。因此所有的任務(wù)都可能因?yàn)橹叭蝿?wù)的執(zhí)行時間過長而被延遲執(zhí)行,尤其對于一些定時器而言。
常見考點(diǎn)
- 什么是事件循環(huán)?
- JS 的執(zhí)行原理?
- 哪些是微宏任務(wù)?
- 定時器是準(zhǔn)時的嗎?
模塊化
當(dāng)下模塊化主要就是 CommonJS 和 ES6 的 ESM 了,其它什么的 AMD、UMD 了解下就行了。
ESM 我想應(yīng)該沒啥好說的了,主要我們來聊聊 CommonJS 以及 ESM 和 CommonJS 的區(qū)別。
CommonJS
CommonJs 是 Node 獨(dú)有的規(guī)范,當(dāng)然 Webpack 也自己實(shí)現(xiàn)了這套東西,讓我們能在瀏覽器里跑起來這個規(guī)范。
// a.js
module.exports = {
a: 1
}
// or
exports.a = 1
// b.js
var module = require('./a.js')
module.a // -> log 1
在上述代碼中,module.exports 和 exports 很容易混淆,讓我們來看看大致內(nèi)部實(shí)現(xiàn)
// 基本實(shí)現(xiàn)
var module = {
exports: {} // exports 就是個空對象
}
// 這個是為什么 exports 和 module.exports 用法相似的原因
var exports = module.exports
var load = function (module) {
// 導(dǎo)出的東西
var a = 1
module.exports = a
return module.exports
};
根據(jù)上面的大致實(shí)現(xiàn),我們也能看出為什么對 exports 直接賦值不會有任何效果。
對于 CommonJS 和 ESM 的兩者區(qū)別是:
- 前者支持動態(tài)導(dǎo)入,也就是 require(${path}/xx.js),后者使用 import()
- 前者是同步導(dǎo)入,因?yàn)橛糜诜?wù)端,文件都在本地,同步導(dǎo)入即使卡住主線程影響也不大。而后者是異步導(dǎo)入,因?yàn)橛糜跒g覽器,需要下載文件,如果也采用同步導(dǎo)入會對渲染有很大影響
- 前者在導(dǎo)出時都是值拷貝,就算導(dǎo)出的值變了,導(dǎo)入的值也不會改變,所以如果想更新值,必須重新導(dǎo)入一次。但是后者采用實(shí)時綁定的方式,導(dǎo)入導(dǎo)出的值都指向同一個內(nèi)存地址,所以導(dǎo)入值會跟隨導(dǎo)出值變化
垃圾回收
本小結(jié)內(nèi)容建立在 V8 引擎之上。
首先聊垃圾回收之前我們需要知道堆棧到底是存儲什么數(shù)據(jù)的,當(dāng)然這塊內(nèi)容上文已經(jīng)講過,這里就不再贅述了。
接下來我們先來聊聊棧是如何垃圾回收的。其實(shí)棧的回收很簡單,簡單來說就是一個函數(shù) push 進(jìn)棧,執(zhí)行完畢以后 pop 出來就當(dāng)可以回收了。當(dāng)然我們往深層了講深層了講就是匯編里的東西了,操作 esp 和 ebp 指針,了解下即可。
然后就是堆如何回收垃圾了,這部分的話會分為兩個空間及多個算法。
兩個空間分別為新生代和老生代,我們分開來講每個空間中涉及到的算法。
新生代
新生代中的對象一般存活時間較短,空間也較小,使用 Scavenge GC 算法。
在新生代空間中,內(nèi)存空間分為兩部分,分別為 From 空間和 To 空間。在這兩個空間中,必定有一個空間是使用的,另一個空間是空閑的。新分配的對象會被放入 From 空間中,當(dāng) From 空間被占滿時,新生代 GC 就會啟動了。算法會檢查 From 空間中存活的對象并復(fù)制到 To 空間中,如果有失活的對象就會銷毀。當(dāng)復(fù)制完成后將 From 空間和 To 空間互換,這樣 GC 就結(jié)束了。
老生代
老生代中的對象一般存活時間較長且數(shù)量也多,使用了兩個算法,分別是標(biāo)記清除和標(biāo)記壓縮算法。
在講算法前,先來說下什么情況下對象會出現(xiàn)在老生代空間中:
- 新生代中的對象是否已經(jīng)經(jīng)歷過一次以上 Scavenge 算法,如果經(jīng)歷過的話,會將對象從新生代空間移到老生代空間中。
- To 空間的對象占比大小超過 25 %。在這種情況下,為了不影響到內(nèi)存分配,會將對象從新生代空間移到老生代空間中。
老生代中的空間很復(fù)雜,有如下幾個空間
enum AllocationSpace {
// TODO(v8:7464): Actually map this space's memory as read-only.
RO_SPACE, // 不變的對象空間
NEW_SPACE, // 新生代用于 GC 復(fù)制算法的空間
OLD_SPACE, // 老生代常駐對象空間
CODE_SPACE, // 老生代代碼對象空間
MAP_SPACE, // 老生代 map 對象
LO_SPACE, // 老生代大空間對象
NEW_LO_SPACE, // 新生代大空間對象
FIRST_SPACE = RO_SPACE,
LAST_SPACE = NEW_LO_SPACE,
FIRST_GROWABLE_PAGED_SPACE = OLD_SPACE,
LAST_GROWABLE_PAGED_SPACE = MAP_SPACE
};
在老生代中,以下情況會先啟動標(biāo)記清除算法:
- 某一個空間沒有分塊的時候
- 空間中被對象超過一定限制
- 空間不能保證新生代中的對象移動到老生代中
在這個階段中,會遍歷堆中所有的對象,然后標(biāo)記活的對象,在標(biāo)記完成后,銷毀所有沒有被標(biāo)記的對象。在標(biāo)記大型對內(nèi)存時,可能需要幾百毫秒才能完成一次標(biāo)記。這就會導(dǎo)致一些性能上的問題。為了解決這個問題,2011 年,V8 從 stop-the-world 標(biāo)記切換到增量標(biāo)志。在增量標(biāo)記期間,GC 將標(biāo)記工作分解為更小的模塊,可以讓 JS 應(yīng)用邏輯在模塊間隙執(zhí)行一會,從而不至于讓應(yīng)用出現(xiàn)停頓情況。但在 2018 年,GC 技術(shù)又有了一個重大突破,這項(xiàng)技術(shù)名為并發(fā)標(biāo)記。該技術(shù)可以讓 GC 掃描和標(biāo)記對象時,同時允許 JS 運(yùn)行,你可以點(diǎn)擊 該博客 詳細(xì)閱讀。
清除對象后會造成堆內(nèi)存出現(xiàn)碎片的情況,當(dāng)碎片超過一定限制后會啟動壓縮算法。在壓縮過程中,將活的對象向一端移動,直到所有對象都移動完成然后清理掉不需要的內(nèi)存。
其它考點(diǎn)
0.1 + 0.2 !== 0.3
因?yàn)?JS 采用 IEEE 754 雙精度版本(64位),并且只要采用 IEEE 754 的語言都有該問題。
不止 0.1 + 0.2 存在問題,0.7 + 0.1、0.2 + 0.4 同樣也存在問題。
存在問題的原因是浮點(diǎn)數(shù)用二進(jìn)制表示的時候是無窮的,因?yàn)榫鹊膯栴},兩個浮點(diǎn)數(shù)相加會造成截斷丟失精度,因此再轉(zhuǎn)換為十進(jìn)制就出了問題。
解決的辦法可以通過以下代碼:
export const addNum = (num1: number, num2: number) => {
let sq1;
let sq2;
let m;
try {
sq1 = num1.toString().split('.')[1].length;
} catch (e) {
sq1 = 0;
}
try {
sq2 = num2.toString().split('.')[1].length;
} catch (e) {
sq2 = 0;
}
m = Math.pow(10, Math.max(sq1, sq2));
return (Math.round(num1 * m) + Math.round(num2 * m)) / m;
};
核心就是計算出兩個浮點(diǎn)數(shù)最大的小數(shù)長度,比如說 0.1 + 0.22 的小數(shù)最大長度為 2,然后兩數(shù)乘上 10 的 2次冪再相加得出數(shù)字 32,然后除以 10 的 2次冪即可得出正確答案 0.32。
手寫題
防抖
你是否在日常開發(fā)中遇到一個問題,在滾動事件中需要做個復(fù)雜計算或者實(shí)現(xiàn)一個按鈕的防二次點(diǎn)擊操作。
這些需求都可以通過函數(shù)防抖動來實(shí)現(xiàn)。尤其是第一個需求,如果在頻繁的事件回調(diào)中做復(fù)雜計算,很有可能導(dǎo)致頁面卡頓,不如將多次計算合并為一次計算,只在一個精確點(diǎn)做操作。
PS:防抖和節(jié)流的作用都是防止函數(shù)多次調(diào)用。區(qū)別在于,假設(shè)一個用戶一直觸發(fā)這個函數(shù),且每次觸發(fā)函數(shù)的間隔小于閾值,防抖的情況下只會調(diào)用一次,而節(jié)流會每隔一定時間調(diào)用函數(shù)。
我們先來看一個袖珍版的防抖理解一下防抖的實(shí)現(xiàn):
// func是用戶傳入需要防抖的函數(shù)
// wait是等待時間
const debounce = (func, wait = 50) => {
// 緩存一個定時器id
let timer = 0
// 這里返回的函數(shù)是每次用戶實(shí)際調(diào)用的防抖函數(shù)
// 如果已經(jīng)設(shè)定過定時器了就清空上一次的定時器
// 開始一個新的定時器,延遲執(zhí)行用戶傳入的方法
return function(...args) {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
func.apply(this, args)
}, wait)
}
}
// 不難看出如果用戶調(diào)用該函數(shù)的間隔小于 wait 的情況下,上一次的時間還未到就被清除了,并不會執(zhí)行函數(shù)
這是一個簡單版的防抖,但是有缺陷,這個防抖只能在最后調(diào)用。一般的防抖會有immediate選項(xiàng),表示是否立即調(diào)用。這兩者的區(qū)別,舉個例子來說:
- 例如在搜索引擎搜索問題的時候,我們當(dāng)然是希望用戶輸入完最后一個字才調(diào)用查詢接口,這個時候適用延遲執(zhí)行的防抖函數(shù),它總是在一連串(間隔小于wait的)函數(shù)觸發(fā)之后調(diào)用。
- 例如用戶給interviewMap點(diǎn)star的時候,我們希望用戶點(diǎn)第一下的時候就去調(diào)用接口,并且成功之后改變star按鈕的樣子,用戶就可以立馬得到反饋是否star成功了,這個情況適用立即執(zhí)行的防抖函數(shù),它總是在第一次調(diào)用,并且下一次調(diào)用必須與前一次調(diào)用的時間間隔大于wait才會觸發(fā)。
下面我們來實(shí)現(xiàn)一個帶有立即執(zhí)行選項(xiàng)的防抖函數(shù)
// 這個是用來獲取當(dāng)前時間戳的
function now() {
return +new Date()
}
/**
* 防抖函數(shù),返回函數(shù)連續(xù)調(diào)用時,空閑時間必須大于或等于 wait,func 才會執(zhí)行
*
* @param {function} func 回調(diào)函數(shù)
* @param {number} wait 表示時間窗口的間隔
* @param {boolean} immediate 設(shè)置為ture時,是否立即調(diào)用函數(shù)
* @return {function} 返回客戶調(diào)用函數(shù)
*/
function debounce (func, wait = 50, immediate = true) {
let timer, context, args
// 延遲執(zhí)行函數(shù)
const later = () => setTimeout(() => {
// 延遲函數(shù)執(zhí)行完畢,清空緩存的定時器序號
timer = null
// 延遲執(zhí)行的情況下,函數(shù)會在延遲函數(shù)中執(zhí)行
// 使用到之前緩存的參數(shù)和上下文
if (!immediate) {
func.apply(context, args)
context = args = null
}
}, wait)
// 這里返回的函數(shù)是每次實(shí)際調(diào)用的函數(shù)
return function(...params) {
// 如果沒有創(chuàng)建延遲執(zhí)行函數(shù)(later),就創(chuàng)建一個
if (!timer) {
timer = later()
// 如果是立即執(zhí)行,調(diào)用函數(shù)
// 否則緩存參數(shù)和調(diào)用上下文
if (immediate) {
func.apply(this, params)
} else {
context = this
args = params
}
// 如果已有延遲執(zhí)行函數(shù)(later),調(diào)用的時候清除原來的并重新設(shè)定一個
// 這樣做延遲函數(shù)會重新計時
} else {
clearTimeout(timer)
timer = later()
}
}
}
整體函數(shù)實(shí)現(xiàn)的不難,總結(jié)一下。
- 對于按鈕防點(diǎn)擊來說的實(shí)現(xiàn):如果函數(shù)是立即執(zhí)行的,就立即調(diào)用,如果函數(shù)是延遲執(zhí)行的,就緩存上下文和參數(shù),放到延遲函數(shù)中去執(zhí)行。一旦我開始一個定時器,只要我定時器還在,你每次點(diǎn)擊我都重新計時。一旦你點(diǎn)累了,定時器時間到,定時器重置為 null,就可以再次點(diǎn)擊了。
- 對于延時執(zhí)行函數(shù)來說的實(shí)現(xiàn):清除定時器ID,如果是延遲調(diào)用就調(diào)用函數(shù)
節(jié)流
防抖動和節(jié)流本質(zhì)是不一樣的。防抖動是將多次執(zhí)行變?yōu)樽詈笠淮螆?zhí)行,節(jié)流是將多次執(zhí)行變成每隔一段時間執(zhí)行。
/**
* underscore 節(jié)流函數(shù),返回函數(shù)連續(xù)調(diào)用時,func 執(zhí)行頻率限定為 次 / wait
*
* @param {function} func 回調(diào)函數(shù)
* @param {number} wait 表示時間窗口的間隔
* @param {object} options 如果想忽略開始函數(shù)的的調(diào)用,傳入{leading: false}。
* 如果想忽略結(jié)尾函數(shù)的調(diào)用,傳入{trailing: false}
* 兩者不能共存,否則函數(shù)不能執(zhí)行
* @return {function} 返回客戶調(diào)用函數(shù)
*/
_.throttle = function(func, wait, options) {
var context, args, result;
var timeout = null;
// 之前的時間戳
var previous = 0;
// 如果 options 沒傳則設(shè)為空對象
if (!options) options = {};
// 定時器回調(diào)函數(shù)
var later = function() {
// 如果設(shè)置了 leading,就將 previous 設(shè)為 0
// 用于下面函數(shù)的第一個 if 判斷
previous = options.leading === false ? 0 : _.now();
// 置空一是為了防止內(nèi)存泄漏,二是為了下面的定時器判斷
timeout = null;
result = func.apply(context, args);
if (!timeout) context = args = null;
};
return function() {
// 獲得當(dāng)前時間戳
var now = _.now();
// 首次進(jìn)入前者肯定為 true
// 如果需要第一次不執(zhí)行函數(shù)
// 就將上次時間戳設(shè)為當(dāng)前的
// 這樣在接下來計算 remaining 的值時會大于0
if (!previous && options.leading === false) previous = now;
// 計算剩余時間
var remaining = wait - (now - previous);
context = this;
args = arguments;
// 如果當(dāng)前調(diào)用已經(jīng)大于上次調(diào)用時間 + wait
// 或者用戶手動調(diào)了時間
// 如果設(shè)置了 trailing,只會進(jìn)入這個條件
// 如果沒有設(shè)置 leading,那么第一次會進(jìn)入這個條件
// 還有一點(diǎn),你可能會覺得開啟了定時器那么應(yīng)該不會進(jìn)入這個 if 條件了
// 其實(shí)還是會進(jìn)入的,因?yàn)槎〞r器的延時
// 并不是準(zhǔn)確的時間,很可能你設(shè)置了2秒
// 但是他需要2.2秒才觸發(fā),這時候就會進(jìn)入這個條件
if (remaining <= 0 || remaining > wait) {
// 如果存在定時器就清理掉否則會調(diào)用二次回調(diào)
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
previous = now;
result = func.apply(context, args);
if (!timeout) context = args = null;
} else if (!timeout && options.trailing !== false) {
// 判斷是否設(shè)置了定時器和 trailing
// 沒有的話就開啟一個定時器
// 并且不能不能同時設(shè)置 leading 和 trailing
timeout = setTimeout(later, remaining);
}
return result;
};
};
Event Bus
class Events {
constructor() {
this.events = new Map();
}
addEvent(key, fn, isOnce, ...args) {
const value = this.events.get(key) ? this.events.get(key) : this.events.set(key, new Map()).get(key)
value.set(fn, (...args1) => {
fn(...args, ...args1)
isOnce && this.off(key, fn)
})
}
on(key, fn, ...args) {
if (!fn) {
console.error(`沒有傳入回調(diào)函數(shù)`);
return
}
this.addEvent(key, fn, false, ...args)
}
fire(key, ...args) {
if (!this.events.get(key)) {
console.warn(`沒有 ${key} 事件`);
return;
}
for (let [, cb] of this.events.get(key).entries()) {
cb(...args);
}
}
off(key, fn) {
if (this.events.get(key)) {
this.events.get(key).delete(fn);
}
}
once(key, fn, ...args) {
this.addEvent(key, fn, true, ...args)
}
}
instanceof
instanceof 可以正確的判斷對象的類型,因?yàn)閮?nèi)部機(jī)制是通過判斷對象的原型鏈中是不是能找到類型的 prototype。
function instanceof(left, right) {
// 獲得類型的原型
let prototype = right.prototype
// 獲得對象的原型
left = left.__proto__
// 判斷對象的類型是否等于類型的原型
while (true) {
if (left === null)
return false
if (prototype === left)
return true
left = left.__proto__
}
}
call
Function.prototype.myCall = function(context, ...args) {
context = context || window
let fn = Symbol()
context[fn] = this
let result = context[fn](...args)
delete context[fn]
return result
}
apply
Function.prototype.myApply = function(context) {
context = context || window
let fn = Symbol()
context[fn] = this
let result
if (arguments[1]) {
result = context[fn](...arguments[1])
} else {
result = context[fn]()
}
delete context[fn]
return result
}
bind
Function.prototype.myBind = function (context) {
var _this = this
var args = [...arguments].slice(1)
// 返回一個函數(shù)
return function F() {
// 因?yàn)榉祷亓艘粋€函數(shù),我們可以 new F(),所以需要判斷
if (this instanceof F) {
return new _this(...args, ...arguments)
}
return _this.apply(context, args.concat(...arguments))
}
}
其他
其他手寫題上文已經(jīng)有提及,比如模擬 new、ES5 實(shí)現(xiàn)繼承、深拷貝。
另外大家可能經(jīng)常能看到手寫 Promise 的文章,其實(shí)根據(jù)筆者目前收集到的數(shù)百道面試題以及讀者的反饋來看,壓根就沒人遇到這個考點(diǎn),所以我們大可不必在這上面花時間。






