[译] 用 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前端后端区块链产品设计人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划官方微博知乎专栏

相关文章
相关标签/搜索