我们知道,在 ES2015 之后,JavaScript 终于迎来了标准的定义类的方式,然而在这之前,定义类只能使用函数定义式加绑 prototype
来实现。此外,虽然现在 JS 支持直接定义类了,但很多时候,开发者还是会选择将其转译成 ES5 的代码,比如使用 Babel,或者 TypeScript 时就经常这么做。
这篇文章,我们来探讨下应该如何优雅地实现判断一个给定的对象,是否是一个类,或者说,可能被当作类来识别(重心在 ES5 风格写的类上,因为 ES2015 的类很容易判断)。
如果你使用 VSCode 编辑器来开发,那么当你在使用 ES5 的方式定义一个类时,编辑器会提示你这个构造函数可能转换为类,并且提供一键转换功能,非常强大。
但是 VSCode 又是怎么实现的呢?其实很简单,VSCode 有很好的语法识别系统,它能根据上下文分析你的代码结构,从而判断出你定义的一个“类构造”函数是否符合类的特征。在这里,我将引用一个“数字指纹”的概念来对其进行阐述。因为即使我们定义一个类的方式很多,但实际上在转换成或者本身就使用 ES5 去定义类的时候,这个类会留下程序能够识别的数字指纹特征。而根据这个特征,我们就能够判断出这个最终的函数是否应该是一个可实例化的类。
VSCode 利用指纹特征并通过上下文来分析代码结构,这能够让它在几乎 100% 的情况下正确判断,但是在我们自己的程序中,要做到融合上下文并不容易,并且其实也不是那么必要。所以接下来我将提供的是一个简化的版本,但它依旧能够提供几乎 98% 的识别率。
废话不多说,直接上代码:
/**
* 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;
现在,再对每一行 if
进行解释一下。
第一句不用多数,如果不是函数,肯定返回 false
,即使是 ES2015 的类,其类型也是一个 function
。
然后。我们对 prototype
进行分析,你肯定很好奇什么样的函数会没有 prototype
,没错,就是箭头(=>
)函数和异步(async
)函数。
接下来,我们判断 constructor
,一个正常的类,只要它的定义是合法的,那么它的 prototype.constructor
就会指向类本身,否则它就不能是一个类,例如生成器函数,其 prototype.constructor
始终指向 GeneratorFunction
基函数,而不是你定义时的那个函数。因此它是不能被当作类来使用的,如果你尝试去 new
一个迭代器函数,解释器还会抛出错误。
而我什么要强调类的定义必须是合法的呢?因为实际上,可能是网上教程错误的原因,导致很多人可能会像下面这样去定义一个“类”:
function MyClass() {}
MyClass.prototype = {
// ...
}
// 或者在继承时
function AnotherClass() {}
AnotherClass.prototype = new MyClass();
请务必注意这两种写法都是错误的,并且一定不要这么写。幸好,现在我们都可以用 ES2015 的写法来定义类了,即使转译为 ES5,编译器也会正确地编译为正确的姿势,而不用我们去考虑应该怎么实现。不过我觉得还是值得提一下,下面这种定义方式才是正确的:
function MyClass() {}
MyClass.prototype.show = function show() {
// ...
}
console.log(MyClass.prototype.constructor === MyClass); // true 永远可用并指向类自身
// 继承
function AnotherClass() {}
Object.setPrototypeOf(AnotherClass, MyClass); // 继承静态方法和静态属性
function Super() { this.construcor = AnotherClass }
Super.prototype = MyClass.prototype;
AnotherClass.prototype = new Super(); // 这样做的好处是不需要传任何参数
console.log(AnotherClass.prototype.constructor === AnotherClass); // true
接下来我们继续看 str.slice(0, 5) == "class"
,使用 ES6 定义的类,它的 toString()
方法返回的文本就是我们定义类时的样子,不会转换为函数,因此总是以 class
开头。
然后我们判断函数的 prototype
(中属性和方法)的长度/个数,之所以条件 >= 2
,是因为一个类,无论是 ES5 还是 ES2015,永远有一个 constructor
属性在 prototype
中(这是标准函数类型的特有属性,除了箭头函数和异步函数,如上面所说),也就是其长度永远至少有一个,我们不能判断一个 prototype
中没有自定义属性和方法的函数为一个类,因为普通函数是不需要它们的。但当它有时,它基本上就是一个类了。
注意我这里使用了 Object.getOwnPropertyNames
而不是 Object.keys
,Object.getOwnPropertyNames
能够返回通过 Object.defineProperty
定义的属性(包括 getter
和 setter
),而 Object.keys
则不能。
当以上检测都不通过后,我们只能从函数体中判断来判断了。首先我们要忽略匿名函数(/^function\s+\(|^function\s+anonymous\(/.test(str)
),即函数定义式中没有指定名字,或者名字为 anonymous
的函数,这两种函数是下面这样的:
var func = function () {}; // 注意在现代引擎中,`func.name` 为 `func`
var func2 = new Function("", ""); // 注意 `func2.name` 为 `anonymous`
由于 Function.name
在这两种匿名函数中都是有值的,因此我们需要通过函数体来判断它是否是匿名函数,而不能依靠 Function.name
。
接下来,我们再在严格模式下判断函数名是否首字母为大写(strict && /^function\s+[A-Z]/.test(str)
)。之所以这个判断在严格模式中,是因为现代建议的 JS 写法,类名是应该要使用首字母大写的驼峰命名法的,而函数与方法,则使用首字母小写的驼峰命名法。因此,严格模式指的是在严格书写格式的前提下,将所有首字母大写得函数识别为类。
最后,我们通过判断函数体中是否存在 this
伪变量来判断它是否应该是一个函数还是类(/\b\(this\b|\bthis[\.\[]\b/.test(str)
),因为函数中是没有 this
变量的。但它依旧可能是一个类方法而不是类,因此我们还需要特别判断,在非 strict
模式时,始终将包含 this
的函数识别为类,因为根据统计学,大多数人写方法时不会指定一个名称,而是直接使用匿名函数,例如:
MyClass.prototype.show = function() {}; // 即使是编译器,也会生成这样,而不是 function show() {}
而我们前面已经将匿名函数判断为 false
了,因此在非严格模式时,我们这么判断是没有问题的,而如果启用严格模式,那么如前面所说的,则会先判断是否首字母大写。如果不通过,我们再判断函数体中是否存在一个 classCallCheck
函数的调用,它是 babel 在转译时自动插入的(实际上是 __classCallCheck
),用来检测用户是否将类当作函数调用,而这个行为在标准 ES2015 类中则是被禁止的。
最后的最后,我们在函数体中包含 this
变量的函数,判断函数是否是一个默认导出的匿名函数,在 TypeScript 中,转译后的默认导出,不管是匿名类还是匿名函数,都会生成一个 default_1
的函数/类名。由于当前函数体中存在 this
变量,而它又是一个默认导出,那么它只能是一个类。
经过这些判断,我们基本上就能够比较准确(98%)地判断一个函数是否为一个类了,并且它也同时兼容 Babel 和 TypeScript 转译后的代码,因此这个判断函数你可以完全在 Babel 和 TypeScript 项目中使用它,而不用担心转译会带来什么缺陷。
更多介绍,请看 GitHub could-be-class。