@Wahson
2018-03-09T03:09:42.000000Z
字数 12723
阅读 1716
前端技术
- 在网页开发中,同一段html时常会多次出现在同一页面甚至多个页面中,这种复制粘贴的方式,到处散落的代码会导致日后难以维护。
- 开发的组件样式非常容易受到外部的污染。
- 时常的id/名称冲突。
Web Components是多个新W3C标准(
HTML Templates,Custom Elements,Shadow DOM,HTML Imports)的集合。他们让我们轻易地定义界面组件(元素),这些组件管理着自己的结构(HTML)和样式(CSS),并且确保这些组件外部的样式不会轻易穿透进来(新的标准依然提供外部修改内部样式的方式)。另外组件一旦定义了,就可以像使用原生HTML元素一样使用组件。
新标准提供了一个新的标签
<template>,自定义的组件结构(HTML代码)以及内联样式(<style>)需要放在它内部。
一个简短的例子:
<template><style>:host {display: block;}div {border: 1px solid seagreen;}</style><div>test</div></template>
template 标签提供了 content 属性用以返回标签的所有子项。
const tmpl = document.querySelector('template');const content = tmpl.content;
在4个新的标准中,
Templates是被浏览器支持的最好的一个。
Shadow DOM标准提供了组件外部与内部结构与样式隔离的解决方案。组件的DOM是独立的,外部不能通过document.querySelector()返回组件shadow DOM中的节点,并且内部的样式规则不会泄露出去,外部的样式也不会渗入。
这跟iframe极其相似,他们都是一个独立的沙箱,iframe有自己的window和document对象,而shadow DOM并没有window,但它有一个叫 document fragment 的轻量级document。
因此,id/类名称命名冲突的问题也不需要担心了。
影子宿主,即shadow DOM依附的元素。
从 DevTools 看,元素 custom-element 就是影子树的宿主。

影子根,也就是shadow DOM 的 document fragment。
影子边界,shadow DOM 通过这一层不可见的堡垒让内部的HTML和CSS与外部隔离。
const shadowRoot = document.createElement('div').attachShadow({mode: 'open'});
参数
{mode: 'open'}指创建一个开放的影子根,你可以传入{mode: 'closed'}创建闭合的影子根,但是并不建议这样做,闭合的影子根会导致完全封闭的组件,你甚至不能在组件内部通过querySelector等api 获取到内部的节点。
slot翻译过来是插槽的意思,shadow DOM通过<slot>,给我们提供了不同组件间组合的能力,不同的组合,我们可以灵活的产生不同效果的组件。比如开发一个自定义按钮<my-button>, 可以组合<img>,添加一张图片作为按钮背景。
更深入一点,slot是组件内部的占位符,组件内部可以定义多个占位符。通过slot引入的组件,称为分布式节点。另外需要区分一下两个概念
Light DOM和Shadow DOM:
Light DOM:元素实际的子项。
Shadow DOM:组件的内部实现,定义了组件的内部结构,样式以及实现详情。
看图找真相:
展开shadow-root:
接下来举个简单的栗子:
<slot></slot><!-- 如果没有提供 light DOM,则以默认内容显示 --><slot>Fancy button</slot><!-- 如果没有提供 light DOM,则以默认内容显示 --><slot><h2>Title</h2><summary>Description text</summary></slot>
用户可以通过名称指定Light DOM节点放置到那个插槽,如果没有指定名称,则放置到默认插槽。
看一下栗子:
<template><slot name="title"></slot><div>---分割线---</div><slot>default</slot></template>
<custom-element><p>我是谁</p><p>我在哪</p><p>我在干嘛</p> <!-- 所有p元素被放置到默认插槽--><div slot="title">我是标题</div> <!-- 被放置到name是title的插槽--></custom-element>
:host选择器 设定样式组件可通过
:host(<selector>)选择器对自身进行样式设定,但是要注意 用户可以从外部替换:host中设定的样式。一个常见的实践是,你可以在:host选择器中设定组件的默认宽高,颜色,布局等样式,使用时用户根据需要自行在外部修改这些默认设定。
惯例,看个例子:
<style>:host {opacity: 0.4;}:host(:hover) {opacity: 1;}:host([disabled]) { /* style when host has disabled attribute. */background: grey;pointer-events: none;opacity: 0.4;}:host(.blue) {color: blue; /* color host when it has class="blue" */}:host(.pink) > #tabs {color: pink; /* color internal #tabs node when host has class="pink". */}</style>
:host-context(<selector>)
:host-context(<selector>)常用于主题化组件,如果组件是selector的后代元素,此选择器中的样式将被应用。
<body class="darktheme"><div><custom-element>...</custom-element></div></body>
:host-context(.darktheme) {color: white;background: black;}
::slotted(<compound-selector>)可以通过
::slotted选择器给分布式节点设定样式。
用法也很简单:
<style>/* 当分布式节点是p元素时,应用样式 */::slotted(p) {color: blue;}/* 当分布式节点包含title类时,应用样式 */::slotted(.title) {color: red;}</style>
<custom-element><p>我是谁</p><p>我在哪</p><p>我在干嘛</p> <!-- 前3个p元素的字体颜色被设置为blue色 --><p class="title">我是标题</p> <!-- 字体颜色被设置为red色 --></custom-element>
敲黑板:跟
:host选择器一样,外部样式总是优先于在 shadow DOM 中定义的样式。
slotchange 事件当用户从light DOM中添加或删除子项时,
slotchange事件会触发。
但是组件实例首次初始化时,也就是组件内部定义了默认的slot内容,不会触发此事件。
slot.assignedNodes()可以查看slot标签中渲染的元素。
slot.assignedNodes({flatten: true})加上参数的调用,如果没有light DOM分配给此slot,则返回此slot在组件中定义的默认内容。
一个很微妙的例子:
<slot>default</slot>
<custom-element></custom-element>
slot.assignedNodes()的返回结果会是什么?这里看起来
custom-element并没有子项,所以理应返回结果是一个空的数组([])。可是,我们来看
![]()
实际上返回了一个包含一个文本节点的数组([text]),而文本节点的内容是一个回车符,并且这里还触发了一次slotchange事件。似乎是手误多敲的一个回车引起了这一系列困惑,但是应该要注意的是,元素子项的任何内容都会称为slot.assignedNodes()返回结果的一部分。一个反向的api,
element.assignedSlot可以返回element分配到哪个slot。
当事件从 shadow DOM 中触发时,其目标将会调整为维持 shadow DOM 提供的封装。 也就是说,事件的目标重新进行了设定,因此这些事件看起来像是来自组件,而不是来自 shadow DOM 中的内部元素。但是要注意事件不会从shadow DOM中传播出去,除了以下事件:
- 聚焦事件:blur、focus、focusin、focusout
- 鼠标事件:click、dblclick、mousedown、mouseenter、mousemove,等等
- 滚轮事件:wheel
- 输入事件:beforeinput、input
- 键盘事件:keydown、keyup
- 组合事件:compositionstart、compositionupdate、compositionend
- 拖放事件:dragstart、drag、dragend、drop,等等
组件内部自定义的事件一般不会穿透到影子边界以外,除非事件是通过 composed: true 标记创建的。
例如:
this.dispatchEvent(new CustomEvent("event-name", {bubbles: true, composed: true}));

使用
自定义元素的API,我们可以创建新的HTML标记、扩展现有的HTML标记,或者扩展其他开发者编写的组件。
核心的API是
customElements.define(name, constructor, options),通过这个全局性的API定义能让浏览器识别的自定义元素。
来看个简短的栗子:
class CustomElement extends HTMLElement {...}window.customElements.define("custom-element", CustomElement);
请注意自定义元素需要扩展基础的 HTMLElement,以确保自定义的元素继承完整的DOM API。另外有几条规则:
<x-tags>、<x-elem> 等。<custom-element/> 这样的写法,浏览器会把斜杠 / 去掉,当做一个新的标签的开始,直到遇到父级元素的闭合,然后自动补全结束标签。这可能导致意料之外的结果,如你本来想创建一个空的标签,但由于浏览器的机制,导致兄弟元素被该标签包裹起来了。比较简单,直接上代码:
class CustomElementChild extends CustomElement {constructor() {super(); // 构造器中请记住总是首先调用父类的构造器。...}}window.customElements.define('custom-element-child', CustomElementChild);
与创建自定义元素的区别是这里继承的是自定义元素
CustomElement。
class CustomButton extends HTMLButtonElement {constructor() {super();this.addEventListener('click', () => console.log('click'));}}window.customElements.define('custom-button', CustomButton, {extends: 'button'});
这里
CustomButton继承的是HTMLButtonElement而不是HTMLElement,因为HTMLButtonElement是button更直接的父类接口,提供了按钮组件相关的API。另外你应该注意到customElements.define的第三个参数,该参数是为了告知浏览器要扩展的标记。
在自定义元素不同的生命周期,可以定义特殊的钩子函数,执行特定的代码。
| 名称 | 调用时机 |
|---|---|
| constructor | 创建或升级元素的一个实例。用于初始化状态、设置事件侦听器或创建 Shadow DOM。 |
| connectedCallback | 元素每次插入到 DOM 时都会调用。用于运行安装代码,例如获取资源或渲染。一般来说,您应将工作延迟至合适时机执行。 |
| disconnectedCallback | 元素每次从 DOM 中移除时都会调用。用于运行清理代码(例如移除事件侦听器等)。 |
| attributeChangedCallback(attrName, oldVal, newVal) | 属性添加、移除、更新或替换。解析器创建元素时,或者升级时,也会调用它来获取初始值。注:仅 observedAttributes 属性中列出的特性才会收到此回调。 |
| adoptedCallback() | 自定义元素被移入新的 document(例如,有人调用了 document.adoptNode(el))。 |
注:这些钩子函数的调用都是同步的,比如你的元素被插入到 DOM 上时,connectedCallback 会立即调用。
平时我们写html代码的时候定义在标签上的叫做特性(attribute),比如
<input type="text" value="Name:">, 这里input标签有2个attribute。而我们知道input是一个DOM节点,本质上它是一个对象(object),自然而然 property 就是对象上的属性。
div.id = 'my-id';div.hidden = true;
<div id="my-id" hidden>
对于HTML原生的属性,这个过程会自动发生,但是对于用户自定义的属性,我们需要自己实现这个过程。但是,但是我们为什么要这样做?为什么非要把属性映射为特性?-- 特性可以用来声明式配置元素,无障碍功能和 CSS 选择器等某些 API 依赖于特性工作。
get disabled() {return this.hasAttribute('disabled');}set disabled(val) {// Reflect the value of `disabled` as an attribute.if (val) {this.setAttribute('disabled', '');} else {this.removeAttribute('disabled');}// do something else}static get observedAttributes() {return ['disabled'];}attributeChangedCallback(name, oldValue, newValue) {if(name === "disabled" && oldVal !== newVal) {setTimeout(() => {this.disabled = newVal === '';}, 0);}}
:host([disabled]) {opacity: 0.5;pointer-events: none;}
此时组件的disabled属性变更时,特性和样式都会得到响应。
前面讲了这么多,我们还没有涉及到给组件编写内部结构(HTML)的方式。这一章节我们将重点介绍。
innerHTML 的内容被添加到元素的 Light DOM,而且添加的内容会覆盖元素的子项。
window.customElements.define('custom-element', class extends HTMLElement {connectedCallback() {this.innerHTML = '<div>test</div>';}});
Shadow DOM 。接下来是一个结合shadow DOM的例子:
window.customElements.define('custom-element', class extends HTML {constructor() {super();const shadowRoot = this.attachShadow({mode: 'open'});shadowRoot.innerHTML = '<div>test</div>';}});

HTML Imports允许我们使用带有rel="import"属性的link标签加载HTML文件,这些HTML文件允许包含脚本(script)、样式(stylesheets),网络字体(web fonts)。
看例子:
<link rel="import" href="/path/to/some/custom-element.html">
这里
custom-element.html由浏览器加载,并保存起来以备用户使用。你可以使用Javascript 读取文件的内容(HTML)并添加到你的页面。但是很重要的一点,如果custom-element.html除了HTML,还包含有CSS和Javascript代码,这些代码会在主文档(main document)上自动执行。
通过以下例子来加深理解:
<!-- custom-element.html --><template id="custom-ele-tmpl"><div>test</div></template><div>won't render into main document</div><style>body {border: 1px solid blue;}</style><script>console.log(document.querySelector('#custom-ele-tmpl')); // nullconsole.log(document.currentScript.ownerDocument.querySelector('#custom-ele-tmpl')); // <template id="custom-ele-tmpl">...</template></script>
<!DOCTYPE html><!-- index.html --><html lang="en"><head><meta charset="UTF-8"><title>WebComponent</title><link rel="import" href="custom-element.html"></head><body class="darktheme"><custom-element></custom-element></body></html>
在
index.html页面<link rel="import" href="custom-element.html">这一行代码会导致浏览器加载custom-element.html页面,custom-element.html页面中<style>和<script>标签中的代码会被执行(index.html页面中的body标签添加了蓝色的边框,并且浏览器控制台有console.log的日志输出),但是其他的html元素并不会渲染到index.html页面,而是被保存起来,等待用户使用(这也是console.log(document.querySelector('#custom-ele-tmpl'))输出null的原因)。
templatecustom-element.html 文件内部读取模板元素:
document.currentScript.ownerDocument.querySelector('#custom-ele-tmpl');
custom-element.html 文件外部读取模板元素:
document.querySelector('link').import.querySelector('#custom-ele-tmpl')
都看过了上面的浏览器兼容性,除了Chrome,还真没一个浏览器能把这一整套玩起来,怎么办?既然浏览器不争气我们就自己打补丁开外挂吧。
我们可以在页面初始化前先加载 Polyfill,让不支持web components特性的浏览器先安装上我们需要的api。
可以先做一个兼容性检测来决定是否需要加载polyfill:
<script>if (!('customElements' in window)|| !("import" in document.createElement("link"))|| !HTMLElement.prototype.attachShadow) {const script = document.createElement('script');script.src = "webcomponents-lite.js";document.head.appendChild(script);}</script>
<!-- webcomponent.html --><!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>WebComponent</title><!-- 在不支持webcomponent标准的浏览器中加载polyfill --><script>if (!('customElements' in window)|| !("import" in document.createElement("link"))|| !HTMLElement.prototype.attachShadow) {const script = document.createElement('script');script.src = "webcomponents-lite.js";document.head.appendChild(script);}</script><!--HTML Import--><link rel="import" href="custom-element.html"><style>div {border: 1px solid #2288dd;}</style></head><body><custom-element></custom-element></body></html>
<!-- custom-element.html --><!--Template--><template id="custom-ele-tmpl"><style>:host {all: initial; /* 将可继承样式重置为初始值 */outline: 5px solid seagreen;display: block; /* 如果没有设定,默认值是inline*/}:host([hidden]) {display: none;}:host(:hover) {outline: 1px solid seagreen;}:host([disabled]) {opacity: 0.5;pointer-events: none;}:host(.blue) {color: blue; /* color host when it has class="blue" */}/*如果 :host-context(<selector>)或其任意父级与 <selector> 匹配,它将与组件匹配。一个常见用途是根据组件的环境进行主题化。*/:host-context(.darktheme) {color: #FFFFFF;background: #000;}::slotted([slot=slot]) {background: saddlebrown;;}:host(:focus) {background: orchid;}::slotted(p) {background: #2288dd;}::slotted(.title) {background: slateblue;}</style><slot>default</slot></template><script>// https://developers.google.com/web/fundamentals/web-components/customelementsclass CustomElement extends HTMLElement {static get is() {return "custom-element";}static get observedAttributes() {return ['test-attr'];}/*** 创建或升级元素的一个实例。用于初始化状态、设置事件侦听器或创建 Shadow DOM。* 参见规范,了解可在 constructor 中完成的操作的相关限制。*/constructor() {super();console.log("created");const tmpl = document.currentScript.ownerDocument.querySelector('#custom-ele-tmpl');// Shadow DOM/** delegatesFocus: true, 将元素的焦点行为拓展到影子树内,* 如果您点击 shadow DOM 内的某个节点,且该节点不是一个可聚焦区域,那么第一个可聚焦区域将成为焦点。* 当 shadow DOM 内的节点获得焦点时,除了聚焦的元素外,:focus 还会应用到宿主。**/const shadowRoot = this.attachShadow({mode: 'open', delegatesFocus: true});// import node from other document, and append to shadowRootshadowRoot.appendChild(document.importNode(tmpl.content, true));this.shadowRoot.querySelector("slot").addEventListener("slotchange", console.log);}/*** 元素每次插入到 DOM 时都会调用。用于运行安装代码,例如获取资源或渲染。* 一般来说,您应将工作延迟至合适时机执行。*/connectedCallback() {console.log("connectedCallback");}/*** 元素每次从 DOM 中移除时都会调用。用于运行清理代码(例如移除事件侦听器等)。*/disconnectedCallback() {console.log("detachedCallback")}/*** 属性添加、移除、更新或替换。* 解析器创建元素时,或者升级时,也会调用它来获取初始值。* 注:仅 observedAttributes 属性中列出的特性才会收到此回调。*/attributeChangedCallback(attrName, oldVal, newVal) {console.log(attrName, oldVal, newVal);}/*** 自定义元素被移入新的 document(例如,有人调用了 document.adoptNode(el))。*/adoptedCallback() {console.log("adoptedCallback")}}window.customElements.define(CustomElement.is, CustomElement);</script>
Polymer 是Google开发的Javascript开源框架。它是对Web Component标准的封装,提供了更方便易用的API,同时还扩展了模板语法,数据绑定(data-binding),样式注入(@apply)等特性。
其他的Web Components 框架还有 X-Tag,Bosonic。
Web Components未来会改变Web开发,但是现阶段包括浏览器兼容性,社区等还有很长的路要走。
【参考文献】