1. 程式人生 > 實用技巧 >JavaScript基礎知識學習總結

JavaScript基礎知識學習總結

以下主要是個人 學習/複習 總結的要點,具體原理分析會比較少。如果需要深入學習原理,請看最後搬運文章

一. 原型到原型鏈

祭出經典老圖:

prototype

4.3.5 prototype

object that provides shared properties for other objects

在ES2019規範中,Prototype被定義為:給其他物件提供共享屬性的物件。

也就是說,我們說Prototype物件描述的是兩個物件之間的某種關係,其中一個,為另一個提供屬性訪問許可權。

每個函式都有一個Prototype屬性,也只有函式才會有Prototype屬性

function Person() {

}
// 雖然寫在註釋裡,但是你要注意:
// prototype是函式才會有的屬性
Person.prototype.name = 'Kevin';
var person1 = new Person();
var person2 = new Person();
console.log(person1.name) // Kevin
console.log(person2.name) // Kevin

接下來我們看看建構函式和原型間的關係:

_proto_

這個物件不同於prototype,這是每個JavaScript物件(除了null)都具有的一個屬性,這個屬性會指向該物件的原型。

我們可以通過程式碼證明:

function Person() {

}
var person = new Person();
console.log(person.__proto__ === Person.prototype); // true

於是我們更新了關係圖:

constructor

既然例項物件和建構函式可以指向原型,那麼原型是否有屬性指向建構函式呢?

constructor是由原型指向關聯建構函式的原型屬性。

function Person() {

}
console.log(Person === Person.prototype.constructor); // true

更新關係圖:

原型的原型

原型也是一個物件,原型物件也是通過Object建構函式生成的。所以原型的原型就是Object.prototype

同時,JS是單繼承,Object.prototype是原型鏈的頂端,所有物件從它繼承了包括toString等等方法和屬性。

更新關係圖:

原型鏈

原因是每個物件都有 __proto__ 屬性,此屬性指向該物件的建構函式的原型。

物件可以通過 __proto__與上游的建構函式的原型物件連線起來,而上游的原型物件也有一個__proto__

,這樣就形成了原型鏈。

二. 詞法作用域和動態作用域

作用域

作用域是指程式原始碼中定義變數的區域。作用域規定了如何查詢變數,也就是確定當前執行程式碼對變數的訪問許可權。

JavaScript是採用詞法作用域(lexical scoping),也就是靜態作用域

靜態作用域和動態作用域

靜態作用域:函式的作用域在定義的時候就已經決定了

動態作用域:函式的作用域在函式呼叫的時候才決定

var value = 1;

function foo() {
    console.log(value);
}

function bar() {
    var value = 2;
    foo();
}

bar();

// 結果是 ???

假設JavaScript採用靜態作用域,讓我們分析下執行過程:

執行 foo 函式,先從 foo 函式內部查詢是否有區域性變數 value,如果沒有,就根據書寫的位置,查詢上面一層的程式碼,也就是 value 等於 1,所以結果會列印 1。

假設JavaScript採用動態作用域,讓我們分析下執行過程:

執行 foo 函式,依然是從 foo 函式內部查詢是否有區域性變數 value。如果沒有,就從呼叫函式的作用域,也就是 bar 函式內部查詢 value 變數,所以結果會列印 2。

前面我們已經說了,JavaScript採用的是靜態作用域,所以這個例子的結果是 1。

三. 執行上下文

什麼是執行上下文?

執行上下文就是當前 JavaScript 程式碼被解析和執行時所在環境的抽象概念, JavaScript 中執行任何的程式碼都是在執行上下文中執行。

執行上下文的型別

執行上下文總共有三種類型:

  • 全域性執行上下文: 這是預設的、最基礎的執行上下文。不在任何函式中的程式碼都位於全域性執行上下文中。它做了兩件事:1. 建立一個全域性物件,在瀏覽器中這個全域性物件就是 window 物件。2. 將 this 指標指向這個全域性物件。一個程式中只能存在一個全域性執行上下文。
  • 函式執行上下文: 每次呼叫函式時,都會為該函式建立一個新的執行上下文。每個函式都擁有自己的執行上下文,但是隻有在函式被呼叫的時候才會被建立。一個程式中可以存在任意數量的函式執行上下文。每當一個新的執行上下文被建立,它都會按照特定的順序執行一系列步驟,具體過程將在本文後面討論。
  • Eval 函式執行上下文: 執行在 eval 函式中的程式碼也獲得了自己的執行上下文,但由於 Javascript 開發人員不常用 eval 函式,所以在這裡不再討論。

執行上下文棧

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

當 JavaScript 引擎第一次遇到你的指令碼時,它會建立一個全域性的執行上下文並且壓入當前執行棧。每當引擎遇到一個函式呼叫,它會為該函式建立一個新的執行上下文並壓入棧的頂部。

引擎會執行那些執行上下文位於棧頂的函式。當該函式執行結束時,執行上下文從棧中彈出,控制流程到達當前棧中的下一個上下文。

我們通過一段程式碼來理解:

let a = 'Hello World!';

function first() {
  console.log('Inside first function');
  second();
  console.log('Again inside first function');
}

function second() {
  console.log('Inside second function');
}

first();
console.log('Inside Global Execution Context');

上述程式碼的執行上下文棧。

當上述程式碼在瀏覽器載入時,JavaScript 引擎建立了一個全域性執行上下文並把它壓入當前執行棧。當遇到 first() 函式呼叫時,JavaScript 引擎為該函式建立一個新的執行上下文並把它壓入當前執行棧的頂部。

當從 first() 函式內部呼叫 second() 函式時,JavaScript 引擎為 second() 函式建立了一個新的執行上下文並把它壓入當前執行棧的頂部。當 second() 函式執行完畢,它的執行上下文會從當前棧彈出,並且控制流程到達下一個執行上下文,即 first() 函式的執行上下文。

first() 執行完畢,它的執行上下文從棧彈出,控制流程到達全域性執行上下文。一旦所有程式碼執行完畢,JavaScript 引擎從當前棧中移除全域性執行上下文。

執行上下文

我們已經看到了 JavaScript 引擎如何管理執行上下文,現在就讓我們來理解 JavaScript 引擎是如何建立執行上下文的。

執行上下文分兩個階段建立:1)建立階段; 2)執行階段

當函式被呼叫,但未執行任何其內部程式碼之前,會做以下三件事:

  • 建立變數環境:首先初始化函式的引數 arguments,提升函式宣告和變數宣告。下文會詳細說明。
  • 建立詞法環境:也就是作用域鏈(Scope Chain)在執行期上下文的建立階段,作用域鏈是在變數物件之後建立的。作用域鏈本身包含變數物件。作用域鏈用於解析變數。當被要求解析變數時,JavaScript 始終從程式碼巢狀的最內層開始,如果最內層沒有找到變數,就會跳轉到上一層父作用域中查詢,直到找到該變數。
  • 確定 this 指向:包括多種情況,下文會詳細說明

這是執行上下文最重要的三個屬性,接下來我們詳細講述。

變數物件

首先我們需要明白一點: JavaScript並不像大部分其他程式語言是根據順序一句一句執行,而會有一個變數提升、函式提升的一個過程。

接下來我們通過一個案例來解釋引擎是如何一段一段執行的。

showName()
console.log(myname) 
var myname = 'javascript' 
function showName() { 
  console.log('函式showName被執⾏'); 
}


//結果:
函式showName被執行
undefined

我們可以看到:

  1. 在⼀個變數定義之前使⽤它,不會出錯,但是該變數的值會為undefifined,⽽不是定義時的值。
  2. 在⼀個函式定義之前使⽤它,不會出錯,且函式能正確執⾏。

根據這種變數提升的現象,我們將變數分為兩種情況:變數物件(VO)以及活動物件(AO)。未進入執行階段之前,變數物件(VO)中的屬性都不能訪問!但是進入執行階段之後,變數物件(VO)轉變為了活動物件(AO),裡面的屬性都能被訪問了,然後開始進行執行階段的操作。但它們其實都是同一個物件,只是處於執行上下文的不同生命週期。

接下來,我們展示一個完整的變數物件的案例,從建立上下文-->執行階段的變化

function foo(a) {
  var b = 2;
  function c() {}
  var d = function() {};

  b = 3;

}

foo(1);

在執行上下文後:

AO = {
    arguments: {
        0: 1,
        length: 1
    },
    a: 1,
    b: undefined,
    c: reference to function c(){},
    d: undefined				//雖然 d 是一個函式,但是根據規定函式可以得到提升,但是函式表示式並不會提升
}

程式碼執行:

AO = {
    arguments: {
        0: 1,
        length: 1
    },
    a: 1,
    b: 3,
    c: reference to function c(){},
    d: reference to FunctionExpression "d"
}

作用域鏈

JavaScript屬於靜態作用域,即宣告的作用域是根據程式正文在編譯時就確定的,有時也稱為詞法作用域。

其本質是JavaScript在執行過程中會創造可執行上下文,可執行上下文中的詞法環境中含有外部詞法環境的引用,我們可以通過這個引用獲取外部詞法環境的變數、宣告等,這些引用串聯起來一直指向全域性的詞法環境,因此形成了作用域鏈。

重點到了,那這個外部環境是什麼呢?作用域鏈的外部環境其實是由詞法作用域決定的,詞法作用域在程式碼階段就已經決定好了,和函式怎麼呼叫是沒有關係的。這其實就對應到第一問裡的靜態作用域

舉個例子:

var a = 10
function fn() {
  var b = 20
  function bar() {
    console.log(a + b) //30
  }
  return bar
}
var x = fn(),
  b = 200
x() //bar()

函式bar呼叫了外部的fnb的值,然後又繼續在fn的外部環境中找a的值,這樣就構成了一個作用域鏈。

我們再使用一張圖來解釋詞法作用域

我們可以看到整個詞法作用域鏈:foo()函式域 --> bar()函式域 --> main()函式域 --> 全域性作用域

所以作用域鏈與誰呼叫它並沒有關係,只與詞法作用域有關係,也就是我們上面講的靜態作用域

this 指向

由於上文提到的詞法作用域的關係,JavaScript在物件內部呼叫物件屬性這種需求情況下卻無法得到滿足。所有就有了this這套體系。先說結論:

  1. new呼叫:繫結到新建立的物件
  2. callapplybind呼叫:繫結到指定的物件
  3. 由上下文物件呼叫:繫結到上下文物件
  4. 預設:全域性物件

this有兩種情況:全域性執行上下文this 和 函式執行上下文this

全域性執行上下文this

全域性執⾏上下⽂中的this是指向window物件的。這也是this和作⽤域鏈的唯⼀交點,作⽤域鏈的最底端包含了window物件,全域性執⾏上下⽂中的this也是指向window物件。相較於函式執行上下文this的複雜情況,全域性則較為簡單。

函式執行上下文this

在一個函式上下文中,this由呼叫者提供,由呼叫函式的方式來決定。如果呼叫者函式,被某一個物件所擁有,那麼該函式在呼叫時,內部的this指向該物件。如果函式獨立呼叫,那麼該函式內部的this,則指向undefined。但是在非嚴格模式中,當this指向undefined時,它會被自動指向全域性物件。

但由於函式呼叫的不同情況還是很多的,有必要用一些例子來演示一下:

// 為了能夠準確判斷,我們在函式內部使用嚴格模式,因為非嚴格模式會自動指向全域性
function fn() {
  'use strict';
  console.log(this);
}

fn();  // fn是呼叫者,獨立呼叫
window.fn();  // fn是呼叫者,被window所擁有

在上面的簡單例子中,fn()作為獨立呼叫者,按照定義的理解,它內部的this指向就為undefined。而window.fn()則因為fn被window所擁有,內部的this就指向了window物件。

var a = 20;
var foo = {
  a: 10,
  getA: function () {
    return this.a;
  }
}
console.log(foo.getA()); // 10

var test = foo.getA;
console.log(test());  // 20

foo.getA()中,getA是呼叫者,他不是獨立呼叫,被物件foo所擁有,因此它的this指向了foo。而test()作為呼叫者,儘管他與foo.getA的引用相同,但是它是獨立呼叫的,因此this指向undefined,在非嚴格模式,自動轉向全域性window。

var a = 20;
function getA() {
  return this.a;
}
var foo = {
  a: 10,
  getA: getA
}
console.log(foo.getA());  // 10

由於getA()並不是獨立呼叫,被物件foo所擁有,因此他的this只想foo

建構函式與原型方法上的this
function Person(name, age) {

    // 這裡的this指向了誰?
    this.name = name;
    this.age = age;   
}

Person.prototype.getName = function() {

    // 這裡的this又指向了誰?
    return this.name;
}

// 上面的2個this,是同一個嗎,他們是否指向了原型物件?

var p1 = new Person('Nick', 20);
p1.getName();

我們已經知道,this,是在函式呼叫過程中確定,因此,搞明白new的過程中到底發生了什麼就變得十分重要。

通過new操作符呼叫建構函式,會經歷以下4個階段。

  • 建立一個臨時物件
  • 給臨時物件繫結原型
  • 給臨時物件對應屬性賦值
  • 將臨時物件return

因此,當new操作符呼叫建構函式時,this其實指向的是這個新建立的物件,最後又將新的物件返回出來,被例項物件p1接收。因此,我們可以說,這個時候,建構函式的this,指向了新的例項物件:p1。

注:當new出來的物件時,建構函式返回物件,預設是返回自身,但如果手動返回一個物件時,則會按照返回的物件返回給例項而不是自身。

而原型方法上的this就好理解多了,根據上邊對函式中this的定義,p1.getName()中的getName為呼叫者,他被p1所擁有,因此getName中的this,也是指向了p1。

例外的的this指向--箭頭函式

先說結論:

箭頭函式的 this 是一個普通變數,指向了父級函式的 this,且這個指向永遠不會改變,也不能改變。

但是如果需要修改箭頭函式的 this ,可以通過修改父級的 this 指標,來達到子箭頭函式 this 的修改。(根本原因是箭頭函式沒有 this,而是在執行時使用父級的 this)。

舉個例子:

function outer(){    
  var inner = function(){        
    var obj = {};       
    obj.getVal=()=>{           
      console.log("*******");          
      console.log(this);           
      console.log("*******");        
    }        
    return obj;    
  };   
  return inner; 
}outer()().getVal();
// 輸出如下*******Window {parent: Window, opener: null, top: Window, length: 0, frames: Window, …}*******

getVal 函式是箭頭函式,方法裡面的 this 是跟著父級的 this。

在 outer() 執行後,返回閉包函式 inner

然後執行閉包函式 inner,而閉包函式的 inner 也是一個普通函式,仍然遵循 [誰呼叫,指向誰],這裡沒有直接呼叫物件,而是最外層的“省略的” window 呼叫的,所以 inner 的 this 是指向 window 的。

call apply 與 bind 手動改變this指向

在JavaScript中,callapplybindFunction物件自帶的三個方法,先說結論:

  1. 三者都是用來改變函式的this指向

  2. 三者的第一個引數都是this指向的物件

  3. bind是返回一個繫結函式可稍後執行,callapply是立即呼叫

  4. 三者都可以給定引數傳遞

  5. call給定引數需要將引數全部列出,apply給定引數陣列

call

call() 方法在使用一個指定的this值和若干個指定的引數值的前提下呼叫某個函式或方法。

當呼叫一個函式時,可以賦值一個不同的 this 物件。this 引用當前物件,即 call 方法的第一個引數。

語法 fun.call(thisArg, arg1, arg2, ...)

  • thisArg
    1. 不傳,或者傳nullundefined, 函式中的this指向window物件
    2. 為原始值(數字,字串,布林值)的this會指向該原始值的自動包裝物件,如 StringNumberBoolean
    3. 傳遞一個物件,函式中的this指向這個物件
apply

語法與 call() 方法的語法幾乎完全相同,唯一的區別在於,apply的第二個引數必須是一個包含多個引數的陣列(或類陣列物件)。apply的這個特性很重要.

語法:fun.apply(thisArg, [argsArray])

  • 用法-將類陣列物件轉化為陣列

    function exam(a, b, c, d, e) {
    
      // 先看看函式的自帶屬性 arguments 什麼是樣子的
      console.log(arguments);
    
      // 使用call/apply將arguments轉換為陣列, 返回結果為陣列,arguments自身不會改變
      var arg = [].slice.call(arguments);
    
      console.log(arg);
    }
    
    exam(2, 8, 9, 10, 3);
    
    // result:
    // { '0': 2, '1': 8, '2': 9, '3': 10, '4': 3 }
    // [ 2, 8, 9, 10, 3 ]
    //
    // 也常常使用該方法將DOM中的nodelist轉換為陣列
    // [].slice.call( document.getElementsByTagName('li') );
    
bind

可以看出,bind會建立一個新函式(稱之為繫結函式),原函式的一個拷貝,也就是說不會像callapply那樣立即執行。

當這個繫結函式被呼叫時,它的this值傳遞給bind的一個引數,執行的引數是傳入bind的其它引數和執行繫結函式時傳入的引數。

語法:fun.bind(thisArg, arg1, arg2, ...)

  • 與 call、apply 的區別

    當我們執行下面的程式碼時,我們希望可以正確地輸出name,然後現實是殘酷的

    function Person(name){
      this.name = name;
      this.say = function(){
        setTimeout(function(){
          console.log("hello " + this.name);
        },1000)
      }
    }
    var person = new Person("axuebin");
    person.say(); //hello undefined
    

    這裡this執行時是指向window的,所以this.nameundefined,為什麼會這樣呢?看看MDN的解釋:

    由setTimeout()呼叫的程式碼執行在與所在函式完全分離的執行環境上。這會導致,這些程式碼中包含的 this 關鍵字在非嚴格模式會指向 window。

    沒錯,這裡我們就可以用到bind了:

    function Person(name){
      this.name = name;
      this.say = function(){
        setTimeout(function(){
          console.log("hello " + this.name);
        }.bind(this),1000)
      }
    }
    var person = new Person("axuebin");
    person.say(); //hello axuebin
    

手寫callapplybind

實現call

先上終版實現程式碼:

//在函式物件原型鏈上增加mycall屬性
Function.prototype.mycall = function(context){
  var context = context || window;    //判斷傳過來的物件是否為空,為空則指向全域性執行上下文
  context.fn = this 									//將呼叫者賦給 context 的一個屬性
  
  var args = []											  //定義一個用來存放傳過來引數的類陣列物件
  for(let i=1;i<arguments.length;i++){//將類陣列物件arguments除第一個外其他放進數組裡
    args.push(arguments[i])
  }
  
  var result=context.fn(...args)			//執行呼叫者函式,並接收返回引數
  
  delete context.fn										//刪除呼叫者的函式
  return result 											//返回結果
}

call實現了什麼

舉個例子:

var foo = {
    value: 1
};

function bar() {
    console.log(this.value);
}

bar.call(foo); // 1

注意兩點:

  1. call 改變了 this 的指向,指向到 foo
  2. bar 函式執行了

5.1.2 模擬實現思路

那麼我們該怎麼模擬實現這兩個效果呢?

試想當呼叫 call 的時候,把 foo 物件改造成如下:

var foo = {
    value: 1,
    bar: function() {
        console.log(this.value)
    }
};

foo.bar(); // 1

這個時候 this 就指向了 foo,是不是很簡單呢?

但是這樣卻給 foo 物件本身添加了一個屬性,這可不行吶!

不過也不用擔心,我們用 delete 再刪除它不就好了~

所以我們模擬的步驟可以分為:

  1. 將函式設為物件的屬性
  2. 執行該函式
  3. 刪除該函式

以上個例子為例,就是:

// 第一步
foo.fn = bar
// 第二步
foo.fn()
// 第三步
delete foo.fn

fn 是物件的屬性名,反正最後也要刪除它,所以起成什麼都無所謂。

實現apply

applycall並沒有太多不同,只是在引數方面,call是一個一個傳參,而apply是多個引數的陣列傳參(或者類陣列物件)。

終版程式碼:

Function.prototype.apply = function (context, arr) {
    var context = context || window;          //判斷傳進來的物件是否為空
    context.fn = this;																//將呼叫者賦給context的一個屬性

    var result;																				//定義一個返回引數
    if (!arr) {																				
        result = context.fn();												//如果傳進來的陣列為空則直接執行呼叫者函式
    }
    else {
        var args = [];																//定義一個數組用來將傳來的陣列轉換為字串以便eval執行
        for (var i = 0, len = arr.length; i < len; i++) {
            args.push('arr[' + i + ']');							
        }
        result = eval('context.fn(' + args + ')')			//執行呼叫者函式並將返回值傳給result
    }
    delete context.fn																	//刪除呼叫者函式
    return result;
}

實現bind

最終程式碼:

Function.prototype.bind2 = function (context) {

    if (typeof this !== "function") {														//判斷是否是函式呼叫
      throw new Error("Function.prototype.bind - what is trying to be bound is not callable");
    }

    var self = this;																						//將呼叫函式賦值給self
    var args = Array.prototype.slice.call(arguments, 1);				//將呼叫bind時所傳的類陣列物件引數傳給args陣列

    var fNOP = function () {};																	//new一個新的例項,用來存放呼叫者的prototype
	
    var fBound = function () {																	//返回的函式
        var bindArgs = Array.prototype.slice.call(arguments);		//將返回的函式呼叫時所傳的引數進行轉換成陣列並賦值
      	//判定當前是普通呼叫還是以建構函式呼叫
      	//如果是建構函式呼叫,則 this 指向例項, 將例項返回
      	//如果是普通呼叫,則直接將呼叫bind的函式進行返回
        return self.apply(this instanceof self ? this : context, args.concat(bindArgs));
    }

    fNOP.prototype = this.prototype;   //為避免 共用一個prototype的現象,使用中間函式進行過度
    fBound.prototype = new fNOP();		//使用new則直接單獨指向
    return fBound;										//返回函式
}

5.3.1 bind實現了什麼

bindcallapply同為更改this指向的方法,但bind同時也需要執行以下的任務:

  1. 改變this指向

  2. 由於需要延遲執行,需要返回一個函式

  3. 引數傳入可分兩次傳入

  4. 當返回的函式作為構造器時,需要使的原有的this失效而讓this返回指向例項

  5. 需要返回的函式原型與呼叫相同

六. JavaScript面向物件

new的實現

我們看new都做了什麼:

  1. 建立一個新物件,並繼承其建構函式的prototype,這一步是為了繼承建構函式原型上的屬性和方法

  2. 執行建構函式,方法內的this被指定為該新例項,這一步是為了執行建構函式內的賦值操作

  3. 返回新物件(規範規定,如果構造方法返回了一個物件,那麼返回該物件,否則返回第一步建立的新物件)

上程式碼:

function objectFactory() {
		//使用一個新的物件,用於接收原型並返回
    var obj = new Object(),
		//將第一個引數(也就是建構函式)進行接收
    Constructor = [].shift.call(arguments);
		//將原型賦給新物件的_proto_
    obj.__proto__ = Constructor.prototype;
		//利用建構函式繼承將父函式的屬性借調給子函式
    var ret = Constructor.apply(obj, arguments);
		//如果建構函式已經返回物件則返回他的物件
  	//如果建構函式未返回物件,則返回我們的新物件
    return ret instanceof Object ? ret : obj;
};

實現繼承

根據上面的學習,我們知道了原型鏈、applycallbind以及new的原理實現,懂得了這些,接下里的繼承就相對簡單些。

原型鏈繼承

利用原型鏈原理,當找不到的屬性會向上查詢。直接讓子類的原型物件指向父類例項,當子類例項找不到對應的屬性和方法時,就會往它的原型物件,也就是父類例項上找,從而實現對父類的屬性和方法的繼承。

// 父類
function Parent() {
    this.name = '寫程式碼像蔡徐抻'
}
// 父類的原型方法
Parent.prototype.getName = function() {
    return this.name
}
// 子類
function Child() {}

// 讓子類的原型物件指向父類例項, 這樣一來在Child例項中找不到的屬性和方法就會到原型物件(父類例項)上尋找
Child.prototype = new Parent()
Child.prototype.constructor = Child // 根據原型鏈的規則,順便繫結一下constructor, 這一步不影響繼承, 只是在用到constructor時會需要

// 然後Child例項就能訪問到父類及其原型上的name屬性和getName()方法
const child = new Child()
child.name          // '寫程式碼像蔡徐抻'
child.getName()     // '寫程式碼像蔡徐抻'

缺點:

  1. 由於所有Child例項原型都指向同一個Parent例項, 因此對某個Child例項的父類引用型別變數修改會影響所有的Child例項
  2. 在建立子類例項時無法向父類構造傳參, 即沒有實現super()的功能
// 示例:
function Parent() {
    this.name = ['寫程式碼像蔡徐抻'] 
}
Parent.prototype.getName = function() {
    return this.name
}
function Child() {}

Child.prototype = new Parent()
Child.prototype.constructor = Child 

// 測試
const child1 = new Child()
const child2 = new Child()
child1.name[0] = 'foo'
console.log(child1.name)          // ['foo']
console.log(child2.name)          // ['foo'] (預期是['寫程式碼像蔡徐抻'], 對child1.name的修改引起了所有child例項的變化)

6.2.2 建構函式繼承

建構函式繼承,即在子類的建構函式中執行父類的建構函式,併為其繫結子類的this,讓父類的建構函式把成員屬性和方法都掛到子類的this上去,這樣既能避免例項之間共享一個原型例項,又能向父類構造方法傳參

function Parent(name) {
    this.name = [name]
}
Parent.prototype.getName = function() {
    return this.name
}
function Child() {
    Parent.call(this, 'zhangsan')   // 執行父類構造方法並繫結子類的this, 使得父類中的屬效能夠賦到子類的this上
}

//測試
const child1 = new Child()
const child2 = new Child()
child1.name[0] = 'foo'
console.log(child1.name)          // ['foo']
console.log(child2.name)          // ['zhangsan']
child2.getName()                  // 報錯,找不到getName(), 建構函式繼承的方式繼承不到父類原型上的屬性和方法

缺點:

  1. 繼承不到父類原型上的屬性和方法

6.2.3 組合式繼承

既然原型鏈繼承和建構函式繼承各有互補的優缺點, 那麼我們為什麼不組合起來使用呢, 所以就有了綜合二者的組合式繼承

function Parent(name) {
    this.name = [name]
}
Parent.prototype.getName = function() {
    return this.name
}
function Child() {
    // 建構函式繼承
    Parent.call(this, 'zhangsan') 
}
//原型鏈繼承
Child.prototype = new Parent()
Child.prototype.constructor = Child

//測試
const child1 = new Child()
const child2 = new Child()
child1.name[0] = 'foo'
console.log(child1.name)          // ['foo']
console.log(child2.name)          // ['zhangsan']
child2.getName()                  // ['zhangsan']

缺點:

  1. 每次建立子類例項都執行了兩次建構函式(Parent.call()new Parent()),雖然這並不影響對父類的繼承,但子類建立例項時,原型中會存在兩份相同的屬性和方法,這並不優雅

6.2.4 寄生式組合繼承

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

Parent.prototype.getName = function () {
    console.log(this.name)
}

function Child (name, age) {
    Parent.call(this, name);
    this.age = age;
}

// 關鍵的三步
// var F = function () {};

// F.prototype = Parent.prototype;

// Child.prototype = new F();

var temp = Object.create(Parent.prototype)
temp.constructor = Child
Child.prototype = temp


var child1 = new Child('kevin', '18');

console.log(child1);

到這裡我們就完成了ES5環境下的繼承的實現,這種繼承方式稱為寄生組合式繼承,是目前最成熟的繼承方式,babel對ES6繼承的轉化也是使用了寄生組合式繼承

六. 閉包

閉包的概念

ECMAScript中,閉包指的是:

  1. 從理論角度:所有的函式。因為它們都在建立的時候就將上層上下文的資料儲存起來了。哪怕是簡單的全域性變數也是如此,因為函式中訪問全域性變數就相當於是在訪問自由變數,這個時候使用最外層的作用域。
  2. 從實踐角度:以下函式才算是閉包:
    1. 即使建立它的上下文已經銷燬,它仍然存在(比如,內部函式從父函式中返回)
    2. 在程式碼中引用了自由變數

閉包的分析

結論:根據程式碼的執行過程,執行上下文會維護一個作用域鏈,即使作用域鏈上的執行上下文被銷燬,JavaScript 依然會將作用域鏈上的變數物件儲存起來,其函式依然可以對其變數物件引用進行讀寫。

例子:

var scope = "global scope";
function checkscope(){
    var scope = "local scope";
    function f(){
        return scope;
    }
    return f;
}

var foo = checkscope();
foo();

這裡直接給出簡要的執行過程:

  1. 進入全域性程式碼,建立全域性執行上下文,全域性執行上下文壓入執行上下文棧
  2. 全域性執行上下文初始化
  3. 執行 checkscope 函式,建立 checkscope 函式執行上下文,checkscope 執行上下文被壓入執行上下文棧
  4. checkscope 執行上下文初始化,建立變數物件、作用域鏈、this等
  5. checkscope 函式執行完畢,checkscope 執行上下文從執行上下文棧中彈出
  6. 執行 f 函式,建立 f 函式執行上下文,f 執行上下文被壓入執行上下文棧
  7. f 執行上下文初始化,建立變數物件、作用域鏈、this等
  8. f 函式執行完畢,f 函式上下文從執行上下文棧中彈出

當我們瞭解了具體的執行過程後,我們知道 f 執行上下文維護了一個作用域鏈:

fContext = {
    Scope: [AO, checkscopeContext.AO, globalContext.VO],
}

對的,就是因為這個作用域鏈,f 函式依然可以讀取到 checkscopeContext.AO 的值,說明當 f 函式引用了 checkscopeContext.AO 中的值的時候,即使 checkscopeContext 被銷燬了,但是 JavaScript 依然會讓 checkscopeContext.AO 活在記憶體中,f 函式依然可以通過 f 函式的作用域鏈找到它,正是因為 JavaScript 做到了這一點,從而實現了閉包這個概念。

閉包的資料儲存--棧與堆

JavaScript有8種資料型別,可以主要分為兩大類:原始資料型別 和 引用資料型別

其中,原始型別的資料是存放在棧中,引⽤型別的資料是存放在堆中的。堆中的資料是通過引⽤和變數關聯 起來的。也就是說,JavaScript的變數是沒有資料型別的,值才有資料型別,變數可以隨時持有任何型別的資料。

根據記憶體來分析閉包

我們上面講過閉包,可能不能很好的理解閉包,這次我們從記憶體上來分析閉包是如何實現的。

先說結論:產⽣閉包的核⼼有兩步:第⼀步是需要預掃描內部函式;第⼆步是把內部函式引⽤的外部變數儲存到堆中。 當預掃描時,發現內部函式對外部函式有變數引用,則將變數存在堆中,儲存變數,外部使用變數也只是引用地址,這樣的話外部函式執行上下文被銷燬,內部函式引用的變數也不會被銷燬。

function foo() { 
  var myName = "極客時間" 
  let test1 = 1 
  const test2 = 2
  var innerBar = { 
    setName:function(newName){
      myName = newName },
    getName:function(){ 
      console.log(test1) 
      return myName
    } 
  }
  return innerBar 
}
var bar = foo() 
bar.setName("極客邦")
bar.getName() 
console.log(bar.getName())

我們站在記憶體模型的⻆度來分析這段程式碼的執⾏流程。

  1. 當JavaScript引擎執⾏到foo函式時,⾸先會編譯,並建立⼀個空執⾏上下⽂。

  2. 在編譯過程中,遇到內部函式setName,JavaScript引擎還要對內部函式做⼀次快速的詞法掃描,發現 該內部函式引⽤了foo函式中的myName變數,由於是內部函式引⽤了外部函式的變數,所以JavaScript 引擎判斷這是⼀個閉包,於是在堆空間建立換⼀個“closure(foo)”的物件(這是⼀個內部物件, JavaScript是⽆法訪問的),⽤來儲存myName變數。

  3. 接著繼續掃描到getName⽅法時,發現該函式內部還引⽤變數test1,於是JavaScript引擎⼜將test1新增 到“closure(foo)”物件中。這時候堆中的“closure(foo)”物件中就包含了myName和test1兩個變量了。

  4. 由於test2並沒有被內部函式引⽤,所以test2依然儲存在調⽤棧中。

閉包的作用

閉包最大的作用就是隱藏變數,閉包的一大特性就是內部函式總是可以訪問其所在的外部函式中宣告的引數和變數,即使在其外部函式被返回(壽命終結)了之後而外部變數無法訪問內部變數。

基於此特性,JavaScript可以實現私有變數、特權變數、儲存變數等

我們就以私有變數舉例,私有變數的實現方法很多,有靠約定的(變數名前加_),有靠Proxy代理的,也有靠Symbol這種新資料型別的。

但是真正廣泛流行的其實是使用閉包。

function Person(){
    var name = 'cxk';
    this.getName = function(){
        return name;
    }
    this.setName = function(value){
        name = value;
    }
}

const cxk = new Person()

console.log(cxk.getName()) //cxk
cxk.setName('jntm')
console.log(cxk.getName()) //jntm
console.log(name) //name is not defined

函式體內的var name = 'cxk'只有getNamesetName兩個函式可以訪問,外部無法訪問,相對於將變數私有化。

搬運文章

深入理解JavaScript

從_proto_和prototype來深入理解js物件和原型鏈

[譯]理解JavaScript中的執行上下文和執行棧

ES6系列之let和const

冴羽的部落格

深入理解JavaScript執行上下文與執行上下文棧

前端基礎進階(七):全方位解讀this

回味JS基礎:call apply 與 bind

對阮一峰《ES6 入門》中箭頭函式 this 描述的探究

2萬字 | 前端基礎拾遺90問