JS 中判断一个函数是否是一个类

我们知道,在 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.keysObject.getOwnPropertyNames 能够返回通过 Object.defineProperty 定义的属性(包括 gettersetter),而 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

    原文作者:Ayonium
    原文地址: https://www.jianshu.com/p/1c05c37f2f4d
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞