純原生的組件化、模塊化的一次小小的嘗試,用到了如下幾個新特性:
shadown-DOM
對HTML
標簽結構的一個封裝,真正意義上的組件,能保證shadow-DOM
中的DOM
元素不會被外界影響,內部也不會影響到外部的行為,變成了一個獨立的模塊。custom-elements
可以在瀏覽器中注冊自定義的標簽來使用,類似這樣的效果<my-tag></my-tag>
,標簽內容基於兩種形式:1. 普通子元素 2.shadow-DOM
custom-events
使用各種自定義事件輔助完成組件之間的通訊ES-module
為瀏覽器原生支持的模塊化的一種方案,直接在瀏覽器里使用import
和export
這類語法,以 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
元素。
很難保證其他資源文件中的CSS
、JS
會不會對上邊的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
元素不會被外界的代碼所影響,這也就是為什么video
和audio
的 UI 難以自定義的原因了-.-。
基本語法
shadow-DOM
的創建必須要使用JavaScript
才能完成,我們需要在文檔中有一個用於掛在shadow-DOM
的真實元素,也被稱為host
。
除此之外的創建過程,就可以像普通DOM
樹那樣的增刪改子元素了。
let $tag = document.querySelector('XXX') // 用於掛載的真實元素 let shadow = $tag.attachShadow({ mode: 'open' }) // 掛載shadow-DOM元素,並獲取其根元素
attachShadow
中的mode
參數有兩個有效的取值,open
和closed
,用來指定一個 shadow-DOM 結構的封裝模式。
當值為open
時,則我們可以通過掛載時使用的真實元素獲取到shadow-DOM
。
$tag.shadowRoot; // shadow-DOM的root元素
當值為closed
時,則表示外層無法獲取shadow-DOM
。
$tag.shadowRoot; // null
后續的操作就與普通的DOM
操作一致了,各種append
、remove
、innerHTML
都可以了。
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
來修改上邊的資料卡。
<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 進行命名而不用擔心沖突了。
如果現在在一個頁面中要展示多個用戶的頭像+姓名,我們可以將上邊的代碼進行封裝,將 className
,appendChild
之類的操作放到一個函數中去,類似這樣的結構:
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
,個人不建議使用,因為看起來會舒服一些
普通標簽的方式
如果是針對普通的一組標簽進行封裝,就是解決了一些相同功能的組件需要在頁面中粘來粘去的問題。
<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依然可以獲取到
<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,才能監聽對應的屬性變化
一個觸發各種事件的簡單示例:
<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
的方式進行封裝。
大致代碼如下:
<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
調用也只是更換了avatar
和name
字段的來源罷了。
現在,我們需要在頁面中使用封裝好的資料卡,僅僅需要注冊一個自定義標簽,然后在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')) // 觸發事件
自定義組件中的使用
現在頁面中有兩個組件,一個容器,容器中包含一個文本框和數個按鈕,點擊按鈕以后會將按鈕對應的文字輸出到文本框中:
<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中循環了自己的子節點,然后依次綁定事件,這種處理是低效的,而且是不靈活的。
如果有新增的子元素,則無法觸發對應的事件。
所以,我們可以開啟事件的冒泡來簡化上邊的代碼:
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文件完成調用。
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
打包就是這么做的。
但是,這樣做帶來的后果就是,明明list
和card
加載完畢后就可以處理自己的邏輯,注冊自定義標簽了,卻還是要等外層的tab
加載完畢后再執行代碼。
這個在使用webpack
打包的React
和Vue
這類框架上邊就是很明顯的問題,如果打包完的js文件過大,幾百k,甚至數兆。
需要等到這個文件全部下載完畢后才會開始運行代碼,構建頁面。
我們完全可以利用下載其他組件時的空白期來執行當前組件的一些邏輯,而使用webpack
這類打包工具卻不能做到,這很顯然是一個時間上的浪費,而ES-module
已經幫忙處理了這件事兒,module
代碼的執行是建立在所有的依賴全部加載完畢的基礎上的。
當card
和list
加載完畢后,list
就會開始執行代碼。而此時的tab
可能還在加載過程中,等到tab
加載完畢開始執行時,list
已經注冊到了document上,就等着被調用了,從某種程度上打散了代碼執行過於集中的問題。
可能之前頁面加載有200ms在下載文件,有50ms在構建組件,50ms渲染頁面(數值純屬扯淡,僅用於舉例)。
有些組件比較輕量級,可能用了20ms就已經下載完了文件,如果它沒有依賴其他的module,這時就會開始執行自身組件的一些代碼,生成構造函數、注冊自定義組件到文檔中,而這些步驟執行的過程中可能瀏覽器還在下載其他的module,所以這就是兩條並行的線了,讓一部分代碼執行的時間和網絡請求消耗的時間所重疊。
舉一個現實中的例子:
你開了一家飯店,雇佣了三個廚師,一個做番茄炒蛋、一個做皮蛋豆腐、還有一個做拍黃瓜,因為場地有限,所以三個廚師共用一套炊具。(單線程)
今天第一天開業,這時候來了客人點了這三樣菜,但是菜還在路上。
webpack:「西紅柿、雞蛋、皮蛋、豆腐、黃瓜」全放到一塊給你送過來,送到了以后,三個廚師輪着做,然后給客人端過去。
ES-module:分撥送,什么菜先送過來就先做哪個,哪個先做完給客人端哪個。
一個簡單的組件嵌套示例
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效果一樣,用於在不知道組件的注冊名的情況下使用
總結
一些小提示
shadow-DOM
無法與普通的子元素共存,設置attachShadow
以后會導致普通子元素在頁面不可見,但是DOM依然保留custom-elements
的注冊名必須要包含一個-
custom-elements
的constructor
函數觸發時不能保證DOM
已經正確渲染完畢,對DOM進行的操作應該放到connectedCallback
中custom-elements
組件的屬性變化監聽需要提前配置observedAttributes
,沒有通配符之類的操作ES-module
相關的操作只能在type="module"
中進行ES-module
的引用是共享的,即使十個文件都import
了同一個JS文件,他們拿到的都是同一個對象,不用擔心浪費網絡資源
一個簡單的TODO-LIST的實現:
瀏覽器原生支持的功能越來越豐富,ES-module
、custom-elements
、shadow-DOM
以及各種新鮮的玩意兒;
web原生的組件化、模塊化,期待着普及的那一天,就像現在可以放肆的使用qsa
、fetch
,而不用考慮是否需要引入jQuery來幫助做兼容一樣(大部分情況下)。