纯原生组件化-模块化的探索
純原生的組件化、模塊化的一次小小的嘗試,用到了如下幾個新特性: shadown-DOM 對HTML標簽結(jié)構(gòu)的一個封裝,真正意義上的組件,能保證 shadow-DOM 中的DOM元素不會被外界影響,內(nèi)部也不會影響到外部的行為,變成了一個獨立的模塊。
custom-elements 可以在瀏覽器中注冊自定義的標簽來使用,類似這樣的效果<my-tag></my-tag>,標簽內(nèi)容基于兩種形式:1. 普通子元素 2. shadow-DOM
custom-events 使用各種自定義事件輔助完成組件之間的通訊
ES-module 為瀏覽器原生支持的模塊化的一種方案,直接在瀏覽器里使用import和export這類語法,以 module 的方式來引入 js 文件。
幾個算是比較新的事物,聚集在一起確實可以做點兒好玩的東西出來。
shadow-DOM
想象有這樣的一個場景,類似資料卡的東東,需要在頁面中展示頭像和用戶的名稱。
頭像在左,寬高100px,圓形;
姓名在右,字號16px,垂直居中。
這算是一段很簡單的CSS了,實現(xiàn)起來大概是這樣的:
<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> 復制代碼此時,我們完成了需求,一切都沒有什么不對的,但是一個很現(xiàn)實的問題。
不會有這么簡單的頁面存在的,就算簡潔如 Google 首頁,也用到了400左右的DOM元素。
很難保證其他資源文件中的CSS、JS會不會對上邊的DOM產(chǎn)生影響。
就比如如果有一個main.css文件中寫了一行:p { color: red;},那么這條CSS就會對我們上邊所寫的.info-name元素產(chǎn)生影響,導致文本顏色變?yōu)榧t色。
這種問題經(jīng)常會出現(xiàn)在一些需要用到第三方插件的頁面中,很可能對方提供的CSS會影響到你的DOM元素,也很有可能你的CSS會對插件中的DOM造成影響。
解決這個問題有一種簡單的辦法,那就是All with !important,使用shadow-DOM。
目前瀏覽器中就有一些shadow-DOM的例子:
- <video>
- <audio>
- 甚至<input>
這些元素在 Chrome 上的構(gòu)建都是采用了shadow-DOM的方式,但是默認情況下在開發(fā)者工具中是看不到這些元素的。
開啟shadow-DOM的流程: Chrome DevTools -> Settings -> 默認 Preferences 面板中找到 Elements -> 點擊勾選 Show user agent shadow DOM 即可
這時候就可以通過開發(fā)者工具查看到shadow-DOM的實際結(jié)構(gòu)了。
shadow-DOM的一個特點,shadow 里邊所有的DOM元素不會被外界的代碼所影響,這也就是為什么video和audio的 UI 難以自定義的原因了-.-。
基本語法
shadow-DOM的創(chuàng)建必須要使用JavaScript才能完成,我們需要在文檔中有一個用于掛在shadow-DOM的真實元素,也被稱為host。
除此之外的創(chuàng)建過程,就可以像普通DOM樹那樣的增刪改子元素了。
attachShadow中的mode參數(shù)有兩個有效的取值,open和closed,用來指定一個 shadow-DOM 結(jié)構(gòu)的封裝模式。
當值為open時,則我們可以通過掛載時使用的真實元素獲取到shadow-DOM。
$tag.shadowRoot; // shadow-DOM的root元素 復制代碼當值為closed時,則表示外層無法獲取shadow-DOM。
$tag.shadowRoot; // null 復制代碼后續(xù)的操作就與普通的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-DOM也會有CSS的屬性繼承,而不是完全的忽略所有外層CSS
<style>body {font-size: 16px; /* 屬性會被.text元素繼承 */}.host {color: red; /* 同樣會被.text元素繼承 */}.text {color: green; /* 直接設(shè)置shadow內(nèi)的元素是無效的 */}p {font-size: 24px; /* 針對p標簽的設(shè)置也不會被.text應(yīng)用 */}/* 對外層設(shè)置flex,內(nèi)部元素也會直接應(yīng)用(但為了保證對外層元素的非侵入性,建議內(nèi)部創(chuàng)建一個容器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只是屏蔽了直接命中了內(nèi)部元素的那一部分規(guī)則。
比如說寫了一個* { color: red; },這個規(guī)則肯定會生效的,因為*代表了全部,實際上shadow-DOM是從外層host元素繼承過來的color: red,而不直接是命中自己的這條規(guī)則。
簡單的小例子
我們使用shadow-DOM來修改上邊的資料卡。
在線demo
源碼地址
P.S. 在 shadow-DOM 內(nèi)部的 css,不會對外界所產(chǎn)生影響,所以使用 shadow-DOM 就可以肆意的對 class 進行命名而不用擔心沖突了。
如果現(xiàn)在在一個頁面中要展示多個用戶的頭像+姓名,我們可以將上邊的代碼進行封裝,將 className,appendChild之類的操作放到一個函數(shù)中去,類似這樣的結(jié)構(gòu):
在線demo
源碼地址
這樣就實現(xiàn)了一個簡單的組件,可以在需要用到的地方,直接傳入一個掛載的DOM即可。
custom-elements
就像上邊的shadow-DOM,已經(jīng)在文檔樹中看不到組件的細節(jié)了,任何代碼也都不會影響到它的結(jié)構(gòu)(open模式下的獲取root操作除外)。
但是,這樣在文檔中是存在一個用來掛在shadow-DOM的根元素,這個根元素依然是一個普通的HTML標簽。
如果是一個大型頁面中,存在了N多類似的組件,搜索一下,全是<div></div>,這個體驗其實是很痛苦的,基本是毫無語義化。
而且我們想要使用這個組件時,還必須額外的調(diào)用JavaScript來獲取DOM元素生成對應(yīng)的shadow-DOM。
所以,我們可以嘗試用custom-elements來注冊自己獨有的標簽。
簡單的通過<my-tag>的方式來調(diào)用自定義組件。
custom-elements支持同時支持普通標簽的封裝以及shadow-DOM的封裝,但兩者不能共存。
基本語法
首先我們需要有一個繼承了HTMLElement的類。
然后需要將其注冊到當前環(huán)境中。
在調(diào)用define時還有一個可選的第三個參數(shù),用來設(shè)置自定義標簽繼承自某個原生標簽。 兩者在后續(xù)的標簽使用上稍微有些區(qū)別:
<!-- 如果設(shè)置了 { extends: 'p' } --> <p is="cus-info" /> <script>document.createElement('p', { is: 'cus-info' }) </script> <!-- 沒有設(shè)置 extends 的情況 --> <info /> <script>document.createElement('cus-info') // 必須要包含一個`-` </script> 復制代碼P.S. 自定義的標簽的注冊名至少要包含一個-
結(jié)合場景來選擇是否使用extends,個人不建議使用,因為看起來會舒服一些
普通標簽的方式
如果是針對普通的一組標簽進行封裝,就是解決了一些相同功能的組件需要在頁面中粘來粘去的問題。
在線demo
源碼地址
實現(xiàn)類似這樣的效果:
shadow-DOM的使用方式
P.S. 當一個元素激活了shadow-DOM以后,里邊的普通子元素都會變得不可見,但是使用DOM API依然可以獲取到
在線demo
源碼地址
生命周期函數(shù)
自定義標簽并不只是一個讓你多了一個標簽可以用。
注冊的自定義標簽是有一些生命周期函數(shù)可以設(shè)置的,目前有效的事件為:
- connectedCallback 標簽被添加到文檔流中觸發(fā)
- disconnectedCallback 標簽被從文檔流中移除時觸發(fā)
- adoptedCallback 標簽被移動時觸發(fā),現(xiàn)有的API貌似沒有一個可以觸發(fā)這個事件的,因為像appendChild或者insertBefore這一類的,對于已經(jīng)存在的DOM元素都是先移除后新增的,所以不存在有直接移動的行為
- attributeChangedCallback 增刪改元素屬性時會觸發(fā) 需要提前設(shè)置observedAttributes,才能監(jiān)聽對應(yīng)的屬性變化
一個觸發(fā)各種事件的簡單示例:
在線demo
源碼地址
P.S. 如果需要處理DOM結(jié)構(gòu)以及綁定事件,推薦在connectedCallback回調(diào)中執(zhí)行 想要attributeChangedCallback生效,必須設(shè)置observedAttributes來返回該標簽需要監(jiān)聽哪些屬性的改變
使用自定義標簽封裝資料卡組件
接下來就是使用custome-elements結(jié)合著shadow-DOM來完成資料卡的一個簡單封裝。
因為shadow-DOM版本的組件相對更獨立一些,所以這里采用的是shadow-DOM的方式進行封裝。
大致代碼如下:
在線demo
源碼地址
針對上邊的initShadow調(diào)用也只是更換了avatar和name字段的來源罷了。
現(xiàn)在,我們需要在頁面中使用封裝好的資料卡,僅僅需要注冊一個自定義標簽,然后在HTML中寫對應(yīng)的標簽代碼即可
再開一下腦洞
因為是采用了注冊html標簽的方式,其實這個是對采用Server端模版渲染特別友好的一件事兒。
如果有使用服務(wù)端渲染的頁面,可能會動態(tài)的拼接一些DOM元素到請求的返回值中。
為了應(yīng)用一些樣式,可能需要在模版中添加各種className,也很有可能手一抖之類的就會導致標簽沒有閉合、結(jié)構(gòu)錯亂,或者某些屬性拼寫出錯,各種233的問題。
比如插入一些表單元素,之前可能是這樣的代碼:
在使用了custom-elements以后,Server端的記憶成本也會降低很多。
Server端只需要表明這里有一個表單元素就夠了,具體渲染成什么樣,還是交由前端來決定。
custom-events
如果在頁面中使用很多的自定義組件,必然會遇到組件之間的通訊問題的。
比如我一個按鈕點擊了以后如何觸發(fā)其他組件的行為。
因為是純原生的版本,所以天然的支持addEventListener,我們可以直接使用custom-events來完成組件之間的通訊。
基本語法
使用自定義事件與原生DOM事件唯一的區(qū)別就在于需要自己構(gòu)建Event實例并觸發(fā)事件:
document.body.addEventListener('ping', _ => console.log('pong')) // 設(shè)置事件監(jiān)聽document.body.dispatchEvent(new Event('ping')) // 觸發(fā)事件 復制代碼自定義組件中的使用
現(xiàn)在頁面中有兩個組件,一個容器,容器中包含一個文本框和數(shù)個按鈕,點擊按鈕以后會將按鈕對應(yīng)的文字輸出到文本框中:
在線demo
源碼地址
上邊是在List中循環(huán)了自己的子節(jié)點,然后依次綁定事件,這種處理是低效的,而且是不靈活的。
如果有新增的子元素,則無法觸發(fā)對應(yīng)的事件。
所以,我們可以開啟事件的冒泡來簡化上邊的代碼:
在線demo
源碼地址
ES-module
ES-module是原生模塊化的一種實現(xiàn),使用ES-module可以讓我們上邊組件的調(diào)用變得更方便。
這里有之前的一篇講解ES-module的文章:傳送陣
所以,不再贅述一些module相關(guān)的基礎(chǔ),直接將封裝好的組件代碼挪到一個js文件中,然后在頁面中引用對應(yīng)的js文件完成調(diào)用。
在線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腳本引入并沒有什么區(qū)別。
確實單純的寫這一個組件的話,是沒有什么區(qū)別的。
但是一個現(xiàn)實中的頁面,不會只有這么一個組件的,假設(shè)有這樣的一個頁面,其中包含了三個組件:
<cus-tab><cus-list><cus-card /><cus-card /></cus-list><cus-list><cus-card /><cus-card /></cus-list> </cus-tab> 復制代碼我們在使用list時要保證card已經(jīng)加載完成,在使用tab時要保證list已經(jīng)加載完成。
最簡單的方法就是等到所有的資源全部加載完成后再執(zhí)行代碼,主流的webpack打包就是這么做的。
但是,這樣做帶來的后果就是,明明list和card加載完畢后就可以處理自己的邏輯,注冊自定義標簽了,卻還是要等外層的tab加載完畢后再執(zhí)行代碼。
這個在使用webpack打包的React和Vue這類框架上邊就是很明顯的問題,如果打包完的js文件過大,幾百k,甚至數(shù)兆。
需要等到這個文件全部下載完畢后才會開始運行代碼,構(gòu)建頁面。
我們完全可以利用下載其他組件時的空白期來執(zhí)行當前組件的一些邏輯,而使用webpack這類打包工具卻不能做到,這很顯然是一個時間上的浪費,而ES-module已經(jīng)幫忙處理了這件事兒,module代碼的執(zhí)行是建立在所有的依賴全部加載完畢的基礎(chǔ)上的。
當card和list加載完畢后,list就會開始執(zhí)行代碼。而此時的tab可能還在加載過程中,等到tab加載完畢開始執(zhí)行時,list已經(jīng)注冊到了document上,就等著被調(diào)用了,從某種程度上打散了代碼執(zhí)行過于集中的問題。
可能之前頁面加載有200ms在下載文件,有50ms在構(gòu)建組件,50ms渲染頁面*(數(shù)值純屬扯淡,僅用于舉例)*。
有些組件比較輕量級,可能用了20ms就已經(jīng)下載完了文件,如果它沒有依賴其他的module,這時就會開始執(zhí)行自身組件的一些代碼,生成構(gòu)造函數(shù)、注冊自定義組件到文檔中,而這些步驟執(zhí)行的過程中可能瀏覽器還在下載其他的module,所以這就是兩條并行的線了,讓一部分代碼執(zhí)行的時間和網(wǎng)絡(luò)請求消耗的時間所重疊。
舉一個現(xiàn)實中的例子:
你開了一家飯店,雇傭了三個廚師,一個做番茄炒蛋、一個做皮蛋豆腐、還有一個做拍黃瓜,因為場地有限,所以三個廚師共用一套炊具。(單線程)
今天第一天開業(yè),這時候來了客人點了這三樣菜,但是菜還在路上。
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 datalet 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 endinitShadow(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 createdata.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效果一樣,用于在不知道組件的注冊名的情況下使用
總結(jié)
一些小提示
一個簡單的TODO-LIST的實現(xiàn):
在線demo
源碼地址
瀏覽器原生支持的功能越來越豐富,ES-module、custom-elements、shadow-DOM以及各種新鮮的玩意兒;
web原生的組件化、模塊化,期待著普及的那一天,就像現(xiàn)在可以放肆的使用qsa 、fetch,而不用考慮是否需要引入jQuery來幫助做兼容一樣(大部分情況下)。
參考資料
文中所有示例的倉庫地址
倉庫地址
總結(jié)
以上是生活随笔為你收集整理的纯原生组件化-模块化的探索的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Android接入支付宝支付实现
- 下一篇: java四种访问权限