1. 程式人生 > >JS 進階知識點及常考面試題

JS 進階知識點及常考面試題

將會學習到一些原理相關的知識,不會解釋涉及到的知識點的作用及用法,如果大家對於這些內容還不怎麼熟悉,推薦先去學習相關的知識點內容再來學習原理知識。

手寫 call、apply 及 bind 函式

涉及面試題:call、apply 及 bind 函式內部實現是怎麼樣的?

首先從以下幾點來考慮如何實現這幾個函式

  • 不傳入第一個引數,那麼上下文預設為 window
  • 改變了 this 指向,讓新的物件可以執行該函式,並能接受引數

那麼我們先來實現 call

Function.prototype.myCall = function(context) {
  if (typeof this !== 'function') { throw new TypeError('Error') } context = context || window context.fn = this const args = [...arguments].slice(1) const result = context.fn(...args) delete context.fn return result } 

以下是對實現的分析:

  • 首先 context 為可選引數,如果不傳的話預設上下文為 window
  • 接下來給 context 建立一個 fn 屬性,並將值設定為需要呼叫的函式
  • 因為 call 可以傳入多個引數作為呼叫函式的引數,所以需要將引數剝離出來
  • 然後呼叫函式並將物件上的函式刪除

以上就是實現 call 的思路,apply 的實現也類似,區別在於對引數的處理,所以就不一一分析思路了

Function.prototype.myApply = function(context) {
  if (typeof this !== 'function') { throw new TypeError('Error') } context = context || window context.fn = this let result // 處理引數和 call 有區別 if (arguments[1]) { result = context.fn(...arguments[1]) } else { result = context.fn() } delete context.fn return result } 

bind 的實現對比其他兩個函式略微地複雜了一點,因為 bind 需要返回一個函式,需要判斷一些邊界問題,以下是 bind 的實現

Function.prototype.myBind = function (context) {
  if (typeof this !== 'function') { throw new TypeError('Error') } const _this = this const args = [...arguments].slice(1) // 返回一個函式 return function F() { // 因為返回了一個函式,我們可以 new F(),所以需要判斷 if (this instanceof F) { return new _this(...args, ...arguments) } return _this.apply(context, args.concat(...arguments)) } } 

以下是對實現的分析:

  • 前幾步和之前的實現大相徑庭,就不贅述了
  • bind 返回了一個函式,對於函式來說有兩種方式呼叫,一種是直接呼叫,一種是通過 new 的方式,我們先來說直接呼叫的方式
  • 對於直接呼叫來說,這裡選擇了 apply 的方式實現,但是對於引數需要注意以下情況:因為 bind 可以實現類似這樣的程式碼 f.bind(obj, 1)(2),所以我們需要將兩邊的引數拼接起來,於是就有了這樣的實現 args.concat(...arguments)
  • 最後來說通過 new 的方式,在之前的章節中我們學習過如何判斷 this,對於 new 的情況來說,不會被任何方式改變 this,所以對於這種情況我們需要忽略傳入的 this

new

涉及面試題:new 的原理是什麼?通過 new 的方式建立物件和通過字面量建立有什麼區別?

在呼叫 new 的過程中會發生以上四件事情:

  1. 新生成了一個物件
  2. 連結到原型
  3. 繫結 this
  4. 返回新物件

根據以上幾個過程,我們也可以試著來自己實現一個 new

function create() {
  let obj = {} let Con = [].shift.call(arguments) obj.__proto__ = Con.prototype let result = Con.apply(obj, arguments) return result instanceof Object ? result : obj } 

以下是對實現的分析:

  • 建立一個空物件
  • 獲取建構函式
  • 設定空物件的原型
  • 繫結 this 並執行建構函式
  • 確保返回值為物件

對於物件來說,其實都是通過 new 產生的,無論是 function Foo() 還是 let a = { b : 1 } 。

對於建立一個物件來說,更推薦使用字面量的方式建立物件(無論效能上還是可讀性)。因為你使用 new Object() 的方式建立物件需要通過作用域鏈一層層找到 Object,但是你使用字面量的方式就沒這個問題。

function Foo() {}
// function 就是個語法糖 // 內部等同於 new Function() let a = { b: 1 } // 這個字面量內部也是使用了 new Object() 

instanceof 的原理

涉及面試題:instanceof 的原理是什麼?

instanceof 可以正確的判斷物件的型別,因為內部機制是通過判斷物件的原型鏈中是不是能找到型別的 prototype

我們也可以試著實現一下 instanceof

function myInstanceof(left, right) {
  let prototype = right.prototype left = left.__proto__ while (true) { if (left === null || left === undefined) return false if (prototype === left) return true left = left.__proto__ } } 

以下是對實現的分析:

  • 首先獲取型別的原型
  • 然後獲得物件的原型
  • 然後一直迴圈判斷物件的原型是否等於型別的原型,直到物件原型為 null,因為原型鏈最終為 null

為什麼 0.1 + 0.2 != 0.3

涉及面試題:為什麼 0.1 + 0.2 != 0.3?如何解決這個問題?

先說原因,因為 JS 採用 IEEE 754 雙精度版本(64位),並且只要採用 IEEE 754 的語言都有該問題。

我們都知道計算機是通過二進位制來儲存東西的,那麼 0.1 在二進位制中會表示為

// (0011) 表示迴圈
0.1 = 2^-4 * 1.10011(0011) 

我們可以發現,0.1 在二進位制中是無限迴圈的一些數字,其實不只是 0.1,其實很多十進位制小數用二進位制表示都是無限迴圈的。這樣其實沒什麼問題,但是 JS 採用的浮點數標準卻會裁剪掉我們的數字。

IEEE 754 雙精度版本(64位)將 64 位分為了三段

  • 第一位用來表示符號
  • 接下去的 11 位用來表示指數
  • 其他的位數用來表示有效位,也就是用二進位制表示 0.1 中的 10011(0011)

那麼這些迴圈的數字被裁剪了,就會出現精度丟失的問題,也就造成了 0.1 不再是 0.1 了,而是變成了 0.100000000000000002

0.100000000000000002 === 0.1 // true

那麼同樣的,0.2 在二進位制也是無限迴圈的,被裁剪後也失去了精度變成了 0.200000000000000002

0.200000000000000002 === 0.2 // true

所以這兩者相加不等於 0.3 而是 0.300000000000000004

0.1 + 0.2 === 0.30000000000000004 // true

那麼可能你又會有一個疑問,既然 0.1 不是 0.1,那為什麼 console.log(0.1) 卻是正確的呢?

因為在輸入內容的時候,二進位制被轉換為了十進位制,十進位制又被轉換為了字串,在這個轉換的過程中發生了取近似值的過程,所以打印出來的其實是一個近似值,你也可以通過以下程式碼來驗證

console.log(0.100000000000000002) // 0.1

那麼說完了為什麼,最後來說說怎麼解決這個問題吧。其實解決的辦法有很多,這裡我們選用原生提供的方式來最簡單的解決問題

parseFloat((0.1 + 0.2).toFixed(10)) === 0.3 // true 

垃圾回收機制

涉及面試題:V8 下的垃圾回收機制是怎麼樣的?

V8 實現了準確式 GC,GC 演算法採用了分代式垃圾回收機制。因此,V8 將記憶體(堆)分為新生代和老生代兩部分。

新生代演算法

新生代中的物件一般存活時間較短,使用 Scavenge GC 演算法。

在新生代空間中,記憶體空間分為兩部分,分別為 From 空間和 To 空間。在這兩個空間中,必定有一個空間是使用的,另一個空間是空閒的。新分配的物件會被放入 From 空間中,當 From 空間被佔滿時,新生代 GC 就會啟動了。演算法會檢查 From 空間中存活的物件並複製到 To 空間中,如果有失活的物件就會銷燬。當複製完成後將 From 空間和 To 空間互換,這樣 GC 就結束了。

老生代演算法

老生代中的物件一般存活時間較長且數量也多,使用了兩個演算法,分別是標記清除演算法和標記壓縮演算法。

在講演算法前,先來說下什麼情況下物件會出現在老生代空間中:

  • 新生代中的物件是否已經經歷過一次 Scavenge 演算法,如果經歷過的話,會將物件從新生代空間移到老生代空間中。
  • To 空間的物件佔比大小超過 25 %。在這種情況下,為了不影響到記憶體分配,會將物件從新生代空間移到老生代空間中。

老生代中的空間很複雜,有如下幾個空間

enum AllocationSpace {
  // TODO(v8:7464): Actually map this space's memory as read-only.
  RO_SPACE,    // 不變的物件空間
  NEW_SPACE,   // 新生代用於 GC 複製演算法的空間
  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 }; 

在老生代中,以下情況會先啟動標記清除演算法:

  • 某一個空間沒有分塊的時候
  • 空間中被物件超過一定限制
  • 空間不能保證新生代中的物件移動到老生代中

在這個階段中,會遍歷堆中所有的物件,然後標記活的物件,在標記完成後,銷燬所有沒有被標記的物件。在標記大型對記憶體時,可能需要幾百毫秒才能完成一次標記。這就會導致一些效能上的問題。為了解決這個問題,2011 年,V8 從 stop-the-world 標記切換到增量標誌。在增量標記期間,GC 將標記工作分解為更小的模組,可以讓 JS 應用邏輯在模組間隙執行一會,從而不至於讓應用出現停頓情況。但在 2018 年,GC 技術又有了一個重大突破,這項技術名為併發標記。該技術可以讓 GC 掃描和標記物件時,同時允許 JS 執行,你可以點選 該部落格 詳細閱讀。

清除物件後會造成堆記憶體出現碎片的情況,當碎片超過一定限制後會啟動壓縮演算法。在壓縮過程中,將活的物件像一端移動,直到所有物件都移動完成然後清理掉不需要的記憶體。

小結

以上就是 JS 進階知識點的內容了,這部分的知識相比於之前的內容更加深入也更加的理論,也是在面試中能夠於別的候選者拉開差距的一塊內容。如果大家對於這個章節的內容存在疑問,歡迎在評論區與我互動。