在 Svelte 中使用自定义元素

Avatar of Geoff Rich
Geoff Rich

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 $200 免费积分!

Svelte 完全支持自定义元素(例如 <my-component>无需任何自定义配置或包装组件,并且在 Custom Elements Everywhere 上获得满分。但是,您仍然需要注意一些怪癖,尤其是在 Svelte 如何在自定义元素上设置数据方面。在阿拉斯加航空公司,我们在将设计系统中的自定义元素集成到 Svelte 应用程序时亲身经历了许多此类问题。

虽然 Svelte 支持 编译为自定义元素,但这不在本文的讨论范围之内。相反,我将重点介绍在 Svelte 应用程序中使用使用 Lit 自定义元素库构建的自定义元素。这些概念应该可以转移到使用或不使用支持库构建的自定义元素。

属性还是特性?

要完全理解如何在 Svelte 中使用自定义元素,您需要了解 Svelte 如何将数据传递给自定义元素。

Svelte 使用一个简单的启发式方法来确定是将数据作为属性还是特性传递给自定义元素。如果在运行时自定义元素上存在相应的特性,Svelte 将数据作为特性传递。否则,它将数据作为属性传递。这看起来很简单,但它有有趣的含义。

例如,假设您有一个 coffee-mug 自定义元素,它接受一个 size 特性。您可以在 Svelte 组件中像这样使用它

<coffee-mug class="mug" size="large"></coffee-mug>

您可以打开此 Svelte REPL 进行操作。您应该看到自定义元素呈现文本“This coffee mug’s size is: large ☕️”。

在编写组件内的 HTML 时,您似乎同时将 classsize 设置为属性。但是,情况并非如此。右键单击 REPL 输出中的“This coffee mug’s size is”文本,然后单击“Inspect”。这将打开 DevTools 检查器。当您检查渲染的 HTML 时,您会注意到只有 class 被设置为属性 - 就像 size 消失了!但是,size 正在以某种方式设置,因为“large”仍然出现在元素的渲染文本中。

这是因为 size 是元素上的一个特性,但 class 不是。由于 Svelte 检测到 size 特性,因此它选择设置该特性而不是属性。没有 class 特性,因此 Svelte 将其设置为属性。这并不是问题,也不会改变我们对组件行为的期望,但如果您对此一无所知,可能会非常令人困惑,因为您认为自己编写的 HTML 与 Svelte 实际输出的 HTML 之间存在脱节。

Svelte 在这种行为上并不独特 - Preact 使用类似的方法来确定是在自定义元素上设置属性还是特性。因此,我讨论的用例也会在 Preact 中出现,尽管解决方法会不同。您不会在 Angular 或 Vue 中遇到这些问题,因为它们有特殊的语法可以让您选择设置属性或特性。

Svelte 的启发式方法使传递复杂数据(如需要设置为特性的数组和对象)变得容易。您的自定义元素的使用者无需考虑他们是否需要设置属性或特性 - 它只是神奇地工作。但是,就像 Web 开发中的任何魔法一样,您最终会遇到一些需要深入挖掘并了解幕后情况的案例。

让我们来看看自定义元素表现奇怪的一些用例。您可以在 此 Svelte REPL 中找到最终示例。

用作样式钩子的属性

假设您有一个 custom-text 元素,它显示一些文本。如果存在 flag 属性,它会在文本前添加一个国旗表情符号和“Flagged:”字样。元素代码如下

import { html, css, LitElement } from 'lit';
export class CustomText extends LitElement {
  static get styles() {
    return css`
      :host([flag]) p::before {
        content: '🚩';
      }
    `;
  }
  static get properties() {
    return {
      flag: {
        type: Boolean
      }
    };
  }
  constructor() {
    super();
    this.flag = false;
  }
  render() {
    return html`<p>
      ${this.flag ? html`<strong>Flagged:</strong>` : ''}
      <slot></slot>
    </p>`;
  }
}
customElements.define('custom-text', CustomText);

您可以在 此 CodePen 中看到元素在起作用。

但是,如果您尝试在 Svelte 中以相同的方式使用自定义元素,它将无法完全正常工作。将显示“Flagged:”文本,但不会显示表情符号。为什么?

<script>
  import './custom-elements/custom-text';
</script>

<!-- This shows the "Flagged:" text, but not 🚩 -->
<custom-text flag>Just some custom text.</custom-text>

这里的关键是 :host([flag]) 选择器。:host 选择元素的影子根(即 <custom-text> 元素),因此此选择器仅在元素上存在 flag 属性时才适用。由于 Svelte 选择设置特性,因此此选择器不适用。基于特性添加了“Flagged:”文本,这就是为什么它仍然显示的原因。

那么我们有什么选择呢?自定义元素不应该假设 flag 总是被设置为属性。这是一个 自定义元素最佳实践,即保持原始数据属性和特性同步,因为您不知道元素的使用者将如何与之交互。理想的解决方案是元素作者确保任何原始特性都反映到属性,尤其是在这些属性用于样式时。Lit 使反映您的特性变得容易

static get properties() {
  return {
    flag: {
      type: Boolean,
      reflect: true
    }
  };
}

有了这个更改,flag 特性将反映回属性,并且一切按预期显示。

但是,您可能无法控制自定义元素定义。在这种情况下,您可以使用 Svelte 操作来强制 Svelte 设置属性。

使用 Svelte 操作来强制设置属性

操作 是 Svelte 的一项强大功能,它会在特定节点添加到 DOM 时运行一个函数。例如,我们可以编写一个操作,它将在我们的 custom-text 元素上设置 flag 属性

<script>
  import './custom-elements/custom-text';
  function setAttributes(node) {
    node.setAttribute('flag', '');
  }
</script>

<custom-text use:setAttributes>
  Just some custom text.
</custom-text>

操作也可以接受参数。例如,我们可以使此操作更通用,并接受一个包含我们要在节点上设置的属性的对象。

<script>
  import './custom-elements/custom-text';
  function setAttributes(node, attributes) {
    Object.entries(attributes).forEach(([k, v]) => {
      if (v !== undefined) {
        node.setAttribute(k, v);
      } else {
        node.removeAttribute(k);
      }
    });
  }
</script>

<custom-text use:setAttributes={{ flag: true }}>
  Just some custom text.
</custom-text>

最后,如果我们希望属性对状态更改做出反应,我们可以从操作中返回一个包含 update 方法的对象。每当我们传递给操作的参数发生变化时,update 函数就会被调用。

<script>
  import './custom-elements/custom-text';
  function setAttributes(node, attributes) {
    const applyAttributes = () => {
      Object.entries(attributes).forEach(([k, v]) => {
        if (v !== undefined) {
          node.setAttribute(k, v);
        } else {
          node.removeAttribute(k);
        }
      });
    };
    applyAttributes();
    return {
      update(updatedAttributes) {
        attributes = updatedAttributes;
        applyAttributes();
      }
    };
  }
  let flagged = true;
</script>
<label><input type="checkbox" bind:checked={flagged} /> Flagged</label>
<custom-text use:setAttributes={{ flag: flagged ? '' : undefined }}>
  Just some custom text.
</custom-text>

使用这种方法,我们不必更新自定义元素以反映特性 - 我们可以在 Svelte 应用程序内部控制设置属性。

延迟加载自定义元素

自定义元素并不总是组件首次渲染时定义的。例如,您可能需要等到 Web 组件填充 加载完毕后才导入自定义元素。此外,在服务器端渲染环境(例如 SapperSvelteKit)中,初始服务器渲染将在加载自定义元素定义之前执行。

无论哪种情况,如果自定义元素未定义,Svelte 将所有内容都设置为属性。这是因为元素上还没有特性。如果您习惯于 Svelte 仅在自定义元素上设置特性,这会让人感到困惑。这可能会导致复杂数据(如对象和数组)出现问题。

例如,让我们来看看以下显示问候语和姓名列表的自定义元素。

import { html, css, LitElement } from 'lit';
export class FancyGreeting extends LitElement {
  static get styles() {
    return css`
      p {
        border: 5px dashed mediumaquamarine;
        padding: 4px;
      }
    `;
  }
  static get properties() {
    return {
      names: { type: Array },
      greeting: { type: String }
    };
  }
  constructor() {
    super();
    this.names = [];
  }
  render() {
    return html`<p>
      ${this.greeting},
      ${this.names && this.names.length > 0 ? this.names.join(', ') : 'no one'}!
    </p>`;
  }
}
customElements.define('fancy-greeting', FancyGreeting);

您可以在 此 CodePen 中看到元素在起作用。

如果我们在 Svelte 应用程序中静态导入元素,一切都会按预期工作。

<script>
  import './custom-elements/fancy-greeting';
</script>
<!-- This displays "Howdy, Amy, Bill, Clara!" -->
<fancy-greeting greeting="Howdy" names={['Amy', 'Bill', 'Clara']} />

但是,如果我们 动态导入 组件,则自定义元素只有在组件首次渲染后才定义。在此示例中,我等到 Svelte 组件已使用 onMount 生命周期函数 安装后才导入元素。当我们延迟导入自定义元素时,姓名列表不会被正确设置,而是会显示回退内容。

<script>
  import { onMount } from 'svelte';
  onMount(async () => {
    await import('./custom-elements/fancy-greeting');
  });
</script>
<!-- This displays "Howdy, no one!"-->
<fancy-greeting greeting="Howdy" names={['Amy', 'Bill', 'Clara']} />

由于 Svelte 将 fancy-greeting 添加到 DOM 时没有加载自定义元素定义,因此 fancy-greeting 没有 names 特性,并且 Svelte 设置 names 属性 - 但它是字符串而不是字符串化的数组。如果您在浏览器 DevTools 中检查元素,您会看到以下内容

<fancy-greeting greeting="Howdy" names="Amy,Bill,Clara"></fancy-greeting> 

我们的自定义元素尝试使用 JSON.parse 将 names 属性解析为数组,这会抛出异常。这将使用 Lit 的默认数组转换器 自动处理,但这同样适用于任何期望属性包含有效 JSON 数组的元素。

有趣的是,一旦您更新传递给自定义元素的数据,Svelte 将开始再次设置特性。在下面的示例中,我将姓名数组移动到状态变量 names 中,以便我可以更新它。我还添加了一个“Add name”按钮,该按钮将在单击时将姓名“Rory”追加到 names 数组的末尾。

单击按钮后,names 数组将被更新,这会触发组件的重新渲染。由于自定义元素现在已定义,因此 Svelte 检测到自定义元素上的 names 特性,并设置该特性而不是属性。这会导致自定义元素正确显示姓名列表,而不是回退内容。

<script>
  import { onMount } from 'svelte';
  onMount(async () => {
    await import('./custom-elements/fancy-greeting');
  });
  let names = ['Amy', 'Bill', 'Clara'];
  function addName() {
    names = [...names, 'Rory'];
  }
</script>

<!-- Once the button is clicked, the element displays "Howdy, Amy, Bill, Clara, Rory!" -->
<fancy-greeting greeting="Howdy" {names} />
<button on:click={addName}>Add name</button>

与之前的示例类似,我们可以使用动作强制 Svelte 以我们想要的方式设置数据。这次,我们不想将所有内容都设置为属性,而是要将所有内容都设置为属性。我们将传递一个对象作为参数,该对象包含我们要在节点上设置的属性。以下是将我们的动作应用于自定义元素的方式

<fancy-greeting
  greeting="Howdy"
  use:setProperties={{ names: ['Amy', 'Bill', 'Clara'] }}
/>

以下是该动作的实现。我们遍历属性对象,并使用每个条目在自定义元素节点上设置属性。我们还返回一个更新函数,以便在传递给动作的参数发生变化时重新应用这些属性。如果您想了解如何使用动作来响应状态变化的知识,请查看上一节

function setProperties(node, properties) {
  const applyProperties = () => {
    Object.entries(properties).forEach(([k, v]) => {
      node[k] = v;
    });
  };
  applyProperties();
  return {
    update(updatedProperties) {
      properties = updatedProperties;
      applyProperties();
    }
  };
}

通过使用该动作,名称在第一次渲染时会正确显示。Svelte 在第一次渲染组件时会设置属性,自定义元素在元素定义后会拾取该属性。

布尔属性

我们遇到的最后一个问题是 Svelte 如何处理自定义元素上的布尔属性。这种行为在 Svelte 3.38.0 中最近发生了变化,但我们将探讨 3.38 之前和之后的行为,因为并非所有人都使用的是最新版本的 Svelte。

假设我们有一个<secret-box>自定义元素,它有一个布尔属性open,表示该框是否打开。实现如下

import { html, LitElement } from 'lit';
export class SecretBox extends LitElement {
  static get properties() {
    return {
      open: {
        type: Boolean
      }
    };
  }
  render() {
    return html`<div>The box is ${this.open ? 'open 🔓' : 'closed 🔒'}</div>`;
  }
}
customElements.define('secret-box', SecretBox);

您可以在这个CodePen中看到该元素的实际操作。

如 CodePen 中所示,您可以通过多种方式将 open 属性设置为true。根据HTML 规范,布尔属性的存在表示true值,而其不存在表示false

<secret-box open></secret-box>
<secret-box open=""></secret-box>
<secret-box open="open"></secret-box>

有趣的是,以上选项中只有最后一个在 Svelte 组件中使用时显示“The box is open”。前两个显示“The box is closed”,尽管设置了open属性。这是怎么回事呢?

与其他示例一样,这一切都归结于 Svelte 选择属性而不是属性。如果您在浏览器 DevTools 中检查这些元素,就不会设置任何属性 - Svelte 已将所有内容设置为属性。我们可以console.log渲染方法中的open属性(或在控制台中查询元素)以发现 Svelte 将open属性设置为了什么。

// <secret-box open> logs ''
// <secret-box open=""> logs ''
// <secret-box open="open"> logs 'open'
render() {
  console.log(this.open);
  return html`<div>The box is ${this.open ? 'open 🔓' : 'closed 🔒'}</div>`;
}

在前两种情况下,open等于一个空字符串。由于空字符串在 JavaScript 中为假值,因此我们的三元运算符会评估为假值情况,并显示该框已关闭。在最后一种情况下,open属性被设置为字符串“open”,该字符串为真值。三元运算符评估为真值情况,并显示该框已打开。

作为旁注,在您延迟加载元素时不会遇到此问题。由于自定义元素定义在 Svelte 渲染元素时没有加载,因此 Svelte 会设置属性而不是属性。请查看上一节以了解详细信息。

有一个简单的方法可以解决此问题。如果您记得您正在设置属性而不是属性,则可以使用以下语法将open属性显式设置为true

<secret-box open={true}></secret-box>

这样您就知道您正在将open属性设置为true。设置为非空字符串也可以,但这种方式是最准确的,因为您正在设置true而不是碰巧为真值的任何东西。

直到最近,这都是正确设置自定义元素上的布尔属性的唯一方法。但是,在 Svelte 3.38 中,我有一个发布的更改,更新了 Svelte 的启发式方法,以允许设置简写布尔属性。现在,如果 Svelte 知道底层属性是布尔类型,它会将openopen=""语法与open={true}视为相同。

这尤其有用,因为这是您在许多自定义元素组件库中看到示例的方式。这种更改使您可以轻松地从文档中复制粘贴,而不必排查为什么某个属性没有按预期工作。

但是,自定义元素作者方面有一个要求 - 布尔属性需要一个默认值,以便 Svelte 知道它是布尔类型。如果您希望该属性为布尔类型,这始终是一种良好的做法。

在我们的secret-box元素中,我们可以添加一个构造函数并设置默认值

constructor() {
  super();
  this.open = true;
}

有了这个更改,以下操作将在 Svelte 组件中正确显示“The box is open”。

<secret-box open></secret-box>
<secret-box open=""></secret-box>

总结

一旦您了解了 Svelte 如何决定设置属性或属性,许多看似奇怪的问题就会变得更有意义。在 Svelte 应用程序中将数据传递给自定义元素时遇到任何问题,请找出它是作为属性还是属性设置的,然后从那里开始。我在本文中提供了一些方法,可以让您在需要时强制使用其中一种方法,但它们通常应该是不必要的。大多数情况下,Svelte 中的自定义元素可以正常工作。您只需要知道如果出现问题,该去哪里查找即可。


特别感谢 Dale Sande、Gus Naughton 和 Nanette Ranes 对本文的早期版本进行了审阅。