你知道原生 HTML 組件嗎?

嘿!看看這幾年啊,Web 前端的發展但是真快啊!
想一想幾年前,HTML 是前端開發者的基本技能,經過各式各樣的標籤就能夠搭建一個可用的網站,基本交互也不是問題。若是再來點 CSS,嗯,金黃酥脆,美味可口。這時候再撒上幾把 JavaScript,簡直讓人慾罷不能。
隨着需求的增加,HTML 的結構愈來愈複雜,大量重複的代碼使得頁面改動起來異常困難,這也就孵化了一批批模版工具,將公共的部分抽取出來變爲公共組件。再後來,隨着 JavaScript 的性能提高,JavaScript 的地位愈來愈高,再也不只是配菜了,前端渲染的出現下降了服務端解析模版的壓力,服務端只要提供靜態文件和 API 接口就好了嘛。再而後,前端渲染工具又被搬回了服務端,後端渲染出現了(黑人問號???)
總之,組件化使得複雜的前端結構變得清晰,各個部分獨立起來,高內聚低耦合,使得維護成本大大下降。
那麼,你有據說過原生 HTML 組件嗎?
四大 Web 組件標準在說原生 HTML 組件以前,要先簡單介紹一下四大 Web 組件標準,四大 Web 組件標準分別爲:HTML Template、Shadow DOM、Custom Elements 和 HTML Imports。實際上其中一個已經被廢棄了,因此變成「三大」了。
HTML Template 相信不少人都有所耳聞,簡單的講也就是 HTML5 中的 <template> 標籤,正常狀況下它無色無味,感知不到它的存在,甚至它下面的 img 都不會被下載,script 都不會被執行。<template> 就如它的名字同樣,它只是一個模版,只有到你用到它時,它纔會變得有意義。
Shadow DOM 則是原生組件封裝的基本工具,它能夠實現組件與組件之間的獨立性。
Custom Elements 是用來包裝原生組件的容器,經過它,你就只須要寫一個標籤,就能獲得一個完整的組件。
HTML Imports 則是 HTML 中相似於 ES6 Module 的一個東西,你能夠直接 import 另外一個 html 文件,而後使用其中的 DOM 節點。可是,因爲 HTML Imports 和 ES6 Module 實在是太像了,而且除了 Chrome 之外沒有瀏覽器願意實現它,因此它已經被廢棄並不推薦使用了。將來會使用 ES6 Module 來取代它,可是如今貌似尚未取代的方案,在新版的 Chrome 中這個功能已經被刪除了,而且在使用的時候會在 Console 中給出警告。警告中說使用 ES Modules 來取代,可是我測試在 Chrome 71 中 ES Module 會強制檢測文件的 MIME 類型必須爲 JavaScript 類型,應該是暫時尚未實現支持。html

Shadow DOM要說原生 HTML 組件,就要先聊聊 Shadow DOM 究竟是個什麼東西。
你們對 DOM 都很熟悉了,在 HTML 中做爲一個最基礎的骨架而存在,它是一個樹結構,樹上的每個節點都是 HTML 中的一部分。DOM 做爲一棵樹,它擁有着上下級的層級關係,咱們一般使用「父節點」、「子節點」、「兄弟節點」等來進行描述(固然有人以爲這些稱謂強調性別,因此也創造了一些性別無關的稱謂)。子節點在必定程度上會繼承父節點的一些東西,也會因兄弟節點而產生必定的影響,比較明顯的是在應用 CSS Style 的時候,子節點會從父節點那裏繼承一些樣式。
而 Shadow DOM,也是 DOM 的一種,因此它也是一顆樹,只不過它是長在 DOM 樹上的一棵特殊的紫薯,啊不,子樹。
什麼?DOM 自己不就是由一棵一棵的子樹組成的嗎?這個 Shadow DOM 有什麼特別的嗎?
Shadow DOM 的特別之處就在於它致力於建立一個相對獨立的一個空間,雖然也是長在 DOM 樹上的,可是它的環境倒是與外界隔離的,固然這個隔離是相對的,在這個隔離空間中,你能夠選擇性地從 DOM 樹上的父節點繼承一些屬性,甚至是繼承一棵 DOM 樹進來。
利用 Shadow DOM 的隔離性,咱們就能夠創造原生的 HTML 組件了。
實際上,瀏覽器已經經過 Shadow DOM 實現了一些組件了,只是咱們使用過卻沒有察覺而已,這也是 Shadow DOM 封裝的組件的魅力所在:你只管寫一個 HTML 標籤,其餘的交給我。(是否是有點像 React 的 JSX 啊?)
咱們來看一看瀏覽器利用 Shadow DOM 實現的一個示例吧,那就是 video 標籤:
<video controls src="./video.mp4" width="400" height="300"></video>複製代碼咱們來看一下瀏覽器渲染的結果:前端

等一下!不是說 Shadow DOM 嗎?這和普通 DOM 有啥區別???
在 Chrome 中,Elements 默認是不顯示內部實現的 Shadow DOM 節點的,須要在設置中啓用:web

注:瀏覽器默認隱藏自身的 Shadow DOM 實現,但若是是用戶經過腳本創造的 Shadow DOM,是不會被隱藏的。
而後,咱們就能夠看到 video 標籤的真面目了:後端

在這裏,你可徹底像調試普通 DOM 同樣隨意調整 Shadow DOM 中的內容(反正和普通 DOM 同樣,刷新一下就恢復了)。
咱們能夠看到上面這些 shadow DOM 中的節點大多都有 pseudo 屬性,根據這個屬性,你就能夠在外面編寫 CSS 樣式來控制對應的節點樣式了。好比,將上面這個 pseudo="-webkit-media-controls-overlay-play-button" 的 input 按鈕的背景色改成橙色:
video::-webkit-media-controls-overlay-play-button { background-color: orange;}複製代碼瀏覽器

因爲 Shadow DOM 實際上也是 DOM 的一種,因此在 Shadow DOM 中還能夠繼續嵌套 Shadow DOM,就像上面那樣。
瀏覽器中還有不少 Element 都使用了 Shadow DOM 的形式進行封裝,好比 <input>、<select>、<audio> 等,這裏就不一一展現了。
因爲 Shadow DOM 的隔離性,因此即使是你在外面寫了個樣式:div { background-color: red !important; },Shadow DOM 內部的 div 也不會受到任何影響。
也就是說,寫樣式的時候,該用 id 的時候就用 id,該用 class 的時候就用 class,一個按鈕的 class 應該寫成 .button 就寫成 .button。徹底不用考慮當前組件中的 id、class 可能會與其餘組件衝突,你只要確保一個組件內部不衝突就好——這很容易作到。
這解決了如今絕大多數的組件化框架都面臨的問題:Element 的 class(className) 到底怎麼寫?用前綴命名空間的形式會致使 class 名太長,像這樣:.header-nav-list-sublist-button-icon;而使用一些 CSS-in-JS 工具,能夠創造一些惟一的 class 名稱,像這樣:.Nav__welcomeWrapper___lKXTg,這樣的名稱仍舊有點長,還帶了冗餘信息。
ShadowRootShadowRoot 是 Shadow DOM 下面的根,你能夠把它當作 DOM 中的 <body> 同樣看待,可是它不是 <body>,因此你不能使用 <body> 上的一些屬性,甚至它不是一個節點。
你能夠經過 ShadowRoot 下面的 appendChild、querySelectorAll 之類的屬性或方法去操做整個 Shadow DOM 樹。
對於一個普通的 Element,好比 <div>,你能夠經過調用它上面的 attachShadow 方法來建立一個 ShadowRoot(還有一個 createShadowRoot 方法,已通過時不推薦使用),attachShadow 接受一個對象進行初始化:{ mode: 'open' },這個對象有一個 mode 屬性,它有兩個取值:'open' 和 'closed',這個屬性是在創造 ShadowRoot 的時候須要初始化提供的,並在建立 ShadowRoot 以後成爲一個只讀屬性。
mode: 'open' 和 mode: 'closed' 有什麼區別呢?在調用 attachShadow 建立 ShadowRoot 以後,attachShdow 方法會返回 ShadowRoot 對象實例,你能夠經過這個返回值去構造整個 Shadow DOM。當 mode 爲 'open' 時,在用於建立 ShadowRoot 的外部普通節點(好比 <div>)上,會有一個 shadowRoot 屬性,這個屬性也就是創造出來的那個 ShadowRoot,也就是說,在建立 ShadowRoot 以後,仍是能夠在任何地方經過這個屬性再獲得 ShadowRoot,繼續對其進行改造;而當 mode 爲 'closed' 時,你將不能再獲得這個屬性,這個屬性會被設置爲 null,也就是說,你只能在 attachShadow 以後獲得 ShadowRoot 對象,用於構造整個 Shadow DOM,一旦你失去對這個對象的引用,你就沒法再對 Shadow DOM 進行改造了。
能夠從上面 Shadow DOM 的截圖中看到 #shadow-root (user-agent) 的字樣,這就是 ShadowRoot 對象了,而括號中的 user-agent 表示這是瀏覽器內部實現的 Shadow DOM,若是使用經過腳本本身建立的 ShadowRoot,括號中會顯示爲 open 或 closed 表示 Shadow DOM 的 mode。app

瀏覽器內部實現的 user-agent 的 mode 爲 closed,因此你不能經過節點的 ShadowRoot 屬性去得到其 ShadowRoot 對象,也就意味着你不能經過腳本對這些瀏覽器內部實現的 Shadow DOM 進行改造。
HTML Template有了 ShadowRoot 對象,咱們能夠經過代碼來建立內部結構了,對於簡單的結構,也許咱們能夠直接經過 document.createElement 來建立,可是稍微複雜一些的結構,若是所有都這樣來建立不只麻煩,並且代碼可讀性也不好。固然也能夠經過 ES6 提供的反引號字符串(const template = ......;)配合 innerHTML 來構造結構,利用反引號字符串中能夠任意換行,而且 HTML 對縮進並不敏感的特性來實現模版,可是這樣也是不夠優雅,畢竟代碼裏大段大段的 HTML 字符串並不美觀,即使是單獨抽出一個常量文件也是同樣。
這個時候就能夠請 HTML Template 出場了。咱們能夠在 html 文檔中編寫 DOM 結構,而後在 ShadowRoot 中加載過來便可。
HTML Template 實際上就是在 html 中的一個 <template> 標籤,正常狀況下,這個標籤下的內容是不會被渲染的,包括標籤下的 img、style、script 等都是不會被加載或執行的。你能夠在腳本中使用 getElementById 之類的方法獲得 <template> 標籤對應的節點,可是卻沒法直接訪問到其內部的節點,由於默認他們只是模版,在瀏覽器中表現爲 #document-fragment,字面意思就是「文檔片斷」,能夠經過節點對象的 content 屬性來訪問到這個 document-fragment 對象。框架

經過 document-fragment 對象,就能夠訪問到 template 內部的節點了,經過 document.importNode 方法,能夠將 document-fragment 對象建立一份副本,而後可使用一切 DOM 屬性方法替換副本中的模版內容,最終將其插入到 DOM 或是 Shadow DOM 中。
<div id="div"></div><template id="temp"> <div id="title"></div></template>複製代碼const template = document.getElementById('temp');const copy = document.importNode(template.content, true);copy.getElementById('title').innerHTML = 'Hello World!';const div = document.getElementById('div');const shadowRoot = div.attachShadow({ mode: 'closed' });shadowRoot.appendChild(copy);複製代碼HTML Imports有了 HTML Template,咱們已經能夠方便地創造封閉的 Web 組件了,可是目前還有一些不完美的地方:咱們必需要在 html 中定義一大批的 <template>,每一個組件都要定義一個 <template>。
此時,咱們就能夠用到已經被廢棄的 HTML Imports 了。雖然它已經被廢棄了,可是將來會經過 ES6 Modules 的形式再進行支持,因此理論上也只是換個加載形式而已。
經過 HTML Imports,咱們能夠將 <template> 定義在其餘的 html 文檔中,而後再在須要的 html 文檔中進行導入(固然也能夠經過腳本按需導入),導入後,咱們就能夠直接使用其中定義的模版節點了。
已經廢棄的 HTML Imports 經過 <link> 標籤實現,只要指定 rel="import" 就能夠了,就像這樣:<link rel="import" href="./templates.html">,它能夠接受 onload 和 onerror 事件以指示它已經加載完成。固然也能夠經過腳原本建立 link 節點,而後指定 rel 和 href 來按需加載。Import 成功後,在 link 節點上有一個 import 屬性,這個屬性中存儲的就是 import 進來的 DOM 樹啦,能夠 querySelector 之類的,並經過 cloneNode 或 document.importNode 方法建立副本後使用。
將來新的 HTML Imports 將會以 ES6 Module 的形式提供,能夠在 JavaScript 中直接 import * as template from './template.html';,也能夠按需 import,像這樣:const template = await import('./template.html');。不過目前雖然瀏覽器都已經支持 ES6 Modules,可是在 import 其餘模塊時會檢查服務端返回文件的 MIME 類型必須爲 JavaScript 的 MIME 類型,不然不容許加載。
Custom Elements有了上面的三個組件標準,咱們實際上只是對 HTML 進行拆分而已,將一個大的 DOM 樹拆成一個個相互隔離的小 DOM 樹,這還不是真正的組件。
要實現一個真正的組件,咱們就須要用到 Custom Elements 了,就如它的名字同樣,它是用來定義原生組件的。
Custom Elements 的核心,實際上就是利用 JavaScript 中的對象繼承,去繼承 HTML 原生的 HTMLElement 類(或是具體的某個原生 Element 類,好比 HTMLButtonElement),而後本身編寫相關的生命週期函數,處理成員屬性以及用戶交互的事件。
看起來這和如今的 React 很像,在 React 中,你能夠這樣創造一個組件:class MyElement extends React.Component { ... },而使用原生 Custom Elements,你須要這樣寫:class MyElement extends HTMLElement { ... }。
Custom Elements 的生命週期函數並很少,可是足夠使用。這裏我將 Custom Elements 的生命週期函數與 React 進行一個簡單的對比:
constructor(): 構造函數,用於初始化 state、建立 Shadow DOM、監聽事件之類。
對應 React 中 Mounting 階段的大半部分,包括:constructor(props)、static getDerivedStateFromProps(props, state) 和 render()。
在 Custom Elements 中,constructor() 構造函數就是其本來的含義:初始化,和 React 的初始化相似,但它沒有像 React 中那樣將其拆分爲多個部分。在這個階段,組件僅僅是被建立出來(好比經過 document.createElement()),可是尚未插入到 DOM 樹中。
connectedCallback(): 組件實例已被插入到 DOM 樹中,用於進行一些展現相關的初始化操做。
對應 React 中 Mounting 階段的最後一個生命週期:componentDidMount()。
在這個階段,組件已經被插入到 DOM 樹中了,或是其自己就在 html 文件中寫好在 DOM 樹上了,這個階段通常是進行一些展現相關的初始化,好比加載數據、圖片、音頻或視頻之類並進行展現。
attributeChangedCallback(attrName, oldVal, newVal): 組件屬性發生變化,用於更新組件的狀態。
對應 React 中的 Updating 階段:static getDerivedStateFromProps(props, state)、shouldComponentUpdate(nextProps, nextState)、render()、getSnapshotBeforeUpdate(prevProps, prevState) 和 componentDidUpdate(prevProps, prevState, snapshot)。
當組件的屬性(React 中的 props)發生變化時觸發這個生命週期,可是並非全部屬性變化都會觸發,好比組件的 class、style 之類的屬性發生變化通常是不會產生特殊交互的,若是全部屬性發生變化都觸發這個生命週期的話,會使得性能形成較大的影響。因此 Custom Elements 要求開發者提供一個屬性列表,只有當屬性列表中的屬性發生變化時纔會觸發這個生命週期函數。
這個屬性列表經過組件類上的一個靜態只讀屬性來聲明,在 ES6 Class 中使用一個 getter 函數來實現,只實現 getter 而不實現 setter,getter 返回一個常量,這樣就是隻讀的了。像這樣:
class AwesomeElement extends HTMLElement { static get observedAttributes() { return ['awesome']; }}複製代碼
disconnectedCallback(): 組件被從 DOM 樹中移除,用於進行一些清理操做。
對應 React 中的 Unmounting 階段:componentWillUnmount()。
adoptedCallback(): 組件實例從一個文檔被移動到另外一個文檔。
這個生命週期是原生組件獨有的,React 中沒有相似的生命週期。這個生命週期函數也並不經常使用到,通常在操做多個 document 的時候會遇到,調用 document.adoptNode() 函數轉移節點所屬 document 時會觸發這個生命週期。async

在定義了自定義組件後,咱們須要將它註冊到 HTML 標籤列表中,經過 window.customElements.define() 函數便可實現,這個函數接受兩個必須參數和一個可選參數。第一個參數是註冊的標籤名,爲了不和 HTML 自身的標籤衝突,Custom Elements 要求用戶自定義的組件名必須至少包含一個短槓 -,而且不能以短槓開頭,好比 my-element、awesome-button 之類都是能夠的。第二個參數是註冊的組件的 class,直接將繼承的子類類名傳入便可,固然也能夠直接寫一個匿名類:
window.customElements.define('my-element', class extends HTMLElement { ...});複製代碼註冊以後,咱們就可使用了,能夠直接在 html 文檔中寫對應的標籤,好比:<my-element></my-element>,也能夠經過 document.createElement('my-element') 來建立,用法與普通標籤幾乎徹底同樣。但要注意的是,雖然 html 標準中說部分標籤能夠不關閉或是自關閉(
或是
),可是隻有規定的少數幾個標籤容許自關閉,因此,在 html 中寫 Custom Elements 的節點時必須帶上關閉標籤。
因爲 Custom Elements 是經過 JavaScript 來定義的,而通常 js 文件都是經過 <script> 標籤外聯的,因此 html 文檔中的 Custom Elements 在 JavaScript 未執行時是處於一個默認的狀態,瀏覽器默認會將其內容直接顯示出來。爲了不這樣的狀況發生,Custom Elements 在被註冊後都會有一個 :defined CSS 僞類而在註冊前沒有,因此咱們能夠經過 CSS 選擇器在 Custom Elements 註冊前將其隱藏起來,好比:
my-element:not(:defined) { display: none;}複製代碼或者 Custom Elements 也提供了一個函數來檢測指定的組件是否已經被註冊:customElements.whenDefined(),這個函數接受一個組件名參數,並返回一個 Promise,當 Promise 被 resolve 時,就表示組件被註冊了。
這樣,咱們就能夠放心的在加載 Custom Elements 的 JavaScript 的 <script> 標籤上使用 async 屬性來延遲加載了(固然,若是是使用 ES6 Modules 形式的話默認的加載行爲就會和 defer 相似)。ide

Custom Elements + Shadow DOM使用 Custom Elements 來建立組件時,一般會與 Shadow DOM 進行結合,利用 Shadow DOM 的隔離性,就能夠創造獨立的組件。
一般在 Custom Elements 的 constructor() 構造函數中去建立 Shadow DOM,並對 Shadow DOM 中的節點添加事件監聽、對特定事件觸發原生 Events 對象。
正常編寫 html 文檔時,咱們可能會給 Custom Elements 添加一些子節點,像這樣:<my-element><h1>Title</h1><p>Content</p></my-element>,而咱們建立的 Shadow DOM 又擁有其本身的結構,怎樣將這些子節點放置到 Shadow DOM 中正確的位置上呢?
在 React 中,這些子節點被放置在 props 的 children 中,咱們能夠在 render() 時選擇將它放在哪裏。而在 Shadow DOM 中有一個特殊的標籤:<slot>,這個標籤的用處就如同其字面意思,在 Shadow DOM 上放置一個「插槽」,而後 Custom Elements 的子節點就會自動放置到這個「插槽」中了。
有時咱們須要更加精確地控制子節點在 Shadow DOM 中的位置,而默認狀況下,全部子節點都會被放置在同一個 <slot> 標籤下,即使是你寫了多個 <slot>。那怎樣更精確地對子節點進行控制呢?
默認狀況下,<slot>Fallback</slot> 這樣的是默認的 <slot>,只有第一個默認的 <slot> 會有效,將全部子節點所有放進去,若是沒有可用的子節點,將會顯示默認的 Fallback 內容(Fallback 能夠是一棵子 DOM 樹)。
<slot> 標籤有一個 name 屬性,當你提供 name 後,它將變爲一個「有名字的 <slot>」,這樣的 <slot> 能夠存在多個,只要名字各不相同。此時他們會自動匹配 Custom Elements 下帶 slot 屬性而且 slot 屬性與自身 name 相同的子節點,像這樣:
<template id="list"> <div> <h1>Others</h1> <slot></slot> </div> <div> <h1>Animals</h1> <slot name="animal"></slot> </div> <div> <h1>Fruits</h1> <slot name="fruit"></slot> </div></template><my-list> <div slot="animal">Cat</div> <div slot="fruit">Apple</div> <div slot="fruit">Banana</div> <div slot="other">flower</div> <div>pencil</div> <div slot="animal">Dog</div> <div slot="fruit">peach</div> <div>red</div></my-list>複製代碼class MyList extends HTMLElement { constructor() { super(); const root = this.attachShadow({ mode: 'open' }); const template = document.getElementById('list'); root.appendChild(document.importNode(template.content, true)); }}customElements.define('my-list', MyList);複製代碼這樣就能夠獲得如圖所示的結構,#shadow-root (open) 表示這是一個開放的 Shadow DOM,下面的節點是直接從 template 中 clone 過來的,瀏覽器自動在三個 <slot> 標籤下放置了幾個灰色的 <div> 節點,實際上這些灰色的 <div> 節點表示的是到其真實節點的「引用」,鼠標移動到他們上會顯示一個 reveal 連接,點擊這個連接便可跳轉至其真實節點。函數

這裏咱們能夠看到,雖然 <my-list> 下的子節點是亂序放置的,可是隻要是給定了 slot 屬性,就會被放置到正確的 <slot> 標籤下。注意觀察其中有一個 <div slot="other">flower</div>,這個節點因爲指定了 slot="other",可是卻找不到匹配的 <slot> 標籤,因此它不會被顯示在結果中。在爲 Custom Elements 下的 Shadow DOM 設置樣式的時候,咱們能夠直接在 Shadow DOM 下放置 <style> 標籤,也能夠放置 <link rel="stylesheet">,Shadow DOM 下的樣式都是局部的,因此不用擔憂會影響到 Shadow DOM 的外部。而且因爲這些樣式僅影響局部更多技術資訊可關注:gzitcast

相關文章
相關標籤/搜索