動畫:《大前端吊打面試官系列》 之原生 JavaScript 精華篇

一隻不甘平凡的小鹿發表於2020-02-12

?更新日誌

文中所有修改或補充內容,會在日誌中實時更新。

  • 2020/01/7 開始決定寫近十幾萬字前端面試系列,規劃整個系列目錄提綱。
  • 2020/01/8 寫完部分“面試官到底考察你什麼”內容。
  • 2020/01/9 繼續完善”面試官到底考察你什麼“內容。
  • 2020/01/11 開始寫準備簡歷部分。
  • 2020/01/13 完善面試前的準備內容。
  • 2020/01/14 對面試準備內容做一次整體的優化。
  • 2020/01/15 開始寫 JS 系列部分。
  • 2020/01/16 寫資料型別中的七大模組部分 。
  • 2020/01/17 寫 this、閉包等 JS 重點部分。
  • 2020/01/30 寫訊息迴圈機制
  • 2020/02/03 新增配圖以及動畫演示
  • 2020/02/02 補充 new 的實現原理
  • 2020/02/03 補充繼承等知識內容,以及一些參考文獻
  • 2020/02/04 補充垃圾回收機制等知識內容
  • 2020/02/05 補充深淺拷貝等知識內容
  • 持續更新中…

接上篇文章《大前端吊打面試官系列》之備戰面試篇,【傳送門~

本系列 Github 倉庫 [傳送門~]

目錄

資料型別

面試官:說說 JavaScript 中的基本型別有哪些?以及各個資料型別是如何儲存的?

javaScript 的資料型別包括原始型別引用型別(物件型別)

原始型別包括以下 6 個:

  • String
  • Number
  • Boolean
  • null
  • undefined
  • Symbol

引用型別統稱為 Object 型別,如果細分的話,分為以下 5 個:

  • Object
  • Array
  • Date
  • RegExp
  • Function

1、資料型別的儲存形式

棧(Stack)和堆(Heap),是兩種基本的資料結構。Stack 在記憶體中自動分配記憶體空間的;Heap 在記憶體中動態分配記憶體空間的,不一定會自動釋放。一般我們在專案中將物件型別手動置為 null 原因,減少無用記憶體消耗。

原始型別是按值形式存放在中的資料段,記憶體空間可以自由分配,同時可以按值直接訪問

var a = 10;
var b = a;
b = 30;
console.log(a); // 10值
console.log(b); // 30值

過程圖示:

引用型別是存放在記憶體中,每個物件在堆記憶體中有一個引用地址,就像是每個房間都有一個房間號一樣。引用型別在棧中儲存的就是這個物件在堆記憶體的引用地址,我們所說的“房間號”。通過“房間號”可以快速查詢到儲存在堆記憶體的物件。

var obj1 = new Object();
var obj2 = obj1;
obj2.name = "小鹿";
console.log(obj1.name); // 小鹿

過程圖示:

2、Null

面試官:為什麼 typeof null 等於 Object?

不同的物件在底層原理的儲存是用二進位制表示的,在 javaScript中,如果二進位制的前三位都為 0 的話,系統會判定為是 Object型別。null的儲存二進位制是 000,也是前三位,所以系統判定 nullObject型別。

擴充套件:

這個 bug 個第一版的 javaScript留下來的。俺也進行擴充套件一下其他的幾個型別標誌位:

  • 000:物件型別。
  • 1:整型,資料是31位帶符號整數。
  • 010:雙精度型別,資料是雙精度數字。
  • 100:字串,資料是字串。
  • 110:布林型別,資料是布林值。

3、資料型別的判斷

面試官:typeof 與 instanceof 有什麼區別?

typeof 是一元運算子,同樣返回一個字串型別。一般用來判斷一個變數是否為空或者是什麼型別。

除了 null 型別以及 Object 型別不能準確判斷外,其他資料型別都可能返回正確的型別。

typeof undefined // 'undefined'
typeof '10'      // 'String'
typeof 10        // 'Number'
typeof false     // 'Boolean'
typeof Symbol()  // 'Symbol'
typeof Function  // ‘function'
typeof null		 // ‘Object’
typeof []        // 'Object'
typeof {}        // 'Object'

既然 typeof 對物件型別都返回 Object 型別情況的侷限性,我們可以使用 instanceof 來進行判斷某個物件是不是另一個物件的例項。返回值的是一個布林型別。

var a = [];
console.log(a instanceof Array) // true

instanceof 運算子用來測試一個物件在其原型鏈中是否存在一個建構函式的 prototype 屬性,如果對原型鏈不怎能瞭解,後邊俺會具體的寫到,這裡大體記一下就 OK。

我們再測一下 ES6 中的 class 語法糖是什麼型別。

class A{}
console.log(A instanceof Function) // true

注意:原型鏈中的prototype 隨時可以被改動的,改變後的值可能不存在於 object的原型鏈上,instanceof返回的值可能就返回 false

4、型別轉換

型別轉換通常在面試筆試中出現的比較多,對於型別轉換的一些細節應聘者也是很容易忽略的,所以俺整理的儘量系統一些。javaScript是一種弱型別語言,變數不受型別限制,所以在特定情況下我們需要對型別進行轉換。

「型別轉換」分為顯式型別轉換隱式型別轉換。每種轉換又分為原始型別轉換物件型別轉換

顯式型別轉換

顯式型別轉換就是我們所說強制型別轉換。

筆試題:其他資料型別轉字串型別!

對於原始型別來說,轉字串型別會預設呼叫 toString() 方法。

資料型別 String型別
數字 轉化為數字對應的字串
true 轉化為字串 “true”
null 轉化為字串 “null”
undefined 轉化為字串 “undefined”
Object 轉化為 “[object Object]”
String(123);      // "123"
String(true);     // "true"
String(null);     // "null"
String(undefined);// "undefined"
String([1,2,3])   // "1,2,3"
String({});		  // "[object Object]"

筆試題:其他資料型別轉布林型別!

除了特殊的幾個值 ‘’undefinedNANnullfalse0 轉化為 Booleanfalse 之外,其他型別值都轉化為 true

Boolean('')         // false
Boolean(undefined)  // false
Boolean(null)       // false
Boolean(NaN)        // false
Boolean(false)      // false
Boolean(0)          // false
Boolean({})		    // true
Boolean([])		    // true

筆試題:轉化為數字型別!

資料型別 數字型別
字串 1) 數字轉化為對應的數字
2) 其他轉化為 NaN
布林型別 1) true 轉化為 1
2) false 轉化為 0
null 0
undefined NaN
陣列 1) 陣列為空轉化為 0;
2) 陣列只有一個元素轉化為對應元素;
3) 其他轉化為NaN
空字串 0
Number(10);        // 10 
Number('10');      // 10 
Number(null);      // 0  
Number('');        // 0  
Number(true);      // 1  
Number(false);     // 0  
Number([]);        // 0 
Number([1,2]);     // NaN
Number('10a');     // NaN
Number(undefined); // NaN

筆試題:物件型別轉原始型別!

物件型別在轉原始型別的時候,會呼叫內建的 valueOf()toString() 方法,這兩個方法是可以進行重寫的。

轉化原始型別分為兩種情況:轉化為字串型別其他原始型別

  • 如果已經是原始型別,不需要再進行轉化。

  • 如果轉字串型別,就呼叫內建函式中的 toString()方法。

  • 如果是其他基本型別,則呼叫內建函式中的 valueOf()方法。

  • 如果返回的不是原始型別,則會繼續呼叫 toString() 方法。

  • 如果還沒有返回原始型別,則報錯。

5、四則運算

隱士型別轉化是不需要認為的強制型別轉化,javaScript 自動將型別轉化為需要的型別,所以稱之為隱式型別轉換。

加法運算

加法運算子是在執行時決定,到底是執行相加,還是執行連線。運算數的不同,導致了不同的語法行為,這種現象稱為“過載”。

  • 如果雙方都不是字串,則將轉化為數字字串
    • Boolean + Boolean會轉化為數字相加。
    • Boolean + Number 布林型別轉化為數字相加。
    • Object + Number 物件型別呼叫 valueOf,如果不是 String、Boolean或者 Number型別,則繼續呼叫 toString()轉化為字串。
true + true  // 2
1 + true     // 2
[1] + 3      // '13'
  • 字串和字串以及字串和非字串相加都會進行連線
1 + 'b'     // ‘1b’
false + 'b' // ‘falseb’
其他運算

其他算術運算子(比如減法、除法和乘法)都不會發生過載。它們的規則是:所有運運算元一律轉為數值,再進行相應的數學運算。

1 * '2'  // 2
1 * []   // 0

6、邏輯運算子

邏輯運算子包括兩種情況,分別為條件判斷賦值操作

條件判斷

  • && :所有條件為真,整體才為真。
  • || :只有一個條件為真,整體就為真。
true && true   // true
true && false  // false
true || true   // true
true || false  // true

賦值操作

  • A && B

首先看 A 的真假, A 為假,返回 A 的值, A 為真返回 B 的值。(不管 B 是啥)

console.log(0 && 1) // 0
console.log(1 && 2) // 2
  • A || B

首先看 A 的真假, A 為真返回的是 A 的值, A 為假返回的是 B 的值(不管 B 是啥)

console.log(0 || 1) // 1
console.log(1 || 2) // 1

7、比較運算子

比較運算子在邏輯語句中使用,以判定變數或值是否相等。

面試官:== 和 === 的區別?

對於 === 來說,是嚴格意義上的相等,會比較兩個操作符的型別和值。

  • 如果 XY 的型別不同,返回 false
  • 如果 XY 的型別相同,則根據下方表格進一步判斷
條件 例子 返回值
undefined === undefined undefined === undefined true
null === null null === null true
String === String
(當字串順序和字元完全相等的時候返回 true,否則返回 false)
‘a’ === ‘a’
‘a’ === ‘aa’
true
false
Boolean === Boolean true === true
true === false
true
false
Symbol === Symbol 相同的 Symbol 返回 true,
不相同的 Symbol 返回 false
Number === Number
① 其中一個為 NaN,返回 false
② X 和 Y 值相等,返回 true
③ 0 和 -0,返回 true
④ 其他返回 false
NaN ==== NaN
NaN === 1
3 === 3
+0 === -0
false
false
true
true

而對於 ==來說,是非嚴格意義上的相等,先判斷兩個操作符的型別是否相等,如果型別不同,則先進行型別轉換,然後再判斷值是否相等。

  • 如果 XY 的型別相同,返回 X == Y 的比較結果;
  • 如果 XY 的型別不同,根據下方表格進一步判斷;
條件 例子 返回值
null == undefined null == undefined true
String == Number,String 轉 Number ‘2’ == 2 true
Boolean == Number,Boolean 轉 Number true == 1 true
Object == String,Number,Symbol,將 Object 轉化為原始型別再比較值大小 [1] == 1
[1] == ‘1’
true
true
其他返回 false false

this

面試官:什麼是 this 指標?以及各種情況下的 this 指向問題。

this就是一個物件。不同情況下 this指向的不同,有以下幾種情況,(希望各位親自測試一下,這樣會更容易弄懂):

  • 物件呼叫,this 指向該物件(前邊誰呼叫 this 就指向誰)。
var obj = {
    name:'小鹿',
    age: '21',
    print: function(){
        console.log(this)
        console.log(this.name + ':' + this.age)
    }
}

// 通過物件的方式呼叫函式
obj.print();        // this 指向 obj
  • 直接呼叫的函式,this指向的是全域性 window物件。
function print(){
	console.log(this);
}

// 全域性呼叫函式
print();   // this 指向 window
  • 通過 new的方式,this永遠指向新建立的物件。
function Person(name, age){
    this.name = name;
    this.age = age;
    console.log(this);
}

var xiaolu = new Person('小鹿',22);  // this = > xaiolu
  • 箭頭函式中的 this

由於箭頭函式沒有單獨的 this值。箭頭函式的 this與宣告所在的上下文相同。也就是說呼叫箭頭函式的時候,不會隱士的呼叫 this引數,而是從定義時的函式繼承上下文。

const obj = {
    a:()=>{
        console.log(this);
    }
}
// 物件呼叫箭頭函式
obj.a(); // window

面試官:如何改變 this 的指向?

我們可以通過呼叫函式的 call、apply、bind 來改變 this的指向。

var obj = {
    name:'小鹿',
    age:'22',
    adress:'小鹿動畫學程式設計'
}

function print(){
    console.log(this);       // 列印 this 的指向
    console.log(arguments);  // 列印傳遞的引數
}

// 通過 call 改變 this 指向
print.call(obj,1,2,3);   

// 通過 apply 改變 this 指向
print.apply(obj,[1,2,3]);

// 通過 bind 改變 this 的指向
let fn = print.bind(obj,1,2,3);
fn();

對於基本的使用想必各位小夥伴都能掌握,俺就不多廢話,再說一說這三者的共同點和不同點。

共同點:

  • 三者都能改變 this指向,且第一個傳遞的引數都是 this指向的物件。
  • 三者都採用的後續傳參的形式。

不同點:

  • call 的傳參是單個傳遞的(試了下陣列,也是可以的),而 apply 後續傳遞的引數是陣列形式(傳單個值會報錯),而 bind 沒有規定,傳遞值和陣列都可以。

  • callapply 函式的執行是直接執行的,而 bind 函式會返回一個函式,然後我們想要呼叫的時候才會執行。

擴充套件:如果我們使用上邊的方法改變箭頭函式的 this 指標,會發生什麼情況呢?能否進行改變呢?

由於箭頭函式沒有自己的 this 指標,通過 call()apply() 方法呼叫一個函式時,只能傳遞引數(不能繫結 this),他們的第一個引數會被忽略。

new

對於 new 關鍵字,我們第一想到的就是在物件導向中 new 一個例項物件,但是在 JS 中的 newJava 中的 new 的機制不一樣。

一般 Java 中,宣告一個建構函式,通過 new 類名() 來建立一個例項,而這個建構函式 是一種特殊的函式。但是在 JS 中,只要 new 一個函式,就可以 new 一個物件,函式和建構函式沒有任何的區別。

面試官:new 內部發生了什麼過程?可不可以手寫實現一個 new 操作符?

new 的過程包括以下四個階段:

  • 建立一個新物件。
  • 這個新物件的 __proto__ 屬性指向原函式的 prototype 屬性。(即繼承原函式的原型)
  • 將這個新物件繫結到 此函式的 this 上 。
  • 返回新物件,如果這個函式沒有返回其他物件。
// new 生成物件的過程
// 1、生成新物件
// 2、連結到原型
// 3、繫結 this
// 4、返回新物件
// 引數:
// 1、Con: 接收一個建構函式
// 2、args:傳入建構函式的引數
function create(Con, ...args){
    // 建立空物件
    let obj = {};
    // 設定空物件的原型(連結物件的原型)
    obj._proto_ = Con.prototype;
    // 繫結 this 並執行建構函式(為物件設定屬性)
    let result = Con.apply(obj,args)
    // 如果 result 沒有其他選擇的物件,就返回 obj 物件
    return result instanceof Object ?  result : obj;
}
// 建構函式
function Test(name, age) {
    this.name = name
    this.age = age
}
Test.prototype.sayName = function () {
    console.log(this.name)
}

// 實現一個 new 操作符
const a = create(Test,'小鹿','23')
console.log(a.age)

面試官:有幾種建立物件的方式,字面量相對於 new 建立物件有哪些優勢?

最常用的建立物件的兩種方式:

  • **new 建構函式 **
  • 字面量

其他建立物件的方式:

  • Object.create()

字面量建立物件的優勢所在:

  • 程式碼量更少,更易讀
  • 物件字面量執行速度更快,它們可以在解析的時候被優化。他不會像 new 一個物件一樣,解析器需要順著作用域鏈從當前作用域開始查詢,如果在當前作用域找到了名為 Object() 的函式就執行,如果沒找到,就繼續順著作用域鏈往上照,直到找到全域性 Object() 建構函式為止。
  • Object() 建構函式可以接收引數,通過這個引數可以把物件例項的建立過程委託給另一個內建建構函式,並返回另外一個物件例項,而這往往不是你想要的。

對於 Object.create()方式建立物件:

Object.create(proto, [propertiesObject]);
  • proto:新建立物件的原型物件。
  • propertiesObject:(可選)可為建立的新物件設定屬性和值。

一般用於繼承:

var People = function (name){
  this.name = name;
};

People.prototype.sayName = function (){
  console.log(this.name);
}

function Person(name, age){
  this.age = age;
  People.call(this, name);  // 使用call,實現了People屬性的繼承
};

// 使用Object.create()方法,實現People原型方法的繼承,並且修改了constructor指向
Person.prototype = Object.create(People.prototype, {
  constructor: {
    configurable: true,
    enumerable: true,
    value: Person,
    writable: true
  }
});

Person.prototype.sayAge = function (){
  console.log(this.age);
}

var p1 = new Person('person1', 25);
 
p1.sayName();  //'person1'
p1.sayAge();   //25

面試官:new/字面量 與 Object.create(null) 建立物件的區別?

  • new 和 字面量建立的物件的原型指向 Object.prototype,會繼承 Object 的屬性和方法。
  • 而通過 Object.create(null) 建立的物件,其原型指向 nullnull 作為原型鏈的頂端,沒有也不會繼承任何屬性和方法。

閉包

閉包面試中的重點,但是對於很多初學者來說都是懵懵的,所以俺就從最基礎的作用域講起,大佬請繞過。

面試官:什麼是作用域?什麼是作用域鏈?

規定變數和函式的可使用範圍叫做作用域。只看定義,挺抽象的,舉個例子:?

function fn1() {
    let a = 1;
}

function fn2() {
    let b = 2;
}

宣告兩個函式,分別建立量兩個私有的作用域(可以理解為兩個封閉容器),fn2 是不能直接訪問私有作用域 fn1 的變數 a 的。同樣的,在 fn1 中不能訪問到 fn2 中的 b 變數的。一個函式就是一個作用域。

在這裡插入圖片描述

每個函式都會有一個作用域,查詢變數或函式時,由區域性作用域到全域性作用域依次查詢,這些作用域的集合就稱為作用域鏈。 如果還不是很好理解,俺再舉個例子​:?

let a = 1
function fn() {
    function fn1() {
        function fn2() {
            let c = 3;
            console.log(a);
        }
        // 執行 fn2
        fn2();
    }
    // 執行 fn1
    fn1();
}
// 執行函式
fn();

雖然上邊看起來巢狀有點複雜,我們前邊說過,一個函式就是一個私有作用域,根據定義,在 fn2 作用域中列印 a,首先在自己所在作用域搜尋,如果沒有就向上級作用域搜尋,直到搜尋到全域性作用域,a = 1,找到了列印出值。整個搜尋的過程,就是基於作用域鏈搜尋的。

在這裡插入圖片描述

面試官:什麼是閉包?閉包的作用?閉包的應用?

很多應聘者喜歡這樣回答,“函式裡套一個函式”,但是面試官更喜歡下面的回答,因為可以繼續為你挖坑。

函式執行,形成一個私有的作用域,保護裡邊的私有變數不受外界的干擾,除了保護私有變數外,還可以儲存一些內容,這樣的模式叫做閉包

閉包的作用有兩個,保護和儲存。

保護的應用

  • 團隊開發時,每個開發者把自己的程式碼放在一個私有的作用域中,防止相互之間的變數命名衝突;把需要提供給別人的方法,通過 returnwindow.xxx 的方式暴露在全域性下。
  • jQuery 的原始碼中也是利用了這種保護機制。
  • 封裝私有變數。

儲存的應用

  • 選項卡閉包的解決方案。

面試官:迴圈繫結事件引發的索引什麼問題?怎麼解決這種問題?

// 事件繫結引發的索引問題
var btnBox = document.getElementById('btnBox'),
    inputs = btnBox.getElementsByTagName('input')
var len = inputs.length;
for(var i = 0; i < 1en; i++){
    inputs[i].onclick = function () {
        alert(i)
    }
}

閉包剩餘的部分,俺在之前的文章已經總結過,俺就不復制過來了,直接傳送過去~ 動畫:什麼是閉包?

原型和原型鏈

面試官:什麼是原型?什麼是原型鏈?如何理解?

**原型:**每個 JS 物件都有 __proto__ 屬性,這個屬性指向了原型。跟俺去看看,

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片儲存下來直接上傳(img-caK3O3Fy-1581046089376)(H:\程式設計筆記\前端面試整理\images\原型\原型1.png)])

再來一個,

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片儲存下來直接上傳(img-lsM0KKOY-1581046089377)(H:\程式設計筆記\前端面試整理\images\原型\原型2.png)])

我們可以看到,只要是物件型別,都會有這個__proto__ 屬性,這個屬性指向的也是一個原型物件,原型物件也是物件呀,肯定也會存在一個 __proto__ 屬性。那麼就形成了原型鏈,定義如下:

原型鏈:原型鏈就是多個物件通過 __proto__ 的方式連線了起來形成一條鏈。

原型和原型鏈是怎麼來的呢?如果理清原型鏈中的關係呢?

對於原型和原型鏈的前世今生,由於篇幅過大,俺的傳送門~ 圖解:告訴面試官什麼是 JS 原型和原型鏈?

PS:下面的看不懂,一定去看文章哦!

再往深處看,他們之間存在複雜的關係,但是這些所謂的負責關係俺已經總結好了,小二上菜

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片儲存下來直接上傳(img-qp06eZmd-1581046089379)(H:\程式設計筆記\前端面試整理\images\原型\原型圖.png)]

這張圖看起來真複雜,但是通過下邊總結的,再來分析這張圖,試試看。

  • 所有的例項的 _proto_都指向該建構函式的原型物件(prototype)。
  • 所有的函式(包括建構函式)是 Function() 的例項,所以所有函式的 _proto_的都指向 Function() 的原型物件。
  • 所有的原型物件(包括 Function 的原型物件)都是 Object 的例項,所以 _proto_都指向 Object (建構函式)的原型物件。而 Object 建構函式的 _proto_ 指向 null
  • Function 建構函式本身就是 Function 的例項,所以 _proto_ 指向 Function 的原型物件。

面試官:instanceOf 的原理是什麼?

之前留了一個小問題,總結了上述的原型和原型鏈之後,instanceof的原理很容易理解。

instanceof 的原理是通過判斷該物件的原型鏈中是否可以找到該構造型別的 prototype 型別。

function Foo(){}
var f1 = new Foo();

console.log(f1 instanceof Foo);// true

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片儲存下來直接上傳(img-ahuVpxas-1581046089380)(H:\程式設計筆記\前端面試整理\images\原型\instanceof.jpg)]

繼承

面試官:說一說 JS 中的繼承方式有哪些?以及各個繼承方式的優缺點。

經典繼承(建構函式)

/ 詳細解析
//1、當用呼叫 call 方法時,this 帶邊 son 。
//2、此時 Father 建構函式中的 this 指向 son。
//3、也就是說 son 有了 colors 的屬性。
//4、每 new 一個 son ,都會產生不同的物件,每個物件的屬性都是相互獨立的。
function Father(){
	this.colors = ["red","blue","green"];
}

function Son(){
    // this 是通過 new 操作內部的新物件 {} ,
    // 此時 Father 中的 this 就是為 Son 中的新物件{}
    // 新物件就有了新的屬性,並返回得到 new 的新物件例項
    // 繼承了Father,且向父型別傳遞引數
	Father.call(this);
}

let s = new Son();
console.log(s.color)

**① 基本思想:**在子類的建構函式的內部呼叫父類的建構函式。

② 優點:

  • 保證了原型鏈中引用型別的獨立,不被所有例項共享。
  • 子類建立的時候可以向父類進行傳參。

③ 缺點:

  • 繼承的方法都在建構函式中定義,建構函式不能夠複用了(因為建構函式中存在子類的特殊屬性,所以建構函式中複用的屬性不能複用了)。
  • 父類中定義的方法對於子型別而言是不可見的(子類所有的屬性都定義在父類的建構函式當中)。

組合繼承

function Father(name){
	this.name = name;
	this.colors = ["red","blue","green"];
}

// 方法定義在原型物件上(共享)
Father.prototype.sayName = function(){
	alert(this.name);
};

function Son(name,age){
    // 子類繼承父類的屬性  
	Father.call(this,name);     //繼承例項屬性,第一次呼叫 Father()
    // 每個例項都有自己的屬性
	this.age = age;
}

// 子類和父類共享的方法(實現了父類屬性和方法的複用)                              
Son.prototype = new Father();   //繼承父類方法,第二次呼叫 Father()

// 子類例項物件共享的方法
Son.prototype.sayAge = function(){
	alert(this.age);
}

var instance1 = new Son("louis",5);
instance1.colors.push("black");
console.log(instance1.colors);//"red,blue,green,black"
instance1.sayName();//louis
instance1.sayAge();//5

var instance1 = new Son("zhai",10);
console.log(instance1.colors);//"red,blue,green"
instance1.sayName();//zhai
instance1.sayAge();//10

① 基本思想:

  • 使用原型鏈實現對**「原型物件屬性和方法」**的繼承。
  • 通過借用建構函式來實現對**「例項屬性」**的繼承。

② 優點:

  • 在原型物件上定義的方法實現了函式的複用。
  • 每個例項都有屬於自己的屬性。

③ 缺點:

  • 組合繼承呼叫了兩次父類的建構函式,造成了不必要的消耗。

原型繼承

function object(o){
	function F(){}
	F.prototype = o;
    // 每次返回的 new 是不同的
	return new F();
}

var person = {
	friends : ["Van","Louis","Nick"]
};

// 例項 1
var anotherPerson = object(person);
anotherPerson.friends.push("Rob");

// 例項 2
var yetAnotherPerson = object(person);
yetAnotherPerson.friends.push("Style");

// 都新增至原型物件的屬性(所共享)
alert(person.friends); // "Van,Louis,Nick,Rob,Style"

**① 基本思想:**建立臨時性的建構函式(無任何屬性),將傳入的物件作為該建構函式的原型物件,然後返回這個新建構函式的例項。

② 淺拷貝:

object 所產生的物件是不相同的,但是原型物件都是 person 物件,所改變存在原型物件的屬性所有生成的例項所共享,不僅被 Person 所擁有,而且被子類生成的例項所共享。

③ **object.create():**在 ECMAScript5 中,通過新增 object.create() 方法規範化了上面的原型式繼承.。

  • 引數一:新物件的原型的物件。
  • 引數二:先物件定義額外的屬性(可選)。

寄生式繼承

function createAnother(original){
	var clone = object(original); // 通過呼叫object函式建立一個新物件
	clone.sayHi = function(){ // 以某種方式來增強這個物件
		alert("hi");
	};
	return clone; //返回這個物件
}
  • 基本思想:不必為了指定子型別的原型而呼叫超型別的建構函式(避免第二次呼叫的建構函式)。

  • 優點:寄生組合式繼承就是為了解決組合繼承中兩次呼叫建構函式的開銷。

垃圾回收機制

說到 Javascript的垃圾回收機制,我們要從記憶體洩漏一步步說起。

面試官:什麼是記憶體洩漏?為什麼會導致記憶體洩漏?

不再用到的記憶體,沒有及時釋放,就叫做記憶體洩漏。

記憶體洩漏是指我們已經無法再通過js程式碼來引用到某個物件,但垃圾回收器卻認為這個物件還在被引用,因此在回收的時候不會釋放它。導致了分配的這塊記憶體永遠也無法被釋放出來。如果這樣的情況越來越多,會導致記憶體不夠用而系統崩潰。

面試官:怎麼解決記憶體洩漏?說一說 JS 垃圾回收機制的執行機制的原理?。

很多程式語言需要手動釋放記憶體,但是很多開發者喜歡系統提供自動記憶體管理,減輕程式設計師的負擔,這被稱為"垃圾回收機制"

之所以會有垃圾回收機制,是因為 js 中的字串、物件、陣列等只有確定固定大小時,才會動態分配記憶體,只要像這樣動態地分配了記憶體,最終都要釋放這些記憶體以便他們能夠被再用,否則,JavaScript 的直譯器將會消耗完系統中所有可用的記憶體,造成系統崩潰

JavaScript與其他語言不同,它具有自動垃圾收集機制,執行環境會負責管理程式碼執行過程中使用的記憶體。

兩種垃圾回收策略

找出那些不再繼續使用的變數,然後釋放其記憶體。垃圾回收器會按照固定的時間間隔,週期性的執行該垃圾回收操作。

共有兩種策略:

  • 標記清除法
  • 引用計數法

標記清除法

垃圾回收器會在執行的時候,會給儲存在記憶體中的所有變數都加上標記,然後它會去掉環境中變數以及被環境中的變數引用的變數的標記。剩下的就視為即將要刪除的變數,原因是在環境中無法訪問到這些變數了。最後垃圾回收器完成記憶體清除操作。

它的實現原理就是通過判斷一個變數是否在執行環境中被引用,來進行標記刪除。

引用計數法

引用計數的垃圾收集策略不常用,引用計數的最基本含義就是跟蹤記錄每個值被引用的次數。

當宣告變數並將一個引用型別的值賦值給該變數時,則這個值的引用次數加 1,同一值被賦予另一個變數,該值的引用計數加 1 。當引用該值的變數被另一個值所取代,則引用計數減 1,當計數為 0 的時候,說明無法在訪問這個值了,所有系統將會收回該值所佔用的記憶體空間。

存在的缺陷:

兩個物件的相互迴圈引用,在函式執行完成的時候,兩個物件相互的引用計數並未歸 0 ,而是依然佔據記憶體,無法回收,當該函式執行多次時,記憶體佔用就會變多,導致大量的記憶體得不到回收。

最常見的就是在 IE BOM 和 DOM 中,使用的物件並不是 js 物件,所以垃圾回收是基於計數策略的。但是在 IE9 已經將 BOM 和 DOM 真正的轉化為了 js 物件,所以迴圈引用的問題得到解決。

如何管理記憶體

雖然說是 js 的記憶體都是自動管理的,但是對於 js 還是存在一些問題的,最主要的一個問題就是分配給 Web 瀏覽器的可用記憶體數量通常比分配給桌面應用程式的少

為了能夠讓頁面獲得最好的效能,必須確保 js 變數佔用最少的記憶體,最好的方式就是將不用的變數引用釋放掉,也叫做解除引用

  • 對於區域性變數來說,函式執行完成離開環境變數,變數將自動解除。
  • 對於全域性變數我們需要進行手動解除。(注意:解除引用並不意味被收回,而是將變數真正的脫離執行環境,下一次垃圾回收將其收回)
var a = 20;  // 在堆記憶體中給數值變數分配空間
alert(a + 100);  // 使用記憶體
var a = null; // 使用完畢之後,釋放記憶體空間

補充:因為通過上邊的垃圾回收機制的標記清除法的原理得知,只有與環境變數失去引用的變數才會被標記回收,所用上述例子通過將物件的引用設定為 null ,此變數也就失去了引用,等待被垃圾回收器回收。

深拷貝和淺拷貝

面試官:什麼是深拷貝?什麼是淺拷貝?

上邊在 JavaScript 基本型別中我們說到,資料型別分為基本型別和引用型別。對基本型別的拷貝就是對值複製進行一次拷貝,而對於引用型別來說,拷貝的不是值,而是值的地址,最終兩個變數的地址指向的是同一個值。還是以前的例子:

var a = 10;
var b = a;
b = 30;
console.log(a); // 10值
console.log(b); // 30值

var obj1 = new Object();
var obj2 = obj1;
obj2.name = "小鹿";
console.log(obj1.name); // 小鹿

要想將 obj1obj2 的關係斷開,也就是不讓他指向同一個地址。根據不同層次的拷貝,分為深拷貝和淺拷貝。

  • **淺拷貝:**只進行一層關係的拷貝。
  • **深拷貝:**進行無限層次的拷貝。

面試官:淺拷貝和深拷貝分別如何實現的?有哪幾種實現方式?

  • 自己實現一個淺拷貝:
// 實現淺克隆
function shallowClone(o){
    const obj = {};
    for(let i in o){
        obj[i] = o[i]
    }
    return obj;
}
  • 擴充套件運算子實現:
let a = {c: 1}
let b = {...a}
a.c = 2
console.log(b.c) // 1
  • Object.assign()實現
let a = {c: 1}
let b = Object.assign({}, a)
a.c = 2
console.log(b.c) // 1

對於深拷貝來說,在淺拷貝的基礎上加上遞迴,我們改動上邊自己實現的淺拷貝程式碼:

var a1 = {b: {c: {d: 1}};
function clone(source) {
    var target = {};
    for(var i in source) {
        if (source.hasOwnProperty(i)) {
            if (typeof source[i] === 'object') {
                target[i] = clone(source[i]); // 遞迴
            } else {
                target[i] = source[i];
            }
        }
    }
    return target;
}

如果功底稍微紮實的小夥伴可以看出上邊深拷貝存在的問題:

  • 引數沒有做檢驗;
  • 判斷物件不夠嚴謹;
  • 沒有考慮到陣列,以及 ES6set, map, weakset, weakmap相容性。
  • 最嚴重的問題就是遞迴容易爆棧(遞迴層次很深的時候)。
  • 迴圈引用問題提。
var a = {};
a.a = a;
clone(a); // 會造成一個死迴圈

兩種解決迴圈引用問題的辦法:

  • 暴力破解
  • 迴圈檢測

還有一個最簡單的實現深拷貝的方式,那就是利用 JSON.parse(JSON.stringify(object)),但是也存在一定的侷限性。

function cloneJSON(source) {
    return JSON.parse(JSON.stringify(source));
}

對於這種方法來說,內部的原理實現也是使用的遞迴,遞迴到一定深度,也會出現爆棧問題。但是對於迴圈引用的問題不會出現,內部的解決方案正是用到了迴圈檢測。對於詳細的實現一個深拷貝,具體參考文章:[深拷貝的終極探索](https://segmentfault.com/a/1190000016672263)

非同步程式設計

由於 JavaScript 是單執行緒的,單執行緒就意味著阻塞問題,當一個任務執行完成之後才能執行下一個任務。這樣就會導致出現頁面卡死的狀態,頁面無響應,影響使用者的體驗,所以不得不出現了同步和非同步的解決方案。

面試官:JS 為什麼是單執行緒?又帶來了哪些問題呢?

JS 單執行緒的特點就是同一時刻只能執行一個任。這是由一些與使用者的互動以及操作 DOM 等相關的操作決定了 JS 要使用單執行緒,否則使用多執行緒會帶來複雜的同步問題。如果執行同步問題的話,多執行緒需要加鎖,執行任務造成非常的繁瑣。

雖然 HTML5 標準規定,允許 JavaScript 指令碼建立多個執行緒,但是子執行緒完全受主執行緒控制,且不得操作 DOM

上述開頭我們也說到了,單執行緒帶來的問題就是會導致阻塞問題,為了解決這個問題,就不得不涉及 JS 的兩種任務,分別為同步任務和非同步任務。

面試官:JS 如何實現非同步程式設計?

最早的解決方案是使用回撥函式,回撥函式不是直接呼叫,而是在特定的事件或條件發生時另一方呼叫的,用於對該事件或條件進行響應。比如 Ajax 回撥:

// jQuery 中的 ajax
$.ajax({ 
    type : "post", 
    url : 'test.json', 
    dataType : 'json', 
    success : function(res) { 
       // 響應成功回撥
    },
    fail: function(err){
       // 響應失敗回撥
    }
}); 

但是如果某個請求存在依賴性,如下:

$.ajax({
    type:"post",
    success: function(res){//成功回撥
        //再次非同步請求
        $.ajax({
            type:"post",
            url:"...?id=res.id,
            success:function(res){
                 $.ajax({
                    type:"post",
                    url:"...?id=res.id,
                    success:function(){
						// 往復迴圈
                    }
                })
            }
        })
    }
})

就會形成不斷的迴圈巢狀,我們稱之為回撥地獄。我們可以看出回撥地獄有以下缺點:

  • 巢狀函式存在耦合性,一旦有所改動,牽一髮而動全身。
  • 巢狀函式一多,就很難處理錯誤。
  • 回撥函式不能使用 try catch 捕獲異常(異常的捕獲只能在函式執行的時候才能捕獲到)。
  • 回撥函式不能直接 return

以上有兩個地方俺需要再進一步詳細說明一下:

  • 為什麼不能捕獲異常?

其實這跟 js 的執行機制相關,非同步任務執行完成會加入任務佇列,當執行棧中沒有可執行任務了,主執行緒取出任務佇列中的非同步任務併入棧執行,當非同步任務執行的時候,捕獲異常的函式已經在執行棧內退出了,所以異常無法被捕獲。

  • 為什麼不能return?

return 只能終止回撥的函式的執行,而不能終止外部程式碼的執行。

面試官:如何解決回撥地獄問題呢?

既然回撥函式存在回撥地獄問題,那我們如何解決呢?ES6 給我們提供了三種解決方案,分別是 Generator、Promise、async/await(ES7)。

由於這部分涉及到 ES6 部分的知識,這一期是有關 JS 的,所以會在下一期進行延伸,這裡不多涉及。

【留下一個傳送門~】

面試官:說說非同步程式碼的執行順序?Event Loop 的執行機制是如何的執行的?

上邊我們說到 JS 是單執行緒且使用同步和非同步任務解決 JS 的阻塞問題,那麼非同步程式碼的執行順序以及 EventLoop 是如何運作的呢?

在深入事件迴圈機制之前,需要弄懂一下幾個概念:

  • 執行上下文(Execution context)

  • 執行棧Execution stack

  • 微任務micro-task

  • 巨集任務macro-task

執行上下文

執行上下文是一個抽象的概念,可以理解為是程式碼執行的一個環境。JS 的執行上下文分為三種,全域性執行上下文、函式(區域性)執行上下文、Eval 執行上下文

  • **全域性執行上下文:**全域性執行上下文指的是全域性 this 指向的 window,可以是外部載入的 JS 檔案或者本地<scripe></script>標籤中的程式碼。

  • **函式執行上下文:**函式上下文也稱為區域性上下文,每個函式被呼叫的時候,都會建立一個新的區域性上下文。

  • Eval 執行上下文: 這個不經常用,所以不多討論。

執行棧

執行棧,就是我們資料結構中的“棧”,它具有“先進後出”的特點,正是因為這種特點,在我們程式碼進行執行的時候,遇到一個執行上下文就將其依次壓入執行棧中。

當程式碼執行的時候,先執行位於棧頂的執行上下文中的程式碼,當棧頂的執行上下文程式碼執行完畢就會出棧,繼續執行下一個位於棧頂的執行上下文。

function foo() {
  console.log('a');
  bar();
  console.log('b');
}

function bar() {
  console.log('c');
}

foo();
  • 初始化狀態,執行棧任務為空。
  • foo 函式執行,foo 進入執行棧,輸出 a,碰到函式bar
  • 然後 bar 再進入執行棧,開始執行 bar 函式,輸出 c
  • bar 函式執行完出棧,繼續執行執行棧頂端的函式 foo,最後輸出 c
  • foo 出棧,所有執行棧內任務執行完畢。

在這裡插入圖片描述

巨集任務

對於巨集任務一般包括:

  • 整體的 script 標籤內的程式碼,
  • setTimeout
  • setInterval
  • setImmediate
  • I/O

微任務

對於微任務一般包括:

  • Promise
  • process.nextTick(Node)
  • MutationObserver

注意:nextTick 佇列會比 Promie 佇列先執行。

以上概念弄明白之後,再來看迴圈機制是如何執行的呢?以下涉及到的任務執行順序都是靠函式呼叫棧來實現的。

1)首先,事件迴圈機制的是從 <script> 標籤內的程式碼開始的,上邊我們提到過,整個 script 標籤作為一個巨集任務處理的。

2)在程式碼執行的過程中,如果遇到巨集任務,如:setTimeout,就會將當前任務分發到對應的執行佇列中去。

3)當執行過程中,如果遇到微任務,如:Pomise,在建立 Promise例項物件時,程式碼順序執行,如果到了執行· then 操作,該任務就會被分發到微任務佇列中去。

4)script 標籤內的程式碼執行完畢,同時執行過程中所涉及到的巨集任務也和微任務也分配到相應的佇列中去。

5)此時巨集任務執行完畢,然後去微任務佇列執行所有的存在的微任務。

6)微任務執行完畢,第一輪的訊息迴圈執行完畢,頁面進行一次渲染。

7)然後開始第二輪的訊息迴圈,從巨集任務佇列中取出任務執行。

8)如果兩個任務佇列沒有任務可執行了,此時所有任務執行完畢。

實戰一下:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>訊息執行機制</title>
</head>
<body>

</body>
    <script>
        console.log('1');
        setTimeout(() => {
            console.log('2')
        }, 1000);
        new Promise((resolve, reject) => {
            console.log('3');
            resolve();
            console.log('4');
        }).then(() => {
            console.log('5');
        });
        console.log('6');// 1,3,4,6,5,2
    </script>
</html>
  • 初始化狀態,執行棧為空。
  • 首先執行 <script> 標籤內的同步程式碼,此時全域性的程式碼進入執行棧中,同步順序執行程式碼,輸出 1。
  • 執行過程中遇到非同步程式碼 setTimeout(巨集任務),將其分配到巨集任務非同步佇列中。
  • 同步程式碼繼續執行,遇到一個 promise 非同步程式碼(微任務)。但是建構函式中的程式碼為同步程式碼,依次輸出3、4,則 then 之後的任務加入到微任務佇列中去。
  • 最後執行同步程式碼,輸出 6。
  • 因為 script內的程式碼作為巨集任務處理,所以此次迴圈進行到處理微任務佇列中的所有非同步任務,直達微任務佇列中的所有任務執行完成為止,微任務佇列中只有一個微任務,所以輸出 5。
  • 此時頁面要進行一次頁面渲染,渲染完成之後,進行下一次迴圈。
  • 在巨集任務佇列中取出一個巨集任務,也就是之前的 setTimeout,最後輸出 2。
  • 此時任務佇列為空,執行棧中為空,整個程式執行完畢。

在這裡插入圖片描述

以上難免有些囉嗦,所以簡化整理如下步驟:

  • 一開始執行巨集任務(script中同步程式碼),執行完畢,呼叫棧為空。
  • 然後檢查微任務佇列是否有可執行任務,執行完所有微任務。
  • 進行頁面渲染。
  • 第二輪從巨集任務佇列取出一個巨集任務執行,重複以上迴圈。

本系列持續更新中…

除此之外,為了能夠在面試中回答做的一些實戰專案經歷,俺把一些專案分享到這裡了,獲取方式如下:

可以在我的公眾號『小鹿動畫學程式設計』,後臺回覆『資源』即可獲取。

在這裡插入圖片描述


❤️ 最後不要忘記三連哦~ [點贊 + 收藏 + 評論]!

如果覺得文章不錯,希望你能給小鹿的文章輕輕的點個贊,希望能夠更多的面試者帶來幫助,謝謝你!


參考文獻:

1、https://www.cnblogs.com/xiaoheimiaoer/p/4572558.html

2、https://juejin.im/entry/584918612f301e005716add6

3、https://juejin.im/post/5ba32171f265da0ab719a6d7

4、https://segmentfault.com/a/1190000012646203

5、前端面試之道

6、https://segmentfault.com/a/1190000016672263

歡迎關注小鹿的公眾號【小鹿動畫學程式設計】,堅持原創以動畫形式講解技術,不定時分享高質量學習資料。

相關文章