现在的前端开发基本离不开 React、Vue 这两个框架的支撑,而这两个框架下面又衍生出了许多定义组件库:
这些组件库的出现,让我们可以直接使用已经封装好的组件,而且在开源社区的帮助下,出现了很多的模板项目( vue-element-admin、Ant Design Pro ),能让我们快速的开始一个项目。
虽然 React、Vue 为我们的组件开发提供了便利,但是这两者在组件的开发思路上,一个是自创的 JSX 语法,一个是特有的单文件模板的语法,两者的目标都是想提供一种组件的封装方法。毕竟都有其原创的东西在里面,和我们刚开始接触的 Web 基础的 HTML、CSS、JS 的方式还是有些出入的。今天介绍的就是,通过 HTML、CSS、JS 的方式来实现自定义的组件,也是目前浏览器原生提供的方案:Web Components。
什么是 Web Components?
Web Components 本身不是一个单独的规范,而是由一组DOM API 和 HTML 规范所组成,用于创建可复用的自定义名字的 HTML 标签,并且可以直接在你的 Web 应用中使用。
代码的复用一直都是我们追求的目标,在 JS 中可复用的代码我们可以封装成一个函数,但是对于复杂的HTML(包括相关的样式及交互逻辑),我们一直都没有比较好的办法来进行复用。要么借助后端的模板引擎,要么借助已有框架对 DOM API 的二次封装,而 Web Components 的出现就是为了补足浏览器在这方面的能力。
流行趋势
- Twitter:嵌入式推文
- YouTube:该站点是使用 Web Components 构建的
- Electronic Arts:该站点也是使用 Web Components 构建的
- Adobe Spectrum:该站点是一个基于 Web Components 的 UI 框架产品
另外还有维基百科、可口可乐、麦当劳、IBM 和 通用电气 等也使用基于Web 组件的技术和框架。
技术优点
-
原生支持
原生支持意味着可以不需要任何框架即可完成开发,同时也意味着这将有更好的用户体验,更低的网络请求,以及更稳定的迭代前景。并且我们一直都有在使用这项技术,比如 input, video,select 等等,其实他们都是标准的原生组件,只是如今我们自己也可以使用这项技术去创造这些组件。 -
无排他性
这是原生支持的一个延伸表现,作为浏览器原生支持也就意味着它可以在任何环境中使用,例如在 React,Angular,Vue 中使用他们。同时也意味着对 Web Components 的支持无需大刀阔斧的颠覆现有逻辑体系,你可以从局部开始进行改造。 -
无依赖性
这一点同样是原生支持的一个延伸表现,通过提供连接特定 Web 组件的选项,而无需将框架的依赖项导入到项目中,您就可以拥有优于流行框架的优势。
让我们想象一个场景:如果您喜欢用 React 创建的小部件,并且想将其包含在您的项目中,则必须首先包含整个 React 库,然后才能导入您喜欢的小部件。相反,如果您选择使用 Web 组件创建的小部件,您可以立即将其插入到项目中——这一切都归功于这项技术的原生特性。
如何使用 Web Components?
Web Components 中包含的几个规范,都已在 W3C 和 HTML 标准中进行了规范化,主要由三部分组成:
- Custom elements(自定义元素):一组 JavaScript API,用来创建自定义的 HTML标签,并允许标签创建或销毁时进行一些操作;
- Shadow DOM(影子DOM):一组 JavaScript API,用于将创建的 DOM Tree 插入到现有的元素中,且 DOM Tree 不能被外部修改,不用担心元素被其他地方影响;
-
HTML templates(HTML模板):通过
<template>
、<slot>
直接在 HTML 文件中编写模板,然后通过 DOM API 获取。
shadow DOM
Web 组件的一个重要方面是封装——能够将标记结构、样式和行为隐藏起来并与页面上的其他代码分开,这样不同的部分就不会发生冲突,并且代码可以保持整洁。Shadow DOM API 是其中的关键部分,它提供了一种将隐藏的分离 DOM 附加到元素的方法。
性能优势-组件隔离(Shadow Dom)
Shadow DOM 为自定义的组件提供了包括 CSS、事件的有效隔离,不再担心不同的组件之间的样式、事件污染了。这相当于为自定义组件提供了一个天然有效的保护伞。
Shadow DOM 实际上是一个独立的子 DOM Tree,通过有限的接口和外部发生作用。我们都知道页面中的 DOM 节点数越多,运行时性能将会越差,这是因为 DOM 节点的相互作用会时常在触发重绘(Repaint)和重排(reflow)时会关联计算大量 Frame 关系。
- Shadow host:Shadow DOM 附加到的常规 DOM 节点。
- Shadow tree:Shadow DOM 内部的 DOM 树。
- Shadow boundary:Shadow DOM 结束的地方,普通 DOM 开始的地方。
-
Shadow root:Shadow tree的根节点。
你可以以与非影子节点完全相同的方式影响影子 DOM 中的节点——例如,附加子项或设置属性、使用 element.style.foo 为单个节点设置样式,或在 <style> 内为整个影子 DOM 树添加样式元素。不同之处在于 shadow DOM 内部的任何代码都不会影响其外部的任何内容,从而可以方便地进行封装。
请注意,shadow DOM 无论如何都不是什么新鲜事物——浏览器已经使用它来封装元素的内部结构很长时间了。以一个 <video> 元素为例,它暴露了默认的浏览器控件。你在 DOM 中看到的只是 <video> 元素,但它的 shadow DOM 中包含一系列按钮和其他控件。shadow DOM 规范已经允许你实际操作你自己的自定义元素的 shadow DOM。
代码演示:
<!DOCTYPE html>
<html>
<body>
<div class="shadow-host"></div>
<!-- js部分-->
<script>
const shadowHost = document.querySelector('.shadow-host');
const shadowRoot = shadowHost.attachShadow({mode: 'closed'})
</script>
</body>
</html>
在浏览器中打开此文档,可以看到div.shadow-host元素下多出了一个#shadow-root(closed)
这里的closed来源于创建 Shadow DOM 时的 {mode: 'closed'} 选项, mode 可选值如下:
closed: shadowRoot元素不可以由外部js访问到, Element.shadowRoot为 null
open: shadowRoot元素可以由外部js访问到,即通过 Element.shadowRoot属性可以访问到此节点
此时 shadow-root 仍然是一个空节点,需要往其中添加一些节点
<!DOCTYPE html>
<html>
<body>
<div class="shadow-host"></div>
<!-- js部分-->
<script>
const shadowHost = document.querySelector('.shadow-host');
const shadowRoot = shadowHost.attachShadow({mode: 'closed'})
const shadowDiv = document.createElement('div');
shadowDiv.classList.add('shadow-div');
shadowDiv.innerText = 'i am shadow div'
shadowRoot.appendChild(shadowDiv)
</script>
</body>
</html>
注意:当给一个普通元素附加Shadow DOM后,其原本所包含的所有普通DOM元素都会失效,一个普通元素下不会同时存在Shadow DOM 和 普通 DOM。虽然仍然可以使用js获取到这部分普通元素,但是浏览器不会将其渲染到页面中。
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<div class="shadow-host"><span class="origin-span">我是普通DOM元素</span></div>
<button style="margin-top: 50px;">添加Shadow DOM</button>
<!-- js部分-->
<script>
const btn = document.querySelector('button');
btn.onclick = attachShadow;
function attachShadow() {
const shadowHost = document.querySelector('.shadow-host');
const shadowRoot = shadowHost.attachShadow({ mode: 'closed' });
const shadowDiv = document.createElement('div');
shadowDiv.classList.add('shadow-div');
shadowDiv.innerText = '我是 shadow div';
shadowRoot.appendChild(shadowDiv);
console.log('已附加Shadow DOM')
// 获取原本存在的普通DOM元素
console.log('shadowRoot下的普通DOM > origin-span: ', document.querySelector('.origin-span'))
}
</script>
</body>
</html>
Shadow DOM的隔离特性
上面说到,Shadow DOM 内部的元素始终不会影响到它外部的元素(除了 :focus-within
)。接下来就来探究Shadow DOM中的元素与外部普通DOM元素究竟有何关联。
focus-within
先说一下focus-within
:是一个CSS 伪类 ,表示一个元素获得焦点,或,该元素的后代元素获得焦点。也就是说即便是Shadow DOM
,当其获得焦点时,也会使得外部的父级DOM元素上的focus-within
生效。下面用代码验证一下:
<!doctype html>
<html lang="en">
<html>
<head>
<meta http-equiv="X-UA-Compatiple" content="IE=edge">
<title>test</title>
<style>
.shadow-host-parent:focus-within {
padding: 10px;
background-color: cyan;
}
.shadow-host {
padding: 5px;
border: 1px dashed black;
}
.shadow-host:focus-within {
background-color: cornflowerblue;
}
</style>
</head>
<body>
<div class="shadow-host-parent">
<div class="shadow-host"></div>
</div>
<!-- js部分-->
<script>
attachShadow();
function attachShadow() {
const shadowHost = document.querySelector('.shadow-host');
const shadowRoot = shadowHost.attachShadow({ mode: 'closed' });
const shadowInput = document.createElement('input');
shadowInput.value = 'input'
shadowRoot.appendChild(shadowInput);
}
</script>
</body>
</html>
样式隔离
外部CSS(包含style和外链资源)中被应用在Shadow Host元素的样式中(包括继承来的样式)能被继承的属性都会被Shadow DOM中的元素继承,通过选择器无法直接对Shadow DOM的元素应用样式。
Shadow DOM中style或者引入的CSS不会对外部元素造成影响
由于Shadow DOM天然具有良好的样式隔离特性,所以Shadow DOM也是作为了微前端框架qiankun中实现样式隔离的一种方案。
例1:外部无法通过类选择器改变 Shadow DOM 内元素样式
可以看到,同样拥有pure-text这个 className,但是却只对外部DOM生效。
例2:Shadow Host 元素上的样式能够被继承
上面例子稍微改一下,我给shadow-host元素添加一个 color样式
例3:Shadow DOM内部应用样式无法改变影响外部元素
在Shadom内引入css文件:
shadow.css
.btn-primary {
background-color: #1890ff;
padding: 5px 10px;
color: #fff;
}
html:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<div class="shadow-host-parent">
<div class="shadow-host"></div>
</div>
<div class="shadow-host"></div>
<div style="margin-top:20px"><span class="btn-primary">click Me!</span></div>
<script>
attachShadow();
function attachShadow() {
const shadowHost = document.querySelector('.shadow-host');
const shadowRoot = shadowHost.attachShadow({ mode: 'closed' });
// 引入shadow.css
shadowRoot.innerHTML = `
<head><link rel="stylesheet" href="./shadow.css" type="text/css"></link></head>
<span class="btn-primary">我是dow DOM 内的 Button, click Me!</span>`;
}
</script>
</body>
</html>
结果如下,只对 Shadow DOM的元素生效:
浏览器提供了一个方法: customElements.define()
, 来进行自定义标签的定义。该方法接受三个参数:
- 自定义元素的名称,一个 DOMString 标准的字符串,为了防止自定义元素的冲突,必须是一个带短横线连接的名称(
e.g. custom-tag
)。 - 定义自定义元素的一些行为,类似于 React、Vue 中的生命周期。
- 扩展参数(可选),该参数类型为一个对象,且需要包含
extends
属性,用于指定创建的元素继承自哪一个内置元素(e.g. { extends: 'p' }
)。
下面通过一些例子,演示其用法,完整代码放到了 JS Bin 上。
两种类型的自定义元素
-
Autonomous custom elements
独立的——它们不继承自标准的 HTML 元素。你可以通过将它们字面上写为 HTML 元素来在页面上使用它们。例如 <popup-info> 或 document.createElement("popup-info")。 -
Customized built-in elements
继承自基本的 HTML 元素。要创建其中之一,你必须指定它们扩展的元素,并且通过设置基本元素的 is 属性指定自定义元素的名称来使用它们。例如 <p is="word-count"> 或 document.createElement("p", { is: "word-count" })
创建一个新的 HTML 标签
先看看如何创建一个全新的自定义元素。
class HelloUser extends HTMLElement {
constructor() {
// 必须调用 super 方法
super();
// 创建一个 div 标签
const $box = document.createElement("p");
let userName = "User Name";
if (this.hasAttribute("name")) {
// 如果存在 name 属性,读取 name 属性的值
userName = this.getAttribute("name");
}
// 设置 div 标签的文本内容
$box.innerText = `Hello ${userName}`;
// 创建一个 shadow 节点,创建的其他元素应附着在该节点上
const shadow = this.attachShadow({ mode: "open" });
shadow.appendChild($box);
}
}
// 定义一个名为 <hello-user /> 的元素
customElements.define("hello-user", HelloUser);
<hello-user name="hello-user"></hello-user>
这时候页面上就会生成一个 <p>
标签,其文本内容为:Hello hello-user
。这种形式的自定义元素被称为: Autonomous custom elements
,是一个独立的元素,可以在 HTML 中直接使用。
扩展已有的 HTML 标签
我们除了可以定义一个全新的 HTML 标签,还可以对已有的 HTML 标签进行扩展,例如,我们需要封装一个与 <ul>
标签能力类似的组件,就可以使用如下方式:
class SkillList extends HTMLUListElement {
constructor() {
// 必须调用 super 方法
super();
if (
this.hasAttribute("skills") &&
this.getAttribute("skills").includes(',')
) {
// 读取 skills 属性的值
const skills = this.getAttribute("skills").split(',');
skills.forEach(skill => {
const item = document.createElement("li");
item.innerText = skill;
this.appendChild(item);
})
}
}
}
// 对 <ul> 标签进行扩展
customElements.define("skill-list", SkillList, { extends: "ul" });
<ul is="skill-list" skills="js,css,html"></ul>
对已有的标签进行扩展,需要用到 customElements.define
方法的第三个参数,且第二参数的类,也需要继承需要扩展标签的对应的类。使用的时候,只需要在标签加上 is
属性,属性值为第一个参数定义的名称。
生命周期
自定义元素的生命周期比较简单,一共只提供了四个回调方法:
-
connectedCallback
:当自定义元素被插入到页面的 DOM 文档时调用。 -
disconnectedCallback
:当自定义元素从 DOM 文档中被删除时调用。 -
adoptedCallback
:当自定义元素被移动时调用。 -
attributeChangedCallback
: 当自定义元素增加、删除、修改自身属性时调用。
下面演示一下使用方法:
// Create a class for the element
class Square extends HTMLElement {
// Specify observed attributes so that
// attributeChangedCallback will work
static get observedAttributes() {
return ['c', 'l'];
}
constructor() {
// Always call super first in constructor
super();
const shadow = this.attachShadow({mode: 'open'});
const div = document.createElement('div');
const style = document.createElement('style');
shadow.appendChild(style);
shadow.appendChild(div);
}
connectedCallback() {
console.log('Custom square element added to page.');
updateStyle(this);
}
disconnectedCallback() {
console.log('Custom square element removed from page.');
}
adoptedCallback() {
console.log('Custom square element moved to new page.');
}
attributeChangedCallback(name, oldValue, newValue) {
console.log('Custom square element attributes changed.');
updateStyle(this);
}
}
customElements.define('custom-square', Square);
function updateStyle(elem) {
const shadow = elem.shadowRoot;
shadow.querySelector('style').textContent = `
div {
width: ${elem.getAttribute('l')}px;
height: ${elem.getAttribute('l')}px;
background-color: ${elem.getAttribute('c')};
}
`;
}
const add = document.querySelector('.add');
const update = document.querySelector('.update');
const remove = document.querySelector('.remove');
let square;
update.disabled = true;
remove.disabled = true;
function random(min, max) {
return Math.floor(Math.random() * (max - min + 1) + min);
}
add.onclick = function() {
// Create a custom square element
square = document.createElement('custom-square');
square.setAttribute('l', '100');
square.setAttribute('c', 'red');
document.body.appendChild(square);
update.disabled = false;
remove.disabled = false;
add.disabled = true;
};
update.onclick = function() {
// Randomly update square's attributes
square.setAttribute('l', random(50, 200));
square.setAttribute('c', `rgb(${random(0, 255)}, ${random(0, 255)}, ${random(0, 255)})`);
};
remove.onclick = function() {
// Remove the square
document.body.removeChild(square);
update.disabled = true;
remove.disabled = true;
add.disabled = false;
};
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Life cycle callbacks test</title>
<style>
custom-square {
margin: 20px;
}
</style>
<script defer src="main.js"></script>
</head>
<body>
<h1>Life cycle callbacks test</h1>
<div>
<button class="add">Add custom-square to DOM</button>
<button class="update">Update attributes</button>
<button class="remove">Remove custom-square from DOM</button>
</div>
</body>
</html>
在元素被创建后,等待 5s,然后将自定义元素移动到 iframe 文档中,这时候能看到控制台会同时出现 删除元素
、移动元素
的 log。
HTML templates(HTML模板)
前面的案例中,有个很明显的缺陷,那就是操作 DOM 还是得使用 DOM API,相比起 Vue 得模板和 React 的 JSX 效率明显更低,为了解决这个问题,在 HTML 规范中引入了 <tempate>
和 <slot>
标签。
使用模板
模板简单来说就是一个普通的 HTML 标签,可以理解成一个 div
,只是这个元素内的所以内容不会展示到界面上。
<template id="helloUserTpl">
<p class="name">User Name</p>
<a target="blank" class="url">##</a>
</template>
在 JS 中,我们可以直接通过 DOM API 获取到该模板的实例,获取到实例后,一般不能直接对模板内的元素进行修改,要调用 tpl.content.cloneNode
进行一次拷贝,因为页面上的模板并不是一次性的,可能其他的组件也要引用。
// 通过 ID 获取标签
const tplElem = document.getElementById('helloUserTpl');
const content = tplElem.content.cloneNode(true);
我们在获取到拷贝的模板后,就能对模板进行一些操作,然后再插入到 Shadow DOM 中。
<hello-user name="Shenfq" url="http://www.baidu.com" />
<script>
// 通过 ID 获取标签
const tplElem = document.getElementById('helloUserTpl');
const content = tplElem.content.cloneNode(true);
class HelloUser extends HTMLElement {
constructor() {
// 必须调用 super 方法
super();
// 通过 ID 获取标签
const tplElem = document.getElementById('helloUserTpl');
const content = tplElem.content.cloneNode(true);
if (this.hasAttribute('name')) {
const $name = content.querySelector('.name');
$name.innerText = this.getAttribute('name');
}
if (this.hasAttribute('url')) {
const $url = content.querySelector('.url');
$url.innerText = this.getAttribute('url');
$url.setAttribute('href', this.getAttribute('url'));
}
// 创建一个 shadow 节点,创建的其他元素应附着在该节点上
const shadow = this.attachShadow({ mode: "closed" });
shadow.appendChild(content);
}
}
// 定义一个名为 <hello-user /> 的元素
customElements.define("hello-user", HelloUser);
</script>
添加样式
<template>
标签中可以直接插入 <style>
标签在,模板内部定义样式。
<template id="helloUserTpl">
<style>
:host {
display: flex;
flex-direction: column;
width: 200px;
padding: 20px;
background-color: #D4D4D4;
border-radius: 3px;
}
.name {
font-size: 20px;
font-weight: 600;
line-height: 1;
margin: 0;
margin-bottom: 5px;
}
.email {
font-size: 12px;
line-height: 1;
margin: 0;
margin-bottom: 15px;
}
</style>
<p class="name">User Name</p>
<a target="blank" class="url">##</a>
</template>
其中 :host
伪类用来定义 shadow-root
的样式,也就是包裹这个模板的标签的样式。
占位元素
占位元素就是在模板中的某个位置先占据一个位置,然后在元素插入到界面上的时候,在指定这个位置应该显示什么。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<template id="helloUserTpl">
<p class="name">User Name</p>
<a target="blank" class="url">##</a>
<!--占位符-->
<slot name="desc"></slot>
</template>
<hello-user name="asdasdasd" url="http://www.baidu.com">
<p slot="desc">你好,我是slot</p>
</hello-user>
<script>
// 通过 ID 获取标签
const tplElem = document.getElementById('helloUserTpl');
const content = tplElem.content.cloneNode(true);
class HelloUser extends HTMLElement {
constructor() {
// 必须调用 super 方法
super();
// 通过 ID 获取标签
const tplElem = document.getElementById('helloUserTpl');
const content = tplElem.content.cloneNode(true);
if (this.hasAttribute('name')) {
const $name = content.querySelector('.name');
$name.innerText = this.getAttribute('name');
}
if (this.hasAttribute('url')) {
const $url = content.querySelector('.url');
$url.innerText = this.getAttribute('url');
$url.setAttribute('href', this.getAttribute('url'));
}
// 创建一个 shadow 节点,创建的其他元素应附着在该节点上
const shadow = this.attachShadow({ mode: "closed" });
shadow.appendChild(content);
}
}
// 定义一个名为 <hello-user /> 的元素
customElements.define("hello-user", HelloUser);
</script>
</body>
</html>
Shadow Host 的 CSS 选择器
:host 伪类选择器
选取内部使用该部分 CSS 的 Shadow host 元素,其实也就是自定义标签元素。用法如下:
:host {
display: block;
margin: 20px;
width: 200px;
height: 200px;
border: 3px solid #000;
}
注意::host 选择器只在 Shadow DOM 中使用才有效果。
另外,可以使用 :host 子选择器 的形式来给 Shadow Host 的子元素设置样式,比如:
:host()伪类函数
:host() 的作用是获取给定选择器的 Shadow Host。比如下面的代码:
<my-card class="my-card"></my-card>
<my-card></my-card>
<script>
class MyCard extends HTMLElement {
constructor () {
super();
this.shadow = this.attachShadow({mode: "open"});
let styleEle = document.createElement("style");
styleEle.textContent = `
:host(.my-card){
display: block;
margin: 20px;
width: 200px;
height: 200px;
border: 3px solid #000;
}
:host .card-header{
border: 2px solid red;
padding:10px;
background-color: yellow;
font-size: 16px;
font-weight: bold;
}
`;
this.shadow.appendChild(styleEle);
let headerEle = document.createElement("div");
headerEle.className = "card-header";
headerEle.innerText = "My Card";
this.shadow.appendChild(headerEle);
}
}
window.customElements.define("my-card", MyCard);
</script>
:host(.my-card) 只会选择类名为 my-card 的自定义元素, 且它后面也可以跟子选择器来选择自己跟节点下的子元素。
需要注意的是::host() 的参数是必传的,否则选择器函数失效
:host-context()伪类函数
用来选择特定祖先内部的自定义元素,祖先元素选择器通过参数传入。比如以下代码:
<div id="container">
<my-card></my-card>
</div>
<my-card></my-card>
<script>
class MyCard extends HTMLElement {
constructor () {
super();
this.shadow = this.attachShadow({mode: "open"});
let styleEle = document.createElement("style");
styleEle.textContent = `
:host-context(#container){
display: block;
margin: 20px;
width: 200px;
height: 200px;
border: 3px solid #000;
}
:host .card-header{
border: 2px solid red;
padding:10px;
background-color: yellow;
font-size: 16px;
font-weight: bold;
}
`;
this.shadow.appendChild(styleEle);
let headerEle = document.createElement("div");
headerEle.className = "card-header";
headerEle.innerText = "My Card";
this.shadow.appendChild(headerEle);
}
}
window.customElements.define("my-card", MyCard);
</script>
:host-context(#container) 只会对 id 为 container 元素下的自定义元素生效