[譯] 用 Shadow DOM v1 和 Custom Elements v1 實現一個原生 Web Component


假如你有一個小表單或者組件要在網站的好幾個地方或者好幾個項目裏用,你但願它們都能有統一的樣式和行爲,可是,你也但願它們能有些靈活性:也許你的表單須要根據容器元素的不一樣有各類大小,或者組件要在不一樣的項目裏顯示不一樣的文字和圖標。你知道你須要什麼嗎?你須要一個 web component!css

Web components 是能夠重用和共享的自定義 HTML 元素。和原生 HTML 元素同樣,它們有屬性,有方法,有事件監聽器,能嵌套,能兼容各類 JavaScript 框架html

怎麼樣,是否是很厲害?沒有 jQuery,沒有難以維護的麪條代碼,它就是一個良好封裝過的帶 UI 和功能的組件了。前端

介紹一下 Mini-Form 組件

咱們要實現一個叫 「mini-form」 的 web component。(Custom element 的名字必須用小寫字母開頭,而且至少有一個連字符。要了解更多能夠閱讀相關標準。)它是一個很簡單的表單組件:讓用戶提交投訴意見,而且能確認是否收到了用戶的輸入(實際上並不真的幹什麼)。這個組件能自適應它容器元素的大小和標題的長度。它有一個基本的 material design 樣式;你能夠給每一個組件實例指定顏色主題。組件的代碼託管在 github.com/pearlbea/mi…,在線示例請見這裏node

定義 Custom Element

Web components 能夠用一些新的 web 標準來實現。其中最重要的是最新修訂過的 Custom Elements 標準。(要了解更多關於新的 Custom Elements V1 標準,能夠閱讀 Eric Bidelman 的文章)要建立一個 custom element,咱們須要兩個東西:一個定義元素行爲的類,以及一個告訴瀏覽器如何關聯 DOM 元素標籤和剛纔那個類的定義。新建一個叫 mini-form.js 的文件,把下面的類和定義代碼放進去:android

class MiniForm extends HTMLElement {
  constructor() {
    super();
  }
}
window.customElements.define('mini-form', MiniForm);
複製代碼

constructor 裏,對 super() 不帶參數的調用必須放在第一行。它會爲組件設置正確的原型鏈和 this 的值。(更多信息能夠參考 Mozilla Developer Network 關於 super 的文章。)ios

其餘準備工做

新建文件的時候,還要建立:一個 index.html,用來實際引用組件;一個 mini-form-test.html,用來寫測試用例,由於組件是你寫的。先在這兩個文件裏寫上基本的 HTML5 樣板代碼。git

你還須要一些 polyfill。咱們使用的 web 標準很是新,還沒被全部瀏覽器支持,至少到目前爲止,polyfill 是必須的。對於咱們這個簡單的組件,只須要兩個 polyfill:custom elementsshadydom,能夠用 Bower 安裝:github

bower install --save webcomponents/custom-elements
bower install --save webcomponents/shadydom
複製代碼

把這兩個 polyfills 放在 index.htmlmini-form-test.html 的 head 裏,(或者用你習慣的構建工具打包在一塊兒,都行,無所謂。)同時,也要把 mini-form.js 引用進每個 HTML 文件裏。index.html 如今差很少是下面的樣子:web

<!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>
複製代碼

注意:shadydom polyfill 要放在 custom elements polyfill 前面。否則,你可能會看到 Element#attachShadow 不存在的報錯。(猜猜我是怎麼知道的。)shadow DOM 的其餘內容後面再說。編程

編寫測試用例

在真的開始寫組件以前,咱們先寫一些測試。咱們要測試這個組件能不能在 DOM 中渲染出一個 div,如今它還通不過測試,畢竟咱們的組件還幾乎不存在。不過,一旦咱們渲染出了一個 div 元素,咱們就能體會到目擊測試經過的樂趣。

測試差很少是這個樣子:

suite('<mini-form>', () => {
  let component = document.querySelector('mini-form');
  test('renders div', () => {
    assert.isOk(component.querySelector('div'));
  });
});
複製代碼

爲了運行測試,咱們要用到 Polymer Project 建立的 web component tester 工具。用 NPM 安裝好 web-component-tester 以後,在 mini-form-test.html 文件的 head 標籤里加上 node_modules/web-component-tester/browser.js,polyfills 和 mini-form.js 也應該在頁面上了。

你還要在 body 里加上 mini-form 的實例,就像這樣:

<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>
複製代碼

好了,跑測試吧!在命令行中輸入 wctweb component tester 會啓動你安裝的全部瀏覽器運行測試。而後,你會看到一個測試失敗的提示:

test/mini-form-test.html » <mini-form> » renders div expected null to be truthy
複製代碼

若是你遇到了其餘問題,能夠在這裏看看到這一步,你的代碼應該是什麼樣子。

編寫模版

如今咱們能夠來擴充組件的實現並讓測試經過了。

class MiniForm extends HTMLElement {

  constructor() {
    super();
  }

  connectedCallback() {
    this.innerHTML = this.template;
  }

  get template() {
    return `
      <div>This is a div</div>
    `;
  }
}
複製代碼

上面的代碼新增了一個返回最簡單模板的 getter。而後,在 connectedCallback 中,模板賦給了組件的 innerHTML。connectedCallback 方法是custom element 生命週期的一部分,當組件插入到 DOM 中時會被調用。

再跑一遍測試,噢耶!此次確定能經過!固然,這個組件最後不會僅僅只顯示一個 div。咱們要寫更多的測試,看着它們測試失敗,再靠代碼實現讓它們最終都能經過。

// 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>
  `;
}
複製代碼

增長樣式和 Shadow DOM

到目前爲止,mini-form 組件還不是很好看,是時候加一點樣式了。無論用在哪裏,組件的樣式都應該在全部的實例間保持統一。咱們並不但願組件所在頁面的 CSS 或者 JS 會影響到組件,也不但願組件的樣式或行爲影響到了它所處的頁面。能夠經過把組件的內容封裝在 Shadow DOM 裏來實現這一點。

Shadow DOM 和你早已熟悉和喜好的 DOM 很像。它有相同的樹形結構和工做方式,只是:它不會和父級 DOM 相互影響;也不會成爲它所附屬元素的子元素。

咱們要修改 mini-form 來讓它支持 Shadow DOM。

connectedCallback() {
  this.initShadowDom();
}

initShadowDom() {
  let shadowRoot = this.attachShadow({mode: 'open'});
  shadowRoot.innerHTML = this.template;
}
複製代碼

咱們再也不把模板內容直接賦給組件自身的 innerHTML,而是建立一個 shadowRoot 做爲中介:給組件關聯上一個 Shadow DOM,而後把模板內容賦給這個 Shadow DOM 的 innerHTML。

這樣作會破壞掉全部的測試,不過,改起來也很簡單,只要在 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'));
});
複製代碼

跑一遍測試,確保全都經過以後,咱們來加上 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>
複製代碼

在瀏覽器裏打開組件的 index.html 看一下,頁面雖然還須要打磨,可是已經有一個好看的輸入框和一個漂亮的粉色按鈕了。

(沒看到粉色按鈕?能夠來這裏看下到這一步,代碼應該是什麼樣子。)

在內部 DOM 中建立 <slot>

Shadow DOM 有個很棒的特性:<slot> 元素,它讓組件能夠把它實際的子元素插入到內部結構中。這個能力讓 web components 變得異常靈活。<slot> 元素扮演了一個佔位符的角色,使用組件的人能夠本身填充內容。對於咱們這個組件來講,咱們將用 slot 讓咱們本身(或者組件將來的用戶)有能力爲表單每個實例提供不一樣的文字提示或者問題。第一步,先寫好測試:

<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>
複製代碼

上面的測試檢查了 <mini-form> 標籤之間的文本內容是否是在組件中顯示出來了。運行一下測試,能夠看到測試失敗了。

爲了讓測試經過,在模板中加一個 <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>
複製代碼

再跑一遍測試,此次經過了!試試在 index.htmlmini-form 標籤之間寫點東西,而後在瀏覽器裏看一下效果。到這一步的代碼在這裏

實現主題化

組件須要能容許咱們爲每個實例指定一個顏色主題。爲了讓主題化和咱們在用的 material design CSS 配合得好,用戶能用的主題會被限制在這裏列出的幾種裏。咱們給組件新增一個 theme 屬性,用戶設置一個字符串值來指定主題。

給這個新特性寫點測試。

<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>
複製代碼

跑一遍測試,肯定一下它們經過沒有。沒經過吧?很好。修改組件的代碼來獲取和使用 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>
    ...
  `;
}
複製代碼

咱們從 <mini-form> 標籤上獲取 theme 屬性,把它或者它的默認值 indigo-pink 用在 CSS 的地址裏。若是咱們給 theme 屬性賦了這個 CSS 類庫實際並無的主題值,CSS 的地址就不會生效,組件就會很難看。解決這個問題須要寫的代碼(和它的測試用例!),我打算交給你本身來完成。

跑一下測試,哎呀,並無所有經過。由於 Firefox 不支持 Shadow DOM,在 Firefox 裏跑的測試失敗了。咱們已經用上了 shadydom polyfill,但它並不支持 CSS 封裝,有另外一個叫 shadycss 的 polyfill 能解決這個問題。跟上面同樣,以後你本身完成。

index.html 裏,給 mini-form 標籤增長一個 theme 屬性。而後你就能在瀏覽器裏看到你的藝術創做了。

處理事件

組件已經很好看了,但還什麼都幹不了。咱們要乾的最後一件事情,是給它加上事件處理的邏輯。當用戶點擊「Submit」按鈕的時候,得發生點什麼事情。代碼要獲取輸入,顯示一個成功或失敗(若是輸入爲空)的提示。當用戶接着聚焦進輸入框的時候,錯誤信息須要消失掉。

給這些事件邏輯寫上測試。

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);
});
複製代碼

在組件代碼裏,給用戶會與之發生交互的兩個元素:輸入框和按鈕綁定事件監聽器。

當用戶聚焦進輸入框,咱們但願清空可能在顯示的任何錯誤提示。首先,在模板裏新增一個錯誤提示,而且建立一個帶有 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> 複製代碼

給輸入框綁定一個事件監聽器,處理它的聚焦事件。

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';
}
複製代碼

上面的代碼給輸入框元素建立了一個 getter、一個在 connectedCallback 裏調用的綁定聚焦事件監聽的方法、還有一個在事件監聽中用來隱藏錯誤提示的方法。

接着,給按鈕增長點擊事件的事件監聽和處理點擊的邏輯。

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 complicated
  return `
    <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 here this.displaySuccessMessage(); } displaySuccessMessage() { this.card.innerHTML = this.message; } displayErrorMessage() { this.errorMessage.className = 'error'; } 複製代碼

跑一遍測試,看它們是否是全都經過!也有可能只是大部分經過:在 Firefox 裏,樣式的測試用例依然會失敗。恭喜,你有一個能工做的 web component 了!

所有的代碼在這裏

還能夠作不少不少事情來完善和擴展這個組件。除了我早就提到過的,你還能夠給頭部標題的文本、圖標加上 slot,或者美化、保存用戶的輸入內容。

以爲還不夠的話,能夠寫一個你本身的組件,在 Twitter 上私信給我。祝編程愉快!

相關連接

有任何問題或想法,均可以在 twitter @bendyworks 或者 Facebook 上聯繫咱們。

若是發現譯文存在錯誤或其餘須要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可得到相應獎勵積分。文章開頭的 本文永久連接 即爲本文在 GitHub 上的 MarkDown 連接。


掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源爲 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章
相關標籤/搜索