web components
自定义元素(custom Elements):可重用的网页组件
TL;DR
自定义元素可以使开发者创建新的HTML 标记。现在有很多网页组件,我们可以使用或者拓展这些组件,使得我们的页面更加模块化。
自定义新元素
可以使用 customElements
来定义全局元素
// 定义一个移动抽屉面板 <app-drawer>
class AppDrawer extends HTMLElement {...}
window.customElments.define('app-drawer', AppDrawer);
// or use an anonymous class if you don't want a named constructor in current scope
window.customElements.define('app-drawer', class extends HTMLElement {...});
<app-drawer> </app-drawer>
自定义的元素与原生的元素没有区别,因为其扩展了 HTMLElement
, 可以确保继承了完整的DOM API, 可以为其添加事件监听等。还可以自定义 JavaScript API
class AppDrawer extends HTMLElement {
// A getter/setter for an open property
get open() {
return this.hasAttribute('open');
}
set open(val) {
// Reflect the value of the open property as an HTML attribute
if (val) {
this.setAttribute('open', '');
} else {
this.removeAttribute('open')
}
this.toggleDrawer();
}
toggleDrawer() {
...
}
}
这个例子中,我们定义了一个有 open
属性,toggleDrawer()
的 drawer. 这些属性将会成为 HTML 的属性。
自定义元素有一个非常牛的地方,那就是在定义内部的 this
指向这个 DOM 元素本身。这样的话就可以使用 this
来访问元素属性,查询节点(this.querySelectorAll(’.items’))等。
创建元素的规则:
- 自定义元素的名称必须包含
-
,这样的话HTML解析器就可以分辨出哪些是自定义元素,这也确保了往HTML加入新元素时,能够向前兼容 - 一个标签名只能注册一次。
- 自定义元素不能是自闭合的,因为HTML仅仅允许特定的元素自闭合。
扩展元素
既可以拓展自定义元素,也可以扩展内置 HTML 元素。
扩展自定义元素
class FancyDrawer extends AppDrawer {
constructor() {
super(); // always call super() first in the constructor
}
toggleDrawer() {
// super.toggleDrawer() or other implemention
}
antherMethod() {
...
}
}
扩展原生 HTML 元素
// <button> 自定义元素需要从 HTMLButtonElement 继承,<img> 需要从扩展 HTMLImageElement
class FancyButton extends HTMLButtonElement {
constructor() {
super();
this.addEventListener('click', e => this.drawRipple(e.offsetX, e.offsetY));
}
// ripple animation
drawRipple(x, y) {
let div = document.creatElement('div');
div.classList.add('ripple');
this.appendChild(div);
div.style.top = `${y - div.clientHeight/2}px`;
div.style.left = `${x - div.clientWidth/2}px`;
div.style.backgroudColor = 'currentColor';
div.classList.add('run');
div.adEventListener('transitionend', e => div.remove());
}
}
customElment.define('fancy-button', FancyButton, {extends: 'button'});
扩展原生元素时,define() 的第三个参数要告诉浏览器所扩展的元素。因为存在多个原生元素使用同一DOM接口的情况,要告诉浏览器具体拓展的元素,浏览器才能确定。
扩展原生元素的用法:
<button is="fancy-button" disabled>Fancy button!</button>
// 使用js创建实例
let button = document.creatElement('button', {is: 'fancy-button'});
button.textContext = 'Fancy button';
button.disabled = true;
document.body.appendChild(button);
// use new
let button = new FancyButton();
button.textContext = 'Fancy button';
button.disabled = true;
自定义元素响应
自定义元素可以为不同的生命周期定义钩子函数,称为自定义元素响应。
名称 | 调用时机 |
---|---|
constructor | 创建或升级元素的一个实例。用于初始化状态,设置时间监听器或者创建shadow DOM |
connectedCallback | 元素每次插入到DOM时都会调用。用于运行setup code,例如获取资源或者进行渲染 |
disconnectedCallback | 元素每次从DOM中移除时都会调用。用于运行清理代码(移除事件监听) |
attributeChangedCallback(attrName, oldVal, newVal) | 属性添加,移除,更新或替换时调用 |
adoptedCallback() | 兹定于元素被移入新的document时 |
浏览器对加入
observedAttributes
白名单队列的属性都会执行attributeChangedCallback()
. 事实上,这里需要进行性能优化,因为对于一些通用属性(style,class)的更改,不应该出现大量的回调。
回调的响应是同步的,当触发事件执行时,将会立即触发回调函数。
属性(properties)和特性(attributes)
将属性映射为特性:
HTML属性通常会将其值与HTML特性的形式映射回DOM。
div.id = 'my-id';
div.hidden = true;
// ----
<div id="my-id" hidden>
映射属性可以让元素的 DOM 状态与其JavaScript状态保持同步,另外还可以将用户自定义的样式在js状态变更时应用。
app-drawer[disabled] {
opacity: 0.5;
pointer-events: none;
}
// ---
...
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');
}
this.toggleDrawer();
}
// ---
<app-drawer open disabled></app-drawer>
保留对属性的更改:
通过 attributeChangedCallback
来对属性的更改做出响应。对于 observedAttributes
数组中的列出的每一个属性更改,浏览器都将调用此方法。
class AppDrawer extends HTMLElement {
...
static get observedAttributes() {
return ['disabled', 'open'];
}
get disabled() {...}
set disabled(val) {...}
// Only called for the disabled and open attributes due to observedAttributes
attributeChangedCallback(name, oldValue, newValue) {
// When the drawer is disabled, update keyboard/screen reader behavior.
if (this.disabled) {
this.setAttribute('tabindex', '-1');
this.setAttribute('aria-disabled', 'true');
} else {
this.setAttribute('tabindex', '0');
this.setAttribute('aria-disabled', 'false');
}
// TODO: also react to the open attribute changing.
}
}
元素升级
自定义元素可以在定义注册 之前使用。因为浏览器会对未知标记的元素采用不同的处理方式。调用 define() 并将类定义赋予现有元素的过程称为“元素升级”。
可以使用 window.cutomElements.whenDefined()
来了解元素合适被定义。他提供可在元素获得定义时进行解析的 Promise
。
customElements.whenDefined('app-drawer').then(() => {
consoel.log('app-frawer defined');
});
元素定义的内容
customElements.define('x-foo-with-markup', class extends HTMLElement {
connectedCallback() {
this.innerHTML = "<b>I'm an x-foo-with-markup!</b>";
}
...
});
// ----声明以上自定义标签,会生成以下内容
<x-foo-with-markup>
<b>I'm an x-foo-with-markup!</b>
</x-foo-with-markup>
创建使用影子DOM 的元素:
在自定义元素中使用 shadow DOM,可以在 constrctor
中调用 this.attachShadow
customElements.define('x-foo-shadowdom', class extends HTMLElement {
constructor() {
super(); // always call super() first in the constructor.
// Attach a shadow root to the element.
let shadowRoot = this.attachShadow({mode: 'open'});
shadowRoot.innerHTML = `
<style>:host { ... }</style> <!-- look ma, scoped styles -->
<b>I'm in shadow dom!</b>
<slot></slot>
`;
}
...
});
<x-foo-shadowdom>
<p><b>User's</b> custom text</p>
</x-foo-shadowdom>
<!-- renders as -->
<x-foo-shadowdom>
<b>I'm in shadow dom!</b>
<slot></slot>
</x-foo-shadowdom>
通过 创建元素
<template id="x-foo-from-template">
<style>
p { color: orange; }
</style>
<p>I'm in Shadow DOM.My markup was stamped from a <template>.</p>
</template>
<script>
customElements.define('x-foo-from-template', class extends HTMLElement {
constructor() {
super(); // always call super() first in the constructor.
let shadowRoot = this.attachShadow({mode: 'open'});
const t = document.querySelector('#x-foo-from-template');
const instance = t.content.cloneNode(true);
shadowRoot.appendChild(instance);
}
...
});
</script>
以上代码实现的功能:
- 在HTML中定义新的元素:
<x-foo-from-template>
- 元素的shadow DOM 使用
templete
创建 - 由于是 shadow DOM,元素的DOM局限于本地元素
- 由于是 shadow DOM,元素的内部CSS作用域限于元素内。
shadow DOM:独立的网络组件
TL;DR
shadow DOM 解决了构建网络应用的脆弱性问题。脆弱性是由HTML、CSS和 JS 的全局性引起的。近年来,已经有很多工具去解决这个问题,比如现在使用HTML 的 id / class 的时候,如果页面中已经存在同名的id,就不会再报错。
Shadow DOM 修复了 CSS 和DOM。它在网络平台中引入作用域样式。无需工具或命名约定,即可使用JavaScript捆绑 CSS,隐藏实现的细节以及编写独立的组件。
四大网页组件:HTML 模板、shadow DOM、自定义元素、HTML导入。
shadow DOM 旨在构建基于组件的应用,因此适用于以下情况:
- 隔离DOM:组件的DOM是独立的(querySelector() 不会反悔组件中 shadow DOM中的节点)
- 作用域CSS:shadow DOM内部定义的CSS在其作用域内。样式规则不会泄露影响整体页面,页面样式也不会渗入。
- 组合:委组建设计一个声明性、基于标记的API
- 简化CSS:作用域DOM意味着可以使用简单的CSS选择器,更通用的 id/class 名称,而不用担心命名冲突。
- 效率:将应用看成多个DOM块,而不是一个大的(全局)页面。
什么是shadow DOM?
shadow DOM 与普通DOM的两点区别:
- 创建、使用的方式
- 与页面其他部分相关的行为方式。
借助于shadow DOM ,可以创建作用域DOM书,DOM树附加在某个元素上,但其又与这个节点的子节点不同。这一作用域子树成为影子树。被附着的元素称为影子宿主。
创建shadow DOM
const header = document.createElement('header');
const shadowRoot = header.attachShadow({mode: 'open'});
shadowRoot.innerHTML = '<h1>Hello Shadow DOM</h1>'// 也可以使用 appendChild()
并不是所有元素都能托管shadow DOM的,比如
<input>
就不能。
为自定义元素创建 shadow DOM:
使用 shadow DOM来分隔元素的HTML、CSS和JS,从而生成一个 web component
// Use custom elements API v1 to register a new HTML tag and define its JS behavior
// using an ES6 class. Every instance of <fancy-tab> will have this same prototype.
customElements.define('fancy-tabs', class extends HTMLElement {
constructor() {
super(); // always call super() first in the constructor.
// Attach a shadow root to <fancy-tabs>.
const shadowRoot = this.attachShadow({mode: 'open'});
shadowRoot.innerHTML = `
<style>#tabs { ... }</style> <!-- styles are scoped to fancy-tabs! -->
<div id="tabs">...</div>
<div id="panels">...</div>
`;// sytle 所定义的样式,将仅限于这个元素内
}
...
});
在使用时,<fancy-tabs></fancy-tabs>
中的子元素将不会进行渲染,因为元素的shadow DOM 代替其子项被渲染。如果要渲染子项,我们要告诉浏览器在哪里进行渲染,也就是在shadow DOM中添加<slot>
元素。
组件(composition)和slot
composition是shadow DOM 最难理解的内容。
一些术语:
- Light DOM:组件的用户编写的标记。该DOM不在组件 shadow DOM内,它是元素实际的子项。
- shadow DOM:该DOM由组件的作者编写,它定义内部结构、作用域CSS并封装实现。
- 扁平的DOM树:浏览器将用户的 light DOM 分布到您的 shadow DOM 的结果,并进行最终渲染。扁平树就是最终看到的树以及在页面上渲染的对象。
<button is="better-button">
<!-- the image and span are better-button's light DOM -->
<img src="gear.svg" slot="icon">
<span>Settings</span>
</button>
#shadow-root
<style>...</style>
<slot name="icon"></slot>
<span id="wrapper">
<slot>Button</slot>
</span>
# 扁平树
<button is="better-button">
#shadow-root
<style>...</style>
<slot name="icon">
<img src="gear.svg" slot="icon">
</slot>
<slot>
<span>Settings</span>
</slot>
</button>
<slot>
元素
shadow DOM 使用 <slot>
元素将不同的 DOM 树组合在一起。Slot是组件内部的占位符,用户可以使用自己的标记来填充。
通过定义 slot,就可以将外部标记引入到组件的shadow DOM 中进行渲染。就相当于再说“在这里渲染用户的标记”。
可以为 slot 命名,这样用户就可以通过特定名称来引用插槽
#shadow-root
<div id="tabs">
<slot id="tabsSlot" name="title"></slot>
</div>
<div id="panels">
<slot id="panelsSlot"></slot>
</div>
# light DOM
<fancy-tabs>
<button slot="title">Title</button>
<button slot="title" selected>Title 2</button>
<button slot="title">Title 3</button>
<section>content panel 1</section>
<section>content panel 2</section>
<section>content panel 3</section>
</fancy-tabs>
# 扁平DOM树 用户自定义内容被渲染进相应的 slot 内。
<fancy-tabs>
#shadow-root
<div id="tabs">
<slot id="tabsSlot" name="title">
<button slot="title">Title</button>
<button slot="title" selected>Title 2</button>
<button slot="title">Title 3</button>
</slot>
</div>
<div id="panels">
<slot id="panelsSlot">
<section>content panel 1</section>
<section>content panel 2</section>
<section>content panel 3</section>
</slot>
</div>
</fancy-tabs>
设定样式
shadow DOM 最有用的功能就是 作用域CSS
- 外部页面的CSS选择器不会应用于组件内部
- 内部定义的样式也不会渗出,仅限于宿主元素。
高级主题
创建闭合影子节点(不推荐)
创建闭合影子树后,在 JavaScript 外部无法访问组件的内部 DOM。这与 <video>
等原生元素工作方式类似。
const div = document.createElement('div');
const shadowRoot = div.attachShadow({mode: 'closed'}); // close shadow tree
// div.shadowRoot === null
// shadowRoot.host === div
不推荐使用{mode: ‘closed’} 的原因:
- 人为的安全功能。没有什么能够阻止攻击者入侵 Element.prototype.attachShadow
- 闭合模式阻止自定义元素代码访问其自己的 shadow DOM。 这根本没用。相反,如果您想要使用如 querySelector() 等元素,您必须存放影子根以备之后参考。 这就与闭合模式的最初目的完全背道而驰!
customElements.define('x-element', class extends HTMLElement {
constructor() {
super(); // always call super() first in the constructor.
this._shadowRoot = this.attachShadow({mode: 'closed'});
this._shadowRoot.innerHTML = '<div class="wrapper"></div>';
}
connectedCallback() {
// When creating closed shadow trees, you'll need to stash the shadow root
// for later if you want to use it again. Kinda pointless.
const wrapper = this._shadowRoot.querySelector('.wrapper');
}
...
});
- 闭合模式使组件对最终用户的灵活性大为降低。
在JS中使用slot
slotchange 事件:当slot的分布式节点发生变化时,slotchange 事件会触发。
const slot = this.shadowRoot.querySelector('#slot');
slot.addEventListener('slotchange', e => {
console.log('light dom children changed!');
});
当组件的实例首次初始化时,slotchange 不触发。
如要监控 light DOM 其他类型的变化,您可以在元素的构造函数中设置 MutationObserver
。
调用slot.assignedNodes()
可查看 slot 正在渲染哪些元素, {flatten: true}
选项将返回 slot 的备用内容
<slot><b>fallback content</b></slot>
用法 | 调用 | 结果 |
---|---|---|
<button is=“better-button”>My button</button> | slot.assignedNodes(); | [text] |
<button is=“better-button”></button> | slot.assignedNodes(); | [] |
<button is=“better-button”></button> | slot.assignedNodes({flatten: true}); | [<b>fallback content</b>] |
element.assignedSlot
将告诉得到元素分配给哪个组件 slot。
shadow DOM 事件模型
当事件从shadow DOM中触发时,将会被调整为看起来像是来自整个组件,而不是来自shadow DOM中的内部元素。
有些事件甚至不能从shadow DOM中传播出去。
能够跨越sshadow DOM 边界的事件有:
- 聚焦事件:blur、focus、focusin、focusout
- 鼠标事件:click、dblclick、mousedown、mouseenter、mousemove,等等
使用自定义事件
通过影子树中内部节点触发的自定义 DOM 事件不会超出影子边界,除非事件是使用 composed: true
标记创建的:
// Inside <fancy-tab> custom element class definition:
selectTab() {
const tabs = this.shadowRoot.querySelector('#tabs');
tabs.dispatchEvent(new Event('tab-select', {bubbles: true, composed: true}));
}
// 如果是 composed: false(默认值),用户无法侦听到影子根之外的事件。