Skip to content

数组判断的安全边界

判断一个值是不是数组,JS 提供了四种路径。但其中三种可以被绕过,只有一种在引擎层面做了保证。这篇不重复基础用法,专门讲每种路径被绕过的具体方式和根因。

关于 typeofObject.prototype.toStringinstanceofconstructorArray.isArray 五种类型判断的完整对比,见 js中判断对象的方法


绕过一:toString → Symbol.toStringTag

js
const fake = { [Symbol.toStringTag]: 'Array' };
Object.prototype.toString.call(fake); // '[object Array]' —— 骗过了

绕过原理:Object.prototype.toString 不直接访问内部 [[Class]],而是读取对象的 @@toStringTag well-known symbol 属性。这个属性是可写的。

在 V8 中,Array.prototype[Symbol.toStringTag] 定义为 getter:

js
Object.getOwnPropertyDescriptor(Array.prototype, Symbol.toStringTag);
// { get: [Function], set: undefined, enumerable: false, configurable: true }

这个 getter 返回 'Array'。由于 set: undefined,直接赋值 Array.prototype[Symbol.toStringTag] = 'x' 不会覆盖 getter——你会创建一个新的数据属性,但 Object.prototype.toString 读取 @@toStringTag 时走的是原型链查找,自定义对象上的数据属性会遮蔽原型上的 getter。这就是"伪造"的底层原理。


绕过二:instanceof → 原型链污染

js
function LikeArray() {}
LikeArray.prototype = Object.create(Array.prototype);
const a = new LikeArray();
a instanceof Array;       // true
a.push(1);                // true —— 继承了 Array.prototype 的方法
Array.isArray(a);         // false —— 只有这个是准的

绕过原理:instanceof 沿 [[Prototype]] 链遍历,只要在链上找到 Array.prototype 就返回 trueObject.create(Array.prototype) 创建了一个以 Array.prototype 为原型的对象,将其设为 LikeArray.prototype,然后 new LikeArray() 创建的实例原型链上必然有 Array.prototype

跨 Realm 场景下 instanceof 还会因为比较不同的 Array.prototype 引用而失准(详见 js中判断对象的方法 中的跨 Realm 分析)。


绕过三:constructor → 原型替换

js
function MyArray() {}
MyArray.prototype.constructor = Array;
const a = new MyArray();
a.constructor === Array; // true —— 但 a 不是数组

constructorprototype 上的一个普通自有属性,可以任意覆盖。任何依赖 constructor 做类型判断的代码都应该用 Array.isArray 替换。


为什么 Array.isArray 无法绕过

V8 中的实现(简化):

cpp
// src/builtins/builtins-array.cc
TF_BUILTIN(ArrayIsArray, ArrayBuiltinsAssembler) {
  Node* value = Parameter(1);
  Label is_array(this), not_array(this);

  // 关键:检查对象的 Map(hidden class)中的 instance_type 字段
  GotoIf(IsJSArray(value), &is_array);
  GotoIf(IsJSProxy(value), &call_proxy);
  Goto(&not_array);

  BIND(&is_array);
  Return(TrueConstant());

  BIND(&call_proxy);
  // 对 Proxy 递归调用 Array.isArray(proxy_target)
  TailCallRuntime(Runtime::kArrayIsArray, context, value);

  BIND(&not_array);
  Return(FalseConstant());
}

核心在 IsJSArray 宏——它检查的是 V8 内部 Map 对象的 instance_type 字段。这个字段在堆对象分配时(JSObject::New)由引擎设定,JS 层面没有任何 API 能修改它。这就是"不可绕过"的根本原因。

对 Proxy 的特殊处理:Array.isArray(new Proxy([], {})) 返回 true。规范要求 Array.isArray 对 Proxy 递归解包,检查最终的 target 是否是数组。


伪数组(array-like)的检测

伪数组满足了"有 length 属性和数字索引"的对象,常见的有 argumentsNodeList、jQuery 对象、字符串。判断伪数组需要根据特征组合:

js
function isArrayLike(obj) {
  if (obj == null) return false;
  const len = obj.length;
  // length 必须是 number 且在合法范围内
  if (typeof len !== 'number' || len < 0 || len > Number.MAX_SAFE_INTEGER) return false;
  // 排除函数(有 length 属性但不是类数组)
  if (typeof obj === 'function') return false;
  // 排除真正的数组(用 Array.isArray 区分)
  if (Array.isArray(obj)) return false;
  // 其余有合法 length 的对象视为类数组
  return true;
}

在实际工程中,大多数需要处理伪数组的场景已被 ES6 解构和 rest 参数替代。arguments 对象在现代代码中几乎不应出现——箭头函数没有 arguments,建议用 rest 参数 (...args) 代替。