Atag – Web Components 最佳實踐

發表於2018-11-07

Atag - Web Components 最佳實踐

引子

上一次社群中談論起 Web Components 已經可以追溯到三四年前了,彼時 Web Components 仍處於不穩定的草案階段,Polymer 的出世使大家似乎看到了新一代的前端技術,但直到今天,在今年五月 Google I/O 釋出 Polymer 3 之後, Web Components 的規模化應用才看似成為了可能。

過去一段時間,我一直在使用 Web Components 構建淘寶小程式的 基礎元件 Atag。MDN 上對 Web Components 這個名詞的解釋是

Web Components是一套不同的技術,允許您建立可重用的定製元素(它們的功能封裝在您的程式碼之外)並且在您的web應用中使用它們。

我們從中提取幾個關鍵字:可重用 定製元素 封裝

這些特性剛好能滿足可複用元件的需求,更重要的是,這是由 W3C 標準提供的,面向標準程式設計不需要再考慮我使用的技術在未來幾年內會不會過時。目前社群中的框架大都具有傳染性,什麼是傳染性?如果你希望使用一個 React 元件,大概率你的整個使用者介面都會使用 React 來開發。而 Custom Elements 不是這樣,因為它替代的是 div,能使用 div 的地方就能使用它,它即插即用:引入一個 js 檔案就可以了,直接操作 DOM 使它的效能更高,它並不跟社群主流的框架相剋,這樣看來它更適合用來開發底層的基礎元件。

回到正題,這篇文章的目的,是希望總結在 Atag 開發階段中使用 Web Components 的經驗,避免大家踩坑。

基礎設施

webcomponentsjs

Github 地址

這是一系列 Web Components 規範的 polyfill 集合,如果你的目標使用者不是最新的現代瀏覽器,強烈建議引入這個庫。

引用方式 :

建議在 html 中使用 script 標籤引入它,而不是通過 npm 引入,這樣瀏覽器可以使用快取幫助你減少二次載入的消耗。

(以下引入方式二選一)

  1. 使用 bundle 整包引入,這樣會引入整個庫中包含的所有 polyfills。
    • 如果你需要按需引入 bundle,這個庫的 bundles 目錄下有一系列預打包好的 bundle 檔案,用字尾標明瞭它包含的功能:ce 表示 custom-elements,sd 表示 shadow-dom,pf 表示環境的 polyfills。
  2. [推薦] 使用 loader 按需引入,引入 webcomponents-loader.js 後,它會根據瀏覽器中的特徵按需載入。

這裡有必要對一些名詞做一些解釋:

  • ShadyDOM 這是 Shadow DOM 的 polyfill 的官方名稱,它通過劫持 HTMLElement 的原型方法來實現一些 Shadow DOM 節點擁有的功能,實際上它的原理是把節點新增到了真實(light) DOM 節點之上。
  • ShadyCSS 跟上面一樣,這也是 polyfill 的名稱,它提供了一些 Shadow DOM 節點內樣式的封裝,使得可以在真實 DOM 中模擬 scoped style 的效果。它的原理是通過解析和重寫 style 節點內部的樣式規則來實現的。

需要注意的是,在引入 polyfill 的同時,有一些功能是無法被模擬的,需要我們在使用的時候避開,在下文中會介紹到。

NOTE: 這個庫的 2.1.x 版本對 Symbol 的 polyfill 有一些問題,在官方修復之前建議使用 2.0.4 的穩定版本。

這裡提供一份 alicdn 的 bundle URL:

https://g.alicdn.com/code/npm/@webcomponents/webcomponentsjs/2.0.4/bundles/webcomponents-sd-ce-pf.js

polymer 3

Github 地址

在 Web Components 的實踐中,Polymer 不是必須的,但有了它會讓我們輕鬆很多。強烈建議使用最新版本的 Polymer 3,在這個版本不再使用 html 定義和引入元件,官方推薦使用 JS 模組的方式進行檔案組織,同時拋棄了 bower 迎接 npm,這使得很多現代的前端工具能派上用場,比如使用 webpack 和 babel 進行打包,使用 ESLint 對程式碼進行規則校驗,使用 prettier 對程式碼進行美化等。

安裝 polymer 3:

官方推薦的 polymer-cli 工具比較雞肋,可以不用。

在使用之前強烈建議對 polymer 的文件 或者本文進行一番瞭解,避免踩坑。

構建配置

為什麼要把構建配置單獨拿出來講呢,當然是因為有坑?。開發元件過程中 ES 678 已經是標配,執行在低版本瀏覽器還得依賴 Babel。Web Components 中每一個 Component,對應一個類 (class)。Babel 的轉換邏輯如下:


—>


這樣執行的話瀏覽器會顯示如下報錯 Uncaught TypeError: Failed to construct 'HTMLElement': Please use the 'new' operator, this DOM object constructor cannot be called as a function,大意就是被繼承的類 HTMLElement 必須使用 new 來初始化,不能使用函式呼叫 + apply 的使用方式。

針對這個問題 webcomponentsjs 額外提供了一份 custom-elements-es5-adapter-index.js 的 polyfill 來解決,這個檔案的具體程式碼見此。引入這個檔案可以通過在元件庫的 webpack 配置中新增或者額外在使用的 html 檔案中通過 script 標籤引入,只要在元件被註冊之前執行這段指令碼就可以避免報錯。

相容性

特性 Import ShadowDOM CustomElement Template
Chrome 最新版 (66) Y Y Y Y
Firefox 最新版 N N N Y
iOS 最新版 (12) N Y Y Y
Android (UC 11.6.0.960) Y Y Y Y

其中 import 特性在整個體系下比較雞肋,可以通過 webpack 打包的方式來替代。

結論是在移動端下,99% 以上的使用者可以通過 polyfill 的方式來獲得比較好的 Web Components 特性支援。

效能

(單位 ms) 註冊 1W 個元件 渲染 1W 個元件
pure web-components 55.5ms 48.6ms 47.2ms 934.3ms 889.0ms 915.1ms
polymer 184.9ms 191.9ms 197.8ms 768.0ms 858.4ms 785.0ms
React 16.2.0 38.9ms 38.0ms 40.60ms 1834.8ms 1754.8ms 1869.5ms
Rax 使用 View 和 Text 元件 86.4ms 73.9ms 82.4ms 11587.4ms 11238.0ms 11289.6ms
Rax使用 append={‘tree’} 模式 82.5ms 67.3ms 81.4ms 798.0ms 823.5ms 878.0ms

可以看到,由於 React JSX 的 VDOM 在構建時解析的加持,React 的註冊時間是最短的,但是放大到 1W 個元件的渲染時,原生 DOM 的效能就發揮出來了,web-components 獲得了比較優秀的表現。對原生 web-components 和 polymer,後者只是在註冊的時候由於需要在執行時解析模板字串,犧牲了一些效能,但是如果元件數量有限,這個效能差距可以忽略不計,加上 polymer 本身提供的一些元件化開發的便利,整體來看使用 polymer 獲得的收益還是比較高的。

基準環境:

  • Chrome 66
  • macOS 10.13.1
  • Macbook Pro 15’ late 2015

元件化

個人認為,Web Components 在整個前端的語境下更偏向於提供符合 DOM 標準的規範,而 Polymer 則是在這種規範之上的一種框架封裝,使用 Polymer 可以帶來更便利的元件化開發體驗。因此這裡就不多介紹如何使用標準的 custom element 來建立自定義標籤,下文都使用 Polymer 來封裝自定義標籤。文章中的元件、自定義標籤、自定義元件其實描述的是同一個東西。

推薦使用以下模式建立一個自定義元件。


使用以下方式註冊自定義元件。


使用以下或任意方式使用自定義元件。


生命週期

PolymerElement 繼承了 HTMLElement,所以它擁有和 HTMLElement 一致的生命週期。

constructor:元件被 create 的時候會被呼叫,整個生命週期中最早觸發也只會觸發一次,通常可以在這裡做一些初始化私有變數、記錄資料的一些操作;但是出於效能和職責分離的考慮,不建議在這裡做一些 DOM 相關的事情。

connectedCallback:元件被 連線 到 DOM Tree 的時候會觸發,這個時機包括節點被插入節點樹、節點被從節點樹中移動,所以它可能會被觸發多次。你可以在這裡監聽 DOM 事件或者對 DOM 節點做一些修改。

disconnectedCallback:元件被從 DOM Tree 中移除的時候觸發,這個生命週期也可能被觸發多次。如果你在 connectedCallback 中監聽了事件,一定要記得在這裡移除,否則事件監聽回撥可能會一直引用導致記憶體洩露和一些奇怪的問題。

adoptedCallback:不常用,不多介紹。

attributeChangedCallback:當元件的 attribute 發生變化的時候觸發,它的三個形參分別是 name, oldValue, newValue,記得別把順序搞反了。如果你宣告瞭 properties 物件,對 attribute 的相應值變化也會觸發這個回撥。需要注意的是,如果你覆蓋了元件的 observedAttributes 靜態方法,properties 物件中宣告的值不會觸發,它會按照你覆蓋的 observedAttributes 靜態方法的返回值為準。

除此之外,polymer 還額外新增了一些生命週期

ready:由於 HTMLElement 的生命週期中沒有一個可以操作 DOM,又只觸發一次的週期,Polymer 人為地新增了 ready 這個時機,它在整個生命週期中只會觸發一次,也就是第一次節點插入到 DOM 樹的時刻。

記得呼叫 super.ready() 來觸發 PolymerElement 的初始化階段。在初始化階段,Polymer 會做以下幾件事情:

  • attache 元件例項的 Shadow DOM,所以在這個階段之後才可以訪問 this.shadowRoot
  • 初始化 properties,並賦初始值
  • 如果 properties 有宣告 observer 或者 computed,會執行它們

通常可以在 ready 函式中給元件例項新增一個 this._isReady = true; 的狀態以標明元件已經 ready。

首屏效能優化技巧

我們知道頁面的首屏渲染直接影響了這個頁面的效能,元件也是一樣。Polymer 提供了 render-status 的 afterNextRender 方法來幫助你在首次渲染之後執行一些不必要的 DOM 操作,比如新增事件繫結。


屬性 (property)和屬性 (attribute)

在 DOM 中,property 和 attribute 這兩個概念是嚴格區分的,雖然有時它們會產生類似雙向繫結的聯動,例如 id、input 的 checked 屬性等。

在 Polymer 中,使用 properties 靜態屬性來宣告元件的 property:


在大多數情況下,如果你的屬性需要暴露為公有 (public) API,你需要把這個屬性宣告到 properties 物件中。

關於 properties 物件的宣告,可以參考文件

屬性的預設值

通過 property 的 value 可以設定屬性的預設值,這裡強烈建議所有的屬性都顯式宣告一個預設值,這樣更清晰於閱讀,也避免了預設為 undefined 值處理的坑。

如果宣告的 value 是一個函式,Polymer 會在初始化這個元件的時候取函式的返回值為預設屬性;如果 value 是一個物件或陣列,Polymer 會在初始化這個元件的時候對 value 做一次淺拷貝,所以不用擔心會在不同元件例項中會共享同一個物件。


attribute 對映為 property

在 properties 物件中宣告的屬性,polymer 會自動建立從 attribute 到 perperty 的對映解析規則,解析的規則是:

  • 大寫的 attribute 名稱會被轉換為小寫的 property:firstName 對映為 firstname
  • 帶中劃線 -的 attribute 名會被轉換為小駝峰 (camelCased) 的 property:first-name 對映為 firstName

值的解析會使用宣告的型別去判斷和解析,對於 JS 基本型別的值,會直接轉換型別對映;對於 Object 和 Array 型別,會通過 JSON.parse 來解析。


由於 JSON 的序列化和反序列化會比較消耗效能,而且我們從資料來源獲取資料後一般已經是 JS Object,這個時候通過 property 賦值的方式來使用 Object/Array 型別的資料可以避免額外的效能消耗。


關於布林值,我們在 React 中習慣於顯式傳遞布林值 <Checkbox checked={false} /> ,但在 DOM 中,attribute 判斷布林型別的方式是判斷 attribute value 字串的長度。<a-checkbox checked /> <a-checkbox checked="false"> 前面這些都表示 checked 為 true,而只有 <a-checkbox /> 才表示 checked 為 false。

所以對於布林值,這裡建議在使用時跟物件一樣,使用 property 賦值的方式去修改,避免使用 setAttribute 修改布林型別的屬性。如果你希望修改 checked attribute 的語義,在為字串 “false” 的時候表示真正的 false 概念,你也可以宣告一個 getter 來幫助你在內部做判斷,但是其實我不建議這麼做。


property 對映為 attribute

雙向繫結不是必須的。

通過開啟 reflectToAttribute: true 選項可以自動把 property 對映為 attribute 值,序列化的方式跟上邊正好相反。由於涉及到 setAttribute 這一 DOM 操作,開啟這個功能是比較消耗效能的,僅在必要的時候開啟。

使用的例子如 radiocheckbox 元件的 checked 屬性,image 元件的 src 屬性等。

計算屬性

跟 Vue 的 computed 很像 (其實是 Vue 參考了這裡),計算屬性允許你用一個方法來計算 property 的值。

觀察屬性

跟 Vue 的 watch 很像,當你的屬性發生變化時,polymer 會通知相應的函式。


觀察 children 的變化

有時候我們需要觀察 children 節點的新增或者刪除,比如在 swiper 元件中,它需要動態地監聽子節點中 swiper-item 節點的增刪改,來變化其內部的 indicator-dots 數量等。

Polymer 提供了 FlattenedNodesObserver 工具集合來追蹤子孫節點的變化。


新增監聽器,每當子節點發生變化的時候會觸發對應的回撥函式,回撥函式的第一個引數包含了一些增加或者刪除節點的資訊可以使用:


當然不要忘記移除監聽器:


獲取子孫節點的方法:


模板語法

Polymer 使用 html 方法來把模板字串轉換成一個 DOM fragment,並可以自由繫結元件例項上下文中的屬性。

建立 Shadow DOM


slot 表示該元件子節點的引用,跟 Vue 的 slot 師出同門,對應 React 的 this.props.children 的概念。

需要注意的是反引號語法在這裡其實等價於 html(templateString),但是不要試圖在模板字串中使用插值,至於原因你可以參考阮老師的 ES6 參考

建立 Light DOM

有時候由於 Shadow DOM 的一些限制,它並不能滿足所有元件的需求,我們可能需要建立一個擁有真實節點的自定義標籤元件。

跟普通的 DOM 操作一樣,如果你需要建立真實節點(light dom),直接在 constructor 生命週期中使用 DOM 方法建立並新增到節點的 children 中即可。


詳細參考 https://www.polymer-project.org/3.0/docs/devguide/dom-template

公私有方法和屬性的命名約定

這是一個建議,但是 W3C 標準中的方法大多遵守這一約定。除生命週期方法外,元件的例項屬性和例項方法會被視作外部介面,可被外部直接訪問到。所以如果是私有的屬性和方法,需要加上 _ 作為字首以作區分。如


Polymer 官方建議使用 _ 字首表示私有變數,__ 字首表示受保護的變數,個人以為這種區分太過複雜,建議用一個 _ 來替代就可以了。

事件

這裡的事件指的都是 DOM 事件,你可以通過模板事件監聽或者 DOM 事件監聽兩種方式來繫結事件。前者是宣告式的而且不需要處理解除繫結,所以更加推薦使用模板事件監聽器的方式來繫結事件。

模板事件監聽器


這裡的 handleClick 指的是例項方法,其它就跟直接繫結 onclick 事件沒什麼差別。

DOM 事件監聽器


不要忘記在 disconnectedCallback 的時候解除繫結


有一種情況下你必須使用 DOM 事件監聽器:你希望監聽的事件節點不在 ShadowDOM 中,例如使用 window 或者 document 物件代理監聽事件等。

關於冒泡

slot 子節點的冒泡,在 ShadowDOM 中,slot 子節點是出現在影子節點樹內部的,所以它冒泡事件傳遞順序會包含 slot 節點,在 polyfill 的實現中並不是這樣,所以不要依賴這一項來實現一些功能。

觸發自定義事件

有時候元件可能希望在事件中帶入一些額外的 detail 資訊。如果你的事件名稱與 DOM 事件不同名,直接使用 Custom Event 的介面宣告和派發即可。


如果你的事件名稱與 DOM 事件同名,原生 DOM 事件可不支援修改,但是你可以在它冒泡的時候阻止它並派發一個自己的同名事件出來。

window 上捕獲你需要攔截的事件,並在事件處理回撥中停止冒泡原生事件和派發一個自定義的同名事件:


手勢

在移動端中手勢的處理可以說是比較常用的功能,我們可以通過監聽 touchstart touchmove touchend touchacancel 事件併合成處理幾乎所有的複雜互動。Polymer 為元件開發者提供了 Gesture 工具庫,你可以按需地引入它:


它提供了很多合成事件的封裝,比如在 ATAG 中,swiper 輪播器元件就使用到了 track 合成事件。

在 ready 的時刻新增 track 事件的監聽,


track 使你追蹤手指滑動的過程和方向。

有時候你需要考慮橫向滾動和縱向滾動之間的關係,比如在橫向滾動 swiper 輪播器的時候,通常並不希望同時觸發縱向的頁面滾動,這個時候就需要在開始內部滾動的時候為元件新增一個拖動的狀態,在拖動狀態中的 touchmove 事件需要被執行 preventDefault


另外在 Polymer 監聽 track 事件的時候,元件本身的 touch-action CSS 屬性會被置為 none,這是一個 Chrome 下已經支援的屬性,當為 none 時元件不向上傳遞觸控事件和阻止預設事件,跟 preventDefault 的效果一致。所以如果你需要響應滾動事件,可以參考以下方法自定義 touch-action 的值:


NOTE:

樣式

使用 Polymer 的 template 方法建立的 customElement 預設是 Shadow DOM,Shadow DOM 內的樣式是區域性作用域的,也就是說內部的樣式不會影響全域性。


避免動態修改 style 標籤來應用樣式

對於 Shadow DOM 內部的樣式,ShadyCSS 會解析和重寫以正確地作用到真實節點上,所以如果你的元件內部有動態建立或者寫 style 標籤的 innerHTML 屬性,這些都不會被 ShadyCSS 作用到,應當避免。

如果實在無法避免,我在使用的過程中使用了一種 HACK 的手段:

  • 給每一個元件新增一個 uid 的標識來區分元件例項,並帶到元件的 data-id attribute 上面
  • 在寫 CSS 的時候通過 [data-id=xxx] 選擇符來區分
  • 這種方式你需要同時寫 :host {} 和 my-element[data-id=xxx]{} 兩個 CSS 選擇符來保證同時非 polyfill 情況下和 polyfill 情況下都能工作
  • 由於對 ShadyCSS 的原始碼研究還不夠,可能還有更好的辦法,如果你有的話也請在下邊評論指教~~

測試

一個健壯的基礎應用必須有響應的測試機制來保障,一般一款軟體的穩定性跟它的測試程式碼/應用程式碼的比例正相關。DOM 相關的 UI 測試在前幾年一直有著不錯的發展,比如截圖對比測試,Driver 服務化等,但是真正被大規模使用的寥寥。本著實用至上的原則,Atag 的測試分為自動化的冒煙測試和手動的 UI 測試。

冒煙測試

顧名思義,就是渲染一個元件,看看有沒有報錯或者渲染異常,進行一些簡單的 DOM 渲染判斷。這裡我們用到了 Karma + puppteer 的搭配,如果你想驗證瀏覽器相容性的差異也可以加入更多的 karma-driver。

karma 配置檔案:


tests/index.js:


一個簡單的測試用例:


UI 測試

真正功能性的測試依舊是通過元件例子的方式來驗證,因為很多富互動的元件用例很難通過單元測試的方式書寫。有了 UI 測試用例,很多可視的元素效果能一目瞭然;當然你也可以使用一些針對 DOM 程式設計的測試框架。

這個是 Atag 的 UI 測試用例

參考資料

題圖出處 https://www.webcomponents.org/community/articles/why-web-components

相關文章