《前端面試之道-JS篇》(上)

yvonneit發表於2019-03-13

掘金出的小冊《前端面試之道》是本好書,但可能由於本人基礎太差的原因一開始看不太懂,最近在找實習不得不硬啃,所以基於小冊找了點資料查漏補缺。

內建型別

JS 中分為七種內建型別,七種內建型別又分為兩大型別:基本型別和物件(Object)。

基本型別有六種: null,undefined,boolean,number,string,symbol(ES6新增)

關於Symbol:表示獨一無二的值,從根本上防止屬性名的衝突。是一種類似於字串的資料型別.

棧:原始資料型別(Undefined,Null,Boolean,Number、String) 堆:引用資料型別(物件、陣列和函式(特殊物件)) 引用資料型別在棧中儲存了指標,該指標指向堆中該實體的起始地址。當直譯器尋找引用值時,會首先檢索其在棧中的地址,取得地址後從堆中獲得實體。

let s = Symbol();

typeof s  // "symbol"
複製程式碼

NaN 也屬於 number 型別,並且 NaN 不等於自身。

建立物件

  1. 物件字面量
  2. 用function來模擬無參的建構函式
  3. 用function來模擬參建構函式來實現(用this關鍵字定義構造的上下文屬性)
  4. 用工廠方式來建立(內建物件)
  5. 用原型方式來建立

Typeof

typeof 對於基本型別,除了 null 都可以顯示正確的型別

typeof 對於物件,除了函式都會顯示 object

對於 null 來說,雖然它是基本型別,但是會顯示 object,這是一個存在很久了的 Bug

如果我們想獲得一個變數的正確型別,可以通過 Object.prototype.toString.call(xx)。

型別轉換

  1. 轉Boolean:

在條件判斷時,除了 undefined, null, false, NaN, '', 0, -0 ,其他所有值都轉為 true,包括所有物件。

  1. 物件轉基本型別:

首先會呼叫valueOf 然後呼叫 toString 。 (或者重寫 Symbol.toPrimitive ,該方法在轉基本型別時呼叫優先順序最高。)

字串轉數字:parseFloat('12.3b');

  1. 四則運算子

只有當加法運算時,其中一方是字串型別,就會把另一個也轉為字串型別。其他運算只要其中一方是數字,那麼另一方就轉為數字。

加法運算會觸發三種型別轉換: 值轉換為原始值,轉換為數字,轉換為字串。

  1. == 操作符

null == undefined //true

解析[] == ![] // -> true

// [] 轉成 true,然後取反變成 false
[] == false
// 根據第 8 條得出
// 若Type(y) is boolean, compare x == ToNumber(y)
[] == ToNumber(false)
[] == 0
// 根據第 10 條得出
// if Type(x) is object and Type(y) is string or number, compare ToPrimitive(x) == y
ToPrimitive([]) == 0
// [].toString() -> ''
'' == 0
// 根據第 6 條得出
0 == 0 // -> true
複製程式碼
  1. 比較運算子
  • 如果是物件,就通過 toPrimitive 轉換物件
  • 如果是字串,就通過 unicode 字元索引來比較

原型

每個函式都有 prototype 屬性,除了 Function.prototype.bind(),該屬性指向原型。

每個物件都有__proto__屬性,指向了建立該物件的建構函式的原型。 其實這個屬性指向了 [[prototype]],但是 [[prototype]] 是內部屬性,我們並不能訪問到,所以使用__proto__ 來訪問。

物件可以通過__proto__ 來尋找不屬於該物件的屬性, 因為__proto__將物件連線起來組成了原型鏈

總結:

  • Object 是所有物件的爸爸,所有物件都可以通過 __proto__ 找到它
  • Function 是所有函式的爸爸,所有函式都可以通過 __proto__ 找到它
  • Function.prototypeObject.prototype 是兩個特殊的物件,他們由引擎來建立
  • 除了以上兩個特殊物件,其他物件都是通過構造器 new 出來的
  • 函式的 prototype 是一個物件,也就是原型
  • 物件的 __proto__ 指向原型, __proto__ 將物件和原型連線起來組成了原型鏈

JS中的原型和原型鏈(面試中獎率120%)

溫故js系列(15)-原型&原型鏈&原型繼承

JS原型鏈與繼承別再被問倒了

原型鏈例子:

function Father(){
	this.property = true;
}
Father.prototype.getFatherValue = function(){
	return this.property;
}
function Son(){
	this.sonProperty = false;
}
//繼承 Father
Son.prototype = new Father();
//Son.prototype被重寫,導致Son.prototype.constructor也一同被重寫
Son.prototype.getSonVaule = function(){
	return this.sonProperty;
}
var instance = new Son();
alert(instance.getFatherValue());//true

//instance例項通過原型鏈找到了Father原型中的getFatherValue方法.
複製程式碼

原型鏈存在的問題:

  • 當原型鏈中包含引用型別值的原型時,該引用型別值會被所有例項共享;
  • 在建立子型別(例如建立Son的例項)時,不能向超型別(例如Father)的建構函式中傳遞引數.

建議的繼承方式:

  1. 使用借用建構函式+原型鏈 = 組合繼承混合方式。

在子類建構函式內部使用apply或者call來呼叫父類的函式即可在實現屬性繼承的同時,又能傳遞引數,又能讓例項不互相影響

function Super(){
    this.flag = true;
}
Super.prototype.getFlag = function(){
    return this.flag;     //繼承方法
}
function Sub(){
    this.subFlag = flase
    Super.call(this)    //繼承屬性
}
Sub.prototype = new Super;
Sub.prototype.constructor = Sub;
var obj = new Sub();
Super.prototype.getSubFlag = function(){
    return this.flag;
}
複製程式碼

小問題:Sub.prototype = new Super; 會導致Sub.prototype的constructor指向Super; 然而constructor的定義是要指向原型屬性對應的建構函式的,Sub.prototype是Sub建構函式的原型,所以應該新增一句糾正:Sub.prototype.constructor = Sub;

組合繼承是 JavaScript 最常用的繼承模式; 不過, 它也有自己的不足。 組合繼承最大的問題就是無論什麼情況下, 都會呼叫兩次父類建構函式: 一次是在建立子型別原型的時候, 另一次是在子型別建構函式內部。

  1. 寄生組合式繼承 就是為了降低呼叫父類建構函式的開銷而出現的。

基本思路是: 不必為了指定子型別的原型而呼叫超型別的建構函式。

function extend(subClass,superClass){
	var prototype = object(superClass.prototype);//建立物件
	prototype.constructor = subClass;//增強物件
	subClass.prototype = prototype;//指定物件
}
複製程式碼

extend的高效率體現在它沒有呼叫superClass建構函式,因此避免了在subClass.prototype上面建立不必要,多餘的屬性. 於此同時,原型鏈還能保持不變; 因此還能正常使用 instanceof 和 isPrototypeOf() 方法.

  1. ES6的class 其內部其實也是ES5組合繼承的方式,通過call借用建構函式,在A類建構函式中呼叫相關屬性,再用原型鏈的連線實現方法的繼承
class B extends A {
  constructor() {
    return A.call(this);  //繼承屬性
  }
}
A.prototype = new B;  //繼承方法  
複製程式碼

ES6封裝了class,extends關鍵字來實現繼承,內部的實現原理其實依然是基於上面所講的原型鏈,不過進過一層封裝後,Javascript的繼承得以更加簡潔優雅地實現

class ColorPoint extends Point {
//通過constructor來定義建構函式,用super呼叫父類的屬性方法
  constructor(x, y, color) {
    super(x, y); // 等同於parent.constructor(x, y)
    this.color = color;
  }
  toString() {
    return this.color + ' ' + super.toString(); // 等同於parent.toString()
  }
}
複製程式碼

new

呼叫new的過程會發生

  1. 新生成了一個空物件,並且 this 變數引用該物件,同時還繼承了該函式的原型。
  2. 加入屬性和方法。
  3. 返回新物件。
//自定義new
function create() {
    // 建立一個空的物件
    let obj = new Object()
    // 獲得建構函式
    let Con = [].shift.call(arguments)
    // 連結到原型
    obj.__proto__ = Con.prototype
    // 繫結 this,執行建構函式
    let result = Con.apply(obj, arguments)
    // 確保 new 出來的是個物件
    return typeof result === 'object' ? result : obj
}
複製程式碼

對於建立一個物件來說,更推薦使用字面量的方式建立物件(無論效能上還是可讀性)。

因為使用 new Object() 的方式建立物件需要通過作用域鏈一層層找到 Object,但是你使用字面量的方式就沒這個問題。

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

new Foo() 的優先順序大於 new Foo

new Foo.getName();   // -> new (Foo.getName());
new Foo().getName();   
// -> (new Foo()).getName(); 
//先執行 new Foo() 產生了一個例項,然後通過原型鏈找到了 Foo 上的 getName 函式
複製程式碼

instanceof

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

this

補充:普通函式和建構函式的區別

  1. 建構函式也是一個普通函式,建立方式一樣,但建構函式習慣上首字母大寫
  2. 建構函式和普通函式的區別在於:呼叫方式不一樣 普通函式的呼叫方式:直接呼叫 person(); 建構函式的呼叫方式:需要使用new關鍵字來呼叫 new Person();
  3. 建構函式的執行流程 A 立刻在堆記憶體中建立一個新的物件 B 將新建的物件設定為函式中的this C 逐個執行函式中的程式碼 D 將新建的物件作為返回值
  4. 普通函式例子:因為沒有返回值,所以為undefined 建構函式例子:建構函式會馬上建立一個新物件,並將該新物件作為返回值返回

JavaScript 中函式的呼叫有以下幾種方式:作為物件方法呼叫,作為函式呼叫,作為建構函式呼叫,和使用 apply 或 call 呼叫。

參考: call、apply和bind方法的用法以及區別

  1. 作為物件呼叫

在 JavaScript 中,函式也是物件,因此函式可以作為一個物件的屬性,此時該函式被稱為該物件的方法,也就是當函式作為方法時,this 被繫結到該物件。

var point = { 
x : 0, 
y : 0, 
moveTo : function(x, y) { 
    this.x = this.x + x; 
    this.y = this.y + y; 
    } 
}; 

point.moveTo(1, 1)//this 繫結到當前物件,即 point 物件
複製程式碼
  1. 作為函式呼叫

函式直接被呼叫(比如回撥函式),此時 this 繫結到全域性物件。在瀏覽器中,window 就是該全域性物件。

function makeNoSense(x) { 
this.x = x; 
} 
 
makeNoSense(5); 
//執行賦值語句,相當於隱式的宣告瞭一個全域性變數(不好)
x;// x 已經成為一個值為 5 的全域性變數
複製程式碼

對於內部函式,即宣告在另外一個函式體內的函式,這種繫結到全域性物件的方式就會產生問題。

內部函式的 this 應該繫結到其外層函式對應的物件上,可以使用變數替代的方法,該變數一般被命名為 that。

var point = { 
x : 0, 
y : 0, 
moveTo : function(x, y) { 
    var that = this; 
    // 內部函式
    var moveX = function(x) { 
    that.x = x; 
    }; 
    var moveY = function(y) { 
    that.y = y; 
    } 
    moveX(x); 
    moveY(y); 
    } 
}; 
point.moveTo(1, 1); 
point.x; //==>1 
point.y; //==>1
複製程式碼
  1. 作為建構函式呼叫

JavaScript 支援物件導向式程式設計,與主流的物件導向式程式語言不同,JavaScript 並沒有類(class)的概念,而是使用基於原型(prototype)的繼承方式。

new 一個函式時,背地裡會將建立一個連線到 prototype 成員的新物件,同時this會被繫結到那個新物件上。

  1. 使用apply或call呼叫

在 JavaScript 中函式也是物件,物件則有方法,apply 和 call 就是函式物件的方法。

apply 和 call 允許切換函式執行的上下文環境(context),即 this 繫結的物件。

bind() 函式會建立一個新函式(稱為繫結函式)

  • bind是ES5新增的一個方法,傳參和call或apply類似
  • 不會執行對應的函式,call或apply會自動執行對應的函式
  • 返回對函式的引用
function Point(x, y){ 
   this.x = x; 
   this.y = y; 
   this.moveTo = function(x, y){ 
       this.x = x; 
       this.y = y; 
   } 
} 
//使用建構函式生成了一個物件 p1,該物件同時具有 moveTo 方法; 
var p1 = new Point(0, 0); 
//使用物件字面量建立了另一個物件 p2
var p2 = {x: 0, y: 0}; 
//使用 apply 可以將 p1 的方法應用到 p2 上,這時候 this 也被繫結到物件 p2 上
p1.moveTo(1, 1); 
p1.moveTo.apply(p2, [10, 10]);

//另一個方法 call 也具備同樣功能,不同的是最後的引數不是作為一個陣列統一傳入,而是分開傳入的。
複製程式碼

總結: this總是指向函式的直接呼叫者(而非間接呼叫者); 作為函式呼叫時,this 繫結到全域性物件;作為方法呼叫時,this 繫結到該方法所屬的物件。 如果有new關鍵字,this指向new出來的那個物件; 在事件中,this指向觸發這個事件的物件,特殊的是,IE中的attachEvent中的this總是指向全域性物件Window;

幾個規則:

//作為函式呼叫
function foo() {
    console.log(this.a)
}
var a = 1
foo()

//作為方法呼叫
var obj = {
	a: 2,
	foo: foo
}
obj.foo()

// 以上兩者情況 `this` 只依賴於呼叫函式前的物件,優先順序是第二個情況大於第一個情況

//作為建構函式呼叫
//以下情況是優先順序最高的,`this` 只會繫結在 `c` 上,不會被任何方式修改 `this` 指向
var c = new foo()
c.a = 3
console.log(c.a)

// 還有種就是利用 call,apply,bind 改變 this,這個優先順序僅次於 new
複製程式碼

箭頭函式中的this:

function a() {
    return () => {
        return () => {
        	console.log(this)
        }
    }
}
console.log(a()()())

//呼叫 a 符合前面程式碼中的第一個情況,所以 this 是 window。並且 this 一旦繫結了上下文,就不會被任何程式碼改變。
複製程式碼

箭頭函式其實是沒有 this 的,這個函式中的 this 只取決於他外面的第一個不是箭頭函式的函式的 this。

執行上下文

  1. 作用域

在ES6之前,js沒有塊級作用域,只有全域性和函式作用域。

注: 其實從 ES3 釋出以來,JavaScript 就有了塊級作用域(with 和 catch分句),而 ES6 引入了 let

變數提升:

console.log(a); //undefined
var a = 2; 

//等同於
var a;
console.log(a);
a = 2;
複製程式碼

var a;是定義宣告,在編譯階段宣告。a = 2; 是賦值宣告,會被留在原地等待執行階段。

函式提升:

在函式作用域中,區域性變數的優先順序比同名的全域性變數高。 函式宣告會被提升,函式表示式不會。

var a;
a = true;
function foo() {
    var a;
    if(a) {
        a = 10;
    }
    console.log(a);
}
foo(); //undefined
複製程式碼

在 foo(...) {} 的函式作用域中,這個重名區域性變數 a 會遮蔽全域性變數 a,換句話說,在遇到對 a 的賦值宣告之前,在 foo(...) {},a 的值都是 undefined! 所以一個 undefined 的 a 進入不了 if(a) {...} 中,最後被列印出來的是 undefined。

  1. 執行上下文
  • 全域性執行上下文

  • 函式執行上下文

  • eval 執行上下文(並不經常使用)

執行棧:也就是在其它程式語言中所說的“呼叫棧”,是一種擁有 LIFO(後進先出)資料結構的棧,被用來儲存程式碼執行時建立的所有執行上下文。

每個執行上下文中都有三個重要的屬性

  • 變數物件(VO),包含變數、函式宣告和函式的形參,該屬性只能在全域性上下文中訪問

  • 作用域鏈(JS 採用詞法作用域,也就是說變數的作用域是在定義時就決定了)

  • this

var 會產生很多錯誤,所以在 ES6中引入了 let

let 不能在宣告前使用.

並不是常說的 let 不會提升,let 提升了宣告但沒有賦值,因為臨時死區導致了並不能在宣告前使用。

在提升的過程中,相同的函式會覆蓋上一個函式,並且函式優先於變數提升

b() // call b second

function b() {
	console.log('call b fist')
}
function b() {
	console.log('call b second')
}
var b = 'Hello world'
複製程式碼
  1. 作用域和執行上下文的關係

幾乎是沒有啥交集。

在一個函式被執行時,建立的執行上下文物件除了儲存了些程式碼執行的資訊,還會把當前的作用域儲存在執行上下文中。所以它們的關係只是儲存關係。

this 的值是通過當前執行上下文中儲存的作用域(物件)來獲取到的。

相關文章