Temporal - 下個世代的 Date

這篇文章寫到快寫完的時候,決定到Modern Web 2017分享,所以就比較晚發佈 ,其實 Modern Web 演講內容比較多,文末有放相關參考資料。

JSConf EU 2017 前陣子放出演講影片,蠻多場次都不錯,這篇要主要是從其中的一場演講而來,演講是「 The Past, Present, and Future of JavaScript Date and Time APIs」,講者是 Matt Johnson,Moment.js的作者,下面是這場演講的影片:

長度不長,推薦可以看一下,主要是在談 JavaScript 的 Datetime,提出這老東西的問題,我覺得可以稱為 WAT JavaScript 的 Datetime 篇,像是 0 起始的月份、不支援 Time Zone、難以運算、是 mutable 物件等等,接著介紹了目前檯面上比較多人用的幾個 library 和他們的特色,都是品質不錯的 library,有需求的可以從中選用,包括了:

最後則是提到他們目前在進行的,改進 JavaScript Datetime 的計畫,也就是新的 ECMAScript Datetime 的 proposal,叫temporal,除了 Matt Johnson 之外,還有一位 Microsoft 的 Maggie Pint 也是目前草案的主力推手,他的 blog 上就有兩篇相關的,裡面有列出目前Date的主要問題:

  1. 不支援 timezone,只有 UTC 和 local
  2. Parser (轉譯日期字串轉成日期物件)的行為不可靠且難以使用
  3. Date object 是 mutable 物件
  4. 日光節約時間的行為無法預期
  5. 日期計算 API 很難用
  6. 不支援Gregorian以外的日曆(例如農曆)

事實上,目前的 Date 物件,當初 Brendan Eich 因為時間緊迫,所以 Datetime 的 API 是直接參考 Java 的,當時是 1995,參考的應該是 Beta 版 Java 的java.util.Date,後來 1996 年 1 月 Java 1.0 發佈,但是到了 1997 年 2 月的 Java 1.1 發佈時,java.util.Date大部分的設計都被捨棄了,然後 1997 年 6 月,ECMAScript 標準 1.0 發佈,結果這個在 Java 語言只活了 1 年多的設計,就活在 JavaScript 世界活了 20 年之久,相信有用過的人都能多少都知道使用起來的痛苦。

不過要改善 JavaScript 從來就不是一件容易的事情,最大的困難點就是你不能隨便改動任何已有的東西,像是已經存在 20 年的Date,即使它設計不好,隨便改動都可能造成大量的網站壞掉,不像是 Java 1.0 升級到 Java 1.1 那樣,各自用各自的,在改善 JavaScript 時基本上就是要當成有人從不升級,不能有 broken change,最簡單的方法就是增加新的東西,而不要去修改舊的,這也是目前 temporal 的方向(其實 ES 5.1 後,舊有的東西該修的東西大概都修完,之後就是一直加新的而已),在 draft 文件已經有一點基礎和預期的 code sample 了:

var ldt = new CivilDateTime(2017, 12, 31, 23, 59);

var addHours = new CivilDateTime(2017, 12, 31, 23, 00)
    .add(2, 'hours');

var zdt = ldt.withZone('America/New_York');

可以看到有方便的加減時間的 API、immutable 特性、還有時區支援等等。事實上這份草案還非常初步而已,還缺非常多細節,預期會有的物件目前已經有八種了,不過這八種物件的 API 也都還沒定義完,不過也正因為如此,想參與的人反而這時候比較有機會提供想法,推薦有興趣的人可以關注關注,給點意見。

最後列一些參考文件:



NodeJS and ES Module

香港 2016

今天看了 TC39 一月會議的 Agenda 後才注意到,nodejs 用的CommonJS ModuleECMAScript Module(ES Module) 在特定情況下會有混淆的情形發生,所謂的特定情形就是沒有import/require也沒export/exports的模組,例如寫東西在 root 物件上,只產生 side effect 的模組:

(function (root) {

  root.lib = {};

}(this));

像這樣的檔案,Parser 就無法判斷他是 CommonJS Module 還是 ES Module,這樣會產生什麼問題呢,其實 ES Module 有一些特色,例如它必須要使用 strict mode 來解析並執行,而光是這個差異,就會讓相同的程式碼有不一樣的執行結果了,而需要同時支援 CommonJS Module 和 ES Module 的主要是 NodeJS 環境,當然它目前還沒有兩種都支援,但是勢必需要支援 ES Module 的,所以 NodeJS 需要能夠百分之百正確的判斷每個 JavaScript 程式碼是屬於 CommonJS Module 還是 ES Module,這在目前是辦不到的,也因為這個問題所以 NodeJS 雖然已經支援大部分的 ES2015 的新功能,但卻遲遲還無法支援 ES Module,相關的討論至少也半年有了,當時還提出了新的副檔名.mjs這種解法,多一種副檔名聽起來有點不可思議,也引此還有個 Twitter帳號專門在關注相關情報的,不過目前最新的解決方法,則是 ES Spec修改Module 的 Grammer 來解決這個問題,修改的方式是就是以後 ES Module 一定要至少有一個import或是exportstatement,如果是上面那種沒有需要 import 也沒有 export 的模組,那就要加上export {},變成:

(function (root) {

  root.lib = {};

}(this));

export {};

語意上剛好等於沒有匯出任何東西,所以不會和現在的 ES2015 版的 Module 有衝突,這份提案已經是 accept 狀態了,所以沒意外應該今年的 ES2017 就會包含進去了,當然這會影響到以前寫出這種 ES Module 的程式碼,不過目前也還沒有那個環境有直接使用 ES Module 的能力,都還是先過 bundler 轉成現在環境可以使用的形式,Web 的<script type="module">也才正要有瀏覽器支援,所以這個時間點做出這個修改影響還算是很小,之後大概就是有記得應該就沒問題了,我自己是比較期待 nodejs 能快點原生支援 ES Module 啦。


smartypants.js

大阪新年

SmartyPants這個東西也是很久了,和 Markdown 差不多時期,都是 John Gruber 幫當時的網路文字出版軟體(ex: MovableType)所做的,而 SmartyPants 是用來處理一些標點符號的,其中,最容易被人注意到的就是引號「"」的轉換了。

雖然鍵盤上的引號按鍵只有一個,但是傳統的文書寫作上,引號是有分左邊(開始)的「“」和右邊(結束)的「”」,只不過早期為了減少鍵盤按鍵數,還有字元編碼上的限制,所以合併成為只有一個,不過隨著計算機的發展,可以使用的字元編碼資料量增加後,就還是有定義了開始和結束的兩種引號,並且有單引號和雙引號兩種:

  • 左單引號 ‘
  • 右單引號 ’
  • 左雙引號 “
  • 右雙引號 ”

理想上,寫作文章時也應該正確的使用這些引號,不過其實因為輸入上比較麻煩,一般人打字也不會特別注意,所以常常被忽略,比較常見的是軟體本身在使用者輸入文字時自動做轉換,例如 Apple 的 Pages、Keynote、微軟的 Skype 等等,講到 Keynote 自動轉換引號這點就要另外岔題一下,就是偶爾都會看到頭影片裡面的程式碼,其中的引號也被轉換過,其實就是因為貼上 code 到 Keynote 的時候被轉換了,如果作者沒有注意到的話就直接釋出,然後讀者 copy 程式碼出來試試看時,就會編譯失敗而無法執行。回到標點符號上,這類標點符號其實不止有引號,SmartyPants 可以處理的還包括:

  • --轉成 en-dash –
  • ---轉成 em-dash —
  • ...轉成 ellipsis …

而且它對於引號的轉換還算是聰明,會判斷是不是真的用來包起文字的,還支援用 backtick 來模擬的雙引號,看起來像:

``quoted string''

另為也會針對年代的特殊寫法做處理,例如'80s會轉成 ’80s,並且會避開 HTML 標籤的部分,不會把 HTML 標籤裡面的屬性值的引號也做轉換,像是<html lang="en">這種。前陣子因為工作上要處理翻譯字串,想要順便好好的處理這些符號,所以就想到了這個工具,因為我一般寫小 script 做事情是用 JavaScript 然後用 node 來執行,所以要用 SmartyPants 就沒辦法那麼直接了,一開始先找 JavaScript 的 solution,找到一個簡單的,只用幾條 regular epxression 的版本,其實運作的也還不錯,但是無法避開不處理 HTML 標籤,所以產生出來的結果也不能用,接著改成用 STDIO 丟給 John Gruber 的 Perl 版,結果這效率實在太差,所以又繼續研究一番,發現還有一套叫typogr的文字處理工具有實做,不過不想要太多功能,所以最後我決定自己把 Perl 版 SmartyPants 移植成 NPM module,也因此有了smartypants.js

這次開發我選擇的語言是 TypeScript,原因可以參考我上一篇文章20k-for-of,然後用 Makefile 加上一些指令轉成類似jQuery UMD形式的 JavaScript 檔案發佈到 NPM 上,目前的 smartypants.js 是完完全全把 Perl 版的邏輯翻過來,不過只有實作轉換編碼的部分加上 CLI 的介面,另外 Perl 版的只能轉換成 HTML entity 的格式,不過現在 UTF-8 已經算是很廣泛應用的文字編碼了,所以我還加上了一個轉成 UTF-8 字元的版本,用 UTF-8 編碼的雙引號其實很不錯,在 JSON 或是 csv 裡面也不用 escape,肉眼看起來也美觀許多。

實做過程比較緊張的地方大概就是要把 Perl 的 regular expression 轉成 JavaScript 的版本了,一度看到沒看過的用法都覺得會不會沒辦法用 JavaScript 做起來,還好沒用到很神奇的語法。另外它處理 HTML 語法的部分,其實是先過一個簡單的 lexer 轉成 token list,裡面兩種 token 分別就是一般文字和 HTML 標籤兩種,接著只針對一般文字 token 來做標點符號轉換,不過其實這個 tokenize 的部分有 bug,例如:

<span title=">">HAHA</span>

就會被分解成如下的 token:

  • <span title=">
  • ">HAHA
  • </span>

而且這種方法就也無法處理到一些文字內容的 HTML 屬性,例如 title 和 alt 等,所以接下來還可以做的改進,一個就是改進這部分的 lexer,然後也要對這些屬性值轉換標點符號,大概需要建立一個白名單,另外還有一個想加強的地方就是補上 test case 了,目前完全沒有相關的 test case 可以用,所以其實也沒很正式的驗證過,不過總之目前是都還運作正常,如果有相似需求的人可以參考一下。


20K for...of

for...of是 ECMAScript 2016 的新語法,有了他之後,要用迴圈跑過陣列不用像以前一樣先用for...in或是用傳統的取長度,然後i++的方法:

var arr = [1, 2, 3];
var i, v, len;

for (i in arr) {
    v = arr[i];
    console.log(v);
}

for (i = 0, len = arr.length; i < len; i++) {
    v = arr[i];
    console.log(v);
}

現在只要用簡單的for...of就可以了:

var arr = [1, 2, 3];

for (let v of arr) {
    console.log(v);
}

不過目前還是需要考慮只有 ECMAScript 5 的環境,例如 IE11,所以一般都還是會用像是Babel之類的 transpiler 來把 ES2015 的 syntax 轉成 ES5 的 code,結果轉出來如下:

"use strict";

var arr = [1, 2, 3];

var _iteratorNormalCompletion = true;
var _didIteratorError = false;
var _iteratorError = undefined;

try {
    for (var _iterator = arr[Symbol.iterator](), _step; !(_iteratorNormalCompletion = (_step = _iterator.next()).done); _iteratorNormalCompletion = true) {
        var v = _step.value;

        console.log(v);
    }
} catch (err) {
    _didIteratorError = true;
    _iteratorError = err;
} finally {
    try {
        if (!_iteratorNormalCompletion && _iterator.return) {
            _iterator.return();
        }
    } finally {
        if (_didIteratorError) {
            throw _iteratorError;
        }
    }
}

結果其實有點意外,一個簡單的for...of竟然變的這麼長,事實上是因為for...of其實沒想像中簡單,因為它可以用的地方其實不只是陣列,而是iterable 物件,不過為了要完整的支援for...of,就變成需要有 iterator, generator, symbol 等等的支援,當然上面的程式碼不能在 ES5 環境下執行,而 Babel 依靠的是babel-polyfill,裡面其實就是core-jsregenerator,不過這一整包,其實有點龐大,要 228KB,即使最小化之後也還要 95KB,所以,就想著是不是能夠只捆包進需要的部分就好了,研究過後,發現有 Babel plugin 叫做transform-runtime,套用上去後:

import _getIterator from "babel-runtime/core-js/get-iterator";
var arr = [1, 2, 3];

var _iteratorNormalCompletion = true;
var _didIteratorError = false;
var _iteratorError = undefined;

try {
  for (var _iterator = _getIterator(arr), _step; !(_iteratorNormalCompletion = (_step = _iterator.next()).done); _iteratorNormalCompletion = true) {
    var v = _step.value;

    console.log(v);
  }
} catch (err) {
  _didIteratorError = true;
  _iteratorError = err;
} finally {
  try {
    if (!_iteratorNormalCompletion && _iterator.return) {
      _iterator.return();
    }
  } finally {
    if (_didIteratorError) {
      throw _iteratorError;
    }
  }
}

可以看到原來用Symbol取 iterator 的地方變成用_getIterator了,而且還有一行:

import _getIterator from "babel-runtime/core-js/get-iterator";

如果要真的把這部分也打包進來,則需要讓 bundler 處理,我個人是偏好rollup,搭配以下兩個 plugin:

然後用以下的設定:

babel({
  exclude: 'node_modules/**',
  plugins: ['transform-runtime'],
  presets: ['es2015-loose-rollup'],
  runtimeHelpers: true
}),
nodeResolve({ jsnext: true }),
commonjs({
  include: 'node_modules/**'
})

結果,就可以得到夢寐以求的 20KB 的程式碼了,當然 20KB 的部分不是預期的啦,相較於一開始的程式碼只有 72Bytes,為了一個for...of變成 20KB 好像有點本末倒置,畢竟我只有要在 Array 上用,難道不能只是簡單的轉成for...in型式嗎。

事實上是有辦法的,第一個就是改寫TypeScript,TypeScript 對於for...of只有兩種處理方法,而且結果都不會如此膨脹,第一種就是變成for...in,第二種則是不變動,保留for...of的語法,後者是在 target 設定成 ES6 的時候使用的,官網也有相關的說明

第二種方法則是用Bublé取代 Babel 做為 transpiler,Bublé 是 rollup 的作者Rich Harris的另外一個作品,我個人是蠻喜歡他的哲學的,Bublé 的哲學則是對於 code 做簡單、直接明瞭的轉換,所以for...of就只會轉成for...in的型式,不過也因此無法支援 iterable 物件,所以預設是不開啟支援的,歸類在dangerious transofrm 之下,另外 Bublé 也還不支援 Async/Await,因為要做出支援 ES3/5 的同樣效果的 code 會增加太多的複雜度,不符合他的哲學理念,所以目前還沒有計畫支援,這點倒是 TypeScript 支援比較完整,目前的2.1 RC已經支援把 Async/Await 轉成 ES3/5 的版本了。

最後結論,基本上就是個取捨,Babel、TypeScript、Bublé 各自有它們的優缺點,所以只能看情況選擇了,如果要 Map/Set 也要在這些物件上用for...of語法然後也要 Async/Await,那就只能用 Babel 加上 babel-polyfill;如果可以不要 Map, Set 或是可以接受不在這些物件上使用for...of語法(還可以用 forEach),那可以選擇 TypeScript,然後加上 Map/Set 的 polyfill,如果不用 Async/Await,也不用 Map/Set 的話,可以考慮用個 Bublé 看看。不過如果完全不需要考慮 ES3/5 的環境的話(Edge, Firefox, Chrome 都已經對 ES2015 支援很完整了),好像問題突然就小很多了XD,最後附上這篇文章提到的各種作法產生的檔案參考,目前都放在 github 上的20k-for-of這個專案。


Input Event

Playtime Credit Card,

今天做了一個特殊的 input 欄位,其實目標只是做成類似像輸入信用卡號那樣,輸入1234完,準備要輸入5的時候,會在4後面補上一個-,變成1234-5,不過我預期做的完美一點,所以考慮了很多狀況,例如:

  • 複製貼上沒有-的資料後會自動格式化
  • 已經輸入一部分資料後,游標移到前面插入資料也會正確格式化
  • 直接用DELBackspace來刪除資料,要讓使用者感覺不到-
  • 先選取一些字元然後用DELBackspace甚至是剪下來刪除資料後會重新格式化
  • 以上幾種操作都不會讓游標亂跳

簡單看過目前一些信用卡相關的 library,在卡號輸入的部分是沒有全部達到的,要達成這些目標,幾乎是等於每個使用者的操作都要攔截下來,然後要抓到當欄位內的值,會用到的事件包括了 keyup、keydown、paste 和 input,等,其中本來我對於一般使用者敲打鍵盤輸入的事件是用 keyup,keyup 事件後會判斷游標位置和輸入的內容,如果需要的話就加上-,然後調整游標位置,通常是 +1,弄好後測試一陣,發現如果按鍵輸入很快的話,游標位置會亂掉,應該要 +1 的卻錯過了,深入除錯一陣子之後發現,keyup 事件其實和欄位內的 value 變更是非同步的,所以不能確保 keyup 事件拿到的欄位值是正確的,能確保欄位值正確的,其實是input 事件,不過 input 事件沒有 keyCode,所以只能自己判斷輸入了什麼,另外刪除內容時也不會觸發 input,還好DELBackspace是用 keydown 事件來處理,兩邊剛好錯開了。

雖然 input 事件似乎很好用,不過其實它在早期的時候支援度是不太好的,算是比較新的事件,有類似狀況的還有一個是 change 事件,我的印象中是某些瀏覽器的行為會不太正確,所以其實我一直都還不太使用,至於 input 事件,我則是需要在不支援的瀏覽器中 fallback 到 keyup 事件,所以就會需要偵測,找了一下在 Modernizr 有支援,仔細看一下內容其實可以發現不是很好偵測,然後我也不是很喜歡 Modernizr 的介面,所以目前用的是在 ModernizrIssue 210裡面 AndyE 提供的版本,稍微精簡一些:

var inputSupport = "oninput" in document.body || checkEvent(document.body);
/*
   The following function tests an element for oninput support in Firefox.  Many thanks to
        http://blog.danielfriesen.name/2010/02/16/html5-browser-maze-oninput-support/
*/
function checkEvent(el) {
    // First check, for if Firefox fixes its issue with el.oninput = function
    el.setAttribute("oninput", "return");
    if (typeof el.oninput == "function")
        return true;

    // Second check, because Firefox doesn't map oninput attribute to oninput property
    try {
        var e  = document.createEvent("KeyboardEvent"),
            ok = false,
            tester = function(e) {
                ok = true;
                e.preventDefault();
                e.stopPropagation();
            }
        e.initKeyEvent("keypress", true, true, window, false, false, false, false, 0, "e".charCodeAt(0));
        document.body.appendChild(el);
        el.addEventListener("input", tester, false);
        el.focus();
        el.dispatchEvent(e);
        el.removeEventListener("input", tester, false);
        document.body.removeChild(el);
        return ok;
    } catch(e) {}
}

測試困難的主因是 Firefox 4 有 bug,所以需要真的建立一個 input 元件,然後用完整模擬 input 事件。然後雖然這個版本的比較精簡好懂,不過之後還是會因為 license 的關係改用 Modernizr 的版本吧。至於我做的 input field 呢,現在當然還是公司資產,大概要等我有空在假日重寫一個 Credit Card 的版本才會放出來吧。


JSON Universe

JSON在這幾年不但標準化,還開始漸漸的取代了 XML,成為網路上主要的資料交換格式,我認為主要的原因在於他相對於 XML 簡單好懂好用很多,另外一個優點就是他格式很簡單好懂,學起來很快,而相較於更傳統的 Form URL encode 的資料傳輸格式,我覺得最大的優點是他多了 Data Type,而且也可以一口氣送出結構化的資料,傳統的 Form URL encode 雖然有 key value pair,也可以用[]模仿出陣列,不過他的所有的 value 都是字串,在 server 端都還要自己判斷欄位,手動做一次型別轉換。只是雖然 JSON 很好上手,還是有幾個地雷容易踩到,如果是會寫 JavaScript 的人更容易中招,大概列舉一下:

  • 不支援註解
  • 物件的 key 一定要用字串型式,就是一定要用引號框起來
  • 字串一定要用雙引號,不能用單引號
  • 陣列或物件的最後一個元素後面不能加逗點(ES5 允許)

當然要避免這些問題,最保險就是用各個語言已經有人實做好的函示庫來處理,而不要自己用組字串的方式來產生 JSON string。不過這篇文章不是要講這些,其實會想寫這篇文章是從E4X那篇文章開始來的,那篇文章有提到 E4X 已經被棄用了,而 Mozilla 建議的替代方案是一個叫 JXON 的東西,名字看起來和 JSON 很像,而事實上除了 JXON,還有一堆名稱和 JSON 很相近的相關技術,像是 JSONH、BSON、LJSON ...等等,不過其實我找不太到有人去收集這些東西,所以就決定自己來整理一下,這篇文章就是要來介紹一下這堆 JSON 衍生出來的東西,以下沒照特定排序。

閱讀「JSON Universe」全文

JSX 的前世

ECMA-357

React 的主要創新在於 Virtual DOM,而伴隨著 Virtual DOM 而來的,就是 Virtual DOM 的操作,由於 JavaScript 語言限制的關係,要產生一個簡單的<div>標籤就要寫成:

var divNode = React.DOM.div({attr1:"value"});

如果還要加上子元素,像是一個 list:

<ol>
  <li>Item A</li>
  <li>Item B</li>
  <li>Item C</li>
  <li>Item D</li>
</ol>

就要寫成:

var listNode = React.DOM.div({},
  React.DOM.li({}, "Item A"),
  React.DOM.li({}, "Item B"),
  React.DOM.li({}, "Item C"),
  React.DOM.li({}, "Item D")
);

雖然比起純 DOM 的語法要簡單很多了,不過其實類似這種寫法來快速產生文件樹節點的 library 已經早就有了,像是Moomlhyperscript(thanks WM),如果 React 當初就直接這樣子推出,大家會想嘗試的意願應該會低很多,Facebook 的工程師應該也知道這樣和直接寫 DOM 或是其他 library 的感覺差異不大,所以他們解決的方法就是建立了一個新的 syntax,看起來就像是讓 DOM 變成像是原生的資料型態一樣,也就是現在的 JSX syntax,然後當然要讓這種語法可以在瀏覽器執行,所以官方有提供了 transpiler,不過其實這個把 XML DOM 當成 JS 語言中的原生資料型態的想法不是第一次出現了,早在好幾年前,就有一個 ECMA-357,又稱為E4X的標準是在做這件事的,全名則是 ECMAScript for XML,早期的 Mozilla SpiderMonkey 是有支援的,約是在 2006 年的 JavaScript 1.6 中推出,不過到 Firefox 21 的時候已經完全移除了,所以現在基本上是已經無法體會到這套 JS 擴充的語法,不過其實這套語法還蠻不錯的,有類似 template string 的寫法:

var doc = <{h}><body>{text}</body></{h}>;

React 則因為同時導入了 immutable 的概念,所以其 Virtual DOM 設計上只有產生文件樹結構的部分,沒有刪除修改,相較於 JSX 只有建置(create)的語法,E4X 則是有更完整的 CRUD 操作(就像是操作 JS 資料一樣),另外還多了用來尋找目標節點的 selector syntax:

languages.lang[1]
person..*
people.person.(name == "Joe").age

而且目標對像是 XML,所以對於 namespace 的處理也都有,整體而言算是很完整的 XML 操作方案,使用起來也比 DOM 簡潔很多,可惜後來被放棄,我推測大概幾個原因:

  1. 使用 XML 的應用越來越少
  2. 實做 E4X 會增加 JS 引擎的複雜度
  3. 相較於 DOM 不依存於單一程式語言,E4X 則是 JS extension
  4. 用 E4X 產生的節點不是 DOM 物件,不直接相容

沒想到後來會在 Facebook 的手上,把這個概念單存的套用在 HTML 文件樹之上而重生。


前端測試入門

Test Well

這篇也是之前花一些時間搞清楚的觀念,想著要記錄下來一陣子了,不過最近很忙碌,一直到這幾個連假才有時間寫下來。

其實身為工程師,我一直沒什麼寫測試,只有在少數幾個工具的 library 中有加上 unit test,大概的原因是因為前端的測試沒這麼好做起來,如果是單一 JavaScript 模組的單元測試還好,不過要做整合測試,或是在瀏覽器上真的測試就麻煩很多了,總之前陣子一方面為了工作需要,一方面幫 Moztw 做了下載檔案的自動檢查,就順便把相關的名詞和觀念弄清楚。

之前最搞不清楚的其實就是Mocha(摩卡咖啡) 和Chai(印度拉茶) 到底分別是什麼定位,後來終於弄清楚了,Chai 只是提供 BDD 語法的測試用的 斷言 函數庫(assert library),什麼是斷言呢,英文是 assert,例如明確知道某個函數的結果是什麼,把他說出來,就是斷言,如果結果和說的不一樣,就是測試到錯誤,一般的情形,這些 assert library 就會 throw error,至於 Mocha 則是 Test Framework,用來組織和管理你的測試的程式碼,Mocha 本身的設計是不含 assert library,所以可以自己挑選喜歡的 assert library,只要它在出錯誤時會 throw error 就好,Mocha 網站上就列出了四套 assert library 供大家選擇,除此之外,像我之前在介紹 TypeScript 時提過的assert.js也可以使用,不過 assert.js 只能檢查型別就是。至於要挑選哪套 assert library 就看各人喜好了,主要是看要怎麼寫斷言,像我挑選 Chai 的原因是他的語法,支援 BDD ,可以寫的看起來很像一句英語:

foo.should.be.a('string');
foo.should.equal('bar');
foo.should.have.length(3);
tea.should.have.property('flavors').with.length(3);

很容易就知道是什麼意思,而且自由度還蠻大的。另外一個原因則是他有支援 Promise,就是所謂的chai-as-promised,為什麼這個很重要呢,因為 JS 很常遇到需要非同步的操作流程,如果沒有支援,Test Framework 當下把他的 function 跑完,沒有 catch 到 error 就認為沒有錯誤了,當然像 mocha 是有支援非同步的,內建有個等待的機制,done

describe('User', function() {
  describe('#save()', function() {
    it('should save without error', function(done) {
      var user = new User('Luna');
      user.save(function(err) {
        if (err) throw err;
        done();
      });
    });
  });
});

就是每個it區塊裡面,其實都會收到一個函數done,如果有要測試非同步的程式,可以在非同步的部分測試完後,才執行done(),這樣 Mocha 才有機會知道你的測試是不是有非同步的部分,還有什麼時候才是測試完成,不過 Chai 是 BDD,不會容許這樣不直觀的寫法的,所以 Domenic Denicola 開發了chai-as-promised

promise.should.be.fulfilled;
promise.should.eventually.deep.equal("foo");
promise.should.become("foo"); // same as `.eventually.deep.equal`
promise.should.be.rejected;

只是要這樣簡潔的寫法,還需要先設定一下:

var chai = require("chai");
var chaiAsPromised = require("chai-as-promised");

chai.use(chaiAsPromised);

其實 chai-as-promised 是 chai 的 plugin,然後用chai.use來使用它,底層怎樣運作我還沒深入研究,覺得還有點 magic,不過還算是想的到怎樣實做出來的程度,猜測可能有用到 function 的toString來判斷有沒有引用next參數。

再來,測試蠻常會用到的假物件,mock 和 stub,兩者的差異其實蠻多文章有說明了,我個人覺得簡單分法就是 stub 沒有副作用,mock 則是有副作用的假物件,至於要說要用哪種物件來完成測試的話,基本上就是 stub 可以達成你的測試需求的話就用 stub,在 JavaScript 的測試環境下,好像只有看到Sinon.js這套比較多人用,去查了一下名稱典故,覺得一個比較可能的來源是特洛伊木馬故事中,騙特洛伊人把木馬搬進去城裡的那位(Mocha 和 Chai 的名稱應該是互相影響的,不過不確定誰先出來的)。另外還有個角色和 mock、stub 很常一起提到的叫 spy(常見用複數形 spies),最常用來當 callback 之類的,在非同步測試案例中,可以用來確保 callback 有被執行到,甚至可以偷看(spy)被執行了幾次,收到什麼參數等等,總之就是個可以測試函數被執行的次數和方式的物件。

最後要說的則是 e2e test,因為 JS 很多時候都是用來在瀏覽器端實做 UI 和使用者行為的 handler,其實要做完整整合的測試不太容易,e2e 指的是 End to End,端點到端點,通常是說一個流程的起點到終點的意思,例如上網站註冊帳號,這樣算是一個流程,或是上網登入購買東西到結帳完成,這樣也是一個流程,由於 Web App 的環境下,跑 JS 的是瀏覽器,沒辦法簡單的介入,所以以往真的要做 e2e 測試幾乎都是要靠人工,後來有了Selenium和 WebDriver,才開始可以讓這些測試自動化。

以前的 Selenium 要控制瀏覽器靠的是Selenium RC,用比較暴力的方式介入瀏覽器,不過現在的 Selenium 2 則是透過WebDriver這個 API 來操作,WebDriver 能進 W3C 標準化其實也是 Selenium 貢獻者的努力,背後也是有些大公司的影子在,目前主流的瀏覽器包括微軟最新的 Edge 也都支援,不過其實 Selenium 因為是 Java 寫的,雖然控制瀏覽器的 script 沒有限制要用 Java,我還是一直不太習慣,所以都沒深入,直到前陣子開始看到 Paul 在 Facebook 上連載介紹Protractor,才又開始有想嘗試的動力,Protractor 的名稱由來也還蠻有趣的,意思是量角器,而 AngularJS 則有諧音 angle 的感覺在,當初出來也是為了要測試 AngularJS 的,Github 上 Protractor 是 AngularJS 下的一個專案,Protractor 和 Selenium 的差別就在於,Protractor 是一個 test framework,然後建好了 WebDriver binding,可以直接透過 WebDriver 來跟瀏覽器溝通,不再需要 Selenium 介面那塊了。

後來 Carl 跟我說到有WebdriverIO這個專案,是只有 WebDriver 介面的部分,可以寫 node script 來叫瀏覽器做事,當然也可以做測試,可以挑自己喜歡的 test framework 和 assert library 來搭配使用,於是我就做了一個可以去 moztw.org 下載安裝檔回來驗證正確性的專案,在這個專案中,還用了一個特殊的寫法:

it('Download OSX Installer', function* () {
  var data = yield hashes;
  ...

其實就是 async function 加上yield來代替 ES2016 的await,要達成這樣的效果其實會需要一個 async function runner,不是 node 可以直接跑起來的,實際測試過也是跑不起來,所以就只能 WebdriverIO 提供的wdio執行檔來執行。

這篇還差一點東西沒講到,就是 test coverage,JS 這邊比較常看到的是istanbul.js,名稱的來源是 carpet coverage,然後 Istanbul 是個生產優質地毯的地方~


Transducer

今天在 JSDC 講的題目是 Transducer , 是目前講過數學和程式碼最多的題目了,不過還是希望能用盡量簡單的範例來說明什麼是 Transducer。


此類別所有文章