onAutofill

Credit Card autofill

在現在這個網路標準橫行的時代,要遇到還沒廣泛標準化的東西其實是越來越難了,不過我最近還是遇到了一個,那就是 autofill 的偵測。

首先要說的是,autofill(自動填入)和 autocomplete(自動補完)嚴格定義下是不一樣的,雖然都可以透過autocomplete來控制相關的行為,但是 autocomplete 其實只能算是 autofill 的一種,而我遇到的就是非 autocomplete 的,信用卡資料的自動填入,那問題在哪呢?

問題就是這種 autofill 發生時,瀏覽器不一定會觸發change/input事件,如果表單設計成自動檢查表單輸入,然後輸入都正確才讓人送出的話就會有使用體驗的問題發生,因為這種設計的欄位檢查通常就是綁在<input>change/input事件上,結果就是如果瀏覽器自動填入,然後又沒觸發change/input事件,於是就不會執行到欄位檢查,表單也就會一直維持在無法送出的狀態,產生的副作用就是使用者體驗反而比按下送出按鈕才作表單檢查還要來的差。

那麼在 Web 標準中,change事件應該何時觸發的定義是為何呢?在 HTML 4.01 是這樣寫的:

The onchange event occurs when a control loses the input focus and its value has been modified since gaining focus. This attribute applies to the following elements: INPUT, SELECT, and TEXTAREA.

按照古時候網路標準的規範,autofill 不是使用者和 DOM 之間的互動,沒有經過 focus blur,所以沒有觸發 change 事件也是合理,事實上也就是現在部分瀏覽器的行為;不過在現在的 HTML Living Standard 是這樣寫的:

Thechangeevent fires when the value is committed, if that makes sense for the control, or else when the control loses focus.

觸發的時機除了失去 focus 時之外,還多了資料 commit(提交)時,變成兩種時機,而這邊的提交主要指的是像type=color或是type=date那種,瀏覽器有支援,有提供另外頁面內的小工具讓使用者方便挑選值的時候,使用者選好,瀏覽器更新值進入<input>的 value 的動作,那 autofill 更新值該算是 commit 嗎?其實文件內也是有講到的,就在同個章節的後面:

When the user agent is to change an input element's value on behalf of the user (e.g. as part of a form prefilling feature), the user agent must queue an element task on the user interaction task source given the input element to first update the value accordingly, then fire an event namedinputat theinputelement, with thebubblesandcomposedattributes initialized to true, then fire an event namedchangeat theinputelement, with thebubblesattribute initialized to true.

這段就是說當瀏覽器代表使用者改變 input 的值時,也是要發一個 input 一個 change 事件,這段文字的重點在於 "on behalf of the user",就是「代表使用者做事」,後面舉的例是 prefill 時,prefill 通常發生在 帳號/密碼 欄位,發生時間點又不太一樣,可能是在 render DOM 時就發生;不過根據文字解釋其實 autofill 應該也符合 "on behalf of the user"。

雖然 HTML 標準有規範了,但是現實世界總是不會這麼美好,不然也不會有這篇文章了,那麼現實世界是怎樣呢?我遇到的狀況就是有些瀏覽器是照著舊的規範,完全沒有事件,發現問題後我就上網搜尋一番之後發現,這問題其實已經很久了,早在 2010 年,@avernet 就寫了一篇 [Autocomplete and JavaScript Change Event][],紀錄了當年的這個問題,根據不同欄位、不同瀏覽器會有不同的行為,即使到了今天,也還是同樣情形,文章最後建議的解法也是很無奈的:

Autocomplete and JavaScript Change Event

  1. 關掉相關功能autocomplete=off
  2. 定時檢查

總之就是讓人不喜歡的解法,那麼時至今日,有沒有比較好的方法呢?其實還真的有,而且蠻聰明的,Klarna 的 Tommy Brunn 在 2016 年寫了 Detecting autofilled fields in Javascript 一文介紹了這種方法,透過 CSS pseudo-class:autofill和 CSS animation 配上animationStart事件,首先 CSS 這樣:

input:autofill {  
  animation-name: autofill;
  animation-duration: 500ms;
  animation-fill-mode: both;
}

@keyframes autofill {
  from {
    background: var(--color1);
  }
  to {
    background: var(--color2);
  }
}

然後 JS 監聽事件並確定動畫名稱沒錯,就可以做事了:

inputNode.addEventListener('animationstart', (event) => {  
  const { currentTarget, animationName } = event;
  
  if (animationName === 'autofill') {
    // do what ever you want, or
    // trigger `change` event
    currentTarget.dispatchEvent(new Event('change'));
    // trigger custom event
    currentTarget.dispatchEvent(new Event('autofill'));
  }  
}, false);

完全成為真的 event based,不用定時檢查了,不過缺點是要 CSS 搭配,不是純 JS 的方案,維護上比較麻煩一些,另外就是 Tommy Brunn 文章內用的是:--webkit-autofill,但是現在完全可以用沒有 prefix 的 pseudo class 了。

以上的程式碼範例就可以處理好瀏覽器內建的自動填入事件,不過現實世界除了瀏覽器內建的自動填入,還有很多的第三方工具支援,像是各種 password manager: 1Password, LastPass, Dashlane 等,這些工具自動填入的行為又不太一樣,我確實有發現有其中一兩家的行為也是 value 會改變,但是不會有 input 和 change 事件,幸好這些工具都會加上各自自訂的 attribute,所以可以另外透過 observer 監看 attribute 的變化來判斷是否有相關的事件,目前我所知道有以下的 attributeName 可以檢查:

  • data-dashlane-autofilledDashlane 的
  • data-com-onepassword-filled1Password 的
  • chrome-autofillediOS Chrome,超容易漏掉

至於 LastPass 目前測試結果是不會有自訂的 attribute,但是會有 change 事件,所以也可以照常運作(不過相對的就完全沒有提供給使用者的視覺提示好像怪怪的)。

這篇內容大概就到這邊,雖然沒有提供很完整的程式碼,不過這些資訊應該很夠幫助其他人完成 autofill 事件的偵測了,其實這次弄信用卡資訊的輸入欄位真是費了不少心力,很多細節可以弄,也很多 domain knowledge(都靠 lib 搞定就是),真是想不到只是信用卡欄位也這麼多眉角。


JSON Type Definition

之前工作上需要,想要一個簡單的可以檢查 JSON 資料結構的工具,研究了一陣子,發現到了 JSON Type Definition(簡稱 JSON Typedef 或是 JTD) 這個 RFC 標準,相較於發展已經很久的 JSON Schema,JSON Typedef 的語法簡潔不少:

{
	"properties": {
		"id": { "type": "string" },
		"createdAt": { "type": "timestamp" },
		"karma": { "type": "int32" },
		"isAdmin": { "type": "boolean" }
	 }
}
閱讀「JSON Type Definition」全文

UI Event Order

我一直以來都對於 DOM 事件的順序抱有一種不確定的感覺,舉例來說,當使用者點滑鼠時,你可以預期到會有mousedownmouseupclick事件,但是它們的順序是什麼呢?可以確定的是 down 一定是第一個,up 一定在 down 之後,那click是在中間還是最後呢?更進一步,點在可以有 focus 的元件上,那focus事件的順序呢?如果已經有其他元件有 focus,那它的blur事件又是在focus的前面還後面呢?除了滑鼠事件外,鍵盤事件又更複雜,除了keydownkeyup之外還有inputchange和 IME 的 composition 事件等,總之最近實在是太在意了,就認真的弄了個測試網頁自己測試,之後又查找了相關的標準規範,這篇就來記錄一下這些事件的順序。

首先就來說滑鼠(指標裝置)相關的事件順序吧,第一個是滑鼠點擊,就是mousedownmouseupclick,是在mouseup後才接著有click事件,其實仔細想了一下也是蠻合理的,要有 up 事件才代表完成了click事件。然後這順序其實是有定義在 UI Events 5.3.3 之中的,其中的最後一個表格就是一個標準的點擊時事件的順序,而且這個表格還包括了mousemovedblclick事件,這個章節中的其他部分則是滑鼠移動經過不同、多層的 DOM node 時,不同 node 上的mouseovermouseout事件的順序。

閱讀「UI Event Order」全文

digital envelope routines::unsupported

Node.js 16 LTS 已經結束維護,所以手上的東西就開始需要升級升級,然後就必須要來正面面對這個我逃避已久的錯誤訊息:

digital envelope routines::unsupported

這錯誤基本上就是發生在幾個網站的專案,尤其是 build 專案時特別會容易看到,而且這個錯誤其實和一般看到的 JS 錯誤長得不太一樣,全貌其實是這樣:

digital envelope routines::unsupported

Error: error:0308010C:digital envelope routines::unsupported

首先是錯誤訊息,前面有一些 hex 值,不知道是什麼,然後下面 trace 的地方,可以看到幾乎都是 node_module 內的東西,不是因為我們自己的 code 造成的,所以就很讓人困惑,想說是不是什麼系統問題、還是有什麼偷用非公開 API 造成不相容的狀況。總之以前就是遇到這個問題就是又降版回來,沒有仔細深究,這次終於要來認真處理,不過搜尋結果,幾乎都是說加一個--openssl-legacy-providerflag,都沒人說到底是什麼問題,尋找許久,終於在 StackOverflow 找到一則最正確的答案,沒想到和 OpenSSL 1.x 的生命已經到盡頭有關。

結果這個錯誤,其實是因為 Node.js 17 開始,從 OpenSSL 1.x 換到 3.x,然後 OpenSSL 3.x 不是向下相容的,所以有些東西有機會出錯,這邊爛掉的,其實是一些 legacy 的 hash method 預設是拿掉的,而 Webpack 在建立 bundle 檔案時,如果檔名有用到 hash 的話,預設的 hash method 用的就是已經被淘汰的 md4,然後 md4 是用 Node.js 的 crypto 來呼叫 OpenSSL 做事,Node.js 的文件也有提到支援的演算法是依據你的 OpenSSL 版本和系統而定,所以其實並沒有保證 md4 一定可以用,而如果使用了 OpenSSL 不支援的演算法,跑出來的錯誤訊息就是像上面截圖一樣特別了,然後我還特別去用 OpenSSL 3 cli 跑跑看,結果出來的錯誤訊息真的就是差不多:

OpenSSL 3 error

使用 flag 開啟舊演算法的支援其實我覺得還算可以接受,畢竟是 build 而已,不是拿來跑服務,不過這個 flag 似乎有點特殊,似乎不能直接放在NODE_OPTIONS裡面,而且同個程式庫要是拿到舊版 Node.js 環境去跑,加這個 flag 反而跑不起來,所以最理想還是把問題解決掉。

那這個問題應該怎麼處理呢?其實簡單說就是把套件升級升級就好了,因為現在的套件新版本都有處理這個問題,不過走上升級這條路之前可以先試試看 StackOverflow 上的解法(有可能讓你專案爛掉,請先備份):

npm audit fix --force

如果你用的是 yarn,沒有audit fix可用,但是也有人提供用 npm 來修理的流程,不過我是沒試過這個流程,我自己有一個專案是靠yarn upgrade升級後解決問題的(實際上是把所有有用到的 loader-utils 都升級到 2.0.4,本來有個套件用到 2.0.0),剩下的還是無法修好的就要靠手工了,然後因為我處理的網站只有 Gatsby 和 CRE(Create React App) 兩種,所以以下就是只有說明這兩個系統的為主,兩者其實都是使用 Webpack 作打包工具的,而 Webpack 是從 v5.61.0 開始保證支援 Node.js 17 的,我稍微查了一下 Gatsby 是從 4.2.0,而 CRA 的則是要最新版 react-script 5.0.1 才保證支援,為什麼說是保證呢?因為^的 semver range 的關係,例如要是你的 react-script 是 5.0.0,那你本地可能會是裝到 Webpack v5.60.0,那就不支援 Node.js 17 了,像我就是有 Gatsby 3.x 的,升級到 4.x 就沒事了。

Gatsby 和 CRA 其實都還好,最慘的是 eject 過的 CRA 了,只能手工升級,基本上就是去 react-script 那邊,複製需要的檔案回到你的專案覆蓋過去,最主要的是scripts/config/下的檔案,然後根據自己的修改紀錄把自己作過的修改改回去,接著更新package.json裡面的 dependencies,版本號就是參照 react-script 那邊的 package.json,最主要的就是webpack相關的,接著安裝套件後重新 build,要是還有一樣的錯誤,就看 trace 看看是哪個相依套件,看有沒有新版有修正就更新試試看,大概就是這樣,很容易漏東西所以會一直重複測試,蠻花時間的,不過最後 build 成功還是有成就感的。

PS. 還要小心其他升級的後遺症,如果是 app 最好要測試過各種行為,像我遇到 Webpack 5 不支援 polyfill Buffer 的問題,剛好那個錯誤又被 catch 掉,所以我 build 是沒問題的,就是測試跑不過,後來參考網路上的文章處理。


CSP for Lambda@Edge

CSP

之前工作上主要是用 AWS,AWS 放靜態網站有過 CloudFront CDN 時,如果需要調整 header 的話,官方的解決方案是用 Lambda@Edge,寫 AWS Lambda function 的時候,其實我個人有一個偏好,就是能不用第三方 module 就不用,主要原因有兩個,第一個原因是,如果程式碼太大包,會無法在 AWS console 上直接看(或修改)程式碼;第二個原因是發佈流程會比較麻煩,因為還要去安裝 module,然後再全部打包起來上傳。

要調整 header 的一個主要原因就是為了 security headers,大部分的 security header 都還算單純,但是 CSP(Content Security Policy)就複雜很多了,如果沒有用結構化的資料,其實很難維護,但是針對 Lambda function 我又不想要用第三方 module,最後我想到的解決方案,就是設計一個很簡短的工具函式來把結構化的資料轉成 CSP header 的值,這就是我最近趁 COSCUP 2021 會議期間整理好的新的 open source 專案:CSP

這個專案內容就只是一個簡單的 function:

const CSP = (directives) => {
  return directives
    .map((directive) => {
      return `${directive.name} ${directive.value.join(' ')};`;
    })
    .join(' ');
};

不過為了好好設計這個 function 其實我也是花不少功夫,首先就是輸入參數的結構要長怎樣,其實一般比較常見的是用物件 property 直接就作為 directive name 的形式,像是 Google 的 CSP Evaluator

{
  "default-src": ["'none'"],
  "script-src": ["'self'"],
  "connect-src": ["blah", "blah"]
}

這種結構比較精簡,但是問題就是無法保證順序,考慮再三之後,決定還是用陣列的形式:

[
  {
    "name": "default-src",
    "value": ["'none'"]
  },
  {
    "name": "script-src"",
    "value": ["'self'"]
  }
]

這樣就可以讓開發人員確保輸出的順序,其實大部分時候我也不會那麼在意順序,不過要是default-src如果不是第一個感覺就很不舒服。確定主要的資料結構後,再來就是屬性名稱要用什麼好的問題了,為了找到正確的名稱,我去翻了 CSP spec 找到關於 parsing 相關的說明,確定了 spec 定義的結構是這樣的(使用 TypeScript 語法):

type Source = string;

type Directive = {
  name: string;
  value: Source[];
};

type Policy = {
  source: "header" | "meta";
  disposition: "enforce" | "report";
  directiveSet: OrderedSet<Directive>;
};

type Policies = Policy[];

在輸入資料的陣列中,每個元素都是DirectiveDirective的兩個屬性分別是namevaluevalue則是Source的陣列集合,當然Source還有更嚴謹的定義,不過這邊就簡化成字串就好。確定完輸入資料的結構後,就是要想盡辦法簡化 function 的內容了,但是也不希望太難讀懂,調整了幾次變成現在的樣子,我還提供了精簡的版本:

const CSP = p => p.map(d => `${d.name} ${d.value.join(' ')};`).join(' ');

其實我對於那個mapjoin一直耿耿於懷,很想要用reduce解決,但是要避免頭尾多空白,會需要多判斷式,就算不予理會,程式碼長度其實還是比現在這個版本長,結果還是mapjoin看起來比較漂亮,所以最後的版本就維持這樣了。

然後我還寫了測試和提供了兩個 example,分別是 Lambda@Edge 和 Cloudflare Workers 的,不確定還有沒有類似的服務,如果有發現會再加上。最後就是,因為這個 function 設計就是要給人複製貼上的,所以並沒有發布到 npm 上,然後使用 MIT-0 license 所以也不用 attribution,覺得有興趣使用的就請直接複製貼上吧~

PS. 如果有其他需求,可以看看 csp-header,例如 Express 使用,我覺得介面設計得很不錯。


Circle CI run Terraform and AWS deployment

最近花很多時間在 CI,其中一個比較大的目標是跑 Terraform 加上用它輸出的 S3 name 來作為後面發佈步驟的發佈目標,然後加上不想要用第三方的 docker image 和 orbs,不過網路上都沒看到有這樣子做的範例,所以花了些時間嘗試、看文件和範例,這篇就是把一些目前的結論記錄下來:

Terraform 是用 hashicorp 官方的 image,基本上就是 alpine + go + terraform 而已,shell 只有 sh 沒有 bash,不過其實 Circle CI 的一些文件看起來,他們應該是建議要使用 bash 為主,其中一個主要原因就是 BASH_ENV 這個環境變數有沒有支援,支援的話就可以很輕鬆的在不同 command 間傳遞環境變數了,不過還好我在 Terraform 這邊只需要寫入,還不需要讀出,所以就是 Terraform 執行完之後加一個 command 執行:

echo "export S3_ID=`terraform output s3_bucket_name`" >> $BASH_ENV

當然你的 terraform module 要有定義好 output。

第二個是重點是$BASH_ENV的值,個人建議是設定絕對路徑,直接寫出完整路徑,不要用其它環境變數來組合,然後位置要放在 working directory 內,好方便能persist_to_workspace,這樣才能夠跨 job 使用,另外就是檔名建議不要用.開頭的隱藏檔名,我遇到過各種找不到檔案的錯誤訊息,然後 working directory 建議不要放在 home 目錄下,一來$BASH_ENV去用$HOME組合出來我遇到錯誤過,用~來寫路徑也是遇到錯誤過,二來不同 image 的 home 目錄路徑不同,如果要在 config 內直接寫死絕對路徑,建議直接定一個固定的位置,我現在是用:

/tmp/workspace

然後這樣後面就可以用官方的 s3 orb 下指令了:

- aws-s3/sync:
    from: build
    to: "s3://${S3_ID}"
    aws-region: "ap-northeast-1"

glob

最近在搞 jsctags-oasis 這個專案,因此認真的研究了一下 glob,glob 這東西其實有在使用 CLI 的話,一定是使用過的,例如:

ls *.js

後面的*.js就是 glob,應該可以稱為一種表達式吧,沒有正規表示式(Regular Expression)強大,是專用於匹配檔案的,現在也已經是內建於 Linux shell 內的功能了,所以其實只要man glob.7或是man 7 glob就可以找到官方文件了(不過 macOS 上沒有),然後 glob 和正規表示式相比,有個很關鍵的差異就是 glob 是有判斷路徑階層的,也就是其實?*雖然是任意字元,但是/不屬於任意字元,/又被稱為 path separator,如果要找不同層子目錄的檔案,就要把路徑寫好,不然比對時不會如願找到想要的目標,而這個差異其實也說明了為什麼ls subfolder/*只會印出該層子目錄下的檔案,而不是把第二第三層子目錄下的東西也都印出來,雖然有**這個寫法,不過我是在 nodejs 開始蓬勃發展之後才在 node-glob 文件上看到的。

其實我第一次看到 glob 這個單字也是 node-glob,不過當時以為 node-glob 和命令列的那套不相容,只是借用名字而已,因為那個**/*.js的語法我以前沒看過,一直以為是 node-glob 自己做的,直到這次研究才發現其實**bash 提供的擴充語法,bash 的 extglob 提供了一些更接近正規表示式的語法:

?(pattern-list)
       Matches zero or one occurrence of the given patterns
*(pattern-list)
       Matches zero or more occurrences of the given patterns
+(pattern-list)
       Matches one or more occurrences of the given patterns
@(pattern-list)
       Matches one of the given patterns
!(pattern-list)
       Matches anything except one of the given patterns

另外還有很多設定可以調整 glob 的行為,其中一樣叫做globstar的,就是讓**可以 recursive 的 match 子目錄的檔案,這個功能是在 bash 4.0 alpha 版的時候新增的,到今天其實也已經超過十年了。

至於為什麼會研究起 glob 呢?是因為我在做 jsctags-oasis 時,要盡量的支援 Exuberant Ctags 支援的參數,其中做到exclude的時候,一開始偷懶用了 node-glob 的 ignore,但是實際上要拿 vim-gutentags 來用時卻行為不如預期,為了能正確支援就研究起這實際上怎麼串起來的,首先是 vim-gutentags 會拿 Vim 那邊的 wildignore 送給 ctags,wildignore 使用的表達式是 Vim 自己的 filepattern,和 glob 有點接近,像是*都是正規表示式的.*,還有?都是正規表示式的.,不過*有特別說到:

Unusual: includes path separators

這行為就和 glob 不一樣了,所以假設 ctags 的exclude也是用 glob 表示式,那是不是表示 vim-gutentags 這邊實做有不正確呢?結果我發現 Exuberant Ctags 的文件是這樣說的:

each pattern specified using this option will be compared against both the complete path (e.g. some/path/base.ext) and the base name (e.g. base.ext) of the file, thus allowing patterns which match a given file name irrespective of its path, or match only a specific path. If appropriate support is available from the runtime library of your C compiler, then pattern may contain the usual shell wildcards (not regular expressions) common on Unix (be sure to quote the option parameter to protect the wildcards from being expanded by the shell before being passed to ctags; also be aware that wildcards can match the slash character, '/').

這時候就要感謝那時期的文件都有寫得很詳細,不用花時間去看程式碼,這邊的說明就是說會比對 basename (檔名加附檔名)和完整的 pathname,另外對於 wildcard 的支援則是看系統,是用 shell wildcards,其實就是 glob 表達式,不過照這樣說,應該就和 Vim filepattern 不一樣了,研究許久才注意到關鍵的地方就在上面那段文件的最後一句,提到 wildcards 也會 match 到/字元,也就是最前面提到的 path separator,結果就是, Vim filepattern 和 Exuberant Ctags 的exclude用的表示式基本上是相容的,但是也因為特性就無法用 node-glob 的ignore來支援了。所以我就照著說明自己實做了比對的部分,然後有用到一個叫 globrex 的 npm package,這個是 tiny-glob 底層用的工具,算是個偷吃步,不管 path separator 直接把 glob 轉成正規表示式的作法,根據原始碼,它會直接把*轉換成.*,這樣就會 match 到/字元了,本來是偷吃步的作法,卻意外的剛好合用,理論上這樣就可以正確的支援 ctags 的exclude才是吧。


addEventListener 的第三個參數

addEventListener

2007 年我寫過一篇一樣標題的 addEventListener 的第三個參數,介紹了事件發生時, DOM Node 的 capture 和 bubbling,事隔十多年,前陣子定睛一看,發現 DOM spec 有變,第三個參數除了可以收 boolean 型別的 useCapture 之外,還可以收 options 物件,又稱為 EventListenerOptions,而這個 options 物件現在支援三個屬性,分別是:

  • capture-就是以前的第三個參數 useCapture,Boolean 型別。
  • once-新的選項,也是 Boolean 型別,用途就像是 jQuery 的 one 一樣,想不到現在也直接在 DOM 層原生支援了
  • passive-也是新選項,一樣是 Boolean 型別,用途是告訴瀏覽器,這個事件 handler function 會不會呼叫event.preventDefault來停止瀏覽器的原生行為,我最初其實是在 Google 的關於 scroll performance 的文件 看到的,就是如果你是 scroll event,以前會因為瀏覽器要判斷會不會被preventDefault,所以讓 scroll 的效能變差,加上這個選項可以直接告訴瀏覽器說沒有要 preventDefault 後,原生的事件行為就可以不管 event handler 直接處理了,如果裡面硬是執行event.preventDefault的話,那就會被忽略掉,然後根據使用的瀏覽器的話,有的會有警告訊息出現在 console。

Passive Event 的效果也有人做了影片可以看(來源):

EventListenerOptions 這個東西大概是在 2015 開始討論的,然後 2016 進到 WICG 討論,瀏覽器開始實做,一開始就是只有passivecaptureonce則是後來才加上的,所以可以看到 MDN 的瀏覽器支援度表格,once還要比較新一點的瀏覽器才支援,像是 Chrome 51 就支援passive,然後要到 55 才支援once,如果再仔細看,會發現最後一列是touchstarttouchmove事件如果是在 document 層的話,預設改為 passive 事件,這是 2017 年 Chrome 主導修改的行為,Firefox 也有跟進,主要就是希望能讓這些事件處理預設效能好一點,這部分的行為修改其實到現在都還沒標準化,目前還是在 WICG 那邊有個 open issue,除了 touch 事件外,其實連 document 層的 wheel 事件也在 Chrome 73,也就是現在的穩定版本也預設改為 passive 事件了,然後也是有 WICG 的 open issue,MDN 的表則是還沒有。

EventListenerOptions 也是有 polyfill 和工具 package 的,首先來說一下 polyfill 吧,我知道的有 dom4,其實搜尋一下還蠻容易找到其它的,不過差異沒很大,feature detection 的方式幾乎都是用 Object 的 getter 來看送進去的 options 物件的passive屬性有沒有被讀取過,有的話就表示瀏覽器有支援,然候 polyfill 其實也只有行為上的補完,不會真的讓效能提升,不過 polyfill 在現在的支援度下來看也是不太需要了。Package 的話有個 default-passive-events 會幫忙把 scroll、wheel、mouse、touch 等等有需要的事件都改為預設passive: true,如果是新專案先加一下似乎不錯,不過感覺上 Google 是很想都改掉的樣子,現在其實也只差 mouse 事件而已,應該是還怕影響太大不敢下手吧。


async, await and try catch

New Zeland

這篇想說一下asyncawait語法的一些小細節,首先從async來說吧,一般來說,async function 是在內部有需要用await等 Promise 結果的時候才使用,也由於這個特性,async function 的回傳值都會是個 Promise,意思就是你回傳非 Promise 的值,會自動被包成 Promise,所以像下面的程式:

async function wow () {
    return Promise.resolve(100);
}

wow().then(v => { console.log(v); });

就等同於:

async function wow () {
    return 100;
}

wow().then(v => { console.log(v); });

和直接回 Promise value 比起來,效能上不會有什麼顯著差異,從建議的實做方法來看就是多一個判斷。再來看看await吧,首先一樣,await一般是用來接 Promise 的,不過其實也是可以接非 Promise value 的

async function wow () {
  var r = await 1;
  console.log(1);
}

wow();
console.log(2);

所以這樣的程式碼也可以正確執行,不過 await 那邊的執行方式還是會維持非同步的(實際上應該是後面的東西都會用 Promise 包起來一次),所以這段程式碼的輸出會是先輸出2再輸出1

再來這點可能比較多人知道,就是連續的多個await不會讓這些非同步操作同時開始:

async function wow () {
  const a = await fetch('/a');
  const b = await fetch('/b');
  const c = await fetch('/c');

  return [a, b, c];
}

這樣其實三個請求會照順序執行,a有結果了才去要bb有結果了才去要c,而不是同時處理,如果要同時發出請求則還是需要用Promise.all,然後不用async了:

function wow () {
  return Promise.all([
    fetch('/a'),
    fetch('/b'),
    fetch('/c')
  ]);
}

不要await的話,也是可以先 assign 給變數的:

function wow () {
  const a = fetch('/a');
  const b = fetch('/b');
  const c = fetch('/c');

  return Promise.all([a, b, c]);
}

然後其實Promise.all是要所有的 Promise 都 fulfilled 時才會 resolve,另外一個角度來看,就是其中只要一個 rejected 的話,就不會 resolve,實際上使用起來變化有點少,而且要做忽略錯誤的fetch也有點麻煩,所以現在 TC39 還有個新的草案叫 Promise.allSettled,不管是 resolve 還是 reject,只要所有參數內的 Promise 都結束了,allSettled就會 resolve,目前這草案還在 stage 1,過幾天的會議有望升到 stage 2,不過這是題外話。

最後一個想說的就是await處理 rejected Promise 的問題,如果是從 jQuery 時期就開始寫 Deferred/Promise 的人,可能會很習慣的把 Promise 的兩種狀態拿來當成值的一部份,事實上這也是jQuery.ajax的設計,如果用這種想法來寫await接值的時候,就會覺得很難處理rejected的狀態,因為要用try...catch

async function wow () {
  try {
    const a = await fetch('/a');     
  } catch (error) {
    // deal with non-ok fetch
  }
}

要這樣寫還不如用舊的.then來接看起來還漂亮一點。不過實際上,這是錯誤的理解 Promise,Promise 不是用來取得兩種狀態用的,而是用來非同步取得單一個數值用的機制,而所謂rejected的狀態,其實就是發生非預期狀況(unexpected exception)的情形,這也就是為什麼 ECMAScript 版的 Promise 是用throw Error的方式來 reject Promise。

我一直覺得用 HTTP 請求來比較這兩種設計蠻好理解的,使用 jQuery 的ajax,server 端回非 200 的 status 的話,就會被當成是錯誤,然後回傳的 Promise 就會被 reject,但是在使用 ECMAScript Promise 的 fetch 中,不管 server 端回應的 status code,fetch 都會 resolve,而會 reject 的情形,就只有網路有問題的時候,像是網路斷線、存取被拒絕(CORS)等完全碰不到遠端主機的情形,也就是對於一個 HTTP 請求來說,真正的非預期狀況,所以如果你有兩種狀況要處理,那應該是回傳值的一部份,後面再用if...else來做分支。

回來看await的使用,究竟應該什麼時候來用try...catch呢,我自己有一個很簡單的初步判斷條件,就是這個取值的程式碼,如果不是非同步,沒有使用await的話,你會不會用try...catch包起來,不會的話,那改成非同步操作的程式碼應該也不用try...catch。不過現實世界當然還是比較難一點,非同步的取值風險和狀況還是比較多的,例如fetch遇到網路問題會 reject,但是還是需要處理這種狀況,不用try...catch的話,怎樣寫比較好呢?我的想法是,用.then/catch處理好需要處理的情形,然後把結果包起來傳回去,所以要處理fetch的非預期狀況的話,就可以改成:

async function wow () {
  const a = await fetch('/a').catch(error => {
    return {
      ok: false,
      status: -1,
      error: error,  
    };
  });
  
  if (a.status === -1) {
    // exception error handling
  }
}

這邊我設計成有非預期狀況時,status code 為-1,並且把 error 資訊也傳回去,然後後面就可以直接拿來判斷是不是非預期狀況,當然也可以把這個處理包成一個自己的myFetch

const myFetch = (url, options) =>
  fetch(url, options)
    .catch(error => {
      ok: false,
      status: -1,
      error: error,  
    });

然後原來的程式就可以直接拿myFetch取代fetch了。

如果要通用一點的,其實有一個叫 await-to-js 的套件我覺得蠻不錯的,直接拿官方的範例看吧:

import to from 'await-to-js';

async function asyncTaskWithCb(cb) {
  let [err, user] = await to(UserModel.findById(1));
  if (!user) return cb('No user found');
}

它可以包裝 Promise 物件,然後不管那個 Promise 成功還是失敗,它自己都會 resolve,resolve 的值就是[error, value]這樣形式的陣列,一來符合 node 的 error-first callbacks,再來就是配合 destructuring assignment 其實程式碼是蠻漂亮的。


ES Module for NPM Package

Queenstown

For English reader: https://github.com/othree/til/blob/master/js/esm-package.md

這個問題我卡蠻久了,最近才解決加上找好一些資訊的來源,目標就是要讓一個 NPM package 同時提供 CommonJS module 和 ES module 的版本,現在很多地方可以用 ES module 了,像是 Node.js 自己有經有在測試用 mjs 副檔名,webpack 和 rollup 也都支援 ES module 的 bundle,而且要 tree shaking 的功能也需要使用 ES module,用以前的 CommonJS 是不支援的,不多廢話,直接看怎樣做吧:

{
  "name": "smartypants",
  "version": "0.1.1",
  "main": "smartypants",
  "module": "smartypants.es6.js",
  "jsnext:main": "smartypants.es6.js",
  ...
}

package.json 這樣寫,然後需要提供以下三個檔案:

-rw-r--r--  1 othree  staff  21874 Jul 14 10:38 smartypants.es6.js
-rw-r--r--  1 othree  staff  24885 Jan  9 17:12 smartypants.js
-rw-r--r--  1 othree  staff  21874 Jul 14 10:38 smartypants.mjs

這段是我從 smartypants.js 那邊拿來的,重點在:

  1. main裡面的檔名不寫副檔名,該檔名要同時提供jsmjs兩種
  2. 多加上module這筆設定

說明一下,Node.js 現在判斷是哪種模組格式的方式是看副檔名,所以一定要mjs的檔案才會當成 ES module,然後剛好解析main檔案時的副檔名會自動補,所以就乾脆拿掉,同時提供smartypants.jssmartypants.mjs兩個檔案,其實都是main用的;再來是module這個設定和 Node.js 以及 NPM 無關,其實是 rollup 提出來的 pkg.module,rollup 如果在解析模組實有看到這個設定,就可以把這個檔案拿來用,當時設計是這個設定 ES module,以前的 main 則是 CommonJS module,雖然是 rollup 提出的,不過 webpack 現在也支援了,範例中還有一筆jsnext:main則是比較早期用的 key。

再更進階一點,還有目標對象的問題,就是產出是瀏覽器用的還是 server 端用的,以前這問題不太常見,不過隨著 server side rendering 越來越普及,這問題就開始比較多人關注了,webpack 就有支援 bundle 的目標對象,也有支援 pkg.browser 設定,webpack 的 issue #5673 有不少討論,有興趣的可以參考看看,不過要注意的是browser似乎是第一順位,設定的時候要小心點。


此類別所有文章