最近開了一個讀者回饋表單,無論是對文章的感想或是對部落格的感想,有什麼想回饋的都可以填表單跟我說:表單連結

你知道的 JavaScript 知識都有可能是錯的

談完了 JavaScript 的歷史以及包袱以後,我們來談談 JavaScript 本身。

不知道大家有沒有想過一個問題,當你看到一本 JavaScript 書籍或是教學文章的時候,你要怎麼知道作者沒有寫錯?要怎麼知道書裡講的知識是正確的?就如同標題所說,會不會你以前知道的 JavaScript 知識其實是錯的?

因為作者常寫技術文章,所以就相信他嗎?還是說看到 MDN 上面也是這樣寫,因此就信了?又或是大家都這樣講,所以鐵定沒錯?

有些問題是沒有標準答案的,例如說電車難題,不同的流派都會有各自認可的答案,並沒有說哪個就一定是對的。

但幸好程式語言的世界比較單純,當我們提到 JavaScript 的知識時,有兩個地方可以讓你驗證這個知識是否正確,第一個叫做 ECMAScript 規格,第二個大家可以先想想,我們待會再提。

ECMAScript

1995 年的時候 JavaScript 正式推出,那時候只是個可以在 Netscape 上跑的程式語言,如果想要保證跨瀏覽器的支援度的話,需要的是一個標準化的規範,讓各家瀏覽器都遵循著標準。

在 1996 年時網景聯繫了 Ecma International(European Computer Manufacturers Association,歐洲電腦製造商協會),成立了新的技術委員會(Technical Committee),因為是用數字來依序編號,那時候正好編到 39,就是我們現在熟悉的 TC39。

1997 年時,正式發佈了 ECMA-262 第一版,也就是我們俗稱的 ECMAScript 第一版。

為什麼是叫做 ECMAScript,而不是 JavaScript 呢?因為 JavaScript 在那時已經被 Sun 註冊為商標,而且不開放給 Ecma 協會使用,所以沒辦法叫做 JavaScript,因此後來這個標準就稱之為 ECMAScript 了。

至於 JavaScript 的話,你可以視為是去實作 ECMAScript 這個規範的程式語言。當你想知道某個 JavaScript 的功能的規範是什麼,去看 ECMAScript 準沒錯,詳細的行為都會記載在裡面。

而標準是會持續進化的,幾乎每一年都會有新的標準出現,納入新的提案。例如說截止撰寫當下,最新的是 2021 年推出的 ECMAScript 第 12 版,通常又被稱之為 ES12 或是 ES2021,大家常聽到的 ES6 也被稱為 ES2015,代表是在 2015 年推出的 ECMAScript 第 6 版。

如果你對 ECMAScript 的歷史以及這些名詞有興趣,可以參考底下文章:

  1. JavaScript 二十年:創立標準
  2. Day2 [JavaScript 基礎] 淺談 ECMAScript 與 JavaScript
  3. JavaScript 之旅 (1):介紹 ECMA、ECMAScript、JavaScript 和 TC39

接著,我們就來簡單看看 ECMAScript 的規格到底長什麼樣子。

初探 ECMAScript

ECMAScript 的所有版本都可以在這個頁面中找到:https://www.ecma-international.org/publications-and-standards/standards/ecma-262/

可以直接下載 PDF 檔,也可以用線上的 HTML 版本觀看,我會建議大家直接下載 PDF,因為 HTML 似乎是全部內容一起載入,所以要載很久,而且有分頁當掉的風險。

我們打開 ES2021 的規格,會發現這是一個有著 879 頁的超級龐大文件。規格就像是字典一樣,是讓你拿來查的,不是讓你一頁一頁當故事書看的。

但只要能善用搜尋功能,還是很快就可以找到我們想要的段落。底下我們來看看三個不同種類的功能的規格。

String.prototype.repeat

搜尋「String.prototype.repeat」,可以找到目錄的地方,點了目錄就可以直接跳到相對應的段落:22.1.3.16 String.prototype.repeat,內容如下:

大家可以自己先試著讀一遍看看。

規格這種東西其實跟程式有點像,就像是虛擬碼(pseudo code)那樣,所以有很多程式的概念在裡面,例如說上面你就會看到有很多 function call,需要去查看其他 function 的定義才能了解確切到底做了什麼。不過,許多函式從命名就可以推測出做的事情,可見函式命名真的很重要。

上面的規格中基本上告訴了我們兩件以前可能不知道的事情:

  1. 呼叫 repeat 時如果 count 是負數或是無限大,就會出錯
  2. repeat 似乎不是只有字串可以用

第二點其實在 JavaScript 中是滿重要的一件事情,在 ECMAScript 你也會很常看到類似的案例,寫著:「The xxx function is intentionally generic」,這是什麼意思呢?

不知道你有沒有注意到前兩個步驟,分別是:

  1. Let O be ? RequireObjectCoercible(this value).
  2. Let S be ? ToString(O).

我們不是已經是字串了嗎?為什麼還要再 ToString?又為什麼跟 this 有關?

當我們在呼叫 "abc".repeat(3) 的時候,其實是在呼叫 String.prototype.repeat 這個函式,然後 this 是 "abc",因此可以視為是 String.prototype.repeat.call("abc", 3)

既然可以轉換成這樣的呼叫方式,就代表你也可以傳一個不是字串的東西進去,例如說:String.prototype.repeat.call(123, 3),而且不會壞掉,會回傳 "123123123",而這一切都要歸功於規格定義時的延展性。

剛剛我們有在規格中看到它有特別寫說這個函式是故意寫成 generic 的,為的就是不只有字串可以呼叫,只要「可以變成字串」,其實都可以使用這個函式,這也是為什麼規格中的前兩步就是把 this 轉成字串,這樣才能確保非字串也可以使用。

再舉一個更奇耙的例子:

function a(){console.log('hello')}
const result = String.prototype.repeat.call(a, 2)
console.log(result)
// function a(){console.log('hello')}function a(){console.log('hello')}

因為函式可以轉成字串,所以當然也可以丟進去 repeat 裡面,而函式的 toString 方法會回傳函式的整個程式碼,因此才有了最後看到的輸出。

有關於 prototype 跟上面這些東西,我們之後提到 prototype 時應該會再講一次。

總之呢,從規格中我們看出 ECMAScript 的一個特性,就是故意把這些內建的方法做得更廣泛,適用於各種型態,只要能轉成字串都可以丟進去。

typeof

一樣在 PDF 中搜尋 typeof,會找到 13.5.3 The typeof Operator,內容如下:

可以看到 typeof 會先對傳入的值進行一些內部的操作,像是 IsUnresolvableReference 或是 GetValue 之類的,但通常我們關心的只有下面那張表格,就是每個型態會回傳的東西。

表格中可以看到兩件有趣的事情,第一件事情就是著名的 bug,typeof null 會回傳 object,這個 bug 到今天已經變成了規格的一部分。

第二件事情是對於規格來說,object 跟 function 其實內部都是 Object,只差在有沒有實作 [[Call]] 這個方法。

事實上,如果看其他段落的話,你也可以看到在規格中多次使用了 function object 這個說法,就可以知道在規格中 function 就只是「可以被呼叫(callable)的物件」

Comments

接著我們來看一下註解的語法,搜尋 comments,可以找到 12.4 Comments,底下是部分截圖:

可以看到 ECMAScript 是怎麼表示語法的,由上讀到下,Comment 分成兩種,MultiLineComment 跟 SingleLineComment,而底下有各自的定義,MultiLineComment 就是 /* MultiLineCommentChars */,那個黃色小字 opt 指的是 optional,意思就是沒有 MultiLineCommentChars 也可以,例如說 /**/,而底下又繼續往下定義,我就不再一一解釋了。

單行註解的地方則是這樣:

其實意思跟多行註解差不多,而最後一行則是把我們引導至了 B.1.3,我們來看一下那邊的內容:

這邊額外定義了 HTML-like Comments,看起來除了一些特殊狀況之外,都是合法的用法。

我們可以看到這裡將註解的定義再額外增加了三種:

  1. SingleLineHTMLOpenComment
  2. SingleLineHTMLCloseComment
  3. SingleLineDelimitedComment

從規格中我們可以得到新的冷知識,那就是單行註解其實不只有 //,連 HTML 的也可以使用:

<!-- 我是註解
console.log(1)

// 我也是
console.log(2)

--> 我也是
console.log(3)

這就是只能從規格中才能看到的 JavaScript 冷知識。

當有人告訴你 JavaScript 的註解只有 ///* */ 時,你只要有看過 ECMAScript 規格,就可以知道他講的是錯的,其實不只。

以上就是我們從 ECMAScript 中找出的三個小段落,主要是想讓大家稍微看一下規格長什麼樣子。

如果你對閱讀規格有興趣的話,我會建議大家先去看 ES3 的規格,因為 ES3 比起前兩版完整度高了許多,而頁數又少,只有 188 頁而已,是可以當作一般書籍來看,可以一頁一頁翻的那種。

雖然說從 ES6 以後規格的用詞跟底層的機制有一些變動,但我認為從 ES3 開始看規格還是挺不錯的,至少可以用最少的力氣去熟悉規格。

若是看一看開始對規格產生興趣,想要仔細研究的話,可以參考底下兩篇文章:

  1. 翻譯 如何閱讀 ECMAScript Specification 中文版
  2. V8 blog - Understanding ECMAScript

前面我們有提到過有兩個地方可以讓你驗證 JavaScript 的知識是否正確,第一個是 ECMAScript 規格,而第二個則是請大家先自己想一想。

現在要來公布答案了,第二個就是:「JavaScript 引擎原始碼」。

淺談 JavaScript 引擎原始碼

ECMAScript 規格定義了一個程式語言「應該如何」,但實際上到底是怎麼樣,就屬於「實作」的部分了,就像是 PM 定義了一個產品規格,但工程師有可能漏看導致實作錯誤,也有可能因為各種原因沒辦法完全遵守規格,會產生一些差異。

所以假如你在 Chrome 上面發現了一個奇怪的現象,去查了 ECMAScript 規格後也發現行為不同,很有可能就是 Chrome 裡 JavaScript 引擎的實作其實跟規格不一樣,才導致這種差異。

規格只是規格,最後我們使用時還是要看引擎的實作為何。

以 Chrome 來說,背後使用一個叫做 V8 的 JavaScript 引擎,如果你對 JS 引擎一無所知,可以先看一下這個影片:Franziska Hinkelmann: JavaScript engines - how do they even? | JSConf EU

而如果想要看 V8 的程式碼,可以看官方版:https://chromium.googlesource.com/v8/v8.git,也可以看這個在 GitHub 上的版本:https://github.com/v8/v8

在看 ECMAScript 規格時,我們看了三個不同的功能,底下就讓我們來看看這些功能在 V8 中是怎麼被實作的。

String.prototype.repeat

在 V8 中有一個程式語言叫做 Torque,是為了更方便去實作 ECMAScript 中的邏輯而誕生的,語法跟 TypeScript 有點類似,詳情可參考:V8 Torque user manual

有關於 String.prototype.repeat 的相關程式碼在這:src/builtins/string-repeat.tq

// https://tc39.github.io/ecma262/#sec-string.prototype.repeat
transitioning javascript builtin StringPrototypeRepeat(
    js-implicit context: NativeContext, receiver: JSAny)(count: JSAny): String {
  // 1. Let O be ? RequireObjectCoercible(this value).
  // 2. Let S be ? ToString(O).
  const s: String = ToThisString(receiver, kBuiltinName);
  try {
    // 3. Let n be ? ToInteger(count).
    typeswitch (ToInteger_Inline(count)) {
      case (n: Smi): {
        // 4. If n < 0, throw a RangeError exception.
        if (n < 0) goto InvalidCount;
        // 6. If n is 0, return the empty String.
        if (n == 0 || s.length_uint32 == 0) goto EmptyString;
        if (n > kStringMaxLength) goto InvalidStringLength;
        // 7. Return the String value that is made from n copies of S appended
        // together.
        return StringRepeat(s, n);
      }
      case (heapNum: HeapNumber): deferred {
        dcheck(IsNumberNormalized(heapNum));
        const n = LoadHeapNumberValue(heapNum);
        // 4. If n < 0, throw a RangeError exception.
        // 5. If n is +∞, throw a RangeError exception.
        if (n == V8_INFINITY || n < 0.0) goto InvalidCount;
        // 6. If n is 0, return the empty String.
        if (s.length_uint32 == 0) goto EmptyString;
        goto InvalidStringLength;
      }
    }
  } label EmptyString {
    return kEmptyString;
  } label InvalidCount deferred {
    ThrowRangeError(MessageTemplate::kInvalidCountValue, count);
  } label InvalidStringLength deferred {
    ThrowInvalidStringLength(context);
  }
}

可以看到註解其實就是規格的內容,而程式碼就是直接把規格翻譯過去,真正在實作 repeat 的程式碼則是這一段:

builtin StringRepeat(implicit context: Context)(
    string: String, count: Smi): String {
  dcheck(count >= 0);
  dcheck(string != kEmptyString);
  let result: String = kEmptyString;
  let powerOfTwoRepeats: String = string;
  let n: intptr = Convert<intptr>(count);
  while (true) {
    if ((n & 1) == 1) result = result + powerOfTwoRepeats;
    n = n >> 1;
    if (n == 0) break;
    powerOfTwoRepeats = powerOfTwoRepeats + powerOfTwoRepeats;
  }
  return result;
}

從這邊可以看到一個有趣的小細節,那就是在 repeat 的時候,並不是直接跑一個 1 到 n 的迴圈,然後複製 n 遍,這樣太慢了,而是利用了平方求冪的演算法。

舉例來說,假設我們要產生 'a'.repeat(8),一般的做法需要 7 次加法,但其實我們可以先加一次產生 aa,然後再互加產生 aaaa,最後再互加一次,就可以用三次加法做出 8 次重複(2^3 = 8),省下了不少字串相加的操作。

從中可以看出,像是 JavaScript 引擎這種接近底層的實作,必須要把效能也考慮在內。

typeof

V8 裡面對於 typeof 的定義在這裡,註解裡面一樣有寫到相關的 spec 段落:src/objects/objects.h#466

// ES6 section 12.5.6 The typeof Operator
static Handle<String> TypeOf(Isolate* isolate, Handle<Object> object);

實作則是在這邊:src/objects/objects.cc#870

// static
Handle<String> Object::TypeOf(Isolate* isolate, Handle<Object> object) {
  if (object->IsNumber()) return isolate->factory()->number_string();
  if (object->IsOddball())
    return handle(Oddball::cast(*object).type_of(), isolate);
  if (object->IsUndetectable()) {
    return isolate->factory()->undefined_string();
  }
  if (object->IsString()) return isolate->factory()->string_string();
  if (object->IsSymbol()) return isolate->factory()->symbol_string();
  if (object->IsBigInt()) return isolate->factory()->bigint_string();
  if (object->IsCallable()) return isolate->factory()->function_string();
  return isolate->factory()->object_string();
}

可以看到裡面針對各種型態都進行了檢查。

有些人可能會很好奇上面的 Oddball 是什麼,nullundefinedtruefalse 都是用這個型態來存的,詳細原因我也不太清楚,想深入研究可參考:

  1. Learning Google V8
  2. Playing with Node/V8 postmortem debugging
  3. V8源码边缘试探-黑魔法指针偏移

不過如果 Oddball 裡面已經包含了 undefined,為什麼底下還有一個檢查,也會回傳 undefined 呢?這個 undetectable 是什麼呢?

if (object->IsUndetectable()) {
  return isolate->factory()->undefined_string();
}

這一切的一切都是因為一個歷史包袱。

在那個 IE 盛行的年代,有一個 IE 專屬的 API,叫做:document.all,可以用 document.all('a') 來拿到指定的元素。而那時候也因為這個 IE 專屬的功能,流行著一種偵測瀏覽器是否為 IE 的做法:

var isIE = !!document.all
if (isIE) {
 // 呼叫 IE 才有的 API
}

後來 Opera 也跟上,實作了 document.all,可是碰到了一個問題,那就是既然實作了,如果網站有用到上面判斷 IE 的方法的話,就會被判定為是 IE,可是 Opera 並沒有那些 IE 專屬的 API,於是網頁就會爆炸,執行錯誤。

Firefox 在實作這個功能時從 Opera 的故事中學到了教訓,雖然實作了 document.all 的功能,可是卻動了一些手腳,讓它沒辦法被偵測到:

typeof document.all // undefined
!!document.all // false

也就是說,typeof document.all 必須強制回傳 undefined,而且 toBoolean 的時候也必須回傳 false,真是 workaround 大師。

而到後來其他瀏覽器也跟上這個實作,這個實作到最後甚至變成了標準的一環,出現在 B.3.7 The [[IsHTMLDDA]] Internal Slot 之中:

我們在 V8 看到的 IsUndetectable,就是為了實作這個機制而產生,可以在註解裡面看得很清楚,程式碼在 src/objects/map.h#391

// Tells whether the instance is undetectable.
// An undetectable object is a special class of JSObject: 'typeof' operator
// returns undefined, ToBoolean returns false. Otherwise it behaves like
// a normal JS object.  It is useful for implementing undetectable
// document.all in Firefox & Safari.
// See https://bugzilla.mozilla.org/show_bug.cgi?id=248549.
DECL_BOOLEAN_ACCESSORS(is_undetectable)

看到這邊,大家不妨去打開 Chrome devtool,把玩一下 document.all,親自體驗這個歷史包袱。

Chrome 也因為這個歷史包袱,曾經出現過一個 bug,相關的故事可以參考:What is the bug of V8’s typeof null returning “undefined”,上述段落也是參考這篇文章寫的。

Comments

前面有提到過 JavaScript 其實還有幾種鮮為人知的註解方式,像是 <!---->,在 V8 中有關於語法的部分,可以看這個檔案:/src/parsing/scanner-inl.h,我們擷取幾個段落:

case Token::LT:
  // < <= << <<= <!--
  Advance();
  if (c0_ == '=') return Select(Token::LTE);
  if (c0_ == '<') return Select('=', Token::ASSIGN_SHL, Token::SHL);
  if (c0_ == '!') {
    token = ScanHtmlComment();
    continue;
  }
  return Token::LT;

case Token::SUB:
  // - -- --> -=
  Advance();
  if (c0_ == '-') {
    Advance();
    if (c0_ == '>' && next().after_line_terminator) {
      // For compatibility with SpiderMonkey, we skip lines that
      // start with an HTML comment end '-->'.
      token = SkipSingleHTMLComment();
      continue;
    }
    return Token::DEC;
  }
  if (c0_ == '=') return Select(Token::ASSIGN_SUB);
  return Token::SUB;

case Token::DIV:
  // /  // /* /=
  Advance();
  if (c0_ == '/') {
    base::uc32 c = Peek();
    if (c == '#' || c == '@') {
      Advance();
      Advance();
      token = SkipSourceURLComment();
      continue;
    }
    token = SkipSingleLineComment();
    continue;
  }
  if (c0_ == '*') {
    token = SkipMultiLineComment();
    continue;
  }
  if (c0_ == '=') return Select(Token::ASSIGN_DIV);
  return Token::DIV;

如果碰到 <!,就呼叫 ScanHtmlComment

如果碰到 --> 而且是在開頭,就呼叫 SkipSingleHTMLComment,這段也告訴了我們一件事,就是 --> 一定要在開頭,不是開頭就會出錯(這邊指的開頭是前面沒有其他有意義的敘述,但空格跟註解是可以的)。

如果碰到 //,檢查後面是不是 # 或是 @,是的話就呼叫 SkipSourceURLComment,這其實就是 source map 的語法,詳情可以參考:sourceMappingURL and sourceURL syntax changedSource map 運作原理

不是的話就呼叫 SkipSingleLineComment

如果是 /* 的話則呼叫 SkipMultiLineComment

上面呼叫的相對應的函式都在 src/parsing/scanner.cc 中,我們看一個比較有趣的,碰到 <! 會呼叫的 ScanHtmlComment

Token::Value Scanner::ScanHtmlComment() {
  // Check for <!-- comments.
  DCHECK_EQ(c0_, '!');
  Advance();
  if (c0_ != '-' || Peek() != '-') {
    PushBack('!');  // undo Advance()
    return Token::LT;
  }
  Advance();

  found_html_comment_ = true;
  return SkipSingleHTMLComment();
}

這邊會繼續往下看,看後面是不是 --,如果不是的話會復原操作,然後回傳 Token::LT,也就是 <;是的話則呼叫 SkipSingleHTMLComment

SkipSingleHTMLComment 的程式碼也很簡單:

Token::Value Scanner::SkipSingleHTMLComment() {
  if (flags_.is_module()) {
    ReportScannerError(source_pos(), MessageTemplate::kHtmlCommentInModule);
    return Token::ILLEGAL;
  }
  return SkipSingleLineComment();
}

按照規格中說的,檢查 flags_.is_module() 是不是 true,是的話就拋出錯誤。如果想重現這個狀況,可以新建一個 test.mjs 的檔案,裡面用 <!-- 當作註解,用 Node.js 執行後就會噴錯:

<!-- 我是註解
   ^

SyntaxError: HTML comments are not allowed in modules

<!-- 可以當作註解,也會造成一個很好玩的現象。大多數時候運算子之間有沒有空格,通常不會影響結果,例如說 a+b>3a + b > 3 結果是一樣的,但因為 <!-- 是一整組的語法,所以:

var a = 1
var b = 0 < !--a 
console.log(a) // 0
console.log(b) // true

執行的過程是先 --a,把 a 變成 0,接著 ! 過後變成 1,然後 0 < 1 是 true,所以 b 就是 true。

但如果把 < !-- 改成 <!--

var a = 1
var b = 0 <!--a 
console.log(a) // 1
console.log(b) // 0

那就變成沒有任何運算操作,因為 <!-- 後面都是註解,所以就是單純的 var a = 1var b = 0 而已。

話說在找尋實作的程式碼時,要從茫茫 code 海中找到自己關注的地方不是件容易的事情,分享一個我自己會用的方法,就是 google。直接搜尋關鍵字,或是利用 filter 去幫你搜尋程式碼,像這樣:typeof inurl:https://chromium.googlesource.com/v8/v8.git

如果程式碼在 GitHub 的話,也可以用這個很好用的網站,叫做 grep.app,可以指定 GitHub repo 去搜尋內容。

結語

當你從任何地方(也包括這篇文章)得到關於 JavaScript 的知識時,都不一定是正確的。

如果想確認的話,有兩個層面可以驗證這個知識是否正確,第一個層面是「是否符合 ECMAScript 的規範」,這點可以透過去尋找 ECMAScript 中相對應的段落來達成。我的文章中如果有參考到 ECMAScript,都會盡量附上參考的段落,方便大家自己去驗證。

第二個層面則是「是否符合 JavaScript 引擎的實作」,因為有時候實作不一定會跟規格一致,而且會有時間的問題,例如說已經被納入規範,但還沒實作,或甚至是反過來。

而 JavaScript 引擎其實也不只一個,像是 Firefox 在使用的 SpiderMonkey 就是另一個不同於 V8 的引擎。

如果你看完這篇文章以後想試試看閱讀規格,卻又不知道該從何下手的話,那我來出一個問題,請你從規格中找出答案:「假設 s 是任意字串,請問 s.toUpperCase().toLowerCase()s.toLowerCase() 是否永遠相等?如果否,請舉一個反例」

透過 Chrome Origin Trials 搶先試用新功能 SQL injection 實戰:在限制底下提升速度

評論