每天10個前端小知識

語言: CN / TW / HK

前端面試基礎知識題

1. 什麼是尾呼叫優化和尾遞迴?

尾呼叫的概念非常簡單,一句話就能說清楚,就是指某個函式的最後一步是呼叫另一個函式。
function f(x){ return g(x); } 上面程式碼中,函式f的最後一步是呼叫函式g,這就叫尾呼叫。
尾呼叫優化
尾呼叫之所以與其他呼叫不同,就在於它的特殊的呼叫位置。
我們知道,函式呼叫會在記憶體形成一個"呼叫記錄",又稱"呼叫幀"(call frame),儲存呼叫位置和內部變數等資訊。如果在函式A的內部呼叫函式B,那麼在A的呼叫記錄上方,還會形成一個B的呼叫記錄。等到B執行結束,將結果返回到A,B的呼叫記錄才會消失。如果函式B內部還呼叫函式C,那就還有一個C的呼叫記錄棧,以此類推。所有的呼叫記錄,就形成一個"呼叫棧"(call stack)。
尾呼叫由於是函式的最後一步操作,所以不需要保留外層函式的呼叫記錄,因為呼叫位置、內部變數等資訊都不會再用到了,只要直接用內層函式的呼叫記錄,取代外層函式的呼叫記錄就可以了。

``` function f() { let m = 1; let n = 2; return g(m + n); } f();

// 等同於 function f() { return g(3); } f();

// 等同於 g(3); ``` 上面程式碼中,如果函式g不是尾呼叫,函式f就需要儲存內部變數m和n的值、g的呼叫位置等資訊。但由於呼叫g之後,函式f就結束了,所以執行到最後一步,完全可以刪除 f() 的呼叫記錄,只保留 g(3) 的呼叫記錄。
這就叫做"尾呼叫優化"(Tail call optimization),即只保留內層函式的呼叫記錄。如果所有函式都是尾呼叫,那麼完全可以做到每次執行時,呼叫記錄只有一項,這將大大節省記憶體。這就是"尾呼叫優化"的意義。
尾遞迴
函式呼叫自身,稱為遞迴。如果尾呼叫自身,就稱為尾遞迴。
遞迴非常耗費記憶體,因為需要同時儲存成千上百個呼叫記錄,很容易發生"棧溢位"錯誤(stack overflow)。但對於尾遞迴來說,由於只存在一個呼叫記錄,所以永遠不會發生"棧溢位"錯誤。

function factorial(n) { if (n === 1) return 1; return n * factorial(n - 1); } factorial(5) // 120 上面程式碼是一個階乘函式,計算n的階乘,最多需要儲存n個呼叫記錄,複雜度 O(n) 。如果改寫成尾遞迴,只保留一個呼叫記錄,複雜度 O(1) 。

function factorial(n, total) { if (n === 1) return total; return factorial(n - 1, n * total); } factorial(5, 1) // 120 "尾呼叫優化"對遞迴操作意義重大,所以一些函數語言程式設計語言將其寫入了語言規格。ES6也是如此,第一次明確規定,所有 ECMAScript 的實現,都必須部署"尾呼叫優化"。這就是說,在 ES6 中,只要使用尾遞迴,就不會發生棧溢位,相對節省記憶體。

2. 堆與棧有什麼區別?

堆(Heap)與棧(Stack)是開發人員必須面對的兩個概念,在理解這兩個概念時,需要放到具體的場景下,因為不同場景下,堆與棧代表不同的含義。一般情況下,有兩層含義:

  • 程式記憶體佈局場景下,堆與棧表示兩種記憶體管理方式;
  • 資料結構場景下,堆與棧表示兩種常用的資料結構。

程式記憶體分割槽中的堆與棧

棧簡介
棧由作業系統自動分配釋放 ,用於存放函式的引數值、區域性變數等,其操作方式類似於資料結構中的棧。
其中函式中定義的區域性變數按照先後定義的順序依次壓入棧中,也就是說相鄰變數的地址之間不會存在其它變數。棧的記憶體地址生長方向與堆相反,由高到底,所以後定義的變數地址低於先定義的變數,比如上面程式碼中變數 s 的地址小於變數 b 的地址,p2 地址小於 s 的地址。棧中儲存的資料的生命週期隨著函式的執行完成而結束。

堆簡介
堆由開發人員分配和釋放, 若開發人員不釋放,程式結束時由 OS 回收,分配方式類似於連結串列。
堆的記憶體地址生長方向與棧相反,由低到高,但需要注意的是,後申請的記憶體空間並不一定在先申請的記憶體空間的後面,即 p2 指向的地址並不一定大於 p1 所指向的記憶體地址,原因是先申請的記憶體空間一旦被釋放,後申請的記憶體空間則會利用先前被釋放的記憶體,從而導致先後分配的記憶體空間在地址上不存在先後關係。堆中儲存的資料若未釋放,則其生命週期等同於程式的生命週期。
關於堆上記憶體空間的分配過程,首先應該知道作業系統有一個記錄空閒記憶體地址的連結串列,當系統收到程式的申請時,會遍歷該連結串列,尋找第一個空間大於所申請空間的堆節點,然後將該節點從空閒節點連結串列中刪除,並將該節點的空間分配給程式。另外,對於大多數系統,會在這塊記憶體空間中的首地址處記錄本次分配的大小,這樣,程式碼中的delete語句才能正確地釋放本記憶體空間。由於找到的堆節點的大小不一定正好等於申請的大小,系統會自動地將多餘的那部分重新放入空閒連結串列。

堆與棧區別

堆與棧實際上是作業系統對程序佔用的記憶體空間的兩種管理方式,主要有如下幾種區別:

(1)管理方式不同。棧由作業系統自動分配釋放,無需我們手動控制;堆的申請和釋放工作由程式設計師控制,容易產生記憶體洩漏;

(2)空間大小不同。每個程序擁有的棧的大小要遠遠小於堆的大小。理論上,程式設計師可申請的堆大小為虛擬記憶體的大小,程序棧的大小 64bits 的 Windows 預設 1MB,64bits 的 Linux 預設 10MB;

(3)生長方向不同。堆的生長方向向上,記憶體地址由低到高;棧的生長方向向下,記憶體地址由高到低。

(4)分配方式不同。堆都是動態分配的,沒有靜態分配的堆。棧有2種分配方式:靜態分配和動態分配。靜態分配是由作業系統完成的,比如區域性變數的分配。動態分配由alloca函式進行分配,但是棧的動態分配和堆是不同的,他的動態分配是由作業系統進行釋放,無需我們手工實現。

(5)分配效率不同。棧由作業系統自動分配,會在硬體層級對棧提供支援:分配專門的暫存器存放棧的地址,壓棧出棧都有專門的指令執行,這就決定了棧的效率比較高。堆則是由C/C++提供的庫函式或運算子來完成申請與管理,實現機制較為複雜,頻繁的記憶體申請容易產生記憶體碎片。顯然,堆的效率比棧要低得多。

(6)存放內容不同。棧存放的內容,函式返回地址、相關引數、區域性變數和暫存器內容等。當主函式呼叫另外一個函式的時候,要對當前函式執行斷點進行儲存,需要使用棧來實現,首先入棧的是主函式下一條語句的地址,即擴充套件指標暫存器的內容(EIP),然後是當前棧幀的底部地址,即擴充套件基址指標暫存器內容(EBP),再然後是被調函式的實參等,一般情況下是按照從右向左的順序入棧,之後是被調函式的區域性變數,注意靜態變數是存放在資料段或者BSS段,是不入棧的。出棧的順序正好相反,最終棧頂指向主函式下一條語句的地址,主程式又從該地址開始執行。堆,一般情況堆頂使用一個位元組的空間來存放堆的大小,而堆中具體存放內容是由程式設計師來填充的。

從以上可以看到,堆和棧相比,由於大量malloc()/free()或new/delete的使用,容易造成大量的記憶體碎片,並且可能引發使用者態和核心態的切換,效率較低。棧相比於堆,在程式中應用較為廣泛,最常見的是函式的呼叫過程由棧來實現,函式返回地址、EBP、實參和區域性變數都採用棧的方式存放。雖然棧有眾多的好處,但是由於和堆相比不是那麼靈活,有時候分配大量的記憶體空間,主要還是用堆。
無論是堆還是棧,在記憶體使用時都要防止非法越界,越界導致的非法記憶體訪問可能會摧毀程式的堆、棧資料,輕則導致程式執行處於不確定狀態,獲取不到預期結果,重則導致程式異常崩潰,這些都是我們程式設計時與記憶體打交道時應該注意的問題。

資料結構中的堆與棧

資料結構中,堆與棧是兩個常見的資料結構,理解二者的定義、用法與區別,能夠利用堆與棧解決很多實際問題。

棧簡介
棧是一種運算受限的線性表,其限制是指只僅允許在表的一端進行插入和刪除操作,這一端被稱為棧頂(Top),相對地,把另一端稱為棧底(Bottom)。把新元素放到棧頂元素的上面,使之成為新的棧頂元素稱作進棧、入棧或壓棧(Push);把棧頂元素刪除,使其相鄰的元素成為新的棧頂元素稱作出棧或退棧(Pop)。這種受限的運算使棧擁有“先進後出”的特性(First In Last Out),簡稱FILO。
棧分順序棧和鏈式棧兩種。棧是一種線性結構,所以可以使用陣列或連結串列(單向連結串列、雙向連結串列或迴圈連結串列)作為底層資料結構。使用陣列實現的棧叫做順序棧,使用連結串列實現的棧叫做鏈式棧,二者的區別是順序棧中的元素地址連續,鏈式棧中的元素地址不連續。
棧的基本操作包括初始化、判斷棧是否為空、入棧、出棧以及獲取棧頂元素等。

堆簡介
堆是一種常用的樹形結構,是一種特殊的完全二叉樹,當且僅當滿足所有節點的值總是不大於或不小於其父節點的值的完全二叉樹被稱之為堆。堆的這一特性稱之為堆序性。因此,在一個堆中,根節點是最大(或最小)節點。如果根節點最小,稱之為小頂堆(或小根堆),如果根節點最大,稱之為大頂堆(或大根堆)。堆的左右孩子沒有大小的順序。
堆的儲存一般都用陣列來儲存堆,i節點的父節點下標就為( i – 1 ) / 2 (i – 1) / 2(i–1)/2。它的左右子節點下標分別為 2 ∗ i + 1 2 * i + 12∗i+1 和 2 ∗ i + 2 2 * i + 22∗i+2。如第0個節點左右子節點下標分別為1和2。

3. JS程式碼中的use strict是什麼意思?

use strict是一種ECMAscript5新增的(嚴格)執行模式,這種模式使得Javascript 在更嚴格的條件下執行。 設立"嚴格模式"的目的,主要有以下幾個: 消除Javascript語法的一些不合理、不嚴謹之處,減少一些怪異行為;消除程式碼執行的一些不安全之處,保證程式碼執行的安全; 提高編譯器效率,增加執行速度; 為未來新版本的Javascript 做好鋪墊。
區別: 禁止使用with語句。 禁止this關鍵字指向全域性物件。 物件不能有重名的屬性。

4. 如何判斷一個物件是不是空物件?

// 方法1 Object.keys(obj).length === 0
// 方法2 JSON.stringify(obj) === '{}'

5. [] == ![]結果是什麼?

== 中,左右兩邊都需要轉換為數字然後進行比較。 []轉換為數字為0。 ![] 首先是轉換為布林值,由於[]作為一個引用型別轉換為布林值為true, 因此![]為false,進而在轉換成數字,變為0。 0 == 0 , 結果為true

6. Instanceof能否判斷基本資料型別?

能。比如下面這種方式: 

class PrimitiveNumber {      static [Symbol.hasInstance](x) {          return typeof x === 'number'          }     }      console.log(111 instanceof PrimitiveNumber) // true 其實就是自定義instanceof行為的一種方式,這裡將原有的instanceof方法重定義,換成了typeof,因此能夠判斷基本資料型別。

7. 0.1+0.2為什麼不等於0.3?

0.1和0.2在轉換成二進位制後會無限迴圈,由於標準位數的限制後面多餘的位數會被截掉,此時就已經出現了精度的損失,相加後因浮點數小數位的限制而截斷的二進位制數字在轉換為十進位制就會變成 0.30000000000000004。

8. 如何判斷一個元素是否在可視區域中?

判斷一個元素是否在可視區域,我們常用的有三種辦法: - offsetTop、scrollTop - getBoundingClientRect - Intersection Observer
具體描述請點選此連結

9. 什麼是防抖和節流,以及如何編碼實現?

本質上是優化高頻率執行程式碼的一種手段
如:瀏覽器的 resizescrollkeypressmousemove 等事件在觸發時,會不斷地呼叫繫結在事件上的回撥函式,極大地浪費資源,降低前端效能
為了優化體驗,需要對這類事件進行呼叫次數的限制,對此我們就可以採用throttle(節流)和debounce(防抖)的方式來減少呼叫頻率
定義

  • 節流: n 秒內只執行一次,若在 n 秒內重複觸發,只有一次生效
  • 防抖: n 秒後在執行該事件,若在 n 秒內被重複觸發,則重新計時

一個經典的比喻:
想象每天上班大廈底下的電梯。把電梯完成一次運送,類比為一次函式的執行和響應
假設電梯有兩種執行策略 debounce 和 throttle,超時設定為15秒,不考慮容量限制
電梯第一個人進來後,15秒後準時運送一次,這是節流
電梯第一個人進來後,等待15秒。如果過程中又有人進來,15秒等待重新計時,直到15秒後開始運送,這是防抖

具體描述請點選此連結

10. 說說 Javascript 為什麼會存在數字精度丟失的問題,以及如何進行解決?

計算機儲存雙精度浮點數需要先把十進位制數轉換為二進位制的科學記數法的形式,然後計算機以自己的規則{符號位+(指數位+指數偏移量的二進位制)+小數部分}儲存二進位制的科學記數法
因為儲存時有位數限制(64位),並且某些十進位制的浮點數在轉換為二進位制數時會出現無限迴圈,會造成二進位制的舍入操作(0舍1入),當再轉換為十進位制時就造成了計算誤差
解決方法
使用 toPrecision 湊整並 parseFloat 轉成數字後再顯示 function strip(num, precision = 12) { return +parseFloat(num.toPrecision(precision)); } 對於運算類操作,如 +-*/,就不能使用 toPrecision 了。正確的做法是把小數轉成整數後再運算。以加法為例

/** * 精確加法 */ function add(num1, num2) { const num1Digits = (num1.toString().split('.')[1] || '').length; const num2Digits = (num2.toString().split('.')[1] || '').length; const baseNum = Math.pow(10, Math.max(num1Digits, num2Digits)); return (num1 * baseNum + num2 * baseNum) / baseNum; } 具體描述請點選此連結


以上內容全部參考於前端面試題寶典

「其他文章」