我們知道,在 ES2015 之后,JavaScript 終于迎來了標(biāo)準(zhǔn)的定義類的方式,然而在這之前,定義類只能使用函數(shù)定義式加綁 prototype 來實(shí)現(xiàn)。此外,雖然現(xiàn)在 JS 支持直接定義類了,但很多時(shí)候,開發(fā)者還是會(huì)選擇將其轉(zhuǎn)譯成 ES5 的代碼,比如使用 Babel,或者 TypeScript 時(shí)就經(jīng)常這么做。
這篇文章,我們來探討下應(yīng)該如何優(yōu)雅地實(shí)現(xiàn)判斷一個(gè)給定的對(duì)象,是否是一個(gè)類,或者說,可能被當(dāng)作類來識(shí)別(重心在 ES5 風(fēng)格寫的類上,因?yàn)?ES2015 的類很容易判斷)。
如果你使用 VSCode 編輯器來開發(fā),那么當(dāng)你在使用 ES5 的方式定義一個(gè)類時(shí),編輯器會(huì)提示你這個(gè)構(gòu)造函數(shù)可能轉(zhuǎn)換為類,并且提供一鍵轉(zhuǎn)換功能,非常強(qiáng)大。
但是 VSCode 又是怎么實(shí)現(xiàn)的呢?其實(shí)很簡單,VSCode 有很好的語法識(shí)別系統(tǒng),它能根據(jù)上下文分析你的代碼結(jié)構(gòu),從而判斷出你定義的一個(gè)“類構(gòu)造”函數(shù)是否符合類的特征。在這里,我將引用一個(gè)“數(shù)字指紋”的概念來對(duì)其進(jìn)行闡述。因?yàn)榧词刮覀兌x一個(gè)類的方式很多,但實(shí)際上在轉(zhuǎn)換成或者本身就使用 ES5 去定義類的時(shí)候,這個(gè)類會(huì)留下程序能夠識(shí)別的數(shù)字指紋特征。而根據(jù)這個(gè)特征,我們就能夠判斷出這個(gè)最終的函數(shù)是否應(yīng)該是一個(gè)可實(shí)例化的類。
VSCode 利用指紋特征并通過上下文來分析代碼結(jié)構(gòu),這能夠讓它在幾乎 100% 的情況下正確判斷,但是在我們自己的程序中,要做到融合上下文并不容易,并且其實(shí)也不是那么必要。所以接下來我將提供的是一個(gè)簡化的版本,但它依舊能夠提供幾乎 98% 的識(shí)別率。
廢話不多說,直接上代碼:
/**
* Checks if an object could be an instantiable class.
* @param {any} obj
* @param {boolean} strict
* @returns {boolean}
*/
function couldBeClass(obj, strict) {
if (typeof obj != "function") return false;
var str = obj.toString();
// async function or arrow function
if (obj.prototype === undefined) return false;
// generator function or malformed definition
if (obj.prototype.constructor !== obj) return false;
// ES6 class
if (str.slice(0, 5) == "class") return true;
// has own prototype properties
if (Object.getOwnPropertyNames(obj.prototype).length >= 2) return true;
// anonymous function
if (/^function\s+\(|^function\s+anonymous\(/.test(str)) return false;
// ES5 class without `this` in the body and the name's first character
// upper-cased.
if (strict && /^function\s+[A-Z]/.test(str)) return true;
// has `this` in the body
if (/\b\(this\b|\bthis[\.\[]\b/.test(str)) {
// not strict or ES5 class generated by babel
if (!strict || /classCallCheck\(this/.test(str)) return true;
return /^function\sdefault_\d+\s*\(/.test(str);
}
return false;
}
exports.couldBeClass = couldBeClass;
exports.default = couldBeClass;
現(xiàn)在,再對(duì)每一行 if 進(jìn)行解釋一下。
第一句不用多數(shù),如果不是函數(shù),肯定返回 false,即使是 ES2015 的類,其類型也是一個(gè) function。
然后。我們對(duì) prototype 進(jìn)行分析,你肯定很好奇什么樣的函數(shù)會(huì)沒有 prototype,沒錯(cuò),就是箭頭(=>)函數(shù)和異步(async)函數(shù)。
接下來,我們判斷 constructor,一個(gè)正常的類,只要它的定義是合法的,那么它的 prototype.constructor 就會(huì)指向類本身,否則它就不能是一個(gè)類,例如生成器函數(shù),其 prototype.constructor 始終指向 GeneratorFunction 基函數(shù),而不是你定義時(shí)的那個(gè)函數(shù)。因此它是不能被當(dāng)作類來使用的,如果你嘗試去 new 一個(gè)迭代器函數(shù),解釋器還會(huì)拋出錯(cuò)誤。
而我什么要強(qiáng)調(diào)類的定義必須是合法的呢?因?yàn)閷?shí)際上,可能是網(wǎng)上教程錯(cuò)誤的原因,導(dǎo)致很多人可能會(huì)像下面這樣去定義一個(gè)“類”:
function MyClass() {}
MyClass.prototype = {
// ...
}
// 或者在繼承時(shí)
function AnotherClass() {}
AnotherClass.prototype = new MyClass();
請(qǐng)務(wù)必注意這兩種寫法都是錯(cuò)誤的,并且一定不要這么寫。幸好,現(xiàn)在我們都可以用 ES2015 的寫法來定義類了,即使轉(zhuǎn)譯為 ES5,編譯器也會(huì)正確地編譯為正確的姿勢,而不用我們?nèi)タ紤]應(yīng)該怎么實(shí)現(xiàn)。不過我覺得還是值得提一下,下面這種定義方式才是正確的:
function MyClass() {}
MyClass.prototype.show = function show() {
// ...
}
console.log(MyClass.prototype.constructor === MyClass); // true 永遠(yuǎn)可用并指向類自身
// 繼承
function AnotherClass() {}
Object.setPrototypeOf(AnotherClass, MyClass); // 繼承靜態(tài)方法和靜態(tài)屬性
function Super() { this.construcor = AnotherClass }
Super.prototype = MyClass.prototype;
AnotherClass.prototype = new Super(); // 這樣做的好處是不需要傳任何參數(shù)
console.log(AnotherClass.prototype.constructor === AnotherClass); // true
接下來我們繼續(xù)看 str.slice(0, 5) == "class",使用 ES6 定義的類,它的 toString() 方法返回的文本就是我們定義類時(shí)的樣子,不會(huì)轉(zhuǎn)換為函數(shù),因此總是以 class 開頭。
然后我們判斷函數(shù)的 prototype (中屬性和方法)的長度/個(gè)數(shù),之所以條件 >= 2,是因?yàn)橐粋€(gè)類,無論是 ES5 還是 ES2015,永遠(yuǎn)有一個(gè) constructor 屬性在 prototype 中(這是標(biāo)準(zhǔn)函數(shù)類型的特有屬性,除了箭頭函數(shù)和異步函數(shù),如上面所說),也就是其長度永遠(yuǎn)至少有一個(gè),我們不能判斷一個(gè) prototype 中沒有自定義屬性和方法的函數(shù)為一個(gè)類,因?yàn)槠胀ê瘮?shù)是不需要它們的。但當(dāng)它有時(shí),它基本上就是一個(gè)類了。
注意我這里使用了 Object.getOwnPropertyNames 而不是 Object.keys,Object.getOwnPropertyNames 能夠返回通過 Object.defineProperty 定義的屬性(包括 getter 和 setter),而 Object.keys 則不能。
當(dāng)以上檢測都不通過后,我們只能從函數(shù)體中判斷來判斷了。首先我們要忽略匿名函數(shù)(/^function\s+\(|^function\s+anonymous\(/.test(str)),即函數(shù)定義式中沒有指定名字,或者名字為 anonymous 的函數(shù),這兩種函數(shù)是下面這樣的:
var func = function () {}; // 注意在現(xiàn)代引擎中,`func.name` 為 `func`
var func2 = new Function("", ""); // 注意 `func2.name` 為 `anonymous`
由于 Function.name 在這兩種匿名函數(shù)中都是有值的,因此我們需要通過函數(shù)體來判斷它是否是匿名函數(shù),而不能依靠 Function.name。
接下來,我們?cè)僭趪?yán)格模式下判斷函數(shù)名是否首字母為大寫(strict && /^function\s+[A-Z]/.test(str))。之所以這個(gè)判斷在嚴(yán)格模式中,是因?yàn)楝F(xiàn)代建議的 JS 寫法,類名是應(yīng)該要使用首字母大寫的駝峰命名法的,而函數(shù)與方法,則使用首字母小寫的駝峰命名法。因此,嚴(yán)格模式指的是在嚴(yán)格書寫格式的前提下,將所有首字母大寫得函數(shù)識(shí)別為類。
最后,我們通過判斷函數(shù)體中是否存在 this 偽變量來判斷它是否應(yīng)該是一個(gè)函數(shù)還是類(/\b\(this\b|\bthis[\.\[]\b/.test(str)),因?yàn)楹瘮?shù)中是沒有 this 變量的。但它依舊可能是一個(gè)類方法而不是類,因此我們還需要特別判斷,在非 strict 模式時(shí),始終將包含 this 的函數(shù)識(shí)別為類,因?yàn)楦鶕?jù)統(tǒng)計(jì)學(xué),大多數(shù)人寫方法時(shí)不會(huì)指定一個(gè)名稱,而是直接使用匿名函數(shù),例如:
MyClass.prototype.show = function() {}; // 即使是編譯器,也會(huì)生成這樣,而不是 function show() {}
而我們前面已經(jīng)將匿名函數(shù)判斷為 false 了,因此在非嚴(yán)格模式時(shí),我們這么判斷是沒有問題的,而如果啟用嚴(yán)格模式,那么如前面所說的,則會(huì)先判斷是否首字母大寫。如果不通過,我們?cè)倥袛嗪瘮?shù)體中是否存在一個(gè) classCallCheck 函數(shù)的調(diào)用,它是 babel 在轉(zhuǎn)譯時(shí)自動(dòng)插入的(實(shí)際上是 __classCallCheck),用來檢測用戶是否將類當(dāng)作函數(shù)調(diào)用,而這個(gè)行為在標(biāo)準(zhǔn) ES2015 類中則是被禁止的。
最后的最后,我們?cè)诤瘮?shù)體中包含 this 變量的函數(shù),判斷函數(shù)是否是一個(gè)默認(rèn)導(dǎo)出的匿名函數(shù),在 TypeScript 中,轉(zhuǎn)譯后的默認(rèn)導(dǎo)出,不管是匿名類還是匿名函數(shù),都會(huì)生成一個(gè) default_1 的函數(shù)/類名。由于當(dāng)前函數(shù)體中存在 this 變量,而它又是一個(gè)默認(rèn)導(dǎo)出,那么它只能是一個(gè)類。
經(jīng)過這些判斷,我們基本上就能夠比較準(zhǔn)確(98%)地判斷一個(gè)函數(shù)是否為一個(gè)類了,并且它也同時(shí)兼容 Babel 和 TypeScript 轉(zhuǎn)譯后的代碼,因此這個(gè)判斷函數(shù)你可以完全在 Babel 和 TypeScript 項(xiàng)目中使用它,而不用擔(dān)心轉(zhuǎn)譯會(huì)帶來什么缺陷。
更多介紹,請(qǐng)看 GitHub could-be-class。