原文作者:matsu.fi
发布时间:2020年6月27日
随着Web Components成为Web标准的一部分,越来越多的库正在用Web Components创建,而不仅仅是暴露上述库的API。
但是在Web Components兴起之前创建的库呢?它们能否被移植到Web Components中,这将是一个什么样的工作量?这就是我们今天要讨论的内容。
前言
那么,如果已经有了一个现有的实现,我们为什么还要创建一个相当于库的Web组件呢?首先想到的是易用性和易实现性。通过创建一个库的Web组件,上述功能的实现就会从原来的
<p id="paragraph-to-highlight">Please highlight me with the library</p>
<script src="/library/api.js"></script>
<script>
window.onload = () => {
const par = document.querySelector('#paragraph-to-highlight');
HighlightLibrary.highlight(par);
};
</script>
更简单的说
<highlight-library>
<p>Please highlight me with the library</p>
</highlight-library>
<script src="/library/wc.js" type="module"></script>
有多个亲的。
- 减少代码
- 不污染ID/类空间
- 没有与窗口加载事件等绑定的任意代码片段。
还有很多其他的问题。当然,其中一些问题可能会通过使用现代框架或库来消除,在这种情况下,javascript位于单独的文件中,而不是在HTML中,但在这种情况下,你仍然需要写一些类似initializeHighlights()的东西,并在页面加载时调用它。
通过在Web组件中自动完成这个过程,并让组件负责它的工作,我们可以大大简化开发过程。
入门
在这篇文章中,我将以我移植的一个名为 Rough Notation 的库为例。
Rough Notation是一个小库,用于在html页面上用漂亮的手绘动画突出显示内容。
Rough Notation的常规用法是这样的。
import { annotate } from 'rough-notation';
const element = document.querySelector('#myElement');
const annotation = annotate(element, { type: 'underline' });
annoation.show();
如果要用我写的Web组件Vanilla Rough Notation来获得同样的功能,你只需要编写
<rough-notation showOnLoad type="underline">
<p>Please underline me</p>
</rough-notation>
这为使用该库进行开发创造了良好的环境,因为不需要额外的javascript。你只需将元素封装在一个Web组件中,功能就会被立即应用。
这使得工作流程更快,因为开发人员不必在代码库中编写初始化函数,只需在创建元素时将效果应用到元素上即可。
这种工作方式也更适合初学者,因为它需要0javascript知识才能上手,所以很容易在例如CMS服务中使用。
那么这个功能是如何实现的呢?
设置阶段
在开发Web组件时,我希望尽可能的保持Vanilla,使Rough Notation成为项目中唯一的依赖。
为了让开发更容易,我还添加了es-dev-server作为开发依赖。Es Dev Server让我们有了一个热重载的无构建环境。
在发布的时候,package.json看起来是这样的。
{
"name": "vanilla-rough-notation",
"version": "0.4.2",
"description": "A vanilla implementation of the Rough Notation library",
"main": "index.js",
"module": "index.js",
"type": "module",
"files": ["*.js"],
"scripts": {
"start": "es-dev-server --app-index index.html --node-resolve --watch --open"
},
"author": "Matsuuu",
"license": "MIT",
"devDependencies": {
"es-dev-server": "^1.54.0"
},
"dependencies": {
"rough-notation": "^0.4.0"
}
}
接下来让我们跳到代码本身。
编写Web组件
首先我们要做的事情是
- 导入我们要移植的库
- 创建一个包含我们代码的类
- 让我们的类扩展HTMLElement类。
- 将Web组件声明为一个自定义元素。
一旦我们这样做了,代码应该是这样的
import { annotate } from 'rough-notation';
export default class VanillaRoughNotation extends HTMLElement {}
if (!customElements.get('rough-notation')) {
customElements.define('rough-notation', VanillaRoughNotation);
}
现在如果我们使用html标签<rough-notation></rough-notation>,我们的类里面的代码就会被执行,如果有的话。
接下来让我们添加一些初始设置。
让我们开始影子盒
Web Components的一大优点是封装了样式和代码。这是通过在Web组件中附加一个ShadowRoot来实现的。
影子根是影子DOM API的一部分,作为主DOM树的子树。Shadow Root与文档的主DOM树是分开渲染的,并且不受主DOM树样式的影响。
在我们的组件中使用影子DOM是必要的,因为影子DOM除了创建一个封装的环境之外,还可以使用Slot。
Slots是Web Components技术套件的相关部分。它们的功能是在Web组件中的占位符,可以用markdown来填充。
让我们开始我们的Web组件代码库,创建一个影子根,并在里面附加一个槽。
export default class VanillaRoughNotation extends HTMLElement {
connectedCallback() {
if (!this.shadowRoot) {
this.attachShadow({ mode: 'open' });
}
this.shadowRoot.innerHTML = '<slot></slot>';
}
}
connectedCallback方法是我们扩展的HTMLElement API的一部分,每次当自定义元素被添加到文档连接的元素中时,connectedCallback都会被调用
在函数中,我们检查元素是否已经有一个shadowRoot,如果没有shadowRoot,我们就用this.attachShadow({mode: 'open'})附加一个。
通过将模式设置为 "open",我们使我们的元素可以从影子根之外的Javascript中访问。元素的内容可以通过document.querySelector("roough-notation").shadowRoot来查询。
如果我们将模式设置为 "封闭",我们就有效地拒绝了外界对影子根内部节点的所有访问。
我们还将<slot>元素设置为shadowRoot的主体。现在我们可以在粗注html标签里面添加其他DOM元素,它们将和组件一起显示。
好了,现在我们可以把元素放在我们的网页里面了。现在我们可以把元素放在我们的Web组件里面了。让我们开始实现该库的功能。
锁定和编码
为了实现我们前面所经历的API的功能,我们首先需要一种方法来获取我们Web组件内部的所有元素。
这可以通过查询我们槽元素的分配节点来实现。
const assignedNodes = this.shadowRoot
.querySelector('slot')
.assignedNodes()
.filter((node) => node instanceof HTMLElement);
当查询assignedNodes时,我们可能也会捕获一些#text元素,因为换行符会生成文本节点。我们要过滤掉这些,只需将节点过滤为HTMLElement的实例就可以轻松完成。
接下来我们要将库的功能应用到所有的节点上。与通常调用API不同,在Web组件中,我们需要考虑到所有可能的变量。
assignedNodes.forEach((an) => {
this.annotation = annotate(an, {
type: this.type,
animate: this.animate,
animationDuration: this.animationDuration,
color: this.color,
strokeWidth: this.strokeWidth,
padding: this.padding,
multiline: this.multiline,
iterations: this.iterations,
});
});
属性和特性
但是等等,你可能会问。我们还没有声明这些变量。是的,没错。接下来我们将看看设置属性。
一个Web组件的属性的基本值是在元素的constructor函数中设置的。
constructor() {
super();
this.type = 'underline';
this.animation = true;
this.animationDuration = 800;
this.color = 'currentColor';
this.strokeWidth = 1;
this.padding = 5;
this.showOnLoad = false;
this.order = 0;
this.multiline = true;
this.iterations = 2;
this.annotation = null;
}
在这里,我们可以使用库的默认值,或者声明一个我们选择的默认情况。我决定实现库中的默认值,同时也将underline设置为默认的符号类型。
目前我们正在摇摆着使用默认值,但我们当然希望用户能够自己声明属性。
让我们创建一个方法来设置HTML元素属性中的变量。
setAttributes() {
this.type = this.getAttribute('type') || this.type;
this.animation = this.hasAttribute('animation') ? this.getAttribute('animation') === 'true' : this.animation;
this.animationDuration = this.getAttribute('animationDuration') || this.animationDuration;
this.color = this.getAttribute('color') || this.color;
this.strokeWidth = this.getAttribute('strokeWidth') || this.strokeWidth;
this.padding = this.getAttribute('padding') || this.padding;
this.showOnLoad = this.hasAttribute('showOnLoad');
this.order = this.getAttribute('order') || this.order;
this.multiline = this.hasAttribute('multiline') ? this.getAttribute('multiline') === 'true' : this.multiline;
this.brackets = this.getBrackets();
this.iterations = this.getAttribute('iterations') || this.iterations;
}
一般的属性我们只需要使用this.getAttribute(attr)方法就可以获取。但是我们的组件中还有几个布尔属性。这些属性比较棘手,因为HTML属性只能是字符串。在这些情况下,我们需要检查属性的存在。
这可以通过this.hasAttribute(attr)来完成。所以现在我们可以通过省略属性的值部分来使用布尔运算符,就像这样。
<rough-notation showOnLoad></rough-notation>
但是如果属性的默认值是true,这就不行了。在这种情况下,我们要检查属性是否是一个字符串,值为true。所以在这种情况下,如果我们想禁用动画,我们可以直接写。
<rough-notation animation="false"></rough-notation>
如果我们想观察这些属性内部的变化,我们可以使用attributeChangedCallback函数和自定义属性设置器。然而我们的Web组件的值是在元素初始化中设置的,所以这里不需要这样做。
回到正轨
现在让我们回到我们的初始化代码。
你可能已经注意到了,我们在API中添加了一个新属性:showOnLoad。这可以用来轻松地使动画在准备好后立即显示,而不是在调用show()方法时运行动画。现在我们来实现这个功能。
Rough Notation库在主DOM中添加了一个样式元素 其中有一些关键的动画关键帧 我们要确保这些关键帧也能应用到我们的开槽元素中去。请记住。Shadow Root封装了样式 所以如果我们的开槽元素在Shadow Root里面 主DOM的样式不会影响到它们
快速浏览一下库的源代码,就会发现样式元素被分配到一个全局变量__rno_kf_s。是 "Rough Notation Keyframe styles "的缩写。
我们可以在我们的元素中克隆这个节点。
this.append(window.__rno_kf_s.cloneNode(true));
我们要确保我们克隆了它,而不是仅仅从主 DOM 中拽出它。
现在,如果我们在克隆节点后立即调用show(),一切都应该正常工作,对吗?
但现在我们注意到动画似乎没有播放。这是怎么回事呢?
如果我们看一下我们要克隆的样式,我们注意到它是一个关键帧样式,大致如下。
@keyframes rough-notation-dash {
to {
stroke-dashoffset: 0;
}
}
这意味着,这给我们的库元素提供了一个动画的结束状态。但是以Javascript的工作方式,只要克隆节点并立即调用show方法,样式元素还没来得及在DOM中初始化自己,这意味着它的样式还不能应用。
你可能首先想到的一个解决方案是
但如果我加个
setTimeout就能解决这个问题,对吧?
那么... 是的,但不是。
SetTimeout是各种讨厌的东西,不应该在这种情况下被过度使用。它确实可以解决这个问题,但它可能会给我们的组件引入一些新的错误。
相反,我们可以告诉我们的代码等待下一个动画帧,然后运行show()命令。这应该可以解决我们的问题。
要做到这一点,我们只需写下
window.requestAnimationFrame(() => {
if (this.showOnLoad) {
this.annotation.show();
}
});
requestAnimationFrame需要一个回调作为参数,然后在浏览器发送一帧后调用它。
逼近目标
所以,现在我们应该有一个有点功能的Web组件的移植。我们的源代码看起来是这样的。
import { annotate } from 'rough-notation';
export default class VanillaRoughNotation extends HTMLElement {
constructor() {
super();
this.type = 'underline';
this.animation = true;
this.animationDuration = 800;
this.color = 'currentColor';
this.strokeWidth = 1;
this.padding = 5;
this.showOnLoad = false;
this.order = 0;
this.multiline = true;
this.iterations = 2;
this.annotation = null;
}
setAttributes() {
this.type = this.getAttribute('type') || this.type;
this.animation = this.hasAttribute('animation') ? this.getAttribute('animation') === 'true' : this.animation;
this.animationDuration = this.getAttribute('animationDuration') || this.animationDuration;
this.color = this.getAttribute('color') || this.color;
this.strokeWidth = this.getAttribute('strokeWidth') || this.strokeWidth;
this.padding = this.getAttribute('padding') || this.padding;
this.showOnLoad = this.hasAttribute('showOnLoad');
this.order = this.getAttribute('order') || this.order;
this.multiline = this.hasAttribute('multiline') ? this.getAttribute('multiline') === 'true' : this.multiline;
this.iterations = this.getAttribute('iterations') || this.iterations;
}
connectedCallback() {
this.setAttributes();
if (!this.shadowRoot) {
this.attachShadow({ mode: 'open' });
}
this.shadowRoot.innerHTML = '<slot></slot>';
const assignedNodes = this.shadowRoot
.querySelector('slot')
.assignedNodes()
.filter((node) => node instanceof HTMLElement);
assignedNodes.forEach((an) => {
this.annotation = annotate(an, {
type: this.type,
animate: this.animate,
animationDuration: this.animationDuration,
color: this.color,
strokeWidth: this.strokeWidth,
padding: this.padding,
brackets: this.brackets,
multiline: this.multiline,
iterations: this.iterations,
});
});
// Clone the style element from the windows styles to shadow dom.
this.append(window.__rno_kf_s.cloneNode(true));
window.requestAnimationFrame(() => {
if (this.showOnLoad) {
this.annotation.show();
}
});
}
}
if (!customElements.get('rough-notation')) {
customElements.define('rough-notation', VanillaRoughNotation);
}
现在,在我们发布这款产品之前,进行最后的修饰。
暴露API
你可能已经注意到了,Rough Notation库公开了一组函数供我们调用。我们想让我们的用户也能使用这些函数。幸运的是,我们可以像原来的库一样,用同样的方式来公开这些API。
我们看到,该库有4个主要函数。
- show()
- hide()
- remove()
- isShowing()
为了暴露这些API,我们可以在我们的组件中创建封装函数。
isShowing() {
return this.annotation != null && this.annotation.isShowing();
}
show() {
if (this.annotation) {
this.annotation.show();
}
}
hide() {
if (this.annotation) {
this.annotation.hide();
}
}
remove() {
if (this.annotation) {
this.annotation.remove();
}
}
现在我们的用户只要从DOM中选择我们的Web组件并调用它,就可以调用这些函数。
document.querySelector('roough-notation').show();
收尾工作
现在我们应该有一个功能性的Web组件,我们可以在我们的项目中使用,无论框架如何。
纯香草Web组件最好的地方在于它们是不可知的框架,不需要依赖React或LitElement来导入到项目中,这使得它们真的只是即插即用。
当然,在这种情况下,我们仍然依赖于粗略的符号库,但有很多Web Components的构建是不需要任何依赖的。
移植现有的库是一种很好的方式,可以让我们进入编写Web Components的感觉。它们同时也让世界变得更简单,因为既然组件已经做到了,那么就不需要再去调用那些库的API了。
相关链接
通过www.DeepL.com/Translator (免费版)翻译