javascript
JavaScript 教程(二)
面向對象編程
實例對象與 new 命令
JavaScript 語言具有很強的面向對象編程能力,這里介紹 JavaScript 面向對象編程的基礎知識
對象是什么
面向對象編程(Object Oriented Programming,縮寫為 OOP)是目前主流的編程范式。它將真實世界各種復雜的關系,抽象為一個個對象,然后由對象之間的分工與合作,完成對真實世界的模擬。每一個對象都是功能中心,具有明確分工,可以完成接受信息、處理數據、發出信息等任務。對象可以復用,通過繼承機制還可以定制。因此,面向對象編程具有靈活、代碼可復用、高度模塊化等特點,容易維護和開發,比起由一系列函數或指令組成的傳統的過程式編程(procedural programming),更適合多人合作的大型軟件項目。那么,“對象”(object)到底是什么?我們從兩個層次來理解
對象是單個實物的抽象
一本書、一輛汽車、一個人都可以是對象,一個數據庫、一張網頁、一個與遠程服務器的連接也可以是對象。當實物被抽象成對象,實物之間的關系就變成了對象之間的關系,從而就可以模擬現實情況,針對對象進行編程
對象是一個容器,封裝了屬性(property)和方法(method)
屬性是對象的狀態,方法是對象的行為(完成某種任務)。比如,我們可以把動物抽象為animal對象,使用“屬性”記錄具體是那一種動物,使用“方法”表示動物的某種行為(奔跑、捕獵、休息等等)
構造函數
面向對象編程的第一步,就是要生成對象。前面說過,對象是單個實物的抽象。通常需要一個模板,表示某一類實物的共同特征,然后對象根據這個模板生成。典型的面向對象編程語言(比如 C++ 和 Java),都有“類”(class)這個概念。所謂“類”就是對象的模板,對象就是“類”的實例。但是,JavaScript 語言的對象體系,不是基于“類”的,而是基于構造函數(constructor)和原型鏈(prototype)。JavaScript 語言使用構造函數(constructor)作為對象的模板。所謂”構造函數”,就是專門用來生成實例對象的函數。它就是對象的模板,描述實例對象的基本結構。一個構造函數,可以生成多個實例對象,這些實例對象都有相同的結構。構造函數就是一個普通的函數,但是有自己的特征和用法
var Vehicle = function () {this.price = 1000; };上面代碼中,Vehicle就是構造函數。為了與普通函數區別,構造函數名字的第一個字母通常大寫
構造函數的特點有兩個。
1.函數體內部使用了this關鍵字,代表了所要生成的對象實例
2.生成對象的時候,必須使用new命令
new 命令
基本用法
new命令的作用,就是執行構造函數,返回一個實例對象
var Vehicle = function () {this.price = 1000; }; var v = new Vehicle(); v.price // 1000上面代碼通過new命令,讓構造函數Vehicle生成一個實例對象,保存在變量v中。這個新生成的實例對象,從構造函數Vehicle得到了price屬性。new命令執行時,構造函數內部的this,就代表了新生成的實例對象,this.price表示實例對象有一個price屬性,值是1000。使用new命令時,根據需要構造函數也可以接受參數
var Vehicle = function (p) {this.price = p; }; var v = new Vehicle(500);new命令本身就可以執行構造函數,所以后面的構造函數可以帶括號,也可以不帶括號。下面兩行代碼是等價的,但是為了表示這里是函數調用,推薦使用括號
var v = new Vehicle(); // 推薦的寫法 var v = new Vehicle; // 不推薦的寫法一個很自然的問題是,如果忘了使用new命令,直接調用構造函數會發生什么事?這種情況下,構造函數就變成了普通函數,并不會生成實例對象。而且由于后面會說到的原因,this這時代表全局對象,將造成一些意想不到的結果
var Vehicle = function (){this.price = 1000; }; var v = Vehicle(); v // undefined price // 1000上面代碼中,調用Vehicle構造函數時,忘了加上new命令。結果,變量v變成了undefined,而price屬性變成了全局變量。因此,應該非常小心,避免不使用new命令、直接調用構造函數。為了保證構造函數必須與new命令一起使用,一個解決辦法是,構造函數內部使用嚴格模式,即第一行加上use strict。這樣的話,一旦忘了使用new命令,直接調用構造函數就會報錯
function Fubar(foo, bar){'use strict';this._foo = foo;this._bar = bar; } Fubar() // TypeError: Cannot set property '_foo' of undefined上面代碼的Fubar為構造函數,use strict命令保證了該函數在嚴格模式下運行。由于嚴格模式中,函數內部的this不能指向全局對象,默認等于undefined,導致不加new調用會報錯(JavaScript 不允許對undefined添加屬性)。另一個解決辦法,構造函數內部判斷是否使用new命令,如果發現沒有使用,則直接返回一個實例對象
function Fubar(foo, bar) {if (!(this instanceof Fubar)) {return new Fubar(foo, bar);}this._foo = foo;this._bar = bar; } Fubar(1, 2)._foo // 1 (new Fubar(1, 2))._foo // 1上面代碼中的構造函數,不管加不加new命令,都會得到同樣的結果
new 命令的原理
使用new命令時,它后面的函數依次執行下面的步驟
1.創建一個空對象,作為將要返回的對象實例
2.將這個空對象的原型,指向構造函數的prototype屬性
3.將這個空對象賦值給函數內部的this關鍵字
4.開始執行構造函數內部的代碼
也就是說,構造函數內部,this指的是一個新生成的空對象,所有針對this的操作,都會發生在這個空對象上。構造函數之所以叫“構造函數”,就是說這個函數的目的,就是操作一個空對象(即this對象),將其“構造”為需要的樣子。如果構造函數內部有return語句,而且return后面跟著一個對象,new命令會返回return語句指定的對象;否則,就會不管return語句,返回this對象
var Vehicle = function () {this.price = 1000;return 1000; }; (new Vehicle()) === 1000 // false上面代碼中,構造函數Vehicle的return語句返回一個數值。這時,new命令就會忽略這個return語句,返回“構造”后的this對象。但是,如果return語句返回的是一個跟this無關的新對象,new命令會返回這個新對象,而不是this對象。這一點需要特別引起注意
var Vehicle = function (){this.price = 1000;return { price: 2000 }; }; (new Vehicle()).price // 2000上面代碼中,構造函數Vehicle的return語句,返回的是一個新對象。new命令會返回這個對象,而不是this對象。另一方面,如果對普通函數(內部沒有this關鍵字的函數)使用new命令,則會返回一個空對象
function getMessage() {return 'this is a message'; } var msg = new getMessage(); msg // {} typeof msg // "object"上面代碼中,getMessage是一個普通函數,返回一個字符串。對它使用new命令,會得到一個空對象。這是因為new命令總是返回一個對象,要么是實例對象,要么是return語句指定的對象。本例中,return語句返回的是字符串,所以new命令就忽略了該語句。new命令簡化的內部流程,可以用下面的代碼表示
function _new(/* 構造函數 */ constructor, /* 構造函數參數 */ params) { var args = [].slice.call(arguments); // 將 arguments 對象轉為數組 var constructor = args.shift(); // 取出構造函數 var context = Object.create(constructor.prototype); // 創建一個空對象,繼承構造函數的 prototype 屬性 var result = constructor.apply(context, args); // 執行構造函數 return (typeof result === 'object' && result != null) ? result : context; // 如果返回結果是對象,就直接返回,否則返回 context 對象 } var actor = _new(Person, '張三', 28); // 實例new.target
函數內部可以使用new.target屬性。如果當前函數是new命令調用,new.target指向當前函數,否則為undefined
function f() {console.log(new.target === f); } f() // false new f() // true使用這個屬性,可以判斷函數調用的時候,是否使用new命令
function f() {if (!new.target) {throw new Error('請使用 new 命令調用!');}// ... } f() // Uncaught Error: 請使用 new 命令調用!Object.create() 創建實例對象
構造函數作為模板,可以生成實例對象。但是,有時拿不到構造函數,只能拿到一個現有的對象。我們希望以這個現有的對象作為模板,生成新的實例對象,這時就可以使用Object.create()方法
var person1 = {name: '張三',age: 38,greeting: function() {console.log('Hi! I\'m ' + this.name + '.');} }; var person2 = Object.create(person1); person2.name // 張三 person2.greeting() // Hi! I'm 張三.上面代碼中,對象person1是person2的模板,后者繼承了前者的屬性和方法
this 關鍵字
涵義
this關鍵字是一個非常重要的語法點。毫不夸張地說,不理解它的含義大部分開發任務都無法完成。this可以用在構造函數之中,表示實例對象;除此之外,this還可以用在別的場合。但不管是什么場合,this都有一個共同點:它總是返回一個對象;簡單說,this就是屬性或方法“當前”所在的對象
var person = {name: '張三',describe: function () { return '姓名:'+ this.name; } }; person.describe() // "姓名:張三"上面代碼中,this.name表示name屬性所在的那個對象。由于this.name是在describe方法中調用,而describe方法所在的當前對象是person,因此this指向person,this.name就是person.name。由于對象的屬性可以賦給另一個對象,所以屬性所在的當前對象是可變的,即this的指向是可變的
var A = {name: '張三',describe: function () { return '姓名:'+ this.name; } }; var B = { name: '李四' }; B.describe = A.describe; B.describe() // "姓名:李四"上面代碼中,A.describe屬性被賦給B,于是B.describe就表示describe方法所在的當前對象是B,所以this.name就指向B.name。稍稍重構這個例子,this的動態指向就能看得更清楚
function f() { return '姓名:'+ this.name; } var A = { name: '張三', describe: f }; var B = { name: '李四', describe: f }; A.describe() // "姓名:張三" B.describe() // "姓名:李四"只要函數被賦給另一個變量,this的指向就會變
var A = {name: '張三',describe: function () { return '姓名:'+ this.name; } }; var name = '李四'; var f = A.describe; f() // "姓名:李四"上面代碼中,A.describe被賦值給變量f,內部的this就會指向f運行時所在的對象(本例是頂層對象)。再看一個網頁編程的例子
<input type="text" name="age" size=3 onChange="validate(this, 18, 99);"> <script> function validate(obj, lowval, hival){if ((obj.value < lowval) || (obj.value > hival))console.log('Invalid Value!'); } </script>上面代碼是一個文本輸入框,每當用戶輸入一個值,就會調用onChange回調函數,驗證這個值是否在指定范圍。瀏覽器會向回調函數傳入當前對象,因此this就代表傳入當前對象(即文本框),然后就可以從this.value上面讀到用戶的輸入值
總結一下,JavaScript 語言之中,一切皆對象,運行環境也是對象,所以函數都是在某個對象之中運行,this就是函數運行時所在的對象(環境)。這本來并不會讓用戶糊涂,但是 JavaScript 支持運行環境動態切換,也就是說,this的指向是動態的,沒有辦法事先確定到底指向哪個對象,這才是最讓初學者感到困惑的地方
實質
JavaScript 語言之所以有 this 的設計,跟內存里面的數據結構有關系
var obj = { foo: 5 };上面的代碼將一個對象賦值給變量obj。JavaScript 引擎會先在內存里面,生成一個對象{ foo: 5 },然后把這個對象的內存地址賦值給變量obj。也就是說,變量obj是一個地址(reference)。后面如果要讀取obj.foo,引擎先從obj拿到內存地址,然后再從該地址讀出原始的對象,返回它的foo屬性。原始的對象以字典結構保存,每一個屬性名都對應一個屬性描述對象。舉例來說,上面例子的foo屬性,實際上是以下面的形式保存的
{foo: {[[value]]: 5[[writable]]: true[[enumerable]]: true[[configurable]]: true} }注意,foo屬性的值保存在屬性描述對象的value屬性里面。這樣的結構是很清晰的,問題在于屬性的值可能是一個函數;這時,引擎會將函數單獨保存在內存中,然后再將函數的地址賦值給foo屬性的value屬性
var obj = { foo: function () {} };由于函數是一個單獨的值,所以它可以在不同的環境(上下文)執行
var f = function () {}; var obj = { f: f }; f() // 單獨執行 obj.f() // obj 環境執行JavaScript 允許在函數體內部,引用當前環境的其他變量
var f = function () {console.log(x); };上面代碼中,函數體里面使用了變量x,該變量由運行環境提供。現在問題就來了,由于函數可以在不同的運行環境執行,所以需要有一種機制,能夠在函數體內部獲得當前的運行環境(context)。所以,this就出現了,它的設計目的就是在函數體內部,指代函數當前的運行環境
var f = function () { console.log(this.x);} var x = 1; var obj = { f: f, x: 2}; // 單獨執行 f() // 1 // obj 環境執行 obj.f() // 2上面代碼中,函數f在全局環境執行,this.x指向全局環境的x;在obj環境執行,this.x指向obj.x
使用場合
this主要有以下幾個使用場合
全局環境
全局環境使用this,它指的就是頂層對象window
this === window // true function f() {console.log(this === window); } f() // true上面代碼說明,不管是不是在函數內部,只要是在全局環境下運行,this就是指頂層對象window
構造函數
構造函數中的this,指的是實例對象
var Obj = function (p) {this.p = p; }; var o = new Obj('Hello World!'); o.p // "Hello World!"上面代碼定義了一個構造函數Obj。由于this指向實例對象,所以在構造函數內部定義this.p,就相當于定義實例對象有一個p屬性
對象的方法
如果對象的方法里面包含this,this的指向就是方法運行時所在的對象。該方法賦值給另一個對象,就會改變this的指向。但是,這條規則很不容易把握。請看下面的代碼
var obj ={foo: function () { console.log(this); } }; obj.foo() // obj上面代碼中,obj.foo方法執行時,它內部的this指向obj。但是,下面這幾種用法,都會改變this的指向
// 情況一 (obj.foo = obj.foo)() // window // 情況二 (false || obj.foo)() // window // 情況三 (1, obj.foo)() // window上面代碼中,obj.foo就是一個值。這個值真正調用的時候,運行環境已經不是obj了,而是全局環境,所以this不再指向obj。可以這樣理解,JavaScript 引擎內部,obj和obj.foo儲存在兩個內存地址,稱為地址一和地址二。obj.foo()這樣調用時,是從地址一調用地址二,因此地址二的運行環境是地址一,this指向obj。但是,上面三種情況,都是直接取出地址二進行調用,這樣的話,運行環境就是全局環境,因此this指向全局環境。上面三種情況等同于下面的代碼
// 情況一 (obj.foo = function () { console.log(this);})() // 等同于 (function () { console.log(this);})() // 情況二 (false || function () { console.log(this);})() // 情況三 (1, function () { console.log(this);})()如果this所在的方法不在對象的第一層,這時this只是指向當前一層的對象,而不會繼承更上面的層
var a = {p: 'Hello',b: {m: function() { console.log(this.p); }} }; a.b.m() // undefined上面代碼中,a.b.m方法在a對象的第二層,該方法內部的this不是指向a,而是指向a.b,因為實際執行的是下面的代碼
var b = {m: function() { console.log(this.p); } }; var a = { p: 'Hello', b: b }; (a.b).m() // 等同于 b.m()如果要達到預期效果,只有寫成下面這樣
var a = {b: {m: function() { console.log(this.p); },p: 'Hello'} };如果這時將嵌套對象內部的方法賦值給一個變量,this依然會指向全局對象
var a = {b: {m: function() { console.log(this.p); },p: 'Hello'} }; var hello = a.b.m; hello() // undefined上面代碼中,m是多層對象內部的一個方法。為求簡便,將其賦值給hello變量,結果調用時,this指向了頂層對象。為了避免這個問題,可以只將m所在的對象賦值給hello,這樣調用時,this的指向就不會變
var hello = a.b; hello.m() // Hello使用注意點
避免多層 this
由于this的指向是不確定的,所以切勿在函數中包含多層的this
var o = {f1: function () {console.log(this);var f2 = function () { console.log(this); }();} } o.f1() // Object // Window上面代碼包含兩層this,結果運行后,第一層指向對象o,第二層指向全局對象,因為實際執行的是下面的代碼
var temp = function () { console.log(this); }; var o = {f1: function () { console.log(this); var f2 = temp(); } }一個解決方法是在第二層改用一個指向外層this的變量
var o = {f1: function() {console.log(this);var that = this;var f2 = function() { console.log(that); }();} } o.f1() // Object // Object上面代碼定義了變量that,固定指向外層的this,然后在內層使用that,就不會發生this指向的改變。事實上,使用一個變量固定this的值,然后內層函數調用這個變量,是非常常見的做法。JavaScript 提供了嚴格模式,也可以硬性避免這種問題。嚴格模式下,如果函數內部的this指向頂層對象,就會報錯
var counter = { count: 0 }; counter.inc = function () {'use strict';this.count++ }; var f = counter.inc; f() // TypeError: Cannot read property 'count' of undefined上面代碼中,inc方法通過'use strict'聲明采用嚴格模式,這時內部的this一旦指向頂層對象,就會報錯
避免數組處理方法中的 this
數組的map和foreach方法,允許提供一個函數作為參數。這個函數內部不應該使用this
var o = {v: 'hello',p: [ 'a1', 'a2' ],f: function f() {this.p.forEach(function (item) { console.log(this.v + ' ' + item); });} } o.f() // undefined a1 // undefined a2上面代碼中,foreach方法的回調函數中的this,其實是指向window對象,因此取不到o.v的值。原因跟上一段的多層this是一樣的,就是內層的this不指向外部,而指向頂層對象。解決這個問題的一種方法,就是前面提到的,使用中間變量固定this
var o = {v: 'hello',p: [ 'a1', 'a2' ],f: function f() {var that = this;this.p.forEach(function (item) { console.log(that.v+' '+item); });} } o.f() // hello a1 // hello a2另一種方法是將this當作foreach方法的第二個參數,固定它的運行環境
var o = {v: 'hello',p: [ 'a1', 'a2' ],f: function f() {this.p.forEach(function (item) { console.log(this.v + ' ' + item); }, this);} } o.f() // hello a1 // hello a2避免回調函數中的 this
回調函數中的this往往會改變指向,最好避免使用
var o = new Object(); o.f = function () { console.log(this === o);} $('#button').on('click', o.f); // jQuery 的寫法上面代碼中,點擊按鈕以后,控制臺會顯示false。原因是此時this不再指向o對象,而是指向按鈕的 DOM 對象,因為f方法是在按鈕對象的環境中被調用的。這種細微的差別,很容易在編程中忽視,導致難以察覺的錯誤。為了解決這個問題,可以采用下面的一些方法對this進行綁定,也就是使得this固定指向某個對象,減少不確定性
綁定 this 的方法
this的動態切換,固然為 JavaScript 創造了巨大的靈活性,但也使得編程變得困難和模糊。有時,需要把this固定下來,避免出現意想不到的情況。JavaScript 提供了call、apply、bind這三個方法,來切換/固定this的指向
Function.prototype.call()
函數實例的call方法,可以指定函數內部this的指向(即函數執行時所在的作用域),然后在所指定的作用域中,調用該函數
var obj = {}; var f = function () { return this; }; f() === window // true f.call(obj) === obj // true上面代碼中,全局環境運行函數f時,this指向全局環境(瀏覽器為window對象);call方法可以改變this的指向,指定this指向對象obj,然后在對象obj的作用域中運行函數f。call方法的參數,應該是一個對象。如果參數為空、null和undefined,則默認傳入全局對象
var n = 123; var obj = { n: 456 }; function a() { console.log(this.n);} a.call() // 123 a.call(null) // 123 a.call(undefined) // 123 a.call(window) // 123 a.call(obj) // 456上面代碼中,a函數中的this關鍵字,如果指向全局對象,返回結果為123。如果使用call方法將this關鍵字指向obj對象,返回結果為456。可以看到,如果call方法沒有參數,或者參數為null或undefined,則等同于指向全局對象。如果call方法的參數是一個原始值,那么這個原始值會自動轉成對應的包裝對象,然后傳入call方法
var f = function () {return this; }; f.call(5) // Number {[[PrimitiveValue]]: 5}上面代碼中,call的參數為5,不是對象,會被自動轉成包裝對象(Number的實例),綁定f內部的this。call方法還可以接受多個參數
func.call(thisValue, arg1, arg2, ...)call的第一個參數就是this所要指向的那個對象,后面的參數則是函數調用時所需的參數
function add(a, b) { return a + b;} add.call(this, 1, 2) // 3上面代碼中,call方法指定函數add內部的this綁定當前環境(對象),并且參數為1和2,因此函數add運行后得到3。call方法的一個應用是調用對象的原生方法
var obj = {}; // object的hasOwnProperty()方法返回一個布爾值,判斷對象是否包含特定的自身(非繼承)屬性 obj.hasOwnProperty('toString') // false // 覆蓋掉繼承的 hasOwnProperty 方法 obj.hasOwnProperty = function () { return true;}; obj.hasOwnProperty('toString') // true Object.prototype.hasOwnProperty.call(obj, 'toString') // false上面代碼中,hasOwnProperty是obj對象繼承的方法,如果這個方法一旦被覆蓋,就不會得到正確結果。call方法可以解決這個問題,它將hasOwnProperty方法的原始定義放到obj對象上執行,這樣無論obj上有沒有同名方法,都不會影響結果
Function.prototype.apply()
apply方法的作用與call方法類似,也是改變this指向,然后再調用該函數。唯一的區別就是,它接收一個數組作為函數執行時的參數,使用格式如下:
func.apply(thisValue, [arg1, arg2, ...])apply方法的第一個參數也是this所要指向的那個對象,如果設為null或undefined,則等同于指定全局對象。第二個參數則是一個數組,該數組的所有成員依次作為參數,傳入原函數。原函數的參數,在call方法中必須一個個添加,但是在apply方法中,必須以數組形式添加
function f(x, y){ console.log(x + y);} f.call(null, 1, 1) // 2 f.apply(null, [1, 1]) // 2上面代碼中,f函數本來接受兩個參數,使用apply方法以后,就變成可以接受一個數組作為參數。利用這一點,可以做一些有趣的應用
找出數組最大元素
JavaScript 不提供找出數組最大元素的函數。結合使用apply方法和Math.max方法,就可以返回數組的最大元素
var a = [10, 2, 4, 15, 9]; Math.max.apply(null, a) // 15將數組的空元素變為undefined
通過apply方法,利用Array構造函數將數組的空元素變成undefined
Array.apply(null, ['a', ,'b']) // [ 'a', undefined, 'b' ]空元素與undefined的差別在于,數組的forEach方法會跳過空元素,但是不會跳過undefined。因此,遍歷內部元素的時候,會得到不同的結果
var a = ['a', , 'b']; function print(i) { console.log(i);} a.forEach(print) // a // b Array.apply(null, a).forEach(print) // a // undefined // b轉換類似數組的對象
另外,利用數組對象的slice方法,可以將一個類似數組的對象(比如arguments對象)轉為真正的數組
Array.prototype.slice.apply({0: 1, length: 1}) // [1] Array.prototype.slice.apply({0: 1}) // [] Array.prototype.slice.apply({0: 1, length: 2}) // [1, undefined] Array.prototype.slice.apply({length: 1}) // [undefined]上面代碼的apply方法的參數都是對象,但是返回結果都是數組,這就起到了將對象轉成數組的目的。從上面代碼可以看到,這個方法起作用的前提是,被處理的對象必須有length屬性,以及相對應的數字鍵
綁定回調函數的對象
前面的按鈕點擊事件的例子,可以改寫如下:
var o = new Object(); o.f = function () { console.log(this === o);} var f = function (){o.f.apply(o); // 或者 o.f.call(o); }; $('#button').on('click', f); // jQuery 的寫法上面代碼中,點擊按鈕以后,控制臺將會顯示true。由于apply方法(或者call方法)不僅綁定函數執行時所在的對象,還會立即執行函數,因此不得不把綁定語句寫在一個函數體內。更簡潔的寫法是采用下面介紹的bind方法
Function.prototype.bind()
bind方法用于將函數體內的this綁定到某個對象,然后返回一個新函數
var d = new Date(); d.getTime() // 1553063951536 var print = d.getTime; print() // Uncaught TypeError: this is not a Date object.上面代碼中,我們將d.getTime方法賦給變量print,然后調用print就報錯了。這是因為getTime方法內部的this,綁定Date對象的實例,賦給變量print以后,內部的this已經不指向Date對象的實例了。bind方法可以解決這個問題
var print = d.getTime.bind(d); print() // 1553063951536上面代碼中,bind方法將getTime方法內部的this綁定到d對象,這時就可以安全地將這個方法賦值給其他變量了。bind方法的參數就是所要綁定this的對象,下面是一個更清晰的例子
var counter = {count: 0,inc: function () { this.count++; } }; var func = counter.inc.bind(counter); func(); counter.count // 1上面代碼中,counter.inc方法被賦值給變量func。這時必須用bind方法將inc內部的this,綁定到counter,否則就會出錯。this綁定到其他對象也是可以的
var counter = {count: 0,inc: function () { this.count++; } }; var obj = { count: 100 }; var func = counter.inc.bind(obj); func(); obj.count // 101上面代碼中,bind方法將inc方法內部的this,綁定到obj對象。結果調用func函數以后,遞增的就是obj內部的count屬性。bind還可以接受更多的參數,將這些參數綁定原函數的參數
var add = function (x, y) { return x * this.m + y * this.n;} var obj = { m: 2, n: 3}; var newAdd = add.bind(obj, 5); newAdd(6) // 28上面代碼中,bind方法除了綁定this對象,還將add函數的第一個參數x綁定成5,然后返回一個新函數newAdd,這個函數只要再接受一個參數y就能運行了。如果bind方法的第一個參數是null或undefined,等于將this綁定到全局對象,函數運行時this指向頂層對象(瀏覽器為window)
function add(x, y) { return x + y;} var plus = add.bind(null, 5); plus(10) // 15上面代碼中,函數add內部并沒有this,使用bind方法的主要目的是綁定參數x,以后每次運行新函數plus,就只需要提供另一個參數y就夠了。而且因為add內部沒有this,所以bind的第一個參數是null,不過這里如果是其他對象,也沒有影響。bind方法有一些使用注意點
每一次返回一個新函數
bind方法每運行一次,就返回一個新函數,這會產生一些問題。比如,監聽事件的時候,不能寫成下面這樣
element.addEventListener('click', o.m.bind(o));上面代碼中,click事件綁定bind方法生成的一個匿名函數。這樣會導致無法取消綁定,所以,下面的代碼是無效的
element.removeEventListener('click', o.m.bind(o));正確的方法是寫成下面這樣:
var listener = o.m.bind(o); element.addEventListener('click', listener); // ... element.removeEventListener('click', listener);結合回調函數使用
回調函數是 JavaScript 最常用的模式之一,但是一個常見的錯誤是,將包含this的方法直接當作回調函數。解決方法就是使用bind方法,將counter.inc綁定counter
var counter = {count: 0,inc: function () {'use strict'; this.count++; } }; function callIt(callback) { callback();} callIt(counter.inc.bind(counter)); counter.count // 1上面代碼中,callIt方法會調用回調函數。這時如果直接把counter.inc傳入,調用時counter.inc內部的this就會指向全局對象。使用bind方法將counter.inc綁定counter以后,就不會有這個問題,this總是指向counter。還有一種情況比較隱蔽,就是某些數組方法可以接受一個函數當作參數。這些函數內部的this指向,很可能也會出錯
var obj = {name: '張三',times: [1, 2, 3],print: function () {this.times.forEach(function (n) { console.log(this.name); });} }; obj.print() // 沒有任何輸出上面代碼中,obj.print內部this.times的this是指向obj的,這個沒有問題。但是,forEach方法的回調函數內部的this.name卻是指向全局對象,導致沒有辦法取到值。稍微改動一下,就可以看得更清楚
obj.print = function () {this.times.forEach(function (n) { console.log(this === window); }); }; obj.print() // true // true // true解決這個問題,也是通過bind方法綁定this
obj.print = function () {this.times.forEach(function (n) { console.log(this.name); }.bind(this)); }; obj.print() // 張三 // 張三 // 張三結合call方法使用
利用bind方法,可以改寫一些 JavaScript 原生方法的使用形式,以數組的slice方法為例
[1, 2, 3].slice(0, 1) // [1] // 等同于 Array.prototype.slice.call([1, 2, 3], 0, 1) // [1]上面的代碼中,數組的slice方法從[1, 2, 3]里面,按照指定位置和長度切分出另一個數組。這樣做的本質是在[1, 2, 3]上面調用Array.prototype.slice方法,因此可以用call方法表達這個過程,得到同樣的結果。call方法實質上是調用Function.prototype.call方法,因此上面的表達式可以用bind方法改寫
var slice = Function.prototype.call.bind(Array.prototype.slice); slice([1, 2, 3], 0, 1) // [1]上面代碼的含義就是,將Array.prototype.slice變成Function.prototype.call方法所在的對象,調用時就變成了Array.prototype.slice.call。類似的寫法還可以用于其他數組方法
var push = Function.prototype.call.bind(Array.prototype.push); var pop = Function.prototype.call.bind(Array.prototype.pop); var a = [1 ,2 ,3]; push(a, 4) a // [1, 2, 3, 4] pop(a) a // [1, 2, 3]如果再進一步,將Function.prototype.call方法綁定到Function.prototype.bind對象,就意味著bind的調用形式也可以被改寫
function f() { console.log(this.v);} var o = { v: 123 }; var bind = Function.prototype.call.bind(Function.prototype.bind); bind(f, o)() // 123上面代碼的含義就是,將Function.prototype.bind方法綁定在Function.prototype.call上面,所以bind方法就可以直接使用,不需要在函數實例上使用
對象的繼承
面向對象編程很重要的一個方面,就是對象的繼承。A 對象通過繼承 B 對象,就能直接擁有 B 對象的所有屬性和方法。這對于代碼的復用是非常有用的。大部分面向對象的編程語言,都是通過“類”(class)實現對象的繼承。傳統上,JavaScript 語言的繼承不通過 class,而是通過“原型對象”(prototype)實現,這里主要介紹 JavaScript 的原型鏈繼承。ES6 引入了 class 語法,基于 class 的繼承暫不在這里介紹
原型對象概述
構造函數的缺點
JavaScript 通過構造函數生成新對象,因此構造函數可以視為對象的模板。實例對象的屬性和方法,可以定義在構造函數內部
function Cat (name, color) { this.name = name; this.color = color;} var cat1 = new Cat('大毛', '白色'); cat1.name // '大毛' cat1.color // '白色'上面代碼中,Cat函數是一個構造函數,函數內部定義了name屬性和color屬性,所有實例對象(上例是cat1)都會生成這兩個屬性,即這兩個屬性會定義在實例對象上面。通過構造函數為實例對象定義屬性,雖然很方便,但是有一個缺點。同一個構造函數的多個實例之間,無法共享屬性,從而造成對系統資源的浪費
function Cat(name, color) {this.name = name;this.color = color;this.meow = function () { console.log('喵喵'); }; } var cat1 = new Cat('大毛', '白色'); var cat2 = new Cat('二毛', '黑色'); cat1.meow === cat2.meow // false上面代碼中,cat1和cat2是同一個構造函數的兩個實例,它們都具有meow方法。由于meow方法是生成在每個實例對象上面,所以兩個實例就生成了兩次。也就是說,每新建一個實例,就會新建一個meow方法。這既沒有必要,又浪費系統資源,因為所有meow方法都是同樣的行為,完全應該共享。這個問題的解決方法,就是 JavaScript 的原型對象(prototype)
prototype 屬性的作用
JavaScript 繼承機制的設計思想就是,原型對象的所有屬性和方法,都能被實例對象共享。也就是說,如果屬性和方法定義在原型上,那么所有實例對象就能共享,不僅節省了內存,還體現了實例對象之間的聯系。下面,先看怎么為對象指定原型。JavaScript 規定,每個函數都有一個prototype屬性,指向一個對象
function f() {} typeof f.prototype // "object"上面代碼中,函數f默認具有prototype屬性,指向一個對象。對于普通函數來說,該屬性基本無用。但是,對于構造函數來說,生成實例的時候,該屬性會自動成為實例對象的原型
function f() {} typeof f.prototype // "object"上面代碼中,函數f默認具有prototype屬性,指向一個對象。對于普通函數來說,該屬性基本無用。但是,對于構造函數來說,生成實例的時候,該屬性會自動成為實例對象的原型
function Animal(name) { this.name = name;} Animal.prototype.color = 'white'; var cat1 = new Animal('大毛'); var cat2 = new Animal('二毛'); cat1.color // 'white' cat2.color // 'white'上面代碼中,構造函數Animal的prototype屬性,就是實例對象cat1和cat2的原型對象。原型對象上添加一個color屬性,結果,實例對象都共享了該屬性。原型對象的屬性不是實例對象自身的屬性。只要修改原型對象,變動就立刻會體現在所有實例對象上。也就是說,當實例對象本身沒有某個屬性或方法的時候,它會到原型對象去尋找該屬性或方法。這就是原型對象的特殊之處。如果實例對象自身就有某個屬性或方法,它就不會再去原型對象尋找這個屬性或方法
Animal.prototype.color = 'yellow'; cat1.color = 'black'; cat1.color // 'black' cat2.color // 'yellow' Animal.prototype.color // 'yellow';總結一下,原型對象的作用,就是定義所有實例對象共享的屬性和方法。這也是它被稱為原型對象的原因,而實例對象可以視作從原型對象衍生出來的子對象
Animal.prototype.walk = function () {console.log(this.name + ' is walking'); };上面代碼中,Animal.prototype對象上面定義了一個walk方法,這個方法將可以在所有Animal實例對象上面調用
原型鏈
JavaScript 規定,所有對象都有自己的原型對象(prototype)。一方面,任何一個對象,都可以充當其他對象的原型;另一方面,由于原型對象也是對象,所以它也有自己的原型。因此,就會形成一個“原型鏈”(prototype chain):對象到原型,再到原型的原型……。如果一層層地上溯,所有對象的原型最終都可以上溯到Object.prototype,即Object構造函數的prototype屬性。也就是說,所有對象都繼承了Object.prototype的屬性。這就是所有對象都有valueOf和toString方法的原因,因為這是從Object.prototype繼承的。那么,Object.prototype對象有沒有它的原型呢?回答是Object.prototype的原型是null。null沒有任何屬性和方法,也沒有自己的原型。因此,原型鏈的盡頭就是null
Object.getPrototypeOf(Object.prototype) // null上面代碼表示,Object.prototype對象的原型是null,由于null沒有任何屬性,所以原型鏈到此為止。Object.getPrototypeOf方法返回參數對象的原型。讀取對象的某個屬性時,JavaScript 引擎先尋找對象本身的屬性,如果找不到,就到它的原型去找,如果還是找不到,就到原型的原型去找。如果直到最頂層的Object.prototype還是找不到,則返回undefined。如果對象自身和它的原型,都定義了一個同名屬性,那么優先讀取對象自身的屬性,這叫做“覆蓋”(overriding)
注意,一級級向上,在整個原型鏈上尋找某個屬性,對性能是有影響的。所尋找的屬性在越上層的原型對象,對性能的影響越大。如果尋找某個不存在的屬性,將會遍歷整個原型鏈。舉例來說,如果讓構造函數的prototype屬性指向一個數組,就意味著實例對象可以調用數組方法
var MyArray = function () {}; MyArray.prototype = new Array(); MyArray.prototype.constructor = MyArray; var mine = new MyArray(); mine.push(1, 2, 3); mine.length // 3 mine instanceof Array // true上面代碼中,mine是構造函數MyArray的實例對象,由于MyArray.prototype指向一個數組實例,使得mine可以調用數組方法(這些方法定義在數組實例的prototype對象上面)。最后那行instanceof表達式,用來比較一個對象是否為某個構造函數的實例,結果就是證明mine為Array的實例。上面代碼還出現了原型對象的constructor屬性
constructor 屬性
prototype對象有一個constructor屬性,默認指向prototype對象所在的構造函數
function P() {} P.prototype.constructor === P // true由于constructor屬性定義在prototype對象上面,意味著可以被所有實例對象繼承
function P() {} var p = new P(); p.constructor === P // true p.constructor === P.prototype.constructor // true p.hasOwnProperty('constructor') // false P.prototype.hasOwnProperty('constructor') // true上面代碼中,p是構造函數P的實例對象,但是p自身沒有constructor屬性,該屬性其實是讀取原型鏈上面的P.prototype.constructor屬性。constructor屬性的作用是,可以得知某個實例對象,到底是哪一個構造函數產生的
function F() {}; var f = new F(); f.constructor === F // true f.constructor === RegExp // false上面代碼中,constructor屬性確定了實例對象f的構造函數是F,而不是RegExp。另一方面,有了constructor屬性,就可以從一個實例對象新建另一個實例
function Constr() {} var x = new Constr(); var y = new x.constructor(); y instanceof Constr // true上面代碼中,x是構造函數Constr的實例,可以從x.constructor間接調用構造函數。這使得在實例方法中,調用自身的構造函數成為可能
Constr.prototype.createCopy = function () {return new this.constructor(); };上面代碼中,createCopy方法調用構造函數,新建另一個實例。constructor屬性表示原型對象與構造函數之間的關聯關系,如果修改了原型對象,一般會同時修改constructor屬性,防止引用的時候出錯
function Person(name) { this.name = name;} Person.prototype.constructor === Person // true Person.prototype = { method: function () {} }; Person.prototype.constructor === Person // false Person.prototype.constructor === Object // true上面代碼中,構造函數Person的原型對象改掉了,但是沒有修改constructor屬性,導致這個屬性不再指向Person。由于Person的新原型是一個普通對象,而普通對象的constructor屬性指向Object構造函數,導致Person.prototype.constructor變成了Object。所以,修改原型對象時,一般要同時修改constructor屬性的指向
// 壞的寫法 C.prototype = {method1: function (...) { ... }, }; // 好的寫法 C.prototype = {constructor: C,method1: function (...) { ... }, }; // 更好的寫法 C.prototype.method1 = function (...) { ... };上面代碼中,要么將constructor屬性重新指向原來的構造函數,要么只在原型對象上添加方法,這樣可以保證instanceof運算符不會失真。如果不能確定constructor屬性是什么函數,還有一個辦法:通過name屬性,從實例得到構造函數的名稱
function Foo() {} var f = new Foo(); f.constructor.name // "Foo"instanceof 運算符
instanceof運算符返回一個布爾值,表示對象是否為某個構造函數的實例
var v = new Vehicle(); v instanceof Vehicle // true上面代碼中,對象v是構造函數Vehicle的實例,所以返回true。instanceof運算符的左邊是實例對象,右邊是構造函數。它會檢查右邊構建函數的原型對象(prototype),是否在左邊對象的原型鏈上。因此,下面兩種寫法是等價的
v instanceof Vehicle // 等同于 Vehicle.prototype.isPrototypeOf(v)由于instanceof檢查整個原型鏈,因此同一個實例對象,可能會對多個構造函數都返回true
var d = new Date(); d instanceof Date // true d instanceof Object // true上面代碼中,d同時是Date和Object的實例,因此對這兩個構造函數都返回true。instanceof的原理是檢查右邊構造函數的prototype屬性,是否在左邊對象的原型鏈上。有一種特殊情況,就是左邊對象的原型鏈上,只有null對象。這時,instanceof判斷會失真
var obj = Object.create(null); typeof obj // "object" Object.create(null) instanceof Object // false上面代碼中,Object.create(null)返回一個新對象obj,它的原型是null。右邊的構造函數Object的prototype屬性,不在左邊的原型鏈上,因此instanceof就認為obj不是Object的實例。但是,只要一個對象的原型不是null,instanceof運算符的判斷就不會失真。instanceof運算符的一個用處,是判斷值的類型
var x = [1, 2, 3]; var y = {}; x instanceof Array // true y instanceof Object // true注意,instanceof運算符只能用于對象,不適用原始類型的值。此外,對于undefined和null,instanceOf運算符總是返回false
var s = 'hello'; s instanceof String // false undefined instanceof Object // false null instanceof Object // false利用instanceof運算符,還可以巧妙地解決,調用構造函數時,忘了加new命令的問題
function Fubar (foo, bar) {if (this instanceof Fubar) {this._foo = foo;this._bar = bar;} else {return new Fubar(foo, bar);} }上面代碼使用instanceof運算符,在函數體內部判斷this關鍵字是否為構造函數Fubar的實例。如果不是,就表明忘了加new命令
構造函數的繼承
讓一個構造函數繼承另一個構造函數,是非常常見的需求。這可以分成兩步實現
第一步:在子類的構造函數中,調用父類的構造函數
function Sub(value) {Super.call(this);this.prop = value; }上面代碼中,Sub是子類的構造函數,this是子類的實例。在實例上調用父類的構造函數Super,就會讓子類實例具有父類實例的屬性。
第二步,是讓子類的原型指向父類的原型,這樣子類就可以繼承父類原型
Sub.prototype = Object.create(Super.prototype); Sub.prototype.constructor = Sub; Sub.prototype.method = '...';上面代碼中,Sub.prototype是子類的原型,要將它賦值為Object.create(Super.prototype),而不是直接等于Super.prototype。否則后面兩行對Sub.prototype的操作,會連父類的原型Super.prototype一起修改掉。
另外一種寫法是Sub.prototype等于一個父類實例
Sub.prototype = new Super();上面這種寫法也有繼承的效果,但是子類會具有父類實例的方法。有時,這可能不是我們需要的,所以不推薦使用這種寫法。舉例來說,下面是一個Shape構造函數
function Shape() { this.x = 0; this.y = 0;} Shape.prototype.move = function (x, y) {this.x += x;this.y += y;console.info('Shape moved.'); };我們需要讓Rectangle構造函數繼承Shape
// 第一步,子類繼承父類的實例 function Rectangle() {Shape.call(this); // 調用父類構造函數 } // 另一種寫法 function Rectangle() {this.base = Shape;this.base(); } // 第二步,子類繼承父類的原型 Rectangle.prototype = Object.create(Shape.prototype); Rectangle.prototype.constructor = Rectangle;采用這樣的寫法以后,instanceof運算符會對子類和父類的構造函數,都返回true
var rect = new Rectangle(); rect instanceof Rectangle // true rect instanceof Shape // true上面代碼中,子類是整體繼承父類。有時只需要單個方法的繼承,這時可以采用下面的寫法
ClassB.prototype.print = function() {ClassA.prototype.print.call(this);// some code }上面代碼中,子類B的print方法先調用父類A的print方法,再部署自己的代碼。這就等于繼承了父類A的print方法
多重繼承
JavaScript 不提供多重繼承功能,即不允許一個對象同時繼承多個對象。但是,可以通過變通方法,實現這個功能
function M1() { this.hello = 'hello';} function M2() { this.world = 'world';} function S() { M1.call(this); M2.call(this);} S.prototype = Object.create(M1.prototype); // 繼承 M1 Object.assign(S.prototype, M2.prototype); // 繼承鏈上加入 M2 S.prototype.constructor = S; // 指定構造函數 var s = new S(); s.hello // 'hello' s.world // 'world'上面代碼中,子類S同時繼承了父類M1和M2。這種模式又稱為 Mixin(混入)
模塊
隨著網站逐漸變成“互聯網應用程序”,嵌入網頁的 JavaScript 代碼越來越龐大,越來越復雜。網頁越來越像桌面程序,需要一個團隊分工協作、進度管理、單元測試等等……開發者必須使用軟件工程的方法,管理網頁的業務邏輯。JavaScript 模塊化編程,已經成為一個迫切的需求。理想情況下,開發者只需要實現核心的業務邏輯,其他都可以加載別人已經寫好的模塊。但是,JavaScript 不是一種模塊化編程語言,ES6 才開始支持“類”和“模塊”。下面介紹傳統的做法,如何利用對象實現模塊的效果
基本的實現方法
模塊是實現特定功能的一組屬性和方法的封裝。簡單的做法是把模塊寫成一個對象,所有的模塊成員都放到這個對象里面
var module1 = new Object({_count : 0,m1 : function (){//...},m2 : function (){//...} });上面的函數m1和m2,都封裝在module1對象里。使用的時候,就是調用這個對象的屬性
module1.m1();但是,這樣的寫法會暴露所有模塊成員,內部狀態可以被外部改寫。比如,外部代碼可以直接改變內部計數器的值
module1._count = 5;封裝私有變量:構造函數的寫法
我們可以利用構造函數,封裝私有變量
function StringBuilder() {var buffer = [];this.add = function (str) {buffer.push(str);};this.toString = function () {return buffer.join('');}; }上面代碼中,buffer是模塊的私有變量。一旦生成實例對象,外部是無法直接訪問buffer的。但是,這種方法將私有變量封裝在構造函數中,導致構造函數與實例對象是一體的,總是存在于內存之中,無法在使用完成后清除。這意味著,構造函數有雙重作用,既用來塑造實例對象,又用來保存實例對象的數據,違背了構造函數與實例對象在數據上相分離的原則(即實例對象的數據,不應該保存在實例對象以外)。同時,非常耗費內存
function StringBuilder() {this._buffer = []; } StringBuilder.prototype = {constructor: StringBuilder,add: function (str) {this._buffer.push(str);},toString: function () {return this._buffer.join('');} };這種方法將私有變量放入實例對象中,好處是看上去更自然,但是它的私有變量可以從外部讀寫,不是很安全
封裝私有變量:立即執行函數的寫法
另一種做法是使用“立即執行函數”(Immediately-Invoked Function Expression,IIFE),將相關的屬性和方法封裝在一個函數作用域里面,可以達到不暴露私有成員的目的
var module1 = (function () {var _count = 0;var m1 = function () {//...};var m2 = function () {//...};return {m1 : m1, m2 : m2}; })();使用上面的寫法,外部代碼無法讀取內部的_count變量
console.info(module1._count); //undefined上面的module1就是 JavaScript 模塊的基本寫法。下面,再對這種寫法進行加工
模塊的放大模式
如果一個模塊很大,必須分成幾個部分,或者一個模塊需要繼承另一個模塊,這時就有必要采用“放大模式”(augmentation)
var module1 = (function (mod){mod.m3 = function () {//...};return mod; })(module1);上面的代碼為module1模塊添加了一個新方法m3(),然后返回新的module1模塊。在瀏覽器環境中,模塊的各個部分通常都是從網上獲取的,有時無法知道哪個部分會先加載。如果采用上面的寫法,第一個執行的部分有可能加載一個不存在空對象,這時就要采用"寬放大模式"(Loose augmentation)
var module1 = (function (mod) {//...return mod; })(window.module1 || {});與"放大模式"相比,“寬放大模式”就是“立即執行函數”的參數可以是空對象
輸入全局變量
獨立性是模塊的重要特點,模塊內部最好不與程序的其他部分直接交互。為了在模塊內部調用全局變量,必須顯式地將其他變量輸入模塊
var module1 = (function ($, YAHOO) {//... })(jQuery, YAHOO);上面的module1模塊需要使用 jQuery 庫和 YUI 庫,就把這兩個庫(其實是兩個模塊)當作參數輸入module1。這樣做除了保證模塊的獨立性,還使得模塊之間的依賴關系變得明顯。立即執行函數還可以起到命名空間的作用
(function($, window, document) {function go(num) {}function handleEvents() {}function initialize() {}function dieCarouselDie() {}//attach to the global scopewindow.finalCarousel = {init : initialize,destroy : dieCarouselDie} })( jQuery, window, document );上面代碼中,finalCarousel對象輸出到全局,對外暴露init和destroy接口,內部方法go、handleEvents、initialize、dieCarouselDie都是外部無法調用的
Object 對象的相關方法
JavaScript 在Object對象上面,提供了很多相關方法,處理面向對象編程的相關操作
Object.getPrototypeOf()
Object.getPrototypeOf方法返回參數對象的原型。這是獲取原型對象的標準方法
var F = function () {}; var f = new F(); Object.getPrototypeOf(f) === F.prototype // true上面代碼中,實例對象f的原型是F.prototype。下面是幾種特殊對象的原型
// 空對象的原型是 Object.prototype Object.getPrototypeOf({}) === Object.prototype // true // Object.prototype 的原型是 null Object.getPrototypeOf(Object.prototype) === null // true // 函數的原型是 Function.prototype function f() {} Object.getPrototypeOf(f) === Function.prototype // trueObject.setPrototypeOf()
Object.setPrototypeOf方法為參數對象設置原型,返回該參數對象。它接受兩個參數,第一個是現有對象,第二個是原型對象
var a = {}; var b = {x: 1}; Object.setPrototypeOf(a, b); Object.getPrototypeOf(a) === b // true a.x // 1上面代碼中,Object.setPrototypeOf方法將對象a的原型,設置為對象b,因此a可以共享b的屬性。new命令可以使用Object.setPrototypeOf方法模擬
var F = function () {this.foo = 'bar'; }; var f = new F(); // 等同于 var f = Object.setPrototypeOf({}, F.prototype); F.call(f);上面代碼中,new命令新建實例對象,其實可以分成兩步。第一步,將一個空對象的原型設為構造函數的prototype屬性(上例是F.prototype);第二步,將構造函數內部的this綁定這個空對象,然后執行構造函數,使得定義在this上面的方法和屬性(上例是this.foo),都轉移到這個空對象上
Object.create()
生成實例對象的常用方法是使用new命令讓構造函數返回一個實例。但是很多時候只能拿到一個實例對象,它可能根本不是由構建函數生成的,那么能不能從一個實例對象生成另一個實例對象呢?JavaScript 提供了Object.create方法,用來滿足這種需求。該方法接受一個對象作為參數,然后以它為原型,返回一個實例對象。該實例完全繼承原型對象的屬性
// 原型對象 var A = {print: function () { console.log('hello'); } }; // 實例對象 var B = Object.create(A); Object.getPrototypeOf(B) === A // true B.print() // hello B.print === A.print // true上面代碼中,Object.create方法以A對象為原型,生成了B對象。B繼承了A的所有屬性和方法。實際上,Object.create方法可以用下面的代碼代替
if (typeof Object.create !== 'function') {Object.create = function (obj) {function F() {}F.prototype = obj;return new F();}; }上面代碼表明,Object.create方法的實質是新建一個空的構造函數F,然后讓F.prototype屬性指向參數對象obj,最后返回一個F的實例,從而實現讓該實例繼承obj的屬性。下面三種方式生成的新對象是等價的
var obj1 = Object.create({}); var obj2 = Object.create(Object.prototype); var obj3 = new Object();如果想要生成一個不繼承任何屬性(比如沒有toString和valueOf方法)的對象,可以將Object.create的參數設為null
var obj = Object.create(null); obj.valueOf() // TypeError: Object [object Object] has no method 'valueOf'上面代碼中,對象obj的原型是null,它就不具備一些定義在Object.prototype對象上面的屬性,比如valueOf方法。使用Object.create方法的時候,必須提供對象原型,即參數不能為空,或者不是對象,否則會報錯
Object.create() // TypeError: Object prototype may only be an Object or null Object.create(123) // TypeError: Object prototype may only be an Object or nullObject.create方法生成的新對象,動態繼承了原型。在原型上添加或修改任何方法,會立刻反映在新對象之上
var obj1 = { p: 1 }; var obj2 = Object.create(obj1); obj1.p = 2; obj2.p // 2上面代碼中,修改對象原型obj1會影響到實例對象obj2。除了對象的原型,Object.create方法還可以接受第二個參數。該參數是一個屬性描述對象,它所描述的對象屬性,會添加到實例對象,作為該對象自身的屬性
var obj = Object.create({}, {p1: { value: 123, enumerable: true, configurable: true, writable: true,},p2: { value: 'abc', enumerable: true, configurable: true, writable: true,} }); // 等同于 var obj = Object.create({}); obj.p1 = 123; obj.p2 = 'abc';Object.create方法生成的對象,繼承了它的原型對象的構造函數
function A() {} var a = new A(); var b = Object.create(a); b.constructor === A // true b instanceof A // true上面代碼中,b對象的原型是a對象,因此繼承了a對象的構造函數A
Object.prototype.isPrototypeOf()
實例對象的isPrototypeOf方法,用來判斷該對象是否為參數對象的原型
var o1 = {}; var o2 = Object.create(o1); var o3 = Object.create(o2); o2.isPrototypeOf(o3) // true o1.isPrototypeOf(o3) // true上面代碼中,o1和o2都是o3的原型。這表明只要實例對象處在參數對象的原型鏈上,isPrototypeOf方法都返回true
Object.prototype.isPrototypeOf({}) // true Object.prototype.isPrototypeOf([]) // true Object.prototype.isPrototypeOf(/xyz/) // true Object.prototype.isPrototypeOf(Object.create(null)) // false上面代碼中,由于Object.prototype處于原型鏈的最頂端,所以對各種實例都返回true,只有直接繼承自null的對象除外
Object.prototype.__proto__
實例對象的__proto__屬性(前后各兩個下劃線),返回該對象的原型。該屬性可讀寫
var obj = {}; var p = {}; obj.__proto__ = p; Object.getPrototypeOf(obj) === p // true上面代碼通過__proto__屬性,將p對象設為obj對象的原型。根據語言標準,__proto__屬性只有瀏覽器才需要部署,其他環境可以沒有這個屬性。它前后的兩根下劃線,表明它本質是一個內部屬性,不應該對使用者暴露;因此,應該盡量少用這個屬性,而是用Object.getPrototypeOf()和Object.setPrototypeOf(),進行原型對象的讀寫操作。原型鏈可以用__proto__很直觀地表示
var A = { name: '張三'}; var B = { name: '李四'}; var proto = {print: function () { console.log(this.name); } }; A.__proto__ = proto; B.__proto__ = proto; A.print() // 張三 B.print() // 李四 A.print === B.print // true A.print === proto.print // true B.print === proto.print // true上面代碼中,A對象和B對象的原型都是proto對象,它們都共享proto對象的print方法。也就是說,A和B的print方法,都是在調用proto對象的print方法
獲取原型對象方法的比較
如前所述,__proto__屬性指向當前對象的原型對象,即構造函數的prototype屬性
var obj = new Object(); obj.__proto__ === Object.prototype // true obj.__proto__ === obj.constructor.prototype // true上面代碼首先新建了一個對象obj,它的__proto__屬性,指向構造函數(Object或obj.constructor)的prototype屬性;因此,獲取實例對象obj的原型對象,有三種方法
obj.__proto__ obj.constructor.prototype Object.getPrototypeOf(obj)上面三種方法之中,前兩種都不是很可靠。__proto__屬性只有瀏覽器才需要部署,其他環境可以不部署。而obj.constructor.prototype在手動改變原型對象時,可能會失效
var P = function () {}; var p = new P(); var C = function () {}; C.prototype = p; var c = new C(); c.constructor.prototype === p // false上面代碼中,構造函數C的原型對象被改成了p,但是實例對象的c.constructor.prototype卻沒有指向p。所以,在改變原型對象時,一般要同時設置constructor屬性
C.prototype = p; C.prototype.constructor = C; var c = new C(); c.constructor.prototype === p // true因此,推薦使用第三種Object.getPrototypeOf方法,獲取原型對象
Object.getOwnPropertyNames()
Object.getOwnPropertyNames方法返回一個數組,成員是參數對象本身的所有屬性的鍵名,不包含繼承的屬性鍵名
Object.getOwnPropertyNames(Date) // ["length", "name", "prototype", "now", "parse", "UTC"]上面代碼中,Object.getOwnPropertyNames方法返回Date所有自身的屬性名。對象本身的屬性之中,有的是可以遍歷的(enumerable)有的是不可以遍歷的。Object.getOwnPropertyNames方法返回所有鍵名,不管是否可以遍歷。只獲取那些可以遍歷的屬性,使用Object.keys方法
Object.keys(Date) // []上面代碼表明,Date對象所有自身的屬性,都是不可以遍歷的
Object.prototype.hasOwnProperty()
對象實例的hasOwnProperty方法返回一個布爾值,用于判斷某個屬性定義在對象自身,還是定義在原型鏈上
Date.hasOwnProperty('length') // true Date.hasOwnProperty('toString') // false上面代碼表明,Date.length(構造函數Date可以接受多少個參數)是Date自身的屬性,Date.toString是繼承的屬性。另外,hasOwnProperty方法是 JavaScript 之中唯一一個處理對象屬性時,不會遍歷原型鏈的方法
in 運算符和 for...in 循環
in運算符返回一個布爾值,表示一個對象是否具有某個屬性。它不區分該屬性是對象自身的屬性,還是繼承的屬性
'length' in Date // true 'toString' in Date // truein運算符常用于檢查一個屬性是否存在。獲得對象的所有可遍歷屬性(不管是自身的還是繼承的),可以使用for...in循環
var o1 = { p1: 123 }; var o2 = Object.create(o1, {p2: { value: "abc", enumerable: true } }); for (p in o2) { console.info(p);} // p2 // p1上面代碼中,對象o2的p2屬性是自身的,p1屬性是繼承的。這兩個屬性都會被for...in循環遍歷。為了在for...in循環中獲得對象自身的屬性,可以采用hasOwnProperty方法判斷一下
for ( var name in object ) {if ( object.hasOwnProperty(name) ) {/* loop code */} }獲得對象的所有屬性(不管是自身的還是繼承的,也不管是否可枚舉),可以使用下面的函數
function inheritedPropertyNames(obj) {var props = {};while(obj) {Object.getOwnPropertyNames(obj).forEach(function(p) { props[p] = true;});obj = Object.getPrototypeOf(obj);}return Object.getOwnPropertyNames(props); }上面代碼依次獲取obj對象的每一級原型對象“自身”的屬性,從而獲取obj對象的“所有”屬性,不管是否可遍歷。下面是一個例子,列出Date對象的所有屬性
inheritedPropertyNames(Date) // ["caller","constructor","toString","UTC",...]對象的拷貝
如果要拷貝一個對象,需要做到下面兩件事情。
1.確保拷貝后的對象,與原對象具有同樣的原型。
2.確保拷貝后的對象,與原對象具有同樣的實例屬性。
下面就是根據上面兩點,實現的對象拷貝函數
function copyObject(orig) {var copy = Object.create(Object.getPrototypeOf(orig));copyOwnPropertiesFrom(copy, orig);return copy; } function copyOwnPropertiesFrom(target, source) {Object.getOwnPropertyNames(source).forEach(function (propKey) {var desc = Object.getOwnPropertyDescriptor(source, propKey);Object.defineProperty(target, propKey, desc);});return target; }另一種更簡單的寫法,是利用 ES2017 才引入標準的Object.getOwnPropertyDescriptors方法
function copyObject(orig) {return Object.create(Object.getPrototypeOf(orig),Object.getOwnPropertyDescriptors(orig)); }嚴格模式
除了正常的運行模式,JavaScript 還有第二種運行模式:嚴格模式(strict mode)。顧名思義,這種模式采用更加嚴格的 JavaScript 語法。同樣的代碼,在正常模式和嚴格模式中,可能會有不一樣的運行結果。一些在正常模式下可以運行的語句,在嚴格模式下將不能運行
設計目的
早期的 JavaScript 語言有很多設計不合理的地方,但是為了兼容以前的代碼,又不能改變老的語法,只能不斷添加新的語法,引導程序員使用新語法。
嚴格模式是從 ES5 進入標準的,主要目的有以下幾個:
1.明確禁止一些不合理、不嚴謹的語法,減少 JavaScript 語言的一些怪異行為
2.增加更多報錯的場合,消除代碼運行的一些不安全之處,保證代碼運行的安全
3.提高編譯器效率,增加運行速度
4.為未來新版本的 JavaScript 語法做好鋪墊
總之,嚴格模式體現了 JavaScript 更合理、更安全、更嚴謹的發展方向
啟用方法
進入嚴格模式的標志,是一行字符串'use strict'
老版本的引擎會把它當作一行普通字符串,加以忽略;新版本的引擎就會進入嚴格模式。嚴格模式可以用于整個腳本,也可以只用于單個函數
整個腳本文件
use strict放在腳本文件的第一行,整個腳本都將以嚴格模式運行;如果這行語句不在第一行就無效,整個腳本會以正常模式運行。(嚴格地說,只要前面不是產生實際運行結果的語句,use strict可以不在第一行,比如直接跟在一個空的分號后面,或者跟在注釋后面)
<script>'use strict';console.log('這是嚴格模式'); </script><script>console.log('這是正常模式'); </script>上面代碼中,一個網頁文件依次有兩段 JavaScript 代碼。前一個
<script>console.log('這是正常模式');'use strict'; </script>單個函數
use strict放在函數體的第一行,則整個函數以嚴格模式運行
function strict() {'use strict';return '這是嚴格模式'; }function strict2() {'use strict';function f() {return '這也是嚴格模式';}return f(); }function notStrict() {return '這是正常模式'; }有時需要把不同腳本合并在一個文件里面。如果一個腳本是嚴格模式,另一個腳本不是,它們的合并就可能出錯。嚴格模式的腳本在前,則合并后的腳本都是嚴格模式;如果正常模式的腳本在前,則合并后的腳本都是正常模式。這兩種情況下,合并后的結果都是不正確的。這時可以考慮把整個腳本文件放在一個立即執行的匿名函數之中
(function () {'use strict';// some code here })();顯式報錯
嚴格模式使得 JavaScript 的語法變得更嚴格,更多的操作會顯式報錯。其中有些操作,在正常模式下只會默默地失敗,不會報錯
只讀屬性不可寫
嚴格模式下,設置字符串的length屬性,會報錯
'use strict'; 'abc'.length = 5; // TypeError: Cannot assign to read only property 'length' of string 'abc'上面代碼報錯,因為length是只讀屬性,嚴格模式下不可寫。正常模式下,改變length屬性是無效的,但不會報錯。嚴格模式下,對只讀屬性賦值,或者刪除不可配置(non-configurable)屬性都會報錯
// 對只讀屬性賦值會報錯 'use strict'; Object.defineProperty({}, 'a', {value: 37,writable: false }); obj.a = 123; // TypeError: Cannot assign to read only property 'a' of object #<Object>// 刪除不可配置的屬性會報錯 'use strict'; var obj = Object.defineProperty({}, 'p', {value: 1,configurable: false }); delete obj.p // TypeError: Cannot delete property 'p' of #<Object>只設置了取值器的屬性不可寫
嚴格模式下,對一個只有取值器(getter)、沒有存值器(setter)的屬性賦值,會報錯
'use strict'; var obj = {get v() { return 1; } }; obj.v = 2; // Uncaught TypeError: Cannot set property v of #<Object> which has only a getter禁止擴展的對象不可擴展
嚴格模式下,對禁止擴展的對象添加新屬性,會報錯
'use strict'; var obj = {}; Object.preventExtensions(obj); obj.v = 1; // Uncaught TypeError: Cannot add property v, object is not extensible上面代碼中,obj對象禁止擴展,添加屬性就會報錯
eval、arguments 不可用作標識名
嚴格模式下,使用eval或者arguments作為標識名,將會報錯。下面的語句都會報錯
'use strict'; var eval = 17; var arguments = 17; var obj = { set p(arguments) { } }; try { } catch (arguments) { } function x(eval) { } function arguments() { } var y = function eval() { }; var f = new Function('arguments', "'use strict'; return 17;"); // SyntaxError: Unexpected eval or arguments in strict mode函數不能有重名的參數
正常模式下,如果函數有多個重名的參數,可以用arguments[i]讀取。嚴格模式下,這屬于語法錯誤
function f(a, a, b) {'use strict';return a + b; } // Uncaught SyntaxError: Duplicate parameter name not allowed in this context禁止八進制的前綴0表示法
正常模式下,整數的第一位如果是0,表示這是八進制數,比如0100等于十進制的64。嚴格模式禁止這種表示法,整數第一位為0,將報錯
增強的安全措施
嚴格模式增強了安全保護,從語法上防止了一些不小心會出現的錯誤
全局變量顯式聲明
正常模式中,如果一個變量沒有聲明就賦值,默認是全局變量。嚴格模式禁止這種用法,全局變量必須顯式聲明
'use strict'; v = 1; // 報錯,v未聲明 for (i = 0; i < 2; i++) { // 報錯,i 未聲明// ... } function f() {x = 123; } f() // 報錯,未聲明就創建一個全局變量因此,嚴格模式下,變量都必須先聲明,然后再使用
禁止 this 關鍵字指向全局對象
正常模式下,函數內部的this可能會指向全局對象,嚴格模式禁止這種用法,避免無意間創造全局變量
// 正常模式 function f() {console.log(this === window); } f() // true// 嚴格模式 function f() {'use strict';console.log(this === undefined); } f() // true上面代碼中,嚴格模式的函數體內部this是undefined,這種限制對于構造函數尤其有用。使用構造函數時,有時忘了加new,這時this不再指向全局對象,而是報錯
function f() {'use strict';this.a = 1; }; f();// 報錯,this 未定義嚴格模式下,函數直接調用時(不使用new調用),函數內部的this表示undefined(未定義),因此可以用call、apply和bind方法,將任意值綁定在this上面。正常模式下,this指向全局對象,如果綁定的值是非對象,將被自動轉為對象再綁定上去,而null和undefined這兩個無法轉成對象的值,將被忽略
// 正常模式 function fun() { return this; } fun() // window fun.call(2) // Number {2} fun.call(true) // Boolean {true} fun.call(null) // window fun.call(undefined) // window// 嚴格模式 'use strict'; function fun() { return this; } fun() //undefined fun.call(2) // 2 fun.call(true) // true fun.call(null) // null fun.call(undefined) // undefined上面代碼中,可以把任意類型的值,綁定在this上面
禁止使用 fn.callee、fn.caller
函數內部不得使用fn.caller、fn.arguments,否則會報錯。這意味著不能在函數內部得到調用棧了
function f1() {'use strict';f1.caller; // 報錯f1.arguments; // 報錯 } f1();禁止使用 arguments.callee、arguments.caller
arguments.callee和arguments.caller是兩個歷史遺留的變量,從來沒有標準化過,現在已經取消了。正常模式下調用它們沒有什么作用,但是不會報錯。嚴格模式明確規定,函數內部使用arguments.callee、arguments.caller將會報錯
'use strict'; var f = function () { return arguments.callee; }; f(); // 報錯禁止刪除變量
嚴格模式下無法刪除變量,如果使用delete命令刪除一個變量,會報錯。只有對象的屬性,且屬性的描述對象的configurable屬性設置為true,才能被delete命令刪除
'use strict'; var x; delete x; // 語法錯誤var obj = Object.create(null, {x: { value: 1, configurable: true } }); delete obj.x; // 刪除成功靜態綁定
JavaScript 語言的一個特點,就是允許“動態綁定”,即某些屬性和方法到底屬于哪一個對象,不是在編譯時確定的,而是在運行時(runtime)確定的。嚴格模式對動態綁定做了一些限制;某些情況下,只允許靜態綁定;也就是說,屬性和方法到底歸屬哪個對象,必須在編譯階段就確定。這樣做有利于編譯效率的提高,也使得代碼更容易閱讀,更少出現意外。具體來說,涉及以下幾個方面:
禁止使用 with 語句
嚴格模式下,使用with語句將報錯;因為with語句無法在編譯時就確定,某個屬性到底歸屬哪個對象,從而影響了編譯效果
'use strict'; var v = 1; var obj = {}; with (obj) { v = 2; } // Uncaught SyntaxError: Strict mode code may not include a with statement創設 eval 作用域
正常模式下,JavaScript 語言有兩種變量作用域(scope):全局作用域和函數作用域。嚴格模式創設了第三種作用域:eval作用域。正常模式下,eval語句的作用域取決于它處于全局作用域還是函數作用域。嚴格模式下,eval語句本身就是一個作用域,不再能夠在其所運行的作用域創設新的變量了,也就是說,eval所生成的變量只能用于eval內部
(function () {'use strict';var x = 2;console.log(eval('var x = 5; x')) // 5console.log(x) // 2 })()上面代碼中,由于eval語句內部是一個獨立作用域,所以內部的變量x不會泄露到外部。注意,如果希望eval語句也使用嚴格模式,有兩種方式
// 方式一 function f1(str){'use strict';return eval(str); } f1('undeclared_variable = 1'); // 報錯// 方式二 function f2(str){ return eval(str); } f2('"use strict";undeclared_variable = 1') // 報錯上面兩種寫法,eval內部使用的都是嚴格模式
arguments 不再追蹤參數的變化
變量arguments代表函數的參數。嚴格模式下,函數內部改變參數與arguments的聯系被切斷了,兩者不再存在聯動關系
function f(a) {a = 2;return [a, arguments[0]]; } f(1); // 正常模式為[2, 2]function f(a) {'use strict';a = 2;return [a, arguments[0]]; } f(1); // 嚴格模式為[2, 1]上面代碼中,改變函數的參數,不會反應到arguments對象上來
向下一個版本的 JavaScript 過渡
JavaScript 語言的下一個版本是 ECMAScript 6,為了平穩過渡,嚴格模式引入了一些 ES6 語法
非函數代碼塊不得聲明函數
ES6 會引入塊級作用域。為了與新版本接軌,ES5 的嚴格模式只允許在全局作用域或函數作用域聲明函數。也就是說,不允許在非函數的代碼塊內聲明函數
'use strict'; if (true) {function f1() { } // 語法錯誤 } for (var i = 0; i < 5; i++) {function f2() { } // 語法錯誤 }上面代碼在if代碼塊和for代碼塊中聲明了函數,ES5 環境會報錯
注意,如果是 ES6 環境,上面的代碼不會報錯,因為 ES6 允許在代碼塊之中聲明函數
保留字
為了向將來 JavaScript 的新版本過渡,嚴格模式新增了一些保留字(implements、interface、let、package、private、protected、public、static、yield等)。使用這些詞作為變量名將會報錯
function package(protected) { // 語法錯誤'use strict';var implements; // 語法錯誤 }異步操作
異步操作概述
單線程模型
單線程模型指的是,JavaScript 只在一個線程上運行;也就是說,JavaScript 同時只能執行一個任務,其他任務都必須在后面排隊等待;注意,JavaScript 只在一個線程上運行,不代表 JavaScript 引擎只有一個線程。事實上,JavaScript 引擎有多個線程,單個腳本只能在一個線程上運行(稱為主線程),其他線程都是在后臺配合
JavaScript 之所以采用單線程,而不是多線程,跟歷史有關系;JavaScript 從誕生起就是單線程,原因是不想讓瀏覽器變得太復雜,因為多線程需要共享資源、且有可能修改彼此的運行結果,對于一種網頁腳本語言來說就太復雜了。如果 JavaScript 同時有兩個線程,一個線程在網頁 DOM 節點上添加內容,另一個線程刪除了這個節點,這時瀏覽器應該以哪個線程為準?是不是還要有鎖機制?所以,為了避免復雜性,JavaScript 一開始就是單線程,這已經成了這門語言的核心特征,將來也不會改變
這種模式的好處是實現起來比較簡單,執行環境相對單純;壞處是只要有一個任務耗時很長,后面的任務都必須排隊等著,會拖延整個程序的執行。常見的瀏覽器無響應(假死),往往就是因為某一段 JavaScript 代碼長時間運行(比如死循環),導致整個頁面卡在這個地方,其他任務無法執行。JavaScript 語言本身并不慢,慢的是讀寫外部數據,比如等待 Ajax 請求返回結果;這個時候,如果對方服務器遲遲沒有響應,或者網絡不通暢,就會導致腳本的長時間停滯
如果排隊是因為計算量大,CPU 忙不過來,倒也算了,但是很多時候 CPU 是閑著的,因為 IO 操作(輸入輸出)很慢(比如 Ajax 操作從網絡讀取數據),不得不等著結果出來,再往下執行。JavaScript 語言的設計者意識到,這時 CPU 完全可以不管 IO 操作,掛起處于等待中的任務,先運行排在后面的任務。等到 IO 操作返回了結果,再回過頭,把掛起的任務繼續執行下去。這種機制就是 JavaScript 內部采用的“事件循環”機制(Event Loop)
單線程模型雖然對 JavaScript 構成了很大的限制,但也因此使它具備了其他語言不具備的優勢。如果用得好,JavaScript 程序是不會出現堵塞的,這就是為什么 Node 可以用很少的資源,應付大流量訪問的原因。為了利用多核 CPU 的計算能力,HTML5 提出 Web Worker 標準,允許 JavaScript 腳本創建多個線程,但是子線程完全受主線程控制,且不得操作 DOM。所以,這個新標準并沒有改變 JavaScript 單線程的本質
同步任務和異步任務
程序里面所有的任務,可以分成兩類:同步任務(synchronous)和異步任務(asynchronous)。
同步任務是那些沒有被引擎掛起、在主線程上排隊執行的任務;只有前一個任務執行完畢,才能執行后一個任務。異步任務是那些被引擎放在一邊,不進入主線程、而進入任務隊列的任務。只有引擎認為某個異步任務可以執行了(比如 Ajax 操作從服務器得到了結果),該任務(采用回調函數的形式)才會進入主線程執行。排在異步任務后面的代碼,不用等待異步任務結束會馬上運行,也就是說,異步任務不具有“堵塞”效應。
舉例來說,Ajax 操作可以當作同步任務處理,也可以當作異步任務處理,由開發者決定。如果是同步任務,主線程就等著 Ajax 操作返回結果,再往下執行;如果是異步任務,主線程在發出 Ajax 請求以后,就直接往下執行,等到 Ajax 操作有了結果,主線程再執行對應的回調函數
任務隊列和事件循環
JavaScript 運行時,除了一個正在運行的主線程,引擎還提供一個任務隊列(task queue),里面是各種需要當前程序處理的異步任務。(實際上,根據異步任務的類型,存在多個任務隊列。為了方便理解,這里假設只存在一個隊列)
首先,主線程會去執行所有的同步任務;等到同步任務全部執行完,就會去看任務隊列里面的異步任務;如果滿足條件,那么異步任務就重新進入主線程開始執行,這時它就變成同步任務了。等到執行完,下一個異步任務再進入主線程開始執行。一旦任務隊列清空,程序就結束執行。
異步任務的寫法通常是回調函數。一旦異步任務重新進入主線程,就會執行對應的回調函數。如果一個異步任務沒有回調函數,就不會進入任務隊列,也就是說,不會重新進入主線程,因為沒有用回調函數指定下一步的操作。
JavaScript 引擎怎么知道異步任務有沒有結果,能不能進入主線程呢?答案就是引擎在不停地檢查,一遍又一遍,只要同步任務執行完了,引擎就會去檢查那些掛起來的異步任務,是不是可以進入主線程了。這種循環檢查的機制,就叫做事件循環(Event Loop)。維基百科的定義是:“事件循環是一個程序結構,用于等待和發送消息和事件(a programming construct that waits for and dispatches events or messages in a program)”
異步操作的模式
下面總結一下異步操作的幾種模式
回調函數
回調函數是異步操作最基本的方法。下面是兩個函數f1和f2,編程的意圖是f2必須等到f1執行完成,才能執行
function f1() {// ... } function f2() {// ... } f1(); f2();上面代碼的問題在于,如果f1是異步操作,f2會立即執行,不會等到f1結束再執行。這時,可以考慮改寫f1,把f2寫成f1的回調函數
function f1(callback) {// ...callback(); } function f2() {// ... } f1(f2);回調函數的優點是簡單、容易理解和實現,缺點是不利于代碼的閱讀和維護,各個部分之間高度耦合(coupling),使得程序結構混亂、流程難以追蹤(尤其是多個回調函數嵌套的情況),而且每個任務只能指定一個回調函數
事件監聽
另一種思路是采用事件驅動模式。異步任務的執行不取決于代碼的順序,而取決于某個事件是否發生。還是以f1和f2為例;首先,為f1綁定一個事件
f1.on('done', f2);上面這行代碼的意思是,當f1發生done事件,就執行f2。然后,對f1進行改寫:
function f1() {setTimeout(function () {// ...f1.trigger('done');}, 1000); }上面代碼中,f1.trigger('done')表示,執行完成后,立即觸發done事件,從而開始執行f2。這種方法的優點是比較容易理解,可以綁定多個事件,每個事件可以指定多個回調函數,而且可以“去耦合”(decoupling),有利于實現模塊化。缺點是整個程序都要變成事件驅動型,運行流程會變得很不清晰。閱讀代碼的時候,很難看出主流程
發布/訂閱
事件完全可以理解成“信號”,如果存在一個“信號中心”,某個任務執行完成,就向信號中心“發布”(publish)一個信號,其他任務可以向信號中心“訂閱”(subscribe)這個信號,從而知道什么時候自己可以開始執行;這就叫做”發布/訂閱模式”(publish-subscribe pattern),又稱“觀察者模式”(observer pattern)。
這個模式有多種實現,下面采用的是 Ben Alman 的 Tiny Pub/Sub,這是 jQuery 的一個插件。
首先,f2向信號中心jQuery訂閱done信號
jQuery.subscribe('done', f2);然后,f1進行如下改寫
function f1() {setTimeout(function () {// ...jQuery.publish('done');}, 1000); }上面代碼中,jQuery.publish('done')的意思是,f1執行完成后,向信號中心jQuery發布done信號,從而引發f2的執行;f2完成執行后,可以取消訂閱(unsubscribe)
jQuery.unsubscribe('done', f2);這種方法的性質與“事件監聽”類似,但是明顯優于后者。因為可以通過查看“消息中心”,了解存在多少信號、每個信號有多少訂閱者,從而監控程序的運行
異步操作的流程控制
如果有多個異步操作,就存在一個流程控制的問題:如何確定異步操作執行的順序,以及如何保證遵守這種順序
function async(arg, callback) {console.log('參數為 ' + arg +' , 1秒后返回結果');setTimeout(function () { callback(arg * 2); }, 1000); }上面代碼的async函數是一個異步任務,非常耗時,每次執行需要1秒才能完成,然后再調用回調函數;如果有六個這樣的異步任務,需要全部完成后,才能執行最后的final函數。請問應該如何安排操作流程?
function final(value) {console.log('完成: ', value); } async(1, function (value) {async(2, function (value) {async(3, function (value) {async(4, function (value) {async(5, function (value) {async(6, final);});});});}); }); // 參數為 1 , 1秒后返回結果 // 參數為 2 , 1秒后返回結果 // 參數為 3 , 1秒后返回結果 // 參數為 4 , 1秒后返回結果 // 參數為 5 , 1秒后返回結果 // 參數為 6 , 1秒后返回結果 // 完成: 12上面代碼中,六個回調函數的嵌套,不僅寫起來麻煩,容易出錯,而且難以維護
串行執行
我們可以編寫一個流程控制函數,讓它來控制異步任務,一個任務完成以后,再執行另一個。這就叫串行執行
var items = [ 1, 2, 3, 4, 5, 6 ]; var results = []; function async(arg, callback) {console.log('參數為 ' + arg +' , 1秒后返回結果');setTimeout(function () { callback(arg * 2); }, 1000); } function final(value) {console.log('完成: ', value); } function series(item) {if(item) {async( item, function(result) {results.push(result);return series(items.shift());});} else {return final(results[results.length - 1]);} } series(items.shift());上面代碼中,函數series就是串行函數,它會依次執行異步任務,所有任務都完成后,才會執行final函數。items數組保存每一個異步任務的參數,results數組保存每一個異步任務的運行結果。注意,上面的寫法需要六秒,才能完成整個腳本
并行執行
流程控制函數也可以是并行執行,即所有異步任務同時執行,等到全部完成以后,才執行final函數
var items = [ 1, 2, 3, 4, 5, 6 ]; var results = []; function async(arg, callback) {console.log('參數為 ' + arg +' , 1秒后返回結果');setTimeout(function () { callback(arg * 2); }, 1000); } function final(value) {console.log('完成: ', value); } items.forEach(function(item) {async(item, function(result){results.push(result);if(results.length === items.length) {final(results[results.length - 1]);}}) });上面代碼中,forEach方法會同時發起六個異步任務,等到它們全部完成以后,才會執行final函數。相比而言,上面的寫法只要一秒就能完成整個腳本;這就是說,并行執行的效率較高,比起串行執行一次只能執行一個任務,較為節約時間;但是問題在于如果并行的任務較多,很容易耗盡系統資源,拖慢運行速度。因此有了第三種流程控制方式
并行與串行的結合
所謂并行與串行的結合,就是設置一個門檻,每次最多只能并行執行n個異步任務,這樣就避免了過分占用系統資源
var items = [ 1, 2, 3, 4, 5, 6 ]; var results = []; var running = 0; var limit = 2; function async(arg, callback) {console.log('參數為 ' + arg +' , 1秒后返回結果');setTimeout(function () { callback(arg * 2); }, 1000); } function final(value) {console.log('完成: ', value); } function launcher() {while(running < limit && items.length > 0) {var item = items.shift();async(item, function(result) {results.push(result);running--;if(items.length > 0) {launcher();} else if(running == 0) {final(results);}});running++;} } launcher();上面代碼中,最多只能同時運行兩個異步任務。變量running記錄當前正在運行的任務數,只要低于門檻值,就再啟動一個新的任務,如果等于0,就表示所有任務都執行完了,這時就執行final函數。
這段代碼需要三秒完成整個腳本,處在串行執行和并行執行之間。通過調節limit變量,達到效率和資源的最佳平衡
定時器
JavaScript 提供定時執行代碼的功能,叫做定時器(timer),主要由setTimeout()和setInterval()這兩個函數來完成。它們向任務隊列添加定時任務
setTimeout()
setTimeout函數用來指定某個函數或某段代碼,在多少毫秒之后執行。它返回一個整數,表示定時器的編號,以后可以用來取消這個定時器
var timerId = setTimeout(func|code, delay);上面代碼中,setTimeout函數接受兩個參數,第一個參數func|code是將要推遲執行的函數名或者一段代碼,第二個參數delay是推遲執行的毫秒數
console.log(1); setTimeout('console.log(2)',1000); console.log(3); // 1 // 3 // 2上面代碼會先輸出1和3,然后等待1000毫秒再輸出2。注意,console.log(2)必須以字符串的形式,作為setTimeout的參數。如果推遲執行的是函數,就直接將函數名,作為setTimeout的參數
function f() { console.log(2); } setTimeout(f, 1000);setTimeout的第二個參數如果省略,則默認為0
setTimeout(f) // 等同于 setTimeout(f, 0)除了前兩個參數,setTimeout還允許更多的參數。它們將依次傳入推遲執行的函數(回調函數)
setTimeout(function (a,b) {console.log(a + b); }, 1000, 1, 1);上面代碼中,setTimeout共有4個參數。最后兩個參數,將在1000毫秒之后回調函數執行時作為回調函數的參數。還有一個需要注意的地方,如果回調函數是對象的方法,那么setTimeout使得方法內部的this關鍵字指向全局環境,而不是定義時所在的那個對象
var x = 1; var obj = {x: 2,y: function () { console.log(this.x); } }; setTimeout(obj.y, 1000) // 1上面代碼輸出的是1,而不是2。因為當obj.y在1000毫秒后運行時,this所指向的已經不是obj了,而是全局環境。為了防止出現這個問題,一種解決方法是將obj.y放入一個函數
var x = 1; var obj = {x: 2,y: function () { console.log(this.x); } }; setTimeout(function () { obj.y(); }, 1000); // 2上面代碼中,obj.y放在一個匿名函數之中,這使得obj.y在obj的作用域執行,而不是在全局作用域內執行,所以能夠顯示正確的值;另一種解決方法是,使用bind方法,將obj.y這個方法綁定在obj上面
var x = 1; var obj = {x: 2,y: function () { console.log(this.x); } }; setTimeout(obj.y.bind(obj), 1000) // 2setInterval()
setInterval函數的用法與setTimeout完全一致,區別僅僅在于setInterval指定某個任務每隔一段時間就執行一次,也就是無限次的定時執行
var i = 1 var timer = setInterval(function() { console.log(2); }, 1000)上面代碼中,每隔1000毫秒就輸出一個2,會無限運行下去,直到關閉當前窗口;與setTimeout一樣,除了前兩個參數,setInterval方法還可以接受更多的參數,它們會傳入回調函數。下面是一個通過setInterval方法實現網頁動畫的例子
var div = document.getElementById('someDiv'); var opacity = 1; var fader = setInterval(function() {opacity -= 0.1;if (opacity >= 0) {div.style.opacity = opacity;} else {clearInterval(fader);} }, 100);上面代碼每隔100毫秒,設置一次div元素的透明度,直至其完全透明為止。setInterval的一個常見用途是實現輪詢。下面是一個輪詢 URL 的 Hash 值是否發生變化的例子
var hash = window.location.hash; var hashWatcher = setInterval(function() {if (window.location.hash != hash) {updatePage();} }, 1000);setInterval指定的是“開始執行”之間的間隔,并不考慮每次任務執行本身所消耗的時間。因此實際上,兩次執行之間的間隔會小于指定的時間。比如,setInterval指定每 100ms 執行一次,每次執行需要 5ms,那么第一次執行結束后95毫秒,第二次執行就會開始。如果某次執行耗時特別長,比如需要105毫秒,那么它結束后,下一次執行就會立即開始。為了確保兩次執行之間有固定的間隔,可以不用setInterval,而是每次執行結束后,使用setTimeout指定下一次執行的具體時間
var i = 1; var timer = setTimeout(function f() {// ...timer = setTimeout(f, 2000); }, 2000);上面代碼可以確保,下一次執行總是在本次執行結束之后的2000毫秒開始
clearTimeout(),clearInterval()
setTimeout和setInterval函數,都返回一個整數值,表示計數器編號。將該整數傳入clearTimeout和clearInterval函數,就可以取消對應的定時器
var id1 = setTimeout(f, 1000); var id2 = setInterval(f, 1000); clearTimeout(id1); clearInterval(id2);上面代碼中,回調函數f不會再執行了,因為兩個定時器都被取消了。setTimeout和setInterval返回的整數值是連續的,也就是說,第二個setTimeout方法返回的整數值,將比第一個的整數值大1
function f() {} setTimeout(f, 1000) // 10 setTimeout(f, 1000) // 11 setTimeout(f, 1000) // 12上面代碼中,連續調用三次setTimeout,返回值都比上一次大了1。利用這一點,可以寫一個函數,取消當前所有的setTimeout定時器
(function() { // 每輪事件循環檢查一次 var gid = setInterval(clearAllTimeouts, 0);function clearAllTimeouts() {var id = setTimeout(function() {}, 0);while (id > 0) {if (id !== gid) { clearTimeout(id); }id--;}} })();上面代碼中,先調用setTimeout,得到一個計算器編號,然后把編號比它小的計數器全部取消
實例:debounce 函數
有時,我們不希望回調函數被頻繁調用。比如,用戶填入網頁輸入框的內容,希望通過 Ajax 方法傳回服務器,jQuery 的寫法如下:
$('textarea').on('keydown', ajaxAction);這樣寫有一個很大的缺點,就是如果用戶連續擊鍵,就會連續觸發keydown事件,造成大量的 Ajax 通信。這是不必要的,而且很可能產生性能問題。正確的做法應該是,設置一個門檻值,表示兩次 Ajax 通信的最小間隔時間。如果在間隔時間內,發生新的keydown事件,則不觸發 Ajax 通信,并且重新開始計時。如果過了指定時間,沒有發生新的keydown事件,再將數據發送出去。
這種做法叫做 debounce(防抖動)。假定兩次 Ajax 通信的間隔不得小于2500毫秒,上面的代碼可以改寫成下面這樣
$('textarea').on('keydown', debounce(ajaxAction, 2500)); function debounce(fn, delay){var timer = null; // 聲明計時器return function() {var context = this;var args = arguments;clearTimeout(timer);timer = setTimeout(function () { fn.apply(context, args); }, delay);}; }上面代碼中,只要在2500毫秒之內,用戶再次擊鍵,就會取消上一次的定時器,然后再新建一個定時器。這樣就保證了回調函數之間的調用間隔,至少是2500毫秒
運行機制
setTimeout和setInterval的運行機制,是將指定的代碼移出本輪事件循環,等到下一輪事件循環,再檢查是否到了指定時間。如果到了,就執行對應的代碼;如果不到,就繼續等待。這意味著,setTimeout和setInterval指定的回調函數,必須等到本輪事件循環的所有同步任務都執行完,才會開始執行。由于前面的任務到底需要多少時間執行完,是不確定的,所以沒有辦法保證,setTimeout和setInterval指定的任務,一定會按照預定時間執行
setTimeout(someTask, 100); veryLongTask();上面代碼的setTimeout,指定100毫秒以后運行一個任務。但是,如果后面的veryLongTask函數(同步任務)運行時間非常長,過了100毫秒還無法結束,那么被推遲運行的someTask就只有等著,等到veryLongTask運行結束,才輪到它執行。再看一個setInterval的例子
setInterval(function () {console.log(2); }, 1000); sleep(3000); function sleep(ms) {var start = Date.now();while ((Date.now() - start) < ms) {} }setTimeout(f, 0)
含義
setTimeout的作用是將代碼推遲到指定時間執行,如果指定時間為0,即setTimeout(f, 0),那么會立刻執行嗎?
答案是不會。因為上一節說過,必須要等到當前腳本的同步任務,全部處理完以后,才會執行setTimeout指定的回調函數f。也就是說,setTimeout(f, 0)會在下一輪事件循環一開始就執行
setTimeout(function () { console.log(1); }, 0); console.log(2); // 2 // 1上面代碼先輸出2,再輸出1。因為2是同步任務,在本輪事件循環執行,而1是下一輪事件循環執行。總之,setTimeout(f, 0)這種寫法的目的是,盡可能早地執行f,但是并不能保證立刻就執行f。實際上,setTimeout(f, 0)不會真的在0毫秒之后運行,不同的瀏覽器有不同的實現。以 Edge 瀏覽器為例,會等到4毫秒之后運行。如果電腦正在使用電池供電,會等到16毫秒之后運行;如果網頁不在當前 Tab 頁,會推遲到1000毫秒(1秒)之后運行。這樣是為了節省系統資源
應用
setTimeout(f, 0)有幾個非常重要的用途。它的一大應用是,可以調整事件的發生順序。比如,網頁開發中,某個事件先發生在子元素,然后冒泡到父元素,即子元素的事件回調函數,會早于父元素的事件回調函數觸發。如果,想讓父元素的事件回調函數先發生,就要用到setTimeout(f, 0)
<input type="button" id="myButton" value="click"> <script>var input = document.getElementById('myButton');input.onclick = function A() {setTimeout(function B() { input.value +=' input'; }, 0)};document.body.onclick = function C() { input.value += ' body' }; </script>上面代碼在點擊按鈕后,先觸發回調函數A,然后觸發函數C。函數A中,setTimeout將函數B推遲到下一輪事件循環執行,這樣就起到了,先觸發父元素的回調函數C的目的了。
另一個應用是,用戶自定義的回調函數,通常在瀏覽器的默認動作之前觸發。比如,用戶在輸入框輸入文本,keypress事件會在瀏覽器接收文本之前觸發。因此,下面的回調函數是達不到目的的
<input type="text" id="input-box"> document.getElementById('input-box').onkeypress = function (event) { this.value = this.value.toUpperCase(); }上面代碼想在用戶每次輸入文本后,立即將字符轉為大寫。但是實際上,它只能將本次輸入前的字符轉為大寫,因為瀏覽器此時還沒接收到新的文本,所以this.value取不到最新輸入的那個字符。只有用setTimeout改寫,上面的代碼才能發揮作用
document.getElementById('input-box').onkeypress = function() {var self = this;setTimeout(function() { self.value = self.value.toUpperCase(); }, 0); }由于setTimeout(f, 0)實際上意味著,將任務放到瀏覽器最早可得的空閑時段執行,所以那些計算量大、耗時長的任務,常常會被放到幾個小部分,分別放到setTimeout(f, 0)里面執行
var div = document.getElementsByTagName('div')[0]; // 寫法一 for (var i = 0xA00000; i < 0xFFFFFF; i++) {div.style.backgroundColor = '#' + i.toString(16); } // 寫法二 var timer; var i=0x100000; function func() {timer = setTimeout(func, 0);div.style.backgroundColor = '#' + i.toString(16);if (i++ == 0xFFFFFF) clearTimeout(timer); } timer = setTimeout(func, 0);上面代碼有兩種寫法,都是改變一個網頁元素的背景色。寫法一會造成瀏覽器“堵塞”,因為 JavaScript 執行速度遠高于 DOM,會造成大量 DOM 操作“堆積”,而寫法二就不會,這就是setTimeout(f, 0)的好處。
另一個使用這種技巧的例子是代碼高亮的處理。如果代碼塊很大,一次性處理,可能會對性能造成很大的壓力,那么將其分成一個個小塊,一次處理一塊,比如寫成setTimeout(highlightNext, 50)的樣子,性能壓力就會減輕。
Promise 對象
概述
Promise 對象是 JavaScript 的異步操作解決方案,為異步操作提供統一接口。它起到代理作用(proxy),充當異步操作與回調函數之間的中介,使得異步操作具備同步操作的接口。Promise 可以讓異步操作寫起來,就像在寫同步操作的流程,而不必一層層地嵌套回調函數。
首先,Promise 是一個對象,也是一個構造函數
function f1(resolve, reject) {// 異步代碼... } var p1 = new Promise(f1);上面代碼中,Promise構造函數接受一個回調函數f1作為參數,f1里面是異步操作的代碼。然后,返回的p1就是一個 Promise 實例。Promise 的設計思想是,所有異步任務都返回一個 Promise 實例。Promise 實例有一個then方法,用來指定下一步的回調函數
var p1 = new Promise(f1); p1.then(f2);上面代碼中,f1的異步操作執行完成,就會執行f2。傳統的寫法可能需要把f2作為回調函數傳入f1,比如寫成f1(f2),異步操作完成后,在f1內部調用f2。Promise 使得f1和f2變成了鏈式寫法。不僅改善了可讀性,而且對于多層嵌套的回調函數尤其方便
// 傳統寫法 step1(function (value1) {step2(value1, function(value2) {step3(value2, function(value3) {step4(value3, function(value4) {// ...});});}); }); // Promise 的寫法 (new Promise(step1)).then(step2).then(step3).then(step4);從上面代碼可以看到,采用 Promise 以后,程序流程變得非常清楚,十分易讀。總的來說,傳統的回調函數寫法使得代碼混成一團,變得橫向發展而不是向下發展。Promise 就是解決這個問題,使得異步流程可以寫成同步流程。Promise 原本只是社區提出的一個構想,一些函數庫率先實現了這個功能。ECMAScript 6 將其寫入語言標準,目前 JavaScript 原生支持 Promise 對象
Promise 對象的狀態
Promise 對象通過自身的狀態,來控制異步操作。Promise 實例具有三種狀態:異步操作未完成(pending)/異步操作成功(fulfilled)/異步操作失敗(rejected);上面三種狀態里面,fulfilled和rejected合在一起稱為resolved(已定型)。
這三種的狀態的變化途徑只有兩種:從“未完成”到“成功” / 從“未完成”到“失敗”。一旦狀態發生變化就凝固了,不會再有新的狀態變化。這也是 Promise 這個名字的由來,它的英語意思是“承諾”,一旦承諾成效,就不能再改變了。這也意味著,Promise 實例的狀態變化只可能發生一次。因此,Promise 的最終結果只有兩種:
異步操作成功,Promise 實例傳回一個值(value),狀態變為fulfilled
異步操作失敗,Promise 實例拋出一個錯誤(error),狀態變為rejected
Promise 構造函數
JavaScript 提供原生的Promise構造函數,用來生成 Promise 實例
var promise = new Promise(function (resolve, reject) {// ...if (/* 異步操作成功 */){resolve(value);} else { /* 異步操作失敗 */reject(new Error());} });上面代碼中,Promise構造函數接受一個函數作為參數,該函數的兩個參數分別是resolve和reject。它們是兩個函數,由 JavaScript 引擎提供,不用自己實現。resolve函數的作用是,將Promise實例的狀態從“未完成”變為“成功”(即從pending變為fulfilled),在異步操作成功時調用,并將異步操作的結果作為參數傳遞出去。reject函數的作用是,將Promise實例的狀態從“未完成”變為“失敗”(即從pending變為rejected),在異步操作失敗時調用,并將異步操作報出的錯誤,作為參數傳遞出去
function timeout(ms) {return new Promise((resolve, reject) => { setTimeout(resolve, ms, 'done'); }); } timeout(100)上面代碼中,timeout(100)返回一個 Promise 實例。100毫秒以后,該實例的狀態會變為fulfilled
Promise.prototype.then()
Promise 實例的then方法,用來添加回調函數。then方法可以接受兩個回調函數,第一個是異步操作成功時(變為fulfilled狀態)的回調函數,第二個是異步操作失敗(變為rejected)時的回調函數(該參數可以省略)。一旦狀態改變,就調用相應的回調函數
var p1 = new Promise(function (resolve, reject) { resolve('成功'); }); p1.then(console.log, console.error); // "成功" var p2 = new Promise(function (resolve, reject) { reject(new Error('失敗')); }); p2.then(console.log, console.error); // Error: 失敗上面代碼中,p1和p2都是Promise 實例,它們的then方法綁定兩個回調函數:成功時的回調函數console.log,失敗時的回調函數console.error(可以省略)。p1的狀態變為成功,p2的狀態變為失敗,對應的回調函數會收到異步操作傳回的值,然后在控制臺輸出。then方法可以鏈式使用
p1.then(step1).then(step2).then(step3).then( console.log, console.error);上面代碼中,p1后面有四個then,意味著依次有四個回調函數。只要前一步的狀態變為fulfilled,就會依次執行緊跟在后面的回調函數。最后一個then方法,回調函數是console.log和console.error,用法上有一點重要的區別;console.log只顯示step3的返回值,而console.error可以顯示p1、step1、step2、step3之中任意一個發生的錯誤。舉例來說,如果step1的狀態變為rejected,那么step2和step3都不會執行了(因為它們是resolved的回調函數)。Promise 開始尋找,接下來第一個為rejected的回調函數,在上面代碼中是console.error。這就是說,Promise 對象的報錯具有傳遞性
then() 用法辨析
Promise 的用法,簡單說就是一句話:使用then方法添加回調函數。但是,不同的寫法有一些細微的差別,請看下面四種寫法:
// 寫法一 f1().then(function () { return f2(); }); // 寫法二 f1().then(function () { f2(); }); // 寫法三 f1().then(f2()); // 寫法四 f1().then(f2);為了便于講解,下面這四種寫法都再用then方法接一個回調函數f3。寫法一的f3回調函數的參數,是f2函數的運行結果
f1().then(function () { return f2(); }).then(f3);寫法二的f3回調函數的參數是undefined
f1().then(function () { f2(); return; }).then(f3);寫法三的f3回調函數的參數,是f2函數返回的函數的運行結果
f1().then(f2()).then(f3);寫法四與寫法一只有一個差別,那就是f2會接收到f1()返回的結果
f1().then(f2).then(f3);實例:圖片加載
下面是使用 Promise 完成圖片的加載
var preloadImage = function (path) {return new Promise(function (resolve, reject) {var image = new Image();image.onload = resolve;image.onerror = reject;image.src = path;}); };上面的preloadImage函數用法如下
preloadImage('https://example.com/my.jpg').then(function (e) { document.body.append(e.target) }).then(function () { console.log('加載成功') })小結
Promise 的優點在于,讓回調函數變成了規范的鏈式寫法,程序流程可以看得很清楚。它有一整套接口,可以實現許多強大的功能,比如同時執行多個異步操作,等到它們的狀態都改變以后,再執行一個回調函數;再比如,為多個回調函數中拋出的錯誤,統一指定處理方法等等。而且,Promise 還有一個傳統寫法沒有的好處:它的狀態一旦改變,無論何時查詢,都能得到這個狀態。這意味著,無論何時為 Promise 實例添加回調函數,該函數都能正確執行。所以,你不用擔心是否錯過了某個事件或信號。如果是傳統寫法,通過監聽事件來執行回調函數,一旦錯過了事件,再添加回調函數是不會執行的。
Promise 的缺點是,編寫的難度比傳統寫法高,而且閱讀代碼也不是一眼可以看懂。你只會看到一堆then,必須自己在then的回調函數里面理清邏輯
微任務
Promise 的回調函數屬于異步任務,會在同步任務之后執行
new Promise(function (resolve, reject) { resolve(1); }).then(console.log); console.log(2); // 2 // 1上面代碼會先輸出2,再輸出1。因為console.log(2)是同步任務,而then的回調函數屬于異步任務,一定晚于同步任務執行;但是,Promise 的回調函數不是正常的異步任務,而是微任務(microtask)。它們的區別在于,正常任務追加到下一輪事件循環,微任務追加到本輪事件循環。這意味著,微任務的執行時間一定早于正常任務
setTimeout(function() { console.log(1); }, 0); new Promise(function (resolve, reject) { resolve(2); }).then(console.log); console.log(3); // 3 // 2 // 1上面代碼的輸出結果是321。這說明then的回調函數的執行時間,早于setTimeout(fn, 0)。因為then是本輪事件循環執行,setTimeout(fn, 0)在下一輪事件循環開始時執行
DOM
DOM 概述
DOM
DOM 是 JavaScript 操作網頁的接口,全稱為“文檔對象模型”(Document Object Model)。它的作用是將網頁轉為一個 JavaScript 對象,從而可以用腳本進行各種操作(比如增刪內容)。瀏覽器會根據 DOM 模型,將結構化文檔(比如 HTML 和 XML)解析成一系列的節點,再由這些節點組成一個樹狀結構(DOM Tree)。所有的節點和最終的樹狀結構,都有規范的對外接口
DOM 只是一個接口規范,可以用各種語言實現。所以嚴格地說,DOM 不是 JavaScript 語法的一部分,但是 DOM 操作是 JavaScript 最常見的任務,離開了 DOM,JavaScript 就無法控制網頁。另一方面,JavaScript 也是最常用于 DOM 操作的語言
節點
DOM 的最小組成單位叫做節點(node)。文檔的樹形結構(DOM 樹),就是由各種不同類型的節點組成。每個節點可以看作是文檔樹的一片葉子
節點的類型有七種。
1.Document:整個文檔樹的頂層節點
2.DocumentType:doctype標簽(比如)
3.Element:網頁的各種HTML標簽(比如
、等)4.Attribute:網頁元素的屬性(比如class="right")
5.Text:標簽之間或標簽包含的文本
6.Comment:注釋
7.DocumentFragment:文檔的片段
瀏覽器提供一個原生的節點對象Node,上面這七種節點都繼承了Node,因此具有一些共同的屬性和方法
節點樹
一個文檔的所有節點,按照所在的層級,可以抽象成一種樹狀結構;這種樹狀結構就是 DOM 樹。它有一個頂層節點,下一層都是頂層節點的子節點,然后子節點又有自己的子節點,就這樣層層衍生出一個金字塔結構,倒過來就像一棵樹。瀏覽器原生提供document節點,代表整個文檔
document // 整個文檔樹文檔的第一層只有一個節點,就是 HTML 網頁的第一個標簽,它構成了樹結構的根節點(root node),其他 HTML 標簽節點都是它的下級節點;除了根節點,其他節點都有三種層級關系:
1.父節點關系(parentNode):直接的那個上級節點
2.子節點關系(childNodes):直接的下級節點
3.同級節點關系(sibling):擁有同一個父節點的節點
DOM 提供操作接口,用來獲取這三種關系的節點。比如,子節點接口包括firstChild(第一個子節點)和lastChild(最后一個子節點)等屬性,同級節點接口包括nextSibling(緊鄰在后的那個同級節點)和previousSibling(緊鄰在前的那個同級節點)屬性
Node 接口
所有 DOM 節點對象都繼承了 Node 接口,擁有一些共同的屬性和方法。這是 DOM 操作的基礎
屬性
Node.prototype.nodeType
nodeType屬性返回一個整數值,表示節點的類型
document.nodeType // 9上面代碼中,文檔節點的類型值為9。Node 對象定義了幾個常量,對應這些類型值
document.nodeType === Node.DOCUMENT_NODE // true上面代碼中,文檔節點的nodeType屬性等于常量Node.DOCUMENT_NODE;不同節點的nodeType屬性值和對應的常量如下:
1.文檔節點(document):9,對應常量Node.DOCUMENT_NODE
2.元素節點(element):1,對應常量Node.ELEMENT_NODE
3.屬性節點(attr):2,對應常量Node.ATTRIBUTE_NODE
4.文本節點(text):3,對應常量Node.TEXT_NODE
5.文檔片斷節點(DocumentFragment):11,對應常量Node.DOCUMENT_FRAGMENT_NODE
6.文檔類型節點(DocumentType):10,對應常量Node.DOCUMENT_TYPE_NODE
7.注釋節點(Comment):8,對應常量Node.COMMENT_NODE
確定節點類型時,使用nodeType屬性是常用方法
var node = document.documentElement.firstChild; if (node.nodeType === Node.ELEMENT_NODE) { console.log('該節點是元素節點'); }Node.prototype.nodeName
nodeName屬性返回節點的名稱
<div id="d1">hello world</div> <script>var div = document.getElementById('d1');div.nodeName // "DIV" </script>上面代碼中,元素節點
的nodeName屬性就是大寫的標簽名DIV;不同節點的nodeName屬性值如下:1.文檔節點(document):#document
2.元素節點(element):大寫的標簽名
3.屬性節點(attr):屬性的名稱
4.文本節點(text):#text
5.文檔片斷節點(DocumentFragment):#document-fragment
6.文檔類型節點(DocumentType):文檔的類型
7.注釋節點(Comment):#comment
Node.prototype.nodeValue
nodeValue屬性返回一個字符串,表示當前節點本身的文本值,該屬性可讀寫;只有文本節點(text)、注釋節點(comment)和屬性節點(attr)有文本值,因此這三類節點的nodeValue可以返回結果,其他類型的節點一律返回null。同樣的,也只有這三類節點可以設置nodeValue屬性的值,其他類型的節點設置無效
<div id="d1">hello world</div> <script>var div = document.getElementById('d1');div.nodeValue // nulldiv.firstChild.nodeValue // "hello world" </script>上面代碼中,div是元素節點,nodeValue屬性返回null。div.firstChild是文本節點,所以可以返回文本值
Node.prototype.textContent
textContent屬性返回當前節點和它的所有后代節點的文本內容
<div id="divA">This is <span>some</span> text</div> document.getElementById('divA').textContent // This is some texttextContent屬性自動忽略當前節點內部的 HTML 標簽,返回所有文本內容;該屬性是可讀寫的,設置該屬性的值,會用一個新的文本節點,替換所有原來的子節點。它還有一個好處,就是自動對 HTML 標簽轉義。這很適合用于用戶提供的內容
document.getElementById('foo').textContent = '<p>GoodBye!</p>'上面代碼在插入文本時,會將
標簽解釋為文本,而不會當作標簽處理。
對于文本節點(text)、注釋節點(comment)和屬性節點(attr),textContent屬性的值與nodeValue屬性相同。對于其他類型的節點,該屬性會將每個子節點(不包括注釋節點)的內容連接在一起返回。如果一個節點沒有子節點,則返回空字符串。文檔節點(document)和文檔類型節點(doctype)的textContent屬性為null。如果要讀取整個文檔的內容,可以使用document.documentElement.textContent
Node.prototype.baseURI
baseURI屬性返回一個字符串,表示當前網頁的絕對路徑。瀏覽器根據這個屬性,計算網頁上的相對路徑的 URL。該屬性為只讀
document.baseURI如果無法讀到網頁的 URL,baseURI屬性返回null;該屬性的值一般由當前網址的 URL(即window.location屬性)決定,但是可以使用 HTML 的標簽,改變該屬性的值
<base href="http://www.example.com/page.html">設置了以后,baseURI屬性就返回標簽設置的值
Node.prototype.ownerDocument
Node.ownerDocument屬性返回當前節點所在的頂層文檔對象,即document對象
var d = p.ownerDocument; d === document // truedocument對象本身的ownerDocument屬性,返回null
Node.prototype.nextSibling
Node.nextSibling屬性返回緊跟在當前節點后面的第一個同級節點;如果當前節點后面沒有同級節點,則返回null
<div id="d1">hello</div><div id="d2">world</div> var d1 = document.getElementById('d1'); var d2 = document.getElementById('d2'); d1.nextSibling === d2 // true注意,該屬性還包括文本節點和注釋節點()。因此如果當前節點后面有空格,該屬性會返回一個文本節點,內容為空格;nextSibling屬性可以用來遍歷所有子節點
var el = document.getElementById('div1').firstChild; while (el !== null) {console.log(el.nodeName);el = el.nextSibling; }上面代碼遍歷div1節點的所有子節點
Node.prototype.previousSibling
previousSibling屬性返回當前節點前面的、距離最近的一個同級節點。如果當前節點前面沒有同級節點,則返回null
<div id="d1">hello</div><div id="d2">world</div> var d1 = document.getElementById('d1'); var d2 = document.getElementById('d2'); d2.previousSibling === d1 // true上面代碼中,d2.previousSibling就是d2前面的同級節點d1。注意,該屬性還包括文本節點和注釋節點。因此如果當前節點前面有空格,該屬性會返回一個文本節點,內容為空格
Node.prototype.parentNode
parentNode屬性返回當前節點的父節點。對于一個節點來說,它的父節點只可能是三種類型:元素節點(element)、文檔節點(document)和文檔片段節點(documentfragment)
if (node.parentNode) { node.parentNode.removeChild(node); }上面代碼中,通過node.parentNode屬性將node節點從文檔里面移除;文檔節點(document)和文檔片段節點(documentfragment)的父節點都是null。另外,對于那些生成后還沒插入 DOM 樹的節點,父節點也是null
Node.prototype.parentElement
parentElement屬性返回當前節點的父元素節點;如果當前節點沒有父節點,或者父節點類型不是元素節點,則返回null
if (node.parentElement) { node.parentElement.style.color = 'red'; }上面代碼中,父元素節點的樣式設定了紅色;由于父節點只可能是三種類型:元素節點、文檔節點(document)和文檔片段節點(documentfragment);parentElement屬性相當于把后兩種父節點都排除了
Node.prototype.firstChild,Node.prototype.lastChild
firstChild屬性返回當前節點的第一個子節點,如果當前節點沒有子節點,則返回null
<p id="p1"><span>First span</span></p> var p1 = document.getElementById('p1'); p1.firstChild.nodeName // "SPAN"注意,firstChild返回的除了元素節點,還可能是文本節點或注釋節點
<p id="p1"><span>First span</span></p> var p1 = document.getElementById('p1'); p1.firstChild.nodeName // "#text"lastChild屬性返回當前節點的最后一個子節點,如果當前節點沒有子節點,則返回null。用法與firstChild屬性相同
Node.prototype.childNodes
childNodes屬性返回一個類似數組的對象(NodeList集合),成員包括當前節點的所有子節點
var children = document.querySelector('ul').childNodes;上面代碼中,children就是ul元素的所有子節點;使用該屬性,可以遍歷某個節點的所有子節點
var div = document.getElementById('div1'); var children = div.childNodes; for (var i = 0; i < children.length; i++) {// ... }文檔節點(document)就有兩個子節點:文檔類型節點(docType)和 HTML 根元素節點
var children = document.childNodes; for (var i = 0; i < children.length; i++) { console.log(children[i].nodeType); } // 10 // 1上面代碼中,文檔節點的第一個子節點的類型是10(即文檔類型節點),第二個子節點的類型是1(即元素節點)
注意,除了元素節點,childNodes屬性的返回值還包括文本節點和注釋節點;如果當前節點不包括任何子節點,則返回一個空的NodeList集合。由于NodeList對象是一個動態集合,一旦子節點發生變化,立刻會反映在返回結果之中
Node.prototype.isConnected
isConnected屬性返回一個布爾值,表示當前節點是否在文檔之中
var test = document.createElement('p'); test.isConnected // false document.body.appendChild(test); test.isConnected // true上面代碼中,test節點是腳本生成的節點,沒有插入文檔之前,isConnected屬性返回false,插入之后返回true
方法
Node.prototype.appendChild()
appendChild()方法接受一個節點對象作為參數,將其作為最后一個子節點,插入當前節點。該方法的返回值就是插入文檔的子節點
var p = document.createElement('p'); document.body.appendChild(p);上面代碼新建一個
節點,將其插入document.body的尾部;如果參數節點是 DOM 已經存在的節點,appendChild()方法會將其從原來的位置,移動到新位置
var div = document.getElementById('myDiv'); document.body.appendChild(div);上面代碼中,插入的是一個已經存在的節點myDiv,結果就是該節點會從原來的位置,移動到document.body的尾部;如果appendChild()方法的參數是DocumentFragment節點,那么插入的是DocumentFragment的所有子節點,而不是DocumentFragment節點本身。返回值是一個空的DocumentFragment節點
Node.prototype.hasChildNodes()
hasChildNodes方法返回一個布爾值,表示當前節點是否有子節點
var foo = document.getElementById('foo'); if (foo.hasChildNodes()) { foo.removeChild(foo.childNodes[0]); }上面代碼表示,如果foo節點有子節點,就移除第一個子節點;注意,子節點包括所有類型的節點,并不僅僅是元素節點。哪怕節點只包含一個空格,hasChildNodes方法也會返回true。判斷一個節點有沒有子節點,有許多種方法,下面是其中的三種:
1.node.hasChildNodes()
2.node.firstChild !== null
3.node.childNodes && node.childNodes.length > 0
hasChildNodes方法結合firstChild屬性和nextSibling屬性,可以遍歷當前節點的所有后代節點
function DOMComb(parent, callback) {if (parent.hasChildNodes()) {for (var node = parent.firstChild; node; node = node.nextSibling) { DOMComb(node, callback); }}callback(parent); } DOMComb(document.body, console.log) // 用法上面代碼中,DOMComb函數的第一個參數是某個指定的節點,第二個參數是回調函數。這個回調函數會依次作用于指定節點,以及指定節點的所有后代節點
Node.prototype.cloneNode()
cloneNode方法用于克隆一個節點。它接受一個布爾值作為參數,表示是否同時克隆子節點。它的返回值是一個克隆出來的新節點
var cloneUL = document.querySelector('ul').cloneNode(true);該方法有一些使用注意點:
1.克隆一個節點,會拷貝該節點的所有屬性,但是會喪失addEventListener方法和on-屬性(即node.onclick = fn),添加在這個節點上的事件回調函數
2.該方法返回的節點不在文檔之中,即沒有任何父節點,必須使用諸如Node.appendChild這樣的方法添加到文檔之中。
3.克隆一個節點之后,DOM 有可能出現兩個有相同id屬性(即id="xxx")的網頁元素,這時應該修改其中一個元素的id屬性。如果原節點有name屬性,可能也需要修改
Node.prototype.insertBefore()
insertBefore方法用于將某個節點插入父節點內部的指定位置
var insertedNode = parentNode.insertBefore(newNode, referenceNode);insertBefore方法接受兩個參數,第一個參數是所要插入的節點newNode,第二個參數是父節點parentNode內部的一個子節點referenceNode。newNode將插在referenceNode這個子節點的前面。返回值是插入的新節點newNode
var p = document.createElement('p'); document.body.insertBefore(p, document.body.firstChild);上面代碼中,新建一個
節點,插在document.body.firstChild的前面,也就是成為document.body的第一個子節點。如果insertBefore方法的第二個參數為null,則新節點將插在當前節點內部的最后位置,即變成最后一個子節點
var p = document.createElement('p'); document.body.insertBefore(p, null);上面代碼中,p將成為document.body的最后一個子節點。這也說明insertBefore的第二個參數不能省略。
注意,如果所要插入的節點是當前 DOM 現有的節點,則該節點將從原有的位置移除,插入新的位置。
由于不存在insertAfter方法,如果新節點要插在父節點的某個子節點后面,可以用insertBefore方法結合nextSibling屬性模擬
parent.insertBefore(s1, s2.nextSibling);上面代碼中,parent是父節點,s1是一個全新的節點,s2是可以將s1節點,插在s2節點的后面。如果s2是當前節點的最后一個子節點,則s2.nextSibling返回null,這時s1節點會插在當前節點的最后,變成當前節點的最后一個子節點,等于緊跟在s2的后面。如果要插入的節點是DocumentFragment類型,那么插入的將是DocumentFragment的所有子節點,而不是DocumentFragment節點本身。返回值將是一個空的DocumentFragment節點
Node.prototype.removeChild()
removeChild方法接受一個子節點作為參數,用于從當前節點移除該子節點。返回值是移除的子節點
var divA = document.getElementById('A'); divA.parentNode.removeChild(divA);上面代碼移除了divA節點。注意,這個方法是在divA的父節點上調用的,不是在divA上調用的。下面是如何移除當前節點的所有子節點
var element = document.getElementById('top'); while (element.firstChild) { element.removeChild(element.firstChild); }被移除的節點依然存在于內存之中,但不再是 DOM 的一部分。所以,一個節點移除以后,依然可以使用它,比如插入到另一個節點下面。如果參數節點不是當前節點的子節點,removeChild方法將報錯
Node.prototype.replaceChild()
replaceChild方法用于將一個新的節點,替換當前節點的某一個子節點
var replacedNode = parentNode.replaceChild(newChild, oldChild);上面代碼中,replaceChild方法接受兩個參數,第一個參數newChild是用來替換的新節點,第二個參數oldChild是將要替換走的子節點。返回值是替換走的那個節點oldChild
var divA = document.getElementById('divA'); var newSpan = document.createElement('span'); newSpan.textContent = 'Hello World!'; divA.parentNode.replaceChild(newSpan, divA);Node.prototype.contains()
contains方法返回一個布爾值,表示參數節點是否滿足以下三個條件之一:
1.參數節點為當前節點
2.參數節點為當前節點的子節點
3.參數節點為當前節點的后代節點
document.body.contains(node)上面代碼檢查參數節點node,是否包含在當前文檔之中
注意,當前節點傳入contains方法,返回true
nodeA.contains(nodeA) // trueNode.prototype.compareDocumentPosition()
compareDocumentPosition方法的用法,與contains方法完全一致,返回一個六個比特位的二進制值,表示參數節點與當前節點的關系
二進制值十進制值含義0000000兩個節點相同0000011兩個節點不在同一個文檔(即有一個節點不在當前文檔)0000102參數節點在當前節點的前面0001004參數節點在當前節點的后面0010008參數節點包含當前節點01000016當前節點包含參數節點10000032瀏覽器內部使用<div id="mydiv"><form><input id="test" /></form> </div> <script> var div = document.getElementById('mydiv'); var input = document.getElementById('test'); div.compareDocumentPosition(input) // 20 input.compareDocumentPosition(div) // 10 </script>上面代碼中,節點div包含節點input(二進制010000),而且節點input在節點div的后面(二進制000100),所以第一個compareDocumentPosition方法返回20(二進制010100,即010000 + 000100),第二個compareDocumentPosition方法返回10(二進制001010)。由于compareDocumentPosition返回值的含義,定義在每一個比特位上,所以如果要檢查某一種特定的含義,就需要使用比特位運算符
var head = document.head; var body = document.body; if (head.compareDocumentPosition(body) & 4) {console.log('文檔結構正確'); } else {console.log('<body> 不能在 <head> 前面'); }上面代碼中,compareDocumentPosition的返回值與4(又稱掩碼)進行與運算(&),得到一個布爾值,表示
是否在前面Node.prototype.isEqualNode(),Node.prototype.isSameNode()
isEqualNode方法返回一個布爾值,用于檢查兩個節點是否相等。所謂相等的節點,指的是兩個節點的類型相同、屬性相同、子節點相同
var p1 = document.createElement('p'); var p2 = document.createElement('p'); p1.isEqualNode(p2) // trueisSameNode方法返回一個布爾值,表示兩個節點是否為同一個節點
var p1 = document.createElement('p'); var p2 = document.createElement('p'); p1.isSameNode(p2) // false p1.isSameNode(p1) // trueNode.prototype.normalize()
normalize方法用于清理當前節點內部的所有文本節點(text)。它會去除空的文本節點,并且將毗鄰的文本節點合并成一個,也就是說不存在空的文本節點,以及毗鄰的文本節點
var wrapper = document.createElement('div'); wrapper.appendChild(document.createTextNode('Part 1 ')); wrapper.appendChild(document.createTextNode('Part 2 ')); wrapper.childNodes.length // 2 wrapper.normalize(); wrapper.childNodes.length // 1上面代碼使用normalize方法之前,wrapper節點有兩個毗鄰的文本子節點。使用normalize方法之后,兩個文本子節點被合并成一個;該方法是Text.splitText的逆方法
Node.prototype.getRootNode()
getRootNode()方法返回當前節點所在文檔的根節點document,與ownerDocument屬性的作用相同
document.body.firstChild.getRootNode() === document // true document.body.firstChild.getRootNode() === document.body.firstChild.ownerDocument // true該方法可用于document節點自身,這一點與document.ownerDocument不同
document.getRootNode() // document document.ownerDocument // nullNodeList 接口,HTMLCollection 接口
節點都是單個對象,有時需要一種數據結構,能夠容納多個節點。DOM 提供兩種節點集合,用于容納多個節點:NodeList和HTMLCollection。這兩種集合都屬于接口規范;許多 DOM 屬性和方法,返回的結果是NodeList實例或HTMLCollection實例。主要區別是,NodeList可以包含各種類型的節點,HTMLCollection只能包含 HTML 元素節點
NodeList 接口
概述
NodeList實例是一個類似數組的對象,它的成員是節點對象。通過以下方法可以得到NodeList實例:
1.Node.childNodes
2.document.querySelectorAll()等節點搜索方法
document.body.childNodes instanceof NodeList // trueNodeList實例很像數組,可以使用length屬性和forEach方法。但是,它不是數組,不能使用pop或push之類數組特有的方法
var children = document.body.childNodes; Array.isArray(children) // false children.length // 34 children.forEach(console.log)上面代碼中,NodeList 實例children不是數組,但是具有length屬性和forEach方法。如果NodeList實例要使用數組方法,可以將其轉為真正的數組
var children = document.body.childNodes; var nodeArr = Array.prototype.slice.call(children);除了使用forEach方法遍歷 NodeList 實例,還可以使用for循環
var children = document.body.childNodes; for (var i = 0; i < children.length; i++) { var item = children[i]; }注意,NodeList 實例可能是動態集合,也可能是靜態集合。所謂動態集合就是一個活的集合,DOM 刪除或新增一個相關節點,都會立刻反映在 NodeList 實例。目前,只有Node.childNodes返回的是一個動態集合,其他的 NodeList 都是靜態集合
var children = document.body.childNodes; children.length // 18 document.body.appendChild(document.createElement('p')); children.length // 19上面代碼中,文檔增加一個子節點,NodeList 實例children的length屬性就增加了1
NodeList.prototype.length
length屬性返回 NodeList 實例包含的節點數量
document.querySelectorAll('xxx').length // 0上面代碼中,document.querySelectorAll返回一個 NodeList 集合。對于那些不存在的 HTML 標簽,length屬性返回0
NodeList.prototype.forEach()
forEach方法用于遍歷 NodeList 的所有成員。它接受一個回調函數作為參數,每一輪遍歷就執行一次這個回調函數,用法與數組實例的forEach方法完全一致
var children = document.body.childNodes; children.forEach(function f(item, i, list) {// ... }, this);上面代碼中,回調函數f的三個參數依次是當前成員、位置和當前 NodeList 實例。forEach方法的第二個參數,用于綁定回調函數內部的this,該參數可省略
NodeList.prototype.item()
item方法接受一個整數值作為參數,表示成員的位置,返回該位置上的成員
document.body.childNodes.item(0)上面代碼中,item(0)返回第一個成員;如果參數值大于實際長度,或者索引不合法(比如負數),item方法返回null;如果省略參數,item方法會報錯;所有類似數組的對象,都可以使用方括號運算符取出成員;一般情況下,都是使用方括號運算符,而不使用item方法
document.body.childNodes[0]NodeList.prototype.keys(),NodeList.prototype.values(),NodeList.prototype.entries()
這三個方法都返回一個 ES6 的遍歷器對象,可以通過for...of循環遍歷獲取每一個成員的信息。區別在于,keys()返回鍵名的遍歷器,values()返回鍵值的遍歷器,entries()返回的遍歷器同時包含鍵名和鍵值的信息
var children = document.body.childNodes; for (var key of children.keys()) { console.log(key); } // 0 // 1 // 2 // ... for (var value of children.values()) { console.log(value); } // #text // <script> // ... for (var entry of children.entries()) { console.log(entry); } // Array [ 0, #text ] // Array [ 1, <script> ] // ...HTMLCollection 接口
概述
HTMLCollection是一個節點對象的集合,只能包含元素節點(element),不能包含其他類型的節點。它的返回值是一個類似數組的對象,但是與NodeList接口不同,HTMLCollection沒有forEach方法,只能使用for循環遍歷。返回HTMLCollection實例的,主要是一些Document對象的集合屬性,比如document.links、document.forms、document.images等
document.links instanceof HTMLCollection // trueHTMLCollection實例都是動態集合,節點的變化會實時反映在集合中;如果元素節點有id或name屬性,那么HTMLCollection實例上面,可以使用id屬性或name屬性引用該節點元素。如果沒有對應的節點,則返回null
<img id="pic" src="http://example.com/foo.jpg"> var pic = document.getElementById('pic'); document.images.pic === pic // true上面代碼中,document.images是一個HTMLCollection實例,可以通過元素的id屬性值,從HTMLCollection實例上取到這個元素
HTMLCollection.prototype.length
length屬性返回HTMLCollection實例包含的成員數量
document.links.length // 18HTMLCollection.prototype.item()
item方法接受一個整數值作為參數,表示成員的位置,返回該位置上的成員
var c = document.images; var img0 = c.item(0);上面代碼中,item(0)表示返回0號位置的成員。由于方括號運算符也具有同樣作用,而且使用更方便,所以一般情況下,總是使用方括號運算符;如果參數值超出成員數量或者不合法(比如小于0),那么item方法返回null
HTMLCollection.prototype.namedItem()
namedItem方法的參數是一個字符串,表示id屬性或name屬性的值,返回對應的元素節點。如果沒有對應的節點,則返回null
<img id="pic" src="http://example.com/foo.jpg"> var pic = document.getElementById('pic'); document.images.namedItem('pic') === pic // trueParentNode 接口,ChildNode 接口
節點對象除了繼承 Node 接口以外,還會繼承其他接口。ParentNode接口表示當前節點是一個父節點,提供一些處理子節點的方法;ChildNode接口表示當前節點是一個子節點,提供一些相關方法
ParentNode 接口
如果當前節點是父節點,就會繼承ParentNode接口。由于只有元素節點(element)、文檔節點(document)和文檔片段節點(documentFragment)擁有子節點,因此只有這三類節點會繼承ParentNode接口
ParentNode.children
children屬性返回一個HTMLCollection實例,成員是當前節點的所有元素子節點,該屬性只讀;下面是遍歷某個節點的所有元素子節點的示例
for (var i = 0; i < el.children.length; i++) {// ... }注意,children屬性只包括元素子節點,不包括其他類型的子節點(比如文本子節點);如果沒有元素類型的子節點,返回值HTMLCollection實例的length屬性為0。另外,HTMLCollection是動態集合,會實時反映 DOM 的任何變化
ParentNode.firstElementChild
firstElementChild屬性返回當前節點的第一個元素子節點;如果沒有任何元素子節點,則返回null
document.firstElementChild.nodeName // "HTML"上面代碼中,document節點的第一個元素子節點是
ParentNode.lastElementChild
lastElementChild屬性返回當前節點的最后一個元素子節點,如果不存在任何元素子節點,則返回null
document.lastElementChild.nodeName // "HTML"上面代碼中,document節點的最后一個元素子節點是(因為document只包含這一個元素子節點)
ParentNode.childElementCount
childElementCount屬性返回一個整數,表示當前節點的所有元素子節點的數目。如果不包含任何元素子節點,則返回0
document.body.childElementCount // 13ParentNode.append(),ParentNode.prepend()
append方法為當前節點追加一個或多個子節點,位置是最后一個元素子節點的后面;該方法不僅可以添加元素子節點,還可以添加文本子節點
var parent = document.body; // 添加元素子節點 var p = document.createElement('p'); parent.append(p); // 添加文本子節點 parent.append('Hello'); // 添加多個元素子節點 var p1 = document.createElement('p'); var p2 = document.createElement('p'); parent.append(p1, p2); // 添加元素子節點和文本子節點 var p = document.createElement('p'); parent.append('Hello', p);注意,該方法沒有返回值;prepend方法為當前節點追加一個或多個子節點,位置是第一個元素子節點的前面;它的用法與append方法完全一致,也是沒有返回值
ChildNode 接口
如果一個節點有父節點,那么該節點就繼承了ChildNode接口
ChildNode.remove()
remove方法用于從父節點移除當前節點
el.remove()上面代碼在 DOM 里面移除了el節點
ChildNode.before(),ChildNode.after()
before方法用于在當前節點的前面,插入一個或多個同級節點,兩者擁有相同的父節點;注意,該方法不僅可以插入元素節點,還可以插入文本節點
var p = document.createElement('p'); var p1 = document.createElement('p'); el.before(p); // 插入元素節點 el.before('Hello'); // 插入文本節點 el.before(p, p1); // 插入多個元素節點 el.before(p, 'Hello'); // 插入元素節點和文本節點after方法用于在當前節點的后面,插入一個或多個同級節點,兩者擁有相同的父節點。用法與before方法完全相同
ChildNode.replaceWith()
replaceWith方法使用參數節點,替換當前節點。參數可以是元素節點,也可以是文本節點
var span = document.createElement('span'); el.replaceWith(span);上面代碼中,el節點將被span節點替換
Document 節點
概述
document節點對象代表整個文檔,每張網頁都有自己的document對象,window.document屬性就指向這個對象;只要瀏覽器開始載入 HTML 文檔,該對象就存在了,可以直接使用。document對象有不同的辦法可以獲取:
1.正常的網頁,直接使用document或window.document
2.iframe框架里面的網頁,使用iframe節點的contentDocument屬性
3.Ajax 操作返回的文檔,使用XMLHttpRequest對象的responseXML屬性
4.內部節點的ownerDocument屬性
document對象繼承了EventTarget接口、Node接口、ParentNode接口;這意味著這些接口的方法都可以在document對象上調用;除此之外,document對象還有很多自己的屬性和方法
屬性
快捷方式屬性
以下屬性是指向文檔內部的某個節點的快捷方式
document.defaultView
document.defaultView屬性返回document對象所屬的window對象。如果當前文檔不屬于window對象,該屬性返回null
document.defaultView === window // truedocument.doctype
對于 HTML 文檔來說,document對象一般有兩個子節點;第一個子節點是document.doctype,指向節點,即文檔類型(Document Type Declaration,簡寫DTD)節點;HTML 的文檔類型節點一般寫成,如果網頁沒有聲明 DTD,該屬性返回null
var doctype = document.doctype; doctype // "<!DOCTYPE html>" doctype.name // "html"document.firstChild通常就返回這個節點
document.documentElement
document.documentElement屬性返回當前文檔的根元素節點(root);它通常是document節點的第二個子節點,緊跟在document.doctype節點后面;HTML網頁的該屬性,一般是節點
document.body,document.head
document.body屬性指向
節點,document.head屬性指向節點;這兩個屬性總是存在的,如果網頁源碼里面省略了或,瀏覽器會自動創建;另外,這兩個屬性是可寫的,如果改寫它們的值,相當于移除所有子節點document.scrollingElement
document.scrollingElement屬性返回文檔的滾動元素。也就是說,當文檔整體滾動時,到底是哪個元素在滾動;標準模式下,這個屬性返回的文檔的根元素document.documentElement(即)。兼容(quirk)模式下,返回的是
元素,如果該元素不存在,返回nulldocument.scrollingElement.scrollTop = 0; // 頁面滾動到瀏覽器頂部document.activeElement
document.activeElement屬性返回獲得當前焦點(focus)的 DOM 元素。通常,這個屬性返回的是、、等表單元素;如果當前沒有焦點元素,返回元素或null
document.fullscreenElement
document.fullscreenElement屬性返回當前以全屏狀態展示的 DOM 元素。如果不是全屏狀態,該屬性返回null
if (document.fullscreenElement.nodeName == 'VIDEO') { console.log('全屏播放視頻'); }上面代碼中,通過document.fullscreenElement可以知道
元素有沒有處在全屏狀態,從而判斷用戶行為節點集合屬性
以下屬性返回一個HTMLCollection實例,表示文檔內部特定元素的集合;這些集合都是動態的,原節點有任何變化,立刻會反映在集合中
document.links
document.links屬性返回當前文檔所有設定了href屬性的
// 打印文檔所有的鏈接 var links = document.links; for(var i = 0; i < links.length; i++) {console.log(links[i]); }document.forms
document.forms屬性返回所有
表單節點var selectForm = document.forms[0];上面代碼獲取文檔第一個表單;除了使用位置序號,id屬性和name屬性也可以用來引用表單
<form name="foo" id="bar"></form> document.forms[0] === document.forms.foo // true document.forms.bar === document.forms.foo // truedocument.images
document.images屬性返回頁面所有圖片節點
var imglist = document.images; for(var i = 0; i < imglist.length; i++) {if (imglist[i].src === 'banner.gif') {// ...} }上面代碼在所有img標簽中,尋找某張圖片
document.embeds,document.plugins
document.embeds屬性和document.plugins屬性,都返回所有節點
document.scripts
document.scripts屬性返回所有
var scripts = document.scripts; if (scripts.length !== 0 ) { console.log('當前網頁有腳本'); }document.styleSheets
document.styleSheets屬性返回文檔內嵌或引入的樣式表集合
小結
除了document.styleSheets,以上的集合屬性返回的都是HTMLCollection實例
document.links instanceof HTMLCollection // true document.images instanceof HTMLCollection // true document.forms instanceof HTMLCollection // true document.embeds instanceof HTMLCollection // true document.scripts instanceof HTMLCollection // trueHTMLCollection實例是類似數組的對象,所以這些屬性都有length屬性,都可以使用方括號運算符引用成員。如果成員有id或name屬性,還可以用這兩個屬性的值,在HTMLCollection實例上引用到這個成員
<form name="myForm"> document.myForm === document.forms.myForm // true文檔靜態信息屬性
以下屬性返回文檔信息
document.documentURI,document.URL
document.documentURI屬性和document.URL屬性都返回一個字符串,表示當前文檔的網址;不同之處是它們繼承自不同的接口,documentURI繼承自Document接口,可用于所有文檔;URL繼承自HTMLDocument接口,只能用于 HTML 文檔
document.URL // http://www.example.com/about document.documentURI === document.URL // true如果文檔的錨點(#anchor)變化,這兩個屬性都會跟著變化
document.domain
document.domain屬性返回當前文檔的域名,不包含協議和接口;比如,網頁的網址是,那么domain屬性就等于www.example.com。如果無法獲取域名,該屬性返回null;document.domain基本上是一個只讀屬性,只有一種情況除外;次級域名的網頁,可以把document.domain設為對應的上級域名。比如,當前域名是a.sub.example.com,則document.domain屬性可以設置為sub.example.com,也可以設為example.com。修改后,document.domain相同的兩個網頁,可以讀取對方的資源,比如設置的 Cookie。
另外,設置document.domain會導致端口被改成null。因此,如果通過設置document.domain來進行通信,雙方網頁都必須設置這個值,才能保證端口相同
document.location
Location對象是瀏覽器提供的原生對象,提供 URL 相關的信息和操作方法。通過window.location和document.location屬性,可以拿到這個對象
document.lastModified
document.lastModified屬性返回一個字符串,表示當前文檔最后修改的時間。不同瀏覽器的返回值,日期格式是不一樣的
document.lastModified // "04/10/2019 12:17:01"注意:document.lastModified屬性的值是字符串,所以不能直接用來比較;Date.parse方法將其轉為Date實例,才能比較兩個網頁
var lastVisitedDate = Date.parse('01/01/2018'); if (Date.parse(document.lastModified) > lastVisitedDate) { console.log('網頁已經變更'); }document.characterSet
document.characterSet屬性返回當前文檔的編碼,比如UTF-8、ISO-8859-1等
document.referrer
document.referrer屬性返回一個字符串,表示當前文檔的訪問者來自哪里
document.referrer // "https://example.com/path"如果無法獲取來源,或者用戶直接鍵入網址而不是從其他網頁點擊進入,document.referrer返回一個空字符串;document.referrer的值,總是與 HTTP 頭信息的Referer字段保持一致。但是,document.referrer的拼寫有兩個r,而頭信息的Referer字段只有一個r
document.dir
document.dir返回一個字符串,表示文字方向。它只有兩個可能的值:rtl表示文字從右到左,阿拉伯文是這種方式;ltr表示文字從左到右,包括英語和漢語在內的大多數文字采用這種方式
document.compatMode
compatMode屬性返回瀏覽器處理文檔的模式,可能的值為BackCompat(向后兼容模式)和CSS1Compat(嚴格模式)。一般來說,如果網頁代碼的第一行設置了明確的DOCTYPE(比如),document.compatMode的值都為CSS1Compat
文檔狀態屬性
document.hidden
document.hidden屬性返回一個布爾值,表示當前頁面是否可見;如果窗口最小化、瀏覽器切換了 Tab,都會導致頁面不可見,使得document.hidden返回true。這個屬性是 Page Visibility API 引入的,一般都是配合這個 API 使用
document.visibilityState
document.visibilityState返回文檔的可見狀態;它的值有四種可能:
1.visible:頁面可見。注意,頁面可能是部分可見,即不是焦點窗口,前面被其他窗口部分擋住了
2.hidden:頁面不可見,有可能窗口最小化,或者瀏覽器切換到了另一個 Tab
3.prerender:頁面處于正在渲染狀態,對于用戶來說,該頁面不可見
4.unloaded:頁面從內存里面卸載了
這個屬性可以用在頁面加載時,防止加載某些資源;或者頁面不可見時停掉一些頁面功能
document.readyState
document.readyState屬性返回當前文檔的狀態,共有三種可能的值:
1.loading:加載 HTML 代碼階段(尚未完成解析)
2.interactive:加載外部資源階段
3.complete:加載完成
這個屬性變化的過程如下:
1.瀏覽器開始解析 HTML 文檔,document.readyState屬性等于loading
2.瀏覽器遇到 HTML 文檔中的
3.HTML 文檔解析完成,document.readyState屬性變成interactive
4.瀏覽器等待圖片、樣式表、字體文件等外部資源加載完成;一旦全部加載完成,document.readyState屬性變成complete
下面的代碼用來檢查網頁是否加載成功
if (document.readyState === 'complete') { // 基本檢查// ... } var interval = setInterval(function() { // 輪詢檢查if (document.readyState === 'complete') {clearInterval(interval);// ...} }, 100);另外,每次狀態變化都會觸發一個readystatechange事件
document.cookie
document.cookie屬性用來操作瀏覽器 Cookie
document.designMode
document.designMode屬性控制當前文檔是否可編輯。該屬性只有兩個值on和off,默認值為off。一旦設為on,用戶就可以編輯整個文檔的內容;
document.implementation
document.implementation屬性返回一個DOMImplementation對象。該對象有三個方法,主要用于創建獨立于當前文檔的新的 Document 對象;
1.DOMImplementation.createDocument():創建一個 XML 文檔
2.DOMImplementation.createHTMLDocument():創建一個 HTML 文檔
3.DOMImplementation.createDocumentType():創建一個 DocumentType 對象
下面是創建 HTML 文檔的例子
var doc = document.implementation.createHTMLDocument('Title'); var p = doc.createElement('p'); p.innerHTML = 'hello world'; doc.body.appendChild(p); document.replaceChild(doc.documentElement,document.documentElement );上面代碼中,第一步生成一個新的 HTML 文檔doc,然后用它的根元素document.documentElement替換掉document.documentElement;這會使得當前文檔的內容全部消失,變成hello world
方法
document.open(),document.close()
document.open方法清除當前文檔所有內容,使得文檔處于可寫狀態,供document.write方法寫入內容;document.close方法用來關閉document.open()打開的文檔
document.open(); document.write('hello world'); document.close();document.write(),document.writeln()
document.write方法用于向當前文檔寫入內容;在網頁的首次渲染階段,只要頁面沒有關閉寫入(即沒有執行document.close()),document.write寫入的內容就會追加在已有內容的后面
// 頁面顯示“helloworld” document.open(); document.write('hello'); document.write('world'); document.close();注意,document.write會當作 HTML 代碼解析,不會轉義
document.write('<p>hello world</p>');上面代碼中,document.write會將
當作 HTML 標簽解釋;如果頁面已經解析完成(DOMContentLoaded事件發生之后),再調用write方法,它會先調用open方法,擦除當前文檔所有內容,然后再寫入
document.addEventListener('DOMContentLoaded', function (event) { document.write('<p>Hello World!</p>'); }); // 等同于 document.addEventListener('DOMContentLoaded', function (event) {document.open();document.write('<p>Hello World!</p>');document.close(); });如果在頁面渲染過程中調用write方法,并不會自動調用open方法。(可以理解成,open方法已調用,但close方法還未調用。)
<html><body> hello <script type="text/javascript">document.write("world") </script> </body></html>在瀏覽器打開上面網頁,將會顯示hello world;document.write是 JavaScript 語言標準化之前就存在的方法,現在完全有更符合標準的方法向文檔寫入內容(比如對innerHTML屬性賦值);所以,除了某些特殊情況,應該盡量避免使用document.write這個方法;document.writeln方法與write方法完全一致,除了會在輸出內容的尾部添加換行符
document.write(1); document.write(2); // 12 document.writeln(1); document.writeln(2); // 1 // 2注意,writeln方法添加的是 ASCII 碼的換行符,渲染成 HTML 網頁時不起作用,即在網頁上顯示不出換行。網頁上的換行,必須顯式寫入
document.querySelector(),document.querySelectorAll()
document.querySelector方法接受一個 CSS 選擇器作為參數,返回匹配該選擇器的元素節點;如果有多個節點滿足匹配條件,則返回第一個匹配的節點;如果沒有發現匹配的節點,則返回null
var el1 = document.querySelector('.myclass'); var el2 = document.querySelector('#myParent > [ng-click]');document.querySelectorAll方法與querySelector用法類似,區別是返回一個NodeList對象,包含所有匹配給定選擇器的節點
elementList = document.querySelectorAll('.myclass');這兩個方法的參數,可以是逗號分隔的多個 CSS 選擇器,返回匹配其中一個選擇器的元素節點,這與 CSS 選擇器的規則是一致的
var matches = document.querySelectorAll('div.note, div.alert');上面代碼返回class屬性是note或alert的div元素;這兩個方法都支持復雜的 CSS 選擇器
document.querySelectorAll('[data-foo-bar="someval"]'); // 選中 data-foo-bar 屬性等于 someval 的元素 document.querySelectorAll('#myForm :invalid'); // 選中 myForm 表單中所有不通過驗證的元素 document.querySelectorAll('DIV:not(.ignore)'); // 選中div元素,那些 class 含 ignore 的除外 document.querySelectorAll('DIV, A, SCRIPT'); // 同時選中 div,a,script 三類元素但是,它們不支持 CSS 偽元素的選擇器(比如:first-line和:first-letter)和偽類的選擇器(比如:link和:visited),即無法選中偽元素和偽類;如果querySelectorAll方法的參數是字符串*,則會返回文檔中的所有元素節點;另外,querySelectorAll的返回結果不是動態集合,不會實時反映元素節點的變化;最后,這兩個方法除了定義在document對象上,還定義在元素節點上,即在元素節點上也可以調用
document.getElementsByTagName()
document.getElementsByTagName方法搜索 HTML 標簽名,返回符合條件的元素。它的返回值是一個類似數組對象(HTMLCollection實例),可以實時反映 HTML 文檔的變化。如果沒有任何匹配的元素,就返回一個空集
var paras = document.getElementsByTagName('p'); paras instanceof HTMLCollection // true上面代碼返回當前文檔的所有p元素節點;HTML 標簽名是大小寫不敏感的,因此getElementsByTagName方法也是大小寫不敏感的。另外,返回結果中,各個成員的順序就是它們在文檔中出現的順序;如果傳入*,就可以返回文檔中所有 HTML 元素
var allElements = document.getElementsByTagName('*');注意,元素節點本身也定義了getElementsByTagName方法,返回該元素的后代元素中符合條件的元素。也就是說,這個方法不僅可以在document對象上調用,也可以在任何元素節點上調用
var firstPara = document.getElementsByTagName('p')[0]; var spans = firstPara.getElementsByTagName('span');上面代碼選中第一個p元素內部的所有span元素
document.getElementsByClassName()
document.getElementsByClassName方法返回一個類似數組的對象(HTMLCollection實例),包括了所有class名字符合指定條件的元素,元素的變化實時反映在返回結果中
var elements = document.getElementsByClassName(names);由于class是保留字,所以 JavaScript 一律使用className表示 CSS 的class;參數可以是多個class,它們之間使用空格分隔
var elements = document.getElementsByClassName('foo bar');上面代碼返回同時具有foo和bar兩個class的元素,foo和bar的順序不重要。;注意,正常模式下,CSS 的class是大小寫敏感的;(quirks mode下,大小寫不敏感。)
與getElementsByTagName方法一樣,getElementsByClassName方法不僅可以在document對象上調用,也可以在任何元素節點上調用
// 非document對象上調用 var elements = rootElement.getElementsByClassName(names);document.getElementsByName()
document.getElementsByName方法用于選擇擁有name屬性的 HTML 元素(比如
、、、、和總結
以上是生活随笔為你收集整理的JavaScript 教程(二)的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: nb-iot简介【转】
- 下一篇: Pycharm 导入 Python 包、