我必须感谢 Jeremy Keith 以及他去年年底发表的 极具洞察力的文章,该文章向我介绍了 HTML Web 组件的概念。这对我来说是一个“顿悟”时刻。
当您将一些现有标记包装到自定义元素中,然后使用 JavaScript 应用一些新行为时,从技术上讲,您并没有做任何以前无法通过一些 DOM 遍历和事件处理完成的事情。但是,使用 Web 组件这样做不那么脆弱。它是可移植的。它遵循单一职责原则。它只做一件事,但它做得很好。
在此之前,我一直错误地认为所有 Web 组件都完全依赖于 JavaScript 的存在,以及那个听起来相当可怕的 Shadow DOM。虽然确实可以通过这种方式创作 Web 组件,但还有另一种方式。也许是更好的方式?特别是如果您像我一样提倡 渐进增强。毕竟,HTML Web 组件只是 HTML。
虽然这超出了我们这里讨论的范围,但 Andy Bell 最近写了一篇文章,提供了 他(极佳的)对渐进增强含义的看法。
让我们看三个具体的例子,展示我认为 HTML Web 组件的关键特性——CSS 样式封装和渐进增强的机会——而无需依赖 JavaScript 即可开箱即用。我们肯定会使用 JavaScript,但这些组件应该在没有它的情况下也能工作。
这些示例都可以在我的 Web UI Boilerplate 组件库(使用 Storybook 构建)中找到,以及 GitHub 上的相关源代码。
<webui-disclosure>
示例 1:我真的很喜欢 Chris Ferdinandi 教授如何 从头开始构建一个 Web 组件,使用披露(显示/隐藏)模式作为示例。第一个示例扩展了他的演示。
让我们从头等公民,HTML 开始。Web 组件允许我们使用我们自己的命名创建自定义元素,在本例中,我们使用了一个 <webui-disclosure>
标签来保存一个 <button>
,该按钮设计为显示/隐藏一段文本块,以及一个 <div>
,该 <div>
包含我们要显示和隐藏的文本的 <p>
。
<webui-disclosure
data-bind-escape-key
data-bind-click-outside
>
<button
type="button"
class="button button--text"
data-trigger
hidden
>
Show / Hide
</button>
<div data-content>
<p>Content to be shown/hidden.</p>
</div>
</webui-disclosure>
如果 JavaScript 被禁用或未执行(出于任何可能的原因),按钮默认情况下是隐藏的——这要归功于其上的 hidden
属性——而 div 内的内容默认情况下会简单地显示。
不错。这是一个非常简单的渐进增强示例。访问者可以使用或不使用 <button>
查看内容。
我提到过这个示例扩展了 Chris Ferdinandi 的初始演示。关键区别在于,您可以通过单击键盘的 ESC
键或单击元素外部的任何位置来关闭该元素。这就是 <webui-disclosure
标签上的两个 [data-attribute]
的作用。
我们首先 定义自定义元素,以便浏览器知道如何处理我们编造的标签名。
customElements.define('webui-disclosure', WebUIDisclosure);
自定义元素的名称必须为破折号标识符,例如 <my-pizza>
或其他任何名称,但如 Jim Neilsen 指出,通过 Scott Jehl,这并不一定意味着破折号必须位于两个单词之间。
我通常更喜欢使用 TypeScript 来编写 JavaScript,以帮助消除愚蠢的错误并强制执行一定程度的“防御性”编程。但是为了简单起见,Web 组件的 ES 模块结构在纯 JavaScript 中看起来像这样
default class WebUIDisclosure extends HTMLElement {
constructor() {
super();
this.trigger = this.querySelector('[data-trigger]');
this.content = this.querySelector('[data-content]');
this.bindEscapeKey = this.hasAttribute('data-bind-escape-key');
this.bindClickOutside = this.hasAttribute('data-bind-click-outside');
if (!this.trigger || !this.content) return;
this.setupA11y();
this.trigger?.addEventListener('click', this);
}
setupA11y() {
// Add ARIA props/state to button.
}
// Handle constructor() event listeners.
handleEvent(e) {
// 1. Toggle visibility of content.
// 2. Toggle ARIA expanded state on button.
}
// Handle event listeners which are not part of this Web Component.
connectedCallback() {
document.addEventListener('keyup', (e) => {
// Handle ESC key.
});
document.addEventListener('click', (e) => {
// Handle clicking outside.
});
}
disconnectedCallback() {
// Remove event listeners.
}
}
您是否想知道那些事件监听器?第一个是在 constructor()
函数中定义的,而其余的则在 connectedCallback()
函数中定义。 Hawk Ticehurst 更胜一筹地解释了其原理。
这个 JavaScript 对于 Web 组件“工作”不是必需的,但它确实添加了一些不错的功能,更不用说辅助功能考虑因素了,以帮助实现渐进增强,使 <button>
能够显示和隐藏内容。例如,JavaScript 会注入适当的 aria-expanded
和 aria-controls
属性,使依赖屏幕阅读器的人能够理解按钮的目的。
这就是这个示例的渐进增强部分。
为了简单起见,我没有为这个组件编写任何额外的 CSS。您看到的样式只是从现有的全局范围或组件样式(例如,排版和按钮)继承来的。
但是,下一个示例确实有一些额外的范围 CSS。
<webui-tabs>
示例 2:第一个示例阐述了 HTML Web 组件的渐进增强优势。我们获得的另一个好处是 CSS 样式是封装的,这是一个花哨的说法,表示 CSS 不会泄漏到组件之外。样式纯粹是作用域到 Web 组件的,并且这些样式不会与应用于当前页面的其他样式冲突。
让我们转向第二个示例,这次演示 Web 组件的样式封装功能以及它们如何支持用户体验中的渐进增强。我们将使用一个选项卡式组件来组织内容,这些内容将按“面板”显示,当单击面板的对应选项卡时将显示这些面板——这与您在许多组件库中找到的东西类似。
从 HTML 结构开始
<webui-tabs>
<div data-tablist>
<a href="#tab1" data-tab>Tab 1</a>
<a href="#tab2" data-tab>Tab 2</a>
<a href="#tab3" data-tab>Tab 3</a>
</div>
<div id="tab1" data-tabpanel>
<p>1 - Lorem ipsum dolor sit amet consectetur.</p>
</div>
<div id="tab2" data-tabpanel>
<p>2 - Lorem ipsum dolor sit amet consectetur.</p>
</div>
<div id="tab3" data-tabpanel>
<p>3 - Lorem ipsum dolor sit amet consectetur.</p>
</div>
</webui-tabs>
您明白了:三个作为选项卡样式化的链接,单击时会打开一个包含内容的选项卡面板,例如 #tab1
、#tab2
等等。请注意,选项卡列表中的每个 [data-tab]
都指向一个与选项卡面板 ID 相匹配的锚点链接。
我们首先看一下样式封装部分,因为我们在上一个示例中没有涉及到这一点。假设 CSS 像这样组织
webui-tabs {
[data-tablist] {
/* Default styles without JavaScript */
}
[data-tab] {
/* Default styles without JavaScript */
}
[role='tablist'] {
/* Style role added by JavaScript */
}
[role='tab'] {
/* Style role added by JavaScript */
}
[role='tabpanel'] {
/* Style role added by JavaScript */
}
}
看看这里发生了什么?我们有两个样式规则——[data-tablist]
和 [data-tab]
——它们包含 Web 组件的默认样式。换句话说,无论 JavaScript 是否加载,这些样式都存在。同时,其他三个样式规则是选择器,只要启用了 JavaScript 并支持 JavaScript,这些选择器就会被注入到组件中。这样,**只有当 JavaScript 在 HTML 中为这些元素放置****role**
**属性时,最后三个样式规则才会应用。**在那里,我们已经通过仅在需要 JavaScript 时设置样式来提供了一点渐进增强。
所有这些样式都完全封装或作用域到 <webui-tabs>
Web 组件。不存在“泄漏”,也就是说不会泄漏到其他 Web 组件的样式中,甚至不会泄漏到页面中全局范围内的任何其他内容中。我们甚至可以选择放弃类名、复杂选择器和 像 BEM 这样的方法,转而使用组件子元素的简单后代选择器,让我们能够以更声明的方式在语义元素上编写样式。
快速:“Light” DOM 与 Shadow DOM
对于大多数 Web 项目,我通常更喜欢将 CSS(包括 Web 组件 Sass 部分)捆绑到一个单独的 CSS 文件中,以便即使 JavaScript 未执行,组件的默认样式也可用在全局范围内。
但是,可以通过 JavaScript 导入一个仅由该 Web 组件使用的样式表,前提是 JavaScript 可用
import styles from './styles.css';
class WebUITabs extends HTMLElement {
constructor() {
super();
this.adoptedStyleSheets = [styles];
}
}
customElements.define('webui-tabs', WebUITabs);
或者,我们可以注入一个包含组件样式的 <style>
标签
class WebUITabs extends HTMLElement {
connectedCallback() {
this.attachShadow({ mode: 'open' }); // Required for JavaScript access
this.shadowRoot.innerHTML = `
<style> <!-- styles go here --> </style>
// etc.
`;
}
}
customElements.define('webui-tabs', WebUITabs);
无论您选择哪种方法,这些样式都直接作用域到 Web 组件,防止组件样式泄漏,但允许继承全局样式。
现在考虑这个简单的示例。我们在组件的开始和结束标签之间写入的所有内容都被视为“Light” DOM 的一部分。
<my-web-component>
<!-- This is Light DOM -->
<div>
<p>Some content... styles are inherited from the global scope</p>
</div>
----------- Shadow DOM Boundary -------------
| <!-- Anything injected by JavaScript --> |
---------------------------------------------
</my-web-component>
Dave Rupert 有一个很好的文章,它非常容易让人看到外部样式如何能够“穿透”Shadow DOM 并选择 Light DOM 中的元素。请注意,在自定义元素标签之间写入的 <button>
元素如何接收全局 CSS 中 button
选择器的样式,而通过 JavaScript 注入的 <button>
则保持不变。
如果我们想要样式化 Shadow DOM <button>
,我们必须使用上面的示例中的内部样式,例如导入样式表或注入内联 <style>
块。
这并不意味着所有 CSS 样式属性都被 Shadow DOM 阻止。实际上,Dave 概述了 Web 组件 继承
的 37 个属性,主要是在文本、列表和表格格式方面。
使用 JavaScript 逐步增强选项卡组件
尽管第二个例子更多的是关于样式封装,但它仍然是一个很好的机会,让我们可以免费看到从 Web 组件中获得的逐步增强。现在让我们进入 JavaScript,这样我们就可以看到如何支持逐步增强。完整的代码相当长,所以我已经对一些内容进行了缩减,以帮助使要点更清晰。
default class WebUITabs extends HTMLElement {
constructor() {
super();
this.tablist = this.querySelector('[data-tablist]');
this.tabpanels = this.querySelectorAll('[data-tabpanel]');
this.tabTriggers = this.querySelectorAll('[data-tab]');
if (
!this.tablist ||
this.tabpanels.length === 0 ||
this.tabTriggers.length === 0
) return;
this.createTabs();
this.tabTriggers.forEach((tabTrigger, index) => {
tabTrigger.addEventListener('click', (e) => {
this.bindClickEvent(e);
});
tabTrigger.addEventListener('keydown', (e) => {
this.bindKeyboardEvent(e, index);
});
});
}
createTabs() {
// 1. Hide all tabpanels initially.
// 2. Add ARIA props/state to tabs & tabpanels.
}
bindClickEvent(e) {
e.preventDefault();
// Show clicked tab and update ARIA props/state.
}
bindKeyboardEvent(e, index) {
e.preventDefault();
// Handle keyboard ARROW/HOME/END keys.
}
}
customElements.define('webui-tabs', WebUITabs);
JavaScript 为屏幕阅读器用户注入 ARIA 角色、状态和属性到选项卡和内容块,以及额外的键盘绑定,以便我们能够使用键盘在选项卡之间导航;例如,TAB
键用于访问组件的活动选项卡和活动 tabpanel
内部的任何可聚焦内容,并且可以使用ARROW
键遍历选项卡。因此,如果 JavaScript 无法加载,默认体验仍然是可访问的,选项卡仍然锚定链接到它们各自的面板,而这些面板自然地垂直堆叠,一个在另一个的顶部。
如果启用了 JavaScript 并得到支持?我们将获得增强的体验,包括更新后的可访问性注意事项。
<webui-ajax-loader>
示例 3:最后一个例子与前面两个不同,因为它完全由 JavaScript 生成,并且使用 Shadow DOM。这是因为它仅用于指示 Ajax 请求的“加载”状态,因此仅在启用 JavaScript 时才需要。
HTML 标记只是开头和结尾的组件标签
<webui-ajax-loader></webui-ajax-loader>
简化的 JavaScript 结构
default class WebUIAjaxLoader extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<svg role="img" part="svg">
<title>loading</title>
<circle cx="50" cy="50" r="47" />
</svg>
`;
}
}
customElements.define('webui-ajax-loader',WebUIAjaxLoader);
注意,<webui-ajax-loader>
标签之间的所有内容都是用 JavaScript 注入的,这意味着它们都在 Shadow DOM 中,封装在与组件没有直接捆绑的其他脚本和样式之外。
但也要注意<svg>
元素上设置的part
属性。我们将在此处放大
<svg role="img" part="svg">
<!-- etc. -->
</svg>
这是我们为自定义元素设置样式的另一种方式:命名部分。现在我们可以从模板字面量外部为该 SVG 设置样式,我们使用它来建立元素。有一个::part
伪选择器可以实现这一点
webui-ajax-loader::part(svg) {
// Shadow DOM styles for the SVG...
}
这是一件很酷的事情:该选择器可以访问 CSS 自定义属性,无论它们是全局作用域还是元素的本地作用域。
webui-ajax-loader {
--fill: orangered;
}
webui-ajax-loader::part(svg) {
fill: var(--fill);
}
就逐步增强而言,JavaScript 提供所有 HTML。这意味着只有在启用并支持 JavaScript 时才呈现加载器。当它被启用时,SVG 被添加,并附带可访问的标题和所有内容。
总结
这就是示例!我希望你现在有和我一样的顿悟,就像我阅读 Jeremy Keith 的文章时一样:HTML Web 组件是 HTML 第一的功能。
当然,JavaScript 确实起着重要作用,但仅在必要时。需要更多封装?想要在访问者的浏览器支持时添加一些 UX 优点?这就是 JavaScript 的作用,也是 HTML Web 组件成为 Web 平台如此出色补充的原因——它们依赖于原生 Web 语言来完成它们原本设计要做的工作,而不会过分依赖其中任何一个。
我想从你那里学到更多,继续保持优秀的成果。
小心在构造函数中放太多内容,如果你以编程方式创建那个 Web 组件,最终会遇到很多错误。那些子元素可能不存在,无法查询。
感谢提醒…但我确实已经编写了防御性代码,所以如果子元素不存在,我会退出
https://github.com/basher/Web-UI-Boilerplate/blob/master/ui/src/javascript/web-components/webui-disclosure.ts#L15
https://github.com/basher/Web-UI-Boilerplate/blob/master/ui/src/javascript/web-components/webui-tabs.ts#L13
请记住,本文中的示例(其中 2 个)是 HTML Web 组件,因此 HTML 不会以编程方式生成。
想发出同样的警告。
Js 可用于操作 light dom,包括属性值,动态添加或删除选项卡。
事件冒泡,因此最好在 Web 组件本身设置内部 light dom 元素的事件监听器。
与其“编写防御性代码”,你也可以直接让它起作用。
https://dev.to/dannyengelman/web-component-developers-do-not-connect-with-the-connectedcallback-yet-4jo7
Web 组件可以以函数式方式实现,还是我必须使用 JS 框架?
Chris Ferdinandi 在自定义事件的回调方面有一些非常棒的信息,值得一看。