Svelte完全支持自定义元素(如<my-component> ),无需任何自定义配置或包装组件,并且在自定义元素方面获得了满分。然而,你仍然需要注意一些怪癖,特别是围绕着Svelte如何在自定义元素上设置数据。在阿拉斯加航空公司,当我们将设计系统中的自定义元素集成到Svelte应用程序中时,我们亲身经历了许多这样的问题。
虽然Svelte支持对自定义元素进行编译,但这并不在这篇文章的范围之内。相反,我将专注于在Svelte应用程序中使用用Lit自定义元素库构建的自定义元素。这些概念应该适用于有或没有支持库的自定义元素。
是属性还是属性?
要完全理解如何在Svelte中使用自定义元素,你需要了解Svelte如何将数据传递给自定义元素。
Svelte使用一个简单的启发式方法来决定是将数据作为一个属性还是属性传递给一个自定义元素。如果在运行时自定义元素上存在相应的属性,Svelte将把数据作为属性传递。否则,它将作为一个属性来传递。这看起来很简单,但却有着有趣的影响。
例如,假设你有一个coffee-mug 自定义元素,它需要一个size 属性。你可以像这样在Svelte组件中使用它。
<coffee-mug class="mug" size="large"></coffee-mug>
你可以打开这个Svelte REPL来进行操作。你应该看到这个自定义元素渲染出 "这个咖啡杯的尺寸是:大☕️"的文字。
在组件内部编写HTML时,你似乎将class 和size 都设置为属性。然而,事实并非如此。右键单击 REPL 输出中的 "这个咖啡杯的尺寸是 "文本,然后单击 "检查"。这将打开DevTools检查器。当你检查渲染的HTML时,你会注意到只有class 被设置为一个属性--就好像size 根本就消失了一样!然而,size 却以某种方式被设置了,因为 "large "仍然出现在该元素的渲染文本中。
这是因为size 是该元素的一个属性,但class 不是。因为Svelte检测到了一个size 属性,所以它选择设置该属性而不是属性。没有class 属性,所以Svelte将其设置为一个属性。这不是一个问题,也不会改变我们期望组件的行为方式,但如果你没有意识到这一点,就会感到非常困惑,因为你认为你所写的HTML和Svelte实际输出的内容之间存在着脱节。
Svelte的这种行为并不独特--Preact也使用类似的方法来决定是否在自定义元素上设置一个属性或者一个属性。正因为如此,我讨论的这些用例也会出现在Preact中,尽管解决方法会有所不同。你不会在Angular或Vue中遇到这些问题,因为它们有一个特殊的语法,可以让你选择设置一个属性或属性。
Svelte的启发式方法让你很容易传递复杂的数据,比如需要设置为属性的数组和对象。你的自定义元素的消费者不应该需要考虑他们是否需要设置一个属性或属性--它只是神奇地工作。然而,就像网络开发中的任何魔法一样,你最终会遇到一些情况,需要你深入挖掘,了解幕后发生的事情。
让我们来看看一些自定义元素表现奇怪的用例。你可以在这个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](https://developer.mozilla.org/en-US/docs/Web/CSS/: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组件polyfills加载完毕后再导入你的自定义元素。另外,在服务器端渲染环境中,如Sapper或SvelteKit,初始服务器渲染将在不加载自定义元素定义的情况下进行。
在这两种情况下,如果自定义元素没有被定义,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 ,将名字属性解析为数组,这就会引发一个异常。这是用Lit的默认数组转换器自动处理的,但同样的情况也适用于任何期望属性包含有效JSON数组的元素。
有趣的是,一旦你更新了传递给自定义元素的数据,Svelte会重新开始设置该属性。在下面的例子中,我把名字的数组移到了状态变量names ,这样我就可以更新它。我还添加了一个 "添加名字 "的按钮,点击后将把名字 "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组件内使用时,只有上述最后一个选项显示 "盒子是打开的"。尽管设置了open 属性,前两个还是显示 "盒子是关闭的"。这到底是怎么回事呢?
和其他的例子一样,这一切都回到了Svelte选择属性而不是属性。如果你在浏览器DevTools中检查这些元素,没有设置任何属性--Svelte将所有东西都设置为属性。open 我们可以在我们的渲染方法中console.log (或者在控制台中查询元素)来发现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知道底层属性是一个布尔值,它将把open 和open="" 语法与open={true} 相同。
这特别有帮助,因为你在许多自定义元素组件库中看到的例子就是这样的。这一变化使得从文档中复制粘贴出来变得很容易,而不需要排查为什么某个属性不能按照你的期望工作。
然而,在自定义元素作者方面有一个要求--布尔属性需要一个默认值,以便Svelte知道它是布尔类型的。如果你想让该属性成为一个布尔值,这是一个很好的做法。
在我们的secret-box 元素中,我们可以添加一个构造函数并设置默认值。
constructor() {
super();
this.open = true;
}
有了这个改变,下面的内容将在Svelte组件中正确地显示 "盒子打开了"。
<secret-box open></secret-box>
<secret-box open=""></secret-box>
收尾工作
一旦你理解了Svelte是如何决定设置一个属性的,很多这些看似奇怪的问题就开始变得有意义了。当你在Svelte应用程序中遇到向自定义元素传递数据的问题时,要弄清楚它是被设置为一个属性还是被设置为一个属性,然后再去解决。在这篇文章中,我给了你一些逃生口,以便在需要时强制使用其中一种,但一般来说,它们应该是不必要的。大多数情况下,Svelte中的自定义元素都能正常工作。你只需要知道在出错的时候该去哪里找。