JavaScript的設計失誤(歷史、現狀以及未來)

sodatea發表於2016-11-18

16 年 9 月份的時候在騰訊的 IMWeb Conf 上就這個話題做過一次分享,如果嫌文章太長太亂的話可以去 GitHub 上看 slides

typeof null === `object`

這是一個眾所周知的失誤。這個問題其實源於初版 JavaScript 實現中的一個 bug

注:上述文章引用的程式碼其實已經不算是最初版本的實現了,但 Brendan Eich 自己也在 Twitter 上表示,這是一個 abstraction leak,可以理解為變相承認這是程式碼 bug。

typeof NaN === `number`

不太確定這個算不算一個設計失誤,但毫無疑問這是反直覺的。

說到 NaN,其實還有更多有趣的知識點,這個 13 分鐘的 talk 非常值得一看:Idiosyncrasies of NaN

NaN, isNaN(), Number.isNaN()

NaN 是 JavaScript 中唯一一個不等於自身的值。雖然這個設計其實理由很充分(參見前面推薦的那個 talk,在 IEE 754 規範中有非常多的二進位制序列都可以被當做 NaN,所以任意計算出兩個 NaN,它們在二進位制表示上很可能不同),但不管怎樣,這個還是非常值得吐槽……

isNaN() 這個函式的命名和行為非常讓人費解:

  1. 它並不只是用來判斷一個值是否為 NaN,因為所有對於所有非數值型的值它也返回 true
  2. 但也不能說它是用來判斷一個值是否為數值的,因為根據前文,NaN 的型別是 number,應當被認為是一個數值。

還好,ES2015 引入了 Number.isNaN() 來撥亂反正:它只對引數為 NaN 的情況返回 true。只不過,這個方法來的還是有點晚了……

分號自動插入(Automatic Semicolon insertion,ASI)機制

Restricted Productions

據 Brendan Eich 稱,JavaScript 最初被設計出來時,上級要求這個語言的語法必須像 Java。所以,跟 Java 一樣,JavaScript 的語句在解析時,是 需要分號 分隔的。但是後來出於降低學習成本,或者提高語言的容錯性的考慮,他在語法解析中加入了分號自動插入的糾正機制。

這個做法的本意當然是好的,有不少其他語言也是這麼處理的(比如 Swift)。但是問題在於,JavaScript 的語法設計得不夠安全,導致 ASI 有不少特殊情況無法處理到,在某些情況下會錯誤地加上分號(在標準文件裡這些被稱為 Restricted Productions)。
最典型的是 return 語句

// returns undefined
return
{
    status: true
};

// returns { status: true }
return {
    status: true
};

這導致了 JavaScript 社群寫程式碼時花括號都不換行,這在其他程式語言社群是無法想象的。

漏加分號的情況

還有另外一種 ASI 不會糾正的問題:

var a = function(x) { console.log(x) }
(function() {
    // do something
})()

這類問題通常出現在兩個檔案被壓縮後再拼接到一起時。

semicolon-less 風格

Restricted Productions 的問題已經是語言的特性了並且無法繞開,無論如何我們都需要去學習掌握。

而前面提到的第二類問題,ASI 失靈的情況,採用強制分號的程式碼風格其實很難規避(現在有了 ESLint 的 no-unexpected-multiline 規則會後會容易點)。更好的辦法是實踐 semicolon-less 的程式碼風格,不在行末寫分號,而是當行首出現 + - [ ( / 這五個操作符之一時再往前加分號,這樣的記憶成本和出錯概率遠低於強制分號風格。

==, ===Object.is()

JavaScript 是一種弱型別語言,存在隱式型別轉換。因此,== 的行為非常令人費解:

[] == ![]   // true
3 == `3`    // true

所以各種 JavaScript 書籍都推薦使用 === 替代 == (僅在 null checking 之類的情況時可以除外)

但事實上,=== 也並不總是靠譜,它至少存在兩類例外情況

  1. 前文提到過, NaN === NaN 會返回 false
  2. +0 === -0 會返回 true,然而這兩個其實是不相等的值(1 / +0 === Infinity; 1 / -0 === -Infinity

一直到 ES2015,我們才有了一個可以比較兩個值是否嚴格相等的方法:Object.is(),它對於 === 的這兩種例外都做了正確的處理。

關於 == 的坑,這裡有份 JavaScript Equality Table 可以一看

Falsy values

JavaScript 中至少有六種假值(在條件表示式中與 false 等價):0, null, undefined, false, `` 以及 NaN

+- 操作符相關的隱式型別轉換

大致可以這樣記:作為二元操作符的 + 會盡可能地把兩邊的值轉為字串,而 - 和作為一元操作符的 + 則會盡可能地把值轉為數字。

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

nullundefined 以及陣列的 “holes”

在一個語言中同時有 nullundefined 兩個表示空值的原生型別,乍看起來很難理解,不過這裡有一些討論可以一看:

不過陣列裡的 “holes” 就非常難以理解了。

產生 holes 的方法有兩種:一是定義陣列字面量時寫兩個連續的逗號:var a = [1, , 2];二是使用 Array 物件的構造器,new Array(3)

陣列的各種方法對於 holes 的處理非常非常非常不一致,有的會跳過(forEach),有的不處理但是保留(map),有的會消除掉 holes(filter),還有的會當成 undefined 來處理(join)。這可以說是 JavaScript 中最大的坑之一,不看文件很難自己理清楚。

具體可以參考這兩篇文章:

Array-like objects

JavaScript 中,類陣列但不是陣列的物件不少,這類物件往往有 length 屬性、可以被遍歷,但缺乏一些陣列原型上的方法,用起來非常不便。比如在為了能讓 arguments 物件用上 shift 方法,我們往往需要先寫這樣一條語句:

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

非常不便。

在 ES2015 中,arguments 物件不再被建議使用,我們可以用 rest parameter (function f(...args) {})代替,這樣拿到的物件就直接是陣列了。

不過在語言標準之外,DOM 標準中也定義了不少 Array-like 的物件,比如 NodeListHTMLCollection
對於這些物件,在 ES2015 中我們可以用 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)下,對 arguments 賦值會改變對應的 形參

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

function b(x) {
    `use strict`;
    console.log(x === 1);
    arguments[0] = 2;
    console.log(x === 2);    // false
}

a(1);
b(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 宣告在被分配空間的同時也會被初始化成 undefinedES5 中的 CreateMutableBinding),這就相當於把 var 宣告的變數提升到了函式作用域的開頭,也就是所謂的 “hoisting”。

ES2015 中引入的 let / const 則實現了 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 ),這一點對於很多人來說也是反直覺的。

ES2015 規範的主筆 Allen Wirfs-Brock 在 ESDiscuss 的一個帖子裡 表示,如果可以從頭再來的話,他會更傾向於選擇 let var / let 或者 mut / let 替代現在的這兩個關鍵詞,可惜這隻能是一個美好的空想了。

for...in

for...in 的問題在於它會遍歷到原型鏈上的屬性,這個大家應該都知道的,使用時需要加上 obj.hasOwnProperty(key) 判斷才安全。

在 ES2015+ 中,使用 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 f(foo, length) {
  with (foo) {
    console.log(length)
  }
}
f([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”

接下來的就是歷史問題了。

直接呼叫和間接呼叫最大的區別在於他們的作用域不同:

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(geval("x + y"))   // Indirect call, uses global scope, throws ReferenceError because `x` is undefined
}

間接呼叫 eval 最大的用處(可能也是唯一的實際用處)是在任意地方獲取到全域性物件(然而 Function(`return this`)() 也能做到這一點):

// 即使是在嚴格模式下也能起作用
var global = ("indirect", eval)("this");

未來,如果 Jordan Harband 的 System.global 提案能進入到標準的話,這最後一點用處也用不到了……

非嚴格模式下,賦值給未宣告的變數會導致產生一個新的全域性變數

Value Properties of the Global Object

我們平時用到的 NaN, Infinity, undefined 並不是作為 primitive value 被使用(而 null 是 primitive value),而是定義在全域性物件上的屬性名

在 ES5 之前,這幾個屬性甚至可以被覆蓋,直到 ES5 之後它們才被改成 non-configurable、non-writable。

然而,因為這幾個屬性名都不是 JavaScript 的保留字,所以可以被用來當做變數名使用。即使全域性變數上的這幾個屬性不可被更改,我們仍然可以在自己的作用域裡面對這幾個名字進行覆蓋。

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

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,那時候並沒有想到這麼多

weird syntax of import

現在的語法是 import x from `y`,但是改成 from y import x 的話,會更自然、更方便觸發 IDE / 編輯器的自動補全。

Brendan Eich 也在 ESDiscuss 的一篇帖子中對此表達過後悔之情。

另外,儘管很多人認為 ES2015 的模組系統是借鑑了 python,但事實上,根據 ES2015 模組系統的設計者 Dave Herman 的說法,這個模組系統的理念主要是參考了 Racket,跟 python 半毛錢關係都沒有(除了最後定下來的語法恰好有點相似)。

Array constructor inconsistency

這是 API 設計的失誤。

// <https://github.com/DavidBruant/ECMAScript-regrets/issues/21>
Array(1, 2, 3); // [1, 2, 3]
Array(2, 3); // [2, 3]
Array(3); // [,,] WAT

Primitive type wrappers

JavaScript 中的 primitive type wrappers (Boolean / Number / String…)絕對是臭名昭著,各種合理或不合理的比較規則和型別轉換能把人折騰瘋,這裡就不詳述了(其實是太懶了寫不動了

Brendan Eich 在 JS2/ES4 中曾經試圖用激進的強型別方案一勞永逸地解決掉這個問題,不過後來 ES4 不了了之了,這個提案也就被擱置在一邊了。

Date Object

JavaScript 裡的 Date 物件是直接抄的 Java Date 類,所以這些問題其實都繼承自 Java(其實不少方法在 Java 裡都已經 deprecated 了,只是 JavaScript 演進了這麼多年,一直沒有加進 Date 的替代品)。

Date.getMonth()

Date.getMonth() 的返回值是從 0 開始計的。

const d = new Date(`2016-07-14`)
d.getDate()     // 14
d.getYear()     // 116 (2016 - 1900)
d.getMonth()    // 6

Date comparison

$ node
> d1 = new Date(`2016-01-01`)
Fri Jan 01 2016 08:00:00 GMT+0800 (CST)
> d2 = new Date(`2016-01-01`)
Fri Jan 01 2016 08:00:00 GMT+0800 (CST)
> d1 <= d2
true
> d1 >= d2
true
> d1 == d2
false
> d1 === d2
false

原因是抽象關係比較演算法中,左右值在一定情況下會先 ToNumber,而抽象相等比較時則不會做轉換,所以造成了這種情況。

具體可以看這篇 ATA 文章

prototype

prototype 有兩個槽點。

第一點是它的命名不合理。

There are only two hard things in Computer Science: cache invalidation and naming things
— Phil Karlton

JavaScript 中的各種詞不達意的命名已經讓人無力吐槽了……

作為物件屬性的 prototype,其實根本就不是我們討論原型繼承機制時說的「原型」概念。
fallbackOfObjectsCreatedWithNew would be a better name.

而物件真正意義上的原型,在 ES5 引入 Object.getPrototypeOf() 方法之前,我們並沒有常規的方法可以獲取。

不過很多瀏覽器都實現了非標準的 __proto__(IE 除外),在 ES2015 中,這一擴充套件屬性也得以標準化了。

Object destructuring syntax

解構賦值時給變數起別名的語法有點讓人費解:

// <https://twitter.com/Louis_Remi/status/748816910683283456>
// 這裡解構出來的新變數是 y,它等價於 z.x
// 冒號可以讀作 `as`,方便記憶
let { x: y } = z

雖然這並不能算作是設計失誤(畢竟很多其他語言也這麼做),但畢竟不算直觀。

其他參考文獻

https://esdiscuss.org/topic/10-biggest-js-pitfalls#content-5
https://esdiscuss.org/topic/excluding-features-from-sloppy-mode
http://wtfjs.com/
http://bonsaiden.github.io/JavaScript-Garden/
https://www.nczonline.net/blog/2012/07/24/thoughts-on-ecmascript-6-and-new-syntax/


相關文章