如何編寫一個前端框架之六-自定義元素(譯)

tristan發表於2018-04-19

本系列一共七章,Github 地址請查閱這裡,原文地址請查閱這裡

自定義元素的好處

這是編寫一個 JavaScript 框架系列的第六章。本章,我將會討論自定義元素的好處和它們在現代前端框架核心內的可能角色。

元件的時代

近些年元件風靡整個網路。所有的現代前端框架諸如 React,Vue 或者 Polymer - 都使用基於模組化的元件。它們提供了不同的 API 並且底層工作方式不一致,然而他們和其它的最新的框架有一些相同的以下功能。

  • 他們有一組API,用於定義元件並按名稱或者選擇器來註冊它們。
  • 他們提供生命週期鉤子,可以用來設定元件邏輯和同步狀態檢視

直到最近,這些功能還缺少了一個簡單的原生 API ,但是這隨著 Custom Elements spec 的定稿而改變。自定義元素可以涵蓋以上功能,但它們並不總是完美的匹配。讓我們們走著瞧^.^。

自定義元素

自定義元素是 Web Components standard 的一部分,它在 2011 被提議,且在最近穩定前出臺了的兩個不同規範。最終定稿感覺是一個簡單原生的元件化框架替代品而不是框架作者的工具。它為定義元件提供了一個漂亮的高階 API, 但它缺少不需用墊片的功能(需要相容外掛來支援)。

如果您還不熟悉自定義元素,請在繼續之前檢視本文。

自定義元素 API

自定義元素 API 是基於 ES6 類的。元素可以由原生的 HTML 元素或者自定義元素繼承而來,並且它們可以用新的屬性和方法擴充套件。他們也可以重寫一系列的方法-定義在規範中-可以作為他們生命週期的鉤子。

class MyEelement extends HTMLElement {
  // these are standard hooks, called on certain events
  constructor() { ... }
  connectedCallback () { ... }
  disconnectedCallback () { ... }
  adoptedCallback () { ... }
  attributeChangedCallback (attrName, oldVal, newVal) { ... }

  // these are custom methods and properties
  get myProp () { ... }
  set myProp () { ... }
  myMethod () { ... }
}

// this registers the Custom Element
customElements.define('my-element', MyElement)
複製程式碼

在定義之後,這些元素可以在 HTML 或者 JavaScript 程式碼中以名稱例項化。

<my-element></my-element>

基於類的 API 非常簡潔,但是在我看來,它缺少靈活性。作為框架作者,我更加喜歡棄用的 v0 API,它是基於老舊的經典原型方法的。

const MyElementProto = Object.create(HTMLElement.prototype)
// native hooks
MyElementProto.attachedCallback = ...
MyElementProto.detachedCallback = ...

// custom properties and methods
MyElementProto.myMethod = ...
document.registerElement('my-element', { prototype: MyElementProto })
複製程式碼

它大概不夠優雅,但是它可以把 ES6 和 ES6 規範之前的程式碼很好地整合在一起。從另一方面說,把 ES6 規範之前的程式碼和類程式碼混合在一起使用會是相當複雜的。

比如,我想要有能力控制元件繼承哪個 HTML 介面。ES6 類使用靜態的 extends 關鍵字來繼承,並且它們要求開發者輸入 MyClass extends ChosenHTMLInterface

這非常不適用於我目前的使用情況,因為 NX 基於中介軟體函式而不是類。在 NX 中,可以用 element 配置屬性來設定介面,介面接受一個有效的 HTML元素名稱比如 - button

nx.component({element: 'button'})
	.register('my-button')
複製程式碼

為了達到這一目標,我不得不使用基於原型的系統來模仿 ES6 類。長話短說,操作起來比人所能想的要讓人蛋疼,並且它需要不需墊片的 ES6 Reflect.construct 和效能殺手 Object.setPrototypeOf 函式。

function MyElement() {
  return Reflect.construct(HTMLELEMENT, [], MyElement)
}
const myProto = MyElement.prototype
Object.setPrototypeOf(myProto, HTMLElement.prototype)
Object.setPrototypeOf(MyElement, HTMLElement)
myProto.connectedCallback = ...
myProto.disconnectedCallback = ...
customElements.define('my-element', MyElement)
複製程式碼

這只是我發現在使用 ES6 類的很困難的情況之一。對於日常應用,我覺得他們是非常好的,但是當我想要很裝逼地充分利用這門語言的功能的時候,我更傾向於使用原型繼承。

生命週期

自定義元素擁有五個生命週期鉤子,會在特定事件觸發的時候同步呼叫。

  • constructor 會在元素的例項化的過程被呼叫
  • connectedCallback 會在元素被掛載到 DOM 的時候呼叫
  • disconnectedCallback 會在元素被從 DOM 中移除的時候呼叫
  • adoptedCallback 會在當使用 importNode 或者 cloneNode 把元素掛載到一個新的文件之中的時候呼叫
  • attributeChangedCallback 會在當被監聽的元素屬性發生變化的時候被呼叫

constructorconnectedCallback 非常適合建立元件狀態和邏輯,而 attributeChangedCallback 可以被用來以 HTML 屬性來顯示元件的屬性,反之亦然。disconnectedCallback 用來在元件銷燬後清理記憶體。

整合在一起,這些涵蓋了一系列很好的功能,但是我仍然忽略了 beforeDisconnectedchildrenChanged 回撥。beforeDisconnected 鉤子適用於簡單的離開動畫,然而除了封裝或者大幅修改 DOM 是無法實現它的。

childrenChanged 鉤子對於橋接狀態和檢視是非常重要的。看下以下示例:

nx.component()
	.use((elem, state) => state.name = 'World')
	.register('my-element')
複製程式碼
<my-component>
	<p>Hello: ${name}<p>
</my-component>
複製程式碼

這是一個簡單的模板片段,把 name 屬性值從狀態插入到檢視中。當使用者決定置換 p 元素為其它元素時,框架會接收到改變的通知。它不得不清理老的 p 元素內容,然後把插值插入到新內容中。childrenChanged 可能不會公開為開發者鉤子,但是知道何時元件內容發生改變是一個框架必備的功能。

如我所述,自定義元素缺少一個 childrenChanged 回撥,但是可以使用老舊的 MutationObserver API 來實現。MutationObservers 也為老瀏覽器提供了 connectedCallbackdisconnectedCallbackattributeChangedCallback 鉤子的替代品。

// create an observer instance
const observer = new MutationObserver(onMutations)

function onMutations (mutations) {
  for (let mutation of mutations) {
    // handle mutation.addedNodes, mutation.removedNodes, mutation.attributeName and mutation.oldValue here
  }
}

// listen for attribute and child mutations on `MyComponentInstance` and all of its ancestors
observer.observe(MyComponentInstance, {
  attributes: true,
  childList: true,
  subtree: true
})
複製程式碼

除了自定義元素的簡潔 API,這將會產生一些自定義元素必要性的問題。

下一章節,我將會闡述 MutationObservers 和 自定義元素的一些關鍵區別以及使用的場景。

自定義元素 VS MutationObservers

當發生 DOM 改變的時候,自定義元素回撥會同步呼叫,而 MutationObservers 收集這些改變並非同步呼叫其中的一批。對於組織邏輯這並不是什麼大問題,但是它會在記憶體清理階段引發一些不可預見的 bugs。當待處理的資料還存在時,有一個小的時間間隔是危險的。

另一個重要的區別是, MutationObservers 沒有進入 shadow DOM 邊界。監聽 shadow DOM 裡面的改變需要自定義元素或者手動為 shadow 根目錄新增一個 MutationObserver。如果你從來沒有聽說過 shadow DOM,你可以在 here 檢視更多。

最後,他們提供了略有不同的掛鉤。自定義元素有 adoptedCallback 鉤子,然而 MutationObservers 可以在任意層次監聽文字的改變和子元素的改變。

綜上所述,把這兩者的最好的方面結合起來使用是一個好主意。

把自定義元素 和 MutationObservers 結合起來

因為自定義元素還沒有被廣泛支援,所以必須使用 MutationObservers 來檢測 DOM 改變。主要有兩種選擇。

  • 在自定義元素的基礎上建立 api ,然後使用 MutationObservers 來作相容
  • 使用 MutationObservers 建立 api ,然後當需要的時候使用自定義元素來新增一些效能改進

我選擇後者,因為 MutationObservers 是檢測子元素改變的必要條件,即使在完全支援自定義元素的瀏覽器中也是如此。

我為下一版本的 NX 使用的系統簡單地在舊瀏覽器的文件新增一個 MutationObserver。然而在現代瀏覽器中,該系統使用自定義元素為最頂層的元件建立鉤子,並且在他們的 connectedCallback 鉤子中新增一個 MutationObserver。這個 MutationObserver 可以用來扮演在元件內部檢測進一步的 DOM 變化的角色。

它只查詢文件中由框架控制的部分中的更改。對應的程式碼大概如下。

function registerRoot (name) {
  if ('customElements' in window) {
    registerRootV1(name)
  } else if ('registerElement' in document) {
    registerRootV0(name)
  } else {
     // add a MutationObserver to the document
  }
}

function registerRootV1 (name) {
  function RootElement () {
    return Reflect.construct(HTMLElement, [], RootElement)
  }
  const proto = RootElement.prototype
  Object.setPrototypeOf(proto, HTMLElement.prototype)
  Object.setPrototypeOf(RootElement, HTMLElement)
  proto.connectedCallback = connectedCallback
  proto.disconnectedCallback = disconnectedCallback
  customElements.define(name, RootElement)
}

function registerRootV0 (name) {
  const proto = Object.create(HTMLElement)
  proto.attachedCallback = connectedCallback
  proto.detachedCallback = disconnectedCallback
  document.registerElement(name, { prototype: proto })
}

function connectedCallback (elem) {
  // add a MutationObserver to the root element
}

function disconnectedCallback (elem) {
// remove the MutationObserver from the root element
}
複製程式碼

這會為現代瀏覽器帶來效能的好處,因為他們只需處理極少的 DOM 變化。

結論

總而言之,重構NX可以很容易地使用沒有很大效能影響的自定義元素,但是自定義元素在某些情況仍然會帶來效能的提升。我需要從他們之中得到的有用的乾貨即是一個靈活的底層 API 和大量的同步生命週期鉤子。

本系列一共七章,Github 地址請查閱這裡,原文地址請查閱這裡

相關文章