純原生元件化-模組化的探索

Jiasm發表於2018-07-01

純原生的元件化、模組化的一次小小的嘗試,用到了如下幾個新特性: shadown-DOMHTML標籤結構的一個封裝,真正意義上的元件,能保證 shadow-DOM 中的DOM元素不會被外界影響,內部也不會影響到外部的行為,變成了一個獨立的模組。
custom-elements 可以在瀏覽器中註冊自定義的標籤來使用,類似這樣的效果<my-tag></my-tag>,標籤內容基於兩種形式:1. 普通子元素 2. shadow-DOM
custom-events 使用各種自定義事件輔助完成元件之間的通訊
ES-module 為瀏覽器原生支援的模組化的一種方案,直接在瀏覽器裡使用importexport這類語法,以 module 的方式來引入 js 檔案。
幾個算是比較新的事物,聚集在一起確實可以做點兒好玩的東西出來。

shadow-DOM

想象有這樣的一個場景,類似資料卡的東東,需要在頁面中展示頭像和使用者的名稱。
頭像在左,寬高100px,圓形;
姓名在右,字號16px,垂直居中。

這算是一段很簡單的CSS了,實現起來大概是這樣的:

<style>
.info { display: flex; }

.info-avatar { width: 100px; height: 100px; border-radius: 50%; }

.info-name { display: flex; align-items: center; font-size: 16px; }
</style>
<div class="info">
  <img class="info-avatar" src="https://avatars1.githubusercontent.com/u/9568094?v=4" />
  <p class="info-name">Jarvis</p>
</div>
複製程式碼

此時,我們完成了需求,一切都沒有什麼不對的,但是一個很現實的問題。
不會有這麼簡單的頁面存在的,就算簡潔如 Google 首頁,也用到了400左右的DOM元素。
很難保證其他資原始檔中的CSSJS會不會對上邊的DOM產生影響。
就比如如果有一個main.css檔案中寫了一行:p { color: red;},那麼這條CSS就會對我們上邊所寫的.info-name元素產生影響,導致文字顏色變為紅色。

這種問題經常會出現在一些需要用到第三方外掛的頁面中,很可能對方提供的CSS會影響到你的DOM元素,也很有可能你的CSS會對外掛中的DOM造成影響。

解決這個問題有一種簡單的辦法,那就是All with !important,使用shadow-DOM

目前瀏覽器中就有一些shadow-DOM的例子:

  • <video>
  • <audio>
  • 甚至<input>

這些元素在 Chrome 上的構建都是採用了shadow-DOM的方式,但是預設情況下在開發者工具中是看不到這些元素的。

純原生元件化-模組化的探索

開啟shadow-DOM的流程: Chrome DevTools -> Settings -> 預設 Preferences 皮膚中找到 Elements -> 點選勾選 Show user agent shadow DOM 即可

這時候就可以通過開發者工具檢視到shadow-DOM的實際結構了。

純原生元件化-模組化的探索

shadow-DOM的一個特點,shadow 裡邊所有的DOM元素不會被外界的程式碼所影響,這也就是為什麼videoaudio的 UI 難以自定義的原因了-.-。

基本語法

shadow-DOM的建立必須要使用JavaScript才能完成,我們需要在文件中有一個用於掛在shadow-DOM的真實元素,也被稱為host
除此之外的建立過程,就可以像普通DOM樹那樣的增刪改子元素了。

let $tag = document.querySelector('XXX') // 用於掛載的真實元素

let shadow = $tag.attachShadow({ mode: 'open' }) // 掛載shadow-DOM元素,並獲取其根元素
複製程式碼

attachShadow中的mode引數有兩個有效的取值,openclosed,用來指定一個 shadow-DOM 結構的封裝模式。

當值為open時,則我們可以通過掛載時使用的真實元素獲取到shadow-DOM

$tag.shadowRoot; // shadow-DOM的root元素
複製程式碼

當值為closed時,則表示外層無法獲取shadow-DOM

$tag.shadowRoot; // null
複製程式碼

後續的操作就與普通的DOM操作一致了,各種appendremoveinnerHTML都可以了。

let $shadow = $tag.attachShadow({ mode: 'open' })

let $img = document.createElement('img')
$shadow.appendChild($img)  // 新增一個img標籤到shadow-DOM中

$shadow.removeChild($img) // 將img標籤從shadow-DOM中移除

$img.addEventListener('click', _ => console.log('click on img'))

$shadow.innerHTML = `
  <div class="wrap">
    <p>Some Text</p>
  </div>
`
複製程式碼

需要注意的一點是,shadow-DOM本身並不是一個實際的標籤,不具備定義CSS的能力。
但是繫結事件是可以的

$shadow.appendChild('<p></p>') // 假裝add了一個標籤
$shadow.appendChild('<p></p>') // 假裝add了一個標籤

// 最後得到的結構就是
// <外層容器>
//   <p></p>
//   <p></p>
// </外層容器>

// 沒有class相關的屬性
$shadow.classList // undefined
$shadow.className // undefined
$shadow.style     // undefined
// 繫結事件是沒問題的
$shadow.addEventListener('click', console.log)
複製程式碼

shadow-DOM也會有CSS的屬性繼承,而不是完全的忽略所有外層CSS

<style>
  body {
    font-size: 16px;  /* 屬性會被.text元素繼承 */
  }
  .host {
    color: red;       /* 同樣會被.text元素繼承 */
  }

  .text {
    color: green;     /* 直接設定shadow內的元素是無效的 */
  }

  p {
    font-size: 24px;  /* 針對p標籤的設定也不會被.text應用 */
  }

  /* 對外層設定flex,內部元素也會直接應用(但為了保證對外層元素的非侵入性,建議內部建立一個容器DOM) */
  .host {
    display: flex;
  }
  .text {
    flex: 1;
  }
</style>
<div class="host">
  #shadow
    <p class="text">Text</p>
    <p class="text">Text</p>
  #shadow
</div>
複製程式碼

所以說,對於shadow-DOM,CSS只是遮蔽了直接命中了內部元素的那一部分規則。
比如說寫了一個* { color: red; },這個規則肯定會生效的,因為*代表了全部,實際上shadow-DOM是從外層host元素繼承過來的color: red,而不直接是命中自己的這條規則。

簡單的小例子

我們使用shadow-DOM來修改上邊的資料卡。

線上demo
原始碼地址

<div id="info"></div>
<script>
  let $info = document.querySelector('#info') // host

  let $shadow = $info.attachShadow({mode: 'open'})

  let $style = document.createElement('style')
  let $wrap = document.createElement('div')
  let $avatar = document.createElement('img')
  let $name = document.createElement('p')

  $style.textContent = `
    .info { display: flex; }
    .info-avatar { width: 100px; height: 100px; border-radius: 50%; }
    .info-name { display: flex; align-items: center; font-size: 16px; }
  `

  $wrap.className = 'info'
  $avatar.className = 'info-avatar'
  $name.className = 'info-name'

  $avatar.src = 'https://avatars1.githubusercontent.com/u/9568094?v=4'
  $name.innerHTML = 'Jarvis'

  $wrap.appendChild($avatar)
  $wrap.appendChild($name)

  $shadow.appendChild($style)
  $shadow.appendChild($wrap)
</script>
複製程式碼

P.S. 在 shadow-DOM 內部的 css,不會對外界所產生影響,所以使用 shadow-DOM 就可以肆意的對 class 進行命名而不用擔心衝突了。

純原生元件化-模組化的探索

如果現在在一個頁面中要展示多個使用者的頭像+姓名,我們可以將上邊的程式碼進行封裝,將 classNameappendChild之類的操作放到一個函式中去,類似這樣的結構:

線上demo
原始碼地址

function initShadow($host, { isOpen, avatar, name }) {
  let $shadow = $host.attachShadow({ mode: isOpen ? 'open' : 'closed' });

  // ...省略各種操作
  $avatar.src = avatar
  $name.innerHTML = name
}

initShadow(document.querySelector('#info1'), {
  avatar: 'https://avatars1.githubusercontent.com/u/9568094?v=4',
  name: 'Jarvis'
});
initShadow(document.querySelector('#info2'), { 
  isOpen: true,
  avatar: 'https://avatars1.githubusercontent.com/u/9568094?v=4',
  name: 'Jarvis' 
})
複製程式碼

這樣就實現了一個簡單的元件,可以在需要用到的地方,直接傳入一個掛載的DOM即可。

custom-elements

就像上邊的shadow-DOM,已經在文件樹中看不到元件的細節了,任何程式碼也都不會影響到它的結構(open模式下的獲取root操作除外)。
但是,這樣在文件中是存在一個用來掛在shadow-DOM的根元素,這個根元素依然是一個普通的HTML標籤。
如果是一個大型頁面中,存在了N多類似的元件,搜尋一下,全是<div></div>,這個體驗其實是很痛苦的,基本是毫無語義化。
而且我們想要使用這個元件時,還必須額外的呼叫JavaScript來獲取DOM元素生成對應的shadow-DOM
所以,我們可以嘗試用custom-elements來註冊自己獨有的標籤。
簡單的通過<my-tag>的方式來呼叫自定義元件。

custom-elements支援同時支援普通標籤的封裝以及shadow-DOM的封裝,但兩者不能共存。

基本語法

首先我們需要有一個繼承了HTMLElement的類。
然後需要將其註冊到當前環境中。

class Info extends HTMLElement {}

customElements.define(
  'cus-info', // 標籤名
  Info        // 標籤對應的建構函式
)
複製程式碼

在呼叫define時還有一個可選的第三個引數,用來設定自定義標籤繼承自某個原生標籤。 兩者在後續的標籤使用上稍微有些區別:

<!-- 如果設定了 { extends: 'p' } -->
<p is="cus-info" />
<script>
  document.createElement('p', { is: 'cus-info' })
</script>
<!-- 沒有設定 extends 的情況 -->
<info />
<script>
  document.createElement('cus-info') // 必須要包含一個`-`
</script>
複製程式碼

P.S. 自定義的標籤的註冊名至少要包含一個-
結合場景來選擇是否使用extends,個人不建議使用,因為看起來會舒服一些

普通標籤的方式

如果是針對普通的一組標籤進行封裝,就是解決了一些相同功能的元件需要在頁面中粘來粘去的問題。

線上demo
原始碼地址

<cus-info>
  <p>native text</p>
  <!-- 預設是可以直接巢狀的,除非在自定義元件中移除 -->
</cus-info>
<script>
  class CusInfo extends HTMLElement {
    constructor() {
      super()

      let $text = document.createElement('p')
      $text.innerHTML = 'Hello custom-elements.'

      this.appendChild($text) // this代表當前自定義元素的例項
    }
  }

  customElements.define('cus-info', CusInfo)
</script>
複製程式碼

實現類似這樣的效果:

純原生元件化-模組化的探索

shadow-DOM的使用方式

P.S. 當一個元素啟用了shadow-DOM以後,裡邊的普通子元素都會變得不可見,但是使用DOM API依然可以獲取到

線上demo
原始碼地址

<cus-info>
  <p>native text</p>
  <!-- 預設是可以直接巢狀的,除非在自定義元件中移除 -->
</cus-info>
<script>
  class CusInfo extends HTMLElement {
    constructor() {
      super()

      let $shadow = this.attachShadow({ mode: 'open' })
      let $text = document.createElement('p')
      $text.innerHTML = 'Hello custom-elements.'

      $shadow.appendChild($text)
    }
  }

  customElements.define('cus-info', CusInfo)
  console.log(document.querySelector('cus-info').children[0].innerHTML) // native text
</script>
複製程式碼

純原生元件化-模組化的探索

生命週期函式

自定義標籤並不只是一個讓你多了一個標籤可以用。
註冊的自定義標籤是有一些生命週期函式可以設定的,目前有效的事件為:

  • connectedCallback 標籤被新增到文件流中觸發
  • disconnectedCallback 標籤被從文件流中移除時觸發
  • adoptedCallback 標籤被移動時觸發,現有的API貌似沒有一個可以觸發這個事件的,因為像appendChild或者insertBefore這一類的,對於已經存在的DOM元素都是先移除後新增的,所以不存在有直接移動的行為
  • attributeChangedCallback 增刪改元素屬性時會觸發 需要提前設定observedAttributes,才能監聽對應的屬性變化

一個觸發各種事件的簡單示例:

線上demo
原始碼地址

<div id="wrap">
  <div id="content"></div>
</div>
<script>
  class CusTag extends HTMLElement {
    static get observedAttributes() { return ['id'] } // 設定監聽哪些屬性變化
    connectedCallback () { console.log('DOM被新增到文件中') }
    disconnectedCallback () { console.log('DOM被從文件中移除') }
    adoptedCallback () { console.log('DOM被移動') }
    attributeChangedCallback () { console.log('DOM屬性有修改') }
  }
  
  customElements.define('cus-tag', CusTag)

  let $wrap = document.querySelector('#wrap')
  let $content = document.querySelector('#content')
  let $tag = document.createElement('cus-tag')
  
  $wrap.appendChild($tag)
  $content.appendChild($tag)
  $tag.setAttribute('id', 'tag-id')
  $tag.setAttribute('id', 'tag-id2')
  $tag.removeAttribute('id')
  $content.removeChild($tag)
</script>
複製程式碼

P.S. 如果需要處理DOM結構以及繫結事件,推薦在connectedCallback回撥中執行 想要attributeChangedCallback生效,必須設定observedAttributes來返回該標籤需要監聽哪些屬性的改變

使用自定義標籤封裝資料卡元件

接下來就是使用custome-elements結合著shadow-DOM來完成資料卡的一個簡單封裝。
因為shadow-DOM版本的元件相對更獨立一些,所以這裡採用的是shadow-DOM的方式進行封裝。
大致程式碼如下:

線上demo
原始碼地址

<info-card name="Jarvis" avatar="https://avatars1.githubusercontent.com/u/9568094?v=4" />
<!-- P.S. 這裡會觸發一個Chrome67版本的一個隱藏bug -->
<script>
  class InfoCard extends HTMLElement {
    connectedCallback () {
      // 穩妥的方式是在確定標籤已經被新增到DOM中在進行渲染
      let avatar = this.getAttribute('avatar')
      let name = this.getAttribute('name')
      initShadow(this, { avatar, name })
    }
  }

  customElements.define('info-card', InfoCard)
</script>
複製程式碼

針對上邊的initShadow呼叫也只是更換了avatarname欄位的來源罷了。
現在,我們需要在頁面中使用封裝好的資料卡,僅僅需要註冊一個自定義標籤,然後在HTML中寫對應的標籤程式碼即可

再開一下腦洞

因為是採用了註冊html標籤的方式,其實這個是對採用Server端模版渲染特別友好的一件事兒。
如果有使用服務端渲染的頁面,可能會動態的拼接一些DOM元素到請求的返回值中。
為了應用一些樣式,可能需要在模版中新增各種className,也很有可能手一抖之類的就會導致標籤沒有閉合、結構錯亂,或者某些屬性拼寫出錯,各種233的問題。
比如插入一些表單元素,之前可能是這樣的程式碼:

router.get('/', ctx => {
  ctx.body = `
    <body>
      <form>
        <div class="form-group">
          <label for="account">Account</label>
          <input id="account" placholder="put account" />
        </div>
        <div class="form-group">
          <label for="password">Account</label>
          <input id="password" placholder="put password" type="password" />
        </div>
        <button>Login</button>
      </form>
    </body>
  `
})
複製程式碼

在使用了custom-elements以後,Server端的記憶成本也會降低很多。
Server端只需要表明這裡有一個表單元素就夠了,具體渲染成什麼樣,還是交由前端來決定。

router.get('/', ctx => {
  ctx.body = `
    <body>
      <form>
        <form-field id="account" label="Account" placholder="put account" />
        <form-field id="password" label="Password" placholder="put password" type="password" />
        <form-login />
      </form>
    </body>
  `
})
複製程式碼

custom-events

如果在頁面中使用很多的自定義元件,必然會遇到元件之間的通訊問題的。
比如我一個按鈕點選了以後如何觸發其他元件的行為。
因為是純原生的版本,所以天然的支援addEventListener,我們可以直接使用custom-events來完成元件之間的通訊。

基本語法

使用自定義事件與原生DOM事件唯一的區別就在於需要自己構建Event例項並觸發事件:

document.body.addEventListener('ping', _ => console.log('pong')) // 設定事件監聽

document.body.dispatchEvent(new Event('ping')) // 觸發事件
複製程式碼

自定義元件中的使用

現在頁面中有兩個元件,一個容器,容器中包含一個文字框和數個按鈕,點選按鈕以後會將按鈕對應的文字輸出到文字框中:

線上demo
原始碼地址

<cus-list>
  <input id="output" />
  <cus-btn data-text="Button 1"></cus-btn>
  <cus-btn data-text="Button 2"></cus-btn>
  <cus-btn data-text="Button 3"></cus-btn>
</cus-list>
<script>
  class CusList extends HTMLElement {
    connectedCallback() {
      let $output = this.querySelector('#output')
      Array.from(this.children).forEach(item => {
        if (item.tagName.toLowerCase() === 'cus-btn') {
          item.addEventListener('check', event => { // 註冊自定義事件的監聽
            $output.value = event.target.innerText
          })
        }
      })
    }
  }
  class CusBtn extends HTMLElement {
    connectedCallback() {
      let { text } = this.dataset

      let $text = document.createElement('p')
      $text.innerHTML = text

      $text.addEventListener('click', _ => {
        this.dispatchEvent(new Event('check')) // 觸發自定義事件
      })

      this.appendChild($text)
    }
  }

  customElements.define('cus-list', CusList)
  customElements.define('cus-btn', CusBtn)
</script>
複製程式碼

上邊是在List中迴圈了自己的子節點,然後依次繫結事件,這種處理是低效的,而且是不靈活的。
如果有新增的子元素,則無法觸發對應的事件。
所以,我們可以開啟事件的冒泡來簡化上邊的程式碼:

線上demo
原始碼地址

class CusList extends HTMLElement {
  connectedCallback() {
    let $output = this.querySelector('#output')

    this.addEventListener('check', event => { // 註冊自定義事件的監聽
      $output.value = event.target.innerText // 效果一樣,因為event.target就是觸發dispatchEvent的那個DOM物件
    })
  }
}
class CusBtn extends HTMLElement {
  connectedCallback() {
    let { text } = this.dataset

    let $text = document.createElement('p')
    $text.innerHTML = text

    $text.addEventListener('click', _ => {
      this.dispatchEvent(new Event('check'), {
        bubbles: true // 啟用事件冒泡
      }) // 觸發自定義事件
    })

    this.appendChild($text)
  }
}
複製程式碼

ES-module

ES-module是原生模組化的一種實現,使用ES-module可以讓我們上邊元件的呼叫變得更方便。
這裡有之前的一篇講解ES-module的文章:傳送陣
所以,不再贅述一些module相關的基礎,直接將封裝好的元件程式碼挪到一個js檔案中,然後在頁面中引用對應的js檔案完成呼叫。

線上demo
原始碼地址

module.js

export default class InfoCard extends HTMLElement { }

customElements.define('info-card', InfoCard)
複製程式碼

index.html

<info-card name="Jarvis" avatar="https://avatars1.githubusercontent.com/u/9568094?v=4"></info-card>
<script type="module" src="./cus-elements-info-card.js"></script>
複製程式碼

第一眼看上去,這樣做好像與普通的js指令碼引入並沒有什麼區別。
確實單純的寫這一個元件的話,是沒有什麼區別的。

但是一個現實中的頁面,不會只有這麼一個元件的,假設有這樣的一個頁面,其中包含了三個元件:

<cus-tab>
  <cus-list>
    <cus-card />
    <cus-card />
  </cus-list>
  <cus-list>
    <cus-card />
    <cus-card />
  </cus-list>
</cus-tab>
複製程式碼

我們在使用list時要保證card已經載入完成,在使用tab時要保證list已經載入完成。
最簡單的方法就是等到所有的資源全部載入完成後再執行程式碼,主流的webpack打包就是這麼做的。
但是,這樣做帶來的後果就是,明明listcard載入完畢後就可以處理自己的邏輯,註冊自定義標籤了,卻還是要等外層的tab載入完畢後再執行程式碼。
這個在使用webpack打包的ReactVue這類框架上邊就是很明顯的問題,如果打包完的js檔案過大,幾百k,甚至數兆。
需要等到這個檔案全部下載完畢後才會開始執行程式碼,構建頁面。

我們完全可以利用下載其他元件時的空白期來執行當前元件的一些邏輯,而使用webpack這類打包工具卻不能做到,這很顯然是一個時間上的浪費,而ES-module已經幫忙處理了這件事兒,module程式碼的執行是建立在所有的依賴全部載入完畢的基礎上的。

cardlist載入完畢後,list就會開始執行程式碼。而此時的tab可能還在載入過程中,等到tab載入完畢開始執行時,list已經註冊到了document上,就等著被呼叫了,從某種程度上打散了程式碼執行過於集中的問題。
可能之前頁面載入有200ms在下載檔案,有50ms在構建元件,50ms渲染頁面*(數值純屬扯淡,僅用於舉例)*。
有些元件比較輕量級,可能用了20ms就已經下載完了檔案,如果它沒有依賴其他的module,這時就會開始執行自身元件的一些程式碼,生成建構函式、註冊自定義元件到文件中,而這些步驟執行的過程中可能瀏覽器還在下載其他的module,所以這就是兩條並行的線了,讓一部分程式碼執行的時間和網路請求消耗的時間所重疊

舉一個現實中的例子:
你開了一家飯店,僱傭了三個廚師,一個做番茄炒蛋、一個做皮蛋豆腐、還有一個做拍黃瓜,因為場地有限,所以三個廚師共用一套炊具。(單執行緒)
今天第一天開業,這時候來了客人點了這三樣菜,但是菜還在路上。
webpack:「西紅柿、雞蛋、皮蛋、豆腐、黃瓜」全放到一塊給你送過來,送到了以後,三個廚師輪著做,然後給客人端過去。 ES-module:分撥送,什麼菜先送過來就先做哪個,哪個先做完給客人端哪個。

一個簡單的元件巢狀示例

線上demo
原始碼地址

cus-elements-info-list.js

import InfoCard from './cus-elements-info-card.js'

export default class InfoList extends HTMLElement {
  connectedCallback() {
    // load data
    let data = [
      {
        avatar: 'https://avatars1.githubusercontent.com/u/9568094?v=4',
        name: 'Jarvis'
      },
      {
        avatar: 'https://avatars1.githubusercontent.com/u/9568094?v=4',
        name: 'Jarvis'
      },
      {
        avatar: 'https://avatars1.githubusercontent.com/u/9568094?v=4',
        name: 'Jarvis'
      }
    ]
    // laod data end

    initShadow(this, { data })
  }
}

function initShadow($host, { data, isOpen }) {
  let $shadow = $host.attachShadow({ mode: isOpen ? 'open' : 'closed' })

  let $style = document.createElement('style')
  let $wrap = document.createElement('div')

  $style.textContent = `
    .list { display: flex; flex-direction: column; }
  `

  $wrap.className = 'list'

  // loop create
  data.forEach(item => {
    let $item = new InfoCard()
    $item.setAttribute('avatar', item.avatar)
    $item.setAttribute('name', item.name)

    $wrap.appendChild($item)
  })

  $shadow.appendChild($style)
  $shadow.appendChild($wrap)
}

customElements.define('info-list', InfoList)
複製程式碼
<info-list></info-list>
<script type="module" src="./cus-elements-info-list.js"></script>
複製程式碼

new Component與document.createElement效果一樣,用於在不知道元件的註冊名的情況下使用

總結

一些小提示

  1. shadow-DOM無法與普通的子元素共存,設定attachShadow以後會導致普通子元素在頁面不可見,但是DOM依然保留
  2. custom-elements的註冊名必須要包含一個-
  3. custom-elementsconstructor函式觸發時不能保證DOM已經正確渲染完畢,對DOM進行的操作應該放到connectedCallback
  4. custom-elements元件的屬性變化監聽需要提前配置observedAttributes,沒有萬用字元之類的操作
  5. ES-module相關的操作只能在type="module"中進行
  6. ES-module的引用是共享的,即使十個檔案都import了同一個JS檔案,他們拿到的都是同一個物件,不用擔心浪費網路資源

一個簡單的TODO-LIST的實現:

線上demo
原始碼地址

瀏覽器原生支援的功能越來越豐富,ES-modulecustom-elementsshadow-DOM以及各種新鮮的玩意兒;
web原生的元件化、模組化,期待著普及的那一天,就像現在可以放肆的使用qsafetch,而不用考慮是否需要引入jQuery來幫助做相容一樣(大部分情況下)。

參考資料

  1. shadow-DOM | MDN
  2. custom-elements | MDN
  3. custom-events | MDN
  4. ES-module | MDN

文中所有示例的倉庫地址

倉庫地址

相關文章