JS21. 使用原生JS封裝一個公共的Alert外掛(HTML5: Shadow Dom)

97z4moon發表於2021-12-11

效果預覽

Shadow DOM

 Web components  的一個重要屬性是封裝——可以將標記結構、樣式和行為隱藏起來,並與頁面上的其他程式碼相隔離,保證不同的部分不會混在一起,可使程式碼更加乾淨、整潔。其中, Shadow DOM  介面是關鍵所在,它可以將一個隱藏的、獨立的 DOM 附加到一個元素上 [ MDN ] 。

當我們對 DOM(文件物件模型)有一定的瞭解,它是由不同的元素節點、文字節點連線而成的一個樹狀結構,應用於標記文件中(例如  Web 文件中常見的 HTML 文件)。請看如下示例,一段 HTML 程式碼:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Simple DOM example</title>
  </head>
  <body>
      <section>
        <img src="dinosaur.png" alt="A red Tyrannosaurus Rex: A two legged dinosaur standing upright like a human, with small arms, and a large head with lots of sharp teeth.">
        <p>Here we will add a link to the <a href="https://www.mozilla.org/">Mozilla homepage</a></p>
      </section>
  </body>
</html>

這個片段會生成如下的 DOM 結構:

JS21. 使用原生JS封裝一個公共的Alert外掛(HTML5: Shadow Dom)

 Shadow DOM  允許將隱藏的 DOM 樹附加到常規的 DOM 樹中——它以 shadow root 節點為起始根節點,在這個根節點的下方,可以是任意元素,和普通的 DOM 元素一樣。

JS21. 使用原生JS封裝一個公共的Alert外掛(HTML5: Shadow Dom)

  • Shadow host:一個常規 DOM節點,Shadow DOM 會被附加到這個節點上。
  • Shadow tree:Shadow DOM內部的DOM樹。
  • Shadow boundary:Shadow DOM結束的地方,也是常規 DOM開始的地方。
  • Shadow root: Shadow tree的根節點。

準備工作

需求分析

常規的 alert 一般是一個 粘性佈局 & 層級較高 的盒子,它能夠被任意頁面 / 元件 呼叫,它不應該被同時多次呼叫。

盒子包含三塊內容:訊息圖示、訊息文字、關閉btn。

設計思路

外掛的設計思路是有良好的封閉性,不影響外部文件本身的DOM樹;易於維護,便於需求更改,在下一個專案中重複使用;足夠靈活,通過傳入引數配置元件在不同文件中的呼叫效果;能夠定製,可以通過外部文件調整外掛。

-

結合  ShadowDom  的知識點,實現一個 alert 已見雛形。

Shadow DOM的基本使用

使用 Element.attachShadow() 方法來將一個 shadow root 附加到任何一個元素上。它接受一個配置物件作為引數,該物件有一個 mode 屬性,值可以是 open 或者 closed

let shadow = elementRef.attachShadow({mode: 'open'});
let shadow = elementRef.attachShadow({mode: 'closed'});

兩者的區別在於能否通過 shadow.shadowRoot 訪問  shadowDOM  中的元素。

 { mode: 'open' } :可以通過頁面內的 JavaScript 方法來獲取 Shadow DOM

JS21. 使用原生JS封裝一個公共的Alert外掛(HTML5: Shadow Dom)

 { mode: 'closed' } :不能從外部獲取 Shadow DOM , Element.shadowRoot 將會返回 null。

JS21. 使用原生JS封裝一個公共的Alert外掛(HTML5: Shadow Dom)

瀏覽器中的某些內建元素就是如此,例如  <video> ,包含了不可訪問的 Shadow DOM。

將 Shadow DOM 附加到一個元素之後,就可以使用 DOM APIs對它進行操作,就和處理常規 DOM 一樣。

var para = document.createElement('p');
shadow.appendChild(para);
etc.

設計Alert

首先構造一個  Shadow DOM  :

class MessageBox extends HTMLElement {
  constructor() {
    super();
    const shadowRoot = this.attachShadow({ mode: 'open' })
  }
}

行1 - extends關鍵字用於類宣告或者類表示式中,以建立一個類,該類是另一個類的子類 [ MDN ]。

行2 - 建構函式屬於被例項化的特定類物件 。建構函式初始化這個物件,並提供可以訪問其私有資訊的方法。建構函式的概念可以應用於大多數物件導向的程式語言。本質上,JavaScript 中的建構函式通常在的例項中宣告 [ MDN ]。

行3 - super關鍵字用於訪問和呼叫一個物件的父物件上的函式。在建構函式中使用時,super關鍵字將單獨出現,並且必須在使用this關鍵字之前使用。super關鍵字也可以用來呼叫父物件上的函式 [ MDN ]。

行4 - Shadow DOM 的方法屬性,用於將一個 shadow root 新增到 instance class 上。

接下來根據需求分析的三塊內容編寫盒子,預留好關閉按鈕的 slot插槽

template() {
    let dom = `
      <main>
        <article>
          <section>
            <i class="icon" aria-label="圖示: info-circle" class="anticon anticon-info-circle ant-alert-icon"><svg viewBox="64 64 896 896" data-icon="info-circle" width="1em" height="1em" fill="currentColor" aria-hidden="true" focusable="false" class=""><path d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm32 664c0 4.4-3.6 8-8 8h-48c-4.4 0-8-3.6-8-8V456c0-4.4 3.6-8 8-8h48c4.4 0 8 3.6 8 8v272zm-32-344a48.01 48.01 0 0 1 0-96 48.01 48.01 0 0 1 0 96z"></path></svg></i>
            <slot name="message"></slot>
            <slot name="icon-close" class="close"></slot>
          </section>
        </article>
      </main>
    `
    return dom
}

為盒子編寫樣式:

stylesheet() {
	let style = `
        <style>
          * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
          }
          main {
            position: fixed;
            top: 20px;
            left: 50%;
            transform: translateX(-50%);
            padding: 12px 24px;
            color: #5e5e5e;
            font-size: 1rem;
            user-select: none;
            background: linear-gradient(to bottom right, rgba(176, 219, 125, .65) 40%, rgba(153, 219, 180, .65) 100%);
            box-shadow: 2px 2px 10px rgb(119 119 119 / 50%);
            border-radius: 4px;
          }
          .icon {
            opacity: .85;
            color: #52c41a;
            position: relative;
            top: 2px;
          }
          .close {
            color: #fff;
            font-size: 14px;
            cursor: pointer;
          }
          .animeShow {
            animation: show .35s ease-out 1 forwards;
          }
          .animeHide {
            animation: hide .35s ease-in 1 forwards;
          }
          @keyframes show {
            from {transform: translate(-50%, calc(-100% - 29px));opacity: 0;}
            to {transform: translate(-50%, 0);opacity: 1;}
          }
          @keyframes hide {
            from {transform: translate(-50%, 0);opacity: 1;}
            to {transform: translate(-50%, calc(-100% - 29px));opacity: 0;}
          }
        </style>
      `
	return style
}

將 DOM 新增到 shadow root 中:

constructor() {
	super()
	const shadowRoot = this.attachShadow({mode: 'open'})
	shadowRoot.innerHTML = this.stylesheet() + this.template()
}

使用  Web Components  為外掛定製函式週期:

/* 生命週期: 首次插入文件DOM */
connectedCallback() {
  console.log('Template element is connected from \'Message Box\'')
}
/* 生命週期: 從文件DOM中刪除 */
disconnectedCallback() {
  console.log('Template element disconnected which \'Message Box\'')
}
/* 生命週期: 被移動到新的文件時 */
adoptedCallback() {
  console.log('Template element adopted which \'Message Box\'');
}
/* 生命週期: 監聽屬性變化 */
attributeChangedCallback() {
  console.log('Template element\'s attribute is changed which \'Message Box\'');
}

 這樣一個完整的 Shadow DOM 就已經編寫完成了,現在註冊這個外掛:

customElements.define('message-box', MessageBox)

接下來我們要做的是把  custom element  放在頁面上,定義一個類來更方便地控制它:

/* message */
class Message {
  constructor() {
    this.containerEl = document.createElement('message-box')
    this.containerEl.id = 'message-box-97z4moon'
  }
}

Message 類的構造器中 create 了這個 custom element,我們再為該類新增 show 方法來實現掛載:

show(text = 'Default text.') {
  let containerEl = this.containerEl

  /* Use Slot */
  containerEl.innerHTML = `<span slot="message">${text}</span>`
  
  /* Render Dom */
  document.body.appendChild(containerEl)
}

在 show( ) 方法中判斷是否同時多次呼叫(DOM是否存在):

show(text = 'Default text.') {
  /* Message box had Render */
  if(document.getElementById('message-box-97z')) {
	return
  }
}

呼叫並檢視效果:

const message = new Message()
message.show('Message box by 97z.')

在生命週期中為 Shadow DOM 新增 fadeInTop 動畫 (css3 - animation 已包含在樣式程式碼部分):

/* 生命週期: 首次插入文件DOM */
connectedCallback() {
  this.shadowRoot.children[1].className = 'animeShow'
}

在 show( ) 方法中利用剛剛預留的 slot 為盒子新增關閉按鈕(這裡用到的是 Ant Design 的 icon svg),併為按鈕繫結點選事件:

show(text = 'Message box by 97z.', closeable = false) {
  /* Append Icon Close */
  if(closeable) {
    let closeEl = document.createElement('i')
    closeEl.setAttribute('slot', 'icon-close')
    closeEl.setAttribute('aria-label', '圖示: close-circle')
    closeEl.style.position = 'relative'
    closeEl.style.left = '10px'
    closeEl.style.top = '1px'
    closeEl.innerHTML = '<svg viewBox="64 64 896 896" data-icon="close-circle" width="1em" height="1em" fill="currentColor" aria-hidden="true" focusable="false" class=""><path d="M685.4 354.8c0-4.4-3.6-8-8-8l-66 .3L512 465.6l-99.3-118.4-66.1-.3c-4.4 0-8 3.5-8 8 0 1.9.7 3.7 1.9 5.2l130.1 155L340.5 670a8.32 8.32 0 0 0-1.9 5.2c0 4.4 3.6 8 8 8l66.1-.3L512 564.4l99.3 118.4 66 .3c4.4 0 8-3.5 8-8 0-1.9-.7-3.7-1.9-5.2L553.5 515l130.1-155c1.2-1.4 1.8-3.3 1.8-5.2z"></path><path d="M512 65C264.6 65 64 265.6 64 513s200.6 448 448 448 448-200.6 448-448S759.4 65 512 65zm0 820c-205.4 0-372-166.6-372-372s166.6-372 372-372 372 166.6 372 372-166.6 372-372 372z"></path></svg>'
    closeEl.addEventListener('click', e => {
      this.containerEl.shadowRoot.children[1].className = 'animeHide'
      setTimeout(() => {
        this.close()
      }, 350)
    })
    containerEl.appendChild(closeEl)
  }
}

那麼這時就要寫一個 close( ) 了:

close() {
  let containerEl = this.containerEl
  document.body.removeChild(containerEl)
}

有了 close( ) 方法我們再給 show( ) 新增一個自動關閉事件:

show(text = 'Message box by 97z.', duration = 2000, closeable = false) {
    /* Destroy Dom */
    this.timer = setTimeout(() => {
        this.containerEl.shadowRoot.children[1].className = 'animeHide'
        setTimeout(() => {
          this.close()
        }, 350)
    }, duration)
}

清除計時器避免使用 close button 關閉後再開啟發生混亂:

close() {
    clearTimeout(this.timer)
}

 檢查呼叫

完整程式碼

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title></title>
  <style type="text/css">
    body {
      margin: 0;
      padding: 0;
      width: 100vw;
      height: 100vh;
    }
    div {
      width: 100%;
      height: 100%;
      background: #ccc;
      display: flex;
      justify-content: center;
      align-items: center;
      background: #333;
      flex-direction: column;
    }
    span {
      text-align: center;
      color: #fff;
      margin-bottom: 24px;
      user-select: none;
      font-size: 20px;
    }
    button {
      width: 80px;
      height: 36px;
      border-radius: 20px;
      background: #fff;
      border: none;
      box-shadow: 2px 2px 10px rgb(119 119 119 / 50%);
      color: #e96075;
      cursor: pointer;
    }
  </style>
</head>
  <body>
  <div>
    <span>Click the button to open Message-Box</span>
    <button onclick="const message = new Message(); message.show('Message box by 97z.', 2000, true)">Message</button>
  </div>
</body>
<script type="text/javascript">
  /* message */
  class Message {
    constructor() {
        this.containerEl = document.createElement('message-box')
        this.containerEl.id = 'message-box-97z'
        this.timer = null
    }
    show(text = 'Message box by 97z.', duration = 2000, closeable = false) {
      /* Message box had Render */
      if(document.getElementById('message-box-97z')) {
        return
      }

      let containerEl = this.containerEl

      /* Use Slot */
      containerEl.innerHTML = `<span slot="message">${text}</span>`

      /* Append Icon Close */
      if(closeable) {
        let closeEl = document.createElement('i')
        closeEl.setAttribute('slot', 'icon-close')
        closeEl.setAttribute('aria-label', '圖示: close-circle')
        closeEl.style.position = 'relative'
        closeEl.style.left = '10px'
        closeEl.style.top = '1px'
        closeEl.innerHTML = '<svg viewBox="64 64 896 896" data-icon="close-circle" width="1em" height="1em" fill="currentColor" aria-hidden="true" focusable="false" class=""><path d="M685.4 354.8c0-4.4-3.6-8-8-8l-66 .3L512 465.6l-99.3-118.4-66.1-.3c-4.4 0-8 3.5-8 8 0 1.9.7 3.7 1.9 5.2l130.1 155L340.5 670a8.32 8.32 0 0 0-1.9 5.2c0 4.4 3.6 8 8 8l66.1-.3L512 564.4l99.3 118.4 66 .3c4.4 0 8-3.5 8-8 0-1.9-.7-3.7-1.9-5.2L553.5 515l130.1-155c1.2-1.4 1.8-3.3 1.8-5.2z"></path><path d="M512 65C264.6 65 64 265.6 64 513s200.6 448 448 448 448-200.6 448-448S759.4 65 512 65zm0 820c-205.4 0-372-166.6-372-372s166.6-372 372-372 372 166.6 372 372-166.6 372-372 372z"></path></svg>'
        closeEl.addEventListener('click', e => {
          this.containerEl.shadowRoot.children[1].className = 'animeHide'
          setTimeout(() => {
            this.close()
          }, 350)
        })
        containerEl.appendChild(closeEl)
      }

      /* Render Dom */
      document.body.appendChild(containerEl)

      /* Destroy Dom */
      this.timer = setTimeout(() => {
        this.containerEl.shadowRoot.children[1].className = 'animeHide'
        setTimeout(() => {
          this.close()
        }, 350)
      }, duration)
    }
    close() {
      clearTimeout(this.timer)
      this.timer = null
      let containerEl = this.containerEl
      document.body.removeChild(containerEl)
    }
  }

  /* message-box (shadowDom) */
  class MessageBox extends HTMLElement {
    constructor() {
      super()
      const shadowRoot = this.attachShadow({mode: 'open'})
      shadowRoot.innerHTML = this.stylesheet() + this.template()
    }
    stylesheet() {
      let style = `
        <style>
          * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
          }
          main {
            position: fixed;
            top: 20px;
            left: 50%;
            transform: translateX(-50%);
            padding: 12px 24px;
            color: #5e5e5e;
            font-size: 1rem;
            user-select: none;
            background: linear-gradient(to bottom right, rgba(176, 219, 125, .65) 40%, rgba(153, 219, 180, .65) 100%);
            box-shadow: 2px 2px 10px rgb(119 119 119 / 50%);
            border-radius: 4px;
          }
          .icon {
            opacity: .85;
            color: #52c41a;
            position: relative;
            top: 2px;
          }
          .close {
            color: #fff;
            font-size: 14px;
            cursor: pointer;
          }
          .animeShow {
            animation: show .35s ease-out 1 forwards;
          }
          .animeHide {
            animation: hide .35s ease-in 1 forwards;
          }
          @keyframes show {
            from {transform: translate(-50%, calc(-100% - 29px));opacity: 0;}
            to {transform: translate(-50%, 0);opacity: 1;}
          }
          @keyframes hide {
            from {transform: translate(-50%, 0);opacity: 1;}
            to {transform: translate(-50%, calc(-100% - 29px));opacity: 0;}
          }
        </style>
      `
      return style
    }
    template() {
      let dom = `
        <main>
          <article>
            <section>
              <i class="icon" aria-label="圖示: info-circle" class="anticon anticon-info-circle ant-alert-icon"><svg viewBox="64 64 896 896" data-icon="info-circle" width="1em" height="1em" fill="currentColor" aria-hidden="true" focusable="false" class=""><path d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm32 664c0 4.4-3.6 8-8 8h-48c-4.4 0-8-3.6-8-8V456c0-4.4 3.6-8 8-8h48c4.4 0 8 3.6 8 8v272zm-32-344a48.01 48.01 0 0 1 0-96 48.01 48.01 0 0 1 0 96z"></path></svg></i>
              <slot name="message"></slot>
              <slot name="icon-close" class="close"></slot>
            </section>
          </article>
        </main>
      `
      return dom
    }
    /* 生命週期: 首次插入文件DOM */
    connectedCallback() {
      console.log('Template element is connected from \'Message Box\'')
      this.shadowRoot.children[1].className = 'animeShow'
    }
    /* 生命週期: 從文件DOM中刪除 */
    disconnectedCallback() {
      console.log('Template element disconnected which \'Message Box\'')
    }
    /* 生命週期: 被移動到新的文件時 */
    adoptedCallback() {
      console.log('Template element adopted which \'Message Box\'');
    }
    /* 生命週期: 監聽屬性變化 */
    attributeChangedCallback() {
      console.log('Template element\'s attribute is changed which \'Message Box\'');
    }
  }
  customElements.define('message-box', MessageBox)
</script>
</html>

- END -

相關文章