2019 年 7 月 15 日

Shadow DOM 和事件

Shadow tree 背後的想法是封裝元件的內部實作細節。

假設點擊事件發生在 <user-card> 元件的 Shadow DOM 內。但主文件中的指令碼不知道 Shadow DOM 內部,特別是如果元件來自第三方程式庫時。

因此,為了保持細節的封裝,瀏覽器會重新設定目標事件。

在 Shadow DOM 中發生的事件,在元件外部被捕捉到時,會以主機元素作為目標。

以下是簡單的範例

<user-card></user-card>

<script>
customElements.define('user-card', class extends HTMLElement {
  connectedCallback() {
    this.attachShadow({mode: 'open'});
    this.shadowRoot.innerHTML = `<p>
      <button>Click me</button>
    </p>`;
    this.shadowRoot.firstElementChild.onclick =
      e => alert("Inner target: " + e.target.tagName);
  }
});

document.onclick =
  e => alert("Outer target: " + e.target.tagName);
</script>

如果您按一下按鈕,訊息會是

  1. 內部目標:BUTTON – 內部事件處理常式取得正確的目標,Shadow DOM 內的元素。
  2. 外部目標:USER-CARD – 文件事件處理常式取得 Shadow 主機作為目標。

事件重新定位是一項很棒的功能,因為外部文件不必了解元件的內部結構。從其觀點來看,事件發生在 <user-card> 上。

如果事件發生在實際存在於光 DOM 中的插槽元素上,則不會發生重新定位。

例如,如果使用者在以下範例中按一下 <span slot="username">,則事件目標就是這個 span 元素,對於陰影和光處理常式皆是如此

<user-card id="userCard">
  <span slot="username">John Smith</span>
</user-card>

<script>
customElements.define('user-card', class extends HTMLElement {
  connectedCallback() {
    this.attachShadow({mode: 'open'});
    this.shadowRoot.innerHTML = `<div>
      <b>Name:</b> <slot name="username"></slot>
    </div>`;

    this.shadowRoot.firstElementChild.onclick =
      e => alert("Inner target: " + e.target.tagName);
  }
});

userCard.onclick = e => alert(`Outer target: ${e.target.tagName}`);
</script>

如果按一下 "John Smith",則對於內部和外部處理常式,目標都是 <span slot="username">。那是光 DOM 中的元素,因此不會重新定位。

另一方面,如果按一下源自陰影 DOM 的元素,例如按一下 <b>Name</b>,則當它從陰影 DOM 中冒出時,其 event.target 會重設為 <user-card>

冒泡、event.composedPath()

對於事件冒泡的目的,會使用扁平化 DOM。

因此,如果我們有一個插槽元素,而且事件發生在其中某個地方,則它會冒泡到 <slot> 和更上層。

可以使用 event.composedPath() 取得包含所有陰影元素的原始事件目標的完整路徑。正如我們從方法名稱中所見,該路徑是在組成之後取得的。

在上述範例中,扁平化 DOM 為

<user-card id="userCard">
  #shadow-root
    <div>
      <b>Name:</b>
      <slot name="username">
        <span slot="username">John Smith</span>
      </slot>
    </div>
</user-card>

因此,對於按一下 <span slot="username">,呼叫 event.composedPath() 會傳回一個陣列:[spanslotdivshadow-rootuser-cardbodyhtmldocumentwindow]。那是組成之後,扁平化 DOM 中目標元素的父鏈。

只有 {mode:'open'} 樹會提供陰影樹詳細資料

如果陰影樹是使用 {mode: 'closed'} 建立的,則組成路徑會從主機開始:user-card 和更上層。

這是與其他使用陰影 DOM 的方法類似的原則。封閉樹的內部結構會完全隱藏起來。

event.composed

大多數事件都能成功地冒泡通過陰影 DOM 邊界。有少數事件無法做到。

這由 composed 事件物件屬性控制。如果它為 true,則事件會跨越邊界。否則,它只能從陰影 DOM 內部擷取。

如果你查看 UI 事件規格,大多數事件都有 composed: true

  • blurfocusfocusinfocusout
  • clickdblclick
  • mousedownmouseup mousemovemouseoutmouseover
  • 滾輪,
  • beforeinputinputkeydownkeyup

所有觸控事件和指標事件也都有 composed: true

不過有些事件有 composed: false

  • mouseentermouseleave(它們完全不會冒泡),
  • loadunloadaborterror
  • select,
  • slotchange.

這些事件只能在事件目標所在的同一個 DOM 中的元素上觸發。

自訂事件

當我們發送自訂事件時,我們需要將 bubblescomposed 屬性都設定為 true,才能讓它冒泡到組件外。

例如,這裡我們在 div#outer 的影子 DOM 中建立 div#inner,並在上面觸發兩個事件。只有 composed: true 的事件會冒泡到文件外

<div id="outer"></div>

<script>
outer.attachShadow({mode: 'open'});

let inner = document.createElement('div');
outer.shadowRoot.append(inner);

/*
div(id=outer)
  #shadow-dom
    div(id=inner)
*/

document.addEventListener('test', event => alert(event.detail));

inner.dispatchEvent(new CustomEvent('test', {
  bubbles: true,
  composed: true,
  detail: "composed"
}));

inner.dispatchEvent(new CustomEvent('test', {
  bubbles: true,
  composed: false,
  detail: "not composed"
}));
</script>

摘要

只有當事件的 composed 旗標設定為 true 時,事件才會跨越影子 DOM 界線。

內建事件大多有 composed: true,如相關規格中所述

一些有 composed: false 的內建事件

  • mouseentermouseleave(也不會冒泡),
  • loadunloadaborterror
  • select,
  • slotchange.

這些事件只能在同一個 DOM 中的元素上觸發。

如果我們發送一個 CustomEvent,那麼我們應該明確設定 composed: true

請注意,在巢狀組件的情況下,一個影子 DOM 可能巢狀在另一個影子 DOM 中。在這種情況下,組成事件會冒泡到所有影子 DOM 界線。因此,如果一個事件只針對直接封裝的組件,我們也可以在影子主機上發送它,並設定 composed: false。然後它就會在組件影子 DOM 之外,但不會冒泡到更高級別的 DOM。

教學課程地圖

留言

留言前請先閱讀…
  • 如果您有建議事項 - 請 提交 GitHub 問題 或提交 pull 要求,而不是留言。
  • 如果您無法理解文章中的內容 - 請詳細說明。
  • 若要插入幾行程式碼,請使用 <code> 標籤,若要插入多行程式碼 - 請將它們包覆在 <pre> 標籤中,若要插入超過 10 行程式碼 - 請使用沙盒 (plnkrjsbincodepen…)