[DevOps]鳳凰計畫

DevOps 最佳佈道書一枚! 身為工程師,除了了解自己的領域外,建議能多多涉獵維運、行銷、設計等相關領域。這樣除了幫助溝通,也能以不同角度去觀察事物,並建立正確地做事方法。

故事簡介

Bill 是無極限零件公司的一名 IT 經理,有天忽然被 CEO 抓來負責這個可能讓公司起死回生的計畫:鳳凰計畫。

在本書的第一章,Bill 帶讀者經歷了每個傳統公司的 IT 部門都會遇到的困境,像是員工不遵守制度、部門工作集中於一位員工、tasks 雜亂無章難以追蹤等等…在 Bill 接近崩潰之時,一位來自董事會的成員,用工廠生產線作為比喻,引導 Bill 將 DevOps 的原則逐漸導入他的 IT 團隊,讓團隊漸入佳境。其中讓我印象最深刻的,是 SOX-404 外部稽核會議裡,公司用下游人工審查 cover 掉 IT 控制問題,本書在這裏帶入了『第一工作法』,告訴我們要時刻注意自己的任務是否和公司的大目標一致!這樣才不會花費大量時間在對公司沒有幫助的事情上。

第二章節,高層不理解 Bill,不但沒有支持 Bill 的改革計劃,還拼命施加壓力給 Bill,終於 Bill 憤而離職,讓整個公司從高處墜落,再從谷底浴火重生,印證『大破才能大興』這個諺語。第一章比爾帶讀者領悟了『四種工作類型』和『第一工作法』,第二章他們用約翰的逆襲帶入『第二工作法』,藉由到處訪問各部門的主管收集需求,開發新功能,再建立能正確把使用者感想傳達回開發端的回饋循環機制。之後為了加速這樣的循環,他們更搭配了自動化,發展出著名的『持續整合』和『持續交付』。公司每個部門的頭,性格都非常鮮明有趣,他們的轉變也令人興奮!

第三章是收尾,基本上就是品嚐勝利的果實,裡面也帶到很多厲害的 DevOps 公司 (Google/Facebook/Amazon/NetFlix…) 常見的開發日常,和第三工作法『培養勇於受挑戰的文化』。好的制度也必須搭配觀念正確的人,才能發揮這個制度最大的優點,所以教育訓練和 mentor 制度在公司很重要!好的員工一位難求~

我流紀錄

分成七個部分說明

不論什麼公司,維運一但失敗,業務就會失敗

  1. 在書中,CEO 認為維運部門只要讓服務乖乖不出錯就好,不需要改善與優化。這觀念嚴重阻礙了主角想做的改革,CEO 常常沒等主角解釋完,就用自己的想法直接評論事情。這樣的主管在我身邊聽到不少,有的擅自猜測員工接下來要說的話,然後直接歪樓;或是不懂裝懂,被員工靠氣勢糊弄等等…其實只要管理者保持虛心受教、事後仔細查證、避免偏見的態度去處理事情,這類問題也是能被解決的。
  2. 公司業務和 IT 之間的關係圖很重要,每個 IT 員工都要知道自己的工作和公司業務之間的關係,這樣不只激發向心力,在算績效時也比較多東西能說嘴。很多較大的公司,員工每季都要寫公司目標、個人目標,和自己在上一季對公司目標的貢獻,提醒每個人做的事情有沒有時時刻刻符合公司利益。
  3. 不管是什麼公司,其實都能稱作 IT 公司,因為沒有成功的 IT 就沒有成功的業務。只有強大的 IT,才能快速交付產品,並調整公司本身以應付市場環境的變化。舉例,像世界第一大藥廠輝瑞的產品,雖然不是最強的,但他的執行團隊就像訓練有素的軍人般執行上頭的銷售策略,能以最快最有效率的方式將藥品銷售到全世界,賺到最多利潤!輝瑞經常代理其他公司的藥品,靠該藥品賺取大把利潤後,再將該藥品的公司併購,壯大自己!

使用大家習慣的東西做任務追蹤

  1. 在主角改革前,開發部主管已經花了大筆錢建立了任務追蹤系統,嘗試要每個員工用該系統做紀錄,但大家不習慣每件事都要填一堆繁瑣的表格,最後這項政策也就無聲無息了。主角很聰明的把大家找來,要他們用最熟悉的便利貼做紀錄,在每天開會時交上便利貼,慢慢大家才習慣這種工作模式。我認為這也是 Scrum 課程喜歡從便利貼開始的原因,因為老外習慣用便利貼,一個便利貼就是一個任務,照著 TODO –> DOING –> DONE 的流程去追蹤每份工作。這裡重點在於用大家熟悉的工具去引入新的工作模式,像假設公司大多數人是習慣用 Email 的,最好做一個系統,當員工發 Email 給 IT 部門時,系統就自動產生對應的 Ticket 排到工作列中。

Dev 和 Ops 不應該在對立面,是相同目標的存在,應該共同合作與體諒

  1. 開發者應該要認知到,工作不是交出程式碼就結束了,應該要包含整個網站的部署,如何和維運部門一同合作,達成該網站上線後的維運需求,也是一門重要的課題!
  2. 整個產品 (ex. 網站) 的生命週期,都是開發者應該在乎的,對維運流程做優化, CP 值也很高!
  3. 維運部門和開發部門應該共同討論出合作的 Guideline
  4. 監控策略、Log 都是能創新、能一起討論的。不要覺得麻煩、懶得耗時間溝通,這是用別的角度切入產品的好機會!
  5. 以前是『程式碼即是應用』,有了 docker 後,變成『整個 Linux kernal 以上的環境 + 程式碼』即是應用,開發者要更有自覺,產出 docker image 且部署完工作才完成!
  6. 維運部門的工作應該是是開發或優化自助式的部屬介面,讓開發者能更方便的用 config 的方式來規劃如何去部署應用程式,而不只是值班監控或幫忙重啟…

避免約束點,約束點是整個部門的改善重點

約束點分兩種,一種是人,一種是工作站。

關於工作站的約束點,PMP 有提過類似的概念,critical path 經過的點就是約束點,只要有一個點 delay 就會影響整個專案進度。

這裡把重點放在『人』這個約束點,這個人涉入許多工作任務,只要沒有他,工作任務就很難完成 (done)。如果很多任務的進度很大的程度上,都依賴於這個人的做事效率,那這個人就是約束點,而且當這人涉入的工作越多,他的工作效率只會越來越差,就像是 queue 塞住無法疏通。

這現象的解決方法,就是將他的知識文件化,藉此把他的工作分擔下去!在鳳凰計畫裡,主角組成『踩坑小組』去採坑,把遇到的問題和解決方式一點一滴用文件累積起來。

約束點還有一個缺點,就是會慢慢把產品的架構變成自己的形狀,沒有人清楚他改了什麼,最後變成只有他懂內部架構在幹嘛。約束點一離開,整個公司就跟垮了差不多,這會是公司的大危機!很多台灣公司約束點的現象非常嚴重,Code Review 能有效避免這個問題,且 Code Review 不能只是做個形式,Reviewer 要知道每份改動的目的是什麼、解決什麼問題、是否是最有效的解法等等。

綜觀全局,善用各部門資源在對的時間做對的事情

在應付 SOX-404 外部稽核時,其實約翰應該善用會計部門的審查小組直接對公司的敏感資料做把關,讓 IT 能在當時 Focus 在更重要的事,而不是像初期為了快速解決 IT 控制問題而上機器偷改程式碼,導致機器重新部署時錯誤連連,給主角帶來一堆隱藏性的炸彈!

布侖特告密,論下屬溝通的重要性

在系統發生意外時,主角組織應變小組去解決問題,無法解決才去拜託約束點:布倫特,這目的是為了讓 IT 部門慢慢脫離約束點的掌控,但我覺得本書的主角沒有好好跟布侖特說明他的用意,導致布侖特對於自己好像被架空感到不安,就越級上報 CEO,CEO 就以為主角亂搞而指責主角的改革計劃。下屬是否有向心力,對整個計畫是否能更有效的進行,有很大的影響,就算邊摸著石頭邊過河,也要讓下面執行的人知道目前上面的策略,降低其他變因而造成失敗的機率。

不管什麼鉅細彌遺的計畫,都比不上日常的回饋改善

執行多年的鳳凰計畫沒有結果,一直是 Work In Progress,多年過去後,當時的評估也可能因為過時,上線後仍然以失敗收場。主角對直面客戶的部門主管們,做了一系列訪談,發現他花了整本書 2/3 頁數所努力的鳳凰計畫,其實是個過時、注定會失敗的垃圾專案,但全公司的未來依然賭在這個註定失敗的專案,讓他感到十分恐惶。

主角透過訪談收集需求,歸納出其中 IT 部門能幫忙的地方 (IT 造成的業務風險/IT 加強業務理解客戶需求的精確度…),在新功能上線後,要求業務收集使用者反饋,並快速回饋給 IT 部門,這才是最棒的鳳凰計畫!也就是第二工作法!

而要達到即時反饋必須要有自動化的 CI/CD 才行,如果人工一定 2 ~ 3 週跑不掉,不能像多個採用 CI / CD 的公司 (Amazon / Facebook…) 那樣每日部署 10 幾次。所以不是 DevOps 一定要有 CI / CD,而是如果沒有 CI / CD 就做不到 DevOps,我把 DevOps 的精髓在於新的管理思維!

結尾

好書改變老闆思維,改變公司文化,大家發大財 w

[影片推薦]瀏覽器中Javascript的運作機制

瀏覽器是如何做渲染的?

Browserwork Image 01

當使用者點擊一個按鈕觸發了 eventListener callback,瀏覽器會照上圖的步驟依序執行,先跑完 Javascript 再解析 CSS,CSS parser 產出的 CSSOM 會和 HTML DOM Tree 結合產生 Render Tree 後,交給 Layout 畫 element 佈局、Paint 畫像是圖片、顏色、文字等內容物、Composite 做不同圖層的疊加,最後輸出 pixel 到 Browser 頁面上。

上述過程中,有很多有趣的細節值得學習,下面針對 Javascript 的部分提供好康給大家參考!

Browserwork Image 02

Jake Archibald: In The Loop - JSConf.Asia 2018

這個演講對上面的流程做了一個很棒的梳理,必看啊!

影片裡面提到 JS 解析和渲染 GUI 兩者不會產生 race condition,是因為它們之間有 event loop 幫忙協調。Event loop、JS engine 和 render engine 的工作都是由瀏覽器的 main thread 負責執行,其他耗時操作 (ex. setTimeout, network, monitor) 會被分配到其他 thread 去執行,再回報給 main thread 去 render 畫面或執行 Javascript callback,如下圖。

Browserwork Image 03

Event loop 要搭配 task queue 才 work!Task queue 可以比喻成 event loop 的 To Do List,event loop 會在空閒時取出 queue 中的任務 (task) 執行。

JS engine 和 GUI render engine 因為都是使用同一條 main thread,故如果在 JS task 裡定義執行 while 無限迴圈,會 block event loop 導致瀏覽器畫面無法更新而死當,參考下面程式碼。

setTimeout(function(){
    while (true) // run here forever
}, 0);

下圖白色的方框代表目前 main thread 走到的地方,左邊是 Task queue,右邊是 GUI render engine,requestAnimationFrame 會在畫 Frame 之前執行 (Safari 的 Raf 是在畫 Frame 之後),瀏覽器會保證在畫 Frame 前執行完全部 “已經發出” 的 Animation callbacks。

Browserwork Image 04

影片裡面有提到為何做動畫時推薦使用 requestAnimationFrame,而不是 setTimeout。

我們知道 setTimeout(callback, 0) 會將 callback 放到 task queue 等待執行,但我們無法強制瀏覽器在下個 Frame 被畫出來之前就執行這個 callback。

瀏覽器是根據當下資源利用的狀況來決定何時執行 task queue 的 task,它可以在兩個 Frame 之間的空檔執行任意多個 tasks,也可以一個 task 都不執行,這是我們用程式無法控制的,故像動畫這種講求畫面流暢度的 feature,為了保證動畫的 script 能在畫下一個 Frame 之前跑到,並避免因重複執行而浪費資源的風險,requestAnimationFrame 比 setTimeout 更適合用在做 UI 動畫上面。

Browserwork Image 05

講者還建議把 animation task 批量放在 requestAnimationFrame callback 執行,如果有需求要強制更新 Layout 可以使用 getComputedStyle 直接強迫 style recalculate,有些人會利用這個特性產生動畫,如下圖,但這種不正常的渲染方式會耗費較多資源,不建議常用。

Browserwork Image 06

講者最後有介紹到 microTask,Promise 的 callback 都會放在 microTask 等待執行,microTask有兩個和 Task 比較不同的特性:

  1. microTask 會等 Task queue 空了才執行
  2. 當執行 microTasks 時,會 block event loop 直到 microTask queue 的 task 都執行完,如果 microTask queue 的 task 太多會造成畫面停滯

整個 JS 執行的順序如下: Task –> microTask –> requestAnimationFrame,影片裡給了很棒的題目測試大家對 task / microTask 執行順序的理解,如果想深入探討可以參考這篇Tasks, microtasks, queues and schedules


在聽影片的過程,我也找了一些文章來解答自己的一些疑問,在下面分享。

Notes on How Browser works!

這篇文章再一次詳細的書裡整個Browser的運作架構,也提到困擾我已久的”瀏覽器哪個process是single thread,哪個是multi-thread”等問題…

簡單來說,瀏覽器是 multi-process,一個瀏覽器只有一個 Browser Process,負責管理 Tabs、協調其他 process 和將 Renderer process 存至 memory 內的 Bitmap 繪製到畫面上 (pixel);在 Chrome,一個 Tab 一個 Renderer Process,Renderer process 是 multi-thread,其中 main thread 負責頁面渲染 (GUI render engine)、執行JS (JS engine) 和 event loop;network component 可以開 2~6 個 I/O threads 平行去處理。

當用戶輸入網址,Browser process 會下載頁面資料,將它交付給 Renderer process 做渲染,之後再將 Renderer process 吐出的結果拿來做繪製。

從 Event Loop 規範探討 Javascript 異步和瀏覽器更新渲染時機

這篇討論將 event loop 梳理的很仔細,留言的部分也有很多人補充。裡面有說到 event loop 是在 HTML Standard 定義的,不包含在 Javascript engine 的實作裡面。撰寫 Javascript 時所用到的 Ajax 網路資源請求、修改 DOM、EventListener attach 等,都是使用瀏覽器的 Web API,Web API 會將這些任務丟到其他相對應的 thread 執行,threads 執行完會將 callback 丟到 event loop 的 task queue 等待。

What forces layout / reflow

這篇 Gist 說明在 Javascript 哪些設值/取值的過程會造成畫面 reflow!

Browserwork Image 07

Reflow 是造成 Layout style 重新計算的意思,它同時也會將新的 Javascript 變更都計算進去,用下面同一段 code 舉例,當執行 getComputedStyle 時,在 getComputedStyle 之前的程式碼,像是 translateX(1000px) 也會被計算進去並呈現在畫面上;但在 getComputedStyle 之後的程式碼則不會,有人會用這個特性製造動畫效果,但不推薦。

Browserwork Image 06

[Performance]瀏覽器運作與效能指標評估

透過這次撰寫 Performance Report 的機會,學習一點瀏覽器運作的方法和網頁效能指標。

Performace 標籤總攬

這頁是我們這次的觀察範例: Yahoo 購物中心的 Mobile 版時時樂頁面。開啟 Chrome Dev Tool 的 Performance Tab 後按重新整理,會看到下圖。

Metric Image 01

Google 的 Performance tunning 教學可以參考此篇,下面會根據上圖的四個區塊再做一些解釋:

(1) Capture settings:按右上角的”齒輪”開啟設定,我們用它來設定跟環境相關的參數,像是降低 CPU 速度、模擬 3G 網速環境之類。

  • 發現網頁有渲染阻塞時,勾選 Enable “Advanced paint instrumentation (slow)”後,便可以在 Paint Profiler 看到更詳細的渲染事件過程,詳見此篇的 See Li’s answer

  • 沒有勾選 Disable JavaScript samples 時,會看到 JS call stack 的全部過程,如果你對 JS function 的呼叫過程沒興趣,可以勾選 Disable JavaScript samples,讓 Main 區塊乾淨一些。

    • 下方第一張圖是 rushbuy.js 在時時樂頁面的執行過程。由左到右是時間,由上到下是 function 呼叫順序。如圖,Rushbuy.js 執行了 window.webpackJsonp,這部分的 code 是 webpack 在打包時插入的,目的是使前端資源調用時能正常運作,稱為 webpack runtime。window.webpackJsonp 呼叫相對應的 JS 資源,該 JS 檔會再繼續呼叫此頁面使用到的 React component。
    • 下方第二張圖是勾選 Disable JavaScript samples 時的畫面,少了 JS call stack 花花綠綠的紀錄,看起來清爽許多。

Metric Image 02

Metric Image 03

(2) Overview: 第二個部分是渲染過程總覽,含三個部分,FPS、CPU 和 Net。

  1. FPS 代表每秒幾個 Frame。綠色線越高代表 FPS 越高,如果是動畫盡量要達到 60 FPS,綠色線上有紅色代表可能出現卡頓。
  2. CPU 代表消耗 CPU 資源執行哪些工作
  3. NET 比較少用,通常看 Network Tab 比較清楚

(3) 火焰圖: 這部分包含多個面向的詳細資料,我主要看的地方只有 Network 和 Main 而已。Network 看資源下載的順序,Main 看資源執行的順序。

  • 舉例,我們可以藉由下面火焰圖的 Network section,看到 HTML 檔下載完成後緊接著 Main section 的 HTML parsing,在執行過程中先遇到放在 head 的 CSS 並發出 HTTP request 請求 CSS 資源,後遇到放在 boby 尾端的 JS 再發出 HTTP request 請求 JS 資源。

Metric Image 04

(4) Details: 在火焰圖中點擊中意的事件,該事件的詳細資料會被顯示在 Detail 區塊。這個部分的圓餅圖總結、Call Tree 內每個 Activity 的耗時, Rendering 的 repaint flashing 和 Layout borders 標示都很有用!

瀏覽器運作簡介

簡單的瀏覽器架構如下:

Metric Image 05

  1. User Interface 是使用者看到的 UI 介面
  2. Browser Engine 負責與 Data Storage (localStorage, Cookies…) 溝通和操作 Render Engine,Firefox 的 Gecko engine 和 Safari / Chrome 的 WebKit engine 都是 Browser Engine
  3. Render Engine 是瀏覽器核心,為 Single thread,會一直循環等待事件觸發任務,它負責三種工作:

    • UI Backend: 渲染 (Paint) 頁面 (Single thread)
    • JS Engine: 負責運行Javascript (Single thread)
    • Network Component: 網路資源請求 (含HTTP Request / WebSockets / WebRTC),可同時發出多個請求

我們的重點在於頁面是如何被渲染出來給使用者觀賞的,也就是 UI Backend 的部分。當 Network Component 接收到 response 資源時,若該資源是 HTML 會被 UI Backend 解析成 DOM Tree;若該資源是 CSS 會被 UI Backend 解析成 CSSDOM Tree;若該資源是 JS 則會交給 JS Engine 處理。

DOM 和 CSSDOM 會被拿來建構 Render Tree,瀏覽器會根據 Render Tree 執行三段過程: Layout (版面佈局) –> Paint (繪製內容) –> Composite (圖層合併) 來完成 Frame 的繪製,如下圖。

Metric Image 06

這裡要特別提到 CSS 和 JS 的阻塞特性:

  • Javascript - parser blocking:當執行 JS 時,HTML / CSS 都會停止解析直到 JS 完成。其原因是 JS 擁有改變 HTML / CSS 的能力,故瀏覽器被設計成等待 JS 執行完後再繼續解析 HTML / CSS,避免無用的 HTML / CSS 解析。
  • CSS - render blocking:當執行 CSS 時,HTML可以繼續被 parsing,但 render tree 的建構會被暫停。因為 render tree 有 DOM 和 CSSDOM 這兩個來源,瀏覽器會等待整個 CSS file 的 CSSDOM 解析完後,再產生 render tree。

以瀏覽器的角度,會希望越快讓使用者看到畫面越好,所以它不會全部的 HTML 解析完才 Paint 頁面,而是將任務切成很多塊,解析完一部分就直接渲染到頁面,導致網頁擁有漸進出現的視覺效果。

時時樂頁面觀察

有了以上觀念後,我們再來對照看看真實網頁的頁面渲染過程。開啟 Dev Tool 後,建議禁用所有 chrome extension 來避免分析干擾,由下圖的 Main section 可以觀察到,chrome extension script 也會造成 parser blocking,安裝Disable Extensions Temporarily可以方便我們禁用 chrome 插件。

Metric Image 07

在開測之前,先簡單介紹這個頁面的架構。本頁面是採用 React + Redux 的 isomorphic app 架構,畫面大部分的 HTML 標籤和 store 的部分資料已經包含在 HTTP response 中 (server side render),isomorphic app 的好處有避免敏感的 API endpoint 露出、讓 First Meaning Paint (FMP) 的時間快一點、SEO 較好 (給搜尋引擎較多 HTML 資訊) 等…

大部分的 server side render 都會包含到 hero element (重點元件) 的內容,我們可以藉由勾選 disable javascript 來禁用本頁面的 JS,觀察 server side render 出來的 HTML 畫面長什麼樣子。

Metric Image 08

下圖是沒有執行任何 JS 的時時樂頁面,由此我們可以知道標籤列以下的內容是 client side render;標籤列以上的大 Banner 可能就是本頁的 hero element。

Metric Image 09

如果妳嘗試點擊標籤列會發現根本無法切換標籤,這是因為 client side 的 JS 被拔掉導致 onClick handler 沒有被附加到 HTML 元件上,使用者因此無法和元件互動,只能閱讀頁面內容。所以我們會建議在撰寫 React Component 時多使用原生 HTML tag 擁有的功能,像是要做 hyperlink 就要用 <a href=”” > 而不是 <div onClick={this.clickHandler} >,讓使用者在等待 client side JS 跑完之前,能和頁面做一些簡單互動。

接著我們回到設定,將 disable javascript 取消掉並 reload,會看到畫面由下面的左圖變成右圖。

左邊server side render,右邊加上client side render

本頁面的 main JS (rushbuy.js) 光是下載就花了 250ms,執行約花 500ms,兩者加起來共約 750ms。故使用者只能看著畫面乾瞪眼,直到 750ms 後才能和畫面做互動,很可能造成不好的使用者體驗。

Metric Image 12

效能指標

由上述經驗,我們可以總結出幾個會影響到使用者體驗的時間因子:

  1. 使用者多快能看到畫面?(Is it happening?)
  2. 使用者多快能看到有意義的畫面?(Is it useful?)
  3. 使用者多快能和畫面做互動?(Is it usable?)
  4. 使用者在與畫面互動的過程是否順暢?(Is it delightful?)

第一點和第二點的時間越快對使用者越好;第二點與第三點之間的時間差如果太大,使用者點擊畫面時會沒反應,或是造成卡頓,導致用戶體驗變差;第四點必須直接靠觀察 Performance Tab 分析,沒有相應的指標,偵錯範例可以參考這一篇,寫得非常清楚。

下面介紹幾個跟上述因子相關的效能指標,由 Google 提出!詳細說明可以參考這篇 User-centric Performance Metrics

  • First Paint (FP):當第一個 pixel 畫到畫面上的時間 (First Fame time),下圖是時時樂頁面的FP畫面。

Metric Image 13

  • First Meaning Paint (FMP):hero element 出現在畫面上的時間,hero element 由我們開發者自己定義,Dev Tool 會用黃色線標示出幾個候選的 hero element 給我們參考,如下圖。

Metric Image 14

  • Time to Interactive (TTI):Google 將這部分的評估劃分為兩個指標 (TTFI、TTCI),目前還在 Beta 階段,詳細計算方式可以參考其文件。我個人認為 TTFI 和 TTCI 沒那麼好用,不如直接取網頁的主要的 JS 所執行完的時間當作 TTI。下圖的 rushbuy.js 就是時時樂頁面最主要的 JS script,它執行完後就能應付此畫面大部分的互動行為。

Metric Image 15

以上三個都是和時間相關的效能指標,但使用者的感官體驗不只與時間相關,也與畫面漸進變化的過程有關。如果頁面在渲染過程中跳來跳去,就算渲染速度很快,也會讓使用者感到不舒服,Perceptual Speed Index / Speed Index 就是能幫助開發者評估使用者視覺體驗的指標。

  • Perceptual Speed Index / Speed Index (PSI/SI): 此數值能透過 Google LightHouse 獲得,它除了考慮時間,也會拿畫面截圖做分析比對,數字越小代表視覺體驗越佳。

Chrome Dev Tool 所標示出的指標線

Metric Image 16

依照上圖的標示依序看,橘色線是 First Meaning Paint candidate,是 Chrome Dev Tool 建議我們的 FMP 時間,可能會有一到多個,由開發者自己決定要取哪個時間作為 FMP;綠色線是 First Paint (FP),是第一屏 (Frame) 的時間點;藍色線和紅色線是前人常用的舊指標,藍色是 DOMContentLoaded event,會在 HTML 解析完呼叫,可以當成 Server side code 完成解析的時間,紅色線是 Load event,會在所有資源 (不包含 Async 等 Non-Blocking 資源) 解析完後觸發,時間大約會在 TTI 附近,可以用它作為 TTI 的參考。

結語

前端工程師追求給網頁使用者最好的互動體驗,了解瀏覽器運作和效率測量工具,能讓我們對自己作品的品質更有信心,共勉之XD


參考

[Web Component v1]JS Framework 以外的另一條道路

為了維護並修改同事寫的 web component v0 元件,我接觸到這個,標榜可以相容所有 JS Framework 的 web component 技術,在此做個紀錄。

Web Component 的意義

初入前端領域的小毛頭如我,在學完HTML, DOM, CSS, JS的基礎後,下一個抉擇就是要選什麼 JS Framework 入坑 (ex. Angular, React, VueJS…)。學習 JS Framework 可以增加我們的開發速度,引導我們正確的實踐方法,但每個 Framework 都有各自的生態圈,它們之間的相容性極低,若專案轉換到新的 Framework,舊 Framework 實作出來的 Components 大部分都無法拿到新的 Framework 中使用,過去努力開發的 share component 都付諸流水,這在變化極快的前端領域顯然增加不少成本。web component 被提出來就是希望能解決上述問題,並期望它的四個主要技術可以列入 W3C 擬定的標準

  • Custom elements
  • Shadow DOM
  • HTML templates
  • HTML Imports

目前只有 HTML templates 已經成為標準,其他三個仍在草稿階段,列為標準的好處是瀏覽器會接著跟進實作,如果未來做到全面支援,所有人就能直接利用原生的 BOM API 開發 web component,不只網頁效率倍增 (ex. Alex Russell 在 Chrome Dev Summit 2016 提到,如果使用 Framework,網頁渲染效率就會受限於框架本身,Framework是個對開發者方便,但將效率成本加築在終端使用者上的東西…),也解決前端框架無法相容的問題了。要撰寫自己的 web component,重點在活用 custom elements API 和 shadow DOM,因為目前各大瀏覽器的支援度仍不足,在開發前要先引入 web component polyfill

1
https://cdnjs.cloudflare.com/ajax/libs/webcomponentsjs/0.7.20/webcomponents.min.js

Custom Element 簡介

Custom Element是骨幹,讓我們能開發自定義元件,自定義標籤或是做屬性的擴充等…

自定義元件可以使用 ES6 class 來定義,用繼承 HTMLElement 來確保自定義的元件擁有完整的 DOM API,創建自定義元件有以下三個規則:

  1. 自定義元件的 TAG 名稱必須包含短橫線(-)。因此,<x-tags>、<my-element> 和 <my-awesome-app> 均爲有效名稱,而 <tabs> 和 <foo_bar> 則爲無效名稱。這個要求是為了讓 HTML 解析器能夠區分自定義元件和 HTML自身元件 (ex. div, span…)
  2. 不能多次註冊同一個 TAG,否則將產生 DOMException。
  3. 自定義元件不能自我封閉 (ex. <App />),因爲 HTML 只允許少數元件自我封閉。所以撰寫時,必須寫成封閉的 TAG (ex. <app-drawer></app-drawer>)。

我們可以把 web component 的自定義元件想成 React 的 Component,他和 React Component 一樣有自己的生命週期,可以根據 attribute 的變化觸發 element 做出改變,下面介紹幾個常用的 Custom Elements’ Life Cycle methods:

  • constructor:Custom Elements 剛建立時呼叫,通常會在這邊註冊 Event listener 或是創建 Shadow Dom。
  • connectedCallback:當自定義元件被插入頁面的 DOM 時會被呼叫,很像 React 的 componentDidMount
  • disconnectedCallback:當自定義元件被移除時會被呼叫,很像 React 的 componentWillUnmount,可以在這邊 remove Event listener 之類的。
  • attributeChangedCallback(attrName, oldVal, newVal):監聽的屬性有變動時會被呼叫,要搭配 observedAttributes() 使用。
  • adoptedCallback:整個 custom element 被人用 document.adoptNode(el) 呼叫時觸發。

以下是自定義元件範例的JS部分,這裡的 this 代表該 custom element 本身,其擁有的 DOM API (ex. hasAttribute) 都可以使用。下方範例裡的 Example class 繼承了 HTMLElement class,開放 ex 屬性給使用者做 getter/setter JS 設置,並監控其屬性變更,我們可以藉由 console.log 觀察剛剛提到的生命週期。

class Example extends HTMLElement {

  // A getter/setter for an open property.
  get ex() {
    return this.getAttribute("ex");
  }

  set ex(val) {
    // Reflect the value of the open property as an HTML attribute.
    if (val) {
      console.log("set ex attritube: " + val);
      this.setAttribute('ex', val);
    } else {
      this.removeAttribute('ex');
    }
  }

  set id(val) {
    // Reflect the value of the open property as an HTML attribute.
    if (val) {
      console.log("set id attritube:" + val);
      this.setAttribute("id", val);
    } else {
      this.removeAttribute("id");
    }
  }
  
  static get observedAttributes() {
      return ["ex"];
  }

  attributeChangedCallback(name, oldValue, newValue) {
    const hasValue = newValue !== null;
    switch (name) {
      case "ex":
        console.log("the ex is changed on " + oldValue + " to " + newValue);
        break;
    }
  }

  constructor() {
      super(); // always call super() first in constructor.
      console.log("In constructor");
  }
}

customElements.define('my-example', Example);

// set attribute by JS
document.getElementsByTagName("my-example")[0].ex = "setByJS"

// set id but not trigger attributeChangedCallback
document.getElementsByTagName("my-example")[0].id = "example"

在 HTML 插入剛剛定義的 custom Tag:

<div>
  <my-example ex="HAHA">
  </my-example>
</div>

我們可以觀察到 console 輸出為:

webComp Image 01

HTML 解析器解析到 my-example 標籤後,會先運行 constructor,再觸發 attributeChangedCallback,設定 ex 屬性為 HAHA,接著我在 JS 呼叫 set ex method 將 ex 設定為 setByJS,一樣也觸發了 attributeChangedCallback! 接著我們再用 JS 呼叫 set id method,設定屬性 id 為 example,但這次沒有觸發 attributeChangedCallback,因為只有 observedAttributes 所列的屬性被改變,才會觸發 attributeChangedCallback。

有了custom element 所提供的 DOM API,我們就能透過自定義屬性和添加 Event Listener,編寫 custom element 與終端使用者互動的邏輯,再配合 shadow dom 的封裝特性,做出華麗的外觀與變化。如何配合運用會在下面繼續說明。

以上範例原始碼可參考我的CodePen: Custom Element Example

Shadow Dom 簡介

Shadow Dom 是 web component 的靈魂,它讓開發者可以將自己的元件「模組化」

Shadow Dom 封裝元件內部的 HTML DOM/CSS,使其不受元件外面的環境影響,且因為外部的 CSS 無法直接修改到 shadow dom 的 CSS 內容,故 Shadow Dom 的 CSS class 命名可以和外部的 CSS class 撞名。目前的 HTML5 元件像是 <video>, <audio>, 各種 <input> 等等,都是 Shadow Dom 應用的產物。

要加 Shadow Dom,直接在 custom element class 的 constructor 裡用 this.attachShadow() 綁定 Shadow Dom Tree,官方建議不要用關閉模式 { mode: ‘closed’ } 創建 Shadow Dom,因為它會讓使用此 custom element 的開發者不能透過任何方式修改該 Shadow Dom 的 style 樣式,使該 custom element 的修改彈性變很低。綁定後,用 innerHTML 直接插入 dom 和 style 字串,之後的撰寫方式都和編寫普通的 HTML/CSS 相同,標籤 <slot> 代表插入 custom element 的 child element 位置所在,我們能用 ::slotted(css slector) 來裝飾該 child element。

以下是JS的範例,style 說如果 child element 為 p,則字的大小設 30px、顏色設為綠色

class Example extends HTMLElement {

  constructor() {
      super(); // always call super() first in constructor.
      let shadowRoot = this.attachShadow({mode: 'open'});
      shadowRoot.innerHTML = '<style>h1{ color: red; } ::slotted(p){ color: green; font-size:30px; }</style><div><h1>Hello Shadow DOM</h1><slot></slot></div>';
  }
}

customElements.define('my-example', Example);

Html範例,my-example標籤內有子元件 <p>I am slot</p>:

<div>
  <my-example>
    <p>I am slot</p>
  </my-example>
</div>

以下是呈現出來的畫面,我們可以看到子元件在紅色的 Hello Shadow DOM 後面,且被裝飾成綠色。

webComp Image 02

開 chrome console 可以觀察到 render dom tree 的 my-example 標籤下有 #shadow-root,內能觀察到被渲染的 shadow dom tree,外面的 CSS 是無法修改到此區域的 style

webComp Image 03

以上範例原始碼可參考我的CodePen: Custom Element Example & Shadow DOM

Shadow Dom + HTML template 讓程式更易維護

使用 HTML template 先定義好 Shadow Dom 的 HTML 結構和 style,再在 custom element 的 constructor 內複製 template 的內容節點到 shadow root。這種做法讓 custom element 的 shadow dom tree 更易讀也更好維護。

首先在 HTML 定義 template:

<template id="my-template">
  <style>
    :host { /* 整個 template */
      display: inline-block;
      padding: 20px;
      border: solid 1px red;
    }
    p {
      font-size: 20px;
      color: orange;
    }
    .slot {
      font-size: 20px;
      color: black;
    }
  </style>
  <p>My example</p>
    <div class="slot">
      <slot></slot>
    <div>
</template>
<div>
  <my-example>
    <p>Hello Web!</p>
  </my-example>
</div>

在 JS 的 custom element 複製 template content node 到 shadow root:

class Example extends HTMLElement {

  constructor() {
      super(); // always call super() first in constructor.
      let shadowRoot = this.attachShadow({mode: 'open'});
      const t = document.querySelector('#my-template');
      const instance = t.content.cloneNode(true); // copy template content node
      shadowRoot.appendChild(instance);
  }
}

customElements.define('my-example', Example);

結果畫面如下,一樣有裝飾到子元件:

webComp Image 04

以上範例原始碼可參考我的CodePen: Custom Element Example & Shadow DOM & HTML template

從 Web Component v0 到 v1

關於兩個版本的差異可以參考此篇,基本上 v0 的 custom element API 都有被 v1 向下支援,但 v0 的 Shadow piercing combinators 功能,也就是從外部透過 /deep/ 和 ::shadow 更改 shadow dom 的 style 方法已經完全被 v1 棄用,取而代之的是用 custom css property 釋出 style 接口,讓使用元件的開發者,透過元件作者所定義的 css property 更改 style 樣式。我個人覺得這樣的改版讓元件使用者很不方便,因為元件開發者不太可能知道元件使用者要怎麼裝飾元件,所以不一定能提供出足夠多的客製 css properties 出來。

實例

Yahoo 購物中心商品頁 的手機版,下方出現的 App 下載橫幅就是由 web component 做的!觀看時請開無痕,並開Dev Tool Console 轉到 Mobile mode ~


參考

[Javascript]非同步的救星Promise

Javascript Asynchronous Code

不論是撰寫前端 Javascript還是後端 NodeJS,異步操作都是無法避免的,像是向後端請求資料、處理瀏覽器事件、計時器等等都是常見的異步操作。 異步操作和我們熟悉的同步不同,當異步操作執行時,瀏覽器/NodeJS 不會等待當前操作的結果回傳後才執行下一行程式碼,他會繼續不回頭的執行下去,也就是我們常聽到的無阻塞特性 (non-blocking)。等遠端資料回傳後,瀏覽器或 NodeJS 才會通知 Javascript 引擎去呼叫使用者當初指定的 callback 做處理。

以下面程式碼為例。先定義一個函式 getRemoteData,其目的是用 Ajax 技術發出GET請求。我們運用 Javascript 中 function 可以當成變數使用的特性,將成功和失敗要執行的函式當成參數傳入 getRemoteData,並在裡面定義兩個函式的呼叫時機。這種撰寫方式稱為 callback pattern,被當成參數傳入的函式就叫callback,之後這個異步函式會在本文後面的例子不斷被使用到。

const getRemoteData = function (url, success, error) {
   let req = new XMLHttpRequest();
   req.responseType = 'json';
   req.open('GET', url);
   req.onload = function() {
      if(req.status == 200) {
         let data = JSON.parse(req.responseText);
         success(data);
      }
      else {
         req.onerror();
      } 
    }
   req.onerror = function () {
      if(error) {
         error(new Error(req.statusText));
      }
};
   req.send();
};

我們知道 Ajax 請求為異步操作,所以這裡程式不會等待 getRemoteData 完成後才繼續執行,而是會馬上接著跑 showStudents,此時 showStudents 的參數 students 仍為 null,故造成程式錯誤。

var students = null;
getRemoteData('/students', function(studentObjs) {
	 students = studentObjs; // 回傳資料
  },
  function (errorObj) {
     console.log(errorObj.message);
} );
showStudents(students); // null

要解決此問題,必須將 showStudents 放入 callback 函式,讓Ajax在資料回傳後執行 success callback 時,才執行 showStudents。

getRemoteData('/students', function(studentObjs) {
	 showStudents(studentObjs);
  },
  function (errorObj) {
     console.log(errorObj.message);
} );

這種寫法會讓熟悉多執行緒的 C#、JAVA 使用者一開始很不習慣,其中也有許多優點與缺點,本篇專注在討論缺點:callback hell,和如何利用 Promise 來解決 callback hell 的問題。

什麼是 callback hell? 如何做初步重構?

如果有異步動作需要有順序性的呼叫,在程式撰寫上常常需要一層包一層,如下面的例子,click 事件的 callback 包了 getRemoteData 這個異步操作,getRemoteData 的 callback 又註冊了 mouseover 事件,mouseover 的 callback 又包了 getRemoteData,這種寫法會導致程式的可讀性變差,我們稱這叫做callback hell。

var _selector = document.querySelector;
_selector('#search-button').addEventListener('click',
   function (event) {
    event.preventDefault();
    let ssn = _selector('#student-ssn').value;
    getRemoteData(`/students/${ssn}`, function (info) {
           _selector('#student-info').innerHTML = info;
           _selector('#student-info').addEventListener('mouseover',
              function() {
                  getRemoteData(`/students/${info.ssn}/grades`,
                      function (grades) {
                          // ... process list of grades for this student
		}); });
       })
       });
} });

為了讓程式更可讀,我們利用 javascript 的 function 是 first class citizen 的特性,將這些 callback 命名成一個個易解讀的變數並分離出來,這種做法叫做 continuation-passing style (CPS)。像上面的例子,可以被重構成如下。

var _selector = document.querySelector;
_selector('#search-button').addEventListener('click', getStudentSNN);

var getStudentSNN = function (event) {
    event.preventDefault();
    let ssn = _selector('#student-ssn').value;
    getRemoteData(`/students/${ssn}`, showInfoAndFindStudentGrade(info)).fail(showError);
} };

var showError = function() {
            console.log('Error occurred!');
};

var showInfoAndFindStudentGrade = function (info) {
           _selector('#student-info').innerHTML = info;
           _selector('#student-info').addEventListener('mouseover',
              function() {
                  getRemoteData(`/students/${info.ssn}/grades`,
                      function (grades) {
                          // ... process list of grades for this student
		}); });
};

在這裡要順道提醒,異步操作和同步操作混用時要多多注意 closure 的特性,他會導致一些超出預期的結果,以下面 loop 內的異步呼叫為例子。

var students = [{ ssn: '111111', country: 'US' },
                { ssn: '111112', country: 'US' },
                { ssn: '111113', country: 'US' }];
for (let i = 0; i < students.length; i++) {
   let student = students[i];
   if (student.country === 'US') {
      getRemoteData(`/students/${student.ssn}/grades`,
         function (grades) {
            showStudents(student.ssn, average(grades));
         },
         function (error) {
           console.log(error.message);
         });
    } 
}

本程式的結果不是大家預期的:

111111 --> 80
111112 --> 78
111113 --> 90

而是

111113 --> 80
111113 --> 78
111113 --> 90

我們會發現,雖然後面的平均成績對了,但其學號都是 students 陣列中最後一位學生的學號。 這個原因是因為 showStudents 並不是馬上執行,而是等到 Ajax 請求的 grades 回傳後才被呼叫,此時的 student 變數已經不是當時請求送出時的 student了,而是被最後一次給值的 student,也就是學號為 111113 的 student。所以我們必須要用 curry 寫法來避免這種問題產生,如下。

var students = [{ ssn: '111111', country: 'US' },
                { ssn: '111112', country: 'US' },
                { ssn: '111113', country: 'US' }];
for (let i = 0; i < students.length; i++) {
   let student = students[i];
   let showGrade = function(student){
        return function (grades) {
            showStudents(student.ssn, average(grades));
        };
    }();
   if (student.country === 'US') {
      getRemoteData(`/students/${student.ssn}/grades`,
         showGrade(grades),
         function (error) {
           console.log(error.message);
      });
   }
}

利用 closure 的特性將 student 變數鎖在 showGrade 的 closure 之中,這樣呼叫 callback 時就會使用到當時指定的 student 變數,並達到我們原本預期的結果。

111111 --> 80
111112 --> 78
111113 --> 90

雖然 CPS 有改善程式的可讀性,但仍遠不夠應付更加複雜的應用程式操作,這時就來到本篇的重點,Promise!

初解Promise

我們常把 Promise 用來包裝異步函式,因為它能做到以下幾點:

  1. 用 pipeline 的方式解決 callback hell 問題
  2. 好的 Error handling 方式

Promise 擁有三種狀態: Pending, Fulfilled, Rejected,Pending 代表此 Promise 內的程式還未完成;Fulfilled 代表程式已執行成功,到達此狀態時 Promise 會呼叫 resolve 函式;Rejected 代表程式執行失敗,到達此狀態時 Promise 會呼叫 reject 函式。

FP

我們可以親自建構 Promise 要何時到達 Pending, Fulfilled, Rejected,如下面是我們用 ES6 Promise 做的展示,ES6 Promise 採用的是 Promises/A+ 的標準。

我們設定 Promise 中異步請求的結果如果回傳成功,就到達 Fulfilled 狀態並執行 resolve(result);請求結果如果回傳失敗,到達 Rejected 狀態並執行 reject 函式。

var fetchData = new Promise(function (resolve, reject) {
    // fetch data async or run long-running computation
    $.get(url, function(result){
        if (<success>) {
            resolve(result); // successful calllback
	    } else {
            reject(new Error('Error performing this operation!'));
        }
    });
});

根據上面的範例,resolve 和 reject 函式都是”非同步操作完成”後才執行,所以在執行到 resolve 或 reject 之前,此 Promise 會一直保持在 Pending 狀態等待遠端的回傳結果,由此達成”異步操作完成後才會繼續執行”的效果。

之後,我們便可以使用 Promise 的 then 指定 resolve 函式,catch 指定 reject 函式,如下:

fetchData.then(function(v){ console.log('Successful-' + v); })
         .catch(function(message){ console.log(message); });

resolve 和 reject 的回傳值會被包裝成新的Promise回傳,之後我們就能利用新的 Promise 繼續做串接,達到 pipeline coding 的效果,讓程式可讀性獲得提升。

fetchData.then(...).then(...).then(...)  // pipeline

Promise串接的圖解可以參考如下,每個操作都是接續同步執行的!

FP

Promise 在 Function Programming 裡類似於 Monad,關於 Monad 的圖解說明可以看這裡。簡單來說,Monad是一個值或是函式的 Wrapper,這個 Wrapper 擁有兩點定義:

  1. unit :: a -> Monad a (將 a 值用 Monad 包裝)
  2. flatMap :: Monad a -> (a -> b) -> Monad b (取出 Monad 的 a 做計算後,生成 b,將結果 b 重新包裝成新的 Monad)

在我們撰寫的 Promise 範例中,function (resolve, reject){ … } 相等於上面定義的 a 值。我們將這個函式用 Promise 包裝,之後用 then 指定 resolve 函式;用 catch 指定 reject 函式。Promise 會在執行 resolve 或 reject 時,將函式執行完的回傳結果包裝成新的 Promise。這樣的流程符合上面定義的 Monad 特性,所以我們可以說 Promise 其實是 Monad。

使用Promise

這節將幫助大家了解如何實際使用Promise。請看下面例子,這段程式首先呼叫後端全部學生的資料,將回傳的學生們經過排名後,用 for 迴圈檢查哪些學生的住所在 US,呼叫每一位在 US 的學生成績後計算平均,最後顯示在頁面。

 
getRemoteData('/students',
    function (students) {
      students.sort(function(a, b){
            if(a.ssn < b.ssn) return -1;
            if(a.ssn > b.ssn) return 1;
            return 0;
      });
      for (let i = 0; i < students.length; i++) {
         let student = students[i];
         if (student.address.country === 'US') {
            getRemoteData(`/students/${student.ssn}/grades`,
              function (grades) {
                  showStudents(student, average(grades));
              },
              function (error) {
                  console.log(error.message);
              }); 
         }
      } 
    },
    function (error) {
        console.log(error.message);
    });

如果單純的使用 callback 撰寫,程式碼的可讀性會變得很差,重用性也不高,接下來我們就用 Promise 改寫。

(1) 將異步操作包成Promise (known as promisifying a function)

首先,讓我們將異步操作 getRemoteData 改寫成回傳 Promise 資料結構的 getPromiseWithRemoteData!

const getPromiseWithRemoteData = function (url) {
    return new Promise(function(resolve, reject) { // return promise
        let req = new XMLHttpRequest();
        req.responseType = 'json';
        req.open('GET', url);
        req.onload = function() {
            if(req.status == 200) {
                let data = JSON.parse(req.responseText);
                resolve(data); // being fulfilled
            }
            else {
                reject(new Error(req.statusText));
            }
        };
        req.onerror = function () {
        if(reject) {
            reject(new Error('IO Error'));
        } };
       req.send();
   });
};

(2) 將連續動作都用 then 的呼叫方式改寫,用 catch 方式補充例外處理

有了 getPromiseWithRemoteData 後,就能將上面的程式改寫成下面,程式碼一下子少了很多。

getPromiseWithRemoteData('/students')
    .then(R.sortBy(R.prop('ssn')))
    .then(R.filter(s => s.address.country == 'US'))
    .then((students) => { 
        return students.map((student) => {
            return getPromiseWithRemoteData(`/students/${student.ssn}/grades`)
                    .then((grades) => showStudents(student, average(grades)))
                    .catch((error) => console.log(error.message))
            })
        })
    .catch((error) => console.log(error.message))

補充 Promise.all()

根據上面的 students.map 程式碼,每個學生會透過 getPromiseWithRemoteData 取得所有分數後將平均顯示到頁面上,再回傳 Fulfilled 狀態的 Promise。但向遠端請求分數時,每個學生的分數回傳時間不一樣,會導致學生們的分數整合困難。舉例,如果我們希望得到住在 US 的學生的平均分數的再平均,根據之前的思路我們可能會撰寫如下。

getPromiseWithRemoteData('/students')
    .then(R.sortBy(R.prop('ssn')))
    .then(R.filter(s => s.address.country == 'US'))
    .then((students) => { 
        return students.map((student) => {
            return getPromiseWithRemoteData(`/students/${student.ssn}/grades`)
                    .then((grades) => average(grades))
                    .catch((error) => console.log(error.message))
            })
        })
    .then((averageGrades) => console.log(average(averageGrades)))  // wrong answer
    .catch((error) => console.log(error.message))

但這種需求用上面的寫法就會出錯,因為學生們的分數不會如我們想的那樣同時到達,而是會陸陸續續回傳,導致無法算出正確的平均,此時就需要使用 Promise.all()。 Promise.all()會將一個 Promise 陣列包裝成新的 Promise 物件,此 Promise 物件會等待陣列中的每個 Promise 都變成 Fulfilled 後,才執行 resolve 函式到達 Fulfilled 狀態,只要陣列中有一個 Promise 到 Rejected,Promise.all() 就會終止並到達 Rejected 狀態。我們可以將上面的程式改寫如下:

getPromiseWithRemoteData('/students')
    .then(R.sortBy(R.prop('ssn')))
    .then(R.filter(s => s.address.country == 'US'))
    .then((students) => { 
            return Promise.all(students)
                .then((student) => getPromiseWithRemoteData(`/students/${student.ssn}/grades`))
                .then((grades) => average(grades))
                .catch((error) => console.log(error.message))
            })
        })
    .then((averageGrades) => console.log(average(averageGrades)))    
    .catch((error) => console.log(error.message))

就算陣列元素本身不是 Promise,Promise.all() 也會先將陣列元素都轉換成 Promise 元素後,再用 Promise 包裝陣列。有了Promise.all(),我們也能做到”等待多個回傳資料”的功能。

後話

熟悉 ES6 Promise 後,可以搭配學習 Generator 和 Async/Await,觀察整個異步撰寫方法的演進。


範例參考與圖片來源