一、構造函數和原型
1、構造函數、靜態成員和實例成員
在ES6之前,通常用一種稱為構造函數的特殊函數來定義對象及其特征,然后用構造函數來創建對象。像其他面向對象的語言一樣,將抽象后的屬性和方法封裝到對象內部。
function Person(uname, age) {
this.uname = uname;
this.age = age;
this.say = function() {
console.log('我叫' + this.uname + ',今年' + this.age + '歲。');
}
}
var zhangsan = new Person('張三', 18);
zhangsan.say(); //輸出:我叫張三,今年18歲。
var lisi = new Person('李四', 20);
lisi.say(); //輸出:我叫李四,今年20歲。
在創建對象時,構造函數總與new一起使用(而不是直接調用)。new創建了一個新的對象,然后將this指向這個新對象,這樣我們才能通過this為這個新對象賦值,函數體內的代碼執行完畢后,返回這個新對象(不需要寫return)。
構造函數內部通過this添加的成員(屬性/方法),稱為實例成員(屬性/方法),只能通過實例化的對象來訪問,構造函數上是沒有這個成員的。
>> Person.uname
undefined
我們也可以給構造函數本身添加成員,稱為靜態成員(屬性/方法),只能通過構造函數本身來訪問。
2、原型
上面的例子中,我們借助構造函數創建了兩個對象zhangsan和lisi,它們有各自獨立的屬性和方法。對於實例方法而言,由於函數是復雜數據類型,所以會專門開辟一塊內存空間存放函數。又由於zhangsan和lisi的方法是獨立的,所以zhangsan的say方法和lisi的say方法分別占據了兩塊內存,盡管它們是同一套代碼,做同一件事情。
>> zhangsan.say === lisi.say
false
試想,實例方法越多,創建的對象越多,浪費的空間也就越大。為了節約空間,我們希望所有的對象調用同一個say方法。為了實現這個目的,就要用到原型。
每個構造函數都有一個prototype屬性,指向另一個對象,稱為原型,由於它是一個對象,也稱為原型對象。(以下為了不產生混淆,將構造函數創建的對象稱為實例)另一方面,實例有一個屬性__proto__,通過它也會指向這個原型對象。為了區分,__proto__的指向一般叫對象的原型,prototype叫原型對象。
原型對象里還有一個constructor屬性,它指回構造函數本身,以記錄該原型對象引用自哪個構造函數。這樣,構造函數、原型和實例就構成了一個三角關系。
引入原型對象后,構造函數改為如下方式定義:
function Person(uname, age) {
this.uname = uname;
this.age = age;
}
Person.prototype.say = function() {
return '我叫' + this.uname + ',今年' + this.age + '歲。';
};
var zhangsan = new Person('張三', 18);
console.log(zhangsan.say());
var lisi = new Person('李四', 20);
console.log(lisi.say());
console.log(zhangsan.say === lisi.say); //輸出:true
在查找對象的成員時,首先在對象自己身上尋找,如果自己沒有,就通過__proto__去原型對象上找。通過構造函數的原型對象定義的函數是所有實例共享的。
一般情況下,我們把實例屬性定義到構造函數中,實例方法放到原型對象中。
3、原型鏈
原型對象也是一個對象,它也有自己的原型,指向Object的原型對象Object.prototype。
>> Person.prototype.__proto__ === Object.prototype
true
>> Person.prototype.__proto__.constructor === Object
true
也就是說,Person的原型對象是由Object這個構造函數創建的。
繼續往下追溯Object.prototype的原型:
>> Object.prototype.__proto__
null
終於到頭了。回過頭來看,我們從zhangsan這個實例開始追溯:
zhangsan.__proto__指向Person的原型對象
zhangsan.__proto__.__proto__指向Object的原型對象Object.prototype
zhangsan.__proto__.__proto__.__proto__指向null
這種鏈式的結構,就稱為原型鏈。
對象的成員查找機制依靠原型鏈:當訪問一個對象的屬性(或方法)時,首先查找這個對象自身有沒有該屬性;如果沒有就找它的原型;如果還沒有就找原型對象的原型;以此類推一直找到null為止,此時返回undefined。__proto__屬性為查找機制提供了一條路線,一個方向。
有了原型鏈的概念之后,現在再來回顧new在執行時做了什么:
1.在內存中創建一個新的空對象;
2.將對象的__proto__屬性指向構造函數的原型對象;
3.讓構造函數里的this指向這個新的對象;
4.執行構造函數里的代碼,給這個新對象添加成員,最后返回這個新對象。
二、繼承
ES6以前,如何實現類的繼承?這就要用到call方法。
function.call(thisArg, arg1, arg2, ...)
thisArg:在function函數運行時指定的this值。
arg1, arg2, ...:要傳遞給function的實參。
我們知道,所調用的函數內部有一個this屬性,根據不同的場景指向調用者、window或undefined。call方法允許我們調用函數時指定另一個this。
var obj = {};
function f () {
console.log(this === window, this === obj);
}
f(); //輸出:true false
f.call(obj); //輸出:false true
以普通的方式調用f函數,this指向window;以call來調用,this就指向obj。
利用call,在子構造函數中調用父構造函數,令其內部的this由父類實例指向子類實例,從而在父構造函數中完成一部分成員的初始化。
來看例子,我們從Person繼承一個Student類,不僅要有Person的一切屬性和方法,還要新增一個grade屬性表示年級,一個exam方法用來考試:
function Person(uname, age) {
this.uname = uname;
this.age = age;
}
Person.prototype.say = function() {
return '我叫' + this.uname + ',今年' + this.age + '歲。';
};
function Student(uname, age, grade) {
Person.call(this, uname, age);
this.grade = grade;
}
Student.prototype.exam = function() {
console.log('正在考試!');
};
var stu = new Student('張三', 16, '高一');
console.log(stu.uname, stu.age, stu.grade); //輸出:張三 16 高一
stu.exam(); //輸出:正在考試!
在Student中,調用了Person函數,令其內部的this指向Student的this,這樣uname和age都給了Student的this,最后給原型對象加了個exam方法。注意我們的目的不是創建一個Person的實例,所以沒有加new,只是把構造函數當普通函數調用而已。
接下來讓我們調用父構造函數中的say方法,看看有沒有被繼承。
>> stu.say()
TypeError: stu.say is not a function //報錯了!
>> stu.say
undefined //stu實例並沒有say這個成員
哦哦,say是放在Person.prototype中的,但是stu並沒有和它產生聯系,得改原型鏈。又由於stu的原型上已經掛了exam,不能直接改變stu.__proto__的指向,只好沿着原型鏈修改Student.prototype.__proto__的指向(它原本指向Object.prototype):
>> Student.prototype.__proto__ = Person.prototype
>> stu.say()
"我叫張三,今年16歲。" //調用成功了!
say方法執行了,打印出了姓名、年齡,但我們的Student構造函數還新增了個grade,也需要打印出來。這可難為say方法了,畢竟當時我們定義它時是基於Person的,並沒有grade屬性。所以我們要覆寫這個方法,讓它能打印grade,同時不影響原有的say方法,也就是和exam一樣掛到Student.prototye上。
>> Student.prototype.say = function() { return '我叫' + this.uname + ',今年' + this.age + '歲。' + this.grade + '學生。'; }
>> stu.say()
"我叫張三,今年16歲。高一學生。"
搞定了!我們碰了好幾次壁,總算解決了繼承的問題。
在很多資料中提到了“寄生組合式繼承”,思路與上面的分析一樣,就是原型對象+構造函數組合使用。不同之處僅在於,沒有保留原有的子構造函數的原型對象,而是將它指向另一個通過Object.create()方法創建的對象:
Student.prototype = Object.create(Person.prototype);
Student.prototype.constructor = Student;
Object.create()方法創建一個新對象,這個新對象的__proto__指向作為實參傳入的Person.prototype。既然指定了另一個對象作為原型,那么constructor應該指回構造函數。
此外,還有另一種繼承方式也經常被提及,稱為“組合式繼承”,同樣要修改Student.prototype的指向:
Student.prototype = new Person(); //不用賦值,我們不關心原型里的uname和age
Student.prototype.constructor = Student;
使用父類實例作為Student.prototype的值,因為父類實例的__proto__一定指向父構造函數的原型對象。這樣做的弊端在於Person總共調用了2次,並且Student.prototype中存在一部分用不到的屬性。
現在,還有最后一個問題:子類的say方法中存在和父類say方法中相同的代碼片段,如何優化這樣的冗余?答案是,調用父構造函數原型中的say方法:
Student.prototype.say = function() {
return Person.prototype.say.call(this) + this.grade + '學生。';
};
直接調用只會打印出undefined,因為this默認指向調用者,即Student.prototype,所以要用call修改this為子類實例。
最后附上一份完整的代碼,采用寄生組合式繼承:
function Person(uname, age) {
this.uname = uname;
this.age = age;
}
Person.prototype.say = function() {
return '我叫' + this.uname + ',今年' + this.age + '歲。';
};
function Student(uname, age, grade) {
Person.call(this, uname, age);
this.grade = grade;
}
Student.prototype = Object.create(Person.prototype);
Student.prototype.constructor = Student; //別忘了把constructor指回來
//Student.__proto__ = Person; //這里挖個坑,后面填
Student.prototype.exam = function() {
console.log('正在考試!');
};
Student.prototype.say = function() {
return Person.prototype.say.call(this) + this.grade + '學生。';
};
var stu = new Student('張三', 16, '高一');
console.log(stu.say()); //輸出:我叫張三,今年16歲。高一學生。
於是原型鏈愈發壯大了:
最后總結一下繼承的思路:
1.首先在子構造函數中用call方法調用父構造函數,修改this指向,實現繼承父類的實例屬性;
2.然后修改子構造函數的prototype的指向,無論是寄生組合式繼承,還是組合式繼承,還是我們自己探索時的修改方式,本質都是把子類的原型鏈掛到父構造函數的原型對象上,從而實現子類繼承父類的實例方法;
3.如果需要給子類新增實例方法,掛到子構造函數的prototype上;
4.如果子類的實例方法需要調用父類的實例方法,通過父構造函數的原型調用,但是要更改this指向。
核心就是原型對象+構造函數組合使用。只使用原型對象,子類無法繼承父類的實例屬性;只使用構造函數,又無法繼承原型對象上的方法。但是雙劍合璧后,就能互補長短。打個不恰當的比方,天龍八部中虛竹救天山童姥那段,天山童姥腿斷了行動不便,但自己有一定法力;虛竹學到輕功之后跑得快,但他不懂得使用內力。最后他倆都成功跑路了。_(:з」∠)_
三、ES6的類和繼承
1、類
ES6中新增了類的概念,使用class關鍵字來定義一個類,語法和其他面向對象的語言很相似。
class Person {
constructor(uname, age) {
this.uname = uname;
this.age = age;
}
say() { //實例方法
return `我叫${this.uname},今年${this.age}歲。`; //模板字符串
}
static staticMethod() { //靜態方法
console.log(`這是靜態方法`);
}
}
let zhangsan = new Person('張三', 18);
console.log(zhangsan.say());
注意點:
1.實例屬性定義在constructor中。constructor不寫也會默認創建。
2.類中方法前面不需要加function關鍵字,各方法也不需要用逗號隔開。
3.靜態方法前加static關鍵字,實例方法不需要。
4.ES6中靜態屬性無法在class內部定義,需使用傳統的Person.xxx或Person['xxx']。
5.class沒有變量提升,必須先定義類,再通過類實例化對象。
2、繼承
使用extends關鍵字實現繼承:
class Person {
constructor(uname, age) {
this.uname = uname;
this.age = age;
}
say() {
return `我叫${this.uname},今年${this.age}歲。`;
}
}
class Student extends Person {
constructor (uname, age, grade) {
super(uname, age);
this.grade = grade;
}
say() {
return `${super.say()}${this.grade}學生。`;
}
exam() {
console.log('正在考試!');
}
}
let stu = new Student('張三', 16, '高一');
console.log(stu.say());
stu.exam();
這段代碼是前面ES5繼承例子的ES6版本。
注意點:
1.子類的constructor中,必須調用super方法,否則新建實例時會報錯。
2.constructor和say中雖然都用到了super,但是它們的意義不一樣,后文會講。
3、class的本質
先說結論:class的原理基本上還是ES5中那一套,只是寫法上更加簡潔明了。
使用class定義的Student仍然是一個構造函數,原型鏈和之前一模一樣:
>> typeof Person //class定義出來的仍然是一個函數
"function"
>> Person.prototype === (new Person()).__proto__
true
>> Person.prototype.constructor === Person //三角關系一模一樣
true
>> stu.__proto__ instanceof Person //stu的原型是Person的實例
true
>> Object.getOwnPropertyNames(stu)
[ "uname", "age", "grade" ]
>> Object.getOwnPropertyNames(stu.__proto__)
[ "constructor", "say", "exam" ] //Student的say和exam掛在原型里
Object.getOwnPropertyNames()方法獲得指定對象的所有掛在自己身上的屬性和方法的名稱(不會去原型鏈上找),這些名稱組成數組返回。我們通過stu能訪問say和exam,因為它們掛在原型里。
其他的我就不一一試了,直接給結論:
1.class定義的仍然是一個構造函數;
2.class中定義的實例方法,掛在原型對象里;靜態方法,掛在構造函數自己身上;
3.子類有兩個地方用到了super,含義不同:constructor中,super被當做函數看待,super(uname, age)代表調用父類的構造函數,相當於Person.call(this, uname, age),另外super()只能用於constructor中;say方法中的super.say()是將super當對象看待,它指向父類的原型對象Person.prototype,super.say()相當於Person.protoype.say.call(this)。
實際上,ES6的類的絕大部分功能,在ES5中都可以實現。當然,class和extends的引入,使得JS在寫法上更加簡潔明了,在語法上更像其他面向對象編程的語言。所以ES6中的類就是語法糖。
4、繼承內建對象
通過extends同樣可以繼承內建對象:
class MyArray extends Array {
constructor() {
super();
}
}
let a_es6 = new MyArray();
a_es6[1] = 'a';
console.log(a_es6.length); //輸出:2
MyArray的表現和Array幾乎無二。
但是如果想用ES5的做法的話,比如說組合式繼承:
function MyArray2() {
Array.call(this);
}
MyArray2.prototype = new Array();
MyArray2.prototype.constructor = MyArray2;
var a_es5 = new MyArray2();
a_es5[1] = 'a';
console.log(a_es5.length); //輸出:0
我們給a_es5的下標1的位置賦了個值,令人失望的是,length還是0。
為什么這兩個類的行為完全不同?因為在ES5的組合繼承中,首先由子類構造函數創建this的值,MyArray2的this指向新創建的對象,然后再調用父構造函數令Array內部的成員添加到this上,但這種方法無法得到Array內部的成員。來看下面這個例子的模擬:
>> let o = {}
>> Object.getOwnPropertyNames(o)
[] //空列表
>> Array.call(o)
>> Object.getOwnPropertyNames(o)
[] //仍然是空列表
我們通過Array.call(o)試圖讓空對象o獲取Array內所有屬性,但是失敗了,o並沒有發生什么變化。“繼承”自Array的a_es5也是如此,它自己連length屬性都沒有,我們能訪問length是因為它掛在原型上。
但在ES6的class中,通過super()創建的this首先指向父類Array的實例,接着子類再在父類實例的基礎上修改值,因此ES6中this可以訪問父類實例的功能。
四、函數的原型
我們知道,函數除了用function和表達式定義,還可以用new Function(參數1,參數2,...,函數體)的方式定義:
>> var f = new Function('a', 'b', 'console.log(a + b);')
>> f(1, 2)
3
換而言之,所有的函數都是Function這個構造函數的實例,都是對象,函數的內部既有prototype屬性也有__proto__屬性,前者指向自己的原型對象,后者指向Funtion的原型對象。當我們創建函數的時候(無論是用ES5中哪種方式去創建),new大致做了這些事情:
1.在內存中創建一個空對象,這里記作F;
2.令F.__proto__指向Function.prototype;
3.用new Object()再創建另一個對象,記作proto;
4.令proto.constructor指向F;
5.令F.prototype指向proto;
6.返回F。
特別地,ES6使用extends進行繼承后,子類的__proto__將指向父類以表示繼承關系,而不是Function.prototype。(還記得前面在寄生組合式繼承的代碼里挖了個坑嗎?)
再來看Function函數。它也有原型對象,Function.prototype。另一方面,作為對象,ES5規定Function的__proto__屬性就指向它自己的原型對象,即Function.__proto__全等於Function.prototype。
Function.prototype也是對象,由new Object創建,因此Function.prototype.__proto__指向Object.prototype。
現在一切都指向Object.prototype,即Object的原型對象,這是除了null以外站在原型鏈頂端的人,它的上面,Object.prototype.__proto__為null。
原型鏈終極圖:
五、原型鏈的實際應用
除了上面介紹的繼承和查找方向,原型鏈也可以反過來用,封掉不想給別人用的內置方法。以b漫為例,我們想把某張漫畫保存下來,首先打開漫畫的閱讀頁面:
可以看到2張圖就是2個canvas。從canvas提取圖像信息,我們想到了toDataUrl和toBlob方法。前者返回一個經過base64加密的data url,后者返回Blob對象,不管哪個,最后都能轉換成圖片文件:
>> let c = document.getElementsByTagName('canvas')[0]
>> c.toDataUrl
undefined //沒了
>> c.toBlob
undefined //這個也沒了
canvas對象的類為HTMLCanvasElement,toDataUrl和toBlob定義於其原型對象上。經查找,JS代碼中有一個立即執行函數把這兩個屬性指向了undefined,就在reader.xxxxxxxxxx.js這個文件里面(這10個x是占位符,均為數字和小寫英文字母之一,比如說我現在的文件名叫reader.8d59f9bef4.js)。
……(前略) function () { try { HTMLCanvasElement.prototype.toDataURL = void 0, HTMLCanvasElement.prototype.toBlob = void 0 } catch (e) {} }(), ……(后略)
不能用ad block之類的擴展把這個js文件屏蔽掉,這將導致canvas元素都不會生成,但可以用其他方法下載圖片,並非本文重點,不詳述:
1.Chrome的sources頁面直接就把圖片展示出來了;
2.火狐給canvas加了個非標准方法mozGetAsFile(),可以轉換為File對象,該方法沒有被封;
3.分析前后的http請求和響應,用爬蟲爬;
4.用fiddler將該文件替換為本地文件,在本地文件中你當然可以注釋掉這兩行代碼。
六、參考資料(擴展閱讀)
4.es5實現繼承