JavaScript(三)(面向物件編程、異步操作)
文章目錄
- JavaScript(三)(面向物件編程、異步操作)
- 五、面向物件編程
- 25. 實體物件與 new 命令
- 25.1 物件是什么
- 25.2 建構式
- 25.3 new 命令
- 25.3.1 基本用法
- 25.3.2 new 命令的原理
- 25.3.3 new.target
- 25.4 Object.create() 創建實體物件
- 26. this 關鍵字
- 26.1 涵義
- 26.2 實質
- 26.3 使用場合
- **(1)全域環境**
- **(2)建構式**
- **(3)物件的方法**
- 26.4 使用注意點
- 26.4.1 避免多層 this
- 26.4.2 ?避免陣列處理方法中的 this(內外層都有this時)
- 26.4.3 避免回呼函式中的 this
- 26.5 系結 this 的方法
- 26.5.1 Function.prototype.call()
- 26.5.2 Function.prototype.apply()
- **(1)找出陣列最大元素**
- **(2)將陣列的空元素變為`undefined`**
- **(3)轉換類似陣列的物件**
- **(4)系結回呼函式的物件**
- 26.6 Function.prototype.bind()
- **(1)每一次回傳一個新函式**
- **(2)結合回呼函式使用**
- **(3)結合`call()`方法使用**
- 27. 物件的繼承
- 27.1 原型物件概述
- 27.1.1 建構式的缺點
- 27.1.2 prototype 屬性的作用
- 27.1.3 原型鏈
- 27.1.4 constructor 屬性
- 27.2 ?instanceof 運算子
- (1)判斷是否為建構式實體
- (2)判斷值的型別(==僅適用于物件,陣列也是物件==)
- 27.3建構式的繼承
- 27.4 多重繼承
- 27.5 模塊
- 27.5.1 基本的實作方法
- 27.5.2 封裝私有變數:建構式的寫法
- 27.5.3封裝私有變數:立即執行函式的寫法
- 27.5.4 模塊的放大模式
- 27.5.5 輸入全域變數
- 28. Object 物件的相關方法
- 28.1 Object.getPrototypeOf()
- 28.2 Object.setPrototypeOf()
- 28.3 Object.create()
- 28.4 Object.prototype.isPrototypeOf()
- 28.5 Object.prototype.__proto__
- 28.6 獲取原型物件方法的比較
- 28.7 Object.getOwnPropertyNames()
- 28.8 Object.prototype.hasOwnProperty()
- 28.9 in 運算子和 for...in 回圈
- 28.10 物件的拷貝
- 29. 嚴格模式
- 29.1 設計目的
- 29.2 啟用方法
- **(1) 整個腳本檔案**
- **(2)單個函式**
- 29.3 顯式報錯
- 29.3.1 只讀屬性不可寫
- 29.3.2 只設定了取值器的屬性不可寫
- 29.3.3 禁止擴展的物件不可擴展
- 29.3.4 eval、arguments 不可用作標識名
- 29.3.5 函式不能有重名的引數
- 29.3.6 禁止八進制的前綴0表示法
- 29.4 增強的安全措施
- 29.4.1 全域變數顯式宣告
- 29.4.2 禁止 this 關鍵字指向全域物件
- 29.4.3 禁止使用 fn.callee、fn.caller
- 29.4.4 禁止使用 arguments.callee、arguments.caller
- 29.4.5 禁止洗掉變數
- 29.5 靜態系結
- 29.5.1 禁止使用 with 陳述句
- 29.5.2 創設 eval 作用域
- 29.5.3 arguments 不再追蹤引數的變化
- 29.6 向下一個版本的 JavaScript 過渡
- 29.6.1 非函式代碼塊不得宣告函式
- 29.6.2 保留字
- 六、異步操作
- 30 異步操作概述
- 30.1 單執行緒模型
- 30.2 同步任務和異步任務
- 30.3 任務佇列和事件回圈
- 30.4 異步操作的模式
- 30.4.1 回呼函式
- 30.4.2 事件監聽
- 20.4.3 發布/訂閱
- 30.5 ?異步操作的流程控制(回呼地獄 參考32.1)
- 30.5.1 串行執行
- 30.5.2 并行執行
- 30.5.3 并行與串行的結合
- 31. 定時器
- 31.1 setTimeout()
- 31.2 setInterval()
- 31.3 clearTimeout(),clearInterval()
- 31.4 實體:debounce 函式(防抖動重在清零,還有節流 (throttle)重在加鎖)
- 31.5 運行機制
- 31.6 setTimeout(f, 0)
- 31.6.1 含義
- 31.6.2 應用
- 32. Promise 物件
- 32.1 概述
- 32.2 Promise 物件的狀態
- 32.3 Promise 建構式
- 32.4 Promise.prototype.then()(console.log/console.error)
- 32.5 then() 用法辨析
- 32.6 實體:圖片加載
- 32.7 小結
- 32.8 微任務
五、面向物件編程
25. 實體物件與 new 命令
JavaScript 語言具有很強的面向物件編程能力,本章介紹 JavaScript 面向物件編程的基礎知識,
25.1 物件是什么
面向物件編程(Object Oriented Programming,縮寫為 OOP)是目前主流的編程范式,它將真實世界各種復雜的關系,抽象為一個個物件,然后由物件之間的分工與合作,完成對真實世界的模擬,
每一個物件都是功能中心,具有明確分工,可以完成接受資訊、處理資料、發出資訊等任務,物件可以復用,通過繼承機制還可以定制,因此,面向物件編程具有靈活、代碼可復用、高度模塊化等特點,容易維護和開發,比起由一系列函式或指令組成的傳統的程序式編程(procedural programming),更適合多人合作的大型軟體專案,
那么,“物件”(object)到底是什么?我們從兩個層次來理解,
(1)物件是單個實物的抽象,
一本書、一輛汽車、一個人都可以是物件,一個資料庫、一張網頁、一個遠程服務器連接也可以是物件,當實物被抽象成物件,實物之間的關系就變成了物件之間的關系,從而就可以模擬現實情況,針對物件進行編程,
(2)物件是一個容器,封裝了屬性(property)和方法(method),
屬性是物件的狀態,方法是物件的行為(完成某種任務),比如,我們可以把動物抽象為animal物件,使用“屬性”記錄具體是哪一種動物,使用“方法”表示動物的某種行為(奔跑、捕獵、休息等等),
可以這么理解,鍵名=屬性,鍵值=方法
25.2 建構式
面向物件編程的第一步,就是要生成物件,前面說過,物件是單個實物的抽象,通常需要一個模板,表示某一類實物的共同特征,然后物件根據這個模板生成,
典型的面向物件編程語言(比如 C++ 和 Java),都有“類”(class)這個概念,所謂“類”就是物件的模板,物件就是“類”的實體,但是,JavaScript 語言的物件體系,不是基于“類”的,而是基于建構式(constructor)和原型鏈(prototype),
JavaScript 語言使用建構式(constructor)作為物件的模板,所謂”建構式”,就是專門用來生成實體物件的函式,它就是物件的模板,描述實體物件的基本結構,一個建構式,可以生成多個實體物件,這些實體物件都有相同的結構,
建構式就是一個普通的函式,但具有自己的特征和用法,
var Vehicle = function () {
this.price = 1000;
};
上面代碼中,Vehicle就是建構式,為了與普通函式區別,建構式名字的第一個字母通常大寫,
建構式的特點有兩個,(函式內部使用this關鍵字就可以理解為建構式)
- 函式體內部使用了
this關鍵字,代表了所要生成的物件實體, - 生成物件的時候,必須使用
new命令,
下面先介紹new命令,
25.3 new 命令
25.3.1 基本用法
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命令,都會得到同樣的結果,
25.3.2 new 命令的原理
使用new命令時,它后面的函式依次執行下面的步驟,
- 創建一個空物件,作為將要回傳的物件實體,
- 將這個空物件的原型,指向建構式的
prototype屬性, - 將這個空物件賦值給函式內部的
this關鍵字, - 開始執行建構式內部的代碼,
也就是說,建構式內部,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建構式,那么這個函式回傳的就一定是物件,會自動忽略他原本的回傳的值)
new命令簡化的內部流程,可以用下面的代碼表示,
function _new(/* 建構式 */ constructor, /* 構造函式引數 */ params) {
// 將 arguments 物件轉為陣列
var args = [].slice.call(arguments);
// 取出建構式
var constructor = args.shift();
// 創建一個空物件,繼承建構式的 prototype 屬性
var context = Object.create(constructor.prototype);
// 執行建構式
var result = constructor.apply(context, args);
// 如果回傳結果是物件,就直接回傳,否則回傳 context 物件
return (typeof result === 'object' && result != null) ? result : context;
}
// 實體
var actor = _new(Person, '張三', 28);
25.3.3 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 命令呼叫!
上面代碼中,建構式f呼叫時,沒有使用new命令,就拋出一個錯誤,
25.4 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的模板,后者繼承了前者的屬性和方法,
Object.create()的詳細介紹,請看后面的相關章節,
26. this 關鍵字
26.1 涵義
this關鍵字是一個非常重要的語法點,毫不夸張地說,不理解它的含義,大部分開發任務都無法完成,
前一章已經提到,this可以用在建構式之中,表示實體物件,除此之外,this還可以用在別的場合,但不管是什么場合,this都有一個共同點:它總是回傳一個物件,
簡單說,this就是屬性或方法“當前”所在的物件,
this.property
上面代碼中,this就代表property屬性當前所在的物件,
下面是一個實際的例子,
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()
// "姓名:李四"
上面代碼中,(初始情況下B是不包括.describe屬性的,后被添加了A的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() // "姓名:李四"
上面代碼中,函式f內部使用了this關鍵字,隨著f所在的物件不同,this的指向也不同,
只要函式被賦給另一個變數,this的指向就會變,
var A = {
name: '張三',
describe: function () {
return '姓名:'+ this.name;
}
};
var name = '李四';
var f = A.describe;
f() // "姓名:李四"
上面代碼中,A.describe被賦值給變數f,內部的this就會指向f運行時所在的物件(本例是頂層物件,這里f()是在全域環境下運行的,因此name表示的是李四),
再看一個網頁編程的例子,
<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的指向是動態的,沒有辦法事先確定到底指向哪個物件,這才是最讓初學者感到困惑的地方,
26.2 實質
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屬性里面,
這樣的結構是很清晰的,問題在于屬性的值可能是一個函式,
var obj = { foo: function () {} };
這時,引擎會將函式單獨保存在記憶體中,然后再將函式的地址賦值給foo屬性的value屬性,
{
foo: {
[[value]]: 函式的地址
...
}
}
由于函式是一個單獨的值,所以它可以在不同的環境(背景關系)執行,
var f = function () {};
var obj = { f: f };
// 單獨執行
f()
// obj 環境執行
obj.f()
JavaScript 允許在函式體內部,參考當前環境的其他變數,
var f = function () {
console.log(x);
};
上面代碼中,函式體里面使用了變數x,該變數由運行環境提供,
現在問題就來了,由于函式可以在不同的運行環境執行,所以需要有一種機制,能夠在函式體內部獲得當前的運行環境(context),所以,this就出現了,它的設計目的就是在函式體內部,指代函式當前的運行環境,
var f = function () {
console.log(this.x);
}
上面代碼中,函式體里面的this.x就是指當前運行環境的x,
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,
26.3 使用場合
this主要有以下幾個使用場合,
(1)全域環境
全域環境使用this,它指的就是頂層物件window,
this === window // true
function f() {
console.log(this === window);
}
f() // true
上面代碼說明,不管是不是在函式內部,只要是在全域環境下運行,this就是指頂層物件window,
(2)建構式
建構式中的this,指的是實體物件,
var Obj = function (p) {
this.p = p;
};
上面代碼定義了一個建構式Obj,由于this指向實體物件,所以在建構式內部定義this.p,就相當于定義實體物件有一個p屬性,
var o = new Obj('Hello World!');
o.p // "Hello World!"
(3)物件的方法
如果物件的方法里面包含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
26.4 使用注意點
26.4.1 避免多層 this
由于this的指向是不確定的,所以切勿在函式中包含多層的this,
var o = {
f1: function () {
console.log(this);
var f2 = function () {
console.log(this);
}();
}
}
o.f1()
// Object
// Window
上面代碼包含兩層this,結果運行后,第一層指向物件o,第二層指向全域物件(上面代碼中f2是一個被定義為全域變數的函式,用的是var f2;如果用的是f2:則標明是f1內部的屬性),因為實際執行的是下面的代碼,
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一旦指向頂層物件,就會報錯,
26.4.2 ?避免陣列處理方法中的 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,指向頂層物件function(item)),
解決這個問題的一種方法,就是前面提到的,使用中間變數固定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
26.4.3 避免回呼函式中的 this
回呼函式中的this往往會改變指向,最好避免使用,
var o = new Object();
o.f = function () {
console.log(this === o);
}
// jQuery 的寫法
$('#button').on('click', o.f);
上面代碼中,點擊按鈕以后,控制臺會顯示false,原因是此時this不再指向o物件,而是指向按鈕的 DOM 物件,因為f方法是在按鈕物件的環境中被呼叫的,這種細微的差別,很容易在編程中忽視,導致難以察覺的錯誤,
為了解決這個問題,可以采用下面的一些方法對this進行系結,也就是使得this固定指向某個物件,減少不確定性,
26.5 系結 this 的方法
**this的動態切換,固然為 JavaScript 創造了巨大的靈活性,但也使得編程變得困難和模糊,有時,需要把this固定下來,避免出現意想不到的情況,**JavaScript 提供了call、apply、bind這三個方法,來切換/固定this的指向,
26.5.1 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 = {};
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上有沒有同名方法,都不會影響結果,
26.5.2 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方法以后,就變成可以接受一個陣列作為引數,
利用這一點,可以做一些有趣的應用,
(1)找出陣列最大元素
JavaScript 不提供找出陣列最大元素的函式,結合使用apply方法和Math.max方法,就可以回傳陣列的最大元素,
var a = [10, 2, 4, 15, 9];
Math.max.apply(null, a) // 15
(2)將陣列的空元素變為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
(3)轉換類似陣列的物件
另外,利用陣列物件的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屬性,以及相對應的數字鍵,
(4)系結回呼函式的物件
前面的按鈕點擊事件的例子,可以改寫如下,
var o = new Object();
o.f = function () {
console.log(this === o);
}
var f = function (){
o.f.apply(o);
// 或者 o.f.call(o);
};
// jQuery 的寫法
$('#button').on('click', f);
上面代碼中,點擊按鈕以后,控制臺將會顯示true,由于apply()方法(或者call()方法)不僅系結函式執行時所在的物件,還會立即執行函式,因此不得不把系結陳述句寫在一個函式體內,更簡潔的寫法是采用下面介紹的bind()方法,
26.6 Function.prototype.bind()
bind()方法用于將函式體內的this系結到某個物件(可以是自身也可以是其他物件),然后回傳一個新函式,
var d = new Date();
d.getTime() // 1481869925657
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() // 1481869925657
上面代碼中,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: 2
};
var newAdd = add.bind(obj, 5);//將`add()`函式的第一個引數`x`系結成`5`
newAdd(5) // 20 //將`add()`函式的第二個引數`y`系結成`5`
上面代碼中,bind()方法除了系結this物件,還將add()函式的第一個引數x系結成5,然后回傳一個新函式newAdd(),這個函式只要再接受一個引數y就能運行了(newAdd(5) 中的5是add函式中的y),
如果bind()方法的第一個引數是null或undefined,等于將this系結到全域物件,函式運行時this指向頂層物件(瀏覽器為window),
function add(x, y) {
return x + y;
}
var plus5 = add.bind(null, 5); //同上面一個代碼
plus5(10) // 15
上面代碼中,函式add()內部并沒有this,使用bind()方法的主要目的是系結引數x,以后每次運行新函式plus5(),就只需要提供另一個引數y就夠了,而且因為add()內部沒有this,所以bind()的第一個引數是null,不過這里如果是其他物件,也沒有影響,
bind()方法有一些使用注意點,
(1)每一次回傳一個新函式
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);
(2)結合回呼函式使用
回呼函式是 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卻是指向全域物件,導致沒有辦法取到值(this.name指向function (n),這是全域物件),稍微改動一下,就可以看得更清楚,
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()
// 張三
// 張三
// 張三
(3)結合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方法就可以直接使用,不需要在函式實體上使用,
27. 物件的繼承
網道(WangDoc.com),互聯網檔案計劃
面向物件編程很重要的一個方面,就是物件的繼承,A 物件通過繼承 B 物件,就能直接擁有 B 物件的所有屬性和方法,這對于代碼的復用是非常有用的,
大部分面向物件的編程語言,都是通過“類”(class)實作物件的繼承,傳統上,JavaScript 語言的繼承不通過 class,而是通過“原型物件”(prototype)實作,本章介紹 JavaScript 的原型鏈繼承,
ES6 引入了 class 語法,基于 class 的繼承不在這個教程介紹,請參閱《ES6 標準入門》一書的相關章節,
27.1 原型物件概述
27.1.1 建構式的缺點
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),
27.1.2 prototype 屬性的作用
JavaScript 繼承機制的設計思想就是,原型物件的所有屬性和方法,都能被實體物件共享,也就是說,如果屬性和方法定義在原型上,那么所有實體物件就能共享,不僅節省了記憶體,還體現了實體物件之間的聯系,(如果有某些屬性你想要生成的實體物件共享,那么就把這個屬性系結在建構式的原型物件上)
下面,先看怎么為物件指定原型,JavaScript 規定,每個函式都有一個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 // "yellow"
cat2.color // "yellow"
上面代碼中,原型物件的color屬性的值變為yellow,兩個實體物件的color屬性立刻跟著變了,這是因為實體物件其實沒有color屬性,都是讀取原型物件的color屬性,也就是說,當實體物件本身沒有某個屬性或方法的時候,它會到原型物件去尋找該屬性或方法,這就是原型物件的特殊之處,
如果實體物件自身就有某個屬性或方法,它就不會再去原型物件尋找這個屬性或方法,
cat1.color = 'black';
cat1.color // 'black'
cat2.color // 'yellow'
Animal.prototype.color // 'yellow';
上面代碼中,實體物件cat1的color屬性改為black,就使得它不再去原型物件讀取color屬性,后者的值依然為yellow,
總結一下,原型物件的作用,就是定義所有實體物件共享的屬性和方法,這也是它被稱為原型物件的原因,而實體物件可以視作從原型物件衍生出來的子物件,
Animal.prototype.walk = function () {
console.log(this.name + ' is walking');
};
上面代碼中,Animal.prototype物件上面定義了一個walk方法,這個方法將可以在所有Animal實體物件上面呼叫,
27.1.3 原型鏈
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(); //讓建構式的`prototype`屬性指向一個陣列,就意味著實體物件可以呼叫陣列方法
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的實體,instanceof運算子的詳細解釋詳見后文,
上面代碼還出現了原型物件的constructor屬性,這個屬性的含義下一節就來解釋,
27.1.4 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是建構式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"
27.2 ?instanceof 運算子
(1)判斷是否為建構式實體
instanceof運算子回傳一個布林值,表示物件是否為某個建構式的實體,
var v = new Vehicle();
v instanceof Vehicle // true
上面代碼中,物件v是建構式Vehicle的實體,所以回傳true,
instanceof運算子的左邊是實體物件,右邊是建構式,它會檢查右邊建構式的原型物件(prototype),是否在左邊物件的原型鏈上,因此,下面兩種寫法是等價的,
v instanceof Vehicle
// 等同于
Vehicle.prototype.isPrototypeOf(v)
上面代碼中,Vehicle是物件v的建構式,它的原型物件是Vehicle.prototype,isPrototypeOf()方法是 JavaScript 提供的原生方法,用于檢查某個物件是否為另一個物件的原型,詳細解釋見后文,
由于instanceof檢查整個原型鏈,因此同一個實體物件,可能會對多個建構式都回傳true,
var d = new Date();
d instanceof Date // true
d instanceof Object // true
上面代碼中,d同時是Date和Object的實體,因此對這兩個建構式都回傳true,
由于任意物件(除了null)都是Object的實體,所以instanceof運算子可以判斷一個值是否為非null的物件,
var obj = { foo: 123 };
obj instanceof Object // true
null instanceof Object // false
上面代碼中,除了null,其他物件的instanceOf Object的運算結果都是true,
instanceof的原理是檢查右邊建構式的prototype屬性,是否在左邊物件的原型鏈上,有一種特殊情況,就是左邊物件的原型鏈上,只有null物件,這時,instanceof判斷會失真,
var obj = Object.create(null);
typeof obj // "object"
obj instanceof Object // false
上面代碼中,Object.create(null)回傳一個新物件obj,它的原型是null(Object.create()的詳細介紹見后文),右邊的建構式Object的prototype屬性,不在左邊的原型鏈上,因此instanceof就認為obj不是Object的實體,這是唯一的instanceof運算子判斷會失真的情況(一個物件的原型是null),
(2)判斷值的型別(僅適用于物件,陣列也是物件)
instanceof運算子的一個用處,是判斷值的型別,
var x = [1, 2, 3];
var y = {};
x instanceof Array // true
y instanceof Object // true
上面代碼中,instanceof運算子判斷,變數x是陣列,變數y是物件,
注意,instanceof運算子只能用于物件,不適用原始型別的值,
var s = 'hello';
s instanceof String // false
上面代碼中,字串不是String物件的實體(因為字串不是物件),所以回傳false,
此外,對于undefined和null,instanceof運算子總是回傳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命令,
27.3建構式的繼承
讓一個建構式繼承另一個建構式,是非常常見的需求,這可以分成兩步實作,第一步是在子類的建構式中,呼叫父類的建構式,
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方法,
27.4 多重繼承
JavaScript 不提供多重繼承功能,即不允許一個物件同時繼承多個物件,但是,可以通過變通方法,實作這個功能,
function M1() {
this.hello = 'hello';
}
function M2() {
this.world = 'world';
}
function S() {
M1.call(this);
M2.call(this);
}
// 繼承 M1
S.prototype = Object.create(M1.prototype);
// 繼承鏈上加入 M2
Object.assign(S.prototype, M2.prototype);
// 指定建構式
S.prototype.constructor = S;
var s = new S();
s.hello // 'hello'
s.world // 'world'
上面代碼中,子類S同時繼承了父類M1和M2,這種模式又稱為 Mixin(混入),
27.5 模塊
隨著網站逐漸變成“互聯網應用程式”,嵌入網頁的 JavaScript 代碼越來越龐大,越來越復雜,網頁越來越像桌面程式,需要一個團隊分工協作、進度管理、單元測驗等等……開發者必須使用軟體工程的方法,管理網頁的業務邏輯,
JavaScript 模塊化編程,已經成為一個迫切的需求,理想情況下,開發者只需要實作核心的業務邏輯,其他都可以加載別人已經寫好的模塊,
但是,JavaScript 不是一種模塊化編程語言,ES6 才開始支持“類”和“模塊”,下面介紹傳統的做法,如何利用物件實作模塊的效果,
27.5.1 基本的實作方法
模塊是實作特定功能的一組屬性和方法的封裝,
簡單的做法是把模塊寫成一個物件,所有的模塊成員都放到這個物件里面,
var module1 = new Object({
_count : 0,
m1 : function (){
//...
},
m2 : function (){
//...
}
});
上面的函式m1和m2,都封裝在module1物件里,使用的時候,就是呼叫這個物件的屬性,
module1.m1();
但是,這樣的寫法會暴露所有模塊成員,內部狀態可以被外部改寫,比如,外部代碼可以直接改變內部計數器的值,
module1._count = 5;
27.5.2 封裝私有變數:建構式的寫法
我們可以利用建構式,封裝私有變數,
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('');
}
};
這種方法將私有變數放入實體物件中,好處是看上去更自然,但是它的私有變數可以從外部讀寫,不是很安全,
27.5.3封裝私有變數:立即執行函式的寫法
另一種做法是使用“立即執行函式”(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 模塊的基本寫法,下面,再對這種寫法進行加工,
27.5.4 模塊的放大模式
如果一個模塊很大,必須分成幾個部分,或者一個模塊需要繼承另一個模塊,這時就有必要采用“放大模式”(augmentation),
var module1 = (function (mod){
mod.m3 = function () {
//...
};
return mod;
})(module1);
上面的代碼為module1模塊添加了一個新方法m3(),然后回傳新的module1模塊,
在瀏覽器環境中,模塊的各個部分通常都是從網上獲取的,有時無法知道哪個部分會先加載,如果采用上面的寫法,第一個執行的部分有可能加載一個不存在空物件,這時就要采用"寬放大模式"(Loose augmentation),
var module1 = (function (mod) {
//...
return mod;
})(window.module1 || {});
與"放大模式"相比,“寬放大模式”就是“立即執行函式”的引數可以是空物件,
27.5.5 輸入全域變數
獨立性是模塊的重要特點,模塊內部最好不與程式的其他部分直接互動,
為了在模塊內部呼叫全域變數,必須顯式地將其他變數輸入模塊,
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 scope
window.finalCarousel = {
init : initialize,
destroy : dieCarouselDie
}
})( jQuery, window, document );
上面代碼中,finalCarousel物件輸出到全域,對外暴露init和destroy介面,內部方法go、handleEvents、initialize、dieCarouselDie都是外部無法呼叫的,
28. Object 物件的相關方法
JavaScript 在Object物件上面,提供了很多相關方法,處理面向物件編程的相關操作,本章介紹這些方法,
28.1 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 // true
28.2 Object.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),都轉移到這個空物件上,(25.3.2new的原理)
28.3 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物件為原型(以實體物件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 null
Object.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,
28.4 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的物件除外,
28.5 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;//`A`物件和`B`物件的原型都是`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方法,**
28.6 獲取原型物件方法的比較
如前所述,__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.prototypeObject.getPrototypeOf(obj)
上面三種方法之中,前兩種都不是很可靠,__proto__屬性只有瀏覽器才需要部署,其他環境可以不部署,而obj.constructor.prototype在手動改變原型物件時,可能會失效,(參考27.1.4)
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.constructor = C;這一步必不可少)==,
C.prototype = p;
C.prototype.constructor = C; //使用.constructor時,這一步必不可少
var c = new C();
c.constructor.prototype === p // true
因此,推薦使用第三種Object.getPrototypeOf方法,獲取原型物件,
28.7 Object.getOwnPropertyNames()
Object.getOwnPropertyNames方法回傳一個陣列,成員是引數物件本身的所有屬性的鍵名,不包含繼承的屬性鍵名,
Object.getOwnPropertyNames(Date)
// ["parse", "arguments", "UTC", "caller", "name", "prototype", "now", "length"]
上面代碼中,Object.getOwnPropertyNames方法回傳Date所有自身的屬性名,
物件本身的屬性之中,有的是可以遍歷的(enumerable),有的是不可以遍歷的,==Object.getOwnPropertyNames方法回傳所有鍵名,不管是否可以遍歷,==只獲取那些可以遍歷的屬性,使用Object.keys方法,
Object.keys(Date) // []
上面代碼表明,Date物件所有自身的屬性,都是不可以遍歷的,
28.8 Object.prototype.hasOwnProperty()
物件實體的hasOwnProperty方法回傳一個布林值,用于判斷某個屬性定義在物件自身,還是定義在原型鏈上,
Date.hasOwnProperty('length') // true
Date.hasOwnProperty('toString') // false
上面代碼表明,Date.length(建構式Date可以接受多少個引數)是Date自身的屬性,Date.toString是繼承的屬性,
另外,hasOwnProperty方法是 JavaScript 之中唯一一個處理物件屬性時,不會遍歷原型鏈的方法,
28.9 in 運算子和 for…in 回圈
in運算子回傳一個布林值,表示一個物件是否具有某個屬性,它不區分該屬性是物件自身的屬性,還是繼承的屬性,
'length' in Date // true
'toString' in Date // true
in運算子常用于檢查一個屬性是否存在,
獲得物件的所有可遍歷屬性(不管是自身的還是繼承的),可以使用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) {//getOwnPropertyNames(obj)回傳自身屬性的陣列,forEach遍歷陣列
props[p] = true;
});
obj = Object.getPrototypeOf(obj);//getPrototypeOf(obj)回傳當前的原型,相當于在原型鏈上往上一級
}
return Object.getOwnPropertyNames(props);
}
上面代碼依次獲取obj物件的每一級原型物件“自身”的屬性,從而獲取obj物件的“所有”屬性,不管是否可遍歷,
下面是一個例子,列出Date物件的所有屬性,
inheritedPropertyNames(Date)
// [
// "caller",
// "constructor",
// "toString",
// "UTC",
// ...
// ]
28.10 物件的拷貝
如果要拷貝一個物件,需要做到下面兩件事情,
- 確保拷貝后的物件,與原物件具有同樣的原型,
- 確保拷貝后的物件,與原物件具有同樣的實體屬性,
下面就是根據上面兩點,實作的物件拷貝函式,
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)
);
}
29. 嚴格模式
除了正常的運行模式,JavaScript 還有第二種運行模式:嚴格模式(strict mode),顧名思義,這種模式采用更加嚴格的 JavaScript 語法,
同樣的代碼,在正常模式和嚴格模式中,可能會有不一樣的運行結果,一些在正常模式下可以運行的陳述句,在嚴格模式下將不能運行,
29.1 設計目的
早期的 JavaScript 語言有很多設計不合理的地方,但是為了兼容以前的代碼,又不能改變老的語法,只能不斷添加新的語法,引導程式員使用新語法,
嚴格模式是從 ES5 進入標準的,主要目的有以下幾個,
- 明確禁止一些不合理、不嚴謹的語法,減少 JavaScript 語言的一些怪異行為,
- 增加更多報錯的場合,消除代碼運行的一些不安全之處,保證代碼運行的安全,
- 提高編譯器效率,增加運行速度,
- 為未來新版本的 JavaScript 語法做好鋪墊,
總之,嚴格模式體現了 JavaScript 更合理、更安全、更嚴謹的發展方向,
29.2 啟用方法
進入嚴格模式的標志,是一行字串use strict,
'use strict';
老版本的引擎會把它當作一行普通字串,加以忽略,新版本的引擎就會進入嚴格模式,
嚴格模式可以用于整個腳本,也可以只用于單個函式,
(1) 整個腳本檔案
use strict放在腳本檔案的第一行,整個腳本都將以嚴格模式運行,如果這行陳述句不在第一行就無效,整個腳本會以正常模式運行,(嚴格地說,只要前面不是產生實際運行結果的陳述句,use strict可以不在第一行,比如直接跟在一個空的分號后面,或者跟在注釋后面,)
<script>
'use strict';
console.log('這是嚴格模式');
</script>
<script>
console.log('這是正常模式');
</script>
上面代碼中,一個網頁檔案依次有兩段 JavaScript 代碼,前一個<script>標簽是嚴格模式,后一個不是,
如果use strict寫成下面這樣,則不起作用,嚴格模式必須從代碼一開始就生效,
<script>
console.log('這是正常模式');
'use strict';
</script>
(2)單個函式
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
})();
29.3 顯式報錯
嚴格模式使得 JavaScript 的語法變得更嚴格,更多的操作會顯式報錯,其中有些操作,在正常模式下只會默默地失敗,不會報錯,
29.3.1 只讀屬性不可寫
嚴格模式下,設定字串的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>
29.3.2 只設定了取值器的屬性不可寫
嚴格模式下,對一個只有取值器(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
上面代碼中,obj.v只有取值器,沒有存值器,對它進行賦值就會報錯,
29.3.3 禁止擴展的物件不可擴展
嚴格模式下,對禁止擴展的物件添加新屬性,會報錯,
'use strict';
var obj = {};
Object.preventExtensions(obj);
obj.v = 1;
// Uncaught TypeError: Cannot add property v, object is not extensible
上面代碼中,obj物件禁止擴展,添加屬性就會報錯,
29.3.4 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
29.3.5 函式不能有重名的引數
正常模式下,如果函式有多個重名的引數,可以用arguments[i]讀取,嚴格模式下,這屬于語法錯誤,
function f(a, a, b) {
'use strict';
return a + b;
}
// Uncaught SyntaxError: Duplicate parameter name not allowed in this context
29.3.6 禁止八進制的前綴0表示法
正常模式下,整數的第一位如果是0,表示這是八進制數,比如0100等于十進制的64,嚴格模式禁止這種表示法,整數第一位為0,將報錯,
'use strict';
var n = 0100;
// Uncaught SyntaxError: Octal literals are not allowed in strict mode.
29.4 增強的安全措施
嚴格模式增強了安全保護,從語法上防止了一些不小心會出現的錯誤,
29.4.1 全域變數顯式宣告
正常模式中,如果一個變數沒有宣告就賦值,默認是全域變數,嚴格模式禁止這種用法,全域變數必須顯式宣告,
'use strict';
v = 1; // 報錯,v未宣告
for (i = 0; i < 2; i++) { // 報錯,i 未宣告
// ...
}
function f() {
x = 123;
}
f() // 報錯,未宣告就創建一個全域變數
因此,嚴格模式下,變數都必須先宣告,然后再使用,
29.4.2 禁止 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上面,
29.4.3 禁止使用 fn.callee、fn.caller
函式內部不得使用fn.caller、fn.arguments,否則會報錯,這意味著不能在函式內部得到呼叫堆疊了,
function f1() {
'use strict';
f1.caller; // 報錯
f1.arguments; // 報錯
}
f1();
29.4.4 禁止使用 arguments.callee、arguments.caller
arguments.callee和arguments.caller是兩個歷史遺留的變數,從來沒有標準化過,現在已經取消了,正常模式下呼叫它們沒有什么作用,但是不會報錯,嚴格模式明確規定,函式內部使用arguments.callee、arguments.caller將會報錯,
'use strict';
var f = function () {
return arguments.callee;
};
f(); // 報錯
29.4.5 禁止洗掉變數
嚴格模式下無法洗掉變數,如果使用delete命令洗掉一個變數,會報錯,只有物件的屬性,且屬性的描述物件的configurable屬性設定為true,才能被delete命令洗掉,
'use strict';
var x;
delete x; // 語法錯誤
var obj = Object.create(null, {
x: {
value: 1,
configurable: true
}
});
delete obj.x; // 洗掉成功
29.5 靜態系結
JavaScript 語言的一個特點,就是允許“動態系結”,即某些屬性和方法到除錯于哪一個物件,不是在編譯時確定的,而是在運行時(runtime)確定的,
嚴格模式對動態系結做了一些限制,某些情況下,只允許靜態系結,也就是說,屬性和方法到底歸屬哪個物件,必須在編譯階段就確定,這樣做有利于編譯效率的提高,也使得代碼更容易閱讀,更少出現意外,
具體來說,涉及以下幾個方面,
29.5.1 禁止使用 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
29.5.2 創設 eval 作用域
正常模式下,JavaScript 語言有兩種變數作用域(scope):全域作用域和函式作用域,嚴格模式創設了第三種作用域:eval作用域,
正常模式下,eval陳述句的作用域,取決于它處于全域作用域,還是函式作用域,嚴格模式下,eval陳述句本身就是一個作用域,不再能夠在其所運行的作用域創設新的變數了,也就是說,eval所生成的變數只能用于eval內部,
(function () {
'use strict';
var x = 2;
console.log(eval('var x = 5; x')) // 5
console.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內部使用的都是嚴格模式,
29.5.3 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物件上來,
29.6 向下一個版本的 JavaScript 過渡
JavaScript 語言的下一個版本是 ECMAScript 6,為了平穩過渡,嚴格模式引入了一些 ES6 語法,
29.6.1 非函式代碼塊不得宣告函式
ES6 會引入塊級作用域,為了與新版本接軌,ES5 的嚴格模式只允許在全域作用域或函式作用域宣告函式,也就是說,不允許在非函式的代碼塊內宣告函式,
'use strict';
if (true) {
function f1() { } // 語法錯誤
}
for (var i = 0; i < 5; i++) {
function f2() { } // 語法錯誤
}
上面代碼在if代碼塊和for代碼塊中宣告了函式,ES5 環境會報錯,
注意,如果是 ES6 環境,上面的代碼不會報錯,因為 ES6 允許在代碼塊之中宣告函式,
29.6.2 保留字
為了向將來 JavaScript 的新版本過渡,嚴格模式新增了一些保留字(implements、interface、let、package、private、protected、public、static、yield等),使用這些詞作為變數名將會報錯,
function package(protected) { // 語法錯誤
'use strict';
var implements; // 語法錯誤
}
六、異步操作
30 異步操作概述
30.1 單執行緒模型
單執行緒模型指的是,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 單執行緒的本質,
30.2 同步任務和異步任務
程式里面所有的任務,可以分成兩類:同步任務(synchronous)和異步任務(asynchronous),
同步任務是那些沒有被引擎掛起、在主執行緒上排隊執行的任務,只有前一個任務執行完畢,才能執行后一個任務,
異步任務是那些被引擎放在一邊,不進入主執行緒、而進入任務佇列的任務,只有引擎認為某個異步任務可以執行了(比如 Ajax 操作從服務器得到了結果),該任務(采用回呼函式的形式)才會進入主執行緒執行,排在異步任務后面的代碼,不用等待異步任務結束會馬上運行,也就是說,異步任務不具有“堵塞”效應,
舉例來說,Ajax 操作可以當作同步任務處理,也可以當作異步任務處理,由開發者決定,如果是同步任務,主執行緒就等著 Ajax 操作回傳結果,再往下執行;如果是異步任務,主執行緒在發出 Ajax 請求以后,就直接往下執行,等到 Ajax 操作有了結果,主執行緒再執行對應的回呼函式,
30.3 任務佇列和事件回圈
JavaScript 運行時,除了一個正在運行的主執行緒,引擎還提供一個任務佇列(task queue),里面是各種需要當前程式處理的異步任務,(實際上,根據異步任務的型別,存在多個任務佇列,為了方便理解,這里假設只存在一個佇列,)
首先,主執行緒會去執行所有的同步任務,**等到同步任務全部執行完,就會去看任務佇列里面的異步任務,**如果滿足條件,那么異步任務就重新進入主執行緒開始執行,這時它就變成同步任務了,等到執行完,下一個異步任務再進入主執行緒開始執行,一旦任務佇列清空,程式就結束執行,
異步任務的寫法通常是回呼函式,一旦異步任務重新進入主執行緒,就會執行對應的回呼函式,如果一個異步任務沒有回呼函式,就不會進入任務佇列,也就是說,不會重新進入主執行緒,因為沒有用回呼函式指定下一步的操作,
JavaScript 引擎怎么知道異步任務有沒有結果,能不能進入主執行緒呢?答案就是引擎在不停地檢查,一遍又一遍,只要同步任務執行完了,引擎就會去檢查那些掛起來的異步任務,是不是可以進入主執行緒了,這種回圈檢查的機制,就叫做事件回圈(Event Loop),維基百科的定義是:“事件回圈是一個程式結構,用于等待和發送訊息和事件(a programming construct that waits for and dispatches events or messages in a program)”,
30.4 異步操作的模式
下面總結一下異步操作的幾種模式,
30.4.1 回呼函式
回呼函式是異步操作最基本的方法,
因為可以把呼叫者與被呼叫者分開,所以呼叫者不關心誰是被呼叫者,它只需知道存在一個具有特定原型和限制條件的被呼叫的函式,簡而言之,回呼函式就是允許用戶把需要呼叫的函式的指標作為引數傳遞給一個函式,以便該函式在處理相似事件的時候可以靈活的使用不同的方法,
回呼函式在實際中有許多作用,假設有這樣一種情況:我們要撰寫一個庫,它提供了某些排序演算法通用,不想在函式中嵌入排序邏輯,而讓使用者來實作相應的邏輯;或者,能讓庫可用于多種資料型別(int、float、string),此時,可以使用函式指標
回呼可用于通知機制,例如,有時要在A程式中設定一個計時器么,就需一個具有特定原型的函式指標進行回呼,通知A程式事件已經發生,實際上,API使用一個回呼函式SetTimer來通知A程式,如果沒有提供回呼函式,它還會把一個訊息發往程式的訊息佇列
下面是兩個函式f1和f2,編程的意圖是f2必須等到f1執行完成,才能執行,
function f1() {
// ...
}
function f2() {
// ...
}
f1();
f2();
上面代碼的問題在于,如果f1是異步操作,f2會立即執行,不會等到f1結束再執行,
這時,可以考慮改寫f1,把f2寫成f1的回呼函式,
function f1(callback) {
// ...
callback();
}
function f2() {
// ...
}
f1(f2);
回呼函式的優點是簡單、容易理解和實作,缺點是不利于代碼的閱讀和維護,各個部分之間高度耦合
30.4.2 事件監聽
另一種思路是采用事件驅動模式,異步任務的執行不取決于代碼的順序,而取決于某個事件是否發生,
還是以f1和f2為例,首先,為f1系結一個事件(這里采用的 jQuery 的寫法),
f1.on('done', f2);
上面這行代碼的意思是,當f1發生done事件,就執行f2,然后,對f1進行改寫:
function f1() {
setTimeout(function () {
// ...
f1.trigger('done');
}, 1000);
}
上面代碼中,f1.trigger('done')表示,執行完成后,立即觸發done事件,從而開始執行f2,
這種方法的優點是比較容易理解,可以系結多個事件,每個事件可以指定多個回呼函式,而且可以“去耦合很不清晰,閱讀代碼的時候,很難看出主流程,
20.4.3 發布/訂閱
事件完全可以理解成“信號”,如果存在一個“信號中心”,某個任務執行完成,就向信號中心“發布”(publish)一個信號,其他任務可以向信號中心“訂閱”(subscribe)這個信號,從而知道什么時候自己可以開始執行,這就叫做”發布/訂閱模式”(publish-subscribe pattern),又稱“觀察者模式”(observer pattern),
這個模式有多種實作,下面采用的是 Ben Alman 的 Ting 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);
這種方法的性質與“事件監聽”類似,但是明顯優于后者,因為可以通過查看“訊息中心”,了解存在多少信號、每個信號有多少訂閱者,從而監控程式的運行,
30.5 ?異步操作的流程控制(回呼地獄 參考32.1)
如果有多個異步操作,就存在一個流程控制的問題:如何確定異步操作執行的順序,以及如何保證遵守這種順序,
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
上面代碼中,六個回呼函式的嵌套,不僅寫起來麻煩,容易出錯,而且難以維護,
30.5.1 串行執行
我們可以撰寫一個流程控制函式,讓它來控制異步任務,一個任務完成以后,再執行另一個,這就叫串行執行,
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陣列保存每一個異步任務的運行結果,
注意,上面的寫法需要六秒,才能完成整個腳本,
30.5.2 并行執行
流程控制函式也可以是并行執行,即所有異步任務同時執行,等到全部完成以后,才執行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函式,
相比而言,上面的寫法只要一秒,就能完成整個腳本(foreach會對陣列中的所有元素同時進行運算,即并行處理),這就是說,并行執行的效率較高,比起串行執行一次只能執行一個任務,較為節約時間,但是問題在于如果并行的任務較多,很容易耗盡系統資源,拖慢運行速度,因此有了第三種流程控制方式,
30.5.3 并行與串行的結合
所謂并行與串行的結合,就是設定一個門檻,每次最多只能并行執行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變數,達到效率和資源的最佳平衡,
31. 定時器
JavaScript 提供定時執行代碼的功能,叫做定時器(timer),主要由setTimeout()和setInterval()這兩個函式來完成,它們向任務佇列添加定時任務,
31.1 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)
// 2
31.2 setInterval()
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毫秒開始,
31.3 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,得到一個計算器編號,然后把編號比它小的計數器全部取消,
31.4 實體:debounce 函式(防抖動重在清零,還有節流 (throttle)重在加鎖)
clearTimeout(timer);//防抖動最關鍵的一步在于計時器清零
有時,我們不希望回呼函式被頻繁呼叫,比如,用戶填入網頁輸入框的內容,希望通過 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毫秒,
31.5 運行機制
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) {
}
}
上面代碼中,setInterval要求每隔1000毫秒,就輸出一個2,但是,緊接著的sleep陳述句需要3000毫秒才能完成,那么setInterval就必須推遲到3000毫秒之后才開始生效,注意,生效后setInterval不會產生累積效應,即不會一下子輸出三個2,而是只會輸出一個2,
31.6 setTimeout(f, 0)
31.6.1 含義
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秒)之后運行,這樣是為了節省系統資源,
31.6.2 應用
setTimeout(f, 0)有幾個非常重要的用途,它的一大應用是,可以調整事件的發生順序,比如,網頁開發中,某個事件先發生在子元素,然后冒泡到父元素,即子元素的事件回呼函式,會早于父元素的事件回呼函式觸發,如果,想讓父元素的事件回呼函式先發生,就要用到setTimeout(f, 0),
// HTML 代碼如下
// <input type="button" id="myButton" value="click">
var input = document.getElementById('myButton');
input.onclick = function A() {
setTimeout(function B() {
input.value +=' input';
}, 0)
};
document.body.onclick = function C() {
input.value += ' body'
};
上面代碼在點擊按鈕后,先觸發回呼函式A,然后觸發函式C,函式A中,setTimeout將函式B推遲到下一輪事件回圈執行,這樣就起到了,先觸發父元素的回呼函式C的目的了,
另一個應用是,用戶自定義的回呼函式,通常在瀏覽器的默認動作之前觸發,比如,用戶在輸入框輸入文本,keypress事件會在瀏覽器接收文本之前觸發,因此,下面的回呼函式是達不到目的的,
// HTML 代碼如下
// <input type="text" id="input-box">
document.getElementById('input-box').onkeypress = function (event) {
this.value = this.value.toUpperCase();
}
上面代碼想在用戶每次輸入文本后,立即將字符轉為大寫,但是實際上,它只能將本次輸入前的字符轉為大寫,因為瀏覽器此時還沒接收到新的文本,所以this.value取不到最新輸入的那個字符,只有用setTimeout改寫,上面的代碼才能發揮作用(參考26.4.2 函式內外都有this時),
document.getElementById('input-box').onkeypress = function() {
var self = this;
setTimeout(function() {
self.value = self.value.toUpperCase();
}, 0);
}
上面代碼將代碼放入setTimeout之中,就能使得它在瀏覽器接收到文本之后觸發,
由于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);//此處類似9.3.4的短路規則,i++這個運算式是一直執行的;相當于i++ if (i++ == 0xFFFFFF) clearTimeout(timer).
}
timer = setTimeout(func, 0);
if (i++ == 0xFFFFFF) clearTimeout(timer);此處類似9.3.4的短路規則,i++這個運算式是一直執行的;相當于i++ if (i++ == 0xFFFFFF) clearTimeout(timer).
上面代碼有兩種寫法,都是改變一個網頁元素的背景色,寫法一會造成瀏覽器“堵塞”,因為 JavaScript 執行速度遠高于 DOM,會造成大量 DOM 操作“堆積”,而寫法二就不會,這就是setTimeout(f, 0)的好處,
另一個使用這種技巧的例子是代碼高亮的處理,如果代碼塊很大,一次性處理,可能會對性能造成很大的壓力,那么將其分成一個個小塊,一次處理一塊,比如寫成setTimeout(highlightNext, 50)的樣子,性能壓力就會減輕,
32. Promise 物件
32.1 概述
Promise 物件是 JavaScript 的異步操作解決方案,為異步操作提供統一介面,它起到代理作用(proxy),充當異步操作與回呼函式之間的中介,使得異步操作具備同步操作的介面,Promise 可以讓異步操作寫起來,就像在寫同步操作的流程,而不必一層層地嵌套回呼函式,
注意,本章只是 Promise 物件的簡單介紹,為了避免與后續教程的重復,更完整的介紹請看《ES6 標準入門》的《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,(then表示在前面的異步操作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);
從上面代碼可以看到,采用 Promises 以后,程式流程變得非常清楚,十分易讀,注意,為了便于理解,上面代碼的Promise實體的生成格式,做了簡化,真正的語法請參照下文,(參考30.5 回呼地獄)
總的來說,傳統的回呼函式寫法使得代碼混成一團,變得橫向發展而不是向下發展,Promise 就是解決這個問題,使得異步流程可以寫成同步流程,
Promise 原本只是社區提出的一個構想,一些函式庫率先實作了這個功能,ECMAScript 6 將其寫入語言標準,目前 JavaScript 原生支持 Promise 物件,
32.2 Promise 物件的狀態
Promise 物件通過自身的狀態,來控制異步操作,Promise 實體具有三種狀態,
- 異步操作未完成(pending)
- 異步操作成功(fulfilled)
- 異步操作失敗(rejected)
上面三種狀態里面,fulfilled和rejected合在一起稱為resolved(已定型),
這三種的狀態的變化途徑只有兩種,
- 從“未完成”到“成功”
- 從“未完成”到“失敗”
一旦狀態發生變化,就凝固了,不會再有新的狀態變化,這也是 Promise 這個名字的由來,它的英語意思是“承諾”,一旦承諾成效,就不得再改變了,這也意味著,Promise 實體的狀態變化只可能發生一次,
因此,Promise 的最終結果只有兩種,
- 異步操作成功,Promise 實體傳回一個值(value),狀態變為
fulfilled, - 異步操作失敗,Promise 實體拋出一個錯誤(error),狀態變為
rejected,
32.3 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,
32.4 Promise.prototype.then()(console.log/console.error)
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,//要么報錯,要么只顯示step3的回傳值
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 物件的報錯具有傳遞性,
32.5 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);
32.6 實體:圖片加載
下面是使用 Promise 完成圖片的加載,
var preloadImage = function (path) {
return new Promise(function (resolve, reject) {
var image = new Image();
image.onload = resolve;
image.onerror = reject;
image.src = path;
});
};
上面代碼中,image是一個圖片物件的實體,它有兩個事件監聽屬性,onload屬性在圖片加載成功后呼叫,onerror屬性在加載失敗呼叫,
上面的preloadImage()函式用法如下,
preloadImage('https://example.com/my.jpg')
.then(function (e) { document.body.append(e.target) })//function (e) 中e是形參的意思
.then(function () { console.log('加載成功') })
上面代碼中,圖片加載成功以后,onload屬性會回傳一個事件物件,因此第一個then()方法的回呼函式,會接收到這個事件物件,該物件的target屬性就是圖片加載后生成的 DOM 節點,
32.7 小結
Promise 的優點在于,讓回呼函式變成了規范的鏈式寫法,程式流程可以看得很清楚,它有一整套介面,可以實作許多強大的功能,比如同時執行多個異步操作,等到它們的狀態都改變以后,再執行一個回呼函式;再比如,為多個回呼函式中拋出的錯誤,統一指定處理方法等等,
而且,Promise 還有一個傳統寫法沒有的好處:它的狀態一旦改變,無論何時查詢,都能得到這個狀態,這意味著,無論何時為 Promise 實體添加回呼函式,該函式都能正確執行,所以,你不用擔心是否錯過了某個事件或信號,如果是傳統寫法,通過監聽事件來執行回呼函式,一旦錯過了事件,再添加回呼函式是不會執行的,
Promise 的缺點是,撰寫的難度比傳統寫法高,而且閱讀代碼也不是一眼可以看懂,你只會看到一堆then,必須自己在then的回呼函式里面理清邏輯,
32.8 微任務
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)在下一輪事件回圈開始時執行,
轉載請註明出處,本文鏈接:https://www.uj5u.com/qianduan/289310.html
標籤:其他
上一篇:SpringMVC中使用JSON
