html5 web component introduction

在软件开发中使用组件,通常能减少重复造轮子的开发,提升开发效率。
使用组件的成本一般很低,无非是一个import,一个实例化。

在web前端,组件却有较高的使用成本,以至于有时候会选择不用组件,而是重新发开。前端组件主要的问题包括:

  1. 众多外部资源的依赖,可以有js库依赖,dom依赖,css样式依赖,图片依赖等。

以bootstrap为例,要使用bootstrap的功能必须有以下几个步骤:

a. 插入样式

<link rel="stylesheet" href="css/bootstrap.min.css">

b. 插入依赖js

<script src="js/jquery-1.9.1.js"></script>
<script src="js/bootstrap.js"></script>
  1. 封装并不严格。例如,外部脚本还是可以修改组件内部结构,外部样式可以影响组件的样式。

随着基于HTML5的webapp的发展,前端的组件也被提升到了新的高度。为了解决前端组件存在的诸多问题,W3C提出了web component提案,提案包括了以下四个部分:

  • HTML template: 定义了<template>标签,方便基于DOM的模板操作。
  • Custom Element: 使开发者可以自己定义并扩展html标签。
  • Shadown DOM: 在一个DOM节点内部创建一个黑盒子,使内部样式和DOM和外部环境隔离。
  • Html Import: 打包组件,并通过<link>标签导入组件。

下面分别阐述下各部分的功能。

写在前面

  • web component标准仍然在开发中,当你看到这个的时候,可能代码已经坏掉了
  • chrome是唯一实现了web component的浏览器,并且需要手动打开chrome对web component的支持。

方法是访问chrome://flags,打开如下的开关。

.. figure:: /uploads/enable_web_component.png

打开chrome对web component的支持

.. figure:: /uploads/enable_html_import.png

html import也开了吧,后面要用

Html Template

模板在前端已经用的比较多了,常见的有John Resig的 micro template <http://ejohn.org/blog/javascript-micro-templating/>, handlerbars <http://handlebarsjs.com/> 等。这些都是基于字符串的模板,要通过innerHTML转变为DOM节点。

它的缺陷是:

  1. 字符串的模板容易被XSS攻击。
  2. 每次innerHTML,浏览器都要通过parse,转换为DOM树,浪费计算,没有clone节点高效。

web component标准提供了 <template> 标签,通过DOM来定义模板。 使用时只需把以前的html标签包裹在 <template> 内即可。 <template> 内部的元素不渲染,不拉取,不执行。通过该节点的content属性,可以获取到内部元素的文档碎片。使用时,克隆该节点即可。


<template id="tmpl">
  <h1></h1>
  <p></p>
</template>
<script type="text/javascript">
  var $frag = document.getElementById('tmpl').content.cloneNode(true);
  $frag.querySelector('h1').textContent = 'hi';
  $frag.querySelector('p').textContent = 'This is nice';
  document.body.appendChild($frag);
</script>

Custom Element

讲自定义标签,我们先从HTML控件说起吧。

举个例子, html里面有个<select> 标签。select标签有一些用起来很爽的地方,比如可以通过disabled禁用下拉菜单项,可以通过optgroup标签将下拉菜单分组。稍微修改一下markup就会得到完全不同的行为,不写一行js。还有,它在IOS上是个滚轮,在android上是个面板。开发者不用关心任何其他的事情。

类似的组件在微软的WPF XAML,adobe Flex,QT的QML等技术中大行其道。为什么web开发中,不能用js实现自己的标签呢,而非要堆放div呢?

在没有web component提案之前,angularjs提供自定义标签的能力,但是方式不同。angularjs使用模板把自定义标签替换为了div等html标签。下面是之前做的一个angularjs组件accordion,你们感受下。


<accordion>
  <pane title="Latest News">
    Lorem ipsum dolor sit amet, consectetur adipiscing elit.
  </pane>
  <pane title="oh my god">
    Phasellus et velit tellus...
  </pane>
</accordion>

实现代码看这里 http://jsbin.com/xexot/1

自定义元素赋予开发者开发自定义标签的能力。具体方法如下:

使用 document.register 定义一个自定义标签,定义之后就可以像正常标签一样使用。


var XFoo = document.register('x-foo', {
  prototype: {
    // 这里的方法会出现在标签的原型上面
    // 这个参数空非必填
  }
});

.. note:: 自定义标签名字一定要包含一个 -符号哦

实例化有三种方法:

new XFoo();
// or
document.createElement('x-foo');
<x-foo></x-foo>

另外,自定义标签提供了几个回调方法,在生命周期中被调用,例如当加到dom上,从dom取下。

  • createdCallback,创建后调用
  • enteredViewCallback,插入document调用
  • leftViewCallback,移出document调用
  • attributeChangedCallback,属性更改调用

更多关于技巧详见参考:

  • 扩展原生标签
  • Element upgrade
  • :unresolved选择器

Shadow DOM

Shadow DOM给予了web组件封装内部实现的方法。虽然之前的代码可以通过一些js的设计模式来实现来实现较好的API层的封装,但这里的封装主要指的是DOM和CSS层的封装,比如操作组件内部节点,使用全局样式影响组件内部的样式。

HTMLElement.createShadowRoot() 方法是创建shadow root节点的方法。
创建了shadow root后,原来节点内部的DOM将不再展示,而是展示shadow root上的节点。shadow root内部的DOM结构,样式独立了。

<button>Hello</button>
<script type="text/javascript">
  var host = document.querySelector('button');
  var root = host.createShadowRoot();
  root.textContent = '<span>Awesome</span>';
  // host.shadowRoot === root
</script>

.. note::

需要注意的是

- 在外部文档,使用 `span{color:red;}` 是不能给该节点样式的。
- 在外部文档,试图querySelector是取不到节点的。
- 访问内部元素必须要通过host的 `shadowRoot` 属性。

更多关于技巧详见参考:

  • insertion point: 标签
  • :host 选择器
  • ^ ^^ combinator
  • resetStyleInheritance 属性
  • multiple shadow root
  • use css variable to cross shadow boundry
  • ::content pseudo element
  • shadow insertion point: 标签
  • Element.getDistributedNodes()
  • Element.getDestinationInsertionPoints()
  • Event model in shadow root

Html Import

html import使<link>标签可以加载html文档,并同时加载html内的所有内容包括js, css, DOM,

<link rel="import" href="/path/to/import/file.html">

使用html import最大的好处就是现在可以一步导入多个资源了。像bootstrap这样的库,就可以一步导入。依赖多个资源的组件也可以一次打包。

例如,导入bootstrap就可以如下:

<link rel="import" href="bootstrap.html">

bootstrap.html

<link rel="stylesheet" href="css/bootstrap.min.css">
<script src="js/jquery-1.9.1.js"></script>
<script src="js/bootstrap.js"></script>

.. note::

几个注意点是:

- 导入的html文件,不用很规范,没有head body都是可以的。可以是一堆script标签,也可以是内联了js, css的html。
- import文档内的js在import时执行,js的作用域是同一个window对象
- import文档内的js异步加载,并顺序执行。
- import文档内的js仅执行一遍,有些像python的import语句
- import文档内的dom和css,对主文档没有作用,除非手动插入::

    // 获取主文档
    document
    // 获取import文档
    document.currentScript.ownerDocument

- html import遵循同源策略,需要CORS实现跨域。本地开发需要本地server。

示例

下面,我们通过学到的组件技术来制作实例 --- 一个钟,使用的时候只需要 <o-clock></o-clock> 就行了。

.. figure:: /uploads/clock.png

clock组件

使用template实现模版

首先,我们写出DOM结构。如下。这里我用template来做,把结构包在 <template> 标签里面就好了。

<template id="tmpl">
  <div class="clock">
    <div>
      <span class="hours"></span>:<span class="minutes"></span><span class="seconds"></span>
    </div>
    <div class="date"></div>
  </div>
</template>

使用document.register实现自定义元素

我们的元素需要继承 HTMLElement 的原型,因为我们需要这个节点上面的方法 appencChild, innerHTML

var proto = Object.create(HTMLElement.prototype);

但这个原型我们需要扩展一下。

首先是初始化,这个在createdCallback里面做,我们把内部的几个dom节点存下来,避免多次重复取值

proto.createdCallback = function (){
  var $tmpl = document.getElementById('tmpl');
  this.appendChild($tmpl.content.cloneNode(true));

  this.$hours = this.querySelector('.hours');
  this.$minutes = this.querySelector('.minutes');
  this.$seconds = this.querySelector('.seconds');
  this.$date = this.querySelector('.date');
};

加上update方法,调用时候刷新钟表的展示时间

proto.update = function (){
  var time = new Date();

  this.$hours.textContent = padZero(time.getHours());
  this.$minutes.textContent = padZero(time.getMinutes());
  this.$seconds.textContent = padZero(time.getSeconds());
  this.$date.textContent = padZero(time.getSeconds());
  this.$date.textContent = time.toDateString();
};

当clock节点加到DOM上的时候,我们启动定时器每标刷新,这个在enteredViewCallback里面做


proto.enteredViewCallback = function init(){
  this.update();
  var self = this;
  this._timer = setInterval(function (){
 	 self.update();
  }, 1000);
};

从节点移除的时候,需要取消定时器

proto.leftViewCallback = function (){
  clearInterval(this._timer);
};

最后使用该原型注册新元素

document.register('o-clock', {
  prototype: proto
});

使用shadow DOM隐藏内部实现

这时候我们的组件已经可以用了。但是由于不是shadow DOM外部的样式,脚本仍然可以轻易干扰组件的工作。我们把它变成一个shadow DOM。这里需要修改初始化方法。
之前是 this.appendChild 现在,创建shadow root后需要 root.appendChild

proto.createdCallback = function (){
  var $tmpl = document.getElementById('tmpl');
  var root = this.createShadowRoot();
  root.appendChild($tmpl.content.cloneNode(true));

  this.$hours = root.querySelector('.hours');
  ...
};

这样修改后的DOM查看器中可以看到结构中多了一个document fragment

.. image:: /uploads/shadow_dom.png

对比非shadow DOM的结构。

.. image:: /uploads/non_shadow_dom.png

使用html import打包组件

最后,试试html import打包吧。

需要注意的是,被导入文档中document引用的问题。我们的组件初始化的时候,用到了 document.getElementById('tmpl') 来获取文档中的template节点。但是在html import的时候,这个 document 指的是声明导入的文档的document对象。这里必须使用 document.currentScript.ownerDocument.getElementById('tmpl') 来指明是被导入的文档的document。

修改这个以后,我们的clock组件就可以用了。

<!DOCTYPE html>
<html>
<head>
  <link rel="import" href="shadow-DOM.html">
</head>
<body>
  <o-clock></o-clock>
</body>
</html>

这样的前端组件,是不是Simple and beautiful~~

参考