[关闭]
@Wahson 2018-03-09T11:09:42.000000Z 字数 12723 阅读 1443

Web Components,了解一下?

前端技术


1. Why Web Components?

1.1 曾经的困惑

  1. 在网页开发中,同一段html时常会多次出现在同一页面甚至多个页面中,这种复制粘贴的方式,到处散落的代码会导致日后难以维护。
  2. 开发的组件样式非常容易受到外部的污染。
  3. 时常的id/名称冲突。

Web Components是多个新W3C标准(HTML TemplatesCustom ElementsShadow DOMHTML Imports)的集合。他们让我们轻易地定义界面组件(元素),这些组件管理着自己的结构(HTML)和样式(CSS),并且确保这些组件外部的样式不会轻易穿透进来(新的标准依然提供外部修改内部样式的方式)。另外组件一旦定义了,就可以像使用原生HTML元素一样使用组件。

2. HTML Templates

新标准提供了一个新的标签 <template>,自定义的组件结构(HTML代码)以及内联样式(<style>)需要放在它内部。

一个简短的例子:

  1. <template>
  2. <style>
  3. :host {
  4. display: block;
  5. }
  6. div {
  7. border: 1px solid seagreen;
  8. }
  9. </style>
  10. <div>test</div>
  11. </template>

template 标签提供了 content 属性用以返回标签的所有子项。

  1. const tmpl = document.querySelector('template');
  2. const content = tmpl.content;

2.1 浏览器兼容性

此处输入图片的描述
Can I Use ?

在4个新的标准中,Templates 是被浏览器支持的最好的一个。

3. Shadow DOM

Shadow DOM标准提供了组件外部与内部结构与样式隔离的解决方案。组件的DOM是独立的,外部不能通过document.querySelector()返回组件shadow DOM中的节点,并且内部的样式规则不会泄露出去,外部的样式也不会渗入。
这跟 iframe 极其相似,他们都是一个独立的沙箱,iframe有自己的 windowdocument 对象,而shadow DOM并没有window,但它有一个叫 document fragment 的轻量级document。
因此,id/类名称命名冲突的问题也不需要担心了。

3.1 概念

3.1.1 Shadow Host

影子宿主,即shadow DOM依附的元素。

从 DevTools 看,元素 custom-element 就是影子树的宿主。
此处输入图片的描述

3.1.2 Shadow Root

影子根,也就是shadow DOM 的 document fragment。

3.1.3 Shadow Boundary

影子边界,shadow DOM 通过这一层不可见的堡垒让内部的HTML和CSS与外部隔离。

3.2 使用

3.2.1 创建shadow DOM

  1. const shadowRoot = document.createElement('div').attachShadow({mode: 'open'});

参数 {mode: 'open'} 指创建一个开放的影子根,你可以传入 {mode: 'closed'} 创建闭合的影子根,但是并不建议这样做,闭合的影子根会导致完全封闭的组件,你甚至不能在组件内部通过 querySelector 等api 获取到内部的节点。

4.2.2 组合和slot

slot 翻译过来是插槽的意思,shadow DOM通过 <slot>,给我们提供了不同组件间组合的能力,不同的组合,我们可以灵活的产生不同效果的组件。比如开发一个自定义按钮 <my-button> , 可以组合 <img>,添加一张图片作为按钮背景。
更深入一点,slot是组件内部的占位符,组件内部可以定义多个占位符。通过slot引入的组件,称为分布式节点。

另外需要区分一下两个概念 Light DOMShadow DOM
Light DOM:元素实际的子项。
Shadow DOM:组件的内部实现,定义了组件的内部结构,样式以及实现详情。
看图找真相:
此处输入图片的描述

展开shadow-root:
此处输入图片的描述

接下来举个简单的栗子:

  1. <slot></slot>
  2. <!-- 如果没有提供 light DOM,则以默认内容显示 -->
  3. <slot>Fancy button</slot>
  4. <!-- 如果没有提供 light DOM,则以默认内容显示 -->
  5. <slot>
  6. <h2>Title</h2>
  7. <summary>Description text</summary>
  8. </slot>

3.2.3 创建命名的slot

用户可以通过名称指定Light DOM节点放置到那个插槽,如果没有指定名称,则放置到默认插槽。
看一下栗子:

  1. <template>
  2. <slot name="title"></slot>
  3. <div>---分割线---</div>
  4. <slot>default</slot>
  5. </template>
  1. <custom-element>
  2. <p>我是谁</p>
  3. <p>我在哪</p>
  4. <p>我在干嘛</p> <!-- 所有p元素被放置到默认插槽-->
  5. <div slot="title">我是标题</div> <!-- 被放置到name是title的插槽-->
  6. </custom-element>

3.2.4 使用 :host选择器 设定样式

组件可通过 :host(<selector>) 选择器对自身进行样式设定,但是要注意 用户可以从外部替换 :host 中设定的样式。一个常见的实践是,你可以在 :host 选择器中设定组件的默认宽高,颜色,布局等样式,使用时用户根据需要自行在外部修改这些默认设定。

惯例,看个例子:

  1. <style>
  2. :host {
  3. opacity: 0.4;
  4. }
  5. :host(:hover) {
  6. opacity: 1;
  7. }
  8. :host([disabled]) { /* style when host has disabled attribute. */
  9. background: grey;
  10. pointer-events: none;
  11. opacity: 0.4;
  12. }
  13. :host(.blue) {
  14. color: blue; /* color host when it has class="blue" */
  15. }
  16. :host(.pink) > #tabs {
  17. color: pink; /* color internal #tabs node when host has class="pink". */
  18. }
  19. </style>

3.2.5 :host-context(<selector>)

:host-context(<selector>)常用于主题化组件,如果组件是 selector 的后代元素,此选择器中的样式将被应用。

  1. <body class="darktheme">
  2. <div>
  3. <custom-element>
  4. ...
  5. </custom-element>
  6. </div>
  7. </body>
  1. :host-context(.darktheme) {
  2. color: white;
  3. background: black;
  4. }

4.2.6 ::slotted(<compound-selector>)

可以通过 ::slotted 选择器给分布式节点设定样式。

用法也很简单:

  1. <style>
  2. /* 当分布式节点是p元素时,应用样式 */
  3. ::slotted(p) {
  4. color: blue;
  5. }
  6. /* 当分布式节点包含title类时,应用样式 */
  7. ::slotted(.title) {
  8. color: red;
  9. }
  10. </style>
  1. <custom-element>
  2. <p>我是谁</p>
  3. <p>我在哪</p>
  4. <p>我在干嘛</p> <!-- 前3个p元素的字体颜色被设置为blue色 -->
  5. <p class="title">我是标题</p> <!-- 字体颜色被设置为red色 -->
  6. </custom-element>

敲黑板:跟 :host 选择器一样,外部样式总是优先于在 shadow DOM 中定义的样式

3.2.7 slotchange 事件

当用户从light DOM中添加或删除子项时,slotchange 事件会触发。
但是组件实例首次初始化时,也就是组件内部定义了默认的slot内容,不会触发此事件。
slot.assignedNodes() 可以查看slot标签中渲染的元素。
slot.assignedNodes({flatten: true}) 加上参数的调用,如果没有light DOM分配给此slot,则返回此 slot 在组件中定义的默认内容。

一个很微妙的例子:

  1. <slot>default</slot>
  1. <custom-element>
  2. </custom-element>

slot.assignedNodes()的返回结果会是什么?这里看起来 custom-element 并没有子项,所以理应返回结果是一个空的数组([])。可是,我们来看
此处输入图片的描述
实际上返回了一个包含一个文本节点的数组([text]),而文本节点的内容是一个回车符,并且这里还触发了一次 slotchange 事件。似乎是手误多敲的一个回车引起了这一系列困惑,但是应该要注意的是,元素子项的任何内容都会称为 slot.assignedNodes() 返回结果的一部分。

一个反向的api, element.assignedSlot 可以返回element分配到哪个 slot

3.2.8 Shadow DOM 事件模型

当事件从 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 标记创建的。
例如:

  1. this.dispatchEvent(new CustomEvent("event-name", {bubbles: true, composed: true}));

3.3 浏览器兼容性

此处输入图片的描述

此处输入图片的描述
Can I Use ?

4. Custom Elements

使用 自定义元素 的API,我们可以创建新的HTML标记、扩展现有的HTML标记,或者扩展其他开发者编写的组件。

4.1 定义新元素

核心的API是 customElements.define(name, constructor, options),通过这个全局性的API定义能让浏览器识别的自定义元素。

来看个简短的栗子:

  1. class CustomElement extends HTMLElement {...}
  2. window.customElements.define("custom-element", CustomElement);

请注意自定义元素需要扩展基础的 HTMLElement,以确保自定义的元素继承完整的DOM API。另外有几条规则:

4.2 扩展元素

4.2.1 扩展自定义元素

比较简单,直接上代码:

  1. class CustomElementChild extends CustomElement {
  2. constructor() {
  3. super(); // 构造器中请记住总是首先调用父类的构造器。
  4. ...
  5. }
  6. }
  7. window.customElements.define('custom-element-child', CustomElementChild);

与创建自定义元素的区别是这里继承的是自定义元素 CustomElement

4.2.2 扩展原生HTML元素

  1. class CustomButton extends HTMLButtonElement {
  2. constructor() {
  3. super();
  4. this.addEventListener('click', () => console.log('click'));
  5. }
  6. }
  7. window.customElements.define('custom-button', CustomButton, {extends: 'button'});

这里 CustomButton 继承的是 HTMLButtonElement 而不是 HTMLElement,因为 HTMLButtonElement 是button更直接的父类接口,提供了按钮组件相关的API。另外你应该注意到 customElements.define 的第三个参数,该参数是为了告知浏览器要扩展的标记。

4.3 自定义元素生命周期钩子

在自定义元素不同的生命周期,可以定义特殊的钩子函数,执行特定的代码。

名称 调用时机
constructor 创建或升级元素的一个实例。用于初始化状态、设置事件侦听器或创建 Shadow DOM。
connectedCallback 元素每次插入到 DOM 时都会调用。用于运行安装代码,例如获取资源或渲染。一般来说,您应将工作延迟至合适时机执行。
disconnectedCallback 元素每次从 DOM 中移除时都会调用。用于运行清理代码(例如移除事件侦听器等)。
attributeChangedCallback(attrName, oldVal, newVal) 属性添加、移除、更新或替换。解析器创建元素时,或者升级时,也会调用它来获取初始值。注:仅 observedAttributes 属性中列出的特性才会收到此回调。
adoptedCallback() 自定义元素被移入新的 document(例如,有人调用了 document.adoptNode(el))。

注:这些钩子函数的调用都是同步的,比如你的元素被插入到 DOM 上时,connectedCallback 会立即调用。

4.4 attribute(特性)和 property(属性)

平时我们写html代码的时候定义在标签上的叫做特性(attribute),比如 <input type="text" value="Name:">, 这里input标签有2个attribute。而我们知道input是一个DOM节点,本质上它是一个对象(object),自然而然 property 就是对象上的属性。

  1. div.id = 'my-id';
  2. div.hidden = true;
  1. <div id="my-id" hidden>

对于HTML原生的属性,这个过程会自动发生,但是对于用户自定义的属性,我们需要自己实现这个过程。但是,但是我们为什么要这样做?为什么非要把属性映射为特性?-- 特性可以用来声明式配置元素,无障碍功能和 CSS 选择器等某些 API 依赖于特性工作。

  1. get disabled() {
  2. return this.hasAttribute('disabled');
  3. }
  4. set disabled(val) {
  5. // Reflect the value of `disabled` as an attribute.
  6. if (val) {
  7. this.setAttribute('disabled', '');
  8. } else {
  9. this.removeAttribute('disabled');
  10. }
  11. // do something else
  12. }
  13. static get observedAttributes() {
  14. return ['disabled'];
  15. }
  16. attributeChangedCallback(name, oldValue, newValue) {
  17. if(name === "disabled" && oldVal !== newVal) {
  18. setTimeout(() => {
  19. this.disabled = newVal === '';
  20. }, 0);
  21. }
  22. }
  1. :host([disabled]) {
  2. opacity: 0.5;
  3. pointer-events: none;
  4. }

此时组件的disabled属性变更时,特性和样式都会得到响应。

4.5 元素定义内容

前面讲了这么多,我们还没有涉及到给组件编写内部结构(HTML)的方式。这一章节我们将重点介绍。

  1. window.customElements.define('custom-element', class extends HTMLElement {
  2. connectedCallback() {
  3. this.innerHTML = '<div>test</div>';
  4. }
  5. });
  1. window.customElements.define('custom-element', class extends HTML {
  2. constructor() {
  3. super();
  4. const shadowRoot = this.attachShadow({mode: 'open'});
  5. shadowRoot.innerHTML = '<div>test</div>';
  6. }
  7. });

4.6 浏览器兼容性

Can I Use ?
此处输入图片的描述

此处输入图片的描述

5. HTML Imports

HTML Imports 允许我们使用带有 rel="import" 属性的 link 标签加载HTML文件,这些HTML文件允许包含脚本(script)、样式(stylesheets),网络字体(web fonts)。

5.1 组件引入

看例子:

  1. <link rel="import" href="/path/to/some/custom-element.html">

这里 custom-element.html 由浏览器加载,并保存起来以备用户使用。你可以使用Javascript 读取文件的内容(HTML)并添加到你的页面。但是很重要的一点,如果 custom-element.html 除了HTML,还包含有CSS和Javascript代码,这些代码会在主文档(main document)上自动执行。

通过以下例子来加深理解:

  1. <!-- custom-element.html -->
  2. <template id="custom-ele-tmpl">
  3. <div>test</div>
  4. </template>
  5. <div>won't render into main document</div>
  6. <style>
  7. body {
  8. border: 1px solid blue;
  9. }
  10. </style>
  11. <script>
  12. console.log(document.querySelector('#custom-ele-tmpl')); // null
  13. console.log(document.currentScript.ownerDocument.querySelector('#custom-ele-tmpl')); // <template id="custom-ele-tmpl">...</template>
  14. </script>
  1. <!DOCTYPE html>
  2. <!-- index.html -->
  3. <html lang="en">
  4. <head>
  5. <meta charset="UTF-8">
  6. <title>WebComponent</title>
  7. <link rel="import" href="custom-element.html">
  8. </head>
  9. <body class="darktheme">
  10. <custom-element></custom-element>
  11. </body>
  12. </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的原因)。

5.2 读取 template

  1. document.currentScript.ownerDocument.querySelector('#custom-ele-tmpl');
  1. document.querySelector('link').import.querySelector('#custom-ele-tmpl')

5.3 浏览器兼容性

此处输入图片的描述
Can I Use ?

6. Hack for browsers

都看过了上面的浏览器兼容性,除了Chrome,还真没一个浏览器能把这一整套玩起来,怎么办?既然浏览器不争气我们就自己打补丁开外挂吧。
我们可以在页面初始化前先加载 Polyfill,让不支持web components特性的浏览器先安装上我们需要的api。

可以先做一个兼容性检测来决定是否需要加载polyfill:

  1. <script>
  2. if (!('customElements' in window)
  3. || !("import" in document.createElement("link"))
  4. || !HTMLElement.prototype.attachShadow) {
  5. const script = document.createElement('script');
  6. script.src = "webcomponents-lite.js";
  7. document.head.appendChild(script);
  8. }
  9. </script>

7. 4个标准的整合

  1. <!-- webcomponent.html -->
  2. <!DOCTYPE html>
  3. <html lang="en">
  4. <head>
  5. <meta charset="UTF-8">
  6. <title>WebComponent</title>
  7. <!-- 在不支持webcomponent标准的浏览器中加载polyfill -->
  8. <script>
  9. if (!('customElements' in window)
  10. || !("import" in document.createElement("link"))
  11. || !HTMLElement.prototype.attachShadow) {
  12. const script = document.createElement('script');
  13. script.src = "webcomponents-lite.js";
  14. document.head.appendChild(script);
  15. }
  16. </script>
  17. <!--HTML Import-->
  18. <link rel="import" href="custom-element.html">
  19. <style>
  20. div {
  21. border: 1px solid #2288dd;
  22. }
  23. </style>
  24. </head>
  25. <body>
  26. <custom-element></custom-element>
  27. </body>
  28. </html>
  1. <!-- custom-element.html -->
  2. <!--Template-->
  3. <template id="custom-ele-tmpl">
  4. <style>
  5. :host {
  6. all: initial; /* 将可继承样式重置为初始值 */
  7. outline: 5px solid seagreen;
  8. display: block; /* 如果没有设定,默认值是inline*/
  9. }
  10. :host([hidden]) {
  11. display: none;
  12. }
  13. :host(:hover) {
  14. outline: 1px solid seagreen;
  15. }
  16. :host([disabled]) {
  17. opacity: 0.5;
  18. pointer-events: none;
  19. }
  20. :host(.blue) {
  21. color: blue; /* color host when it has class="blue" */
  22. }
  23. /*如果 :host-context(<selector>)
  24. 或其任意父级与 <selector> 匹配,它将与组件匹配。
  25. 一个常见用途是根据组件的环境进行主题化。
  26. */
  27. :host-context(.darktheme) {
  28. color: #FFFFFF;
  29. background: #000;
  30. }
  31. ::slotted([slot=slot]) {
  32. background: saddlebrown;;
  33. }
  34. :host(:focus) {
  35. background: orchid;
  36. }
  37. ::slotted(p) {
  38. background: #2288dd;
  39. }
  40. ::slotted(.title) {
  41. background: slateblue;
  42. }
  43. </style>
  44. <slot>default</slot>
  45. </template>
  46. <script>
  47. // https://developers.google.com/web/fundamentals/web-components/customelements
  48. class CustomElement extends HTMLElement {
  49. static get is() {
  50. return "custom-element";
  51. }
  52. static get observedAttributes() {
  53. return ['test-attr'];
  54. }
  55. /**
  56. * 创建或升级元素的一个实例。用于初始化状态、设置事件侦听器或创建 Shadow DOM。
  57. * 参见规范,了解可在 constructor 中完成的操作的相关限制。
  58. */
  59. constructor() {
  60. super();
  61. console.log("created");
  62. const tmpl = document.currentScript.ownerDocument.querySelector('#custom-ele-tmpl');
  63. // Shadow DOM
  64. /** delegatesFocus: true, 将元素的焦点行为拓展到影子树内,
  65. * 如果您点击 shadow DOM 内的某个节点,且该节点不是一个可聚焦区域,那么第一个可聚焦区域将成为焦点。
  66. * 当 shadow DOM 内的节点获得焦点时,除了聚焦的元素外,:focus 还会应用到宿主。
  67. **/
  68. const shadowRoot = this.attachShadow({mode: 'open', delegatesFocus: true});
  69. // import node from other document, and append to shadowRoot
  70. shadowRoot.appendChild(document.importNode(tmpl.content, true));
  71. this.shadowRoot.querySelector("slot")
  72. .addEventListener("slotchange", console.log);
  73. }
  74. /**
  75. * 元素每次插入到 DOM 时都会调用。用于运行安装代码,例如获取资源或渲染。
  76. * 一般来说,您应将工作延迟至合适时机执行。
  77. */
  78. connectedCallback() {
  79. console.log("connectedCallback");
  80. }
  81. /**
  82. * 元素每次从 DOM 中移除时都会调用。用于运行清理代码(例如移除事件侦听器等)。
  83. */
  84. disconnectedCallback() {
  85. console.log("detachedCallback")
  86. }
  87. /**
  88. * 属性添加、移除、更新或替换。
  89. * 解析器创建元素时,或者升级时,也会调用它来获取初始值。
  90. * 注:仅 observedAttributes 属性中列出的特性才会收到此回调。
  91. */
  92. attributeChangedCallback(attrName, oldVal, newVal) {
  93. console.log(attrName, oldVal, newVal);
  94. }
  95. /**
  96. * 自定义元素被移入新的 document(例如,有人调用了 document.adoptNode(el))。
  97. */
  98. adoptedCallback() {
  99. console.log("adoptedCallback")
  100. }
  101. }
  102. window.customElements.define(CustomElement.is, CustomElement);
  103. </script>

8. Polymer

Polymer 是Google开发的Javascript开源框架。它是对Web Component标准的封装,提供了更方便易用的API,同时还扩展了模板语法,数据绑定(data-binding),样式注入(@apply)等特性。
其他的Web Components 框架还有 X-TagBosonic

9. 结语

Web Components未来会改变Web开发,但是现阶段包括浏览器兼容性,社区等还有很长的路要走。

【参考文献】

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注