在软件开发中使用组件,通常能减少重复造轮子的开发,提升开发效率。
使用组件的成本一般很低,无非是一个import,一个实例化。
在web前端,组件却有较高的使用成本,以至于有时候会选择不用组件,而是重新发开。前端组件主要的问题包括:
- 众多外部资源的依赖,可以有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>
- 封装并不严格。例如,外部脚本还是可以修改组件内部结构,外部样式可以影响组件的样式。
随着基于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节点。
它的缺陷是:
- 字符串的模板容易被XSS攻击。
- 每次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~~
参考
- http://w3c.github.io/webcomponents/explainer/
- http://www.html5rocks.com/en/tutorials/webcomponents/template/
- http://www.html5rocks.com/en/tutorials/webcomponents/customelements/
- http://www.html5rocks.com/en/tutorials/webcomponents/shadowdom/
- http://www.html5rocks.com/en/tutorials/webcomponents/shadowdom-201/
- http://www.html5rocks.com/en/tutorials/webcomponents/shadowdom-301/
- http://www.html5rocks.com/en/tutorials/webcomponents/imports/