[Web翻译]移植库到Web Components

445 阅读10分钟

原文地址:matsu.fi/posts/porti…

原文作者: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 (免费版)翻译