JavaScript 的迷惑行为大赏

发布于 2023-05-10 15:39:20 字数 20739 浏览 71 评论 0

今天来聊一聊 JavaScript 中让人摸不着头的设计失误。

Brendan Eich 在 1995 年加入 Netscape 公司,当时 Netscape 和 Sun 合作开发一个可运行在浏览器上的编程语言,当时 JavaScript 的开发代号是 Mocha。Brendan Eich 花了 10 天完成了第一版的 JavaScript。

由于设计时间太短,语言的一些细节考虑得不够严谨,一些因不可抗因素而无法修复的 bug,加之后来填坑过程中新挖的坑,总之开发者表示很烦...

一起看下 JavaScript 设计的 有哪些?

一、typeof null === 'object'

这是一个众所周知的失误。

对于刚接触 JavaScript 的朋友,有可能会直觉性地、错误地认为 typeof null === 'null',这是不对的。

typeof null === 'object' 的 bug 其实是第一版 JavaScript 就存在了,随着 JavaScript 的流行,很多人提议修复这个 bug,但被拒绝了,因为修改它意味着会破坏现有的代码。历史原因可以看下这篇文章:The history of “typeof null”

在 JavaScript 中,数据类型在底层都是以二进制形式表示的。在初版 JavaScript 中,以 32 位为单位存储一个值,其中包括一个类型标记(1-3 位)和该值的实际数据。类型标记存储在单元的低位。其中有五个:

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

也就是说,最低位如果是 1,那么类型标记长度只有 1 位;如果最低位是 0,那么类型标记长度为 3 位,为四种类型提供两个附加位。

有两个特殊的值:

  • undefined(JSVAL_VOID)是整数 −2^30^(整数范围之外的数字)
  • null(JSVAL_NULL)是机器码空指针。或:一个对象类型标记加上一个零的引用。(null 二进制表示全是 0)

现在我们知道为什么 typeof 会认为 null 是一个对象了,它检查了 null 的类型标记,且类型标记表示 object。以下是该引擎的 typeof 代码。

JS_PUBLIC_API(JSType)
JS_TypeOfValue(JSContext *cx, jsval v)
{
  JSType type = JSTYPE_VOID;
  JSObject *obj;
  JSObjectOps *ops;
  JSClass *clasp;

  CHECK_REQUEST(cx);
  if (JSVAL_IS_VOID(v)) { // (1)
    type = JSTYPE_VOID;
  } else if (JSVAL_IS_OBJECT(v)) { // (2)
    obj = JSVAL_TO_OBJECT(v);
    if (obj &&
      (ops = obj -> map -> ops,
        ops == & js_ObjectOps
          ? (clasp = OBJ_GET_CLASS(cx, obj),
            clasp -> call || clasp == & js_FunctionClass) // (3,4)
          : ops -> call != 0)) { // (3)
      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)首先检查值 v 是否 undefined(VOID)。通过 == 比较值是否相同:
#define JSVAL_IS_VOID(v)  ((v) == JSVAL_VOID)
  • 下一个检查(2)是该值是否具有对象标记。如果它另外可以调用(3)或它的内部属性 [[Class]] 将其标记为一个函数(4),则 v 是一个函数。否则,它是一个对象。这是由 typeof null 产生的结果。
  • 随后的检查是数字,字符串和布尔值。甚至没有显式的 null 检查,可以由以下 C 宏执行。
#define JSVAL_IS_NULL(v)  ((v) == JSVAL_NULL)

这似乎是一个非常明显的错误,但请不要忘记,只有很少的时间来完成 JavaScript 的第一个版本。

Brendan Eich 在 Twitter 表示这是一个 abstraction leak,可理解为变相承认这是代码的 bug。

null means "no object", undefined =>"no value". Really it's an abstraction leak: null and objects shared a Mocha type tag.

下面列出各种数据类型 typeof 对应的结果:

OperandResult
undefinded"undefined"
null"object"
Boolean value"boolean"
Number value"number"
BigInt value (ES11)"bigint"
String value"string"
Symbol value (ES6)"symbol"
宿主对象(由 JS 环境提供)取决于具体实现
Function"function"
All other values"object"

typeof returning "object" for null is a bug. It can’t be fixed, because that would break existing code. Note that a function is also an object, but typeof makes a distinction. Arrays, on the other hand, are considered objects by it.

某文章表示:在 JavaScript V8 引擎中,针对 typeof null === 'object' 这种“不规范”情况,对 null 提前做了一层判断。假设在 V8 中把这行代码删掉, typeof null 会返回 undefined

GotoIf(InstanceTypeEqual(instance_type, ODDBALL_TYPE), &if_oddball);

好了,关于 typeof null === 'object' 的话题告一段落。

二、typeof NaN === 'number'

不确定这个算不算一个设计失误,但毫无疑问这是反直觉的。

关于 NaN,还有一些很有趣的知识点,推荐一个 Slide,非常值得一看:Idiosyncrasies of NaN v2

三、NaN、isNaN()、Number.isNaN()

在 JavaScript 中,NaN 是一个看起来很莫名其妙的存在。当然 NaN 不是只有 JavaScript 才存在的。其他语言也是有的。

我觉得应该是这样:"NaN" actually stands for "Not a NaN".

1. NaN

NaN 是一个全局对象属性,其属性的初始值就是 NaN,和 Number.NaN的值一样。

NaN 是 JavaScript 中唯一一个不等于自身的值。虽然这个设计其实理由很充分(参照前面推荐的那个 Slide,在 IEEE 754 规范中有非常多的二进制序列都可以被当做 NaN,所以任意计算出两个 NaN,它们在二进制表示上很可能不同),但不管怎样,这个还是非常值得吐槽...

NaN == NaN // false
NaN === NaN // false
Number.NaN === NaN // false

2. isNaN()

isNaN() 是全局对象提供的一个方法,它的命名和行为非常让人费解:

  • 它并不只是用来判断一个值是否为 NaN,因为所有对于所有非数字类型的值它也返回 true
  • 但也不能说它是用来判断一个值是否为数值的,因为根据前文,NaN 的类型是 number,应当被认为是一个数值。

isNaN() 方法,当参数值是 NaN 或者将参数转换为数字的结果为 NaN,则返回 true,否则返回 false。因此,它不能用来判断是否严格等于 NaN

isNaN(NaN) // true
isNaN('hello world') // true

3. Number.isNaN()

ES6 提供了 Number.isNaN() 方法,用于判断一个值是否严格等于 NaN,终于是拨乱反正了。

和全局函数 isNaN() 相比,Number.isNaN() 不会自行将参数转换成数组,它会先判断参数是否为数字类型,如不是数字类型则直接返回 false,接着判断参数值是否为 NaN,若是则返回 true

Number.isNaN(NaN) // true
Number.isNaN(Number.NaN) // true
Number.isNaN(0 / 0) // true
Number.isNaN('hello world') // false
Number.isNaN(undefined) // false

4. 总结几种判断值是否为 NaN 的方法

// 1. 利用 NaN 的特性,JavaScript 中唯一一个不等于自身的值
function myIsNaN(v) {
  return v !== v
}

// 2. 利用 ES5 的 isNaN() 全局方法
function myIsNaN(v) {
  return typeof v === 'number' && isNaN(v)
}

// 3. 利用 ES6 的 Number.isNaN() 方法
function myIsNaN(v) {
  return Number.isNaN(v)
}

// 4. 利用 ES6 的 Object.is() 方法
function myIsNaN(v) {
  return Object.is(v, NaN)
}

四、==、=== 与 Object.is()

JavaScript 是一种弱类型语言,存在隐式类型转换。因此,== 的行为非常令人费解。

[] == ![] // true
2 == '2' // true

所以,各种 JavaScript 书籍都推荐使用 === 替代 ==(仅在 null checking 之类的情况除外)。

但事实上, === 也并不总是靠谱,它至少存在两类例外情况。(Stricter equality in JavaScript

// 1. 前文提到的 NaN
NaN === NaN // false

// 2. +0 与 -0 两者其实是不相等的值
+0 === -0 // true
// 因为
1 / +0 === Infinity // true
1 / -0 === -Infinity // true
Infinity === -Infinity // false


// ES6 是提供的方法
Object.is(NaN, NaN) // true
Object.is(+0, -0) // false

直到 ES6 才有一个可以比较两个值是否严格相等的方法:Object.is(),它对于 === 的这两者例外都做了正确的处理。

如果 ES6 以下,这样实现 Object.is()

function myObjectIs (x, y) {
  if (x === y) {
    // x === 0 => compare via infinity trick
    return x !== 0 || (1 / x === 1 / y)
  }

  // x !== y => return true only if both x and y are NaN
  return x !== x && y !== y
}

关于 ===== 部分值的比较,可以看下 JavaScript-Equality-Table

Always use 3 equals unless you have a good reason to use 2.(除非您有充分的理由 ==,否则始终使用 ===

五、分号自动插入机制(ASI)

此前还专门针对 ASI 内容写了一篇文章:JavaScript ASI 机制详解,不用再纠结分号问题

1. Restricted Productions

据 Brendan Eich 称,JavaScript 最初被设计出来时,上级要求这个语言的语法必须像 Java。所以跟 Java 一样,JavaScript 的语句在解析时,是需要分号分隔的。但是后来出于降低学习成本,或者提高语言的容错性的考虑,他在语法解析中加入了分号自动插入的纠正机制。

这个做法的本意当然是好的,有不少其他语言也是这么处理的(比如 Swift)。但是问题在于,JavaScript 的语法设计得不够安全,导致 ASI 有不少特殊情况无法处理到,在某些情况下会错误地加上分号(在标准文档里这些被称为 Restricted Productions)。

最典型的是 return 语句:

// returns undefined
return
{
  name: 'Frankie'
}

// returns { name: 'Frankie' }
return {
  name: 'Frankie'
}

这导致了 JavaScript 社区写代码时花括号都不换行,这在其他编程语言社区是无法想象的。

2. 漏加分号的问题

有好几种情况要注意(更多 ASI 详情看上面推荐的文章),比如:

// 假设源码是这样的
var a = function (x) { console.log(x) }
(function () {
  console.log('do something')
})()

// 在 JS 解析器的眼里却是这样的,所以这段代码会报错
var a = function (x) { console.log(x) }(function () {
  console.log('do something')
})()

3. semicolon-less

由于以上这些已经是语言特性了,并且无法绕开,无论怎样我们都需要去学习掌握。

对于使用 semicolon-less 风格的朋友,注意一下 5 种情况就可以了:

如果一条语句是以 ([/+- 开头,那么就要注意了。根据 JavaScript 解析器的规则,尽可能读取更多 token 来构成一个完整的语句,而以上这些 token 极有可能与前一个 token 可组成一个合法的语句,所以它不会自动插入分号。

实际项目中,以 /+-作为行首的代码其实是很少的,([ 也是较少的。**当遇到这些情况时,通过在行首手动键入分号 ; 来避免 ASI 规则产生的非预期结果或报错。**这样的记忆成本和出错概率远低于强制分号风格。

还有,ESLint 中有一条规则 no-unexpected-multiline 哦,这样就几乎没有什么负担了。

六、Falsy values

在 JavaScript 中至少有七种假值(在条件表达式中与 false 等价):00nnullundefinedfalse'' 以及 NaN。(其中 0n 是 BigInt 类型的值)

以上六种假值均可通过 Double Not 运算符(!!)来显示转换成 Boolean 类型的 false 值。

七、+、- 操作符相关的隐式类型转换

大致可以这样记:作为二元操作符的 + 会尽可能地把两边的值转为字符串,而 - 和作为一元操作符的 + 则会尽可能地把值转为数字。

('foo' + + 'bar') === 'fooNaN' // true
'3' + 1 // '31'
'3' - 1 // 2
'222' - - '111' // 333

注意: + 两侧只要有一侧是字符串,另一侧的数字则会自动转换成字符串,因为其中存在隐式转换。

八、null、undefined 以及数组的 holes

在一个语言中同时有 nullundefined 两个表示空值的原生类型,乍看起来很难理解,不过这里有一些讨论可以一看:
* Java has null but only for reference types. With untyped JS, the uninitialized value should not be reference-y or convert to 0.
* GitHub 上的一些讨论 - Null for Objects and undefined for primitives

不过数组里的 "holes" 就非常难以理解了。

产生 holes 的方法有两种:一是定义数组字面量时写两个连续的逗号:var a = [1, , 2];二是使用 Array 对象的构造器:new Array(3)

数组的各种方法对于 holes 的处理非常非常非常不一致,有的会跳过(forEach),有的不处理但是保留(map),有的会消除掉 holes(filter),还有的会当成 undefined 来处理(join)。这可以说是 JavaScript 中最大的坑之一,不看文档很难自己理清楚。

具体可以参考这两篇文章:

九、 Array-like objects

在 JavaScript 中,类数组但不是数组的对象不少,这类对象往往有 length 属性、可以被遍历,但缺乏一些数组原型上的方法,用起来非常不便。比如在为了能让 arguments 对象用上 Array.prototype.shift() 方法,我们往往需要先写这样一条语句,非常不便。

var args = Array.prototype.slice.apply(arguments)

在 ES6 中,arguments 对象不再被建议使用,我们可以用 Rest parametersconst fn = (...args) => {}),这样拿到的对象(args)就直接是数组了。

不过在语言标准之外,DOM 标准中也定义了不少 Array-like 的对象,比如 NodeList 和 HTMLCollection。对于这些对象,在 ES6 中我们可以用 spread operator 处理:

const nodeList = document.querySelectorAll('div')
const nodeArray = [...nodeList]

console.log(Object.prototype.toString.call(nodeList))   // [object NodeList]
console.log(Object.prototype.toString.call(nodeArray))   // [object Array]

arguments

在非严格模式下(sloppy mode)下,对 argument 赋值会改变对应的形参

可以看下这篇文章:JavaScript 严格模式详解(8-2 小节)

function foo(x) {
  console.log(x === 1) // true
  arguments[0] = 2
  console.log(x === 2) // true
}

function bar(x) {
  'use strict'
  console.log(x === 1) // true
  arguments[0] = 2
  console.log(x === 2) // false
}

foo(1)
bar(1)

十、函数作用域与变量提升(Variable hoisting)

函数作用域

蝴蝶书上的例子想必大家都看过:

// The closure in loop problem
for (var i = 0; i !== 10; ++i) {
  setTimeout(function() { console.log(i) }, 0)
}

函数级作用域本身没有问题,但是如果只能使用函数级作用域的话,在很多代码中它会显得非常反直觉。比如上面的这个循环例子,对于程序员来说,根据花括号的违章确定变量作用域远比找到外层函数容易得多。

在以前,要解决这个问题,我们只能使用闭包 + IIFE 产生一个新作用域,代码非常难看(其实 with 以及 catch 语句后面跟的代码块也算是块级作用域,但这并不通用)。

幸而现在 ES2015 引入了 let / const,让我们终于可以用上真正的块级作用域。

变量提升

JavaScript 引擎在执行代码的时候,会先处理作用域内所有的变量声明,给变量分配空间(在标准里叫 binding),然后在再执行代码。

这本来没什么问题,但是 var 声明在被分配空间的同时也会被初始化成 undefined(ES5 中的 CreateMutableBinding),这就相当于把 var 声明的变量提升到了函数作用域的开头,也就是所谓的 “hoisting”。

ES6 中引入的 letconst 则实现了 temporal dead zone,虽然进入作用域时用 letconst 声明的变量也会被分配空间,但不会被初始化。在初始化语句之前,如果出现对变量的引用,会抛出 ReferenceError 错误。

// without TDZ
console.log(a) // undefined
var a = 1

// with TDZ
console.log(b) // ReferenceError
let b = 2

在标准层面,这是通过把 CreateMutableBing 内部方法分拆成 CreateMutableBinding 和 InitializeBinding 两步实现的,只有 VarDeclaredNames 才会执行 InitializeBinding 方法。

let、const

然而,letconst 的引入也带来了一个坑。主要是这两个关键词的命名不够精确合理。

const 关键词所定义的是一个 immutable binding(类似于 Java 的 final 关键词),而非真正的常量(constant),这一点对于很多人来说也是反直觉的。

ES6 规范的主笔 Allen Wirfs-Brock 在 ESDiscuss 的一个帖子里表示,如果可以从头再来的话,他会更倾向于选择 let var / let 或者 mut / let 替代现在的这两个关键词,可惜这只能是一个美好的空想了。

for...in

for...in 的问题在于它会遍历到原型链上的属性,这个大家应该都知道的,使用时需要加上 obj.hasOwnProperty(key) 判断才安全。

在 ES6+ 中,使用 for(const key of Object.keys(obj)) 或者 for(const [key, value] of Object.entries()) 可以绕开这个问题。

顺便提一下 Object.keys()Object.getOwnPropertyNames()Reflect.ownKeys() 的区别:我们最常用的一般是 Object.keys() 方法,Object.getOwnPropertyNames() 会把 enumerable: false 的属性名也会加进来,而 Reflect.ownKeys() 在此基础上还会加上 Symbol 类型的键。

with

最主要的问题在于它依赖运行时语义,影响优化。此外还会降低程序可读性、易出错、易泄露全局变量。

function fn(foo, length) {
  with(foo) {
    console.log(length)
  }
}
fn([1, 2, 3], 222) // 3

eval

eval 的问题不在于可以动态执行代码,这种能力无论如何也不能算是语言的缺陷。

Scope

它的第一个坑在于传给 eval 作为参数的代码段能够接触到当前语句所在的闭包。

而用 new Function 动态执行的代码就不会有这个问题。因为 new Function 所生成的函数是确保执行在最外层作用域下的(严格来说标准里不是这样定义的,但实际效果基本可以看作等同,除了 new Function 中可以获取到 arguments 对象)。

function test1() {
  var a = 11
  eval('(a = 22)')
  console.log(a) // 22
}

function test2() {
  var a = 11
  new Function('return (a = 22)')()
  console.log(a) // 11
}

直接调用 vs 间接调用(Direct Call vs Indirect Call)

第二个坑是直接调用 eval 和间接调用的区别。事实上,但是「直接调用」的概念就足以让人迷糊了。

首先,eval 是全局对象上的一个成员函数

但是,window.eval() 这样的调用 不算是 直接调用,因为这个调用的 base 是全局对象而不是一个 "environment record"

接下来的就是历史问题了。

直接调用和间接调用最大的区别在于他们的作用域不同:javascript function test() { var x = 2, y = 4 console.log(eval("x + y")) // Direct call, uses local scope, result is 6 var geval = eval; console.log(eval("x + y")) // Indirect call, uses global scope, throws ReferenceError becausexis undefined }

间接调用 eval 最大的用处(可能也是唯一的实际用处)是在任意地方获取到全局对象(然而 Function('return this')() 也能做到这一点): javascript // 即使是在严格模式下也能起作用 var global = ("indirect", eval)("this");

未来,如果 Jordan Harband 的 System.global 提案能进入到标准的话,这最后一点用处也用不到了……

十一、非严格模式下,赋值给未声明的变量会导致产生一个新的全局变量

Value Properties of the Global Object

平常我们使用到的 NaNInfinityundefined 并不是作为原始值被使用的,而是定义在全局对象上的属性名

在 ES5 之前,这几个属性甚至可以被覆盖,直到 ES5 之后它们才被改成 non-configurable、non-writable。

然而,因为这几个属性名都不是 JavaScript 的保留字,所以可以被用来当做变量名使用。即使全局变量上的这几个属性不可被更改,我们仍可以在自己的作用域里面对这几个名字进行覆盖。

(function () {
  var undefined = 'foo'
  console.log(undefined, typeof undefined) // "foo" "string"
})()

Stateful RegExps

JavaScript 中,正则对象上的函数是有状态的:

const re = /foo/g
console.log(re.test('foo bar')) // true
console.log(re.test('foo bar')) // false

这使得这些方法难以调试,无法做到线程安全。

Brendan Eich 的说法是 这些方法来自于 90 年代的 Perl 4,那时候并没有想到这么多

十二、参考

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据

关于作者

0 文章
0 评论
22 人气
更多

推荐作者

亽野灬性zι浪

文章 0 评论 0

少年亿悲伤

文章 0 评论 0

南七夏

文章 0 评论 0

qq_EJoXxu

文章 0 评论 0

17780639550

文章 0 评论 0

萌逼全场

文章 0 评论 0

    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文