[译] 用 Shadow DOM v1 和 Custom Elements v1 实现一个原生 Web Component
- 原文地址:Make a Native Web Component with Custom Elements v1 and Shadow DOM v1
- 原文作者:Pearl Latteier
- 譯文出自:掘金翻譯計(jì)劃
- 本文永久鏈接:github.com/xitu/gold-m…
- 譯者:newraina
- 校對(duì)者:CoderMing
假如你有一個(gè)小表單或者組件要在網(wǎng)站的好幾個(gè)地方或者好幾個(gè)項(xiàng)目里用,你希望它們都能有統(tǒng)一的樣式和行為,但是,你也希望它們能有些靈活性:也許你的表單需要根據(jù)容器元素的不同有各種大小,或者組件要在不同的項(xiàng)目里顯示不同的文字和圖標(biāo)。你知道你需要什么嗎?你需要一個(gè) web component!
Web components 是可以重用和共享的自定義 HTML 元素。和原生 HTML 元素一樣,它們有屬性,有方法,有事件監(jiān)聽器,能嵌套,能兼容各種 JavaScript 框架。
怎么樣,是不是很厲害?沒有 jQuery,沒有難以維護(hù)的面條代碼,它就是一個(gè)良好封裝過的帶 UI 和功能的組件了。
介紹一下 Mini-Form 組件
我們要實(shí)現(xiàn)一個(gè)叫 “mini-form” 的 web component。(Custom element 的名字必須用小寫字母開頭,并且至少有一個(gè)連字符。要了解更多可以閱讀相關(guān)標(biāo)準(zhǔn)。)它是一個(gè)很簡(jiǎn)單的表單組件:讓用戶提交投訴意見,并且能確認(rèn)是否收到了用戶的輸入(實(shí)際上并不真的干什么)。這個(gè)組件能自適應(yīng)它容器元素的大小和標(biāo)題的長(zhǎng)度。它有一個(gè)基本的 material design 樣式;你可以給每個(gè)組件實(shí)例指定顏色主題。組件的代碼托管在 github.com/pearlbea/mi…,在線示例請(qǐng)見這里。
定義 Custom Element
Web components 可以用一些新的 web 標(biāo)準(zhǔn)來(lái)實(shí)現(xiàn)。其中最重要的是最新修訂過的 Custom Elements 標(biāo)準(zhǔn)。(要了解更多關(guān)于新的 Custom Elements V1 標(biāo)準(zhǔn),可以閱讀 Eric Bidelman 的文章)要?jiǎng)?chuàng)建一個(gè) custom element,我們需要兩個(gè)東西:一個(gè)定義元素行為的類,以及一個(gè)告訴瀏覽器如何關(guān)聯(lián) DOM 元素標(biāo)簽和剛才那個(gè)類的定義。新建一個(gè)叫 mini-form.js 的文件,把下面的類和定義代碼放進(jìn)去:
class MiniForm extends HTMLElement {constructor() {super();} } window.customElements.define('mini-form', MiniForm); 復(fù)制代碼constructor 里,對(duì) super() 不帶參數(shù)的調(diào)用必須放在第一行。它會(huì)為組件設(shè)置正確的原型鏈和 this 的值。(更多信息可以參考 Mozilla Developer Network 關(guān)于 super 的文章。)
其他準(zhǔn)備工作
新建文件的時(shí)候,還要?jiǎng)?chuàng)建:一個(gè) index.html,用來(lái)實(shí)際引用組件;一個(gè) mini-form-test.html,用來(lái)寫測(cè)試用例,因?yàn)榻M件是你寫的。先在這兩個(gè)文件里寫上基本的 HTML5 樣板代碼。
你還需要一些 polyfill。我們使用的 web 標(biāo)準(zhǔn)非常新,還沒被所有瀏覽器支持,至少到目前為止,polyfill 是必須的。對(duì)于我們這個(gè)簡(jiǎn)單的組件,只需要兩個(gè) polyfill:custom elements 和 shadydom,可以用 Bower 安裝:
bower install --save webcomponents/custom-elements bower install --save webcomponents/shadydom 復(fù)制代碼把這兩個(gè) polyfills 放在 index.html 和 mini-form-test.html 的 head 里,(或者用你習(xí)慣的構(gòu)建工具打包在一起,都行,無(wú)所謂。)同時(shí),也要把 mini-form.js 引用進(jìn)每一個(gè) HTML 文件里。index.html 現(xiàn)在差不多是下面的樣子:
<!doctype html> <html lang="eng"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, minimum-scale=1, initial-scale=1, user-scalable=yes"><script src="bower_components/shadydom/shadydom.min.js"></script><script src="bower_components/custom-elements/custom-elements.min.js"></script><script src="mini-form.js"></script></head><body></body> </html> 復(fù)制代碼注意:shadydom polyfill 要放在 custom elements polyfill 前面。不然,你可能會(huì)看到 Element#attachShadow 不存在的報(bào)錯(cuò)。(猜猜我是怎么知道的。)shadow DOM 的其他內(nèi)容后面再說。
編寫測(cè)試用例
在真的開始寫組件之前,我們先寫一些測(cè)試。我們要測(cè)試這個(gè)組件能不能在 DOM 中渲染出一個(gè) div,現(xiàn)在它還通不過測(cè)試,畢竟我們的組件還幾乎不存在。不過,一旦我們渲染出了一個(gè) div 元素,我們就能體會(huì)到目睹測(cè)試通過的樂趣。
測(cè)試差不多是這個(gè)樣子:
suite('<mini-form>', () => {let component = document.querySelector('mini-form');test('renders div', () => {assert.isOk(component.querySelector('div'));}); }); 復(fù)制代碼為了運(yùn)行測(cè)試,我們要用到 Polymer Project 創(chuàng)建的 web component tester 工具。用 NPM 安裝好 web-component-tester 之后,在 mini-form-test.html 文件的 head 標(biāo)簽里加上 node_modules/web-component-tester/browser.js,polyfills 和 mini-form.js 也應(yīng)該在頁(yè)面上了。
你還要在 body 里加上 mini-form 的實(shí)例,就像這樣:
<body><mini-form></mini-form><script>suite('<mini-form>', function() {let component = document.querySelector('mini-form');test('renders div', () => {assert.isOk(component.shadowRoot.querySelector('div'));});});</script> </body> 復(fù)制代碼好了,跑測(cè)試吧!在命令行中輸入 wct,web component tester 會(huì)啟動(dòng)你安裝的所有瀏覽器運(yùn)行測(cè)試。然后,你會(huì)看到一個(gè)測(cè)試失敗的提示:
? test/mini-form-test.html ? <mini-form> ? renders div expected null to be truthy 復(fù)制代碼如果你遇到了其他問題,可以在這里看看到這一步,你的代碼應(yīng)該是什么樣子。
編寫模版
現(xiàn)在我們可以來(lái)擴(kuò)充組件的實(shí)現(xiàn)并讓測(cè)試通過了。
class MiniForm extends HTMLElement {constructor() {super();}connectedCallback() {this.innerHTML = this.template;}get template() {return `<div>This is a div</div>`;} } 復(fù)制代碼上面的代碼新增了一個(gè)返回最簡(jiǎn)單模板的 getter。然后,在 connectedCallback 中,模板賦給了組件的 innerHTML。connectedCallback 方法是custom element 生命周期的一部分,當(dāng)組件插入到 DOM 中時(shí)會(huì)被調(diào)用。
再跑一遍測(cè)試,噢耶!這次肯定能通過!當(dāng)然,這個(gè)組件最后不會(huì)僅僅只顯示一個(gè) div。我們要寫更多的測(cè)試,看著它們測(cè)試失敗,再靠代碼實(shí)現(xiàn)讓它們最終都能通過。
// mini-form-test.html test('renders input', function() {assert.isOk(component.querySelector('input[type="text"]')); });test('renders button', function() {assert.isOk(component.querySelector('button')); });// mini-form.js get template() {return `<div><input type="text" name="complaint" /><button>Submit</button></div>`; } 復(fù)制代碼增加樣式和 Shadow DOM
到目前為止,mini-form 組件還不是很好看,是時(shí)候加一點(diǎn)樣式了。不管用在哪里,組件的樣式都應(yīng)該在所有的實(shí)例間保持統(tǒng)一。我們并不希望組件所在頁(yè)面的 CSS 或者 JS 會(huì)影響到組件,也不希望組件的樣式或行為影響到了它所處的頁(yè)面。可以通過把組件的內(nèi)容封裝在 Shadow DOM 里來(lái)實(shí)現(xiàn)這一點(diǎn)。
Shadow DOM 和你早已熟悉和喜愛的 DOM 很像。它有相同的樹形結(jié)構(gòu)和工作方式,只是:它不會(huì)和父級(jí) DOM 相互影響;也不會(huì)成為它所附屬元素的子元素。
我們要修改 mini-form 來(lái)讓它支持 Shadow DOM。
connectedCallback() {this.initShadowDom(); }initShadowDom() {let shadowRoot = this.attachShadow({mode: 'open'});shadowRoot.innerHTML = this.template; } 復(fù)制代碼我們不再把模板內(nèi)容直接賦給組件自身的 innerHTML,而是創(chuàng)建一個(gè) shadowRoot 作為中介:給組件關(guān)聯(lián)上一個(gè) Shadow DOM,然后把模板內(nèi)容賦給這個(gè) Shadow DOM 的 innerHTML。
這樣做會(huì)破壞掉所有的測(cè)試,不過,改起來(lái)也很簡(jiǎn)單,只要在 DOM 查詢上加上剛定義過的 shadowRoot 即可。
test('renders div', () => {assert.isOk(component.shadowRoot.querySelector('div')); }); test('renders input', () => {assert.isOk(component.shadowRoot.querySelector('input')); }); test('render button', () => {assert.isOk(component.shadowRoot.querySelector('button')); }); 復(fù)制代碼跑一遍測(cè)試,確保全都通過之后,我們來(lái)加上 Material Design 的樣式。
<style>@import 'https://fonts.googleapis.com/icon?family=Material+Icons';@import 'https://code.getmdl.io/1.3.0/material.indigo-pink.min.css';@import 'http://fonts.googleapis.com/css?family=Roboto:300,400,500,700';.mdl-card {width: 100%;}.mdl-button {margin-top: 10px;}i {margin-right: 5px;} </style> <div class="mdl-card mdl-shadow--2dp"><header class="mdl-layout__header"><div class="mdl-layout__header-row"><i class="material-icons">mood_bad</i><div class="mdl-layout-title">complaint box</div></div></header><div class="mdl-card__supporting-text"><input type="text" class="mdl-textfield__input" /></div><div class="mdl-card__actions"><button class="mdl-button mdl-button--raised mdl-button--accent">Submit</button></div> </div> 復(fù)制代碼在瀏覽器里打開組件的 index.html 看一下,頁(yè)面雖然還需要打磨,但是已經(jīng)有一個(gè)好看的輸入框和一個(gè)漂亮的粉色按鈕了。
(沒看到粉色按鈕?可以來(lái)這里看下到這一步,代碼應(yīng)該是什么樣子。)
在內(nèi)部 DOM 中創(chuàng)建 <slot>
Shadow DOM 有個(gè)很棒的特性:<slot> 元素,它讓組件可以把它實(shí)際的子元素插入到內(nèi)部結(jié)構(gòu)中。這個(gè)能力讓 web components 變得異常靈活。<slot> 元素扮演了一個(gè)占位符的角色,使用組件的人可以自己填充內(nèi)容。對(duì)于我們這個(gè)組件來(lái)說,我們將用 slot 讓我們自己(或者組件未來(lái)的用戶)有能力為表單每一個(gè)實(shí)例提供不同的文字提示或者問題。第一步,先寫好測(cè)試:
<body><mini-form>What?!</mini-form><script>suite('<mini-form>', function() {let component = document.querySelector('mini-form');...test('renders prompt', () => {let index = component.innerText.indexOf('What?!');assert.isAtLeast(index, 0);});});</script> </body> 復(fù)制代碼上面的測(cè)試檢查了 <mini-form> 標(biāo)簽之間的文本內(nèi)容是不是在組件中顯示出來(lái)了。運(yùn)行一下測(cè)試,可以看到測(cè)試失敗了。
為了讓測(cè)試通過,在模板中加一個(gè) <slot>。
<div class="mdl-card mdl-shadow--2dp"><div class="mdl-card__supporting-text"><h4><slot></slot></h4><input type="text" rows="3" class="mdl-textfield__input" name="prompt" /></div>... </div> 復(fù)制代碼再跑一遍測(cè)試,這次通過了!試試在 index.html 的 mini-form 標(biāo)簽之間寫點(diǎn)東西,然后在瀏覽器里看一下效果。到這一步的代碼在這里。
實(shí)現(xiàn)主題化
組件需要能允許我們?yōu)槊恳粋€(gè)實(shí)例指定一個(gè)顏色主題。為了讓主題化和我們?cè)谟玫?material design CSS 配合得好,用戶能用的主題會(huì)被限制在這里列出的幾種里。我們給組件新增一個(gè) theme 屬性,用戶設(shè)置一個(gè)字符串值來(lái)指定主題。
給這個(gè)新特性寫點(diǎn)測(cè)試。
<body><mini-form theme="blue-green">What?!</mini-form><script>suite('<mini-form>', function() {let component = document.querySelector('mini-form');...test('applies color theme to button', () => {let button = component.shadowRoot.querySelector('button');let buttonColor = window.getComputedStyle(button).getPropertyValue('background-color');assert.equal(buttonColor, 'rgb(105, 240, 174)');});test('applies color theme to header', () => {let header = component.shadowRoot.querySelector('header');let headerColor = window.getComputedStyle(header).getPropertyValue('background-color');assert.equal(headerColor, 'rgb(33, 150, 243)');});});</script> </body> 復(fù)制代碼跑一遍測(cè)試,確定一下它們通過沒有。沒通過吧?很好。修改組件的代碼來(lái)獲取和使用 theme 屬性。
get theme() {return this.getAttribute('theme') || 'indigo-pink'; }get template() {return `<style>@import 'https://code.getmdl.io/1.3.0/material.${this.theme}.min.css';...</style>...`; } 復(fù)制代碼我們從 <mini-form> 標(biāo)簽上獲取 theme 屬性,把它或者它的默認(rèn)值 indigo-pink 用在 CSS 的地址里。如果我們給 theme 屬性賦了這個(gè) CSS 類庫(kù)實(shí)際并沒有的主題值,CSS 的地址就不會(huì)生效,組件就會(huì)很難看。解決這個(gè)問題需要寫的代碼(和它的測(cè)試用例!),我打算交給你自己來(lái)完成。
跑一下測(cè)試,哎呀,并沒有全部通過。因?yàn)?Firefox 不支持 Shadow DOM,在 Firefox 里跑的測(cè)試失敗了。我們已經(jīng)用上了 shadydom polyfill,但它并不支持 CSS 封裝,有另一個(gè)叫 shadycss 的 polyfill 能解決這個(gè)問題。跟上面一樣,之后你自己完成。
在 index.html 里,給 mini-form 標(biāo)簽增加一個(gè) theme 屬性。然后你就能在瀏覽器里看到你的藝術(shù)創(chuàng)作了。
處理事件
組件已經(jīng)很好看了,但還什么都干不了。我們要干的最后一件事情,是給它加上事件處理的邏輯。當(dāng)用戶點(diǎn)擊“Submit”按鈕的時(shí)候,得發(fā)生點(diǎn)什么事情。代碼要獲取輸入,顯示一個(gè)成功或失敗(如果輸入為空)的提示。當(dāng)用戶接著聚焦進(jìn)輸入框的時(shí)候,錯(cuò)誤信息需要消失掉。
給這些事件邏輯寫上測(cè)試。
let input = component.shadowRoot.querySelector('input[type="text"]'); let button = component.shadowRoot.querySelector('button'); let errorMsg = component.shadowRoot.querySelector('.error');test('displays an error message on submit', () => {button.click();let index = errorMsg.innerText.indexOf('Don\'t you have something to say?');assert.isAtLeast(index, 0); }); test('clears error message on focus', () => {input.focus();let index = errorMsg.innerText.indexOf('Don\'t you have something to say?');assert.isAtLeast(index, -1); }); test('displays a success message on submit', () => {input.value = 'Some text';button.click();let index = component.shadowRoot.querySelector('.mdl-card').innerText.indexOf('Thank you.');assert.isAtLeast(index, 0); }); 復(fù)制代碼在組件代碼里,給用戶會(huì)與之發(fā)生交互的兩個(gè)元素:輸入框和按鈕綁定事件監(jiān)聽器。
當(dāng)用戶聚焦進(jìn)輸入框,我們希望清空可能在顯示的任何錯(cuò)誤提示。首先,在模板里新增一個(gè)錯(cuò)誤提示,并且創(chuàng)建一個(gè)帶有 visibility: hidden 屬性的 CSS 類 hide。
<div class="mdl-card__supporting-text"><h4><slot></slot></h4><input type="text" rows="3" class="mdl-textfield__input" name="question" /><div class="error hide">Don't you have something to say?</div> </div> 復(fù)制代碼給輸入框綁定一個(gè)事件監(jiān)聽器,處理它的聚焦事件。
connectedCallback() {this.initShadowDom();this.addFocusListener(); } get input() {return this.shadowRoot.querySelector('input'); } get errorMessage() {return this.shadowRoot.querySelector('.error'); } addFocusListener() {this.input.addEventListener('focus', e => {this.hideErrorMessage();}); } hideErrorMessage() {this.errorMessage.className = 'error hide'; } 復(fù)制代碼上面的代碼給輸入框元素創(chuàng)建了一個(gè) getter、一個(gè)在 connectedCallback 里調(diào)用的綁定聚焦事件監(jiān)聽的方法、還有一個(gè)在事件監(jiān)聽中用來(lái)隱藏錯(cuò)誤提示的方法。
接著,給按鈕增加點(diǎn)擊事件的事件監(jiān)聽和處理點(diǎn)擊的邏輯。
connectedCallback() {this.initShadowDom();this.addFocusListener();this.addClickListener(); } get button() {return this.shadowRoot.querySelector('button'); } get card() {return this.shadowRoot.querySelector('.mdl-card'); } get message() {// this could be a separate component and probably should be if you make it more complicatedreturn `<div><div class="mdl-card__title"><h4>Thank you.</h4></div><div class="mdl-card__supporting-text">We have received your complaint.</div><div class="mdl-card__actions"></div></div>`; } addClickListener() {this.button.addEventListener('click', e => {this.getUserInput();}); } getUserInput() {this.input.value.length > 0 ? this.handleSuccess() : this.displayErrorMessage(); } handleSuccess() {// You could call a method to save the user's answer herethis.displaySuccessMessage(); } displaySuccessMessage() {this.card.innerHTML = this.message; } displayErrorMessage() {this.errorMessage.className = 'error'; } 復(fù)制代碼跑一遍測(cè)試,看它們是不是全都通過!也有可能只是大部分通過:在 Firefox 里,樣式的測(cè)試用例依然會(huì)失敗。恭喜,你有一個(gè)能工作的 web component 了!
全部的代碼在這里。
還可以做很多很多事情來(lái)完善和擴(kuò)展這個(gè)組件。除了我早就提到過的,你還可以給頭部標(biāo)題的文本、圖標(biāo)加上 slot,或者美化、保存用戶的輸入內(nèi)容。
覺得還不夠的話,可以寫一個(gè)你自己的組件,在 Twitter 上私信給我。祝編程愉快!
相關(guān)鏈接
- webcomponents.org,關(guān)于 web components 最重要的信息來(lái)源
- Web Components v1 — the next generation Google 的 Web 更新動(dòng)向,Taylor Savage 編寫
- Custom Elements v1: Reusable Web Components Google 的 Web 基礎(chǔ)知識(shí),Eric Bidelman 編寫
- Shadow DOM v1: Self-Contained Web Components Google 的 Web 基礎(chǔ)知識(shí),Eric Bidelman 編寫
- Custom Elements That Work Anywhere Rob Dodson 編寫
- Polymer,一個(gè) web component 庫(kù)
- Skate,也是一個(gè) web component 庫(kù)
- web-component-tester,一個(gè)測(cè)試 web components 的工具
有任何問題或想法,都可以在 twitter @bendyworks 或者 Facebook 上聯(lián)系我們。
如果發(fā)現(xiàn)譯文存在錯(cuò)誤或其他需要改進(jìn)的地方,歡迎到 掘金翻譯計(jì)劃 對(duì)譯文進(jìn)行修改并 PR,也可獲得相應(yīng)獎(jiǎng)勵(lì)積分。文章開頭的 本文永久鏈接 即為本文在 GitHub 上的 MarkDown 鏈接。
掘金翻譯計(jì)劃 是一個(gè)翻譯優(yōu)質(zhì)互聯(lián)網(wǎng)技術(shù)文章的社區(qū),文章來(lái)源為 掘金 上的英文分享文章。內(nèi)容覆蓋 Android、iOS、前端、后端、區(qū)塊鏈、產(chǎn)品、設(shè)計(jì)、人工智能等領(lǐng)域,想要查看更多優(yōu)質(zhì)譯文請(qǐng)持續(xù)關(guān)注 掘金翻譯計(jì)劃、官方微博、知乎專欄。
總結(jié)
以上是生活随笔為你收集整理的[译] 用 Shadow DOM v1 和 Custom Elements v1 实现一个原生 Web Component的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 3结构介绍_豹驰(BOACH)声学材料吸
- 下一篇: JQ的异步文件上传