JavaScript三大重點同步異步與作用域和閉包及原型和原型鏈詳解

如圖所示,JS的三座大山:

  • 同步、異步
  • 作用域、閉包
  • 原型、原型鏈

1. 同步、異步

JavaScript執行機制,重點有兩點:

  • JavaScript是一門單線程語言
  • Event Loop(事件循環)是JavaScript的執行機制

JS為什麼是單線程

最初設計JS是用來在瀏覽器驗證表單操控DOM元素的是一門腳本語言,如果js是多線程的,那麼兩個線程同時對一個DOM元素進行瞭相互沖突的操作,那麼瀏覽器的解析器是無法執行的。

js為什麼需要異步

如果js中不存在異步,隻能自上而下執行,如果上一行解析時間很長,那麼下面的代碼就會被阻塞。

對於用戶而言,阻塞就以為著“卡死”,這樣就導致瞭很差的用戶體驗。比如在進行ajax請求的時候如果沒有返回數據後面的代碼就沒辦法執行

JS的事件循環(eventloop)是怎麼運作的

  • 首先判斷JS是同步還是異步,同步就進入主線程運行,異步就進入event table.
  • 異步任務在event table中註冊事件,當滿足觸發條件後,(觸發條件可能是延時也可能是ajax回調),被推入event queue
  • 同步任務進入主線程後一直執行,直到主線程空閑時,才會去event queue中查看是否有可執行的異步任務,如果有就推入主線程中。

如圖所示:

那怎麼知道主線程執行棧為空呢?js引擎存在monitoring process進程,會持續不斷的檢查 主線程執行棧是否為空,一旦為空,就會去event queue那裡檢查是否有等待被調用的函數

宏任務 包含整個script代碼塊,setTimeout, setIntval

微任務 Promise , process.nextTick

在劃分宏任務、微任務的時候並沒有提到async/ await的本質就是Promise

setTimeout(function() {
    console.log('4')
})
new Promise(function(resolve) {
    console.log('1') // 同步任務
    resolve()
}).then(function() {
    console.log('3')
})
console.log('2')

執行結果: 1-2-3-4
1. 這段代碼作為宏任務,進入主線程。
2. 先遇到setTimeout,那麼將其回調函數註冊後分發到宏任務event queue.
3. 接下來遇到Promise, new Promise立即執行,then函數分發到微任務event queue
4. 遇到console.log(), 立即執行
5. 整體代碼script作為第一個宏任務執行結束, 查看當前有沒有可執行的微任務,執行then的回調。(第一輪事件循環結束瞭,我們開始第二輪循環)
6. 從宏任務的event queue開始,我們發現瞭宏任務event queue中setTimeout對應的回調函數,立即執行。

console.log('1')
setTimeout(function() {
    console.log('2')
    process.nextTick(function() {
        console.log('3')
    })
    new Promise(function(resolve) {
        console.log('4')
        resolve()
    }).then(function() {
        console.log('5')
    })
})
process.nextTick(function() {
    console.log('6')
})
new Promise(function(resolve) {
    console.log('7')
    resolve()
}).then(function() {
    console.log('8')
})
setTimeout(function() {
    console.log('9')
    process.nextTick(function() {
        console.log('10')
    })
    new Promise(function(resolve) {
        console.log('11')
        resolve()
    }).then(function() {
        console.log('12')
    })
})

1.整體script作為第一個宏任務進入主線程,遇到console.log(1)輸出1

遇到setTimeout, 其回調函數被分發到宏任務event queue中。我們暫且記為setTimeout1
3.遇到process.nextTick(),其回調函數被分發到微任務event queue中,我們記為process1
4.遇到Promise, new Promise直接執行,輸出7.then被分發到微任務event queue中,我們記為then1
又遇到setTimeout,其回調函數被分發到宏任務event queue中,我們記為setTimeout2.
現在開始執行微任務, 我們發現瞭process1和then1兩個微任務,執行process1,輸出6,執行then1,輸出8, 第一輪事件循環正式結束, 這一輪的結果輸出1,7,6,8.那麼第二輪事件循環從setTimeout1宏任務開始
5. 首先輸出2, 接下來遇到瞭process.nextTick(),統一被分發到微任務event queue,記為process2
8new Promise立即執行,輸出4,then也被分發到微任務event queue中,記為then2
6. 現在開始執行微任務,我們發現有process2和then2兩個微任務可以執行輸出3,5. 第二輪事件循環結束,第二輪輸出2,4,3,5. 第三輪事件循環從setTimeout2哄任務開始
10。 直接輸出9,跟第二輪事件循環類似,輸出9,11,10,12
7. 完整輸出是1,7,6,8,2,4,3,5,9,11,10,12(請註意,node環境下的事件監聽依賴libuv與前端環境不完全相同,輸出順序可能會有誤差)

async/await用來幹什麼

用來優化promise的回調問題,被稱為是異步的終極解決方案

async/await內部做瞭什麼

async函數會返回一個Promise對象,如果在函數中return一個直接量(普通變量),async會把這個直接量通過Promise.resolve()封裝成Promise對象。如果你返回瞭promise那就以你返回的promise為準。await是在等待,等待運行的結果也就是返回值。await後面通常是一個異步操作(promise),但是這不代表await後面隻能跟異步才做,await後面實際是可以接普通函數調用或者直接量。

async相當於 new Promise,await相當於then

await的等待機制

如果await後面跟的不是一個promise,那await後面表達式的運算結果就是它等到的東西,如果await後面跟的是一個promise對象,await它會’阻塞’後面的diamante,等著promise對象resolve,

然後得到resolve的值作為await表達式的運算結果。但是此"阻塞"非彼“阻塞”,這就是await必須用在async函數中的原因。 async函數調用不會造成"阻塞",它內部所有的“阻塞”都被封裝在一個promise對象中異步執行(這裡的阻塞理解成異步等待更合理)

async/await在使用過程中有什麼規定

每個async方法都返回一個promise, await隻能出現在async函數中

async/await在什麼場景使用

單一的promise鏈並不能發現async/await的有事,但是如果需要處理由多個promise組成的then鏈的時候,優勢就能體現出來瞭(Promise通過then鏈來解決多層回調的問題,現在又用async/awai來進一步優化它)

2. 作用域、閉包

閉包

  • 閉包是指有權訪問另外一個函數作用域中的變量的函數(紅寶書)
  • 閉包是指那些能夠訪問自由變量的函數。(MDN)其中自由變量, 指在函數中使用的,但既不是函數參數arguments也不是函數的局部變量的變量,其實就是另外一個函數作用域中的變量。)

作用域

說起閉包,就必須要說說作用域,ES5種隻存在兩種作用域:

  • 函數作用域。
  • 全局作用域 當訪問一個變量時,解釋器會首先在當前作用域查找標示符,如果沒有找到, 就去父作用域找, 直到找到該變量的標示符或者不在父作用域中
  • 這就是作用域鏈,每一個子函數都會拷貝上級的作用域, 形成一個作用域的鏈條。
let a = 1;
function f1() {
    var a = 2
  function f2() {
       var a = 3;
       console.log(a); //3
   }
}

在這段代碼中,

  • f1的作用域指向有全局作用域(window) 和它本身
  • 而f2的作用域指向全局作用域(window)、 f1和它本身
  • 而且作用域是從最底層向上找, 直到找到全局作用域window為止
  • 如果全局還沒有的話就會報錯。閉包產生的本質就是
  • 當前環境中存在指向父級作用域的引用
function f2() {
    var a = 2
    function f3() {
        console.log(a); //2
    }
    return f3;
}
var x = f2();
x();

這裡x會拿到父級作用域中的變量, 輸出2。

因為在當前環境中,含有對f3的引用, f3恰恰引用瞭window、 f3和f3的作用域。

因此f3可以訪問到f2的作用域的變量。那是不是隻有返回函數才算是產生瞭閉包呢?回到閉包的本質,隻需要讓父級作用域的引用存在即可。

var f4;
function f5() {
    var a = 2
    f4 = function () {
        console.log(a);
    }
}
f5();
f4();

讓f5執行,給f4賦值後,等於說現在f4擁有瞭window、f5和f4本身這幾個作用域的訪問權,還是自底向上查找,最近是在f5中找到瞭a,因此輸出2。在這裡是外面的變量f4存在著父級作用域的引用,

因此產生瞭閉包,形式變瞭,本質沒有改變。

場景

  • 返回一個函數
  • 作為函數參數傳遞
  • 在定時器、 事件監聽、 Ajax請求、 跨窗口通信、 Web Workers或者任何異步中,隻要使用瞭回調函數, 實際上就是在使用閉包。

IIFE(立即執行函數表達式) 創建閉包, 保存瞭全局作用域window和當前函數的作用域。

	var b = 1;
	function foo() {
	    var b = 2;
	
	    function baz() {
	        console.log(b);
	    }
	    bar(baz);
	}
	function bar(fn) {
	    // 這就是閉包
	    fn();
	}
	// 輸出2,而不是1
	foo();
	// 以下的閉包保存的僅僅是window和當前作用域。
	// 定時器
	setTimeout(function timeHandler() {
	   console.log('111');
	}, 100)
	
	// 事件監聽
	// document.body.click(function () {
	//     console.log('DOM Listener');
	// })
	// 立即執行函數
	var c = 2;
	(function IIFE() {
	    // 輸出2
	    console.log(c);
	})();

經典的一道題

for (var i = 1; i <= 5; i++) {
    setTimeout(function timer() {
        console.log(i)
    }, 0)
}  // 6 6 6 6 6 6
// 為什麼會全部輸出6? 如何改進, 讓它輸出1, 2, 3, 4, 5?

解析:

  • 因為setTimeout為宏任務, 由於JS中單線程eventLoop機制, 在主線程同步任務執行完後才去執行宏任務。
  • 因此循環結束後setTimeout中的回調才依次執行, 但輸出i的時候當前作用域沒有。

往上一級再找,發現瞭i,此時循環已經結束,i變成瞭6,因此會全部輸出6。

利用IIFE(立即執行函數表達式)當每次for循環時,把此時的i變量傳遞到定時器中

	for (var i = 0; i < 5; i++) {
	    (function (j) {
	        setTimeout(() => {
	            console.log(j)
	        }, 1000);
	    })(i)
	}

給定時器傳入第三個參數, 作為timer函數的第一個函數參數

for (var i = 0; i < 5; i++) {
	    setTimeout(function (j) {
	        console.log(j)
	    }, 1000, i);
	}

使用ES6中的let

  • let使JS發生革命性的變化, 讓JS有函數作用域變為瞭塊級作用域
  • 用let後作用域鏈不復存在。 代碼的作用域以塊級為單位,
for (let i = 1; i <= 5; i++) {
    setTimeout(function timer() {
        console.log(i)
    }, 2000)
}

3. 原型、原型鏈

原型(prototype)

JS中所有函數都會有prototype屬性,隻有函數才有

其所有的屬性和方法都能被構造函數的實例對象共享訪問

代碼如下:

function Person(name){
		this.name = name
	}
	Person.prototype.sayHello(){
		console.log('sayHello')
	}
	let p1 = new Person();
	let p2 = new Person();
	console.log(p1.sayHello) //sayHello
	console.log(p2.sayHello) //sayHello

構造函數(constructor)

JS中constructor存在每個函數的prototype屬性中,其保存瞭指向該函數的引用

Person.prototype.constructor ==Person   //true

原型鏈(_ _ proto _ _)

JS中對象都會有個內置屬性,即__proto__,(隱式原型鏈的屬性),一般情況下執行創建它的構造函數的prototype的屬性,另外函數比較特殊,也會有該屬性

p1.__proto__ == Person.prototype

JS 引擎查找摸個屬性時,先查找對象本身是否存在該屬性,如果不存在就會在原型鏈上一層一層進行查找

有幾個面試經常會問的幾個問題

如何精確地判斷短數組的類型

[] instanceof Array   //[].__proto__ == Array.prototype
Object.prototype.toString.call([])  //[Object Array]
Array.isArray([]) //true
[].constructor ==Array

下面代碼輸出什麼

Object instanceof Function //true
Function instanceof Object // true

實現一個原型鏈繼承

	function Person(name){
		this.name = name
	}
	Person.prototype.sayHello(){
		console.log('sayHello')
	}
	function Boy(){};
	Boy.prototype = new Person();
	let b1 = new Boy();
	b1.sayHello() //sayHello

原型、原型鏈、構造函數、實例的關系

1.instanceof檢測構造函數與實例的關系:

	function Person () {.........}
	person = new Person ()
	res = person instanceof Person
	res  // true

2.實例繼承原型上的定義的屬性:

	function Person () {........}
	Person.prototype.type = 'object n'
	person = new Person ()
	res = person.type
	res  // object n

3.實例訪問 ===> 原型

實例通過__proto__訪問到原型 person.proto=== Person.prototype

4.原型訪問 ===> 構造函數

原型通過constructor屬性訪問構造函數 Person.prototype.constructor === Person

5.實例訪問===>構造函數

person.proto.constructor === Person

原型鏈

在讀取一個實例的屬性的過程中,如果屬性在該實例中沒有找到,那麼就會循著 proto 指定的原型上去尋找,如果還找不到,則尋找原型的原型:

實例上尋找

function Person() {}
    Person.prototype.type = "object name Person";
    person = new Person();
    person.type = "我是實例的自有屬性";
    res = Reflect.ownKeys(person); //嘗試獲取到自有屬性
    console.log(res);
    res = person.type;
    console.log(res); //我是實例的自有屬性(通過原型鏈向上搜索優先搜索實例裡的)

原型上尋找

function Person() {}
    Person.prototype.type = "object name Person";
    person = new Person();
    res = Reflect.ownKeys(person); //嘗試獲取到自有屬性
    console.log(res);
    res = person.type;
    console.log(res); //object name Person

原型的原型上尋找

function Person() {}
    Person.prototype.type = "object name Person";
    function Child() {}
    Child.prototype = new Person();
    p = new Child();
    res = [p instanceof Object, p instanceof Person, p instanceof Child];
    console.log(res); //[true, true, true] p同時屬於Object,Person, Child
    res = p.type; //層層搜索
    console.log(res); //object name Person (原型鏈上搜索)
    console.dir(Person);
    console.dir(Child);

原型鏈上搜索

原型同樣也可以通過 proto 訪問到原型的原型,比方說這裡有個構造函數 Child 然後“繼承”前者的有一個構造函數 Person,然後 new Child 得到實例 p;

當訪問 p 中的一個非自有屬性的時候,就會通過 proto 作為橋梁連接起來的一系列原型、原型的原型、原型的原型的原型直到 Object 構造函數為止;

原型鏈搜索搜到 null 為止,搜不到那訪問的這個屬性就停止:

function Person() {}
  Person.prototype.type = "object name Person";
  function Child() {}
  Child.prototype = new Person();
  p = new Child();
  res = p.__proto__;
  console.log(res);         //Person {}
  res = p.__proto__.__proto__;
  console.log(res);         //Person {type:'object name Person'}
  res = p.__proto__.__proto__.__proto__;
  console.log(res);         //{.....}
  res = p.__proto__.__proto__.__proto__.__proto__;
  console.log(res);         //null

繼承

  • JS 中一切皆對象(所有的數據類型都可以用對象來表示),必須有一種機制,把所有的對象聯系起來,實現類似的“繼承”機制。
  • 不同於大部分面向對象語言,ES6 之前並沒有引入類(class)的概念,JS 並非通過類而是通過構造函數來創建實例,javascript中的繼承是通過原型鏈來體現的。
  • 其基本思想是利用原型讓一個引用類型繼承另一個引用繼承的屬性和方法。

什麼是繼承

  • js中,繼承是一種允許我們在已有類的基礎上創建新類的機制;它可以使用現有類的所有功能,並在無需重新編寫
  • 原來的類的情況下對這些功能進行擴展。

為什麼要有繼承

提高代碼的重用性、較少代碼的冗餘

目前我總結的一共有6種繼承方式

  • 原型鏈繼承
  • 借用構造函數繼承
  • 組合式繼承(原型鏈+構造函數)
  • 原型式繼承
  • 寄生式繼承
  • 寄生組合式繼承
function Person(name){
	this.name = name;
	this.sum=function(){
		alert('this.name',this.name)
	}
}
Person.prototype.age = 100

原型鏈繼承

實現方式: 利用原型鏈的特點繼承,讓實例的原型等於父類的實例

優點: 實例可以繼承父類的構造個函數,實例的構造函數,父類的原型

缺點: 不能向父類傳遞參數,由於實例的原型等於父類的實例,那麼改變父類的屬性,實例的屬性也會跟著改變

function child(){
	this.name="xiaoming"
}
child.prototype = new Person()
let child1 = new Child()
child1.name //xiaoming
child1.age //100
child1 instanceof Person //true

借用構造函數繼承

實現方式: 使用call/apply將父類的構造函數引入子類函數

優點: 可以禰補原型鏈繼承的缺點,可以向父類傳遞參數,隻繼承父類構造函數的屬性

缺點: 不能復用,每次使用需要重新調用,每個實例都是父類構造函數的副本,比較臃腫

	function child(){
		Person.call(this,'xiaoming')
	}
	let child1 = new child()
	child1.name //xiaoming
	child1.age //100
	child1 instanceof Person //false

組合式繼承

實現方式: 復用+可傳遞參數

優點: 基於原型鏈的優點和借用構造函數的優點

缺點: 調用兩遍父類函數

	function child(){
		Person.call(this,'xiaoming')
	}
	child.prototype = new Person 
	let child1 = new child()
	child1.name //xiaoming
	child1.age //100
	child1 instanceof Person //true
	child instanceof Person //false

原型式繼承

實現方式: 函數包裝對象,返回對象的引用,這個函數就變成可以隨時添加實例或者對象,Object.create()就是這個原理

優點: 復用一個對象用函數包裝

缺點: 所有實例都繼承在原型上面 無法復用

	function child(obj){
		 function F(){}
		 F.prototype = obj
		 return new F()
	}
	let child1 = new Person()
	let child2 = child(child1)
	child2.age //100

寄生式繼承

實現方式: 在原型式繼承外面包瞭一個殼子

優點: 創建一個新對象

缺點: 沒有用到實例 無法復用

	function child(obj){
		 function F(){}
		 F.prototype = obj
		 return new F()
	}
	let child1 = new Person()
	function subObject(){
		let sub =child(child1)
		sub.name='xiaoming'
		return sub
	}
	let child2 = subObject(child1)
	typeof subObject //function
	typeof child2 //object
	child2.age //100

寄生組合式繼承

實現方式: 在函數內返回對象的調用

優點: 函數的實例等於另外的一個實例,使用call/apply引入另一個構造函數,可傳遞參數,修復瞭組合繼承的問題

缺點: 無法復用

	function child(obj){
			 function F(){}
			 F.prototype = obj
			 return new F()
		}
		let child1 = child(Person.prototype)
		function Sub(){
			Person.call(this)
		}
		Sub.prototype = child
		child.constructor = Sub
		let sub1 = new Sub()
		sub1.age //100

到此這篇關於JavaScript三大重點同步異步與作用域和閉包及原型和原型鏈詳解的文章就介紹到這瞭,更多相關JavaScript 同步異步內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: