JavaScript 的 typeof 原理小记

4,564 阅读7分钟

篇首语

这篇文章算是对《JavaScript 函数的特性与原型链讲解》 的一个补充,如果你还没有看过那篇文章,可以先大致浏览一下。

写这篇文章的原因是分享完《JavaScript 函数的特性与原型链讲解》后,被问到为什么 typeof 一个函数会返回 "function"。这次就给大家讲解一下 typeof 是如何看待函数的,希望能给大家带来帮助,同时也作为上篇文章的补充。

回顾 JS 的数据类型

我们先来回顾一下最新的 ECMAScript 标准的 8 种数据类型:

7 种原始类型(或称作基本类型):Boolean、Null、Undefined、Number、BigInt、String、Symbol 和 Object。

基本类型(基本数值、基本数据类型)是一种既非对象也无方法的数据。在 JavaScript 中,共有7种基本类型:string,number,bigint,boolean,null,undefined,symbol (ECMAScript 2016新增)。

多数情况下,基本类型直接代表了最底层的语言实现。

所有基本类型的值都是不可改变的。但需要注意的是,基本类型本身和一个赋值为基本类型的变量的区别。变量会被赋予一个新值,而原值不能像数组、对象以及函数那样被改变。

何谓不可改变

var bar = "baz";
bar[0] = "a";
console.log(bar[0]); // "b"
console.log(bar);// "baz"

基本类型的包装对象

除了 null 和 undefined 外的基本类型都有其对应的包装对象:

  • String 为字符串基本类型。
  • Number 为数值基本类型。
  • BigInt 为大整数基本类型。
  • Boolean 为布尔基本类型。
  • Symbol 为字面量基本类型。

这个包裹对象的 valueOf() 方法返回基本类型值。

var s_prim = "foo";
var s_obj = new String(s_prim);

console.log(typeof s_prim); // "string"
console.log(typeof s_obj);  // "object"
console.log(typeof s_obj.valueOf());  // "string"

typeof 可能的返回值表

下表总结了 typeof 可能的返回值:

类型 结果
Undefined "undefined"
Null "object" (见下文)
Boolean "boolean"
Number "number"
BigInt "bigint"
String "string"
Symbol (ECMAScript 2015 新增) "symbol"
宿主对象(由 JS 环境提供) 取决于具体实现
Function 对象 (按照 ECMA-262 规范实现 [[Call]]) "function"
其他任何对象 "object"

分析 typeof 的误区

我之前在分析 typeof 函数和对象的时候,是根据这种方式来分析的:

以前面的 String 为例:

var s_obj = new String(s_prim);
console.log(typeof String); // "function"
// 使用 typeof 调用函数的时候,是把函数当做函数对象来调用的,又因为所有的函数对象都是由 Function 构造出来的,所以:String.__Proto__ === Function.prototype,所以结果返回了 "function"

console.log(typeof s_obj);  // "object"
// 1. 因为 s_obj 是一个对象,它是由 String 构造出来的,所以:S_obj.__proto__ === String.prototype;
// 2. 又因为所有的构造函数都是由 Object 派生出来的,所以:String.prototype.__proto__ === Object.prototype;
// 3. 所以 s_obj 返回 "object";

如果你看过我的这边文章《JavaScript 函数的特性与原型链讲解》 那么应该能理解我为什么会这么分析,因为原型链是这样的。

但是问题点出在,typeof 是提前把函数的原型链终止了吗?

// 因为
Function.prototype.__proto__ === Object.prototype;

这么看来,如果不提前终止原型链,那么 typeof 的结果应该返回 "object" 才对啊。

一小段 JS 历史

带着这个问题,我们来看一点 JavaScript 的历史。

大家都知道 "typeof null" 的 bug,它是在 JavaScript 的第一版就存在的。在这个版本中,值以 32 位的单位存储,包括一个小型类型标记(1-3 位)和值的实际数据。类型标记存储在单元的较低位上。一共有 5 种类型:

  • 000: object,表示这个数据是一个对象的引用。
  • 1: int,表示这个数据是一个 31 位的有符号整型。
  • 010: double,表示这个数据是一个双精度浮点数的引用。
  • 100: string,表示这个数据是一个字符串的引用。
  • 110: boolean,表示这个数据是一个布尔值。

两个值比较特殊:

  • undefined (JSVAL_VOID) 的值是 −2^30 整型(一个超出整型范围的数)。

  • null (JSVAL_NULL) 是机器码空指针。或者是:一个对象类型标记加上一个为零的引用。详情参考下面的源码。

现在应该很清楚为什么 typeof 认为 null 是一个对象了:它检查了类型标记和类型标记表示的对象。

mozilla 的 typeof 的源码

回顾完第一版的历史,我们再来看一下 1998 年 mozilla 的 typeof 的源码(对源码不感兴趣的可以跳过不用看,我会在源码后面再附上一遍结论):

// https://dxr.mozilla.org/classic/source/js/src/jspubtd.h#52

/* Result of typeof operator enumeration. */
typedef enum JSType {
    JSTYPE_VOID,                /* undefined */
    JSTYPE_OBJECT,              /* object */
    JSTYPE_FUNCTION,            /* function */
    JSTYPE_STRING,              /* string */
    JSTYPE_NUMBER,              /* number */
    JSTYPE_BOOLEAN,             /* boolean */
    JSTYPE_LIMIT
} JSType;

// https://dxr.mozilla.org/classic/source/js/src/jsapi.h

#define JS_BIT(n)       ((JSUint32)1 << (n))
#define JS_BITMASK(n)   (JS_BIT(n) - 1)

#define JSVAL_OBJECT            0x0     /* untagged reference to object */
#define JSVAL_INT               0x1     /* tagged 31-bit integer value */

#define JSVAL_TAGBITS           3
#define JSVAL_TAGMASK           JS_BITMASK(JSVAL_TAGBITS)
#define JSVAL_TAG(v)            ((v) & JSVAL_TAGMASK)

#define JSVAL_IS_OBJECT(v)      (JSVAL_TAG(v) == JSVAL_OBJECT)
#define JSVAL_IS_NULL(v)        ((v) == JSVAL_NULL)

#define JSVAL_IS_VOID(v)        ((v) == JSVAL_VOID)

#define OBJECT_TO_JSVAL(obj)    ((jsval)(obj))
#define JSVAL_NULL              OBJECT_TO_JSVAL(0)

#define JSVAL_VOID              INT_TO_JSVAL(0 - JSVAL_INT_POW2(30))
#define INT_TO_JSVAL(i)         (((jsval)(i) << 1) | JSVAL_INT)

#define JSVAL_INT_POW2(n)       ((jsval)1 << (n))

// https://dxr.mozilla.org/classic/source/js/src/jsfun.c#1172
JSClass js_FunctionClass = {
    "Function",
    JSCLASS_HAS_PRIVATE | JSCLASS_NEW_RESOLVE,
    JS_PropertyStub,  fun_delProperty,
    fun_getProperty,  fun_setProperty,
    fun_enumProperty, (JSResolveOp)fun_resolve,
    fun_convert,      fun_finalize,
    NULL,             NULL,
    NULL,             NULL,
    fun_xdrObject,    fun_hasInstance
};

// 这是 typeof 源码部分
// https://dxr.mozilla.org/classic/source/js/src/jsapi.c#333
JS_PUBLIC_API(JSType)
JS_TypeOfValue(JSContext *cx, jsval v)
{
    JSType type = JSTYPE_VOID;
    JSObject *obj;
    JSObjectOps *ops;
    JSClass *clasp;

    CHECK_REQUEST(cx);
    // 判断是否是 undefined,判断步骤为:
    // 1、判断 ((v) == JSVAL_VOID)
    // 2、JSVAL_VOID 是 INT_TO_JSVAL(0 - JSVAL_INT_POW2(30))
    // 3、JSVAL_INT_POW2(30) 是 ((jsval)1 << (n)),即 1 左移 30 位,相当于 2^30;所以,0 - JSVAL_INT_POW2(30) = -2^30
    // 4、INT_TO_JSVAL 是 (((jsval)(i) << 1) | JSVAL_INT) 就转成了:-2147483647 即 -2^31 这个值。
    // 5、所以如果传入的一个值等于 JSVAL_VOID,那这个值就代表了 undefined。
    // PS:这段分析如果有问题请帮我指出
    if (JSVAL_IS_VOID(v)) {
        type = JSTYPE_VOID;
    } 
    // 根据类型标记判断是否是对象
    // JSVAL_IS_OBJECT(v) 判断的步骤为:
    // 1、(JSVAL_TAG(v) == JSVAL_OBJECT)  即:标记类型 == JSVAL_OBJECT,而 JSVAL_OBJECT  =   0x0,就转成了(JSVAL_TAG(v) == 0x0)
    // 2、JSVAL_TAG(v)是:((v) & JSVAL_TAGMASK);
    // 3、JSVAL_TAGMASK 是:JS_BITMASK(JSVAL_TAGBITS);
    // 4、JSVAL_TAGBITS = 3;
    // 5、JS_BITMASK(n)是:(JS_BIT(n) - 1)
    // 6、JS_BIT(n) 是:((JSUint32)1 << (n));
    // 7、这样就判断出来是否是一个对象了
    // 8、那么如何判断 null 呢:
    // 9、因为:JSVAL_NULL 是:OBJECT_TO_JSVAL(0);OBJECT_TO_JSVAL(obj) 是:((jsval)(obj))
    // 10、通过 JSVAL_TAG(JSVAL_NULL) 的计算得到的是 0x0,跟 JSVAL_OBJECT 一致,所以 null 就得到的是一个对象类型了
    else if (JSVAL_IS_OBJECT(v)) {
        // JSVAL_TO_OBJECT(v)是:((JSObject *)JSVAL_TO_GCTHING(v)),即把传入的值转换成了内部的 JSObject 结构的类型。
        obj = JSVAL_TO_OBJECT(v);
        // 这一串判断很复杂,我只能分析个大概:
        // 首先判断是否是 js_ObjectOps,如果是,那么把它转成 JSClass,再看里面是否有 call 属性,
        // 如果没有那么再看看是否是 js_FunctionClass;如果不是,那么再看 call 是否不为0;
        // 综上判断:如果对象有 call 或者 FunctionClass 或者 call 不为 0,那么就返回 "function",其它情况都返回 "object"
        if (obj &&
            (ops = obj->map->ops,
             ops == &js_ObjectOps
             ? (clasp = OBJ_GET_CLASS(cx, obj),
                clasp->call || clasp == &js_FunctionClass)
             : ops->call != 0)) {
            type = JSTYPE_FUNCTION;
        } else {
            type = JSTYPE_OBJECT;
        }
    } else if (JSVAL_IS_NUMBER(v)) {
        type = JSTYPE_NUMBER;
    } else if (JSVAL_IS_STRING(v)) {
        type = JSTYPE_STRING;
    } else if (JSVAL_IS_BOOLEAN(v)) {
        type = JSTYPE_BOOLEAN;
    }
    return type;
}

小结:

1、typeof null 返回 "object",是因为 JS 内部把值的低位为 0x0 的标记定义成对象类型,而 null 被计算成了一个低位具有对象类型标记的值,所以就返回了 "object";

2、typeof 返回 "function",只要一个对象有一个不为 0 的 call 属性/方法,或者是 js_FunctionClass 类型,也就是这个对象里面有 "Function" 标记,那么就返回 "function",其它情况都返回 "object";

ES6 是如何解释的

我们再来看一下现在 ES6 的 typeof 是如何对待函数和对象类型的:

  1. 如果一个对象(Object)没有实现 [[Call]] 内部方法,那么它就返回 object
  2. 如果一个对象(Object)实现了 [[Call]] 内部方法,那么它就返回 function

这跟源码中是否存在 call 很类似。

[[Call]] 是什么

执行与此对象关联的代码。通过函数调用表达式调用。内部方法的参数是一个 this 值和一个包含调用表达式传递给函数的参数的列表。实现此内部方法的对象是可调用的。

这是翻译的原话,简单点说,一个对象如果支持了内部的 [[Call]] 方法,那么它就可以被调用,就变成了函数,所以叫做函数对象。

相应地,如果一个函数对象支持了内部的 [[Construct]] 方法,那么它就可以使用 new 或 super 来调用,这时我们就可以把这个函数对象称为:构造函数。

typeof 原理就大致分析到这里了,我对函数对象和构造函数又有了更清晰的认识,跟各位一起学习进步,如果文章中有不对的地方还请指正。

参考资料:

更新

12-16 增加源码和分析,增加源码小结。