Javascript 技術原理剖析

風靈使發表於2018-06-01

javascript 事件驅動機制

javascript在瀏覽器端和伺服器端(node.js)的事件驅動機制。

1、javascript 在瀏覽器端的事件驅動機制

javascript 在瀏覽器端執行是單執行緒的,這是由瀏覽器決定的,這是為了避免多執行緒執行不同任務會發生衝突的情況。也就是說我們寫的javascript 程式碼只在一個執行緒上執行,稱之為主執行緒(HTML5提供了web worker API可以讓瀏覽器開一個執行緒執行比較複雜耗時的 javascript任務,但是這個執行緒仍受主執行緒的控制)。

有些操作比如說獲取遠端資料、I/O操作等,他們都很耗時,如果採用同步的方式,那麼程式在執行這些操作時就會因為耗時而等待,就像上面那樣,下面的任務也只能等待,這樣效率並不高。
為了解決單執行緒帶來的阻塞問題很多作業系統實現了非同步程式設計機制,瀏覽器中也是這麼做的,主要表現如下:
- 只在主執行緒中執行 javascript 程式碼
- 主執行緒一啟動就進入事件迴圈,整個過程就是不斷的迴圈,不斷地執行回撥函式
- 遇到網路請求、I/O操作等時,瀏覽器會單開工作執行緒來處理,並設定相應的觀察者,然後立即返回主執行緒,主執行緒繼續執行下面的任務
- 瀏覽器開的執行緒處理好任務或者有監聽的事件後會用得到的資料(或輸入)形成一個事件,放在相應觀察者的事件佇列中,事件佇列是在主執行緒中
- 主執行緒不斷的迴圈,不斷檢查事件佇列,通過遍歷事件依次執行事件對應的回撥函式

注意:下圖中的訊息佇列是儲存在主執行緒中

上圖中,假設你發起了一個AJAX請求,無論你把這個請求寫在什麼地方,它始終都在回撥函式裡。
因為事件驅動機制就是把一切抽象為事件,程式碼開始執行也是一個事件,也會隱式呼叫回撥函式,呼叫回撥函式就是開始執行程式碼。然後主執行緒發起非同步任務後就會隨即返回,繼續執行”程式碼開始事件”對應回撥函式裡下面的程式碼,等到這個回撥函式執行完畢,就會執行下一個事件。在這之間,Ajax執行緒會完成請求,然後把請求完成的事件(包含返回的資料)傳送到事件隊尾中等待處理,等到主執行緒執行到這個事件時,指定的回撥函式即被執行。

watcher機制
watcher,觀察者,是事件驅動系統重要的機制。

setTimeout稱為定時器,這是瀏覽器給的API。
每當你使用定時器,這個函式將會設定一個watcher,觀察者。主執行緒會不斷的迴圈,不斷的”經過”這裡檢查時間,當主執行緒檢查時間間隔符合要求時,就會產生一個定時器事件,加入到這個watcher事件佇列中並執行回撥函式。
因此執行setTimeout只是在時間到的時候產生了要呼叫回撥函式的訊息加入到了事件佇列中,因此,回撥函式並不一定在指定的時間時呼叫,它取決於前面有多少等待處理的事件。

剛才講的是定時器觀察者,還有I/O觀察者、網路請求觀察者、滑鼠事件觀察者、鍵盤事件觀察者等等等等,我們經常遇到事件監聽函式會讓你繫結一個回撥函式,這種監聽函式一般就會設定watcher,其他執行緒產生的事件也會放到相應watcher的事件佇列中,因此每個watcher會產生自己的事件佇列。主執行緒在迴圈的時候,實際上是在依次呼叫這些watcher,檢查每個watcher的事件佇列,有事件就執行相應的回撥。

它的過程就是 :
- 程式一啟動就進入事件迴圈
- 有監聽就新增watcher
- 遍歷watcher下的事件佇列
- 執行下一個watcher
事件驅動機制,它會有各種各樣的事件,大量的事件,它所做的一切都跟處理事件有關。但並不是所有的事件都有watcher,如果都有,主程式任務會變得非常繁重,況且有些事件我們並不關心,例如你只寫了一個定時器,代表你關心這個事件,那麼點選事件、網路請求事件就不用關心,因為你根本就沒寫啊,也就沒有watcher。

2、javascript 在 node.js上的事件驅動機制
javascript 在 node.js上的事件驅動機制與瀏覽器端大致相同,都是單執行緒,都有event loop,上面講的javascript在瀏覽器端的事件迴圈機制在node上也是大致一樣的,不同的是執行者何執行者的行為不一樣,因為他們關注的任務不一樣:
- node端非同步機制和事件迴圈更加純粹一些。node為了支援高併發,所有的API幾乎都是非同步的,這樣會充分利用作業系統的其他執行緒來幫忙完成任務,主執行緒只負責事件消費。例如當web server接收到請求,node就把它關閉,交給其他執行緒進行處理,然後去服務下一個web請求。當這個請求完成,它被放到處理佇列,當到達佇列開頭,這個結果被返回給使用者。這樣的話webserver一直接受請求而不等待任何讀寫操作,這種非阻塞型I/O效能很強。
- 瀏覽器端是瀏覽器負責執行BOM API,管理執行緒,處理使用者輸入資訊等,在node上是node的一個核心庫libuv負責執行node API,管理主執行緒(執行javascript)和工作執行緒等。
- 因為前端和後端關注的內容不同,因此兩個執行環境的API也專注於不同的任務


阻塞非阻塞與同步非同步的區別

1、同步與非同步

同步和非同步關注的是訊息通訊機制 。
同步,就是在發出一個呼叫時,在沒有得到結果之前,該呼叫就不返回。但是一旦呼叫返回,就得到返回值了。
換句話說,就是由呼叫者主動等待這個呼叫的結果。

而非同步則是相反,呼叫在發出之後,這個呼叫就直接返回了,所以沒有返回結果。
換句話說,當一個非同步過程呼叫發出後,呼叫者不會立刻得到結果。而是在呼叫發出後,被呼叫者通過狀態、通知來通知呼叫者,或通過回撥函式處理這個呼叫。

2.、阻塞與非阻塞

阻塞和非阻塞關注的是程式在等待呼叫結果(訊息,返回值)時的狀態.

阻塞呼叫是指呼叫結果返回之前,當前執行緒會被掛起。呼叫執行緒只有在得到結果之後才會返回。

非阻塞呼叫指在不能立刻得到結果之前,該呼叫不會阻塞當前執行緒。


javascript 堆疊

棧:原始資料型別(Undefined,Null,Boolean,Number、String)
堆:引用資料型別(物件、陣列和函式)

兩種型別的區別是:儲存位置不同;

原始資料型別直接儲存在棧(stack)中的簡單資料段,佔據空間小、大小固定,屬於被頻繁使用資料,所以放入棧中儲存;

引用資料型別儲存在堆(heap)中的物件,佔據空間大、大小不固定。
如果儲存在棧中,將會影響程式執行的效能。
引用資料型別在棧中儲存了指標,該指標指向堆中該實體的起始地址。
當直譯器尋找引用值時,會首先檢索其在棧中的地址,取得地址後從堆中獲得實體。


javascript 跨域

只要協議、域名、埠有任何一個不同,都被當作是不同的域,之間的請求就是跨域操作。

1、如何解決跨域問題?
jsonp、 iframe、window.name、window.postMessage、伺服器上設定代理頁面

2、造成跨域的兩種策略
瀏覽器的同源策略會導致跨域,這裡同源策略又分為以下兩種DOM同源策略:
(1)禁止對不同源頁面DOM進行操作。這裡主要場景是iframe跨域的情況,不同域名的iframe是限制互相訪問的。
(2)XmlHttpRequest同源策略:禁止使用XHR物件向不同源的伺服器地址發起HTTP請求。

3、跨域的解決方式

(1)跨域資源共享
CORS是一個W3C標準,全稱是”跨域資源共享”(Cross-origin resource sharing)。
對於客戶端,我們還是正常使用xhr物件傳送ajax請求。唯一需要注意的是,我們需要設定我們的xhr屬性withCredentials為true,不然的話,cookie是帶不過去的哦,設定: xhr.withCredentials = true;
對於伺服器端,需要在 response header中設定如下兩個欄位:Access-Control-Allow-Origin: http://www.yourhost.comAccess-Control-Allow-Credentials:true這樣,我們就可以跨域請求介面了。

(2)jsonp
基本原理就是通過動態建立script標籤,然後利用src屬性進行跨域。

(3)伺服器代理
瀏覽器有跨域限制,但是伺服器不存在跨域問題,所以可以由伺服器請求所要域的資源再返回給客戶端。
伺服器代理是萬能的。

(4)使用window.name進行跨域
window.name跨域同樣是受到同源策略限制,父框架和子框架的src必須指向統一域名。
window.name的優勢在於,name的值在不同的頁面(或者不同的域名),載入後仍然存在,除非你顯示的更改。並且支援的長度達到2M.

(5)location.hash跨域
location.hash方式跨域,是子框架具有修改父框架src的hash值,通過這個屬性進行傳遞資料,且更改hash值,頁面不會重新整理。
但是傳遞的資料的位元組數是有限的。

(6)使用postMessage實現頁面之間通訊
資訊傳遞除了客戶端與伺服器之前的傳遞,還存在以下幾個問題:
- 頁面和新開的視窗的資料互動。
- 多視窗之間的資料互動。
- 頁面與所巢狀的iframe之間的資訊傳遞。
window.postMessage是一個HTML5的api,允許兩個視窗之間進行跨域傳送訊息。這個應該就是以後解決dom跨域通用方法了,具體可以參照MDN。


javascript 繼承

JS繼承的實現方式:首先得有一個父類。

// 定義一個動物類
function Animal (name) {
  // 屬性
  this.name = name || 'Animal';
  // 例項方法
  this.sleep = function(){
    console.log(this.name + '正在睡覺!');
  }
}
// 原型方法
Animal.prototype.eat = function(food) {
  console.log(this.name + '正在吃:' + food);
};

1、原型鏈繼承

核心: 將父類的例項作為子類的原型

function Cat(){ 
}
Cat.prototype = new Animal();
Cat.prototype.name = 'cat';

// Test Code
var cat = new Cat();
console.log(cat.name);
console.log(cat.eat('fish'));
console.log(cat.sleep());
console.log(cat instanceof Animal); //true 
console.log(cat instanceof Cat); //true

特點:
- 非常純粹的繼承關係,例項是子類的例項,也是父類的例項
- 父類新增原型方法/原型屬性,子類都能訪問到
- 簡單,易於實現

缺點:
- 要想為子類新增屬性和方法,必須要在new Animal()這樣的語句之後執行,不能放到構造器中
- 無法實現多繼承
- 來自原型物件的引用屬性是所有例項共享的(詳細請看附錄程式碼: 示例1)
- 建立子類例項時,無法向父類建構函式傳參

2、構造繼承
核心:使用父類的建構函式來增強子類例項,等於是複製父類的例項屬性給子類(沒用到原型)

function Cat(name){
  Animal.call(this);
  this.name = name || 'Tom';
}

// Test Code
var cat = new Cat();
console.log(cat.name);
console.log(cat.sleep());
console.log(cat instanceof Animal); // false
console.log(cat instanceof Cat); // true

特點:
- 解決了1中,子類例項共享父類引用屬性的問題
- 建立子類例項時,可以向父類傳遞引數
- 可以實現多繼承(call多個父類物件)

缺點:
- 例項並不是父類的例項,只是子類的例項
- 只能繼承父類的例項屬性和方法,不能繼承原型屬性/方法
- 無法實現函式複用,每個子類都有父類例項函式的副本,影響效能

3、例項繼承
核心:為父類例項新增新特性,作為子類例項返回。

function Cat(name){
  var instance = new Animal();
  instance.name = name || 'Tom';
  return instance;
}

// Test Code
var cat = new Cat();
console.log(cat.name);
console.log(cat.sleep());
console.log(cat instanceof Animal); // true
console.log(cat instanceof Cat); // false

特點:
- 不限制呼叫方式,不管是new 子類()還是子類(),返回的物件具有相同的效果

缺點:
- 例項是父類的例項,不是子類的例項
- 不支援多繼承

4、拷貝繼承

function Cat(name){
  var animal = new Animal();
  for(var p in animal){
    Cat.prototype[p] = animal[p];
  }
  Cat.prototype.name = name || 'Tom';
}

// Test Code
var cat = new Cat();
console.log(cat.name);
console.log(cat.sleep());
console.log(cat instanceof Animal); // false
console.log(cat instanceof Cat); // true

特點:
- 支援多繼承

缺點:
- 效率較低,記憶體佔用高(因為要拷貝父類的屬性)
- 無法獲取父類不可列舉的方法(不可列舉方法,不能使用for in 訪問到)

5、組合繼承
核心:通過呼叫父類構造,繼承父類的屬性並保留傳參的優點,然後通過將父類例項作為子類原型,實現函式複用

function Cat(name){
  Animal.call(this);
  this.name = name || 'Tom';
}
Cat.prototype = new Animal();
Cat.prototype.constructor = Cat;

// Test Code
var cat = new Cat();
console.log(cat.name);
console.log(cat.sleep());
console.log(cat instanceof Animal); // true
console.log(cat instanceof Cat); // true

特點:
- 彌補了方式2的缺陷,可以繼承例項屬性/方法,也可以繼承原型屬性/方法
- 既是子類的例項,也是父類的例項
- 不存在引用屬性共享問題
- 可傳參
- 函式可複用

缺點:
- 呼叫了兩次父類建構函式,生成了兩份例項(子類例項將子類原型上的那份遮蔽了)

6、寄生組合繼承

核心:通過寄生方式,砍掉父類的例項屬性,這樣,在呼叫兩次父類的構造的時候,就不會初始化兩次例項方法/屬性,避免的組合繼承的缺點

function Cat(name){
  Animal.call(this);
  this.name = name || 'Tom';
}
(function(){
  // 建立一個沒有例項方法的類
  var Super = function(){};
  Super.prototype = Animal.prototype;
  //將例項作為子類的原型
  Cat.prototype = new Super();
})();

// Test Code
var cat = new Cat();
console.log(cat.name);
console.log(cat.sleep());
console.log(cat instanceof Animal); // true
console.log(cat instanceof Cat); //true

Cat.prototype.constructor = Cat; // 需要修復下建構函式

特點:
- 堪稱完美

缺點:
- 實現較為複雜


javascript 閉包

閉包是指有權訪問另一個函式作用域中變數的函式。
建立閉包的最常見的方式就是在一個函式內建立另一個函式,通過另一個函式訪問這個函式的區域性變數,利用閉包可以突破作用鏈域,將函式內部的變數和方法傳遞到外部。

見解:
閉包就是通過外層函式將引數傳到內層函式,由內層函式計算執行,再return出外層函式。外部函式的引數值不會被垃圾回收機制回收。

閉包的特性:
- 函式內再巢狀函式
- 內部函式可以引用外層的引數和變數
- 引數和變數不會被垃圾回收機制回收

相關文章